JVM关键知识点整理,从入门到提高到实践

举报
yd_245979989 发表于 2023/09/09 15:22:35 2023/09/09
【摘要】 基础篇一、了解JVM内存结构Java 虚拟机定义了各种在程序执行期间使用的运行时数据区域。这些数据区域有一些是在Java虚拟机启动时创建的,并在Java虚拟机退出时销毁,有一些数据区域是每个线程独有的,在线程创建时创建,在线程销毁时销毁,根据《Java虚拟机规范》的规定,Java虚拟机运行时所需要管理的数据区域主要如下图所示:程序计数器(线程私有)程序计数器是一块非常小的内存区域,因为它只是...

基础篇

一、了解JVM内存结构

Java 虚拟机定义了各种在程序执行期间使用的运行时数据区域。这些数据区域有一些是在Java虚拟机启动时创建的,并在Java虚拟机退出时销毁,有一些数据区域是每个线程独有的,在线程创建时创建,在线程销毁时销毁,根据《Java虚拟机规范》的规定,Java虚拟机运行时所需要管理的数据区域主要如下图所示:


程序计数器(线程私有)

程序计数器是一块非常小的内存区域,因为它只是用来记录记个数,可以看作是当前线程执行的字节码的行号指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

因为JVM虚拟机的多线程是通过CPU时间片轮转来实现的,所以就肯定会发生某一个线程代码未执行完就被中断执行,那么当下次再执行获得时间片执行时就需要这个记录的行号来告诉线程应该从什么地方开始执行。

如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,这个计数器值则应为空(Undefined)。此内存区域是唯 一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。

Java虚拟机栈(线程私有)

虚拟机栈是一种先进先出的数据结构,主要作用是用来存放当前线程运行方法时所需要的内存空间,每个方法在被执行的时候,虚拟机都会创建一个栈帧用于存储局部变量表、操作数栈、返回地址、动态链接等信息,方法执行完毕后栈帧就会被移除虚拟机栈。

异常类型

这块区域会产生两种异常:StackOverflowError、OutOfMemoryError。

1. StackOverflowError

表示栈的深度超过虚拟机所允许的最大深度。

java复制代码public class Test {
    static int count = 0;
    public static void main(String[] args) {
        try {
            A();
        } catch (Throwable e) {
            System.out.println(count);
            e.printStackTrace();
        }
    }
    private static void A() {
        count++;
        A();
    }
}


这块区域默认大小为1M,执行21053次超时栈的最大深度,当然你可以通过-Xss的方式进行修改,比如我们改为-Xss2m表示大小调整为2M。


2. OutOfMemoryError

Java虚拟机的栈区域是支持动态扩展的,那么当栈扩展无法申请到足够的内存时,就会抛出OutOfMemoryError异常。

栈的动态扩展只是Java虚拟机规范中有支持,但具体情况还要看具体的虚拟机开发商,比如我们常用的HotSpot虚拟机的栈就是不可以动态扩展的。

本地方法栈(线程私有)

这块区域与虚拟机栈功能相似,虚拟机是管理Java中的方法,本地方法栈是管理由C语言实现的方法,也就是调用native的方法。(HotSpot直接把本地方法栈和虚拟机栈合二为一了。)

异常类型

一样会发生StackOverflowError、OutOfMemoryError。

方法区(线程共享)

方法区主要是用于存储已被虚拟机加载的类的信息、常量、静态变量等数据。

关于方法区和永久代

方法区是Java虚拟机规范所定义的空间,是一种规范,而HotSpot在JDK1.8之前,并没有严格按照Java虚拟机规范来设计,而是设计了一个名为永久代的部分,并且为了垃圾收集器的分代设计又把永久代放入了堆空间以便管理。所以要注意不要把方法区和永久代搞混了,因为实际上对于其他虚拟机,比如J9来说是不存在永久代这个概念的。

当然HotSpot也意识到如果把永久代放在堆空间内,可能会出现因为永久代的使用过多,导致堆空间内存溢出的问题,所以为了隔离这种影响,从JDK8开始,永久代也改名为元空间,并将其移出堆空间,转而是把这部分数据存储到了本地内存中。

异常类型

对方法区中的数据回收条件是非常苛刻的,但是又不能完全不回收,这一部分空间同样会出现OutOfMemoryError异常。

永久代溢出:OutOfMemoryError: PermGen space

元空间溢出:OutOfMemoryError: Metaspace

堆(线程共享)

堆空间是最大的一块内存空间,主要作用就是用来存放创建的对象,几乎所有的对象都是存放在堆空间的(当然有些特殊的场景存在,比如:堆外分配、对象逃逸、栈上分配等一些为了提高性能的优化手段,了解即可),所以这也是我们需要重点关注的一部分区域,我们平常所谈论的垃圾回收,分代收集也都是针对这一部分空间的对象处理。

异常类型

同样这一部分也会出现OutOfMemoryError:Java heap space异常。

运行时常量池

运行时常量池是方法区的一部分,主要用来存放编译期间生成的符合引用和字面量。

异常类型

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常,具体异常类型根据不同的JDK版本来决定。

直接内存

这一部分区实际上并不是《Java虚拟机规范》中所定义的内存区域,但是由于在JDK1.4开始新加入了NIO类,使得Java通过native函数可以直接分配堆外内存,并通过对象引用对这块内存进行操作,避免数据在Java堆和native堆中的来回复制,从而提高性能。

异常类型

这块区域不受限于Java堆的大小限制,而是受限服务器本身的内存容量,所以也会出现OutOfMemoryError异常。

二、关于垃圾回收

1. 如何判断一个对象是垃圾

引用计数法

这是一种非常简单的方法,为每一个对象中添加一个计数器,当对象被引用时,计数器就加1,当引用被释放时,计数器就减1,最后如果计数器为0,则表示该对象没有任何引用关系了,即为垃圾对象。

这是一种实现简单,判定效率较高的方法,也有一些著名的应用案例,比如Python语言中就使用了这种方式,但是在JVM中并没有使用这种算法,因为它存在一个明显的问题:循环引用。

如下图所示,A引用B,B引用A,除此之外再无其他任何对象引用了这个两个对象,所以这两个对象应当为垃圾对象,但却因为计数器都不为0,所以不能被回收。

可达性分析法

为了避免上述的问题,在JVM中采用的是另一种可达性分析法来判断对象是否存活,这个算法的思想就是通过判断一系列被称为GCRoots的根对象,并作为起点,根据引用关系向下查找,查找过的路径称为引用链,如果某个对象到GCRoots对象没有任何一条引用链,则判断此对象为可回收对象。

如下图所示,D、G对象与GCRoots没有任何引用链关系所以为可回收对象。


GCRoots的范围

  1. 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
  2. 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  3. 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
  4. 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  5. Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  6. 所有被同步锁(synchronized关键字)持有的对象。
  7. 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除此之外根据某些垃圾收集器的选用,还有可能会存在临时性的GCRoot对象,因为垃圾划代收集的方式,比如扫描新生代对象的时候,还需要考虑被老年代中对象引用的情况,此时老年代中的对象也可视为GCRoot对象。

2. 哪些区域需要垃圾回收

对于线程独享的内存区域来说,例如:程序计数器、Java虚拟机栈、本地方法栈这些,他们的生命周期都与线程相同,因此是不需要单独对其进行管理的。

真正需要进行GC管理的主要就是线程共享的堆(Heap)和方法区(Method area)这两块区域了。

3. 垃圾回收的算法

3.1 标记-清除

我们首先介绍标记清除算法,因为这是一种最基础的垃圾回收算法,它的回收过程主要分为两个阶段:1、根据可达性分析算法,标记每一个存活的对象,2、将没有被标记的对象作为垃圾对象进行回收。当然也可以反过来标记。

