一文掌握JVM

内存结构/运行时数据分区

背景知识:

JVM运行流程:

!在这里插入图片描述

JVM 内存结构 复习笔记


一、整体结构

复制代码
┌─────────────────────────────────────────────────────────┐
│                    JVM 运行时数据区                      │
│                                                         │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │  线程私有     │  │  线程私有     │  │  线程私有     │  │
│  │  程序计数器   │  │  虚拟机栈     │  │  本地方法栈   │  │
│  └──────────────┘  └──────────────┘  └──────────────┘  │
│                                                         │
│  ┌──────────────────────────────────────────────────┐   │
│  │              线程共享                             │   │
│  │  ┌────────────────────────────────────────────┐  │   │
│  │  │              堆(Heap)                     │  │   │
│  │  │  年轻代 + 老年代                            │  │   │
│  │  └────────────────────────────────────────────┘  │   │
│  │  ┌────────────────────────────────────────────┐  │   │
│  │  │           方法区(Method Area)             │  │   │
│  │  │  类信息、静态变量、常量、编译后的代码        │  │   │
│  │  └────────────────────────────────────────────┘  │   │
│  └──────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  直接内存(不属于 JVM 管理,操作系统内存)                │
└─────────────────────────────────────────────────────────┘

二、各区域详解

1. 程序计数器(Program Counter)

维度 说明
线程私有/共享 线程私有
作用 记录当前线程正在执行的字节码指令地址
物理实现 寄存器
GC 不涉及
OOM 不会
唯一为 null 的情况 执行 native 方法时,计数器值为 undefined

2. 虚拟机栈(VM Stack)

维度 说明
线程私有/共享 线程私有
作用 每个线程运行时需要的内存
组成 多个栈帧,每个栈帧对应一次方法调用
栈帧存储内容 方法参数、局部变量、方法返回地址
活动栈帧 同一时刻只有一个,对应正在执行的方法
GC 不涉及
溢出 递归过深 → StackOverflowError

3. 本地方法栈(Native Method Stack)

维度 说明
线程私有/共享 线程私有
作用 native 方法运行时需要的内存
什么是 native 方法 用 C/C++ 编写的方法,Java 通过接口间接调用
为什么需要 Java 不能直接和操作系统底层打交道,通过 native 方法调用 OS API

4. 堆(Heap)

维度 说明
线程私有/共享 线程共享
作用 保存对象实例
逻辑组成 年轻代(生命周期短)+ 老年代(生命周期长)
GC 主要回收区域

JDK7 vs JDK8 变化:

复制代码
JDK7:方法区的实现 = 永久代(在堆内,大小固定)
JDK8:方法区的实现 = 元空间(在本地内存,大小自动调整)

5. 方法区(Method Area)

维度 说明
线程私有/共享 线程共享
存储内容 类信息、静态变量、常量、编译后的代码、运行时常量池

JDK7 vs JDK8:

版本 实现名称 内存位置 大小
JDK7 永久代(PermGen) 堆内 固定(-XX:PermSize)
JDK8 元空间(Metaspace) 本地内存 自动调整(-XX:MaxMetaspaceSize)

6. 直接内存(Direct Memory)

维度 说明
是否属于 JVM 不属于,是操作系统内存
常见场景 NIO 操作的数据缓冲区
特点 分配回收成本高,但读写性能高
访问 Java 代码和系统代码都能访问

三、面试对比表

区域 线程私有/共享 存储内容 会 GC? 会 OOM?
程序计数器 私有 字节码指令地址 ❌ ❌
虚拟机栈 私有 栈帧(局部变量、参数) ❌ StackOverflowError
本地方法栈 私有 native 方法调用 ❌ StackOverflowError
堆 共享 对象实例 ✅ 主要区域 OOM: heap space
方法区 共享 类元信息、常量 ✅ 回收类 OOM: Metaspace
直接内存 - NIO 缓冲区 ❌ OOM: direct buffer

对象的分配流程

问题背景

new Object() 创建一个对象,这个对象放哪?

直觉答案:放堆里。

JVM 实际想法:能不放堆就不放,堆里放多了 GC 很累。

所以 JVM 在分配对象时,会按优先级尝试三个地方:

