JVM(三)JVM垃圾回收

目录

JVM垃圾回收

JVM内存分配

对象优先在Eden中进行分配

大对象直接进老年代

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

动态年龄判断

主要进行GC的区域

空间分配担保

如何判断对象存活

引用计数法

可达性分析算法

垃圾回收算法

标记-清除算法

标记-复制算法

标记-整理算法

分代收集算法

性能与优化

垃圾收集器


JVM垃圾回收

image-20220310161509925

当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

JVM内存分配

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。同时,Java 自动内存管理最核心的功能是 堆 内存中对象的分配与回收。

Java 堆是垃圾收集器管理的主要区域,因此也被称作GC 堆(Garbage Collected Heap).从垃圾回收的角度,由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代:再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。进一步划分的目的是更好地回收内存,或者更快地分配内存。

image-20220310162149666

大部分情况,对象都会首先在 Eden 区域分配,在一次新生代垃圾回收后,如果对象还存活,则会进入 s0 或者 s1,并且对象的年龄还会加 1(Eden 区->Survivor 区后对象的初始年龄变为 1),当它的年龄增加到一定程度(默认为大于 15 岁),就会被晋升到老年代中。

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置默认值,这个值会在虚拟机运行过程中进行调整,可以通过-XX:+PrintTenuringDistribution来打印出当次 GC 后的 Threshold。

“Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的一半时,取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值”。

uint ageTable::compute_tenuring_threshold(size_t survivor_capacity) {
//survivor_capacity是survivor空间的大小
size_t desired_survivor_size = (size_t)((((double)survivor_capacity)*TargetSurvivorRatio)/100);
size_t total = 0;
uint age = 1;
while (age < table_size) {
  //sizes数组是每个年龄段对象大小
  total += sizes[age];
  if (total > desired_survivor_size) {
      break;
  }
  age++;
}
uint result = age < MaxTenuringThreshold ? age : MaxTenuringThreshold;
...
}

经过这次 GC 后,Eden 区和"From"区已经被清空。这个时候,"From"和"To"会交换他们的角色,也就是新的"To"就是上次 GC 前的“From”,新的"From"就是上次 GC 前的"To"。不管怎样,都会保证名为 To 的 Survivor 区域是空的。Minor GC 会一直重复这样的过程,在这个过程中,有可能当次 Minor GC 后,Survivor 的"From"区域空间不够用,有一些还达不到进入老年代条件的实例放不下,则放不下的部分会提前进入老年代。

调试代码参数如下

-verbose:gc
-Xmx200M
-Xms200M
-Xmn50M
-XX:+PrintGCDetails
-XX:TargetSurvivorRatio=60
-XX:+PrintTenuringDistribution
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-XX:MaxTenuringThreshold=3
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC

示例代码:

/*
* 本实例用于java GC以后,新生代survivor区域的变化,以及晋升到老年代的时间和方式的测试代码。需要自行分步注释不需要的代码进行反复测试对比
*
* 由于java的main函数以及其他基础服务也会占用一些eden空间,所以要提前空跑一次main函数,来看看这部分占用。
*
* 自定义的代码中,我们使用堆内分配数组和栈内分配数组的方式来分别模拟不可被GC的和可被GC的资源。
*
*
* */

public class JavaGcTest {

