垃圾收集器与垃圾收集算法

之前介绍了Java内存运行时区域的各个部分,其中程序计数器,虚拟机栈,本地方法栈这3个区域随线程而生随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊的执行着出栈和入栈操作,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或线程结束的时候,内存就自然跟着回收了。

因此我们接下来讨论的内存分配与回收特指Java堆和方法区

对象已死?

垃圾回收器在对堆进行回收前,第一件事就是要确认哪些对象需要被回收也就是已经死去

引用计数法

在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的

在Java领域主流的虚拟机并没有使用引用计数法来管理内存,主要原因是这个看似简单的算法有很多例外需要考虑,比如单纯的引用计数就很难解决对象之间循环引用的问题。

可达性分析算法

当前主流的商用程序语言(Java,C#)的内存管理子系统都是通过可达性分析算法来判定对象是否存活的。

这个算法的思路就是通过一系列成为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,证明此对象不可能再被使用

在Java中,固定可以作为GC Roots的对象包括以下几种:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如当前正在运行的方法所使用到的参数,局部变量,临时变量
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量(static)
  • 在方法区中常量引用的对象,譬如字符串常量池里的引用,final
  • 在本地方法栈中Native方法引用的对象
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象,还有系统类加载器
  • 所有被同步锁(synchronized 关键字)持有的对象
  • 反映Java虚拟机内部情况的JMXBean,JVMTI中注册的回调,本地代码缓存等

除了这些固定的GC Roots对象外根据用户当前选用的垃圾收集器以及当前回收的内存区域不同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。

引用

在JDK1.2以前,Java中引用的定义很传统: 如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。这种定义有些狭隘,一个对象在这种定义下只有被引用或者没有被引用两种状态。

我们希望能描述这一类对象: 当内存空间还足够时,则能保存在内存中;如果内存空间在进行垃圾回收后还是非常紧张,则可以抛弃这些对象。很多系统中的缓存对象都符合这样的场景。

在JDK1.2之后,Java对引用的概念做了扩充,将引用分为强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)和虚引用(Phantom Reference)四种,这四种引用的强度依次递减。

强引用

强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。当内存空间不足,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。 ps:强引用其实也就是我们平时A a = new A()这个意思。

软引用

如果一个对象只具有软引用,则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用。软引用可用来实现内存敏感的高速缓存。JDK 1.2以后提供了SoftReference类来实现软引用

弱引用

弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象。JDK 1.2以后提供了WeakReference类来实现弱引用

虚引用

“虚引用”顾名思义,就是形同虚设,与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收。
虚引用主要用来在对象被回收时收到一个通知。虚引用与软引用和弱引用的一个区别在于:虚引用必须和引用队列 (ReferenceQueue)联合使用。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用,就会在回收对象的内存之前,把这个虚引用加入到与之 关联的引用队列中。
在JDK 1.2以后提供了PhantomReference类来实现虚引用

生存还是死亡?

即使在可达性分析算法中判定为不可达的对象,也不意味着对象真正死亡,要宣告一个对象真正死亡需要经过两次标记过程。

第一次标记发生在对对象进行可达性分析时,发现没有与GC Roots相连的引用链,那么他将会被第一次标记。然后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法,如果对象没有finalize()方法或者finalize()方法已经被虚拟机调用过,则认为没有必要执行。如果有必要执行finalize()方法,那么该对象会被放在一个F-Queue的队列之中,并在稍后由一条虚拟机自动创建的,低调度优先级的Finalizer线程执行他们的finalize()方法

如果对象在finalize()方法中重新和引用链上的对象建立关联,那么在第二次标记是会被移出“即将回收”的集合,否则就会被真正的回收。

需要注意的是,任何对象的finalize()方法只会被系统调用一次!所以不建议把finalize()作为拯救对象的方法!

方法区垃圾回收

实际上方法区的垃圾回收“性价比”是很低的。

方法区的垃圾回收主要有两部分内容:废弃的常量和不再使用的类型信息。

判断一个常量是否需要回收看虚拟机中有没有地方引用这个常量(即值为这个常量),没有的话就会被清理出常量池。

判断一个类型信息是否需要回收,需要满足三个条件:

  • 该类的所有实例被回收,也就是说Java堆中不存在该类极其子类的实例
  • 加载该类的类加载器被回收,这个条件除非是精心设计的可替换类加载器场景,否则通常是很难达成的
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类

在满足了这三个条件后,Java虚拟机允许对无用类进行回收,仅仅是允许,而不是必须。具体回收与否取决于虚拟机的设置

垃圾收集算法

从如何判定对象消亡的角度出发,垃圾收集算法分为“引用计数式垃圾收集(Reference Counting GC)”和“追踪式垃圾收集(Tracing GC)”。由于主流Java虚拟机未涉及引用计数式垃圾收集算法,因此,接下来介绍的都属于追踪式垃圾收集的范畴

分代收集理论

