抱歉,您的浏览器无法访问本站
本页面需要浏览器支持(启用)JavaScript
了解详情 >

JVM面试题-垃圾回收

查看源图像

运行时数据区都包含什么

img

虚拟机的基础面试题

  1. 程序计数器
  2. Java 虚拟机栈
  3. 本地方法栈
  4. Java 堆
  5. 方法区

程序计数器

程序计数器是线程私有的,并且是JVM中唯一不会溢出的区域,用来保存线程切换时的执行行数

​ 程序计数器(Program Counter Register)是一块较小的内存空间,可以看作是当前线程所执行字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器完成。

​ 由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式实现的。为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各线程之间的计数器互不影响,独立存储。

  1. 如果线程正在执行的是一个 Java 方法,计数器记录的是正在执行的虚拟机字节码指令的地址;
  2. 如果正在执行的是 Native 方法,这个计数器的值为空。

程序计数器是唯一一个没有规定任何 OutOfMemoryError 的区域

虚拟机栈

Java 虚拟机栈(Java Virtual Machine Stacks)是线程私有的,生命周期与线程相同。

​ 虚拟机栈描述的是 Java 方法执行的内存模型:每个方法被执行的时候都会创建一个栈帧(Stack Frame),存储

​ 每一个方法被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程

组成部分
  • 局部变量表
  • 操作数栈
  • 动态链接
  • 方法出口
异常情况
  • StackOverflowError:线程请求的栈深度大于虚拟机所允许的深度
  • OutOfMemoryError:虚拟机栈扩展到无法申请足够的内存时

本地方法栈

本地方法栈(Native Method Stacks)为虚拟机使用到的 Native 方法服务

Java 堆

Java 堆(Java Heap)是 Java 虚拟机中内存最大的一块。Java 堆在虚拟机启动时创建,被所有线程共享。

​ 作用:存放对象实例。垃圾收集器主要管理的就是 Java 堆。Java 堆在物理上可以不连续,只要逻辑上连续即可。

包含元素
  • 对象
  • 数组
  • 非静态变量
有什么异常
  • java.lang.OutOfMemoryError: Java heap space:这种是java堆内存不够,一个原因是真不够,另一个原因是程序中有死循环
  • java.lang.OutOfMemoryError: GC overhead limit exceeded:JDK6新增错误类型,当GC为释放很小空间占用大量时间时抛出

方法区

方法区(Method Area)被所有线程共享,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

​ 和 Java 堆一样,不需要连续的内存,可以选择固定的大小,更可以选择不实现垃圾收集。

垃圾回收算法有哪些

常用的垃圾回收算法有如下四种:标记-清除、复制、标记-整理和分代收集。

标记-清除算法

​ 从算法的名称上可以看出,这个算法分为两部分,标记和清除。首先标记出所有需要被回收的对象,然后在标记完成后统一回收掉所有被标记的对象。

这个算法简单,但是有两个缺点:

  • 一是标记和清除的效率不是很高;
  • 二是标记和清除后会产生很多的内存碎片,导致可用的内存空间不连续,当分配大对象的时候,没有足够的空间时不得不提前触发一次垃圾回收。

执行过程如下图

img

复制算法

​ 为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,他将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活这的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半,未免太高了一点。

优缺点

  • 优点:简单高效

  • 缺点:代价是将内存缩小为原来的一半,代价高

执行过程如下图

img

标记-整理算法

​ 复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是如果不想浪费50%的空间就要使用额外的空间进行分配担保(Handle Promotion当空间不够时,需要依赖其他内存),以应对被使用的内存中所有对象都100%存活的极端情况

​ 对于“标记-整理”算法,标记过程仍与“标记-清除”算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有的存活对象都向一端移动,然后直接清理掉端边界以外的内存,”标记-整理“算法示意图如下:

img

标记-整理算法解决了复制算法多复制效率低、空间利用率低的问题,同时也解决了内存碎片的问题。

分代收集算法

​ 根据对象生存周期的不同将内存空间划分为不同的块,然后对不同的块使用不同的回收算法。一般把Java堆分为新生代和老年代,新生代中对象的存活周期短,只有少量存活的对象,所以可以使用复制算法,而老年代中对象存活时间长,而且对象比较多,所以可以采用标记-清除和标记-整理算法。

img

判断对象是否有效

引用计数算法

img

