垃圾收集器与内存分配策略

        说起垃圾收集(Garbage Collection,下文简称GC),有不少人把这项技术当作Java语言的伴生产 物。事实上,垃圾收集的历史远远比Java久远,在1960年诞生于麻省理工学院的Lisp是第一门开始使 用内存动态分配和垃圾收集技术的语言。当Lisp还在胚胎时期时,其作者John McCarthy就思考过垃圾 收集需要完成的三件事情:

  • 哪些需要回收?
  • 什么时候回收?
  • 怎么回收?

下面我们就来分析在JVM中这三个问题是怎么解决的

对象以死?

        在堆里面存放着Java世界中几乎所有的对象实例,垃圾收集器在对堆进行回收前,第一件事情就 是要确定这些对象之中哪些还“存活”着,哪些已经“死去”。下面介绍两种方法,用来判断对象的状态:

引用计数器

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

        客观来说,这是一个不错的设计,实现简单,判断效率也很高,业界也有许多依靠这个来实现的著名案例,但是,在Java 领域,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存,主要原因是,这个看似简单 的算法有很多例外情况要考虑,必须要配合大量额外处理才能保证正确地工作,譬如单纯的引用计数 就很难解决对象之间相互循环引用的问题。

        例如,对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已 经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也 就无法回收它们。

可达性分析算法

        当前主流的商用高级语言的内存管理子系统(Java,c#等)都是基于可达性分析算法来进行对象状态判断,算法基本思想是通过一系列GCRoot对象,遍历其引用链,如果没有在这些引用链,那就代表该对象死亡。

在Java中,固定可作为GCRoot的对象有:

  • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
  • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
  • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized关键字)持有的对象。
  • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

除了这些固定的GC Roots集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不 同,还可以有其他对象“临时性”地加入,共同构成完整GC Roots集合。譬如后文将会提到的分代收集 和 局部回收(Partial GC),如果只针对Java堆中某一块区域发起垃圾收集时(如最典型的只针对新生代的垃圾收集),必须考虑到内存区域是虚拟机自己的实现细节(在用户视角里任何内存区域都是不 可见的),更不是孤立封闭的,所以某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,如果不做一些措施的话这些对象将会被错误的回收,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性。

生存还是死亡?

        即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓 刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:

  • 如果对象在进行可达性分析后发现没 有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是 否有必要执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执行”。
  • 如果这个对象被判定为确有必要执行finalize()方法,那么该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。这里所说的“执行”是指虚拟机会触发这个方法开始运行,但并不承诺一定会等待它运行结束,这样做的原因是防止执行时间过长。finalize()方法是对 象逃脱死亡命运的最后一次机会,稍后收集器将对F-Queue中的对象进行第二次小规模的标记,在这期间重新与引用链上的任何一个对象建立关联即可成功复活,那在第二次标记时它将被移出“即将回收”的集 合。

建议:finalize()不推荐使用,通常情况下try-finally做的更加好

回收方法区

        上面所说的仅仅的对堆的垃圾对象回收,那么方法区会不会进行垃圾回收了,这主要看使用的垃圾收集器,事实上也确实有未实现或未能完整 实现方法区类型卸载的收集器存在(如JDK 11时期的ZGC收集器就不支持类卸载),主要是因为方法区的回收比较苛刻。

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

         要判定一个类型是否属于“不再被使用的类”的条件。需要同时满足下面三个条件:

  • 该类已经没有任何实例对象
  • 加载该类的类加载器已经被回收
  • 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方 法。

满足了上述三个条件仅仅是被允许回收,并不代表一定会回收,具体还得看各自的实现

垃圾收集算法

        在说完哪些对象需要回收后,我们在来聊聊怎么回收。这里只重点介绍分代收集理论和几种算法思想及其发展过程。

