JVM 面试宝典:从入门到调优实战

本文系统梳理 JVM 核心知识体系,按照"基础概念 → 底层原理 → 调优实战 → 面试精讲"的路径组织,覆盖大厂面试中 90% 以上的 JVM 高频考点。


目录

  • [一、JVM 概述:Java 程序运行的基础](#一、JVM 概述:Java 程序运行的基础)
  • 二、类加载子系统
  • [三、JVM 内存管理](#三、JVM 内存管理)
  • 四、垃圾回收机制
  • [五、JVM 调优实战](#五、JVM 调优实战)
  • [六、面试高频 50 问精讲](#六、面试高频 50 问精讲)

一、JVM 概述:Java 程序运行的基础

1.1 JVM、JRE、JDK 的关系

概念 全称 包含内容 面向用户
JVM Java Virtual Machine 类加载器、运行时数据区、执行引擎 核心运行环境
JRE Java Runtime Environment JVM + 核心类库 + 资源文件 Java 程序运行用户
JDK Java Development Kit JRE + 开发工具(javac、javap、jdb 等) Java 开发者

三者关系:JDK ⊃ JRE ⊃ JVM。JVM 是整个 Java 生态的基石,屏蔽了底层操作系统的差异,实现了"一次编写,到处运行"。

1.2 JVM 整体架构

JVM 的架构可以分为三大子系统:

复制代码
┌─────────────────────────────────────────────────────────┐
│                      JVM 架构总览                         │
├─────────────────────────────────────────────────────────┤
│  ┌─────────────┐  ┌──────────────────┐  ┌────────────┐ │
│  │ 类加载子系统  │  │   运行时数据区     │  │  执行引擎   │ │
│  │             │  │                  │  │            │ │
│  │ Bootstrap  │  │ 方法区/元空间     │  │ 解释器      │ │
│  │ Extension  │  │ 堆               │  │ JIT 编译器  │ │
│  │ Application│  │ 虚拟机栈          │  │ GC 垃圾回收 │ │
│  │ Custom     │  │ 本地方法栈        │  │            │ │
│  │             │  │ 程序计数器        │  │            │ │
│  └─────────────┘  └──────────────────┘  └────────────┘ │
└─────────────────────────────────────────────────────────┘
  • 类加载子系统 :负责将 .class 文件加载到 JVM 内存中
  • 运行时数据区:JVM 运行过程中所使用到的内存空间
  • 执行引擎:解释执行字节码、JIT 即时编译、GC 垃圾回收

1.3 Java 程序运行流程

复制代码
.java 源文件 ──javac编译──▶ .class 字节码 ──类加载器──▶ 方法区(类的元数据)
                                                        │
                            操作系统 ◀──机器码◀── 执行引擎(解释/JIT) ◀── 运行时数据区

Java 之所以被称为"半解释半编译"语言,是因为 JVM 中的执行引擎既包含逐条翻译字节码的解释器 ,也包含将热点代码直接编译为本地机器码的 JIT(Just-In-Time)编译器


二、类加载子系统

🔥 面试热度:★★★★★。类加载过程和双亲委派模型是面试中的必问项,通常与 SPI 机制、Tomcat 类加载打破双亲委派等场景结合考察。

2.1 类加载过程详解

类从 .class 文件到 JVM 中可用的 Class 对象,经历了三个大阶段:加载 → 链接 → 初始化,其中链接又可细分为验证、准备、解析三个小阶段。

复制代码
加载(Loading) → 验证(Verification) → 准备(Preparation) → 解析(Resolution) → 初始化(Initialization)
│──────────    链接(Linking)    ──────────────────│
2.1.1 加载(Loading)

JVM 在加载阶段完成三件事:

  1. 通过全限定名获取类的二进制字节流 :来源不限于 .class 文件,可以是 ZIP 包(jar/war)、网络流(Applet)、运行时计算生成(动态代理)、数据库等
  2. 将字节流的静态存储结构转换为方法区的运行时数据结构
  3. 在堆中生成 java.lang.Class 对象,作为该类在方法区中数据的访问入口

💡 深入思考 :数组类和普通类的加载有何不同?

数组类本身不通过类加载器创建,而是由 JVM 在运行时直接创建。但数组的元素类型(如 String[] 中的 String)仍然需要类加载器加载。

2.1.2 链接(Linking)

验证(Verification)

确保 Class 文件的字节流符合 JVM 规范,不会危害 JVM 安全。包括:

  • 文件格式验证 :魔数 0xCAFEBABE、版本号检查等
  • 元数据验证:是否有父类、是否继承了 final 类、抽象方法实现等
  • 字节码验证:类型转换是否合法、跳转指令是否正确等
  • 符号引用验证:通过全限定名能否找到对应的类

⚠️ 验证阶段虽重要但耗时,-Xverify:none 可关闭(正式环境不建议)。

准备(Preparation)

为类的静态变量 分配内存并设置零值(默认初始值),注意这里分配的内存位于方法区中。

静态变量类型 准备阶段初始值
static int 0
static boolean false
static Object null
java 复制代码
public static int value = 123;   // 准备阶段 value = 0,初始化阶段才赋值为 123
public static final int CONST = 123;  // final 修饰的常量在编译期就生成了 ConstantValue,准备阶段直接赋值为 123

解析(Resolution)

将常量池中的符号引用 替换为直接引用。解析动作主要针对类/接口、字段、方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用。

2.1.3 初始化(Initialization)

初始化是类加载过程的最后一步,JVM 真正开始执行类中定义的 Java 程序代码 。初始化阶段执行的是类构造器 <clinit>() 方法:

  • <clinit>() 由编译器自动收集类中所有静态变量的赋值动作静态代码块中的语句合并产生
  • 收集顺序严格按源文件中出现的顺序
  • <clinit>() 对于类/接口来说并不是必需的,如果一个类没有静态代码块和静态变量赋值,可以不生成
  • JVM 保证一个类的 <clinit>() 在多线程环境下被正确加锁同步

类加载的时机(主动引用 vs 被动引用)

六种主动引用会触发初始化:

  1. new 关键字创建对象实例
  2. 读取或设置一个类的静态字段(final 修饰的常量除外)
  3. 调用一个类的静态方法
  4. 使用 java.lang.reflect 对类进行反射调用
  5. 初始化子类时,若父类未初始化,先触发父类初始化
  6. 包含 main() 方法的主类

三种被动引用不会触发初始化(常被面试问到):

java 复制代码
// 1. 通过子类引用父类静态字段 → 不会触发子类初始化
class Parent { static int value = 1; }
class Child extends Parent { static int childValue = 2; }
int v = Child.value;  // 只初始化 Parent,不初始化 Child

// 2. 通过数组定义引用类 → 不会触发初始化
Parent[] arr = new Parent[10];  // 触发的是数组类 [LParent 的初始化

// 3. 引用常量 → 不会触发初始化(常量在编译期已存入常量池)
class ConstClass { static final String HELLO = "hello"; }
String s = ConstClass.HELLO;  // 不触发 ConstClass 初始化

2.2 类加载器体系

JVM 中的类加载器遵循层次结构:

复制代码
                    ┌──────────────────────┐
                    │ Bootstrap ClassLoader │  ← 启动类加载器(C++ 实现,JVM 的一部分)
                    │ 加载 <JAVA_HOME>/lib  │     加载 rt.jar、tools.jar 等
                    └──────────┬───────────┘
                               │ 父加载器
                    ┌──────────▼───────────┐
                    │ Extension/Platform   │  ← 扩展类加载器(JDK8 及之前)
                    │ 加载 <JAVA_HOME>/lib/ext│    JDK9 起变为 Platform ClassLoader
                    └──────────┬───────────┘
                               │ 父加载器
                    ┌──────────▼───────────┐
                    │ Application          │  ← 应用类加载器(默认)
                    │ 加载 classpath 下的类  │     sun.misc.Launcher$AppClassLoader
                    └──────────┬───────────┘
                               │
                    ┌──────────▼───────────┐
                    │ Custom ClassLoader   │  ← 用户自定义类加载器
                    └──────────────────────┘
类加载器 JDK 8 JDK 9+ 加载路径
启动类 Bootstrap CL Bootstrap CL jre/lib/rt.jar
扩展类 Extension CL Platform CL jre/lib/ext → 模块化后改变
应用类 Application CL Application CL classpath

2.3 双亲委派模型

🔥🔥🔥 面试最高频考点之一,必须能画流程图、说出源码逻辑、并能举出打破该模型的场景。

2.3.1 工作原理

当一个类加载器收到类加载请求时:

  1. 它不会自己先去加载,而是委托给父类加载器去完成

  2. 每一层的类加载器都这样向上委托,直到顶层的 Bootstrap ClassLoader

  3. 只有当父加载器反馈自己无法完成加载时,子加载器才会尝试自己加载

    请求加载 java.lang.String


    Application CL ──▶ Extension CL ──▶ Bootstrap CL

    找到了!由 Bootstrap 加载
    确保核心类不被篡改

2.3.2 源码分析(ClassLoader.loadClass()
java 复制代码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 第1步:检查类是否已经被加载
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                // 第2步:如果有父加载器,委托父加载器加载
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 第3步:父为null时,直接由Bootstrap ClassLoader加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器加载失败,忽略异常
            }
            if (c == null) {
                // 第4步:父加载器无法加载时,调用自己的findClass()加载
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}
2.3.3 为什么要使用双亲委派模型

一、避免类的重复加载:父加载器加载过的类,子加载器不会再次加载,确保 JVM 中每个类的唯一性。

二、保证核心类库的安全java.lang.Stringjava.lang.Object 等核心类只能被 Bootstrap ClassLoader 加载。即使用户自定义了一个同名的 java.lang.String 类,双亲委派机制也会让 Bootstrap ClassLoader 先加载官方的 String,防止核心 API 被篡改。

2.3.4 破坏双亲委派模型的经典案例

🎯 面试中能说出以下三种破坏场景,面试官基本认定你对类加载机制有深度理解。

场景一:SPI 机制(JDBC 驱动加载)

这是 JDK 官方"自己破坏自己规则"的典型案例。SPI(Service Provider Interface)接口定义在 rt.jar 中由 Bootstrap 加载,但具体的实现类(如 com.mysql.cj.jdbc.Driver)位于 classpath 下,Bootstrap 无法加载。

解决方式是引入了线程上下文类加载器(Thread Context ClassLoader):

java 复制代码
// java.sql.DriverManager 中的核心逻辑(简化版)
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ServiceLoader.load() 内部使用线程上下文类加载器来加载实现类
// 线程上下文类加载器默认就是 Application ClassLoader

具体流程:

复制代码
DriverManager (Bootstrap加载)
      │ 需要加载 Driver 的实现类 (classpath 下的 MySQL Driver)
      ▼
使用 Thread.currentThread().getContextClassLoader()
      │ (Application ClassLoader)
      ▼
找到并加载 com.mysql.cj.jdbc.Driver

场景二:Tomcat 的 WebApp ClassLoader

Tomcat 需要为每个 Web 应用提供独立的类加载器,实现应用隔离和热部署:

复制代码
      Bootstrap ClassLoader
              │
    ┌─────────┼─────────┐
    │         │         │
System CL  Common CL  (共享)
              │
    ┌─────────┼─────────┐
    │         │         │
WebApp1 CL  WebApp2 CL  (隔离)

每个 WebApp ClassLoader 优先加载自己 /WEB-INF/classes/WEB-INF/lib 下的类,打破了先向上委托的规则,确保:

  • 不同 Web 应用的库互不干扰
  • 同一个类库的不同版本可以共存
  • 卸载 Web 应用时对应的类加载器可以一起被回收

场景三:OSGi 模块化框架

OSGi 实现了网状结构的类加载器,不再是单一的父子委派链。每个模块(Bundle)都有自己的类加载器,类加载请求在 Bundle 之间按依赖关系传递,完全不遵循双亲委派模型。

2.4 符号引用 vs 直接引用

对比维度 符号引用 直接引用
产生时机 编译期 解析阶段
存在形式 字符串字面量 内存地址/偏移量
与内存关系 无关,独立于虚拟机内存布局 直接指向运行时内存中的具体位置
跨平台性 支持(编译后不变) 不支持(不同 JVM 内存布局不同)

使用符号引用的原因:编译器无法预知运行时类、方法、字段在内存中的真实位置,使用符号引用实现编译解耦、跨平台兼容,并支持动态链接和懒加载机制。

2.5 解释执行与编译执行

JVM 的执行引擎同时采用了解释执行编译执行两种方式:

复制代码
解释执行:字节码 ──逐条解释──▶ 机器码     (启动快,执行慢)
编译执行:字节码 ──批量编译──▶ 机器码     (启动慢,执行快)
             JIT 编译器在运行时编译热点代码

热点代码检测 :JVM 采用热点探测(Hot Spot Detection)来识别需要 JIT 编译的方法:

  1. 基于采样的热点探测:周期性检查各线程的栈顶,经常出现的方法即为热点方法
  2. 基于计数器的热点探测(HotSpot 默认):为每个方法维护两个计数器------方法调用计数器和回边计数器(循环),超过阈值触发 JIT 编译

分层编译(JDK 7+)

复制代码
第0层:纯解释执行,不采集性能数据
第1层:C1 编译(Client Compiler),快速编译,轻度优化
第2层:C1 编译 + 更多 profiling 数据
第3层:C1 编译 + 完整 profiling 数据
第4层:C2 编译(Server Compiler),耗时较长,激进优化

三、JVM 内存管理

🔥 面试热度:★★★★★。内存区域划分、对象创建过程、OOM 排查是面试中最常被问到的内容之一,通常结合线上问题排查场景来考察。

3.1 运行时数据区域

根据 JVM 规范,运行时数据区分为以下五大区域:

复制代码
┌─────────────────── 线程私有 ────────────────────┐
│                                                 │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐      │
│  │程序计数器  │  │ 虚拟机栈  │  │本地方法栈  │      │
│  │   PC     │  │VM Stack  │  │Native    │      │
│  └──────────┘  └──────────┘  └──────────┘      │
│                                                 │
├─────────────────── 线程共享 ────────────────────┤
│                                                 │
│  ┌─────────────────┐  ┌──────────────────┐     │
│  │      堆 Heap     │  │ 方法区/元空间     │     │
│  │  (对象、数组)     │  │ (类信息、常量、   │     │
│  │                  │  │  静态变量、JIT)    │     │
│  └─────────────────┘  └──────────────────┘     │
│                                                 │
└─────────────────────────────────────────────────┘
3.1.1 程序计数器(Program Counter Register)
  • 线程私有,生命周期与线程相同
  • 一块较小的内存区域,是当前线程所执行的字节码的行号指示器
  • 唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError 情况的区域
  • 若线程执行的是 Native 方法,计数器值则为空(Undefined)
  • 分支、循环、跳转、异常处理、线程恢复等基本功能都依赖 PC 寄存器
3.1.2 Java 虚拟机栈(JVM Stack)
  • 线程私有,生命周期与线程相同
  • 每个方法执行时,JVM 会同步创建一个栈帧(Stack Frame)
  • 异常情况:StackOverflowError(栈深度超限)和 OutOfMemoryError(栈扩展无法申请足够内存)

栈帧的内部结构

复制代码
┌─────────────────────────┐
│      栈帧 Stack Frame     │
├─────────────────────────┤
│  局部变量表(Local Vars)   │  ← 方法参数 + 方法体内局部变量
│  操作数栈(Operand Stack)  │  ← 字节码执行时操作数据的区域
│  动态链接(Dynamic Link)   │  ← 指向运行时常量池中该方法的符号引用
│  方法返回地址(Return Addr) │  ← 恢复到调用者的位置继续执行
│  附加信息                  │  ← 调试信息等
└─────────────────────────┘

局部变量表(Local Variables Table):

  • 最基本存储单元是 Slot(变量槽),当前 32 位以内的类型(byte、short、int、float、reference)占 1 个 Slot,64 位的(long、double)占 2 个 Slot
  • 实例方法第 0 位默认存放 this 引用
  • 局部变量表在编译期已经确定大小,运行期不会改变

操作数栈(Operand Stack):

  • 与局部变量表一样,编译期就确定了最大深度
  • 方法刚开始时操作数栈为空,通过字节码指令进行压栈和弹栈操作

动态链接(Dynamic Linking):

  • 每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用
  • 持有这个引用是为了支持方法调用过程中的动态链接
  • 符号引用一部分会在类加载解析阶段转为直接引用(静态解析),另一部分在每次运行期间转为直接引用(动态链接)

💡 静态解析 vs 动态链接

  • 静态解析的对象是在编译期可知、运行期不可变的方法(如静态方法、私有方法、实例构造器、父类方法、final 方法),在类加载的解析阶段就可以把符号引用转换为直接引用
  • 动态链接针对的是需要在运行期间才能确定具体调用目标的方法(如重写方法),每次运行时重新转换
3.1.3 本地方法栈(Native Method Stack)
  • 与虚拟机栈功能类似,但服务于 Native 方法(用 C/C++ 编写的本地方法)
  • HotSpot 将本地方法栈和虚拟机栈合二为一
  • Native 方法通过 JNI(Java Native Interface) 与底层系统交互
3.1.4 Java 堆(Heap)

🔥🔥🔥 面试最核心的内存区域:几乎所有对象都在堆中分配,GC 的主要工作区域。

  • 线程共享,JVM 启动时创建
  • JVM 中最大的一块内存区域,主要存储对象实例和数组
  • GC 垃圾回收的主战场,因此也称为 GC 堆(Garbage Collected Heap)
  • 所有线程共享的 Java 对象都可能被分配在这里

堆中的特殊区域

① 字符串常量池(String Constant Pool)

JDK 7 之前字符串常量池位于方法区中(永久代),JDK 7 开始被移到堆中,原因是永久代空间有限且 GC 回收效率低。

java 复制代码
String s1 = "hello";              // 直接从常量池获取
String s2 = new String("hello");  // 在堆中新建对象
String s3 = s2.intern();          // 将对象引用放入常量池并返回
System.out.println(s1 == s2);     // false
System.out.println(s1 == s3);     // true

② TLAB(Thread Local Allocation Buffer)

由于堆是线程共享的,并发分配内存需要加锁,影响效率。JVM 为每个线程在 Eden 区分配一小块私有缓存区域,称为 TLAB,线程在自己的 TLAB 中分配对象无需同步。TLAB 用完后再申请新的,才需要同步锁定。

复制代码
┌────── Eden 区 ──────────────────┐
│ TLAB-1 │ TLAB-2 │ TLAB-3 │ ...  │
│(Thread1│(Thread2│(Thread3│      │
│ 专属)   │ 专属)   │ 专属)   │      │
└─────────────────────────────────┘

配置参数:-XX:+UseTLAB(默认开启)、-XX:TLABSize 设置大小。

③ 逃逸分析与栈上分配

JDK 6u23 后 HotSpot 默认开启逃逸分析。当一个对象只在方法内部使用,不会被外部引用时:

  • JVM 可以将其分配在栈上(随着栈帧的出栈而自动销毁,无需 GC)
  • 对未逃逸对象还可以进行标量替换(将对象打散为其成员变量,直接在栈上分配)
  • 对未逃逸的锁可以实施锁消除
java 复制代码
// 逃逸分析示例
public void test() {
    Point p = new Point(1, 2);  // p 未逃逸 → 可能在栈上分配
    System.out.println(p.x);
}
// 参数: -XX:+DoEscapeAnalysis(默认开启)-XX:+EliminateAllocations
3.1.5 方法区 / 元空间(Method Area / Metaspace)

方法区是 JVM 规范中的逻辑概念 ,HotSpot 在 JDK 8 之前用永久代 (PermGen)实现,JDK 8 起用元空间(Metaspace)替代。

对比维度 永久代(JDK ≤ 7) 元空间(JDK ≥ 8)
存储位置 JVM 堆内存中 本地内存(Native Memory)
大小限制 -XX:MaxPermSize 限制,默认 85MB 受操作系统可用内存限制,-XX:MaxMetaspaceSize 可设上限
OOM 现象 java.lang.OutOfMemoryError: PermGen space java.lang.OutOfMemoryError: Metaspace
回收策略 Full GC 时回收 GC 时回收,可设置 -XX:MaxMetaspaceFreeRatio

元空间替代永久代的原因

  1. 永久代大小难以确定:不同应用加载的类数量差异大,固定空间易 OOM
  2. 永久代 GC 复杂且效率低:类元数据和对象混在一起回收
  3. 方便与 JRockit 等 HotSpot 以外的 JVM 实现保持一致(JRockit 从未有永久代概念)
  4. 字符串常量池移到堆后,永久代只剩类元数据,用本地内存更灵活

运行时常量池(Runtime Constant Pool):

  • 是方法区的一部分,Class 文件中常量池表在运行时的表现形式
  • 存储编译期生成的各种字面量 (文本字符串、final 常量值)和符号引用(类/接口全限定名、字段名和描述符、方法名和描述符)
  • 动态性:运行期间也可以将新的常量放入池中(如 String.intern()
3.1.6 直接内存(Direct Memory)
  • 不属于 JVM 运行时数据区,也不是 JVM 规范定义的内存区域
  • NIO 使用 Native 函数库直接分配堆外内存,通过 DirectByteBuffer 对象作为引用操作
  • 避免了在 Java 堆和 Native 堆之间来回复制数据,显著提升 I/O 性能
  • OOM 排查时容易被忽略:受操作系统内存限制,-XX:MaxDirectMemorySize 可指定上限

3.2 HotSpot 对象探秘

3.2.1 对象创建过程(五步详解)

当 JVM 遇到 new 指令时:

复制代码
new 指令
  │
  ▼
① 类加载检查 ──▶ 检查常量池中是否有该类的符号引用
                检查该类是否已完成加载、解析、初始化
                (未完成则先执行类加载)
  │
  ▼
② 分配内存 ────▶ 从堆中划分一块确定大小的内存给新对象
                分配方式:指针碰撞 / 空闲列表
                并发安全:CAS + 失败重试 / TLAB
  │
  ▼
③ 初始化零值 ──▶ 将分配到的内存空间初始化为零值
                保证对象的实例字段不赋初值就能直接使用
                int→0, boolean→false, 引用类型→null
  │
  ▼
④ 设置对象头 ──▶ 设置 Mark Word(哈希码、GC分代年龄、锁状态等)
                设置类型指针(指向方法区中类的元数据)
                数组对象额外设置数组长度
  │
  ▼
⑤ 执行 <init> ──▶ 按"父类→子类"顺序执行构造方法
                完成实例变量的显式赋值

分配内存的两种方式

分配方式 适用场景 原理
指针碰撞(Bump the Pointer) 堆内存规整(Serial、ParNew 等带整理功能的收集器) 已用内存和空闲内存之间维护一个分界指针,分配时指针向空闲方向移动对象大小的距离
空闲列表(Free List) 堆内存不规整(CMS 这种基于标记-清除的收集器) JVM 维护一个空闲块列表,从中找到足够大的空间分配给对象

并发分配内存的线程安全问题

  1. CAS + 失败重试:对分配空间动作进行同步处理
  2. TLAB:每个线程预先在 Eden 区分配一块私有缓存,大部���对象分配在 TLAB 中完成(线程内无竞争),TLAB 用完后才需要同步锁定分配新的 TLAB
3.2.2 对象的内存布局

HotSpot 虚拟机中,对象在堆内存中的存储布局分为三部分:

复制代码
┌──────────────────────────────────────────────┐
│              对象内存布局                       │
├──────────┬──────────────┬────────────────────┤
│  对象头   │   实例数据     │      对齐填充        │
│ (Header) │ (Instance    │   (Padding)        │
│          │   Data)      │                    │
├──────────┴──────────────┴────────────────────┤
│  Mark Word (32/64 bit)                       │
│  Klass Pointer (32/64 bit,压缩后32bit)       │
│  [Array Length](仅数组对象)                   │
│  ...实例字段(按类型宽度排序)...                │
│  对齐填充到 8 字节的整数倍                      │
└──────────────────────────────────────────────┘

对象头详解

Mark Word(标记字段):存储对象自身的运行时数据

32 位 JVM 下 Mark Word 的结构(不同锁状态下内容不同,复用存储空间):

锁状态 23 bit 2 bit 4 bit 1 bit(是否偏向) 2 bit(锁标志)
无锁 对象的 hashCode 分代年龄 0 0 01
偏向锁 ThreadID(23) Epoch(2) 分代年龄(4) 1 01
轻量级锁 指向栈中锁记录的指针(30) 00
重量级锁 指向重量级锁的指针(30) 10
GC标记 空(30) 11

64 位 JVM 默认开启指针压缩(+UseCompressedOops)后,Mark Word 占 8 字节。

Klass Pointer (类型指针):指向方法区中类元数据的指针,确定对象是哪个类的实例。默认开启 -XX:+UseCompressedClassPointers 压缩为 4 字节。

实例数据 (Instance Data):父类字段 + 子类字段,相同宽度的字段分配在一起,在满足要求的前提下父类变量会出现在子类变量之前(可设置 -XX:FieldsAllocationStyle 调整)。

对齐填充(Padding):HotSpot 要求对象起始地址必须是 8 字节的整数倍,对象的整体大小也必须是 8 字节的整数倍,不足时补齐。

3.2.3 对象的访问定位

JVM 规范只规定了 reference 类型是一个指向对象的引用,没有规定如何定位。主流的访问方式有两种:

① 句柄访问

复制代码
reference ──▶ 句柄池 ──▶ 实例数据指针 ──▶ 对象实例数据(堆中 Instance Pool)
                        ──▶ 类型数据指针 ──▶ 对象类型数据(方法区)

优点:GC 移动对象时只需修改句柄中的实例数据指针,reference 本身不用变。

② 直接指针访问(HotSpot 使用):

复制代码
reference ──▶ 对象实例数据(堆中)──▶ 对象类型数据(方法区)

优点:访问速度快,减少一次指针定位开销;HotSpot 中 GC 时用写屏障追踪引用变化。

3.3 堆内存分区(分代模型)

🔥 面试最常见的问题之一:为什么分代?Eden 到 Survivor 怎么复制?什么对象进入老年代?

复制代码
┌─────────────────────────────────────────────────┐
│                    Java 堆                        │
├──────────────────┬──────────────────────────────┤
│     新生代        │           老年代               │
│  (Young Gen)     │        (Old Gen)              │
│  ┌───┬────┬────┐ │                              │
│  │E │S0 │ S1 │  │                              │
│  │d │   │    │  │  长期存活对象                   │
│  │e │   │    │  │  (年龄 ≥ 15)                  │
│  │n │   │    │  │  大对象(直接进入)              │
│  └───┴────┴────┘ │                              │
│  默认比例 8:1:1   │                              │
├──────────────────┴──────────────────────────────┤
│                 默认比例 1:2                       │
│           (新生代:老年代 = 1:2)                   │
└─────────────────────────────────────────────────┘

分代收集的理论基础

  • 弱分代假说:绝大多数对象都是朝生夕灭的(新对象很快变成垃圾)
  • 强分代假说:熬过越多次垃圾收集的对象就越难消亡
  • 跨代引用假说:跨代引用相对于同代引用来说仅占极少数

Eden → Survivor 复制过程

  1. 新对象优先分配在 Eden 区
  2. Eden 满 → 触发 Minor GC(Young GC)
  3. 存活对象从 Eden + From Survivor 复制到 To Survivor
  4. 每经历一次 Minor GC 且存活,对象年龄 +1
  5. To Survivor 满了 → 存活对象直接进入老年代
  6. From 和 To 角色互换,始终保持一个 Survivor 区为空

对象晋升老年代的条件

条件 说明
年龄阈值 对象每熬过一次 Minor GC 年龄 +1,达到 -XX:MaxTenuringThreshold(默认15)则晋升
动态年龄判定 当 Survivor 中相同年龄的所有对象大小总和 > Survivor 空间的一半,年龄 ≥ 该值的对象直接晋升
大对象 占用连续空间超过 -XX:PretenureSizeThreshold 的对象,直接在老年代分配
空间担保失败 Survivor 空间无法容纳存活对象时,直接进入老年代

3.4 内存溢出与内存泄漏

🔥🔥🔥 线上问题排查的必考场景,面试官期待的不只是概念,更是完整的排查思路和工具使用经验。

3.4.1 内存溢出(OutOfMemoryError)
OOM 类型 错误信息 常见原因
Java 堆溢出 java.lang.OutOfMemoryError: Java heap space 对象过多、大对象持有时间过长、内存泄漏
元空间溢出 java.lang.OutOfMemoryError: Metaspace 动态生成了大量类、类加载器泄漏
栈溢出 java.lang.StackOverflowError 递归过深、方法调用层级过多
直接内存溢出 java.lang.OutOfMemoryError: Direct buffer memory NIO 使用不当、直接内存未释放
GC 开销超限 java.lang.OutOfMemoryError: GC overhead limit exceeded GC 时间占总运行时间 98% 以上,但回收的内存不足 2%
创建线程失败 java.lang.OutOfMemoryError: unable to create new native thread 线程数量到达操作系统上限
Map 操作异常 java.lang.OutOfMemoryError: Map failed mmap 映射文件时系统资源不足
3.4.2 内存泄漏(Memory Leak)的常见原因

内存泄漏指的是不再使用的对象不能被 GC 回收,它们占用的内存持续增长,最终可能导致 OOM。

高频内存泄漏场景

① 静态集合类持有对象引用

java 复制代码
// ❌ 问题代码
public class MemoryLeak {
    private static List<Object> list = new ArrayList<>();
    public void addData() {
        for (int i = 0; i < 100000; i++) {
            Object obj = new Object();
            list.add(obj);       // 静态集合持有引用,永不释放
            obj = null;          // 局部变量置空也无济于事
        }
    }
}
// ✅ 修复:定期清理或使用 WeakHashMap

② ThreadLocal 内存泄漏 ⭐ 重点详解:

ThreadLocal 的内部结构决定了它天生容易导致内存泄漏:

java 复制代码
// ThreadLocal 的核心存储结构
Thread → ThreadLocalMap → Entry(WeakReference<ThreadLocal>, value)
                                │
                            Key 是弱引用,一旦 GC 就可能变为 null
                            而 value 仍然是强引用,无法回收!

关键点:

  • ThreadLocalMap 的 Entry 中,Key(ThreadLocal 对象)是弱引用 ,但 Value 是强引用
  • 当 ThreadLocal 对象不再被外部引用时,GC 会回收它,Map 中的 Key 变为 null
  • Value 仍然被 Entry 强引用,不会被回收,造成内存泄漏
  • 线程池中的线程生命周期很长,ThreadLocalMap 不会被清除,泄漏持续累积
java 复制代码
// ✅ 正确用法:finally 中必须调用 remove()
ThreadLocal<UserContext> threadLocal = new ThreadLocal<>();
try {
    threadLocal.set(new UserContext(user));
    // ... 业务逻辑
} finally {
    threadLocal.remove();  // 必须手动清理!
}

③ 数据库/IO/Socket 连接未关闭

java 复制代码
// ❌ 问题代码
Connection conn = dataSource.getConnection();
// ... 使用 conn
// 忘记 close(),GC 不会自动释放连接池资源
return;

// ✅ try-with-resources(JDK7+ 自动调用 close())
try (Connection conn = dataSource.getConnection();
     PreparedStatement ps = conn.prepareStatement(sql)) {
    // ...
}

④ 单例模式持有外部引用:单例的生命周期与应用相同,如果单例持有了短生命周期对象的引用,这些对象将永远无法被回收。

⑤ 内部类持有外部类引用:非静态内部类默认持有外部类的强引用,如果内部类实例的生命周期超过外部类(如被提交到线程池),外部类实例无法回收。

java 复制代码
// ❌ 匿名内部类隐式持有外部类引用
public class Outer {
    public void submit() {
        executor.submit(new Runnable() {
            @Override public void run() { /* 持有 Outer.this */ }
        });
    }
}
// ✅ 改为静态内部类或 Lambda(Lambda 不持有 this 除非引用外部变量)

⑥ 监听器/回调未移除:注册了事件监听器但忘记反注册,导致观察者持有被观察者的引用。

3.4.3 OOM 排查思路与常用参数

排查标准流程

复制代码
怀疑 OOM
  │
  ▼
① 获取 dump 文件
  ├─ JVM 参数启动: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump
  ├─ 手动导出: jmap -dump:format=b,file=heap.hprof <pid>
  └─ Arthas: heapdump /tmp/dump.hprof
  │
  ▼
② 使用 MAT / JProfiler / VisualVM 分析 dump
  ├─ 查看占用最大的对象(Dominator Tree)
  ├─ 分析 GC Roots 引用链(Path to GC Roots)
  └─ 定位导致泄漏的代码位置
  │
  ▼
③ 结合 jstat 实时监控
  jstat -gc <pid> 1000 10  # 每秒输出一次 GC 情况,共 10 次
  ├─ 观察 Eden/Old/Metaspace 各区域使用率变化
  └─ 关注 Full GC 频率和回收效果

四、垃圾回收机制

🔥🔥🔥 JVM 面试最高频的模块:GC 算法、CMS vs G1、三色标记法、ZGC 等是区分候选人水平的关键问题。

4.1 判断对象是否存活

4.1.1 引用计数法(Reference Counting)
  • 每个对象维护一个引用计数器,被引用时 +1,引用失效时 -1
  • 计数器为 0 时即可回收
  • 优点:原理简单、判定效率高
  • 缺点无法解决循环引用问题(A 引用 B,B 引用 A,计数器永远不为 0)
  • Java 没有采用此方法(Python 采用了引用计数 + 标记清除的混合方案)
java 复制代码
public class CycleRef {
    Object ref;
    public static void main(String[] args) {
        CycleRef a = new CycleRef();
        CycleRef b = new CycleRef();
        a.ref = b;
        b.ref = a;   // 循环引用,引用计数法无法回收
        a = null;
        b = null;    // 实际上已不可达,但计数器仍为 1
    }
}
4.1.2 可达性分析算法(Reachability Analysis)

Java、C# 等主流语言所采用的判定方式:

  • 以一组称为 GC Roots 的根对象为起点

  • 从 GC Roots 出发,根据引用关系向下搜索,搜索路径叫做引用链(Reference Chain)

  • 如果对象到 GC Roots 没有任何引用链相连,即证明此对象可被回收

    复制代码
        GC Roots
        /   │   \
       ▼    ▼    ▼
     obj1  obj2  obj3
      │     │
      ▼     ▼
     obj4  obj5  ← 从 GC Roots 可达 → 存活
     
     obj6 (孤立对象,没有任何 GC Roots 引用) → 可回收
4.1.3 GC Roots 有哪些
GC Roots 说明 示例
虚拟机栈中的引用对象 当前正在执行方法里的局部变量、参数 方法中的局部变量 User u = new User()
本地方法栈中 JNI 的引用 Native 方法引用的 Java 对象 JNI 代码中的全局引用
方法区中类的静态变量 类的静态属性引用的对象 private static User instance = ...
方法区中常量引用的对象 字符串常量池、运行时常量池中的引用 String s = "hello"
所有被同步锁持有的对象 synchronized 关键字锁定的对象 synchronized(lock) {...}
JVM 内部的引用 基本类型的 Class 对象、常驻异常对象、系统类加载器 Object.classNullPointerException
反映 JVM 内部情况的 JMXBean JVMTI 中注册的回调、本地代码缓存等

4.2 引用类型详解

🔥 面试高频考点:四种引用的区别及使用场景。

引用类型 回收时机 使用场景
强引用(Strong) 永不回收(即使 OOM) 99% 的对象引用,Object obj = new Object()
软引用(Soft) 内存不足时回收 本地图片缓存、内存敏感的高速缓存
弱引用(Weak) 下一次 GC 时回收 WeakHashMap、ThreadLocal 的 Key
虚引用(Phantom) 随时可能回收(无法通过它获取对象) 堆外内存回收跟踪(NIO)、对象被 GC 时的通知机制
java 复制代码
// 软引用示例:内存缓存
SoftReference<Bitmap> cache = new SoftReference<>(bitmap);
Bitmap b = cache.get();
if (b == null) {
    b = loadFromDisk();  // 已被回收,重新加载
    cache = new SoftReference<>(b);
}

// 弱引用示例:WeakHashMap
WeakHashMap<String, Object> map = new WeakHashMap<>();
String key = new String("key");
map.put(key, value);
key = null;  // 下一次 GC 时,该 Entry 自动被清除

// 虚引用示例:跟踪对象回收
ReferenceQueue<Object> queue = new ReferenceQueue<>();
PhantomReference<Object> ref = new PhantomReference<>(obj, queue);
// obj 被 GC 回收后,ref 进入 queue,可通过 queue 感知回收事件

4.3 finalize() 方法

  • 对象在被 GC 回收前,如果重写了 finalize() 且未被调用过,会被放入 F-Queue
  • 一个低优先级的 Finalizer 线程会执行 finalize() 方法
  • finalize() 中对象可以"自救"------重新与 GC Roots 建立引用
  • 任何对象的 finalize() 只会被 JVM 调用一次
  • 不推荐使用 :运行代价高、不确定性大、无法保证执行顺序,JDK 9 已标记为 @Deprecated

💡 JVM 使用 Cleaner 机制来替代 finalize(),NIO 中的 DirectByteBuffer 就是通过 Cleaner 释放直接内存。

4.4 垃圾回收算法

🔥 三大基础算法及其优缺点、适用场景必须倒背如流。

4.4.1 标记-清除(Mark-Sweep)
复制代码
标记阶段:从 GC Roots 遍历,标记所有可达对象
清除阶段:遍历堆,回收未标记的对象

优点:不需要移动对象,实现简单
缺点:① 执行效率不稳定(对象越多,标记清除越慢)
      ② 产生大量不连续的内存碎片
4.4.2 标记-复制(Mark-Copy)
复制代码
将内存分为两块,每次只使用其中一块
GC 时:将存活对象复制到另一块 → 清除当前整块

优点:无内存碎片、分配效率高(指针碰撞)
缺点:可用内存缩小一半、对象存活率较高时需要大量复制

适用于:新生代 GC(Minor GC),92%~98% 的对象会死亡

HotSpot 的半区复制(Appel 式回收):

  • 新生代分 Eden + 两块 Survivor(8:1:1,可通过 -XX:SurvivorRatio 调整)
  • 每次只用 Eden + 一块 Survivor(可用空间 = 90%)
  • GC 时存活对象复制到另一块 Survivor
  • Survivor 放不下时,剩余对象直接进老年代(内存分配担保)
4.4.3 标记-整理(Mark-Compact)
复制代码
标记阶段:同标记-清除
整理阶段:将所有存活对象向内存一端移动,然后清除边界外的所有内存

优点:无内存碎片
缺点:移动对象需要 STW 并更新引用关系,效率低于标记-清除

适用于:老年代 GC

为什么老年代不用标记-复制?

老年代对象存活率高、大对象多,复制成本极高,且没有额外的空间进行担保。

4.4.4 算法对比总结
算法 是否移动对象 是否有碎片 内存利用率 适用场景
标记-清除 100% CMS 老年代
标记-复制 50% → 90%(HotSpot 优化后) 新生代
标记-整理 100% Serial Old、Parallel Old

4.5 Stop The World(STW)

🔥 中文面试常问:STW 是什么?为什么需要 STW?哪些 GC 阶段的 STW 时间最长?

STW 定义 :JVM 在执行 GC 的某些阶段时,需要暂停所有用户线程(Java 线程),以保证内存的一致性。所有线程在安全点(Safepoint)上暂停,等待 GC 完成后恢复。

STW 的根本原因 :可达性分析必须在一个能保障一致性的快照中进行,如果在分析过程中对象的引用关系还在不断变化,分析结果的准确性就无法保证。

各收集器 STW 情况

收集器 涉及 STW 的阶段 STW 耗时特征
Serial 全程 STW(单线程) 较长
ParNew 全程 STW(多线程) 中等
Parallel Scavenge 全程 STW(多线程) 中等,追求吞吐量
CMS 初始标记 + 重新标记 初始标记极短,重新标记较短
G1 初始标记 + 重新标记 + 部分清理 可预期停顿时间
ZGC 极少量 STW 阶段 < 1ms(JDK16+)

4.6 安全点与安全区域

安全点(Safepoint) :用户线程需要暂停时并非在任意位置都能停下,只能在安全点 处暂停。安全点的选择标准:是否具有让程序长时间执行的特征,如方法调用、循环跳转、异常跳转等。

如何在 GC 时让所有线程到达安全点

  • 抢先式中断(几乎不用):GC 时中断所有线程,如果发现不在安全点就恢复线程直至到达
  • 主动式中断(HotSpot 使用):设置一个标志位,各线程到达安全点后主动轮询这个标志,发现需要中断则自行挂起

安全区域(Safe Region):如果线程处于 Sleep 或 Blocked 状态(不在安全点,无法响应中断请求),JVM 使用安全区域来解决。安全区域是指一段引用关系不会发生变化的代码片段,只要在这个区域内任意位置开始 GC 都是安全的。线程离开安全区域前需要检查 GC 是否已完成。

4.7 经典垃圾收集器

4.7.1 七大收集器全景图
复制代码
            ┌─────────── 新生代 ───────────┐    ┌───── 老年代 ─────┐
            │                             │    │                  │
Serial ─────┤                             │    │                  │
            │                             │    │                  │
ParNew ─────┤    ┌──────────────────┐     ├────┤   CMS           │
            │    │   G1 (跨代)       │     │    │                │
Parallel ───┤    │   ZGC (跨代)      │     ├────┤  Serial Old    │
Scavenge     │    │  Shenandoah(跨代)│     │    │                │
            │    └──────────────────┘     ├────┤  Parallel Old   │
            │                             │    │                │
            └─────────────────────────────┘    └────────────────┘
收集器 作用区域 算法 线程模型 特点 状态
Serial 新生代 标记-复制 单线程 简单高效,STW
ParNew 新生代 标记-复制 多线程 Serial 多线程版 JDK 9 不推荐
Parallel Scavenge 新生代 标记-复制 多线程 关注吞吐量 JDK 14 移除 CMS 后的默认组合
Serial Old 老年代 标记-整理 单线程 CMS 后备方案
Parallel Old 老年代 标记-整理 多线程 关注吞吐量
CMS 老年代 标记-清除 并发 低停顿 JDK 9 弃用,JDK 14 移除
G1 全堆 标记-整理+复制 并发 可控停顿 JDK 9+ 默认
ZGC 全堆 标记-整理(染色指针) 并发 超低延迟 JDK 15 生产可用
4.7.2 CMS(Concurrent Mark Sweep)详解 ★★★

🔥🔥🔥 面试最高频的收集器问题,必须能把四个阶段、三色标记法、漏标处理、以及 CMS 的缺陷讲清楚。

设计目标:以最短的回收停顿时间为目标的收集器(低延迟优先)。

四个阶段

复制代码
┌──────────┐    ┌──────────┐    ┌──────────┐    ┌──────────┐
│ 初始标记   │───▶│ 并发标记   │───▶│ 重新标记   │───▶│ 并发清除   │
│ STW ✓    │    │ 并发 ✗    │    │ STW ✓    │    │ 并发 ✗    │
│ 耗时极短   │    │ 耗时最长   │    │ 耗时较短   │    │ 耗时较长   │
└──────────┘    └──────────┘    └──────────┘    └──────────┘
标记GC Roots    遍历整个         修正并发标记      清除未标记
直接关联的       对象图           阶段的误差        的对象
老年代对象

阶段详解

  1. 初始标记(Initial Mark)--- STW:

    • 只标记 GC Roots 能直接关联到的老年代对象
    • 需要 STW,但耗时极短,通常仅几毫秒
  2. 并发标记(Concurrent Mark)--- 与用户线程并发:

    • 从 GC Roots 出发遍历整个老年代对象图
    • 耗时最长,但与用户线程并发执行,不影响应用
    • 此阶段可能产生漏标问题(详见下文三色标记法)
  3. 重新标记(Remark)--- STW:

    • 修正并发标记期间因用户线程运行导致的标记变化
    • STW 时间远小于并发标记阶段
    • 仅扫描并发标记阶段发生过变化的对象(通过写屏障记录的卡表)
  4. 并发清除(Concurrent Sweep)--- 与用户线程并发:

    • 清除已标记为垃圾的对象
    • 使用标记-清除算法,会产生内存碎片

CMS 的三大问题

问题 原因 后果
① 并发标记产生浮动垃圾 并发清除阶段用户线程新产生的垃圾,本次 GC 无法回收 只能等下次 CMS 周期处理,占据老年代空间
② Concurrent Mode Failure 并发清除时老年代剩余空间不足以容纳从新生代晋升的对象 触发 Serial Old 单线程 Full GC,导致长时间 STW(秒级)
③ 内存碎片化 标记-清除算法不整理内存 大对象可能无法分配,触发 Full GC

三色标记法(Tri-color Marking)------ CMS/G1 并发标记的核心算法:

并发标记阶段,对象被分为三种颜色:

颜色 含义 状态
白色 尚未被标记器访问过 标记结束时仍为白色 → 垃圾
灰色 已被标记器访问,但其引用的子对象还未全部访问 待扫描
黑色 已被标记器访问,且其所有引用子对象也都已访问 存活对象

消失的对象问题 (漏标):并发标记过程中,用户线程修改了引用关系,可能导致原本存活的对象被误判为垃圾。漏标需要同时满足两个条件:

  1. 赋值器插入了一条或多条从黑色对象到白色对象的新引用
  2. 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用

CMS 解决漏标方案 --- 增量更新(Incremental Update):

  • 当黑色对象插入指向白色对象的新引用时,将黑色对象重新标记为灰色
  • 重新标记阶段会重新扫描这些变灰色的对象

CMS 相关 JVM 参数

bash 复制代码
-XX:+UseConcMarkSweepGC           # 启用 CMS
-XX:CMSInitiatingOccupancyFraction=75  # 老年代使用率达到 75% 触发 CMS(JDK 6 默认 92)
-XX:+UseCMSInitiatingOccupancyOnly     # 只根据设定阈值触发,不自动调整
-XX:+CMSParallelRemarkEnabled          # 并行重新标记
-XX:+CMSScavengeBeforeRemark           # 重新标记前先执行一次 Young GC
-XX:+CMSClassUnloadingEnabled          # CMS 回收 PermGen/Metaspace
-XX:+UseCMSCompactAtFullCollection     # Full GC 后整理碎片(默认开启)
-XX:CMSFullGCsBeforeCompaction=0       # 多少次 Full GC 后整理碎片(0=每次)
4.7.3 G1(Garbage First)详解 ★★★

🔥🔥🔥 JDK 9+ 的默认收集器,面试必问。需要彻底理解 Region 模型、SATB、Mixed GC、写屏障等技术。

设计目标:在延迟可控的前提下,获得尽可能高的吞吐量。

核心创新 --- Region 内存布局

G1 不再坚持固定大小的分代划分,而是将 Java 堆划分为多个大小相等的独立 Region(默认约 2048 个,每个 1~32MB):

复制代码
┌──────┬──────┬──────┬──────┬──────┬──────┬──────┬──────┐
│ E │ S │ E │ O │ H │ E │ O │ S │ ...  │ Free │ Free │
│(新生)(存活)(新生)(老年)(大对)(新生)(老年)(存活)       │       │       │
└──────┴──────┴──────┴──────┴──────┴──────┴──────┴──────┘
E=Eden, S=Survivor, O=Old, H=Humongous
  • 每个 Region 角色不固定,可在 Eden、Survivor、Old 之间动态切换
  • Humongous 区域:用于存储大对象(大小 ≥ Region 容量的 50%),专门处理巨型对象,避免在新生代和老年代之间频繁复制大对象

G1 的核心运行过程

复制代码
┌──────────┐   ┌──────────────────────────┐   ┌─────────────────┐
│  Young GC │   │      并发标记周期          │   │    Mixed GC     │
│ (仅年轻代)│   │  ═══════════════════════  │   │ (年轻代+部分老年代)│
│          │   │  ① 初始标记(STW)          │   │                 │
│          │   │  ② 根区域扫描(并发)        │──▶│ 筛选回收价值最高  │
│          │   │  ③ 并发标记(并发)          │   │ 的 Region 进行回收│
│          │   │  ④ 重新标记(STW)          │   │                 │
│          │   │  ⑤ 清理(STW,统计回收价值)  │   │                 │
└──────────┘   └──────────────────────────┘   └─────────────────┘
                    ↑ 此阶段中仍然可以发生 Young GC ↑

G1 并发标记周期详解

  1. 初始标记(Initial Mark)--- STW:

    • 标记 GC Roots 直接可达的对象
    • 借用 Young GC 的 STW 完成,实际额外开销很小
  2. 根区域扫描(Root Region Scan)--- 并发:

    • 扫描 Survivor 区中被初始标记阶段标记的对象
    • 必须在下一个 Young GC 之前完成
  3. 并发标记(Concurrent Mark)--- 并发:

    • 使用 SATB 算法进行整个堆的并发可达性分析
    • 与用户线程并发,此过程中也可发生 Young GC
  4. 重新标记(Remark)--- STW:

    • SATB 保证并发标记的正确性,修正并发期间的引用变化
    • G1 的重新标记比 CMS 的更快,因为 SATB 快照缩小了重标记范围
  5. 清理(Cleanup)--- 部分 STW:

    • 统计各 Region 的存活对象数量,计算回收价值
    • 将回收价值高的 Region 加入回收集(Collection Set, CSet)

SATB(Snapshot-At-The-Beginning)算法

G1 使用 原始快照 而不是 CMS 的增量更新来解决并发标记的漏标问题:

  • 在并发标记开始时,为当时的对象图拍一个快照

  • 并发过程中,当灰色对象删除指向白色对象的引用时,记录这个被删除的引用(写屏障的前置操作)

  • 重新标记阶段,以这些记录的引用为根再扫描一次

    SATB 解决漏标的逻辑:
    条件②(灰色→白色引用被删除)发生后,将被删引用的白色对象记录为 SATB 待扫描对象
    → 该白色对象不会被漏标 → 存活对象不会被误回收

    CMS 增量更新的逻辑:
    条件①(黑色→白色引用被插入)发生后,将黑色对象改为灰色重新扫描
    → 扫描范围更大,但不会漏标

💡 SATB vs 增量更新

  • SATB 关注引用删除 ,增量更新关注引用插入
  • SATB 会造成更多"浮动垃圾"(本应在并发期间变为垃圾的对象仍被标记为存活),但重新标记阶段更快
  • G1 定位是"可预期停顿",SATB 使重新标记更轻量,与其设计理念一致

Mixed GC

G1 最独特的能力:不只回收新生代,还会回收回收价值最高的部分老年代 Region。这被称为 Mixed GC。

  • 并发标记周期结束后,G1 知道每个老年代 Region 中的垃圾占比
  • Mixed GC 选取垃圾占比最高的若干个老年代 Region 进行回收
  • 垃圾较少的 Region 留到后续 Mixed GC 处理

G1 的写屏障(Write Barrier)

写屏障是 G1 并发执行的关键支撑技术。每当用户线程修改对象引用时,写屏障会做两件事:

  1. SATB 前置写屏障:记录引用修改前的值(用于 SATB 并发标记)
  2. 后置写屏障:更新卡表(Remembered Set),记录跨 Region 的引用关系

Remembered Set(记忆集)

每个 Region 都维护一个 RSet,记录了哪些 Region 中的对象引用了本 Region 中的对象。这样在回收某个 Region 时,不需要扫描整个堆来找 GC Roots,只扫描 RSet 中记录的 Region 即可。

G1 vs CMS 对比

对比维度 CMS G1
内存划分 连续的新生代 + 老年代 大小相等的 Region
收集范围 仅老年代(配合 ParNew 收集新生代) 全堆(Young GC + Mixed GC)
并发算法 增量更新 SATB(原始快照)
碎片处理 无(标记-清除,会产生碎片) 有(复制/整理,无碎片)
停顿控制 不可控,可能 Full GC 用户可指定 -XX:MaxGCPauseMillis(默认 200ms)
内存占用 较小 较大(Remembered Set 占用额外内存)
适用场景 低延迟,小~中堆 大堆(>6GB)、可预测停顿

G1 常用参数

bash 复制代码
-XX:+UseG1GC                       # 启用 G1
-XX:MaxGCPauseMillis=200           # 期望的最大 GC 停顿时间(ms)
-XX:G1HeapRegionSize=4m            # Region 大小,须为 2 的幂次,1~32MB
-XX:InitiatingHeapOccupancyPercent=45  # 堆使用率达 45% 触发并发标记周期
-XX:G1NewSizePercent=5             # 新生代最小占比
-XX:G1MaxNewSizePercent=60         # 新生代最大占比
-XX:G1MixedGCCountTarget=8         # Mixed GC 次数目标
-XX:G1MixedGCLiveThresholdPercent=85  # Region 中存活对象超过 85% 不回收

4.8 低延迟收集器

4.8.1 ZGC(Z Garbage Collector)

JDK 11 实验性引入,JDK 15 生产可用,JDK 16+ 支持并发线程栈处理

核心特点

  • 亚毫秒级停顿:所有 STW 阶段都在 1ms 以内,JDK 16+ 实现了并发线程栈处理(零 STW 候选)
  • 不分代(JDK 21 已加入分代支持):使用 Region(分为小型 2MB、中型 32MB、大型 2MB 的倍数)
  • 染色指针技术(Colored Pointer):将对象存活信息、地址信息记录在 64 位指针中,使用指针的 4 个 bit 存储元数据
  • 读屏障:在读取对象引用时执行额外检查(自愈机制),保证并发整理的正确性

染色指针结构(64位)

复制代码
┌──┬────┬─────────────────┬──────────────────────────┐
│  │    │                 │                          │
│  │ GC │    unused       │        对象地址            │
│  │标记│                 │    (实际可寻址范围)        │
│  └────┴─────────────────┴──────────────────────────┘
  4 bit   12 bit            48 bit
  (Finalizable, Remapped, Marked1, Marked0)

ZGC 回收阶段(几乎所有阶段都并发执行):

  1. 初始标记(STW,极短,<1ms)
  2. 并发标记/重映射
  3. 再标记前 STW(极短)
  4. 并发转移(整理)+ 并发重映射
4.8.2 Shenandoah

RedHat 主导开发,功能与 ZGC 类似,使用读屏障和 Brooks 转发指针实现并发整理。JDK 12 引入,JDK 15 生产可用。

4.9 如何选择垃圾收集器

复制代码
应用特征分析
  │
  ├─ 小数据量(< 100MB)、单机客户端
  │   └─▶ Serial + Serial Old
  │
  ├─ 追求吞吐量(批处理、科学计算)
  │   └─▶ Parallel Scavenge + Parallel Old
  │
  ├─ 低延迟(Web 应用、实时系统)、堆 ≤ 4~6GB
  │   └─▶ CMS(JDK 8)/ G1(JDK 9+,优先使用)
  │
  ├─ 大堆(> 6GB)、需要可控停顿
  │   └─▶ G1
  │
  └─ 超低延迟(< 1ms 停顿)、大堆
      └─▶ ZGC(JDK 15+)/ Shenandoah

五、JVM 调优实战

🔥 有经验候选人的分水岭:不是罗列命令,而是展示发现问题 → 分析 → 定位 → 解决的完整思路。

5.1 调优的目标和思路

三个维度

  1. 吞吐量:用户代码执行时间 / (用户代码执行时间 + GC 时间)
  2. 延迟:单次 GC 的停顿时间(STW 时长)
  3. 内存占用:GC 所需的内存开销

三者不可兼得,需要根据业务场景做权衡。

调优黄金法则

复制代码
凡事三思而后调 → 没有明确目标不调 → 不监控不调 → 可回滚可对比

5.2 常用 JVM 参数速查

内存参数
bash 复制代码
# 堆
-Xms2g                    # 初始堆大小
-Xmx4g                    # 最大堆大小
-Xmn1g                    # 新生代大小(或 -XX:NewRatio=2 设置老/新比例)
-XX:SurvivorRatio=8       # Eden:Survivor = 8:1:1

# 元空间
-XX:MetaspaceSize=256m    # 元空间初始大小
-XX:MaxMetaspaceSize=512m # 元空间上限

# 栈
-Xss1m                    # 每个线程栈大小

# 直接内存
-XX:MaxDirectMemorySize=512m

# 大对象阈值
-XX:PretenureSizeThreshold=3m  # 超过 3MB 的对象直接在老年代分配
GC 参数
bash 复制代码
# GC 选择
-XX:+UseG1GC              # 使用 G1
-XX:+UseZGC               # 使用 ZGC(JDK 15+)

# GC 日志(JDK 9+)
-Xlog:gc*:file=/path/gc-%t.log:time,tags,level:filecount=10,filesize=100M
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/gc.log

# OOM 诊断
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/heapdump
-XX:ErrorFile=/path/to/hs_err_pid%p.log
-XX:+ExitOnOutOfMemoryError

# GC 调优关键参数
-XX:MaxGCPauseMillis=200   # G1 期望停顿(默认 200ms)
-XX:G1HeapRegionSize=4m
-XX:InitiatingHeapOccupancyPercent=45
-XX:+ParallelRefProcEnabled  # 并行处理引用对象

5.3 常用诊断命令与工具

bash 复制代码
# jps --- 列出 Java 进程
jps -l -v

# jstat --- 实时监控 JVM 状态(最常用)
jstat -gc <pid> 1000 10       # GC 统计,每秒一次,共 10 次
jstat -gcutil <pid> 1000       # GC 各区域使用百分比
jstat -gccapacity <pid>        # 各区域容量
jstat -class <pid>             # 类加载统计

# jmap --- 内存分析
jmap -heap <pid>               # 堆概要
jmap -histo:live <pid> | head -20  # 堆中对象统计(触发 FGC 获取精确数据)
jmap -dump:format=b,file=heap.hprof <pid>  # 导出堆转储

# jstack --- 线程分析
jstack <pid>                   # 线程快照
jstack -l <pid>                # 含锁信息
# 排查死锁: jstack <pid> | grep -A 30 "deadlock"

# jinfo --- 查看运行时参数
jinfo -flags <pid>             # 所有 JVM 参数
jinfo -flag MaxHeapSize <pid>  # 单个参数

# jcmd --- JDK 7+ 统一诊断命令
jcmd <pid> GC.heap_info        # 同 jmap -heap
jcmd <pid> Thread.print        # 同 jstack
jcmd <pid> GC.run              # 触发 GC
jcmd <pid> VM.system_properties

5.4 典型问题排查案例

案例一:频繁 Full GC

现象:应用响应变慢,系统 CPU 飙升

排查流程

bash 复制代码
# 1. 查看 GC 频率和时间
jstat -gc <pid> 1000

# 2. 看到 FGC 次数持续增长,FGC 平均耗时 > 500ms
# 3. 分析 Eden 和 Old Gen 使用率
# 4. 可能原因:
#    - 对象分配速率过高
#    - 老年代空间不足(Minor GC 后的对象无法进入老年代)
#    - CMS 的 Concurrent Mode Failure

# 5. 导出 dump 分析大对象
jmap -dump:format=b,file=heap.hprof <pid>

常见解决方案

  • 调大堆空间或调整各区域比例
  • 分析代码中是否有内存泄漏点
  • 针对 CMS 可降低 CMSInitiatingOccupancyFraction
案例二:元空间 OOM

现象java.lang.OutOfMemoryError: Metaspace

排查

bash 复制代码
jstat -gc <pid>   # 查看 MU(Metaspace Used)持续增长
jmap -clstats <pid>  # 查看类加载器及加载的类数量

常见原因

  • 大量动态代理生成类(如 CGLIB、Javassist、Spring AOP)
  • 大量 JSP 文件(每个 JSP 编译为独立的类)
  • 类加载器泄漏(如频繁热部署后旧类加载器未回收)
案例三:CPU 100% 排查
bash 复制代码
# 1. 找到 CPU 最高的进程
top -H -p <pid>

# 2. 找到 CPU 最高的线程(十进制转十六进制)
printf "%x\n" <tid>

# 3. 查看线程状态
jstack <pid> | grep -A 30 <hex_tid>

# 4. 常见结果:
#    - 死循环代码 → 定位业务代码
#    - GC 线程高 CPU → GC 频繁,分析 GC 日志
#    - 锁竞争 → 多个线程 BLOCKED 在同一把锁上

六、面试高频 50 问精讲

以下问题根据近三年大厂面试真题整理,按频率排序,★★★ 为必考★★ 为高频★ 为选择性考察

类加载篇

Q1 ★★★ 类加载过程有哪几个阶段?每个阶段做了什么?

加载(获取二进制流→方法区运行时结构→生成 Class 对象)、链接(验证→准备→解析)、初始化(执行 <clinit>())。详见 2.1 节。

Q2 ★★★ 什么是双亲委派模型?为什么要用?如何打破?

三层委派机制→保证类唯一性和安全性→SPI/Tomcat/OSGi 三种打破场景。详见 2.3 节。

Q3 ★★ 你自定义过类加载器吗?怎么做?

继承 ClassLoader,重写 findClass() 方法(而不是 loadClass())。从特定来源(如加密的 class 文件、网络)获取字节流,调用 defineClass() 生成 Class 对象。

Q4静态变量和静态代码块的初始化顺序?

按代码中书写的顺序依次执行。父类的 <clinit>() 先于子类执行。注意:接口的 <clinit>() 不需要先执行父接口的 <clinit>(),只有在用到父接口中的变量/方法时才初始化父接口。

内存管理篇

Q5 ★★★ JVM 内存区域划分?每个区域的作用?

PC 寄存器、虚拟机栈、本地方法栈、堆、方法区/元空间。详见图 3.1。

Q6 ★★★ 堆和栈的区别?

① 线程共享(堆共享,栈私有);② 生命周期(堆由 GC 管理,栈随方法结束释放);③ 存储内容(堆存对象实例,栈存局部变量/方法参数/对象引用);④ 内存大小(堆远大于栈);⑤ 异常(堆 OOM,栈 StackOverflowError/OOM)。

Q7 ★★★ 对象创建过程是怎样的?

类加载检查→分配内存(指针碰撞/空闲列表)→初始化零值→设置对象头→执行 <init>()。详见 3.2.1 节。

Q8 ★★★ 对象的内存布局是怎样的?

对象头(Mark Word + Klass Pointer + 可能的数组长度)+ 实例数据 + 对齐填充。详见 3.2.2 节。

Q9 ★★★ 为什么 JDK 8 用元空间替代永久代?

永久代受 -XX:MaxPermSize 限制、难以确定大小、GC 复杂。元空间使用本地内存,OOM 概率更低。详见 3.1.5 节。

Q10 ★★★ 什么情况下对象会进入老年代?

① 年龄阈值(默认 15);② 动态年龄判定;③ 大对象直接分配;④ Survivor 空间不足。详见 3.3 节。

Q11 ★★ TLAB 是什么?为什么需要?

线程本地分配缓冲(Thread Local Allocation Buffer),每个线程在 Eden 区拥有私有的分配区域,避免并发分配内存时的锁竞争。-XX:+UseTLAB 默认开启。

Q12 ★★ 逃逸分析是什么?有什么作用?

分析对象作用域:对象未逃逸→栈上分配(无需 GC)+ 标量替换(打散为基本类型分配)+ 锁消除。JDK 6u23+ 默认开启。

Q13 ★★★ 什么情况会 OOM?你遇到过哪些?怎么排查的?

堆溢出、元空间溢出、栈溢出、直接内存溢出、GC overhead limit exceeded 等。完整排查流程见 3.4.3 节。

Q14 ★★★ 内存泄漏怎么排查?

① 观察监控指标(Old Gen 持续增长,Full GC 后内存不下降)→ ② jmap -dump 导出 → ③ MAT 分析 Dominator Tree → ④ 追踪 GC Root 引用链 → ⑤ 定位代码。常见场景见 3.4.2 节。

Q15 ★★★ ThreadLocal 为什么会导致内存泄漏?如何避免?

Entry 的 Key 是弱引用(GC 后变 null),Value 是强引用(永不释放),线程池中线程复用导致累积。必须 finally { threadLocal.remove(); }。详见 3.4.2 节。

Q16 ★★★ String 的 intern() 方法作用?JDK 6/7/8 中字符串常量池的位置?

将字符串放入常量池并返回引用。JDK 6:方法区(永久代);JDK 7+:堆中。intern() 在 JDK 7 后首次遇到字符串时将堆中的对象引用记录到常量池中(而不是复制一份)。

垃圾回收篇

Q17 ★★★ 如何判断一个对象是否应该被回收?

可达性分析算法:从 GC Roots 出发,无法到达的对象判定为垃圾。详见 4.1.2。

Q18 ★★★ GC Roots 有哪些?

虚拟机栈中的引用、静态变量、常量池引用、JNI 引用、被锁持有的对象、JVM 内部引用。详见 4.1.3。

Q19 ★★★ Java 中有哪几种引用类型?分别用于什么场景?

强/软/弱/虚引用。软引用→缓存;弱引用→WeakHashMap、ThreadLocal;虚引用→堆外内存回收跟踪。详见 4.2。

Q20 ★★★ 讲讲你知道的垃圾回收算法?

标记-清除(碎片多)、标记-复制(新生代,无碎片但浪费空间)、标记-整理(老年代,无碎片但 STW 长)。详见 4.4。

Q21 ★★★ 为什么新生代要用复制算法?

新生代对象 98% 朝生夕灭,存活率极低,复制成本很小。复制算法无碎片且分配效率高(指针碰撞)。

Q22 ★★★ CMS 的回收过程是怎样的?有什么优缺点?

四个阶段:初始标记→并发标记→重新标记→并发清除。优点:低停顿;缺点:浮动垃圾、Concurrent Mode Failure、内存碎片。详见 4.7.2。

Q23 ★★★ G1 收集器的核心原理?和 CMS 有什么区别?

Region 分配 + SATB 并发标记 + Mixed GC。对比 CMS 见 4.7.3 节对照表。
💡 加分回答:G1 的 SATB 保证标记的正确性但会产生更多浮动垃圾,CMS 的增量更新浮动垃圾少但重新标记阶段更慢------这是两种不同的 trade-off。

Q24 ★★★ 什么是 STW?为什么需要 STW?

Stop The World。可达性分析要求对象引用关系的"一致性快照",GC 期间必须暂停所有修改对象引用的用户线程。详见 4.5。

Q25 ★★ 什么是三色标记法?CMS 和 G1 分别如何处理漏标问题?

白色(未访问)→ 灰色(已访问,扫描中)→ 黑色(已访问且扫描完成)。漏标需要同时满足插入+删除两个条件。CMS:增量更新(插入黑色→白色引用时,将黑色变灰);G1:SATB(删除灰色→白色引用时,记录被删引用)。详见 4.7.2 和 4.7.3。

Q26 ★★ 什么是跨代引用?怎么处理?

老年代对象引用新生代对象。Minor GC 需要扫描这些引用来确定新生代对象存活。解决方式:卡表(Card Table)------ 老年代被划分为 512 字节的卡片,一旦老年代对象引用新生代对象,对应的卡片变"脏",Minor GC 时只扫描脏卡片而非整个老年代。

Q27 ★★ ZGC 了解吗?为什么能做到超低延迟?

染色指针 + 读屏障 + 并发整理。几乎所有阶段都并发执行,STW 阶段小于 1ms。详见 4.8.1。

Q28 ★★ 什么时候会触发 Full GC?

System.gc() 建议执行;② 老年代空间不足;③ 方法区/元空间不足;④ Minor GC 后晋升老年代的平均大小 > 老年代剩余空间;⑤ CMS 的 Concurrent Mode Failure;⑥ G1 的 Evacuation Failure。

Q29Minor GC、Major GC、Full GC 的区别?

  • Minor GC / Young GC:仅回收新生代(Eden + Survivor)
  • Major GC / Old GC:仅回收老年代(CMS 的并发回收就是 Major GC)
  • Full GC:回收整个堆 + 方法区/元空间,STW 时间最长
  • Mixed GC(G1 特有):回收新生代 + 部分老年代 Region

性能调优篇

Q30 ★★★ 你做过 JVM 调优吗?说说具体过程和思路?

① 明确目标(吞吐量、延迟、内存占用)→ ② 收集指标(GC 日志、监控平台)→ ③ 分析问题(GC 频率、STW 时长、内存趋势)→ ④ 调整参数(逐步调整,一次只改一个)→ ⑤ 验证对比。详见第五章。

Q31 ★★★ 线上突然 CPU 100% 怎么排查?

top 找进程→top -H 找线程→jstack 定位线程状态→分析是死循环/GC/锁竞争。详见 5.4 案例三。

Q32 ★★★ 频繁 Full GC 怎么排查?

jstat -gc 观察频率→jmap dump→MAT 分析大对象/泄漏→检查 GC 参数是否合理。详见 5.4 案例一。

Q33 ★★ 高并发场景下 JVM 参数如何设置?

堆足够大但不超过物理内存的 75%;使用 G1 并设置 MaxGCPauseMillis;打开 GC 日志;设置 -XX:+HeapDumpOnOutOfMemoryError-XX:+AlwaysPreTouch 减少运行时缺页开销。

Q34常用的性能监控工具有哪些?

命令行:jstat、jmap、jstack、jcmd、jinfo;可视化:JVisualVM、JConsole、Java Mission Control(JMC + JFR);第三方:Arthas(阿里巴巴,功能极其强大)、MAT、GCViewer。

进阶原理篇

Q35 ★★★ synchronized 底层是如何实现的?锁升级过程是怎样的?

Monitor 机制(monitorenter/monitorexit 指令)+ 锁升级:无锁 → 偏向锁 → 轻量级锁(CAS 自旋)→ 重量级锁(OS 互斥量)。Mark Word 中记录了锁状态。

Q36 ★★ volatile 的实现原理?

① 可见性:写 volatile 变量后强制刷新到主内存,读之前强制从主内存加载(内存屏障 lock 前缀指令);② 禁止指令重排:内存屏障禁止特定类型的指令重排序。

Q37 ★★ happens-before 原则是什么?

JMM(Java 内存模型)定义的偏序关系,保证可见性:① 程序次序规则;② 管程锁定规则(unlock happens-before lock);③ volatile 规则(写 happens-before 读);④ 传递性;⑤ 线程 start/join 规则等。

Q38CAS 的原理?ABA 问题怎么解决?

Compare And Swap:比较内存值与期望值,相等则更新(原子操作,CPU cmpxchg 指令)。ABA:用 AtomicStampedReference 加版本号解决。


附录

A. JVM 参数速查表

分类 参数 说明 默认值
-Xms 初始堆大小 物理内存的 1/64
-Xmx 最大堆大小 物理内存的 1/4
-Xmn 新生代大小
-XX:NewRatio 老年代:新生代比例 2
-XX:SurvivorRatio Eden:Survivor 比例 8
-Xss 线程栈大小 1MB(Linux x64)
元空间 -XX:MetaspaceSize 元空间初始大小 ~21MB
元空间 -XX:MaxMetaspaceSize 元空间上限 无限制
GC -XX:+UseG1GC 使用 G1 JDK 9+ 默认
GC -XX:+UseZGC 使用 ZGC
GC -XX:MaxGCPauseMillis 期望最大 STW(G1) 200ms
GC 日志 -Xlog:gc* GC 日志(JDK 9+)
OOM -XX:+HeapDumpOnOutOfMemoryError OOM 时 dump false
OOM -XX:HeapDumpPath dump 文件路径 当前目录

B. GC 日志解读示例

G1 GC 日志片段

复制代码
[2024-01-15T10:30:00.123+0800][info][gc] GC(100) Pause Young (Normal) (G1 Evacuation Pause) 85M->62M(200M) 12.3ms
[2024-01-15T10:30:05.456+0800][info][gc] GC(101) Pause Initial Mark (G1 Evacuation Pause) 90M->70M(200M) 15.1ms
[2024-01-15T10:30:10.789+0800][info][gc] GC(102) Pause Remark 75M->75M(200M) 2.1ms
[2024-01-15T10:30:15.012+0800][info][gc] GC(103) Pause Mixed (G1 Evacuation Pause) 80M->50M(200M) 25.8ms

解读:

  • GC(100):第 100 次 GC
  • Pause Young (Normal):普通新生代 GC(G1 中称为 Evacuation Pause)
  • 85M->62M(200M):GC 前堆占用 85MB → GC 后 62MB,堆总大小 200MB
  • 12.3ms:本次 GC STW 耗时 12.3 毫秒
  • Pause Initial Mark:初始标记阶段(并发标记周期的开始)
  • Pause Mixed:Mixed GC(同时回收新生代和部分老年代 Region)

C. 推荐阅读


📝 写作说明:本文以面试为导向,系统梳理 JVM 从基础概念到调优实战的全部知识体系。内容结构上,前半部分按知识点展开,后半部分聚焦面试问答,每一节都标注了面试热度等级,方便读者按优先级复习。文中"💡"标注的内容属于加分知识点,掌握后可在面试中展现更深入的理解。

相关推荐
糖果店的幽灵1 小时前
人已经用 WorkBuddy 找工作拿了面试,你还在一份份手工改简历
人工智能·面试·职场和发展·langgraph
liang_jy2 小时前
文件管理(六)—— 文件共享和保护
面试·操作系统
liang_jy2 小时前
文件管理(五)—— 文件的基本操作
面试·操作系统
Mark_ZP3 小时前
JVM OOM 排查与真实案例复盘
jvm
Revolution616 小时前
页面更新后为什么出现 Loading chunk failed:旧页面如何请求了已删除的构建产物
前端·面试·前端工程化
thesky1234568 小时前
27届大模型岗面试准备(五):预训练全流程拆解——从数据清洗到 Tokenizer 再到 PT 的每
人工智能·面试·大模型·预训练·tokenizer
众人皆醒我独醉9 小时前
为什么 AI 每次回答不一样?—— 温度参数是 AI 的"创意调节旋钮"
面试·ai编程
音符犹如代码11 小时前
Arthas Profiler 火焰图实战:CPU 热点在哪一目了然
java·jvm·spring boot
清泓y11 小时前
深度学习算法
算法·ai·语言模型·面试