JVM探索之GC

前言

JVM系统架构中执行引擎是其中一个重要组成部分,执行引擎的作用就是将字节码指令解释或者编译为对应平台上的本地机器指令。本文主要介绍执行引擎的编译功能以及JVM的垃圾回收

执行引擎

执行引擎的功能是将Java代码生成的class字节码文件转换成机器能够执行的二进制指令,因为JVM加载字节码相关的指令后,这些字节码指令、符号表以及其他辅助信息无法被操作系统直接识别执行,所以需要执行引擎进行翻译。

在介绍JVM的执行引擎之前先了解一下Java代码的编译和执行流程

Java的源码.java文件先通过编译器进行编译生成JVM虚拟机可执行的.class字节码文件。这个编译过程同C/C++的编译不同,当C编译器编译生成一个对象的代码时,该代码是为了在某一特定的硬件平台运行产生的。因此,在编译过程中,编译程序通过查表将所有对符号的引用转换成特定的内存偏移量,以保证程序运行。Java编译是将对变量和方法的引用编译为数值引用,也不确定程序执行过程中的内存布局,而是将这些符号引用保留在字节码中,由解释器在运行过程中创立内存布局,然后再通过查表来确定一个方法所在的地址。这样确保了Java的可移植性和安全性。

编译与解释

运行JVM字节码的工作是由解释器来完成的。解释执行过程分三步进行:代码的装入、代码的校验和代码的执行。装入代码的工作由"类装载器"(class loader)完成。类装载器负责装入运行一个程序需要的所有代码,这也包括程序代码中的类所继承的类和被其调用的类。当类装载器装入一个类时,该类被放在自己的名字空间中。除了通过符号引用自己名字空间以外的类,类之间没有其他办法可以影响其他类。在本台计算机上的所有类都在同一地址空间内,而所有从外部引进的类,都有一个自己独立的名字空间。这使得本地类通过共享相同的名字空间获得较高的运行效率,同时又保证它们与从外部引进的类不会相互影响。当装入了运行程序需要的所有类后,解释器便可确定整个可执行程序的内存布局。解释器为符号引用同特定的地址空间建立对应关系及查询表。通过在这一阶段确定代码的内存布局,Java很好地解决了由超类改变而使子类崩溃的问题,同时也防止了代码对地址的非法访问

Client Compiler注重启动速度和局部的优化,Server Compiler则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢。

字节码的运行方式目前主要介绍下面几种

  1. 解释执行
  2. 提前静态编译(AOT)
  3. 即时编译器编译(JIT)

解释执行

解释执行由执行引擎中的解释器负责,解释器对每条字节码进行翻译成机器指令,由于每一条指令都需要翻译效率比较低

提前静态编译AOT

AOT编译是JDK9才引入的,在程序运行之前将字节码转换成机器码。这样做的好处是启动速度相对解释执行快,效率更高。但是由于发布前进行编译不够灵活无法动态优化,并且由于是提前编译,无法支持反射这样的动态加载(可以通过配置和工具进行支持)

即时编译JIT

JIT在编译期间会探测热点代码(循环、调用频率高等)直接编译成本地机器码并进行缓存,当字节码执行到这部分代码时直接使用之前编译的机器码

目前的HotSpot同时使用解释器和JIT,在Java程序运行时,JVM可以快速启动,前期先由解释器发挥作用,不需要等到编译器把所有字节码指令编译完之后才执行,这样可以省去很大一部分的编译时间。后续随着程序在线上运行的时间越来越久,JIT发挥作用,慢慢的将一些程序中的热点代码替换为本地机器码运行,这样可以让程序的执行效率更高。同时,因为HotSpotVM中存在热度衰减的概念,所以当一段代码的热度下降时,JIT会取消对它的编译,重新更换为解释器执行的模式工作,所以HotSpot的这种执行模式也被成为"自适应优化"执行

HotSpot提供了两种不同的即时编译器,Client Complier和Server Complier,简称为C1、C2编译器,分别用在客户端和服务端。目前主流的HotSpot虚拟机中默认是采用解释器与其中一个编译器直接配合的方式工作。程序使用哪个编译器,取决于虚拟机运行的模式

