JVM第三周 垃圾回收算法

490 阅读14分钟

(面试题)什么情况下JVM内存中的一个对象会被垃圾回收

垃圾回收算法

引用计数法

  • 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;

  • 任何时刻计数器为零的对象就是不可能再被使用的。

引用计数法的缺陷(举例说明)

例子来自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 3.2.1章节

对象objA和objB都有字段instance,赋值令 objA.instance=objB及objB.instance=objA,除此之外,这两个对象再无任何引用,实际上这两个对象已 经不可能再被访问,但是它们因为互相引用着对方,导致它们的引用计数都不为零,引用计数算法也 就无法回收它们。 [《手撸 Spring》 • 小傅哥.pdf](............\Desktop\《手撸 Spring》 • 小傅哥.pdf)

public class ReferenceCountingGC {
    
    public               Object instance = null;
    
    private static final int    _1MB     = 1024 * 1024;
    /*** 这个成员属性的唯一意义就是占点内存,以便能在GC日志中看清楚是否有回收过 */
    private              byte[] bigSize  = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null; 
        // 假设在这行发生GC,objA和objB是否能被回收?
        System.gc();
    }
}

虚拟机并没有因为这两个对象互相引用就放弃回收它们,这也从侧面说明并没有通过“引用计数法”来判定哪些对象可以被回收,哪些对象不可以被回收。

可达性算法

一句话概括:对每个对象,都分析一下有谁在引用它,然后一层一层往上判断,看是否存在一个GC Roots。

只要你的对象被方法的局部变量和静态变量所引用,那么JVM垃圾回收线程就不会回收掉它。

GC Roots:

  • 局部变量
  • 静态变量

Java中对象不同的引用类型

强引用

private static ReplicaManager replicaManager = new ReplicaManager();

一个变量引用一个对象

只要是强引用的类型,那么垃圾回收时绝对不会去回收这个对象。

软引用

private static SoftReference<ReplicaManager> replicaManager = new SoftReference<>(new ReplicaManager());

变量replicaManagerReplicaManager对象的引用,属于软引用。

软引用和垃圾回收

正常垃圾回收下 是不会回收软引用对象;但是如果你在进行垃圾回收之后,发现内存空间还是不够放新的对象,内存都快要溢出的话,此时就会将这些软引用对象回收掉;哪怕它被变量引用了,但是因为你是软引用,所以还是会被回收掉。

弱引用

private static WeakReference<ReplicaManager> replicaManager=new WeakReference<>(new ReplicaManager());

只要发生垃圾回收,就会将对象回收掉。

虚引用

应用很少,稍微知道即可

API:PhantomReference

Object#finalize

根据我们前面的铺垫,不难得出以下结论:

  1. 有GC Roots引用的对象不能被回收(但是,请看第2条总结),没有GC Roots引用的对象会被回收
  2. 如果有GC Roots引用,但是如果是软引用或者弱引用,仍然可能会被回收掉

烂大街面试题:假设没有GC Roots引用的对象,在垃圾回收阶段一定会被回收掉吗?

答案:当然不是。

在finalize方法里面,对象可以拯救自身。

思考:如果发生垃圾回收,会回收掉ReplicaFetcher对象吗

public class Kafka{
    public static ReplicaManager replicaManager = new ReplicaManager();
}
public class ReplicaManager{
    public ReplicaFetcher replicaFetcher  = new ReplicaFetcher();
}
public class ReplicaFetcher{
   public ReplicaFetcher(){
        // ...
   }
}

存在GC Roots : replicaManager,因此ReplicaFetcher对象不会被回收掉。

(面试题)JVM中哪些垃圾回收算法,谁优谁劣

复制算法(主要服务新生代GC)

  • 内存碎片 -> 内存浪费

优点

这么做最大的好处,就是只有10%的内存空间是被闲置的,90%的内存都被使用上了(内存使用效率高)

