【JVM知识总结-3】垃圾收集策略与算法

【JVM知识总结-3】垃圾收集策略与算法
程序计数器、虚拟机栈、本地方法栈随线程而生,也随线程而灭;栈帧随着方法的开始而入栈,随着方法的结束而出栈。这几个区域的内存分配和回收都确定性,在这几个区域不需要过多的考虑回收的问题,因为方法结束或者线程结束,内存自然就跟随着回收了。
而对于Java堆和方法区,我们只有在程序运行期间才能知道会创建哪些对象这部分内存的分配和回收都是动态的,垃圾回收器所关注的正是这一部分内存。
判定对象是否存活
若一个对象不被任何对象或变量引用,那么它就是无效对象,需要被回收。
引用计数法 在对象头维护者一个counter计数器,对象被引用一次则计数器+1;若引用时效则计数器-1。当计数器为0时,就认为该对象无效了。
引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的算法。但是主流的Java虚拟机里没有选用引用计数算法来管理内存,主要是因为它很难解决对象之间循环引用的问题。(虽然循环引用的问题可以通过Recycler算法解决,但是在多线程环境下,引用计数变更也要进行昂贵的同步操作,性能较低,早期的编程语言会采用此算法。)

举个例子:对象objA和objB都有字段instance,令objA.instance = objB并且objB.instance = objA,由于他们互相引用着对方,导致他们的引用计数器都不为0,于是引用计数器算法无法通知GC收集器回收它们。
可达性分析法 所有和GC Roots直接或间接关联的对象都是有效对象,和GC Roots没有关联的对象就是无效对象。
GC Roots是指:
  • Java虚拟机栈(战神中的本地变量表)中引用的对象。
  • 本地方法中引用的对象
  • 方法区中常量引用的对象
  • 方法去中静态属性引用的对象
GC Roots并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。
引用的种类 判定对象是否存在活与“引用”有关。在JDK1.2以前,Java中的引用定义很传统,一个对象只有被引用或者没有被引用两种状态,我们希望能描述这一现象:当内存空间还足够时,则多保留在内存中;如果内存空间在进行垃圾收集后还非常紧张,则可以抛弃这些对象。很多系统的缓存功能复合这样的使用场景。
在JDK1.2之后,Java对引用的概念进行了扩充,将引用分为了一下四种。不同的引用类型,主要体现的是对象不同的可达性状态reachable和垃圾收集的影响。
【JVM知识总结-3】垃圾收集策略与算法
文章图片

强引用(Strong Reference) 类似Object obj = new Object()这类的引用,就是强引用,只要强引用存在,垃圾回收器永远不会回收被引用的对象。但是,如果我们错误的保持了强引用,比如:赋值给了static变量,那么对象在很长一段时间内不会被回收,会产生内存泄露。
软引用(Soft Reference) 软引用是一种相对强引用弱化一些的引用,可以让对象能豁免一些垃圾回收器,只有当JVM认为内存不足时,才会去试图回收软引用指向的对象。JVM会确保在抛出OutOfMemeryError之前,清理软引用指向的对象。软引用通常用来实现内存敏感的缓存,如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
弱引用(Weak Reference) 弱引用的强度比软引用更弱一些。当JVM进行垃圾回收时,无论内存是否充足,都会回收只被弱引用关联的对象。
虚引用(Phantom Reference) 虚引用也称幽灵引用或者幻影引用,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响。它仅仅是提供了一种确保对象被finalize以后,做某些事情的机制,比如,通常用来做所谓的Post-Mortem清理机制。
回收堆中无效对象 对于可达性分析中不可达的对象,也并不是没有存活的可能。
判定finalize()是否有必要执行
JVM会判断此对象是否有必要执行finalized()方法,如果对象没有覆盖finalized()方法,或者finalize()方法已经被虚拟机调用过,那么视为“没有必要执行”。那么对象基本上就真的被回收了。
如果对象被判定为有必要执行finalize()方法,那么对象会被放入一个F-Queue队列中,虚拟机会以比较低的优先级执行这些finalize()方法,但不会确保所有的finalize()方法都会执行结束。如果finalize()方法出现耗时操作,虚拟机就直接停止指向该方法。将对象清除。
对象重生或死亡
如果在执行finalize()方法时,将this赋给了某一个引用,那么该对象就重生了。如果没有,那么就会被垃圾收集器清除。
任何一个对象的finalize()方法只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,想继续在finalize()中自救就失效了。
回收方法区内存 方法区中存放生命周期较长的类信息、常量、静态变量,每次垃圾收集只有少量的垃圾被清除。方法区中主要清除两种垃圾:
  • 废弃常量
  • 无用的类
