JVM - 方法区
# 1. 引言:运行时数据区的最后一块拼图
在 JVM 的运行时数据区中,我们已经探讨了程序计数器、虚拟机栈、本地方法栈和堆。本次我们将深入研究最后一部分——方法区 (Method Area)。
从线程共享的角度来看,方法区与堆一样,是所有线程共享的区域。而虚拟机栈、本地方法栈和程序计数器是线程私有的。
(回顾:ThreadLocal 通过为每个线程提供独立的变量副本,解决了线程共享区域中的并发安全问题,常用于数据库连接管理和会话管理。)
# 2. 栈、堆、方法区的交互关系
理解方法区的作用,需要先明确它与栈、堆之间的协作关系,特别是对象创建和访问的过程。
考虑以下代码:
Person person = new Person();
这行简单的代码涉及了 JVM 运行时数据区的三个核心部分:
- 方法区 (Method Area):
- 存储
Person
类的类型信息(类的结构、字段、方法、构造函数、常量池等)。当类加载器加载Person.class
文件时,这些信息就被放入方法区。
- 存储
- Java 栈 (Java Stack):
- 当前线程的栈帧中,局部变量表会存储
person
这个引用变量。它只是一个地址或句柄,指向堆中的对象。
- 当前线程的栈帧中,局部变量表会存储
- Java 堆 (Java Heap):
new Person()
这个操作会在堆中创建一个Person
类的实例对象。对象包含了实例变量等数据。
关键联系:堆中的 Person
对象内部通常会包含一个指向方法区中 Person
类类型信息的指针。这个指针使得 JVM 能够知道这个对象是由哪个类创建的,从而可以访问类的方法、静态变量等信息。
# 3. 方法区的核心理解
# 3.1 方法区的概念与规范
官方定义 (参考 Java SE 8 JVM Specification §2.5.4): 方法区是 JVM 内存区域的一部分,用于存储每个类的结构信息,例如运行时常量池 (Runtime Constant Pool)、字段和方法数据、构造函数和普通方法的代码,包括在类和实例初始化以及接口初始化中使用的特殊方法。
关键特性:
- 线程共享:与堆一样,方法区被该虚拟机中所有线程共享。
- 创建时机:方法区在虚拟机启动时创建。
- 逻辑上属于堆?:
- 《Java虚拟机规范》提到:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”
- 但在 HotSpot JVM 的实现中,为了区分管理,方法区通常被称为 Non-Heap (非堆)。因此,实践中我们通常将方法区视为独立于 Java 堆的内存空间。
- 物理连续性:和堆一样,方法区的物理内存空间可以不连续。
- 大小可调:方法区的大小可以是固定的,也可以是可扩展的。
- 内存溢出 (OOM):如果方法区无法满足内存分配请求(例如加载过多的类),JVM 将抛出
OutOfMemoryError
。- JDK 7 及之前:
java.lang.OutOfMemoryError: PermGen space
(永久代空间不足) - JDK 8 及之后:
java.lang.OutOfMemoryError: Metaspace
(元空间不足) - 常见触发场景:加载大量第三方 JAR 包、部署过多的 Web 应用(如 Tomcat)、大量使用动态代理或反射生成类。
- JDK 7 及之前:
- 关闭时释放:方法区的内存在 JVM 关闭时会被释放。
示例:方法区加载大量类
一个看似简单的 Java 程序,在运行时会加载许多核心库的类。
// 文件名: MethodAreaDemo.java
public class MethodAreaDemo {
public static void main(String[] args) {
System.out.println("start...");
try {
// 保持进程存活足够长时间,以便观察
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
使用 JVisualVM 等工具监控此程序的“类”加载情况,可以看到即使是这个简单程序,也加载了数千个类(包括 JDK 核心类库)。
(一个简单的应用也可能加载 1600+ 个类)
# 3.2 HotSpot JVM 中方法区的实现演进
重要概念区分:
- 方法区 (Method Area):这是 JVM 规范中定义的逻辑内存区域。
- 永久代 (Permanent Generation, PermGen):这是 HotSpot JVM 在 JDK 7 及之前版本中对方法区的具体实现。它使用JVM管理的堆内存的一部分。
- 元空间 (Metaspace):这是 HotSpot JVM 在 JDK 8 及之后版本中对方法区的具体实现。它使用的是本地内存 (Native Memory),不再占用堆空间。
演进历程:
- JDK 1.7 及之前 (永久代 PermGen):
- 方法区通过永久代实现,位于堆内存中。
- 受到 JVM 堆大小参数 (
-Xmx
) 和永久代专用参数 (-XX:PermSize
,-XX:MaxPermSize
) 的限制。 - 缺点:
- 永久代大小难以设定,容易导致
PermGen space OOM
。 - 永久代的垃圾回收(主要是类卸载)条件苛刻且效率不高,容易造成内存泄漏。Full GC 时才会回收。
- 永久代大小难以设定,容易导致
- JDK 1.8 及之后 (元空间 Metaspace):
- 完全废弃永久代,改用元空间实现方法区。
- 元空间使用本地内存 (Native Memory),与堆内存隔离。
- 优点:
- 解决了永久代 OOM 的主要问题:默认情况下,元空间大小只受可用本地内存限制,不易溢出。
- 简化了调优:不再需要精细调整永久代大小。
- 存储内容调整:永久代中的一些数据(如字符串常量池、静态变量)在 JDK 7 和 8 的演变中被移到了堆中,元空间主要存储类的元数据本身。
(无论是永久代还是元空间,如果无法满足内存分配,都会抛出 OOM)
# 4. 设置方法区大小与 OOM
虽然元空间默认使用本地内存,但仍可对其进行大小限制和调优。
# 4.1 JDK 7 及之前的永久代参数
-XX:PermSize=<size>
:设置永久代的初始分配空间。默认值约 20.75M。-XX:MaxPermSize=<size>
:设置永久代的最大可分配空间。32 位系统默认 64M,64 位系统默认 82M。- OOM:当加载的类信息所需空间超过
-XX:MaxPermSize
时,抛出java.lang.OutOfMemoryError: PermGen space
。
# 4.2 JDK 8 及之后的元空间参数
-XX:MetaspaceSize=<size>
:设置元空间的初始大小(不是初始容量,而是初始高水位线 (Initial High Watermark))。达到这个值会触发第一次 Full GC(用于卸载不再使用的类)。默认值依赖平台,64 位服务器 JVM 通常约 21MB。- GC 触发与调整:GC 后,JVM 会根据释放的空间动态调整这个高水位线。如果释放空间不足,会提高水位线(不超过
-XX:MaxMetaspaceSize
);如果释放过多,会降低水位线。 - 调优建议:如果初始值设置过低,可能导致频繁的 Full GC。建议根据应用实际情况,将其设置为一个较高的值(如
-XX:MetaspaceSize=256m
)以减少启动初期的 GC 次数。
- GC 触发与调整:GC 后,JVM 会根据释放的空间动态调整这个高水位线。如果释放空间不足,会提高水位线(不超过
-XX:MaxMetaspaceSize=<size>
:设置元空间的最大容量。默认值是 -1,表示没有限制(只受可用本地内存限制)。- 建议设置:虽然默认无限制,但在生产环境中强烈建议设置一个上限(如
-XX:MaxMetaspaceSize=1g
或更高,取决于应用和物理内存),以防止因类加载失控导致耗尽服务器所有内存。
- 建议设置:虽然默认无限制,但在生产环境中强烈建议设置一个上限(如
- OOM:如果元空间大小达到了
-XX:MaxMetaspaceSize
的限制,并且 Full GC 后仍无法分配所需空间,抛出java.lang.OutOfMemoryError: Metaspace
。
# 4.3 模拟方法区 OOM
通过动态生成大量类来填满方法区(永久代或元空间)。
// 文件名: OOMTest.java
// 需要引入 ASM 库来动态生成类,例如:
// import jdk.internal.org.objectweb.asm.ClassWriter; // JDK 自带(不推荐直接使用内部 API)
// import jdk.internal.org.objectweb.asm.Opcodes;
// 或者使用 Maven/Gradle 引入 org.ow2.asm:asm
import jdk.internal.org.objectweb.asm.ClassWriter; // 仅为示例,生产环境应使用标准 ASM 库
import jdk.internal.org.objectweb.asm.Opcodes;
/**
* 模拟方法区 OOM
* JDK 7: -XX:PermSize=10m -XX:MaxPermSize=10m
* JDK 8+: -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
*/
public class OOMTest extends ClassLoader { // 继承 ClassLoader 以便调用 defineClass
public static void main(String[] args) {
int j = 0; // 计数成功加载的类的数量
try {
OOMTest test = new OOMTest();
for (int i = 0; i < 100000; i++) { // 尝试加载大量类
// 1. 使用 ASM 创建类的字节码
ClassWriter classWriter = new ClassWriter(0); // 0 表示不需要计算 maxs 和 frames
// 定义类的基本信息:版本(V1_8), 访问权限(public), 类名("Class"+i), 包(null), 父类(Object), 接口(null)
classWriter.visit(Opcodes.V1_8, Opcodes.ACC_PUBLIC, "Class" + i, null, "java/lang/Object", null);
// 生成类的字节码 byte 数组
byte[] code = classWriter.toByteArray();
// 2. 使用 ClassLoader 加载生成的类
// defineClass 是 ClassLoader 的 protected 方法,用于将 byte 数组转换为 Class 对象
test.defineClass("Class" + i, code, 0, code.length);
j++;
// 可选:稍微打印进度,观察加载过程
// if (j % 1000 == 0) {
// System.out.println("Loaded " + j + " classes");
// }
}
} finally {
// 打印最终成功加载的类的数量
System.out.println("Successfully loaded classes: " + j);
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
运行情况 1:不设置上限 (JDK 8+)
使用默认 JVM 参数。程序会尝试加载完所有类(取决于循环次数)。 输出示例:
Successfully loaded classes: 100000
运行情况 2:设置小上限 (JDK 8+)
VM Options: -XX:MetaspaceSize=10m -XX:MaxMetaspaceSize=10m
运行后程序会在加载一定数量的类后因元空间不足而抛出 OOM。 输出示例:
Successfully loaded classes: 8531
Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:756) // 行号可能因JDK版本变化
at java.lang.ClassLoader.defineClass(ClassLoader.java:635)
at OOMTest.main(OOMTest.java:31) // 指向 defineClass 调用行
2
3
4
5
6
(如果使用 JDK 7 并设置 -XX:MaxPermSize=10m
,会抛出 PermGen space
OOM)
# 4.4 如何解决方法区 OOM
解决 PermGen space
或 Metaspace
OOM 的步骤:
- 分析 Dump 文件:
- 出现 OOM 时,使用 JVM 参数
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=<path>
生成堆转储快照(Heap Dump)。虽然 OOM 是方法区,但 Heap Dump 中通常也包含了类加载器的信息,有助于分析。 - 使用内存分析工具(如 Eclipse MAT, JProfiler, VisualVM)打开 Dump 文件。
- 出现 OOM 时,使用 JVM 参数
- 区分内存泄漏 (Memory Leak) 与 内存溢出 (Memory Overflow):
- 内存泄漏:某些类或类加载器本应被卸载回收,但由于存在意外的强引用(例如被长期存活的对象、线程、集合等持有),导致 GC 无法回收,最终占满了方法区。
- 内存溢出:程序确实需要加载如此多的类,超出了设置的方法区容量上限。
- 处理内存泄漏:
- 通过分析工具查找占用内存最多的类或类加载器实例。
- 查看这些实例到 GC Roots 的引用链,找出是哪个不该存在的引用阻止了它们的回收。
- 定位到代码中产生这些引用的地方并修复逻辑。常见原因包括:
- 集合类持有对象引用未清理。
- 监听器、回调未注销。
- 自定义类加载器使用不当,其自身或加载的类无法被回收。
- ThreadLocal 使用后未调用
remove()
。
- 处理内存溢出:
- 如果确认不存在泄漏,确实是应用需要加载大量类:
- 增大方法区容量:调整
-XX:MaxPermSize
(JDK 7) 或-XX:MaxMetaspaceSize
(JDK 8+)。确保物理内存充足。 - 优化代码:检查是否可以减少动态类的生成(如优化动态代理使用),是否可以合并或移除不必要的依赖库。
- 检查应用服务器配置:如果部署了大量应用,考虑是否超出服务器承载能力。
- 增大方法区容量:调整
- 如果确认不存在泄漏,确实是应用需要加载大量类:
# 5. 方法区的内部结构
方法区主要存储的是已被 JVM 加载的类相关信息。
根据《深入理解Java虚拟机》的描述,方法区存储内容主要包括:
- 类型信息 (Type Information)
- 域信息 (Field Information)
- 方法信息 (Method Information)
- 运行时常量池 (Runtime Constant Pool)
- 静态变量 (Static Variables) (JDK 7+ 移到堆中,但逻辑上仍与类关联)
- 即时编译器 (JIT) 编译后的代码缓存 (Code Cache)
# 5.1 类型信息 (Type Information)
对于每个加载的类型(类 class、接口 interface、枚举 enum、注解 annotation),JVM 必须在方法区中存储以下信息:
- 完整有效名称 (Fully Qualified Name):例如
java.lang.String
。 - 直接父类的完整有效名称:对于
Object
类或接口,此项为空。 - 类型的修饰符 (Modifiers):例如
public
,abstract
,final
等。 - 直接实现的接口列表 (Interfaces):一个有序列表,包含该类型直接实现的所有接口的完整有效名称。
# 5.2 域信息 (Field Information / 成员变量信息)
JVM 必须保存类型中所有字段(成员变量)的相关信息,并保持声明顺序:
- 域名称 (Field Name)。
- 域类型 (Field Type):例如
int
,java.lang.String
。 - 域修饰符 (Field Modifiers):例如
public
,private
,static
,final
,volatile
,transient
等。
# 5.3 方法信息 (Method Information)
JVM 必须保存类型中所有方法的信息,并保持声明顺序:
- 方法名称 (Method Name)。
- 返回类型 (Return Type):例如
void
,int
,java.lang.String
。 - 参数数量和类型 (Parameter Types):按声明顺序排列。
- 方法修饰符 (Method Modifiers):例如
public
,private
,static
,final
,synchronized
,native
,abstract
。 - 方法的字节码 (Bytecodes):实际的可执行代码(抽象方法和本地方法除外)。
- 操作数栈大小 (Operand Stack Size) 和 局部变量表大小 (Local Variable Table Size)(抽象和本地方法除外)。
- 异常表 (Exception Table):描述方法中
try-catch
块的信息(抽象和本地方法除外)。- 每个异常处理条目包含:
try
块的起始和结束指令位置、catch
块的起始指令位置、需要捕获的异常类型的常量池索引。
- 每个异常处理条目包含:
# 5.4 运行时常量池 (Runtime Constant Pool)
这是方法区中非常重要的一部分。
- 来源:每个
.class
文件都有一个常量池表 (Constant Pool Table),它是 Class 文件结构的一部分,存储了编译时产生的各种字面量 (Literals) 和 符号引用 (Symbolic References)。当类被加载到 JVM 时,这个 Class 文件常量池的内容会被转存到方法区,形成运行时常量池。 - 内容:
- 字面量:如文本字符串 (
"hello"
),final
常量值 (如final int MAX = 100;
中的 100)。 - 符号引用:一种编译时的符号表示,用于描述代码中引用的目标,还未解析为直接内存地址。包括:
- 类和接口的全限定名。
- 字段的名称和描述符(类型)。
- 方法的名称和描述符(参数类型和返回类型)。
- 字面量:如文本字符串 (
- 与 Class 文件常量池的区别:
- 时机:Class 文件常量池是静态的(编译时确定),运行时常量池是动态的(类加载后创建,运行时可能变化)。
- 地址:运行时常量池中的符号引用在解析 (Resolution) 阶段会被替换为直接引用 (Direct References),即指向内存中实际数据(如对象地址、方法代码入口)的指针或偏移量。
- 动态性:运行时常量池具有动态性。例如,
String.intern()
方法可以在运行时将新的字符串添加到字符串常量池(它是运行时常量池的一部分)。
- 作用:运行时常量池是 JVM 执行字节码指令(如
ldc
,getfield
,putfield
,invokevirtual
)时查找常量、类、字段、方法信息的关键数据结构。
常量池 (Constant Pool) vs 运行时常量池 (Runtime Constant Pool)
- 常量池:存在于
.class
文件中,包含字面量和符号引用。是静态的。 - 运行时常量池:存在于方法区中,是类加载后常量池的运行时表示。包含解析后的直接引用(也可能保留符号引用待运行时解析),具有动态性。
为什么需要常量池?
Java 字节码文件需要引用大量外部信息(类名、方法名、字段名、字符串等)。如果将这些信息直接硬编码在字节码指令中,会使字节码文件变得非常臃肿。常量池提供了一种间接引用机制:字节码指令只包含指向常量池中某个条目的索引,JVM 在运行时通过索引查找常量池即可获得所需信息,大大减小了 .class
文件的大小。
运行时常量池的 OOM:如果运行时常量池所需内存超过方法区限制,也会抛出 OutOfMemoryError
。
# 5.5 静态变量 (Static Variables)
- 逻辑归属:静态变量是类级别的变量,与类本身关联,被所有实例共享。从逻辑上看,它们是类信息的一部分,属于方法区管理。
- 物理存储位置 (HotSpot):
- JDK 6 及之前:静态变量存储在永久代(方法区的实现)中。
- JDK 7:静态变量从永久代移到了 Java 堆中。
- JDK 8 及之后:永久代被元空间取代,但静态变量仍然存储在 Java 堆中。
- 访问:即使静态变量的物理存储位置在堆中,访问它们仍然需要通过方法区中的类信息来定位。
# 5.6 即时编译器编译后的代码缓存 (JIT Code Cache)
- JVM 的执行引擎在运行时会将热点代码(经常执行的方法或循环)通过 JIT 编译器编译成本地机器码,以提高执行效率。
- 这些编译后的本地机器码会被缓存起来,这个缓存区域通常也位于方法区(或一个与之关联的区域)。
# 5.7 内部结构反编译示例
通过 javap -v
查看字节码,可以直观了解方法区存储的部分信息。
// 文件名: MethodInnerStrucTest.java
import java.io.Serializable;
/**
* 测试方法区的内部构成
*/
public class MethodInnerStrucTest extends Object implements Comparable<String>, Serializable {
// 实例变量 (存储在堆对象中,但其描述信息在方法区)
public int num = 10;
// 类变量 (静态变量) - JDK 7+ 实际存储在堆,但描述信息和引用在方法区关联的 Class 对象中
private static String str = "测试方法的内部结构";
// 构造器 (方法信息存储在方法区)
public MethodInnerStrucTest() {}
// 实例方法 (方法信息存储在方法区)
public void test1() {
int count = 20; // 局部变量,存储在栈帧中
System.out.println("count = " + count);
}
// 静态方法 (方法信息存储在方法区)
public static int test2(int cal) { // cal 是局部变量
int result = 0; // 局部变量
try {
int value = 30; // 局部变量
result = value / cal;
} catch (Exception e) { // e 是局部变量
e.printStackTrace();
}
return result;
}
// 实现接口方法 (方法信息存储在方法区)
@Override
public int compareTo(String o) { // o 是局部变量
return 0;
}
// 静态代码块 (编译后成为 <clinit> 方法,方法信息存储在方法区)
static {
// str 的初始化在此进行
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
使用命令 javap -v -p MethodInnerStrucTest.class
反编译后的部分内容(已在问题中提供,此处不再重复),可以清晰看到:
- 类信息:
public class com.youngkbt.java.MethodInnerStrucTest extends java.lang.Object implements java.lang.Comparable<java.lang.String>, java.io.Serializable
- 常量池 (Constant pool):大量的条目,包括类引用 (
#17 = Class #69 // com/youngkbt/java/MethodInnerStrucTest
), 字段引用 (#2 = Fieldref #17.#53 // com/youngkbt/java/MethodInnerStrucTest.num:I
), 方法引用 (#1 = Methodref #18.#52 // java/lang/Object."<init>":()V
), 字符串字面量 (#6 = String #57 // count =
) 等。 - 域信息:
public int num;
,private static java.lang.String str;
及其描述符和标志。 - 方法信息:包括构造器
<init>
,test1
,test2
,compareTo
以及编译器生成的桥接方法compareTo(Object)
和类初始化方法<clinit>
。每个方法都包含描述符、标志、字节码 (Code
属性)、行号表、局部变量表、异常表(如有)等。
# 5.8 non-final 的类变量 (静态变量) 访问
静态变量与类本身关联,可以通过类名直接访问,也可以通过对象引用访问(但不推荐,可能引起误解)。
// 文件名: MethodAreaTest.java
public class MethodAreaTest {
public static void main(String[] args) {
Order order = null; // order 引用为 null
order.hello(); // 编译通过,运行时也正常!调用静态方法不需要对象实例
System.out.println(order.count); // 编译通过,运行时正常!访问静态变量不需要对象实例
// 上面两行虽然用了 null 引用,但因为访问的是 static 成员,
// 编译器和 JVM 会解析为 Order.hello() 和 Order.count
}
}
class Order {
public static int count = 1; // 静态变量
public static final int number = 2; // 静态常量
public static void hello() { // 静态方法
System.out.println("hello!");
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
输出:
hello!
1
2
这证明了静态成员是属于类的,不依赖于具体的对象实例。
# 5.9 全局常量 (static final)
使用 static final
修饰的变量是全局常量。
- 基本类型/String字面量常量:如果在声明时就用字面量初始化(例如
public static final int NUMBER = 2;
或public static final String GREETING = "Hello";
),并且这个值在编译期可知,那么这个常量的值会直接嵌入到使用该常量的类的字节码中(常量传播优化),并且在类的常量池中也会存储这个字面量值。访问这种常量通常不会触发类的初始化。 - 引用类型/运行时计算的常量:如果
static final
变量是引用类型,或者其值需要在运行时计算(例如static final Date NOW = new Date();
),那么它仍然是一个静态变量,其初始化在类的<clinit>
方法中进行,值存储在堆中(如果是对象的话),引用存储在与 Class 对象关联的地方(堆中)。
字节码对比:
对于 Order
类中的 count
和 number
:
// javap 输出片段
public static int count; // 普通静态变量
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC
public static final int number; // 静态常量 (基本类型)
descriptor: I
flags: ACC_PUBLIC, ACC_STATIC, ACC_FINAL
ConstantValue: int 2 // <<-- 关键!值在编译时已确定并写入 class 文件
2
3
4
5
6
7
8
9
可以看到 number
有一个 ConstantValue
属性,表明其值在编译期就确定了。
# 5.10 字符串常量池 (String Table / String Pool)
- 运行时常量池的一部分:字符串常量池是运行时常量池的一个特例,专门用于存储字符串字面量和通过
String.intern()
方法添加的字符串。 - 目的:复用相同的字符串对象,节省内存。当代码中出现字符串字面量(如
"abc"
)时,JVM 会检查字符串常量池中是否已存在等值的字符串。如果存在,就直接返回池中对象的引用;如果不存在,就在池中创建一个新的字符串对象,并返回其引用。 - 位置变化:
- JDK 6及之前:字符串常量池位于永久代中。
- JDK 7:字符串常量池从永久代移到了 Java 堆中。
- JDK 8及之后:字符串常量池仍然在 Java 堆中。
- 详细内容:请参考专门的 JVM - StringTable字符串常量池 文档。
# 6. 方法区使用示例(字节码执行流程)
回顾之前的代码示例及其字节码执行过程,可以更清晰地理解方法区(特别是运行时常量池)的作用。
// 文件名: MethodAreaDemo.java (简化版)
public class MethodAreaDemo {
public static void main(String args[]) {
int x = 500;
int y = 100;
int a = x / y; // 5
int b = 50;
System.out.println(a + b); // 输出 55
}
}
2
3
4
5
6
7
8
9
10
字节码执行流程图示 (结合常量池和操作数栈/局部变量表):
sipush 500
:将 short 型整数 500 推到操作数栈顶。istore_1
:将操作数栈顶的 int 值 (500) 存入局部变量表索引为 1 的位置 (即变量 x)。bipush 100
:将 byte 型整数 100 推到操作数栈顶。istore_2
:将操作数栈顶的 int 值 (100) 存入局部变量表索引为 2 的位置 (即变量 y)。iload_1
:将局部变量表索引 1 的 int 值 (500) 推到操作数栈顶。iload_2
:将局部变量表索引 2 的 int 值 (100) 推到操作数栈顶。idiv
:将操作数栈顶的两个 int 值相除 (100 / 500,注意栈是后进先出,所以是 y / x -> 100/500 = 0.2,整数除法结果为 0,啊这里例子和字节码似乎有点对不上,按字节码是 500/100 = 5),并将结果 (5) 压入栈顶。(图中结果是 5,说明是 500/100)
istore_3
:将操作数栈顶的 int 值 (5) 存入局部变量表索引为 3 的位置 (即变量 a)。bipush 50
:将 byte 型整数 50 推到操作数栈顶。istore 4
:将操作数栈顶的 int 值 (50) 存入局部变量表索引为 4 的位置 (即变量 b)。getstatic #2
:获取静态字段java.lang.System.out
。#2
是指向运行时常量池中System.out
字段符号引用的索引。JVM 解析该符号引用,找到System.out
对应的PrintStream
对象引用,并将其压入操作数栈。iload_3
:将局部变量表索引 3 的 int 值 (5) 推到操作数栈顶。iload 4
:将局部变量表索引 4 的 int 值 (50) 推到操作数栈顶。iadd
:将操作数栈顶的两个 int 值 (5 和 50) 相加,结果 (55) 压入栈顶。invokevirtual #3
:调用实例方法java.io.PrintStream.println(I)V
。#3
是指向运行时常量池中该方法符号引用的索引。JVM 解析引用,找到方法入口。操作数栈顶的55
作为参数,PrintStream
对象引用作为调用者,执行方法。return
:main 方法执行完毕,返回。
程序计数器 (PC Register) 在整个过程中始终指向下一条要执行的指令的地址,确保流程控制(包括方法调用返回、线程切换恢复)的正确性。
# 7. 方法区的演进细节再讨论
# 7.1 为什么要用元空间替换永久代?
- 移除 HotSpot 特有实现,向 JRockit 看齐:永久代是 HotSpot JVM 的一个特定实现,而 Oracle 收购的 BEA JRockit JVM 并无此概念。移除永久代有助于整合两者。
- 解决永久代大小设置难题:为永久代分配多大空间是一个老大难问题。设置小了,动态加载类多时易 OOM;设置大了,浪费内存。元空间使用本地内存,默认只受物理内存限制,大大降低了 OOM 风险。
- 改善 Full GC 性能:永久代的回收(特别是类卸载)通常发生在 Full GC 时,且效率不高。将类的元数据移到与堆隔离的本地内存,理论上可以为未来更优化的类元数据管理和回收机制(虽然目前元空间的回收仍主要依赖 Full GC)打下基础,并可能减少 Full GC 对堆内对象回收的影响。
# 7.2 StringTable (字符串常量池) 位置调整的原因
- JDK 7 将 StringTable 移到堆中:
- 主要原因:永久代的垃圾回收效率太低(通常只在 Full GC 时回收)。
- 问题:应用程序通常会创建大量字符串对象,如果这些字符串都进入永久代的 StringTable,并且回收不及时,很容易导致
PermGen space OOM
。 - 解决方案:将 StringTable 移到堆中,这样它就可以参与更频繁的 Minor GC 和 Major GC,使得不再被引用的字符串常量能更快地被回收,缓解了永久代的压力。
# 7.3 静态变量 (Static Variables) 的存放位置演变
- 核心结论:静态变量引用的对象实例 (即
new
出来的对象实体) 始终都存储在 Java 堆中。发生变化的是静态变量这个引用本身存放在哪里。 - 演变过程 (HotSpot):
- JDK 6 及之前:静态变量引用和类信息一起存放在永久代。
- JDK 7:静态变量引用从永久代移到了 Java 堆中,与该类对应的
java.lang.Class
对象实例关联在一起(Class
对象本身也存放在堆中)。 - JDK 8 及之后:静态变量引用仍然在 Java 堆中,与
Class
对象关联。
示例验证:
// 文件名: StaticFieldTest.java
/**
* 演示静态变量引用的对象实例在堆中
* JDK 7 VM Options: -Xms200m -Xmx200m -XX:PermSize=100m -XX:MaxPermSize=100m -XX:+PrintGCDetails
* JDK 8 VM Options: -Xms200m -Xmx200m -XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m -XX:+PrintGCDetails
*/
public class StaticFieldTest {
// 创建一个 100MB 的 byte 数组,并用静态变量 arr 引用它
private static byte[] arr = new byte[1024 * 1024 * 100]; // 100MB
public static void main(String[] args) {
System.out.println("Static byte array allocated.");
// 保持运行以便观察内存
try {
Thread.sleep(1000000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 打印 arr 引用,防止被 JIT 优化掉
System.out.println(StaticFieldTest.arr.length);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
使用 JVisualVM 或 GC 日志分析:
- JDK 7 环境:
(可以看到 Old Gen (老年代) 被占用了约 100MB,而 Perm Gen 使用量很小)
- JDK 8 环境:
(同样,Old Gen 被占用了约 100MB,Metaspace 使用量很小)
深入分析 (使用 JHSDB - JDK 9+):
《深入理解 Java 虚拟机》书中通过 JHSDB 工具分析了类似场景:
// 文件名: StaticObjTest.java (来自书中案例)
public class StaticObjTest {
static class Test {
static ObjectHolder staticObj = new ObjectHolder(); // 静态变量引用
ObjectHolder instanceObj = new ObjectHolder(); // 实例变量引用
void foo() {
ObjectHolder localObj = new ObjectHolder(); // 局部变量引用
System.out.println("done");
}
}
private static class ObjectHolder {} // 空对象,用于占位
public static void main(String[] args) {
Test test = new StaticObjTest.Test();
test.foo();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
分析结论:
staticObj
,instanceObj
,localObj
这三个引用变量所指向的ObjectHolder
对象实例,本身都创建在 Java 堆中。staticObj
这个引用变量本身存储在哪里?书中通过 JHSDB 发现它存储在一个java.lang.Class
对象实例的字段里。- 最终结论 (JDK 7+):HotSpot JVM 选择将静态变量引用与对应的
java.lang.Class
对象实例一起存放在 Java 堆中。
# 8. 方法区的垃圾回收
方法区(无论是永久代还是元空间)确实存在垃圾回收,尽管其回收条件和效率与堆不同。
回收目标:
- 常量池中废弃的常量:
- 主要指运行时常量池中的字面量和不再被任何地方引用的符号引用。
- 回收条件:只要常量没有被任何代码或对象引用,就可以被回收。
- 回收机制:类似于堆中对象的回收。相对简单。
- 不再使用的类型 (Unused Classes/Types):
- 这是方法区回收的主要难点和重点。
- 回收条件(非常苛刻,需同时满足):
- 1. 该类的所有实例都已经被回收:Java 堆中不存在该类及其任何子类的对象实例。
- 2. 加载该类的类加载器 (ClassLoader) 已经被回收:这是最难满足的条件之一。JVM 自带的启动类加载器、扩展类加载器、应用类加载器通常很难被回收。只有在使用了自定义类加载器的场景下(如 OSGi, JSP 热加载, 动态代理等),当自定义类加载器本身可以被回收时,它加载的类才有可能被卸载。
- 3. 该类对应的
java.lang.Class
对象没有在任何地方被引用:无法通过反射等方式访问该类。
- 回收行为:JVM 允许对满足上述三个条件的无用类进行回收,但不保证一定会回收。
- 控制参数:
-Xnoclassgc
:可以禁用类的卸载(不推荐)。-verbose:class
:打印类加载和卸载信息。-XX:+TraceClassLoading
/-XX:+TraceClassUnloading
:跟踪类加载/卸载的详细过程。
- 必要性:在大量使用反射、动态代理、CGLib、热部署、OSGi 等场景中,类的卸载能力至关重要,否则会因不断加载新版本的类而导致方法区内存溢出。
# 9. 方法区小结
- 定义:方法区是 JVM 规范定义的线程共享内存区域,用于存储类信息、运行时常量池、JIT 代码缓存等。
- 实现:HotSpot JVM 中,JDK 7 前用永久代(堆内),JDK 8+ 用元空间(本地内存)。
- 演进:元空间取代永久代解决了大小设定难题和部分 OOM 问题,并将字符串常量池、静态变量移至堆中。
- 内部核心:运行时常量池是 Class 文件常量池的运行时表示,包含字面量和解析后的直接/符号引用,支持动态性。
- 垃圾回收:方法区会进行垃圾回收,主要回收废弃常量和不再使用的类,类的卸载条件非常苛刻。
- OOM:当方法区空间不足时,会抛出
PermGen space
(JDK 7-) 或Metaspace
(JDK 8+) OOM。
# 10. 常见面试题回顾
(此处列出的面试题是对原文的整理,可作为复习要点)
- JVM 内存模型/结构:包含哪些区域?(堆、栈、方法区、程序计数器、本地方法栈)
- 各区域作用:分别存储什么数据?(类信息、对象实例、线程执行状态、本地方法调用状态等)
- 栈与堆的区别:生命周期、存储内容、共享性、空间大小、异常类型(StackOverflowError vs OutOfMemoryError)。
- 堆的结构:新生代(Eden, S0, S1)、老年代。为什么要分代?
- 新生代内部比例:Eden vs Survivor 默认比例 (8:1:1)?为什么需要两个 Survivor 区?(复制算法需要)
- 对象晋升老年代的条件:年龄阈值、动态年龄判断、大对象、Survivor 空间不足。
- Java 8 内存模型变化:元空间取代永久代,存储位置(本地内存),字符串常量池和静态变量仍在堆中。
- 方法区/永久代/元空间是否发生 GC:是。回收什么?(废弃常量、无用类)。类卸载条件?
- JVM 内存分区:结合具体场景解释各区域交互(如对象创建过程)。