分代收集理论

        分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分 代假说之上:

  • 弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。
  • 强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消 亡。

        这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储。将朝生夕死的对象放一起,老不死的放一起,这样就可以避免每次清理全堆扫描,也可以提高效率。

        在对Java堆进行划分后,垃圾收集器才可以每次只回收其中某一个或者某些部分的区域 ——因而才有了“Minor GC”“Major GC”“Full GC”这样的回收类型的划分;也能对每个区域采用特定的算法,因此引申出了具有针对性的垃圾回收算法:“标记-复制算法”、“标记-清除算法”、“标记-整理算法”等。

        当然,解决一个问题必然会出现新的问题,分代理论就会产生一个分代引用的问题,假设一次对新生代的GC,新生代中的对象是完全有可 能被老年代所引用的,如果不采取其他措施,直接遍历老年代,效率将会大大降低。

        因此,为了避免不必要的扫描,新增一个全局的数据结构(该结构被称 为“记忆集”,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会 存在跨代引用,在进行MinorGC的将这个集合也加入到GCRoot中。这种方式虽然可以解决分代引用,但是也还是会找出一些额外的开始,对象的引用变更都需要维护记录数 据的正确性。

标记-清除算法

        最早出现也是最基础的垃圾收集算法是“标记-清除”(Mark-Sweep)算法,算法分为“标记”和“清除”两个阶段,后续的收集算法大多都是以标记-清除算法为基础,对其 缺点进行改进而得到的。它的主要缺点有两个:第一个是执行效率不稳定,第二个是内存空间的碎片化问题。

标记-复制算法

        将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这种算法将会产生大量的内存间复制的开销,可用内存缩小为了原来的一半。

        现在的商用Java虚拟机大多都优先采用了这种收集算法去回收新生代,只是不需要按照1∶1的比例来划分新生代的内存空间。因此对复制算法进行优化把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾搜集时,将Eden和Survivor中仍 然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1。

        当然谁也不能保证一次MinorGC后存活的对象一定比10%小,所以当存活对象大于10%时,将会通过担保机制,将这些对象存放到其他区域(通常是老年区)。

标记-整理算法

        该算法是针对老年代对象的存亡特征提出的,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可 回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。

垃圾收集器

        上面介绍了这么多垃圾回收算法,现在介绍一些经典的垃圾收集器,下面这张图简单的概括了哪些收集器以及其作用的区域。

两个收集器之间有连线,代表可以搭配使用。

Serial收集器

        Serial收集器是最基础、历史最悠久的收集器。这个收集器是一个单线程工作的收集器,但它的“单线 程”的意义并不仅仅是说明它只会使用一个处理器或一条收集线程去完成垃圾收集工作,更重要的是强 调在它进行垃圾收集时,必须暂停其他所有工作线程。

ParNew收集器

        ParNew收集器实质上是Serial收集器的多线程并行版本,除了同时使用多条线程进行垃圾收集之 外,其余的行为完全一致。

Parallel Scavenge收集器

        也是一款新生代垃圾收集器,同样基于标记-复制算法,它与ParNew很相似,但是它的关注点和其他收集器不同,它关注的是吞吐量

        它提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间 的-XX:MaxGCPauseMillis参数以及直接设置吞吐量大小的-XX:GCTimeRatio参数。

        自适应调节策略是Parallel Scavenge收集器区别于ParNew收集器的一个重要特性。它能动态的管理新时代的大小,对象晋升条件来满足吞吐量要求。

Serial Old收集器

        Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法

Parallel Old收集器

        Parallel Old是Parallel Scavenge收集器的老年代版本,支持多线程并发收集,基于标记-整理算法实 现。

在此之前,新生代的Parallel Scavenge收集器一直处于相 当尴尬的状态,原因是如果新生代选择了Parallel Scavenge收集器,老年代除了Serial Old(PS MarkSweep)收集器以外别无选择,其他表现良好的老年代收集器,如CMS无法与它配合工作。由于 老年代Serial Old收集器在服务端应用性能上的“拖累”,使用Parallel Scavenge收集器也未必能在整体上 获得吞吐量最大化的效果。同样,由于单线程的老年代收集中无法充分利用服务器多处理器的并行处 理能力,在老年代内存空间很大而且硬件规格比较高级的运行环境中,这种组合的总吞吐量甚至不一 定比ParNew加CMS的组合来得优秀。

CMS收集器

        CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于标记-清除算法实现的,它的运作 过程相对于前面几种收集器来说要更复杂一些,整个过程分为四个步骤,包括:

  • 初始标记:仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。
  • 并发标记:从GC Roots的直接关联对象开始遍历整个对 象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。
  • 重新标记:修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的 标记记录
  • 并发清除:清理删除掉标记阶段判断的已经死亡的 对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的

        其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

当然,CMS也是有一些缺点的:

  • CMS收集器对处理器资源非常敏感。因为很多地方都是并发,所以可能会导致吞吐量变低。
  • 无法清除浮动垃圾。因为是并发清除的,在清除期间可能出现新垃圾导致不能及时清理。
  • 因为使用的是标记-清除算法,所以会出现内存碎片问题。

Garbage First收集器(G1)

        它开创了收集 器面向局部收集的设计思路和基于Region的内存布局形式。在G1收集器出现之前的所有 其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代(Minor GC),要么就是整个老 年代(Major GC),再要么就是整个Java堆(Full GC)。虽然G1仍然遵循分代理论,但是不再坚持固定大小以及固定数量的 分代区域划分,而是把连续的Java堆划分为多个大小相等的独立区域(Region),每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。

        Region中还有一类特殊的Humongous区域,专门用来存储大对象。G1将这个区域当成老年代来看待。

        G1的这种设计能建立可预测的停顿时间模型。每次收集到的内存空间都是Region大小的整数倍,这样可以有计划地避免 在整个Java堆中进行全区域的垃圾收集。

        G1的这个设计思路不难想到,但是用来将近10年才有可商用的诞生。主要原因是还有其他许多细节需要进行处理,比如:

  • 分代引用这么解决?

        这里我们知道可以使用记忆集避免全堆作为GC Roots扫描,但在G1收集器上记 忆集的应用其实要复杂很多,它的每个Region都维护有自己的记忆集,这些记忆集会记录下别的Region 指向自己的指针,并标记这些指针分别在哪些卡页的范围之内。所以G1的维护成本会提示。

  • 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?

        CMS收集器采用增量更新算法实现,而G1 收集器则是通过原始快照(SATB)算法来实现的。此外,G1为每一个Region设 计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过 程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在 这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。

        G1的回收过程如下:

        G1回收阶段不是并发的

初始标记(Initial Marking):仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的Region中分配新对象。这个阶段需要 停顿线程,但耗时很短,而且是借用进行Minor GC的时候同步完成的,所以G1收集器在这个阶段实际 并没有额外的停顿。

并发标记(Concurrent Marking):从GC Root开始对堆中对象进行可达性分析,递归扫描整个堆 里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理SATB记录下的在并发时有引用变动的对象。

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

筛选回收(Live Data Counting and Evacuation):负责更新Region的统计数据,对各个Region的回 收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个Region 构成回收集,然后把决定回收的那一部分Region的存活对象复制到空的Region中,再清理掉整个旧 Region的全部空间。这里的操作涉及存活对象的移动,是必须暂停用户线程,由多条收集器线程并行 完成的。

G1与CMS的比较

        首先最显著的区别就是G1可以指定最大停顿时间分Region的内存布局按收益动态确定回收集

        从算法的角度看,CMS使用的标记-清除算法,G1从整体来看是基于“标记-整理”算法实现的收集器,但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现,所以没有内存碎片问题。

        G1无论是为了垃圾收集产生的内存占用还是程序运行时的额外执行负载都要比CMS要高(主要体现在卡表的维护上)。

低延迟垃圾收集器

ZGC

        简单概括一下就是:ZGC收集器是一款基于Region内存布局的,(暂时) 不设分代的,使用了读屏障染色指针内存多重映射等技术来实现可并发的标记-整理算法的,以低 延迟为首要目标的一款垃圾收集器。下面来分别介绍这些技术和一些细节。

动态Region

        首先就是Region的堆内存布局,ZGC的Region的堆内存布局和其他的有一些区别,ZGC的Region具有动态性——动态创建和销毁,以及动态的区域容量大小。ZGC的 Region可以具有大、中、小三类容量:

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。
  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对 象。
  • 大型Region(Large Region):容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置 4MB或以上的大对象。

        

并发整理算法

        ZGC收集器有一个标志性的设计是它采用的染色指针技术,这里不详细介绍了(有点复杂),建议去看相关书籍(可以看深入理解java虚拟机)

垃圾收集器的选择

  • 应用程序的主要关注点是什么?如果是数据分析、科学计算类的任务,目标是能尽快算出结果, 那吞吐量就是主要关注点;如果是SLA应用,那停顿时间直接影响服务质量,严重的甚至会导致事务 超时,这样延迟就是主要关注点;而如果是客户端应用或者嵌入式应用,那垃圾收集的内存占用则是 不可忽视的。
  • 运行应用的基础设施如何?譬如硬件规格,要涉及的系统架构是x86-32/64、SPARC还是 ARM/Aarch64;处理器的数量多少,分配内存的大小;选择的操作系统是Linux、Solaris还是Windows 等。
  • 使用JDK的发行商是什么?版本号是多少?是ZingJDK/Zulu、OracleJDK、Open-JDK、OpenJ9抑 或是其他公司的发行版?该JDK对应了《Java虚拟机规范》的哪个版本?

举个例子:

  • 如果你有充足的预算但没有太多调优经验,那么一套带商业技术支持的专有硬件或者软件解决方
    案是不错的选择,Azul公司以前主推的Vega系统和现在主推的Zing VM是这方面的代表,这样你就可以使用传说中的C4收集器了。
  • 如果你虽然没有足够预算去使用商业解决方案,但能够掌控软硬件型号,使用较新的版本,同时又特别注重延迟,那ZGC很值得尝试。
  • 如果你对还处于实验状态的收集器的稳定性有所顾虑,或者应用必须运行在Win-dows操作系统下,那ZGC就无缘了,试试Shenandoah吧。
  • 如果你接手的是遗留系统,软硬件基础设施和JDK版本都比较落后,那就根据内存规模衡量一
    下,对于大概4GB到6GB以下的堆内存,CMS一般能处理得比较好,而对于更大的堆内存,可重点考察一下G1。

当然,切不可纸上谈兵,一切以实际出发。

内存分配

        上面介绍的都是对象的回收,下面介绍一下对象的内存分配情况。

对象优先在Eden区分配

        大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。在Minor GC的时候,发现整理出来的对象无法放入Survivor空间,就会通过分配担保机制进入老年代。(注意:这里是把Survivor无法容纳的对象直接送入老年代)

大对象直接进入老年代

        大对象就是指需要大量连续内存空间的Java对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。大对象的产生对虚拟机是不友好的,特别是朝生夕死的大对象,因为大对象它容易造成有空间但是还是触发垃圾回收和高频的内存复制开销。所以一般大对象直接进入老年代,通过-XX:PretenureSizeThreshold参数来指定多大为大对象。

长期存活的对象将进入老年代

        HotSpot虚拟机的收集器大部分是根据分代理论来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中,对象经历过一次MinorGC就加1,达到15就晋升老年代。

动态对象年龄判定

        为了能更好地适应不同程序的内存状况,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

空间分配担保

        在进行Minor GC之前,首先会判断老年代的大小是否大于新生代所有对象总空间,如果大于则直接进行Minor GC,如果小于,则会查看是否设置了允许担保失败 (-XX:HandlePromotionFailure参数),如果允许,则会继续检查往期MinorGC晋升的对象的平均大小是否小于老年代大小,如果小则进行Minor GC,否则进行Full GC

总结

        上面介绍了对象的回收和分配,垃圾回收器的分类和功能。下面会继续介绍虚拟机的相关知识,比如class文件,虚拟机工具。

        

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值