深入拆解Java虚拟机笔记(3)对象的内存布局、垃圾回收的三种方式、Minor GC

一 Java对象的内存布局

在 Java 程序中,我们拥有多种新建对象的方式。除了最为常见的 new 语句之外,我们还可以通过反射机制、Object.clone方法、反序列化以及Unsafe.allocateInstance 方法来新建对象。

其中,Object.clone 方法和反序列化通过直接复制已有的数据,来初始化新建对象的实例字段。Unsafe.allocateInstance 方法则没有初始化实例字段,而 new 语句和反射机制,则是通过调用构造器来初始化实例字段

1.1 构造器的约束规则

  1. 首先,如果一个类没有定义任何构造器的话, Java 编译器会自动添加一个无参数的构造器。
  2. 然后,子类的构造器需要调用父类的构造器。如果父类存在无参数构造器的话,该调用可以是隐式的,也就是说 Java 编译器会自动添加对父类构造器的调用。但是,如果父类没有无参数构造器,那么子类的构造器则需要显式地调用父类带参数的构造器。
  3. 无论是直接的显式调用,还是间接的显式调用父类构造器,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段。(即没有父类对象,就不会有子类对象!!!
  4. 总而言之,当我们调用一个构造器时,它将优先调用父类的构造器,直至 Object 类。这些构造器的调用者皆为同一对象,也就是通过 new 指令新建而来的对象。

通过 new 指令新建出来的对象,它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。

1.2 压缩指针

对象头

在 Java 虚拟机中,每个 Java 对象都有一个对象头(object header),这个由标记字段和类型指针所构成。其中,标记字段用以存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信息以及锁信息,而类型指针则指向该对象的类

64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节(128/8=16)。以 Integer 类为例,它仅有一个 int 类型的私有字段,占 4 个字节。因此,每一个 Integer 对象的额外内存开销至少(这只是对象的对象头)是 400%。这也是为什么 Java 要引入基本类型的原因之一。

1.3 指针被压缩

为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针 的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象指针压缩成 32位的。

这样一来,对象头中的类型指针也会被压缩成 32 位,使得对象头的大小从 16 字节降至 12 字节。当然,压缩指针不仅可以作用于对象头的类型指针,还可以作用于引用类型的字段,以及引用类型数组

小结:是因为jvm压缩了对象指针,而对象头中的类型指针本质也是对象指针。所以也被压缩了空间

原理

打个比方,路上停着的全是房车,而且每辆房车恰好占据两个停车位。现在,我们按照顺序给它们编号。也就是说,停在 0 号和 1 号停车位上的叫 0 号车,停在 2 号和 3 号停车位上的叫 1号车,依次类推。

原本的内存寻址用的是车位号。比如说我有一个值为 6 的指针,代表第 6 个车位,那么沿着这个指针可以找到 3 号车。现在我们规定指针里存的值是车号,比如 3 指代 3 号车。当需要查找3 号车时,我便可以将该指针的值乘以 2,再沿着 6 号车位找到 3 号车。

这样一来,32 位压缩指针最多可以标记 2 的 32 次方辆车,对应着 2 的 33 次方个车位。当然,房车也有大小之分。大房车占据的车位可能是三个甚至是更多。不过这并不会影响我们的寻址算法:我们只需跳过部分车号,便可以保持原本车号 *2 的寻址系统。

上述模型有一个前提:就是每辆车都从偶数号车位停起。这个概念我们称之为内存对齐(对应虚拟机选项 -XX:ObjectAlignmentInBytes,默认值为 8

默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)

  • 所以堆中存在着部分浪费,可以想象内存对齐越小,浪费的最大值就越小。但同样表示的总地址就少:比如本来128表示的是128*8,如果内存对齐为1.则内存中的地址也为1了
  • 在默认情况下,Java 虚拟机中的 32 位压缩指针可以寻址到 2 的 35 次方个字节(8=2的3次方),也就是 32GB的地址空间(超过 32GB 则会关闭压缩指针(那表示我不省空间了,直接用64位来表示吧))。
  • 在对压缩指针解引用时,我们需要将其左移 3 位(乘以8),再加上一个固定偏移量,便可以得到能够寻
    址 32GB 地址空间的伪 64 位指针了
  • 此外,我们可以通过配置刚刚提到的内存对齐选项(-XX:ObjectAlignmentInBytes)来进一步提升寻址范围。但是,这同时也可能增加对象间填充,导致压缩指针没有达到原本节省空间的效果。
  • 当然,就算是关闭了压缩指针,Java 虚拟机还是会进行内存对齐
  • 此外,内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。

内存对齐的原因

是让字段只出现在同一 CPU 的缓存行中。如果字段不是对齐的,那么就有可能出现跨缓存行的字段。也就是说,该字段的读取可能需要替换两个缓存行,而该字段的存储也会同时污染两个缓存行。这两种情况对程序的执行效率而言都是不利的。

字段重排列

没看懂,先记录!
字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为1),但都会遵循如下两个规则

  1. 其一,如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值。
    以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。
  2. 其二,子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。
    在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。

@Contended(了解)

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

虚共享是怎么回事呢?假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。然而,如果这两个字段恰好同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。(volatile 字段和缓存行的故事会在之后的篇章中详细介绍。)

Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。具体的分布算法属于实现细节,随着 Java 版本的变动也比较大,因此这里就不做阐述了。

二 垃圾回收-判断对象状态