给对象添加一个引用计数器,每当一个地方引用它时,数据器加1;当引用失效时,计数器减1;计数器为0的即可被回收。

  • 优点:实现简单,判断效率高
  • 缺点:很难解决对象之间的相互循环引用(objA.instance = objB; objB.instance = objA)的问题,所以java语言并没有选用引用计数法管理内存

根搜索算法

img

​ Java和C#都是使用根搜索算法来判断对象是否存活。通过一系列的名为“GC Root”的对象作为起始点,从这些节点开始向下搜索,搜索所有走过的路径称为引用链(Reference Chain),当一个对象到GC Root没有任何引用链相连时(用图论来说就是GC Root到这个对象不可达时),证明该对象是可以被回收的。

在Java中这些对象可以成为GC Root:

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

GC调优命令

jps

JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。

jstat

jstat(JVM statistics Monitoring)是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。

jmap

jmap(JVM Memory Map)命令用于生成heap dump文件,

​ 如果不使用这个命令,还阔以使用-XX:+HeapDumpOnOutOfMemoryError参数来让虚拟机出现OOM的时候·自动生成dump文件。 jmap不仅能生成dump文件,还阔以查询finalize执行队列、Java堆和永久代的详细信息,如当前使用率、当前使用的是哪种收集器等。

jhat

jhat(JVM Heap Analysis Tool)命令是与jmap搭配使用,用来分析jmap生成的dump

​ jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看。在此要注意,一般不会直接在服务器上进行分析,因为jhat是一个耗时并且耗费硬件资源的过程,一般把服务器生成的dump文件复制到本地或其他机器上进行分析。

jstack

jstack用于生成java虚拟机当前时刻的线程快照。

​ 线程快照是当前java虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等。 线程出现停顿的时候通过jstack来查看各个线程的调用堆栈,就可以知道没有响应的线程到底在后台做什么事情,或者等待什么资源。

​ 如果java程序崩溃生成core文件,jstack工具可以用来获得core文件的java stack和native stack的信息,从而可以轻松地知道java程序是如何崩溃和在程序何处发生问题。另外,jstack工具还可以附属到正在运行的java程序中,看到当时运行的java程序的java stack和native stack的信息, 如果现在运行的java程序呈现hung的状态,jstack是非常有用的。

jinfo

jinfo(JVM Configuration info)这个命令作用是实时查看和调整虚拟机运行参数。 之前的jps -v口令只能查看到显示指定的参数,如果想要查看未被显示指定的参数的值就要使用jinfo口令

JVM内存分配策略

img

对象内存分配两种方法

为对象分配空间的任务等同于把一块确定大小的内存从Java堆中划分出来。

​ 对象内存分配有两种方法:指针碰撞、空闲列表

​ 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。

指针碰撞

​ 指针碰撞(Serial、ParNew等带Compact过程的收集器) 假设Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”(Bump the Pointer)。

空闲列表

​ 空闲列表(CMS这种基于Mark-Sweep算法的收集器) 如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”(Free List)。

Java对象分配流程

img

栈上分配

​ 我们通过JVM内存分配可以知道JAVA中的对象都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,JVM通过逃逸分析确定该对象不会被外部访问。那就通过标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

img

逃逸分析

​ 逃逸分析是编译语言中的一种优化分析,而不是一种优化的手段。通过对象的作用范围的分析,为其他优化手段提供分析数据从而进行优化。包括全局变量赋值逃逸,方法返回值逃逸,实例引用发生逃逸,线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量。

标量替换

​ 标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量。通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而会将该对象成员变量分解若干个被这个方法使用的成员变量所代替。这些代替的成员变量在栈帧或寄存器上分配空间。

TLAB分配

TLAB的全称是Thread Local Allocation Buffer,即线程本地分配缓存区,这是一个线程专用的内存分配区域。

img

​ 由于对象一般会分配在堆上,而堆是全局共享的。因此在同一时间,可能会有多个线程在堆上申请空间。因此,每次对象分配都必须要进行同步(虚拟机采用CAS配上失败重试的方式保证更新操作的原子性),而在竞争激烈的场合分配的效率又会进一步下降。JVM使用TLAB来避免多线程冲突,在给对象分配内存时,每个线程使用自己的TLAB,这样可以避免线程同步,提高了对象分配的效率。

