JVM专题
# JVM
本篇所有题目均基于当前各个大厂的最新面试题
# 运行时数据区
美团
1、请简述JVM运行时数据区的组成结构及各部分作用
A.线程共享区。
方法区(存储了方法的元信息)
--运行时常量池
B.线程私有区。
虚拟机栈(存对象的引用,指向堆)
--栈帧(main,各种方法)
栈帧内存储局部变量表,操作数栈,动态链接,方法出口
本地方法栈
程序计数器
C.直接内存
2、说说程序计数器的作用?
主线程要记录当前执行到字节码的哪一行,当有别的方法执行完毕时,执行引擎会通过程序技术器所记录的行数继续向下执行。
3、代码异常后如何执行?
有一个异常表,记录了从程序计数器中的字节码的第几行到第几行中抛出的什么异常

4、为什么finally总会被执行?
反编译时可以看出,finally字节码有很多份,存放在码表中
字节
1、java内存区域?局部变量在哪?
# 题解
# 总览
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同 的数据区域,不同的区域存储不同的数据,Java 引以为豪的就是它的自动内存 管理机制,相比于 C++的手动内存管理、复杂难以理解的指针等,Java 程序写 起来就方便的多。所以要深入理解 JVM 必须理解JVM虚拟内存的结构划分。

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

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

虚拟机栈顾名思义首先是一个栈结构,线程每执行一个方法时都会有一个栈帧入栈,方法执行结束后栈帧出栈,栈帧中存储的是方法所需的数据,指令、返回地址等信息,虚拟机栈的结构如下
- 虚拟机栈是基于线程的,哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与执行的方法栈帧会频繁地入栈和出栈,虚拟机栈的生命周期是和线程一样的。
- 每个虚拟机栈的大小缺省为 1M
- 堆栈溢出,栈帧深度压栈但并不出栈,导致栈空间不足,抛出java.lang.StackOverflowError,典型的就是递归调用。
- 栈帧大体都包含四个区域:(局部变量表、操作数栈、动态连接、返回地址),如下图所示

# 局部变量表
存放我们的局部变量的(方法内的变量)。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存 放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部变量是 一个对象,存放它的一个引用地址即可。
# 操作数栈
存放 java 方法执行的操作数的,它也是一个栈,操作的的元素可以是任意的 java 数据类型,一个方法刚刚开始的时候操作数栈为空,操作数栈本质上是JVM执行引擎的一个工作区,方法在执行,才会对操作数栈进行操作。
# 动态链接
Java 语言特性多态
# 完成出口
正常返回(调用程序计数器中的地址作为返回)、异常的话(通过异常处理器表<非栈帧中的>来确定)
# 本地方法栈
本地方法栈和虚拟机栈类似,具备线程隔离的特性,不同的是,本地方法 栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方 法,虚拟机规范里对这块所用的语言、数据结构、没有强制规定,虚拟机可以 自由实现它,hotspot把它和虚拟机栈合并成了1个。
# 程序计数器
较小的内存空间,存储当前线程执行的字节码的偏移量;各线程之间独立存储,互不影响。
# 方法区
方法区(Method Area)是可供各线程共享的运行时内存区域,主要用来存储已被虚拟机加载的类信息、常量、静态变量、JIT编译器编译后的代码缓存等等,它有个别名叫做:非堆(non-heap),主要是为了和堆区分开。
方法区中存储的信息大致可分以下两类:
# 1.类信息
主要指类相关的版本、字段、方法、接口描述、引用等
# 2.运行时常量池
编译阶段生成的常量与符号引用、运行时加入的动态变量
运行时常量池
在jvm规范中,方法区除了存储类信息之外,还包含了运行时常量池。这里首先要来讲一下常量池的分类
常量池可分类
1、Class常量池(静态常量池)
2、运行时常量池
3、字符串常量池(没有明确的官方定义,其目的是为了更好的使用String ,真实的存储位置在堆)
# 堆
1、堆被划分为新生代和老年代(Tenured)
2、新生代与老年代的比例的值为 1:2 ,该值可以通过参数-XX:NewRatio来指定。
3、新生代又被进一步划分为 Eden 和 Survivor 区, Survivor由From Survivor和To Survivor组成,eden,from,to的大小比例为:8:1:1;
可通过参数-XX:SurvivorRatio来指定

# 对象
美团
JVM对象内存布局,new一个对象有多大?
阿里
阐述对象的分配策略
Boss直聘
new 一个对象都有哪些步骤?(ex: User user = new User() )
# 题解
对象内存布局如下

对象的创建过程,如下图

