Loading
Loading...

深入浅出 JVM 之运行时数据区-堆

系统环境:

  • JDK 1.8

参考地址:

深入浅出 JVM 系列文章

博文中大部分概念都是基于 HotSpot 虚拟机为基础的相关概念。

一、堆概述

在 Java 虚拟机 (JVM) 中,堆(Heap) 扮演着至关重要的角色。它是在 JVM 启动时创建的,其初始大小与最大容量可通过 JVM 参数进行配置。堆作为 JVM 运行时数据区中最为庞大的共享内存区域,尽管在逻辑上表现为连续的内存空间,但在物理层面却未必连续,这种设计允许了内存的灵活分配与管理。

堆的主要职责在于存储 Java 中的对象实例,几乎每一个由 Java 应用程序创建的对象实例都会驻留在堆内存中。这一特性决定了堆是垃圾回收器 (Garbage Collection, GC) 重点关注的区域。为了提高垃圾回收的效率,现代的垃圾收集器普遍采用了基于分代收集理论的设计理念,将堆空间细分为几个子区域,分别为 新生代 和 老年代。

不过值得注意的是,这些区域划分并不是通用规则,而仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,并非某个 JVM 中具体实现的固有内存布局,更不是《Java虚拟机规范》里对堆的进一步细致划分。所以,在理解和应用这些概念时,应当结合具体的 JVM 实现和版本进行考量。

二、堆的组成

在探讨 Java 虚拟机堆空间的组成时,我们的焦点往往集中在 HotSpot 虚拟机上,尤其是针对 JDK 8 版本的分析。在这个版本中,堆空间被精心划分为多个区域,以适应不同生命周期对象的管理和优化内存使用效率。以下是 JDK 8 中堆空间的主要组成部分:

  • 新生代 (Young Generation): 新生代作为对象生命周期的起点,主要用于存放新创建的对象。由于这个区域的对象生命周期较短,因此,新生代是频繁发生垃圾回收活动的地方,这种回收过程通常被称为 Minor GC 或 Young GC。
  • 老年代 (Old Generation): 随着对象在新生代中的年龄增长,即经历了一定次数的 Minor GC 后仍能幸存的对象,将被晋升至老年代。老年代区域中存储的是生命周期较长的对象,因此,这里的垃圾回收频率远低于新生代,回收过程被称为 Full GC。
  • 类变量 (Class Variables): 类变量也称为静态变量,这类变量通过 static 关键字修饰,用于存储类级别的状态信息,不属于任何特定对象实例。
  • 线程本地分配缓冲区 (Thread Local Allocation Buffer, TLAB): TLAB 是每个线程独有的内存分配缓冲区,旨在减少线程间在对象分配过程中的竞争,从而提高多线程环境下对象分配的效率。
  • 运行时常量池 (Runtime Constant Pool): 运行时常量池主要负责存储加载的字节码文件中的常量池信息。比如,常量池中的 字面量 和 符号引用 等。并且,在类加载阶段,符号引用 会被解析为 直接引用,这些引用也同样会存储到运行时常量池中,以便程序运行时访问。

2.1 新生代

在 Java 应用程序的生命周期中,大部分对象只能存活短暂的时间,为了高效管理这些对象,避免无谓的内存占用,JVM 中的垃圾回收机制采用了分代收集策略,将对象按照生命周期进行分类,分别置于不同的内存区域中,从而实施针对性的回收策略。

这一策略的核心思想是: 大多数对象都是朝生夕死,而只有少部分对象能够长期存活。因此,在 JVM 中设计了新生代与老年代俩个区域,前者专用于存储新创建的对象,后者则负责保存那些已经证明具有较长生命周期的对象。

而在 HotSpot 虚拟机中,新生代进一步细分为三个区域,分别为 Eden区 和两个 Survivor区,这俩区的作用如下:

区域 描述
Eden Eden 区用于存储新创建的对象,当该区域的内存空间不足时,就会执行 Minor GC 清理空间,清理完成后会将仍然存活的对象移动到 Survivor 区中。
Survivor Survivor 区分为 S0 和 S1 两个部分,每次 Minor GC 后,Eden 区中幸存的对象以及之前的 Survivor 区中的对象,会迁移到当前未使用的 Survivor 区。下一次 Minor GC 时,这个过程会重复,对象会在两个 Survivor 区之间迁移,直到对象的 "年龄" 达到设定阈值,或者 Survivor 区不足以容纳,此时对象将被晋升到老年代。

