JVM面试篇

192 阅读57分钟

运行时数据区

总览

java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,不同的区域存储不同的数据,Java 引以为豪的就是它的自动内存管理机制,相比于 C++的手动内存管理、复杂难以理解的指针等,Java 程序写起来就方便的多。所以要深入理解 JVM 必须理解JVM虚拟内存的结构划分。如下图:

image.png

分线程共享和线程私有两类,或者你也曾见过如下图,大致都是一样的

image.png

这样的划分只是JVM的一种规范,至于具体的实现是不是完全按照规范来?这些区域是否都存在?这些区域具体在哪儿?不同的虚拟机不同的版本在实现上略有不同

线程共享区

方法区

方法区(Method Area)是可供各线程共享的运行时内存区域,主要用来存储已被虚拟机加载的类信息、常量、静态变量、JIT编译器编译后的代码缓存等等,它有个别名叫做:非堆(non-heap),主要是为了和堆区分开。

方法区中存储的信息大致可分以下两类:

  • 类信息:主要指类相关的版本、字段、方法、接口描述、引用等
  • 运行时常量池:编译阶段生成的常量与符号引用、运行时加入的动态变量

运行时常量池

在jvm规范中,方法区除了存储类信息之外,还包含了运行时常量池。这里首先要来讲一下常量池的分类常量池可分两类:

Class常量池

(静态常量池)

字符串常量池

(没有明确的官方定义,其目的是为了更好的使用String ,真实的存储位置在堆)

  • 堆被划分为新生代和老年代( Tenured )
  • 新生代与老年代的比例的值为 1:2 ,该值可以通过参数 –XX:NewRatio来指定
  • 新生代又被进一步划分为 Eden 和 Survivor 区, Survivor 由 From Survivor 和 To Survivor 组成,eden,from,to的大小比例为:8:1:1;可通过参数XX:SurvivorRatio 来指定

线程私有区

每个线程独有,由虚拟机栈、本地方法栈、程序计数器组成

虚拟机栈

虚拟机栈顾名思义首先是一个栈结构,线程每执行一个方法时都会有一个 栈帧入栈,方法执行结束后栈帧出栈,栈帧中存储的是方法所需的数据,指 令、返回地址等信息,虚拟机栈的结构如下:

image.png

  • 虚拟机栈是基于线程的:哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与执行的方法栈帧会频繁地入栈和出栈,虚拟机栈的生命周期是和线程一样的。
  • 栈大小:每个虚拟机栈的大小缺省为 1M,
  • 堆栈溢出:栈帧深度压栈但并不出栈,导致栈空间不足,抛出java.lang.StackOverflowError ,典型的就是递归调用,
  • 栈帧的组成:
    • 局部变量表
    • 操作数栈
    • 动态连接
    • 返回地址

image.png

局部变量表

存放我们的局部变量的(方法内的变量)。首先它是一个32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部变量是一个对象,存放它的一个引用地址即可。

操作数栈

存放 java 方法执行的操作数的,它也是一个栈,操作的元素可以是任意的 java 数据类型,一个方法刚刚开始的时候操作数栈为空,操作数栈本质上是JVM执行引擎的一个工作区,方法在执行,才会对操作数栈进行操作。

动态链接

Java 语言特性多态

完成出口

正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)

本地方法栈

本地方法栈和虚拟机栈类似,具备线程隔离的特性,不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法,虚拟机规范里对这块所用的语言、数据结构、没有强制规定,虚拟机可以自由实现它,hotspot把它和虚拟机栈合并成了1个。

程序计数器

较小的内存空间,存储当前线程执行的字节码的偏移量;各线程之间独立 存储,互不影响。

面试题

美团

1、请简述JVM运行时数据区的组成结构及各部分作用 1. Jvm运行时数据区由堆和栈组成,其中分为线程共享区和线程私有区和直接内存。 2. 线程私有区又分为虚拟机栈、本地方法栈、程序计数器。 3. 虚拟机栈又分为局部变量表、操作数栈、动态连接、完成出口。其中局部变量表用来存储this和局部变量,一个为32个字节。不够的使用高低位存储,占两个字节;操作数栈用来存储操作中的操作数;动态连接是java多态的体现;完成出口记录了如果出现异常后继续执行的位置。 4.本地方法栈存储的是native方法,不由java编写的方法。比如C 5.程序计数器记录了当前线程执行的偏移量。

2、说说程序计数器的作用? 程序计数器的作用是用来记录当前线程执行到的偏移量,每个线程独立存储,互不影响。

3、代码异常后如何执行? 代码异常执行后回去异常表Exception Table中查看开始位置,结束位置,异常的类型,目标位置。然后再从目标位置继续执行。

4、为什么finally总会被执行? 因为finally代码块的代码在被编译的时候就会编译到各种情况执行完的后面。

字节

1、java内存区域?局部变量在哪? java内存区域是在虚拟机栈中,局部变量存在虚拟机栈中的局部变量表中。虚拟机栈中如果存储的是引用类型,引用类型存储在堆中。虚拟机栈中存储的是它的引用。

总结

  1. JAVA运行时数据区一共分为三大块,按照线程是否共享分为线程私有区和线程共享区;还要一个使用的直接内存。
  2. 线程共享区:我们平时经常说的堆就在其中,线程共享区主要分为方法区和堆。方法区中主要存储已经被虚拟机加载的类信息:版本,字段,方法,接口描述、引用等;运行时常量池:静态常量池、运行时常量池、字符串常量池。堆中主要分为:新生代(eden、FromSurvivor、ToSurvivor)、老年代。其中新生代和老年代的比例为1:2,eden、FromSurvivor、ToSurvivor的比例为8:1:1
  3. 线程私有区:虚拟机栈、本地方法栈、程序计数器。本地方法栈主要是用来调用本地其它方法,我们主要用到的是虚拟机栈。虚拟机栈:局部变量表、操作数栈、完成出口、动态连接。动态连接是Java多态的实现;局部变量表用来存储this指向和其他该线程需要用到的局部变量;操作数栈是jvm执行引擎的工作区,所有字节码指令本质都是对操作数栈进行处理;完成出口是通过程序计数器进行返回,发生异常通过异常处理表来返回。程序计数器是用来记录当前线程运行到哪一步。

对象

对象的内存布局

