文章目录

运行时数据区

 Java虚拟机在执行Java程序的过程中会把它所管理的区域划分为若干个不同的数据区。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。《Java虚拟机规范》规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域:

垃圾收集器与回收算法-LMLPHP

 如上图可知为Java虚拟机进程启动时建立的运行时数据区:其中方法区是线程共享的数据区域,是存放数据的主要场所;虚拟机栈(又被称为Java方法栈),本地方法栈,PC寄存器是线程私有的数据区域。

PC寄存器

 PC寄存器(Program Counter Register)是一块很小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码指示器工作时可以通过改变这个计数器的值来选取下一条需要执行的字节码指令:分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖PC寄存器来完成。

 Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器只会执行一条线程中的指令。因此为了线程切换后能恢复到正确的执行位置,每个线程都需要一个独立的PC寄存器,各线程之间的PC寄存器互不影响,独立存储。

Java虚拟机栈(Java方法栈)

 Java虚拟栈也可以称为Java方法栈,它是线程私有的,生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧用于存储局部变量表,操作数栈,动态连接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。关于栈帧的详细描述,请看:https://blog.csdn.net/king123456man/article/details/82911685

 在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,但在扩展时没有申请到足够的内存,就会抛出 OutOfMemoryError 异常。

本地方法栈

 本地方法栈也是线程私有的数据,与虚拟机栈所发挥的作用很相似。它们之间的区别是:虚拟机栈为虚拟机执行Java方法服务;本地方法栈为虚拟机使用到的Native方法服务。native方法是底层操作系统实现的方法,Java程序有时需要底层native的方法的支持,这时就需要使用到本地方法栈,如我们最熟悉的 HashCode() 方法就是一个 native方法:

* @return  a hash code value for this object.
public native int hashCode();

Java堆

 Java堆是虚拟机所管理内存中最大的一块,是被所有线程所共享的一块内存区域,在虚拟机启动时创建。Java堆是用来存放对象实例的,几乎所有的对象实例(普通对象实例和数组对象)都在这里分配内存。Java堆是垃圾收集器管理的主要区域,因此又被称为GC堆(Garbage Collected Heap)。

  • 从内存回收的角度看,由于现在的垃圾收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代。

  • 从内存分配的角度看,线程共享的Java堆可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)

方法区

 方法区和Java堆一样是线程共享的内存区域,它用于存储已经被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。

运行时常量池

 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本,字段,方法,接口等信息外,还有一项信息就是常量池(Constant Pool),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池存放。运行时常量池是方法区的一部分,受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

对象是如何诞生的?

  • 虚拟机遇到一条new指令时,首先会检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用所代表的类是否已经被加载,解析和初始化过。例如下面的例子:要创建一个 A 的实例,首先会去常量池中查找是否存在关于 A 的描述信息(类名,包名等),找到描述信息后检查内存中是否已经有这个类的对象。
A a =  new A();
  • 类加载检查通过后,虚拟机为新生对象分配内存(对象所需内存的大小在类加载完成后便可完全确定下来,为对象分配内存的任务等同于把一块确定大小的内存从Java堆中划分出来)

  • 内存分配完成后,虚拟机将分配到的内存空间都初始化为零值(保证了对象的实例字段在Java代码中可以不赋初始值就可以使用,程序能够访问到这些字段的数据类型所对应的零值)

  • 虚拟机对对象进行必要的设置:对象是哪个类的实例,如何找到类的元数据信息,对象的哈希吗,对象的GC分代年龄等信息,这些信息放在对象的对象头之中

  • 执行实例构造器方法,按照程序员的意愿初始化,这样一个真正的对象完全诞生

垃圾收集策略

