GQ JVM

Java 中有哪些垃圾回收算法?

我简单介绍一下常见的几种垃圾回收算法,其实大多数 GC 算法都是基于两个 “祖宗级” 的思想演变而来的:一个是 “标记 - 清除” 算法,一个是 “复制” 算法。

1. 标记 - 清除(Mark-Sweep):

先是标记阶段,将所有存活的对象进行标记。 再是清除阶段,从内存中删除没有被标记也就是非存活对象,这种方式实现简单,只需要在第一阶段给每个对象维护标志位,第二阶段删除对象即可。

img

问题是容易产生内存碎片,比如清掉很多小块内存后,剩下的零碎空间可能凑不出一个新对象需要的空间。

img

2. 复制(Copying)算法:

它的想法是:准备两块内存区域(From 空间和 To 空间),每次只用一块(From 空间),在GC阶段把存活的对象从当前区域复制到另一块区域(From->To),然后整体清空原来的区域。这样做虽然避免了内存碎片的问题,但缺点是只能用一半的空间,浪费比较大。

img

3. 标记 - 整理(Mark-Compact)算法:

也是先是标记阶段,将所有存活的对象进行标记。再是整理阶段,会把所有存活对象往内存的一边移动,然后清理掉后面的一大片空闲内存。这样既解决了碎片问题,又保留了内存的连续性。不过移动对象的成本较高,效率会稍低。

img

4. 分代垃圾回收(Generational GC):

现代 JVM 一般都采用分代的方式,把堆内存分成 “年轻代” 和 “老年代”,不同代使用不同算法。

  • 年轻代通常用 “复制” 算法,因为这里对象生命周期短、回收频繁
  • 老年代则常用 “标记 - 清除” 或 “标记 - 整理”,因为对象通常存活时间较长,不适合频繁复制。

这个分代回收机制有个好处:程序中大部分对象都是朝生夕死,大部分新创建的对象很快就不再用,所以可以快速在年轻代清理掉,减少对整个堆的 Full GC 影响,从而提升应用的吞吐量响应性能

img

总结:

img

JVM 的 TLAB(Thread-Local Allocation Buffer)是什么?

TLAB 是 JVM 为每个线程在堆中新生代 Eden 区分配的一小块私有内存,用来快速分配对象

  • 每个线程优先在自己的 TLAB 中分配对象,避免了多线程竞争共享堆的同步开销。
  • 如果 TLAB 用完,就会重新申请新的;
  • 大对象会直接在 Eden 区分配。

简单来说,TLAB 就是让对象分配更快、更安全的一种线程本地优化机制。

Java 是如何实现跨平台的?

Java 实现跨平台的关键在于它的“编译一次,到处运行”的理念。简单来说,Java 源代码首先被编译成字节码文件(.class 文件),然后这些字节码文件通过 Java 虚拟机(JVM)在不同平台上进行解释和执行。JVM 是与平台相关的,每个操作系统或硬件平台都有一个特定的 JVM 实现。这样,无论在哪个平台上,只要有适配的 JVM,Java 程序就能运行。

总结

  1. Java 编译成字节码(.class 文件),然后通过 JVM 在不同平台上执行。

  2. 每个操作系统或硬件平台都需要有特定的 JVM 实现。

  3. JVM 让 Java 程序能够在任何平台上运行,而不需要重新编译。

JVM 由哪些部分组成?

img

  • 类加载子系统,负责加载、验证、解析、初始化class字节码文件。其核心就是类加载器。
  • 运行时数据区,管理 JVM 使用到的内存。
    • 线程共享区
      • 堆:存放所有对象
      • 方法区:存放类的元信息、常量池、静态变量
    • 线程私有区
      • 虚拟机栈:每个方法对应一个栈帧
      • 本地方法栈:本地方法的栈
      • 程序计数器:记录当前线程正在执行的字节码指令地址,确保线程切换后恢复到正确的这些位置
  • 执行引擎,分为
    • 解释器 解释执行字节码指令;
    • 即时编译器 优化代码执行性能;
    • 垃圾回收器 将不再使用的对象进行回收。
  • 本地接口,保存了本地已经编译好的方法,使用 C/C++ 语言实现。

然后我们再理解型的记忆上述几个组成部分:

  1. 首先需要准备编译好的 Java 字节码文件(即class文件)。
  2. 然后需要先通过一定方式(类加载器)将 class 文件加载到内存中(运行时数据区)。
  3. 又因为字节码文件是 JVM 定义的一套指令集规范,底层操作系统无法直接执行。
  4. 因此需要特定的命令解释器(执行引擎)将字节码翻译成特定的操作系统指令集交给 CPU 去执行。
  5. 这个过程中会需要调用到一些不同语言为 Java 提供的接口(例如驱动、地图制作等),这就用到了本地方法接口(Native Interface)。

编译执行与解释执行的区别是什么?JVM 使用哪种方式?

编译执行:
定义:编译执行是将程序的源代码(如 Java 程序中的 .java 文件)在运行前由编译器转化为机器语言(通常是 .class 文件)。然后该机器代码直接在 CPU 上运行。

解释执行:
定义:解释执行是程序在运行时逐行由解释器将源代码转换为机器语言并执行。常见的解释型语言有 Python 和 Ruby。

JVM 的方式:

JVM 使用的是混合模式,结合了编译执行和解释执行的优点。Java 在运行时使用 解释执行 将字节码转化为机器码,并且采用 JIT(即时编译器) 技术,在程序运行过程中,JVM 会将经常使用的代码片段编译成机器码,从而提高性能。这种方式在启动时使用解释执行,而在运行过程中逐渐将热点代码编译为机器码,提高执行效率。

总结

  • 编译执行 提供了更高的执行效率,但缺乏跨平台性。
  • 解释执行 更具跨平台能力,但执行效率较低。
  • JVM 结合了两者的优势,初期使用解释执行,之后通过 JIT 编译器将热点代码编译为机器码,提升效率

JVM 的内存区域是如何划分的?

Java 虚拟机运行时数据区分为方法区、堆、虚拟机栈、本地方法栈、程序计数器。

img

程序计数器

程序计数器(Program Counter Register)也叫 PC 寄存器,每个线程会通过程序计数器记录当前线程要执行的的字节码指令的地址
主要有两个作用:

  • 程序计数器可以控制程序指令的进行,实现分支、跳转、异常等逻辑。

img

  • 多线程执行情况下,Java 虚拟机需要通过程序计数器记录 CPU 切换前解释执行到那一句指令并继续解释运行。

img

简单理解就是:它就像一个小导航器,记录着当前线程正在执行哪一条字节码指令。每个线程都有自己的程序计算器,线程切换时它能帮我们恢复现场。

虚拟机栈

Java 虚拟机栈采用栈的数据结构来管理方法调用中的基本数据,先进后出,每一个方法的调用使用一个栈帧来保存。每个线程都会包含一个自己的虚拟机栈,它的生命周期和线程相同。

img

栈帧主要包含三部分内容:

1、局部变量表,在方法执行过程中存放所有的局部变量。

img

2、操作数栈,虚拟机在执行指令过程中用来存放临时数据的一块区域。

如下图中,iadd 指令会将操作数栈上的两个数相加,为了实现 i+1。最终结果也会放到操作数上。

img

3、帧数据,主要包含动态链接、方法出口、异常表等内容。

  • 动态链接:方法中要用到其他类的属性和方法,这些内容在字节码文件中是以编号保存的,运行过程中需要替换成内存中的地址,这个编号到内存地址的映射关系就保存在动态链接中。
  • 方法出口:方法调用完需要弹出栈帧,回到上一个方法,程序计数器要切换到上一个方法的地址继续执行,方法出口保存的就是这个地址。
  • 异常表:存放的是代码中异常的处理信息,包含了异常捕获的生效范围以及异常发生后跳转到的字节码指令位置。

本地方法栈

Java 虚拟机栈存储了 Java 方法调用时的栈帧,而本地方法栈存储的是 native 本地方法的栈帧。

在 Hotspot 虚拟机中,Java 虚拟机栈和本地方法栈实现上使用了同一个栈空间。本地方法栈会在栈内存上生成一个栈帧,临时保存方法的参数同时方便出现异常时也把本地方法的栈信息打印出来。

img

  • 一般 Java 程序中堆内存是空间最大的一块内存区域。创建出来的对象都存在于堆上
  • 栈上的局部变量表中,可以存放堆上对象的引用。静态变量也可以存放堆对象的引用,通过静态变量就可以实现对象在线程之间共享。
  • 堆是垃圾回收最主要的部分,堆结构更详细的划分与垃圾回收器有关。

img

方法区

方法区是 Java 虚拟机规范中提出来的一个虚拟机概念,在 HotSpot 不同版本中会用永久代或者元空间来实现。方法区主要存放的是基础信息,包含:

1、每一个加载的类的元信息(基础信息)。
2、运行时常量池,保存了字节码文件中的常量池内容,避免常量内容重复创建减少内存开销。
3、字符串常量池,存储字符串的常量。

img

面试简要回答

如果面试官问我:“你能说一下 JVM 的运行时数据区吗?”

答:当然可以。JVM 的运行时数据区,其实就是 JVM 在运行 Java 程序时会分配的一些内存区域,这些区域各自有不同的用途。整体上可以分成线程共享的和线程私有的两类。

线程共享的有两个:

  • 堆(Heap): 这是用来存储所有对象实例的地方,也是垃圾回收器的主要工作区域。对象被 new 出来之后都会放在这里。
  • 方法区(Method Area): 这块区域里保存的是类的信息,比如类名、字段、方法、常量池这些。你可以理解成它更偏向“类”的元数据。

线程私有的有三个:

  • 程序计数器(PC Register): 这就像一个小导航器,记录着当前线程正在执行哪一条字节码指令。每个线程都有自己的,线程切换时它能帮我们恢复现场。
  • 虚拟机栈(JVM Stack): 每个线程也有自己的栈,它是方法调用的基础结构。每个方法执行时都会生成一个叫“栈帧”的东西,用来保存方法的局部变量、操作数、返回地址等。
  • 本地方法栈(Native Method Stack): 它是专门用来支持 native 本地方法的,和 JVM 栈类似,只不过作用在本地方法上。

另外还有一个特殊的区域叫 直接内存(Direct Memory),它不是 JVM 管的,而是由操作系统直接管理的,常用于高性能 I/O,比如 NIO 中的 DirectByteBuffer。

所以总结下来就是:JVM 把内存分区做得很清楚,不同的区域负责不同的功能,有的线程共享,有的线程独占,目的就是为了更高效地管理对象和执行代码。

JVM 方法区是否会出现内存溢出?

运行时数据区的哪些区域会出现内存溢出?

内存溢出指的是内存中某一块区域的使用量超过了允许使用的最大值,从而使用内存时因空间不足而失败,虚拟机一般会抛出指定的错误。
在 Java 虚拟机中,只有程序计数器不会出现内存溢出的情况,因为每个线程的程序计数器只保存一个固定长度的地址

img

堆内存溢出:

堆内存溢出指的是在堆上分配的对象空间超过了堆的最大大小,从而导致的内存溢出。堆的最大大小使用 - Xmx 参数进行设置,如 - Xmx10m 代表最大堆内存大小为 10m。
溢出之后会抛出 OutOfMemoryError,并提示是 Java heap Space 导致的:

img

栈内存溢出:

栈内存溢出指的是所有栈帧空间的占用内存超过了最大值,最大值使用 - Xss 进行设置,比如 - Xss256k 代表所有栈帧占用内存大小加起来不能超过 256k。
溢出之后会抛出 StackOverflowError:

img

方法区内存溢出:

方法区内存溢出指的是方法区中存放的内容比如类的元信息超过了方法区内存的最大值,JDK7 及之前版本方法区使用永久代(-XX:MaxPermSize = 值)来实现,JDK8 及之后使用元空间(-XX:MaxMetaspaceSize = 值)来实现。

元空间溢出:

img
永久代溢出:

img

直接内存溢出:

直接内存溢出指的是申请的直接内存空间大小超过了最大值,使用 -XX:MaxDirectMemorySize = 值 设置最大值。

溢出之后会抛出 OutOfMemoryError:

img

面试时简答:

回答:

在 JVM 中,内存溢出通常指的是某一块区域的内存超过了它的最大限制,导致无法分配内存,抛出相应的异常。常见的内存区域和对应的异常现象如下:

  1. 堆内存溢出(OutOfMemoryError: Java heap space)
  • 原因:堆内存用于存储对象,如果应用创建了大量对象,超过了最大堆内存限制,就会导致堆内存溢出。
  • 现象:抛出 OutOfMemoryError: Java heap space 异常。
  1. 栈内存溢出(StackOverflowError)
  • 原因:每个线程都有自己的栈内存,如果递归调用过深,栈空间会被耗尽,导致栈内存溢出。
  • 现象:抛出 StackOverflowError 异常。
  1. 方法区内存溢出
  • 原因:JDK7及之前的版本使用永久代(PermGen)存储类的元数据,JDK8及之后使用元空间(Metaspace)。如果加载的类太多,或者类的元信息过多,可能会导致方法区溢出。
  • 现象:抛出 OutOfMemoryError,JDK7及之前提示 PermGen space,JDK8及之后提示 Metaspace。
  1. 直接内存溢出(OutOfMemoryError: Direct buffer memory)
  • 原因:Java 使用 NIO 进行 I/O 操作时,分配直接内存(通过 ByteBuffer.allocateDirect()),如果直接内存超过了最大限制,会发生溢出。
  • 现象:抛出 OutOfMemoryError: Direct buffer memory 异常。

