柚子快报激活码778899分享:性能优化 - JVM 优化

http://yzkb.51969.com/

JVM(Java 虚拟机)是一个能够解释和执行 Java 字节码的虚拟计算机,它是 Java 程序运行的基础平台。JVM 包含了多个子系统,包括类加载器子系统、内存管理子系统、执行引擎子系统等,可以将 Java 程序翻译成为机器指令运行在不同的操作系统平台上。

注意:JVM 是运行在操作系统之上的,它与硬件没有直接的交互,如下图所示:

JVM 优化则是指对 Java 程序在运行时的性能进行提升的一系列技术和策略。JVM 优化旨在改善 Java 应用程序的执行效率、内存管理和资源利用率,以提供更好的性能和响应能力。

本文将首先简要介绍下 JVM 的四大组成部分:类加载子系统、运行时数据区、执行引擎以及本地接口,然后再依次从类加载子系统、运行时数据区、执行引擎这三个方面来介绍 JVM 自带的优化,以及我们在开发中可以从哪些方面来进行优化。

JVM 组成

JVM 包含四大组成部分:类加载子系统、运行时数据区、执行引擎以及本地接口,下图演示了它们之间的关系:

上图中并未画出本地接口,不重要,我们只需要知道本地接口是由执行引擎调用的。

类加载子系统

类加载子系统(Class Loader Subsystem)负责将类的字节码加载到内存中,并在运行时动态地连接和初始化类。

运行时数据区

JVM 使用运行时数据区(Runtime Data Area)来存储程序执行期间需要的数据和信息。主要包括堆、方法区、虚拟机栈、本地方法栈和程序计数器。

执行引擎

执行引擎(Execution Engine)负责执行加载到内存中的字节码,将其转换为机器指令并执行。执行引擎通常包括解释器(Interpreter)和即时编译器(Just-In-Time Compiler,JIT)两个部分。

解释器:逐行解释执行字节码指令。即时编译器:将热点代码(频繁执行的代码路径)编译为本地机器代码,提高执行速度。

本地接口

Java 本地接口(Java Native Interface,JNI)是 Java 平台提供的一种机制,用于在 Java 代码和本地代码之间进行交互。它允许 Java 应用程序调用本地语言(如 C、C++)编写的代码,也可以让本地代码调用 Java 代码。

JNI 的主要作用是实现 Java 与本地代码的无缝衔接,为 Java 开发者提供了更多底层操作的能力。

除非是与硬件有关的应用,比如通过 Java 程序驱动打印机,或者使用 Java 系统管理生产设备,在企业级应用中已经很少使用本地接口了,因为现在的异构领域间的通信很发达,比如可以使用 Socket 通信,也可以使用 Web Service 等等。

类加载和初始化优化

一个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载 、验证、准备、解析、初始化 、使用和卸载七个阶段,其中验证、准备、解析三个部分统称为连接。这七个阶段的发生顺序如下图所示:

其中,在连接阶段我们能干预的有限,在加载阶段我们的可控性则比较强,但也是关于如何“通过一个类的全限定名来获取定义此类的二进制字节流”,与性能干系不大,因此本节更多的是从类加载的时机和初始化等方面来介绍如何优化。

连接阶段优化:

如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段就可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施(即省去验证阶段),以缩短虚拟机类加载的时间。

除了通过 Class 文件来获取类的二进制字节流,我们还可以:

从 ZIP 压缩包中读取,这也是 JAR、EAR、WAR 等格式的基础。从网络中获取,最典型的应用就是 Web Applet(已淘汰)。运行时计算生成,使用得最多的就是动态代理技术。由其他文件生成,典型场景是 JSP 应用,由 JSP 文件生成对应的 Class 文件(已淘汰)。从数据库中读取,这种场景相对少见些,例如有些中间件服务器(如 SAP Netweaver)可以选择把程序安装到数据库中来完成程序代码在集群间的分发。可以从加密文件中获取,这是典型的防 Class 文件被反编译的保护措施,通过加载时解密 Class 文件来保障程序运行逻辑不被窥探。

预加载

预先加载在应用程序启动时就会被频繁使用的类。

通过预加载,将这些类提前加载到内存中,使得它们在后续的使用中能够更快地被访问到,减少类加载和初始化的延迟。

