Java虚拟机 - 结构原理与运行时数据区域

http://liuwangshu.cn/java/jvm/1-runtime-data-area.html

前言

本来计划要写Android内存优化的,觉得有必要在此之前介绍一下Java虚拟机的相关知识,Java虚拟机也并不是三言两语能够介绍完的,因此开了Java虚拟机系列,这一篇文章我们来学习Java虚拟机的结构原理与运行时数据区域。

1.Java虚拟机概述

Oracle官方定义的Java技术体系主要包括以下几个部分:

  • Java程序设计语言
  • 各种平台的Java虚拟机
  • Class文件格式
  • Java API类库
  • 第三方Java类库

可以把Java程序设计语言、Java虚拟机和Java API类库这三部分统称为JDK(Java Development Kit),它是Java程序开发的最小环境。另外,Java API中的Java SE API子集和Java虚拟机这两部分统称为JRE(Java Runtime Environment),它是Java程序运行的标准环境。
从上面可以看出Java虚拟机及其重要,它是整个Java平台的基石,是Java语言编译代码的运行平台。你可以把Java虚拟机看做一个抽象的计算机,它有各种指令集和各种运行时数据区域。

1.1 Java虚拟机家族

很多同学可能认为Java虚拟机,就是一个虚拟机而已,它还有家族?或者认为Java虚拟机指的就是Oracle的HotSpot虚拟机。这里来简单介绍Java虚拟机家族,自从1996年Sun公司发布的JDK1.0中包含的Sun Classic VM到今天,出现和消亡了很多种虚拟机,我们这里只简单介绍目前存活的相对主流Java虚拟机。

HotSpot VM
Oracle JDK和OpenJDK中自带的虚拟机,是最主流的和使用范围最广的Java虚拟机。介绍Java虚拟机的技术文章,如果不做特殊说明,大部分都是介绍HotSpot VM的。HotSpot VM并非是Sun公司开发的,而是由Longview Technologies这家小公司设计的,它在1997年被Sun公司收购,Sun公司又在2009年被Oracle收购。
J9 VM 
J9 VM 是IBM开发的VM,目前是其主力发展的Java虚拟机。J9 VM的市场定位和HotSpot VM接近,它是一款设计上从服务端到桌面应用再到嵌入式都考虑到的多用途虚拟机,目前J9 VM的性能水平大致跟HotSpot VM是一个档次的。
Zing VM
以Oracle的HotSpot VM为基础,改进了许多会影响延迟的细节。最大的三个卖点是:

  • 1.低延迟,“无暂停”的C4 GC,GC带来的暂停可以控制在10ms以下的级别,支持的Java堆大小可以到1TB;
  • 2.启动后快速预热功能。
  • 3.可管理性:零开销、可在生产环境全时开启的、整合在JVM内的监控工具Zing Vision。

1.2 Java虚拟机执行流程

当我们执行一个Java程序时,它的执行流程是怎样的呢?如下图所示。

Java虚拟机 - 结构原理与运行时数据区域

从上图可以看到Java虚拟机与java语言没有什么必然联系,它只与特定的二进制文件:Class文件有关。

2.Java虚拟机结构

这里所讲的体系结构,是指的Java虚拟机的抽象行为,而不是具体的比如HotSpot VM的实现。按照Java虚拟机规范,抽象的Java虚拟机如下图所示。

Java虚拟机 - 结构原理与运行时数据区域

2.1 Class文件格式

Java文件被编译后生成了Class文件,这种二进制格式文件不依赖于特定的硬件和操作系统。每一个Class文件中都对应着唯一一个类或者接口的的定义信息,但是类或者接口并不一定定义在文件中,比如类和接口可以通过类加载器来直接生成。

ClassFile的文件结构如下所示。

ClassFile {
u4 magic; //魔数,固定值为0xCAFEBABE,用来判断当前文件是能被Java虚拟机处理的Class文件
u2 minor_version; //副版本号
u2 major_version; //主版本号
u2 constant_pool_count; //常量池计数器
cp_info constant_pool[constant_pool_count-1]; //常量池
u2 access_flags; //类和接口层次的访问标志
u2 this_class; //类索引
u2 super_class; //父类索引
u2 interfaces_count; //接口计数器
u2 interfaces[interfaces_count]; //接口表
u2 fields_count; //字段计数器
field_info fields[fields_count]; //字段表
u2 methods_count; //方法计数器
method_info methods[methods_count]; //方法表
u2 attributes_count; //属性计数器
attribute_info attributes[attributes_count]; //属性表
}

