
先讲一个真实场景。那天晚上快十一点线上支付服务开始出现卡顿用户反馈提交订单要转很久。登录服务器一看GC日志里Full GC已经变成两秒一次老年代占用率98%堆内存几乎被打满。当时脑子里蹦出来的第一个念头就是Java内存又出事了。后来通过heap dump定位到罪魁祸首——一个static的HashMap日积月累往里塞了几百万个不该长期保留的缓存对象而代码里没有任何清理机制。这种现场我见过太多次几乎每个Java开发者迟早都会遇到。不管是刚学Java的新手被“堆和栈有什么区别”问住还是工作三五年的人排查线上OOM最终都会落到同一个主题Java内存。这篇文章我就围绕这个主题把JVM内存模型、对象分配、常见参数、内存泄漏与溢出、堆外内存以及面试高频考点整体过一遍。适合的人群很明确正在学Java基础的人、准备面试的开发者以及已经在线上被内存问题折磨、想系统补一遍的人。1. 先画一张Java内存地图运行时数据区1.1 线程私有区域程序计数器、虚拟机栈、本地方法栈Java程序跑起来之后JVM会从操作系统申请一块内存然后按用途切成几块区域这就是所谓的“运行时数据区”。很多教材喜欢一上来就堆术语我换个说法每个线程都有一份自己独占的小本本而所有线程共享一块大黑板。线程独占的东西主要是程序计数器、虚拟机栈、本地方法栈。程序计数器是最小的那块内存作用就是记录当前线程正在执行的字节码指令地址。它不会出现OutOfMemoryError因为JVM规范压根没给它分配内存限额它只是行号指示器。这个区域平时没人关心但你要是学并发编程理解“上下文切换后线程怎么恢复到之前执行位置”就得靠它。虚拟机栈是高频考点也是我排坑时最常打交道的地方。Java方法执行的时候每个方法都会创建一个栈帧栈帧里存了局部变量表、操作数栈、动态链接、方法返回地址。说人话就是你每调用一个方法就往栈里压一个栈帧方法返回就弹出。栈深度超过JVM允许的深度就会抛StackOverflowError典型的例子就是无限递归。如果栈容量允许扩展但内存不够会抛OutOfMemoryError不过在HotSpot虚拟机里栈容量不允许动态扩展所以基本见到的都是StackOverflowError。本地方法栈和虚拟机栈几乎一样区别只在于它服务的是native方法。对大多数业务开发来说这个区域基本感知不到除非你调用了JNI或者某些底层native库时出了问题才可能看到它的身影。1.2 线程共享区域堆和方法区堆是Java内存的绝对主角。几乎所有对象实例都在这里分配严格说还有栈上分配、标量替换等情况但主战场就是堆。堆也是垃圾收集器的核心工作区域所以它还有一个名字叫“GC堆”。堆可以细分为新生代和老年代新生代里又分Eden区和两个Survivor区这些概念后面讲对象生命周期时会细说。方法区在JDK 8之后被彻底改了个名字叫元空间Metaspace并且从JVM堆内存里挪到了本地内存。这是很多面试题喜欢挖坑的地方JDK 8之前方法区是堆的一部分叫永久代JDK 8开始被移除取而代之的元空间不占用堆内存而是使用操作系统本地内存。如果方法区存了太多类元数据、常量池信息就可能抛出OutOfMemoryError: Metaspace。运行时数据区里还有一块很特殊的部分运行时常量池。它在JDK 8之后也搬到了堆里属于方法区的一部分可以用intern()方法操作。面试如果问“字符串常量存在哪”答案不是“栈”也不是“堆”一句话能概括的得说清楚JVM把字符串常量池放在堆里字面量在类加载时进入常量池运行期创建的字符串在堆里具体得看代码怎么写的。我画过一张Java内存地图的速查表对照着看很清晰区域是否线程共享存的什么常见异常程序计数器否当前线程执行的字节码行号无虚拟机栈否栈帧局部变量表、操作数栈等StackOverflowError本地方法栈否native方法调用信息StackOverflowError堆是几乎所有对象实例、数组OutOfMemoryError: Java heap space方法区/元空间是类信息、常量池、静态变量、JIT代码缓存OutOfMemoryError: Metaspace这张表不一定要背下来但你每次定位内存问题前先对着它确认“爆的是哪块区域”方向就不会错。2. 一个Java对象的完整一生从new到被回收2.1 对象的创建过程不是只有new这一步很多人以为new一个对象就是分配一块内存然后完事实际上JVM内部要干好几件事。首先是类加载检查虚拟机遇到一条new指令会先检查这个类的符号引用能不能在常量池中定位到如果没有被加载、解析、初始化过就先执行类加载过程。然后是分配内存。对象所需内存大小在类加载完成后就能确定接下来在堆里划出一块区域。分配方式有两种如果堆内存是规整的用“指针碰撞”把空闲内存的边界指针往空闲方向挪动一段距离如果堆内存不规整用“空闲列表”JVM维护一张表记录哪些内存块可用分配时找一块足够大的分给对象。使用哪种方式取决于垃圾收集器有没有压缩整理能力比如Serial、ParNew带Compact过程就适合指针碰撞CMS基于Mark-Sweep就适合空闲列表。分配完内存后JVM还要把对象头里的数据初始化好包括对象是哪个类的实例、哈希码、GC分代年龄、锁信息等。接着执行构造函数也就是字节码里的init方法Java层面程序员写的初始化逻辑在这一步才真正执行。注意一个细节内存空间初始化为零值发生在构造函数之前所以Java对象不用赋值也有默认值比如int默认0、引用类型默认null背后就是这段“零值初始化”的功劳。2.2 对象在堆里的分配策略为什么对象不一定进Eden绝大多数对象优先在新生代Eden区分配但不是所有对象都这么听话。如果对象太大比如一个超大的数组或大字符串JVM会觉得它占满Eden不划算直接把它扔进老年代这就是“大对象直接进入老年代”的策略。可以通过-XX:PretenureSizeThreshold参数设置阈值超过这个大小的对象直接在老年代分配。这样做是为了避免大对象在Eden和Survivor之间来回复制减少GC成本。还有一个高频考点是长期存活的对象进入老年代。每个对象有一个分代年龄默认情况下对象每在Minor GC中存活一次年龄加1当年龄达到15岁可以通过-XX:MaxTenuringThreshold设置就会被晋升到老年代。但注意不是必须到15岁才能晋升JVM还有“动态年龄判定”如果在Survivor区中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接晋升到老年代。再说一个隐藏很深但面试常问的点栈上分配。JVM通过逃逸分析判断对象是否只在当前方法内使用如果对象没有逃逸也就是不会被外部方法或线程访问到那么JVM优化后可能直接在栈上分配内存而不是进堆。这样做对象随方法调用结束就自动销毁不需要垃圾回收介入。HotSpot里的“标量替换”则更进一步把对象拆解成若干个基本类型的局部变量来分配。不过这些都是JIT编译期优化不是Java代码能主动控制的初学者知道机制即可不用过度纠结。2.3 Minor GC和Major GC/Full GC到底在回收什么新生代里发生的垃圾回收叫Minor GC老年代里发生的叫Major GCFull GC则是“清理整个堆方法区”的统称。Minor GC非常频繁回收速度也快因为新生代的对象大多朝生夕死。老年代GC频率低但每次都很耗时这就是为什么线上出现频繁Full GC时系统会明显卡顿。场景还原一下新对象不断在Eden区分配Eden满了触发Minor GC存活对象挪到S0区下次再触发时存活对象挪到S1区。每挪一次年龄加1直到年龄到达阈值晋升老年代。如果老年代也放不下了就得触发Full GC。Full GC一旦频发最直接的表现就是应用响应时间飙升CPU时不时飙满日志里全是GC停顿警告。所以平时排查内存问题第一步永远是看GC日志而不是急着去翻代码。3. 关键参数与实操怎么给JVM设置内存怎么用工具排查3.1 常用的JVM内存参数速查表很多初学者会问生产环境的JVM参数到底该怎么配没有银弹但有常用模板。下面这一组参数是Java 8/11时代最经典的组合我每次部署Spring Boot应用基本就是这个套路java -Xms4g -Xmx4g -Xmn2g \ -XX:MetaspaceSize512m \ -XX:MaxMetaspaceSize512m \ -XX:SurvivorRatio8 \ -XX:UseG1GC \ -Xloggc:/data/logs/gc.log逐个解释一下-Xms4g和-Xmx4g堆初始大小和堆最大大小建议设成相同值避免运行期堆大小反复伸缩带来性能损耗。-Xmn2g新生代大小。新生代不是越大越好它的增减会影响老年代可用空间。-XX:MetaspaceSize元空间的初始大小JDK 8之后元空间使用本地内存但设置初始值可以避免动态扩容带来的抖动。-XX:SurvivorRatio8代表Eden区与一个Survivor区的比例是8:1也就是Eden占比8/10的新生代。-XX:UseG1GC启用G1垃圾收集器Java 9之后默认就是G1所以这个参数在Java 11以上可以不写。-Xloggc把GC日志输出到文件这是排查一切内存问题的第一步。3.2 拿到OOM日志以后怎么一步步定位很多人的第一反应是直接google异常信息然后随便加参数。我建议按这个顺序排查效率最高第一步看日志。打开GC日志文件观察Full GC的触发频率和耗时。如果Full GC次数特别多而且每次回收后老年代占用率几乎没降基本可以判定是内存泄漏。如果回收后占用率降下去了但很快又涨上来可能是并发峰值太高需要扩容或调整参数。第二步抓堆转储文件。在启动参数里加上-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath/path/to/dumpOOM时JVM自动把当前堆快照导出。已经OOM但没加参数的线上环境也可以手工执行jmap -dump:formatb,file/tmp/heap.hprof pid。注意这个命令触发时会STW对线上有影响最好在低峰期执行。第三步用MAT或者VisualVM分析dump文件。这是我最常用的方式MAT打开hprof后直接看“Leak Suspects”它会自动帮你列出嫌疑最大的对象和引用链。之前排查那个HashMap泄漏就是通过MAT看到对象占用了2.3G内存顺着引用链一路找到那个static集合问题一下水落石出。3.3 常见工具配套使用jstat、jmap、jcmd光靠一个工具很难覆盖全部场景。我常用的组合是jstat看GC趋势jmap看堆分布和dumpjcmd做综合性诊断。# 查看PID为12345的进程GC情况每1秒输出一次共10次 jstat -gcutil 12345 1000 10 # 查看堆内存各区域使用情况 jmap -heap 12345 # 查看类加载情况排查类加载器导致的内存泄漏时很有用 jmap -clstats 12345 # jcmd是综合工具替代了很多jmap的功能 jcmd 12345 GC.heap_infojstat输出里的FGC列是Full GC次数FGCT是Full GC累计耗时。如果FGC快速增加且FGCT居高不下说明老年代压力很大。jmap -heap可以看到Eden、S0、S1、Old各区的容量和当前使用量对照业务峰值判断参数是否合理。有一个很典型的坑很多Java开发者直到换电脑都没有开过GC日志。等线上出了问题连最基础的现场数据都没有只能靠猜。我建议所有Java服务从开发环境就开始输出GC日志占用的磁盘空间很小但关键时刻能救命。4. 内存异常四大类泄漏、溢出、GC病、堆外内存4.1 内存泄漏看着内存够其实偷偷流走了内存泄漏不是“内存真的从机器里漏掉”而是话说白了对象已经不再使用但GC无法回收导致可用内存越来越少。最典型的代码写法就是静态集合类public class CacheService { private static final MapString, Object CACHE new HashMap(); public void put(String key, Object value) { CACHE.put(key, value); } }如果这个缓存没有淘汰机制数据只进不出很快就会把堆打满。很多人会拿WeakHashMap或者LRU来救场但本质上是要想清楚对象的生命周期。还有ThreadLocal使用不当导致的内存泄漏也很经典ThreadLocalMap的key是弱引用value是强引用如果线程池里的线程长期存活而业务代码没有调用remove()value就永远无法被回收。不少公司的排查手册里都专门有一条用了ThreadLocal必须finally里remove。另一个隐蔽场景是连接未关闭。数据库连接、网络连接、文件流这都属于资源型对象如果只开不关底层会持有大量堆内存和native内存表现就是内存占用持续上涨直到把连接池和堆全部拖垮。解决方式很朴素try-with-resourcesJava 7之后的正确姿势。4.2 内存溢出直接抛出OutOfMemoryError内存泄漏是温水煮青蛙内存溢出则是水烧开了直接冒泡。抛出的异常不同对应的解决方法也不同异常信息对应的区域常见原因Java heap space堆堆大小不足或内存泄漏Metaspace元空间加载的类太多或CGLIB动态生成类过多StackOverflowError虚拟机栈无限递归方法调用层级过深Direct buffer memory堆外内存DirectByteBuffer未能及时释放堆溢出的直接解决思路就是调大堆比如-Xmx从4g改成8g。但调大堆是治标不治本如果存在泄漏改再大也会再次爆掉。正确做法永远是先拿到dump找到谁占了内存。元空间溢出我见过最多的是接了外部MQ或者RPC框架provider侧动态创建了海量代理类每生成一个类都会占用元空间类没有被卸载最后项目启动或者运行一段时间就抛Metaspace OOM。栈溢出就不用多说了递归没有终止条件是最典型的。不过我见过更隐蔽的一种某个ORM框架在加解密场景里发生AOP代理嵌套调用同一个方法不断回调自己肉眼看起来没递归但实际调用链深度呈指数增长最终栈炸了。4.3 堆外内存JVM管不到的内存才是最头疼的堆外内存就是DirectByteBuffer这类直接分配在操作系统本地内存上的空间不受JVM堆大小控制但受机器内存总量限制。很多高性能框架比如Netty大量使用堆外内存来减少数据在堆内和系统内核之间的拷贝次数。堆外内存最麻烦的地方在于如果使用不当你看到物理内存被吃光了但jmap -heap显示堆内存还很健康。到底是什么占满的排查思路要调整。可以用jcmd配合NMT来排查# 开启本地内存跟踪 -XX:NativeMemoryTrackingsummary # 运行时查看NMT数据 jcmd pid VM.native_memory summaryNMT明显标注了DirectBuffer、Socket和线程栈占用一般就能定位到方向。如果是Netty分配堆外内存没有释放重点检查ByteBuf是否被正确release()了。我再补充一句堆外内存OOM时JVM抛出的是java.lang.OutOfMemoryError: Direct buffer memory它不等于堆OOM报错时别再一个劲调-Xmx了方向就错了。4.4 线程栈溢出与元空间问题的小型复盘这里分享一个我实际踩过的坑。有一次监控告警说应用CPU飙升但堆内存一切正常GC频率也不高。后来用jstack看线程栈发现大量线程都卡在一个加解密工具类的Cipher.getInstance()上。进一步排查发现代码里频繁调用这个初始化方法每次调用都会加载类并创建对象方法栈深度瞬时增大同时元空间也在快速增长。原因解释清楚后解决方案就是从单例改为复用Cipher实例同时把元空间初始大小调大减少了类加载和卸载的抖动。这类问题的共性规律是内存问题不止堆内存一种栈、元空间、堆外内存、线程都有各自的“爆炸”方式。排查时如果只紧盯一个区域很容易走进死胡同。5. 面试八股文里的Java内存到底在考什么5.1 面试官最爱问的五个问题现在Java岗位面试内存这块几乎是必问而且问的角度越来越刁钻。我把高频问题按套路拆一下第一个问题是“Java运行时数据区有哪些哪些线程共享”这个属于送分题但要答得有层次先说程序计数器、虚拟机栈、本地方法栈、堆、方法区再说哪些私有、哪些共享顺手提一句JDK 8之后方法区演化成元空间就能区分很多只会背旧八股的人。第二个问题是“什么样的对象会进入老年代”这个特别能看出候选人有没有真正理解GC。关键答法是把四种情况都说全大对象直接进入、长期存活对象年龄到阈值、动态年龄判定、Survivor空间容纳不下时直接晋升。第三个问题是“什么时候触发Full GC为什么Full GC会卡顿”要答出三个常见场景老年代空间不足、元空间不足、System.gc()被调用。然后解释Full GC要回收整个堆STW时间更长所以会造成明显的暂停。最好还能接一句G1和ZGC都是通过减少STW来改善这个卡顿问题。第四个问题是“OOM了怎么排查”这个问题基本等于问实战能力。完整回答开GC日志、jstat看GC趋势、jmap抓堆dump、MAT分析大对象和引用链、定位到业务代码后修复。能把这个链路说得越细越有说服力。第五个问题是“Java有哪几种引用类型”软引用、弱引用、虚引用和强引用的区别以及各自适合什么场景。比如软引用适合做缓存弱引用适合做WeakHashMap虚引用主要用于ByteBuffer堆外内存回收的通知场景。5.2 给新手的学习路径别一上来就翻JVM源码我知道有些初学者一聊到Java内存就头大总觉得要去看《深入理解Java虚拟机》才算入门。其实不然我的建议是三种学习方式并行别一头扎进去出不来。先按“用”的层面学启动一个最简单的Spring Boot项目打开visualvm或者JConsole观察Eden、S0、S1、Old四条曲线用jmap -heap看看当前堆分布。主动把堆调小到50m写一个循环加list的demo亲眼看着它OOM再用MAT分析一次。这个流程走完你对Java内存的感知就和没做过的人完全不同了。再按“面”的层面学系统的参数有哪些GC收集器有哪些CMS和G1的行为差异是什么堆外内存出现的原因是什么。这部分和面试直接挂钩也是最容易被八股文覆盖的部分。建议以“能不能讲给别人听”为标准讲不清楚的地方就是没学会的地方。最后是按“线”的层面学从一次真实事件出发比如“线上OOM怎么从报警、日志、dump、分析、修复、回滚这条链路走完”。不管公司规模多大争取找一次机会亲手参与或者自己模拟整条链路。很多开发者的差距不是知识量而是处理问题的路径是否清晰。还有一个学习层面的提醒别把“看视频”当学习闭环。Java内存这种纯工程性的知识看十遍视频不如自己敲一遍命令、踩一次坑。官方的jstat、jmap、jstack文档都写得非常清楚配合本地环境多试比收藏多少篇文章都强。我当时就是从反复执行jmap -heap和jstat -gcutil开始慢慢把整套模型串起来的。