总结:

  • 堆内存:溢出后抛出 OutOfMemoryError: Java heap space。
  • 栈内存:溢出后抛出 StackOverflowError。
  • 方法区:溢出后抛出 OutOfMemoryError,JDK7之前是 PermGen space,JDK8之后是 Metaspace。
  • 直接内存:溢出后抛出 OutOfMemoryError: Direct buffer memory。

本题问的是JVM 方法区是否会出现内存溢出? 我们只需要回答方法区内存溢出的内容:

方法区会出现内存溢出,JDK7及之前的版本使用永久代(PermGen)存储类的元数据,JDK8及之后使用元空间(Metaspace)。如果加载的类太多,或者类的元信息过多,可能会导致方法区溢出。溢出后抛出 OutOfMemoryError,JDK7及之前提示 PermGen space,JDK8及之后提示 Metaspace。

JVM 有那几种情况会产生 OOM(内存溢出)?

回答:

在 JVM 中,内存溢出通常指的是某一块区域的内存超过了它的最大限制,导致无法分配内存,抛出相应的异常。常见的内存区域和对应的异常现象如下:

  1. 堆内存溢出(OutOfMemoryError: Java heap space)
  • 原因:堆内存用于存储对象,如果应用创建了大量对象,超过了最大堆内存限制,就会导致堆内存溢出。
  • 现象:抛出 OutOfMemoryError: Java heap space 异常。
  1. 栈内存溢出(StackOverflowError)
  • 原因:每个线程都有自己的栈内存,如果递归调用过深,栈空间会被耗尽,导致栈内存溢出。
  • 现象:抛出 StackOverflowError 异常。
  1. 方法区内存溢出
  • 原因:JDK7及之前的版本使用永久代(PermGen)存储类的元数据,JDK8及之后使用元空间(Metaspace)。如果加载的类太多,或者类的元信息过多,可能会导致方法区溢出。
  • 现象:抛出 OutOfMemoryError,JDK7及之前提示 PermGen space,JDK8及之后提示 Metaspace。
  1. 直接内存溢出(OutOfMemoryError: Direct buffer memory)
  • 原因:Java 使用 NIO 进行 I/O 操作时,分配直接内存(通过 ByteBuffer.allocateDirect()),如果直接内存超过了最大限制,会发生溢出。
  • 现象:抛出 OutOfMemoryError: Direct buffer memory 异常。
  1. 线程数过多导致的内存溢出(Unable to Create New Native Thread)
  • 原因:每个线程都需要找空间和一定的操作系统资源。如果创建过多线程而超出操作系统的资源限制,可能无法再创建新的线程,导致 OOM。
  • 现象:抛出 java.lang.OutOfMemoryError: Unable to create new native thread 异常。
  • 解决方法:减少线程数,合理设置线程池的大小,避免无限制地创建线程。
  1. GC 执行耗时过长导致的 OOM(GC Overhead Limit Exceeded)
  • 原因:当 JVM 在垃圾回收时花费的时间过多回收的内存不足以满足需求,JVM 会抛出 GC Overhead Limit Exceeded 错误,以避免长时间的垃圾回收循环。
  • 现象:抛出 java.lang.OutOfMemoryError: GC overhead limit exceeded 错误。
  • 解决方法:增加堆内存,优化内存管理,减少不必要的对象创建,或者调整垃圾回收策略。

总结:

  • 堆内存:溢出后抛出 OutOfMemoryError: Java heap space。
  • 栈内存:溢出后抛出 StackOverflowError。
  • 方法区:溢出后抛出 OutOfMemoryError,JDK7之前是 PermGen space,JDK8之后是 Metaspace。
  • 直接内存:溢出后抛出 OutOfMemoryError: Direct buffer memory。
  • 线程数过多:抛出 Unable to create new native thread。
  • GC 执行耗时过长:抛出 GC Overhead Limit Exceeded。

Java 中堆和栈的区别是什么?

  1. 堆(Heap):
  • 用于存储对象实例和数组。每次使用 new 关键字创建对象时,JVM 都会在堆内存中分配空间。
  • 堆内存是动态分配的,大小不固定。
  • 对象的生命周期由垃圾回收器管理,垃圾回收会定期检查不再被引用的对象,并回收其占用的内存。
  • 抛出异常:OutOfMrmoryError。
  • 是线程共享的
  1. 栈(Stack):
  • 用于存储局部变量、方法调用和方法中的引用(例如,方法参数)。
  • 每个线程都会有自己的栈内存,存储方法调用的信息(例如返回地址、局部变量等)。
  • 对象的生命周期和方法调用一致,方法结束时就释放
  • 栈内存是静态分配的,并且大小在编译时就确定
  • 抛出异常:StackOverflowError

简单理解:

  • 堆:就像是一个存储大量数据的大仓库,里面存放着所有创建的对象。它的空间是动态分配的,垃圾回收机制会帮忙清理不再使用的空间
  • 栈:每个线程都有一个小仓库,里面存放的是方法调用时的局部数据和地址。这个仓库很小,方法一结束就会自动清空

什么是 Java 中的直接内存(堆外内存)?

直接内存并不在《Java 虚拟机规范》中存在,所以并不属于 Java 运行时的内存区域。在 JDK 1.4 中引入了 NIO 机制,由操作系统直接管理这部分内容 ,是堆外内存,主要为了提升读写数据的性能。在网络编程框架如 Netty 中被大量使用。(Netty 使用了 Reactor 模型,主线程去负责链接,然后分发子任务给 Reactor 子线程)

要创建直接内存上的数据,可以使用 ByteBuffer。

  • 语法: ByteBuffer directBuffer = ByteBuffer.allocateDirect (size);

与堆内存的区别:

  • 直接内存访问更快,减少了堆内存到直接内存之间的复制;
  • 使用完需要用cleaner进行回收。jvm垃圾收集器不会进行回收。

什么是 Java 中的常量池?

常量池(Constant Pool)是一个用于存储常量值的内存区域,主要用于提高性能和节省内存。常量池中有两种类型的常量:

  • 运行时常量池
  • 字符串常量池
  1. 运行时常量池

    • 这是 JVM 在运行时通过 Class 文件加载的常量池,它包含类和方法中的常量信息,包括常量字符串、字段、方法引用等。加载到 JVM 后,运行时常量池会通过类的加载过程进入 Java 方法区,直到 JVM 退出。
  2. 字符串常量池

    • 这是专门用于存储字符串常量的区域位于 Java 堆内存中。通过字符串的 intern() 方法,字符串常量会被存入常量池中。这样如果遇到相同的字符串,JVM 就可以直接使用已经存在的字符串,而不是重新创建一个新的字符串对象,从而节省内存。

常量池的作用:

  • 通过将常用的常量值(如字符串)存储在常量池中,避免重复创建相同的常量对象,从而提高性能和减少内存占用。

对比和优化:

  • 字符串常量池的实现让字符串在内存中的管理变得更高效。当在代码中使用类似 String s = "Hello"; 的方式时,JVM 会首先检查常量池中是否有 “Hello” 这个字符串。如果没有,则将它添加进去;如果已经有了,就直接引用已有的对象。

总的来说,常量池不仅提升了 Java 应用的效率,还减少了内存的占用特别是在大量使用常量字符串的场景中,优化显著。

你了解 Java 的类加载器吗?

什么是类加载器

它用于在运行时动态地将class字节码加载到内存中,以供JVM执行。

  • 启动类加载器 (Bootstrap ClassLoader):这是最基本的类加载器,负责加载核心的 Java 类库,比如 rt.jar。它由 JVM 自身用 C++ 实现,因此不能直接在 Java 程序中访问。它是所有类加载器的父类加载器。
  • 扩展类加载器 (Extension ClassLoader):这个类加载器负责加载 lib/ext 目录下的类库,或者是由 java.ext.dirs 系统属性指定的路径下的类。它的主要任务是加载 Java 扩展的类库,比如一些第三方的库。
  • 应用程序类加载器 (Application ClassLoader):它负责加载应用程序的类路径下(classPath)的类(即 .class 文件)。通常我们直接使用 ClassLoader.getSystemClassLoader() 获取到的就是这个类加载器。它是用户自定义类加载器的默认父类加载器。
  • 自定义类加载器(CustomizeClassLoader):自定义类继承ClassLoader,实现自定义类加载规则。

JDK 1.9 的变化

在 JDK 1.9 中,类加载器发生了一些变化。原本的扩展类加载器被重命名为 平台类加载器 (PlatformClassLoader)。这个新的类加载器主要加载 JDK 平台本身的类库属于 Java 平台模块系统的非核心模块,而不是 java.* 系列类。平台类加载器的引入是为了配合 JDK 9 的模块化特性。

类加载器之间的关系

虽然有“双亲委派”的说法,但其实类加载器之间是通过组合关系来建立父子关系的每个类加载器都有一个 parent 属性,表示它的父类加载器。当一个类加载器无法加载一个类时,它会委派给父类加载器去尝试加载。

总结

  • JDK 1.8 及之前:

    • 启动类加载器:加载核心 Java 类。
    • 扩展类加载器:加载扩展库。
    • 应用程序类加载器:加载用户类路径下的类。
  • JDK 1.9 及以后:

    • 扩展类加载器被重命名为 平台类加载器,用于加载非核心模块,配合 Java 模块系统。

什么是 Java 中的 JIT(Just-In-Time)?

JIT,全称 Just-In-Time,就是即时编译器。它的作用是——在程序运行时,把 Java 字节码动态编译成机器码,让代码执行得更快

原理:

  1. Java 一开始是解释执行的,也就是说,JVM 会一边解释字节码、一边执行,所以效率比较低
  2. 但当 JVM 发现某些方法或代码块被频繁执行时,就会把它们标记为热点代码(Hotspot Code)
  3. 然后 JIT 编译器就会出手,把这段热点代码编译成机器码,并缓存起来下次再执行的时候,直接运行机器码,不用再解释一遍,性能就接近 C++ 那种编译型语言了。

举个栗子

比如有个循环执行上百万次,JVM 发现它很“热”,就会触发 JIT,把这段循环编译成本地机器指令。下次再跑这段循环时,几乎是“原生速度”在执行,快很多。

JIT 编译优化做了什么?

JIT 不只是把代码编译成机器码,还会做很多优化,比如:

  • 方法内联(Inlining):把小方法的代码直接展开,减少方法调用开销
  • 逃逸分析(Escape Analysis):判断对象是否只在一个线程中使用,JIT编译器根据对象的逃逸状态采用不同的优化策略,以提高Java程序的性能和效率。
  • 循环展开(Loop Unrolling):减少循环判断次数,加快执行速度。
  • 锁消除、标量替换:进一步减少多线程锁竞争,提高性能。

这些优化都是在运行时动态完成的,因此 JIT 会越“跑越快”。

热点代码识别方式:

JIT 会通过两种方式识别热点:

  • 采样探测:周期性地检测哪些方法经常出现在栈顶;
  • 计数器探测(HotSpot 采用):统计方法被调用的次数或循环执行次数,超过阈值就触发编译。

JIT 编译器类型

JVM 里其实有两个 JIT 编译器:

  • C1(Client):启动快,适合客户端程序;
  • C2(Server):优化更激进,适合服务器端应用。

JIT可能带来的问题:

虽然 JIT 能显著提高性能,但它也有一些潜在的副作用:

  1. 首次执行慢(编译开销):
    热点代码第一次被编译时需要消耗 CPU 时间,会导致程序初期性能波动。

➤ 解决方案:可以通过“预热机制”提前让热点代码运行几次,让 JIT 提前触发;或者使用 AOT(Ahead-Of-Time)提前编译。

  1. 内存占用增加:
    编译后的机器码会被缓存到内存中,代码越多、占用越大。

➤ 解决方案:通过 JVM 参数(比如 -XX:ReservedCodeCacheSize)限制代码缓存大小

  1. 编译与执行争用 CPU 资源:
    JIT 编译本身也会消耗 CPU,可能在高并发场景下带来瞬时抖动。

➤ 解决方案:使用分层编译(Tiered Compilation),让 C1 先做轻量编译,C2 后做深度优化,平衡性能与稳定性。

小结

