JVM内存模型与内存分配

 2023-01-28
原文作者:爱学习的小金鱼 原文地址:https://juejin.cn/post/6943788247483056164

JVM内存模型

内存模型图

202301011630252591.png

分析内存模型图

  • 类加载子系统 : java字节码文件由类加载子系统加载与初始化,类加载器的知识可参考这篇文章

  • : 栈我更习惯叫它线程栈, 每一条线程都有自己单独的栈空间,线程结束后会释放栈内存。栈是以帧为单位保存当前线程的运行状态,名为栈帧。

    • 普通方法栈帧: 栈帧由局部变量、操作数栈、动态链接三部分组成。

      • 下面使用一个例子介绍 局部变量操作数栈 的关系:
            //使用javap -c 解析该代码
            public static int incr() {
                    int a = 1;
                    int b = 2;
                    int c = (a + b) * 3;
                    return c;
            }
            //JVM指令
            Code:
             0: iconst_1 //将int类型常量1压入操作数栈()
             1: istore_0 //将int类型值存入局部变量0
             2: iconst_2 //将int类型常量2压入操作数栈
             3: istore_1 //将int类型值存入局部变量1
             4: iload_0  //从局部变量0中装载int类型值
             5: iload_1  //从局部变量1中装载int类型值
             6: iadd	 //执行int类型的加法
             7: iconst_3 //将int类型常量3压入栈
             8: imul	 //执行int类型的乘法
             9: istore_2 //将int类型值存入局部变量2
             10: iload_2 //从局部变量2中装载int类型值
             11: ireturn //从方法中返回int类型的数据
    JVM会先将变量值压入 **操作数栈** , 再将变量存入 **局部变量** 中, 最后将装载变量值。
    
     *  动态链接: 符号引用和直接引用在运行时进行 **解析和链接的过程** ,叫动态链接。
 *  main方法栈帧: main方法栈帧的 **局部变量** 和普通方法是有区别的, 普通方法的局部变量都是在栈内进行的,main方法的局部变量会以指针的形式指向堆内存区。
  • : 堆区域分为新生代和老年代, 其中新生代分为eden区和Survivor区, Survivor区又分为from区和to区。

JVM内存参数设置

例:

    java -Xms2048M -Xmx2048M -Xmn1024M -Xss512K -XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M -jar microservice-eureka-server.jar

-Xss:每个线程的栈大小

-Xms:初始堆大小,默认物理内存的1/64

-Xmx:最大堆大小,默认物理内存的1/4

-Xmn:新生代大小

-XX:NewSize:设置新生代初始大小

-XX:NewRatio:默认2表示新生代占年老代的1/2,占整个堆内存的1/3。

-XX:SurvivorRatio:默认8表示一个survivor区占用1/8的Eden内存,即1/10的新生代内存。

关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N

-XX:MaxMetaspaceSize : 设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。

-XX:MetaspaceSize : 指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M左右,达到该值就会触发full gc进行类型卸载, 同时收集器会对该值进行调整: 如果释放了大量的空间, 就适当降低该值; 如果释放了很少的空间, 那么在不超过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值。这个跟早期jdk版本的**-XX:PermSize**参数意思不一样,- XX:PermSize 代表永久代的初始容量。

由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大,对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。

对象的创建

流程图

202301011630260472.png

1. 类加载检查

当虚拟机遇到一条new指令时,首先检查指令的参数是否能再常量池中定位到一个类的符号引用, 并检查符号引用的类是否已经被 加载、解析、初始化 , 如果没有会先执行类加载。

2.分配内存

当类加载检查通过后, JVM会为新生对象分配内存, 所需的内存大小在类加载完成后就可完全确定。

分配内存是有两个问题:

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

