文章

JVM 学习笔记 | 垃圾收集器和内存分配策略

1. 首先需要解决的问题:哪些对象已经死亡

1.1 引用计数算法

引用分析算法是这样的:

给每一个对象添加一个引用计数器, 每当有一个地方使用,计数器就加一; 任何时刻计数器为0的对象就是 不可能再被使用的。

缺点:难以解决对象之间的循环引用问题

image.png

1.2 可达性分析算法(商用程序语言的主流实现):

这个算法的基本思想就是通过一系列的”GC ROOTS”的对象作为起始点(注意是一系列,不是某个’对象’),从这个节点开始向下搜索. 搜索所走过的路径称为引用链,当一个对象没有到 “GC ROOTS”的任何引用链相连时(图不可达),则证明此对象是不可用的,属于可回收对象。

image.png

枚举根节点:

在正式的GC之前总是需要进行可达性分析来查找内存中所有存活的对象,以便GC能够正确的回收已经死亡的对象。那么对于一个十分复杂的系统,每次GC的时候都要遍历所有的引用肯定是不现实的。 因为在可达性分析的时候,需要进行Stop The World,程序中的线程需要停止来配合可达性分析。

  1. 保守式gc

在进行GC的时候,会从一些已知的位置(GC Roots)开始扫描内存,扫描到一个数字就判断他是不是可能是指向GC堆中的一个指针(这里会涉及上下边界检查(GC堆的上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是4字节对齐,那么不能被4整除的数字就肯定不是指针),之类的。)。然后一直递归的扫描下去,最后完成可达性分析。这种模糊的判断方法因为无法准确判断一个位置上是否是真的指向GC堆中的指针,所以被命名为保守式GC。这种可达性分析的方式因为不需要准确的判断出一个指针,所以效率快,但是也正因为这种特点,他存在下面两个明显的缺点:

  • 因为是模糊的检查,所以对于一些已经死掉的对象,很可能会被误认为仍有地方引用他们,GC也就自然不会回收他们,从而引起了无用的内存占用,就是典型的占着茅坑不拉屎,造成资源浪费。

  • 由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。有一种办法可以在使用保守式GC的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层“句柄”(handle)在中间,所有引用先指到一个句柄表里,再从句柄表找到实际对象。这样,要移动对象的话,只要修改句柄表里的内容即可。但是这样的话引用的访问速度就降低了。Sun JDK的Classic VM用过这种全handle的设计,但效果实在算不上好。

  1. 准确性gc

与保守式GC相对的就是准确式GC,何为准确式GC? 就是我们准确的知道,某个位置上面是否是指针,对于java来说,就是知道对于某个位置上的数据是什么类型的,这样就可以判断出所有的位置上的数据是不是指向GC堆的引用,包括栈和寄存器里的数据。

java中实现的方式是:从外部记录下类型信息,存成映射表,在HotSpot中把这种映射表称之为OopMap,不同的虚拟机名称可能不一样。

实现这种功能,需要虚拟机的解释器和JIT编译器支持,由他们来生成OopMap。生成这样的映射表一般有两种方式:

  • 每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
  • 为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。

总而言之,GC开始的时候,就通过OopMap这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中哪些位置是引用。

说明:

实际情况是GC Root通常是一组特别管理的指针,这些指针是tracing GC的trace的起点。它们不是对象图里的对象,对象也不可能引用到这些“外部”的指针.

那么哪些节点可以作为GC Root呢?

  1. 虚拟机栈中的引用对象的变量
  2. 方法区的引用对象的常量
  3. 方法区中引用对象的静态变量
  4. 本地方法栈中引用对象的变量

1.3 对象的消亡:

要真正宣布对象的消亡,需要进行两次标记过程:

如果对象第一次发现没有与GC ROOTS 相连, 则进行第一次标记筛选, 筛选条件是判断对象是否有必要执行 finalize()方法。如果对象没有覆盖过这个方法或者这个方法已经执行过,则不再执行这个方法。 否则,虚拟机将调用这个方法,finalize()方法是对象最后逃脱被清理的机会(在方法中与引用用对象建立连接)

