Java 垃圾收集
在 Java 中,运行时内存区域可以分为:程序计数器、虚拟机栈、本地方法栈、堆和方法区,其中程序计数器、虚拟机栈和本地方法栈是线程私有的,因此这部分划分的内存与线程的生命周期相同:栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作,每一个栈帧中分配多少的内存基本上在类结构确定之后就已知了(在概念模型中可以暂时忽略 JIT 编译器的优化),因此这几个区域的内存分配和回收都具有确定性。而 Java 堆和方法区则不一样,一个接口的多个实现类需要的内存可能不一样,一个方法中多个分支需要的内存也可能不一样,只有在代码处于运行期间才能知道会创建哪些内存,这部分内存的分配和回收都是动态的,Java 的 GC 关注的就是这部分内存。
垃圾收集需要完成三件事情:首先需要确定哪些内存需要回收,然后是何时回收以及怎样回收。
对象存活判断
垃圾收集器在对堆进行回收之前,需要先确认哪些对象还“存活”着,哪些已经“死去”。而判断对象是否存活的关键就是引用。
对象的引用
在 JDK 1.2 以前,引用的定义很传统,如果变量中存储的是另外一块内存的起始地址,那么这块内存就代表着一个引用。在 JDK 1.2 之后,Java 对引用的概念进行了扩充,将引用分为强引用(StrongReference)、软引用(SoftReference)、弱引用(WeakReference)和虚引用(PhantomReference)。
强引用就是类似 Object obj = new Object()
这样的引用,只要强引用还在,垃圾收集器就永远不会回收被引用的对象。
软引用用来描述一些有用但非必须的对象。只有在系统将要发生内存溢出异常之前,才会对软引用关联的对象进行回收,如果这次回收后还没有足够的内存,才会抛出 OutOfMemoryError。
弱引用的强度比软引用更弱,被弱引用关联的对象只能生存到下次垃圾收集之前。
虚引用是最弱的一种引用,虚引用顾名思义,就是形同虚设。虚引用并不会影响对象的生命周期,如果一个对象仅关联着虚引用,那么它就和没有任何引用一样,在任何时候都可能会被 GC 回收,我们无法通过虚引用获取它所关联的实例对象。虚引用的唯一作用就是跟踪垃圾收集器的回收活动。并且需要注意的是,虚引用必须和引用队列(ReferenceQueue)配合使用,当垃圾收集器准备回收一个对象时,如果发现它还有虚引用,那么就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
引用计数算法
引用计数法就是给对象添加一个引用计数器,每当有一个地方引用它,计数器就加一;当引用失效时,计数器就减一。当计数器的值为 0 时表示对象没有被任何地方引用,可以进行回收,用下图表示会比较清晰:
引用计数器算法实现简单,效率也很高,但是在主流的 Java 虚拟机里都没有采用这种算法,主要原因是它很难解决对象之间相互循环引用的问题。比如:
1 | class A { |
虽然对象 a 和 b 都为 null,但是由于 a 和 b 之间存在循环引用,因此它们的引用计数器都不为 0,GC 也就一直无法回收它们。用一个图展示会比较清晰:
可达性分析算法
可达性分析算法通过一系列的称为 GC Roots 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连,就可以说从 GC Roots 到这个对象不可达,也就意味着这个对象是可以被回收的。
在 Java 中,可以作为 GC Roots 的对象包括:虚拟机栈(栈帧中的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象以及本地方法栈中 JNI(即 Native 方法)引用的对象。
枚举根节点
在做可达性分析时首先需要找到所有的 GC Roots 节点,可以作为 GC Roots 的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果逐个检查这方面的引用,那么必然需要耗费大量的时间。另外可达性分析对执行时间的敏感还体现在 GC 停顿(也就是 STW,Stop The World)上,因为在分析期间需要整个执行系统暂停(所有的 Java 执行线程暂停运行,所有 GC 相关的工作交由 VMThread 处理),看起来就像被冻结在某个时间点上,不能出现在分析过程中对象的引用关系还在发生变化的情况,这会导致分析结果不准确。即使是号称几乎不会发生停顿的 CMS 收集器中,枚举根节点也是需要停顿的。
由于目前主流的 Java 虚拟机使用的都是准确式 GC,所以当执行系统停顿后,GC 并不需要一个不漏地检查所有执行上下文和全局的引用位置,虚拟机有办法直接得知哪些地方存放着对象引用。在 HotSpot 的实现中,它使用了一组称为 OopMap
的数据结构来达成这一目的,在类加载完成的时候,HotSpot 就把对象内所有偏移量上的数据类型计算出来,在 JIT 编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样 GC 在扫描时就可以直接得知这些信息了。
准确式内存管理意味着虚拟机可以知道内存中某个位置数据的类型,比如内存中有一个 32 位整数 123456,它到底是一个引用类型指向 123456 的内存地址值还是一个数值为 123456 的整数,虚拟机有能力分辨,这样才能在 GC 时准确判断堆上的数据是否还可能被使用。
安全点
在 OopMap 的帮助下,HotSpot 可以快速准确地完成 GC Roots 的枚举,但是一个现实的问题就是:可能引起引用关系变化的指令非常多,如果为每一条这样的指令都生成对应的 OopMap,那么将会需要大量的空间,同时安全点还有可能会影响 JIT 对代码进行优化(deoptimization safepoint),因此 HotSpot 只在特定的位置记录了这些信息,这些位置就是安全点(Safepoint),即程序执行时并非在所有的地方都能停顿下来进行 GC,只有在到达安全点时才可以。安全点的选定既不能太少以致于让 GC 长时间等待,也不能太多以致于虚拟机运行负荷增大,因此一般安全点选定的依据是“程序是否具有长时间运行的特征”,这些特定的位置主要有:uncounted loop(即无界循环)回跳之前、方法返回前、抛出异常的位置等等。JIT 编译器会在这些可能长时间运行的代码之后设置安全点,这样就可以避免出现程序中其他线程都已经到达安全点,而一些线程在长时间运行之后还不能进入安全点的情况。
counted loop 是指虚拟机认为执行时间会比较短的循环,因此在编译时不会加入安全点检查的代码;而 uncounted loop 则可能会长时间执行,因此会在每次循环中加入安全点检查的代码。
对于安全点,另一个需要考虑的问题就是如何在 GC 发生时让所有的执行线程(不包括执行 JNI 调用的线程)都“跑”到最近的安全点并停顿下来。这里有两种方案:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。其中抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时会直接把所有的线程中断,如果发现有线程中断的地方不在安全点,就恢复线程的执行并等待它到达安全点再中断。现在几乎没有虚拟机使用这种方式。而在主动式中断中,JIT 编译器编译的时候会在安全点的位置加入检查的代码(另外在创建对象需要分配内存的地方也会加入),当 GC 需要中断线程的时候并不直接操作线程,而是仅仅设置一个标志,各个线程在执行到安全点时会主动去检查这个标志,如果发现中断标志为真时就自己中断挂起。下面的代码中 test 指令是 HotSpot 生成的轮询指令,当需要暂停线程时,虚拟机会把 0x160100 的内存页设置为不可读,线程执行到 test 指令时就会产生一个自陷异常信号,在预先注册的异常处理器中暂停线程实现等待。
1 | 0x01b6d627: call 0x01b2b210 ; OopMap{[60]=Oop off=460} |
安全区域
安全点机制可以保证程序在运行不太长的时间内就会遇到可以进入的 Safepoint,但是当程序不执行时,即程序没有被分配到 CPU 时间,典型的例子就是线程处于 Sleep 或者 Blocked 状态,这个时候线程无法响应虚拟机的中断请求,“走”到安全的地方去中断挂起,虚拟机显然也不大可能等待线程重新被分配 CPU 时间,这种情况就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化,在这个区域中的任意地方开始 GC 都是安全的。在线程执行到安全区域中的代码时,首先标识自己已经进入了安全区域,在这段时间里如果虚拟机要发起 GC,就不用管自己为 Safe Region 状态的线程了。在线程要离开安全区域时,它要检查系统是否已经完成了 GC Roots 的枚举(或者是整个 GC 过程),如果完成了,那么线程就继续执行,否则它就必须等待直到接收到可以离开安全区域的信号为止。
二次标记
即使在可达性分析算法中不可达的对象,也并非一定会消亡,要真正回收一个对象,至少要经历两次标记过程。
在可达性分析后发现对象没有与 GC Roots 相连接的引用链,那么它会被进行第一次标记并进行一次筛选,当对象没有覆盖 finalize()
方法或者该对象的 finalize()
方法已经被虚拟机调用过,则虚拟机将认为该对象可以被回收;否则这个对象将会被放置在一个叫做 F-Queue 的队列中,稍后虚拟机会对该队列进行第二次小规模的标记:通过建立一个低优先级的 Finalizer 线程去执行队列中各个对象的 finalize()
方法。如果对象想要继续存活,就需要在覆盖的 finalize()
方法中重新与任意一个对象建立关联。
回收方法区
方法区也存在垃圾收集,收集的主要内容为废弃常量和无用的类。
回收废弃常量与回收 Java 堆中的对象类似,以常量池中的字面量回收为例,当系统中没有任何一个 String 对象引用常量池中的某个常量,也没有其他地方引用了这个字面量,则在必要的时候此字面量就会被回收。
判断一个类是否是无用的类需要同时满足三个条件:该类所有的实例都已经被回收,加载该类的 ClassLoader 已经被回收,该类对应的 java.lang.Class 对象没有在任何地方被引用(包括反射)。在满足了这三个条件后,还需要虚拟机开启类回收功能,比如 HotSpot 虚拟机就提供了 -Xnoclassgc
参数。
在大量使用反射、动态代理、CGLIB 等字节码框架,动态生成 JSP 以及 OSGi 这类频繁自定义 ClassLoader 的场景都需要虚拟机具有类卸载的功能,以保证永久代不会溢出。
垃圾收集算法
垃圾收集算法的作用是指导收集器以何种方式回收内存,最基础的算法就是标记-清除算法,其他算法都是在它的基础上发展而来。
标记-清除算法
标记-清除(Mark-Sweep)算法是最基础的收集算法,该算法主要分为“标记”和“清除”两个阶段:标记阶段会标记出所有可以回收的对象,标记的过程在生存还是死亡中已经说明了。在标记完成后,虚拟机会统一进行回收。
该算法主要两个缺陷:一个是标记和清除的效率都不高;二是标记并清除之后会产生大量不连续的内存碎片,当碎片太多时可能会导致以后程序运行过程中需要分配大内存对象时因为无法找到足够的连续内存而不得不提前触发一次垃圾收集动作。
复制算法
复制算法是将内存按照容量划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另外一块上,然后把之前使用的那一块内存空间清理掉。这样每次都是对整个半区进行内存回收,内存分配时也不必考虑碎片问题,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。
复制算法的缺点也很明显,比较直观的就是可用内存缩小为原来的一半,并且当对象存活率较高时就需要进行较多的复制操作,效率会降低。同时如果另一块内存空间不足以存储所有存活的对象时,还需要依赖其他内存(比如老年代)进行分配担保。
标记-整理算法
标记-整理(Mark-Compact)算法的标记过程和标记-清除算法相同,但是后续并不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。这种算法克服了标记-清除算法会产生大量碎片的问题。
分代收集算法
当前的商业虚拟机的垃圾收集大都采用分代收集算法,这种算法根据对象生存周期的不同将内存划分为几块,一般是分为新生代和老年代。针对不同代的特点采取不同的收集策略。
在新生代,由于对象存活时间短,所以采用复制算法,每次只需要复制少量存活的对象就可以完成收集。而在老年代,由于对象存活率较高,没有额外空间对它进行分配担保,因此只能采用标记-清除或者标记-整理算法来回收。
内存分代机制
Java 对象根据存活时间被分为新生代(Young Generation)和老年代(Old/Tenured Generation),由于永久代(Permanent Generation)在 JDK 1.8 已经被完全废弃了,故不再讨论。
新生代
在对象被创建时,内存的分配首先发生在新生代(大对象直接被创建在老年代)。大部分的对象在创建后很快就不再使用(IBM 的研究表明,98% 的对象在创建后很快就会走向消亡),因此新生代比较适合采用复制算法进行垃圾收集,这种发生在新生代的 GC 机制被称为 Minor GC。由于新生代中的对象大都朝生夕死,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存划分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次只使用 Eden 和其中的一块 Survivor。
Eden 区是对象首次分配内存的区域,默认大小为整个新生代的 80%。Eden 区是连续的内存区域,所以分配内存极快,当 Eden 区没有足够的空间分配时,虚拟机将会发起一次 Minor GC。Survivor From 区是对象的存活区,默认大小为整个新生代的 10%。Survivor To 区同样是对象的存活区,默认大小为整个新生代的 10%。
在初次分配内存时,如果 Eden 已满,则虚拟机会发起一次 Minor GC,将剩余存活的对象复制到 Survivor From 区,同时清空 Eden 区。在下次 Eden 区满时,再次执行 Minor GC,将存活的对象复制到 Survivor To,然后清空 Eden 和 Survivor From 区。当一个对象在两个存活区之间切换了几次(HotSpot 中默认为 15 次,可以通过参数 -XX:MaxTenuringThreshold
控制。该值只是一个最大值,并不代表一定是这个值)之后,将被复制到老年代。
老年代
一个对象如果在新生代存活了足够长的时间而没被清理掉,那么将会被复制到老年代。如果对象比较大(比如长字符串或大数组),则会在首次分配内存时被分配到老年代。当老年代内存不够时,将会触发 Major GC。老年代发生 GC 的次数一般要比新生代少。
空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果条件成立,那么可以确保进行 Minor GC 是安全的。如果不成立,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象容量的平均大小。如果大于,那么会尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的。如果小于,或者 HandlePromotionFailure 设置为不允许冒险,那么此时会进行一次 Full GC。
我们知道新生代采用复制算法,但是为了内存利用率,只使用了一个 Survivor 空间作为轮换,因此当大量对象在 Minor GC 后仍然存活时,就需要老年代进行分配担保,把 Survivor 中无法容纳的对象放入老年代。老年代进行担保的前提是老年代本身还有容纳这些对象的空间,由于一共会有多少对象存活下来只能在实际完成内存回收之后才能知道,所以在老年代空间不足以容纳新生代所有对象时,只好取之前每次回收晋升到老年代的对象容量的平均大小作为经验值,与老年代剩余的空间进行比较,以此决定是否进行 Full GC 来让老年代腾出更多空间。取平均值进行比较仍然是一个动态的概率,如果某次 Minor GC 后存活的对象突增,远远高于平均值的话,仍然可能会导致担保失败(Handle Promotion Failure),此时只好在失败后重新发起一次 Full GC。虽然设置 HandlePromotionFailure 为允许冒险时可能绕的圈子是最大的,但是大部分情况下还是会将它开启,避免 Full GC 过于频繁。
参考
《深入理解 Java 虚拟机》