JVM
JVM
Java程序可以在不同的操作系统和硬件架构上运行,而无需对代码进行修改,主要得益于Java语言和平台的设计理念以及Java虚拟机(JVM)的存在,不同的平台只需要安装对应平台的Java虚拟机即可运行(包含在JRE中),在任何平台编译出的字节码文件(.class)都是一样的,最后实际上是将编译后的字节码交给JVM处理执行。
当使用Java编写程序时,源代码会首先被编译成一种称为字节码的中间代码。,与其他编程语言(C/C++编译型语言)不同,Java 不直接生成本地机器代码,而是生成这种中间代码。
对于C/C++等编译型语言,源代码在编译时被直接转换为目标平台的本地机器代码。因此,同一份C/C++代码在不同的操作系统上需要重新编译,生成特定于该平台的可执行文件。这也是为什么同一个C/C++程序需要为Windows、macOS和Linux等平台提供不同的可执行文件的原因。
正是得益于这种规范,除了Java以外,还有多种JVM语言,如Kotlin、Groovy、Scala等语法虽然和Java不同,但是最终编译得到的字节码文件和Java的规范相同,同样可以交给JVM处理。
虚拟机
常用的虚拟机包括安装操作系统的虚拟机也有Java虚拟机,但是它们面向的对象不同。虽然JVM只是面向单一应用程序的虚拟机,但是和其他的操作系统级别的虚拟机一样,也可以分配实际的硬件资源比如最大内存大小等。
在计算机组成原理中提到,传统程序设计一般都是基于寄存器的指令集架构,以CPU为例:
其中AX、BX、CX、DX被称为数据寄存器:
- AX(Accumulator):累加器,16位寄存器,用于存储中间结果和运算的结果。
- BX(Base):基地址寄存器,16位寄存器,用于存储内存地址,通常用作指向数据段的指针。
- CX(Count):计数器,16位寄存器,用于循环计数。
- DX(Data):数据寄存器,16位寄存器,存储数据和执行一些特殊用途的操作。
SP和BP被称为指针寄存器:
- SP(Stack Pointer):栈指针,位数取决于处理器的位数,存储栈顶的地址即栈中最新数据的内存地址。在栈操作的过程中,SP的值会动态变化,指向当前栈的栈顶。
- BP(Base Pointer):基址指针,位数取决于处理器的位数,指向当前函数在栈中的起始位置,用于访问局部变量和函数参数。在函数调用时,BP的值通常会被保存到栈上,以便在函数返回时恢复调用者的栈帧。
在函数调用过程中,SP 和 BP 一起协作,动态地维护栈的结构。SP 用于管理整个栈的大小和栈顶的位置,而 BP 则用于在函数内访问局部变量和参数。
SI和DI被称为变址寄存器:
- SI(Source Index):源索引,位数取决于处理器的位数,通常被用作源数据的索引,指向源数据的起始位置。
- DI(Destination Index):目标索引位数取决于处理器的位数,通常被用作目标数据的索引,指向目标数据的起始位置。
- 常常与字符串操作的指令一起使用,比如用于在两个内存区域之间复制数据。SI 和 DI 的值会在复制或移动数据时逐渐递增或递减,以实现数据的传输。
控制寄存器:
- IP(nstruction Pointer):指令指针寄存器,16位,存储着CPU当前正在执行的指令的地址,在程序执行期间,IP 的值会不断地递增,指向下一条要执行的指令。
- FLAGS:标志寄存器,16位,用于记录CPU执行过程中的各种状态信息,包括零标志Zero Flag)、进位标志(Carry Flag)、溢出标志(Overflow Flag)、符号标志(Sign Flag)等。
IP 负责指导CPU执行指令的流程,而标志寄存器则记录了CPU执行过程中的各种条件和状态。
段寄存器:
- CS(Code Segment):代码段寄存器,存储代码段的起始地址
- DS(Data Segment):数据段寄存器,用于存储数据段的起始地址。数据段通常包含程序使用的全局变量和静态变量。
- SS(Stack Segment):堆栈段寄存器,用于存储堆栈段的起始地址。
- ES(Extra Segment):附加段寄存器,用于存储额外的数据段的起始地址。
在不同的CPU架构下,实际上得到的汇编代码也不一样,以一段C语言的代码为例:
1 | int main() { //实现一个最简的a+b功能,并存入变量c |
1 | gcc -S main.c |
在x86架构下:
1 | .file "main.c" |
在arm架构下:
1 | .section __TEXT,__text,regular,pure_instructions |
在不同的CPU架构下,得到的汇编代码也不一样,所以只能通过不同的汇编指令操作来实现,这也是为什么C语言不支持跨平台的原因,只能将同样的代码在不同的平台上编译之后才能在对应的平台上运行我们的程序。
而Java利用了JVM,Java程序编译之后,并不是可以由平台直接运行的程序,而是将字节码放到JVM运行。同时,JVM采用了基于栈的指令集架构,并没有依赖寄存器,而是利用操作栈来完成,实现了与硬件无关的特性,使 Java 程序具有跨平台的能力。
对一个类进行反编译:
1 | public class Main { |
1 | javap -v target/classes/com/test/Main.class #使用javap命令对class文件进行反编译 |
结果如下:
1 | ... |
Java文件编译之后,也会生成类似于C语言的汇编指令(Java字节码的文本表示形式),但这些命令都是交给JVM去执行的命令,最下方存放的是本地变量(局部变量)表,表示此方法中出现的本地变量,实际上this也在其中,所以我们才能在非静态方法中使用this关键字,在最上方标记了方法的返回值类型、访问权限等。
- 方法描述符
descriptor: ()I
:该方法没有输入参数,返回类型为整数
- 方法访问标志
flags: ACC_PUBLIC
:该方法是公共的
- 方法体
- 方法以
Code:
开始 stack=2
:操作数栈(Operand Stack)的最大深度是 2,操作数栈是用于存储临时数据和中间计算结果的栈结构。locals=4
:局部变量表(Local Variables)的大小为 4,局部变量表用于存储方法中定义的局部变量,包括方法参数和其他临时变量。args_size=1
:该方法参数为1,无输入参数,隐含this
作为方法的参数
- 方法以
- 字节码指令
- bipush 10:将常量10推到操作数栈的顶部
- istore_1:将栈顶的int类型数值存入到局部变量表中的第二个位置(索引为1)
- bipush 20:将常量20推到操作数栈的顶部
- istore_2:将栈顶的int类型变量存入到局部变量表中的第三个位置(索引为2)
- iload_1:将局部变量表的索引为1的值(10)加载到操作数栈顶部
- iload_2:将局部变量表的索引为2的值(20)加载到操作数栈顶部
- iadd:将栈顶的两个整数值相加,并将结果压入栈顶
- istore_3:将栈顶的int类型变量存入到局部变量表中的第四个位置(索引为3)
- iload_3:将局部变量表中索引为3的值(30)加载到操作数栈顶部
- ireturn:返回栈顶的整数值
实际上,JVM执行的命令基本都是出栈入栈等,所以和传统的汇编指令相比执行起来就会更复杂,想实现相同的功能需要的指令条数也会更多,所以Java实际上的执行效率是不如C/C++的,虽然通过JVM可以很方便地实现跨平台,但是性能会打折扣。
JVM的历史
在1996年Java1.0面世的时候,出现了第一款JVM即Sun Classic VM。早期的虚拟机都采取解释执行字节码的方式,提供Java解释器,也就是读取.class文件,然后转化成一条一条的命令,JVM再依次执行。虽然这样的方式非常简单,但是因为相同的代码可能被重复解释再执行很多次,导致效率很低。
随着技术进步,现在大多数的JVM会根据当前代码进行判断,如果某个方法或代码块的运行特别频繁,在运行时JVM会把这些代码编译成本地平台相关的机器码并进行优化,这种编译器被称为即时编译器(Just In Time Compiler,JIT)。
在Java1.4版本后,Sun Classic VM不再被使用,取而代之的是至今都在用的HotSpot VM,拥有上面提到的热点代码检测、准确式内存管理(虚拟机可以知道内存中某个位置的数据具体是什么类型)等技术。
JVM发展
2018年4月,Oracle Labs发布了最新的Graal VM。Graal VM是一个在HotSpot虚拟机基础上增强而成的跨语言全栈虚拟机,可以作为“任何语言”的运行平台使用,包括了Java、Scala、Groovy、Kotlin等基于JVM之上的语言,还包括了C、C++还有Rust等基于LLVM的语言,同时支持其他像JavaScript、Ruby、Python和R语言等等。Graal VM可以无额外开销地混合使用这些编程语言,支持不同语言中混用对方的接口和对象,也能够支持这些语言使用已经编写好的本地库文件。
Graal VM的基本工作原理是将这些语言的源代码(如JavaScript)或源代码编译后的中间格式(例如LLVM字节码)通过解释器转换成能被Graal VM接受的中间表示,比如设计一个解释器专门对LLVM输出的字节码进行转换来支持C和C++语言,这个过程称为“程序特化”。
优点:
- 立即启动,一般启动时间小于100ms
- 更低的内存消耗
- 独立部署,不再需要JVM
- 同样的峰值性能要比JVM消耗的内存小
缺点:
- 构建时间长
- 只支持新的Springboot版本
JVM内存管理
在传统的C/C++开发中,我们经常通过使用申请内存的方式来创建对象或是存放某些数据,但是这样也带来了一些额外的问题,我们要在何时释放这些内存,怎么才能使得内存的使用最高效,因此,内存管理是一个非常严肃的问题。
以C语言为例,可以动态申请内存并用于存放数据:
1 |
|
而Java只支持直接使用基本数据类型和对象类型,至于内存到底如何分配,并不是由我们来处理,而是JVM帮助我们进行控制,这样就帮助我们节省很多内存上的工作,但也会带来一定问题,一旦出现内存问题,就无法像在使用C/C++的时候处理内存。
内存区域划分
JVM对内存的管理采用的是分区治理,不同的内存区域各司其职,在虚拟机运行时,内存区域划分如下:
可以看到,内存区域一共分为五个区域,方法区和堆是所有线程共享的区域,随着虚拟机的创建而创建,虚拟机的结束而销毁,而虚拟机栈、本地方法栈、程序计数器都是线程之间相互隔离的,每个线程都有一个自己的区域,并且线程启动时会自动创建,结束之后会自动销毁。内存划分完成之后,JVM执行引擎和本地库接口,也就是Java程序开始运行之后就会根据分区合理地使用对应区域的内存了。
程序计数器
在CPU中,程序计数器负责储存内存地址,该地址指向下一条即将执行的指令,每解释执行完一条指令,PC寄存器的值就会自动被更新为下一条指令的地址,进入下一个指令周期时,就会根据当前地址所指向的指令,进行执行。
JVM中的程序计数器可以看作是当前线程执行的字节码的行号指示器,而行号正好指的就是某一条指令,字节码解释器在工作时也会改变这个值,来指定下一条即将执行的指令。
在Java多线程中,从一个线程切换到另一个线程的时候,当前线程的执行位置的执行位置会被保存到当前线程的程序计数器中,当再次回到次线程时就会从之前的位置继续向下执行。
因为程序计数器只会记录很少的信息,所以只占用很小一部分内存。
虚拟机栈
虚拟机栈是一个非常关键的部分,每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧(栈里面的的元素),栈帧中包括了当前方法的一些信息,比如局部变量表、操作数栈、动态链接、方法出口等。
栈帧中的局部变量表就是我们方法中的局部变量,在class文件中就已经定义好了,操作数栈就是前面字节码执行时需要用到的栈结构,每个栈帧还保存了一个可以指向当前方法所在的类的运行时常量池。通过这种方式,我们在当前方法中如果想要调用其他方法的时候,能够从运行时常量池汇总找到对应的符号引用,然后将符号引用转换为直接引用,就可以直接调用对应方法,这就是动态链接。最后是方法出口,也就是方法如何结束,是抛出异常还是正常返回。
模拟一下整个虚拟机栈的运作流程,编写一个测试类:
1 | public class Main { |
当主方法执行后,会依次执行:a() -> b() -> c() -> 返回
,反编译后:
1 | { |
在编译之后,整个方法的最大操作数栈深度、局部变量表大小、方法参数的数量都是确定的了,当程序开始执行时,JVM会根据这些信息封装一个对应的栈帧,从main
方法开始:
接着继续运行,到了0: invokestatic #2 // Method a:()I
时,需要调用方法a()
,这时当前的方法即主方法就不会继续进行了,而是去执行方法a()
,与之前的相同,将方法a()
放到栈顶位置,就会把main
方法的栈帧压下去:
继续运行,从方法a()
有进入到方法b()
,最后进入方法c()
,这时虚拟机栈就变成了:
现在开始执行方法c()
中的指令,返回变量a
和变量b
的和。在方法c()
返回之后,就说明方法c()
已经结束了,栈帧4会自动出栈。这时栈帧3就得到了上一栈帧返回的结果,并继续执行,然后在方法b()
中就又返回了一次,然后栈帧3出栈并把返回值交给栈帧2,继续执行方法a()
,然后栈帧2出栈,再将结果返回给栈帧1,栈帧1就可以继续向下运行了,最后输出结果。
本地方法栈
本地方法栈与虚拟机栈类似,只是虚拟机栈主要执行Java方法,而本地方法栈专门用于执行用其他语言(如C/C++)编写的本地方法,并通过JNI(Java Native Interface)在Java程序中调用。
JNI是Java平台的一部分,允许Java代码与本地应用程序和库进行交互,允许Java应用程序调用和被本地代码调用,使得Java与其他语言(如C、C++)编写的代码集成在一起。
堆
堆是整个Java应用程序共享的区域,也是整个虚拟机最大的一块内存空间,而此区域的职责就是存放和管理对象和数组。
方法区
方法区也是整个Java应用程序共享的区域,它用于存储所有的类信息、常量、静态变量、动态编译缓存等数据,可以大致分为两个部分,一个是类信息表,一个是运行时常量池:
首先类信息表中存放的是当前应用程序加载的所有类信息,包括类的版本、字段、方法、接口等信息,同时会将编译时生成的常量池数据全部存放到运行时常量池中。后面在程序运行时也有可能会有新的常量进入到常量池。
String
类就使用了常量池进行优化:
1 | public static void main(String[] args) { |
因为str1
和str2
是单独创建的两个对象,那么这两个对象就会在堆中存放,保存在不同的地址:
所以在使用==
判断时,会得到false
,说明不是同一个对象,但是使用equals()
时比较的是值,就会得到true
。
换一种创建方法:
1 | public static void main(String[] args) { |
使用引号直接创建,两次比较的结果就都是true
。这是因为在使用引号赋值的时候,会先在常量池中查找是否存在相同的字符串,若存在则将引用直接指向这个字符串,如果不存在则在常量池中生成一个字符串,再将引用指向该字符串:
String
类中有一个intern()
方法,是依赖于这种机制的。
intern()
方法在早期的jdk版本和现在版本的功能不同:
在1.6及以前的版本中,intern()
方法会检查字符串常量池是否已经有相同内容的字符串,如果有则返回这个常量池中的字符串的引用,否则把这个字符串复制到方法区的常量池中,并返回常量池中这个字符串的引用。而在1.7及以后的版本中,就算常量池中不存在相同的字符串也不会再复制一遍,而是在常量池中记录堆中创建的这个字符串的引用。
1 | public static void main(String[] args) { |
jdk1.6的输出是两个false
,jdk1.8的输出是一个true
和一个false
。
为什么会出现这种结果呢,首先在Java的System
类中,有一个Version.init
方法:
在Version.init
中又定义了各种常量:
可以看到,包括openjdk、版本号等等都已经加载到常量池中。
在jdk1.6中,String str1 = new String("ab") + new String("c")
在堆中创建了一个abc
字符串,然后在String str2 = str1.intern()
中,intern()
方法在常量池中检查是否有与str1
相同的字符串即abc
,明显是没有的,所以String str2 = str1.intern()
就会复制一个abc
到字符串常量池中并返回这个复制后的字符串的引用。str1
是堆中abc
的引用,而str2
是方法区的字符串常量池中的abc
的引用,所以str1
和str2
肯定是不相等的。str3
指向的是创建在堆中的字符串openjdk
,在执行String str4 = str3.intern()
时,根据上面说过的,intern()
方法在检查时发现字符串常量池中默认存在这样一个openjdk
字符串,那么就会直接指向这个常量池中的字符串,所以str3
和str4
也不相等。
而在jdk1.8中,String str1 = new String("ab") + new String("c")
在堆中创建了一个abc
字符串,然后在String str2 = str1.intern()
中,intern()
方法在常量池中检查是否有与str1
相同的字符串即abc
,明显是没有的,此时intern()
方法不会复制这个字符串到常量池中,而是在常量池中记录这个字符串的引用,也就是指向这个堆中的abc
,那么str1
和str2
指向的引用都是堆中的abc
字符串,那么二者肯定是相等的。而至于str3
与str4
就和在jdk1.6中的情况相同,是不相等的。
值得注意的是,随着jdk1.7的更新,为了解决方法区可能存在的内存泄漏、难以调整大小等问题,字符串常量池不再放在方法区中,而是移动到了堆中。而其他的类常量池和运行时常量池仍然位于方法区中。
最后我们再来进行一个总结,各个内存区域的用途:
- (线程独有)程序计数器:保存当前程序的执行位置。
- (线程独有)虚拟机栈:通过栈帧来维持方法调用顺序,帮助控制程序有序运行。
- (线程独有)本地方法栈:同上,作用与本地方法。
- 堆:所有的对象和数组都在这里保存。
- 方法区:类信息、即时编译器的代码缓存、运行时常量池。
爆内存与爆栈
在Java程序运行时,内存容量不可能是无限制的。当对象创建过多或是数组容量过大时,就会导致我们的堆内存不足以存放更多新的对象或是数组,这时就会出现错误,比如申请一个21亿多的int数组:
1 | public static void main(String[] args) { |
这么大的数组不可能放进堆内存中,所以程序运行时就会报OutOfMemoryError
:
1 | Exception in thread "main" java.lang.OutOfMemoryError: Requested array size exceeds VM limit |
也就是内存溢出错误。
我们可以通过参数来控制堆内存的最大值和最小值,比如我们配置JVM时限制堆内存固定值为10M,并且在抛出内存溢出异常时保存当前的内存堆转储快照:
1 | -Xms10m -Xmx10m -XX:+HeapDumpOnOutOfMemoryError |
接着写一个一定会导致内存溢出的代码:
1 | public class Main { |
运行:
可以看到错误出现原因正是Java heap space
,也就是堆内存满了。
根据之前设置的参数,在抛出内存溢出异常时保存当前的内存堆转储快照,查看后可以发现,在创建了360146个Test对象之后,堆内存满了,就抛出了内存溢出的错误:
接下来看栈溢出,根据之前的,虚拟机栈会在方法调用时插入栈帧,设计一个无限递归的情况:
1 | public class Main { |
这很明显是一个永无休止的程序,并且会不断继续向下调用test方法本身,无限地插入栈帧那么一定会将虚拟机栈塞满,所以,当栈的深度已经不足以继续插入栈帧时,就会这样:
运行:
这就是栈溢出,和堆内存类似,也可以用参数控制栈容量的限制。
堆外内存
除了堆内存可以存放对象数据以外,我们也可以申请堆外内存(直接内存),也就是不受JVM管控的内存区域,这部分区域的内存需要我们自行去申请和释放,本质就是JVM通过C/C++
调用malloc
函数申请的内存。不过虽然是直接内存,不会受到堆内存容量限制,但是依然会受到本机最大内存的限制,所以还是有可能抛出OutOfMemoryError
异常,但是由于JVM提供的堆内存会进行垃圾回收等工作,效率不如直接申请和操作内存来得快,一些比较追求极致性能的框架会用到堆外内存来提升运行速度。
JVM垃圾回收机制
不同于C/C++需要手动管理内存,由于JVM提供了一套全自动的内存管理机制,当一个Java对象不再用到时,JVM会自动将其进行回收并释放内存。
如何判定一个对象是否可以回收、何时回收、如何回收,就依靠JVM的垃圾回收机制。
对象存活判定算法
对象什么情况下可以被判定为不再使用可以回收了?
引用计数法
如果要经常操作一个对象,首先要创建一个引用变量:
1 | //str就是一个引用类型的变量,它持有对后面字符串对象的引用,可以代表后面这个字符串对象本身 |
考虑到一个对象只要还存在着对它的引用,它就还有实用价值,所以一种比较简单的对象存活判定算法——引用计数法可以用于判断一个对象是否还需要被使用:
- 每个对象都包含一个 引用计数器,用于存放引用计数(被引用的次数)
- 每当有一个地方引用此对象时,引用计数+1
- 当引用失效(离开了局部变量的作用域或是引用被设定为null)时,引用计数-1
- 当引用计数为0时,表示此对象不可能再被使用,这时我们就无法得到这个对象的引用了
但是这个简单的引用计数法存在着一个问题,如果两个对象相互引用:
1 | public class Main { |
创建一个静态内部类Test
,有一个成员变量another
。我们在主函数里创建两个Test
类的对象a
、b
,并分别建立从a
到b
和从b
到a
的引用,之后再把a
和b
直接赋值为null。虽然a
、b
都被设置为null,但是他们仍然相互引用。那么引用计数器的值将会永远是1,即使这两个对象已经没有任何用途了,也不会被垃圾收集器回收,直到程序终止。所以引用计数法不是最好的解决方案。
可达性分析算法
目前比较主流的编程语言一般都会使用可达性分析算法来判断对象是否存活,它采用了类似于树结构的搜索机制。
首先每个对象的引用都有机会成为树的根节点(GC Root),可以被选为根节点的条件如下:
- 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象(其实就是我们方法中的局部变量)、同样也包括本地方法栈中JNI引用的对象。
- 类的静态成员变量引用的对象。
- 方法区中,常量池里面引用的对象,比如我们之前提到的String类型对象。
- 被添加了锁的对象(比如synchronized关键字)
- 虚拟机内部需要用到的对象。
简而言之:
- 位于虚拟机栈的栈帧中的本地变量表中所引用到的对象:这些对象是方法中的局部变量,包括方法参数和在方法中声明的局部变量。如果这些局部变量引用了对象,那么这些对象也可以被选定为 GC Roots。
- 本地方法栈中 JNI 引用的对象:当 Java 代码调用本地方法(通过 JNI)时,在本地方法栈中会存储本地方法的参数和局部变量。如果本地方法中引用了 Java 对象,那么这些对象也可以被选定为 GC Roots。
- 类的静态成员变量引用的对象:如果一个类的静态成员变量引用了某个对象,那么这个对象也可以被选定为 GC Roots。
- 方法区中的常量池里引用的对象:方法区中的常量池包含了字符串常量、类静态常量等,如果这些常量引用了对象,那么这些对象也可以被选定为 GC Roots。
- 被添加了锁的对象:如果一个对象被添加了锁,比如使用了 synchronized 关键字,那么这个对象也可以被选定为 GC Roots。
- 虚拟机内部需要用到的对象:虚拟机内部可能会维护一些对象,比如一些运行时常量池的信息等,这些对象也可以被选定为 GC Roots。
一旦已经存在的根节点不满足存在的条件时,那么根节点与对象之间的连接将被断开。此时虽然对象1仍存在对其他对象的引用,但是由于其没有任何根节点引用,所以此对象即可被判定为不再使用。
比如某个方法中的局部变量引用,在方法执行完成返回之后:
那这种方法的好处是什么呢,回头看之前的循环引用的情况:
如果像刚刚一样给两个引用都赋值为null,这样虽然两个对象还在相互引用,但这两个对象各自的引用也就是GC Roots断开,那么就会变成下面这样:
这样就认定这两个对象可以被回收。
这就是可达性分析方法:如果某个对象无法到达任何GC Roots,则证明此对象是不可能再被使用的。
最终判定
虽然在经历了可达性分析算法之后基本可能判定哪些对象能够被回收,但是并不代表此对象一定会被回收,我们依然可以在最终判定阶段对其进行挽留。
Object
类中存在一个finalize()
方法:
1 | /** |
这个方法就是最终判定方法,如果在子类中重写了这个方法,那么子类的某个对象在判定为可回收时,会进行二次确认也就是执行这个finalize()
方法。
看这样一个例子:
1 | public class Main { |
我们写了一个静态内部类Test
,首先声明了一个静态变量a
类型是Test
但是没有初始化,在主方法中我们创建了一个Test
类的新对象并将其赋值给变量a
,然后把a
赋值为null,这样刚刚创建的Test
类对象就得不到无法访问了。通过System.gc();
手动申请垃圾回收操作,然后Thread.sleep(1000);
等待垃圾回收,然后我们尝试将a
打印出来:
重写一下finalize()
方法:
1 | public class Main { |
在Test
类中重写的finalize()
方法重新引用了对象this
,也就是将本来要被当做垃圾处理的对象重新赋值给静态变量a
。这样就给这个对象重新建立了引用,有了GC Roots,这个对象就不满足可回收的条件了,这样就不会回收这个对象。
这个finalize()
方法不是在主线程中调用的,而是虚拟机自动建立了一个低优先级的Finalize
线程进行处理,所以要等待一秒钟。同时,一个对象的finalize()
方法只会调用一次,也就是如果我们连续两次这样操作,那么第二次这个对象一定会被回收:
1 | public class Main { |
finalize()
方法并不是专门防止对象被回收的,也可以被用来释放一些程序使用中的资源等。
对象存活判定算法的总结如下:
垃圾回收算法
通过对象存活判定算法我们已经知道了堆中的哪些对象可以被回收了,接下来就需要考虑如何对对象进行回收。垃圾收集器会不定期检查堆中的对象查看是否满足被回收的条件,如果一个一个判断对象是否需要回收效率很低,这时就需要垃圾回收算法。
分代收集机制
为了解决每个对象依次判断效率很低的问题,JVM实现了分代收集机制,对堆中的对象进行分代管理。比如某些对象在多次垃圾回收时都未被判定为可回收对象,就可以将这种对象放在一起并让垃圾收集器减少回收此区域对象的频率,这样就能很好地提高垃圾回收的效率。
因此,JVM虚拟机将堆内存划分为新生代、老年代(有些JVM如HotSpotVM有永久代)。不同的分代回收机制也存在不同之处,以HotSpotVM为例,新生代被划分成三块:一块较大的Eden空间和两块较小的Survivor空间(分别称为From和To),默认比例为8:1:1,老年代的垃圾回收频率会相对较低,而永久代一般存放类信息(实际上就是方法区的实现),如图所示:
整个JVM中,完整的垃圾回收(GC)分为几个阶段:
- Minor GC:次要垃圾回收,主要进行新生代区域的垃圾收集,一般发生在新生代的Eden区容量已满时。
- Major GC:主要垃圾回收,主要进行老年代的垃圾收集。
- Full GC:完全垃圾回收,对整个Java堆内存和方法区进行垃圾回收。触发条件主要有以下几个:
- 每次晋升到老年代的对象平均大小大于老年代剩余空间
- Minor GC后存活的对象超过了老年代剩余空间
- 永久代内存不足(JDK8之前)
- 手动调用
System.gc()
方法
那么这个分代收集机制是如何运作的?
先从新生代的垃圾回收开始:
首先所有新创建的对象都会进入新生代的Eden区(如果是大对象则会直接进入老年代),在对新生代进行垃圾回收时,首先会对所有新生代区域的对象进行扫描,并回收那些不再使用的对象。在一次垃圾回收中,Eden区域没有被回收的对象会进入Survivor区。
Survivor区的对象每活过一次垃圾回收,那么它的寿命就会加一,当它寿命到达一定程度就会进入老年代中。因为新生代的对象寿命基本都很短,所以新生代的垃圾回收算法使用的是复制算法,复制算法的基本思想是将内存分为两块,每次只使用一块,当这一块内存用完,就将还活着的对象复制到另外一块上面。
在新生代的垃圾回收开始的时候,对象只会存在于Eden区和名为From的Survivor区,名为To的Survivor区是空的。随着无效对象的回收,Eden区中存活的对象会被复制到Survivor区的To区域中,同时From区域中仍存活的对象也会根据其年龄值来决定去向。年龄达到一定值的,也就是在多次垃圾回收中存活的会被移动到老年代中,没有达到年龄阈值的和Eden区的对象一起进入Survivor区的To区域。
经过上面的过程后,Eden区和Survivor区的From区域已经被清空了,有些对象被复制到Survivor区的To区域,而有些寿命很长的对象被移动到了老年区。这个时候,Survivor区的From区域和To区域会相互交换,即新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。无论怎样,每次垃圾回收刚开始的时候,Survivor区中的To区域都是空的。
新生代会不断重复这样的垃圾回收过程。或许在某一次垃圾回收中,Eden区和From区域中的对象移动到To区域后,To区域被填满,这时就会将To区域中的所有对象移动到老年代中。
空间分配担保
考虑一种极端情况,在一次垃圾回收之后,新生代Eden区的大量存活对象超过了Survivor区的容量,也就是说Survivor装不下了,导致新生代Eden区依然存在大量对象,那么该怎么办?这时就需要空间分配担保机制,可以把Survivor区无法容纳的对象直接送到老年代,让老年代直接进行分配担保,这样新生代就腾出空间容纳更多对象。
但是如果老年代剩下的空间也装不下新生代的对象呢?
所以在发生Minor GC之前,JVM会检查当前老年代可用的空间是否大于新生代所有对象的总空间,也就是考虑在极端情况下新生代的所有对象全部进入老年代,老年代是否有充足空间容纳新生代所有的对象,有两种情况:
- 如果老年代剩余空间大于新生代所有对象的总空间,也就是说这次Minor GC无比安全,可以直接进行。
- 如果小于了,JVM会查看配置
-X:HandlePromotionFailure
是否允许担保失败,又会有两种情况:- 如果
HandlePromotionFailure
=true
,也就是说设置允许担保失败,那么JVM会回头去看历次Minor GC中从新生代移动到老年代的对象的平均大小,再把它与当前老年代可用的空间相比,还会有两种情况:- 如果老年代可用的空间大一点,就说明“按照过往的经验”,这次Minor GC或许是安全的,那么就尝试进行一次MinorGC.
- 如果之前的平均值大一点,那么直接进行一次Full GC。
- 如果
HandlePromotionFailure
=false
,那么直接进行一次Full GC。
- 如果
综上所述,整个Minor GC的流程图如下:
标记-清除算法
在前面已经了解了整个堆内存的垃圾回收主要依赖于分代收集机制,那么具体的回收过程是怎样的?
最古老的垃圾回收算法是标记-清除
算法。首先标记出所有需要回收的对象,然后再依次回收掉被标记的对象,或是标记出所有不需要回收的对象,只回收未标记的对象。
虽然此方法非常简单,但是缺点也是非常明显的 ,首先如果内存中存在大量的对象,那么可能就会存在大量的标记,并且大规模进行清除。并且一次标记清除之后,连续的内存空间可能会出现许许多多的空隙,碎片化会导致连续内存空间利用率降低。
标记-复制算法
为了解决标记-清除算法在面对大量对象时效率低的问题,标记-复制算法出现了。标记复制算法,实际上就是将内存区域划分为大小相同的两块区域,每次只使用其中的一块区域,每次垃圾回收结束后,将所有存活的对象全部复制到另一块区域中,并一次性清空当前区域。虽然浪费了一些时间进行复制操作,但是这样能够很好地解决对象大面积回收后空间碎片化严重的问题。
这种算法就非常适用于新生代(因为新生代的回收效率极高,一般不会留下太多的对象)的垃圾回收,而我们之前所说的新生代Survivor区其实就是这个思路,包括8:1:1的比例也正是为了对标记复制算法进行优化而采取的。
标记-整理算法
虽然标记-复制算法能够很好地应对新生代高回收率的场景,但是放到老年代,它就显得很鸡肋了。我们知道,一般长期都回收不到的对象,才有机会进入到老年代,所以老年代一般都是些钉子户,可能一次GC后,仍然存留很多对象。而标记复制算法会在GC后完整复制整个区域内容,并且会折损50%的区域,显然这并不适用于老年代。
那么我们能否这样,在标记所有待回收对象之后,不急着去进行回收操作,而是将所有待回收的对象整齐排列在一段内存空间中,而需要回收的对象全部往后丢,这样,前半部分的所有对象都是无需进行回收的,而后半部分直接一次性清除即可。
虽然这样能保证内存空间充分使用,并且也没有标记复制算法那么繁杂,但是缺点也是显而易见的,它的效率比前两者都低。甚至,由于需要修改对象在内存中的位置,此时程序必须要暂停才可以,在极端情况下,可能会导致整个程序发生停顿。
所以,我们可以将标记清除算法和标记整理算法混合使用,在内存空间还不是很凌乱的时候,采用标记清除算法其实是没有多大问题的,当内存空间凌乱到一定程度后,我们可以进行一次标记整理算法。
垃圾收集器
Serial收集器
Serial是早期(JDK1.3.1之前)JVM新生代收集器的唯一选择。Serial是单线程的垃圾收集器,也就是说当开始进行垃圾回收时,需要暂停所有的线程,直到垃圾收集工作结束。它的新生代收集算法采用的是标记复制算法,老年代采用的是标记整理算法。
可以看到,当进入到垃圾回收阶段时,所有的用户线程必须等待GC线程完成工作,就像看一部电影,每看一分钟都要卡顿五秒等待缓冲。
虽然Serial收集器的缺点让人难以接受但是也有一定的优点:
- 设计简单而高效。
- 在用户的桌面应用场景(文档、画图、办公软件、多媒体播放器等)中,内存一般不大,可以在较短时间内完成垃圾收集,只要不频繁发生,使用Serial回收器是可以接受的。
所以,在客户端模式(JVM的运行模式设置,与服务器模式相对应)下的新生代中,默认垃圾收集器至今依然是Serial收集器。不过用户也可以根据具体的需求和场景选择其他垃圾收集器,以达到更好的性能和体验。
ParNew收集器
ParNew垃圾收集器相当于是Serial收集器的多线程版本,它能够支持多线程垃圾收集除了多线程支持以外,其他内容基本与Serial收集器一致:
目前某些JVM默认的服务器模式新生代收集器就是使用的ParNew收集器。
Parallel Scavenge/Parallel Old收集器
Parallel Scavenge同样是一款面向新生代的垃圾收集器,同样采用标记复制算法实现,在JDK6时也推出了其老年代收集器Parallel Old,采用标记整理算法实现:
与ParNew收集器不同的是,它会自动衡量一个吞吐量,并根据吞吐量来决定每次垃圾回收的时间,这种自适应机制,能够很好地权衡当前机器的性能,根据性能选择最优方案。
目前JDK8采用的就是这种 Parallel Scavenge + Parallel Old 的垃圾回收方案。
CMS收集器
在JDK1.5,HotSpot推出了一款可认为有划时代意义的垃圾收集器:CMS(Concurrent-Mark-Sweep)收集器,这款收集器是HotSpot虚拟机中第一款真正意义上的并发(注意这里的并发和之前的并行是有区别的,并发可以理解为同时运行用户线程和GC线程,而并行可以理解为多条GC线程同时工作)收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。
CMS收集器主要使用标记清除算法:
CMS的垃圾回收分为4个阶段:
- 初始标记(需要暂停用户线程):这个阶段的主要任务是标记出GC Roots能直接关联到的对象例如被引用的对象、静态变量等。速度比较快,不用担心会停顿太长时间。
- 并发标记:初始标记结束后,CMS收集器会使用多个线程来标记所有存活对象,也就是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,是与应用程序并发执行的。
- 重新标记(需要暂停用户线程):这个阶段的目的是标记在并发标记阶段中产生的新对象,和在并发标记阶段结束后又被修改引用的对象。由于并发标记阶段可能某些用户线程会导致标记产生变动,因此这里需要再次暂停所有线程,这个时间会比初始标记时间长一点。
- 并发清除:最后就可以直接将所有标记好的无用对象进行删除,因为这些对象程序中也用不到了,所以可以与用户线程并发运行。
CMS收集器有几个优点:
- 降低停顿时间:CMS收集器的主要目标是降低长时间的停顿时间,尤其适用于对响应时间要求较高的应用程序。通过并发标记和并发清除的方式,CMS收集器能够在大部分时间内与应用程序并发执行,减少了垃圾收集器造成的应用程序停顿时间。
- 避免全局性的停顿: 与传统的标记-清除(Mark-Sweep)算法不同,CMS收集器不需要在整个堆上停止应用程序来执行垃圾收集。相反,它会在部分阶段与应用程序并发执行,只在标记和清除阶段暂停应用程序的执行,因此避免了全局性的停顿。
- 高吞吐量: 尽管CMS主要关注降低停顿时间,但在某些情况下,CMS也能够提供较高的吞吐量。虽然并发执行可能会降低单次垃圾收集的效率,但由于停顿时间较短,应用程序的整体吞吐量可能会提高。
- 适用于多核处理器: CMS收集器利用了多线程并发的特性,因此在多核处理器上的性能表现通常比较好。它能够有效利用多个处理器核心来执行垃圾收集任务,从而提高垃圾收集的效率。
但是缺点也是显而易见的,之前说过,标记清除算法会产生大量的内存碎片,导致可用连续空间逐渐变少,长期这样下来,会有更高的概率触发Full GC,并且在与用户线程并发执行的情况下,也会占用一部分的系统资源,导致用户线程的运行速度一定程度上减慢。
不过,如果希望的是最低的GC停顿时间,CMS收集器无疑是最佳选择,不过自从G1收集器问世之后,CMS收集器不再推荐使用了。
Garbage First(G1)收集器
在JDK1.7后,面向服务器模式的G1收集器出现,并且在JDK9时,取代了JDK8默认的 Parallel Scavenge + Parallel Old 的回收方案。
之前提到,垃圾回收分为Minor GC、Major GC和Full GC,它们分别对应的是新生代,老年代和整个堆内存的垃圾回收,而G1收集器巧妙地绕过了这些约定,它将整个Java堆划分成2048个大小相同的独立Region(分区)块,每个Region块的大小根据堆空间的实际大小而定,整体被控制在1MB到32MB之间,且都为2的N次幂。所有的Region大小相同,且在JVM的整个生命周期内不会发生改变。
那么分出这些Region有什么意义呢?每一个Region都可以根据需要,自由决定扮演哪个角色(Eden、Survivor和老年代),收集器会根据对应的角色采用不同的回收策略。此外,G1收集器还存在一个Humongous区域,它专门用于存放大对象(一般认为大小超过了Region容量一半的对象为大对象)这样,新生代、老年代在物理上,不再是一个连续的内存区域,而是到处分布的。
它的回收过程与CMS大体类似:
同样分为四个步骤:
- 初始标记(暂停用户线程):标记一下GC Roots能直接关联到的对象,并且修改TAMS指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际并没有额外的停顿。
- 并发标记:从GC Root开始对堆中对象进行可达性分析,递归追踪所有可达的对象标记为存活,找出要回收的对象,这阶段耗时较长,但可与应用程序并发执行。
- 最终标记(暂停用户线程):对用户线程做一个短暂的暂停,用于处理并发标记阶段漏标的那部分对象。
- 筛选回收:在这个阶段,G1垃圾收集器会根据每个Region的垃圾堆积情况和回收价值进行排序,根据用户所期望的停顿时间来制定回收计划,选择性地回收部分Region中的垃圾对象。然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧Region的全部空间。这里的操作涉及存活对象的移动,所以必须暂停用户线程,由多个收集器线程并行完成,旨在最大限度地减少停顿时间。同时,这个阶段可能会涉及到对象的整理和压缩,以减少内存碎片。
G1收集器还提供了两种主要的垃圾回收模式:Young GC和Mixed GC。Young GC主要负责回收新生代中的垃圾对象,而Mixed GC则负责回收新生代和部分老年代中的垃圾对象。这两种模式都是根据堆内存的使用情况和GC的触发条件来自动选择的。
Young GC中Eden区和Survivor区的存活对象会被复制到另一个Survivor区或者晋升到老年代,但是由于新生代中的对象通常较少,所以Young GC在回收过程中的暂停时间通常较短,对应用程序的性能影响也较小。
Mix GC则是G1收集器特有的回收策略,它不仅回收新生代中的所有Region,还会回收部分老年代中的Region。这种策略的目标是在保证停顿时间不超过预期的情况下,尽可能地回收更多的垃圾对象。在Mix GC的回收阶段,G1会根据标记结果选择收益较高的部分老年代Region和新生代Region一起进行回收。这个选择过程是基于对Region中垃圾对象的数量和回收价值的评估。与Young GC不同,Mix GC的停顿时间可能会更长,因为它涉及到对老年代中对象的扫描和回收。但是,由于Mix GC能够回收更多的垃圾对象,因此它通常能够更有效地释放内存空间,减少垃圾堆积对应用程序性能的影响。