前言

为了更好的理解jvm运行机制,在工作上每每写完一个功能的代码后再做一步Jvm的性能调优,就像每次执行代码规范检查一样。 会让你的系统更健壮。

整体内容算是学习这本《深入理解JVM虚拟机》的阅读笔记,所以整个目录结构也是跟这个书保持了一致,相信一切都是最好的安排

欢迎指出日志中不足的地方,包含错误或者描述不清的地方。

1. 第二部分 自动内存管理实践

runing

1.1. 程序计数器(Program Counter Register)

程序计数器主要存储当前线程所执行的字节码行号指示器,通过改变计数器的值来获取下一条需要执行的字节码指令。

首先我们要搞清楚JVM的多线程实现方式。JVM的多线程是通过CPU时间片轮转(即线程轮流切换并分配处理器执行时间)算法来实现的。也就是说,某个线程在执行过程中可能会因为时间片耗尽而被挂起,而另一个线程获取到时间片开始执行。当被挂起的线程重新获取到时间片的时候,它要想从被挂起的地方继续执行,就必须知道它上次执行到哪个位置,在JVM中,通过程序计数器来记录某个线程的字节码执行位置。因此,程序计数器是具备线程隔离的特性,也就是说,每个线程工作时都有属于自己的独立计数器。

特点:

  • 线程隔离性,每个线程有自己的独立技术器。

  • 执行java方法时,计数器中记录的是正在执行的虚拟机字节码指令地址

  • 执行native方法时,计数器值为空。why? native方法大多是通过C实现的并未编译成需要执行的字节码指令,也就不需要存储执行指令地址了。

  • jvm规范中唯一没有规定OutOfMemoryError情况的区域

  • 占用内存很小,可忽略不计

1.2. 虚拟机栈(VM Stack)

虚拟机栈描述的是Java方法执行的线程内存模型,存放的是一个方法的所有局部变量。

每个方法被执行的时候,虚拟机栈都会同步创建一个栈帧(Stack Frame) 用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法被调用直至执行完毕的过程就对应着一个栈帧在虚拟机中从入栈到出栈的过程

下面我们用这段代码介绍下虚拟机栈结构及用途

VmStackDome.java
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
public class VmStackDome {

    private static Object obj = new Object();

    public void methodOne(int i){

      int j = 1;
      int sum = i+j;
      Object object = obj;
      long start = System.currentTimeMillis();
      methodTwo();

    }

    public void methodTwo(){
      int k = 10;
      System.out.printf("k="+k);
    }

}
  1. 当虚拟机要执行一个方法methodOne()时,就会有一个栈帧压入线程独享的虚拟机栈中,如下图

ruzhanguocheng
  1. 当线程开始执行这个方法时这些局部变量应该从哪里读取,又是在哪里做的运算?我们通过看这个类的反编译代码来看

bianyi
  1. 我们直接看这个方法的执行区,可以看到在执行int j=1;时对应的执行指令是iconst_1,我们再参考Java虚拟机字节码指令表看,iconst_1的含义是将int型1推送至栈顶

iconst 1
iconst 1 2
  1. 执行istore_2指令,指令含义:将栈顶int型数值存入第三个本地变量

istore
  1. 执行iload_1指令(将第二个int型本地变量推送至栈顶)、iload_2(将第三个int型本地变量推送至栈顶)假设 i=10;

iload
  1. 执行iadd指令(将栈顶两int型数值相加并将结果压入栈顶)、执行istore_3(将栈顶int型数值存入第四个本地变量)

