【JAVA 学习笔记】java垃圾回收机制 探究

前言

文章仅是笔者个人的学习笔记,存在一些只有笔者个人能看到的用词或者描述,如果有不明确的地方,欢迎留言,理性讨论。

一、概述

  • 为什么要了解垃圾回收?
    • 当需要排查各种内存 溢出、内存泄漏问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对这些“自动 化”的技术实施必要的监控和调节
  • 垃圾回收主要关注的内存区域?
    • 程序计数器、虚拟机栈、本地方法栈:
      • 3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。
      • 因此这几个区域的内存分配和回收都具备确定性, 在这几个区域内就不需要过多考虑如何回收的问题,当方法结束或者线程结束时,内存自然就跟随着 回收了。
    • Java堆和方法区:
      • 只有处于运行期间,我们才 能知道程序究竟会创建哪些对象,创建多少个对象,这部分内存的分配和回收是动态的。
      • 垃圾收集器 所关注的正是这部分内存该如何管理

二、对象已死?

1.引用计数法
  • 在对象中添加一个引用计数器,每当有一个地方 引用它时,计数器值就加一;当引用失效时,计数器值就减一;任何时刻计数器为零的对象就是不可 能再被使用的。
  • 引用计数算法(Reference Counting)虽然占用了一些额外的内存空间来进行计数,但 它的原理简单,判定效率也很高,在大多数情况下它都是一个不错的算法。
  • 但是它有太多需要额外处理的问题了,例如:循环引用问题

2.可达性分析算法
  • 算法的基本思路就是通过 一系列称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索。搜索过程所走过的路径称为“引用链”(Reference Chain)。
  • 如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
  • 固定的GC Root对象包括:
    • 在虚拟机栈(栈帧中的本地变量表)中引用的对象,譬如各个线程被调用的方法堆栈中使用到的 参数、局部变量、临时变量等。
    • 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量。
    • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用。
    • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象。
    • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
    • 所有被同步锁(synchronized关键字)持有的对象。
    • 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。
  • 除以上这些固定的 ROOT 之外,还可以有其他对象“临时性”地加入,共同构成完整GCRoots集合:
    • 例如,在分代回收的时候,回收新生代的对象,要考虑老年代中是否有持有新生代引用的对象,那么老年代的对象就也是一种GC ROOT了

