Java 性能优化:技巧和技术

2025年1月7日 | 阅读10分钟

一般来说,Java 应用程序性能的提升是一个复杂的过程,包含各种任务和方法。因此,性能优化可以确保应用程序运行良好、资源高效利用并提供良好的用户体验。以下是与 Java 性能相关的不同主题列表。涵盖的主题包括性能剖析、数据结构、内存、多线程、I/O 优化等等。

1. 性能剖析与监控

性能剖析的重要性

首先,在不知道问题瓶颈的情况下,我们无法进行优化并开始增加价值。性能剖析可以定义为在计算机上运行应用程序时评估其不同特征的过程,例如 CPU 利用率、内存消耗以及特定方法花费的时间。如果没有识别出具体的性能剖析,优化措施可能就像试图磨利一把钝刀,而实际上需要的是一把新刀。

性能剖析工具

有几种工具可以帮助剖析和监控 Java 应用程序。

VisualVM:一款免费开源的 Java 应用程序软件监控、故障排除和性能剖析工具。它提供了 CPU 和内存剖析、垃圾回收器监控等功能。

JProfiler:一款商业工具,通过 CPU 和内存剖析、线程监控和遥测等选项,提供了更详细的系统剖析机会。它因其友好易用的界面以及分析的多样性而广受欢迎。

YourKit:另一款商业性能剖析工具 YourKit,包含 CPU 和内存剖析、线程剖析等功能。它支持多种 JVM 语言,并且有助于识别内存泄漏。

Java Mission Control (JMC):一套专业的 Java 应用程序性能剖析、监控和分析工具集。一些适用于高性能要求的新功能包括:Java Flight Recorder (JFR),它以最小的开销记录全面的性能数据。

性能剖析最佳实践

在代表性环境中进行剖析:确保通过模拟生产阶段的环境来准确衡量性能。

关注热点:首先,需要了解代码中哪些部分是耗时且资源密集型的,例如 CPU、内存或 I/O。

迭代剖析:在优化过程中,在线运行应用程序时监控修改的影响。

2. 高效的数据结构

一旦您确定在代码中使用数据结构是高效且最佳的,下一个挑战就是选择合适的数据结构。

每个 Java 应用程序的性能可能因所选数据结构的差异而有显著不同。存在各种数据结构,它们之间的差异在于效率,因此选择适合特定任务的数据结构至关重要。

数组 vs 列表

数组:更适合固定大小的集合,在代码开发过程中,其容量在未更改之前是固定的。数组提供单个元素的访问和常数时间访问,但不能调整大小。

ArrayList:一种具有特定 List 接口特征且大小可变的数据结构,类似于数组。它允许对大多数需要随机访问和动态调整大小的应用程序进行一次操作时间和插入的摊销一次操作时间。

LinkedList:这是使用双向链表数据结构实现的列表,实现了 List 接口。插入和删除操作为常数时间,而访问操作为线性时间。当需要在列表的开头或中间部分进行大量插入或删除时,它很合适。

HashMap vs TreeMap

HashMap:保证基本操作(如 get 和 put)的执行时间为常数。它理论上是无序的,并且支持一个键为 null。适合快速查找。

TreeMap:使用红黑树实现的 Map 接口。它为操作提供对数时间复杂度,并考虑键的顺序。如果需要排序的键顺序,则使用它,因为需要列表引用才能获得有序集合。

集合 (Sets)

HashSet:一种利用哈希表进行操作的数据结构,实现了 Set 接口。提供线性时间操作,并允许一个哨兵值。

TreeSet:基于 TreeMap 实现 NavigableSet 接口的类。它根据元素的自然顺序或特定的比较器对列表进行排序。

避免不必要的数据结构创建

通过应用减少不必要数据结构数量的技能,可以减少程序使用的内存量和处理时间。例如,如果集合只是被遍历以访问其元素,并且不需要其他元素,那么就可以避免使用链表,因为在这种情况下,简单的列表或数组更快。