iadd
  1. 接下来执行istore_3指令(将栈顶int型数值存入第四个本地变量)

  2. 接下来执行getstatic指令(获取指定类的静态域, 并将其压入栈顶)

  3. 思考一个问题,执行字节码指令是哪个组件在执行?

    1. 答案是:执行引擎,它负责执行虚拟机的字节码,现代虚拟机为了提高执行效率,会使用即时编译(just in time)技术将方法编译成机器码后再执行。

    2. Java HotSpot Client VM(-client),为在客户端环境中减少启动时间而优化的执行引擎;本地应用开发使用。(如:eclipse)

    3. Java HotSpot Server VM(-server),为在服务器环境中最大化程序执行速度而设计的执行引擎。应用在服务端程序。(如:tomcat)

    4. Java HotSpot Client模式和Server模式的区别

      1. 当虚拟机运行在-client模式的时候,使用的是一个代号为C1的轻量级编译器, 而-server模式启动的虚拟机采用相对重量级,代号为C2的编译器. C2比C1编译器编译的相对彻底,服务起来之后,性能更高。JDK安装目录/jre/lib/(x86、i386、amd32、amd64)/jvm.cfg文件中的内容,-server和-client哪一个配置在上,执行引擎就是哪一个。如果是JDK1.5版本且是64位系统应用时,-client无效。在部分JDK1.6版本和后续的JDK版本(64位系统)中,-client参数已经不起作用了,Server模式成为唯一。

1.3. 本地方法栈(Native Method Stacks)

与虚拟机所发挥的作用是非常相似的,其区别是虚拟机为本地Java方法执行字节码服务,本地方法栈为native方法执行字节码服务

nativeMethod

该线程首先调用了两个Java方法,而第二个Java方法又调用了一个本地方法,这样导致虚拟机使用了一个本地方法栈。假设这是一个C语言栈,其间有两个C函数,第一个C函数被第二个Java方法当做本地方法调用,而这个C函数又调用了第二个C函数。之后第二个C函数又通过本地方法接口回调了一个Java方法(第三个Java方法),最终这个Java方法又调用了一个Java方法

1.4. 堆(Heap)

此内存区域唯一目的就是存放对象实例,Java世界里几乎所有的对象的实例都在这里分配内存。

Java虚拟机根据对象存活的周期不同,把堆内存划分为几块,一般分为新生代、老年代和永久代(对HotSpot虚拟机而言),这就是JVM的内存分代策略。
   堆内存是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域,我们程序所有的对象实例都存放在堆内存中。给堆内存分代是为了提高对象内存分配和垃圾回收的效率。试想一下,如果堆内存没有区域划分,所有的新创建的对象和生命周期很长的对象放在一起,随着程序的执行,堆内存需要频繁进行垃圾收集,而每次回收都要遍历所有的对象,遍历这些对象所花费的时间代价是巨大的,会严重影响我们的GC效率。
   有了内存分代,情况就不同了,新创建的对象会在新生代中分配内存,经过多次回收仍然存活下来的对象存放在老年代中,静态属性、类信息等存放在永久代中,新生代中的对象存活时间短,只需要在新生代区域中频繁进行GC,老年代中对象生命周期长,内存回收的频率相对较低,不需要频繁进行回收,永久代中回收效果太差,一般不进行垃圾回收,还可以根据不同年代的特点采用合适的垃圾收集算法。分代收集大大提升了收集效率,这些都是内存分代带来的好处。
   当前主流的Java堆都是可扩展的,通过-Xmx和-Xms设定的,如果堆中没有内存完成实例分配也无法再扩展时,jvm就会抛出OutOfMemoryError异常

特点

  • 线程共享的一块区域

  • 可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)已提升分配对象时的效率

  • 堆可以处于物理上不连续的区域,但逻辑上是连续的。

neicunfendai

1.5. 方法区(Method Area)

它用于存储已被虚拟机加载的类型信息(class)、常量、静态变量、即时编译后的代码缓存等数据。 jdk1.8之后元空间实现了方法区,使用内存区域是本地内存。

方法区 ≠ 永久代/元空间。 方法区只是一种逻辑上的概念,是一中规范,而永久代/元空间是实现层面的东西,指物理上的一块空间。元空间并不在虚拟机中,而是使用本机内存,元空间大小仅受本地内存限制。

特点

  • 线程共享的一块区域

  • jdk8之后实现层是元空间,不属于虚拟机,直接占用本机内存。

  • jdk7叫永久代,已经把原本放在永久代的字符串常量池、静态变量移出,

  • jdk6永久代全部属于堆空间

1.6. 运行时常量池(Runtime Constant Pool)

存储常量池表—​编译期生成的各种字面量与符号引用。这部分内容将在类加载后存放到方法区的运行时常量池中。

yunxingshichangliangchi

2. 第二部分 虚拟机对象探秘

2.1. 对象的创建

