环己三烯的冬眠舱

天天网抑云,偶尔读点书。

0%

JVM 垃圾收集器

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

哪些对象需要回收?

引用计数算法

在对象中添加一个引用计数器用于记录该对象的引用数量。当某对象的引用数量归零时就可以回收这个对象了。

优势:实现简单,判定效率高。简单到面试手撕代码时会考“使用引用计数算法来实现一个C++智能指针”

劣势:有大量的例外情况需要考虑,例如两个对象互相引用时引用计数器就永远不会为0,导致这两个对象永远不会被回收。

可达性分析算法

Java的内存管理系统是通过可达性分析算法来判定对象是否存活的。该算法从一组被称为“GC Roots”的根对象作为起始节点集开始,顺着引用链向下搜索,如果某对象与GC Roots之间没有任何引用链相连,说明该对象不可能再被使用,即可以被回收。

GC Roots主要包括两栈两方法:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象

和由具体的垃圾收集器临时性加入的其他对象。

分代收集理论

基础假说

分代收集理论建立在两个假说之上:

  1. 弱分代假说:绝大多数对象都是朝生夕灭的。(IBM公司实测,有98%的对象熬不过第一轮GC)
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

根据以上假说,收集器设计者一般会将Java堆划分为新生代和老年代,对象在新生代区域创建,若其在若干次GC后依然存活便可晋升至老年代。每次GC可以只对新生代进行回收,也可只对老年代进行回收,以此划分为只回收新生代的“Minor GC”,只回收老年代的“Major GC”和回收整个Java堆的“Full GC”。

由于不同区域的对象有不同的特征,所以可以针对不同区域设计针对性的垃圾收集算法。

PS:Major GC只回收老年代的说法存疑,因为很多Major GC是由Minor GC触发的,所以Major GC通常跟Full GC是等价的。但是个人觉得单从分类上还是可以这么说,不用太钻牛角尖。

PPS:还有一种Mixed GC,目标是收集整个新生代和部分老年代,目前只有G1收集器会有这种行为。

跨代引用

假如现在要进行一次Minor GC,由于新生代的对象可能会被老年代中的对象引用,所以GC选择的GC Roots除了新生代本身的GC Roots外,还需要扫描整个老年代中的对象,来确保可达性分析结果的正确性,这就造成了很大的性能负担。于是引入第三条经验法则:

  1. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

如果某个新生代对象被老年代对象引用,由于老年代对象不容易被回收,所以该新生代对象也很容易就能进入老年代,这样就不存在跨代引用了。

所以我们没必要为了少量的跨代引用去扫描整个老年代,只需要在新生代上维护一个全局的“记忆集”,记忆集会把老年代划分为若干小块,用于标记老年代的哪些块的内存存在跨代引用,此后在发生Minor GC的时候,只需要把被标记的老年代内存块中的对象加入到GC Roots里扫描就可以了。

垃圾收集算法

标记-清除算法

顾名思义,标记-清除算法有标记和清除两个阶段。首先标记出所有需要回收(或不需要回收)的对象,然后回收被标记(或未被标记)的对象。

缺点:①执行效率不稳定,如果有大量需要清除的对象就会花很多时间。②简单清除之后会产生大量不连续的内存碎片,时间久了会影响较大对象的内存分配。

标记-复制算法

标记-复制算法将内存按容量划分为大小相等的两块,每次只使用其中一块。在GC时,把还存活的对象复制到另一块,然后一次性清理掉一整块内存。

优点:①只需要复制存活对象,在存活对象较少时效率比较高。所以适合用于回收新生代。②不用考虑内存碎片问题。

缺点:浪费了一半内存。

优化:不需要按1:1的比例划分,如Appel式回收。

Appel式回收把新生代分为一块较大的Eden(伊甸园)空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor空间。GC时,将Eden和Survivor中仍然存活的对象复制到空的Survivor空间里,然后直接清理Eden和刚用完的Survivor空间。HotSpot虚拟机默认Eden和两块Survivor的空间比例是8:1:1,即每次新生代内存中可用内存空间为整个新生代内存容量的90%。这样的内存浪费就比较可以接受了。

如果Survivor空间不足以容纳一次Minor GC后存活的所有对象,那这些对象就全部直接进入老年代,也就是说此时新生代将不包括任何存活对象。如果老年代空间也不够用了,虚拟机就会触发一次Major GC以尝试释放内存。

标记-整理算法

标记-整理算法在标记完毕后,让所有存活的对象向内存空间的一端移动,然后清除掉边界以外的内存。

优点:①不存在内存浪费,也不用额外空间担保,适合用于老年代。②不会产生内存碎片。

