1. 方法区介绍
《Java 虚拟机规范》中明确说明:“尽管所有的方法区在逻辑上是属于堆的一部分,但一些简单的实现可能不会选择去进行垃圾收集或者进行压缩。”但对于 HotSpot JVM 而言,方法区还有一个别名叫做 Non-Heap(非堆),目的就是要和堆分开。
因此、可以把方法区看作独立于堆的内存空间JVM 规范中的一个抽象概念,属于线程共享区域,其内存大小可固定也可动态配置(具体指令取决于方法区在不同虚拟机下不同版本的实现),主要用来存储 Class 的静态数据结构,也就意味着当加载过多的类时,会造成
java.lang.OutOfMemoryError: xxx(PermGen space、Metaspace)
异常。JVM启动时创建关闭时销毁,其大小可固定可扩展,其内存空间是可以不连续的,方法区的大小决定了可以存储多少个类。
全文主要以 HotSpot 虚拟机为例来对方法区进行讲诉
2. 方法区的演进(HotSpot)
在 HotSpot 中,无论是 JDK1.7 及以前的永久代还是 JDK1.8 及以后的元空间,都是对方法区的实现。
二者最大的区别是:元空间没有使用虚拟机内存,而是使用了本地内存
3. 方法区的内部结构(HotSpot)
3.1 运行时常量池
JVM 启动后会在方法区分配出一片区域用来存储程序运行期间的字面量(常量)和引用,一个类在编译期成为 class 文件后,会在 class 文件中形成一个常量池(Constant pool),经过类加载后将常量池数据加载到运行时常量池,区别就是运行时常量池存储的为真实地址(直接引用),当然还有一部分类加载期间不能确定的符号引用,这里就是运行时常量池的另一大特征动态性,会在程序运行期间动态的把符号引用转化为直接引用。
3.2 类信息
包含了加载的 class、enum、interface、annotation 中所有的信息。如下:
描述 | |
类信息 | 修饰符(public、private、protected、static、final、native、synchronized)、类的全限定名(包名.类名) |
域信息(类中的成员变量) | 修饰符、类型、名称 |
类里面的方法 | 修饰符、返回类型、方法名、参数(类型、数量且按顺序记录)、操作数栈、局部变量表及其大小 |
non-final (static) 修饰的类变量(静态变量) | 随着类的加载而加载、即便没有实例化也可以访问 |
注意:static final 修饰的常量、在编译期就会分配到 class 文件的 常量池(Constant pool)、通过类加载器加载到运行时常量池
3.3 JIT 代码缓存(即时编译)
JIT 的由来:一个程序在运行期间,每次调用都是需要将我们写的代码通过解释器转化为机器指令去执行,针对一些(是一些还是全部取决于内存大小设置)使用率特别高的热点代码,如果每次都经过解释器编译就是很耗费时间。JIT 概念因此而衍生出来,就是把热点代码通过解释器转化成的机器指令缓存下来,后面在调用的时候就不再经过解释器。
3.3.1 如何识别热点代码
JVM 为每个方法设定了两个计数器、并配置各自的阀值、一旦达到阀值就会被识别为热点代码
- 方法调用计数器:统计方法的调用次数
- 回边计数器:主要用于循环体、在计算机指令中控制流向后跳转的指令称为回边
3.3.2 JIT 方法内联来优化性能
// 原代码
public int add1(int a,int b,int c,int d) {
return add2(a,b) + add2(c,d);
}
public int add2(int a,int b) {
return a + b;
}
// 方法内联优化后
public int add1(int a,int b,int c,int d) {
return a + b + c + d;
}
方法内联就是把被调用的方法复制到调用方中,方法的调用是要进行虚拟机栈的入栈和出栈的,而入栈和出栈都是要有性能开销的,通过方法内联也就少了一次方法调用,也就因此提升了性能。
4. 方法区大小设定引发的OOM
4.1 JDK1.7 及以前(永久代)
-XX:Permsize
永久代空间初始大小、默认 20.75 M-XX:MaxPermsize
永久代最大可分配空间、32位系统默认 64M、64位系统默认 82M
当使用空间超过了这个值 OutOfMemoryError:PermGen space
jps
jinfo -flag -PerSize <Pid>
4.2 JDK1.8 及以后(元空间)
-XX:MetaspaceSize
元空间初始大小、64位系统默认 21M-XX:MaxMetaspaceSize
元空间最大可分配空间、默认 -1 无限制
默认情况下、元空间是可以耗尽所有可用内存、一旦耗尽就会产生 OutOfMemoryError:Metaspace
当元空间使用量达到了设置的初始值、此时就会引发 Full GC 卸载没用的类并释放内存空间,此时初始值会进行动态计算;
初始值的动态计算:取决于内存空间释放了多少、释放多了就比之前低一点、释放少了就比之前高一点
如果初始值设置过低:会导致频繁 Full GC、可通过观察垃圾回收日志来设置一个合理的初始值
5. 方法区在代码运行期间的操作
public static void main(String args[]) {
int x = 500;
int y = 100;
int a = x / y;
int b = 2;
int c = -1;
int d = -2;
System.out.println(a+b+c+d);
}
0: sipush 500 // 把 500 压入操作数栈
3: istore_1 // 把 500 存到局部变量表 1 号位置
4: bipush 100 // 把 100 压入操作数栈
6: istore_2 // 把 100 存到局部变量表 1 号位置
7: iload_1 // 把局部变量表 1 号位的数据压入操作数栈
8: iload_2
9: idiv // 把操作数栈顶部两个 int 类型数据相除、并把结果入栈
10: istore_3 // 把结果从栈顶存到局部变量表 3 号位置
11: iconst_2 // 把 2 压入操作数栈
12: istore 4
14: iconst_m1 // 把 -1 压入操作数栈
15: istore 5
17: bipush -2 // 把 -2 压入操作数栈
19: istore 6
21: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream; 获取类或接口的字段值并入栈
24: iload_3
25: iload 4
27: iadd // 把栈顶两个 int 类型数据相加
28: iload 5
30: iadd
31: iload 6
33: iadd
34: invokevirtual #3 // Method java/io/PrintStream.println:(I)V 调用静态方法,JVM 会根据方法描述创建新的栈帧,方法参数会先由当前操作数栈弹出压入虚拟机栈,然后开始执行虚拟机栈最上面的栈帧,执行完毕后会再回到当前 main 方法栈帧
37: return // void 函数返回、main 方法执行结束
6. 运行时常量池 和 常量池
位置 | 存放 | 特性 | |
常量池 | 字节码文件内 | 字面量(八种基本数据类型、字符串、final修饰) 和 符号引用(类引用、字段引用、方法引用) | |
运行时常量池 | 方法区内 | 字面量 和 符号引用 和 直接引用 | 动态性 |
常量池表(Constant Pool Table)是 Class 文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
动态性:String 的 intern() 方法可将新的常量放到运行时常量池
7. 方法区的演进历史
JDK1.7 之前 | 有永久代,静态变量存储在永久代 |
JDK1.7 版本 | 有永久代,字符串常量池、静态变量转为堆存储 |
JDK1.7 之后 | 无永久代,在1.7的基础上转变为使用本地内存的元空间 |
7.1 方法区的实现为何从永久代转变为元空间
方法区主要加载的是类的元信息,意味着当动态加载的类特别多的时候需要占据极大的内存空间,当内存空间设置不合理的时候极容易出现 OOM,内存空间的设置变成了重中之重。
- 永久代是基于虚拟机内存来实现的,它的上限就是虚拟机的内存大小,对永久代调优属实有点困难。
- 元空间则是基于本地内存来实现的,默认情况下仅受本地内存大小的限制。
7.2 静态变量在哪儿存放
public class StaticObjTest {
static ObjectHolder staticobj = new ObjectHolder(); // staticobj 随着 StaticObjTest 的类型信息存放在方法区中
ObjectHolder instanceobj = new ObjectHolder(); // instanceobj 随着 StaticObjTest 的对象实例存放于堆中
void foo() {
ObjectHolder localobj = new ObjectHolder(); // localobj 存放于 foo() 方法栈帧中的局部变量表中
System.out.println("done");
}
private static class ObjectHolder {
public static void main(String[] args) {
StaticObjTest staticObjTest = new StaticObjTest();
staticObjTest.foo();
}
}
}
通过使用 JHSDB 工具进行分析,三个对象的数据在内存中的地址都落咋 Eden 区范围内,
所以可以得到结论,只要是对象实例就必然在堆中分配。
在《Java 虚拟机规范》中定义,所有 Class 相关的信息都应该存放在方法区之中,但是具体怎么实现却未曾有定义,因此 HotSpot 虚拟机在 1.7 之后将静态变量存放到堆中。
public class StaticObjTest {
static Long abc = System.currentTimeMillis();
public static void main(String[] args) {
StaticObjTest s1 = new StaticObjTest();
StaticObjTest s2 = new StaticObjTest();
System.err.println(s1.abc);
System.err.println(s2.abc);
System.err.println(StaticObjTest.abc);
System.err.println(s1.abc.equals(s2.abc)); // true
System.err.println(StaticObjTest.abc.equals(s2.abc)); // true
}
}
7.3 字符串常量池的位置调整
因为永久代的回收效率极低,只有在 Full GC 的时候才会回收。而 Full GC 是因为老年代、永久代空间不足时才会触发。
当大量字符串创建的时候,StringTable 越来越大,无用的字符串不能得到及时清理,占用大量内存空间。
把字符串常量池放在堆内存,可以及时清理回收内存。
8. 方法区的垃圾回收
有的言论认为方法区是没有垃圾回收的,其实根据《Java 虚拟机规范》中所描述的是 “可以不在方法区中实现垃圾回收行为”,也就意味着确实有虚拟机对方法区未进行或者未完整进行垃圾回收(如 JDK11 的 ZGC 收集器就不支持类卸载)。从实际出发讨论,类卸载的条件特别苛刻,确实有很大的实现难度,可是根据 以往 Sun 公司公布的 Bug 列表中,很多重大 Bug 就是因为低版本的 HotSpot 虚拟机对方法区的垃圾回收不彻底而造成了内存泄漏,所以方法区的垃圾回收还是很有必要。
方法区的垃圾回收主要回收两部分内容:常量池中废弃的常量 和 不再使用的类。
- 常量池中废弃的常量:只要常量池中的常量没有被任何地方引用就可以回收
- 方法区常量池主要存放两大类常量:字面量和符号引用。
- 字面量:也就是代码中常见的文本字符串、以及被 final 修饰的常量等;
- 符号应用:则是指类和接口的全限定名、字段和方法的名称和描述符。
- 不在使用的类(必须先同时满足以下三个条件)
- 该类的所有实例都已经被回收、意味着堆中不能有该类以及任何派生子类的实例
- 该类的所属类加载器已经被回收、这个条件除非是那种可替换类加载器的恶场景,如 OSGI、JSP的重加载等,否则很难达成
- 该类对应的 java.lang.Class 对象没有在任何地方被引用,也就是没有在任何地方通过反射访问该类的方法
当同时满足以上三个条件时,也仅仅是“被允许”进行回收,具体是否要进行回收,HotSpot 虚拟机提供了
-Xnoclassgc
参数来控制。
还可以通过-verbose:class
、-XX:+TraceClassLoading
、-XX:+TraceClassUnLoading
查看类加载和卸载信息。
在大量使用反射、动态代理、CGLib 等字节码框架,动态生成 JSP 以及 OSGI 这类频繁自定义类加载器的场景中,通常都要 Java 虚拟机具备类型卸载的能力,来保证不会对方法区造成过大的内存压力。