深入理解Java虚拟机:(七)Java对象的内存布局

一、运行数据区域

想要了解 JVM 在执行 Java 程序的过程中会把内存划分成不同的数据区域,每个区域都有不同的作用以及创建时间、销毁时间。下面是运行时数据区的结构图,我们接下来就一一来详细分析各个区域起的作用。
在这里插入图片描述

1、程序计数器

  • 线程内存独享,占用内存小,生命周期与线程相同(随线程诞生而诞生,随线程消亡而消亡)。
  • 记录正在执行的虚拟机字节码指令的地址(如果正在执行的是本地方法则为空)。

2、Java 虚拟机栈

每个 Java 方法在执行的同时会创建一个栈帧用于存储局部变量表、操作数栈、常量池引用等信息。 从方法调用直至执行完成的过程,就对应着一个栈帧在 Java 虚拟机栈中入栈和出栈的过程。 对于执行引擎来说,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法。 执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。

在这里插入图片描述
(1)、局部变量表

局部变量表存放了编译器可知的基本数据类型和对象引用(reference 类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和 returnAddress 类型(指向了一条字节码指令的地址)。

局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。

(2)、操作数栈

一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)。

操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问

当然操作数栈所需的容量大小在编译期就可以被完全确定下来,并保存在方法的Code属性中。
可以通过 -Xss 这个虚拟机参数来指定每个线程的 Java 虚拟机栈内存大小:

java -Xss512M HelloTest

该区域可能抛出以下异常:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常。
  • 如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError 异常。

3、本地方法栈

本地方法栈与 Java 虚拟机栈类似,它们之间的区别只不过是本地方法栈为本地方法服务。

本地方法一般是用其它语言(C、C++ 或汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

4、Java 堆

Java 堆是垃圾收集器管理的主要区域。
从内存回收的角度来看,由于现在收集器基本都采用分代收集算法。所以,Java 堆可细分为新生代老年代;再细分一点的话新生代可细分为 Eden 区、From Survivor 区、To Survivor 区。
从内存分配的角度来看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。
无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存。

可以通过 -Xms-Xmx 两个虚拟机参数来指定一个程序的堆内存大小,第一个参数设置初始值,第二个参数设置最大值。

java -Xms512M -Xmx512M HelloTest

5、方法区

用于存放已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

和堆一样不需要连续的内存,并且可以动态扩展,动态扩展失败一样会抛出 OutOfMemoryError 异常。

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难实现。

HotSpot 虚拟机把它当成永久代来进行垃圾回收。但是很难确定永久代的大小,因为它受到很多因素影响,并且每次 Full GC 之后永久代的大小都会改变,所以经常会抛出 OutOfMemoryError 异常。为了更容易管理方法区,从 JDK 1.8 开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存中。

6、运行时常量池

运行时常量池是方法区的一部分。

Class 文件中的常量池(编译器生成的各种字面量和符号引用)会在类加载后被放入这个区域。

除了在编译期生成的常量,运行期间也可将新的常量放入池中,例如 String 类的 intern()。

7、直接内存

在 JDK 1.4 中新加入了 NIO 类,它可以使用 Native 函数库直接分配堆外内存(Native 堆),然后通过一个存储在 Java 堆里的 DirectByteBuffer 对象作为这块内存的引用进行操作。

这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

二、HotSpot 虚拟机对象

1、对象的创建

(1)、类加载检查

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

(2)、分配内存

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。 对象所需的内存大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。分配方式有 “指针碰撞” 和 “空闲列表” 两种,选择那种分配方式由 Java 堆是否规整决定, 而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。

在这里插入图片描述

(3)、内存分配并发问题

对象的创建在虚拟机中是非常频繁的,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。解决这个问题通常有两种方案:

  • CAS+失败重试: CAS 是乐观锁的一种实现方式。所谓乐观锁就是, 每次不加锁而是假设没有冲突而去完成某项操作, 如果因为冲突失败就重试,直到成功为止。 虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。
  • TLAB: 每一个线程预先在Java堆中分配一块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)。 哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才采用上述的CAS进行内存分配。

(4)、初始化零值

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

(5)、设置对象头

初始化零值完成之后,虚拟机要对对象进行必要的设置, 例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希吗、对象的 GC 分代年龄等信息。 这些信息存放在对象头中。 另外,根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

(6)、执行< init >方法

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了, 但从 Java 程序的视角来看,对象创建才刚开始,< init > 方法还没有执行,所有的字段都还为零。 所以,一般来说(由字节码中是否跟随 invokespecial 指令所决定),执行 new 指令之后会接着执行 < init > 方法, 把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来。

2、对象的内存布局

在 Hotspot 虚拟机中,对象在内存中的布局可以分为3块区域:

  • 对象头(Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

(1)、对象头

Hotspot虚拟机的对象头包括两部分信息:

  • 存储对象自身的运行时数据(哈希码、GC分代年龄、锁状态标志等等)。
  • 类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。

(2)、实例数据

实例数据部分是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。

(3)、对齐填充

对齐填充部分不是必然存在的,也没有什么特别的含义,仅仅起占位作用。 因为 Hotspot虚拟机的自动内存管理系统要求对象起始地址必须是8字节的整数倍, 换句话说就是对象的大小必须是8字节的整数倍。而对象头部分正好是8字节的倍数(1倍或2倍), 因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

(4)、对象的访问定位

建立对象就是为了使用对象,我们的Java程序通过栈上的 reference 数据来操作堆上的具体对象。 对象的访问方式也是取决于虚拟机的实现而定,目前主流的访问方式有两种:

  • 使用句柄
    如果使用句柄的话,那么Java堆中将会划分出一块内存来作为句柄池, reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
    在这里插入图片描述
  • 直接指针
    如果使用直接指针访问,那么 Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息, 而 reference 中存储的直接就是对象的地址。
    在这里插入图片描述

这两种对象访问方式各有优势:

  • 使用句柄来访问的最大好处是 reference 中存储的是稳定的句柄地址, 在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。
  • 使用直接指针访问方式最大的好处就是速度快,它节省了一次指针定位的时间开销。
发布了333 篇原创文章 · 获赞 201 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/riemann_/article/details/104056486