性能调优 11. JVM对象创建与内存分配机制深度剖析

1. 对象的创建过程


‌‌‌  对象创建的主要流程图如下。

在这里插入图片描述

类加载检查


‌‌‌  虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在Class常量池中(不是动态常量池)定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程

‌‌‌  new指令对应到语言层面上讲是,new关键词、对象克隆、对象序列化等。


分配内存


‌‌‌  在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小类加载完成后便可完全确定,为对象分配空间的任务等同于把 一块确定大小的内存从Java堆中划分出来。

‌‌‌  这个步骤有两个问题:

‌‌‌  1. 如何划分内存。

‌‌‌  2. 在并发情况下, 可能出现正在给对象A分配内存,指针还没来得及修改,对象B又同时使用了原来的指针来分配内存的情况


划分内存的方法


1. 指针碰撞”(Bump the Pointer)(默认用的方法)

‌‌‌  如果垃圾收集器算法是标记整理,一般是使用这种。如果Java堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离。

在这里插入图片描述

2. 空闲列表(Free List)

‌‌‌  垃圾收集器算法是标记清除下,一般是这种。如果Java堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表记录哪些内存块是可用的,在分配的时候比对下,从列表中找到一块足够大的空间划分给对象实例, 并更新列表上的记录。


解决对象分配内存并发问题的方法


‌‌‌  不管是哪种分配,同一块内存位置,存在并发问题。

‌‌‌  1. CAS(compare and swap)解决并发问题

‌‌‌  虚拟机采用CAS配上失败重试的方式保证更新操作的原子性来对分配内存空间的动作进行同步处理。抢同一块空间,抢到的就分配,抢不到就抢下一块空间。

‌‌‌  2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB))解决并发问题

‌‌‌  把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存通过-XX:+/-UseTLAB参数来设定虚拟机是否使用TLAB(JVM会默认开启-XX:+UseTLAB),JDK8默认就开启使用这种

‌‌‌  -XX:TLABSize 指定TLAB大小,如果放不下就走CAS。


初始化零值


‌‌‌  内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(不包括对象头), 如果使用TLAB,这一工作过程也可以提前至TLAB分配时进行。这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。

设置对象头


‌‌‌  初始化零值之后,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象的对象头之中


对象头组成部分


‌‌‌  HotSpot虚拟机的对象头包括三部分信息:Mark Word,Klass Pointer,数组长度

‌‌‌  对象头结构图如下:
‌‌‌ 在这里插入图片描述

‌‌‌  对象头在hotspot的C++源码markOop.hpp文件里的注释如下:
‌‌‌  说明了组成部分。
  
  在这里插入图片描述


Mark Word

‌‌‌  用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

‌‌‌  32位对象头的Mark Word

‌‌‌  不同对象头状态,Mark Word数据组成不一样。

在这里插入图片描述


Klass Pointer

‌‌‌  类型指针,C++实现的,记录对象指向在方法区的类元数据的指针(类元数据也是C++数据结构),虚拟机通过这个指针来确定这个对象是哪个类的实例。

‌‌‌  符号转成直接引用,需要查找方法区存储代码内容地址,就需要借助该指针。


数组长度

‌‌‌  如果对象是数组对象,则会有这部分。


执行<init>方法


‌‌‌  执行<init>方法(JVM底层会调用对应方法),即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法

2. 对象大小与指针压缩


‌‌‌对象大小组成


‌‌‌  在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。 对象大小一定是8字节的倍数,通过对齐字节填充补齐。


对象头


‌‌‌  在HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、 实例数据(Instance Data)和对齐填充(Padding)

‌‌‌  HotSpot虚拟机的对象头包括三部分信息:Mark Word,Klass Pointer,数组长度

‌‌‌  对象头的结构图如下:

在这里插入图片描述
‌‌‌  对象头在hotspot的C++源码markOop.hpp文件里的注释如下:

在这里插入图片描述


Mark Word

‌‌‌  用于存储对象自身的运行时数据, 如哈希码(HashCode)、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。

‌‌‌  32位对象头的Mark Word

在这里插入图片描述

‌‌‌  64位对象头的Mark Word

在这里插入图片描述


Klass Pointer