无论是垃圾回收的性能,内存碎片的控制,还是说内存使用的效率,都非常的好。

  • 内存空间利用率高
  • 内存碎片小(复制+清空,依赖一块空白的Survivor区)

为什么要使用2 Survivor+Eden这样的方式分配新生代内存?

目的:提高内存利用率,如果是两块内存区域实现内存算法,就会导致其中一块内存永远得不到充分利用

为什么新生代GC不使用标记清除算法呢?

  • 新生代存活对象不多,如果是标记清除,就会导致大量的垃圾对象被清除掉,产生很多内存碎片,降低了内存的使用效率

    可能因为内存碎片太多的缘故,虽然所有的内存碎片加起来其实有很大的一块内存,但是因为这些内存都是碎片式分散的,所以导致没有一块完整的足够的内存空间来分配新的对象

特殊情况

  • 万一垃圾回收过后,存活下来的对象超过了10%的内存空间,在另外一块Survivor区域中放不下咋整?

  • 万一我们突然分配了一个超级大的对象,大到啥程度?新生代找不到连续内存空间来存放,此时咋整?

  • 到底一个存活对象要在新生代里这么来回倒腾多少次之后才会被转移都老年代去?

思考题

  1. 可以估算一下,每秒钟系统会使用多少内存空间,然后多长时间会触发一次垃圾回收?

  2. 垃圾回收之后,你们系统内大体会有多少对象存活下来?为什么?

  3. 然后都有哪些对象会存活下来?存活下来的对象会占多少内存空间?

    随着不停的跟着专栏学习,希望大家多结合自己负责的系统来思考,你会养成一个核心能力,能够从JVM的角度去考虑系统运行时的模型。

    Minor GC非常频繁

(面试题)新生代和老年代分别适合什么样的垃圾回收算法

新生代中的对象什么情况下会进入老年代

1. 躲过15次Minor GC(Young GC)后进入老年代

-XX:MaxTenuringThreshold 默认为15

2. 动态对象年龄判断

假如说当前存放对象的Survivor区域里,一批对象的总大小 大于了 这块Survivor区域的内存大小的80%,那么此时大于等于这批对象年龄的对象,就可以进入老年代了。

举例说明:

  • 前提:Eden空间 800M,Survivor 1: 100M ,Survivor 2: 100M

假设Survivor2区有两个对象,这俩对象的年龄一样,都是2岁

然后俩对象加起来对象超过了50MB,超过了Survivor2区的100MB内存大小的一半了,这个时候,Survivor2区里的大于等于2岁的对象,就要全部进入老年代里去。

这就是所谓的动态年龄判断的规则,这条规则也会让一些新生代的对象进入老年代。(就是不用经过15次MinorGC)

本质:年龄1+年龄2+年龄n的多个年龄对象总和超过了Survivor区域的50%,此时就会把年龄n以上的对象都放入老年代。

目的:希望可能长期存活的对象,提早进入老年代。

3. 大对象直接进入老年代

  • JVM参数: -XX:PretenureSizeThreshold,单位是字节

如果你要创建一个大于这个大小的对象,比如一个超大的数组,或者是别的对象,此时就直接把这个大对象放到老年代里去。压根儿不会经过新生代。

之所以这么做,就是要避免新生代里出现那种大对象,然后屡次躲过GC,还得把他在两个Survivor区域里来回复制多次之后才能进入老年代,那么大的一个对象在内存里来回复制,不是很耗费时间吗?

处于性能考虑

4. Minor GC后的对象太多放不下Survivor区怎么办呢?

问题来了,如果在Minor GC之后发现剩余的存活对象太多了,没办法放入另外一块Survivor区怎么办?

再具体一些,假设在发生GC的时候,发现Eden区里超过150MB的存活对象,此时没办法放入Survivor区中,此时该怎么办呢?(Survivor区空间为100MB)

此时必须将存活对象直接转移放入老年代。

老年代空间担保原则

如果新生代中有大量对象存活下来,确实是自己的Survivor区放不下了,必须转移到老年代去;