当对象第二次因为没有与GC ROOTS 相连而被标记后,他将面临被回收的命运。

说明:

强烈不建议使用这个方法。

1.4 方法区的回收

方法区回收的内容主要包含: 废弃的常量无用的类

类需要满足下面3个条件才算是”无用的类”:

  1. 该类所有的实例都已经被回收,也就是java堆中不存在该类的任何实例
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

2. 对于确定对象已死亡的空间的回收方式: 垃圾收集算法:

2.1 标记-清理算法

算法分为标记清理两个过程

image.png

image.png

主要缺点:

  1. 标记和清除过程效率不高 。

  2. 标记清除之后会产生大量不连续的内存碎片。

2.2 复制算法(新生代主流收集算法)

它将可用内存容量划分为大小相等的两块,每次只使用其中的一块。当这一块用完之后,就将还存活的对象复制到另外一块上面,然后在把已使用过的内存空间一次理掉。这样使得每次都是对其中的一块进行内存回收,不会产生碎片等情况,只要移动堆订的指针,按顺序分配内存即可,实现简单,运行高效。

image.png

image.png

主要缺点: 内存缩小为原来的一半。

优化:

IBM 公司的研究表明, 新生代的对象 98% 是’朝生夕死’的,所以并不需要1:1回收内存,而是将内存分成一个大的 Eden空间,和两个较小的Survivor空间,每次使用 Eden和其中的一块 Survivor空间。

当回收时, 将Eden和其中的一块Survivor中还活着的对象一次性复制到另一个Survivor上,最后清理掉Eden和刚才用过的Survivor空间。

当Survivor没有足够的空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

image.png

2.3 标记-整理算法(适用于老年代的回收算法)

标记操作和“标记-清除”算法一致,后续操作不只是直接清理对象,而是在清理无用对象完成后让所有存活的对象都向一端移动,并更新引用其对象的指针。

image.png

image.png

特点:

主要缺点:在标记-清除的基础上还需进行对象的移动,成本相对较高 优点 : 不会产生内存碎片。

2.4 分代收集算法

根据对象的存活周期的不同将内存划分为几块。一般把java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。 在新生代,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。 而老年代中因为对象存活率高、没有额外空间对他进行分配担保,就必须使用“标记-整理”算法进行回收。

3. 垃圾回收的时机:安全点与安全区域:

3.1 安全点:

程序并不能在任意地方都可以停下来进行GC,只有到达安全点时才能暂停。此外,在安全点中,HotSpot也会开始记录虚拟机的相关信息,如OopMap信息的录入。安全点的选择不能太少,否则GC等待时间太长;也不能太多,否则会增大运行负荷。其选择的原则为“是否具有让程序长时间执行的特征”,如方法调用,循环等等。具体安全点有下面几个:

  • 循环的末尾 (防止大循环的时候一直不进入safepoint,而其他线程在等待它进入safepoint)
  • 方法返回前
  • 调用方法的call之后
  • 抛出异常的位置

安全点暂停线程运行的手段有两种:抢先式中断主动式中断:

  • 抢占式终端:

不需要线程的执行代码主动配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上再暂停。 不过现在的虚拟机几乎没有采用此算法的

  • 主动式终端:

GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时去主动轮询查询此标志,发现中断标志为真时就中断自己挂起。 轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。

3.2 安全区域

产生原因:

安全点机制保证了程序执行时进入GC的问题。 但是对于非执行态下,如线程Sleep或者Block下,由于此时程序(线程)无法响应JVM的中断请求,JVM也不太可能一直等待线程重新获取时间片,此时就需要安全区域了。

运行机制:

在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region。 当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。 当线程要离开Safe Region时,如果整个GC完成,那线程可继续执行,否则它必须等待直到收到可以安全离开Safe Region的信号为止。

4.垃圾收集器

image.png

