在 Java 程序中,我们拥有多种新建对象的方式。除了 new 语句外,我们还可以通过反射、Object.clone 方法、反序列化以及 Unsafe.allocateInstance 方法来新建对象。其中,Object.clone 方法和反序列化通过直接复制已有的数据来初始化新建对象的实例字段。Unsafe.allocateInstance 方法则没有初始化实例字段,而 new 语句和反射机制则是通过调用构造器来初始化实例字段。

以 new 语句为例,它编译而成的字节码将包含用来请求内存的 new 指令以及用来调用构造器的 invokespecial 指令,如下所示:

  1. // Foo foo = new Foo(); 编译而成的字节码
  2. 0 new Foo
  3. 3 dup
  4. 4 invokespecial Foo()
  5. 7 astore_1

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

在类加载检查通过后,虚拟机将为新生对象分配内存。对象所需内存大小在类加载完成后便可完全确定,为对象分配空间的任务实际上等同于把一块确定大小的内存块从 Java 堆中划分出来。在分配内存空间时需要进行同步操作,比如采用 CAS(Compare And Swap)失败重试、区域加锁等方式保证分配操作的原子性。

对象的创建过程

1. 内存分配方式

假设 Java 堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那么分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为 指针碰撞(Bump The Pointer)。

但如果 Java 堆中的内存并不是规整的,已被使用的内存和空闲的内存相互交错在一起,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为 空闲列表(Free List)。

选择哪种分配方式由 Java 堆是否规整来决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有空间压缩整理的能力决定。当使用 SerialParNew 等带压缩整理过程的收集器时,系统采用的是指针碰撞,既简单又高效;而当使用 CMS 这种基于清除算法的收集器时,理论上就只能采用较为复杂的空闲列表来分配内存。

2. 并发分配时的线程安全问题

除如何划分可用空间外,还有另外一个需要考虑的问题:对象创建在虚拟机中是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。

解决这个问题有两种可选方案:

一种是对分配内存空间的动作进行同步处理——实际上虚拟机是采用 CAS 配上失败重试的方式保证更新操作的原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块内存,称为 本地线程分配缓冲(TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机可通过 -XX:+/-UseTLAB 参数来开启 TLAB。

3. 初始化

内存分配完成之后,虚拟机必须将分配到的内存空间(但不包括对象头)都初始化为零值,如果使用了 TLAB 的话,这一项工作也可以提前至 TLAB 分配时顺便进行。这步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就直接使用,使程序能访问到这些字段的数据类型所对应的零值。

接下来,Java 虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到该类的元数据信息、对象的哈希码(实际上对象的哈希码会延后到真正调用 hashCode() 方法时才计算)、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头(Object Header)之中。根据虚拟机当前运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

4. 构造函数

在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了。但从程序的视角来看,对象创建才刚刚开始——构造函数,即 Class 文件中的 <init>() 方法还没有执行,所有的字段都为默认的零值。一般在 new 指令之后会接着执行 () 方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。

子类的构造器需要调用父类的构造器。如果父类存在无参数构造器的话,该调用可以是隐式的,即 Java 编译器会自动添加对父类构造器的调用。如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器。

  1. // Foo类构造器会调用其父类Object的构造器
  2. public Foo();
  3. 0 aload_0 [this]
  4. 1 invokespecial java.lang.Object() [8]
  5. 4 return

显式调用又可分为两种,一是直接使用 super 关键字调用父类构造器,二是使用 this 关键字调用同一个类中的其他构造器。无论是直接的显式调用还是间接的显式调用,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段。

总而言之,当我们调用一个构造器时,它将优先调用父类的构造器,直至 Object 类。这些构造器的调用者皆为同一对象,也就是通过 new 指令新建而来的对象。实际上,通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但子类的实例还是会为这些父类实例字段分配内存的。

对象的内存布局

在 HotSpot 虚拟机里,对象在堆内存中的布局可划分为三个部分:对象头实例数据对齐填充
对象.jpeg

1. 对象头

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

第一类是用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32 个比特和 64 个比特,官方称它为 Mark Word。对象头里的信息是与对象自身定义的数据无关的额外存储成本,考虑到虚拟机的空间效率,Mark Word 被设计成一个动态的数据结构,以便在极小的空间内存储尽量多的数据。

对象头的另外一部分是 类型指针,即对象指向它的类型元数据的指针,Java 虚拟机通过这个指针来确定该对象是哪个类的实例。但并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据信息并不一定要经过对象本身。此外,如果对象是一个 Java 数组,则对象头中还必须有一块用于记录 数组长度
的数据,因为虚拟机可以通过普通 Java 对象的元数据信息确定 Java 对象的大小,但如果数组的长度是不确定的,将无法通过元数据中的信息推断出数组的大小。

1.1 压缩指针

在 64 位的 Java 虚拟机中,对象头的运行时数据占 64 位,而类型指针又占了 64 位。即每一个 Java 对象在内存中的额外开销是 16 个字节。以 Integer 类为例,它仅有一个占 4 个字节的 int 类型的私有字段,但 Integer 对象的额外内存开销为 16 个字节,膨胀了 400%。这也是为什么 Java 要引入基本类型的原因之一。

为了尽量较少对象的内存使用量,在 64 位的 Java 虚拟机引入了压缩指针的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32 位的。这样对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字节(Mark Word 不会被压缩)。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组。

1.2 压缩指针的原理

打个比方,路上停着的全是房车,而且每辆房车恰好占据两个停车位。现在我们按照顺序给它们编号,停在 0 号和 1 号停车位上的叫 0 号车,停在 2 号和 3 号停车位上的叫 1 号车,依次类推。原本的内存寻址用的是车位号。比如我有一个值为 6 的指针,代表第 6 个车位,那么沿着这个指针可以找到 3 号车。现在我们规定指针里存的值是车号,比如 3 指代 3 号车。当需要查找 3 号车时,我便可以将该指针的值乘以 2,再沿着 6 号车位找到 3 号车了。

这样一来,32 位压缩指针最多可以标记 232 辆车,对应着 233 个车位。不过房车也会有大小之分,大房车占据的车位可能是三个甚至是更多。不过这并不会影响我们的寻址算法,我们只要保证每辆车都从偶数号车位停起即可,这个概念我们称之为 内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8)。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了,这些浪费掉的空间我们称为对象间的填充。
image.png
在默认情况下,Java 虚拟机中的 32 位压缩指针可以寻址到 232 * 23 = 235 次方个字节,也就是 32GB 的地址空间,超过 32GB 则会关闭压缩指针。在对压缩指针解引用的时候,我们需要将其左移 3 位,再加上一个固定偏移量,便可以得到能够寻址 32GB 地址空间的伪 64 位指针了。