而且,新生代中 Eden 区与两个 Survivor 区的默认比例为 8:1:1,也就是说新生代内存的 8/10,而两个 Survivor 区各占 1/10。这个值可以通过修改 JVM 参数 -XX:SurvivorRatio 进行调整,以适应不同应用场景的需求。并且,由于绝多大数刚创建的对象都会存入新生代的 Eden 区中,所以 Eden 区默认情况下分配的内存比例比 Survivor 区大,如下图所示:

这一机制有效地利用了对象的 “Young” 特性,通过快速回收短生命周期对象,减少了对老年代不必要的扫描和处理,从而提升了整个系统的性能和响应速度。

2.2 老年代

新生代作为 Java 对象生命周期的起始点,承载着大量瞬时对象的存储任务。然而,对于那些即便历经多次 Minor GC 考验依然“幸存”的对象,JVM 采取了一种巧妙的策略——将这些“长寿”对象迁移到老年代进行存储。这一举措不仅有效地减少了新生代中活跃对象的数量,降低了新生代的垃圾回收频率和开销,同时也避免了对这些长期存活对象的重复扫描,显著提升了垃圾回收的整体效率。

而在 HotSpot 虚拟机中,新生代与老年代的默认内存比例设定为 1:2,意味着总堆内存中,新生代占据 1/3,而老年代则占据剩余的 2/3。这一比例的设定基于经验观察,即大多数对象生命周期短暂,适合在较小的新生代中快速回收,而长期存活的对象则自然过渡至较大的老年代,享受更宽松的内存环境和更稀疏的垃圾回收周期。

然而,这一默认比例并非一成不变。开发人员可以根据应用程序的具体需求和行为特征,通过 JVM 参数 -XX:NewRatio 动态调整新生代与老年代之间的内存分配。例如,若应用程序中存在大量长寿命对象,增加老年代的相对大小可能更为适宜;反之,如果大部分对象生命周期极短,则可以适当增大新生代的份额,以优化短期对象的回收效率。

通过这一机制,Java 虚拟机为开发者提供了高度的灵活性,使其能够在不同应用场景下,通过精细调优内存布局,达到最佳的性能表现。

2.3 TLAB 空间

(1) 并发分配内存空间的方式

在 Java 虚拟机中,对象的内存分配是频繁发生的操作,尤其是在多线程环境下,这种操作的并发性可能导致竞争条件和不一致性。为了保证内存分配的正确性和效率,JVM 采用了多种策略来应对并发问题,确保对象能在正确的内存位置上被创建。

在多线程场景中,Java 虚拟机主要采用 CAS+失败重试 和 本地线程分配缓冲 两种方式来处理并发分配内存的问:


方式 描述
CAS+失败重试 当多个线程试图同时分配内存时,JVM 可以使用 CAS (Compare-and-Swap) 操作来确保内存分配的原子性。CAS 是一种原子更新技术,它检查内存位置的值是否与预期值一致,如果一致则进行更新,否则进行重试。这种方法可以避免线程之间的冲突,确保即使在高并发环境下也能正确的进行内存空间分配。
本地线程分配缓冲 (TLAB) 这种方式主要是为每个线程分配一个私有的内存缓冲区,即 Thread Local Allocation Buffer (TLAB)。在创建对象时,线程首先检查其 TLAB 中是否有足够的空间,如果有,就在 TLAB 中直接分配内存空间,这样可以避免线程间的竞争,因为每个线程都有自己的 TLAB 内存空间。只有当 TLAB 空间不足时,才会尝试从公共的 Eden 区中进行内存空间分配,这时可能需要同步操作。

(2) TLAB 是什么

上面在介绍并发分配内存空间的方式时提到过 TLAB,它的全称是 Thread Local Allocation Buffer,它是 Java 虚拟机中用于优化多线程环境下对象分配效率的一项关键技术。在本质上,TLAB 是 HotSpot 虚拟机在新生代 Eden 区中为每个线程分配的一块私有内存缓冲区。默认情况下,每个 TLAB 空间大概会占据 Eden 区大约 1% 的内存空间。

在多线程场景下,当 Java 虚拟机需要为新创建的对象分配内存时,会优先考虑使用线程自身的 TLAB 空间。这是因为每个线程都有自己的 TLAB 空间,这避免了线程间在分配对象时可能产生的锁竞争,从而极大提升了对象分配的效率和系统的整体吞吐量。

而且,在 HotSpot 虚拟机中,参数 -XX:+UseTLAB 默认是开启的,这意味着 TLAB 机制默认处于启用状态。也就是说,每当创建一个线程,虚拟机都会在 Eden 区中为该线程分配一块 TLAB 空间。

