一、概述

    本章讲解JAVA虚拟机内存的各个区域,介绍这些区域的作用、服务对象、以及其中可能产生的问题。

二、运行时数据区域

2.1 运行时数据区域主要分为(图2.1):

  1. 方法区(线程共享)
  2. 堆(线程共享)
  3. 虚拟机栈
  4. 本地方法区
  5. 程序计数器
(图2.1)

2.2 各个区域的定义与作用

2.2.1 程序计数器(PC计数器)

    程序计数器是一块较小的内存空间,是当前线程锁执行字节码的型号指示器。字节码解释器工作时就是通过改变计数器的值,来获取下一次需要执行的字节码指令。     JAVA虚拟机的多线程是通过线程轮流且切换、分配处理器执行时间的方式来实现的(抢占式)。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。 如果线程正在执行的是一个JAVA方法,那么这个计数器记录的是正在执行的虚拟机字节码指令的地址值。

2.2.2 JAVA虚拟机栈

    JAVA虚拟机栈是线程私有的,它的生命周期与线程相同。每个方法被执行的时候,JAVA虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。

(1)局部变量表:     是一组变量值存储空间,用于存放局部变量(这里的局部变量指的是基本数据类型:boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不是对象本身,可能是一个指向对象起始地址值的引用指针,也可能是指向一个代表对象的句柄,目前HotSpot虚拟机,主要存储的是引用指针) 和 returnAddress类型(它指向了一条字节码指令的地址)。局部变量存储在局部变量表中,随着线程而生,线程而灭。并且线程间数据不共享。

(2)操作数栈:     每个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出的操作数栈,也可以称为 表达式栈。操作数栈可以认为是,在一个方法中,用来暂时存放执行复制、交换、求和等操作的临时工作区,主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建出俩,这个方法的操作数栈是空的。如下图:

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

Math math=new Math();
math.compute();//调用实例方法compute();

    以上面两行代码为例,解释一下动态连接:math.compute()调用时compute()叫符号,需要通过compute()这个符号去到常量池中去找到对应方法的符号引用,运行时将通过符号引用找到方法的字节码指令的内存地址。

(4)方法出口:     当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),这种退出方法的方式称为正常完成出口。另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

    无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址(即前一栈帧的地址值),栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

PS:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。

2.2.3 JAVA本地方法栈

    本地方法栈与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行JAVA方法服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

2.2.4 JAVA堆

    JAVA堆是虚拟机所管理的内存中最大的一块。JAVA堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,JAVA世界里几乎所有的对象实例都在这里分配内存。JAVA堆是垃圾收集器管理的内存区域,从内存回收的角度看,由于现代垃圾收集器大部分都是基于分代收集理论设计的,所以JAVA堆经常会出现“新生代”、“老年代”、“永久代”、“Eden空间”、“From Survivor空间”、“To Survivor空间”等名词,这些区域划分仅仅是一部分垃圾收集器的共同特性或者设计风格而已,而非某个JAVA虚拟机具体实现的固有内存布局。

    如果从分配内存的角度来看,所有线程共享的JAVA堆中可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率,不过无论从什么角度,无论如何划分,都不会改变JAVA堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将JAVA堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

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

2.2.5 方法区

    方法区和JAVA堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即使编译器编译后的代码缓存等数据。方法区是堆的一个逻辑分区,本质上是属于堆内存,为了便于区分便称之为方法区,它还有个别名叫做“非堆”。

    在JDK8以前,许多人把“永久代”和方法区两者混为一谈,但本质上两者并不是等价的,因为“永久代”只是方法区的一种实现方式。JDK8以后,完全废弃了“永久代”的设计概念,取而代之的是通过本地内存实现的“元空间”,可以这么理解,JDK8之后的元空间,类似于之前的版本的“方法区”。为什么会放弃“永久代”?是因为通过永旧代来实现方法区,会使得JAVA应用更容易受到内存溢出的问题(永久代有-XX:MaxPermSize的上限),即使不设置也有默认的大小,而通过本地内存来实现的元空间,只要没触碰到进程可使用的上限,就没有什么问题。所以在JDK7的时候就把原本放在永久代的字符串常量池、静态变量移出。而到了JDK8则完全废弃了“永久代”的概念,把JDK7中永久代剩余的内容(主要是类型信息)全部移到元空间中。 PS:     1.方法区如果无法满足新的内存分配需求是,也会报OOM异常。     **2.运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量和符号引用,以及符号引用被翻译后的直接引用。**这部分内容将在类加载后存放到方法区的运行时常量池中。