对象的访问:句柄和直接指针
对象的分配策略,整体策略如下图所示

- 逃逸分析:https://www.yisu.com/zixun/361902.html
==长期存活的对象进入老年代==
HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。 为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中( markword )。
有以下几点要注意
如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor区中每熬过一次 Minor GC ,年龄就增加 1,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15),CMS 是 6 时,就会被晋升到老年代中。
可以通过参数:-XX:MaxTenuringThrehold=threshold调整为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold 才能晋升老年代,如果在Survivor 空间中相同
年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold 中要求的年龄。
# 空间分配担保
在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续空间是 否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保 是安全的。
如果不成立,则虚拟机会查看 -XX:HandlePromotionFailure 设置值 是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是 否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次Minor GC,尽管这次 Minor GC 是有风险的,如果担保失败则会进行一次Full GC;如果小于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。
# 字节码
美团
1、说说异常时是如何保证锁释放的
快手
1、符号引用是什么?
在反编译时会生成一份常量池(const pool),符号引用就是引用表中所定义的常量
小米
1、拆箱/装箱的原理?
2、字符串拼接的优化?
# 类加载
美团
1、JVM类加载机制说一下
# 题解
一个java类在整个运行时大致会经过如下阶段

加载
加载 loading是整个类加载(class loading)过程的一个阶段,加载阶段
虚拟机需要完成以下3件事情
1.通过一个类的全限定名来获取定义此类的二进制字节流。
2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3.在内存中生成一个代表这个类的java.lang.Class 对象,作为方法区 这个类的各种数据的访问入口。

注意:
加载的字节码来源,不一定非得是class文件,可以是符合字节码规范 的任意地方,甚至二进制流等
从字节码到内存,是由加载器(ClassLoader)完成的,下面我们详细 看一下加载器相关内容
# 验证
例如:
1、文件格式验证(版本号,是不是CAFEBABYE开头,..........)
2、元数据验证(验证属性、字段、类关系、方法等是否合规)
3、字节码验证
4、符号引用验证
准备
为class中定义的各种类变量(静态变量)分配内存,并赋初始值,注意是 对应类型的初始值,赋具体值在后面的初始化阶段。注意!即便是static变量, 它在这个阶段初始化进内存的依然是该类型的初始值!而不是用户代码里的初 始值。
看下面两个实例:
//类变量:在准备阶段为它开辟内存空间,但是它是int的初始值,也就是0,而真正123的赋值,是在下面的初始化阶段
public static int a = 123;
//类成员变量(实例变量)的赋值是在类对象被构造时才会赋值
public String address = "北京"
//final修饰的类变量,编译成字节码后,是一个ConstantValue类型,在准备阶段,直接给定值123,后期也没有二次初始化一说
public static final int b = 123;
2
3
4
5
6
那 static 变量什么时候赋具体的业务值呢?在类加载的最后一步:初始化阶段。
解析
将常量池内的符号引用替换为直接引用的过程
初始化
类加载的最后一个步骤,经过这个步骤后,类信息完全进入了jvm内存,直 到它被垃圾回收器回收
1、前面几个阶段都是虚拟机来搞定的。我们也干涉不 了,从代码上只能遵从它的语法要求。而这个阶段,是初始化赋值,java虚拟机 才真正开始执行类中编写的java程序代码,将主导权移交给应用程序。
2、在准备阶段,静态变量已经赋过一次系统要求的初始值了,而在初始化 阶段要执行初始化函数
3、
4、
5、虚拟机能保障父类的
6、在
public static int a = 123;
这行代码的123才真正赋值完成。
# 双亲委派机制
快手
1、双亲委派机制的过程及作用,三种类加载器,加载过程,双亲委派机制能打 破吗?
# 题解
类加载器
类加载器做的事情就是上面 5 个步骤的事(加载、验证、准备、解析、 初始化),java提供了3个系统加载器,分别是Bootstrp ClassLoader**、 **ExtClassLoader、AppClassLoader,这三个加载器在定义上不构成继承关系,但是从逻辑上构成父子关系。

# 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);
}
//......其他略.
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
由于启动类加载器是由C++实现的,所以在Java代码里面是访问不到启动类6加载器的,如果尝试通过 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程序默认的类加载器
# 双亲委派模型

