JVM基础(三)

71 阅读11分钟

JVM基础(三)

  • JVM中的常见垃圾回收器

    • 示意图:

      image-20220217171906679

    • 最早的时候:JVM刚出现的时候

      • 概述

        • 只有两个垃圾回收器并且是单线程的
        • 当时java中的堆空间是较小的(几十,一百兆)
        • 并且在垃圾回收的时候会暂停所有的程序(暂停时间还比较长)
      • 垃圾回收器

        • 新生代:Serial(这个还是可以用的:-XX:+UseSerial)
        • 老年代:Serial Old
      • 工作机制:

        • 要对新生代进行GC,暂停所有的程序,然后YGC
        • 要对老年代进行GC,暂停所有的程序,然后FGC

        image-20220216221410943

    • JDK1.3:引入了多线程垃圾回收器(Parallel Scavenge与Parallel Old)

      • 概述:

        • 在JDK1.8环境下,默认就是这一对垃圾回收器
        • 多线程的垃圾回收器会尽可能利用CPU:系统吞吐量优先
      • 为什么是吞吐量优先

        • 参数控制

          • MaxGCPauseMills:控制GC的暂停时间,以毫秒为单位

            • 这个参数只是JVM完成任务而已

              • 当堆空间较大,发生GC的时候(堆空间占满了),此时暂停时间大于这个参数了
              • 那么可以在达到堆空间一半的时候就GC,但是提前,那么GC的次数增加,总GC时间还是没有改变
            • 设置的小,JVM就会增大GC的频率

          • GCTimeRatic:GC时间占总时间的占比

            • 参数:1-100的整数,默认是99

            • 计算方法:参数设置为99

              • GCTimeRatic=1/1+99:
              • 系统有效时间为99%
          • UseAdativeSizePolicy:启用自适应生成大小,默认是开启的

            • 系统启动后,会给一个自适应的堆,堆的大小随着程序执行而改变,每次GC都会扩容(Eden,from,to这个比例就不再是8:1:1了,内部有算法;并且新生代占1/3,老年代占2/3,这个也不再适用,内部有算法)
          • MaxHeapFreeRatio:在GC后允许剩余堆空间占原来堆空间的比例

            • 默认为70%,若原来堆为1G,GC后的剩余堆空间大于等于700MB,那么就要对堆空间进行缩小
          • MinHeapFreeRatio:最小的剩余比例,默认为40%,小于这个,那么就扩容

            • 但是这个实际取决于实际的参数
          • JVM调优:如果说能预估到堆的大小,就将-Xms与-Xmx设为一样的

            • 因为这个动态调整堆空间的话,会伴随GC,GC就有暂停时间(但是比起单线程要快),影响效率
      • 特点:

        • 可以完成自适应:不用显示设置堆的大小
      • 工作机制:

        image-20220216224123586

    • 吞吐量优先发展到响应优先

      • 因为GC时,暂停业务线程:stop the world

        • 后面的垃圾回收器,都是从STW的减少为目标
      • 此时引入CMS