当Java虚拟机遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到 一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那 必须先执行相应的类加载过程,后面还会详细的探讨这部分细节。

2.2. 对象的内存布局

对象在堆内存中的存储布局可以划分为三个部分:对象头(Header)、实例 数据(Instance Data)和对齐填充(Padding)。

2.3. 对象的访问定位

创建对象自然是为了后续使用该对象,我们的Java程序会通过栈上的reference数据来操作堆上的具 体对象。由于reference类型在《Java虚拟机规范》里面只规定了它是一个指向对象的引用,并没有定义 这个引用应该通过什么方式去定位、访问到堆中对象的具体位置,所以对象访问方式也是由虚拟机实 现而定的,主流的访问方式主要有使用句柄和直接指针两种

3. 第三部分 如何确定对象是存活还是死亡

前面了解了Java内存运行时区域的各个部分,其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈 中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基 本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性, 在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着 回收了。

但是`堆` 和`方法区`则有着很显著的不确定性:一个接口的多个实现类需要的内存可能不一样,一个方法所执行的不同条件, 只有处于运行期我们才能知道程序究竟会创建哪些对象,这部分内存的分配和回收是动态的。

说起垃圾收集我们需要考虑三个问题
  1. 哪些内存需要回收?

  2. 什么时候回收?

  3. 如何回收?

3.1. 引用计算算法(Reference Counting)

对每个对象的引用进行计数,每当有一个地方引用它时计数器 +1、引用失效则 -1,引用的计数放到对象头中,大于 0 的对象被认为是存活对象。 在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。 循环引用的问题可通过 Recycler 算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。

3.2. 可达性分析(Reachability Analysis)

这个算法的基本思路就是通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过 程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的

如图下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的, 因此它们将会被判定为可回收的对象。

