0%

Java 虚拟机 3:Java内存模型-内存区域划分以及对象创建的过程

一、概述

与 C、C++ 相比,Java 把内存控制权交给了虚拟机,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去配对delete/free代码,最直接的好处就是不容易出现内存泄露和内存溢出问题,但是一旦出现内存泄露和内存溢出的问题,如果不了解JVM是如何划分内存区域的,那就难以排查问题,所以了解虚拟机的内存区域以及会引起内存泄露和内存溢出的常见场景,将会帮助我们更快的排查问题,这也是成为 OOM Killer 的基本素质。

二、内存区域划分

java虚拟机在执行Java程序的过程中会把他所管理的内存划分为若干个不同的数据区域。Java虚拟机规范将JVM所管理的内存分为以下几个运行时数据区:程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区。如下图所示:

上图描述了Java虚拟机运行时的数据区,下面再详细讲下各个区域的特点,下面我用思维导图的形式描述。

三、对象创建的过程

Java是一门面向对象的语言,Java程序运行过程中无时无刻都有对象被创建出来。在语言层面上,创建对象(克隆、反序列化)就是一个new关键字而已,但再背后虚拟机为我们做了哪些事呢?下面看一下在虚拟机层面上创建对象的步骤:

1、虚拟机遇到一条new指令,首先去检查这个指令的参数能否在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化。如果没有,那么必须先执行类的初始化过程。

2、类加载检查通过后,虚拟机为新生对象分配内存。对象所需内存大小在类加载完成后便可以完全确定,为对象分配空间无非就是从Java堆中划分出一块确定大小的内存而已。这里分配内存的方式有两种:

  • 如果内存是规整的,那么虚拟机将采用的是指针碰撞法来为对象分配内存。意思是所有用过的内存在一边,空闲的内存在另外一边,中间放着一个指针作为分界点的指示器,分配内存就仅仅是把指针向空闲那边挪动一段与对象大小相等的距离罢了。如果垃圾收集器选择的是Serial、ParNew这种基于压缩算法的,虚拟机采用这种分配方式。

  • 如果内存不是规整的,已使用的内存和未使用的内存相互交错,那么虚拟机将采用的是空闲列表法来为对象分配内存。意思是虚拟机维护了一个列表,记录上哪些内存块是可用的,再分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的内容。如果垃圾收集器选择的是CMS这种基于标记-清除算法的,虚拟机采用这种分配方式。

另外需要特别注意的是new对象时候的线程安全性。因为可能出现虚拟机正在给对象A分配内存,指针还没有来得及修改,对象B又同时使用了原来的指针来分配内存的情况。虚拟机采用了CAS配上失败重试的方式保证更新更新操作的原子性和TLAB两种方式来解决这个问题。

3、内存分配结束,虚拟机将分配到的内存空间都初始化为零值(不包括对象头)。这一步保证了对象的实例字段在Java代码中可以不用赋初始值就可以直接使用,程序能访问到这些字段的数据类型所对应的零值。

4、对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象的对象头中。

5、执行方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

整个过程可以简要概括为: 检查加载类 -> 给对象分配内存 -> 初始化零值 -> 设置对象头信息 -> 调用构造函数初始化

四、对象的内存布局

在 HotSpot 虚拟机中,对象在内存中的布局分为3快区域:对象头(Header)、实例数据(Instance Data)、对其填充(Padding)。如下图所示:

五、对象的访问

引用存放在虚拟机栈中,数据类型为reference,对象实例存放在堆中。
而我们的Java程序需要通过栈上的 reference 数据(引用)来操作堆上的具体对象,那么引用是如何指向对象实例的呢?主流的访问方式有两种:

第一种是通过句柄池,如果使用句柄池,那么java堆中将会划分出一部分内存作为句柄池,句柄包含对象类型指针指向方法区的类型信息,还有对象实例指针,指向堆中的实例地址。如下图所示:

第二种是reference引用直接指向堆中的对象实例,对象实例的对象头存放对象类型指针。如下图所示:

对比:
两种方法各有优势,第一种可以在对象实例在GC时移动的时候只改变句柄池中的对象实例数据指针,而不用改变reference引用本身。第二种方法就是访问速度快,减少了一次指针定位的时间开销。目前HotSpot虚拟机就采用的第二种方式。

六、参考文献

《深入理解Java虚拟机》 –第二章 周志明著