‌‌‌  类型指针,C++实现的,记录对象指向在方法区的类元数据的指针(类元数据也是C++数据结构),虚拟机通过这个指针来确定这个对象是哪个类的实例。

‌‌‌  类元数据存在句柄访问和直接指针访问,HotSpot虚拟机的类元数据指针就是直接指针访问。

‌‌‌  符号转成直接引用,需要查找方法区存储代码内容地址,就需要借助该指针。

‌‌‌  类对应的Class对象是放在堆区,用来给JAVA使用,通过该类对象可以拿到类的信息(可以认为是个入口,不存类的代码),运行本地方法底层都是通过JVM 的C+去实现。


数组长度

‌‌‌  如果对象是数组对象,则会有这部分。


实例数据


‌‌‌  简单理解就是对象里头各种类型的字段存储的内容。比如下面代码
‌‌‌  字段a,字段b就是实例数据。


‌‌‌  public class Main {
    
    private int a;
    
    private String b;

‌‌‌  }



‌‌‌对齐填充


‌‌‌  对于大部分处理器,对象都是8字节整数倍,对齐填充用来填充对象字节保证8字节整数倍。


‌‌‌ 对象的指针压缩


‌‌‌  jdk1.6 update14(8u14)开始,在64bit操作系统中,JVM支持指针压缩。可以对对象的类型指针和对象指针(对象的引用)进行压缩。JVM64位下,对象的引用是8字节可以压缩到4字节,类型指针是8字节可以压缩到4字节。


‌‌‌启用参数


‌‌‌  1. 启用所有指针压缩(对象指针或者说对象的引用,类型指针):-XX:+UseCompressedOops(JDK1.6后默认开启),禁止所有指针压缩:-XX:-UseCompressedOops

‌‌‌  2. 启用只压缩类型指针 -XX:+UseCompressedClassPointers,禁止指针压缩:-XX:-UseCompressedClassPointers


‌‌‌进行指针压缩原因


‌‌‌  1. 在64位平台的HotSpot中,使用32位指针记录对象地址(这边别跟前面类型指针搞混了,这个指针说的是对象引用,记录对象地址的指针)。如果不进行指针压缩用64位,内存使用会多出1.5倍左右。使用较大指针在主内存和缓存之间移动数据,占用较大宽带,同时GC也会承受较大压力。为了减少64位平台下内存的消耗,启用指针压缩功能

‌‌‌  2. 在JVM中,32位地址最大支持4G内存(2的32次方),可以通过对对象指针的存入堆内存时压缩编码、取出到CPU寄存器后解码方式进行优化,对象指针在堆中压缩编码是32位,对象指针或者说对象的引用是在对象的实例数据里头,跟对象一起存储在堆。在寄存器后解码是35位(2的35次方=32G),使得JVM只用32位地址就可以支持更大的内存配置(小于等于32G)。

‌‌‌  3. 堆内存小于4G时,不需要启用指针压缩,JVM会直接去除高32位地址,即使用低虚拟地址空间。

‌‌‌  4. 堆内存大于32G时(那么对象指针里头真正记录对象地址位,要用到超过35位),压缩指针会失效(JVM还没实现大于35位对象地址的压缩算法),会强制使用64位(即8字节)来对JAVA对象寻址,这就会出现1的问题,所以堆内存不要大于32G为好。

‌‌‌  注意

‌‌‌  1. 对象指针(对象引用),里头其实真正存储对象地址,并不都是所有位来存储。看垃圾回收器怎么设置,比如ZGC最多用44位来存储对象地址。

‌‌‌  2. 对象指针的压缩,还要看垃圾回收器支不支持,因为有的垃圾回收器超过了35位用来存储真正对象地址,比如ZGC,只能运行在64位JVM上。


3. 对象内存分配


对象内存分配流程图


在这里插入图片描述

‌‌‌  大致流程

‌‌‌  1. 创建对象,看能否分配到栈上,能就分配到栈上。

‌‌‌  2. 不能分配到栈上,进入Eden区前,如果是大对象,直接放入老年代。

‌‌‌  3. 不是大对象,如果开启了TLAB,每个线程在Eden区开启一块内存,对象根据合适方法划分内存存入。