可以使用静态代码块或在启动阶段手动触发这些类的加载和初始化。

延迟加载

将类的加载和初始化延迟到真正需要使用时再进行。

这种方式可以避免不必要的类加载和初始化开销,特别是对于一些不常用的类或资源消耗较大的类而言。

可以使用懒加载技术,如使用懒汉式单例模式,在首次使用该类时才进行加载和初始化。

优化类加载器层次结构

JVM 支持多个类加载器,它们按照一定的层次结构进行类的加载。在设计应用程序时,合理划分类加载器的层次结构,并根据类的使用情况选择合适的类加载器,可以提高类加载的效率和灵活性。如 OSGI 通过“破坏”双亲委派模型来实现代码热替换、模块热部署等,又如 Tomcat 通过自定义类加载器来实现类库的隔离性、共享性与热部署等。

使用字节码技术

通过使用字节码工具进行优化,可以在类加载时对字节码进行修改或增强,以达到优化的目的。例如,可以使用字节码增强框架,如 ASM、Javassist 等,对类进行动态修改和加载。

静态字段初始化优化

对于包含大量静态字段的类,可以考虑将这些字段的初始化逻辑延迟到真正需要使用时再进行,以避免不必要的初始化开销。可以使用惰性初始化的方式,将静态字段设置为 null 或标记为未初始化,在首次访问时进行初始化。

避免频繁的创建和销毁对象

对象的创建和销毁都需要耗费一定的时间和内存空间。如果程序中需要频繁地创建和销毁大量的对象,会导致严重的性能问题,如内存碎片化、频繁 GC 等。此时,我们可以考虑使用对象池或缓存来避免重复创建和销毁对象。

即时编译器优化

即时编译器(JIT)通过在运行时动态地将 Java 热点代码(频繁执行的字节码)转换为机器代码,以代替解释执行的方式,从而减少了解释器的开销,提高了程序的运行速度。

除此之外,JIT 还使用了多种优化技术来改进生成的机器代码的质量和性能。

常量传播

指在编译期时,能够直接计算出结果(这个结果往往是常量)的变量,将被编译器由直接计算出的结果常量来替换这个变量。

复写传播

指编译器用一个变量替换两个或多个相同的变量,以消除重复的计算和存取操作。

常量折叠

指在编译期间,如果有可能,多个变量的计算可以最终替换为一个变量的计算,通常是多个变量的多级冗余计算被替换为一个变量的一级计算。

冗余消除

指通过消除冗余计算和存取操作,以减少程序的执行时间和内存占用。

范围传播

指通过推导变量的取值范围,并将这些信息传播到程序中的其他地方,以避免不必要的计算和分支操作。

表达式化简

指通过应用数学等价变换规则,将复杂的表达式转化为简化的形式,从而减少计算量、消除冗余计算、降低存储需求,并提高程序的可读性。

代数化简

指通过应用代数运算的规则和性质,将复杂的代数表达式化简为等价但更简单、更紧凑的形式。

公共子表式消除

指如果一个表达式 E 之前已经被计算过了,并且从先前的计算到现在 E 中所有变量的值都没有发生变化,那么 E 的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直接用前面计算过的表达式结果代替 E。如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除。

无用代码消除

指永远不能被执行到的代码或者没有任何意义的代码会被清除掉,比如 return 之后的语句,变量自己给自己赋值等等。

循环不变表达式外提

指识别那些在整个循环中计算结果不会改变的表达式,然后将这些表达式移动到循环外部进行计算,以避免在循环内重复计算。

跳转线程化

指将条件分支中的常量条件或者可以在编译时确定的条件进行求值,并在编译时将分支条件替换为相应的目标代码。这样做的目的是减少分支的数目,避免无谓的分支开销和分支预测错误,从而提高程序的执行效率。

尾部合并

指当一个函数或者跳转指令的下一条指令是另一个函数或者跳转指令时,将它们合并成一个单独的函数或者跳转指令。这样做的目的是减少函数调用或者跳转的次数,以减少函数调用的开销和跳转的预测错误。

循环展开

指将循环体内的多个迭代展开成一个更大的循环,从而减少循环迭代次数。

数组范围检查消除

指编译器会根据数据流分析出变量的取值范围是否在 [0,array.length] 之间,如果是,那么在循环期间就可以把数组的上下边界检查(避免 ArrayIndexOutOfBoundsException)消除,以减少不必要的性能损耗。

