一、虚拟机的结构
Java 虚拟机(JVM)是一个抽象的计算机器,它通过在实际的计算机上模拟各种计算机功能来运行 Java 程序。它的核心作用是将 Java 字节码(.class文件)翻译成特定操作系统和硬件平台的机器指令,从而实现 Java 的核心特性:"一次编写,到处运行"(Write Once, Run Anywhere)。

JVM的结构主要由下面的三个子系统和两个内存区域组成:
三大子系统:
- 类加载子系统
- 运行时数据区(方法区、堆、虚拟机栈、程序计数器、本地方法栈)
- 执行引擎
两大内存区域:
- 本地方法接口(JNI)
- 本地方法库
1.1 类加载器子系统
类加载子系统负责从文件系统或者网络中加载Class 信息,加载的类信息存放于一块称为方法区的内存空间。除了类的信息外,方法区中可能还会存放运行时常量池信息,包括字符串字面量和数字常量(这部分常量信息是Class文件中常量池部分的内存映射)。

它负责加载 、链接 和初始化 .class文件(字节码文件)。
加载 (Loading):通过类的全限定名(如 java.lang.String)找到并读取 .class文件的二进制数据,然后在方法区中创建一个代表这个类的 java.lang.Class对象。
链接 (Linking):分为三个步骤:
- 验证 (Verification):确保被加载的 .class文件符合 JVM 规范,是安全且正确的,防止恶意代码破坏。这是 Java 安全模型的重要一环。
- 准备 (Preparation):为类的静态变量分配内存并设置默认初始值(零值),例如 static int a会被初始化为 0。
- 解析 (Resolution):将常量池中的符号引用(例如类名、方法名)转换为直接引用(具体的内存地址)。
初始化 (Initialization):执行类的静态代码块 (static {}) 和为静态变量赋予程序员定义的初始值(例如 static int a = 100;)。
1.1.1 主要职责
java
public class ClassLoaderSystem {
/*
* 类加载器子系统职责:
* 1. 加载:读取.class文件到内存
* 2. 链接:验证、准备、解析
* 3. 初始化:执行类构造器<clinit>()
*
* 类加载过程:加载 → 链接(验证→准备→解析) → 初始化
*/
// 双亲委派模型
public class ParentDelegationModel {
/*
* 类加载器层级:
* 1. Bootstrap ClassLoader(启动类加载器)
* - 加载核心类库:rt.jar、charsets.jar等
* - C++实现,Java中显示为null
*
* 2. Extension ClassLoader(扩展类加载器)
* - 加载扩展目录:jre/lib/ext/*.jar
* - Java实现,sun.misc.Launcher$ExtClassLoader
*
* 3. Application ClassLoader(应用类加载器)
* - 加载classpath下的类
* - Java实现,sun.misc.Launcher$AppClassLoader
*
* 4. 自定义ClassLoader
* - 用户自定义的类加载器
*/
}
}
1.1.2 类加载过程代码示例
java
public class ClassLoadingProcess {
static {
System.out.println("静态代码块执行 - 初始化阶段");
}
public static final int CONST_VALUE = 100; // 准备阶段分配内存并设零值
// 初始化阶段赋值为100
public static void main(String[] args) throws Exception {
// 演示类加载过程
ClassLoader loader = ClassLoader.getSystemClassLoader();
Class<?> clazz = loader.loadClass("com.example.MyClass");
// 触发初始化
Class.forName("com.example.MyClass");
}
}
双亲委派模型 (Parent Delegation Model):这是类加载器的工作机制。当一个类加载器收到加载请求时,它不会自己先尝试加载,而是将这个请求委派给父类加载器去完成。只有当父类加载器无法完成加载时,子加载器才会尝试自己加载。这保证了 Java 核心库(如 java.lang包)的安全性,防止被用户自定义的类替代。
1.2 运行时数据区
这是 JVM 内存管理的核心区域,用于存储程序运行时的数据。
1.2.1 方法区
JDK1.7被称之为永久区 ,JDK1.8之后消失变成元空间(Meta space)来进行存储我们的类代替方法区的Meta space是分配到直接内存当中的。
- 存储内容:已被加载的类信息(类名、父类、方法、变量等元数据)、常量、静态变量、即时编译器编译后的代码缓存。
- 注意:在 HotSpot JVM 中,方法区常被称为 "永久代" (PermGen)(Java 8 之前),但自 Java 8 起,永久代被移除,取而代之的是 元空间 (Metaspace)。元空间使用本地内存(而非 JVM 内存),大大减少了内存溢出的可能性。
java
方法区(Method Area)逻辑结构:
┌──────────────────────────────────────┐
│ 方法区(Method Area) │
├──────────────────────────────────────┤
│ 1. 类型信息(Class Metadata) │
│ ├── 类基本信息 │
│ ├── 常量池 │
│ ├── 字段信息 │
│ ├── 方法信息 │
│ └── 类加载器引用 │
├──────────────────────────────────────┤
│ 2. 运行时常量池(Runtime Constant Pool)│
│ ├── 字面量(Literals) │
│ ├── 符号引用(Symbolic References)│
│ └── 直接引用(Direct References) │
├──────────────────────────────────────┤
│ 3. 静态变量(Static Variables) │
│ ├── 基本类型静态变量 │
│ ├── 引用类型静态变量 │
│ └── 静态常量(final static) │
├──────────────────────────────────────┤
│ 4. 即时编译器代码缓存(Code Cache) │
│ ├── 已编译的本地代码 │
│ ├── 方法字节码 │
│ └── 方法元数据 │
├──────────────────────────────────────┤
│ 5. 方法字节码(Method Bytecode) │
│ 6. JIT 编译信息(JIT Metadata) │
│ 7. 类变量初始值(Class Variable Init)│
└──────────────────────────────────────┘
1.2.2 堆
Java堆是和Java应用程序关系最为密切的内存空间,几乎所有的对象都存放在堆中。并且Java堆是完全自动化管理的,通过垃圾回收机制,垃圾对象会被自动清理,而不需要显式地释
根据垃圾回收机制的不同,Java堆有可能拥有不同的结构。最为常见的一种构成是将整个Java堆分为新生代和老年代。新生代包含Eden+Survivor区,survivor区里面分为from和to区,内存回收时,如果用的是复制算法,从from复制到to(我们也教s0,s1),当经过一次或者多次GC之后,存活下来的对象会被移动到老年区其中,新生代有可能分为eden区、s0区、s1区,s0 和s1也被称为from和to区域,它们是两块大小相等、可以互换角色的内存空间。