在 HotSpot 实现中有多种选择:C1、C2 和 C1 + C2,分别对应 client、server 和分层编译。

  1. C1 编译速度快,优化方式比较保守;
  2. C2 编译速度慢,优化方式比较激进;
  3. C1 + C2 在开始阶段采用 C1 编译,当代码运行到一定热度之后采用 C2 重新编译;

在 JDK8 之前,分层编译默认是关闭的,可以添加 -server -XX:+TieredCompilation 参数进行开启

C1和C2不同的优化策略

C1编译器上主要有方法内联,去虚拟化、冗余消除

  • 方法内联:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程
  • 去虚拟化:对唯一的实现樊进行内联
  • 冗余消除:在运行期间把一些不会执行的代码折叠掉

C2的优化主要是在全局层面,逃逸分析是优化的基础

  • 标量替换:用标量值代替聚合对象的属性值
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆
  • 同步消除:清除同步操作,通常指synchronized

热点代码探测

JIT会将运行中的热点代码编译成机器码,而对于热点代码的判断就是热点代码探测技术

尽管编译动作是由循环体所触发的,热点只是方法的一部分,但编译器依然必须以整个方法作为编译对象,只是执行入口(从方法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。这种编译方式因为编译发生在方法执行的过程中,因此被很形象地称为栈上替换"(On Stack Replacement,OSR),即方法的栈帧还在栈上,方法就被替换了。

目前主流的热点探测判定方式有两种,分别是:

  • 基于采样的热点探测(Sample Based Hot Spot Code Detection) 采用这种方法的虚拟机会周期性地检查各个线程的调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是"热点方法"。基于采样的热点探测的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

  • 基于计数器的热点探测(Counter Based Hot Spot Code Detection) 采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是"热点方法"。这种统计方法实现起来要麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系。但是它的统计结果相对来说更加精确严谨。

在HotSpotVM中,热点代码探测技术主要是基于计数器实现的。HotSpot中会为每个方法创建两个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter),方法调用计数器主要用于统计方法被调用的次数,回边计数器主要用于统计一个方法体中循环体的循环次数

调用计数器

方法调用计数器的阈值在Client模式下默认是1500次,在Server模式下默认是10000次,当一段代码的执行次数达到这个阈值则会触发JIT即时编译。

可以通过JVM参数-XX :CompileThreshold来自己指定

回边计数器

回边计数器的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令就称为"回边(Back Edge)",很显然建立回边计数器统计的目的是为了触发栈上的替换编译。

HotSpot虚拟机也提供了一个类似于方法调用计数器阈值-XX:CompileThreshold 的参数-XX:BackEdgeThreshold供用户设置,但是当前的HotSpot虚拟机实际上并未使用此参数,我们必须设置另外一个参数-XX:OnStackReplacePercentage来间接调整回边计数器的阈值

热度衰减

当我们以默认形式启动Java时,方法调用计数器统计的执行次数并不是绝对次数,而是一个相对的执行频率,也代表是指方法在一段时间内被执行的次数。当超过一定的时间,但计数器还是未达到编译阈值无法提交给JIT即时编译器编译时,那此时就会对计数器进行减半,这个过程被称为方法调用计数器的热度衰减(Counter Decay),而这段时间则被称为方法调用计数器的半衰周期(Counter Half Life Time)。

而发生热度衰减的动作是在虚拟机GC进行垃圾回收时顺带进行的,可以通过参数-XX:-UseCounterDecay关闭热度衰减,这样可以使得方法调用计数器的判断基准变为绝对调用次数,而不是以相对执行频率作为阈值判断的标准。不过如果关闭了热度衰减,就会导致一个Java程序只要在线上运行的时间足够长,程序中的方法必然绝大部分都会被编译为本地机器码。

注意:热度衰减只是针对于方法调用计数器,回边计数器没有计数热度衰减的过程,因此这个计数器统计的就是该方法循环执行的绝对次数。当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程
注意在回边计数器的流程中有一个步骤是调整回边计数器的值,这个实际操作是降低回边计数器的值。我对这里的理解是进行OSR编译需要一定的时间,但是这里触发的是循环,执行非常快,如果没有降低则会继续提交OSR编译。

GC

在执行引擎中除了上面介绍的解释器和JIT还有一个很重要的组成部分就是垃圾回收器。