JIT 就是即时编译器,它在 Java 程序运行时,把热点代码动态编译成本地机器码执行。
这样程序能越跑越快,性能接近 C++。
但它也有缺点,比如首次编译会导致启动慢、占 CPU 和内存,还可能因为过度优化而回退。
通常我们通过预热或分层编译(Tiered Compilation)来平衡性能和稳定性。

JIT 编译后的代码存在哪?

JIT 编译后的机器码会被存放在 Code Cache(代码缓存区) 里。
这个区域是 JVM 专门为即时编译器(JIT)准备的一块独立内存,用来存放编译生成的本地机器码。

  • 需要注意的是,Code Cache 不在堆内,也不在方法区(或元空间)里
  • 而 Code Cache 是另外一块独立的本地内存区域,JVM 会在里面缓存 JIT 编译后的机器码,方便后续直接执行。

Code Cache 的大小和行为可以通过 JVM 参数来配置

  • -XX:InitialCodeCacheSize:设置初始大小
  • -XX:ReservedCodeCacheSize:设置最大容量
  • -XX:+PrintCodeCache:可以打印出当前 Code Cache 的使用信息

小结

JIT 编译后的机器码会被放在 JVM 的 Code Cache 区,这是一块独立于堆和元空间的本地内存,用来缓存编译后的机器码,提高执行效率

什么是 Java 的 AOT(Ahead-Of-Time)?

什么是AOT?

AOT,全称 Ahead-Of-Time,就是提前编译。它和 JIT(即时编译)相反,JIT 是程序运行时才把字节码编译成机器码,而 AOT 是在程序运行之前,就提前把字节码编译成本地机器码

工作原理:

AOT 在编译阶段就会对 Java 字节码进行静态分析,然后生成目标平台能直接执行的机器码,也就是所谓的 Native Image(本地镜像)。
这样程序启动时就不需要 JVM 再去解释或即时编译,可以直接运行在操作系统上,启动非常快。

AOT 的优势:

  • 启动快:因为代码已经是机器码,不需要再运行时编译,特别适合快速启动的场景,比如云原生、Serverless、微服务。
  • 内存占用更低:AOT 编译后的程序不再依赖完整 JVM,内存占用更少。
  • 冷启动性能好:省去了 JIT 的预热阶段,程序一启动就能达到高性能

AOT 的缺点:

  • 缺乏运行时优化:JIT 可以根据程序运行时的热点信息动态优化,而 AOT 生成的代码是静态的,长时间运行的程序性能可能不如 JIT
  • 跨平台性差:编译后的机器码是平台相关的,不能像字节码那样“一次编译,到处运行”
  • 灵活性差:像反射、动态代理、类加载等运行时特性会受到限制,编译时必须都能确定(也就是所谓的“封闭性假设”)。

使用场景:
AOT 一般用在对启动速度敏感的场景,比如:

  • 云原生 / Serverless 应用
  • 容器化环境、微服务、嵌入式系统
  • 启动频繁、生命周期短的任务型服务

常见 AOT 工具:

  • GraalVM:目前最主流的 AOT 编译器,能把 Java 程序直接编译成本地可执行文件。
  • jaotc:Java 9 引入的官方 AOT 工具,但在生产中使用不多,更多是实验性质。

小结

AOT 就是提前编译(Ahead-Of-Time),在程序运行前就把 Java 字节码编译成本地机器码。
这样程序启动更快、占用更少,非常适合微服务、云原生、Serverless 这种对启动速度要求高的场景。
但它缺点是少了 JIT 的运行时优化,性能长期表现可能不如 JIT,灵活性也更差。
常见实现是 GraalVM 的 native image。

你了解 Java 的逃逸分析吗?

什么是逃逸分析

逃逸分析(Escape Analysis)是 JVM 的一种编译优化技术,主要用来判断一个对象是否会逃出当前方法或线程的作用范围。如果不会逃逸,JVM 就能对它做一系列性能优化,比如栈上分配、锁消除、标量替换等。

核心原理

JVM 在 JIT 编译阶段,会静态分析对象的使用范围:

  • 如果对象只在当前方法内使用,不会被别的线程或外部引用到,就属于“不逃逸”;
  • 如果对象作为参数传给别的方法,或被赋值给全局变量、返回给外部,那就是“发生了逃逸”。

逃逸的本质,就是看一个对象的生命周期是否“跑出了”原来的作用域

根据逃逸范围分类:

  • 方法逃逸(Arg Escape):对象被当作参数传递到别的方法;
  • 线程逃逸(Global Escape):对象被多个线程共享,比如赋给静态变量;
  • 无逃逸(No Escape):对象完全只在当前方法中使用。

JVM 根据逃逸情况会做的三种优化:

  • 栈上分配
    • 如果不逃逸,JVM 就能直接在栈上分配,而不是在堆上分配。这样分配速度快,还能减少 GC 压力。
  • 锁消除:
    • 如果对象只在单线程内使用,那加的 synchronized 锁其实没意义,JVM 就能自动去掉
  • 标量替换:
    • 如果对象不逃逸,JVM 可以把它的字段拆成独立的局部变量,甚至连对象本身都不创建。

逃逸分析虽然很强,但也有一些问题:

  • 它本身是个复杂的分析过程,会增加编译开销
  • 在某些复杂逻辑中,分析结果可能不明显,导致优化收益不如消耗
  • 所以实际中,逃逸分析对短小高频方法特别有用,但对复杂代码可能收益有限

常见 JVM 参数(了解即可)

1
2
3
4
-XX:+DoEscapeAnalysis       # 开启逃逸分析(默认开启)
-XX:-DoEscapeAnalysis # 关闭逃逸分析
-XX:+EliminateLocks # 启用锁消除
-XX:+EliminateAllocations # 启用标量替换

小结

逃逸分析是 JVM 的一种优化技术,用来判断一个对象会不会‘逃出’当前方法或线程。
如果对象不会逃逸,JVM 就能做三种优化:
一是栈上分配,不用进堆,减少 GC;
二是锁消除,去掉没必要的同步;
三是标量替换,直接把对象拆成局部变量。
简单来说,它能让代码执行更快、内存占用更少,是 JIT 编译器很核心的优化手段。

Java 中的强引用、软引用、弱引用和虚引用分别是什么?

在 Java 中,引用分为强引用、软引用、弱引用和虚引用四种类型。
它们的区别在于——GC(垃圾回收器)是否以及何时会回收对象
简单说:强软弱虚,引用越往后,GC 越容易回收。

强引用(Strong Reference)

这是最常见、也是 Java 默认的引用方式
只要一个对象有强引用存在,GC 永远不会回收它,即使内存不足也会宁可抛出OutOfMemoryError。

特点

  • 最普通的 new 出来的对象就是强引用;
  • 只要引用还在,GC 不会动它。

举例:

1
String[] arr = new String[]{"a", "b", "c"};

这就是典型的强引用。比如在 HashMap 里,键和值默认就是强引用。

软引用(Soft Reference)

软引用是“可有可无”的引用,常用于缓存
当系统内存不足时,GC 会优先清理掉软引用指向的对象,以避免 OOM,内存足够时,不会去管他

特点

  • 对象不会立即回收,只有在内存紧张时才清理;
  • 常用于实现缓存机制,比如图片缓存、对象缓存。

举例:

1
SoftReference<Object> softRef = new SoftReference<>(new Object());

Guava Cache 内部就使用了 SoftReference 来做内存友好的缓存

弱引用(Weak Reference)

弱引用比软引用更脆弱。
只要 GC 运行,就会回收它,无论内存够不够

特点:

  • 生命周期非常短;
  • 常用于防止内存泄漏,比如 WeakHashMap、ThreadLocal。

举例:

1
WeakReference<Object> weakRef = new WeakReference<>(new Object());

像 WeakHashMap 中的 key 就是弱引用,当 key 没被外部引用后,会被自动回收,Entry 也随之清除。

虚引用(Phantom Reference)

虚引用最弱,几乎不影响对象生命周期
它存在的意义不是访问对象,而是用来“感知对象何时被回收”

特点:

  • 不能通过虚引用获取对象;
  • 通常配合 ReferenceQueue 使用,用于资源清理(比如文件句柄、DirectBuffer)。

举例:

1
2
PhantomReference<Object> phantomRef =
new PhantomReference<>(new Object(), new ReferenceQueue<>());

当对象被回收后,虚引用会被放入队列中,方便开发者执行资源释放逻辑。

对比表格

引用类型 生命周期 何时被 GC 回收 典型用途
强引用 最长 永不(除非引用断开) 普通对象引用
软引用 次长 内存不足时 缓存(如图片缓存)
弱引用 很短 一旦 GC 运行 防止内存泄漏(如 WeakHashMap)
虚引用 最短 几乎立即 跟踪回收状态、资源清理

小结

Java 里的引用分四种:强、软、弱、虚,强度依次减弱。
强引用是默认的,只要有它,对象就不会被回收;
软引用一般做缓存用,内存不够时才清理;
弱引用更弱,GC 一运行就回收,比如 WeakHashMap;
虚引用最弱,用来监听对象何时被回收,比如清理 DirectBuffer。
一句话总结:越往后越容易被回收。

Java 中常见的垃圾收集器有哪些?

在JVM中,垃圾回收器是实现垃圾回收算法的具体工具,主要分为处理年轻代和老年代的回收器。除了G1,其他回收器一般需要成对使用。常见的垃圾回收器主要包括以下几类:

  1. Serial 和 Serial Old:这是最基础的串行垃圾回收器,单线程工作。它适合资源受限的客户端程序,虽然简单但在多核系统下效率不高。

  2. ParNew 和 CMS (Concurrent Mark Sweep) :ParNew 是 Serial 的多线程版本CMS 关注的是减少系统停顿时间,它能在用户线程运行的同时并发进行垃圾回收。不过 CMS 会有“浮动垃圾”、内存碎片以及退化成Full GC的问题。

  3. Parallel Scavenge 和 Parallel Old (PS + PO)这是 JDK8 默认使用的回收器组合,采用多线程方式,追求高吞吐量,适合后台批处理任务等场景。但它的暂停时间较长,不太适合对响应时间要求高的业务。

  4. G1 (Garbage First)G1 是 JDK9 之后的默认回收器它可以同时处理年轻代和老年代,采用分区+并发回收机制,能很好地控制最大停顿时间。适合大内存的服务端应用,比如堆内存 6G 以上的场景。

  5. Shenandoah:由 RedHat 开发,主打低停顿,连“整理”这一阶段都可以并发执行,因此无论堆大小如何,停顿时间都可以非常低。

  6. ZGC:是更进一步的低延迟回收器,号称 STW 停顿不会超过 1ms,而且支持从几百兆到 16TB 的堆内存扩展,适合超大内存和延迟非常敏感的系统。

总结一句话:
JDK8 之前推荐用 CMS 或 PS+PO,JDK9 之后推荐 G1;如果对延迟非常敏感,可以选择 Shenandoah 或 ZGC。

对比一下 G1、Shenandoah 和 ZGC 这三款垃圾回收器

G1、Shenandoah 和 ZGC 都是现代 Java 的低延迟 GC 回收器。

  • G1 将堆划分为 Region,通过并发标记和 STW 压缩来减少 full GC,适合通用场景;
  • Shenandoah 实现了并发压缩,通过转发表解决引用更新问题,可将 GC 停顿控制在毫秒级;
  • ZGC 采用 colored pointer 技术,实现了全并发、几乎全程无停顿的 GC,停顿时间低于 1ms,适用于延迟极敏感的大型系统。
回收器 一句话记住它
G1 GC 区域化管理,目标是均衡停顿和吞吐
Shenandoah 并发压缩,目标是最小停顿(MS 级)
ZGC 染色指针,全并发回收,目标是亚毫秒级停顿

Java 中如何判断对象是否是垃圾?不同实现方式有何区别?

其实在 JVM 中,为了判断堆里的对象还能不能继续使用,主要用的是一种叫“可达性分析”的方法。

  • 早期也有一种方法叫“引用计数法”,就是每个对象维护一个计数器每次被别的对象引用时加一,引用断了就减一。如果计数器变成 0,就说明没人再引用它了,可以回收了。这种方法实现简单、逻辑也直观,但有一个很大的问题:循环引用比如对象 A 引用 B,B 也引用 A,虽然它们已经不再被程序其他地方引用,但它们互相指着对方,引用计数就永远不是 0,这样就会造成内存泄漏

  • 所以 Java 后来改用可达性分析算法(Reachability Analysis),这是一种从一组“GC Root”对象出发,看一个对象是不是还能被这些根对象“追踪”到的方式。如果一个对象从 GC Root 开始,一路沿着引用链可以走到,那说明它还在被用,就不能回收;反过来,如果走不到,就是“不可达”,可以回收。这种方法的好处是能很自然地处理掉循环引用的问题。

什么是GC Root

GC Root 通常包括像:当前线程正在运行的方法栈、静态变量、类加载器、本地方法引用等。

小结

重点记住“引用计数有循环引用问题”、“可达性分析是 Java 的选择”这两个点就够了。

