Java-高性能指南-全-

Java 高性能指南(全)

原文:zh.annas-archive.org/md5/d89ea010aae6dd65d369a7ed809545e5

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

Java 仍然是世界上最受欢迎和强大的编程语言之一。自从其诞生以来,它赋予了开发者创建跨各种领域的强大、高性能应用程序的能力。本书,《Java 高性能》,深入探讨了优化 Java 应用程序以实现最佳性能的复杂细节。从理解 Java 虚拟机JVM)的基本原理到利用高级分析工具,本书是任何希望提高其 Java 开发技能并交付高性能解决方案的人的全面指南。

本书面向的对象

本书旨在为对语言有基础理解的 Java 开发者提供帮助,他们希望深化对性能优化的知识。无论您是经验丰富的开发者还是中级程序员,本书都可以提供宝贵的见解和实用的技术,以改善您的 Java 应用程序的性能。假设您对 Java 开发工具和 Java 开发工具包JDK)有基本的了解。

本书涵盖的内容

第一章,探索 Java 虚拟机,深入探讨了 JVM,包括垃圾回收和 即时JIT)编译器优化,以最大化应用程序性能。

第二章,数据结构,让您了解不同数据结构对性能的影响,以及如何为您的应用程序选择和实现最有效的数据结构。

第三章,优化循环,涵盖了优化编程中的基本结构——循环的技术,以避免瓶颈并提高运行时性能。

第四章,Java 对象池,深入探讨了 Java 中的对象池概念,以及如何在您的 Java 应用程序中使用它们来实现高性能。

第五章,算法效率,帮助您了解如何为任何特定需求选择正确的算法。

第六章,策略性对象创建和不可变性,提供了关于如何使对象不可变的信息和示例,并解释了为什么您应该考虑这一点。

第七章,字符串对象,专注于在 Java 应用程序中高效使用字符串对象。

第八章,内存泄漏,提供了避免内存泄漏的技术、设计模式、编码示例和最佳实践。

第九章,并发策略和模型,提供了关于并发概念的基础信息,并提供了在 Java 程序中实现并发的实际机会。

第十章连接池,介绍了连接池的概念,提供了基本原理、实现方法和示例。

第十一章超文本传输协议,介绍了在网络上进行信息交换的基础协议。

第十二章优化框架,提供了对关键优化框架的基础理解。

第十三章性能导向库,深入探讨了 JMH、Netty 和 Jackson 等库,以及它们如何用于提高 Java 应用程序的性能。

第十四章剖析工具,探讨了各种剖析工具,包括 JDK 捆绑的工具和额外的工具,以监控和改进 Java 应用程序的性能。

第十五章使用 SQL 查询优化数据库,展示了如何优化 SQL 查询和管理数据库以确保高性能。

第十六章代码监控和维护,探讨了代码监控和维护的重要性,包括使用应用性能管理APM)工具、代码审查和日志记录。

第十七章单元和性能测试,涵盖了确保应用程序满足性能要求的单元和性能测试策略。

第十八章利用人工智能(AI)提高高性能 Java 应用程序的性能,探讨了如何将 AI 集成到 Java 应用程序中以提高性能,包括预测、预测和内容预处理。

为了充分利用本书

为了充分利用本书的内容,读者应具备扎实的 Java 编程基础知识,并熟悉开发工具,如集成开发环境IDEs)和版本控制系统。熟悉性能监控和剖析概念将具有优势。本书假设读者有深入了解 Java 性能调优的复杂性并愿意尝试不同的技术以获得最佳结果的愿望。

本书涵盖的软件/硬件 操作系统要求
Java 21 Windows、macOS 或 Linux
JDK 21.0.1
IDE

本书包含库和框架,其安装是可选的。

如果您正在使用本书的数字版,我们建议您亲自输入代码或从本书的 GitHub 仓库(下一节中提供链接)获取代码。这样做将有助于您避免与代码的复制和粘贴相关的任何潜在错误。

下载示例代码文件

您可以从 GitHub 下载本书的示例代码文件 github.com/PacktPublishing/High-Performance-with-Java。如果代码有更新,它将在 GitHub 仓库中更新。

我们还有其他来自我们丰富图书和视频目录的代码包,可在 github.com/PacktPublishing/ 获取。查看它们!

使用的约定

本书使用了多种文本约定。

文本中的代码:表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“正如您在以下内容中可以看到的,我们可以使用javap命令而不需要任何参数。”

代码块应如下设置:

public class CH1EX1 {
  public CH1EX1 { 
  public static void main(java.lang.String[]);
  }
}

当我们希望将您的注意力引到代码块的一个特定部分时,相关的行或项目将以粗体显示:

public class Example2 {
  public static void main(String[] args) {
    List<String> petNames = new LinkedList<>();

任何命令行输入或输出都应如下编写:

$ cd src
$ ls
CH1EX1.java
$

小贴士或重要提示

它看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈:如果您对本书的任何方面有疑问,请通过电子邮件发送至 customercare@packtpub.com,并在邮件主题中提及书名。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问 www.packtpub.com/support/errata 并填写表格。

盗版:如果您在互联网上以任何形式发现我们作品的非法副本,如果您能提供位置地址或网站名称,我们将不胜感激。请通过电子邮件发送至 copyright@packt.com 并附上材料的链接。

如果您有兴趣成为作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问 authors.packtpub.com

分享您的想法

一旦您阅读了《Java 高性能编程》,我们很乐意听到您的想法!请点击此处直接进入本书的亚马逊评论页面 并分享您的反馈。

您的评论对我们和科技社区都很重要,并将帮助我们确保我们提供高质量的内容。

下载本书的免费 PDF 副本

感谢您购买本书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?

您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 书籍,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何地点、任何设备上阅读。直接从您最喜欢的技术书籍中搜索、复制和粘贴代码到您的应用程序中。

优惠不仅限于此,您还可以每天在您的收件箱中独家获得折扣、时事通讯和丰富的免费内容

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/978-1-83546-973-6

  1. 提交您的购买证明

  2. 就这样!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件

第一部分:代码优化

本部分深入探讨了优化 Java 代码的核心技术和策略。它从对Java 虚拟机JVM)及其对性能的影响的深入探讨开始。您将了解数据结构的有效使用、优化循环的技术、Java 对象池的好处以及提高算法效率的策略。通过掌握这些基本概念,您将显著提高您的 Java 应用程序的性能。

本部分包含以下章节:

  • 第一章, 探索 Java 虚拟机(JVM)内部

  • 第二章, 数据结构

  • 第三章, 优化循环

  • 第四章, Java 对象池

  • 第五章, 算法效率

第一章:探秘 Java 虚拟机

我将向您介绍一项革命性的技术,它帮助改变了软件行业。认识一下Java 虚拟机JVM)。好吧,你可能已经对 JVM 很熟悉了,了解并欣赏它在编译 Java 字节码和几乎无限数量的硬件平台之间的中间件所具有的巨大价值是非常重要的。Java 和 JVM 的巧妙设计是它广受欢迎和用户、开发者价值的证明。

自从 20 世纪 90 年代 Java 的第一个版本发布以来,JVM 一直是 Java 编程语言真正的成功因素。有了 Java 和 JVM,“一次编写,到处运行”的概念应运而生。Java 开发者可以编写一次程序,并允许 JVM 确保代码在安装了 JVM 的设备上运行。JVM 还使 Java 成为一种平台无关的语言。本章的主要目标是更深入地了解 Java 的默默无闻的英雄——JVM。

在本章中,我们将深入探讨 JVM,以便我们能够学会如何充分利用它,从而提高我们的 Java 应用程序的性能。

在本章中,我们将涵盖以下主题:

  • JVM 的工作原理

  • 垃圾回收

  • 即时编译器JIT)优化

到本章结束时,您应该了解如何充分利用 JVM 来提高您的 Java 应用程序的性能。

技术要求

要遵循本章中的说明,您需要以下内容:

  • 安装了 Windows、macOS 或 Linux 的计算机

  • 当前版本的 Java SDK

  • 建议使用代码编辑器或集成开发环境IDE)(如 Visual Studio Code、NetBeans、Eclipse 或 IntelliJ IDEA)

本章的完整代码可以在以下位置找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter01

重要提示

本书基于 Java 21 和Java 开发工具包JDK)21.0.1。它还使用了在 macOS 上运行的 IntelliJ IDEA Community Edition IDE。

本章包含您可以用来跟随和实验的代码示例。因此,您需要确保您的系统已经正确准备。

首先,下载并安装您选择的 IDE。这里有一些选项:

在你的 IDE 设置完成后,你需要确保它已配置为 Java 开发。大多数现代 IDE 都具备为你下载和安装 Java SDK 的功能。如果你不是这种情况,Java SDK 可以从这里获取:www.oracle.com/java/technologies/downloads/.

确保你的 IDE 已准备好 Java 开发非常重要。以下是一些 IDE 特定的链接,以防你需要一些帮助:

一旦你在电脑上安装并配置了 IDE 和 Java SDK,你就可以进入下一部分了。

JVM 的工作原理

在核心上,JVM 位于你的 Java 字节码和电脑之间。如图所示,我们在 IDE 中开发 Java 源代码,我们的工作以.java文件保存。我们使用 Java 编译器将 Java 源代码转换为字节码;生成的文件是.class文件。然后我们使用 JVM 在我们的电脑上运行字节码:

图片

图 1.1 – Java 应用程序工作流程

重要的是要认识到 Java 与典型的编译和执行语言不同,在这些语言中,源代码被输入到编译器中,然后产生一个.exe文件(Windows),一个.app文件(macOS),或者ELF文件(Linux)。这个过程比看起来要复杂得多。

让我们用一个基本示例来更详细地看看 JVM 的工作原理。在下面的代码中,我们实现了一个简单的循环,它会打印到控制台。这样呈现是为了让我们看到 JVM 是如何处理 Java 代码的:

// Chapter1
// Example 1
public class CH1EX1 {
    public static void main(String[] args) {
        System.out.println("Basic Java loop.");
        // Basic loop example
        for (int i = 1; i <= 5; i++) {
            System.out.println("1 x " + i + " = " + i);
        }
    }
}

我们可以使用 Java 编译器将我们的代码编译成.class文件,这些文件将以字节码格式排列。让我们打开一个终端窗口,导航到我们的项目src文件夹,如以下所示,并使用ls命令来显示我们的.java文件:

$ cd src
$ ls
CH1EX1.java
$

现在我们已经进入了正确的文件夹,我们可以使用javac将我们的源代码转换为字节码。正如你接下来可以看到的,这创建了一个.class文件:

$ javac CH1EX1.java
$ ls
CH1EX1.class    CH1EX1.java
$

接下来,我们可以使用javap来打印我们的字节码的反编译版本。正如你接下来可以看到的,我们可以使用javap命令而不带任何参数:

javap CH1EX1.class
Compiled from "CH1EX1.java"
public class CH1EX1 {
  public CH1EX1();
  public static void main(java.lang.String[]);
}

如您所见,我们使用javap命令简化了反编译字节码的打印。现在,让我们使用-c参数来揭示字节码:

$ javap -c CH1EX1.class

这里是使用-c参数运行javap命令并使用我们的示例代码的输出:

Compiled from "CH1EX1.java"
public class CH1EX1 {
  public CH1EX1();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/
                                            Object."<init>":()V
       4: return
  public static void main(java.lang.String[]);
    Code:
       0: getstatic     #7                  // Field java/lang/System.
                                            out:Ljava/io/PrintStream;
       3: ldc           #13                 // String Basic Java loop.
       5: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
       8: iconst_1
       9: istore_1
      10: iload_1
      11: iconst_5
      12: if_icmpgt     34
      15: getstatic     #7                  // Field java/lang/System.
                                            out:Ljava/io/PrintStream;
      18: iload_1
      19: iload_1
      20: invokedynamic #21,  0             // InvokeDynamic #0:makeConcatWithConstants:(II)Ljava/lang/String;
      25: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
      28: iinc          1, 1
      31: goto          10
      34: return
}

您现在应该对 JVM 所做奇迹般的工作有了更好的认识。我们才刚刚开始!

使用 javap 的更多选项

到目前为止,我们已经以两种形式使用了javap命令:不带参数和带-c参数。还有一些其他参数我们可以使用,以进一步了解我们的字节码。为了回顾这些参数,我们将使用一个简化的示例,如下所示:

public class CH1EX2 {
    public static void main(String[] args) {
        System.out.println("Simple Example");
    }
}

您现在可以先使用javac编译类,然后运行它。让我们使用-sysinfo参数输出系统信息。如您所见,打印了文件路径、文件大小、日期和SHA-256哈希值:

$ javap -sysinfo CH1EX2.class

这里是使用-sysinfo参数的输出:

Classfile /HPWJ/Code/Chapter1/CH1Example2/src/CH1EX2.class
  Last modified Oct 22, 2023; size 420 bytes
  SHA-256 checksum 9328e8bab7fcd970f73fc9eec3a856a809b7a45e7b743f8c8b3b7ae0a7fbe0da
  Compiled from "CH1EX2.java"
public class CH1EX2 {
  public CH1EX2();
  public static void main(java.lang.String[]);
}

让我们再看一个javap命令的例子,这次使用-verbose参数。以下是使用终端命令行使用该参数的方法:

$ javap -verbose CH1EX2.class

使用-verbose参数的输出如下。如您所见,提供了大量附加信息:

Classfile /HPWJ/Code/Chapter1/CH1Example2/src/CH1EX2.class
  Last modified Oct 22, 2023; size 420 bytes
  SHA-256 checksum 9328e8bab7fcd970f73fc9eec3a856a809b7a45e7b743f8c8b3b7ae0a7fbe0da
  Compiled from "CH1EX2.java"

剩余的输出可以在此处获得:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter01/java-output

我们可以使用javap命令彻底检查我们的字节码。以下是一些常见的参数,除了我们已使用的-c-sysinfo-verbose之外:

javap 参数 打印内容
-constants 常量(静态 final)
-help (或 -?) 帮助
-l 行变量和局部变量
-private (或 -p) 所有类
-protected 受保护和公共类(非私有)
-public 公共类(不打印私有类)
-s 内部签名类型
-sysinfo Java 发布版本

表 1.1 – javap 参数

现在您已经看到了 JVM 的内部工作原理,让我们看看它在垃圾收集方面所做的不凡工作。

垃圾收集

Java 开发者长期以来一直享受 JVM 管理内存的能力,包括分配和释放。内存管理的分配部分很简单,没有固有的问题。最重要的领域是释放不再由应用程序需要的先前分配的内存。这被称为释放或垃圾收集。虽然并非 Java 编程语言独有,但其 JVM 在垃圾收集方面做得非常出色。本节将详细探讨 JVM 的垃圾收集。

垃圾收集过程

以下示例是创建两个对象,使它们相互引用,然后使它们都为 null。一旦它们被置为 null,尽管它们相互引用,但它们就不再可达。这使得它们有资格进行垃圾收集:

public class Main {
    public static void main(String[] args) {
        // Creating two objects
        SampleClass object1 = new SampleClass("Object 1");
        SampleClass object2 = new SampleClass("Object 2");
        // Making the objects reference each other
        object1.reference = object2;
        object2.reference = object1;
        // References are nullified
        object1 = null;
        object2 = null;
    }
}
class SampleClass {
    String name;
    SampleClass reference;
    public SampleClass(String name) {
        this.name = name;
    }
    // Overriding finalize() method to see the garbage collection 
    // process
    @Override
    protected void finalize() throws Throwable {
        System.out.println(name + " is being garbage collected!");
        super.finalize();
    }
}

在前面的示例中,我们可以显式地调用垃圾收集器。这通常不建议这样做,因为 JVM 已经在这方面做得很好,额外的调用可能会影响性能。如果您确实想进行显式调用,以下是这样做的方法:

System.gc();

关于finalize()方法的说明

JVM 仅在启用的情况下才会调用finalize()方法。启用后,它可能会在不确定的时间延迟后由垃圾收集器调用。该方法已被弃用,并且仅应用于测试,而不应在生产系统中使用。

如果你想进行额外的测试,你可以添加尝试访问不可达对象的代码。以下是这样做的方法:

try {
  object1.display();
  } catch (NullPointerException e) {
    System.out.println("Unreachable object!");
}

前面的try-catch块调用了object1。由于该对象不可达,它将抛出NullPointerException异常。

垃圾收集算法

JVM 有几种垃圾收集算法可供选择,具体使用哪种算法取决于 Java 的版本和特定的使用场景。以下是一些值得注意的垃圾收集算法:

  • 串行收集器

  • 并行收集器

  • 并发标记清除 收集器CMS

  • 垃圾-第一G1收集器

  • Z 垃圾 收集器ZGC

以下将逐一介绍这些垃圾收集算法,以便您更好地理解 JVM 为我们分配内存所做的大量工作。

串行收集器

串行垃圾收集器是 JVM 最基础的算法。它用于小型堆和单线程应用程序。这类应用程序的特征是顺序执行,而不是并发执行。由于只有一个线程需要考虑,内存管理要容易得多。另一个特征是,单线程应用程序通常只使用主机 CPU 能力的一部分。最后,由于执行是顺序的,预测垃圾收集的需要很简单。

并行收集器

并行垃圾收集器,也称为吞吐量垃圾收集器,在 Java 8 之前的早期 Java 版本中是默认的。它的能力超过了串行收集器,因为它是为中等到大型堆和多线程应用程序设计的。

CMS

CMS 垃圾收集器在 Java 9 中被弃用,并在 Java 14 中从 Java 平台中移除。其目的是最小化应用程序因垃圾收集而暂停的时间。弃用和移除的原因包括资源消耗高、由于延迟导致的失败、维护代码库困难,以及有更好的替代方案。

G1 收集器

G1 收集器在 Java 9 中成为默认的垃圾收集器,取代了并行收集器。G1 是为了提供更快、更可预测的响应时间和极高的吞吐量而设计的。G1 收集器可以被认为是主要的和默认的垃圾收集器。

ZGC

Java 11 发布时,ZGC 垃圾收集器作为一个实验性功能。ZGC 的目标是提供一个低延迟、可扩展的垃圾收集器。目标是让 ZGC 能够处理从非常小到非常大的各种堆大小(例如,太字节)。ZGC 实现了这些目标,而没有牺牲暂停时间。这一成功导致 ZGC 作为 Java 15 的一个功能被发布。

垃圾收集优化

精明的 Java 开发者专注于为他们的应用程序从 JVM 中获取最佳性能。为此,可以通过垃圾收集来采取一些措施,以帮助提高 Java 应用程序的性能:

  1. 根据你的用例、堆大小和线程数选择最合适的垃圾收集算法。

  2. 初始化、监控和管理你的堆大小。它们不应超过绝对需要的大小。

  3. 限制对象创建。我们将在第六章中讨论这个问题的替代方案。

  4. 为你的应用程序使用适当的数据结构。这是第二章的主题。

  5. 如本章前面所述,为了避免或至少显著限制对 finalize 方法的调用,以减少垃圾收集延迟和处理开销。

  6. 优化字符串的使用,特别关注最小化重复字符串。我们将在第七章中更深入地探讨这个问题。

  7. 注意不要在 JVM 的垃圾收集范围之外分配内存(例如,本地代码)。

  8. 最后,使用工具来帮助你在实时监控垃圾收集。我们将在第十四章中回顾一些这些工具。

希望你已经对 JVM 的垃圾收集和优化方法有了新的认识。现在,我们将探讨具体的编译器优化。

JIT 编译器优化

JVM 有三个关键组件:一个初始化和链接类和接口的类加载器;运行时数据,包括内存分配;以及执行引擎。本节的重点是这个执行引擎组件。

执行引擎的核心责任是将字节码转换为可以在主机 中央处理单元CPU)上执行的形式。这个过程有三个主要实现:

  • 解释

  • 提前编译AOT

  • JIT 编译

在深入研究 JIT 编译优化之前,我们将简要地看看解释和 AOT 编译。

解释

解释是 JVM 可以用来读取和执行字节码而不将其转换为宿主 CPU 的本地机器码的技术。我们可以通过在终端窗口中使用java命令来调用此模式。以下是一个示例:

$ java Main.java

使用解释模式的唯一真正优势是通过避免编译过程来节省时间。这对于测试可能是有用的,但不建议用于生产系统。与 AOT 和 JIT(接下来将描述)相比,使用解释通常会出现性能问题。

AOT 编译

我们可以在应用程序执行之前将我们的字节码编译成本地机器码。这种方法称为 AOT,可以用于性能提升。这种方法的具体优势包括以下内容:

  • 你可以避免与传统的 JIT 编译过程相关的正常应用程序启动延迟

  • 启动执行速度保持一致

  • 启动时的 CPU 负载通常较低

  • 通过避免其他编译方法来减轻安全风险

  • 你可以在启动代码中构建优化

使用 AOT 编译方法也有一些缺点:

  • 当你在编译时,你将失去在任何设备上部署的能力

  • 可能你的应用程序会变得臃肿,增加存储和相关成本

  • 你的应用程序将无法利用与 JIT 编译关联的 JVM 优化

  • 代码维护的复杂性增加

了解其优势和劣势可以帮助你决定何时以及何时不使用 AOT 编译过程。

JIT 编译

JIT 编译过程可能是我们最熟悉的。我们调用 JVM,并让它将我们的字节码转换为针对当前主机机器的特定机器码。这种机器码被称为本地机器码,因为它对本地 CPU 来说是本地的。这种编译是在即时发生的,或者是在执行之前。这意味着不是所有的字节码都会一次性编译。

JIT 编译的优点是相对于解释方法,性能提高,能够在任何设备(平台无关)上部署你的应用程序,以及能够进行优化。JIT 编译器过程能够优化代码(例如,删除死代码、循环展开等)。缺点是初始启动时的开销以及由于需要存储本地机器码翻译而导致的内存使用。

摘要

到现在为止,你应该已经对 JVM 的复杂性和其工作原理有了认识。本章涵盖了javacjavap命令行工具,用于创建和分析字节码。JVM 的垃圾回收功能也通过应用程序性能的角度进行了考察。最后,还介绍了 JIT 编译的优化。

到本书出版之日,JVM 已经有 29 年的历史,自从它的首次发布以来已经取得了长足的进步。除了持续的改进和优化之外,JVM 还能支持额外的语言(例如 Kotlin 和 Scala)。对持续提高其 Java 应用性能感兴趣的 Java 开发者应该关注 JVM 的更新。

对于 JVM 的扎实理解,我们在下一章将关注数据结构。我们的重点将放在如何最优地使用数据结构作为我们高性能策略的一部分。

第二章:数据结构

数据结构是我们 Java 应用程序性能的组成部分,它们可能有助于提高性能,也可能降低性能。它们是程序中的基础部分,用于整个程序,可以帮助我们有效地组织和操纵数据。数据结构对于优化我们的 Java 应用程序的性能至关重要,因为它们可以确保我们的数据访问、内存管理和缓存效率。正确使用数据结构可以提高算法效率,提高解决方案的可扩展性,并确保线程的安全性。

数据结构的重要性可以通过减少操作的时间复杂度来证明。通过适当的数据结构实现,我们可以提高应用程序性能的可预测性和一致性。除了提高 Java 应用程序的性能外,适当选择的数据结构还可以提高代码的可读性,使它们更容易维护。

在本章中,我们将涵盖以下主要主题:

  • 列表

  • 数组

  • 栈和队列

  • 高级数据结构

到本章结束时,您应该了解特定数据结构,如列表数组队列,如何影响 Java 应用程序的性能。您将有机会通过 Java 代码的实际操作来获得经验,展示如何通过适当的数据结构选择和实现来提高性能。

技术要求

要遵循本章中的示例和说明,您需要能够加载、编辑和运行 Java 代码。如果您还没有设置您的开发环境,请参阅第一章

本章的代码可以在以下位置找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02

使用列表提高性能

列表是 Java 编程语言中的基本数据结构。它们使我们能够轻松创建、存储和操作有序元素集合。此数据结构使用java.util.list接口,并扩展了java.util.Collection接口。

在本节中,我们将仔细研究列表,探讨何时以及为什么使用它们,以及如何从它们中获得最佳性能的技术。

为什么使用列表?

解释列表数据结构可以用于的最常见方式可能是作为一个勾选/to-do 列表或购物清单。我们在程序中创建列表,因为我们想利用其一或多个优势:

  • 有序元素:列表用于保持我们元素的顺序,即使在添加新元素时也是如此。这使我们能够以特定的顺序操纵我们的数据。考虑一个系统日志,它通过日期和时间戳添加新条目。我们希望这些条目保持特定的顺序。

  • 自动调整大小:列表可以在我们的程序添加和删除元素时动态调整大小。这在ArrayList中尤其如此,这将在本章后面进行介绍。

  • 位置数据访问:列表允许我们通过使用元素的索引(也称为位置数据)来高效地获得随机访问。

  • 重复元素:列表允许我们拥有重复元素。因此,如果这对您的用例很重要,那么您可能需要考虑使用列表作为您的数据结构选择。

  • forEach方法。我们将在本章下一节中查看此示例。

  • LinkedListArrayListVector列表类型。这些列表类型具有独特的特性。以下表格显示了这些列表类型的不同特性。请特别注意性能这一行:

LinkedList ArrayList Vector
数据结构 双向链表 动态数组 动态数组
用例 经常操纵数据 快速读取 需要线程安全
性能 + 添加和删除- 通过索引访问 - 添加和删除+ 通过索引访问 - 添加和删除- 通过索引访问
线程安全 否,默认情况下不是 否,默认情况下不是 是,默认情况下是

表 2.1 – 列表

在选择LinkedListArrayListVector列表类型时,我们应该根据我们的用例考虑需求。例如,如果我们的用例包括频繁的添加和删除,那么LinkedList可能是最佳选择。或者,如果我们添加和删除不频繁,但读取量大,那么ArrayList可能是我们的最佳选择。最后,如果我们最关心线程安全,那么Vector可能是我们的最佳选择。

关于线程安全的重要注意事项

虽然LinkedListArrayList默认不是线程安全的,但可以通过显式同步访问来使其线程安全。这应该可以防止并发访问相关的数据损坏,但可能会降低 Java 应用程序的性能。

现在我们已经回顾了为什么我们应该使用列表,让我们看看常见的实现示例。

常见的列表实现

在本节中,我们将查看ArrayListLinkedListVector的实现示例。

ArrayList 示例

我们的第一个列表实现示例是一个数字的ArrayList列表类型。我们将假设这是一个人力资源(HR)系统的一部分,该系统存储开始日期、结束日期和服务长度。如下所示,我们必须导入java.util.ArrayListjava.util.List

import java.util.ArrayList;
import java.util.List;

接下来,我们有我们的类声明和主方法。在这里,我们必须创建一个名为hr_numbersArrayList列表类型:

public class Example1 {
  public static void main(String[] args) {
    .add method to add elements to our ArrayList:

hr_numbers.add(1983);

hr_numbers.get 方法,并使用它们来计算一个值,将其作为 ArrayList 的第三个元素添加:

    int startYear = hr_numbers.get(0);
    int endYear = hr_numbers.get(1);
    hr_numbers.add(endYear-startYear);
        代码的最后部分是一个`for`循环。这个循环遍历列表,并将输出提供给终端窗口:
    for (int number : hr_numbers) {
      System.out.println(number);
    }
        这是程序的输出:
1983
2008
25
        如您所见,我们的输出符合预期;我们只是使用`for-each`循环将三个元素简单地打印到终端窗口。

        for-each 循环

        Java 5 引入了一个增强的`for`循环,称为`for-each`循环。我们可以使用这个循环来遍历元素,而无需使用显式的迭代器或索引。这使得我们的代码更快地编写,更易于阅读。

        链接示例

        我们的`LinkedList`实现示例包括简单的`get`、`remove`、`contains`和`size`方法。如您所见,我们导入了`java.util.LinkedList`和`java.util.List`:
Import java.util.LinkedList;
import java.util.List;
        接下来,我们有我们的类声明和主方法。在这里,我们创建一个名为`petNames`的`LinkedList`列表类型:
public class Example2 {
  public static void main(String[] args) {
    .add method to add elements to our LinkedList:

petNames.add("Brandy");

petNames.add("Muzz");

petNames.add("Java");

petNames.get 方法:

    String firstPet = petNames.get(0);
    String secondPet = petNames.get(1);
        代码的下一部分是一个`for-each`循环,它遍历列表并将输出提供给终端窗口:
    for (String pet : petNames) {
      System.out.println(pet);
    }
        这是循环的输出:
Brandy
Muzz
Java
Bougie
        我们可以通过使用`remove`方法从我们的`LinkedList`中删除一个元素,如下所示。如您所见,在调用`remove`方法后,`Brandy`不再是`LinkedList`中的一个元素:
petNames.remove("Brandy");
        我们代码的下一部分调用`contains`方法来检查特定值是否在`LinkedList`中。由于我们之前已经从这个`LinkedList`中删除了这个宠物,布尔结果是`false`:
boolean containsBrandy = petNames.contains("Brandy");
System.out.println(containsBrandy);
        `println`语句的输出符合预期:
false
        我们代码的最后一部分展示了`size`方法的使用。在这里,我们调用该方法,它返回一个整数。我们使用这个值在我们的最终输出中:
int size = petNames.size();
System.out.println("You have " + size + " pets.");
        最终输出反映了我们的`LinkedList`预期的长度:
You have 3 pets.
        现在我们已经了解了如何实现`ArrayList`和`LinkedList`,让我们看看我们的最后一个示例,**Vector**。

        向量示例

        我们最后一个列表实现示例是一个`Vector`列表类型。如您将看到的,`Vector`与`ArrayList`类似。我们将它们实现为动态数组,以便我们可以从向量的元素中获得高效的随机访问。向量默认是线程安全的,这是由于我们之前讨论的默认同步。让我们看看一些示例代码。

        我们的示例程序将存储一组幸运数字。它首先导入`java.util.Vector`和`java.util.Enumeration`包:
import java.util.Vector;
import java.util.Enumeration;
        接下来,我们有我们的类声明和主方法。在这里,我们创建一个名为`luckyNumbers`的`Vector`列表类型,它将存储整数:
public class Example3 {
  public static void main(String[] args) {
    .add method to add elements to our Vector.

luckyNumbers.add(8);

luckyNumbers.add(19);

luckyNumbers.get 方法。请注意,索引从零(0)开始:

    int firstNumber = luckyNumbers.get(0);
    int secondNumber = luckyNumbers.get(2);
        代码的下一部分使用了一个传统的遍历`Vector`列表类型的方法。枚举方法可以用作替代增强型或`for-each`循环。实际上,向量被视为一个正在逐渐被淘汰的列表类型。以下代码遍历列表并向终端窗口提供输出:
    Enumeration<Integer> enumeration = luckyNumbers.elements();
    while (enumeration.hasMoreElements()) {
      int number = enumeration.nextElement();
      System.out.println(number);
    }
        下面是程序的输出:
8
19
24
        我们可以通过使用`removeElement`方法从我们的`Vector`中移除一个元素,如下所示。在调用`removElement`方法后,幸运数字 19 被从`Vector`中移除作为一个元素:
luckyNumbers.removeElement(19);
        我们代码的下一部分调用`contains`方法来检查特定值是否在`Vector`中。由于我们之前已经从`Vector`中移除了这个幸运数字,布尔结果为`false`:
boolean containsNineteen= luckyNumbers.contains(19);
System.out.println(containsNineteen);
        `println`语句的输出符合预期:
false
        以下代码的最后一部分展示了`size`方法的使用。在这里,我们调用该方法,它返回一个整数。我们使用这个值在我们的最终输出中:
int mySize = luckyNumbers.size();
System.out.println("You have " + mySize + " lucky numbers.");
        最终输出反映了我们`LinkedList`列表类型的预期大小:
You have 2 lucky numbers.
        本节提供了使用`ArrayList`、`LinkedList`和`Vector`的列表的示例。还有其他实现可以考虑,包括`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`LinkedHashSet`。栈是另一种实现,将在本章后面介绍。接下来,我们将探讨如何通过列表实现高性能。

        高性能的列表

        让我们看看如何提高我们使用列表时 Java 应用程序的性能。以下代码示例实现了整数类型的`LinkedList`列表类型。在这里,我们将创建一个`LinkedList`列表类型,向其中添加四个元素,然后遍历列表,将每个元素打印到屏幕上:
import java.util.LinkedList;
import java.util.List;
public class Example4 {
  public static void main(String[] args) {
    List<Integer> numbers = new LinkedList<>();
    numbers.add(3);
    numbers.add(1);
    numbers.add(8);
    numbers.add(9);
    System.out.println("Initial LinkedList elements:");
    for (int number : numbers) {
      System.out.println(number);
    }
  }
}
        本节的第一部分输出如下:
Initial LinkedList elements:
3
1
8
9
        下一个代码部分使用`remove`方法删除一个元素——具体来说,是第一个出现的 8:
numbers.remove(Integer.valueOf(8));
        以下代码段使用`contains`方法执行两个检查。首先,它检查 3,然后是 8。结果被打印在屏幕上:
boolean containsThree = numbers.contains(3);
System.out.println("\nThe question of 3: " + containsThree);
boolean containsEight = numbers.contains(8);
System.out.println("The question of 8: " + containsEight);
        本节代码的输出如下所示:
The question of 3: true
The question of 8: false
        以下代码段遍历`LinkedList`并打印其值:
System.out.println("\nModified LinkedList elements:");
for (int number : numbers) {
  System.out.println(number);
}
        以下代码段是本节最后部分的输出:
Modified LinkedList elements:
3
1
9
        现在,是时候看看我们如何修改我们的代码来提高整体性能了。我们可以使用几种技术:

            +   `List<Integer>`对象。`Integer`类本质上是对原始`int`数据类型的包装。`Integer`对象只包含一个`int`类型的字段。以下是我们如何修改我们的代码的方法:

```java
LinkedList<Integer> numbers = new LinkedList<>();
```

                +   对于`LinkedList`,我们应该使用`java.util.Iterator`包。这是一个避免诸如**ConcurrentModificationsException**等错误的有效方法,这种异常会在我们尝试同时对一个集合进行两次修改时抛出。以下是我们如何编写这样的迭代器的方法:

```java
Iterator<Integer> iterator = numbers.iterator();
while (iterator.hasNext()) {
  int number = iterator.next();
  if (number == 8) {
    iterator.remove();
  }
}
```

完全修订的示例可在[`github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example5.java`](https://github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example5.java)找到。

        现在,你应该对在 Java 中使用列表以帮助提高应用程序性能有了更多的知识和信心。接下来,我们将探讨数组以及如何最优地使用它们。

        使用数组提高性能

        在之前,我们讨论了`ArrayList`;在本节中,我们将专注于数组。这两种数据类型之间存在关键差异:数组具有固定的大小,而 ArrayList 没有。在本节中,我们将回顾数组的特性和如何在实现数组时提高 Java 应用程序的性能。

        数组特性

        数组有四个主要特性。让我们看看:

            +   **大小**:数组的大小在创建时确定。在应用程序运行时无法更改。这种固定大小的特性也被称为**静态**。以下是创建数组的语法:

```java
int[] myNumbers = new int[10];
```

如你所见,我们在数组声明中明确指定了大小为`10`。

                +   **同质**:同质意味着数组中的所有数据必须属于同一类型。就像之前的例子一样,我们创建了一个整数数组。我们可以使用字符串,但不能在单个数组中混合数据类型。我们也可以有对象数组和数组数组。

            +   **索引**:这一点不言而喻,所以作为一个提醒,我们的索引从 0 开始,而不是 1。因此,我们之前创建的数组有 10 个元素,索引从 0 到 9。

            +   **连续内存**:数组为我们提供的一项巨大效率是随机访问。这种效率源于数组中的所有元素都存储在连续内存中的事实。换句话说,元素存储在相邻的内存位置。

        现在我们对数组特性有了牢固的理解,让我们探索一些实现这种重要数据结构的代码。

        实现数组

        本节介绍了一个基本的 Java 应用程序,该程序使用`String`数据类型实现行星数组。随着我们遍历代码,我们将创建数组,访问和打印数组元素,使用`length`方法,使用索引访问数组元素,并修改数组。

        这段代码的第一个部分创建了一个**字符串**数组:
public class Example6 {
  public static void main(String[] args) {
    String[] planets = {
      "Mercury",
      "Venus",
      "Earth",
      "Mars",
      "Jupiter",
      "Saturn",
      "Uranus",
      "Neptune"
    };
  }
}
        如你所见,我们创建了一个包含八个字符串的数组。接下来的代码部分展示了如何访问和打印数组的所有元素:
System.out.println("Planets in our solar system:");
for (int i = 0; i < planets.length; i++) {
  System.out.println(planets[i]);
}
        以下是前面代码片段的输出:
Planets in our solar system:
Mercury
Venus
Earth
Mars
Jupiter
Saturn
Uranus
Neptune
        我们可以使用`length`方法来确定我们数组的大小。虽然看起来我们不需要确定大小,因为数组创建时大小就已经确定了。通常,我们不知道数组的初始大小,因为它们基于外部数据源。以下是确定数组大小的步骤:
int numberOfPlanets = planets.length;
System.out.println("Number of planets: " + numberOfPlanets);
        前面代码的输出如下所示:
Number of planets: 8
        接下来,我们将探讨如何通过在数组中引用其索引位置来访问数组元素:
String thirdPlanet = planets[2];
System.out.println("The third planet is: " + thirdPlanet);
        上一两行代码的输出如下:
The third planet is: Earth
        如您所见,我们通过使用索引引用`2`打印了数组中的第三个元素。请记住,我们的索引从 0 开始。

        我们最后的代码段展示了我们如何修改数组中的元素:
planets[1] = "Shrouded Venus";
System.out.println("After renaming Venus:");
for (String planet : planets) {
  System.out.println(planet);
}
        上述代码的输出如下。如您所见,索引位置 1 的元素的新名称已反映在输出中:
After renaming Venus:
Mercury
Shrouded Venus
Earth
Mars
Jupiter
Saturn
Uranus
Neptune
        完全修订的示例可在[`github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example6.java`](https://github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example6.java)找到。

        现在您已经学会了如何在 Java 中实现数组,让我们看看一些提高应用程序性能的方法,特别是关于数组的使用。

        使用数组实现高性能

        正如我们所看到的,我们的列表实现方法对 Java 应用程序的性能有直接影响。本节记录了优化与数组相关的 Java 代码的几种策略和最佳实践。这些策略可以分为算法优化、数据结构、内存管理、并行处理、向量化、缓存以及基准测试和性能分析。让我们逐一查看它们。

        算法优化

        在选择算法时,我们应该确保它们是最适合我们使用的数据类型的。例如,根据我们数组的尺寸和特性选择最有效的排序算法(例如归并排序、快速排序等)是很重要的。此外,在排序数组中进行搜索时可以实现二分查找。

        本书*第五章*提供了更多信息。

        数据结构

        本章的核心内容是针对您的用例和需求选择合适的数据结构。当涉及到数组时,我们应该在我们需要频繁读取访问时选择它们;在这里,我们的数据集可以是固定大小的。此外,知道何时选择`ArrayList`而不是`LinkedList`同样重要。

        内存管理

        在可能的情况下,我们应该避免创建临时对象来支持数组操作。相反,我们应该重用数组或使用`Arrays.copyOf`或`System.arraycopy`等方法以提高效率。

        本书*第八章*提供了更多信息。

        并行处理

        当我们必须对大型数组进行排序和处理时,利用 Java 的并行处理能力,如使用`parallelSort`,可能是有益的。您应该考虑使用多线程进行并发数组处理。这对于大型数组尤为重要。

        本书*第九章*提供了更多信息。

        向量化

        在 Java 数组的上下文中,向量化是一种涉及同时对一个数组的多个元素执行操作的技术。这通常包括优化现代中央处理器。目标是增加数组处理操作。这通常被称为`java.util.Vector`类,它是随着 Java 16 引入的。

        向量化可以提供显著的性能优势,尤其是在处理大型数组时。当操作可以并行化时,这一点更是如此。可以向量化的内容有限,例如依赖关系和复杂操作。

        缓存

        一种经过验证的性能方法是优化数组访问模式以支持缓存局部性。这可以通过访问连续的内存位置来实现。另一种方法是尽可能减少指针别名。虽然指针别名可能支持编译器优化,但为了获得最佳性能,应使用局部变量或数组索引。

        本书*第八章*提供了更多信息。

        基准测试和分析

        在可能的情况下,我们应该进行基准测试,以便比较我们的数组操作方法。有了这种分析,我们可以选择最有效且经过验证的方法。作为我们分析的一部分,我们可以使用分析工具来帮助我们识别特定于我们的数组操作的性能瓶颈。

        本书*第十三章*提供了更多信息。

        现在你已经学会了如何提高处理数组时的 Java 应用程序性能,让我们看看如何使用树来实现这一点。

        使用树提高性能

        **树**是一种层次数据结构,由父节点和子节点**节点**组成,类似于物理树。最顶部的节点被称为**根**,在树数据结构中只能有一个。节点是这个数据结构的基础组件。每个节点都包含数据和指向子节点的引用。还有其他你应该熟悉的关于树的概念。**叶节点**没有子节点。任何节点及其后代都可以被认为是**子树**。

        树结构的示例

        如以下示例所示,我们可以将树实现为对象或类:
class TreeNode {
  int data;
  TreeNode left;
  TreeNode right;
  public TreeNode(int data) {
    this.data = data;
    this.left = null;
    this.right = null;
  }
}
        上述代码片段定义了一个`TreeNode`类,它可以与另一个类一起用于树操作管理。我们需要在`main()`方法中创建我们的树。以下是如何做到这一点的示例:
Example7BinarySearchTree bst = new Example7BinarySearchTree();
        要将数据插入我们的树,我们可以使用以下代码:
bst.insert(50);
bst.insert(30);
bst.insert(70);
bst.insert(20);
bst.insert(40);
bst.insert(60);
bst.insert(80);
        要使用或搜索我们的树,我们可以使用类似于以下代码的代码在树中执行二分查找:
int searchElement = 70;
if (bst.search(searchElement)) {
  System.out.println("\n" + searchElement + " was found in the tree.");
} else {
  System.out.println("\n" + searchElement + " was not found in the 
  tree.");
}
        一个完整的示例代码可以在[`github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example7.java`](https://github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example7.java)找到。

        高性能考虑因素

        树数据结构可能非常复杂,因此我们应该考虑我们的 Java 应用程序的整体性能,特别是关于操作我们的树的算法。额外的考虑可以归类为类型、安全性、迭代、内存、操作和缓存。让我们简要地看看这些类别。

        类型

        重要的是要仔细构建我们的树,使它们尽可能平衡。我们应该注意**树的高度**,即从根节点到叶节点的最长路径。我们应该审查数据需求,以便我们可以选择最优的树。例如,我们可以使用**二叉搜索树**,它可以为我们提供高效的搜索。

        安全性

        与其他数据结构一样,你应该始终实现线程安全协议,或者至少使用并发树结构。

        迭代

        迭代操作常常会呈现瓶颈,需要调用优化策略。此外,应尽量减少递归算法的使用,以提高整体性能。

        内存

        通常,具体到树,我们应该尽量减少对象创建和开销。我们应该努力高效地管理内存。

        本书*第八章*提供了更多信息。

        操作

        我们可以采取一些措施来优化操作。例如,我们可以使用批量操作来减少处理开销。优化我们的算法可以帮助我们避免过度处理和数据更新。

        本书*第五章*提供了更多信息。

        缓存

        我们可以优化数据布局的方式,以最大化缓存局部性。我们的目标是减少内存访问时间。此外,我们可以访问内存中的几乎所有节点,以进一步提高性能。

        本书*第八章*提供了更多信息。

        使用栈和队列提高性能

        **栈**和**队列**几乎总是放在一起,因为它们都可以用来管理和操作数据元素集合。它们都是线性栈;这就是它们的相似之处。虽然它们被分组,但它们在操作方式上有关键的区别。在本节中,我们将探讨如何实现栈和队列,以及如何为高性能 Java 应用程序优化它们。

        实现栈

        栈是线性数据结构,使用`push`元素到顶部,`peek`查看一个元素,以及`pop`移除顶部元素。

        以下示例演示了如何在 Java 中创建一个栈。你会注意到我们首先导入`java.util.Stack`包:
import java.util.Stack;
public class Example8 {
  public static void main(String[] args) {
    Stack<Double> transactionStack = new Stack<>();
    transactionStack.push(100.0);
    transactionStack.push(-50.0);
    transactionStack.push(200.0);
    while (!transactionStack.isEmpty()) {
      double transactionAmount = transactionStack.pop();
      System.out.println("Transaction: " + transactionAmount);
    }
  }
}
        以下代码是 Java 栈的简单实现。该程序的目的是对银行交易进行处理。现在你已经看到了这个简单的实现,让我们看看我们如何可以改进我们的代码以获得更好的性能。

        提高栈的性能

        以我们之前的示例为起点,我们将对其进行优化以提高运行时的性能。我们将首先使用自定义的栈实现。这种优化特别适用于涉及大量事务的使用案例。正如您所看到的,我们必须导入 `java.util.EmptyStackException` 包。

        接下来,我们必须声明一个类并使用具有 **double** 数据类型的数组。我们选择这种方法是为了避免由于 **auto-boxing** 而产生的处理开销:
public class Example9 {
  private double[] stack;
  private int top;
  public Example9(int capacity) {
    stack = new double[capacity];
    top = -1;
  }
        以下代码段定义了 `push`、`pop` 和 `isEmpty` 方法:
public void push(double transactionAmount) {
  if (top == stack.length - 1) {
    throw new RuntimeException("Stack is full.");
  }
  stack[++top] = transactionAmount;
}
public double pop() {
  if (isEmpty()) {
    throw new EmptyStackException();
  }
  return stack[top--];
}
public boolean isEmpty() {
  return top == -1;
}
        我们最后的代码段是 `main()` 方法,它处理栈:
public static void main(String[] args) {
  Example9 transactionStack = new Example9(10);
  transactionStack.push(100.0);
  transactionStack.push(-50.0);
  transactionStack.push(200.0);
  while (!transactionStack.isEmpty()) {
    double transactionAmount = transactionStack.pop();
    System.out.println("Transaction: " + transactionAmount);
  }
}
        简单和优化后的两组程序的输出相同,并在此展示:
Transaction: 200.0
Transaction: -50.0
Transaction: 100.0
        我们栈实现的两个版本的完整工作示例可在 [`github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example8.java`](https://github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example8.java) 和 [`github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example9.java`](https://github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example9.java) 找到。

        实现队列

        队列是另一种线性数据结构,并使用 `enqueue`、`peek` 和 `dequeue` 来管理我们的队列。

        以下示例演示了如何在 Java 中创建一个队列。正如您所看到的,我们首先导入 `java.util.LinkedList` 和 `java.util.Queue` 包:
import java.util.LinkedList;
import java.util.Queue;
public class Example10 {
  public static void main(String[] args) {
    Queue<Double> transactionQueue = new LinkedList<>();
    transactionQueue.offer(100.0);
    transactionQueue.offer(-50.0);
    transactionQueue.offer(200.0);
    while (!transactionQueue.isEmpty()) {
      double transactionAmount = transactionQueue.poll();
      System.out.println("Transaction: " + transactionAmount);
    }
  }
}
        上述代码是 Java 队列的简单实现。程序的目的,就像栈示例一样,是处理银行交易。现在您已经看到了这个简单实现,让我们看看我们如何可以优化我们的代码以获得更好的性能。

        提高队列的性能

        我们优化的队列实现已经针对运行时性能进行了优化。这对于高事务性应用尤为重要。我们首先导入 `java.util.NoSuchElementException` 包,然后声明类和一组私有类变量,在创建自定义队列实现构造函数之前:
import java.util.NoSuchElementException;
public class Example11 {
  private double[] queue;
  private int front;
  private int rear;
  private int size;
  private int capacity;
  public Example11(int capacity) {
    this.capacity = capacity;
    queue = new double[capacity];
    front = 0;
    rear = -1;
    size = 0;
  }
        以下代码段包括 `enqueue`、`dequeue` 和 `isEmpty` 方法:
public void enqueue(double transactionAmount) {
  if (size == capacity) {
    throw new RuntimeException("Queue is full.");
  }
  rear = (rear + 1) % capacity;
  queue[rear] = transactionAmount;
  size++;
}
public double dequeue() {
  if (isEmpty()) {
    throw new NoSuchElementException("Queue is empty.");
  }
  double transactionAmount = queue[front];
  front = (front + 1) % capacity;
  size--;
  return transactionAmount;
}
public boolean isEmpty() {
  return size == 0;
}
        我们最后的代码段是 `main()` 方法,它处理队列:
public static void main(String[] args) {
  Example11 transactionQueue = new Example11(10);
  transactionQueue.enqueue(100.0);
  transactionQueue.enqueue(-50.0);
  transactionQueue.enqueue(200.0);
  while (!transactionQueue.isEmpty()) {
    double transactionAmount = transactionQueue.dequeue();
    System.out.println("Transaction: " + transactionAmount);
  }
}
        简单和优化后的两组程序的输出相同,并在此提供:
Transaction: 100.0
Transaction: -50.0
Transaction: 200.0
        我们队列实现的两个版本的完整工作示例可在 [`github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example10.java`](https://github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example10.java) 和 [`github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example11.java`](https://github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter02/Example11.java) 找到。

        在 Java 中,栈和队列都可以用于多种用例。确保我们从性能角度出发最合理地使用它们非常重要。此外,我们必须考虑之前详细说明的优化方法。

        使用高级数据结构提高性能

        我们可以使用的比本章迄今为止介绍的数据结构还要多。有时,我们不应该改进我们对数据结构(如列表、数组、树、栈或队列)的使用,而应该实现针对我们特定用例量身定制的高级数据结构,然后优化它们的使用。

        让我们看看一些额外的、更高级的数据结构,它们需要考虑性能因素。

        哈希表

        当我们需要快速的**键值**查找时,我们可以使用**哈希表**。有多种哈希函数可供选择,你应该注意哈希冲突——它们需要被高效地管理。一些额外的考虑因素包括负载因子、调整大小、内存使用和性能。

        下面是一个创建哈希表并为其创建根节点的示例:
import java.util.HashMap;
HashMap<String, Integer> hashMap = new HashMap<>();
hashMap.put("Alice", 25);
        图

        图,例如**矩阵**或**邻接表**,可以用来网络化一个应用程序并建模复杂的数据关系。在实现图时,我们应该实现能够高效遍历图的算法。值得探索的两个算法是**广度优先搜索**和**深度优先搜索**。比较这些算法的效率可以帮助你选择最优化的解决方案。

        下面是如何实现图的方法:
import java.util.ArrayList;
import java.util.List;
List<List<Integer>> graph = new ArrayList<>();
int numNodes = 5;
for (int i = 0; i < numNodes; i++) {
  graph.add(new ArrayList<>());
}
graph.get(0).add(1);
        字典树

        **字典树**是一种在编程自动完成、前缀匹配等方法时非常有用的数据结构。字典树允许我们高效地存储和搜索字符串序列。

        下面是一个创建字典树并为它创建根节点的示例:
class TrieNode {
  TrieNode[] children = new TrieNode[26];
    boolean isEndOfWord;
}
TrieNode root = new TrieNode();
        我们将在本书的*第七章*中介绍字符串操作。

        堆

        **优先队列**通常实现为**堆**,因为它们可以基于元素的顺序或优先级高效地管理元素。这对于包括调度、优先级排序和元素顺序在内的用例来说是一个理想的数据结构。

        下面是一个将优先队列实现为堆的示例。此示例演示了`minHeap`和`maxHeap`,并为每个添加了一个元素:
import java.util.PriorityQueue;
PriorityQueue<Integer> axheap = new PriorityQueue<>();
axheap.offer(3);
PriorityQueue<Integer> axheap = new PriorityQueue<>((a, b) -> b – a);
axheap.offer(3);
        四叉树

        **四叉树**是一种用于高效组织空间数据的二维分区。它们在空间索引、地理信息系统、碰撞检测等方面非常有用。

        创建四叉树比实现其他数据结构要复杂一些。下面是一个简单的方法:
class QuadTreeNode {
  int val;
  boolean isLeaf;
  QuadTreeNode topLeft;
  QuadTreeNode topRight;
  QuadTreeNode bottomLeft;
  QuadTreeNode bottomRight;
  public QuadTreeNode() {}
  public QuadTreeNode(int val, boolean isLeaf) {
      this.val = val;
      this.isLeaf = isLeaf;
  }
}
QuadTreeNode root = new QuadTreeNode(0, false);
        八叉树

        **八叉树**是一种用于高效组织空间数据的三维分区。像四叉树一样,八叉树在空间索引、地理信息系统、碰撞检测等方面非常有用。

        在 Java 中实现八叉树可能比较困难,这与大多数三维或更高维度的数据结构一样。

        位图集

        当我们需要为整数或布尔数据集提供空间高效的存储,并确保它们的操作是高效的,**位集**数据结构值得考虑。示例用例包括遍历图并标记已访问节点的算法。

        下面是一个实现位集的示例:
import java.util.BitSet;
BitSet bitSet = new BitSet(10); // Creates a BitSet with 10 bits
bitSet.set(2);
boolean isSet = bitSet.get(2);
        线索

        线索非常适合实现高效的字符串处理,尤其是大型字符串。它们用于执行连接和子字符串操作。一些示例用例包括文本编辑应用程序和字符串操作功能。

        从零开始实现线索是一个非常复杂的数据结构。开发者通常会转向 JDK 之外的包和工具来完成这项工作。

        我们将在本书的*第七章*中介绍字符串操作。

        这些只是 Java 中可用的部分高级数据结构。当我们采用这些结构时,我们必须了解它们的工作原理、它们的优点和缺点。

        概述

        本章深入探讨了几个数据结构,并展示了它们对 Java 应用程序整体性能的重要性。我们拥抱数据结构的基本性质,以优化我们的 Java 应用程序的性能,因为它们可以确保我们的数据访问、内存管理和缓存都是高效的。正确使用数据结构可以提高算法效率、我们解决方案的可扩展性和线程的安全性。

        通过代码示例,我们研究了列表、数组、树、栈和队列的非优化和优化代码示例。我们还探索了几个高级数据结构,并检查了它们的附加复杂性。当我们正确实现数据结构时,我们可以提高我们应用程序性能的可预测性和一致性,并使我们的代码更易于阅读和维护。

        在下一章中,我们将专注于循环以及如何优化它们以提高我们的 Java 应用程序的性能。这是在介绍数据结构之后的一个自然进展,因为我们将使用它们在下一章的示例中。



第三章:优化循环

循环是基本编程结构,理解或编写它们并不特别困难。我们使用它们来遍历应用程序的数据结构并执行重复性任务。由于它们的简单语法和可读性,我们经常将循环视为理所当然。当性能成为关注点时,循环具有双重性。一方面,循环作为高效数据处理的基本结构。另一方面,未优化的循环可能会引入显著的瓶颈,并降低我们的 Java 应用程序的整体性能。

本章涉及的概念包括循环开销、循环展开、基准测试、循环融合、循环并行化和循环向量化。我们将使用代码示例来提供见解并展示最佳实践。

本章涵盖了以下主要主题:

  • 循环类型

  • 测试循环的性能

  • 嵌套循环

本章探讨了技术、策略和最佳实践,以帮助您从循环中获得最佳性能,并防止无意中引入会削弱运行时性能的严重瓶颈。

技术要求

要遵循本章中的示例和说明,您需要能够加载、编辑和运行 Java 代码。如果您尚未设置开发环境,请参阅第一章

本章的完整代码可以在以下位置找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter03

循环类型

循环是遍历数据结构、控制代码流程和执行重复性任务不可或缺的结构。它们是许多算法和应用的核心。虽然循环可以用来高效地处理数据和执行重复性任务,但当我们关注性能时,它们也可能同样有问题。

理解不同类型的循环、它们的特性和性能影响是很重要的。本节探讨了 Java 中的不同循环类型,包括forwhiledo-whilefor-each。我们的目标是理解每种循环类型的目的及其对代码可读性和效率的影响。具体来说,我们将涵盖以下主题:

  • 循环对性能的影响

  • 循环优化基础

  • 循环展开

拥有对循环类型的深入理解可以使您能够为特定需求选择正确的循环。

循环对性能的影响

为了理解循环对性能的影响,我们必须对不同的循环类型有一个牢固的理解。本节提供了关于每种循环类型的信息,包括用例和优势。使用示例来提供实现细节。

for 循环

for循环是最基本的循环类型。它们具有简洁的语法和定义良好的循环控制。它们是非官方的默认循环,用于遍历一系列项目。让我们看看这些循环的以下组成部分:初始化、条件、迭代和效率。

for循环的语法如下:

for (int i = 0; i < 5; i++); {}
{

分析之前的语法,我们可以将int i =0;识别为i,并将其设置为0i < 5;表示i小于5。如果评估结果返回true,则循环继续;否则,循环结束。最后一部分是i++,在每次迭代的末尾将控制变量增加 1。

for循环存在一些与性能相关的问题。当我们初始化循环时,应避免在循环外部初始化变量,因为这有时会因循环内的冗余初始化而降低性能。条件表达式直接影响循环将执行的迭代次数。使这个表达式过于复杂可能会减慢循环的速度。最后,迭代表达式应设计得当,以帮助防止无限循环和不效率。

让我们看看一个低效的例子,然后是一个具有性能改进的第二例子。以下示例计算有多少 corgi 的名字包含字母“e”:

Corgi[] corgis = getCorgiArray();
int count = 0;
for (int i = 0; i < corgis.length; i++) {
  if (corgis[i].getName().contains("e")) {
    count++;
  }
}

之前的代码有两个低效之处:

  • 循环条件在每次迭代中使用length()方法进行检查。这意味着,对于数组中的每个 corgi,都会访问数组长度,这可能导致不必要的性能开销。

  • 我们的代码片段使用corgis[i].getName().contains("e")来检查 corgi 的名字是否包含字母“e”。因此,对于每个 corgi 名字,我们都在创建一个新的字符串并执行字符串搜索。这肯定会增加计算成本,尤其是在名字很长的情况下。

考虑到这些低效之处,让我们看看代码的一个修订版块:

Corgi[] corgis = getCorgiArray();
int count = 0;
int corgisLength = corgis.length;
for (int i = 0; i < corgisLength; i++) {
  String name = corgis[i].getName();
  if (name.indexOf('e') != -1) {
    count++;
  }
}

此代码片段是一个改进版本,具有以下性能提升:

  • 我们通过将数组长度存储在corgislength变量中来缓存数组长度。由于我们在循环外部填充该变量,我们避免了在每次迭代中对数组长度的重复访问。这可以大大节省计算工作量。

  • 我们不是使用contains()方法来检查名字,而是使用indexof()方法。这里有一个显著的区别。当我们使用contains()方法时,会创建一个新的子字符串并执行完整搜索。使用indexof()方法返回“e”首次出现的位置索引,如果没有找到,则返回-1。这是一个更有效的方法来检查字符串中字符的存在,而不创建不必要的子字符串。

完成了对for循环的探索后,让我们看看如何高效地使用while循环。

while循环

while循环可能是 Java 中第二常用的循环。它们是基本的流程控制结构,我们可以使用它们来重复执行代码块,只要指定的条件保持true

while循环的语法如下:

int count = 0;
int number = 1;
while (number <= 100) {
    count += number;
    number++;
}

这些循环不需要初始化,但一旦使用了初始化,应确保其正确性,以防止出现无限循环等意外的应用行为。当条件为true时,循环体将被执行。循环在条件为false时终止。

条件表达式

我们应该努力创建简单的条件表达式以提高循环效率。过于复杂的条件表达式会增加处理开销,导致性能下降。

让我们来看一个低效的例子,然后是第二个具有性能改进的例子。以下是一个在线订购系统的例子。我们想要检查每个订单 ID 是否以“OL”前缀开头。如果不是,则应该添加:

List<String> orderIDs = getOrderIDs();
int index = 0;
while (index < orderIDs.size()) {
  String orderID = orderIDs.get(index);
  if (!orderID.startsWith("OL")) {
    orderID = "OL" + orderID;
    orderIDs.set(index, orderID);
  }
    index++;
}

在前面的代码片段中存在两个低效之处。首先,列表的大小被反复访问;它在每次迭代时都会进行检查。这可能导致不必要的性能开销。其次,使用+运算符进行字符串连接会创建一个新的字符串,当在循环中重复执行时可能会效率低下。

以下代码片段是修改后的在线订购系统版本:

List<String> orderIDs = getOrderIDs();
int index = 0;
int orderCount = orderIDs.size();
while (index < orderCount) {
  String orderID = orderIDs.get(index);
  if (!orderID.startsWith("OL")) {
    orderIDs.set(index, "OL" + orderID);
  }
  index++;
}

前面的更新代码片段由于两个因素而有望提高系统性能。首先,列表大小现在被缓存,其次,我们使用了一种更有效的字符串修改方法。在这里,我们修改字符串而不创建新的字符串对象。

在完成对while循环的探索后,让我们看看如何高效地使用do-while循环。

do-while 循环

与之前讨论的do-while循环一样,do-while循环是允许在指定条件保持true时重复执行代码块的流程控制结构。这种类型循环的独特之处在于,条件是在循环代码块执行之后进行检查的。这确保了循环代码块至少执行一次。

以下示例展示了用于猜数字游戏的简单do-while循环,用户会继续猜测数字,直到猜对为止:

int secretNumber = generateSecretNumber();
int userGuess;
boolean correctGuess = false;
do {
  userGuess = getUserGuess();
  if (userGuess == secretNumber) {
    correctGuess = true;
  } else {
      System.out.println("Try again!");
    }
  } while (!correctGuess);
  System.out.println("You guessed the secret number: " + 
  secretNumber);

正如你在前面的代码片段中所见,我们使用布尔标志来控制我们的循环。该标志在每次迭代时都会被检查。这种方法效率低下,因为它增加了不必要的复杂性和额外的变量。让我们修改代码,使其更加高效:

import java.util.random;
int secretNumber = generateSecretNumber();
int userGuess;
  do {
    userGuess = getUserGuess();
    if (userGuess != secretNumber) {
      System.out.println("Try again!");
    }
  } while (userGuess != secretNumber);
  System.out.println("You guessed the secret number: " + 
  secretNumber);

在我们的更新示例中,我们使用userGuess != secretNumber作为循环条件,从而消除了对布尔标志的需要。这既简化了我们的代码,也可以使我们的代码更加高效。

for-each 循环

我们在第二章中介绍了for-each循环,并指出这种类型的循环也被称为增强型for循环。当我们想要遍历数据集中的所有元素,而不需要管理迭代器或索引时,我们选择实现这种类型的循环。让我们先看一个初始示例,然后为了更好的性能对其进行优化。

以下代码片段是一个汽车零部件处理应用:

List<String> autoParts = getAutoParts();
for (String part : autoParts) {
  processPart(part);
}

上述代码中的低效源于在循环中对每个汽车零部件进行顺序处理。这可能是一个耗时的操作,并可能导致性能不佳。让我们看看一个改进版本:

List<String> autoParts = getAutoParts();
processAutoParts(autoParts);

在这个改进版本中,我们实现了一个processAutoParts()方法,它接受整个零部件列表作为参数。这允许批量处理零部件,这可以显著提高整体性能。

在我们完成了对循环类型的探索之后,现在让我们回顾一下循环优化的基础。

循环优化基础

当我们努力确保我们的 Java 应用程序具有高性能时,我们当然会关注循环优化。循环在 Java 编程中同样无处不在,这使得它们的低效特别成问题。循环效率可能因实现和使用方式而显著不同。本节将探讨循环优化的基本方面,目的是为您提供知识和工具,以提升您的 Java 应用程序的性能。

循环开销

循环开销指的是与循环的初始化、实现和终止相关的额外计算成本。虽然循环对于实现各种编程任务至关重要,但它们并非没有计算开销。理解循环开销至关重要,因为过度的开销可能会降低您 Java 应用程序的整体性能。

循环开销的三个主要组成部分是循环初始化、条件评估和迭代。正如之前所展示的,每种类型的循环都可能导致一个或多个这些组件的低效。幸运的是,我们可以采用一些策略来帮助最小化循环开销。首先,我们可以优化循环初始化。这可以通过在循环外部初始化我们的控制变量来实现。所获得的性能提升是由于避免了在循环中的冗余赋值。我们还可以最小化在循环中声明的变量数量。

第二种策略是使用简单高效的条件表达式。我们希望避免在循环中包含复杂的条件。如果我们的循环条件依赖于数据集的长度,那么这个长度应该在循环外部缓存,以防止重复访问。

第三,我们可以简化迭代。为了采用这种策略,我们应该确保迭代被设计为正确且高效地更新循环控制变量。我们还应该以高效和正确的方式使用适当的表达式递增/递减。最后,我们应该考虑针对每套要求选择不同的循环类型。

瓶颈

未优化的循环可能导致我们的 Java 应用程序性能出现瓶颈。了解这些瓶颈的原因是避免它们的第一步。正如本章前面所提到的,以下是与 Java 编程中的循环相关的常见性能瓶颈:

  • 低效的条件表达式

  • 低效的迭代步骤

  • 不必要的计算

  • 不理想的数据访问模式

  • 过多的内存分配

我们应该努力实现针对每种循环类型的特定优化策略,如前几节所述。一般的循环优化策略如下:

  • 分析我们的循环以识别性能瓶颈

  • 避免过早优化

  • 选择合适的数据结构

  • 选择合适的算法

此外,正如你将在本章后面学到的那样,分析工具和方法可以帮助我们确定我们的循环中存在哪些瓶颈。

基准测试

优化循环超出了本章涵盖的基本策略,需要深入了解它们的性能、特性和测量优化以提升性能的能力。我们通过基准测试来完成这一测量。我们的目标是评估我们的循环优化的有效性。

我们应该建立一个基准测试环境,以便我们可以评估循环性能。这需要我们设置一个一致的环境,便于准确测量和比较分析。每个优化都可以通过基准测试来测试,以确定它是否提高了或降低了应用程序的性能。

要建立基准测试环境,请遵循以下步骤:

  1. 选择一个提供测量我们 Java 代码性能工具的基准测试框架或库。我们将在第十三章中具体探讨Java 微基准测试工具JMH)。

  2. 确保你的开发环境配置正确且最新。

  3. 编写包含我们想要基准测试的循环的基准测试类。

  4. 为我们的基准测试指定设置和参数。这可以包括测量时间、迭代次数和预热迭代次数。

  5. 确保涉及的变量在所有测试中都是一致的。

  6. 进行预热运行以考虑任何 JVM 特定的预热效果。

  7. 测量执行时间。

  8. 分析结果。

  9. 重复并多次验证以确保你发现的有效性。

  10. 解释你的结果并进行优化。

  11. 记录你的结果。

通过遵循这些步骤建立基准测试环境,你可以就循环优化做出明智的决定,识别性能瓶颈,并微调你的 Java 应用程序以实现更高的性能。

循环展开

循环展开是一种高级优化策略。它是一种我们可以在 Java 中采用的优化技术,以改进我们的循环。这种方法涉及复制或展开循环的代码块多次,以减少所需的迭代次数。更具体地说,我们不是每次迭代执行一次循环的代码块,而是扩展循环,在单个迭代中多次执行代码块。虽然这听起来可能很复杂,但它可以减少由条件检查和更新变量引起的循环开销。

循环展开的好处包括以下内容:

  • 减少循环开销

  • 改善 CPU 指令缓存使用

  • 增强编译器优化

  • 并行机会

循环展开有两种类型:手动和自动。让我们看看每种类型的示例。第一个示例是手动循环展开。在这里,我们必须明确重写我们的循环代码以展开它。以下是一个示例:

// Original loop
for (int i = 0; i < 5; i++) {
  // loop's code block
}
// Manual loop unrolling
for (int i = 0; i < 4; i += 2) {
  // loop's code block (iteration 1)
  // loop's code block (iteration 2)
  // ...
}

手动循环展开为我们提供了对展开过程的有限控制,但由于它是手动完成的,因此难以维护代码。

通过自动循环展开,我们可以利用编译器优化工具自动展开我们的循环代码。编译器将引用我们指定的编译器标志。以下是一个示例:

// Original loop
for (int i = 0; i < 5; i++) {
    // loop's code block
    // ...
}
// Compiler optimization flags

编译器标志

在 Java 中,编译器标志用于在编译时指定非默认设置或选项。我们将标志(例如,-O,以在编译时启用优化)传递给javac编译器命令。

使用自动展开可以简化优化过程并节省我们的时间。这种方法的缺点是展开的结果可能不是最有效的。随着我们循环的复杂性增加,自动展开的结果效率会降低。

当我们有一个已知且固定的迭代次数时,使用循环展开被认为是一种最佳实践。此外,如果循环开销不是一个重要的问题,那么循环展开可能不是必要的。手动展开的副作用可能是增加内存使用。每次展开循环时,我们都应该对性能优化进行性能分析。

循环展开有一些局限性。首先,并非每个循环都适合展开,例如当数据集大小是可变或动态时。展开的另一个局限性或缺点是它可能导致代码膨胀,增加二进制文件大小,这反过来又可能导致指令缓存错误。最后,自动展开不一定总是产生期望的循环优化。

现在,你应该对 Java 中的循环类型有一个全面的理解,包括它们的优缺点和它们理想的使用场景。你现在可以做出更明智的循环选择决策,这有助于确保你创建的代码既高效又易于维护。

测试循环的性能

现在我们已经对不同类型的循环及其优缺点有了牢固的掌握,我们应该有信心做出最佳的循环选择。此外,我们还应该能够实施优化策略。但如何知道我们的循环优化策略是否提高了性能或降低了性能?这就是测试发挥作用的地方,也是本节的重点。

在本节中,我们将涵盖以下概念:

  • 分析工具和方法

  • 基准测试和测试策略

  • 案例研究和示例

我们的目标是获取评估、优化和充分利用 Java 应用程序中循环的全部潜力的所需知识和实践技能。此外,我们希望有一个测试策略,能够告诉我们优化的有效性。

分析工具和方法

在我们的 Java 应用程序中编写优化的循环是不够的。我们应该对分析工具和方法有一个清晰的理解。在我们的环境中,分析是分析我们循环执行以深入了解其性能特性的实践。我们可以从分析中获得关于我们的循环需要多少 CPU 时间的关键信息。

为什么分析很重要

分析被认为是关注 Java 应用程序性能的开发者使用的诊断工具。分析适用于我们代码的各个部分,在本节中,我们的重点是循环。我们使用分析工具来深入了解代码在运行时的行为。以下是我们可以从分析中获得的一些见解:

  • 我们的循环消耗了多少 CPU 时间?

  • 是否存在内存泄漏?

  • 我们的代码是否导致了过度的内存分配?

  • 我们的循环如何与 CPU 缓存交互?

  • 我们的循环如何与内存交互?

我们可以用不同的方式编写循环,并使用分析来帮助我们了解哪种方法在整体运行时性能方面最好。

分析类型

基本上,有三种基本的分析类型:CPU、内存和线程。在CPU 分析中,重点是我们的代码如何使用 CPU。这种分析类型对于识别由我们的循环创建的 CPU 瓶颈特别有用。

内存分析让我们深入了解与内存相关的问题。这些问题可能包括过度的内存消耗、内存泄漏以及过度的或低效的对象创建。这是我们关注循环优化时最有用的分析类型之一。

线程分析类型有助于识别可能影响我们循环性能的同步问题。

分析工具

性能分析工具是帮助我们自动选择性能分析选项的软件应用程序。Java 开发者有许多可用的工具,其中许多是开源的,并且免费使用。这些工具可以分为三类:与 JDK 捆绑的工具、与集成开发环境IDE)捆绑的工具,以及商业或第三方工具。

让我们看看两个与 JDK 捆绑提供的性能分析工具的例子。首先,Java 飞行记录器JFR)是一个内置工具,可以记录和分析应用程序行为。这个工具因其低处理开销而受到赞誉。VisualVM是另一个与 JDK 捆绑提供的性能分析工具。它提供了关于 CPU 使用、内存使用以及线程活动的深刻见解。

性能分析方法

性能分析方法本质上是你进行性能分析的方法。这不仅仅是简单地选择一个性能分析器来使用。实际上,你通常会希望在你的方法中使用多个工具。当被问及你的性能分析方法时,考虑一下你的整体方法是什么。

你可能会采用采样性能分析方法,定期获取应用程序执行的数据。这种采样方法可以快速查看代码执行行为,并可以提供深入了解哪些可能需要更深入的性能分析。

另一种常见的性能分析方法是仪器性能分析。这种方法要求我们在应用程序中添加性能分析代码。虽然这种方法可以提供关于代码执行行为的最高保真度,但它也要求最高的 CPU 开销。

第三种流行的性能分析方法是持续性能分析。这种方法在应用程序长时间运行期间收集大量数据。采用这种方法的开发者可以深入了解长期趋势,并更容易地检测性能异常。

识别循环中的热点

在 Java 循环的上下文中,热点是在运行时消耗过多 CPU 时间或内存的循环。我们迫切需要在我们的循环中确定这些热点,以便我们可以优化它们。

在对性能分析的最后一点说明中,我们应该意识到,识别热点只是第一步,使用性能分析工具则是另一步。我们需要解决我们发现的这些问题,然后重新应用性能分析工具以确保我们的更改产生预期的效果。开发者应该对他们的循环和其他代码保持持续改进的心态。这需要持续监控和性能分析我们的代码。

基准测试和测试策略

我们已经接受,优化循环是确保我们的 Java 应用程序在高水平运行的关键部分。这种高性能需要基准测试和测试策略。本节将探讨这两种策略,并分享最佳实践。

基准测试策略

JMH 是用于进行 Java 代码性能测试最常用的工具包。它在处理 Java 性能测试的有限组件方面做得非常出色。正如其名称所暗示的,它是专门为测试 Java 代码而设计的,并且在微级性能测试中得到广泛应用。JMH 的一些特性如下:

  • 它测量微基准测试(代码段)的性能特征。对于循环性能测试来说,它特别方便。

  • JMH 支持测量平均时间、样本时间、单次时间和吞吐量。这为开发者提供了极大的灵活性。

  • 使用 JMH,开发者可以对测试环境有有限的控制。

  • 可以与构建工具(例如,Maven)集成。

  • 提供详细的微基准测试结果。

无论我们使用哪种基准测试工具,首先建立基准线都很重要。我们可以通过在未优化的代码上运行我们的工具来完成这项工作。一旦我们有了那个测试的结果,我们就有了基准线。测试优化后的代码可以与基准线进行比较。

测试策略

有三种主要的测试策略。第一种是单元测试。这种策略可以帮助我们确保我们对循环所做的任何更改都不会影响预期的代码行为。单元测试还可以帮助我们测试循环的边缘情况,例如使用极端值。

配置文件和热点分析是本章已经介绍过的另一种测试策略。提醒一下,我们使用配置文件工具,如 JFR 或 VisualVM,来帮助我们分析 CPU 和内存使用情况。

最后,我们可以在对应用程序进行更改后使用回归测试来测试我们的循环。这可以帮助确保我们的更改不会对其他任何功能产生负面影响。

无论我们的测试策略如何,我们都应该以迭代的方式处理循环优化测试。这意味着我们应该对我们的循环进行小的、逐步的更改,并在每次更改后进行测试。

案例研究和示例

本节涵盖了优化 Java 中循环的实际案例研究和示例。回顾现实场景可以非常强大,因为我们有机会深入了解常见的挑战和解决方案的最佳实践。

案例研究

考虑一个案例研究,其中我们需要处理大量数据集并根据业务需求聚合数据。在这个场景中,我们的挑战是聚合循环非常慢。由于我们有大量数据集,我们希望解决慢循环的问题。我们能做什么?我们可以确保使用ArrayList而不是LinkedList。我们还可以使用 Java 的Stream API来实现并行处理。这应该能更好地利用多核 CPU。我们还可以在循环中尽量减少对象创建。考虑到这个场景和提出的解决方案,我们很可能会显著减少聚合循环的操作时间以及循环的复杂性。

让我们看看另一个案例研究。这个案例研究是一个银行应用程序,它在大数据集上计算投资风险指标。在这种情况下,挑战在于我们的主要计算循环效率低下。我们可能采取三管齐下的优化策略。首先,我们将检查我们的算法,看看是否可以改进。接下来,我们将编写我们的代码,使其支持即时编译JIT)。最后,我们将查看我们的数据结构,并做出任何必要的更改以最小化内存访问问题。这种三管齐下的方法可以导致应用程序性能显著提高。

示例

让我们看看两个现实世界的循环测试和优化应用。

我们的第一个例子是医疗保健分析的数据处理。挑战在于处理大量患者数据以生成分析报告很慢。解决方案是在我们的循环中实现多线程和批量处理,以便我们可以并行处理数据块。这种方法的成果应该是数据处理时间的显著减少。

另一个例子是作为电子商务 Java 应用程序一部分的库存管理。挑战在于优化我们代码中处理库存更新的循环。对于这种情况,我们将假设循环比过去慢,这很可能是由于库存数据集不断增长。解决方案可能是使用高效的数据结构并相应地更改我们的循环结构。这种方法的成果应该是更快的库存处理和更长的响应时间。

本节涵盖了分析工具、分析方法、基准测试、测试策略、案例研究和示例。我们现在可以自信地评估、优化并充分利用 Java 应用程序中的循环,从而实现高运行时性能。

嵌套循环

现在,我们将介绍在 Java 应用程序中有效优化嵌套循环所涉及的基本概念、实用优化策略和技术复杂性。在接下来的章节中,我们将探讨嵌套循环的概念,以及它们与高性能 Java 应用程序的关系:

  • 嵌套循环简介

  • 嵌套循环中的循环融合

  • 嵌套循环并行化

  • 嵌套循环向量化

理解何时使用嵌套循环以及如何以最优化方式实现它们是很重要的。

嵌套循环简介

嵌套循环是指一个循环位于另一个循环内部。这创建了一个复杂的迭代场景。以下是一个简单嵌套循环的语法:

for (int i = 0; i < 10; i++) {          // Outer loop
    for (int j = 0; j < 10; j++) {      // Inner loop
        System.out.println("i = " + i + ", j = " + j);
    }
}

如您在先前的语法中看到的那样,外循环运行 10 次,对于每次迭代,内循环也运行三次。这导致总共 10 x 10 = 100 次迭代。

我们可以在嵌套循环中实现有限级别的嵌套。随着每个内部循环的出现,我们的代码变得更加复杂,更难以阅读,也更难以维护。那么,我们何时会使用嵌套循环呢?一个常见的实现是当处理多维数组,如矩阵时。它们也用于在表格上执行操作。

警告

在实现嵌套循环时要小心。它们可能会非常快地变得低效,并使你的应用程序变得缓慢且无响应。

嵌套循环中的循环融合

如果我们必须在 Java 应用程序中实现嵌套循环,为了提高效率,我们应该考虑对它们应用循环融合。循环融合本质上是一种算法方法,其中我们将相邻的循环,即对同一数据集执行操作的循环,合并成一个循环。

我们还可以尝试减少冗余计算并提高缓存利用率。当我们合并循环时这是可能的,因为它可以最小化冗余,从而提高缓存的使用。这种技术涉及在相同迭代期间重新使用加载到缓存中的数据。结果通常是更少的缓存未命中。

当使用复杂算法,如矩阵乘法时,循环融合也可能证明是有益的。可能存在一个副作用,即增加了循环的复杂性。这种更大的复杂性导致代码可读性降低,使得代码更难以维护。

并行化嵌套循环

要在 Java 中并行化嵌套循环,我们需要重新构建我们的循环,以便它们可以在多个 CPU 核心上并发执行,而不是在单个核心上。正如你所期望的,这是并行计算的一个组成部分,可以显著提高运行时性能。并行化嵌套循环的策略包括将数据集分块,并在单独的线程中处理每个块。

多线程的概念通常与嵌套循环中的并行流一起讨论。比较两者,我们可以了解到多线程提供了额外的控制,但需要我们显式地管理线程和任务。Java 的 Stream API 中的并行流提供了一种更简单的方法来并行化操作,但我们对线程模型的控制较少。

最后,在并行化时,我们必须处理同步和线程安全。同步变得至关重要,确保线程安全也同样重要。这在我们处理共享数据结构时尤其如此。

嵌套循环向量化

你可能还记得,从本书的第二章中,向量化指的是在多个数据点上同时执行单个指令的过程。我们可以利用向量化来提高嵌套循环的计算速度,尤其是在处理复杂的数据操作,如矩阵时。

单指令多数据SIMD)在嵌套循环优化中可能是一个重要的概念。SIMD 是向量化中的一个关键概念,正如其名称所暗示的,它对多个数据元素执行单个操作。这对于重复的嵌套循环来说是一种特别有效的技术。向量化的有效性取决于编译器优化和硬件支持。并非所有 CPU 都具有 SIMD 功能。

本节提供了一个结构化的嵌套循环优化概述,旨在提高 Java 应用程序的性能。涵盖了基本概念,以及实用的优化策略,还涉及在 Java 应用程序中有效优化嵌套循环的一些技术复杂性。

摘要

本章重点介绍了循环,这是编程的基本结构,以及如何从运行时性能的角度最大限度地利用它们。涵盖的概念包括循环开销、循环展开、基准测试、循环融合、循环并行化和循环向量化。我们通过代码示例提供见解并展示最佳实践。

我们探讨了技术、策略和最佳实践,以帮助您从循环中获得最佳性能,并防止无意中引入重大的瓶颈,从而损害运行时性能。

下一章将专门探讨 Java 对象池,这是一种设计模式,用于管理可重用对象以节省资源并提高应用程序性能。

第四章:Java 对象池

我们使 Java 应用程序高度性能化的使命包括对 Java 对象池的考察。本章深入探讨了 Java 中对象池的概念以及如何在您的 Java 应用程序中使用它们来实现高性能。

本章首先解释了对象池及其在 Java 中的实现方法。提供了示例代码,以帮助您理解 Java 编程语言中特定的对象池操作。您还将有机会了解 Java 中对象池的优点和缺点。最后,本章展示了如何使用 Java 对象池进行性能测试。

本章涵盖了以下主要主题:

  • 进入对象池

  • 优点和缺点

  • 性能测试

到本章结束时,您应该对 Java 对象池有坚实的理论理解,以及实际操作经验。这种经验可以帮助确保您从 Java 应用程序中获得高性能。

技术要求

要遵循本章中的示例和说明,您需要能够加载、编辑和运行 Java 代码。如果您尚未设置您的开发环境,请参阅第一章

本章的完成代码可以在这里找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter04

进入对象池

在我们进入对象池之前,让我们看看什么是对象池。

对象池

对象池是一组可以重复使用的对象。

使用对象池是一种优化方法,可以积极影响应用程序的性能。我们不需要每次需要时都重新创建对象,而是将一组对象池化,并简单地回收它们。为了帮助理解对象池,可以考虑一个现实世界的例子,即物理图书馆。图书馆可以借出书籍(我们的对象),当读者用完书后,将它们归还到集合(我们的池)中。这允许图书馆将书籍重新借给下一个需要的人。考虑一下另一种情况。如果图书馆在每次使用后销毁(垃圾收集)书籍,那么每次需要时都必须创建一个新的。这将不会高效。

数据库示例

在 Java 编程中,一个常见的对象池实现是使用数据库连接。数据库连接的典型方法是与数据库建立连接,然后执行所需的操作来更新或查询数据库。使用打开-查询-关闭过程。这种方法的问题在于频繁地打开和关闭数据库可能会影响 Java 应用程序的整体性能。这种处理开销是我们应该尽量避免的。

使用对象池的方法,以我们的数据库示例为例,涉及维护一个空闲的预创建数据库请求池。当应用程序请求数据库连接时,会从池中取出一个。下一节将演示如何在 Java 应用程序中创建和使用这些池。

在 Java 中实现对象池

根据前一小节中的数据库连接示例实现对象池,涉及一个数据库连接类、一个对象池类以及一个包含main()方法的类。让我们逐一查看这些类。

注意

此示例模拟对象池,并不连接到实际的数据库。

首先,我们有我们的DBConnect类。这是我们将会池化的类:

public class DBConnect {
  static int dbConnectionCount = 0;
  private int dbConnectID;
  public DBConnect() {
    this.dbConnectID = ++dbConnectionCount;
    System.out.println("Database connection created: DBConnect" + 
    dbConnectID + ".");
  }
  public void dbMethod() {
    // placeholder
  }
  public void dbConnectionClose() {
    System.out.println("Database connection closed: DBConnect" + 
    dbConnectID + ".");
  }
}

如前述代码所示,这里有一些功能占位符。

接下来,我们创建一个DBConnectObjectPool类来维护一个DBConnect对象集合(池):

import java.util.LinkedList;
import java.util.Queue;
public class DBConnectObjectPool {
  private final Queue<DBConnect> pool;
  private final int maxSize;
  public DBConnectObjectPool(int size) {
    this.maxSize = size;
    this.pool = new LinkedList<>();
  }
  public synchronized DBConnect getConnection() {
    if (pool.isEmpty()) {
      if (DBConnect.dbConnectionCount < maxSize) {
        return new DBConnect();
      }
      throw new RuntimeException("Error: Maximum object pool size 
      reached. There are no DB connections available.");
    }
    return pool.poll();
  }
  public synchronized void releaseConnection(DBConnect dbConnection) {
    if (pool.size() < maxSize) {
      pool.offer(dbConnection);
      System.out.println("Splash: Connection object returned to the 
      pool.");
    } else {
      dbConnection.dbConnectionClose();
    }
  }
}

如前述代码所示,我们假设了一个最大连接数。这被认为是一种最佳实践。

最后,我们有一个部分应用示例来展示如何使用我们的对象池:

public class ObjectPoolDemoApp {
  public static void main(String[] args) {
    DBConnectObjectPool objectPool = new DBConnectObjectPool(8);
    for (int i = 0; i < 10; i++) {
      DBConnect conn = objectPool.getConnection();
      conn.dbMethod();
      objectPool.releaseConnection(conn);
    }
  }
}

当我们的应用程序请求数据库连接时,会从池中提供一个。在连接不可从池中获取的情况下,会创建一个新的连接。我们确实检查以确保不超过允许的最大连接数。最后,在DBConnect对象使用后,将其返回到对象池。

对象池的优势和劣势

现在你已经了解了对象池是什么以及如何在 Java 中实现它,我们应该考虑这是否是我们应用程序的正确策略。与大多数应用程序代码优化方法一样,都有其优缺点。本节将探讨这两者。

优势

使用对象池有几个潜在的优势。这些优势可以分为性能、资源管理和可扩展性类别。

性能优势

实现对象池可以让我们避免对象创建的开销。这种方法在需要高交易量应用和系统响应时间重要的场景中尤其有用。通过对象池,我们可以帮助确保我们的 Java 应用程序能够通过避免过多的对象创建来提高性能。

我们还可以在应用程序使用过程中体验到一致的性能。例如,使用对象池应该导致在最小负载和重负载下应用程序性能的一致性。这种可预测的行为是由于我们应用程序的稳定性。这种稳定性是通过避免频繁的对象创建和对垃圾回收的过度依赖来实现的。

资源管理优势

在对象池优势的背景下,资源指的是实时、处理负载和内存。减少对象创建和销毁操作的数量是对象池方法的一个好处。本章前面提到的例子是数据库连接。之所以使用这个例子,是因为数据库连接操作是众所周知的资源消耗者。对象池方法减少了执行这些操作所需的时间,并且资源消耗较少。

另一个资源管理优势是它增加了我们的内存管理架构。当对象创建不受控制时,消耗的内存量是可变的,可能会导致系统错误。

可扩展性优势

第三个优势类别是使我们的应用程序更具可扩展性。这在我们有大量同时用户的应用程序中尤其如此。在处理数据库连接时,数据库是共享资源,这也很有益。对象池本质上充当了这些请求的缓冲。

另一个原因是我们使用对象池的应用程序更具可扩展性,那就是我们对资源的控制能力得到了增强。在本章前面提到的数据库连接示例中,我们设置了池中对象的最大数量。

劣势

不幸的是,使用对象池的潜在劣势比优势多。这些劣势可以分为代码复杂性和资源管理类别。

代码复杂性劣势

就像任何非标准编程方法一样,对象池会增加我们的代码复杂性。我们创建了与对象池相关的类,这些类必须包含管理对象池的算法以及与主程序的接口。尽管这不太可能导致代码膨胀,但它可能会使维护变得困难。

在 Java 应用程序中实现对象池时,每次系统、连接系统或数据发生变化时,都会增加一个测试组件。这可能会消耗时间和资源。

资源管理劣势

总是有风险,尤其是在高峰负载时段,资源可能不足。当我们设置对象池的最大大小时,它们可能不足以处理那些高峰负载时段。这也可以被称为资源饥饿,因为我们的池中所有对象都已分配,阻止新的请求入队。这些延迟可能导致整体性能下降和用户不满。

与内存分配和释放一起工作可能会出现问题。如果我们没有,例如,管理对象返回池的方式,可能会造成数据丢失。这可能会加剧池中无可用对象的情况。实现错误检查和异常处理变得至关重要。

最后,我们需要保持对象池过大或过小的平衡。如果它太小,可能会导致池化对象的大量排队时间。如果池子太大,应用程序可能会过度消耗内存,从而影响其他可能利用它的应用程序区域。

在考虑了优点和缺点之后,你应该能够确定对象池是否最适合你的应用程序。

性能测试

当我们在 Java 应用程序中实现对象池时,我们想要做三件事:

  • 确保我们的程序正常工作

  • 证明我们的实现提高了性能

  • 量化优化

在前面的章节中,我们探讨了如何在 Java 中实现对象池。在本节中,我们将探讨如何设计性能测试,如何实施对象池性能测试,以及如何分析测试结果。

设计性能测试

在我们决定实施性能测试后,我们的第一个行动是设计测试。我们需要回答的问题包括以下内容:

  • 我们的目标是什么?

  • 我们将测量什么?

  • 我们将如何测量?

  • 我们的测试将存在什么条件?

在考虑这些问题的基础上,我们可以开始设计我们的性能测试。我们应该为我们的性能测试设定一个明确的目标或一系列目标。例如,我们可能希望关注系统内存、CPU 负载等。

一旦我们有一个具体的目标,我们必须决定要测量什么。在测试中,我们测量的是被认为是关键性能指标KPIs)。对象池的性能测试可能包括内存使用、CPU 使用、数据吞吐量和响应时间。这些只是其中的一些例子。

接下来,我们需要设置我们的测试环境并创建测试场景。测试环境紧密地复制了生产系统。你可能会在开发环境中复制你的系统,这样就不会影响实时系统。同样,测试场景应该紧密地反映你系统在现实世界中的使用。在尽可能的范围内,我们应该创建尽可能多的不同场景来代表我们的实时系统所处理的内容。

到目前为止,你已经准备好记录你的测试计划并实施它。下一节将介绍如何实施性能测试。

实施性能测试

实施你的测试计划不应该非常困难。在这里,你只是在将你的计划付诸实施。测试环境已经建立,你运行你的测试场景。在测试运行时,你应该收集数据以供后续分析。至关重要的是能够重现你的测试条件以支持未来的比较测试。

让我们看看如何使用本章中的数据库连接示例在 Java 中编写性能测试。我们的目标是减少应用程序从对象池获取数据库连接并在此数据库上执行简单操作所需的时间。我们的测试计划将比较我们的测试结果与未实现对象池的应用程序版本上的相同测试结果。

我们的代码从类声明和类变量开始:

public class DBConnectionPerformanceTest {
  private static final int NUMBER_OF_TESTS = 3000;
  private static DBConnectObjectPool dbPool = new 
  DBConnectObjectPool(24);

接下来,我们将编写main()方法的第一部分。这段代码将是我们使用对象池进行测试的方式:

public static void main(String[] args) {
  long startTime_withPooling = System.nanoTime();
  for (int i = 0; i < NUMBER_OF_TESTS; i++) {
    DBConnect conn = dbPool.getConnection();
    conn.dbMethod();
    dbPool.releaseConnection(conn);
  }
  long endTime_withPooling = System.nanoTime();

现在,我们将编写代码来测试不使用对象池的情况:

long startTime_withoutPooling = System.nanoTime();
  for (int i = 0; i < NUMBER_OF_TESTS; i++) {
    DBConnect conn = new DBConnect();
    conn.dbMethod();
    conn.dbConnectionClose();
  }
long endTime_withoutPooling = System.nanoTime();

在编写完两组性能测试后,我们需要添加计算和输出结果的能力。我们通过简单地从endTime值中减去startTime值并将其转换为毫秒来生成结果。然后我们将结果输出到控制台:

long totalTime_withPooling = (endTime_withPooling - startTime_withPooling) / 1_000_000;
long totalTime_withoutPooling = (endTime_withoutPooling - startTime_withoutPooling) / 1_000_000;
System.out.println("Total time with object pooling: " + totalTime_withPooling + " ms");
System.out.println("Total time without object pooling: " + totalTime_withoutPooling + " ms");

这个简单的对象池性能测试示例旨在给你一个如何编写这些测试的一般概念。每个应用程序都是不同的,你编写性能测试的方式也会有所不同。

分析结果

一旦我们的测试完成,我们就可以分析结果。你如何分析结果将取决于你的目标和关键绩效指标(KPIs)。分析任务不应仓促完成。记住,你收集这些数据是为了帮助你在对象池上做出决策。复杂性将根据性能测试计划而变化。

以数据库连接为例,我们可以简单地将它添加到DBConnectionPerformanceTest类的底部,以比较两组结果。以下是该代码的第一部分:

if (totalTime_withPooling < totalTime_withoutPooling) {
  System.out.println("Results with object pooling: " + totalTime_
  withPooling);
  System.out.println("Results without object pooling: " + totalTime_
  withoutPooling);
  System.out.println("Analysis: Object pooling is faster by " + 
  (totalTime_withoutPooling - totalTime_withPooling) + " ms");
}

如您所见,我们只是检查totalTime_withPooling是否小于totalTime_withoutPooling。如果是这种情况,相关结果将在控制台上显示。

接下来,我们将检查totalTime_withPooling是否大于totalTime_withoutPooling。相关结果将在控制台上显示:

} else if (totalTime_withPooling > totalTime_withoutPooling) {
  System.out.println("Results with object pooling: " + totalTime_
  withPooling);
  System.out.println("Results without object pooling: " + totalTime_
  withoutPooling);
  System.out.println("Analysis: Object pooling is slower by " + 
  (totalTime_withPooling - totalTime_withoutPooling) + " ms");
}

当前两个条件不满足时,我们的最终代码片段将执行。这意味着两次测试花费了相同的时间:

} else {
  System.out.println("Results with object pooling: " + totalTime_
  withPooling);
  System.out.println("Results without object pooling: " + totalTime_
  withoutPooling);
  System.out.println("Analysis: No significant time difference between 
  object pooling and non-pooling.");
}

与所有测试一样,你应该记录你的计划、测试结果、分析、结论以及测试后的行动。这种稳健的文档方法将帮助你详细保留测试的历史。

摘要

本章深入探讨了 Java 对象池。建议对象池是确保我们的 Java 应用程序在高水平上运行的重要技术。在掌握理论知识的基础上,本章探讨了对象池的优点和缺点。我们关注了内存、CPU 使用和代码复杂度等方面。最后,我们展示了如何创建性能测试计划,如何实施它,以及如何分析结果。

在下一章中,我们将关注算法效率。我们的目标将是确保我们的算法具有低时间复杂度。本章将展示低效算法以及如何将它们转换为支持高性能。

第五章:算法效率

专注于确保他们的 Java 应用程序达到高水平的开发者必须考虑单个算法的效率。我们不是通过算法的代码行数来判断其效率;相反,我们在分析测试结果后做出这种判断。

本章旨在帮助你学习如何为任何特定需求选择正确的算法。它还涵盖了时间复杂度的概念,包括降低时间复杂度的策略。我们还将关注简洁高效的代码。本章还强调了算法测试的重要性。

本章涵盖了以下主要主题:

  • 算法选择

  • 低时间复杂度

  • 测试算法的效率

到本章结束时,你应该对算法效率有坚实的理论理解,同时也有创建和修改算法的实践经验。这种经验可以帮助确保你从 Java 应用程序中获得高性能。

技术要求

要遵循本章中的示例和说明,你需要能够加载、编辑和运行 Java 代码。如果你还没有设置你的开发环境,请参阅第一章

本章的完整代码可以在以下链接找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter05

算法选择

编写软件的最好之处之一是,对于一个问题没有唯一的解决方案。我们可以自由地使用独特的风格,并整合数据结构、库和算法,只要我们获得正确的结果。这有点夸张。我们可以用几乎无限的不同方式编写算法并得到相同的结果。

这种编程灵活性也可能成为一种不利因素,正如低性能算法所证明的那样。所以,尽管我们可以随心所欲地编写算法,但这并不意味着我们应该这样做。我们应该在算法选择和创建上采取战略性的方法,因为它将对我们的 Java 应用程序的整体效率产生重大影响。

在本节中,我们将探讨选择算法的特定过程、案例研究和发展趋势。

选择过程

虽然没有行业范围内的官方算法选择流程,但这里有一个六步法:

  1. 完全理解需求。这看起来可能很明显,但许多开发者在这方面犯了错误。完全理解需求是至关重要的。这是我们想要算法解决的问题。理解问题涉及学习约束、数据集、输入、输出等等。一旦我们完全理解了需求,我们就可以进行下一步。

  2. 在这一第二步中,我们应该熟悉我们的算法将要关联的数据。这可能包括静态数据、数据流,甚至生成数据。这一步是你完全沉浸在与应用程序数据相关的一切中的时候。除了数据熟悉之外,我们还应该开始考虑我们的算法应该使用哪种类型的数据结构。

  3. 考虑每个潜在算法的计算复杂度。这里需要考虑的因素包括时间和内存需求。你可以实施基准测试来帮助你做出决定。

在这一步中,你应该确定与处理和内存相关的资源限制。了解这些上限将有助于你的算法决策。

这是你测试你的算法、进行改进并重新测试的地方。这是一个迭代过程,你应该对你的算法进行小的、渐进性的更改,以帮助确定什么是最有效的。

记录你的最终决定以及导致这一决定的所有因素。你的文档越详细,越好。这将在你稍后回顾你的选择时有所帮助。

理想情况下,你应该采取持续的过程改进心态,不要犹豫去质疑之前的决定。即使你选择并改进了一个能够实现最佳性能的算法,它也应该定期进行审查。环境、数据和其他因素可能会随时间变化并影响算法的效率。

现在,让我们通过回顾下一节的案例研究来了解这些步骤如何实施。

案例研究

复习案例研究可以帮助我们巩固对算法选择如何显著提高 Java 应用程序性能的理解。本节详细介绍了企业电子商务平台的算法选择。该企业指出,其系统处理搜索请求效率低下,导致用户投诉系统运行缓慢。因此,该企业希望改进搜索功能,从而提高其应用程序的整体性能。

在这个案例中的第一步是确定导致问题的原因。在我们的例子中,电子商务平台实施了一个线性搜索算法,该算法将用户请求与数据库条目相匹配。自初始系统开发以来,搜索算法没有改变,而数据库的规模持续增加。所有搜索结果都缓慢。

我们的第二步是理解数据。因此,开发者审查数据库模式并查看样本数据库记录,以便熟悉系统的数据需求和用途。在接下来的两个步骤中,开发者审查搜索功能的计算复杂度,并确定任何资源限制。

开发者现在可以评估替代算法。在这个示例中,可能的替代方案可能包括二分搜索和其他经过验证的搜索模式。对这些替代方案的审查导致采用倒排索引作为可以优化全文搜索的数据结构。

倒排索引

一种常用于全文搜索的数据结构。索引识别每个独特的术语并列出该术语出现的位置。实现通常包括使用字典或哈希表。

现在的开发者可以根据他们系统的需求来定制他们的选择。接下来,他们将进行多次测试并记录结果。在这个示例案例中,系统的搜索功能现在性能极高。剩下的是记录决策和决策因素。

示例案例向开发者表明,他们需要在应用程序性能和资源利用之间取得平衡。接下来,让我们看看一些算法选择的发展趋势。

发展趋势

Java 软件开发是一个动态的领域,我们创建和使用的算法也是如此。关于算法效率有几个趋势,本节描述了其中五个。

  • 人工智能和机器学习:科技领域的许多人将 2023 年称为人工智能AI)之年。人工智能和机器学习ML)并不新鲜,但它们的广泛应用和采用从未如此普遍,这得益于 OpenAI 在 2015 年的推出以及生成式预训练转换器GPTs),如 ChatGPT 的引入。行业正在经历向与 AI 相关的算法的转变。

    您可以在第十八章中了解更多关于利用 AI 进行高性能 Java 应用的信息。

  • 并发:随着多核处理器的普及,算法需要专注于并行处理的需求增加。幸运的是,Java 对并发提供了强大的支持。

    您可以在第九章中了解更多关于并发策略的信息。

  • 云优化:随着越来越多的应用程序托管在云上,我们需要更新 Java 应用程序以进行云优化的需求增加。云计算要求我们考虑可扩展性和分布式处理等概念。这些考虑对于正在为云开发的新 Java 应用程序以及我们希望迁移到云上的现有应用程序都至关重要。

  • 安全:在软件开发中,似乎一直保持不变的是我们系统和数据所面临的网络威胁数量不断增加。安全是一种心态,而不是软件开发中的一个步骤。这里的趋势是在整个产品生命周期中对安全的重视程度不断提高。

  • 社区:在众包算法设计和其他开源贡献领域取得了令人印象深刻的增长。由此产生的系统和算法越来越稳健和多样化。

现在我们已经回顾了算法选择,分享了一个案例研究,并讨论了算法趋势的发展,我们可以看看低时间复杂度与算法效率的关系。

低时间复杂度

时间复杂度是指任何给定算法的时间效率的度量。我们希望确定 Java 应用程序的执行时间,并确保我们的算法不会给应用程序增加时间复杂度。总体目标是减少算法执行时间。我们应该使用各种可能的输入和环境来测试我们的算法。

在本节中,我们将探讨具体的策略,以帮助您降低算法的时间复杂度。我们还将讨论与时间复杂度相关的一些常见陷阱。

降低时间复杂度的策略

我们可以采用几种策略来帮助我们降低算法的时间复杂度。可能最简单的策略就是确保我们的算法不过于复杂。这需要我们仔细检查我们的算法逻辑,并使用本书中涵盖的优化技术。减少循环中的不必要的计算,尤其是在递归中,是一种很好的优化策略。

有时我们的算法可能看起来过于复杂,我们找不到简化它们的方法。在这些情况下,考虑将算法分解成多个算法,每个算法执行原始指令集的一个子集,是值得考虑的。

常见陷阱

当审查与算法时间效率相关的常见陷阱时,我们可以参考前面的章节,并查看即将到来的其他章节。正如您从这里列出的前四个常见陷阱的列表中可以看到,详细信息可以在本书的几个章节中找到:

  • 数据结构使用不当(第二章)

  • 过度使用非标准库(第十三章)

  • 测试和性能分析不足(第十四章)

  • 忽视可读性和可维护性(第十六章)

此外,我们可能只是忽略了应用程序整体性能的大局。有时,我们可能会陷入代码的细节中,以牺牲整体算法或应用程序效率为代价。

正如我们所见,低时间复杂度既是一个概念也是一个目标。当我们的最终目标是提高 Java 应用程序的性能时,这是一个重要的考虑因素。

测试算法的效率

到目前为止,很明显我们需要我们的算法是高效的。本节重点介绍如何根据我们的需求衡量算法效率。我们将从一个关于测试重要性的简短部分开始,然后介绍如何准备测试,如何进行测试,以及测试之后应该做什么。

测试的重要性

简而言之,如果我们不对我们的算法进行效率测试,我们就无法确定它们是高效的还是低效的。如果我们不衡量它们对特定算法和整体应用性能的影响,优化就毫无意义。

我们可能会根据我们的经验假设我们知道哪些优化将带来最佳性能。尽管这是基于经验的,但它仍然只是轶事。输入和操作环境的变化可能会挑战我们对算法效率的先前知识,因此我们不应该未经测试就假设。

小贴士:成为一个测试狂热者

关注高性能的优秀程序员致力于这样一个观点:算法效率测试是软件生命周期的一个核心部分。

准备算法效率测试

设置测试环境可能需要时间,因此为它制定计划并分配足够的时间在你的开发项目中是很重要的。以下是准备算法效率测试的七个步骤概述:

  1. 识别需求:在这个第一步,我们需要彻底理解算法旨在做什么,以及你将在测试中使用的关键性能指标KPIs)。

  2. 建立测试环境:我们很少在生产环境中进行测试,因此我们需要在开发环境中复制数据和条件。目标是尽可能接近地反映生产环境。

  3. 工具选择:在这个步骤中,我们选择我们将使用的分析和基准测试工具。

  4. 获取测试数据:在这里,我们想要创建一个实时数据的副本,以便我们可以用它进行测试。

  5. 基准测试:在这个步骤中,我们建立基线,以便我们可以将我们当前的算法与未来的优化版本进行比较。

  6. 详尽的文档:记录我们的测试计划是很重要的,这样我们就可以在未来复制它们。

  7. 迭代测试:这一最后步骤本质上是一个调度步骤。为了支持迭代测试的需求,应该安排多次迭代。

现在你已经知道了如何准备测试,让我们看看如何进行测试。

进行测试

你现在可以实施你的测试计划了。让我们看看一个简单的 Java 代码示例。

我们的例子将是测试冒泡排序算法的执行时间。以下是我们的冒泡排序算法:

public class EfficiencyTestExample {
  public static void main(String[] args) {
    algorithmEfficiencyTest();
  }
  // Here is our bubble sort algorithm
  public static void ourBubbleSort(int[] array) {
    int nbrElements = array.length;
    int temp;
    for (int i = 0; i < nbrElements; i++) {
      for (int j = 1; j < (nbrElements - i); j++) {
        if (array[j - 1] > array[j]) {
          temp = array[j - 1];
          array[j - 1] = array[j];
          array[j] = temp;
        }
      }
    }
  }
}

接下来,我们需要创建一个测试我们的冒泡排序算法执行时间的方法。这将是这样的:

public static void algorithmEfficiencyTest() {
  int[] ourTestArray = new int[30000];
  for (int i = 0; i < ourTestArray.length; i++) {
    ourTestArray[i] = (int) (Math.random() * 30000);
  }
  long startTime = System.nanoTime();
  ourBubbleSort(ourTestArray);
  long endTime = System.nanoTime();
  long duration = (endTime - startTime);
  System.out.println("Execution Time (nanoseconds): " + duration);
}

正如你所看到的,我们创建了一个包含 30,000 个元素的测试数组,并用随机数填充它。接下来,我们记录了开始时间,执行了冒泡排序,然后记录了结束时间。有了开始和结束时间,我们就知道算法花费了多长时间。输出可能看起来是这样的:

Execution Time (nanoseconds): 1054427380

接下来,让我们看看我们的测试后操作应该是什么。

测试后操作

我们的测试完成后,我们的工作并未结束。我们需要执行以下测试后行动:

  • 深入分析我们的测试结果

  • 根据我们的测试结果优化我们的算法

  • 记录算法的更改并记录我们做出更改的原因

  • 根据适当情况,与利益相关者分享您的发现和流程

  • 当需要时,更新我们的测试环境

  • 采用持续过程改进的心态,并持续监控

  • 进行事后反思,并记录关于流程及其改进方法的学习内容

当我们彻底分析我们的测试结果并执行其他测试后行动时,我们增加了测试计划得到适当执行并产生有效结果的机会。

摘要

本章旨在帮助您学习如何为任何特定需求选择正确的算法,以及如何衡量您的结果。它涵盖了时间复杂度的概念,并包括了降低时间复杂度的策略。我们还强调了算法测试的重要性。您现在应该对算法效率有很强的理论理解,并且有创建和修改算法的实践经验。这种经验可以帮助确保您从 Java 应用程序中获得高性能。

在下一章,战略对象创建和不可变性中,我们将介绍以提升我们 Java 应用程序整体性能为目标的对象创建策略。该章节涵盖了最小化对象创建、对象不可变性和垃圾回收。

第二部分:内存优化和 I/O 操作

内存管理对于高性能 Java 应用程序至关重要。本部分重点介绍战略对象创建和利用不可变性来优化内存使用。它还涵盖了有效处理字符串对象以及识别和预防内存泄漏。本部分中的章节提供了关于高效管理内存和确保稳健 I/O 操作的实用见解。

本部分包含以下章节:

  • 第六章战略对象创建和不可变性

  • 第七章字符串对象

  • 第八章内存泄漏

第六章:战略性对象创建和不可变性

本章继续我们的寻找方法,以从我们的 Java 应用程序中获得最佳性能。创建对象是所有 Java 应用程序的核心部分,因此目标不是消除它;而是采取战略性的对象创建方法。

我们如何以及何时创建对象可能在应用程序性能中扮演关键角色。对象创建不仅影响性能,还影响整体效率、垃圾回收和内存使用。本章旨在为您提供实现对象创建策略所需的知识和技能。

对象创建策略的核心部分是对象不可变性的概念。本章提供了如何使对象不可变的信息和示例,并解释了为什么你应该考虑这一点。

本章涵盖了以下主要主题:

  • 最小化对象创建

  • 对象不可变性

  • 垃圾回收

  • 设计模式

到本章结束时,你应该对战略性对象创建和对象不可变性的强大概念有一个理解和欣赏。这种理解将帮助你提高 Java 应用程序的性能。

技术要求

要遵循本章中的示例和说明,你需要能够加载、编辑和运行 Java 代码。如果你还没有设置你的开发环境,请参阅第一章

本章的代码可以在以下位置找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter06

最小化对象创建

在追求高性能应用程序时,最小化对象创建是一个关键关注点。每次我们创建一个对象,我们都会使用内存和处理资源。尽管现代系统拥有令人印象深刻的内存容量和处理能力,但它们并非无限。

为了确保我们正确处理这个关注点,我们应该寻求理解 Java 对象的整个生命周期,对象创建如何影响内存,以及对象池是什么。我们还应该尝试不同的对象初始化方法和减少系统开销的方法。这就是本节的目标。让我们通过查看 Java 对象的整个生命周期来开始我们最小化对象创建的探索。

Java 对象生命周期

Java 编程语言中对象的概念并非新颖。在考虑高性能 Java 应用程序时,我们需要考虑我们对象的整个生命周期。对象的创建、使用和删除直接影响到我们应用程序的整体性能。以下图展示了典型对象的典型生命周期:

图 6.1 – Java 对象生命周期

图 6.1 – Java 对象生命周期

如*图 6**.1 所示,对象生命周期的第一步是对象创建。当创建一个新对象时,它被称为使用new关键字在堆上分配内存,以便我们可以存储对象的数据:

public class Corgi {
  // Instance variables
  private String name;
  private int age;
  private int weight;
  // Constructor
  public Corgi(String name, int age, int weight) {
    this.name = name;
    this.age = age;
    this.weight = weight;
  }
  // Getter methods
  // Setter methods
  // Methods
}

对象生命周期的下一阶段是其使用和引用。这是执行对象方法并引用其属性的地方。

第三个阶段是垃圾回收。一旦我们停止使用一个对象,并且它不能再被引用,Java 虚拟机(JVM)的垃圾回收器将回收该对象使用的内存。

对象生命周期的最后阶段是它被销毁。垃圾回收器为我们处理这个问题。一旦对象被销毁,它就不再对应用程序可访问。

理解对象的生命周期是能够创建和采用对象创建策略的先决条件。

内存

我们隐含地理解到,对象的存在需要内存,并且我们的应用程序在某一时刻使用的对象越多,所需的内存就越多。为了支持高性能,我们应该努力理解 Java 如何管理特定于对象创建的内存。为了帮助我们的理解,让我们看看三个关键点。

栈与堆内存

都用于内存分配,用于不同的目的,并且表现不同。让我们首先定义栈。

栈是内存区域,其中使用后进先出LIFO)模型存储静态元素集合。

接下来,让我们看看堆是什么,以便我们可以将其与栈进行比较,并确定它们如何影响性能。

堆是用于动态内存分配的内存区域。当我们使用应用程序中的new关键字时,对象在堆上分配。

Java 使用栈和堆,正如你所学的,它们的使用方式不同。让我们更深入地了解栈。我们通常使用栈来存储应用程序的局部变量,以及方法引用信息。栈使用LIFO以高效访问局部变量和方法调用。栈的限制因素包括其有限的生命周期和大小。这使得使用栈进行长期对象存储不切实际。

当我们使用 Java 的new关键字时,我们将对象推入堆中。堆为我们提供动态内存分配。当我们使用堆时,我们的对象可以存在于创建它们的方法的范围之外。这需要使用 JVM 的垃圾回收器从堆中移除不可达的项。

下表总结了 Java 中栈和堆之间的差异:

特征
存储 局部变量 对象及其方法和属性
内存管理 LIFO 顺序 由 JVM 垃圾回收器管理
大小 有限 大于栈
生命周期 变量存在直到声明它的方法结束 对象存在直到它们不再可达
性能 快速分配和释放 比栈慢

表 6.1 – 栈和堆比较

栈和堆之间的关键区别在于它们的使用范围、内存管理方式、访问速度和存储大小。此外,还有运行时出错的风险。例如,栈可能会耗尽内存,导致StackOverflowError错误。如果堆耗尽内存,可能会抛出OutOfMemoryError错误。我们需要处理这些错误。让我们看一个例子:

public static void main(String[] args) {
  // Catch StackOverflowError
  try {
    // your code goes here
  } catch (StackOverflowError e) {
    System.out.println("Caught StackOverflowError");
  }
  // Catch OutOfMemoryError
  try {
    // your code goes here
  } catch (OutOfMemoryError e) {
    System.out.println("Caught OutOfMemoryError");
  }
}

正如你所见,我们使用了try-catch块来捕获错误。此外,重要的是要知道这些错误是Error的实例,而不是Exception。因此,这些确实是错误,而不是异常。

垃圾回收的内存管理

Java 编程语言中最受推崇的特性之一是其垃圾回收。这种自动内存释放可以减轻开发者的许多负担,但也有一些缺点。让我们更深入地了解一下 Java 看似简单的垃圾回收机制。

Java 的垃圾回收器识别应用中不再可达的对象。一旦识别,这些对象将被移除,它们所使用的内存将被释放,使其可用于应用。

虽然我们可以赞扬垃圾回收器的努力,并感激它为我们的应用释放的内存,但它可能会对性能产生影响。当我们有频繁的垃圾回收周期时,运行时可能会引入暂停和响应速度降低。缓解策略包括最小化对象创建和及时处理对象。这些策略可以减少垃圾回收的频率。

我们将在本章后面讨论如何实现这些策略。

优化技术

我们可以采用几种策略来帮助优化 Java 应用程序中的内存使用:

  • 限制对象创建;仅在绝对需要时创建

  • 避免在循环中创建对象

  • 使用局部变量和对象池(见第四章

  • 实现对象不可变性(本章后面的对象不可变性部分将介绍)

  • 使用性能分析工具来监控内存使用(见第十四章

在我们的 Java 应用程序中进行内存管理时,采取有目的和了解的方法是很重要的。为此,我们需要了解内存限制以及内存是如何分配和释放的。

对象池

如您可能从第四章中回忆起,对象池是我们可以使用的重要设计模式,用于创建一组可以保存在池中、随时可供使用的对象,而不是在我们需要时分配它们,在它们超出作用域时释放它们。对象池通过重用对象帮助我们有效地管理系统资源。当我们的对象很大且创建需要花费大量时间时,这种方法的优点尤为明显。

初始化方法

在 Java 中创建对象有多种方式,我们的初始化方法可以显著影响 Java 应用程序的整体性能以及内存的使用方式。让我们看看四种方法:直接、懒加载、对象池和构建器。

直接初始化

创建新对象最常用的方法是直接初始化方法。如下面的示例所示,这种方法很简单。我们使用构造函数或初始化器来实现这种方法:

Corgi myCorgi = new Corgi("Java", 3);

这种方法的优点是易于理解和编程。它也可以用于许多需要创建对象的场景。这种方法的缺点包括可能导致不必要的对象创建,这与我们最小化对象创建的目标相反。这在使用直接初始化方法在循环或频繁调用的方法中尤为明显。另一个缺点是使用这种方法创建的对象不能被重用。

懒加载初始化

将对象的创建推迟到应用程序需要时的策略被称为懒加载初始化。如下面的代码片段所示,除非存在特定条件,否则对象不会被创建:

Corgi myCorgi = null; // Initialize to null
// ...
if (someCondition) {
    myCorgi = new Corgi("Java", 3); // Create the object when needed
}

这种方法的优点是对象创建直到需要时才最小化。此外,当我们有条件地创建多个对象时,内存使用量会减少。然而,这种策略会导致代码复杂性增加,并且在处理多线程环境时可能会引入同步问题。

对象池

我们还可以使用对象池来创建一个预初始化的对象池,这些对象可以多次使用,根据需要从池中取出并返回。以下代码片段显示了这种代码的结构:

ObjectPool<Corgi> corgiPool = new ObjectPool<>(Corgi::new, 10); // Create a pool of 10 Corgi objects
// ...
Corgi myCorgi = corgiPool.acquire(); // Get obj from pool
// ...
corgiPool.release(myCorgi); // Release obj to the pool

您可以回顾第四章以获取关于对象池的更多详细信息。

构建器模式

另一种越来越受欢迎的对象创建方法是使用构建器模式。这是一种将对象构建和其表示分别处理的设计模式。这种方法使我们能够逐步创建多属性对象。以下代码片段说明了构建器模式的概念:

CorgiBuilder builder = new CorgiBuilder();
builder.setName("Java");
builder.setAge(3);
Corgi myCorgi = builder.build();

这种方法的优点之一是它引入了对象构造的灵活性。这在创建复杂对象时非常有用。另一个优点是它允许我们通过具有复杂构造函数来设置选定的属性。构建者模式方法的缺点之一是我们必须为应用程序中使用的每种类型的对象定义一个单独的构建器类。这可能会显著增加我们代码的复杂性,并降低其可读性和可维护性。

开销减少

当我们考虑最小化对象创建的目标时,我们应该考虑对象池、对象克隆和对象序列化。我们在本章前面提到了对象池,并在第四章中提供了深入探讨。我们将把关于对象克隆的讨论留到本章的后面。现在,知道它可以与开销减少相关。

第三个概念是对象序列化。幸运的是,Java 允许我们将对象转换为二进制形式,也可以从二进制形式转换回对象。我们通常使用它进行对象持久化,也可以用它来创建具有减少开销的对象副本。

这里有一个例子,说明我们如何序列化(转换为二进制)和反序列化(转换回对象):

// Serialize the object
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
ObjectOutputStream objectOutputStream = new ObjectOutputStream(outputStream);
objectOutputStream.writeObject(originalCorgi);
// Deserialize the object
ByteArrayInputStream inputStream = new ByteArrayInputStream(outputStream.toByteArray());
ObjectInputStream objectInputStream = new ObjectInputStream(inputStream);
Corgi clonedCorgi = (Corgi) objectInputStream.readObject();

在创建对象时减少开销应该是我们期望应用程序具有高性能时的一个关键考虑因素。

对象不可变性

让我们继续学习如何最小化对象创建,以提高我们的 Java 应用程序的性能。对象不可变性指的是一旦实例化后就不能被修改的对象。不可变性可以被视为对象的属性或特征。使对象不可变的优点包括系统可预测性和性能。

让我们从对象不可变性的简要概述开始。

不可变性概述

对象不可变性不是一个新概念,但它是非常重要的。一般前提是我们创建一个具有所有期望的属性和行为的对象,然后在整个对象的生命周期中防止它被修改(改变)。

不可变对象被认为是安全的,因为它们不能被改变。这意味着我们可以在多线程环境中共享这些对象,而不需要同步。因此,并发编程得到了简化。你将在第九章中了解更多关于并发的内容。

已知不可变对象具有不可变状态、安全共享、可预测的行为,并与函数式编程原则一致。

最佳实践

创建不可变对象不仅仅是设置一个属性;它需要遵循某些最佳实践。我们希望我们的不可变对象是健壮的,代码是可维护的,并且对应用程序的整体性能有积极贡献。

理解这些最佳实践对于正确实施至关重要。

声明为 final

创建不可变对象的第一项最佳实践是确保所有属性都声明为final。以下代码展示了如何将类声明为final,以及两个变量:

public final class ImmutableExample1 {
    private final int value;
    private final String text;
    public ImmutableExample1(int value, String text) {
        this.value = value;
        this.text = text;
    }
    // Getter methods...
}

遵循这项最佳实践可以确保一旦对象创建,其属性就不能更改。这也有助于我们检测任何在运行时更改对象的尝试。

完整构造函数

第二项最佳实践仅仅是确保类的所有属性都在构造函数中初始化。在构造函数中初始化所有字段很重要。目标是确保对象在创建时完全定义。记住,我们以后将无法更改对象。

避免 setter

当你成为一名资深的 Java 开发者时,你可能会在类中自动创建 setter 和 getter。我们不需要检查清单;这仅仅成为一种习惯。在不可变对象的情况下,我们不希望给我们的应用程序提供调用 setter 的能力,因为对象创建后不应对其进行任何更改。以下代码片段展示了如何创建一个带有构造函数的标准类。这里有一个 getter 方法,但没有 setter:

public final class ImmutableExample2 {
    private final int value;
    private final String text;
    public ImmutableExample2(int value, String text) {
        this.value = value;
        this.text = text;
    }
    public int getValue() {
        return value;
    }
    public String getText() {
        return text;
    }
}

由于我们不应该在不可变对象上调用任何 setter,因此不包括它们在我们的类中是很重要的。

防御性复制

当我们从不可变对象返回一个指向内部可变对象的引用时,返回一个防御性副本是很重要的。这可以防止任何外部修改。以下代码片段演示了我们应该如何实现这项最佳实践:

public final class ImmutableExample3 {
    private final List<String> data;
    public ImmutableExample3(List<String> data) {
        // Create a defensive copy to ensure the list cannot be 
        // modified externally
        this.data = new ArrayList<>(data);
    }
    public List<String> getData() {
        // Return an unmodifiable view of the list to prevent 
        // modifications
        return Collections.unmodifiableList(data);
    }
}

使用这种方法有助于确保对象的状态保持不可变。

注解

在创建不可变对象时,我们最后的最佳实践是使用@Immutable注解。在这里,我们使用的是Lombok 项目库:

import lombok.Immutable;
@Immutable
public final class ImmutableExample4 {
    private final int value;
    private final String text;
    public ImmutableExample4(int value, String text) {
        this.value = value;
        this.text = text;
    }
    // Getter methods...
}

当我们使用这个注解时,我们可以从自动生成的代码中受益,使我们更加高效。请注意,这个注解可能不会在 Lombok 的后续版本中可用。

性能优势

如你所学,对象的不可变性为我们和我们的应用程序提供了几个好处。其中一类好处是性能。以下是一个性能优势列表:

  • 可预测状态:当使用不可变对象时,我们可以依赖它们的状态在其生命周期内保持不变。

  • 垃圾回收效率:使用不可变对象减少了对象收集和销毁函数需要运行的频率。

  • 安全缓存:我们可以安全地缓存不可变对象,甚至可以在多个线程之间共享它们,而无需担心数据损坏。

  • 减少开销:因为不可变对象是线程安全的,所以我们不需要在多线程环境中使用同步机制。

  • 易于并行化:当我们使用不可变对象时,可以简化并发编程和并行编程。

  • 函数式编程优势:如前所述,不可变对象与函数式编程相一致。在该编程范式中,函数产生可预测的结果而没有副作用。

了解使用对象不可变性的性能优势可以鼓励我们采用这种方法,这可以导致 Java 应用程序的性能得到显著提升。

自定义类

我们之前已经讨论了实现不可变对象的最佳实践。除了这些最佳实践之外,我们还应该实现equalshashCode方法。让我们在代码中看看这一点:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    CustomImmutable that = (CustomImmutable) o;
    return value == that.value && Objects.equals(text, that.text);
}
@Override
public int hashCode() {
    return Objects.hash(value, text);
}

正如你所见,当我们想要执行等性测试并确保与某些数据结构兼容时,我们可以在自定义不可变类中重写equalshashCode方法。当我们这样做时,我们必须确保我们考虑了所有可能影响等性的属性。

字符串类

如你所知,字符串是常用的数据类型,它们本质上就是不可变的。让我们看看字符串作为不可变对象是如何工作的,这样我们就能更好地理解如何设计不可变对象。

字符串确实是不可变的,即使我们认为我们在修改它们,Java 也会创建一个新的对象。例如,考虑以下代码行:

String original = "Java";
String modified = original.concat(", is the name of my Corgi");

正如你所见,当我们对第一个字符串调用concat方法时,会创建一个新的字符串对象。

字符串不可变性为我们提供了几个优势,例如线程安全、可预测的行为以及字符串操作的高效性。在底层,Java 维护一个字符串池,也称为内部池,用于存储唯一的字符串字面量。这是字符串不可变性的另一个优势。让我们在代码中看看这一点:

String s1 = "Java"; // Stored in the string pool
String s2 = "Java"; // Reuses the same string from the pool

字符串不可变性的第五个优势是安全性。这意味着我们可以放心地使用字符串来存储敏感数据,例如银行信息、密码和加密密钥,因为无意中的修改被阻止了。

垃圾回收

我们已经确定,当我们的目标是让 Java 应用程序以高水平运行时,内存管理很重要。我们还探讨了垃圾收集的工作原理及其好处。

垃圾回收的影响

Java 垃圾回收的自动性导致许多开发者忽略了它。他们把垃圾回收视为理所当然,并且不实施任何最佳实践。对于小型项目来说,这没问题,因为这些项目不是数据密集型或内存密集型的。让我们看看垃圾回收如何影响我们的应用程序和内存管理:

  • 应用程序暂停:频繁的垃圾回收周期可能导致我们的应用程序暂停。垃圾收集的类型和堆大小是决定这些暂停长度的重要因素。

  • 内存开销:垃圾回收增加了内存开销。每次垃圾回收运行时,都会影响 CPU 周期和内存资源。

我们可以采取几种方法来帮助减轻垃圾回收对我们应用程序的影响:

  • 有目的地管理我们对象的生命周期

  • 避免不必要的对象创建

  • 重复使用对象

  • 实现对象池

  • 使用不可变对象

在下一节中,我们将探讨对象拷贝如何与垃圾回收相关。

对象拷贝

如您所假设的,对象拷贝是指我们创建一个与现有对象完全相同的新对象。它与垃圾回收的相关性在于它可能对对象的管理和处置方式产生影响。这种影响类型受所使用的拷贝类型的影响。让我们看看两种拷贝类型:浅拷贝和深拷贝。

浅拷贝

浅拷贝的过程涉及通过复制原始对象的内容来创建一个新的对象。需要注意的是,如果原始对象包含对其他对象的引用,克隆将指向相同的对象。这是一个预期的行为;我们正在克隆所有对象,以便它们包含引用,但不包含被引用的对象。让我们通过一个简短的代码示例来看一下:

class Corgi implements Cloneable {
    private String name;
    private String authenticityCertificate;
    // Constructor and getters ...
    @Override
    public Object clone() throws CloneNotSupportedException {
        return super.clone();
    }
}

如您所见,当我们克隆一个Corgi对象时,新的 Corgi 将共享与原始对象相同的authenticityCertificate。如果该字段是可变的,那么通过一个引用对其所做的更改将影响原始和克隆的 Corgi 对象。

深拷贝

当我们创建一个深拷贝时,我们仍然创建一个新的对象,但它也会递归地复制原始对象引用的所有对象。这种拷贝方法确保新对象及其子对象与原始对象独立。让我们通过代码来看一下:

class Corgi implements Cloneable {
  private String name;
  private Address address;
  // Constructor and getters...
  @Override
  public Object clone() throws CloneNotSupportedException{
    Corgi clonedCorgi = (Corgi) super.clone();
    clonedCorgi.address = (Address) address.clone(); // Deep copy of the Address object
    return clonedCorgi;
  }
}

如您所见,当我们克隆一个Corgi对象时,它是一个新的Corgi对象,并且创建了一个新的Address对象。使用这种方法,我们可以对其中一个对象进行更改,而不会影响另一个对象。

设计模式

设计模式是经过时间考验的常见软件问题的解决方案。它们可以被认为是一套最佳实践,并且在 Java 开发中被广泛使用。关于战略对象创建和不可变性,有两个设计模式值得我们关注:

  • 单例模式

  • 工厂模式

设计模式是什么不是

设计模式是解决已知问题的结构化方法。它们不是算法、模板、库,甚至不是代码片段。相反,它们提供了我们可以遵循的高级指导。

让我们逐一查看这些模式,以便我们了解它们的使用如何帮助我们提高 Java 应用程序的性能。

单例模式

单例设计模式确保只有一个类的实例,并提供对该实例的全局访问。当应用程序管理数据库连接、资源管理、日志记录、缓存或配置设置时,通常使用此模式。

让我们看看这个模式的一个简单实现方法:

public class Singleton {
    private static Singleton instance;
    private Singleton() {
        // Private constructor to prevent instantiation
    }
    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

上述代码是一个标准示例。正如您所看到的,这个类禁止创建多个实例。此外,getInstance() 方法是我们提供对 Singleton 类实例全局访问的方式。

工厂模式

工厂设计模式涉及一个超类和一个用于在其中创建对象的接口。此模式允许子类更改可以创建的内容。它促进了超类与正在创建的子类之间的松耦合。此模式最常见组件如下:

  • 抽象工厂: 这是一个声明用于创建对象的方法的接口。

  • 具体工厂: 这是一个实现抽象工厂接口的类。它创建具体对象。

  • 产品: 这是工厂创建的对象。

  • 具体产品: 这是实现产品接口的类。

使用此模式的优势包括关注点的分离、代码的可重用性、灵活性和封装。接下来,我们将查看几个代码片段,以展示简单的实现示例。

首先,这是一个抽象产品的示例:

interface Product {
    void create();
}

现在,让我们看看具体产品的示例:

class ConcreteProductA implements Product {
    @Override
    public void create() {
        System.out.println("Creating Concrete Product A");
    }
}
class ConcreteProductB implements Product {
    @Override
    public void create() {
        System.out.println("Creating Concrete Product B");
    }
}

以下代码片段说明了如何实现抽象工厂:

interface Factory {
    Product createProduct();
}

最后,以下代码演示了如何实现具体工厂:

class ConcreteFactoryA implements Factory {
    @Override
    public Product createProduct() {
        return new ConcreteProductA();
    }
}
 class ConcreteFactoryB implements Factory {
    @Override
    public Product createProduct() {
        return new ConcreteProductB();
    }
}

当您需要根据特定要求或条件创建对象时的灵活性时,工厂模式可以成为一个有价值的工具。

摘要

本章重点介绍了策略性对象创建和不可变性,这两个密切相关且同等重要的主题。我们研究了这些主题的各个方面,以帮助提高我们的 Java 应用程序的性能。具体来说,我们研究了最小化对象创建、对象不可变性、垃圾回收和设计模式。现在,您应该对策略性对象创建的重要性以及其实践和实现策略有更好的理解和深刻的认识。您也应该对对象不可变性的概念有牢固的掌握。

在下一章,字符串对象,我们将深入探讨字符串对象,同时涵盖诸如适当的字符串池、延迟初始化和字符串操作策略等主题。

第七章:字符串对象

为了分析我们 Java 应用程序的各个方面,确保它们以高度高效的速率运行,我们需要考虑字符串对象。字符串是 Java 应用程序的重要组成部分,用于从简单的名称列表到复杂的数据库存储需求等无限用途。这些对象的创建、操作和管理应该是首要关注的问题。

本章重点介绍在 Java 应用程序中高效使用字符串对象。第一个概念是合适的字符串池化。我们将探讨这个概念,并探讨使用字符串池化以实现高性能的最佳实践。本章还介绍了延迟初始化的概念,分析了其优势,并通过示例代码进行说明。最后,我们将探讨额外的字符串操作策略,包括高级字符串操作技术。

在本章中,我们将涵盖以下主要主题:

  • 合适的字符串池化

  • 延迟初始化

  • 额外的字符串操作策略

技术要求

为了遵循本章中的示例和说明,你需要具备加载、编辑和运行 Java 代码的能力。如果你尚未设置你的开发环境,请参阅第一章

本章的完整代码可以在以下链接找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter07

合适的字符串池化

我们的主要关注点是确保我们的 Java 应用程序以高水平运行。为此,内存管理是一个关键问题。我们设计、测试和实施优化内存使用的技术至关重要。字符串池化就是这样一种技术,其重点是使字符串对象能够重用,从而提高应用程序的效率。效率的提高源于减少内存开销。

字符串池化是一个重要的概念,它基于将字符串值共享作为创建新字符串实例的替代方案。如果你的应用程序频繁使用字符串字面量,那么你应该会发现字符串池化特别有用。

字符串字面量

字符串字面量是一个由双引号包围的固定值。例如,以下语句中的"Read more books."部分是一个字符串字面量:

System.out.println("Read more books.");

为了检验字符串池化,我们将从字符串内部化的概念开始,然后回顾最佳实践,并通过 Java 的代码示例完成我们的探索。为了进一步深入这一概念,我们将探讨数据库查询中的字符串池化。

字符串内部化

字符串池化是一个有趣的概念。它是一种内存减少技术,允许多个字符串使用相同的内存位置。正如你所期望的,这些字符串的内容必须相同才能工作。内存减少是可能的,因为我们能够消除重复的对象。字符串池化依赖于字符串池来实现这一点。正如我们在第四章中提到的,池化使用一个特殊的堆区域来存储字符串字面量。

让我们尝试像intern()方法一样思考,这是String类的一部分,以实现这一点。我们将在本章的代码示例部分稍后查看一个示例。

最佳实践

通常,被认为在我们的应用程序中使用字符串池化是一个好主意。在实现字符串池化时,有两个重要的考虑因素需要注意。首先,我们不应该过度使用字符串池化。性能和内存使用的益处是明显的,但如果我们的应用程序在非常长的字符串上使用池化,那么我们可能会消耗比我们想要的更多的内存。

另一个值得考虑的最佳实践是避免使用intern()方法。这样,我们可以确保我们的应用程序中行为的一致性。

代码示例

让我们通过一个简单的示例来演示字符串池化。以下代码首先定义了两个字符串字面量,s1s2。这两个字符串具有相同的值。第三个字符串s3使用intern()方法创建。最后的两个语句用于比较引用:

public class CorgiStringIntern {
    public static void main(String[] args) {
        String s1 = "corgi";
        String s2 = "corgi";
        String s3 = new String("corgi").intern();
        System.out.println("Are s1 and s2 the same object? " + (s1 == 
        s2));
        System.out.println("Are s1 and s3 the same object? " + (s1 == 
        s3));
    }
}

如你所见,从我们应用程序的输出中可以看出,两个引用比较都被评估为true

Are s1 and s2 the same object? true
Are s1 and s3 the same object? true

数据库查询的字符串池化

SQL 和其他数据库查询通常在我们的代码中动态创建,可能会变得相当复杂。与其他大型字符串一样,我们可以使用字符串池来帮助确保我们的应用程序不会不必要地创建字符串对象。我们的目标是重用任何常用的数据库查询,而不是在运行时多次重新创建它们。

延迟初始化

不在需要时实例化对象的决定被称为延迟初始化。通常,“懒惰”这个词带有负面含义;然而,在软件开发中,延迟初始化是一种性能优化方法,其目标是管理应用程序的开销。这种设计模式在详细处理非常大的对象时特别有用。

我应该实现延迟初始化吗?

如果你拥有大型或复杂的字符串对象,并且需要在初始化时消耗大量开销,那么你应该考虑延迟初始化。

延迟初始化不会减少创建的字符串对象数量;相反,它将初始化延迟到应用程序需要处理时。这种延迟技术可以帮助你进行内存优化。在我们检查源代码之前,让我们先看看一个叙述示例。

想象一下,你有一个使用非常复杂的字符串对象的遗留应用程序。当你的应用程序打开时,会显示一个加载屏幕,并且在幕后,应用程序创建几个字符串对象以支持应用程序的正常操作。用户抱怨应用程序“加载时间过长”,并且当尝试启动应用程序时,计算机经常锁定。在审查源代码后,你意识到在应用程序启动时创建的许多字符串对象仅在用户选择某些功能时被应用程序使用。你的解决方案是实现懒加载,这样这些字符串对象仅在需要时创建。

代码示例

现在,让我们看看如何在 Java 中实现懒加载初始化设计模式。我们首先导入java.util.function.Supplier。这是一个接口,它提供了一个单一的get()方法,我们将使用它来检索一个值:

import java.util.function.Supplier;

接下来,我们声明我们的类,并使用StringBuilder通过多次调用append()方法生成一个复杂字符串:

public class LazyInitializationExample {
  private Supplier<String> lazyString = () -> {
    StringBuilder stringBuilder = new StringBuilder();
    stringBuilder.append("Java is an incredibly ");
    stringBuilder.append("flexible programming language ");
    stringBuilder.append("that is used in a wide range of 
    applications. ");
    stringBuilder.append("This is a complex string ");
    stringBuilder.append("that was lazily initialized.");
    return stringBuilder.toString();
};

LazyInitializationExample类内部,我们调用getLazyString()方法。此方法用于在需要时创建或检索复杂字符串,而不是在需要之前:

public String getLazyString() {
  return lazyString.get();
}

我们代码的最后一部分是main()方法。当此方法运行时,我们访问懒加载的字符串:

  public static void main(String[] args) {
    LazyInitializationExample example = new 
    LazyInitializationExample();
    String lazyString = example.getLazyString();
    System.out.println("Lazily initialized string: " + lazyString);
  }
}

程序的输出如下:

Lazy-initialized string: Java is an incredibly flexible programming language that is used in a wide range of applications. This is a complex string that was lazily initialized.

最佳实践

懒加载的概念很简单,决定实现它也很容易。如果你有大型字符串对象,那么你应该尝试这种方法。在懒加载中有两个最佳实践需要考虑。首先,当我们在一个多线程环境中工作时,我们应该实现同步。这是为了帮助促进线程安全。另一个最佳实践是避免过度使用。当我们过度使用懒加载时,我们的代码可能变得难以阅读和维护。

额外的字符串操作策略

字符串池和懒加载是优秀的优化策略,可以帮助提高我们 Java 应用程序的整体性能。除了这些策略之外,我们还可以确保我们的字符串连接操作是高效的,我们正确地利用正则表达式,并且我们有效地处理大文本文件。本节回顾了这些领域的各项技术。

字符串连接

字符串连接——使用加号(+)操作符将两个或多个字符串连接成一个——通常会导致代码效率低下。这种连接会创建一个新的字符串对象,我们希望避免。让我们看看两种提供更好性能的替代方案。

第一种选择使用了StringBuilder。在下面的示例中,我们创建了一个StringBuilder对象,向其中追加五个字符串字面量,将StringBuilder对象转换为String,然后输出结果:

public class ConcatenationAlternativeOne {
  public static void main(String[] args) {
    StringBuilder myStringBuilder = new StringBuilder();
    myStringBuilder.append("Java");
    myStringBuilder.append(" is");
    myStringBuilder.append(" my");
    myStringBuilder.append(" dog's");
    myStringBuilder.append(" name.");
    String result = myStringBuilder.toString();
    System.out.println(result);
  }
}

另一种替代方案是使用StringBuffer。以下程序类似于我们的StringBuilder示例,但使用的是StringBuffer。如您所见,两种方法都是用相同的方式实现的:

public class ConcatenationAlternativeTwo {
  public static void main(String[] args) {
    StringBuffer myStringBuffer = new StringBuffer();
    myStringBuffer.append("Java");
    myStringBuffer.append(" is");
    myStringBuffer.append(" my");
    myStringBuffer.append(" dog's");
    myStringBuffer.append(" name.");
    String result = myStringBuffer.toString();
    System.out.println(result);
  }
}

StringBuilderStringBuffer在字符串连接上的区别在于StringBuffer提供了线程安全,因此应在多线程环境中使用;否则,StringBuilder是使用加号(+)运算符进行字符串连接的替代方案。

正则表达式

正则表达式为我们提供了优秀的模式匹配和字符串操作方法。因为这些表达式可能对处理器和内存资源要求较高,因此学习如何高效使用它们非常重要。我们可以通过仅编译一次模式并按需重用它们来优化正则表达式的使用。

让我们来看一个例子。在我们应用程序的第一部分,我们导入了MatcherPattern类:

import java.util.regex.Matcher;
import java.util.regex.Pattern;

接下来,我们建立我们的电子邮件模式正则表达式:

public class GoodRegExExample {
  public static void main(String[] args) {
    String emailRegex = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]
    {2,}$";

以下代码段创建了一个示例电子邮件地址列表。我们稍后将检查这些地址的有效性:

String[] emails = {
  "java@example.com",
  "muzz.acruise@gmail.com",
  "invalid-email",
  "bougie@.com",
  "@example.com",
  "brenda@domain.",
  "edward@domain.co",
  "user@example"
};

下一个语句编译了正则表达式模式:

Pattern pattern = Pattern.compile(emailRegex);

代码的最后一部分遍历我们列表中的每个电子邮件地址:

    for (String email : emails) {
      Matcher matcher = pattern.matcher(email);
      if (matcher.matches()) {
        System.out.println(email + " is a valid email address.");
      } else {
        System.out.println(email + " is an invalid email address.");
      }
    }
  }
}

这个简单的正则表达式实现检查电子邮件地址格式,并演示了如何一次性编译正则表达式模式,并在应用程序的其他地方作为每次执行电子邮件地址验证操作时的有效替代方案使用。

大型文本文件

一些 Java 应用程序可以处理非常大的文本文件,甚至长达书籍的文件。一次性加载文件的完整内容是不推荐的。我们的应用程序可能会迅速耗尽可用内存,导致不希望的运行时结果。一种替代方案是使用缓冲方法分段读取文本文件。

以下示例假设本地目录中有一个文本文件。我们使用BufferedReader逐行读取:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class LargeTextFileHandlingExample {
  public static void main(String[] args) {
    String filePath = "advanced_guide_to_java.txt";
    try (BufferedReader reader = new BufferedReader(new 
    FileReader(filePath))) {
      String line;
      int lineCount = 0;
      while ((line = reader.readLine()) != null) {
        System.out.println("Line " + (++lineCount) + ": " + line);
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }
}

这种方法可以帮助我们管理内存,并提升我们应用程序的整体性能。

摘要

本章重点介绍了如何创建、操作和管理字符串,以提升您 Java 应用程序的整体性能。您现在应该理解了字符串池,并基于最佳实践有信心有效地使用它。在处理大量字符串使用且关注线程安全时,延迟初始化现在应该是一个您在未来的应用程序中考虑实现的策略。本章还介绍了高级字符串操作策略,以帮助您在设计高性能 Java 应用程序时有所选择。

在下一章“内存泄漏”中,我们将探讨内存泄漏是什么,它们是如何产生的,以及它们对我们应用程序的影响。我们将探讨避免内存泄漏的策略,以提升我们 Java 应用程序的性能。

第八章:内存泄漏

内存泄漏是由于不当的内存管理而发生的,可以直接影响应用程序的性能。这些泄漏发生在内存被不当释放或分配但变得不可访问时。不当的内存管理不仅会负面影响性能,还会阻碍可扩展性,由于OutOfMemoryError而导致系统崩溃,并破坏用户体验。许多开发者隐含地相信 Java 的垃圾收集器(在第第一章中介绍)在应用程序运行时管理内存;然而,尽管垃圾收集器具有不可思议的能力,内存泄漏仍然是一个持续存在的问题。

垃圾收集器并没有出错;相反,当垃圾收集器无法回收不再被应用程序需要的对象所存储的内存时,就会发生内存泄漏。不当的引用是主要原因,幸运的是,我们可以避免这种情况。本章提供了避免内存泄漏的技术、设计模式、编码示例和最佳实践。

本章涵盖了以下主要内容:

  • 正确引用

  • 监听器和加载器

  • 缓存和线程

到本章结束时,你应该对可能导致运行时内存泄漏的因素以及它们可能对我们 Java 应用程序造成的潜在破坏有一个全面的理解,并且你会知道如何有目的地和高效地预防它们。通过实验提供的示例代码,你可以对自己的内存泄漏预防策略充满信心。

技术要求

要遵循本章中的示例和说明,你需要具备加载、编辑和运行 Java 代码的能力。如果你还没有设置你的开发环境,请参阅第一章

本章的完整代码可以在以下链接找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter08

正确引用

不可否认——内存泄漏会导致资源可用性的逐渐下降,导致系统变慢和潜在的系统崩溃。好消息是,有两个主要组件提供了解决方案。一个组件是 Java 虚拟机(JVM)的一部分,即垃圾收集器。它功能强大,是 Java 语言的一个亮点。第二个,也是更重要的一点,是开发者。作为 Java 开发者,我们通过在代码中对内存管理采取有目的的方法,有权力最小化甚至消除内存泄漏。

为了支持解决方案中开发者部分的消除内存泄漏,本节将重点介绍如何正确引用对象,以防止它们导致内存泄漏,如何识别内存泄漏,以及避免它们的策略。

引用简介

避免内存泄漏最重要的方面可能是使用适当的引用。这应该被鼓励,因为它将控制权交给了开发者。Java 为我们提供了强大的工具箱来帮助我们在这方面的工作。具体来说,有几种引用类型,每种类型都有自己的用途和相关的垃圾回收器行为。

引用

在 Java 中,引用是内存位置的指针,是内存管理和内存泄漏缓解的关键组件。

让我们检查 Java 中各种引用类型,包括强引用、软引用、弱引用和虚引用,以便我们可以确定哪种方法最适合任何给定的用例。记住,我们的总体目的是实现高效的内存管理,避免内存链接,以提高我们 Java 应用程序的整体性能。

强引用

强引用是我们需要重点关注的最重要的引用类型。它不仅是 Java 中的默认引用类型,而且也是内存泄漏最常见的原因。具有强引用类型的对象不符合垃圾回收的条件。要创建强引用,我们只需使用变量直接引用对象即可。

在以下示例代码中,我们使用sampleCorgiObject变量创建了一个对象的强引用。只要该变量包含对SampleCorgiObject实例的引用,我们就会在内存中保留该对象,垃圾回收器将无法释放其内存:

public class CH8StrongReferenceExample {
  public static void main(String[] args) {
    SampleCorgiObject sampleCorgiObject = new SampleCorgiObject();
    System.out.println(sampleCorgiObject);
  }
  static class SampleCorgiObject {
    @Override
    public String toString() {
      return "This is a SampleCorigObject instance.";
    }
  }
}

这之所以是 Java 中的默认引用类型,是有原因的。典型的用例是我们需要在整个应用程序运行期间保持对象,例如配置属性。我们应该谨慎使用强引用,特别是当对象很大时。一个最佳实践是在不再需要引用时立即将其设置为null。这将使垃圾回收器能够释放相关的内存。

软引用

OutOfMemoryError和系统崩溃。当我们创建软引用时,只有在有足够空间的情况下,这些对象才会保留在内存中。这使得软引用成为缓存大型对象的理想解决方案。

重要的是要理解,JVM 仅在必要时才会收集具有软引用的对象。JVM 的垃圾回收器首先收集所有其他可以收集的对象,然后作为最后的努力,收集具有软引用的对象,并且只有在系统内存不足的情况下才会这样做。

要实现软引用,我们使用java.lang-ref包中的SoftReference类。让我们通过代码示例来看看。

我们通过导入SoftReference开始我们的应用程序。然后,我们创建类头,并使用强引用通过myBougieObject初始化MyBougieObject。然后我们用SoftReference<MyBougieObject>包装它以建立软引用。请注意,我们将myBougieObject设置为 null,这确保了我们的MyBougieObject实例只能通过我们创建的软引用访问:

import java.lang.ref.SoftReference;
public class CH8SoftReferenceExample {
  public static void main(String[] args) {
    MyBougieObject myBougieObject = new MyBougieObject("Cached 
    Object");
    SoftReference<MyBougieObject> softReference = new 
    SoftReference<>(myBougieObject);
    myBougieObject = null;

在下一节代码中,我们尝试从软引用中回顾myBougieObject。我们使用System.gc()来提供一个在正常条件下观察我们的软件引用行为的方法,然后模拟内存压力:

    MyBougieObject retrievedObject = softReference.get();
    if (retrievedObject != null) {
        System.out.println("Object retrieved from soft reference: " 
          + retrievedObject);
    } else {
      System.out.println("The Object has been garbage collected by the 
      JVM.");
    }
    System.gc();
    retrievedObject = softReference.get();
    if (retrievedObject != null) {
      System.out.println("Object is still available after requesting 
      GC: " + retrievedObject);
    } else {
      System.out.println("The Object has been garbage collected after 
      requesting GC.");
    }
  }
}

这最后一部分暗示了我们的MyBougieObject类:

class MyBougieObject {
  private String name;
  public MyBougieObject(String name) {
    this.name = name;
  }
  @Override
  public String toString() {
    return name;
  }
}

下面是输出可能的样子。当然,结果将取决于您系统可用内存的大小:

Object retrieved from soft reference: Cached Object
Object is still available after requesting GC: Cached Object

弱引用

到目前为止,我们已经介绍了防止垃圾回收的强引用和允许垃圾回收作为最后手段以回收内存的软引用弱引用的独特之处在于,只有当弱引用是特定对象的唯一引用时,才允许垃圾回收。这种方法在需要更多灵活性的内存管理解决方案中特别有用。

弱引用的一个常见用途是在缓存中,当我们希望对象保留在内存中,但又不想阻止它们在系统内存不足时被 JVM 的垃圾回收器回收。为了实现弱引用,我们使用WeakReference类,它是java.lang-ref包的一部分。让我们通过一个代码示例来看看。我们的代码从必要的import语句和类声明开始。正如您在下面的代码块中可以看到的,我们将CacheCorgiObject包装在WeakReference中,它最初可以通过弱引用访问。当我们将强引用(cacheCorgiObject)设置为 null 时,我们调用System.gc()来调用 JVM 的垃圾回收器。根据您的系统内存,对象可能会被回收,因为它可用。在垃圾回收之后,我们调用weakCacheCorgiObject.get(),如果发生了对象回收,则返回null

import java.lang.ref.WeakReference;
public class CH8WeakReferenceExample {
  public static void main(String[] args) {
    CacheCorgiObject cacheCorgiObject = new CacheCorgiObject();
    WeakReference<CacheCorgiObject> weakCacheCorgiObject = new 
    WeakReference<>(cacheCorgiObject);
    System.out.println("Cache corgi object before GC: " + 
    weakCacheCorgiObject.get());
    cacheCorgiObject = null;
    System.gc();
    System.out.println("Cache corgi object after GC: " + 
    weakCacheCorgiObject.get());
  }
}
class CacheCorgiObject {
@Override
  protected void finalize() {
    System.out.println("CacheCorgiObject is being garbage collected");
  }
}

下面是程序的一个示例输出。结果将因系统可用内存的不同而有所变化:

Cache corgi object before GC: CacheCorgiObject@7344699f
Cache corgi object after GC: null
CacheCorgiObject is being garbage collected

幻影引用

我们最后一种引用类型是幻影引用。这种引用类型不允许直接检索引用的对象;相反,它提供了一个方法来确定对象是否已经被 JVM 垃圾回收器终结并回收。这发生时不会阻止对象被回收。

实现需要java.lang.ref包中的两个类——PhantomReferenceReferenceQueue。我们的示例代码演示了垃圾收集器确定具有虚引用的对象是否可达。这意味着该对象已被终结并准备进行垃圾回收。在这种情况下,引用将被排队,我们的应用程序能够相应地做出反应:

import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;
public class CH8PhantomReferenceExample {
  public static void main(String[] args) throws InterruptedException {
    ReferenceQueue<VeryImportantResource> queue = new 
    ReferenceQueue<>();
    VeryImportantResource resource = new VeryImportantResource();
    PhantomReference<VeryImportantResource> phantomResource = new 
    PhantomReference<>(resource, queue);
    resource = null;
    System.gc();
    while (true) {
      if (queue.poll() != null) {
        System.out.println("ImportantResource has been garbage 
        collected and its phantom reference is enqueued");
        break;
      }
    }
  }
}
class VeryImportantResource {
  @Override
  protected void finalize() throws Throwable {
    System.out.println("Finalizing VeryImportantResource");
    super.finalize();
  }
}

采用虚引用方法使我们能够根据具有虚引用集合的对象调用我们自己的内存清理操作,这种方法不会干扰正常的垃圾回收操作。

使用finalize()方法

finalize()方法已被弃用,并计划在未来版本的 Java 中删除。在先前的代码示例中,它仅用于演示虚引用方法,并不建议使用finalize()。有关更多信息,请参阅 Java 文档。

现在我们已经审查了正确的引用方法,我们应该对开发者干预解决内存泄漏问题的重要性有所认识。此外,对编码示例的审查应使我们能够自信地在我们的 Java 应用程序中实现正确的引用。

内存泄漏识别

在我们的 Java 应用程序中,我们应该实现的最重要的事情之一是识别内存泄漏的能力。我们的目标是创建高性能的 Java 应用程序,而内存泄漏与这一目标相悖。为了识别内存泄漏,我们需要了解哪些症状表明存在内存泄漏。这些症状包括垃圾回收活动增加、OutOfMemoryError和性能逐渐下降。让我们看看五种识别内存泄漏的方法。

识别内存泄漏最重要的方法之一是审查我们的代码,以确保我们遵循最佳实践并避免常见陷阱,例如未能清除静态集合、使用后未移除监听器以及失去控制的缓存。我们将在本章后面讨论监听器:

  • 第二种方法涉及使用一个工具,该工具可以揭示我们应用程序中哪个对象消耗了最多的内存。

  • 第三种方法是使用工具生成堆转储,这是当前内存中所有对象的瞬间快照。这可以帮助您分析和检测潜在问题。

在审查对象保留时,寻找异常或不希望的图案。例如,当对象的生命周期比预期更长或存在大量您不期望看到的大量特定类型的对象时。

识别内存泄漏的第五种方法是持续不断地测试和评估您的应用程序。一旦您确定了内存泄漏,您就实施修复,然后应该重新测试。

我们可以使用工具来帮助我们识别内存泄漏,包括JProfilerYourKitJava Flight RecorderJFR)、VisualVM和 Eclipse 的内存分析工具MAT)。如果您想认真对待内存泄漏的识别,您应该研究这些工具,看看您如何利用它们的功能。

避免内存泄漏的策略

正确的对象引用,如本章前面所述,是避免 Java 应用程序中内存泄漏的主要策略。识别和修复内存泄漏是另一种策略,尽管它是反应性的。这两种策略都很重要,并且已经讨论过。让我们简要地看看一些额外的策略。

避免内存泄漏的第三种策略是正确管理集合对象。将对象放入集合中然后忽略或遗忘的情况并不少见,这可能导致内存泄漏。因此,为了避免这种情况,我们应该开发应用程序,使其定期删除不再需要的应用程序对象。使用弱引用可以帮助实现这一点。在使用静态集合时,我们也应该小心谨慎。这种类型的集合其生命周期与类加载器相关联。

我们还应该注意我们如何实现缓存。缓存的使用可以显著提高应用程序的性能,但也可能导致内存泄漏。在实现缓存时,我们应该使用软引用,设置有限的缓存大小限制,并持续监控缓存使用情况。

第五种策略是持续使用分析工具并测试您的应用程序。这种策略需要不断致力于检测和删除应用程序中的内存泄漏。这是一个重要的策略,不应被轻视。

当我们实施一系列避免内存泄漏的策略时,我们更有可能确保我们的应用程序具有高性能。

接下来,我们将回顾如何使用监听器和加载器来帮助避免内存泄漏。

监听器和加载器

我们 Java 应用程序的几个方面可能会影响性能,更具体地说,会导致内存泄漏。其中两个方面是监听器加载器。本节专门探讨事件监听器类加载器,并包括减轻使用它们的风险的策略,同时不牺牲它们可以为我们的 Java 应用程序提供的强大功能和效率。

事件监听器

事件监听器用于允许对象对事件做出反应。这种方法在交互式应用程序中得到了广泛的应用,并且是事件驱动编程的一个关键组件。这些监听器可以导致高度交互的应用程序;然而,如果管理不当,它们也可能成为内存泄漏的来源。

为了更好地理解这个问题,重要的是要注意,事件监听器必须订阅事件源,以便它们可以接收需要采取行动的通知(例如,按钮点击或游戏中的非玩家角色进入预定义区域)。正如你所期望的,每个事件监听器都维护着它们所订阅的事件源的引用。当事件源不再需要但被一个或多个监听器引用时,就会出现问题;这阻止了垃圾收集器收集事件源。

在处理事件监听器时,以下是一些避免内存泄漏的最佳实践:

  • 使用弱引用,正如本章前面详细说明的那样。

  • 当适用时,明确从事件源注销监听器。

  • 为监听器实现静态嵌套类,因为它们没有对外部类实例的隐式引用。应使用这种方法而不是实现非静态内部类。

  • 将事件监听器的生命周期与其相关的事件源对齐。

类加载器。

类加载器使我们能够动态加载类,使它们成为Java 运行时环境JRE)的关键组件。类加载器通过支持多态性和可扩展性提供了巨大的力量和灵活性。当我们的应用程序动态加载类时,这表明 Java 在编译时不需要了解这些类。这种强大的灵活性带来了内存泄漏的潜在风险,我们需要减轻,如果不是消除。

JVM 有一个涉及多个类加载器类型的类加载委托模型:

  • 一个引导类加载器,用于加载 Java 的核心 API 类。

  • java.ext.dirs属性。

  • classpath

类加载器是必要的,当加载的类在内存中保留的时间超过必要的时间时,它们可以引入内存泄漏。这里的罪魁祸首通常是具有静态字段的类,这些字段持有应该由垃圾收集器收集的对象的引用,被具有长期生命周期的对象引用的加载类对象,以及没有适当管理的缓存保留类实例。以下是一些减轻这些风险的策略:

  • 使用弱引用。

  • 最小化静态字段的使用。

  • 确保当不再需要时,自定义类加载器对垃圾收集器可用。

  • 根据需要监控、分析和改进。

当我们对加载器和监听器以及减轻它们相关风险的战略有彻底的了解时,我们提高在 Java 应用程序中最大限度地减少内存泄漏的机会。

缓存和线程。

本节探讨了缓存策略、线程管理和 Java 并发工具的有效使用。这些是我们继续开发高性能 Java 应用程序旅程中需要掌握的重要概念。我们将探讨这些概念、相关的最佳实践以及减轻使用它们引入的内存泄漏风险的技巧。

缓存策略

在编程中,我们使用缓存在内存位置临时存储数据,以便允许快速访问。这允许我们重复访问数据,而不会造成延迟或系统崩溃。缓存的好处包括更负责任的应用程序和减少对长期存储解决方案(如数据库和数据库服务器)的负载。当然,也存在陷阱。如果我们没有妥善管理我们的缓存,我们可能会在我们的应用程序中引入显著的内存泄漏。

我们有多个缓存策略可以考虑;其中两个你应该已经熟悉,因为它们在本章的早期已经介绍过,尽管不是专门针对缓存。第一个熟悉策略是使用弱引用。当我们使用缓存中的弱引用时,允许在内存不足时进行垃圾回收。第二个熟悉策略是使用软引用。这种策略在垃圾回收周期中提供了更高的保留优先级。

另一种缓存策略被称为最近最少使用LRU),正如其名所示,我们首先移除最少访问的项目,因为它们再次被我们的应用程序使用的可能性最小。

生存时间TTL)是另一种有用的缓存策略。TTL 方法跟踪缓存插入时间,并基于规定的时间自动过期项目。

另一种缓存策略是使用基于大小的驱逐。这种策略确保缓存不会超过你设定的最大内存边界。这个边界可以用内存使用量或项目总数来设定。

当我们实现缓存时,我们应该注意由于实现不佳而引入内存泄漏。这种有意的做法要求我们进行容量规划,为 LRU 和 TTL 方法建立驱逐策略,并监控系统。这种监控需要后续的微调和重新测试。

线程管理

我们在我们的应用程序中使用线程来促进多个并发操作。这使现代 CPU 得到更好的利用,并提高了 Java 应用程序的响应性。当我们手动管理线程时,由于线程管理的复杂性和易出错性,我们可能会引入内存泄漏。

我们可以通过扩展Thread类或在 Java 中实现Runnable接口来创建线程。这些方法直接且易于使用线程;然而,对于大型系统来说,这不是推荐的方法,因为它们直接创建和利用线程,导致显著的开销。相反,考虑使用 Java 的Executor框架来从主应用程序中抽象线程管理。

线程管理的最佳实践包括以下内容:

  • 优先使用执行器而非直接创建线程

  • 将你使用的线程池数量限制在最小值

  • 在你的代码中支持线程中断

  • 持续监控和优化

让我们通过一个简单的例子来展示正确的线程使用方法。我们将使用Executor框架。正如您将看到的,以下应用程序创建了一个固定线程池来运行一系列任务。每个任务通过暂停一段时间来模拟现实世界的操作。在您浏览代码的过程中,您将看到Executor框架被有效地用于管理线程:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
public class CH8ThreadManagementExample {
  public static void main(String[] args) {
    ExecutorService executor = Executors.newFixedThreadPool(5);
    for (int i = 0; i < 10; i++) {
      final int taskId = i;
      executor.submit(() -> {
        System.out.println("Executing task " + taskId + " Thread: " + 
        Thread.currentThread().getName());
        try {
          TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            System.out.println("Task " + taskId + " was interrupted");
        }
      });
    }
    executor.shutdown();
    try {
      if (!executor.awaitTermination(60, TimeUnit.SECONDS)) {
        executor.shutdownNow();
        System.out.println("Executor did not terminate in the 
        specified time.");
        if (!executor.awaitTermination(60, TimeUnit.SECONDS))
          System.err.println("Pool did not terminate");
      }
    } catch (InterruptedException ie) {
        executor.shutdownNow();
        Thread.currentThread().interrupt();
    }
    System.out.println("Finished all threads");
  }
}

此示例应用程序提供了一种在 Java 应用程序中管理并发的高效方法。

Java 并发工具

我们很幸运,java.util.concurrent包包含了一组我们可以用于并发的实用工具。这些工具使我们能够编写线程安全的应用程序,这些应用程序是可靠和可扩展的。这些工具帮助我们解决并发编程中的常见陷阱和挑战,包括数据一致性、生命周期管理和同步。

使用java.util.concurrent包中包含的并发工具的优点包括使我们的应用程序更加可靠,使它们在更高的水平上运行,并简化编程工作。让我们看看五个具体的并发工具。

我们的第一个并发工具是之前提到的Executor框架。仔细研究这个框架可以发现,存在多种类型的 Executor 服务,包括ScheduledExecutorService接口。此接口可用于引入执行延迟。主要接口ExecutorService使我们能够管理线程终止并帮助我们跟踪同步任务。

CountDownLatchCyclicBarrierExchangerPhaserSemaphore。如果您需要提高 Java 应用程序中的线程管理,这些方法在 Java 文档中值得回顾。

三个额外的并发工具是java.util.concurrent.atomic包。在java.util.concurrent.locks包中可用的锁允许我们锁定线程并等待直到满足特定条件。最后,并发集合提供了具有完整并发支持的线程安全集合。

现在我们已经探讨了缓存策略、线程管理和 Java 并发工具的有效使用,您应该已经准备好继续构建高性能的 Java 应用程序。

摘要

本章深入探讨了有效管理内存的复杂性,以帮助防止内存泄漏。我们必须不惜一切代价避免这些泄漏,因为它们可能会降低我们的系统性能,破坏用户体验,甚至导致系统崩溃。我们确定了内存泄漏通常是由于不当引用引起的,这阻碍了垃圾收集器释放内存的能力。我们专注于正确的引用、监听器和加载器,以及缓存和线程。现在,您应该已经准备好并自信地实施有效的内存泄漏避免策略,以应用于您的 Java 应用程序。

在下一章,并发策略中,我们将涵盖线程、同步、volatile、原子类、锁等概念。我们将利用本章中涵盖的线程相关内容,为我们深入研究并发提供一个先发优势。通过实践方法,您可以深入了解 Java 中的并发,并采用策略帮助您的 Java 程序性能更佳。

第三部分:并发和网络

并发和网络对于现代 Java 应用程序至关重要,尤其是那些需要高吞吐量和低延迟的应用程序。本部分介绍了高级并发策略来高效地管理多个线程。它还涵盖了连接池技术以优化网络性能,并探讨了超文本传输协议的复杂性。通过理解和应用这些概念,您将创建高度响应和可扩展的应用程序。

本部分包含以下章节:

  • 第九章并发策略模型

  • 第十章连接池

  • 第十一章超文本传输协议

第九章:并发策略和模型

今天的计算世界由分布式系统、基于云的架构以及由多核处理器加速的硬件组成。这些特性需要并发策略。本章提供了关于并发概念的基础信息,并提供了在 Java 程序中实现并发的实际操作机会。其根本目标是利用并发的优势和优点来提高我们的 Java 应用程序的性能。

本章首先回顾了不同的并发模型及其实际应用。概念包括基于线程的内存模型和消息传递模型。然后,我们将从理论角度和实际操作方面探讨多线程。将涵盖线程生命周期、线程池和其他相关主题。同步也将被涵盖,包括如何确保线程安全以及避免常见陷阱的策略。最后,本章介绍了非阻塞算法,这是一种高级并发策略,通过原子变量和特定数据结构来提高应用程序性能。

在本章中,我们将涵盖以下主要主题:

  • 并发模型

  • 多线程

  • 同步

  • 非阻塞算法

到本章结束时,您应该对并发及其相关策略和模型有深入的理解。您应该准备好在 Java 应用程序中实现并发,确保它们以高水平运行。

技术要求

要遵循本章中的示例和说明,您需要具备加载、编辑和运行 Java 代码的能力。如果您尚未设置您的开发环境,请参阅第一章

本章的完整代码可以在以下位置找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter09

并发模型

Java 编程语言最令人兴奋的方面之一是其健壮性。在解决并行执行挑战时,Java 支持多种模型,因此我们采取的方法取决于我们。通常,做事情的方法不止一种,每种可能的解决方案都既有优点也有缺点。我们的目标是创建运行效率高、可扩展且易于维护的 Java 应用程序。为此,我们将使用基于线程的并发方法(本章后面将详细介绍)。我们的选择基于其简单性。

并发在计算机科学中是指指令的并行执行。这是通过多线程(想想多任务处理)实现的。这种编程范式包括从多个线程访问 Java 对象和其他资源的能力。让我们看看三种特定的模型(基于线程、消息传递和反应式),然后比较它们,以确定在特定场景下哪个模型可能比其他模型更理想。

基于线程的模型

Thread 类以及 CallableRunnable 接口。

让我们看看一个简单的实现示例。我们将实现 increment 方法,并用 synchronized 关键字标记它。这告诉 Java 在任何给定时间只执行一个线程:

public class MyCounter {
  private int count = 0;
  public synchronized void increment() {
    count++;
  }
  public int getCount() {
    return count;
  }

以下代码段包含我们的 main() 方法。在这个方法中,我们创建了两个线程;它们都将增加我们的计数器:

  public static void main(String[] args) throws InterruptedException {
    MyCounter counter = new MyCounter();
    Thread t1 = new Thread(() -> {
      for(int i = 0; i < 1000; i++) {
        counter.increment();
      }
    });
    Thread t2 = new Thread(() -> {
      for(int i = 0; i < 1000; i++) {
        counter.increment();
      }
    });

下一行代码启动了线程:

    t1.start();
    t2.start();

在接下来的两行代码中,我们等待两个线程完成:

    t1.join();
    t2.join();

最后,我们输出最终结果:

    System.out.println("Final counter value: " + counter.getCount());
  }
}

基于线程的模型实现方式简单直接,这代表了一个巨大的优势。这种方法适用于较小的应用程序。使用此模型存在潜在的不利因素,因为当多个线程尝试访问共享的可变数据时,可能会引入死锁竞态条件

死锁和竞态条件

当两个线程等待对方释放所需资源时发生死锁。当需要线程执行的顺序时发生竞态条件。

在我们的应用程序中,应尽可能避免死锁和竞态条件。

消息传递模型

消息传递模型是一个有趣的模型,因为它避免了共享状态。此模型要求线程通过发送消息进行相互通信。

共享状态

当应用程序中的多个线程可以同时访问数据时存在共享状态。

消息传递模型提供了防止死锁和竞态条件的保证。此模型的一个好处是它促进了可伸缩性。

让我们看看如何实现消息传递模型。我们的示例包括一个简单的发送者和接收者场景。我们首先编写 import 语句,然后创建一个 Message 类:

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
public class MessagePassingExample {
  static class Message {
    private final String content;
    public Message(String content) {
      this.content = content;
    }
    public String getContent() {
      return content;
    }
  }

接下来,我们将让我们的 Sender 类实现 Runnable 接口:

static class Sender implements Runnable {
  private final BlockingQueue<Message> queue;
  public Sender(BlockingQueue<Message> q) {
    this.queue = q;
  }
  @Override
  public void run() {
    // Sending messages
    String[] messages = {"First message", "Second message", "Third 
    message", "Done"};
    for (String m : messages) {
      try {
        Thread.sleep(1000); // Simulating work
        queue.put(new Message(m));
        System.out.println("Sent: " + m);
      } catch (InterruptedException e) {
          Thread.currentThread().interrupt();
      }
    }
  }
}

接下来,我们将让我们的 Receiver 类实现 Runnable 接口:

static class Receiver implements Runnable {
  private final BlockingQueue<Message> queue;
  public Receiver(BlockingQueue<Message> q) {
    this.queue = q;
  }
  @Override
  public void run() {
    try {
      Message msg;
      // Receiving messages
      while (!((msg = queue.take()).getContent().equals("Done"))) {
        System.out.println("Received: " + msg.getContent());
        Thread.sleep(400); // Simulating work
      }
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
  }
}

最后一步是创建我们的 main() 方法:

  public static void main(String[] args) {
    BlockingQueue<Message> queue = new ArrayBlockingQueue<>(10);
    Thread senderThread = new Thread(new Sender(queue));
    Thread receiverThread = new Thread(new Receiver(queue));
    senderThread.start();
    receiverThread.start();
  }
}

我们的示例将 SenderReceiver 实现为 Runnable 类。它们使用 BlockingQueue 进行通信。队列用于 Sender 添加消息,Receiver 用于接收和处理它们。Sender 向队列发送 Done,以便 Receiver 知道何时停止处理。消息传递模型通常用于分布式系统,因为它支持高度可伸缩的系统。

反应式模型

响应式模型 比我们之前讨论的最后两个模型更新。它的重点是 非阻塞事件驱动编程。此模型通常体现在处理大量输入/输出操作的大规模系统中,尤其是在需要高可伸缩性时。我们可以使用外部库来实现此模型,包括 Project ReactorRxJava

让我们来看一个使用 Project Reactor 的简单实现示例。我们首先将 Project Reactor 依赖项 添加到我们的项目中。以下是使用 Maven 作为构建工具时的样子:

<dependency>
    <groupId>io.projectreactor</groupId>
    <artifactId>reactor-core</artifactId>
    <version>3.4.0</version>
</dependency>

以下示例演示了如何创建响应式流来处理一系列事件:

import reactor.core.publisher.Flux;
public class ReactiveExample {
    public static void main(String[] args) {
        Flux<String> messageFlux = Flux.just("Hello", "Reactive", 
        "World", "with", "Java")
                .map(String::toUpperCase)
                .filter(s -> s.length() > 4);
        messageFlux.subscribe(System.out::println);
    }
}

响应式模型提供了高效的资源使用、避免阻塞操作和对异步编程的独特方法。然而,与我们所讨论的其他模型相比,它可能更难实现。

比较分析

这三个并发模型各自提供了不同的好处,了解它们的个性和差异可以帮助您做出明智的决定,选择采用哪种模型。

多线程

多线程简单地说就是程序中两个或更多部分的同步执行,或并发执行,它是 Java 并发编程机制的基本方面。我们执行程序的多部分,利用多核中央处理器CPU)资源来优化我们应用程序的性能。

在我们深入探讨多线程之前,让我们专注于创建和启动线程的单个 Thread 类:

class MyThread extends Thread {
    public void run() {
        System.out.println("Thread executed by extending Thread 
        class.");
    }
}
// This is how we create and start a thread.
MyThread thread = new MyThread();
thread.start();

下面的代码片段演示了如何实现 Runnable 接口:

class MyRunnable implements Runnable {
    public void run() {
        System.out.println("Thread executed by implementing Runnable 
        interface.");
    }
}
// Here, we create and start a thread.
Thread thread = new Thread(new MyRunnable());
thread.start();

现在您已经了解了创建和启动线程是多么容易,让我们来检查它们的生命周期。

线程生命周期

Java 线程有一个明确的开始和结束状态。它们还有额外的状态,如下面的图中所示。

图 9.1 – Java 线程生命周期

图 9.1 – Java 线程生命周期

理解这些状态非常重要,这样我们才能有效地管理我们的线程。让我们简要地看看 Java 线程生命周期中的每个状态:

  1. 新建:线程处于此状态时,我们创建了它们但尚未启动它们。

  2. 可运行:当线程正在执行时,存在此状态。已启动并等待 CPU 时间的线程也有此状态。

  3. 阻塞:线程被阻止访问资源。

  4. 等待/定时等待:线程可以等待其他线程。有时,可能会有特定的等待时间,而在其他时候,等待可能是无限期的。

  5. 终止:线程在执行完成后处于此状态。

对于依赖于线程通信和同步的应用程序,理解这些状态非常重要。

多线程最佳实践

在处理多线程时,有一些事情我们应该注意,以确保我们的应用程序按预期运行,并且我们的线程是安全的。

首先,我们希望确保每个资源一次只被一个线程访问,使用 java.util.concurrent 包,它包括我们可以用于同步的并发数据结构和方法。利用这个包可以帮助我们实现线程安全。

Java 的 Object 类包括 wait()notify()notifyAll() 方法,这些方法可以用来使 Java 线程能够相互通信。以下示例应用程序演示了这些方法如何使用。我们的示例包含一个 Producer 线程,它为 Consumer 线程创建一个值。我们不希望这两个操作同时进行;事实上,我们希望 Consumer 等待 Producer 创建值。此外,Producer 必须等待 Consumer 接收到最后一个值后才能创建新的值。第一部分定义了我们的 WaitNotifyExample 类:

Public class WaitNotifyExample {
  private static class SharedResource {
    private String message;
    private boolean empty = true;
    public synchronized String take() {
      // Wait until the message is available.
      while (empty) {
        try {
          wait();
        } catch (InterruptedException e) {}
      }
      // Toggle status to true.
      empty = true;
      // Notify producer that status has changed.
      notifyAll();
      return message;
    }
    public synchronized void put(String message) {
      // Wait until the message has been retrieved.
      while (!empty) {
        try {
          wait();
        } catch (InterruptedException e) {}
      }
      // Toggle the status to false.
      empty = false;
      // Store the message.
      this.message = message;
      // Notify that consumer that the status has changed.
      notifyAll();
    }
  }
  private static class Producer implements Runnable {
    private SharedResource resource;
    public Producer(SharedResource resource) {
      this.resource = resource;
    }
    public void run() {
      String[] messages = {"Hello", "World", "Java", "Concurrency"};
      for (String message : messages) {
        resource.put(message);
        System.out.println("Produced: " + message);
        try {
          Thread.sleep(1000); // Simulate time passing
        } catch (InterruptedException e) {}
      }
      resource.put("DONE");
    }
  }

接下来,我们需要创建我们的 Consumer 类并实现 Runnable 接口:

  private static class Consumer implements Runnable {
    private SharedResource resource;
    public Consumer(SharedResource resource) {
      this.resource = resource;
    }
    public void run() {
      for (String message = resource.take(); !message.equals("DONE"); 
      message = resource.take()) {
        System.out.println("Consumed: " + message);
        try {
          Thread.sleep(1000); // Simulate time passing
        } catch (InterruptedException e) {}
      }
    }
  }

我们应用程序的最后一部分是 main() 类:

  public static void main(String[] args) {
    SharedResource resource = new SharedResource();
    Thread producerThread = new Thread(new Producer(resource));
    Thread consumerThread = new Thread(new Consumer(resource));
    producerThread.start();
    consumerThread.start();
  }
}

我们的应用程序输出如下所示:

Produced: Hello
Consumed: Hello
Produced: World
Consumed: World
Produced: Java
Consumed: Java
Produced: Concurrency
Consumed: Concurrency

当我们遵循本节提供的最佳实践时,我们增加了拥有高效多线程的机会,从而有助于构建高性能的 Java 应用程序。

同步

同步 是我们在寻求完全理解并发时应该掌握的另一个关键 Java 概念。正如我们之前指出的,我们使用同步来避免 竞态条件

竞态条件

当多个线程同时尝试修改共享资源时的条件。这种情况的结果是不可预测的,应该避免。

让我们通过查看几个代码片段来了解如何在我们的 Java 应用程序中实现同步。首先,我们展示了如何在方法声明中添加 synchronized 关键字。这样我们就可以确保一次只有一个线程可以执行特定对象上的方法:

public class Counter {
    private int count = 0;
     public synchronized void increment() {
        count++;
    }
     public synchronized int getCount() {
        return count;
    }
}

我们还可以实现 synchronized 块,它是方法的一个子集。这种粒度级别允许我们在不需要锁定整个方法的情况下同步一个块:

public void increment() {
    synchronized(this) {
        count++;
    }
}

Java 还包括一个 Lock 接口,可以用于更精细的资源锁定方法。以下是实现方法:

import java.util.concurrent.locks.ReentrantLock;
public class Counter {
    private final ReentrantLock lock = new ReentrantLock();
    private int count = 0;
    public void increment() {
        lock.lock();
        try {
            count++;
        } finally {
            lock.unlock();
        }
    }
}

Java 还包括 volatile 关键字,我们可以用它来告诉 Java,特定的变量可能被多个线程修改。当我们用这个关键字声明变量时,Java 将变量的值放置在一个所有线程都可以访问的内存位置:

public class Flag {
    private volatile boolean flag = true;
    public boolean isFlag() {
        return flag;
    }
    public void setFlag(boolean flag) {
        this.flag = flag;
    }
}

如你无疑将理解的那样,同步对于 Java 中成功的并发编程至关重要。

非阻塞算法

作为并发编程的最后一个概念,让我们来看看同步方法和同步块。非阻塞算法有三种类型——无锁无等待无阻塞。尽管它们的名称具有自我描述性,但让我们更深入地了解一下。

现代 CPU 支持原子操作,Java 包含了一些我们可以在实现非阻塞算法时使用的原子类。

原子操作

这些是现代 CPU 作为单一、有限的步骤执行的操作,确保一致性而无需锁。

这里有一个代码片段,说明了如何使用AtomicInteger

import java.util.concurrent.atomic.AtomicInteger;
public class Counter {
    private AtomicInteger count = new AtomicInteger(0);
    public void increment() {
        count.incrementAndGet();
    }
    public int getCount() {
        return count.get();
    }
}

以下示例演示了如何实现一个非阻塞栈。正如你所看到的,我们的栈使用原子引用,这确保了线程安全:

import java.util.concurrent.atomic.AtomicReference;
class Node<T> {
    final T item;
    Node<T> next;
    Node(T item) {
        this.item = item;
    }
}
public class ConcurrentStack<T> {
    AtomicReference<Node<T>> top = new AtomicReference<>();
    public void push(T item) {
        Node<T> newHead = new Node<>(item);
        Node<T> oldHead;
        do {
            oldHead = top.get();
            newHead.next = oldHead;
        } while (!top.compareAndSet(oldHead, newHead));
    }
    public T pop() {
        Node<T> oldHead;
        Node<T> newHead;
        do {
            oldHead = top.get();
            if (oldHead == null) {
                return null;
            }
            newHead = oldHead.next;
        } while (!top.compareAndSet(oldHead, newHead));
        return oldHead.item;
    }
}

当我们使用非阻塞算法时,我们可以获得性能优势,尤其是在我们的应用程序处理高并发时。这些优势被代码复杂性所抵消,这可能导致错误和更难维护的代码。

摘要

本章重点介绍了并发策略和模型,旨在深入探讨并发概念、不同的模型和策略,以及一些实现示例。我们探讨了理论概念和实践示例。涵盖的概念包括并发模型、同步和非阻塞算法。你现在应该具备足够的知识来开始尝试编写代码。

在下一章中,我们将探讨连接池,具体包括概念、实现和最佳实践。你将有机会学习如何创建和维护数据库连接对象的缓存,以帮助提高你的 Java 应用程序的性能。

第十章:连接池

连接池 是软件开发中用于管理数据库连接的技术。这些连接可以在程序执行期间重复使用,传统智慧认为任何可以重复使用的东西都应该一次性创建,并在需要时重复使用。这一直是本书的宗旨,我们努力实现高性能的 Java 应用程序。本章涵盖了连接池的概念,提供了基本原理、实现方法和示例。我们的覆盖范围包括建立连接、管理它们以及在不再需要时终止它们。还将涵盖与连接池相关的最佳实践。

本章涵盖了以下主要主题:

  • 连接池概念

  • 实现连接池

  • 连接池最佳实践

到本章结束时,你应该对连接池有深入的了解,能够实现连接池,并战略性地设计一种利用连接池来提高性能的方法。

技术要求

要遵循本章中的示例和说明,你需要具备加载、编辑和运行 Java 代码的能力。如果你还没有设置你的开发环境,请参阅 第一章

本章的完整代码可以在以下位置找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter10

连接池概念

大多数现代系统都包含多个数据库,尤其是在实施 微服务架构 时。这使得连接池的概念成为高效 Java 应用程序的关键组件。

微服务架构

微服务是软件系统中与特定业务功能相关的独立组件。它们通常拥有自己的数据库,因此可以与主应用程序解耦,并独立于其他微服务进行更新。

关键问题是软件应用程序需要连接到数据库,而这些连接会消耗系统资源。连接池的概念是建立到所需数据库的连接,当它们不再使用时,将它们返回到池中。从池中获取连接比每次数据库操作都需要创建新连接更快,资源消耗也更少。

下面的插图显示了连接池的过程,这也被认为是 连接池 生命周期

图 10.1 – 连接池生命周期

图 10.1 – 连接池生命周期

连接池在应用程序最初加载时初始化。初始化的一部分涉及建立连接数量。我们将在本章后面的示例中详细介绍。现在,你应该理解我们将使用连接池库来实现。

第二个组件是连接借用。每当需要进行数据库操作时,就会从池中获取一个连接。术语“借用”意味着一旦服务不再需要连接,它就会被返回到池中。这就是连接返回组件的生命周期的一部分。未使用的连接被返回到池中,以便再次使用。

连接池的优点

连接池有三个主要优点。首先,使用连接池可以提高性能。这种改进是可能的,因为连接被重用,从而加快数据库操作。

在我们的应用程序中实现连接池的另一个优点是我们能更好地优化资源利用。在任何给定时间打开的数据库连接数量减少了,因为未使用的连接在池中。这导致应用程序和数据库服务器开销降低。

连接池的另一个优点是它支持可伸缩性。这是可能的,因为当我们的应用程序使用连接池时,它们可以处理许多同时进行的数据库操作。

连接池的挑战

在我们的 Java 应用程序中实施的高性能方法几乎都会带来挑战和关注点。连接池也不例外。有三个主要关注领域。

首先,确定我们连接池的最佳大小至关重要。如果我们不允许足够的连接,我们的应用程序可能会变得缓慢或无响应。当我们的连接池过小时,也可能出现瓶颈。另一方面,如果我们的连接池过大,我们可能会过度使用数据库服务器,导致整体系统性能下降。

连接泄漏是另一个关注领域。管理连接池的生命周期,重点关注连接借用和返回操作,这一点很重要。当我们未能正确管理这些操作时,可能会发生连接泄漏。

连接泄漏

当我们未能正确地将连接返回到池中时,会发生连接泄漏。这可能导致连接池的资源耗尽。

现在,你应该对连接池及其优点和挑战有一个基础的了解。下一节将介绍实现示例。

实现连接池

现在,我们应该理解连接池是什么以及相关的优势和挑战。让我们通过使用 Java 实现连接池来扩展我们的知识。在本节中,我们将查看连接池库,设置连接池,将连接池与应用程序逻辑集成,并探讨如何监控我们的连接池。

连接池库

一旦我们决定在一个应用程序中使用连接池,我们需要选择一个合适的连接池库。目前有多个连接池库可供我们使用 Java,我们选择哪一个取决于我们的应用程序需求。让我们来看看三个流行的连接池库。

Apache Commons 数据库连接池DBCP)是一个成熟的库,被认为是稳定的,并且具有广泛的应用性。正如其名所示,这是一个来自 Apache 的开源库。虽然这是一个经过验证的库,但它的效率不如更现代的库。

C3PO基于云的保密性保持连续查询处理)连接池库是另一个可行的选择。它包括一组强大的功能,包括在无法建立连接时自动重试连接。这个库比 Apache Commons DBCP 库更灵活。

第三个连接池库选项是Hikari 连接池HikariCP)。这是一个比前两个更新的库,因其简洁性和性能而受到赞誉。为了提高我们的 Java 应用程序的性能,HikariCP 是连接池的一个很好的选择,也是本章剩余部分所介绍的库。

选择连接池库时应考虑的六个主要因素:

  • 兼容性:你应该检查库是否与你的 Java 版本以及你计划使用的任何数据库驱动程序或工具兼容。

  • 熟悉度:如果你和你的开发团队已经熟悉某个特定的连接池库,如果你继续使用你熟悉的库,你可以引入更快的开发和更少的错误。这种做法的缺点可能是你可能会为了开发效率而牺牲功能和运行时性能。

  • 功能:应审查功能列表,以确保你选择的库可以完成你期望它做的事情。

  • 维护:我们应该始终倾向于选择可维护的库。

  • 性能:这是一个至关重要的因素。你希望确保在你选择的库在压力下(高且持续的工作负载)不会表现不佳。这是在正式采用连接池库之前你应该测试的事情。

  • 支持:检查官方网站以确保有足够的文档。此外,你希望选择一个有强大社区支持的库。这可以在你遇到开发挑战和故障排除时帮助你。

在选择连接池库时,应高度重视整体应用程序的性能。这可能需要尝试多个库并反复试验。审查每个库的功能可以帮助你做出明智的决定。以下表格可以帮助你进行审查。

特性 Apache DBCP C3P0 HikariCP
性能 良好 良好 极佳
连接超时
语句缓存
空闲连接测试/验证
连接验证
池大小灵活性 良好 良好 极佳
文档 良好 良好 极佳
社区支持 良好 良好 极佳
配置简便性 中等复杂 中等复杂 简单
现代框架集成 良好 极佳

表 10.1 – 库特性

如前表所示,许多特性在所有三个连接库中都有相同的评级。这表明可能需要更深入的研究。此比较仅提供了一个高级概述,并提供了你可能需要进一步研究的领域的见解。

设置连接池

现在我们已经选择了连接池库,在我们的例子中是 HikariCP,我们需要遵循几个特定的步骤。让我们通过使用 Maven 作为构建工具的示例来逐步说明:

  1. 将库添加到 你的项目

    我们需要编辑我们的 pom.xml 文件以将 HikariCP 添加到我们的依赖项中。以下是这样做的方法:

    <dependency>
        <groupId>com.zaxxer</groupId>
        <artifactId>HikariCP</artifactId>
        <version>2.6.3</version>
    </dependency>
    
  2. dataSource中设置几个参数以配置我们的连接池:

    import com.zaxxer.hikari.HikariConfig;
    import com.zaxxer.hikari.HikariDataSource;
    public class DatabaseConfig {
      private static HikariDataSource dataSource;
      static {
        HikariConfig config = new HikariConfig();
        config.setJdbcUrl( "jdbc:postgresql://localhost:5432/
        myDatabase");
        config.setUsername("databaseUser");
        config.setPassword("databasePassword");
        // Pool configuration
        config.setMaximumPoolSize(10);
        config.setMinimumIdle(5);
        config.setIdleTimeout(600000);
        config.setMaxLifetime(1800000);
        config.setConnectionTimeout(30000);
        dataSource = new HikariDataSource(config);
      }
      public static HikariDataSource getDataSource() {
        return dataSource;
      }
    }
    
  3. DatabaseConfig类中的static连接池。我们采用这种方法,以便在类加载时初始化并准备好我们的连接池。我们通过getDataSource()方法建立了对连接的全局访问点。这便于我们的应用程序方法从池中借用连接。

  4. 使用getDataSource()方法,我们可以访问连接池并获取连接。以下是一个完成此任务的示例方法。如前所述,这并不是一个完整的应用程序;而是一个代表代码片段,用于展示如何使用连接池:

    import java.sql.Connection;
    import java.sql.ResultSet;
    import java.sql.Statement;
    public class DatabaseOperations {
      public void executeQuery(String query) {
        try (Connection conn = DatabaseConfig.getDataSource().
        getConnection();
            Statement stmt = conn.createStatement();
            ResultSet rs = stmt.executeQuery(query)) {
          while (rs.next()) {
            // Here you would process the result set
          }
        } catch (Exception e) {
            e.printStackTrace();
        }
      }
    }
    

如前所述,我们实现了try-with-resources语句来自动关闭我们的数据库连接,并将连接返回到池中以便再次使用。

集成连接池

将连接池集成到我们的 Java 应用程序中需要我们创建代码(如前节所示),以创建、使用和关闭数据库连接。我们强调,我们不再需要在需要数据库连接时每次都打开一个新的数据库连接。相反,我们从池中借用连接,并在使用完毕后归还。

主要集成点如下:

  • 管理连接

  • 获取连接

  • 处理错误

一旦我们设置了连接池,我们需要在运行时对其进行监控,并根据需要调整。让我们在下一节中查看这些任务。

监控连接池

连接池是现代软件系统的重要组件,它们可以极大地提高整体系统性能。这强调了在运行时监控它们性能的重要性。我们可以通过审查日志和使用监控工具来完成这项任务。大多数连接池库都附带足够用于此任务的工具。

除了错误之外,我们还应寻找以下情况:

  • 连接泄漏

  • 长等待时间

  • 不适当的池大小

监控连接池的一部分包括建立关键指标的需求。仅仅查看系统日志是不够的;我们需要一套指标或基准来正确衡量我们连接池的成功和性能。

在我们继续监控连接池性能的同时,我们可以做出在应用程序启动时应用的配置更改。对于始终开启的系统,您可能需要重新启动服务或服务器。持续改进性能的心态可以帮助确保我们从连接池中获得最佳性能,并积极影响系统的整体性能。

连接池的最佳实践

连接池实现相对简单,就像大多数编程任务一样,您将很快拥有自己的代码库,您可以为后续编程项目重构它。这通常是系统成功的关键组成部分,因为它为您的应用程序访问数据提供了机制。在您的连接池策略中应考虑以下因素。让我们看看主要因素。

连接池大小

确保您的连接池大小适当是您应考虑的第一个因素。我们应该努力在性能和资源使用之间找到理想的平衡。如果我们的池太小,访问等待时间可能会增加,这将对性能产生负面影响。过大的连接池可能导致浪费资源,如空闲连接需要系统资源。

挑战在于知道理想的连接池大小。这可能需要一些尝试和错误。最初,我们可以估计我们的应用程序可能一次需要的数据库连接数。对此没有魔法公式,因此在做出初始估计时请考虑以下因素:

  • 您的应用程序需要数据库连接以完全功能的服务数量

  • 您需要的并发连接数

  • 审查使用模式

  • 峰值负载条件

以我们之前提到的HikariCP为例,我们可以在DataSourceConfig类中使用一行代码来设置连接池的大小:

    config.setMaximumPoolSize(10);
    config.setMinimumIdle(5);

如您所见,我们将最大连接数设置为10,并将池维护的最小空闲连接数设置为5

一旦您做出初始连接池大小的决定,继续监控性能并根据需要调整您的配置。您可以使用连接池库附带的工具,以及外部工具,如应用性能监控APM)工具。

处理连接泄漏

一旦您的应用程序开始运行,您应该致力于持续监控您的连接池性能。虽然您希望不会遇到连接泄漏,但最佳实践是为此做好准备。提醒一下,当连接不再使用时未将其返回到池中时,就会发生连接泄漏。这可能导致池中可用连接耗尽。最终,这可能导致您的应用程序失败。

处理连接泄漏有两种主要方法,并且可以相互配合使用。

超时设置

我们可以为借用连接设置超时时间。如果连接从池中被借用的时间过长,那么我们可以将其回收,或者至少记录一条日志条目以帮助您的监控工作。

让我们回顾一下本章前面代码片段中的池配置部分:

    config.setIdleTimeout(600000);
    config.setMaxLifetime(1800000);
    config.setConnectionTimeout(30000);

如前述代码片段所示,我们将连接在池中处于空闲状态的最大时间设置为 60,000 毫秒(约 1 分钟)。第二行代码将连接在池中的最大生命周期设置为大约 30 分钟,第三行设置等待从池中获取连接的时间。

连接处理模式

我们应该审查我们的代码,以确保连接始终被关闭。这可以在finally块中完成,或者在本章前面使用过的try-with-resource语句中完成。让我们看看每个示例。

第一个代码片段仅为了说明目的而采用简化的格式。它演示了我们可以如何确保连接被关闭。在以下示例中,我们假设所有适当的导入语句都将被包含,并且dataSource已在应用程序的其他地方初始化。在finally块中,我们确保资源被关闭以帮助避免连接泄漏:

// import statements
public class DatabaseUtil {
    private DataSource dataSource;
    public void executeQuery(String query) {
        Connection conn = null;
        Statement stmt = null;
        ResultSet rs = null;
        try {
            conn = dataSource.getConnection();
            stmt = conn.createStatement();
            rs = stmt.executeQuery(query);
            // Process the result set
            while (rs.next()) {
                // Handle data
            }
        } catch (Exception e) {
            // Handle exception
        } finally {
            if (rs != null) {
                try {
                    rs.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            if (stmt != null) {
                try {
                    stmt.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
            if (conn != null) {
                try {
                    conn.close();
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }
}

在实现finally块以帮助确保资源关闭以避免连接泄漏的同时,另一种方法是使用try-with-resources语句。以下是该语句的示例:

public void executeQuery(String query) {
    try (Connection conn = dataSource.getConnection();
        Statement stmt = conn.createStatement();
        ResultSet rs = stmt.executeQuery(query)) {
        // Process the result set
        while (rs.next()) {
            // Handle data
        }
    } catch (Exception e) {
        // Handle exception
        e.printStackTrace();
    }
}

这种方法导致资源在退出 try-catch 块后自动关闭。退出可以基于正常的程序流程或当捕获到异常时发生。在这两种情况下,资源都将自动关闭。如您所见,这种方法不需要finally块,因此是推荐的方法。

连接池安全性

连接池使我们能够访问数据库,我们必须始终保护它们。维护数据库连接池可能代表巨大的安全风险。我们可以实施两种保护措施。

首先,我们可以加密我们的配置文件。这些文件包含我们的数据库连接信息,应被视为敏感信息。加密和解密可能会消耗处理时间,从而导致性能略有下降,但这是你应用程序的一个必要组件。

另一种保护措施是使用最小权限的概念,只授予应用程序运行所需的最小权限。例如,如果你有一个仅需要搜索客户数据库以显示诸如姓名、电子邮件和账户号码等关键信息的服务,不要授予该服务创建、更新或删除数据库的访问权限。在这种情况下,你只需要授予该服务读取访问权限。

高级主题

有几个连接池主题超出了基础知识,在我们旨在优化连接池性能的过程中值得考虑。以下是我们将探讨的四个主题:

  • 云原生:当我们与基于云的应用程序一起工作时,我们可以利用云环境本地的功能。这可以包括旨在提高弹性、可靠性和可伸缩性的功能。理想情况下,我们的数据库选择将基于云原生数据库服务,以进一步优化连接池。

  • 连接验证:定期执行一个函数来验证池中的连接是一个好主意。这可以确保它们保持有效,并可以防止代价高昂的问题。

  • 故障转移:数据库冗余是云计算环境的关键特性。具体到连接池,我们可以实现一个故障转移方案,在第一个数据库失败时切换到备份数据库。

  • 冗余:为了支持故障转移,作为正常实践的一部分,我们应该实现数据库冗余。利用云计算数据库服务可以使这相对容易配置。

遵循本节中提出的最佳实践可以帮助你以有助于提高 Java 应用程序性能的方式实现连接池。

摘要

本章深入探讨了连接池的基础概念和组件,重点关注提高我们 Java 应用程序的性能、弹性、可靠性和可伸缩性。我们还探讨了优化我们连接池使用的实现策略和最佳实践。具体来说,我们强调了连接池的工作原理、它们的优点以及开发人员面临的挑战。我们回顾了多个连接池库的功能,并选择了 HikariCP 库作为我们的代码示例。你应该对连接池有一个牢固的理解,包括为什么我们应该使用它们,以及如何创建、监控和微调它们。

在下一章中,我们将探讨超文本传输协议HTTP)。该协议用于传输数据,是网络数据通信的骨干。我们的重点是探讨如何利用 HTTP 让 Java 应用程序与网络浏览器和网络服务器进行通信。本章旨在帮助您学习如何在 Java 网络应用程序中使用 HTTP,同时保持高性能,如何实施有效的 HTTP 使用策略,以及如何使用 HTTP 在 Java 应用程序和 API 之间进行通信。

第十一章:超文本传输协议

超文本传输协议HTTP)是用于网络信息交换的基础协议。它使客户端计算机和服务器之间的通信成为可能。HTTP 对 Java 的适用性主要归因于 Java 网络应用程序。

本章从 Java 上下文中的 HTTP 介绍开始。一旦解决了基础知识,章节将转向使用内置和第三方 HTTP 相关库的实用方法。本章还涵盖了使用 HTTP 进行 API 集成,并探讨了使用 HTTP 的安全顾虑,以及 HTTPS 的使用。最后一节专注于在采用 HTTP 时对 Java 应用程序进行性能优化。到本章结束时,你应该对 HTTP 如何影响 Java 应用程序的性能有一个牢固的理解,并且对未来的 HTTP 实现感到舒适。

本章涵盖了以下主要主题:

  • HTTP 简介

  • Java 网络应用程序

  • 在 Java 中使用 HTTP

  • API 集成

  • 安全考虑

  • 性能优化

技术要求

要遵循本章中的示例和说明,你需要具备加载、编辑和运行 Java 代码的能力。如果你还没有设置你的开发环境,请参阅第一章窥探 Java 虚拟机 (JVM)

本章的完整代码可以在以下链接找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter11.

HTTP 简介

HTTP 大约在 1990 年发布,因此你很可能至少熟悉了客户端和服务器通信的基本概念。万维网建立在 HTTP 之上,HTTP 仍然是今天网络的基础。如果你计划构建一个网络应用程序,特别是使用 Java,理解这个协议是很重要的。在本节中,我们将探讨 Java 中的 HTTP。

HTTP 核心组件

HTTP 是一种无状态协议,使用称为请求-响应的通信模型。每个请求-响应交换都是独立的,这使得它成为一个非常简单的协议。今天的网络服务在很大程度上依赖于 HTTP。

无状态协议

无状态协议是指接收者不需要保留或跟踪发送的信息。没有保存会话信息。

HTTP 有四个基本组成部分:

  • 首先是请求和响应对。这对表示了所有网络通信的核心。客户端发送一个请求以获取信息,例如加载网页,服务器则发送回包含所需信息的响应或另一个操作结果,如错误。

  • 请求和响应都包含提供元数据的头部。这个 HTTP 组件包括发送内容类型、请求内容、认证细节和其他信息。

  • 第三个 HTTP 组件是状态码。当服务器响应客户端的请求时,它们会提供一个状态码来描述请求的结果。这些代码按照下表所示按编号系列分类:

类别 响应类型 示例
100 系列 信息性响应 100 Continue
200 系列 成功响应 200 OK
300 系列 重定向消息 301 永久移动
400 系列 客户端错误 404 未找到
500 系列 服务器错误 500 内部服务器错误

表 11.1 – HTTP 状态码及示例

  • 第四个 HTTP 组件是方法。HTTP 使用几种请求方法来执行所需操作。以下是常见 HTTP 方法的列表:
方法 功能
DELETE 删除资源
GET 获取资源
HEAD 获取资源的头部
POST 向资源提交数据
PUT 更新资源

表 11.2 – HTTP 方法

现在我们对 HTTP 有了基本了解,让我们来探讨这个协议对 Java 开发者的意义。

Java 和 HTTP

有许多库和例如HttpClient这样的库可以帮助简化我们对 HTTP 操作的使用。学习如何使用可用的 API 和库非常重要,尤其是在我们关注 Java 应用程序性能时。

HTTP 知识之所以如此重要,其中一个原因是大多数 API 集成都涉及 HTTP 通信。这要求我们了解如何构建 HTTP 请求,如何处理响应状态码,以及如何解析响应。

Java 开发者开发 Web 应用程序时也应该精通 HTTP。HTTP 是客户端-服务器通信的基础协议。这强调了 HTTP 知识对 Java 开发者的重要性。

Java 开发者应该全面理解 HTTP 的另一个原因是它在整体程序性能中起着重要作用。HTTP 缺乏复杂性,但仍然是开发 Web 应用、微服务、小程序和其他应用程序类型的关键协议。

Java Web 应用程序

Java Web 应用程序是一种服务器端应用程序,用于创建动态网站。我们创建与 Java Web 应用程序交互的网站,根据用户输入动态生成内容。常见示例包括以下内容:

  • 电子商务平台

  • 企业应用程序

  • 在线银行

  • 信息管理系统

  • 社交媒体平台

  • 基于云的应用程序

  • 教育平台

  • 医疗保健应用程序

  • 游戏服务器

  • 物联网IoT)应用程序

这些示例展示了使用 Java 开发和部署高性能 Web 应用的灵活性。正如你所预期的那样,HTTP 是这些 Java Web 应用的基础组件。

让我们接下来回顾 Java Web 应用程序的基本架构,以便我们理解 HTTP 的作用。

Java Web 应用程序架构

大多数 Java Web 应用程序由四个层次组成,使其成为一个多层级架构:

  • 客户端层:客户端层是用户所见到的部分,通常通过 Web 浏览器。这些网页通常由超文本标记语言HTML)、层叠样式表CSS)和JavaScriptJS)组成。

  • Web 层(或服务器层):这一层接收并处理 HTTP 请求。我们可以使用多种技术,如Java 服务器页面JSP)来完成这项任务。

  • 业务层:业务层是我们应用程序逻辑所在的地方。在这里,数据处理、计算执行和基于逻辑的决策被做出。这一层与 Web 层之间的联系非常广泛。

  • 数据层:数据层是后端系统的关键部分。这一层负责管理数据库,确保数据安全和持久性。

关键技术

值得一提的关键技术包括 Servlets、JSPs、Spring 框架和 Jakarta EE:

  • Servlets:运行在 Web 服务器上的 Java 程序被称为 Servlet。这种特殊软件位于客户端的 HTTP 请求和 Web 服务器上的应用程序和/或数据库之间。

  • JSP:JSP 是用于在服务器上执行并生成动态网页内容的文本文档。JSP 通常与 Servlets 一起使用。

  • Spring 框架:Spring 是一个常用的 Java 应用程序框架,用于开发 Java Web 应用程序。

  • Jakarta EEJakarta 企业版Jakarta EE)是一组应用程序规范,它扩展了 Java 标准版SE)。它包括关于 Web 服务和分布式计算的规范。

创建简单 Java Web 应用程序的步骤

创建 Java Web 应用程序有六个基本步骤。请注意,这是一个简化的方法,并且将根据你的应用程序需求而变化,例如对数据库、API 等的需要:

  1. 第一步是建立你的开发环境。这包括一个集成开发环境IDE),如 Visual Studio Code,最新的Java 软件开发工具包JDK),以及一个 Web 服务器,如 Apache Tomcat。

  2. 下一步是在你的 IDE 中创建一个新的 Web 应用程序项目。这一步包括创建项目的文件目录结构。

  3. 接下来,我们将编写 Java Servlet 的代码来处理 HTTP 请求。在这一步中,我们还将定义 Servlet 将响应的路线。这通常使用 URL 定义。

  4. 接下来,我们将创建我们计划用于生成 HTML 内容的 JSP 页面或模板。这是我们发送回客户端的内容。

  5. 接下来,我们将创建业务逻辑,这是我们应用程序的核心。我们可以通过一系列 Java 类来实现这一点。

  6. 最后,我们将我们的应用程序打包成一个Web 应用程序存档WAR),它类似于Java 应用程序存档JAR),但用于 Web 应用程序,并将其部署。

在开发 Java Web 应用程序时,我们应该创建具有明确边界的应用程序,这些边界分别位于表示层、业务层和数据访问层之间。这种方法有助于模块化、可扩展性和可维护性。还建议使用 Spring 和 Jakarta EE 等框架。这样做可以简化我们的开发工作,并为 Web 应用程序开发提供内在支持。

动态网页是标准,并且用户期望如此,因此拥抱 Java Web 应用程序技术对所有 Java 开发者来说都很重要。

在 Java 中使用 HTTP

作为 Java 开发者,我们可以使用 HTTP 来创建动态 Web 应用程序。这些应用程序将使用 HTTP 在浏览器和服务器之间进行通信。Java 包括HttpClient库,这是一个 Java 库,它使得处理 HTTP 请求和响应变得高效。让我们来看一个例子。

上一段代码使用了HttpClient库来创建一个GET请求,该请求从特定的资源(在我们的例子中是模拟的)检索数据:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class GetRequestExample {
  public static void main(String[] args) {
    HttpClient client = HttpClient.newHttpClient();
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.not-real-just-
         an-example.com/data"))
        .GET()
        .build();
    try {
      HttpResponse<String> response = client.send(request,
      HttpResponse.BodyHandlers.ofString());
      System.out.println("Status Code: " +
      response.statusCode());
      System.out.println("Response Body: \n" + response.body());
    } catch (IOException | InterruptedException e) {
        e.printStackTrace();
    }
  }
}

上一段示例向一个模拟的 URL 发送一个GET请求,并打印出响应的状态码和正文。

接下来,让我们看看制作POST请求的方法。这种请求类型可以用来使用 JSON 向特定资源提交数据。在我们的例子中,这将是一个模拟的资源:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpHeaders;
import java.nio.charset.StandardCharsets;
public class PostRequestExample {
  public static void main(String[] args) {
    HttpClient client = HttpClient.newHttpClient();
    String json = "{\"name\":\"Neo
    Anderson\",\"email\":\"neo.anderson@thematrix.com\"}";
    HttpRequest request = HttpRequest.newBuilder()
        .uri(URI.create("https://api.not-real-just-an-
        example.com/users"))
        .header("Content-Type", "application/json")
        .POST(HttpRequest.BodyPublishers.ofString(json,
        StandardCharsets.UTF_8))
        .build();
    try {
      HttpResponse<String> response = client.send(request,
      HttpResponse.BodyHandlers.ofString());
      System.out.println("Status Code: " +
      response.statusCode());
      System.out.println("Response Body: \n" +
      response.body());
    } catch (IOException | InterruptedException e) {
        e.printStackTrace();
    }
  }
}

这个例子简单地向一个模拟的 URL 发送一个POST请求,其中包含一个包含用户信息的 JSON 包。

利用HttpClient库可以简化与 Web 服务交互的代码开发过程。

API 集成

当我们构建 Java Web 应用程序时,我们可以集成外部 API 来扩展我们应用程序的功能。一个例子是天气服务 API,可以用来在网站上显示当地温度。对于本节,我们将重点关注表示状态转换RESTful)服务,因为这些是最常见的 Web API 类型。

RESTful API 使用标准的 HTTP 方法,例如上一节中的GETPOST示例。正如你所期望的,RESTful API 主要通过 HTTP 数据交换使用 JSON 和 XML 格式进行通信。

当我们实现一个 API 时,我们首先了解其所需请求方法,以及请求和响应的指定格式。API 需要认证的情况越来越普遍,因此你可能需要使用 API 密钥或其他授权技术来应对这一点。

下面的示例演示了一个实现天气 API 的简单应用程序:

import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class WeatherApiExample {
  public static void main(String[] args) {
    HttpClient client = HttpClient.newHttpClient();
    String apiKey = "your_api_key_here";
    String city = "Florence";
    String uri = String.format("https://api.fakeweatherapi.com/v1/
    current.json?key=%s&q=%s", apiKey, city);
    HttpRequest request = HttpRequest.newBuilder()
      .uri(URI.create(uri))
      .GET()
      .build();
    try {
      HttpResponse<String> response = client.send(request, 
      HttpResponse.BodyHandlers.ofString());
      System.out.println("Weather Data: \n" + response.body());
    } catch (IOException | InterruptedException e) {
        e.printStackTrace();
    }
  }
}

我们的示例向 API 发送一个GET请求,将城市作为查询参数传递。JSON 响应将包含适用的天气数据,这些数据将打印在系统的显示上。

API 集成可以被认为是许多基于 Java 的 Web 应用程序的核心组件,因为它的适用性非常广泛。

安全考虑

每当我们向我们的 Java 应用程序添加发送信息到应用程序外部或从外部源接收信息的函数时,安全性就成为一个至关重要的关注点。这在我们将 API 和 HTTP 集成到 Java 应用程序中时尤其如此。让我们看看我们可以使用的九个最佳实践,以确保我们的 HTTP 通信既安全,又在与 API 一起工作时安全:

  • 使用 HTTPS 而不是 HTTP:如果你的 Java Web 应用程序处理敏感的、受保护的或私人信息,你应该在发送请求和响应时使用HTTP 安全HTTPS)而不是 HTTP。这将有助于防止篡改和数据拦截。这将要求你为你的服务器获取安全套接字层SSL)证书。

  • 不要信任输入:我们应该始终验证我们的系统输入,包括用户输入和以编程方式传递给我们的应用程序的数据。我们不应假设这些数据是正确的格式。在验证数据后,我们可能需要清理它,以便可以在我们的应用程序中使用。这种方法可以帮助减轻诸如SQL 注入等恶意操作。

  • 认证:尽可能识别你的应用程序与之交互的用户和系统。前面提到的 API 密钥在这里发挥作用。

  • 授权:一旦用户或系统经过认证,我们应该确保他们有权在你的应用程序中执行特定操作。并非每个用户都将拥有相同的权限级别。

  • 保护 API 密钥:我们已经提到了 API 密钥的重要性及其在解决安全问题的适用性。API 密钥就像密码一样;我们必须保护它们免受利用。我们不希望将这些密钥硬编码到我们的应用程序中;相反,我们应该将它们存储在加密的配置文件中,这样它们就可以免受未经授权的眼睛的侵害。

  • 使用安全头:我们有使用HTTP 安全头的选项。以下是一些详细信息:

    • 内容安全策略CSP):这通过明确标识允许加载的资源来帮助防止 XSS 攻击

    • HTTP 严格传输安全HSTS):这可以用来强制执行安全的服务器连接

  • 谨慎处理敏感数据:这一点不言而喻,但敏感数据值得特别注意。例如,永远不要在 URL 中传输敏感数据,因为它们可能会被记录下来,然后泄露。此外,确保在存储敏感数据(如密码)时对其进行加密或散列。另外,使用如令牌化等技术来安全地处理支付信息。

  • 更新依赖项:我们应该定期检查我们的依赖项和 Java 库是否是最新的。我们不希望使用可能存在已知漏洞的组件的旧版本。

  • 记录和监控:与我们的所有软件一样,我们希望确保我们实施适当的记录,然后监控操作以确保日志中不包含敏感信息。

安全性始终应该是开发者心中的首要任务。当与 HTTP 和外部 API 一起工作时,这一点尤为重要。遵循本节中讨论的九项最佳实践是开发 Java Web 应用程序安全策略的好起点。

性能优化

现在我们已经充分介绍了 HTTP 是什么,它在 Java 中的应用,以及一些技术和最佳实践,让我们考虑使用 Java 时与性能相关的问题。我们查看性能问题的目标是提升用户体验,并提高我们应用程序的可扩展性、弹性和可维护性。

让我们看看在使用 HTTP 进行 Java 应用程序时,与性能优化相关的七个具体领域:

  • 第一个领域侧重于HttpClient库,包括对连接池的支持。

  • 我们可以使用HTTP Keep-Alive来保持与常见主机的多个请求的连接打开。这将减少通信握手次数并提高延迟。

  • 我们经常可以利用异步请求(即 API 调用)来改善应用程序流程。

  • 缓存是另一个需要关注的领域,以帮助优化性能。有一些缓存策略可以用来提高性能:

    • 在应用程序级别缓存频繁访问的数据。具体取决于您的应用程序及其使用的数据。甚至还有如 Caffeine 之类的缓存框架可以使用。

    • 使用 HTTP 缓存头(即 Cache-Control)可以帮助您控制响应缓存。

    • 如果您的 Java Web 应用程序处理静态内容(即图像),您可以考虑使用内容分发网络CDNs)来将内容缓存得更靠近您的用户(即在特定地理区域的服务器上存储数据)。这种方法可以显著缩短用户的加载时间。* 另一个需要考虑的领域是优化数据传输。有两个具体的方法值得考虑来改进数据传输:

    • 在尽可能的范围内,我们应该最小化数据请求。显然,数据请求越少,我们的应用程序性能越好。实现这一点需要针对 API 集成设计采取有目的的方法。我们可以使用特定的 API 端点仅获取完成任务所需的数据,而不是使用庞大的数据包。

    • 当可能且适用时,在您的 API 上实施速率限制。这有助于防止滥用和拒绝服务攻击。同时也有助于维护服务质量。

    • 如果你的应用程序和 API 支持批量请求,那么实现它是值得的。这可以在系统性能上产生深远的影响。* 代码优化是另一个需要关注的领域。可以使用 VisualVM 和 JProfiler 等性能分析工具来帮助识别性能瓶颈。这些工具可以用来针对内存和 CPU 操作。详见第十四章性能分析工具,获取更多信息。* SQL 优化是另一个需要关注的领域。SQL 查询可以被优化以减少数据库负载和执行时间。对数据库模式的彻底审查可以帮助识别更多的优化机会。详见第十五章优化数据库和 SQL 查询,获取更多信息。* 当我们在 Java 中处理 HTTP 时,我们关注的最后一个性能领域是可伸缩性。这个领域中的两个主要技术是负载均衡,有助于提高应用程序的可用性,以及微服务架构,以获得更好的性能。

优化我们 Java 应用程序的每一个方面对于实现开发高性能应用程序的目标至关重要。在 Java 中使用 HTTP 代表了一组独特的挑战和优化机会。

摘要

本章强调了 HTTP 在 Java Web 应用程序开发中所扮演的角色。HTTP 的目的被表述为促进动态 Web 应用程序的发展。我们对这个主题的覆盖表明,HTTP 可以被高效且安全地使用。我们还探讨了 Java Web 应用程序、API 集成、安全性和性能优化策略。由于 HTTP 和 Java Web 应用程序开发领域都非常活跃,因此了解相关变化和更新变得尤为重要。

下一章,第十二章优化框架,介绍了使用异步输入/输出、缓冲输入/输出和批量操作来创建高性能 Java 应用程序的策略。本章还涵盖了微服务和云原生应用程序的框架。

第四部分:框架、库和性能分析

利用合适的框架和库可以显著提升应用程序的性能。本部分将探讨为优化设计的各种框架,并介绍可集成到 Java 项目中的以性能为导向的库。此外,它还提供了使用性能分析工具来识别和解决性能瓶颈的指南。本节中的章节旨在为您提供调整应用程序以实现最大效率所需的工具和知识。

本部分包含以下章节:

  • 第十二章优化框架

  • 第十三章以性能为导向的库

  • 第十四章性能分析工具

第十二章:优化框架

优化框架是旨在帮助开发者提升其 Java 应用程序性能的库、工具和指南。例如,包括流程简化、资源利用率降低和处理器负担减轻。这正是本章的重点。我们将首先探讨异步输入和输出,其重要性以及相关的库和框架。本章接着探讨缓冲输入和输出,包括用例和性能影响。您可以了解异步和缓冲输入/输出操作如何提高效率并减少延迟。我们将探讨批量操作的好处以及相关的框架和 API。我们将回顾优化批量操作的技术,以最小化资源利用并最大化数据流。

本章介绍了微服务,并涵盖了可以与微服务一起使用的特定框架。这些框架以及云原生应用程序框架将被探讨,因为这些高级架构在现代软件开发中普遍存在。我们将强调如何实现这些框架以优化我们的 Java 应用程序的性能。为了结束本章,我们将回顾几个案例研究和性能分析,提供实际情境,以便在现实场景中使用本章的特色框架。

到本章结束时,您应该对优化 Java 应用程序的关键框架有基础的了解。案例研究应该有助于加深您对这些框架如何影响应用程序性能的理解。您还应该能够根据本章介绍的框架创建和实施优化策略。

本章涵盖了以下主要主题:

  • 异步输入/输出

  • 缓冲输入/输出

  • 批量操作

  • 微服务框架

  • 云原生应用程序框架

  • 案例研究和性能分析

技术要求

为了跟随本章中的示例和说明,您需要具备加载、编辑和运行 Java 代码的能力。如果您尚未设置您的开发环境,请参阅第一章Java 虚拟机(JVM)内部窥视

本章的完整代码可以在以下位置找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter12

异步输入/输出

异步指的是非协调(非同步)的通信操作。在输入/输出操作的上下文中,数据不必以稳定的流传输。这是一种我们可以在 Java 中使用的技巧,允许我们的程序在不阻塞主线程执行的情况下处理输入和输出操作。虽然不一定总是需要采用这种技术,但它可以提供处理依赖于高响应性和性能的系统时的巨大性能优势。

为了简洁起见,让我们用 AIO 这个缩写来指代 异步输入/输出。使用 AIO,我们可以启动进程,然后让它们独立运行,这允许我们的主应用程序继续运行其他进程。考虑 同步的替代方案,其中主应用程序必须在运行另一个操作之前等待一个操作完成。同步方法可能导致延迟、更长的响应时间和其他低效。

现在您已经对异步输入/输出有了基础的了解,让我们来探讨使用这种技术来提高 Java 应用程序性能的优点和最佳实践。

优点和最佳实践

AIO 有几个优点,并且最好在遵循行业最佳实践的情况下利用它们。首先,我们将回顾优点,然后是最佳实践。

优点

在我们的 Java 应用程序中实现 AIO 有三个优点:

  • 效率:AIO 实现可以带来资源效率,因为我们可以使用线程进行处理,而不是等待输入/输出操作。

  • 响应性:当我们解耦输入/输出操作与主线程执行时,我们提高了应用程序的整体响应性。使用 AIO,主应用程序可以保持对输入(即用户交互)的响应,同时其他输入/输出操作正在进行。

  • 可伸缩性:如果您的应用程序需要可伸缩性,您肯定会想考虑实现 AIO,这对于构建可伸缩的应用程序至关重要。AIO 帮助我们管理多个同时连接,而无需额外的线程。这显著减少了开销。

在考虑到这些优势的情况下,让我们回顾一下在 Java 中优化它们的最佳实践。

最佳实践

这里有一些最佳实践来帮助指导您对 AIO 的使用:

  • 错误处理:您的 AIO 实现策略应包括强大的错误处理机制,以捕获和处理异常。

  • 处理程序:建议使用 回调处理程序来响应输入/输出事件,以帮助您保持代码的整洁和可维护性。

  • 资源管理:与大多数编程一样,您应确保在您的应用程序中使用的所有资源(例如,网络套接字)在相关操作完成后关闭。这将有助于防止资源/内存泄漏。

我们已经确定实现 AIO 是一种可以积极影响我们应用程序性能的方法。在下一节中,我们将探讨如何在 Java 中实现 AIO。

实现 AIO

Java 平台包括一个新输入/输出NIO)库,它包括以下功能:

  • AsynchronousFileChannel:这个类使我们能够从文件中读取和写入数据,而不会阻塞其他任务。

  • AsynchronousServerSocketChannelAsynchronousSocketChannel:这些类用于处理异步网络操作,这有助于使我们的应用程序可伸缩。

  • 通道缓冲区:Java 的 NIO 库在通道和缓冲区上有着重度的依赖。通道是执行输入/输出操作的组件的连接,例如网络套接字。缓冲区处理数据。

除了 Java NIO 之外,我们还有几个与 AIO 相关的框架和库可供选择。以下有两个例子:

  • Akka:这是一个由库组成的工具包,帮助我们构建具有弹性的 Java 应用程序,重点是分布式和当前系统。

  • Netty:这是一个用于高性能应用的框架,它使得开发者能够轻松地创建网络应用。它支持异步 IO 和事件驱动通信模型。

让我们来看看代码中的 AIO。以下示例应用程序演示了AsynchronousFileChannel类来执行异步文件读取操作。正如你所看到的,该应用程序使用回调机制来处理读取操作的完成。

我们的应用程序从一系列它所需的import语句开始。正如你所看到的,我们正在利用 Java 的 NIO 库:

import java.nio.ByteBuffer;
import java.nio.channels.AsynchronousFileChannel;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.concurrent.Future;

接下来是我们的main()类:

public class CH12AsyncFileReadExample {
  public static void main(String[] args) {
    Path path = Paths.get("ch12.txt");
    try (AsynchronousFileChannel fileChannel = AsynchronousFileChannel.open(path, StandardOpenOption.READ)) {
      ByteBuffer buffer = ByteBuffer.allocate(1024);
      Future<Integer> result = fileChannel.read(buffer, 0);
      while (!result.isDone()) {
        System.out.println("Processing something else while reading 
        input...");
      }
      int bytesRead = result.get();
      System.out.println(bytesRead + " bytes read");
      buffer.flip();
      byte[] data = new byte[bytesRead];
      buffer.get(data);
      System.out.println("Read data: " + new String(data));
    } catch (Exception e) {
       System.err.println("Error encountered: " + e.getMessage());
    }
  }
}

从前面的代码中我们可以看出,我们使用AsynchronousFileChannel打开ch12.txt文件。我们使用AsynchronousFileChannel.open()异步打开它,指定路径,并设置只读打开选项。接下来,我们使用ByteBuffer来保存我们从文件中读取的数据。这是一个非阻塞方法,它立即返回一个表示待处理结果的Future对象。我们在读取操作期间模拟处理其他任务,并从主线程打印一条消息。最后,我们实现了一个 while 循环,使用isDone()来确定读取操作是否完成。

在我们的 Java 应用程序中实现 AIO 可以帮助我们实现高性能、增加响应性和可伸缩性。接下来,我们将研究缓冲输入/输出。

缓冲输入/输出

输入/输出的缓冲方法,通常称为缓冲输入/输出,可以实现以减少所需的输入/输出操作的数量。这种方法是通过使用称为缓冲区的临时存储来实现的。缓冲区在传输过程中暂时持有数据。这种方法的目标是最大限度地减少与硬件和数据流的直接交互。Java 通过在处理之前在缓冲区中累积数据来实现这一承诺。

现在你已经对缓冲输入/输出有了基础的了解,让我们来探讨使用这种技术来提高 Java 应用程序性能的优点和最佳实践。

优点和最佳实践

缓冲输入/输出有几个优点,并且最好在遵循行业最佳实践的情况下利用它们。接下来,我们将回顾优点和最佳实践。

优点

在我们的 Java 应用程序中实现缓冲输入/输出的三个优点如下:

  • 数据处理:缓冲输入/输出可以提高数据处理效率,因为它允许在读写操作期间临时存储数据。这对于处理数据流和大文件特别有益。

  • 灵活性:我们可以使用缓冲类来封装我们的输入和输出流。这使得它们更适合各种用途。

  • 性能:缓冲输入/输出的目标是减少输入/输出操作的次数,从而减少交互开销,最终提高整体应用程序性能。

记住这些优点,让我们回顾一下在 Java 中优化它们的最佳实践。

最佳实践

这里有一些最佳实践可以帮助指导你使用缓冲输入/输出:

  • 缓冲区大小:应进行测试以确定最合适的缓冲区大小。每个用例都不同,取决于数据、应用程序和硬件。

  • 错误处理:在应用程序中添加健壮的错误处理总是一个好的做法。这在输入/输出操作可能因外部问题(例如文件访问权限或网络问题)而失败的情况下尤为重要。

  • 资源管理:关闭缓冲流将释放系统资源并有助于避免内存泄漏。

我们已经确定实现缓冲输入/输出是一种可以积极影响我们应用程序性能的方法。在下一节中,我们将探讨如何在 Java 中实现缓冲输入/输出。

实现缓冲输入/输出

Java 平台在java.io库中包含了几种具有以下功能的类:

  • BufferedInputStream: 这个类用于从流中读取二进制数据。数据存储在缓冲区中,允许高效的数据检索。

  • BufferedOutputStream: 这个类将字节写入流,收集数据到缓冲区,然后写入输出设备。

  • BufferedReader: 这个类从输入流中读取数据并缓冲数据。

  • BufferedWriter: 这个类将数据写入输出流,缓冲数据以实现从缓冲区进行高效写入操作。

让我们来看看代码中的缓冲输入/输出:

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.FileReader;
import java.io.FileWriter;
import java.io.IOException;
public class CH12BufferedReadWriteExample {
  public static void main(String[] args) {
    String inputFilePath = "input.txt";
    String outputFilePath = "output.txt";
    try (BufferedReader reader = new BufferedReader(new 
    FileReader(inputFilePath));
    BufferedWriter writer = new BufferedWriter(new 
    FileWriter(outputFilePath))) {
      String line;
      while ((line = reader.readLine()) != null) {
        writer.write(line);
        writer.newLine();
      }
    } catch (IOException e) {
        System.err.println("An error occurred: " + e.getMessage());
    }
  }
}

正如你在示例代码中所看到的,我们使用BufferedReaderBufferedWriter来读取和写入文件。readLine方法用于高效地从输入文件中读取行,而BufferedWriter可以快速写入文件,输入/输出操作最少。

通过利用本节中涵盖的缓冲输入/输出类,并遵循提供最佳实践,我们可以显著提高 Java 应用程序的性能,尤其是那些有频繁读写操作的应用程序。

批量操作

批量操作的概念意味着我们一次性处理大量数据或将多个任务组合成一个单一操作。与逐个处理事情相比,这种处理方式可以带来巨大的效率,并减少开销。实现批量操作通常是提高性能的好方法,尤其是在大规模数据操作、文件处理和数据库交互中。

实际中的批量操作涉及执行一系列作业,通常将大型数据集作为组来处理。这些分组基于自然或逻辑分组,目的是减少与重复启动和停止过程相关的计算开销。

优势和最佳实践

批量操作有多个优势,并且最好在遵循行业最佳实践的情况下利用它们。接下来,我们将回顾优势,然后是最佳实践。

优势

在我们的 Java 应用程序中实现批量操作有以下三个优势:

  • 性能:批量操作代表了性能的巨大提升。考虑到需要处理 100 个文件的场景,如果逐个处理,则需要 100 个操作。使用批量操作处理这 100 个文件会更快,因为系统调用会显著减少。网络延迟也会得到改善。

  • 资源使用:实现批量操作可以减少开销并最大化资源利用率。

  • 可扩展性:批量操作使得我们的系统更容易处理大量数据集。这种方法本身具有可扩展性。

考虑到这些优势,让我们回顾一下最佳实践,以在 Java 中优化它们。

最佳实践

这里有一些最佳实践,可以帮助指导你使用批量操作:

  • 批量大小:在批量大小过小和过大之间需要找到一个平衡点。如果太小,你不太可能获得性能上的好处;如果太大,你的应用程序可能会遇到内存问题。确定正确的大小需要测试,并受处理类型和数据类型的影响。

  • 错误处理:作为批量操作错误处理的一部分,确保在某个部分失败时考虑到批量操作的每个部分。

  • 监控:与所有主要系统一样,记录和监控这些日志的重要性不容小觑。

现在,让我们看看如何在 Java 中实现批量操作。

实现批量操作

Java 平台包括以下 API 和框架,帮助我们实现批量操作:

  • Java 批处理Java 规范请求JSR)规范 352 提供了一种标准的批处理实现方法。它包括定义和步骤。

  • JDBC 批处理Java 数据库连接JDBC)批处理用于处理批量的结构化查询语言SQL)语句。我们将在下一节中演示这一点。

  • Spring batch:这是一个提供大量批处理功能的框架,包括作业处理统计、资源优化和事务管理。

让我们通过使用 JDBC 批处理来查看一个示例。以下示例程序演示了如何使用 JDBC 批处理高效地将多条记录插入到数据库中。请注意,数据库是模拟的。

我们的示例从import语句开始:

import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.PreparedStatement;
import java.sql.SQLException;

接下来,我们创建我们的类和main()方法。本节包括连接到并登录到模拟数据库:

public class CH12JDBCBatchExample {
  public static void main(String[] args) {
    String url = "jdbc:mysql://localhost/testdb";
    String user = "root";
    String password = "password";

以下是我们计划在批处理操作中使用的 SQL 语句。在该语句之后,我们使用try-catch块来封装我们的批处理操作:

    String sql = "INSERT INTO staff (name, department) VALUES (?, ?)";
    try (Connection conn = DriverManager.getConnection(url, user, 
    password);
      PreparedStatement statement = conn.prepareStatement(sql)) {
      conn.setAutoCommit(false); // Turn off auto-commit
      // Add batch operations
      statement.setString(1, "Brenda");
      statement.setString(2, "Marketing");
      statement.addBatch();
      statement.setString(1, "Chris");
      statement.setString(2, "Warehouse");
      statement.addBatch();
      statement.setString(1, "Diana");
      statement.setString(2, "HR");
      statement.addBatch();
      int[] updateCounts = statement.executeBatch();
      conn.commit(); // Commit all the changes
      System.out.println("Rows inserted: " + updateCounts.length);
    } catch (SQLException e) {
       System.err.println("SQL Exception: " + e.getMessage());
    }
  }
}

我们的简单示例程序将多个INSERT语句分组到一个批次中,并且它们在一个对模拟数据库的单个请求中执行。

如果适用,在我们的 Java 应用程序中结合批处理操作可以显著提高应用程序性能。它还可以增加可扩展性和系统可维护性。

微服务框架

微服务是一种软件架构方法,它由松散耦合的模块(微服务)组成,这些模块构成了整个应用程序。微服务的优点包括能够同时让团队在单个微服务上工作,能够独立于整个应用程序进行更新,提高了可扩展性,以及更高效的维护性。本节重点介绍微服务的优化框架。

现在你已经对微服务框架有了基础的了解,让我们来探讨使用这种技术来提高 Java 应用程序性能的优点和最佳实践。

优点和最佳实践

实施微服务框架有多个优点,并且最好在遵循行业最佳实践时加以利用。接下来,我们将回顾这些优点以及最佳实践。

优点

在我们的 Java 应用程序中实施微服务框架有三个优点:

  • 故障隔离:使用微服务的一个关键优点是隔离故障。这是可能的,因为每个微服务都与另一个微服务松散耦合。这意味着一个微服务的故障不会必然影响其他微服务。

  • 灵活性:采用微服务框架在开发和维护系统时提供了更大的敏捷性和灵活性。

  • 可扩展性:微服务的分布式特性使得它们具有极大的可扩展性;它们天生就是可扩展的。

考虑到这些优势,让我们回顾一下最佳实践,以优化 Java 中的这些实践。

最佳实践

这里有一些最佳实践,可以帮助指导您使用微服务框架:

  • API 设计: 在设计和开发 API 时,重要的是要彻底审查其稳定性和向后兼容性。

  • 配置管理: 应使用正式的版本控制系统,以确保所有微服务之间的一致性。

  • 监控: 创建一个强大的日志系统并对其日志进行监控是至关重要的。这有助于在问题变得关键之前识别它们。

我们已经确定,实现微服务框架是一种可以积极影响我们应用程序性能的方法。在下一节中,我们将探讨如何在 Java 中实现微服务框架。

实现微服务框架

采用微服务架构设计的系统本质上由独立的应用程序(微服务)组成,这些应用程序基于业务功能。每个微服务都有自己的数据。有几种框架可用于在 Java 中实现微服务。以下是四种:

  • Helidon: 这个框架由 Oracle 提供,帮助我们使用微服务架构创建应用程序。这是一个现代框架,API 提供了许多选项。

  • Micronaut: 这是一个现代且健壮的基于 JVM 的框架,包括依赖注入和容器管理等功能。

  • Quarkus: 如果您使用 Kubernetes 进行容器化,Quarkus 是创建微服务应用程序的好选择。

  • Spring Boot: 这是在 Java 中实现微服务最常用的框架。它易于设置、配置和使用。

让我们看看一个简单的 Micronaut 微服务应用程序。我们将采用三步法:

  1. 第一步是设置我们的项目。这可以通过 Micronaut 命令行界面CLI)或支持的 集成开发环境IDE),如 IntelliJ IDEA 完成。使用 CLI,设置代码可能如下所示:

    mn create-app example.micronaut.CH12Service --features=http-server
    
  2. 接下来,我们需要创建一个控制器来处理 HTTP 请求。以下是实现方式:

    package example.micronaut;
    import io.micronaut.http.annotation.Controller;
    import io.micronaut.http.annotation.Get;
    @Controller("/ch12")
    public class CH12Controller {
        @Get("/")
        public String index() {
            return "Hello from CH12's Micronaut!";
        }
    }
    
  3. 第三步和最后一步是简单地运行应用程序。请注意,以下示例指的是 Gradle Wrapper

    ./gradlew run
    

进一步检查代码,我们可以看到我们使用 @Controller 注解来标识我们的类为控制器,其基本 URI 为 /ch12。当应用程序运行时,服务将位于 http://localhost:8080/ch12

如 Helidon、Micronaut、Quarkus 和 Spring Boot 这样的微服务框架为我们提供了创建使用微服务架构的 Java 应用程序的多项功能。

云原生应用程序框架

开发云原生应用是一个战略决策,通常基于利用云计算固有的可扩展性、弹性、安全性和灵活性。

云原生

在软件开发背景下,云原生指的是从头开始使用云计算创建应用并将其部署。

我们有多个框架可供选择,支持 Java 的云原生应用开发。云原生应用从头到尾在云环境中构建。这些应用通常构建为微服务,它们被打包进容器中,通过DevOps接受的过程进行编排和管理。

现在您已经对云原生应用有了基础的理解,让我们来探讨使用这种技术来提高 Java 应用性能的优点和最佳实践。

优势和最佳实践

云原生框架有几个优点,并且当遵循行业最佳实践时,它们可以得到最佳利用。接下来,我们将回顾这些优点和最佳实践。

优点

在我们的 Java 应用中实施云原生框架有三个优点:

  • 效率:使用云原生框架为我们 Java 应用带来的最大优点之一是由于自动化、开发一致性和测试而引入的巨大效率。

  • 容错性:由于云原生应用是以微服务编写的,一个服务的故障不会必然影响到其他服务。

  • 可扩展性:微服务架构本身是可扩展的,云环境也是如此。这使得我们能够构建高度可扩展的 Java 应用。

考虑到这些优势,让我们回顾一下在 Java 中优化它们的最佳实践。

最佳实践

以下是一些最佳实践,以帮助指导您使用框架开发云原生应用:

  • 容器化:应用应与其依赖项一起打包到容器中。这将有助于确保每个服务的一致性,无论运行环境如何。

  • 持续集成/持续交付(CI/CD):采用 CI/CD 方法,实现自动部署和测试,可以显著提高开发速度并最小化非自动化流程中固有的错误。

  • 监控:创建健壮的日志并持续监控它们可以帮助在问题成真之前识别潜在的问题。

我们已经确定实施云原生框架是一种可以积极影响我们应用性能的方法。在下一节中,我们将探讨如何在 Java 中实施它们。

实施云原生应用

我们有多个框架可供选择,以帮助我们用 Java 开发云原生应用。以下是三个流行的框架:

  • Eclipse MicroProfile:这是一个为 Java 企业应用程序优化而设计的可移植 API,适用于微服务架构。它具有许多功能,包括健康检查、指标和容错。

  • Quarkus:这个框架采用容器优先的哲学,与 Kubernetes 配合最佳。

  • Spring Cloud:这是 Spring 环境的一部分(即 Spring Boot),是一套针对云环境构建常见软件模式(如配置和服务发现)的开发工具。

让我们看看一个简单的使用 Eclipse MicroProfile 的云原生应用程序:

import org.eclipse.microprofile.config.inject.ConfigProperty;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.Produces;
import javax.ws.rs.core.MediaType;
@Path("/hello")
public class CH12HelloController {
    @ConfigProperty(name = "username")
    String username;
    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        return "Hello " + username + ", from MicroProfile!";
    }
}

上述代码片段展示了如何使用 MicroProfile 注入配置属性(例如,用户名)。这个非常简单的例子强调了使用此类框架有效处理配置和其他微服务相关问题的优势。

案例研究和性能分析

到目前为止,在本章中,我们已论证实施优化框架可以极大地提高我们 Java 应用程序的性能和可伸缩性。本节回顾了两个案例研究,并探讨了性能分析,以帮助确定框架采用对我们 Java 应用程序的影响。

案例研究

通过审查基于合理现实情况的案例研究,可以帮助展示采用优化框架的优势。以下有两个案例研究。

案例研究 1

名称:基于微服务的电子商务应用程序

背景:Reinstate LLC 公司是一家大型电子商务零售商,最近从其之前的单体架构过渡到微服务架构。他们使用 Spring Boot 和 Spring Cloud 来提高可伸缩性、可靠性和可维护性。

机会:Reinstate LLC 在高峰时段难以扩展其单体系统。他们还注意到,他们的开发周期过长,并将其归因于应用程序组件之间的相互关联性。

解决方案:每个应用程序组件都被重构为基于业务功能或逻辑的微服务。这些微服务包括客户资料、库存管理、订单管理和购物车管理。Spring Boot 用于创建单个微服务,Spring Cloud 用于服务发现、负载均衡和配置管理。

结果:在实施微服务架构后,Reinstate LLC 经历了 65% 的停机时间减少和 42% 的响应时间提升。

案例研究 2

名称:金融服务批量处理。

背景:CashNow,一家金融服务公司,希望每天处理大量交易,同时确保高精度和可靠性,使用批量处理。

机会:CashNow 的现有系统效率低下,经常出现交易延迟。这对它的报告和每日结算流程造成了破坏。

解决方案:CashNow 实施了 Spring Batch 来帮助他们管理和优化批量处理。这个框架使他们能够实现作业处理、处理块和错误处理。

结果:CashNow 注意到批量处理时间减少了 92%,同时错误率也显著下降。这一变化帮助他们简化了日常流程,包括日终核对和报告。

性能分析

实施优化框架只是解决方案的一部分。一旦实施,我们希望确保它们带来改进。我们还希望确保这些更改按我们的意图工作。这通常是通过观察随时间变化的指标来实现的。

常见的性能分析方法包括性能分析工具(见第十四章性能分析工具)和监控工具。例如,JProfilerVisualVM。使用强大的性能分析工具和监控工具可以帮助我们识别潜在的瓶颈,例如内存泄漏和缓慢的数据库查询。

本节中展示的案例研究和性能分析强调了在我们的 Java 应用程序中实施优化框架的重要性,以帮助提高性能、可扩展性和可维护性。

摘要

本章探讨了各种优化框架和技术,以帮助提高我们的 Java 应用程序的性能,支持可扩展性,使我们的代码更具可维护性,并提供高效的开发周期。我们涵盖了异步输入/输出、缓冲输入/输出、批量操作、微服务框架和云原生应用程序框架。我们以两个现实案例研究和性能分析概述结束本章。

希望本章提供的全面概述能帮助您进一步优化 Java 应用程序。本章涵盖的框架和技术可以帮助您提高应用程序的性能,增加可扩展性、一致性、可靠性和可维护性。

在下一章(第十三章面向性能的库)中,我们将探讨几个开源 Java 库,这些库旨在提供高性能。这些完全优化的库可以为我们带来优势。涵盖的知名库包括Java Microbenchmark HarnessJMH),它是 OpenJDK 项目的一部分;Netty,用于处理网络协议,可以用来减少延迟;以及 FasterXML Jackson,这是一个数据处理工具套件。

第十三章:性能重点库

现代 Java 应用程序的性能是一个至关重要的关注点,它可以显著影响应用程序和组织的成功。性能可以包括执行速度、网络响应性和数据处理优化。无论你试图提高哪种类型的性能,选择和正确实施最理想的工具和库是实现性能改进目标的关键。

本章突出了可以显著提高 Java 应用程序性能的一组特定工具。首先介绍的工具是Java 微基准工具JMH),它帮助我们创建可靠的基准。我们的 JMH 覆盖范围将包括基本知识和实践应用。Netty,一个专注于高性能的网络应用程序框架,也将被介绍。该框架的最大价值在于需要快速响应时间或可扩展网络架构的应用程序。

我们对以性能为重点的库的覆盖范围包括对 FasterXML Jackson 的考察,这是一个高速的JavaScript 对象表示法JSON)处理器,正如你将有机会学习的,它促进了数据处理效率。FasterXML Jackson,也简称为 Jackson,具有流式和数据绑定 API,可以在处理 JSON 数据时显著提高性能。本章最后将介绍其他值得注意的库,包括 Eclipse Collections、Agrona 和 Guava。

在本章结束时,你应该对以性能为重点的库有基础的了解,并能够利用从实践练习中获得的知识来提高你的 Java 应用程序的性能。

本章涵盖了以下主要内容:

  • Java Microbenchmark Harness

  • Netty

  • FasterXML Jackson

  • 其他值得注意的库

技术要求

要遵循本章中的示例和说明,你需要具备加载、编辑和运行 Java 代码的能力。如果你还没有设置你的开发环境,请参考第一章探索 Java 虚拟机(JVM)

本章的完整代码可以在以下链接找到:github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter13

Java Microbenchmark Harness

基准测试对于衡量性能的能力至关重要。JMH 是一个用于实施严格基准的工具包。本节将探讨 JMH 及其关键特性,并提供实现示例。

Java Microbenchmark Harness (JMH)

JMH 用于构建和实施基准来分析 Java 代码的性能。它是由创建Java 虚拟机JVM)的团队用 Java 编写的。

使用 JMH 的开发者可以以可重复和受控的条件来衡量 Java 代码片段的性能。

关键特性

JMH 是一个开源工具包,用于在纳米、微和宏观层面构建和实现基准测试。JMH 不仅仅是一个性能测试器;它旨在克服或避免常见的性能测量陷阱,包括预热时间和即时编译(JIT)的影响。

JMH 工具包的关键特性包括以下内容:

  • 注解:正如您将在下一节中看到的那样,JMH 使用 Java 注解轻松定义基准测试。这个特性对开发者友好。

  • JVM 集成:JMH 与 JVM 间隔同步工作。这为我们提供了一致和可靠的结果。

  • 微基准测试支持:正如其名所示,JMH 专注于小的代码片段。这有助于提高性能测量的准确性。

现在您对 JMH 工具包有了基本的了解,让我们看看如何编写基准测试。

将 JMH 库添加到您的 IDE 中

根据您的集成开发环境IDE)设置,您可能需要手动将 JMH 库添加到 Java 项目中。以下步骤可用于 Visual Studio Code 将 JMH 库添加到 Java 项目中:

  1. 创建一个Maven quickstart项目。

  2. 通过添加以下依赖项编辑pom.xml文件:

    <dependency>
        <groupId>org.openjdk.jmh</groupId>
        <artifactId>jmh-core</artifactId>
        <version>1.32</version>
        <scope>test</scope>
    </dependency>
    <dependency>
        <groupId>org.openjdk.jmh</groupId>
        <artifactId>jmh-generator-annprocess</artifactId>
        <version>1.32</version>
        <scope>test</scope>
    </dependency>
    
  3. 使用命令面板通过Maven:重新加载项目保存并重新加载项目。

您可能会选择使用更健壮的 IDE,例如 IntelliJ IDEA。以下是向该 IDE 中的项目添加这些库的步骤:

  1. 在 IntelliJ IDEA 中创建一个项目。

  2. 选择文件 | 项目结构菜单选项。

  3. 项目结构界面中,选择项目设置 |

  4. 点击+按钮添加新的库。

  5. 选择从 Maven选项。

  6. 使用搜索功能查找org.openjdk.jmh:jmh-core的最新版本,然后点击OK将库添加到您的项目中。

  7. 使用搜索功能查找org.openjdk.jmh:jmh-generator-annprocess的最新版本,然后点击OK将库添加到您的项目中。

  8. 点击应用以应用更改,然后点击OK关闭项目结构对话框窗口。

  9. 最后,确保库已自动添加到您的模块中。如果不是这种情况,请选择文件 | 项目结构 | 模块菜单选项。如果新的 JMH 库未列在依赖项区域中,请使用+按钮添加它们。

如果您不使用 Visual Studio Code 或 IntelliJ IDEA,请按照适合您 IDE 的步骤进行操作。

编写基准测试

要在 Java 代码中使用 JMH,我们只需添加@Benchmark注解,并使用 JMH 的核心 API 配置和执行基准测试。让我们看看代码中的示例。我们的示例测试了两种方法,它们以不同的方法进行字符串反转:

package org.example;
import org.openjdk.jmh.annotations.Benchmark;
import org.openjdk.jmh.annotations.Setup;
import org.openjdk.jmh.annotations.State;
import org.openjdk.jmh.annotations.Scope;
import org.openjdk.jmh.runner.Runner;
import org.openjdk.jmh.runner.options.Options;
import org.openjdk.jmh.runner.options.OptionsBuilder;
@State(Scope.Thread)
public class Ch13StringBenchmark {
    private String sampleString;
    @Setup
    public void prepare() {
        sampleString = "The quick brown fox jumps over the lazy dog";
    }
    @Benchmark
    public String reverseWithStringBuilder() {
        return new StringBuilder(sampleString).reverse().toString();
    }
    @Benchmark
    public String reverseManually() {
        char[] strArray = sampleString.toCharArray();
        int left = 0;
        int right = strArray.length - 1;
        while (left < right) {
            char temp = strArray[left];
            strArray[left] = strArray[right];
            strArray[right] = temp;
            left++;
            right--;
        }
        return new String(strArray);
    }
    public static void main(String[] args) throws Exception {
        Options opt = new OptionsBuilder()
                .include(Ch13StringBenchmark.class.getSimpleName())
                .forks(1)
                .build();
        new Runner(opt).run();
    }
}

接下来,让我们运行我们的代码并查看结果。

运行基准测试

一旦我们定义了基准,我们只需通过 Java 应用程序中的main()方法或通过命令行运行它们。如果我们的应用程序不包含main()方法,那么我们将运行包含基准的类。在我们的上一个代码示例中,那将是Ch13StringBenchmark()类。JMH 提供了带有时间测量和吞吐量率的详细输出。分析这些数据可以为基准代码提供有关性能的显著见解。

即使我们的简单测试也产生了大量的输出。以下图提供了输出的最后一段。完整的输出可以在本章 GitHub 仓库的Ch13StringBenchmarkOutput.txt文件中找到。

图 13.1 – 最终基准输出

图 13.1 – 最终基准输出

参考前面的输出,让我们看看如何分析这些信息以提供性能洞察。

分析结果

如您在上一节中看到的,JMH 提供了大量的输出。查看图 13.1中展示的最后三行输出,有几个列是我们应该理解的:

  • Mode:这是基准模式。我们的模式是thrpt,用于吞吐量。它也可以是avgt,用于平均时间。

  • Cnt:这是基准迭代的计数。在我们的案例中,每个基准都是5

  • Score:这是基准分数,显示在我们的案例中是平均吞吐量时间(以微秒为单位)。

  • Error:此列包含分数的误差范围。

根据输出,我们可以看到第一个基准比第二个基准快。查看这类结果可以帮助开发者决定如何实现某些功能,以在代码中实现高性能。

用例

有几个常见的 JMH 用例:

  • 算法优化

  • 比较分析

  • 性能回归测试

JMH 赋予 Java 开发者创建和实施基准的能力。分析结果可以帮助开发者根据经验数据进行分析,从而做出明智的决策。这可以导致更高效的 Java 应用程序。

Netty

Netty,因其网络特性而巧妙地命名,是一个高性能、事件驱动的应用程序框架。这个框架通过简化网络功能编程(如使用用户数据报协议(UDP)和传输控制协议(TCP)套接字服务器)来帮助开发者创建网络应用程序。

网络编程通常涉及低级 API,Netty 提供了一定程度的抽象,使其更容易开发。Netty 架构可扩展,支持许多连接,并设计用于最小化延迟和资源开销。

核心功能

由于其可靠性、可扩展性和易用性,Netty 是许多网络开发者的首选框架。Netty 的核心功能包括以下内容:

  • 内置编解码器支持:Netty 具有内置的编解码器,可以帮助开发人员处理包括 HTTP 和 WebSocket 在内的各种协议。Netty 否定了为支持的协议进行单独实现的需要。

  • 可定制管道:Netty 框架包括一个管道架构,该架构有助于数据封装和处理程序。它采用模块化方法,使管道配置对开发人员来说变得简单易行。

  • 事件驱动:Netty 的事件驱动设计导致异步输入/输出处理。这种非阻塞方法最小化了网络延迟。

在理解 Netty 核心功能的基础上,让我们回顾性能考虑因素。

性能考虑因素

在整本书中,我们的重点是高性能 Java 应用程序。Netty 是我们高性能工具套件的一个很好的补充。它通过其线程模型灵活性零拷贝能力强调高性能。让我们看看那些性能考虑因素:

  • 线程模型灵活性:Netty 的线程管理高度可配置。开发人员可以根据特定的用例(如扩展或缩减规模以及减少线程数量)配置 Netty 来管理其应用程序的线程。

  • 零拷贝能力:Netty 的零拷贝 API 有助于使数据处理(输入和输出)更高效。这是通过最小化不必要的内存复制来实现的。

让我们看看使用 Netty 创建一个回显服务器的示例,该服务器简单地将其接收到的数据回显给客户端。

实现

以下示例演示了 Netty 处理网络事件相对容易,以及 Netty 如何促进网络通信中的高性能。请注意,你将在pom.xml文件中记录你的依赖项:

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.codec.string.StringDecoder;
import io.netty.handler.codec.string.StringEncoder;
public class Ch13EchoServer {
  public static void main(String[] args) throws InterruptedException {
    EventLoopGroup bossGroup = new NioEventLoopGroup(1);
    EventLoopGroup workerGroup = new NioEventLoopGroup();
    try {
      ServerBootstrap b = new ServerBootstrap();
      b.group(bossGroup, workerGroup)
       .channel(NioServerSocketChannel.class)
       .childHandler(new ChannelInitializer<SocketChannel>() {
        @Override
        protected void initChannel(SocketChannel ch) throws Exception {
          ChannelPipeline p = ch.pipeline();
          p.addLast(new StringDecoder(), new StringEncoder(), new 
          Ch13EchoServerHandler());
        }
      });
      ChannelFuture f = b.bind(8080).sync();
      f.channel().closeFuture().sync();
    } finally {
        workerGroup.shutdownGracefully();
        bossGroup.shutdownGracefully();
    }
  }
  static class Ch13EchoServerHandler extends 
  ChannelInboundHandlerAdapter {
    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
      ctx.write(msg);
      ctx.flush();
    }
  }
}

下面是 Netty 回显服务器的模拟输出:

Server started on port 8080.
Client connected from IP: 192.168.1.5
Received from client: Hello Server!
Echoed back: Hello Server!
Received from client: This is a test message.
Echoed back: This is a test message.
Received from client: Netty is awesome!
Echoed back: Netty is awesome!
Client disconnected: IP 192.168.1.5
Server shutting down...

Netty 是一个成熟且健壮的网络应用程序开发框架。它本身具有可伸缩性,并提供了网络功能方面的性能优势。Netty 还引入了开发效率和更短的开发时间。

FasterXML Jackson

JSON 是一种用于数据交换的文件格式。它是有结构和可读的文本,用于传输数据对象。该格式由数组和属性值对组成。以下代码块中提供的示例 JSON 对象代表一个社交媒体系统的用户配置文件。正如你所看到的,字段包含用户的姓名、年龄、电子邮件地址和爱好:

{
    "name": "Neo Anderson",
    "age": 24,
    "email": "neo.anderson@matrix.com",
    "hobbies": ["coding", "hacking", "sleeping"]
}

这个 JSON 对象由属性值或键值对以及用户爱好的字符串数组组成。JSON 是数据存储和 Web 应用程序中数据表示的常用方法。

FasterXML Jackson是一个具有快速处理和创建 JSON 对象主要能力的库。这些对象是顺序读取的,从现在起称为Jackson,它使用游标来跟踪其位置。Jackson 被誉为性能最大化者和内存最小化者。

FasterXML Jackson中的XML表明它也能处理 XML 文件。除了 Jackson 快速处理 JSON 的能力外,它还可以处理逗号分隔值CSV)、可扩展标记语言XML)、YAML Ain’t Markup LanguageYAML)和其他文件格式。

核心特性

Jackson 的核心特性包括以下内容:

  • 数据绑定:数据绑定是 Jackson 的一个特性,它支持高效且可靠的 Java 对象与 JSON 文本之间的转换。实现方式简单直接。

数据绑定

计算机编程中的一种技术,它将数据源(提供者)和接收者(客户端)链接(绑定)在一起。

  • 流式 API:Jackson 有一个高效、低级的流式 API 用于解析和生成 JSON。

  • 树模型:当需要灵活的 JSON 操作时,Jackson 的树模型可以实现,以树结构表示 JSON 文档——节点树。这通常用于 JSON 结构复杂的情况下。

现在你已经了解了 Jackson 的核心特性,让我们回顾一下性能考虑因素。

性能考虑

这里详细说明的性能考虑因素展示了 Jackson 是如何被设计为一个以性能为重点的库:

  • 自定义序列化和反序列化:Jackson 使开发者能够为自定义字段定义自己的序列化和反序列化器。这可以带来显著的性能提升。

  • 零拷贝:与 Netty 一样,Jackson 的零拷贝 API 有助于使数据处理(输入和输出)高效。这是通过最小化不必要的内存复制来实现的。

让我们看看使用 Jackson 序列化和反序列化 Java 对象的示例。

实现

让我们先在我们的pom.xml文件中添加依赖项,以将 Jackson 包含到我们的项目中。这可能看起来是这样的:

<dependencies>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-core</artifactId>
        <version>2.12.3</version>
    </dependency>
    <dependency>
        <groupId>com.fasterxml.jackson.core</groupId>
        <artifactId>jackson-databind</artifactId>
        <version>2.12.3</version>
    </dependency>
</dependencies>

以下示例说明了 Jackson 在开发对象序列化和反序列化方面的直接性:

import com.fasterxml.jackson.databind.ObjectMapper;
  public class Ch13JacksonExample {
    public static void main(String[] args) throws Exception {
      ObjectMapper mapper = new ObjectMapper();
      // Example of a Plan Old Java Object (POJO)
      class User {
        public String name;
        public int age;
        // Constructors, getters, and setters have been omitted for 
        // brevity
      }
    // Serialize Java object to JSON
    User user = new User();
    user.name = "Neo Anderson";
    user.age = 24;
    String jsonOutput = mapper.writeValueAsString(user);
    System.out.println("Serialized JSON: " + jsonOutput);
    // Deserialize JSON to Java object
    String jsonInput = "{\"name\":\"Neo Anderson\",\"age\":24}";
    User userDeserialized = mapper.readValue(jsonInput, User.class);
    System.out.println("Deserialized user: " + userDeserialized.name);
  }
}

在本节中,我们了解到 Jackson 是处理 JSON 的关键工具。它速度快、灵活且健壮。当需要在 Java 应用程序中处理 JSON 时,Jackson 可能是你首先想到的工具。

其他值得注意的库

到目前为止,我们已经介绍了 JMH、Netty 和 Jackson,并提出了它们是专注于 Java 高性能的核心库。每个库都是为特定类型的任务设计的。还有其他值得学习的库。本节探讨了三个额外的库:AgronaEclipse CollectionsGuava

Agrona

Agrona 是一个专门设计用于创建高性能 Java 应用程序的数据结构集合。这些数据结构包括映射和环形缓冲区。一个示例用例是一个股票交易应用程序,其成功取决于低延迟。

Agrona 的关键特性包括以下内容:

  • 非阻塞数据结构——这支持高吞吐量和低延迟

  • 专门为高频股票和证券交易系统设计

  • 使用直接缓冲区,有助于提高堆外内存管理的效率

让我们看看 Agrona 的实现示例,它说明了如何使用特定的性能数据结构。在我们的示例中,我们将使用 ManyToOneConcurrentArrayQueue 数据结构:

import org.agrona.concurrent.ManyToOneConcurrentArrayQueue;
public class Ch13AgronaExample {
  public static void main(String[] args) {
    // Create a queue with a capacity of 10 items
    ManyToOneConcurrentArrayQueue<String> queue = new 
    ManyToOneConcurrentArrayQueue<>(10);
    // Producer thread that offers elements to the queue
    Thread producer = new Thread(() -> {
      for (int i = 1; i <= 5; i++) {
        String element = "Element " + i;
        while (!queue.offer(element)) {
          // Retry until the element is successfully added
          System.out.println("Queue full, retrying to add: " + 
          element);
          try {
            Thread.sleep(10); // Sleep to simulate backoff
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
          }
        }
        System.out.println("Produced: " + element);
      }
    });
    // Consumer thread that polls elements from the queue
    Thread consumer = new Thread(() -> {
      for (int i = 1; i <= 5; i++) {
        String element;
        while ((element = queue.poll()) == null) {
          // Wait until an element is available
          System.out.println("Queue empty, waiting for elements...");
          try {
            Thread.sleep(10); // Sleep to simulate processing delay
          } catch (InterruptedException e) {
              Thread.currentThread().interrupt();
          }
        }
        System.out.println("Consumed: " + element);
      }
    });
    // Start both threads
    producer.start();
    consumer.start();
    // Wait for both threads to finish execution
    try {
      producer.join();
      consumer.join();
    } catch (InterruptedException e) {
      Thread.currentThread().interrupt();
    }
  }
}

如您在前面代码中看到的,并从代码注释中推断,我们使用容量值 10 初始化 ManyToOneConcurrentArrayQueue。这种类型的队列非常适合只有一个 客户 和多个 生产者 的用例。我们的示例包括消费者和生产者线程。代码实现了基本的线程处理。

Eclipse Collections

Eclipse Collections 是一组内存高效的算法和数据结构。这些集合可以用来显著提高性能。为大规模系统设计,Eclipse Collections 提供了可变和不可变两种形式。它们提供了高效的内存管理。

Eclipse Collections 的关键特性包括以下内容:

  • 一个全面的数据结构集合,包括包、列表、映射、集合、栈等

  • 原始集合及其相关类

  • 可以转换集合并用于过滤、迭代和排序的实用方法

让我们演示如何使用 Eclipse Collections 中的 ImmutableList。这是更内存高效的集合之一:

import org.eclipse.collections.api.factory.Lists;
import org.eclipse.collections.api.list.ImmutableList;
public class Ch13EclipseCollectionsExample {
    public static void main(String[] args) {
        // Creating an immutable list using Eclipse Collections
        ImmutableList<String> immutableList = Lists.immutable.
        of("Apple", "Pear", "Cherry", "Lime");
        // Displaying the original list
        System.out.println("Original immutable list: " + 
        immutableList);
        // Adding an item to the list, which returns a new immutable 
        // list
        ImmutableList<String> extendedList = immutableList.
        newWith("Orange");
        // Displaying the new list
        System.out.println("Extended immutable list: " + 
        extendedList);
        // Iterating over the list to print each element
        extendedList.forEach(System.out::println);
    }
}

我们的示例从创建一个不可变的水果列表开始。接下来,我们在列表中添加一个元素,然后遍历列表进行输出。

要在我们的应用程序中使用 Eclipse Collections,我们需要将库包含到我们的项目中。以 Maven 为例,我们只需简单地将以下内容添加到我们的 pom.xml 文件中:

<dependency>
    <groupId>org.eclipse.collections</groupId>
    <artifactId>eclipse-collections-api</artifactId>
    <version>11.0.0</version>
</dependency>
<dependency>
    <groupId>org.eclipse.collections</groupId>
    <artifactId>eclipse-collections</artifactId>
    <version>11.0.0</version>
</dependency>

我们提供的代码片段为使用 Eclipse Collections 提供了基本介绍。

Guava

Guava 是来自 Google 的一个产品,包括新的集合类型,如 multimap 和 multiset。它还包括不可变集合、图形库、对原生的支持以及缓存实用工具。以下是 Guava 的关键特性列表:

  • 高级集合类型。

  • 高级集合实用工具。

  • 使用 CacheBuilder 的缓存支持。这可以用来提高应用程序的速度。

  • 并发实用工具。

  • 哈希实用工具。

  • 输入/输出操作实用工具。

下面是一个示例应用程序,展示了 Guava 的 CacheBuilder 的使用。该应用程序创建了一个自动加载和存储基于值的键的缓存:

import com.google.common.cache.CacheBuilder;
import com.google.common.cache.CacheLoader;
import com.google.common.cache.LoadingCache;
import java.util.concurrent.TimeUnit;
public class Ch13GuavaExample {
  public static void main(String[] args) throws Exception {
    LoadingCache<String, String> cache = CacheBuilder.newBuilder()
      .maximumSize(100)
      .expireAfterWrite(10, TimeUnit.MINUTES)
      .build(
        new CacheLoader<String, String>() {
          public String load(String key) {
            return "Value for " + key;
          }
        }
      );
    System.out.println(cache.get("key1"));
    System.out.println(cache.get("key2"));
    System.out.println(cache.get("key3"));
  }
}

Java 有几个库和框架可以用来提高整体性能。了解我们可用的库以及如何实现它们对于应用程序的成功至关重要。

摘要

本章探讨了几个关键的高性能库,我们可以利用这些库来提升我们的 Java 应用程序的性能。具体来说,我们回顾了 JMH,并指出它提供了可靠的性能基准测试。我们还研究了 Netty,并确定了它在提升网络应用程序性能方面的适用性。FastXML Jackson 也被审查,因为它在处理 JSON 对象方面的专用用途。最后,我们还介绍了三个额外的库:Agrona、Eclipse Collections 和 Guava。

本章中介绍的所有库都是针对特定的 Java 编程需求定制的。这些工具有望帮助我们显著提升 Java 应用程序的性能。在自己的 Java 项目中尝试这些库可以帮助巩固你对它们的理解,以及每个库的最佳使用案例。此外,适当地实现这些库可以导致 Java 应用程序的整体性能提升。

第十四章:性能分析工具

随着我们的 Java 应用程序复杂性的增加,深入了解它们如何使用系统资源,如 CPU 和内存的需求变得越来越重要,并且是确保我们的应用程序高效运行的关键方面。这正是性能分析工具发挥作用的地方;它们可以帮助我们识别瓶颈和内存泄漏等问题,以便我们改进应用程序,提升用户体验和整体性能。

本章深入探讨性能分析和性能分析工具。我们将从性能分析及其对我们优化应用程序以实现最佳性能能力的重要性进行介绍。还将涵盖性能分析工具的分类及其用途,以帮助您获得基本理解,并最终对特定的性能分析工具进行回顾。

我们将涵盖与 Java 开发工具包(JDK)捆绑的性能分析工具以及嵌入在集成开发环境(IDE)中的性能分析工具,例如 IntelliJ IDEA、Eclipse 和 NetBeans。此外,还将审查第三方性能分析工具,包括 YourKit Java Profiler、JProfiler 和 VisualVM。目的是为您提供对各种性能分析工具的深入了解,包括它们的优缺点和实际用例,以便您确定哪种工具最适合您的需求并有效地使用它们。

我们对性能分析工具的覆盖包括比较分析,以帮助您评估性能开销、工具准确性、易用性、集成问题和成本。我们将以对未来的 Java 性能分析趋势的探讨结束本章,包括性能分析工具的进步、新兴标准和人工智能(AI)与机器学习(ML)的集成,以进一步优化性能。

到本章结束时,您应该对性能分析工具有一个基础的了解,并能够利用从实际练习中获得的知识来提高您的 Java 应用程序的性能。

本章涵盖了以下主要主题:

  • 性能分析工具简介

  • JDK 内置的性能分析器

  • IDE 内置的性能分析器

  • 其他性能分析器

  • 性能分析工具比较分析

  • 实践性能分析策略

  • 案例研究

  • Java 性能分析的未来趋势

性能分析工具简介

性能分析工具在性能调整和主动应用程序增强中发挥着关键作用,以支持高性能 Java 应用程序。

性能分析

性能分析是分析软件运行时的过程,其目标是识别性能问题,包括瓶颈、资源使用和优化机会。

一旦我们构建和测试了我们的软件,它就会进入生产阶段。这是性能分析发生的地方,即在运行时。使用性能分析工具,我们可以深入了解我们应用程序的运行时行为。目标是拥有高效的代码和我们的系统以最佳性能运行,包括低延迟、高可靠性和高可用性。我们的应用程序不仅要准确执行,还需要高效执行。使用性能分析工具使我们能够定位性能问题,以便我们进一步优化我们的代码。

性能调优中性能分析的重要性

让我们看看五个具体的原因,这些原因说明了性能分析对支持性能调优的重要性:

  • 应用程序响应性:性能调优的核心是确保我们的应用程序具有响应性。用户不应遭受高延迟。性能分析工具帮助我们分析我们的资源是如何使用的,揭示了改进的机会。

  • 瓶颈:使用性能分析工具可以帮助我们识别性能瓶颈甚至潜在的瓶颈。这使我们能够采取主动的性能调优方法,并帮助我们避免未来的灾难性瓶颈。

  • 持续改进:软件系统不是开发、部署后就不再关注了。我们维护我们的系统,并努力持续改进它们。今天表现良好的系统可能在新的操作系统发布或其它环境因素改变时表现不佳。这需要一种持续改进的心态。持续地对我们的应用程序进行性能分析并解决优化机会可以帮助确保我们的系统保持响应性和高效性。

  • 资源利用率:应密切监控硬件资源(如 CPU 和内存)的使用,因为对这些资源的低效使用可能导致系统延迟和次优的输入/输出操作。性能分析工具可以帮助我们识别可以优化的区域。

  • 可扩展性:我们的应用程序越大,即使是微小的性能问题对整体系统性能的影响也越显著。使用性能分析工具可以帮助我们识别提升性能和解决任何可能随着应用程序扩展而变得更加明显的相关问题的机会,这可能是由于需求的增加。

性能分析工具的类型及其用途

存在着七种广泛的性能分析工具类别,每个类别都有自己的关注领域或目的。现在让我们来回顾一下:

  • CPU 性能分析器:这一类性能分析器分析应用程序的 CPU 使用情况。它们可以识别我们应用程序中最占用 CPU 资源的方法。

  • IDE 内嵌性能分析器:大多数主要的 IDE(例如,NetBeans、Eclipse 和 IntelliJ IDEA)都有内置的性能分析工具。因为它们是 IDE 的一部分,所以它们可以无缝地与开发环境集成。这使得它们特别易于使用。

  • 输入/输出分析器:这些分析器专门关注我们的应用程序的输入/输出操作。如果我们的输入/输出过程缓慢或效率低下,这些分析器可以将它们暴露出来,以便我们可以优化文件处理、数据库交互和网络通信等操作。

  • 内存分析器:内存分析器为我们提供了内存分配和内存使用的窗口。这可以帮助我们识别实际或潜在的内存泄漏,并识别内存消耗优化的机会。我们可以使用这类分析器来深入了解垃圾收集、内存保留,甚至对象生命周期。

  • 网络分析器:正如其名所示,这类分析器专门分析应用程序的网络通信。这些分析器可以帮助我们识别延迟问题,并了解带宽的使用情况。网络分析器还可以帮助我们识别次优或不高效的网络协议。

  • 专用分析器:这是一类为特定性能情况提供定制功能的分析器。它们也可以用于特定环境(例如,分布式系统或实时系统)或特定场景。

  • 线程分析器:线程分析器监控线程活动,在多线程环境中特别有用。这类分析器帮助我们检测线程竞争问题和潜在的死锁,并识别优化线程管理的机遇。

理解分析工具及其类别和使用方法非常重要。这些知识可以帮助您选择最适合您性能目标和系统要求的最合适的工具。

JDK 中的分析器捆绑

JDK 包含了可以为我们提供关于 Java 应用程序性能宝贵见解的分析工具。强烈建议使用这些工具来识别性能问题和性能微调的机会。本节探讨了 JDK 内置的主要分析器——JVisualVM 和 Java 使命控制JMC)。

JVisualVM

JVisualVM,即 Java 可视化虚拟机,是 JDK 内置的一个强大的分析工具。它提供了一套令人印象深刻的特性,包括监控、分析和故障排除 Java 应用程序。

概述和功能

JVisualVM 提供了一个图形用户界面,它结合了多个 JDK 工具,包括 JConsole。界面如图所示。

图 14.1:JVisualVM 图形用户界面

图 14.1:JVisualVM 图形用户界面

JVisualVM 的关键特性总结如下:

  • 堆转储分析:JVisualVM 能够捕获堆转储并为我们分析它们。这可以帮助我们深入了解内存使用情况,并识别实际或潜在的内存泄漏。

  • 插件集成:此工具的功能可以通过众多可用的插件进行扩展。插件库可通过工具菜单访问,如下图所示。

图 14.2:JVisualVM 插件库

图 14.2:JVisualVM 插件库

  • 监控:JVisualVM 可以实时监控我们应用程序的内存消耗、垃圾回收、线程活动和 CPU 使用。

  • 配置文件分析:此工具提供了对 CPU 和内存使用进行详细配置文件分析的能力。这有助于识别内存泄漏和瓶颈。

  • 线程分析:JVisualVM 可以用来监控和分析我们应用程序的线程状态和线程活动。

用例和示例

我们可以使用 JVisualVM 进行多种用例,它在开发、测试和生产阶段特别有用。此工具为我们提供了详细的 CPU 和内存使用洞察。我们可以使用此工具查看哪些方法消耗了最多的 CPU 时间。我们还可以查看内存消耗、对象分配和垃圾回收,以帮助我们优化应用程序的内存使用。

例如,考虑一种情况,我们有一个用户报告偶尔出现延迟的 Web 应用程序。我们可以使用 JVisualVM 来监控我们应用程序的 CPU 和内存使用。这有助于我们识别峰值。从那里,我们可以分析线程转储以确定问题的来源。在这种情况下,问题可能只是一个导致偶尔延迟的单个 Java 方法或线程。使用配置文件分析工具可以帮助我们快速找到问题的核心,从而相应地改进我们的代码。

JMC

JMC 是另一个强大的 JDK 工具,我们可以用它来配置文件和监控我们的 Java 应用程序,尤其是在生产环境中。如图下截图所示,JMC 包括JMX 控制台飞行记录器

图 14.3:JMC 欢迎屏幕

图 14.3:JMC 欢迎屏幕

概述和功能

Java 管理扩展JMX)控制台用于监控和管理我们的 Java 应用程序。飞行记录器用于持续监控和配置文件分析。它收集详细性能数据,对应用程序性能的影响有限。记录的事件包括方法调用、内存分配、线程活动和输入/输出操作。Java 飞行记录器JFR)是 JMC 的核心组件,可以用来记录我们的运行应用程序,然后分析结果,给我们提供关于 CPU 使用、特定方法执行时间、内存分配数据等方面的洞察。

利用 JMC 和 JVisualVM 的功能可以深入了解我们应用程序的性能,使我们能够优化资源使用并提高应用程序的响应速度。在下一节中,我们将回顾 IDE 内嵌的配置文件分析工具。

IDE 内嵌的配置文件分析器

Java 开发者使用他们最喜欢的 IDE 中的内置分析工具。使用这些分析器为我们提供了一个方便的方法,可以直接在我们的首选开发环境中分析我们的软件。本节探讨了 IntelliJ IDEA、Eclipse 和 NetBeans IDE 的内置功能。

IntelliJ IDEA 分析器

IntelliJ IDEA 分析器允许我们在 IDE 内部对 Java 应用程序进行分析。这个强大的功能仅适用于 IDE 的商业版(IntelliJ IDEA Ultimate),因此如果您使用的是社区版CE),则无法使用 IntelliJ IDEA 分析器。集成和设置都很简单;以下是步骤:

  1. 打开 IntelliJ IDEA。

  2. 编写或加载您的 Java 项目。

  3. 使用运行 | 分析菜单选项来启动分析。

  4. 选择您想要的分析类型(CPU 或内存)。

使用 CPU 分析,该工具可以识别代码中消耗最多 CPU 时间的函数。还提供了调用树,显示了代码路径和整体资源使用情况(CPU 和内存)。IntelliJ IDEA 分析器的内存分析功能包括跟踪对象分配、分析垃圾收集效率以及检测内存泄漏的能力。

Eclipse 的测试和性能工具平台

Eclipse IDE 对于 Java 开发者来说很受欢迎,它之前有一个内置的分析工具,称为测试和性能工具平台TPTP)。它作为插件提供,可在 Eclipse 市场获得。关键特性如下:

  • CPU 性能分析

  • 内存分析

  • 线程分析

  • 输入/输出分析

  • 网络活动

在此处提及 TPTP 是为了让使用 Eclipse 的开发者不会对 Eclipse 内置的分析工具感到困惑。TPTP 已被存档,可能是因为 JDK 内置分析工具功能的不断增强。

NetBeans 分析器

NetBeans 分析器集成到 NetBeans IDE 中,并有一个顶级的分析菜单项,便于配置和访问。该工具可以在遥测、方法、对象、线程、锁和 SQL 查询等类别中进行性能分析。

以下截图显示了在遥测分析被选中时,工具仪表板的四个主要组件。

图 14.4:NetBeans 遥测分析器窗口

图 14.4:NetBeans 遥测分析器窗口

下图所示的对象分析器提供了实时分析,以便您可以在运行时审查应用程序的性能。

图 14.5:NetBeans 对象分析器窗口

图 14.5:NetBeans 对象分析器窗口

了解与您的 IDE 集成的分析工具可以帮助您更有效地利用时间,因为这些集成工具与第三方工具相比,配置和使用起来最为简便,在某些情况下,甚至比使用 JDK 捆绑的分析工具还要容易。

其他分析器

到目前为止,我们已经查看了一些内置在 JDK 和 IDE 中的分析工具。还有一个第三类分析器,它们是 JDK 和 IDE 之外的外部分析器。本节回顾了该类别中最普遍的三个分析器。这些是专门工具,可以根据我们对运行时应用程序的复杂分析提供更深入的见解。

YourKit Java Profiler

YourKit Javak Profiler 是一个具有开源许可的强大分析工具。高级功能和支持需要购买许可证。此工具的关键功能和能力如下:

  • 持续集成/持续交付CI/CD)集成

  • CPU 分析

  • 数据库分析

  • 输入/输出分析

  • 内存分析

  • 远程服务器分析

  • 线程分析

JProfiler

JProfiler 分析工具是一个商业应用程序,据说是一个直观的、适用于 Java 应用程序的全能分析器。它具有友好、易于使用的界面和以下强大功能:

  • CPU 分析

  • 堆和对象图分析

  • IDE 集成

  • 内存分析

  • 移除服务器分析

  • SQL 分析

  • 线程分析

VisualVM 插件和扩展

我们之前介绍了 VisualVM 作为 JDK 捆绑的分析工具。本节将其包括在内,因为有许多第三方插件和扩展可以用来增强 VisualVM。

以下列表展示了可用的部分插件:

  • 缓冲区监控器:可用于监控直接缓冲区使用。

  • 堆分析器:此插件提供有限内存和堆分析。

  • 终止应用程序:此插件便于终止无响应的监控进程。

  • 采样插件:提供详细的 CPU 和内存采样选项。

  • 启动分析器:此插件便于从启动时使用仪器分析。这对于运行时间较短的应用程序很有用。

  • 线程检查器:提供高级线程分析。

  • 追踪器:这是一个使用探针进行详细监控的框架。

  • VisualGC:为垃圾收集器提供深入分析和可视化。

除了 VisualVM 分析器外,还有额外的插件和扩展。此外,如果需要,开发者可以创建自己的自定义插件。

分析工具的比较分析

使用分析工具的一个好处是我们有多个选项可供选择。本节使用以下类别回顾了本章中介绍的六个顶级分析工具:

  • 性能开销

  • 准确性

  • 易用性

  • 集成

  • 成本

通过对每个类别的分析,我们将对每个这些分析工具进行评分,以查看它们之间的对比。

性能开销

比较分析工具时,考虑它们的性能开销很重要。这个因素可以帮助你选择适合你用例的正确工具。以下是六个选定分析工具的性能开销回顾:

  • IntelliJ IDEA Profiler:这款工具的开销适中,适用于开发和测试;然而,它并不适合生产环境,尤其是在高负载应用中。

  • JMC:最小开销是 JMC 最强的特点之一。这在使用 JFR 时尤为明显。JMC 设计用于生产使用,通常对性能的影响可以忽略不计。

  • JProfiler:这款工具的开销适中,但具有高度详细的分析能力,使得开发者需要在详细洞察和增加开销之间做出艰难的决定。使用此工具进行开发和测试环境以及受控的生产实例的剖析是合适的。

  • JVisualVM:该分析工具的性能开销介于低到中等之间,取决于所需的剖析深度。它适用于开发和生产环境。

  • NetBeans Profiler:这款工具的开销适中,仅适用于非生产环境。

  • YourKit Java Profiler:这款工具具有高度的可配置性,有助于管理开销。它适用于生产和非生产环境。

准确度

并非所有分析工具都能达到 100%的准确度,了解您工具的准确度水平非常重要。以下是您特色分析工具准确度的综述:

  • IntelliJ IDEA Profiler:该工具在 CPU 和内存剖析方面具有最高的准确度。当剖析线程时,准确度会下降,并且可能会增加性能开销。

  • JMC:这是一款高精度分析工具,即使在生产环境中也能以最小的性能影响提供精确的结果。

  • JProfiler:这款分析工具提供高度准确的结果以及详细的可视化。

  • JVisualVM:虽然这款分析工具提供了准确采样和数据,但需要更高精度的需求会增加性能开销。

  • NetBeans Profiler:这款工具提供了准确的 CPU 和内存剖析以及实时线程分析。

  • YourKit Java Profiler:这款分析工具在 CPU、内存和线程分析方面提供高度准确的结果。

易用性

易用性是我们在选择分析工具时需要考虑的另一个重要因素。如果一个工具难以使用且需要大量时间,可能不是适合您的工具。以下是关于我们特色工具易用性的综述:

  • IntelliJ IDEA Profiler:这款工具无缝集成到 IntelliJ IDEA IDE 中,使其直观且易于使用。

  • JMC:这是一款高级工具,可能需要集中精力学习。

  • JProfiler:这款工具以其易用性而闻名。它也经过了详尽的文档记录。

  • JVisualVM:这款分析工具对初学者友好,具有简单的用户界面。

  • NetBeans Profiler:这款工具集成到 NetBeans IDE 中,使其易于使用。

  • YourKit Java Profiler:此分析工具具有直观的用户界面,易于使用。

集成

开发者倾向于选择具有深度集成的分析工具。以下是针对我们六个选定的分析工具的该特性的回顾:

  • IntelliJ IDEA Profiler:此工具深度集成到 IntelliJ IDEA IDE 中,并支持自定义工作流程。

  • JMC:JMC 与 JDK 捆绑,使得与基于 JVM 的应用程序的集成无缝。

  • JProfiler:此工具可以轻松集成到最流行的构建工具和 IDE 中。

  • JVisualVM:此工具与 IDE 集成,易于使用。

  • NetBeans Profiler:此工具完全集成到 NetBeans IDE 中。

  • YourKit Java Profiler:此分析工具支持与主要 IDE 和 CI/CD 集成。

成本和许可考虑因素

分析工具的成本可能是一个重要因素。独立开发者倾向于选择免费工具,而大型团队可能会选择支付更多功能强大的工具。以下是我们的六款工具的成本因素:

  • IntelliJ IDEA Profiler:此分析器包含在 IntelliJ IDEA Ultimate 版本中,需要付费订阅。

  • JMC:此工具在开发和测试中免费,但在生产中使用可能需要付费。

  • JProfiler:这是一个需要付费使用的商业工具。

  • JVisualVM:此工具免费,并捆绑在 JDK 中。

  • NetBeans Profiler:这是一个集成到 NetBeans IDE 中的免费工具。

  • YourKit Java Profiler:这是一个需要付费使用的商业工具。

每个工具的最佳用例

很可能没有单个分析工具会每次都满足您的需求。我们的每个工具都有它们最适合的具体用例:

  • IntelliJ IDEA Profiler:此工具最适合希望在开发期间使用集成分析工具的 IntelliJ IDEA 用户。

  • JMC:由于其低开销和提供详细分析的能力(尤其是在使用 JFR 时),此工具非常适合生产环境。

  • JProfiler:此分析工具非常适合希望有一个易于使用、功能强大且具有高级分析能力的开发者。

  • JVisualVM:此工具最适合希望在软件开发和测试阶段使用免费 CPU 和内存分析选项的开发者。

  • NetBeans Profiler:此工具非常适合想要一个可靠的集成分析工具(用于 CPU、内存和线程分析)的 NetBeans IDE 用户。

  • YourKit Java Profiler:此分析工具最适合希望在软件开发生命周期的所有阶段进行深入分析的用户,包括生产阶段。

评分我们的分析工具

以下表格根据性能开销、分析准确性、易用性、集成和成本对六个特色分析工具进行评分。评分范围从 1 到 5,1 为最低分,5 为最高分。

表 14.1:剖析工具评分矩阵

表 14.1:剖析工具评分矩阵

如您所见,没有单个工具在每个类别中都获得了最高分。根据这个评分,JMC 获得了最高的 22 分,其次是 JProfiler 和 YourKit Java 剖析器,各得 21 分。JVisualVM 和 NetBeans 剖析器得 20 分,IntelliJ IDEA 剖析器得分最低,为 19 分。

通过了解每个剖析工具的优势、局限性和权衡,您应该能够选择最适合您需求的工具。

实践剖析策略

剖析我们的 Java 应用程序以帮助识别和解决性能问题的益处已经得到证实。接下来,我们将探讨有效的剖析策略,以便您能够高效地优化 Java 应用程序。本节涵盖了识别性能瓶颈的策略,以及在不同开发和生产环境之间区分剖析方法。我们还将探讨如何实施持续剖析以进行长期性能管理。

识别性能瓶颈

剖析的主要目标是识别性能瓶颈。我们可以采用几种策略来实现这一目标,包括以下内容:

  • 首先,监控 CPU 使用率、内存消耗和响应时间。这些高级指标可以帮助您确定需要进一步分析的区域。

  • 考虑使用能够快速识别消耗最多 CPU 时间的方法的采样剖析器。例如,JVisualVM 和 IntelliJ IDEA 剖析器提供此类功能。目标是使用采样来定位问题,而不会对性能产生重大影响。

  • 一旦确定了需要进一步分析的领域,请使用如 JProfiler 和 YourKit Java 剖析器提供的仪器剖析。这些工具可以帮助您检查特定的代码路径和方法。

  • 一定要分析您应用程序的线程活动。这对于采用并发处理的应用程序至关重要。例如,JMC 和 NetBeans 剖析器等工具具有广泛的线程分析功能。它们可以用来检测线程竞争、死锁以及甚至低效的同步。

  • 使用内存剖析器来分析对象分配,识别 Java 垃圾收集器未回收的对象,并捕获堆转储。例如,JProfiler 和 YourKit Java 剖析器具有这种功能,并有助于识别潜在的内存泄漏。

  • 最后,如果你的应用程序大量使用数据库交互或输入/输出操作,请使用能够提供对 JDBC 调用和输入/输出洞察的剖析器。目标是识别低效的查询和输入/输出瓶颈。

开发与生产中的剖析

我们如何以及剖析什么应该由我们的当前环境来指导。在开发环境中进行剖析的方法应与在生产环境中剖析运行中的应用程序的方法不同。

以下表格提供了基于 10 个关键分析方面的开发环境和生产环境中分析的比较分析。

方面 开发分析 生产分析
主要关注点 识别和解决性能问题 监控和解决运行时性能问题
访问要求 直接访问 远程访问
频率 经常性分析 根据观察进行选择性分析
性能影响 可以容忍高开销 需要最小开销
分析类型 详细仪器 抽样
工具 IntelliJ IDEA 分析器、JProfiler 和 YourKit Java 分析器 JMC 和 JVisualVM
数据粒度
负载模拟 模拟真实负载 实时用户负载
自动化 集成到 CI/CD 持续监控

表 14.2:分析比较分析

了解基于您的应用程序环境的分析差异可以帮助您进行有效的分析,从而允许您提高 Java 应用程序的性能。

持续分析

我们应该在开发过程中进行分析,然后在我们应用程序投入生产后持续进行。实施持续分析包括以下方面:

  • 建立性能基线,以便您可以将其与未来的分析结果进行比较

  • 将分析集成到您的 CI/CD 管道中

  • 确保您正在监控关键性能指标,包括 CPU 使用情况、内存使用情况和响应时间

  • 存储您的轮询数据,以便您可以进行历史分析

  • 除了常规监控外,定期进行全面的分析审计

  • 确保与开发团队沟通分析结果,使他们了解情况,并提高您当前系统的性能,有助于使未来的应用程序性能更佳

这些实用的分析策略可以帮助识别和解决性能瓶颈。建立一种正式的分析方法,可以细致地遵循,这一点非常重要。

案例研究

本节详细介绍了三个案例研究,以说明在现实场景中实际实施分析工具的实用方法。

案例研究 1 – 分析大型企业应用

场景:一家大型全球金融服务公司开发、测试和部署了一个企业应用来处理他们的交易和报告。最近,该应用开始出现性能下降。这在高峰用户交易时间尤为明显。

分析工具:开发团队选择了 JProfiler,因为它能够提供详细的分析,并且它与他们的开发环境集成。

实施过程:该公司采取了三步走的方法进行分析:

  1. 他们首先进行了初步分析,查看 CPU 使用情况、内存使用情况和线程分析。

  2. 接下来,他们使用第 1 步的配置文件数据来识别瓶颈。

  3. 第三步是优化他们的企业应用。这次优化包括重写低效算法、实施更有效的数据结构、优化对象创建和销毁,以及通过实施精细的锁定机制减少线程竞争。

结果:交易时间显著改善,内存消耗现在稳定。此外,改进后的应用现在能够处理峰值负载时间,而不会出现网络延迟问题或影响用户体验。

案例研究 2 – 在微服务架构中进行性能调整

场景:一家科技公司使用微服务架构开发了一个大规模的电子商务应用。有许多微服务,其中主要的服务处理用户身份验证、产品库存、产品目录、支付交易和订单处理。员工和用户报告了间歇性的延迟问题和偶尔的超时。

分析工具:该科技公司选择 JMC 进行分析,因为它具有低开销,并且能够使用 JFR 在生产环境中监控应用程序。

实施过程:公司决定在所有微服务上启用 JFR 记录。他们的计划是在不显著影响性能的情况下收集详细性能数据。接下来,他们使用 JMC 分析 JFR 数据,以识别资源使用模式和潜在热点。

分析显示,他们的产品目录服务有间歇性的 CPU 使用高峰,影响了整体响应时间。他们的线程分析显示,订单处理服务由于线程竞争导致超时。他们还审查了网络分析数据,显示支付交易服务的数据库交互有高延迟。

在完成性能分析和配置文件后,开发团队优化了他们的电子商务应用。具体来说,他们通过实施缓存机制优化了产品目录服务。订单处理服务使用优化后的线程管理方案重写,采用了线程池。最后,支付交易服务的数据库查询得到优化,并实施了连接池。

结果:经过优化后,该科技公司的电子商务系统性能得到改善。延迟减少,稳定性显著提高,超时情况很少。员工报告满意度提高,用户投诉减少。

案例研究 3 – 分析和优化高吞吐量系统

场景:一家通信公司使用一个系统进行高吞吐量和实时数据处理与分析。在负载高峰期间,他们的系统性能显著下降。这导致数据处理和分析操作延迟。

分析工具:公司选择了 YourKit Java Profiler 工具,因为它具有全面的功能,非常适合高吞吐量系统。

实施过程:通信公司的开发团队采用了三步方法——进行初始配置文件、识别瓶颈和优化。

初始配置文件包括了对 CPU 和内存使用的分析。他们专注于系统的数据处理和分析组件。他们还进行了详细的线程分析,以帮助识别线程处理中明显的瓶颈。

他们的第二步是识别瓶颈。他们的 CPU 配置文件审查发现,他们的数据处理模块中包含了一些复杂的计算,这显著增加了 CPU 的使用。他们的内存配置文件结果显示,他们的分析模块内存使用量高。同样,该模块频繁的垃圾回收事件对系统的整体性能产生了负面影响。他们还分析了线程分析结果,结果显示在高峰数据负载期间存在线程竞争,进一步降低了性能。

公司的最后一步是对其系统进行优化。他们优化了数据处理算法以提高计算效率,并增强了并行处理以利用多核处理器。开发者还通过减少对象创建、改进数据结构和最小化垃圾回收来优化他们的分析模块。团队还优化了应用程序管理线程的方式。他们引入了线程池以减少线程竞争。

结果:通信公司的高吞吐量系统在系统优化后实现了显著的性能提升。他们的数据处理时间减少了 55%,分析处理期间的延迟最小化。他们的系统现在可以高效地处理高数据负载并满足实时数据处理需求。

三个案例研究展示了在不同情境下使用配置文件策略和工具的实际应用。它们都采用了类似的三步方法(配置文件、识别、优化)并且取得了成功的成果。

Java 配置文件的未来趋势

软件开发领域一直在不断变化,配置文件工具和技术同样具有动态性。随着技术创新的出现,帮助开发者应对和管理这些技术的工具也应运而生。本节探讨了 Java 配置文件的未来趋势,重点关注配置文件工具与人工智能和机器学习的集成。本节最后列出了新兴的标准和最佳实践。

配置文件工具的进步

当前的配置文件工具持续得到改进,新版本经常发布。定期引入新的配置文件工具也是常见的。配置文件工具的关键进步体现在以下五个领域:

  • 改进的用户界面:我们应该看到更直观的界面和更好的可视化。

  • 云原生配置文件:云计算和分布式计算已成为常态,配置文件工具更有可能适应这些环境。

  • 增强实时性能分析:性能分析工具将增强其实时处理能力,并持续减少其对性能的影响。

  • 低开销的仪器化:对高吞吐量系统进行性能分析可能会导致系统延迟。未来的进步将减少这些系统对性能的影响。

  • 统一的监控和性能分析:监控和性能分析工具的融合对于希望结合实时监控和深度性能数据的诊断的开发团队来说是有益的。

与 AI 和机器学习集成

性能分析工具可以利用 AI 和 ML 技术。这些技术可以帮助性能调优工作。以下是 AI 和 ML 在性能调优中使用的关键方面:

  • 自适应性能分析

  • 自动异常检测

  • 智能优化建议

  • 预测性能建模

  • 根本原因分析

新兴标准和最佳实践

以下建议旨在帮助指导您有效使用现有的性能分析工具和技术,以及它们的发展:

  • 采用持续性能分析策略。

  • 在性能分析工具收集数据时,注意安全和隐私考虑。

  • 与您的团队沟通和协作。这包括他们参与您的性能分析策略,审查结果,以及优化计划。

  • 将您的性能分析工作纳入整体性能管理计划。

  • 标准化您的性能分析 API,以便您可以引入互操作性和集成简化。这些标准可以帮助确保您的性能分析数据是一致的,并且您可以有效地在不同工具和环境之间比较标准。

重要的是要关注您使用的性能分析工具的变化,并意识到新工具和技术被引入的情况。这种意识,加上对最佳实践的遵守,可以帮助您充分利用性能分析工具和技术,从而实现高性能的 Java 应用程序。

摘要

本章全面探讨了性能分析工具及其在 Java 应用程序性能中的作用。我们探讨了各种性能分析器,包括包含在 JDK 中的和嵌入在 IDE 中的分析器,以及第三方工具如 JProfiler 和 YourKit Java Profiler。涵盖了实用的性能分析策略,以帮助识别性能瓶颈,开发环境和性能环境所需的独特性能分析方法,以及持续性能分析对长期性能管理的重要性。我们介绍了三个真实案例研究,以说明性能分析工具的应用。最后,我们考察了关于 Java 性能分析工具的未来趋势和最佳实践。

在下一章中,我们将探讨如何优化我们的数据库和查询,以增强我们的 Java 应用程序的性能。我们将回顾数据库设计考虑因素、有目的的 SQL 查询生成,以及包括规范化、索引、连接池、缓存、JDBC 和 ORM 优化、事务管理和测试在内的几种策略。

第五部分:高级主题

书的最后一部分讨论了推动 Java 性能优化的边界的高级主题。它从优化数据库和 SQL 查询的策略开始,接着是有效的代码监控和维护技术。你将学习关于单元和性能测试的知识,以确保你的应用程序符合性能标准。本部分以探索利用人工智能AI)来提高 Java 应用程序的性能结束,提供了对未来趋势和技术的前瞻性视角。

本部分包含以下章节:

  • 第十五章优化你的数据库和 SQL 查询

  • 第十六章代码监控和维护

  • 第十七章单元和性能测试

  • 第十八章利用人工智能(AI)提高高性能 Java 应用程序

第十五章:优化您的数据库和 SQL 查询

数据库是大型软件系统的一个关键组件。这些系统不断使用数据库连接和查询检索和更新数据。现代系统中的数据规模极其庞大,导致需要查询、更新和显示的数据更多。这种增加的规模可能导致我们的 Java 应用程序出现性能问题,强调了确保我们的数据库和查询得到优化的重要性。

本章探讨了关键的数据库设计概念,包括数据库模式、索引策略和数据分区技术。数据库查询也被检查,特别关注结构化查询语言SQL)查询。我们对查询优化的覆盖包括编写高效查询的最佳实践、查询执行计划和高级 SQL 技术。

本章还涵盖了高级 SQL 技术,如数据库配置、性能监控和数据库维护。本章以几个现实世界的案例研究结束,这些案例研究有助于展示如何识别和解决现有系统中的数据库相关性能问题。

在本章结束时,你应该对优化数据库和数据库查询的策略有基础的了解。有了这种理解,并利用从实际练习中获得的经验,你应该能够提高包含数据库和数据库查询的 Java 应用程序的性能。

本章涵盖了以下主要主题:

  • 数据库设计

  • SQL 查询优化

  • 额外的策略

  • 案例研究

数据库设计

对于新系统,我们有在设计数据库时考虑性能的便利。我们的数据库设计可以显著影响 SQL 查询的效率。在本节中,我们将检查数据库设计的关键原则,包括模式、索引、分区和分片。

模式原则

数据库模式

数据库模式是数据库的设计,作为创建它的蓝图。

在我们创建数据库之前,我们应该创建一个模式来记录我们的数据如何组织以及它们是如何相互关联的。我们的目标是设计一个使查询数据库更高效的模式。

需要做的早期决定之一是确定我们的数据库将是规范化还是非规范化。非规范化数据库涉及减少表的数量以降低查询的复杂性。相反,规范化涉及创建单独的表以消除重复数据。让我们来看一个例子。

下表显示了作者和出版社字段中的重复数据。

BookID Author Title Publisher Price ($)
1 N. Anderson Zion 的介绍 Packt 65.99
2 N. Anderson Zion 的插图历史 Packt 123.99
3 W. Rabbit 天体力学 Packt 89.99
4 W. Rabbit Gyro Machinery Forest Press 79.99

表 15.1 – 一个非归一化表

如您在前面的表中可以看到,有两个条目对应两位不同的作者,一个出版社被列出了不止一次。这是一个未归一化的表。为了归一化这个表,我们将创建三个表,每个表分别对应书籍、作者和出版社。

BookID Title AuthorID PublisherID Price ($)
1 Zion介绍 1 1 65.99
2 Zion插图历史 1 1 123.99
3 Astro Mechanics 2 1 89.99
4 Gyro Machinery 2 2 79.99

表 15.2 – 书籍表

Books 表引用了 AuthorIDPublisherID 字段。这些字段在以下表中建立。以下是 Authors 表:

AuthorID Author
1 N. Anderson
2 W. Rabbit

表 15.3 – 作者表

我们的最后一个表是出版社的表:

PublisherID Publisher
1 Packt
2 Forest Press

表 15.4 – 出版社表

实施归一化或非归一化数据库的决定涉及考虑查询的复杂性、您数据库的大小以及数据库的读写负载。

另一个重要的数据库设计考虑因素是每列的数据类型。例如,使用 SQL 创建具有适当数据类型的 Authors 表是合适的:

CREATE TABLE Authors {
  Author_ID INT,
  Author VARCHAR(80)
);

在我们设计好数据库表并确定数据类型后,我们需要实现索引。让我们在下一节中看看这一点。

索引

我们索引数据库,以便快速找到数据。我们的索引策略直接影响到我们的查询性能,因此需要尽职尽责。有两种索引类型。第一种是平衡树B-tree),这是大多数数据库中实现的方式。这种索引保持数据排序,并允许顺序访问、插入和删除。

数据库索引的第二种类型是哈希索引。当需要等值比较时,这种索引类型是理想的,但它不适合范围查询。

索引创建很简单,以下 SQL 语句展示了这一点:

CREATE INDEX idx_authors_authorid ON Authors (AuthorID);

如果您有一个现有的数据库,并且经常使用 WHEREJOINORDER BYGROUP BY,您可能可以从索引中受益。让我们看看一个例子。

当我们没有索引时,我们可以使用以下 SQL 语句:

SELECT * FROM Authors WHERE Author = 'N. Anderson';

以下 SQL 语句搜索相同的作者,但使用了索引:

CREATE INDEX idx_authors_authorid ON Authors (AuthorID);
SELECT * FROM Authors WHERE Author = 'N. Anderson';

使用第二个例子将比非索引方法提供更快的结果。请注意,索引确实会占用额外的存储空间,并在使用 INSERTDELETEUPDATE 操作时增加额外的处理开销。

在下一节中,我们将检查分区和分片作为提高数据库和数据库查询效率的方法。

分区与分片

分区和分片是用于通过将大型数据集划分为更小的组件来提高大型数据库及其查询性能的策略。

分区

有两种分区类型,水平分区垂直分区。水平分区是通过将表拆分为行来实现的,每个水平分区包含这些行的一个子集。这种方法的典型用例是基于日期范围创建分区。以下示例创建了三个表,每个表包含特定年份的订单信息:

CREATE TABLE Book_Orders_2021 (
    CHECK (OrderDate >= '2021-01-01' AND OrderDate < '2022-01-01')
) INHERITS (Book_Orders);
CREATE TABLE Book_Orders_2022 (
    CHECK (OrderDate >= '2022-01-01' AND OrderDate < '2023-01-01')
) INHERITS (Book_Orders);
CREATE TABLE Book_Orders_2023 (
    CHECK (OrderDate >= '2023-01-01' AND OrderDate < '2024-01-01')
) INHERITS (Book_Orders);

垂直分区将数据库表拆分为列,每个分区包含列的子集。为了演示垂直分区,让我们看看一个尚未分区的Books表:

CREATE TABLE Books (
    BookID INT PRIMARY KEY,
    Title VARCHAR(100),
    AuthorID INT,
    Pages INT,
    Genre VARCHAR(50),
    PublisherID INT,
    PublishedDate DATE,
    OriginalPrice DECIMAL(10, 2),
    DiscountPrice DECIMAL(10, 2)
);

现在,使用垂直分区,让我们创建两个表,每个表包含列的子集:

CREATE TABLE Books_CatalogData (
    BookID INT PRIMARY KEY,
    Title VARCHAR(100),
    AuthorID INT,
    Pages INT,
    Genre VARCHAR(50),
    PublisherID INT,
    PublishedDate DATE,
);
CREATE TABLE Books_SalesData (
    BookID INT PRIMARY KEY,
    OriginalPrice DECIMAL(10, 2),
    DiscountPrice DECIMAL(10, 2),
    FOREIGN KEY (BookID) REFERENCES Books_CatalogData(BookID)
);

通过将我们的表拆分为两个分区,我们可以更有效地搜索,因为我们不需要处理任何无关数据(即,在搜索目录数据时,我们不关心销售数据)。

现在我们来看另一种提高数据库效率的策略,称为分片

分片

分片是将我们的数据分布到多个服务器上的过程,将数据移动到用户附近。这种策略有两个主要好处——将数据移动到靠近用户的服务器可以减少网络延迟和单个服务器的负载。一个常见的用例是基于地理位置进行分片。以下是一个示例,说明我们如何实现这一点:

CREATE TABLE Users_US (
    UserID INT PRIMARY KEY,
    UserName VARCHAR(100),
    Region CHAR(2) DEFAULT 'US'
);
CREATE TABLE Users_EU (
    UserID INT PRIMARY KEY,
    UserName VARCHAR(100),
    Region CHAR(2) DEFAULT 'EU'
);

上述示例创建了两个表。下一步是将每个表存储在不同的服务器上。

有目的的分区和分片方法可以导致一个性能就绪的数据库设计。它可以提高你的 SQL 查询效率,从而提高 Java 应用程序的整体性能。

查询优化

现在我们已经对如何以性能为导向设计数据库有了基本的了解,我们可以开始探讨编写高效查询的最佳实践。我们还将探讨查询执行计划和一些高级 SQL 技术。为了使本节中的示例 SQL 语句更具相关性,我们将使用一个图书库存和订单处理数据库。

查询执行

理解我们的数据库如何处理查询是能够对其进行优化的关键。

查询执行计划

查询执行计划提供了数据库引擎执行查询的详细信息

查询执行计划包括数据库查询操作(如连接和排序)的详细信息。让我们看看一个简单的查询,它给出了特定书籍的总销售额:

FROM Orders o
JOIN Books b ON o.BookID = b.BookID
WHERE b.Title = 'High Performance with Java';

现在,让我们向相同的查询添加一个EXPLAIN命令,以揭示数据库引擎执行我们的查询的步骤:

EXPLAIN SELECT SUM(o.Qty * o.Price) AS TotalSales
FROM Orders o
JOIN Books b ON o.BookID = b.BookID
WHERE b.Title = 'High Performance with Java';

通过查看查询执行计划,我们可以识别潜在的瓶颈,这为我们进一步优化数据库和查询提供了机会。

接下来,让我们看看编写高效查询的一些最佳实践。

最佳实践

编写 SQL 查询时的目标是尽量减少资源使用并减少执行时间。为了实现这些目标,我们应该遵循最佳实践,包括以下针对 SELECT 语句、JOIN 操作、子查询公用表表达式CTEs)的详细说明。

SELECT 语句

使用 SELECT 语句有三个最佳实践。首先,我们应该避免使用 SELECT *,而只指定我们需要的列。例如,不要使用 SELECT * FROM Books;,而应使用 SELECT Title, AuthorID, Genre FROM Books;

另一个最佳实践是使用 WHERE 子句尽可能缩小我们的结果集。以下是一个示例:

SELECT Title, AuthorID, Genre FROM Books WHERE Genre = 'Non-Fiction';

使用 SELECT 语句的第三个最佳实践是限制查询返回的行数。我们可以使用 LIMIT 子句,如下所示:

SELECT Title, AuthorID, Genre FROM Books WHERE Genre = 'Non-Fiction' LIMIT 10;

使用 SELECT 语句的三个最佳实践对于提高查询效率至关重要。接下来,让我们看看使用 JOIN 操作的最佳实践。

JOIN 操作

使用 JOIN 操作有两个最佳实践。首先,我们应该确保在 JOIN 条件中使用的所有列都已建立索引。这将提高这些操作的效率。

另一个最佳实践是使用以下表格中指示的适当 JOIN 类型。

类型 用途
INNER JOIN 用于匹配行
LEFT JOIN 包括左表中的所有行
RIGHT JOIN 包括右表中的所有行

表 15.5:JOIN 类型及其用途

接下来,让我们探讨子查询的概念及其相关最佳实践。

子查询

正如标题所示,子查询用于将复杂查询分解成多个更简单的查询。以下是一个示例:

SELECT b.Title, b.Genre
FROM Books b
WHERE b.BookID IN (SELECT o.BookID FROM Orders o WHERE o.Qty > 100);

接下来,让我们看看 CTEs。

CTEs

CTEs 可以用来使复杂查询更易于阅读。这增加了它们的可重用性并简化了它们的可维护性。以下是一个示例:

WITH HighSales AS (
    SELECT BookID, SUM(Qty) AS TotalQty
    FROM Orders
    GROUP BY BookID
    HAVING SUM(Qty) > 100
)
SELECT b.Title, b.Genre
FROM Books b
JOIN HighSales hs ON b.BookID = hs.BookID;

现在我们已经审查了编写查询的几个最佳实践,让我们看看一些高级 SQL 技术。

高级 SQL 技术

本节演示了三种高级 SQL 技术——窗口函数、递归查询和临时表及视图。

窗口函数

窗口函数用于计算与当前行相关的一组行的值。以下是一个示例:

SELECT BookID, Title, Genre,
      SUM(Quantity) OVER (PARTITION BY Genre) AS TotalSalesByGenre
FROM Books b
JOIN Orders o ON b.BookID = o.BookID;

递归查询

递归查询是复杂的,但在处理具有层次结构的数据(如书籍类别和子类别)时非常有用。以下是一个示例:

WITH RECURSIVE CategoryHierarchy AS (
    SELECT CategoryID, CategoryName, ParentCategoryID
    FROM Categories
    WHERE ParentCategoryID IS NULL
    UNION ALL
    SELECT c.CategoryID, c.CategoryName, c.ParentCategoryID
    FROM Categories c
    JOIN CategoryHierarchy ch ON c.ParentCategoryID = ch.CategoryID
)
SELECT * FROM CategoryHierarchy;

临时表和视图

另一种高级技术是使用临时表和视图来提高性能并帮助管理复杂查询。以下是一个临时表的示例:

CREATE TEMPORARY TABLE TempHighSales AS
SELECT BookID, SUM(Qty) AS TotalQty
FROM Orders
GROUP BY BookID
HAVING SUM(Qty) > 100;
SELECT b.Title, b.Genre
FROM Books b
JOIN TempHighSales ths ON b.BookID = ths.BookID;

以下 SQL 语句是一个临时视图的示例:

CREATE VIEW HighSalesView AS
SELECT BookID, SUM(Qty) AS TotalQty
FROM Orders
GROUP BY BookID
HAVING SUM(Qty) > 100;
SELECT b.Title, b.Genre
FROM Books b
JOIN HighSalesView hsv ON b.BookID = hsv.BookID;

在本节中尝试使用所介绍的高级技术可以提高编写高效查询的能力,从而有助于提高 Java 应用程序的整体性能。

额外策略

到目前为止,本章已经涵盖了为效率设计数据库模式以及如何编写高效的 SQL 查询。我们还可以采用一些其他策略,包括微调、监控和维护。本节将探讨这些策略,并使用上一节中相同的图书库存和订购示例。

微调

我们可以微调数据库服务器的配置参数,以确保我们的查询能够高效地使用资源。这种微调可以归类如下:

  • 数据库 服务器参数

  • 使用 MySQL 中的innodb_buffer_pool_size参数和SET shared_buffers = '3GB'; SQL 语句。

  • 连接池:如第十章中详细介绍的连接池,我们可以将数据库连接进行池化,以减少开销并提高整体应用性能。

  • SET query_cache_size = 256MB';将启用查询缓存。

  • 内存管理

  • 数据库缓存:我们可以缓存数据库以加快对频繁访问数据的读取操作。可以使用Redis等工具来辅助这项技术。

接下来,让我们探讨如何监控和评估数据库的性能。

数据库性能监控

一旦我们的数据库启动并运行,并且所有查询都已建立,我们就可以准备监控数据库的性能。监控可以帮助我们识别潜在的瓶颈,并允许我们对性能进行优化。

识别瓶颈的一个有效方法是启用慢查询日志。这可以帮助我们识别哪些查询的执行时间超过了我们的期望。以下是启用方法:

SET long_query_time = 1;
SET slow_query_log = 'ON';

使用查询分析工具可以帮助我们分析和优化慢查询。根据您的数据库类型和服务,有各种可用的工具。

监控和评估可以帮助识别优化机会。在下一节中,我们将探讨数据库维护。

数据库维护

数据库是动态的,需要通过定期维护来维护。这是一种主动而非被动的方法来维护数据库。以下是一些建议:

  • 定期运行VACUUM以回收存储空间。

  • 在每次运行VACUUM之后,运行ANALYZE以便查询规划器更新统计信息。

  • 使用REINDEX定期重新索引数据库。这将提高查询性能。

  • 存档不再需要的老数据。您可以将这些数据分区到历史数据库中。

  • 清除不再需要的数据。这将释放存储空间并应提高查询性能。

本节中提出的策略可以帮助您进一步优化查询性能、数据库性能以及 Java 应用程序的整体性能。这些策略对于大型数据库和高交易率数据库尤为重要。

在下一节中,我们将回顾几个现实世界的案例研究,以帮助您将本章中提出的概念置于上下文中。

案例研究

本节展示了三个使用本章中提到的书籍库存和订单处理数据库的真实案例研究。对案例研究的回顾将展示本章中提出的策略和技术如何用于解决常见的数据库性能问题。

每个案例研究都按照以下格式展示:

  • 场景

  • 初始 SQL 查询

  • 问题

  • 优化步骤

  • 结果

案例研究 1

场景:每次书店管理员运行销售报告时,都需要几分钟时间——比应有的时间要长得多。报告只是简单地按标题总结总销售额。数据库模式与本章前面展示的相同。

初始 SQL 查询

SELECT b.Title, SUM(o.Qty * o.Price) AS TotalSales
FROM Orders o
JOIN Books b ON o.BookID = b.BookID
GROUP BY b.Title;

BooksOrders 表。这导致性能缓慢。

优化步骤

  1. 数据库管理员在 BooksOrders 表的 BookID 列上添加了索引:

    CREATE INDEX idx_books_bookid ON Books (BookID);
    CREATE INDEX idx_orders_bookid ON Orders (BookID);
    
  2. 查询被优化,仅包括检索所需数据所需的列:

    EXPLAIN ANALYZE
    SELECT b.Title, SUM(o.Qty * o.Price) AS TotalSales
    FROM Orders o
    JOIN Books b ON o.BookID = b.BookID
    GROUP BY b.Title;
    

EXPLAIN ANALYZE 命令显示查询时间显著减少。销售报告现在在一分钟内完成。

案例研究 2

Orders 表现在包含数百万条记录。对特定年份的查询运行速度极慢。

初始 SQL 查询

SELECT * FROM Orders WHERE OrderDate BETWEEN '2023-01-01' AND '2023-12-31';

问题:查询执行了全表扫描,导致性能缓慢。

优化步骤

  1. 数据库管理员执行了水平分区,为每年的数据创建表:

    CREATE TABLE Orders_2023 (
        CHECK (OrderDate >= '2023-01-01' AND OrderDate < '2024-01-
        01')
    ) INHERITS (Orders);
    CREATE TABLE Orders_2024 (
        CHECK (OrderDate >= '2024-01-01' AND OrderDate < '2025-01-
        01')
    ) INHERITS (Orders);
    
  2. 在数据分区后,管理员更新了查询以针对特定的分区:

    SELECT * FROM Orders_2023 WHERE OrderDate BETWEEN '2023-01-01' AND '2023-12-31';
    

结果:查询性能显著提高,运行时间仅为之前的一小部分。

案例研究 3

Books 表导致数据库产生显著且不必要的负载。

初始 SQL 查询

SELECT * FROM Books WHERE BookID = 1;

问题:数据库被反复发送相同的查询,导致高负载和缓慢的响应时间。

优化步骤:数据库管理员使用 Redis 缓存书籍详情。

结果:数据库负载显著减少,响应时间大幅缩短。

总结

本章探讨了优化数据库和 SQL 查询的必要策略和技术。本章的总体目标是介绍与数据库相关的增强和最佳实践,以提高您数据驱动应用程序的性能。我们从数据库设计的根本原则开始,包括模式规范化、适当的索引和分区策略。然后我们探讨了如何编写高效的 SQL 查询。我们的覆盖范围还包括查询执行计划和利用高级 SQL 技术,如窗口函数和递归查询。本章还讨论了包括数据库配置、监控、分析和定期维护在内的其他策略。本章以实际案例研究结束,以展示本章中涵盖的策略和技术的实际应用。现在,您应该有信心实施这些最佳实践,并确保您的数据库系统可以轻松处理大型数据集和复杂查询。

在下一章中,我们将探讨代码监控和代码维护的概念,始终保持对我们的 Java 应用程序高性能的警觉。我们的代码监控和维护方法将包括在问题出现之前进行代码审查,以识别潜在的性能问题。具体来说,我们将探讨应用性能管理APM)工具、代码审查、日志分析和持续改进。

第十六章:代码监控和维护

编写高效的代码不足以确保我们的 Java 应用程序在系统初始发布后仍能以高水平运行。我们必须采用强大的代码监控和维护策略,以确保我们的系统即使在数据和使用量增加以及环境发生变化的情况下,也能继续以期望的水平运行。本章重点介绍了代码监控和维护的关键实践和相关工具。

我们本章从探索我们可以使用的应用性能管理(APM)工具开始,这些工具可以用于实时监控并获取诊断数据,帮助我们保持应用程序的高效运行。我们的 APM 工具探索将包括用例和实施策略。

代码审查的重要性也得到了涵盖,目的是培养对持续过程改进的奉献精神,特别是持续维护代码质量。对最佳实践和自动化工具的见解将为您提供有关如何使用所选工具和实践来识别代码中潜在问题的知识,从而在它们导致不期望的系统行为并负面影响用户体验之前。

本章还介绍了日志记录的概念,并分享了它如何有效地用于监控应用程序。我们将探讨最佳实践、日志框架以及如何分析日志数据。我们的目标是记录正确的数据,并学会使用记录的数据来识别优化机会,而不会引入过多的系统开销。

本章涵盖了以下主要主题:

  • APM 工具

  • 代码审查

  • 记录

  • 监控和警报

  • 维护策略

APM 工具

Java 开发者花费在维护系统上的时间比开发系统的时间多。这是因为我们的系统在生产中的时间比编写和测试代码的时间长。因此,我们应该认真对待应用性能管理(APM),并认识到它在确保我们的系统持续以最佳性能运行并最大化用户体验方面所起的至关重要作用。

APM 工具概述

APM 工具旨在帮助我们监控系统,以管理其性能。这些工具提供了对我们应用程序性能的实时洞察。它们可以帮助我们识别潜在的瓶颈和可能的优化机会,例如资源使用。由 APM 工具生成的度量标准可以与基线进行比较,并为我们提供系统整体健康状况的真实画面。

APM 工具的主要目标包括以下内容:

  • 确保应用可靠性

  • 确保应用可扩展性

  • 识别性能问题

  • 提供可操作的见解

  • 实时应用监控

  • 用户交互跟踪

接下来,让我们看看 APM 工具的关键特性。

APM 工具关键特性

APM 工具的范围从基础到强大,可以具有各种功能。以下是 APM 工具的 10 个关键功能,不分先后顺序:

  • 根据性能阈值提供可配置的警报

  • 监控系统资源使用情况

  • 持续监控应用程序性能,如响应时间

  • 通过跟踪单个事务来识别性能瓶颈

  • 识别并记录异常和错误,提供足够的详细信息以支持故障排除

  • 跟踪和测量用户交互,如事务持续时间和加载时间

  • 进行深入分析

  • 提供分析数据的数字和可视化报告

  • 与其他工具(如 CI/CD 管道)集成

  • 自动化性能管理流程

既然我们已经认识到 APM 工具的重要性、它们的目标和关键特性,让我们回顾一下用于 Java 应用程序监控的五个常见 APM 工具。

流行的事务性能管理(APM)工具

可用于 Java 应用程序开发者的 APM 工具种类繁多。下表列出了五个常见工具,并为每个工具提供了简要描述和 URL。

工具 描述 URL
AppDynamics 具有出色 Java 应用程序可见性的强大性能监控工具 www.appdynamics.com/product/application-performance-monitoring
Datadog 基于云的监控和分析工具 www.dynatrace.com/monitoring/solutions/cloud-monitoring-cio-report/
Dynatrace 利用人工智能进行性能监控的高级工具 www.dynatrace.com/monitoring/platform/application-observability/
Elastic APM 开源性能监控工具 www.elastic.co/observability/application-performance-monitoring
New Relic 可能是使用最广泛的工具,具有广泛的监控和分析功能 www.newrelic.com

表 16.1:Java 应用程序的流行 APM 工具

关于 URL 的说明

以下表中列出的 URL 在本书首次出版时是有效的。如果您发现链接已不再有效,您可以搜索工具名称加上Java 应用程序的 APM 工具(例如,Java 应用程序的 Dynatrace APM 工具)来找到新的链接。

您应该花时间尝试每个 APM 工具,以确定您想使用哪一个或哪几个。一旦您有了使用哪一个或哪几个的想法,您就可以查看下一节中提出的最佳实践。

APM 工具最佳实践

无论您采用哪种 APM 工具,都有一些最佳实践可以帮助您充分利用所选工具来支持您的监控工作。以下是一些您可以考虑的最佳实践:

  • 定期分析您的性能数据。这可以帮助您识别趋势和异常。

  • 安排并定期审查您的 APM 配置和性能数据。

  • 建立明确的表现监控目标,并与您的组织目标保持一致。

  • 创建您的关键绩效指标KPIs),并定期审查它们的相关性。

  • 在您的团队中建立性能意识文化。

  • 实施全面的仪器化方法,确保可以监控到所有内容。

  • 将 APM 工具集成到您的 DevOps 流程中。

  • 优先监控您最关键的指标,如错误率、响应时间、资源使用等。

  • 配置您的 APM 工具,以便为您提供警报和通知,以提高效率。

遵循这里提出的最佳实践可以帮助您最大化使用 APM 工具的益处。

代码审查

代码审查是软件开发的基本组成部分。正如我们之前建议的,代码质量在上线后不会保持不变。环境会变化,新的数据会被引入,可能会发生扩展,用户行为也可能发生变化。这强调了进行代码审查的重要性。

代码审查的目的如下:

  • 确保遵守标准和指南的一致性

  • 鼓励协作

  • 促进团队之间的知识转移,并增加个人对代码质量的承诺

  • 提高优化

  • 使用质量保证识别缺陷和安全漏洞

接下来,让我们回顾一下进行代码审查时的最佳实践。

最佳实践

在进行代码审查时,以下是一些自我描述性的最佳实践供您考虑:

  • 尽可能实现自动化

  • 鼓励建设性的反馈

  • 建立编码标准

  • 限制代码更改的大小

  • 优先处理关键代码部分

  • 设定代码审查时间限制

  • 使用清单以确保审阅者的统一性

第一项最佳实践建议您尽可能实现自动化。有一些自动代码审查工具值得您独立研究和分析。以下是一些供您审查的列表:

  • Checkstyle

  • Code Climate

  • FindBugs及其继任者SpotBugs

  • PMD

  • SonarQube

仔细审查自动化工具。一旦您选择了最喜欢的一个,在正式将其用于实际项目之前,先对其进行实验。接下来,让我们看看同行评审流程。

同行评审流程

代码审查通常由同行进行,如果不正确处理,可能会显得尴尬。以下是一些进行正确和高效同行评审的建议:

  • 指派具有适当专业知识和经验的审阅者

  • 轮换审阅者以减轻懈怠

  • 通过强制在代码中添加注释标准来为审查做准备

  • 使用代码审查工具(如 GitHub 拉取请求)来简化流程

  • 鼓励相互尊重和开放沟通的氛围

  • 跟进所有反馈

遵循这些提示可以减轻尴尬并提高同行审查的效率。接下来,让我们看看一些关于代码审查的常见陷阱。

常见陷阱

在进行代码审查时存在几个陷阱,尽管这些陷阱被我们从过程中获得的益处所掩盖。本节将探讨进行代码审查时遇到的前五大陷阱,并提出解决方案。

陷阱 解决方案
延迟审查 设定清晰的截止时间并将它们整合到你的开发工作流程中。
标准不统一 建立明确的指南并使用清单。
缺乏建设性反馈 专注于提供建设性反馈。要具体,并确保反馈是可操作的。
忽视自动化工具 利用自动化工具的优势。它们可以在你的开发者审查更复杂问题时捕捉到常规问题。
审查时间过长 保持审查简短且频繁。

表 16.2:常见的代码审查陷阱及解决方案

了解这些陷阱可以帮助确保你的代码审查无缝集成到你的工作流程中。

在下一节中,我们将审查日志。

日志

日志数据是代码监控和维护的基本组成部分。这些数据可以揭示有关我们的代码性能、失败的地方、可能存在的安全担忧等方面的信息。它涉及记录程序运行的信息。我们可以使用这些信息进行审计、调试和监控。

日志的关键方面包括以下内容:

  • DEBUGERRORINFO

  • 日志消息:这是应用程序事件的叙述性描述。

  • 日志轮转:我们通过存档旧日志并开始新的日志来轮转日志。这防止了难以管理的大型日志,并可能导致存储问题。

  • 日志目标:目标是日志的存储目的地。

现在我们对日志有了基本了解,让我们来回顾一些最佳实践。

最佳实践

我们的日志实现应专注于所捕获内容的相关性和系统的效率。考虑到这些目标,以下是关于日志的一些最佳实践:

  • 对日志采用简洁但描述性的心态。你希望它们清晰,而不是过于冗长。

  • 将日志集中化以方便聚合和综合处理。

  • 考虑使用结构化日志格式,如 JSON,以便更容易解析和分析。

  • 专注于与你的系统最关键流程相一致的日志级别。

  • 将你的日志实践规范化,包括格式和命名约定。

  • 保护个人可识别信息或其他敏感数据不被包含在日志中。

  • 定期审查你的日志。

接下来,我们将审查有用的日志框架。

日志框架

我们可以使用几个日志框架来为我们的 Java 应用程序服务。开发者通常在审查了他们的选项后选择一个。以下是一些更受欢迎的框架列表:

  • java.util.logging)。虽然它只提供基本的日志功能,但我们可以在不需要额外库的情况下使用它。如果您刚开始使用日志,这是一个很好的框架。

  • Log4j2:这是一个支持各种配置、日志级别、类型和目的地的先进框架。

  • Logback:这个框架提供了一个与JavaSimple Logging Facade(SLF4J)兼容的高性能选项。

  • SLF4J:这个框架提供了一个抽象层,以支持多个日志框架,如 Logback。

  • Tinylog:正如其名所示,这是一个轻量级框架,具有低开销。它非常适合小型应用程序。

这些框架可以帮助简化我们的日志实现和管理。接下来,让我们看看分析我们日志数据的关键策略。

分析日志数据

日志系统数据的一个主要原因是提供给我们分析它的能力,以改善我们系统整体性能。以下是一些您可以用于分析和管理日志数据的策略:

  • 聚合:将您的日志输入到一个中央存储库中,以便更有效地分析。有几个工具(如LogstashElasticsearch)可以帮助实现这一策略。

  • 分析:使用工具(如DatadogGraylog)来帮助分析和可视化日志数据。使用统计分析来获得深入见解。

  • 自动化:使用基于您设置的阈值的自动警报来通知您活动情况。

  • 存档:存档日志以避免日志膨胀。

遵循最佳实践并利用选定的框架和工具可以帮助确保您的日志工作是有目的和高效的。接下来,让我们看看如何设置监控和警报。

监控和警报

当我们想要回顾日志数据时,我们的日志数据总是可用的,但更重要的是,它可以基于我们如何设置来提供自动警报。有效的监控和警报是维护我们的 Java 应用程序并确保其高性能和安全性的关键操作。

监控和警报可以帮助我们实时了解应用程序的性能,并及时提醒我们任何需要立即采取行动的问题。

监控系统设置

设置监控系统的步骤将取决于您选择的框架和工具。以下是一个六步流程,无论您选择哪些框架和工具都可以使用:

  1. 识别关键指标:您需要知道您想要收集什么,以便收集的内容是有用的。您的重要指标可能包括 CPU 使用率、内存使用、错误率、响应时间等。一旦您确定了关键指标,您就可以建立性能目标。

  2. 选择监控工具:选择最适合你的应用程序架构和要求的监控工具(例如GrafanaNew RelicPrometheus)。

  3. 集成监控代理:将监控代理集成到你的 Java 应用程序中以收集性能数据。根据你的解决方案,你可能需要添加特定的监控代码,利用现有框架的内置功能,或者使用 API。

  4. 设置数据收集:配置你的监控系统以收集和存储性能数据。要谨慎,并确保数据收集操作高效,不会导致显著的应用程序开销。

  5. 可视化:创建或使用由你的监控工具提供的仪表板来直观地表示收集到的数据。你可以使用 Grafana 等工具构建交互式仪表板,这有助于你快速了解应用程序的性能状态。

  6. 审查和调整:定期审查你的监控设置的有效性,并做出必要的调整。每次引入新的组件或服务时,都要重新评估这一点。

一旦你的监控系统已经设置好,你就可以准备配置它以提供信息丰富的警报。

警报配置

配置警报涉及设置阈值和规则,当满足某些性能条件时触发通知。按照以下步骤设置有效的警报架构:

  1. 定义标准:确定你想要警报的条件,并具体说明。例如,你可能会选择 92%的 CPU 使用率、增加的响应时间、错误激增等。你可以根据历史应用程序数据和性能基准来设置你的阈值。

  2. 设置级别:根据严重性(即信息警告关键)对警报进行分类,以便你的团队能够优先处理响应工作。

  3. 配置通道:设置警报通知的通道,以便你的团队能够得到通知。你可能只需要使用短信、电子邮件、SlackDiscord,或者实施一个事故管理平台,如PagerDuty

  4. 微调阈值:在设置警报时,你想要避免警报疲劳。这将需要你微调你的警报阈值。用不必要的警报淹没你的团队将抵消你的警报系统的有效性。

  5. 测试:定期测试你的警报设置以确保其正常工作。你甚至可以进行演习来模拟性能情况,以验证你系统的有效性。

一旦你的警报系统已经设置好,你需要确定你的响应方法。

警报和事故响应

建立正式的事故响应方法很重要。在建立你的警报响应架构时,考虑以下问题:

  • 哪些警报需要响应?

  • 谁响应哪些警报?

  • 需要哪些内部沟通?

  • 需要哪些外部沟通?

  • 需要哪些后续操作?

采用结构化的方法来应对警报和事件,可以帮助确保关键问题得到适当的处理。这可以导致快速解决和最小化停机时间。

有效的警报和事件响应方法的关键组成部分包括以下内容:

  • 警报确认:收到警报时,应立即确认。您可以为您团队设定最大响应时间。

  • 评估:调查每个警报以了解根本原因。目标是不仅解决当前问题,还要防止问题在未来重复发生。

  • 计划的执行:确保您的团队在问题解决过程中遵循预定义的事件响应计划。这些计划应包括针对常见问题和复杂问题的文档化步骤。

  • 沟通:使用预定义的沟通渠道,让内部和外部利益相关者了解事件状态和解决进度。

  • 文档:在解决事件后,应记录根本原因、解决步骤和经验教训。在适当的情况下,与您的团队进行事件后审查,以确定改进机会。

当我们设置了一个强大而有目的的监控和警报系统时,我们显著提高了维护和改进 Java 应用程序性能的能力,即使它们在扩展。

维护策略

我们需要一个策略来维护我们的应用程序代码,这超出了仅仅响应系统警报的范围。当我们采取有目的的代码维护方法时,我们可以确保 Java 应用程序的持续可靠性、可用性和性能。

主要概念是在计划维护和反应性维护之间保持平衡。下表提供了每种方法的见解,包括它们的优点和最佳实践。

预定维护 反应性维护
方法细节 计划在预定间隔更新代码 随时解决出现的问题
优点
  • 可预测的停机时间

  • 降低故障风险

  • 持续优化

|

  • 立即解决问题

  • 相比于预定维护方法,需要的资源更少

|

最佳实践
  • 建立并遵循维护计划

  • 在维护窗口期间测试变更

  • 将计划传达给内部和外部利益相关者

|

  • 实施强大的监控和警报系统

  • 创建事件响应计划

  • 创建文档以支持故障排除

|

表 16.3:预定维护和反应性维护方法比较

一旦您已经建立了维护方法,您应该考虑文档化和知识管理。让我们在下一节中看看这一点。

文档化和知识管理

有效的文档和知识管理对于维护健康的代码库无疑是至关重要的。它还可以帮助确保维护活动期间平稳过渡。

文档应该是全面的,特别是对于大型系统。全面的文档应包括以下主要组件:

  • API 文档

  • 代码文档

  • 配置文档

同时,拥有一个知识共享组件也很重要,这可能包括内部维基和为新团队成员提供培训。随着系统的更新,知识共享工件应进行更新,以确保它们保持相关性。知识共享的方式几乎与共享的内容一样重要。可以使用团队SharePointConfluenceGitHub 维基等协作工具来促进协作文档和知识共享。

维护策略通常包括重构,我们将在下一节中对其进行回顾。

重构策略

重构是重新利用现有代码而不改变其外部行为的过程。重构的目的是提高代码的可读性、可维护性和性能。

我们可以通过代码异味来识别重构的需求,这些异味是设计不良的代码、重复代码、大型或长类和方法等迹象。性能瓶颈和复杂逻辑也是代码应该重构的额外指标。

以下技术可用于重构您的代码:

  • 将大型类和方法分解为更小、更易于管理的组件。

  • 重命名类、方法和变量,使它们具有意义且自我描述。这种增加的代码清晰度提高了整体的可读性和可维护性。

  • 删除未使用的代码。

  • 将复杂的条件逻辑简化为更清晰、更易读的结构。

当重构我们的代码时,我们应该努力以小步骤、增量方式进行重构。这将最小化引入新错误的风险。我们应该在重构前后始终编写单元测试(见第十七章单元和性能测试)。这有助于我们确保功能保持不变。最后,我们应该使用版本控制系统来跟踪我们的更改,并在必要时支持代码回滚

有时我们继承系统并必须维护遗留代码。这将在下一节中介绍。

遗留代码

遗留代码是指较老的代码库。由于过时的实践、缺乏文档或已弃用的技术(即使用通用商业面向 语言COBOL)编写的系统),它们通常难以维护。

当继承遗留代码库时,我们应首先进行全面代码审查,以识别问题区域、过时的依赖项和安全漏洞。凭借代码审查的知识,我们可以优先考虑代码中需要立即关注或存在重大风险或安全漏洞的关键部分。

当可行时,我们应该采用针对遗留代码的现代化策略。这可能包括以下组件:

  • 实施增量更新

  • 使用代码包装器实现新功能

  • 自动化变更的测试

  • 记录遗留代码及其变更

  • 实施备份方案

  • 使用版本控制系统

  • 将遗留组件从系统其余部分隔离

  • 对开发者进行遗留代码和维修计划的培训

实施这些维护策略可以帮助你确保 Java 应用程序的持续稳定性和性能。这些策略旨在使代码更容易更新、维护和随时间扩展。

摘要

本章探讨了有效代码监控和维护的基本实践和工具,重点关注确保长期性能、可靠性、安全性和可扩展性。我们首先概述了 APM 工具,详细介绍了它们的关键功能,如实时监控、事务跟踪、错误跟踪和用户体验监控。我们回顾了 Java 中流行的 APM 工具,以及它们的实施和使用最佳实践。

我们强调了代码审查对于保持高质量代码的重要性。我们涵盖了最佳实践,详细说明了同行评审流程,并分享了常见的陷阱,提供了避免解决方案。

检查了日志的概念,从基础开始,包括日志级别、消息和目标。我们概述了有效日志记录的最佳实践,例如使用适当的级别、避免敏感信息以及集中日志。我们还介绍了 Java 中流行的日志框架,并讨论了分析和管理日志数据的技术。

强调了监控和警报。我们介绍了如何设置全面的监控系统,以及警报配置和事件响应策略。我们通过介绍维护策略来结束本章。我们比较了计划性和反应性维护方法,强调了主动规划的重要性。我们强调了文档和知识管理在维护健康代码库中的作用。最后,我们探讨了有效的重构策略,并提供了如何处理遗留代码的指导。

在下一章中,我们将介绍创建和使用单元测试和性能测试的策略,以帮助创建和维护高性能的 Java 应用程序。具体来说,我们将探讨单元测试、性能测试和总体策略。

第十七章:单元测试和性能测试

代码彻底测试的重要性不容忽视;此外,这种测试应该是高效的。随着我们的系统在复杂性和规模上的增长,确保我们软件的每个组件都能准确且高效地运行变得越来越关键。这就是单元和性能测试发挥作用的地方。这也是本章的重点。

本章首先介绍了单元测试,这是我们用来验证代码各个单元的方法。目标是确保单元能够高效且按预期运行。通过单元测试,我们可以在代码部署到生产环境之前及早发现异常(错误)。性能测试作为一个补充过程被引入,在这个过程中,我们测试软件在各种条件下的行为,如响应性、可用性、可伸缩性、可靠性和可伸缩性。正如本章所展示的,性能测试可以帮助我们识别潜在的瓶颈,并确保我们的系统可以处理预期的用例和负载。

本章采用了理论和实践相结合的方法,给你提供了在单元和性能测试中获得知识和经验的机会。我们将涵盖包括集成两种类型测试、自动化、测试环境、持续测试和反馈循环在内的总体策略。

到本章结束时,你将全面理解单元和性能测试,并能够利用它们来提高 Java 应用程序的可靠性和效率。

本章涵盖了以下主要主题:

  • 单元测试

  • 性能测试

  • 总体策略

技术要求

要遵循本章中的示例和说明,你需要能够加载、编辑和运行 Java 代码。如果你还没有设置你的开发环境,请参考第一章Java 虚拟机(JVM)内部窥视

本章的完成代码可以在以下链接找到:

github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter17

单元测试

软件开发者的格言“测试,测试,再测试”仍然适用于现代系统,但方式更为精细。我们不再测试整个系统,而是关注代码的小组件,以确保它们能够高效且按预期运行。这些组件被称为单元。当我们隔离代码单元时,我们可以更容易地检测到错误并提高代码的整体质量。这种方法被称为单元测试,也是本节的重点。

单元测试

单元测试是一种软件测试方法,它涉及测试系统代码的最小部分,以确保它在独立的情况下能够正确且高效地运行。

单元测试的主要好处包括以下内容:

  • 错误检测:单元测试使我们能够在代码作为更大系统的一部分发布之前早期检测错误。

  • 代码质量:这种具有有限关注点的测试方法导致代码质量更高。

  • 文档:单元测试的过程包括记录每个单元的功能、目的、连接性和依赖关系。

现在我们已经了解了单元测试是什么以及为什么它很重要,让我们看看两个流行的单元测试框架。

框架

最常见的单元测试框架之一是JUnit,可能是因为它的简单性和与集成开发环境IDEs)集成的容易性。另一个流行的框架是TestNG,它比 JUnit 更灵活,并具有 JUnit 之外的功能。

我们将专注于 JUnit,并在下一节中演示如何编写单元测试。

编写单元测试

你可以以多种方式编写和实现单元测试。这里有一个简单的方法:

  1. 确保你已经安装了最新的Java 开发工具包JDK)。

  2. 下载并安装JUnit Jupiter API 和 Engine JARs。完成此过程的方法将取决于你的 IDE。

  3. 假设你正在使用 Visual Studio Code,请安装Java 测试运行器扩展。

为了演示单元测试,我们将编写一个简单的计算器程序,如下所示:

public class CH17Calculator {
  public int add(int a, int b) {
    return a + b;
  }
  public int subtract(int a, int b) {
    return a - b;
  }
  public int multiply(int a, int b) {
    return a * b;
  }
  public int divide(int a, int b) {
    if (b == 0) {
      throw new IllegalArgumentException("Division by zero");
    }
    return a / b;
  }
}

如你所见,我们的应用程序包含基于传递参数添加、减去、乘以和除以两个数字的方法。接下来,让我们创建一个testing类:

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
public class CH17CalculatorTest {
  private CH17Calculator calculator;
  @BeforeEach
  public void setUp() {
    calculator = new CH17Calculator();
  }
  @Test
  public void testAdd() {
    int result = calculator.add(3, 4);
    Assertions.assertEquals(7, result);
  }
  @Test
  public void testSubtract() {
    int result = calculator.subtract(10, 5);
    Assertions.assertEquals(5, result);
  }
  @Test
  public void testMultiply() {
    int result = calculator.multiply(2, 3);
    Assertions.assertEquals(6, result);
  }
  @Test
  public void testDivide() {
    int result = calculator.divide(8, 2);
    Assertions.assertEquals(4, result);
  }
  @Test
  public void testDivideByZero() {
    Assertions.assertThrows(IllegalArgumentException.class, () -> {
      calculator.divide(1, 0);
    });
  }
}

如前述代码所示,我们已为初级类文件中的方法创建了单独的测试方法。下一步是运行测试。使用 Visual Studio Code,你可以选择左侧面板中的测试图标(烧杯)。现在,你可以通过选择每个测试名称右侧的运行测试按钮来运行单个测试或所有测试。

图 17.1 – Visual Studio Code 导航面板

图 17.1 – Visual Studio Code 导航面板

测试结果将在你的testMultiple()方法的底部部分显示:

%TESTC  1 v2
%TSTTREE2,CH17CalculatorTest,true,1,false,1,CH17CalculatorTest,,[engine:junit-jupiter]/[class:CH17CalculatorTest]
%TSTTREE3,testMultiply(CH17CalculatorTest),false,1,false,2,testMultiply(),,[engine:junit-jupiter]/[class:CH17CalculatorTest]/[method:testMultiply()]
%TESTS  3,testMultiply(CH17CalculatorTest)
%TESTE  3,testMultiply(CH17CalculatorTest)
%RUNTIME84

这演示了一个简单的单元测试。接下来,让我们回顾一些最佳实践,以帮助我们充分利用单元测试。

最佳实践

单元测试的一个关键最佳实践是尽量保持测试尽可能小。这将使我们能够更紧密地关注,并允许我们通过代码编辑更快地调试和解决问题。

另一个最佳实践是确保我们的测试是隔离测试,即独立于任何外部因素的测试。这有助于确保我们检测到的任何错误或问题是由我们的代码而不是外部环境引起的。如果我们不采取这种做法,我们可能难以有效地确定错误的来源。

第三个实践是确保我们的测试覆盖了各种场景,包括错误条件和甚至边缘情况。这是一个彻底的方法,旨在测试任何可能的情况。这种方法所花费的额外时间是值得的,因为它可以帮助确保我们的系统在常规和不规则条件下都能正常工作。

第四个最佳实践,断言,将在下一节中介绍。

断言

断言是一个重要的最佳实践。这个最佳实践就是利用断言来验证预期的结果。

断言

断言是用于检查条件是否为真的代码语句。

当一个断言失败时,这表明断言评估的条件是错误的,这通常会导致单元测试失败。在 JUnit 中,我们可以使用几种断言方法。让我们看看四种最常见的断言方法。

assertEquals

Assertions.assertEquals(expected, actual);

示例:

int result = calculator.add(3, 6);
Assertions.assertEquals(9, result, "3 + 6 should equal 9");

assertNotEquals

Assertions.assertNotEquals(unexpected, actual);

示例:

int result = calculator.subtract(24, 8);
Assertions.assertNotEquals(10, result, "24 - 8 should not equal 10");

assertTrue

Assertions.assertTrue(condition)

示例:

boolean result = someCondition();
Assertions.assertTrue(result, "The condition should be true");

assertFalse

Assertions.assertFalse(condition);

示例:

boolean result = someCondition();
Assertions.assertFalse(result, "The condition should be false");

要在我们的应用程序中使用断言,我们只需在每个单元测试中添加一行代码。例如,当你审查我们的CH17CalculatorTest应用程序中的testAdd()方法时,你会看到它使用了assertEquals()断言方法:

public void testAdd() {
  int result = calculator.add(3, 4);
  Assertions.assertEquals(7, result);
}

现在我们已经了解了编写单元测试的一些最佳实践,让我们回顾一些常见的陷阱。

陷阱

尽管单元测试非常强大,但它也伴随着一些陷阱。以下是与单元测试相关的三个常见陷阱以及如何避免它们:

陷阱 避免策略
忽略边缘情况 当我们在单元测试中忽略边缘情况时,我们的系统可能会有未检测到的错误。确保你在单元测试中包含一个健壮的边缘情况策略。
过度测试 在我们的语境中,过度测试是指创建过大、覆盖多个单元的测试。为了避免这个陷阱,创建与外部依赖隔离且专注于单一代码单元的单元测试。
测试不足 测试不足是指没有频繁运行测试以捕捉问题,尤其是在更改环境和扩展系统时。为了避免这个陷阱,要频繁进行测试。

表 17.1 – 单元测试陷阱和避免策略

我们将通过查看测试驱动开发TTD)来结束我们对单元测试的讨论。

TDD

TTD 是一种有趣的软件开发方法,其中单元测试是在编写代码之前编写的。TDD 周期通常被称为红-绿-重构,并在图 17.2中展示。

图 17.2 – TTD 周期

图 17.2 – TTD 周期

TDD 实施从红色步骤开始,我们编写一个失败的测试,因为相关的功能尚未编写。接下来,在绿色步骤中,我们编写所需的最少代码,以便测试通过。最后,在重构步骤中,我们重构代码以提高其效率、改善其可读性,并确保所有相关单元测试继续通过。

实施 TDD 的优势包括以下内容:

  • 促进编写干净的代码

  • 确保代码可测试

  • 帮助开发者在编写代码前充分考虑需求

TDD 方法也有一些挑战,包括以下内容:

  • 它对初学者不友好

  • 这需要思维模式的转变

  • 它可能会减缓初始开发

现在我们已经对单元测试有了牢固的掌握,让我们在下一节中探索性能测试。

性能测试

本书的核心在于确保我们的 Java 应用程序以尽可能高的水平运行。我们已经介绍了多种策略、工具和技术,以帮助我们实现高性能的目标。在前一节中,我们介绍了单元测试,以帮助我们确保功能的正确性。通过性能测试,我们将测试我们的应用程序,看它们是否能在各种条件和负载下运行。这种测试策略包括评估以下应用程序的特性:效率(速度)、稳定性、响应性、可靠性和可扩展性。

性能测试有几个主要目标,包括确定是否满足性能标准。我们还希望识别性能瓶颈,以便我们可以优化代码。另一个目标是确保我们的应用程序可以处理预期的系统和用户负载。

类型与工具

五种主要性能测试类型在下面的表格中详细说明:

类型 重点
耐久性测试 在长时间持续负载下检查内存泄漏和资源耗尽
负载测试 使用特定数量的并发用户测试性能
可扩展性测试 通过添加事务和用户来检查可扩展性
峰值测试 确定应用程序是否能够处理负载的突然增加
压力测试 将负载推过容量,以确定破坏点

表 17.2 – 性能测试类型

实施一个包含每种性能测试类型,并特别关注对你特定应用程序和目标更关键的类型的性能测试计划是很重要的。

有几种工具可以帮助我们进行性能测试。以下表格中列出了三种最常见工具,并包括每个工具的使用案例:

工具 描述 使用案例
Apache Bench 用于基准测试 HTTP 服务的基本命令行工具 简单的 HTTP 服务负载测试
Apache JMeter 开源负载测试工具 支持多种协议的综合测试(即 HTTP、FTP 等)
Gatling 高级开源工具,用于模拟高用户负载 高级负载测试场景

表 17.3 – 性能测试工具

让我们通过查看针对单元测试和性能测试的具体的大图景方法来结束本章。

总体策略

本章的前两节专注于单元测试和性能测试。本节的最后部分考虑了如何将这两种测试类型结合起来形成一个连贯的策略。这种测试的双重性对于我们开发和维护健壮且高效 Java 应用程序的能力至关重要。

我们将首先探讨如何整合这两种测试类型。

整合单元测试和性能测试

我们可以采用一些策略来整合这两种测试类型。第一种方法是并行测试,它涉及并行运行单元测试和性能测试。这种方法可以节省我们的时间。另一种方法是共享测试用例,这可以使我们的测试更加高效。这种方法允许我们利用共享的测试数据和潜在配置。第三种,更高级的策略是使用统一测试框架。这些框架支持两种类型的测试,并确保它们之间的无缝过渡。

无论我们的实现方法如何,我们都想确保我们有全面的测试覆盖。为了实现这一点,我们应该使用工具来测量这两种测试类型的代码覆盖率。这被称为覆盖率分析,有助于我们确保所有关键路径都已测试。我们还应该使用增量测试,通过逐渐增加我们的测试覆盖率,直到所有代码都被测试覆盖。最后,我们应该对我们的测试结果与性能结果进行交叉验证。这种验证用于确认功能准确性以及性能效率的可接受性。

摘要

本章探讨了测试在确保我们的 Java 应用程序可靠性和效率中的关键作用。我们从一个单元测试的介绍开始,强调了其目的、好处以及编写有效测试的最佳实践。然后我们介绍了性能测试,解释了其目标和各种类型,如负载和压力测试。本章最后讨论了将这两种测试类型无缝集成到开发工作流程中的总体策略,强调了统一框架和全面测试覆盖对于提高我们 Java 应用程序整体质量和性能的必要性。

在下一章中,我们将广泛探讨如何利用人工智能AI)工具和技术来确保我们的应用程序尽可能高效,并且它们能够达到最高的性能水平。本章为开发者提供了几个机会,以利用人工智能的力量来提升他们 Java 应用程序的性能。

第十八章:利用人工智能(AI)创建高性能 Java 应用程序

秘密已经揭晓;人工智能AI)已经到来,并且继续几乎在每个行业中引发革命,包括软件开发。AI 已经成为了一场游戏规则的改变者和成功的推动者。它也已经用于帮助开发者显著提高他们的 Java 应用程序的性能。本章旨在帮助 Java 开发者理解和利用 AI 来创建和维护高性能的 Java 应用程序。

本章从 Java 中的 AI 简介开始,涵盖其与实现高性能的相关性,并讨论 AI 的当前和未来方向。接下来,本章将具体探讨如何使用 AI 优化代码、进行性能调优以及使用 机器学习ML)模型来预测性能瓶颈。

我们的覆盖范围包括对 AI 服务和平台的集成的探索。这包括对流行 AI 工具和实现无缝集成的最佳实践的见解。如果我们不对使用 AI 进行高性能 Java 应用程序的伦理和实际考虑进行分析,我们将犯下错误。具体来说,我们将探讨在软件开发中使用 AI 的伦理影响,并探讨相关的实际挑战。我们对伦理考虑的覆盖范围包括确保 AI 驱动系统中的公平性和透明度。

本章涵盖的主要主题如下:

  • Java 中的 AI 简介

  • AI 用于性能优化

  • AI 驱动的监控和维护

  • 与 AI 服务和平台的集成

  • 伦理和实际考虑

技术要求

本章的完成代码可以在以下位置找到:

github.com/PacktPublishing/High-Performance-with-Java/tree/main/Chapter18

Java 中的 AI 简介

所有经验水平的软件开发者都已经将 AI 作为他们工作流程的一部分。与 AI 相关的工具和技术可以显著提高我们开发高性能 Java 应用程序的能力。我们需要考虑两个方面。首先,我们可以使用 AI 工具来帮助我们开发、测试和改进我们的代码。其次,我们可以将 AI 集成到我们的应用程序中,以引入创新并增强功能。

在本节中,我们将探讨 AI 与高性能 Java 应用程序的相关性、Java 中 AI 的当前趋势和 AI 的未来方向。

AI 与高性能 Java 应用程序的相关性

AI 技术的力量是无可否认的。借助 AI 的一个子集 ML(机器学习),我们可以训练模型进行高级预测、分析数据和自动化复杂任务。Java 开发者可以利用这些能力来改善他们的开发、测试和维护 Java 项目。

我们可以通过以下四种关键方法利用 AI 作为我们 Java 开发和系统支持工作的部分:

  • 自动化监控:我们可以使用 AI 来增强传统的监控工具。这可能导致自动检测异常、错误和瓶颈。输出是警报,让我们了解系统的性能,并提供有针对性的重构和优化。使用 AI 进行自动化监控可以最小化停机时间并保护用户体验。

  • 性能优化:AI 可以快速分析大量数据集,这可以帮助我们识别瓶颈。此外,AI 可以根据自己的分析提出优化建议。更进一步,我们可以使用机器学习模型来预测我们代码中哪些部分最有可能引发问题。这使得我们能够主动而不是被动地进行优化。

  • 预测性分析:AI 和机器学习模型正越来越多地被用于各个行业,基于数据集和趋势分析进行预测。对于 Java 开发者来说,这可能表现为预测未来的系统负载和性能问题。这使我们能够就基础设施、扩展和资源分配做出明智的决定。

  • 预测性维护:采用预测性维护模型可以帮助我们准确预测未来硬件和软件的故障。这使我们能够规划、采取预防性维护措施、防止性能下降,并提高系统的整体效率。

现在我们已经了解了如何利用 AI 为高性能 Java 应用程序提供支持,让我们来看看一些与 Java 相关的 AI 当前趋势。

当前趋势

有四个与 AI 相关的趋势正在塑造 Java 开发者如何整合 AI 并利用其力量:

  • 云服务:每个主要的云服务提供商(亚马逊网络服务AWS)、谷歌云微软 Azure)都提供可以与 Java 应用程序集成的 AI 服务。服务提供商之间的模型各不相同,通常提供预构建模型和应用程序编程接口API)以执行复杂任务,如图像识别预测性分析自然语言处理NLP)。

  • 边缘 AI:随着云服务的广泛应用,边缘计算的概念得以实现。这个概念简单来说就是将系统和数据部署在用户附近,以提高响应时间和减少网络延迟。边缘 AI作为边缘计算的一个扩展,涉及在边缘设备上部署 AI 模型。

  • 库和框架:越来越多的 AI 特定库和框架对 Java 开发者变得可用。这些库和框架的目标是简化我们在 Java 应用程序中实现 AI 模型的过程。值得研究的知名库和框架包括以下内容:

    • Apache Spark MLlib

    • 深度 Java 库DJL

    • TensorFlow for Java

  • 透明度:随着我们对 AI 的使用增加,记录 AI 决策是如何做出的需求是可理解的。可解释 AIXAI)要求 AI 决策和过程是透明的。

我们所描述的趋势目前在行业中已经显现。下一节将揭示我们可能在未来看到的情况。

未来方向

我们目前可以从使用 AI 工具和技术中获得的效率和功能令人印象深刻。考虑这些工具和技术可能采取的未来方向以及我们如何可能利用它们来增强我们创建和维护高性能 Java 应用程序的能力是非常令人兴奋的。以下有四个未来趋势:

  • AI 驱动开发工具:随着 AI 工具和技术变得成熟,它们很可能会集成到我们的集成开发环境IDEs)中。可能在未来几年出现一种新的 IDEs,即专门为 AI 开发工具而设计的 IDEs。

  • AI 网络安全:随着新的 AI 工具和技术发布,网络安全的重要性在增加。AI 可以用来检测甚至响应对我们系统的网络安全威胁。这种能力预计在未来几年将增加。

  • 混合模型:类似技术通常独立引入,然后后来结合形成混合模型。例如,增强现实虚拟现实最初是分别引入的,后来形成了一个被称为混合现实的混合模型。这与 AI、机器学习、深度学习和其他相关系统类似。

  • 量子计算:量子计算是一个适合 AI 应用的领域。量子计算的强大计算能力与 AI 的智能相结合,有望彻底改变 AI 及其应用方式。

AI 工具和技术可以增强我们的高性能 Java 工具集。通过理解和利用 AI 技术,我们可以创建不仅高效和可扩展,而且智能和自适应的应用程序。在下一节中,我们将具体探讨 AI 在性能优化方面的应用。

AI 性能优化

正如我们在上一节中讨论的,AI 为开发者提供了增强其性能优化努力并提高其结果的一个令人难以置信的机会。通过采用一套 AI 工具和模型,我们可以在持续改进的心态下有效地改进我们的应用程序。例如,AI 的预测能力可以帮助我们在瓶颈发生之前预测它们。想象一下,在不影响任何系统用户的情况下,更新您的代码以防止瓶颈或资源耗尽。我们不再需要等待低响应速度的投诉或读取日志来查看错误和警报。

接下来,我们将探讨如何使用 AI 进行代码优化和性能微调。

代码优化和性能调整

人工智能在分析现有代码并提供优化见解方面能力很强。这是通过训练机器学习模型来识别低效代码模式、可能导致内存泄漏的代码以及额外的性能问题来实现的。

让我们用一个例子来说明这一点。下面的代码使用了一种低效的数据处理方式。在监控应用程序时可能不会注意到这一点,但如果数据呈指数增长,可能会变得灾难性。以下是低效的代码:

import java.util.ArrayList;
import java.util.List;
public class CH18Example1 {
  public static void main(String[] args) {
    CH18Example1 example = new CH18Example1();
    example.processData();
  }
  public void processData() {
    List<Integer> data = new ArrayList<>();
    for (int i = 0; i < 1000000; i++) {
      data.add(i);
  }
  int sum = 0;
  for (Integer num : data) {
    sum += num;
  }
  System.out.println("Sum: " + sum);
  }
}

下一个提供的是人工智能优化的代码。优化描述紧随代码之后:

import java.util.stream.IntStream;
public class CH18Example2 {
  public static void main(String[] args) {
    CH18Example2 example = new CH18Example2();
    example.processData();
  }
  public void processData() {
    int sum = IntStream.range(0, 1000000).sum();
    System.out.println("Sum: " + sum);
  }
}

使用人工智能,代码在三个方面进行了优化:

  • 优化后的代码不再使用ArrayList。这是一个聪明的改变,因为这种数据结构可能会消耗大量内存并增加处理其内容所需的时间。

  • 代码现在使用IntStream.range来生成整数的范围。它还计算总和,而无需创建一个临时集合来存储。

  • 最后,优化后的代码使用 Java 的IntStream来高效地处理整数范围并执行求和操作。在这里,我们的代码得益于 Stream 的内在优化特性,从而提高了性能。

人工智能能够对我们的简单但低效的代码进行三次优化。想象一下它能为更健壮的应用程序做什么。

接下来,让我们看看机器学习模型如何预测诸如瓶颈等问题。

预测性能瓶颈

机器学习模型可以被训练来预测性能瓶颈。这些模型通过摄入历史性能数据来学习。从那里,它们可以识别可能导致性能瓶颈的模式。为了演示这一点,我们将使用Waikato 环境知识分析WEKA)平台。

WEKA

WEKA 是一个机器学习和数据分析平台。它是免费软件,根据 GNU 通用公共许可证发布。

这里有一个简单的机器学习例子,我们可以用它来预测性能瓶颈:

import weka.classifiers.Classifier;
import weka.core.Instances;
import weka.core.converters.ConverterUtils.DataSource;
public class CH18Example3 {
    public static void main(String[] args) throws Exception {
        DataSource source = new DataSource("path/to/performance_data.
        arff");
        Instances data = source.getDataSet();
        data.setClassIndex(data.numAttributes() - 1);
        Classifier classifier = (Classifier) weka.core.
        SerializationHelper.read("path/to/model.model");
        double prediction = classifier.classifyInstance(data.
        instance(0));
        System.out.println("Predicted Performance: " + prediction);
    }
}

我们使用了 Weka 库来加载历史性能数据,然后使用一个预训练的分类器模型来预测潜在的性能问题。以下是我们的代码可能产生的预期模拟输出:

Predicted Performance: Bottleneck

前面的模拟输出伴随着许多假设。为了提供上下文,让我们假设我们训练了我们的 Weka 模型来将代码分类为正常瓶颈

现在让我们继续审查一个真实的案例研究。

简化案例研究

本节提供了一个简化的案例研究,以帮助说明使用人工智能进行性能优化的实际应用。该案例研究使用了一个在高峰使用时段出现延迟的基于 Java 的 Web 应用程序场景。

  1. 第一阶段:本案例研究的第一阶段涉及数据收集。这包括收集特定的性能指标,包括响应时间、内存和 CPU 使用情况、交易率等。这些数据由开发团队收集,并包括 12 个月的数据。

  2. 第二阶段:现在,开发团队有了 12 个月的性能数据,他们训练了一个机器学习模型来预测潜在的性能瓶颈。这次训练使模型能够识别导致性能下降的数据中的模式。

  3. 第三阶段:在这个阶段,开发团队已经有了数据和训练好的机器学习模型。接下来是实施阶段,他们将现在训练好的模型集成到他们的应用程序监控系统中。当预测到瓶颈时,系统监控会产生警报和优化脚本。这些优化脚本旨在调整资源分配并优化数据库查询。

  4. 第四阶段:这个最终阶段是结果阶段。开发团队成功收集了数据,然后训练了一个机器学习模型并将其应用于他们的监控系统。结果显著,包括响应时间提高了 35%,减速降低了 42%。

本案例研究展示了使用 AI 帮助我们优化性能的实用应用及其可衡量的益处。

我们的下一个小节将专注于利用 AI 的力量来帮助我们监控和维护我们的代码。

AI 驱动的监控和维护

我们在第十六章,“代码监控和维护”中广泛讨论了监控和维护的关键目的。我们可以将这个主题扩展到手动干预和预定义的基准和阈值之外。AI 驱动的监控和维护代表了一种转向主动和高效的方法,该方法使用机器学习和其他 AI 技术来检测异常、预测瓶颈和故障,并自动化响应。

本节将探讨如何利用 AI 进行异常检测、自动化监控、日志记录和警报。我们还将探讨使用预测性维护模型的维护策略。让我们从使用 AI 进行异常检测开始。

异常检测

监控的主要目标之一是检测可能导致重大问题的异常。AI 处理和分析大量数据的能力使其能够在非 AI 工具或人类审查数据时检测到异常。

下面的代码是一个示例 Java 应用程序,它使用 AI 模型进行异常检测。特别是,这个例子查看性能指标:

import org.deeplearning4j.nn.multilayer.MultiLayerNetwork;
import org.deeplearning4j.util.ModelSerializer;
import org.nd4j.linalg.api.ndarray.INDArray;
import org.nd4j.linalg.factory.Nd4j;
public class CH18Example4 {
    public static void main(String[] args) throws Exception {
        MultiLayerNetwork model = ModelSerializer.
        restoreMultiLayerNetwork("path/my_anomaly_model.zip");
        double[] performanceMetrics = {75.0, 85.7, 500, 150};
        INDArray input = Nd4j.create(performanceMetrics);
        INDArray output = model.output(input);
        double anomalyScore = output.getDouble(0);
        System.out.println("Anomaly Detection System Report (ADSR): 
        Anomaly Score: " + anomalyScore);
        if (anomalyScore > 0.3) {
            System.out.println("Anomaly Detection System Report 
            (ADSR): Anomaly detected!");
        } else {
            System.out.println("Anomaly Detection System Report 
            (ADSR): System is operating normally.");
        }
    }
}

我们前面的例子使用神经网络模型来分析性能指标。计算结果产生一个异常分数,输出根据分数变化。这可以帮助我们确定需要审查的区域或触发自动响应。

接下来,让我们看看 AI 如何用于日志记录和警报。

基于 AI 的日志记录

如预期的那样,我们可以使用 AI 来增强我们的日志记录和警报系统。AI 工具可以为我们提供比其他方式更高效和更具上下文的相关警报。让我们看看一个简单的 AI 日志记录系统实现,该系统包括警报功能:

import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
public class CH18Example5 {
    private static final Logger logger = LogManager.
    getLogger(CH18Example5.class);
    public static void main(String[] args) {
        double cpuUsage = 80.0;
        double memoryUsage = 90.0;
        double responseTime = 600;
        if (isAnomalous(cpuUsage, memoryUsage, responseTime)) {
            logger.warn("Anomalous activity detected: CPU Usage: 
            {}, Memory Usage: {}, Response Time: {}", cpuUsage, 
            memoryUsage, responseTime);
        } else {
            logger.info("System operating normally.");
        }
    }
    private static boolean isAnomalous(double cpuUsage, double 
    memoryUsage, double responseTime) {
        return cpuUsage > 75.0 && memoryUsage > 85.0 && responseTime > 
        500;
    }
}

如前例所示,实现简单,导致通知和警告。值得注意的是,这只是为了说明目的。在完整实现中,将集成预训练的机器学习模型来检测异常和不异常的情况。

让我们以探索使用预测性维护模型进行维护策略结束本节。

维护策略

人工智能技术包括我们训练模型来预测维护的能力。这个模型可以用来预测硬件和软件故障,在它们发生之前很久。当我们收到预测通知时,我们可以主动进行硬件和软件维护。

让我们看看一个使用人工智能进行预测性维护的简单程序:

import weka.classifiers.Classifier;
import weka.core.Instances;
import weka.core.converters.ConverterUtils.DataSource;
public class CH18Example6 {
    public static void main(String[] args) throws Exception {
        DataSource source = new DataSource("path/my_maintenance_data.
        arff");
        Instances data = source.getDataSet();
        data.setClassIndex(data.numAttributes() - 1);
        Classifier classifier = (Classifier) weka.core.
        SerializationHelper.read("path/my_maintenance_model.model");
        double prediction = classifier.classifyInstance(data.
        instance(0));
        if (prediction == 1.0) {
            System.out.println("Predictive Maintenance System Report 
            (PMSR): Maintenance required soon.");
        } else {
            System.out.println(" Predictive Maintenance System Report 
            (PMSR): System is operating normally.");
        }
    }
}

我们的示例使用机器学习模型来预测是否需要维护。这是基于历史数据的分析。

如本节所述,人工智能可以为我们提供异常检测的能力,自动化我们的监控,增强我们的日志和警报系统,并预测何时需要硬件和软件维护。在下一节中,我们将简要探讨如何将我们的 Java 应用程序与人工智能服务和平台集成。

AI 集成

人工智能需要大量的计算能力。利用云服务提供商的弹性云计算是常见的。还有提供预构建模型的开源平台。让我们简要了解一下来自三个最大的云服务提供商(AWS、Microsoft Azure 和 Google Cloud)以及两个开源平台(Apache Spark MLlib 和 TensorFlow for Java)的人工智能服务,从三个云服务提供商开始。

  • AWS AI Services:Amazon 的云平台提供了一套完整的 AI 服务,包括用于构建训练模型的SageMaker,用于图像和视频分析的Amazon Rekognition,以及用于自然语言处理的Amazon Comprehend

  • Microsoft Azure AI:Microsoft 提供与 AWS 类似的 AI 工具,包括用于机器学习模型开发的Azure ML,用于创建人工智能聊天机器人的Bot Service,以及包含预构建人工智能功能的Cognitive Services

  • Google Cloud AI:Google Cloud,像 Amazon 和 Microsoft 一样,提供了一套人工智能工具。这些包括AutoML,可用于定制模型训练,Vision AI用于图像识别,以及Natural Language API用于文本分析。

现在,让我们回顾两个开源人工智能平台:

  • Apache Spark MLlib:这是一个可扩展的机器学习库,正如其名所示,是 Apache Spark 的附加组件。这个库包括许多算法,可用于分类、聚类、协同过滤和回归。这些算法都适用于 Java 应用程序。

  • TensorFlow:这是一个专注于数值计算和机器学习的开源库。这个库的一个优点是它提供了Java 绑定,使我们能够在 Java 应用程序中使用机器学习功能。

让我们来看看将 AI 服务集成到我们的 Java 应用中的最佳实践。

最佳实践

从云服务提供商或开源平台集成 AI 服务可能会显得令人畏惧。服务提供商提供了大量的文档来帮助实施。无论你实施哪个平台或库,以下最佳实践都适用:

  • 明确定义你想要用 AI 解决的问题。这种清晰度将有助于确保你的实现既高效又有目的。

  • 确保你用于训练你的机器学习模型的数据是干净且高质量的。你的数据越好(即其质量、准确性、组织越优化),你的机器学习模型就越容易从中学习。

  • 优化你的 AI 操作性能。如前所述,AI 操作计算量很大,因此优化至关重要。

  • 就像其他复杂的软件一样,AI 服务可能会失败(即产生意外的结果或崩溃),所以请确保包含健壮的错误和异常处理。

  • 确保持续监控你的 AI 方法和模块的性能。维护代码,使其保持优化。

接下来,我们将回顾使用 AI 进行软件开发的伦理和实际考虑。

伦理和实际考虑因素

考虑将 AI 纳入我们的 Java 应用中的伦理和实际影响是很重要的。AI 提供的力量很容易使我们屈服,它能显著提高我们应用的效率和性能。但这不应掩盖考虑与数据隐私、公平和透明度相关的挑战的道德义务。

让我们来看看使用 AI 在我们的应用中的一些关键伦理影响。对于每个影响,都提出了一种解决方案:

伦理影响 挑战 解决方案
数据隐私 和安全 AI 模型需要大量数据集,它们可能包含敏感的用户信息。 实施数据匿名化技术。
公平 和偏见 AI 模型可能无意中持续偏见。 使用具有广泛代表性的多样化数据集。
透明度 深度学习网络可能会阻碍对底层理解。 记录并使 AI 模型决策透明。

表 18.1:使用 AI 的伦理影响及建议解决方案

接下来,让我们看看使用 AI 在我们的应用中的一些实际挑战。对于每个挑战,都提出了一种解决方案:

实际挑战 挑战 解决方案
持续学习和维护 AI 模型必须持续更新。 实施用于重新训练模型的自动化管道。
模型可解释性 我们需要理解 AI 模型是如何做出决策的,以便我们可以进行故障排除和调试。 使用可解释的模型并记录模型学习过程。
性能开销 AI 计算量大。 优化 AI 模型,将它们分解成更小的组件模块。
可扩展性 扩展 AI 驱动的应用程序可能会大幅增加开销。 考虑可扩展性进行设计。使用可扩展的框架。

表 18.2:使用 AI 的实际挑战及其建议解决方案

最后,让我们回顾一些确保我们 AI 驱动系统公平性和透明度的战略方法:

  • 清晰的文档:文档对于所有软件开发都是一项良好实践,在实施 AI 模型时尤其重要。记录过程、决策、识别的偏见、策略、限制和变更。

  • 定期审计:一旦将 AI 纳入您的应用程序,定期对您的模型进行审计就很重要。您应检查是否符合伦理标准以及是否存在偏见。这些审计可以手动进行或借助自动化工具进行。

  • 利益相关者参与:内部和外部利益相关者应参与 AI 模型的开发、设计和部署。根据您组织的大小,您可能需要考虑添加以下领域的专家:

    • 领域专家

    • 伦理学家

    • 用户代表

  • 用户教育:无需多言,说明 AI 在应用中的使用方式至关重要。这种透明度建立信任,并且是正确的事情。

将 AI 集成到我们的 Java 应用程序中具有巨大的潜在利益。这也带来了伦理和实际挑战。通过积极应对这些挑战,我们可以创建不仅性能高,而且公平、透明和值得信赖的 AI 驱动系统。这是我们 Java 应用程序中 AI 使用的专业和纯净方法。

摘要

本章探讨了如何将 AI 集成到 Java 应用程序中以提高性能、效率和可靠性。本章涵盖了 Java 中 AI 的介绍,包括 AI 对高性能 Java 应用程序的相关性概述,突出了 AI 的当前趋势和未来方向。我们还研究了 AI 如何用于代码优化和预测性能瓶颈。

本章还探讨了 AI 如何通过异常检测、基于 AI 的日志记录和警报系统来帮助改进监控和维护流程。我们还研究了预测性维护模型的概念。对 AI 服务平台的分析包括 TensorFlow、Apache Spark MLlib、AWS AI 服务和 Google Cloud AI。

我们以对伦理和实际考虑的探讨结束本章,这不仅是对本书的补充,而且是一个重要的最后概念,让您留下深刻印象。我们讨论了使用 AI 的伦理影响,包括数据隐私、公平性、透明度和问责制。讨论了解决方案和最佳实践,以确保负责任和道德的 AI 集成。

通过理解和实施本章中涵盖的 AI 工具和技术,我们能够更好地创建既高效又可扩展、同时符合伦理且值得信赖的高性能应用程序。

后记

您已经到达了本书的结尾,我希望阅读这些章节是您时间的好投资。现在,重要的是要从整体的角度考虑 Java 的高性能,并反思本书中探讨的核心主题和见解。

我们掌握 Java 高性能的道路应该是一个持续的学习和试错过程,以及愿意适应新挑战的意愿。本书中涵盖的概念和技术集合旨在帮助您打下坚实的基础,但真正的精通将在您开始将这些概念应用到您独特的 Java 项目中时到来。

Java 持续发展,当前的性能优化工具和技术非常复杂。从理解 Java 虚拟机、优化数据结构、微调内存管理以及利用高级并发策略中获得的见解对于任何严肃的 Java 开发者来说都是无价的。本书对框架、库、分析工具以及如 AI 等新兴技术的探讨突显了软件开发动态性质的核心,也强调了跟上最新进展的重要性。

对持续改进和性能卓越的奉献精神区分了优秀开发者与杰出开发者。随着您的前进,我鼓励您继续实验,保持好奇心,并且永远不要犹豫深入探究您代码的内部运作。性能优化不仅仅是编写更快的代码;它关乎创建更高效、可扩展和可维护的应用程序,这些应用程序能够满足今天的需求并应对明天的挑战。

感谢您与我一同踏上使用 Java 实现高性能的旅程。我希望这些页面中分享的知识能够赋予您构建卓越 Java 应用程序的能力,并激励您继续拓展可能性的边界。

posted @ 2025-09-10 15:11  绝不原创的飞龙  阅读(2)  评论(0)    收藏  举报