参考链接:https://www.baeldung.com/jvm-compressed-oops

2. 实例数据

实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,上面讲到,实例数据其实涵盖了所有父类中的实例字段,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但子类的实例还是会为这些父类实例字段分配内存的。

2.1 字段重排序

实例数据的存储顺序会受虚拟机分配策略参数 -XX:FieldsAllocationStyle 和字段在 Java 源码中定义顺序的影响,因为 HotSpot 虚拟机在存储实例数据时会重新分配字段的先后顺序,以达到内存对齐的目的。但不管选择了何种分配策略,Java 虚拟机都会遵循如下两个规则:

其一:如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值。以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。

其二:子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。

如果 HotSpot 虚拟机的 +XX:CompactFields 参数值为 true(默认为 true),那子类之中较窄的变量也允许插入父类变量的空隙之中,以节省出一点空间。

代码示例:

  1. class A {
  2. long l;
  3. int i;
  4. }
  5. class B extends A {
  6. long l;
  7. int i;
  8. }

下面通过 JOL 工具打印了 B 类在启用压缩指针和未启用压缩指针时,各个字段的偏移量:

  1. # 启用压缩指针时,B类的字段分布
  2. B object internals:
  3. OFFSET SIZE TYPE DESCRIPTION
  4. 0 4 (object header)
  5. 4 4 (object header)
  6. 8 4 (object header)
  7. 12 4 int A.i 0
  8. 16 8 long A.l 0
  9. 24 8 long B.l 0
  10. 32 4 int B.i 0
  11. 36 4 (loss due to the next object alignment)
  12. Instance size: 40 bytes
  13. Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

当启用压缩指针时,可以看到 Java 虚拟机将 A 类的 int 字段放置在了 long 字段的前面,以填充因为 long 字段对齐造成的 4 字节缺口。由于对象整体大小需要对齐至 8N,因此对象的最后会有 4 字节的空白填充。

  1. # 关闭压缩指针时,B类的字段分布
  2. B object internals:
  3. OFFSET SIZE TYPE DESCRIPTION
  4. 0 4 (object header)
  5. 4 4 (object header)
  6. 8 4 (object header)
  7. 12 4 (object header)
  8. 16 8 long A.l
  9. 24 4 int A.i
  10. 28 4 (alignment/padding gap)
  11. 32 8 long B.l
  12. 40 4 int B.i
  13. 44 4 (loss due to the next object alignment)
  14. Instance size: 48 bytes
  15. Space losses: 4 bytes internal + 4 bytes external = 8 bytes total

