四、JVM 堆

【目录】 【上一篇:本地方法接口、栈】 【下一篇:方法区】

四、堆

1、堆的核心概述


  • 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域,在该 JVM 进程中,它是线程共享的;
  • Java 堆区在 JVM 启动的时候被创建,其空间大小也就确定了。是 JVM 管理的最大的一块内存空间;
    • 堆内存大小是可以调节的。
  • 《Java 虚拟机规范》规定:堆可以处于物理上不连续的内存空间中,但在逻辑上,它应该被视为连续的;
  • 所有的线程都共享 Java 堆,在这里还可以划分线程私有的 缓冲区域(Thread Local Allocation Buffer, TLAB);
  • Java 中“几乎”所有的对象实例都在堆中分配(《Java虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上),但随着 Java 语言的发展,逃逸技术的日渐强大,这个描述也不再那么绝对;
  • 在方法结束之后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除;并且堆是 GC 执行垃圾回收的重点区域。

在这里插入图片描述

1.1、内存细分

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

💡 Java 7 及之前堆内存在逻辑上分为三部分:新生区 + 老年区 + 永久区
> 新生区(Yong Generation Sapce)又被划分为 Eden 区 和 Survivor 区
> 养老区(Old Generation Sapce)
> 永久区(Permanent Sapce)

在这里插入图片描述

💡 Java 8 及之后堆内存在逻辑上分为三部分:新生区 + 老年区 + 元空间
> 新生区(Yong Generation Sapce)又被划分为 Eden 区和 两个 Survivor 区(s0、s1)
> 养老区(Old Generation Sapce)
> 元空间(Meta Space)

在这里插入图片描述

1.2、设置堆内存大小与OOM


1、堆空间大小的设置:

  • Java 堆区用于存储 Java 对象实例,那么堆的大小在 JVM 启动时就已经设定好了(有默认设置),也可以手动使用 -Xms 、-Xmx 命令来进行设置:

    -> -Xms 用于表示堆区(年轻代+老年代)的起始内存,等价于: -XX:InitialHeapSize;

    -> -Xmx 用于表示堆区(年轻代+老年代)的最大内存,等价于:-XX:MaxHeapSize 。

  • 一旦堆区中的内存大小超过 -Xmx 所指定的最大内存时,程序将会抛出 OutOfMemoryError 异常;

  • 通常情况 在服务器部署服务的时候,会将 -Xms 和 -Xmx 配置成相同的值,其目的是为了能够在 Java 垃圾回收机制清理完堆区后不需要重新分配计算堆的大小,从而提高性能;

  • 默认情况下,初始内存大小为:物理机内存 / 64 ;最大内存大小为:物理机内存 / 4

  • 查看设置的参数:

    方式一:①命令窗口输入 jps 查询程序的进程id;②输入命令查询 jstat -gc 进程id

    方式二:在程序启动时,加上命令 -XX:+PrintGCDetails

1.3、年轻代与老年代


在这里插入图片描述

1.3.1、存储在 JVM 中的 Java 对象可以划分为两类:

  • 一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常的迅速(朝生夕死):年轻代
    • 年轻代又可以划分为:Eden 空间、Survivor0 空间、Survivor1 空间(有时也称之为 from 区、to 区)
  • 另一类对象的生命周期非常长,甚至能与 JVM 的生命周期保持一致:老年代

1.3.2、年轻代与老年代在堆结构的占比:

  • 默认情况下,年轻代与老年代的占比为 1:2 ;即年轻代占整个堆的 1/3 、老年代占整个堆区的 2/3 。

  • 配置年轻代与老年代的空间占比:

    -XX:newRatio=4,表示占比为 1:4 ;年轻代占 1 ,老年代占 4 。

  • 年轻代中,Eden 空间与另两个 Survivor 空间的比例为 8:1:1 ;(因为有自适应机制存在,这个比例实际上为 6:1:1 ,但在官方文档、命令查询时,其结果都是 8:1:1 ;在官网中,提供了 -XX:-UseAdaptiveSizePolicy 命令来关闭自适应机制,但这个命令不太好使)

    💡 -XX:SurvivorRatio=8 命令来重新设置这个占比,表示 Eden 空间占比为 8;
    在命令窗口中,使用命令:
    jinfo -flag 【newRatio、SurvivorRatio】 进程id
    来查询对应的占比

  • 几乎所有的 Java 对象都是在 Eden 区被 new 出来的(当对象太大、Eden 区太小装不下时,这时候就会在老年代去 new 对象)