类加载器加载某个类的时候,因为有多个加载器,甚至可以有各种自定义的,他们呈父子关系。这给人一种印象,子类的加载会覆盖父类,其实恰恰相 反
与普通类继承属性不同,类加载器会优先调父类的 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;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
# 为什么这么设计
避免重复加载、 核心类篡改
采用双亲委派模式的是好处是Java类随着它的类加载器一起具备了一种带有 优先级的层次关系,通过这种层级关可以避免类的重复加载,当父加载器已经加载了该类时,就没有必要子加载器再加载一次。
其次是考虑到安全因素,java核心api中定义类型不会被随意替换,假设通 过网络传递一个名为 java.lang.Integer 的类,通过双亲委派模型传递到启动 类加载器,而启动类加载器发现这个名字的类,发现该类已被加载,就不会重 新加载网络传递过来的 java.lang.Integer ,而直接返回已加载过的Integer.class ,这样便可以防止核心API库被随意篡改。
# 双亲委派能否打破
答案是可以的。
# 1.tomcat
tomcat通过war包进行应用的发布,它其实是违反了双亲委派机制原则,简单看一下tomcat类加载的层次结构如下

比如:Tomcat的webappClassLoader加载web应用下的class文件,不会传递给父类加载器,
问题:tomcat的类加载器为什么要打破该模型?
首先一个tomcat启动后是会起一个jvm进程的,它支持多个web应用部署到同一个tomcat里,为此
1、对于不同的web应用中的class和外部jar包,需要相互隔离,不能因为不同的web应用引用了相同的jar或者有相同的class导致一个加载成功了另一个加载不了。
2、web容器支持jsp文件修改后不用重启,jsp文件也是要编译成.class文件的,每一个jsp文件对应一个JspClassLoader,它的加载范围仅仅是这个jsp文件 所编译出来的那一个.class文件,当Web容器检测到jsp文件被修改时,会替换 掉目前JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文 件的热部署功能。
# GC
美团
1、JVM怎么判断一个类是不是垃圾?
2、说到GC ROOTS,你知道Java中哪些对象可作为GC ROOTS吗?
3、对象不可达是不是立即被回收死亡?
4、CMS垃圾回收器的回收过程
5、如何解决跨代引用?
字节
1、CMS收集器的流程,缺点;G1收集器的流程,相对于CMS收集器的优点
腾讯
1、请阐述常见的GC策略
2、垃圾回收的时候服务怎么办
神策数据
1、G1了解不,说说G1
快手
1、并发标记的过程是怎么样的?
# 题解
# 对象存活判断
# 引用计数
引用计数如何解决循环引用的问题:弱引用
# 可达性分析
来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连 时,则证明此对象是不可用的。
作为 GC Roots 的对象包括下面几种(重点是前面 4 种):
- 虚拟机栈(栈帧中的本地变量表)中引用的对象;各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。
- 方法区中类静态属性引用的对象;java 类的引用类型静态变量。
- 方法区中常量引用的对象;比如:字符串常量池里的引用。
- 本地方法栈中 JNI(即一般说的 Native 方法)引用的对象。
- JVM 的内部引用(class 对象、异常对象 NullPointException、 OutofMemoryError ,系统类加载器)。(非重点)
- 所有被同步锁( synchronized )持有的对象。(非重点)
- JVM 内部的JMXBean、JVMTI中注册的回调、本地代码缓存等(非重点)
- JVM 实现中的“临时性”对象,跨代引用的对象(在使用分代模型回收时只回收部分代的对象)(非重点)
除了这些固定的GC Roots集合以外,跟进用户选用的垃圾回收器以及当前回收的内存区域不同,还可能会有其他对象"临时"加入成为GC Roots

以上的回收都是普通的对象,普通对象大都在堆区,而对于类( Class ) 的回收条件比较苛刻。
# finalize
即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于 “缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找 到与 GCRoots 的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆 盖了 finalize),我们可以在 finalize 中去拯救,俗称对象的自我救赎。
需要注意的是:
1、 finalize 只会执行一次,不会多次执行。
2、建议大家尽量不要使用 finalize ,因为这个方法太不可靠。
如果一个对象被判定为的确有必要执行 finalize 方法,会将该对象放入一 个名为 F-QUEUE 的队列中,有虚拟机创建的名为 Finalizer 的线程去执行这些 对象的 finalize 方法。
# 分代回收理论
当前商业虚拟机的垃圾回收器,大多遵循“分代收集”的理论来进行设计,这 个理论大体上有三条法则:
1、 绝大部分的对象都是朝生夕死。
2、 熬过多次垃圾回收的对象就越难回收。
根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一 个区域,这个就构成了新生代和老年代,并且不同的分代采用的回收算法不一 样。

