jvm - 垃圾回收
注意 : 本系列文章为学习系列,部分内容会取自相关书籍或者网络资源,在文章中间和末尾处会有标注
垃圾回收的意义
它使得java程序员不再时时刻刻的关注内存管理方面的工作.
垃圾回收机制会自动的管理jvm内存空间,将那些已经不会被使用到了的"垃圾对象"清理掉",释放出更多的空间给其他对象使用.
何为对象的引用?
Java中的垃圾回收一般是在Java堆中进行,因为堆中几乎存放了Java中所有的对象实例
在java中,对引用的概念简述如下(引用强度依次减弱) :
-
强引用 : 这类引用是Java程序中最普遍的,只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象
-
软引用 : 用来描述一些非必须的对象,在系统内存不够使用时,这类对象会被垃圾收集器回收,JDK提供了SoftReference类来实现软引用
-
弱引用 : 用来描述一些非必须的对象,只要发生GC,无论但是内存是否够用,这类对象就会被垃圾收集器回收,JDK提供了WeakReference类来实现弱引用
-
虚引用 : 与其他几种引用不同,它不影响对象的生命周期,如果这个对象是虚运用,则就跟没有引用一样,在任何时刻都可能会回收,JDK提供了PhantomReference类来实现虚引用
如下为相关示例代码
public class ReferenceDemo {
public static void main(String[] arge) {
//强引用
Object object = new Object();
Object[] objects = new Object[100];
//软引用
SoftReference<String> stringSoftReference = new SoftReference<>(new String("SoftReference"));
System.out.println(stringSoftReference.get());
System.gc();
System.out.println(stringSoftReference.get()); //手动GC,这时内存充足,对象没有被回收
System.out.println();
//弱引用
WeakReference<String> stringWeakReference = new WeakReference<>(new String("WeakReference"));
System.out.println(stringWeakReference.get());
System.gc();
System.out.println(stringWeakReference.get()); //手动gc,这时,返回null,对象已经被回收
System.out.println();
//虚引用
//虚引用主要用来跟踪对象被垃圾回收器回收的活动。
//虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。
//当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中
ReferenceQueue<String> stringReferenceQueue = new ReferenceQueue<>();
PhantomReference<String> stringPhantomReference = new PhantomReference<>(new String("PhantomReference"), stringReferenceQueue);
System.out.println(stringPhantomReference.get());
}
}
当然,关于这几种引用还有很多知识点,本文只做简单的介绍,后续有机会再单独的文章详细介绍.
如何确定需要回收的垃圾对象?
引用计数器
每个对象都有一个引用计数器 , 新增一个引用的时候就+1,引用释放的时候就-1,当计数器为0的时候,就表示可以回收
引用计数算法的实现简单,判定效率也很高,在大部分情况下它都是一个不错的选择,当Java语言并没有选择这种算法来进行垃圾回收,主要原因是它很难解决对象之间的相互循环引用问题
public class LoopReferenceDemo {
public static void main(String[] args) {
TestA a = new TestA(); //1
TestB b = new TestB(); //2
a.b = b; //3
b.a = a; //4
a = null; //5
b = null; //6
}
}
class TestA {
public TestB b;
}
class TestB {
public TestA a;
}
虽然a和b都为null,但是a和b存在循环引用,这样a和b就永远不会被回收
如果你在互联网上搜索"引用计数器"这个关键字,通常都会得到以上这一个结论,但是究竟为什么a和b不会被回,收其实还是没有说清楚的,下面简单说明一下 :
-
第一行 : TestA的引用计数器加1,TestA的引用数量为1
-
第二行 : TestB的引用计数器加1,TestB的引用数量为1
-
第三行 : TestB的引用计数器加1,TestB的引用数量为2
-
第四行 : TestA的引用计数器加1,TestA的引用数量为2
内存分布如下图
-
第五行 : 将a变量设置为null,不再指向堆中的引用,所以TestA的引用计数器减1,TestA的引用数量为1
-
第六行 : 将b变量设置为null,不再指向堆中的引用,所以TestB的引用计数器减1,TestB的引用数量为1
内存分布如下图
- 结论 : 虽然上面程序将a和b设置为null了,但是在堆中,TestA和TestB还是互相持有对方的引用,引用计数器依然不等于0,这个就称为循环引用,所以说"引用计数器"会存在这个问题,导致这类对象无法被清理掉.
以上的知识点参考 : https://www.zhihu.com/question/21539353
可达性分析
虽然以上的"引用计数器"算法存在"循环引用"的问题,不过目前主流的虚拟机都采用"可达性分析(GC Roots Tracing)"算法来标记那些对象是可以被回收的.
该算法是从GC Roots开始向下搜索,搜索走过的路径称之为引用链.当一个对象到GC Roots没有任何引用链相连时,就代表这个对象是不可用的.称为"不可达对象"
GC Roots包括:
-
虚拟机栈(栈帧中的本地变量表)中的引用对象
-
方法区中的静态属性实体引用的对象
-
方法区中常量引用的对象
-
本地方法栈中JNI(Native方法)引用的对象
实际上,在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程 :
-
如果对象在进行根搜索后发现没有与GC Roots相连接的引用链,那它会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法
-
当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行
-
如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法
-
finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次), 稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重引用链上的任何一个对象建立关联即可
-
而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉
如下图所示
从上图上看,reference1,2,3都是gc roots
reference1指向instance1,reference2指向instance4,并且instance4又指向了instance6,reference3则指向了instance2
所以说instance1,2,4,6都具有gc roots可达性,是存活着的对象,不会被垃圾回收器回收掉
而instance3,5则不具备gc roots可达性,是不可用对象,将会被垃圾回收器回收掉
从上图描述"引用计数器"的图例场景来看,TestA和TestB虽然互相有持有引用,但是并不具备gc roots可达性,所以,在"可达性分析"算法下,是会被垃圾回收器回收掉的
垃圾收集的算法
标记-清除 算法
算法分为"标记"和"清除"两个阶段,首先标记出需要回收的对象,在标记完成后,统一回收掉之前被标记的所有对象. 它是最基础的收集算法 . 后续的收集算法都是基于这种思想,并且对其缺点进行改进而产生的
主要缺点:
-
效率问题 : 需要标记和清除两次扫描
-
空间问题 : 标记和清除之后会产生大量的不连续的内存碎片,可能会导致,当程序需要分配一个较大内存空间的时候,无法找到足够的连续内存,从而不得不提前出发另外一次垃圾回收动作
复制 算法
将可用内存按容量划分为两块,每次只使用其中的一块,当内存使用完了后,就将还存活着的对象复制到另外一块上面,然后在把前面一块内存一次性清理掉
优点 :
- 每次只操作一块内存,分配内存的时候无需考虑内存碎片的情况,只需要移动对象的指针,按顺序分配内存即可,实现简单,运行高效
缺点 :
-
会将内存缩小为原来的一半
-
持续复制长生期的对象则导致效率降低 (没理解) (对于存活率较高的对象,就会对其进行多次复制,从而导致效率降低)
标记-压缩 算法
和标记-清除算法一样,只不过标记后的动作不是清除,而是将所有对象向一端移动,然后直接清理掉边界以外的对象(被标记的对象)
特点 :
-
复制算法比较适合于新生代,在老年代中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如标记—清理算法
-
该算法标记的过程与标记—清除算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存
分代收集 算法
把java的堆分为"新生代"和"老年代",对于不同的年代采用不同算法
在新生代中,由于对象生命周期非常短暂,所以每次垃圾回收的时候都会有大量的对象死去,只有少量存活,这样,采用"复制算法",就只需要付出少量存活对象的复制成本,就能完成回收
在老年代中,由于对象生命周期比较长,存活率较高,没有额外的空间对它进行分配和担保,那就必须使用"标记-清除算法"或者"标记-压缩算法"来进行回收
Minor GC: 从年轻代空间(包括Eden和Survivor区域)回收内存被称为Minor GC
Major GC: 清理老年代
Full GC: 清理整个堆空间—包括年轻代和老年代
年轻代: 是所有新对象产生的地方.年轻代被分为3个部分(Enden区和两个Survivor区,也叫From和To),当Eden区被对象填满时,就会执行Minor GC,并把所有存活下来的对象转移到其中一个survivor区(Form),Minor GC同样会检查存活下来的对象,并把它们转移到另一个survivor区(To),这样在一段时间内,总会有一个空的survivor区,经过多次GC周期后,仍然存活下来的对象会被转移到年老代内存空间,常这是在年轻代有资格提升到年老代前通过设定年龄阈值来完成的,需要注意,Survivor的两个区是对称的,没先后关系,from和to是相对的.
老年代: 在年轻代中经历了N次回收后仍然没有被清除的对象,就会被放到年老代中,都是生命周期较长的对象.对于年老代,则会执行Major GC,来清理.在某些情况下,则会触发Full GC,来清理整个堆内存
元空间: 堆外的一部分内存,通常直接使用的是系统内存,用于存放运行时常量池,等内容,垃圾回收对应元空间来说没有明显的影响
垃圾收集器
垃圾收集器是内存回收算法的具体实现,Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大的差别
Sun HotSpot虚拟机1.6版包含了如下收集器:Serial、ParNew、Parallel Scavenge、CMS、Serial Old、Parallel Old
这些收集器以不同的组合形式配合工作来完成不同分代区的垃圾收集工作,如下是垃圾收集器简单介绍 :
Serial收集器
串行收集器,最古老,最稳定,以及效率高的收集器,但是可能会造成程序较长时间的停顿,只使用一个线程去回收.新生代,老年代使用串行回收
新生代使用"复制算法"
老年代使用"标记压缩算法"
垃圾回收的过程中会"程序暂停"(Stop the world)
ParNew收集器
是Serial收集器的多线程版,新生代并行,老年代串行
新生代使用"复制算法"
老年代使用"标记压缩算法"
垃圾回收的过程中会"程序暂停"(Stop the world)
Paralle收集器
类似于ParNew收集器,但是更关注系统的吞吐量.
可以通过参数来打开"自适应调节策略",虚拟机会根据系统当前的运行情况收集性能监控信息,动态调整这些参数以便提供最合适的停顿时间和最大的吞吐量
也可以通过参数控制GC的时间不大于多少毫秒或者比例
新生代使用"复制算法"
老年代使用"标记压缩算法"
Parallel Old收集器
是Paralle收集器的老年代版本 , 使用多线程和"标记-整理算法",这个收集器在JDK1.6中才开始使用
CMS收集器
是基于"标记-清除"算法实现的,它的运作过程相对于前面的其中收集器要复杂一些,整个过程分为4个步骤,包括 :
-
初始标记(CMS initial mark)
-
并发标记(CMS concurrent mark)
-
重新标记(CMS remark)
-
并发清除(CMS concurrent sweep)
初始标记和并发标记仍需要Stop the World.
初始标记仅仅只是标记一下GC Root能直接关联到的对象,速度很快.
并发标记阶段就是进行GC Root Tracing的过程.
重新标记这是为了修正并发标记期间,因用户程序继续运作而导致标记变动的那一部分的标记记录,这一阶段的停顿时间会比初始标记阶段的时间稍长一些,但远比并发标记时间短
整个过程中耗时最长的并发标记和并发清除过程中,收集器线程可以与用户线程一起工作,所以总体来说,CMS收集器的内存回收是与用户线程一起并发执行的
优点 : 并发收集,低停顿
缺点 : 产生大量的空间碎片,并发阶段会降低吞吐量
G1收集器
与CMS收集器项目,G1收集器有以下特点 :
-
空间整合 :
- G1收集器采用标记-整理算法,不会产生空间碎片.分配大对象时不会应为找不到连续的空间而提前触发下一次GC
-
可预测停顿 :
-
降低停顿时间是G1和CMS的共同关注点
-
G1除了追求低停顿外,还能建立可预测的停顿时间模型.
-
能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,几乎已经是实时java(RTSJ)垃圾回收的特征了
-
上面提到的垃圾收集器,收集的范围都是整个新生代或者老年代,而G1不在是这样.
使用G1收集器的时候,JAVA堆的内存布局与其他的收集器有很大的差别,它将这个java堆划分为多个大小相等的独立区域(Region),虽然还保留新生代和老年代的概念,但新生代和老年代不再是物理隔阂了,他们都是一部分(可以不连续)Region的集合
G1的新生代收集器跟ParNew类似,当新生代占用达到一定的比例的时候,开始触发收集
和CMS类似,G1收集器收集老年代对象的时候会有短暂停顿
收集步骤如下 :
-
标记阶段 :
- 首先初始标记(initial mark),这个阶段是停顿的(Stop the world event),并且会触发一次普通的Mintor GC(从年轻代空间回收)
-
Root Region Scanning :
- 运行程序过程中会回收survivor区(存活到老年代),这一过程必须在young GC之前完成
-
Concurrent Marking :
-
在整个java堆中进行并发标记(和应用程序并发执行),此过程可能会被young GC中断
-
若发现区域对象中的所有对象都是垃圾,那个区域就会被立即回收
-
同时,并发标记过程中,会去计算每个区域的对象活性(区域中存活对象的比例)
-
-
Remark :
-
再标记,会有短暂停顿(STW)
-
是用来收集并发标记阶段,产生新的垃圾(并发阶段和应用程序一同执行)
-
G1中采用了比CMS更快的初始快照算法 : snapshot-at-the-beginning (SATB)
-
-
Copy / Clean up :
-
多线程清除失活对象,会有STW
-
G1将回收区域的存活对象拷贝到新的区域,清除Remember Sets,并发清空回收区域,并把它返回到空闲的区域链表中
-
-
复制/清除过程后 :
- 回收区域的活性对象已经被收集器回收到"最近复制的年轻代"(recently copied in young generation)和"最近复制的老年代"(recently copied in old generation)区域中了
参考文献
<<深入理解JVM虚拟机>>
结束
本文提到的点很多,有对象引用,如何定义垃圾对象,gc算法,现有的垃圾收集器,等.
由于篇幅和时间原因,每个点都提及的不深入(当然,本篇文章的每个点深入的聊起来,都够写本书的了,呵呵).
后续会找机会逐个的将这些点跟大家深入的讨论.
总之 "学无止境" , 与大家共勉 .
代码仓库 (博客配套代码)
想获得最快更新,请关注公众号