A point during program execution at which all GC roots are known and all heap object contents are consistent. From a global point of view, all threads must block at a safepoint before the GC can run.
不过 safepoint 不仅仅只有 GC 有用,比如 deoptimization、Class redefinition 都有,只是 GC safepoint 比较知名。
我们再来想一下可以在哪些位置放置这个安全点。
对于解释器来说其实每个字节码边界都可以成为一个安全点,对于 JIT 编译的代码也能在很多位置插入安全点,但是实现上只会在一些特定的位置插入安全点。
因为安全点是需要 check 的,而 check 需要开销,如果安全点过多那么开销就大了,等于每执行几步就需要检查一下是否需要进入安全点。
其次也就是我们上面提到的会记录 OopMap ,所以有额外的空间开销。
那 mutator 是如何得知此时需要在安全点暂停呢?
其实上面已经提到了是 check,再具体一些还分解释执行和编译执行时不同的 check。
在解释执行的时候的 check 就是在安全点 polling 一个标志位,如果此时要进入 GC 就会设置这个标志位。
而编译执行是 polling page 不可读,在需要进入 safepoint 时就把这个内存页设为不可访问,然后编译代码访问就会发生异常,然后捕获这个异常挂起即暂停。
这里可能会有同学问,那此时阻塞住的线程咋办?它到不了安全点啊,总不能等着它吧?
这里就要引入安全区域的概念,在这种引用关系不会发生变化的代码段中的区域称为安全区域。
在这个区域内的任意地方开始 GC 都是安全的,这些执行到安全区域的线程也会标识自己进入了安全区域,
所以会 GC 就不用等着了,并且这些线程如果要出安全区域的时候也会查看此时是否在 GC ,如果在就阻塞等着,如果 GC 结束了那就继续执行。
可能有些同学对counted 循环有点疑问,像for (int i...) 这种就是 counted 循环,这里不会埋安全点。
所以说假设你有一个 counted loop 然后里面做了一些很慢的操作,所以很有可能其他线程都进入安全点阻塞就等这个 loop 的线程完毕,这就卡顿了。
分代收集前面我们提到标记-清除方式的 GC 其实就是攒着垃圾收,这样集中式回收会给应用的正常运行带来影响,所以就采取了分代收集的思想。
因为研究发现有些对象基本上不会消亡,存在的时间很长,而有些对象出来没多久就会被咔嚓了。这其实就是弱分代假说和强分代假说。
所以将堆分为新生代和老年代,这样对不同的区域可以根据不同的回收策略来处理,提升回收效率。
比如新生代的对象有朝生夕死的特性,因此垃圾收集的回报率很高,需要追溯标记的存活对象也很少,因此收集的也快,可以将垃圾收集安排地频繁一些。
新生代每次垃圾收集存活的对象很少的话,如果用标记-清除算法每次需要清除的对象很多,因此可以采用标记-复制算法,每次将存活的对象复制到一个区域,剩下了直接全部清除即可。
但是朴素的标记-复制算法是将堆对半分,但是这样内存利用率太低了,只有 50%。
所以 HotSpot 虚拟机分了一个 Eden 区和两个Survivor,默认大小比例是8∶1:1,这样利用率有 90%。
每次回收就将存活的对象拷贝至一个 Survivor 区,然后清空其他区域即可,如果 Survivor 区放不下就放到
老年代去,这就是分配担保机制。
而老年代的对象基本上都不是垃圾,所以追溯标记的时间比较长,收集的回报率也比较低,所以收集频率安排的低一些。
这个区域由于每次清除的对象很少,因此可以用标记-清除算法,但是单单清除不移动对象的话会有很多内存碎片的产生,所以还有一种叫标记-整理的算法,等于每次清除了之后需要将内存规整规整,需要移动对象,比较耗时。
所以可以利用标记-清除和标记-整理两者结合起来收集老年代,比如平日都用标记-清除,当察觉内存碎片实在太多了就用标记-整理来配合使用。
可能还有很多同学对的标记-清除,标记-整理,标记-复制算法不太清晰,没事,咱们来盘一下。
标记-清除分为两个阶段:
标记阶段:tracing 阶段,从根(栈、寄存器、全局变量等)开始遍历对象图,标记所遇到的每个对象。
清除阶段:扫描堆中的对象,将为标记的对象作为垃圾回收。
基本上就是下图所示这个过程: