以前写c/c++的时候虽然也有shared_ptr这样的自动内存管理机制,但是它内部其实是通过引用计数的原理进行内存管理的,容易产生循环应用的问题,也没有什么实际意义上的内存收集器。和java的内存收集机制差别很大,所以一直对java的内存收集机制抱有很强的好奇心。
最近在看《深入理解 Java 虚拟机-Jvm 高级特性与最佳实践》,里面对java垃圾收集讲的挺不错的。然后再将书中没有讲透的知识在网上搜索了下,整理成了这篇博客,哪天一时半会记不起来的时候还能回来瞧一瞧。
GC Roots
在Java虚拟机中,存在自动内存管理和垃圾回收机制,能自动回收没有用的对象的内存。
那怎么去判定一个对象是否还有用呢?java中是通过可达性分析来判定的。
具体的做法就是从一系列被称作"GC Roots"的对象作为起始点,从这些对象开始通过引用关系进行搜索。当GC Roots到某个对象没有任何引用链相连,则证明此对象是不可用的,是不需要存活,可以被清理的。
例如下图的object1、object2、object3、object4就是从GC Roots可达的,不能被回收。而object5、object6、object7虽然相互引用,但从GC Roots不可达,证明程序中无法访问到它们,所以它们是无用的,可以被回收。
在Java中,可以作为GC Roots的对象包括下面几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象
- 方法区中类静态属性引用的对象
- 方法去中常量引用的对象
- 本地方法栈中JNI引用的对象
Java 堆中的内存分配与回收
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配。
由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为新生代和老年代。
新生代中的Minor GC
大部分对象被创建时,内存的分配首先发生在年轻代(占用内存比较大的对象如数组,会被直接分配到老年带)。大部分的对象在创建之后很快就不再使用了,因此很快变成不可达的状态,于是被新生代的GC机制清理掉。这个GC机制被称为Minor GC或叫Young GC。
新生代可以分为3个区域:一个Eden区和两个Survivor区。两个Survivor中总有一个是空的,我们叫他Survivor To区,还有一个非空的,我们叫他Survivor From区。Eden区和两个Survivor区的大小为8:1:1。
对象被创建的时候,绝大部分都是被分配在Eden区。Eden区是连续的内存空间,因此在其上分配内存极快。当Eden区满的时候,就会执行Minor GC。
复制算法
Minor GC时Eden区和Survivor From区还存活着的对象会一次性被复制到Survivor To区。然后Eden区和Survivor From区的内存会被清空。
之后原来的Survivor From区就空了,而原来的Survivor To区就非空。这个时候它们的角色就调换了,空的叫做Survivor To区,非空的叫做Survivor From区。
这种垃圾收集算法叫做复制算法,整个过程如下图所示:
为什么需要两个Survivor区
复制算法的优点在于,gc完成之后占用的内存会被整理到一个连续的空间中。而空闲的内存也是连续的区域,不会造成内存碎片。
如果只有一个Eden区和一个Survivor区,在Minor GC的时候,Eden区的存活对象可以被复制到Survivor区,这样Eden区可以被清空出一个完整的空闲内存区域。
而Survivor区存活的对象要怎么办呢:
如果直接进入老年代。可能有些对象经过少数的几次GC就能被释放。但在老年代中GC发生的频率比新生代低很多。这样的话就会导致老年代的内存很快被占满。
如果不管存活的对象,只是简单的清除不可达的对象。那么Survivor区就会产生内存碎片
如果进行压缩整理,与从新生代复制过来的存活对象一起整理到Survivor中某个连续的区域的话,消耗的计算资源会比较高。
所以最简单的做法就是拿多一个Survivor To区出来,Eden区和Survivor From区存活的对象会被连续的复制到Survivor To区的一个连续区域中。然后将Eden区和Survivor From区清空就好。
由于新生代大部分的对象生命周期都很短,所以需要复制的对象的数目也不会很多,所以这是比较高效的做法。
对象进入老年代
对象在下面三种情况下,对象进入到老年代:
占用内存比较大的对象如数组,在创建的时候不会分配到Eden区,而会被直接分配到老年代
当Minor GC时Survivor To区已经放不下还存活的对象的时候,这些对象就会被放到老年代中。
每经历一次Minor GC,对象的年龄会大一岁。当对象的年龄到达某一个值,Minor GC的时候就不会去到Survivor To区,而会进入老年代。
老年代
在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。
在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,所以选用复制算法,只需要付出少量对象的复制成本就能完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就需要使用"标记-清理"或者"标记-整理"算法来进行回收。
标记-清理算法
标记-清除算法顾名思义,主要就是两个动作,一个是标记,另一个就是清除。首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。
它的缺点有两个
- 标记与清除的效率都比较低
- 标记清除之后会产生大量不连续的内存碎片
它的执行过程如下图所示:
发生在老年代的GC叫做Major GC,通常当Minor GC晋升到老年代的大小大于老年代的剩余空间时,Major GC就会被触发。
出现了Major GC,通常会伴随着至少一次的Minor GC(不是绝对的,有些收集器有直接进行Major GC的策略)。Major GC的速度一般会比Minor GC慢10倍以上。
除了Minor GC和Major GC之外,还有一个Full GC的概念,它们的区别如下:
- Minor GC : 回收新生代的垃圾
- Major GC : 回收老年代的垃圾
- Full GC : 回收整个堆的垃圾,包括新生代、老年代、持久代等
标记-整理算法
标记过程仍和标记-清理算法一样,但是后续的步骤不是直接对可回收的对象进行清理,而是让所有存活的对象往一端移动,然后再清理掉边界以外的内存。它的过程如下图所示:
垃圾搜集器
如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,所以不同的厂商、不同版本的虚拟机提供的垃圾收集器都可能有很大差别。
下面这张图列出了JDK1.7 Update 14之后HotSpot虚拟机所包含的垃圾收集器:
每种收集器具体的实现方法这里我就不罗列了,感兴趣的同学可以自行搜索。
finalize方法
即使在可达性分析中不可达的对象,也并非是"非死不可"的。这时它们只是处于"缓刑"阶段,要宣布一个对象死亡,至少要经历两次标记过程:
第一次可达性分析之后,不可达的对象会被标记出来放到一个"即将回收"的集合中。
被标记的对象会进行一次筛选,覆盖了finalize方法并且finalize方法没有被调用过的对象会放到一个叫做F-Queue的队列中。虚拟机会创建一个低优先级的Finalizer线程去执行它。如果一个对象想逃脱死亡的命运,只需要在finalize方法中将自己重新连接上引用链就可以了,例如将自己赋给某个类变量或对象的成员变量。
第二次可达性分析会将被重新连接上引用链的对象移出"即将回收"的集合。
最后将不可达的对象内存回收
这里有两点需要注意的是:
"执行"finalize方法指的是虚拟机会触发这个方法,但不承诺等待它运行结束,这样做的原因是防止某个对象的finalize方法运行缓慢或者发生死循环,导致F-Queue的队列其他对象永久等待甚至导致内存回收系统崩溃。
finalize只有一次被调用的机会。如果在finalize中将对象重新连接上引用链,只有在对象在第一次即将被回收的时候,finalize方法会被调用。在下一次GC的标记过程中,因为finalize方法已经被调用过了,所以就不会被放到F-Queue的队列中。