与语言相关的其他消除操作还有不少,如自动装箱消除、安全点消除 、消除反射等。

方法内联

指将比较简短的函数或者方法代码直接粘贴到其调用者中,以减少函数调用时的开销。

方法内联可以说是编译器最重要的优化手段,因为它除了消除方法调用的成本之外,其更重要的意义是为其他优化手段建立良好的基础:没有内联,多数其他优化都无法有效进行。

逃逸分析

逃逸分析不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。

逃逸分析的基本原理是:分析对象动态作用域。当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线程逃逸,称为对象由低到高的不同逃逸程度。 如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实例采取不同程度的优化。

栈上分配

如果确定一个对象不会逃逸出线程之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。

在一般应用中,完全不会逃逸的局部对象和不会逃逸出线程的对象所占的比例是很大的,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集子系统的压力将会下降很多。

栈上分配可以支持方法逃逸,但不能支持线程逃逸。

标量替换

若一个数据已经无法再分解成更小的数据来表示了,(如 int、long 等数值类型及 reference 类型等),那么这些数据就可以被称为标量。相对的,如果一个数据可以继续分解,那它就被称为聚合量(如 Java 对象)。

如果把一个 Java 对象拆散,根据程序访问的情况,将其用到的成员变量恢复为原始类型来访问,这个过程就称为标量替换。

假如逃逸分析能够证明一个对象不会被方法外部访问,并且这个对象可以被拆散,那么程序真正执行的时候将可能不去创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写之外,还可以为后续进一步的优化手段创建条件。

标量替换可以视作栈上分配的一种特例,实现更简单(不用考虑整个对象完整结构的分配),但对逃逸程度的要求更高,它不允许对象逃逸出方法范围内。

同步消除

线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那么这个变量的读写肯定就不会有竞争, 对这个变量实施的同步措施也就可以安全地消除掉。

锁优化

锁消除

锁消除(即上文中“同步消除”)是指在编译器优化阶段,通过静态分析代码,判断程序中的锁是否实际上没有竞争并被消除。在多线程环境下,使用锁可以保证共享资源的正确同步,但是加锁和释放锁的过程会带来一定的开销。如果在分析代码时发现某个锁变量只被一个线程使用,那么这个锁就可以被消除掉,从而避免了加锁和释放锁的开销,提高程序的执行效率。

锁消除的主要判定依据来源于逃逸分析的数据支持,如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

锁粗化

锁粗化是指在多线程执行过程中,锁的竞争情况加剧,从而导致锁的粒度变大或锁的数量增加。当多个线程之间频繁地竞争同一个锁时(甚至加锁操作是出现在循环体之中),为了减少锁竞争的激烈程度,系统会将原本细粒度的锁变为粗粒度的锁,或者将多个锁合并成一个。这样可以减少锁竞争的激烈程度,提高程序的执行效率。

自旋锁与自适应锁

自旋锁是指当一个线程尝试获取某个锁时,如果该锁已被其他线程占用,就一直循环检测锁是否被释放,而不是进入线程挂起或睡眠状态。

自旋等待不能替换阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但是它是要占用处理器的时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。

轻量级锁

轻量级锁名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来替代重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

在传统的重量级锁机制中,当一个线程请求一个已经被其他线程持有的锁时,请求的线程会被挂起,并进入操作系统的调度队列,等待锁被释放。然而,上下文切换的成本是相当高昂的,严重影响并发性能。

轻量级锁则旨在避免这种情况。当一个线程请求一个已经被另一个线程持有的轻量级锁时,JVM 会让请求的线程进入自旋状态,而非将其挂起。自旋状态下的线程会在用户态不断检查锁的状态,期望在短时间内锁能够被释放,避免了昂贵的上下文切换。然而,如果锁的竞争持续时间过长,轻量级锁也会膨胀为重量级锁,以避免过长时间的无效自旋。

偏向锁

引入偏向锁的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用 CAS 操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连 CAS 操作都不去做了。

偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(TradeOff)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。

偏向锁无法使用自旋锁优化,因为一旦有其它线程申请锁,就破坏了偏向锁的的假定。

提前编译器优化

