欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

垃圾收集器(深入理解 Java 虚拟机笔记)

程序员文章站 2022-07-12 20:09:07
...

概述

Java内存区域的程序计数器,虚拟机栈,本地方法栈3个区域随着线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而执行着出栈和入栈操作。每一个栈帧中分配多少内存基本在类结构确定下来时就已知了。因此这几个区域的内存分配和回收都具有确定性,当方法结束或线程结束时,内存自然跟着回收。

而Java堆和方法区有显著的不确定性:一个接口的多个实现类 需要的内存可能不一样,一个方法执行的不同条件分支所需要的内存也可能不一样。只有处于运行期间,我们才能知道程序会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的,而垃圾收集器关注的正是这部分内存该如何管理,后续讨论的内存分配与回收也仅仅特指这一部分。

 


确定对象存活

垃圾收集器在对堆进行回收前,首先要确定这些对象哪些是存活,哪些已死去(不可能再被任何途径使用的对象)。

引用计数算法

在对象中添加一个引用计数器,每当有一个地方引用时,计时器加一;当引用失效时,计数器值减一。

虽然它的原理简单,判定效率高,但在主流的Java虚拟机里,都没有引用计数算法来管理内存。主要原因是:算法有很多例外情况要考虑,必须配合大量额外处理才能保证正确工作。

测试:下面的代码中,对象objA和objB都有字段Instance,赋值令objA.instance=objB及objB.instance=objA,此后赋值null断掉引用,此时两个对象不可能再被访问,但由于它们互相引用着对方,导致它们的引用计数器为不为0,这种算法就无法回收它们。

public class JavaVM {
    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        JavaVM objA = new JavaVM();
        JavaVM objB = new JavaVM();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;            // 断掉引用
        objB = null;
        System.gc();
    }

    public static void main(String[] args) throws Exception {
        JavaVM objA = new JavaVM();
        JavaVM objB = new JavaVM();
        objA.instance = objB;
        objB.instance = objA;

        System.gc();
    }
}

代码示意图

垃圾收集器(深入理解 Java 虚拟机笔记)

 

可达性分析算法

通过一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中走过的路径称为“引用链”,若某个对象到GC Roots间没有任何引用链连接,则证明此对象不可能再被引用。

如下所示,对象object 5、object 6、object 7虽然互有关联,但是它们到GC Roots是不可达的, 因此它们将会被判定为可回收的对象。

垃圾收集器(深入理解 Java 虚拟机笔记)

在Java体系里,可作为GC Roots的对象如下:

  • 在虚拟机栈(栈帧中的局部变量表)中引用的对象,比如各个线程被调用的方法堆栈中使用到的参数,局部变量,临时变量等。
  • 在方法区中类静态属性引用的对象,比如Java类的引用类型静态变量。
  • 在方法区中常量引用的对象,比如字符串常量池里的引用。
  • Native方法引用的对象。
  • 所有被同步锁(synchronized关键字)持有的对象。

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

 

引用分类

引用分为强引用,软引用,弱引用和虚引用,它们的强度依次减弱。

(1)强引用是代码中普遍存在的引用赋值,类似“Object obj=new Object()”这种引用关系。只要强引用关系还存在,垃圾收集器永远不会回收掉被引用的对象。

(2)软引用用来描述还有用,但非必须的对象。只被软引用关联着的对象,在系统将要发生内 存溢出异常前,会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存, 才会抛出内存溢出异常。

(3)弱引用也是描述那些非必须对象,但是它的强度比软引用更弱一些,被弱引用关联的对象只 能生存到下一次垃圾收集发生为止。当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只 被弱引用关联的对象。

(4)虚引用:一个对象是否有虚引用的 存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚 引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个系统通知。

 

判断是否回收的流程

即使可达性分析算法判定为不可达的对象,也不一定会被回收。在回收之前,至少要经历两次标记过程:

  1. 对象在进行可达性分析后发现没 有与GC Roots相连接的引用链,那它将会被第一次标记。
  2. 随后进行一次筛选,筛选的条件是此对象是 否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用 过,那么虚拟机将这两种情况都视为“没有必要执行”。

若这个对象被判定为有必要执行finalize()方法,则该对象将会被放在F-Queue队列之中,并在稍后 由一条虚拟机自动建立,低调度优先级的Finalizer线程去执行它们的finalize() 方法。虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束。这是因为若某个对象的finalize()方法执行缓慢或发生死循环,很可能导致队列中其他对象永久等待,进而导致整个内存回收子系统崩溃。接下来,收集器将对队列中的对象进行第二次小规模标记,若对象在finalize()方法中重新与引用链上任何一个对象建立管理(比如把自己(this)赋值给某个类变量或对象的成员变量),则它将被移除“即将回收”的集合;而如果对象此时还没逃脱,那基本上它就真的被回收了