但是分代收集也并不是简单划分一下内存区域这么简单,因为对象不是孤 立的,对象之间存在跨代引用,譬如:现在要在新生代进行回收,但新生代的 对象极有可能被老年代对象所引用,那为了找到这些可能存活的对象,不得不 在既定的GC Roots之外,再遍历整个老年代对象确保可达性分析结果的正确 性。反过来回收老年代也是一样。
但是这样无疑带来了性能负担,为了解决这个问题,分代收集理论添加了第三条法则:
3、跨代引用相对于同代引用来说仅仅占少数
正是因为只占少数,所以不应该为了为了这些少量的跨代引用而区扫描整 个老年代,也不能浪费空间让每个对象都记录它是否存在跨代引用,所以为了 解决这个问题只需要在新生代建立一个全局的数据结构叫做:记忆集 (Remembered Set),这个结构把老年代划分成若干小块,并标识哪块内存 存在跨代引用,后续新生代发生 gc 时,只有包含了跨代引用的小内存区域才会 被加入到GC Roots进行扫描;当然这种方法需要在对象改变引用关系的时候维 护记忆集中数据的正确性。这种做法相比垃圾收集时扫描整个老年代来说仍然 时划算的。
# 常见回收算法
# 复制算法
原始的复制算法(Copying)是这样的
1、将内存按容量划分为大小相等的两块,每次只使用其中的一块。
2、当其中一块内存用完了,就将还存活着的对象复制到另外一块上面,然 后再把已使用过的内存空间一次清理掉。
==带来的好处==
1、实现简单,运行高效,
2、每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片 等复杂情况,只要按顺序分配内存即可,
==存在的弊端==
1、内存的使用率缩小为原来的一半。
2、内存移动是必须实打实的移动(复制),所以对应的引用(直接指针) 需要调整。
==适用场景==
复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。
但是像 hotspot 这样的虚拟机大都对原生的复制算法进行了改进,因为它对内存空间的利用率不高,而且专门研究表明,新生代中的对象 98%是“朝生夕死”的,所以并不需要按照 1:1的比例来划分内存空间,所以改进后的复制回收策略叫做==Appel式回收==。
1、将新生代划分为一块较大的Eden 区和两块较小的Survivor空间(你 可以叫做From或者To) ,HotSpot 虚拟机默认 Eden 和 Survivor 的大小 比例是 8:1。
2、每次使用 Eden 和其中一块 Survivor,当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden 和刚才用过的Survivor 空间。

在这样的算法下,
1、每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被 “浪费”
2、当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每 次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要依赖 其他内存(老年代)进行分配担保(Handle Promotion)。
# 标记-清除算法
标记-清除(Mark-Sweep)算法分为“标记”和“清除”两个阶段:
1、首先扫描所有对象标记出需要回收的对象,
2、在标记完成后扫描并回收所有被标记的对象,故需要两次扫描

注意:
1、回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为 需要大量标记对象和回收对象,对比复制回收效率要低,所以该算法不适合新 生代。
2、它的主要问题是在标记清除之后会产生大量不连续的内存碎片,空间碎 片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够 的连续内存而不得不提前触发另一次垃圾回收动作。
3、标记清除算法适用于老年代。
# 标记整理算法
算法逻辑如下:
1、首先标记出所有需要回收的对象,
2、在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有 存活的对象都向一端移动,
3、然后直接清理掉端边界以外的内存。

注意:
1、标记整理需要扫描两遍
2、标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单 会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的 地方都需要更新(直接指针需要调整)。
3、标记整理算法不会产生内存碎片,但是效率偏低。
4、标记整理算法适用于老年代。
所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。
# HotSpot实现细节
# STW
收集器在根节点枚举这步都是必须要暂停用户线程的( STW ),如果不这 样的话在根节点枚举的过程中由于引用关系在不断变化,分析的结果就不准确。
# 安全点
收集器在工作的时候某些时间是需要暂停正在执行的用户线程的 ( STW ),这个暂停也并不是说用户线程在执行指令流的任意位置都能停顿下来开始垃圾收集,而是需要等用户线程执行到最近的安全点后才能够暂停。
安全点如何选取呢?,安全点的选取基本是以:”是否具有让程序长时间执 行的特征“为标准选定的,而最明显的特征就是指令序列的复用,主要有以下几 点:
1、方法调用
2、循环跳转
3、异常跳转等等
对于安全点另一个问题是:垃圾收集器工作时如何让用户线程都跑到最近的安全点停顿下来?有两种方案:
1、抢先式中断:不需要用户代码主动配合,垃圾收集发生时,系统把用户 线程全部中断,如果发现用户线程中断的地方不在安全点上,就恢复这个线程 执行让它执行一会再重新中断。不过现在的虚拟机几乎没有采用这种方式。
2、主动式中断:思想是当垃圾收集器需要中断线程的时候,不直接对线程 操作,仅仅设置一个标志位,各个线程执行过程中会不停的去主动轮询这个标 志,一旦发现中断标志为真时就自己再最近的安全点上主动挂起。
# 安全区域
安全点的设计似乎完美的解决了如何停顿用户线程,它能保证用户线程在 执行时,不太长时间内就会遇到可进入垃圾回收的安全点,但是如果用户线程 本身就没在执行呢?比如用户线程处于 sleep 或者 blocked 状态,这个时候它 就无法响应虚拟机的中断请求,没办法主动走到安全的地方中断挂起自己,对 于这种情况就必须引入安全区域(Safe Regin)来解决。
安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化, 因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区 域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安 全区域,这段时间里 JVM 要发起 GC 就不必去管这些线程了。 当线程要离开安 全区域时,它要检查 JVM 是否已经完成了根节点枚举(或者其他 GC 中需要暂 停用户线程的阶段)
1、如果完成了,那线程就当作没事发生过,继续执行。
2、如果没完成,它就必须一直等待, 直到收到可以离开安全区域的信号为止。