垃圾回收器要从垃圾回收算法、垃圾回收种类以及具体的垃圾回收器几个方面介绍。

对象存活判断算法

垃圾回收顾名思义是回收"垃圾对象",不会对存活的对象进行回收。JVM判断对象是否存活主要有两种算法,分别是引用计数法和可达性分析算法。

引用计数法

当一个对象被创建的时候自身都会携带一个引用计数器,当一个指针指向当前对象时,该计数器就会+1。

java 复制代码
Object obj = new Object();

如上述例子,Object对象实例被创建之后计数器被初始化为1,因为局部变量obj的指针引用了该实例对象,后续代码中如果有其他对象引用该实例时,该对象的引用计数器会+1。而当前方法执行结束,栈帧中的局部变量表中的引用对象的指针被销毁时,当前对象的引用计数器会-1。当一个对象的计数器为0时,表示这个对象已经没有指针引用它,那么在下一次发生GC时,这个对象就会被判定为"垃圾对象"被垃圾回收器进行回收。

优势

实现简单、垃圾便于辨识,回收没有延时性

缺点

  1. 额外的存储计数器增加了存储成本和更新时的时间开销
  2. 无法解决循环引用的问题

这里着重说一下循环引用的问题,由于引用计数法是根据一个对象的引用数量来判断是否能够进行回收,当我们两个对象出现循环依赖时这两个对象的引用数永远不会为0,意味着这两个对象永远不会被回收,最终会造成两个对象所占的空间发生内存泄露。(Java并没有选择使用引用计数法作为垃圾判断算法,但是Python、PHP等使用该算法)

可达性分析法

可达性算法是从GC Roots根节点开始从上到下进行搜索分析,搜索分析走过的链路被称为引用链,当一个对象没有任何引用链相连时,则会被判定为该对象是不可达的,即代表着此对象不可用,最终该对象会被判定为垃圾对象等待回收。

Gc Roots对象有下面四大类

  • 虚拟机栈中引用的对象
  • 方法区中的静态变量引用的对象
  • 方法区中的常量引用的对象
  • 本地方法栈中JNI(native方法)中引用的对象

除了上面四类对象可以作为根节点外synchronized持有的对象、JVM自身引用的对象(类加载器、NullPointExcepiton、OutOfMemoryError等)都可以作为GC Root

OopMap

GC Roots 枚举的过程中,是需要暂停用户线程的,对栈进行扫描,找到哪些地方存储了对象的引用。然而,栈存储的数据不止是对象的引用,因此对整个栈进行全量扫描,显然是很耗费时间,影响性能的。因此,在 HotSpot 中采取了空间换时间的方法,使用 OopMap 来存储栈上的对象引用的信息。在 GC Roots 枚举时,只需要遍历每个栈桢的OopMap,通过OopMap 存储的信息,快捷地找到 GC Roots

OopMap 中存储了两种对象的引用:

  1. 栈里和寄存器内的引用
  2. 在即时编译中,在特定的位置记录下栈里和寄存器里哪些位置是引用

对象内的引用

类加载动作完成时,HotSpot 就会计算出对象内什么偏移量上是什么类型的数据

把存储单元的实际地址与其所在段的段地址之间的距离称为段内偏移,也称为有效地址或偏移量,因此,实际地址=所在段的起始地址+偏移量

在 JVM中,一个线程为一个栈,一个栈由多个栈桢组成,一个栈桢对应一个方法,一个栈帧可能有多个OopMap

跨代引用

JVM分代收集中可能存在一种情况那就是新生代中存在对老年代的引用,或者老年代中存在对新生代的引用

因为新生代的发生的young gc是十分频繁的,但是如果新生代中的对象被老年代引用那就必须遍历老年代来确保可达性分析结果的正确。但是跨代引用这种场景出现的很少(即使发生了可能由于老年代的对象存活时间比较长,新生代对象后面晋升到老年代而消失)

为了解决这个问题,JVM使用记忆集这一数据结构解决这个问题。记忆集位于新生代中,是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。用以避免把整个老年代加进GC Roots扫描范围

记忆集的作用和我们之前讲的OopMap很相似,维护了类似一种映射表的关系,避免了全局扫描,本质是用空间换时间。

此后当发生YGC时,只要把记忆集加进来一起扫描,就能知道新生代对象被老年代引用的情况,而不必扫描整个老年代。

