04-JVM(42)

1. TLAB

一般而言生成对象需要向堆中的新生代申请内存空间,而堆又是全局共享的,像新生代内存又是规整的,是通过一个指针来划分的

  • 内存是紧凑的,新对象创建指针就右移对象大小 size 即可,这叫指针加法(bump [up] the pointer)
  • 如果多个线程都在分配对象,那么这个指针就会成为热点资源,需要互斥那分配的效率就低了
  • 为了避免对象分配时的竞争而设计的 => TLAB
d2ac8f17371547a18d59259baee48386.png

TLAB(Thread Local Allocation Buffer),为线程分配内存申请区域

  • 这个区域只允许这一个线程申请分配对象,允许所有线程访问这块内存区域
  • TLAB 的思想其实很简单,就是划一块区域给一个线程,这样每个线程只需要在自己的那亩地申请对象内存,不需要争抢热点指针
  • 当这块内存用完了之后再去申请即可
  • 不过每次申请的大小不固定,会根据该线程启动到现在的历史信息来调整。eg:这个线程一直在分配内存那么 TLAB 就大一些,如果这个线程基本上不会申请分配内存那 TLAB 就小一些
d2ac8f17371547a18d59259baee48386.png
  • 可以看到每个线程有自己的一块内存分配区域,短一点的箭头代表 TLAB 内部的分配指针
  • 还有 TLAB 会浪费空间。如图
    • 可以看到 TLAB 内部只剩一格大小,申请的对象需要两格,这时候需要再申请一块 TLAB ,之前的那一格就浪费了。在 HotSpot 中会生成一个填充对象来填满这一块
d2ac8f17371547a18d59259baee48386.png
  • 还有 TLAB 只能分配小对象,大的对象还是需要在共享的 eden 区分配

2. concurrent mode failure原因

《深入理解JavaJVM》:由于CMS收集器无法处理“浮动垃圾”(FloatingGarbage),有可能出现“Con-current Mode Failure”失败进而导致另一次完全“Stop The World”的Full GC的产生。

  • 这段话的意思是因为抛这个错而导致一次 Full GC
  • 而实际上是 Full GC 导致抛这个错,看一下源码,版本是 openjdk-8
d2ac8f17371547a18d59259baee48386.png

full gc 的时候 cms gc 还在进行中导致抛这个错

  • 究其原因是因为分配速率太快导致堆不够用,回收不过来因此产生 full gc
  • 也有可能是发起 cms gc 设置的堆的阈值太高

3. 怎么实现跨平台的

所谓的跨平台主要指的是在不同的硬件或操作系统上,Java 代码都可以运行,不需要针对不同平台做对应的修改

  • Java 代码会被编译成 .class 文件。但是机器最终只认识 0101 二进制指令
  • JVM 做了一位很好的翻译官,把 .class 转换成对应硬件和OS认可的二级制,替我们负重前行

4. 编译、解释执行区别

  • 正常情况下 JVM 是解释执行
  • 但是如果 JVM 发现这段逻辑执行特别频繁,是热点代码,那么就会把它就会通过 JIT(JUST IN TIME)即时编译,将其直接编译成机器码,这样就是编译执行了

5. JVM内存区域划分

  1. 程序计数器
    • 作为当前线程执行字节码的行号指示器
  2. JVM栈
    • 每个线程执行时在JVM栈中都会有自己的栈帧,存储局部变量、方法出口、操作数栈等信息,在方法调用栈帧入栈,方法返回,栈帧出栈
  3. 本地方法栈
    • 与JVM栈类似,它是用于本地方法的调用,即 native 方法
    • 堆主要存放的就是平时 new 的对象实例和数组,按垃圾回收划分,堆可以分为新生代、老年代、永久代(Java 8 后被元空间取代,不在堆内了)
  4. 方法区
    • 方法区主要存储类结构、常量、静态变量、即时编译(JIT)后的代码等信息。Java 8 后存在元空间(元空间可以认为是方法区的一个实现),存储在堆外内存中

6. 堆、栈区别

  • 栈:主要用于存储局部变量、操作数栈、方法返回地址等数据,它空间的分配和回收是自动的,每个线程都拥有自己栈空间,线程之间不共享
  • 堆:存储 new 的 Java 对象实例与数组,堆的内存是通过手动触发器 gc 或者垃圾回收器自动回收,堆是线程共享的

7. 直接内存是什么

  • 启动 JVM 时都会设置堆的大小,而直接内存占用的是堆外的内存,它不属于堆
  • 理论上,在 Java 中想要操作堆外的内存,需要将其拷贝到堆内,因此 Java 弄了个 Direct Memory,它允许 Java 访问原生内存(非堆内内存),这样就减少了堆外到堆内的这次拷贝,提升 I/O 效率,在文件读写和网络传输场景直接内存有很大的优势
  • 不过堆外内存不归 JVM 设置的堆大小限制(在 JVM 中 -XX:MaxDirectMemorySize 设置直接内存的最大值),且不受垃圾回收器管理,因此在使用上需要注意直接内存的释放,防止内存泄漏

在 Java 中利用 Unsafe 类和 NIO 类库使用直接内存

import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;

public class DirectMemoryExample {
    public static void main(String[] args) {
        // 分配直接内存
        ByteBuffer directBuffer = ByteBuffer.allocateDirect(1024);

        // 写入数据
        directBuffer.put("Hello, 面试鸭 Direct Memory!".getBytes());

        // 切换为读模式
        directBuffer.flip();

        // 读取数据
        byte[] bytes = new byte[directBuffer.remaining()];
        directBuffer.get(bytes);

        // 打印结果
        String retrievedData = new String(bytes, StandardCharsets.UTF_8);
        System.out.println(retrievedData);

        // 手动释放直接内存
        ((sun.nio.ch.DirectBuffer)directBuffer).cleaner().clean();
    }
}






 
















 


  • 注意:最后一行释放内存的 cleaner 。因为垃圾回收器无法直接管理堆外内存,所以 JVM 在创建 ByteBuffer 时,在堆内存储了这个对象的指针,然后注册了一个关联的 cleaner(清理器)
  • 源码里面绑定了一个 cleaner
企业微信截图_48f9c9da-1927-4c6f-a96a-889bb7dcee91.png
  • cleaner 是个虚引用
    • JVM 检测到没有对象关联 ByteBuffer,说明这个堆外内存已经成为了垃圾,此时 ByteBuffer 会被回收,然后 cleaner 会被加入到引用队列中,之后会就会被触发其 clean 接口,然后清理堆外内存
企业微信截图_b44d9298-401e-468f-9dd2-8d6ca9f90c62.png

8. Java常量池