​ TLAB本身占用Eden区空间,在开启TLAB的情况下,虚拟机会为每个Java线程分配一块TLAB空间。参数-XX:+UseTLAB开启TLAB,默认是开启的。TLAB空间的内存非常小,缺省情况下仅占有整个Eden空间的1%,当然可以通过选项-XX:TLABWasteTargetPercent设置TLAB空间所占用Eden空间的百分比大小。

​ 由于TLAB空间一般不会很大,因此大对象无法在TLAB上进行分配,总是会直接分配在堆上。TLAB空间由于比较小,因此很容易装满。比如,一个100K的空间,已经使用了80KB,当需要再分配一个30KB的对象时,肯定就无能为力了。

​ 这时虚拟机会有两种选择,第一,废弃当前TLAB,这样就会浪费20KB空间;第二,将这30KB的对象直接分配在堆上,保留当前的TLAB,这样可以希望将来有小于20KB的对象分配请求可以直接使用这块空间。实际上虚拟机内部会维护一个叫作refill_waste的值,当请求对象大于refill_waste时,会选择在堆中分配,若小于该值,则会废弃当前TLAB,新建TLAB来分配对象。这个阈值可以使用TLABRefillWasteFraction来调整,它表示TLAB中允许产生这种浪费的比例。默认值为64,即表示使用约为1/64的TLAB空间作为refill_waste。默认情况下,TLAB和refill_waste都会在运行时不断调整的,使系统的运行状态达到最优。如果想要禁用自动调整TLAB的大小,可以使用-XX:-ResizeTLAB禁用ResizeTLAB,并使用-XX:TLABSize手工指定一个TLAB的大小。

内存分配规则

对象优先分配在Eden区

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

大对象直接进入老年代

​ 大对象是指,需要大量连续内存空间的Java对象,虚拟机提供了相关参数调整大小

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

​ 每次Minor GC,年龄就增加一岁,默认15岁,进入老年代,也可以通过参数调整。

动态对象年龄判定

​ 如果在Survivor空间中相同年龄所有对象大小的总和大小大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

​ 在发生Minor GC前,虚拟机会检查老年代的最大可用连续空间是否大于新生代所有对象的总空间,成立安全,不成立,触发Full GC。

young gc触发条件

对象直接在年轻代中的Eden区进行分配,如果Eden区域没有足够的空间,那么就会触发YGC(Minor GC)

进入老年代的途径

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

通过新生代的复制算法,年龄达到15仍存活的可进入(JVM参数MaxTenuringThreshold决定,默认15)

​ 虚拟机给每个对象定义了一个对象年龄计数器,在对象在Eden创建并经过第一次Minor GC后仍然存活,并能被Suivivor容纳的话,将会被移动到Survivor空间,并对象年龄设置为1。每经历过Minor GC,年龄就增加1岁,当到一定程度(默认15岁,可以通过参数-XXMaxTenuringThreshold设置),就将会晋升年老代。

空间担保

谁进行空间担保

JVM使用分代收集算法,将堆内存划分为年轻代和老年代,两块内存分别采用不同的垃圾回收算法,空间担保指的是老年代进行空间分配担保

img

什么是空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间

  • 如果大于,则此次Minor GC是安全的

  • 如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则尝试进行一次Minor GC,但这次Minor GC依然是有风险的;如果小于或者HandlePromotionFailure=false,则改为进行一次Full GC。

为什么要进行空间担保?

​ 是因为新生代采用复制收集算法,假如大量对象在Minor GC后仍然存活(最极端情况为内存回收后新生代中所有对象均存活),而Survivor空间是比较小的,这时就需要老年代进行分配担保,把Survivor无法容纳的对象放到老年代。老年代要进行空间分配担保,前提是老年代得有足够空间来容纳这些对象,但一共有多少对象在内存回收后存活下来是不可预知的,因此只好取之前每次垃圾回收后晋升到老年代的对象大小的平均值作为参考。使用这个平均值与老年代剩余空间进行比较,来决定是否进行Full GC来让老年代腾出更多空间。

动态年龄

survivor中已满,且至少有50%的对象年龄大于平均年龄,则会把这些大于平均年龄的对象直接写到老年区中。

​ 为了更好地适应不同程序内存状况,虚拟机并不硬性要求对象年龄达到MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入年老代

大对象直接进入老年代

大对象在Survivor里存不下,也直接进入old区
tips:只针对Serial和Parnew收集器生效,PS收集器无效