‌‌‌  4. TLAB开启下,对象存入指定的内存失败,CAS方式选择合适的方法在Eden区划分一块内存,存放对象。

‌‌‌  5. Eden区满没有剩余空间,触发Minor GC 。

‌‌‌  6. Minor GC后存活的对象分代年龄满,放到老年代。

‌‌‌  7. 老年代满Full GC。


对象栈(线程栈)上分配


‌‌‌  栈上分配其实是JIT编译优化的技术,后面文章会讲JIT编译的优化技术。

‌‌‌  JAVA中的对象一般都是在堆上进行分配,当对象没有被引用的时候,需要依靠GC进行回收内存,如果对象数量较多的时候,会给GC带来较大压力,也间接影响了应用的性能。为了减少临时对象在堆内分配的数量,可以对对象进行栈上分配,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力

‌‌‌  栈上分配,需要JVM开启逃逸分析和标量替换。当逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。
‌‌‌  将对象拆分后,可以分配对象的成员变量在栈帧或寄存器上(虚拟机栈在硬件上实现一般是高速缓存或者寄存器)原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换(前提是需要开启逃逸分析)。

‌‌‌  有用的参数

‌‌‌  -XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
‌‌‌  -XX:-DoEscapeAnalysis 关闭逃逸分析

‌‌‌  -XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
‌‌‌  -XX:-EliminateAllocations 关闭标量替换

	-XX:+PrintGC 打印GC日志查看是否有垃圾回收,说明是不是栈上分配
    -XX:+PrintFlagsFinal 表示打印出所有参数选项在运行程序时生效的值



JIT对象逃逸分析


‌‌‌  触发流程

在这里插入图片描述

原理

‌‌‌  逃逸分析技术就是一个对象在方法中定义后,其作用域不会离开该方法,则该对象就是没有逃逸,可以尝试栈上分配(需要借助标量替换)。

‌‌‌  分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。

‌‌‌  比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸

‌‌‌  从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。

‌‌‌  逃逸分析技术属于JIT的优化技术,所以必须要符合热点代码,JIT才会优化,另外对象如果要分配到栈上,还需要将对象拆分,这种编译优化就是标量替换技术

‌‌‌参数

‌‌‌  -XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
‌‌‌  -XX:-DoEscapeAnalysis 关闭逃逸分析


‌‌ 标量替换


‌‌‌  通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配

‌‌‌  标量替换下,如果不能存放还是会存放到堆中。

‌‌‌  标量替换适用于逃逸分析前提下,也就是方法中创建的对象,没有离开方法的作用域

‌‌‌  如下图中foo方法如果使用标量替换的话,那么最后执行的话就是foo1方法的效果。

在这里插入图片描述


‌‌‌参数


‌‌‌  -XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
‌‌‌  -XX:-EliminateAllocations 关闭标量替换


‌‌标量与聚合量

‌‌‌  标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference引用类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量


栈上分配示例


例1. 通过栈上分配减少GC

‌‌‌  1. 如果方法里头User对象都存放在堆区会发生大量GC。

‌‌‌  2. 如果方法里头User对象放在栈帧上几乎不会GC,因为栈帧运行完就释放。

‌‌‌  3. -Xmx— 设置堆最大大小, -Xms—设置堆初始大小,-XX:+PrintGC—GC产生会打印GC日志 -XX:+DoEscapeAnalysis—开启逃逸分析,-XX:+EliminateAllocations—开启标量替换。

‌‌‌  4. 这边使用1.8的JVM默认是开启逃逸分析和标量替换的

‌‌‌  5. JVM机制可能启动就会触发一次GC是正常现象。



‌‌‌  package com.liu.java_dui_xiang_chuang_jian_he_nei_cun_fen_pei.zhan_shang_fen_pei;

‌‌‌  /**
 * @author jsLiu
 * @version 1.0.0
 * @description 栈上分配
 * @date 2023/08/25
 */

‌‌‌  import lombok.Data;