2.2 类加载器子系统

类加载器子系统通过多种类加载器来查找和加载Class文件到 Java 虚拟机中。Java虚拟机有两种类加载器:系统加载器和用户自定义加载器。其中系统加载器包括以下三种:

  • 引导类加载器(Bootstrap Class Loader):用C/C++代码实现的加载器,用以加载Java虚拟机运行时所需要的系统类,这些系统类在{JRE_HOME}/lib目录下。Java虚拟机的启动就是通过引导类加载器创建一个初始类来完成的。由于类加载器是使用平台相关的底层C/C++语言实现的, 所以该加载器不能被Java代码访问到。但是,我们可以查询某个类是否被引导类加载器加载过。引导类装载器并不继承java.lang.ClassLoader。
  • 扩展类加载器(Extensions Class Loader):用于加载 Java 的拓展类 ,拓展类一般会放在 {JRE_HOME}/lib/ext/ 目录下,用来提供除了系统类之外的额外功能。
  • 应用程序类加载器(Application Class Loader):该类加载器是用于加载用户代码,是用户代码的入口。应用类加载器将拓展类加载器当成自己的父类加载器,当尝试加载类的时候,首先尝试让拓展类加载器加载,如果拓展类加载器加载成功,则直接返回加载结果Class instance,如果加载失败,则会询问引导类加载器是否已经加载了该类,如果没有,应用类加载器才会尝试自己加载。

用户自定义加载器,则是通过继承 java.lang.ClassLoader类的方式来实现自己的类加载器。
类加载器子系统除了要加载Class文件类到 Java 虚拟机中,还必须负责验证被导入的Class类的正确性,为类变量分配并初始化内存,以及帮助解析符号引用。这些动作必须严格按以下顺序进行:

1.加载:查找并加载Class文件。
2.链接:验证、准备、以及解析。

  • 验证:确保被导入类型的正确性。
  • 准备:为类的静态字段分配字段,并用默认值初始化这些字段。
  • 解析:根据运行时常量池的符号引用来动态决定具体值得过程。

3.初始化:将类变量初始化为正确初始值。

2.3 数据类型

Java虚拟机与Java语言的数据类型相似,可以分为两类:基本类型和引用类型。Java虚拟机希望编译器在编译期间尽可能的完成类型检查,使得虚拟机在运行期间无需进行类型检查操作。

2.4 运行时数据区域

很多人将Java的内存分为堆内存(heap)和栈内存(Stack),这种分发不够准确,Java的内存区域划分实际上远比这复杂。
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为不同的数据区域,根据《Java虚拟机规范(Java SE7版)》的规定,这些数据区域分别为程序计数器、Java虚拟机栈、本地方法栈、Java堆和方法区,下面我们来一一的对它们进行介绍。

2.4.1 程序计数器

为了保证程序能够连续地执行下去,处理器必须具有某些手段来确定下一条指令的地址,而程序计数器正是起到这种作用。
程序计数器(Program Counter Register)也叫做PC寄存器,是一块较小的内存空间。在虚拟机概念模型中,字节码解释器工作时就是通过改变程序计数器来选取下一条需要执行的字节码指令,Java虚拟机的多线程是通过轮流切换并分配处理器执行时间的方式来实现的,在一个确定的时刻只有一个处理器执行一条线程中的指令,为了在线程切换后能恢复到正确的执行位置,每个线程都会有一个独立的程序计数器,因此,程序计数器是线程私有的。如果线程执行的方法不是Native方法,则程序计数器保存正在执行的字节码指令地址,如果是Native方法则程序计数器的值则为空(Undefined)。程序计数器是Java虚拟机规范中唯一没有规定任何OutOfMemoryError情况的数据区域。

2.4.2 Java虚拟机栈

每一条Java虚拟机线程都有一个线程私有的Java虚拟机栈(Java Virtual Machine Stacks)。它的生命周期与线程相同,与线程是同时创建的。Java虚拟机栈存储线程中Java方法调用的状态,包括局部变量、参数、返回值以及运算的中间结果等。一个Java虚拟机栈包含了多个栈帧,一个栈帧用来存储局部变量表、操作数栈、动态链接、方法出口等信息。当线程调用一个Java方法时,虚拟机压入一个新的栈帧到该线程的Java栈中,当该方法执行完成,这个栈帧就从Java栈中弹出。我们平常所说的栈内存(Stack)指的就是Java虚拟机栈。

