《深入理解JAVA虚拟机》第二版 阅读笔记3 垃圾收集与内存分配(1)

垃圾收集的第一步是识别哪些对象已死,两种方法:

判断对象已死1:引用计数法

当一个对象被引用时,使它的引用计数器加1,当引用失效,计数器减1,当对象的引用为0,说明对象已死。这个方法理解起来非常简单,但是JAVA虚拟机没有采用这种方式,因为它有一个大BUG:

A a = new A();// A实例引用+1
B b = new B();// B实例引用+1
a.field = b;// B实例引用+1
b.filed = a;// A实例引用+1
a = null;// A实例引用-1
b = null;// B实例引用-1

当这段代码执行结束时,A和B的实例各自都还有1个引用,是它们互相引用的,虚拟机已经无法访问到它们了,却不能通知GC清除它们。

判断对象已死2:可达性分析法

虚拟机将所有对象变成树形结构,根节点被成为GC Roots,每个节点就是一个对象,只要这个对象最终能追溯到根节点,说明它是活着的,否则它就死了。
能作为GC Root的对象都是引用对象,它们存在于:
1. 虚拟机栈的本地变量表
2. 虚拟机栈中JNI(即Native方法)引用的变量
3. 方法区(JAVA8成为元空间)中的静态属性或常量引用对象


JAVA中的引用分为四种:

强引用

Object obj = new Object()
这是我们平时最经常使用的引用,只要引用还在,没有被置null或者指向别的地址,那么这个对象就永远不会被回收

软引用

SoftReference JVM会在内存溢出前对软引用对象进行回收,一般用来做内存敏感型的缓存

弱引用

WeakReference强度与软引用更弱一些,只能生存到下一次GC之前,意思是当垃圾收集器工作时,无论当前的内存是否充足,都会回收掉只被弱引用关联的对象

虚引用

PhantomReference 名字叫做幽灵引用 or 幻影引用,好像没啥用处啊,因为甚至都不能通过幻影应用获得到它关联的对象。


再给你最后的机会 —— 二次标记

现在通过可达性分析法可以知道一个对象是不是存活了,那是不是每次GC都会直接把死掉的对象扔掉呢,不一定。

如果一个死亡的对象没有实现finalize方法,或者finalize方法已经被调用过一次了,它会被直接标记为可以回收

否则对象会被放入F-Queue队列,之后虚拟机会自动建立一个优先级很低的线程去执行队列中对象的finalize方法,这里的执行并不保证会完整的执行整个方法,只是触发一下,这是为了防止某个对象的finalize方法执行时间过长甚至死循环,这就要耽误GC的大事了,如果在finalize方法中,这个本来死掉的对象又跟GC Roots关联上了,那就是起死回生了

紧接着虚拟机对F-Queue中的对象做第二次标记,把那些依然处于死亡状态的对象打上回收标记,然后就开始清理死亡对象了。

有一个重点容易被忽略的是,finalize方法最多执行一次,一个对象第一次通过这个方法复活之后,再次死亡了会被直接标记为辣鸡,不会再放入F-Queue队列,因为虚拟机知道它的finalize方法已经执行过一次了


方法区的内存回收

这一块的内存回收主要针对:无用的类定义信息,无用的常量
无用的常量:这个的判断与对象是否死亡的判断类似,不再赘述
无用的类定义:需要满足已下三个条件
1. 所有的实例都被回收
2. 加载该类的ClassLoader被回收
3. 没有地方引用类对应的java.lang.Class对象,也没有使用反射访问该类的方法、属性等

HotSpot虚拟机提供了-Xnoclassgc参数可以让虚拟机不要回收无用类


现在我们可以甄别哪些对象要回收了,具体怎么删掉这些辣鸡还是有讲究的,假如我们把内存想象成一个数组,0表示空,1表示存活对象,4表示可回收的对象。有以下三种算法:

Mark-Sweep 标记清除算法

回收前(已经打好标记了):
0114000144001111444111000
回收后:
0110000100001111000111000