‌‌‌  /**
 * 栈上分配,标量替换
 * 代码调用了1亿次alloc(),如果是分配到堆上,大概需要1GB以上堆空间,如果堆空间小于该值,必然会触发GC。
 *
 * 使用如下参数不会发生GC
 * -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
 * 使用如下参数都会发生大量GC
 * -Xmx15m -Xms15m -XX:-DoEscapeAnalysis -XX:+PrintGC -XX:+EliminateAllocations
 * -Xmx15m -Xms15m -XX:+DoEscapeAnalysis -XX:+PrintGC -XX:-EliminateAllocations
 */

‌‌‌  public class AllotOnStack {


    public static void main(String[] args) {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 100000000; i++) {
            alloc();
        }
        long end = System.currentTimeMillis();
        System.out.println(end - start);
    }

    private static void alloc() {
        User user = new User();
        user.setId(1);
        user.setName("zhuge");
    }
    

    @Data
    public static class  User{

        private int id;
        
        private String name;
        
    }

‌‌‌  }


‌‌‌  结论

‌‌‌  1. 触发热点前提下,都开启逃逸分析和标量替换,才会触发栈上分配,开启栈上分配能减少大量GC。


例2. 通过栈上减少代码运行时间


‌‌‌  package com.liu.jit;
‌‌‌  /**
 * @author King老师
 * 逃逸分析-栈上分配
 * -XX:+DoEscapeAnalysis 开启逃逸分析
 * -XX:+EliminateAllocations 开启标量替换
 * -XX:+PrintGC 打印GC日志查看是否有垃圾回收,说明是不是栈上分配
 * -XX:+PrintFlagsFinal 表示打印出所有参数选项在运行程序时生效的值
 */
‌‌‌  public class EscapeAnalysisTest {
    public static void main(String[] args) throws Exception {
        long start = System.currentTimeMillis();
        // 满足热点探测技术,触发jit编译进行逃逸分析
        for (int i = 0; i < 500000000; i++) {//5000万次---5000万个对象
            allocate();
        }
        System.out.println((System.currentTimeMillis() - start) + " ms");
        Thread.sleep(600000);
    }

    static void allocate() {//逃逸分析(不会逃逸出方法)
        //这个myObject引用没有出去,也没有其他方法使用
        MyObject myObject = new MyObject(2020, 2020.6);
    }

    static class MyObject {
        int a;
        double b;

        MyObject(int a, double b) {
            this.a = a;
            this.b = b;
        }
    }
‌‌‌  }


‌‌‌  这段代码在调用的过程中Myboject这个对象属于不可逃逸,JVM可以做栈上分配,所以运行速度非常快。

在这里插入图片描述

‌‌‌  关闭逃逸分析运行。


‌‌‌  -XX:-DoEscapeAnalysis

在这里插入图片描述

‌‌‌  不关闭逃逸分析,关闭标量替换运行。


‌‌‌  -XX:-EliminateAllocations

在这里插入图片描述

‌‌‌  结论

‌‌‌  1. 触发热点前提下,都开启逃逸分析和标量替换,才会触发栈上分配,开启栈上分配能减少代码运行时间。

‌‌‌注意

‌‌‌  1. 测试时候不要用Debug模式,即使JVM参数都设置了,不会触发栈上分配。我这边IDEA测试用正常运行模式才看到效果。


对象在Eden区分配


‌‌‌  这块内容的前提是当前JVM的垃圾收集器,支持老年代,年轻代,S0,S1区这样分代收集算法去划分堆的结构。这里使用的JDK1.8的虚拟机,默认使用的垃圾收集器是Parallel的,年轻代的回收和老年代回收分别用到其年轻代版本和老年代版本。

‌‌‌  大多数情况下,对象在新生代中 Eden 区分配。当 Eden 区没有足够空间进行分配时,虚拟机将发起一次Minor GC/Young GC。

‌‌‌  下面内容其实都是比较笼统说话,实际情况跟内容有点差距。


‌‌‌ Eden与Survivor区默认8:1:1原因


‌‌‌  大量的对象被分配在eden区,eden区满了后会触发Minor GC,可能会有99%以上的对象成为垃圾被回收掉,剩余存活的对象会被挪到为空的那块survivor区,下一次eden区满了后又会触发Minor GC,把eden区和survivor区垃圾对象回收,把剩余存活的对象一次性挪动到另外一块为空的survivor区,因为新生代的对象都是朝生夕死的,存活时间很短,所以JVM默认的8:1:1的比例是很合适的,让eden区尽量的大,survivor区够用即可