​ 大对象即需要大量连续内存空间的Java对象,如长字符串及数组。经常出现大对象导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来安置他们。

​ 虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。 这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制(新生代采用复制算法收集内存)。

FullGc触发条件

Full GC相对于Minor GC来说,停止用户线程的STW(stop the world)时间过长,至少慢10倍以上,所以要尽量避免,首先说一下Full GC可能产生的原因,接着给出排查方法以及解决策略。

System.gc()方法的调用

​ 在代码中调用System.gc()方法会建议JVM进行Full GC,但是注意这只是建议,JVM执行不执行是另外一回事儿,不过在大多数情况下会增加Full GC的次数,导致系统性能下降,一般建议不要手动进行此方法的调用,可以通过-XX:+ DisableExplicitGC来禁止RMI调用System.gc。

老年代(Tenured Gen)空间不足

​ 在Survivor区域的对象满足晋升到老年代的条件时,晋升进入老年代的对象大小大于老年代的可用内存,这个时候会触发Full GC。

Metaspace区内存达到阈值

​ 从JDK8开始,永久代(PermGen)的概念被废弃掉了,取而代之的是一个称为Metaspace的存储空间。Metaspace使用的是本地内存,而不是堆内存,也就是说在默认情况下Metaspace的大小只与本地内存大小有关。-XX:MetaspaceSize=21810376B(约为20.8MB)超过这个值就会引发Full GC,这个值不是固定的,是会随着JVM的运行进行动态调整的

动态年龄判断大于老年代

统计得到的Minor GC晋升到旧生代的平均大小大于老年代的剩余空间

Survivor区域对象晋升到老年代有两种情况:

  • 一种是给每个对象定义一个对象计数器,如果对象在Eden区域出生,并且经过了第一次GC,那么就将他的年龄设置为1,在Survivor区域的对象每熬过一次GC,年龄计数器加一,等到到达默认值15时,就会被移动到老年代中,默认值可以通过-XX:MaxTenuringThreshold来设置。
  • 另外一种情况是如果JVM发现Survivor区域中的相同年龄的对象占到所有对象的一半以上时,就会将大于这个年龄的对象移动到老年代,在这批对象在统计后发现可以晋升到老年代,但是发现老年代没有足够的空间来放置这些对象,这就会引起Full GC。

堆中产生大对象超过阈值

​ 这个参数可以通过-XX:PretenureSizeThreshold进行设定,大对象或者长期存活的对象进入老年代,典型的大对象就是很长的字符串或者数组,它们在被创建后会直接进入老年代,虽然可能新生代中的Eden区域可以放置这个对象,在要放置的时候JVM如果发现老年代的空间不足时,会触发GC。

老年代连续空间不足

​ JVM如果判断老年代没有做足够的连续空间来放置大对象,那么就会引起Full GC,例如老年代可用空间大小为200K,但不是连续的,连续内存只要100K,而晋升到老年代的对象大小为120K,由于120>100的连续空间,所以就会触发Full GC。

CMS GC错误

CMS GC时出现promotion failed和concurrent mode failure

  • 提升失败(promotion failed),在 Minor GC 过程中,Survivor Unused 可能不足以容纳 Eden 和另一个 Survivor 中的存活对象, 那么多余的将被移到老年代, 称为过早提升(Premature Promotion)。 这会导致老年代中短期存活对象的增长, 可能会引发严重的性能问题。 再进一步, 如果老年代满了, Minor GC 后会进行 Full GC, 这将导致遍历整个堆, 称为提升失败(Promotion Failure)。
  • 在 CMS 启动过程中,新生代提升速度过快,老年代收集速度赶不上新生代提升速度。在 CMS 启动过程中,老年代碎片化严重,无法容纳新生代提升上来的大对象,这是因为CMS采用标记清理,会产生连续空间不足的情况,这也是CMS的缺点

总结

可以发现其实堆内存的Full GC一般都是两个原因引起的,要么是老年代内存过小,要么是老年代连续内存过小

常见垃圾回收器

下图展示了7种作用于不同分代的收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用,收集器所处的区域,则表示它是属于新生代还是老年代收集器。

img

  • 并行(Parallel):指多条垃圾收集器线程并行工作,但此时用户线程仍然处于等待。

  • 并发(Concurrent):指用户线程与垃圾收集器线程同时执行(但不一定是并行的,可能会交替执行),用户程序在继续运行,而垃圾收集器程序运行于另一个CPU之上。

