HotSpot 垃圾回收

本文仅以 HotSpot 虚拟机展开,详解它的垃圾回收机制

总结于​​​​​​Java性能优化之JVM GC(垃圾回收机制) - 知乎 (zhihu.com)

【Java虚拟机】JVM垃圾回收机制和常见回收算法原理-腾讯云开发者社区-腾讯云 (tencent.com)

1.概述

GC 是垃圾回收器的简称,全称是Garbage Collection。

Stop The World 也是一个很重要的关键词,它会在任何一种GC算法中发生,其实可以把它理解为JVM GC在清理内存时,整个程序的停顿时间。当 Stop The World 发生时,除GC所需的线程外,所有的线程都进入等待状态,直到 GC 任务完成。每一代的Java垃圾回收器,都把缩减 Stop The World 停顿时间作为很重要的目标。

这是一个三角形关系,由内存占用、吞吐量、停顿时间三者组成,内存占用少,吞吐量就低。想吞吐量高,内存占用也得提高。但内存占用大,标记和清理的停顿时间又会变长,这又会影响吞吐量

2.回收区域

JVM GC只回收堆区和方法区内的基本类型数据和对象。

栈区的数据(仅指基本类型数据),在超出作用域后会自动出栈释放掉,所以其不在JVM GC的管理范围内。

3.判断对象是否能够被回收了

条件:对象没有被引用了或者对象不可达

如何判断对象是否存活?(也就是判断对象没有被引用了或者对象不可达)

引用计数法 和 可达性分析法

引用计数法:

在对象里添加一个被引用的计数器,每当有地方引用了它,计数器就加1,引用失效时,计数器就减1。

在触发回收内存的时候,遍历所有对象,把计数器值等于0的找出来,释放掉即可。

main函数调用method方法,method方法中new了一个A的对象,赋值给局部变量a,此时堆内存中的对象A的实例的计数器就会+1。当方法结束时,局部变量会随之销毁,堆内存中的对象的计数器就会-1(没有指向实例对象的指针了,也就是实例对象没有被引用了)。

缺点:

引用计数器开销大,每个对象都需要一个引用计数器,如果对象很多,开销就会很大

循环引用无法回收。如果两个对象互相引用,它们的引用计数器永远不会为0,因此无法被回收,导致内存泄漏。

内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。 内存泄漏缺陷具有隐蔽性、积累性的特征,比其他内存非法访问错误更难检测。

例:

Main方法中,执行完两个set后,main方法结束,图中两条红线引用消失,可以看到,留下两个对象在堆内存中循环引用,但此时已经没有地方在用他们了,造成内存泄漏。

可达性分析法(根搜索算法):

其实不止是Java,C# 也是使用可达性分析算法来判断对象是否存活的,这个算法也可以称之为根搜索算法。这个算法的基本原理是通过一系列可被作为 GC Roots 的根对象来作为起始节点,从这些节点开始,根据引用关系向下搜索,搜索过程的就是一条引用链(Reference Chain),没有在这个链条上面的对象,也就是根节点通过引用链不可达到这个对象时,就认为这个对象是可以被回收的。

什么是GC Root?

指一些被JVM认为是存活的对象,它们是垃圾回收算法的起点,可以理解为由堆外指向堆内的引用,本身是没有存储位置,都是字节码加载运行过程中加入 JVM 中的一些普通引用,是垃圾回收器的起点,如果一个节点没有任何子节点与根节点相连,那这个节点就被认为是不可达的,可以被回收器回收

JVM中的GC Roots对象有哪几种?

-虚拟机栈(栈帧中的本地变量表)中引用的对象。

-方法区中类静态属性引用的对象,比如你定义了一个static 的集合对象,那里面添加的对象就是可以被GC Root可达的

-JDK 1.7 开始静态变量的存储从方法区移动到堆中

-方法区中常量引用的对象

-字符串常量池从 JDK 1.7 开始由方法区移动到堆中

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

-在JVM内部的对象,例如基本数据类型的Class对象,一些常驻的异常对象(NullPointExcepiton),系统类加载器等。
-所有synchronized同步锁的持有对象。
-反映JVM内部情况的JMXBean、JVMTI注册的回调、本地代码缓存等。

JVM GC什么时候执行?

当程序创建一个新的对象或者基本类型的数据,内存空间不足时,会触发GC的执行。

不同的垃圾回收器,会有不同的回收策略,但大致可以分为两类:分代回收和局部回收两种策略。

分代回收机制

分代回收设计垃圾收集器的理论,建立在两个分代假说上:

-弱分代假说:绝大多数对象都是朝生夕死的。
-强分代假说:熬过越多次的垃圾回收的对象,就越难消亡