(3) TLAB 的作用

经过以上介绍,相信大家已经了解到 TLAB 设计的初衷,主要是为了优化在多线程环境中小对象的分配效率。一般情况下这些对象不存在线程共享,并且用过即丢,所以这些小对象通常会被 Java 虚拟机优先分配在 TLAB 空间中。TLAB 中分配的对象是线程私有的,所以分配过程不会有锁的开销,而且也可以避免一系列的非线程安全问题,提升了对象内存空间的分配效率。

简而言之,Java 中每个线程都会有自己的缓冲区称作 TLAB,每个 TLAB 都只有一个线程可以操作,TLAB 结合 指针碰撞 (bump-the-pointer) 技术可以实现快速的对象分配,而不需要任何的锁进行同步,也就是说,在对象分配的时候不用锁住整个堆,而只需要在自己的缓冲区分配即可。

(4) 指针碰撞和空闲列表

在介绍 TLAB 作用时提了一下 指针碰撞 (bump-the-pointer) 这个概念,其实这个技术是 Java 虚拟机中用于对象内存空间分配方式,一般常用的有俩种,分别是 指针碰撞 和 空闲列表:


方式 描述
指针碰撞 (Bump The Pointer) 指针碰撞方式适用于内存布局规整的场景。在这种情况下,已分配的内存空间位于一端,而未分配的内存空间位于另一端。虚拟机使用一个指针作为已分配内存和未分配内存的分界点。

当需要分配内存给对象时,JVM 只需将指针向未分配内存的方向移动,移动的距离等于对象所需的内存大小。这种方式简单且高效,因为不需要复杂的搜索或锁定操作,只需简单的指针移动即可完成内存分配。
空闲列表 (Free List) 当内存布局不规整,已分配和未分配的内存空间相互交错时,指针碰撞方式就不再适用。此时,JVM 会采用空闲列表方式进行内存空间分配。

这种方式,需要虚拟机维护一个记录可用内存空间的列表,这样当对象需要进行内存空间分配时,JVM 只需要从可用空闲列表中找到一块足够大的空间划分给对象使用即可,并且分配完成后,再对类别中可用的内裤空间进行更新。

虽然空闲列表的方式涉及到列表的维护和搜索,比指针碰撞方式复杂一些,但它能够有效处理不规整的内存布局。

两种对象内存空间分配方式区别如下图所示:

(5) TLAB 分配过程

TLAB 分配流程如下面的流程图所示,图中描述的过程已经很清晰明了,这里就不过多介绍。

TLAB 空间只会占用非常小的 Eden 空间,所以大对象无法进行 TLAB 分配。

2.4 类变量和字符串常量池

类变量也称为静态变量,使用 static 关键字修饰。并且,类变量不隶属于任何一个对象实例,而是由该类的所有实例共享。不论创建多少个类的实例,类变量在内存中仅有一份拷贝,这意味着对类变量的修改会影响到所有该类的实例。

而字符串常量池则是 Java 中用于存储字符串字面量的特殊内存区域,它的主要功能是避免相同的字符串字面量重复创建,从而节省内存空间。在字符串常量池中,相同的字符串字面量只保留一份副本,后续对该字符串的引用都将指向池中的同一实例。

在 HotSpot 虚拟机中的不同的 JDK 版本中,类变量和字符串常量池的存储位置经历了显著的变化,比如:

JDK版本 描述
JDK 6及以前版本 类变量和字符串常量池主要存储在方法区的永久代 (Permanent Generation) 中。永久代是方法区的一种实现,专门用于存储类元数据、静态变量和字符串常量池等信息。然而,永久代的固定大小限制了其扩展性,容易导致内存溢出问题。
JDK 7及以后版本 为了解决永久代的局限性,从 JDK 7 开始,类变量和字符串常量池被移到了堆内存中,与普通对象共享同一块内存区域。

到了 JDK 8 版本中,直接将永久代替换为元空间 (Metaspace),元空间位于本地内存中,不再受堆大小的限制,从而解决了永久代容易溢出的问题。

三、对象在堆中的分配过程

3.1 对象在堆中的分配过程描述

在 Java 虚拟机中,为新对象分配内存空间是一件非常严谨和复杂的任务,Java 虚拟机的设计者们不仅仅要考虑内存空间如何分配,在哪里分配,使用什么内存分配算法等之外,还要考虑创建的对象在无用后如何进行回收,使用什么回收算法进行回收,回收后是否会在内存空间中产生内存碎片等问题。这里介绍下 HotSpot 虚拟机中,对象在堆空间中的分配过程,步骤如下:

步骤 流程描述
(1) 当创建一个新的对象时,JVM 会将新创建的对象存储到新生代的 Eden 区中 (此区有大小限制)。
(2) 当大量的新创建的对象存入 Eden 区后导致其内存空间不足,并且应用又需要创建新的对象时,这时就会触发 JVM 执行 Minor GC 对 Eden 区进行垃圾回收任务,将无用的对象进行回收。GC 执行完回收任务释放空间后,就会将 Eden 区中仍然存活的对象移动到新生代的 S0 区,并且使这些对象的岁数 +1。之后再将之前待创建的新对象存入 Eden 区。
(3) 在经过一段时间后,如果 Eden 区再次满了,就会再次触发 Minor GC,那么垃圾回收器 GC 就会将新生代 (Eden区+Survivor区) 中的无用对象回收掉,并且还会将仍然存活下来对象移动到 S1 区,并使对象的岁数 +1。
(4) 同样,等到 Eden 区再次满了后,触发 Minor GC 垃圾回收,此时 JVM 会将存活对象重新移回 S0 区,并且使对象的岁数 +1。所以,每次触发 Minor GC 时,就会对新生代中的 Eden 和 Survivor 区中的无用的对象进行清理,将清理后仍存活的对象在 S0 和 S1 区之间交替移动,并且还会使对象的岁数 +1。
(5) 每次触发 Minor GC 后,不仅会使对象岁数 +1,而且还会筛选达到一定岁数的对象 (默认是15岁),将这些达到岁数的对象从新生代移动到老年代中进行存储。
(6) 老年代是为长期存活的对象准备的,具有更大的容量和更长的垃圾回收周期,所以在老年代区域中的对象相对悠闲,一般情况下不会被 GC 垃圾回收器回收,只有当老年代内存不足时,JVM 才会触发 Full GC,对整个堆空间进行清理,进行全面的垃圾回收,这一过程将非常耗时。
(7) 若执行了 Full GC 后,发现老年代中依然无法满足新对象的存储需求,那么这时候就会抛出内存溢出 OutOfMemoryError 错误。

3.2 对象在堆中的分配过程图解

(1) 当创建一个新的对象时,JVM 会将新创建的对象存储到 新生代 的 Eden 区中 (此区有大小限制)。

(2) 当大量的新创建的对象存入 Eden 区后导致其内存空间不足,并且应用又需要创建新的对象时,这时就会触发 JVM 执行 Minor GC 对 Eden 区进行垃圾回收任务,将无用的对象进行回收。GC 执行完回收任务释放空间后,就会将 Eden 区中仍然存活的对象移动到 新生代 的 S0 区,并且使这些对象的岁数 +1。之后再将之前待创建的新对象存入 Eden 区;

(3) 在经过一段时间后,如果 Eden 区再次满了,就会再次触发 Minor GC,那么垃圾回收器 GC 就会将 新生代 (Eden区 + Survivor区) 中的无用对象回收掉,并且还会将仍然存活下来对象移动到 S1 区,并使对象的岁数 +1。

(4) 同样,等到 Eden 区再次满了后,触发 Minor GC 垃圾回收,此时 JVM 会将存活对象重新移回 S0 区,并且使对象的岁数 +1。所以,每次触发 Minor GC 时,就会对新生代中的 Eden 和 Survivor 区中的无用的对象进行清理,将清理后仍存活的对象在 S0 和 S1 区之间交替移动,并且还会使对象的岁数 +1。

(5) 每次触发 Minor GC 后,不仅会使对象岁数 +1,而且还会筛选达到一定岁数的对象 (默认是15岁),将这些达到岁数的对象从 新生代 移动到老年代中进行存储。

(6) 老年代 是为长期存活的对象准备的,具有更大的容量和更长的垃圾回收周期,所以在 老年代 区域中的对象相对悠闲,一般情况下不会被 GC 垃圾回收器回收,只有当老年代内存不足时,JVM 才会触发 Full GC,对整个堆空间进行清理,进行全面的垃圾回收,这一过程将非常耗时。

(7) 若执行了 Full GC 后,发现 老年代 中依然无法满足新对象的存储需求,那么这时候就会抛出内存溢出 OutOfMemoryError 错误。

3.3 对象分配的特殊情况