垃圾收集需要完成的三件事情:

  • 哪些内存需要回收?(Java堆和方法区)

  • 什么时候回收?(计算引用是否可用的算法)

  • 如何回收?(垃圾回收算法和垃圾收集器)

 在前面说了Java内存运行时区域的各个部分,其中PC寄存器,虚拟机栈,本地方法栈3个区域随着线程而生,随着线程而灭。虚拟机栈中栈帧随着方法的进入和退出而有条不紊地执行着入栈和出栈操作,每一个栈帧中分配多少内存基本上在类结构确定是就已经可知(编译期可知),因此在这几个区域的内存分配和回收具备确定性,因为方法结束或者线程结束时,内存自然也被回收。

 然而Java堆和方法区却不一样,一个接口中的多个实现类需要的内存可能不一样,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间才能知道创建哪些对象,这部分内存的分配和回收都是动态的,而垃圾收集器关注的正是这部分内存(Java堆和方法区中的内存)。

对象存活判定算法

判定对象是否可回收的算法(一)——引用计数算法

引用计数算法的基本原理为:给对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1,任何时刻计数器值为0的对象就是不可能再被使用的对象。

优点:实现简单,判定高效,可以很好解决大部分场景的问题。

缺点:很难解决对象之间相互循环引用的问题(所以Java语言没有选用引用计数算法来管理内存);开销较大,频繁且大量的引用变化带来大量的额外运算。

如下面的代码示例所示:

public class ReferenceTest{
    
    public Objetc instance = null;
    
    public static void testGC() {
        ReferenceTest A = new ReferenceTest();
        ReferenceTest B = new ReferenceTest();
        A.instance = B;
        B.insatnce = A;
        
        A = null;
        B = null;
        
        System.gc();
    }
}

 在上面的代码中,按理说在运行 System.gc() 之后,A,B对象应该被回收,因为它们的运行周期已经结束,在没有被访问的可能。但假如使用引用计数算法,因为A和B互相引用,引用计数器值不为0,所以不回进行回收。

判定对象是否可回收的算法(二)——可达性分析算法

 Java语言使用可达性分析算法来判定对象是否存活。其基本原理就是:有一个被称为 GC Root Set 的集合来保存一系列的 GC Roots,GC Roots是对象的起始点,从这个节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连,则证明此对象不可用。如下图所示:

垃圾收集器与回收算法-LMLPHP

 绿色部分因为还保持着对 GC Roots 的引用,所以它们是仍然存活的对象;蓝色部分已经没有引用链连接 GC Roots,所以这部分对象将在未来个时刻被回收。

在Java语言中可作为 GC Roots 的对象包括下面几种:

  • 栈帧中局部变量表中引用的对象

  • 方法区中静态属性引用的变量

  • 方法区中常量引用的对象

  • 本地方法栈(JNI,Native方法)引用的对象

优点

  • 更加精确严谨

  • 可以分析出循环数据结构相互引用的情况

缺点

  • 实现复杂,需要大量数据分析,消耗大量时间

  • 分析过程需要GC停顿,即停止所有所有正在执行的Java工作线程(Stop The World)

方法区回收策略

 方法区一般被称为永久代,永久代垃圾收集主要回收两部分内容:废弃常量和无用的类。回收废弃常量和回收Java堆中的对象类似,回收无用的类相对要复杂一些,类需要同时满足下面3个条件才能算无用的类:

  • 该类所有的实例已经被回收,也就是Java堆中不存在该类的任何实例

  • 加载该类的ClassLoader已经被回收

  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法

可以使用虚拟机的如下参数去查看类加载与卸载信息

//查看类加载信息
-XX:+TraceClassLoading

//查看类卸载信息
-XX:+TraceClassUnLoading

判断对象是否死亡

1. 第一次标记

 如果对象在进行可达性分析后没有与GC Roots相连接的引用链,进行第一次标记;并且进行一次筛选:此对象是否有必要执行finalize()方法

(1)没有必要执行的情况:对象已死,可以回收

  • 对象没有覆盖 finalize() 方法
  • finalize() 方法已经被JVM调用过

(2) 有必要执行的情况

  • 将有必要执行 finalize() 方法的对象放入F-Queue队列
  • 稍后由一个虚拟机自动建立的,低优先级的Finalizer线程去执行