在绝大多数情况下,对象首先分配在eden区,在一次新生代回收后,如果对象还存活,则会进入s0或者s1,之后,每经过一次新生代回收, 对象如果存活,它的年龄就会加1。当对象的年龄达到一定条件后,就会被认为是老年对象,从而进入老年代。
- **存储内容:**几乎所有对象实例和数组都在这里分配内存。这是 JVM 中最大的一块内存区域,是垃圾回收器 (Garbage Collector, GC) 管理的主要区域,因此也被称为"GC 堆"。
- 结构 :为了更好的进行内存管理和垃圾回收,堆空间又细分为:
- 新生代 (Young Generation):新创建的对象首先在这里分配。它又分为一个 Eden 区和两个 Survivor 区 (S0, S1)。
- 老年代 (Old Generation):在新生代中经历多次 GC 后仍然存活的对象会被晋升到这里。
- 注意:Java 8 之后还有一个元空间 (Metaspace),但它不属于堆,而是使用本地内存。
展示Java堆、方法区和Java栈之间的关系:
java
public class SimpleHeap {
private int id;
public SimpleHeap(int id) {
this.id = id;
}
public void show() {
System.out.println("My ID is " + id);
}
public static void main(String[] args) {
SimpleHeap s1 = new SimpleHeap(1);
SimpleHeap s2 = new SimpleHeap(2);
s1.show();
s2.show();
}
}
上述代码声明了一个SimpleHeap类,并在main(函数中创建了两个SimpleHeap实例。此时,各对象和局部变量的存放如图所示。SimpleHeap实例本身分配在堆中,描述SimpleHeap类的信息存储在方法区,main中的s1和s2的局部变量存储在栈中并且指向两个实例。

1.2.3 虚拟机栈
Java栈是一块线程私有的内存空间。如果说,Java堆和程序数据密切相关,那么Java栈就是和线程执行密切相关的。线程执行的基本行为是函数调用,每次函数调用的数据都是通过Java栈传递的。
Java栈只支持出栈和入栈两种操作。
- 线程私有,生命周期与线程相同。
- 每个方法在执行时都会创建一个栈帧 (Stack Frame) ,用于存储局部变量表 、操作数栈 、动态链接 、方法出口等信息。方法从调用到执行完成,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
- 我们常说的"栈内存"指的就是这里,存储局部变量等。
1.2.3.1 栈帧
在Java栈中保存的主要内容为栈帧。每一次函数调用, 都会有一个对应的栈帧被压入Java栈,每一个函数调用结束,都会有一个栈帧被弹出Java栈。(栈帧就是对象,不是变量等。后入先出)
栈帧包含:局部变量表、操作数栈、栈数据区
栈帧的结构图:

解释:
- 函数1对应栈帧1,函数2对应栈帧2,依此类推。函数1中调用函数2,函数2中调用函数3,函数3中调用函数4。
- 当函数I被调用时,栈帧1入栈;当函数2被调用时,栈帧2入栈;当函数3被调用时,栈帧3入栈;当函数4被调用时,栈帧4入栈。当前正在执行的函数所对应的帧就是当前的帧(位于栈顶),它保存着当前函数的局部变量、中间运算结果等数据。
- 当函数返回时,栈帧从Java栈中被弹出。Java 方法有两种返回函数的方式,一种是正常的函数返回,使用return指令;另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。在一个栈帧中,至少要包含局部变量表、操作数栈和帧数据区几个部分。
1.2.3.2 局部变量表
局部变量表用于保存函数的参数以及局部变量。局部变量表中的变量只在当前函数调用中有效,当函数调用结束后,随着函数栈帧的销毁,局部变量表也会随之销毁。
由于局部变量表在栈帧之中,因此,如果函数的参数和局部变量较多,会使得局部变量表膨胀,从而每一次函数调用就会占用更多的栈空间,最终导致函数的嵌套调用次数减少。
下面的代码演示了这种情况,第1个recursion()函数含有3个参数和10个局部变量,因此,其局部变量表含有13个变量。而第2个recursion()函数不含有任何参数和局部变量。当这两个函数被嵌套调用时,第2个recursion()函数可以拥有更深的调用层次。
java
public class TestStackDeep {
private static int count = 0;
public static void recursion(long a, long b, long c) {
long e=1, f=2,g=3,h=4,i=5,k=6,q=7,x=8,y=9,z=10;
count++;
recursion(a,b,c) ;
}
public static void recursion() {
count++;
recursion();
}
public static void main(String args[]) {
try {
recursion(1,2,3);
} catch (Throwable e) {
System.out.println("deep of calling = " + count);
e.printStackTrace();
}
}
}
-Xss128k调用无参数的方法

-Xss128k调用有参数的方法

可以看到,在相同的栈容量下,局部变量少的函数可以支持更深的函数调用。
使用jclasslib工具可以更进一步查看函数的局部变量信息。图2.6显示了第一个recursion()函数的最大局部变量表的大小为26个字。因为该函数包含总共13 个参数和局部变量,且都为long型,long 和double在局部变量表中需要占用2个字,其他如int、short、 byte、 对象引用等占用1个字。
第一个方法

第二个方法


可以看到,在Class文件的局部变量表中,显示了每个局部变量的作用域范围、所在槽位的索引(index 列)、变量名(name 列)和数据类型(J 表示long型)。
栈帧中的局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。
示例\]下面的代码显示了局部变量表槽位的复用。
在localvar1()函数中,局部变量a和b都作用到了函数末尾,故b无法复用a所在的位置。而在localvar2()函数中,局部变量a在第16行时不再有效,故局部变量b可以复用a的槽位(1个字)。

