1. 为什么要啃JVM这块硬骨头
聊到Java后端开发,JVM几乎是绕不开的坎。不管你是刚入行的新人,还是写了三五年业务代码的老手,迟早会撞上JVM相关的问题:线上服务突然Full GC频繁、接口响应变慢、内存溢出、类加载冲突。这些问题的根源,都在JVM里。
很多人觉得JVM抽象难懂,学起来枯燥,但我想说的是——JVM其实没那么神秘,它就是一个运行Java字节码的"虚拟机",这个虚拟机会帮你管理内存、执行代码、回收垃圾。你每天写的new对象、调用的方法、创建的线程,背后都是JVM在工作。
这篇是JVM学习的第一篇,我会从整体架构、类加载机制、运行时数据区、垃圾回收基础这几个核心块讲起,最后配上常用的调优参数和排查工具。内容尽量大白话,该给代码给代码,该给参数给参数,争取让你看完就能用起来。适合谁看?准备面试的人、工作中想排查线上问题的人、以及想让自己的代码跑得更稳的Java开发。
2. 先把JVM的整体架构装进脑子里
2.1 JVM到底是什么
先回答一个最基础的问题:JVM(Java Virtual Machine,Java虚拟机)是一台"抽象的计算机"。它有自己的指令集、自己的内存模型、自己的执行引擎。Java代码之所以能"一次编译,到处运行",靠的就是JVM——只要某个平台上有对应的JVM实现,同一份字节码就能跑起来。
打个比方,JVM就像是一个翻译官。你写的Java代码经过编译变成.class字节码,字节码是JVM的"母语",JVM拿到字节码后,在自己的内部解释执行或者编译成机器码执行。所以说,JVM隔离了操作系统和你的代码,让你的代码不直接跟操作系统打交道。
2.2 JVM的三个核心子系统
为了方便理解,可以把JVM拆成三大块:
- 类加载子系统:负责把
.class文件加载进来,进行校验、准备、解析、初始化,最终生成可执行的类。 - 运行时数据区:就是JVM的内存布局,包括堆、栈、方法区、程序计数器等,所有数据的存取都发生在这里。
- 执行引擎:真正干活的地方,它会把字节码指令翻译成机器指令去执行,同时负责垃圾回收、即时编译等任务。
这三块的关系是这样的:类加载子系统把类"搬"进运行时数据区,执行引擎再从运行时数据区里拿数据、执行指令。你遇到的大部分JVM问题,比如内存溢出、GC频繁,都和运行时数据区直接相关;而类加载冲突、NoClassDefFoundError这类问题,则要往类加载子系统里查。
2.3 为什么要先看整体再看细节
我见过不少同学一上来就埋头学垃圾回收算法,把什么CMS、G1的参数背得滚瓜烂熟,结果问到他"G1收集器负责的是堆的哪个区域"就答不上来。这就是典型的"只见树木,不见森林"。
正确的学习路径应该是:先建立整体认知,知道JVM里有哪些部件、每个部件干什么用、部件之间怎么协作,然后深入每个部件的细节。这也是这篇文章先讲整体架构的原因。等你脑子里有了这张地图,后续看任何JVM知识,都能快速归类——这个知识点属于类加载、内存模型、还是GC。
3. 类加载机制:代码是怎么被"唤醒"的
3.1 从字节码到Class对象的三步走
类加载机制是JVM学习的第一道坎。你写的public class User,在运行之前要经历三个阶段:加载、链接、初始化。
加载阶段做的事很简单:把.class文件读进内存,生成一个Class对象。这个阶段不涉及任何业务逻辑,就是把字节码文件找出来、读进来。
链接阶段又分三步:
- 验证:检查字节码的格式是否合法、是否符合JVM规范。这一步防止恶意或损坏的字节码混进来。
- 准备:为类的静态变量分配内存并设置默认值。比如
static int count = 10,在准备阶段count的值是0,不是10,真正的10要等初始化阶段才赋值。 - 解析:把常量池中的符号引用替换为直接引用。简单说,就是把代码里写的"调用某个方法"这种符号,替换成方法在内存中的实际地址。
初始化阶段才是真正执行类构造器<clinit>()方法的阶段,所有静态变量的赋值操作、静态代码块都在这里执行。所以你在静态代码块里写的逻辑,其实是在初始化阶段被执行的。
注意:类加载的时机并不是程序一启动就把所有类都加载进来,而是"首次主动使用"时才加载。比如
new一个对象、访问静态字段、调用静态方法,都会触发类的初始化。用不到的类,JVM不会主动加载。
3.2 双亲委派模型:为什么你的类不会被"山寨"掉
双亲委派模型是类加载机制里最有意思的设计。它的核心规则是:一个类加载器收到类加载请求时,不会自己先加载,而是先委托给父加载器去加载,父加载器再往上委托,直到最顶层的启动类加载器。只有父加载器反馈加载不了,子加载器才会尝试自己加载。
JVM里有三层内置的类加载器:
| 加载器名称 | 职责 |
|---|---|
| 启动类加载器(Bootstrap) | 加载$JAVA_HOME/lib下的核心类库,如rt.jar中的Java标准库 |
| 扩展类加载器(Extension) | 加载$JAVA_HOME/lib/ext下的扩展类库 |
| 应用程序加载器(Application) | 加载classpath上的用户类,也就是你项目里的代码 |
三层之间的关系是"父子"关系,注意这里的父子是包含关系,不是继承关系。每个类加载器都持有父加载器的引用。
这套机制的价值在哪?最直接的受益就是类的唯一性。JVM判断两个类是否相同,不仅要看类的全限定名,还要看加载这个类的类加载器是谁。如果没有双亲委派,你自己写一个java.lang.String放在classpath里,应用程序加载器直接加载了它,那整个系统就乱套了——JVM里会出现两个String类,一个来自启动类加载器,一个来自你的classpath,代码里到处是类型冲突。
双亲委派模型保证了:核心类库永远由启动类加载器加载,你写再多的"山寨"java.lang.String也不会被加载。因为请求会被逐层委托到最顶层的启动类加载器,而它发现String已经加载过了,直接返回已有的那个。
3.3 自定义类加载器:打破双亲委派的一个实战场景
双亲委派模型虽好,但在某些场景下需要打破它。最典型的场景就是热部署——Tomcat、Jetty这类Web容器,需要实现同一个类在不同版本的Web应用里各自独立加载,互不干扰。
我简化一下思路:自定义类加载器继承ClassLoader类,重写findClass()方法,从指定路径读取字节码文件,然后调用defineClass()把字节码转换成Class对象。如果不想走双亲委派,就重写loadClass()方法,直接执行自己的加载逻辑。
public class HotSwapClassLoader extends ClassLoader { private String classPath; public HotSwapClassLoader(String classPath) { this.classPath = classPath; } @Override protected Class<?> findClass(String name) throws ClassNotFoundException { String fileName = classPath + "/" + name.replace('.', '/') + ".class"; try (FileInputStream fis = new FileInputStream(fileName); ByteArrayOutputStream bos = new ByteArrayOutputStream()) { byte[] buffer = new byte[4096]; int len; while ((len = fis.read(buffer)) != -1) { bos.write(buffer, 0, len); } byte[] bytes = bos.toByteArray(); // defineClass 是 ClassLoader 的核心方法,把字节码转换为 Class 对象 return defineClass(name, bytes, 0, bytes.length); } catch (Exception e) { throw new ClassNotFoundException(name, e); } } }这段代码的意图很简单:从指定目录读取.class文件,用defineClass()生成类。实际开发中,简单的业务应用不建议自己写类加载器,容易踩坑;但在理解JVM的学习阶段,亲手写一个很小的自定义类加载器,对理解类加载机制特别有帮助。
我自己的体会是:类加载这块,面试最爱问的就是双亲委派模型、类加载的过程、什么情况下会打破双亲委派。你把这三块吃透,基本能应对大多数问题。
4. 运行时数据区:一张JVM的内存地图
4.1 五个区域,各司其职
运行时数据区是JVM学习里最核心的部分。JVM把内存划分成几个区域,每个区域有自己的职责、生命周期和异常场景。我建议你先记住这张总表:
| 区域 | 存储内容 | 线程共享? | 异常场景 |
|---|---|---|---|
| 程序计数器 | 当前线程正在执行的字节码地址 | 否 | 无 |
| Java虚拟机栈 | 栈帧、局部变量、操作数栈 | 否 | StackOverflowError、OutOfMemoryError |
| 本地方法栈 | 本地方法调用的栈帧 | 否 | StackOverflowError、OutOfMemoryError |
| Java堆 | 对象实例、数组 | 是 | OutOfMemoryError |
| 方法区(元空间) | 类元信息、常量、静态变量 | 是 | OutOfMemoryError |
程序计数器是JVM里唯一不会出现OOM的区域,因为它很小,只记录一条指令的地址。每个线程都有一个属于自己的程序计数器,线程切换时靠它恢复现场。
Java虚拟机栈和每个线程一一对应。每次调用一个方法,JVM就会在栈上压入一个"栈帧",栈帧里装着这个方法的局部变量表、操作数栈、方法返回地址等信息。方法执行完,栈帧弹出。递归调用过深,栈帧压太多,就会抛出StackOverflowError——这就是无限递归导致栈溢出的根源。
4.2 堆和方法区:重点关注的对象
Java堆是JVM管理的最大一块内存区域,也是垃圾回收的主战场。你写的new User(),对象就分配在堆上。堆里又分成新生代和老年代,新生代里再细分Eden区和两个Survivor区。这个我们后面讲GC的时候细说。
方法区在JDK 8之后换了个名字,叫元空间(Metaspace),而且把实现从"堆内的永久代"挪到了"本地内存"。为什么要挪?因为永久代的大小不太好控制——你限定了最大值,就可能遇到OOM;你不限定,类加载多了又会占满整个堆。挪到本地内存之后,元空间的默认上限就是机器可用内存大小,同时垃圾回收的策略也更清晰了。
方法区里存的是类的元信息:类的结构、方法信息、字段信息、常量池、静态变量等。注意:从JDK 8开始,字符串常量池也挪到了堆中。这个细节很多人会忽略,面试里经常会问"JDK 7和JDK 8中字符串常量池的位置有什么区别"。
4.3 对象在内存中的布局
问一个经常被忽略的问题:一个普通的Java对象,在堆里到底占多大内存?
一个对象在堆内存里由三部分组成:对象头、实例数据、对齐填充。
- 对象头:存储对象的运行时元数据,包括哈希码、GC分代年龄、锁状态标志等,另外还有一个指向类元信息的指针。开启压缩指针后,对象头大小一般12字节或16字节。
- 实例数据:就是你定义的那些字段的实际值。这部分的排列顺序受字段声明顺序和虚拟机分配策略影响。
- 对齐填充:JVM要求对象的大小必须是8字节的整数倍,不满足时就用填充来补齐。
听起来有点抽象,我给你算一个实际的例子。假设有一个类:
public class Point { private int x; // 4字节 private int y; // 4字节 }开启压缩指针的默认情况下,对象头12字节,实例数据8字节,加起来20字节,不是8的倍数,所以需要填充4字节,总共占用24字节。就这么一个简单的对象,实际的堆占用比你想的大不少。这也是为什么线上系统里,对象数量一多,内存开销会非常惊人。
4.4 堆内存该怎么分
堆内存的划分直接关系到垃圾回收的效率。主流的分代回收思想把堆分成两大部分:
- 新生代:存放"朝生夕灭"的对象。里面又分一块Eden区和两块Survivor区(S0、S1,也叫From和To)。默认比例是Eden:S0:S1 = 8:1:1。
- 老年代:存放存活时间较长的对象。对象在新生代熬过一定次数的垃圾回收后,会晋升到老年代。
为什么要分代?因为绝大多数对象生命周期很短,创建出来用一下就没人引用了。分代之后,每次垃圾回收可以"局部扫"而不需要全堆都扫,这样效率高很多。用一句话概括:分代回收是把"大部分对象很快死亡"这个统计规律用到了极致。
5. 垃圾回收:JVM的"清洁工"是怎么工作的
5.1 判定"垃圾"的两种算法
先解决一个最基本的问题:JVM怎么知道一个对象已经是垃圾了?
主流有两种算法。一种是引用计数法:每个对象维护一个计数器,被引用一次就加1,引用失效就减1,计数为0说明没人用,可以回收。听起来简单,但它解决不了循环引用的问题——A引用B,B引用A,两个对象互相撑着,计数永远是1,实际却没人用它们了。
所以JVM主流用的是可达性分析算法。从一组称为"GC Roots"的根节点出发,沿着引用链往下找,能被找到的说明还活着,找不到的说明可以回收。这里的GC Roots包括:虚拟机栈中引用的对象、静态属性引用的对象、常量引用的对象、本地方法栈中JNI引用的对象等。
打个比方:GC Roots就像是教室里的几盏灯,被灯光照到的位置就是"存活"的对象,照不到的角落里那些东西,就是可以扔掉的垃圾。
5.2 三色标记与漏标问题
面试进阶必问的一个点是三色标记法。垃圾收集器在做可达性分析时,会把对象标记成三种颜色:
- 白色:还没扫描过,可能被回收。
- 灰色:正在被扫描,自己已标记,但引用的对象还没被完全扫描。
- 黑色:扫描完成,自己和引用的对象都已标记。
在并发标记阶段,如果用户线程在跑,对象的引用关系随时会变化,就可能导致"漏标"——一个本来应该活着的对象没被标记上,结果被回收了。这是严重的事故。
解决漏标问题有两种思路:
- 增量更新:记录黑色对象新增的引用,重新扫描这些引用。CMS就是这种思路。
- 原始快照(SATB):记录灰色对象被删除的引用,在标记结束前重新处理。G1用的就是这种思路。
这块内容很细,建议你把三色标记的变化过程自己在纸上画几遍,理解了增量更新和原始快照的区别,面试里这块基本无往不利。
5.3 常见垃圾收集器怎么选
算笔时间账。从JDK 8到JDK 11,默认的垃圾收集器变了:JDK 8默认Parallel Scavenge + Parallel Old,JDK 9开始默认G1,JDK 17之后ZGC也逐渐普及。但国内很多企业还跑在JDK 8上,所以Parallel和CMS仍然有大量的现实存在。
简单概括一下几个常见的收集器:
- Serial:单线程回收,适合客户端应用或者内存极小的场景,有停顿但实现简单。
- Parallel:多线程并行回收,追求高吞吐量,适合后台计算任务。JDK 8默认的就是它。
- CMS:第一款并发收集器,追求低停顿,适合交互式应用。但它有个著名的问题——并发清除阶段会产生内存碎片,而且无法处理浮动垃圾,可能导致Concurrent Mode Failure从而退化为Serial Old,搞得一次Full GC非常久。
- G1:把堆分成无数Region,可以动态调整新生代和老年代的区域,追求可预测的停顿时间。用
-XX:MaxGCPauseMillis可以指定一个期望的GC停顿时间。G1通过记录每个Region的回收价值和成本,每次回收"性价比最高"的那些Region。 - ZGC:JDK 11引入的实验性收集器,JDK 15转为正式特性。核心是染色指针和读屏障,可以达到几乎零停顿,但要求内存带宽好,大堆场景特别合适。
补充一个实践中的口诀:小堆用Parallel,追求吞吐;中堆交互型用G1,追求低停顿;超大堆低延迟上ZGC。不过这些都建立在充分压测的基础上,别盲目跟风。
5.4 新生代和老年代的GC过程
GC这块不能只会背概念,你要能讲明白一次GC的完整过程。我拿G1之外的经典分代GC来举例:
- 新对象默认分配到新生代Eden区。
- Eden区快满时,触发Minor GC。
- Minor GC时,把Eden区和S0中存活的对象复制到S1,并且对象每熬过一次GC,年龄加1。
- 对象年龄到达阈值(默认15),或者S1区放不下了,就晋升到老年代。
- 老年代空间不够时,触发Major GC/Full GC,回收整个堆。
这里有个值得深挖的细节:大对象直接进入老年代。JVM提供了一个参数-XX:PretenureSizeThreshold,超过指定大小的对象直接在老年代分配。为什么?因为大对象在新生代里来回复制,代价太高。你在代码里创建一个超大的数组或者大缓存对象时,其实它在老年代里诞生。
6. 常用JVM参数与排查工具,实战必备
6.1 堆内存参数:给JVM定个"预算"
排查线上问题前,你得先知道当前进程用了多大的堆、GC情况如何。最常用的几个参数:
# 设置初始堆大小 -Xms512m # 设置最大堆大小 -Xmx1024m # 设置新生代大小 -Xmn256m # 设置元空间大小 -XX:MetaspaceSize=128m -XX:MaxMetaspaceSize=256m # 打印GC日志 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log一个经验值:生产环境建议把-Xms和-Xmx设为相同值,避免堆大小动态伸缩导致的性能抖动。JVM在启动时一次性申请好内存,运行时不再扩容,GC的行为反而更可预测。
另外一个容易被忽略的参数是-XX:+HeapDumpOnOutOfMemoryError。加上它之后,一旦发生OOM,JVM会自动把当前堆的内存快照dump到一个文件中,方便你事后分析。这个参数真的很值得加上,别等OOM了才后悔没开。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/export/logs/app.hprof6.2 常用排查工具
前面讲了理论,真到了排查问题的时候,你得有手上有"武器"。下面这些工具都是很实用的:
- jps:查看Java进程列表,拿到进程PID。
- jstat:查看JVM的类加载、GC情况。比如
jstat -gcutil PID 1000,每秒打印一次GC统计。 - jmap:导出堆dump文件,比如
jmap -dump:format=b,file=heap.bin PID。 - jstack:查看线程快照,排查死锁、线程阻塞非常有用。
- jcmd:JDK 8之后的多功能诊断工具,集成了上面很多命令的能力。
具体到一次OOM排查,我的例行流程是这样的:
- 用
jps -l找到目标进程。 - 用
jstat -gcutil PID 1000看GC频率和内存占用趋势。 - 如果GC很频繁且有OOM趋势,用
jmap -dump导出堆dump。 - 用MAT或者VisualVM分析dump文件,看哪个对象占了大头。
注意:
jmap导出大堆的dump文件时,会触发一次Full GC,对线上服务有短暂影响。高峰期导出需要谨慎,最好在低峰期操作,或者用jcmd GC.heap_dump结合-XX:+HeapDumpOnOutOfMemoryError提前配置。
6.3 几个典型的JVM参数配置方案
我按照不同场景,整理了三组常用的参数模板,算是实践中的"抄作业"配置:
场景一:普通Web应用,4核8G的机器,JDK 8
-Xms2g -Xmx2g -Xmn768m -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=256m -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app/gc.log -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/app/场景二:追求低停顿的交互型应用,G1收集器
-Xms4g -Xmx4g -XX:+UseG1GC -XX:MaxGCPauseMillis=200 -XX:ConcGCThreads=4 -XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/var/log/app/gc.log场景三:小内存的微服务,2G内存的容器
-Xms512m -Xmx512m -Xmn256m -XX:MetaspaceSize=128m -XX:+UseSerialGC -XX:+PrintGCDetails容器场景多提醒一句:如果跑了容器化环境,JVM的默认堆大小可能跟宿主机内存有关,要用-Xmx显式限制住,不然JVM可能申请的内存远超容器限制,直接被系统杀掉。JDK 10之后引入了-XX:+UseContainerSupport,默认开启了容器感知,但还是建议显式指定堆大小。
7. 常见问题与排查技巧实录
7.1 典型报错速查表
实践中最常遇到的几个问题,我整理成一张速查表,方便你对照排查:
| 报错信息 | 可能原因 | 排查方向 |
|---|---|---|
java.lang.OutOfMemoryError: Java heap space | 堆内存不足 | 调大堆、检查是否有大对象长期持有、导出堆dump分析 |
java.lang.OutOfMemoryError: Metaspace | 元空间不足,类加载过多 | 检查是否有动态生成类(如反射、CGLIB代理)过多 |
java.lang.OutOfMemoryError: unable to create new native thread | 线程数超限 | 检查线程池、操作系统ulimit限制 |
java.lang.StackOverflowError | 递归调用过深、栈太小 | 检查递归代码逻辑,考虑调大-Xss |
NoClassDefFoundError/ClassNotFoundException | 类加载器问题、依赖缺失 | 检查classpath、依赖冲突、自定义类加载器逻辑 |
7.2 Full GC频繁的排查套路
Full GC频繁是线上最常见的问题之一。我总结了一套简化的排查流程:
第一,先用jstat -gcutil PID 1000连续观察几轮输出。看FGC列的数字增长是不是很快,如果每几秒就增加一次,说明Full GC很频繁。
第二,看FGCT列的时长。Full GC单次耗时长(超过1秒),说明老年代回收困难,可能是对象太多或者回收器不合适。时长正常但次数多,说明内存本来就是不够用或者有大对象源源不断进入老年代。
第三,导出堆dump,用MAT分析。重点看两个指标:支配树上占用最大的对象是哪些,以及GC Roots到这些大对象的引用链是哪条路径。如果是某个全局缓存Map一直膨胀,就是你代码的问题;如果是某个第三方组件持有了不该持有的引用,就要考虑升级版本或者换思路。
我之前排查过一个案例:一个报表服务每跑一次就加载全量数据到静态Map里缓存,结果内存不断上涨,每次Full GC都要4秒多。后来改成弱引用缓存加定时清理,Full GC基本消失,接口耗时就降下来了。
7.3 三个我踩过的坑
第一个坑:-Xmx设太大反而更慢。我遇到过有人把8G机器的堆设成7G,结果GC一停就是十几秒。堆越大,单次GC扫描的区域越大,停顿时间越长。正确做法是堆大小要跟对象存活率、并发量匹配,并配合G1这种可控停顿的收集器。
第二个坑:在迭代里打印大量日志。有人会在循环里打日志,日志虽然不会直接占用堆,但日志字符串的拼接过程会产生大量临时对象,直接压垮新生代,触发频繁Minor GC。这个问题排查起来特别容易忽略,因为堆dump里看到的都是日志字符串对象。
第三个坑:忽略了JIT编译对性能的影响。JVM会把热点代码编译成机器码(即时编译),性能会提升一个量级。所以压测时如果只跑几秒钟,测出来的就是解释执行时的慢速;真实线上跑起来之后性能往往会更好。反过来,如果你在压测时看到某些方法特别慢,可以先用-XX:+PrintCompilation确认是否被JIT编译了。
写在最后
JVM这块内容,说难确实难,但说透也就那么几件事:类加载机制、内存模型、垃圾回收、参数调优。我自己的学习路径是先把整体架构记牢,然后逐个击破,配合大量的实操验证——改参数、看GC日志、dump堆分析。光靠看书记概念是记不牢的,一定要自己动手跑一跑。
最后分享一个小技巧:你可以在本地随便写个简单的循环创建对象的程序,用上文中提到的-XX:+PrintGCDetails参数跑起来,亲眼看看Minor GC的日志长什么样。等你亲眼见过一次GC的完整日志,再回头看分代回收那些概念,会觉得豁然开朗。下一篇我会继续深入JVM的即时编译、字符串常量池和各种工具链细节,我们下次接着聊。