2. 第二次标记

 GC将对F-Queue队列中的对象进行第二次小规模标记。finalize() 方法是对象逃脱死亡命运的最后一次机会:

  • 如果对象在其finalize()方法中重新与引用链上任何一个对象建立关联,第二次标记时会将其移除即将回收的集合
  • 否则,认为对象已死,可以回收

 任何一个对象的finalize()方法只会被系统自动调用一次,调用过finalize()方法而逃脱死亡的对象,第二次将不会再调用。

垃圾收集算法

标记-清除算法

标记-清除算法是最基础的收集算法,算法分为标记清除两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。其执行过程如下图所示:

垃圾收集器与回收算法-LMLPHP

 如上图所示,灰色部分代表可回收的对象,绿色部分代表存活对象,空白部分代表未使用的内存空间。标记-清除算法的最大优点就是简单,但却有两个主要不足之处:第一是效率问题,标记和清除两个过程的效率都不高;第二就是空间问题,标记清除之后会留下大量不连续的内存碎片,空间碎片太多可能会导致以后程序在运行过程中需要分配较大对象时,无法找到足够连续内存而不得不提前触发另一次垃圾收集动作。

复制算法

 为了解决标记-清除算法产生的效率和空间不连续问题,出现了一种新的回收算法——复制算法,其原理为:将可用内存划分为大小相等的两块,每次只是用其中一块,当其中一块的内存用完时,就将还存活的对象复制到另一块上去,然后再把已使用的内存回收。其优点就是:效率高且不会出现内存碎片多的情况;缺点也明显:内存缩小为原来的一半,代价高。其执行过程如下:

垃圾收集器与回收算法-LMLPHP

 现在商用的虚拟机一般都采用复制算法来回收新生代,经研究表明,新生代中的对象98%是朝生夕死的,所以并不需要按照 1:1 的比例来划分内存空间,而是将内存分为一块较大的 Eden空间 和两块较小的 Survivor空间,每次使用Eden和其中一块Survivor空间。

 当回收时,将Eden和Survivor中还存活的对象一次性复制到另外一块Survivor空间上,最后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是 8:1,每次新生代中可用的内存空间为90%,只有10%的内存会被浪费。如果另一块Survivor没有足够空间存放上一次新生代收集下来的存活对象时,这些对象将直接通过分配担保机制进入老年代。

使用如下虚拟机参数可以修改Eden和Survivor的比例,默认是 8:1

-XX:SurvivorRatio=8
标记-整理算法

 复制收集算法在对象存活率较高时要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保。所以一般老年代的垃圾回收不使用复制收集算法。根据老年代的特点,出现了一种名为*标记-整理**的算法,标记过程与标记清除算法相同,但后续步骤不是直接对可回收的对象进行整理,而是让所有存活对象都向一端移动,然后直接清理掉边界以外的内存。下面是执行过程:

垃圾收集器与回收算法-LMLPHP

分代收集算法

分代收集算法并没有多么高深的原理,只是将前面介绍的算法进行了合理组合与利用。根据对象存活周期的不同将内存划分为几块,一般将Java堆分为新生代和老年代。在新生代中,每次垃圾收集时会有大量的对象死去,只有少量存活,使用复制收集算法;老年代中对象存活率较高,没有额外空间对其进行分配担保,使用标记-清除算法标记-整理来进行回收。

HotSpot算法实现

可达性分析问题
  • 消耗大量时间:前面已经说过,可以作为GC Roots的对象主要是**全局性的引用(常量或静态属性)执行上下文(栈帧中的本地变量表)**中,要在这些大量数据中逐个检查引用会消耗很多时间

  • GC停顿:可达性分析期间需要保证整个执行系统的一致性,对象的引用关系不能发生变化。GC进行时必须停顿所有Java执行线程(Stop The World),就算是几乎不发生停顿的CMS收集器,在枚举根节点时也必须停顿。

Stop The World

  • JVM在后台自动发起和自动完成
  • 在用户不可见的情况下,把用户正常的工作线程全部停掉
