引用计数法与可达性分析
引用计数法(reference counting)。为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。法处理循环引用对象。
目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法,这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。GC root可以暂时理解为由堆外指向堆内的引用,一般而言,GC Roots 包括(但不限于)如下几种:
- 活着的线程和虚拟机栈 (栈桢中的本地变量表) 中的引用的对象
- 方法区中的类静态属性引用的对象
- 方法区中的常量引用的对象
- 本地方法栈中 JNI 的引用的对象
- 所有 synchronized 锁住的对象引用,即用于同步的监控对象
- finalize 执行队列中的对象
可达性分析算法也有一定问题,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。
对象的复活
当一个对象第一次被标记为死亡时,并不会立即被销毁,而是会将其放置在一个 finalize 队列中,JVM 会使用一个优先级较低的线程执行这些对象的 finalize 函数,如果一个对象在 finalize 函数的执行过程中将自身的应用挂载到了任意 GC Root 可以访问到的地方,那么就不会销毁该对象,这个过程,我们称之为对象的 “复活”。不过通过 finalize 复活的这套机制只能使用一次,下次该对象再次被标记为死亡时,就不会再执行 finalize 函数了。
Stop-the-world 以及安全点
Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收,暂停时间(GC pause)。
Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。
垃圾回收的三种方式
标记清除法
第一种是标记清除法,即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

有两个缺点。
- 会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
- 分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointer bumping)来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项,来查找能够放入新建对象的空闲内存。
标记复制算法
即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即复制算法会导致内存折半,只能使用一半内存,而且如果存活对象较多时,要复制大量对象,对效率也有影响。

标记压缩算法
复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情況是大部分对象都是存活对象。如果依然使用复制算法,由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。标记压缩算法是一种老年代的回收算法。即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
在进行压缩的过程中也要进行计算,才能规划好压缩方案,而对于那些存活比率较低的新生代对象来说,规划压缩方案的消耗可能还没有直接复制来得快,所以标记复制算法和标记压缩算法分别代表了空间换时间和时间换空间思想,并分别活跃在各自擅长的场景中。


分代算法
分代算法将内存根据对象的特点分成了几块,根据每块内存的区间对象特点,使用不同的回收算法,来提高回收的效率。一般来说,Java 虚拟机会将所有的新建对象都放入称为新生代的内存区域,新生代的特点是对象朝生夕灭,大约 90% 的新建对象会被很快回收,因此,新生代比较适合使用复制算法。当一个对象经过几次回收后依然存活,对象就会被放入称为老年代的内存空间,此外当 survivor 区内存紧张时,也会将一些对象 “破格” 提升到老年代。在老年代中,几乎所有的对象都是经过几次垃圾回收后依然得以存活的。因此,可以认为这些对象在一段时期内,甚至在应用程序的整个生命周期中,将是常驻内存的。如果依然使用复制算法回收老年代,将需要复制大量对象。再加上老年代的回收性价比也要低于新生代,因此这种做法是不可取的。根据分代的思想,可以对老年代的回收使用与新生代不同的标记压缩或标记清除算法,以提高垃圾回收效率。
Java 虚拟机的堆划分
Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。

当我们调用 new 指令时,它会在 Eden 区中划出一块作为存储对象的内存。为了防止两个线程同时申请一块内存,jvm的解决方法是,预留一块内存,TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。每次分配所需的内存,如果不够的话,就申请新的TLAB。
当Eden区耗尽了,就会触发一次Minor GC,存活下来的对象,则会被送到 Survivor 区。新生代共有两个 Survivor 区,我们分别用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。
当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的。
Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
JVM 年轻代到年老代的晋升过程的判断条件是什么呢
1:部分对象会在From和To区域中复制来复制去,如此交换15次(由JVM参数MaxTenuringThreshold决定,这个参数默认是15),最终如果还是存活,就存入到老年代。
2:如果对象的大小大于Eden的二分之一会直接分配在old,如果old也分配不下,会做一次majorGC,如果小于eden的一半但是没有足够的空间,就进行minorgc也就是新生代GC。
3:minor gc后,survivor仍然放不下,则放到老年代
4:动态年龄判断 ,大于等于某个年龄的对象超过了survivor空间一半 ,大于等于某个年龄的对象直接进入老年代
接下来有一个问题就是,老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots。
卡表
卡表(Card Table)将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。在新生代 GC 时,可以不用花大量时间扫描所有老年代对象,来确定每一个对象的引用关系,而可以先扫描卡表,只有当卡表的标记位为 1 时,才需要扫描给定区域的老年代对象,而卡表位为 0 的所在区域的老年代对象,一定不含有新生代对象的引用。如下图所示,卡表中每一位表示老年代 4KB 的空间,卡表记录为 0 的老年代区域没有任何对象指向新生代,只有卡表位为 1 的区域才有对象包含新生代引用,因此在新生代 GC 时,只需要扫描卡表位为 1 所在的老年代空间。

