1. 从一道面试题说起JVM内存模型到底在讲什么我第一次被问到“JVM内存模型”的时候脑子里瞬间闪过的是堆、栈、方法区这些零散名词但真要在纸上画出来、讲清楚它们各自管什么、谁会被谁影响当场就卡壳了。后来带过不少新人发现这是特别普遍的现象——大家背过概念但没建立起“模型”思维。所谓内存模型其实就是JVM在运行Java程序时把操作系统给它的那块内存划分成了几个不同的区域每个区域有自己明确的职责、生命周期和异常规则。之所以要划分是因为不同数据的使用方式完全不同。你可以把JVM想象成一个大型仓库里面既有长期存放贵重物品的保险库也有中转快递的临时分拣区还有员工手里随用随扔的便签纸。如果不分区所有东西混在一起存取效率、清理策略、空间管理都会乱套。这篇内容对两类人特别有价值一是正在准备面试的Java开发内存模型几乎是JVM问题的必考点二是写了几年业务代码、想搞清楚OOM到底是怎么发生、该怎么排查的工程师。我会尽量把每个概念都拆开揉碎用大白话和实际例子来讲同时也给出一些实打实的排查经验。理解了内存模型你在看GC日志、配JVM参数、分析性能瓶颈的时候才真正知道自己在调什么。这个底子打好了后面学垃圾回收器、调优工具都会顺利很多。2. 运行时数据区JVM内存的五大核心区域2.1 程序计数器Java多线程的“书签”先聊一个最容易理解也最容易被忽略的区域——程序计数器。它是JVM内存中一块非常小的空间可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。为什么需要它因为Java的多线程是靠线程轮流切换并分配处理器执行时间的方式实现的。一个线程在某个时刻被操作系统挂起等它重新获得CPU时间片继续执行时必须知道上次执行到哪一行了。这时候每个线程独立的程序计数器就派上了用场它保证了线程切换后能恢复到正确的执行位置。有个细节需要注意如果线程正在执行的是一个Java方法计数器记录的是正在执行的虚拟机字节码指令地址如果执行的是Native方法底层C/C方法计数器的值则为空Undefined。这块内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域因为它只占用极小的内存空间生命周期与线程一致线程启动时创建线程结束时销毁。2.2 Java虚拟机栈方法执行的“舞台”Java虚拟机栈描述的是Java方法执行的线程内存模型也就是大家平时口头说的“栈”。每个方法被执行的时候JVM都会同步创建一个栈帧用来存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法从调用到执行完毕的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。拿“局部变量表”来说它存放了编译期可知的各种Java虚拟机基本数据类型boolean、byte、char、short、int、float、long、double、对象引用和returnAddress类型。局部变量表的内存空间在编译期就已经确定并分配完成进入一个方法时这个方法需要在帧中分配多大的局部变量空间是确定的运行期间不会改变局部变量表的大小。我用一个例子帮大家建立画面感。假设有个方法A调用了方法Bpublic void methodA() { methodB(); } public void methodB() { int x 10; }当methodA被执行时JVM为它创建一个栈帧压入虚拟机栈methodA内部调用methodBJVM再为methodB创建一个栈帧压在methodA栈帧之上。methodB执行完它的栈帧出栈并销毁控制权交还给methodA。整个过程严格遵循“后进先出”的规则。如果方法嵌套调用太深比如无限递归栈帧不断入栈最终会撑爆虚拟机栈抛出StackOverflowError。如果虚拟机栈允许动态扩展扩展时无法申请到足够内存会抛出OutOfMemoryError。面试时一个高频问题“递归为什么会栈溢出”答案就在这。2.3 本地方法栈为Native方法服务的专属区域本地方法栈与虚拟机栈所发挥的作用非常相似区别只是虚拟机栈为虚拟机执行Java方法字节码服务而本地方法栈则为虚拟机使用到的Native方法服务。HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一了所以用JDK自带的工具看不到一个独立的“本地方法栈”区域但这并不代表规范层面没有这个概念。这块区域在面试中被问到的频率不高但理解它有助于你明白为什么Java可以通过JNI调用C/C库。比如常见的System.currentTimeMillis()底层就是通过native方法实现的它在执行时使用的栈空间就来自本地方法栈。和虚拟机栈一样本地方法栈也会抛出StackOverflowError和OutOfMemoryError。2.4 Java堆所有对象实例的“家”Java堆是JVM内存中最大的一块区域也是GC垃圾回收的主要战场。几乎所有的对象实例以及数组都在这里分配内存。堆是线程共享的也就是说所有线程创建的对象都会放到同一个堆里这自然带来了线程安全方面的问题也是后面理解锁、并发工具时必须有的背景知识。这里重点说一个在实际工作中影响很大的事情堆的物理内存可以不连续但逻辑上要连续。这就给了我们很大的灵活性比如用-Xms设置堆初始大小、-Xmx设置堆最大大小。我见过很多线上事故都是因为将这两个参数设置成了相同的值导致堆无法动态扩展流量一上来就OOM。从内存回收的角度堆还可以细分为新生代和老年代。新生代又进一步分为Eden区、From Survivor区S0、To Survivor区S1。为什么要分代因为绝大多数对象“朝生夕灭”将它们集中放在新生代用复制算法进行GC效率非常高少数能熬过多次GC的对象进入老年代用标记-整理或标记-清除算法处理。这个设计是从实际统计规律出发的不是什么拍脑袋决策。2.5 方法区与运行时常量池类的“档案馆”方法区用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。在JDK 8以前HotSpot虚拟机用“永久代”来实现方法区结果因为内存上限难控、容易触发Full GC等问题一直被诟病。JDK 8开始HotSpot彻底移除了永久代改用“元空间”Metaspace来实现方法区。元空间使用的是本地内存而不是JVM堆内存默认情况下只受系统可用内存的限制。字符串常量池是一个经常被拿来问的点。JDK 7之前字符串常量池在方法区永久代中JDK 7及以后它被移到了Java堆中。这个调整的直接后果是字符串常量池中的字符串可以被GC回收了。你可能会在面试中被问到“new String(abc)创建了几个对象”这类问题的核心就是区分字符串常量池和堆中的对象。运行时常量池是方法区的一部分用于存放编译期生成的各种字面量与符号引用。Class文件中除了有类的版本、字段、方法、接口等描述信息外还有一项是常量池表用于存放编译期生成的各种字面量与符号引用这部分内容在类加载后存放到方法区的运行时常量池中。2.6 直接内存堆外内存的实用价值直接内存不是JVM运行时数据区的一部分也不是Java虚拟机规范中定义的内存区域。但这块内存被频繁使用而且可能导致OutOfMemoryError所以讲内存模型时不能跳过它。JDK 1.4引入的NIO引入了基于通道与缓冲区的I/O方式它可以使用Native函数库直接分配堆外内存然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。为什么需要直接内存因为在网络传输和文件读写场景中传统做法需要把数据从内核态拷贝到用户态再在堆内和堆外之间复制一次性能有损耗。直接内存省去了把数据从堆内复制到堆外的步骤I/O效率更高。但直接内存有个坑它不占用堆内存空间却会占用系统的物理内存。很多人把-Xmx调得很大以为内存够用结果还是OOM了查了半天才发现是直接内存不够。Netty这类高性能框架默认就大量使用直接内存在排查问题的时候一定要把这块纳入考虑范围。3. 对象的一生从创建到回收的完整路径3.1 对象创建从类加载到内存分配的完整过程当一个Java程序使用new关键字创建对象时JVM内部实际上经历了一个复杂且严谨的过程。下面这个流程可以帮助你把上面讲的内存区域串起来第一步遇到new指令时JVM先检查这个指令的参数能否在常量池中定位到一个类的符号引用并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有就先执行类加载过程。这就是为什么一个类第一次被使用时会比较慢JVM需要把Class文件解析成运行时的数据结构。第二步类加载检查通过后JVM为新生对象分配内存。对象所需内存的大小在类加载完成后就可以完全确定。分配方式有两种指针碰撞和空闲列表。这取决于堆内存是否规整。如果使用Serial、ParNew这类带压缩整理过程的收集器堆是规整的用指针碰撞如果使用CMS这种基于标记-清除算法的收集器堆不规整用空闲列表。第三步内存分配完成后JVM将分配到的内存空间初始化为零值不包括对象头这样就保证了对象的实例字段在Java代码中可以不赋初值就直接使用。接着JVM对对象头进行设置比如对象属于哪个类的实例、对象的哈希码、对象的GC分代年龄等。第四步执行构造函数也就是Class文件中的init()方法。从Java程序视角来看new指令执行完成一个可用的对象才真正被创建出来。这里还有个并发安全问题需要处理创建对象非常频繁在堆上分配内存必然涉及并发修改指针的问题。HotSpot的解决方案有两种一种是对分配内存空间的动作进行同步处理CAS加失败重试另一种是使用线程本地分配缓冲区Thread Local Allocation BufferTLAB每个线程在Eden区预分配一小块内存只有TLAB用完并分配新的TLAB时才需要同步锁定。-XX:UseTLAB参数默认是开启的这也是为什么高并发场景下对象分配效率依然很高的原因之一。3.2 对象的内存布局对象头、实例数据和对齐填充一个对象在堆内存中的存储布局可以划分为三个部分对象头、实例数据和对齐填充。平时我们只关注对象里有哪些业务字段但在排查内存占用问题时理解对象的内存布局非常关键。对象头包含两部分信息。第一部分用于存储对象自身的运行时数据比如哈希码、GC分代年龄、锁状态标志、线程持有的锁等。这部分数据在32位和64位虚拟机中长度不同官方称它为“Mark Word”。注意哈希码这个点默认的Object.hashCode()返回的并不是对象内存地址而是在第一次调用时计算并存储在Mark Word中的一个值。第二部分是类型指针即对象指向它的类元数据的指针JVM通过这个指针来确定对象是哪个类的实例。如果对象是一个Java数组对象头中还必须有一块记录数组长度的数据因为JVM无法从数组的元数据中推断出数组的长度。实例数据是对象真正存储的有效信息也就是程序代码中定义的各种类型字段内容。无论是从父类继承下来的还是在子类中定义的字段都需要记录。这部分的存储顺序受到虚拟机分配策略参数和字段在源码中定义顺序的影响HotSpot默认的分配策略是相同宽度的字段总是被分配到一起比如long和double优先、int和float其次、short和char再次最后是byte和boolean。对齐填充不是必然存在的也没有特别的含义它仅仅起着占位符的作用。HotSpot要求任何对象的大小必须是8字节的整数倍而对象头部分正好是8字节的倍数32位虚拟机下是4字节的倍数所以当对象实例数据部分没有对齐时就需要通过对齐填充来补全。举个例子一个只有int类型字段a和long类型字段b的类在64位虚拟机、开启压缩指针的情况下对象头12字节实例数据为了对齐long类型的b可能不会紧接在int后面而是排到某个偏移量上最终对象总大小必须被8整除。3.3 对象的访问定位句柄还是直接指针创建对象是为了使用它Java程序会通过栈上的reference数据来操作堆上的具体对象。目前主流的访问方式有两种使用句柄和直接指针。句柄方式是在堆中划分一块内存作为句柄池reference中存储的是对象的句柄地址句柄中包含到对象实例数据和类型数据的各自地址。好处是当对象被移动GC时常见的动作时只需要改变句柄中的实例数据指针reference本身不需要修改这对于经常移动对象的垃圾收集器来说很友好。直接指针方式则不同reference中存储的直接就是对象地址。这种方式的好处是速度更快因为减少了定位对象的一次指针定位开销。HotSpot虚拟机主要使用直接指针方式这在对象访问非常频繁的Java应用中是个可观的性能优化。Sun公司有一句很著名的话叫“所有的对象都位于堆上”这句话在HotSpot里有个例外通过逃逸分析技术JVM可能把不会被其他线程访问的对象直接分配到栈上从而减少GC压力。不过这属于JIT编译优化范畴日常开发中很少手动干预。3.4 对象回收判断引用计数法与可达性分析对象创建完成、被使用最终逃不开被回收的命运。判断对象是否已死主流有两种算法。第一种是引用计数法。每个对象有一个引用计数器被引用时加1引用失效时减1计数器为0的对象就不可能再被使用。这个算法原理简单、判定效率高但有一个致命问题它无法解决循环引用。比如对象A持有对象B的引用对象B持有对象A的引用除此之外没有任何其他引用那么A和B的引用计数器永远不为0但它们实际上已经不可达了。Java虚拟机并没有使用引用计数法。第二种是可达性分析算法。基本思路是通过一系列被称为“GC Roots”的根对象作为起始节点集从这些节点开始根据引用关系向下搜索搜索过程所走过的路径称为“引用链”。如果某个对象到GC Roots间没有任何引用链相连就证明此对象不可能再被使用。在Java技术体系中固定可作为GC Roots的对象包括虚拟机栈中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI引用的对象、Java虚拟机内部的引用如基本数据类型对应的Class对象、常驻的异常对象、系统类加载器等。这里要特别提一下引用类型从JDK 1.2开始Java把引用分为了强引用、软引用、弱引用、虚引用四种。强引用就是Object obj new Object()这种只要强引用还存在GC永远不会回收被引用的对象软引用用于描述还有用但非必需的对象在系统将要发生内存溢出异常前会把软引用关联的对象列入回收范围进行第二次回收弱引用只能生存到下一次垃圾收集发生为止虚引用是最弱的引用关系为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。理解这四种引用不仅面试有用写缓存和连接池代码时也会用到。比如用WeakHashMap做缓存key被置为null后下一次GC就可以把整个Entry回收掉避免内存泄漏。4. 垃圾回收内存模型运转的“清洁工”4.1 分代收集理论为什么新生代和老年代要分开处理前面提到堆被划分为新生代和老年代这个设计不是随意定的它基于两个假说弱分代假说绝大多数对象都是朝生夕灭的强分代假说熬过越多次垃圾收集过程的对象就越难以消亡。基于这两个假说设计者可以把堆分成不同区域对不同区域的对象使用不同的回收策略兼顾了回收效率和空间利用率。新生代对象“死亡率”高用标记-复制算法最合适。把Eden区和Survivor区配合使用每次GC后把存活对象复制到空的Survivor区然后一次性清空Eden区和另一个Survivor区没有内存碎片效率极高。对象每熬过一次Minor GC年龄加1默认到15岁可通过-XX:MaxTenuringThreshold调整就晋升到老年代。老年代的对象存活率高用标记-复制算法就不划算了因为需要较大的空间来容纳复制后的对象。HotSpot对老年代使用标记-清除或标记-整理算法。标记-清除会产生内存碎片标记-整理则把存活对象往一端移动解决碎片问题但移动对象需要暂停用户线程停顿时间会更长。4.2 Stop The World为什么GC会卡顿不管使用哪种收集器进行垃圾回收时除了垃圾收集器线程其他所有工作线程都必须被暂停这就是著名的Stop The WorldSTW现象。为什么会这样因为可达性分析算法要求在一个能确保一致性的快照中进行如果在分析过程中对象引用关系还在变化分析结果就无法保证正确性。STW时间是JVM调优中最重要的指标之一。早期的Serial收集器进行Full GC时停顿时间可能达到秒级对于线上高并发服务来说是不可接受的。后来出现的各种收集器本质都是在和STW时间作斗争Parallel收集器追求高吞吐量允许较长的停顿CMS收集器追求低停顿用更复杂的算法减少停顿时间G1则把堆划分为多个Region每次回收一部分让停顿时间可控。理解了STW你就明白了为什么线上系统要监控Full GC次数和耗时。一次长时间的Full GC意味着所有请求都会卡住数据库连接池被打满上游服务超时甚至引发雪崩。4.3 常见垃圾收集器怎么选这里不把每个收集器的实现细节展开只讲它们的特点和适用场景。Serial收集器是单线程工作的收集器进行垃圾收集时必须暂停所有工作线程。它简单高效对于单CPU环境下的Client模式是很好的选择但由于是单线程且需要STW在多核服务器上表现不佳。ParNew收集器是Serial的多线程并行版本默认开启的收集线程数与CPU核数相同。在JDK 8及之前它是服务端模式下新生代首选的收集器有一个很重要的原因是它只能与CMS收集器配合工作。Parallel Scavenge收集器关注的是可控的吞吐量吞吐量指运行用户代码时间占总运行时间的比例。它提供了两个参数来控制吞吐量-XX:MaxGCPauseMillis控制最大垃圾收集停顿时间-XX:GCTimeRatio设置吞吐量大小。CMS收集器Concurrent Mark Sweep是第一款真正意义上的并发收集器它实现了让垃圾收集线程与用户线程同时工作极大地减少了停顿时间适合互联网站等对响应时间敏感的场景。但CMS有两个明显缺点对处理器资源敏感在CPU核数少时会因为抢占CPU导致程序变慢无法处理浮动垃圾可能导致并发失败进而触发一次Full GC。G1收集器是JDK 9及之后服务端默认的垃圾收集器。它的设计理念是把堆划分为多个大小相等的Region每个Region都可以独立扮演Eden、Survivor或者老年代的角色。G1可以根据用户设定的停顿时间目标优先回收价值收益最大的Region这就是“Garbage First”名字的由来。G1的特点是既能像CMS一样低停顿又能像Parallel一样高吞吐Java运维中切换到G1后一般都能获得不错的体验。还有新一代的ZGC收集器它把停顿时间控制在了10毫秒以内而且这个停顿时间不会随着堆大小的增加而增长。如果线上服务特别在意延迟堆又很大ZGC值得一试。JDK 17中ZGC已经支持了分代收集后续会越来越成熟。4.4 GC触发机制什么时候会触发哪种GC新生代Minor GC的触发条件很简单Eden区空间不足时。因为大多数对象在新生代分配Eden区很快就会被占满每次分配新对象时都会检查Eden区剩余空间不足就触发Minor GC。Minor GC非常频繁但速度也很快因为新生代对象大多数是“朝生夕灭”的存活对象很少复制成本很低。老年代Major GC或Full GC的触发条件就复杂多了。常见的有这几种情况老年代空间不足比如大对象直接进入老年代或者长期存活对象晋升到老年代时发现空间不够元空间不足导致Full GC调用System.gc()方法虽然只是建议JVM执行GC但默认情况下会触发Full GCCMS收集器出现并发模式失败Concurrent Mode Failure会退化为Serial Old收集器进行Full GC堆中分配大对象时如果新生代放不下大对象直接进入老年代老年代也放不下时触发Full GC。排查线上问题的时候一定要关注Full GC的触发原因。如果是大对象过多导致的需要检查代码中是否有一次性申请超大数组或者没限制大小的集合操作如果是元空间不足需要检查是否有动态生成类的逻辑比如频繁使用反射、CGLIB代理等。5. 内存溢出实战从堆OOM到栈溢出的排查思路5.1 堆OutOfMemoryError最常见的内存灾难堆OOM是Java应用中最常见的故障类型报错信息一般是java.lang.OutOfMemoryError: Java heap space。出现这种情况要么是堆太小要么是内存泄漏导致堆被耗尽再或者就是创建的对象确实太多超出了堆的承载能力。排查步骤我总结了一个标准流程先获取堆转储快照。在启动参数中加上-XX:HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath/path/to/dump.hprof这样OOM时JVM会自动把堆内存快照保存下来。然后用MATMemory Analyzer Tool或者VisualVM分析快照重点看大对象和支配树。拿到快照后第一步看看堆中占比最大的是什么对象。如果是业务实体对象大量堆积用支配树功能找出谁在引用它们沿着引用链一路向上找到GC Roots一般能定位到某个静态集合不断往里面塞数据没清理或者某个缓存没有设置过期策略。如果是char[]、byte[]这类数组占大头就要怀疑是否有大字符串或者文件流没有关闭某段逻辑在循环里不断拼接字符串或者没有使用缓冲的IO方式硬读大文件。如果是对象本身创建太多导致的OOM比如一个接口在高峰时段被疯狂调用每个请求都会创建几MB的临时对象这种属于“内存分配速率过高”不一定是泄漏。优化思路是减少对象创建比如复用对象池、使用基本类型避免自动装箱、减少在循环中创建中间对象。切记一个原则堆OOM一定要用工具分析快照靠肉眼读代码猜原因效率极低。有快照在手大部分问题都能在半小时内定位。5.2 栈溢出递归和大方法调用的隐患栈溢出的报错是java.lang.StackOverflowError通常是因为方法调用深度太深。最常见的原因是递归没有正确的退出条件比如把if (n 1) return 1;写成了if (n 1) return 1;就会无限递归下去很快打爆栈。但还有一个容易被忽视的原因方法内部定义了过多的局部变量导致单个栈帧占用的内存过大。虽然Java虚拟机规范中不限制局部变量表的最大容量但一个栈帧占的空间越大能容纳的栈帧数量就越少调用深度稍微大一点就溢出了。有些ORM框架的嵌套查询、很深的对象转换链也会在看似正常的代码中触发栈溢出。栈大小可以通过-Xss参数调整默认为1MB。注意这个参数调大并不能解决递归算法本身的问题它只是延长了溢出发生的时间。要真正解决需要把递归改成循环或者使用栈数据结构自己模拟递归过程。每次发生OOM都应该把这些信息记录下来什么操作触发的、当时QPS多少、堆使用率曲线如何、GC日志是什么样的。积累几次之后你会形成自己的排查套路遇到问题不再慌张。5.3 元空间OOM动态生成类的锅元空间OOM的报错是java.lang.OutOfMemoryError: Metaspace。JDK 8之后元空间使用的是本地内存理论上只要机器物理内存够大就不会OOM但架不住有些框架会疯狂生成新类。典型场景包括大量使用CGLIB动态代理生成代理类、JSP在运行时不断编译产生新类、反射频繁调用Class.forName加载类。比较隐蔽的是Groovy或Scala这类动态语言的脚本类每次执行脚本都会生成新的Class对象如果没有做缓存运行一段时间后元空间就会被耗尽。排查这类问题可以通过-XX:MaxMetaspaceSize给元空间设置上限让它更早暴露问题然后用jstat观察Metaspace区域的增长趋势。如果是Groovy脚本导致的考虑改用GroovyClassLoader的缓存机制或者改用静态编译如果是反射导致的检查是否频繁创建Method对象而没有使用缓存。5.4 排查工具推荐jps、jstat、jmap、jstack、arthas实战命令记不住没关系但要记住每个工具解决什么问题关键时刻能想起来用哪个。jps用于查看Java进程列表最常用的用法是jps -l显示主类全名。拿到进程ID后所有其他工具都以它为参数。jstat用于监视JVM各种运行状态信息最常用的用法是jstat -gcutil pid 1000每隔1秒打印一次GC信息能看到各个代的使用率、GC次数和耗时。判断是否存在频繁Full GC这个命令最直接。jmap用于生成堆转储快照和查看堆的详细信息。jmap -heap pid显示堆的配置参数和当前使用情况jmap -dump:formatb,fileheap.hprof pid导出堆快照。注意在JDK 8及以后jmap在导大堆时可能比较慢而且执行时会对进程产生一定影响线上谨慎使用。jstack用于生成线程快照排查死锁、线程阻塞、CPU飙高的问题。jstack pid输出所有线程的栈信息可以配合top命令找到CPU占用最高的线程ID转成十六进制后在jstack输出中定位对应线程。arthas是阿里开源的高级诊断工具用法是java -jar arthas-boot.jar然后选择要连接的Java进程。它最大的优势是不需要重启应用就能动态查看类加载信息、调用方法耗时、反编译代码还能直接在线执行表达式。实测排查生产问题比jmap和jstack效率高很多比如trace命令可以跟踪一个方法的调用链和耗时watch命令可以观察某个方法的入参和返回值。实际排查中我的组合拳是先用jps和jstat快速判断是不是GC问题再用arthas做深入分析不到万不得已不用jmap导堆。如果确认需要看堆快照再配合MAT做离线分析这样对线上服务的影响能降到最低。6. 调优实战用参数和工具把内存模型玩明白6.1 核心参数一网打尽从堆大小到GC选择JVM参数很多但日常用得上的核心参数就那十几个。我把它们按用途分组说明方便你直接抄作业。堆内存大小相关-Xms堆初始大小默认物理内存的1/64。-Xmx堆最大大小默认物理内存的1/4。-Xmn新生代大小。设置后老年代大小为-Xmx减去-Xmn。-XX:NewRatio老年代与新生代比值默认2即老年代是新生代的2倍。-XX:SurvivorRatioEden区与单个Survivor区的比值默认8即Eden占新生代的8/10两个Survivor各占1/10。这里有个血泪教训生产环境的-Xms和-Xmx一定要设置成相同值。如果不相等JVM会先以初始值启动随着对象增多不断扩容堆。扩容涉及向操作系统申请内存和堆内存重新分配代价不小而且很容易被误判为性能问题。启动时直接申请最大堆内存反而更稳定。GC日志相关-XX:PrintGCDetails打印详细的GC日志。-XX:PrintGCDateStamps在GC日志中打印日期时间戳。-Xloggc:/path/gc.log把GC日志输出到指定文件。JDK 9及以后推荐使用-Xlog:gc*:filegc.log统一日志配置。GC选择相关-XX:UseSerialGCSerial Serial Old。-XX:UseParallelGCParallel Scavenge Parallel OldJDK 8默认。-XX:UseConcMarkSweepGCParNew CMS Serial Old。-XX:UseG1GCG1JDK 9及以后默认。-XX:MaxGCPauseMillis设置目标最大停顿时间Parallel和G1都支持。如果你还在用JDK 8我建议在条件允许的情况下测试后切换到G1。G1在处理大堆和可控停顿方面比Parallel有明显的优势。6.2 一个真实案例从Full GC频繁到性能稳定下面分享一个我做过的真实调优案例展示参数和工具怎么配合使用。有一个内部报表系统部署在4核8G的服务器上Java堆配置为-Xmx4g。业务高峰期访问量上来后接口响应时间从平均200ms飙升到3秒以上监控系统报警。第一反应是先看GC情况用jstat -gcutil pid 1000观察发现Full GC每几分钟就一次单次耗时达到5秒左右Minor GC频率也异常高。查看GC日志发现有大量对象晋升到老年代老年代空间迅速被占满。用jmap导堆后用MAT分析发现一个统计报表的查询结果集占用极大数据量每次查询都会加载近一个月的数据到内存在内存中聚合计算。这个逻辑本身没问题但没有做分页和限制再加上多用户同时查询内存自然扛不住。解决方案分两步。第一步调整代码逻辑给查询加上时间范围限制和条数限制大结果集改用分批处理。第二步调整JVM参数把新生代从默认的1G调大到1.5G让更多短期对象在新生代就被回收减少晋升把-Xmx调到6G机器内存8G预留2G给操作系统和直接内存同时开启-Xloggc方便后续持续观察。调整后Full GC基本消失Minor GC频率降到每分钟几次接口响应时间稳定在200ms左右。这个案例说明一个道理调优不是盲目改参数而是先定位问题是代码问题就改代码是参数不合理就调参数。参数调优只是手段不是目的。6.3 设置IDE的JVM内存避免开发时OOM开发阶段遇到OOM也很烦人尤其是启动一个大型Spring Boot项目时IDEA加载全部依赖和编译大量代码默认内存设置经常不够用。虽然这类OOM影响范围小但足以打断编码节奏值得顺手解决。IDEA的JVM内存设置在Help菜单下的Edit Custom VM Options里默认文件是idea.vmoptions。核心参数是-Xms初始堆、-Xmx最大堆、-XX:ReservedCodeCacheSize代码缓存、-XX:MaxMetaspaceSize最大元空间。我一般设置-Xms2g -Xmx4g代码缓存保持默认或适当加大到512m元空间设置1g。如果你的电脑内存足够大把-Xmx调到6gIDEA的卡顿会明显减少。另外要注意IDEA自身的VM Options和项目的VM Options是两回事。项目的运行内存要在Run/Debug Configurations里的VM options中设置比如Spring Boot项目启动时出现Metaspace OOM就在那里加-XX:MaxMetaspaceSize512m。除了IDEMaven打包时也可能因为内存不足报错通常是在MAVEN_OPTS环境变量中设置-Xmx。如果你经常构建大项目提前设置export MAVEN_OPTS-Xmx2g会很省心。6.4 线程池与内存的联动关系热词里有一条“线程池设置最大线程数是jvm剩余可用线程”这是一个容易导致误解的话题。线程池的最大线程数和JVM内存之间没有直接的数字换算关系但它们确实有很强的联动。每个线程都会占用一块栈空间默认1MB。如果线程池设置了很大的最大线程数比如1000理论上就会占用约1GB的栈内存这还不算线程内创建的对象占用的堆内存。所以无脑调大线程池最大线程数不但不能让系统更快反而会增加内存压力甚至引发OOM。线程池大小应该根据任务的类型来定。CPU密集型任务线程数设置为CPU核数1即可IO密集型任务因为有大量时间在等待IO可以设置更多线程比如CPU核数 * 2或者用公式CPU核数 / (1 - 阻塞系数)来估算。实际操作中最好通过压测来确定最佳值而不是拍脑袋。JVM在处理线程池相关的内存问题时要特别关注两个方面一是线程栈占用的内存是否被计算进了整体内存预算二是线程中创建的对象是否会滞留在内存中比如使用ThreadLocal但没清理线程池中的线程复用会导致ThreadLocal中的对象一直无法被回收这是线上非常隐蔽的内存泄漏之一。解决方法是使用完ThreadLocal后一定要调用remove方法。7. 面试必问JVM内存模型高频问题深度拆解7.1 你能否画一下JVM内存模型这是一道送命题但很多人答不好就是因为在纸上现场画的时候逻辑混乱。建议按“线程私有 vs 线程共享”作为第一层分类来回答然后逐个展开。线程私有的部分程序计数器、虚拟机栈、本地方法栈。它们的共同点是随线程创建而创建随线程消亡而消亡不需要做垃圾回收。线程共享的部分Java堆、方法区。堆是GC的主要区域方法区在JDK 8之后由元空间实现。答完这些之后主动补一句JDK 8以后永久代被移除字符串常量池移到堆中元空间使用本地内存。这个补充会显得你对版本差异很敏感是加分项。7.2 为什么程序计数器是线程私有的因为多线程环境下线程切换后需要恢复到正确的执行位置每个线程都必须有一个独立的程序计数器记录自己当前执行到哪条字节码指令。如果所有线程共用一个计数器切换后根本无法恢复程序就乱套了。7.3 什么是STW为什么G1能控制停顿时间STW是垃圾回收时必须暂停所有工作线程的现象原因是为了保证可达性分析时对象引用关系的快照一致性。G1通过把堆划分为多个Region每次回收一部分Region而不是全堆扫描并且记录了一个RSet记忆集来精确知道哪些跨Region引用从而让停顿时间变得可控能通过-XX:MaxGCPauseMillis设置目标停顿时间。7.4 如何排查内存泄漏先说什么不是内存泄漏堆太小、对象太多导致的OOM不算泄漏因为释放掉就不再出错。真正的泄漏是对象不再使用但被人引用着无法被GC回收。排查思路是拿到堆快照后用MAT的“Leak Suspects”功能定位嫌疑对象再通过支配树找到持有这些对象的GC Roots引用链。常见泄漏场景包括静态集合类不断添加数据而不清理、监听器或回调没有注销、ThreadLocal忘记remove、连接/流未关闭、缓存设置无过期策略。线上建议开启-XX:HeapDumpOnOutOfMemoryError让JVM在OOM时自动落盘然后离线分析。7.5 JVM、JRE和JDK的关系是什么这个问题经常出现在入门面试中表面简单但其实考的是概念清晰度。JVM是Java虚拟机负责执行字节码是Java跨平台的关键JRE是Java运行环境包含JVM和Java核心类库用于运行Java程序JDK是Java开发工具包包含JRE、编译器javac、调试器jdb等工具用于开发Java程序。它们的关系是JDK包含了JREJRE包含了JVM。7.6 实战知识什么是逃逸分析逃逸分析是JIT编译器的一种优化技术它分析对象的动态作用域对象被方法内部创建没有传递给外部也没有被外部方法引用就是“不逃逸”反之就是“逃逸”。如果不逃逸JVM可以把对象分配在栈上而不是堆上方法结束时栈帧弹出对象直接销毁完全不用GC参与。还会触发标量替换、同步消除等优化。了解这个概念能让你在面试中解释为什么有些“new对象”并没有触发GC。8. 常见问题速查表我把日常开发中遇到的内存问题整理成一张速查表方便你遇到问题时快速对照。报错信息可能原因排查方向Java heap space堆内存不足内存泄漏或对象过多导堆快照MAT分析大对象和引用链Metaspace动态生成类过多元空间不足检查反射、CGLIB、Groovy脚本设置MaxMetaspaceSizeStackOverflowError方法递归过深栈帧过大检查递归退出条件栈帧中局部变量是否过多OutOfMemoryError: GC overhead limit exceededGC频繁回收但回收效果差98%时间用于GC且回收不到2%堆空间导堆快照分析泄漏点同时关注GC参数Direct buffer memory直接内存不足检查NIO、Netty使用情况适当加大MaxDirectMemorySizeUnable to create new native thread线程数超限无法创建新线程检查线程池是否无界降低线程数检查操作系统线程数限制关于“Unable to create new native thread”这个报错需要多说两句。它和JVM堆没有直接关系本质是操作系统层面的线程资源耗尽。每个线程在操作系统内核中都有对应资源32位系统还有线程栈地址空间限制。遇到这个问题优先排查代码中有没有无限创建线程的开关比如每来一个请求就new一个Thread而不用线程池然后检查系统文件描述符限制和用户进程最大线程数限制。GC overhead limit exceeded这个报错经常被忽略。它的触发条件是JVM花费98%以上的时间执行GC但回收不到2%的堆空间。遇到这种情况几乎可以断定是堆太小或对象太多别犹豫直接导堆分析。提示排查OOM时第一条原则是保留现场。先把-XX:HeapDumpOnOutOfMemoryError开起来确保OOM发生时留下证据再谈其他。没有快照的排查80%的精力都花在猜测上。9. 个人实操心得讲了这么多理论和方法最后分享几个我从实际踩坑中积累的体会希望对你有帮助。第一不要为了调优而调优。很多团队一遇到性能问题就跑到群里问“JVM参数怎么配”恨不得求一个万能配置。实际上大部分性能问题出在代码层面比如不合理的SQL、循环里的IO操作、无脑的大集合加载。先把代码审查一遍再考虑参数。我给新人的建议是默认参数可以应对80%的场景不要随便动。第二GC日志是很珍贵的数据。生产环境一定要开GC日志并且设置日志轮转避免单个文件过大。一次GC日志记录的GC频率、停顿时间、晋升情况远比任何监控大盘都精确。很多你以为的“JVM问题”看一眼日志就真相大白了。第三元数据和缓存相关的内存问题最隐蔽。比如某个框架在类加载时做了不可见的缓存某个中间件客户端未关闭或者ThreadLocal在异步线程里没清理。这类问题靠参数调不准必须靠快照分析锁定引用链。所以我强烈建议每位Java开发者学会用MAT或arthas这是线上排查的硬技能。第四G1不是银弹。虽然G1是JDK 9默认收集器但如果你用的是小堆比如2G以下、低并发的应用Parallel收集器的效果可能更好。选收集器要根据业务特点来追求吞吐量用Parallel追求低延迟用G1或ZGC没有最好的只有最合适的。第五内存模型的每个概念都不是孤立的。栈帧引用了堆中的对象方法区中的类元数据描述了对象的模板GC Roots从栈和方法区出发标记存活对象直接内存又独立于堆之外。把这几个区域串起来想你会突然发现JVM在运行时就是一个精密的生态系统一旦建立起整体视野面试题和调优问题都会变得简单起来。如果这篇文章帮到你了建议你亲自动手做一次完整的堆快照分析哪怕是对着自己写的demo。实践一次比读十篇博客都有用。真遇到JVM调优的疑难杂症时记住核心原则保留现场、从GC日志入手、用工具说话、别靠猜。祝你在JVM这条路上少踩坑多收获。