白话说Java虚拟机原理系列【第四章】:内存结构之方法区详解
文章目录
- 执行引擎
- 内存结构:运行时数据区
- 方法区(永久代PermGen)
- 方法区的设计初衷?
- 方法区存的什么内容?
- 方法区的异常:
- 运行时常量池:
- `方发表:`这里我们详细讲解
前导说明:
本文基于《深入理解Java虚拟机》第二版
和个人理解完成,
以大白话的形式期望能用大家都看懂的描述讲清楚虚拟机内幕,
后续会增加基于《深入理解Java虚拟机》第三版
内容,并进行二个版本的对比
。
执行引擎
即线程,每一个线程都可理解成是一个执行引擎。
内存结构:运行时数据区
运行时数据区:当JVM运行一个程序时,需要在内存中存储许多数据。比如字节码/程序创建的对象/传递的方法参数/返回值/局部变量等等。JVM会把这些东西都分开存储到几个不同的区域(运行时数据区)中并关联起来,便于它内部的管理,为了直观JVM规范指定了这几个区域的名字,如下:
- 方法区
- 堆
- Java栈
- PC寄存器
- 本地方法栈
直接内存:
该区域是JDK1.7之后单独提出来的区域。
方法区(永久代PermGen)
线程共享此区域
:即存与方法区中的数据有并发隐患,存在缓存一致性问题
。
方法区的设计初衷?
用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等。虽然JVM规范把方法区描述为堆内存的一个逻辑部分,但是它还有另一个名字叫Non-Heap(非堆)。
为了让GC垃圾收集器能收集方法区这个内存区域,JVM使用永久代的概念来实现了方法区,其实方法区和永久代是不等同的,永久代是因为GC分带收集而产生的概念,以此来让GC也能对方法区进行垃圾收集,这样就省去了专门为方法区编写内存管理的代码工作。而想要GC收集此空间的资源主要包括常量池
和类型的卸载
,但是类型卸载的回收要求严苛,很难实现,目前GC对该区域的回收基本没有什么实际回收作用。
这样的以永久代实现方法区的只是HotSpod的做法,其他第三方实现的虚拟机有的都不存在永久代的说法。目前看用永久代实现方法区不太好,因为永久代有-XX:MaxPermSize的上限,这样就间接的导致了方法区也有了上限,会出现内存溢出。所以新的规划是放弃永久代(jdk 1.8),改用Native Memory(本地内存)来实现方法区,这样就没有限制了,但是需要自己编写内存管理功能。从JDK 1.7开始已经把存放在永久代(方法区)的字符串常量池移出。
方法区存的什么内容?
类被加载器加载后,将会提取class文件的内容存储到方法区,存储的内容基本包含一个class文件的全部信息,最后还会通过类加载器的defineClass()方法创建一个Class类的对象(对象内容当然也是class文件内的信息),该对象会存入堆内存,但是方法区中该class文件的存储区域会保存有与堆中对象的关联信息,以便能获取到相关的数据。
下边看下具体方法区会保存class文件的哪些信息?
1.类的全限定名,如java.io.FileOutputStream。
2.类的直接超类的全限定名,如java.lang.Object。
3.类是接口还是类类型
4.类的访问修饰符
5.等等。。。,其实就是几乎把整个class文件的接口全部保存了。具体可见class字节码文件格式
6.类静态变量
,通过class文件常量池的概念就知道,静态变量也存于此,所以静态的属于类的说法在于此,因为不是保存在堆中,而是保存在方法区的该类的信息中,即有了类就有了静态变量,而不是有了对象才有的静态变量。
7.类加载器信息
也会保存在该类在方法区中的结构中,用来确定哪个类加载器加载的该类。
8.指向堆中该类的Class对象的引用。
9.方法表
,就是类中定义的各个方法,此处涉及到java多态的原理,在下边的方法调用小结讲解。
方法区的异常:
方法区无法满足内存分配时,将抛出OutOfMemoryError异常。
运行时常量池:
- 位于方法区的一个区域。
- 上边我们提到过,编译后的class文件中有一个常量池区域,保存的字面量和符号引用。这个常量池的内容在class文件被加载后会被放在这个单独的"运行时常量池"中。
- 运行时常量池优于class文件中的常量池的地方是他是动态的,就是除了class文件中的常量池会进入这里外,程序运行时创建的一些常量也会放入这个运行时常量池中。【比如String.intern()方法,这个方法是判断当前变量是否在常量池中存在,如果存在则拿过来用,如果不存在则创建并返回引用,不过这个方法比较复杂,可以百度看详细或看api文档。
String.intern()方法: 判断这个常量是否存在于常量池。 如果存在 判断存在内容是引用还是常量, 如果是引用, 返回引用地址指向堆空间对象, 如果是常量, 直接返回常量池常量 如果不存在, 将当前对象引用复制到常量池,并且返回的是当前对象的引用 比如: String a = "AA"; //它会先在常量池中创建AA,然后从常量池返回AA给a String b = new String("BB"); //它会先常量池创建BB,然后在堆内存分配空间指向常量池,最后b指向堆得引用 String c = b.intern(); //这个方法将会做一些列检查,然后返回给c 注意:运行时常量池也可以保存引用哦。 注意:因为JDK 1.7将字符串常量移出了方法区,所以以上的描述只适用于JDK 1.7版本之前,之后的话创建的字符串将直接在堆中保存,即堆中维护着一个字符串常量池。
- 异常:因为是占用方法区的内存区域,所以同样受方法区的限制,即当运行时常量池无法再申请内存时则抛出OutOfMemoryError异常。
总结:
方法区中保存的是加载的class文件信息、常量池信息(保存的是已经经过常量池解析后的常量池,即符号引用已转为直接引用)、运行时常量池等。
方发表:
这里我们详细讲解
- 方法调用:注意方法调用不等于方法执行,
方法调用阶段唯一的任务就是要确定被调用方法的版本(就是确定要调用哪个方法),暂时还涉及不到方法代码的运行,即方法调用过程结果后,才会进行方法执行。- 符号引用:
我们已经知道,java代码被编译成class文件后保存的都是符号引用(且这些符号引用都是指向class文件的常量池中),就是说一切方法的调用在class文件中保存的都是符号引用(因为压根代码都没进入内存何来有内存分配的地址呢?),而不是方法在实际运行时内存布局中的入口地址(就是直接引用的意思)。所以无法在编译后的class文件中确定直接引用,那么自然就需要运行时动态来确定具体的直接引用到底是哪个内存地址了,这就是java多态的本质。符号引用变成直接引用
:其实这个变化的过程可以大致的分成2个阶段
①类加载阶段:就是在类被加载器加载时把一部分符号引用转换成直接引用,也叫做解析
;
②运行时阶段:另一部分没在解析过程转换的,就只能到程序运行时的时候动态转换了,也叫做分派
。
解析:能在这个过程完成符号引用转换成直接引用的条件:
①方法在程序真正运行之前就有一个可以确定的调用版本;
②并且这个方法的调用版本在运行期是不可变的。
换句话说:其实就是在程序代码写好、编译器在进行生成class文件的编译的时候,就能确定方法的调用版本了,因为类加载后,并没有做一些高级的工作,只是装载、连接、初始化,也就是说类加载能确定的,其实在代码编写的时候就已经确定了。
那么满足“编译期可知,运行期不变”的方法都是啥方法呢?
①static静态方法:属于类的,于类直接绑定
②private私有方法:也是类私有,其他外部不能访问
③final方法:这个比较特殊,因为被他修饰的方法不会被重写,所以也是属于当前类的,不会有不同的版本;
最后与字节码指令对应:我们知道有以下常规的4种调用指令
①invoke static:调用静态方法
②invoke special:调用私有方法、构造方法、父类方法
③invoke virtual:调用虚方法
④invoke interface:调用接口,会在运行时再确定一个实现此接口的对象
而从这里看,我们能清楚知道①、②两个调用指令正是调用解析过程的能将符号引用转换成直接引用的方法,当然final比较特殊,他是通过invoke virtual调用,就是会用多态方式在运行时才能确定具体的版本,但是因为final只会有一个版本,所以也就跟此处说的解析过程的方法没区别了,这就是所谓的静态绑定原理所在。很显然了除final方法以外的所有通过invoke virtual调用的方法都将是动态绑定的方法,即多态的功能原理。
分派:也可以说这里就是动态绑定的方法,就是只有运行时才能确定方法调用的版本,
换个角度说就是只有运行期间才能确定要调用的方法的内存地址是啥,
再换个角度就是只有运行时才能确定一个方法的符号引用到底对应的直接引用是啥。
先补充一下java的特性:继承、多态、封装,而此处就涉及到两个重要的名词,
①重载:同一个类中同方法名不同的参数结构的方法
②重写:发生在继承关系的子类中,子类重写父类的方法
为什么跟这两个概念有关?
我们知道方法调用的重点就是确定方法的版本,而可能导致一个方法有多个版本的情况,就只有【重写】和【重载】了。
当然我们会有疑问,那没有被重写和重载的方法也并没有归到静态绑定啊?
这部分方法其实方法的版本肯定也是只有一个,和final类似,只不过不像final一样是事先肯定能确定的只有一个版本,所以这些方法肯定也是invoke virtual调用,只不过最终只会有一个版本罢了,因为不如final典型特殊,所以仍然归到动态绑定里说,后边我们也会说清楚为什么他们这些普通方法只有一个版本。
我们就重点看看【重载】和【重写】到底怎么实现动态绑定吧?
重载
:先看一个代码,如下:其中Human是父类,Man是子类
Human man = new Man();
这里有两个概念:
①变量的静态类型:Human就是man变量的静态类型
②变量的实际类型:Man就是man变量的实际类型
从这段代码看,其实Human man = new Man()被编译器编译的时候,常量池中就会记录man变量的符号引用是Human,也即编译期静态类型
是知道的,但是实际类型
则只有创建对象后才知道是Man类型,我们也可以通过javap查看字节码指令来知道编译的class文件中确实是已经确定了符号引用的值为Human,只不过运行后符号引用替换成直接引用后就变成了Man类对象在堆中的地址位置了。举例: public class T{ static class Human{} static class Man extends Humman{} static class Woman extends Humman{} public void sayHello(Human m){System.out.println("Human")} public void sayHello(Man m){System.out.println("Man")} public void sayHello(Woman m){System.out.println("Woman")} public static void main(String[] args){ Human m = new Man(); Human w = new Woman(); T t = new T(); t.sayHello(m); t.sayHello(w); } } // 结果: Human Human
分析:从代码看,我们知道方法最终都是会进行编译的,那么三个sayHello最终会编译成符号引用,那么方法的符号引用的表示方法我们知道,就是方法名+符号描述符,符号描述符当然包括返回值和参数,因为返回值都一样,所以区分他们的地方就是参数类型了,我们知道参数类型是Human、Man、Woman这三个。
静态分派
:我们从上边知道,虽然重载的方法都不是静态绑定的那种方法,但是他们经过编译器编译后确实就已经能确定方法的版本了(通过静态类型确定方法的版本),我们也知道编译成的指令只有在调用方法的时候才会真实的执行invokexxx指令,所以t.sayHello(m)这句代码,肯定是会编译成invokevirtual指令,那么指令指向的符号引用是哪个方法呢?很简单,因为调用语句传入的m是Human类型,即静态类型是Human,所以invokevirtual要调用的当然就是参数类型是Human的这个方法了,直接在编译期确定调用版本,这也叫静态分派,注意这个和静态绑定不是同一个概念。
==>总结:至此我们在编译期能确定方法版本的如下:
- ①编译期能确定版本的方法:之前说的静态绑定的方法
- ②编译期能确定版本的方法:重载的方法
重写
:到这个环节还不能确定方法版本的,在java中也就只剩下继承关系中的子类中重写父类的方法的这些方法了吧,因为其他类型的方法上边都说完了。例子: public class T{ static class Human{ protected void sayHello(){System.out.println("Human");} } static class Man extends Human{ protected void sayHello(){System.out.println("Man");} } static class Woman extends Human{ protected void sayHello(){System.out.println("Woman");} } public static void main(String[] args){ Human m = new Man(); Human w = new Woman(); m.sayHello(); w.sayHello(); } } 结果: Man Woman
显然:重写的方法并不是根据静态类型就能确定版本的了,否则调用结果就应该都是Human了。他的结果很明显是跟实际类型有关的。
分析:通过javap查看class文件字节码:
17、21行就是2个调用方法的语句,生成的指令都是invokevirtual,并且符号引用都是指向了常量池的22号位置,其实都是指向了Human.sayHello方法,为什么指向这里,原因很简单,因为编译器只能使用符号引用,所以m和w都是Human类型的变量所以肯定是指向Human.sayHello方法,那么为什么实际中他们运行起来又会调用具体的实际类型的方法?
这就要从invokevirtual的执行原理
说起了,或者说是查找方法版本的过程:该指令会进行以下查找尝试:
①找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C(如:m或w的实际类型)。
②如果在类型C中找到与常量池中的描述符和简单名称都相符的方法(如Man或Woman中的sayHello方法),则进行访问权限的验证,
如果验证通过,则返回这个方法的直接引用,查找过程即结束;
如果验证失败,则返回java.lang.IllegalAccessError异常;
③如果在类型C中没有找到相符的方法,则按照继承关系从下往上依次对C的各个父类进行②步骤的搜索和验证过程。
④如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
invokevirtual指令的第一步是在栈中进行的操作,所以已经是运行时的状态了,并不是在类加载过程中了,所以此时就是运行时过程的开始,也即动态了。所以最终两个invokevirtual得到了两种不同的直接引用(即w和m对象的实际类型的sayHello方法),即根据实际类型确定了方法的版本,这也是方法重写的本质,也叫做动态分派
。
注意:
这里看不太懂可以看完Java栈后再回来,因为这里讲的是方法的调用,而方法的调用是在Java栈中执行的,即所有方法的执行都在Java栈中,而此处说的操作数栈顶也是Java栈的结构。
==>归纳:方法调用的过程,其实就是执行具体的调用指令(invoke开头的指令),而指令的执行过程是需要先确定版本然后再调用,确定版本的方法我们已经知道,有解析和分派两种,在实际运行时,这两种方式是不会分隔的,即解析和分派都会起作用,只不过在确定方法版本的过程中,是先通过解析来确定,确定不了再使用分配确定,就好比静态方法,我们说是解析过程确定的,那么如果静态方法有重载呢?那当然解析过程就确定不了,还是要执行到分派过程的静态分派的时候才能确认,注意理解一下这里的概念,不要有理解偏差。
扩展
:动态分派中,我们知道会通过实际类型获得方法的版本,那么哪些方法会作为数据源呢?也就是说比对的时候总要有一个池子吧?没错,JVM不会满内存的找方法区比对,而是我们上边提到的方发表,他只会从方发表中选取一个确定版本的方法再调用,这也是动态分派的底层实现。
方发表的实现
:动态分派要想确定一个方法的版本,那肯定是要在运行时,在类的所有方法中进行搜索,以便找到合适的,但是如果这样搜索太消耗性能,毕竟子类中有父类中也有,对应关系不够明确,所以jvm在类被加载到运行时数据区的方法区后,会在当前类的存储区域保存一个虚方法表vtable,也就是此处说的方法表,也会保存一个接口方法表itable(用来为invokeinterface指令使用,和方法表作用相同),把类中的方法全都保存到这个方法表中,这样查找起来就简单的多了。
下边看看方法表的结构:
Son重写了Father的全部方法,所以Son中的重写的方法的方法表内容都指向了Son并没有指向Father,当然如果有没有重写的方法肯定是要指向Father的,而因为Son和Father都没有重写Object类的方法,所以这些方法都指向了Object类。
方法表的特性:
①方法表中存储着各个方法的实际入口地址(即直接引用)
②如果某个方法没有在子类中被重写,那么子类的方发表中的该方法的地址入口和父类中同名方法的保存的地址相同,都是指向父类的实现入口;
③如果某个方法在子类中被重写了,那么子类的方法表中的该方法的地址入口将被替换成指向子类实现版本的入口地址。
==>总结
:方法调用即方法版本的确认:
①编译期,确定private、static、final类型的方法,叫解析;
②编译期,也能通过方法静态类型确定重载的方法,叫静态分派;
③运行时期,通过方法表来确定继承关系中的重写方法的版本,叫做动态分派;