根据强弱分代假说,可以将对象分为老年代和新生代,新生代中绝大多数对象存活时间短,会被回收清理,从中幸存下来的对象可以采用复制算法移至幸存者区域,剩下的直接释放即可。对于多次未被回收清理的对象,一般就很难回收了,如果每次GC都对他们进行标记搜索,浪费资源,所有就将他们放入老年代,jvm就能以较少的频率回收老年代区域的对象,可以提高效率

各区域触发垃圾回收的类型与解释:

-Minor GC:只回收新生代区域。
-Major GC:只回收老年代区域。只有CMS实现了Major GC,

-Mixed GC:回收整个新生代和部分老年代。G1收集器实现了这个类型。

-Full GC:回收整个堆区和方法区

在老年代里,触发GC,除了CMS和G1之外的其他收集器,大多数触发的其实是 Full GC

新生代:

绝大多数新创建的对象都会被分配到这里,这个区域触发的垃圾回收称之为:Minor GC。

空间结构:

默认情况下,新生代(Young generation)、老年代(Old generation)所占空间比例为 1:2 。

新生代被分成三个空间:

· 1个伊甸园空间(Eden)

· 2个幸存者空间(From Survivor、To Survivor)

默认情况下,新生代空间的分配:Eden : From : To = 8 : 1 : 1

(国外有公司统计过多数业务,98%撑不过一次GC; 所以不用1:1比例分配新生代的空间)

新生代GC收集的执行顺序如下:

1、绝大多数新创建的对象会存放在伊甸园空间(Eden)。

2、在伊甸园空间执行第1次GC(Minor GC)之后,存活的对象被移动到其中一个幸存者空间(Survivor)。

3、此后每次 Minor GC,都会将 Eden 和 使用中的Survivor 区域中存活的对象,一次性复制到另一块空闲中的Survivor区,然后直接清理 Eden 和 使用过的那块Survivor 空间。

4、从以上空间分配我们知道,Survivor区内存占比很小,当空闲中的Survivor空间不够存放活下来的对象时,这些对象会通过分配担保机制直接进入老年代。

5、在以上步骤中重复N次(N = MaxTenuringThreshold(年龄阀值设定,默认15))依然存活的对象,就会被移动到老年代。

从上面的步骤可以发现,两个幸存者空间,必须有一个是保持空的。

我们需要重点记住的是,新创建的对象,是保存在伊甸园空间的(Eden)。那些经历多次GC依然存活的对象会经由幸存者空间(Survivor)转存到老年代空间(Old generation)。

(也有例外出现,对于一些大的对象(指需要占用大量连续内存空间的对象)则直接进入到老年代。Java提供了 -XX:PretenureSizeThreshold 来指定对象大于这个值,直接分配到老年代。)

老年代:

对象在新生代周期中存活了下来的,会被拷贝到这里。通常情况下这个区域分配的空间要比新生代多。正是由于对象经历的GC次数越多越难回收,加上相对大的空间,发生在老年代的GC次数要比新生代少得多。这个区域触发的垃圾回收称之为:Major GC或者Full GC

为什么老年代的回收耗时,比新生代更长呢?

有两点原因:

老年代内存占比更大,所以理论上回收的时间也更长
老年代使用的是标记-整理算法,清理完成内存后,还得把存活的对象重新排序整理成连续的空间,成本更高。

方法区:

这个区域主要回收废弃的常量和类型,例如常量池里不会再被使用的各种符号引用等等。类型信息的回收相对来说就比较严苛了,必须符合以下3个条件才会被回收:

1、所有实例被回收
2、加载该类的ClassLoader 被回收
3、Class 对象无法通过任何途径访问(包括反射)

可以使用 -Xnoclassgc 禁用方法区的回收。

跨代引用的问题

新生代中的对象很有可能会被老年代里的对象所引用,当新生代触发GC的时候,只搜索新生代的区域明显是不够的,还得搜索老年代的对象是否引用了新生代中非 GC Roots 引用链上的对象,来确保正确性。但这样做会带来很大的性能开销。为了解决这个问题,Java定义了一种名为记忆集的抽象的数据结构,用于记录存在跨区域引用的对象指针集合。

大多数的虚拟机,都采用一种名为卡表(Card Table)的方式去实现记忆集,卡表由一个数组构成,每一个元素都对应着一块特定大小的内存区域,这块内存区域被称之为卡页(Card Page),每一个卡页,可能会包含N个存在跨区域引用的对象,只要存在跨区域引用的对象,这个卡页就会被标识为1。当GC发生的时候,就不需要扫描整个区域了,只需要把这些被标识为1的卡页加入对应区域的 GC Roots 里一起扫描即可。