为什么 Java 的垃圾收集器将堆分为老年代和新生代?

Java 堆之所以要分为新生代(Young Generation)和老年代(Old Generation),
核心目的是:优化 GC 性能。不同生命周期的对象放在一起回收效率太低,分代后可以“有针对性地清理”,大幅提升回收效率。

为什么要分代?

在 Java 程序运行过程中,对象的生命周期差异很大,有的“朝生夕死”,有的会长期存在。

绝大多数对象存活时间很短:例如临时变量、方法中的局部对象,往往用一次就没了。
→ 这些短命对象集中放在新生代,用轻量的算法快速清理。

少数对象生命周期很长:比如缓存、线程池、单例等,长期存在。
→ 这类对象被晋升到老年代,使用更稳定的算法回收,避免频繁扫描。

  • 即:不同“寿命”的对象放在不同区域,GC 时就能有针对性地处理,提升整体性能

堆的分代结构

Java 堆(Heap)根据对象的生命周期分为三块:

  • 新生代(Young Generation):存放新创建的对象;
  • 老年代(Old Generation):存放存活时间较长的对象;
  • 永久代 / 元空间(Metaspace):存放类元数据(方法、静态变量等)。

新生代结构

新生代又细分为三块:

  • Eden 区:新对象优先分配在这里;
  • Survivor0(S0)和 Survivor1(S1):两个 Survivor 区轮流使用。

GC 时,Eden 区中还活着的对象会被复制到一个 Survivor 区,
当对象在多次 GC 后仍然存活,就会晋升(Promote)到老年代。
新生代一般采用 复制算法(Copying GC),清理速度快、内存碎片少,适合短生命周期对象。

老年代的作用

老年代用于存放长期存活或较大对象,GC 频率较低。
通常采用 标记-清除(Mark-Sweep) 或 标记-整理(Mark-Compact) 算法。
这类算法能最大限度地回收空间,但执行时间较长。

小结:

Java 把堆分成新生代和老年代是为了提升 GC 效率
因为大部分对象‘朝生夕死’,只活一会儿,就放在新生代,用轻量算法快速清理。
而少量长期存在的对象,比如缓存和单例,会晋升到老年代,用更稳定的标记清除算法处理。
这样既减少扫描范围,又能让 GC 更高效。

为什么 Java 8 移除了永久代(PermGen)并引入了元空间(Metaspace)?

Java 8 移除永久代(PermGen),引入元空间(Metaspace),
是为了解决PermGen 固定大小、易导致 OOM、GC 效率低等问题,让类元数据的存储更灵活高效。

PermGen 的问题与局限

永久代是 HotSpot 虚拟机在 Java 7 及之前版本中用于存储类的元信息(类名、方法、常量池等) 的一块 JVM 内存区域。
但这种设计存在不少缺:

  • 固定大小,不可动态扩展
    • 永久代的大小需要在 JVM 启动时固定设置(通过 -XX:MaxPermSize),如果类加载过多(如动态加载、反射、大量 JSP),很容易触发 OutOfMemoryError: PermGen space。
  • 存储内容复杂,容易混乱
    • PermGen 既保存类信息,又存储静态变量、常量池等数据,不仅容易造成空间竞争,还让 GC 难以优化。
  • GC 效率低
    • 永久代的数据大多是长期存在的类元信息,GC 很难高效回收,容易频繁触发 Full GC,尤其在应用频繁加载类或热部署时问题更明显。

一句话总结 PermGen 的痛点:固定大小 + 内存受限 + GC 慢 + 容易 OOM。

Metaspace 的改进与优势

  1. 使用本地内存(Native Memory)
    元空间不再使用 JVM 堆内存,而是使用本地内存
  • 理论上只受操作系统内存限制,更灵活、更不容易 OOM。
  1. 自动扩容
  • 元空间可以根据类加载的实际需求自动增长大小,减少了手动设置内存的麻烦,同时降低了内存管理风险。
  1. GC 效率更高
    元空间中的类元数据与堆分离,GC 在扫描堆对象时更轻量,同时降低 Full GC 频率,提高 JVM 性能。

  2. 跨平台与统一性
    HotSpot 与 JRockit 在此之前实现不同,Java 8 将两者合并时选择了移除 PermGen,统一为 Metaspace。

如何调整元空间大小

虽然元空间可动态增长,但仍可通过参数手动控制:

1
2
-XX:MetaspaceSize=128M       # 元空间初始大小
-XX:MaxMetaspaceSize=512M # 元空间最大上限

表格对比

对比项 永久代(PermGen) 元空间(Metaspace)
存储位置 JVM 堆内存 本地内存(Native)
是否可扩展 否,大小固定 是,可根据需要自动扩展
常见问题 容易 OOM、GC 低效 更灵活,OOM 几率更低
JVM 版本 Java 7 及之前 Java 8 及之后

小结

Java 8 把永久代移除掉,换成元空间,主要是为了解决内存固定、OOM 和 GC 效率低的问题
永久代的大小是固定的,类加载多了就容易溢出;
而元空间用的是本地内存,能根据需要自动扩展,管理更灵活,性能也更好。
同时它还降低了 Full GC 触发的概率,整体上让 JVM 的内存管理更稳定。

为什么 Java 新生代被划分为 S0、S1 和 Eden 区?

Java 把新生代划分为 Eden、S0(Survivor 0)和 S1(Survivor 1) 三个区,
核心目的是:提高内存利用率,配合复制算法实现高效的 Minor GC(年轻代垃圾回收)。

为什么要分区?

在 JVM 的新生代中,大多数对象“朝生夕死”,即生命周期很短。
JVM 使用 复制算法(Copying GC) 来高效清理这部分内存。
复制算法的基本思想是:

  • 每次只使用一半内存,当发生 GC 时,把存活的对象复制到另一半,然后一次性清理原区域。

这个方法非常高效,但有个问题:
如果只用两块区域,一块存放对象,一块作为复制目标,那每次只有 50% 的内存被使用,利用率太低
于是,Java 采用了“三分法”:Eden + Survivor0(S0)+ Survivor1(S1),在保证复制安全的前提下,把内存利用率从 50% 提高到了 90%

分区结构与比例

默认情况下,新生代的比例为:Eden : S0 : S1 = 8 : 1 : 1
也就是说:

  • Eden 占新生代的约 80%;
  • 两个 Survivor 区各占约 10%。
  • 内存利用率 = 90%,因为只有一个 Survivor 是备用区。

GC时的工作流程

  1. 新对象首先分配在 Eden 区。
  2. 当 Eden 区满时触发 Minor GC:
  • JVM 会把 Eden + 正在使用的 Survivor 区 中还存活的对象复制到另一个空闲的 Survivor 区。
  • Eden 和旧 Survivor 区会被清空。
  1. 两个 Survivor 区轮流使用(S0↔S1),达到“翻转”效果。
  2. 当对象在 Survivor 区经历多次 GC 后仍然存活,会晋升到 老年代(Old Gen)。

整个过程称为 复制-清空-切换(Copy-Clear-Swap),保证了:

  • 快速分配(因为 Eden 连续分配,指针移动即可);
  • 无内存碎片(复制算法本身保证连续性);
  • 高内存利用率(90% 使用率)。

如果 Survivor 放不下怎么办?

当 Survivor 空间不足时,幸存的对象会直接晋升到老年代
这就是所谓的“提前晋升(Premature Promotion)”,
频繁出现会导致 老年代压力增加、触发 Full GC,所以 Survivor 的合理大小非常重要。

小结

新生代分成 Eden、S0 和 S1,是为了配合复制算法优化内存利用率
对象先放在 Eden,GC 时把活着的对象复制到另一个 Survivor 区,两个 Survivor 轮流用。
这样只需要一块备用空间,就能让内存利用率达到 90%,同时避免内存碎片。
简单说,就是为了让年轻代的 GC 又快又省空间。

什么是三色标记算法?

三色标记算法是 JVM 并发垃圾回收中用来区分对象状态的算法。
它把对象分成三种颜色:白色表示没访问过、可能是垃圾;灰色表示正在扫描;黑色表示已经扫描完成的活对象
GC 从根对象开始,把引用的对象标记成灰色,然后逐步转黑,最后剩下的白色对象就会被清除。
具体流程:

  1. 所有对象初始为白色。
  2. 根对象被标为灰色 → 遍历它的引用。
  3. 遍历时发现其他对象 → 标为灰色。
  4. 被完全扫描完的灰色 → 转为黑色。
  5. 最终白色的对象无人引用 → GC 清除。
  • 这个过程循环往复,直到堆中只剩黑色对象。
    这种方式可以让 GC 和应用线程同时工作,减少停顿时间,在 CMS 和 G1 收集器里都用了它。

三色标记中可能出现漏标问题和多标问题

  • 漏标问题,就是说一个对象本来应该是黑色存活对象,但是没有被正确的标记上,导致被错误的垃圾回收掉了。
  • 多标:就是这个对象原本应该被回收掉的白色对象,但是被错误的标记成了黑色的存活对象。从而导致这个对象没有被GC回收掉。
    • 多标的话,会产生浮动垃圾,这个问题一般都不太需要解决,因为这种垃圾一般都不会太多,另外在下一次GC的时候也都能被回收掉。

漏标问题解决方法:
具体的解决方式,在CMS和G1中也不太一样。CMS采用的是增量更新方案,G1则采用的是原始快照的方案。

Java 中的 young GC、old GC、full GC 和 mixed GC 的区别是什么?

Java 中的 GC 根据回收范围不同,可以分为 Young GC(Minor GC)、Old GC(Major GC)、Full GC 和 Mixed GC。
它们的区别主要在于:回收区域不同、触发条件不同、执行代价不同

Young GC(年轻代垃圾回收)又称Minor GC 或 YGC

  • 作用范围:
    • 只回收 新生代(Eden + Survivor0 + Survivor1) 的对象。
  • 触发条件:
    • 当 Eden 区空间满了,无法再为新对象分配内存时触发。
  • 执行特性:
    • 新生代对象“朝生夕死”,大多数是短命对象;
    • 回收速度快,采用复制算法(Copying GC);
    • 回收频率高但耗时短。

一句话总结:Young GC 就像清理临时文件,清得勤但快。

Old GC(老年代垃圾回收)又称Major GC 或 OGC

  • 作用范围:
    • 只回收 老年代(Old Generation)。
  • 触发条件:
    • 老年代空间不足;
    • 或新生代晋升的对象太多,挤爆老年代。
  • 执行特性:
    • 对象存活时间长,数量多;
    • 通常使用 标记-清除(Mark-Sweep)或标记-整理(Mark-Compact)算法;
    • 执行频率低,但时间更长。

一句话总结:Old GC 是大扫除,执行慢,但必要时得做

Full GC(全堆垃圾回收)

  • 作用范围:
    • 同时回收 新生代 + 老年代 + 方法区(Metaspace)。
  • 触发条件:
    • 老年代空间不足,且 old GC 后仍无法释放足够内存;
    • 永久代或者元空间不足;
    • 手动调用 System.gc();
    • 年轻代晋升到老年代失败。
  • 执行特性:
    • 会暂停所有应用线程(Stop-The-World);
    • 耗时最长,对系统性能影响最大;
    • 通常需要尽量避免。
      一句话总结:Full GC 是“系统级停机维护”,代价最高,非不得已别触发。

Mixed GC(混合垃圾回收)

  • 出现版本:
    • 仅存在于 G1(Garbage First)收集器 中。
  • 作用范围:
    • 同时回收 整个新生代 + 部分老年代(不是全老年代)。
  • 触发条件:
    • G1 在执行一定次数的 Young GC 后,老年代占用比例达到阈值(默认约 45%)时触发。
    • 执行特性:
    • 回收效率高;
    • 通过选取“回收收益高的 Region”进行混合回收;
    • 可控制停顿时间,减少 全堆垃圾回收 频率。

一句话总结:Mixed GC 是 G1 的“智能混合回收”,在性能与停顿之间做平衡。

总结表格

GC 类型 回收范围 触发条件 执行频率 影响程度
Young GC 新生代 Eden 区满
Old GC 老年代 老年代满 / 晋升太多
Full GC 新生代 + 老年代 + 元空间 堆内存或元空间不足 / System.gc()
Mixed GC 新生代 + 部分老年代(G1) G1 老年代占用率超阈值 中偏低

小结

Java 的 GC 主要分四种:

  • Young GC 回收新生代,触发快、耗时短;
  • Old GC 回收老年代,频率低但耗时长;
  • Full GC 是全堆回收,最耗时,一般要尽量避免;
  • Mixed GC 是 G1 独有的,它会同时回收新生代和部分老年代,在性能和停顿时间之间取得平衡。

什么条件会触发 Java 的 young GC?

  • 当 Eden 区空间满了
  • 无法再为新对象分配内存时
  • 部分垃圾回收器在Full GC之前先youngGC

