深入了解JVM垃圾处理GC过程(汇总篇一)

文章参考及相关图片出处:

  1. https://www.sohu.com/a/254731966_465221
  2. https://www.iteye.com/blog/uule-2114697
  3. https://blog.csdn.net/m0_50654102/article/details/115102905
  4. https://www.zhihu.com/question/60103311
  5. https://mp.weixin.qq.com/s/nY6vL5MlUXY1lfnIvNHMnw
  6. https://zhuanlan.zhihu.com/p/377672271
  7. https://www.cnblogs.com/gushiye/p/13948556.html

一、 相关名称解释

JDK:Java开发工具包。是一个编写Java的Applet小程序和应用程序的程序开发环境。JDK是整个Java的核心,包括了Java运行环境(Java Runtime Environment),一些Java工具和Java的核心类库(Java API)。
JRE:Java运行时环境,也就是运行Java程序所需要的标准环境。
JVM:Java虚拟机。可以看做是一个虚拟的计算机,也是Java实现跨平台最核心的部分。它屏蔽了操作系统与平台的相关信息,使得Java语言能够不依赖平台而运行,也可以说只要有JVM存在的地方,就能运行Java语言程序。
HotSpot:JVM协议的具体实现,包括一个解释器和两个编译器(client 和 server,二选一的),解释与编译混合执行模式,默认启动解释执行。

二、JVM组成:

  • 类装载子系统(ClassLoader)
  • 运行时数据区
  • 执行引擎
  • 内存回收

三、运行时数据区:

  • 直接内存
  • 方法区(Method Area)
  • JAVA堆(Heap)
  • 虚拟机栈(Stack)
  • 程序计数器
  • 本地方法栈

c77c1702688a456ab71ad377f335df52.jpeg (1080×586)

四、堆内存划分(分代回收机制):

  • New:年轻代/新生代(存放JVM刚分配的java对象)
  • Tenured:老年代(年轻代中经过垃圾回收没有回收掉的对象将被Copy到年老代)

 Perm:永久代(不属于堆内存中,属于方法区,存放Class、Method元信息,其大小跟项目的规模、类、方法的量有关,一般设置为128M就足够,设置原则是预留30%的空间。)

-XX:NewRatio:年轻代:老年代 =2(默认2);

五、年轻代划分:

  • Eden(用来存放JVM刚分配的对象)
  • Survivor0
  • Survivor1 (两个Survivor空间一样大,当Eden中的对象经过垃圾回收没有被回收掉时,会在两个Survivor之间来回Copy,当满足某个条件,比如Copy次数,就会被Copy到Tenured。显然,Survivor只是增加了对象在年轻代中的逗留时间,增加了被垃圾回收的可能性。)

-XX:SurvivorRatio:Eden:Survivor0:Survivor1=8:1:1 (默认8)

注意:当某个对象分配需要大量的连续内存时,此时对象的创建不会分配在 Eden 区,会直接分配在老年代,因为如果把大对象分配在 Eden 区, Minor GC 后再移动到 S0,S1 会有很大的开销(对象比较大,复制会比较慢,也占空间),也很快会占满 S0,S1 区,所以干脆就直接移到老年代。

六、JVM参数:

七、垃圾回收说明:

        由上述说明的JVM数据分区可知道,程序计数器、虚拟机栈及本地方法栈这3个区域为线程私有,会随线程消亡而自动回收,所以不需要管理。因此垃圾收集只需要关注堆和方法区。而方法区的回收性价比比较低,因此判断可以回收的条件比较苛刻。下面分析的垃圾回收识别算法和垃圾回收机制都是基于堆的垃圾集;

八、GC算法评判标准:

  • 吞吐量:即单位时间内的处理能力。

  • 最大暂停时间:因执行GC而暂停执行程序所需的时间。

  • 堆的使用效率:鱼与熊掌不可兼得,堆使用效率和吞吐量、最大暂停时间是不可能同时满足的。即可用的堆越大,GC运行越快;相反,想要利用有限的堆,GC花费的时间就越长。

  • 访问的局部性:在存储器的层级构造中,我们知道越是高速存取的存储器容量会越小。由于程序的局部性原理,将经常用到的数据放在堆中较近的位置,可以提高程序的运行效率。