Serial收集器

Serial收集器是最基本、发展历史最悠久的收集器。是一个单线程收集器,只会使用一个CPU或者一条收集线程去完成垃圾收集工作,进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

​ 尽管有以上让人不能接受的地方,但是Serial收集器还是有其优点的:简单而高效,对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集可以获得较高的收集效率。

实际到目前为止,Serial收集器依然是JVM运行在Client模式下的默认新生代收集器。

img

ParNew收集器

ParNew收集器是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集外,其余行为和Serial收集器完全一样。

​ ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器。除去性能因素,很重要的原因是除了Serial收集器外,目前只有它能与CMS收集器配合工作。

​ ParNew收集器是使用 -XX:+UserConcMarkSweepGC 选项后的默认新生代收集器,也可以使用 -XX:+UserParNewGC 选项来强制指定它。

​ 在单CPU环境中,ParNew收集器不会有比Serial收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个CPU的环境中都不能百分之百地保证可以超越Serial收集器。当然,随着可以使用的CPU数量的增加,它对于GC时系统资源的有效利用还是很有好处的。它默认开启的收集线程数和CPU的数量相同,可以使用 -XX:ParallelGCThreads 参数来限制垃圾收集器的线程数。

img

Parallel Scavenge 收集器

Parallel Scavenge收集器也是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器。

​ Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量。

吞吐量 = 程序运行时间 / (程序运行时间 + 垃圾收集时间)。虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。高吞吐量适合高效利用CPU时间,主要用于后台运算不需要太多交互的任务。

Parallel Scavenge收集器提供了两个参数来精确控制吞吐量:

  • -XX:MaxGCPauseMillis

    ​ 控制最大垃圾收集停顿时间,是一个大于0的毫秒数,停顿时间设置小了就要牺牲相应的吞吐量和新生代空间。

  • -XX:GCTimeRatio

    ​ 设置吞吐量大小,是一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率,默认值为99,也就是允许最大1%的垃圾回收时间。

  • -XX:+UseAdaptiveSizePolicy

    ​ 这是一个开关参数,当这个参数打开后,就不用手动设置新生代大小(-Xmn)、Eden和Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。这种调节方式称为GC自适应的调节策略。这也是其与PreNew收集器的一个重要区别,也是其无法与CMS收集器搭配使用的原因(CMS收集器尽可能地缩短垃圾收集时用户线程的停顿时间,以提升交互体验)。

img

Serial Old 收集器

​ Serial Old收集器是Serial收集器的老年代版本,也是一个单线程收集器,采用“标记-整理算法”进行回收。其运行过程与Serial收集器一样。Serial Old收集器的主要意义也是在于给Client模式下的虚拟机使用。如果在Server模式下,那么它主要还有两大用途:一种用途是在JDK 1.5以及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用。

Parallel Old 收集器

​ Parallel Old收集器是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法进行垃圾回收。其通常与Parallel Scavenge收集器配合使用,“吞吐量优先”是这个组合的特点,在注重吞吐量和CPU资源敏感的场合,都可以使用这个组合。

CMS 收集器

