文章收录地址:Java-Bang
专一于零碎架构、高可用、高性能、高并发类技术分享
在读博士的时候,我已经写过一个统计 Java 对象生命周期的动态分析,并且用它来跑了一些基准测试。
其中一些程序的后果,恰好验证了许多钻研人员的假如,即大部分的 Java 对象只存活一小段时间,而存活下来的小局部 Java 对象则会存活很长一段时间。
(pmd 中 Java 对象生命周期的直方图,红色的示意被逃逸剖析优化掉的对象)
之所以要提到这个假如,是因为它造就了 Java 虚拟机的 分代回收思维 。简略来说,就是将堆空间划分为两代,别离叫做 新生代 和老年代 。 新生代用来存储新建的对象 。 当对象存活工夫够长时,则将其挪动到老年代。
Java 虚拟机能够给不同代应用不同的回收算法。对于新生代,咱们猜想大部分的 Java 对象只存活一小段时间,那么便能够频繁地采纳耗时较短的垃圾回收算法,让大部分的垃圾都可能在新生代被回收掉。
对于老年代,咱们猜想大部分的垃圾曾经在新生代中被回收了,而在老年代中的对象有大概率会持续存活。当真正触发针对老年代的回收时,则代表这个假如出错了,或者堆的空间曾经耗尽了。
这时候,Java 虚拟机往往须要做一次全堆扫描,耗时也将不计成本。(当然,古代的垃圾回收器都在并发收集的路线上倒退,来防止这种全堆扫描的状况。)
明天这一篇咱们来关注一下针对新生代的 Minor GC。首先,咱们来看看 Java 虚拟机中的堆具体是怎么划分的。
Java 虚拟机的堆划分
后面提到,Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小雷同的 Survivor 区。
默认状况下,Java 虚拟机采取的是一种 动态分配 的策略(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),依据生成对象的速率,以及 Survivor 区的应用状况动静调整 Eden 区和 Survivor 区的比例。
当然,你也能够通过参数 -XX:SurvivorRatio 来固定这个比例。然而须要留神的是,其中一个 Survivor 区会始终为空,因而比例越低节约的堆空间将越高。
通常来说,当咱们 调用 new 指令时 ,它 会在 Eden 区中划出一块作为存储对象的内存 。因为 堆空间是线程共享的,因而间接在这里边划空间是须要进行同步的。
否则 ,将有 可能呈现两个对象共用一段内存的事变。如果你还记得前两篇我用“停车位”打的比如的话,这里就相当于两个司机(线程)同时将车停入同一个停车位,因此产生剐蹭事变。
Java 虚拟机的解决办法是为每个司机事后申请多个停车位,并且只容许该司机停在本人的停车位上。那么 当司机的停车位用完了该怎么办呢(假如这个司机代客泊车)?
答案是:再申请多个停车位便能够了。这项技术被称之为 TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。
具体来说,每个线程能够向 Java 虚拟机申请一段间断的内存 ,比方 2048 字节, 作为线程公有的 TLAB。
这个操作须要加锁,线程须要保护两个指针 (实际上可能更多,但重要也就两个), 一个指向 TLAB 中空余内存的起始地位,一个则指向 TLAB 开端。
接下来的 new 指令,便能够间接通过 指针加法(bump the pointer)来实现,即 把指向空余内存地位的指针加上所申请的字节数。
1. 我猜想会有留言问为什么不把 bump the pointer 翻译成指针碰撞。这里先解释一下,在英语中咱们通常省略了
2. bump up the pointer 中的 up。在这个上下文中 bump 的含意应为“进步”。另外一个例子是当咱们公布软件的新版本
3. 时,也会说 bump the version number。
如果加法后空余内存指针的值仍小于或等于指向开端的指针,则代表调配胜利 。 否则,TLAB 曾经没有足够的空间来满足本次新建操作 。这个时候, 便须要以后线程从新申请新的 TLAB。
当 Eden 区的空间耗尽了 怎么办?这个时候 Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区。
后面提到,新生代共有两个 Survivor 区,咱们别离用 from 和 to 来指代。其中 to 指向的 Survivior 区是空的。
当产生 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。
这样一来,岂不是又做了一次全堆扫描呢?
卡表
HotSpot 给出的解决方案是一项叫做 卡表(Card Table)的技术。该技术将整个堆划分为一个个大小为 512 字节的卡,并且保护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的援用。如果可能存在,那么咱们就认为这张卡是脏的。
在进行 Minor GC 的时候,咱们便能够不必扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象退出到 Minor GC 的 GC Roots 里。当实现所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。
因为 Minor GC 随同着存活对象的复制,而复制须要更新指向该对象的援用 。因而, 在更新援用的同时 ,咱们又会 设置援用所在的卡的标识位 。这个时候,咱们 能够确保脏卡中必然蕴含指向新生代对象的援用。
在 Minor GC 之前 ,咱们 并不能确保脏卡中蕴含指向新生代对象的援用。其起因和如何设置卡的标识位无关。
首先,如果想要保障每个可能有指向新生代对象援用的卡都被标记为脏卡,那么 Java 虚拟机须要截获每个援用型实例变量的写操作,并作出对应的写标识位操作。
这个操作在解释执行器中比拟容易实现。然而在即时编译器生成的机器码中,则须要插入额定的逻辑。这也就是所谓的 写屏障(write barrier,留神不要和 volatile 字段的写屏障混同)。
写屏障须要尽可能地放弃简洁。这是因为咱们并不心愿在每条援用型实例变量的写指令后跟着一大串注入的指令。
因而,写屏障并不会判断更新后的援用是否指向新生代中的对象,而是宁肯错杀,不可放过,一律当成可能指向新生代对象的援用。
这么一来,写屏障便可精简为上面的伪代码 [1]。这里右移 9 位相当于除以 512,Java 虚拟机便是通过这种形式来从地址映射到卡表中的索引的。最终,这段代码会被编译成一条移位指令和一条存储指令。
CARD_TABLE [this address >> 9] = DIRTY;
尽管 写屏障不可避免地带来一些开销,然而它可能加大 Minor GC 的吞吐率 (利用运行工夫 /(利用运行工夫 + 垃圾回收工夫))。总的来说还是值得的。不过, 在高并发环境下 , 写屏障 又带来了虚共享(false sharing)问题 [2]。
在介绍对象内存布局中我曾提到虚共享问题,讲的是几个 volatile 字段呈现在同一缓存行里造成的虚共享。这里的虚共享则是卡表中不同卡的标识位之间的虚共享问题。
在 HotSpot 中,卡表是通过 byte 数组来实现的。对于一个 64 字节的缓存行来说,如果用它来加载局部卡表,那么它将对应 64 张卡,也就是 32KB 的内存。
如果同时有两个 Java 线程,在这 32KB 内存中进行援用更新操作,那么也将造成存储卡表的同一部分的缓存行的写回、有效化或者同步操作,因此间接影响程序性能。
为此,HotSpot 引入了一个新的参数 -XX:+UseCondCardMark,来尽量减少写卡表的操作。其伪代码如下所示:
1. if (CARD_TABLE [this address >> 9] != DIRTY)
2. CARD_TABLE [this address >> 9] = DIRTY;
总结与实际
明天我介绍了 Java 虚拟机中垃圾回收具体实现的一些通用常识。
Java 虚拟机将堆分为新生代和老年代,并且对不同代采纳不同的垃圾回收算法。其中,新生代分为 Eden 区和两个大小统一的 Survivor 区,并且其中一个 Survivor 区是空的。
在只针对新生代的 Minor GC 中,Eden 区和非空 Survivor 区的存活对象会被复制到空的 Survivor 区中,当 Survivor 区中的存活对象复制次数超过肯定数值时,它将被降职至老年代。
因为 Minor GC 只针对新生代进行垃圾回收,所以在枚举 GC Roots 的时候,它须要思考从老年代到新生代的援用。为了防止扫描整个老年代,Java 虚拟机引入了名为卡表的技术,大抵地标出可能存在老年代到新生代援用的内存区域。