Java支持内存动态分配、垃圾自动回收,而 C++ 不支持。我想这可能也是 为何 Java 脱胎于 C++ 的一个缘由吧。java
GC 的历史比 Java 更久远,好比 1960 年诞生的于 MIT 的 Lisp
就是第一门真正使用内存动态分配和垃圾回收的语言。程序员
咱们从这三个问题出发,来更深一层地看看 JVM GC 为咱们作了哪些工做。算法
咱们都知道,JVM 栈和堆所使用的计算机内存都是由 JVM 统一管理的,只不过栈中元素的内存分配和回收是由 JVM 全权管理,而堆中对象建立时的内存分配则由咱们 Java 程序员来控制,内存回收则由 JVM GC 负责。eclipse
JVM Stack 栈中元素的生命周期随着方法栈的结束或者线程栈的结束而结束,并且每个栈中元素须要分配多少的内存空间在 Java 代码编译成 class 字节码文件的时候就已经肯定了,所以 JVM 对于栈的内存管理相对于堆来讲,是要简单一些的。在这里关于 JVM 栈的知识点就不作细致挖掘,之后再分享。本文只针对回收堆内存中的对象。工具
通俗点讲,JVM 堆内存中的全部对象都是 JVM GC 回收的目标对象,但只有已经肯定死掉的对象才会被 GC 回收,否则 JVM 中就乱套了,想一想看:一个对象正在愉快地搬砖,忽然被一只看不见的手给杀掉了,连尸体都没留下,它的亲人们就会很着急啊,对象失踪了,究竟是死是活,活要见人死要见尸啊。性能
Java 世界是一个法制社会,作任何事情要有理有据,那么就引出了一个问题:优化
只有被打上了“死亡”标记的对象,才会被 GC 回收掉。那么咱们能够将 “哪些对象会被 GC 回收?” 的问题稍微转换一下角度:“如何判断一个对象已经死亡,并给它打上'死亡'标记?”.net
先来介绍两种给对象“判死刑”的算法:线程
原理:在建立对象时,给每一个对象都添加一个“引用计数器”,每当有一个地方引用它时,计数器的值就加 1;反之,当一个指向该对象的引用失效时,计数器值就减 1。在任什么时候刻,计数器值为 0 时,就表示这个对象已经不可用了,或者说已经死掉了。指针
引用计数算法的原理实现起来很简单,并且对死亡对象的断定效率也很高,在大部分状况下,这都是一个很不错的断定算法。有一些很著名的应用案例:微软公司的COM(Component Object Model)技术、Python 语言都使用了引用计数算法。
可是!在主流的 JVM 中却没有选择 引用计数算法 来管理内存,其中最主要的缘由:它很难解决对象之间相互循环引用的问题。
举个简单的栗子:
public class Test { public Object obj = null; public static void main(String []args){ // 建立并初始化两个 Test 对象 Test a = new Test(); Test b = new Test(); // 让两个对象 相互循环引用 a.obj = b; b.obj = a; // 接下来是关键点:让两个对象的引用失效 a = null; b = null; // 假设执行了 GC System.GC(); } }
问题来了,执行了 System.GC();
以后,a 和 b 两个对象会被回收吗?
咱们能够经过配置 eclipse.ini 开启 Eclipse 工具打印 GC 日志的功能,而后对比执行 GC 以前的堆空间大小与执行 GC 以后的堆空间大小,就能获得答案:没有被回收。所以能够肯定 JVM 没有使用引用计数算法做为对象是否存活的断定算法。
在主流的商用程序语言(Java、C#,还有前面提到的古老的Lisp)主要都是经过可达性分析(Reachability Analysis)来断定对象是否存活的。
算法基本思路:经过一系列称为 “GC Roots” 的对象做为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用数学图论的话来讲,就是从 GC Roots到这个对象不可达)时,则证实此对象是不可用的。
GC Roots 的图例:
以上图例中,object 五、object 六、object 7 三个对象虽然有关联,可是它们到 GC Roots 是不可达的,因此它们将会被断定为可回收的对象。
在 Java 中,能够做为 GC Roots 的对象包括下面几种:
对可达性分析算法简单总结一下:只要 Java 堆中的对象与最后一个 GC Roots 断开了链接,这个对象就成为了 GC 的回收目标。
由于 Java 堆中对象的建立是由咱们 Java 程序员控制的,所以:
建立时机不肯定,到底须要多少的内存空间也不肯定,那么 JVM 也就不知道该在什么时候为建立对象准备足够的资源空间,为了不 当须要为建立对象分配内存空间时,却已经没有可用的内存空间
这种尴尬的状况发生,JVM GC 就须要适时地在暗地里操控 JVM 堆内存,回收被那些已经死掉的对象占用的内存空间。
那么这个“适时”究竟是何时呢?你们都知道 GC 执行的时间不肯定性,但这不表明 GC 就是在随性而为,下面咱们来对这个 GC 时机 进行讲解:
Java 中,通过可达性分析算法断定后,成为 GC 回收目标的对象,并非被判了死刑要当即执行,而是死缓。
要真正宣告一个对象死亡,至少要经历两次标记过程:
若是对象在通过可达性分析以后发现没有与 GC Roots 相链接的引用链,那它将会被 GC 打上第一次标记而且执行一次筛选断定,筛选的条件是该对象是否有必要执行 finalize() 方法。
当对象没有覆写 finalize() 方法,或者 finalize() 方法刚刚被 JVM 调用过了,那么 JVM 就会认为没有必要执行 finalize() 方法,也就失去了重生的机会。
若是这个对象被断定为有必要执行 finalize() 方法,那么这个对象就会被放置在一个叫 F-Queue 的队列中,并在稍后由一个低优先级的 Finalizer 线程去执行 finalize() 方法,只要在 finalize() 方法的执行过程当中,将对象与引用链上的任何一个对象创建关联,那么在 GC 第二次标记时就会将该对象移除“回收名单”,若第二次标记时仍然没有可达链接,就将这个对象完全回收。
一个对象的 finalize() 方法只会被系统自动调用一次,也就是说,这个复活技能只能使用一次。
关于 finalize() 方法,不建议使用,不肯定性太大,没法保证各个对象的调用顺序,finalize() 能作的,try-finally 能作得更好、并且更及时。
咱们换个通俗点的说法总结一下:第一次打标记就是给这个对象发法院的一审判决通知,这个对象要么上诉,上诉了还有胜诉的但愿,要么什么都不作等待执刑,第二次打标记就是对那些没有胜诉的对象斩立决。
前面提到的两种算法,不管是经过引用计数算法判断对象的引用数量,仍是经过可达性分析算法判断对象的引用链是否可达,判断对象是否存活的关键,都与“引用”有关。在这里对“引用”这个概念作一下扩展。
在 JDK 1.2 之前,Java 中的引用的定义很传统:
若是 reference 类型的数据中存储的数值表明的是另一块内存的起始地址,就称这块内存表明着一个引用。
这也是咱们做为 Java 初学者时常认为的引用概念,在这种定义下,一个对象只有被引用或者没有被引用两种状态,在 JDK 1.2 以后,Java 对引用的概念进行了扩充,将引用分为:强引用、软引用、弱引用、虚引用,这 4 种引用的强度依次递减。
- 强引用(Strong Reference):指在程序代码中广泛存在的,相似 “
Object obj = new Object();
” 这种建立的对象引用。只要强引用还存在,JVM GC 就不会回收掉被引用的对象。当内存空间不足时,JVM 宁愿抛出 OutOfMemoryError 错误使程序异常终止,也不会靠随意回收具备强引用的对象来解决内存不足的问题。- 软引用(Soft Reference):用来描述一些还有用但并非必需的对象。对于软引用关联着的对象,在系统将要发生内存溢出异常以前,将会把这些对象列入回收范围之中,但不会当即回收,此时这些对象仍然能够被程序使用,只有当内存空间确实不足时,才会对回收范围内的对象进行回收处理。
- 弱引用(Weak Reference):也是用来描述非必需对象的,但强度比软引用要更弱,生命周期更短暂,在 GC 线程扫描它所管辖的内存区域的过程当中,一旦发现了只具备弱引用的对象,无论当前内存空间是否足够,都会回收它。
- 虚引用(Phantom Reference):虚引用并不会决定对象的生命周期,若是一个对象仅持有虚引用,那么它就和没有任何引用同样,在任什么时候候均可能被 GC 回收,固然咱们也不能经过虚引用获得一个对象实例。为一个对象加上虚引用的惟一目的就是能在这个对象被 GC 回收时,能够收到一个系统通知。
由于篇幅问题,本文仅对实现垃圾回收的算法进行分析,不作过多实现细节上的描述。
垃圾回收目前经常使用的有 3 种算法思路:
标记 - 清除算法是最基础的回收算法。该算法分为 “标记” 和 “清除” 两个阶段:
之因此说它是最基础的算法,由于后续的回收算法都是基于这种思路,而且对其不足进行改进而来。
标记 - 清除算法有两个明显的不足:
咱们来看看使用“标记 - 清除”算法执行先后的内存变化:
为了解决“标记 - 清除算法”的效率问题,诞生了“复制算法”。
复制算法的思路:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另外一块上,而后再把已使用过的内存空间一次性清理掉。
这样使得每次 GC 都是在对整个堆内存的一半区域进行操做,进行内存分配时也就不用考虑内存碎片等复杂问题,每次分配只须要移动堆顶的指针,按顺序分配内存便可,实现简单,运行高效。
缺点也很明显:将可用内存缩小为原来的一半,代价太大了。
可是目前主流的虚拟机都是采用复制算法来进行垃圾回收的。为何你们还要用缺点这么明显的算法呢?这牵扯到另外一个问题:JVM 堆内存分代。
在这里咱们简单描述下堆内存分代
的概念:
JVM 堆内存并非一锅乱炖的大杂烩,而是将堆内存进行了分代(新生代、老年代),分代的目的也就是为了优化 GC 的性能,就比如硬盘要分区,要建文件夹管理文件同样,方便寻找和管理资源。
HotSpot 版本的 JVM 将新生代内存区域分为了三个部分: 1 块较大的 Eden 区和 2 块较小的 Survivor 区(分别名叫 from 和 to)。HotSpot JVM 中默认 Eden 和 Survivor 空间大小的比例是 8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的 90%( 80% +10% ),要说浪费,也只有其中的 10% 被浪费了。
新生代中实际可用的内存区域只有: Eden 和 其中的一块 Survivor(第一次是 from,from 满后转移到 to)。
通常状况下,新建立的对象都会被分配到 Eden 区(先放到 Eden 区是由于有些对象比较大,但不必定是常驻对象),Eden 中的对象通过第一次 Minor GC 后,若是仍然存活,将会被移到 Survivor 区。对象在 Survivor 区中每熬过一次 Minor GC,年龄就会增长 1 岁,当它的年龄增长到必定程度时,就会被移动到老年代中。
另外作一个关于 新生代 和 老年代 的扩展:
新生代 和 老年代:
- 新生代:刚建立、存活时间较短的对象,通常都存放在新生代堆区
- 老年代:在新生代中存活超过了必定年龄的对象,就会被转移到老年代堆区
新生代 GC 和 老年代 GC:
- 新生代 GC(Minor GC):指发生在新生代的垃圾回收动做。由于 java 对象大多都具有“朝生夕灭”的特性,因此 Mirnor GC 很是频繁,回收速度也比较快。
- 老年代 GC(Major GC / Full GC):指发生在老年代的垃圾回收动做。Major GC 的速度通常会比 Minor GC 慢 10 倍以上。
使用复制算法,在对象存活率比较高时,要复制的内容就多了,相应的操做效率就会下降。另外,若是内存空间总体的使用率要求超过一半,好比内存中 100% 的对象都是存活状态的极端状况,用复制算法就不可靠了,特别是在 老年代
中,不能使用复制算法,这就催生而出另外一种符合 老年代
特色的算法:标记 - 整理算法。
该算法的思路:标记的过程与 “标记 - 清除”算法是一致的,区别在于后续步骤不是直接对可回收对象进行清理,而是让全部存活的对象都向内存空间的一端移动,而后直接清理掉内存另外一端边界之外的内存,用图来讲话:
分代收集算法是目前商业虚拟机的垃圾回收主要采用的算法。
其实分代收集算法并无什么特别的新思想,只是根据对象存活周期的不一样,将内存划分为新生代和老年代,而后根据不一样的年代内存区域,采用符合各自特色的回收算法。好比:在新生代中,由于每次 GC 都会发现大量的死对象,只有少许存活,选用复制算法回收效率更高;而在老年代中的对象存活率高、也没有额外的空间为其冗余,就必须采用 “标记 - 清除” 或 “标记 - 整理”算法进行回收。
至此,关于 Java 虚拟机垃圾回收的知识点分享就到这里,谢谢。
参考资料:《深刻理解Java虚拟机:JVM高级特性与最佳实践》 - 周志明