3. 避免不必要的对象创建

对象创建的开销

创建对象会带来内存分配、构造函数调用、处理以及 GC 开销等问题。尽管当今的 JVM 在对象生成和清理方面效率很高,但对于性能而言,过多地创建对象并不总是合适的,尤其是在高服务器请求或内存占用小的环境中。

管理对象创建的措施

重用对象:不要总是创建新对象;相反,重用已创建的对象。例如,对于经常使用的项(如数据库连接或线程),可以使用对象池。

使用基本类型:默认应使用基本类型(int、double 等)而不是包装类(Integer、Double 等)。基本类型没有与对象创建和垃圾回收相关的额外开销。

使用对象池:如果对象创建成本高昂,例如数据库连接或线程池,则建议使用对象池。这有助于减少对象的创建和销毁,从而降低成本。

避免自动装箱/拆箱:特别注意自动装箱和自动拆箱等操作;它们会导致混乱和不必要对象的创建。例如,不要在循环或频繁调用的方法中使用装箱类型。

4. 字符串处理

高效的字符串操作

应注意,Java 中的字符串是对象,并且被用作不可变数据,因为对字符串的任何更改都会导致创建新字符串。此特性确保 Java 字符串在内存方面高效,但当对字符串执行许多操作(包括连接)时,这种不可变性会成为弱点。

使用 StringBuilder 和 StringBuffer

StringBuilder:一种可变字符序列。它不是同步的,因此在单线程操作时比 StringBuffer 快。应使用 java.util.StringBuilder 来连接字符串,因为它比 '+' 连接运算符性能更好。

StringBuffer:它与 StringBuilder 类似,但它是同步的,因此在多线程环境中可以使用。如果多个线程正在修改字符串缓冲区,则使用 StringBuffer。

不应在循环中使用 '+' 运算符进行字符串连接,因为每次迭代都会创建一个新的临时对象。相反,使用 StringBuilder 或 StringBuffer 更可取。

字符串的内部化 (Interning)

字符串内部化是一种只存储每个字符串值的副本的方法,该字符串值应该是不可变的。可以对字符串应用 intern() 方法,通过提供实例来帮助高效地使用内存空间。但要注意,频繁的内部化会使情况恶化;内部化字符串池的内存开销会增加。

5. 内存管理

垃圾回收优化

Java 中的内存管理是一个主要组成部分,其中通过垃圾回收 (GC) 来清理内存。虽然它实现了自动内存管理,但调整不当的 GC 可能导致严重的性能问题,例如长时间的暂停和高 CPU 使用率。

设计垃圾收集器时需要做出的基本决定是选择收集器算法。

各种 GC 旨在在不同环境中运行良好,一些用于分代收集,一些用于并发收集等。标准的 GC 算法包括:

Serial GC:一种易于实现且单线程的垃圾收集器。适用于内存占用不多的小型应用程序。

Parallel GC:一种吞吐量导向的多线程收集器。适用于可以暂时中断进程的情况。

G1GC(Garbage-First Garbage Collector):一种低延迟收集器,它使用基于区域的堆划分。适用于其他策略无法为最活跃线程提供足够工作集的应用程序。

ZGC(Z Garbage Collector):这是一款低延迟的垃圾收集器,旨在处理大堆。即使处理大堆,也需要将暂停时间保持在 10ms 以内。

调整 GC 参数

堆大小:使用 -Xms 指定堆的初始大小,使用 -Xmx 指定最大大小。确保堆大小等于应用程序所需的最佳大小,但不要过大,以免频繁发生完全 GC。

GC 日志记录:使用 -Xlog:gc 记录 GC 信息,以帮助监控 GC,特别是检测潜在问题。

减少内存泄漏

内存泄漏发生在不再需要的对象由于无法被回收而仍然被使用时。常见原因包括:

未关闭的资源:切勿忘记释放资源,如文件流、数据库连接和套接字。try-with-resources 语句有助于自动关闭资源。

静态引用:对于静态字段,要格外小心,因为它们可能会长时间持有对象。

事件监听器:当不再需要事件监听器时,请务必将其销毁。

可以使用 VisualVM、JProfiler 或 YourKit 等工具检测堆转储,并识别未释放的对象,从而导致内存泄漏。

6. 并发与多线程

高效使用线程

Java 非常好地支持多线程,这意味着可以同时执行多个任务。但如果使用不当,它会引入竞争和死锁,从而降低性能。

使用现代并发工具

java.util.concurrent 包提供了丰富的并发工具集,可以简化多线程编程。

Executor 框架:使用 ExecutorService 来运行工作线程池。它将在任务提交和任务执行之间建立隔离,从而有助于线程控制。

并发集合:使用 ConcurrentHashMap、CopyOnWriteArrayList 和 BlockingQueue 等并发数据结构,以确保并发访问期间的线程安全。

锁和同步器:为了更精细地控制并发和同步,应该使用 ReentrantLock、ReadWriteLock、Semaphore 和 CountDownLatch。

最小化同步开销

减少锁竞争:减少持有锁的时间。不要使用单个、大而粗粒度的锁。

使用非阻塞算法:对于简单的原子操作,建议使用无锁算法和数据结构,如 AtomicInteger 和 AtomicReference。

并行处理

对于可并行化的 CPU 密集型任务,Java 7 中有 Fork/Join 框架,Java 8 中有并行流。

Fork/Join 框架:允许将任务分解为子任务,并可能并发地完成它们。在可以分解为更小、相似子任务的任务中,它非常有效。

并行流:它有助于并行处理集合,其内部机制使用 fork/join 框架。对于 map-reduce 等处理,请使用并行流。

7. I/O 优化

文件和网络 I/O 效率

任何读取或写入文件或通过网络传输数据的输入/输出操作都可能对性能产生重大影响。优化这些活动可以提高应用程序的运行速度。

使用缓冲 I/O

文件 I/O 流通过内存(BufferedReader、BufferedWriter、BufferedInputStream、BufferedOutputStream)提供数据缓冲。这对于读取或写入具有许多文本行的文件(例如配置文件)很有益。

Java NIO 和 NIO.2

Java NIO(New I/O):Java 1.4 中引入的 NIO,提供了面向通道的操作,这些操作是非阻塞的 I/O 操作,并且可以提供高数据传输速率。其中一些是通道、选择器和缓冲区。

NIO.2:Java 7 中引入的新 I/O 不仅仅是旧 I/O 的替代品,它是 Java 最新版本中可用 I/O 的增强版本。Java 7.2 引入了异步 I/O 操作,这是一种执行非线程阻塞调用的更有效方式。它还包含了全面的文件和文件系统库。

减少磁盘和网络 I/O

缓存:存储常用数据,以便不必每次都从磁盘或网络检索。基于库的解决方案包括使用 Ehcache 或 Caffeine 等缓存库。

批量处理:将一系列 I/O 操作组织成一个大的操作,以尽量减少多次小 I/O 操作的成本。

结论

Java 性能优化是一个复杂的过程,始终包含五个步骤:

  • 了解应用程序的行为
  • 选择合适的数据结构
  • 减少资源消耗
  • 调整 JVM

关于 Java 性能剖析、内存使用、并发和输入/输出的决策,以及代码优化过程,可以极大地提高 Java 应用程序的性能。

优化的可能方式始终是通过性能剖析和监控来确定实际瓶颈。应逐步实施优化,并将其结果与观察结果进行比较,以查看其效益。

请记住,优化的成本始终是性能与复杂性/可维护性之间的权衡;因此,应始终关注那些具有最高响应/影响因子,同时对代码的清晰度和可维护性影响最小的优化。