这个方法肥肠简单粗暴,但是缺点很明显:
1. 是可用的内存变得很碎,比较难找到大块的连续空间,当有大对象找不到连续空间放的时候,会导致提前触发下一次垃圾收集。
2. 因为内存很碎,清除步骤的效率低,一个对象一个对象一块内存一块内存地去清。

Copying 复制算法

为了解决标记清除算法的问题,就有复制算法了,把可用内存平均分成两块,一块空着,一块放东西
回收前:
第一块:0000000000
第二块:1441100014

回收第一步:把活着的对象挪到空的那块去,从头按顺序放
第一块:1111000000
第二块:1441100014(保持原样)

回收第二步:把原来放东西那块全部清除:
第一块:1111000000
第二块: 000000000

实现简单,运行高效,不会有碎片,但是也有两个不好
1是存活对象的内存地址会变化,那说明引用对象保存的地址要变,或者句柄池里的地址得变
2是最大的弊端,只有一半的内存空间可以用,太浪费了

对于新生代基本都用的这种算法(新生代老年代啥的后面会提到),只是虚拟机肯定不会这么傻,因为绝大部分对象是“朝生夕死”的,所以分配初始空间的区域要大,而留给存活对象的空间可以比它小得多,因此HotSpot将内存分为一个较大的Eden区和两个较小的Survivor区,每次使用Eden和其中一个Survivor,GC时把存活对象放到另一个Survivor,清空Eden原来的Survivor,然后继续使用Eden和目前存放存活对象的Survivor。HotSpot默认Eden和Survivor的比例是8:1,就是说任何时候新生代中可用的内存是90%。那其实也会有个问题,如果存活的对象超过Survivor区域了怎么办,这时候就需要老年代进行分配担保Handle Promotion

Mark-Compact 标记整理算法

从之前的结论看来,HotSpot支持的复制算法已经很不错了,但是如果对象的存活率比较高甚至是100%,就要进行大量的复制操作,Survivor区不够放的话还要从其他区域(老年代)进行分配担保,肥肠麻烦,所以针对老年代的内存回收,这种算法不合适。标记整理算法就出来了,它跟标记清除很像,但是用巧妙的方法避免了内存碎片。

回收前(已经打好标记了):
0114000144001111444111000
整理(把所有存活的对象往一边挪):
1111111111(边缘)040004400444000
清理(把边缘外面的对象全部清除):
1111111111(边缘)00000000000000

Generation Collection 分代收集

这不是一种收集算法,只是将内存分成新生代和老年代,这样可以针对各自不同的特点选用不同的算法,更加合理。
新生代里绝大部分的对象是朝生夕死的,所以适合使用复制算法
而老年代里的对象存活率很高,所以适合使用标记-清理或者标记-整理算法


JVM在进行垃圾收集时还有一些其他需要注意的地方,大致过一下:

枚举根节点

可达性分析的时候,要逐个检查GC Roots的引用,如果直接去方法区和栈里面一个一个查会很耗费时间,并且这个过程必须Stop The World,因为如果JVM一边检查着,对象的引用状态还在不停变化着,检查结果就会不准确,所以必须想办法减少这个时间,最好JVM可以直接知道哪些地方存着引用对象。
HotSpot虚拟机通过OopMap达到这个目的,在类加载完成以及在JIT编译过程中,HotSpot JVM就把方法区或栈或寄存器中哪些位置放了引用记录到OopMap中,GC扫描时就可以直接知道这些信息了。

关于寄存器:CPU执行运算时的数据, 需要从内存里载入寄存器中, 运算完再从寄存器存入内存, 对象的地址也要经过这么个过程,所以有的时候对象的地址是在寄存器,而不在JVM内存里

Safe Point 安全点

