JVM的垃圾收集算法
Posted 真正的飞鱼
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM的垃圾收集算法相关的知识,希望对你有一定的参考价值。
介绍分代收集理论和几种垃圾收集算法的思想及其发展过程。
分代收集理论
当前商业虚拟机的垃圾收集器,大多数都遵循了 “分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,分代收集理论它建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:垃圾收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。显而易见:
- 如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低的代价回收到大量的空间;
- 如果剩下的都是难以消亡的对象,那把它们集中放在一起,虚拟机便可以使用较低的频率来回收这个区域。
- 这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
在 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,因而才有了 “Minor GC”、“Major GC”、“Full GC” 这样的回收类型的划分;也才能够针对不同的区域 安排与里面存储对象存亡特征相匹配的垃圾收集算法,因而发展出了 “标记-清除算法”、“标记-复制算法”、“标记-整理算法” 等针对性的垃圾收集算法。
把分代收集理论具体放到现在的商用 Java 虚拟机里,设计者一般至少会把 Java 堆划分为新生代(Young Generation) 和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都会发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
分代收集并非只是简单划分一下内存区域那么容易,它至少存在一个明显的困难:对象不是孤立的,对象之间会存在跨代引用。假如现在要进行一次只局限于新生代区域内的垃圾收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有的对象来确保可达性分析结果的正确性,反过来也是一样。
遍历整个老年代中所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担。为了解决这个问题,就需要对分代收集理论添加第三条经验法则:跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在垃圾收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”, Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起垃圾收集时扫描整个老年代来说仍然是划算的。
标记-清除算法
最早出现也是最基础的垃圾收集算法是 “标记-清除”(Mark-Sweep)算法,“标记-清除” 算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。也可以反过来,标记出所有存活的对象,在标记完成后,统一回收掉所有未被标记的对象。
之所以说 “标记-清除” 算法是最基础的收集算法,是因为后续的垃圾收集算法大多是以 “标记-清除” 算法为基础,对 “标记-清除” 算法的缺点进行改进而得到的。“标记-清除” 算法的主要缺点有两个:
- 第一个是:执行效率不稳定。如果 Java 堆中包含大量的对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除这两个过程的执行效率都随对象数量增长而降低;
- 第二个是:内存空间的碎片化问题。标记、清除之后会产生大量不连续的内存碎片,内存碎片太多可能会导致程序运行的过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
“标记-清除” 算法的执行过程如图所示。
标记-复制算法
“标记-复制” 算法常被简称为复制算法。
为了解决 “标记-清除” 算法面对大量可回收对象时执行效率低的问题,1969 年 Fenichel 提出了一种被称为 “半区复制”(Semispace Copying)的垃圾收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完时,就将还存活着的对象复制到另外一块内存上,然后再将已使用过的内存空间一次清理掉。
“标记-复制” 算法的优劣局限:
- 如果内存中多数对象都是存活的,“标记-复制” 算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
- “标记-复制” 算法的实现简单,运行高效,不过其缺陷也显而易见,“标记-复制” 算法的代价是将可用内存缩小为了原来的一半,空间浪费有点多。
“标记-复制” 算法的执行过程如图所示。
标记-整理算法
“标记-复制” 算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以在老年代一般不能直接选用 “标记-复制” 算法。
针对老年代对象的存亡特征,1974 年 Edward Lueders 提出了一种有针对性的 “标记-整理”(Mark-Compact)算法, “标记-整理” 算法的标记过程仍然与 “标记-清除” 算法一样,但后续的步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
“标记-整理” 算法的执行过程如图所示。
“标记-清除” 算法与 “标记-整理” 算法的本质差异在于 “标记-清除” 算法是一种非移动式的回收算法,而 “标记-整理” 算法是一种移动式的回收算法。是否移动回收后的存活对象是一项优缺点并存的风险决策:
- 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为 “Stop The World”。
- 但如果跟 “标记-清除” 算法那样完全不考虑移动和整理存活对象的话,弥散于 Java 堆中的存活对象导致的内存碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过 “分区空闲分配链表” 来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间, 能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的) 。内存的访问是用户程序最频繁的操作,甚至都没有之一,假如在内存访问这个环节上增加了额外的负担,势必会直接影响应用程序的吞吐量。
基于以上两点,是否移动对象都存在弊端,移动对象则内存回收时会更复杂,不移动对象则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。即使不移动对象会使得收集器的效率提升一些, 但因内存分配和访问相比垃圾收集的频率要高得多,这部分的耗时增加,总吞吐量仍然是下降的。HotSpot 虚拟机里面关注吞吐量的 Parallel Scavenge 收集器是基于 “标记-整理” 算法的,而关注延迟的 CMS 收集器则是基于 “标记-清除” 算法的,这也从侧面印证了这一点。
此语境中,吞吐量的实质是赋值器(Mutator,可以理解为使用垃圾收集的用户程序,本书为便于理解,多数地方用 “用户程序” 或 “用户线程” 代替)与收集器的效率总和。
另外, 还有一种 “和稀泥式” 的解决方案可以不在内存分配和访问上增加太大的额外负担,做法是让虚拟机平时多数时间都采用 “标记-清除” 算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用 “标记-整理” 算法收集一次,以获得规整的内存空间。前面提到的基于 “标记-清除” 算法的 CMS 收集器面临内存碎片过多时采用的就是这种处理办法。
总结
分代收集理论
分代收集理论建立在两个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:垃圾收集器应该将 Java 堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。
在 Java 堆划分出不同的区域之后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域,因而才有了 “Minor GC”、“Major GC”、“Full GC” 这样的回收类型的划分;也才能够针对不同的区域 安排与里面存储对象存亡特征相匹配的垃圾收集算法,因而发展出了 “标记-清除算法”、“标记-复制算法”、“标记-整理算法” 等针对性的垃圾收集算法。
把分代收集理论具体放到现在的商用 Java 虚拟机里,设计者一般至少会把 Java 堆划分为新生代(Young Generation) 和老年代(Old Generation)两个区域。顾名思义,在新生代中,每次垃圾收集时都会发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
垃圾收集算法
“标记-清除” 算法
“标记-清除” 算法分为 “标记” 和 “清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象。也可以反过来,标记出所有存活的对象,在标记完成后,统一回收掉所有未被标记的对象。
后续的垃圾收集算法大多是以 “标记-清除” 算法为基础,对 “标记-清除” 算法的缺点进行改进而得到的。
“标记-复制” 算法
“标记-清除” 算法在有大量对象需要回收时,要进行大量的清除操作,垃圾收集的效率将会降低。为了解决这个问题,有一个人提出了 “标记-复制” 算法,也被称为 “半区复制”。“标记-复制” 算法将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块内存用完时,就将还存活着的对象复制到另外一块内存上,然后再将已使用过的内存空间一次清理掉。
“标记-整理” 算法
“标记-复制” 算法在对象存活率较高(多数对象都是存活的,几乎没有对象需要回收)时,要进行大量的复制操作,垃圾收集的效率将会降低。为了解决这个问题,有一个人提出了 “标记-整理” 算法, “标记-整理” 算法的标记过程仍然与 “标记-清除” 算法一样,但后续的步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存。
不同算法的优劣局限
不同垃圾收集算法的优劣局限。
“标记-清除” 算法的优劣局限:
- **第一个是:存在内存空间的碎片化问题。**标记、清除之后会产生大量不连续的内存碎片,内存碎片太多可能会导致程序运行的过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
- **第二个是:执行效率不稳定。**在有大量对象需要回收时,要进行大量的清除操作,垃圾收集的效率将会降低。
“标记-复制” 算法的优劣局限:
- **第一个是:不存在内存空间的碎片化问题。**当一块内存用完时,就将还存活着的对象复制到另外一块内存上,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
- **第二个是:执行效率不稳定。**在对象存活率较高(多数对象都是存活的,几乎没有对象需要回收)时,要进行大量的复制操作,垃圾收集的效率将会降低。
- 第三个是:空间浪费。“标记-复制” 算法的代价是将可用内存缩小为了原来的一半,空间浪费有点多
“标记-整理” 算法的优劣局限:
- **第一个是:不存在内存空间的碎片化问题。**垃圾收集时,让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的内存,不存在内存空间的碎片化问题。
- 第二个是:停顿时间较长。垃圾收集时,需要移动存活的对象并更新所有引用这些对象的地方,这种对象移动操作必须全程暂停用户应用程序才能进行,停顿时间较长。
“标记-清除” 算法与 “标记-整理” 算法的本质差异在于 “标记-清除” 算法是一种非移动式的回收算法,而 “标记-整理” 算法是一种移动式的回收算法。是否移动回收后的存活对象是一项优缺点并存的风险决策。
- 移动回收后的存活对象,不存在内存空间的碎片化问题,但内存回收时会更复杂(需要移动存活的对象并更新所有引用这些对象的地方);
- 不移动回收后的存活对象,内存回收时的停顿时间会更短,甚至可以不需要停顿,但是内存分配时会更复杂(需要考虑内存空间的碎片化问题)。
参考资料
《深入理解 Java 虚拟机》第 3 章:垃圾收集器与内存分配策略 3.3 垃圾收集算法
JVM之垃圾收集算法与垃圾收集器
Java垃圾收集算法与垃圾收集器
以下文章内容来自周志明老师的深入理解Java虚拟机和看了尚硅谷康师傅的JVM教程之后的笔记。
1.垃圾收集的经典四连问
1.1. 什么是垃圾?
垃圾是指在运行的程序中没有任何指针指向的对象,这个对象就是要被回收的垃圾。
1.2.为什么要垃圾回收?
-
如果不及时对内存中的垃圾进行清理,那么,这些垃圾对象所占的内存空间会一直保留到应用程序结束,被保留的空间无法被其他对象使用。甚至可能导致内存溢出。
-
除了释放没用的对象,垃圾回收也可以清除内存里的记录碎片。碎片整理将所占用的堆内存移到堆的一端, 以便JVM将整理出的内存分配给新的对象。
-
随着应用程序所应付的业务越来越庞大、复杂,用户越来越多,没有GC就不能保证应用程序的正常进行。而经常造成STW的GC又跟不上实际的需求,所以才会不断地尝试对GC进行优化。
1.3. 垃圾什么时候回收?
如果某个对象已经不存在任何引用,那么它可以被回收。但是,具体到什么时刻执行,这个是由系统来进行决定,是无法预测的。
1.4.垃圾如何回收?
-
首先需要知道,GC又分为 minor GC 和 Full GC (也称为 Major GC )。Java 堆内存分为新生代和老年代,新生代中又分为1个 Eden 区域 和两个 Survivor 区域。
-
那么对于 Minor GC 的触发条件:大多数情况下,直接在 Eden 区中进行分配。如果 Eden区域没有足够的空间,那么就会发起一次 Minor GC;对于 Full GC(Major GC)的触发条件:也是如果老年代没有足够空间的话,那么就会进行一次 Full GC。
上面所说的只是一般情况下,实际上,需要考虑一个内存分配担保的问题:
在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。如果大于则进行Minor GC,如果小于则看HandlePromotionFailure设置是否允许担保失败(不允许则直接Full GC)。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于则尝试Minor GC(如果尝试失败也会触发Full GC),如果小于则进行Full GC。
何为内存分配担保?
内存的分配担保好比我们去银行借款,如果我们信誉很好,在98%的情况下都能按时偿还,于是银行可能会默认我们下一次也能按时按量地偿还贷款,只需要有一个担保人能保证如果我不能还款时,可以从他的账户扣钱,那银行就认为没有什么风险了。内存的分配担保也一样,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存活对象,这些对象便将通过分配担保机制直接进入老年代,这对虚拟机来说就是安全的。
2. 垃圾回收的相关概念
2.1 System.gc ()的理解
在默认情况下,程序员可以通过System.gs ()
或者Runtime. getRuntime() .gc()
的调用,来显式触发FullGC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用System.gc()
。
public class systemGCTest {
public static void main(String[] args) {
new SystemGCTest();
//提醒jvm的垃圾回收器执行gc,但是不确定是否马上执gc
System. gc();
//与Runtime. getRuntime().gc();的作用一样。
System.runFinalization();//强制调用使用引用的对象的finalize()方法
@Override
protected void finalize() throws Throwable {
super.finalize();
System. out . println("SystemGCTest重写了finalize()");
}
}
2.2 内存溢出与内存泄漏
一.内存溢出 :
javadoc中对outofMemoryError的解释是,没有空闲内存,并且垃圾收集器也无法提供更多内存。内存溢出是程序崩溃的罪魁祸首之一。
这个解释中我们可以从两个角度来看:
1.没有空闲内存:
首先说没有空闲内存的情况:说明Java虚拟机的堆内存不够。原因有二:
(1) Java虚拟机的堆内存设置不够。
比如:可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定JVM堆大小或者指定数值偏小。我们可以通过参数-Xms、-Xmx
来调整堆内存大小。
(2)代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)
对于老版本的Oracle JDK Java1.8之前, 因为永久代的大小是有限的,并且JVM对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现OutOfMemoryError
也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似intern
字符串缓存占用太多空间,也会导致00M问题。对应的异常信息,会标记出来和永久代相关:“java. lang . OutOfMemoryError: PermGen space"。
随着java1.8中元数据区的引入,方法区内存已经不再那么窘迫,所以相应的OOM有所改观,出现OOM,异常信息则变成了
“java.lang.OutOfMemoryError:Metaspace"。
直接内存不足,也会导致OOM。
2.垃圾收集器也无法提供更多内存:
这里面隐含着一层意思是,在抛出0utOfMemoryError之前,通常垃圾收集器
会被触发,尽其所能去清理出空间。
例如:在引用机制分析中,涉及到JVM会去尝试回收软引用指向的对象等。
在java. nio. BIts . reserveMemory( )方法中,我们能清楚的看到,System. gc()会被调用,以清理空间。
当然,也不是在任何情况下垃圾收集器都会被触发的。比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM可以判断出垃圾收集并不能解决这个问题,所以直接拋OutOfMemoryError
。
二.内存泄漏:
-
内存泄漏也称作“存储渗漏”。严格来说,只有对象不会再被程序用到了,但是GC又不能回收他们的情况,才叫内存泄漏。
-
但实际情况很多时候–些不太好的实践(或疏忽)会导致对象的生命周期变得很长甚至导致OOM,也可以叫做宽泛意义上的“内存泄漏”。
尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现0utOfMemory异常,导致程序崩溃。
注意:这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。
2.3 Stop The World
-
stop- the-world,简称STW,指的是GC事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为STW。
-
被STW中断的应用程序线程会在完成GC之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带-样,所以我们需要减少STW的发生。
2.4 垃圾回收的并发和并行
并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。
串行(Serial): 相较于并行的概念,单线程执行。如果内存不够,则程序暂停,启动JVM垃圾回收器进行垃圾回收。回收完,再启动程序的线程。
并发Concurrent) :指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。用户程序在继续运行,而垃圾收集程序线程运行于另一个CPU上;
2.5 安全点与安全区域
安全点(Safepoint ) :程序执行时并非在所有地方都能停顿下来开始GC,只有在特定的位置才能停顿下来开始GC这些位置称为安全点。
Safe Point的选择很重要,如果太少可能导致GC等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为Safe Point, 如方法调用、循环跳转和异常跳转等。
问题来了如何在GC发生时,检查所有线程都跑到最近的安全点停顿下来呢?
第一种解决办法:抢先式中断:(目前没有虚拟机采用了)
首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安 全点。
第二种解决办法:主动式中断:
设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志, 如果中断标志为真,则将自己进行中断挂起。
Safepoint机制虽然保证了程序执行时,在不太长的时间内就会遇到可进入GC的Safepoint 。但是,程序“不执行”的时候呢?
例如线程处于Sleep 状态或Blocked状态,这时候线程无法响应JVM 的中断请求,“走” 到安全点去中断挂起,JVM也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region) 来解决。
安全区域:是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。
2.6 五种引用
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
在JDK 1.2版之前,Java里面的引用是很传统的定义:
如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。这种定义并没有什么不对,只是现在看来有些过于狭隘了,一个对象在这种定义下只有“被引用”或者“未被引用”两种状态,对于描述一些“食之无味,弃之可惜”的对象就显 得无能为力。
譬如我们希望能描述一类对象:当内存空间还足够时,能保留在内存之中,如果内存空间在进行垃圾收集后仍然非常紧张,那就可以抛弃这些对象。
在JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为
强引用(Strongly Re-ference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)
这4种引用强度依次逐渐减弱。
2.6.1 强引用介绍:
强引用:是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()
”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
在Java程序中,最常见的引用类型是强引用(普通系统99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。当在Java语言中使用new操作符创建一个新的对象, 并将其赋值给个变量的时候,这个变量就成为指向该对象的一个强引用。
强引用的对象是可触及的,垃圾收集器就永远不会回收掉被引用的对象。
对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将相应(强)引用赋值为null,就是可以当做垃圾被收集了,当然具体回收时机还是要看垃圾收集策略。
相对的软引用、 弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成Java内存泄漏的主要原因之一。
强引用特点:
-
强引用可以直接访问目标对象。
-
强引用所指向的对象在任何时候都不会被系统回收,虚拟机宁愿抛出OOM异常,也不会回收强引用所指向对象。
-
强引用可能导致内存泄漏。
2.6.2 软引用介绍:
软引用:是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。
软引用作用: 软引用通常用来实现内存敏感的缓存。比如:高速缓存就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。
什么时候清理 :垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列( Reference Queue)。类似弱引用,只不过Java虛拟机会尽量让软引用的存活时间长一些,迫不得已才清理。
在JDK 1.2版之后提供了SoftReference类来实现软引用。
Object obj = new 0bject(); //声明强引用
SoftReference<Object> sf = new SoftReference<Object>(obj);
obj = null; //销毁强引用
2.6.3 弱引用介绍:
弱引用:也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。
弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。
软引用的作用:软引用、弱引用都非常适合来保存那些可有可无的缓存数据。如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源,充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。
在JDK 1.2版之后提供了WeakReference类来实现弱引用。
object obj = new Object(); / /声明强引用
WeakReference<Object> wr = new WeakReference<Object>(obj );
obj = null; //销毁强引用
弱引用对象与软引用对象的区别:
弱引用对象与软引用对象的最大不同就在于,当GC在进行回收时,需要通过
算法检查是否回收软引用对象,而对于弱引用对象,GC总是进行回收。弱引
用对象更容易、更快被GC回收。
2.6.4 虚引用介绍:
虚引用:也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。
-
虚引用必须和引用队列一起使用。虚引用在创建时必须提供-一个引用队列作为参数。当垃圾回收器准备回收–个对象时,如果发现它还有虛引用,就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。
-
由于虚引用可以跟踪对象的回收时间,因此,也可以将–些资源释放操作
放置在虚引用中执行和记录。
在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
object obj = new 0bject();
ReferenceQueue phantomQueue = new ReferenceQueue( );
PhantomReference<0bject> pf = new PhantomReference<object> (obj, phantomQueue);
obj = null;
2.6.5 终结器引用(Final reference)
- 它用以实现对象的finalize()方法,也可以称为终结器引用。
- 无需手动编码,其内部配合引用队列使用。
- 在GC时,终结器引用入队。由Finalizer线程通过终结器引用找到被引用
对象并调用它的finalize()方法,第二次GC时才能回收被引用对象。
3. 垃圾回收相关算法
3.1 垃圾标记阶段算法
在堆里存放着几乎所有的Java对象实例,在GC执行垃圾回收之前,首先需
要区分出内存中哪些是存活对象,哪些是已经死亡的对象。只有被标记为己
经死亡的对象,GC才会在执行垃圾回收时,释放掉其所占用的内存空间,因
此这个过程我们可以称为垃圾标记阶段。
那么在JVM中究竟是如何标记一个 死亡对象呢?简单来说,当一个对象已经不再被任何的存活对象继续引用时,就可以宣判为已经死亡。
3.1.1引用计数算法
引用计数算法(Reference Counting) 比较简单,对每个对象保存一个 整型的引用计数器属性。用于记录对象被引用的情况。
对于一个对象A,只要有任何一个对象引用了A,则A的引用计数器就加1;当引用失效时,引用计数器就减1。只要对象A的引用计数器的值为0,即表示对象A不可能再被使用,可进行回收。
优点:实现简单,垃圾对象便于辨识;判定效率高,回收没有延迟性。
缺点:
- 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销。
- 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销。
- 引用计数器有一个严重的问题,即无法处理循环引用的情况。这是一条致命 缺陷,导致在Java的垃圾回收器中没有使用这类算法。
内存泄漏
public class ReferenceCountingGC {
public Object instance = null;
private static final int _1MB = 1024 * 1024;
/**
* 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过
*/
private byte[] bigSize = new byte[2 * _1MB];
public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
// 假设在这行发生GC,objA和objB是否能被回收?
System.gc();
}
public static void main(String[] args) {
testGC();
}
}
GC(0) Pause Full (System.gc()) 9M->0M(10M) 5.293ms
从运行结果中可以清楚看到内存回收日志中包含“ GC(0) Pause Full (System.gc()) 9M->0M(10M) 5.293ms
”,意味着虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明了Java虚拟机并不是通过引用计数算法来判断对象是否存活的
3.1.2可达性分析算法
基本思路:
- 可达性分析算法是以根对象集合(GC Roots) 为起始点,按照从上至下
的方式搜索被根对象集合所连接的目标对象是否可达。 - 使用可达性分析算法后,内存中的存活对象都会被根对象集合直接或间
接连接着,搜索所走过的路径称为引用链(Re ference Chain) - 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己
经死亡,可以标记为垃圾对象。 - 在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象
才是存活对象。
所谓"GC Roots "根集合就是一组必须活跃的引用。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
- 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
- 所有被同步锁(synchronized关键字)持有的对象。
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。
譬如分代收集和局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。
由于Root采用栈方式存放变量和指针,所以如果一个指针,它保存了堆内存
里面的对象,但是自己又不存放在堆内存里面,那它就是一个Root
如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在
一个能保障一致性的快照中进行。这点不满足的话分析结果的准确性就无
法保证。
3.2 对象的finalize机制
Java语言提供了对象终止(finalization) 机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。
当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会
先调用这个对象的finalize()方法。
finalize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字和数据库连接等。
永远不要主动调用某个对象的finalize()方法,应该交给垃圾回收机制调用。理由包括下面几点:
- 在finalize() 时可能会导致对象复活。
- finalize() 方法的执行时间是没有保障的,它完全由GC线程决定,极端情况下,若不发生GC,则finalize() 方法将没有执行机会。
- 一个糟糕的finalize() 会严重影响GC的性能 。
- 从Java9开始该方法已经被标识为过时方法了
@Deprecated(since="9") protected void finalize() throws Throwable { }
从功能上来说,finalize ()方法与C+ +中的析构函数比较相似,但是Java采用的是基于垃圾回收器的自动内存管理机制,所以finalize ()方法在本质上不同于C++中的析构函数。
由于finalize ()方法的存在,虚拟机中的对象一般处于三种可能的状态。
如果从所有的根节点都无法访问到某个对象,说明对象已经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某-一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:
可触及的: 从根节点开始,可以到达这个对象。
可复活的: 对象的所有引用都被释放,但是对象有可能在finalize()中复活。
不可触及的: 对象的finalize()被调用,并且没有复活,那么就会进入不可触及状态。 不可触及的对象不可能被复活,因为finalize() 只会被调用一次。
以上3种状态中,是由于finalize ()方法的存在,进行的区分。只有在对象不可触及时才可以被回收。
判定一个对象objA是否可回收,至少要经历两次标记过程:
1.如果对象objA到GC Roots没有引用链,则进行第一次标记。.
2.进行筛选,判断此对象是否有必要执行finalize()方法
①如果对象objA没有 重写finalize()方法,或者finalize() 方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA被判定为不可触及的。
②如果对象objA重写 了finalize()方法,且还未执行过,那么objA会 被插入到F-Queue队列中,由一个虛拟机自动创建的、低优先级的Finalizer线程触发其finalize()方法执行。
③finalize() 方法是对象逃脱死亡的最后机会,稍后Gc会对F-Queue队列中的对象进行第二次标记。如果objA在finalize()方法中与引用链上的任何一-个对象建立了联系,那么在第二次标记时,objA会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下,finalize方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的finalize方法只会被调用一次。
public class FinalizeEscapeGC {
public static FinalizeEscapeGC SAVE_HOOK = null;
public void isAlive() {
System.out.println("yes, i am still alive :()");
}
@Override
protected void finalize() throws Throwable {
super.finalize();
System.out.println("finalize method executed!");
FinalizeEscapeGC.SAVE_HOOK = this;
}
public static void main(String[] args) throws Throwable {
SAVE_HOOK = new FinalizeEscapeGC();
//对象第一次成功拯救自己
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :()");
}
// 下面这段代码与上面的完全相同,但是这次自救却失败了
SAVE_HOOK = null;
System.gc();
// 因为Finalizer方法优先级很低,暂停0.5秒,以等待它
Thread.sleep(500);
if (SAVE_HOOK != null) {
SAVE_HOOK.isAlive();
} else {
System.out.println("no, i am dead :()");
}
}
}
finalize method executed!
yes, i am still alive :()
no, i am dead :()
从代码中我们可以看到一个对象的finalize()被执行,但是它仍然可以存活。
另外一个值得注意的地方就是,代码中有两段完全一样的代码片段,执行结果却是一次逃脱成功,一次失败了。这是因为任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行,因此第二段代码的自救行动失败了。
3.3 方法区的垃圾回收
方法区的垃圾收集主要回收两部分内容:废弃的常量和不再使用的类型。
回收废弃常量与回收Java堆中的对象非常类似。举个常量池中字面量回收的例子,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
判定一个常量是否“废弃”还是相对简单,而要判定一个类型是否属于“不再被使用的类”的条件就比较苛刻了。需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计的可替换类加载器的场景,如OSGi、JSP的重加载等,否则通常是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc
参数进行控制,以下参数查看类加载和卸载信息:
-verbose:class
-XX:+TraceClass-Loading
-XX:+TraceClassUnLoading
在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。
注意在JDK1.6之前和之后方法区进行了如下调整:
3.4 垃圾清除阶段算法
当成功区分出内存中存活对象和死亡对象后,GC接下来的任务就是执行垃圾
回收,释放掉无用对象所占用的内存空间,以便有足够的可用内存空间为新对象分配内存。
目前在JVM中比较常见的三种垃圾收集算法是标记-清除算法( Mark-Sweep)、复制算法(Copying)、标记一压缩算法(Mark-Compact )。
3.4.1 标记-清除算法( Mark-Sweep)
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,在1960年由Lisp之父John McCarthy所提出。如它的名字一样,算法分为“标记”和“清除”两个阶段:
标记: Collector从引用根 节点开始遍历,标记所有被引用的对象。一般是 在对象的Header中记录为可达对象。
清除: Collector对堆内存从头到尾进行线性的遍历,如果发现某个对象在其Header中没有标记为可达对象,则将其回收。
注意:这里深入理解java虚拟机这本书中清除阶段描述的是:
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
标记-清除算法的执行过程如图所示:
它的主要缺点有两个:
第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低;
第二个是内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。需要维护一个空闲列表。
所以这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够, 如果够,就存放。
3.4.2 复制算法(Copying)
核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在
使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用
的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收。
优点:
- 没有标记和清除过程,实现简单,运行高效
- 复制过去 以后保证空间的连续性,不会出现“碎片”问题。
缺点:
- 此算法的缺点也是很明显的,就是需要两倍的内存空间。
- 对于G1这种分拆成为大量region的GC,复制而不是移动,意味着GC需要维护region之间对象引用关系,不管是内存占用或者时间开销也不小。
- 如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。
应用场景:
在新生代,对常规应用的垃圾回收,一次通常可以回收70号- 99号的内存空间。回收性价比很高。所以现在的商业虚拟机都是用这种收集算法回收新生代。
3.4.3 标记一压缩(整理)算法(Mark-Compact )
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法,正是因为这个原因所以标记压缩算法诞生了。
标记一压缩算法执行过程:
第一阶段:和标记清除算法一样,从根节点开始标记所有被引用对象。
第二阶段:将所有的存活对象压缩到内存的一端,按顺序排放之后,清理边界外所有的空间。
标记-压缩算法的最终效果等同于标记-清除算法执行完成后,再进行一次
内存碎片整理,因此,也可以把它称为标记-清除-压缩(Mark- Sweep-
Compact)算法。
二者的本质差异在于标记-清除算法是一种非移动式的回收算法,标记-压
缩是移动式的。是否移动回收后的存活对象是一项优缺点并存的风险决策。
为什么说是优缺点并存呢?原因有如下两点
- 如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行也就是必须stop-the-world。
- 如果跟标记-清除算法那样完全不考虑移动和整理存活对象的话,弥散于堆中的存活对象导致的空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。譬如通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的)。
基于以上两点,是否移动对象都存在弊端,移动则内存回收时会更复杂,不移动则内存分配时会更复杂。从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿,但是从整个程序的吞吐量来看,移动对象会更划算。
有一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用标记-整理算法收集一次,以获得规整的内存空间。其中CMS收集器面临空间碎片过多时采用的就是这种处理办法。
指针碰撞(Bump the Pointer)
如果内存空间以规整和有序的方式分布,即已用和未用的内存都各自一边,彼此之间维系着一个记录下一次分配起始点的标记指针,当为新对象分配内存时,只需要通过修改指针的偏移量将新对象分配在第一个空闲内存位置上,这种分配方式就叫做指针碰撞(Bump tHe Pointer) 。
优点:
- 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可。
- 消除了复制算法当中,内存减半的高额代价。
缺点:
- 从效率上来说,标记- -整理算法要低于复制算法。
- 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
- 移动过程中,需要全程暂停用户应用程序。即: STW
三种算法对比
Mark-Sweep | Mark-Compact | Copying | |
---|---|---|---|
速度 | 中等 | 最慢 | 最快 |
空间开销 | 少(但会堆积碎片) | 少(不地积碎片) | 通常需要活对象的2倍大小(不堆积碎片) |
是否移动对象 | 否 | 是 | 是 |
3.5 分代收集算法
前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自己独特的优势和特点。分代收集算法应运而生。
分代收集算法:是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。一 般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。
在Java程序运行的过程中,会产生大量的对象,其中有些对象是与业务信息相关,比如Http请求中的Session对象、线程、Socket连接,这类对象跟业务
直接挂钩,因此生命周期比较长。
但是还有一些对象,主要是程序运行过程中生成的临时变量,这些对象生命周期会比较短,比如: string对象, 由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。
目前几乎所有的GC都是采用分代收集(Generational Collecting) 算法执行垃圾回收的。
在HotSpot中,基于分代的概念,GC所使用的内存回收算法必须结合年轻代和老年代各自的特点。
年轻代(Young Gen)
年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频 繁。
这种情况复制算法的回收整理,速度是最快的。复
以上是关于JVM的垃圾收集算法的主要内容,如果未能解决你的问题,请参考以下文章