常量池其实就是方法区的一部分,全称应该是运行时常量池(runtime constant pool),主要用于存储字面量和符号引用等编译期产生的一些常量数据

  • 字符串、整数、浮点数都是字面量,源代码中一个固定的值的都叫字面量
    • 代码写了一个 String s = 'aa'; 那么 aa 就是字面量,存储在常量池当中
  • 符号引用指的是字段的名称、接口全限定名等等,这些都算符号引用
  • 优点:减少内存的消耗,同样的字符串,常量池仅需存储一份
  • 常量池在类加载后就已经准备好了,提升运行时的效率

JDK7时,HotSpot 将字符串从运行时常量池(方法区内)中剥离出来,转为字符串常量池存储在堆内,因为字符串对象也经常需要被回收,放置到堆中好管理回收

  • 按照JVM定义而言,字符串常量池还是属于运行时常量池,只不过 HotSpot 的实现将其放在里堆中而已,逻辑上它还是属于运行时常量池
企业微信截图_fd863e4d-646b-4b69-8c91-ead9144b9f07.png

9. 类加载器

代码保存在.java文件里,经过编译会生成.class文件,这个文件存储的就是字节码,必须把它加载到 JVM 中

img

JDK8 一共有三种类加载器:

  1. 启动类加载器(Bootstrap ClassLoader):属于JVM自身的一部分,用 C++ 实现的(JDK9 后用 java 实现),主要负责加载 <JAVA_HOME>\lib 目录中或被 -Xbootclasspath 指定的路径中的并且文件名是被JVM识别的文件,它是所有类加载器的父亲
  2. 扩展类加载器(Extension ClassLoader):是 Java 实现的,独立于JVM,主要负责加载 <JAVA_HOME>\lib\ext 目录中或被 java.ext.dirs 系统变量所指定的路径的类库
  3. 应用程序类加载器(Application ClassLoader):是 Java 实现的,独立于JVM。主要负责加载用户类路径(classPath)上的类库,如果没有实现自定义的类加载器那这个加载器就是程序中的默认加载器
img

JDK9 之后,类加载器进行了一些修改,主要是因为 JDK9 引入了模块化,即 Jigsaw,原来的 rt.jartool.jar 等都被拆成了数十个 jmod 文件,已满足可扩展需求,无需保留 <JAVA_HOME>\lib\ext ,所以扩展类加载器也被重命名为平台类加载器(PlatformClassLoader),主要加载被 module-info.java 中定义的类

  • 双亲委派的路径也做了一定的变化:
企业微信截图_356498bd-8c60-4b0e-b599-999b047d4376.png
  • 在平台和应用类加载器受到加载请求时,会先判断该类是否属于一个系统模块,如果属于则委派给对应的模块类加载器加载,反之才委派给父类加载器
  • JDK9 之后类加载器负责模块
企业微信截图_c43e2b11-4eb9-4a0f-b07b-4326341a1b6e.png
企业微信截图_a2c32e43-ee8a-42cc-a07d-52ccc862fc85.png
企业微信截图_eab6eed5-fc23-462b-9e0d-6dd3c2131ec7.png

10. 双亲委派

子类加载器先让父类加载器去查找该类来加载,在父类加载器没有找到所请求的类的情况下,子类加载器才会尝试去加载,一层一层上去又下来

img

目的:为了让基础类得以正确地统一地加载

  • 如果自定义了一个 java.lang.Object 类,双亲委派模式是会把这个请求委托给启动类加载器,它扫描 <JAVA_HOME>\lib 目录就找到了 jdk 定义的 java.lang.Object 类来加载,压根不会加载自定义的 java.lang.Object 类,避免一些程序不小心或者有意的覆盖基础类

JDK9 因为引入了模块化,即 Jigsaw,导致类加载路径产生了变化

  • 在平台和应用类加载器收到加载请求时,会先判断该类是否属于一个系统模块,如果属于则委派给对应的模块类加载器加载,反之才委派给父类加载器
企业微信截图_356498bd-8c60-4b0e-b599-999b047d4376.png

11. JIT

  • Java 默认是解释执行,解释执行的效率确实比不上编译执行
  • 因此 Java 就搞了个 JIT(Just-In-Time)编译器,它在 Java 程序运行时,发现热点代码,将字节码转为机器码,因为这种转换是在程序运行时即时进行的,因此得名“Just-In-Time”

12. AOT

  • AOT(Ahead-Of-Time)它和 JIT(Just-In-Time)编译相对
  • JIT 是在 Java 运行时,将一些代码编译成机器码,而 AOT 则是在代码运行之前就编译成机器码,也就是提前编译
  • 好处:减少运行时编译的开销,且减少程序启动所需的编译时间,提高启动速度

13. 逃逸分析

是一种优化,它用于确定对象是否可以被限定在某个方法或线程中而不会被外部或其他线程访问,用来优化对象的分配

  • 正常情况下,分配对象都是在堆上,而堆需要内存管理,即垃圾回收
  • 假设,对象不可能会被外部访问,只会在当前的线程内被访问,那么是不是可以分配在栈上?
    • 分配在栈上的变量会随着方法的结束而自动销毁,这样就可以减少 GC 工作

作用:

  1. 栈上分配
  2. 同步消除:消除该对象的同步锁,减少锁带来的性能开销
  3. 标量替换:将对象拆解为基本类型,直接在局部变量中维护

JVM 默认会开启这项优化,逃逸分析是 JIT 编译的一部分,在 JIT 编译时就会进行这项优化

14. 指令重排

背景:因为内存访问的速度比 CPU 运行速度慢很多,因此需要编排一下执行的顺序,防止因为访问内存的比较慢的指令而使得 CPU 闲置着

  • 为了提高效率就会进行指令重排,导致指令乱序执行的情况发生,不过会保证单线程最终一致性(as-if-serial)
  • 不过多线程就无法保证了,在 Java 中的 volatile 关键字可以禁止修饰变量前后的指令重排

15. 强、软、弱、虚引用

根据生命周期的长短:

  • 强引用:平时 new 一个对象的引用。当 JVM 的内存空间不足时,宁愿抛出 OutOfMemoryError 使得程序异常终止,也不愿意回收具有强引用的存活着的对象
  • 软引用:生命周期比强引用短,当 JVM 认为内存空间不足时,会试图回收软引用指向的对象,也就是说在 JVM 抛出 OutOfMemoryError 之前,会去清理软引用对象,适合用在内存敏感的场景
  • 弱引用:比软引用还短,GC 时,不管内存空间足不足都会回收这个对象,ThreadLocal 中的 key 就用到了弱引用,适合用在内存敏感的场景
  • 虚引用:也称幻象引用,get() 永远都是 null
    • 唯一作用就是配合引用队列来监控引用的对象是否被加入到引用队列中,也就是可以准确的让我们知晓对象何时被回收

1.8 doc

d2ac8f17371547a18d59259baee48386.png