不同于 JIT 编译器是在运行时进行编译,提前编译器(Ahead-of-Time Compiler,AOT)可以在应用程序部署阶段就将 Java 字节码编译为本地机器代码。

理论上,提前编译可以减少即时编译带来的预热时间,减少 Java 应用长期给人带来的“第一次运行慢”的不良体验,可以放心地进行很多全程序的分析行为,可以使用时间压力更大的优化措施。

但是提前编译的坏处也很明显,它破坏了 Java “—次编写,到处运行”的承诺,必须为每个不同的硬件、操作系统去编译对应的发行包;同时也显著降低了 Java 链接过程的动态性,必须要求加载的代码在编译期就是全部已知的,而不能在运行期才确定,否则就只能舍弃掉己经提前编译好的版本,退回到原来的即时编译执行状态。

AOT 的优点

在程序运行前编译,可以避免在运行时的编译性能消耗和内存消耗可以在程序运行初期就达到最高性能,程序启动速度快运行产物只有机器码,打包体积小

AOT 的缺点

由于是静态提前编译,不能根据硬件情况或程序运行情况择优选择机器指令序列,理论峰值性能不如 JIT没有动态能力同一份产物不能跨平台运行

垃圾回收优化

选择合适的垃圾收集器

垃圾收集器概览

Serial:新生代收集器,特点是单线程执行,即在进行垃圾收集时,暂停应用程序的所有线程,只有垃圾收集线程在工作。采用标记-复制算法。ParNew:新生代收集器,实质上是 Serial 收集器的多线程并行版本。采用标记-复制算法。Parallel Scavenge:新生代收集器,不同于其它收集器,其目标是达到一个可控制的吞吐量。采用标记-复制算法。Serial Old:老年代收集器,Serial 收集器的老年代版本。采用标记-整理算法。Parallel Old:老年代收集器,Parallel Scavenge 收集器的老年代版本。采用标记-整理算法。CMS:老年代收集器,其目标是获取最短回收停顿时间。主要采用标记-清除算法。G1:基于分代收集算法,同时结合了并行和并发收集机制分带收集器,它将堆分为多个小区域,并并行、增量地进行垃圾收集,其目标是在延迟可控的情况下获得尽可能高的吞吐量。在 JDK9 及以上版本中,它是默认的收集器。ZGC:一种低延迟垃圾收集器,旨在减少停顿时间,适用于大型堆内存(数十 GB至数百 GB)和需要高吞吐量和低停顿时间的应用场景。Shenandoah:由 Red Hat 公司开发,和 ZGC 类似,但采用不同的算法和实现。

CMS 是一款优秀的处理器,但它也有以下三个明显的缺点:

对 CPU资源非常敏感无法处理浮动垃圾会产生大量空间碎片

因此,CMS 收集器在 JDK 9 中已被宣布为废弃,在 JDK 14 中正式被移除,作为替代方案,可以选择 G1、 ZGC 或 Shenandoah。

垃圾收集器适用场景

单核处理器和小内存应用

年轻代使用 Serial 收集器,老年代使用 Serial Old 收集器。

多核处理器和大内存应用

关注延迟时间(基于浏览器的 B/S 应用)

JDK 8 之前:年轻代使用 ParNew 收集器,老年代使用 CMS + Serial Old 收集器。JDK 8 及之后:如果内存够大(8 G),就优先选择 G1 收集器,否则考虑继续采用 ParNew + CMS + Serial Old 方案。

关注吞吐量(批处理等计算型应用)

年轻代适用 Parallel Scavenge 收集器,老年代使用 Parallel Old 收集器。

多核处理器和超大内存应用

使用 ZGC 或 Shenandoah GC。

使用指定垃圾收集器

查看当前使用的垃圾收集器:java -XX:+PrintCommandLineFlags -version

也可以通过 GC 日志和堆信息来获知垃圾收集器的信息

参数 描述 -XX:+UseSerialGC JVM 在 Client 模式下的默认值 使用 Serial + SerialOld 收集器组合 -XX:+UseParallelGC JVM 在 Server 模式下的默认值 使用 Parallel Scavenge + Parallel Old 收集器组合 -XX:+UseParallelOldGC 使用 Parallel Scavenge + Parallel Old 收集器组合 -XX:+UseConcMarkSweepGC 使用 ParNew + CMS + Serial Old 收集器组合 -XX:+UseG1GC 使用 G1 收集器 -XX:+UseZGC 使用 ZGC 收集器 -XX:+UseShenandoahGC 使用 Shenandoah 收集器