什么情况下会触发 Java 的 Full GC?

  • 老年代空间不足,且 old GC 后仍无法释放足够内存;
  • 永久代或者元空间不足;
  • 手动调用 System.gc();
  • 年轻代晋升到老年代失败。

什么是 Java 的 PLAB?

PLAB 是 JVM 在垃圾回收时的一种优化机制,主要用在对象从新生代晋升到老年代的过程中
GC 是多线程执行的,为了避免多个线程同时去申请老年代空间而竞争,JVM 给每个 GC 线程分配了一块本地缓冲区,也就是 PLAB。
线程在晋升对象时直接在自己的 PLAB 中分配,速度更快,也避免了锁冲突。

TLAB vs PLAB

简单来说,TLAB 是运行时线程分配对象的缓冲区,加快对象创建。PLAB 是 GC 时晋升对象的缓冲区,加快垃圾回收。

对比项 TLAB PLAB
所属阶段 正常运行时 垃圾回收时
使用线程 应用线程(App Thread) GC 工作线程
分配区域 新生代(Eden 区) 老年代(Old Generation)
作用目标 加速普通对象的创建 加速对象晋升的分配
优化点 避免线程同步 避免 GC 线程竞争

JVM 垃圾回收时产生的 concurrent mode failure 的原因是什么?

Concurrent Mode Failure(并发模式失败) 是在使用 CMS 垃圾回收器 时的一种失败现象
它表示 CMS 在并发回收阶段没来得及回收出足够的老年代空间
导致新对象或晋升对象无法分配,从而 退化为 Full GC,造成明显的性能抖动。

可以总结为一句话:

  • “CMS 回收太慢,程序分配太快,老年代撑不住了。”

更细地说主要有以下几类情况:

  1. 老年代空间不足
  • 当 CMS 正在并发回收时,如果老年代中没有足够的可用空间分配新对象或晋升对象,就会触发 concurrent mode failure。
  1. 对象晋升过快(晋升风暴)
  • 年轻代晋升对象太多、太频繁,老年代来不及回收,空间被迅速吃光。
  1. 长生命周期对象过多
  • 应用中存在大量长期存活的对象(缓存、连接池等),占满老年代,回收压力持续存在。
  1. CMS 触发太晚
  • CMS 默认在老年代使用率达到约 92% 才启动(由参数XX:CMSInitiatingOccupancyFraction 控制),启动太迟可能回收不及,从而失败。
  1. 参数配置不合理 / 碎片过多
  • 老年代空间碎片化严重(CMS 是标记-清除算法),可用内存虽多但无法连续分配大对象。

CMS 的工作原理简述

CMS 的工作流程分为四个阶段:

  1. 初始标记(Initial Mark):标记与 GC Roots 直接关联的对象。
  2. 并发标记(Concurrent Mark):和应用线程并行,标记可达对象。
  3. 重新标记(Remark):修正上一步漏标或错标的对象。
  4. 并发清除(Concurrent Sweep):清理垃圾对象空间,与应用线程并发执行。

在并发清除阶段应用线程仍在运行,如果这时分配需求过高或清理不及时,就容易触发 concurrent mode failure。

常见的优化措施

  1. 提早触发 CMS 回收
  • 通过降低参数 -XX:CMSInitiatingOccupancyFraction(例如 70),
  • 让 CMS 更早开始回收,留出充足的缓冲时间。
  1. 增加老年代空间
  • 通过调大 -Xmx 或调整 -XX:NewRatio,为老年代分配更多空间。
  1. 减少晋升压力
  • 调大年轻代空间(减少对象晋升频率);
  • 优化对象生命周期,减少短命对象进入老年代。
  1. 降低碎片影响
  • 开启 -XX:+UseCMSCompactAtFullCollection,在 Full GC 时整理碎片;
  • 或使用更现代的收集器(如 G1)。
  1. 监控与调优
  • 通过 jstat -gcutil、-XX:+PrintGCDetails 等参数观察 CMS 触发频率和老年代使用情况。

小结:

Concurrent Mode Failure 是在使用 CMS 垃圾回收器时,老年代在并发回收阶段来不及回收出足够的空间导致新对象或晋升对象无法分配,只能退化执行 Full GC 的情况

  • 常见原因包括老年代空间太小、晋升太快、CMS 触发太晚或内存碎片严重
  • 优化可以通过提前触发 CMS、加大老年代空间、减少晋升频率或开启内存压缩来避免。

为什么 Java 中 CMS 垃圾收集器在发生 Concurrent Mode Failure 时的 Full GC 是单线程的?

因为 CMS 在设计上只负责并发回收老年代它的备用 Full GC 是由 Serial Old 收集器 执行的
Serial Old 是单线程的,所以当 CMS 发生 Concurrent Mode Failure 时,JVM 会退化到单线程 Full GC。
这主要是历史原因:CMS 最初是为低延迟设计的,不追求并行性能,而后来开发资源又转向了 G1,因此没有为 CMS 的 Full GC 实现多线程版本。

为什么 Java 中某些新生代和老年代的垃圾收集器不能组合使用?比如 ParNew 和 Parallel Old

不同的 GC 不能随意组合,是因为它们的底层框架和设计目标不同
比如 ParNew 属于老的 GC 框架,而 Parallel Old 属于新的 Parallel 框架,
两者的数据结构、线程模型和内存管理逻辑都不兼容。

  • 就像汽车组装,ParNew 是日系引擎接口,Parallel Old 是美系变速箱接口,
    连接口规格不同,插上去也跑不了。

ParNew 只能和 CMS 或 Serial Old 搭配,而 Parallel Scavenge 只能和 Parallel Old 一起用。
强行组合不仅会性能下降,还可能导致内存布局和晋升逻辑出错

JVM 新生代垃圾回收如何避免全堆扫描?

新生代 GC 之所以不会“误删”被老年代引用的新生代对象,
是因为 JVM 使用了 写屏障(Write Barrier) + 卡表(Card Table) 两个机制来跟踪这种引用关系。
这样在 Minor GC 时,只需扫描标记过的“脏卡”,而不是整个老年代。

为什么会有“跨代引用”问题?

当 JVM 做 Minor GC(回收新生代)时,只扫描新生代对象。但如果有一个老年代对象 A 引用了新生代对象 B,而 GC 只扫描新生代范围,就可能误以为 B 没被引用,错误回收

解决方案:卡表(Card Table)+ 写屏障(Write Barrier)

JVM 为了解决这个问题,引入了两个关键机制

  1. 写屏障(Write Barrier)
  • 是在“引用写入操作”时触发的一段特殊逻辑。
  • 当老年代对象要引用一个新生代对象时,写屏障机制会自动记录这次跨代引用。
  • 类似 Spring AOP 的“切面”,在赋值语句时自动插入监控逻辑。
    例如:
1
oldObj.field = youngObj; 

执行这行代码时,写屏障会被触发,
告诉 JVM:“兄弟,这个老年代对象引用了一个新生代对象。”

  1. 卡表机制(Card Table)
  • JVM 将老年代的内存划分成许多小区域(称为卡页,Card), 每个卡页一般对应 512 字节
  • 当某个卡页内的对象引用了新生代对象,JVM 会把该卡页对应的卡表项标记为“脏卡(Dirty Card)”。

所以:

  • “卡表”其实是一个字节数组;
  • 0 表示“干净”(没跨代引用),非 0 表示“脏”(存在跨代引用)。

Minor GC 时的工作流程

当触发 Minor GC 时:

  1. 扫描卡表,找到所有标记为“脏卡”的老年代区域。
  2. 只扫描这些脏卡中的对象,检查是否引用了新生代对象。
  3. 把这些被引用的新生代对象标记为存活对象,
    确保它们不会被错误回收。

因此:

JVM 不需要扫描整个老年代,只需关注少量脏卡,大大提高了 GC 效率,同时避免了“误删对象”的风险。

机制 作用
写屏障 拦截引用写操作,检测跨代引用发生的时刻
卡表 记录老年代哪些内存区域存在跨代引用
Minor GC 时扫描脏卡 保证被老年代引用的新生代对象不会被错误回收

优点:

  • 避免全堆扫描
  • 减少 GC 停顿时间
  • 确保引用正确性

小结

新生代 GC 之所以不会误删被老年代引用的对象,是因为 JVM 在对象赋值时会触发 写屏障机制,自动记录老年代引用新生代的情况。

JVM 会把这些内存区域在 卡表 中标记为‘脏卡’,下次 Minor GC 时只需扫描这些脏卡,就能找到所有跨代引用,避免错误回收并提高效率。

Java 的 CMS 垃圾回收器和 G1 垃圾回收器在记忆集的维护上有什么不同?

CMS 和 G1 都需要记住跨代引用(防止老年代引用新生代对象被误回收),
但它们在“记忆集”的实现方式和粒度上完全不同:

  • CMS 用卡表(Card Table)实现,粒度粗,结构简单
  • G1 用记忆集(Remembered Set, RSet),粒度细到 Region 级,更精确但更复杂

CMS 的记忆集机制
CMS 使用 卡表(Card Table) 来记录跨代引用,
其实它就是一种“简化版的记忆集”,实现思路如下:

  1. 原理:
    当老年代对象引用了新生代对象时,触发 写屏障(Write Barrier)。
    JVM 会将老年代中对应的卡页(Card)标记为“脏卡(Dirty Card)”。

  2. 数据结构:

  • 每个卡页对应约 512 字节的老年代内存;
  • 卡表中一个字节标记一个卡页是否含有跨代引用。
  1. GC 时行为:
    Minor GC 扫描卡表中被标记为“脏卡”的部分,
    只扫描这些区域,而不是整个老年代,从而避免全堆扫描。

  2. 特点总结:
    优点 实现简单,开销小;
    缺点 粒度粗,一张“脏卡”里可能包含多个无关对象,精度不高。

G1 的记忆集机制

G1 的设计更复杂,它采用真正意义上的多层记忆集(Remembered Set, RSet)。

  1. 原理:
  • G1 把堆划分为多个小的 Region(一般几 MB)。
  • 每个 Region 都有自己的记忆集,用来记录“哪些其他 Region 中的对象引用了我”。
  • 当某个对象在不同 Region 间发生引用时,写屏障会更新对应的 RSet。
  1. 数据结构:
  • 每个 RSet 里维护的是一组“卡表条目(Card Table Entry)”,它指向引用该 Region 的其他 Region 的具体卡页。
  • 这是一种“Region 粒度”的多对多关系映射。
  1. GC 时行为:
  • G1 只扫描与目标 Region 的 RSet 有关联的 Region,这样可以做到非常精确的增量式回收。
  1. 特点总结:
  • 优点:
    • 粒度细、定位精准;
    • 支持 Region 之间的并发回收;
  • 缺点:
    • 维护成本更高,内存与 CPU 开销更大。

对比总结

对比项 CMS G1
实现形式 卡表(Card Table) 记忆集(Remembered Set)
跟踪范围 老年代 → 新生代 Region ↔ Region(任意方向)
粒度 卡页(约 512B) Region 内卡页级(更细)
精确度 粗略 精确
维护开销
优势 简单、低延迟 可控回收、分区化并发效率高

小结

CMS 用卡表来实现记忆集,只记录老年代引用新生代的情况,通过写屏障把对应区域标记为‘脏卡’,GC 时扫描这些脏卡即可,结构简单但粒度较粗。
而 G1 的记忆集是以 Region 为粒度的,每个 Region 都维护一个自己的 RSet,记录哪些其他 Region 有引用它,粒度更细更精准,
适合 G1 这种分区化、并发回收的设计,只是维护成本更高。

为什么 G1 垃圾收集器不维护年轻代到老年代的记忆集?

G1 不维护年轻代到老年代的记忆集,因为年轻代每次 GC 都是整区回收,
所有年轻代 Region 都会被扫描,根本不需要额外记录年轻代引用老年代的对象。
而老年代引用年轻代的情况才需要维护记忆集,否则年轻代 GC 时可能误删对象。

Java 中的 CMS 和 G1 垃圾收集器如何维持并发的正确性?

CMS 和 G1 在并发标记时都会遇到“引用关系可能变化”的问题,
为保证标记的正确性:

  • CMS 使用「增量更新(Incremental Update)」机制
  • G1 使用「SATB(Snapshot At The Beginning)」机制

这两种机制的目的,都是在“应用线程继续运行、对象引用不断变化”时,
仍能保证 GC 标记结果的正确性。

为什么需要并发正确性机制?

在并发标记阶段,应用线程(Mutator)仍在运行,
对象之间的引用关系会持续变化,比如:

  • 对象 A 删除了对 B 的引用
  • 对象 C 新增了对 D 的引用

这会导致标记线程和应用线程看到的“对象图”不一致,
如果不处理,就可能:

  • 误删仍被引用的对象(错误回收)
  • 或保留已经无用的对象(浮动垃圾)

所以 CMS 和 G1 都必须用特殊机制来记录这些变化。

CMS:增量更新(Incremental Update)

