likes
comments
collection
share

JVM(二)JVM内存模型深度剖析与对象分配机制

作者站长头像
站长
· 阅读数 16

JVM内存模型深度剖析与对象分配机制

一、背景铺垫


/**
	0. cd 文件所在目录
	1. 将java文件编译成字节码文件 javac Math.java -->Math.class
	2. 运行 java Math
*/
public class Math {

    public int add(int a, int b) {
        return a + b;
    }
    public static void main(String[] args) {
        Math math = new Math();
        System.out.println(math.add(1, 2));
    }

}

我们都知道java语言的一大特性就是夸平台,我们日常写的代码都是编译成字节码文件,运行在jvm虚拟机中,在开始JVM内存模型之前请简单运行此案例,并记住他

二、JVM整体结构及内存模型

JVM(二)JVM内存模型深度剖析与对象分配机制

如图所示,JVM运行时一共分为如下几块数据区

  1. 堆 - 我们都知道对是用来存放对象,堆有分成不同的区域,新生代、老年代;其中新生代有分为Eden 、s0 、s1 区域,再触发gc的时候 新生代触发minor gc 老年代触发full gc

JVM(二)JVM内存模型深度剖析与对象分配机制

  1. 方法区 - 常量 + 静态变量 + 类信息
  2. 栈 - 线程运行在栈上
  3. 本地方法栈 - 运行 native 修饰的方法
  4. 程序计数器 - 记录当前线程正在执行的字节码指令的地址.它可以用来支持线程的恢复和继续执行。当线程被切换到另一个线程时,当前线程的程序计数器的值会被保存下来,当该线程被再次切换回来时,它的程序计数器的值会被恢复。这个过程可以让线程在被中断后继续执行。
  5. 字节码执行引擎 — java 代码的执行是有字节码执行引擎执行 的,每执行一行代码都会字节码执行引擎都会修改线程栈中程序计数器的值。

我们都知道java 线成是运行在栈上面的,每个线程启动时会在栈上分配一块区域,供线程使用。当一个方法被调用时,Java 虚拟机会为该方法创建一个栈帧,并将其压入栈顶。当方法执行完毕后,虚拟机会弹出该栈帧,将控制权返回给调用该方法的方法。栈针包含局部变量表、操作数栈、动态链接、方法出口

  1. 局部变量表 — 用于存放方法运行过程汇总的局部变量
  2. 操作数栈 — 当方法执行时,它会从字节码中读取指令,并根据指令类型从操作数栈中弹出相应数量的操作数,执行指令后将结果压回操作数栈中。
  3. 动态链接 — 具体来说,当程序调用一个方法时,虚拟机会在类的方法表中查找方法的符号引用,并将其转化为直接引用
  4. 方法出口 — 因为java调用 在栈上变化就是栈帧的进栈和出栈,当一个方法调用完成之后需要回到调用该方法的地方 继续执行

三、java 中的对象