在 Java 虚拟机的内存管理中,对象的分配通常遵循一定的规则,但在一些特殊情况下,这些规则会发生变化,以适应不同的运行时环境和对象特性。以下是几种可能导致直接分配对象到老年代的特殊情况:

  • Survivor区内存不足: 在新生代中的 Eden 区内存空间不足时,会触发 Minor GC 清理内存空间,而 Survivor 区内存空间不足时,不会触发 Minor GC 操作,而是会直接将 Survivor 区中的对象 (不考虑是否达到指定岁数) 直接移动到老年代中。
  • 大对象直接分配: 如果创建的对象是一个大对象(例如,占用大量连续内存空间的对象,如大型数组),则会判断新生代的 Eden 区是否可以放下该大对象,如果不行就继续判断新生代中的 Survivor 区是否能够放下该大对象,如果还不行就最后判断老年代中是否可以放下该大对象,如果可以就直接放到老年代中,如果不行就抛出内存溢出 OutOfMemoryError 错误。

大体上对象的分配流程如下:

3.4 对象分配中的疑问

(1) GC 垃圾回收器有几种类型?

Java 虚拟机中的垃圾回收机制是自动管理内存的重要组成部分,负责回收不再使用的对象所占用的内存空间。根据垃圾回收的目标区域和执行方式,目前 GC 可以分为以下几种类型:

  • Minor GC / Young GC: 回收新生代中的不可达对象,执行频率较为频繁;
  • Major GC / Old GC: 回收老年代中不可达对象 (目前只有 CMS 垃圾回收器存在);
  • Mixed GC 混合收集,回收整个新生代,以及部分老年代中的不可达对象 (目前只有 G1 垃圾回收器存在);
  • Full GC: 回收整个堆空间中的不可达对象 (清理区域包含新生代、老年代、方法区等),清理空间非常大,因此执行的效率非常低,会导致系统长时间停顿 (Stop The World, STW),影响用户体验和系统性能,所以减少 Full GC 是 JVM 优化的重点;

(2) 什么时候会触发 Minor GC

当新生代中的 Eden 区满了后就会触发 Minor GC,而 Survivor 区满了则不会触发,每次执行 Minor GC 就会对整个新生代进行清理。

(3) 执行 Full GC 时会清理元空间么?

元空间的大小是根据系统可用的本地内存来动态扩展的,可以通过 JVM 参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize 来控制元空间的初始大小和最大容量。当元空间的使用接近或达到 -XX:MaxMetaspaceSize 时,JVM 会尝试触发类卸载或执行 Full GC,以释放不再需要的元数据。

(4) 为什么采用分代回收?

Java 虚拟机采用分代回收策略,这一设计并非偶然,而是基于对象生命周期特性的深入理解和优化需求。分代回收策略的核心思想是将堆内存划分为几个不同的代 (Generation),并针对每一代的特点采用最合适的垃圾回收算法。下面列举的是采用分代回收策略的主要原因及其带来的优势:

  • ① 缩小内存扫描的范围,减少 STW (Stop The World) 的时间;
  • ② 将不同生命周期对象放置到不同的区域中进行存储,针对不同的区域采用不同的垃圾收集机制,最大程度保证垃圾回收的执行效率;
  • ③ 堆中绝大多数对象都是 “朝生夕灭”,将新创建的对象放到新生代,新生代中垃圾回收速度快,能够快速对不可达的对象进行回收;
  • ④ 堆中少部分对象可以 “长期存活”,将长期稳定存活的对象放到老年代,老年代中的对象比较稳定,一般情况下不会轻易进行垃圾回收;

四、逃逸分析

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

4.1 什么是逃逸分析

内存逃逸分析 (Escape Analysis) 是 Java 虚拟机中一项先进的优化技术,其核心在于分析对象的动态作用域,以确定对象是否有可能被外部方法或线程访问。根据逃逸分析的结果,JVM 可以采取相应的优化措施,包括 栈上分配 (Stack Allocation)、同步消除 (Synchronization Elimination) 和 标量替换 (Scalar Replacement) 等,从而显著提升程序的运行效率。

4.2 方法逃逸和线程逃逸

逃逸行为常见的形式有两种,分别是 方法逃逸 和 线程逃逸:

  • 方法逃逸: 方法逃逸是指,在一个方法内部创建的对象,其作用域超出了该方法的边界,被其它方法引用。比如,一个对象在方法中创建后,作为参数传递给了其它方法,或者作为方法的返回值被其它方法使用,那么这个对象就被认为是发生了方法逃逸。
  • 线程逃逸: 线程逃逸是指,一个对象被一个线程创建后,又被其它线程访问。比如,一个线程创建了一个成员变量,然后该变量又被其它线程访问,读取了该变量值,这就相当于发生了线程逃逸。