机制原理

当一个“已标记的黑色对象”向“未标记的白色对象”建立引用时,CMS 会立刻把这个白色对象标记成灰色(表示后续还要扫描它)。

这相当于:

  • “黑指白时,不让它漏标”

核心思想:补充新增的引用关系。也就是说,只要有新的引用产生,CMS 会通过写屏障立即更新标记状态。

实现方式

  • CMS 使用 写屏障(Write Barrier) 拦截引用更新操作。
  • 当检测到黑对象引用白对象时,就把白对象加入重新扫描队列。
  • 在 remark 阶段会重新检查这些对象,确保不会漏标。

优缺点

  • 优点 能保证标记阶段的完整性;
  • 缺点 但容易产生“浮动垃圾”——即在 GC 标记后新创建但未扫描的对象。

G1:SATB(Snapshot At The Beginning)

机制原理

SATB(Snapshot At The Beginning) 的思路是:
“在并发标记开始那一刻,拍下堆的快照,哪怕引用关系之后改变,也以快照时的状态为准。”
也就是说:

  • 标记开始时认为“当时存活的对象都是活的”;
  • 即使之后引用被删除,也不会立刻当作垃圾。

核心思想:保留旧的引用关系,防止误删对象。

实现方式

  • 在写屏障中,当对象引用被删除时,会把“被删除的旧引用”记录下来。
  • 后续 GC 扫描这些记录,确保被删除的对象仍被视为“存活”。

G1 的实现细节

G1 在每个 Region 里维护两个指针:

  • prevTAMS(上一次标记的起点)
  • nextTAMS(本次标记的起点)

在标记开始时,prevTAMS 到 Top 之间的对象被认为是存活的,
相当于捕捉了堆的一份“快照”。

优缺点

  • 优点标记更精确,避免误删;
  • 缺点可能暂时保留一些“实际上已无用”的对象(也就是浮动垃圾)。
对比项 CMS(增量更新) G1(SATB)
核心思路 更新新引用关系 保留旧引用关系
触发时机 新引用创建时 引用删除时
写屏障作用 记录新增引用 记录被删除引用
优点 不漏标新引用 不误删旧对象
缺点 会留下浮动垃圾 也会保留浮动垃圾,但控制更好

小结

在并发标记阶段,对象引用关系可能不断变化。
为了保证标记结果的正确性,CMS 用的是 增量更新,它会在引用新增时立刻标记新对象,防止漏标。
G1 用的是 SATB(快照在最初),在标记开始时拍个快照,认为当时活着的对象都是存活的,
即使引用被删除也不会误删。
简单来说,CMS 记录新增的引用,G1 记录被删的引用
两者目的都是保证并发标记阶段的正确性

Java G1 相对于 CMS 有哪些进步的地方?

G1 是 CMS 的“下一代垃圾回收器”,
它在 内存布局、回收算法、停顿控制大对象优化 等方面都有明显进步,
目标是:更可控、更高效、更适合多核和大内存环境。

从内存管理机制来看

CMS 是传统的“分区制”,而 G1 把堆打碎成很多小块(Region),动态调整哪些块回收哪些保留,内存使用更灵活,也更容易做并行优化。

从回收算法来看

CMS 标记-清除算法 清完垃圾后容易留下碎片,就像地板上扫完灰但还乱,
而 G1 标记-整理算法 会顺便把对象挤一挤,像打包箱子那样整齐,所以几乎不会出现碎片问题。

从停顿时间来看

CMS 的停顿不可控,有时候很短,有时候就卡几秒。
而 G1 可以设定“停顿目标”,GC 会自动规划任务,尽量让回收既高效又平稳。

从大对象与跨代引用优化来看

CMS 对大对象处理比较笨,可能直接塞进老年代,容易导致 老年代空间紧张、甚至 Full GC
而 G1 是分块管理的,大对象单独放一个 Region,管理起来更灵活,不容易挤爆老年代。

额外补充

  • G1 不仅负责老年代,也能回收新生代,是一个 统一的垃圾收集器(不像 CMS 还要配合 ParNew)。
  • G1 的并行和并发设计更现代,在多核、大内存服务器上表现更稳定。

小结

G1 相比 CMS 的最大进步有四点:
第一,G1 把堆划成很多小块(Region),内存更灵活不易碎片化;
第二,CMS 用标记清除,而 G1 用标记整理,几乎不会有内存碎片;
第三,G1 可以设置停顿目标,GC 会自动调度,用户体验更平滑;
最后,G1 对大对象和跨代引用处理更智能,也不需要搭配 ParNew,整体更适合大内存和多核环境。

什么是 Java 中的 logging write barrier?

Logging Write Barrier(日志写屏障) 是一种在 Java GC 中用来记录对象引用变化的写屏障机制
它能让垃圾收集器在应用线程继续运行时,依然感知哪些引用被修改或删除,
从而保证并发标记的正确性
(特别是 G1 的 SATB 和记忆集维护)。

先理解写屏障(Write Barrier)

写屏障其实是一段 由 JVM 在引用赋值操作时插入的小代码
当对象的引用字段发生改变(比如 a.field = b)时,写屏障就会被触发,执行一些额外逻辑。

常见的两种类型是:

  • Pre-Write Barrier(写前屏障):在引用被覆盖前触发(记录旧值)
  • Post-Write Barrier(写后屏障):在引用写入后触发(记录新值)

Logging Write Barrier 是什么

Logging Write Barrier 本质上属于 “Post-Write Barrier” 的一种实现形式。
它的核心思想是:

  • 每当引用变化时,把变化记录(log)下来,而不是立刻处理。”

这些变化日志(log entries)会被放入专用队列(如 SATBMarkQueue),
等到合适时机再由 GC 线程批量处理。

这样做的好处是:

  • 避免频繁打断应用线程
  • 降低同步开销
  • 保证并发标记时引用关系的正确性

Logging Write Barrier 的工作原理

在 G1 的 SATB 模式下,当对象引用被覆盖(即旧引用要被删掉)时:

  1. 触发写前屏障(Pre-Write Barrier)
  2. JVM 检查当前是否处于并发标记阶段(mark_queue_set().is_active())
  3. 如果是,就把“旧引用对象”入队到 SATBMarkQueue
  4. 后续由 GC 线程异步扫描这些队列,确保被删除的对象仍能被正确标记

换句话说:
即使应用线程删除了某个对象引用,GC 也能通过日志知道“它原来是被引用过的”,
从而避免错误回收。

在不同 GC 中的用途

垃圾收集器 写屏障用途 说明
G1 GC 记录对象引用变化,用于 SATB 快照机制记忆集(RSet)更新 确保跨 Region 引用关系正确
CMS GC 用于 增量更新(Incremental Update),标记新增引用 防止漏标新引用对象
Shenandoah / ZGC 用于 读屏障(Read Barrier),配合指针重定位 保证并发压缩时引用有效性

Logging Write Barrier 的意义

它解决了两个问题:
1.GC 与应用线程并发运行时,如何同步引用变化?

  • 通过日志异步记录,避免扫描全堆。

2.如何保证标记结果不出错?

  • 在 SATB 中保存旧引用,防止误删对象;
    在增量更新中记录新引用,防止漏标对象。

小结
Logging Write Barrier 是 GC 用来记录对象引用变化的机制,它会在引用写入时,把旧或新的引用信息记录下来放入日志队列,比如 SATBMarkQueue。
这样垃圾收集器在并发标记时,就能知道哪些对象引用被修改或删除,避免误删或漏标。
像 G1 用它来维护 SATB 快照和记忆集,CMS 用它来做增量更新,既保证正确性,又减少同步开销。

Java 的 G1 垃圾回收流程是怎样的?

G1 垃圾回收器的整个流程可以分为两个大阶段:

  • 并发标记(Concurrent Marking)
  • 对象拷贝与整理(Evacuation)
    其中标记阶段又包含 初始标记 → 并发标记 → 最终标记 → 清理四个子步骤。

简单说,G1 先“找出该回收的垃圾”,再“把存活的对象搬走”,实现高效、可控的 GC

一、初始标记(Initial Mark,STW)

  • 首先会暂停所有用户线程(Stop The World);
  • 从 GC Root 出发,标记第一层直接可达的对象;
  • 因为只标记少量对象,停顿时间非常短;
  • 这一步属于 SATB(Snapshot At The Beginning) 的起点阶段。

这一步就像拍一张快照,先把根对象能直接触及的那一层标记起来,速度很快,停顿也短。

二、并发标记(Concurrent Mark)

  • 与应用线程并发进行,不会完全停顿;
  • 从前面标记到的灰色对象开始,继续追踪并标记它们引用到的对象;
  • 会标记出整个堆中所有可达的对象;
  • 由于应用线程此时还在运行,引用关系可能变化,G1 使用 SATB 技术 保证正确性(在标记开始时拍快照,后续引用变化用日志记录)。

这阶段是“大规模扫图”,GC 和程序一起跑;如果应用代码在过程中修改了引用,G1 用 SATB 机制保证不会漏标对象。

三、最终标记(Final Remark,STW)

  • 再次短暂停顿应用线程;
  • 扫描并处理并发阶段残留的 SATB 队列,
  • 把新创建或删除的引用关系补充上;
  • 确保标记阶段的结果完全准确。

这一步相当于“复核”,GC 停下来把并发阶段遗留的小细节都补上,保证标记结果没问题。

四、清理阶段(Cleanup,STW)

  • 检查各个 Region 的存活对象数量;
  • 对没有存活对象的 Region 进行释放;
  • 更新统计信息,为下一次回收做准备。

就像打扫完屋子后清点一下哪些房间是空的,空房间就直接回收释放。

五、对象拷贝 / 转移(Evacuation,STW)

  • 这一阶段也是 STW(停顿的);
  • 根据前面标记的结果,挑选一批“垃圾多、收益高”的 Region 作为回收目标集合(CSet,Collection Set);
  • 把这些 Region 里的存活对象复制到新的 Region 中;
  • 同时更新引用关系和卡表(Card Table);
  • 完成后释放原有的 Region 空间。

最后这一步是“搬家”——GC 把活着的对象搬到新区域,把旧区的垃圾一起清掉,这样堆空间就重新整理干净了。

G1 的并发调优参数

参数 作用
-XX:MaxGCPauseMillis=<n> 设置最大停顿时间目标(比如 200ms),G1 会自动调整每次回收的工作量来满足这个目标。
-XX:G1HeapRegionSize=<size> 控制 Region 大小(默认 1MB~32MB),影响内存划分与扫描效率。
-XX:InitiatingHeapOccupancyPercent=<p> 设置老年代触发并发标记的阈值(默认 45%)。
-XX:G1MixedGCCountTarget=<n> 控制 mixed GC 阶段一次混合回收多少个 Region。

小结:
G1 的垃圾回收过程分为五步:
先是 初始标记,快速标记 GC Roots 可达对象;
然后 并发标记,和程序一起运行,用 SATB 机制保证引用变化不出错;
接着 最终标记,补上并发阶段遗漏的部分;
之后是 清理,回收空 Region;
最后是 对象转移,把活的对象搬到新区域、释放旧区空间。
整个过程既保证高吞吐量,又能控制停顿时间,非常适合大内存场景。

SATB
SATB 主要为了解决并发标记阶段可能产生的对象引用变化问题,SATB 宁可多标也不漏标,多标的大不了下一轮 GC 时再被回收(浮动垃圾),但漏标却会影响到程序的正常运行。

Java 的 CMS 垃圾回收流程是怎样的?

CMS 是一种以最小化停顿时间为目标老年代垃圾收集器
它通过“并发标记”和“并发清理”来减少 STW(Stop The World)时间,
整体流程可以分为 初始标记 → 并发标记 → 预清理 → 重新标记 → 并发清理 → 并发重置 六个主要阶段

一、初始标记(Initial Mark,STW)

  • 暂停所有用户线程(STW)。
  • 从 GC Roots 出发,标记直接可达的对象。
  • 只扫描第一层引用,因此非常快。

初始标记就是先从 GC Roots 开始,快速打个底,把能直接关联到的对象标记出来,这一步虽然是停顿的,但时间很短。

二、并发标记(Concurrent Marking)

  • 与应用线程并发执行,不会完全停顿。
  • 从初始标记的对象开始,遍历整个对象图,找出所有可达对象。
  • 这一阶段的标记可能会受应用线程继续运行影响(对象引用可能被修改)。

接下来进入并发标记阶段,GC 和应用线程一起跑,后台遍历堆里的对象图,把所有活对象都标出来。

三、预清理(Concurrent Preclean)

  • 仍是并发执行。
  • CMS 会处理在并发标记过程中,由于用户线程继续运行而新产生的引用变化。
  • 这一步的目的是尽量减少后续“重新标记”阶段的工作量。

因为标记过程中程序还在运行,可能又改了些引用,所以这一步会提前把新变化的引用先处理掉,让后面停顿更短。