内存划分的方法:

  • 指针碰撞(默认使用) 如果堆中内存是绝对规整的, 将用过的内存放在一边, 没用过的放到另一边, 中间以一个指针作为界点的指示器,划分内存时只需向空闲内存那边挪动对象大小相等的距离即可。

  • 空闲列表

    如果对重的内存不是规整的,虚拟机就必须维护一个列表, 记录那块内存可用哪块不可用, 分配内存时从列表中找到一块足够大的空间为对象划分内存, 这样有一个显然的问题就是内存碎片。

解决并发问题的方法

  • CAS: 虚拟机采用CAS配上失败重试的方法保证更新操作的原子性对分配空间进行同步处理。
  • 本地线程分配缓冲: 把内存分配的动作按照线程划分在不同的空间之中进行, 即每个线程在堆中预先分配一小块内存,通过-XX:+/-UseTLAB来设定虚拟机是否使用TLAB(默认开启), -XXTLABSize指定TLAB的大小。

3.初始化

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

4.设置对象头

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

在HotSpot虚拟机中,对象在内存中存储的布局分为3块区域: 对象头、实例数据和对齐填充。 对象头包括两部分信息, 第一部分用于存储对象自身运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。 另一部分是类型指针,对象指向它的类元数据的指针, 虚拟机通过指针来确定这个对象是哪个类的实例。

202301011630265843.png

5.执行 方法

对应到语言层面上讲就是为属性赋值(代码中赋的值)和执行构造方法

对象大小与指针压缩

什么是指针压缩?

jdk1.6开始在64位系统中支持指针压缩, jvm配置参数:XX:+/-UseCompressedOops 开启/禁止指针压缩

为什么要使用指针压缩?

  • 在64位操作平台的HtpSpot中使用32位指针内存的使用会多出1.5倍左右,使用较大指针在主内存和缓存之间移动数据, 占用带宽大, GC也会承受较大的压力。
  • 在jvm中32位地址最大支持4G内存(2的32次方),Mark Word占用4个字节。在64位系统中需要占用8个字节, 使用指针压缩算法编码为4个字节存储, 处理器处理前进行解码为8字节进行处理,使得jvm只用32位地址就可以支持更大的内存配置。
  • 对内存小于4G时不需要启用指针压缩, 但是大于32G时,压缩指针会失效, 会强制使用64位既8个字节来对java对象寻址, 所以堆内存不要大于32较好。

对象内存分配

对象栈上分配

对象逃逸分析

通过对JVM的分析,我们知道对象都是在堆上进行分配, 当对象没有引用时需要依靠GC来进行垃圾回收, 如果垃圾较多会给GC来带较大的压力,影响性能, 为减少临时对象在堆内分配的数量, JVM通过 逃逸分析 来确定对象不会被外部访问。

    	//分析对象动态作用域
    	//对象逃逸
        public User getUser(){
            User user = new User();
            user.setId(1);
            return user;
        }
        //对象未逃逸
        public void setUser(){
            User user = new User();
            user.setId(1);
        }

如果不会逃逸可以将对象在 栈上分配 内存, 这样对象占用的内存就可以随着栈帧出栈而被销毁,减轻了GC的压力。

通过-XX:+DoEscapeAnalysis开启逃逸分析, jdk7之后默认开启逃逸分析, 如需关闭通过-XX:-DoEscapeAnalysis参数。

标量替换

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

标量与聚合量

简而言之 标量 就是不可拆分的量,如int long等基本数据类型以及reference类型等, 聚合量 是可拆分的量,java对象就是可以被进一步分解的量

对象在Eden区分配

大多数情况对象在新生代的Eden区分配, 当Eden区没有足够的空间分配时, JVM会发起一次minor GC。

Eden区与Survivor区默认的比例是8:1:1

触发minor GC后可能有99%的对象都将成为垃圾被回收掉, 剩余存活的对象会被移动到Survivor区,下一次触发minor GC时会把eden区和Survivor区的垃圾对象回收, 并把剩余存活的对象一次性移动到另外一块survivor区。

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

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象 如字符串、数组。

JVM参数 -XX:PretenureSizeThreshold 可以设置大对象的大小,如果对象超过设置大小会直接进入老年代,不会进入年轻代,这个参数只在 Serial 和ParNew两个收集器下 有效。

