深入理解 JVM 内存管理 - 方法区
得益于JVM的自动内存管理机制,开发者在写代码时,很少再去关注内存分配与释放,一般情况下,也不会出现内存泄露和溢出问题。不过,由于开发者把内存的控制权交给了JVM,一旦出现内存泄露和溢出问题,如果不了解JVM是怎样使用内存的,将很难排查和修正错误。本文从概念上介绍JVM内存的各个区域及其作用。
JVM在执行Java程序时会把其所管理的内存划分成多个不同的数据区域,每个区域的创建时间、销毁时间以及用途都各不相同。比如有的内存区域是所有线程共享的,而有的内存区域是线程隔离的。线程隔离的区域就会随着线程的启动和结束而创建和销毁。JVM所管理的内存将会包含以下几个运行时数据区域,如下图的上半部分所示。
一、方法区(Mehod Area)
方法区是所有线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、JIT编译后的代码缓存等数据。在Java虚拟机规范中,方法区属于堆的一个逻辑部分,但很多情况下,都把方法区与堆区分开来说。而平时开发中通过反射获取到的类名、方法名、字段名称、访问修饰符等信息都是从这块区域获取的。
在JDK8以前,使用HotSpot虚拟机的同学都喜欢将方法区称为永久代(Permanent Generation),但实际上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队是用永久代来实现方法区而已,这样使得垃圾收集器能够像管理堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。但对于其他的虚拟机(JRockit、J9)来说,并不存在永久代这一概念的。
但现在看来,使用永久代来实现方法区并不是一个好注意,由于方法区会存放Class的相关信息,如类名、访问修饰符、常量池、字段描述、方法描述等,在某些场景下非常容易出现永久代内存溢出。如Spring、Hibernate等框架在对类进行增强时,都会使用到CGLib这类字节码技术,增强的类越多,就需要越大的方法区来保证动态生成的Class可以加载入内存。
举个简单的例子来测试,下面的代码通过CGLIB的Enhancer
来指定要代理的目标对象以及对代理对象增强的逻辑,最终通过调用create()
方法得到代理对象,对这个对象所有非final方法的调用都会转发给MethodInterceptor.intercept()
,因此,我们在这里可以加入各种增强逻辑,比如打印日志和安全检查等。而通过调用proxy.invokeSuper()
会把调用转发给代理对象。
运行时设置VM参数控制Metaspace大小(JDK1.8以上),运行过程中,可以使用VisualVM来监控Metaspace的空间大小变化情况,大致如下图所示。
监控时,先启动VisualVM,然后debug运行程序,接着在VisualVM找到对应的进程,切换到监控选项卡后,最后放开断点,观察监控情况。
而运行结果则是抛出内存溢出错误:
Metaspace不使用堆内存,而是Native Memory,其大小受本机内存大小限制,默认情况下,一般不会出现内存溢出错误,但在使用Hibernate或者Spring AOP时,最好可以关注一下Metaspace的使用情况。
虽然很少会有人再使用JDK1.6了,但这里还是提一句。在JDK1.6中,方法区虽然被称为永久代,但并不意味着这些对象真的能够永久存在,JVM的内存回收机制,仍然会对这一块区域进行扫描,只不过回收这部分内存的条件相当苛刻罢了。
1.1、运行时常量池 (Runtime Constant Pool)
回过头来看下图1的下半部分,方法区主要包含:
运行时常量池(Runtime Constant Pool)
类信息(Class & Field & Method data)
编译器编译后的代码(Code)等等
后面两项都比较好理解,但运行时常量池有何作用,其意义何在?抛开运行时3个字,首先了解下何为常量池。
Java源文件经编译后得到存储字节码的Class文件,Class文件是一组以8位字节为基础单位的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件中。也就是说,哪个字节代表什么含义,长度多少,先后顺序如何都是被严格限定的,是不允许改变的。比如:开头的4个字节存放魔数,用于确定这个文件是否能够被JVM接受,接下来的4个字节用于存放版本号,再接着存放的就是常量池。常量池的长度是不固定的,所以,在常量池的入口存放着常量池容量的计数值。
常量池主要用于存放两大类常量:字面量和符号引用量,字面量相当于Java语言层面常量的概念,比如:字符串常量、声明为final的常量等等。符号引用是用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可。
说到这儿,首先简单介绍下如何查看分析字节码,其实这部分内容最好是到B站找视频看,这样会更直观一些,但这里也作一些简单介绍。与其他文章不同,这里会分析一段稍显复杂的代码:
代码非常简单,但已经包含一些理解字节码非常基础的操作,比如静态代码库、同步代码库、同步方法、异常处理,如果你能很好的理解这段代码产生的字节码,那么你也可以非常轻松的分析其他字节码,接下来我会分块介绍这段代码生成的字节码,主要是因为太长了。
1.1.1 魔数与Class文件的版本
每个Class文件的头4个字节被称为魔数,它的唯一作用是确定这个文件是否为一个可被虚拟机接受的Class文件,其值为0xCAFEBABE,在上面的字节码中并未体现。
1.1.2 常量池
常量池中每个项目其类型如下
Methodref:类中方法的符号引用
Class:类或接口的符号引用
NameAndType:字段或方法的部分符号引用
Utf8:UTF-8编码的字符串
Fieldref:字段的符号引用
String:字符串类型的字面量
常量池被喻为Class文件的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一。
1.1.3 常量与变量
1.1.4 构造方法
LocalVariableTable属性可以在Javac中使用-g:none或-g:vars选项来取消或要求生成这项信息。如果没有生成这项属性,最大的影响就是当其他人引用这个方法时,所有的参数名称都将会丢失,譬如IDE将会使用诸如arg0、arg1之类的占位符代替原有的参数名,这对程序运行没有影响,但是会对代码编写带来较大不便,而且在调试期间无法根据参数名称从上下文中获得参数值,在接下来会有更自观的感受。
1.1.5 同步方法
关于具体的指令这里不再作过多的说明,每条指令代表的具体含义,大家可以参考The Java Virtual Machine Instruction Set。这个方法的字节码中有亮点需要注意:
可能你曾经多少听说过,synchronized关键字实现的原理是在字节码中生成
monitorenter
和monitorexit
指令来实现的,但这里的synchronized方法并没有生成这两条指令,而是用ACC_SYNCHRONIZED
来描述,其实原理是一样的这里对LocalVariableTable的理解会更直观一些,里就是描述了这个方法的两个参数名,一个是this,一个是param
1.1.6 同步代码块
StackMapTable属性在JDK 6增加到Class文件规范之中,它是一个相当复杂的变长属性,位于Code属性的属性表中。这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器(Type Checker)使用,目的在于代替以前比较消耗性能的基于数据流分析的类型推导验证器。
可能大家对类型推导的感知不强,毕竟大家所写的Java代码对每个变量都定义了类型,但实际上JVM在加载字节码时需要验证每个变量与其实际定义的类型是否一致,比如,如果一个变量被自定为字符串类型,那么我们就不能给它赋值为数字,这属于验证。而如果你关注最新的JDK特性的话,Java在很早以前就引入了var
关键字,比如:
如果你对这部分内容感兴趣的话,可以参考:能介绍一下StackMapTable属性的运作原理吗?
1.1.7 异常处理
① dup与astore指令
其实每个new指令之后,都会跟一个dup指令,而紧接着就是invokespecial指令来调用构造方法,比如:
其字节码大致是:
而创建一个对象的大致分为3个步骤:
创建并初始化Object类型的对象
调用Object的构造方法
将obj指向新创建的对象
而new指令的作用是创建指定类型的对象实例,对其进行默认初始化(注意跟调用构造函数的初始化不同,在类加载部分已经详细讲解,如有疑问,可翻看前面几篇文章),并将指向该实例的一个引用压入操作数栈顶。
然后invokespecial指令会消耗掉栈顶的引用,因为构造方法有一个默认的参数this,jvm会把操作数栈顶的对象应用拿出来作为构造方法的this参数。如果我们希望在invokespecial调用后,在操作数栈顶还有一个指向新建对象的应用,就得在调用这个指令之前先复制一份该对象的引用。
这就是dup指令的作用。
在此基础上,如果我们把这个引用保存到局部变量中去,就会有对应的astore指令,它会把操作数栈顶的那个引用消耗掉,保存到指定的局部变量去。就比如:
在调用完InputStreamReader的构造方法后,会把InputStreamReader对象保存到局部变量中,因为代码是这样写的:
保存到局部变量后,在创建BufferedReader对象,注意在dup指令后,有一个aload指令,这是因为在构造BufferedReader对象时需要InputStreamReader对象,所以需要首先把InputStreamReader对象从局部变量中给捞出来。
② 异常表
字节码是代码编译过后得到的二进制文件,是静态的,简单的理解也就是把源码翻译了一下,但异常在哪儿抛出来,抛出来以后又往什么地方走,这些都是在运行过程中才知道的。因此,在上面的字节码中可以看到很多的goto语句。而到底异常在哪儿抛出来,抛出来后又改如何处理?这就是异常表,JVM会在出现异常的方法中,查找异常表,看看能否找到合适的处理者来处理,异常表的具体内容如下:
其中 from 表示可能发生异常的起始索引,to表示可能发生异常的结束索引,target表示抛出对应类型异常后开始处理异常的字节码索引,连起来也就是说:如果从第0-62行中,抛出了FileNotFoundException异常,那么程序就跳转到第73行。回过去,看看第73行指令是什么:
如果某个异常,既不属于FileNotFoundException,也不属于IOException,JVM会继续查找异常表中的剩余条目,比如这里的any,表示可以处理任何异常,即无论发生任何异常都跳转到105行,开始处理异常。
异常表的后面3个条目,表示catch或者finally中出现异常的话,仍然跳转的105行继续处理。
1.1.8 静态代码块
静态代码块,表示给静态变量mint赋值为1,指令iconst_n表示将int类型数字n推送至栈顶,n取值0~5。
1.1.9 字节码小结
到这,基本上把日常开发中最基础最常用的字节码都介绍完了,如果你能够很好的理解上面的内容,在结合Oracle官方的指令说明文档,基本上能够很好的阅读理解复杂的字节码。当然,如果你的代码本身使用了很多新的特性以及很复杂的逻辑,生成的字节码肯定会很复杂,这时候你想理解这些字节码仍然不易,一个简单的方法是另写一个类,保留主要逻辑,去掉一些判断、循环、异常处理等细枝末节再看看,也许会简单一些。
如果你对上面内容的理解仍有困难,建议去B站上面看看,有相当多的视频教你一个字节一个字节的读,对你理解最基础的内容应该有帮助。
1.1.10 运行时常量池小结
常量池是字节码中的内容,是存储在Class文件中的,而Class文件中存储的各种信息,最终都需要加载到虚拟机中之后才能运行和使用。运行时常量池就可以理解为常量池被加载到内存之后的版本,但并非只有Class文件中常量池的内容才能进入方法区的运行时常量池,运行期间也可能产生新的常量,它们也可以放入运行时常量池中。
很多同学在学习这部分内容的时候,对「直接引用」和「符号引用」这两个概念不是很清楚,建议大家看看:JVM里的符号引用如何存储 ,应当对你有帮助。
深入理解JVM系列的第4篇,从目录阅读请移步:深入理解JVM系列文章目录
参考资料
周志明著;深入理解Java虚拟机(第三版);机械工业出版社;2019-12
版权声明: 本文为 InfoQ 作者【NORTH】的原创文章。
原文链接:【http://xie.infoq.cn/article/2704b86c71b33d70930b5cdcf】。未经作者许可,禁止转载。
评论