GC垃圾回收机制

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(保持饥饿,保持愚蠢)。在日常工作中,不应只满足于业务,多了解程序背后的原理和运行机制,对我们自身的提升大有裨益。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值