11 doc

d2ac8f17371547a18d59259baee48386.png

所以 JDK9 之前虚引用的引用对象无法被 GC,9 及之后的版本没影响

16. 常见垃圾收集器

1. Serial

Serial 收集器是最基础、历史最悠久的收集器,它是一个单线程收集器

  • 缺点:GC时,必须暂停其他所有的工作线程,直到收集结束
  • 优点:单线程避免了多线程复杂的上下文切换,因此在单线程环境下收集效率非常高
    • 由于这个优点,迄今为止,其仍然是 HotSpot JVM在客户端模式下默认的新生代收集器
Snipaste_2024-05-02_21-04-13.jpg

2. ParNew

Serial 收集器的多线程版本,可以使用多条线程进行GC

Snipaste_2024-05-02_21-05-06.jpg

3. Parallel Scavenge

Parallel Scavenge 也是新生代收集器,基于《标记-复制》算法进行实现。目标:达到一个可控的吞吐量

吞吐量 = 运行用户代码时间 \ (运行用户代码时间 + 运行垃圾收集时间)

Parallel Scavenge 提供两个参数用于精确控制吞吐量:

  1. -XX:MaxGCPauseMillis:控制最大垃圾收集时间,假设需要回收的垃圾总量不变,那么降低垃圾收集的时间就会导致收集频率变高,所以需要将其设置为合适的值,不能一味减小
  2. -XX:MaxGCTimeRatio:直接用于设置吞吐量大小,它是一个大于 0 小于 100 的整数。默认值为 99 ,表示此时允许的最大垃圾收集时间占总时间的 1%(即 1/(1+99)

4. Serial Old

Serial 收集器的老年代版本,同样是一个单线程收集器,采用《标记-整理》算法,主要给客户端模式下的 HotSpot 使用

Snipaste_2024-05-02_21-06-38.jpg

5. Paralled Old

Paralled Old 是 Parallel Scavenge 收集器的老年代版本,支持多线程并发收集,采用《标记-整理》算法

Snipaste_2024-05-02_21-08-38.jpg

6. CMS

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,基于《标记-清除》算法实现。分为四个阶段:

  1. 初始标记(inital mark) :标记 GC Roots 能直接关联到的对象,耗时短但需要 STW
  2. 并发标记(concurrent mark) :从 GC Roots 能直接关联到的对象开始遍历整个对象图,耗时长但不需要 STW
  3. 重新标记(remark):采用增量更新算法,对并发标记阶段因为用户线程运行而产生变动的那部分对象进行重新标记,耗时比初始标记稍长且需要 STW
  4. 并发清除(inital sweep) :并发清除掉已经死亡的对象,耗时长但不需要 STW
Snipaste_2024-05-02_21-10-28.jpg

优点:

  • 耗时长的《并发标记》《并发清除》都不需要STW,因此其停顿时间较短

缺点:

  • 由于涉及并发操作,因此对处理器资源比较敏感
  • 由于是基于 标记-清除 算法实现的,因此会产生大量空间碎片
  • 无法处理浮动垃圾(Floating Garbage):由于并发清除时用户线程还是在继续,所以此时仍然会产生垃圾,这些垃圾就被称为浮动垃圾,只能等到下一次垃圾收集时再进行清理

7. G1

G1(Garbage-Frist)收集器是一种面向服务器的垃圾收集器,主要应用在多核 CPU 和 大内存的服务器环境中

  • G1 虽然也遵循分代收集理论,但不再以固定大小和固定数量来划分分代区域,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region)。每一个 Region 都可以根据不同的需求来扮演新生代的 Eden 空间、Survivor 空间或老年代空间,收集器会根据其扮演角色的不同而采用不同的收集策略
  • Region 使用 H 进行标注,它代表 Humongous,表示这些 Region 用于存储大对象(humongous object,H-obj),即大小大于等于 region 一半的对象
zongjie-e0f5da26-6e46-4f9d-bfcc-0842cc7079e7.png

GC 步骤:

  1. 初始标记(Inital Marking):标记 GC Roots 能直接关联到的对象,并且修改 TAMS(Top at Mark Start)指针的值,让下一阶段用户线程并发运行时,能够正确的在 Reigin 中分配新对象
    • G1 为每一个 Reigin 都设计了两个名为 TAMS 的指针,新分配的对象必须位于这两个指针位置以上,位于这两个指针位置以上的对象默认被隐式标记为存活的,不会纳入回收范围
  2. 并发标记(Concurrent Marking):从 GC Roots 能直接关联到的对象开始遍历整个对象图。遍历完成后,还需要处理 SATB 记录中变动的对象
    • SATB(snapshot-at-the-beginning,开始阶段快照)能够有效的解决并发标记阶段因为用户线程运行而导致的对象变动,其效率比 CMS 重新标记阶段所使用的增量更新算法效率更高
  3. 最终标记(Final Marking):STW,用于处理并发阶段结束后仍遗留下来的少量的 STAB 记录。虽然并发标记阶段会处理 SATB 记录,但由于处理时用户线程依然是运行中的,因此依然会有少量的变动,所以需要最终标记来处理
  4. 筛选回收(Live Data Counting and Evacuation):负责更新 Regin 统计数据,按照各个 Regin 的回收价值和成本进行排序,在根据用户期望的停顿时间进行来指定回收计划,可以选择任意多个 Regin 构成回收集
    • 然后将回收集中 Regin 的存活对象复制到空的 Regin 中,再清理掉整个旧的 Regin 。此时因为涉及到存活对象的移动,所以需要暂停用户线程,并由多个收集线程并行执行
Snipaste_2024-05-02_21-14-27.jpg

17. 判断对象是否是垃圾

1. 引用计数

引用计数其实就是为每一个内存单元设置一个计数器,当被引用的时候计数器加一,当计数器为 0 时,即为垃圾

  • 需要占据额外的存储空间
  • 循环引用问题
Snipaste_2024-05-03_15-41-59.jpg

2. 可达性分析

可达性分析其实就是利用《标记-清除》(mark-sweep),就是标记可达对象,清除不可达对象

  • 所谓的根引用包括:全局变量、栈上引用、寄存器上的等
Snipaste_2024-05-03_15-48-13.jpg

18. 为什么分老年代、新生代

分区是为了更高效地管理不同生命周期的对象。大部分对象朝生夕死,而少部分一直存在堆中,所以按照存活时间分区管理更加高效

  • 因为不同分区的生命周期不同,新生代的对象“死亡率”比较高,《标记清除》整理比较合适(大部分对象都消失了,容易整理),而老年代的对象存活时间比较长,因此《标记清除》即可(存活对象比较多,整理的话耗时比较长)
  • 且分区后可以减少 GC 暂停的时间

19. Java8移除永久代?

img

