文章目录
- 概要
- 对象存活判断
- 引用计数算法
- 可达性分析算法
- 对象是否存活
- 各种引用
- 垃圾收集算法
- 分代收集理论
- 复制算法
- 标记清除算法
- 标记-整理算法
概要
垃圾收集(Garbage Collection, 下文简称GC),其优缺点如下:
优点:
- 不需要考虑内存管理,
- 可以有效的防止内存泄漏,有效的利用可使用的内存,
缺点:
- java开发人员不了解自动内存管理, 内存管理就像一个黑匣子,过度依赖就会降低我们解决内存溢出/内存泄漏等问题的能力。
对象存活判断
引用计数算法
引用计数算法可以这样实现:给每个创建的对象添加一个引用计数器,每当此对象被某个地方引用时,计数值+1,引用失效时-1,所以当计数值为0时表示对象已经不能被使用。引用计数算法大多数情况下是个比较不错的算法,简单直接,也有一些著名的应用案例但是对于Java虚拟机来说,并不是一个好的选择,因为它很难解决对象直接相互循环引用的问题。
优点:
实现简单,执行效率高,很好的和程序交织。
缺点:
无法检测出循环引用。
可达性分析算法
在主流的商用程序语言如Java、C#等的主流实现中,都是通过可达性分析(Reachability Analysis)来判断对象是否存活的。此算法的基本思路就是通过一系列的“GC Roots”的对象作为起始点,从起始点开始向下搜索到对象的路径。搜索所经过的路径称为引用链(Reference Chain),当一个对象到任何GC Roots都没有引用链时,则表明对象“不可达”,即该对象是不可用的。
在Java语言中,可作为GC Roots的对象包括下面几种:
- 栈帧中的局部变量表中的reference引用所引用的对象
- 方法区中static静态引用的对象
- 方法区中final常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
- Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如 NullPointExcepiton、
OutOfMemoryError) 等, 还有系统类加载器。 - 所有被同步锁(synchronized关键字) 持有的对象
- 反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、 本地代码缓存等
对象是否存活
finalize()方法最终判定对象是否存活:
即使在可达性分析算法中判定为不可达的对象, 也不是“非死不可”的, 这时候它们暂时还处于“缓刑”阶段, 要真
正宣告一个对象死亡, 至少要经历两次标记过程:
第一次标记:
如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链, 那它将会被第一次标记, 随后进行一次筛选, 筛选的条件是此对象是否有必要执行finalize()
方法。
没有必要:
假如对象没有覆盖finalize()方法, 或者finalize()方法已经被虚拟机调用过, 那么虚拟机将这两种情况都视为“没有必要执行”。
有必要:
如果这个对象被判定为确有必要执行finalize()方法, 那么该对象将会被放置在一个名为F-Queue的 队列之中, 并在
稍后由一条由虚拟机自动建立的、 低调度优先级的Finalizer线程去执行它们的finalize()
方法。 finalize()方法是对 象
逃脱死亡命运的最后一次机会, 稍后收集器将对F-Queue中的对象进行第二次小规模的标记, 如果对 象要在
finalize()
中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可, 譬如把自己 (this关键字) 赋值
给某个类变量或者对象的成员变量, 那在第二次标记时它将被移出“即将回收”的集 合; 如果对象这时候还没有逃
脱, 那基本上它就真的要被回收了。
Finalizer线程去执行它们的finalize()
方法, 这里所说的“执行”是指虚拟机会触发这个方法开始运行, 但并不承诺一定会等待它运行结束。 这样做的原因是, 如果某个对象的finalize()方法执行缓慢, 或者更极端地发生了死循环, 将很可能导 致F-Queue队列中的其他对象永久处于等待, 甚至导致整个内存回收子系统的崩溃。
各种引用
在JDK1.2之后,Java对引用的概念做了扩充,将引用分为 强引用(Strong Reference) 、 软引用(Soft Reference) 、 弱引用(Weak Reference) 和 虚引用(Phantom Reference) 四种,这四种引用的强度依次递减。
-
强引用:是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟
机宁愿抛出OutOfMemoryError
错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问
题。 ps:强引用其实也就是我们平时A a = new A()
这个意思。 -
软引用: 如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。 软引用可以和一个引用队列
(ReferenceQueue)联合使用,如果软引用所引用的对象被垃圾回收器回收,Java虚拟机就会把这个软引用加入到与之关联的引用队列中。 -
弱引用:用来描述那些非必须对象。 当垃圾收集器开始工作, 无论当前内存是否足够, 都会回收掉只被弱引用关联的对象。 在JDK 1.2版之后提供了WeakReference类来实现弱引用。 弱引用可以和一个引用队列(ReferenceQueue)联合使用,如果弱引用所引用的对象被垃圾回收,Java虚拟机就会把这个弱引用加入到与之关联的引用队列中.
-
虚引用:一个对象仅持有虚引用,在任何时候都可能被垃圾回收器回收。虚引用主要用来跟踪对象被垃圾回收器回收的活动
虚引用与软引用和弱引用的一个区别在于:
1、引用必须和引用队列 (ReferenceQueue)联合使用。
2、当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到
与之 关联的引用队列中。
垃圾收集算法
分代收集理论
当前商业虚拟机的垃圾回收器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的:
- 绝大部分的对象都是朝生夕死。
- 熬过多次垃圾回收的对象就越难回收
- 跨代引用相对于同代引用来说仅仅占少数
根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代,并且不同的分代采用的回收算法不一样。
对于第3个理论,这里补充说明以下。
分代收集也并不是简单划分一下内存区域这么简单,因为对象不是孤立的,对象之间存在跨代引用,譬如:现在要在新生代进行回收,但新生代的对象极有可能被老年代对象所引用,那为了找到这些可能存活的对象,不得不在既定的 GC Roots 之外,再遍历整个老年代对象确保可达性分析结果的正确性。反过来回收老年代也是一样。
但是这样无疑带来了性能负担,为了解决这个问题,才有了第3条理论;那如何解决跨代引用呢?
正是因为只占少数,所以不应该为了为了这些少量的跨代引用而区扫描整个老年代,也不能浪费空间让每个对象都记录它是否存在跨代引用,所以为了解决这个问题只需要在新生代建立一个全局的数据结构叫做:记忆集( Remembered Set ),这个结构把老年代划分成若干小块,并标识哪块内存存在跨代引用,后续新生代发生 gc 时,只有包含了跨代引用的小内存区域才会被加入到 GC Roots 进行扫描;当然这种方法需要在对象改变引用关系的时候维护记忆集中数据的正确性。这种做法相比垃圾收集时扫描整个老年代来说仍然时划算的。
同时对于GC的叫法,大体有这么几种:
1、新生代回收( Minor GC/Young GC ):指的是进行新生代的回收。
2、老年代回收( Major GC/Old GC ):指的是进行老年代的回收。目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为。( Major GC 定义相对没有那么明确,有说指是老年代,有的说是做整个堆的收集,没有固定的说法,有时候 Major GC 和 Full GC 大致是等价的)
3、整堆回收( Full GC ):定义相对明确,收集整个 Java 堆和方法区(注意包含方法区)
复制算法
原始的复制算法(Copying)是这样的:
- 将内存按容量划分为大小相等的两块,每次只使用其中的一块。
- 当其中一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
优点:
- 实现简单,运行高效,
- 每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可
缺点:
- 内存的使用率缩小为原来的一半。
- 内存移动是必须实打实的移动(复制),所以对应的引用(直接指针)需要调整。
适用场景:
复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。
像 hotspot 这样的虚拟机大都对原生的复制算法进行了改进,因为它对内存空间的利用率不高,而且专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1:1 的比例来划分内存空间,所以改进后的复制算法策略是:
1、将新生代划分为一块较大的 Eden 区和两块较小的 Survivor 空间(你可以叫做 From 或者 To ) , HotSpot 虚拟机默认 Eden 和 Survivor 的大小比例是 8:1 。
2、每次使用 Eden 和其中一块 Survivor ,当回收时,将 Eden 和Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上,最后清理掉 Eden 和刚才用过的 Survivor 空间。
在这样的算法下,
1、每次新生代中可用内存空间为整个新生代容量的 90%(80%+10%),只有 10%的内存会被 “浪费”
2、当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10%的对象存活,当 Survivor 空间不够用时,需要依赖其他内存(老年代)进行分配担保( Handle Promotion )。
标记清除算法
标记-清除(Mark-Sweep)算法分为“标记”和“清除”两个阶段:
- 首先扫描所有对象标记出需要回收的对象
- 在标记完成后扫描并回收所有被标记的对象,故需要两次扫描
注意:
1、回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低,所以该算法不适合新生代。
2、它的主要问题是在标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾回收动作。
3、标记清除算法适用于老年代。
标记-整理算法
标记-整理(Mark-Compact)算法逻辑如下:
1、首先标记出所有需要回收的对象,
2、在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,
3、然后直接清理掉端边界以外的内存。
注意:
1、标记整理需要扫描两遍
2、标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新(直接指针需要调整)。
3、标记整理算法不会产生内存碎片,但是效率偏低。
4、标记整理算法适用于老年代。
所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。