G1之前都是使用卡表(CardTable)的方式实现记忆集,G1使用Remembered Set。

在HotSpot虚拟机里面,卡表采用的是字节数组的形式。字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作卡页(Card Page)

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。简单来说,就是卡页的字节数组只有0和1两种状态,1表示哪些内存区域存在跨代指针,那么只要把1的加入GC Roots中一并扫描,就能知道哪些进行跨代引用了,这样就不用挨个去扫描了。

卡页变脏是其他分代区域中的对象引用了本区域中的对象时,变脏时间点原则上应该发生在引用类型字段赋值的那一刻,HotSpot通过写屏障实现写屏障可以看作在虚拟机层面对「引用类型字段赋值」这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知

三色标记

在并发的可达性分析算法中我们使用三色标记(Tri-color Marking)来标记对象是否被收集器访问过:

  • 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
  • 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
  • 灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。

可达性分析的扫描过程,其实就是一股以灰色为波峰的波纹从黑向白推进的过程,但是在并发的推进过程中会产生"对象消失"的问题,如图:

对象消失理论,只有同时满足才会发生对象消失:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用;

要解决对象消失问题只需要破坏其中一条就行了,目前常用有两种方案:

  • 增量更新(Incremental Update):增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。
  • 原始快照(Snapshot At TheBeginning,SATB):原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

以上无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

finalize与对象复活

finalize是Object类的八个方法之一,所有类都可以重写这个方法,它的作用就是在一个对象没有任何引用,被垃圾回收器回收之前执行这个方法,我们可以通过重写这个方法让对象重新进入引用链。

finalize()是当对象没有任何引用的时候,在发生GC之前会调用这个方法的finalize()。但是如果这个对象所属的类没有重写finalize(),或者这个方法已经被执行过一次就不会在执行这个方法。反之如果重写该方法并且没有执行过,那么该对象会被插入到F-Queue队列中,这个队列是JVM自动创建的一个队列,由低优先级的Finalizer线程执行队列里对象的finalize()。如果我们重写的finalize()方法中让我们这个对象重新和引用链上的对象建立联系,那么这个对象就会被移出这个队列成为一个正常的存活对象。但是要注意由于这个执行线程的优先级比较低,当出现入队速度大于出队速度,比如:我们有很多不在引用链上的对象都重写了finalize(),或者finalize()中执行耗时操作会引发OOM。而且finalize()中发生异常是uncheck,无法通过日志打印出异常信息。Java9中已经废弃了这个接口改用Cleaner接口。

垃圾回收算法

  • 标记-清除算法:标记无用对象,然后进行清除回收
  • 复制算法:按照容量划分两个大小相等的内存区域,当一块用完的时候将活着的对象复制到另一块上,然后再将已使用的内存空间一次清理掉
  • 标记-整理算法:标记无用对象,让所有存活的对象都向一端移动,然后直接清除掉端边界以外的内存
  • 分代算法:根据对象存活周期的不同将内存划分为几块,一般是新生代和老年代,新生代基本采用复制算法、老年代采用标记整理算法

标记-清除算法

标记无用对象,然后进行清除回收,标记-清除算法是一种常见的基础垃圾收集算法,他将垃圾收集分为两个阶段

  1. 标记阶段:标记出可以回收的对象
  2. 清除阶段:回收标记的对象所占用的空间

优点:实现简单,不需要对象进行移动

缺点:标记、清除过程效率低,产生大量不连续的内存碎片,提高了垃圾回收的效率

复制算法

为了解决标记-清除算法的效率不高的问题,产生了复制算法。它把内存空间划分为两个相等的区域,每次只使用其中一个区域。垃圾收集时,遍历当前使用的区域,把存活对象复制到另一个区域中,最后将当前使用的区域的可回收对象进行回收

优点:按顺序分配内存即可,实现简单、运行高效,不用考虑内存碎片

缺点:可用的内存大小缩小为原来的一半,对象存活率高时会频繁进行复制

复制算法的高效是建立在大部分对象都"朝生夕灭"的特性上的,如果存活对象过多,复制对象并维持其正确性就成为一个沉重的负担