枚举根节点

 枚举根节点也就是查找GC Rootss,由于目前使用的都是准确式GC,所以当执行系统停顿下来之后,并不需要全部,逐个检查所有执行上下文和全局的引用位置,虚拟机有自己放入方法直接得到哪些地方存放着对象引用。在HotSpot中,使用一组称为OopMap的数据结构来实现这个目的:

  • 在类加载时,计算对象内各偏移量上是什么数据类型
  • 在JIT编译时,记录栈和寄存器中哪些位置是引用
  • GC扫描时直接得到信息
安全点(Safepoint)

 HotSpot在OopMap的帮助下可以快速准确地完成GC Roots枚举,但却有一个问题:运行中非常多的指令都会倒置引用关系变化,如果这些指令都生成对应的OopMap,需要很高的空间成本。解决办法就是:在特定的位置记录OopMap引用关系,这些位置就是安全点(Safepoint),也就是说程序在执行时并非在所有地方都能停顿下来进行GC,必须在Safepoint才能GC

安全点的筛选: Safepoint 的选定既不能太少以至于让GC等待太长时间,也不能过于频繁以至于过分增大运行的负荷。所以安全点的选用是以程序是否具有让程序长时间执行的特征为标准选定的,长时间执行最明显的特征就是指令序列复用,例如:方法调用,循环跳转,异常跳转等,具有这些功能的指令才会产生Safepoint。

如何在安全点上停顿

  • 抢占式中断(Preemptive Suspension):不需要线程主动配合(几乎没有JVM采用这种方式)

    • 在GC发生时,首先中断所有线程
    • 如果发现不在Safepoint上的线程,就恢复让其运行到Safepoint上
  • 主动式中断(Voluntary Suspension)

    • 在GC发生时,不直接操作线程中断,而是仅简单设置一个标志
    • 让各线程执行时去轮询这个标志,发现标志中断为真时就自己中断挂起

    轮询标志的地方和Safepoint是重合的。在JIT执行方式下,test指令是HotSpot生成的轮询指令;一条test汇编指令便完成Safepoint轮询和触发线程中断。

安全区域(Safe Region)

 Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但在没有分配CPU时间(线程处于Sleep状态或者Blocked状态)时,线程无法响应JVM的中断请求,到安全的区域挂起。对于这种情况,就需要**安全区域(Safe Region)**来解决。

 安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任何地方开始GC都是安全的(可以看做扩展了的Safepoint)

安全区域解决问题的思路:在线程执行进入到 Safe Region 区域时,首先标识自己已经进入了 Safe Region;在线程要离开 Safe Region 区域时,检查系统是否已经完成了根节点枚举:如果完成了,线程继续执行;如果没有完成,等待直到收到可以离开 Safe Region 区域的信号为止。

垃圾收集器

JDK7/8后,HotSpot虚拟机所有收集器及组合如下图

上图展示了7种不同的分代收集器:

Serial,ParNew,Parallel Scavenge,Serial Old,Parallel Old,CMS,G1

根据它们所处区域可划分为新生代收集器和老年代收集器:

  • 新生代收集器: Serial,ParNew,Parallel Scavenge
  • 老年代收集器: Serial Old,Parallel Old,CMS
  • 整堆收集器: G1

两个收集器之间的连线表名它们可以搭配使用:

Serial/Serial Old、Serial/CMS、ParNew/Serial Old、ParNew/CMS、Parallel Scavenge/Serial Old、Parallel Scavenge/Parallel Old、G1

相关概念

并行和并发的区别
  • 并行(Parallel): 指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态

    ParNew、Parallel Scavenge、Parallel Old
    

    设置并行GC时进行内存回收的线程数:

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

    CMS、G1
    
Minor GC 和 Full GC(Major GC)
  • 新生代GC(Minor GC): 指发生在新生代的垃圾收集动作,因为Java对象大多具备朝生夕死的特性,所以 Minor GC 非常频繁,一般回收速度也比较快

  • 老年代GC(Major GC/Full GC): 指发生在老年代的GC,出现 Major GC,经常会伴随着至少一次的 Minor GC, Major GC 的速度一般会比 Minor GC 慢10倍以上

 判断对象是新生代还是老年代:在JVM中有一个默认参数 MaxTenuringThreshold 标志新生代对象经过几次 Minor GC 后进入老年代,默认值是15。每个对象在坚持过一次 Minor GC 之后,年龄增加1,当超过这个参数值时就进入老年代。