‌‌‌  JVM默认有这个参数 -XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化可以设置参数-XX:-UseAdaptiveSizePolicy(这个比例也有点误差)。


‌‌‌Minor GC和Full GC 区别


‌‌‌  Minor GC/Young GC:指发生新生代的的垃圾收集动作,Minor GC非常频繁,回收速度一般也比较快。

‌‌‌  Major GC/Full GC:一般会回收老年代 ,年轻代,方法区的垃圾,Major GC的速度一般会比Minor GC的慢10倍以上。


‌‌‌Minor GC/Young GC


‌‌‌  分代收集算法下的堆,绝大情况创建对象放在Eden区,Eden区满就会Minor GC/Young GC。该操作大体就是根据可达性分析算法,从GC Roots根节点开始,找所有关联这些根节点的对象(比如,引用这些对象的,这些对象的成员变量等),以及关联这些根节点的对象的关联对象,一层层下去查找,总之有关联的都找出来。凡是能找到的对象,就是非垃圾对象

‌‌‌  将非垃圾对象,复制到S0区,S0区放不下就移动到老年代。留在Eden就是垃圾对象,GC时候就清掉。

‌‌‌  一个对象如果GC后还存在,分代年龄就加1。

‌‌‌  分代年龄存在对象头里。第一次Eden满清空后,如果第二次Eden又放满,则GC还是会按照上面判断垃圾对象方式,将非垃圾对象移动到S1区,S1区放不下就移动到老年代。清理Eden和S0区垃圾对象,非垃圾对象分代年龄又加1。

‌‌‌  第三次同理,这时非垃圾对象就移动到S0,周而复始切换使用S0和S1,非垃圾对象分代年龄加1。当对象分代年龄达到15(不同垃圾回收器值可能不一样,不管怎样最大不超过15)就会移动到老年代。一般像静态变量,缓存对象等可能就会移动到老年代。

‌‌‌  老年代满后就会触发Full GC/Major GC(具体用什么垃圾回收算法和算法怎么实现,看后面垃圾回收器章节,不同垃圾回收器使用的不一样),查找非垃圾对象方法基本都用到可达性分析算法回收是整个堆(侧重堆),还有方法区。如果触发Full GC,回收垃圾对象后,存活的对象还是不能完全放入老年代,老年代满后就触发OOM

例子验证过程

‌‌‌  1. 这边使用JDK1.8,设置JVM启动参数 -XX:+PrintGC -Xms61M -Xmx61M,运行下面代码JVM启动下有些对象会占用点Eden区,新生代至少使用几M内存。



‌‌‌  //添加运行JVM参数: -XX:+PrintGCDetails
‌‌‌  public class GCTest1 {
    public static void main(String[] args) throws InterruptedException {

    }
‌‌‌  }


‌‌‌  看日志,使用了3M内存。
在这里插入图片描述

‌‌‌  2. 运行如下代码。

‌‌‌  public class GCTest2{

    public static void main(String[] args) {

        byte[] allocation1,allocation2;
        allocation1 = new byte[15000*1024];

    }
‌‌‌  }

‌‌‌  Eden区已经有3M对象,此时Eden区没有足够空间容纳allocation1对象。虚拟机将发起一次Minor GC,GC期间虚拟机发现allocation1对象无法存入Survior空间,只好把新生代的对象提前转移到老年代中去。老年代上的空间足够存放allocation1的对象,所以不会出现Full GC。

在这里插入图片描述


Eden区对象进入老年代各种情况


‌‌‌  这些触发机制,是根据垃圾收集器符合老年代 年轻代这些分代收集的思想下来说,即使符合有的垃圾收集器未必支持。


对象进入Eden区时触发

大对象直接进入老年代

‌‌‌  大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代。

‌‌‌  这个参数只在 Serial垃圾收集器下(启用 -XX:+UseSerialGC,表示年轻代垃圾收集器使用这个垃圾收集器的) 和ParNew垃圾收集器(启用 -XX:+UseParNewGC,表示年轻代垃圾收集器使用这个垃圾收集器的)有效

‌‌‌  触发时间

