JVM内存模型、垃圾回收与类加载机制全解析
1. 项目概述:为什么我们需要一张JVM的“地图”?
干了这么多年Java开发,我越来越觉得,JVM(Java虚拟机)就像是你每天开车上下班必经的那条路。你可能开了好几年,知道哪个路口容易堵,哪个红绿灯时间最长,但真要你画出这条路的地图,标出每一个车道、每一个交通标志,甚至地下管线的走向,大部分人可能就懵了。我们天天用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 8,HotSpot彻底移除了永久代,用元空间(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的老年代版本,支持多线程并发收集。
- CMS(Concurrent Mark Sweep)收集器:这是一种以获取最短回收停顿时间为目标的收集器。它非常符合互联网站或者B/S系统的服务端需求,这类应用尤其重视服务的响应速度。其运作过程分为四个步骤:
- 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
- 并发标记:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但可以与用户线程并发执行。
- 重新标记:为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。这个阶段的停顿时间通常会比初始标记阶段稍长一些,但远比并发标记的时间短,也需要“Stop The World”。
- 并发清除:清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。 CMS的优点是并发收集、低停顿。但缺点也很明显:对处理器资源非常敏感;无法处理“浮动垃圾”;基于“标记-清除”算法,会产生大量空间碎片。
- G1(Garbage 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:NewRatio=2表示老年代:新生代=2:1。-XX:SurvivorRatio:Eden区与一个Survivor区的比例。例如-XX:SurvivorRatio=8表示Eden:S0:S1=8: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:GCLogFileSize:GC日志文件滚动配置。
其他重要参数:
-XX:MetaspaceSize/-XX:MaxMetaspaceSize:设置元空间初始大小和最大大小。对于动态加载类较多的应用(如使用Spring、反射、动态代理等),需要适当调大,避免元空间OOM。-XX:+HeapDumpOnOutOfMemoryError/-XX:HeapDumpPath=<path>:在发生OOM时自动生成堆转储文件,这是分析内存泄漏的利器。-XX:+PrintCommandLineFlags:打印JVM启动时生效的参数,方便确认。
7.2 典型问题排查与性能优化案例
案例一:频繁Full GC导致服务卡顿
- 现象:应用监控显示,服务响应时间周期性变长,同时GC监控显示Full GC频率很高(如每分钟数次)。
- 排查思路:
- 分析GC日志:查看每次Full GC前后,老年代和新生代的使用情况。重点关注老年代的使用率是否在每次Full GC后都能有效下降。如果下降不明显,可能存在内存泄漏。
- 检查对象晋升:使用
jstat -gcutil <pid>观察各代容量和使用率,特别是观察YGC(Young GC次数)和YGCT(Young GC时间),以及FGC(Full GC次数)和FGCT(Full GC时间)。如果YGC很频繁但每次回收掉的对象很少,可能导致大量短期存活对象过早进入老年代(“过早晋升”)。 - 生成堆转储文件:在Full GC频繁的时间点,使用
jmap -dump:format=b,file=heap.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>观察M(Metaspace)列的使用情况,看是否持续增长且不下降。 - 使用
-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:TieredStopAtLevel=1限制编译层级(只使用C1),牺牲一些峰值性能来换取更平滑的响应。或者使用G1收集器的-XX:+UseStringDeduplication(字符串去重)等功能来减少内存占用和GC压力。
- 对于启动慢,可以考虑使用应用类数据共享。JDK 8u40以后,可以使用
JVM调优是一门实践性极强的艺术,没有放之四海而皆准的参数模板。最好的方法是:建立完善的监控(如Prometheus + Grafana监控JVM指标),保留完整的GC日志,在压测或灰度环境中进行参数调整和对比测试,用数据说话。理解本文所讲的原理,是为了让你在看到监控图表和GC日志时,能知道每一个波动背后的故事,从而做出正确的判断和决策。