roots
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种
  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。

  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。

  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。

  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。

  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。

  6. 所有被同步锁(synchronized关键字)持有的对象。

  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段。要真正宣告一个对象死亡,至少要经历两次标记过程
  1. 对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,

  2. 判断是否需要筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用 过,那么虚拟机将这两种情况都视为“没有必要执行”。翻译成代码:

     ----
    if(对象没有覆盖finalize()方法 || finalize()方法已经被虚拟机调用过){
         //没有必要执行finalize();
    }else{
        //对象进入 F-Queue......
    }
     ----
  3. 如果这个对象被判定为有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue队列中, 然后虚拟机自动建立低调度优先级的Finalizer线程去执行他们的finalize()方法。(触发其开始运行,但并不承诺一定会等待它运行结束,如果某个对象的finalize()方法执行缓慢,或者更极端地发生了死循环,将很可能导 致F-Queue队列中的其他对象永久处于等待,甚至导致整个内存回收子系统的崩溃)

  4. 第二次标记:收集器将对F-Queue中的对象进行第二次小规模的标记。如果对 象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己 (this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集 合;如果对象这时候还没有逃脱,那基本上它就真的要被回收了。

package com.jinkun.jvm.notes;

public class FinalizeEscapeGC {

  public static FinalizeEscapeGC SAVE_HOOK = null;

  @Override
  protected void finalize() throws Throwable {
    super.finalize();
    System.out.println("finalize method executed!");
    FinalizeEscapeGC.SAVE_HOOK = this;
  }

  public static void main(String[] args) throws Throwable {
    SAVE_HOOK = new FinalizeEscapeGC();
    //对象第一次成功拯救自己
    SAVE_HOOK = null;
    System.gc();
    // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
    Thread.sleep(500);
    if (SAVE_HOOK != null) {
      System.out.println("yes, i am still alive :");
    } else {
      System.out.println("no, i am dead :(");
    }
    // 下面这段代码与上面的完全相同,但是这次自救却失败了
    SAVE_HOOK = null;
    System.gc();
    // 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
    Thread.sleep(500);
    if (SAVE_HOOK != null) {
      System.out.println("yes, i am still alive :"+SAVE_HOOK);
    } else {
      System.out.println("no, i am dead :"+SAVE_HOOK);
    }
  }

}
运行结果:
finalize method executed!
yes, i am still alive :
no, i am dead :(

3.3. 标记清除法(Mark-Sweep)

标记清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象

入下图:使用标记清除算法对一块连续的内存空间进行回收。从根节点开始(这里显示了2个根),所有的有引用关系的对象均被标记为存活对象(箭头表示引用)。从根节点起,不可达的对象均为垃圾对象。在标记操作完成后,系统回收所有不可达的空间。

bjqc
  • 该算法最大的缺点就是:回收后的空间是不连续的。在对象的空间分配过程中,尤其是大对象的内存分配,不连续的内存空间的工作效率要低于连续的空间。因此这也是该算法的最大缺点。优化升级版是:标记压缩法。

3.4. 复制算法(Copying)

复制算法的核心思想是:将原来的内存空间分为两块,每次只使用其中一块,在进行垃圾回收时将正在使用的那块区域中存活对象复制到未使用的内存块中,之后清楚正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。

如图所示:A、B两块相同的内存空间,A在进行垃圾回收时,将存活对象复制到B中,B中的空间在复制后保持连续。复制完成后清空A,并将B空间设置为当前使用空间。

fuzhisuanfa

在java的新生代串行垃圾回收器中使用了复制算法的思想,新生代分为eden空间、from空间和to空间3个部分。其中from和to空间可视为用于复制的两块大小相同、地位相等且可进行角色互换的空间快。如下图

fzsf

3.5. 标记压缩法(Mark-Compact)

标记清除算法在标记压缩算法的基础上做了一些优化,等同于标记清除算法执行完成后再进行一次内存碎片整理,因此也叫标记清理压缩算法(MarkSweepCompact)。

标记压缩算法也首先需要从根节点开始,对所有可达对象做一次标记。但之后它并不只是简单的清理未标记的对象,而是将所有的存活对象压缩到内存的一端。之后清理边界所有的空间。这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此性价比非常高。

如下图:通过跟节点标记出所有可达对象后,沿虚线进行对象移动,将所有的可达对象都移动到一端,并保持他们之间的引用关系,最后清理边界外的空间,即可完成回收工作。

bjys

3.6. 分代收集理论—​分代算法(Generational Collectiong)

分代算法的思想就是:内存区间根据对象的特点分为几块,根据每块内存区间的特点使用不同的回收算法,以提高垃圾回收的效率,一般会分成新生代和老年代两个区域。

  1. 新生代:Java虚拟机会将所有的新建对象都放入新生代的内存区域,新生代的特点是对象朝生夕灭,大约90%的对象都会被很快回收,因此新生代比较适合使用复制算法。

  2. 老年代:当一个对象经过几次回收后依然存活,对象就会被放入老年代的内存空间。在老年代中,几乎所有的对象都是经过几次垃圾回收后依然存活的。因此,可以认为这些对象在一段时期内、甚至在应用程序的整个生命周期中,都将是常驻内存的。如果依然使用复制算法回收老年代将需要复制大量对象,回收性价比远远低于新生代,因此老年代比较适合使用标记清除压缩算法。

但是这样会产生一个问题,年轻代中发生minor gc的频率很高,经常会扫描年轻代中的对象进行标记,如果老年代中有对象引用了年轻代中的对象,那岂不是每次进行minor gc时也要进行全堆的扫描?所以为了支持高频率的新生代回收,同时又避免把整个来年代加进GC Roots扫描范围,虚拟机会使用一种叫做卡表的数据结构来记录老年代引用新生代的关系。

  • 记忆集—​卡表

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构。用于避免把整个老年代加进GC Roots扫描范围。

记忆集 是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。

卡表 卡表就是记忆集的一种具体实现(可以理解为hashMap与Map的关系),卡表是一个比特位集合,它定义了记忆集的记录精度、与堆内存的映射关系。每一个比特位可以用来表示老年代的某一区域中所有对象是否持有新生代对象的引用。这样在新生代GC时可以不用花大量时间扫描所有的老年代对象来确定每一个对象的引用关系,可以先扫码卡表 只有当卡表的标记位为1时,才需要扫码给定区域的老年代对象,而卡表为0的老年代对象,一定不含有新生代对象的引用。

cardTable

以Hotspot虚拟机为例,卡表的设计,是将整个堆空间分割成一个个卡页(card page),每个卡页大小为512字节(其他虚拟机也基本都为2的n次幂),而卡表本事为一个简单的字节数组,记录当前对应卡页的标记值。当判断一个卡页中有存在对象的夸代引用时,将这个页标记为脏页,在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的 GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

card

3.7. 写屏障

  我们已经解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等?
  卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变
脏,即如何在对象赋值的那一刻去更新维护卡表呢?

写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外的动作,也就是说赋值的 前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障,在赋值后的则叫作写后屏障。G1收集器出现之前其他收集器都只用到了写后屏障。

void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}

3.8. 伪共享

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题。伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line) 为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能

