概念
方法区是所有Java虚拟机线程共享的区域(这点跟堆有点像),这里存储了跟类的结构相关的一些信息,比如成员变量、方法数据以及成员方法、构造方法的代码部分,还有特殊方法(主要是指类的构造器)。因此可看出来,方法区存的是跟类相关的一些信息,还有运行时常量池。
方法区是在虚拟机启动时被创建,方法区在逻辑上是堆的组成部分(可以理解为他在概念上定义了方法区,但是具体不同的JVM厂商去实现方法区时,不一定遵从这种逻辑上的定义,实际上是不是堆的一部分,不同的JVM厂商实现方式上是不一样的),这个规范并不强制你这个方法区的位置(比如有些JVM厂商会使用堆内存的一部分当做方法区,所以不同的实现在方法区的位置有选择上的不同)。
网友高论
- 方法区是规范,永久代[PermGen,JDK1.6,占用的是堆内存]和元空间[Metaspace,JDK1.8,占用的是系统的内存]是方法区的实现。
- 永久代就是方法区的实现,占用的是堆控件。方法区也是一种规范,具体的实现是永久代和元空间,不同的厂商实现方式不同。
- jdk1.8版本之前方法区的堆的内存,叫永久代,JDK1.8之后用的是操作系统的内存,叫元空间。方法区是概念,永久代和元空间是实现。
- 1.7把字符串常量池放堆中,其他常量池保留永久代。
- 方法区和永久代的关系类似Java里的接口和实现吧。
- JDK8之后,字符串常量池和静态变量移到堆中了。
- stringtable一直都在本地内存[即操作系统内存],stringtable中存储的是对字符串对象的指针,对应的string对象在堆中。
如果方法区申请内存时发现不足,他也会让虚拟机抛出OutOfMemoryError异常,即会导致内存溢出的错误。
方法区内存溢出
方法区里存的是类的数据,但类能有多大呢,为何会出现内存溢出?
1.8之后会导致元空间内存溢出
// 演示元空间内存溢出
public class Demo extends ClassLoader {
public static void main(String[] args) {
int j = 0;
try {
Demo test = new Demo();
for(int i = 0; i < 10000; i ++,j ++) {
ClassWriter cw = new ClassWriter(0);
cw.visit(Opcodes.V1_8,Opcodes.ACC_PUBLIC,"Class"+i, null, "java/lang/Object", null);
byte[] code = cw.toByteArray();
test.defineClass("Class"+i, code, 0, code.length);
}
}finally {
print(j);
}
}
}
代码解析:
Demo继承了ClassLoader(类加载器),类加载器可以用来加载类的二进制字节码,所以可以用这玩意动态的加载越来越多的类的字节码。
实例化Demo之后,接下来做for循环,循环一万次,目的是为了加载一万个新的类,用ClassWriter类用代码的方式生成类的二进制字节码(网友1:高版本没有这个类了),用调用visit方法生成,其参数1
是代表类的版本号(1.8的还是1.7的等等),参数2
是类的访问修饰符(public之类的),参数3
是类的名字,此例子中类的名字是“Class0…9999”,参数4
是包名,参数5
是代表类的父类,参数6
是类要实现的接口,即用visit方法定义这个类要什么样子。定义之后,调用toByteArray()返回类的字节码,然后用类加载器(这里是test对象,因为继承了类加载器,所以是类加载器的子类),类加载器就是要加载类,调用difineClass方法执行了类的加载,参数1是类名,参数2是字节数组,参数3是byte数组的下标0开始读取,参数4是读取到哪里,根据这些二进制字节码去加载类,实际上就是生成了Class对象。
执行之后,可以看看是不是因为类加载过多而导致方法区的内存溢出?由于元空间默认使用的是系统的内存,而且默认情况下没有设置他的上限(方法区内存的上限?),所以运行这段代码,你并不会观察到这个方法区的内存溢出,所以此时运行,循环10000次,把这10000个类加载到了内存当中。所以元空间溢出很难演示出,因为物理内存还是不算小的。所以为了演示方法区内存溢出,可以加一个虚拟机参数:-XX:MaxMetaspaceSize=8m
,即设置最大的元空间大小为8m,这样的话才能演示出元空间内存溢出的问题。可以在idea的Run/Debug Configuration中选择这个Java文件在“VM options”中加-XX:MaxMetaspaceSize=8m
。然后重新运行,然后循环5000多次后,就出现异常了:Exception in thread "main" java.lang.OutOfMemoryError: Metaspace
1.8以前会导致永久代内存溢出
假设目前JDK版本是1.6,演示永久代内存溢出,代码都差不多,相关虚拟机参数是:-XX:MaxPermSize=8m,设置小一些,才容易让问题出现(但循环次数改成了20000)。运行之后,循环1万9多的时候,出现了异常:Exception in thread "main" java.lang.OutOfMemeryError: PermGen space
,永久代内存空间不足导致的异常。
上面的两种情况是,自己循环很多次产生了一堆class而导致的内存溢出,那实际场景下会有这种傻子吗?其实,实际场景里我们动态产生class,并加载这些类的场景的非常非常多的。
场景:
- spring框架
- mybatis框架
这些框架里面都用了一些字节码技术,比如,spring会利用cglib来生成一些代理类,mybatis也用到了cglib,用于产生mapper接口他的实现类,cglib实际上他的底层是什么呢,比如上面例子中,用到了ClassWriter,他继承了ClassVisitor,这些玩意都在org.objectweb.asm包中。cglib里面也有asm包,里面也有ClassWriter,也继承了ClassVisitor,当然,虽然所属包名都做了修改,其实都是系出同源,都基本上是一个东西,即cglib的ClassWriter和上面例子中的ClassWriter的功能都差不多的,都是在运行期间,动态生成类的字节码,来完成动态的类加载。
所以实际情况中,用spring,mybatis的时候,经常会产生大量的在运行期间生成的类,其实还是很容易导致永久代的内存溢出的,当然到了1.8以后由于元空间使用的是系统内存,相对充裕了很多,并且他的垃圾回收机制,也是由元空间自己来管理的,所以不会像永久代一样垃圾回收效率非常的低,所以不会造成因为回收效率低而导致内存溢出。(网友1:JDK动态代理就是通过JVM动态生成字节码代理类对象)
运行时常量池
不管是1.6的方法区(永久代),还是1.8的方法区(元空间),他们在方法区的组成中都有运行时常量池的部分。运行时常量池里面,还可能会包含一个叫stringtable的东西(请参考 《JVM 方法区 - 常量池(2)》)。
常量池
常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量(比如下面的hello world之类的,或者整数、布尔类型啥的)等信息。
public class HelloWorld {
public static void main(String[] args) {
print("hello world");
}
}
运行这个程序时,要先把他编译成二进制字节码,那这些字节码由哪些部分组成呢?一般来说,由三部分组成,即类的基本信息、类的常量池、类的方法定义(这里面又包含了虚拟机指令)。由于二进制字节码普通人都看不懂,所以借助JDK的工具,把这个HelloWorld.class文件再做一次反编译,反编译后的结果人类可以勉强读懂了。
先进入到class的所载目录,用JDK提供的javap工具(-v 参数是显示反编译后的详细信息)反编译该class,即执行如下:
javap -v HelloWorld.class
执行之后,出现一堆内容。
上面的Classfile
开始到Constant pool
之前,都是类的基本信息,里面包含类的文件路径、最后修改时间、签名、类的访问修饰符、包名、类名、版本(52是内部版本,对应着JDK1.8)…等等一堆类相关信息。
然后Constant pool
部分就是常量池,然后常量池部分下面的{
开始就是累中的方法定义,可以看到里面有构造方法,还有main方法的信息,这个main方法信息里面就包含了虚拟机指令
,就是Main方法里面的那些代码对应的虚拟机指令
,比如如下:
0: getstatic #2 // javap给加的注释内容,此处省略 ,解释器是不会执行的,解释器只执行前面的指令。
3: ldc #3 // 同上
5: invokevirtual #4 // 同上
8: return
那么这些东西和常量池有什么关系呢?解释器
在执行这些指令的时候,他看到的只是下面这些:
getstatic #2
ldc #3
invokevirtual #4
那解释器是怎么解释这些呢?
他会对#2 #3 #4这些真正进行一个查表翻译,比如getstatic
指令后面是#2,那么这个#2到底代表的是啥呢,因为getstatic
指令是“要获取静态变量(这里是System.out)”,所以要想知道要获取哪个静态变量,就要根据这个#2去查常量池的表,然后在往上查找Constant pool
部分中的#2,可以看到如下:
..
#2 = Fieldref #21.#22 // java/lang/System.out:Ljava/io/PrintStream;
..
可以看到#2是Fieldref,即他去引用成员变量,引用的是#21和#22,往下找#21和#22,如下:
..
#21 = Class #28 // java/lang/System
#22 = NameAndType #29:#30 // out:Ljava/io/PrintStream;
..
#28 = Utf8 java/lang/System
#29 = Utf8 out
#30 = Utf8 Ljava/io/PrintStream
..
可以看到#21是Class,即类名,是要找#28的成员变量,后面注释也写着java/lang/System,所以要找的静态成员变量所载的类就是System。
接着#22又引用了#29:#30,即#29是一个叫out的变量,#30是类型,是java/io包下的PrintStream类型。
所以再回到#2时,也可以从注释中看得出,我们要找的是System这个类中的out这个成员变量,这个成员变量的类型是PrintStream。
然后再回到刚才的虚拟机指令getstatic
下,现在就能知道getstatic
是要找某个类的静态成员变量的指令,然后就是要找System这个类中的out这个成员变量了。
接下来的指令也可以按照同样方式去读,比如 ldc #3
这个部分,ldc
就是要“可以理解为要找一个引用地址”的指令,找哪个引用地址呢,是要找#3,然后再看看上面Constant pool
部分中#3的信息,如下:
#3 = String #23 // hello world
..
#23 = Utf8 hello world
..
可以看到#3是字符串,他又要的是#23,#23是“hello world”,把这个“hello world”符号变成字符串对象,这里的Utf8虚拟机里面的一种类型,可以暂时理解为字符串?。所以ldc #3 这个指令干的就是要一个“hello world”。
接下来的指令是invokevirtual #4
,invokevirtual
是执行一次虚方法调用,执行哪个方法调用呢,是#4了。去常量池部分查#4的话如下:
..
#4 = Methodref #24.#25 // java/io/PrintStream.println:(Ljava/lang/String;)v
..
#4是Methodref
即方法引用,根据#24.#25找到了PrintStream中一个名为println的方法,并且参数是String类型。
因此经过了这么一番从长两次的查找,这三条指令就能确定了到底要执行哪个类的哪个方法,参数又是什么,所以常量池的作用,就是给我们这些指令提供一些常量符号,根据这个常量符号就用查找的方式去找到他们,这样你的虚拟机指令才能成功的去执行。
运行时常量池
常量池是 *.class文件中的。但当该类被虚拟机加载以后,他的常量池信息就会放入运行时常量池(比如,刚才看到的Constant pool部分只是这一个类的常量池信息,这些常量池在运行时得放在内存里)并把里面的符号地址变为真实地址(刚才是#1 #2之类的符去找的,但真正运行期间去找的话,肯定是不能根据#1 #2这种东西去找了,他会把这些编号都变为真正的内存中的地址,根据这个内存地址去找相应的后面这些类名方法名…之类的)。