Java:从GC底层原理到JVM经典垃圾回收器

Java:从GC底层原理到JVM经典垃圾回收器

虽然一直都在用C++很久没碰Java了,但还是心血来潮决定翻一下《深入理解Java虚拟机》,回味并总结一下Java Virtual Machine的垃圾回收机制,毕竟垃圾回收的核心思想是超越语言的。因为本书的介绍都是基于HotSpot虚拟机,所以下文的虚拟机也只局限于此。
全文均为本人一个字一个字从书中摘录,希望大家尊重下劳动成果。


Java内存模型

Java内存区域分为五大部分,其中方法区和堆区是所有线程共享的;虚拟机栈、本地方法栈和程序计数器是每个线程独占一份的。下面简单概括下:

  1. 方法区:储存常量、静态变量、类型信息等缓存。JDK8之前常被称为(误称)永久代,因为方法区难以回收,管理机制类似于永久代。当然JDK8后废除了永久代的概念。
  2. 堆区:储存绝大多数对象的实例。这片区域是GC的主要+重点回收区域。
  3. 虚拟机栈:每个方法执行时都会创建一个栈帧储存局部变量表、操作数栈、方法出口等。这些方法从执行到结束对应着栈帧在虚拟机栈从出栈到入栈。C++内存模型常提堆内存和栈内存,其中栈内存指的就是虚拟机栈,或者更多情况指的是局部变量表,因为这些程序员显然更关注这些部分。局部变量表储存基本数据类型、对象引用等。
  4. 本地方法栈:虚拟机栈为Java方法服务,本地方法栈为本地Native方法服务,目的是一致的。有些虚拟机如HotSpot就把他们二合一进行管理。
  5. 程序计数器:记录执行的字节码行号。

上述内存模型中,虚拟机栈、本地方法栈和程序计数器都是每个线程私有的,生命周期与线程相同。因此他们的内存分配和回收都有确定性。所以垃圾回收器只需要关心Java堆和方法区这两部分,内存的分配与回收也是特指这两部分。


垃圾回收的理论

如何判断什么时候回收,有两种方法:引用计数算法(另称直接垃圾收集)和可达性分析算法(又被称为“追踪式垃圾收集”或“间接垃圾收集”)。
JVM没有使用引用计数算法管理内存,毕竟无法处理环形引用之类的问题,所以本文(包括我看的那本书)不讨论这个。
诸如Java、C#等用的都是可达性分析算法实现的追踪式垃圾回收器。通过一系列GC Roots的对象作为起点,根据引用关系向下拓展形成引用链。如果一个对象与GC Roots没有任何引用链就被认为是不可达的,可被回收。
GC Roots包含:

  1. 虚拟机栈引用的对象,比如方法中使用参数、局部变量、临时变量。
  2. 静态属性引用的变量,如静态变量。
  3. 常量引用的变量,如字符串常量池。
  4. 同步锁持有的对象。
  5. Java虚拟机内部的引用,如基本数据类型的Class、常驻异常对象(NullPointException、OutOfMemoryError)、系统类加载器等。

市面上绝大多数垃圾回收器除了采用可达性分析算法,还遵循了“分代收集理论”。包括三大部分:

  1. 弱分代假说:大多数对象都是朝生夕灭的。
  2. 强分代假说:熬过越多次垃圾收集过程的对象越难以消亡。
  3. 跨代引用假说:存在相互引用关系的对象倾向于同时生存和消亡。比如有个新生代对象引用了老年代对象,那他就可以得以存活并渐升至老年代,此时跨代引用自然也消除了。

基于以上假说,垃圾回收器通常至少有新生代和老年代两块区域,且不必为了少数跨代引用去扫描整个老年代。通过在新生代上建立一个记忆集,他把老年代划分为若干块,表示出老年代哪一块内存存在跨代引用,只需将那一小片区域的老年代对象加入GC Roots扫描即可。
如果每次垃圾回收时再从GC Roots枚举,“Stop the World”即阻塞用户线程就会特别久。因此引入OopMap的数据结构,类加载完成时就会记录对象引用。之后收集器扫描时就可以直接知道信息无需从GC Roots开始查找。