官方:因为 JRockit 没有永久代,而 JRockit 要和 Hotspot 融合,所以把 Hotspot 永久代给去了

  • 其实永久代之前的存在就有点尴尬,归堆管但是实际上回收效率很低
  • 如果永久代满了也会触发 full gc,触发了回收但是回收率又很低,所以很不划算
  • 因此官方借着和 JRockit 合并就把永久代也干掉,用元空间代替。元空间放在堆外,至少没堆内内存限制

20. 新生代分s1、s2、eden?

  • 新生代适合复制算法。如果内存一分为二的话,空间利用率只有一半了(每次分配对象只能占据一半的内存大小),太不划算
  • 基于这点,定义 Eden 区和两个 Survivor 区,Survivor 区可以比二分之一大,提升利用率,然后利用两个 Survivor 来交替接收 gc 后存活的对象
  • 并且程序可以根据自身的特性调整 Eden 区和 Survivor 区的比例

21. 垃圾回收算法?

常见的就是:复制、标记-清除、标记整理。

1. 标记-清除

缺点:空间碎片问题

2. 复制算法

复制算法就是把内存空间一分为二,然后将一边存活的对象复制到另一边,没有空间碎片问题

  • 复制算法一般用于新生代(大部分对象都是短命鬼),但是内存利用率太低了,只有 50%,所以 HotSpot 中是把一块空间分为 3 块,一块 Eden,两块 Survivor。默认的空间划分比例是 8:1:1
  • 每次只使用 Eden 和一块 Survivor,然后把活下来的对象都扔到另一块 Survivor。再清理 Eden 和之前的那块 Survivor
  • 最后再把 Eden 和存放存活对象的那一块 Survivor 用来迎接新的对象,等于每次回收了之后都会对调一下两个 Survivor

3. 标记-整理算法

先标记那些需要清除的对象,会移动所有存活的对象,且按照内存地址次序依次排列,也就是把活着的对象都像一端移动,然后将末端内存地址以后的内存全部回收。所以没有空间碎片

22. 三色标记

主要来区分 GC 对象被扫描过的情况:

  • 白色:表示还未搜索到的对象
  • 灰色:表示正在搜索还未搜索完的对象
  • 黑色:表示搜索完成的对象

三色的转换:

  • GC 开始前所有对象都是白色
  • GC 一开始所有根能够直达的对象被压到栈中,待搜索,此时颜色是灰色
  • 然后灰色对象依次从栈中取出搜索子对象,子对象也会被涂为灰色,入栈。当其所有的子对象都涂为灰色之后该对象被涂为黑色
  • 当 GC 结束之后灰色对象将全部没了,剩下黑色的为存活对象,白色的为垃圾

23. young,old,full,mixed gc区别

其实 GC 分为两大类,分别是 Partial GC 和 Full GC

Partial GC 即部分收集,分为 young gc、old gc、mixed gc

  • young gc:只收集年轻代的 GC
  • old gc:只收集老年代的 GC
  • mixed gc:G1 收集器特有的,指收集整个年轻代和部分老年代的 GC

Full GC 即整堆回收,指的是收取整个堆,包括年轻代、老年代,如果有永久代的话还包括永久代

  • Major:在《深入理解Java JVM》中这个名词指代的是只老年代的 GC,和 old gc 等价的,不过也有很多资料认为其是和 full gc 等价的
  • Minor GC:其指的就是年轻代的 gc

24. young gc触发条件

大致上可以认为在年轻代的 eden 快要被占满的时候会触发 young gc

  • 一个是为对象分配内存不够,一个是为 TLAB 分配内存不够
  • 为什么要说大致上呢?
    • 因为有一些收集器的回收实现是在 full gc 前会让先执行以下 young gc。比如 Parallel Scavenge,不过有参数可以调整让其不进行 young gc
    • 可能还有别的实现也有这种操作,不过正常情况下就当做 eden 区快满了即可

25. full gc触发条件

  • 在要进行 young gc 时,根据之前统计数据发现年轻代平均晋升大小比现在老年代剩余空间要大,那就会触发 full gc
  • 有永久代的话如果永久代满了也会触发 full gc
  • 老年代空间不足,大对象直接在老年代申请分配,如果此时老年代空间不足则会触发 full gc
  • 担保失败即 promotion failure
    • 新生代的 to 区放不下从 eden 和 from 拷贝过来对象
    • 或新生代对象 gc 年龄到达阈值需要晋升,老年代放不下的话都会触发 full gc
  • 执行 System.gc()jmap -dump 等命令会触发 full gc

26. PLAB

(Promotion Local Allocation Buffers)。和 TLAB 很像,每个线程先申请一块作为 PLAB ,然后在这一块内存里面分配晋升的对象

  • 在多线程并行执行 YGC 时,可能有很多对象需要晋升到老年代,此时老年代的指针就“热”起来了
  • 先从老年代 freelist(空闲链表) 申请一块空间,然后在这一块空间中就可以通过指针加法(bump the pointer)来分配内存,这样对 freelist 竞争也少了,分配空间也快了
d2ac8f17371547a18d59259baee48386.png

27. CMS GC failure

CMS GC 发生 concurrent mode failure 时的 full GC 为什么是单线程的?

因为没足够开发资源,偷懒了。就这么简单。没有任何技术上的问题。 大公司都自己内部做了优化

所以最初怎么会偷这个懒的呢?多灾多难的CMS GC经历了多次动荡

  • 它最初是作为 Sun Labs 的 Exact VM 的低延迟GC而设计实现的
  • 但 Exact VM 在与 HotSpot VM 争抢 Sun 的正牌 JVM 的内部斗争中失利,CMS GC 后来就作为 Exact VM 的技术遗产被移植到了 HotSpot VM上
  • 就在这个移植还在进行中的时候,Sun 已经开始略显疲态;到 CMS GC 完全移植到 HotSpot VM 的时候,Sun 已经处于快要不行的阶段了
  • 开发资源减少,开发人员流失,当时的 HotSpot VM 开发组能够做的事情并不多,只能挑重要的来做。而这个时候 Sun Labs 的另一个 GC 实现,Garbage-First GC(G1 GC)已经面世
  • 相比可能在长时间运行后受碎片化影响的 CMS,G1 会增量式的整理/压缩堆里的数据,避免受碎片化影响,因而被认为更具潜力
  • 于是当时本来就不多的开发资源,一部分还投给了把G1 GC产品化的项目上——结果也是进展缓慢
  • 毕竟只有一两个人在做。所以当时就没能有足够开发资源去打磨 CMS GC 的各种配套设施的细节,配套的备份 full GC 的并行化也就耽搁了下来

