【JVM学习笔记06】堆

【JVM学习笔记06】堆,第1张

【JVM学习笔记06】堆 七、堆

Java堆是Java虚拟机所管理的内存中最大的一块,其唯一的目的是存放对象实例。java堆是被所有线程所共享的一块内存区域(TLAB区除外),在虚拟机启动时创建,几乎所有对象的实例都存储在堆中,所有的对象和数组都要在堆上分配内存。

  • 堆和方法区针对一个 JVM 进程来说是唯一的,也就是一个进程只有一个 JVM ,但是进程包含多个线程,他们是共享同一堆和方法区空间的,每个线程各自包含一套程序计数器、本地方法栈和虚拟机栈。

  • 一个 JVM 实例只存在一个堆内存,堆也是 Java 内存管理的核心区域,是 JVM 管理的最大一块内存空间。Java 堆在 JVM 启动的时候即被创建,其空间大小也就确定了。Java堆可以被实现成固定大小的,也可以是可扩展的。

  • 《Java虚拟机规范》中对 Java 堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。更合理的应该是“几乎”所有的对象实例都在这里分配内存,因为还有一些对象是在栈上分配的【逃逸】。

  • java堆是垃圾收集器(GC)管理的主要区域。在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集(GC)的时候才会被移除。

7.1 堆结构划分

《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。但是从不同的角度,可以对堆进行不同的划分。无论从什么角度,无论如何划分,都不会改变 Java 堆中存储内容的共性,无论是哪个区域,存储的都只能是对象的实例,将Java堆细分的目的只是为了更好地回收内存,或者更快地分配内存。

7.1.1 回收内存的角度

根据不同类型的垃圾收集器,堆可以被划分为不同的分区。

  • 基于分代收集理论划分:新生代、老年代分区
  • 基于非分代收集理论划分:Region分区【G1垃圾收集器】
(1)基于分代收集理论划分

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

  • 生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速,生命周期短的,及时回收即可
  • 生命周期非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致的对象

由于现在垃圾收集器大部分都是基于分代收集理论设计的,所以就会将堆内存逻辑上分为三部分:

  • 新生代:在新生代中,每次垃圾收集时都会发现有大量对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年区中存放;
  • 老年代
  • 永久区/元空间(JDK8及以后)

元空间与永久代之间最大的区别在于:

==永久代使用的JVM的堆内存,但是java8以后的元空间并不在虚拟机中而是使用本机物理内存。==因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由MaxPermSize 控制, 而由系统的实际可用空间来控制。

IBM研究表明,新生代中的对象有98%熬不过第一轮收集【朝生夕灭】,因此不需要按照1:1的比例来划分新生代的内存空间。新生代与老年代的默认内存分配(1:2)如下:

from/to区:这两个分区的位置是相对的,是不固定的。新生代中默认指定幸存者区中的空区域为to区。


为什么要把 Java 堆分代?不分代就不能正常工作了吗?经研究,不同对象的生命周期不同。70%-99%的对象是临时对象。

  • 新生代:有 Eden 、两块大小相同的 Survivor(又称为 From/To,S0/S1)构成,To 总为空。
  • 老年代:存放新生代中经历多次 GC 仍然存活的对象。

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

(2)基于非分代收集理论划分

上述的新生代、老年代的划分方式,仅仅只是一部分垃圾收集器的共同特性或设计风格,而非某个 Java 虚拟机具体实现的固有内存布局,更不是《Java 虚拟机规范》中对 Java 堆的进一步细致划分。

在具有划时代意义的 G1 收集器诞生之后,出现了不采用分代设计新垃圾收集器,采用了基于 Region 的垃圾收集,虽然其内部还是保留了“新生代”,“老年代”等角色的划分,但是实现机制完全不同了。

7.1.2 内存分配的角度