-XX:MaxTenuringThreshold=15
吞吐量(Throughput)

吞吐量是CPU用于运行用户代码的时间与CPU消耗总时间的比值

吞吐量 = 运行用户代码时间 / (用户运行代码时间 + 垃圾收集时间)

例如:虚拟机一共运行了100分钟,其中垃圾收集花掉1分钟,那么吞吐量就是99%

垃圾收集器的关注点(期望)
  • 停顿时间: 停顿时间越短越适合需要与用户交互的程序;良好的响应速度能提高用户的体验

  • 吞吐量: 高吞吐量意味着可以高效率的利用CPU时间,尽快完成运算任务,主要适合在后台计算而不需要太多交互的任务

  • 覆盖区(Footprint): 在达到前面两个目标的前提下,尽量减少堆的内存空间,可以获得更好的空间局部性

新生代垃圾收集器

1. Serial收集器

Serial收集器是一个新生代单线程垃圾收集器,采用复制算法单线程并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在进行垃圾收集时,必须暂停其他所有的工作线程(Stop The World),直到它收集结束。

特点: 针对新生代;采用复制算法;单线程收集;进行垃圾收集时必须暂停所有工作线程直到完成(Stop The World)

优点: 简单高效,限定单个CPU环境时,没有线程交互开销。

适用场景: Client模式下的默认新生代收集器

参数设置

//显式使用串行垃圾收集器
-XX:+UseSerialGC

下图是 Serial / Serial Old 组合收集器的运行过程:

2. ParNew收集器

 ParNew收集器是Serial收集器的多线程版本,是一个新生代并行收集器,采用复制算法。除了使用多线程收集之外,其余行为和特点都和Serial收集器相同(包括:虚拟机控制参数,收集算法,Stop The World,对象分配规则,回收策略等)。

适用场景: 多CPU环境时在Server模式下与CMS收集器配合使用

参数设置

//指定使用CMS后,会默认使用ParNew作为新生代收集器
-XX:+UseConcMarkSweepGC

//强制指定使用ParNew
-XX:+UseParNewGC

//指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同
-XX:ParallelGCThreads

下图是 ParNew/Serial Old 组合收集器运行过程:

3. Parallel Scavenge收集器

Parallel Scavenge收集器是一个新生代并行收集器,采用复制算法

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

适用场景: 以高吞吐量为目标的场景,即减少垃圾收集时间,让用户代码获得更长的运行时间;当应用程序运行在具有多个CPU,对暂停时间没有特别高的要求的场景时(程序主要进行后台计算而不需要与用户进行太多交互)。例如:批量处理,订单处理,工资支付,科学计算的应用程序等。

参数设置

//控制最大垃圾收集停顿时间(设置太小,停顿时间会缩短,但也可能倒置吞吐量下降)
-XX:MaxGCPauseMillis

//设置垃圾收集时间占总时间的比率,0<n<100的整数(相当于设置吞吐量大小)
-XX:GCTimeRatio

//JVM会根据当前系统运行情况收集性能监控信息,动态调整这些参数,以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomiscs)
-XX:+UseAdptiveSizePolicy

老年代垃圾收集器

1. Serial Old收集器

 Serial Old收集器是Serial收集器的老年代版本,是一个老年代单线程收集器,采用标记-整理算法

适用场景: 主要用于Client模式

server模式下的两大用途

  • 在JDK1.5之前,与Parallel Scavenge收集器搭配使用(JDK1.6有Parallel Old收集器可搭配)

  • 作为CMS收集器的后备预案,在并发收集发生时使用(Concurrent Mode Failure)

下图是 Serial/Serial Old 组合收集器运行过程:

2. Parallel Old收集器

 Parallel Old收集器是Parallel Scavenge收集器的老年代版本,是一个老年代多线程收集器,采用标记—整理算法

适用场景: JDK1.6之后用来代替老年代的 Serial Old收集器(server模式与多CPU模式下)