判定废弃常量
只要常量池中的常量不被任何变量或对象引用,那么这些常量就会被清除掉。比如,一个字符串“bingo”进入了常量池,但是当期系统没有任何一个String对象引用常量池中的“bingo”常量,也没有其他地方引用这个字面量,必要的话,“bingo”常量会被清除出常量池。
判定无用的类
判定一个类是否是“无用的类”,条件极为苛刻。
  • 该类的所有对象都已经被清除
  • 加载该类的ClassLoader已经被回收
  • 该类的java.lang.Classd对象已经没有在任何地方被引用,无法在任何地方通过反射的方式访问该类的方法。
一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类别加载进方法区时被创建,在方法区该类被删除时清除。
垃圾收集算法 学会了如何判定无效对象、无用类、废弃常量之后,剩余的工作就是回收这些垃圾。常见的垃圾收集算法有一下几个:
标记-清除算法
标记:遍历所有GC Roots,然后将所有GC Roots可达的对象标记为存活的对象。
【注意】标记的是存活的不需要回收的对象!!!
清除:清除的过程将遍历堆中所有的对象,将没有标记的对象全部清除掉。与此同时,清除哪些被标记过的对象的标记,以便下次的垃圾回收。
这种方法有两个不足:
  • 效率问题:标记和清除两个过程的效率都不高。
  • 空间问题:标记清除之后会产生大量不连续的内存碎片。碎片太多可能导致以后需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次的垃圾收集动作。
复制算法
为了解决效率问题,“复制”收集器算法出现了。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完,需要进行垃圾回收时,就将存活者的对象复制到另一块上面,然后将第一款内存全部清除。这种算法有优势有劣:
  • 优点:不会有内存碎片的问题。
  • 缺点:内存缩小为原来的一半,浪费空间。
为了解决空间利用率问题,可以将内存分为三块:Eden/From Survivor/To Survivor,比例是8:1:1,每次使用Eden和其中一块Survivor。回收时,将Eden和Survivor中还存活的对象一次性复制到另一块Survivor空间上,最后清理掉Eden和刚才使用的Survivor空间。这样只有10%的内存被浪费。
但是我们无法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够时,需要依赖其他内存(指老年代)进行分配担保。
分配担保 为对象分配内存空间时,如果Eden+Survivor中空闲区域无法装下该对象,会触发MinorGC进行垃圾收集。但如果Minor GC过后依然有超过10%的对象存活,这样存活的对象直接通过分配担保机制进入老年代,然后再将新对象存入Eden区。
标记-整理法(老年代)
标记:它的第一个阶段与标记-清除算法是一模一样的,均是遍历GC Roots,然后将存活的对象标记。
整理:移动所有存活的对象,且按照内存地址次序依次排列,然后将末端的内存地址以后的内存全部回收。因此,第二阶段才称为整理阶段。
这是一种老年代的垃圾收集算法。老年代的对象一般寿命比较长,因此每次垃圾回收会有大量对象存活,如果采用复制算法,每次需要复制大量存活的对象,效率很低。
分代收集算法
【【JVM知识总结-3】垃圾收集策略与算法】根据对象存活周期的不同,将内存划分为几块。一般是把Java堆内分为新生代和老年代,针对各个年代的特点采用最合适的收集算法。
  • 新生代:复制算法
  • 老年代:标记-清除算法、标记-整理算法

    推荐阅读