那么假设老年代剩余空间也不够放这些对象了呢

  1. 首先,在执行任何一次Minor GC前,JVM会检查一下老年代可用的内存空间,是否大于新生代所有对象的总大小

    这是因为在最极端的情况下,可能一次新生代Minor GC后,所有对象都存活下来了,那么所有对象就会进入老年代了。

    1. 如果发现老年代中可用的内存大小 是大于 新生代中所有对象的大小,此时就可以放心大胆的对新生代发起一次Minor GC了,因为即使Minor GC后所有对象存活,Survivor区放不下了,也能放进老年代。

    2. 如果发现老年代中可用内存大小 小于 新生代中的所有对象大小;这个时候有可能会出现Minor GC后新生代的对象全部存活下来,然后这些存活对象要被转移到老年代中去,但是老年代内存又不够(存在这样可能)

      • 所以假如Minor GC之前,发现老年代的可用内存已经小于了新生代的全部对象大小了,就会看一个**“-XX:-HandlePromotionFailure”**的参数是否设置了

        • 如果设置了参数,就会执行下一步判断操作

          • 看老年代的可用内存大小 是否 大于 之前每一次Minor GC后进入老年代的对象的平均大小

            假设之前每一次Minor GC后,平均有10MB左右的对象会进入老年代,那么此时老年代的可用内存大于10MB,这就表明,很可能这次Minor GC后也会有10MB左右的对象会进入老年代,此时老年代的空间是足够的

            这样就会冒险尝试一下Minor GC

            1. Minor GC后,剩余存活对象的大小,是小于Survivor区的大小,此时存活对象直接进入Survivor区即可

            2. Minor GC后,剩余存活对象的大小,大于Survivor区的大小,但是小于老年代可用空间,直接进入老年代就行了

            3. Minor GC存活后,剩余存活对象的大小,大于Survivor区的大小,同时大于老年代可用空间

              老年代目前放不下这些存活对象,就会发生 “Handle Promotion Failure“的情况,这个时候就会触发一个Full GC(Full GC就是对老年代进行垃圾回收,同时一般也会对新生代进行垃圾回收

              老年代此时空间不足,必须将老年代中没人引用的对象清除掉,然后才可能让Minor GC后剩余的存活对象进入老年代里面

              比较极端的是,假如老年代GC后,还是没有足够的可用空间容纳这些存活对象,就会导致“OOM”内存溢出了

          • 如果是小于,就会触发一次Full GC,就是对老年代进行GC,尽量腾出一些空间来,然后再执行Minor GC

        • 如果未设置该参数,就会触发一次 Full GC,就是对老年代进行GC,尽量腾出一些空间来,然后再执行Minor GC

详细图示

图示即对上述文字的图形化展示,便于我们理解Minor GC对于特殊情况的处理。

老年代垃圾回收算法

老年代垃圾回收时机

  1. 在Minor GC之前,发现Minor GC后要进入老年代的对象太多了,老年代放不下,此时需要提前触发一个Full GC,然后再带着进行Minor GC
  2. 要不然就是发现Minor GC后,发现剩余对象太多,老年代都放不下了

标记整理算法

  1. 标记出存活对象
  2. 让存活对象在内存里进行移动,把存活对象尽量移动到一边,从而使存活对象紧凑的靠在一起,避免垃圾回收后存在过多内存碎片的问题
  3. 清理垃圾对象

算法特点

老年代的垃圾回收算法比新生代的垃圾回收算法的速度要慢上10倍;

如果系统频繁出现老年代的垃圾回收(Full GC),会导致系统性能被严重影响,从而出现频繁卡顿的情况。

因此JVM优化的目的之一,就是减少Full GC的频率。

剧透一下,所谓的JVM性能优化,就是尽可能让对象都在新生代分配和回收,尽量别让太多对象频繁进入老年代,避免频繁对老年代进行垃圾回收,同时给系统足够的内存,避免新生代频繁的进行垃圾回收。

大厂面试题:JVM中都有哪些常见的垃圾回收器,各自的特点是什么?

Serial&&Serial Old垃圾回收器

工作原理:单线程运行,垃圾回收的时候回停止我们的工作线程,让我们系统直接卡死不动,然后让它们垃圾回收

被淘汰的垃圾回收器

ParNew和CMS垃圾回收器

ParNew: 一般是用于新生代的垃圾回收器,CMS一般是用于老年代的垃圾回收器。

  • 多线程并发机制,拥有更好的性能
  • 一般是线上生产系统的标配

G1垃圾回收器

统一收集新生代和老年代,采用了更加优秀的算法和设计机制

Stop The World(JVM调优目的)

  • 垃圾回收线程执行垃圾回收工作时,会停止我们Java系统的工作线程的运行,此时不能创建Java对象。

  • 一旦垃圾回收完毕,就可以继续恢复我们写的Java系统的工作线程的运行了,然后我们的那些代码就可以继续运行,继续在Eden中创建新的对象

Stop The World造成的系统卡顿

假设我们的Minor GC要运行100ms,那么可能就会导致我们的系统直接停顿100ms不能处理任何请求

在这100ms期间用户发起的所有请求都会出现短暂的卡顿,因为系统的工作线程不在运行,不能处理请求。

  • Minor GC

    假设你开发的是一个Web系统,那么可能导致你的用户从网页或者APP上点击一个按钮,然后平时只要几十ms就可以返回响应了

    现在因为你的Web系统的JVM正在执行Minor GC,暂停了所有的工作线程,导致你的请求过来到响应返回,这次需要等待几百毫秒。

  • Full GC

    而Full GC是最慢的,有的时候弄不好一次回收要进行几秒钟,甚至几十秒,有的极端场景几分钟都是有可能的

    一旦你频繁的Full GC,系统每隔七八分钟就会卡顿个30秒。

    在30秒内任何用户的请求全部卡死无法处理,然后用户看到的都是系统超时之类的提示,这会让用户体验极差

总结:无论是新生代GC还是老年代GC,都尽量不要让GC频率过高也避免持续时间过长,避免影响系统正常运行,这也是使用JVM过程中一个最需要优化的地方,也是最大的一个痛点

  • GC频率过高,就会不停的卡顿
  • GC持续时间过长,就会导致卡顿时间太长,特别影响用户体验,尤其是高并发系统(垃圾回收器的优化)

JVM优化是什么

我们作为一个合格的Java工程师,我们的责任就是尽可能搞懂这些垃圾回收器的运行机制和算法;

合理的对线程系统优化内存分配和垃圾回收,尽可能减少垃圾回收的频率,降低垃圾回收的时间,减少垃圾回收对系统运行的影响。

垃圾回收器的优化

减少“Stop the World”的时间,避免长时间卡死我们的系统。

案例分析:一个日处理上亿数据的计算系统

  • 案例槽点:老年代频繁Full GC

  • 案例背景:

    • 计算系统会从MySQL和其他数据源中获取数据,然后放到JVM内存里计算
    • 计算系统每分钟要执行500次数据提取和计算任务
      • 分布式计算系统,由多台服务器组成
      • 每台服务器每分钟要执行100次数据提取和计算任务
      • 每一次会提取大概1万条左右的数据到内存里计算,平均每次计算需要消耗 10秒的时间
      • 每台服务器的配置:4核心8G内存
      • JVM配置: JVM内存4G,新生代和老年代分别占用1.5G内存

详细图示

知识补充

面试题

一个parNew+cms的GC,如何保证只做ygc,JVM参数怎么设置

首先,上线系统以后,要借助一些工具观察每秒钟会新增多少对象在内存里,然后多长时间触发一个ygc(Minor GC),平均每次Minor GC后有多少存活对象,Survivor区是否可以放下。

关键点就是要让Survivor区能够放下,并且不能因为动态年龄判定规则进入老年代。

老年代为什么不采用复制算法

因为老年代存活对象太多了,复制太损耗性能了