0%

JVM2-Java内存区域

Java内存区域

下面从概念上介绍Java虚拟机内存的各个区域,讲解这些区域的作用、服务对象以及其中可能产生的问题,这是翻越虚拟机内存管理这堵围墙的第一步。

运行时数据区域

Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,包含以下几个运行时数据区域。

注意看图上分为线程共享数据区域线程私有数据区域

线程私有数据区

程序计数器

程序计数器(Program Counter Register)是比较小的一块内存空间,在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

一个处理器一时间只会执行一条线程的指令,因此线程切换后为了能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为线程私有的内存。

Java虚拟机栈

与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈入栈到出栈的过程。
在这个区域中,如果我们写一个回调的死循环可能会抛出StackOverFlow异常,或者是在区域大小动态扩展的时候申请不到足够的内存,也会抛出OutOfMemoryError异常。

本地方法栈

与虚拟机栈类似,不过是为Native方法服务的。虚拟机规范中没有强制的规定,HotSpot VM直接把本地方法栈和虚拟机栈合二为一了。

线程共享数据区

Java堆

对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块,此内存区域的唯一目的就是存放对象实例。由于现代GC基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;再细致一点还有Eden空间、From Survivor空间、To Survivor空间等。这个区域如果满了,会抛出OutOfMemoryError异常。

方法区

方法区(Method Area)用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在Hotspot VM中,这个区域被称为“永久代”(Permanent Generation),其他虚拟机则不存在永久代。并且使用永久代来实现方法区,容易遇到内存溢出问题(-XX:MaxPermSize),所以JDK8的HotSpot VM去掉“永久代”,以“元数据区”(Metaspace)替代之。在JDK7的HotSpot中,原本放在永久代的字符串常量池也被移除。这个区域如果满了,会抛出OutOfMemoryError异常。

运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分,图上面没有。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。那么运行时常量池相对Class文件常量池另外一个重要特征是动态性,并非Class文件中常量池的内容才能进入方法区运行时常量池,例如String的intern()方法就能将新的常量放入池中。常量池如果满了,会抛出OutOfMemoryError异常。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
JDK1.4加入的NIO,引入了基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这个堆外内存虽不会受堆大小的限制,但是受本机总内存(RAM+SWAP)大小以及处理器寻址空间的限制,所以可能会出现OutOfMemoryError异常。

对象探秘

对象创建

在语言层面上,创建对象只是一个new关键字而已,而在虚拟机中创建一个对象的过程呢?

  1. 当虚拟机遇到一条new指令,先检查指令参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过,如果没有,就先执行相应的类加载过程。
  2. 在类加载检查通过后,虚拟机为新生类分配内存(对象所需内存大小在类加载完成后已经确定),为对象分配空间就是把一块确定大小的内存从Java堆中划分出来。
    • 如果Java堆内存是规整的,使用指针碰撞方式。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。
    • 如果Java堆内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。如果垃圾收集器选择的是CMS这种基于标记-清除算法的,虚拟机采用这种分配方式。
      除了可用空间外,还有个问题是在虚拟机中创建并发创建对象也不是线程安全的,有两个方案解决这个问题:
      • 对分配内存空间的动作进行同步处理
      • 使用本地线程分配缓冲(Thread Local Allocation Buffer, TLAB),即每个线程在Java堆中预先分配一小块内存。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。虚拟机是否使用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
  3. 内存分配完成,虚拟机需要将分配到的内存空间都初始化为零值。这一步保证了对象的实例字段不被赋值就可以使用对应字段的零值。
  4. 虚拟机进行必要设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的GC分代年龄信息。这些信息放在对象的对象头中。
  5. 从虚拟机角度来看一个新的对象已经产生了,但从Java程序的视角来看,对象创建才刚开始—-<init>方法没有执行,所有的字段都还为零。接下来执行<init>方法,按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

对象的内存布局

在Hotspot虚拟机中,对象在内存中存储的布局可用分为3块区域:对象头,实例数据,和对齐填充。

  1. 对象头包含2部分数据,第一部分用于存储对象自身的运行时数据(如哈希吗、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等)。第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例,不过这跟对象访问定位的实现方式有关系,下面介绍。
  2. 实例数据就是对象真正存储的有效信息,包括从父类继承下来的。
  3. 对齐填充起占位符的作用,因为HotSpot VM的自动内存管理系统要求对象的起始地址必须的8字节的整倍数。

对象的访问定位

建立对象是为了使用对象,我们的Java程序需要通过栈上的reference数据来操作堆上的具体对象,目前主流的访问方式有使用句柄和直接指针两种。

  1. 如果使用句柄访问,Java堆中会划分一块内存来做句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。如图所示
  2. 如果使用直接指针访问,那么Java堆的对象布局中就包含了类型指针,而reference中存储的直接就是对象地址。(这种方式类型指针就在对象数据中)。HotSpot就是使用的这种方式,如图所示