复制代码
优先级 1:栈上        (最快,零 GC)
优先级 2:TLAB        (较快,线程私有)
优先级 3:堆          (最慢,要 GC)

第 1 步:逃逸分析 + 栈上分配

什么是"逃逸":对象逃出当前方法的作用域,被外面的代码拿到。

复制代码
不逃逸(可以栈上分配):
  void method() {
      Object obj = new Object();
      System.out.println(obj.toString());
  }  ← 方法结束,obj 随栈帧销毁,没人再用到它

不能栈上分配(逃逸了):
  Object method() {
      Object obj = new Object();
      return obj;   ← 返回给调用者,逃逸了!必须放堆里
  }

栈上分配的好处:

复制代码
堆分配:  对象在堆里 → GC 扫描堆 → 发现没引用了 → 回收 → 压缩
栈分配:  对象在栈帧里 → 方法结束 → 栈帧弹出 → 直接没了,零 GC

第 2 步:TLAB(Thread Local Allocation Buffer)

如果对象必须放堆里,JVM 也不会直接在堆上抢地盘------那多线程会冲突。

TLAB 的原理:

复制代码
堆内存被预先切出一小块,每个线程独占一块:

┌─────────────────────────────────────────┐
│                堆(Heap)                │
│  ┌──────────┬──────────┬──────────┐     │
│  │ TLAB-A   │ TLAB-B   │ TLAB-C   │     │
│  │ 线程1专用 │ 线程2专用 │ 线程3专用 │     │
│  └──────────┴──────────┴──────────┘     │
└─────────────────────────────────────────┘

线程1 要 new 对象 → 自己在 TLAB-A 里划一块 → 不用加锁
线程2 要 new 对象 → 自己在 TLAB-B 里划一块 → 不用加锁

好处:每个线程在自己的 TLAB 里分配,不用抢锁,速度快。

TLAB 满了怎么办:回到第 3 步,直接在堆里分配。


第 3 步:堆分配

TLAB 也满了,或者对象太大(一个对象比整个 TLAB 还大),直接在堆的 Eden 区分配。

复制代码
对象太大(比如大数组)→ 跳过栈、跳过 TLAB → 直接进堆
TLAB 满了           → 当前 TLAB 不够用 → 直接在堆 Eden 分配
                    → 或者申请一个新的 TLAB 再分配

完整流程图

复制代码
new Object()
    │
    ▼
逃逸分析:这个对象会不会逃出当前方法?
    │
    ├─ 不会逃逸 ──→ 栈上分配 ──→ 方法结束自动销毁 ──→ 完成 ✅
    │
    └─ 会逃逸 ──→ 必须放堆里
                    │
                    ▼
              本线程 TLAB 有空间吗?
                    │
                    ├─ 有 ──→ TLAB 分配 ──→ 完成 ✅
                    │
                    └─ 没有 ──→ 堆 Eden 分配 ──→ 完成 ✅

一句话总结

JVM 分配对象的优先级:栈(不逃逸) > TLAB(线程私有缓冲区) > 堆。能轻量就轻量,实在不行才放堆里给 GC 擦屁股。

!在这里插入图片描述

TLAB

定义:JVM的一种内存分配的优化机制。用于提高多线程环境下,内存分配的效率

问题引入:多线程同时new对象时,JVM为其分配内存,为了防止内存重复分配,需要加入互斥锁,降低了内存分配的速度

解决方案:引⼊了TLAB的概念,Threal Local Allocate Buffer,是每个线程在Eden区专属的⼀块区域,当线程的TLAB区域未满时,可以直接在其中分配内存,不需要加锁,提⾼内存分配速

度。

当⼀个线程创建的时候,JVM会为这个线程分配⼀个固定⼤⼩的TLAB空间

TLAB 仅优化内存分配:TLAB 的线程安全仅体现在内存分配过程中,与对象本身的线程安全性⽆关

垃圾回收(GC)复习笔记


一、什么是垃圾

判断方法

方法 原理 问题
引用计数法 对象被引用时计数 +1,引用失效时 -1,计数为 0 就是垃圾 无法解决循环引用
可达性分析(Java 采用) 从 GC Roots 出发,不可达的对象就是垃圾