回收之前内存状态


回收之后内存状态


优点:

  1. 整个回收过程只需要简单的设置一个标记位,相对而言系统资源消耗较少,速度较快。
  2. 整个回收过程不会移动存活的对象。
  3. 相比复制算法,内存利用率高。

缺点:

  1. 执行效率不稳定,如果标记的是存活对象,那么存活对象较多时就需要大量的标记和清除,如果标记的是可回收对象,那么可回收对象较多时就需要大量的标记和清除。
  2. 内存碎片问题,从上图中也可以看出,再一次回收完成后内存未使用空间看起来依然很零碎,这样将导致大对象因为没有连续的内存空间而无法被分配。

3.2 标记-复制

使用标记复制算法当存活对象较少时的可以得到不错的收益,基本的算法思想是将内存分为两个大小相等的区域,分配对象时每次只使用其中的一块区域,当这块区域用完时,就把还存活的对象复制到另一块区域上,然后再把这块区域已使用的空间直接清除。

回收之前内存状态


回收之后内存状态


优点:

  1. 如果大部分对象都是可回收的,那么只需要复制少量存活的对象,效率较高。
  2. 不存在内存碎片的问题。
  3. 分配对象简单,只需移动堆顶指针,按顺序分配即可。

缺点:

  1. 内存利用率较低,需要空出一半的内存空间用来确保容得下存活的对象。
  2. 存活对象在内存中的位置会发生变化,需要移动对象的引用地址。
  3. 同样只适合存活对象较少的场景,如果存活对象较多就会复制大量的存活对象。

3.3 标记-整理

标记整理算法同时解决了标记清除的内存碎片问题和标记复制的内存浪费的问题,相比标记清除算法,标记整理多个一步整理阶段,即移动存活对象,让存活对象向堆的一端移动,然后再清理掉其余的内存空间。

回收之前内存状态


回收之后内存状态


优点:

  1. 解决了内存碎片的问题。
  2. 解决复制算法的内存浪费的问题。

缺点:

  1. 同样如果存活对象较多,每次移动存活对象又会带来不小的开销。

三、对象分配策略

一般自动内存管理都需要解决的以下三个问题:

  1. 为新对象分配空间。
  2. 确定存活对象。
  3. 回收死亡对象所占用的空间。

其中第2个问题实际上要解决的就是如何判断一个对象是垃圾的?这个在前面的文章中已经有介绍,第3个问题实际上就是垃圾回收的方式,这个在后面的文章中也会介绍,本节再来看看对于对象分配的问题是如何解决的。

首先我们依然基于分代划分的思想,将堆空间分为新生代、老年代,其中新生代一般又被分为一个Eden区和两个Survivor区。

1. 对象优先在Eden区分配

大多数情况下,对象肯定是优先分配在Eden区的,如果Eden区空间不足,就会触发一次新生代的回收(也可以叫做:Minor GC或YGC)。

TLAB

本地线程分配缓冲,内存分配实际上被按照不同的线程划分在不同的内存之间进行,每个线程在Eden区中中有一块独享的小区域,这样做的好处是可以减少同步处理带来的性能消耗。

可以使用-XX:TLABSize设置大小。

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

大对象一般指的是那种需要占用连续的内存空间的对象,比如很大的一个数组对象。

为什么大对象不优先在Eden区分配?

首先我们知道Eden区的对象都是默认被我们假设为朝生夕死的对象,在Eden区中的对象默认需要经历15次垃圾回收(动态年龄)才会被放入老年代,所以假设这个大对象不是一个短命鬼,那么我们就需要在内存中来回复制15次,这必然会降低垃圾回收的效率,所以干脆直接放入老年代,以避免大对象的频繁复制过程。

写代码时应该注意避免大对象的频繁产生

了解这个分配原则后,我们平时在写代码就应当尽量避免不必要的大对象产生,尤其是那种朝生夕死的大对象,因为这样的对象就会频繁的进入老年代,并且如果老年代的连续内存空间不足,就会频繁的触发FullGC,因为要为大对象整理出连续的内存空间。

同时大对象必然需要消耗更多的内存复制的开销。

使用
-XX:PretenureSizeThreshold这个参数可以设置大对象的阈值,不过要注意这个参数只对Serial和ParNew两款新生代收集器有效。

分配演示

java复制代码public class Test {

    public static void main(String[] args) throws InterruptedException {
        byte[] bytes = new byte[1024*1024*1];//分配1M内存
        Thread.sleep(Integer.MAX_VALUE);//让程序休眠,观察内存情况
    }
}

设置JVM参数,JDK1.8环境

-Xms20m(堆的初始大小)

-Xmx20m(堆的最大大小)

-XX:NewSize=10m(新生代的初始大小)

-XX:MaxNewSize=10m(新生代的最大大小)

我们可以通过jmap命令查看heap的分配情况


Eden区一共使用了5M,4M大约来自JDK本身启动时所需加载的对象所占用的内存空间。

设置
-XX:PretenureSizeThreshold=1024(单位为byte),垃圾收集器为Serial,再看一下效果。


这时候1M的byte数组就被直接分配到了老年代中了。

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

对象首先被分配到Eden区,当发生MinorGC后,如果对象仍然存活,那么就会被移动到Survivor区,此时对象的年龄就会+1岁,当到达指定年龄后对象仍然存活,这样的对象就属于长期存活的对象,那么就会被放入老年代中,这样做的好处当然是为了减少对象在新生代中来回复制带来的性能消耗。

使用-XX:MaxTenuringThreshold参数可以配置年龄的大小,其中parallel默认为15,CMS默认为6。

示例演示

java复制代码public class Test {

    public static void main(String[] args) {
        byte[] b1 = new byte[1024 * 256];
        byte[] b2 = new byte[1024 * 1024 * 1];
        byte[] b3 = new byte[1024 * 1024 * 2];
        byte[] b4 = new byte[1024 * 1024 * 2];
    }
}

当使用默认年龄时,发生MinorGC后,有一部分对象进入Survivor区。

