vlambda博客
学习文章列表

JVM 中存放对象的地方

大家好,我是程序员学长

今天我们来聊一下 JVM 中一个比较重要的概念 -- 堆

针对一个 JVM 进程来说,堆是唯一的,也就是说一个 JVM 进程中的多个线程是共享堆空间的。
堆在JVM启动的时候即被创建,其空间大小也就确定了。同时,堆也是 JVM 管理的最大一块内存空间。

下图是我们通过 jdk自带的插件 jvisualvm 来查看堆空间的内容。

JVM 中存放对象的地方

堆内存的细分

java7及之前堆内存在逻辑上划分为三部分:新生区、养老区和永久区。

  • Young/New Generation Space 新生区又被划分为 Eden 区和 Survivor 区。
  • Old/Tenure Generation Space 养老区。
  • Permanent Space 永久区

java8 及之后堆内存逻辑上分为三部分:新生区、养老区和元空间。

  • Young/New Generation Space 新生区又被划分为 Eden 区和 Survivor 区。
  • Old/Tenure Generation Space 养老区。
  • Meta Space 元空间
tips:新生区也叫新生代、年轻代,养老区也叫老年代,永久区也叫永久代。

堆空间内部结构从JDK1.8开始,由之前的永久代变成了元空间。

JVM 中存放对象的地方

设置堆内存的大小

Java 堆区用于存储 Java 对象实例。堆大小在 JVM 启动的时候就已经设定好了,我们可以通过 -Xms 和 -Xmx 参数来进行设置。
  • -Xms 用于表示堆区的起始内存,等价于 -xx:InitialHeapSize。
  • -Xmx 用于表示堆区的最大内存,等价于 -xx:MaxHeapSize。
一旦堆区中的内存大小超过 "-Xmx" 所指定的最大内存时,将会抛出 OutofMemoryError 异常。通常会将 -Xms 和 -Xmx 两个参数配置成相同的值,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。
/**
 *
 * -Xms 用来设置堆空间(年轻代+老年代)的初始内存大小
 * -Xmx 用来设置堆空间(年轻代+老年代)的最大内存大小
 *
 */
public class TestHeapSize {
    public static void main(String[] args) {

        // 返回Java虚拟机中堆内存总量

        long initialMemory=Runtime.getRuntime().totalMemory() / 1024 / 1024;

        // 返回Java虚拟机试图使用的最大内存
        long maxMemory=Runtime.getRuntime().maxMemory() / 1024 / 1024;

        System.out.println("-Xms:" + initialMemory );
        System.out.println("-Xmx:" + maxMemory );

    }
}

年轻代和老年代

存储在 JVM 中的 Java 对象可以被划分为两类。

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速。
  • 另外一类对象的生命周期却非常长,在某些极端的情况下还能与 JVM 生命周期保持一致。
Java 堆区进一步细分的话,可以划分为年轻代和老年代,其中年轻代又可以划分为 Eden 空间 、 Survivor0 空间和Survivor1 空间,其中Survivor0 、Survivor1 有时也会被称为 from 区 和 to 区。
JVM 中存放对象的地方
我们可以通过调整配置参数 -XX:NewRatio 来调整年轻代和老年代在堆空间中的占比。默认 -XX:NewRatio = 2,表示年轻代占1,老年代占2,年轻代占整个堆空间的 1/3。
当发现在整个项目中,生命周期长的对象偏多,那么就可以调整老年代的大小来进行调优。
在 HotSpot 虚拟机中,Eden 区 和 另外两个 Survivor 区在默认配置下,空间的占比是 8:1:1。我们可以通过调整 -XX:SurvivorRatio 参数来调整这个空间占比。例如 -XX:SurvivorRatio = 8。
几乎所有的 Java 对象都是在 Eden 区被 New 出来的。绝大多数的 Java 对象的销毁都是在年轻代中进行的(有些大对象无法在 Eden 区存储的时候,将直接进入老年代)。

对象分配过程

为新对象分配内存是一项非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法和垃圾回收算法密切相关,所以还需要考虑 GC 执行完后是否会在内存空间中产生内存碎片。
  • 新new的对象先放在 Eden(伊甸园)区。
  • 当 Eden(伊甸园)区的空间被填满时,程序又需要创建对象时,JVM 的垃圾回收器将对伊甸园区进行垃圾回收 (Minor GC),将伊甸园区中不再被其它对象所引用的对象进行销毁。

  • 然后将伊甸园区中剩余的对象移动到 Survivor (幸存者)0 区。
  • 如果再次触发垃圾回收,此时如果上次幸存下来的放置到幸存者 0区的对象,如果没有被垃圾回收掉,将会放入 幸存者 1 区中。

  • 如果再次经历垃圾回收,此时会重新回到幸存者0区,接着再去幸存者1区。
  • 那么什么时候进入老年代呢?默认情况下是经历 15 次的循环往复。
  • 在老年代中,相对比较悠闲。当老年代内存不足时,触发垃圾回收(Major GC),进行老年代的内存清理。

  • 若在老年代执行 Major GC 后,发现依然无法进行对象的保存,就会产生 OOM 的异常。