GC Roots 包括

  • 虚拟机栈中引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中 JNI 引用的对象

二、垃圾回收算法

算法 优点 缺点
标记-清除 速度快 产生内存碎片
标记-整理 无碎片 效率低,对象移动复杂
复制 无碎片,效率高 浪费一半空间

三、分代垃圾回收

核心思路:对象生命周期不同,差异化管理。

复制代码
堆
├── 年轻代(朝生夕死)→ 频繁 GC → 用复制算法(快,不在乎浪费空间)
└── 老年代(存活很久)→ 很少 GC → 用标记-整理算法(不碎片,慢点无所谓)

四、垃圾回收器

按代分

回收器 针对代 算法 特点
Serial / ParNew 年轻代 复制 简单
CMS 老年代 标记-清除 追求低延迟
G1 全年代 复制 + 标记整理 可预测停顿时间
Parallel GC 全年代 并行 JDK8 默认

CMS

目标:响应时间优先,低停顿

实现方式:

  1. 用标记-清除算法(速度快)
  2. 并发标记和并发清理阶段允许用户线程和 GC 线程同时工作

弊端:

  • 内存碎片(标记-清除的代价)
  • 浮动垃圾(并发清理时产生的新垃圾)

G1

目标:全年代回收,可设置 STW 暂停时间目标(如 200ms)

实现方式:

  1. 把堆分成若干大小相等的 Region
  2. 只回收垃圾多的 Region,垃圾少的暂时不回收
  3. 控制回收范围 → 控制停顿时间

五、GC 调优

可调整的参数

  • 堆内存大小(-Xms / -Xmx)
  • 年轻代大小(-Xmn)
  • 年轻代与老年代比例(默认 1:2)
  • Eden 与 Survivor 比例(默认 8:1:1)
  • 元空间大小
  • 垃圾回收器选择

如何选择回收器

场景 选择
默认 / 吞吐量优先 Parallel GC
低延迟 / 响应时间优先 CMS
超大堆 / 可预测停顿 G1

JVM 自身对 GC 的优化

  • JDK7→JDK8:方法区从堆内永久代移到本地内存元空间,防止动态加载类过多影响堆 GC
  • 逃逸分析 + 栈上分配:对象不进堆,减少 GC 压力

六、面试回答模板

什么是垃圾:Java 用可达性分析判断,从 GC Roots 出发,不可达的对象就是垃圾。

三种算法:标记-清除(快但有碎片)、标记-整理(无碎片但慢)、复制(无碎片高效但浪费空间)。

分代回收:年轻代对象朝生夕死用复制算法,老年代对象存活久用标记-整理。

CMS:针对老年代,标记-清除 + 并发标记/清理,追求低延迟,但会有内存碎片。

G1:把堆分 Region,只回收垃圾多的区域,可设置暂停时间目标如 200ms。

调优:先监控告警,再根据业务特点选回收器、调堆大小。

垃圾回收器

一、CMS(Concurrent Mark Sweep)

设计目标

响应时间优先,减少 STW 时间,适用于低延迟、低停顿的系统。

回收过程(4 阶段)

阶段 是否 STW 做什么
1. 初始标记 ✅ 需 STW 标记 GC Roots 直接关联的对象(很快)
2. 并发标记 ❌ 并发 从初始标记的对象出发,遍历整个对象图,标记所有可达对象
3. 重新标记 ✅ 需 STW 处理并发标记期间用户线程导致的引用变化,补漏
4. 并发清理 ❌ 并发 清理未标记的垃圾对象

如何实现短 STW

  • 耗时最长的"标记"和"清理"阶段,都允许和用户线程并发执行
  • 只有初始标记和重新标记需要 STW,这两步很快
  • 使用标记-清除算法,速度快

两个弊端

1. 内存碎片

复制代码
原因:标记-清除算法不整理内存
      ┌───┬─────┬───┬───────┬───┐
      │ █ │     │ █ │       │ █ │   █ = 存活对象,空白 = 碎片
      └───┴─────┴───┴───────┴───┘
后果:总空间够,但没有连续空间放大对象 → 分配失败 → 触发 Full GC

2. 浮动垃圾

