GC垃圾回收机制
我是目录
本文本为笔记内容,抄自LeetCode微信公众号文章
在C/C++程序中,开发者需要自己手动管理程序的内存。也就是说某个对象不再被使用的时候,我们需要手动将其设置为NULL。老生常谈了,这虽然更自由,但也更繁琐,如果处理不得当,还可能会出现以下两种问题
- 某个对象释放内存的时候,多释放了一次,如果有一个其他对象刚刚申请到这块儿内存,突然被这个对象释放的内存删除了,就会引起一些奇怪的bug,并且这种bug还很难查找
- 某个对象使用过后忘记释放内存,导致内存泄漏(这个可能是黑客攻击的点)
所以内存管理一直是c/c++开发者比较头疼的问题。但是在Java中就不会出现这种情况,这得益于Java中优秀的GC(Garbage Collector)机制,GC会帮助我们自动回收不需要的对象
本文就来学习一下Java的GC算法
什么是垃圾?
在Java中,每new一个对象,就会在栈或者堆中分配一块儿内存,比如这一行代码:
Object o = new Object();
变量o保存了这个对象的内存地址,我们称之为o持有这个 new Object()的引用,当o被置为null的时候
o = null;
在堆或栈中,为这个new Object()分配的内存不再被任何变量引用,这块儿内存现在就孤苦伶仃,没人知道它的存在,没人能在访问到它,它就成了一个垃圾。
垃圾:程序中的一块儿内存没有被任何变量持有引用,导致这块儿内存无法被这个程序再次访问时,这块儿内存就称之为垃圾。
怎么找到垃圾?
1. 引用计数法
上文说到,没有任何引用指向的对象称之为垃圾。所以我们可以想到一种算法:在某个对象被引用指向时,将其引用数量计数。每多一个引用指向这个对象,计数+1,每少一个引用指向这个对象,计数-1,当计数为0的时候,表示这个对象成为了一个垃圾,将其回收掉,Python语言的GC机制就是采用的此算法,它被称之为引用技术法。
但是引用计数法无法解决一个问题:循环引用。例如:
public class client{
public static void main(String[] args){
Test a = new Test();
Test b = new Test();
a.o = b;
b.o = a;
}
class Test{
Object o;
}
}
在这种情况下,a引用了b,b又引用了a,如果使用引用计数法,他们的计数都为1,当main执行完毕后,a和b都不再被使用,但是由于他们的引用不为0,所以他们将无法被GC回收掉,如果使用这种引用计数法,必须小心这种循环引用带来的问题,所以Java并没有采用这种引用计数法类进行内存回收
2.可达性分析算法(Root Searching)
可达性分析算法又被称为根搜索法,GC定义了一些根(roots),从根开始不断搜索,能够被引用到的对象就不是垃圾,不能被引用的对象就是垃圾
可达性分析算法解决了循环引用的问题,即使有两个或多个对象之间循环引用,只要根访问不到它们,他们就是一对垃圾,或一堆垃圾。
GC roots包括:虚拟机栈(局部变量表)中引用的对象,本地方法栈中JNI引用的对象,方法区中静态引用的对象,存活的线程对象等等。
怎么清理垃圾
垃圾回收算法一共有三种
- 标志清除(Mark-Sweep)
- 拷贝(Coping)
- 标记压缩(Mark-Compact)
标志清除(Mark-Sweep)
标记清除算法的思想是:先扫描一遍内存中所有对象,将找到的垃圾做一个标记,回收时,再扫描一遍所有对象,将带有标记的垃圾清除。
优点:
- 算法简单,容易理解
- 在垃圾较少时,效率较高
缺点:
- 需要扫描两次
- 容易产生内存碎片,可能导致最后无法找到一块儿连续的内存存放大对象(这个和操作系统中内存调度有点类似,具体是什么想不到了,回头要复习复习)
拷贝(Coping)
拷贝的思想是:将内存空间一分为二,只在一半的空间上分配对象,GC时,正在使用的一半内存中,存活的对象拷贝到另一半中,然后将正在使用的这一半内存整个回收掉
优点:
- 只扫描一次,效率很高,特别是在垃圾较多的情况下。
- 不会产生内存碎片
缺点:
- 浪费空间,可用内存减少
- 移动时,需要复制对象,必须调整对象的引用
标记压缩(Mark-Compact)
标记压缩算法的思想是:先扫描一遍内存中所有的对象,将垃圾做一个标记,回收时,先清除垃圾,然后将存活的对象移动到被回收的位置。
优点:
- 不会有内存碎片
- 不会使内存减少
缺点:
- 需要扫描两次
- 移动时,需要复制对象,并调整对象的引用
内存分代模型:
分代模型并不是一种垃圾回收算法,而是一种内存管理模型,它将Java中的内存分为不同的区域,在GC时,不同的区域采取不同的算法,可以提高回收效率。
内存分代模型将内存中的区域分为两部分:新生代(new/young)和老年代(old/tenuring)。两块儿区域的比例默认为1:2,我们也可以自己设置这个比例(通过 -Xms初始化堆的大小,通过 -Xmx设置堆最大分配的内存大小,通过 -Xn设置新生代的内存大小)。
顾名思义,对象存活的时间较短,则属于新生代,存活时间较长,则属于老年代。那么如何去衡量对象存活的时间呢?JVM的做法是:每经过一次GC,没被回收的对象年龄+1,大约15岁之后,新生代的对象到达老年代。
新生代中,被分为一个伊甸区(eden),两个存活区(survivor)。当对象刚被new出来,通常分配在伊甸区,伊甸区的对象大多数生命周期较短,据不完全统计,每GC一次,伊甸区存活的对象只占5%~10%,由于存活的对象较少,所以在伊甸区的GC采用的是拷贝算法,但这里的拷贝算法并不是将内存一分为二,因为伊甸区存活的对象数量较少,所以存活区只需要占用很小的内存,(伊甸区和存活区的默认比例为8:1:1,通过 -XX:SurvivorRatio可以自定义此比例)
新生代的GC被称为YGC(Young Garbage Collector年轻代垃圾回收)或者MinorGC(Minor Garbage Collector,次要垃圾回收),整个回收过程类似这样:
- 对象在伊甸区被创建出来
- 伊甸区经过一次GC之后,存活的对象到达存活1区,清空伊甸区
- 伊甸区和存活1区的对象经历第二次GC,存活的对象到达存活2区,清空伊甸区和存活1区
- 伊甸区和存活2区尽力第三次回收,存活的对象到达存活1区,清空伊甸区和存活2区
- 循环往复
- 每经过一次GC,每被回收掉的对象年龄+1.当存活的对象到达一定年龄只有,新生代的对象到达老年代
新生代转移到老年代的年龄根据垃圾回收器的类型而有所不同CMS(Concurrent Mark Sweep,一种垃圾回收器)设置的默认年龄是6,其他垃圾回收器的默认年龄都是15,这个年龄可以我们自己设置(通过参数 -XXMaxTenuringThreshold配置),但不可以超过15,因为对象头中用于记录年龄的空间只有四位
老年代的GC采用的是标记清除或者标记整理,因为老年代的空间较大,所以老年代的GC并不像新生代那样频繁。
整个内存回收称之为FGC(Full Garbage Collector,完整垃圾回收),或者MajorGC(Major Garbage Collector,重要垃圾回收)YGC/MinorGC在新生代空间耗尽时出发FGC/MajorGC在老年代空间耗尽时触发。FGC/MajorGC触发时,新生代和老年代会同时进行GC。在Java程序中,也可以通过System.gc()来手动调用FGC。
小结:整个内存回收过程如图所示:
当对象刚被创建的时候,优先考虑在栈上分配空间,因为栈上分配内存效率很高,当栈帧从虚拟机栈pop出去的时候,对象就被回收了。但在栈上分配内存时,必须保证此对象不会被其他栈帧调用,否则此栈帧必须pop出去,就会产生对象逃逸,产生bug
如果此对象不能在栈上分配内存,则判断此对象是否是大对象,如果对象过大,则直接分配到老年代(具体多大可以通过 -XXPretenureSizeThreshold参数设置)
否则考虑在TLAB(Thread Local Allocation Buffer,线程本都分配缓存区)上分配内存,这块儿内存是伊甸区为每一个线程分配的一块儿区域,它的大小是伊甸区的1%(可以通过 -XXTLABWasteTargetPercent设置),作用是减少线程间互相争抢伊甸区的空间,以减少同步操作。
伊甸区的对象经过GC,存活的对象在Survivor1区和Survivor2区不断拷贝,到达一定年龄后到达老年代。
老年代的垃圾在FGC时被回收,这就是Java中的整个GC过程
Garbage Collector
随着Java的不断发展,垃圾回收器也在不断地更新。在JDK5及之前,主要采用Serial/SerialOld进行垃圾回收,他们分别用于回收新生代/老年代,从名字就可以看出,二者都是单线程的
在JDK6中,引用了Parallel Scavenge/Parallel Old,简称PS/PO,分别用于回收新生代/老年代,在JDK6到JDK8中采用PS/PO进行垃圾回收,它们都是多线程的。
在JDK8之后,出现过一个承上启下的垃圾回收器CMS,它开启了并发回收的先河,只要用于老年代的垃圾回收,与其搭配使用的新生代垃圾回收器名为ParNew
之前的PS/PO虽然也使用了多线程,但多线程回收和并发的区别在于:多线程回收是指多个线程同时执行垃圾回收,而并发回收的意思是垃圾回收线程和工作线程同时执行。但可惜的是,CMS使用起来有一个很大的问题,但它开启了GC的新思路,之后的并发垃圾回收器,如G1(Garbage First)、ZGC(Z Garbage Collector)、Shenandoah等都是由它启发出来的。
JDK11引入了ZGC,JDK12引用了Shenandoah。但在JDK9之后,默认都是采用G1进行垃圾回收,G1是一个非常高效的并发垃圾回收器
Serial/Serial Old
Serial: a stop-the-word,copying collector which uses a single GC thread.
Stop-the-word。简称STW,意思是GC操作中,所有的线程必须停止所有工作,等待GC完成后再继续工作,STW会造成界面的卡顿。
从定义中可以看出,Serial采用的是拷贝算法,并且是单线程运行
Serial Old: a stop-the-word ,mark-sweep-compact collector that uses a single GC thread
和Serial类似,但它主要用于老年代的垃圾回收,采用的是标记压缩算法,也是单线程运行。
这两个最早的垃圾回收器现在已经不实用了,因为他们的效率实在太低。并且随着程序内存越来越大,STW的时间也会越来越长,最终导致界面卡死的时间越来越长
Parallel Scavenge/Parallel Old
Parallel Scavenge: a stop-the-word,copying collector which uses multiple GC threads.
从定义中可以看出,Parlllel Scavenge采用的拷贝算法,多线程运行
Parallel Old :a compacting collector that uses multiple GC threads
Parallel Old采用标记压缩算法,多线程运行
CMS/Parnew
CMS(Concurrent Mark Sweep): amostly concurrent,low-pause collector
CMS采用的是标记清除算法,并且是并发执行的。
并发虽好,但是使用不当会带来很多问题,核心问题有两类
- 某个对象将要被当成垃圾回收时,工作线程突然有一个引用准备指向它,导致标记了不该回收的对象
- 某个对象在GC扫描时没有被当成垃圾,扫描过后又变成了垃圾,导致没有标记到应该回收的对象
这两个问题是并发垃圾回收器需要解决关键问题,以CMS为力,我们来看一下它是怎么解决这两类问题的
CMS主要分为四个阶段:初始标记(initial mark),并发标记(concurrent mark),重新标记(remark),并发清理(Concurrent sweep)。
初始标记阶段:从GCroots开始,通过可达性分析算法找到所有垃圾,这个阶段是最耗时的,但是由于并发执行,所有不会出发STW。这里会用到三色扫描算法
黑色:自己已经标记,且fields已经标记完成
灰色:自己标记完成,但是fields还没标记
白色:没有遍历到的节点
并发标记是最困难的一步,难点在于标记对象的过程中,对象的引用关系正在发生改变,白色对象可能会被错误回收。
重新标记阶段:这个阶段主要用于纠错,也就是修复上文中提到的,标记了不该回收的对象 和 没有标记到应该回收的对象,这两个错误,这时会触发STW,但时间不会很长,因为出错的对象毕竟是少数。
并发清理阶段:清楚所有垃圾,不会出发STW。
由于CMS采用的是标记清除算法,所以不可避免地产生了较多的内存碎片。当来年代中内存碎片过多,导致无法为大对象分配内存时,CMS会使用Serial Old对老年代进行垃圾回收,这回出现一次非常长时间的STW,这也是前文说到的使用CMS最大的一个问题,所以,没有任何一个JDK版本采用CMS作为默认垃圾回收器
ParNew : astop-the-word,coping collector which uses multiple GC threads. It differs from “Parallel Scavenge” in that it has enhancements htat make it usable with CMS
从定义中可以看出,ParNew是PS的一个变种,采用拷贝算法,多线程运行,主要是为了配合CMS。
G1、ZGC、Shenandoah
三者都是比较搞笑的并发垃圾回收器,在CMS的Remark阶段,为了修复并发标记过程中的错误标记,CMS采用了一种,Increment Updata的算法,但这种算法在并发时可能会产生漏标。在G1中此阶段采用的方案是SATB(Snapshot At The Begining),ZGC和Shenandoah采用的方案是Colored Pointers。这几种算法都比较复杂。
引用
聊完了Java内存回收,再来看看Java中的四种引用类型。引用类型由强到弱分别为:
- 强引用: Object obj = new Object();这样new出来的对象就属于强引用类型。GC不会回收强引用对象。
- 软引用: SoftReference< Object > softObj = new SoftReference();当内存实在不足时,GC就会回收软引用对象。
- 弱引用: WeakReference< Object > weakObj = new WeakReference();当GC回收时,遇到弱引用对象就会将其回收。
- 虚引用:不会被使用
总结
本文介绍了Java内存回收算法的知识体系,包括什么是垃圾,如何找到垃圾以及如何回收垃圾。介绍了回收垃圾时用到的三种回收算法:标记清除、拷贝、标记整理。然后介绍了历史上的几种垃圾回收器,以及Java中的四种引用类型。
Java中的内存泄漏:某个生命周期长的对象持有了生命周期短的对象的引用,导致生命周期短的对象无法被及时回收。
并不是说有了GC机制,我们就完全不用操心内存回收的问题了。在有的情况下,当某个强引用对象不再需要使用时,我们应该手动将其设置为null,使GC能够识别出这段内存已经成为了一个垃圾。
并且,由前文可知,方法区中静态引用的对象输入GCroots,所以使用静态变量和静态方法时要小心,这些对象一旦创建出来,就会一直存在于内存中,直到程序退出或者变量被手动设置为null之后,这段内存才能被回收掉。
Stay Hungry,Stay Foolish(保持饥饿,保持愚蠢)。在日常工作中,不应只满足于业务,多了解程序背后的原理和运行机制,对我们自身的提升大有裨益。