三、虚拟机对象

3.1 概述

    本节主要介绍虚拟机内存中数据是如何创建、布局、访问的。基于实用优先的原则,以最常用的内存区域JAVA堆为例,深入探讨一下HotSpot虚拟机在JAVA堆中对象分配,布局和访问的全过程。

3.2 对象的创建

    这边的对象的创建指的是通过new的方式创建对象,不包括复制、反序列化。当java虚拟机遇到一条字节码new指令的时候会有以下步骤:

    1、会根据这个指令的参数去运行时常量池里面找到对应的类的符号引用(运行时常量池存储类对应Class文件的信息,JDK8后把运行时常量池移到元空间里去),如果找不到符号,说明没有这个类或对应的方法调用,在开发中,尤其是多人合作开发,本地代码更新引用了其他工程的类的方法或者字段,虽然在idea上并不会报错,但是当你本地工程打包的时候便会报找不到 xxxxxx.java 对应的符号引用,处理的方法需要你将应用的代码打包install到你的本地仓库才行。

    2、会检查这个符号引用对应的类是否已被加载、解析、初始化。如果没有那必须执行响应的类加载过程。类加载过程相对来说也比较复杂,类从被加载到JVM中开始,到卸载为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段,其中还涉及到双亲委派机制等等,此处不做展开,后续的章节会提到。

    3、通过类加载后,虚拟机会为新生对象分配内存,对象所需内存大小在类加载完后便可以完全确定(对象的大小包括:对象头、实例数据、对齐填充)。对象内存空间的分配实际上等同于把一块确定大小的内存从java堆里面划分出来**(有些情况不分配在堆里面,一般的对象,要求没有逃逸情况,而且对象大小不是很大,会优先在栈内存分配,因为在栈内存分配的对象GC效率很高,弹栈即GC)**。分配方式有“指针碰撞”方式、“空闲列表”方式,具体采用那种分配方式,取决于采用的垃圾收集器是否带有空间压缩的整理能力决定。

    4、内存分配完后,对分配到的内存空间(不包括对象头)初始化为零值

    5、设置对象头:对象属于哪个实例、对象哈希码值、对象GC分代年龄、是否启用偏向锁等。

    以上4个步骤从虚拟机角度来看,一个新对象已经产生,但从JAVA程序视角来看,对象的创建才刚刚开始,因为Class文件中的init方法还没执行,所有的字段都为默认的零值,new指令之后会接着init方法,按照程序员的医院对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

3.3 对象的内存的布局

  对象对内存中的存储布局可以氛围三个部分:对象头、实例数据、对齐填充。

3.3.1 对象头

  对象头部分包括两类信息:   一、用于存储对象自身运行数据:哈希码值、GC分代年龄、锁信息(锁状态标志、线程持有的锁、偏向锁ID)等。   二、类型指针:即对象指向它的类型元数据的指针,JAVA虚拟机通过这个指针来确定该对象是哪个类的实例。

3.3.2 实例数据

  实例数据是对象真正存储的有效信息,即我们在程序代码里所定义的各种类型的字段内容,无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。

3.3.3 对齐填充

  没有特别的含义,JVM虚拟机的自动内存管理系统要求对象大小必须是8字节的整数倍,对象头已经被设计成8的倍数,如果对象实例数据不是8的倍数,便要通过对齐填充来补全。

3.3.4 数组长度

  如果对象是一个数组,那么对象头还必须有一块用于记录数组的长度的数据,因为JAVA虚拟机可以通过普通JAVA对象的元数据信息来确定JAVA对象的大小,但是如果数组的长度是不确定,将无法通过元数据中的信息来推断出数组的大小来分配所需要的内存。

3.4 对象的访问定位

  JAVA程序会通过栈里面的局部变量表存储的reference数据(对象引用),来操作堆上具体的对象,HotSpot虚拟机采取的对象引用主要通过直接指针访问的方式来实现(其他类型的虚拟机有的采用句柄的方式),reference中存储的直接就是对象地址,它的好处就是访问速度块。   句柄指针: 如果使用句柄访访问的话,JAVA堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。   直接指针: 如果使用直接指针的话,JAVA堆对象中的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次简介访问的开销,如图2-3。   这两种访问各有优势,使用句柄来访问的足底啊好处是reference存储的是稳定的句柄,在对象被移动(垃圾回收,标记-复制,改变对象内存的地址值)时,只会改变句柄中的实例数据指针,而reference不需要修改。而使用直接指针访问的最大好处是访问速度更快。