前言
JVM 是 Java 体系的基石,也是后端面试必问模块。很多面试上来直接提问:JVM 内存区域分为哪几块?哪些线程共享?类加载流程是什么?双亲委派是什么?
本篇把 JVM 基础核心全部梳理完毕,包含概念、原理、代码示例,文末附带面试真题,看完可以直接应对面试。
一、什么是 JVM
JVM 全称 Java Virtual Machine,Java 虚拟机。JVM 运行在操作系统之上,不直接和硬件打交道,由操作系统为 JVM 提供硬件能力。
JVM 两大核心能力
- 加载并执行字节码文件(
.class文件)Java 源码.java经过javac编译,生成平台无关的字节码.class,JVM 负责解析执行字节码,实现一次编译,到处运行。同一个 class 文件,可以在 Windows、Linux、Mac 不同操作系统的 JVM 上运行。 - 内存管理负责内存分配、内存回收,实现自动垃圾回收,开发者不需要手动释放对象内存。
重点:
.java是源码;.class是字节码;JVM 只识别 class 字节码。
市面上主流 JVM 实现
JVM 是一套规范,不同厂商有不同实现:
表格
| 虚拟机 | 归属 & 说明 |
|---|---|
| HotSpot | 最主流,OpenJDK 开源,OracleJDK 商用版本,我们日常开发使用最多 |
| JRockit | BEA 公司,主打高性能,现在逐步退出市场 |
| J9 | IBM 推出,内存占用低,IBM 产品大量使用 |
| Dragonwell(龙井 JDK) | 阿里开源 JDK,基于 OpenJDK 改造,针对电商、金融高并发场景优化 |
JVM 整体架构总览
JVM 由 4 大子模块组成:
- 类加载器子系统:读取磁盘 class 字节码,加载进内存。
- 运行时数据区:内存核心区域,程序运行时的数据全部存放在这里。
- 执行引擎:解释器 + JIT 即时编译器 + 垃圾回收器;把字节码翻译成机器指令交给 CPU 执行。
- 本地方法接口 + 本地方法栈:调用
native本地方法,对接操作系统底层 C/C++ 实现。
结构图:
流程简述:类加载器加载 class 字节码放入运行时数据区 → 执行引擎读取字节码执行,需要操作系统能力时调用本地方法接口。
二、JVM 运行时数据区
必背区分:线程共享、线程私有
- 线程共享:方法区、堆,多个线程共用同一块内存。
- 线程私有:虚拟机栈、本地方法栈、程序计数器 PC 寄存器;每新建一个线程,就分配一份私有内存,线程之间互不干扰。
| 内存区域 | 线程归属 | 存储内容 |
|---|---|---|
| 方法区 | 线程共享 | 类 / 接口元数据、静态变量、运行时常量池、JIT 代码缓存 |
| 堆 | 线程共享 | 对象实例、数组;GC 垃圾回收主要区域 |
| 虚拟机栈 (Java 栈) | 线程私有 | Java 方法执行,存储栈帧 |
| 本地方法栈 | 线程私有 | native 本地方法执行信息 |
| 程序计数器 PC 寄存器 | 线程私有 | 字节码行号指示器,唯一一个不会发生 OOM 的内存区域 |
2.1 程序计数器 PC Register
- 线程私有,内存空间极小,可以忽略不计。
- 存储当前线程下一条要执行字节码指令的地址。
- 多线程切换的时候,会保存当前执行位置,线程切回来继续从该地址执行。
- 该区域没有 OOM。
举例子:字节码一共 10 行,现在执行到第 3 行,PC 寄存器记录 4,告诉执行引擎下一条执行第 4 行。
2.2 虚拟机栈 Java Stack
- 线程私有,线程创建的时候同步创建,线程销毁栈内存就释放。
- JVM 对虚拟机栈只有两种操作:栈帧入栈、栈帧出栈,先进后出。
- 每调用一个 Java 方法,就会创建一个栈帧(Stack Frame)压入栈;方法执行完毕,栈帧出栈销毁。
栈帧 Stack Frame 内部结构
栈帧就是一个方法执行需要的内存集合,包含 4 部分:
- 局部变量表:方法入参、方法内部局部变量。存储 8 大基本数据类型、对象引用 reference。
- 操作数栈:字节码执行的临时运算空间,做计算、赋值。
- 动态链接:指向方法区常量池符号引用,运行期转为直接引用。
- 方法返回地址:方法执行结束后回到调用者的位置。
演示代码理解栈帧入栈出栈
public class StackDemo { public static void main(String[] args) { System.out.println("main()开始"); test(); System.out.println("main()结束"); } public static void test(){ System.out.println("test执行"); } }执行流程:
- main 方法调用,创建 main 栈帧,压栈;
- main 调用 test,创建 test 栈帧,压栈;
- test 执行完毕,test 栈帧弹出销毁;
- main 执行完毕,main 栈帧弹出销毁。
栈溢出 StackOverflowError
当递归调用深度过大,不断产生栈帧入栈,栈内存耗尽,抛出:Exception in thread "main" java.lang.StackOverflowError
栈大小可以通过 JVM 参数
-Xss设置,比如-Xss256k。
两个面试小问题:
1、虚拟机栈会不会 GC?
不会。方法执行完成栈帧直接出栈释放,不靠垃圾回收。
2、方法内部局部变量是否线程安全?
如果局部变量不逃逸出方法作用域,线程安全。每个线程有自己虚拟机栈,每个线程的局部变量是私有副本;如果变量是 static 静态变量,属于线程共享,线程不安全。
2.3 本地方法栈
和虚拟机栈几乎一样,区别:虚拟机栈服务 Java 方法;本地方法栈专门服务 native 标记的本地方法。 native 方法底层是 C/C++ 实现,用来调用操作系统底层 API,比如Thread.start()底层就有 native 方法。线程私有。
2.4 方法区
重点:方法区是 JVM 规范定义的逻辑概念。 HotSpot 虚拟机实现变迁:
- JDK1.7 及之前:永久代 PermGen,放在堆内存。
- JDK1.8 及以后:废弃永久代,改用元空间 Metaspace,使用操作系统本地内存(堆外内存)。
方法区存储内容:
- 被虚拟机加载的类、接口元数据(类名、方法信息、字段信息)
- static 静态变量
- 运行时常量池
- JIT 即时编译器编译后的代码缓存
JDK7 重要改动:字符串常量池、静态变量从方法区移至 Java 堆。
注意:方法区虽然逻辑上属于堆概念,HotSpot 把它叫 Non‑Heap(非堆),物理上和堆分开。
方法区也可以发生 GC:主要用来卸载不再使用的类,条件苛刻,FullGC 的时候才会触发。
2.5 Java 堆 Heap
线程共享,JVM 启动时创建。几乎所有对象实例、数组分配在堆上,是 GC 垃圾回收最主要的区域。下一篇博客详细讲解堆分代模型。
三、类加载器 ClassLoader
class 字节码文件开头固定魔数:CA FE BA BE,用来校验文件是不是合法 class。
重要一句话:ClassLoader 只负责字节码的加载,这个类能不能正常运行由执行引擎负责。加载完成之后类元信息存放在方法区。
3.1 类完整生命周期
一个类从磁盘被加载,到最后被卸载,完整生命周期分为:加载 → 链接(验证、准备、解析) → 初始化 → 使用 → 卸载
① 加载
- 通过类全限定名获取 class 二进制字节流;字节流来源可以是磁盘、网络、数据库。
- 将字节流静态存储结构,转换为方法区运行时数据结构。
- 在堆中生成
java.lang.Class对象,作为访问方法区类元数据的入口。
Java 类是懒加载:不是程序启动全部加载,用到的时候才加载。
② 链接,分为验证、准备、解析三步
1、验证校验 class 字节流格式、语义合法性,保证不会破坏虚拟机安全,防止恶意字节码攻击。
2、准备给类的static 静态变量分配内存,赋默认初始值。
注意:这里赋的是 JVM 默认零值,不是代码写的值。
static int a = 10;,准备阶段 a = 0;初始化阶段赋值为 10。final static编译期常量,编译阶段直接赋值,准备阶段不会赋零值。- 实例变量不属于准备阶段,实例变量是 new 对象的时候,分配到堆内存赋值。
3、解析把常量池中的符号引用替换为直接引用。
- 符号引用:只是一组字面描述,不知道内存真实地址。比如类名、方法名字符串。
- 直接引用:真实内存指针,指向已经加载进内存的目标。
③ 初始化
执行类构造器<clinit>()方法。 把代码中写的静态变量赋值、执行静态代码块。这一步才执行程序员写的业务赋值逻辑。
<clinit>由编译器收集静态变量赋值语句、static {} 静态代码块合并生成。
④ 使用
实例化对象、调用静态方法、访问字段,业务代码使用这个类。
⑤ 卸载
满足条件,类可以被 GC 卸载,释放方法区内存。 卸载类必须同时满足 3 个条件:
- 该类的所有实例全部被回收,堆不存在该类对象。
- 该类对应的
Class对象没有任何地方被引用。 - 加载这个类的 ClassLoader 没有被引用。
普通 AppClassLoader 加载的类不会卸载;自定义类加载器场景经常会发生类卸载,比如热部署。
3.2 四大类加载器
关键点:加载器之间的父子,不是类继承 extends 关系,是逻辑委派关系。
- BootstrapClassLoader 启动类加载器
- C++ 语言实现,没有 Java 对象,获取它的 classLoader 会返回
null。 - 加载 JDK 核心模块,
java.base等核心类库,比如String、Object。
- C++ 语言实现,没有 Java 对象,获取它的 classLoader 会返回
- PlatformClassLoader 平台类加载器(JDK9 之后,替代旧的 ExtClassLoader 扩展类加载器)
- Java 代码实现。
- 加载 JDK 平台模块。
- AppClassLoader 应用程序类加载器(系统类加载器)
- Java 实现,我们自己写的业务代码默认由它加载。
- 加载
‑cpclasspath 下的 class 文件、项目引入的 jar 包。
- 自定义类加载器继承
ClassLoader,自己实现读取字节码逻辑;用于热部署、插件化、加密 class 解密。
测试代码,查看类加载器层级
public class ClassLoaderTest { public static void main(String[] args) { //自己写的类,AppClassLoader System.out.println(ClassLoaderTest.class.getClassLoader()); //父加载器 PlatformClassLoader System.out.println(ClassLoaderTest.class.getClassLoader().getParent()); //祖父 BootstrapClassLoader,输出null(C++实现) System.out.println(ClassLoaderTest.class.getClassLoader().getParent().getParent()); //String由Bootstrap加载,输出null System.out.println(String.class.getClassLoader()); } }输出示例:
jdk.internal.loader.ClassLoaders$AppClassLoader@2437c6dc jdk.internal.loader.ClassLoaders$PlatformClassLoader@1b6d3586 null null3.3 双亲委派模型(沙箱安全机制)
什么是双亲委派
核心思想:收到类加载请求,先向上委托父加载器加载;父加载器全部加载失败,自己才尝试加载。
完整流程:
“双亲” 并不是指两个父加载器,是向上一层一层委托的含义。
双亲委派两大核心目的
1、安全性(最重要)防止我们自定义java.lang.String类去替换 JDK 原生核心类。
当你写一个自己的 String 类,加载请求会委托给 Bootstrap,Bootstrap 已经加载过原生 java.lang.String,直接返回,不会加载我们写的恶意 String 类。
2、避免重复加载类父加载器已经加载过的类,子类不会重复加载,保证同一个全限定名类在 JVM 只有一份。
破坏双亲委派模型
双亲委派不是强制约束,可以被破坏。 重写ClassLoader的loadClass()方法,就可以破坏。
常见场景:SPI 机制、热部署、插件化框架。
JDK9 模块化之后对双亲委派做了部分改动。
四、本篇面试高频题汇总
- JVM 运行时数据区分为哪 5 块?哪些线程共享,哪些线程私有?
- 程序计数器会不会 OOM?虚拟机栈会不会 GC?
- 栈帧里面包含哪些内容?StackOverflowError 什么场景产生?
- 简述类完整生命周期,链接分为哪三步,每一步干什么?
- 准备阶段静态变量赋值特点,final static 常量是什么时候赋值?
- 四大类加载器分别是什么,Bootstrap 为什么获取返回 null?
- 双亲委派模型流程,两大作用?如何破坏双亲委派,什么场景会破坏?
- 什么样的类才会被卸载?三个条件?
小贴士
运行时数据区、类加载、双亲委派是 JVM 面试的第一道门槛,务必理解记忆。下一篇我们进入堆内存,学习分代模型、GC 触发条件、OOM 现象。