JVM内存分配机制详解

87 阅读12分钟

JVM内存分配机制详解

对象的创建过程

对象的创建过程.png

  • 对象的创建过程

    • 类加载检查

      • 虚拟机遇到一条new指令时,首先会去检查这个指令的参数是否能在常量池定位到一个类的符号引用,并且检查此符号引用代表的类是否已经被加载、验证、准备、解析和初始化过,如果没有,就会先执行类加载过程
    • 分配内存

      • 在类加载检查完之后,对象的内存页可以被确定下来,接下来JVM就会从堆上划分一块确定大小的内存给该对象

      • 划分内存的方法

        • 指针碰撞: 假设Java中的内存是规整的,用过的内存和空闲的内存用一个指针隔开,每次对象需要分配内存的时候,只要将指针往空闲区移动同等大小的距离
        • 空闲列表: 假设Java中的内存是不规整的,用户的内存和空闲的内存都是交错的,可以通过维护一个列表来记录空闲内存的地址,每次对象需要分配内存的时候,只要在列表上找一块足够大的给对象,并且更新列表的记录
      • 注意: 不管用什么方式分配,都有并发问题,如何解决对象分配的并发问题

        • CAS + 失败重试

          • 先比较,如果能分配下再将对象分配到内存块,如果分配失败,就重新尝试
        • TLAB(线程本地分配缓冲)

          • 为了避免线程之间内存资源竞争问题,对于每个线程都会在堆的Eden区申请一块指定大小的内存空间供自己分配对象内存使用,如果TLAB放不下时,只能走CAS + 失败重试去Eden区分配了
    • 初始化零值

      • 内存分配完成之后,JVM会给分配到的内存空间都初始化零值(不包括对象头),如果使用TLAB,这一流程也可以提前到TLAB分配时进行。这一步的操作保证了对象实例字段在Java代码中可以不赋值就可以直接使用,程序能访问到这些字段的数据类型对应的零值
    • 设置对象头

      • 虚拟机对对象头进行相应的设置,比如hash码,GC分代年龄等

      • 对象的布局

        • 对象头

          • 对象头的布局

            • Mark Word

              • 用于存储对象自身的运行时数据,比如hash、GC分代年龄、锁标志、线程持有的锁
              • 32位占4字节,64位占8字节
            • Klass Pointer

              • Klass类型的指针,指向类元数据信息的指针
              • 32位占4字节,64位占8字节,开启指针压缩占用4字节
            • 数组长度: 只有数组对象才有,占用4字节

        • 实例数据

          • 存放类的属性数据信息,包括父类的属性信息
        • 对齐填充: 保证对象是8字节的整数倍,经过内存对齐填充后,CPU的内存访问效率大大提升

          • 问题: 为什么要进行对齐填充

            • 性能问题: 避免重复访问内存导致效率降低,所以采用空间换时间的思想去提升效率
            • 平台原因: 不是所有的硬件平台都能访问任意地址上的任意数据的,某些硬件平台只能在某些地址处取某些特定类型的数据,否则抛出硬件异常
      • 问题: Object的内存大小

        • calc = (Mark Word-64位) + (Klass Pointer + 开启指针压缩) + 对齐填充 = 8 byte + 4 byte + 4byte = 16 byte
        • calc = (Mark Word-64位) + (Klass Pointer + 关闭指针压缩) = 8 byte + 8 byte = 16 byte
        • Object的内存大小占用16字节
    • 执行方法

      • 设置完对象头,然后执行<init>方法,也就是按照开发者的意愿进行初始化,对应的属性赋上预期的值,然后执行构造方法

指针压缩

  • 通过开启某个参数,可以把64位对象的内存地址从8字节压缩到4字节
  • 启动指针压缩: -XX:+UserCompressedOops(JDK6默认开启),禁止指针压缩-XX:-UserCompressedOops
  • 小常识: 32位电脑最大支持4G(2^32 ),64位电脑最大支持2^64内存,也就是TB级别

为什么要进行指针压缩

  • 通过对对象指针压缩编码,再从堆中取出后到CPU进行解码,这样就可以支持更大的内存地址,但目前JVM支持指针压缩的堆内存最大到32G
  • 如果不开启,会导致堆的压力变大,触发GC的可能性增加,而且会占用较大的带宽
  • 减少64位平台下内存消耗

对象内存分配

详细链接: www.processon.com/view/link/6…

对象内存分配流程.png

  • 对象内存分配流程

    • 创建一个对象的时候先考虑能否在栈上分配,如果能在栈上分,JVM不会创建对象,而是在栈上进行标量替换存放,随着栈帧的出栈而销毁,如果不能栈上分配,判断是否是大对象,如果是大对象,那么就直接移动到堆的老年代存放,如果不是大对象,判断能否在TLAB上进行分配,如果能分配就在Eden区的TLAB上进行分配,如果不能,就在Eden区进行CAS + 失败重试进行分配,对于存放在Eden区的对象,在经历了MinorGC之后,如果存活下来会被移动到Survivor区,并且分代年龄加1,每次Minor GC存活都会被移动到空的Survivor区的那部分并且分代年龄都会加1,当分代年龄达到了阈值,就会被移动到老年代