复制代码
定义:并发清理阶段,用户线程继续产生的新垃圾
后果:浮动垃圾占满老年代 → 并发模式失败 → 触发 Full GC

Full GC 退化

CMS 发生 Full GC 时,退化为 Serial Old 回收器:

  • 单线程,STW,不允许用户线程工作
  • 使用标记-整理算法
  • 长时间 STW,这就是 CMS 最怕的事

二、G1(Garbage First)

核心变化:分区取代分代

复制代码
传统分代:                G1 分区:
┌────────┬────────┐       ┌───┬───┬───┬───┬───┬───┐
│ 新生代 │ 老年代 │       │ E │ S │ O │ E │ H │ O │
└────────┴────────┘       └───┴───┴───┴───┴───┴───┘
  两块固定区域              若干大小相等的 Region
                          类型动态分配:
                          E = Eden, S = Survivor
                          O = Old,  H = Humongous(大对象)

核心特点:可预测的停顿时间

可以设置每次 GC 的 STW 目标,比如 不超过 200ms。

实现原理:

复制代码
1. 每个 Region 都会产生垃圾
2. GC 时只选垃圾多的 Region 回收,垃圾少的暂时不管
3. 范围缩小 → 节省时间
4. 只回收垃圾多的 → 回收效率高
5. 控制回收的 Region 数量 → STW 时间接近设定目标

回收过程(4 阶段)

阶段 是否 STW 做什么
1. 初始标记 ✅ 需 STW 标记 GC Roots 直接关联的对象
2. 并发标记 ❌ 并发 遍历对象图,标记所有可达对象
3. 最终标记 ✅ 需 STW 处理并发标记期间的引用变化,补漏
4. 混合回收 ✅ 需 STW 选择新生代、老年代、大对象中的部分 Region 回收(复制存活对象到空 Region,清空原 Region)

适用场景

超大堆内存 + 高吞吐量 + 低延迟 + 可预测停顿时间。


三、CMS vs G1 对比

维度 CMS G1
堆结构 分代(新生代/老年代) 分区(Region)
算法 标记-清除 复制 + 整理
内存碎片 有 无(复制算法天然整理)
停顿时间 短但不可预测 可设定目标(如 200ms)
Full GC 退化 退化为 Serial Old(长时间 STW) 无此问题
适用场景 低延迟,堆不大 超大堆,可预测停顿

四、面试要点

CMS:用"并发标记+并发清理"缩短 STW 时间,但有内存碎片和浮动垃圾两个弊端,Full GC 会退化为 Serial Old 导致长时间停顿。

G1:把堆分成若干 Region,每次只回收垃圾最多的 Region,控制停顿时间可预测。用复制算法解决碎片问题。适合超大堆场景。

类加载器与双亲委派


一、什么是类加载器

作用 :将字节码文件(.class)加载到 JVM 内存中。


二、四种类加载器

复制代码
优先级从上到下递减:

┌──────────────────────────────────────────────────┐
│  Bootstrap ClassLoader(启动类加载器)            │
│  加载位置:JAVA_HOME/jre/lib 下的核心类           │
│  举例:rt.jar 中的 java.lang.*、java.util.*      │
│  实现:C/C++ 编写,无法直接访问                    │
│  上级:无(最顶层)                               │
└──────────────────────┬───────────────────────────┘
                       │ 委派给上级
┌──────────────────────┴───────────────────────────┐
│  Extension ClassLoader(扩展类加载器)             │
│  加载位置:JAVA_HOME/jre/lib/ext 下的类           │
│  上级:Bootstrap                                  │
└──────────────────────┬───────────────────────────┘
                       │ 委派给上级
┌──────────────────────┴───────────────────────────┐
│  Application ClassLoader(应用程序类加载器)        │
│  加载位置:classpath 目录(我们写的代码)          │
│  上级:Extension                                  │
└──────────────────────┬───────────────────────────┘
                       │ 委派给上级
┌──────────────────────┴───────────────────────────┐
│  自定义类加载器                                    │
│  可以实现自定义加载规则                            │
│  上级:Application                                │
└──────────────────────────────────────────────────┘

注意 :父子关系用组合实现,不是继承。


三、什么是双亲委派

行为规则:加载类时,类加载器的行动流程:

复制代码
收到类加载请求
    │
    ▼