收集器串行、并行or并发新生代/老年代算法目标适用场景
Serial串行新生代复制算法响应速度优先单CPU环境下的Client模式
Serial Old串行老年代标记-整理响应速度优先单CPU环境下的Client模式、CMS的后备预案
ParNew并行新生代复制算法响应速度优先多CPU环境时在Server模式下与CMS配合
Parallel Scavenge并行新生代复制算法吞吐量优先在后台运算而不需要太多交互的任务
Parallel Old并行老年代标记-整理吞吐量优先在后台运算而不需要太多交互的任务
CMS并发老年代标记-清除响应速度优先集中在互联网站或B/S系统服务端上的Java应用
G1并发both标记-整理+复制算法响应速度优先面向服务端应用,将来替换CMS

Minor GC 和 Full GC

新生代GC (Minor GC) 指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特性,所以Minor GC非常频繁,一般回收速度也比较快。

老年代GC (Full GC) 指发生在老年代的GC,出现了Full GC,经常会伴随至少一次的Minor GC(但非绝对的,在Parallel Scavenge收集器的收集策略里就有直接进行Major GC的策略选择过程)。Major GC的速度一般会比Minor GC慢10倍以上。

4.1 Serial收集器

Serial(串行)收集器是最基本、发展历史最悠久的收集器,它是采用复制算法的新生代收集器,曾经(JDK 1.3.1之前)是虚拟机新生代收集的唯一选择。 它是一个单线程收集器,只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有的工作线程,直至Serial收集器收集结束为止(“Stop The World”)。 这项工作是由虚拟机在后台自动发起和自动完成的,在用户不可见的情况下把用户正常工作的线程全部停掉,这对很多应用来说是难以接收的。

下图展示了Serial 收集器(老年代采用Serial Old收集器)的运行过程:

image.png

在用户的桌面应用场景中,分配给虚拟机管理的内存一般不会很大,收集几十兆甚至一两百兆的新生代(仅仅是新生代使用的内存,桌面应用基本不会再大了),停顿时间完全可以控制在几十毫秒最多一百毫秒以内,只要不频繁发生,这点停顿时间可以接收。所以,Serial收集器对于运行在Client模式下的虚拟机来说是一个很好的选择。

4.2 ParNew 收集器

ParNew收集器就是Serial收集器的多线程版本,它也是一个新生代收集器。 除了使用多线程进行垃圾收集外,其余行为包括Serial收集器可用的所有控制参数、收集算法(复制算法)、Stop The World、对象分配规则、回收策略等与Serial收集器完全相同,两者共用了相当多的代码。

ParNew收集器的工作过程如下图(老年代采用Serial Old收集器):

image.png

ParNew收集器除了使用多线程收集外,其他与Serial收集器相比并无太多创新之处,但它却是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关的重要原因是: 除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作

4.3 Parallel Scavenge 收集器

Parallel Scavenge收集器也是一个并行的多线程新生代收集器,它也使用复制算法。 Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量(Throughput)

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。 而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务

Parallel Scavenge收集器除了会显而易见地提供可以精确控制吞吐量的参数,还提供了一个参数-XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后,就不需要手工指定新生代的大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)。自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别。

另外值得注意的一点是,Parallel Scavenge收集器无法与CMS收集器配合使用,所以在JDK 1.6推出Parallel Old之前,如果新生代选择Parallel Scavenge收集器,老年代只有Serial Old收集器能与之配合使用。

4.4 Serial Old收集器

Serial Old 是 Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”(Mark-Compact)算法。

4.5 Parallel Old收集器

Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。 前面已经提到过,这个收集器是在JDK 1.6中才开始提供的,在此之前,如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old以外别无选择, 所以在Parallel Old诞生以后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量以及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。

Parallel Old收集器的工作流程与Parallel Scavenge相同,这里给出Parallel Scavenge/Parallel Old收集器配合使用的流程图:

image.png

4.6 CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器, 它非常符合那些集中在互联网站或者B/S系统的服务端上的Java应用,这些应用都非常重视服务的响应速度。 从名字上(“Mark Sweep”)就可以看出它是基于“标记-清除”算法实现的。

CMS收集器工作的整个流程分为以下4个步骤

  • 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
  • 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
  • 并发清除(CMS concurrent sweep)

由于整个过程中耗时最长的并发标记并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。 通过下图可以比较清楚地看到CMS收集器的运作步骤中并发和需要停顿的时间:

image.png

优点:

CMS是一款优秀的收集器,它的主要优点在名字上已经体现出来了:并发收集、低停顿,因此CMS收集器也被称为并发低停顿收集器(Concurrent Low Pause Collector)。

缺点:

  • 对CPU资源非常敏感: 其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但会因为占用了一部分线程(或者说CPU资源)而导致应用程序变慢,总吞吐量会降低。CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
  • 无法处理浮动垃圾(Floating Garbage): 可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就被称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  • 标记-清除算法导致的空间碎片 : CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会有大量空间碎片产生。空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。

4.7 G1收集器

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,它是一款面向服务端应用的垃圾收集器,HotSpot开发团队赋予它的使命是(在比较长期的)未来可以替换掉JDK 1.5中发布的CMS收集器。

与其他GC收集器相比,G1具备如下特点:

  • 并行与并发 : G1 能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短“Stop The World”停顿时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。
  • 分代收集 : 与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新创建的对象和已存活一段时间、熬过多次GC的旧对象来获取更好的收集效果。
  • 空间整合 : G1从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。这意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。此特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
  • 可预测的停顿 : 这是G1相对CMS的一大优势,降低停顿时间是G1和CMS共同的关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

横跨整个堆内存 :

在G1之前的其他收集器进行收集的范围都是整个新生代或者老生代,而G1不再是这样。 G1在使用时,Java堆的内存布局与其他收集器有很大区别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,而都是一部分Region(不需要连续)的集合。

建立可预测的时间模型

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个Java堆中进行全区域的垃圾收集。 G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。

避免全堆扫描——Remembered Set

G1把Java堆分为多个Region,就是“化整为零”。但是Region不可能是孤立的,一个对象分配在某个Region中,可以与整个Java堆任意的对象发生引用关系。 在做可达性分析确定对象是否存活的时候,需要扫描整个Java堆才能保证准确性,这显然是对GC效率的极大伤害。

为了避免全堆扫描的发生,虚拟机为G1中每个Region维护了一个与之对应的Remembered Set。 虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过CardTable把相关引用信息记录到被引用对象所属的Region的Remembered Set之中。 当进行内存回收时,在GC根节点的枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。

5. 内存分配与回收策略

image.png

Java技术体系中所提倡的自动内存管理最终可以归结为自动化地解决了两个问题:给对象分配内存以及回收分配给对象的内存

对象的内存分配,往大方向讲,就是在堆上分配,对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓冲,将按线程优先在TLAB上分配。 少数情况下也可能会直接分配在老年代中,分配的规则并不是百分之百固定的,其细节取决于当前使用的是哪一种垃圾收集器组合,还有虚拟机中与内存相关的参数的设置。

参数说明:

-Xms : 最小堆 -Xmx : 最大堆 -Xmn : 新生代大小 -XX:+PrintGCDetails : 打印gc信息 -XX:SurvivorRatio : Eden 与 Survivor 的占比

  1. 对象优先在Eden分配

大多数情况下,对象在新生代Eden区 中分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次新生代GC(Minor GC).

  1. 大对象直接进入老年代

“大对象”是代表需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组.

大对象对虚拟机的内存分配就是一个坏消息(拓展一下:对Java虚拟机而言,比遇到一个大对象更坏的情况时遇到一群“朝生夕灭”的“短命大对象”,编写程序时应当避免此现象产生). 经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。

虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置的对象直接在老年代分配。目的是为了避免在Eden区及两个Survivor区之间发生大量的内存复制

  1. 长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别对象应放在新生代还是老年代。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。

如果对象在Eden出生并经过第一次 Minor GC后仍然存活,并且能被Survivor 容纳的话,将被移动到 Survivor空间中,并且对象年龄设为1。 对象在Survivor 区 每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就会晋升到老年代中。

大体的新生代与老年代内存大小设置都是一样,这里多出现了一种参数:-XX:MaxTenuringThreshold,可通过它来设置对象晋升老年代的年龄阀值。

5.4 空间分配担保

为了能够更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象年龄必须达到MaxTenuringThreshold规定值才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到参数的规定值。

本文由作者按照 CC BY 4.0 进行授权

© . 保留部分权利。

本站采用 Jekyll 主题 Chirpy