在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。
由于 Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。
串行垃圾回收器Serial GC
Serial GC,它是最古老的垃圾收集器,“Serial”体现在其收集工作是单线程的,并且在进行垃圾收集过程中,会进入“Stop-The-World”状态。当然,其单线程设计也意味着精简的 GC 实现,无需维护复杂的数据结构,初始化也简单。从年代的角度,
- 工作在新生代时:使用标记复制算法
- 工作在老年代时:使用标记压缩算法
串行收集器采用单线程stop-the-world的方式进行收集。当内存不足时,串行GC设置停顿标识,待所有线程都进入安全点(Safepoint)时,应用线程暂停,串行GC开始工作,采用单线程方式回收空间并整理内存。单线程也意味着复杂度更低、占用内存更少,但同时也意味着不能有效利用多核优势。事实上,串行收集器特别适合堆内存不高、单核甚至双核CPU的场合。
并行垃圾回收器
ParNew GC
是个新生代 GC 实现,它实际是 Serial GC 的多线程版本,最常见的应用场景是配合老年代的 CMS GC 工作。
ParNew 回收器也是独占式的回收器,在收集过程中,应用程序会全部暂停。但由于并行回收器使用多线程进行垃圾回收,因此,在并发能力比较强的 CPU 上,它产生的停顿时间要短于串行回收器,而在单 CPU 或者并发能力较弱的系统中,并行回收器的效果不会比串行回收器好,由于多线程的压力,它的实际表现很可能比串行回收器差。
并行收集器与串行收集器工作模式相似,都是stop-the-world方式,只是暂停时并行地进行垃圾收集。年轻代采用复制算法,老年代采用标记-整理,在回收的同时还会对内存进行压缩。关注吞吐量主要指年轻代的Parallel Scavenge收集器,通过两个目标参数-XX:MaxGCPauseMills和-XX:GCTimeRatio,调整新生代空间大小,来降低GC触发的频率。并行收集器适合对吞吐量要求远远高于延迟要求的场景,并且在满足最差延时的情况下,并行收集器将提供最佳的吞吐量。
新生代 ParallelGC
新生代 ParallelGC也是并行垃圾回收器的一种,从表面上看,它和 ParNew 回收器一样,都是多线程、独占式的收集器。但是,ParallelGC 回收器有一个重要的特点:它非常关注系统的吞吐量。
ParallelGC 回收器提供了两个重要的参数用于控制系统的吞吐量。
- -XX:MaxGCPauseMillis: 设置最大垃圾收集停顿时间。它的值是一个大于 0 的整数。ParallelGC 在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMillis 以内。如果希望减少停顿时间,而把这个值设得很小,为了达到预期的停顿时间,虚拟机可能会使用一个较小的堆 (一个小堆比一个大堆回收快), 而这将导致垃圾回收变得很频繁,从而增加了垃圾回收总时间,降低了吞吐量。
- -XX:GCTimeRatio: 设置呑吐量大小。它的值是一个 0 到 100 之间的整数。假设 GCTimeRatio 的值为 n, 那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。比如 GCTimeRatio 等于 19, 则系统用于垃圾收集的时间不超过
1/(1+19)=5%。默认情况下,它的取值是 99, 即不超过1/(1+99)=1%的时间用于垃圾收集。
除此以外,ParallelGC 回收器与 ParNew 回收器另一个不同之处在于它还支持一种自适应的 GC 调节策略。使用 -XX:+UseAdaptiveSizePolicy 可以打开自适应 GC 策略。在这种模式下,新生代的大小、eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。在手工调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标吞吐量 (GCTimeRatio) 和停顿时间 (MaxGCPauseMillis), 让虚拟机自己完成调优工作。
老年代 ParallelOldGC
ParallelOldGC 回收器使用标记压缩算法。和新生代 ParallelGC 回收器一样,它也是一种关注吞吐量的收集器。从名字上看,它在 ParallelGC 中间插入了 Old, 表示这是一个应用于老年代的回收器,并且和 ParallelGC 新生代回收器搭配使用。
CMS 回收器
CMS 是只针对老年代的回收器所以通常和 ParNew 配合使用。设计目标是尽量减少停顿时间,CMS 能让(部分)垃圾回收过程和应用程序并发执行。它尽可能的将一些花费时间较长的引用扫描工作隔离出来,让这部分工作和应用程序并发执行,然后在一个关键的时间点将应用程序暂停,检查引用扫描结果的正确性并进行校正,在垃圾回收线程确保所有需要保留的对象都记录在案没有漏下的时候,再放行应用程序,之后的清理工作也可以使用并发的方式进行,因为 CMS 使用的是标记清除算法,清除过程中不会影响到现存对象的使用。这一点对于 Web 等反应时间敏感的应用非常重要,一直到今天,仍然有很多系统使用 CMS GC。但是,CMS 采用的标记 - 清除算法,存在着内存碎片化问题,所以难以避免在长时间运行等情况下发生 full GC,导致恶劣的停顿。另外,既然强调了并发(Concurrent),CMS 会占用更多 CPU 资源,并和用户线程争抢。
- 初始标记
初始标记阶段是第一个 STW 的阶段,这个阶段的目标是从 GC Root 出发,查找到所有由 GC Root 和年轻代存活对象直接引用的老年代对象。注意,这里我们只是扫描直接引用的老年代对象,而不进行更深入的老年代扫描。
-
并发标记
根据初始标记的对象,标记出所有存活的老年代对象,在这个阶段垃圾回收器和应用程序是可以并发执行的,也就是说这里扫描的结果并不一定是完全正确的,在这个期间可能引用关系发生了改变,而我们在并发标记阶段中可能并不能感知到这种变化。
-
预清理
在进行并发标记时可能对象的引用关系已经发生了改变,很显然我们要重新检查这些对象的最新引用关系,但是重新扫描又很浪费资源。所以,JVM 将老年代的内存划分为了多个区域(我们称之为 Card),每当一个区域中的对象发生了引用关系改变时,就将该区域标记为 “脏”,这个机制是通过写屏障(write barrier)实现的,可以把它理解成一个切面,在进行赋值 A.field = B 之前,标记目标对象 A 所处的内存区为 “脏”。们只需要重新扫描所有的 “脏” 区域,就能修正存活对象的标记了,此外预清理阶段还会做一些别的准备工作。
预清理也不是STW的,所以还是会存在不完全的情况,这时候就需要重新标记。
-
重新标记
重新标记阶段是另一个 STW 阶段。因为我们在前面的几个阶段的扫描工作都是基于初始标记的老年代可直接访达的对象,而之后的并发环节中可能 GC Root 又引入了新的老年代对象,这些是我们没有进行扫描的,而且就像前面所说的还会有一些引用关系变动而出现的 “脏区域 “,所以我们要重新进行一次标记的修正,这个过程和初始标记一样从 GC Root 出发,扫描整个新生代内存,进而找出所有存活新生代对象和 GC Root 关联到的老年代对象。之前我们只记录了老年代的 “脏” 内存区,并没有记录新生代的脏内存区,这主要是因为新生代对象关系变化比较大,很可能大部分对象所处的内存区都是脏的,所以 CMS 索性直接扫描新生代的全部对象关系。
在预清理阶段实际上会做一件和 -XX:+CMSScavengeBeforeRemark 参数背道而驰的事,它除了进行正常的清理准备和检查以外,可能会尝试控制一次停顿时间,让重新标记阶段尽量避开年轻代回收,这是为什么呢?因为 CMS 回收器是以减少停顿时间作为目标的,如果刚进行完一次年轻代 GC 又紧接着进行 STW 的重新标记过程,可能会造成应用程序停顿太长时间。为了避免这种情况,预处理时,会刻意等待一次新生代 GC 的发生,然后根据历史性能数据预测下一次新生代 GC 可能发生的时间,然后在当前时间和预测时间的中间时刻,进行重新标记。这样,从最大程度上避免新生代 GC 和重新标记重合,尽可能减少应用程序停顿时间。
-
并发清除
经过重新标记之后,就能开始移除未使用的对象并回收相应的内存空间了,这个过程可以和应用程序并发进行。
- 并发重置
我们需要重置 CMS 算法使用到的内部数据,然后准备下一次垃圾回收过程。
CMS 是一个基于标记清除算法的回收器。而标记清除算法将会造成大量内存碎片,离散的可用空间无法分配较大的对象。就如下图所示。为了解决这个问题,CMS 回收器提供了几个用于内存压缩整理的参数。-XX:+UseCMSCompactAtFullCollection 开关可以使 CMS 在域圾收集完成后,进行一次内存碎片整理,内存碎片的整理不是并发进行的。-XX:CMSFullGCsBeforeCompaction 参数可以用于设定进行多少次 CMS 回收后,进行一次内存压缩。
5万+

被折叠的 条评论
为什么被折叠?