适当调整垃圾收集器的参数

不同的垃圾收集器有不同的参数可供调整,如堆大小、年轻代和老年代的比例、垃圾收集的触发条件等。通过合理调整这些参数,可以使得垃圾收集器更好地适应应用的需求,提高垃圾收集的效率。

常用参数

-Xmx:设置堆内存最大大小。默认为物理内存的 1/4。-Xms:设置堆内存初始大小。默认为物理内存的 1/64。-Xmn:设置堆内新生代的大小。通过该值可以得到老年代的大小(-Xmx - Xmn)。如果使用的是 G1 收集器,通常无需设置该值,G1 收集器会根据堆内存大小以及运行时的情况动态调整新生代的大小(堆内存的 5% ~ 60%)。-XX:MaxNewSize:设置新生代(Eden 区)的最大大小。-XX:NewSize:设置新生代(Eden 区)的初始大小。-XX:NewRatio:设置新生代和老年代的比值。默认值为 2,表示老年代的大小是新生代的 2 倍。-XX:SurvivorRatio:设置新生代中 Eden 区和 Survivor 区的比例。默认值为 8。-XX:MaxMetaspaceSize:设置元空间的最大大小。-XX:MetaspaceSize:设置元空间的初始大小。-XX:MaxTenuringThreshold:设置对象经过多少次 YGC 后晋升到老年代。默认值为 15。如果是 0,则直接进入老年代。(当 Survivor 区不够时,也可能会提前进入到老年代)-XX:ParallelGCThreads:设置并行垃圾收集器的线程数。

如果显示指定了值,则使用指定的值;当 CPU 数小于 8 是,ParallelGCThreads = CPU 个数;当 CPU 数大于等于 8 时,ParallelGCThreads = 8 + ((N - 8) * 5 / 8)。一般使用默认值即可。

-XX:ConcGCThreads:设置在非 STW 期间的 GC 工作线程数。默认值为 -XX:ParallelGCThreads / 4。-XX:MaxGCPauseMillis:设置最大垃圾收集停顿时间的(软性)目标值,用于控制垃圾收集的吞吐量和停顿时间之间的权衡。-XX:InitiatingHeapOccupancyPercent:设置触发全局并发标记的老年代使用占比。默认值为 45%(即如果 Mixed GC 周期结束后老年代使用率还是超过 45%,那么会再次触发全局并发标记过程,这样就会导致频繁的老年代 GC,继而影响应用吞吐量)-XX:G1NewSizePercent:设置 G1 收集器新生代的初始大小占整个堆内存大小的百分比。默认值为 5%。-XX:G1MaxNewSizePercent:设置 G1 收集器新生代的最大大小占整个堆内存大小的百分比。默认值为 60%。-XX:G1ReservePercent:设置 G1 收集器为分配担保预留的空间比例。默认值为 10%(即老年代会预留 10% 的空间来给新生代的对象晋升)。-G1HeapWastePercent:设置 G1 收集器触发 Mixed GC 的堆垃圾占比。默认值为 5%(即在全局标记结束后会统计出所有 Cset 内可被回收的垃圾占整个堆大小的比例值,如果超过 5%,那么就会触发之后的多轮 Mixed GC)。-G1MixedGCCountTarget:设置 G1 收集器在一个周期内触发 Mixed GC 最大次数。默认值为 8。-Xss:线程栈内存大小。默认是是 1M。栈的大小决定了函数调用的最大深度,减小这个值意味着同样的内存空间可以生成更多的线程。

尽管 CMS 收集器在高版本 JDK 中已废弃,但目前仍有很多应用在使用它,故这里还是记录下 CMS 相关调优参数。