一个对象的finalize()方法最多只会被系统自动调用一次

 

回收方法区

方法区的垃圾收集主要回收:废弃的常量和不再使用的类型。

以常量池中字面量回收为例,假如一个字符串“java”曾进入常量池中,但当前系统没有任何一个字符串对象的值是“java",即没有任何字符串对象 引用常量池中的"java"变量,且虚拟机中也没有其他地方引用这个字面量。若此时发生内存回收,且垃圾收集器判断有必要的话,这个"java"常量将会被系统清理出常量池。常量池中其他类(接口),方法,字段的符号引用的判断标准与此类似。

判定一个类是否属于”不再被使用的类“的条件较苛刻,需要同时满足三个条件:

  • 该类所有实例都已被回收,即Java堆中不存在该类及任何派生子类的实例。
  • 加载该类的类加载器已经被回收。该条件通常很难达成。
  • 该类对于的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

在大量使用反射,动态代理等自定义类加载器的场景中,通常需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。

 


垃圾收集算法

在如何判定对象消亡的角度上,垃圾收集算法划分为“引用计数式垃圾收集”(直接垃圾收集)和“追踪式垃圾收集”(间接垃圾收集)两大类,此处主要讨论追踪式垃圾收集的算法。

 

分代收集理论

常用的垃圾收集器有一个一致的设计原则:收集器应将Java堆划分出不同的区域,将回收对象依据年龄(熬过垃圾收集过程的次数)分配到不同的区域之中存储。垃圾收集器每次只回收其中一个或某些部分的区域。

一般将Java堆划分为新生代和老生代两个区域。在新生代中,每次垃圾收集时都有大量对象被回收,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。

此外,存在互相引用的两个对象,应该倾向于同时生成或同时消亡,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,这使得新生代对象得以存活,进而在年龄增长之后晋升到老年代。因此,相对于同代引用,跨代引用仅占极少数。依据这条假说,需要在新生代中建立一个全局数据结构(被称为记忆集),这个结果把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用。此后发生Minor GC时,只有包含了跨代引用的小块内存里的对象 才会被加入到GC Roots进行扫描。虽然这种方法增加开销,但比起收集时扫描整个老年代来说仍是划算的。

Minor GC:新生代收集,目标只是新生代的垃圾收集

Major GC:老年代收集,目标只是老年代的垃圾收集。

Full GC:收集整个Java堆和方法区的垃圾收集

 

 

标记-清除算法

首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回 收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,

后续的收集算法大多是以标记-清除算法为基础,对其缺点改进而得到。他的主要缺点有:

  • 执行效率不稳定,若Java堆包含大量对象,且其中大部分是需要被回收掉,则必须进行大量标记和清除的动作,导致标记和清除两个过 程的执行效率都随对象数量增长而降低;
  • 标记、清除之后会产生大 量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找 到足够的连续内存而不得不提前触发另一次垃圾收集动作。

 

标记-复制算法

它将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,再把已使用过的内存空间一次清理掉。现代的Java大多优先采用这种算法回收新生代。

  • 优点:对于多数对象都是可回收的情况,算法复制的是少数的存活对象,且每次都是针对半区进行内存回收,不用考虑有空间碎片的复杂情况。
  • 缺点:可用内存缩小为原来一般,浪费空间;在对象存活多较高时,就要进行较多的复制操作,效率降低

由于新生代中的对象大多熬不过第一轮收集,因此不需要按照1:1的比例划分新生代的内存空间。HotSpot虚拟机的Serial、ParNew等新生代收集器采用了“Appel式回收”策略来设计新生代的内存布局:

  1. 把新生代划分为一块较大的Egen空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。
  2. 发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另一块Survivor空间上,然后直接清理Eden和已用过的Survivor空间,HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1。
  3. 如果Survivor空间不足以容纳一次次Minor GC之后存活的对象时,就需要其他内存区域进行分配担保(实际上大多数进入老年代)

标记-整理算法

对于标记-复制算法,如果不想浪费50%的空间,就需要有额外的空间来进行分配担保,以应对内存中所有对象都存活的极端情况。因此老年代一般不直接选用这种算法。

标记-整理算法的标记过程与标记-清除算法一样,但后续步骤是让所有存活对象都向内存空间一段移动,然后直接清理边界以外的内存。

