第四篇:JVM——Java程序跑在了一台“虚拟电脑”上
别再背内存分区的大小了,你只需要知道“你的对象在内存里是怎么活着、怎么死的”
写在前面
很多人觉得JVM是Java面试里最“玄学”的一块,什么堆栈方法区、垃圾回收算法、类加载机制……听起来像天书。
别慌。JVM的本质就是一台“虚拟的电脑”。
- 类加载器 = 把.class文件(你的代码)装进这台电脑的“硬盘”。
- 内存区域(运行时数据区) = 这台电脑的“内存条”。
- 执行引擎 = 这台电脑的“CPU”,负责执行字节码指令。
- 垃圾回收器(GC) = 这台电脑的“自动清洁工”,定时把内存里没人用的垃圾扫走。
你把JVM想象成一台装了特殊操作系统(Java字节码)的虚拟电脑,所有的问题就迎刃而解了。
一、JVM内存区域:你的对象住在哪个房间?
面试官最爱问:“JVM的内存结构是怎样的?” 别慌,我们分区域讲,每个区域负责什么,一目了然。
1.1 堆(Heap)—— 最大的“公共仓库”
这是JVM里最大的一块内存,所有线程共享。你new出来的所有对象,都住在这里。
- 垃圾回收(GC)的主要战场:清洁工(GC)没事就在这个仓库里转悠,把没人要的对象扔出去。
- 分代设计(绝大多数GC器都采用) :
- 年轻代(Young Generation) :新对象刚出生时放这儿。这里又分
Eden区和两个Survivor区(S0和S1)。对象在这里“熬过”几次垃圾回收后,就会被挪到老年代。 - 老年代(Old Generation / Tenured) :存放生命周期长的“老油条”对象。
- 注:G1等新一代垃圾回收器逻辑上不再强制物理分代,而是划分为多个Region,但分代概念依然存在。
- 年轻代(Young Generation) :新对象刚出生时放这儿。这里又分
面试常问:堆大小怎么设置?
-Xms:初始堆大小(比如-Xms2g)。-Xmx:最大堆大小(比如-Xmx4g)。- 最好把-Xms和-Xmx设成一样大,这样可以避免JVM在运行时频繁向操作系统申请扩容/缩容,减少性能抖动。
1.2 栈(Stack)—— 每个线程的“工作台”
每个线程都有自己的栈,互不干扰。 它存的是你方法调用的“现场”。
- 每调用一个方法,就在栈上压入一个栈帧(Stack Frame)。
- 栈帧里存了:局部变量表(方法里的变量)、操作数栈(做计算的地方)、方法返回地址。
- 方法执行完,栈帧弹出(销毁)。
面试常问:栈里存对象吗?
不存! 栈里只存基本数据类型(int、boolean等)和对象的引用(reference)(相当于指向堆里对象的“遥控器”),对象本体永远在堆里。
1.3 方法区(Method Area)/ 元空间(Metaspace)—— 放“图纸”的地方
这是所有线程共享的。 它存的是“类”的信息,而不是“对象”的信息。
- 类的全限定名、方法字节码、字段信息、常量池、静态变量(
static)。 - JDK 1.8的重大变化:永久代(PermGen)被移除,改成了元空间(Metaspace)。
- 为什么改? 因为PermGen大小很难设,且容易OOM。Metaspace不再在JVM内存里,而是直接用本地内存(Native Memory) ,最大可用空间受限于操作系统内存,不容易炸。
1.4 程序计数器(PC Register)—— 记“小账本”
每个线程独有。它非常小,就记录着一件事:当前线程执行到哪一行字节码了。
- 线程切换(CPU时间片轮转)时,需要它来保存和恢复执行位置,不然CPU切回来不知道从哪开始。
1.5 本地方法栈(Native Method Stack)
和栈功能一样,只不过它是用来执行**native修饰的方法**(比如Java调用底层的C/C++代码,如Object.hashCode()的底层实现)。
二、类加载机制:你的代码是怎么被装进虚拟电脑的?
面试官问:“类加载过程是怎样的?” 你千万别只背“加载、验证、准备、解析、初始化”这五个词,要说出为什么这么设计。
2.1 类加载的生命周期
- 加载(Loading) :把
.class文件(二进制字节流)找到,读进内存,并在堆里生成一个java.lang.Class对象。 - 验证(Verification) :检查这个字节流合不合规,是不是恶意代码,防止破坏JVM安全。
- 准备(Preparation) :给静态变量(
static) 分配内存,并设置默认初始值(比如int是0,引用是null)。- 注意:准备阶段不会执行代码里的赋值语句(比如
static int a = 10,此时a只是被赋值为0,真正的10要到初始化阶段)。
- 注意:准备阶段不会执行代码里的赋值语句(比如
- 解析(Resolution) :把符号引用(类里的那些“代号”)转换成直接引用(内存里的实际地址)。
- 初始化(Initialization) :执行类构造器
<clinit>(),即执行静态代码块和静态变量的赋值。这时候才真正执行了你写的赋值逻辑(a = 10)。
2.2 双亲委派模型(绝对核心)
这是Java类加载最巧妙的设计。
- 三个默认加载器:
- 启动类加载器(Bootstrap ClassLoader) :加载
<JAVA_HOME>/lib下最核心的类(比如rt.jar里的java.lang.*)。 - 扩展类加载器(Extension ClassLoader) :加载
<JAVA_HOME>/lib/ext下的类。 - 应用类加载器(Application ClassLoader) :加载你的Classpath(你写的代码)。
- 启动类加载器(Bootstrap ClassLoader) :加载
- 什么叫双亲委派?
当一个类加载器收到加载请求,它不会自己去加载,而是把请求抛给它的父加载器。父加载器加载不了,才甩给子加载器自己加载。
面试追问:“为什么要这么设计?”(这才是考点)
核心目的:防止核心类库被篡改。
假设你自己写了一个类叫
java.lang.String(包名和类名跟JDK一样)。如果没有双亲委派,你的String类就会被加载进JVM。有了双亲委派,向上交给启动类加载器,启动类加载器一看是java.lang.String,直接就从JDK的rt.jar里把正宗String拿出来了。你的山寨String永远没机会加载,JVM核心安全得到了保障。
三、垃圾回收(GC)—— 清洁工是怎么打扫卫生的?
GC是面试绝对的重头戏。面试官想知道:内存满了,谁被清理?怎么清理?
3.1 怎么判断对象是垃圾?(引用计数 vs 可达性分析)
① 引用计数法(已淘汰)
每个对象记着被引用了多少次。引用归零就回收。
弊端:无法解决循环引用(A引用B,B引用A,但外部没人用它们,它们互相死抱着对方,内存泄漏)。
② 可达性分析(JVM用的)
从“GC Roots”作为起点,往下找引用链,能找得到的对象就是“活的”,找不到的对象就是“垃圾”(可回收)。
GC Roots包括什么?
- 虚拟机栈(栈帧局部变量表)中引用的对象。
- 静态属性(
static)引用的对象。 - 常量池引用的对象。
- Native方法引用的对象。
- 注意:互相引用的A和B,如果没有被GC Roots引用到,依然会被判定为垃圾。
3.2 垃圾回收三大算法(理解原理即可)
① 标记-清除(Mark-Sweep)
先标记出所有垃圾,然后清除。
缺点:清除后内存变得碎片化(东一块西一块的空洞),大对象来了找不到连续空间。
② 复制算法(Copying)
把内存分成两块,只往一块里放对象,满了之后,把活着的对象复制到另一块,前一块整块清空。
缺点:内存只能利用一半。
应用:年轻代的Eden + S0/S1就是复制算法。
③ 标记-整理(Mark-Compact)
先标记垃圾,但清除时不是直接扔,而是把所有存活对象往内存的一头移动(压缩),让内存连续。
应用:老年代用这个算法(因为老年代对象存活率高,用复制算法太亏)。
3.3 分代收集理论(为什么分年轻代和老年代?)
一句话概括:“朝生夕死的放一起,老不死的放一起,分治处理。”
- 年轻代:对象死的快,用复制算法(效率高,只有少量存活对象要复制)。
- 老年代:对象存活率高,用标记-清除/标记-整理(清理频率低,但每次清理成本高)。
3.4 常见垃圾回收器(面试常问,但不用背细节)
记住主流就行:
- Serial / ParNew:早期的,单线程/多线程,会Stop The World(STW,停下所有用户线程)。现在极少用。
- CMS(Concurrent Mark Sweep) :追求低延迟(垃圾回收时,用户线程可以和GC线程并发执行)。但有浮动垃圾(清理时又产生了新垃圾)、产生内存碎片。JDK 9后已标记废弃,不要再用了。
- G1(Garbage First) :目前的主流(JDK 9+默认)。
- 不再物理分代,而是把堆分成多个Region(区域)。
- 可以设定预期停顿时间(比如100ms),G1会计算每个Region的垃圾回收收益,优先回收垃圾最多的Region(Garbage First名字来源)。
- ZGC(JDK 11+) :号称实现亚毫秒级停顿(<10ms),不管是几G还是几十G的堆。算法极其复杂,了解即可。
四、JVM调优和OOM排查(面试官真正关心你的实战能力)
面试官不会只问你理论,一定会问:“你们线上出过OOM吗?怎么排查的?”
4.1 常见的OOM场景
- 堆内存溢出(
java.lang.OutOfMemoryError: Java heap space) :对象太多,撑爆了。比如大List循环往里塞数据。 - 元空间溢出(
java.lang.OutOfMemoryError: Metaspace) :动态生成太多类(比如频繁使用CGLib代理)。 - 栈内存溢出(
StackOverflowError) :递归调用太深,方法栈帧把栈撑爆了。
4.2 排查步骤(面试必备话术)
- 启动时加上参数,让JVM在OOM时自动dump快照:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof - 应用挂了之后,拿到
dump.hprof文件。 - 用工具分析:MAT(Memory Analyzer Tool)、JVisualVM、Eclipse Memory Analyzer。
- 重点看:哪个对象占用了最多的内存(Dominator Tree),GC Roots指向了谁,到底是哪个线程、哪行代码一直在申请内存。
- 找到原因后修复:要么改代码释放引用,要么增大
-Xmx内存,要么优化数据结构。
五、面试官真正想听什么
场景1:“讲一下JVM内存模型。”
普通回答(及格):背出堆、栈、方法区、程序计数器。
面试官想听的(加分):
“除了堆和方法区是线程共享,栈和程序计数器是线程私有外,我更关注哪些区域容易发生OOM。堆存对象最容易爆,栈的线程数太多会爆
OutOfMemoryError: unable to create new native thread,元空间如果大量使用动态代理也要留意。在实际项目中,我最关心堆的使用情况,因为GC主要发生在这里。”
场景2:“你遇到过GC频繁的问题吗?”
死记硬背版:“遇到过,可能是代码有问题。”
加分版(真实感):
“我们系统遇到过Full GC频繁导致接口RT飙高。我先用
jstat -gcutil pid 1000看GC频率,发现老年代占用持续上升且无法回收。然后dump了堆内存,用MAT分析,发现是某个缓存类被误存了百万级对象,且没有设置过期时间,导致老年代瞬间填满。后来我们改成用Guava Cache设置最大容量和TTL,Full GC频率降为0。”
小结
| 核心概念 | 大白话 | 面试考点 |
|---|---|---|
| 堆(Heap) | 对象本体的大仓库 | GC主战场,-Xms和-Xmx设置 |
| 栈(Stack) | 方法调用的工作台 | 存引用和基本类型,StackOverflowError |
| 元空间(Metaspace) | 放类图纸 | 用本地内存,静态变量存这里 |
| 双亲委派 | 找老爸先加载 | 防止核心类被篡改 |
| 可达性分析 | 从GC Roots找引用链 | 判断垃圾的方式 |
| G1垃圾回收器 | 分区回收,优先清垃圾多的 | 主流GC器,设定停顿时间 |
| OOM排查 | 加参数dump+MAT分析 | 真正的实战能力 |
记住一句话:JVM面试不考记忆力,考的是你处理过多少内存问题。哪怕没线上实战过,能流畅地说出“如何加启动参数dump堆栈、如何用MAT分析最大的对象”,面试官也会觉得你是有经验的人。
下一篇我们讲MySQL——数据库到底是怎么存数据的?为什么索引偏偏是B+树?InnoDB和MyISAM到底怎么选?下期见。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐
所有评论(0)