动态链接
在介绍动态链接之前先说说静态链接,即字节码文件被装载进JVM内部时,如果被调用的目标方法在编译期可知,且运行期间保持不变时。这种情况下将调用方法的符号引用转换为直接引用的过程称之为静态链接。但是,如果被调用方法在编译期间无法被确定下来,只能在程序运行时将调用方法的符号引用转换为直接引用,由于这种引用转换的过程具备动态性,被称为动态链接。
如图14所示,上面是反编译的字节码部分,对应的#3、#6、#5等等就是符号引用,下面的Constant pool就是常量池。在Java源文件被编译成字节码文件时,所有的变量和方法引用都作为符号引用保存在class文件的常量池中。例如在指令第9行会执行invokevirtual的指令,对应的符号引用就是#7,所对应常量池中的#7 就是Methodref,也就是方法引用,这里对应的方法是com.itcast.java.DynamicLinkTest中的methodA方法。
图14 从字节码到常量池中的方法引用
如图15所示,当字节码文件被加载后,字节码文件中的一些数据,如类型信息、域信息、方法信息等,就会被放置到方法区中。而栈帧中的当前类常量池引用(Current Class Constant Pool Reference)保存的是方法符号引用,真正的方法引用放在了方法区(Method Area)中的方法引用(method reference)中了,这个方法引用是为了支持代码的动态链接。动态链接就是将符号引用转化为直接引用。
图15 栈帧中的当前类常量池引用对应方法区中的方法引用
JVM之所以这么设计是因为字节码文件需要数据支持的量会很大,因此不能直接将这些数据存放到字节码中。针对方法的引用创建符号引用,这个符号引用放在栈帧的常量池引用中,而实际的方法和符号引用的对照表却放在方法区的常量池中,这样字节码就可以通过常量池中的对照关系找到引用的方法,并且也不会增加栈帧的容量。
方法返回地址
当一个方法开始执行后,可以通过两种方式退出该方法。第一种是执行引擎遇到方法返回的字节码指令,此时返回值会传递到上层调用者,这种方式称为正常完成出口。另外一种退出方式是在方法执行中遇到异常,这个异常在方法体内没有得到处理,就会导致方法退出,这种方式称为异常完成出口。由于是异常退出,就不会给上层调用者任何返回值。无论采取上面那种退出方式,方法都会到处调用它的位置,程序才能继续执行。方法在返回的时候需要在栈帧中保存一些信息,用来恢复调用该方法的上层方法的执行状态。这里可以通过方法调用者的程序计数器存放返回地址,如果是正常退出方法,上层方法会从程序计数器中保存的地址继续执行接下来的步骤。如果是异常退出的情况,返回地址就需要异常处理器来确定了。
程序计数器
有了上面虚拟机栈的讲解,对于程序计数器的理解会相对简单点。记得在虚拟机栈中的操作数栈的例子中,提到了使用程序计数器记录操作指令的地址。程序计数器就是一块较小的内存空间,它是当前线程执行的字节码的行号(操作指令的地址)指示器。在栈帧中字节码解释器就是通过改变计数器的值来选去下一条要执行的字节码指令的,例如:分支、循环、跳转、异常处理、线程恢复等。
上面讲虚拟机栈的时候提到过,多个执行的Java线程就是多个虚拟机栈,每个栈中存在多个栈帧,在一个时刻只有一个栈帧执行,也就是当前栈帧。也就是说在一个时刻一个处理只会对一个线程中的一个帧栈执行一条指令,而每个栈帧都会维护一个属于自己的程序计数器,这个计数器就是来记录指令执行的地址的。每个线程的计数器不会相互影响,这也保证了在Java 多线程进行切换的时候,每个线程都能够保证正确的指令地址被读取。
如图 16所示,在invokevirtual的框图中存在多个线程,每个线程就是一个虚拟机栈,每个线程中包含多个Frame 也就是栈帧,针对每个线程都会维护一个PC Registers也就是程序寄存器,它会记录指令地址信息,从而让方法实现:跳转、分支、循环、异常处理和线程恢复的功能。
图16 程序计数器
本地方法栈
本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们之间的区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈为虚拟机所使用到的Native方法服务。本地方法栈也会抛出StackOverflowError和OutOfMemoryError异常。
说白了,本地方法(Native Method)就是一个Java调用非Java代码的接口。 当Java应用需要与Java之外的环境交互时就需要使用本地方法,特别与底层系统、操作系统以及硬件打交道时就会用到本地方法。大家可以把本地方法理解为一种交流机制:它提供了一个对外的简洁的接口,让我们无需去了解Java应用之外的细节。
那么JVM是如何使用Native Method的呢?当一个类第一次被使用时,类的字节码会被加载到内存,在字节码的入口维持着该类所有方法描述符的list,包括:方法代码来源,参数,方法描述符(例如:public)等等。
如果方法描述符是native,同时描述符块将有一个指向该方法实现的指针,而具体实现在DLL文件内,此时DLL文件会被操作系统加载到Java程序的地址空间里。当一个带有本地方法的类被加载时,其相关的DLL并未被加载,因此指向方法实现的指针并不会被设置。当本地方法被调用之前, DLL才会被加载,即通过调用java.system.loadLibrary()实现的。
堆和方法区
上面说的虚拟机栈、程序计数器和本地方法栈都是线程私有的,而接下来说的方法区和堆是线程共享的。这里把堆和方法区合起来说。
堆
Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。Java对象实例以及数组都在堆上分配。堆的大小可以是固定的,也可以根据计算的需要进行扩展,如果不需要更大的堆,则可以收缩。堆的内存不需要是连续的。Java虚拟机实现可以为程序员或用户提供对堆初始大小的控制,如果可以动态扩展或收缩堆,还可以控制堆的最大和最小大小。
Java堆是垃圾收集器管理的主要区域,所以也被称为GC堆。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;新生代再细分就是:Eden空间、From Survivor空间、ToSurvivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不论如何划分,都与存放内容无关,无论哪个区域,存放的都仍然是对象实例;进一步划分的目的是为了更好的回收内存,或者更快地分配内存。
对于堆中垃圾回收的部分这里不展开说明,后面会有文章去介绍。
方法区
方法区和堆一样是线程共享的内存区域,它用来存放被虚拟机加载的类型信息、运行时常量池、静态变量、JIT代码缓存、域信息、方法信息等。方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,有如下特点:
· 方法区在JVM启动的时候被创建,并且它的实际的物理内存空间和Java堆区一样都可以是不连续的。
· 方法区的大小,和堆空间一样,可以选择固定大小和可扩展。
· 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机就会抛出内存溢出错误:
java.lang.OutOfMemoryError:PermGenspace或者 java.lang.OutOfMemoryError: Metaspace。
· 关闭JVM就会释放这个区域的内存。
这里把堆、方法区和虚拟机栈的关系整理一下。如图17 所示,在右边创建了AppMain 类,在运行时JVM 会把AppMain的信息放入到方法区,因为方法区会存放类型信息。同时main 的方法本身也会放入到方法区。接下来的new Sample(“测试1”)的语句中Sample的自定义对象会放到堆里面,而对应的test1 应用会放入到虚拟机栈中,对应的test1.printName()方法的执行会在虚拟机栈中的栈帧中通过指令执行完成。另外下面的class Sample也是放到方法区中的,声明的private name,其中name的引用放在虚拟机栈中,name对应的对象放在堆中。对应的printName方法是放在方法区中的。
图17 栈、堆、方法区关系
总结
JVM 会把Java的字节码加载到运行时数据区内,这个内存区域分为:方法区、堆、虚拟机栈、本地方法栈以及程序计数器。堆里面放对象,也是垃圾回收器要处理的对象;方法区放类型、方法描述、方法本体;程序计数器负责记录虚拟机栈中指令执行的地址;虚拟机栈对应Java执行的线程,对象的引用都保存在栈帧中,通过指令地址和指令执行方法中的内容;本地方法栈用来调用Java 之外的系统级别的接口。
译者介绍
崔皓,51CTO社区编辑,资深架构师,拥有18年的软件开发和架构经验,10年分布式架构经验。曾任惠普技术专家。乐于分享,撰写了很多热门技术文章,阅读量超过60万。《分布式架构原理与实践》作者。
原文标题 : 17张图带你了解,JVM运行时数据区