一、内存模型
1. 程序计数器
每个线程会通过程序计数器记录当前要执行的字节码指令地址。主要有两个作用:
- 程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。
- 在多线程执行情况下,Java 虚拟机需要通过程序计数器记录CPU切换前解释执行到那一句指令并继续解释运行
2. 栈
Java虚拟机栈采用栈的数据结构来管理方法调用中的基本数据,先进后出,每一个方法的调用使用一个栈恢来保存每个线程都会包含一个自己的虚拟机栈,它的生命周期和线程相同。
栈帧主要包含三部分内容
- 局部变量表,在方法执行过程中存放所有的局部变量。本质上是个数组,长度就是局部变量个数
- 操作数栈,虚拟机在执行指令过程中用来存放临时数据的一块区域
- 帧数据,主要包含动态链接、方法出口、异常表等内容。
- 动态链接,方法中要用到其他类的属性和方法,这些内容在字节码文件中是以编号保存的,运行过程中需要替换成内存中的地址,这个编号到内存地址的映射关系就保存在动态链接中。
- 方法出口: 方法调用完需要弹出栈,回到上一个方法,程序计数器要切换到上一个方法的地址继续执行,方法出口保存的就是这个地址
- 异常表:存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置
3. 本地方法栈
Java虚拟机栈存储了Java方法调用时的栈帧,而本地方法栈存储的是native本地方法的栈帧。
在 Hotspot
虚拟机中,Java虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。
4. 堆
一般Java程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上。
栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
堆是垃圾回收最主要的部分,堆结构更详细的划分与垃圾回收器有关
5. 方法区
方法区是Java虚拟机规范中提出来的一个虚拟机概念,在 Hotspot
不同版本中会用永久代或者元空间来实现。方法区主要存放的是基础信息,包含:
- 每一个加载的类的元信息 (基础信息)
- 运行时常量池,保存了字节码文件中的常量池内容,避免常量内容重复创建减少内存开销。
- 字符串常量池,存储字符串的常量。
方法区位置
方法区是《Java虚拟机规范》中设计的虚拟概念,每款Java虛拟机在实现上都各不相同。Hotspot 设计如下: .
- JDK7及之前的版本将方法区存放在堆区域中的永久代空间,堆的大小由虚拟机参数来控制。
- JDK8及之后的版本将方法区存放在元空间中,元空间位于操作系统维护的直接内存中,默认情况下只要不超过操作系统承受的上限,可以一直分配。也可以手动设置最大大小。
使用元空间替换永久代的原因:
1、提高内存上限:元空间使用的是操作系统内存,而不是JVM内存。如果不设置上限,只要不超过操作系统内存上限,就可以持续分配。而永久代在堆中,可使用的内存上限是有限的。所以使用元空间可以有效减少OOM情况的出现。
2、优化垃圾回收的策略:永久代在堆上,垃圾回收机制一般使用老年代的垃圾回收方式,不够灵活。使用元空间之后单独设计了一套适合方法区的垃圾回收机制。
字符串常量池位置
字符串常量池从方法区移动到堆的原因:
1、垃圾回收优化:字符串常量池的回收逻辑和对象的回收逻辑类似,内存不足的情况下,如果字符串常量池中的常量不被使用就可以被回收;方法区中的类的元信息回收逻辑更复杂一些。移动到堆之后就可以利用对象的垃圾回收器,对字符串常量池进行回收。
2、让方法区大小更可控:一般在项目中,类的元信息不会占用特别大的空间,所以会给方法区设置一个比较小的上限。如果字符串常量池在方法区中,会让方法区的空间大小变得不可控。
3、intern方法的优化:JDK6版本中 intern ()
方法会把第一次遇到的字符串实例复制到永久代的字符串常量池中。JDK7及之后版本中由于字符串常量池在堆上,就可以进行优化:字符串保存在堆上把字符串的引用放入字符串常量池,减少了复制的操作。
6. 直接内存
直接内存并不在《Java虚拟机规范》中存在,所以并不属于Java运行时的内存区域。在JDK 1.4 中引入了 NIO机制,由操作系统直接管理这部分内容,主要为了提升读写数据的性能。在网络编程框架如 Netty 中被大量使用。要创建直接内存上的数据,可以使用 ByteBuffer
。
语法:
ByteBuffer directBuffer = ByteBuffer.allocateDirect(size);
二、虚拟机参数设置
VM选项有三种:
-
:标准VM选项,VM规范的选项
-X
:非标准VM选项,不保证所有VM支持
-XX
:高级选项,高级特性,但属于不稳定的选项
参数 | 说明 |
---|---|
-Xms | 初始堆内存大小为1GB |
-Xmx | 最大堆内存大小为1GB |
-Xss | 每个线程的栈内存最大为256KB |
-XX:MaxMetaspaceSize | 最大元空间大小为512MB |
-XX:+DisableExplicitGC | 禁止显式触发垃圾回收 |
-XX:+HeapDumpOnOutOfMemoryError | 出现OutOfMemoryError错误时生成堆内存快照 |
-XX:HeapDumpPath | 堆内存快照生成位置 |
-XX:+PrintGCDetails | 打印详细垃圾回收日志 |
-XX:+PrintGCDateStamps | 打印垃圾回收时间 |
-Xloggc | 垃圾回收日志文件输出位置 |
三、类的生命周期
1. 加载
- 加载(Loading)阶段第一步是类加载器根据类的全限定名通过不同的渠道以二进制流的方式获取字节码信息
- 类加载器在加载完类之后,Java虚拟机会将字节码中的信息保存到内存的方法区中。在方法区生成一个
InstanceKlass
对象,保存类的所有信息。 - 在堆中生成一份与方法区中数据类似的
java.lang.Class
对象, 作用是在Java代码中去获取类的信息.。
2. 连接
1)验证
连接(Linking)阶段的第一个环节是验证,验证的主要目的是检测Java字节码文件是否遵守了《Java虚拟机规 范》中的约束。这个阶段一般不需要程序员参与。
主要包含如下四部分,具体详见《Java虚拟机规范》:
- 文件格式验证,比如文件是否以0xCAFEBABE开头,主次版本号是否满足当前Java虚拟机版本要求。
- 元信息验证,例如类必须有父类(super不能为空)。
- 验证程序执行指令的语义,比如方法内的指令执行到一半强行跳转到其他方法中去。
- 符号引用验证,例如是否访问了其他类中private的方法等。
2)准备
准备阶段为静态变量(static)分配内存并设置初值。final修饰的基本数据类型的静态变量,准备阶段直接会将 代码中的值进行赋值。
3)解析
解析阶段主要是将常量池中的符号引用替换为直接引用。符号引用就是在字节码文件中使用编号来访问常量池中 的内容。直接引用不在使用编号,而是使用内存中地址进行访问具体的数据。
3. 初始化
初始化阶段会执行字节码文件中 clinit
(class init)部分的字节码指令,即静态代码块中的代码,为静态变量赋值
clinit 是静态的,类级别的;而 init 方法中是非静态的,对象级别的
- 连接的准备阶段 value 赋初值为 0
- 初始化阶段执行 clinit 方法中的指令,value 先赋值为1,再赋值为 2
- 如果创建对象,会执行构造 init 方法,value赋值为3(类中代码块中的内容被放到了构造方法中)
4. 卸载
判定一个类可以被卸载。需要同时满足下面三个条件:
- 此类所有实例对象都已经被回收,在堆中不存在任何该类的实例对象以及子类对象。
- 加载该类的类加载器已经被回收。
- 该类对应的 java.lang.Class 对象没有在任何地方被引用。
四、类加载器
1. 启动类加载器
启动类加载器(Bootstrap ClassLoader)是由Hotspot虚拟机提供的类加载器,JDK9之前使用C++编写的、JDK9之 后使用Java编写。
- 默认加载 Java 安装目录
/jre/lib
下的类文件,比如rt.jar
,tools.jar
,resources.jar
等。
2. 扩展类加载器
扩展类加载器(Extension Class Loader)是JDK中提供的、使用Java编写的类加载器。JDK9之后由于采用了模块化, 改名为Platform平台类加载器。
- 默认加载 Java 安装目录
/jre/lib/ext
下的类文件。
3. 应用程序类加载器
应用程序类加载器(Application Class Loader)是JDK中提供的、使用Java编写的类加载器。默认加载为应用程序 classpath
下的类。
4. 自定义类加载器
自定义类加载器允许用户自行实现类加载的逻辑,可以从网络、数据库等来源加载类信息。自定义类加载器需要继承自 ClassLoader 抽象类,重写 findClass 方法。
5. 双亲委派
双亲委派机制指的是:当一个类加载器接收到加载类的任务时,会向上查找是否加载过,再由顶向下进行加载。
案例:先向上查找,再向下尝试加载
1)双亲委派的作用
- 保证类加载的安全性
通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如 java.lang.String,确保核心类 库的完整性和安全性。 - 避免重复加载
双亲委派机制可以避免同一个类被多次加载。
2)打破双亲委派
ClassLoader中包含了4个核心方法,打破双亲委派机制的唯一方法就是实现自定义类加载器 重写 loadClass
方法,将其中的双亲委派机制代码去掉。
// 类加载的入口,提供了双亲委派机制。内部会调用 findClass
public Class<?> loadClass(String name)
// 由类加载器子类实现,获取二进制数据调用 defineClass ,比如URLClassLoader会根据文件路 径去获取类文件中的二进制数据。
protected Class<?> findClass(String name)
// 做一些类名的校验,然后调用虚拟机底层的方法将 字节码信息加载到虚拟机内存中
protected final Class<?> defineClass(String name, byte[] b, int off, int len)
// 执行类生命周期中的连接阶段
protected final void resolveClass(Class<?> c)
6. Tomcat自定义类加载器
common 类加载器主要加载 tomcat 自身使用以及应用使用的 jar 包,默认配置在 catalina.properties
文件中。
common.loader="${catalina.base}/lib","${catalina.base}/lib/*.jar"
server.loader=
:默认配置为空,为空时 catalina 加载器和 common 加载器是同一个。shared.loader=
:默认配置为空,为空时 shared 加载器和 common 加载器是同一个。ParallelWebappClassLoader
:可以多线程并行加载应用中使用到的类,每个应用都拥有一个自己的该类加载器。同一个类加载器,只能加载一个同名的类。两个应用中相同名称的类都必须要加载,所以一个应用一个该类加载器JasperLoader
:类加载器负责加载JSP文件编译出来的class字节码文件,为了实现热部署(不重启让修改的 jsp生效),每一个jsp文件都由一个独立的JasperLoader负责加载。
ParallelWebappClassLoader 执行流程,默认是没有开启代理的:
五、垃圾回收器
1. 如何判断堆上的对象没有被引用
1)引用计数法
引用计数法会为每个对象维护一个引用计数器,当对象被引用时加1,取消引用时减1。
引用计数法的优点是实现简单,缺点有两点:
- 每次引用和取消引用都需要维护计数器,对系统性能会有一定的影响
- 存在循环引用问题,所谓循环引用就是当A引用B,B同时引用A时会出现对象无法回收的问题。
2)可达性分析算法
Java 使用的是可达性分析算法来判断对象是否可以被回收。
可达性分析将对象分为两类:垃圾回收的根对象(GC Root
)和普通对象,对象与对象之间存在引用关系。 下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个到 GC Root
对象是可达的,对象就 不可被回收。
哪些对象被称之为GC Root对象呢?
- 线程 Thread 对象,引用线程栈帧中的方法参数、局部变量等。
- 系统类加载器加载的
java.lang.Class
对象,引用类中的静态变量。 - 监视器对象,用来保存同步锁 synchronized 关键字持有的对象。
- 本地方法调用时使用的全局对象。
2. 引用类型
- 强引用,JVM中默认引用关系就是强引用,即是对象被局部变量、静态变量等GC Root关联的对象引用,只要这层关系存在,普通对象就不会被回收。
- 软引用,软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。软引用主要在缓存框架中使用。
- 弱引用,弱引用的整体机制和软引用基本一致,区别在于弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收,弱引用主要在
ThreadLocal
中使用。 - 虚引用(幽灵引用/幻影引用),不能通过虚引用对象获取到包含的对象。虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。直接内存中为了及时知道直接内存对象不再使用,从而回收内存,使用了虚引用来实现。
- 终结器引用,终结器引用指的是在对象需要被回收时,终结器引用会关联对象并放置在Finalizer类中的引用队 列中,在稍后由一条由FinalizerThread线程从队列中获取对象,然后执行对象的finalize方法,在对象第二次被回收时,该对象才真正的被回收。
ThreadLocal中为什么要使用弱引用?
ThreadLocal 中是这样去保存对象的:
- 在每个线程中,存放了一个 ThreadLocalMap 对象,本质上就是一个数组实现的哈希表,里边存放多个Entry对象。
- 每个Entry对象继承自弱引用,内部存放 ThreadLocal 对象。同时用强引用,引用保存的 ThreadLocal 对应的value值。
不再使用 Threadlocal 对象时,threadlocal = null;
由于是弱引用,那么在垃圾回收之后,ThreadLocal 对象就可以被回收。
此时还有 Entry 对象和 value 对象没有能被回收,所以在 ThreadLocal 类的set、get、remove方法中,在某些特定条件满足的情况下,会主动删除这两个对象。如果一直不调用set、get、remove方法或者调用了没有满足条件,这部分对象就会出现内存泄漏。
强烈建议在 ThreadLocal 不再使用时,调用 remove
方法回收将 Entry 对象的引用关系去掉,这样就可以回收这两个对象了。
3. 垃圾回收算法
1)标记清除算法
标记清除算法的核心思想分为两个阶段:
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 清除阶段,从内存中删除没有被标记也就是非存活对象。
优点:实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。
缺点:
- 碎片化问题 由于内存是连续的,所以在对象被删除之后,内存中会出现很多细小的可用内存单元。如果我们需要的是一个比较大的空间,很有可能这些内存单元的大小过小无法进行分配。
- 分配速度慢。由于内存碎片的存在,需要维护一个空闲链表,极有可能发生每次需要遍历到链表的最后才能获得合适的内存空间。
2)复制算法
- 准备两块空间 From 空间和 To 空间,每次在对象分配阶段,只能使用其中一块空间(From空间)。
- 在垃圾回收GC阶段,将 From 中存活对象复制到 To 空间。
- 将两块空间的 From 和 To 名字互换。
优点:
- 吞吐量高。复制算法只需要遍历一次存活对象复制到 To 空间即可,比标记-整理算法少了一次遍历的过程,因而性能较好,但是不如标记-清除算法, 因为标记清除算法不需要进行对象的移动
- 不会发生碎片化。复制算法在复制之后就会将对象按顺序放入 To 空间中,所以对象以外的区域都是可用空间,不存在碎片化内存空间。
缺点:内存使用效率低。每次只能让一半的内存空间来为创建对象使用
3)标记整理算法
标记整理算法也叫标记压缩算法,是对标记清理算法中容易产生内存碎片问题的一种解决方案。
- 标记阶段,将所有存活的对象进行标记。Java中使用可达性分析算法,从GC Root开始通过引用链遍历出所有存活对象。
- 整理阶段,将存活对象移动到堆的一端。清理掉存活对象的内存空间。
4)分代垃圾回收算法
现代优秀的垃圾回收算法,会将上述描述的垃圾回收算法组合进行使用,其中应用最广的就是分代垃圾回收算法(Generational GC)。分代垃圾回收将整个内存区域划分为年轻代和老年代:
分代回收时,创建出来的对象,首先会被放入Eden伊甸园区。 随着对象在Eden区越来越多,如果Eden区满,新创建的对象已经无法放入,就会触发年轻代的GC,称为
Minor GC
或者Young GC
。Young GC 会把需要 eden 中和 From 需要回收的对象回收,把没有回收的对象放入 To 区。
接下来,S0 会变成 To 区,S1 变成 From 区。当 eden 区满时再往里放入对象,依然会发生Young GC。 此时会回收 eden 区和 S1(from)中的对象,并把 eden 和 from 区中剩余的对象放入 S0。 注意:每次Young GC中都会为对象记录他的年龄,初始值为0,每次GC完加1。
如果Young GC后对象的年龄达到阈值(最大15,默认值和垃圾回收器有关),对象就会被晋升至老年代。 当老年代中空间不足,无法放入新的对象时,先尝试Young GC如果还是不足,就会触发
Full GC
,Full GC
会对整个堆进行垃圾回收。
如果Full GC依然无法回收掉老年代的对象,那么当对象继续放入老年代时,就会抛出
Out Of Memory
异常。
优点:
- 可以通过调整年轻代和老年代的比例来适应不同类型的应用程序,提高内存的利用率和性能。
- 新生代和老年代使用不同的垃圾回收算法,新生代一般选择复制算法效率高、不会产生内存碎片,老年代可以选择标记-清除和标记-整理算法,由程序员来选择灵活度较高。
- 分代的设计中允许只回收新生代(Young GC),如果能满足对象分配的要求就不需要对整个堆进行回收 (Full GC),STW(Stop The World)由垃圾回收引起的停顿时间就会减少。
4. G1垃圾回收器
JDK9之后默认的垃圾回收器是G1(Garbage First)垃圾回收器。
Parallel Scavenge关注吞吐量,允许用户设置最大暂停时间 ,但是会减少年轻代可用空间的大小。 CMS 关注暂停时间,但是吞吐量方面会下降。 而G1设计目标就是将上述两种垃圾回收器的优点融合:
- 支持巨大的堆空间回收,并有较高的吞吐量。
- 支持多CPU并行垃圾回收。
- 允许用户设置最大暂停时间。
1)内存结构
G1的整个堆会被划分成多个大小相等的区域,称之为区 Region
,区域不要求是连续的。分为 Eden
、Survivor
、 Old
区。Region
的大小通过 堆空间大小/2048
计算得到,也可以通过参数 -XX:G1HeapRegionSize=32m
指定(其中32m指定region大小为32M),Region size
必须是2的指数幂,取值范围从 1M 到 32M。
2)垃圾回收
- 年轻代回收(Young GC)
年轻代回收(Young GC),回收Eden
区和Survivor
区中不用的对象。会导致STW
,G1中可以通过参数-XX:MaxGCPauseMillis=n
(默认200) 设置每次垃圾回收时的最大暂停时间毫秒数,G1垃圾回收器会尽可能地保证暂停时间。 - 混合回收(Mixed GC):G1对老年代的清理会选择存活度最低的区域来进行回收,这样可以保证回收效率最高,这也是G1(Garbage first)名称的由来。
混合回收分为:初始标记(initial mark)、并发标记(concurrent mark)、最终标记(remark或者Finalize Marking)、并发清理(cleanup)
- Full GC:如果清理过程中发现没有足够的空 Region 存放转移的对象,会出现 Full GC。单线程执行标记-整理算法,此时会导致用户线程的暂停。所以尽量保证应该用的堆内存有一定多余的空间。
执行流程
新创建的对象会存放在 Eden 区。当 G1 判断年轻代区不足(max默认60%),无法分配对象时需要回收时会执行 Young GC。
标记出 Eden 和 Survivor 区域中的存活对象,
根据配置的最大暂停时间选择某些区域将存活对象复制到一个新的
Survivor
区中(年龄+1),清空这些区域。
G1在进行Young GC的过程中会去记录每次垃圾回收时每个Eden区和Survivor区的平均耗时,以作为下次回收时的参考依据。这样就可以根据配置的最大暂停时间计算出本次回收时最多能回收多少个Region区域了。
- 比如 -XX:MaxGCPauseMillis=n(默认200),每个Region回收耗时40ms,那么这次回收最多只能回收4个Region。
后续Young GC时与之前相同,只不过Survivor区中存活对象会被搬运到另一个Survivor区。
当某个存活对象的年龄到达阈值(默认15),将被放入老年代。
部分对象如果大小超过 Region 的一半,会直接放入老年代,这类老年代被称为
Humongous
区。比如堆内存是 4G,每个Region是2M,只要一个大对象超过了1M就被放入Humongous区,如果对象过大会横跨多个Region。多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值时 (
-XX:InitiatingHeapOccupancyPercent
默认45%)会触发混合回收MixedGC
。回收所有年轻代和部分老年代的对象以及大对象区。采用复制算法来完成。
3)年轻代回收(Young GC)
年轻代回收只扫描年轻代对象(Eden + Survivor),所以从GC Root到年轻代的对象或者年轻代对象引用了其他年轻代的对象都很容易扫描出来。这里就存在一个问题,年轻代回收只扫描年轻代对象(Eden + Survivor),如果有老年代中的对象引用了年轻代中的对象,我们又如何知道呢?
方案 | 说明 | 图示 | 问题 |
---|---|---|---|
方案一 | 从GC Root开始,扫描所有对象,如果年轻代对象在引用链上,就标记为存活。 | 不可行,需要遍历引用链上所有对象,效率太低。 | |
方案二 | 每个 Region 维护一个详细的表,记录该 Region 中哪个对象被哪个老年代引用了。在年轻代中被引用的对象,不进行回收。 | 如果对象太多这张表会占用很大的内存空间。存在错标的情况 | |
方案二的第1次优化 | 只记录Region 被哪些对象引用了。这种引用详情表称为记忆集 RememberedSet (简称RS 或 RSet ):是一种记录了从非收集区域对象引用收集区域对象的这些关系的数据结构。扫描时将记忆集中的对象也加入到GC Root中,就可以根据引用链判断哪些对象需要回收了。 |
如果区域中引用对象很多,还是占用很多内存。 | |
方案二的第2次优化 | 将所有区域中的内存按一定大小划分成很多个块,每个块进行编号。记忆集中只记录对块的引用关系。如果一个块中有多个对象,只需要引用一次,减少了内存开销。 |
卡表(Card Table)
每一个 Region
都拥有一个自己的卡表,如果产生了跨代引用(老年代引用年轻代),此时这个 Region
对应的卡表上就会将字节内容进行修改,JDK8源码中 0
代表被引用了称为脏卡。这样就可以标记出当前 Region 被老年代中的哪些部分引用了。那么要生成记忆集就比较简单了,只需要遍历整个卡表,找到所有脏卡。
如图:一个 Region 被分成多个卡页(Card Page, 512字节),假设 Region2 是 Old 区,其中编号为20的卡页中A对象引用了 Region1(Eden区) 中的 F 对象,那么 Region1 中的卡表对应的字节内容修改为 0
年轻代回收标记时,会将记忆集中的对象也加入到GC Root对象中,进行扫描并标记其引用链上的对象。
卡表会占用一定的内存空间,堆大小是1G时,卡表大小为 1G = 1024 MB / 512 = 2MB
写屏障
JVM使用写屏障(Write Barrier)技术,在执行引用关系建立的代码时,可以在代码前和代码后插入一段指令, 从而维护卡表。
记忆集中不会记录新生代到新生代的引用,同一个Region中的引用也不会记录。
写屏障会损失一部分的性能,大约在5%~10%之间。
记忆集生成流程
- 通过写屏障获得引用变更的信息。
- 将引用关系记录到卡表中,并记录到一个脏卡队列中。
- JVM中会由 Refinement 线程定期从脏卡队列中获取数据,生成记忆集。不直接写入记忆集的原因是避免过多线程并发访问记忆集。
详细的Young GC流程
过程是STW的:
- Root扫描,将所有的静态变量、局部变量扫描出来。
- 处理脏卡队列中的没有处理完的信息,更新记忆集的数据,此阶段完成后,记忆集中包含了所有老年代对当前 Region的引用关系。
- 标记存活对象。记忆集中的对象会加入到GC Root对象集合中,在GC Root引用链上的对象也会被标记为存活对象。
- 根据设定的最大停顿时间,选择本次收集的区域,称之为回收集合 Collection Set。
- 复制对象:将标记出来的对象复制到新的区中,将年龄加1,如果年龄到达15则晋升到老年代。老的区域内存直接清空。
- 处理软、弱、虚、终结器引用,以及JNI中的弱引用。
4)混合回收(Mixed GC)
多次回收之后,会出现很多Old老年代区,此时总堆占有率达到阈值(默认45%)时会触发混合回收MixedGC。混合回收会由年轻代回收之后或者大对象分配之后触发,混合回收会回收整个年轻代 + 部分老年代。老年代很多时候会有大量对象,要标记出所有存活对象耗时较长,所以整个标记过程要尽量能做到和用户线程并行执行。
混合回收的步骤:
- 初始标记,STW,采用三色标记法标记从GC Root可直达的对象。
- 并发标记,并发执行,对存活对象进行标记。
- 最终标记,STW,处理SATB相关的对象标记。
- 清理,STW,如果区域中没有任何存活对象就直接清理。
- 转移,将存活对象复制到别的区域。
初始标记
初始标记会暂停所有用户线程,只标记从GC Root可直达的对象,所以停顿时间不会太长。采用三色标记法进行标记,三色标记法在原有双色标记(黑也就是1代表存活,白0代表可回收)增加了一种灰色,采用队列的方式保存标记为灰色的对象。
- 黑色:存活,当前对象在GC Root引用链上,同时他引用的其他对象也都已经标记完成。
- 灰色:待处理,当前对象在GC Root引用链上,他引用的其他对象还未标记完成。
- 白色:可回收,不在GC Root引用链上。
三色标记中的黑色和白色是使用位图(bitmap)来实现的,比如8个字节使用1个bit来标识标记的内容,黑色为1,白色为0,灰色不会体现在位图中,会单独放入一个队列中。如果对象超过8个字节,仅仅使用第一个bit位处理。
并发标记
接下来进入并发标记阶段,继续进行未完成的标记任务。此阶段和用户线程并发执行。
从灰色队列中获取尚未完成标记的对象B。标记B关联的A和C对象,由于A对象并未引用其他对象,可以直接标记成黑色,而B也完成了所有引用对象的标记,也标记为黑色。C对象有引用对象E,所以先标记成灰色。
最后从队列获取C对象,标记为黑色,E也标记为黑色。所以剩余对象F就是白色,可回收。
三色标记法存在的问题
三色标记存在一个比较严重的问题,由于用户线程可能同时在修改对象的引用关系,就会出现错标的情况,比如:下面这个案例中正常情况下,B和C都会被标记成黑色。
但是在BC标记前,用户线程执行了 B.c = null;
将B到C的引用去除了。同时执行了A.c = c;
添加了A到C的引用。
此时会出现严重问题,C是白色可回收一旦回收代码中再去使用对象会造成重大问题
SATB
G1为了解决这个问题,使用了 SATB 技术(Snapshot At The Beginning, 初始快照)。SATB 技术是这样处理的:
- 标记开始时创建一个快照,记录当前所有对象,标记过程中新生成的对象直接标记为黑色。
- 采用前置写屏障技术,在引用赋值前比如
B.c = null
之前,将之前引用的对象c放入SATB待处理队列中。SATB队列每个线程都有一个,最终标记会把他们都汇总到一个大的SATB队列中。 - 后续最终标记阶段处理 SATB
最终标记
最终标记会暂停所有用户线程,主要是为了处理SATB相关的对象标记。这一步中,将所有线程的SATB队列中剩余的数据合并到总的SATB队列中,然后逐一处理。
SATB队列中的对象,默认按照存活处理,同时要处理他们引用的对象。
SATB的缺点是在本轮清理时可能会将不存活的对象标记成存活对象,产生了一些所谓的浮动垃圾,等到下一轮清理时才能回收。
转移
根据最终标记的结果,可以计算出每一个区域的垃圾对象占用内存大小,根据停顿时间,选择转移效率最高(垃圾对象最多)的几个区域。
转移时先转移GC Root直接引用的对象,然后再转移其他对象。
回收老的区域,如果外部有其他区域对象引用了转移对象,也需要重新设置引用关系。