参数设置

//指定使用Parallel Old收集器
-XX:+UseParallelOldGC

下图是 Parallel Scavenge/Parallel Old 组合收集器运行过程:

3. CMS收集器

并发标记清理(Concurrent Mark Sweep,CMS)收集器也称为并发低停顿收集器(Concurrent Low Pause Collector)低延迟(low-latency)垃圾收集器。CMS收集器是一种以获取最短回收停顿时间为目标的收集器。采用标记清除算法实现,它的运作过程分为如下四个步骤:

  • 初始标记(CMS initial mark): 仅标记一下GC Roots能直接关联到的对象,速度很快,需要Stop The World

  • 并发标记(CMS concurrent mark): 进行GC Roots Tracing的过程,从刚才产生的集合中标记出存活对象(无法标记出全部存活的对象),耗时最长

  • 重新标记(CMS remark): 为了修正并发标记期间因用户程序继续运行而导致标记变动的那一部分对象的标记记录,需要进行Stop The World,且停顿时间会比初始标记时稍长,但远比并发标记时短。采用多线程并行执行来提升效率。

  • 并发清除(CMS concurrent sweep): 由于整个过程中耗时最长的并发标记和并发清除过程,收集线程线程与用户线程都可以一起工作,所以总体来说CMS收集器的内存回收过程与用户线程一起并发执行。

下图是 Concurrent Mark Sweep 收集器运行过程:

CMS收集器的3个明显缺点

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

    并发收集虽然不会暂停用户线程,但因为占用了一部分CPU资源,会导致应用程序变慢,总吞吐量降低。CMS的默认收集线程数量=(CPU数量+3)/4,当CPU数量多于4个,收集线程占用的CPU资源多于25%,并且随着CPU数量的增加而下降。

    针对上述情况,虚拟机曾提供了一种名为**增量式并发收集器(Incremental Concurrent Mark Sweep/i-CMS)**的CMS收集器变种,使用类似抢占式模拟多任务机制的思想,让收集线程和用户线程交替运行,减少收集线程运行时间(JDK1.6后就官方不再提倡用户使用)。

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

    • 浮动垃圾:在并发清除时用户线程新产生的垃圾。使得并发清除时需要预留一定的内存空间,不能像其他收集器在老年代几乎填满再进行收集。
    //设置CMS预留的内存空间
    -XX:CMSInitiatingOccupancyFraction
    
    JDK1.5默认值为68%
    JDK1.6变为大约92%    
    
    • Concurrent Mode Failure 失败:如果CMS预留内存空间无法满足程序需要,就会出现一次 Concurrent Mode Failure失败。这时JVM启用后备预案:临时启用Serail Old收集器,导致另一次Full GC的产生。这样的代价较高,所以CMSInitiatingOccupancyFraction不能设置得太大
  • CMS收集器会产生大量内存碎片

    由于CMS采用标记-清除算法,会产生大量的内存碎片,无法找到足够的连续内存,从而需要提前触发另一次 Full GC 动作。

    解决方法:

    //使得CMS出现上面这种情况时不进行Full GC,而开启内存碎片的合并整理过程
    //但合并整理过程无法并发,停顿时间会变长
    //默认开启
    1. -XX:+UseCMSCompactAtFullCollection
    
    //设置执行多少次不压缩的Full GC后,进行一次压缩整理
    //默认为0,也就是说每次都执行Full GC,不会进行压缩整理
    2. -XX:+CMSFullGCsBeforeCompaction
    

总体来看,与Parallel Old垃圾收集器相比,CMS减少了执行老年代垃圾收集时应用暂停的时间,但却增加了新生代垃圾收集时应用暂停的时间、降低了吞吐量而且需要占用更大的堆空间。

G1收集器

 G1收集器是当今收集器技术发展的最前沿成果之一,是JDK7-u4才推出商用的收集器。采用标记-整理算法 + 复制算法老年代并行收集器