四、重新标记(Remark,STW)

  • 再次进入停顿(STW)。
  • 扫描并修正并发阶段遗漏的标记信息,确保标记结果的准确性。
  • 通常会用 多线程并行 来加速。

这一步是“复核”阶段,GC 再次短暂停顿应用线程,把之前并发标记时漏掉的活对象都补标一下。

五、并发清理(Concurrent Sweep)

  • 开始真正清理垃圾对象,释放内存。
  • 清理过程与用户线程并发执行,不会停顿。
  • 因为 CMS 使用的是 标记-清除算法,所以不会做内存整理(会留下碎片)。

然后进入并发清理阶段,GC 在后台把标记为垃圾的对象清掉,这时程序照常运行。
不过 CMS 只是清理不会压缩,所以容易产生碎片。

六、并发重置(Concurrent Reset)

  • 清理完成后,CMS 会重置内部数据结构(如标记位图),为下一次 GC 做准备。
  • 同样是并发执行,不会造成停顿。

最后一步是收尾,清空标记信息,重置状态,为下一次回收做好准备。

CMS 的两个典型问题

  • 浮动垃圾(Floating Garbage)
    • 因为清理阶段是并发的,可能在清理期间又产生新的垃圾,这部分垃圾只能留到下次回收。
  • 内存碎片(Fragmentation)
    • CMS 使用“标记-清除算法”,不会整理堆空间,久而久之会产生碎片,导致Promotion Failed(对象无法晋升到老年代)。

优化方式:

  • -XX:+UseCMSCompactAtFullCollection → Full GC 时进行压缩。
  • -XX:CMSFullGCsBeforeCompaction → 每多少次 Full GC 后压缩一次。
  • -XX:CMSInitiatingOccupancyFraction= → 调低触发阈值,提前启动回收,防止碎片过多。

CMS 的缺点主要有两个:一个是清理时程序还在跑,会有浮动垃圾;
另一个是因为不压缩内存,会产生碎片,可能导致晋升失败。
可以通过参数配置,让它在 Full GC 时顺便压缩一下来解决。

小结:

CMS 的垃圾回收过程分六步:
先是初始标记,快速标记 GC Roots 直接关联的对象
接着并发标记,后台扫描整个对象图
然后预清理,处理新引用
再进入重新标记阶段,短暂停顿修正遗漏
接着并发清理,真正删除垃圾对象
最后并发重置,为下次 GC 做准备
它的优点是停顿时间短,缺点是容易有浮动垃圾内存碎片

你了解 Java 的 ZGC(Z Garbage Collector)吗?

ZGC(Z Garbage Collector)是 Java 中的一款超低延迟、高可扩展性垃圾收集器
它的目标是:让 GC 停顿时间不超过 1ms,无论堆多大(可达 16TB)。
非常适合对响应时间要求极高的场景,比如金融交易系统、在线游戏、实时计算等。

ZGC 的核心特性

  • 低延迟:每次 Stop-The-World 停顿都小于 1ms。
  • 高并发:几乎所有阶段都是与应用线程并发执行。
  • 超大堆支持:支持从几百 MB 到 16TB 的堆内存。
  • 无碎片问题:采用并发压缩算法,回收后堆空间整洁无碎片。

ZGC 的目标就是让 GC 延迟小到可以忽略不计,不管你堆是 1G 还是 10TB,
都能在 1ms 内完成暂停,非常适合对延迟特别敏感的系统。

ZGC 的核心原理

ZGC 之所以能实现低延迟,关键在于两项技术:

  1. 读屏障(Load Barrier)
  • 当线程读取对象引用时,会经过一层“读屏障”检查。
  • 如果发现对象被移动过,屏障逻辑会自动更新引用地址,确保读到的是最新位置的对象。
  1. 着色指针(Colored Pointers)
  • ZGC 在 64 位指针中嵌入标记位(颜色位),用来记录对象的状态(是否标记、是否转移、是否重映射)。
  • 不再需要额外的标记表,提高效率。

G1 回收时对象转移必须停顿所有线程,而 ZGC 用读屏障 + 着色指针,让对象转移也能并发执行,这就是它能做到“超低停顿”的秘密。

ZGC流程

ZGC 的垃圾回收流程分为多个阶段,几乎都是并发完成的。

  1. 初始标记(STW)
  • 标记 GC Roots(线程栈、静态变量等)直接可达对象。
  • 时间极短(<1ms)。
  1. 并发标记
  • 遍历对象图,标记所有可达对象。
  • 用户线程在访问对象时如果发现对象未标记,会“顺带”帮忙标记(协同机制)。
  1. 并发处理
  • 决定哪些内存页(ZPage)需要转移。
  • 建立“旧地址 → 新地址”的映射表。
  1. 转移阶段(开始 + 并发)
  • 初始转移(STW):转移 GC Roots 直接引用的对象。
  • 并发转移:后台将其他对象复制到新内存页(ZPage),并更新映射关系。
  • 用户线程访问对象时,会通过读屏障自动更新引用地址。
  1. 第二次 GC(补全未完成转移)
    ZGC 使用“双标记位机制” (Marked0 / Marked1)
    区分当前和上次 GC 状态,保证不会重复转移或漏转。

  2. 并发问题处理机制

如果用户线程和 GC 线程同时尝试转移对象,ZGC 会通过映射表判断是否已存在转移结果,
若存在则放弃重复操作,避免冲突。

ZGC 的流程可以概括为:
“标记活对象 → 建立映射关系 → 并发搬家 → 用户线程自动帮忙修正引用”。
整个过程几乎没有明显停顿。

ZGC与 G1 的区别

对比点 G1 GC ZGC
转移过程 必须 STW(停顿) 可并发进行
屏障类型 写屏障(Write Barrier) 读屏障(Load Barrier)
碎片问题 可能有碎片 并发压缩,无碎片
停顿时间 10ms ~ 100ms < 1ms
堆大小支持 几 GB ~ 几十 GB 可达 16TB

简单说,G1 是“低延迟”,ZGC 是“超低延迟”。
它几乎把 GC 的所有步骤都做成并发了。

小结

ZGC 是 Java 的一款超低延迟垃圾回收器,能做到 GC 停顿不超过 1ms,支持从几百 MB 到 16TB 的堆内存。

它的核心是读屏障(Load Barrier)和着色指针(Colored Pointer),让对象转移过程也能并发执行,不用再 STW。

整个流程包括初始标记、并发标记、并发处理、并发转移和重映射,几乎全程并发,特别适合实时、高响应系统。

JVM 垃圾回收调优的主要目标是什么?

JVM 垃圾回收调优的主要目标有两个:

  • 最短暂停时间(低延迟)
  • 高吞吐量(高性能)

这两者是 GC 调优的核心方向,但通常是此消彼长的,需要根据业务场景权衡取舍。

  • 最短暂停时间的目标就是让 GC 尽快干完活,别拖慢应用。比如我们希望每次 GC 只暂停几毫秒,让用户几乎感受不到卡顿。
  • 高吞吐量的目标不是让 GC 更快结束,而是尽量少触发 GC,让 CPU 更多地去跑业务代码,而不是在收垃圾。

两者的权衡

  • 低延迟和高吞吐量往往难以兼得。
    • 比如:每次 GC 停顿 100ms、每秒 GC 5 次(响应好但频繁);
    • 或者每次 GC 停顿 200ms、每秒 GC 2 次(吞吐高但延迟更大)。
  • 所以,调优时必须明确你的应用更关注响应速度还是处理效率。

调优的时候要先想清楚目标。如果你是支付系统,就追求低延迟
如果是离线计算任务,就追求高吞吐。两者很难两全。

小结

JVM 垃圾回收调优的主要目标是两个:
一是减少 GC 停顿时间,提升系统的响应能力;
二是提高吞吐量,让程序在单位时间内处理更多业务。
一般低延迟系统更关注停顿时间,高并发或计算密集型系统更看重吞吐量,
两者通常需要平衡取舍。

如何对 Java 的垃圾回收进行调优?

Java 垃圾回收调优的核心目标是:

尽量让对象在年轻代就被回收掉,减少进入老年代的对象,从而降低 Full GC 的频率。

  • 目标一:降低 Full GC 次数,Full GC 的停顿时间长,对性能影响大。所以调优的关键是让尽可能多的对象在年轻代就被清理掉,让老年代空间保持充足,避免频繁 Full GC。
  • 目标二:提升年轻代回收效率,尽量让短命对象“生得快,死得也快”,在 Young GC 阶段直接回收,减少对象晋升到老年代的概率

常见调优方法

1.分析 GC 日志,找到瓶颈点
先别急着调参数,要先看 Young GC 和 Full GC 的触发频率、停顿时间、晋升速率、老年代使用量等关键指标。

2.调整年轻代与老年代的比例

  • 如果发现对象频繁晋升到老年代,就说明年轻代太小。
  • 可以适当增大 -Xmn 或者调大 SurvivorRatio,让更多对象在年轻代被回收。
    3.优化 Survivor 区大小
  • Survivor 太小会导致对象提前晋升老年代。
  • 适当增大 Survivor 区,让短命对象在新生代多活几次 GC,被及时清理掉。

4.避免频繁的 System.gc() 调用
有些第三方库可能会主动调用 System.gc() 导致频繁 Full GC。
可以通过参数 -XX:+DisableExplicitGC 禁用显式 GC 调用。

具体调优要看 GC 日志,
如果 Young GC 后老年代涨得快,就说明晋升太多,要调大新生代或 Survivor;
如果频繁 Full GC,就要看看是不是老年代空间太小或者有内存泄漏。
另外别忘了关掉那些偷偷调 System.gc() 的库。

举例

  • 情况一:Full GC 太频繁

    • 增大老年代空间;
    • 优化 Survivor,减少晋升。
  • 情况二:Young GC 频繁且耗时

    • 调整 Eden 与 Survivor 比例;
    • 调大新生代,让对象更集中回收。
  • 情况三:内存碎片或老年代膨胀

    • 考虑使用 G1 或 ZGC 这类具备压缩能力的收集器。

小结

调优垃圾回收的关键是让短命对象尽快在年轻代被清理掉,
减少老年代的压力,从而降低 Full GC 的频率。
一般我们会通过分析 GC 日志,观察 Young GC 和 Full GC 的次数、
晋升速率和内存占用,再去调整年轻代、Survivor 大小或者禁用 System.gc()。
核心思路其实就一句话:尽量在新生代把对象回收掉

常用的 JVM 配置参数有哪些?

类别 参数 作用说明 示例
内存相关参数 -Xms 设置初始堆内存大小 -Xms512m
-Xmx 设置最大堆内存大小 -Xmx2g
-Xmn 设置年轻代大小 -Xmn512m
-XX:NewRatio 设置年轻代与老年代的比例 -XX:NewRatio=2
-XX:SurvivorRatio 设置 Eden 区与 Survivor 区比例 -XX:SurvivorRatio=8
-XX:MetaspaceSize 元空间初始大小 -XX:MetaspaceSize=128m
-XX:MaxMetaspaceSize 元空间最大大小 -XX:MaxMetaspaceSize=512m
GC 相关参数 -XX:+UseG1GC 启用 G1 垃圾收集器
-XX:+UseConcMarkSweepGC 启用 CMS 垃圾收集器
-XX:+UseParallelGC 启用并行 GC
-XX:MaxGCPauseMillis 设置最大 GC 停顿时间目标 -XX:MaxGCPauseMillis=200
-XX:GCTimeRatio 设置 GC 时间与应用执行时间的比例 -XX:GCTimeRatio=9
性能调优参数 -XX:+AggressiveOpts 开启积极优化选项
-XX:+UseCompressedOops 启用压缩指针(节省内存)
-XX:+DoEscapeAnalysis 启用逃逸分析
-XX:+UseBiasedLocking 启用偏向锁,减少锁竞争
调试与监控参数 -XX:+HeapDumpOnOutOfMemoryError OOM 时生成堆转储文件
-XX:HeapDumpPath 指定堆转储文件路径 -XX:HeapDumpPath=/tmp/heap.hprof
-XX:+PrintGC 打印 GC 基本信息
-XX:+PrintGCDetails 打印详细 GC 日志
-Xloggc 设置 GC 日志输出路径 -Xloggc:/var/log/gc.log
其他常用参数 -Dfile.encoding 设置文件编码格式 -Dfile.encoding=UTF-8
-server 启用服务器模式(默认在 64 位 JDK)
-client 启用客户端模式
-XX:MaxDirectMemorySize 设置直接内存大小(影响 NIO) -XX:MaxDirectMemorySize=1g

在实际项目中,常见的 JVM 启动配置一般会包含以下几类参数组合:

类型 常用配置
基础内存设置 -Xms1g -Xmx2g -Xmn512m
垃圾回收设置 -XX:+UseG1GC -XX:MaxGCPauseMillis=200
性能优化 -XX:+UseCompressedOops -XX:+DoEscapeAnalysis -XX:+UseBiasedLocking
监控调试 -XX:+PrintGCDetails -Xloggc:/logs/gc.log -XX:+HeapDumpOnOutOfMemoryError
其他常用项 -Dfile.encoding=UTF-8 -XX:MaxDirectMemorySize=512m