HotSpot虚拟机的Serial、ParNew等新生代收集器均使用这种算法。将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配只使用Eden和其中的一块Survivor,发生垃圾收集时,将Eden和Survivor中的存活对象一次性复制到另一块Survivor中,之后直接清理掉Eden和用过的那块Survivor区。HotSpot虚拟机默认Eden和Survivor的比例是8:1

分配担保

复制回收时,如果存活对象的大小大于Survivor时,这些对象直接进入老年代

标记-整理算法

在新生代中可以使用复制算法,但是在老年代就不能选择复制算法了,因为老年代的对象存活率会较高,这样会有较多的复制操作,导致效率变低。标记-清除算法应该可以应用在老年代中,但是它效率不高,在内存回收后容易产生大量内存碎片。因此就出现了一种标记-整理算法,与标记-清除不同的是,在标记可回收的对象后将所有存活的对象压缩到内存的一端,使他们紧凑的排列在一起,然后对端边界以外的内存进行回收。回收后,已用和未用的内存都各自一边

优点:解决了标记-清除算法存在内存碎片问题

缺点:仍需要进行局部对象移动,一定程度上降低了效率

分代收集算法

当前商用虚拟机都采用分代收集的垃圾收集算法。分代收集就是根据对象的存活周期将内存划分成几块。一般包括新生代、老年代

新生代

大部分新生成的对象首先都会放到新生代中,新生代的目标就是尽可能快速的收集掉那些生命周期短的对象。新生代一般分为一个Eden区,两个Survivor区。大部分对象在Eden区生成。当Eden区满时,还存活的对象将被复制到其中一个Survivor区,当这个Survivor区也满的时候,从第一个Survivor区复制过来并且此时还存活的对象,将被复制到另一个Survivor区。需要注意的是:两个Survivor区是对称的,没有先后关系,所以同一个区可能同时存在从Eden区复制过来的对象以及从另一个Survivor区复制过来的对象。并且Survivor区总有一个是空的,同时可以根据程序需要将Survivor区配置为多个,这样可以增加对象在新生代中存在的时间,减少放到老年代的可能。

老年代

在新生代中经历了N(最多15)次垃圾回收后仍然存活的对象会被放到老年代中。因此,可以认为老年代中存放的都是一些生命周期较长的对象

新生代对象进入老年代的条件
  • 长期存活的对象

如果对象在Eden出生并经过第一次MinorGC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并将对象年龄设为1。对象在Survivor区中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,其实每个M、每个GC都有所不同)时,就会被晋升到老年代中。判断的年龄可以通过-XX:MaxTenuringThreshold来设置。

  • 大对象直接进入老年代

新生代中使用复制算法收集垃圾,大对象直接进入老年代可以避免在Eden和Survivor区发生大量的内存复制,另一个原因是Survivor空间比较小,大对象可以无法存放(比较经典的大对象就是很长的字符串以及数组)。

  • 动态对象年龄判定 为了更好的适应不同程序的内存情况,虚拟机不是永远要求对象年龄必须达到了某个值才能进入老年代,如果 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无需达到要求的年龄

注意上面说的相同年龄的对象的大小总和大于Survivor空间的一半其实应该是年龄从小到大的累加和,而不是某个年龄段对象的大小,这个空间的一半其实是由参数TargetSurvivorRatio控制

  • 动态空间担保机制 1.在发生 Minor GC 之前,虚拟机先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果条件成立的话,那么 Minor GC 可以确认是安全的

2.如果不成立的话,虚拟机会查看 HandlePromotionFailure 设置值是否允许担保失败,如果允许那么就会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那么就要进行一次 Full GC

因为新生代使用的是复制算法,为了内存利用率,只使用其中一个Survivor区来做轮换备份,因此如果大量对象在minor GC后仍存活,导致Survivor区空间不够用,就会通过分配担保机制,将多出来的对象提前转到老年代,但老年代要进行担保的前提是自己本身还有容纳这些对象的剩余空间,由于无法提前知道会有多少对象存活下来,所以取之前每次晋升到老年代的平均大小作为经验值,与老年代的剩余空间作比较

stop the world

stop the world简称STW,一般发生在GC的时候,JVM会停下所有的用户线程,从而导致Java程序出现全局停顿的无响应情况,在发生STW之后,所有的Java代码会停止运行,不过native代码是可以继续执行的,但也不能和JVM交互。一般发生STW都是由于GC引起的,但在某几种少数情况下,也会导致STW出现,如线程Dump、死锁检查、堆日志Dump等