我们看到localvar1有三个槽位

我们看到localvar2中槽位1得到复用,b复用了a的槽位

###### 局部变量的回收
局部变量表中的变量也是重要的垃圾回收根节点,只要被局部变量表中直接或间接引用的对象都是不会被回收的。因此,理解局部变量表对理解垃圾回收也有一定帮助。
```java
public class LocalVarGCTest {
//情况1
public void localvarGc1() {
byte[] a = new byte[6 * 1024 * 1024];
System.gc();
}
//情况2
public void localvarGc2() {
byte[] a = new byte[6 * 1024 * 1024];
a = null;
System.gc();
}
//情况3
public void localvarGc3() {
{
byte[] a = new byte[6 * 1024 * 1024];
}
System.gc();
}
//情况4
public void localvarGc4() {
{
byte[] a = new byte[6 * 1024 * 1024];
}
int c = 10;
System.gc();
}
//情况5
public void localvarGc5() {
localvarGc1();
System.gc();
}
public static void main(String[] args) {
LocalVarGCTest ins = new LocalVarGCTest();
ins.localvarGc4();
}
}
```
上述代码中,每一个localvarGc函数都分配了一块6MB的堆空间,并使用局部变量引用这块空间。
* 情况1:在localvarGc1中,在申请空间后,立即进行垃圾回收,很明显,由于byte 数组被变量a引用,因此无法回收这块空间。
* 情况2:在localvarGc2中,在垃圾回收前,先将变量a置为null,使byte数组失去强引用,故垃圾回收可以顺利回收byte数组。
* 情况3:对于localvarGc3, 在进行垃圾回收前,先使局部变量a失效,虽然变量a已经离开了作用域,但是变量a依然存在于局部变量表中,并且也指向这块byte数组,故byte数组依然无法被回收。
* 情况4:对于localvarGc4,在垃圾回收之前,不仅使变量a失效,更是申明了变量c,使变量c复用了变量a的字,由于变量a此时被销毁,故垃圾回收器可以顺利回收byte数组。
* 情况5:对于localvarGc5,它首先调用了localvarGc1很明显,在localvarGc1中 并没有释放byte数组,但在localvarGc1返回后,它的栈帧被销毁,自然也包含了栈帧中的所有局部变量,故byte数组失去引用,在localvarGc5的垃圾回收中被回收。
可以使用参数-XX:+PrintGC执行上述几个函数,在输出的日志中,可以看到垃圾回收前后堆的大小,进而推断byte数组是否被回收。下 面的输出是函数localvarGc4的运行结果:

##### 1.2.3.3 操作数栈
操作数栈也是栈帧中重要的内容之一,它主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。
操作数栈也是一个先进后出的数据结构,只支持入栈和出栈两种操作。许多Java字节码指令都需要通过操作数栈进行参数传递。比如iadd指令,它就会在操作数栈中弹出两个整数并进行加法计算,计算结果会被入栈,如图所示,显示了iadd前后操作数栈的变化。

```java
public static int add(){
int i = 10;
int j = 20;
int z = i +j;
return z;
}
```

```java
* 步骤 | 指令 | 局部变量表 | 操作数栈 | 说明
* -----|-----------|---------------|-------------|----------------
* 0 | | [] | [] | 初始状态
* 1 | bipush 10 | [] | [10] | 压入常量10
* 2 | istore_0 | [a=10] | [] | 存储到变量a
* 3 | bipush 20 | [a=10] | [20] | 压入常量20
* 4 | istore_1 | [a=10,b=20] | [] | 存储到变量b
* 5 | iload_0 | [a=10,b=20] | [10] | 加载变量a
* 6 | iload_1 | [a=10,b=20] | [10,20] | 加载变量b
* 7 | iadd | [a=10,b=20] | [30] | 执行加法
* 8 | istore_2 | [a=10,b=20,c=30] | [] | 存储结果到c
* 9 | iload_2 | [a=10,b=20,c=30] | [30] | 加载返回值
* 10 | ireturn | 销毁 | 清空 | 返回30
*
```
```java
public static void test(){
int i = 0;
i = i++;
System.out.println(i);
}
```

```java
* 步骤 | 指令 | i的值 | 操作数栈 | 说明
* -----|-----------|-------|---------|------------------
* 0 | iconst_0 | - | [0] | 压入常量0
* 1 | istore_0 | 0 | [] | 初始化i=0
* 2 | iload_0 | 0 | [0] | 加载i的当前值(0)
* 3 | iinc 0,1 | 1 | [0] | i自增为1,栈不变
* 4 | istore_0 | 0 | [] | 将栈顶的0存回i!
```
#### 1.2.4 程序计数器
* **线程私有,是一块很小的内存空间。**
* **它可以看作是当前线程所执行的字节码的行号指示器**。字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令。它是程序控制流的指示器。
* **本地方法栈 (Native Method Stack):**
* 与虚拟机栈作用非常相似,区别在于**虚拟机栈为执行 Java 方法服务,而本地方法栈则为执行本地(Native)方法服务(用 C/C++ 编写的方法)**。
```java
0 iconst_0
1 istore_0
2 iload_0
3 iinc 0 by 1
6 istore_0
7 getstatic #2