自己不先加载,委派给父加载器
    │
    ▼
父加载器也委派给它的父加载器
    │
    ▼
一路传到顶层 Bootstrap ClassLoader
    │
    ▼
Bootstrap 能加载?→ 加载完成,返回
Bootstrap 不能?→ 层层返回,子加载器自己尝试

一句话 :先让上面的加载,上面搞不定自己再上。


四、双亲委派的好处

好处 解释
类的唯一性 父加载器已加载的类,子加载器不会重复加载,避免同一个类出现多份
类的安全性 防止用户自定义类覆盖核心类(比如自己写一个 java.lang.String),核心类库不会被篡改

五、双亲委派的实现

核心代码在 java.lang.ClassLoader 的 loadClass() 方法:

复制代码
loadClass(name):
    1. 检查类是否已加载 → 已加载直接返回
    2. 有父加载器?→ 调用 parent.loadClass(name)
       没父加载器?→ 调用 Bootstrap 加载
    3. 父加载器加载成功 → 返回
    4. 父加载器加载失败(ClassNotFoundException)→ 调用自己的 findClass(name)

六、如何破坏双亲委派

方法 :自定义类加载器,重写 loadClass() 方法,内部逻辑不遵循双亲委派(比如优先自己加载,不委派给父)。


七、经典案例:Tomcat 为什么破坏双亲委派

问题

复制代码
一个 Tomcat 同时运行多个 Web 应用:
  应用A:依赖 Spring 5.0
  应用B:依赖 Spring 4.0

  两个 Spring 的全类名完全一样(org.springframework.core.*)
  → 双亲委派下,Application ClassLoader 只会加载一份
  → 两个应用只能用同一个版本的 Spring → ❌

Tomcat 的解决方案

复制代码
每个 Web 应用 → 独立的 WebAppClassLoader

WebAppClassLoader 加载逻辑:
  私有类(应用自己的类)→ 不遵循双亲委派,自己加载
  公共类(如 Spring)→ 正常委派给 SharedClassLoader

结果:
  应用A 的 WebAppClassLoader 加载 Spring 5.0
  应用B 的 WebAppClassLoader 加载 Spring 4.0
  全类名相同,但类加载器不同 → JVM 视为不同类 ✅

共享类怎么处理

复制代码
公共 jar 包(各应用版本一样)→ SharedClassLoader 加载
  → 所有 Web 应用共享,避免重复加载
  → 节省内存

八、面试要点

双亲委派:类加载时先委派给父加载器,父搞不定自己再加载。好处是保证类的唯一性和安全性(防止核心类被覆盖)。

实现 :代码集中在 ClassLoader.loadClass() 方法。破坏方式:重写 loadClass(),不遵循委派逻辑。

Tomcat 为什么破坏:多个 Web 应用可能依赖同名但不同版本的类库,双亲委派无法加载两份同名类。Tomcat 用 WebAppClassLoader 各自加载私有类,SharedClassLoader 共享公共类。

JVM 调优


一、可以调什么

参数 默认值 含义
堆内存初始大小 / 最大大小 -Xms / -Xmx 整个堆的内存范围
年轻代初始大小 / 最大大小 -Xmn 年轻代内存范围
年轻代与老年代比例 1:2 年轻代占堆的 1/3
Eden 与 Survivor 比例 8:1:1 Eden 占年轻代的 80%
元空间初始大小 / 最大大小 -XX:MetaspaceSize / -XX:MaxMetaspaceSize 方法区内存
垃圾收集器 JDK8 默认 Parallel GC 可换 CMS / G1

二、为什么要调

因为区域大小直接影响程序运行效果。

Eden 区大小的影响

复制代码
Eden 很小 → 很快填满 → YoungGC 频繁 → STW 次数多
Eden 很大 → 填满慢 → 但一次 YoungGC 要清理的垃圾多 → 单次耗时长

Survivor 区大小的影响

复制代码
Survivor 很小 → YoungGC 后存活对象放不下 → 直接晋升老年代 → 不该晋升的晋升了 → FullGC
Survivor 很大 → 一半区域始终不用(两个 Survivor 只用一个)→ 浪费内存

为什么要调优的核心原因