2、对象分配过程


1、当发生 new 对象时,这个对象先放到 Eden 区;

2、当 Eden 区快填满时,又创建了新的对象,这时会触发垃圾回收机制(YGC、Minor GC),根据垃圾回收算法,将不需要再被其他对象引用的对象进行销毁,对还被其他对象引用的对象加载到 幸存者0区(Survivor0),并给这些对象设置一个年龄标识 1 ;

3、继续创建对象,当 Eden 区再次填满时,又会触发垃圾回收机制,根据垃圾回收算法,将不再被其他对象引用的对象进行销毁,对还被其他对象引用的对象加载到 幸存者1区(Survivor1),并给这些对象设置一个年龄标识 1;同时,垃圾回收机制也会对 幸存者0 区进行垃圾回收,对不需要的对象进行销毁,有用的对象采用复制算法复制到幸存者1区,并给这些对象的年龄标识 +1,然后清空幸存者0区;

4、继续创建对象,反复(3)过程。当幸存者区中,对象的年龄标识达到15这个阈值时,如果对象还被使用,那么这些对象就会被复制到老年代;

可以设置参数: -XX:MaxTenuringThreshold=15 来设置这个阈值,默认为15

5、当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理;

6、若养老区执行了Major GC之后,发现依然无法进行对象的保存,就会产生OOM异常。

💡 注意:
①、幸存者区有垃圾回收机制,但它不会触发垃圾回收。也就是说就算幸存者区空间满了,它也不会主动触发垃圾回收机制,只有在 Eden 区触发垃圾回收机制时,幸存者区才会被动的执行垃圾回收机制。
②、大部分对象是在 Eden 区创建的,但一些特殊对象会直接在 Old 区创建。
③、Eden 区触发垃圾回收机制时,只会同步去回收幸存者区,不会回收 Old 区,Old 区有其自己的触发机制(FGC)。
④、关于垃圾回收:频繁在新生区收集、很少在老年区收集、几乎不在永久区/元空间收集

💡 Minor GC(YGC)、Major GC、Full GC 详解

在这里插入图片描述

3、堆空间的分代思想


内存分代的主要目的是为了优化 GC 性能;

对于堆空间,不分代分区也是可用正常使用的,但这样的话,每次 GC 都会扫描整个堆空间里的对象进行判断,然而很多对象都是朝生夕死的,对象的创建又非常的快,这样频繁的全堆 GC 势必会影响程序的执行效率。如果将朝生夕死的对象统一放到新生代,对生命周期长的对象放到老年代,在执行 GC 时候,只需要重点关注新生代,少量关注老年代即可。

4、内存分配策略


一般情况:

如果对象在 Eden 出生并经过第一次 Minor GC 后任然存活,并且能被 Survivor 容纳的话, 将被移动到 Survivor 空间中,并将对象年龄设置为 1。对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定阈值(默认 15),就会被晋升到老年代中。

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

对象提升规则:

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

  • 优先分配到 Eden
  • 大对象直接分配到老年代(尽量避免程序中出现过多的大对象,特别是朝生夕死的大对象)
  • 长期存活的对象分配到老年代

2、动态对象年龄判断:

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

3、空间分配担保

  • -XX:HandlePromotionFaolure

💡 在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次 Minor GC 是安全的
  • 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置是否允许担保失败。
    • 如果 HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。
      • 如果大于,则尝试进行一次 MInor GC,但这次的 Minor GC 依然是有风险的。
      • 如果小于,则改为进行一次 Full GC。
    • 如果 HandlePromotionFailure=false,则改为进行一次 Full GC。

在 JDK6 Update24 之后,HandlePromotionFailure 参数将不再影响虚拟机的空间分配担保策略(也就是说不管设不设置,都默认为 true)。之后的规则改为:只要老年代的连续空间大于新生代对象的总大小或则老年代的连续空间大于历次晋升的平均值,就进行 Minor GC,否则进行 Full GC

5、为对象分配内存:TLAB


