内存结构/运行时数据分区
背景知识:
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
目标:响应时间优先,低停顿
实现方式:
- 用标记-清除算法(速度快)
- 并发标记和并发清理阶段允许用户线程和 GC 线程同时工作
弊端:
- 内存碎片(标记-清除的代价)
- 浮动垃圾(并发清理时产生的新垃圾)
G1
目标:全年代回收,可设置 STW 暂停时间目标(如 200ms)
实现方式:
- 把堆分成若干大小相等的 Region
- 只回收垃圾多的 Region,垃圾少的暂时不回收
- 控制回收范围 → 控制停顿时间
五、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 做标记整理,
把内存碎片整理掉,保证高峰期稳定。