我们都知道 我们在日常使用java的时候 通过 new 关键字创建的对象 大部分情况下都存储在堆中 (有一些情况会触发栈上分配后续讲),那么我们的对象 是个什么样的结构呢,以及对象创建的流程是什么样的呢?

  1. 对象的结构

    对象结构包含两个主要部分:对象头和实例数据。

    1. 对象头:对象头是一个固定大小的数据结构,用于存储对象的元数据信息,包括对象的哈希码、类元数据指针、锁状态标志等。对象头的大小和内容可能因 JVM 实现而异。

    JVM(二)JVM内存模型深度剖析与对象分配机制

    1. 实例数据:实例数据是对象存储的主要部分,它包含对象的属性或字段的值,以及可能包含一些对象的方法和其他操作。
  2. 对象创建流程

    JVM(二)JVM内存模型深度剖析与对象分配机制

    1. 类加载检查 - 当我们去new 关键字创建对象的时候 jvm会先检查 对应的类是否已经加载进jvm内存,如果未加载,会先进行加载
    2. 内存分配,我们知道对象是有大小的,并且存储在堆中,当我们要new 一个对象在堆中需要一小块连续的区域来存放对象 ,那么这块就有两个小问题了,
      1. jvm分配内存有哪些方法
        1. 指针碰撞法

          指针碰撞(Pointer Bumping)是一种内存分配算法,通常用于基于物理地址的内存管理系统中。这种算法利用指针在内存中的连续性,将内存分为两个区域:已分配和未分配。当需要分配一块新的内存时,指针会直接指向未分配区域的空闲内存,将指针向前移动所需的内存块大小,然后返回指针的旧值作为分配的内存的起始地址

        2. 空闲列表

          空闲列表(Free List)是一种内存分配算法中常用的数据结构,用于管理可用的内存块,即还未被分配的内存块。空闲列表通常是一个链表,链表中的每个节点代表一个空闲的内存块,节点中存储了该内存块的大小和起始地址等信息

      2. jvm如何保证对象分配过程中多线程同时分配不出现内存泄露问题呢?
        1. CAS compare and swap

        2. 本地线程分配缓冲(Thread Local Allocation Buffer,TLAB)

          java 中由于多线程共享同一块内存区域,在对象分配时会存在争抢的线程,本地线程分配缓冲指的是 每个线程在创建的时候会在堆中开辟一块线程独享的区域,当线程需要分配内存时,JVM会尝试从该线程的TLAB中分配内存。如果TLAB中没有足够的空间,就会退化为普通的堆内存分配,此时需要加锁以保证线程安全

    3. 初始化零值— 对象创建完成后会对对象中的变量进行初始化,注意这会初始化时各种类型的默认值,这一步操作保证了对象的实例字段在Java代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。
    4. 设置对象头 — 设置对象头相关信息
    5. 执行对象init 方法 — 即对象按照程序员的意愿进行初始化。对应到语言层面上讲,就是为属性赋值(注意,这与上面的赋零值不同,这是由程序员赋的值),和执行构造方法。

四、对象内存分配流程

JVM(二)JVM内存模型深度剖析与对象分配机制

栈上分配

我们都知道java的对象是被分配在栈上的,当我们的对象没有被引用的时候,会通过gc 回收掉,但是如果创建很多没有被引用的对象 的话会给gc造成一定的压力,在我们创建对象的过程中jvm会通过一中逃逸分析技术来判断我们创建的对象是否会逃逸处我们的方法 ,如果无法逃逸出去,并且空间足够就会实行栈上分配,这样做就会随着线程运行完直接销毁空间,减少gc压力

逃逸分析

public User save1(){
        User user = new User();
        user.setId(1);
        user.setName("admin");
        
        //TODO 保存数据库
        return user;
  }

public void save2(){
    User user = new User();
    user.setId(1);
    user.setName("admin");

    //TODO 保存数据库
}

save1 方法中创建的对象,最后会返回,也就是说user 对象有可能会被方法外的变量引用,但是save2 方法中创建的对象,逃不出save2 方法, 当save2方法运行结束 user 对象就会成为垃圾对象,对于这样的对象我们其实可以把对象分配到栈上,随着方法的技术释放内存

JVM对于这种情况可以通过开启逃逸分析参数(-XX:+DoEscapeAnalysis)来优化对象内存分配位置,使其通过标量替换优先分配在栈上(栈上分配),JDK7之后默认开启逃逸分析,如果要关闭使用参数(-XX:-DoEscapeAnalysis)

**标量替换:**通过逃逸分析确定该对象不会被外部访问,并且对象可以被进一步分解时,JVM不会创建该对象,而是将该对象成员变量分解若干个被这个方法使用的成员变量所代替,这些代替的成员变量在栈帧或寄存器上分配空间,这样就不会因为没有一大块连续空间导致对象内存不够分配。开启标量替换参数(-XX:+EliminateAllocations),JDK7之后默认开启

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

测试栈上分配