垃圾回收的类型

根据GC对象进行的分类:

  1. Minor GC/Young GC:新生代收集,只收集新生代的垃圾。
  2. Major GC/Old GC:老年代收集只收集老年代的垃圾。
  3. Mixed GC:混合收集,收集新生代+部分老年代的垃圾,只有G1收集器有这种行为。
  4. Full GC:整堆收集,收集整个堆内存和方法区的垃圾。

具体到怎么回收、处理垃圾,又有三种分类:标记-清除算法、标记-复制算法、标记-整理算法。

标记-清除算法

算法先扫描并标记要回收的对象(或者反过来标记要存活的,毕竟绝大多数对象都会被回收),然后再统一清除垃圾。缺点如下:

  • 要标记和回收的对象太多了,逐个标记+回收效率低。
  • 清除完产生大量内存碎片。
标记-复制算法

绝大多数垃圾回收器使用了这种算法。最早被提出的时候是“半区复制”算法,将内存分为两片相等大小的区域,每次只能使用其中一半。回收时就将存活对象复制到另一片内存上,然后直接回收原先的半片内存。优点如下:

  • 只用复制少数对象,而且复制时从内存一端开始顺序使用不产生碎片。
  • 直接回收半片内存,效率高。

上述方法的缺点是“浪费”了50%内存,当然这个算法可以进一步改进,因为绝大多数对象朝生夕灭,因此可以采用更优化的策略,称为“Appel式回收”。只需要把新生代分为一块大的Eden区和两块小的Survivor区,大小为8:1:1。每次只使用一块Eden区和一块Survivor区,垃圾回收时就把所有存活的对象复制到另一块Survivor区,因此只“浪费”了10%内存。若是某次GC时Survivor容不下所有存活对象,就会依赖其他内存担保(通常是老年代,对象直接进入老年代)。缺点如下:

  • 存活率高时复制效率低。
  • 需要有额外空间担保,所以老年代不能选用这种算法。
标记-整理算法

标记-清除算法太简陋,标记-复制算法不能用于老年代,因此标记-整理算法就是针对老年代的特征而设计的。其基于标记-清除算法,先标记对象(但并不直接清除!),然后将存活对象向内存一段移动(过程会覆盖要清除的对象),最后在将边界区外的内存全部清除。优点如下:

  • 整理后不产生内存碎片。
  • 清除时直接清除一段内存效率高。

缺点:如果需要移动的对象多效率会低。

标记-清除和标记-整理的对比:前者因为内存碎片化,分配新内存时需要借助“内存空闲分配链表”,会更加复杂;而后者回收的时候更复杂。从停顿时间来看,清除算法停顿时间更短或不需要停顿,整理算法相反;从吞吐量角度来看,整理带来的是整个程序的效率提升,吞吐量也会上去。因此关注吞吐量的算法如Parallel Scavenge使用标记-整理算法,关注延迟的CMS使用标记-清除算法。
当然还有一种混合方法,以CMS为例,大多数时候使用标记-清除,当内存碎片化程度太大时在进行一次标记-整理。


经典垃圾回收器

注:以下新生代和老年代垃圾回收器的搭配只考虑最常用的搭配,部分不推荐或JDK9后被废除的不再冗述。

Serial和Serial Old收集器

由名字可知二者是单线程的收集器,Serial回收新生代,配合Serial Old回收老年代。
Serial采用了标记-复制算法,清理时会暂停所有用户线程(Stop The World)。
Serial Old采用了标记-整理算法,清理时会暂停所有用户线程(Stop The World)。

优点:

  • 简单而高效,占用内存开销最小。
  • 适合单核或核心数少的环境。
  • 适合微服务。

缺点:

  • 单线程清理速度慢。
  • Stop The World会带来糟糕的体验。

ParNew和CMS收集器