G1收集器的特点
  • 并行与并发: 能充分利用多CPU,多核环境下的硬件优势,使用多个CPU来缩短Stop The World停顿时间。能通过并发的方式让垃圾收集线程与用户程序同时执行

  • 分代收集: 能独立管理整个GC堆(新生代和老年代),而不需要其他收集器的配合;能采用不同方式处理不同时期的对象。

  • 空间整合: G1从整体上看是基于标记-整理算法,而局部上(两个Region)看是基于复制算法。这两种算法意味着G1运行期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

  • 可预测的停顿: 低停顿的同时实现高吞吐量。G1除了追求低停顿外,还能建立可预测的停顿时间模型。可以明确指定M毫秒时间片内垃圾收集消耗的时间不超过N毫秒。

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

G1收集器的适用场景
  • 面向服务端应用,针对具有大内存,多处理器的机器。

  • 为需要低GC延迟,并且具有大堆的应用程序提供解决方案

  • 替代CMS收集器

    • 超过50%的Java堆被活动数据占用时
    • 对象分配频率或年代提升频率很大时
    • GC停顿时间过长时(长于0.5至1秒)
G1的参数设置
  • -XX:+UseG1GC: 指定使用G1收集器

  • -XX:InitiatingHeapOccupancyPercent: 当整个Java堆的占用率达到参数值时,开始并发标记阶段,默认为45

  • -XX:MaxGCPauseMillis: 为G1设置暂停时间目标,默认值为200毫秒

  • -XX:G1HeapRegionSize: 设置每个Region大小,范围1MB到32MB;在最小Java堆时可以拥有约2048个Region

G1能实现可停顿预测的原因
  • G1可以有计划的避免在Java堆进行全区域的垃圾收集

  • G1跟踪各个Region获得其收集价值的大小,在后台维护一个优先队列

  • 每次G1根据允许的收集时间,优先回收价值最大的Region(Garbage-First)

  • 保证在有限时间内可以获取尽可能高的收集效率

对象被不同区域引用问题

 Region之间不可能是孤立的,一个Region中的对象可以被其他任意Region中的对象引用,于是就出现了一个问题:判断对象是否存活时,是否需要扫描整个Java堆才能保证准确?回收新生代也必须扫描老年代吗?。这样的问题在其他收集器也同样存在,但是正在G1中却更为突出。

解决办法: 无论G1还是其他分代收集器,JVM都是使用Remembered Set来避免全局扫描。每个Region都有一个对应的Remembered Set,每次Reference类型数据进行写操作时,都会产生Write Barrier暂时中断操作,然后检查Reference引用的对象是否处于不同的Region之中(其他收集器:检查老年代对象是否引用了新生代对象);如果不同,通过CardTable把相关引用信息记录到引用指向对象的所在Region对应的Remembered Set中。当进行垃圾收集时,在GC根节点的枚举范围加入Remembered Set,就可以保证不进行全局扫描,也不会有遗漏。

G1收集器运作过程

如果不计算维护emembered Set的操作,可以分为4个步骤:

  • 初始标记(Initial Marking): 仅标记一下GC Roots能直接关联到的对象,且修改TAMS(Next Top at Mark Start),让下一阶段并发运行时,用户程序能在正确可用的Region中创建新对象。需要Stop The World,速度很快。

  • 并发标记(Concurrent Marking):进行GC Roots Tracing的过程,从刚才产生的集合中标记出存活对象(无法标记出全部存活的对象),耗时较长

  • 最终标记(Final Marking): 为了修正并发标记期间因用户程序继续运作而导致标记变动的那一部分对象的标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,需要Stop The World,但可以多线程并行执行。停顿时间比初始标记稍长,但远比并发标记短。

  • 筛选回收(Live Data Counting and Evacuation): 首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划,最后按计划从Region回收一些价值高的对象。回收时采用复制算法,从一个或多个Region复制存活对象到堆上的另一个空Region,并且在此过程中压缩和释放内存。可以并发进行,降低停顿时间,增加吞吐量。

下图是 G1 收集器运行过程:

垃圾收集器总结

垃圾回收算法总结如下:

垃圾收集器总结如下:

虚拟机参数总结如下:

参考

10-03 20:55