跳到主要内容

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、CleanerNMT、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 对象的一生​

  1. 新对象优先在 Eden 分配;大对象在 G1 里进 Humongous Region(可跨连续 Region),在 Parallel/Serial 里可能直接进 Old;
  2. Eden 满触发 Young GC,存活对象复制到 Survivor;
  3. 对象在 Survivor 之间来回复制,年龄每熬过一次 GC 加 1;
  4. 年龄达到阈值(默认 15,-XX:MaxTenuringThreshold)或 Survivor 放不下,晋升到 Old;
  5. 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,吞吐优先批处理、吞吐敏感
G1Region 化,可预测停顿,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、RSSjcmd、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 是两件事。
加载评论中...