
深入理解JVM(二)——垃圾收集器与内存分配策略
以HotSpot为主,介绍了主流JVM垃圾收集器中的相关内存分配策略。
虽然之前也写过一篇关于垃圾收集器的文章,但是当时的文章大多基于网上的八股文,不是特别系统,对内容也只是一知半解,现在重新学习,就当是自己的读书笔记了。
垃圾收集的历史实际上比Java这门语言要更加久远,诞生于MIT的Lisp是第一门开始使用内存动态分配和垃圾收集技术的语言。垃圾收集其实主要就是考虑三件事情:哪些内存需要回收?什么时候回收?如何回收?
从上一篇文章中可以知道,程序计数器、虚拟机栈和本地方法栈三个区域随线程而生,随线程而灭,每个栈帧分配多少内存基本上是在类结构确定下来就已知的,因此这部分的内存基本不用考虑如何回收的问题。主要的问题集中在Java堆和方法区这两个区域:因为一个接口的多个实现类所需的内存可能会不一样,一个方法执行的不同条件分支所需要的内存也可能不一样,只有在运行期间,才能够知道程序会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的,也正是垃圾收集器所要关注的。
哪些内存需要回收?
当然是“死去“的对象的内存,即不再使用的对象的内存,至于如何判定哪些对象是“死去”的,主要有以下方法。
引用计数算法
在对象中添加一个引用计数器,当有一个地方引用它时,计数器的值就加一;当引用失效时,计数器的值就减一;任何时刻计时器为零的对象就是不可能再被使用的。
引用计数算法虽然占用了一些额外的内存空间来计数,但是其原理简单,判定效率高。在大多数时候都是一个不错的算法。
但是它无法解决对象之间相互循环引用的问题。当有两个对象你引用我,我引用你时,其引用计数器的值永远不可能为零,但是这两个对象实际上都已经不再被使用,这样的情况引用计数算法就无法解决。
可达性分析算法
当前的主流商用程序语言的内存管理子系统都是通过可达性分析算法来判定对象是否存活的。算法的思路就是通过一些称为“GC Roots”的跟对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,即从GC Roots到这个对象不可达时,证明此对象是不可能再被使用的。
在Java技术体系中,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈中引用的对象,如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,如字符串常量池中的引用。
- 在本地方法栈JNI(即Native方法)引用的对象。
- Java虚拟机内部的引用,像是基本数据类型对应的Class对象,一些常驻的异常对象等。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码等。
除了以上固定的GC Roots集合外,根据具体的垃圾收集器的不同以及回收的内存区域的不同,还会有其他对象临时加入,共同构成完整的GC Roots集合。
“引用”
无论是引用计数算法还是可达性算法,都和所谓的“引用”有关,在JDK1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用和虚引用4种,4种引用强度依次逐渐减弱:
- 强引用是指在程序代码中普遍存在的引用赋值,即类似于"
Object obj = new Object()"这种引用关系。无论任何情况下,只要强引用关系存在,垃圾收集器就永远不会回收被引用的对象。 - 软引用是指一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常之前,会将这些对象列入到回收范围中进行第二次回收,即在将要发生内存溢出时,先进行第一次回收,第一次回收中软引用不会被回收,但是如果第一次回收后还是无法满足分配要求,就在第二次回收中将软引用进行回收,回收后若还是无法满足,则抛出内存溢出异常。
- 弱引用是指比软引用更弱的引用,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论内存是否足够,都会回收掉只被弱引用关联的对象。
- 虚引用也被成为“幽灵引用”或“幻影引用”,是最弱的一种引用关系。一个对象是否有虚引用存在,完全不会对其生存空间构成影响,也无法通过虚引用来取得一个对象实例。一个对象设置虚引用关联的唯一目的是让在这个对象在被收集器回收时能够收到一个系统通知。
时代的眼泪finalize()
要确认一个对象已经死亡,需要经历两次标记过程,第一次是在对对象进行可达性分析后发现没有与GC Roots相连接的引用链,则会被第一次标记,随后进行一次筛选。筛选的条件是该对象是否有必要执行finalize()方法,如果该对象重写了finalize()方法且finalize()方法还没有被虚拟机调用过,则该对象会进入一个F-Queue,并且在稍后通过由虚拟机自动建立的一个低调度优先级的Finalizer线程执行它的finalize()方法。此处的执行只是虚拟机会除法这个方法开始运行,而不保证等待其运行结束。因为如果某个对象的finalize()执行缓慢或者陷入死循环,就会导致F-Queue中的其他对象永久等待,甚至导致整个内存回收子系统崩溃。稍后虚拟机会对F-Queue中的其他对象进行第二次标记,如果对象在finalize()方法中重新与引用链上的任意一个对象重新建立了联系,此时就能够逃逸出F-Queue,从而不会被回收。finalize()方法只会被系统自动调用一次,如果对象面临下一次回收,其fianlize()方法不会再被执行。因为其执行时机不确定且性能负担较重,该方法已经在JDK 9中移除,现在更加推荐使用try-with-resources或者cleaner方法在对象回收时进行一些清理。
回收方法区
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
- 回收废弃常量类似回收Java堆中的对象。例如常量池中的字面量回收,一个字符串a曾经进入常量池中,但是当前系统没有任何一个字符串对象引用常量池中的这个字符串a,如果此时发生内存回收且垃圾收集器判断有必要回收,则该常量就会被系统清理出常量池,常量池中的其他类、方法、字段的符号引用也与此类似。
- 判断一个类型是否属于“不再被使用的类”则需要满足以下三个条件:1.类的所有实例都已被回收。2.加载该类的加载器已经被回收。3.该类对应的java.lang.Class对象没有任何地方被引用,无法在任何地方通过反射访问该类的方法。Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里的“被允许”不代表没有引用了就必然被回收。
垃圾收集算法
此处只讨论分代收集理论和几种算法思想极其发展过程。
分代收集理论
分代收集理论实质上是一套符合大多数程序运行实际情况的经验法则,建立在两个分代假说上:
- 弱分代假说:绝大多数对象的生命周期都非常短。
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用垃圾收集器的设计原则:收集器应该将Java堆划分成不同的区域,然后将回收对象根据其年龄分配到不同的区域中进行存储。每次回收时只关注如何保留少量存活而不是标记大量将要被回收的对象,就能够以较低代价回收大量空间;如果剩下的都是难以消亡的对象,就将其集中在一起,虚拟机就可以以较低的频率来回收这个区域,兼顾了垃圾收集的时间开销和内存的空间有效利用。
将分代收集理论具体放到现在的商用虚拟机中,设计者通常至少会把Java堆划分为新生代和老年代两个区域,新生代中每次垃圾收集都会有大批对象死去,每次回收后存活的少量对象,则逐步晋升到老年代中存放。但是实际上分代收集并不是简单划分内存区域,因为对象不是孤立的,对象之间存在跨代引用。假设现在只在新生代中进行垃圾收集,为了找出该区域的存活对象,就要在GC Roots外遍历整个老年代来确保可达性分析结果的正确性,这样会带来非常大的性能负担,因此引出了第三条经验法则:
- 跨代引用假说:跨代引用相对于同代引用来说只占极少数。
通过第三条假说,只需要在新生代上建立一个全局数据结构,将老年代划分为若干小块,标识出老年代的哪一块内存会存在跨代引用,在这之后发生新生代的GC时,只有包含了跨代引用的小块内存中的对象才会被加入到GC Roots进行扫描,这种方式添加了一些额外的维护开销,但是相对于扫描整个老年代来说是极其划算的。
标记-清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。或者反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
其缺点主要为:执行效率不稳定和内存空间碎片化。执行效率不稳定表现为大部分对象是需要回收的时候,必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率随对象数量的增长而降低。内存空间碎片化则表现为标记清除后产生大量不连续的内存碎片,导致后续分配大对象时无法找到足够的连续内存。
标记-复制算法
标志-复制算法被简称为复制算法,主要是为了解决标记-清除算法在面对大量可回收对象时执行效率低的问题(因此一般用在新生代),将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内容用完时,就将还存活的对象复制到另外一块上,再将已经使用过的内存空间一次清理掉。如果对象中多数对象都是存活的,就会产生大量的内存间复制开销,但是对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按照顺序分配即可。
该实现简单高效,缺陷就是将可用内存缩小为了原来的一半,浪费大量空间。
在这个缺陷的基础上进行的优化就是,不需要按照1:1的比例进行划分。例如HotSpot虚拟机中的Serial和ParNew等新生代收集器,将新生代划分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor,发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor上,然后直接清理Eden和原本的Survivor。HotSpot默认Eden的大小比例是8:1,这样每次可用的内存就达到了90%,相比于50%,这是可以接受的。
标记-整理算法
标记-复制算法在处理对象存活率较高的情况时需要进行较多复制操作,效率降低,且需要有额外的空间进行分配担保,面对老年代可能会出现100%存活的情况,标记-复制算法并不适合。
针对老年代对象的特征,标记-整理算法的标记过程仍然和标记-清除算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
移动vs不移动:如果移动对象,尤其是在老年代这种每次回收都有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方会是负载极高的操作,并且这种对象移动操作需要“Stop The World”,即全称暂停用户应用程序。但是如果不移动对象,空间碎片化问题又只能够依赖更加复杂的内存分配器和内存访问器来解决。例如通过“分区空闲分配链表”来解决内存分配问题。
是否移动对象都会存在弊端,移动则内存回收时更复杂,不移动则内存分配更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间更短甚至可以不停顿;从程序吞吐量的来看,移动对象会更加划算。
还有一种折中的办法:虚拟机平时多数时候使用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用整标记-整理法收集一次,以获得规整的内存空间。
HotSpot算法细节实现
根节点枚举
所有的收集器在根节点枚举这一步骤时都必须暂停用户线程,因此会涉及到”Stop The World“问题。现在的可达性算法耗时最长的查找引用链的过程已经可以做到与用户线程一起并发,但是根节点枚举还是必须在一个能保障一致性的快照中才得以进行,即枚举期间执行子系统就像是被冻结在某个时间点上,不会出现在分析的过程中,以此来保证分析结果的准确性。
虚拟机有办法直接得到哪些地方存放着对象引用,在HotSpot中使用数据结构OopMap来达到这个目的,一旦类加载完成后,HotSpot就会将对象内特定偏移量上的类型数据计算出来,这样扫描器在扫描时就能直接获取这些信息。
安全点
使用OopMap,HotSpot可以快速准确地完成GC Roots枚举,但是这又有可能导致引用关系的变化,如果为每条指令都生成OopMap,将会需要大量的额外存储空间。
HotSpot只在特定的位置记录OopMap,这些位置被称为安全点,解决了用户程序执行时,不会在代码指令流的任意位置都停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。安全点的选定不能太少以至于让收集器等待时间过长,也不能太过于频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上以”是否具有让程序长时间执行的特征“为标准进行选定。“长时间执行”最明显的特征是指令序列的复用,如方法调用、循环跳转、异常跳转等,只有具有这些功能的指令才会产生安全点。
另外一个需要考虑的问题是让所有线程在垃圾收集发生时跑到最近的安全点,然后停顿下来。此处有两种方案:
- 抢占式中断:不需要线程的执行代码主动配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复线程执行,让它一会儿再重新中断,直到跑到安全点上。
- 主动式中断:当垃圾收集需要中短线程时,不直接对线程操作,而是简单设置一个标志位,线程在执行过程中会主动轮询该标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方。
安全区域
安全点机制保证了程序执行时在不太长的时间内遇到可进入垃圾手机过程的安全点,但是程序不执行的时候,即没有分配处理器时间时,线程无法响应虚拟机的中断请求,这时候就需要引入安全区域来解决。
安全区域是指确保在某一段代码片段中,引用关系不会发生变化,因此在这区域中的任何地方开始垃圾收集都是安全的,可以将安全区域看作被扩展拉伸了的安全点。
记忆集与卡表
垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免将整个老年代加进GC Roots扫描范围。记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。如果不考虑效率和成本,最简单的实现可以用非收集区域中所有含有跨代引用的对象数组来实现该数据结构。但是这个简单的实现记录了全部含有跨代引用对象的实现方案,空间占用和维护成本都比较高。在垃圾收集的过程中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,不需要了解跨代指针的全部细节。可以根据具体的需求使用不同的记录精度:
- 字长精度:每个记录精确到一个机器字长,即处理器的寻址位数,该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象中有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域中有对象含有跨代指针。
其中卡精度所指的是用一种称为”卡表“的方式去实现记忆集,也是目前最常用的一种记忆集实现形式。卡表定义了记忆集的记录精度、与堆内存的映射关系等,最简单的形式可以是一个字节数组,每一个元素都对应着其标识的内存区域中一块特定大小的内存块,该内存块称为”卡页“。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个对象的字段存在着跨代指针,则对应卡表的数据元素的标识的值为1,称为该元素变脏。在垃圾收集时,只筛选出卡表中变脏的元素,就能够轻易得出哪些卡页内存块中包含跨代指针,将其加入到GC Roots中一并扫描。
写屏障
HotSpot虚拟机中通过写屏障技术维护卡表状态。所谓的写屏障可以看做虚拟机层面堆”引用类型字段赋值“这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,即赋值前后都在写屏障的覆盖范畴内。赋值前的部分写屏障称为写前屏障,赋值后的则叫做写后屏障。例如:
应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代的引用,每次只要对引用进行更新,就会产生额外的开销。
并发的可达性分析
并发的可达性分析解决的是在堆越大,存储的对象越多,对象的图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,即停顿时间与Java堆容量成正比例关系。如果能够削减这部分停顿时间,则收益也会是系统性的。
为了追踪对象图的遍历过程,将对象分为三种状态:
- 白色:未被垃圾收集器访问。在分析开始时,除了GC Roots,所有对象均为白色,分析结束阶段,仍然是白色的对象即代表不可达,属于垃圾对象。
- 黑色:对象已经被访问过,且该对象引用的所有其他对象也都已经被访问过。黑色对象代表存活对象,且不可能直接指向白色对象。
- 灰色:对象已经被访问过,但该对象引用的其他对象中,至少有一个尚未被访问(即仍为白色)。灰色是白色与黑色之间的分界线。
正常的引用过程如下图所示,这种情况不会导致并发问题。
但是如果用户线程在标记进行时并发修改了引用关系,扫描就会出现问题:
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题,即原本应该是黑色的对象被误标记为白色:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用。
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
要解决对象消失问题,只需破坏两个条件的任意一个,由此产生了两种解决方案,分别是增量更新和原始快照。
- 增量更新:破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,将这个新插入的引用记录下来,等到并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。即黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
- 原始快照:破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将要删除的引用记录下来,在并发扫描结束以后,再将这些记录过的引用关系的灰色对象为根,重新扫描一次。即无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。
参考自《深入理解JAVA虚拟机:JVM高级特性与最佳实践》