/**
 * 栈上分配,标量替换
 * 代码调用了1亿次createUser(),如果是分配到堆上,大概需要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 Test {

    public static void main(String[] args) {

        long start = System.currentTimeMillis();

        for (int i = 0; i < 1000000000; i++) {
            createUser();
        }

        System.out.println(System.currentTimeMillis() - start);

    }

    public static void createUser() {
        User user = new User();
        user.setId(1);
        user.setName("admin");
    }
}

对象在Eden 区分配

我们知道,大对数对象都是在eden区分配,当eden 区空间不足的时候会触发一次minor gc 将对象已到s0\s1 区,我们先来测试一下,在测试之前我们来看下 minor gc 和full gc 有什么不同

minor gc 和full gc 的不同

  1. minor gc 是发生在新生代的gc 特点是 频繁,耗时短
  2. full gc 是发生早老年代 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

示例:

public class GCTest {

    /**
     * -XX:+PrintGCDetails
     *
     * @param args
     */
    public static void main(String[] args) {
        byte[] allocation1, allocation2/*, allocation3, allocation4, allocation5, allocation6*/;
        allocation1 = new byte[60 * 1024 * 1024];

//        allocation2 = new byte[8 * 1024 * 1024];

    }
}

Heap
 PSYoungGen      total 76288K, used 65536K [0x000000076ab00000, 0x0000000770000000, 0x00000007c0000000)
  eden space 65536K, 100% used [0x000000076ab00000,0x000000076eb00000,0x000000076eb00000)
  from space 10752K, 0% used [0x000000076f580000,0x000000076f580000,0x0000000770000000)
  to   space 10752K, 0% used [0x000000076eb00000,0x000000076eb00000,0x000000076f580000)
 ParOldGen       total 175104K, used 0K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
  object space 175104K, 0% used [0x00000006c0000000,0x00000006c0000000,0x00000006cab00000)
 Metaspace       used 3276K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 362K, capacity 388K, committed 512K, reserved 1048576K

Process finished with exit code 0

我们看到 eden 分配 60M 的空间 此时 eden区已经站慢,我们在打开 allocation2 的注释,在在看gc 日志
[GC (Allocation Failure) [PSYoungGen: 65372K->608K(76288K)] 65372K->62048K(251392K), 0.0220770 secs] [Times: user=0.07 sys=0.02, real=0.02 secs] 
Heap
 PSYoungGen      total 76288K, used 9455K [0x000000076ab00000, 0x0000000774000000, 0x00000007c0000000)
  eden space 65536K, 13% used [0x000000076ab00000,0x000000076b3a3ef8,0x000000076eb00000)
  from space 10752K, 5% used [0x000000076eb00000,0x000000076eb98020,0x000000076f580000)
  to   space 10752K, 0% used [0x0000000773580000,0x0000000773580000,0x0000000774000000)
 ParOldGen       total 175104K, used 61440K [0x00000006c0000000, 0x00000006cab00000, 0x000000076ab00000)
  object space 175104K, 35% used [0x00000006c0000000,0x00000006c3c00010,0x00000006cab00000)
 Metaspace       used 3276K, capacity 4496K, committed 4864K, reserved 1056768K
  class space    used 362K, capacity 388K, committed 512K, reserved 1048576K

我们看到,此时触发了一次minorgc  会把eden 区 60M数据向**Survivor 区移动,但是survior 区空间 不足,最后将数据移动到了老年代**

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下有效

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

jvm 每进行一次minor gc 每有没回收掉的对象 年龄就会+1 (对象年龄在对象头中又一个字段表示),当对象年龄大于 Jvm 设定的值时 (默认是 15 cms 回收器 默认是 6),就是把对象从新生代 挪到老年代 ,对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

对象动态年龄判断机制

当前放对象的Survivor区域里(其中一块区域,放对象的那块s区),一批对象的总大小大于这块Survivor区域内存大小的50%(-XX:TargetSurvivorRatio可以指定),那么此时大于等于这批对象年龄最大值的对象,就可以直接进入老年代了.

例如

-XX:MaxTenuringThreshold = 15

  1. 年龄为1 的对象 占 33%
  2. 年龄为2 的对象 占 33%
  3. 年龄为3 的对象占 34%

当Survivor 区满的时候,对象按年龄从小算起 年领1+年龄2+年龄N 对象,指导对象占比空间超过 50%,会将 大于该年龄的以上的对象移入老年代

正对以上面的案例,年领1+年龄2 的对象 > 50%, 此时就会把 年龄≥2 的对象提前移入老年代

老年代空间担保机制

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

如果这个可用空间小于年轻代里现有的所有对象大小之和(包括垃圾对象)

就会看一个“-XX:-HandlePromotionFailure”(jdk1.8默认就设置了)的参数是否设置了

如果有这个参数,就会看看老年代的可用内存大小,是否大于之前每一次minor gc后进入老年代的对象的平均大小