    public static void main(String[] args) throws InterruptedException {
        //空跑一次main函数来查看java服务本身占用的空间大小,我这里是占用了3M。所以40-3=37,下面分配三个1M的数组和一个34M的垃圾数组。


        // 为了达到TargetSurvivorRatio(期望占用的Survivor区域的大小)这个比例指定的值, 即5M*60%=3M(Desired survivor size),
        // 这里用1M的数组的分配来达到Desired survivor size
        //说明: 5M为S区的From或To的大小,60%为TargetSurvivorRatio参数指定,可以更改参数获取不同的效果。
        byte[] byte1m_1 = new byte[1 * 1024 * 1024];
        byte[] byte1m_2 = new byte[1 * 1024 * 1024];
        byte[] byte1m_3 = new byte[1 * 1024 * 1024];

        //使用函数方式来申请空间,函数运行完毕以后,就会变成垃圾等待回收。此时应保证eden的区域占用达到100%。可以通过调整传入值来达到效果。
        makeGarbage(34);

        //再次申请一个数组,因为eden已经满了,所以这里会触发Minor GC
        byte[] byteArr = new byte[10*1024*1024];
        // 这次Minor Gc时, 三个1M的数组因为尚有引用,所以进入From区域(因为是第一次GC)age为1
        // 且由于From区已经占用达到了60%(-XX:TargetSurvivorRatio=60), 所以会重新计算对象晋升的age。
        // 计算方法见上文,计算出age:min(age, MaxTenuringThreshold) = 1,输出中会有Desired survivor size 3145728 bytes, new threshold 1 (max 3)字样
        //新的数组byteArr进入eden区域。


        //再次触发垃圾回收,证明三个1M的数组会因为其第二次回收后age为2,大于上一次计算出的new threshold 1,所以进入老年代。
        //而byteArr因为超过survivor的单个区域,直接进入了老年代。
        makeGarbage(34);
    }
    private static void makeGarbage(int size){
        byte[] byteArrTemp = new byte[size * 1024 * 1024];
    }
}

堆内存分为新生代和老年代,新生代是用于存放使用后准备被回收的对象,老年代是用于存放生命周期比较长的对象。

对象优先在Eden中进行分配

大部分我们创建的对象,都属于生命周期比较短的,所以会存放在新生代。新生代又细分Eden空间、From Survivor空间、To Survivor空间,我们创建的对象,对象优先在Eden分配。

image-20220313145702661

随着对象的创建,Eden剩余内存空间越来越少,就会触发Minor GC,于是Eden的存活对象会放入From Survivor空间。

image-20220313145729261

Minor GC后,新对象依然会往Eden分配。

image-20220313145755169

Eden剩余内存空间越来越少,又会触发Minor GC,于是Eden和From Survivor的存活对象会放入To Survivor空间。

image-20220313145834269

大对象直接进老年代

在上面的流程中,如果一个对象很大,一直在Survivor空间复制来复制去,那很费性能,所以这些大对象直接进入老年代。

可以用XX:PretenureSizeThreshold来设置这些大对象的阈值。

image-20220313150010887

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

在上面的流程中,如果一个对象Hello_A,已经经历了15次Minor GC还存活在Survivor空间中,那他即将转移到老年代。这个15可以通过-XX:MaxTenuringThreshold来设置的,默认是15。

虚拟机为了给对象计算他到底经历了几次Minor GC,会给每个对象定义了一个对象年龄计数器。如果对象在Eden中经过第一次Minor GC后仍然存活,移动到Survivor空间年龄加1,在Survivor区中每经历过Minor GC后仍然存活年龄再加1。年龄到了15,就到了老年代。

image-20220313150146496

动态年龄判断

除了年龄达到MaxTenuringThreshold的值,还有另外一个方式进入老年代,那就是动态年龄判断在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。

比如Survivor是100M,Hello1和Hello2都是3岁,且总和超过了50M,Hello3是4岁,这个时候,这三个对象都将到老年代。

image-20220313150419330

主要进行GC的区域

img

针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
  • 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
  • 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。

整堆收集 (Full GC):收集整个 Java 堆和方法区。

空间分配担保

上面的流程提过,存活的对象都会放入另外一个Survivor空间,如果这些存活的对象比Survivor空间还大呢?整个流程如下:

  1. Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果大于,则发起Minor GC
  2. 如果小于,则看HandlePromotionFailure有没有设置,如果没有设置,就发起full gc。
  3. 如果设置了HandlePromotionFailure,则看老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果小于,就发起full gc。
  4. 如果大于,发起Minor GC。Minor GC后,看Survivor空间是否足够存放存活对象,如果不够,就放入老年代,如果够放,就直接存放Survivor空间。如果老年代都不够放存活对象,担保失败(Handle Promotion Failure),发起full gc。