-XX:CMSInitiatingOccupancyFraction:设置当老年代使用率达到多少时将触发垃圾收集。默认值为 98%。该参数需要配合 UseCMSInitiatingOccupancyOnly 一起使用,单独设置无效。-XX:UseCMSInitiatingOccupancyOnly:该参数启用后,参数 CMSInitiatingOccupancyFraction才会生效。默认关闭。-XX:+UseCMSCompactAtFullCollection:在进行 FGC 时是否执行内存碎片整理。默认启用。(由于 CMS 垃圾收集器采用了“标记-清除”算法进行垃圾回收,因此它会产生大量碎片空间)-XX:CMSFullGCsBeforeCompaction:在触发多少次 FGC 后,对老年代进行碎片整理。 默认值为 0(即每次 FGC 后都会清理老年代碎片)-XX:+CMSParallelInitialMarkEnabled:启用并行的初始标记。默认启用。-XX:+CMSScavengeBeforeRemark:在重新标记阶段之前,先(尝试)执行一次 YGC。默认关闭。

调优建议

将 Xmx 设置为 FGC 之后老年代存活对象的 3-4 倍;或将 Xmx 设置为系统可用内存的 70%~80%(既可以充分利用系统内存,又避免了系统内存不足的情况)将年轻代大小设置为老年代存活对象的 1-1.5 倍,将老年代大小设置为老年代存活对象的 2-3 倍。(整个堆内存即为老年代存活对象的 3-4.5 倍,符合第一条建议)Sun 官方建议将年轻代大小设置为为整个堆内存的 3/8 左右。(基本符合前两条建议)将 Xms 设置成与 Xmx 相等。默认,空闲堆内存小于 40% 时,JVM 会扩大堆到 -Xmx 指定的大小;空闲堆内存大于 70% 时,JVM 会减小堆到 -Xms 指定的大小;如果在 Full GC 后满足不了内存需求会动态调整,这个阶段比较耗费资源。新生代尽量设置大一些,让对象在新生代多存活一段时间,每次 YGC 都要尽可能多的回收垃圾对象,防止或延迟对象进入老年代的机会,从而减少应用程序发生 FGC 的频率。如果老年代使用的是 CMS 收集器,新生代可以不用太大,因为 CMS 的并行收集速度也很快,收集过程比较耗时的并发标记和并发清除阶段都可以与用户线程并发执行。关于方法区(元空间)大小的设置,在 JDK7 之前需要考虑系统运行时动态增加的常量、静态变量等,在 JDK7 之后只需保证能装下启动时和后期动态加载的类信息就行。添加 JVM 参数,输出 GC 日志,以便后续分析。

-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -Xloggc:/data/jvm/gc.log

添加 JVM 参数,在发生内存溢出错误时自动生成堆转储文件,以便后续分析。

-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/jvm/dump.hprof

添加 JVM 参数,在 JVM 崩溃时自动输出错误日志,以便后续分析。

-XX:ErrorFile=/data/jvm/error.log

使用合适的数据类型

在编写 Java 程序时,选择合适的数据类型可以减少内存的使用。

例如,当需要存储一个整数时,可以使用 int 类型而不是 Integer 类型,因为 Integer 类型是一个对象,它会占用更多的内存。类似地,如果需要存储大量的浮点数,可以考虑使用 float 类型而不是 double 类型。

《阿里巴巴Java开发手册》关于基本数据类型与包装数据类型的使用标准:

【强制】所有的 POJO 类属性必须使用包装数据类型。【强制】RPC 方法的返回值和参数必须使用包装数据类型。【推荐】所有的局部变量使用基本数据类型。

说明:POJO 类属性没有初值是提醒使用者在需要使用时,必须自己显式地进行赋值,任何 NPE 问题,或者入库检查, 都由使用者来保证。

正例:数据库的查询结果可能是 null,因为自动拆箱,用基本数据类型接收有 NPE 风险。反例:某业务的交易报表上显示成交总额涨跌情况,即正负 x%,x 为基本数据类型,调用的 RPC 服务,调用不成功时, 返回的是默认值,页面显示为 0%,这是不合理的,应该显示成中划线。

所以包装数据类型的 null 值,能够表示额外的信息,如:远程调用失败,异常退出。

避免创建大量临时对象

频繁的内存分配和释放会增加垃圾收集的负担,影响程序的性能。可以通过对象池、缓存等技术来重用对象,减少内存分配的次数,从而降低垃圾收集的压力。

避免同时加载大量数据

如一次从数据库中取出大量数据,或者一次从 Excel 中读取大量记录,可以分批读取,用完尽快清空引用。

适当使用软引用、弱引用

