【CSDN 编者按】文章分享了作者在使用 Digma 工具和其他方法优化一个 Java 项目的性能问题时的经验和技巧。文章介绍了六种常见的 Java 性能问题,分别是内存泄漏、线程死锁、过度垃圾回收、依赖库过多、代码效率低下和并发问题,并提供了相应的解决方案和建议。文章的目的是帮助 Java 开发者提高代码的性能和质量。
过去一年,我们的项目规模显著增长。虽然项目增长本是好事,但随着规模扩大,我们面临的性能问题也日益严重。我们遇到的挑战包括资源利用的瓶颈、偶发的内存泄漏以及数据库访问慢等问题。为了与 Java 社区分享我们的学习经验并贡献我们的知识,我整理了这篇文章,总结出一些有价值的建议和洞察。我使用了 Digma(持续监测)等工具来监测并解决扩展过程中出现的性能问题。你也可以使用 JMH 和性能分析器等工具来测量性能、发现性能热点,并对它们进行优化。我相信,通过分享这些经验,我们能帮助他人更轻松地应对类似的挑战。虽然Java是一种优秀的编程语言,作为Java开发者,我们也不可避免地会遇到一些特殊挑战。重要的是要认识到,这些挑战并不降低 Java 的价值,而是突显了作为开发者需要警觉潜在问题的重要性。这些挑战通常体现为性能问题,需要细心考虑和采取主动优化策略。尽管 Java 的优势在于其平台独立性、健壮的库和广泛的生态系统,但解决性能问题才能充分利用这些优势。性能优化不是一劳永逸的过程,而是从开发开始、经过测试和部署,并在应用的整个生命周期中持续进行的过程。以下是 Microsoft 首席工程组经理( Java 和 Golang) Martijn Verburg 在 Reddit 上分享的 Java 应用性能优化经验。采用特定的性能诊断方法,如 Kirk Pepperdine 的 Java 性能诊断模型、Brendan Gregg 的 USE 方法或 Monica Beckwith 的自上而下(Top-Down)方法。如果没有结构化的方法,优化工作可能缺乏方向。明确设定性能目标,如在标准 DS v4 VM 上实现每秒至少 1000 个事务,且 P999 的延迟不超过 500 毫秒。为每个阶段设置一个时间限制或预算。例如,了解往返时间的细分,如 JavaScript 约 200ms,JVM 约 400ms,数据库约 300ms。引入可观测性以深入洞察。运用数学和统计学技术来细致理解性能指标,包括 P99 延迟曲线、基线、抽样和平滑技术。熟悉整个技术栈的每一层,包括 JVM、CPU、内存、操作系统、虚拟机监控器、Docker、Kubernetes等之间的交互方式,并理解每一层如何影响到下一层的关键性能指标。JVM 理解 加深对 JVM 内部机制的了解,包括垃圾收集(GC)、即时编译(JIT)、Java 内存模型(JMM)等关键概念。随着该领域的不断发展,保持对工具和策略的最新了解。探索负载测试、时间箱的可观测性工具、资源监控和诊断工具等。利用 JFR/JMC、GCToolkit等工具进行有效的性能分析。虽然 Java 的垃圾收集机制提供了自动内存管理,但仍可能发生内存泄漏。Java 的垃圾收集器(GC)是一种强大的工具,旨在自动完成内存的分配与回收工作,减轻了程序员手动管理内存的负担。然而,完全依赖于自动内存管理系统并不能完全避免性能问题的出现。Java 垃圾收集器能够自动识别并回收无用的内存,这是 Java 内存管理关键优势特性之一。即便如此,由于某些高级功能的存在,即使是经验丰富的程序员也可能不小心引入内存泄漏。内存泄漏指的是当对象被无意间保留在内存中时,妨碍了垃圾收集器对这些内存的回收。随着时间推移,这会导致内存占用不断增加,进而影响应用程序的性能。内存泄漏的检测和解决可能因为其表现出的症状相似而变得困难。在我们的例子中,最直观的表现是出现**OutOfMemoryError** 堆错误,其次是性能逐渐下降。引起 Java 内存泄漏的原因多种多样,识别问题的第一步是分析内存溢出错误,判断是由于设计不良导致的内存不足,还是真正的内存泄漏。我们首先关注可能的主要原因,例如静态变量、集合以及声明为静态的大对象,它们可能在应用程序的整个生命周期内占用重要的内存资源。比如,在以下代码示例中,移除静态列表初始化时的 static 关键词能显著降低内存的占用。public class StaticFieldsMemoryTestExample {
public static List<Double> list = new ArrayList<>();
public void addToList() {
for (int i = 0; i < 10000000; i++) {
list.add(Math.random());
}
Log.info("Debug Point 2");
}
public static void main(String[] args) {
new StaticFieldsDemo().addToList();
}
}
我们还会采取其他措施,比如检查可能阻碍内存回收的未释放的资源或连接。另外,通过正确重写 equals() 和 hashCode() 方法,尤其是在使用 HashMaps 和 HashSets时,可以解决方法实现不当的问题。下面是一个正确实现 equals() 和 hashCode() 方法的例子。public class Person {
public String name;
public Person(String name) {
this.name = name;
}
@Override
public boolean equals(Object o) {
if (o == this) return true;
if (!(o instanceof Person)) {
return false;
}
Person person = (Person) o;
return person.name.equals(name);
}
@Override
public int hashCode() {
int result = 17;
result = 31 * result + name.hashCode();
return result;
}
}
还需注意,内部类对象会隐式持有对其外部类对象的引用,这可能导致内部类对象不在垃圾收集器的回收范围内。- 当你的代码中涉及外部资源使用,例如文件句柄、数据库连接或网络套接字时,务必在这些资源不再被需要时,明确地进行释放。
- 利用内存分析工具,比如 VisualVM 或 YourKit,对你的应用程序进行分析,以便发现和定位潜在的内存泄漏问题。
- 在实现单例模式时,推荐采用懒加载方式而非饿汉式加载,这样可以防止在单例实际被需要之前,进行不必要的资源分配。
- 当代码中使用到外部资源时,如文件句柄、数据库连接或网络套接字,确保在不需要时明确释放这些资源,以避免内存泄漏。
Java 是一种支持多线程编程的语言。这一特性使得它可以开发能并行处理多任务的企业级应用程序。多线程编程意味着程序会同时运行多个执行线程。每个线程作为独立的执行单元拥有自己的执行路径,所以一个线程的异常不会直接影响其他线程。然而,当多个线程尝试同时访问同一资源(或锁)时,会发生什么问题呢?这种情况下,很可能会出现死锁。我在参与开发一个实时金融数据处理系统的过程中就遇到过死锁问题。该项目中,我们设计了多个线程来从外部API获取数据、执行复杂的计算以及更新共享的内存数据库。随着该工具使用频率的增加,我们开始接收到系统偶尔冻结的报告。通过线程堆栈分析,发现一些线程处于等待状态,形成了锁之间的循环依赖问题。在以下示例中,我们展示了两个线程(thread1 和 thread2)尝试按不同顺序获取两个锁(lock1 和 lock2)的情况。这种做法导致了循环等待的问题,从而增加了发生死锁的风险。public class DeadlockExample {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1");
为了应对死锁问题,我们可以通过重构代码来确保线程在获取锁时始终保持一致的顺序。这可以通过对锁进行全局顺序并确保所有线程按照这个顺序来获取锁实现。public class DeadlockSolution {
private static final Object lock1 = new Object();
private static final Object lock2 = new Object();
public static void main(String[] args) {
Thread thread1 = new Thread(() -> {
synchronized (lock1) {
System.out.println("Thread 1: Holding lock 1");
- 锁排序 — 通过确保所有线程在尝试获取多个锁时遵循相同的顺序,从而避免循环等待的发生。
- 实现锁超时 — 在尝试获取锁时引入超时机制。如果线程在指定的时间内无法获取到锁,那么它应该释放它已经持有的所有锁并重试,以减少死锁的风险。
- 避免嵌套锁 — 尽量避免在持有其他锁的情况下再去获取新的锁。嵌套锁定会增加发生死锁的可能性,因为它增加了多个锁同时被不同线程请求的复杂度。
在 Java 中,垃圾回收机制扮演着不可或缺的角色,自动管理着内存的分配与回收。这一机制大大简化了开发者的工作,因为它自动地清理了那些不再被需要的对象。虽然垃圾回收极大地方便了开发者,但它消耗的 CPU 周期可能对应用程序性能产生负面影响。除了常见的内存溢出问题外,过度的垃圾回收还可能导致应用程序偶尔冻结、响应延迟或甚至崩溃。特别是在使用云服务时,优化垃圾回收过程可以显著节约计算成本。例如,Uber 公司通过实施一种高效、低风险、大规模、半自动化的 Go 垃圾回收调优策略,在其 30 个关键服务上节约了 70K 核心资源。- 日志分析与调优 —— 分析日志以识别垃圾回收过程中出现的模式,例如完整的垃圾回收周期或较长的暂停时间。
- 选择合适的垃圾回收算法 —— 根据你的应用需求,在不同的垃圾回收算法之间做出选择,比如 Serial、Parallel、G1、Z GC 等。根据应用的工作负载和性能需求选择最合适的算法,可以有效减少 CPU 的消耗。
- 代码优化减少对象创建 —— 通过使用内存分析工具,如 HeapHero 或 YourKit ,识别出产生大量对象的代码区域,并对其进行优化。实现对象池可以复用对象,从而减少对象的频繁创建和分配开销。
- 调整堆大小 —— 通过修改堆的大小来调整垃圾回收对 CPU 的消耗。根据应用的内存需求,适当增加堆的大小可以降低垃圾回收频率,或者对于内存使用量较小的应用,减少堆的大小以节约资源。
- 在云环境中分散工作负载 —— 如果应用运行在云平台上,可以考虑通过增加容器或 EC2 实例的数量来分散工作负载。这样不仅可以更有效地利用资源,还可以减轻单个实例上的负担。
随着构建工具如 Maven 和 Gradle 的发展,Java 项目中管理依赖变得更加简单。这些工具不仅使得引入或使用外部库变得轻而易举,还简化了项目配置的过程。然而,这种便捷性也带来了库和依赖项臃肿的风险。2021 年的一项研究揭露,随应用程序编译代码一起打包的依赖项实际上并非构建和运行应用程序所必需的。这篇论文的详细内容可以点击这里查看。随着软件项目为了修复错误、添加新功能和新依赖项而迅速扩张,项目可能会变得难以控制,超出了我们作为开发者的有效维护范围。这种臃肿不仅可能引入安全漏洞,还会增加额外的性能开销。面对这种情况,一个解决方案是从你的应用程序中移除未使用的依赖项和库。Java 生态系统中有几种工具可以帮助管理依赖项,其中一些最常用的包括 Maven 依赖插件和 Gradle 依赖分析插件,这些工具擅长检测未使用的依赖项、传递依赖项(你可能想直接声明的那些)以及错误配置的依赖项(例如 API 对比实现,或是 compileOnly)。其他工具,如 Sonarqube 和 JArchitect,以及一些现代 IDE(例如 IntelliJ),也具备优秀的依赖分析功能。- 依赖审计 —— 定期执行依赖审计,以识别项目中未使用或过时的库。利用工具如 Maven 依赖插件或 Gradle 的 dependencyInsight 进行依赖分析,有助于审计过程。
- 版本控制 —— 保持依赖项的最新状态,使用版本控制系统来跟踪依赖项变化,并有系统地管理它们的更新。
- 依赖范围管理 —— 有效地使用依赖范围(如编译、运行时、测试等),尽量减少编译范围内的依赖项数量,以降低最终产品的大小,从而避免臃肿。
我们都知道,开发者从未故意编写低效或劣质代码。但是,由于项目截止时间的压力、对底层技术的理解不足,以及需求不断变化等原因,都会导致低质量的代码流到生产环境。这些都可能迫使开发者把实现功能置于性能优化之前。String result = "";
for (int i = 0; i < 1000; i++) {
result += "example";
}
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
sb.append("example");
}
String result = sb.toString();
低效代码的存在可能导致内存使用增加、响应时间变长,进而影响系统的整体效率。最终,这将降低用户体验,增加运维成本,并限制应用程序在处理更大负载时的扩展性。要摆脱低效代码,首先需要识别出那些标志性的低效模式,比如没有适当终止条件的嵌套循环、不必要的对象创建和实例化、过度同步以及低效的数据库查询等。- 重构和模块化代码:避免不必要的代码重复,使代码更加清晰高效。
- 优化 I/O 操作:采用异步或并行 I/O 操作,避免阻塞主线程,提升性能。
- 避免不必要的对象创建:在关键性能部分避免创建不必要的对象。尽可能复用对象,并考虑采用对象池技术。
- 使用 StringBuilder 构建字符串:相比于使用+操作符连接字符串,StringBuilder 可以避免额外的对象创建。
选用高效的算法和数据结构:根据具体任务选择最合适的算法和数据结构,确保性能最优。当多个线程同时操作共享资源时,就可能会引发并发问题,导致程序出现意外行为。如果你有丰富的编程经验,可能已经深刻体会到在开发过程中遇到问题所带来的挫败感。而准确地定位并有效解决这些问题无疑是一项挑战。坦白说,如果你对程序的实际性能缺乏深入了解,这些问题可能会持续影响你的应用程序。尤其是在处理复杂的分布式系统时,这一挑战更加明显。在没有足够洞察力的情况下,很难做出合理的设计选择或评估代码更改的影响。这正是持续反馈发挥其重要作用的场景。Digma 插件 在此扮演了关键角色,它使我们能够在开发周期中持续发现问题,并提供实时的、有价值的洞察。不过,我们此处不深入讨论背后的技术细节。我们来看看我们实际上做了什么。最近,我们发现后端服务中出现了一些意外行为,并遇到了性能瓶颈。我们采取的措施是什么呢?我们决定测试 Digma,以找出问题并确定其根本原因。在传统情况下,这种调查可能需要列出待办事项、进行优先级排序、分配任务,并希望它们不会因其他紧急事项而被推延。幸运的是,借助 Digma 提供的可观测性数据,我们能够得到非常具体的分析,明确我们面临的并发问题。Digma 的分析不仅帮助我们识别问题,还通过准确指出问题扩散的根本原因,帮助我们解决问题。如图示的橙色线条所表示,根本原因与总执行时间的并行运行相关联,对应于处理特定端点请求时触发的查询调用。这两条线显示出相似的模式,表明该查询存在问题,进而影响了端点。在确定了根本原因后,我们审查了相关的跨度,并发现了一个效率低下的查询执行计划。我们通过重构查询并添加所缺失的索引来解决了潜在的问题,从而迅速解决了性能瓶颈问题。- 采用原子变量:通过 java.util.concurrent.atomic 包中的类,如 AtomicInteger 和 AtomicLong,可以在无需显式同步的情况下执行原子操作。
- 避免共享可变状态:设计不可变的类,以消除对同步的需求,确保线程安全。
- 最小化锁争用:通过细粒度锁定或锁分段等技术,减少对同一把锁的竞争,从而降低锁争用。
- 使用 **synchronized**** 关键字**:通过 synchronized 关键字创建同步块或方法,保证同一时间只有一个线程能访问该代码块。
- 使用线程安全的数据结构:利用 java.util.concurrent 包提供的线程安全数据结构,如 ConcurrentHashMap、CopyOnWriteArrayList 和 BlockingQueue,处理并发访问,无需额外的同步措施。
希望本文对你有所帮助。同时,你也需要注意不要过早地进行优化。在性能优化时,需要识别和优先处理对性能有显著影响的关键代码。Digma 在此方面表现卓越,它通过运行时数据分析,帮助你定位对系统扩展能力影响最大的代码部分,从而让你的优化更加有目标,减少盲目优化。优化 Java 应用对于提高性能、减少资源消耗以及提升用户体验至关重要。它确保了系统资源的高效利用,并提高了应用的扩展性。通过跟踪 CPU 使用率、内存消耗和响应时间等关键性能指标,监控提供了应用程序健康状况的即时视图。它帮助及时识别性能瓶颈和潜在的改进点,从而有效提升 Java 应用的性能。监控哪些关键性能指标对于 Java 应用来说重要?对 Java 应用而言,CPU 使用率、内存占用、垃圾回收活动、响应时间、错误率和吞吐量等均为关键性能指标。密切监控这些指标能够帮助开发者主动识别并解决性能问题。如何最大程度减少 I/O 操作对 Java 应用性能的影响?通过实现异步 I/O 操作避免主线程阻塞,采用高效的文件处理和数据库连接管理策略,以及并行处理大数据集,可以显著减少 I/O 操作对 Java 应用性能的不良影响。