为什么要这样做?

为了避免为大对象分配内存时的复制操作而降低效率。

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

JVM给每一个对象一个对象年龄计数器,新生代中的对象每经历一次minor gc能够存活下来并且能被Survivor容纳的话, 对象年龄就会+1, 当达到一定年龄(默认值是15, CMS收集器默认是6)后如果对象还会存活那么就会直接进入老年代。

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

对象动态年龄判断

当前放对象的一块Survivor区域,一批对象的总大小大于这块Survivor区总容量的50%(-XX:TargetSurvivorRatio可以指定), 那么此时 大于等于 这批对象年龄的 最大值 的对象就可以直接进入老年代了。 这个规则其实就是希望那些可能长期存活的对象尽早进入老年代。

对象动态年龄判断机制一般都是发生在minor gc之后。

老年代空间分配担保机制

年轻代每次发生minor gc之前, JVM都会计算一个老年代的剩余可用空间, 如果这个可用空间小于年轻代现有的所有对象,就会检测是否开启了“-XX:-HandlePromotionFailure”(JDK1.8默认开启)参数设置,如果有这个参数, 就会查看老年代剩余空间大小是否大于之前minor gc进入老年代大小的平均值。

如果没有设置该参数那么就会触发一次full gc, 如果回收后还没有足够的空间存放新的对象就会发生OOM。

如果minor gc之后需要移动到老年代的对象大小大于老年代剩余的空间那么也会触发full gc, 相同如果回收后还是没有足够空间则会发生OOM。

对象内存回收算法

堆中几乎放着所有对象的实例, 对堆垃圾回收钱的第一步就是要判断哪些对象已经死亡, 需要回收。

引用计数法

给对象中添加一个引用计数器, 每当有一个地方引用它, 计数器就会加1, 当引用实现计数器就会减1, 任何时候计数器为0的对象就是需要回收的对象。

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

例如 现有A/B两个对象, A引用着B,B也引用A, 导致这两个对象的引用计数器永远都不是0。

可达性分析算法

将"gc roots" 对象作为七点, 从这些结点开始向下搜索引用的对象, 找到的对象都是 非垃圾对象 , 其余的都是垃圾对象。

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

202301011630278804.png

常见的引用类型

强引用、软引用、弱引用、虚引用

  • 强引用 : 普通的变量引用
        Tax tax = new Tax();
  • 软引用 : 将对象用SoftReference软引用类型的对象包裹, 正常情况不会被回收, 但是GC完成后发现释放不空间存放新的对象, 则会把这些软引用的对象回收掉。 软引用可用来实现内存敏感的告诉缓存。
        SoftReference<Tax> tax = new SoftReference<Tax>(new Tax());
  • 弱引用 : 将对象用WeakReference软引用类型的对象包裹, 弱引用和没引用差不多, GC会直接回收掉, 一般不用
        WeakReference<Tax> tax = new WeakReference<Tax>(new Tax());
  • 虚引用 : 最弱的一种引用关系, 几乎不用

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

  • 第一次标记并进行一次筛选:

    • 筛选的条件是此对象是否有必要执行finalize()方法,既可达性分析后发现没有GC Roots相连接的引用。
    • 当对象没有重写finalize()方法,对象将直接被回收
  • 第二次标记

    • 如果重写了finalize方法,就可以在该对象回收前做一些事情, 比如要拯救自己, 如果要在finalize中拯救自己, 只需要重新与GC Roots引用链上任何的一个对象关联即可。
    • 一个对象的finalize()方法只会被执行一次, 也就是说通过finalize方法只能拯救自己一次。

如何判断一个类时无用的类

方法区主要回收无用的类

类需要同时满足3个条件才算 无用类

  • 该类的所有实例都被回收
  • 加载该类的ClassLoader已经被回收
  • 该类的Class对象没有在任何地方被引用, 无法在任何地方通过反射访问该类的方法。