image.png

对象构成部分

  • 对象头
    • 标记Markworkd 8个字节
    • 类型指针ClassPoint 4个字节
    • 数组长度:只有类型为数组时才有
  • 实例数据
  • 填充padding

一个对象的内存结构由对象头和实例数据构成,内存大小必须为8个字节的整数倍,如果不足时需要填充。对象有由8个字节的markword和4个字节的Classpoint组成,如果类型为数组时,还有4个字节的数组长度。

注意: Classpoint原本为8个字节,是因为开启了指针压缩才变为4个字节。

查看是否开启指针压缩

java -XX:+PrintCommandLineFlags -version

image.png

-XX:+UseCompressedClassPointers:开启calsspoint指针压缩 -XX:+UseCompressedOops:开启对象应用指针压缩

对象的创建过程

image.png

  • 类加载
  • 检查加载
  • 分配内存:在内存中开辟对应大小的内存空间
    • 划分内存的方式
      • 指针碰撞
      • 空闲列表
    • 解决并发安全
      • CAS加失败重试
      • 本地线程分配缓冲
  • 内存空间初始化:为对应的类型分配不同的初始值
  • 设置:设置请求头
  • 对象初始化:执行构造init函数,赋初始值

对象的分配策略

image.png

  • 通过逃逸分析判断是否可以进行栈上分配
    • 如果可以分配直接分配在栈上
  • 判断对象是否为大对象
    • 如果为大对象直接分配在老年代
    • 如果不是大对象,使用TLAB本地线程缓冲分配在eden区

逃逸分析 www.yisu.com/zixun/36190…

gc回收

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

  • HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。 为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中( markword )。
  • 有以下几点要注意:
    • 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor 区中每熬过一次 Minor GC ,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15),CMS 是 6 时,就会被晋升到老年代中。
    • 可以通过参数: -XX:MaxTenuringThreshold=threshold 调整

image.png

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

空间分配担保:

  • 在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。
  • 如果不成立,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC ,尽管这次 Minor GC 是有风险的,如果担保失败则会进行一次Full GC ;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC 。

面试题

美团

1、JVM对象内存布局,new一个对象有多大?

如果是普通对象的话,为12个字节的字节头+实例数据的长度+填充大小。如果是数组的话为16个字节的字节头+实例数据的大小+填充大小。

阿里

1、阐述对象的分配策略

会先根据逃逸分析判断是否可以在栈上分配,可以的话直接分配到栈上。再判断对象是否为大对象,如果为大对象直接分配到老年代。然后分配到eden区,如果使用了TLAB本地线程缓冲,通过TLAB本地缓冲模式分配。经过minor gc后,没有被回收调的对象会进入到survivor区。当年龄达到一定阈值时,对象会进入到老年代。

Boss直聘

1、new 一个对象都有哪些步骤?(ex: User user = new User() )

  1. 首先编译器会将java代码编译成class文件,执行引擎会将class文件加载到内存中。
  2. jvm会执行加载检查,检查通过后会进行内存分配。
  3. 内存分配方式有指针碰撞和空闲列表,当内存空间为连续的时候会使用指针碰撞,这种方式效率较高;当内存空间不连续时,使用空闲列表的方式分配内存,这种方式效率较低。多线程环境下会产生线程安全问题,解决并发安全的方式有CAS本地重试和本地线程分配缓冲
  4. 内存空间初始化,赋类型的初始值。
  5. 设置对象头,Markword和ClassPoint
  6. 执行对象的初始化,执行构造init方法。赋真正的值。

补充:句柄和直接指针的区别

  • 直接指针只用一次引用,效率较高。但是一旦发生对象的迁移或者gc后,引用会随着内存地址的改变而改变。
  • 句柄会有两次引用,效率相对较低。但是当发生对象的迁移或者gc后,引用地址不会发生改变。

字节码

面试题

美团

1、说说异常时是如何保证锁释放的

在字节码编译阶段,会考虑正常情况和各种异常情况。在各种情况中都会执行monitorexit,释放锁资源。

快手

1、符号引用是什么?

符号引用是在字节码解决段用来便是一个对象的引用的,在类加载后变成一个实际的对象。

小米

1、拆箱/装箱的原理?

在字节码阶段,int为例。装箱会调用valueOf方法,拆箱会调用intValue方法。

2、字符串拼接的优化?

  • 会先判断字符串在常量池中是否存在,如果存在的话就不用重新开辟空间,直接赋值。
  • 如果都是常量的话,会先拼接后再判断。
  • 如果常量和变量进行拼接的话会使用StringBuilder的append方法优化。

类加载

JVM 加载机制

image.png

加载

image.png

“加载 loading”是整个类加载(class loading)过程的一个阶段,加载阶段虚拟机需要完成以下 3 件事情:

  • 通过一个类的全限定名来获取定义此类的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。

注意:

  • 加载的字节码来源,不一定非得是class文件,可以是符合字节码规范的任意地方,甚至二进制流等
  • 从字节码到内存,是由加载器(ClassLoader)完成的,下面我们详细看一下加载器相关内容

验证

  • 文件格式验证(版本号,是不是CAFEBABYE开头,..........)
  • 元数据验证(验证属性、字段、类关系、方法等是否合规)
  • 字节码验证
  • 符号引用验证

准备

为class中定义的各种类变量(静态变量)分配内存,并赋初始值,注意是对应类型的初始值,赋具体值在后面的初始化阶段。注意!即便是static变量,它在这个阶段初始化进内存的依然是该类型的初始值!而不是用户代码里的初始值。

看下面两个实例:

//类变量:在准备阶段为它开辟内存空间,但是它是int的初始值,也就是0,而真正123的赋值,是在下面的初始化阶段
public static int a = 123;

//类成员变量(实例变量)的赋值是在类对象被构造时才会赋值
public String address = "北京"

//final修饰的类变量,编译成字节码后,是一个ConstantValue类型,在准备阶段,直接给定值123,后期也没有二次初始化一说
public static final int b = 123;

那 static 变量什么时候赋具体的业务值呢?在类加载的最后一步:初始化阶段。

解析

将常量池内的符号引用替换为直接引用的过程

初始化