‌‌‌  对象进入Eden区时。

‌‌‌  原因

‌‌‌  尽早进入老年代,避免为大对象分配内存时的复制操作而降低效率。

‌‌‌  例子

‌‌‌  设置JVM参数-XX:+PrintGCDetails -Xms61M -Xmx61M -XX:PretenureSizeThreshold=1000000(单位字节,这边约等于1MB) -XX:+UseSerialGC(年轻代使用Serial垃圾收集),再执行下面程序,可以发现原本allocation1应该存放在Eden区,直接进入了老年代。

‌‌‌  public class GCTest2{

    public static void main(String[] args) {

        byte[] allocation1;
        allocation1 = new byte[10000*1024];

    }
‌‌‌  }

在这里插入图片描述


Minor GC之前触发

老年代空间分配担保机制

年轻代每次Minor GC之前,JVM都会计算下老年代剩余可用空间

如果老年代可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),就会看-XX:-HandlePromotionFailure(jdk1.8默认就设置了)参数是否设置了。类似担保机制。,就会看看老年代的可用内存大小,是否大于之前每一次Minor GC后进入老年代的对象的平均大小。如果开启了参数,且平均大小大于老年代剩余空间就会Full GC,如果平均大小小于老年代剩余空间就会Minor GC。

‌‌‌  如果老年代可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象),没有开启参数就直接Full GC。

‌‌‌  此机制触发Full GC后,回收完老年代还是没有足够空间存放新进来的对象就会发生OOM

‌‌‌  触发时间

‌‌‌  Minor GC之前。

在这里插入图片描述


Minor GC之后触发

触发Minor GC后Eden区放不下当前对象,直接进入老年代

‌‌‌  前面说过Minor GC/Young GC执行流程,当Eden区放不下会触发Minor GC,清理Eden区垃圾对象,清理后剩余空间还不下要进来的对象,就将对象放到老年代。

‌‌‌  触发时间

‌‌‌  Minor GC之后触发。


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

‌‌‌  既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。
‌‌‌  如果对象在 Eden区 经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。后面对象在 Survivor 中每熬过一次 MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁,CMS收集器默认6岁,不同的垃圾收集器会略微有点不同),就会被放到老年代中。对象放到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置一般在Minor GC之后触发的

‌‌‌  触发时间

‌‌‌  Minor GC之后触发。


对象动态年龄判断机制

‌‌‌

‌‌‌  对象动态年龄判断机制一般是在Minor GC之后触发。当前存放对象的Survivor区域里(其中一块S区域),一批对象的总大小,大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定比例),那么此时大于等于这批对象年龄最大值的存活对象,就可以直接进入老年代了

‌‌‌  例如Survivor区域里现在有一批对象,年龄1+年龄2+年龄n的多个年龄对象总和刚好超过了Survivor区域的50%,此时就会把年龄n(含)以上的对象都放入老年代(再具体说,如一个S区里头存在1个年龄为1和年龄为2的对象,所有总大小累加刚好超过了S区50%,则GC后就把年轻代里头存活的所有年龄2以及2以上对象放入老年代)。这个规则其实是希望那些可能是长期存活的对象,尽早进入老年代。

‌‌‌  触发时间

‌‌‌  Minor GC之后触发。


4. 对象内存回收


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


常见引用类型


‌‌‌  java的引用类型一般分为四种:强引用、软引用、弱引用、虚引用。

‌‌‌  1. 强引用:普通的变量引用(常用)

‌‌‌  public static User user = new User();

‌‌‌  2. 软引用:将对象用SoftReference软引用类型的对象包裹,正常情况不会被回收,但是GC做完后发现释放不出空间存放新的对象,则会把这些软引用的对象回收掉(即使被引用着)。软引用可用来实现内存敏感的高速缓存


‌‌‌  public static SoftReference<User> user = new SoftReference<User>(new User());

‌‌‌  软引用在实际中有重要的应用,例如浏览器的后退按钮。按后退时,这个后退时显示的网页内容是重新进行请求还是从缓存中取出这就要看具体的实现策略了。

