对于 Java 开发人员来说,进行程序的性能优化是很有挑战的工作,也是很有意义的一件事。本篇主要根据 JVM 内存模型和垃圾回收的详细讲解,可以更好的理解JVM的调优的根本原理。
JVM内存模型
JVM 架构
- 类加载器(Classloader):类加载器是JVM的一个子系统,用于加载类文件。每当我们运行java程序时,它首先由类加载器加载。
- 类(方法)区(Class(Method) Area):类(方法)区存储每个类的结构,例如运行时常量池、字段和方法数据、方法的代码。
- 堆(Heap):是分配对象的运行时数据区域。
- 堆栈(Stack):Java 堆栈存储帧。它保存局部变量和部分结果,并在方法调用和返回中发挥作用。每个线程都有一个私有的 JVM 堆栈,与该线程同时创建。每次调用方法时都会创建一个新框架。当其方法调用完成时,框架将被销毁。
- 程序计数器寄存器(PC):PC(程序计数器)寄存器包含当前正在执行的Java虚拟机指令的地址。
- 本机方法堆栈(Native Method Stack):它包含应用程序中使用的所有本机方法。
- 执行引擎(Execution Engine):它包含:一个虚拟处理器;解释器:读取字节码流然后执行指令。
- Just-In-Time(JIT)编译器:它用于提高性能。JIT 同时编译具有相似功能的字节码部分,从而减少编译所需的时间。这里,术语“编译器”是指从Java虚拟机(JVM)的指令集到特定CPU的指令集的翻译器。
- Java 本机接口:Java 本机接口 (JNI) 是一个框架,提供与用其他语言(如 C、C++、汇编等)编写的另一个应用程序进行通信的接口。Java 使用 JNI 框架将输出发送到控制台或与操作系统交互。
应该已经使用了一些像这样的 JVM 配置
JAVA_OPTS=”-server -Xms2560m -Xmx2560m -XX:NewSize=1536m -XX:MaxNewSize=1536m -XX:MetaspaceSize=768m -XX:MaxMetaspaceSize=768m -XX:InitialCodeCacheSize=64m -XX:ReservedCodeCacheSize=96m -XX:MaxTenuringThreshold=5″
- -server - 启用“ServerHotspotVM”;该参数在 64 位 JVM 中默认使用。
- -Xms - 堆的初始空间。
- -Xmx - 堆的最大空间。
- -XX:NewSize - 初始新空间。将新大小设置为总堆的一半通常比使用较小的新大小提供更好的性能。
- -XX:MaxNewSize - 最大新空间。
- -XX:MetaspaceSize - 静态内容的初始空间。
- -XX:MaxMetaspaceSize - 静态内容的最大空间。
- -XX:InitialCodeCacheSize - JIT 编译代码的初始空间。代码缓存太小(默认为 48m)会降低性能,因为 JIT 无法优化高频方法。
- -XX:ReservedCodeCacheSize - JIT 编译代码的最大空间。
- -XX:MaxTenuringThreshold - 在升级到老年代空间之前,将幸存者保留在幸存者空间中最多 15 次垃圾回收。
那么 JVM 是如何驻留在内存上的?JVM 消耗主机操作系统内存上的可用空间。
然而,在 JVM 内部,存在独立的内存空间(堆、非堆、缓存),以存储运行时数据和编译后的代码。
堆内存
- 堆分为两部分:Young Generation 和 Old Generation
- JVM 启动时分配堆(初始大小:-Xms)
- 应用程序运行时堆大小增加/减少
- 堆的最大空间:-Xmx
以下是有关服务器应用程序堆大小的一般准则:
- 除非遇到暂停问题,否则请尝试为虚拟机授予尽可能多的内存。默认大小通常太小。
- 将-Xms和-Xmx设置为相同的值可以消除虚拟机中最重要的大小调整决策,从而提高可预测性。但是如果设置相同大小出了错误,虚拟机将无法进行补偿。
- 一般来说,随着处理器数量的增加,内存也会随之增加,因为分配可以并行进行。
年轻代(Young Generation)
- 这是为包含新分配的对象而保留的
- Young Gen 包括三个部分——Eden Memory 和两个 Survivor Memory 空间(S0、S1)
- 大多数新创建的对象都会进入Eden space。
- 当 Eden 空间充满对象时,将执行 Minor GC(又名 Young Collection),并将所有幸存者对象移动到幸存者空间之一。
- Minor GC 还会检查幸存者对象并将它们移动到其他幸存者空间。所以在某一时刻,幸存者的一个空间总是空着的。
- 经过多次GC后幸存的对象会被移至Old代内存空间。通常,这是通过在年轻代对象有资格晋升到老年代之前设置年龄阈值(-XX:MaxTenuringThreshold)来完成的。
老年代(Old Generation)
- 这是为包含在多轮 Minor GC 后仍能存活的长寿命对象而保留的
- 当 Old Gen 空间满时,将执行 Major GC(又名 Old Collection)(通常需要更长的时间)
非堆内存
- 这包括永久生成(自 Java 8 起被 Metaspace 取代)
- Perm Gen 存储每个类的结构,例如运行时常量池、字段和方法数据、方法和构造函数的代码以及内部字符串
- 可以使用 -XX:PermSize 和 -XX:MaxPermSize 更改其大小
高速缓存存储器
- 这包括代码缓存
- 存储JIT编译器生成的编译代码(即本机代码)、JVM内部结构、加载的分析器代理代码和数据等。
- 当代码缓存超过阈值时,它会被刷新(GC 不会重新定位对象)。
什么是GC?
Java 通过一个称为垃圾收集器的程序提供自动内存管理。
“移除不再使用的对象。”
上面的一切都是在堆中完成的,堆是运行时动态内存分配的空间,用于包含所有 java 对象。除了堆之外,还有堆栈,其中包含支持线程执行的局部变量和函数调用。
Java 垃圾收集的实际工作原理
许多人认为垃圾收集会收集并丢弃死对象。事实上,Java 垃圾收集的作用恰恰相反!活动对象被跟踪,其他所有对象都被指定为垃圾。这种根本性的误解可能会导致许多性能问题。
让我们从堆开始,它是用于动态分配的内存区域。在大多数配置中,操作系统会提前分配堆,以便在程序运行时由 JVM 管理。这有几个重要的影响:
- 对象创建速度更快,因为不需要每个对象都与操作系统进行全局同步。分配只是声明内存数组的某些部分并将偏移指针向前移动(参见图 2.1)。下一个分配从此偏移量开始,并声明数组的下一部分。
- 当不再使用某个对象时,垃圾收集器会回收底层内存并将其重新用于将来的对象分配。这意味着没有显式删除,也没有内存返回给操作系统。
新对象简单地分配在已用堆的末尾
一旦某个对象不再被引用并且因此应用程序代码无法访问该对象,垃圾收集器就会将其删除并回收未使用的内存。
垃圾收集根——所有对象树的来源
每个对象树必须有一个或多个根对象。只要应用程序可以到达这些根,那么整棵树都是可以到达的。但是这些根对象什么时候被认为是可达的呢?称为垃圾收集根,它是特殊对象始终是可访问的,任何在其根处具有垃圾收集根的对象也是如此。
Java中有四种GC root:
- 局部变量通过线程的堆栈保持活动状态。这不是真实的对象虚拟引用,因此不可见。无论如何,局部变量都是 GC 根。
- 活动的 Java 线程始终被视为活动对象,因此是 GC 根。这对于线程局部变量尤其重要。
- 静态变量由它们的类引用。这一事实使它们成为事实上的 GC 根。类本身可以被垃圾收集,这将删除所有引用的静态变量。
- JNI 引用是本机代码作为 JNI 调用的一部分创建的 Java 对象。这样创建的对象会被特殊对待,因为 JVM 不知道它是否被本机代码引用。
GC 根是 JVM 本身引用的对象,因此可以防止其他所有对象被垃圾收集。
因此,一个简单的 Java 应用程序具有以下 GC 根:
- main方法中的局部变量
- 主线程
- 主类的静态变量
标记并清除垃圾
标记可达对象
为了确定哪些对象不再使用,JVM 间歇性地运行所谓的“标记和清除”算法。正如所直觉的,这是一个简单的两步过程:
- 该算法从 GC 根开始遍历所有对象引用,并将找到的每个对象标记为活动对象。
- 所有未被标记对象占用的堆内存都会被回收。它只是被简单地标记为空闲,基本上清除了未使用的对象。
活动对象在上图中表示为蓝色。当标记阶段结束时,每个活动对象都被标记。因此,所有其他对象(上图中的灰色数据结构)都无法从 GC 根访问,这意味着应用程序无法再使用无法访问的对象。此类对象被视为垃圾,GC 应在以下阶段中删除它们。
标记阶段需要注意以下重要方面:
- 需要停止应用程序线程才能进行标记,因为如果图表一直在不断变化,就无法真正遍历图表。当应用程序线程暂时停止以便 JVM 可以进行内务活动时,这种情况称为安全点,导致 Stop The World 暂停。安全点可以因不同的原因而被触发,但垃圾收集是迄今为止引入安全点的最常见原因。
- 此暂停的持续时间既不取决于堆中对象的总数,也不取决于堆的大小,而是取决于活动对象的数量。因此增加堆的大小并不会直接影响标记阶段的持续时间。
- 当标记阶段完成后,GC就可以进行下一步并开始删除不可达的对象。
删除未使用的对象
对于不同的 GC 算法,未使用对象的删除略有不同,但所有此类 GC 算法都可以分为三步:清除(sweeping)、压缩(compacting)和复制(copying)。
Sweep
标记和清除算法在概念上使用最简单的垃圾处理方法,即忽略此类对象。这意味着在标记阶段完成后,未访问对象占用的所有空间都被视为空闲,因此可以重用以分配新对象。
该方法需要使用所谓的空闲列表记录每个空闲区域及其大小。空闲列表的管理增加了对象分配的开销。这种方法还有另一个弱点——可能存在大量空闲区域,但如果没有一个区域足够大来容纳分配,分配仍然会失败(在 Java 中会出现 OutOfMemoryError 错误)。
它通常被称为标记-清除算法。
Compact
Mark-Sweep-Compact算法通过将所有标记的对象 (即活动的对象)移动到内存区域的开头来解决Mark-and-Sweep算法的缺点。这种方法的缺点是增加了GC暂停时间,因为我们需要将所有对象复制到一个新位置,并更新对这些对象的所有引用。Markand Sweep的好处也是显而易见的--在这样一个压缩操作之后,通过指针碰撞,新对象的分配再次变得非常便宜。使用这种方法,空闲空间的位置总是已知的,也不会触发碎片问题。
它通常被称为标记-压缩算法。
Copy
标记和复制算法非常类似于标记和压缩,因为它们也重新定位所有活动对象。重要的区别在于,对象搬迁的目标是不同的记忆区域,作为幸存对象的新家。标记和复制方法具有一些优点,因为复制可以与标记在同一阶段同时发生。缺点是需要多一个内存区域,该内存区域应该足够大以容纳幸存的对象。
它通常被称为标记复制算法。
停止世界 (STW)
所有垃圾收集都是“Stop the World”事件。这意味着所有应用程序线程都将停止,直到操作完成。垃圾收集始终是“Stop the World”事件。
老年代用于存储长期存活的对象。通常,为年轻代对象设置一个阈值,当达到该年龄时,该对象将被移动到老年代。最终需要收集老年代。此事件称为主垃圾收集。
主垃圾收集也是 Stop the World 事件。通常,主垃圾收集要慢得多,因为它涉及所有活动对象。因此,对于响应式应用程序,应最大程度地减少主要垃圾收集。另请注意,主要垃圾收集的 Stop the World 事件的长度受到用于老年代空间的垃圾收集器类型的影响。
GC 可视化过程
当应用程序启动并在 Eden 空间上分配内存时。蓝色是活动对象,灰色是死对象(无法到达)。当给定空间已满时,应用程序尝试创建另一个对象,并且 JVM 尝试在 Eden 上分配某些内容,但分配失败。这实际上会导致轻微GC。
第一次minor GC后,所有存活对象将被移动到Survivor 1,年龄为1,死亡对象将被删除。
应用程序正在运行,新对象再次在 Eden 空间中分配。有些对象在 Eden 空间和 Survivor 1 上都变得无法访问
在第二次 Minor GC 之后,所有存活对象将被移动到 Survivor 2(来自年龄为 1 的 Eden 和年龄为 2 的 Survivor 1),并且死亡对象将被删除。
应用程序仍在运行,新对象在 Eden 空间上分配,过了一会儿,一些对象从 Eden 和 Survivor 2 都无法访问
在第三次minor GC之后,随着年龄的增加,所有存活对象将从Eden和Survivor 2移动到Survivor 1,并且死亡对象将被删除。
在Survivor中存活时间较长的对象,如果年龄大于-XX:MaxTenuringThreshold,将会被提升到老年代(Tuner)
我们可以使用 VisualVM 的插件 VisualGC 附加到已检测的 HotSpot JVM,收集并以图形方式显示垃圾收集、类加载器和 HotSpot 编译器性能数据。
性能基础知识
通常,在调整 Java 应用程序时,重点是两个主要目标之一:响应速度和吞吐量。
响应速度
响应能力是指应用程序或系统响应所请求的数据的速度。示例包括:
- 桌面 UI 响应事件的速度有多快
- 网站返回页面的速度有多快
- 返回数据库查询的速度有多快
对于注重响应能力的应用程序来说,较长的暂停时间是不可接受的。重点是在短时间内做出响应。
吞吐量
吞吐量侧重于在特定时间段内最大化应用程序的工作量。如何测量吞吐量的示例包括:
- 在给定时间内完成的交易数量。
- 批处理程序在一小时内可以完成的作业数。
- 一小时内可以完成的数据库查询数量。
对于注重吞吐量的应用程序来说,较长的暂停时间是可以接受的。由于高吞吐量应用程序关注较长时间段的基准,因此不考虑快速响应时间。
GC 有哪些类型?
并发标记清除 (CMS) 垃圾收集
CMS垃圾收集本质上是升级的标记和清除算法。它使用多个线程扫描堆内存。它经过修改以利用更快的系统并增强了性能。
它尝试通过与应用程序线程同时执行大部分垃圾收集工作来最大程度地减少由于垃圾收集而导致的暂停。它在年轻代中使用并行的 stop-the-world 标记复制算法,在老年代中使用大多数并发的标记清除算法。
要使用 CMS GC,请使用以下 JVM 参数:
-XX:+UseConcMarkSweepGC
串行垃圾收集
该算法对年轻代使用标记-复制,对老生代使用标记-清除-压缩。它在单线程上工作。执行时,它会冻结所有其他线程,直到垃圾收集操作结束。
由于串行垃圾收集的线程冻结性质,它仅适用于非常小的程序垃圾收集。
要使用串行 GC,请使用以下 JVM 参数:
-XX:+UseSerialGC
并行垃圾收集
与串行GC类似,它在年轻代中使用标记复制,在老年代中使用标记清除紧凑。多个并发线程用于标记和复制/压缩阶段。可以使用 -XX:ParallelGCThreads=N 选项配置线程数。
如果主要的目标是通过有效利用现有系统资源来提高吞吐量,则并行垃圾收集器适用于多核计算机。使用这种方法,可以大大缩短 GC 循环时间。
要使用并行 GC,请使用以下 JVM 参数:
-XX:+UseParallelGC
G1垃圾收集
G1(垃圾优先)垃圾收集器在 Java 7 中可用,旨在作为 CMS 收集器的长期替代品。G1 收集器是一个并行、并发、增量压缩的低暂停垃圾收集器。
这种方法涉及将内存堆分割成多个小区域(通常为 2048 个)。每个区域都被标记为年轻代(进一步分为eden regions或survivor regions)或老年代。这使得 GC 可以避免一次收集整个堆,而是逐步解决问题。这意味着一次仅考虑区域的子集。
G1 持续跟踪每个区域包含的实时数据量。该信息用于确定包含最多垃圾的区域;所以首先收集它们。这就是为什么它被称为垃圾优先收集。
不幸的是,就像其他算法一样,压缩操作是使用 Stop the World 方法进行的。但根据其设计目标,可以为其设置特定的性能目标。还可以配置暂停持续时间,例如在任何给定的秒内不超过 10 毫秒。垃圾优先 GC 将尽最大努力以高概率实现这一目标(但不确定,由于操作系统级别的线程管理)。
如果你想在 Java 7 或 Java 8 机器上使用,请使用 JVM 参数,如下所示:
-XX:+UseSerialGC
G1 优化选项
- -XX:G1HeapReginotallow=16m 堆区域的大小。该值是 2 的幂,范围从 1MB 到 32MB。目标是根据最小 Java 堆大小拥有大约 2048 个区域。
- -XX:MaxGCPauseMillis=200 设置所需最大暂停时间的目标值。默认值为 200 毫秒。指定的值不适合堆大小。
- -XX:G1ReservePercent=5 这确定堆中的最小保留量。
- -XX:G1Cnotallow=75 这是确信度百分比。
- -XX:GCPauseIntervalMillis=200 这是每个 MMU 的暂停间隔时间片(以毫秒为单位)。
建议
G1配置
-XX:+UseG1GC
-XX:+UseStringDeduplication
-XX:+ParallelRefProcEnabled
-XX:+AlwaysPreTouch
-XX:+DisableExplicitGC
-XX:ParallelGCThreads=8
-XX:GCTimeRatio=9
-XX:MaxGCPauseMillis=25
-XX:MaxGCMinorPauseMillis=5
-XX:ConcGCThreads=8
-XX:InitiatingHeapOccupancyPercent=70
-XX:MaxTenuringThreshold=10
-XX:SurvivorRatio=6
-XX:-UseAdaptiveSizePolicy
-XX:MaxMetaspaceSize=256M
-Xmx4G
-Xms2G
优化结果
总结
请注意,JVM性能调优是一个复杂的过程,需要结合具体的应用程序特性和需求来进行调优。不同的应用场景可能需要不同的调优策略。在进行JVM性能调优时,应该先进行性能测试和分析,找出性能瓶颈,然后有针对性地进行优化。同时,及时记录和备份调优前的配置和参数,以便在调优过程中出现问题时能够恢复到原始状态。