JVM方法区深度解析
[toc]
# 写在文章开头
本文将深入jvm将从不同版本的jdk对方法区展开介绍,希望对你有所启发。
我是 SharkChili,Java 开发者,Java Guide开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 元空间基本概念
# 什么是JVM方法区
方法区主要是用于存储类信息、静态变量以及常量信息的。是各个线程共享的一个区域。我们都知道JVM中有个区域叫堆区,所以有时候人们也会称方法区为Non-Heap(非堆)。
在JDK8之前方法区存放在一个叫永久代的空间里。
在JDK8之后由于HotSpot 和JRockit 的合并,所以方法区就被作为元数据区了。
# 方法区和永久代是什么关系?
其实方法区并不是一个实际的区域,他不过是JVM虚拟机规范提出的一个概念而已。在HotSpot 实现方法区的方式就在JVM内存中划分一个区域作为永久代来存放这些数据。
在JDK8之前我们可以用下面的参数来调整永久代的大小
-XX:PermSize=N //方法区 (永久代) 初始大小
-XX:MaxPermSize=N //方法区 (永久代) 最大大小,超过这个值将会抛出 OutOfMemoryError 异常:java.lang.OutOfMemoryError: PermGen
2
# 为什么JDK8之后要把永久代 (PermGen)换成元数据区(MetaSpace)
将数据放在永久代固然没问题,但是随着时间的推移,方法区使用的空间可能会逐渐变大,若我们分配大小不当很可能造成线上OOM问题,所以设计者们就在方法区移动到本地内存中,通过本地内存来存放数据。并且元数据区默认分配值为unlimited(我们也可以通过-XX:MetaspaceSize来动态调整),理论上是没有明确大小,是可以动态分配空间的,这样一来由于元数据区就不会受到JVM内存分配的约束了,所以理论上发生OOM的概率会小于永久代。
# 深入理解Java虚拟机关于方法区的说法
笔者查阅权威 《深入理解Java虚拟机》 中看到,《Java虚拟机规范》 对于方法区的实现即元空间或者永久代垃圾回收行为没有强制要求。 原因很简单,方法区进行垃圾收集的回收的收益不是很大,它并不像堆内存的新生代那样,在一次新生代的垃圾回收就能回收 70%-90% 的内存空间。这也使得大部分人(包括笔者)认为方法区不涉及GC的,实际上对于 jdk8 版本的Hotspot虚拟机而言,JVM 中某一个类符合以下这3个条件时将会卸载类并回收这个类的元数据空间:
- 在堆中没有任何基于当前类或者基于该类派生子类的实例。
- 该类的java.lang.Class对象没有在任何地方被引用,以及无法通过反射等方式访问该类的方法。
- 加载该类的类加载器被回收,这个条件除非是精心设计过的可替换类加载器的场景,否者很难实现。
需要注意的是,在判断是否有实例还在使用当前类以及是否有类加载器引用这个类这两个步骤的时候,为了能够明确这两点,可能需要扫描全部堆空间的,这也就意味着元空间的回收可能伴随着FullGC。
# 实践
# jdk8之后静态变量存储在元空间?
静态变量属于类变量而非实例变量,这意味着它的生命周期跟随类而非任何对象实例,对应我们给出下面这段代码印证,试想我们基于Counter无参构造创建了个对象实例,他们的输出的id上多少呢?
public class Counter {
//静态变量count
private static int count = 0;
//实例变量id
private int id;
public int getId() {
return id;
}
public Counter() {
this.id = ++count;
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
答案是毫无疑问的,对应的两个对象实例对应的id上1和2,原因很简单,静态变量上所有相同类的实例对象所共享,其生命周期伴随着类加载开始,随着类卸载而结束:
{"id":1}
{"id":2}
2
那么我们的问题也就来了,jdk8版本将类的元数据信息存到元空间上,同时我们也知道类的元信息记录着类的字段、方法等描述信息以及静态字段,那么对应静态对象是存在堆上还是元空间上呢?
对此我们不妨做这样一个实验:
- 将堆内存设置为50M
- 创建一个20M的静态byte数组
- 通过监控工具查看堆内存分布情况
对应给出如下代码,这里笔者基于arthas的heapdump指令将其堆内存快找导出并用mat打开:
private static byte[] largeByteArray = new byte[20 * 1024 * 1024];
public static void main(String[] args) throws InterruptedException {
TimeUnit.DAYS.sleep(1);
}
2
3
4
5
6
可以看到静态数组byte对应的堆内存占用大约是20m左右,由此说明在jdk8版本对应静态实例变量分配在堆空间:

结合权威资料的说法,在jdk8版本下,当一个类被加载时,会基于calss文件在元空间中生成该类的元信息,包括但不限于:
- 类的方法定义
- 常量池
- 访问标志
- 静态字段
本着java一切皆对象的原则,jvm会基于该元信息在堆内存中生成这个java.lang.class对象作为元信息的容器,包括方法名、字段名、访问标志,而与之对应的静态字段的引用也会存储在这个类对象中,并指向堆中这个静态字段对应的实例。当多个实例对象访问静态字段时,本质上都是基于这个class对象元数据信息从而获取静态实例的值:

# 代理对象创建不当导致元空间OOM问题
可以看到最后一点比较苛刻,所以就导致如果我们使用Spring等框架通过增强技术生成大量的新类型载入元空间内存,导致元空间内存溢出 (Caused by: java.lang.OutOfMemoryError: Metaspace) ,就像下面这段代码一样,为了更快看到效果,我们手动设置一下元空间大小-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=100m:
public static void main(String[] args) {
while (true){
Enhancer enhancer = new Enhancer();
//设置代理目标
enhancer.setSuperclass(EmptyObject.class);
enhancer.setUseCache(false);
//设置单一回调对象,在调用中拦截对目标方法的调用
enhancer.setCallback((MethodInterceptor) (o, method, objects, methodProxy) -> methodProxy.invokeSuper(objects, args));
enhancer.create();
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
我们通过jconsole定位查看当前进程的类加载信息:

可以看到大量EmptyObject的增强类被加载至元空间中:

键入命令jmap 定位加载的类信息再次进行确认:
jmap -histo 4532
可以看到生成了大量的net.sf.cglib.proxy相关的类
num #instances #bytes class name
----------------------------------------------
1: 3824742 600680704 [C
2: 1932145 170028760 java.lang.reflect.Method
3: 3806008 91344192 java.lang.String
4: 1779516 37754664 [Ljava.lang.Class;
5: 26568 15064520 [I
6: 618402 14841648 net.sf.cglib.core.Signature
7: 79344 12595728 java.lang.Class
8: 154765 12381200 java.lang.reflect.Constructor
9: 308844 9883008 net.sf.cglib.proxy.MethodProxy
10: 308844 9883008 net.sf.cglib.proxy.MethodProxy$CreateInfo
2
3
4
5
6
7
8
9
10
11
12
13
14
我们以MethodProxy进行定位可以看到这个类是在create方法创建的,这也就意味着上述代码的最后一个create方法会创建大量的MethodProxy并存到元空间中导致元空间内存溢出:
public static MethodProxy create(Class c1, Class c2, String desc, String name1, String name2) {
MethodProxy proxy = new MethodProxy();
proxy.sig1 = new Signature(name1, desc);
proxy.sig2 = new Signature(name2, desc);
proxy.createInfo = new CreateInfo(c1, c2);
return proxy;
}
2
3
4
5
6
7
所以尽管说jdk8将类信息存到原空间中,但我们日常进行开发也需要留意对于cglib等增强技术的使用是否得当,如果发现大量的增强类出现在元空间时,需要及时定位并解决。
# 小结
本文从JVM发展历程和底层结构并结合一个实际案例带读者深入了解JVM元空间核心知识点,希望对你有所启发。
我是 SharkChili,Java 开发者,Java Guide开源项目维护者。欢迎关注我的公众号:写代码的SharkChili,也欢迎您了解我的开源项目 mini-redis:https://github.com/shark-ctrl/mini-redis。
为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。
# 参考
《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版) 》
为啥Metadata GC会触发Full GC? - Hashcon的回答 - 知乎: https://www.zhihu.com/question/442664600/answer/1713131403 (opens new window)
Where Java Stores Static vs. Instance Data in Memory:https://medium.com/@AlexanderObregon/where-java-stores-static-vs-instance-data-in-memory-bcfb00ee73cf (opens new window)