在合适的场景(如实现缓存)采用软引用、弱引用,来保证在内存不足时,能释放一定的内存空间。

避免内存泄漏

内存泄漏是指程序在使用堆内存时,未能释放不再使用的对象或资源,导致这些对象或资源一直占据着内存空间,无法被垃圾收集器回收。随着内存泄漏的积累,程序所需的可用内存逐渐减少,最终可能导致系统性能下降、程序崩溃或者出现内存耗尽的情况。

以下是几种常见的内存泄漏情况:

对象引用未及时释放:当一个对象不再被使用时,如果其引用没有被正确地置为 null,那么该对象仍然存在于内存中,垃圾收集器无法回收它。这种情况通常发生在长时间运行的应用中,特别是在使用全局变量或静态变量的时候容易出现。集合类对象未正确管理:在使用集合类对象时,如果添加对象后未及时删除,或者持有对象引用的容器没有被正确地清空,就会导致这些对象一直存在于内存中,无法被回收。资源未释放:例如打开了文件、数据库连接、网络连接等资源,但在使用完毕后未主动关闭或释放,就会导致这些资源一直占据内存,无法被垃圾收集器回收。循环引用:当两个或多个对象之间存在循环引用,且这些对象都不再被使用时,由于彼此之间存在引用关系,导致它们无法被垃圾收集器回收。这种情况通常发生在对象之间存在相互引用的数据结构中,如链表、树等。缓存未清理:在使用缓存时,如果缓存的对象长时间不被访问,但仍然保留在内存中,就会导致内存泄漏。在使用缓存时,需要注意合理设置缓存的生命周期和清理策略,及时清理不再使用的缓存对象。

为避免内存泄漏,可以采取以下措施:

明确对象生命周期,及时释放不再使用的对象引用。确保集合类对象在使用完毕后进行适当的清空操作。确保资源使用后及时关闭或释放。避免循环引用的产生,可以使用弱引用等机制解决。合理管理缓存,设置适当的过期时间和清理策略。使用内存分析工具来检测和定位内存泄漏问题。

线程优化

减少线程创建和销毁的开销

线程的创建和销毁涉及到系统资源的申请和释放,因此频繁地创建和销毁线程会增加系统开销。可以采用线程池来重用线程对象,避免频繁创建和销毁的开销。

调整线程数量

过多的线程会导致资源竞争和上下文切换的开销,而过少的线程可能无法充分利用系统资源。通过对程序的需求进行评估和监测,合理设置线程池中线程的数量,使得线程数能够适应当前系统的负载情况。

避免线程间的竞争条件

竞争条件是指多个线程同时访问和修改共享数据时可能发生的问题。为了避免竞争条件,可以采用同步机制(如锁、信号量等)来保证对共享数据的互斥访问,或者使用并发容器(如 ConcurrentHashMap、ConcurrentLinkedQueue 等)来代替传统的同步容器。

减少线程的阻塞和等待时间

阻塞和等待会导致线程处于非运行状态,无法执行其他任务,浪费了系统资源。可以使用异步编程模型(如 WebFlux),将阻塞操作转换为非阻塞的异步操作,或使用异步 IO 等技术来减少线程的等待时间。

避免死锁和饥饿

死锁是指多个线程因为互相等待对方释放资源而无法继续执行的情况,饥饿则是指某个线程一直无法获得所需的资源。为了避免死锁和饥饿,需要仔细设计和管理线程间的依赖关系、资源分配策略以及锁的获取和释放顺序。

使用合适的并发工具

Java 提供了许多并发工具,如 CountDownLatch、CyclicBarrier、Semaphore 等,可以用于控制线程之间的同步和通信,通过合理使用这些工具可以简化并发编程,提高程序的可读性和可维护性。

使用轻量级的线程机制-协程

传统的线程模型在创建和切换线程时需要较大的开销,可以考虑使用如协程( 升级到 JDK21 或引入类库 Quasar)等,来减少线程的创建和切换开销。

参考资料

深入理解Java虚拟机:JVM高级特性与最佳实践编译器中常见的基础优化关键业务系统的JVM参数推荐(2018仲夏版)

柚子快报激活码778899分享:性能优化 - JVM 优化

http://yzkb.51969.com/

参考文章

评论可见,请评论后查看内容,谢谢!!!评论后请刷新页面。