解决常见Java性能问题的6个技巧

虽然 Java 的优势在于其平台独立性、强大的库和广泛的生态系统,但解决性能问题是充分利用其功能的关键。

1.内存泄漏
有人会问,既然 Java 通过垃圾回收器实现了自动内存管理,这怎么可能呢?的确,Java 的垃圾回收器是一个功能强大的工具,可自动处理内存分配和删除,减轻了我们手动分配和删除内存的负担。  但是,仅仅依靠自动内存管理并不能保证不面临性能挑战。

Java 中的垃圾回收器(GC)可以自动识别和回收不再使用的内存,这是该语言强大内存管理系统的一个重要方面。

然而,即使有了这些先进的机制,即使是最熟练的程序员也仍有可能遇到并无意中引入 Java 内存泄漏。当对象无意中保留在内存中,导致垃圾回收器无法回收相关内存时,就会发生内存泄漏。随着时间的推移,这会导致内存消耗增加并降低应用程序的性能。

内存泄漏的检测和解决非常棘手,这主要是因为它们的症状相互重叠。在我们的案例中,这是最明显的症状:堆出错(OutOfMemoryError heap error),随之而来的是一段时间内的性能下降。

导致 Java 内存泄漏的问题有很多。我们的第一种方法是通过分析内存不足错误消息来确定这是正常的内存耗尽(由于设计不当)还是泄漏。

我们首先检查了最可能的罪魁祸首,静态字段、集合和声明为静态的大型对象,它们可能会在应用程序的整个生命周期中阻塞重要内存。  

例如,在下面的代码示例中,在初始化列表时移除静态关键字就能大大减少内存使用量。

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() 方法。下面是一个正确实现 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)来分析和识别应用程序中潜在的内存泄漏。
  • 使用单例时,使用懒加载而不是急迫加载,以避免在实际需要单例之前进行不必要的资源分配。
  • 如果您的代码使用文件句柄、数据库连接或网络套接字等外部资源,请确保在不再需要这些资源时明确释放它们。


2.线程死锁
Java 是一种多线程语言。这是使 Java 成为一种合适的编程语言的特性之一,尤其适用于开发并发处理多个任务的企业应用程序。

顾名思义,多线程涉及多个线程,每个线程都是最小的执行单元。线程是独立的,有单独的执行路径,因此其中一个线程出现异常不会影响其他线程。  

但是,如果线程试图同时访问相同的资源(锁),会发生什么情况呢?这时就会出现死锁。我在合作开发实时金融数据处理系统时就有过这样的经历。在这个项目中,我们有多个线程负责从外部 API 获取数据、执行复杂的计算以及更新共享的内存数据库。

随着该工具使用量的增加,我们开始偶尔接到一些关于偶尔冻结的报告。线程转储显示,某些线程陷入了等待状态,形成了对锁的循环依赖。

在这个例子中,我们有两个线程(线程 1 和线程 2)试图以不同的顺序获取两个锁(锁 1 和锁 2)。这就引入了循环等待,增加了死锁的可能性。

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");
               
// Introducing a delay to increase the likelihood of deadlock
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println(
"Thread 1: Holding lock 1 and lock 2");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock2) {
                System.out.println(
"Thread 2: Holding lock 2");
               
// Introducing a delay to increase the likelihood of deadlock
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock1) {
                    System.out.println(
"Thread 2: Holding lock 2 and lock 1");
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

为了解决这个问题,我们可以重构代码,确保线程始终以一致的顺序获取锁。为此,我们可以引入全局锁顺序,并确保所有线程都遵循相同的顺序。

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");
               
// Introducing a delay to increase the likelihood of deadlock
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println(
"Thread 1: Holding lock 2");
                }
            }
        });
        Thread thread2 = new Thread(() -> {
            synchronized (lock1) {
                System.out.println(
"Thread 2: Holding lock 1");
               
// Introducing a delay to increase the likelihood of deadlock
                try {
                    Thread.sleep(100);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                synchronized (lock2) {
                    System.out.println(
"Thread 2: Holding lock 2");
                }
            }
        });
        thread1.start();
        thread2.start();
    }
}

防止线程死锁的技巧

  • 锁排序 - 确保所有线程在获取锁时遵循相同的顺序,以防止循环等待。
  • 实施锁超时 - 如果线程无法在指定时间内获取锁,则释放所有获取的锁并重试。
  • 避免嵌套锁定 - 避免在关键部分获取锁。
  • 防止线程死锁的技巧。
  • 锁排序 - 确保所有线程在获取锁时遵循相同的顺序,以防止循环等待。
  • 实施锁超时 - 如果线程无法在指定时间内获取锁,则释放所有获取的锁并重试。
  • 避免嵌套锁定 - 避免在关键部分获取锁,因为其他锁已被锁定。嵌套锁会增加死锁的风险。

3.过度的垃圾回收
Java 中的垃圾回收就像是为我们管理内存的幕后英雄。它会自动清理不再需要的对象,让我们的开发生活变得轻松许多。  虽然这种自动垃圾回收为开发人员提供了方便,但它可能会以占用垃圾回收的 CPU 周期为代价,从而影响应用程序的性能。

除了典型的内存不足错误外,您还可能偶尔遇到应用程序冻结、延迟或应用程序崩溃。如果您使用的是云计算,优化垃圾回收过程可以为您节省大量计算成本。一个案例是,一家名为 Uber 的公司通过使用高效、低风险、大规模、半自动化的 Go 垃圾回收调整机制,为 30 个关键任务服务节省了 7 万个核。