发生GC时必须要STW主要有两个原因

  • 避免产生浮动垃圾

如果在GC发生时不停下用户线程,那么会导致这么一种情况出现,就是刚刚标记完成一块区域中的对象,但转眼用户线程又在该区域中产生了新的"垃圾"。同时,如果在GC发生时不做全局停顿,带来的后果则是:会给GC线程造成很大的负担,GC算法的实现难度也会增加,因为GC机制很难去准确判断哪些是垃圾。

  • 确保内存一致性

在GC发生时,可达性分析算法的工作必须要在一个能够确保一致性的内存快照中进行。也就是指:在整个分析期间,JVM看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果的准确性无法得到保证。这也是GC进行时,必须停止所有用户线程的其中一个重要原因。

GC的分类

JVM发生GC的区域主要是新生代、老年代、方法区(Java7的永久代会跟着老年代一起发生GC,但是Java8的元空间不需要显式的垃圾回收)。程序运行期间,绝大多数GC都是发生在新生代。

一般而言,GC可以分为两大类

  • 部分收集:

    1. 新生代收集(Minor GC):只进行新生代垃圾收集。

当Eden区空间不足时会触发Minor GC,因为Java对象大多都是朝生夕死的特性,所以Minor GC非常频繁,并且回收速度非常快。Minor GC会引发STW

  1. 老年代收集(Major GC):只进行老年代垃圾收集(目前只有CMS这种GC会有单独收集老年代的行为)

当老年代空间不足时,会触发Major GC。如果出现了Major GC,经常会伴随至少一次的Minor GC(但并非绝对,Paralle Scavenge收集器的收集策略就有直接进行Major GC的策略选择过程)。

Major GC的速度一般会比Minor GC慢10倍以上,STW的时间更长。如果Major GC后,内存还不足,就报OOM了。

  1. 混合收集(Mixed GC):进行整个新生代以及部分老年代的垃圾收集(目前只有G1会有这样的行为)
  • 整堆收集(Full GC):收集整个Java堆和整个方法区的垃圾收集

Full GC触发的时机有下面几种:

1.调用System.gc()时,系统建议执行Full GC,但不是必然的

2.老年空间不足

3.方法区空间不足(Java8之后元空间内存不足不会触发)

4.通过Minor GC 进入老年代的平均大小大于老年代的可用内存

5.由Eden区、S0区向S1区复制时,对象大小大于S1区可用内存,会把对象转存到老年代,并且老年代可用内存小于该对象的大小

安全点和安全区域

安全点

无论是在GC中还是并发编程中,都会经常出现安全点这个概念,因为当我们需要阻塞停止一条线程时,都需要在安全点停止,简单说安全点就是指当线程运行到这类位置时,堆对象状态是确定一致的,线程停止后,JVM可以安全地进行操作,如GC、偏向锁撒销等。

而JVM中对于安全点的定义主要有如下几种:

  • 循环结束的末尾段
  • 方法调用之后
  • 抛出异常的位置
  • 方法返回之前

当JVM需要发生GC、偏向锁撤销等操作时,如何才能让所有线程到达安全点阻塞或停止?

  • 主动式中断(JVM采用的方式):不中断线程,而是设置一个标志,而后让每条线程执行时主动轮询这个标志,当一个线程到达安全点后,发现中断标志为true时就自己中断挂起。
  • 抢断式中断:先中断所有线程,如果发现线程未执行到安全点则恢复线程让其运行到安全点位置。

安全区

安全区域是指一条线程执行到一段代码时,该区域的代码不会改变堆中对象的引用。在这区域内JVM可以安全地进行操作。当线程进入到该区域时需要先标识自己进入了,这样GC线程则不会管这些已标识的线程,当线程要离开这个区域时需要先判断可达性分析是否完成,如果完成了则往下执行,如果没有则需要原地等待到GC线程发出安全离开信息为止。

GC介绍

目前Java8的默认GC是Parallel Scavenge(复制算法) + Serial Old(标记整理),Java9开始默认是G1

G1内存模型

