JVM之垃圾回收机制以及垃圾回收器(基于《深入理解Java虚拟机》之第三章垃圾收集器与内存分配策略?(上)

本文详细介绍了Java垃圾回收机制,包括判断哪些内存需要回收、何时回收以及如何回收。通过可达性分析算法确定对象生死,探讨引用计数、标记-清除、标记-复制和标记-整理四种算法。讲解了分代收集思想,以及如何处理跨代引用问题,如记忆集和卡表。最后,分析了并发标记中如何避免对象消失问题,如增量更新和原始快照策略。此外,提到了火车算法作为一种补充,允许在限定时间内进行小区域的垃圾回收。
摘要由CSDN通过智能技术生成

sad 考试终于落下帷幕,总有人说不出去放松放松嘛,但是我觉得考完就是最大的放松呢,接下来就要准备组会文献的讲解还有更新博客,总结虚拟机的相关内容,而我的丹丹学妹会陪着我的~
sad而我们本篇文章主要是给丹丹学妹讲解垃圾回收机制以及垃圾回收器,而垃圾回收机制我们要想一下到底要如何做(就像我们做一件事情,要确定是具体做什么事,哪里做,什么时候做,如何做)?

sasadasdsddss①、哪些垃圾(内存)需要回收呢?

ssadasdasadss②、什么时候回收呢?

saasdasdsadss③、以什么方式回收呢?

因此我们大致分为三部分分别介绍:


sasadsadasdasdasdasdsadsdaddss①、哪些内存(垃圾)需要回收呢?

  • 根据我们上一篇博客讲解的Java内存区域到底包括什么可以得知,所有的区域都应该回收,如果不回收,早晚都会溢出
    在这里插入图片描述
  • 通过Java内存运行时区域的图,我们可以将其按线程是否私有分别进行垃圾回收:
    saass①、程序计数器、虚拟机栈、本地方法栈:都是线程私有,随着线程的生而生,线程死而死。
    sa【注】: 比如虚拟机栈中的每个栈帧都随着方法的进入和退出执行入栈和出栈操作,并且每一个栈帧中分配多少内存基本上是在类结构确定下来就已经知道了(当然在运行时JIT可以相应优化,但是大体上不会变),所以我们不用过多考虑垃圾回收问题,因为我i们会*在方法结束或线程结束的时候,内存就跟着回收了。
    saass②、堆、方法区:都是线程共享的,所以不会和栈、程序计数器一样,随着方法或线程的结束而回收,因此堆和方法区的回收都有着不确定性
    sa【注】: 比如一个接口的多个实现类需要的内存可能会不一样,一个方法所执行的不同条件分支所需要的内存也可能不一样,因此,只有处于运行期间,我们才能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的,所以这部分是我们垃圾回收的重点!!!(这也是为什么Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙)

接下来我们就来看堆和方法区中的哪些数据(内存)是需要回收的(先看堆)?

  • 堆里面几乎存放着所有的对象实例,而垃圾回收机器在对堆进行回收时,首先要做的就是确定这些对象值中哪些是“活着”的,哪些是"死去"的。我们要回收的,就是“死去”的对象。

  • 如何判断哪些对象是"死去"的(两种算法)?
    saass①、引用计数算法 :在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可能再被使用的。
    saasass优点:原理简单,判定效率高;
    saasass缺点:很难解决对象之间相互循环引用的问题;点击!
    saass②、可达性分析算法:通过“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
    ssa【注】:固定可作为GC Roots的对象包括以下几种:
    s1231231sa虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
    s1231231sa⒉方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量;
    s1231231sa⒊在方法区中常量引用的对象
    s1231231sa⒋本地方法栈中JNI(即通常所说的Native方法)引用的对象
    s1231231sa⒌所有被同步锁(synchronized关键字)持有的对象
    s1231231sa⒍反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等;
    s1231231sa⒎可以有其他对象“临时性”地加入,因为某个区域里的对象完全有可能被位于堆中其他区域的对象所引用,这时候就需要将这些关联区域的对象也一并加入GC Roots集合中去,才能保证可达性分析的正确性;
    在这里插入图片描述

  • 无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可达,我们发现都有一个共有的名词:“引用”:
    saass①、在JDK 1.2之前,Java里面的引用表示:reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用;
    ssa【注】: 但是这种定义太过狭隘,因为如果一个对象,虽然在当前未被引用,但是也不一定要被回收,因为它有可能在这之后经常用到,因此针对于此我们对其“引用”这个概念进行扩充,也就是②;
    saass②、在JDK 1.2之后,将引用分为强引用、软引用、弱引用和虚引用4种;
    ssa【注】:
    s1231231sa⒈强引用:是最传统的“引用”的定义,在程序中普遍存在,比如用new创建的对象,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
    s1231231sa⒉软引用: 用来描述一些还有用,但非必须的对象,就是在发生内存溢出前,将软引用关联的对象也列进回收范围进行第二次GC,如果这次回收还没有足够的内存,才会抛出内存溢出异常。(就是先进行一次GC后发现还会溢出,则把软引用的对象也加入到GC范围,进行第二次GC)
    s1231231sa⒊弱引用: 是用来描述那些非必须对象,被弱引用关联的对象只能生存到下一次垃圾收集发生为止,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
    s1231231sa⒋虚引用: 一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的只是为了能在这个对象被收集器回收时收到一个 系统通知

  • 分析到这里,丹丹学妹突然问我一个问题: giegie,刚刚你说了判断对象是否“死去”的两种方法, 那比如在可达性算法中,当我们将其判断为“不可达”的对象,就可以判定其能进行GC了嘛?
    答:当然不是的,法律都有缓刑,那我们GC机制当然也有啦,其实要真正判定一个对象“死亡”,至少要经过两次标定过程的,大致过程如下:
    saass①、首先,如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记;
    saass②、标记后根据 finalize() 方法来判断是否需要进行第二次标记;
    saass③、如果没有使用过finalize()就进入 F-Q队列 中等待虚拟机建立Finalizer线程去执行它们的finalize()方法(如果对象没有重写这个方法或者已经被虚拟机执行了相应的finalize()方法就不用缓刑(不用进入F-Q队列)了,直接死刑!),如果在执行finalize()方法中成功与引用链上的任何一个对象建立关联,那么恭喜你,不用执行死刑(GC)了!!!
    saass注意:Finalizer线程是虚拟机自动建立的、低调度优先级的线程;并且该线程虽然会执行F-Q队列中的各个对象的finalize()方法,但是不代表会等待它执行完,毕竟如果这个方法执行特别缓慢,或者死循环等,会使整个内存回收系统崩溃;最后,finalize() 能做的所有工作,使用try-finally或者其他方式都可以做得更好、更及时,所以这个方法大家可以不用使用。
    saass总结:对象可以在被GC时自我拯救,不过这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次。

  • 丹丹学妹又问道:堆的GC讲完了,那方法区的GC呢?
    答:我心想着啥急呀,听我慢慢说哎!

  • 方法区的垃圾收集主要回收两部分内容:①、废弃的常量 ②、不再使用的类
    【注】:
    sss⒈对于常量是否”废弃“是很简单的, 我们假设一个字符串“java”曾经进入常量池中,但是已经没有任何字符串对象引用常量池中的“java”常量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。(JDK7以后,字符串常量池就移到了堆里)
    sss⒉对于类是否”不再被使用“的判断要相对复杂一些,需要同时满足三个条件:
    ss22s⑴Java堆中不存在该类及其子类的实例;
    ss22s⑵加载该类的类加载器已经被回收(该条件一般很难达成);
    ss22s⑶该类对应的java.lang.Class对象没有在任何地方被引用(无法通过反射反应该类);
    s22s注意:当满足上述三个条件时,仅仅是被同意回收,而是否需要对其回收,虚拟机提供了-Xnoclassgc参数进行控制;
    s22s常识:在大量使用反射、动态代理、CGLib等字节码框架,动态生成JSP、以及OSGi这类频繁自定义类加载器的场景中,通常都需要Java虚拟机具备类型卸载的能力,以保证不会对方法区造成过大的内存压力。


sasadaq12313 123123213123sdsddss③、以什么方式回收呢?

  • 说到回收方式,其实要具体到不同的虚拟机,不同的垃圾收集器,因此在这里主要看其思想,方式----》以不同的算法实现,我们先学其思想;

  • 从如何判定对象消亡的角度出发,垃圾收集算法可以分成两大类:
    sasadsadasdsadaq12313 12①、引用计数式垃圾收集 adsdad2 ②、追踪式垃圾收集
    【注】: 由于引用计数法我们一般不用,所以我们主要讨论以可达性分析为基础的追踪式垃圾收集相关的算法;

  • 当前商业虚拟机的垃圾收集器,大多数都遵循了 “分代收集” 的理论进行设计(分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为 新生代和老年代两个区域),它建立在三个分代假说之上:
    sasa①、弱分代假说:绝大多数对象都是朝生夕灭的;
    sasa②、强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡;
    sasa③、跨代引用假说:跨代引用相对于同代引用来说仅占极少数,这也是分代收集的难点
    ss 【注】:
    sasa ⒈常用的垃圾收集器的一致设计原则:根据三个分代假说理论,收集器将Java堆划分出不同的区域,然后将回收对象依据其年龄(对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。垃圾收集器每次只回收其中一个或某些区域,并且我们可以根据不同区域设计与该区域存储对象存亡特征相匹配的GC算法,这样就好处就是提高了GC效率。
    sasa 跨代引用占少数的原因:如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时跨代引用也随即被消除了。
    sasa ⒊针对跨代引用,我们需要怎么做呢?我们只需要在新生代 (通常能单独发生收集行为的只是新生代) 建立一个全新的数据结构-—“记忆集”,这个结构把老年代划分成若干小块,标记出老年代的哪一块内存存在跨代引用。当再发生年轻代GC时,只有包含了跨代引用的小块内存里的对象才被加入到GC Roots进行扫描,虽然记忆集也需要有额外的开销:需要维护记录数据的正确性,但是这样相对于扫描整个老年代也是提高了效率。

  • 有了上述相关知识,我们进入正题,基于上述设计原则,我们分析三种算法:
    sadaq12asdasdas12 ①、标记-清除算法 sadsad ②、标记-复制算法 sadsad ③、标记-整理算法

标记-清除算法:

  • 顾名思义,算法分成两个阶段: ①、标记 ②、 清除
    • 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。
  • 两大缺点:
    sasa⒈ 执行效率不稳定, 如果此GC区域存在大量对象需要被GC,那么需要执行大量的标记和清除动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低(说明不适应新生代区域的GC)。
    sasa内存空间的碎片化 问题, 标记-清除之后会产生大量不连续的内存碎片,当我们对较大对象(比如数组)无法分配足够大的连续连续内存时,就不得不提前触发一次GC。
    在这里插入图片描述
  • 此方法是最基础的收集算法,因为后续的收集算法大多都是以标记-清除算法为基础,对其缺点进行改进而得到的。

标记-复制算法:(适用新生代、主流,优化)

  • 由来: 这个算法是在标记-清除算法的基础上解决其执行效率低以及产生空间碎片而提出的,可以称其为”半区复制“。顾名思义,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
    在这里插入图片描述
  • 优点:实现简单,运行高效,只要移动堆顶指针,按顺序分配即可,不用考虑有空间碎片的复杂情况。
  • 缺点:如果大部分对象是存活的,那么复制就会产生很大的开销,也就是说适用于新生代GC,不过内存缩小为了原来的一半,造成了空间浪费。
  • 优化:
    sasa①、优化场景:现在的商用Java虚拟机大多都优先采用了这种标记-复制算法去回收新生代,并且对这种算法进行了优化。HotSpot虚拟机的Serial、ParNew等新生代收集器均采用了这种优化的策略来设计新生代的内存布局。
    sasa②、优化思想:因为针对不同的区域需要使用不同的算法GC,而对于新生代的大部分对象都是一次性的,所以没必要对新生代一半一半分,我们只要留下一点空间来当作存储存活对象的区域即可,而如果留下的区域不够用的时候我们就把其放入到老年代中,毕竟新生代不够用了。
    sasa③、优化的具体实现:把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生GC时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间,当Survivor空间不足以容纳一次新生代GC之后存活的对象时,就需要依赖其他内存区域(实际上大多就是老年代)进行分配担保(我们把这种分配担保称为"逃生门"设计)。

标记-整理算法:(适用 老年代)

  • 由来:标记-复制算法在对象存活率较高时就要进行较多的复制操作,所以效率将会降低。 更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法,针对老年代对象的存亡特征,提出了“标记-整理”算法。
  • 对比:“标记-整理”算法可以看成是对”标记-清除“算法的优化,标记完后排列,然后集中清除需要回收的,也就是分三步:
    sasaasasdasdasddasdsadsadas①、标记 ②、排列 ③、清除
    在这里插入图片描述
  • 分析:
    sasa ⒈ 在老年区这种每次GC都有大量对象存活的区域,如果使用"标记-整理"算法意味着要移动大量存活的对象并更新引用这些对象的所有地方,这其实是一种极为负重的操作,并且这种操作需要全程暂停用户应用程序才能进行,也就是"Stop The World"(STW);
    sasa⒉但是如果我们不考虑移动这些对象,还是按”标记-清除“算法那样,标记完直接清除,那样产生的空间碎片化问题会需要更复杂的内存分配器来解决。
    sasa【碎片化的解决方案】:通过“分区空闲分配链表”来解决内存分配问题(计算机硬盘存储大文件就不要求物理连续的磁盘空间,能够在碎片化的硬盘上存储和访问就是通过硬盘分区表实现的);
    sasa⒊ 通过以上分析,是否移动对象都存在弊端,移动则内存回收时(需要 更新引用存活对象 的所有地方)会更复杂,不移动则内存分配时 (碎片化问题) 会更复杂。从不同的角度看:
    sasaaa ㈠、从垃圾收集的停顿时间来看,不移动对象停顿时间会更短,甚至可以不需要停顿;
    sasasa ㈡ 、从整个程序的吞吐量来看,移动对象会更划算;
    sasasa 总结:"标记-整理"比"标记-清除"算法的吞吐量要高一些,所以因地制宜,看你的收集器侧重用哪方面,如果侧重延迟则选择"标记-清除"算法;如果侧重吞吐量方面,则选择"标记-整理"算法;
  • 优化:"标记-清除"和"标记-整理"算法折中的使用就是先按"标记-清除"算法执行,当造成的空间碎片最大化,已经无法忍受时,我们再进行"标记-整理"算法GC一次

三种方法对比: "标记-复制"的优化算法对应新生代GC最好,”标记-整理“相比”标记-清除“算法更适合老年代,当然具体看你通过什么角度看,如果需要吞吐量,就”标记-整理“算法进行GC,当然也可以将其结合起来使用。


到此,我们已经知道了哪些内存需要回收,以及如何回收,接下来我在总结一些GC细节问题,并以及相关知识,并看看到底什么时候GC?为我们下篇文章介绍各个GC器作铺垫,丹丹学妹好好听哦~

①、(GC Roots枚举问题)在可达性分析中,在GC Roots中查找相应的引用链需要STW吗?

  • 其实这个问题确实是要考虑的,迄今为止,所有收集器在根节点枚举这一步骤时都是必须暂停用户线程的,也就是"STW"。 虽然现在的可达性分析算法中耗时最长的查找引用链的过程也已经可以做到与用户线程一起并发,但 根节点枚举始终还是必须在一个能保障一致性的快照中才得以进行,这是导GC过程必须STW的一个重要原因。

    【一致性】: 这里“一致性”的意思是整个枚举期间执行子系统看起来就像被冻结在某个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断变化的情况,而这也是满足结果准确性的必要条件。

②、(GC Roots枚举问题)在GC Roots中查找相应的引用链时,需要检查所有的引用位置吗?

  • 由于目前主流Java虚拟机使用的都是准确式垃圾收集,所以当STW时,也就是用户线程停顿下来以后,其实并不需要检查所有的引用位置 ,虚拟机是有办法直接得到哪些地方存放着对象引用的。
    比如,在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。一旦类加载动作完成的时候HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来在JIT过程中,也会在特定的位置记录下栈和寄存器里哪些位置是引用。 这样GC器在扫描时就可以直接得知这些信息了,并不需要真正一个不漏地从方法区等GC Roots开始查找。
    在这里插入图片描述
    上图,是HotSpot虚拟机客户端模式下生成的一句本地代码,可以看到在0x026eb7a9处的call指令有OopMap记录,它指明了EBX寄存器和栈中偏移量为16的内存区域中各有一个普通对象指针(Ordinary Object Pointer,OOP)的引用
    【OopMap】:
    sasa ⒈ 在HotSpot中,对象的类型信息里有记录自己的OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。所以从对象开始向外的扫描可以是准确的;这些数据是在类加载过程中计算得到的。
    sasa ⒉可以把oopMap简单理解成是调试信息。 在源代码里面每个变量都是有类型的,但是编译之后的代码就只有变量在栈上的位置了。oopMap就是一个附加的信息 ,告诉你栈上哪个位置本来是个什么东西。 这个信息是在JIT编译时跟机器码一起产生的 。因为只有编译器知道源代码跟产生的代码的对应关系。 每个方法可能会有好几个oopMap,就是根据safepoint把一个方法的代码分成几段,每一段代码一个oopMap,作用域自然也仅限于这一段代码。 循环中引用多个对象,肯定会有多个变量,编译后占据栈上的多个位置。那这段代码的oopMap就会包含多条记录。

③、(OopMap与安全点的关系)在OopMap的协助下,HotSpot虽然可以快速准确的完成GC Roots枚举,但是对其维护也会产生巨大的成本,所以说为每一条指令都生成对应的OopMap是不现实的,那应该怎么做呢?

  • 实际上,HotSpot只是在 "特定的位置"记录这些信息,而这些位置称为安全点。有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。
    【安全点的选定规则】: 安全点的选定既不能太少也不能太多,太少就会让GC器等待时间过长,太多就增大了运行时的内存负荷以及维护成本。因此我们规定,安全位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,只有具有这些功能的指令才会产生安全点。

④、(安全点对应的两种方案)如何在GC发生时让所有线程(这里其实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来?

  • 两种方案选择:⒈主动式中断(主流方式) ⒉抢先式中断
  • 抢先式中断的思想: 不需要线程的执行代码主动去配合,在GC时,系统首先会把所有用户线程中断,如果发现有用户线程没有停在安全点上,就恢复这条线程执行,让它一会再中断直到跑到安全点上(现在几乎没有虚拟机用抢先式中断)。
  • 主动式中断的思想: 当GC需要中断线程的时候,不直接对线程操作,而是设置一个标志位,各个线程执行过程时不停的主动去轮询这个标志位,一旦发现这个标志位为true,就把自己挂在附近的安全点上。
    【注】:标志位和安全点是重合的。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。
    【思考】: 由于轮询操作在代码中会频繁出现,所以我们要让这个操作尽可能高效,HotSpot使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。

⑤、(安全点的局限性)安全点解决了如何停顿用户线程,但是那些处于Sleep状态或Blocked状态的线程无法响应虚拟机的中断请求,所以不可能自己走到安全点去中断挂起,这种情况应该怎么做呢?

  • 我们是通过 安全区域 来解决这种情况的;
  • 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的;
    【安全区域和安全点的关系】:安全区域看作被扩展拉伸了的安全点。
    【安全区域的用法】: 首先进入安全区域要标识,没离开安全区域时要检查虚拟机是否完成了GC Roots枚举,如果完成了就可以离开安全区域了(就是垃圾收集过程中需要暂停其他用户线程的阶段,只要经过了这个阶段就可以离开安全区域了,不一定非要完成GC Roots枚举),否则就一直等待,直到收到可以离开安全区域的信号为止(当线程处于睡眠或阻塞状态之前会进入安全区域?希望大神看到能解答一下)。
    在这里插入图片描述

①——⑤问题总结:在可达性分析中,在GC Roots中查找相应的引用链需要STW,但是没必要检查所有的引用位置,只需要通过在安全点的位置使用OopMap数据结构来看具体引用什么对象即可,不过在使用安全点的时候,要注意两点,其一就是让阻塞或睡眠状态的线程进入安全区域,其二就是我们通过主动式中断的思想来让线程挂在安全点上,其中用到的轮询标志位操作只需要一条汇编指令即可,并且轮询标志位和安全点是重合的。


⑥、(分代收集思想中的跨代引用问题)前面提到了为解决对象跨代引用所带来的问题,GC器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。所有涉及部分区域收集的(Partial GC)行为的GC器都会面临这一问题。那什么是记忆集呢?

  • 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。最简单的实现就是用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。 这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂❌,并且 我们所需的记忆集不需要具体判断是哪一个对象跨代引用,而是判断哪一块非收集区域是否存在有指向收集区域的指针就可以了。所以我们可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本。
    【记录精度】:
    sasa ⒈字长精度:每个记录精确到一个机器字长(一般是8的整数倍,32位或者64位,机器字长是指计算机进行一次整数运算所能处理的二进制数据的位数),这个精度决定了机器访问物理内存地址的指针长度,该字包含跨代指针。
    sas a⒉对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
    sa sa⒊卡精度(“卡表”):每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
    【注】:
    sasaⅠ、 第三种“卡精度”所指的是用一种称为“卡表”的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
    sasaⅡ、 卡表最简单的形式可以只是一个字节数组,该数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”。一般来说,卡页大小都是以2的N次幂的字节数,HotSpot中使用的卡页是2的9次幂,即512字节(之所以使用byte数组而不是bit数组主要是速度上的考量,现代计算机硬件都是最小按字节寻址的,没有直接存储一个bit的指令,所以要用bit的话就不得不多消耗几条shift+mask指令。
    sasaⅢ、 一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。

⑦、(卡表的维护问题)上一个问题讲到了"卡表",也就是记忆集的具体实现,来缩短了跨代引用带来的GC Roots的扫描范围问题,但是这个卡表如何维护(它们何时变脏,谁把它们变脏等)呢?

  • 其实很容易得到答案,当然是有非收集区域引用了收集区域的对象,我们就把非收集区域对应的卡表元素变脏。变脏时间点原则上,应该发生在引用类型字段赋值的那一刻。
    【注】:在引用类型字段赋值的那一刻,我们也要对其区分:
    sasssa⒈ 如果是解释执行的字节码: 虚拟机负责每条字节码的执行,这时候就有充分的介入空间,去(维护)更新卡表;
    sasssa⒉如果是JIT(即时编译)后的代码: 这时候已经是机器码,跳过了字节码,这时候我们就不能以字节码的形式去维护卡表;
    sasssa总结:经过⒈和⒉的分析,我们可以得知,为了统一,我们应该找到一个在机器码层面的手段,把维护卡表的动作放入到每一个赋值操作值中。
  • 这个机器层面来维护卡表的手段是什么呢?写屏障。
    【注】:
    sasssa⒈ 写屏障: 可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障(Post-Write Barrier)。
    sasssa ⒉应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令, 一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新, 就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的。
  • 写屏障带来的问题: 卡表在高并发场景下面临着 “伪共享” 问题。伪共享是处理并发底层细节时一种经常需要考虑的问题CPU的缓存系统中是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
    【举例】:
    sasssa 假设一个CPU的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。
    【解决方法】:
    sasssa ⒈、 为了避免伪共享问题,一种简单的解决方案是:而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏;

    sasssa ⒉、JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断;

⑥、⑦为跨代引用问题。


⑧、思考:在前面我们已经提到当前主流的GC器都是通过可达性分析算法来判断对象是否"存活",并且可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须全程冻结用户线程的运行。然后我们进一步优化 (比如OopMap)了GC Roots枚举,它带来的停顿已经是非常短暂且相对固定(不随堆容量而增长)的了,因此GC枚举这里我们就讨论到这里。那么我们想一想是否还可以在别的方面做一些优化呢?

  • GC Roots枚举的相应优化已经存在,那么找到相应的 GC Root后再怎么往下遍历对象图呢?如何对不"存活"的对象进行标记呢?
    这一步骤的停顿时间就必定会与Java堆容量直接成正比例关系了:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间自然就更长,不过如果我们能消除"标记"这部分停顿的时间,那么收益是系统性的。

⑨、要想消除"标记"这部分停顿的时间,我们就必须要清除这部分时间到底具体指什么,也就是为什么必须在一个能保障一致性的快照上才能进行对象图的遍历?

  • 为了为了能解释清楚这个问题,我们引入 “三色标记” 法来辅助推导遍历过程中遇到的对象是否"存活",进一步说明这个问题。
    【三色标记】:
    sasssa ⒈白色:表示对象尚未被垃圾收集器访问过。 若在分析结束的阶段,仍然是白色的对象,即代表不可达。
    sasssa ⒉黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
    sasssa ⒊灰色: 表示对象已经被GC器访问过,但这个对象上至少存在一个引用还没有被扫描过(可以理解为半成品)。
    sasssa总结:关于可达性分析的扫描过程,相当于对象图上以灰色为波峰的波纹从黑向白推进的过程
  • 关于可达性分析的扫描过程,相当于对象图上以灰色为波峰的波纹从黑向白推进的过程,如果用户线程此时是冻结的,只有GC器线程在工作,那不会有任何问题,但如果用户线程与GC器是并发工作呢? 这种情况可能会出现两种错误结果:
    sasssa⒈把原本消亡的对象错误标记为存活;
    sasssa⒉把原本存活的对象错误标记为已消亡(很严重的错误);
    在这里插入图片描述
  • 通过图可以得知,同时满足两个条件,会产生“对象消失”的问题,即原本应该是黑色的对象被误标为白色:
    sasssa⒈赋值器插入了一条或多条从黑色对象到白色对象的新引用;
    sasssa⒉赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。
    ·

    【注】:如果我们要解决并发扫描时的对象消失问题,只需破坏这两个条件的任意一个即可:增量更新 和原始快照 。
  • 增量更新:破坏的是第一个条件: 当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。
  • 原始快照:破坏的是第二个条件, 当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次 (其实可以理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索)。
  • 无论是对引用关系记录的插入还是删除,虚拟机的记录操作都是通过写屏障实现的。在HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS是基于增量更新来做并发标记的,G1、Shenandoah则是用原始快照来实现。

sad 到此差不多就结束啦,GC回收机制的理论相关知识已经介绍完了,下一篇再介绍不同GC器的具体实现!


asdasdasdsadasdasdasdasddsa2021.7.6:补充一个GC算法:火车算法 可看!

  • 火车算法也称列车算法,是一种更彻底的分区域处理收集算法,是对分代收集算法的一个有力补充。
  • 算法思路:
    1231 在火车算法中,内存被分为块,多个块组成一个集合。 为了形象化,一节车厢代表一个块,一列火车代表一个集合.火车与车箱都按创建顺序标号,每个车厢大小相等,但每个火车包含的车厢数不一定相等; 每节车箱有一个 记忆集合,而 每辆火车的记忆集合是它所有车厢记忆集合的总和;记忆集合由指向车箱中对象的引用组成,这些引用来自同一辆火车中序号较高的车箱中的对象,以及序号较高中的对象;
  • GC收集是 以车厢为单位,整体算法流程如下:
    1asdasd231⒈选择 标号最小的火车;
    1asdasd231⒉如果火车的 记忆集合是空的, 释放整列火车并终止, 否则进行第三步操作;
    1asdasd231⒊选择火车中 标号最小的车厢
    1asdasd231⒋对于 车厢记忆集合的每个元素进行判别
    1asdadasdsd1 ①、如果它是一个被根引用引用的对象, 那么, 将拷贝到一列新的火车中去;
    1asdadasdsd1 ②、如果是一个被其它火车的对象指向的对象, 那么, 将它拷贝到这个指向它的火车中去.;
    1asdadasds31 ③、假设有一些对象已经被保留下来了, 那么通过这些对象可以触及到的对象将会被拷贝到同一列火车中去;
    1asdaasss 231④、 如果一个对象被来自多个火车的对象引用, 那么它可以被拷贝到任意一个火车去;这个步骤中, 有必要对受影响的引用集合进行相应地更新;
    1asdasd231释放车厢并且终止;
    [注]:收集过程会删除一些空车箱和空车,当需要的时候也会创建一些车箱和火车,更多信息请参考:《编译原理》第二版7.75"列车算法"
  • 执行过程如下图:
    在这里插入图片描述
  • 优点: 可以在成熟对象空间提供限定时间的渐近收集;而不需要每次都进行一个大区域的垃圾回收过程;即可以控制垃圾回收的时间,在 指定时间内进行一些小区域的回收;
  • 缺点: 实现较为复杂,如采用类似的算法的G1收集器在JDK7才实现;一些场景下可能性价比不高;
  • 应用场景: JDK7后HotSpot虚拟机 G1收集器采用类似的算法,能建立可预测的停顿时间模型
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值