3.再谈引用
  • 无论是通过引用计数算法判断对象的引用数量,还是通过可达性分析算法判断对象是否引用链可 达,判定对象是否存活都和“引用”离不开关系。
  • 引用的定义:
    • 初始定义:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表 某块内存、某个对象的引用。
    • 扩展定义:为了满足不同需求的“引用”
      • 强引用:也就是直接引用
      • 软引用:SoftReference ,关联的对象在内存不足时会被回收
      • 弱引用:WeakReference,关联的对象在下一次触发GC时就会被回收(讲实话感觉很不实用
      • 虚引用:PhantomReference,更不实用的,唯一作用是触发回收时候拿到一个系统通知

4.to be or not to be ?finalize() 。
  • 即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的。这时候它们暂时还处于“缓 刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
    • 如果对象在进行可达性分析后发现没 有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是 否有必要执行finalize()方法。
    • 如果有必要,那么该对象将会被放置在一个名为F-Queue的 队列之中,并在稍后由一条由虚拟机自动建立的、低调度优先级的Finalizer线程去执行它们的finalize() 方法。
      • 并不一定会执行完这个方法,显而易见,如果有一个的方法很耗时,就会把整个队列卡死
    • 稍后收集器将对F-Queue中的对象进行第二次小规模的标记,如果此时对象和引用链上的另一个对象关联上,它就可以避免被回收了。
  • finalize() 能做的所有工作,使用try-finally或者其他方式都可以做得更好、 更及时。
    • 它最初是为了那些用惯了 C 和 C++ 的程序员发明的,对标析构函数
    • 但是实际它不符合JAVA的环境,早已是官方不推荐使用的方法了。

5.回收方法区
  • 方法区垃圾收集 的“性价比”通常也是比较低的:
    • Java堆中,尤其是在新生代中,对常规应用进行一次垃圾收集通常 可以回收70%至99%的内存空间
    • 方法区中,由于回收囿于苛刻的判定条件,其区域垃圾收集的回 收成果往往远低于此。
      • 例如静态变量,就是放在方法区,毫无疑问,它们的生命周期大多数时候都非常的长
  • 方法区中的类回收(类生命周期中的卸载)
    • 类的实例都被回收了
    • 类加载器也已经被回收了(除非是用的自定义加载器,不然这一条很难满足
    • class对象本身没有引用,也没有反射调用

三、垃圾收集算法

1.分代收集理论
  • 分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则
  • 它建立在两条假说之上
    • 弱分代假说:绝大多数对象都是朝生夕灭的。
    • 强分代假说:熬过越多次垃圾收集过程的对象就越难以消 亡。
      • 这里有点哲学意味,根据现象总结出来的理论,得到应用之后,反过来又会强化这种现象,事物总是在相互转化啊。
  • 这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:
    • 收集器应该将Java堆划分 出不同的区域,然后将回收对象依据其年龄(即对象熬过垃圾收集过程的次数)分配到不同的区 域之中存储。
  • 通常来说,会划分为两个代:为新生代 (Young Generation)和老年代(Old Generation)
    • 在新生代中,每次垃圾收集 时都发现有大批对象死去,
    • 而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
  • 但是,分代收集有个明显的弱点:对象不是孤立的,对象之间会存在跨代引用。
    • 新生代中的对象是完全有可 能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外,再额外遍历整 个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样,这个开销可想而知。
  • 为了解决上述问题,引入第三条假说(同样是经验总结):
    • 跨代引用假说:跨代引用相对于同代引用来说仅占极 少数。
  • 依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代
    • 只需在新生代上建立一个全局的数据结构,这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会 存在跨代引用。
    • 此后当发生年轻代的 GC时,只有包含了跨代引用的小块内存里的对象才会被加入到GC Roots进行扫描。
  • 最后定义一下不同的GC(主要根据GC的范围来分):
    • 部分GC:
      • 新生代收集(Minor GC/Young GC):指目标只是新生代的垃圾收集。
      • 老年代收集(Major GC/Old GC):指目标只是老年代的垃圾收集。目前只有CMS收集器会有单 独收集老年代的行为。
      • 混合收集(Mixed GC):指目标是收集整个新生代以及部分老年代的垃圾收集。目前只有G1收 集器会有这种行为。
    • 全GC:也就是 Full GC,整个Java堆和方法区的垃圾收集。
    • 这里先简单说明一下CMS:
      • 安卓在davilk时代的垃圾回收就是用的cms,所以会经常产生内存碎片,导致卡顿。
      • ART时代的初期,也是沿用的 cms ,后续有所更改,默认使用 CC ,但是仍然保留了 cms。
      • cms 详细内容后续再说吧,这里大概标一下。

2.标记-清除算法
  • 最早出现也是最基础的垃圾收集算法
    • 首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。
  • 它的主要缺点是两个:
    • 第一个是执行效率不稳定,如果Java堆中包含大量对 象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作。
    • 第二个是内存空间的碎片化问题,标记、清除之后会产生大 量不连续的内存碎片。空间碎片太多可能会导致需要分配较大对象时,无法找到足够的连续内存。
  • 它的优势也是明显的:简单、迅速嘛

3.标记-复制算法
  • 为了解决标记-清除算法面对大量可回收对象时执行效率低 的问题,而出现的垃圾回收算法
    • 将可用 内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着 的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。
  • 它的主要缺点也是两个:
    • 如果内存中多数对象都是存 活的,这种算法将会产生大量的内存间复制的开销
    • 复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一 点。
      • 这一点在实践过程中有所优化,具体见下文
  • 在商用java虚拟机的实践中,发现了:新生代中的对象有98%熬不过第一轮收集。由此得到一个启示:
    • 并不需要按照1∶1的比例来划分新生代的内存空间。
  • 一种广为采用的办法是:
    • 把新生代分为一块较大的Eden空间和两块较小的 Survivor空间,每次分配内存只使用Eden和其中一块Survivor。
    • 具体的回收逻辑,笔者自己概括为:
      • 假说 Eden 空间命名为 A ,两块 Survivor空间分别命名为 B 和 C
      • 初始时候,A 、B、C 都是空的,这时候只用往 A 中放新建的对象,A 放不下再往 B 中放。
      • 触发第一次回收时,是回收A,B中的内存,放入C中
      • 然后程序继续运行,这时候 C 中的内存就要参与下一次回收了,所以此时依然往 A 中放新建的对象,但是 A 放不下会往 C 中放。
      • 触发第二次回收时,是回收A,C中的内存,放入B中。
      • 然后程序继续运行,就和初始的情况一致了。
      • 这样的 B-> C -> B为一个来回,标记-复制算法进行回收,就是不停的进行这种循环。
  • 由上可知,优化后的标记-复制算法,它的特点是:
    • 标记-复制 算法 相对于 标记-清除算法 来说,解决了内存碎片化的问题。
    • 同时通过更改内存的配比为:8:1:1,缓解了它自身的先天劣势,就是对内存的浪费问题。
      • 不过这种配比毕竟是一种经验数据,实际环境中很可能出现例外情况,这时候就需要引入一种担保机制。
      • 即充当罕见情况的“逃生门”的安全设计,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(实 际上大多就是老年代)进行分配担保。

4.标记-整理算法
  • 标记 - 整理算法是为了应对 标记 - 复制算法不擅长应对的场景而出现的
    • 如上面所说,标记-复制算法有个先天劣势:如果内存中多数对象都是存 活的,将产生大量复制开销。
    • 很明显的,老年代就非常符合“大多数对象都会存活”这个现象,因此老年代一般不适用 标记 - 复制算法 。
  • 标记 - 整理算法步骤
    • 标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可 回收对象进行清理,
    • 而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内 存
    • 标记-清除算法与标记-整理算法的本质差异在于前者是一种非移动式的回收算法,而后者是移动 式的。
  • 对 标记-清除 和 标记-整理算法 的进一步对比分析:
    • 跟标记-清除算法不一样,增加了移动的步骤:
      • 移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,
      • 移动存活对象,并更新所有引用这些对象的地方,会是一种极为负重的操作。
      • 且移动的时候,需要暂停用户应用程序,才能进行,这也是非常影响体验的。
    • 但是,跟标记-清除算法那样,完全不考虑移动和整理存活对象的话
      • 弥散于堆中的存活对象导致的 空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决。
      • 类似于硬盘空间的分区管理,它就不要求空间连续,但是依然可以存放大文件。
      • 但是内存的访问频率是远超硬盘的,因此在访问环节增加负担,显然很不划算
    • 因此,无论是移动,还是不移动,都有弊端:
      • 移动则内存回收时会更复杂,就是回收的时候,延时会增加,
      • 不移动则内存分配时会更复杂,要更耗资源
      • 总的来说,不移动时:
        • 对象会使得收集器的效率提升一些,
        • 但因内存分配和访问相比垃圾收集频率要 高得多,
        • 总吞吐量仍然是下降的。
    • 还有一种“和稀泥式”解决方案:
      • 做法是让虚 拟机平时多数时间都采用标记-清除算法,暂时容忍内存碎片的存在,
      • 直到内存空间的碎片化程度已经 大到影响对象分配时,
      • 再采用标记-整理算法收集一次,以获得规整的内存空间。
      • 目测 Android 最开始的 Davilk 虚拟机,就是用的这种方法

四、经典垃圾收集器

1. Serial收集器
  • Serial收集器是最基础、历史最悠久的收集器
    • 它是一个单线程垃圾收集器
    • 进在行垃圾收集时,必须暂停其他所有工作线程,直到它收集结束。
    • 想象一下如果是用户场景的,例如电脑工作10分钟,就要暂停5分钟:直接整个人原地爆炸!
  • 虽然它如此简陋,但是正因为简陋,所以它非常实用,
    • 能够在极端恶劣的环境下工作:例如极小内存
    • 对于不需要垃圾回收的环境,它的效率反而更高:
      • 在用户桌面的应用场景以及近年来流行的部分微服务应用中,分配给虚 拟机管理的内存一般来说并不会特别大,收集几十兆甚至一两百兆的新生代,
      • 垃圾收集的停顿时间完全可以控制在十几、几十毫秒,最多一 百多毫秒以内

2. ParNew收集器
  • 这是 Serial收集器 的多线程版本
  • CMS收集器只能和 ParNew 或者 Serial 配合工作,这促使 ParNew 一段时间之内成为了主流的收集器
    • CMS 来收集老年代,ParNew 收集新生代
    • 但是好景不长,G1 这个更加先进的收集器出现了,并且开始替代 CMS
    • 自然ParNew也开始没落了

3. Parallel Scavenge收集器
  • 一款基于标记-复制算法的新生代收集器,支持并行收集(不是并发噢)
  • Parallel Scavenge收集器的特点
    • CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,
    • 而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐 量(Throughput)。
  • 吞吐量的定义:

image.png

  • 吞吐量的含义:
    • 较低的吞吐量,但是短停顿时间,适用于需要与用户交互的场景(例如安卓端)。
    • 高吞吐量,可能停顿时间更长,适合在后台运算而不需要太多交互的分析任务。

4. CMS(Concurrent Mark Sweep) 收集器
  • CMS收集器是一种以获取最短回收停顿时间为目标的收集器,是可以并发的收集器。
    • 安卓中dalvik虚拟机用的是CMS,ART一开始也沿用了这个收集器,在8.0之后换成了默认用CC收集器(并发复制),10.0之后,CC拓展了分代回收的能力。
  • 整个回收过程,包括四个步骤(三次标记,一次清除):
    1. 初始标记:需暂停用户线程
      • 初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快
    2. 并发标记
      • 并发标记阶段就是从GC Roots的直接关联对象开始遍历整个对 象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行
    3. 重新标记:需暂停用户线程
      • 重新标记阶段是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录
    4. 并发清除
      • 清理删除掉标记阶段判断的已经死亡的对象,由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。
  • CMS的优点:
    • 它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官 方公开文档里面也称之为“并发低停顿收集器”
  • CMS的缺点:
    • CMS收集器对处理器资源非常敏感。
      • 就是说会比较占用CPU资源,对核心线程比较少的情况来说,会有明显影响。
      • 也有一种变种 cms ,用户线程交替运行,尽量减少垃圾收集线程的独占资源的时间,这样整个垃圾收集的过程会更长(聊胜于无的修改 b
    • CMS 无法处理 “浮动垃圾”
      • 在并发标记和并发清理阶段
        • 用户线程是还在继续运行的,程序在运行自然就还会伴随有新的垃圾对象不断产生,
        • 但这一部分 垃圾对象是出现在标记过程结束以后,CMS无法在当次收集中处理掉它们
      • 这就导致,CMS 无法等老年代内存完全用完了再进行收集,必须留一部分空间,给“收集过程”中产生的新对象来用
        • 这又导致另一个风险:有可能出现“Con-current Mode Failure”失败
        • 进而导致虚拟机不得不启动后被预案,冻结所有用户线程,临时启用Serial Old收集器来重新进行老年代的垃圾收集。
    • 因为基于标记-清除算法,所以显然它会有内存碎片的问题

5. G1(Garbage First) 收集器
  • Garbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果
    • 它开创了收集 器面向局部收集的设计思路和基于Region的内存布局形式
  • G1的特点:
    • 不再坚持固定大小以及固定数量的分代区域划分
      • 将连续的Java堆划分为多个大小相等的独立区域(Region)
      • 每一个Region都可以根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。
      • 虽然G1仍然保留新生代和老年代的概念,但新生代和老年代不再是固定的了,它们都是一系列区 域(不需要连续)的动态集合。
      • 对此,笔者认为:
        • 更加灵活,更加动态化,资源的灵活组合,感觉是一个相通的技术点,不论你是垃圾收集器,还是view,或者其他的什么东西。
        • G1的垃圾回收,是更加“智能化”的,没有固定的套路,所有机制联系组合,只为追求:垃圾回收的效益最大化
    • 目标不同
      • 能够应付应用的内存分配速率 (Allocation Rate),而不追求一次把整个Java堆全部清理干净。
      • 应用在分配,同时收集器在收 集,只要收集的速度能跟得上对象分配的速度,那一切就能运作得很完美。
      • 这也是之后的垃圾收集器的共同追求。
    • 多种算法结合:
      • 从整体来看是基于“标记-整理”算法实现的收集器,
      • 但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现
      • 这两种算法都意味着G1运作期间不会产生内存 空间碎片,垃圾收集完成之后能提供规整的可用内存。
  • G1的缺点:
    • 内存占用和执行负载:
      • 在用户程序运行过程 中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载 (Overload)都要比CMS要高
      • 也就是牛逼的东西,必然还是需要增加一定的基础开销的,只是它带来的收益也会更大,在小内存的设备CMS还是占优的

五、低延迟垃圾收集器

1.低延迟,why?
  • 垃圾收集器从Serial发展到CMS再到G1,经历了逾二十年时间,经过了数百上千万台服 务器上的应用实践,已经被淬炼得相当成熟了,不过它们距离“完美”还是很遥远。
  • 怎样的收集器才算 是“完美”呢?
    • 关键指标是:内存占用(Footprint)、吞吐量(Throughput)和延迟 (Latency),三者共同构成了一个“不可能三角”
    • 内存占用:
      • 随着硬件的发展,内存占用越来越松,因为现在硬件越来越便宜,内存越来越大了
    • 吞吐量:
      • 硬件的规格和性能越高,有助于降低收集器运行时对应用程序的影响,也就是吞吐量越来越大了
    • 延迟:
      • 硬件规格提升,准确地说是内存的扩 大,对延迟反而会带来负面的效果,因为要回收的内存区域变大了!
      • 虚拟机要回收完整的1TB的堆内 存,毫无疑问要比回收1GB的堆内存耗费更多时间。
    • 所以,延迟是更被关注的指标,也是更加限制性能增长的点



2.Shenandoah收集器
  • 在G1的基础上,进一步的改进,支持并行整理!
  • Brooks:
    • 一种转发指针,支持并行整理的核心概念
    • 从结构上来看,Brooks提出的转发指针与某些早期Java虚拟机使用过的句柄定位,有一些相似之处
      • 两者都是一种间接性的对象访问方式,
      • 差别是句柄通常会统一存储在专 门的句柄池中,
      • 而转发指针是分散存放在每一个对象头前面。
    • 它的工作流程:
      • 当对象拥有了一份新的副本时,只需要修 改一处指针的值,即旧对象上转发指针的引用位置,
      • 使其指向新对象,便可将所有对该对象的访问转 发到新的副本上。
      • 这样只要旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址 访问的代码便仍然可用,都会被自动转发到新对象上继续工作。

六、垃圾收集实战

  • 这一部分要结合安卓的内存优化知识来进行,暂且留空吧!记得后面来补充



七、总结

  • 引用的分类:
    • 四种类型
  • 如何判断对象已死?
    • 引用计数
    • 可达性分析
  • 垃圾收集算法:
    • 标记-清除
    • 标记-复制
    • 标记-整理
  • 垃圾收集器:
    • Serial 收集器:最早最经典,
      • 单线程,标记-清除算法
    • CMS收集器:经典收集器,
      • 低延迟,多线程,标记-清除算法
    • G1收集器:收集器发展的里程碑,
      • 灵活的配置和调控,多线程,不追求一次性把内存全部完成回收
      • 从整体来看是基于“标记-整理”算法实现的收集器,
      • 但从局部(两个Region 之间)上看又是基于“标记-复制”算法实现
  • 感悟:
    • 垃圾收集算法看起来都是很简单的,但是它们只是实现垃圾收集的一种抽象的思想体现
    • 实际的垃圾收集器,代码是非常复杂的,要了解清楚,怕是需要对JAVA语言和虚拟机,从编译到运行,从源码到发展历史有极其深刻的认识才行

八、引用

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值