天天看点

深入理解Java虚拟机读书笔记(一)- 运行时数据区域

运行时数据区域

深入理解Java虚拟机读书笔记(一)- 运行时数据区域

1. 程序计数器

程序计数器是线程私有的一块较小的内存区域。可以看做是当前线程所执行的字节码的行号指示器,类似于通用寄存器中的PC寄存器,不同的是,PC寄存器是在CPU中的一个寄存器,而这个程序计数器是有Java虚拟机自己实现的一个数据结构。如果线程执行的是一个Java方法,则程序计数器中记录的是正在执行的虚拟机字节码指令的地址;如果执行的是native方法,则这个计数器的值为空。此处内存区域是唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError的区域。

PC寄存器:用于存放下一条即将执行的指令的地址。

IR寄存器:用于存放正在执行的指令。

在计算机的程序的执行就是靠的是,取指执行。PC寄存器就是用于存储下一条即将执行的指令的地址,所以可以通过修改PC寄存器中的值,来控制指令执行的流程。

当计算机要开始执行指令时,分为以下几个步骤:

  1. 从PC寄存器中获取下一条将要执行的指令的地址
  2. 通过指令的地址从存储器中获取指令存入IR寄存器中
  3. 控制器解析这条指令
  4. 控制器控制其他部件完成这条指令

2. Java虚拟机栈

Java虚拟机栈时线程私有的,生命周期和线程相同,为执行Java方法服务。每个方法执行的时候会朝当前线程的Java虚拟机栈中压入一个栈帧,用于存放局部变量表、操作数栈、动态链接、方法出口等,方法执行完毕的时候会从栈顶弹出,而中途如果又调用了其他方法,那么就会生成另一个方法对应的栈帧压入Java虚拟机栈栈顶。

在《Java虚拟机规范》中,对这个内存区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的的深度,就会报StackOverflowError,多见于递归不合适的时候;如果Java虚拟机栈容量可以动态扩展,那么当栈扩展时无法申请到足够的内存会抛出OutOfMemoryError。

2.1 栈帧结构

每一个栈帧都包括了,局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在程序编译的时候,每个方法的栈帧需要多大的局部变量表,多深的操作数栈都已经全部确定了,并且写入了到了方法表的Code属性中,所以一个方法的栈帧需要分配多少内存,不会受到运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

在线程中,方法的调用链会很长,那么对于执行引擎来说,在Java虚拟机栈栈顶的栈帧才是有效的。

2.1.1 局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为class文件时,就在方法的Code属性的max_locals数据项中确定了该方法需要分配的局部变量表的最大容量,这里的大小指的是变量槽的数量。

局部变量表中的容量以变量槽(Variable Slot)为最小单位。虽然Java虚拟机规范中没有明确指明每个Slot应该占用的内存空间的大小,只是有导向性的说到每个Slot都应该能存放一个boolean、byte、short、int、float、reference或returnAddress类型的数据,这个8中数据类型都可以使用32位或者更小的物理内存来存放,这几个数据类型的占用都是在4个字节以内的。

reference:表示一个对象实例的引用。

returnAddress:他是为字节码指令:jsr、jsr_w、ret服务的,指向了一条字节码指令的地址。在很古老的虚拟机中用这几条指令来实现异常处理,目前的异常处理都由异常表代替。

对于64位(8字节)的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。Java语言中明确的64位(8字节)的数据类型有long、double,reference则是有可能是32位或者64位。由于Java虚拟机栈是线程私有的,所以连续读取两个Slot不会引发线程安全问题。

long和double的非原子性协定

虚拟机使用索引定位的方式来使用局部变量表,如果访问的是32位的数据类型,则索引n就代表了第n个Slot;如果是64位的数据类型,则说明会使用n和n+1两个Slot。在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static的方法),那局部变量表的第0位索引就代表了方法所属对象实例的引用,使用this关键字来访问到这个隐含的参数。其余方法参数则按照参数表顺序排列,占用从1开始的局部变量Slot,参数分配完毕之后,在根据方法体内部定义的变量顺序以及作用域分配Slot,局部变量表中的Slot是可以重用的,假如当前指令计数器已经超过了某个变量的作用域,那么这个变量对应的Slot就可以被其他变量占用。在这种情况下可能会引起系统的垃圾回收行为。

代码:

在执行的时候,添加参数:-verbose:gc,来查看gc的过程。