# 记忆集与卡表
前面讲分代收集理论的时候提到过一个跨代引用的问题,为了解决跨代引用带来的问题,垃圾收集器在新生代建立了一个叫做:记忆集(Remembered Set)的数据结构存储老年代哪些区域存在跨代引用,以便在根节点扫描时将这些老年代区域加入 的扫描范围,这样避免将整个老年代都加入的扫描范围。
当然跨代引用的问题并非只在回收新生代才有,回收老年代也是一样的,所以需要更进一步理解记忆集的原理和实现方式。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
记忆集的实现,最常见的实现方式是通过卡表的方式去实现,卡表最简单的形式是一个字节数组,如下:
CARD_TABLE[this address >> 9 ] = 0
1、字节数组 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作:卡页( ),卡页大小一般是2的N次幂, 中是2的9次幂(地址右移9位),即512字节。
2、如果卡表标识的起始地址是:0x0000,那数组的0,1,2号元素,分别对应的地址范围是:0x0000~ox01ff,0x0200~0x03ff,0x0400~0x05ff,如下

3、一个卡页的内存中通常包含不止一个对象,只要卡页内存中有一个或多 个对象的字段存在跨代引用指针,那就将卡表对应字节数组元素的值标识位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);
}
2
3
4
5
6
当然这里还需要解决一个问题:卡表在高并发场景下面临着 伪共享 问题, 一般处理器的缓存行(cache line)大小是64字节,由于卡表一个元素占一 个字节,64个卡表元素共享同一个缓存行,这64个卡表元素对应的卡页总大小 内存为:64*512bytes=32M,也就是说如果不同线程更新的对象引用正好处在 这32M内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。
为了解决伪共享的问题,简单的解决方案就是不采用无条件的写屏障,而是先检查卡标记,只有当卡表元素未被标记过时才将其标记为变脏,即更新卡表的逻辑变更如下:
if (CARD_TABLE[this address >> 9] != 0 ) {
CARD_TABLE[this address >> 9] = 0;
}
2
3
在jdk1.7之后 , hotspot虚拟机增加了一个参数-XX:+UseCondCardMark,用来解决是否开启卡表更新前的条件判断,开启会增加一次额外的条件判断开销,但能够避免伪共享问题,两者各有性能损耗,是否开启需要根据实际情况来测试权衡,默认是关闭的。
# 回收器

常见的垃圾回收器如下:
SerialGC

ParallelGC

# CMS
CMS的整体执行过程分成5个步骤,其中标记阶段包含了三步,具体细节如 下:
1、初始标记
标记GC Roots直接关联的对象,会导致STW,但是这个没多少对象,时间短。
2、并发标记
从 GC Roots 开始关联的所有对象开始遍历整个可达路径的 对象,这步耗时比较长,所以它允许用户线程和GC线程并发执行,并不会导致STW ,但面临的问题是可能会漏标,多标,等问题。
3、重新标记
为了修正并发标记期间因用户程序继续运作而导致标记产生 变动的那一部分对象的标记记录,这个阶段会导致 STW ,但是停顿时间一般会 比初始标记阶段稍长一些,但远比并发标记的时间短。
4、并发清除
将被标记的对象清除掉,因为是标记-清除算法,不需要移 动存活对象,所以这一步与用户线程并发运行。
5、重置线程
重置GC线程状态,等待下次CMS的触发,与用户线程同时运行。

