
1. 项目概述为什么我们需要一张JVM的“地图”干了这么多年Java开发我越来越觉得JVMJava虚拟机就像是你每天开车上下班必经的那条路。你可能开了好几年知道哪个路口容易堵哪个红绿灯时间最长但真要你画出这条路的地图标出每一个车道、每一个交通标志甚至地下管线的走向大部分人可能就懵了。我们天天用Java写代码程序在JVM上跑但JVM内部到底是怎么“开车”的怎么管理“交通”内存怎么处理“事故”异常很多人其实只有一个模糊的印象。尤其是在面试或者排查一些诡异的生产问题时这种模糊感就会变成一种无力感。面试官问你“对象是怎么分配的什么时候会进入老年代” 你心里可能知道个大概但要说清楚“指针碰撞”和“空闲列表”的区别以及它们各自的应用场景可能就得卡壳。线上服务突然Full GC老年代内存居高不下你看着监控图表一头雾水不知道从何下手只能重启大法好。这其实就是因为缺乏一张清晰的、体系化的JVM“地图”。所以我决定花时间画一张详细的JVM图解并配上文字目的不是堆砌概念而是带你真正“走一遍”JVM的核心区域理解每一个关键路口的设计逻辑。这不是一篇快餐式的面试题汇总而是一次深度的内部探秘。无论你是想夯实基础、备战面试还是为了解决实际中的性能难题这张“地图”都能给你提供清晰的路径。我们不止要看懂图更要搞懂图背后的“为什么”——为什么内存要这么划分为什么垃圾回收要设计这么多算法理解了这些你才能从“会用Java”进阶到“懂Java”。2. JVM内存模型深度拆解不止是“堆”和“栈”提到JVM内存很多人第一反应就是“堆”和“栈”。这个理解没错但太粗糙了就像把一座城市只分为“住宅区”和“商业区”完全忽略了里面的街道、公园、学校等细节。JVM的内存模型要精细和复杂得多它是整个虚拟机执行引擎的基石。2.1 运行时数据区的全景图与核心设计思想JVM在执行Java程序时会把它管理的内存划分为若干个不同的数据区域。这些区域各有各的用途各有各的创建和销毁时机。我们可以把它们分为两大类线程私有的和线程共享的。线程私有区域这类区域的生命周期与线程相同随线程而生随线程而灭。每个线程都有自己独立的一份互不干扰。这就像每个公司员工都有自己的办公桌虚拟机栈和正在处理的当前任务清单程序计数器别人不能随便动。程序计数器Program Counter Register这是一块很小的内存空间你可以把它看作是当前线程所执行的字节码的行号指示器。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。为什么每个线程都要有一个独立的程序计数器因为JVM的多线程是通过线程轮流切换、分配处理器执行时间的方式来实现的。任何一个时刻一个处理器对于多核处理器来说是一个内核都只会执行一条线程中的指令。因此为了线程切换后能恢复到正确的执行位置每条线程都需要有一个独立的程序计数器。Java虚拟机栈Java Virtual Machine Stacks它的生命周期与线程相同。描述的是Java方法执行的内存模型每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。我们常说的“栈内存”通常指的就是虚拟机栈中局部变量表部分。本地方法栈Native Method Stack它与虚拟机栈发挥的作用非常相似区别在于虚拟机栈为虚拟机执行Java方法也就是字节码服务而本地方法栈则为虚拟机使用到的Native方法服务。在HotSpot虚拟机中本地方法栈和虚拟机栈是合二为一的。线程共享区域这类区域是所有线程共享的存放着被所有线程访问的数据。这就像公司的公共资料库方法区和仓库堆大家都可以按规则存取东西。Java堆Java Heap这是JVM所管理的内存中最大的一块也是垃圾收集器管理的主要区域因此很多时候也被称作“GC堆”。几乎所有的对象实例以及数组都在这里分配内存。Java堆是线程共享的但在实现时可以考虑划分出多个线程私有的分配缓冲区TLAB以提升对象分配时的效率。从内存回收的角度由于现代垃圾收集器基本都采用分代收集算法所以Java堆可以细分为新生代和老年代。从内存分配的角度线程共享的Java堆中可能划分出多个线程私有的分配缓冲区。方法区Method Area它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。很多人愿意把方法区称为“永久代”本质上两者并不等价。仅仅是HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区或者说用永久代来实现方法区而已这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存省去专门为方法区编写内存管理代码的工作。但在其他虚拟机如J9、JRockit上是不存在永久代的概念的。到了JDK 8HotSpot彻底移除了永久代用元空间Metaspace取而代之元空间使用的是本地内存而不是JVM堆内存。注意将方法区称为“永久代”是一个容易误导人的习惯。它容易让人以为这个区域的数据是“永久”存在的不会被回收。实际上这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载条件非常苛刻但确实是会发生的。改用元空间后一方面避免了永久代的OOM问题因为元空间大小受本地内存限制默认情况下只受限于系统可用内存另一方面也将类元数据的管理从JVM堆转移到了本地内存。2.2 堆内存的精细结构新生代与老年代的生存博弈Java堆是GC的主战场理解它的结构是理解所有垃圾回收算法的前提。现代JVM的堆内存通常采用分代设计核心思想是根据不同对象的存活周期将内存划分为几块从而根据每块内存的特点采用最合适的垃圾收集算法。新生代Young Generation顾名思义新创建的对象首先被分配在这里。研究表明绝大部分Java对象都具有“朝生夕死”的特性生命周期极短。因此新生代的设计目标是用较小的内存空间配合高效的垃圾回收算法快速清理掉这些短期存活的对象。新生代内部又分为三个区域Eden区伊甸园对象诞生的地方。几乎所有新对象都在Eden区分配。当Eden区空间不足时会触发一次针对新生代的垃圾回收Minor GC。Survivor区幸存者区分为Survivor 0简称S0或From区和Survivor 1简称S1或To区。这两个区域大小相等角色互换。在Minor GC后Eden区中仍然存活的对象会被移动到其中一个Survivor区比如S0。下次Minor GC时会扫描Eden区和当前存活的Survivor区S0将仍然存活的对象复制到另一个空的Survivor区S1然后清空Eden和刚才的S0。每经历一次Minor GC且存活下来对象的年龄就增加1岁。晋升阈值当对象的年龄增长到一定程度默认是15岁可通过-XX:MaxTenuringThreshold设置在下一次Minor GC时它就会被移动到老年代。这个设计非常巧妙它让那些经过多次GC“考验”的、大概率会长期存活的对象自动迁移到更适合它们的老年代避免在新生代被反复复制。老年代Old Generation存放那些生命周期长的对象以及一些大对象可能直接分配在老年代避免在新生代来回拷贝。由于老年代中的对象存活率高没有额外的空间进行复制担保所以老年代的垃圾回收Major GC / Full GC通常采用“标记-清除”或“标记-整理”算法速度比Minor GC慢得多。Full GC会清理整个堆包括新生代和老年代通常会导致应用线程停顿Stop-The-World是影响应用响应时间的主要元凶之一。为什么需要Survivor区如果没有Survivor区Eden区每次GC后存活对象直接进入老年代会迅速填满老年代触发更耗时的Full GC。有了Survivor区相当于给新生代对象一个“缓冲带”和“成年礼”只有真正经过考验的“成年人”才能进入老年代这极大地降低了老年代被快速填满的概率提升了整体GC效率。3. 对象的一生从诞生到消亡的全流程解析理解了内存布局我们跟着一个Java对象走完它从创建到被回收的完整生命周期。这个过程充满了JVM设计者的精巧构思。3.1 对象的创建与内存分配机制当你在代码中写下new Object()时JVM会做一系列事情类加载检查首先检查这个new指令的参数是否能在常量池中定位到一个类的符号引用并检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有必须先执行相应的类加载过程。分配内存在类加载检查通过后JVM将为新生对象在Java堆中分配内存。对象所需内存大小在类加载完成后便可完全确定。分配方式取决于Java堆是否规整而Java堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定。这就引出了两个核心概念指针碰撞Bump the Pointer如果Java堆的内存是绝对规整的意味着所有用过的内存放在一边空闲的内存放在另一边中间放着一个指针作为分界点指示器那么分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离。Serial、ParNew等带有压缩整理功能的收集器采用这种方式。分配速度极快。空闲列表Free List如果Java堆的内存并不是规整的已使用的内存和空闲的内存相互交错虚拟机就必须维护一个列表记录哪些内存块是可用的在分配的时候从列表中找到一块足够大的空间划分给对象实例并更新列表上的记录。CMS这种基于标记-清除算法的收集器通常采用空闲列表。初始化零值内存分配完成后JVM需要将分配到的内存空间不包括对象头都初始化为零值。这步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用程序能访问到这些字段的数据类型所对应的零值。设置对象头接下来JVM要对对象进行必要的设置例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中。执行init方法从JVM的视角看一个新的对象已经产生了。但从Java程序的视角看对象创建才刚刚开始——init方法即构造函数还没有执行。执行init方法按照程序员的意愿对对象进行初始化这样一个真正可用的对象才算完全产生出来。3.2 对象的内存布局与访问定位在HotSpot虚拟机中对象在堆内存中的存储布局可以划分为三个部分对象头Header、实例数据Instance Data和对齐填充Padding。对象头包含两部分信息。Mark Word用于存储对象自身的运行时数据如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。这部分数据在32位和64位的虚拟机中分别为32bit和64bit官方称它为“Mark Word”。它被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中如果对象处于未被锁定的状态那么Mark Word的32bit空间中的25bit用于存储对象哈希码4bit用于存储对象分代年龄2bit用于存储锁标志位1bit固定为0。类型指针即对象指向它的类元数据的指针虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针例如通过句柄访问的方式就不需要。实例数据对象真正存储的有效信息即我们在程序代码里所定义的各种类型的字段内容无论是从父类继承下来的还是在子类中定义的都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。对齐填充它仅仅起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍换句话说就是任何对象的大小都必须是8字节的整数倍。对象头部分已经被精心设计成正好是8字节的倍数因此如果对象实例数据部分没有对齐的话就需要通过对齐填充来补全。创建了对象程序如何访问它主流的访问方式有两种使用句柄Java堆中划分出一块内存作为句柄池reference中存储的是对象的句柄地址而句柄中包含了对象实例数据与类型数据各自的具体地址信息。优点是reference中存储的是稳定的句柄地址在对象被移动垃圾收集时移动对象是非常普遍的行为时只会改变句柄中的实例数据指针而reference本身不需要修改。使用直接指针reference中存储的直接就是对象地址。优点是速度更快它节省了一次指针定位的时间开销。由于对象的访问在Java中非常频繁因此这类开销积少成多后也是一项非常可观的执行成本。HotSpot虚拟机主要使用直接指针方式进行对象访问。4. 垃圾收集机制JVM的“清洁工”系统垃圾收集是JVM的核心功能也是面试和调优的重点。它的目标很简单自动回收不再使用的内存。但实现起来却是一套极其复杂的系统工程。4.1 如何判断对象已“死”—— 可达性分析算法在垃圾收集器对堆进行回收前第一件事就是要确定哪些对象还“活着”哪些已经“死去”即不可能再被任何途径使用的对象。主要有两种算法引用计数算法给对象中添加一个引用计数器每当有一个地方引用它时计数器值就加1当引用失效时计数器值就减1任何时刻计数器为0的对象就是不可能再被使用的。客观来说引用计数算法实现简单判定效率也很高但它很难解决对象之间相互循环引用的问题。因此主流的Java虚拟机里没有选用引用计数算法来管理内存。可达性分析算法这是当前主流商用程序语言的内存管理子系统所选择的算法。这个算法的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点集从这些节点开始根据引用关系向下搜索搜索过程所走过的路径称为“引用链”如果某个对象到GC Roots间没有任何引用链相连则证明此对象是不可能再被使用的。在Java技术体系里固定可作为GC Roots的对象包括以下几种在虚拟机栈栈帧中的本地变量表中引用的对象譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。在方法区中类静态属性引用的对象譬如Java类的引用类型静态变量。在方法区中常量引用的对象譬如字符串常量池里的引用。在本地方法栈中JNI即通常所说的Native方法引用的对象。Java虚拟机内部的引用如基本数据类型对应的Class对象一些常驻的异常对象比如NullPointException、OutOfMemoryError等还有系统类加载器。所有被同步锁synchronized关键字持有的对象。反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。4.2 分代收集理论与经典垃圾收集器基于“绝大多数对象朝生夕死”和“熬过越多次垃圾收集过程的对象就越难以消亡”这两个经验法则分代收集理论应运而生。它奠定了现代垃圾收集器的一致设计原则将Java堆划分出不同的区域然后根据区域的特点采用不同的垃圾收集算法。基于分代理论衍生出多种经典的垃圾收集器它们各有侧重适用于不同场景Serial / Serial Old收集器这是一个单线程的收集器。它的“单线程”意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作更重要的是在它进行垃圾收集时必须暂停其他所有工作线程直到它收集结束。它是客户端模式下的默认新生代收集器简单而高效。ParNew收集器实质上是Serial收集器的多线程并行版本除了同时使用多条线程进行垃圾收集之外其余行为包括Serial收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一致。它是许多运行在服务端模式下的HotSpot虚拟机中首选的新生代收集器其中一个重要原因是除了Serial收集器外目前只有它能与CMS收集器配合工作。Parallel Scavenge / Parallel Old收集器这是一组注重吞吐量的收集器。吞吐量就是处理器用于运行用户代码的时间与处理器总消耗时间的比值。Parallel Scavenge收集器提供了用于控制吞吐量的参数适合在后台运算而不需要太多交互的任务。Parallel Old是Parallel Scavenge的老年代版本支持多线程并发收集。CMSConcurrent Mark Sweep收集器这是一种以获取最短回收停顿时间为目标的收集器。它非常符合互联网站或者B/S系统的服务端需求这类应用尤其重视服务的响应速度。其运作过程分为四个步骤初始标记仅仅只是标记一下GC Roots能直接关联到的对象速度很快需要“Stop The World”。并发标记从GC Roots的直接关联对象开始遍历整个对象图的过程这个过程耗时较长但可以与用户线程并发执行。重新标记为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间通常会比初始标记阶段稍长一些但远比并发标记的时间短也需要“Stop The World”。并发清除清理删除掉标记阶段判断的已经死亡的对象由于不需要移动存活对象所以这个阶段也是可以与用户线程同时并发的。 CMS的优点是并发收集、低停顿。但缺点也很明显对处理器资源非常敏感无法处理“浮动垃圾”基于“标记-清除”算法会产生大量空间碎片。G1Garbage First收集器这是一款面向服务端应用的垃圾收集器是JDK 9以后的默认垃圾收集器。G1不再坚持固定大小以及固定数量的分代区域划分而是把连续的Java堆划分为多个大小相等的独立区域每一个区域都可以根据需要扮演新生代的Eden空间、Survivor空间或者老年代空间。收集器能够对扮演不同角色的区域采用不同的策略去处理。G1跟踪各个区域里面的垃圾堆积的“价值”大小即回收所获得的空间大小以及回收所需时间的经验值在后台维护一个优先列表每次根据允许的收集时间优先回收价值最大的区域。这种使用区域划分内存空间以及有优先级的区域回收方式保证了G1收集器在有限的时间内可以获取尽可能高的收集效率。G1从整体上看是基于“标记-整理”算法实现的收集器但从局部两个Region之间上看又是基于“复制”算法实现的这意味着运行期间不会产生内存空间碎片。4.3 内存分配与回收的关键策略对象的内存分配往大方向讲就是在堆上分配。但细节上分配规则并不是固定的取决于当前使用的是哪种垃圾收集器组合还有虚拟机中与内存相关的参数的设置。对象优先在Eden分配大多数情况下对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时虚拟机将发起一次Minor GC。大对象直接进入老年代大对象是指需要大量连续内存空间的Java对象最典型的大对象便是那种很长的字符串或者元素数量很庞大的数组。虚拟机提供了一个-XX:PretenureSizeThreshold参数指定大于该设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间来回复制产生大量的内存复制开销。长期存活的对象将进入老年代虚拟机给每个对象定义了一个对象年龄计数器。对象通常在Eden区里诞生如果经过第一次Minor GC后仍然存活并且能被Survivor容纳的话该对象会被移动到Survivor空间中并且将其年龄设为1岁。对象在Survivor区中每熬过一次Minor GC年龄就增加1岁当它的年龄增加到一定程度默认为15就会被晋升到老年代中。动态对象年龄判定为了能更好地适应不同程序的内存状况HotSpot虚拟机并不是永远要求对象的年龄必须达到MaxTenuringThreshold才能晋升老年代。如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半年龄大于或等于该年龄的对象就可以直接进入老年代无须等到MaxTenuringThreshold中要求的年龄。空间分配担保在发生Minor GC之前虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果这个条件成立那这一次Minor GC可以确保是安全的。如果不成立则虚拟机会先查看-XX:HandlePromotionFailure参数的设置值是否允许担保失败如果允许那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小。如果大于将尝试进行一次有风险的Minor GC如果小于或者参数设置不允许冒险那这时就要改为进行一次Full GC。5. 类加载子系统代码如何变成可执行指令我们写的.java文件经过编译变成.class字节码文件。但字节码是一套平台无关的指令集它不能直接交给操作系统执行。JVM的类加载子系统就是负责将.class文件加载到内存并进行验证、准备、解析和初始化最终形成可以被JVM直接使用的Java类型。这个过程是Java实现“一次编写到处运行”的基石。5.1 类加载的生命周期一个类型从被加载到虚拟机内存中开始到卸载出内存为止它的整个生命周期将会经历加载、验证、准备、解析、初始化、使用和卸载七个阶段其中验证、准备、解析三个部分统称为连接。加载在这个阶段虚拟机需要完成以下三件事情通过一个类的全限定名来获取定义此类的二进制字节流。将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。在内存中生成一个代表这个类的java.lang.Class对象作为方法区这个类的各种数据的访问入口。 这个“通过全限定名获取二进制字节流”的动作可以由JVM内置的引导类加载器完成也可以由用户自定义的类加载器去完成这为Java应用程序提供了极高的灵活性如从ZIP包中读取、从网络中获取、运行时计算生成等。验证这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求保证这些信息被当作代码运行后不会危害虚拟机自身的安全。验证阶段大致上会完成下面四个阶段的检验动作文件格式验证、元数据验证、字节码验证、符号引用验证。准备准备阶段是正式为类中定义的变量即静态变量被static修饰的变量分配内存并设置类变量初始值的阶段。这里所说的初始值“通常情况”下是数据类型的零值。假设一个类变量的定义为public static int value 123;那变量value在准备阶段过后的初始值为0而不是123因为这时尚未开始执行任何Java方法。而把value赋值为123的putstatic指令是程序被编译后存放于类构造器clinit()方法之中所以赋值为123的动作要到类的初始化阶段才会被执行。但对于final static修饰的常量在准备阶段就会直接赋予指定的值。解析解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程。符号引用以一组符号来描述所引用的目标符号可以是任何形式的字面量只要使用时能无歧义地定位到目标即可。直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。初始化类的初始化阶段是类加载过程的最后一个步骤。直到初始化阶段Java虚拟机才真正开始执行类中编写的Java程序代码。初始化阶段就是执行类构造器clinit()方法的过程。clinit()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。虚拟机会保证一个类的clinit()方法在多线程环境中被正确地加锁、同步如果多个线程同时去初始化一个类那么只会有一个线程去执行这个类的clinit()方法其他线程都需要阻塞等待直到活动线程执行完毕clinit()方法。5.2 双亲委派模型及其破坏类加载器是实现“类加载”这个动作的代码模块。从JVM的角度看只存在两种不同的类加载器一种是启动类加载器由C语言实现是虚拟机自身的一部分另一种就是所有其他的类加载器由Java语言实现独立于虚拟机外部并且全都继承自抽象类java.lang.ClassLoader。站在Java开发人员的角度类加载器可以划分得更细致一些。自JDK 1.2以来Java一直保持着三层类加载器、双亲委派的类加载架构。启动类加载器负责加载存放在JAVA_HOME\lib目录或者被-Xbootclasspath参数所指定的路径中存放的而且是Java虚拟机能够识别的类库加载到虚拟机的内存中。扩展类加载器负责加载JAVA_HOME\lib\ext目录中或者被java.ext.dirs系统变量所指定的路径中所有的类库。应用程序类加载器负责加载用户类路径上的所有类库。如果应用程序中没有自定义过自己的类加载器一般情况下这个就是程序中默认的类加载器。双亲委派模型的工作过程是如果一个类加载器收到了类加载的请求它首先不会自己去尝试加载这个类而是把这个请求委派给父类加载器去完成每一个层次的类加载器都是如此因此所有的加载请求最终都应该传送到最顶层的启动类加载器中只有当父加载器反馈自己无法完成这个加载请求时子加载器才会尝试自己去完成加载。双亲委派模型的好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object它存放在rt.jar之中无论哪一个类加载器要加载这个类最终都是委派给处于模型最顶端的启动类加载器进行加载因此Object类在程序的各种类加载器环境中都能够保证是同一个类。反之如果没有使用双亲委派模型都由各个类加载器自行去加载的话如果用户自己也编写了一个名为java.lang.Object的类并放在程序的ClassPath中那系统中就会出现多个不同的Object类Java类型体系中最基础的行为也就无从保证。然而双亲委派模型并非强制约束历史上出现过三次较大规模的“被破坏”情况都是为了解决一些特定的问题如JNDI服务、OSGi模块化热部署、JDK 9模块化系统等。理解这些“破坏”案例反而能更深刻地理解双亲委派模型的本质和灵活性。6. 执行引擎与运行时优化字节码如何飞起来加载到内存中的类信息最终需要被执行。JVM的执行引擎负责执行字节码。现代JVM的执行引擎不再是简单的解释器而是集解释执行与即时编译于一身的复杂系统。6.1 解释器与即时编译器JIT解释器当虚拟机启动时解释器可以首先发挥作用而不必等待即时编译器全部编译完成后再执行这样可以省去许多不必要的编译时间。解释器逐条读取、解释、执行字节码指令。它的优点是启动速度快占用内存少。缺点是执行效率低因为每次执行都需要解释。即时编译器为了提升执行效率HotSpot VM内置了两个或三个即时编译器。客户端编译器又称C1编译器是一个简单快速的编译器主要关注局部优化适用于执行时间较短或对启动性能有要求的客户端程序。服务端编译器又称C2编译器或Opto编译器是为长期运行的服务器端应用程序设计的性能编译器会进行大量的全局优化耗时更长但能生成更高质量的本地代码。Graal编译器在JDK 10中首次引入是一个用Java编写的前瞻性即时编译器目标是替代C2未来可期。HotSpot VM采用了一种混合模式程序启动初期解释器首先发挥作用省去编译时间立即执行。同时一个后台的“热点代码探测”线程在运行它会统计方法被调用的次数和循环体执行的循环次数。当某个方法或代码块的调用次数达到一定的阈值编译阈值它就会被判定为“热点代码”。此时即时编译器会介入把这段代码编译成本地机器码并进行各种层次的深度优化。下次再执行这段代码时就直接执行编译好的本地机器码速度大大提升。这种在运行时才把字节码编译成本地机器码的技术就是即时编译。6.2 热点代码探测与编译优化技术热点探测判定方式主要有两种基于采样的热点探测虚拟机周期性地检查各个线程的调用栈顶如果发现某个或某些方法经常出现在栈顶那这个方法就是“热点方法”。基于计数器的热点探测虚拟机为每个方法甚至是代码块建立计数器统计方法的执行次数如果执行次数超过一定的阈值就认为它是“热点方法”。HotSpot VM使用的是基于计数器的热点探测方法。它为每个方法准备了两类计数器方法调用计数器和回边计数器用于统计循环体执行次数。一旦方法被判定为热点即时编译器就会对其进行优化。这些优化技术非常复杂和精妙例如方法内联将目标方法的代码“复制”到发起调用的方法之中避免发生真实的方法调用。这是最重要的优化手段之一除了消除方法调用的成本更重要的是为其他优化手段建立良好的基础。逃逸分析分析对象动态作用域当一个对象在方法中被定义后它可能被外部方法所引用例如作为调用参数传递到其他方法中这称为方法逃逸。甚至可能被外部线程访问到这称为线程逃逸。如果能证明一个对象不会逃逸到方法或线程之外就可以对这个变量进行一些高效的优化栈上分配如果确定一个对象不会逃逸出方法之外那就可以在栈上分配内存对象所占用的内存空间就可以随栈帧出栈而销毁从而减轻垃圾收集系统的压力。标量替换如果把一个Java对象拆散根据程序访问的情况将其用到的成员变量恢复为原始类型来访问这个过程就称为标量替换。如果逃逸分析证明一个对象不会被外部访问并且这个对象可以被拆散那么程序真正执行的时候将可能不去创建这个对象而改为直接创建它的若干个被这个方法使用的成员变量来代替。同步消除如果逃逸分析能够确定一个变量不会逃逸出线程那么这个变量的读写肯定就不会有竞争对这个变量实施的同步措施也就可以安全地消除掉。7. 实战JVM调优核心参数与问题排查思路理论最终要服务于实践。理解了JVM的构成和原理我们最终的目的是为了能更好地使用它、优化它。JVM调优没有银弹核心思路是监控分析 - 定位瓶颈 - 调整参数 - 验证效果。7.1 核心JVM参数解析与设置建议JVM提供了大量的参数供我们调整但切忌盲目调整。以下是一些最核心、最常用的参数堆内存相关-Xms初始堆大小。通常设置成和-Xmx相同以避免堆内存动态扩容时带来的性能损耗。-Xmx最大堆大小。这是最重要的参数之一决定了你的应用最多能使用多少内存。设置太小容易OOM设置太大会导致GC停顿时间变长。通常建议设置为系统可用内存的70%-80%。-Xmn新生代大小。Sun官方推荐配置为整个堆的3/8。增大新生代会减小老年代大小影响Full GC频率需要权衡。-XX:NewRatio老年代与新生代的比例。例如-XX:NewRatio2表示老年代:新生代2:1。-XX:SurvivorRatioEden区与一个Survivor区的比例。例如-XX:SurvivorRatio8表示Eden:S0:S18:1:1。垃圾收集器相关-XX:UseSerialGC使用Serial Serial Old收集器组合。-XX:UseParNewGC使用ParNew Serial Old收集器组合已不推荐。-XX:UseParallelGC/-XX:UseParallelOldGC使用Parallel Scavenge Parallel Old收集器组合JDK 8默认。-XX:UseConcMarkSweepGC使用ParNew CMS Serial Old收集器组合。CMS收集器失败时的后备方案是Serial Old。-XX:UseG1GC使用G1收集器JDK 9默认。-XX:MaxGCPauseMillis设置最大GC停顿时间目标毫秒。这是一个软目标JVM会尽力实现但不保证。-XX:GCTimeRatio设置吞吐量目标GC时间与应用时间占比。公式为1 / (1 GCTimeRatio)默认99即允许1%的GC时间。GC日志相关日志是排查问题的第一手资料必须开启。-XX:PrintGCDetails打印详细的GC日志。-XX:PrintGCDateStamps/-XX:PrintGCTimeStamps在GC日志上打印日期或时间戳。-Xloggc:file将GC日志输出到文件。-XX:UseGCLogFileRotation/-XX:NumberOfGCLogFiles/-XX:GCLogFileSizeGC日志文件滚动配置。其他重要参数-XX:MetaspaceSize/-XX:MaxMetaspaceSize设置元空间初始大小和最大大小。对于动态加载类较多的应用如使用Spring、反射、动态代理等需要适当调大避免元空间OOM。-XX:HeapDumpOnOutOfMemoryError/-XX:HeapDumpPathpath在发生OOM时自动生成堆转储文件这是分析内存泄漏的利器。-XX:PrintCommandLineFlags打印JVM启动时生效的参数方便确认。7.2 典型问题排查与性能优化案例案例一频繁Full GC导致服务卡顿现象应用监控显示服务响应时间周期性变长同时GC监控显示Full GC频率很高如每分钟数次。排查思路分析GC日志查看每次Full GC前后老年代和新生代的使用情况。重点关注老年代的使用率是否在每次Full GC后都能有效下降。如果下降不明显可能存在内存泄漏。检查对象晋升使用jstat -gcutil pid观察各代容量和使用率特别是观察YGCYoung GC次数和YGCTYoung GC时间以及FGCFull GC次数和FGCTFull GC时间。如果YGC很频繁但每次回收掉的对象很少可能导致大量短期存活对象过早进入老年代“过早晋升”。生成堆转储文件在Full GC频繁的时间点使用jmap -dump:formatb,fileheap.hprof pid手动生成堆转储或用-XX:HeapDumpOnOutOfMemoryError参数在OOM时自动生成。然后用MAT、JProfiler等工具分析。可能原因与调优内存泄漏分析堆转储找到占用内存最大的对象和引用链。常见于未关闭的连接、大集合缓存未清理、监听器未注销等。新生代过小如果新生代设置太小会导致Minor GC非常频繁且每次回收后存活对象很容易超过Survivor区容量直接进入老年代加速老年代填满。可以尝试增大-Xmn新生代大小。Survivor区过小或晋升阈值过低调整-XX:SurvivorRatio增大Survivor区或适当调大-XX:MaxTenuringThreshold如从15调到20让对象在新生代多“待”几次。大对象过多检查是否有大量大对象如大数组、大字符串直接分配在老年代占用大量连续空间。考虑优化业务逻辑或调整-XX:PretenureSizeThreshold但需谨慎。案例二元空间Metaspace持续增长导致OOM现象应用运行一段时间后出现java.lang.OutOfMemoryError: Metaspace错误。排查思路使用jstat -gc pid观察MMetaspace列的使用情况看是否持续增长且不下降。使用-XX:TraceClassLoading和-XX:TraceClassUnloading参数或使用Java Flight Recorder查看类加载和卸载情况。可能原因与调优动态类生成过多大量使用CGLib、ASM、动态代理如Spring AOP等框架会生成大量动态类。这些类默认不会被卸载除非其对应的类加载器被回收。检查是否有类加载器泄漏如每次请求都创建新的类加载器。反射调用频繁频繁调用Method.invoke()也可能导致生成一些临时类。调优首先适当增大-XX:MaxMetaspaceSize如512m。其次对于已知会大量生成动态类的框架可以考虑使用缓存或者评估是否过度使用。对于使用Spring的应用可以检查是否开启了spring.aspectj.autoproxy且scope配置不当。案例三应用启动慢或运行时偶发卡顿现象应用启动时间很长或者运行过程中偶尔出现不明原因的短暂停顿几百毫秒到几秒。排查思路检查GC日志确认停顿是否由Full GC引起。如果是按案例一排查。检查JIT编译如果停顿非GC引起可能是JIT编译器在后台进行激进优化如C2编译占用了大量CPU资源。可以添加JVM参数-XX:PrintCompilation来输出编译日志观察卡顿时是否有大型方法正在被编译。检查安全点所有GC和某些JVM操作如偏向锁撤销、代码反优化都需要在“安全点”进行即所有线程都到达一个安全的状态点。如果有个别线程比如正在执行一个很长的循环迟迟无法进入安全点就会导致其他线程等待表现为停顿。可以尝试在循环中加入Thread.yield()或使用可中断的循环。调优建议对于启动慢可以考虑使用应用类数据共享。JDK 8u40以后可以使用-XX:UseAppCDS和-XX:DumpLoadedClassList等参数将启动时加载的类列表保存下来下次启动时直接共享可以显著提升启动速度。对于C2编译导致的停顿可以尝试调整编译策略例如使用-XX:TieredStopAtLevel1限制编译层级只使用C1牺牲一些峰值性能来换取更平滑的响应。或者使用G1收集器的-XX:UseStringDeduplication字符串去重等功能来减少内存占用和GC压力。JVM调优是一门实践性极强的艺术没有放之四海而皆准的参数模板。最好的方法是建立完善的监控如Prometheus Grafana监控JVM指标保留完整的GC日志在压测或灰度环境中进行参数调整和对比测试用数据说话。理解本文所讲的原理是为了让你在看到监控图表和GC日志时能知道每一个波动背后的故事从而做出正确的判断和决策。