在编译程序代码时,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入了方法表的Code属性之中。因此,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

Java虚拟机规范中定义了两种异常情况:

  • 如果线程请求分配的栈容量超过Java虚拟机所允许的的最大容量,Java虚拟机会抛出*Error。
  • 如果Java虚拟机栈可以动态扩展(大部分Java虚拟机都可以动态扩展),但是扩展时无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的Java虚拟机栈,则会抛出OutOfMemoryError异常。

这两种情况存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。在单线程的操作中,无论是由于栈帧太大,还是虚拟机栈空间太小,当栈空间无法分配时,虚拟机抛出的都是*Error异常,而不会得到OutOfMemoryError异常。而在多线程环境下,则会抛出OutOfMemoryError异常。

下面详细说明栈帧中所存放的各部分信息的作用和数据结构。

1、局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,其中存放的数据的类型是编译期可知的各种基本数据类型、对象引用(reference)和returnAddress类型(它指向了一条字节码指令的地址)。局部变量表所需的内存空间在编译期间完成分配,即在Java程序被编译成Class文件时,就确定了所需分配的最大局部变量表的容量。当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

局部变量表的容量以变量槽(Slot)为最小单位。在虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小(允许其随着处理器、操作系统或虚拟机的不同而发生变化),一个Slot可以存放一个32位以内的数据类型:boolean、byte、char、short、int、float、reference和returnAddresss。reference是对象的引用类型,returnAddress是为字节指令服务的,它执行了一条字节码指令的地址。对于64位的数据类型(long和double),虚拟机会以高位在前的方式为其分配两个连续的Slot空间。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的Slot数量,对于32位数据类型的变量,索引n代表第n个Slot,对于64位的,索引n代表第n和第n+1两个Slot。

在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),则局部变量表中的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

局部变量表中的Slot是可重用的,方法体中定义的变量,作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省空间,在某些情况下Slot的复用会直接影响到系统的而垃圾收集行为。

2、操作数栈

操作数栈又常被称为操作栈,操作数栈的最大深度也是在编译的时候就确定了。32位数据类型所占的栈容量为1,64为数据类型所占的栈容量为2。当一个方法开始执行时,它的操作栈是空的,在方法的执行过程中,会有各种字节码指令(比如:加操作、赋值元算等)向操作栈中写入和提取内容,也就是入栈和出栈操作。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。因此我们也称Java虚拟机是基于栈的,这点不同于Android虚拟机,Android虚拟机是基于寄存器的。

基于栈的指令集最主要的优点是可移植性强,主要的缺点是执行速度相对会慢些;而由于寄存器由硬件直接提供,所以基于寄存器指令集最主要的优点是执行速度快,主要的缺点是可移植性差。

3、动态连接

每个栈帧都包含一个指向运行时常量池(在方法区中,后面介绍)中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。

4、方法返回地址

当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出站,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。

2.4.3 本地方法栈

Java虚拟机实现可能要用到C Stacks来支持Native语言,这个C Stacks就是本地方法栈(Native Method Stack)。它与Java虚拟机栈类似,只不过本地方法栈是用来支持Native方法服务。如果Java虚拟机不支持Native方法,并且也不依赖于C Stacks,可以无需支持本地方法栈。在Java虚拟机规范中对本地方法栈的语言和数据结构等没有强制规定,因此具体的Java虚拟机可以*实现它,比如HotSpot VM将本地方法栈和Java虚拟机栈合二为一。
与Java虚拟机栈类似,本地方法栈也会抛出 *Error和OutOfMemoryError异常

2.4.4 Java堆

Java堆(Java Heap)是被所有线程共享的运行时内存区域。Java堆用来存放对象实例,几乎所有的对象实例都在这里分配内存。Java堆存储的对象被垃圾收集器管理,这些受管理的对象无需也无法显示的销毁。从内存回收的角度,Java堆可以粗略的分为新生代和老年代。从内存分配的角度Java堆中可能划分出多个线程私有的分配缓冲区。不管如何划分,Java堆存储的内容是不变的,进行划分是为了能更快的回收或者分配内存。
Java堆的容量可以时固定的,也可以动态的扩展。Java堆的所使用的内存在物理上不需要连续,逻辑上连续即可。
Java虚拟机规范中定义了一种异常情况:

  • 如果在堆中没有足够的内存来完成实例分配,并且堆也无法进行扩展时,则会抛出OutOfMemoryError异常。