上面说到,GC要发生时,很有必要通过OopMap快速的枚举GC Roots,那么什么时候维护OopMap呢,一个是类加载完的时候,这时候是维护方法区的OopMap,还有就是代码执行过程中,需要维护栈的OopMap,但是,会导致引用变化的代码实在太多太频繁了,如果每次遇到这样的代码就要维护OopMap,代价太大了,所以出现了安全点,当线程执行到安全点,就去检查是不是在GC,如果是,就把自己停下,然后更新自己的 OopMap ,记下栈上哪些位置代表着引用。刚刚说的这种STW(Stop The World)的方式叫做主动式中断,就是线程到安全点之后主动检查GC的标志位,这种方法也是JVM采纳的方法。还有一种叫做抢先式中断,JVM强行停止所有线程,然后检查各个线程是不是在安全点上,如果不在,就恢复让它继续执行直到安全点,听起来就不靠谱的样子。
安全点不能太多,太多了影响性能,也不能太少,太少了会导致GC发生时等待线程执行到安全点要等很久。

https://www.jianshu.com/p/c79c5e02ebe6
以Hotspot为例,简单的说明一下什么地方会放置safepoint:
1、理论上,在解释器的每条字节码的边界都可以放一个safepoint,不过挂在safepoint的调试符号信息要占用内存空间,如果每条机器码后面都加safepoint的话,需要保存大量的运行时数据,所以要尽量少放置safepoint,在safepoint会生成polling代码询问VM是否要“进入safepoint”,polling操作也是有开销的,polling操作会在后续解释。
 
2、通过JIT编译的代码里,会在所有方法的返回之前,以及所有非counted loop的循环(无界循环)回跳之前放置一个safepoint,为了防止发生GC需要STW时,该线程一直不能暂停。另外,JIT编译器在生成机器码的同时会为每个safepoint生成一些“调试符号信息”,为GC生成的符号信息是OopMap,指出栈上和寄存器里哪里有GC管理的指针。

Safe Region 安全区域

程序执行时,在不长的时间里就会遇到可进入GC的安全点,但如果线程没有分配cpu时间,比如线程处于sleep或blocked状态,就无法走到安全点去挂起。Safe Region解决了这一问题。

安全区域是指在一段代码片段中,引用关系不会发生变化,在该区域的任何地方发生GC都是安全的。当代码执行到安全区域时,首先标识自己已经进入了安全区域,那样如果在这段时间里JVM发起GC,就不用管标示自己在安全区域的那些线程了,在线程离开安全区域时,会检查系统是否正在执行GC,如果是,就等到GC完成后再离开安全区域。

这里其实每太明白,意思是能使线程处于sleep或者blocked的代码一定是会被JVM标记为安全区域的吗?所以当线程醒过来继续执行时就会判断是不是在GC?

RememberedSet

新生代Minor GC发生得非常频繁。GC过程是这样的:首先枚举根节点。根节点有可能在新生代中,也有可能在老年代中。这里由于我们只想收集新生代(换句话说,不想收集老年代,因为不是Major GC),所以没有必要对位于老年代的 GC Roots 做全面的可达性分析。但问题是,确实可能存在位于老年代的某个 GC Root,它引用了新生代的某个对象,这个对象你是不能清除的。那怎么办呢?

仍然是拿空间换时间的办法。事实上,对于位于不同年代对象之间的引用关系,虚拟机会在程序运行过程中给记录下来。对应上面所举的例子,“老年代对象引用新生代对象”这种关系,会在引用关系发生时,在新生代边上专门开辟一块空间记录下来,这就是 RememberedSet。所以新生代的GC Roots + RememberedSet ,才是新生代收集时真正的 GC Roots 。然后就可以以此为据,在新生代上做可达性分析,进行垃圾回收。

我们知道, G1 收集器使用的是化整为零的思想,把一块大的内存划分成很多个域( Region )。但问题是,难免有一个 Region 中的对象引用另一个 Region 中对象的情况。为了达到可以以 Region 为单位进行垃圾回收的目的, G1 收集器也使用了 RememberedSet 这种技术,在各个 Region 上记录自家的对象被外面对象引用的情况。

猜你喜欢

转载自blog.csdn.net/u010588262/article/details/81411737