public static void main(String[] args){
    //向内存中填充了64MB的数据
    byte[] placeholder = new byte[64*1024*1024];
    //通知虚拟机进行垃圾收集
    System.gc();
}
           

在这种情况下不会回收这64MB的内存。因为执行System.gc()的时候还处于作用域中。

public static void main(String[] args){
    //向内存中填充了64MB的数据
    {
    	byte[] placeholder = new byte[64*1024*1024];
    }
    //通知虚拟机进行垃圾收集
    System.gc();
}
           

修改了作用域,但是还是没有回收掉。

public static void main(String[] args){
    //向内存中填充了64MB的数据
    {
    	byte[] placeholder = new byte[64*1024*1024];
    }
    int a = 0;
    //通知虚拟机进行垃圾收集
    System.gc();
}
           

这种情况下收集成功了。

在第二种情况也不会进行垃圾收集,因为在局部变量表中还存有placeholder的引用。在这种情况下,placeholder的Slot还没有被其他变量复用。那么在第三种情况的时候,多加了一个a变量,那么原有的placeholder变量所在的Slot被变量a复用了,就没有placeholder的引用了。

2.1.2 操作数栈

称为操作栈,是一个先入后出的栈。和局部变量表一样,栈的深度也在编译的时候写入到了max_stacks数据项中,操作数栈可以是任意的Java数据类型,32位的栈容量为1,64位的栈容量为2。

当一个方法开始执行的时候,这个方法的操作数栈的空的,在执行过程中会有各种字节码指令往操作数栈中写入和提取内容,比如在做算术运算的时候,iadd 1,2:会把1和2压入操作数栈顶,然后执行add操作的时候会将操作数栈栈顶的两个数出栈并相加,相加之后的结果压入栈顶。操作数栈中的数据的数据格式必须和指令的序列严格匹配,比如iadd在执行时,栈顶的两个元素必须是int类型。

2.1.3 动态连接

每个栈帧都包含一个执行运行时常量池中改栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的态连接。

2.1.4 方法返回地址

方法返回有两种方式:

(1)方法正常结束,会有一个方法返回的字节码指令。

(2)方法异常结束,在执行过程中遇到了异常,且这个异常没有在方法体中得到处理、就会导致方法退出。

那么在方法正常退出时,需要退出到上层方法调用本方法的位置,那么就需要在调用本方法时,将当前指令计数器中的值保存下来,当本方法退出时,就可以将指令计数器的值设置为保存的计数器的值。如果方法异常退出的话,返回地址要通过异常处理器表来确定。

方法退出的可能过程:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值指向方法调用者指令后面一条指令。

3 本地方法栈

本地方法栈的作用和Java虚拟机栈是相似的,不同的是,本地方法栈是为本地方法(native)服务的。与Java虚拟机一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常。

4. Java堆

Java堆的最大作用就是用于存放对象实例数据,是线程之间共享的,“几乎”所有的对象实例都在Java堆内分配内存,注意“几乎”是大部分,而之后的实现中有可能会有栈上分配,标量替换等优化手段。Java堆也是垃圾收集器管理的内存区域。《Java虚拟机规范》中规定,Java堆在物理上可以处于不连续的内存空间中,但是在逻辑上应该是被视为连续的。

Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,Java虚拟机将会抛出OutOfMemoryError异常。

5. 方法区

在Java虚拟机规范中定义:方法区也是线程共享的,它用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,但是具体实现很难。根据《Java虚拟机规范》的规定,如果方法区无法满足新的内存分配需求时,将抛出OutOfMemoryError异常。

Hotspot虚拟机中的实现:到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载。

5.1 运行时常量池

运行时常量池是方法区的一部分。在class文件中,有一个重要的部分是常量池表,用于存放编译期间生成的各种字面量与符号引用,这部分内容在类加载之后存放到方法区的运行时常量池中,一般来说,会保存除了class文件中描述的符号引用之外,还会把由符号引用翻译出来的直接引用也存储在运行时常量池中。运行时常量池是动态的,在运行期间也可以将新的常量放入池中,比如String的intern()方法。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。

6. 直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,在JDK1.4中加入了NIO,他可以使用native函数库直接分配堆外内存,堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。本机直接内存的分配不会受到Java堆的大小的限制,但是会受到本机总内存限制。

直接内存的OOM设置:Direct buffer memory:OutofMemory

通过MaxDirectMemorySize设置

如果不指定,默认与堆的最大值-Xmx参数一致

☆☆☆ 显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。