当然,在CMS中也会出现一些问题,主要有以下几点
1、CPU敏感
对处理器资源敏感,毕竟采用了并发的收集、当处理核心数 不足 4 个时,CMS 对用户的影响较大,因为CMS默认启动的回收线程数量是: (CPU核数+3)/ 4。
2、浮动垃圾
由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运 行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉,这一部分垃圾 就称为“浮动垃圾”(比如用户线程运行产生了新的GC Roots)。由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能 像其它收集器那样等待老年代快满的时候再回收,在 1.6 的版本中老年代空间 使用率阈值(92%) ;如果预留的内存不够存放浮动垃圾,就会出现,这时虚拟机将临时启用 来替代 CMS,冻结用户 线程的执行了回收老年代,这样会导致很长的停顿时间。
3、空间碎片
这是由于CMS采用的是标记-清除算法导致的,当碎片较多 时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS提供一个参数: -XX:+UseCMScompactAtFullCollection,一般是开启的,如果分配不了大对象,就进行内存碎片的整理过程;这个地方一般会使用Serial Old,因为Serial Old是一个单线程,回收时会暂停用户线程,然后进行空间整理。所以如果分配的对象较大,且较多时,CMS发生这样的情况会很卡。
# ParNew
收集器实质上是 收集器的多线程并行版本,除了使用多个 线程并行收集外,其他行为和能使用的参数跟 收集器完全一致,可以和Serial Old搭配使用。
# 并发标记
到目前为止,所有收集器在根节点枚举遍历其直接关联的对象时是要 STW 的,并发收集器在继续往下进行可达性标记时是允许用户线程并发执行的,这样有效的减少了整体STW时间, 那这个并发标记到底是如何工作的呢?这就是我们要说的三色标记。
# 算法概述
首先约定好jvm在GC时会对对象进行颜色标记,按照对象是否被访问过这 个条件将对象标记成以下三种颜色:
# 白色
表示该对象尚未被收集器访问过,在可达性分析结束后,仍为白色 的对象表示不可达,即为垃圾。要被回收
# 灰色
表示该对象已被收集器访问过,但是这个对象至少存在一个引用还未被扫描
# 黑色
表示该对象已被收集器访问过,并且它的所有引用都已被扫描,黑色对象是安全存活的。
另外,对于黑色对象
1、如果有其他对象的引用指向了黑色对象,无需重新扫描一遍
2、黑色对象不可能绕过灰色对象直接指向白色对象。
下面我们根据可达性分析算法来看一下三色标记的过程:
# 三色标记过程
# 初始状态
首先所有对象都是白色的,进行GC Roots枚举,STW,枚举后只有GC Roots是黑色的

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

# 并发标记
这个阶段是并发执行, GC 线程扫描整个引用链,分两种情况:
1、没有子节点,将本节点标记为黑色。
2、有子节点,将当前节点标记为黑色,子节点标记为灰色。
就这样继续沿着对象图遍历下去

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

扫描完成后,黑色对象就是存活的对象,白色对象就是已消亡可回收的对象。
# 三色标记的问题
在并发标记阶段的时候,因为用户线程与 GC 线程同时运行,有可能会产生 多标或者漏标。
# 多标
如下图,假设已经遍历到 E(变为灰色了),此时应用程序将D > E 的引用断开。

D > E的引用断开之后,E、F、G 三个对象不可达,应该要被回收的。然而因为 E 已经变为灰色了,其仍会被当作存活对象继续遍历下去。最终的结果 是:这部分对象仍会被标记为存活,即本轮 GC 不会回收这部分内存。
这部分本应该回收但是没有回收到的内存,被称之为 浮动垃圾 。浮动垃圾 并不会影响应用程序的正确性,只是需要等到下一轮垃圾回收中才被清除。
另外,针对并发标记开始后的新创建的对象,通常的做法是直接全部当成黑色,本轮不会进行清除。这部分对象期间可能会变为垃圾,这也算是浮动垃圾的一部分。
# 漏标
假设 GC 线程已经遍历到 E(变为灰色了),此时应用线程断开E > G的引 用,同时添加D > G的引用。