对于老年代这种每次回收都有大量对象存活而言,移动存活对象并更新所有引用这些对象的地方 是一种极为负重的操作,且移动对象操作必须暂停用户应用程序才能进行。但如果与标记-清除算法一样 ,完全不考虑移动和整理存活对象,则空间碎片化问题只能依赖更为复杂的内存分配器和内存访问器来解决,而内存访问是用户程序最频繁的操作,若增加了此项负担,则会直接影响应用程序的吞吐量。

 


经典垃圾收集器

Serial收集器

它是一个单线程工作的收集器,这意味着它只会使用一个处理器或一条收集线程来完成垃圾收集,同时它在进行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。

垃圾收集器(深入理解 Java 虚拟机笔记)

它是是HotSpot虚拟机运行在客户端模式下的默认新生代收集器。与其他收集器的单线程相比:

  • 它简单高效,且在内存资源受限的环境下,它是所有收集器里额外内存消耗最小的;
  • 对于单核处理器或处理器核心数较少的环境下,由于Serial收集器没有线程交互的开销,单线程收集效率是最高的;
  • 在用户桌面的应用常见以及部分微服务应用中,分配给虚拟机管理的内存一般不会特别大,收集几十兆甚至一两百兆的新生代,且不是频繁发生收集,垃圾收集的停顿时间是可以接受的。因此,Serial收集器对于运行在客户端模式下的虚拟机来说是一个很好的选择。

 

ParNew收集器

ParNew收集器实质上是Serial收集器的多线程并行版本,它的所有控制参数,收集算法,Stop The World,回收策略等都与Serial收集器完全一致,除了Serial收集器,只有它能与CMS收集器配合工作。因此它是不少运行在服务器端模式下的HotSpot虚拟机 首选的新生代收集器。

垃圾收集器(深入理解 Java 虚拟机笔记)

由于存在线程交互的开销,该收集器在通过超线程技术实现的伪双核处理器环境中都不能百分比保证超越Serial收集器。不过,随着可以被使用的处理器核心数量的增加,ParNew收集器对于垃圾收集时系统资源的高效利用是有好处。它默认开启的收集线程数与处理器核心数相同,可以使用-XX:ParallelGCThreads参数来限制垃圾收集的线程数。

 

Parallel Scavenge收集器

Parallel Scavenge收集器是基于标记-复制算法实现且能并行收集的新生代收集器。CMS等收集器关注于尽可能缩短垃圾收集时 用户线程的停顿时间,而Parallel Scavenge收集器目标则是达到一个可控制的吞吐量。

吞吐量是处理器运行用户代码的时间 / 处理器总消耗时间(运行用户代码时间+ 运行垃圾收集时间)

停顿时间越短就越适合需要与用户交互或需要保证服务响应质量的程序,而高吞吐量可以保证最高效率利用处理器资源,尽快完成程序运算任务,主要适合在后台运算而不需要太多交互的分析任务。

Parallel Scavenge收集器提供了两个用于精确控制吞吐量的参数以及一个开关参数:

(1)-XX:MaxGCPauseMillis:控制最大垃圾收集停顿时间

该值是一个大于0 的毫秒数,收集器尽量保证内存回收花费的时间不超过用户设定值。不要觉得该值设定得越小越好,因为垃圾收集停顿时间的缩短是以牺牲吞吐量和新生代空间换来的:因为会导致垃圾收集发生得更加频繁,造成吞吐量下降。

(2)-XX:GCTimeRatio:直接设置吞吐量大小

该值是一个大于0小于100的整数。比如该参数设置为19,则允许的最大垃圾收集时间占用时间的 1 / (1 + 19) = 5%,默认值为99。

(3)-XX:+UseAdaptiveSizePolicy

该参数被**后,就不需要人工指定新生代的大小,Eden与Survivor区 的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数,虚拟机会根据当前系统的运行情况,动态调整这些参数。这种调节方式称为垃圾收集的自适应调节策略。

如果对收集器运作不了解,可以使用Scavenge收集器配合自适应调节策略是一个不错的选择。则需把基本的内存数据设置好(如-Xmx设置最大堆),然后使用-XX:MaxGCPauseMillis参数(更关注最大停顿时间)或XX:GCTimeRatio(更关注吞吐量)参数给虚拟机设立一个优化目标,那具体细节参数的调节工作就 由虚拟机完成了。

自适应调节策略是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。

 

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。该收集器主要提供客户端模式下的HotSpot虚拟机使用;若是在服务端模式下,有两种用途:

  • 在JDK 5以及之前的版本中与Parallel Scavenge收集器搭配使用
  • 作为CMS 收集器发生失败时的后备预案,在并发收集发生Concurrent Mode Failure时使用。

 