HotSpot VM 不是已经有并行GC了么?而且还有好几个?

  • ParNew:并行的 young gen GC,不负责收集 old gen
  • Parallel GC(Parallel Scavenge):并行的 young gen GC,与ParNew相似但不兼容;同样不负责收集old gen
  • ParallelOld GC(PSCompact):并行的 full GC,但与 ParNew / CMS 不兼容

HotSpot VM 确实是已经有并行 GC 了,但两个是只负责在 young GC 时收集 young gen 的,这俩之中还只有 ParNew 能跟 CMS 搭配使用

而并行 full GC 虽然有一个 ParallelOld,但却与 CMS GC 不兼容所以无法作为它的备份 full GC 使用

28. ParNew、PO不能组合

为什么有些新老年代的收集器不能组合使用比如 ParNew 和 Parallel Old?

d2ac8f17371547a18d59259baee48386.png

这张图是 2008 年 HostSpot 一位 GC 组成员画的,那时候 G1 还没问世,在研发中,所以画了个问号在上面。里面的回答是 :

"ParNew" is written in a style... "Parallel Old" is not written in the "ParNew" style

HotSpot VM 自身的分代收集器实现有一套框架,只有在框架内的实现才能互相搭配使用

  • 而有个开发他不想按照这个框架实现,自己写了个,测试的成绩还不错后来被 HotSpot VM 给吸收了,这就导致了不兼容

29. YGC如何避免全堆扫描

在常见的分代 GC 中就是利用记忆集来实现,记录可能存在的老年代中有新生代的引用的对象地址,来避免全堆扫描

d2ac8f17371547a18d59259baee48386.png

上图有个对象精度的,一个是卡精度的,卡精度的叫卡表

  • 把堆中分为很多块,每块 512 字节(卡页),用字节数组中的一个元素来表示某一块,1表示脏块,里面存在跨代引用
d2ac8f17371547a18d59259baee48386.png
  • 在 Hotspot 中的实现是卡表,是通过写后屏障维护的,伪代码如下
d2ac8f17371547a18d59259baee48386.png

CMS 中需要记录老年代指向年轻代的引用,但是写屏障的实现并没有做任何条件的过滤

  • 不判断当前对象是老年代对象且引用的是新生代对象才会标记对应的卡表为脏
  • 只要是引用赋值都会把对象的卡标记为脏,当然YGC扫描的时候只会扫老年代的卡表
  • 这样做是减少写屏障带来的消耗,毕竟引用的赋值非常的频繁

30. CMS、G1记忆集的不同

CMS 的记忆集的实现是卡表即 card table

  • 通常实现的记忆集是 points-out 的,我们知道记忆集是用来记录非收集区域指向收集区域的跨代引用,它的主语其实是非收集区域,所以是 points-out 的
  • 在 CMS 中只有老年代指向年轻代的卡表,用于年轻代 gc

G1 是基于 region 的,所以在 points-out 的卡表之上还加了个 points-into 的结构

  • 因为一个 region 需要知道**有哪些别的 region 有指向自己的指针,然后还需要知道这些指针在哪些 card 中 **
  • 其实 G1 的记忆集就是个 hash table,key 就是别的 region 的起始地址,然后 value 是一个集合,里面存储这 card table 的 index
d2ac8f17371547a18d59259baee48386.png

像每次引用字段的赋值都需要维护记忆集开销很大,所以 G1 的实现利用了 logging write barrier

  • 也是异步思想,会先将修改记录到队列中,当队列超过一定阈值由后台线程取出遍历来更新记忆集

31. G1不维护年轻到老年代记忆集

G1 分了 young gcmixed gc

  • young gc:会选取所有年轻代的 region 进行收集
  • midex gc:会选取所有年轻代的 region 和一些收集收益高的老年代 region 进行收集

年轻代的 region 都在收集范围内,所以不需要额外记录年轻代到老年代的跨代引用

32. CMS、G1维持并发的正确性

CMS、G1为了维持并发的正确性分别用了什么手段

并发执行漏标的两个充分必要条件是:

  1. 将新对象插入已扫描完毕的对象中,即插入黑色对象到白色对象的引用
  2. 删除了灰色到白色对象的引用

cms 和 g1 分别通过增量更新和 SATB 来打破这两个充分必要条件,维持了 GC 线程与应用线程并发的正确性

  • cms 用了增量更新(Incremental update),打破了第一个条件,通过写屏障将插入的白色对象标记成灰色,即加入到标记栈中,在 remark 阶段再扫描,防止漏标情况
  • G1 用了 SATB(snapshot-at-the-beginning),打破了第二个条件,会通过写屏障把旧的引用关系记下来,之后再把旧引用关系再扫描过。就是在 GC 开始时候如果对象是存活的就认为其存活,等于拍了个快照
  • 而且 gc 过程中新分配的对象也都认为是活的。每个 region 会维持 TAMS (top at mark start)指针,分别是 prevTAMS 和 nextTAMS 分别标记两次并发标记开始时候 Top 指针的位置
    • Top 指针就是 region 中最新分配对象的位置,所以 nextTAMS 和 Top 之间区域的对象都是新分配的对象都认为其是存活的即可
3c0ed3f6f1aa465992e4f9d2e6d7690e
  • 而利用增量更新的 cms 在 remark 阶段需要重新所有线程栈和整个年轻代,因为等于之前的根有新增,所以需要重新扫描过,如果年轻代的对象很多的话会比较耗时
    • 要注意这阶段是 STW 的,很关键,所以 CMS 也提供了一个 CMSScavengeBeforeRemark 参数,来强制 remark 阶段之前来一次 YGC
  • 而 g1 通过 SATB 的话在最终标记阶段只需要扫描 SATB 记录的旧引用即可,从这方面来说会比 cms 快,但是也因为这样浮动垃圾会比 cms 多

33. logging write barrier

写屏障其实耗的是应用程序的性能,是在引用赋值时执行的逻辑,这个操作非常的频繁,因此就搞了个 logging write barrier

  • 把写屏障要执行的一些逻辑搬运到后台线程执行,来减轻对应用程序的影响
  • 在写屏障里只需要记录一个 log 信息到一个队列中,然后别的后台线程会从队列中取出信息来完成后续的操作,其实就是异步思想

像 SATB write barrier ,每个 Java 线程有一个独立的、定长的 SATBMarkQueue,在写屏障里只把旧引用压入该队列中。满了之后会加到全局 SATBMarkQueueSet

d2ac8f17371547a18d59259baee48386
  • 后台线程会扫描,如果超过一定阈值就会处理,开始 tracing
  • 在维护记忆集的写屏障也用了 logging write barrier

34. G1回收流程

G1 从大局上看分为两大阶段,分别是并发标记和对象拷贝