5.1、为什么有 TLAB(Thread Local Allocation Buffer)?

  • 堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据;
  • 由于对象实例创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的;为避免多个线程操作同一地址,需要使用加锁等机制,这会影响分配速度。而 TLAB 是 JVM 为每个线程分配的一个私有缓存区域,直接在这里分配内存,可以避免加锁操作,从而提高分配速度。

5.2、什么是 TLAB ?

  • 从内存模型而不是垃圾收集的角度来看,对 Eden 区域继续进行划分,JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。

  • 多线程在同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

  • 尽管不是所有的对象实例都能够在 TlAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。

  • 在程序中,可以通过选项 -XX:+UseTLAB 设置是否开启 TLAB 空间。

    💡 命令: jinfo -flag UseTLAB 进程id 来查询是否开启 TLAB 空间

  • 默认情况下,TLAB 的空间内存非常小,仅占有整个 Eden 空间的 1%,也可以通过 -XX:TLABWasteTargetPercent 设置TLAB所占用 Eden 空间的百分比。

  • 一旦对象在 TlAB 空间分配内存失败时,JVM 就会尝试通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存。
    在这里插入图片描述

6、堆空间的参数设置


  • -XX:+PrintFlagsInitial:查询所有参数的默认初始值
  • -XX:+PrintFlagsFinal:查询所有参数的最终值
  • -Xms:设置堆空间的初始内存(默认为物理机的 1/64)
  • -Xmx:设置堆空间的最大内存(默认为物理机的1/4)
  • -Xmn:设置新生代的堆内存大小
  • -XX:NewRtatio:设置新生代与老年代在堆结构的占比(默认是 1:2)
  • -XX:SurvivorRatio:设置新生代中 Eden 区与 Survivor 区的占比(官网默认是 8:1:1,实际上有自适应机制的存在,可能是 6:1:1)
  • -XX:MaxTenuringThreshold:设置新生代中对象的最大年龄(默认是 15)
  • -XX:+PrintGCDetails:输出详细的 GC 处理日志
  • -XX:UseTLAB:设置是否开启 TLAB 空间(默认是开启)
  • -XX:TLABWasteTargetPercent:设置TLAB所占用 Eden 空间的百分比(默认是 1%)
  • -XX:HandlePromotionFailure:是否设置空间分配担保(默认为 true,JDK6 Update24 版本之后,失效)

7、拓展:堆是分配对象的唯一选择吗?


在《深入理解Java虚拟机》中,关于 Java 堆内存有这样一段描述:

随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在 Java 虚拟机中,对象是在 Java 堆中分配内存的。但有一种特殊情况,如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就有可能被优化成栈上分配内存。这样就无需在堆上分配内存,无需进行垃圾回收,直接随着栈帧的结束而释放内存。这也是最常见的堆外存储技术。

7.1、逃逸分析概述:

  • 如何将堆上的对象分配到栈,需要使用逃逸分析手段;这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法;
  • 逃逸分析的基本行为就是分析对象动态作用域,从而决定这个对象是分否在堆中分配内存:
    • 当一个对象在方法中被定义后,对象实付只在方法内部使用,则认为没有发生逃逸,这个对象实体的内存就有可能在栈帧中分配。
    • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生了逃逸,这个对象实体的内存就在堆中分配。例如作为参数传递给其它方法。
  • 在 JDK 6u23 版本之后,HotSpot 中默认就已经开启了逃逸分析;

7.2、逃逸分析:代码优化

使用逃逸分析,编译器可以对代码做如下优化:

  • 栈上分配:将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会发生逃逸,对象可能是栈上分配的候选,而不是堆上分配;
    • JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。
  • 同步省略:如果一个对象被发现只有一个线程被访问到,那么对于这个对象的操作可以不考虑同步;
    • 线程同步的代价是相当高的,同步的后果是降低并发性和性能;
    • 在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。这个取消同步的过程就叫同步省略,也叫锁消除
  • 分离对象或标量替换:有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
    • 在 JIT 阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT 优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

什么是标量?
标量(scalar)是指一个无法再分解成更小的数据的数据。Java中的原始数据类型就是标量。
相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

7.3、逃逸分析结论

无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

由于逃逸分析技术还不成熟,所以目前的 JVM 中,实例对象还是在堆中分配的。

【目录】 【上一篇:本地方法接口、栈】 【下一篇:方法区】

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值