对象在栈上分配

  • 什么逃逸分析

    • 这发生在栈上分配的情况,当对象不被外部引用表示不逃逸,会在栈上标量替换存放,随着栈帧的出栈而销毁,如果被外部引用就会在堆上分配。
    • 逃逸分析的目的是为了栈上分配,而栈上分配主要是为了减少临时对象在堆上分配,减少GC的压力。
    • 而JDK8是默认开启逃逸分析的.
  • 标量和聚合量

    • 标量是不能进一步分解的量,比如一些基本数据类型
    • 聚合量是能进一步分解的量,比如对象
  • 什么是标量替换

    • 把不会逃逸的对象进行分解,JVM不会创建对象,而是将对象成员变量拆解成若干个被这个方法使用的成员变量的代替,这些代替的成员变量会在栈帧或寄存器上分配内存,这样就不会因为没有一块连续的空间导致对象内存不够分配
    • 开启参数: -XX:+EliminateAllocations,JDK7默认开启
    • 开启后,更大程度的把更多不逃逸的对象分配在栈上,前提是栈空间足够
  • 为什么要进行标量替换

    • 一个对象经过了逃逸分析,能够被确定在栈上分配,JVM不会创建对象,而是将这个对象拆分成若干个成员变量所替代,这些成员变量会被安排在栈帧上分配空间,这样就不会因为没有一整块连续空间而导致对象内存不够分配.

对象内存分配策略

  • 问题: MinorGC和FullGC有何不同
  • MinorGC:在年轻代进行垃圾回收,回收频率大,速度快
  • FullGC: 一般会回收老年代,年轻代,方法区,速度非常慢

对象优先在Eden去分配

  • 大多数情况下,对象在年轻代的Eden区分配,当Eden区没有足够空间进行分配时,JVM将发起一次MinorGC

  • 注意: Eden区和Survivor区默认8:1:1

    • 大量的对象分配到Eden区,Eden区满了就会触发MinorGC,可能会有99%以上的对象会成为垃圾被回收掉,剩余存活的对象会被移动到Survivor区,下一次Eden区又满了又会触发MinorGC
    • ,然后再重新把Eden区和Survivor区垃圾对象回收,把剩余存活的对象移动到另一块Survivor区,因为年轻代的对象都是朝生夕死的,存活的时间很短,所以JVM默认的8:1:1的比例是很合适的,所以要让Eden区尽量的大,Survivor区够用即可
  • 注意: JVM默认有这个参数-XX:+UseAdaptiveSizePolicy(默认开启),会导致这个8:1:1比例自动变化,如果不想这个比例有变化,可以设置参数-XX:UseAdaptiveSizePolicy

大对象直接进入老年代

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

  • 问题: 为什么只有Serial和parNew这两个收集器才生效

    • 因为这两个垃圾收集器采用的是复制算法,为了避免大对象分配内存时的复制操作而效率降低

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

  • 方案: JVM通过给每个独享分配一个分代年龄计数器

    • 对于存放在Eden区的对象,在经历了MinorGC之后,如果存活下来会被移动到Survivor区,并且分代年龄加1,每次Minor GC存活都会被移动到空的Survivor区的那部分并且分代年龄都会加1,当分代年龄达到了阈值(默认15,CMS默认是6,不同的垃圾收集器略微有点不同),就会被移动到老年代
    • 可以通过参数-XX:MaxTenuringThreshold设置

对象动态年龄判断机制

  • 经历过MinorGC在Survivor区存活下的前n代的对象的内存总和超过Survivor区的一半,那么n代以及n代以后的对象会被移动到老年代
  • 这个机制目的是为了那些可能是长期存活对象的尽早的进入老年代,对象动态年龄判断机制一般是在MinorGC之后触发

老年代空间分配担保机制

  • 年轻代每次MinorGC之前都会计算老年代剩余可用空间

  • 如果老年代剩余可用空间不小于年轻代堆里的现有所有对象包括垃圾对象,就会触发MinorGC

  • 反之,就会看有没有配置担保参数

  • 如果配置了这个担保参数

    • 那么就会看老年代剩余可用空间是否小于历史每一次MinorGC之后进入老年代的对象的平均大小

      • 如果老年代剩余可用空间是小于历史每一次MinorGC之后进入老年代的对象的平均大小,就会触发FullGC
      • 反之,触发MinorGC
  • 如果前面没有配置担保参数,就会触发FullGC

老年代空间分配担保机制.png

对象内存回收

对象内存回收算法

  • 问题: 如何判断对象是否存活

    • 引用计数法: 为每个对象添加一个引用计数器,每次被引用就会进行累加1,引用被释放了之后就减1,当计数器为0时代表可以被回收。这种算法简单高效,但是存在对象之间循环引用的问题

    • 可达性分析算法: 从gcroots出发找出它们所引用的对象,再从引用的对象一直找,直至没有引用别的对象为止,在这条引用链上的对象会被标记为非垃圾对象,其余的对象将会被回收.

      • 什么对象作为gcroots

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

finalize方法最终判断对象是否存活

  • 即使在可达性分析算法中不可达的对象,也并非一定要被回收,因为真正选好一个对象死亡,要经历再次标记过程
  • 第一次标记并进行一次筛选,筛选条件就是看此对象是否有必要执行finalize方法,如果没有,就直接回收
  • 第二次标记,如果这个对象重写finalize方法,对象可以将自身重新与gcroots引用链关联进行自救
  • 注意: 一个对象的finalize方法只会执行一次,也就是说通过finalize方法自救只能一次,调用一次后就会被回收

常见的引用类型

  • 强引用: 一般new的对象就属于强引用,如果被引用着,GC的时候就不会去回收
  • 软引用: 将对象用SoftRefrence类型的对象包裹,正常情况下不会被GC回收用软引用关联的对象,系统将要发生OOM之前,会回收这些对象,这次回收之后还是没有足够的空间,才会抛出OOM,一般用作高速缓存
  • 弱引用: 将对象用WeakRefrence类型的对象包裹,用弱引用关联的对象只能生存到下一次GC之前,下一次GC时,不管内存够不够,都会被回收
  • 虚引用: 幽灵引用,最弱随时会被GC回收

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

  • 问题: 什么情况下类会被卸载

    • 这个类的所有的对象实例都被回收
    • 加载这个类的类加载已经被回收
    • 这个类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问这个类的方法