1.垃圾回收需要干什么?
- 哪些内存需要回收?
- 什么时候回收?
- 如何回收?
2. 那些内存需要回收?
2.1 分析
上一篇提到,程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的),因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着回收了。
但是Java堆和方法区这两个区域则有着很显著的不确定性:一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。垃圾收集器所关注的正是这部分内存该如何管理。
2.2 对象什么时候“死亡”?
在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就是要确定这些对象之中哪些还“存活”着,哪些已经“死去”(“死去”即不可能再被任何途径使用的对象)了。
2.2.1 引用计数法
定义:在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的,如下所示。
2.2.2 引用计数法的问题
引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。
但是,在Java领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数就很难解决对象之间相互循环引用的问题。如下代码所示
publicclassTest
{
publicObjectt=null;
publicstaticvoidmain(String[] args)
{
Testa=newTest();//①
Testb=newTest();//②
a.t=b;//③
b.t=a;//④
a=null;//⑤
b=null;//⑥
}
}
分析:
- 当执行完①②后,分别创建了两个Test对象,内存如下所示,两个对象的引用计数均为1。
- 当执行完③④后,a中的成员变量t指向了b,b中的成员变量t指向了a,那么相当于a对象分别被自己和b关联,b对象分别被自己和a关联,引用计数均为2,内存如下
- 当执行完⑤⑥后,a和b都指为空,两个对象相互引用,引用计数为1.
- 这时候其实已经没有了对象的引用,但是其对象的引用计数仍为1,这将导致对象无法“死亡”。
2.2.3 可达性分析
当前主流的商用程序语言(Java、C#,上溯至前面提到的古老的Lisp)的内存管理子系统,都是通过可达性分析(Reachability Analysis)算法来判定对象是否存活的。这个算法的基本思路就是通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的,即“死亡”。
如下图所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GCRoots是不可达的,因此它们将会被判定为可回收的对象。
GC Roots的对象
- 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等。
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
- 在本地方法栈中的Native方法引用的对象。
- Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
2.3 引用的分类
无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,判定对象是否存活都和“引用”离不开关系。
JDK 1.2版之后,Java对引用的概念进行了扩充,将引用分为强引用(Strongly Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)4种,这4种引用强度依次逐渐减弱。(以下引用自《深入理解JVM》)
- 强引用是最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj=new Object()”这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
- 软引用是用来描述一些还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。在JDK 1.2版之后提供了SoftReference类来实现软引用。
- 弱引用也是用来描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK 1.2版之后提供了WeakReference类来实现弱引用。
- 虚引用也称为“幽灵引用”或者“幻影引用”,它是最弱的一种引用关系。一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。在JDK 1.2版之后提供了PhantomReference类来实现虚引用。
3. 分代收集理论
分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在三个分代假说之上:
- 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
- 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。
- 跨代引用假说(Intergenerational Reference Hypothesis):跨代引用相对于同代引用来说仅占极少数。
4. 回收的算法
4.1 标记-清除
首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
如下图所示,蓝色为正在占用的内存,红叉的蓝色区域为要被回收掉的对象,将其标记,然后统一回收,结果如第二张图所示
我们从上图可以发现,在回收后,会产生很多不连续的内存空间,这样的产生的空间碎片如果太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存,而不得不提前触发另一次垃圾收集动作。
其次,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。
4.2 标记-复制
标记-复制算法常被简称为复制算法。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
如下所示,在内存中有两块一样大小的地方,要删除左半区的2 4 5,首先将要回收的标记,接着将没别标记的按照顺序复制到右半区,接着将左半区所有内存回收。
但是,如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,并且这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费太多。
现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,这是由于新生代中的对象有大多熬不过第一轮收集,因此并不需要按照1∶1的比例来划分新生代的内存空间。
标记-复制优化(Appel式回收)
Appel式回收的具体做法是把新生代分为一块较大的Eden(伊甸区)空间和两块较小的Survivor(幸存区)空间(大概比例为8:1:1),每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
如下所示,蓝色区域为存活对象,灰色区域为可回收对象,大多数新生代Eden区对象经过第一轮GC后都会被回收,将剩下的通过复制算法到Survivor1区,再将Survivor1区的对象经历一轮GC,将存活对象复制到Survivor2区,由此对象越来越少,根据强分代假说,熬过越多次垃圾收集过程的对象就越难以消亡,将其放入老年代区域(老年代区域GC频率较少)。
4.3 标记-整理
标记整理与标记清除算法类似,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。如下所示
5. 垃圾收集器名词
- 部分收集(Partial GC):指目标不是完整收集整个Java堆的垃圾收集,其中又分为:
- 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
- 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。
- 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。
- 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集。
6. 常见垃圾收集器
6.1 Serial收集器
只看名字就能够猜到,这个收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束(这期间被称为STW)。
STW:Stop The World,如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行。
举个例子:当你工作时,这时候清洁工人来打扫你办公桌的卫生,这时候你只能停下手中的工作,离开座位在旁边等待,清扫结束后才能继续工作。
6.2 ParNew收集器
ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之外,其余的行为几乎都相同。
6.3 Serial Old收集器
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
6.4 Parallel Old收集器
Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。
6.5 CMS 收集器
CMS(Concurrent Mark Sweep,并发低停顿收集器)收集器是一种以获取最短回收停顿时间为目标的收集器,也是一种 “标记-清除”算法实现。其垃圾收集分为四个过程:
- 初始标记(CMS initial mark)(STW)
初始标记仅仅只是标记一下GC Roots能直接关联到的对象(由于能和GC Roots关联的对象相对较少,所以速度极快)。 - 并发标记(CMS concurrent mark)
从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。 - 重新标记(CMS remark)(STW)
由于并发标记时,可能产生一些错误标记(需要回收的被刚执行的用户线程使用,所以不能回收),这个期间就是修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录 - 并发清除(CMS concurrent sweep)
清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。