CMS垃圾回收器:Concurrent Mark Sweep 标记清除算法

  • 概述:

    • 单独的一个针对于老年代的垃圾回收器,系统的卡顿,主要来自老年代
    • 新生代都是复制算法,回收很快
    • 在JDK1.9的时候,将这个新生代中的ParNew跟这个CMS搞了一个组合,当然CMS也可以配合Serial,ParNew比起Serial主要是前者可以使用多线程
    • JDK1.8并且内存小于6G,并且接受服务器重启(垃圾回收器退化的问题)就用这个
    • Android中的垃圾回收器,都是源于CMS
  • 工作原理:采用的是标记清除算法

    • CMS将标记,清除拆分成了下面的四个

      1. 初始标记

        1. 首先找到GCRoots

        2. 找到与GCRoots直连的对象,然后标记

          暂停业务线程(时间短),因为跟GCRoots直接连接的对象少

      2. 并发标记:用户线程和GC同时进行

        1. 标记后面的对象,这个可能是一大堆,比如说集合

          不会暂停用户线程,采用并发去标记(耗时操作--->整成并发),耗时长,但是不卡顿;

      3. 重新标记:处理并发标记中引用发生改变的对象,垃圾被finalize了,变成了非垃圾

        1. 暂停所有的业务线程:不暂停是不能确定对象的状态(也就不能确定引用)
        2. 时间短
      4. 并发清除:因为标记清除算法可以做到不暂停,垃圾在业务线程中用不到,这个地方就不会做暂停了

    • CMS中的细节:一般会配一个ParNew(默认的,参数关了就是Serial)

      • 基本思路:CMS以最短暂停时间优先(初始标记与重新标记有暂停)

        • 希望在并发中能多干事情,让重新标记的时间越短越好(将重新标记的事情做了)
        • 初始标记是决定不了的:决定因为是根的数量
      • 并发标记中的细节:

        • 预清理
        • 并发可中断预清理
      • 预清理:在并发标记后紧跟着的,一定开启的

        • 参数:CMSPrecleaningEnabled,默认为true,预清理是开启的

        • 场景一(业务线程中发生新生代执行老年代):在并发标记中,有个垃圾B,但是此时业务线程执行并在Eden区弄了个A引用了B(跨代引用--->一般认为这个B就不是垃圾了,这个A就是GCRoots),那么就要想办法将这个B标记为非垃圾;不在并发标记中做预清理,那么就要在重新标记中去做

        • 场景二(业务线程中在老年代中发生内部引用的变化):在并发标记阶段,由于业务线程不暂停,可能导致可达性中的叶子节点(内部引用)又有的子节点

          • 在这种情况下会进行CMS的内存分区(分成若干个区,某个区发生了这种情况,那么就是dirty),在重新标记阶段就只用去扫描这些dirty区域,不用扫描整个堆
          • 这个表不是卡表
      • 并发可中断的预清理:from区或者to指向Eden区域,不一定(Eden2MB)

        • 场景:from区或者to区对象指向了老年代(到老年代可达)

        • 参数控制:CMSEdenSize(默认是两兆),也就是说此时的Eden区已经使用了两兆,那么就开启并发可中断

        • 这种机制一直开启,但是可以设置触发条件

        • 工作机制:

          while(){

          1. 处理From区和To区的对象到老年代可达,导致老年代的并发标记中,引用发生变化
          2. 老年代内部的引用变化,记录类似卡表

          }

          • 这个是可以中断的:

            • 循环参数:0(默认没有限制)
            • 循环时间:5000(5S)
            • Eden区已内存使用比例:50%
      • 重新标记:拿到了并发标记中发生的引用变化(新生代指向老年代与老年代叶节点有新的子节点)

    • CMS日志查看

      • 示意图:

        image-20220217170054264

      • 为什么要重置线程:

        • 线程里面保存的栈的东西

          • 当对象变成垃圾,那么他保存在栈里面的局部变量表中的引用
    • CMS的缺点

      • CPU敏感:因为有并发操作,一般就是四核

      • 浮动垃圾:在并发清理中造成的垃圾,只有等待下一次GC了

        • 万一堆满了,这个垃圾就没有地方放,所以要预留一部分内存
        • 并且因为这个原因,CMS需要提前GC(不能等到堆都满了,一般就是老年代中内存利用率92%,就开始收垃圾),触发时间比Serial和ParNew早
      • 内存碎片:由于标志-清除(优点:GC与业务同时执行,缺点:造成内存碎片)

        • 大对象存不下:对象存储需要连续的内存空间

        • 处理办法:垃圾回收器退化,给CMS搭配一个Serial Old(单线程,使用标记整理算法,在老年代中只有CMS采用标记清除算法)

          • 这次退化,会导致GC时间很长,因为这个时候使用单线程,暂停业务线程了,用户大量卡顿
          • 要不服务器就重启(重新划分堆):类似于服务器维护
    • 其他的垃圾回收器:G1

      • 多线程,可以处理新生代和老年代
      • 但是需要较大的内存空间(JDK1.8,内存8G以上)
      • 在JDK1.9以后,默认就是用这个