Par = Parallel,CMS = Concurrent Mark Sweep。parallel:并行,描述的是多个GC线程并行的关系,默认用户线程已阻塞;concurrent:并发,描述的是用户线程和GC线程可以并发运行。

ParNew可理解为是Serial的多线程并行版,也是基于标记-复制算法,除了垃圾回收时会有多个GC线程同时进行其他行为都一样。需要配合CMS回收老年代。
CMS由名字可知是并发运行的,使用的是标记-清除算法 + 标记-整理算法(碎片过多时启动)。具体运作过程:

  1. 初始标记(Stop The World)。
  2. 并发标记。
  3. 重新标记(Stop The World)。
  4. 并发清除。

初始标记很快,不解释。并发标记会和其他用户线程并发运行,耗时长但不需要阻塞用户线程。重新标记会短暂Stop The World,对并发标记期间修改的对象进行重新修正,但这个时间也是很短的。最后并发清除,清除线程也和用户线程一起并发运行互不干涉。

优点:

  1. 低停顿时间,“只有”重新标记会短暂暂停用户线程。
  2. 并发收集,并发清除,效率高。

缺点:

  1. 并发运行占用资源,降低吞吐量。
  2. 无法清理浮动垃圾,有可能导致并发失败(Concurrent Mode Failure)引起Full GC大停顿。因为标记和清除时用户线程还会产生新的垃圾,这部分垃圾就逃脱了这次清理所以被称为浮动垃圾,如果浮动垃圾过多会导致内存不足,暂停全部用户线程进行一次很慢的Full GC。
  3. 不如G1收集器,既生瑜何生亮。

Parallel Scavenge和Parallel Old收集器

Parallel Scavenge和ParNew很像,也是基于标记-复制算法实现的,需要配合Parrallel Old回收老年代。Parrallel Old使用的也是标记-整理算法。二者都可以多线程+并发收集。Parallel Scavenge的目标是提升吞吐量,达到可控制吞吐量的目的;ParNew和CMS则关注降低停顿时间。

Parallel Scavenge提供了参数控制停顿时间和控制吞吐量大小。停顿时间短牺牲了新生代空间和吞吐量。因为停顿减少意味着每次清理的空间小了→新生代可用空间小了→清理次数增加总清理时间变长(当然单次时间减少了)→吞吐量降低。

优缺点同ParNew+CMS。多了一个优点:用户能设定最大停顿时间和期望吞吐量大小。

G1收集器

Garbage First收集器是HotSpot的默认收集器,作为集大成者可以同时兼顾新生代和老年代的内存回收。从JDK9登场起,就击败了ParNew+CMS和Parallel Scavenge+Parallel Old。

之前的算法都严格区分新生代和老年代,因此只能进行Minor GC、Major GC或Full GC。而G1创新性的实现了Mixed GC,面向堆内存的任何部分来进行回收。G1不再坚持固定大小和固定数量的分代区域划分,而是将连续的Java堆划分为多个大小独立的Region区域,每个Region都能储存新生代+老年代对象。每次回收都以单个Region作为最小单元,收集器会跟踪各个Region的回收“价值”,即回收得到的空间大小和回收时间,根据每个Region的价值在后台维护一个优先级列表,然后根据在用户设定的最大停顿时间****内优先回收高价值区域(和Parallel Scavenge一样停顿时间可控!)。另外还有个Humongous区用来存放大于半个Region空间的大对象,G1通常把Humongous当做老年代对待。具体运作过程:

  1. 初始标记(Stop The World)。
  2. 并发标记。
  3. 最终标记(Stop The World),等同于ParNew的重新标记。
  4. 筛选回收(Stop The World)。