如果上一步结果是小于或者之前说的参数没有设置,那么就会触发一次Full gc,对老年代和年轻代一起回收一次垃圾,如果回收完还是没有足够空间存放新的对象就会发生"OOM"

当然,如果minor gc之后剩余存活的需要挪动到老年代的对象大小还是大于老年代可用空间,那么也会触发full gc,full gc完之后如果还是没有空间放minor gc之后的存活对象,则也会发生“OOM”

JVM(二)JVM内存模型深度剖析与对象分配机制

五、判断对象是否是垃圾对象的两种方法

引用计数法

一个对象没被一个变量引用,对象的引用计数就会加1 当对象引用计数为0的时候,可以判断次对象是垃圾对象。但是这存在一个循环引用的问题

public class CycleRefObject {

    public CycleRefObject instance = null;

    public static void main(String[] args) {
        CycleRefObject a = new CycleRefObject(); // a 对象引用计数此时为1
        CycleRefObject b = new CycleRefObject(); // b 对象引用计数此时为1

        b.instance = a; // a 对象引用计数此时 +1 变为2
        a.instance = b; // b 对象引用计数此时 +1 变为2

        a = null; // a 对象引用计数此时 -1 变为1
        b = null; // b 对象引用计数此时 -1 变为1
        
        // 当我们程序运行完,a b 对象引用计数 分别为1 无法做到清零,这就是我们常说的循环引用
        
    }
}

对象可达性分析算法

将**“GC Roots”** 对象作为起点,从这些节点开始向下搜索引用的对象,找到的对象都标记为非垃圾对象,其余未标记的对象都是垃圾对象

GC Roots根节点:线程栈的本地变量、静态变量、本地方法栈的变量等等

JVM(二)JVM内存模型深度剖析与对象分配机制

六、Java 中的引用类型

java 中一共有四种引用类型 强引用、弱引用、软引用、虚引用

  1. 强引用 : 普通变量的引用

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

    public static SoftReference<User> user = new SoftReference<User>(new User());
    
    
  3. 弱引用:将对象用WeakReference软引用类型的对象包裹,弱引用跟没引用差不多,GC会直接回收掉,很少用

    public static WeakReference<User> user = new WeakReference<User>(new User());
    
  4. 虚引用 : 几乎不用

七、总结

  1. jvm 内从模型一共分我堆、方法区、栈、本地方法栈、程序计数器
  2. 对象分配内存空间有两种算法 指针碰撞、空连列表,保证对象分不出现并发问题采用的方法是CAS 和TLAB
  3. 对象分配流程中 逃逸分析 标量替换以及栈上分配
  4. 对象分配到Eden 区,Eden 区和Survior 区默认是 8:1:1
  5. 对象进入老年代的几种机制
    1. 大对象直接进入老年代
    2. 长期存活的的对象进入老年代
    3. 对象动态年龄判断机制
    4. 老年代空间担保机制
  6. JVM 判断对象是否是垃圾算法的两种算法 ,引用计数和可达性分析算法
  7. Java 中常见的四种引用类型
    1. 强引用
    2. 软引用
    3. 弱引用
    4. 虚引用
  8. 本文中出现的一些配置参数
    1. -XX:+DoEscapeAnalysis 开启逃逸分析 (jdk 7 之后默认开始,关闭使用 -XX:-DoEscapeAnalysis)
    2. -XX:+EliminateAllocations 开启标量替换 (jdk 7 之后默认开始,关闭使用 -XX:-EliminateAllocations)
    3. -XX:+UseAdaptiveSizePolicy 默认开启,功能是jvm 会动态调整 Eden 和Survior 区8:1:1的比例 关闭使用-XX:-UseAdaptiveSizePolicy
    4. -XX:MaxTenuringThreshold 进入老年代对象年龄阈值 (默认是15,cms 垃圾回收器是 6)
    5. -XX:TargetSurvivorRatio Survior 区触发动态年龄判断机制的阈值(默认 50%)
    6. “-XX:-HandlePromotionFailure”(jdk1.8默认就设置了) 老年代空间担保机制
    7. -XX:+PrintGCDetails 打印gc 详情
    8. -XX:+PrintGC 打印Gc