并发标记是基于 STAB 的,四大阶段:

  1. 初始标记(initial marking):STW,扫描根集合,标记根直接可达的对象即可。在G1中标记对象是利用外部的bitmap来记录,而不是对象头
  2. 并发阶段(concurrent marking):这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象。 STAB 也会在这个阶段记录着变更的引用
  3. 最终标记(final marking):STW,处理 STAB 中的引用
  4. 清理阶段(clenaup):STW,根据标记的 bitmap 统计每个 region 存活对象的多少,如果有完全没存活的 region 则整体回收

对象拷贝阶段(evacuation),这个阶段是 STW 的

  • 根据标记结果选择合适的 reigon 组成收集集合(collection set 即 CSet),然后将 CSet 存活对象拷贝到新 region 中

G1 的瓶颈在于对象拷贝阶段,需要花较多的瓶颈来转移对象

35. CMS回收流程

  1. 初始标记(initial mark):STW ,扫描根集合,标记根直接可达的对象即可
  2. 并发标记(Concurrent marking):这个阶段和应用线程并发,从上一步标记的根直接可达对象开始进行 tracing,递归扫描所有可达对象
  3. 并发预清理(Concurrent precleaning):这个阶段和应用线程并发,就是想帮重新标记阶段先做点工作,扫描一下卡表脏的区域和新晋升到老年代的对象等
  4. 可中断的预清理阶段(AbortablePreclean):和上一个阶段基本上一致,分担重新标记标记的工作
  5. 重新标记(remark):STW ,因为并发阶段引用关系会发生变化,所以要重新遍历一遍新生代对象、Gc Roots、卡表等,来修正标记
  6. 并发清理(Concurrent sweeping):这个阶段和应用线程并发,用于清理垃圾
  7. 并发重置(Concurrent reset):这个阶段和应用线程并发,重置 cms 内部状态

cms 的瓶颈就在于重新标记阶段,需要较长花费时间来进行重新扫描

36. ZGC

ZGC 是 Java11 引入的新垃圾回收器

  • ZGC 就是通过多阶段的并发和几个短暂的 STW 阶段来达到低延迟的特性
  • 利用指针染色技术和读屏障实现并发转移对象,利用 STAB 保证并发阶段不会漏标对象
  • 对了 ZGC 还不分代,就是没分新生代和老年代
    • ZGC 的不分代其实是它的缺点,因为分代比较难实现(JDK 21 实现分代了)

现代垃圾收集器的演进就是往并发上面靠,目标就是减少停顿时间

  • 不过并发需要注意内存分配的速率,因为并发导致一次垃圾回收总的时间变长了
  • 如果内存分配过快那就回收不过来了,因此都需要预留内存空间或者说要更大的内存空间来应对快速的分配速率

1. 目标

3aaa78547d0d49dd810cfc7ebeda587d
  • 目标:低延迟,保证最大停顿时间在几毫秒之内,不管堆多大或者存活的对象有多少
  • 可以处理 8MB-16TB 的堆
  • 按 openjdk 的 wiki 来了解 zgc
    • 并发
    • 基于Region
    • 整理内存
    • 支持NUMA
    • 用了染色指针
    • 用了读屏障
275016df0c25427a9df2613468d64f76

ZGC 用的是 STAB

2. Concurrent

应用线程并发执行,ZGC 一共分了 10 个阶段,只有 3 个很短暂的阶段是 STW

f865c3d887804124803fc2ea4f83852b

只有初始标记、再标记、初始转移阶段是 STW

  • 初始标记:扫描 GC Roots 直接可达的,耗时很短
  • 再标记:一般而言也很短,如果超过 1ms 会再次进入并发标记阶段再来一遍,所以影响不大
  • 初始转移:扫描 GC Roots 也很短,所以可以认为 ZGC 几乎是并发的

  • 之所以说停顿时间不会随着堆的大小和存活对象的数量增加而增加,是因为 STW 几乎只和 GC Roots 集合大小有关,和堆大小没关系
  • 这其实就是 ZGC 超过 G1 很关键的一个地方, G1 的对象转移需要 STW 所以堆大需要转移对象多,停顿的时间就长了,而 ZGC 有并发转移

并发回收有个情况就是回收时应用线程还在产生新的对象,所以需要预留一些空间给并发时生成的新对象

  • 如果对象分配过快导致内存不够,在 CMS 中发生 Full gc,而 ZGC 则是阻塞应用线程

ZGC 触发的时间

  • ZGC 有自适应算法来触发也有固定时间触发,所以可以根据实际场景来修改 ZGC 触发时间,防止过晚触发而内存分配过快导致线程阻塞

参数设置,加快回收的速度

  • ParallelGCThreads:STW 并行时候的线程数
  • ConcGCThreads:并发阶段的线程数。因为此阶段是和应用线程并发,如果线程数过多会影响应用线程

3. Region-based

为了能更细粒度的控制内存的分配,和 G1 一样 ZGC 也将堆划分成很多分区

  • 分了三种:2MB32MBX*MB(受操作系统控制)

源码中的注释:

9969f4e97fd740c5a1d4979e85e12312

回收策略:优先收集小区,中、大区尽量不回收

4. Compacting

和 G1 一样都分区了,所以肯定从整体来看像是《标记-复制》算法,不会产生内存碎片

5. NUMA-aware

以前的 G1 是不支持的,不过在 JDK14 G1 也支持了

d8fc2e044c474f38b13cf7f39c3509f6
  • 在早期处理器都是单核的,因为根据摩尔定律,处理器的性能每隔一段时间就可以成指数型增长
  • 而近年来这个增长的速度逐渐变缓,于是很多厂商就推出了多核计算机
  • 早期 CPU 通过前端总线到北桥到内存总线,最后才访问到内存。这个架构被称为 SMP (Symmetric Multi-Processor),因为任一个 CPU 对内存的访问速度是一致的,不用考虑不同内存地址之间的差异,所以也称一致内存访问(Uniform Memory Access, UMA )
61081acb895c40058b3a8a0d45f57dbc
  • 核心越加越多,渐渐的总线和北桥就成为瓶颈,于是就把 CPU 和内存集成到一个单元上,这个就是非一致内存访问 (Non-Uniform Memory Access,NUMA)
1efefce5cc0a4baea61d34d1402caca5
  • 简单的说就是把内存分一分,每个 CPU 访问自己的本地内存比较快,访问别人的远程内存就比较慢
  • 也可以多个 CPU 享受一块内存或者多块,如图:
43cb36ca2fb740258932cbdf61b59af6
  • 但是因为内存被切分为本地内存和远程内存,当某个模块比较“热”的时候,就可能产生本地内存爆满,而远程内存都很空闲的情况
    • eg:64G 内存一分为二,模块一的内存用了31G,而另一个模块的内存用了5G,且模块一只能用本地内存,这就产生了内存不平衡问题
    • 如果有些策略规定不能访问远程内存时,就会出现明明还有很多内存却产生 SWAP(将部分内存置换到硬盘中)的情况
    • 即使允许访问远程内存那也比本地内存访问速率相差较大