‌‌‌  1. 如果一个网页在浏览结束时就进行内容的回收,则按后退查看前面浏览过的页面时,需要重新构建。
‌‌‌  2. 如果将浏览过的网页存储到内存中会造成内存的大量浪费,甚至会造成内存溢出。

‌‌‌  简单说如果是缓存的对象,可有可无,可以设置软引用。

‌‌‌  弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用。

‌‌‌  public static WeakReference<User> user = new WeakReference<User>(new User());

‌‌‌  虚引用:虚引用也称为幽灵引用或者幻影引用,它是最弱的一种引用关系,几乎不用。


引用计数法


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

‌‌‌  这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,主要的原因是它很难解决对象之间相互循环引用的问题

‌‌‌  对象之间的相互引用问题

‌‌‌  如下面代码所示,除了对象objA 和 objB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为0,于是引用计数算法无法通知 GC 回收器回收它们。原本应该是垃圾对象,因为相互引用计数器一直存在,导致不能回收,所以JVM一般不用引用计数器。


‌‌‌  package com.liu.java_dui_xiang_chuang_jian_he_nei_cun_fen_pei.dui_xiang_nei_cun_hui_shou;

‌‌‌  /**
 * @author jsLiu
 * @version 1.0.0
 * @description 引用计数器
 * @date 2023/08/25
 */
‌‌‌  public class ReferenceCountingGc {

    Object instance = null;

    public static void main(String[] args) {
        // ReferenceCountingGc对象1,引用计数器加1,当前计数器为1
        ReferenceCountingGc objA = new ReferenceCountingGc();
        // ReferenceCountingGc对象2,引用计数器加1,当前计数器为1
        ReferenceCountingGc objB = new ReferenceCountingGc();
        // ReferenceCountingGc对象1引用计数器加1,当前计数器为2
        objA.instance = objA;
        // ReferenceCountingGc对象2引用计数器加1,当前计数器为2
        objB.instance = objB;
        // ReferenceCountingGc对象1引用计数器减1,当前计数器为1
        objA = null;
        // ReferenceCountingGc对象2引用计数器减1,当前计数器为1
        objB = null;
        // 因为互相引用导致两个对象计数器值一直不为0
    }

‌‌‌  }



可达性分析算法


‌‌‌  来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。

在这里插入图片描述

‌‌‌  作为GC Roots的对象主要包括下面4种

‌‌‌  虚拟机栈(栈帧中的本地变量表):各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。

‌‌‌  方法区中类静态变量:java类的引用类型静态变量。

‌‌‌  方法区中常量:比如:字符串常量池里的引用。

‌‌‌  本地方法栈中JNI指针:(即一般说的Native方法)。


finalize()方法最终判定对象是否存活


‌‌‌  即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。

‌‌‌  标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。

‌‌‌  1. 第一次标记并进行一次筛选。

‌‌‌  在回收前,筛选的条件是此对象是否有必要执行finalize()方法。

‌‌‌  有finalize()方法则做第一次标记,当对象没有覆盖finalize方法,对象将直接被回收。

‌‌‌  2. 第二次标记

‌‌‌  如果这个对象覆盖了finalize方法,finalize方法是对象脱逃死亡命运的最后一次机会,如果对象要在finalize()中成功拯救自己,只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,那在第二次标记时它将移除出“即将回收”的集合。如果对象这时候还没逃脱,那基本上它就真的被回收了。

‌‌‌  示例代码:


‌‌‌  package com.liu.java_dui_xiang_chuang_jian_he_nei_cun_fen_pei.dui_xiang_nei_cun_hui_shou;

‌‌‌  import java.util.ArrayList;
‌‌‌  import java.util.List;
‌‌‌  import java.util.UUID;

‌‌‌  /**
 * @author jsLiu
 * @version 1.0.0
 * @description finalize()方法测试
 * @date 2023/08/25
 */
‌‌‌  public class OOMTest {

    public static List<User> list=new ArrayList();

    public static void main(String[] args) {
        List<Object> list = new ArrayList<>();
        int i = 0;
        int j = 0;
        while (true) {
            // User有引用是非垃圾对象
            list.add(new User(i++, UUID.randomUUID().toString()));
            // User没有引用是垃圾对象
            new User(j--, UUID.randomUUID().toString());
        }
    }






‌‌‌  }



