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.String、java.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.class、NullPointerException
反映 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。

Q29 ★ Minor 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 规则等。

Q38 ★ CAS 的原理?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 从基础概念到调优实战的全部知识体系。内容结构上,前半部分按知识点展开,后半部分聚焦面试问答,每一节都标注了面试热度等级,方便读者按优先级复习。文中"💡"标注的内容属于加分知识点,掌握后可在面试中展现更深入的理解。

相关推荐
晨小安5 小时前
ThreadLocal及其内存泄漏问题深度解析
java·jvm·tomcat·哈希表
我叫黑大帅6 小时前
Go日志库工程选型与逃逸分析评测报告
后端·面试·go
此时不提桶,更待何时8 小时前
05-05-B-对象存储与文件服务面试与生产事故实战
面试·nosql
晚安日记wanna9 小时前
大厂禁 JOIN 的真正原因,拆到第四层才清楚
数据库·后端·面试
多喝水身体棒9 小时前
Spring Boot 自动配置原理,看完终于不慌了
面试·程序员
丑陋小蚊子11 小时前
有作品集的岗位,简历和作品各写什么?
面试·求职招聘·大学生·简历·简历下载
多多爱学习12 小时前
括号有几层?得看还有多少个左括号没闭合
c语言·c++·算法·面试
丑陋小蚊子12 小时前
同一家公司升过岗,简历别合成一段
面试·求职招聘·大学生·简历·简历下载
jingli912 小时前
一个测试用例把连接池卡死 30 秒:SQLite 连接池 max_size=1 下的重入自锁复盘
jvm·sqlite·测试用例
此时不提桶,更待何时12 小时前
05-04-B-HBase与时序库面试与生产事故实战
数据库·面试·hbase