切回到 GC 线程,因为 E 已经没有对 G 的引用了,所以不会将 G 置为灰 色;尽管因为 D 重新引用了 G,但因为 D 已经是黑色了,不会再重新做遍历处 理。最终导致的结果是:G 会一直是白色,最后被当作垃圾进行清除。这直接 影响到了应用程序的正确性,是不可接受的。
当然漏标的发生有两个条件:
1、一个或者多个黑色对象重新引用了白色对象;即黑色对象成员变量增加 了新的引用
2、灰色对象断开了白色对象的直接或间接引用;即灰色对象原来成员变量 的引用发生了变化。
对于这种情况,我们需要将 G 这类对象记录下来,作为灰色对象在重新标 记阶段继续向下遍历,当然这个阶段需要 STW 。
# 读写屏障
针对于漏标问题,JVM 团队采用了读屏障与写屏障的方案。其目的很简单,就是在读写前后将G这类对象给记录下来。
读屏障
oop oop_field_load(oop* field) {
// 读屏障-读取前操作
pre_load_barrier(field); return *field;
}
2
3
4
当读取成员变量之前,先记录下来,这种做法是保守的,但也是安全的。 因为条件1中【一个或者多个黑色对象重新引用了白色对象】,重新引用的前提 是:得获取到该白色对象,此时已经读屏障就发挥作用了。
写屏障
所谓的写屏障,其实就是指给某个对象的成员变量赋值操作前后,加入一 些处理(类似 Spring AOP 的概念)。
void oop_field_store(oop* field, oop new_value) {
// 写屏障-写前操作
pre_write_barrier(field);
*field = new_value;
// 写屏障-写后操作
post_write_barrier(field, value);
}
2
3
4
5
6
7
不管是条件1还是条件2中,都有对一个灰色对象或者黑色对象的属性进行写操作。
# 增量更新与原始快照
解决漏标问题,只要破坏漏标的两个条件之一即可,不同收集器采用的方案也不一样,
# 增量更新
1、主要针对对象新增的引用,利用写屏障将其记录下来,这样破坏了条件
2、后续重新扫描时还会继续从记录下来的新增引用深度扫描下去 CMS收集器采用的是这种方案。
# 原始快照
1、当某个对象断开其属性的引用时,利用写屏障,将断开之前的引用记录下来,
2、尝试保留开始时的对象引用图,即原始快照,当某个时刻的 GC Roots 确定后,当时的对象引用图就已经确定了。
3、后续标记是按照开始时的快照走,比如E > G,即使期间发生变化,通 过写屏障记录后,保证标记还是按照原本的视图来,
4、SATB破坏的是漏标条件2,主要针对是引用的减少。
G1收集器采用的是这种方案。
# 总结
基于可达性分析的 GC 算法,标记过程几乎都借鉴了三色标记的算法思想,尽管实现的方式不尽相同,比如标记的方式有栈、队列、多色指针等。
对于读写屏障,以 Java HotSpot VM 为例,其并发标记时对漏标的处理方 案如下:
1、CMS
写屏障 + 增量更新
2、G1、Shenandoah
写屏障 + 原始快照
另外,他们各自的总结如下。
1、原始快照相对增量更新来说效率更高,因为不需要在重新标记阶段再次 深度扫描被删除引用对象,当然原始快照可能造成更多的浮动垃圾。
2、而 CMS 对增量引用的根对象会做深度扫描,G1 因为很多对象都位于不 同的 region,CMS 就一块老年代区域;重新深度扫描对象的话 G1 的代价会比 CMS 高,所以 G1 选择原始快照不深度扫描对象,只是简单标记,等到下一轮 GC 再深度扫描。
G1
G1全称--Garbage First,是一种服务器式垃圾收集器,针对具有大内存 的多处理器机器。它试图以高概率满足垃圾收集 (GC) 暂停时间目标,同时实现 高吞吐量。是垃圾收集器发展史上里程碑式的成果,它开创了收集器面向局部 收集的设计思路以及基于 Region 的内存布局形式。
G1是全堆操作且与应用程序线程并发执行,并通过多种技术实现高性能和 暂停时间目标。G1的产生是为解决CMS算法产生空间碎片和其它一系列的问题 缺陷,oracle官方计划在jdk9中将G1变成默认的垃圾收集器,以替代CMS。
JDK9默认G1为垃圾收集器的提案:https://openjdk.java.net/jeps/248
将CMS标记为丢弃的提案:https://openjdk.java.net/jeps/291
# 设计思想
作为CMS的替代者和继承人,设计者希望能够建立起具有--停顿时间模型的收集器,通过该模型的意思是--要达到在指定时间为M毫秒内,垃圾收集耗时大概率不超过N毫秒的目标。
# 1、思想转变
要实现这个目标,首先要有一个思想上的转变,G1收集器出 现之前的其他所有收集器,他们的收集范围要么是新生代(Minor GC),要么 是老年代(Major GC),要么是整堆(Full GC),而G1跳出了这个樊笼,它可以面向堆内存任何部分来组成回收集(Collection Set,CSet)进行回收,衡量标准不再是它属于哪个分代,而是哪个回收集中存放的垃圾最多,回收收益最大,就回收哪个。
# 2、新的内存布局
当然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仍然保留了新生代,老年代的概念,只不过它们不再连续和固定的了,

