再看 JVM

 2023-01-24
原文作者:前行的乌龟 原文地址:https://juejin.cn/post/6847902216939012104

202301011712503151.png

文章太长了,分2篇写吧,上一篇:再看 JVM(1)

堆内存

想必大家对堆内存都是耳熟能详了,自己都去仔细研究过了,但是这里还是要重点说明,尽量力争全面,有些细节点的点还是有很多人不知道的 φ(≧ω≦*)♪

堆内存特点

  • 几乎所有的对象实例都在堆空间分配内存

  • 方法结束后,堆中的对象不会马上被移除,仅仅在垃圾回收时才会移除

  • 栈上保存对象引用,对象本身还是储存在堆内存中的

    202301011712511272.png

堆内存结构

大部分现代垃圾收集器都是基于分带收集理论设计,而决定堆空间的又是GC,所以 JVM 采用哪种类型的垃圾收集器,堆空间的结构就趋近哪种构型设计

我们以 JDK1.8 Hotspot 虚拟机为准,目前开发绝大部分都是 JDK1.8 的,因为之后就收费了 o( ̄ヘ ̄o#)

202301011712518913.png

对象分2类:

  • 生命周期非常端的瞬时对象,这类对象的创建和销毁都非常快,比如方法里面创建的对象
  • 生命周期非常长,某些极端情况下和JVM生命周期一样长,比如okhttp的单例,application的单例,最典型的就是一些链接的操作对象了,妥妥的超长生命周期 o(一︿一+)o

堆内存和GC算法都是围绕这2种生命周期的对象来的,堆内存分成:新生代和老年代2个部分,新生代保存那些生命周期短的对象,尤其是经过IBM的研究,80%的对象生命周期都只有非常短,像栈帧里面开辟的对象都是这种类型的。老年代保存那些生命周期及其漫长的对象,新生代的对象要是经过15次GC之后会升级到老年代里

至于 Eden、S0、S1 懒的说了,大家自己去查把,估计大家都知道 o( ̄ε ̄*)

Eden、S0、S1 默认的比例是 8:1:1,但打印出来实际上是 6:1:1,原理是默认开启了 自适应内存分配策略,当然你就是把它关了也还是 6:1:1,除非你通过 -XX:SurvivorRatio=8 显示指定才有效,注意自适应内存分配策略下有时候 S0、S1 的大小会不一样

堆内存 JVM 配置、命令

  • -XX:UseTLAB - TLAB 线程专属空间大小
  • Xms - 堆内存初始值,默认=物理内存的 1/64
  • Xmx - 堆内存最大值,默认=物理内存的 1/4
  • Xms500m - VM options 这么写
  • -XX:NewRatio=2 - 新生代老年代比例,2的意思是新生代是1占总数的1/3,老年代代是2占总数的2/3,一般我们不改这个参数,因为新生代小了,意味这GC回收频率就要高了
  • -XX:SurvivorRatio=8 - Eden、S0、S1 的比例,8的意思 Eden 是8占总数的8/10,S0是1占总数的1/10,S1是1占总数的1/10
  • Xmn - 新生代最大值,一般不动,一般都用比例,这个写了比例就不算数了
  • -XX:+UseAdaptiveSizePolicy - 自适应内存分配策略,-号是取消设置,+号是采用设置,这个其实不起作用的...
  • jinfo -flag NewRatio 进程ID - 打印新生代老年代比例
  • jinfo -flag SurvivorRatio 进程ID - 打印新生代内比例

通过 Runtime 对象可以获取这2个参数

    long Xms = Runtime.getRuntime().totalMemory();
    long Xmx = Runtime.getRuntime().maxMemory();

一般情况下我们把 Xms、Xmx 设置成相等的,为的是减少系统压力。他俩要是不等的话,堆内存在需求增长的情况下会不停的去申请内存,新申请的内存和原来内存是不连续的,内存碎片化会降低内存读写性能。内存需求减少的情况下,系统会回收堆内存不用的空间,这样频繁的来来回回申请、回收内存会极大的系统压力,更何况GC本身就很耗费性能还会阻塞用户进程,GC之后我们再来这么一下系统性能压力就更大了 (๑•̀ㅂ•́)و✧

打印堆内存有2个方式:

  • jsts -gc 进程ID: 这是命令行的,随时都能能用
  • -XX:+PrintGCDetails: 这是配置到 VM options 里面的,只有进程结束时才能代印出数据,前面章节介绍过了

看下命令行打印出来的数据,认识下参数:

    ➜  ~ jstat -gc 28763
     S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT     GCT
    25600.0 25600.0  0.0    0.0   153600.0 61443.3   409600.0     0.0     4480.0 774.4  384.0   75.9       0    0.000   0      0.000    0.000
  • C结尾的是总数,U结尾的是使用量
  • EC/EU: 新生代
  • OC/OU: 老年代
  • S0C/S0U: S0
  • S1C/S1U: S1

不过这里有个点要知道,我们用代码把堆内存打印出来,参数我们设置的是:-Xms600m -Xmx600m

    public static void main(String[] args) {
    
        long Xms = Runtime.getRuntime().totalMemory() / 1024 / 1024;
        long Xmx = Runtime.getRuntime().maxMemory() / 1024 / 1024;
    
        System.out.println("Xms:" + Xms);
        System.out.println("Xmx:" + Xmx);
    
    }

实际打印出来的是 575,为啥???

    Xms:575
    Xmx:575

因为这里少了一个 s1 的大小,堆内存中真正能存数据的就是 Ednt+s0或者s1其中的一个,s0、s1 是为了相互赋值的,一个时刻只有一个用来存储对象数据,另一个留着准备给GC复制对象用

jvisualvm 截图看下,S0、S1 的大小是25M

202301011712528174.png

实际上 -XX:+PrintGCDetails 打印出来的新生代的 total 也是575,也是不算 S1 的

    Xms:575
    Xmx:575
    Heap
     PSYoungGen      total 179200K, used 12288K [0x00000007b3800000, 0x00000007c0000000, 0x00000007c0000000)
      eden space 153600K, 8% used [0x00000007b3800000,0x00000007b44001b8,0x00000007bce00000)
      from space 25600K, 0% used [0x00000007be700000,0x00000007be700000,0x00000007c0000000)
      to   space 25600K, 0% used [0x00000007bce00000,0x00000007bce00000,0x00000007be700000)
     ParOldGen       total 409600K, used 0K [0x000000079a800000, 0x00000007b3800000, 0x00000007b3800000)
      object space 409600K, 0% used [0x000000079a800000,0x000000079a800000,0x00000007b3800000)
     Metaspace       used 3387K, capacity 4496K, committed 4864K, reserved 1056768K
      class space    used 376K, capacity 388K, committed 512K, reserved 1048576K

GC

GC 的特点请大家记住:

关于垃圾回收,频繁在新生代,很少在老年代,几乎不在方法区,80% 的对象在新生代销毁

GC JVM 参数、配置

  • -XX:MaxTenuringThreshold=15 老年代阀值
  • -XX:PretenureSizeThreshold=600k 单个对象大小大于这个数直接进入老年代
  • ``

GC 种类

JVM 在GC时,并非每次都对3个内存区域(堆、栈、方法区)一起回收,大部分时间回收的还是新生代

  • YGC/Minor GC/Young GC:
    新生代回收,IBM 研究 80% 的对象都朝生夕死,过不去一次GC
  • Major GC/Old GC:
    老年代回收,只有老版 JDK 的并发垃圾回收器 CMS 会对老年代做单独的GC
  • Mixed GC:
    混合回收,回收新生代和部分老年代,只有 G1 垃圾回收器会这么搞
  • FGC/Full GC:
    整堆回收,回收整个堆内存和方法区

老年代的回收比新生代耗时多了,至少也是10倍以上的差距,STW 时间更长 ╮(╯▽╰)╭ 所以涉及到老年代的 GC 都是我们应该尽量避免的

新生代 YGC 虽然会暂停用户线程,但是 YGC 一般很快,并不会造成多少压力

GC 会引起STW,STW 侯iu停止用户线程进行GC,幸存区 S0、S1 满了是不会触发GC的,会随着 Eden 一起G进行GC

STW 是判断新生代对象是不是垃圾,对新生代垃圾做标记的过程

System.gc() 调起的就是 Full GC,老年代和方法区的不足都会调起 Full GC,老年代和方法区GC之后还是不足的话,要是不能动态调节那么就OOM了

对象分配策略

考验我们的时候到了,这里是看你 JVM 调优,GC 理解到不到位的核心知识点了,可以参杂大量的实际案例去问你,你也可以从实际案例区分析。面试官问到这里的时候千万不要狗带啊 o( ̄ε ̄*)

这里是真对正常的从年轻代晋升老年代的过程来说的,下面都是基本都是特殊情况下,新生代对象更早的生如老年代

1. 对象优先在Eden中分配

如果 Eden 区没有足够的空间,执行一次 YGC,幸存者要是放不下,放不下的部分进入老年代,老年代放不在执行一次 FGC,还放不下 OOM

2. 大对象直接进去老年代

创建大对象,Eden 放不下直接方老年代,JVM 参数 -XX:PretenureSizeThreshold=600k 大于这个数直接进入老年代,通常比较长的字符串和数组都是属于大对象的这个范围,当然具体的还得看了。大对象最坑的是需要较多的连续内存空间,一到连续这个词很可能你看着对象不是太大,但是 Eden 虽然有这么大空间,但是就是没有这么大的连续内存空间了,这时就会被升级到老年代里面区了。蛋疼的是你创建出来的这些大对象还是朝生夕死的,一次 Full GC 很慢的... 你非淂来这么一次吗!

上面说的大对象需要的从来都是也必须是连续内存空间,对象分配时给的都是连续内存空间,必须注意 O(≧口≦)O

大对象直接晋升到老年代不会引起GC啊,就是内存复制了一下,注意可不是引起GC啦

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

JVM给每个对象设置了一个对象年龄(Age)计数器,每熬过一场 YGC 之后,对象年龄增加1岁,当它的年龄增加到阈值(默认为15),就会晋升到老年代。

JVM提供了一个 -XX:MaxTenuringThreshold=15 参数,超过这个年龄阈值进入老年代

4. 动态年龄判定

为了适应不同程序的内存状态,JVM并不是永远要求对象的年龄必须达到 MaxTenuringThreshold 之后才能晋升老年代

如果幸存者区中相同年龄的所有对象大小的总和大于幸存者空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代

5. 空间分配担保机制

YGC 之前会对于 GC 级别进行判断

  • 条件: 老年代的连续空间是否大于新生代对象总大小或者历次晋升的平均大小

    • 是 -> YGC
    • 否 -> FGC

1.6之前,有个 HandlePromotionFailure 参数,代表是否要冒险进行一次 YGC

  • 是 -> 再走上面的流程
  • 否 -> 进行FGC

这个参数1.6之后就不用了,1.6之后只要老年代的连续空间大于新生代对象的总大小或者历次晋升到老年代的对象的平均大小就进行YGC,否则FGC

总结

综上所述,新生代或者 Eden 大小或者瞬间创建大量对象都非常容易引起这批对象过早晋升老年代,从而频繁触发 FGC。另外 android 堆内存每次新申请内存空间都只有几M或者十几M,很小,要是你的代码某个点开销比较大的话,很容易就 FGC 了,FGC 绝逼是卡的最严重的那种,我就经常写这样的代码 ✧(≖ ◡ ≖✿)

到这里大家应该知道了,堆对象搞得这么复杂就是为了提高GC性能,对于死的快的,我们都隔离到 Eden 里面去,这样高频的 YGC 就能尽可能少的扫描对象了

TLAB

TLAB - 线程私有缓存

堆内存是线程间共享的,若是多个线程操作同一个内存地址也是会产生并发问题的,JVM对于这个问题就是加锁。针对这个场景的就是对象创建了,多个线程同一时刻都要在堆内存分配空间来创建对象,这就是资源冲突,而且对象创建是非常频繁的,要是每次都加锁那性能还能看吗  ̄△ ̄

所以TLAB产生了,解决问题的最好办法就是消灭问题本身,不就是资源冲突嘛 ( ̄_, ̄ ) 那我们每个线程在堆内存中搞个私有空间不就行了,这一小块内存空间只给一个线程使用不就没有资源冲突了嘛,不就不用加锁了嘛,这种策略叫:快速分配策略

202301011712535105.png

当然这个TLAB的空间肯定不大,默认大小是每个线程的TLAB占 Eden 的1%,所以线程多了堆内存也扛不住,我估计JVM肯定也有优化的,不是这么简单的

  • -XX:UseTLAB: 开启TLAB,默认是打开的
  • -XX:+TLABSize
  • -XX:TLABWasteTargetPercent=1 TLAB 占 Eden 的比例
  • -XX:TLABRefillWasteFraction
  • -XX:+PrintTLAB

一旦 TLAB 失败,JVM就会加锁保证数据操作的原子性,直接在 Eden 中分配内存

更详细的部分请看:jvm 优化篇-(5)-线程局部缓存TLAB 指针碰撞、Eden区分配

栈上分配、逃逸分析

有面试管问:对象是都在堆上分配吗? 这个考的你知不知道栈上逃逸这个点

随着JIT编译器技术的发展,栈上分配、标量替换这些优化技术会动摇所有对象都分配到堆内存上这个观点

经过逃逸技术分析后,如果一个对象没有逃逸出方法的话,那么就可能被优化到直接在栈上分配内存,而不是去堆里开辟内存区域了

阿里巴巴自己的 JVM TaobaoVM 创新出了一个技术 GCIH,生命周期较长的对象可以从heap中移至heap外,从而不必被GC管理,达到降低GC频率,调高GC效率的目的

什么是栈上分配呢,简单来说,方法内创建的对象声明周期要是没有超过该方法的话,new 对象申请内存可以直接在栈帧所在内存上进行,而不用再跑去堆内存上了,堆内存还要有TLAB和加锁

前面分析过,IBM 认为80%的对象都是朝生夕死的,就是说在一个对象在方法内new,完事随着方法执行完成而销毁。如果对象都是在堆内存上开辟的话,第一会进行TLAB优化,TLAB放不下了会在堆内存中加锁,加CAS操作去开辟内存空间,大量的对象的创建销毁会频繁引起YGC

若是这样朝生夕死的对象在栈帧里开辟空间,根据栈内存栈顶缓存机制,将要执行的栈帧会加载到CPU高速缓存中,然后方法结束后对象随着栈帧数据一起销毁回收,优点是:

  1. cpu高速缓存速度那是真的快,比内存快100倍
  2. 减少cpu和内存的通信,也可以节省一些时间,内存到CPU毕竟有速度限制,在cpu时钟周期角度来说是很慢的
  3. 大大减少了堆内存压力,可以有效减少YGC的频率,这是代码优化很重要的一点

注意栈上分配,优化的是方法中new的对象而不是其他

典型的栈上分配例子:

    public void name1(){
        String name = new String("AA");
        int length = name.length();
    }

优化的就是name了,有了栈上分配技术,name就不用开辟在堆空间了

JDK7开始,默认就开启了逃逸分析

  • -XX:+DoEscapeAnalysis: 开启逃逸分析
  • -XX:PrintEscapeAnalysis: 打印逃逸分析结果

逃逸分析是在编译期就确定的了,虽然我们人自己可以确定最终对象是不是逃逸了,但对于编译器来说就不能确定了,只要不是百分百确定,那么方法里new的对象就无法使用栈上分配技术

所以分析逃逸成不成,关键就是看方法内new的对象是不是被外部引用

下面栈上逃逸不能起作用的例子,知己知彼百战不殆

    public class Max {
    
        public Dog dog ;
    
        /**
         * 逃逸失败,方法返回的对象有可能是全局变量
         * 但是对于编译器来说不是百分之百可以确定对象生命周期只在方法内
         * 所以即便最后真的返回的是new的Dog,这个new的对象也是在堆内存上分配的
         * @return
         */
        public Dog dog1(){
            return dog == null ? new Dog() : dog;
        }
        
        /**
        * 逃逸失败,new 的对象引用直接被交给全局变量了
        */
        public void getDog(){
            this.dog = new Dog();
        }
        
        public Dog getVlue(){
            return new Dog();
        }
    
        /**
         * 逃逸失败,dog 的生命周期虽然没有出dog2()这个方法
         * 但是对于getVlue()方法中new的对象来说,这个对象是跨栈帧的了
         * 栈帧之间不能相互访问,执行完一个栈帧,帧帧数据就销毁了
         * 所以getVlue()方法里面new的对象必须在堆中开辟
         * 要不对象是new出来了,但是随着栈帧一起销毁了,还怎么返回给别的方法使用啊
         */
        public void dog2(){
            Dog dog = getVlue();
        }
    }

最后结论:开发中能使用局部变量的,就不要在方法外面定义了

神转折来啦 (/// ̄皿 ̄)○~ 这是《深入理解JVM虚拟机里的原话》

逃逸分析的技术99年就出现了,一直到JDK1.6 Hotspot 才开始支持初步的逃逸分析,即便到现在这项技术仍未成熟,还有很大的改进余地。不成熟的原因是逃逸分析的计算成本非常高,甚至不能保证带来的性能优势会高于计算成本,在实际应用中,尤其是大型应用中反而发现逃逸发分析可能出现不稳定的状态。直到JDK7时才默认开启这项技术,服务模式的java程序才支持

JVM 角度看代码优化中例子效果明显,更多原因是因为下面会说的标量替换,没看到内存快照嘛,即便开启逃逸分析之后,Dog对象在堆内存中还是有非常多的对象存在,这和理论差距还是满大的

String

String 不可变性

字符串储存在堆内存的字符串常量池中,对于内存中存储的字符串来说,一旦字符串在堆内存中存储,那么该字符串就不能再变化了,若是要改变字符串内容,会重新生成一个新的字符串存储再字符串常量池中

字符串常量池里是不会存储相同内容的字符串的

看几个例子:

        public void test1() {
    
            String name1 = "abc";
            String name2 = "abc";
    
            System.out.println(name1 == name2); // true
        }
    
        public void test2() {
    
            String name1 = "abc";
            String name2 = "abc";
    
            name2 += "edf";
            System.out.println(name1); // abc
            System.out.println(name2); // abcedf
        }
    
        public void test3() {
    
            String name1 = "abc";
            String name2 = name1.replace("a", "h");
    
            System.out.println(name1); // abc
            System.out.println(name2); // hbc
        }

任何对字符串的处理都不会改变原来字符串的值,会产生一个新的字符串

String 的变化

  • JDK1.8中,String 内部使用 char[] 字符数组存储
  • JDK1.9中,String 内部使用 byte[] 字节数组存储

因为 String 的不可变性,一旦字符串写了,那么就是不可变的,长度就是固定的,所以使用数组来存储

经过研究发展,大多数人使用的还是拉丁文字,这样用一个char2个字节储一个字符就浪费内存空间了,所以改成byte1个字节

但是对于像中文这样基础字符多的文字,添加了一个字符集标识,还是使用2个或是更多个字符标识

这点其实可以理解为从JDK1.9开始,java 强制使用UTF-8字符编码了

UTF-8 使用一至四个字节为每个字符编码:

  • 使用一个字节编码:128 个 ASCII 字符(Unicode 范围由 U+0000 至 U+007F)
  • 使用二个字节编码:带有变音符号的拉丁文、希腊文、西里尔字母、亚美尼亚语、希伯来文、阿拉伯文、叙利亚文及马尔代夫语(Unicode 范围由 U+0080 至 U+07FF)
  • 使用三个字节编码:其他基本多文种平面(BMP)中的字符(CJK属于此类-Qieqie注),中文就在这个范围内
  • 使用四个字节编码:其他 Unicode 辅助平面的字符,比如 emoji 表情

String pool

字符串常量池是一个数组固定大小的 Hashtable,Hashtable是数组+链表,类似于map,存储hash值的数组大小是固定的,数组大小要是小了或是寸的字符串太多了,那么链表就会很长,那每次添加新的字符串进来的话,每次遍历链表的时间就会变长,性能就会降低

-XX:StringTableSize=2000 修改字符串常量池大小

  • JDK1.6 默认长度是 1009
  • JDK1.7 是 60013
  • JDK1.8 最小值必须是 1009

从 JDK1.7 开始,字符串常量池都保存在堆内存中了 (๑•̀ㅂ•́)و✧

202301011712544446.png

注意字符串常量池中存储的字符串对象的引用,而不是字符串对象本身

以这行代码为例说下此时的内存结构

    String name = new String("abc");

202301011712553887.png 一共2个对象,new 关键字肯定会在堆内存生成一个String对象的,但是这不是碰到"abc"了嘛,JVM在编译时会有优化,会在字符串常量池中把这个字符串对象创建出来

还有常量池也是会有GC的,它的GC是YGC,这里知道就行了

其他

字符串还有其他的几个重要的点,比如优化、拼接等,大家看我下面的文章吧:

对象的 finalization 机制

java 语言提供了 finalization 机制来允许对象被销毁前进行自定义逻辑操作,当垃圾回收器回收一个对象之前,总会先调用这个对象的 finalize() 方法

finalize() 方法声明在 Ojbect 基类中,但是 Ojbect 的 finalize() 方法是 protected 的,有需要的对象需求自行实现

    Object{
        protected void finalize() throws Throwable { }
    }

注意 finalize() 只会调用一次,不管被GC标记几次,不管是不是复活过,finalize() 只能被执行一次

finalize() 方法永远不要自己手动、主动调用,而是必须交给GC来使用,有3个理由:

  • finalize() 可能会导致对象复活
  • finalize() 方法何时执行是没有保障的,完全由GC线程决定,极端情况下若没有GC,finalize() 方法就永远不会执行
  • 一个糟糕的 finalize() 会严重影响GC性能

由于finalize() 方法,虚拟机中的对象有3种状态:

  • 可触及的: 从根节点开始,可以到达这个对象
  • 可复活的: 对象有可能在 finalize() 方法中复活
  • 不可触及的: finalize() 方法被调用,对象没有复活,就会进入不可触及状态

GC判断一个对象是否可以被回收,至少要经历2次标记过程:

  1. 对象A没有 GC Roots 引用链可达,那么进行第一次标记

  2. 然后进行筛选,判断对象有没有必要执行 finalize() 方法

    • 对象A没有重写 finalize() 方法,或者 finalize() 方法被执行过了,对象A被判定为不可触及状态
    • 对象A 重写 finalize() 方法了,但还未执行,那么对象A会被插入 F-Queue 队列中,这是一个由虚拟机创建的、低优先级的线程,专门执行 finalize() 方法
    • F-Queue 队列会进行第二次标记,如果对象A在 finalize() 方法中与引用链上任何一个对象建立了联系,那么对象A就会被移出去,执行 finalize() 方法

finalize() 方法的意义在哪里,就是为了给对象一个执行回收各种资源、链接等操作的机会,finalize() 方法本身只有一次执行机会,在对象被 GC 标记之后,发现该对象还有 finalize() 方法要回收资源并且真的要回收资源,那么就给这个对象一个机会,先不回收它,让他执行自己的 finalize() 方法,执行完了,下次GC再标记的时候就逃不过GC回收了

GC 算法

什么是垃圾

这是一掉面试题:是指在运行程序中没有任何指针指向的对象,这些对象就是需要被回收的垃圾

呵呵,能不加思考直接会打出来的说实话真没几个人

注意 GC 的回收范围仅仅只有堆和方法区:

202301011712567698.png JVM规范没有强制规定方法区一定要有垃圾回收机制,的看具体的JVM实现有没有了 <( ̄ˇ ̄)/

GC 算法的2个阶段

GC 的算法分成2个阶段:标记清除,好多人混为一坛,这是不对的,也不是非常好的面试回答

  • 标记:

    • 引用计数算法
    • 可达性分析算法
  • 清除

    • 标记-清除算法
    • 复制算法
    • 标记-压缩算法
    • 分代收集算法
    • 增量收集算法
    • 分区算法
  • GC回收原则:频繁收集新生代,较少收集老年代,几乎不动方法区,80%对象收集在新生代

没有最优的算法,只有最合适的算法

引用计数算法

引用计算算法中,每一个对象都有一个整形的计数器属性记录引用数。对象A,只要有任何对象引用到了A,那么A的计算器+1

  • 优点:

    • 实现简单,判定效率高,回收没有延迟性,随时发现随时回收,不用停用户线程
  • 缺点:

    • 增加空间开销,因为每个属性都会增加一个整形的属性
    • 增加时间开销,计数器的加减操作都需要时间
    • 无法处理循环引用,这是致命弱点,导致java没有使用这一算法

什么是循环引用 ( ̄~ ̄) 嚼!

202301011712580419.png a、b、c 他们3就是,按我们的认知,只要P没用了,那么abc也都没用了,但是在引用计数算法中,因为abc相互引用,引用计数器不可能是0,最少也是1,所以没法回收

可达性分析算法

可达性分析算法也叫跟搜索算法、追踪性垃圾收集,以一系列根对象(GC Roots)集合为起点,按照从上到下的方式,搜索可以被跟对象链接的所有对象,搜索路径叫做引用链。不能被引用链引用到的都是垃圾,可以被回收的

可达性分析算法能够有效解决循环引用和内存泄露问题,java、C# 都使用了该算法

2023010117125879610.png

那些对象可以作为跟对象 GC Roots:

  • 虚拟机栈中的对象

    • 比如:栈帧里局部变量表里的对象
  • 本地方法栈JNI里的对象

  • 静态对象

  • 常量对象

    • 比如:字符串常量池中的字符串对象
  • 被同步锁 aynchronized 持有的对象

  • java 虚拟机内部的对象

    • 比如:所有基本数据类型对应的class对象,系统类加载器

应用分析工作必须在一个能保证一致性的内存快照中进行,所有必须停止用户线程,堆用户线程的停顿叫:STW - stop the world,即便是在之前的并行垃圾回收器CMS中,枚举根节点时一样会有STW操作

标记-清除算法

  • 标记: 利用上面引用可达性算法,从 GC Roots 根节点开始,标记所有能达到的对象,标记存储在对象头中
  • 清除: 对堆内存从头到尾进行线性遍历,删除回收没有被标记的对象

注意:

  • 这里标记的是引用链可以到达的对象,然后对整体遍历一遍,就知道哪些是垃圾了,而不是很多人说的对垃圾标记,这里很重要,反过来大家想想,那个好实现
  • 这里的清除不是说真的删除,而是回收垃圾对象的内存地址,而这里垃圾对象还在,只有下次分配对象时才会删除旧有的数据

2023010117130022111.png

优点:

  • 思路简单

缺点:

  • 效率不是最优,需要经历2次遍历,标记一次,清除一次
  • GC需要暂停用户线程
  • 这种方式清除出来的内存都是不连续的,会产生内存碎片,问题很严重,内存分配时很多时候都要求连续空间的 (* ̄ω ̄)

复制算法

为了提高上面标记-清除算法的效率,提出了复制算法。把内存分成2分,每次只使用1分,GC时遍历引用链,把引用链能达到的对象复制到另一份内存中,垃圾的内存区域整体删除。下次重复交换2个内存区域的数据

2023010117130123412.png

优点:

  • 不需要标记,没有清除过程,简单效率更高
  • 对象复制到另一块内存空间,没有内存碎片了

缺点:

  • 对内存的使用效率只有50%
  • 对象复制会造成内存地址改变,这样维护对象引用开销很大,比如栈空间中的对象引用,尤其是对于G1这种拆分成大量region的垃圾收集器,开销更大。大家想象对象储存的2个方式:句柄池直接引用,句柄池就没用这种烦恼

复制算法适用于:存活对象比例很低的情况,新生代就非常适合这个算法

标记-压缩(整理)算法

为了解决复制算法内存利用率低的问题,对标记-清除算法进行改进,加入了内存整理的过程

复制算法适用于对象幸存率低的场景,比如新生代,而标记-压缩(整理)算法就适合老年代这样的场景了

老年代本身内存空间最大,大部分对象不会被回收,还要方大对象,若是内存碎片化严重的话,大对象估计就放不下了

2个阶段

  • 标记: 这个没变
  • 压缩: 把所有幸存的对象压缩到内存一端,按顺序排放,这个是移动操作,不是复制了

2023010117130235413.png

优点:

  • 内存利用率搞了
  • 没有内存碎片了

缺点:

  • 效率最低
  • STW时间最长,幸存对象多,还要挨个移动,时间能不长嘛
  • 维护对象引用也是个麻烦

分代收集算法

分带算法是进一步的优化算法,根据对象生命周期的不同,把他们放到不同的内存区域,使用有针对性的GC算法,以实现细粒度的专业性优化,现在垃圾收集器一般都采用这个思路

分带算法把对象分成2个部分

  • 年轻代: 区域较小,对象生命周期短,适用于复制算法
  • 老年代: 区域大,对象生命周期长,对内存碎片要求严格,使用标记-整理算法

增量收集算法

因为上面几种算法,STW 用户线程挂起时间较长,体验不好,为了减少用户延迟时间,考虑并发进行GC操作

思路:垃圾回收线程每次只收集一小片区域,接着切换到用户线程,一次反复,直到垃圾收集完毕

GC算法没变,但是在妥善处理了线程之间的执行,允许垃圾收集器以分阶段的方式完成标记、整理、复制工作,看着挺像并发的

缺点:

  • 不说别的,这样频繁切换线程,光线程上下文交换来带的性能损耗都是非常大的。线程的切换必须要系统内核调用硬件指令来实现

分区算法

分区算法是针对G1垃圾回收器来说的。在分代的基础上,把内存区域进一步分成一个个小块,每个小块独立使用,独立回收。根据目标时间,每次回收若干个小块,而不是整个堆内存,从而减少一次GC的停顿时间

2023010117130336814.png

最后

这些只是垃圾回收的基本算法,实际要复杂的多,前沿的GC都是符合算法,并行并发兼顾的

垃圾回收关联概念

还是有一些知识点和 GC 关联的,不写不合适,写了之后会拓展一些我们知识点,还是很有意义的,实际也会用的到

System.gc()

System.gc() 是我们手动调用GC的方法,有时候偶尔我们也是会用的。但是很多人其实不知道的是,System.gc() 方法并不会马上触发GC,这个方法其实是给GC线程发个通知,至于GC线程何时GC这个不是我们能控制的了的

System.gc() 触发的GC是 Full GC,这点一定要清楚

        // System 类:
        public static void gc() {
            boolean shouldRunGC;
            synchronized (LOCK) {
                shouldRunGC = justRanFinalization;
                if (shouldRunGC) {
                    justRanFinalization = false;
                } else {
                    runGC = true;
                }
            }
            if (shouldRunGC) {
                Runtime.getRuntime().gc();
            }
        }
        
        // Runtime 类:
        public native void gc();

看代码,最后是走到了一个本地方法里面

还有一个System.runFinalization() 方法,是运行被回收对象的 finalize() 方法,和 System.gc() 一样,不一定会马上就成触发

但是一般我们写了的话,系统不忙的话还是会执行的,反正测试这种情况一般都会跑的

内存溢出

内存溢出是指没有空闲内存,并且垃圾回收器也无法提供更多内存的情况,当然还有一些其他情况也会触发OOM:

  • 空指针
  • 角标越界
  • 直接分配大内存,超过堆内存上限

结合JVM运行时数据区看一下:

  • 堆内存: 有OOM、有GC
  • 方法区: 有OOM、有GC
  • 方法栈: 有OOM、没GC
  • 本地方法栈: 有OOM、没GC
  • 程序计数器: 都没有

在内存溢出之前肯定会执行一次 Full GC 的,大量的内存泄露也会导致内存溢出

STW

stop-the-world 简称 STW,是指GC事件过程中,产生的用户线程停顿,停顿时整个应用进程都会暂停,没有任何相应,频繁的中断会让用户感觉像电影卡带一样,所以我们需要尽量减少STW的产生

引用可达性分析要求时间必须固定住,要是内存时刻不听变化的话,没法确定对象的引用状况,所以可达性分析一定会造成STW

STW 的产生和使用哪种垃圾收集器没有关系,没有一个垃圾收集器不会不产生STW的,即便是并发算法,至少在引用可达分析时一样会STW。STW由JVM在后台自动运行,用户是不可控的

GC算法有2个指标:吞吐量暂停时间,GC主流发展思路都是优先实现低延迟

GC的并发和并行

( ̄~ ̄) 嚼!是的,多线程里最讨厌的概念,GC这里一样涉及到了

我们先回忆下多线程的并发和并行:

  • 并行:

    • 2个线程,同一时刻,运行在不用的核心上
    • 相互之间没有资源冲突
  • 并发:

    • 多个线程,同一时间段内一起执行
    • 可能存在资源争强

然后我们再看看GC里的并发个并行:

  • 并行:

    • 多条垃圾收集线程并行工作,此时用户线程处于等待状态
    • 典型的如:ParNew、Parallel Scavenge、Parallel Old
  • 串行:

    • 单线程做垃圾回收
  • 并发:

    • 用户线程和垃圾回收线程同时进行,也不一定是并行的,也可能会交替执行,具体看会不会有STW,要是有STW还是会交替执行的
    • 典型的如:CMS、G1

安全点和安全区域

1. 安全点: 能够停下来执行GC操作的代码点。程序执行时并非在任何地方都可以停下来开始GC,只有一些特殊的位置才可以GC,这些点就是安全点

  • 安全点要是太少,那么GC间隔就会变长,会造成内存垃圾过多不能及时回收的问题
  • 安全点要是太多,可能会造成GC过于频繁

安全点的挂起方式:

  • 抢先式中断: GC时先中断所有线程,如果线程不在安全点上,那么恢复线程,直到跑到安全点。目前已经没有JVM采用这种方式了
  • 主动式中断: GC时先设置一个标记,线程运行到安全点时,先看这个标记,若是true就中断挂起

2. 安全区域: 是指一段代码片段中,对象的引用关系不会变化,在这个区的任何位置进行GC都是安全的。可以看成是扩展的安全点

既然有了安全点,那为啥还要有安全区域啊?因为GC时有的线程可能处于休眠、阻塞状态,要是在GC阶段这些线程恢复执行就破坏了GC回收垃圾了。碰到这种情况GC是不会把这些休眠的线程唤醒再执行到安全点的,绝对不会

安全区域作用机制:

  • GC时会忽略进入安全区域线程的状态,运行的话就挂起,挂起的话就不用管了
  • 线程离开安全区域时,JVM会检查GC是否完成,没有完成的话线程挂起

这个点我估计一大部分人都说不清楚,貌似这是面试官最爱刁难人的题啦 (੭ˊᵕˋ)੭*ଘ

4中对象引用

  • 强引用:

    • Strong Reference
    • String name = new String("AA")
    • 只要引用关系还在,谁也不能回收它,即便OOM也不会改变
    • 主要问题:内存泄露和OOM
  • 软引用:

    • Soft Reference
    • SoftReference<String> soft = new SoftReference<String>(name);
    • 即将OOM时,会尝试回收软引用,若内存还是不够,就OOM
    • 对象作用范围:还有用,但是非必须的一些对象
    • 应用场景:缓存
  • 弱引用:

    • Weak Reference
    • ``
    • 弱引用对象生命周期只有一次GC,GC来临时会直接回收弱引用对象
    • 应用场景:缓存
  • 虚引用:

    • Phantom Reference
    • ``
    • 虚引用无法获得对象实例,唯一的目的是在GC回收这个对象时收到一个系统通知,和没有引用基本上是一样的了
    • 应用场景:GC监控

4大引用这个面试题在前些年的确是偏门知识点,但是谁让大家爱问呢,现在成了必学必会的了,简单说下大家还是能说出来的。但是NB的面试官还是爱问,问你实际使用,查考的是开发经验够不够深入。一般我们真用不上软引用、弱引用这些,即便会用到,也就是1-2个点会涉及。但是在很多应用框架内都会大量出现他们的身影。发展到现在已经有了固定的使用场景了,这不是光靠背就能搞定的了 (ˉ▽ ̄~) 切~~

补一个概念

  • 1次回收: 就是正常GC回收那些能回收的
  • 2次回收: 内存不足了,回收软、弱引用这些对象

完整的软引用应该这么写:

    String name = "AA";
    SoftReference<String> soft = new SoftReference<String>(name);
    // 必须把强引用对象处理掉,才能只剩下软引用1个
    name = null;
    // 然后通过 get 我们可以从软银用中拿到这个对象
    String name2 = soft.get();

弱引用涉及到一个数据结构:WeakHashMap,有兴趣的可以自己去看,里面的每个entry直接就是弱引用类型了

虚引用,get方法拿不到对象的,并且手动传一个队列进去,在GC回收虚引用之前,GC会把这个虚引用对象放到你声明的这个队列里面

            ReferenceQueue queue = new ReferenceQueue();
            PhantomReference<String> weak1 = new PhantomReference<String>(name, queue);
            name = null;
    
            if (queue != null) {
                try {
                    // queue.remove() 是会阻塞的,天然就需要开专门的线程的
                    PhantomReference<String> weak2 = (PhantomReference<String>) queue.remove();
                    if(weak2!=null){
                        System.out.println("追踪到GC了");
                    }
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }

虚引用说不好厉害不厉害,因为我也没接触到这样的引用案例,也许都在框架内部呢,但是看到的时候不能不认识啊,我估么着应该挺NB的

查到一个虚引用案例,感觉优点勉强,这样的话要专门有一个监听线程

jdk中直接内存的回收就用到虚引用,由于jvm自动内存管理的范围是堆内存,而直接内存是在堆内存之外(其实是内存映射文件,自行去理解虚拟内存空间的相关概念),所以直接内存的分配和回收都是有Unsafe类去操作,java在申请一块直接内存之后,会在堆内存分配一个对象保存这个堆外内存的引用,这个对象被垃圾收集器管理,一旦这个对象被回收,相应的用户线程会收到通知并对直接内存进行清理工作。

垃圾回收器

上文讲了很多垃圾回收算法,但是算法并不能代表最终的垃圾收集器。GC这块发展、变化是JDK中最频繁的,有必要大家了解一下,对我们深入理解JVM有莫大帮助

-XX:+PrintCommandLineFlags 查看哪种垃圾回收器,找其中带+号的就是目前用的

垃圾收集器分类

从GC线程看

  • 串行回收器: GC线程单线程,适合硬件资源差的单核CPU,效率其实也式可以的

  • 并行回收器: GC线程多线程并行

    2023010117130471815.png

从工作模式看

  • 独占式: STW 时间长,GC阶段用户线程必须停止
  • 并发式: 用户线程和垃圾回收线程同时运行,GC延迟降低了不少

从内存压缩算法看

  • 压缩式: 带碎片整理,在内存利用率上有极大提升
  • 非压缩式:

从工作区域看

  • 新生代: 不同内存区域的对象有自己的特点,不能通用回收算法
  • 老年代:

垃圾收集器指标

  • 吞吐量: 用户代码运行时间占总运行时间的比例
  • 垃圾收集器开销: 100% - 吞吐量
  • 暂停时间: 就是常说的STW,执行垃圾回收时,用户线程停顿的时间
  • 收集频率: 相对于整个程序,GC发生的频率
  • 内存占用: java 堆内存占内存总大小的比例
  • 快速: 一个对象从诞生到回收经历的时间

这其中最重要的指标就是暂停时间了,这是我们最关心的,也是所有垃圾收集器一直努力的重点领域

吞吐量和暂停时间有些矛盾,是相互妥协的一堆指标,吞吐量上去了可能单词暂停时间就有些长了,但是吞吐量低了那一定是GC多了,这需要我们呢根据实际来有针对性的做出策略

2023010117130541216.png

像android这种内存小、内存紧张、交互优先的设备,天然的GC次数就会较多,低延迟就只能是我们唯一努力优化的目标了

其实大家看这几个指标,我们优化 java 程序其实优化的就是其中的几个指标,我是做 android 开发的,异常关注:内存占用收集频率

他山之石可以攻玉,知识点之间很多都是通用的,大家多思考下,这点点滴滴的收获就是经验了 (ˉ▽ ̄~) 切~~

垃圾回收器历史

这里我就不写文字了,直接截图了:

2023010117130648617.png

这么多垃圾回收器我们也不必都直到,重点是了解:CMS、G1、ZGC这3个就行了

  • G1 是目前JVM默认的垃圾回收器
  • CMS 是最早的一点并发的垃圾收集器
  • ZGC 是下一代回收器,虽然到目前 JDK14 了,ZGC还不是正式版,但未来必然会替代G1的

搞这些个垃圾回收器出来,主要还是因为 java 应用范围太广了,没有一种垃圾回收器能玩转所有场景:

  • 低成本嵌入式设备,硬件性能低的串行回收效率会好与并发、并行的,因为少了线程的切换
  • 移动端的设备,重交互,延迟时间长了是及其要命的
  • 服务端设备,需要高吞吐量

7种经典垃圾回收器:

  • 串行垃圾回收器:Serial、Serial Old(回收老年代)
  • 并行垃圾回收器:ParNew、Parallerl、Parallerl Old(回收老年代)
  • 并发垃圾回收器:CMS、G1

G1是唯一可以在新生代和老年代通用的,很难淂

2023010117130847418.png

除了G1,其他垃圾回收器都只能在新生代或老年代中执行任务

2023010117130989419.png

可以看到新生代中很难支持并发的垃圾回收器,不是串行就是并行的,G1另说毕竟G1出来都是JDK7了,JDK9才开始默认使用G1,并且在后面的版本中不断完善,到JDK12时才算差不多

这些垃圾回收期都是混合使用,毕竟新生代和老年代回收分开了嘛,一个代选择一个回收器,CMS和Parallerl不能混用

CMS因为是引发的,所以有个特性就是提前开始回收任务,因为用户线程在执行时还会继续产生垃圾,为了保证CMS的回收效果,CMS要搭配Serial Old一起使用,CMS回收失败,Serial Old再上

  • JDK8 之前,注意CMS和Parallerl不混用就行了,其他基本都能混用

  • JDK8的时候,下图红线的部分就不能用了,废弃了Serial+CMS、ParNew+Serial Old的组合,串行的只能和串行的玩,并行的只能和并行的玩

  • JDK14的时候,下图绿线的部分就不能用了,废弃了Parallerl+Serial Old的组合

  • JDK14的时候,CMS直接被删除了,因为G1成熟了嘛,老的并发的就不再用了

    2023010117131100220.png

当然常用的垃圾收集器组合:

  • JDK8,默认的是用并行的组合,Parallerl+Parallerl OLD
  • JDK9开始,我们就使用默认的G1了

基本上使用思路就是新生代我们用串行的垃圾回收器了,那么老年代跟着用,不混用了,反之并行,老年到可以考虑使用并发的CMS回收器

做 android 的朋友们,我们没有这种烦恼啦,android 设备上我们是没法对JVM进行顶置的,Google给什么我们就用什么,但是了解这些还是有必要的,万一以后用的上呢。更重要的学习其中的思路,以后觉得用得上

1. Serial

Serial 是串行垃圾回收器,Serial 回收新生代,Serial Old 回收老年代,他俩采用的回收算法是不一样的

  • Serial: 复制算法
  • Serial Old: 标记-整理算法

Serial 因为是单线程GC的,GC全程都会阻塞用户线程,所以STW时间最长,JDK1.3之前,java 使用的就是这个回收器,但是不要小看它,最简单的也是最不容易出问题的,Serial 很多时候都是作为备用分身存在,即便到了 JDK14,Serial 依然还在,只不过不是默认的了

Serial 的使用场景是:单核心、内存小(100M内)的嵌入式设备,这种设备串行回收器的效率谁也比不了,还别小看这个,现在好多20-50块的"智能设备",CPU性能有限的很

-XX:UserSerialGC 可以启动 Serial 回收器,同时再新生代和老年代生效

2. ParNew

ParNew 可以看成是多线程,并行执行的 Serial,回收新生代,老年代回收可以搭配 CMS、Serial Old

ParNew 内部基本和 Serial 差不多,回收算法同样也是:复制算法

  • -XX:UserParNewGC 可以启动 ParNew 回收器
  • -XX:ParallelGCThreads 可以指定并行GC线程数量,默认是和CPU核心数一样

3. Parallerl

Parallerl 和 ParNew 一样都是并行垃圾回收器,并且性能也差不多,为啥还会有 Parallerl 出现呢。Parallerl 是按吞吐量优先设计的,有内存自适应调节技术,可以根据情况动态调节各部分内存的分配

  • Parallerl: 新生代,复制算法
  • Parallerl Old: 老年代,标记-整理算法

Parallerl 因为其设计,非常适合后台程序,像科学计算等这些大量cpu计算,而没有交互的程序,天然就淂使用 Parallerl。JDK1.6 - JDK 1.8,我们一直用的都是 Parallerl 收集器,Parallerl 也是系统默认的收集器

  • -XX:UseParallerlGC 启动 Parallerl 收集器
  • -XX:UseParallerlOldGC 启动 Parallerl old 收集器
  • -XX:ParallerlGCThreads Parallerl GC 线程数
  • -XX:ParallerlOldGCThreads Parallerl old GC 线程数
  • -XX:MaxGCPauseMillis STW 最大时间,这个参数慎用
  • -XX:GCTimeRatio 吞吐量,取值范围0-100,99=垃圾回收时间不超过1%
  • -XX:+UseAdaptiveSizePolicy 开启自动调节,默认是开启的

MaxGCPauseMillis 参数要是设的比较小,那么收集器就会动态把堆空间往小里调,这样以便能更快的完成GC,以实现这个参数的设置,那么总体来看GC就会非常频繁了。所以这个参数慎用啊,很可能就起反效果了

自动调节主要是为了实现堆大小,吞吐量,停顿时间之间的平衡。在手动调节比较困难的时候,用自动调节是一个好的选择,在指定最大的堆大小,吞吐量,停顿时间之后,让虚拟机自己完成调优工作

自动调节是自动调整新生代的大小,Eden/S0/S1 的比例,晋升老年代的次数

新生代内部默认是 8:1:1 的比例,但是实际上 6:1:1,这个就是启动了自动调节后的效果

最后再次重点强调下,Parallerl 这个垃圾回收器重点还是强调吞吐量,适合的服务端程序

4. CMS

JDK1.5 的时候,CMS出现了,他是第一款正式的并发垃圾收集器,主要目的的是为了低延迟,为了重交互的设备开发的,虽然 JDK9 的时候,改用G1了,但是还是有很多设备再使用 CMS,直到 JDK14 CMS 被移除,被移除也是因为作为替代产品的 G1 彻底成熟,但是这个时候G1的下一代 ZGC 也快成熟了

  • CMS 作用于老年代,采用标记-清除算法,无法和 Parallerl 收集器配合使用

CMS 为了降低延迟时间,采用的思路是查分整个GC过程,把其中能和用户线程并发执行的都尽量并发执行,减少绝对的STW时间

这个其实和敏捷开发是一样的,工作总量是不变的,工作量总量也是不可能减少的。由于我们对整个工作过程的熟悉,我们把工作拆分成一块一块的,在非任务时段也能做一些工作,以减少开发任务阶段的工作量,并把一些工作量可以在多个任务中进行复用,是在整体角度上的优化

总体上 CMS 把 GC 分成5个阶段,先看图:

2023010117131224721.png

  • 只用初始标记、重新标记阶段是STW独占的,是需要停顿用户线程的,这2个阶段耗时都很短
  • 初始标记: 这个阶段耗时很短,会STW停顿所有用户线程,该阶段任务是标记出 GC Roots 能直接关联的对象,我任务其实就是标记作为 GC Roots 跟节点的这些对象
  • 并发标记: 这个阶段耗时较长,但是已经可以跟用户线程并发执行了,甚至都可以跟垃圾收集线程并发执行
  • 重新标记: 这个阶段耗时也很短,该阶段会STW停顿所有用户线程,任务是对上一阶段因与用户线程并发执行的时候变动的那些对象。耗时虽然比初始标记长一点,但是比并发标记短多了,如果说初始标记耗时1,并发标记耗时10,那重新标记耗时2。
  • 并发清除: 这个阶段就是清理垃圾了,因为会和用户线程并发,所以就不能使用内存整理技术了,标记完了只能清除,所以会产生内存碎片这个问题

所有的垃圾回收器 STW 时间都做不到没有,都只是在使用不同手段来减少 STW 的时间,CMS 把最耗时的遍历整个对象树和清除垃圾的任务都做到和用户线程并发执行了,这就是 CMS 能做到低延迟的原因

CMS 因为把最耗时的阶段和用户线程一起执行了,所以在GC的过程中,我们一边回收内存,用户线程一边任然在消耗内存,所以 CMS 垃圾回收器做不到内存不够时再回收。为了保证程序不 OOM,GC的时候内存还能够用,CMS 只能提前启动GC,当堆内存使用量达到一个阀值,就开始GC回收,这点和 android 的GC机制很像

CMS 还是有一个保底算法的,就是 Serial Old。碎片太多,或是GC时内存需求太大,CMS并发是会失败的,这个时候就会切换到 Serial Old

CMS 缺点:

  • 产生内存碎片 这个问题是非常致命的。经过一些时间过后,内存中要是碎片很多的话,要是来来一个大对象,很可能因为没有连续空间能放的下从而造成一次 Full GC。另外在碎片很多的时候,要是突然来一次业务高峰,一时间要创建很多对象从而引发GC,CMS 很可能会失败,这个会切换到Serial Old,Serial Old可是串行的,是速度最慢的,很可能会造成异常糟糕的延迟,以影响到用户体验
  • CMS对CPU资源很敏感 CMS是并发回收期,在回收时时会占用一些CPU核心的,这对于像是后台程序来说是不能接受的,这样会大大降低吞吐量
  • CMS无法处理浮动垃圾 所谓的浮动垃圾就是在并发标记阶段产生的新的垃圾,这些垃圾本次CMS是无法处理的, 只能等到下一次了
  • -XX:+UseConcMarkSweepGC 启动CMS回收器
  • -XX:CMSInitiatingOccupanyFraction 堆内存占用阀值,过了这个阀值,CMS开始执行。JDK5之前是68%,老年代过了这个数开始执行回收,JDK6之后是92%。若内存增速缓慢,这个这个值设的高一点问题都没有,要是某个操作要消耗大量内存,比如一个按钮事件,这个时候要是阀值设的太高了,很可能会造成CMS失败而切到Serial Old去,那延迟就高了,卡就会出现了
  • -XX:+UseCMSCompactAtFullCollection Full GC之后启动内存压缩整理
  • -XX:CMSFullGCsBeforeCompaction 执行多少次 Full GC,0=每次都整理 之后启动内存压缩整理,和上面那个是一对
  • -XX:ParallelCMSThreads CMS线程数量,默认数量是(核数+3)/4

CMS 因为弊端很尖锐,所以在G1出来后,直接就被废弃了,能用G1谁还用CMS,CMS最大的问题就是碎片化,很麻烦,带来的影响可能比受益多得多

G1 垃圾收集器

G1 这个垃圾收集器可是现在面试官最爱问的了,从JDK7 G1出现在JDK中开始,到JDK9 G1成为默认垃圾收集器后,G1 就和面试结下了不解之缘,只要你是用 java 的,不管事哪个平台,面试官不问问你 G1 就不显得专业

G1 出来后,CMS 就不用了,除非祖传屎山老代码动不得 (-∀=) ,G1 的目标很明确就是替代 CMS,CMS 的内存碎片问题实际运行时性能问题是在太大,所以不得不想办法解决,于是 G1 就出来了

编程界有个优化的思路,当当前维度实在没有优化余地时,那么就挖掘更深的维度,实现更细粒度的管理。这就是 G1 的思路,在以前垃圾收集器内存分代的基础上,增加了内存分区的思路,实现对内存更细粒度的管理

G1垃圾收集器是最复杂垃圾收集器,以前的垃圾收集器只能单独处理新生代、老年代中的一个,G1 是都可以处理,2个区域通吃,但是通吃不代表,一个回收算法可以通吃所有,不同的区域还是有不一样的算法和考虑,正因为太复杂,所以专门拿出来作为一节

1. 分区算法

G1 在分代算法的基础上,又加入了分区算法。何为分区算法,就是把内存分成一个个相互独立,相互不联系,单独回收的内存块

2023010117131367122.png

  • 每一个内存块叫:region
  • region 之间相互不联系,region 内存块内存是内存连续的,没有内存碎片存在的,使用指针碰撞分配对象空间,依然还有 TLAB
  • 每一个 region 在GC回收之后可以更换角色,之前可以是 Eden,回收之后可以 Old
  • G1 里面没有 S0、S1的划分了,统一的都是 S 幸存者区域了
  • 相对于整个堆内存来说,region 可以看成是成块的、可维护的内存碎片了,堆内存会维护一个可用内存块列表

相对于传统垃圾收集器堆内存各内存区域必须是连续的:

2023010117131447123.png

G1发现既然并发回收器内存碎片是必然存在的,那么我们就承认它,把它搞成可维护的:

2023010117131527224.png 比如上图中的一次 YGC,我们保证每一小块中的内存是连续的,没有内存碎片就行了,块的大小相对于对象来说大的多,这样即便在整体内存看来因为 region 的出现,内存像是又内存碎片,但是在对象角度来看,因为每个块足够大,能够放下自己,那就没有内存碎片一说了

2. G1 的特点

  • G1 算是并行与并发并重的垃圾回收器了,并且在之后的更新中,基本上倾向与并发了,G1标记阶段是并发的,回收阶段是并行的
  • G1 的新生代回收是独占式的,就是整个新生代回收阶段都是 STW的
  • 当 G1 并发回收老年代失败时,会替补方案就是:Full GC,单线程,STW 的,G1 垃圾回收器一边不会再进行FGC了,除了并发失败才会进行 FGC,在使用 G1 时要是发现老是出现FGC,那么就是说我们该优化了
  • G1 回收器虽然是低延迟的,但是还是更适合后台应用,尤其是在大内存场景下性能十分优越,这个分界线是6G,一般认为进程占用内存超过6G内存之后,G1的性能开始大幅度超越CMS
  • G1 在回收老年代时一定会先回收新生代,回收老年代时根据设置选择性回收部分 region,而不是整个老年代,通过这种手段来提供性能

3. G1 CMS 的比较

虽然 G1 是为了取代 CMS 而出现的,但是相对于 CMS 来说,G1 并没有全方位、压倒性优势。G1 占用的内存比 CMS 就要多一些,因为 region 中的一些特性的原因

经验上来说,小内存还是 CMS 有优势,G1 适合大内存场景,平衡点在6-8G

G1 相比 CMS,提高的不是上限的性能,而是下限的性能,相比CMS,G1的性能下限要高的多,这样平均性能就搞了,一个系统性能的好坏,恰恰就是性能下限决定的,所以 G1 出来后,CMS 就没有用了,G1 真的是好用啊

4. 基于可预测停顿时间设计

G1 有个参数可以设置垃圾回收器单次回收时间的长短,然后垃圾回收器会维护一个 region 回收性能表,记录每一个 region 中垃圾的多少,预计回收时间多少,按优先级高低依次记录到维护表中,然后根据设置的单次回收时间,选择性的尽可能在不超过设定时间的基础上回收优先级最高的那部分 region 内存快以实现垃圾回收的目的,这样不用回收整个内存区域,工作量要小很多

不过有一点就是,只有老年代是这样的,新生代还是整个必须回收的,基于新生代回收时间较短这一特点考虑

5. region

G1垃圾收集器默认把整个堆内存分成 2000块,每个块根据实际分配大小,必须是2的几次方才行,比如:2、4、8、16、32,G1 会动态调节各内存部分的比例

G1 里 region 块有4种角色

  • E: Eden 区
  • S: 幸存者区,没有 S0,S1 这种细分了
  • O: 老年代
  • H: 大对象区域

对于大对象分配原则:

  • 0.5-1 个region,存入单个H中
  • >1 个region,存入连续的多个H中

2023010117131638025.png

每个 region 里面额外有2个属性

  • Rset: RememberSets,存储本 region 的对象被其他 region 对象的引用记录,对象之间的引用肯定会有夸 region 的情况,为啥记录这个,为了的是 GC 时减少扫描对象的范围
  • Csets: CollectionSets,堆内存维护的记录每个 region 的垃圾率和预计回收时间,表中按优先级排列,每次老年代GC时该回收那些 region 块都是看这个表的

Rset 淂说特别说一下

这个 Rset 很不一样的,其中淂考量很重要。之前的垃圾回收器执行标记算法时不管是回收新生代还是老年代,都必须把全堆都遍历一遍才知道谁是垃圾不是,GC Roots 这些根节点可是遍布全堆内存的

G1 垃圾回收器采用分区算法,把内存分割成一个个 region 内存块,据说再这样扫描全堆内存对象淂引用淂话,性能是有问题的。为了解决这个问题,region 会记录其他 region 引用自己内存的情况,这就是 Rset 的诞生

2023010117131798426.png

有了 Rset 之后,G1 新生代回收不用再遍历老年代了,只遍历自己就行了,其他区域的对象我们都认为还幸存就行了,这样只扫描E区域就行了,避免了全堆扫描,这样性能好很多,尤其是对应大内存情况,这种优势是十分明显的

可能有的人没明白咋回事,Rset 里面包括O引用E的情况,这样我们都直到哪些EO引用,我们就不用再去遍历一遍老年代了

再深入一点,介绍一个概念:卡表 Card Table

  • RSet 记录的是谁引用了我
  • Card Table 记录的是我引用了谁

G1 的 RSet 是在 Card Table 的基础上实现的。考虑到 RSet 可能会频繁、并发写入,涉及到锁的问题,所以一旦 region 中对象被别的 region 对象引用了,那些这个时候就加入一个写入锁,把这个变化先记录 Card Table 中。 Card Table 本身是个队列,空闲时把引用变化同步到 RSet 中

关于卡表的解释很不好理解,资料少的可怜,官方文档甚至都没有,不知道我解释的对不对

6. GC 方式

G1 有3种GC方式:

  • YGC: 年轻代GC,全程STW的,复制算法
  • MixGC: 混合GC,回收全部年轻代和部分老年代,并发结合并行
  • Full GC: 全堆GC,串行、单线程、STW的,不会单独出现,只有MixGC失败才会触发FGC

1. YGC 新生代回收

G1 有单独的、只是新生代自己回收的时机的。G1 新生代回收是并行的、有多条线程共同执行的、整个过程全程阻塞用户线程 STW 的,采用复制算法,数据在不同的 region 之间复制

官网的图:

  • 会收前 ->

    2023010117131884727.png

  • 回收后 ->

    2023010117131984728.png

新生代标记过程如下:

  • 标记 GC Roots 根对象
  • 更新 Rest,这块会用到卡表的,上面说过卡表会记录跨 region 对象引用,是个队列会在空闲时一个个写入 Rest,此时必须强制性把卡表内存都更新到 Rest 中,然后才能根据 Rest 去找老年代指向的新生代对象
  • 处理 Rest,识别哪些被老年代指向的对象,这些对象肯定是存活的
  • 遍历对象树,复制对象,这个阶段大家应该熟悉了,不过要说的是扫描的对象都是Eregion

大家要注意 region 内部是内存连续的,不存在内存碎片

2. MixGC

混合回收,当堆内存使用率超过设定的阀值后触发,会先来一次 YGC,然后根据设定的回收时间,选择回收多少老年代的 region

混合回收标记阶段是并发的,挺复杂的:

  • 初始标记阶段,这个阶段是STW的,注意这个阶段会触发一次YGC,混合回收必会回收新生代就是这么来的。此阶段发生在Yong GC的最后,且包含在Yong GC中。由此可以看出Mixed GC发生过程:Yong GC => Marking Cycle => Mixed GC会扫描一遍所有的GC根节点。这个阶段是以正常的年轻代垃圾收集为基础的,需要STW
  • 根区域扫描,扫描survior区中由初始标记出来的root region,找出指向old区的对象引用,并标记引用对象;该阶段与应用并发进行,必须在下一个Yong GC发生之前完成
  • 并发标记阶段,这个阶段可能会被YGC打断,该阶段要是发现某个region全是垃圾了,就直接在这个阶段清除垃圾,同时会计算每个 region 的活性,也就是有多少垃圾
  • 再次标记,使用比 CMS 更快的算法:STAB 初始快照算法
  • 独占清理,STW的,计算每个 region 的对象和GC比例并排好序,为下个阶段做准备,实际上这个阶段是不会清除垃圾的
  • 并发清理阶段,首先是统计记录所有空region和下次Mixed GC时的候选region,并且清理Rset,这个过程是STW的;然后重置空region并放入空闲队列,这个过程是并发的

标记阶段目的是:回收old中完全是垃圾的region,标记剩余region中垃圾占多少,然后根据设置计算要回收多少region,优先回收哪些region

3. Full GC

G1 的 FGC 是在 MixGC 阶段失败时的一种后补策略,FGC 本身是一个单线程,串行,STW 的回收器,基本就是个串行垃圾回收器

导致 FGC 出现的条件:

  • GC停顿时间设置的过段,以至于在并发回收时碰到大量内存分配的情况,这时内存不够了只能 FGC 了
  • 并发阶段没有足够空间方大对象

7. G1 参数

  • -XX:+UseG1GC 启动G1
  • -XX:G1HeapRegionSize 每个 region 的大小
  • -XX:MaxGCPauseMillis=200 最大GC停顿时间,默认是200ms,回收器尽可能在这个时间内回收优先级高的 region
  • -XX:ParallelGCThreads=8 垃圾回收器并行线程数量,默认是8
  • -XX:ConcGCThreads 垃圾回收器并发线程数量,一般设置成 ParallelGCThreads 的1/4
  • -XX:InitiatingHeapOccupancyPercent=45 堆内存占用阀值,超过这个数就要进行混合回收了,默认是 45%
  • -XX:G1MixedGCCountTarget=8 混合回收并发标记后计算出了垃圾在region中的分布情况,这个数值的意义是按几次回收完整个老年代来计算需要回收的region范围,默认是8次
  • ‐XX:G1MixedGCLiveThresholdPercent=65 region 垃圾占用阀值,超过这个阀值的 region 在混合回收中都会被回收,垃圾太少的 region 回收的话,复制算法要花不少时间
  • ‐XX:G1HeapWastePercent=10 可以被回收的垃圾占堆内存的比例低于10%时,可以不再进行混合回收,这样可以减少GC的时间

8. G1 调优原则

  • 设置堆内存大小

  • 不显示指定新生代大小,比如:-Xmn,-XX:NewRatio 这些参数,用改交由G1自己来决定新生代大小占比

  • GC最大暂停时间不应该设置的太小,G1整体的目标是99%的应用时间,1%的暂停时间

  • 一般情况下可以调整的参数如下,其他参数应慎重使用:

    • 触发阈值:

      • -XX:InitiatingHeapOccupancyPercent
    • mixed GC触发条件:

      • -XX:G1HeapWastePercent
      • -XX:G1MixedGCLiveThresholdPercent
    • old region回收限制:

      • -XX:G1MixedGCCountTarget
      • -XX:G1OldCSetRegionThresholdPercent

GC 日志分析

GC 日志打印命令

  • -XX:+PrintGC: GC信息
  • -XX:+PrintGCDetails 详细GC信息,带内存详情
  • -XX:+PrintGCTimeStamps GC时间戳-基准时间形式
  • -XX:+PrintGCDataStamps GC时间戳-日期形式
  • -XX:+PrintHeapAtGC GC前后堆的情况
  • -Xloggc:./logs/gc.log
  • -verbose:gc 打开GC日志
  • jmap -heap xxx 打印xxx进程堆内存情况

GC 日志分析

  1. PrintGC 命令
    [GC (Allocation Failure)  20334K->874K(60928K), 0.0004539 secs]
  • Allocation Failure 是失败原因,内存分配失败
  • 20334K 是GC之前堆占用
  • 874K 是GC之后堆占用
  • 60928K 是堆内存本身大小,注意这里我们使用的是简单单音命令,所以打印出来的信息是堆内存总体信息
  • 0.0004539 secs GC花费时间
  1. PrintGCDetails 命令
    [GC (Allocation Failure) [PSYoungGen: 15360K->761K(17920K)] 15360K->769K(58880K), 0.0011138 secs] [Times: user=0.00 sys=0.00, real=0.01 secs]

PrintGCDetails 命令是最重要的GC日志打印命令了,一般我们都是用这个命令居多。找到一张非常好的图,大家看图就容易理解了

2023010117132065229.png

图里没有的是 PSYoungGen 这个代表什么,使用的垃圾收集器不同,这里显示的也是不一样的:

  • PSYoungGen = Parallele 垃圾收集器
  • DefNew = Serial 串行垃圾收集器
  • ParNew = ParNew 垃圾收集器
  • garbage-frist heap = G1 垃圾收集器

对于时间参数呢图上说的不是很明确:

  • User - 用户线程花费的时间,因为我们可能采用并发垃圾回收器
  • Sys - 系统等待的时间,也就是系统等待CPU时间片的时间
  • real - GC时机花费的时间

一般我们看 real 这个参数就行了

  1. PrintGCTimeStamps、PrintGCDataStamps 命令

这2个命令会在GC信息开头加上日期和时间,上面那张图里没怎么说,这里我再解释一下

  • 2020-07-29T18:21:59.424 GC发生的日期
  • -0800 时区:东八区
  • 0.433 JVM 启动之后多少时间发生了GC事件
  1. PrintHeapAtGC

这个是打印堆内存GC前后的详细信息,有需要再加上这人

    {Heap before GC invocations=1 (full 0):
     PSYoungGen      total 17920K, used 15360K [0x00000007bec00000, 0x00000007c0000000, 0x00000007c0000000)
      eden space 15360K, 100% used [0x00000007bec00000,0x00000007bfb00000,0x00000007bfb00000)
      from space 2560K, 0% used [0x00000007bfd80000,0x00000007bfd80000,0x00000007c0000000)
      to   space 2560K, 0% used [0x00000007bfb00000,0x00000007bfb00000,0x00000007bfd80000)
     ParOldGen       total 40960K, used 0K [0x00000007bc400000, 0x00000007bec00000, 0x00000007bec00000)
      object space 40960K, 0% used [0x00000007bc400000,0x00000007bc400000,0x00000007bec00000)
     Metaspace       used 3369K, capacity 4500K, committed 4864K, reserved 1056768K
      class space    used 375K, capacity 388K, committed 512K, reserved 1048576K
    
    2020-07-29T18:37:24.387-0800: 0.489: [GC (Allocation Failure) [PSYoungGen: 15360K->793K(17920K)] 15360K->801K(58880K), 0.0010335 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
    
    Heap after GC invocations=1 (full 0):
     PSYoungGen      total 17920K, used 793K [0x00000007bec00000, 0x00000007c0000000, 0x00000007c0000000)
      eden space 15360K, 0% used [0x00000007bec00000,0x00000007bec00000,0x00000007bfb00000)
      from space 2560K, 31% used [0x00000007bfb00000,0x00000007bfbc6758,0x00000007bfd80000)
      to   space 2560K, 0% used [0x00000007bfd80000,0x00000007bfd80000,0x00000007c0000000)
     ParOldGen       total 40960K, used 8K [0x00000007bc400000, 0x00000007bec00000, 0x00000007bec00000)
      object space 40960K, 0% used [0x00000007bc400000,0x00000007bc402000,0x00000007bec00000)
     Metaspace       used 3369K, capacity 4500K, committed 4864K, reserved 1056768K
      class space    used 375K, capacity 388K, committed 512K, reserved 1048576K
    }
  1. jmap -heap xxx

一般我们会使用这个命令来查看堆内存情况,有张图:

2023010117132163230.png

  • MaxHeapSize 堆最大值,对应 -Xmx
  • NewSize 年轻代空间
  • MaxNewSize 年轻代最大值
  • OldSize 老年代空间
  • NewRatio 老年代、新生代比例
  • SurvivorRatio Eden、幸存者区比例
  • MetaspaceSize 方法区大小
  • MaxMetaspaceSize 方法区最大值
  • CompressedClassSpaceSize 类指针压缩空间大小
  • G1HeapRegionSize G1 垃圾收集器每个 region 的大小
  1. -Xloggc:./logs/gc.log 命令

这个命令可以让我们把GC数据写入到指定文件中,注意这里的路径使用项目的跟路径,我们需要在项目根目录下创建logs文件夹出来才行

这个命令是非常有现实意义的,gc经常发生,我们在控制台里看这么一次2次还行,要是次次都在控制台里看那就非常难受了,相关的数据分析还要自己做

但是现在我们有了gc日志文件,结合一些分析工具我们就能很方便的很细gc数据了,尤其是对后台程序而言更是如此

目前gc日志分析工具有:GCViewer/GCEasy/GCHisto/GCLogViewer/hpjmeter/garbagecat等,其实最常用的还是GCViewer/GCEasy,GCViewer 是一个jar,我们下下来可以运行的,有UI界面,GCEasy 是一个网站,很方面,网速好的话最合适的还是用这个,数据分析的很全面