JVM调优技术

  • JVM内存的划分:

    • 以活跃数据为基础:持续运行一段时间后通过打印GC日志看这个里面有多少的活跃数据

    • 示意图:

      image-20220217172124336

  • 扩容新生代能否提升GC效率:是可以的

    • 实现原理:

      • 通过扩容新生代,增贷MinorGC的间隔,让更多的对象朝生夕死
    • 实现基础:

      1. 垃圾回收器处理新生代时均为复制算法(将对象从Eden区复制到From区或To区,耗时)

      2. 一次MinorGC总耗时:扫描(很快)+复制(耗时)

      3. 当MinorGC的时间间隔增大后,对象的存活时间不变(业务代码决定),有可能活不到下一次GC,就死掉了,这样就不用再复制到From或者To区,节省时间

        • 避免内存抖动
      4. 一般情况下,对象都是比较少的

      5. 就算都是大对象,也没有事情

    • 问题:

      • 新生代扩容后,需要处理更多的对象,那不行

        • 对象都活不到下一次
    • 示意图:

      image-20220217173256283

  • JVM是如何避免MinorGC中扫描全堆

    • 场景:发生跨代引用(老年代到新生代可达),此时认为这个老年代也是GCRoots,在进行GC的时候,就要去找所有的GCRoots;如果说老年代中有GC,就非常麻烦了,会扫描全堆(与分代回收背道而驰)

    • 解决:引入卡表

      • 概述:将老年代进行分区,可以快速找到老年代中的dirty数据

常见面试题:

  • 常量池

    • 分类:

      • Class常量池,也叫静态常量池
      • 运行时常量池
      • 字符串常量池
    • Class常量池

      • java在编译后,会有以下常量池,存放编译时的各种常量,字面量,符号引用
    • 运行时常量池(从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用):存放符号引用在运行时变成的一些东西

      • java在运行时,经过类加载(类加载器将这个类加载到运行时数据区中的方法区)
      • 在运行的时候,才能将符号引用转变为直接引用(具体在方法区的哪里)
    • 字符串常量池:在虚拟机规范中是没有明确定义的,这个跟String有极大的关系

      • 深入理解String(JDK1.8)

        • 底层是以数组实现的:包括一个char的value数组和一个Hash

          image-20220217175747452

        • 并且String以及其封装的value数组被final修饰(不可继承)

          • 所以String对象一旦被定义出来,那就不可变
          • 保证了String的安全性,因为这个东西用的非常多
        • 在JDK1.9的时候是byte数组,据说效率较高
    • 深入理解String类

      • 直接赋值(这个好)

         public void mode1(){
             //代码加载的时候,会在常量池中创建常量"abc",运行时,返回常量池中的字符串引用(保存在下面的str)
             //好处(池化技术):可以复用,再来一句这个String str1 ="abc";那么str1中的引用跟str相同,复用了
             String str ="abc";
         }
        

        image-20220217181714212

      • 使用new操作符:不好,会多出一个对象

         public void mode2(){
             //1.代码加载时,就在常量池中创建常量"abc"
             //2.在调用new的时候,会在堆中创建String对象,并引用常量池中的字符串对象的char[],并返回String对象引用
             //3.new不好,因为还需要从栈里面去找这个对象(栈中的局部变量表中存储了这个对象的引用)
             String str =new String("abc");
         }
        

        image-20220217181727366

      • 类的成员变量中含有String:

        • 在类加载的时候,"深圳","南山"就在字符串常量池中了
        • 在创建对象的时候,会在堆中创建一个location对象,成员变量city/region执行字符串常量池的东西
         public void mode3(){
             Location location = new Location();
             location.setCity("深圳");
             location.setRegion("南山");
         }
        
      • 字符串直接拼接:编译器会优化

         public void mode4(){
         //3个对象。效率最低。java -》class- java
             String str2= "ab" + "cd" + "ef";
             String str2 = "abcdef";//编译器会直接将其优化成这种,反编译就可以看到的
         }
        
      • 字符串循环(次数较多)拼接:编译器会优化

             public void mode5(){
                 String str = "abcdef";
                 for(int i=0; i<1000; i++) {
                     str = str + "0";
                 }
         //        //优化
         //        String str = "abcdef";
         //        for(int i=0; i<1000; i++) {
         //            str = (new StringBuilder(String.valueOf(str)).append(i).toString());
         //        }
             }
        
      • intern();方法,返回常量池中的地址

         public void mode6(){
             //去字符串常量池找到是否有等于该字符串的对象,如果有,直接返回对象的引用。
             String a =new String("king").intern();// new 对象、king 字符常量池创建
             String b = new String("king").intern();// b ==a。
             if(a==b) {
                 System.out.print("a==b");
             }else{
                 System.out.print("a!=b");
             }
         }
        
      • 字符串常量池在哪里?

        • 可能在堆,因为虚拟机规范中没有定义需要这个东西;它只是一种优化技术

        • 因为不好划分:

          • 一般来说这种静态的都放在方法区,但是这个需要优化的东西一般放在堆

          • 况且虽然这个是做池化技术的(方法区),但是又需要添加(堆)