解决方案:一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏;

在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

3.9. 第四部分 G1(Garbage First)收集器

G1收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器 面向局部收集的设计思路和基于Region的内存布局形式

开启选项:-XX:+UseG1GC
g1All

3.9.1. 分区

G1采用了分区(Region)的思路,不再坚持固定大小以及固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间,收集器能够对扮演不同角色的Region采用不同的策略去处理。启动时可以通过参数-XX:G1HeapRegionSize=n可指定分区大小(1MB~32MB,且必须是2的幂),默认将整堆划分为2048个分区。

Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。而对于那些超过了整个Region容量的超级大对象, 将会被存放在N个连续的Humongous Region之中,有时候不得不先启动FullGC, G1的大多数行为都把Humongous Region作为老年代,

H区有以下几个特点:

  • 每个H Object会放在1个或者多个连续的Region里。

  • 直接分配到了老年代,防止反复拷贝移动

  • 在YGC、并发标记阶段、cleanup和FGC阶段回收

  • 在分配H Object之前先检查是否超过了 initiating heap occupancy percent (由参数-XX:InitiatingHeapOccupancyPercent控制) 和the marking threshold 如果超过的话就启动并发收集周期,为的是提早回收,防止Evacuation Failure 和 Full GC。

为什么要有H区?

G1做了一个优化:通过查看所有根对象以及年轻代分区的RSet,如果确定RSet中巨型对象没有任何引用,则说明G1发现了一个不可达的巨型对象,该对象分区会被回收。

g1

3.9.2. 堆空间

  • G1 同样可以通过 -Xms/-Xmx 来指定堆空间大小

  • 当发生YGC 和 Mixed GC时,通过计算GC与应用的耗费时间比,自动调整堆空间大小。

  • 如果GC频率太高可以增加堆空间大小降低GC频率

    • 目标参数-XX:GCTimeRatio即为GC与应用的耗费时间比:G1默认为12(JDK7,8为99,JDK11+开始为12)

    • 通过-XX:GCTimeRatio=<value>我们告诉JVM吞吐量要达到的目标值。 更准确地说,-XX:GCTimeRatio=N指定目标应用程序线程的执行时间(与总的程序执行时间)达到N/(N+1)的目标比值。 例如,通过-XX:GCTimeRatio=9我们要求应用程序线程在整个执行时间中至少9/10是活动的(因此,GC线程占用其余1/10)。 基于运行时的测量,JVM将会尝试修改堆和GC设置以期达到目标吞吐量。 -XX:GCTimeRatio的默认值是99,也就是说,应用程序线程应该运行至少99%的总执行时间。

3.9.3. 分代

  • 而无需整堆扫描,避免长命对象的拷贝,同时独立收集有助于降低响应时间。

  • G1将内存在逻辑上划分为年轻代和老年代

  • 但年轻代空间并不是固定不变的,当现有年轻代分区占满时,JVM会分配新的空闲分区加入到年轻代空间。

  • 整个年轻代内存会在初始空间-XX:NewSize与最大空间-XX:MaxNewSize之间动态变化,且由参数目标暂停时间-XX:MaxGCPauseMillis、需要扩缩容的大小以及分区的已记忆集合(RSet)计算得到

    • 当然,G1依然可以设置固定的年轻代大小(参数-XX:NewRatio、-Xmn),但同时暂停目标将失去意义