从分配内存的角度看,所有线程共享的 Java 堆中可以划分出一部分区域供线程自己私有——TLAB分配缓冲区,以提升对象分配时的效率。TLAB外加上述分代理论中的新生代-老年代,共同构成堆的结构划分。

首先需要明确,堆是为所有线程所共享的,但是共享的并不是堆中的所有空间,而只是一部分空间。其中,堆内的TLAB区是为线程所私有的。

(1)TLAB简介

JVM在内存新生代Eden Space中开辟了一小块线程私有的区域,称作TLAB(Thread-local allocation buffer)。默认设定为占用Eden Space的1%。**在Java程序中很多new的对象都是小对象且用过即丢,它们不存在线程共享也适合被快速GC,所以对于小对象通常JVM会优先分配在TLAB上,并且TLAB上的分配由于是线程私有所以没有锁开销。**因此在实践中分配多个小对象的效率通常比分配一个大对象的效率要高。

从内存模型而不是垃圾收集的角度,对 Eden 区域继续进行划分, JVM 为每个线程分配了一个私有缓存区域,它包含在 Eden 空间内。多线程同时分配内存时,使用 TLAB 可以避免一系列的线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为快速分配策略。

  • 尽管不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选
  • 在程序中,开发人员可以通过选项“-XX:UseTLAB”设置是否开启 TLAB 空间
  • 默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的1%,当然我们可以通过选项“-XX:TLABWasteTargetPercent”设置 TLAB 空间所占用 Eden 空间的百分比大小
  • 一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据 *** 作的原子性,从而直接在 Eden 空间中分配内存
(2)基于TLAB的对象分配

在开启了TLAB的堆中,对象的创建会优先选择在伊甸园区中的TLAB区进行分配。这种情况只适用于小对象的分配,若是大对象的创建,还是需要按照之前的一般过程进行内存分配。

7.2 堆参数设置 7.2.1 堆内存参数设置

Java 堆在 JVM 启动的时候即被创建,其空间大小也就确定了。Java堆可以被实现成固定大小的,也可以是可扩展的。 可以通过选项"-Xmx"和"-Xms"来进行设置:

一旦堆区中的内存大小超过 “-Xmx” 所指定的最大内存时,将会抛出 OutOfMemoryError 异常。

Xmx最大内存参数的相关解释:Xmx指定内存并不是真正的分配,而是一种保留。Xmx的内存是在Java进程启动的时候直接分配(预留)的,而不是不断增加的。因为大部分 GC 算法依赖于被分配为连续的内存块的堆,因此不能在堆需要扩大时再分配更多本机内存,所有堆内存必须预先保留最大物理内存。

(1)默认设置

默认情况下:

  • 初始内存大小:物理电脑内存大小/64
  • 最大内存大小:物理电脑内存大小/4
public static void main(String[] args) {
    long maxMemory = Runtime.getRuntime().maxMemory();      // 虚拟机试图使用的最大内存量
    long totalMemory = Runtime.getRuntime().totalMemory();  // 虚拟机的内存总量

    System.out.println("-Xmx:MAX_MEMORY = " + maxMemory + "(字节)、" + (maxMemory / (double)1024 / 1024) + "MB");
    System.out.println("-Xms:TOTAL_MEMORY = " + totalMemory + "(字节)、" + (totalMemory / (double)1024 / 1024) + "MB");
}

(2)通常设置

通常会将 -Xms 和 -Xmx 两个参数配置相同的值。

