Java内存模型与对象的探秘

 2019-12-10 16:07  阅读(892)
文章分类:Java Core

前言:最近看了《深入jvm》一书,感受颇深,但是不写点什么总感觉不是自己的,所以动手捋一捋。主要讲的内容是java的内存区域,对象的创建,对象的内存布局和对象的访问方式。

一、java的内存区域划分

这个问题几乎是面试官必问的问题,很多人都会直接回答:“堆和栈”。其实这种划分是很粗略的,要是遇到认真的面试官,你就尴尬了。说个题外话,不要把“栈”说成“堆栈”,虽然说很多书上它们是等同的,但是还是有部分面试官不买你的账,博主就因此跌过坑。接下来看看java内存区域的细致划分吧,如下图:

20191210001581\_1.png

看了这个模型,发现我们使用堆和栈划分内存区域还是很有道理的,java虚拟机规范将方法区描述为堆的一个逻辑部分。这种模型和我们粗略的内存模型的对应关系为(等号左侧是粗略的模型,右侧是细致的模型):

堆=堆+方法区

栈=虚拟机栈+本地方法栈

所以说这种堆和栈的划分事实上已经囊括了大部分的内存区域。

接下来我们来看看各个内存区域的区别:

  • 程序计数器:线程隔离,即每个线程都有自己的程序计数器,并且互不影响。分为两种情况,当线程正在执行的是一个java方法,它的作用是作为字节码的行号指示器,指向下一条需要执行的指令。当线程正在执行的是一个Native方法,那么它的值为空(Undefined)。java虚拟机规范中唯一没有定义OOM异常的内存区域。
  • java虚拟机栈:线程隔离,生命周期与线程相同。它描述的是java方法执行的内存模型,每一个java方法执行的时候都会产生一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息。当进入一个方法时,栈帧的大小是编译器确定的,运行时不会改变其大小。当虚拟机栈不可扩展的时候,可能抛出StackOverflowError异常,反之,可能抛出OOM异常。
  • 本地方法栈:与java虚拟机栈功能一致,只不过本地方法栈是针对Native方法的。同样在虚拟机规范中定义了StackOverflowError和OOM两种异常。
  • Java堆:这个应该是我们最熟悉的区域了,只要是用到new关键字创建的对象都会进入到这个区域,包括对象,数组。堆还能进一步划分,比如按照内存回收的角度来看,堆可以进一步划分为新生代(Eden+Survivor)和老年代。按照内存分配的角度来看,堆可以进一步划分为多个线程私有的分配缓存区,即TLAB(Thread Local Allocation Buffer)。这种进一步的划分是为了更高效地回收和分配内存。java虚拟机规范中定义了OOM异常。
  • 方法区:这个区域很容易引发误会,很多人会以为方法区会存储方法中的局部变量,然而并不是。这个区域用于存储被加载的类的信息,常量,静态变量以及即时编译器编译后的代码等数据。虚拟机规范中定义了OOM异常。还需要注意的一点是,HotSpot虚拟机中的方法区被很多人称之为“永生代”,这是因为HotSpot的开发团队将分代收集算法运用到了方法区,但是这并不是必须的。

附:再说说直接内存的概念,为什么分开来说呢?这是因为直接内存并不是java运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域。直接内存事实上是系统中没有分配给当前进程的内存,因为系统分配给一个进程的空间往往是有限的,所以直接内存常被用来扩展可用的内存区域,也可以用来提升性能。原理是这样的,使用Native函数库直接分配堆外内存,通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,从而避免了在java堆和Native堆之间复制数据的开销。

二、对象的创建

大体上分为4个步骤:类加载、分配内存、内存区域的初始化、虚拟机对对象进行必要的设置。

  • 类加载:虚拟机遇到一条new指令的时候,首先检查是否有必要进行类加载,即检查指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用是否已经被加载、解析和初始化,若没有,则进行类加载。
  • 分配内存:若是虚拟机的垃圾回收器有压缩整理内存的功能,即指针将内存区域分为两个部分,一边已分配,另一边未分配,则采用指针碰撞(Bump the Pointer)进行内存分配。否则采用空闲列表(Free List)分配内存。
  • 内存空间初始化为零值:顾名思义,将对象分配到的内存空间进行初始化,但是不包括对象头。
  • 对对象进行一些必要的设置:例如这个对象是哪个类的实例,如何找到类的元数据信息,对象哈希码,GC分代年龄等信息。这些信息存放在对象头(Object Header)中。

至此,虚拟机认为这个对象已经创建完毕,但是在我们程序中,这个对象才刚刚可以使用。

三、对象的布局

以HotSpot虚拟机为例,对象在内存中的存储布局分为三个部分:对象头(Object Header)、实例数据(Instance Data)、对齐填充(Padding)。

  • 对象头:包括两部分的信息,第一部分用于存储对象本身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。第二部分用于存储对象的类元数据指针,虚拟机可以通过这个指针找到对象的类元数据。(还有通过句柄寻找对象的并不需要这个指针,下面将会说到)
  • 实例数据:实例数据没啥好说的,就是对象真正存储的有效信息,代码中定义的各类型字段内容。
  • 对齐填充:它并不是必须的,仅仅起到了占位符的作用。因为HotSpot虚拟机的自动内存管理系统要求对象起始地址必须要是8的整数倍,而对象头刚好就是8的整数倍,因此我们只需要对实例数据进行部分填充就可以使得对象的起始地址是8的整数倍。这种考虑是为了设计简单和寻址效率。

四、对象的访问方式

了解了对象的创建和对象在内存中的布局之后,接下来就应该考虑如何去访问它了。对象的访问方式大体上分为两种,一种是直接指针,一种是句柄池,取决于具体的虚拟机实现。

  • 句柄池:这种方式java堆需要划分一块内存来作为句柄池,对象的引用reference指向的就是句柄地址,而句柄地址中存放的是对象的实例数据和类型数据的地址指针。这种方式也就是我们上面说到的,可以不通过对象来找到其类元数据,此时对象的对象头可以不存类型数据的指针。模型图如下:

20191210001581\_2.png

  • 直接指针:这种方式就是我们常规的认识,因为我们经常是使用HotSpot虚拟机的,而这个虚拟机就是采用直接指针实现。这种方式对象的引用reference指向对象的存放空间地址。模型如下:

20191210001581\_3.png
这种访问方式有什么区别呢?句柄访问方式是稳定的句柄地址,在对象被移动的时候,只会改变句柄中实例数据的指针,而reference本身不需要修改,但是多了一次寻址开销。直接指针则是快,但是相应地在同场景下需要修改reference指针值。这些修改时虚拟机自动完成的,对程序员透明,但是相应的开销是无法避免的。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java内存模型与对象的探秘

相关推荐