3.9.4. 本地分配缓冲区

  • LAB(Local allocation buffer): 每个线程均可以“认领”某个分区用于线程本地的内存分配,不需要顾及分区是否连续。

  • TLAB(Thread Local allocation buffer) 应用线程可以独占的本地缓冲区

  • GCLAB(GC Local allocation buffer) 每次垃圾收集时,每个GC线程同样也可以占用一个本地缓存区用来转移对象,每次转移对象都会将对象复制到s区

  • PLAB(Promotion Local allocation buffer) 对于从Eden/Survivor空间晋升到s/o 空间的对象,同样有GC独占的本地缓冲区进行操作。

3.9.5. 卡片

在每个分区内部又被分成了若干个大小为512 Byte卡片,标识堆内存最小可用粒度。所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。

g1Card
特点
  • 用户可指定期望的停顿时间

     G1收集器之所以能建立起可预测的停顿时间模型是因为它将Region作为单次回收的最小单元,既每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免在整个Java堆中进行全区域的垃圾收集,更具体的处理思路是让G1收集器去跟踪各个Region里面的垃
    圾堆积的“价值”大小,价值即回收所获得的空间大小以及回收所需时间的经验值,然后在后台维护一
    个优先级列表,每次根据用户设定允许的收集停顿时间(使用参数-XX:MaxGCPauseMillis指定,默
    认值是200毫秒),优先处理回收价值收益最大的那些Region,这也就是“Garbage First”名字的由来
  • 追求能够应付应用的内存分配速率,而不追求一次把整个Java堆全部清理干净

      在G1收集器出现之前的所有 其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老 年代(Major GC),再要么就是整个Java堆(Full GC)。 而G1跳出了这个樊笼,它可以面向堆内存任
    何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块内存中存放的垃圾数量最多,回收收益最大,这就是G1收集器的Mixed GC模式
  • 并行性:G1在回收期间,可以由多个GC线程同时工作,有效利用多核计算能力

  • 并发性:G1拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,一般来说,不会在整个回收期间完全阻塞应用程序。

设计思路
  • 将Java堆分成多个独立Region后,Region里面存在的跨Region引用对象如何解决?

    • 解决的思路:使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上的记忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。里面存储的元素是卡表的索引号。这 种“双向”的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)比原来的卡表实现起来更 复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃 圾收集器有着更高的内存占用负担。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额 外内存来维持收集器工作

  • 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

    • 这里首先是要解决的是用户线程改变对象引用关系时,必须保证其不能打破原本的对象图结构,导致标记结果出现错误,该问题的解决办法是G1收集器通过使用原始快照(SATB)算法来实现的。此外垃圾收集对用户线程的影响还体现在回收过程中新创建对象的内存分配上,程序要继续运行就肯定会持续有新对象被创建,G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间 划分出来用于并发回收过程中新对象的分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,既默认它们是存活的,不纳入回收范围。与CMS中 的“Concurrent Mode Failure”失败会导致Full GC类似,如果内存回收的速度赶不上内存分配的速度, G1收集器也要被迫冻结用户线程执行,导致Full GC而产生长时间“Stop The World”。

  • 怎样建立起可靠的停顿预测模型?

    • 用户通过-XX:MaxGCPauseMillis参数指定的停顿时间只意味着垃圾收集发生之前的期望值,但G1收集器要怎么做才能满足用户的期望呢?G1收集器的停顿预测模型是以衰减均值为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时,每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本, 并分析得出平均值、标准偏差、置信度等统计信息。平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。换句话说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

数据结构

3.9.6. Rset (Remember Set,已记忆集合)

RSet记录了其他Region中的对象引用本Region中对象的关系. RSet的价值在于使得垃圾收集器不需要扫描整个堆找到谁引用了当前分区中的对象,只需要扫描RSet即可. 堆内存中的每个region都有一个RSet. RSet 使heap区能并行独立地进行垃圾集合. RSets的总体影响小于5%.

  • G1为了避免STW式的整堆扫描,在每个分区记录了一个记忆集合(Rset),内部类似于一个反向指针,记录引用分区内对象的卡片card索引

  • 当要回收该分区region时,通过扫描分区的Rset,来确定引用本分区内的对象是否存活,进而确定本分区内的对象存活情况

  • 只有老年代分区会记录Rset记录。

3.9.7. PRT(Per Region Table)