G1之前的堆空间在逻辑和物理上都是分代的,但是G1只是逻辑上进行了分代,物理上没有。G1将堆中的内存区域划分成一个个的Region区(这样划分JVM不需要再为堆空间分配连续的内存,并且可以根据不同的region进行回收节约了时间)。每个Region可能是新生代也可能是老年代也可能是新生代。

G1将Java堆划分为多个大小相等的独立的Region区域,不过在HotSpot的源码TARGET_REGION_NUMBER定义了Region区的数量限制为2048个(实际上允许超过这个值,但是超过这个数量后,堆空间会变的难以管理),一般Region大小的计算方式是堆空间的总大小除以2048

默认新生代对堆内存的初始占比是5%,如果堆大小为8GB,那么年轻代占据400MB左右的内存,对应大概是100个Region区,可以通过-XX:G1NewSizePercent设置新生代初始占比。

在Java程序运行中,JVM会不停的给新生代增加更多的Region区,但是最多新生代的占比不会超过堆空间总大小的60%,可以通过-XX:G1MaxNewSizePercent调整(也不推荐,如果超过这个比例,年老代的空间会变的很小,容易触发全局GC)。新生代中的Eden区和Survivor区对应的Region区比例也跟之前一样,默认8:1:1,假设新生代现在有400个Region,那么整个新生代的占比则为Eden=320,S0/From=40,S1/To=40

大对象处理

注意G1提供了专门的Region来存放大对象,而不是让大对象进入老年代的Region中。在G1中,大对象的判定规则就是一个大对象超过一个Region大小的50%,比如按照上面算的,每个Region是2M,只要一个大对象超过1M,就会被放入大对象专门的Region中,而且如果一个大对象太大,可能会横跨多个Region来存放

Humongous区存在的意义:可以避免一些"短命"的巨型对象直接进入年老代,节约年老代的内存空间,可以有效避免年老代因空间不足时的GC开销

G1新生代垃圾回收触发点和如何设置回收时间

当新生代的Eden对应的Region中不停放对象,JVM会不停的给新生代加入更多的Region,直到新生代占据堆大小的最大比例60%,一旦新生代达到了设定的占据堆内存的最大大小60%,比如都有1200个Region了,里面的Eden可能占据了1000个Region,每个Survivor是100个Region,而且Eden区还占满了对象,此时如下图所示

这个时候还是会触发新生代的GC,G1就会用之前说过的复制算法来进行垃圾回收,进入一个"Stop the World"状态。(但是stw的的时间是可以控制的),然后把Eden对应的Region中的存活对象放入S1对应的Region中,接着回收掉Eden对应的Region中的垃圾对象,如下图

但是这个过程跟之前是有区别的,因为G1是可以设定目标GC停顿时间的,也就是G1执行GC的时候最多可以让系统停顿多长时间,那么也就意味着,GC完成之后,肯定存在某些region 的垃圾是不能回收的,因为需要在设定的时间内进行回收垃圾。可以通过"-XX:MaxGCPauseMills"参数来设定,默认值是200ms。那么G1就会通过之前说的,对每个Region追踪回收他需要多少时间,可以回收多少对象来选择回收一部分的Region,保证GC停顿时间控制在指定范围内,尽可能多的回收掉一些对象

预期停顿

G1最大的特点就是可以让我们设置一个垃圾回收的预期停顿时间,也就是说比如我们可以指定:希望G1在垃圾回收的时候可以保证,在一个小时内G1垃圾回收导致的STW时间,也就是系统停顿时间不能超过一分钟。G1要做到这一点就必须追踪每个Region里的回收价值(它必须搞清楚每个Region里的对象有多少是垃圾,如果这个Region进行垃圾回收需要耗费多少时间,可以回收多少垃圾)

如图,G1通过追踪发现,1个Region中的垃圾对象有10M,回收他们需要耗费1秒,另一个Region中的垃圾对象有20M,回收他们需要200毫秒,然后在垃圾回收的时候,G1会发现在最近的一个时间段内,垃圾已经导致了几百毫秒的系统停顿,现在又要执行一次垃圾回收,那么必须是回收图中那个只需要200毫秒就能回收掉的20M垃圾的Region,于是G1触发了一次垃圾回收,虽然可能会导致系统停顿了200毫秒,但是一下子回收了更多垃圾,如下图