image-20220313150728654

如何判断对象存活

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象)。

image-20220313144936969

当我们调用一个方法的时候,就会创建这个方法的栈帧,当方法调用结束的时候,这个栈帧出栈,栈帧所占用的内存也随之释放。

如果这个线程销毁了,那与这个线程相关的栈以及程序计数器的内存也随之被回收,那在堆内存中创建的对象怎么办这些对象可是都占着很多的内存资源的。因此我们需要知道哪些对象是可以回收的,哪些对象是不能回收的。

image-20220310171444119

引用计数法

给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1;当引用失效,计数器就减 1;任何时候计数器为 0 的对象就是不可能再被使用的。

这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题。 所谓对象之间的相互引用问题,如下面代码所示:除了对象 objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

public class ReferenceCountingGc {
    Object instance = null;
	public static void main(String[] args) {
		ReferenceCountingGc objA = new ReferenceCountingGc();
		ReferenceCountingGc objB = new ReferenceCountingGc();
		objA.instance = objB;
		objB.instance = objA;
		objA = null;
		objB = null;

	}
}

可达性分析算法

可达性算法就是从GC Roots出发,去搜索他引用的对象,然后根据这个引用的对象,继续查找他引用的对象。

如果一个对象到GC Roots没有任何引用链相连,说明他是不可用的,这个类就可以回收,比如下图的object5、object6、object7。

image-20220313145355504

我们回忆一下合并图:

  1. 类加载到方法区的时候,初始化阶段会为静态变量赋值,他所引用的对象可以做GC Roots。
  2. 同样的,方法区的常量引用的对象可以做GC Roots。
  3. 调用方法的时候,会创建方法的栈帧,栈帧里的局部变量引用的对象,可以做GC Roots。
  4. 同样的,本地方法栈中栈帧里的局部变量引用的对象,可以做GC Roots。
  5. 所有被同步锁持有的对象

对象可以被回收,就代表一定会被回收吗?

即使在可达性分析法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑阶段”,要真正宣告一个对象死亡,至少要经历两次标记过程;可达性分析法中不可达的对象被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行 finalize 方法。当对象没有覆盖 finalize 方法,或 finalize 方法已经被虚拟机调用过时,虚拟机将这两种情况视为没有必要执行。

被判定为需要执行的对象将会被放在一个队列中进行第二次标记,除非这个对象与引用链上的任何一个对象建立关联,否则就会被真的回收。

可达性算法除了GC Roots,还有一个引用,引用分以下几种:

  1. 强引用(Strong Reference):只要强引用还存在,垃圾收集器永远不会回收被引用的对象。
  2. 软引用(Soft Reference):在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会拋出内存溢出异常。
  3. 弱引用(Weak Reference ):被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够, 都会回收掉只被弱引用关联的对象。
  4. 虚引用(Phantom Reference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被收集器回收时收到一个系统通知。

如何判断一个常量是废弃常量?

运行时常量池主要回收的是废弃的常量。那么,我们如何判断一个常量是废弃常量呢?

  1. JDK1.7 之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时 hotspot 虚拟机对方法区的实现为永久代
  2. JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是 hotspot 中的永久代
  3. JDK1.8 hotspot 移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)

假如在字符串常量池中存在字符串 "abc",如果当前没有任何 String 对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池了。

如何判断一个类是无用的类?

方法区主要回收的是无用的类,那么如何判断一个类是无用的类的呢?

判定一个常量是否是“废弃常量”比较简单,而要判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面 3 个条件才能算是 “无用的类”

  • 该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
  • 加载该类的 ClassLoader 已经被回收。
  • 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

虚拟机可以对满足上述 3 个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。