Java堆可以分为新生代和老年代两个区,其中新生代又可以分为一个Eden区和两个Survivor区,两个Survivor区分别被命名为From和To以示区分,新生代和老年代的比例为1:2,它们共同组成堆的内存区,所以新生代占堆的1/3,老年代占2/3,但这个比例可以修改,下面分别来介绍一下新生代和老年代。

1、【新生代】

新生代分为三个区域,一个Eden区和两个Survivor区,它们之间的比例为(8:1:1),这个比例也是可以修改的。通常情况下,对象主要分配在新生代的Eden区上,少数情况下也可能会直接分配在老年代中。Java虚拟机每次使用新生代中的Eden和其中一块Survivor(From),在经过一次Minor GC后,将Eden和Survivor中还存活的对象一次性地复制到另一块Survivor空间上(这里使用的复制算法进行GC),最后清理掉Eden和刚才用过的Survivor(From)空间。将此时在Survivor空间存活下来的对象的年龄设置为1,以后这些对象每在Survivor区熬过一次GC,它们的年龄就加1,当对象年龄达到某个年龄(默认值为15)时,就会把它们移到老年代中。

在新生代中进行GC时,有可能遇到另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象将直接通过分配担保机制进入老年代;

总结:

1、Minor GC是发生在新生代中的垃圾收集,采用的复制算法;

2、新生代中每次使用的空间不超过90%,主要用来存放新生的对象;

3、Minor GC每次收集后Eden区和一块Survivor区都被清空;

2、【老年代】

老年代里面存放都是生命周期长的对象,对于一些较大的对象(即需要分配一块较大的连续内存空间),是直接存入老年代的,还有很多从新生代的Survivor区域中熬过来的对象。

老年代中使用的是Full GC,Full GC所采用的是标记-清除算法。老年代中的Full GC不像Minor GC操作那么频繁,并且进行一次Full GC所需要的时间要比Minor GC的时间长。

总结:

1、老年代中使用Full GC,采用的标记-清除算法

2.4.5 方法区

方法区(Method Area)是被所有线程共享的运行时内存区域。用来存储已经被Java虚拟机加载的类的结构信息,包括:
运行时常量池、字段和方法信息、静态变量等数据。方法区是Java堆的逻辑组成部分,它一样在物理上不需要连续,并且可以选择在方法区中不实现垃圾收集。方法区并不等同于永久代,只是因为HotSpot VM使用永久代来实现方法区,对于其他的Java虚拟机,比如J9和JRockit等,并不存在永久代概念。
Java虚拟机规范中定义了一种异常情况:

  • 如果方法区的内存空间不满足内存分配需求时,Java虚拟机会抛出OutOfMemoryError异常。

运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。在2.1 Class文件格式这一小节中我们得知,Class文件不仅包含了类的版本、接口、字段和方法等信息,还包含了常量池,它用来存放编译时期生成的字面量和符号引用,这些内容会在类加载后存放在方法区的运行时常量池中。运行时常量池可以理解为是类或接口的常量池的运行时表现形式。
Java虚拟机规范中定义了一种异常情况:
当创建类或接口时,如果构造运行时常量池所需的内存超过了方法区所能提供的最大值,Java虚拟机会抛出OutOfMemoryError异常。

直接内存(Direct Memory)

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,它直接从操作系统中分配,因此不受Java堆大小的限制,但是会受到本机总内存的大小及处理器寻址空间的限制,因此它也可能导致OutOfMemoryError异常出现。在JDK1.4中新引入了NIO机制,它是一种基于通道与缓冲区的新I/O方式,可以直接从操作系统中分配直接内存,即在堆外分配内存,这样能在一些场景中提高性能,因为避免了在Java堆和Native堆中来回复制数据。关于NIO的详细使用可以参考我的Java网络编程系列中关于NIO的相关文章

参考资料
《深入理解Java虚拟机 第二版》
《Java虚拟机规范(Java SE7版)》
理解Java虚拟机体系结构
目前主流的 Java 虚拟机有哪些?-知乎
jvm运行时数据区域解析
Java虚拟机原理图解
深入探讨 Java 类加载器

上一篇:《深入理解Java虚拟机》笔记01 -- 运行时数据区


下一篇:JVM 专题十三:运行时数据区(八)直接内存