ZGC 对 NUMA 的支持:小分区分配时会优先从本地内存分配,如果本地内存不足则从远程内存分配,对于中、大分区的话就交由操作系统决定

  • 原因:生成的绝大部分都是小分区对象,因此优先本地分配,速度较快,而且也不易造成内存不平衡的情况,而中、大分区对象较大,如果都从本地分配则可能会导致内存不平衡的情况

6. Using colored pointers

染色指针其实就是从 64 位的指针中,拿几位来标识对象此时的情况,分别表示 Marked0Marked1RemappedFinalizable

fe6fad22e4924cddbd730fa13f916fba

源码中的注释:

e6ff00f95dd44fabbdbfc7c008c08024
  • 0-41 这 42 位就是正常的地址,所以说 ZGC 最大支持 4TB(理论上可以16TB)的内存,因为就 42 位用来表示地址
  • 也因此 ZGC 不支持 32 位指针,也不支持指针压缩
  • 然后用 42-45 位来作为标志位,其实不管这个标志位是啥,指向的都是同一个对象

通过多重映射来做的,就是多个虚拟地址指向同一个物理地址,不管对象地址是 0001... 还是 0010... 对应的都是同一个物理地址即可

为什么就支持 4TB,不是还有很多位没用吗

  • 首先 X86_64 的地址总线只有 48 条 ,所以最多其实只能用 48 位,指令集是 64 位没错,但是硬件层面就支持 48 位
  • 因为基本上没有多少系统支持这么大的内存,那支持 64 位就没必要了,所以就支持到 48 位

7. Using load barriers

在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障

写屏障是在对象引用赋值时的 AOP,而读屏障是在读取引用时的 AOP

  • eg:Object a = obj.foo; 这个过程就会触发读屏障
  • 也正是用了读屏障,ZGC 可以并发转移对象,而 G1 用的是写屏障,所以转移对象时候只能 STW
  • 简单的说,就是 GC 线程转移对象之后,应用线程读取对象时,可以利用读屏障通过指针上的标志来判断对象是否被转移
  • 如果是的话修正对象的引用,上面的例子,不仅 a 能得到最新的引用地址,obj.foo 也会被更新,这样下次访问的时候一切都是正常的,就没有消耗了

下图展示了读屏障的效果,其实就是转移时找地方记一下即 forwardingTable,然后读的时候触发引用的修正

23b9ae1bb5924914b4e1068ab6b41f2a
  • 这种也称之为“自愈”,不仅赋值的引用时最新的,自身引用也修正了

染色指针和读屏障是 ZGC 能实现并发转移的关键所在

8. ZGC 回收流程

ZGC 三大阶段(都是并发):

  • 标记:从根开始标记所有存活对象
  • 转移:选择部分活跃对象转移到新的内存空间上
  • 重定位:因为对象地址变了,所以之前指向老对象的指针都要换到新对象地址上

这是意识上的阶段,具体的实现上重定位其实是糅合在标记阶段的

  • 在标记时,如果发现引用的还是老的地址则会修正成新的地址,然后再进行标记
  • 简单的说,就是从第一个 GC 开始经历了标记,然后转移了对象,这个时候不会重定位,只会记录对象都转移到哪里了
  • 在第二个 GC 开始标记时发现这个对象是被转移了,然后发现引用还是老的,则进行重定位,即修改成新的引用。所以说重定位是糅合在下一步的标记阶段中
02f3a7257d5842929eee7c186a3be3cf

不要深入 ZGC 实现的细节,而是了解 ZGC 大致的突出点和简单流程即可

1. 初始标记

CMS、G1 都有这个阶段,这个阶段是 STW 的,仅标记根直接可达的对象,压到标记栈中

  • 还有其他动作,eg:重置 TLAB、判断是否要清除软引用等

2. 并发标记

根据初始标记的对象开始并发遍历对象图,还会统计每个 region 的存活对象的数量

  • 并发标记其实有个细节,标记栈其实只有一个,但是并发标记的线程有多个
  • 为了减少之间的竞争每个线程其实会分到不同的标记带来执行
  • 理解为标记栈被分割为好几块,每个线程负责其中的一块进行遍历标记对象,就和1.7 Hashmap 的 segment 一样
  • 有的线程标记的快,有的标记的慢,那么先空闲下来的线程会去窃取别人的任务来执行,从而实现负载均衡。就是 ForkJoinPool 的工作窃取机制!

3. 再标记阶段

STW,因为并发阶段应用线程还是在运行的,所以会修改对象的引用导致漏标的情况

  • 如果这个阶段执行的时间过长,就会再次进入到并发标记阶段,因为 ZGC 的目标就是低延迟,所以一有高延迟的苗头就得扼制
  • 这个阶段还会做非强根并行标记,非强根指的是:系统字典、JVMTI、JFR、字符串表
    • 有些非强根可以并发,有些不行,具体不做分析

4. 非强引用并发标记

  • 就是上一步非强根的遍历,软引用、弱引用、虚引用的一些处理
  • 这个阶段是并发的

5. 重置转移集

在读屏障时,提到的 forwardingTable 就是个映射集,可以理解为:key 就是对象转移前的地址,value 是对象转移后的地址

  • 不过这个映射集在标记阶段已经用了,也就是说标记的时候已经重定位完了,所以现在没用了
  • 但新一轮的垃圾回收需要还是要用到这个映射集的,因此这个阶段对那些转移分区的地址映射集做了一个复位操作

6. 回收无效分区

回收那些物理内存已经被释放的无效的虚拟内存页面

  • 就是在内存紧张时会释放物理内存,如果同时释放虚拟空间的话也不能释放分区,因为分区需要在新一轮标记完成之后才能释放
  • 所以就会有无效的虚拟内存页面存在,在这个阶段回收

7. 选择待回收的分区

这和 G1 一样,因为会有很多可以回收的分区,会筛选垃圾较多的分区,来作为这次回收的分区集合

8. 初始化待转移集合的转移表

初始化待回收的分区的 forwardingTable

9. 初始转移

从根集合出发,如果对象在转移的分区集合中,则在新的分区分配对象空间。如果不在转移分区集合中,则将对象标记为 Remapped

注意:这个阶段是 STW,只转移根直接可达的对象

10. 并发转移

和并发标记阶段就很类似了,从上一步转移的对象开始遍历,做并发转移

  • G1 的转移对象整体都需要 STW,而 ZGC 做到了并发转移,所以延迟会低很多

至此十个步骤就完毕了,一次 GC 结束

9. 染色指针的标记位

