查看GC日志,确认Full GC频率和耗时
从“橡树”到“云原生”:Java虚拟机发展史诗与核心知识点详解(超详细·最终补全版)
引言
1991年,在美国Sun Microsystems公司的一间实验室里,詹姆斯·高斯林(James Gosling)和他的团队正在为一个名为“Green”的项目埋头苦干。他们当时的目标是开发一种能够在各种消费电子产品(如机顶盒、冰箱、PDA)上运行的编程语言。谁也不会想到,这个最初名为“Oak”(橡树)的语言,将在未来的三十年里彻底改变企业级软件开发的格局。
而这一切奇迹的幕后功臣,正是Java虚拟机(Java Virtual Machine,简称JVM)。它不仅承载着Java“一次编写,到处运行”的跨平台梦想,更是一个历经四分之一个世纪持续演进的软件工程奇迹。从最初的纯解释器模式,到如今支持低延迟垃圾回收、即时编译、云原生适配的现代化运行时,JVM的发展史就是一部浓缩的编程语言虚拟机进化史。
为什么要深入理解JVM? 面试中,JVM是必考的高频知识点;研发中,线程死锁、内存溢出、性能优化等重难点问题都绕不开JVM;更重要的是,作为Java开发者,JVM是绕不过去的那座大山,早晚得攀。今天,我们翻开这部跨越三十年的JVM进化史诗,探寻它从默默无闻到称霸武林的传奇历程。
第一章:前JVM时代——Java的诞生与第一款虚拟机的尝试(1991-1998)
1.1 Green项目与Oak语言的梦想
故事始于1991年4月,由詹姆斯·高斯林领导的“Green计划”启动。当时,嵌入式消费电子设备的市场蓬勃发展,但不同厂商的设备硬件和操作系统千差万别,开发者需要为每一种平台编写不同的代码,这无疑是一场噩梦。
高斯林团队的愿景是创造一种与平台无关的语言:开发者只需编写一次代码,就能在各种设备上运行。他们设计的语言最初被称为“Oak”(橡树),灵感来自高斯林办公室窗外的一棵橡树。然而,由于“Oak”这个名字已经被另一家公司注册,1995年,团队决定将其更名为“Java”——这个名字据说灵感来源于印度尼西亚的爪哇岛(Java),那里盛产咖啡,而程序员们也正是喝着这种咖啡日夜奋战。
1995年5月23日,Sun公司正式发布Java,并喊出了那句响彻计算机历史的口号:“Write Once, Run Anywhere”(一次编写,到处运行) 。支撑这句豪言壮语的基石,就是JVM。
1.2 JDK 1.0与Sun Classic VM的亮相
1996年1月23日,JDK 1.0正式发布,Java语言首次拥有了商用的正式运行环境。这个JDK中所带的虚拟机就是世界上第一款商用Java虚拟机——Sun Classic VM。
以今天的视角来看,Classic VM的技术相当原始:
- 纯解释器模式:它只能使用解释器来一行行地执行Java字节码,效率非常低下。
- 无法与JIT协作:虽然可以外挂即时编译器(JIT)来提升性能,但一旦挂上JIT,编译器就完全接管了虚拟机的执行系统,解释器便不再工作。由于解释器和编译器不能配合工作,编译器不得不对每一个方法、每一行代码都进行编译,根本不敢应用编译耗时稍高的优化技术,导致执行效率与传统C/C++程序有很大差距。
“Java语言很慢”的形象就是在这时候开始在用户心中树立起来的。尽管存在诸多缺陷,Classic VM作为开山鼻祖,为Java的跨平台梦想迈出了坚实的第一步。
什么是解释器,什么是JIT?
- 解释器:用来解释执行Java编译器编译后的字节码文件,把字节码转化为特定平台所能看懂的机器码并运行。编译后的字节码文件一行一行解释运行,每解释一行代码就运行一次,程序运行速度比较缓慢。
- JIT(Just-in-time compilation):又叫即时编译,是一种在Java程序运行时将字节码编译成平台所能看懂的原生机器码技术,并且会将翻译过的机器码缓存起来以便下次执行时直接使用,提高程序的性能。
1.3 Exact VM的短暂过渡
为了解决Classic VM的效率问题,Sun在JDK 1.2时期(1998年)于Solaris平台上推出了一款名为Exact VM的虚拟机。
Exact VM引入了“准确式内存管理”(Exact Memory Management,也叫Non-Conservative/Accurate Memory Management),这意味着虚拟机能够明确知道内存中某个位置的数据具体是什么类型。例如,内存中有一个32bit的整数123456,它到底是一个reference类型指向123456的内存地址,还是一个数值为123456的整数,虚拟机将有能力分辨出来。这样在GC时就可以准确判断堆上的数据是否还可能被使用,大大提高了垃圾回收的效率。
Exact VM的执行系统已经具备现代高性能虚拟机的雏形:如两级即时编译器、编译器与解释器混合工作模式等。然而,Exact VM的命运如同流星般短暂,只在Solaris平台短暂使用,甚至还没有来得及发布Windows和Linux平台下的商用版本,就被后来更为优秀的HotSpot VM所取代。
有趣的是,据说在Sun公司内部还进行了激烈的讨论,到底是选择HotSpot VM还是Exact VM,最终HotSpot打败Exact并不能算技术上的胜利。
第二章:HotSpot的崛起——统治地位的确立(1999-2006)
2.1 热点代码探测技术的诞生
就在Sun努力改进自家虚拟机的同时,一家名为“Longview Technologies”的小公司设计出了一款划时代的产品——HotSpot VM。有意思的是,这款虚拟机最初并非是为Java语言而开发的,它来源于Strongtalk语言,而虚拟机中相当多的技术又是来源于一款支持Self语言实现“达到C语言50%以上的执行效率”的目标而设计的虚拟机。
Sun公司注意到了这款虚拟机在JIT编译技术上有许多优秀的理念和实际效果,于是在1997年收购了Longview Technologies公司,从而获得了HotSpot VM。
HotSpot VM的核心优势在于其热点代码探测技术(Hot Spot Detection)。它会通过计数器找出整个应用程序中最具编译价值的代码(即热点代码),然后通知JIT编译器将这些代码编译成本地机器码。同时,对于非热点代码,则继续使用解释器执行。
这种解释器与编译器协同工作的模式,在最优化的程序响应时间与最佳执行性能中取得了平衡,而且无需等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于引入更多的代码优化技术,输出质量更高的本地代码。
2.2 HotSpot VM成为Sun JDK默认虚拟机
1999年4月,HotSpot VM诞生。在JDK 1.3(2000年)时,HotSpot VM正式成为Sun JDK的默认虚拟机。有趣的是,第一代商用虚拟机Classic VM在JDK 1.0、1.1、1.2时仍是首选默认,在JDK 1.3时成为了HotSpot VM的备份(使用java -classic参数切换),直到JDK 1.4时完全退出商用虚拟机的历史舞台。
从此,HotSpot VM开启了它称霸武林的时代。不管是现在仍在广泛使用的JDK 6,还是使用比例较多的JDK 8中,默认的虚拟机都是HotSpot。Sun/Oracle JDK和OpenJDK的默认虚拟机都是它。
2.3 群雄并起:BEA JRockit与IBM J9
除了Sun公司以外,其他组织、公司也研发出不少虚拟机实现,其中规模最大、最著名的就是BEA和IBM公司。
BEA JRockit:曾号称“世界上速度最快的Java虚拟机”。它是BEA公司在2002年从Appeal Virtual Machines公司收购获得的虚拟机。JRockit专注于服务器端应用,是一款专门为服务器硬件和服务端应用场景高度优化的虚拟机。它不太关注程序启动速度,因此内部不包含解释器实现,全部代码都靠即时编译器编译后执行。大量的行业基准测试显示,JRockit JVM是世界上最快的JVM。它还提供了著名的诊断工具套件MissionControl。
IBM J9:IBM开发的高度模块化的JVM,内部代号J9。它的市场定位与HotSpot接近,是一款从服务器端到桌面应用再到嵌入式等场景都涉及的虚拟机。它与HotSpot和JRockit并称为“三大商用Java虚拟机”。2017年左右,IBM将J9开源并贡献给Eclipse基金会,命名为OpenJ9。
2.4 其他值得关注的虚拟机
KVM/CDC/CLDC HotSpot:Sun面对移动和嵌入式市场发布的虚拟机产品。K中的K是“Kilobyte”的意思,它强调简单、轻量、高度可移植,在Android、iOS等智能手机操作系统出现前曾经在手机平台上得到非常广泛应用。
Microsoft JVM:微软为了在IE浏览器中支持Java Applets开发的JVM,只能在Windows平台运行,但却是当时Windows下性能最好的Java VM。1997年,Sun以侵犯商标、不正当竞争罪名起诉微软成功,微软在Windows XP SP3中抹掉了其VM。
Apache Harmony:Apache推出的与JDK 1.5和JDK 1.6兼容的Java运行平台,是IBM和Intel联合开发的开源JVM。但由于受到同样开源的OpenJDK的压制,最终于2011年退役。虽然它没有被大规模商用的案例,但它的Java类库代码被吸纳进了Android SDK。
第三章:Oracle时代——整合与开源(2006-2014)
3.1 Java开源与OpenJDK的诞生
2006年的JavaOne大会上,Sun宣布将Java开源,并在随后的一年陆续地将JDK的各个部分(包括HotSpot VM)在GPL协议下公开了源码,并在此基础上建立了OpenJDK。这样,HotSpot VM便成为了Sun JDK和OpenJDK两个实现极度接近的JDK项目的共同虚拟机。这一举措极大地推动了Java社区的繁荣。
3.2 Oracle收购Sun,整合JRockit特性
2009年4月20日,科技界发生了一笔震惊世人的收购:Oracle公司以74亿美金收购Sun公司。Oracle不仅得到了Java语言和HotSpot VM,还在此前(2008年)收购BEA公司时获得了JRockit VM。
手握两大顶尖虚拟机,Oracle宣布了一个雄心勃勃的计划:在HotSpot的基础上,移植JRockit的优秀特性,将二者合二为一。整合的方式大致上是在HotSpot的基础上,使用JRockit的垃圾回收器与MissionControl服务,使用HotSpot的JIT编译器与混合的运行时系统。这个整合工作在JDK 8中基本完成,使得HotSpot VM拥有了JRockit的强大诊断能力和卓越性能。
3.3 JDK 8的里程碑:元空间取代永久代
2014年3月18日,JDK 8正式发布,这被公认为Java发展史上最重要的版本之一。在JVM层面,它做出了一个重大改变:用元空间(Metaspace)取代了永久代(PermGen)。
方法区、永久代、元空间的关系:
- 方法区是JVM规范中定义的一块内存区域,用来存储类元数据、方法字节码、即时编译器需要的信息等。
- 永久代是HotSpot虚拟机对JVM规范的实现(1.8之前)。
- 元空间是HotSpot虚拟机对JVM规范的另一种实现(1.8以后),使用本地内存作为这些信息的存储空间。
永久代是一个固定大小的内存区域,用于存储类元数据,常常导致令人头疼的java.lang.OutOfMemoryError: PermGen space错误。元空间则移至本地内存,自动增长,大大降低了这类内存溢出问题的发生概率,且垃圾回收时机更灵活。
此外,JDK 8还引入了Lambda表达式、Stream API等语言特性,极大地提升了开发效率,这也使得JDK 8至今仍是许多企业的首选。
第四章:现代化革新——低延迟GC与云原生适配(2014-2026)
4.1 发布节奏变革与LTS版本
从JDK 9(2017年)开始,Oracle改变了Java的发布节奏,确定为每六个月发布一个大版本,并引入长期支持版本(LTS,每三年一个)。这一变化使得JVM的新特性能够更快地交付到开发者手中。
4.2 垃圾回收器的革命:G1、ZGC、Shenandoah
随着内存越来越大,对GC暂停时间的要求也越来越苛刻。现代JVM的垃圾回收器迎来了一场革命:
| 垃圾回收器 | 引入版本 | 核心特点 | 目标场景 |
|---|---|---|---|
| G1(Garbage-First) | JDK 7引入,JDK 9成为默认 | 将堆划分为多个Region,可预测停顿时间 | 面向服务端,平衡吞吐量与停顿时间 |
| ZGC | JDK 11引入,JDK 15生产可用 | 基于染色指针和读屏障,暂停时间<10ms,与堆大小无关 | 超低延迟,大内存(TB级) |
| Shenandoah | JDK 12引入,JDK 15生产可用 | 通过并发 evacuation 实现低延迟 | 与ZGC类似,由RedHat主导开发 |
这些新GC的出现,让Java在面对海量数据和高实时性要求的场景时更加游刃有余。
4.3 JVM服务能力与工具的演进
JVM不再仅仅是一个执行引擎,它内置了越来越强大的诊断和监控工具:
-
JFR(Java Flight Recorder)与JMC(Java Mission Control):原本是JRockit的商业特性,整合进HotSpot并在JDK 11中开源。JFR是一个高性能、低开销的事件记录器,可以持续收集JVM和应用运行时的详细数据,是性能分析和故障诊断的利器。
-
模块化系统(JDK 9):Jigsaw项目为JDK本身带来了模块化,使得开发者可以只打包运行应用所需的核心模块,从而缩小运行时镜像体积,这对于云原生和容器化部署意义重大。
第五章:JVM家族的“另类”成员与本土力量
5.1 Dalvik VM与ART:Android的抉择
Google为Android平台开发的虚拟机。它并非遵循Java虚拟机规范,不直接执行Class文件,而是执行经过转换后的DEX(Dalvik Executable)文件,并采用基于寄存器的架构(不是JVM的栈架构)。从Android 5.0开始,ART(Android Runtime)取代了Dalvik,引入了预编译(AOT)和混合编译模式,进一步提升了Android应用的运行效率。
5.2 GraalVM:多语言全栈虚拟机的野心
2018年4月,Oracle Labs发布了极具野心的GraalVM,号称“Run Programs Faster Anywhere”,与1995年Java的“write once,run anywhere”遥相呼应。它不仅仅是一个JVM,更是一个“高性能的多语言全栈虚拟机”,支持Java、Scala、Groovy、Kotlin、C、C++、JavaScript、Ruby、Python、R等多种语言。
GraalVM的工作原理是将这些语言的源代码或编译后的中间格式,通过解释器转换为能被Graal VM接受的中间表示,在运行时还能进行即时编译优化,获得比原生编译器更优秀的执行效率。它还提供将Java应用静态编译成原生可执行文件的能力,以解决传统JVM启动慢、内存占用高的痛点,在云原生和Serverless时代展现出巨大的潜力。
如果说HotSpot有一天真的被取代,GraalVM希望最大。
5.3 Taobao JVM(AJDK):阿里巴巴的深度定制
阿里巴巴基于OpenJDK深度定制的高性能服务器版Java虚拟机,是国内Java技术实力的杰出代表。它开创性地提出了GCIH(GC invisible heap) 技术,将生命周期较长的Java对象从heap中移到heap之外,并且GC不能管理GCIH内部的Java对象,以此达到降低GC的回收频率和提升GC的回收效率的目的。GCIH中的对象还能够在多个Java虚拟机进程中实现共享。此外,它还使用crc32指令实现JVM intrinsic降低JNI调用开销,针对大数据场景提供ZenGc等特性。
目前AJDK已经在淘宝、天猫上线,把Oracle官方JVM版本全部替换了。
5.4 其他高性能虚拟机
Azul VM:Azul Systems公司在HotSpot基础上进行大量改进,运行于Azul Systems公司的专有硬件Vega系统上的Java虚拟机,可以管理至少数十个CPU和数百GB内存的硬件资源。2010年,Azul Systems公司开始从硬件转向软件,发布了自己的Zing JVM,可以在通用x86平台上提供接近于Vega系统的特性。
Liquid VM:BEA公司开发的,直接运行在自家Hypervisor系统上的虚拟机,不需要操作系统的支持。随着JRockit虚拟机终止开发,Liquid VM项目也停止了。
第六章:JVM核心知识体系全景图
6.1 JVM架构总览
JVM主要由以下几个子系统组成:
Java源代码 → 编译器 → Class文件 → 类加载子系统 → 运行时数据区 → 执行引擎 → 操作系统
↑ ↓
本地方法接口 ← 本地方法库
- 类加载子系统:负责加载Class文件,进行链接、初始化等工作。
- 运行时数据区:JVM运行时划分的内存区域。
- 执行引擎:包含解释器、JIT编译器、垃圾回收器。
- 本地方法接口(JNI):与本地方法库交互,调用C/C++编写的本地方法。
6.2 类加载机制详解
6.2.1 类加载的时机
类加载主要有四个时机:
- 遇到
new、getstatic、putstatic、invokestatic四条指令时。 - 使用
java.lang.reflect包方法进行反射调用时。 - 初始化一个类时发现其父类还没初始化。
- 虚拟机启动时,初始化包含
main()方法的主类。
6.2.2 类加载的过程
类加载主要做三件事:
- 通过类的全限定名称获取二进制字节流(加载阶段)。
- 将字节流的静态存储结构转化为方法区的运行时数据结构(链接阶段)。
- 在堆中生成代表这个类的
java.lang.Class对象,作为方法区这个类的各种数据的访问入口(初始化阶段)。
可以从哪些途径加载字节码?Jar包、War包、JSP生成的class、数据库中读取、网络中传输、动态代理生成的字节码等。
6.2.3 类加载器层次结构
JVM的类加载是通过ClassLoader及其子类来完成的:
| 类加载器 | 加载路径 | 实现语言 | 说明 |
|---|---|---|---|
| Bootstrap ClassLoader(启动类加载器) | JAVA_HOME/lib目录,或被-Xbootclasspath指定的路径 | C++ | 不是ClassLoader的子类,是JVM的一部分 |
| Extension ClassLoader(扩展类加载器) | JAVA_HOME/lib/ext目录,或被java.ext.dirs指定的路径 | Java | 负责加载Java的扩展类库 |
| Application ClassLoader(应用程序类加载器) | 用户路径classpath上的类库 | Java | 负责加载用户类路径上的类 |
| Custom ClassLoader(自定义类加载器) | 开发者指定的任意路径 | Java | 继承ClassLoader,重写findClass()方法 |
6.2.4 双亲委派模型
什么是双亲委派?
当一个类加载器收到类加载任务,会先交给其父类加载器去完成。因此,最终加载任务都会传递到顶层的启动类加载器,只有当父类加载器无法完成加载任务时,子类才会尝试执行加载任务。
为什么需要双亲委派?
考虑到安全因素,双亲委派可以避免重复加载,当父加载器已经加载了该类的时候,子加载器就没有必要再加载一次。比如加载位于rt.jar包中的java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object对象。
双亲委派机制源码:
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException {
synchronized (getClassLoadingLock(name)) {
// 首先,检查class是否被加载
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
if (parent != null) {
// 如果父类加载不为空,则交给父类加载器加载
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父类加载器没有加载到
}
if (c == null) {
// 父类加载器没有加载到,则由子类进行加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
检查顺序是自底向上:从Custom ClassLoader到BootStrap ClassLoader逐层检查是否已加载。加载的顺序是自顶向下:由上层来逐层尝试加载此类。
6.2.5 如何打破双亲委派?
为什么需要打破双亲委派?
双亲委派解决了Java基础类统一加载的问题,但存在缺陷。JDK中的基础类作为典型的API被用户调用,但也存在API调用用户代码的情况,典型的如SPI(Service Provider Interface) 机制。以数据库驱动DriverManager为例,Driver接口定义在JDK中,其实现由各个数据库的服务商提供,由系统类加载器加载。这时就需要启动类加载器来委托子类加载器加载Driver实现,这就破坏了双亲委派。
如何打破双亲委派?
- 自定义类加载器重写
loadClass方法:不遵循先委派给父类的逻辑。 - 线程上下文类加载器:
Thread.currentThread().setContextClassLoader(),典型的如JNDI、JDBC等SPI服务。 - OSGi、JBoss等模块化框架:实现自己的类加载体系。
6.2.6 自定义类加载器实战
场景:自定义类加载器,加载指定路径在D盘下的lib文件夹下的类。
步骤1:创建需要被加载的类Test.java
package com.hero.jvm.classloader;
public class Test {
public void say() {
System.out.println("Hello HeroClassLoader");
}
}
编译后,将生成的Test.class文件放到D:/lib/com/hero/jvm/classloader文件夹下。
步骤2:自定义类加载器HeroClassLoader
package com.hero.jvm.classloader;
import java.io.*;
public class HeroClassLoader extends ClassLoader {
private String classpath;
public HeroClassLoader(String classpath) {
this.classpath = classpath;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
byte[] classDate = getData(name);
if (classDate != null) {
// defineClass方法将字节数组数据转为字节码对象
return defineClass(name, classDate, 0, classDate.length);
}
} catch (IOException e) {
e.printStackTrace();
}
return super.findClass(name);
}
// 加载类的字节码数据
private byte[] getData(String className) throws IOException {
String path = classpath + File.separatorChar +
className.replace('.', File.separatorChar) + ".class";
try (InputStream in = new FileInputStream(path);
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buffer = new byte[2048];
int len = 0;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
}
return out.toByteArray();
} catch (FileNotFoundException e) {
e.printStackTrace();
return null;
}
}
}
步骤3:测试自定义类加载器
package com.hero.jvm.classloader;
import java.lang.reflect.Method;
public class TestMyClassLoader {
public static void main(String []args) throws Exception {
// 自定义类加载器的加载路径
HeroClassLoader hClassLoader = new HeroClassLoader("D:\\lib");
// 包名+类名
Class c = hClassLoader.loadClass("com.hero.jvm.classloader.Test");
if (c != null) {
Object obj = c.newInstance();
Method method = c.getMethod("say", null);
method.invoke(obj, null);
System.out.println(c.getClassLoader().toString());
}
}
}
输出结果:
Hello HeroClassLoader
com.hero.jvm.classloader.HeroClassLoader@xxxxxxx
6.3 运行时数据区详解
6.3.1 内存区域划分
JVM运行时数据区包括以下部分:
| 区域 | 线程共享 | 作用 | 异常 |
|---|---|---|---|
| 程序计数器 | 私有 | 记录当前线程执行的字节码行号,当执行本地方法时为null | 无 |
| Java虚拟机栈 | 私有 | 每个方法调用时都会创建一个栈帧(局部变量表、操作数栈、动态链接、方法出口) | StackOverflowError, OutOfMemoryError |
| 本地方法栈 | 私有 | 为Native方法服务 | 同上 |
| 堆 | 共享 | 存放对象实例和数组,是JVM中最大的内存区域 | OutOfMemoryError |
| 方法区 | 共享 | 存储类信息、常量、静态变量、JIT编译后的代码 | OutOfMemoryError |
| 运行时常量池 | 共享 | 方法区的一部分,存放编译期生成的字面量和符号引用 | OutOfMemoryError |
| 直接内存 | 非JVM管理 | NIO使用的堆外内存,由操作系统管理 | OutOfMemoryError |
6.3.2 堆和栈的区别
| 维度 | 栈 | 堆 |
|---|---|---|
| 用途 | 存储方法返回地址、方法参数、临时变量 | 存储对象的实例信息、类实例信息、数组信息 |
| 生命周期 | 每次方法执行完栈帧就会弹出,生命周期确定 | 由垃圾回收器回收,回收时间不确定 |
| 存储速度 | 速度快,遵循"先进后出"原则 | 速度慢,需要内存分配和垃圾回收 |
| 存储空间 | 空间相对较小且固定,由操作系统管理 | 空间是JVM中最大的,由JVM管理 |
| 可见性 | 每个线程私有 | 所有线程共享 |
存储细节:如果执行方法时创建了基本类型,基本类型的数据会存入栈中;如果创建了引用类型,会将地址存入栈,其实例数据存入堆中。
6.3.3 堆内存的细分
堆主要分为两部分:新生代和老年代,比例为1:2。
新生代:分为伊甸园区(Eden)和幸存者区(Survivor),幸存者区又平均分为S0和S1区。伊甸园区与S0与S1之间的比例:8:1:1。
对象分配过程:
- 新创建的对象实例先存入伊甸园区。
- 当伊甸园区内存不足时,触发Minor GC,使用可达性分析算法标记存活对象。
- 将存活对象复制移入S0或S1中(这个过程叫Minor GC)。
- 如果这次移入的是S0,那么下次就会将伊甸园区和S0中的存活对象移入S1中,循环反复。
- 每经历一次Minor GC,对象年龄加一,直到大于等于15时,晋升到老年代。
TLAB(Thread Local Allocation Buffer):在多线程情况下,如果同时进行对象分配,线程竞争压力大会导致性能损失。因此会给每个线程从伊甸园区中先申请一块TLAB区域,先将对象存入该区。如果该区内存使用完,会重新申请或直接存入伊甸园区。
大对象区:存储新创建的大对象(如大数组),会直接将该对象存入老年代的大对象区,不在存入新生代。因为伊甸园区的内存空间本身不大,频繁复制移动大对象很影响性能。
6.3.4 方法区的内容演进
方法区主要包含:类信息、静态变量信息、运行时常量池、即时编译器的缓存数据。
字符串池的变化:
- JDK 1.6及以前:字符串池属于永久代。
- JDK 1.7:字符串池移入堆中,但还是属于永久代。
- JDK 1.8及以后:字符串池存入堆中,不属于元空间了。
6.3.5 会发生内存溢出的区域
- 不会出现内存溢出的区域:程序计数器。
- 出现OutOfMemoryError的情况:
- 堆内存耗尽:对象越来越多,又一直在使用,不能被垃圾回收。
- 方法区内存耗尽:加载的类越来越多,很多框架会在运行期间动态生成新的类。
- 虚拟机栈累积:每个线程最多占用1M内存,线程数越来越多而又长时间运行不销毁时。
- 出现StackOverflowError的区域:JVM虚拟机栈,原因有方法递归调用未正确结束、反序列化json时循环引用。
6.4 垃圾回收机制
6.4.1 判断对象是否可回收
可达性分析算法:从一组称为GC Roots的根对象出发,遍历对象图,无法到达的对象判定为可回收。
GC Roots包括:
- 虚拟机栈(栈帧中的局部变量表)引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- Java虚拟机内部的引用(如基本数据类型对应的Class对象、常驻的异常对象等)
- 所有被同步锁(synchronized)持有的对象
6.4.2 引用类型详解
Java提供了四种引用类型,等级依次递减:
| 引用类型 | 回收时机 | 使用场景 | 代码示例 |
|---|---|---|---|
| 强引用 | 永远不会被回收 | 普通的new对象 | Object obj = new Object(); |
| 软引用 | 内存溢出前,下次GC时回收 | 缓存实现,内存敏感缓存 | SoftReference<Object> softRef = new SoftReference<>(obj); |
| 弱引用 | 每次GC时回收 | WeakHashMap、ThreadLocal | WeakReference<Object> weakRef = new WeakReference<>(obj); |
| 虚引用 | 每次GC时回收,但get()永远返回null | 跟踪对象被回收,用于直接内存管理 | PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue); |
软引用示例:
public class SoftRefDemo {
public static void main(String[] args) throws InterruptedException {
// 1. 创建引用队列
ReferenceQueue<Object> queue = new ReferenceQueue<>();
// 2. 创建大对象
byte[] data = new byte[10 * 1024 * 1024]; // 10MB
// 3. 创建软引用并关联队列
SoftReference<Object> softRef = new SoftReference<>(data, queue);
// 4. 移除强引用
data = null;
System.out.println("GC前: softRef.get() = " + softRef.get());
// 5. 强制GC
System.gc();
Thread.sleep(1000);
System.out.println("GC后: softRef.get() = " + softRef.get());
System.out.println("队列中: queue.poll() = " + queue.poll());
}
}
// 输出:GC前有值,GC后为null,队列中有软引用对象
弱引用实用场景:WeakHashMap:
WeakHashMap<Key, Value> map = new WeakHashMap<>();
Key key = new Key();
map.put(key, new Value());
// 移除强引用
key = null;
System.gc();
// GC后Entry自动被移除
System.out.println(map.size()); // 输出: 0
虚引用与直接内存:直接内存就是从操作系统中申请的空间,GC不能对其进行回收。当强引用消失只剩下虚引用时,会将虚引用对象存入引用队列,等队列来执行本地方法释放直接内存。
6.4.3 三种基础垃圾回收算法
| 算法 | 原理 | 优点 | 缺点 |
|---|---|---|---|
| 标记-清除 | 标记存活对象,清除未标记对象 | 速度快 | 产生内存碎片 |
| 标记-整理 | 标记后,将存活对象向一端移动,再清除边界以外 | 避免内存碎片 | 速度慢,需要移动对象 |
| 标记-复制 | 将内存分为两块,存活对象复制到另一块,整体清除当前块 | 无碎片,分配高效 | 占用成倍空间 |
标记清除法细节:清除不是真正的清除,而是记录对象的起始地址和结束地址到一个地址表中,下次要添加新对象时会先从表中找合适大小的进行覆盖。
6.4.4 分代回收思想
理论依据:大部分对象朝生夕灭,用完立刻就可以回收,另有少部分对象会长时间存活,每次很难回收。
根据这两类对象的特性:
- 新生代:采用标记-复制算法,因为存活对象少,复制成本低。
- 老年代:一般采用标记-整理算法,因为存活对象多,复制成本高。
GC规模:
- Minor GC:发生在新生代的垃圾回收,暂停时间短。
- Mixed GC:新生代 + 老年代部分区域的垃圾回收,G1收集器特有。
- Full GC:新生代 + 老年代完整垃圾回收,暂停时间长,应尽力避免。
6.4.5 三色标记与并发漏标
三色标记:用三种颜色记录对象的标记状态。
- 黑色:已标记,且它的所有引用都处理完毕。
- 灰色:已标记,但它的引用还未处理完。
- 白色:还未标记。
并发漏标问题:并发标记过程中,用户线程修改了对象引用,可能导致本应存活的对象被错误标记为白色。
两种解决方案:
- 增量更新(Incremental Update):CMS垃圾回收器采用。拦截每次赋值动作,被赋值的对象会被记录下来,在重新标记阶段再确认一遍。
- 原始快照(SATB,Snapshot At The Beginning):G1垃圾回收器采用。拦截每次赋值动作,记录变化,在重新标记阶段二次处理。
6.4.6 主流垃圾回收器对比
| 收集器 | 工作区域 | 算法 | 特点 | 适用场景 |
|---|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程,Client模式默认 | 单CPU、桌面应用 |
| ParNew | 新生代 | 复制 | Serial的多线程版本 | 配合CMS使用 |
| Parallel Scavenge | 新生代 | 复制 | 关注吞吐量,Server模式默认 | 后台计算、批处理 |
| Serial Old | 老年代 | 标记-整理 | Serial的老年代版本 | CMS失败后备方案 |
| Parallel Old | 老年代 | 标记-整理 | Parallel Scavenge的老年代搭档 | 吞吐量优先场景 |
| CMS | 老年代 | 标记-清除 | 并发收集,低停顿 | 响应时间优先场景 |
| G1 | 整个堆 | Region化+复制 | 可预测停顿,JDK9+默认 | 面向服务端,大堆内存 |
| ZGC | 整个堆 | 并发标记-整理 | 暂停时间<10ms,与堆大小无关 | 超低延迟,TB级堆 |
| Shenandoah | 整个堆 | 并发 evacuation | 与ZGC类似,RedHat主导 | 超低延迟场景 |
6.5 即时编译器(JIT)
分层编译:HotSpot内置了多个即时编译器:
- C1(客户端编译器):编译速度快,但优化程度较低。
- C2(服务端编译器):编译速度慢,但优化程度高。
JDK 7引入分层编译,在程序启动初期用C1快速编译,热点代码用C2深度优化,兼顾启动速度和峰值性能。
热点代码探测:通过执行计数器找出最具编译价值的代码,然后通知JIT编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中回边(向后跳转)次数很多,将会分别触发标准编译和OSR(栈上替换)编译。
6.6 JVM调优常用参数
6.6.1 堆内存设置
| 参数 | 说明 |
|---|---|
-Xms2g | 初始堆内存大小 |
-Xmx2g | 最大堆内存大小 |
-Xmn512m | 新生代大小 |
-XX:NewRatio=2 | 老年代:新生代 = 2:1 |
-XX:SurvivorRatio=8 | Eden:Survivor = 8:1(实际Eden占8/10) |
-XX:+AlwaysPreTouch | 启动时分配所有内存,防止运行时再向操作系统申请 |
建议:通常将-Xms与-Xmx设置为大小相等,即不需要保留内存,不需要从小到大增长,这样性能较好。
6.6.2 元空间设置
| 参数 | 说明 |
|---|---|
-XX:MetaspaceSize=256m | 元空间初始大小 |
-XX:MaxMetaspaceSize=256m | 元空间最大大小 |
-XX:CompressedClassSpaceSize=1g | 压缩类空间大小 |
6.6.3 垃圾回收器选择
| 参数 | 说明 |
|---|---|
-XX:+UseG1GC | 使用G1垃圾回收器 |
-XX:MaxGCPauseMillis=200 | G1目标最大停顿时间 |
-XX:+UseZGC | 使用ZGC(JDK 15+) |
-XX:+UseConcMarkSweepGC | 使用CMS(JDK 9后被标记废弃) |
-XX:+UseParallelGC | 使用Parallel Scavenge + Parallel Old |
6.6.4 GC日志参数
| 参数 | 说明 |
|---|---|
-XX:+PrintGCDetails | 打印GC详细信息 |
-Xloggc:gc.log | 输出GC日志到文件 |
-XX:+PrintGCDateStamps | 打印GC发生的时间戳 |
-XX:+PrintHeapAtGC | 打印GC前后的堆信息 |
-XX:+PrintTenuringDistribution | 打印对象年龄分布 |
6.6.5 OOM时输出堆转储
| 参数 | 说明 |
|---|---|
-XX:+HeapDumpOnOutOfMemoryError | OOM时自动生成堆转储 |
-XX:HeapDumpPath=/path | 堆转储文件保存路径 |
6.6.6 其他常用参数
| 参数 | 说明 |
|---|---|
-XX:+DisableExplicitGC | 禁止System.gc()显式触发GC |
-XX:+PrintCommandLineFlags | 打印JVM启动参数 |
-XX:+PrintFlagsFinal | 打印所有JVM参数的最终值 |
-XX:+UnlockExperimentalVMOptions | 启用实验性参数 |
-XX:+UseCompressedOops | 启用指针压缩(默认开启) |
第七章:面试题库与实战场景
5道难度递增的基础面试题
第1题:什么是JVM?它有哪些主要组成部分?(难度⭐)
参考答案: JVM是Java Virtual Machine的缩写,它是一个抽象的计算机,负责执行Java字节码。主要组成部分包括:
- 类加载子系统:负责加载Class文件
- 运行时数据区:包括堆、栈、方法区等
- 执行引擎:包含解释器、JIT编译器、垃圾回收器
- 本地方法接口:调用本地方法库
第2题:JVM的运行时数据区包括哪些?哪些是线程私有的?(难度⭐⭐)
参考答案: 包括程序计数器、Java虚拟机栈、本地方法栈(以上线程私有)、堆、方法区(线程共享)、运行时常量池(方法区的一部分)。私有区域随线程生灭,共享区域随JVM生灭。
补充:程序计数器是唯一在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
第3题:解释双亲委派模型及其好处。如何打破双亲委派?(难度⭐⭐⭐)
参考答案: 双亲委派模型:当一个类加载器收到类加载任务,会先交给其父类加载器去完成。最终加载任务传递到顶层的启动类加载器,只有当父类加载器无法完成时,子类才尝试执行。
好处:
- 保证Java核心类库的安全,防止用户自定义的类覆盖核心类。
- 避免类的重复加载,保证同一个类在所有类加载器环境下都是同一个。
打破双亲委派的方式:
- 自定义类加载器重写
loadClass()方法,不遵循委派逻辑。 - 使用线程上下文类加载器(如JDBC SPI实现)。
- OSGi等模块化框架的自定义类加载体系。
第4题:垃圾回收中,如何判断对象已死?请详细说明可达性分析算法和GC Roots。(难度⭐⭐⭐⭐)
参考答案: HotSpot采用可达性分析算法:从一组称为GC Roots的根对象出发,遍历对象图,无法到达的对象判定为可回收。
GC Roots包括:
- 虚拟机栈中引用的对象(局部变量表)
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI引用的对象
- Java虚拟机内部的引用(基本数据类型对应的Class对象等)
- 所有被同步锁持有的对象
引用计数法的缺点:无法解决循环引用问题,所以主流的Java虚拟机都没有使用引用计数法来管理内存。
第5题:CMS垃圾回收器有哪些主要阶段?它有什么优缺点?为什么JDK 9之后不建议使用?(难度⭐⭐⭐⭐)
参考答案: CMS主要阶段:
- 初始标记(STW):标记GC Roots直接关联的对象。
- 并发标记:从GC Roots遍历对象图,与用户线程并发。
- 重新标记(STW):修正并发标记期间因用户线程运行导致变动的标记。
- 并发清除:清除垃圾对象,与用户线程并发。
优点:并发收集,低停顿。 缺点:
- 对CPU资源敏感
- 产生浮动垃圾
- 标记-清除算法导致内存碎片
- 无法处理并发失败(退化到Serial Old)
JDK 9之后不建议使用的原因:CMS被标记为废弃(deprecated),因为G1已经足够成熟,能提供更好的可预测停顿时间,且在大多数场景下性能不输于CMS。
3道实战排查场景题
场景1:频繁发生Full GC,如何排查?
问题:线上Java应用频繁发生Full GC,导致服务响应变慢,甚至超时。请描述排查思路。
考察点:GC日志分析、内存分析工具、常见Full GC原因
参考思路:
第一步:确认问题
# 查看GC日志,确认Full GC频率和耗时
grep "Full GC" gc.log | wc -l
第二步:分析GC日志
- 查看Full GC前后的内存变化:老年代是否被占满?
- 查看Full GC触发原因:
Allocation Failure(分配失败)、Metadata GC Threshold(元空间满)、System.gc()等
第三步:生成堆转储分析
# 如果JVM启动时没有配置-XX:+HeapDumpOnOutOfMemoryError,可以手动触发
jmap -dump:live,format=b,file=heap.bin <pid>
第四步:使用MAT或Visual VM分析堆转储
- 查看大对象:
org.eclipse.mat.api:top_consumers - 查看可疑泄漏:
org.eclipse.mat.api:leak_suspects - 查看GC Roots:确认对象为何未被回收
第五步:常见Full GC原因及对策:
| 原因 | 对策 |
|---|---|
| 老年代空间不足 | 增大堆内存、优化对象晋升 |
| 永久代/元空间满 | 增大MetaspaceSize、检查类加载泄漏 |
System.gc()被调用 | 添加-XX:+DisableExplicitGC |
| CMS并发模式失败 | 调整CMS触发阈值、使用G1 |
场景2:线上CPU飙升,疑似死循环或频繁GC,如何定位?
问题:线上服务器CPU使用率持续100%,服务不可用。请描述定位步骤。
考察点:Linux命令、jstack、jstat
参考思路:
第一步:找到CPU最高的Java进程
top
第二步:找到进程中CPU最高的线程
top -H -p <pid>
第三步:将TID转换为十六进制
printf "%x\n" <tid>
第四步:查看该线程堆栈
jstack <pid> | grep -A 30 <nid>
第五步:分析线程状态
- RUNNABLE且持续执行:可能是业务逻辑死循环。
- IN_NATIVE:可能是本地方法调用卡住。
- 如果是GC线程(名称包含"GC"),说明是频繁GC导致的CPU飙升,需要进一步分析GC情况。
第六步:观察GC情况
jstat -gcutil <pid> 1000 10
# 查看YGC、FGC次数和时间
第七步:如果是GC问题,生成堆转储分析内存泄漏;如果是业务代码问题,分析线程堆栈定位具体代码。
场景3:应用启动很慢,如何优化?
问题:一个Spring Boot应用在容器中启动需要1分钟以上,如何分析并优化启动速度?
考察点:启动过程、类加载、元空间、JIT预热
参考思路:
第一步:分析启动耗时分布
# 打印类加载情况
java -verbose:class -jar app.jar
# 打印GC详情
java -XX:+PrintGCDetails -jar app.jar
第二步:减少类加载数量
- 使用Spring Boot的自动配置排除不必要的模块。
- 使用
spring-context-indexer加速组件扫描。
第三步:调整元空间大小
# 避免频繁的元空间扩容
-XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=128m
第四步:使用AppCDS(应用类数据共享)
# 步骤1:创建类列表
java -XX:DumpLoadedClassList=classes.lst -jar app.jar
# 步骤2:创建共享存档
java -Xshare:dump -XX:SharedClassListFile=classes.lst -XX:SharedArchiveFile=app.jsa -jar app.jar
# 步骤3:使用存档启动
java -Xshare:on -XX:SharedArchiveFile=app.jsa -jar app.jar
第五步:考虑GraalVM原生镜像(如果启动速度要求极高) 将应用编译为二进制文件,实现毫秒级启动,但需要注意反射、动态代理等需要额外配置。
第八章:未来展望——JVM将走向何方?
8.1 云原生适配:GraalVM原生镜像、CRaC
面对云原生环境对应用启动速度、内存占用的苛刻要求,JVM正在不断进化。GraalVM原生镜像通过静态编译将Java应用编译为二进制可执行文件,解决传统JVM启动慢、内存占用高的痛点。CRaC(Coordinated Restore at Checkpoint) 项目允许将运行中的应用状态保存下来,下次启动时直接恢复,实现“瞬时启动”。
8.2 新硬件利用:向量API、外部函数与内存API
JVM将持续利用现代硬件特性:
- 向量API:利用SIMD指令加速计算。
- 外部函数与内存API:安全、高效地访问本地代码和内存,替代不安全的JNI。
8.3 虚拟线程(Loom项目)的普及
Loom项目带来的虚拟线程(在JDK 21中正式发布),将极大简化高并发应用的编写和维护,让JVM能够以极低的成本创建和管理数百万个线程。
8.4 值对象与Primitive类
Valhalla项目致力于将不可变值类型引入Java,让开发者可以定义像int一样高效的用户自定义类型,解决包装类带来的内存开销问题。
8.5 分代ZGC
ZGC最初设计为不分代,但分代ZGC的实验性特性正在开发中,通过结合分代思想进一步提升性能,同时保持ZGC的低延迟特性。
结语
从最初只为在机顶盒上运行而设计的“橡树”,到今天驱动着全世界最关键业务系统的JVM,这段旅程本身就是一部技术创新与商业博弈交织的史诗。它见证了编程语言虚拟机从无到有、从简陋到强大的全过程。
从Sun Classic VM的纯解释执行,到Exact VM的准确式内存管理,再到HotSpot VM的热点代码探测技术;从JRockit的服务器端极致优化,到IBM J9的模块化设计,再到GraalVM的多语言全栈愿景;从JDK 8的元空间革命,到JDK 9+的G1、ZGC、Shenandoah低延迟GC,JVM始终在进化。
JVM之所以能够长盛不衰,不仅在于其卓越的技术本身,更在于其背后活跃的、充满活力的全球开发者社区。正是这些先驱者们持续不断地将新的思想、新的技术融入其中,才使得JVM这棵“老树”总能绽放出新的枝芽,从容应对每一次技术浪潮的冲击。
正如它的名字“Java”一样,JVM像一杯香浓的咖啡,愈品愈有味道,值得我们每一位开发者去细细探索和品味。掌握JVM,不仅是应对面试的需要,更是深入理解Java这门语言、写出高性能代码、快速定位线上问题的必备技能。
参考资料:
- InfoQ:Java虚拟机家族考
- 华为开发者社区:java虚拟机知识
- 知乎专栏:Java大厂高频面试
- 腾讯云开发者社区:JVM发展历程
- 阿里云开发者社区:JVM虚拟机
- 腾讯云开发者社区:虚拟机发展史
- 百度百科:java虚拟机
- 阿里云开发者社区:初识JVM
知识点测试
读完文章了?来测试一下你对知识点的掌握程度吧!
评论区
使用 GitHub 账号登录后即可发表评论,支持 Markdown 格式。
如果评论系统无法加载,请确保:
- 您的网络可以访问 GitHub
- giscus GitHub App 已安装到仓库
- 仓库已启用 Discussions 功能