深入理解Java虚拟机中的运行时数据区:堆

 2023-01-17
原文作者:香香软软的战列舰 原文地址:https://juejin.cn/post/6896463867590180872

堆在JVM启动时被创建,大小也随之确定,是JVM最大、最重要的一块内存空间。堆在物理上不连续,而在逻辑上连续。虽然堆是每个线程共享,但也可以划分线程私有的缓冲区

几乎所有的对象实例和数组都分配在堆中,少部分分配在栈中。对象和数组可能永远不会存储在栈上,栈帧中仅保存引用,而这引用指向堆中的位置。在栈帧结束后,堆中的对象不会马上被移除,仅在垃圾回收时才被移除。

虚拟机的方法区和堆空间,是每个线程共用的,其他数据区为每个线程私有,私有数据区颗粒度较细,难有优化的空间。而共用的数据区,内存分配时能得到较多的内存,所以该数据区是GC时的重点。

新生代和老年代

在Java7以前,堆空间在逻辑上划分为:新生区+老年区+永久代;在Java8及之后分为:新生区+老年区+元空间

永久代和元空间,是方法区的具体实现,方法区又叫non-heap,所以永久代和元空间实际上不归属于堆,只是逻辑划分而已。

存储在JVM的Java对象可以被划分成两类,一类是生面周期比较短的瞬时对象,这些类的创建和消亡都非常迅速;另一类对象的生命周期却非常长,在某些极端情况下,还能够与JVM的生命周期保持一直,如Runtime

为什么要进行内存分带?大量数据表明,不同对象的生命周期不同,70%~90%的对象时临时对象,对对象进行分代可以更有目的的进行GC,优化GC性能。

新生代

新生代可以划分为EdenSurvivor0Survivor1,Survivor0和Survivor1有时也叫作from、to。

  • Eden:对象(几乎所有)最开始创建的位置。
  • Survivor0和Survivor1:Eden区没有被GC清除的对象会被放到S0和S1中,S0和S1大小一致,只有一个空间用来存储对象,另一个区用来全量GC。

绝大部分的Java对象都是在新生代销毁,IBM研究表明,新生代80%的对象都是朝生夕死。

老年代

堆空间除了新生代就是老年代啦。。

对象在堆中的分配过程

1、对象最开始先被分配到Eden区,Eden区满了之后会进行Young GC,存活下来的对象会被移动到Survivor区。每个对象都有一个年龄计数器,在Eden区为0,被移动到S区则变为1。S区满不会触发YGC

2、存活在S区的对象,在经过第二次GC之后能存活下来的,年龄计数器会继续+1。当年龄计数器达到15后,对应的对象会被移动(promotion)至老年区。该值可以通过-XX:MaxTenuringThreshold=15进行设置。

3、当对象占用的空间太大,YGC后任然超过了Eden区的空间时,会直接将该对象分配到老年代。老年代放不下则进行Full GC,GC后任然放不下则OOM。存活在Eden区的对象在YGC后,S区放不下,则会直接分配到老年代。

与堆内存设置相关的参数

  • -Xms,表示堆的起始内存(新生代和老年代)

全称为-XX:InitialHeapSize

X是JVM的运行参数,ms是memory start。默认单位为字节,有k、m、g

  • -Xmx,表示堆的最大内存

全称为-XX:MaxHeapSize

默认的堆空间大小:

-Xms = 计算机内存大小 /64

-Xmx = 计算机内存大小 /4

一旦超过了-Xmx所制定的最大内存时,将会抛出OutOfMemoryError。通常会将两个参数设置成相同的值,让垃圾回收完后,不需要重新分隔计算堆区的大小,进而提高性能。

可以通过以下程序查看JVM的堆大小:

    // -Xms 堆的起始值,s0,s1区只算其中的一个
    long initalMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024
    // -Xmx 堆的最大值
    long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024

关闭自适应的内存分配策略

-XX:-UseAdaptiveSizePolicy:-为关闭,+为开启,默认开启

配置新生代和老年代在对结构的占比

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3;可以修改为-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5
  • 在HotSpot中,Eden和S0、S1的默认比例为8:1:1,可以通过-XX:SurvivorRatio=8调整比例

配置新生代的最大内存大小

  • -Xmn,一般默认即可,有设置就以这个参数为准。

与堆相关的GC

JVM在GC时,并不是整体进行垃圾收集,而是频繁收集新生代,较少收集老年代,几乎不在永久代/元空间收集。

  • Minor GC,只对新生代进行垃圾收集,当Eden区内存不足时会触发,Survivor区满不会GC。

  • Major GC ,只对老年代进行垃圾收集

    目前只有CMS GC会有单独收集老年代的行为

    很多时候Major GC会和Full GC混淆使用,需要具体分辨是老年代回收还是整堆回收

    Major GC经常会伴随Minor GC,也就是在老年代空间不足时,会尝试先触发Minor GC,之后空间还不足,则触发Major GC。Major GC的速度比Minor GC慢十倍以上,STW时间也更长。

    尽量避免

  • Mixed GC,收集新生代和部分老年代的垃圾,只出现于G1 GC

  • Full GC,收集整个Java堆和方法区的垃圾

    引起Full GC的几种情况: 调用System.gc()时,系统建议执行Full GC,但是不必然执行;老年代空间不足;方法区空间不足;Minor GC后进入老年代的平均大小大于老年代的可用内存;老年代放不下从Eden区过来的大对象;

    尽量避免

堆内存分配策略

  • 优先分配到Eden区
  • 大对象直接分配到老年代,避免程序中出现过多的大对象
  • 长期存活的对象分配到老年代
  • 动态对象年龄判断,如果Survivor区中相同年龄的所有对象大小总和大于S区空间的一半,年龄大于等于的对象可以直接进入老年代,无需等到MaxTenuringThreshold要求的年龄
  • 空间分配担保,-XX:HandlePromotionFailure

堆内存的线程私有空间:TLAB

TLAB:ThreadLocal Allocation Buffer

堆区时线程共享的区域,任何线程都可以访问到堆区中共享数据。由于对象实例创建在JVM中非常频繁,因此在并发环境下,从堆区划分内存空间是线程不安全的。为了避免多个线程操作同一地址,需要加锁等机制,加锁影响分配速度。因此有必要为每个线程单独分配一块区域存放数据,该区域存在于Eden区中,这种策略也叫快速分配策略

尽管不是所有的对象实例都能够在TLAB中成功分配内存,但JVM确实将TLAB作为内存分配的首选。可以通过-XX:UseTLAB设置是否开启,默认开启。默认情况下,TLAB空间非常小,只占Eden区的1%,可以通过-XX:TLABWasteTargetPercent进行设置占用的百分比。

一旦对象在TLAB分配失败时,JVM会尝试加锁确保数据操作的原子性,从而直接在Eden区分配。