java复制代码[GC (Allocation Failure) 
Desired survivor size 1048576 bytes, new threshold 2 (max 2)
[PSYoungGen: 6350K->1016K(9216K)] 6350K->4476K(19456K), 0.0015667 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 9216K, used 3230K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 27% used [0x00000000ff600000,0x00000000ff8299b8,0x00000000ffe00000)
  from space 1024K, 99% used [0x00000000ffe00000,0x00000000ffefe020,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 3460K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 33% used [0x00000000fec00000,0x00000000fef61010,0x00000000ff600000)
 Metaspace       used 3193K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 349K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

当设置-XX:MaxTenuringThreshold=0后,发现Survivor区没有存活对象了。

java复制代码[GC (Allocation Failure) 
Desired survivor size 1048576 bytes, new threshold 0 (max 0)
[PSYoungGen: 6350K->0K(9216K)] 6350K->4417K(19456K), 0.0017623 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 PSYoungGen      total 9216K, used 2214K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
  eden space 8192K, 27% used [0x00000000ff600000,0x00000000ff829960,0x00000000ffe00000)
  from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
  to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
 ParOldGen       total 10240K, used 4417K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
  object space 10240K, 43% used [0x00000000fec00000,0x00000000ff050420,0x00000000ff600000)
 Metaspace       used 3181K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 348K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

4. 对象动态年龄判断

在HotSpot虚拟机设计中,并不是完全要等对象年龄到达-XX:MaxTenuringThreshold设置的值以后才会被放入老年代,也有一种例如的情况,当Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

5. 栈上分配

几乎所有的对象都是分配在堆内存中,但是还有一种比较特殊的分配方式是分配在栈上,这是借助于逃逸分析来辅助实现的,逃逸分析中指出如果对象的作用域不会逃出方法或者线程之外,也就是无法通过其他途径访问到这个对象,那么就可以对这个对象采取一定程度的优化,这其中就包含了:栈上分配。

栈上分配的好处在于,对象可以随着栈的出栈过程被自然的销毁,节省了堆中垃圾回收所消耗的性能。

对象分配大致的流程图


四、对象的引用关系

在Java中引用类型分别有强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)、虚引用(Phantom Reference)这4 种类型,对应的引用强度依次减弱。

1. 强引用

对于直接引用通过new关键字生成的对象就是强引用关系,此类引用必须由垃圾收集器判断确定没有任何引用关系后才能被回收。

2. 软引用

软引用是用来处理一些有用但是并非必需的对象,这些对象就可以用软引用关联,软引用关联的对象会在系统将要发生内存溢出OOM之前回收。

下面这段代码演示了在内存不足时,将要发生OOM,所以软引用对象被回收。

-Xms10m -Xmx10m,设置最大内存10M。

java复制代码public class Test {

    public static void main(String[] args) {
        byte[] b = new byte[1024 * 1024 * 5];
        SoftReference softReference = new SoftReference(b);
        b = null;
        System.gc();
        System.out.println("b对象没有任何引用,手动调用gc,b对象被回收:" + b + " ,软引用对象:" + softReference.get());
        
        //由于已经存在5M对象,再分配6M肯定不够分配,所以为了避免OOM,软引用对象被回收。
        byte[] b1 = new byte[1024 * 1024 * 6];
        System.out.println("b1对象,超过内存最大值,触发垃圾回收,b1对象:" + b1 + " ,软引用对象被回收:" + softReference.get());
    }
}


3. 弱引用

弱引用和软件引用作用相似,只不过弱引用级别更低,在下一次垃圾回收时就会被回收,无论当前内存是否足够。

java复制代码public class Test {

    public static void main(String[] args) {
        byte[] b = new byte[1024 * 1024 * 1];
        WeakReference weakReference = new WeakReference(b);
        b = null;
        System.out.println("弱引用对象在gc前:" + weakReference.get());
        System.gc();
        System.out.println("弱引用对象在gc后:" + weakReference.get());
    }
}


4. 虚引用

对引用对象完全没有影响,随时可能被回收,唯一目的是对象在回收时能收到一个通知。

使用场景

软引用:一般都可以作为缓存使用,当做一种淘汰策略,可避免OOM。

弱引用:也可作为缓存使用,但是会更快的被清除,所以与软引用的缓存级别不相同,更适用于一些临时、短期缓存。 另外还可参考ThreadLocal中的一种使用场景,就是自动删除,避免由于key的内存泄露,key有一个强引用和一个弱引用,一旦强引用没了,就希望key能够自动回收,而不需要主要删除这个key,因为删除这个key可能比较麻烦,所以就可以通过弱引用实现。

虚引用:java直接内存就使用了虚引用的方式,堆中有一个对象,保存了堆外内存的引用,当这个对象被回收时,会收到通知,则回收堆外内存。

提高篇

一、分代垃圾回收算法

前面在基础篇中提到了三种垃圾回收算法,实际上每种垃圾收集器都有各自的优缺点,没有一种算法可以适应所有的场景,因此在JVM中并没有完全的采用其中任意一种垃圾回收算法,而是根据不同的场景选择合适的算法。

分代收集

为了满足选择合适的垃圾回收算法,JVM中采用了分代收集的理论进行设计,它建立在如下两个分代假设之上:

  1. 弱分代假说:绝大多数对象都是朝生夕死的。
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。

基于这两个假设的基础上,JVM对堆空间进行了划分,并根据对象的年龄(熬过多次的对象)划分到不同的空间上,所以就划分出了:新生代、老年代。

新生代

基于弱分代假说理论,大多数对象都是可回收的,所以可以采用标记复制算法。

老年代

基于强分代假说理论,把在新生代经历过多次回收都存活的对象,放入老年代,那么老年代中的对象大部分就都是不可回收的,所以可以采用标记清除或者标记整理算法,只需要少量的标记和清除可回收对象即可。

同时新生代中的对象因为死的快,而老年代中的对象大多数都是难以消亡的,所以把这个两部分区域划分开来,就又能以不同回收频率去进行回收,老年代的回收频率往往要远低于新生代的回收频率。

对标记复制算法的改进

现在我们知道新生代可以采用标记复制算法进行回收,而标记复制算法的缺点就是需要空出一半的内存空间,那么在JVM中实际上并没有这样做,IBM公司曾有一项专门研究对新生代朝生夕灭的特点做了更量化的诠释——新生代中有98%的对象是熬不过第一轮GC的。因此并不需要按照1:1的比例来划分新生代的内存空间。

依据这一项研究,HotSpot虚拟机就又把新生代划分为了3个部分:Eden、Survivor0、Survivor1,他们的比例默认为8:1:1,也就是说两个Survivor区采用完全复制的算法,这样一来仅仅浪费了新生代的10%的内存空间。

当然理论之下总有意外,如果存活的对象就是超过了10%怎么办?当然这时候一般就会依靠老年代来进行担保了。

标记清除还是标记整理?

HotSpot中关注吞吐量的Parallel Scavenge收集器是基于标记整理算法的,而关注延迟的CMS收集器则是基于标记清除算法的。

为什么Parallel Scavenge选择标记整理,CMS选择标记清除?

标记清除和标记整理的主要区别就在于对象的移动整理,如果移动则内存回收时会更加复杂,如果不移动则对象分配时会更加复杂,所以标记清除算法在垃圾回收时速度更快、停顿时间更短,而标记整理算法虽然停顿时间稍长,但是对象的分配和访问则相比标记清除更加快,又因为分配和访问的频率要远远高于垃圾回收的频率,所以从总比来看吞吐量是要高于标记清除算法的。

CMS中还有一种特殊的做法,就是一般情况下采用标记清除算法,直到碎片程度太严重的时候可以再采用标记整理算法。

二、三色标记法

三色标记法是JVM中用来标记对象是否为垃圾的一种方法,主要是针对CMS、G1等垃圾收集器使用的,这类收集器都有一个垃圾回收线程与用户线程同时执行的并发过程,这是一般标记清除算法不能支持的。

三色标记法就是为了使垃圾扫描阶段能够与用户线程并发执行而产生的,因为传统的标记清除算法,必须要暂停所有用户线程。

在传统的标记清除算法下,只有两个状态位:0、1,比如0:表示未标识,1:表示对象可达,一次扫描结束后,清除所有状态为0的,然后再把状态为1的重置为0。

但是如果与用户线程同时执行就不能这样玩了,因为一旦同时执行就有可能在一条链未全部扫描完的情况下,用户线程改变了这条链上的引用关系,比如现在有一条引用链:A--->B--->C--->D,当扫描到C时,A和B都被标识为1,此时C到D的引用关系被删除,那么D对象就不能确定是否为垃圾对象,因为有可能D又被用户线程设置为其他对象的引用了,那么为了D不被误删,只能让D的标识也为1,但是如果D就是没有被其他对象引用了,那么D就逃过了这次垃圾收集的过程,这就会造成大量的浮动垃圾。

当然肯定也不能设置为0,因为0在未扫描之前虽然表示的是未标记对象,但是在扫描开始后就表示垃圾对象了。

所以上述问题很明显就是缺少了一个表示中间状态的过程,由于线程同时进行,所以引用链上的对象并不是简单的可达与不可达的关系,而是会有一个扫描过程中的状态,所以就出现了三色标记法。

1. 三色标记法中的三色

白色

表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是 白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。

灰色

表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过,也就是整个引用链还未全部扫完。

黑色

表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。

初始阶段:全部为白色


A对象扫描完成后变为黑色,B对象正在扫描则标识为灰色,剩余的白色对象标识还未被扫描。


最终按照可达性分析算法一轮扫描下来结果如下


最终白色对象E即为垃圾对象。

主要就是利用三个集合,分别来存放三种颜色的对象,开始扫描时把被扫描的白色对象从白色集合中移动到灰色集合中,灰色对象扫描完成后,又被移动到黑色对象集合中,最终完成所有初始标记时识别到的GCRoot引用链路径后,余下的白色集合中的对象即为垃圾对象。

2. 三色标记的漏标问题

三色标记的思想非常简单,但仔细分析一下就会发现其中的问题,如果把一个白色对象的引用设置到一个黑色的对象上,那么这个白色对象就会被错误的认为是一个垃圾对象,因为黑色对象表示的是这个对象已经完成了扫描且这个对象的所有引用都已经扫描过。

第一次标记时,关系如下:


用户线程修改了引用关系如下:


此时接着扫描E对象,发现E对象之后没有引用关系了,把E对象设置为黑色,垃圾收集器认为两条引用链上的对象全部扫描完毕,但是F对象却被遗漏了。

Wilson于1994年在理论上证明了,当且仅当以下两个条件同时满足时,会产生对象消失的问题,即原本应该是黑色的对象被误标为白色:

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

3. 如何解决漏标问题?

既然问题的产生需要同时满足上述两个条件,那么要解决就只需破坏其中一种即可,CMS和G1恰好分别利用其中一种条件来解决。

3.1. 增量更新(Incremental Update)

增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象了。

C对象被修改为灰色,那么就会沿着灰色对象继续扫描,最终会扫描到F对象。


3.2. 原始快照(Snapshot At The Beginning, SATB)

原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。

第一、二两条链扫描完成后,多出了第三条引用链,从之前的灰对象E开始,指向F对象,这样F对象就不会被清理掉了。


使用这种方式会有一个问题,假设引用关系如下:


之后引用关系被改变


E到F的引用没有了,F也没有再被其他对象引用,但是由于E对象为灰色对象,所以为了避免漏标,E对象最终还是会有一条到F的引用关系,这就是浮动垃圾问题,F对象会逃过本次的垃圾扫描,等待下次再被清理,但这总比漏标要好的多,但这种情况还是比较少的,因为只有在改变灰色对象时才需要记录。

三、垃圾回收器

一般大家认知比较高的垃圾回收器都在下图中了,当然图上的最新回收器是G1,而在JDK11时发布的ZGC也是话题度很高的一款新型垃圾回收器,虽然有这么多种垃圾回收器,不过就当下来看,目前用的最多的还以parallel、cms、g1这三种为代表。


接下来,我们就分别来谈谈这三种垃圾回收器。

1. Parallel

首先是Parallel,见名知意,这一款能够并行执行的垃圾回收器,其主要的关注点在于保证系统的吞吐量,你可能会觉得这款垃圾回收器太老了,也不能做到并发回收,但它可是如今使用最多的JDK8中默认的垃圾回收器,而这一点我发现很多人都不知道(查查你们公司目前正在用的垃圾回收器是不是它),并且如果服务内存本身就比较小,那对于Parallel来说自身占用内存也是比较少的。

1.1. Parallel Scavenge、Parallel Old回收算法

Parallel Scavenge是针对新生代的垃圾回收器,而Parallel Old是针对老年代的垃圾回收器,对于新生代的回收算法,参考前面相关的理论知识,应该选择标记-复制算法,而老年代,可以用标记-清除或者标记-整理,那追求吞吐量的情况下,Parallel Old肯定是选择了标记-整理。

吞吐量

这里有必要说明一下什么是吞吐量?在垃圾回收中,吞吐量指的就是运行用户线程时间占系统总运行时间的比值。

举个例子:运行用户代码时间为99分钟,垃圾收集器进行垃圾回收运行了1分钟,那么吞吐量就是:99 / (1+99) = 99%

追求高吞吐量可以最大程度的利用CPU资源完成运算的任务,这就比较适合关注后台运算,而与用户交互较少的场景。

1.2. 两个关键参数:

-XX:MaxGCPauseMillis

设置最大GC暂停时间的目标(以毫秒为单位)。这是一个软目标,并且JVM将尽最大的努力来实现它。 默认情况下,没有最大暂停时间值,这需要额外注意,他很有可能会造成较长时间的GC暂停。 下面的示例显示如何将最大目标暂停时间设置为500ms: -XX:MaxGCPauseMillis = 500

当然你不能简单的认为这个值设置的越小越好,你要知道Parallel Scavenge是如何做到控制停顿时间的?实际上就是简单的增加垃圾回收频率而已,也就是说你设置的停顿时间越短,垃圾回收的频率就会越频繁,比如:原来30秒一次垃圾回收,一次停顿2秒,现在由于设置的停顿时间为1秒,所以必须10秒执行一次垃圾回收,虽然停顿时间短了,但是吞吐量也低了。

-XX:GCTimeRatio

这个参数的值则应当是一个大于0小于100的整数,也就是垃圾收集时间占总时间的 比率,相当于吞吐量的倒数。譬如把此参数设置为19,那允许的最大垃圾收集时间就占总时间的5% (1/(1+19)),默认值为99,即允许最大1%(1/(1+99))的垃圾收集时间。

2. CMS

CMS(Concurrent Mark Sweep)是HotSpot虚拟机中第一款实现并发收集的垃圾回收器,是为那些希望使用较短的垃圾收集暂停时间并且可以在应用程序运行时与垃圾收集器共享处理器资源的应用程序而设计的,简单来说,CMS就是追求最短停顿时间的垃圾收集器。

CMS的热度一直都很高,也算是具有重要意义的一款垃圾回收器,不过遗憾的是,它并没有成为任何一版JDK中的默认垃圾回收器,我想应该也是因为它缺点明显,后面又有了更出色的G1的原因吧,尽管如此,CMS的设计理念还是很值得我们学习的,所以让我们一起看看它到底是如何做到同时兼顾垃圾回收与对象产生的。

2.1 回收策略

CMS主要针对老年代进行垃圾回收,可以配合Serial或者ParNew新生代垃圾收集器进行回收,并且从名字上包含Mark Sweep就可以看出CMS收集器是基于标记-清除算法实现的,相对之前的垃圾收集器CMS整个回收过程要稍微复杂一些,大致分为4步:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

2.1.1. 初始标记(CMS initial mark)

首先初始标记,需要暂停用户线程,不过这一步仅仅标记GCRoots能直接关联到的对象,因此暂停时间很短。

只标记GCRoots直接可达对象


2.1.2. 并发标记

并发标记就是接着初始标记的根对象继续往下标记,这个阶段是最耗时的,但是好在是与用户线程并发执行的。

考虑一种情况,老年代对象被新生代对象引用,如果此时只扫描老年代的GCRoots对象,A对象就会被遗漏,所以并发标记时实际上也会扫描新生代对象。


2.1.3. 重新标记

重新标记阶段是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

2.1.4. 并发清除

清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

并发预清理阶段

实际上除了上述的主要流程之外,CMS还有一步并发预清理阶段,这个阶段主要是发生在重新标记之前,此阶段工作与重新标记类似,目的主要是为了希望能够在重新标记前触发一次新生代的GC,这样就可以减少重新标记的停顿时间,此阶段主要标记新生代晋升到老年代的对象,直接分配到老年代的对象,并发过程中引用发生修改的对象,默认情况下当eden区达到了2M,则会开启并发预清理阶段,当eden区使用达到50%时停止预清理,或者预清理阶段超过默认时间5秒时也会停止预清理,配置CMSScavengeBeforeRemark参数,也可强制使每次重新标记前都触发一次YGC,但是这样的做法,虽然减少了重新标记的任务,但如果刚好已经执行过一次YGC,重新标记又执行一次,也会造成STW时间变长。

如何解决并发标记时引用关系改变问题?

由于第二阶段垃圾标记是与用户线程并发执行的,那就有可能产生错误标记的问题,比如一个对象我们刚刚标记完,结果用户线程又把其他对象引用到这个刚刚标记完的对象上。

如下图,当垃圾线程标记时,A的这条引用链走到B就已经走完了,但是如果之后用户线程让B对象又引用了C对象,那么C对象就会被漏标,最终会被当做垃圾对象被清理掉,显然C对象是不能被回收的。


为了解决这样的问题,CMS首先将老年代等份划分成了好多小块,这些小块的集合可以叫做card table(byte数组,数组中每一元素对应一个块),当某一个对象的引用发生变化时(只记录黑色对象引用发生变化),就改变这个对象所在的块的标识,比如标记为:脏card,这样我们在最终标记时只要在遍历一次所有的脏card即可。

如何确定新生代对象是否存活?

  1. GC可达性分析
  2. 老年代引用新生代对象

GC可达性分析不用多说,主要分析一下老年代引用新生代对象的问题,刚才分析初始标记时就已经了解到,在分代收集中只是扫描GCRoots肯定是不够的,要确认老年代对象是否存活就必须扫描所有新生代对象,所以刚才介绍了CMS并发预清理阶段就是为了来一次新生代的垃圾回收,这样新生代中大多数对象就被回收了。

现在问题是新生代要判断哪些对象被老年代引用了,老年代的对象的都是长期存活的,一次垃圾回收可没用,那就只能全量扫描老年代了?显然CMS不会这样做,这时候card table又派上用场了,当有新生代引用老年代对象时,只需要把老年代所在的card标记新增一个标识即可,就像上面标记为脏一样,这样新生代只需要扫描所有有相关标识的card即可。

card table是一个byte数组,一个byte有8个位,只要约定好每一位的含义就可以区分标识是对象在并发期间修改了,还是老年代引用新生代对象!

2.2 CMS缺点

  1. 因为是并发执行,所以会占用用户线程,CPU核心数小于4的服务器不推荐使用。
  2. 浮动垃圾问题,因为CMS是与用户线程并发执行的,所以并不能等待内存占用达到100%了再回收,jdk6以后默认是92%,就会开启CMS垃圾回收,如果过程中产生Concurrent Mode Failure,则会切换成serial old进行回收。
  3. 垃圾碎片:CMS采用标记-清除算法,因此会存在碎片问题,CMS默认情况下每一次FullGC都会进行一次压缩整理,通过参数可以配置UseCMSCompactAtFullCollection默认为true, CMSFullGCsBeforeCompaction就是表示配置每多少次CMS的FullGC执行一次压缩,但是如果用户调用system.gc或者担保失败,那也会触发压缩的FullGC。

2.3 CMS常见问题解决思路

并发模式失败和晋升失败都会导致长时间的停顿,常见解决思路如下:

  1. 降低触发CMS GC的阈值。即参数 -XX:CMSInitiatingOccupancyFraction 的值,让CMS GC尽早执行,以保证有足够的空间。
  2. 增加CMS线程数,即参数 -XX:ConcGCThreads。
  3. 增加老年代空间。
  4. 让对象尽量在新生代回收,避免进入老年代。

通常CMS GC的过程基于标记清除算法,不带压缩动作,导致越来越多的内存碎片需要压缩。 常见以下场景会触发内存碎片压缩:

  1. 新生代Young GC出现新生代晋升担保失败(promotion failed))
  2. 程序主动执行System.gc()

可通过参数
CMSFullGCsBeforeCompaction的值,设置多少次Full GC触发一次压缩。

默认值为:0,代表每次进入Full GC都会触发压缩,带压缩动作的算法为单线程Serial Old算法,暂停时间(STW)时间非常长,需要尽可能减少压缩时间。

2.4 关键参数

-XX:CMSInitiatingOccupancyFraction

这个参数指的是一个百分比(0-100),表示当内存空间使用率达到百分之N时就开始执行垃圾回收,设置的过小,容易导致内存利用率低,设置过高,如果并发回收时,内存无法满足程序分配新对象的需要,就会出现一次并发失败(Concurrent Mode Failure),冻结用户线程的执行,临时启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了。

JDK5时这个值默认为68%,JDK6时,已经把默认值提升至92%,这个值要根据实际情况来设置。

-XX:ConcGCThreads

设置用于并发GC的线程数。缺省值取决于JVM可用的CPU数量。

-XX:CMSFullGCsBeforeCompaction

这个参数的作用是要求CMS收集器在执行过若干次(数量 由参数值决定)不整理空间的FullGC之后,下一次进入Full GC前会先进行碎片整理(默认值为0,表示每次进入Full GC时都进行碎片整理)。

3. Garbage-First (G1)

Garbage-First (G1)是一款非常具有特殊意义垃圾收集器的技术发展体现,因为相比G1之前的垃圾收集器,G1首次打破了基于老年代或者新生代一整块内存进行收集的设计思想,G1设计上依然有分代的思想,但是在内存上不再进行分代上的物理划分,也就是在一块大的内存区域中,既有年轻代也有老年代,G1适用于具有大内存的多核服务器,G1虽然与CMS一样都是追求低停顿时间的垃圾收集器,但是由于G1在设计上的突破,使其能在更大的内存空间回收时,保持优秀的垃圾回收效率,这是G1之前的所有垃圾收集器所不能做到的。

3.1 G1中的分代设计

G1与其他的垃圾收集器相比不再有物理上的区域划分,而是直接使用一整块内存空间,并且划分为多个大小相等的独立区域(Region),每一个Region可以在逻辑上被划分为Eden区、Suvivor区、Old区、Humongous区,并且每一个类型的Region也没有固定的数量、大小与地址。

Humongous区是G1中新增的区域,专门用来存放大对象的,G1中定义一个对象如果超过Region大小的50%就属于大对象。

每个Region的大小可以通过参数-XX:G1HeapRegionSize设定,取值范围为1MB~32MB,且应为2的N次幂。


每一个Region表示的含义是不固定的,Eden区可能会变成Old区,G1可以根据优化策略自行调整它们之间的比例,所以一般使用G1时,不需要手动配置新生代与老年代大小。

3.2 可预测的停顿时间

在G1中使用参数-XX:MaxGCPauseMillis,可以控制最大的停顿时间,这依然是一个软目标,但相比Parallel Scavenge设置而言,这要更加可控一些,因为现在的内存已经被划分为许多小的Region区,G1通常可以在并发标记阶段完成之后,就能计算出每个Region区回收时的大小,以此来评估出此次可优先回收的Region区域,当然如果你把这个值设置的太小,那么G1最终也只能牺牲每次回收的垃圾量而导致垃圾回收变得更频繁,这反而降低了总体性能。

3.3 回收过程

3.3.1. Initial marking phase


此阶段为GCRoot根节点标记,需要暂停用户线程,这个阶段是在MinorGC(年轻代垃圾回收)阶段完成的。

3.3.2. Root region scanning phase

此阶段会对在初始标记阶段标记的Suvivor区域进行扫描,看看是否有对老年代对象的引用并进行标记,此阶段可以与服务同时运行,但一定会在下一个MinorGC(年轻代垃圾回收)开始之前完成。

3.3.3. Concurrent marking phase


此阶段开始对整个堆区域进行扫描,此阶段也是与服务同时运行,但可能会被MinorGC(年轻代垃圾回收)中断。

3.3.4. Remark phase


此阶段是STW阶段,会暂停用户线程,处理并发阶段时引用产生变化的一些对象。

3.3.5. Copying/Cleanup phase


复制/清理阶段完成之后


最后一个阶段依然需要暂停用户线程,统计Region中的数据以及对RSet进行清理,根据期望的停顿指标进行相应处理选择。

3.4 常见问题

3.4.1 CSet集合

CSet集合即每次选出来的待回收的集合,Young GC和Mix GC都将会向CSet集合中添加内容。

3.4.2 RSet集合

之前在介绍CMS时提到了一个问题如何确定新生代对象是否存活?对于G1同样存在这个问题,就是那些在老年代的对象引用了新生代的对象,与CMS一样,G1也是把每一个Region划分为一些Card Table块,不同的是因为CMS的老年代只有一个,所以只需要维护一个对应的Card Table集合,而G1中的老年代会有很多个,这就需要维护很多个Card Table集合,所以G1在外面又加了一层集合,直接用来记录当前新生代被哪些老年代引用了,这个集合就是RSet,RSet可以理解为是一个Map集合,Key就是Region分区的起始地址,Value又是一个集合,集合中的元素就是这个Region分区中Card Table的脏下标。

3.4.3 浮动垃圾

关于浮动垃圾的问题,前面在介绍三色标记法时已经提到过了,因为G1采用的是SATB的方式来解决漏标的问题,因此会产生浮动垃圾的问题(具体解释看前面的三色标记法的介绍)。

3.4.4 Allocation (Evacuation) Failure

与CMS一样,因为都是与用户线程并行执行的,因此有可能会遇到用户线程产生垃圾的速度比垃圾回收器回收的速度要快,一旦遇到这样的情况,那就会产生一次Full GC。

3.4.5 Young GC

Young GC还是针对Eden区和Suvivor区的回收,一次YGC后,存活下来的Eden区和Suvivor区的对象将被复制到一块新的区域,并会放入CSet集合中,同样的经过多次YGC后仍然存活的对象将会被移动到old区。

3.4.6 Mix GC

当完成并发标记后,G1就会进入混合垃圾收集阶段,在此阶段G1会选择将一些old区添加到将要收集的Eden区和Suvivor区中,当选择了足够多的old区域以后,G1就又会回到YGC的回收。

3.4.7 资源消耗

内存

相比G1之前的垃圾回收器,由于其特有的选择回收方式,使得在大内存下G1依然能够控制好回收时间,不过也因为G1中每个Region都需要维护一份RSet集合,这就导致G1中的RSet可能会占整个堆容量的20%乃至更多的内存空间。

CPU

CMS和G1都有因为并发标记过程用户线程改变对象引用关系的问题,二者都需要进行Card Table的维护,CMS和G1中都通过写后屏障进行维护,不过G1中为了实现原始快照的算法还需要写前屏障来跟踪指针的变化情况,所以在用户程序运行过程中会产生由跟踪引用变化带来的额外负担。

3.4. 使用建议

  1. 一般情况使用G1时,不建议指定年轻代的大小或者调整其占比,因为这样会使期望GC暂停的设置失效。
  2. G1虽然可以指定暂停时间的数值,但并不建议设置的太低,G1的吞吐量目标是90%的应用程序执行时间和10%的垃圾回收时间,设置过于激进的目标则会使垃圾回收变的非常频繁,这将直接影响吞吐量。
  3. 关于混合垃圾回收的调整,请注意下面几个参数值的设置:-XX:G1MixedGCLiveThresholdPercent、-XX:G1MixedGCLiveThresholdPercent、-XX:G1HeapWastePercent、-XX:G1MixedGCCountTarget、-XX:G1OldCSetRegionThresholdPercent
  4. 关于大对象,无论是分配还是回收都会带来一定的危害,建议根据实际情况调整G1HeapRegionSize的值来避免过多的对象被定义为大对象。

3.5. 关键参数

标题

1

-XX:MaxGCPauseMillis

期望的最大GC暂停时间,默认为:200ms,G1的默认策略是期望在吞吐量与延迟之间保持平衡,所以如果你希望获得较高的吞吐量,那么可以通过减少GC暂停的频率来实现,而减少GC暂停频率的主要方式就是增加最大GC暂停时间。

-XX:ParallelGCThreads

垃圾收集暂停期间用于并行工作的最大线程数。默认根据运行JVM计算机的可用线程数决定,计算方式:当进程可用的CPU线程数小于等于8时,则直接使用该数,否则,将设置为:8 + (n - 8) * (5/8) 。

-XX:ConcGCThreads

用于并发工作的最大线程数,默认情况下,此值为:-XX:ParallelGCThreads除以4。

-XX:G1HeapRegionSize

默认会根据最大堆的大小,按照划分出2048个region来计算出每个region的大小,最大值为32M,用户可自定义的范围是1~512M,且必须是2的幂。

-XX:G1NewSizePercent

新生代最小堆的百分比占比,默认为Java堆的5%。

-XX:G1MaxNewSizePercent

新生代最大堆的百分比占比,默认为Java堆的60%。

-XX:G1HeapWastePercent

为了更有效的进行垃圾回收,G1会从CSet中选择释放一些对内存空间增益更大的region,其中有一项参考就是可回收空间要大于XX:G1HeapWastePercent设置的值,默认为:5%,表示占当前堆空间的5%。

-XX:G1MixedGCCountTarget

在混合回收阶段,G1期望能够最大化的的进行回收,但同时还需要考虑XX:MaxGCPauseTimeMillis,因此通常会把一次大的混合回收,拆分为多次,这个次数就由XX:G1MixedGCCountTarget决定,默认为:8次,这样就减少了每一次混合回收的暂停时间,以达到XX:MaxGCPauseTimeMillis的目标值。

-XX:G1MixedGCLiveThresholdPercent

在混合回收阶段,会避免回收那些需要大量时间来处理的region,那么如何鉴定是否需要大量时间来处理呢?那么在大多数情况下,占用率高的region就需要耗费更多的时间来处理,
XX:G1MixedGCLiveThresholdPercent就是设置的存活对象占用率的阈值,默认为:85%,也就是如果一个region中的存活对象占比达到此-XX:GCPauseTimeInterval= region的85%,那么就不会回收这个region。

-XX:G1ReservePercent

保留空闲区域的百分比,默认为10%

-XX:G1OldCSetRegionThresholdPercent

设置混合垃圾回收周期中要收集的old 区数量的上限。默认值为堆的10%

-XX:InitiatingHeapOccupancyPercent

设置触发标记周期的堆占用阈值,默认为占用整个堆的 45%

3.6 G1日志分析

yaml复制代码
G1 Evacuation Pause
young(年轻代回收):表示年轻代使用空间满了。
mixed(年轻代+老年代一起回收):表示老年代使用占用到了堆空间的-XX:InitiatingHeapOccupancyPercent设置的值。

G1 Humongous Allocation
大对象申请都会触发一次GC。

[GC pause (G1 Evacuation Pause) (young), 0.0264657 secs]秒

   并行执行阶段
   
   GC启动了10个线程并行回收,耗时20.7ms
   [Parallel Time: 20.7 ms, GC Workers: 10]
      记录GC开始时间
      [GC Worker Start (ms): Min: 99341.2, Avg: 99341.2, Max: 99341.3, Diff: 0.1]
      根扫描
      [Ext Root Scanning (ms): Min: 0.7, Avg: 1.3, Max: 5.1, Diff: 4.4, Sum: 13.1]
      更新RSet集合
      [Update RS (ms): Min: 0.0, Avg: 0.9, Max: 1.2, Diff: 1.2, Sum: 8.6]
         [Processed Buffers: Min: 0, Avg: 5.1, Max: 18, Diff: 18, Sum: 51]
      扫描RSet集合
      [Scan RS (ms): Min: 0.0, Avg: 1.6, Max: 2.0, Diff: 2.0, Sum: 16.4]
      Root对象对region引用的情况扫描
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.5, Diff: 0.5, Sum: 1.9]
      存活对象复制并将存活对象从某个region区移动其他region区
      [Object Copy (ms): Min: 15.5, Avg: 16.6, Max: 16.8, Diff: 1.4, Sum: 165.8]
      GC终止
      [Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
         [Termination Attempts: Min: 1, Avg: 1.0, Max: 1, Diff: 0, Sum: 10]
      GC工作时,被其他JVM任务占用的时间,本身和GC无关   
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.1, Diff: 0.1, Sum: 0.5]
      GC过程总耗时
      [GC Worker Total (ms): Min: 20.6, Avg: 20.6, Max: 20.7, Diff: 0.1, Sum: 206.3]
      GC结束时间
      [GC Worker End (ms): Min: 99361.8, Avg: 99361.8, Max: 99361.9, Diff: 0.0]
      
   串行执行阶段
   
   Root对象修正,比如region内的对象被移动了,那则要更新一下引用地址
   [Code Root Fixup: 0.1 ms]
   Root对象清理
   [Code Root Purge: 0.1 ms]
   清理card table中已扫描的标志
   [Clear CT: 0.2 ms]
   其他耗时总计
   [Other: 5.3 ms]
      选择要回收的CSet集合
      [Choose CSet: 0.0 ms]
      软引用处理
      [Ref Proc: 4.2 ms]
      添加可以被回收的软引用
      [Ref Enq: 0.0 ms]
      软引用处理可能需要更新card table为脏
      [Redirty Cards: 0.2 ms]
      大对象统计(YGC阶段也会带着处理一点大对象)
      [Humongous Register: 0.0 ms]
      大对象回收耗时
      [Humongous Reclaim: 0.0 ms]
      CSet回收,并置位空闲
      [Free CSet: 0.6 ms]
   各个区域的回收前后对比记录   
   [Eden: 492.0M(492.0M)->0.0B(500.0M) Survivors: 52.0M->39.0M Heap: 863.3M(1024.0M)->377.1M(1024.0M)]
 [Times: user=0.31 sys=0.00, real=0.03 secs]