类加载的最后一个步骤,经过这个步骤后,类信息完全进入了jvm内存,直到它被垃圾回收器回收

  • 前面几个阶段都是虚拟机来搞定的。我们也干涉不了,从代码上只能遵从它的语法要求。而这个阶段,是初始化赋值,java虚拟机才真正开始执行类中编写的java程序代码,将主导权移交给应用程序。
  • 在准备阶段,静态变量已经赋过一次系统要求的初始值了,而在初始化阶段要执行初始化函数 函数,注意 并不是程序员在代码中编写的,而是由 javac 编译器自动生成的,
  • 函数是由编译器自动收集类中的所有静态变量的赋值动作和静态语句块( static 代码块)中的语句合并产生的。
  • 函数与类的构造函数(虚拟机视角的 函数)是不同的, 函数是在运行期创建对象时才执行,而 在类加载的时候就执行了。
  • 虚拟机能保障父类的 函数优先于子类 函数的执行。
  • 在 函数中会对类变量赋具体的值,也就是我们说的:
public static int a = 123; 

这行代码的123才真正赋值完成。

类加载器

类加载器做的事情就是上面 5 个步骤的事(加载、验证、准备、解析、初始化) java提供了3个系统加载器,分别是 Bootstrp ClassLoader、ExtClassLoader 、AppClassLoader

这三个加载器在定义上不构成继承关系,但是从逻辑上构成父子关系。

image.png

BootstrapClassLoader

