1.垃圾回收概述
垃圾收集(Garbage Collection, 下文简称GC);
垃圾收集需要完成的三件事情
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
垃圾回收的优缺点
- 优点:
- 不需要考虑内存管理
- 可以有效的防止内存泄漏,有效的利用可使用的内存
- 由于有垃圾回收机制,Java中的对象不再有"作用域"的概念,只有对象的引用才有"作用域"
- 缺点:
- java开发人员不了解自动内存管理, 内存管理就像一个黑匣子,过度依赖就会降低我们解决内存溢出/内存泄漏等问题的能力
2.垃圾回收-对象是否存活
2.1 引用计数算法
引用计数算法可以这样实现:
给每个创建的对象添加一个引用计数器
,每当此对象被某个地方引用时,计数值+1,引用失效时-1,所以当计数值为0时表示对象已经不能被使用。引用计数算法大多数情况下是个比较不错的算法,简单直接,也有一些著名的应用案例但是对于Java虚拟机来说,并不是一个好的选择,因为它很难解决对象直接相互循环引用
的问题。
优点
实现简单,执行效率高,很好的和程序交织。
缺点
无法检测出循环引用。
示例
譬如有A和B两个对象,他们都互相引用,除此之外都没有任何对外的引用,那么理论上A和B都可以被作为垃圾回收掉,但实际如果采用引用计数算法,则A、B的引用计数都是1,并不满足被回收的条件,如果A和B之间的引用一直存在,那么就永远无法被回收了
public class App {
public static void main(String[] args) {
Test object1 = new Test();
Test object2 = new Test();
object1.object = object2;
object2.object = object1;
object1 = null;
object2 = null;
}
}
class Test {
public Test object = null;
}
2.2 可达性分析算法
2.2.1 可达性分析算法
在主流的商用程序语言如Java、C#等的主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的。此算法的基本思路就是通过一系列的“GC Roots”的对象作为起始点,从起始点开始向下搜索到对象的路径。搜索所经过的路径称为引用链(Reference Chain),当一个对象到任何GC Roots都没有引用链时,则表明对象“不可达”,即该对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 栈帧中的局部变量表中的reference引用所引用的对象
- 方法区中static静态引用的对象
- 方法区中final常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
- Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如 - - - - - NullPointExcepiton、OutOfMemoryError) 等, 还有系统类加载器。
- 所有被同步锁(synchronized关键字) 持有的对象。
2.2.2 finalize()方法最终判定对象是否存活
即使在可达性分析算法中判定为不可达的对象, 也不是“非死不可”的, 这时候它们暂时还处于“缓 刑”阶段, 要真正宣告一个对象死亡, 至少要经历两次标记过程:
第一次标记:·
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记, 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()方法。
没有必要:
假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为“没有必要执行”。
有必要:
如果这个对象被判定为确有必要执行finalize()方法, 那么该对象将会被放置在一个名为F-Queue
的 队列之中, 并在稍后由一条由虚拟机自动建立的、 低调度优先级的Finalizer线程去执行它们的finalize() 方法
。 finalize()方法是对 象逃脱死亡命运的最后一次机会, 稍后收集器将对F-Queue中的对象进行第二次小规模的标记, 如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己 (this关键字) 赋值给某个类变量或者对象的成员变量, 那在第二次标记时它将被移出“即将回收”的集合; 如果对象这时候还没有逃脱, 那基本上它就真的要被回收了。
/**
1. 此代码演示了两点:
2. 1.对象可以在被GC时自我拯救。
3. 2.这种自救的机会只有一次, 因为一个对象的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 :(");
}
}
}
2.2.3 再谈引用
在JDK1.2以前,Java中引用的定义很传统: 如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。 我们希望能描述这一类对象: 当内存空间还足够时,则能保存在内存中;如果内存空间在进行垃圾回收后还是非常紧张,则可以抛弃这些对象。
很多系统中的缓存对象都符合这样的场景。 在JDK1.2之后,Java对引用的概念做了扩充,将引用分为 强引用(Strong Reference)
、 软引用(Soft Reference)
、 弱引用(Weak Reference)
和 虚引用(Phantom Reference)
四种,这四种引用的强度依次递减。
强引用(StrongReference)
强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。 ps:强引用其实也就是我们平时A a = new A()这个意思。
软引用(SoxReference)
如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。 软引用可以和一个引用队列(ReferenceQueue)
联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中
弱引用(WeakReference)
用来描述那些非必须对象, 但是它的强度比软引用更弱一些, 被弱引用关联的对象只能生存到下一次垃圾收集发生为止
。 当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只 被弱引用关联的对象。 在JDK 1.2版之后提供了WeakReference类来实现弱引用。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中。
虚引用(PhantomReference)
“虚引用”顾名思义,它是最弱的一种引用关系。如果一个对象仅持有虚引用,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动。
弱引用与软引用的区别在于
①更短暂的生命周期;
②一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
虚引用与软引用和弱引用的一个区别在于
①虚引用必须和引用队列 (ReferenceQueue)联合使用。
②当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。
3.垃圾收集算法
3.1 分代收集理论
思想也很简单,就是根据对象的生命周期将内存划分,然后进行分区管理。 当前商业虚拟机的垃圾收集器, 大多数都遵循了“分代收集”
(Generational Collection)的理论进 行设计, 分代收集名为理论, 实质是一套符合大多数程序运行实际情况的经验法则, 它建立在两个分代假说之上:
1) 弱分代假说
(Weak Generational Hypothesis) : 绝大多数对象都是朝生夕灭的。
2) 强分代假说
(Strong Generational Hypothesis) : 熬过越多次垃圾收集过程的对象就越难以消亡。
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则: 收集器应该将Java堆划分 出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数) 分配到不同的区 域之中存储。 显而易见, 如果一个区域中大多数对象都是朝生夕灭, 难以熬过垃圾收集过程的话, 那 么把它们集中放在一起, 每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对 象, 就能以较低代价回收到大量的空间; 如果剩下的都是难以消亡的对象, 那把它们集中放在一块, 虚拟机便可以使用较低的频率来回收这个区域, 这就同时兼顾了垃圾收集的时间开销和内存的空间有 效利用。在Java堆划分出不同的区域之后, 垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 ——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分; 也才能够针对不同的区域安 排与里面存储对象存亡特征相匹配的垃圾收集算法——因而发展出了“标记-复制算法”“标记-清除算 法”“标记-整理算法”等针对性的垃圾收集算法。
他针对不同分代的类似名词, 为避免产生混淆, 在这里统一定义 :
- 部分收集(Partial GC) : 指目标不是完整收集整个Java堆的垃圾收集, 其中又分为:
新生代收集(Minor GC/Young GC)
: 指目标只是新生代的垃圾收集。老年代收集(Major GC/Old GC)
: 指目标只是老年代的垃圾收集,目前只有CMS收集器会有单 独收集老年代的行为。混合收集(Mixed GC)
: 指目标是收集整个新生代以及部分老年代的垃圾收集。 目前只有G1收集器会有这种行为。
- 整堆收集(Full GC) : 收集整个Java堆和方法区的垃圾收集
3.2 标记-清除算法
最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)
算法, 在1960年由Lisp之父 John McCarthy所提出。 如它的名字一样, 算法分为“标记”和“清除”两个阶段: 首先标记出所有需要回 收的对象, 在标记完成后,统一回收掉所有被标记的对象, 也可以反过来, 标记存活的对象, 统一回 收所有未被标记的对象。
标记过程就是对象是否属于垃圾的判定过程, 这在前一节讲述垃圾对象标记 判定算法时其实已经介绍过了。 之所以说它是最基础的收集算法, 是因为后续的收集算法大多都是以标记-清除算法为基础, 对其 缺点进行改进而得到的。
标记-清除算法有不足之处:
内存空间的碎片化问题
, 标记、 清除之后会产生大 量不连续的内存碎片, 空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作。
3.3 标记-复制算法
1969年Fenichel提出了一种称为“半区复制”
(Semispace Copying) 的垃圾收集算法, 它将可用 内存按容量划分为大小相等的两块, 每次只使用其中的一块。 当这一块的内存用完了, 就将还存活着 的对象复制到另外一块上面, 然后再把已使用过的内存空间一次清理掉。 如果内存中多数对象都是存活的, 这种算法将会产生大量的内存间复制的开销, 但对于多数对象都是可回收的情况, 算法需要复制的就是占少数的存活对象, 而且每次都是针对整个半区进行内存回收, 分配内存时也就不用考虑有空间碎片的复杂情况, 只要移动堆顶指针, 按顺序分配即可.
但是这种算法也有缺点:
1)需要提前预留一半的内存区域用来存放存活的对象(经过垃圾收集后还存活的对象),这样导致可用的对象区域减小一半,总体的GC更加频繁了
2)如果出现存活对象数量比较多的时候,需要复制较多的对象,成本上升,效率降低
3)如果99%的对象都是存活的(老年代),那么老年代是无法使用这种算法的。
扩展:
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代, IBM公司曾有一项专门研 究对新生代“朝生夕灭”的特点做了更量化的诠释——新生代中的对象有98%熬不过第一轮收集。 因此 并不需要按照1∶1的比例来划分新生代的内存空间。Appel式回收的具体做法是把新生代分为一块较大的Eden空间和两块较小的 Survivor空间, 每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时, 将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空 间。 HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1, 也即每次新生代中可用内存空间为整个新 生代容量的90%(Eden的80%加上一个Survivor的10%) , 只有一个Survivor空间, 即10%的新生代是会 被“浪费”的。
3.4 标记-整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作, 效率将会降低。 更关键的是, 如果不想浪费50%的空间, 就需要有额外的空间进行分配担保, 以应对被使用的内存中所有对象都100%存活的极端情况, 所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征, 1974年Edward Lueders提出了另外一种有针对性的“标记-整 理”(Mark-Compact)
算法, 其中的标记过程仍然与“标记-清除”算法一样, 但后续步骤不是直接对可回收对象进行清理, 而是让所有存活的对象都向内存空间一端移动, 然后直接清理掉边界以外的内存 。
标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法, 而后者是移动式的。 是否移动回收后的存活对象是一项优缺点并存的风险决策:
是否移动对象都存在弊端, 移动则内存回收时会更复杂, 不移动则内存分配时会更复杂。 从垃圾收集的停顿时间来看, 不移动对象停顿时间会更短, 甚至可以不需要停顿, 但是从整个程序的吞吐量来看, 移动对象会更划算
。
4.垃圾收集器
4.1垃圾收集器概述
1.垃圾回收器与垃圾回收算法
垃圾回收算法分类两类,第一类算法判断对象生死算法,如引用计数法、可达性分析算法 ;第二类收集死亡对象方法有四种,如标记-清除算法、标记-复制算法、标记-整理算法。一般的实现采用分代回收算法,根据不同代的特点应用不同的算法。垃圾回收算法是内存回收的方法论。垃圾收集器是算法的落地实现
。和回收算法一样,目前还没有出现完美的收集器,而是要根据具体的应用场景选择最合适的收集器,进行分代收集 .
2.垃圾收集器分类
串行垃圾回收(Serial)
串行垃圾回收是为单线程环境设计且只使用一个线程进行垃圾回收,会暂停所有的用户线程,不适合交互性强的服务器环境
并行垃圾回收(Parallel)
多个垃圾收集器线程并行工作,同样会暂停用户线程,适用于科学计算、大数据后台处理等多交互场景。
并发垃圾回收(CMS)(重要)
用户线程和垃圾回收线程同时执行,不一定是并行的,可能是交替执行,可能一边垃圾回收,一边运行应用线程,不需要停顿用户线程,互联网应用程序中经常使用,适用对响应时间有要求的场景
G1垃圾回收 (重要)
G1垃圾回收器将堆内存分割成不同的区域然后并发地对其进行垃圾回收。
3.七种垃圾收集器及其组合关系
根据分代思想,我们有7种主流的垃圾回收器
新生代垃圾收集器:Serial 、 ParNew 、Parallel Scavenge
老年代垃圾收集器:Serial Old 、 Parallel Old 、CMS
整理收集器:G1
垃圾收集器的组合关系
JDK8中默认使用组合是: Parallel Scavenge GC 、ParallelOld GC
JDK9默认是用G1为垃圾收集器
JDK14 弃用了: Parallel Scavenge GC 、Parallel OldGC
JDK14 移除了 CMS GC
4.GC性能指标
吞吐量:
即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
暂停时间:
执行垃圾回收时,程序的工作线程被暂停的时间
内存占用:java堆所占内存的大小
收集频率:垃圾收集的频次
4.2 Serial收集器
4.3 ParNew 收集器
4.4 Parallel Scavenge收集器
4.5 Serial Old收集器
4.6 Parallel Old收集器
4.7 CMS 收集器
4.7.1 CMS垃圾回收器
CMS(concurrent mark sweep)是以获取最短垃圾收集停顿时间为目标的收集器,CMS收集器的关注点尽可能缩短垃圾收集时用户线程的停顿时间,停顿时间越短就越适合与用户交互的程序,目前很大一部分的java应用几种在互联网的B/S系统服务器上,这类应用尤其注重服务器的响应速度,系统停顿时间最短,给用户带来良好的体验,CMS收集器使用的算法是标记-清除算法实现的;
4.7.2 CMS垃圾收集过程
整个过程分4个步骤:
1)初始标记
2) 并发标记
3) 重新标记
4) 并发清除
其中 初始标记
和 重新标记
都需要stopTheWorld
CMS整个过程比之前的收集器要复杂,整个过程分为4个阶段即、初始标记 并发标记 、重新标记、并发清除
- 初始标记(Initial-Mark)阶段:这个阶段程序所有的工作线程都将会因为"Stop-the-Wold"机制而出现短暂的的暂停,这个阶段的主要任务标记处GC Roots 能够直接关联到第一层的对象.一旦标记完成后就恢复之前被暂停的的所有应用。 由于直接关联对象第一层比较小,所以这里的操作速度非常快。
- 并发标记(Concurrent-Mark)阶段:从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长,但是不需要暂停用户线程, 用户线程可以与垃圾回收器一起运行。
- 重新标记(Remark)阶段():由于并发标记阶段,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,因此,为了修正并发标记期间因为用户继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间通常比初始标记阶段长一些,但也远比并发标记阶段时间短。
(由于并发标记用户线程可以与垃圾回收器一起运行,三色标记解决漏标,多标问题)
- 清除并发(Concurrent-Sweep)阶段: 此阶段清理删除掉标记判断已经死亡的对象,并释放内存空间。由于不需要移动存活对象,所以这个阶段可以与用户线程同时并发运行。
由于最消耗事件的并发标记与并发清除阶段都不需要暂停工作,因为整个回收阶段是低停顿(低延迟)的。
4.7.3 CMS收集器三个缺点
- CMS收集器对CPU资源非常敏感。
其实,面向并发设计的程序都对CPU资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。如果应用本来的处理器负载就很高, 还要分出一半的运算能 力去执行收集器线程, 就可能导致用户程序的执行速度忽然大幅降低。 - CMS收集器无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生。
由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在
标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为"浮动垃圾"
。同样也是由于在垃圾收集阶段用户线程还需要持续运行,那就还需要预留足够内存空间提供给用户线程使用, 因此CMS收集器不能像其他收集器那样等待 到老年代几乎完全被填满了再进行收集, 必须预留一部分空间供并发收集时的程序运作使用.
在JDK 5的默认设置下, CMS收集器当老年代使用了68%的空间后就会被激活, 这是一个偏保守的设置,如果 在实际应用中老年代增长并不是太快, 可以适当调高参数-XX: CMSInitiatingOccu-pancyFraction的值 来提高CMS的触发百分比, 降低内存回收频率, 获取更好的性能。 到了JDK 6时, CMS收集器的启动 阈值就已经默认提升至92%。但这又会更容易面临另一种风险: 要是CMS运行期间预留的内存无法满 足程序分配新对象的需要, 就会出现一次“并发失败”(Concurrent Mode Failure) , 这时候虚拟机将不 得不启动后备预案: 冻结用户线程的执行, 临时启用Serial Old收集器来重新进行老年代的垃圾收集, 但这样停顿时间就很长了。 - 空间碎片:CMS是一款基于标记-清除算法实现的收集器,所有会有空间碎片的现象。
当空间碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次Full GC。