4、GC通用参数

标题


-Xmn

新生代大小,一般建议为整个堆大小的1/2~1/4之间,但如果使用G1垃圾收集器,则一般建议不要设置

-Xms

该参数有两个作用,分别为:堆的最小值以及初始值,默认为物理内存的1/64

-Xmx

堆的最大值,默认为物理内存的1/4,对于大多数应用服务来说,-Xms,-Xmx应该设置为一样的

-XX:SurvivorRatio

Eden区和Survivor区比例,默认是8,即表示eden区和两个Survivor区的比例为,8:1:1

-XX:+UseTLAB

使用TLAB分配,默认为开启

XX:+DisableExplicitGC

禁用System.gc(),默认为禁用

-XX:+PrintGCDetails

打印GC详情信息,默认为不打印

-Xloggc:/opt/xxx/logs/xxx-xxx-gc-%t.log

日志文件的输出路径

-XX:+HeapDumpOnOutOfMemoryError

OOM时生成Dump文件

-XX:HeapDumpPath=/memory.hprof

OOM文件生成地址

问题排查篇

一、排查工具

1. JVM自带工具

1.1 jmap

一般通过jmap可以生成堆的当前使用情况的快照,然后用它来分析或者调优JVM内存使用。


打印堆的直方图。对于每个Java类,将打印对象数,以字节为单位的内存大小以及完全限定的类名。JVM内部类名称以*前缀打印。如果指定了live子选项,则仅计算活动对象。


