开始。众所周知,Java程序的执行依赖于JVM(JavaVirtualMachine)。JVM会将Java源代码编译成字节码文件,然后使用类加载器加载到运行时数据区执行,垃圾回收器也会对运行时数据区进行对象回收。今天就来说说JVM的运行时数据区。运行时数据区概述在计算机世界中,内存是非常重要的系统资源,承担着操作系统和应用程序实时运行的重任。JVM内存布局规定了Java运行过程中内存的申请、分配和管理策略,保证了JVM的高效稳定运行。Java虚拟机在执行Java程序的过程中,将涉及到的数据划分到不同的内存区域进行管理。其中一些数据区在虚拟机启动时创建,在虚拟机关闭时销毁。另一部分随线程生命周期一起创建和销毁。这部分区域就是接下来要讲的Java虚拟机的运行时数据区。图1运行时数据区如图1所示,红色部分为运行时数据区,包括方法区、堆、虚拟机栈、本地方法栈、程序计数器五部分。图1中黄色标记的方法区和堆是线程间共享的,也就是说,它们会在虚拟机启动时创建,虚拟机退出时销毁。橙色部分为每个线程单独拥有,即与线程一一对应,会随着线程的启动和结束而创建和销毁。在HotSpotJVM中,每个线程都直接映射到操作系统的本机线程。例如,当一个Java线程准备执行时,操作系统的一个本地线程被创建并与Java线程相对应。当Java线程执行结束后,native线程也会被回收。同时,操作系统负责线程调度,分配相应的CPU执行线程。一旦操作系统本地线程初始化成功,就会调用Java线程中的run()方法来执行Java线程。棕色部分的执行引擎负责读取指令并交给CPU执行,其中包括解释器、JIT(即时编译器)和GC(垃圾收集器)。而另一个棕色的本地库接口将提供Java程序调用的本地方法。另外,运行时数据区的划分也在随着JDK的发展而变化。如图2所示,JDK1.6、JDK1.7、JDK1.8的内存划分会有所不同。图2运行时数据区的变化如图2所示,在JDK1.8中加入了元数据区的概念,包括原来存放在方法区的运行时常量池和类常量池。上面的虚拟机栈介绍了JVM运行时数据区的概念和组成,接下来逐一介绍各个组件,从虚拟机栈开始。每个Java线程对应一个虚拟机栈,换句话说,多个线程对应多个虚拟机栈。如上所述,虚拟机栈是线程私有的。虚拟机栈包含多个栈帧(StackFrame)。每个堆栈帧都是为方法执行而创建的。栈帧描述了Java方法执行的内存模型。每个方法从调用到完成的整个过程对应一个栈帧。栈帧用于管理Java程序的运行,保存方法的局部变量和部分结果,参与方法的调用和返回。在一个活动线程中,只有一个栈帧是活动的,也就是说只有栈顶的栈帧是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令只对当前栈帧进行操作。如图3所示,每个Java方法对应一个栈帧,左边的四个方法对应四个栈帧。方法调用的顺序是从下到上。最后,方法1会调用方法4,此时正在执行方法4,其对应的栈帧4为“当前栈帧”,处于活动状态,包含局部变量表、操作数栈等信息,动态链接和返回地址。图3栈框结构局部变量表定义为一个数字数组,主要用于存放方法参数和框内定义的局部变量,包括基本数据类型、对象引用、returnAddress类型。它建立在线程的栈上,是线程的私有数据,不存在数据安全问题。局部变量表所需的容量是在编译时确定的,运行时不会改变其容量。方法嵌套调用的次数由栈的容量决定。例如,在图3中,嵌套了四个方法。也就是说,栈越大,方法被嵌套调用的次数就越多。对于一个函数来说,它的参数和局部变量越多,对应的栈帧就会越大。因此,函数调用占用更多的堆栈空间。局部变量表中的变量只在当前方法调用中有效。方法执行时,虚拟机通过局部变量表完成参数值到参数变量列表的传递。当方法调用结束时,随着方法栈帧的销毁,局部变量表也会被销毁。操作数栈是后进先出栈。在方法执行过程中,根据字节码指令,将数据写入栈或从栈中取出,即出栈/出栈。字节码指令将值压入操作堆栈,其余字节码指令将操作数弹出堆栈,执行操作,并将结果压入堆栈。操作包括:copy、swap、sum等,这个比较抽象,我们来看一个具体的例子。如图4所示,生成一个testAdd方法,分别给变量i和j赋值1和2,然后相加,将结果赋值给k。图4操作数栈代码使用jclasslib对上述代码进行反编译得到图5的结果图5jclasslib的反编译结果如图6所示,地址0执行时,操作指令为bipush。此时程序寄存器的地址显示为0,bipush命令将1压入操作数栈顶。如图6和图7所示,当指令地址到达2时,程序寄存器显示为2,此时执行指令istore_1,将栈顶的数字1保存到局部变量中桌子。如图7和图8所示,当指令地址到达3时,程序寄存器为3,bipush指令将2压入操作数栈顶。如图8所示,当指令地址为5时,程序寄存器的值为5,istore_2指令将操作数栈中的2保存到局部变量表中2的位置。如图9和图10所示,当指令地址为6时,执行iload_1指令,获取局部变量表中位置1的值,即1,放入操作数栈顶。如图10和图11所示,当指令地址为7时,执行iload_2指令,从局部变量表2的位置取值2,放在操作数栈顶。如图11和图12所示,当指令地址为8时,执行iadd指令,将操作数栈中的1和2两个数相加得到3,将其压入操作数栈顶。图12,如图13,然后执行指令地址9,istore_3执行完后,将操作数栈顶的3保存到局部变量表3的位置,完成加法运算,最后返回该方法通过指令地址10中的返回指令。图13动态链接在介绍动态链接之前,先说一下静态链接,即字节码文件加载到JVM中时,如果调用的目标方法在编译时已知,并且在运行过程中保持不变。在这种情况下,将调用方法的符号引用转换为直接引用的过程称为静态链接。但是,如果在编译时无法确定被调用的方法,则只能在程序运行时将调用方法的符号引用转换为直接引用。因为这个引用转换过程是动态的,所以称为动态链接。如图14所示,上面是反编译后的字节码部分,对应的#3、#6、#5等是符号引用,下面是常量池。当Java源文件被编译成字节码文件时,所有变量和方法引用都作为符号引用存储在类文件的常量池中。比如invokevirtual指令会在指令的第9行执行,对应的符号引用是#7,对应常量池中的#7是Methodref,也就是方法引用,这里对应的方法在com.itcast.java.DynamicLinkTest方法中的一个方法。图14字节码到常量池的方法引用如图15所示,当加载字节码文件时,字节码文件中的一些数据,如类型信息、域信息、方法信息等,会被放入方法中区域。栈帧中的当前类常量池引用(CurrentClassConstantPoolReference)保存的是方法符号引用,真正的方法引用放在方法区(MethodArea)中的方法引用(methodreference)中。此方法引用是为了支持代码的动态链接。动态链接是将符号引用转换为直接引用。图15栈帧中的当前类常量池引用对应于方法区中的方法引用。JVM之所以这样设计,是因为字节码文件需要大量的数据支持,所以这些数据不能直接存储在字节码中。.为方法的引用创建一个符号引用。这个符号引用放在栈帧的常量池引用中,但是实际方法和符号引用的对照表放在方法区的常量池中,这样字节码才能通过常量池。引用的方法是通过比较关系找到的,不会增加栈帧的容量。方法返回地址当一个方法开始执行时,有两种方法可以退出该方法。首先是执行引擎遇到方法返回的字节码指令,此时返回值会传递给上层调用者。该方法称为正常完成退出。另一种退出方式是在方法执行过程中遇到异常。如果异常没有在方法体中处理,就会导致方法退出。该方法称为异常完成退出。由于异常退出,所以不会给上层调用者任何返回值。不管上面的exit方法是什么,该方法都会到处调用它所在的位置,然后程序才能继续执行。当方法返回时,需要在栈帧中保存一些信息,以恢复调用该方法的上层方法的执行状态。这里,返回地址可以存储在方法调用者的程序计数器中。如果方法正常退出,上层方法会从程序计数器保存的地址继续执行下一步。如果是异常退出,返回地址需要由异常处理器来确定。程序计数器有了上面虚拟机栈的讲解,对于程序计数器的理解就会比较简单了。还记得在虚拟机栈中操作数栈的例子中,提到过程序计数器是用来记录操作指令的地址的。程序计数器是一块很小的内存空间,它是当前线程执行的字节码的行号(操作指令的地址)的一个指标。在栈帧中,字节码解释器通过改变计数器的值来选择下一条要执行的字节码指令,如:分支、循环、跳转、异常处理、线程恢复等。上面讲虚拟机的时候有提到栈,多个执行的Java线程是多个虚拟机栈,每个栈中有多个栈帧。一次只执行一个栈帧,即当前栈帧。也就是说,同一时刻,一个进程在一个线程中只会执行一个帧栈上的一条指令,每个栈帧都会维护自己的程序计数器,用来记录指令执行的地址。每个线程的计数器不会互相影响,这也保证了Java多线程切换时,每个线程都能保证读取到正确的指令地址。如图16所示,invokevirtual框图中有多个线程,每个线程都是一个虚拟机栈,每个线程包含多个Frames,也就是栈帧,维护一个PCRegisters,也就是一个程序每个线程寄存器,记录指令地址信息,允许方法实现跳转、分支、循环、异常处理和线程恢复等功能。图16程序计数器本地方法栈本地方法栈和虚拟机栈起到的作用非常相似。它们的区别在于,虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务的,而本地方法栈是为虚拟机使用的Native方法服务的。本机方法堆栈还会抛出StackOverflowError和OutOfMemoryError异常。说白了,本地方法(NativeMethod)就是Java调用非Java代码的接口。当Java应用程序需要与Java以外的环境进行交互时,特别是在处理底层系统、操作系统和硬件时,会使用本机方法。你可以把本地方法理解为一种通信机制:它为外界提供了一个简洁的接口,这样我们就不需要去了解Java应用程序之外的细节。那么JVM是如何使用NativeMethod的呢?当一个类第一次被使用时,会将该类的字节码加载到内存中,并在字节码的入口处维护一个该类所有方法描述符的列表,包括:方法代码源、参数、方法描述符(例如:public)等。如果方法描述符是native,那么描述符块中会有一个指向方法实现的指针,具体实现在DLL文件中。这时,DLL文件就会被操作系统加载到Java程序的地址空间中。当加载具有本机方法的类时,不会加载其关联的DLL,因此不会设置指向方法实现的指针。在调用本机方法之前加载DLL,即调用java.system.loadLibrary()。上面提到的堆和方法区中的虚拟机栈、程序计数器、本地方法栈都是线程私有的,而下面提到的方法区和堆是线程共享的。这里堆和方法区结合在一起。堆Java堆是Java虚拟机管理的最大一块内存。它在虚拟机启动时创建,并被所有线程共享。Java对象实例和数组都分配在堆上。堆的大小可以固定,也可以根据计算的需要扩大,如果不需要更大的堆可以收缩。堆的内存不需要是连续的。Java虚拟机实现可以让程序员或用户控制堆的初始大小,如果可以动态扩展或收缩堆,还可以控制堆的最大和最小大小。Java堆是垃圾收集器管理的主要区域,所以也叫GC堆。从内存回收的角度来看,由于收集器基本采用分代收集算法,所以Java堆又可以细分为:新生代和老年代;新生代又细分为:Eden空间、FromSurvivor空间、ToSurvivor空间等。从内存分配的角度来看,线程共享的Java堆中可能会划分出多个线程私有分配缓冲区(ThreadLocalAllocationBuffer,TLAB).不管怎么划分,都与存储内容无关。无论在哪个区域,它仍然存储对象实例;进一步划分的目的是为了更好地回收内存或更快地分配内存。堆中垃圾回收的部分这里就不多说了,后面会有文章介绍。方法区和堆一样,方法区是线程共享的一块内存区域。用于存放虚拟机加载的类型信息、运行时常量池、静态变量、JIT代码缓存、领域信息、方法信息等。方法区(MethodArea)和Java堆一样,是一块共享的内存区域通过每个线程。它具有以下特点:方法区是在JVM启动时创建的,其实际物理内存空间可以与Java堆区相同。不断地。方法区的大小和堆空间一样,可以是固定的,也可以是可扩展的。方法区的大小决定了系统可以保存多少类。如果系统定义的类过多,方法区溢出,虚拟机会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGenspace或java.lang.OutOfMemoryError:Metaspace。关闭JVM将释放该区域的内存。这里我们梳理一下堆、方法区和虚拟机栈的关系。如图17,右侧创建了AppMain类,JVM在运行时会将AppMain信息放入方法区,因为方法区会存放类型信息。同时,main方法本身也会被放入方法区。在下面的newSample("test1")语句中,会将Sample的自定义对象放入堆中,将对应的test1应用放入虚拟机栈中,并执行对应的test1.printName()方法会在虚拟机栈中通过栈帧中的指令执行来完成。另外下面的类Sample也放在方法区,声明的私有名字,名字的引用放在虚拟机栈中,名字对应的对象放在堆中。对应的printName方法放在方法区。图17栈、堆、方法区关系总结JVM会将Java字节码加载到运行时数据区。这个内存区分为:方法区、堆、虚拟机栈、本地方法栈、程序计数器。堆中的对象也是垃圾收集器要处理的对象;方法区包含类型、方法描述和方法体;程序计数器负责记录指令执行在虚拟机栈中的地址;虚拟机栈对应Java执行的线程,对象的引用都保存在栈帧中,通过指令地址和指令执行方法中的内容;本地方法栈用于调用Java以外的系统级接口。译者介绍崔浩,社区编辑,资深架构师。他拥有18年的软件开发和架构经验,以及10年的分布式架构经验。他曾经是惠普的技术专家。乐于分享,撰写了多篇阅读量超过60万的热门技术文章。《分布式架构原理与实践》作者。