缺点:①老年代往往会有大量对象存活,整理时需要移动这些存活对象,必须全程暂停用户程序(直到后来发明了移动时不用暂停的垃圾收集器)。

对于针对老年代的垃圾回收,标记-清除算法只需要清除少量的非存活对象,不需要长时间暂停用户程序,但会带来大量的内存碎片,采用该算法可以带来较低的时延,但同时也会有较低的吞吐量;标记-整理算法需要移动大量的存活对象,所以需要更久地暂停用户程序,但可以消除内存碎片,采用该算法会有较高的时延,但同时也会有较高的吞吐量。当然,也可以把两者结合起来,在平时多数时间使用标记-清除算法,直到内存碎片太多太碎,影响到对象分配时,再进行一次标记-整理。

一些经典的垃圾收集器

Serial收集器

顾名思义,Serial收集器是一个单线程工作的收集器。不仅是本身单线程,还得在工作时暂停所有其他用户线程。

Serial收集器在新生代采用标记-复制算法,有Serial Old收集器作为老年代收集器与之配套。

虽然要打断用户线程,但是对于内存资源受限的环境,它足够简单而高效,跟其他的花里胡哨的高级收集器相比,它的额外内存消耗最少,也没有线程交互的开销。针对少量的新生代垃圾,Serial收集器的停顿时间完全可控。所以Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

Serial Old收集器

Serial Old收集器采用标记-整理算法,是Serial收集器的老年代版本。

ParNew收集器

ParNew收集器是Serial收集器的多线程并行版本。

Parallel Scavenge收集器

Parallel Scavenge收集器的目标是达到一个可控制的吞吐量。吞吐量指的是运行用户代码的时间与处理器总消耗时间的比值。它可以通过参数-XX:MaxGCPauseMillis来设置内存回收允许的时间。垃圾收集停顿时间缩短的代价是牺牲新生代空间(收集300MB肯定比收集500MB快)和吞吐量(少量多次收集,总收集时间变长,吞吐量就降低了)换的。

Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本。

CMS收集器

CMS收集器全名叫Concurrent Mark Sweep,顾名思义就是可以并发地完成标记,而且是标记-清除算法。它的设计目标是获取最短的回收停顿时间。整个回收过程分为四个步骤:

  1. 初始标记(仅仅只是标记一下GC Roots能直接关联到的对象,虽然需要停顿但是速度很快)
  2. 并发标记
  3. 重新标记(修正并发标记阶段用户修改的引用关系,需要停顿,但时间依然远比并发标记短)
  4. 并发清除(直接清除标记为死亡的对象,不需要移动存活对象,所以可与用户线程并发)

优点:大大降低了GC时对用户线程的停顿时间。

缺点:①会占用一部分CPU核心,在CPU核心数量不多的设备上运行时会严重影响用户进程。②在清理垃圾的同时用户线程依然在运行,还可能需要更多的内存,所以不能在内存彻底耗尽时才触发GC,需要留有余量。如果余量不足,就会出现并发失败,JVM只能改用停顿时间较长的Serial Old来重新进行GC。③会产生内存碎片,需要定期整理。

Garbage First收集器

