Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启 动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号****指示器。在 Java 虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码****指令,它是程序控制 流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
Java 虚拟机的多线程****是通过线程轮流切换、分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的****程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空 (Undefined)。此内存区域是唯一一个在《Java 虚拟机规范》中没有规定任何 OutOfMemoryError (内存耗尽)情况的区域。
Java方法
- 定义:Java方法是用Java语言编写的方法。这些方法的字节码由Java虚拟机(JVM)执行。
- 运行环境:在Java虚拟机内部执行,使用JVM字节码解释器或JIT编译器进行运行。
- 内存****分配:使用JVM管理的内存(如堆(Heap)、方法区(Method Area)等)。
- 错误处理:如果内存不足,可能会抛出
OutOfMemoryError
异常,例如,当JVM堆或方法区耗尽内存时。 - 应用场景:适用于绝大多数需要在Java环境下运行的逻辑。
本地方法
- 定义:本地方法是用非Java语言编写的方法(如C或C++),并通过Java本地接口(JNI)从Java代码中调用。
- 运行环境:在Java虚拟机外部执行,直接运行在宿主机的硬件上。
- 内存****分配:不仅使用由JVM管理的内存,还可能使用由本地程序运行环境管理的内存。
- 错误处理:虽然在本地方法执行过程中不会直接由JVM抛出
OutOfMemoryError
,但如果本地代码分配内存时资源不足,仍可能导致错误或程序崩溃。 - 应用场景:通常用于需要直接访问系统资源或执行系统级操作、优化性能的场景,或是为了使用已有的非Java库。
Java 虚拟机栈(Java Virtual Machine Stack)也是线程私有的, 它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的线程内存模型:每个方法 被执行的时候,Java 虚拟机都会同步创建一个****栈帧用于存储局部变量 表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程, 就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
经常有人把 Java 内存区域笼统地划分为堆内存(Heap)和栈内存(Stack),这样是很粗糙的,实际的内存划分比这个复杂,这里的栈通常就是指这里讲的虚拟机栈,或者更多的情况下只是指虚拟机栈中局部变量表部分。
局部变量****表:
局部变量表存放了编译期可知的各种 Java 虚拟机基本数据类型,对象引用(reference 类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或者其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。
这些数据类型在**局部变量****表中的存储空间以局部变量槽(Slot)**来表示,其中 64 位 长度的 long 和 double 类型的数据会占用两个变量槽,其余的数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大 小。请读者注意,这里说的“大小”是指变量槽的数量,虚拟机真正使用多大的内存空间 (譬如按照 1 个变量槽占用 32 个比特、64 个比特,或者更多)来实现一个变量槽,这 是完全由具体的虚拟机实现自行决定的事情
-
本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。 《Java 虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,因此具体的虚拟机可以根据需要自由实现它,甚至有的 Java 虚拟机(譬 如 Hot-Spot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地 方法栈也会在栈深度溢出或者栈扩展失败时分别抛出 StackOverflowError 和 OutOfMemoryError 异常。
-
Java 堆
对于 Java 应用程序来说,Java 堆是虚拟机所管理的内存中最大的一 块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。
Java 堆是垃圾收集器管理的内存区域
如果从分配内存的角度看,所有线程共享的 Java 堆中可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率。无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区 域,存储的都只能是对象的实例,将 Java 堆细分的目的只是为了更好地回收内存,或 者更快地分配内存
Java 堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的
Java 堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的 Java 虚拟 机都是按照可扩展来实现的
方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。 虽然《Java 虚拟机规范》中把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 作“非堆”(Non-Heap),目的是与 Java 堆区分开来。
《Java 虚拟机规范》对方法区的约束是非常宽松的,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现****垃圾收集。相对而言,垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收效果比较难令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收有时又确实是必要的。以前 Sun 公司的 Bug 列表中,曾出现过的若干个严重的 Bug 就是由于低版本的 HotSpot 虚拟机对此区域未完全回收而导致内存泄漏。
运行时常量池
运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中
运行时常量池
- 概念:运行时常量池是方法区的一部分,用于**存放编译期生成的各种字面量(如文字字符串、final常量等)**和符号引用(如类和接口的全限定名、字段的名称和描述符、方法的名称和描述符等)。
- 动态性:它具有动态性,意味着不仅包含Class文件中编译期间生成的常量,也可以在运行期间接受新的常量(比如String的intern()方法可以将字符串添加到运行时常量池中)。
- 内存****限制:因为运行时常量池是方法区的一部分,它的大小和方法区的内存大小直接相关。如果方法区内存不足,运行时常量池也无法扩展,会抛出
OutOfMemoryError
异常。
Class文件常量池
- 概念:Class文件常量池是Class文件结构的一部分,它包含了类中所有的编译期常量信息,比如上文提及的字面量和符号引用。
- 作用:Class文件常量池为类定义中的所有名字和其他常量提供了索引和引用支持。
关系
Class文件常量池是静态的,它存储在磁盘上的Class文件里;而运行时常量池是动态的,它存储在JVM内存的方法区里。二者的主要区别在于存储位置和动态性,但它们都是用来存储类中的常量信息的。运行时常量池是Class文件常量池在JVM内存中的一种运行时表现形式。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java 虚 拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致 OutOfMemoryError 异常出现,所以我们放到这里一起讲解。
本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,则肯 定还是会受到本机总内存(包括物理内存、SWAP 分区或者分页文件)大小以及处理器 寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx 等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括 物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。
总结
- 程序计数器:字节码的行号指示器,明确下一步命令执行什么,每个线程独立
- java虚拟机栈:存放java方法,当一个方法开始执行创建栈帧,执行结束栈帧出栈,局部变量表中以局部变量槽为对象存储数据,线程独立
- 本地方法区:和上面类似,不过是处理本地方法
- java堆:存放对象实例,所有线程共享
- 方法区:存储已经加载的类型信息,常量,静态变量,代码缓存等信息,所有线程共享
这里具体的讨论一下HotSpot 虚拟机在java堆中的对象分配,布局,访问的全过程
在我们使用的时候创建一个对象一般(比如复制就不是)是new一个对象,那么在虚拟机中是如何实现的
在虚拟机中,遇到一个new命令,首先会检查这个指令参数是否可以在常量池中定位到一个类的引用,同时会检查她是否已经被加载,解析,初始化过,如果没有那么先执行类加载过程
在类加载检查通过后,接下来虚拟机会新的对象分配内存,这里内存的大小在类加载完即可确定,这里分配对象是等同于将一块确定大小的内存从java堆中划分出来,这里如何分配内存有俩种方法,一个是指针碰撞,一个是空闲列表
- 指针碰撞:如果在java堆中内存是规整的,使用过和没使用过的内存中间会有一个指针作为分界器的指示器,这里的分配内存就是将这个指针向空闲的方向移动确定距离
- 空闲联表:如果内存不是规整的,那么虚拟机需要维护一个列表记录那些内存块是可用的,分配的时候将足够大的空间分配给对象实例,在列表上更新内容
这里的**分配方式是由java对内存是否规整确定的,而java堆是否规整又是由垃圾回收器是否带有空间压缩整理功能决定的,**所以不同的垃圾回收器决定了它使用那种分配方式
确定了划分空间后,需要知道,对象创建在java虚拟机中是非常频繁的,如果只是改变指针指向位置分配内存,会有并发问题,即在给对象A分配内存,指针还未修改,对象B已经开始使用原来的指针分配内存,这里有俩种解决方法
一个是对分配空间的动作进行同步处理(实际上虚拟机是采用 CAS (一种配合硬件支持的保证原子性操作)配上失败重试的方式保证更新操作 的原子性)
一个是把内存的分配动作根据线程划分在不同的空间中进行,在java堆每一个线程预先分配一块内存,称为本地线程分配缓冲(TLAC),那个线程本地缓冲区分配完毕,分配新的缓冲区的时候才会同步锁定,虚拟机是否使用TLAC可以通过参数设置
内存分配完毕后,虚拟机****必须将分配到的内存初始化为零值,如果使用了本地线程分配缓冲这个工作也可以在分配时顺便执行,
到了这里,虚拟机还需要对对象进行必要的设置,将一些信息放置到对象的对象头,根据虚拟机的不同会有不同的对象头设置方式
到这里对象的创建基本结束,但是在java程序中,还有其他操作,比如构建函数的处理,即Class文件中的init()方 法还没有执行,所有的字段都为默认的零值,对象需要的其他资源和状态信息也还没有按照预定的意图构造好。一般来说(由字节码流中 new 指令****后面是否跟随 invokespecial 指令所决定,Java 编译器会在遇到 new 关键字的地方同时生成这两条字节码指令,但如果直接通过其他方式产生的则不一定如此),new 指令之后会接着执行 ()方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来
总结:首先分配地址,有俩种分配方法,然后是并发问题,再者是为空间赋零值,最后是将一些信息放入到对线头中进行存储,到这里对象基本创建完毕,但是还有他的构建方法,执行init方法
在Java中,一个对象在内存中由三部分组成:对象头,实例数据,以及对齐填充
- 对象头
对象头包含了对象的一些元数据,主要用于运行时的管理。它通常包括以下几个部分:
-
Mark Word(标记字段):用于存储对象的运行时状态信息,如哈希码(HashCode)、GC分代年龄、锁状态标志等,这部分数据的长度在 32 位和 64 位的虚拟机中分别为 32 个比特和 64 个比特。
-
Class Pointer(类型指针):指向对象的类定义,即指向元数据部分的指针,这样 JVM 可以通过这个指针获取对象的类信息。
-
Array Length(数组长度):如果对象是一个数组,对象头中还包含一个数组长度的字段,用于快速访问数组中的元素数量。
-
实例数据(Instance Data)
实例数据部分存储对象的实际数据,即类的属性内容(包括从父类继承的内容)。存储顺序受虚拟机分配参数和字段在java代码中的定义顺序
- 对齐填充(Padding)
对齐填充不是必然存在的,它用于确保对象的大小是机器地址位数的整数倍,这样有助于提高内存访问的效率。
实例数据的默认分配机制
HotSpot 虚拟机默认的分配顺序为 longs/doubles、ints、shorts/chars、 bytes/booleans、oops(Ordinary Object Pointers,OOPs),从以上默认的分配策略中可以 看到,相同宽度的字段总是被分配到一起存放,在满足这个前提条件的情况下,在父类 中定义的变量会出现在子类之前。如果 HotSpot 虚拟机的+XX:CompactFields 参数值为 true(默认就为 true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省 出一点点空间。
前面创建了对象,我们在后续使用对象的时候虚拟机需要找到这个对象,这里Java 程序会通过栈上的 reference 数据来操作堆上的具体对象,reference 类型在《Java 虚拟机规范》里面只规定了它是 一个指向对象的引用,并没有定义这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实现而定的,主流的访问方式主要有使用句柄和直接指针两种:
使用句柄
Java 堆中将可能会划分出一块内存来作为句柄池, reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息
如果使用直接指针访问的话,Java 堆中对象的内存布局就必须考虑如何放置访问 类型数据的相关信息,reference 中存储的直接就是对象地址,如果只是访问对象本身的 话,就不需要多一次间接访问的开销
这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而 reference 本身不需要被修改。
直接指针:直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,这里的HotSpot虚拟机使用了它,但是在其他地方第一中也有不少使用
-
OOM异常(内存耗尽)
-
java堆异常
java堆存储对象实例,只需要不停创建对象,然后保证GC Roots(垃圾收集器)到对象之间有可达路径避免垃圾回收机制清除对象,那么对象不停增加,总容量到达最大堆的容量限制之后和产生内存溢出
while (true) {
list.add(new OOMObject());
}
到这里,它最后会报错,OutOfMemoryError,要解决这个问题,处理方法是:通过内存****映像分析工具对 Dump 出来的堆转储快照进行分析。第一步首先应确认内存中导致 OOM 的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏 还是内存溢出。
如果不是内存泄漏,就是这些对象都是需要的,那么应该检查java虚拟机的堆参数设置,与机器的内存比较,看是否有可以继续上调空间,再在代码上检查是否有些对象设计不合理等,尽量减少程序内存消耗
HotSpot 虚拟机****中并不区分虚拟机栈和本地方法栈,因此对于 HotSpot 来说,- Xoss 参数(设置本地方法栈大小)虽然存在,但实际上是没有任何效果的,栈容量只能 由-Xss 参数来设定。
两种常见异常:
- 如果线程请求的栈深度大于虚拟机所允许的最大深度,将抛出 StackOverflowError 异常。
- 如果虚拟机的栈内存允许动态扩展,当扩展栈容量无法申请到足够的内存时, 将抛出 OutOfMemoryError 异常。
《Java 虚拟机规范》明确允许 Java 虚拟机实现自行选择是否支持栈的动态扩展,而 HotSpot 虚拟机的选择是不支持扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OutOfMemoryError 异常,否则在线程运行时是不会因为扩展而导致内存溢出的,只会因为栈容量无法容纳新的栈帧而导致 StackOverflowError 异常。
这里有两种方法使其爆出异常,一个是:使用-Xss 参数减少 栈内存容量。结果:抛出 StackOverflowError 异常,异常出现时输出的堆栈深度相应缩 小。
public class JavaVMStackSOF {
private int stackLength = 1;
public void stackLeak() {
stackLength++;
stackLeak();
}
public static void main(String[] args) throws Throwable {
JavaVMStackSOF oom = new JavaVMStackSOF();
try {
oom.stackLeak();
} catch (Throwable e) {
System.out.println("stack length:" + oom.stackLength);
throw e;
}
}
}
运行结果
另一个是:定义了大量的本地变量,增大此方法帧中本地变量表的长度。
这个代码是在方法定义很多变量,消耗他的本地变量表
无论是由于栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法 分配的时候, HotSpot 虚拟机抛出的都是 StackOverflowError 异常,这里的HotSpot是不支持动态扩展内存的,在一些支持动态扩展内存的虚拟机中保存的错误将会是内存耗尽,
如果通过不断建立线程的方式,在HotSpot中也会出现内存溢出,但是这样产生的内存溢出异常和栈空间是否足够并不存在任何直接的关系,主要取决于操作系统本身的内存使用状态。甚至可以说,在这种情况下,给每个线程的栈分配的内存越大,反而越容易产生内存溢出异常。操作系统分配给每个进程的内存是有限制的
由于运行时常量池是方法区的一部分,所以这两个区域的溢出测试可以放到一起进行
- 永久代:在java虚拟机中在非堆内存中,和堆内存一样受到物理内存大小的限制
- 元空间:元空间使用的是本地内存(Native Memory),而不是虚拟机内存
二者区别
- 内存管理:元空间由于使用本地内存,理论上可以拥有更大的空间,减少了内存溢出的风险。
- 性能:元空间的引入改善了类的加载和卸载机制,可能会提高程序的性能。
- 垃圾回收:元空间的垃圾回收更加高效,有助于优化内存使用。
String::intern()是一个本地方法,它的作用是如果**字符串常量池(JDK7之前他是方法区永久代的一部分,JDK8永久代被移除它被移动到了java堆中)**中已经包含一个等于此 String 对象的字符串,则返回代表池中这个字符串的 String 对象的引用;否则,会将 此 String 对象包含的字符串添加到常量池中,并且返回此 String 对象的引用
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
// 使用 Set 保持着常量池引用,避免 Full GC 回收常量池行为
Set<String> set = new HashSet<String>(); // 在 short 范围内足以让 6MB 的
short i = 0;
while (true) {
set.add(String.valueOf(i++).intern());
}
}
}
这里的字符串通过intern方法存储到了字符串常量池中,然后导致OOM异常
public class RuntimeConstantPoolOOM {
public static void main(String[] args) {
String str1 = new StringBuilder("计算机").append("软件").toString();
System.out.println(str1.intern() == str1);
String str2 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str2);
}
}
这里的代码在不同的Jdk的返回值不同,如果是jdk6和以前他会返回俩个false,如果是jdk7中,他的返回值是一个true一个false,这里解释为什么,
首先在jdk6之前,intern()方法会首先检查在字符串常量池中是否由这个实例,然后创建一个在字符串常量池中,这里的字符串常量池不在java堆中,在方法区的永久代中,所以它会返回一个创建在永久代中的实例引用,而StringBuilder 创建的字符串对象实例在 Java 堆上,所以二者不同会打印false,str1和str2都是这个原因
其次在jdk7之后,字符串常量池在java堆中,第一次检查的时候就会在java堆中找到,不会创建新的实例所以返回的结果是true,而java在之前已经创建过了,而StringBuilder会创建一个新的实例,intern()方法会返回第一个实例所以俩个不同返回false
我们再来看看方法区的其他部分的内容,方法区的主要职责是用于存放类型的相关 信息,如类名、访问修饰符、常量池、字段描述、方法描述等。对于这部分区域的测试,基本的思路是运行时产生大量的类去填满方法区,直到溢出为止。借助了 CGLib[3]直接操 作字节码运行时生成了大量的动态类。这种情况不光是实验在很多框架中也会出现这种情况如: Spring、Hibernate 对类 进行增强时,都会使用到 CGLib 这类字节码技术,当增强的类越多,就需要越大的方法 区以保证动态生成的新类型可以载入内存
方法区溢出也是一种常见的内存溢出异常,一个类如果要被垃圾收集器回收,要达 成的条件是比较苛刻的。在经常运行时生成大量动态类的应用场景里,就应该特别关注 这些类的回收状况。
在 JDK 8 以后,永久代便完全退出了历史舞台,元空间作为其替代者登场。在默认设置下,前面列举的那些正常的动态创建新类型的测试用例已经很难再迫使虚拟机产生 方法区的溢出异常了。不过为了让使用者有预防实际应用里出现破坏性的操作,HotSpot 还是提供了一些参数作为元空间的防御措施,主要包括:
- MaxMetaspaceSize:设置元空间最大值,默认是-1,即不限制,或者说只受限于本地内存大小。
- MetaspaceSize:指定元空间的初始空间大小,以字节为单位,达到该值就会触发垃圾收集进行类型卸载,同时收集器会对该值进行调整:如果释放了大量的空间, 就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize(如 果设置了的话)的情况下,适当提高该值。
- MinMetaspaceFreeRatio:作用是在垃圾收集之后控制最小的元空间剩余容量的百分比,可减少因为元空间不足导致的垃圾收集的频率。类似的还有-XX:MaxMetaspaceFreeRatio,用于控制最大的元空间剩余容量的百分比。
直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize 参数来 指定,如果不去指定,则默认与 Java 堆最大值一致,
由直接内存导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见有 什么明显的异常情况,如果读者发现内存溢出之后产生的 Dump 文件很小,而程序中又 直接或间接使用了 DirectMemory(典型的间接使用就是 NIO),那就可以考虑重点检查 一下直接内存方面的原因了。
总结:
java虚拟机一共五块内存区域,程序计数器,java虚拟机栈,本地方法栈,java堆,方法区,这里的程序计数器不需要关注OOM问题,其他四块区域:
- java堆:存储对象,如果对象过多他会通过内存映像分析工具来处理,首先他会分析内存对象是否是必须的,分析他是内存泄漏还是内存溢出,如果是内存溢出比较java虚拟机内存参数与本地内存,看是否可以上调内存大小
- java虚拟机栈和本地方法栈:他有俩个异常,一个是栈数过深,一个是内存溢出,这里虚拟机可以在设计的时候自己选择是否支持动态扩展,如果不支持那么只能在一开始创建的时候爆出内存溢出 。栈帧太大还是虚拟机栈容量太小,当新的栈帧内存无法 分配的时候, HotSpot 虚拟机抛出的都是 StackOverflowError 异常,这里的HotSpot是不支持动态扩展内存的,在一些支持动态扩展内存的虚拟机中保存的错误将会是内存耗尽,不断建立线程也会出现内存溢出的情况
- 方法区和运行时常量池溢出:主要是元空间和永久代的问题