打印heap的使用情况,配置的参数信息,使用的垃圾收集器等信息。

MaxHeapSize:最大堆空间

NewSize:新生代分配大小

MaxNewSize:新生代最大分配大小

OldSize:老年代分配大小

NewRatio:新生代占整个堆空间的比例,2表示:新生代:老年代 = 1:2

SurvivorRatio:Survivor区占新生代空间的比例,8表示:Survivor:eden = 2:8

MetaspaceSize:元空间大小

后半部分是heap的使用情况


生成当前heap使用情况的快照,方便通过专业的内存分析工具进行分析。


1.2 jstack

jstack命令主要用于生成虚拟机当前时刻线程快照信息,用于跟踪并调试虚拟机堆栈信息,通过这个命令可以检测死锁、死循环、线程长时间停顿等问题。

命令格式:jstack [ options ] pid

死锁问题

死锁代码

java复制代码public class DeadLock {
    public static Object one = new Object();
    public static Object two = new Object();
    public static void main(String[] args) {
        new Thread(() -> {
            //获取第一个锁,并且不会被其他线程抢占
            synchronized (one) {
                try {
                    System.out.println(Thread.currentThread().getName() + "获得one锁,等待two锁。");
                    //确保第二个线程此时先获取到了第二个锁
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                //请求获取第二锁,并且任然持有第一个锁
                synchronized (two) {
                    System.out.println(Thread.currentThread().getName() + "获得two锁。");
                }
            }
        }).start();
        new Thread(() -> {
            synchronized (two) {
                try {
                    System.out.println(Thread.currentThread().getName() + "获得two锁,等待one锁。");
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (one) {
                    System.out.println(Thread.currentThread().getName() + "获得one锁。");
                }
            }
        }).start();
    }
}


死循环问题

死循环问题,一般我们在linux平台使用top命令,找到CPU占用率高的进程,然后再找进程里面CPU占用率的线程,拿到线程ID,通过jstack命令打印后,搜索相应的线程ID即可(jstack线程ID显示的是16进制,top里找到的是10进制,需要转换一下)。

一段死循环代码

java复制代码public class EndlessLoop {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            while (true) {
            }
        });
        t.setName("endless loop thread");
        t.start();
    }
}