从 JDK 6 Update 23 开始,服务端编译器中才默认开启逃逸分析,并且在 JDK 8 中是默认开启逃逸分析的。

4.3 逃逸分析示例

逃逸分析是 Java 虚拟机中用于优化程序性能的一项关键技术,通过分析对象的动态作用域,决定是否可以对对象进行栈上分配或标量替换等优化。下面,我们将通过一系列具体的 Java 代码示例,来深入理解逃逸分析的工作原理和应用场景。示例代码如下:

public class EscapeAnalysis {
public static Object a; // 类变量
public Object b; // 实例变量
/**
* 静态变量,外部线程可见,会发生逃逸
*/
public void globalVariableEscape() {
a = new Object();
}
/**
* 赋值给实例变量,外部线程可见,会发生逃逸
*/
public void instanceObjectEscape() {
b = new Object();
}
/**
* 返回方法中定义的对象,会发生逃逸
*/
public Object returnObjectEscape() {
return new Object();
}
/**
* 方法中定义的变量传递到其它方法中,会发生逃逸
*/
public void methodEscape1() {
Object obj = new Object();
methodEscape2(obj);
}
public void methodEscape2(Object obj) {
System.out.println(obj);
}
/**
* 对象没有被其它方法和线程引用,没法发生逃逸
*/
public void noEscape() {
Object noEscape = new Object();
}
}

4.4 逃逸分析优化方式

使用逃逸分析主要目的是分析出没有发生逃逸的对象,然后使用一些优化手段对其进行优化,常用优化手段有 栈上分配 (Stack Allocation)、同步消除 (Synchronization Elimination) 和 标量替换 (Scalar Replacement) 等。

(1) 栈上分配 (Stack Allocations)

一般情况下创建的对象是在堆中进行分配,堆中的对象是会被垃圾回收器 GC 进行扫描与回收,扫描与回收过程中需要消耗大量的 CPU 资源。换一个角度,如果对象在经过逃逸分析后,发现对象不会逃逸出方法,既然这样就就可以将对象直接分配到在栈上进行分配,栈中的数据是随着线程调用方法创建栈帧而生成的,当方法执行结束后栈帧就会销毁,这样的话就无须使用 GC 对该对象进行扫描与回收,减少 GC 垃圾回收器的压力,而且也可以减少在多线程环境下发生线程安全问题。

注: 由于复杂度等原因,目前在 HotSpot 虚拟机中目前暂时还没有实现这项优化,但是在一些其它的虚拟机 (如Excelsior JET) 中已经使用了这项优化。

(2) 同步消除 (Synchronization Elimination)

同步消除是另一种基于逃逸分析的优化方式,主要用于减少多线程环境下的锁操作。如果一个对象被证明不会逃逸到其它线程,即它只在一个线程内被访问,那么对该对象的所有同步操作 (如synchronized块) 都是不必要的,因为不存在跨线程的数据竞争。在这种情况下,编译器可以安全地移除这些同步机制,从而提高代码的执行速度。

注: 同步消除也被称为锁消除。

(3) 标量替换 (Scalar Replacement)

标量替换是一种优化策略,用于减少对象创建和内存访问开销。如果一个对象不会逃逸,那么它的成员变量 (字段) 可以被视为独立的标量变量,而不是作为一个整体的对象来处理。这意味着,编译器可以将对象的字段分解为单独的变量,直接在栈或寄存器中操作这些变量,而不是访问堆上的对象实例。这种优化不仅可以减少内存分配和访问的时间,还可以避免对象创建和销毁的开销。

注: 标量替换的好处就是,可以在栈上分配对象分解后替换为标量,这样就可以减少堆内存的占用。

五、堆相关的配置

5.1 基本参数配置

⑴ -Xss

  • 默认值: 1M (具体数值依JVM版本和操作系统有所不同)
  • 配置示例: -Xss512k
  • 描述: 设置每个线程的栈空间大小。较大的栈空间可以支持更深的函数调用层次,但同时也会消耗更多的内存资源。对于大量线程的应用,调整 -Xss 参数可以有效控制总体内存使用,防止因线程栈溢出导致的 OutOfMemoryError。

⑵ -Xmx

  • 默认值: 服务器内存的1/4
  • 配置示例: -Xmx4g
  • 描述: 设置 JVM 可以使用的最大堆内存。这是新生代和老年代的总和。合理设置 -Xmx 参数可以避免频繁的垃圾回收,同时也可以防止因内存不足而引发的 OutOfMemoryError 错误。