CMS收集器(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。适合交互式应用的需求。

CMS收集器时基于“标记-清除”算法实现,运作过程分为四个步骤:

  • 初始标记:需要进行GC停顿,标记 GC Roots 能直接关联到的对象,速度很快。

  • 并发标记:进行可达性分析的过程。

  • 重新标记:修正并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一点,但远比并发标记的时间短。

  • 并发清除:进行清除工作。

img

优点

​ CMS的优点很明显:并发收集、低停顿。由于进行垃圾收集的时间主要耗在并发标记与并发清除这两个过程,虽然初始标记和重新标记仍然需要暂停用户线程,但是从总体上看,这部分占用的时间相比其他两个步骤很小,所以可以认为是低停顿的。

缺点

尽管如此,CMS收集器的缺点也是很明显的,主要有3个缺点:

CMS收集器对CPU资源非常敏感

​ 在并发(并发标记、并发清除)阶段,虽然不会导致用户线程停顿,但是会占用CPU资源而导致应用程序变慢,总吞吐量下降。CMS默认启动的回收线程数是:(CPU数量 + 3) / 4。因此CMS垃圾收集器始终不会占用少于25%的CPU,特别是双核CPU的时候,已经占用了5/8的CPU,吞吐量会很低。为了解决这种情况,虚拟机提供了“增量式并发收集器”(Incremental Concurrent Mark Sweep/i-CMS)。就是采用抢占式来模拟多任务机制,在并发(并发标记、并发清除)阶段,让GC线程、用户线程交替执行,尽量减少GC线程独占CPU,这样垃圾收集过程更长,但是对用户程序影响小一些。实际上i-CMS效果很一般,目前已经不提倡用户使用。

CMS收集器无法处理浮动垃圾

CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure“失败而导致另一次 Full GC 的产生

​ 由于CMS并发清理阶段用户线程还在运行,伴随程序的运行自热会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在本次收集中处理它们,只好留待下一次GC时将其清理掉。这一部分垃圾称为“浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,即需要预留足够的内存空间给用户线程使用,因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分内存空间提供并发收集时的程序运作使用。

​ 在默认设置下,CMS收集器在老年代使用了68%的空间时就会被激活,也可以通过参数 -XX:CMSInitiatingOccupancyFraction 的值来提高触发百分比,以降低内存回收次数提高性能。

​ JDK1.6中,CMS收集器的启动阈值已经提升到92%。要是CMS运行期间预留的内存无法满足程序其他线程需要,就会出现“Concurrent Mode Failure”失败,这时候虚拟机将启动后备预案:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。所以说参数 -XX:CMSInitiatingOccupancyFraction 设置的过高将会很容易导致“Concurrent Mode Failure”失败,性能反而降低。

会产生大量碎片

CMS是基于“标记-清除”算法实现的收集器,使用“标记-清除”算法收集后,会产生大量碎片。

​ 空间碎片太多时,将会给对象分配带来很多麻烦,比如说大对象,内存空间找不到连续的空间来分配不得不提前触发一次Full GC。为了解决这个问题,CMS收集器提供了一个 -XX:UseCMSCompactAtFullCollection 开关参数(默认开启),用于在Full GC之后增加一个内存碎片的合并整理过程,但是内存整理过程是无法并发的,解决了空间碎片问题,却使停顿时间变长。所以还提供了 -XX:CMSFullGCBeforeCompaction 参数设置执行多少次不压缩的 Full GC 之后,跟着来一次碎片整理过程(默认值是0,表示每次进入Full GC时都进行碎片整理)。

G1收集器

G1(Garbage First)收集器是一个新的面向服务端应用的垃圾收集器,其目标就是替换掉JDK1.5发布的CMS收集器。之前一直处于实验阶段,直到jdk7u4之后,才正式作为商用的收集器。

特点

与前几个收集器相比,G1收集器有以下特点:

  • 并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或CPU核心)来缩短停顿(Stop The World)时间。

  • 分代收集:G1不需要与其他收集器配合就能独立管理整个GC堆,但它能够采用不同方式去处理新建对象和已经存活了一段时间、熬过多次GC的老年代对象以获取更好收集效果。

  • 空间整合:从整体来看是基于“标记-整理”算法实现,从局部(两个Region之间)来看是基于“复制”算法实现的,但是都意味着G1运行期间不会产生内存碎片空间,分配大对象时不会因为没有连续空间而进行下一次GC。

  • 可预测的停顿:降低停顿时间是G1和CMS共同关注点,但G1除了追求低停顿,还能建立可预测的停顿模型,可以明确地指定在一个长度为M的时间片内,消耗在垃圾收集的时间不超过N毫秒

原理

​ G1之前的收集器的手机范围都是整个新生代或者整个老年代,G1收集器将Java堆划分成多个大小相等的独立区域(Region),虽然保留了新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合(不需要连续)。

​ G1跟踪各个Region里面的垃圾堆积的价值大小(回收可以获得的空间大小和回收所需要的时间的经验值),后台维护一个优先队列,根据每次允许的收集时间,优先回收价值最大的Region(Garbage-First 理念)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限时间可以获得尽可能高的收集效率。

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

收集过程

忽略 Remembered Set 的维护,G1的运行步骤可简单描述为一下四步:

  • 初始标记:标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象。这个阶段需要停顿线程,但耗时很短。

  • 并发标记:从GC Roots开始对堆中对象进行可达性分析,找出存活对象,此阶段时间较长可与用户程序并发执行。

  • 最终标记:修正在并发标记期间因用户线程继续运行而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,需要停顿线程,但是可并行执行。

  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来制定回收计划。