垃圾回收就是是将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配。在 Java 虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间。这里便涉及了一个关键的问题:如何辨别一个对象是存是亡?

2.1 引用计数法

每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说
明该对象已经死亡,便可以被回收了。

问题

  1. 需要额外的空间来存储计数器
  2. 繁琐的更新操作(需要截获所有的引用更新操作,并且相应地增减目标对象的引用计数器。)
  3. 无法处理循环引用对象。

2.2 可达性分析算法

实质是将一系列GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记**(mark**)。最终,未被探
索到的对象便是死亡的,是可以回收的。
那么什么是 GC Roots 呢?我们可以暂时理解为由堆外指向堆内的引用,一般而言,GC Roots包括(但不限于)如下几种:

  1. Java 方法栈桢中的局部变量;
  2. 已加载类的静态变量;
  3. JNI handles
  4. 已启动且未停止的 Java 线程(比如主线程)。

问题

在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。

解决办法(Stop-the-world)

在 Java 虚拟机里,传统的垃圾回收算法采用的是一种简单粗暴的方式,那便是 Stop-the-world,停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)

安全点(safepoint)

Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的(这时候先到的先等!)线程都到达安全点,才允许请求 Stop-theworld 的线程进行独占的工作。

三 垃圾回收的三种方式

3.1 清除(sweep)

把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。

清除这种回收方式的原理及其简单,但是有两个缺点。

  • 一是会造成内存碎片。由于 Java 虚拟机的堆中对象必须是连续分布的,因此可能出现总空闲内存足够,但是无法分配的极端情况。
  • 分配效率较低。如果是一块连续的内存空间,那么我们可以通过指针加法(pointerbumping) 来做分配。而对于空闲列表,Java 虚拟机则需要逐个访问列表中的项(遍历!时间复杂度),来查找能够放入新建对象的空闲内存。

3.2 压缩(compact)

存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。

3.3 复制(copy)

即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下
在这里插入图片描述

3.4 现代的垃圾回收器

现代的垃圾回收器往往会综合上述几种回收方式,综合它们优点的同时规避它们的缺点。

四 新生代回收

4.1 分代回收思想基于一个假设

简单来说,就是将堆空间划分为两代,分别叫做新生代和老年代。新生代用来存储新建的对象。当对象存活时间够长时,则将其移动到老年代。

Java 虚拟机可以给不同代使用不同的回收算法

  • 对于新生代,我们猜测大部分的Java 对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。
  • 对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了。这时候,Java 虚拟机往往需要做一次全堆扫描,耗时也将不计成本。(当然,现代的垃圾回收器都在并发收集的道路上发展,来避免这种全堆扫描的情况。)

4.2 虚拟机的堆划分

Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为Eden 区,以及两个大小相同的Survivor 区。
在这里插入图片描述

通常来说,当我们调用new 指令时,它会在Eden 区中划出一块作为存储对象的内存。由于堆空间是线程共享的,因此直接在这里边划空间是需要进行同步的。否则,将有可能出现两个对象共用一段内存的事故

TLAB

Thread Local AllcoationBuffer,对应虚拟机参数-XX:+UseTLAB,默认开启.
具体来说,每个线程可以向Java 虚拟机申请一段连续的内存,比如2048 字节,作为线程私有的TLAB。这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向TLAB 中空余内存的起始位置,一个则指向TLAB 末尾。省略…

4.3 针对新生代的Minor GC

当Eden 区的空间耗尽了怎么办?这个时候Java 虚拟机便会触发一次Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到Survivor 区。

当发生Minor GC 时,Eden 区和from 指向的Survivor 区中的存活对象会被复制到to 指向的Survivor 区中,然后交换from 和to 指针,以保证下一次Minor GC 时,to 指向的Survivor区还是空的。

晋升至老年代

Java 虚拟机会记录Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为15(对应虚拟机参数-XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个Survivor 区已经被占用了50%(对应虚拟机参数-XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。

总而言之,当发生Minor GC 时,我们应用了标记- 复制算法,将Survivor 区中的老存活对象晋升到老年代,然后将剩下的存活对象和Eden 区的存活对象复制到另一个Survivor 区中。理想情况下,Eden 区中的对象基本都死亡了,那么需要复制的数据将非常少,因此采用这种标记- 复制算法的效果极好

不用对整个堆进行垃圾回收

Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。但是,它却有一个问题,那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为GC Roots。这样一来,岂不是又做了一次全堆扫描呢?

4.4 卡表

简单记录下
HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到Minor GC 的GC Roots 里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零

由于Minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用。因此,在更新引用的同时,我们又会设置引用所在的卡的标识位(前面所有卡都清除了标志,这里可以精准地设置哪些卡是脏卡)。这个时候,我们可以确保脏卡中必定包含指向新生代对象的引用。

在Minor GC 之前,我们并不能确保脏卡中包含指向新生代对象的引用。其原因和如何设置卡的标识位有关(这里的解释太复杂了,略过!)

4.5 糟糕的情况

现实情况中并非每个程序都符合前面提到的假设。如果一个程序拥有中等生命周期的对象,并且刚移动到老年代便不再使用,那么将给默认的垃圾回收策略造成极大的麻烦。

4.6 垃圾回收器(扩展)

在这里插入图片描述

发布了107 篇原创文章 · 获赞 1 · 访问量 3948

猜你喜欢

转载自blog.csdn.net/m0_38060977/article/details/104306402
今日推荐