先查看top命令,找到占用CPU较多的进程(是我们的java进程),进程ID:7971


top -p 7971,再按H,找到最耗CPU的线程ID,7981


jstack 7971,找到7981这个线程,7981转换成16进制就是0x1f2d,这样就找到了具体的线程了,并且如果你按照规范给线程起了名称,比如我们这里叫:endless loop thread,这样我们就能很快的定位到具体的代码位置了。


1.3 jstat

jstat主要是用来监控虚拟机各种运行状态信息的一种工具,通过jstat命令主要可以用来观察虚拟机在运行时垃圾收集状况,以及类加载和编译状况。

命令格式为:jstat [ generalOption | outputOptions vmid [interval[s|ms] [count]] ]

下面我们通过几个演示案例,看一下具体的使用方式,环境声明:JDK1.8,使用-XX:+UseSerialGC垃圾回收器

常用的命令:jstat -gc pid interval[ms] count


每一列含义解释

S0C:Survivor0区容量

S1C:Survivor1区容量

S0U:Survivor0区已使用容量

S1U:Survivor1区已使用容量

EC:Eden区容量

EU:Eden已使用容量

OC:老年代容量

OU:老年代已使用容量

MC:元空间容量

MU:元空间已使用容量

CCSC:压缩类容量

CCSU:压缩类已使用容量