YoungGC 全程 STW,期间请求得不到响应:

复制代码
正常请求:10ms 完成
处理到 5ms 时发生 YoungGC,耗时 50ms
→ 实际响应时间 = 5ms + 50ms + 5ms = 60ms → 超时

三、背景知识:Old GC vs Full GC

复制代码
CMS 中:
  Old GC   → 只回收老年代
  Full GC  → 回收整个堆(年轻代 + 老年代 + 元空间)

Full GC 触发时机:
  1. CMS Old GC 回收失败(内存碎片过多)
  2. 元空间不足(OutOfMemoryError: Metaspace)
  3. 显式调用 System.gc()

四、面试回答策略

大多数情况下,JVM 默认参数已经够用,不需要调优先配置监控告警(GC 耗时、GC 频率),出现波动再人为介入分析结合具体案例说明调优思路


五、三个调优案例

案例一:CMS 内存碎片导致 Full GC

项目 内容
环境 ParNew + CMS + JDK8
现象 C端核心业务高峰期发生 Full GC,耗时 1s,请求超时
原因 CMS 使用标记-清除算法,老年代碎片化严重。总空间够但没有连续空间分配大对象
策略 业务低峰期(凌晨四点)显式调用 System.gc(),触发 Full GC 退化为 Serial Old,用标记-整理算法整理内存碎片
效果 高峰期基本不再发生 Full GC

复盘:CMS 追求低延迟选择了标记-清除,代价是内存碎片。利用业务低峰期主动整理,保证高峰期稳定。


案例二:年轻代过小导致 GC 频繁

项目 内容
环境 ParNew + CMS + JDK8
现象 YGC 每分钟 50 次(每次 25ms),OldGC 几分钟 1 次(每次 200ms)
原因 为了 YGC 快,年轻代设太小 → YGC 频繁 → 大量对象过早晋升老年代 → OldGC 也频繁
策略 年轻代内存扩大到原来的 3 倍
效果 单次 YGC 耗时只增加 5%,YGC 频率降低 60%,OldGC 降到几小时 1 次

解释:YGC 用复制算法,耗时主要在复制阶段,大部分对象还是被回收了,所以扩大年轻代后单次耗时只是轻微增加。


案例三:G1 暂停时间目标过小

项目 内容
环境 G1 + JDK8
现象 YoungGC 频繁
原因 -XX:MaxGCPauseMillis 设置过小,JVM 自动缩小年轻代的 Region 数量来满足时间目标
策略 方案1:调大暂停时间目标参数;方案2:将年轻代 Region 大小设为固定值

六、面试时如何回答

首先,默认参数大多数情况够用,不需要调优。

其次,配置 GC 监控告警(耗时、频率),出现波动再介入分析。

最后举个例子:C端业务要求响应时间 < 100ms,高峰期发生 Full GC 导致超时。

原因是 CMS 标记-清除算法产生内存碎片,老年代没有连续空间放大对象。

解决方案是在业务低峰期主动触发 Full GC,让 CMS 退化为 Serial Old 做标记整理,

把内存碎片整理掉,保证高峰期稳定。

相关推荐
顺风尿一寸9 小时前
从 FileOutputStream 到 int fd:一次 JNI 字段读写全链路源码剖析
linux·jvm
用户094248568039 小时前
第36章:JVMTI、Java Agent与运行时扩展点
java·jvm
脚踏实地,坚持不懈!10 小时前
Linux 内存回收机制:水位线、kswapd 与 Direct Reclaim
linux·jvm·算法
wuminyu10 小时前
ZGC染色指针在虚拟线程stackChunkOop上的并发屏障全景分析
java·linux·c语言·jvm·c++
江湖有缘1 天前
Docker实战 | 使用Docker部署rmoments社交平台
jvm·docker·oracle
H.莓飛1 天前
【C++】内存管理
linux·开发语言·jvm·c++·visual studio
10mAh1 天前
【SQL】NOT IN 遇到 NULL 为什么返回空集?三值逻辑与反关联实战
jvm·数据库·sql
wuminyu1 天前
stackChunkOop内部结构与GC屏障的配合工作原理剖析
java·linux·c语言·jvm·c++
老三牛擦2 天前
订单履约流程
jvm