简单来说,G1可以做到让你来设定垃圾回收对系统的影响,它自己通过把内存拆分成大量小Region,以及追踪每个Region中可以回收的对象大小和预估时间,最后在垃圾回收的时候,尽量把垃圾回收对系统造成的影响控制在你指定的时间范围内,同时在有限的时间内尽量回收尽可能多的垃圾对象,这就是G1的核心设计思路

不同分代的GC

Young GC

G1对于整个堆空间所有的Region区不会在一开始就全部分配完,无论是新生代、幸存区以及年老代在最开始都是会有初始数量的,在程序运行过程中会根据需求不断增加每个分代区域的Region数量。所以YoungGC并非说Eden区放满了就会立马被触发,在G1中,当新生代区域被用完时,G1首先会大概计算一下回收当前的新生代空间需要花费多少时间,如果回收时间远远小于参数-XX:MaxGCPauseMills设定的值,那么不会触发YoungGC,而是会继续为新生代增加新的Region区用于存放新分配的对象实例。直至某次Eden区空间再次被放满并经过计算后,此次回收的耗时接近-XX:MaxGCPauseMills参数设定的值,那么才会触发YoungGC。

G1收集器中的新生代收集,依旧保留了分代收集器的特性,当YoungGC被触发时,首先会将目标Region区中的存活对象移动至幸存区空间(被打着Survivor-from区标志的Region)。同时达到晋升年龄标准的对象也会被移入至年老代Region中存储(G1默认停顿时间是200ms)

Mixed GC

MixedGC翻译过来的意思为混合型GC,而并非是指FullGC。当整个堆中年老代的区域占有率达到参数-XX:InitiatingHeapOccupancyPercent设定的值后触发MixedGC,发生该类型GC后,会回收所有新生代Region区、部分年老代Region区(会根据期望的GC停顿时间选择合适的年老代Region区优先回收)以及大对象Humongous区。如果在进行Mixed回收的时候,无论是年轻代还是老年代都基于复制算法进行回收,都要把各个Region的存活对象拷贝到别的Region里去,此时万一出现拷贝的过程中发现没有空闲Region可以承载自己的存活对象了,就会触发 一次失败。 一旦失败,立马就会切换为停止系统程序,然后采用单线程进行标记、清理和压缩整理,空闲出来一批Region,这个过程是极慢极慢的

Full GC

当整个堆空间中的空闲Region不足以支撑拷贝对象或由于元数据空间满了等原因触发,在发生FullGC时,G1首先会停止系统所有用户线程,然后采用单线程进行标记、清理和压缩整理内存,以便于清理出足够多的空闲Region来供下一次MixedGC使用。但该过程是单线程串行收集的,因此这个过程非常耗时的

G1垃圾收集过程

  1. 初始标记:标记与GC Roots直接关联的对象,停止所有用户线程,只启动一条初始标记线程,这个过程很快
  2. 并发标记:进行全面的可达性分析,开启一条并发标记线程与用户线程并行执行,这个过程比较长
  3. 最终标记:标记出并发标记过程中用户线程新产生的垃圾,停止用户线程,并使用多条最终标记线程并行执行
  4. 筛选回收:回收废弃的对象,此时也需要停止一切用户线程,并使用多条筛选回收线程并行执行(G1会根据设置的系统暂停时间回收尽可能多的垃圾)
相关推荐
程序猿20233 小时前
MAT(memory analyzer tool)主要功能
jvm
期待のcode6 小时前
Java虚拟机的非堆内存
java·开发语言·jvm
jmxwzy9 小时前
JVM(java虚拟机)
jvm
Maỿbe10 小时前
JVM中的类加载&&Minor GC与Full GC
jvm
人道领域11 小时前
【零基础学java】(等待唤醒机制,线程池补充)
java·开发语言·jvm
小突突突11 小时前
浅谈JVM
jvm
饺子大魔王的男人13 小时前
远程调试总碰壁?局域网成 “绊脚石”?Remote JVM Debug与cpolar的合作让效率飙升
网络·jvm
天“码”行空1 天前
java面向对象的三大特性之一多态
java·开发语言·jvm
独自破碎E1 天前
JVM的内存区域是怎么划分的?
jvm
期待のcode1 天前
认识Java虚拟机
java·开发语言·jvm