防止过度垃圾回收的技巧

  • 日志分析和调整 - 确定垃圾回收周期过长或暂停时间过长等模式。
  • 评估并切换不同的垃圾回收算法。考虑 JDK 算法,如串行、并行、G1、Z GC 等。
  • 根据应用程序的工作量和性能特点选择算法。切换到更合适的 GC 算法可以减少 CPU 消耗。
  • 优化代码,减少创建过多对象。您可以使用 HeapHero 或 YourKit 等内存剖析工具来识别产生过多对象的区域。实施对象池,重复使用对象并减少分配开销。
  • 修改堆大小,以影响垃圾回收过程中的 CPU 消耗。考虑增加堆大小,以减少收集周期的频率,或者为内存占用较少的应用程序减少堆大小。
  • 如果在云上运行,可考虑在多个实例之间分配工作负载。您可以增加容器实例或 EC2 实例的数量,以便更好地利用资源,减轻单个实例的压力。

4.臃肿的库和依赖关系
Maven 和 Gradle 等构建工具彻底改变了我们管理 Java 项目依赖关系的方式。这些工具提供了包含外部库的精简方式,并简化了项目配置过程。然而,在带来便利的同时,臃肿的库和依赖关系也带来了风险。

由于错误修复、新功能和新依赖关系的出现,软件项目有快速增长的趋势。有时,项目的发展会超出我们作为开发人员的能力范围,无法对其进行有效维护。 它们还会带来安全漏洞和额外的性能开销。

如果您遇到这种情况,我建议您研究如何将应用程序中未使用的依赖项和库去掉,以此作为补救措施之一。

在 Java 生态系统中,我发现有几种工具可用于管理依赖关系。其中最常见的包括 Maven 依赖性插件和 Gradle 依赖性分析插件,它们在检测未使用的依赖性、已使用的传递依赖性(您可能希望直接声明)以及在错误配置(API vs 实现 vs 仅编译等)上声明的依赖性方面表现出色。

您还可以利用 Sonarqube 和 JArchitect 等其他工具。一些现代集成开发环境(如 Intellij)也有不错的依赖关系分析功能。

防止 Java 依赖关系臃肿的技巧

  • 依赖审核 -  定期进行审核,以识别未使用或过时的库。Maven 依赖性插件或 Gradle 的 dependencyInsight 等工具可协助进行依赖性分析。
  • 版本控制 - 保持依赖关系的最新状态。利用版本控制系统跟踪依赖关系的变化,并系统地管理更新。
  • 依赖范围 - 有效利用依赖范围(编译、运行时、测试等)。尽量减少编译范围中的依赖项数量,以缩小最终成果的大小。

5.低效代码
没有哪个开发人员会故意编写低效或次优代码。然而,尽管用心良苦,但由于各种原因,低效代码还是会出现在生产中。这可能是由于项目期限紧迫、对底层技术的理解有限,或者不断变化的需求迫使开发人员优先考虑功能而不是优化。

低效:

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();


低效代码会导致内存消耗增加、响应时间延长以及系统整体效率降低。最终,低效代码会降低用户体验,增加运营成本,并限制应用程序的扩展以应对负载的增加。

摆脱低效代码首先要识别表明低效的模式。我经常注意的一些模式包括:没有适当退出条件的嵌套循环、不必要的对象创建和实例化、过度同步、低效数据库查询等。

  • 帮助您编写高效 Java 代码的技巧
  • 重构和模块化代码,避免不必要的重复
  • 优化 I/O 操作 - 使用异步或并行 I/O 操作,防止阻塞主线程。
  • 避免创建不必要的对象,尤其是在代码中性能关键的部分。尽可能重复使用对象,并考虑使用对象池技术。
  • 在创建字符串时,使用 StringBuilder 而不是使用 + 运算符来连接字符串。这样可以避免创建不必要的对象。
  • 使用高效算法和数据结构 - 选择适合任务的算法和数据结构,以确保最佳性能。

6.并发问题
当多个线程同时访问共享资源时,就会出现并发问题,往往会导致意想不到的行为。

如果你已经在编码游戏中摸爬滚打了一段时间,那么你很可能会在整个开发周期中遇到新出现问题的困扰。发现并有效解决这些问题确实是一项挑战。

老实说,如果不能清楚地了解实际性能,这些问题往往会挥之不去,困扰着您的应用程序。在处理复杂的分布式系统时,这种困难甚至更为现实。如果没有适当的洞察力,就很难做出明智的设计决策或评估代码更改的影响。

帮助防止 Java 并发问题的技巧

  • 使用原子变量--java.util.concurrent.atomic 包提供了 AtomicInteger 和 AtomicLong 等类,无需显式同步即可实现原子操作。
  • 避免共享易变对象 - 尽可能将类设计成不可变的,这样就不需要同步并确保线程安全。
  • 尽量减少锁竞争 - 为尽量减少锁竞争,可使用细粒度锁或锁条带等技术来减少对同一锁的竞争。
  • 利用同步关键字创建同步代码块或方法,确保一次只能有一个线程访问同步代码块。
  • 使用线程安全数据结构--Java 的 java.util.concurrent 包提供了线程安全数据结构,如 ConcurrentHashMap、CopyOnWriteArrayList 和 BlockingQueue,无需额外的同步即可处理并发访问。

总结
希望这次讨论能给您带来启发。切记不要成为过早优化的牺牲品。同样重要的是要认识到,并非所有代码部分都对应用程序的性能有同样的影响。关键在于识别并优先处理对性能有重大影响的关键区域。