⑶ -Xms

  • 默认值: 服务器内存的1/64
  • 配置示例: -Xms2g
  • 描述: 设置 JVM 启动时的初始堆内存大小。一般情况下推荐设置 -Xms 和 -Xmx 一致,这样可以避免 JVM 在运行过程中动态调整堆大小,从而减少内存碎片和提高性能。

⑷ -Xmn

  • 默认值: 无
  • 配置示例: -Xmn256m
  • 描述: 设置新生代的大小。新生代的大小直接影响到垃圾回收的频率和效率。通常,可以根据应用的特性 (如短生命周期对象的比例) 来调整新生代的大小,以优化垃圾回收性能。

⑸ -XX:InitialRAMPercentage

  • 默认值: 无
  • 配置示例: -XX:InitialRAMPercentage=60.0
  • 描述: 设置初始堆内存占用物理内存的百分比。

⑹ -XX:MinRAMPercentage

  • 默认值: 无
  • 配置示例: -XX:MinRAMPercentage=60.0
  • 描述: 设置最小堆内存占用物理内存的百分比。

⑺ -XX:MaxRAMPercentage

  • 默认值: 无
  • 配置示例: -XX:MaxRAMPercentage=80.0
  • 描述: 最大堆内存占用物理内存的百分比。

5.2 新生代与老年代配置

⑴ -XX:NewRatio

  • 默认值: 2
  • 配置示例: -XX:NewRatio=2
  • 描述: 设置新生代和老年代的容量比例。例如,-XX:NewRatio=2 表示老年代的容量是新生代的两倍。

⑵ -XX:SurvivorRatio

  • 默认值: 8
  • 配置示例: -XX:SurvivorRatio=8
  • 描述: 设置 Eden 区与两个 Survivor 区的容量比例。例如 -XX:SurvivorRatio=8 表示 Eden 区与一个 Survivor 区的比例为 8:1。通常,Survivor区有两个,因此实际比例为 8:1:1。

⑶ -XX:+UsePSAdaptiveSurvivorSizePolicy

  • 默认值: 默认启用
  • 配置示例: -XX:+UsePSAdaptiveSurvivorSizePolicy
  • 描述: 开启此选项后,JVM 会根据对象生成速率和 Survivor 区的使用情况自动调整 Eden 区和 Survivor 区的比例。这提供了更灵活的内存管理,适用于难以预测对象生存率的应用场景。

⑷ -XX:MaxTenuringThreshold

  • 默认值: 15
  • 配置示例: -XX:MaxTenuringThreshold=10
  • 描述: 设置对象从新生代晋升到老年代所需的 “年龄” 阈值。这个年龄是对象在 Survivor 区经历垃圾回收次数的度量。降低这个阈值可以使更多对象提前晋升到老年代,反之则让对象在新生代停留更长时间。

⑸ -XX:PretenureSizeThreshold

  • 默认值: 0
  • 配置示例: -XX:PretenureSizeThreshold=1m
  • 描述: 设置超过多大尺寸的对象为大对象,会被直接分配到老年代。如果设置为 0,则所有对象首先尝试在新生代分配。对于已知的大对象,适当设置这个参数可以避免不必要的新生代垃圾回收。

5.3 堆内存溢出参数配置

⑴ -XX:+HeapDumpOnOutOfMemoryError

  • 默认值: 默认禁用
  • 配置示例:
    • 启用: -XX:+HeapDumpOnOutOfMemoryError
    • 禁用: -XX:-HeapDumpOnOutOfMemoryError
  • 描述: 当发生 OOM 错误时,启用此参数将触发 JVM 生成当前堆的快照,即 heap dump 文件。heap dump 文件包含了所有 Java 对象的实例信息,包括对象的状态、引用关系等,这对于分析内存泄漏或大对象占用过多内存等问题非常有用。

⑵ -XX:+HeapDumpPath

  • 默认值: 默认禁用
  • 配置示例: 启用: -XX:HeapDumpPath=xxx 禁用: -XX:-HeapDumpPath=xxx (实际上,禁用语法不正确,应通过其它方式控制是否生成heap dump) 描述: 指定heap dump文件的保存路径。当-XX:+HeapDumpOnOutOfMemoryError启用时,此参数定义了OOM发生时heap dump文件的存放位置。合理选择路径确保有足够的磁盘空间来存储可能非常大的heap dump文件。

5.4 堆 TLAB 参数配置

