深入理解JVM读书笔记
从半个多世纪前的Lisp语言开始,垃圾回收机制正式登上历史舞台。但是直到今天,仍然没有一个完美的垃圾回收方案。从Java诞生到现如今的Java 8, Java的垃圾回收管理机制也一直在尝试优化。虽然到今天位置垃圾回收机制并不完美,但是它仍旧是目前人类顶尖智慧的结晶,其中的一些设计不得不让人佩服。
说起到垃圾回收机制,我们不得不思考三个问题:
- 哪些内存需要回收?
- 如何进行回收?
- 什么时间进行回收?
一. 哪些内存需要回收?
1. 引用计数算法
很多教科书中把引用计数算法作为判断对象是否存活的算法:“给每个对象添加一个计数器,当使用该引用引用时计数器值加1,当引用失效时计数器值减1;当某个对象的引用计数器值为0时,表示该对象可以被回收。
客观的来说引用技术算法实现简单,效率也不低;但是它却有一个致命的问题:很难解决对象循环引用的问题。
在JVM中,循环引用并不会影响对象的回收。所以JVM中并不是通过引用技术算法来判断对象是否能被回收。
//TODO: Code
2. 可达性分析算法
在主流商用语言(Java,C#等)中,一般是通过可达性分析来判断对象是否可被回收。从 “GC Root” 对象作为起点开始搜索,搜索所走过的路径成为引用链。如果一个对象从GC Root开始无引用链可达,则表示这个对象是不被使用的,是可被回收的。
那GC Root到底是些什么样的对象呢? 在Java中可以做GC Root的对象包括 本文不会对JVM内存模型进行解析,如果大家有疑问可以自行参考相关文章:
- 虚拟机栈中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常亮引用的对象
- 本地方法栈中JNI引用的对象
3. finalize() 方法
如果一个对象通过可达性分析算法判定为不可达对象,那么等待它的就只是被杀死的命运吗?然而并不是,咸鱼都有翻身的机会,对象也该有它的机会!
一个对象如果被判定为“不可达”状态,系统会对它进行筛选,判断对象是否需要执行finalize() 方法。 如果对象没有覆盖finalize()方法,或者finalize()方法已经被系统调用过一次 [finalize()方法只会被调用一次],拿它就会被判定为不需要执行finalize(),也就失去了最后翻身的机会。
相反如果对象被判定为有必要执行finalize()方法,那么这个对象会被放到一个F-Queue的队列中。稍后系统会建立一个优先级非常低的线程去调用对象的finalize()方法。如果在finalize()方法中,对象重新连接上引用链,那么在第二次标记时它会被移除“即将被回收”的集合,否则它将被回收。
//TODO: code
二. 如何进行回收?
1. 垃圾收集算法
垃圾回收算法有很多种,每一种算法的细节又非常复杂。在此我们尽量避免介绍一些算法细节,只是介绍下主流垃圾回收算法的思想。
1.1 标记-清除算法
法如其名,首先将所有需要回收的对象做标记【标记过程即是上文提到的标记】,标记完成后统一回收所有被标记对象。
标记清除算法是最基础的收集算法。因为后续的收集算法都是基于标记清除算法,并尝试解决它的两个不足:
- 效率问题,标记和清除效率都不高
- 控件问题,清除后会产生很多不连续的内存碎片
//TODO: Image
1.2 复制算法
Copy算法很大程度上是为了解决效率问题而诞生的。它将可用内存平分为两部分。每次只使用其中的一部分,当这部分内存将要用完时触发回收,之后将存活的对象复制到另一部分内存上并清理掉之前的内存。
它的优势:每次都是对一半的区域进行回收;不需要考虑内存碎片问题,内存分配简单高效。缺点也很明显:可用内存空间只能是全部内存的一半。
复制算法在实际应用中使用的比较多,它常被一些商业虚拟机用来做对新生代的回收。但是实际上内存分块并不是按照1:1来分配的,因为IBM研究表明98%新生代中的对象都是可被回收的,也就是2%的对象是需要被拷贝的。在复制算法的升级版本中内存被分为一个大的Eden区和两个小的Survivor区,每次使用Eden和一个Survivor区。当GC时,讲Eden和Survivor中存活的对象Copy到另一个Survivor区中。在HotPot虚拟机中Eden和Survivor的默认比例是8:1,也就是新生代的可用内存为整个新生代内存总量的90%。一个Survivor区可用内存只有10%的总容量,但是系统并不能保证每次回收完之后剩余对象大小一定在10%以内。在此种情况下Survivor区控件不足时,系统需要依赖其他内存(老年代)进行分配担保(Handle Promotion)。
//TODO: Image
1.3 标记整理算法
复制算法在新生代回收中非常给力,但是在老年代中对象存活率较高,对象的多次拷贝效率会降低,并且因为对象存活率比较高,无法像新生代一样设置8:1的比例一般只能按照1:1来设置,导致老年代内存的使用率只能是50%左右。所以在老年代中一般不会直接使用复制算法。
标记清理算法正是专门针对老年代的特点而产生的。它跟标记-清除算法类似。只是标记完成后不是直接回收清除,而是让所有存活的对象都像一端移动,然后直接清理其他所有空间。
1.4 分代收集算法
当前普遍使用的分代收集算法并不能算是一个独立的算法。它更像是一种机制。它只是根据对象存活周期的不同将内存分为新生代和老年代。而新生代和老年代具体的内存回收算法则是前面介绍的算法来实现。
2. 垃圾收集器
垃圾收集器是JVM中对垃圾回收算法的具体实现。不同厂商,不同的虚拟机版本所提供的垃圾收集器都可能有很大的不同,我们只对其中比较有代表性的几个垃圾收集器做介绍。
2.1 Serial、Serial Old
Serial收集器是最基本,历史最悠久的收集器,一般用于分代收集中的新生代收集。见名知意,它是一个单线程的垃圾收集器。并且Serial还有一层含义是,它在进行垃圾回收时必须停止其他所有工作线程(Stop The World),然后使用Copy算法进行垃圾回收,最后恢复其他工作线程。
Serial Old是Serial的老年代版本。它跟Serial机制差不多,同样是一个单线程收集器,不过它是通过标记-整理算法来实现的。
2.2 ParNew、Parallel Scavenge、Parallel Old
Parallel系列收集器都是通过多条线程进行垃圾回收的。
ParNew与Serial相比除了使用多线程外,并没有太多的创新之处(Copy算法)。它是很多Server模式虚拟机的新生代收集器,因为它能很好的与老年代的CMS收集器配合工作。
Parallel Scavenge收集器跟ParNew很像,使用Copy算法多线程回收。它的特点是它是一个吞吐量优先收集器,吞吐量=运行用户代码时间/(运行用户代码时间+GC时间)。Parallel Scavenge提供的参数可以控制Eden和Survivor的空间大小和比例,进而控制每次GC所用时间和系统总吞吐量。
Parallel Old收集器是Parallel Scavenge的老年代版本。它使用标记整理算法多线程回收。它一般用作吞吐量优先收集器的老年代版本,跟Parallel Scavenge搭配自称吞吐量优先组合。
2.3 CMS
CMS(Concurrent Mark Sweep)收集器是一种为了尽量缩短停顿时间的收集器。为了追求更好的用户体验,对服务响应速度的要求也会相应的提高。CMS也就应运而生。 CMS采用MS(Mark-Sweep)标记清除算法进行垃圾回收。CMS回收过程分为四个阶段:
- 初始标记
- 并发标记
- 重新标记
- 并发清除
其中初始标记和重新标记仍然需要Stop The World。初始标记仅仅是标记GC Root直接关联到的对象,速度很快。并发标记就是做GC Root引用链的搜索,重新标记阶段是为了纠正并发阶段期间可能由于代码执行导致的标记变化。耗时最长的并发标记和并发清除阶段都可以与用户工作线程并发执行,所以总体来看CMS基本上是与用户线程并发执行的。
当然CMS并不是一个完美的垃圾收集器。它的并发会让它对CPU资源敏感,CPU数较少时GC会占用比较多的CPU资源。另一方面由于是基于标记清除算法,它只能通过Full GC(收集整个堆,不管新生代老年代)对内存空间碎片进行处理。
2.4 G1
G1(Garbage-First) 收集器是当今收集器技术发展的最前沿成果之一。但是因为是从JDK7u4版本开始移除的Experimental标记,目前在实际生产环境中使用并不多。
其他的收集器收集的范围都是整个新生代或者老年代。而G1将内存区进一步划分为若干个大小相等的独立区域(Region)。而新生代和老年代不再是物理隔离的,而分别是一部分不需要连续性的Region的集合。G1可以跟踪并维护每个Region的回收价值(回收所得空间大小与回收所费时间的经验值),每次回收优先回收价值最大的区域,也也就是Garbage-First的由来。
G1收集器收集过程也是分为4个阶段,前三个阶段与CMS相同,最后阶段由并发清除变为筛选回收。所谓筛选回收就是计算Region的回收价值并排序,选择价值最高的Region进行回收。
它与CMS等其他收集器相比有以下的一些优势:
- 并行与并发:充分利用多核或多CPU技术来缩短Stop The World 的时间。
- 分代收集: G1可以独立管理新老代,也可以与某些其他收集器配合使用。
- 空间整合: 与CMS的标记-清理算法不同,整体来看G1是基于标记-整理算法的收集器,从局部来看Region间是基于Copy算法实现的。无论如何都是不会产生内存碎片的。
- 可预测的停顿:G1和CMS都致力于降低停顿时间。G1显然技高一筹。G1允许使用者明确指定M时间段内,消耗在GC的时间不得超过N。
三. 垃圾回收的时机
1. 安全点
在GC开始时,系统会停顿下来做枚举根节点的操作。虚拟机一般会维护一个数据结构,来保存当前执行上下文和全局的引用位置。在HotSpot虚拟机中是通过OopMap这个数据结构来实现的。在OopMap的帮助下,HotPots可以快速精准的完成GC Root枚举。
但是问题来了:有很多指令都可能导致OopMap的内容变化,如果为每一条指令都生成对应的OopMap会占用大量的额外空间成本。事实上HotPots也不会为每条指令生成OopMap,只是在“某些特定位置”生成了这些信息。这些位置就是所谓的安全点,也就是系统并不会随时随地在任何地方都能停顿下来做GC操作,只有在安全点才能。
安全点太少会导致GC等待时间过长,安全点过多会增加系统运行成本。那如何选择安全点呢? 安全点的选择一般以“是否有让程序长时间运行的特征”为标准。例如在方法调用,循环跳转,异常跳转等地方才会有安全点。
为了保证GC发生时所有线程都能在安全点,系统提供抢先式中断和主动式中断来实现。抢先式中断就是所有线程先中断,然后让不在安全点的线程恢复并运行到安全点。主动式中断就是系统不直接中断线程而是只设置一个标记位,其他线程执行时主动轮询这个标记位。
2. 安全区域
安全点解决了如何进入GC的问题,保证程序执行不长时间都会触发一次GC。但是如果程序没有执行呢,如果线程处于Sleep或Blockd状态呢?这种情况就需要安全区域来解决。 安全区域是指引用关系不会发生变化的一段代码片段。在这个区域的任何地方进行GC都是安全的。线程进入安全区域后会先标识自己进入了安全区域,系统GC时就会认为此线程是回收安全状态。当线程要离开安全区域时,会检查系统是否完成了根节点枚举才决定是否能离开。