YGC:新生代垃圾回收次数

YGCT:新生代垃圾回收耗时

FGC:FullGC发生次数

FGCT:FullGC耗时

GCT:总GC耗时

有时候还可以使用:jstat -gcutil pid interval[ms] count,查看使用比例。


1.4 jvisualvm

如果你的服务器已经开启了远程JMX,你可以通过jvisualvm工具查询。


2. Arthas

阿里开源的一款线上监控诊断产品,简单好用,官网资料也很详细,本文就不多赘述了。

3. Eclipse MAT(Memory Analyzer Tooling)

一款JAVA内存分析工具,可以对dump出来的hprof文件进行分析,同样详细使用方式可以参见官网。

4. gceasy

gceasy是一款用于分析GC日志的工具,可以对gc日志进行分析,并分析出问题,给出推荐的解决方案,参见官网

二、常见问题

1. CPU过高

常见的CPU使用率较高问题一般有:大量循环嵌套处理逻辑、疯狂开线程、频繁FullGC、复杂算法等,遇到CPU过高的问题,可直接通过jstack抓取使用率高的线程进行查看,具体方式前面介绍jstack时已经介绍过了,这里就不再赘述了。

2. 内存过高

前面几款工具介绍完之后,对于内存过高的原因也就好分析了,通过jmap或arthas都可以查看内存使用情况,同时也都可以直接dump内存情况,然后通过Eclipse MAT进行分析。