当前商业虚拟机的垃圾收集器,大多遵循了“分代收集”的理论。

分代收集的理论建立在三个假说之上:

  • 大多数对象都是朝生夕灭的
  • 如果一个对象熬过很多次垃圾收集,那他就越难消亡
  • 跨代引用相对于同代引用来说仅占少数

分代收集的设计原则:把Java堆划分出不同的区域,按照回收对象的年龄(即对象熬过垃圾收集过程的次数)分到不同的区域去存储。

设计者一般都会把Java堆划分为新生代(Young Generation)和 老年代(Old Generation)。在新生代中,每次垃圾收集都会有大量的对象死亡,存活下来的对象晋升老年代。

如果我们要对新生代进行垃圾收集,如何解决跨代引用问题?根据第三条假说,我们不必扫描整个老年代,而是在新生代建立一个全局的数据结构(被称为记忆集),这个结构把老年代划分为若干小块,标识出老年代的哪一块内存会存在跨代引用,此后young gc时,只有这部分内存里的对象会被加入GC Roots进行扫描。

标记-清除算法

这是最早出现的垃圾收集算法,分为标记和清除两个阶段:首先标记处所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象。标记过程就是对象是否属于垃圾的判定过程。

它的主要缺点有两个:一是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随着对象数量增长而降低。第二个是内存空间的碎片化问题,标记,清除之后会产生大量比连续的内存碎片,空间碎片太多会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

标记-复制算法

新生代的垃圾回收多采用标记-复制算法。

半区复制:把内存分为大小相等的两块,每次只使用其中的一块,当这一块内存用完了就将还活着的对象复制到另外一块上去,然后再把已使用过的内存空间一次清理了。如果内存中有多数对象都是需要回收的,算法复制的就是占少数的存活对象,这样实现简单,运行高效,不过缺点也明显:内存可用空间缩小为原来的一半!

Appel式回收:考虑到对象“朝生夕灭”的特点,Apple式回收把新生代分为一块较大的Eden空间(80%)和两块较小的Survivor空间(各占10%),每次分配内存只使用Eden空间和一块Survivor,把存活的对象复制到另一块Survivor空间,如果这个Survivor空间不够,会依赖其他内存区域(大多是老年代)进行分配担保。

标记-整理算法

老年代中存活的对象较多,因此不适合选用标记-复制算法。

标记-整理算法的标记过程与标记-清除算法一致,但后续不是直接对可回收对象进行清理而是让所有存活的对象都向内存空间一端移动,然后清理掉边界以外的内存。

标记-清除算法在内存分配时更复杂,标记-整理算法在内存回收时更复杂,因此老年代选用何种算法取决于垃圾收集器的考虑。

常见垃圾收集器

新生代:Serial,ParNew,Parallel Scavenge
老年代:CMS,Serial Old(MSC),Paraller Old
混合:G1

Serial

单线程收集器,只会使用一个处理器或一条收集线程完成垃圾收集过程,并且在垃圾收集工程中,必须暂停其他所有工作线程,直到收集结束

实际上上述所有的垃圾收集器都完全无法避免这个问题,只不过暂停的时间越来越短。

对于内存资源受限的环境,他是所有收集器里额外内存消耗最小的;对于单核处理器或处理器核心数较少的环境,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可获得最高的单线程收集效率。在用户桌面的应用场景以及近年流行的部分微服务应用中,分配给虚拟机的内存不大,使用Serial收集器是一个很好的选择。

  • 收集区域: Serial (新生代),Serial Old(老年代)。

  • 使用算法: Serial (标记复制法),Serial Old(标记整理法)。

  • 搜集方式: 单线程收集。

  • 优势: 内存资源占用少、单核CPU环境最佳选项。

  • 劣势: 整个搜集过程需要停顿用户线程。多核CPU、内存富足的环境,资源优势无法利用起来。

ParNew

ParNew实际是Serial的多线程并行版本。

  • 收集区域: 新生代。

  • 使用算法: 标记复制法。

  • 搜集方式: 多线程。

  • 搭配收集器: CMS。

  • 优势: 多线程收集,CPU多核环境下效率要比serial高,新生代唯一一个能与CMS配合的收集器。

  • 劣势: 整个搜集过程需要停顿用户线程。

Parallel Scavenge收集器

Parallel Scavenge 和Parallel Old的工作机制一样,这里以Parallel Scavenge为例,Parallel Old在收集过程中会开启多个线程一起收集,整个过程都会暂停用户线程,直到整个垃圾收集过程结束。和之前的Serial垃圾收集器一对比,同样进行垃圾收集前都是先叫其他人都离开房间,但是不同的是serial只有一个人打扫房间,而这里却是有多个人一起打扫房间,所以从这一点看Parallel 系列的收集器要比之前的效率高上很多。