把地址的值右移9位相当于除于512就是卡表索引,每字节512为一组对应卡表同一个元素,一组就是一个卡页,如果这个卡页中只要有一个对象被其他区域对象所引用,对应卡表元素的值就变成1,也就是所谓的元素变脏。

在垃圾回收时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页对应的内存包含跨代指针,把他们加入GC Rootsz中一并扫描。

回收算法

标记 - 清除算法

这个算法和它的名字一样,分两个步骤:标记 和 清除。首先标记出所有存活的对象,再扫描整个空间中未被标记的对象直接回收。

并没有规定标记阶段一定要标记“存活”的对象,也可以标记“可回收”的对象

我们假设要标记“可回收”的对象,再进行清除,那么需要三个步骤:
1、先通过可达性分析法,通过根对象(GC Roots)顺着引用链先把这些存活对象都标出来
2、遍历这个区域所有对象,把没标记存活的对象,打上一个“可回收”的标记
3、遍历这个区域所有对象,把标记了“可回收”的对象,释放掉。
但标记的是“存活”的对象,再进行清除,只需要两个步骤即可:
1、先通过可达性分析法,通过根对象(GC Roots)顺着引用链先把这些存活对象都标出来
2、遍历这个区域所有对象,把没标记存活的对象,直接清理掉即可。
标记 - 清除算法由于回收后没有进行整理的操作,所以会存在内存空间碎片化的问题,这个确实是缺点,但也是这个算法的特点,正因为它不进行整理,所以效率才高。

标记 - 复制算法

常规的复制算法,是把内存分成两块大小相同的空间(1 : 1),每次只使用其中一块,当使用中的这块内存用完了,就把存活的对象移动到另一块内存中,再把使用过的这块内存空间一次性清理掉。这个做法虽然效率极高,但也浪费了一半的内存空间。

标记-复制算法,而是按照 8 : 1 : 1 的比例来分配内存空间,也就是一个80%的Eden空间和两个10%的Survivor空间。

每次分配内存,只使用Eden和其中一块Survivor空间,发生GC回收时,把Eden和其中一块Survivor空间中存活的对象,复制到另一块空闲的Survivor空间,然后直接把Eden和使用过的那块Survivor空间清理掉。

标记-复制算法还有一个非常重要的知识点,就是分配担保机制,虽然根据IBM的研究,每次GC新生代里98%的对象都会被回收,但这不是百分之百的几率,极端情况下可能会出现超过10%的对象存活。分配担保机制就是为了保证当出现这种情况时,有其他内存空间来进行兜底。通常这个“担保人”是老年代,当存活的对象超过Survivor空间大小时,这些存活的对象会忽略年龄,直接进入老年代里。

空间担保策略(Promotion Guarantee)是JVM中的一种机制,确保在Minor GC时,存活的对象能够成功晋升到老年代。如果老年代没有足够的空间来接收新晋升的对象,JVM可能会提前触发一次Full GC来释放空间,或者调整自己的内存分配策略以避免此类情况的发生。

在进行Minor GC前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间。如果这个条件不能满足,虚拟机会查看 -XX:HandlePromotionFailure 设置是否允许担保失败。如果不允许(false),那么会提前进行一次Full GC来清理老年代并为新生代晋升的对象腾出空间。如果允许担保失败(true),那么只要老年代剩余空间大于历次晋升到老年代对象的平均大小即可进行Minor GC(有风险),否则也要提前进行Full GC。

从JDK 7开始,HotSpot虚拟机的垃圾收集器在做Minor GC之前的空间分配担保策略上进行了调整,取消了之前版本中的 -XX:HandlePromotionFailure 选项。每次都会判断老年代剩余最大连续空间大于历次Minor GC晋升的平均大小 或者 大于新生代所有对象的大小总和 , 大于任意一个,就允许触发MinorGC,反之触发 Full GC

标记 - 整理算法

和标记-清除算法一样,先标记,但清除之前,会先进行整理,把所有存活的对象往内存空间的左边移动,然后清理掉存活对象边界以外的内存,即完成了清除的操作。标记-整理 算法是在 标记-清除 算法之上,又进行了对象的移动排序整理,因此成本更高,但却解决了内存碎片的问题。

老年代里的对象存活率很高,不适合使用标记-复制的算法。而且老年代存储大对象的概率要比新生代大很多,这些大对象需要连续的内存空间来存储,标记-清除这个算法也不适合。所以大多数的老年代都采用标记-整理来作为这个区域的回收算法。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值