九、概念说明

  • 可达性 

        通过一系列称为“GC Roots”的对象为起点,从这些节点开始向下搜索,搜索走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则说明对象是不可达的。

        以Java为例,以下对象可作为GC Roots:

  1. 栈(栈帧中的本地变量表)中引用的对象。
  2. 方法区中的静态成员。

  3. 方法区中的常量引用的对象(全局变量)。

  4. 本地方法栈中JNI(一般说的Native方法)引用的对象。

    注:第一和第四种都是指的方法的本地变量表,第二种表达的意思比较清晰,第三种主要指的是声明为final的常量值。

十、垃圾回收算法:

1.引用计数法(直接回收)

        为每一个内存单元设置一个引用计数器,需要占用额外存储空间,当引用计数为0时意味着这个内存单元再也无法被引用,立即释放内存。这个开销平摊到应用的日常运行中,适用于对内存敏感场景。缺陷是循环引用及需要占用额外存储空间。
        java中未使用该算法,python使用该算法,同时结合非传统标记-清除方案(非可达性算法)变现弥补循环引用的缺陷。而且最原始没使用GC Roots。

        优点:

  • 可即时回收垃圾
  • 最大暂停时间短
  • 没有必要沿着指针查找

        缺点:

  • 计数器值的增减处理非常繁重
  • 计算器需要占用很多位
  • 实现繁琐
  • 循环引用无法回收