图解过程

我们新创建的对象一般都放在 Eden(伊甸园)区,当 伊甸园 区满了之后,就会触发 MinorGC 操作。
当我们进行一次垃圾回收后,垃圾对象(黄色)被回收,存活对象(红色)被放置在 S0(Survivor From) 区。同时我们给每个对象设置了一个年龄计数器,一次回收后就是1。
JVM 中存放对象的地方
同时 伊甸园 区继续存放对象,当 伊甸园区 再次存满对象时,又会触发一次 MinorGC操作,此时GC操作会把 Eden 区 和 S0(Survivor From )区中的对象进行一次垃圾回收,然后把存活的对象放到 S1(Survivor To)区,同时让年龄+1。
我们不断的进行对象的生成和垃圾的回收,当 Survivor 区中的对象的年龄到达 15 时,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中。
tips:只有当 Eden(伊甸园)区满了之后,才会触发 MinorGC 操作,而 Survivor 区满了,是不会触发 MinorGC 操作的,当 Survivor 区满了之后,会触发特殊的规则,即 Survivor 区中的对象可能会直接晋升到老年代。

总结

  • 对于幸存者 S0、S1 来说,哪个区为空,哪个就是 to。

  • 对于垃圾回收来说,频繁的在年轻代收集,很少在老年代收集,几乎不在永久代和元空间进行垃圾收集。

MinorGC、MajorGC、FullGC

Minor GC 指在年轻代进行垃圾收集,Major GC 是指在老年代进行收集,Full GC 是指在整个 Java 堆和方法区进行垃圾收集。
我们都知道,垃圾回收是 JVM 调优的一个重要环节,我们需要尽量的避免垃圾回收,因为在垃圾回收的过程中,容易出现 STW 的问题。而 MajorGC 和 FullGC 出现 STW 的时间,是 MinorGC 的 10 倍以上。
JVM 在进行 GC 时,并非每次都对上面三个内存区域一起回收的,大部分都是对新生代进行垃圾回收。针对 HotSopt 虚拟机的实现,它里面的 GC 按照回收区域又分为两大种类型:一种是部分收集(Partial GC),一种是整堆收集(Full GC)。

部分收集:不是收集整个 Java 堆的垃圾收集。其中又分为:

  • 年轻代收集,MinorGC,只是年轻代的垃圾收集。
  • 老年代收集,MajorGC,只是老年代的垃圾收集。目前,只有 CMS GC 垃圾收集器会有单独收集老年代的行为。这里需要注意一点,在很多时候 MajorGC 会和 FullGC 混淆使用,需要分辨是老年代回收还是整堆回收。

  • 混合收集(MixedGC),收集整个年轻代和老年代的垃圾收集,目前只有 G1 垃圾收集器有这种行为。

整堆收集:收集整个 Java 堆和方法区的垃圾收集。

MinorGC

当年轻代空间不足时,就会触发 MinorGC(这里的年轻代空间满,是指伊甸园区满,Survivor区满不会触发GC)。
因为Java对象大多都具备“朝生夕灭”的特性,所以 MinorGC 会很频繁,一般回收速度也比较快。
MinorGC 会引发 STW,暂停其它用户线程,等垃圾回收结束,用户线程才恢复运行。

Major GC

MajorGC 是指发生了老年代的 GC,对象从老年代消失时,我们就说 “MajorGC” 或 “FullGC” 发生了。一般来说,出现了 MajorGC 时,经常会伴随至少一次 MinorGC(并非绝对,在 Parallel Scavenge 垃圾收集器的收集策略里就有直接进行 MajorGC 策略选择过程),也就是说在老年代空间不足时,会先尝试触发MinorGC。如果之后空间还不足,则触发 MajorGC,如果在 MajorGC 之后,空间还不足,则会触发OOM。

Full GC

触发 FullGC 执行的情况有如下五种:

  • 调用 System.gc()时,系统建议执行 FullGC,但是不必然执行。
  • 老年代空间不足时。
  • 方法区空间不足时。
  • 通过 MinorGC 后进入老年代的平均大小大于老年代的可用内存时。
  • 由 Eden 区、Survivor 区(From区)向 Survivor 区(To 区)复制时,对象大小大于 To 区可用内存时,则把该对象转存到老年代,此时如果老年代的可用内存小于该对象大小时。