小案例

一、TLAB分配、栈上分配性能测试

TLAB分配

TLAB:本地线程分配缓冲,内存分配实际上被按照不同的线程划分在不同的内存之间进行,每个线程在Eden区中中有一块独享的小区域,这样做的好处是可以减少同步处理带来的性能消耗。

下面的小案例中启动了100个线程,如果没有TLAB优化,那么启动的线程越多,对象分配时的同步处理就越耗时

首先配置如下参数启动,-XX:-UseTLAB、-XX:-DoEscapeAnalysis,表示关闭TLAB分配,关闭逃逸分析,确保对象只能在堆上分配。

java复制代码public class TestAlloc {
    class User {
    }

    void alloc() {
        new User();
    }

    public static void main(String[] args) throws InterruptedException {
        TestAlloc t = new TestAlloc();
        CountDownLatch countDownLatch = new CountDownLatch(100);
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100; i++) {
            new Thread(() -> {
                for (int j = 0; j < 10_0000; j++) {
                    t.alloc();
                }
                countDownLatch.countDown();
            }).start();
        }
        countDownLatch.await();
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }
}

执行耗时大约1秒。


修改配置参数启动,-XX:+UseTLAB、-XX:-DoEscapeAnalysis,表示开启TLAB分配,关闭逃逸分析,确保对象只能在堆上分配。

执行耗时大约100毫秒,性能差距大约10倍。


栈上分配

这是借助于逃逸分析来实现的,逃逸分析中指出如果对象的作用域不会逃出方法或者线程之外,也就是无法通过其他途径访问到这个对象,那么就可以对这个对象采取一定程度的优化,比如:将对象直接分配到栈上,对象可以随着栈的出栈过程被自然的销毁,既省去了堆上分配的消耗,也节省了堆中垃圾回收所消耗的性能。

还是同样的案例,alloc方法中new出来的User对象,作用域只在该方法中,所以可以通过逃逸分析的结果,实现栈上分配。对象优先栈上分配,所以TLAB是否开启不影响,使用默认配置就行。

首先配置如下参数启动,-XX:-DoEscapeAnalysis,关闭逃逸分析。

java复制代码public class TestAlloc {
    class User {
    }

    void alloc() {
        new User();
    }

    public static void main(String[] args) {

        TestAlloc t = new TestAlloc();

        long start = System.currentTimeMillis();
        for (int j = 0; j < 1_0000_0000; j++) {
            t.alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }
}

执行耗时大约300毫秒。


当配置开启逃逸分析时 -XX:+DoEscapeAnalysis,执行耗时只有10毫秒左右。


二、使用ParallelGC频繁出现FGC

使用的垃圾收集器:ParallelGC

服务参数:Non-default VM flags: -XX:+
HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=null -XX:InitialHeapSize=526385152 -XX:MaxHeapSize=8392802304 -XX:MaxNewSize=2797600768 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=1572864 -XX:OldSize=524812288 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseParallelGC

初始化内存大小:500M,最大内存:8G。

服务器运行一段时间后,可以看出FGC次数频繁且耗时长,104次FGC,耗时达到258秒,平均每次FGC持续时间2秒多。


查看一下实时内存使用情况,eden区2G多,老年代5G多,很明显老年代常规状态下有5G多的对象就很不正常,初步判断就是大量的对象被过早的放入到了老年代。


问题分析

使用ParallelGC要特别注意AdaptiveSizePolicy参数的问题,还是上面那张图,看看Eden和Survivor区的分配占比,明显不是8:1:1了,这就是因为AdaptiveSizePolicy动态调整的原因。

AdaptiveSizePolicy 有三个目标:

Pause goal:应用达到预期的 GC 暂停时间。

Throughput goal:应用达到预期的吞吐量,即应用正常运行时间 / (正常运行时间 + GC 耗时)。

Minimum footprint:尽可能小的内存占用量。

所以为了达到期望的目标,Survivor区被调整的很小,导致新生代的对象被大量的移到了老年代了。
又由于每次FGC后老年代空间被动态调整的问题,导致老年代空间越来越大,同时也就意味着一次FGC的时间将会变得越来越长。

问题解决

-XX:SurvivorRatio,指定比例Eden区和Survivor的比例,不要让AdaptiveSizePolicy动态调整。

合理控制老年代大小,对于ParallelGC这样的垃圾收集器,老年代空间越大,一次FGC的停顿时间就越长。

控制新生代大小,新生代一般可以适当调大一些,让那些朝生夕死的对象能够全部在新生代被回收。

堆内存到底设置多大合适?这个一般要根据线上的实际使用情况来决定,其实如果不存在内存泄露问题,则只需从每次gc的后存活对象的大小,就能大致估算出实际所需要的内存空间(GC日志的重要性),为了用来应对系统峰值时的业务量激增导致产生的对象也激增的场景,再做一些适当的冗余即可。

垃圾收集器之所以要分代就是为了能够快速的把一些朝生夕死的对象给处理掉,如果Survivor小到形容虚设,就失去了分代收集的意义,因为每次Eden区的对象只要能熬过一次YGC就会被放到老年代(Survivor区太小不够放),实际上可能在第二次YGC时就可以回收了,对于ParallelGC这样的垃圾收集器,对象一旦进入老年代就只能等待内存100%后触发FGC才会被回收了。

三、内存使用率过高

背景


之前遇到过一次线上OOM问题,经排查分析发现是由于有一个接口使用JPA方式查询数据库,并且一次性返回的数据量过多导致(大约200W条数据),不过对于问题接口数据量虽然较多,但返回的数据只有一个未7个字符长度的String类型字段,所以200W条大约也就十几M,理论上还不至于造成OOM。

问题分析

一般遇到这样的情况,就需要用到一些内存分析工具来进行检查了,于是我们dump出堆内存信息,使用Eclipse MAT工具进行分析。


通过堆内存分析可以看出,使用JPA查询时原对象会被进行各种包装,并且被包装后占用空间剧增,最终达到1G多。


四、线程过多导致CPU使用率过高

按照前面介绍过的方式,线上遇到CPU使用率过高的问题,最直接的方式就是利用jstack进行分析。


线程数量异常


导出jstack文件进一步分析,确认都是线程池中的线程


最终直接找到线程池构建的代码,发现构建时最大线程数设置错了,设置成了1000,所以改掉之后即可恢复正常。

【版权声明】本文为华为云社区用户转载文章,如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。