Java 与内存管理
Java 把内存管理交给了 JVM,但「自动」不等于「不用管」:对象分配在 Eden、TLAB 里怎么走,G1 的 Region 怎么回收,
-Xmx和容器内存为什么对不上,堆外内存为什么会 OOM——这些问题最终都要回到 JVM 的运行时数据区。
1. 一张总表
| 问题 | 机制 | 观测工具 |
|---|---|---|
| 对象存在哪? | Heap(Eden/Survivor/Old) | jcmd GC.heap_info |
| 对象多大? | 对象头 + 字段 + 对齐 | jol(第三方) |
| 分配为什么快? | TLAB + bump-the-pointer | -XX:+UseTLAB、-Xlog:gc+alloc |
| 什么时候回收? | 分代 GC / G1 / ZGC | -Xlog:gc*、jstat |
| 为什么没回收? | 仍有 GC Root 可达 | jmap -histo、MAT、JFR |
| 堆外内存谁管? | DirectByteBuffer、Cleaner | NMT、pmap、-XX:MaxDirectMemorySize |
| 容器里为什么被杀? | RSS 超过 cgroup 限制 | dmesg、jcmd VM.native_memory |
2. JVM 运行时数据区
JVM 进程
├─ Heap 对象实例(-Xmx)
│ ├─ Young:Eden + Survivor0 + Survivor1
│ └─ Old
├─ Method Area 方法区(规范概念)
│ └─ Metaspace 类元数据、运行时常量池、方法字节码(本地内存)
├─ Code Cache JIT 编译后的机器码
├─ JVM Stack 每个平台线程一个,存栈帧/局部变量/操作数栈
├─ Native Method Stack native 方法调用栈
├─ PC Register 当前线程执行到哪条字节码
├─ Direct Memory DirectByteBuffer、Netty ByteBuf(本地内存)
└─ Native Memory JNI、malloc、线程栈、文件映射
只有 Heap 是「Java 堆」;方法区是 JVM 规范里的概念,JDK 8+ 由 Metaspace 实现,位于本地内存,受 -XX:MaxMetaspaceSize 限制。Direct Memory、线程栈、Code Cache 也都是本地内存,受不同参数限制。
3. 对象布局
一个对象在堆里大致是:
┌──────────────────────────────┐
│ Mark Word(8 字节) │ hash、GC age、锁状态
├──────────────────────────────┤
│ Klass Pointer(4/8 字节) │ 指向类元数据
├──────────────────────────────┤
│ 实例字段(按类型排列) │ long/double、int/float、short/char、byte/boolean、引用
├──────────────────────────────┤
│ 对齐填充(8 字节对齐) │
└──────────────────────────────┘
- 开启压缩指针(堆小于约 32 GB 时默认)后,Klass Pointer 是 4 字节,对象头 12 字节;数组再多 4 字节长度,头 16 字节。
- 字段会重排:long/double 放前面,引用放后面,减少 padding。
- 对象大小必须是 8 的倍数,不足补 padding。
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.17</version>
</dependency>
import org.openjdk.jol.info.ClassLayout;
public class LayoutLab {
public static void main(String[] args) {
System.out.println(ClassLayout.parseInstance(new Object()).toPrintable());
}
}
-XX:+PrintFieldLayout 是 notproduct 选项,只有 fastdebug 构建可用;普通 release JDK 用它会在启动时直接报错,不要写进生产脚本。
4. 分配:TLAB 与分代
4.1 TLAB
每个线程在 Eden 里有一块私有的 TLAB(Thread Local Allocation Buffer)。分配对象时,线程在自己的 TLAB 里「撞指针」:
TLAB: [ 已用 ][ 下一个空闲 ][ ... ][ 末尾 ]
↑ top
- 绝大多数对象分配不需要锁,就是指针加一下;
- TLAB 用完才去 Eden 申请新块;
-XX:+UseTLAB默认开启,-XX:TLABSize可调。
4.2 对象的一生
- 新对象优先在 Eden 分配;大对象在 G1 里进 Humongous Region(可跨连续 Region),在 Parallel/Serial 里可能直接进 Old;
- Eden 满触发 Young GC,存活对象复制到 Survivor;
- 对象在 Survivor 之间来回复制,年龄每熬过一次 GC 加 1;
- 年龄达到阈值(默认 15,
-XX:MaxTenuringThreshold)或 Survivor 放不下,晋升到 Old; - Old 满触发 Old GC / Mixed GC / Full GC。
G1 里没有物理连续的 Eden/Old,而是把堆切成大小相等的 Region,每个 Region 动态扮演 Eden/Survivor/Old/Humongous:
G1 Heap: [E][E][S][O][O][H][E][O][S][O] ...
Region 大小 1~32 MB(-XX:G1HeapRegionSize)
超过 Region 一半的对象叫 Humongous,直接占用连续 Region。
5. GC:从分代到低延迟
5.1 可达性
GC 从 GC Roots 出发做可达性分析,不可达的对象才会被回收。常见根:
- 活动线程栈上的局部变量;
- 类的静态字段;
- JNI 全局引用;
- 正在使用的监视器/锁;
- 系统类加载器加载的类。
四种引用:
| 引用 | 回收时机 | 典型用途 |
|---|---|---|
| 强引用 | 永远不回收(只要可达) | 普通对象 |
SoftReference | 内存不足时回收 | 缓存 |
WeakReference | 下一次 GC 就回收 | WeakHashMap、元数据 |
PhantomReference | 对象 finalized 后、回收前 | 资源清理、Cleaner |
finalize() 从 Java 9 起被标记废弃,Java 18 起可禁用(JEP 421)。资源清理用 try-with-resources 或 Cleaner,不要依赖 finalize。
5.2 常见收集器
| 收集器 | 特点 | 适用 |
|---|---|---|
| Serial | 单线程 STW,简单 | 小堆、客户端 |
| Parallel | 多线程 STW,吞吐优先 | 批处理、吞吐敏感 |
| G1 | Region 化,可预测停顿,JDK 9+ 默认 | 大多数服务端 |
| ZGC | 并发标记/整理,低停顿;21 可选分代,23 起默认分代 | 大堆、低延迟 |
| Shenandoah | 并发整理,低停顿 | 低延迟(部分发行版) |
| Epsilon | 不回收,只分配 | 性能测试、短生命周期任务 |
G1 的关键参数:
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200 # 目标停顿,不是硬保证
-XX:G1HeapRegionSize=16m
-XX:InitiatingHeapOccupancyPercent=45
G1 的一次 Young / Mixed GC
- Young GC:STW,Evacuation 把 Eden/Survivor 的存活对象复制到新的 Survivor 或 Old;
- RSet(Remembered Set):记录「哪些其他 Region 引用了本 Region」,回收单个 Region 时不必扫全堆;
- 并发标记:用 SATB(snapshot-at-the-beginning)记录标记开始时的引用快照,处理并发修改;
- Mixed GC:回收 Young + 一部分 Old Region,按「回收价值」挑选 Region(Garbage First 的由来);
- PLAB:并行 Evacuation 时每个 GC 线程的本地分配缓冲,避免复制对象时争抢。
这也解释了 G1 为什么会有 Remark、为什么 Humongous Region 回收代价高:前者要处理 SATB 的残留引用,后者要跨连续 Region 搬运。
ZGC:
# JDK 21:分代 ZGC 需要显式开启
-XX:+UseZGC -XX:+ZGenerational -Xmx16g
# JDK 23+:默认就是分代 ZGC
-XX:+UseZGC -Xmx16g
ZGC 用染色指针和读屏障,把标记/整理大部分工作并发化;代价是额外的内存和 CPU 开销。JDK 24 起非分代 ZGC 被移除,版本差异要以对应 JDK 文档为准。
5.3 STW 与 Safepoint
GC 不是随时都能开始:线程要走到 safepoint 才能停。计数循环、JNI critical section、长时内在函数(intrinsic)可能延迟进入 safepoint,表现为「GC 日志里停顿不长,但应用还是卡」。可以用 -Xlog:safepoint 观察。
6. GC 日志与诊断
# JDK 9+ 统一日志
-Xlog:gc*:file=gc.log:time,uptime,level,tags
# 命令行看统计
jstat -gc <pid> 1000 10
# 堆信息
jcmd <pid> GC.heap_info
jmap -histo <pid> # 不带 :live,不触发 Full GC
# 堆转储
jcmd <pid> GC.heap_dump /tmp/heap.hprof
jmap -dump:live,format=b,file=/tmp/heap.hprof <pid>
生产建议:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/var/log/app/
拿到 .hprof 后用 Eclipse MAT 或 VisualVM 分析:先看 dominator tree,找「谁持有了最多内存」,再看 GC Roots 路径。
7. 内存泄漏:GC 也救不了的情况
Java 的「泄漏」不是忘了 free,而是对象仍然可达,但业务上已经没用了。
| 形态 | 例子 | 处理 |
|---|---|---|
| 静态集合 | static Map 只增不删 | 设上限、定期清理、用缓存库 |
ThreadLocal | 线程池复用线程,值一直留着 | finally { remove(); } |
| 监听器 | 注册后没有反注册 | 弱引用、生命周期管理 |
| 类加载器 | 热部署/插件卸载不干净 | 检查 ClassLoader 引用链 |
| 缓存 | 无界 HashMap 缓存 | Caffeine/Guava + 容量/过期 |
| 队列 | LinkedBlockingQueue 无界堆积 | 有界队列 + 拒绝策略 |
| 堆外内存 | DirectByteBuffer 未释放 | 限制 MaxDirectMemorySize、池化 |
| 连接/流 | 未关闭的 socket/文件 | try-with-resources |
WeakHashMap 适合「key 没用了,entry 自动消失」的元数据场景;SoftReference 缓存要小心,GC 策略不可控,生产更推荐 Caffeine。
8. 堆外内存
ByteBuffer direct = ByteBuffer.allocateDirect(1024 * 1024);
- 不受
-Xmx限制,受-XX:MaxDirectMemorySize限制(默认约等于-Xmx); - 由
Cleaner在对象被 GC 后释放;堆很空闲时可能迟迟不触发 GC,表现为「堆没满,进程 RSS 一直涨」; - Netty 用池化的
ByteBuf管理堆外内存,引用计数归零才归还池; - Java 22 的 Foreign Function & Memory API 提供了更安全的堆外内存访问方式(
Arena管理生命周期)。
Native Memory Tracking:
-XX:NativeMemoryTracking=summary
jcmd <pid> VM.native_memory summary
9. 容器里的内存
JVM 在容器里要同时考虑:
- 堆:
-Xmx或-XX:MaxRAMPercentage; - Metaspace:默认无上限,类加载多的应用要设
-XX:MaxMetaspaceSize; - Code Cache:JIT 代码,
-XX:ReservedCodeCacheSize; - 线程栈:平台线程数 ×
-Xss; - Direct Memory:Netty/NIO;
- JVM 自身:GC 数据结构、JIT 线程、符号表等。
-XX:MaxRAMPercentage=70
-XX:MaxMetaspaceSize=256m
-XX:MaxDirectMemorySize=256m
-XX:ActiveProcessorCount=4
MaxRAMPercentage 只控制堆,不是整个进程;默认值是 25%,容器感知由 -XX:+UseContainerSupport 控制(现代 JDK 默认开启)。容器内存限制 1 GiB 时,堆给 70%,剩下 30% 要覆盖 Metaspace、栈、Code Cache、Direct Memory 和 JVM 自身。给太少会 OutOfMemoryError,给太多会被 OOMKilled(进程被内核杀死,不是 Java 异常)。
10. 与 Python 内存管理的对照
| Python(CPython) | Java(HotSpot) | |
|---|---|---|
| 对象存放 | 私有堆 | Java Heap |
| 线程本地缓冲 | 无(按 size class 共用 free list) | TLAB |
| 小块分配 | pymalloc(arena/pool/block) | Eden bump + PLAB/free list |
| 回收主策略 | 引用计数即时释放 + 循环 GC | 可达性分析 + 分代 GC |
| 循环引用 | trial deletion 的循环 GC | 可达性分析天然处理 |
| 对象移动 | 不移动(id 稳定) | 多数收集器复制/整理会移动;identityHashCode 不是地址 |
| 堆外内存 | mmap、C 扩展 | DirectByteBuffer、FFM |
| 观测 | tracemalloc、RSS | jcmd、jstat、MAT |
11. 常见坑
-Xmx等于容器内存:没有给 Metaspace/栈/直接内存留余量,容易 OOMKilled。- Metaspace 无上限:动态生成类/热部署会导致 Metaspace OOM。
ThreadLocal不 remove:线程池复用导致内存泄漏和脏数据。- 无界缓存/队列:对象一直可达,GC 无能为力。
System.gc()调优:触发 Full GC,停顿不可控;可用-XX:+DisableExplicitGC禁用。- 堆外内存泄漏:堆很空但 RSS 涨,最后
OutOfMemoryError: Direct buffer memory。 jmap -histo:live与jcmd GC.class_histogram都可能触发 Full GC:两者都会做一次存活对象检查,生产慎用;要避免 STW,用jmap -histo(不带:live)或 JFR/async-profiler 做分配采样。- 把
OutOfMemoryError当成可恢复异常:大多数 OOM 之后进程状态已不可信,应该重启并保留堆转储。
12. 动手实验
# 启动一个会 OOM 的小程序,观察 GC 日志
java -Xmx128m -Xlog:gc*:file=gc.log:time,uptime -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/tmp -cp . MemoryLab
# 看堆
jcmd <pid> GC.heap_info
jstat -gc <pid> 1000 5
# 看对象直方图(jmap -histo 不带 :live,不触发 Full GC)
jmap -histo <pid> | head -20
# 看原生内存
jcmd <pid> VM.native_memory summary
import java.util.ArrayList;
import java.util.List;
public class MemoryLab {
public static void main(String[] args) {
List<byte[]> keep = new ArrayList<>();
while (true) {
keep.add(new byte[1024 * 1024]); // 每次 1 MiB
}
}
}
观察:
- Young GC 频率、晋升速度;
- Old 区增长到多少触发 Full GC;
OutOfMemoryError: Java heap space前有没有堆转储;- 如果用
-Xmx128m,进程 RSS 通常明显大于 128 MiB。
13. 小结
- JVM 内存不止 Heap:还有 Metaspace、Code Cache、线程栈、Direct Memory;容器里要留余量。
- 对象分配走 TLAB + Eden,大多数分配只是指针碰撞;GC 用可达性分析,不靠引用计数。
- G1 是服务端默认,ZGC 适合大堆低延迟;GC 日志和
jcmd是排查的第一手材料。 - Java 的「内存泄漏」是对象仍然可达但业务无用;静态集合、
ThreadLocal、监听器、无界缓存是重灾区。 - 堆外内存不受
-Xmx限制,要单独限制和观测;OOMKilled与OutOfMemoryError是两件事。