# 3、回收策略
G1之所以能建立可预测的“停顿时间模型”的原因在于它将 Region 作为单次回收的最小单元,即每次回收的空间都是 Region 的整数倍,同时G1会去追踪各个 Region 里面垃圾的“价值”(回收所获得的空间大小以及回 收所需要的时间的经验值),然后在后台维护一个优先级列表,每次根据用户 设定停顿时间( -XX:MaxGCPauseMillis=time,默认200毫秒),优先回收价值收益最大的那些Region 。
回收时G1将存活的对象从堆的一个或多个Region复制到堆上的单个其他 Region ,并在此过程中压缩和释放内存。这个工作是在多处理器上并行执行, 以减少暂停时间并提高吞吐量。因此,每次垃圾回收时,G1 都会不断努力减少碎片

# 实现细节
G1将堆内存“化整为零”的思路看起来不难理解,但是有很多细节问题需要
# 解决
# 1、跨Region引用如何解决
前面我们知道通过记忆集(RSet )解决跨指向自己,但是G1中,每个Region都系要维护自己的记忆集,记录别的Region指向自己,但是G1中的Region数量要比传统收集器的分代数量明显多的多,所以G1中使用记忆集要比其他收集器有着更高的内存占用负担,根据经验,G1至少要耗费大约相当于java堆容量的10%~20%。
# 2、并发标记问题
如何保证并发标记阶段GC收集线程与用户线程互不干扰,当然G1是通过原始快照(SATB )解决的(CMS是通过增量更新实现的)。
另外一个需要解决的就是并发回收阶段如何处理用户线程新创建对象的内存分配,G1的做法是为每个Region设计了两个名为TAMS的指针,把Region中的一部分空间划分出来用于存放并发回收过程中的新对象分配。G1收集器在本次回收时默认这些对象是存活的,不回收的。
# 3、如何建立可靠的可预测模型
用户通过-XX:MaxGCPauseMillis=time参数指定的停顿时间只是一个期望值,但是G1怎么做才能满足用户的期望呢?
G1收集器在收集过程中会记录每个Region的回收耗时,每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析出平均值,标准偏差,置信度等统计信息。根据这些信息决定Region的回收价值。
# 参数设置
# 1、启用G1收集器 -XX:+UseG1GC

# 2、设置分区大小 -XX:G1HeapRegionSize=value

# 3、设置最大GC暂停时间 -XX:MaxGCPauseMillis=time

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

# 1、初始标记
标记出GC Roots直接关联的对象,并且修改TAMS指针的值,这个阶段速度较快,STW,单线程执行,
# 2、并发标记
从GC Root开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行。
# 3、重新标记
修正在并发标记阶段因用户程序执行而产生变动的标记记录,即处理 SATB 记录。STW,并发执行。
# 4、筛选回收
筛选回收阶段会对各个 Region 的回收价值和成本进行排 序,根据用户所期望的 GC 停顿时间来制定回收计划,筛出后移动合并存活对象 到空Region,清除旧的,完工。因为这个阶段需要移动对象内存地址,所以必 须STW。
==思考一下,这属于什么算法呢==
- 从Region的动作来看G1使用的是标记-复制算法。而在全局视角上,类似标记 - 整理
# 总结
G1前面的几步和CMS差不多,只有在最后一步,CMS是标记清除,G1需要 合并Region属于标记整理
# 优缺点
# 1、并发性
继承了CMS的优点,可以与用户线程并发执行。当然只是在并发标记阶段。其他还是需要STW
# 2、分代GC
G1依然是一个分代回收器,但是和之前的各类回收器不同, 它同时兼顾年轻代和老年代。而其他回收器,或者工作在年轻代,或者工作在老年代
# 3、空间整理
G1在回收过程中,会进行适当的对象移动,不像CMS只是 简单地标记清理对象。在若干次GC后,CMS必须进行一次碎片整理。而G1不同,它每次回收都会有效地复制对象,减少空间碎片,进而提升内部循环速 度。
# 4、可预测性
为了缩短停顿时间,G1建立可预存停顿的模型,这样在用户 设置的停顿时间范围内,G1会选择适当的区域进行收集,确保停顿时间不超过 用户指定时间。
==几点建议==
1、如果应用程序追求低停顿,可以尝试选择G1
2、经验值上,小内存6G以内,CMS优于G1,超过8G,尽量选择G1
3、是否代替CMS只有需要实际场景测试才知道。(如果使用G1后发现性 能还不如CMS,那么还是选择CMS)