当关闭压缩指针时,B 类字段的起始位置需对齐至 8N。这么一来,B 类字段的前后就各有 4 字节的空白。

2.2 @Contended

Java 8 还引入了一个新的注释 @Contended,用来解决对象字段之间的伪共享(false sharing)问题。这个注释也会影响到字段的排列。

伪共享是怎么回事呢?

伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响而导致性能降低。

假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。但如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。
Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,但因此也会有大量的空间被浪费掉。

3. 对齐填充

对齐填充不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot 虚拟机的自动内存管理系统要求对象起始地址必须是 8 字节的整数倍,换句话说就是任何对象的大小都必须是 8 字节的整数倍。对象头部分已经被精心设计成正好是 8 字节的倍数(1 倍或 2 倍),因此,如果对象实例数据部分没有对齐的话,就需要通过对齐填充来补全。

内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。字段内存对齐的其中一个原因,是让字段只出现在同一 CPU 的缓存行中。否则就有可能出现跨缓存行的字段,导致该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。

对象的存活判定

1. 引用计数

引用计数算法(reference counting)是这样的:在每个对象中添加一个引用计数器,每当有一个地方引用它时计数器值就加一;当引用失效时计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。

客观地说,引用计数算法虽然占用了一些额外的内存空间以及繁琐的更新操作外,它的原理是很简单的,判定效率也很高,在大多数情况下是一个不错的算法。但主流的 Java 虚拟机都没有采用引用计数法来管理内存。主要是这个看似简单的算法有很多例外情况要考虑,譬如引用计数很难解决对象之间相互循环引用的问题。
image.png
举例说明,下面代码中的对象 a 和 b 都有字段 instance,赋值令 a.instance=b 及 b.instance=a,此外,这两个对象再无任何引用,实际上这两个对象已经不可能再被访问,但它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也就无法回收它们。然后通过 -XX:+PrintGCDetails 参数打印 GC 日志观察下。

2. 可达性分析

目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法(Reachability Analysis)。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集,然后从该合集出发,根据引用关系向下搜索所有能够被该集合引用到的对象,并将其加入到该集合中,搜索过程所走过的路径称为引用链,这个过程我们也称之为标记。

如果某个对象到 GC Roots 间没有任何引用链相连,或者说从 GC Roots 到这个对象不可达时,则证明此对象是不可能再被使用的。可达性分析可以解决引用计数法所不能解决的循环引用问题,如下图所示,对象 object 5、object 6、object 7 虽然互有关联,但是它们到 GC Roots 是不可达的, 因此会被判定为可回收的对象。
image.png
我们可以暂时把 GC Roots 理解为由堆外指向堆内的引用,固定可作为 GC Roots 的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,比如方法参数、局部变量、临时变量等。
  • 在方法区中类静态属性、常量引用的对象。
  • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象、常驻的异常对象、系统类加载器等。
  • 所有被同步锁(synchronized 关键字)持有的对象。
  • 已启动且未停止的 Java 线程。

除了这些固定的 GC Roots 集合外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整 GC Roots 集合。

对象的引用类型

在 JDK 1.2 之前,Java 里的引用是很传统的定义:如果 reference 类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该 reference 数据是某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显得无能为力。

在 JDK 1.2 版之后,Java 对引用的概念进行了扩充,将引用分为 强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和 虚引用(Phantom Reference)四种,这四种引用强度依次逐渐减弱。
SoftReference.png
1)强引用
强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。因此强引用可能导会致内存泄漏。

2)软引用
软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常之前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在 JDK 1.2 版之后提供了 SoftReference 类来实现软引用。

3)弱引用
弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK 1.2 版之后提供了 WeakReference 类来实现弱引用。弱引用主要用于指向某个易消失的对象,在强引用断开后,此引用不会劫持对象。调用 WeakReference.get() 可能返回 null,要注意空指针异常。

4)虚引用
虚引用有时候也翻译成幻象引用,是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在 JDK 1.2 后提供了 PhantomReference 类来实现虚引用。

虚引用仅仅是提供了一种确保对象被 finalize 以后做某些事情的机制。它必须与引用队列联合使用,当垃圾回收时,如果发现存在虚引用,就会在回收对象内存前,把这个虚引用加入与之关联的引用队列中。比如,通常用来做所谓的 Post-Mortem 清理机制(DirectByteBuffer 的回收)。