RSet在内部使用Per Region Table(PRT)记录分区Region的引用情况。 由于RSet的记录要占用分区Region的空间,如果一个分区非常"受欢迎",那么RSet占用的空间会上升,从而降低分区Region的可用空间。 G1应对这个问题采用了改变RSet的密度的方式,在PRT中将会以三种模式记录引用:

  • 稀少:直接记录引用对象的卡片Card的索引

  • 细粒度:记录引用对象的分区Region的索引

  • 粗粒度:只记录引用情况,每个分区对应一个比特位

由上可知,粗粒度的PRT只是记录了引用数量,需要通过整堆Heap扫描才能找出所有引用,因此扫描速度也是最慢的。

3.9.8. Collection Sets* 简称 CSets.

收集集合,一组可被回收的分区的集合。在CSet中存活的数据会在GC过程中被移动到另一个可用分区,CSet中的分区可以来自Eden空间、survivor空间、或者老年代。CSet会占用不到整个堆空间的1%大小

  • 收集集合(CSet)代表每次GC暂停时回收的一系列目标分区Region

  • 年轻代手机(YGC)的CSet只容纳年轻代分区,而混合收集(Mixed GC)会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中

    • 候选老年代分区的CSet准入条件,可以通过活跃度阈值-XX:G1MixedGCLiveThresholdPercent(默认85%)进行设置,从而拦截那些回收开销巨大的对象;

    • 同时,每次混合收集可以包含候选老年代分区,可根据CSet对堆的总大小占比-XX:G1OldCSetRegionThresholdPercent(默认10%)设置数量上限。

3.9.9. 年轻代收集集合 CSet of Young Collection

  • 应用线程不断活动后,年轻代空间会被逐渐填满。当JVM分配对象到Eden区域失败(Eden区已满)时,便会触发一次STW式的年轻代收集。

  • 在年轻代收集中,Eden分区存活的对象将被拷贝到Survivor分区; 原有Survivor分区存活的对象,将根据任期阈值(tenuring threshold)分别晋升到PLAB中,新的survivor分区和老年代分区。而原有的年轻代分区将被整体回收掉。

  • 年轻代收集还负责维护对象的年龄(存活次数),辅助判断老化(tenuring)对象晋升的时候是到Survivor分区还是到老年代分区。

3.9.10. 混合收集集合 CSet of Mixed Collection

  • 当老年代占用空间超过整堆比IHOP阈值-XX:InitiatingHeapOccupancyPercent(老年代占整堆比,默认45%)时,G1就会启动一次混合垃圾收集周期。

  • 首先经理并发标记周期,识别出高收益的老年代分区。

如果我们不去计算用户线程运行过程中的动作(如使用写屏障维护记忆集的操作),G1收集器的 运作过程大致可划分为以下四个步骤:

  • 初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际 并没有额外的停顿。

  • 并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB记录下的在并发时有引用变动的对象。

  • 最终标记(Final Marking):对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留 下来的最后那少量的SATB记录。

  • 筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。

g1Running

3.9.11. GC日志

并发标记周期 Concurrent Marking Cycle

[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0094252 secs]
# 根分区扫描,可能会被 YGC 打断,那么结束就是如:[GC pause (G1 Evacuation Pause) (young)[GC concurrent-root-region-scan-end, 0.0007157 secs]
[GC concurrent-mark-start]
[GC concurrent-mark-end, 0.0203881 secs]
# 并发标记阶段
[GC remark [Finalize Marking, 0.0007822 secs] [GC ref-proc, 0.0005279 secs] [Unloading, 0.0013783 secs], 0.0036513 secs]
#  重新标记,STW
 [Times: user=0.01 sys=0.00, real=0.00 secs]
[GC cleanup 13985K->13985K(20480K), 0.0034675 secs]
 [Times: user=0.00 sys=0.00, real=0.00 secs]
# 清除

年轻代收集 YGC