JVM 参数主要分五大类:
内存分配、GC 策略、性能优化、监控调试、系统配置。

你常用哪些工具来分析 JVM 性能?

分析 JVM 性能常用的工具主要分为五类:
基础信息工具、内存与 GC 工具、线程分析工具、可视化工具、第三方监控分析工具

基础信息工具

工具 作用
jps 查看当前用户的 Java 进程,快速定位进程 PID。
jinfo 查看或修改 JVM 参数配置,适合排查参数设置问题。

“我一般先用 jps 查进程号,再用 jinfo 看看 JVM 参数,比如堆大小、GC 策略这些。”

内存与 GC 分析工具

工具 作用
jmap 导出堆快照 dump 文件,或直接查看堆内对象分布情况。
jstat 实时监控 GC、类加载、编译和内存使用等运行时指标。

“我常用 jstat 监控 GC 频率,看是否有 Full GC 过多的问题;
如果怀疑内存泄漏,就用 jmap dump 出堆文件,再用 MAT 分析。”

线程和锁分析工具

工具 作用
jstack 抓取线程堆栈信息,用来分析死锁、线程阻塞、高 CPU 占用等问题。

“应用 CPU 飙高或者响应慢时,我会先 jstack 看看是不是线程死锁或者有线程在 spin。”

可视化工具

工具 作用
jhat 简单的堆快照分析工具(功能较弱,推荐用 MAT 或 VisualVM)。
jconsole JDK 自带图形化监控工具,可查看内存、GC、线程和 CPU。
VisualVM 功能强大的可视化工具,支持实时监控、GC 统计、CPU 采样、dump 文件分析等。

“我平时调优时喜欢用 VisualVM,能直观看到堆和 GC 的变化,还能采样分析 CPU。”

第三方高级工具

工具 作用
Arthas 阿里开源的 Java 诊断神器,支持在线排查、查看类加载、监控方法调用和内存实时情况。
MAT(Memory Analyzer Tool) 深度分析堆转储文件,定位内存泄漏和大对象引用链。

“如果是线上问题,我一般直接上 Arthas,远程 attach 进程实时看。
如果是内存泄漏,就导出 dump 文件用 MAT 深挖引用链。”

常见问题与工具选择建议

问题类型 推荐工具
内存溢出(OOM) jmap + MAT
内存泄漏 jstat + jmap + MAT
GC 频繁 GC 日志 + jstat
线程死锁 jstack
CPU 飙高 top / ps + jstack
长期监控 Prometheus + Grafana

小结

我平时分析 JVM 性能问题主要分几类工具:
jps、jinfo 这种看基础信息的;
jmap、jstat 看内存和 GC;
jstack 查线程死锁或 CPU 问题;
可视化我用 VisualVM 或 jconsole;
线上排查就用 Arthas,离线分析用 MAT。
一般套路是:先 jps 定进程,再 jstat 看 GC,再根据问题用 jstack 或 jmap 定位。

如何在 Java 中进行内存泄漏分析?

面试官:“你知道如何解决内存泄漏问题吗?”

答:内存泄漏其实是指在 Java 中,有些对象在用完之后,虽然不再需要,但是它们还是被某些地方引用着,导致垃圾回收器无法回收这些对象,最终可能会导致内存溢出。

解决这个问题的步骤一般包括几个方面:

  1. 发现问题:

    • 首先,你可以通过监控内存的使用情况来发现问题。正常情况下,内存会随着程序的运行起伏,
      Minor GC 后内存会回落,但如果你看到内存持续增长,手动执行 FULL GC 后内存依然不下降,那就有可能是内存泄漏了。
  2. 诊断问题:

    • 一旦怀疑是内存泄漏,你可以生成堆内存快照。这可以通过 JVM 参数来自动做,像是加上
      -XX: +HeapDumpOnOutOfMemoryError,这样如果发生 OOM 错误时就会自动生成一个堆快照文件。或者你可以手动导出,比如用 jmap 或者 Arthas 来获取内存快照,这样就能看到哪些对象还没被回收。
  3. 定位问题:

    • 接着,你可以使用 MAT(Memory Analyzer Tool) 来分析这些堆快照,看看是不是某些对象占用了大量的内存,导致其他对象无法被回收。
  4. 修复问题:

    • 修复的时候,首先要看看问题是出在哪。比如代码中如果有不必要的引用,可以手动清理;如果是并发问题,可能是因为设置的堆内存太小或者设计不当,这时候可以通过调整内存参数或优化设计来解决。
  5. 工具的使用:

    • 常用的工具有 JDK 自带的 jps (查看进程)和 jmap (生成内存快照),以及第三方工具像 VisualVMArthas,它们都能帮助我们监控和分析内存使用情况。

总的来说,内存泄漏是一个比较常见的问题,但只要我们通过监控、生成堆快照并分析,能够及时发现并修复它。

Java 里的对象在虚拟机里面是怎么存储的?

在 Java 中,对象是存放在 堆内存 中的,而对象在堆中的布局主要由三部分组成:
对象头(Header)+ 实例数据(Instance Data)+ 对齐填充(Padding)

对象的整体结构

对象在内存中的基本组成如下:

组成部分 说明 示例
对象头(Object Header) 存放对象的元数据和运行时信息 Mark Word、Klass Pointer、数组长度
实例数据(Instance Data) 存放对象的实际字段(成员变量) int idString name
对齐填充(Padding) 用于保证 8 字节对齐的内存要求 根据对象大小自动补齐

对象头(Header)详解

对象头是 JVM 用来描述对象自身信息的区域,主要包含三部分内容:

  1. Mark Word(标记字段)
  • 存储对象运行时数据,比如:hashCode、GC 分代年龄、锁状态标志(无锁、偏向锁、轻量级锁、重量级锁)等。
  • 是一个会随着对象状态变化的多功能字段。
  1. Klass Pointer(类型指针)
  • 指向对象所属类的元数据,用于确定该对象的类型。
  1. 数组长度(仅数组对象有)
  • 如果是数组对象,这里会额外存储数组长度信息。

简单理解:“对象头其实就是对象的身份证,里面记着它的 hashCode、锁状态、GC 信息,还有它是哪种类创建的。”

实例数据(Instance Data)

  • 存储对象的实际字段数据,比如成员变量。
  • JVM 会根据字段类型和继承层次进行内存重排,让对象结构更加紧凑。

简单理解:“这一块就是真正保存业务数据的地方,比如一个 User 对象,它的 id、name 都在这里。”

对齐填充(Padding)

  • JVM 为了提高内存访问效率,要求对象大小是 8 字节的倍数。
  • 如果对象实际大小不足,JVM 会自动添加填充字节。

简单理解:“对齐填充就像打补丁,为了让对象大小对齐到 8 字节边界。”

存储位置

  • 普通对象:一般分配在(Heap)中。
  • 年轻代(Young Generation):新对象分配在 Eden 区,经过多次 GC 后可能晋升到老年代。
  • 老年代(Old Generation):存放生命周期较长的对象,比如缓存对象或静态实例。

Mark Word 示例(64 位)

位数 内容 含义
54bit 哈希码(hashCode)/ GC 年龄 / 锁信息 动态变化字段
2bit 锁状态标志 00 无锁 / 01 偏向锁 / 10 轻量级锁 / 11 重量级锁
其他 线程 ID、GC 标志等 取决于锁类型

“Mark Word 就像对象的‘运行日志’,JVM 会不断更新这里的锁状态和 GC 信息。”

小结

在 JVM 里,对象是存放在堆中的,它的内存结构主要分三块:
第一是对象头,里面有 Mark Word(存 hashCode、锁信息、GC 年龄等)和类指针;
第二是实例数据,也就是对象真正的字段内容;
第三是对齐填充,用来保证 8 字节对齐。
数组对象会多一项数组长度字段。整体来看,对象头就像身份证,实例数据是内容,而填充只是凑整。

img

说说 Java 的执行流程?

Java的执行流程:

  1. 源代码:编写.java文件
  2. 编译:使用javac编译器生成.class字节码文件。
  3. 类加载:JVM的类加载器加载.class文件到内存中。
  4. 解释执行:JVM将字节码转为机器码执行。
  5. JIT编译:JVM根据需要将热点代码编译为机器码。
  6. 运行:执行main方法中的逻辑。
  7. 垃圾回收:JVM管理内存,并回收不再使用的对象。
  8. 程序结束:main方法结束,JVM清理资源,推出程序。

线上 CPU 飙高如何排查?

线上 CPU 飙高其实是一个非常常见、但排查流程非常固定的问题。我一般会按照 “定位进程 → 定位线程 → 定位代码 → 修复问题” 这套方法走。

① 定位哪个进程占用 CPU

第一步用:

1
top

看到哪个进程 CPU 最高,比如 Java 进程占了 180%+,就说明是应用本身的问题。

② 定位哪个线程占用 CPU

继续用:

1
top -Hp <进程ID>

找到 CPU 占用最高的线程,比如线程号 4519。

然后将线程号转成 16 进制:

1
printf "%x\n" 4519

③ 打印线程栈,定位到底是哪段代码造成的

用 jstack 或 Arthas:

1
jstack <pid> | grep -A 200 <16进制线程号>

再查看堆栈最顶层,看代码卡在哪个方法。

④ 修复 CPU 热点代码

  • 把高频创建的对象提前到应用启动时初始化一次
    (如:Sequence、Validator)
  • 避免不必要的反射、正则、序列号获取
  • 检查是否有死循环、大量 JSON 解析、大对象创建等

小结

CPU 飙高我一般按固定流程排查:先找进程、再找线程、最后定位到具体代码。

  1. 定位高 CPU 进程
    top 看是哪个进程占满 CPU。

  2. 定位高 CPU 线程
    top -Hp <pid> 找出最耗 CPU 的线程号。

  3. 定位具体代码
    把线程号转成 16 进制:
    printf "%x\n" <tid>
    再用:
    jstack <pid> | grep -A 200 <16进制tid>
    或 Arthas thread -n 3,直接找到热点方法。

  4. 根据堆栈优化代码
    多数根因都是:频繁创建对象、正则/反射过多、DB/IO 阻塞、死循环等。

一句话总结:

top 找进程 → top -Hp 找线程 → jstack/Arthas 找热点方法 → 优化代码,这是排查 CPU 飙高最标准、最高效的流程。

怎么分析 JVM 当前的内存占用情况?OOM 后怎么分析?

怎么分析 JVM 当前的内存占用情况?OOM 之后怎么分析?

我一般会分两步:先看实时内存情况,再分析 OOM dump 文件 去定位根因。

一、如何分析 JVM 当前的内存占用情况?

主要用自带工具:

1)jstat —— 看 JVM 内存实时情况

jstat -gc <pid>
可以看到:

  • Eden、Survivor、Old 区的使用情况
  • GC 次数和耗时
    方便判断是不是某块内存持续上涨、GC 频繁等问题。

2)jmap —— 查看堆的详细结构

jmap -heap <pid> 查看:

  • 堆大小配置
  • 当前各区占用情况
  • GC 类别

jmap -histo <pid> 可以看到:

  • 哪些类实例最多、占用最大
    用来判断是否有往堆里塞大量对象的风险。

二、OOM 后怎么分析?

OOM 时最关键的是 拿到 heap dump 文件

1)开启自动 dump

在启动参数加:

1
2
-XX:+HeapDumpOnOutOfMemoryError 
-XX:HeapDumpPath=/tmp/heapdump.hprof

发生 OOM 时 JVM 会自动生成 .hprof 文件。

2)用专业工具分析 dump

常用工具:

  • MAT(Eclipse Memory Analyzer)
  • VisualVM
  • GCeasy
  • YourKit

这些工具可以告诉你:

  • 哪些对象占了最多内存
  • 哪些对象存在强引用链无法被清理
  • 是否存在缓存未清理、连接未关闭、集合无限膨胀等问题
    最后定位到具体代码并修复。

小结

分析 JVM 内存我主要用两类工具:在线看实时内存 + OOM 后看 dump 文件

① 在线分析(实时内存占用)

  • jstat -gc <pid> 看 Eden、Survivor、Old 区的使用情况和 GC 情况。
  • jmap -heap <pid> 看堆大小、配置、当前占用情况。
  • jmap -histo <pid> 找占内存最多的类。

② OOM 之后怎么分析

  • 启动参数加上:
    -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/heap.hprof
    OOM 时自动生成 dump。
  • MAT / VisualVM / GCeasy 打开 dump,找占用最大的对象、引用链,定位内存泄漏或对象膨胀的代码。

一句话总结:

在线靠 jstat/jmap 看趋势,OOM 靠 dump 文件找大对象和引用链,这两步就能定位大部分 JVM 内存问题。