JVM 方法区 - 常量池(1)

概念

方法区是所有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 #4invokevirtual是执行一次虚方法调用,执行哪个方法调用呢,是#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这种东西去找了,他会把这些编号都变为真正的内存中的地址,根据这个内存地址去找相应的后面这些类名方法名…之类的)。

  • 2
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值