垃圾回收算法

标记-清除算法

标记-清除算法就是,先标记哪些对象是存活的,哪些对象是可以回收的,然后再把标记为可回收的对象清除掉。

从下面的图可以看到,回收后,产生了大量的不连续的内存碎片,如果这个时候,有一个比较大的对象进来,却没有一个连续的内存空间给他使用,又会触发一次垃圾收集动作。

image-20220313154502607

标记-复制算法

为了解决效率问题,“标记-复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。

复制算法是通过两个内存空间,把一方存活的对象复制到另外一个内存空间上,然后再把自己的内存空间直接清理。标记后,此时情况如下:

image-20220313154625050

然后再把左边的存活对象复制到右边:

图片

复制完后,再清理自己的内存空间:

图片

右边的空间开始回收,再复制到坐标,如此往复。这样就可以让存活的对象紧密的靠在一起,腾出了连续的内存空间。

缺点就是空间少了一半,这少了一半的空间用于复制存活的对象。但是在实际过程中,大部分的对象的存活时间都非常短,也就是说这些对象都可以被回收的。

比如说左边有100M空间,但是只有1M的对象是存活的,那我们右边就不需要100M来存放这个1M的存活对象。

因此我们的新生代是分成3个内存块的:Eden空间、From Survivor空间、To Survivor空间,他们的默认比例是8:1:1。

也就是说,平常的时候有Eden+Survivor=90M可以使用,10M用于存放存活对象(假设100M空间)。

标记-整理算法

除了新生代,老年代的内存也要清理的,但是上面的算法并不适合老年代。

因为老年代对象的生命周期都比较长,那就不能像新生代一样仅浪费10%的内存空间,而是浪费一半的内存空间。

标记-整理与标记-清除都是先标记哪些对象存活,哪些对象可以回收,不同的是他并没有直接清理可回收的对象,而是先把存活的对象进行移动,这样存活的对象就紧密的靠在一起,最后才把垃圾回收掉。

回收前,存活对象是不连续的。

图片

回收中,存活对象是连续的。

图片

回收后,回收垃圾对象。

图片

分代收集算法

当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

比如在新生代中,每次收集都会有大量对象死去,所以可以选择”标记-复制“算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。

延伸的面试题:HotSpot 为什么要分为新生代和老年代?根据上面的对分代收集算法的介绍回答。

性能与优化

由于每次GC,都会Stop The World,也就是说,此时虚拟机会把所有工作的线程都停掉,会给用户带来不良的体验及影响,所以我们要尽量减少GC的次数。

针对新生代,Minor GC触发的原因就是新生代的Eden区满了,所以为了减少Minor GC,我们新生代的内存空间不要太小。如果说我们给新生代的内存已经到达机器的极限了,那只能做集群了,把压力分担出去。

老年代的垃圾回收速度比新生代要慢10倍,所以我们也要尽量减少发生Full GC。

根据前面的JVM内存分配,我们知道有几种对象直接进入老年代:

  • 大对象直接进入老年代:这个没办法优化,总不能调大对象大小吧,那这些大对象在新生代的复制就会很慢了。
  • 长期存活的对象将进入老年代:年龄的增加,是每次Minor GC的时候,所以我们可以减少Minor GC(这个方法上面提到了),这样年龄就不会一直增加。
  • 动态年龄判断:有一个重要的条件就是在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,所以要加大新生代的内存空间。
  • 空间分配担保:这里面有一个条件是Minor GC后,Survivor空间放不下的就存放到老年代,为了让存活不到老年代,我们可以加大Survivor空间。

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

虽然我们对各个收集器进行比较,但并非要挑选出一个最好的收集器。因为直到现在为止还没有最好的垃圾收集器出现,更加没有万能的垃圾收集器,我们能做的就是根据具体应用场景选择适合自己的垃圾收集器。试想一下:如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。

JVM CMS垃圾收集器

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值