[GC pause (G1 Evacuation Pause) (young), 0.0022483 secs]
# young -> 年轻代      Evacuation-> 复制存活对象
   [Parallel Time: 1.0 ms, GC Workers: 10] # 并发执行的GC线程数,以下阶段是并发执行的
      [GC Worker Start (ms): Min: 109.0, Avg: 109.1, Max: 109.1, Diff: 0.2]
      [Ext Root Scanning (ms): Min: 0.1, Avg: 0.2, Max: 0.3, Diff: 0.2, Sum: 2.3] # 外部根分区扫描
      [Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] # 更新已记忆集合 Update RSet,检测从年轻代指向老年代的对象
         [Processed Buffers: Min: 0, Avg: 0.0, Max: 0, Diff: 0, Sum: 0]
      [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]# RSet扫描
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1] # 代码根扫描
      [Object Copy (ms): Min: 0.3, Avg: 0.3, Max: 0.4, Diff: 0.1, Sum: 3.5] # 转移和回收,拷贝存活的对象到survivor/old区域
      [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0] # 完成上述任务后,如果任务队列已空,则工作线程会发起终止要求。
         [Termination Attempts: Min: 1, Avg: 5.8, Max: 9, Diff: 8, Sum: 58]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1] # GC外部的并行活动,该部分并非GC的活动,而是JVM的活动导致占用了GC暂停时间(例如JNI编译)。
      [GC Worker Total (ms): Min: 0.5, Avg: 0.6, Max: 0.7, Diff: 0.2, Sum: 5.9]
      [GC Worker End (ms): Min: 109.7, Avg: 109.7, Max: 109.7, Diff: 0.0]
   [Code Root Fixup: 0.0 ms] # 串行任务,根据转移对象更新代码根
   [Code Root Purge: 0.0 ms] #串行任务, 代码根清理
   [Clear CT: 0.5 ms] #串行任务,清除全局卡片 Card Table 标记
   [Other: 0.8 ms]
      [Choose CSet: 0.0 ms] # 选择下次收集集合  CSet
      [Ref Proc: 0.4 ms] # 引用处理 Ref Proc,处理软引用、弱引用、虚引用、final引用、JNI引用
      [Ref Enq: 0.0 ms] # 引用排队 Ref Enq
      [Redirty Cards: 0.3 ms] # 卡片重新脏化 Redirty Cards:重新脏化卡片
      [Humongous Register: 0.0 ms]
      [Humongous Reclaim: 0.0 ms] # 回收空闲巨型分区 Humongous Reclaim,通过查看所有根对象以及年轻代分区的RSet,如果确定RSet中巨型对象没有任何引用,该对象分区会被回收。
      [Free CSet: 0.0 ms]  # 释放分区 Free CSet
   [Eden: 12288.0K(12288.0K)->0.0B(11264.0K) Survivors: 0.0B->1024.0K Heap: 12288.0K(20480.0K)->832.0K(20480.0K)]
 [Times: user=0.01 sys=0.00, real=0.00 secs]
# 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
# 从老年代分区转移存活对象时,无法找到可用的空闲分区 这两种情况之一导致的 YGC
[GC pause (G1 Evacuation Pause) (young) (to-space exhausted), 0.0916534 secs]
# 并发标记周期 Concurrent Marking Cycle 中的 根分区扫描阶段,被 YGC中断
[GC pause (G1 Evacuation Pause) (young)[GC concurrent-root-region-scan-end, 0.0007157 secs]

混合收集周期 Mixed Collection Cycle, Mixed GC

# 并发标记周期 Concurrent Marking Cycle 的开始
[GC pause (G1 Evacuation Pause) (young) (initial-mark) , 0.0443460 secs]

Full GC

[Full GC (Allocation Failure) 20480K->9656K(20480K), 0.0189481 secs]
   [Eden: 0.0B(1024.0K)->0.0B(5120.0K) Survivors: 0.0B->0.0B Heap: 20480.0K(20480.0K)->9656.8K(20480.0K)], [Metaspace: 4960K->4954K(1056768K)]
 [Times: user=0.03 sys=0.00, real=0.02 secs]

G1与CMS对比优势与劣势

  • 可以指定最大停顿时间

  • 分Region的内存布局

  • 按收益动态确定回收集

  • 不会产生内存空间碎片:单从最传统的算法理论上看,G1也更有发展潜力。与CMS 的“标记-清除”算法不同,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现,无论如何,这两种算法都意味着G1运作期间不会产生内存 空间碎片

  • 在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载都要比CMS要高

  • G1的卡表实现更为复杂,占用堆空间更多。堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和 其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝 生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的