一 Java对象的内存布局
在 Java 程序中,我们拥有多种新建对象的方式。除了最为常见的 new 语句之外,我们还可以通过反射机制、Object.clone
方法、反序列化以及Unsafe.allocateInstance
方法来新建对象。
其中,Object.clone 方法和反序列化通过直接复制已有的数据
,来初始化新建对象的实例字段。Unsafe.allocateInstance 方法则没有初始化实例字
段,而 new 语句和反射机制,则是通过调用构造器来初始化
实例字段
1.1 构造器的约束规则
- 首先,如果一个类没有定义任何构造器的话, Java 编译器会自动添加一个无参数的构造器。
- 然后,子类的
构造器需要调用父类的构造器
。如果父类存在无参数构造器的话,该调用可以是隐式
的,也就是说 Java 编译器会自动添加对父类构造器的调用。但是,如果父类没有无参数构造器
,那么子类的构造器则需要显式
地调用父类带参数的构造器。 - 无论是直接的显式调用,还是间接的显式调用父类构造器,都需要作为构造器的第一条语句,以便优先初始化继承而来的父类字段。(
即没有父类对象,就不会有子类对象!!!
) - 总而言之,当我们调用一个构造器时,它将优先调用父类的构造器,
直至 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),但都会遵循如下两个规则。
- 其一,如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值。
以 long 类为例,它仅有一个 long 类型的实例字段。在使用了压缩指针的 64 位虚拟机中,尽管对象头的大小为 12 个字节,该 long 类型字段的偏移量也只能是 16,而中间空着的 4 个字节便会被浪费掉。 - 其二,子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。
在具体实现中,Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。
@Contended(了解)
Java 8 还引入了一个新的注释 @Contended,用来解决对象字段之间的虚共享(falsesharing
)问题。这个注释也会影响到字段的排列。
虚共享是怎么回事呢?假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上
它们并没有共享内容,因此不需要同步。然而,如果这两个字段恰好
在同一个缓存行
中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。(volatile 字段和缓存行的故事会在之后的篇章中详细介绍。)
Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。具体的分布算法属于实现细节,随着 Java 版本的变动也比较大,因此这里就不做阐述了。
二 垃圾回收-判断对象状态
垃圾回收就是是将已经分配出去的,但却不再使用的内存回收回来,以便能够再次分配
。在 Java 虚拟机的语境下,垃圾指的是死亡的对象所占据的堆空间
。这里便涉及了一个关键的问题:如何辨别一个对象是存是亡?
2.1 引用计数法
为每个对象
添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0
,则说
明该对象已经死亡,便可以被回收了。
问题
- 需要额外的空间来存储计数器
- 繁琐的更新操作(需要
截获所有的引用更新操作
,并且相应地增减目标对象的引用计数器。) - 无法处理循环引用对象。
2.2 可达性分析算法
实质是将一系列GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象
,并将其加入到该集合中,这个过程我们也称之为标记**(mark**)。最终,未被探
索到的对象便是死亡的,是可以回收的。
那么什么是 GC Roots 呢?我们可以暂时理解为由堆外指向堆内的引用
,一般而言,GC Roots包括(但不限于)如下几种:
- Java 方法栈桢中的局部变量;
- 已加载类的静态变量;
- JNI handles;
- 已启动且未停止的 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 糟糕的情况
现实情况中并非每个程序都符合前面提到的假设。如果一个程序拥有中等生命周期的对象,并且刚移动到老年代便不再使用,那么将给默认的垃圾回收策略造成极大的麻烦。