‌‌‌  package com.liu.java_dui_xiang_chuang_jian_he_nei_cun_fen_pei.dui_xiang_nei_cun_hui_shou;

‌‌‌  /**
 * @author jsLiu
 * @version 1.0.0
 * @description finalize()方法测试对象
 * @date 2023/09/03
 */
‌‌‌  public class User {
    Integer id;

    String name;

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }


    public User(int i, String uid){
        this.id=i;
        this.name=uid;
    }
    //User类需要重写finalize方法
    @Override
    protected void finalize() throws Throwable {
        // 让对象被引用,逃脱回收
        OOMTest.list.add(this);
        System.out.println("关闭资源,userid=" + this.getId() + "即将被回收");
    }
‌‌‌  }


‌‌‌  注意

‌‌‌  一个对象的finalize()方法只会被执行一次,也就是说通过调用finalize方法自我救命的机会就一次,躲过这次回收,下次回收还是要被回收。

‌‌‌  finalize()方法的运行代价高昂, 不确定性大, 无法保证各个对象的调用顺序, 如今已被官方明确声明为不推荐使用的语法。 有些资料描述它适合做“关闭外部资源”之类的清理性工作, 这完全是对finalize()方法用途的一种自我安慰。 finalize()能做的所有工作, 使用try-finally或者其他方式都可以做得更好、更及时, 所以建议大家完全可以忘掉Java语言里面的这个方法


如何判断一个类(类元信息)是无用的类


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

‌‌‌  类需要同时满足下面3个条件才能算是 无用的类”:

‌‌‌  1. 该类所有的对象实例都已经被回收,也就是 Java 堆中不存在该类的任何实例

‌‌‌  2. 加载该类的ClassLoader已经被回收(由JVM内部3个类加载器加载的类基本不会回收)。

‌‌‌  3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。

  • 29
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
JVM内存结构: JVM内存分为如下五个部分: 1. 程序计数器 程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。每个线程都有一个程序计数器,是线程私有的,生命周期与线程相同。 2. Java虚拟机栈 Java虚拟机栈也是线程私有的,生命周期与线程相同。每个方法执行的时候,JVM都会同步创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。方法调用结束后,相应的栈帧也会被销毁。 3. 本地方法栈 本地方法栈也是线程私有的,它与Java虚拟机栈的作用非常相似,只不过它是为虚拟机使用到的Native方法服务。 4. Java堆 Java堆是JVM所管理的内存中最大的一块,也是所有线程共享的。Java堆是用于存储对象实例的内存区域,几乎所有的对象实例都在这里分配内存。Java堆是垃圾收集器管理的重点区域,也被称为GC堆。 5. 方法区 方法区也是线程共享的,用于存储已被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在JDK8之前,永久代(PermGen)是方法区的一部分。在JDK8时,永久代被彻底移除,使用了元空间(Metaspace)来代替。 内存分配策略: JVM内存分配策略主要有以下几种: 1. 对象优先在Eden区分配 当JVM需要为新的对象分配内存时,会优先在Eden区进行分配。如果Eden区没有足够的空间,JVM会通过Minor GC回收部分内存空间。 2. 大对象直接进入老年代 如果要分配的对象大小超过了Eden区的一半,JVM会直接将该对象分配到老年代。这样做的目的是为了避免在Eden区内产生大量的垃圾对象,从而降低了Minor GC的频率。 3. 长期存活的对象进入老年代 JVM会为每个对象定义一个年龄计数器,当一个对象在Eden区经历了一次Minor GC后仍然存活,会被移动到Survivor区。在Survivor区中,对象会被继续观察,如果其存活时间达到了一定的阈值,就会被晋升到老年代中。这样做的目的是为了保证长期存活的对象能够在老年代中有足够的空间进行分配。 4. 空间分配担保 每次进行Minor GC时,JVM都会检查老年代的可用空间是否足够,如果足够,就可以安全地将所有存活的对象晋升到老年代中。如果不足,JVM会检查这次Minor GC之前的晋升到老年代的对象的平均大小与老年代的剩余空间的比值,如果比值大于某个阈值(通常为50%),那么这次Minor GC就会中止,JVM会进行Full GC来释放一些空间。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值