最近帮一个朋友排查线上服务频繁卡死的问题,一路顺着线程快照追下去,发现堆内存被撑爆,线程全部阻塞在内存分配上。排查了大半天,最后定位到一个往静态集合里塞数据的逻辑,没有做容量控制。这个场景让我特别有感触:很多Java开发者写业务代码很熟练,但一遇到OOM、栈溢出这类问题,往往只能对着报错日志干瞪眼,或者直接重启了事,根本原因是运行时数据区这块基础没吃透。
Java运行时数据区是JVM内存管理的核心模型,也是面试八股文里最常被问到、最容易翻车的一块。网上讲这个主题的文章不少,但大多是把《Java虚拟机规范》里的定义抄一遍,看完感觉什么都懂了,真遇到问题还是不会排查。这篇东西我就按自己的理解,把运行时数据区从头到尾拆一遍,讲清楚每个区域是干什么的、什么情况下会出问题、怎么看报错信息定位问题。不管你是准备面试,还是已经写了几年Java想补一下底层基础,应该都能从中捞到点干货。
1. 整体框架:运行时数据区到底在管理什么
1.1 为什么要划分这么多区域
先解决一个最基础的问题:JVM为什么要把内存分成这么多块,为什么不搞一块大内存随便用?
这个问题想通了,后面所有内容都能串起来。Java从设计之初就定了一个调子——程序员不直接管内存,内存的分配和回收交给虚拟机。但“虚拟机管内存”不代表虚拟机可以像没头苍蝇一样乱用,它得有一套清晰的内存组织方式,才能回答三个问题:放什么、放多久、谁来回收。
于是JVM把内存按用途和生命周期划分成了几个区域。有的区域生命周期跟线程一致,线程启动就创建,线程结束就销毁,这类区域不需要垃圾回收介入,比如程序计数器、虚拟机栈、本地方法栈。有的区域是线程共享的,生命周期跟JVM进程一致,这类区域是垃圾回收的重点,比如堆、方法区。
这个划分逻辑很像一个公司做物资管理:办公用品跟着工位走(线程私有),公共会议室和服务器机房全公司共用(线程共享)。私人的东西丢了就丢了,公共区域没人收拾就会越堆越乱,需要专门的保洁定期清理——这就是垃圾回收器干的事。
1.2 区域总览与线程的关系
把运行时数据区的整体结构放在一张表里看,会比较清晰:
| 区域名称 | 线程私有/共享 | 存放内容 | 异常类型 |
|---|---|---|---|
| 程序计数器 | 线程私有 | 当前线程执行的字节码行号 | 无 |
| 虚拟机栈 | 线程私有 | 栈帧(局部变量表、操作数栈等) | StackOverflowError / OutOfMemoryError |
| 本地方法栈 | 线程私有 | native方法调用信息 | StackOverflowError / OutOfMemoryError |
| Java堆 | 线程共享 | 对象实例、数组 | OutOfMemoryError |
| 方法区 | 线程共享 | 类信息、常量、静态变量、JIT编译产物 | OutOfMemoryError |
| 运行时常量池 | 线程共享(在方法区内) | 编译期生成的字面量、符号引用 | OutOfMemoryError |
| 直接内存 | 线程共享(非JVM运行时数据区) | NIO分配的堆外内存 | OutOfMemoryError |
这里有个容易混淆的点:直接内存并不是JVM运行时数据区的一部分,但它是JDK的NIO库在堆外分配的一块内存,JVM会把它纳入内存管理范畴。你设置了-XX:MaxDirectMemorySize,它的大小就受限;不设置,默认等于堆的最大值。很多NIO场景下的OOM,根因都出在直接内存上。
从JVM的整体视角看,运行时数据区就是一套内存规划方案。理解这套规划方案,是后续所有调优、排查、面试的基础。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 逐个解剖:每个区域是干什么的
2.1 程序计数器:最容易忽略的小透明
程序计数器(Program Counter Register)是运行时数据区里最小的一个区域,也是很多人最不重视的一个。它是一块很小的内存空间,作用是记录当前线程正在执行的字节码指令的地址。
为什么需要这个东西?因为JVM是多线程的,线程之间通过时间片轮转切换执行。一个线程被切出去之后再切回来,得知道自己刚才执行到哪了,不然就乱套了。程序计数器就是干这个的——它保存了当前线程执行到的字节码行号,线程切换回来,从记录的位置继续往下执行就行。
这里有一个值得注意的细节:程序计数器是唯一一个不会抛出OutOfMemoryError的区域。因为它的容量是固定的,而且非常小,不需要动态扩展。另外,如果执行的是native方法,程序计数器的值是undefined——因为native方法是通过JNI调用的,不会执行JVM自己的字节码指令。
对面试来说,能说清楚程序计数器的存在意义,就能展示你对JVM线程调度机制的理解。对写代码来说,这个区域基本不需要关注,知道它存在即可。
2.2 虚拟机栈:方法调用的“账本”
虚拟机栈(Java Virtual Machine Stack)描述的是Java方法执行的内存模型。每个方法从调用到执行结束,对应一个栈帧在虚拟机栈里的入栈和出栈。
这里要展开讲一下栈帧的内部结构,这是面试容易追问的点。一个栈帧包含四样东西:
- 局部变量表:存放方法参数和方法内部定义的局部变量。注意,局部变量表以槽(slot)为单位,long和double类型占两个槽,其他类型占一个槽。对象引用类型也是占一个槽,因为存的是引用(指针),不是对象本身。
- 操作数栈:方法执行过程中的临时计算区域。比如执行
int c = a + b,先把a压入操作数栈,再把b压入,执行加法指令时从栈顶弹出两个数,相加后把结果压回栈里。这个栈的深度在编译期就确定了。 - 动态链接:指向运行时常量池中该方法的引用。方法在执行过程中可能需要调用其他方法,通过符号引用转为直接引用,这个转换过程就靠动态链接完成。
- 方法返回地址:方法正常退出或异常退出后,要回到调用者的位置继续执行,这个恢复位置就记录在这里。
理解栈帧结构对排查问题很有用。你看到Exception in thread "main" java.lang.StackOverflowError,说明虚拟机栈的深度超过了JVM允许的最大深度,最常见的触发场景就是无限递归。
展开说一个真实案例。有次我接手一个老项目,上线后时不时报栈溢出,但重新启动又好一阵子。排查了很久才发现,有段代码在递归调用时把一个对象作为参数传递,这个对象里又持有集合引用,递归深度一大,栈帧里的局部变量表就一直持有大量对象引用,导致GC无法回收——这其实是栈和堆配合不当导致的隐性内存问题。所以别看栈是线程私有的,它跟堆的交互关系密切,排查问题时不能割裂开看。
2.3 本地方法栈:native方法的专属通道
本地方法栈(Native Method Stack)跟虚拟机栈的作用非常相似,区别在于虚拟机栈服务Java方法,本地方法栈服务native方法。native方法是用Java调用非Java语言编写的接口,比如C、C++实现的方法,在JDK源码里经常能看到用native关键字修饰的方法签名。
HotSpot虚拟机做了一个取舍:直接把本地方法栈和虚拟机栈合二为一了。所以在HotSpot上,这两个区域在物理上是一块内存,逻辑上分开理解即可。这也是面试中可能遇到的细节问题——问你HotSpot的本地方法栈和虚拟机栈的关系,你能说出来它们合并了,印象分会好很多。
本地方法栈同样会抛出StackOverflowError和OutOfMemoryError,排查思路跟虚拟机栈类似。不过实际业务中,遇到本地方法栈出问题的情况比较少,更多是在写JNI程序或者使用某些底层框架时才会碰到。
2.4 Java堆:内存管理的主战场
Java堆(Java Heap)是JVM管理的内存中最大的一块,也是垃圾回收器的主要活动区域。
堆里存放什么?几乎所有通过new创建的对象实例和数组。方法中声明的局部变量在栈里,但new出来的对象本身在堆里,栈上的变量只是持有指向堆中对象的引用。局部变量随方法退出而销毁,但堆里的对象不一定会立即被回收——它要等垃圾回收器发现它不再被任何引用指向,才会被回收。这中间的时间差,就是内存泄漏产生的温床。
HotSpot对堆做了更细的划分。从垃圾回收的角度,堆被分成新生代和老年代:
- 新生代:细分为Eden区、From Survivor区、To Survivor区,默认比例是8:1:1。新创建的对象优先分配在Eden区,Eden区满时触发Minor GC,存活对象通过复制算法在Survivor区之间来回移动,每经历一次Minor GC且存活下来,年龄加1,达到阈值(默认15)后晋升到老年代。
- 老年代:存放长期存活的对象以及大对象。老年代满了会触发Major GC或Full GC,Major GC通常比Minor GC慢得多,因为老年代的对象存活率高,需要扫描和处理的量大。
这个分代设计是有讲究的。绝大多数对象“朝生夕灭”,活不过几轮Minor GC,把内存分成新生代和老年代,可以用不同的算法处理不同类型的对象——新生代对象存活率低,用复制算法比较划算;老年代对象存活率高,用标记-整理或标记-清除算法更合适。
堆的参数配置是Java调优的重头戏。-Xms设置堆初始大小,-Xmx设置堆最大大小,这两个值一样时堆不会动态扩展,能减少运行时扩缩容的开销,这也是生产环境推荐的设置方式。-Xmn设置新生代大小,-XX:SurvivorRatio设置Eden区和Survivor区的比例,-XX:MaxTenuringThreshold设置对象晋升老年代的年龄阈值。调这些参数的前提是理解每个区域的工作机制,不然就是在碰运气。
2.5 方法区:类信息的仓库
方法区(Method Area)存放的是类的元信息:类的结构信息、字段信息、方法信息、静态变量、运行时常量池、JIT编译后的代码缓存等。从JVM逻辑上讲,方法区是规范中定义的独立区域;从物理实现上讲,不同JDK版本的实现差异很大。
这里必须说清楚JDK8前后的一个重要变化。JDK7及以前,HotSpot用永久代(PermGen)来实现方法区,方法区是堆的一部分逻辑视图,物理上跟堆连在一起。永久代有默认大小限制,JDK7的默认值是84M左右,类加载一多很容易OOM。JDK8开始,永久代被移除,方法区改为元空间(Metaspace),物理上用的是本地内存,不再占用堆内存,默认情况下只受本地内存大小限制。
这个变化的直接好处是,由永久代引起的OOM问题大幅减少,因为元空间的容量上限比以前大得多。但副作用是,如果加载的类无限增长——比如频繁创建动态代理类、热部署没做好清理——元空间也会被撑爆,报java.lang.OutOfMemoryError: Metaspace。这类问题在用了CGLib、ASM这类字节码生成技术的框架时比较常见。
另外还要提一下运行时常量池。它在方法区内,存放编译期生成的各种字面量和符号引用。字面量包括文本字符串、final常量值等;符号引用是类、方法、字段的引用描述,在链接阶段会被解析为直接引用。JDK7之后,字符串常量池因为创建逻辑的调整,被挪到了堆里,这是面试中高频考点。
2.6 字符串常量池:从永久代到堆的搬家
字符串常量池值得单独拿出来讲,因为它是面试中经常翻车的一个细节点。
JDK6及以前,字符串常量池在永久代里,永久代空间有限,String.intern()方法调用频繁时容易OOM。JDK7开始,字符串常量池移到了堆中。这个变化的实际影响是:字符串常量池里的对象可以被垃圾回收,而且由于堆空间比永久代大得多,intern()导致的OOM概率大幅降低。
用一个经典面试题来演示一下。这段代码:
java复制String s1 = new String("a") + new String("b");
s1.intern();
String s2 = "ab";
System.out.println(s1 == s2);
在JDK7及以后,输出是true还是false?答案是true。原因是new String("a") + new String("b")会在堆中创建一个"ab"字符串,s1.intern()发现字符串常量池里没有"ab",但堆里已经有这个对象了,就把堆中这个对象的引用记录到字符串常量池里(注意,不会复制字符串对象,只记录引用)。然后String s2 = "ab",发现常量池里有"ab",就把这个引用直接赋给s2,此时s1和s2指向同一个对象,所以比较结果为true。
如果把intern()那行去掉,输出就是false。因为s2 = "ab"会在常量池里创建一个新对象,跟堆里的s1指向的对象不是一个东西,引用比较自然不相等。
这个知识点看起来挺绕,但理解了字符串常量池的机制后,逻辑就顺了。这里也可以说明一点:JDK版本升级会带来内存模型细节的变化,学习时一定要确认自己用的版本对应的实现。
3. 实战验证:写代码触发内存问题并定位
3.1 栈溢出:亲手制造一个StackOverflowError
理论知识再多,不如亲手让内存出一次问题。先写一段必定栈溢出的代码:
java复制public class StackOverflowDemo {
public static void main(String[] args) {
recursiveMethod(0);
}
private static void recursiveMethod(int depth) {
System.out.println("当前递归深度: " + depth);
recursiveMethod(depth + 1);
}
}
运行后很快会看到Exception in thread "main" java.lang.StackOverflowError,同时日志里打印递归深度停在几千到一万多不等,取决于JVM默认栈大小(通常-Xss默认值是1MB)。
这个实验能直观理解两件事:
- Java方法调用是有开销的,每个栈帧都要占内存,栈帧里要存方法的参数、局部变量、返回地址等信息。递归深度越深,栈内存消耗越大。
- StackOverflowError不是一个可以被捕获后随意恢复的错误。即使你用try-catch捕获它,如果递归逻辑本身有问题,捕获后继续递归还是会再次抛出。要解决栈溢出,核心是消除无限递归,而不是捕获异常。
那-Xss参数在哪些场景下需要调?比较典型的是方法内部使用了大量局部变量,或者函数式编程里递归链比较深,默认1MB不够用。这时可以把栈调大到2MB、4MB。但调大栈的代价是总内存不变的前提下,能创建的线程数变少。如果服务器要开几百个线程,栈设太大可能导致创建线程时直接OOM。
3.2 堆溢出:把大对象往堆里塞
再写一段堆溢出的代码:
java复制import java.util.ArrayList;
import java.util.List;
public class HeapOOMDemo {
public static void main(String[] args) {
List<byte[]> list = new ArrayList<>();
while (true) {
list.add(new byte[1024 * 1024]);
}
}
}
运行前设置-Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError,这样内存溢出时JVM会自动dump一份堆快照。运行后很快会看到java.lang.OutOfMemoryError: Java heap space。
这里有个关键点:-XX:+HeapDumpOnOutOfMemoryError一定要在生产环境也开着。它会在OOM发生时生成堆转储文件,这个文件是事后分析内存问题最直接的证据。配合-XX:HeapDumpPath=/path/to/dump指定dump文件输出位置。很多团队等到OOM了才想起来没开这个参数,日志里只有一行无头无尾的报错,排查起来非常被动。
拿到dump文件后,用MAT或VisualVM分析。排查思路一般是:先看哪些对象占用的内存最多,再通过引用链找到GC Roots,确认这些对象是被谁持有、为什么没被回收。通常查到的根因无非几种:静态集合没有清理、IO流没关闭、ThreadLocal使用不当、连接池泄漏。
3.3 方法区溢出:动态生成类把元空间撑爆
模拟方法区溢出稍微麻烦点,需要通过动态生成类来触发。用JDK自带的java.lang.reflect.Proxy或者CGLib都可以。以一个简单的动态代理为例:
java复制import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.util.ArrayList;
import java.util.List;
public class MetaspaceOOMDemo {
public static void main(String[] args) {
List<Object> list = new ArrayList<>();
while (true) {
Object proxy = Proxy.newProxyInstance(
MetaspaceOOMDemo.class.getClassLoader(),
new Class<?>[]{Runnable.class},
(Object target, Method method, Object[] params) -> null
);
list.add(proxy);
}
}
}
运行时设置-XX:MaxMetaspaceSize=50m,很快就能看到java.lang.OutOfMemoryError: Metaspace。
这个案例在真实业务中的对应场景是:热部署功能反复卸载和重新加载应用、动态创建大量代理类但没有合理的类卸载机制。元空间用本地内存虽然不容易满,但无限增长也扛不住。排查思路是检查类加载器是否被错误持有,防止类加载器泄漏——这才是元空间OOM的根本原因。
3.4 运行参数对照表
把上面用到的参数整理一下,方便查阅:
| 参数 | 作用 | 默认值(HotSpot) | 注意事项 |
|---|---|---|---|
| -Xms | 堆初始大小 | 物理内存的1/64 | 生产环境建议和-Xmx一致 |
| -Xmx | 堆最大大小 | 物理内存的1/4 | 过大导致GC停顿增长 |
| -Xmn | 新生代大小 | 堆的1/3 | 需要根据对象生命周期调整 |
| -Xss | 虚拟机栈大小 | 1MB(Linux x64) | 调大意味着线程数减少 |
| -XX:MaxMetaspaceSize | 元空间最大大小 | 无上限(受本地内存限制) | 动态代理/热部署场景建议设置 |
| -XX:+HeapDumpOnOutOfMemoryError | OOM时生成堆dump | 默认关闭 | 生产环境强烈建议开启 |
| -XX:SurvivorRatio | Eden与Survivor比例 | 8 | 调整影响Minor GC频率 |
| -XX:MaxTenuringThreshold | 对象晋升老年代年龄阈值 | 15 | 并行GC下可能调整为6 |
4. 排查思路与典型问题实录
4.1 拿到OOM报错后,先判断是哪个区域
这是排查内存问题的第一步,也是最关键的一步。OOM报错信息里,Java heap space对应Java堆,Metaspace对应方法区(元空间),unable to create new native thread对应无法创建线程(通常跟栈内存和操作系统线程数限制有关),Direct buffer memory对应直接内存。
截几个真实报错来分析:
java.lang.OutOfMemoryError: Java heap space:堆空间不足。下一条命令是jmap -dump:format=b,file=/path/to/dump.hprof <pid>手动生成堆dump,再用MAT分析大对象。java.lang.OutOfMemoryError: GC overhead limit exceeded:JVM认为GC即将耗尽内存,垃圾回收反复运行但回收效果极差。这是堆空间不足的另一种表现,代表98%的时间在GC,但回收不到2%的内存。先别急着看代码,很大概率是堆太小或者内存泄漏。java.lang.OutOfMemoryError: Metaspace:元空间被撑爆。用jstat -class <pid>看类的加载和卸载数量,如果类的加载数越来越大而卸载数为0,基本可以断定类加载器泄漏。java.lang.OutOfMemoryError: Direct buffer memory:直接内存耗尽。常见于使用Netty等NIO框架时,分配了太多DirectBuffer没有释放。这个可以用-XX:MaxDirectMemorySize限制大小,但根本解法是检查ByteBuffer有没有正确释放。
4.2 一次真实排查:老年代缓慢增长
有次帮一个金融项目排查内存问题,现象是系统运行一段时间后反应变慢,GC日志显示Full GC越来越频繁。先从监控面板看堆内存趋势,发现老年代使用量曲线一直在往上爬,虽然没有立刻OOM,但趋势明显不对。
初步怀疑是缓存没做清理。查了代码里的缓存实现,用的是ConcurrentHashMap,但key是请求对象,每次请求都产生新对象,所以缓存只增不减。这个设计问题不解决,加多大堆都没用,因为泄漏速度会随着并发量线性增长。
用jmap dump了堆,用MAT分析,确认了HashMap中的Entry对象占了75%以上的堆空间,再查看引用链,定位到缓存持有的key对象一直没释放。修复方式是把key改为脱敏后的用户ID,同时给缓存加上定期清理机制。
这个案例想说明的是:排查内存问题,不要一上来就想堆大小调多大、GC参数怎么优化。优化参数只能拖延问题爆发的时间,根因不解决,内存迟早还会爆。90%的内存问题都是代码层面的问题,只有少数是参数配置不合理。
4.3 线程栈相关的排查技巧
线程问题相对隐蔽,但线上也很常见。比如线程数飙升导致OOM,报错是unable to create new native thread。这个时候要看系统层面的线程数限制:用ulimit -u看进程能创建的线程数上限,用ps -eLf | wc -l看当前线程总数,再用jstack抓线程快照分析线程都在干什么。
线程数疯涨的常见原因有:线程池拒绝策略配置不当导致任务积压、HTTP连接池的连接泄漏导致线程等待超时、死锁导致线程互相等待无法退出。
排查线程问题有一个很好用的工具链:top -H -p <pid>按CPU占用排线程,printf "%x" <threadId>把线程ID转成十六进制,再用jstack抓出来的线程快照里找对应的nid,就能定位到具体是哪段代码在消耗CPU。这套流程是线上排查性能问题的基本功。
4.4 别再背错了:高频面试题梳理
运行时数据区是面试八股文的重头戏,但很多面试者答得比较浅。整理几道高频题,给出我认为比较完整的回答思路:
问:JVM运行时数据区分为哪几块?
答:分为程序计数器、虚拟机栈、本地方法栈、Java堆、方法区。其中程序计数器、虚拟机栈、本地方法栈是线程私有的,Java堆和方法区是线程共享的。另外运行时常量池在方法区里,JDK7之后字符串常量池挪到了堆里。答到这里,还可以主动补一句:JDK8中方法区的实现从永久代换成了元空间。
问:对象一定分配在堆上吗?
答:不一定。HotSpot支持栈上分配,通过逃逸分析判断对象是否只在方法内部使用,如果对象没有逃逸,可以在栈上分配,随着方法退出直接销毁,省去GC回收的负担。标量替换也是类似思路,把不逃逸的聚合量拆成标量,直接在栈上分配,避免在堆上创建对象。但这些优化默认不一定开启,需要配合参数使用。这个回答能展示你对JIT编译器优化机制的理解。
问:一个Java对象在内存中的布局是什么样的?
答:在HotSpot虚拟机中,对象在堆内存中的存储布局分为三块:对象头、实例数据、对齐填充。对象头包括Mark Word和类型指针,Mark Word存储对象的哈希码、GC分代年龄、锁状态标志等。实例数据存放真正的字段值。对齐填充是为了让对象大小是8字节的整数倍。这个问题比较细节,但能答出来会让面试官眼前一亮。
问:为什么把永久代换成元空间?
答:两个主要原因。一是永久代的空间有限,默认最大值是固定的,频繁加载类会导致永久代OOM;而元空间用本地内存,突破了JVM堆大小的限制,大项目里类元数据不容易撑爆。二是永久代和堆的内存管理耦合,GC的复杂度高;元空间独立出来后,永久代的GC问题和常量池访问性能问题都得到了改善。
问:什么是直接内存?
答:直接内存不是JVM运行时数据区的一部分,而是通过NIO在Java堆外分配的内存。它不受堆大小限制,但受本地内存限制。使用直接内存的好处是避免数据在堆内和堆外之间复制,提升IO性能,Netty就是典型应用。风险是如果忘记释放,容易造成堆外内存泄漏,而且堆外内存的释放依赖Cleaner机制,GC不及时会导致直接内存耗尽。
5. 学习路线与实用工具推荐
5.1 从入门到精通的顺序
如果你刚接触运行时数据区这个概念,我的建议是不要一上来就啃《深入理解Java虚拟机》里最厚的章节。按这个顺序学习,效率会高很多:
第一步,先建立整体认知。把每个区域的作用、线程私有还是共享、异常是什么,记清楚就行。这个阶段目标是看到OOM报错能判断是哪个区域的问题。
第二步,深入栈和堆。搞清楚栈帧的内部结构、对象的分配流程、垃圾回收的分代模型。这个阶段配合代码实战,写几段会溢出的代码,亲自跑一遍,对内存机制的理解会深刻很多。
第三步,学GC相关细节。运行时数据区和垃圾回收是紧密相关的,新生代和老年代的分区本质就是为了服务GC算法。推荐先搞懂GC Roots有哪些——虚拟机栈中的引用、静态变量引用、JNI引用这些都是GC Roots,这也是面试追问的重点。
第四步,学命令和工具。jps、jstat、jmap、jstack、jconsole、VisualVM、MAT这些工具,至少要熟练两三种。排查问题是学内存知识的最好方式,有真实的报错和dump文件,知识才不是空中楼阁。
5.2 值得反复读的三本书
- 《深入理解Java虚拟机:JVM高级特性与最佳实践》:周志明老师的书是Java开发者绕不开的经典,JVM内存这块讲得非常透彻。注意买最新版,因为JDK版本不同内容差异很大。
- 《Java性能权威指南》:偏实战,里面有大量关于JVM内存调优、GC日志分析的案例,适合有一定基础后阅读。
- 《Java并发编程实战》:虽然主题是并发,但线程和内存的交互讲得很深入,对理解虚拟机栈、线程安全与内存模型的关系很有帮助。
工具方面,强烈建议先把命令行工具JVM自带的这套用熟。IDE插件和可视化工具虽然方便,但线上环境通常没有图形界面,命令行工具才是排查问题的救命稻草。
5.3 几个踩坑经验
最后分享几个这些年踩过的坑。
坑一:只调-Xmx不调-Xms。 生产环境如果只设置最大值不设置初始值,JVM启动时堆很小,运行后随着对象增多逐渐扩容,扩容过程本身需要分配内存、触发GC、进行内存复制,有一定开销。所以生产环境强烈建议-Xms和-Xmx设置成同一个值,应用启动时一次性把堆内存分配到位,运行过程不会再扩缩容。
坑二:盲目调大堆内存。 有些同学以为堆越大越好,直接把堆设成机器物理内存的80%。堆设太大,Full GC时停顿时间会非常长,因为要扫描和处理的对象太多了,用户可能直接感觉系统卡死了。堆大小要根据应用的实际内存占用情况来定,一般建议先设置一个保守值,运行观察GC频率和Full GC耗时,再逐步调整。
坑三:忽略线程栈对内存的影响。 每个线程默认分配1MB的栈空间,如果服务器开了300个线程,光是栈就占用300MB内存,还没算堆。有些场景下,调小-Xss到256KB,对递归深度不深的业务完全没影响,却能节省大量内存,让整体并发能力提升不少。这个参数值得根据实际业务场景仔细权衡。
坑四:Metaspace不设上限。 默认情况下元空间可以无限使用本地内存,如果项目里有动态代理、反射生成类、热部署等逻辑,类的数量可能会失控。建议设置一个-XX:MaxMetaspaceSize,宁可在早期报OOM,也不要让内存悄悄耗尽导致整个机器卡死。
这些坑我基本都是真金白银踩出来的。每次线上出问题,先看区域、再分析根因、最后调参数,这个顺序不能乱。调整参数只是止血,修复代码才是根治。