Full GC 是开发或调优中尽量要避免的。

堆空间的分代思想

现在我们来思考一个问题,为什么要把 Java 堆进行分代处理呢?不分代就不能正常工作了吗? 经研究,不同对象的生命周期是不同的,70%~99%的对象都是临时对象。
新生代:有 Eden、两块大小相同的 survivor (又称为 from/to ,s0/s1)构成,其中 to 总为空。

老年代:存放新生代中经历多次 GC 仍存活的对象。

其实不分代也可以,分代的唯一理由就是优化 GC 的性能。如果没有分代,那么所有对象都在一块,就如同把一个学校的所有学生都放在一个教室里。GC的时候要找到哪些对象没用,就需要对堆的所有区域进行扫描。然而很多对象都是 “朝生夕死”的,如果分代的话,把新创建的对象放到某一个地方,当GC的时候先把这块存储 “朝生夕死” 对象的区域进行回收,这样就会腾出很大的空间出来。

内存分配策略

如果对象在 Eden 出生并经历过第一次 MinorGC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1。对象在 Survivor 区中每熬过一次 MinorGC,年龄都会增加1。当它的年龄增加到一定程度(默认15岁,其实每个JVM、每个GC都有所不同)时,就会被晋升到老年代。对象晋升到老年代的年龄阀值,我们可以通过选项 -xx:MaxTenuringThreshold 来设置。

针对不同年龄段的对象分配原则如下所示:

  • 优先分配到 Eden

    开发中比较长的字符串或者数组,会直接存在老年代。但是因为新创建的对象都是“朝生夕死”的,所以这个大对象可能也很快被回收。但是因为老年代触发 Major GC 的次数比 Minior GC 要更少,因此回收起来就会比较慢。
  • 大对象直接分配到老年代

    尽量要避免程序中出现过多的大对象。

  • 长期存活的对象分配到老年代

  • 动态对象年龄判断

    如果 Survivor 区中相同年龄的所有对象的大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象可以直接进入老年代,无需等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保 -xx:HandlePromotionFailure

也就是经过 MinitorGC 后,所有的对象都存活,因为 Survivor 空间比较小,所以就需要将 Survivor 无法容纳的对象,存放到老年代中。

为对象分配内存 TLAB

问题:堆空间都是共享的吗?

不一定。因为还有 TLAB 这个概念,在堆中划分出一块区域,为每个线程所独占。

为什么有TLAB

TLAB:Thread Local Allocation Buffer,也就是为每个线程单独分配了一个缓冲区。

堆区是线程共享的区域,任何线程都可以访问到堆区中的共享数据。

由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的,为避免多个线程操作同一地址,需要使用加锁机制,进而影响分配速度。

什么是TLAB

从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。
多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。
尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但是 JVM 确实是将 TLAB 作为内存分配的首选。在程序中,开发人员可以通过选项 -xx:UseTLAB 来设置是否开启 TLAB 空间。默认情况下,TLAB空间的内存非常小,仅占整个 Eden 空间的 1%。我们可以通过选项 -xx:TLABWasteTargetPerent 来设置 TLAB 空间所占用 Eden 空间百分比大小。

一旦对象在 TLAB 空间分配内存失败,JVM 会尝试着通过加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存(对象首先是通过 TLAB 开辟空间,如果不能放入,那么需要通过 Eden 来进行分配)。

总结

年轻代是对象的诞生、成长和消亡的区域,一个对象在这里产生、应用,最后被垃圾回收器收集、清理。

老年代放置长生命周期的对象,通常都是从 Survivor 区筛选拷贝过来的 java 对象。当然,也会有特殊的情况,我们知道普通的对象会被分配到 TLAB 上;如果对象较大,JVM 会试图直接分配在 Eden 区其它位置上;如果对象太大,完全无法在新生代找到足够大的连续空闲空间,JVM会直接分配到老年代。

当 GC 只发生在年轻代时,回收年轻代对象的行为被称为 MinorGC。当GC发生在老年代时,则被称为 MajorGC 或者 FullGC。

一般来说,MinorGC 的发生频率要比 MajorGC 高很多,即老年代中垃圾回收发生的频率大大低于年轻代。

最后

到此为止,我们就把 JVM 的堆区 聊完了,如果觉得不错,转发、在看、点赞安排起来吧。

你知道的越多,你的思维越开阔。我们下期再见。

祝大家五一快乐。