2.可达性算法(间接回收)

        利用标记-清除(mark-sweep),就是标记可达对象,清除不可达对象。

        标记-清除具体的做法是定期或者内存不足时进行垃圾回收,从根引用(GC Roots)开始遍历扫描,将所有扫描到的对象标记为可达,然后将所有不可达的对象回收了。

        优点:

  • 实现简单
  • 兼容保守式GC算法

        缺点:

  • 碎片化(内存空间回收后不连续,造成利用率不足)
  • 分配速度慢(不连续,分配需要遍历空闲链表)
  • 与写时复制技术不兼容(https://www.zhihu.com/question/60103311

3.复制算法

        将内存空间按容量分成两块。当这一块内存用完的时候,就将还存活着的对象复制到另外一块上面,然后把已经使用过的这一块一次清理掉。这样使得每次都是对半块内存进行内存回收。内存分配时就不用考虑内存碎片等复杂情况,只要移动堆顶的指针,按顺序分配内存即可,实现简单,运行高效。

        优点:

  • 优秀吞吐量
  • 可高速分配
  • 不会发生碎片化
  • 与缓存兼容

        缺点:

  • 堆使用效率低下(容量划分占用)
  • 不兼容保守式GC算法
  • 递归调用函数

4.标志-压缩算法

        标记-压缩算法与标记-清理算法类似,只是后续步骤是让所有存活的对象移动到一端,不分区,然后直接清除掉端边界以外的内存。

        优点:

  • 堆空间利用率比复制算法高

        缺点:

  • 压缩需要进行多次整堆遍历操作,进行新位置记录、指针更新、对象移动等,因此需要花费更多的计算和时间成本;

5.分代收集

        标记-清除方式的 GC 其实就是攒着垃圾收,这样集中式回收会给应用的正常运行带来影响,所以就采取了分代收集的思想。针对新生代和老年代对不同的区域可以根据不同的回收策略来处理,提升回收效率。
        年轻代每次清除存活的对象很少,采用标志-复制的算法,HotSpot 虚拟机将年轻代分了一个 Eden 区和两个Survivor,默认大小比例是8∶1:1,这样利用率有 90%。每次回收就将存活的对象拷贝至一个 Survivor 区,然后清空其他区域即可,如果 Survivor 区放不下就放到 老年代去,这就是分配担保机制。
        老年代每次清除的对象很少,所以追溯标记的时间比较长,收集的回报率也比较低,所以收集频率安排的低一些。利用标记-清除标记-整理两者结合起来收集老年代,比如平日都用标记-清除,当察觉内存碎片实在太多了就用标记-整理来配合使用。

6.跨代引用

        在回收新生代的时候,有可能有老年代的对象引用了新生代对象,所以老年代也需要作为根,但是如果扫描整个老年代的话效率就又降低了。采用记忆集(Remembered Set)来记录跨代之间的引用而避免扫描整体非收集区域。

7.增量式引用

        在应用线程执行中,穿插完成GC,时间跨度变大,但是应用暂停时间变短了。采用三色标记算法实现。

十一、引用类型识别:

        不论标记-清楚还是引用计数,其实都只关心引用类型,像一些整型啥的就不需要管。所以 JVM 还需要判断栈上的数据是什么类型,这里又可以分为保守式 GC、半保守式 GC、和准确式 GC。

1.保守式 GC

        保守式GC指的是 JVM 不会记录数据的类型,也就是无法区分内存上的某个位置的数据到底是引用类型还是非引用类型。因此只能靠一些条件来猜测是否有指针指向。比如在栈上扫描的时候根据所在地址是否在 GC 堆的上下界之内是否字节对齐是否指着对象头等手段来判断这个是不是指向 GC 堆中的指针。但是有这一种特殊情况就是恰好有数值的值就是地址的值,这时候也是满足上述条件的,因此无法肯定是否是指针指向。
        此时JVM的处理是保守地认为是对象。因此就会有出现其它连锁情况,例如该值指向的对象其实是需要被清除的,但是被该数值指向因此就误以为它还活着放过了它。

2.半保守式 GC

        半保守式GC,在对象上会记录类型信息而其他地方还是没有记录,因此从根扫描的话还是一样,得靠猜测。但是得到堆内对象了之后,就能准确知晓对象所包含的信息了,因此之后 tracing 都是准确的,所以称为半保守式 GC。
        现在可以得知半保守式 GC 只有根直接扫描的对象无法移动,从直接对象再追溯出去的对象可以移动,所以半保守式 GC 可以使用移动部分对象的算法,也可以使用标记-清除这种不移动对象的算法。而保守式 GC 只能使用标记-清除算法。

3.准确式 GC

        在指针上打标记,来表明类型,或者在外部记录类型信息形成一张映射表。在 HotSpot 中,对象的类型信息里会记录自己的 OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据,而在解释器中执行的方法可以通过解释器里的功能自动生成出 OopMap 出来给 GC 用。
        被 JIT 编译过的方法,也会在特定的位置生成 OopMap,记录了执行到该方法的某条指令时栈上和寄存器里哪些位置是引用。

这些特定的位置主要在:

  • 循环的末尾(非 counted 循环)

  • 方法临返回前 / 调用方法的call指令后

  • 可能抛异常的位置

        这些位置就叫作安全点(safepoint)。那为什么要选择这些位置插入呢?因为如果对每条指令都记录一个 OopMap 的话空间开销就过大了,因此就选择这些个关键位置来记录即可。所以在 HotSpot 中 GC 不是在任何位置都能进入的,只能在安全点进入。
        至此我们知晓了可以在类加载时计算得到对象类型中的 OopMap,解释器生成的 OopMap 和 JIT 生成的 OopMap ,所以 GC 的时候已经有充足的条件来准确判断对象类型。因此称为准确式 GC。
        其实还有个 JNI 调用,它们既不在解释器执行,也不会经过 JIT 编译生成,所以会缺少 OopMap。在 HotSpot 是通过句柄包装来解决准确性问题的,像 JNI 的入参和返回值引用都通过句柄包装起来,也就是通过句柄再访问真正的对象。这样在 GC 的时候就不用扫描 JNI 的栈帧,直接扫描句柄表就知道 JNI 引用了 GC 堆中哪些对象了。

        安全点

        不止给记录 OopMap 用的,因为 GC 需要一个一致性上下文快照来枚举所有的根对象,所以应用线程需要暂停,而暂停点的选择就是安全点。快照的获取需要停止所有应用程序所有线程,不然就得不到一致的数据,导致一些活着对象丢失,这里说的一致性其实就像事务的一致性。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值