如何识别垃圾

如何识别堆中的对象是垃圾,是GC的第一个问题,下面介绍两种算法来回答这个问题。

引用计数法

定义一个变量,如果一个对象被引用,那么该对象的引用计数+1;如果删除一个对象的引用,那么该对象的引用技术-1。如下所示
Object o = new Object(); //引用o指向新创建的Object对象,对象引用计数为1
o = null; //删除了Object对象的引用o,所以此时对象的引用为0

引用计数法最大的问题,如果把引用计数作为判断是否为垃圾的唯一标准,那么可能会产生内存泄漏的问题。如下所示:

public class Thing{
    Object x;
}

Thing t1 = new Thing()
Thing t2 = new Thing()
t1.x = t2
t2.x = t1
t1, t2 = null

上面的代码创建了两个对象并使两个对象t1、t2,随后他们各自的引用类型属性x指向对方,随后删除t1、t2的引用,但两个对象由于对方有引用指向自己,所以引用计数都不为0,所以无法被回收,造成内存泄漏。

可达性算法

定义一些对象为GC root节点,从这些节点出发向下搜索,搜索过的路径称为引用链,当一个对象到root节点没有任何引用链相连(即root节点到该对象不可达),则认为该对象是垃圾。
以下四种对象可被认为是root节点:

  • 虚拟机栈(栈帧中的本地表量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中引用的对象

如何回收内存

标记清除法

当判断到什么对象可以被回收后,就要将其释放掉以腾出内存。怎么释放呢?最直观的方法就是标记清除法。原理很简单:当一个对象被标为垃圾,就地释放掉。但是这样会造成很多的内存碎片。

复制法

此算法的特点是,将内存空间一分为二,一边用于存储,另一边总是空的。当需要垃圾回收时,将用于存储的内存中没被标记为垃圾的对象直接拷贝到另一边内存中,原来的内存全部清空。这样就保证了内存的连续可用问题,内存分配时也不用考虑内存碎片的问题,十分简单高效。缺点是内存只有一半可以被使用了。

标记整理/标记压缩法

这个方法和标记法类似。和标记法类似,当标记出要回收的对象后,将他们回收掉,然后存活的对象向一端移动。就类似于从书架上取出若干本书(垃圾),为了让剩余的书(存活的对象)整齐、且腾出连续的更多的空间(空闲内存)给以后要到来的书使用,于是把剩余的书都推向书架的一边。这样做克服了上两种方法的缺点,但是由于内存变动频繁,效率低。

分代策略

前面介绍的三种方法过于简单粗暴,于是提出将内存分成不同区域的概念,再组合上述的几种方法,提高回收的效率。

第一步:将堆分成两个大区域:新生代区(占1/3)和老年代区(占2/3)。
第二步:新生代中,分为Eden区(8/10)和FromSurvivor区(1/10)和ToSurvivor区(1/10)

新生代区

Eden区
根据统计研究,绝大多数对象都是在创建不久就会死去。因此根据这种现象,将新创建的对象首先会在Eden区进行分配,当Eden区没有足够空间进行分配时,执行一次Minor GC,Minor GC是针对新生代去的,速度比Major GC(Full GC)更快,也更频繁。Minor GC后,Eden区大多数对象被回收,没被回收的对象进入To区(如果To区满了的话则直接进入老年代区)。
Survivor区
此区被划分为From区和To区,这两个区域是互相转换的。所以一次完成的Minor GC应该是这样的。Eden区满了以后,From区和Eden区一起执行一次Minor GC,将大多数对象清空,存活对象放到To区,如果放不下的话则进入老年代区,然后To区现在变成From区,From区变成To区。
下面有两个问题:

  1. 为什么需要Survivor区?
    Survivor区相当于一个缓冲的作用。假设没有这个区域,Eden代一满则进行Minor GC,而没被GC的对象则直接进入老年代区,这样老年代区很快就满了。事实上很多对象尽管能在一次Minor GC上存活,但是他实际上也很快就要消亡了。所以不能那么快将它放入老年代区。事实上,Survivor区的缓冲作用是保证了一个对象在经历了16次Minor GC还能存活时才会被放入老年区。
  2. 为什么需要两个Survivor区?
    这就要考虑到之前说的三种基本的垃圾回收方法了。假设只有一个Survivor区,在进行一次Minor GC后,这个Survivor区会有一定的存活对象,之后再进行Minor GC时,Survivor区里的对象可能也有的会被清除。问题就来了,此时怎么清除Eden和Survivor区的垃圾对象?不能使用复制法,因为没有多余的空闲区域;使用标记压缩法也不好,因为Minor GC较为频繁,标记压缩法太低效;只能使用标记法,但是后果是产生很多内存碎片。所以,才需要两个Survivor区。目的正是可以在Minor GC中使用复制法进行垃圾回收,即高效,也不会浪费太多空间。那为什么不用3、4甚至更多的Survivor区?因为分的越多区则越细,每一块空间比较小,很快就满了。况且复制法的性能也不会随着块数越多越好,所以完全没必要。
  3. 为什么采用复制法?
    前面分析了两个Survivor区可以保证使用复制法。复制法本身就效率高、无内存碎片。且对于新生代而言很多对象都会被标为垃圾,而复制法是复制未被标为垃圾的对象,所以复制法对于在新生代的Minor GC来说再适合不过。
    老年代区
    老年代区占2/3的空间,只有在Major GC(Full GC)时才会清理。由于老年代区对象存活率高,因此采用复制***存在很多复制操作,因此采用标记压缩法。下面介绍进入老年代区的条件:
  • Minor GC后存活的、又无法进入ToSurvivor区的对象。
  • 长期存活的对象,指经历了15次Minor GC都存活的对象
  • 大对象,指需要大量连续内存空间的对象,这些对象不管生命周期长或短,都会直接进入老年代。这样做是避免大对象在Minor GC阶段,反复在To和From两个区进行内存复制操作(因为他们大,所以复制法回收会需要复制特别多)
  • 动态对象年龄:并不是一定要经历了15次Minor GC才会进入老年去,当Survivor区中相同年龄所有对象大小的和大于Survivor空间的一半,此时年龄大于等于该年龄的对象就可以直接去老年区了。这其实是一种负载均衡。可以避免某次Minor GC时大量对象都要移到老年代中。
    象都要移到老年代中。

什么样的对象会被清除

前面讲了怎么定义垃圾对象以及对堆整体而言的GC策略。但是一个对象并不是被标记为垃圾后,就一定会在下一轮GC中被释放的。每个对象还有一个自救(复活)的机会。当一个对象被发现与root不可达,如果该对象没有被复活过、且重写了finalize()方法,那么该对象就可以在被回收前有一次执行finalize()方法的机会,如果finalize()方法中重新给出了指向对象自己的引用,那么对象就自救成功,不会被回收。但这样的机会只有一次。如下图所示:
图片说明

触发GC的时机

  • 常规:Eden区满了触发Minor GC,老年代的对象大于老年代剩余空间了触发Full GC。
  • Minor GC前,会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间,如果大于,则本次Minor GC安全没有问题。如果小于,则检查HandlePromotionFailure参数设置值是否允许担保失败。如果为True即允许,则会检查老年代剩余连续空间是否大于历代新生代晋升到老年代对象的平均大小,如果大于,则进行Minor GC,但仍然有风险。如果设置为False,即不允许担保失败,则触发Full GC。

    GC调优部分参数

  • HandlePromotionFailure参数,前文已提及。
  • GCTimeRatio:如果GC与非GC时间耗时超过了GCTimeRatio的限制,将引发内存泄漏。(关于内存泄漏的深入了解可参考:https://blog.csdn.net/qq_37552993/article/details/89702860
  • NewRatio:控制新生代老年代比例。
  • MaxTenuringThreshold:控制进入老年前生存次数等。
  • UseCMSCompactAtFullCollection:用于设置在Full GC(标记清除法)之后是否进行整理(压缩)。
  • CMSFullGCsBeforeCompaction:用于设置多少次不压缩的Full GC后进行一轮压缩。
    更详细的GC调优与参数:https://zhuanlan.zhihu.com/p/141669715?from_voters_page=true

    总结

  • GC的过程:删除不使用的对象,回收内存空间;运行默认的finalize()方法(如果有的话)。
  • 如果想立刻释放对象,可用dipose()以释放资源。
  • 对象序列化后也可以使对象复活。