分析下几个标记位:M0M1Remapped

  1. 地址视图:指的就是此时地址指针的标记位

    • eg:标记位现在是 M0,那么此时的视图就是 M0 视图
    • 在垃圾回收开始前视图是 Remapped
  2. 进入标记阶段标记

    • 标记线程访问发现对象地址视图是 Remapped,这时将指针标记为 M0,即将地址视图置为 M0,表示活跃对象
    • 如果扫描到对象地址视图是 M0,则说明这个对象是标记开始之后新分配的或已经标记过的对象,所以无需处理
    • 应用线程 如果创建新对象,则将其地址视图置为 M0,如果访问的对象地址视图是 Remapped 则将其置为 M0,并且递归标记其引用的对象
    • 如果访问到的是 M0 ,则无需操作
    • 标记阶段结束后,ZGC 会使用一个对象活跃表来存储这些对象地址,此时活跃的对象地址视图是 M0
  3. 并发转移阶段,地址视图被置为 Remapped

    • 也就是说 GC 线程如果访问到对象,此时对象地址视图是 M0,并且存在或活跃表中,则将其转移,并将地址视图置为 Remapped
    • 如果在活跃表中,但是地址视图已经是 Remapped 说明已经被转移了,不做处理
    • 应用线程此时创建新对象,地址视图会设为 Remapped
    • 此时访问对象如果对象在活跃表中,且地址视图为 Remapped 说明转移过了,不做处理
    • 如果地址视图为 M0,则说明还未转移,则需要转移,并将其地址视图置为 Remapped
    • 如果访问到的对象不在活跃表中,则不做处理

那 M1 什么用

  • M1 是下一次 GC 时用的,下一次的 GC 就用 M1来标记,不用 M0。再下一次再换过来
  • 简单的说,就是 M1 标识本次垃圾回收中活跃的对象,而 M0 是上一次回收被标记的对象,但是没有被转移,在本次回收中也没有被标记活跃的对象
  • 其实从上面的分析得知,如果没有被转移就会停留在 M0 这个地址视图,而下一次 GC 如果还是用 M0 来标识那混淆了这两种对象,所以用 M1

如图示意:

4f8a03524b72457ca330f0c371f6dbcf

37. CMS写屏障作用

CMS 写屏障又是维护卡表,又得维护增量更新?

卡表其实只有一份,又得用来支持 YGC,又得支持 CMS 并发时的增量更新,肯定是不够的

  • 每次 YGC 都会扫描重置卡表,这样增量更新的记录就被清理了

所以还搞了个 mod-union table,在并发标记时,如果发生 YGC 需要重置卡表的记录时,就会更新 mod-union table 对应的位置

  • 这样 cms 重新标记阶段就能结合当时的卡表和 mod-union table 来处理增量更新,防止漏标对象

38. GC调优的两大目标

分别是最短暂停时间和吞吐量调优时候需要明确应用的目标

  • 最短暂停时间: 因为 GC 会 STW 暂停所有应用线程,对于用户而言就等于卡顿了,因此对于时延敏感的应用来说减少 STW 的时间是关键
  • 吞吐量: 对于一些对时延不敏感的应用,吞吐量是关注的重点,它们不关注每次 GC 停顿的时间,只关注总的停顿时间少,吞吐量高

举个例子:

  • 方案一:每次 GC 停顿 100 ms,每秒停顿 5 次
  • 方案二:每次 GC 停顿 200 ms,每秒停顿 2 次

两个方案相对而言第一个时延低,第二个吞吐高,基本上两者不可兼得

39. GC如何调优

具体场景具体分析,在面试中就不要讲太细,大方向清楚就行,不需要涉及具体的垃圾收集器比如 CMS 调什么参数,G1 调什么参数之类的。

核心思路: 尽可能的使对象在年轻代被回收,减少对象进入老年代

  • 具体调优还是得看场景根据 GC 日志具体分析,常见的需要关注的指标是 Young GC 和 Full GC 触发频率、原因、晋升的速率、老年代内存占用量等
  • eg:发现频繁会产生 Full GC,分析日志之后发现没有内存泄漏,只是 Young GC 之后会有大量的对象进入老年代,然后最终触发 Ful GC。所以就能得知是 Survivor 空间设置太小,导致对象过早进入老年代,因此调大 Survivor
  • 或者是晋升年龄设置的太小,也有可能分析日志之后发现是内存泄漏、或者有第三方类库调用了 System.gc

40. JVM配置参数

  • -Xms:初始化堆内存大小
  • -Xmx:最大堆内存大小
  • -XX:MetaspaceSize:初始化元空间大小
  • -XX:MaxMetaspaceSize:最大元空间大小
  • -XX:+HeapDumpOnOutOfMemoryError:当发生 OutOfMemoryError 时,生成堆转储(heap dump)
  • -XX:+PrintGCDetails:打印详细的垃圾回收日志

41. 分析JVM工具

  • jmap:用于生成堆转储的命令行工具,可以用于分析JVM内存使用情况,尤其是内存泄漏问题
  • jstack:用于生成线程转储的命令行工具,可以用于分析线程状态,排查死锁等问题
  • jstat:用于监控JVM统计信息的命令行工具,提供了实时的性能数据。eg:类加载、垃圾回收、编译器等信息
  • MAT:用于分析堆转储文件的工具,可以帮助识别内存泄漏和优化内存使用
  • jconsole:可以监控JVM的内存使用、垃圾回收、线程、类加载等信息
  • VisualVM:可实时显示JVM的内存使用、垃圾回收、类加载等信息,也可以分析 Heap Dump 等

42. 内存泄露分析

思路其实很简单,先确认是否真的发生了内存泄漏,即观察内存使用情况

  • 利用 jstat 命令(jstat -gc)来观察 gc 概要信息,如果发现 GC 后内存并没有明显的减少且还是持续增加持续触发 gc,那说明内存泄漏的概率很大
  • 此时可以利用 jmap(jmap -dump:format=b,file=heapdump.hprof)生成 heap dump,然后将其导入 MAT 或者 VisualVM 工具内进行分析,通过大量内存的占用可以找到对应的对象
  • 通过对象找到对应的代码分析,确认是否可能存在内存泄漏的场景,最终修复代码,解决内存泄漏的问题

附 jstat 输出字段解释:

  • S0C/S1C/S0U/S1U:年轻代中Eden区、Survivor区1、Survivor区2的容量(Capacity)和使用量(Used)
  • EC/EU:年轻代中Eden区的容量和使用量
  • OC/OU:老年代的容量和使用量
  • MC/MU:方法区的容量和使用量
  • CCSC/CCSU:压缩类空间的容量和使用量
  • YGC:年轻代GC的次数
  • YGCT:年轻代GC所用的时间
  • FGC:老年代GC的次数
  • FGCT:老年代GC所用的时间
  • GCT:总的GC时间