Garbage First收集器简称G1收集器,是一款主要面向服务端应用的垃圾收集器。G1收集器将内存空间划分为若干个大小相等的Region,每个Region都可以当成新生代的Eden空间、Survivor空间或者老年代空间使用。还有一类专门用于处理大对象的Humongous Region,G1一般将这种Region视作老年代的一部分处理。通过这种设计,G1收集器可以避免每次GC都回收像整个新生代这么大的内存空间,而是选择最有性价比的Region进行回收,使得停顿时间可控。回收时,G1收集器采用标记-复制算法,整个回收过程大致可划分为四个步骤:

  1. 初始标记(需要停顿
  2. 并发标记
  3. 最终标记(需要停顿
  4. 筛选回收(把选中Region的存活对象复制到空Region里,然后清理掉整个旧Region。由于涉及存活对象的移动,所以需要停顿。)

优点:①可以指定最大停顿时间,在不同应用场景中取得吞吐量和延迟的最佳平衡。②不会产生内存碎片。

缺点:①需要占用更多额外内存来维护跨Region引用的关系。②存在和CMS收集器一样的并发失败问题。

Shenandoah收集器

Shenandoah收集器像是G1收集的下一代继承者,二者之间共享了一部分实现代码。Shenandoah收集器的目标是实现一种能在任何堆内存大小下都可以把垃圾收集的停顿时间限制在十毫秒以内。

Shenandoah收集器也是使用基于Region的堆内存布局,也是优先处理回收价值最大的Region,但是Shenandoah收集器支持并发的整理算法,默认不使用分代收集,且改用连接矩阵来代替G1中耗费大量内存和计算资源去维护的记忆集。

Shenandoah收集器的工作过程大致可划分为九个阶段:

  • 初始标记(需要停顿
  • 并发标记
  • 最终标记(需要停顿
  • 并发清理(清理整个Region里只有死亡对象的Region)
  • 并发回收(通过读屏障和转发指针来实现可与用户线程并发的对象移动)
  • 初始引用更新(确保并发回收阶段中的回收线程均已完成对象移动任务,需要短暂停顿
  • 并发引用更新
  • 最终引用更新(修正GC Roots中的引用,需要停顿
  • 并发清理(清理掉所有完成复制的Region)

为了实现收集器对存活对象的复制和用户线程对存活对象的访问这两件事的并发,Shenandoah回收器在对象头引入了转发指针“Brooks Pointer”。在不处于并发移动的状态下,这个转发指针指向对象自己。

但是如果不做任何保护措施的话,转发指针可能会发生并发问题。例如:

  1. 收集器线程复制了新的对象副本
  2. 用户线程更新对象的某个字段
  3. 收集器线程更新转发指针的引用值为新副本地址

如果事件2在事件1和3之间发生的话,线程2的修改就仅仅在旧对象上,无法对新对象生效。而Shenandoah收集器则是通过CAS原子操作来保证并发时对象的访问正确性的。

CAS:Compare And Swap,解决多线程并行情况下使用锁造成性能损耗的一种机制,CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可。

这块内容存疑,作者没说明CAS具体是在什么操作上应用的。如果是在更新转发指针时使用CAS的话,根据我的理解,CAS的作用是可以在多个线程同时修改一个变量的时候保证并发安全,但是这里用户线程只是读取转发指针的引用值,只有收集器线程在修改转发指针的引用值。这么说来,CAS应该不能处理前面提到的那个问题。在这块内容上还有些细节作者在书中没有细说,想了一个下午也没想明白,暂且搁置。如果有人偶然看到这里,欢迎与我讨论。

优点:并发收集,可以实现很低的延迟。

缺点:需要使用读写屏障,吞吐量会受到影响。

ZGC收集器

ZGC收集器的设计目标和Shenandoah收集器是高度相似的,但实现思路却差异显著。

ZGC也是采用基于Region的堆内存布局,分为小型、中型、大型三类。

ZGC引入了“染色指针”。染色指针是一种直接将少量额外信息存储在指针上的技术。因为在64位系统中,理论上可以访问2^64B = 16EB的内存,但由于需求、性能和成本等考虑,现有的硬件架构和操作系统不会使用全部64位来寻址内存,例如Linux则使用了其中的46位。而ZGC的染色指针技术则从这46位里再抽了高4位出来存储四个标志信息,通过这四个标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入重分配集等状态。由于占用了这4位,所以ZGC能管理的内存不能超过2^42B = 4TB。染色指针使用虚拟内存映射来寻址,保证无论染色位的情况如何,都能把指针映射到相同的物理内存。

ZGC收集器的工作过程大致可划分为四个大阶段,四个大阶段都是可并发执行的,只有两个阶段中间会存在短暂的停顿,如类似G1的初始标记和最终标记。

  1. 并发标记(与G1和Shenandoah的标记类似,不过是通过染色指针来代替直接在对象上标记)
  2. 并发预备重分配(根据标记结和“特定的查询条件”统计得出本次收集过程要清理哪些Region,组成重分配集)
  3. 并发重分配(把重分配集中的存活对象复制到新的Region中)
  4. 并发重映射(修正整个堆中指向旧对象的所有引用)

ZGC重分配集和G1回收集的区别:G1收集器实现了分代回收功能,回收行为可能局限于新生代或老年代,选出的回收集也就是从局部选出的。由于分代,所以需要维护记忆集来处理跨代引用问题。ZGC收集器没有实现分代回收功能,不需要维护记忆集,省下来的时间用来进行全堆扫描了,重分配集也是从整个堆选出来的。

在并发重分配阶段,ZGC会为每个旧Region维护一个转发表,记录旧对象和新对象的转发关系。用户线程可以直接从染色指针上看出旧对象是否在重分配集里,如果用户线程发现旧对象在重分配集里,就会立即根据转发表将访问转发到新对象上(通过内存屏障实现),同时修正更新该引用的值,使其直接指向新对象,这种行为称作指针的“自愈”

由于指针可以自愈,并发重映射阶段并不需要迫切地被执行,清理旧对象的引用主要是为了减少自愈前唯一的那一次转发,以及清理完毕后可以释放转发表。因此,ZGC把并发重映射阶段的工作合并到了下一次GC的并发标记阶段里完成,两次操作只需要在同一次遍历对象图中完成即可。

有的没的

没事听点歌(Lynyrd Skynyrd - Free Bird)