JVM 基本原理(3)

Java 对象的内存布局

常见的 new 语句会被编译为 new 指令,以及对构造器的调用。每个类的构造器皆会直接或者间接调用父类的构造器,并且在同一个实例中初始化相应的字段。

压缩指针

每个对象都有一个对象头,对象头包括两部分,标记信息和类型指针。标记信息包括哈希值,锁信息,GC信息。类型指针指向这个对象的class。两个信息分别占用8个字节,所以每个对象的额外内存为16个字节。很消耗内存。

Java 虚拟机引入了压缩指针的概念,为了减少类型指针的内存占用,将64位指针压缩至32位,进而节约内存。之前64位寻址,寻的是字节。现在32位寻址,寻的是变量。再加上内存对齐(补齐为8的倍数),可以每次寻变量都以一定的规则寻找,并且一定可以找得到。

压缩指针要求 Java 虚拟机堆中对象的起始地址要对齐至 8 的倍数。Java 虚拟机还会对每个类的字段进行重排列,使得字段也能够内存对齐。

内存对齐的另一个好处是,使得CPU缓存行可以更好的实施。保证每个变量都只出现在一条缓存行中,不会出现跨行缓存。提高程序的执行效率。

字段重排序

其实就是更好的执行内存对齐标准,会调整字段在内存中的分布,达到方便寻址和节省空间的目的。

虚共享,当两个线程分别访问一个对象中的不同volatile字段,理论上是不涉及变量共享和同步要求的。但是如果两个volatile字段处于同一个CPU缓存行中,对其中一个volatile字段的写操作,会导致整个缓存行的写回和读取操作,进而影响到了另一个volatile变量,也就是实际上的共享问题。

@Contented注解,就是用来解决虚共享问题的,被该注解标识的变量,会独占一个CPU缓存行。但也因此浪费了大量的内存空间。


垃圾回收

JVM 语境下,垃圾回收,指将已经分配出去的,但已经死亡的对象所占据的堆空间回收回来,以便能够再次分配。

引用计数法与可达性分析

引用计数法(reference counting):
它的做法是为每个对象添加一个引用计数器,用来统计指向该对象的引用个数。一旦某个对象的引用计数器为 0,则说明该对象已经死亡,便可以被回收了。
它的问题在于除了需要额外的空间来存储计数器,以及繁琐的更新操作,引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。

可达性分析算法
这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。
它的问题在于,比如说,在多线程环境下,其他线程可能会更新已经访问过的对象中的引用,从而造成误报(将引用设置为 null)或者漏报(将引用设置为未被访问过的对象)。

Stop-the-world 以及安全点

为了解决漏报的麻烦,在 JVM 里,传统的垃圾回收算法采用的是 Stop-the-world(通过安全点机制来实现),停止其他非垃圾回收线程的工作,直到完成垃圾回收。这也就造成了垃圾回收所谓的暂停时间(GC pause)。

垃圾回收的三种方式

当标记完所有的存活对象时,便可以进行死亡对象的回收工作。主流的基础回收方式可分为三种:

  • 清除(sweep)
    即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
    它有两个缺点,即会造成内存碎片,和分配效率较低。
  • 压缩(compact)
    即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。
    这种做法能够解决内存碎片化的问题,但代价是压缩算法的性能开销。
  • 复制(copy)
    即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容。
    复制这种回收方式同样能够解决内存碎片化的问题,但是它的缺点也极其明显,即堆空间的使用效率极其低下。

Java 虚拟机的堆划分

Java 虚拟机将堆分为新生代和老年代(JVM 的分代回收思想),并且对不同代采用不同的垃圾回收算法。其中,新生代分为 Eden 区和两个大小一致的 Survivor 区,并且其中一个 Survivor 区是空的。

在只针对新生代的 Minor GC 中,Eden 区和非空 Survivor 区的存活对象会被复制到空的 Survivor 区中,当 Survivor 区中的存活对象复制次数超过一定数值时,它将被晋升至老年代。

卡表

因为 Minor GC 只针对新生代进行垃圾回收,所以在枚举 GC Roots 的时候,它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java 虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。

该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。


链接

参考资料:
极客时间-深入拆解Java虚拟机