Bootstrp加载器是用 C++ 语言写的,它在Java虚拟机启动后初始化,它主要负责加载以下路径的文件:

  • %JAVA_HOME%/jre/lib/*.jar
  • %JAVA_HOME%/jre/classes/*
  • -Xbootclasspath 参数指定的路径

这一步会加载一个关键的类: sun.misc.Launcher ,这个类包含了两个静 态内部类: ExtClassLoader , AppClassLoader ,如下:

public Launcher() {
    Launcher.ExtClassLoader var1;
    try {
        var1 =
        Launcher.ExtClassLoader.getExtClassLoader();
    } catch (IOException var10) {
        throw new InternalError("Could not create extension class loader", var10);
    }

    try {
        this.loader =
        Launcher.AppClassLoader.getAppClassLoader(var1);
    } catch (IOException var9) {
        throw new InternalError("Could not create application class loader", var9);
    }
    //......其他略.
}

由于启动类加载器是由C++实现的,所以在Java代码里面是访问不到启动类加载器的,如果尝试通过 String.class.getClassLoader() 获取启动类加载器的引用,会返回 null

ExtClassLoader

ExtClassLoader 是用 Java 写的,具体来说就是sun.misc.Launcher$ExtClassLoader ExtClassLoader 主要加载:

  • %JAVA_HOME%/jre/lib/ext/*
  • ext 下的所有 classes 目录
  • java.ext.dirs 系统变量指定的路径中类库

AppClassLoader

AppClassLoader 也是用Java写成的,它的实现类是sun.misc.Launcher$AppClassLoader ,另外我们知道 ClassLoader 中有个getSystemClassLoader 方法,此方法返回的就是它。

  • 负责加载 -classpath 所指定的位置的类或者是jar文档
  • 也是Java程序默认的类加载器

双亲委派模型

image.png

类加载器加载某个类的时候,因为有多个加载器,甚至可以有各种自定义的,他们呈父子关系。这给人一种印象,子类的加载会覆盖父类,其实恰恰相反!

与普通类继承属性不同,类加载器会优先调父类的 loadClass 方法,如果父类能加载,直接用父类的,否则最后一步才是自己尝试加载,从源代码上可以验证。

双亲委派模型的实现在: ClassLoader.loadClass() 方法中:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 首先,检测是否已经加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            //如果没有加载,开始按如下规则执行:
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    //重点!父加载器不为空则调用父加载器的loadClass
                    c = parent.loadClass(name, false);
                } else {
                    //没有父加载器也会先让Bootstrap加载器去加载
                    c =findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
            
            }
    
            if (c == null) {
                long t1 = System.nanoTime();
                //父加载器没有找到,则调用findclass,自己查找并加载
                c = findClass(name);
            }
        }
    
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

为什么这么设计 避免重复加载、 核心类篡改 采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有 优先级的层次关系,通过这种层级关可以避免类的重复加载,当父加载器已经 加载了该类时,就没有必要子加载器再加载一次。 其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通 过网络传递一个名为 java.lang.Integer 的类,通过双亲委派模型传递到启动 类加载器,而启动类加载器发现这个名字的类,发现该类已被加载,就不会重 新加载网络传递过来的 java.lang.Integer ,而直接返回已加载过的 Integer.class ,这样便可以防止核心API库被随意篡改。 } else { //没有父加载器也会先让Bootstrap加载器 去加载 c = findBootstrapClassOrNull(name); } } catch (ClassNotFoundException e) { } if (c == null) { long t1 = System.nanoTime(); //父加载器没有找到,则调用findclass,自己 查找并加载 c = findClass(name); } } if (resolve) { resolveClass(c); } return c; } }

双亲委派能否打破

答案是可以的。

tomcat

tomcat通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则,简单看一下tomcat类加载的层次结构如下:

image.png

比如:Tomcat的 webappClassLoader 加载web应用下的class文件,不会传递给父类加载器,

问题:tomcat的类加载器为什么要打破该模型?

首先一个tomcat启动后是会起一个jvm进程的,它支持多个web应用部署到同一个tomcat里,为此

  • 对于不同的web应用中的class和外部jar包,需要相互隔离,不能因为不同的web应用引用了相同的jar或者有相同的class导致一个加载成功了另一个加载不了。
  • web容器支持jsp文件修改后不用重启,jsp文件也是要编译成.class文件的,每一个jsp文件对应一个JspClassLoader,它的加载范围仅仅是这个jsp文件所编译出来的那一个.class文件,当Web容器检测到jsp文件被修改时,会替换掉目前JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的热部署功能。

tomcat的类加载器

CommonClassLoader

CommonClassLoader会去加载tomcat目录下lib目录下的所有jar包

CatalinaClassLoader

CatalinaClassLoader会去加载Bootstrap核心启动类

SharedClassLoader

每个应用会有一个独立的WebAppClassLoader,去加载web应用下自己的class文件

面试题

美团

1、JVM类加载机制说一下

JVM类加载机制,会分为五个阶段。第一个阶段会将字节码文件加载到内存中,生成对应的方法区对象;第二个阶段会去检查文件格式,校验数据等;第三个阶段会去开辟内存空间,并赋类型的初始值;第四个阶段会将符号引用转换为具体的对象;第五个阶段会去初始化变量的值,赋予的值(只有静态变量和静态代码块才会赋值)。

快手

1、双亲委派机制的过程及作用,三种类加载器,加载过程,双亲委派机制能打破吗?

双亲委派机制的过程是:先通过父ClassLoader来加载,如果加载到了就直接返回。作用是保护核心类库的class不被覆盖。

三种类加载器:BootStrapClassLoader、ExtClassLoader、AppClassLoader。会先使用BootStrapClassLoader进行加载,加载不到使用ExtClassLoader加载,最后使用AppClassLoader加载。

双亲委派机制是可以被打破的,如果tomcat的加载机制。tomcat为了保证每个应用有自己独立的class和jar包,会为每个应用生成一个独立的WebAppClassLoader。先从自己的web环境中加载class文件,加载不到再去父ClassLoader中加载。

GC

对象存活判断

引用计数

引用计数如何解决循环引用的问题:弱引用

引用类型

  • 强引用:如果一个对象具有强引用,那垃圾回收器绝不会回收它。
  • 软引用:垃圾收集线程会在虚拟机抛出OutOfMemoryError之前回收软引用对象。
  • 弱引用:弱引用与软引用的区别在于:
    • 只具有弱引用的对象拥有更短暂的生命周期。
    • 在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
    • 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
  • 虚引用:
    • 虚引用必须和引用队列(ReferenceQueue)联合使用。
    • 当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之关联的引用队列中。
    • 程序可以通过判断引用队列中是否已经加入了虚引用,来了解被引用的对象是否将要进行垃圾回收。
    • 如果程序发现某个虚引用已经被加入到引用队列,那么就可以在所引用的对象的内存被回收之前采取必要的行动。

可达性分析

来判定对象是否存活的。

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链( Reference Chain ),当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。

作为 GC Roots 的对象包括下面几种(重点是前面 4 种):

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象;各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。
  • 方法区中类静态属性引用的对象;java 类的引用类型静态变量。
  • 方法区中常量引用的对象;比如:字符串常量池里的引用。
  • 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
  • JVM 的内部引用( class 对象、异常对象 NullPointException 、OutofMemoryError ,系统类加载器)。(非重点)
  • 所有被同步锁( synchronized )持有的对象。(非重点)JVM 内部的 JMXBean 、 JVMTI 中注册的回调、本地代码缓存等(非重点)
  • JVM 实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收时只回收部分代的对象)(非重点)

除了这些固定的 GC Roots 集合以外,跟进用户选用的垃圾回收器以及当前回收的内存区域不同,还可能会有其他对象"临时"加入成为 GCRoots 。

以上的回收都是普通的对象,普通对象大都在堆区,而对于类( Class ) 的回收条件比较苛刻。

finalize

即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与 GCRoots 的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了 finalize ),我们可以在 finalize 中去拯救,俗称对象的自我救赎。

需要注意的是:

  • finalize 只会执行一次,不会多次执行。
  • 建议大家尽量不要使用 finalize ,因为这个方法太不可靠。
  • 如果一个对象被判定为的确有必要执行 finalize 方法,会将该对象放入一个名为 F-QUEUE 的队列中,有虚拟机创建的名为 Finalizer 的线程去执行这些对象的 finalize 方法。

分代回收理论

当前商业虚拟机的垃圾回收器,大多遵循“分代收集”的理论来进行设计,这个理论大体上有三条法则:

  1. 绝大部分的对象都是朝生夕死。
  2. 熬过多次垃圾回收的对象就越难回收。 根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一 个区域,这个就构成了新生代和老年代,并且不同的分代采用的回收算法不一 样。

image.png 但是分代收集也并不是简单划分一下内存区域这么简单,因为对象不是孤 立的,对象之间存在跨代引用,譬如:现在要在新生代进行回收,但新生代的 对象极有可能被老年代对象所引用,那为了找到这些可能存活的对象,不得不 在既定的 GC Roots 之外,再遍历整个老年代对象确保可达性分析结果的正确 性。反过来回收老年代也是一样。 但是这样无疑带来了性能负担,为了解决这个问题,分代收集理论添加了 第三条法则:

  1. 跨代引用相对于同代引用来说仅仅占少数

正是因为只占少数,所以不应该为了为了这些少量的跨代引用而区扫描整 个老年代,也不能浪费空间让每个对象都记录它是否存在跨代引用,所以为了 解决这个问题只需要在新生代建立一个全局的数据结构叫做:记忆集 ( Remembered Set ),这个结构把老年代划分成若干小块,并标识哪块内存 存在跨代引用,后续新生代发生 gc 时,只有包含了跨代引用的小内存区域才会 被加入到 GC Roots 进行扫描;当然这种方法需要在对象改变引用关系的时候维 护记忆集中数据的正确性。这种做法相比垃圾收集时扫描整个老年代来说仍然 时划算的。

常见回收算法

复制算法

原始的复制算法(Copying)是这样的:

  • 将内存按容量划分为大小相等的两块,每次只使用其中的一块。
  • 当其中一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

image.png

带来的好处是:

  • 实现简单,运行高效,
  • 每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片
  • 等复杂情况,只要按顺序分配内存即可,

存在的弊端是:

  • 内存的使用率缩小为原来的一半。
  • 内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。

适用场景:

复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的 对象比较少,效率自然就高,另外一半的一次性清理是很快的。

但是像 hotspot 这样的虚拟机大都对原生的复制算法进行了改进,因为它 对内存空间的利用率不高,而且专门研究表明,新生代中的对象 98% 是“朝生夕 死”的,所以并不需要按照 1:1 的比例来划分内存空间,所以改进后的复制回收 策略叫做: Appel 式回收。

  • 将新生代划分为一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To ) , HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1 。
  • 每次使用 Eden 和其中一块 Survivor ,当回收时,将 Eden 和Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间

image.png

在这样的算法下,

  • 每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被 “浪费”
  • 当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保( Handle Promotion )。

标记-清除算法

标记-清除(Mark-Sweep)算法分为“标记”和“清除”两个阶段:

  • 首先扫描所有对象标记出需要回收的对象,
  • 在标记完成后扫描并回收所有被标记的对象,故需要两次扫描

image.png

注意:

  • 回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低,所以该算法不适合新生代。
  • 它的主要问题是在标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。
  • 标记清除算法适用于老年代。

标记整理算法

算法逻辑如下:

  • 首先标记出所有需要回收的对象,
  • 在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,
  • 然后直接清理掉端边界以外的内存。

image.png

注意:

  • 标记整理需要扫描两遍
  • 标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。
  • 标记整理算法不会产生内存碎片,但是效率偏低。
  • 标记整理算法适用于老年代。

所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有 缺点。

HotSpot实现细节

STW

收集器在根节点枚举这步都是必须要暂停用户线程的( STW ),如果不这样的话在根节点枚举的过程中由于引用关系在不断变化,分析的结果就不准确。

安全点

收集器在工作的时候某些时间是需要暂停正在执行的用户线程的( STW ),这个暂停也并不是说用户线程在执行指令流的任意位置都能停顿下来开始垃圾收集, 而是需要等用户线程执行到最近的安全点后才能够暂停。

安全点如何选取呢?,安全点的选取基本是以:”是否具有让程序长时间执行的特征“为标准选定的,而最明显的特征就是指令序列的复用,主要有以下几点:

  • 方法调用
  • 循环跳转,
  • 异常跳转等等

对于安全点另一个问题是:垃圾收集器工作时如何让用户线程都跑到最近的安全点停顿下来?有两种方案:

  • 抢先式中断:不需要用户代码主动配合,垃圾收集发生时,系统把用户线程全部中断,如果发现用户线程中断的地方不在安全点上,就恢复这个线程执行让它执行一会再重新中断。不过现在的虚拟机几乎没有采用这种方式。
  • 主动式中断:思想是当垃圾收集器需要中断线程的时候,不直接对线程操作,仅仅设置一个标志位,各个线程执行过程中会不停的去主动轮询这个标志,一旦发现中断标志为真时就自己再最近的安全点上主动挂起。

安全区域

安全点的设计似乎完美的解决了如何停顿用户线程,它能保证用户线程在执行时,不太长时间内就会遇到可进入垃圾回收的安全点,但是如果用户线程本身就没在执行呢?比如用户线程处于 sleep 或者 blocked 状态,这个时候它就无法响应虚拟机的中断请求,没办法主动走到安全的地方中断挂起自己,对于这种情况就必须引入安全区域( Safe Regin )来解决。

安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这些线程了。 当线程要离开安全区域时,它要检查 JVM 是否已经完成了根节点枚举(或者其他 GC 中需要暂停用户线程的阶段)

  • 如果完成了,那线程就当作没事发生过,继续执行。
  • 如果没完成,它就必须一直等待, 直到收到可以离开安全区域的信号为止。

image.png

记忆集与卡表

前面讲分代收集理论的时候提到过一个跨代引用的问题,为了解决跨代引用带来的问题,垃圾收集器在新生代建立了一个叫做:记忆集( Remembered Set )的数据结构存储老年代哪些区域存在跨代引用,以便在根节点扫描时将这些老年代区域加入 GC Roots 的扫描范围,这样避免将整个老年代都加入 GC Roots 的扫描范围。

当然跨代引用的问题并非只在回收新生代才有,回收老年代也是一样的,所以需要更进一步理解记忆集的原理和实现方式。

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

记忆集的实现:最常见的实现方式是通过卡表( Card Table )的方式去实现,卡表最简单的形式是一个字节数组( hotspot ),如下:

CARD_TABLE[this address >> 9 ] = 0 1 
  • 字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作:卡页( Card Page ),卡页大小一般是2的N次幂, hotspot 中是2的9次幂(地址右移9位),即512字节。
  • 如果卡表标识的起始地址是:0x0000,那数组的0,1,2号元素,分别对应的地址范围是:0x0000ox01ff ,0x02000x03ff,0x0400~0x05ff,如下:

image.png

  • 一个卡页的内存中通常包含不止一个对象,只要卡页内存中有一个或多个对象的字段存在跨代引用指针,那就将卡表对应字节数组元素的值标识位1,称之为 Ditry ,没有则标识位0,垃圾收集器工作时只要筛查 CARD_TABLE 中为1的元素,就能轻易找到哪些卡页内存块中包含跨代引用,就把这些内存块加入到 GC Roots 的扫描范围内。

读写屏障

目前已经解决了用记忆集来缩减存在跨代引用时 GC Roots 的扫描范围,但是还没解决卡表如何维护的问题,比如:何时将卡表变脏?

答案似乎明显:非收集区域存在收集区域的引用时,对应卡表元素就变脏,变脏的时间点原则上应发生在引用类型字段赋值的那一刻, 但问题时如何在引用类型字段赋值的那一刻去维护卡表呢?

如果是解释执行的字节码那相对好处理,虚拟机负责每条字节码的执行,有充分的介入空间,但如果是编译执行的场景呢?即时编译器编译后的代码已经是纯粹的机器指令了,所以必须找一个在机器码操作的层面,在赋值操作发生时来维护卡表。

hotspot 中是通过写屏障( write barrier )来维护的, 这里的读写屏障要和解决并发问题的 内存屏障 区分开来,这里的读写屏障类似于 spring 的AOP ,比如以下代码是一个卡表更新的简化逻辑

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

当然这里还需要解决一个问题:卡表在高并发场景下面临着 伪共享 问题,一般处理器的缓存行( cache line )大小是64字节,由于卡表一个元素占一个字节,64个卡表元素共享同一个缓存行,这64个卡表元素对应的卡页总大小内存为:64*512bytes=32M,也就是说如果不同线程更新的对象引用正好处在这32M内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

为了解决伪共享的问题,简单的解决方案就是不采用无条件的写屏障,而是先检查卡标记,只有当卡表元素未被标记过时才将其标记为变脏,即更新卡表的逻辑变更如下:

if (CARD_TABLE[this address >> 9] != 0 ) {
    CARD_TABLE[this address >> 9] = 0;
}

在jdk1.7之后 , hotspot 虚拟机增加了一个参数 -XX:+UseCondCardMark ,用来解决是否开启卡表更新前的条件判断,开启会增加一次额外的条件判断开销,但能够避免伪共享问题,两者各有性能损耗,是否开启需要根据实际情况来测试权衡,默认是关闭的。

回收器

常见的垃圾回收器如下:

image.png

SerialGC

image.png

ParallerGC

jdk8默认的

image.png

CMS

CMS的整体执行过程分成5个步骤,其中标记阶段包含了三步,具体细节如下:

  • 初始标记:标记 GC Roots 直接关联的对象,会导致 STW ,但是这个没多少对象,时间短 。

  • 并发标记:从 GC Roots 开始关联的所有对象开始遍历整个可达路径的对象,这步耗时比较长,所以它允许用户线程和GC线程并发执行,并不会导致STW ,但面临的问题是可能会漏标,多标,等问题。

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

  • 并发清除;将被标记的对象清除掉,因为是标记-清除算法,不需要移动存活对象,所以这一步与用户线程并发运行。

  • 重置线程:重置GC线程状态,等待下次CMS的触发,与用户线程同时运行。

image.png

当然,在CMS中也会出现一些问题,主要有以下几点:

  • CPU敏感:对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足 4 个时,CMS 对用户的影响较大,因为CMS默认启动的回收线程数量是:(CPU核数+3)/ 4。
  • 浮动垃圾:由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉,这一部分垃圾就称为“浮动垃圾”(比如用户线程运行产生了新的 GC Roots )。
  • 由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收,在 1.6 的版本中老年代空间使用率阈值(92%) ;如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure ,这时虚拟机将临时启用 Serial Old 来替代 CMS,冻结用户线程的执行了回收老年代,这样会导致很长的停顿时间。
  • 空间碎片:这是由于CMS采用的是标记-清除算法导致的,当碎片较多时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS 提供一个参数: -XX:+UseCMSCompactAtFullCollection ( HotSpot(TM) 64-Bit Server VM is deprecated ),一般是开启的,如果分配不了大对象,就进行内存碎片的整理过程;这个地方一般会使用 Serial Old ,因为 Serial Old 是一个单线程,回收时会暂停用户线程,然后进行空间整理。所以如果分配的对象较大,且较多时,CMS 发生这样的情况会很卡。

ParNew

ParNew 收集器实质上是 Serial 收集器的多线程并行版本,除了使用多个线程并行收集外,其他行为和能使用的参数跟 Serial 收集器完全一致,可以和Serial Old 搭配使用。

并发标记

到目前为止,所有收集器在根节点枚举遍历其直接关联的对象时是要 STW的,并发收集器在继续往下进行可达性标记时是允许用户线程并发执行的,这样有效的减少了整体 STW 时间, 那这个并发标记到底是如何工作的呢?这就是我们要说的三色标记。

算法概述

首先约定好jvm在GC时会对对象进行颜色标记,按照对象是否被访问过这个条件将对象标记成以下三种颜色:

  • 白色:表示该对象尚未被收集器访问过,在可达性分析结束后,仍为白色的对象表示不可达,即为垃圾。要被回收
  • 灰色:表示该对象已被收集器访问过,但是这个对象至少存在一个引用还未被扫描
  • 黑色:表示该对象已被收集器访问过,并且它的所有引用都已被扫描,黑色对象是安全存活的。

另外:对于黑色对象

  • 如果有其他对象的引用指向了黑色对象,无需重新扫描一遍
  • 黑色对象不可能绕过灰色对象直接指向白色对象。

下面我们根据可达性分析算法来看一下三色标记的过程:

三色标记过程

初始状态

首先所有对象都是白色的,进行 GC Roots 枚举, STW ,枚举后只有 GC Roots 是黑色的

image.png

初始标记

初始标记仅仅只是标记一下 GC Roots 能直接关联的对象,速度很快,也会STW 。

image.png

并发标记

这个阶段是并发执行, GC 线程扫描整个引用链,分两种情况:

  • 没有子节点,将本节点标记为黑色。
  • 有子节点,将当前节点标记为黑色,子节点标记为灰色。 就这样继续沿着对象图遍历下去:

image.png

重新标记

这一阶段是修正在并发标记阶段因用户线程并发执行而产生的一系列问题,继续标记,直至灰色对象没有其它子节点引用时结束,这一阶段需要STW 。

image.png

扫描完成后,黑色对象就是存活的对象,白色对象就是已消亡可回收的对象。

三色标记的问题

在并发标记阶段的时候,因为用户线程与 GC 线程同时运行,有可能会产生多标或者漏标。

多标

如下图,假设已经遍历到 E(变为灰色了),此时应用程序将 D > E 的引用断开。

image.png D > E 的引用断开之后, E、F、G 三个对象不可达,应该要被回收的。然而因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。

这部分本应该回收但是没有回收到的内存,被称之为 浮动垃圾 。浮动垃圾并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。

另外,针对并发标记开始后的新创建的对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。

漏标

假设 GC 线程已经遍历到 E(变为灰色了),此时应用线程断开 E > G 的引用,同时添加 D > G 的引用。

image.png 切回到 GC 线程,因为 E 已经没有对 G 的引用了,所以不会将 G 置为灰色;尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处理。最终导致的结果是:G 会一直是白色,最后被当作垃圾进行清除。这直接影响到了应用程序的正确性,是不可接受的。

当然漏标的发生有两个条件:

  • 一个或者多个黑色对象重新引用了白色对象;即黑色对象成员变量增加了新的引用
  • 灰色对象断开了白色对象的直接或间接引用;即灰色对象原来成员变量的引用发生了变化。 对于这种情况,我们需要将 G 这类对象记录下来,作为灰色对象在重新标记阶段继续向下遍历,当然这个阶段需要 STW 。

读写屏障

针对于漏标问题,JVM 团队采用了读屏障与写屏障的方案。其目的很简单,就是在读写前后将 G 这类对象给记录下来。

读屏障:

oop oop_field_load(oop* field) {
    // 读屏障-读取前操作
    pre_load_barrier(field);
    return *field;
}    

当读取成员变量之前,先记录下来,这种做法是保守的,但也是安全的。因为条件1中【一个或者多个黑色对象重新引用了白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。

写屏障:

所谓的写屏障,其实就是指给某个对象的成员变量赋值操作前后,加入一些处理(类似 Spring AOP 的概念)。

void oop_field_store(oop* field, oop new_value) {
    // 写屏障-写前操作
    pre_write_barrier(field);
    *field = new_value;
    // 写屏障-写后操作
    post_write_barrier(field, value);
}

不管是条件1还是条件2中,都有对一个灰色对象或者黑色对象的属性进行写操作。

增量更新与原始快照

解决漏标问题,只要破坏漏标的两个条件之一即可,不同收集器采用的方案也不一样,

增量更新(Incremental Update):

  • 主要针对对象新增的引用,利用写屏障将其记录下来,这样破坏了条件
  • 后续重新扫描时还会继续从记录下来的新增引用深度扫描下去

CMS收集器采用的是这种方案。

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

  • 当某个对象断开其属性的引用时,利用写屏障,将断开之前的引用记录下来,
  • 尝试保留开始时的对象引用图,即原始快照,当某个时刻的 GC Roots确定后,当时的对象引用图就已经确定了。
  • 后续标记是按照开始时的快照走,比如 E > G ,即使期间发生变化,通过写屏障记录后,保证标记还是按照原本的视图来,
  • SATB破坏的是漏标条件2,主要针对是引用的减少。 G1收集器采用的是这种方案。

总结 基于可达性分析的 GC 算法,标记过程几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同,比如标记的方式有栈、队列、多色指针等。

对于读写屏障,以 Java HotSpot VM 为例,其并发标记时对漏标的处理方案如下:

  • CMS :写屏障 + 增量更新
  • G1、Shenandoah :写屏障 + 原始快照

另外,他们各自的总结如下: 原始快照相对增量更新来说效率更高,因为不需要在重新标记阶段再次深度扫描被删除引用对象,当然原始快照可能造成更多的浮动垃圾。 而 CMS 对增量引用的根对象会做深度扫描,G1 因为很多对象都位于不同的 region,CMS 就一块老年代区域;重新深度扫描对象的话 G1 的代价会比CMS 高,所以 G1 选择原始快照不深度扫描对象,只是简单标记,等到下一轮GC 再深度扫描

G1

G1全称:Garbage First,是一种服务器式垃圾收集器,针对具有大内存的多处理器机器。它试图以高概率满足垃圾收集 (GC) 暂停时间目标,同时实现高吞吐量。是垃圾收集器发展史上里程碑式的成果,它开创了收集器面向局部收集的设计思路以及基于 Region 的内存布局形式。

G1是全堆操作且与应用程序线程并发执行,并通过多种技术实现高性能和暂停时间目标。

G1的产生是为解决CMS算法产生空间碎片和其它一系列的问题缺陷,oracle官方计划在jdk9中将G1变成默认的垃圾收集器,以替代CMS。

JDK9默认G1为垃圾收集器的提案:openjdk.java.net/jeps/24 将CMS标记为丢弃的提案:openjdk.java.net/jeps/291

设计思想

作为CMS的替代者和继承人,设计者希望能够建立起具有:“停顿时间模型” 的收集器,通过该模型的意思是:要达到在指定时间为M毫秒内,垃圾收集耗时大概率不超过N毫秒的目标。

  • 思想转变:要实现这个目标,首先要有一个思想上的转变,G1收集器出现之前的其他所有收集器,他们的收集范围要么是新生代( Minor GC ),要么是老年代( Major GC ),要么是整堆( Full GC ),而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集( Collection Set , CSet )进行回收,衡量标准不再是它属于哪个分代,而是哪个回收集中存放的垃圾最多,回收收益最大,就回收哪个。
  • 新的内存布局:当然G1能达到这个目标的关键在于G1开创了基于Region 的堆内存布局,当然也依然遵循了分代收集理论,但是堆内存布局与其他收集器有明显差异,G1不再坚持固定大小以及固定数量的分代区域划分,而是把连续的java堆内存划分成多个大小相等的独立区域( Region ),每个Region 可以根据需要扮演新生代的 Eden , Survivor ,或者老年代。收集器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是对于新创建的对象还是对于熬过很多次垃圾收集的旧对象都有很好的收集效果。

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

Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。 G1认为只要大小超过了一个 Region 容量一半的对象即可判定为大对象,对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的Humongous Region 之中。

G1仍然保留了新生代,老年代的概念,只不过它们不再连续和固定的了,

image.png

回收策略:G1之所以能建立可预测的“停顿时间模型”的原因在于它将Region 作为单次回收的最小单元,即每次回收的空间都是 Region 的整数倍,同时G1会去追踪各个 Region 里面垃圾的“价值”(回收所获得的空间大小以及回收所需要的时间的经验值),然后在后台维护一个优先级列表,每次根据用户设定停顿时间( -XX:MaxGCPauseMillis=time ,默认200毫秒),优先回收价值收益最大的那些 Region 。

回收时G1 将存活的对象从堆的一个或多个 Region 复制到堆上的单个其他Region ,并在此过程中压缩和释放内存。这个工作是在多处理器上并行执行,以减少暂停时间并提高吞吐量。因此,每次垃圾回收时,G1 都会不断努力减少碎片

image.png

实现细节

G1将堆内存“化整为零”的思路看起来不难理解,但是有很多细节问题需要解决:

  • 跨 Region 引用如何解决:前面我们知道通过记忆集( RSet )解决跨代引用,但是在G1中,每个 Region 都需要维护自己的记忆集,记录别的 Region指向自己,但是G1中的 Region 数量要比传统收集器的分代数量明显多的多,所以G1中使用记忆集要比其他收集器有着更高的内存占用负担,根据经验,G1至少要耗费大约相当于java堆容量的10%~20%。
  • 并发标记问题:如何保证并发标记阶段GC收集线程与用户线程互不干扰,当然G1是通过原始快照( SATB )解决的(CMS是通过增量更新实现的)。

另外一个需要解决的就是并发回收阶段如何处理用户线程新创建对象的内存分配,G1的做法是为每个 Region 设计了两个名为 TAMS ( Top at Mark Start )的指针,把 Region 中的一部分空间划分出来用于存放并发回收过程中的新对象分配。G1收集器在本次回收时默认这些对象是存活的,不回收的。

  • 如何建立可靠的可预测模型:用户通过 -XX:MaxGCPauseMillis=time参数指定的停顿时间只是一个期望值,但是G1怎么做才能满足用户的期望呢?

G1收集器在收集过程中会记录每个 Region 的回收耗时,每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析出平均值,标准偏差,置信度等统计信息。根据这些信息决定 Region 的回收价值。

参数设置

  • 启用G1收集器: -XX:+UseG1GC

image.png

  • 设置分区大小: -XX:G1HeapRegionSize=value ,

image.png

  • 设置最大GC暂停时间: -XX:MaxGCPauseMillis=time

image.png

  • 设置堆的最大内存,对于需要大堆( >6GB )且GC延迟需求有限(稳定且可预测的暂停时间低于0.5秒)的应用程序,推荐使用G1收集器。

运行过程

image.png

  • 初始标记:标记出 GC Roots 直接关联的对象,并且修改TAMS指针的值,这个阶段速度较快,STW,单线程执行,
  • 并发标记:从 GC Root 开始对堆中的对象进行可达性分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行。
  • 重新标记:修正在并发标记阶段因用户程序执行而产生变动的标记记录,即处理 SATB 记录。STW,并发执行。
  • 筛选回收:筛选回收阶段会对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划,筛出后移动合并存活对象到空Region,清除旧的,完工。因为这个阶段需要移动对象内存地址,所以必须STW。

思考一下,这属于什么算法呢???

答:从Region的动作来看G1使用的是标记-复制算法。而在全局视角上,类似标记 - 整理

总结:

G1前面的几步和CMS差不多,只有在最后一步,CMS是标记清除,G1需要合并Region属于标记整理

优缺点

  • 并发性:继承了CMS的优点,可以与用户线程并发执行。当然只是在并发标记阶段。其他还是需要STW
  • 分代GC:G1依然是一个分代回收器,但是和之前的各类回收器不同,它同时兼顾年轻代和老年代。而其他回收器,或者工作在年轻代,或者工作在老年代;
  • 空间整理:G1在回收过程中,会进行适当的对象移动,不像CMS只是简单地标记清理对象。在若干次GC后,CMS必须进行一次碎片整理。而G1不同,它每次回收都会有效地复制对象,减少空间碎片,进而提升内部循环速度。
  • 可预测性:为了缩短停顿时间,G1建立可预存停顿的模型,这样在用户设置的停顿时间范围内,G1会选择适当的区域进行收集,确保停顿时间不超过用户指定时间。

几点建议:

  • 如果应用程序追求低停顿,可以尝试选择G1;
  • 经验值上,小内存6G以内,CMS优于G1,超过8G,尽量选择G1
  • 是否代替CMS只有需要实际场景测试才知道。(如果使用G1后发现性能还不如CMS,那么还是选择CMS)

面试题

美团

1、JVM怎么判断一个类是不是垃圾?

会通过引用计数和可达性分析,当引用计数变为0的时候,就没有对象使用了;也可以通过基于gc roots的可达性分析,打一个对象到gc roots没有任何引用,就证明该对象是不可用的。

2、说到GC ROOTS,你知道Java中哪些对象可作为GC ROOTS吗? 虚拟机栈中的局部变量表中引用的对象;方法区中静态属性引用的对象;静态常量引用的对象;字符串常量池的引用对象;

3、对象不可达是不是立即被回收死亡? 也不一定,一个对象的死亡会经过两次标记。一次是不可达,随后还要进行一次筛选。如果对象覆盖了finalize方法,可以在该方法中进行自救。

4、CMS垃圾回收器的回收过程 初始标记(STW的),并发标记,重新标记(STW的),并发清除,重置线程。在初始标记中,会枚举所有根gc roots,标记为黑色。并且找到所有gc roots的直接引用对象;并发标记标记的时候和用户线程并行,遍历整个可达路径的对象;重新标记的时候会STW,标记出被用户线程改变引用的对象。采用写屏障+增量更新的方法解决漏标问题;最后并发清除标记的对象;重置线程等待下一次cms。

5、如何解决跨代引用? 使用记忆集rs记录从非收集区指向收集区的指针集合,记录了哪些区域存在跨代引用。rs中的每一个元素代表着内存空间中一块特定大小的内存块,这个内存块称为卡页。在根节点进行扫描时可以使用卡页,代替整块的扫描。

字节

1、CMS收集器的流程,缺点;G1收集器的流程,相对于CMS收集器的优点

初始标记(STW的),并发标记,重新标记(STW的),并发清除,重置线程。在初始标记中,会枚举所有根gc roots,标记为黑色。并且找到所有gc roots的直接引用对象;并发标记标记的时候和用户线程并行,遍历整个可达路径的对象;重新标记的时候会STW,标记出被用户线程改变引用的对象。采用写屏障+增量更新的方法解决漏标问题;最后并发清除标记的对象;重置线程等待下一次cms。

缺点是CMS使用的是标记清除算法,会产生大量的内存碎片;对于CPU比较敏感,要求大于4个,不然会对用户影响比较大;会产生浮动垃圾,在并行的时候用户线程还在运行,会有新的垃圾产生。

G1的流程和CMS类似,只有在最后一步采用的是标记整理算法。

G1相对于CMS来讲会在每次回收都有效的复制对象,减少空间碎片;G1建立了可预测停顿模型,在用户设定的时间范围内可以选择合适的区域进行回收,确保停顿时间不超过用户指定时间。

腾讯

1、请阐述常见的GC策略 标记清除、标记整理、复制策略、分代回收

2、垃圾回收的时候服务怎么办 垃圾回收分为5个阶段,在初始标记和重新标记的时候用户线程需要STW。其余阶段可以并发执行

神策数据

1、G1了解不,说说G1

了解,G1是基于分代回收的算法,但是和普通分代回收不同。引入了regoin的概念,通过rs挤一挤存储跨代引用。在垃圾回收的时候,每次都进行复制,整理。可以减少内存碎片,还可以使用可预测停顿模型,在用户规定的时间内。选择合适的region进行回收,保证利益最大化

快手

1、并发标记的过程是怎么样的? 会并发执行,gc线程会扫描整个引用链。从gc roots的直接引用开始,没有子节点,将本节点标记为黑色。有子节点,将当前节点标记为黑色,子节点标记为灰色。继续遍历戏曲。