如果现有的垃圾收集器没有出现任何问题,没有任何理由去选择G1,如果应用追求低停顿,G1可选择,如果追求吞吐量,和 Parallel Scavenge/Parallel Old 组合相比G1并没有特别的优势。

img

Class回收的条件

注意:Class要被回收,条件比较苛刻,必须同时满足以下条件(仅仅是可以,不代表必然,因为还有一些参数可以进行控制)

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

什么是安全点

在虚拟机在进行可达性分析时,HotSpot虚拟机会在特定的位置记录在哪有引用,这些特定的位置就叫做安全点。

安全点的作用是什么

​ JVM进行垃圾回收是一个非常复杂的过程,如何进行垃圾标记、什么时候进行垃圾、如果进行垃圾回收等等都非常复杂,当前主流测JVM在垃圾回收时都会进行STW(stop the world),即使宣称非常快的CMS垃圾回收期早期也会STW标记垃圾状态。那么这里有个问题,什么时候进行标记对象是否可以被回收呢?

​ CPU在执行运算过程时需要把数据从内存中载入到寄存器,运算完后再从寄存器中载入到内存中,Java中对象地址也是这么个过程,设想如果一个Java线程分配一个对象,此时对象的地址还在寄存器中,这时候这个线程失去了CPU 时间片,而此时STW GC发现没有任何GC ROOTS与该对象关联起来,此时这个对象呗认为是垃圾并被回收了,之后CPU重新获得时间片后发现此时对象已经不存在了这时候程序就GG了。

​ 因此不是在任何时候都可以随便GC的,复杂的JVM早就考虑到这个问题,在JVM里面引入了一个叫安全点(Safe Point)的东西来避免这个问题。GC的目的是帮助我们回收不再使用的内存,在多线程环境下这种回收将会变得非常复杂,要安全地回收需要满足一下两个条件:

  1. 堆内存的变化是受控制的,最好所有的线程全部停止。

  2. 堆中的对象是已知的,不存在不再使用的对象很难找到或者找不到即堆中的对象状态都是可知的。

为了准确安全地回收内存,JVM是在Safe Point点时才进行回收,所谓Safe Point就是Java线程执行到某个位置这时候JVM能够安全、可控的回收对象,这样就不会导致上所说的回收正在使用的对象。

既然达到Safe Point就可以安全准确的GC,name如何到达Safe Point。

如何达到安全点

既然达到Safe Point就可以安全准确的GC,name如何到达Safe Point,有两种方案:抢先式中断、主动式中断

抢先式中断

​ 抢先式中断指的是在GC发生时,现将所有线程都中断,然后再检查没有到安全点的线程恢复执行到安全点。

主动式中断

​ 主动式中断指的是在GC需要中断线程时,不直接操作线程,只是置一个标志,让所有线程去轮询这个标识为,当标志为真时则自己中断挂起,轮训标志的地方和安全点是重合的,另外再加上创建对象所需要分配内存的地方。

如何设置标志

​ 既然JVM使用的是主动性主动到达安全点,那么应该在什么地方设置全局变量呢?显然不能随意设置全局变量,进入安全点有个默认策略那就是:“避免程序长时间运行而不进入Safe Point”,程序要GC了必须要等线程进入安全点,如果线程长时间不进入安全点这样就比较糟糕了,因此安全点主要咋以下位置设置:

  1. 循环的末尾
  2. 方法返回前
  3. 调用方法的call之后
  4. 抛出异常的位置

安全区域

​ 安全点完美的解决了如何进入GC问题,实际情况可能比这个更复杂,但是如果程序长时间不执行,比如线程调用的sleep方法,这时候程序无法响应JVM中断请求这时候线程无法到达安全点,显然JVM也不可能等待程序唤醒,这时候就需要安全区域了。

​ 安全区域是指一段代码片中,引用关系不会发生变化,在这个区域任何地方GC都是安全的,安全区域可以看做是安全点的一个扩展。线程执行到安全区域的代码时,首先标识自己进入了安全区域,这样GC时就不用管进入安全区域的线层了,线层要离开安全区域时就检查JVM是否完成了GC Roots枚举,如果完成就继续执行,如果没有完成就等待直到收到可以安全离开的信号。

评论