Praller Scavenge收集器更关注吞吐量,吞吐量=运行用户代码时间/(运行用户代码时间+运行垃圾收集时间)

  • 收集区域: Parallel Scavenge (新生代),Parallel Old(老年代)。

  • 使用算法: Parallel Scavenge (标记复制法),Parallel Old(标记整理法)。

  • 搜集方式: 多线程。

  • 优势: 多线程收集,CPU多核环境下效率要比serial高。

  • 劣势: 整个搜集过程需要停顿用户线程。

CMS收集器

CMS收集器是一种以获取最短回收停顿时间为目标的收集齐。目前主要应用在互联网网站或B/S架构的服务端上。

CMS收集器采用标记-清除算法,整个过程分为四个步骤:初始标记,并发标记,重新标记,并发清除。

其中初始标记,重新标记这两个步骤需要停止用户线程。

初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。并发标记是从GC Roots的直接关联对象开始遍历整个对象图的过程,这个过程耗时很长但是不需要停顿用户线程,重新标记则是为了修正并发标记期间,因为用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,最后并发清除就是清除掉被标记阶段判断为死亡的对象,这个阶段也是不需要停止用户线程,可以与用户线程并发进行。

  • 收集区域: 老年代。

  • 使用算法: 标记清除法+标记整理法。

  • 搜集方式: 多线程。

  • 搭配收集器: ParNew。

  • 优势: 多线程收集,收集过程不停止用户线程所以用户请求停顿时

CMS收集器也有缺点:一是比较占用CPU资源。二是无法处理“浮动垃圾”,浮动垃圾指的是在标记阶段之后产生的垃圾对象,CMS只能等待下一次垃圾收集处理他们,因此CMS收集器不能等待老年代被填满了再进行垃圾收集而是需要预留一部分空间,如果预留的空间不够“浮动垃圾”,会停止用户线程,临时采用Serial Old收集器进行垃圾处理。

此外由于CMS收集器采用标记-清除算法,产生大量的内存碎片,给大对象分配带来很大麻烦,不得不提前触发一次Full GC,因此CMS垃圾收集器提供参数来开启在Full GC之前可以进行内存碎片的整理也就是对象的挪动,需要注意的是这个过程也必须停止用户线程。

G1

G1收集器是一款主要面对服务端应用的垃圾收集器。并用来替代CMS收集器。

G1设计的目标就是实现“停顿预测模型”,即支持指定在一个长度M毫秒的时间片段内,消耗在垃圾收集上的时间不超过N毫秒这样的目标。

G1的内存布局并不是固定的分代区域划分,而是把连续的JAVA堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间,Survivor空间,或者老年代空间。收集器根据Region的角色采用不同的策略进行处理。

Region中还有一类特殊的Humongous区域,专门用来存储大对象,G1认为只要大小超过一个Region容量一半的对象就是大对象。

G1之所以能建立可预测的停顿时间模型,就是因为它将Region作为单次回收的最小单元,这样可以避免在整个Java堆中进行全区域的垃圾收集。更具体的是,G1去跟踪各个Region中垃圾堆的“价值”大小,价值即回收所获得的空间大小以及回收需要的时间的综合考虑,然后在后台维护一个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region。

G1的回收流程和CMS逻辑大致相同,分别进行初始标记、并发标记、重新标记、筛选清除,区别在最后一个阶段G1不会直接进行清除,而是会根据设置的停顿时间进行智能的筛选和局部的回收(回收就是把需要回收的Region中存活的对象移动到空的Region中,再清除掉这个Region即可,即标记复制法)。

  • 收集区域: 整个堆内存。

  • 使用算法: 标记复制法

  • 搜集方式: 多线程。

  • 搭配收集器: 无需其他收集器搭配。

  • 优势: 停顿时间可控,吞吐量高,可根据具体场景选择吞吐量有限还是停顿时间有限,不需要额外的收集器搭配。

  • 劣势: 因为需要维护的额外信息比较多,所以需要的内存空间也要大,6G以上的内存才能考虑使用G1收集器。

ZGC

ZGC是jdk 11推出的一款低延迟垃圾回收器。

ZGC也使用了标记-复制算法。

与G1不同的是,ZGC在进行对象的复制时也实现了一定程度的并发。ZGC通过着色指针和读屏障技术,解决了转移过程中准确访问对象的问题,实现了并发转移。大致原理描述如下:并发转移中“并发”意味着GC线程在转移对象的过程中,应用线程也在不停地访问对象。假设对象发生转移,但对象地址未及时更新,那么应用线程可能访问到旧地址,从而造成错误。而在ZGC中,应用线程访问对象将触发“读屏障”,如果发现对象被移动了,那么“读屏障”会把读出来的指针更新到对象的新地址上,这样应用线程始终访问的都是对象的新地址。那么,JVM是如何判断对象被移动过呢?就是利用对象引用的地址,即着色指针。下面介绍着色指针和读屏障技术细节。

ZGC详解

Young GC与 Full GC

posted @ 2021-09-07 22:16  刚刚好。  阅读(70)  评论(0)    收藏  举报