JVM基础(二)

144 阅读20分钟

JVM_02:对象的分配及垃圾回收机制

  • JVM中对象的创建过程:

    • 检查加载:将类加载到JVM的运行时数据区

      • 没有加载就加载
    • 分配内存

      • 划分内存的方式
      • 并发安全问题(Java的多线程背景)
    • 初始化

      • 置零处理
    • 设置

      • 对象头
    • 对象初始化

      • 构造方法
  • 教案图示:对象的创建过程

    image-20220216182854614

  • 类加载:

    • new出对象后,将其放到 JVM运行时数据区中的方法区
  • 检查加载

    • 检查类是否已经加载过

      • 没有:重新加载
    • 检查符号引用能否变成直接引用:如果不行,那么就回退到前面的初始化,解析

      • 直接引用:真实的地址(这个地址是操作系统提供的)

      • 符号引用:不知道真实的地址

        • 存在于常量池中:

        • 这个其实就是一个字面量

        • 应用场景:

          在加载类A的时候,A的常量池对类B进行了一次引用;但是,此时类B尚未加载进来,那么久使用一个符号来指定类B;本质上还是一个引用

  • 分配内存:

    • new出一个对象后,需要为其分配内存;就是在堆中指定一块区域用于存储(实质上,对象不一定百分百在堆上进行分配)

    • 由于 Java的多线程背景,存在并发冲突控制与内存分配两个问题

    • 解决并发冲突:启动多个线程,进行内存分配

      • 解决办法:

        • CAS+失败重试
        • TLAB(本地线程分配缓冲)
      • 为什么会造成并发安全问题?

        • 堆是线程共享的
        • 两个线程都想在同一块内存区域上进行划分(分配对象),造成覆盖
    • 解决内存分配问题:取决于堆空间是否规整

      • 指针碰撞:挨着分配,指针位于上一次分配的末尾

        • 适用对象:堆空间规整,效率高(不需要查表),采用指针分配模式;每次分配均会产生一个偏移量

        • 堆空间图示:划了三个出去

          • 分配前:

            image-20210910180644752

          • 分配后

            image-20210910180758075

      • 空闲列表:记录每块空间是否空闲,在为对象分配空间的时候,找一段连续的给他

        • 处理空间碎片(零散的可用空间),适用于堆空间零散,这个有点慢,要去查个表 什么表?

        • 堆空间图示:

          image-20210910182909969

  • CAS+失败重试机制:乐观的,不加锁的机制;但是实际上还是有锁

    • 核心:比较交换

    • 执行流程:线程中对其即将访问的对象旧状态事先进行了进行保存,等待触发。当时机成熟时,线程对对象进行访问(将线程中保存的对象状态的旧值与此时对象的状态值相比较)

      • 相同:访问成功并修改对象的状态值(将空闲变成繁忙),这次操作就是CAS(比较交换),类似于精子与卵细胞的融合过程

      • 不同:访问失败,重试(已经有线程占据这个对象了,因为对象一般保存在堆中,而堆属于线程共享区域,所以不同的线程可以进行访问)

        • 再次访问就行
      • 这个过程就叫CAS;

    • CAS在底层上算作CPU指令,即为原子操作,即保证了线程安全;这个相当于自带了一个锁;对多核CPU来说,有个叫Look Modify(复合指令)的东西也可以保证只有一个CPU

    • CAS:每个对象只要被线程访问就都有个这个,类似于朴素版Dijjistra算法中的时间复杂度估计,这个是从整体角度进行分析的,虽然这个是CPU指令(快),但,从整体上看,就慢下来了;

    • Java由于并发编程背景,本质上是不推荐加锁的,加了影响效率,相当于在同一时刻只有一个线程在执行了(其他的线程会空转),但是如果不加以控制,会导致异步问题;(引入TLAB)

  • TLAB(本地线程缓冲机制):为每一个线程分配一个线程缓冲,空间换时间

    • 实现原理:一个新的对象创建时,一般创建在Eden区;那么就在Eden区中给这个线程分配一块空间(Eden区的百分之一),拿给这个线程去分配对象;那么,当又来了一个线程,那就再给它划出一块区域,让新线程在这个空间里面分配对象;这样,在线程分配对象的时候各自在Eden区中的独立部分就解决了并发冲突的问题
    • JVM默认解决并发访问冲突手段,但是这个是可以关掉的,关了就用CAS+重试;Java8中有个UseTLAB选项(默认开启),默认使用TLAB,关掉就用CAS+重试
  • 内存空间初始化

    • 成员变量(基本都是零值):基本数据类型---->0/false
    • 引用数据类型---->null
    • 对数据均有一个初始值,为了在 Java中可以直接进行使用
  • 设置:主要就是设置对象头

    • 对象的内存布局示意图

      image-20220216183115612

    • 对象头

      • Mark word:存储在运行时对象自身的东西(数组与非数组都有)

        • 哈希码:任何一个对象都有HashCode
        • GC分代年龄:在GC中会用这个
        • 锁状态特征:加锁的问题
        • 线程私有的锁
        • 偏向锁ID
        • 偏向时间戳
      • 类型指针(只有数组才有)

        • 指向方法区中的类,(对象需要知道它属于那个类)
        • 源码分析:JVM/openjdk/hotspot/src/share/vm/oops/markopp.hpp(这个里面就有详细的类型问题)
      • 对象数组:记录数据长度的数据(这个东西只针对于对象中含有数组)

    • 实例数据

    • 对象填充(并不是必须的)

      • (保证对象头+实例数据+[对象填充])%8个字节 = 整数
      • 为什么是8个字节嘛,猜测 Java中广泛采用Unicode编码
  • 对象初始化:

    • 根据类的构造方法对对象进行初始化
    • 到这里对象的创建过程结束
  • 虚拟机栈的内容

    • 栈帧

      • 局部变量表

        • 八大基本数据类型
        • 对象的引用,指向对象(直接引用)
        • 返回地址
      • 操作数栈

      • 动态链接

      • 完成出口

  • 对象的访问定位

    • 示意图:

      image-20220216183201688

    • 直接引用

      • 虚拟机栈中的引用直接指向堆中对象
    • 使用句柄:句柄池

      • 原理:Java栈本地变量表中存放的引用指向 Java堆中的句柄池中的一条数据,这条数据再指向对象的实例数据;做了一个二次转换

      • 句柄池中的内容(到对象实例数据的指针,到对象类型数据的指针),其中到对象实例数据的指针再指向堆中对象实例数据,到对象类型数据的指针,指向方法区中的对象类型数据;存在重定位机制

      • 优点:

        • 仅需修改句柄池中的句柄,无需修改具体数据即可达到栈指向的东西更新;
        • 在gc中,对对象的处理也有好处,此时不要需要做具体的修改操作,只要修改句柄了;
      • 缺点:

        • 由于有二次转换,所以效率会比较低
    • 直接指针:引用里面保存的就是堆空间中对象的地址,主流方式

      • 原理:Java栈本地变量表中存放的引用变量指向 Java堆中的实例数据与到对象类型数据的指针(后者包含在前者之中),其中到对象类型数据的指针进而指向方法区中的对象类型数据

      • 优点:

        • 快,没有二次转换
      • 缺点:

        • 当对象发生改变(被修改,被删除)具体的线程里面的东西也要进行更新
      • 大多数虚拟机采用这项技术,Hotspot

    • 为什么方法区总是被指向的?

      • 因为在对象头中有类型指针,说明了这个对象属于哪个类;类是放在方法区的
  • 各种语言的垃圾问题

    • java:自动化的垃圾回收
  • c:malloc与free

    • c++:new与delete
  • 手动化的垃圾回收会带来什么问题?

    • 忘记回收,导致内存泄漏
    • 在多线程背景下,可能重复回收
  • 判断对象是否存活

    • 示意图:

      image-20220216184220798

    • 引用计数算法

      • 原理:在对象头上给他加上一个数,当有引用指向此对象时,这个数就加一。有几个就加几个,当引用失效时就减掉这个引用,引用为零就是垃圾

      • Python中就是用的这个

      • 问题:无法处理循环引用这种垃圾,两个对象互相进行引用;这个算垃圾啊,因为这样的对象,无法进行利用啊,总不能直接去干到人家的地址里面去嘛;

      • Python处理循环引用问题

        • 再额外启动一个线程,专门去扫这种循环引用,看到了就干掉它;
        • 其实这样效率会很低,将问题复杂化
    • 可达性分析(根可达)

  • 可达性分析(根可达)

    • 什么是根?是一系列根的集合

      • GC Roots:Garbage Collection RootSet,跟垃圾回收有关的根

        • 静态变量
        • 线程栈变量
        • 常量池
        • JNI(指针),这个是用来处理本地方法的
        • 内部引用:class对象,异常对象(Exception),类加载器,
        • 同步锁:synchronized修饰的对象也算根
        • 内部对象:JMX中的Bean,本地缓存,回调带来的东西,
        • 临时对象:跨代引用(在分代年龄哪里),这个在gc中也可以作为根
    • 原理:从根出发去找对象,那种不可达的就是垃圾,从根本上解决了循环引用问题

  • 证明可达性分析算法解决了循环引用问题

    • 代码:

      image-20220216185145648

    • 打印GC日志,VMpotion:-XX:+PrintGC

      • 开启垃圾回收参数:-XX:+PrintGC 打印垃圾回收日志,这个东西默认是关闭的

        当执行这段程序并开启打印垃圾回收日志后,可以看到回收了近20M的数据,这个就说明了JVM采用的是根可达,垃圾回收

      • System.gc() :

        • 手动进行gc
        • 一般情况下是等到空间满了,它自己会进行gc,
    • JVM在空间满了,才进行垃圾回收

      • 垃圾回收会影响效率,手动触发可以强制GC
    • JVM不只是回收堆,其他的也会

  • 根可不可以回收?:Class回收条件(静态变量和常量会跟着Class回收一起走,因为他们都是在类加载的时候同步创建的):都要满足才能进行类对象的回收

    1. 由这个Class new 出来的对象全部都要被回收掉:因为对象会指向类

    2. 类加载器也要被回收掉:类是从类加载器哪里来的

    3. 类,Java.lang,class对象,在任何地方都没有被引用

    4. 在任何地方都没有被引用,并且不能通过反射调用类中的方法

    5. 参数控制问题:

      -Xnoclassgc:禁用类的垃圾回收,这个参数需要关掉才能GC,,这可以节省部分原有的GC时间,从而缩短应用程序运行期间的中断

      在启动指定时,应用程序中的类对象在GC期间保持不变,并始终被认为时活动的,这有可能导致更多的内存被永久占用,若不慎启用,极易造成内存不足从而引发异常情况

      方法区的GC条件严苛,所以GC一般都是在堆中的

  • Finalize:进行筛选(一个对象是不是垃圾要进行两次筛选,根可达为第一次,Finalize是第二次)

    • 执行级别比较低,并且这个方法只能执行一次(用了一次以后,再将对象=null,即使主线程休眠,这个方法也不会再执行了)

      • 为什么优先级别比较低?

        1. 重写这个方法时(在里面写点代码,不能空)

        2. 那么在会在Finalizer的源码中就会new出一个对象,这个对象被压入队列,并且重新启动一个线程进行处理(遍历一个链表)finalize(JVM 内部反射使用的)

           /* Invoked by VM */
           static void register(Object finalizee) {
               new Finalizer(finalizee);
           }
          
    • 替代:try-catch-finally(在这个地方finalize)

    • 缺点:

      • 不能很好控制线程的优先级别
      • 并且这个方法不能二次使用
    • 代码示例:

       public class FinalizeGC {
           //这个是静态的,这个对象在方法区
           public static FinalizeGC instance = null;
           //如果对象还可以调用这个方法,证明对象还活着
           public void isAlive(){
               System.out.println("I am still alive!");
           }
           //任何一个类都可以覆盖这个方法,因为在Object中有一个  protected void finalize() throws Throwable { }
           @Override
           protected void finalize() throws Throwable{//不推荐使用
               super.finalize();
               System.out.println("finalize method executed");
               FinalizeGC.instance = this;//把引用接上
           }
           public static void main(String[] args) throws Throwable {
               instance = new FinalizeGC();
               //对象进行第1次GC
               instance =null;//将引用置空,那么,这个对象就死掉了,因为没有引用指向它了
               System.gc();
               Thread.sleep(1000);//Finalizer方法优先级很低,需要等待
               if(instance !=null){
                   instance.isAlive();
               }else{
                   System.out.println("I am dead!");
               }
           }
       }
      
    • 运行截图(主线程休眠):

      image-20220216193019827

    • 运行截图:将主线程休眠注释掉

      image-20220216193054927

  • 各种引用分析

    • 强引用:用 = 来干的

      • JVM回收不了这种跟GC Roots有强引用的东西,就是GC Roots直接赋值的那种,
      • 干掉强引用:将其置空即可(赋值为null)
    • 软引用:

      • 虽然与GC Roots具有可达性关系,但是不是直接可达;

      • 当系统即将OOM的时候,会考虑干掉软引用,以求避免系统崩溃,有时候就真的只差那么一点;

      • 应用场景:在 Java中会将图片保存在缓存中

        • 图片占据空间大
        • 即使被回收了,依然可以从数据库,云端进行调取
      • 补充:手动OOM

        • 调整堆的大小(-Xms 20m -Xmx 20m)将堆的默认值与最大值均设置为20m
        • 不断向一个List中添加东西
      • 代码示例

         public class TestSoftRef {
            //对象
            public static class User{
               public int id = 0;
               public String name = "";
               public User(int id, String name) {
                  super();
                  this.id = id;
                  this.name = name;
               }
               @Override
               public String toString() {
                  return "User [id=" + id + ", name=" + name + "]";
               }
         ​
            }
            //
            public static void main(String[] args) {
               User u = new User(1,"King"); //new是强引用
               SoftReference<User> userSoft = new SoftReference<User>(u);//软引用
               u = null;//干掉强引用,确保这个实例只有userSoft的软引用
               System.out.println(userSoft.get()); //看一下这个对象是否还在
               System.gc();//进行一次GC垃圾回收  千万不要写在业务代码中。
               System.out.println("After gc");
               System.out.println(userSoft.get());
               //往堆中填充数据,导致OOM
               List<byte[]> list = new LinkedList<>();
               try {
                  for(int i=0;i<100;i++) {
                     //System.out.println("*************"+userSoft.get());
                     list.add(new byte[1024*1024*1]); //1M的对象 100m
                  }
               } catch (Throwable e) {
                  //抛出了OOM异常时打印软引用对象
                  System.out.println("Exception*************"+userSoft.get());
               }
         ​
            }
         }
        
      • 运行结果:软应用消失

    • 弱引用

      • 简单gc即可实现回收,一般来说活不到下一次gc

      • 应用场景:

        • setLock
        • wakedHashMap
      • 代码示例

         public class TestWeakRef {
            public static class User{
               public int id = 0;
               public String name = "";
               public User(int id, String name) {
                  super();
                  this.id = id;
                  this.name = name;
               }
               @Override
               public String toString() {
                  return "User [id=" + id + ", name=" + name + "]";
               }
         ​
            }
         ​
            public static void main(String[] args) {
               User u = new User(1,"King");
               WeakReference<User> userWeak = new WeakReference<User>(u);
               u = null;//干掉强引用,确保这个实例只有userWeak的弱引用
               System.out.println(userWeak.get());
               System.gc();//进行一次GC垃圾回收,千万不要写在业务代码中。
               System.out.println("After gc");
               System.out.println(userWeak.get());
            }
         }
        
    • 虚引用

      • 随时都有可能被回收,

      • 应用场景:

        • 可以在 JVM的内部进行使用虚引用证明gc正常工作:因为gc也是一个线程(是线程就存在死锁,循环,阻塞等问题)

        • 在NIO中的DirectByteBuffer(直接分配堆外内存)中的源码就有这个虚引用

          • 就有一个cleaner
          • 在处理直接内存的时候,使用上者是可以的,毕竟他是openJDK提供的,JVM会进行回收的;内部就是有这个机制的
        • 补充知识: 在处理直接内存的时候,使用上者是可以的,毕竟他是openJDK提供的,JVM会进行回收的,但是Unsafe就真的是完全不一样了。

  • 对象的分配策略

    • 示意图:

      image-20220216200116265

    • 分配原则:几乎所有的对象都在堆中进行分配(new出的对象:堆中分配或者栈上分配)

      • 对象优先在Eden区进行分配,不进行栈上分配

      • 空间分配担保

      • 大对象直接进入老年代:

      • 动态对象年龄判断

    1. 是否走栈上分配:所以对象不只是在堆中分配

      • 条件:当代码代码执行很多次,后端中一般是一万多次,就会触发JIT并且经过JVM内置逃逸分析技术检验,两者都符合那么就采用栈上分配

        • 逃逸分析:看这个对象能不能逃出这个方法

          • 为什么要走逃逸分析:满足逃逸分析,就证明其他的线程调用不到这个代码块;那么就相当于线程私有了,栈就是线程私有的
          • 那么就将这个对象拆分,塞到栈里面(如果在堆中分配,那么肯定会执行gc)
        • 本地线程缓冲技术

      • 逃逸分析:只有 Java HotSpot Server VM 支持逃逸分析

        • 判断此时这个对象n能否逃出这个方法
        • 这个是可以关掉的(Hotspot Service有个参数:-DoEscapeAnalysis,默认开启,关掉就不走栈上分配了)
      • 触发JIT(热点数据)

        • 在后端中当方法、循环次数达到一万次及以上,此时触发逃逸分析,看能否逃出这个循环,逃不出,就相当于其他的线程就调用不到这个方法;此方法仅供当前线程使用,这样就可以放到栈上面,栈是线程私有的
      • 为什么要进行栈上分配?

        • 减少gc次数
    2. 大对象直接进入老年代:大于4兆+垃圾回收器特定的两种

      • 对垃圾回收器有要求,只能是Serial、Parnew
      • 参数设置:-XX:PretenureSizeThreshold=4m(默认值)
      • 触发逃逸分析:看能否逃出这个方法,逃不出,其他线程调用不到这个对象;直接分配在栈上
      • 将对象拆分放进局部变量中;之前都是解释执行的,现在是热点编译;
      • 若将大对象放到新生代,会导致大对象频繁进入老年代,影响执行效率
    3. 不是大对象,在Eden区进行分配

      • 几乎所有的对象都在堆中进行分配(还有栈上分配的情况)

      • 堆中存在的分代模型,JVM在回收的时候是分开回收的

        • 年轻代:Eden、From、To、
        • 老年代:Tenured
      • 使用本地线程分配缓冲,进行对象分配

    4. 在Eden区域的对象没有死,长期存活的对象进入老年代

      • 对象在一次gc中存活下来,年龄加一,Hotspot最大值为15(CMS垃圾回收器最大值为6),年龄由4位二进制存放(Hotspot中)这个是可以进行设置的

      • 参数控制(-XX:MaxTenuringThreshold=阈值)

        • 适用于自适应gc大小调整的最大任期阈值,最大为15(4位二进制存储);并行(吞吐量)收集器的默认值为15,CMS默认值为6
      • 大于15就进入老年代

    5. 也不一定要等到15,动态对象年龄判断

      • 关于对象年龄设置

        • 过大:占用新生代过多

          设置为15,即经过15次gc后对象才变为老年代

          太大了,就一直在新生代中进行分配,知道新生代装不下,还是会放到老年代里面去(这个时候,年龄的设置就没有什么用了)

        • 过小:对象不能很好地在新生代中进行gc,导致老年代内卷,员工倒挂;

      • 为了更好的适应:做了一个适配(from区和to区同时只有一个对象)

        • 假设交换区(from区)是10MB,当交换区中age=3的对象有1MB,age=4的有4MB,age=5的有1MB,那么就不找了,直接将age>=3的对象全部晋入老年代
    6. 空间分配担保

      • 一般情况下,老年代的绝大部分是从新生代过来的;在回收的时候是分代回收的(新生代--->YGC,老年代--->FGC)

        • YGC后老年代可能放不下:进行一次YGC就将FGC清一下;但是这个是不行的,所以就存在空间分配担保
      • 空间分配担保实现:

        • JVM统计历次老年代回收的空间取出一个平均值,假设为10MB;那么,当YGC释放8MB,那么就不用搞FGC了,直接先过来;要是,不行,那么就再搞一次gc
        • 担保,只管分,放不下,调用一次gc就行了
      • 分配阈值:统计历次进行老年代回收空间(FGC),取个平均值,这个作为空间偿还能力评估

  • 垃圾回收基础:

    • 理论图:FGC肯定伴随YGC

      image-20220216202608344

    • GC:垃圾回收,这个就是一个动作

    • 新生代+老年代:组成一个堆

      • 新生代:Eden,from(s0),to(s1)

        • s:幸存者空间
        • 8:1:1
        • 注意from区与to区,只有一个能用,会预留一个
  • 垃圾回收算法 :

    • 复制算法:这个是JVM中效率最高的,只是说会预留空间而已

      • 示意图:

        image-20220216203510232

      • 执行流程:

        1. 先预留出一半的空间
        2. 根据可达关系,找出可达的对象,垃圾先不管
        3. 将对象复制到预留的地方去,对原来的那块空间进行一次格式化,并将其作为新的预留空间
      • 特点:

        • 实现简单,运行高效

          • 对象都是朝生夕死的,垃圾占大多数(因为更新频繁),要复制的较少
        • 没有内存碎片:后面去分配对象的时候就可以用指针碰撞(比空闲列表高)

        • 但是,空间利用率就比较低,只有50%;那么就引出了Appel式的复制回收

    • Appel式的复制回收:加强版的复制算法,用于新生代

      • 示意图:

        image-20220216203544153

      • 跟之前的那个的区别就在于,预留区域有两个,各占对的10%,整体上是8:1:1

      • 整体利用率在90%

    • 标记清除算法:处理老年代

      • 示意图:

        image-20220216204447703

      • 执行流程:

        1. 标记垃圾
        2. 清除垃圾
      • 特点:

        • 缺点:位置不连续,会产生内存碎片(对象需要占用连续的空间,碎片多了,就可能导致还有7个单位的可用内存但分配不下一个3个单位的对象)

        • 优点:可以做到不暂停

          1. 在垃圾回收的时候有两个线程:业务线程,垃圾回收线程
          2. 垃圾对象在业务线程中用不到(因为它是垃圾,用的到,就是根可达的),并且在标记-清除的过程中并不会移动垃圾的位置
    • 标记-整理算法:

      • 示意图:

        image-20220216205122436

      • 执行流程:

        1. 先标记:哪些不是垃圾
        2. 接着就整理(移动对象,导致业务线程暂停),将不是垃圾的对象整理成一段连续的
        3. 将剩下的一次性清掉(整体清除)
        4. 没有内存碎片

        为什么移动对象会导致业务线程暂停?

        • 因为在虚拟机栈的局部变量表中有对象的引用,这个引用就指示了对象到底在哪里;对象移动的时候,局部变量表要更新,所以会暂停
  • 基础知识补充:

    • JVM,Java中都是自动化的内存管理

       C语言 malloc  free
       C++   new    delete//C/C++,忘了就导致内存泄漏;同时在多线程中多次回收可能产生覆盖问题
       Java  new       自动gc 
      
    • Java触发gc,首先判断哪些为垃圾,就是哪些对象存活

    • 细节:

      • 可达性分析是很快的
      • 移动对象就很慢,移动了,hash值就会变