⑴ -XX:+UseTLAB

  • 默认值: 默认启用
  • 配置示例:
    • 启用: -XX:+UseTLAB
    • 禁用: -XX:-UseTLAB
  • 描述: 控制是否开启 TLAB 机制。启用后,每个线程在 Eden 区都会分配一块私有的内存区域,用于新对象的快速分配。

⑵ -XX:TLABSize

  • 默认值: 0
  • 配置示例: -XX:TLABSize=512k
  • 描述: 设置 TLAB 空间的初始大小。如果设置为 0,则由 JVM 自动调整。适当设置该值可以平衡对象分配速度和内存碎片。

⑶ -XX:TLABWasteTargetPercent

  • 默认值: 1%
  • 配置示例: -XX:TLABWasteTargetPercent=1
  • 描述: 设置 TLAB 可占用 Eden 区的百分比。这个参数影响着 TLAB 的大小调整策略,较高的值可能会导致更多的 Eden 区被预分配给 TLAB,但也可能增加内存碎片。

⑷ -XX:TLABRefillWasteFraction

  • 默认值: 64
  • 配置示例: -XX:TLABRefillWasteFraction=32
  • 描述: 设置 TLAB 中允许浪费的内存比例。如果 TLAB 中剩余的空间小于这个比例,JVM 将尝试重新分配一个新的 TLAB,而不是在当前的 TLAB 中进行同步分配。这样可以减少同步开销,提高对象分配的效率。例如,如果你设置 -XX:TLABRefillWasteFraction=64,那么当TLAB中剩余的空间小于其大小的 1/64 时,JVM 将尝试重新分配一个新的 TLAB。

⑸ -XX:+PrintTLAB

  • 默认值: 默认禁用
  • 配置示例:
    • 启用: -XX:+PrintTLAB
    • 禁用: -XX:-PrintTLAB
  • 描述: 设置是否打印 TLAB 的使用情况。启用后,在日志中可以看到每个线程 TLAB 的分配、使用情况,对于理解对象分配行为和内存管理非常有帮助。

⑹ -XX:+ResizeTLAB

  • 默认值: 默认启用
  • 配置示例:**
    • 启用: -XX:+ResizeTLAB
    • 禁用: -XX:-ResizeTLAB
  • 描述: 控制 TLAB 的空间大小是否可变。启用时,JVM 会根据对象分配模式动态调整 TLAB 大小,禁用时则保持固定大小。

5.5 逃逸分析参数配置

⑴ -XX:+DoEscapeAnalysis

  • 默认值: 默认启用
  • 配置示例:
    • 启用: -XX:+DoEscapeAnalysis
    • 禁用: -XX:-DoEscapeAnalysis
  • 描述: 控制是否执行逃逸分析。逃逸分析是 JVM 进行优化的基础,启用后,JVM 会在运行时分析对象的作用域,以决定是否可以应用各种优化。

⑵ -XX:+PrintEscapeAnalysis

  • 默认值: 默认禁用
  • 配置示例:
    • 启用: -XX:+PrintEscapeAnalysis
    • 禁用: -XX:-PrintEscapeAnalysis
  • 描述: 控制是否打印逃逸分析的结果。在开发和调试阶段,启用此参数可以帮助理解哪些对象发生了逃逸,以及 JVM 如何基于逃逸分析进行优化。

⑶ -XX:+EliminateLocks

  • 默认值: 默认启用
  • 配置示例:
    • 启用: -XX:+EliminateLocks
    • 禁用: -XX:-EliminateLocks
  • 描述: 控制是否开启同步消除。基于逃逸分析的结果,如果一个对象被证明不会被多个线程共享,JVM 可以消除对该对象的同步锁,从而减少同步操作的开销,提高并发性能。

⑷ -XX:+EliminateAllocations

  • 默认值: 默认启用
  • 配置示例:
    • 启用: -XX:+EliminateAllocations
    • 禁用: -XX:-EliminateAllocations
  • 描述: 控制是否开启标量替换。对于未逃逸的对象,JVM 可以将对象的成员变量分解为独立的标量变量,避免对象的封装和解封装,减少对象创建和内存分配的开销。
---END---
如果本文对你有帮助,可以关注我的公众号 "小豆丁技术栈" 了解最新动态,顺便也请帮忙 Github 点颗星哦,感谢~

本文作者:超级小豆丁 @ 小豆丁技术栈

本文链接:http://www.mydlq.club/article/133/

本文标题:深入浅出 JVM 之运行时数据区-堆

本文版权:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!