G1较大变化的就是筛选回收这一步,更新各Region的价值,按照用户期望停顿时间指定回收计划,然后把决定回收的Region内的存活对象复制到空Region中,然后清理掉整个旧Region。这个过程设计存活对象的移动因此要暂停用户线程,由多个GC线程并行参与。局部上G1是标记-复制算法(新旧Region间的复制),但从整体来看又是基于标记-整理算法不会产生内存碎片。
可见G1除了并发标记其他阶段都要暂停用户线程,因此他的强项不在于低延迟(ParNew+CMS的强项),而在于在延迟可控的情况下获得尽可能高的吞吐量(Parallel Scavenge + Parallel Old哭晕)。当然停顿时间不能降得特别低,不然每次只能回收很少的内存,最终会触发Full GC反而降低性能。

优点:

  1. 以Region为单位的回收,不会产生内存碎片。
  2. 用户能设定最大收集停顿时间。
  3. 吞吐量大。
  4. 大内存应用下有优势。

缺点:

  1. 执行负载高(CPU)。
  2. 占用更多的内存。对于跨Region引用的问题,每个Region都会维护自己的记忆集,基于哈希表实现双向卡表结构,总消耗大约占Java堆10%~20%的内存。相比之下CMS只用维护一份卡表(老年代到新生代的引用,反过来不需要)。

注:CMS严格区分新生代和老年代,且只有CMS有单独针对老年代的回收。回收通常回收新生代所以只需要一份老年代到新生代的引用的卡表,避免回收部分被引用的新生代。相反新生代朝生夕灭而且变化频繁,如果维护新生代到老年代的引用成本高,且老年代回收次数少,真发生Major GC/Old GC时再去把整个新生代添加进GC Roots进行扫描即可。

ZGC收集器

Z Garbage Collector,低延迟垃圾回收器。
ZGC在G1的Region分区基础上,将全部回收过程变成了并发执行,因此延迟极大极大极大降低了。为了实现并发整理,采用了标志性的染色指针技术,64位系统下,实际只能使用46位指针进行寻址(Linux环境46为128TB,Windows为44位16TB),ZGC将高4位指针提取出来进行三色标记,所以只剩42位即最多支持4TB内存,受限于篇幅此处不展开介绍。运行过程如下:

  1. 并发标记。
  2. 并发预备重分配,扫描全部Region统计得出本次要回收哪些Region。
  3. 并发重分配,将存活对象复制到新Region,为每个要回收的Region维护一个转发表,然后回收旧Region。
  4. 并发重映射,修正堆中指向旧对象的所有引用,指向新地址。

ZGC的并发预备重分配过程因为扫描了全部Region,因此不用像G1维护一个庞大的记忆集。

并发重分配时维护一个转发表,记录旧对象到新对象的转向关系,即某个线程按照原本旧的地址寻址,但旧Region已被清空,此时依赖转发表可以找到新的对象,同时还会更新该对象的地址(染色指针的“自愈”能力)。得益于转发表的存在,只要旧Region的存活对象复制完毕就可以直接清空(与用户线程并发!),不用像G1那样Stop The World才能复制+清空。

并发重映射过程其实与并发标记合并,因为染色指针有自愈能力,所以不用急着修正引用。同时并发标记本身就要遍历全部堆中对象,标记时顺便修正旧引用即可。

优点:

  • 无敌。
  • 运行负担很小,因为无记忆集(G1通过写屏障维护记忆集),无分代,无跨代引用卡表。
  • 延迟极少,停顿极低。
  • 高吞吐量。

缺点:

  • 最大仅支持4TB内存。
  • 对象分配速率不能太高。因为每一次完整的并发GC都是漫长的周期,在这过程产生的新对象未必能被标记到,形成浮动垃圾必须等到下一次清理,因此如果大量对象快速分配就无了(而且他们其实都是朝生夕灭的,却因为回收周期长存活时间久)。
  • JDK11诞生,普及慢。

Epsilon收集器

Episilon是希腊字母ε,是不进行任何垃圾回收的回收器,于JDK11推出。用于给运行敷在绩效,运行时间很短的应用服务。


总结

新生代垃圾回收器都使用标记-复制算法,老年代垃圾回收器都使用标记-整理算法(除了CMS是标记-清除+标记-整理)。G1和ZGC使用Region分代方法,前者需要停顿收集,后者全程并发。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值