别再背内存分区的大小了,你只需要知道“你的对象在内存里是怎么活着、怎么死的”

写在前面

很多人觉得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,但分代概念依然存在。

面试常问:堆大小怎么设置?

  • -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 类加载的生命周期

  1. 加载(Loading) :把.class文件(二进制字节流)找到,读进内存,并在堆里生成一个java.lang.Class对象。
  2. 验证(Verification) :检查这个字节流合不合规,是不是恶意代码,防止破坏JVM安全。
  3. 准备(Preparation) :给静态变量(static 分配内存,并设置默认初始值(比如int是0,引用是null)。
    • 注意:准备阶段不会执行代码里的赋值语句(比如static int a = 10,此时a只是被赋值为0,真正的10要到初始化阶段)。
  4. 解析(Resolution) :把符号引用(类里的那些“代号”)转换成直接引用(内存里的实际地址)。
  5. 初始化(Initialization) :执行类构造器<clinit>(),即执行静态代码块和静态变量的赋值。这时候才真正执行了你写的赋值逻辑(a = 10)。

2.2 双亲委派模型(绝对核心)

这是Java类加载最巧妙的设计。

  • 三个默认加载器
    1. 启动类加载器(Bootstrap ClassLoader) :加载<JAVA_HOME>/lib下最核心的类(比如rt.jar里的java.lang.*)。
    2. 扩展类加载器(Extension ClassLoader) :加载<JAVA_HOME>/lib/ext下的类。
    3. 应用类加载器(Application ClassLoader) :加载你的Classpath(你写的代码)。
  • 什么叫双亲委派?
    当一个类加载器收到加载请求,它不会自己去加载,而是把请求抛给它的父加载器。父加载器加载不了,才甩给子加载器自己加载。

面试追问:“为什么要这么设计?”(这才是考点)

核心目的:防止核心类库被篡改。

假设你自己写了一个类叫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 排查步骤(面试必备话术)

  1. 启动时加上参数,让JVM在OOM时自动dump快照
    -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump.hprof
  2. 应用挂了之后,拿到 dump.hprof 文件
  3. 用工具分析:MAT(Memory Analyzer Tool)、JVisualVM、Eclipse Memory Analyzer。
  4. 重点看:哪个对象占用了最多的内存(Dominator Tree),GC Roots指向了谁,到底是哪个线程、哪行代码一直在申请内存。
  5. 找到原因后修复:要么改代码释放引用,要么增大-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到底怎么选?下期见。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