1.引用计数法
引用计数器实现很简单,对于对象A,有任何一个对象引用,则加一,引用失效了,就减一,当对象A的引用计数器为0,则表示A不可能会再次被引用
缺点:
1.无法处理循环引用问题。
2.每次引用产生和消除,都伴随计算,对性能有影响。
2.标记-清除算法
最基础的算法是标记清除算法,分为两步:
1.标记出需要回收的对象。
2.标记完成后统一回收清除所有标记的对象。
不足:
1.效率问题,标记和清除的过程效率不高。
2.空间问题。清除后的会产生大量的空间碎片,因为清除的对象是不连续的,所以会有很多碎片,导致当后面程序运行过程需要分配大对象的时候,无法找到足够内存去分配。
3.复制算法(现在的商用虚拟机基本用这种来回收新生代)
这种算法是为了解决效率问题出现的。它是将内存分为大小相等的两片,每次只使用其中的一半,另外一半空闲。当使用的那块内存用完了,就进行垃圾清除,然后将还存活着的对象复制到另一半内存。这时候将原来的那块内存全部清除。而新的那块内存也不需要考虑分配时的内存内存碎片等情况,只要移动指针,按顺序分配即可。![复制算法的执行过程(https://upload-images.jianshu.io/upload_images/4017523-1fa7336eb168582f.png?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
缺点:
变相的将可用内存缩小了一般,代价很大。
改进的复制算法
新生代: 存放年轻对象的堆空间,指刚创建不久,或者经历垃圾回收次数不多的对象。
老年代: 存放老年对象的堆空间,指经过多次垃圾回收依然存活的对象。
在垃圾回收时,eden空间中存活的对象会被复制到未使用的servivor空间中(假设是to空间),正在使用的survivor空间(假设是from)中的年轻对象也会被复制到to空间(大对象,或者老年对象会直接进入老年代,如果to空间已满,则对象会直接进入老年代)此时eden空间和from空间就是剩余的对象就是垃圾对象了,可以直接清空,to空间则存放此次回收后的存活对象。这种改进的复制算法,既保证了空间的连续性,又避免了大量的内存空间浪费。如图
为什么老年代基本不用这种算法,而是新生代使用?
因为新生代中的对象基本上百分之98是照升夕死的,所以并不需要按照这样划分空间。在新生代中会将内存划分为一块比较大的Eden空间以及两份Survivor。每次使用一块Eden以及一块Survivor空间。当回收时,会将Eden以及Survivor中存放的对象一次性复制到另一块Survivor中,然后清理掉Eden以及刚用过的Survivor。
在HotSpot中默认Eden和Survivor的大小比例是8:1。也就是整个每次可用容量都能利用到百分之90.只有百分之10会被浪费。
还需要知道的是每次98%的新生代中的对象会被回收,并不是绝对的,没办法保证每次只有不到百分之10对象存活,因此当Survivor空间不够用时,还需要依赖(主要是老年代)的内存 进行都分担。
3.标记-整理算法(也有叫标记清除压缩算法的)(老年代用得比较多)
复制算法如果每次对象清除后,还存活的对象很多,那么就要进行很多对象的复制操作,这样效率会变很低。更关键的还是如果不想浪费"一半"的空间空闲,那么就需要有额外的空间作为分配担保,来应对所有对象都存活的情况,老年代一般不选用这种算法。
标记整理算法,顾名思义,先标记再整理。它的标记过程跟标记清除算法的一样的,把标记的对象清理。
然后是整理过程,它的整理是把还存活的对象向前一端移动,直接清理掉边界外的内存。标记整理的算法如图所示:
4.分代收集算法
分代收集算法的意思是这样的,一般来说,会把内存按照里面对象的存活周期以及大小等分为几块。一般把java堆内存分为新生代内存和老年代内存,然后根据不同的年代的特点采用不同的垃圾收集算法。所以叫分代收集算法。在新生代中,因为每次垃圾收集都会有大量对象死去,收集率达到百分之90以上,所以选择使用复制算法。在老年代中,对象存活率高,所以选择标记清除算法或者是标记整理算法。这是基于前面两种算法的。
为了支持高频率的回收,比如新生代,虚拟机还会可能使用一种卡表的数据结构,卡表为了一个比特位集合,每一个比特位可以用来表示老年代的某一块区域中的所有对象是否持有新生代对象的引用。这样在新生代gc时,可以不用花大量的时间来扫面老年代对象,来确定每个对象的引用关系,可以先扫面卡表,只有当卡表标记为1时,才需要扫面给定区域的老年代对象,而卡表位为0的所在区域老年代区域,肯定不含新生代对象的引用。使用该方式,可以大大加快新生代的回收速度。
5.分区算法
分区算法按照对象的生命周期长短划分为两个部分,分区算法讲整个堆空间划分为连续的不同大小区间。如图,每个区间都可以独立使用,独立回收。这种算法好处是可以控制一次回收多少个小区间。
当堆空间越大的时候,GC时间越长,分区的花,可以控制大小。
HotSpot的算法实现
HotSpot虚拟机上实现这些算法时,必须对算法的执行效率有严格的考量,才能保证虚拟机高效运行。
1.枚举根节点
从可达性分析中(https://www.jianshu.com/p/f6741d1cbd8c)从GC Roots节点找引用链这个操作为例,可作为GC Roots的节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中,现在很多应用仅仅方法区就有数百兆,如果要逐个检查这里面的引用,那么必然会消耗很多时间。
另外,可达性分析对执行时间的敏感还体现在GC停顿上,因为这项分析工作必须在一个能确保一致性的快中进行——这里“一致性”的意思是指在整个分析期间整个执行系统看起来就像被冻结在某个时间点上,不可以出现分析过程中对象引用关系还在不断变化的情况,该点不满足的话分析结果准确性就无法得到保证。这点是导致GC进行时必须停顿所有Java执行线程(Sun将这件事情称为“Stop The World”)的其中一个重要原因,即使是在号称(几乎)不会发生停顿的CMS收集器中,枚举根节点时也是必须要停顿的。
由于目前的主流Java虚拟机使用的都是准确式GC,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。
主要实现:在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样,GC在扫描时就可以直接得知这些信息了。
下面的代码清单3-3是HotSpot Client VM生成的一段String.hashCode()方法的本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer)的引用,有效范围为从call指令开始直到0x026eb730(指令流的起始位置)+142(OopMap记录的偏移量)=0x026eb7be,即hlt指令为止。
[Verified Entry Point]
0x026eb730:mov%eax,-0x8000(%esp)
…… ;ImplicitNullCheckStub slow case
0x026eb7a9:call 0x026e83e0 ;OopMap{ebx=Oop[16]=Oop off=142} ;*caload ;-java.lang.String:hashCode@48(line 1489)
;{runtime_call}
0x026eb7ae:push$0x83c5c18 ;{external_word}
0x026eb7b3:call 0x026eb7b8
0x026eb7b8:pusha
0x026eb7b9:call 0x0822bec0;{runtime_call}
0x026eb7be:hlt
2.安全点
实际上,HotSpot也的确没有为每条指令都生成OopMap,前面已经提到,只是在“特定的位置”记录了这些信息,这些位置称为安全点(Safepoint),即程序执行时并非在所有地方都能停顿下来开始GC,只有在到达安全点时才能暂停。Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。
所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。
对于Sefepoint,另一个需要考虑的问题是如何在GC发生时让所有线程(这里不包括执行JNI调用的线程)都“跑”到最近的安全点上再停顿下来。这里有两种方案可供选择:
抢先式中断(现在几乎没有虚拟机使用)
其中抢先式中断不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
主动式中断
而主动式中断的思想是当GC需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志的地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
3.安全区域
Safepoint机制保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。对于这种情况,就需要安全区域(Safe Region)来解决。
安全区域是指在一段代码片段之中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。
在线程执行到Safe Region中的代码时,首先标识自己已经进入了Safe Region,那样,当在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,它要检查系统是否已经完成了根节点枚举(或者是整个GC过程),如果完成了,那线程就继续执行,否则它就必须等待直到收到可以安全离开Safe Region的信号为止。
到此,介绍了HotSpot虚拟机如何去发起内存回收的问题
虚拟机如何具体地进行内存回收动作仍然未涉及,因为内存回收如何进行是由虚拟机所采用的GC收集器决定的,而通常虚拟机中往往不止有一种GC收集器。待续。。。
来自《深入理解JVM虚拟机》JVM高级特性与最佳实现。