Parallel Old收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实现。在注重吞吐量或处理器资源较为稀缺的场合,可以优先考虑Parallel Scavenge加Parallel Old收集器这个组合。

 

CMS收集器

一种以获取最短回收停顿时间为目标的收集器,它是基于标记-清除算法实现的。目前很大一部分的Java应用集中在互联网网站或着基于浏览器的B/S系统的服务端上,这类应用通常较为关注服务的响应速度,希望系统停顿时间尽可能短,而CMS收集器非常符合此类需求。

CMS的运作过程如下:

(1)初始标记

标记一下GC Roots能直接关联到的对象,速度很快;该过程需要”Stop the World“(停止其他所有工作线程)。

(2)并发标记

从GC Roots直接关联的对象开始 遍历整个对象图的过程。过程耗时较长但不停顿用户线程。

(3)重新标记

为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。该阶段的停顿时间通常比初始标记阶段稍长一些,远比并发标记阶段的时间短。该过程需要”Stop the World“。

(4)并发清除

清理删除标记阶段中判定已经死亡的对象。由于不需要移动存活对象,此阶段可以与用户线程同时并发。

它的缺点如下:

(1)CMS收集器对处理器资源非常敏感。在并发阶段,他虽然不会导致用户线程停顿,但会因为占用处理器的计算能力而导致应用程序变慢,降低总吞吐量。

(2)在CMS的并发标记和并发清理阶段,用户线程是在继续运行的,程序在运行自然还会不断产生新的垃圾对象,但这一部分的垃圾是在标记过程结束以后出现的,CMS无法在当时的收集中处理掉,只能等待下一次的收集;此外,由于用户线程是在与性能的,因此CMS收集器必须预留一部分空间给并发收集时的程序继续运作。如果预留的内存无法满足需要,则虚拟机将冻结用户程序的运行,临时启用Serial Old收集器来重新进行老年代的垃圾收集

(3)CMS基于标记-清除的算法,意味着收集结束时 会有大量空间碎片产生,这给大对象分配带来了麻烦,即出现老年代还有很多剩余空间,但无法找到足够大的连续空间来分配当前对象,而不得不提前触发一次Full GC(收集整个Java堆和方法区的垃圾收集)的情况

 

G1收集器

它是服务端模式下的默认垃圾收集器,是作为CMS收集器的替代者和继承人。

它具备如下特点:

(1)G1能充分多CPU,多核环境下,使用多个CPU来缩短”Stop The World“停顿时间。其他收集器需要停止其他所有工作线程,执行GC操作,而G1收集器可以通过并发的方式让Java程序继续执行

(2)G1虽然遵循分代收集理论设计,但它堆内存的布局和其他收集器有明显差异:G1不再坚持固定大小和固定数量的分代区域划分,而是把连续的Java堆划分为多个大小相对的独立区域Region,每一个区域都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。收集器能根据扮演不同角色的区域来采用不同的策略区处理。

(3)G1从整体来看是基于标记-整理实现的,从两个Region之间看是基于 复制 算法实现的。这意味G1运行期间不会产生内存碎片,收集后能提供规则的可用内存。

(4)降低停顿时间是G1和CMS共同关注点,但G1除了降低停顿外,还能建立可预测的停顿时间模型,让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC的时间不得超过N毫秒。之所以能建立这样的模型,是因为G1收集器可以有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值,在后台维护一个优先队列,每次根据允许的收集时间,优先回收佳置最大的Region。这保证了G1收集器在有限的时间内,可以获取尽可能高的收集效率。

G1把Java堆分为多个Region,但Region不是孤立的。虚拟机为G1中每个Region维护了一个与之对应的Remembered Set。当虚拟机发现程序在对Reference类型的数据进行写操作时,会产生暂时中断写操作,检查Reference引用的对象是否出不同的Region中(在分代的角度就是检查老年代中的对象引用了新生代中的对象),若是,把相关引用信息记录到被引用对象所示的Region的Remembered Set中。这样当进行收集时,在GC根节点的枚举范围内添加Remembered Set,便可确保在不扫描全堆的情况下不会有遗漏。

G1收集器的运作过程大致如下:

(1)初始标记

标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的。

(2)初始标记

从GC Roots开始 对堆中对象进行可达性分析,找出要回收的对象。此阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成后,还需要重新处理SATB记录下的在并发时有引用变动的对象。

(3)最终标记

对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。

(4)筛选回收

负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以*选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。此阶段需要暂停用户线程,由多条收集器线程并行完成