常规的JVM参数使用,如:java -Xms512m -Xmx1g。在这种配置下,JVM启动时会分配512M的堆内存空间,随着程序的执行,所需的堆空间越来越大,则会逐渐增大堆内存空间,直到Xmx参数指定的堆最大空间1G。当堆内存使用率降低,则会逐渐减小该内存区域的大小。整个过程看似非常合理,但为什么很多生产环境却也将两个值配置为相同的值呢?

  • 频繁的GC会造成性能降低

    当堆内存使用情况变化时,并不是单纯的扩大和缩小堆内存,在此之前还会执行GC *** 作。如果-Xms起初值设置的比较小,那么就频繁触发GC *** 作。当GC *** 作无法释放更多内存时,才会进行内存的扩充。GC *** 作是需要耗时的,而且Full GC会引起“Stop the World”,也就是会引起线程停止,不可避免地就会引起性能问题。

  • 设置成相同值的优点

    为了避免在生产环境由于heap内存扩大或缩小导致应用停顿,降低延迟,同时避免每次垃圾回收完成后JVM重新分配内存。所以,-Xmx和-Xms一般都是设置相等的。【避免内存抖动】

7.2.2 新生代与老年代参数设置 (1)堆结构占比设置

配置新生代与老年代在堆结构的占比:

  • 默认-XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
  • 可以修改-XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

一般情况下,上述参数不需进行调节。但是当发现在整个项目中,生命周期长的对象偏多,那么就可以通过调整老年代的大小,来进行调优。

(2)新生代参数设置

一般采用-XX:SurvivorRatio来调整新生代空间的比例,默认-XX:SurvivorRatio=8,意为Eden:From:To -> 8:1:1

7.3 堆总结 7.3.1 堆参数总结
  • -XX:+PrintFlagsInitial:查看所有的参数的默认初始值

  • -XX:+PrintFlagsFinal:查看所有的参数的最终值(可能会存在修改,不再是初始值)

  • -Xms:初始堆空间内存(默认为物理内存的1/64)

  • -Xmx:最大堆空间内存(默认为物理内存的1/4)

  • -Xmn:设置新生代的大小。(初始值及最大值)

  • -XX:NewRatio:配置新生代与老年代在堆结构的占比,默认为2

  • -XX:SurvivorRatio:设置新生代中Eden和S0/S1空间的比例,默认为8

  • -XX:MaxTenuringThreshold:设置新生代垃圾的最大年龄,默认为15

  • -XX:+PrintGCDetails:输出详细的GC处理日志

  • 打印gc简要信息:①-Xx:+PrintGC ② - verbose:gc

  • -XX:HandlePromotionFalilure:是否设置空间分配担保

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

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

在 JDK 6 Update24 之后,HandlePromotionFailure 参数不会再影响到虚拟机的空间分配担保策略,观察 OpenJDK 中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。 JDK6 Update24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC ,否则将进行 Full GC 。

7.3.2 堆相关总结 (1)堆空间不都是共享的

堆是为所有线程所共享的,但是共享的并不是堆中的所有空间,而只是一部分空间。其中,堆内的TLAB区是为线程所私有的。

(2)堆不是分配对象的唯一选择

在《深入理解Java虚拟机》中关于 Java 堆内存有这样一段描述:随着 JIT 编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在 Java 虚拟机中,对象是在 Java 堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收GC了,这样也就极大地减少了STW时间,提高了性能。这也是最常见的堆外存储技术。

逃逸分析:是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

  • 如何判定堆上的对象是否需要分配到栈,需要使用逃逸分析手段。
  • 逃逸分析的基本行为就是分析对象动态作用域:
    • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
    • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

如何快速的判断是否发生了逃逸分析,就看 new 的对象是否在方法外被调用。

开发中能使用局部变量的,就不要使用在方法外定义。

(3)堆内存分配

《Java虚拟机规范》规定,**堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。**即不要求所有的对象都是连续存放的,但是对于大对象来说,多数虚拟机出于实现简单、存储高效的考虑,很可能会要求连续的内存空间。

(4)栈-堆-方法区关系

在使用Person p1 = new Person();Person p2 = new Person();Person p3 = new Person();创建三个引用类型的对象时,JVM内存区域中的栈-堆-方法区的关系如下:

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/5564045.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-12-14
下一篇 2022-12-14

发表评论

登录后才能评论

评论列表(0条)

保存