JavaEE8-高性能指南-全-
JavaEE8 高性能指南(全)
原文:
zh.annas-archive.org/md5/addd5c7312b7dbcdfa9f5f214464c6e0译者:飞龙
前言
Java 多年来一直是后端开发的主流技术。其背后的部分原因是它在 API 方面的稳定性高,严格的向后兼容性规则,为任何专业软件提供了一个非常稳定和可靠的骨干,以及其良好的性能。
Java EE 平台为开发者提供了一个灵活且强大的 API,使他们能够更多地关注平台已解决的问题的功能,而不是技术挑战。但这并不意味着你应该盲目使用它,而忘记容器为你所做的工作;这会导致应用程序表现不佳且无响应。
当谈到性能时,Java EE 设计得很好,让你能够获取指标,对你的应用程序进行仪表化,找到任何潜在的瓶颈,提高应用程序的性能以达到预期的服务水平协议(SLAs),并满足你的客户和最终用户。
然而,性能通常被认为是在应用程序完全开发后我们关注的事情。对于独立应用程序来说,这可能是对的,因为它们容易优化,因为它们是自包含的。然而,随着微服务和分布式应用程序的出现,你需要将性能视为一个持续的过程,以避免在之后不得不重新开发你的应用程序。
这就是本书的全部内容——为你提供所需工具,以便能够工作在 Java EE 应用程序的性能上,确保你从 Java EE API 的简单性中受益,而不会滥用它们并遇到意外,确保你提前考虑整个应用程序流程,而不仅仅是整个系统的一个子部分,这可能导致局部优化和全局悲剧,最后,确保你能够持续控制性能,因此在整个开发阶段都能顺利地工作,并成功交付高质量的产品。
本书面向对象
本书的目标受众是必须与 Java 和 Java EE 应用程序一起工作的人,特别是那些在本地或分布式环境中以及生产或开发环境中从事性能主题工作的人。
本书假设你至少熟悉 Java 8 开发和一些 Java EE 概念。你不需要成为 Java EE 专家,但基本理解诸如 Java 代理和拦截等概念将有助于你掌握本书中涵盖的概念。
本书涵盖内容
第一章,货币 - 引用管理器应用,首先解释了如何设置一个基本环境以便能够跟随本书的章节。它还展示了如何开发一个通用的 Java EE 8 应用,你可以在整章中用它来轻松测试每个主题。
第二章,揭开盖子 – 这个 EE 是什么东西?,解释了我们为什么使用 JavaEE 8 容器以及它为开发者带来了什么。它回顾了一些主流技术,并展示了服务器在幕后所做的工作以及它如何在不按设计方式使用时影响性能。
第三章,监控你的应用程序,针对应用程序的多种仪表化。高层次的目标是在每个级别和层面上获取你应用程序的指标,以确保你可以清楚地识别瓶颈并改进应用程序的性能。它使用了几种技术,简单或复杂程度不一,并针对你可以在应用程序上执行的不同工作阶段。
第四章,应用程序优化 – 内存管理和服务器配置,针对你可以使用的配置来优化你的应用程序,而无需修改你的代码。它主要围绕 JVM 内存调整,这在现代平台上可能非常高级,以及 Java EE 服务器配置,这可能值得一看。
第五章,扩大规模 – 线程和影响,专注于 Java EE 8 提供的线程模型。它首先列出你将使用的线程池,如何定义特定于你应用程序的新线程池以及如何与平台集成,最后,它开启了章节的结尾,讨论 Java EE 8 平台中的现代编程模型,这完全改变了你使用线程的方式,使其更加反应灵敏。
第六章,懒散一点;缓存你的数据,处理缓存问题。它首先解释了什么是缓存以及它应该如何帮助你提高性能。然后,它解释了 Java EE 8 如何帮助你依赖 HTTP 级别的缓存以及它是如何与 JCache 规范集成以极大地加快你的代码。
第七章,具备容错性,帮助你设计和编码分布式应用程序,如微服务,以保持你的性能可控和应用程序稳定。总体目标是确保你不会因为依赖的服务而受到不良影响,并且无论他人处于何种状态,你的服务都保持可用。换句话说,它帮助你防止系统某处出现问题时的多米诺效应。
第八章,日志记录器和性能 - 权衡,处理日志记录器。这无疑是所有应用程序中最常用的 API,但它也可能严重影响性能,因为一个使用不当或配置错误的日志记录器可能会对您的性能数据产生重大影响。本章解释了什么是日志框架以及如何使用它。这将帮助您避免所有这些陷阱。
第九章,基准测试您的应用程序,是关于您项目的专用基准项目或阶段。它解释了要使用哪些工具,要经过哪些步骤以确保您的基准测试是有用的,以及如何充分利用基准测试。
第十章,持续性能评估,探讨了确保您能够以连续的方式评估系统性能的几种解决方案。它的目的是确保您可以在每个开发阶段从系统中获取性能数据,并且在最极端的情况下,您甚至可以为项目上的每个 pull request 获取它。本章探讨了某些工具和解决方案,并解释了如何通过一些粘合代码来丰富一些现有的框架,以便进一步发展,而不会在尚未那么常见但非常重要的是具有定义 SLA 的应用程序的主题上受阻。
为了充分利用这本书。
-
需要具备一些 Java 技术的知识才能阅读这本书。
-
在性能调查方面有一些基本经验会很好。
-
虽然不是强制性的,但了解分布式系统的一些知识可以帮助您理解某些部分。
-
即使不是强制性的,也强烈建议您在可以开发和运行 Java 应用程序的计算机上操作。一个设置示例是具有 Linux 或 Windows 的机器,1 GB(建议 2 GB)的内存,双核 CPU 以及至少 1 GB 的可用磁盘空间。
-
能够使用控制台将大大有帮助。
下载示例代码文件。
您可以从www.packtpub.com的账户下载本书的示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便直接将文件通过电子邮件发送给您。
您可以通过以下步骤下载代码文件:
-
在www.packtpub.com登录或注册。
-
选择“支持”标签。
-
点击“代码下载与勘误”。
-
在搜索框中输入书的名称,并遵循屏幕上的说明。
下载文件后,请确保使用最新版本解压缩或提取文件夹:
-
适用于 Windows 的 WinRAR/7-Zip。
-
适用于 Mac 的 Zipeg/iZip/UnRarX。
-
适用于 Linux 的 7-Zip/PeaZip。
本书代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Java-EE-8-High-Performance。我们还有其他来自我们丰富图书和视频目录的代码包,可在github.com/PacktPublishing/找到。查看它们吧!
下载彩色图像
我们还提供了一份包含本书中使用的截图/图表的彩色图像的 PDF 文件。您可以从这里下载:www.packtpub.com/sites/default/files/downloads/JavaEE8HighPerformance_ColorImages.pdf。
使用的约定
本书使用了多种文本约定。
CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。以下是一个示例:“本书的大部分内容将适用于 Web 配置文件服务器,因此我们将我们的应用程序打包为war:”
代码块设置如下:
<packaging>war</packaging>
任何命令行输入或输出都应如下编写:
$ export JAVA_HOME=/home/developer/jdk1.8.0_144
$ export MAVEN_HOME=/home/developer/apache-maven-3.5.0
粗体: 表示新术语、重要单词或你在屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下。以下是一个示例:“从管理面板中选择系统信息。”
警告或重要提示看起来像这样。
技巧和窍门看起来像这样。
联系我们
我们欢迎读者的反馈。
一般反馈: 请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及书籍标题。如果你对本书的任何方面有疑问,请通过questions@packtpub.com给我们发送电子邮件。
勘误: 尽管我们已经尽一切努力确保内容的准确性,但错误仍然可能发生。如果你在这本书中发现了错误,我们将不胜感激,如果你能向我们报告这一点。请访问www.packtpub.com/submit-errata,选择你的书籍,点击勘误提交表单链接,并输入详细信息。
盗版: 如果你在互联网上以任何形式遇到我们作品的非法副本,如果你能提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并附上材料的链接。
如果你有兴趣成为作者: 如果你有一个你擅长的主题,并且你对撰写或为书籍做出贡献感兴趣,请访问authors.packtpub.com。
评论
请留下评论。一旦你阅读并使用了这本书,为什么不在你购买它的网站上留下评论呢?潜在的读者可以看到并使用你的无偏见意见来做出购买决定,Packt 公司可以了解你对产品的看法,我们的作者也可以看到他们对书籍的反馈。谢谢!
如需了解 Packt 的更多信息,请访问 packtpub.com
第一章:货币 – 引用管理器应用程序
在评估和增强您应用程序的性能之前,您确实需要一个应用程序。在本部分中,我们将创建一个小应用程序,我们将用它来展示本书的每个部分。本章的目的不是解释创建 Java EE 应用程序所需的全部步骤。它将给出总体步骤并确保后续步骤的引用将是明显的。
本应用程序的使用案例将是一个微服务,它提供一组网络服务来管理股票和股份。因此,本章将向您介绍应用程序环境:
-
应用程序代码结构
-
数据库设置
-
数据持久化
-
通过 HTTP 暴露数据
-
部署您的应用程序
设置环境
在开始编写代码之前,请确保您有一个准备好的 Java EE 工作环境。我们需要一个 Java 虚拟机 8(JVM 8)以及更具体地说,Java 开发工具包 8(JDK 8)。作为一个快速提醒,Java EE 版本 V 基于 Java 独立版(Java SE)版本 V。您可以在 Oracle 网站上下载 JDK(www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html)。
或者,您可以在 OpenJDK 项目网站上下载 JDK 的 OpenJDK 版本(openjdk.java.net/install/),但我建议您使用 Oracle 版本。我们将在本书的后续章节中讨论这个问题。
不要忘记接受许可协议并选择适合您操作系统的正确发行版(Windows、Linux 或 macOS)。
现在我们有了 JDK,我们需要一个工具来构建我们的应用程序并将其转换为可以在我们的 Java EE 服务器上部署的格式。本书将使用 Apache Maven(maven.apache.org/)来构建应用程序。它可以在 Apache Maven 下载页面上下载(maven.apache.org/download.cgi)。我们需要二进制发行版;Linux 用户必须选择 tar.gz 格式,而 Windows 用户必须选择 .zip 归档。
到目前为止,我们已经拥有了创建我们应用程序所需的一切。您可能希望有一个 集成开发环境(IDE),例如 NetBeans(netbeans.org/)、Eclipse(eclipse.org/ide/)或 Intellij Idea(www.jetbrains.com/idea/)。由于本书更多地关于性能而不是开发,我们不会深入讨论 IDE。如果您需要,只需选择您最熟悉的即可。
为了确保环境已准备就绪,我们将设置变量以定义软件的位置,而无需每次都使用完整的二进制文件或脚本的完整路径。JAVA_HOME 将指向您从 JDK 中提取的文件夹,而 MAVEN_HOME 将指向您从 Apache Maven 存档中提取的文件夹。以下是一个 Linux 的示例(对于 DOS shell,请将 export 替换为 set):
$ export JAVA_HOME=/home/developer/jdk1.8.0_144
$ export MAVEN_HOME=/home/developer/apache-maven-3.5.0
现在,我们需要确保 JDK 和 Maven 工具可用。为此,我们将它们添加到 Linux 的 PATH 和 Windows 的 Path 中:
# On Linux
$ export PATH=$JAVA_HOME/bin:$MAVEN_HOME/bin:$PATH
# On Windows
$ set Path=%JAVA_HOME%\bin;%MAVEN_HOME%\bin;%Path%
您可以通过执行以下命令来验证您的设置:
$ mvn -version
Maven home: /home/developer/apache-maven-3.5.0
Java version: 1.8.0_144, vendor: Oracle Corporation
Java home: /home/developer/jdk1.8.0_144/jre
Default locale: fr_FR, platform encoding: UTF-8
OS name: "linux", version: "4.10.0-32-generic", arch: "amd64", family: "unix"
要运行 Java EE 应用程序,我们还需要一个容器,例如 GlassFish、WildFly、WebSphere Liberty Profile 或 Apache TomEE。由于部署是特定的,Java EE 8 非常新,我们将在这本书中使用 GlassFish。
最后,为了使一切准备就绪,我们将使用数据库。我们将使用 MySQL 作为一个非常常见的案例,但任何其他关系型数据库也可以正常工作。您可以从 dev.mysql.com/downloads/mysql/ 下载 MySQL,但大多数 Linux 发行版都将有一个可安装的软件包。例如,在 Ubuntu 上,您只需执行以下行:
sudo apt install mysql-server
应用程序架构
我们的应用程序将每天导入一些股票报价;然后将其公开,并允许您通过 Web 服务进行更新。
为了实现它,我们将使用标准的 Java EE 架构:
-
持久层将使用 JPA 2.2 并将数据存储在 MySQL 数据库中。
-
服务层将实现业务逻辑并协调持久层。它将依赖于以下内容:
-
Java 事务 API (JTA) 1.2 用于事务性
-
上下文和依赖注入 2.0 (CDI) 用于 控制反转 (IoC)
-
Bean Validation 2.0 用于验证
-
-
前端层将通过 HTTP 公开服务层的一部分。它将依赖于以下内容:
-
JAX-RS 2.1 用于无状态端点
-
WebSocket 1.1 用于有状态通信
-
JSON-B 1.0 用于序列化/反序列化
-
下面是一张总结此结构的图片:

应用程序项目亮点
为了能够创建和运行此应用程序,我们需要设置一个构建工具。对于本书,它将是 Apache Maven;然而,Gradle、Ant 或任何其他替代品也可以完美工作。然后我们将确定应用程序代码的一些关键部分,最后,我们将插入一些数据以确保在调查其性能之前我们的应用程序是可用的。
构建
Java EE 所需的唯一依赖项是 Java EE API:
<dependency>
<groupId>javax</groupId>
<artifactId>javaee-api</artifactId>
<version>${javaee-api.version}</version> <!-- 8.0 -->
<scope>provided</scope>
</dependency>
如果您愿意,您确实可以注册所有单个规范,但这将需要更多的工作来维护与 Java EE 升级同步的列表。因此,通常更倾向于使用捆绑包。
这里,重点是确保提供 API,这意味着它将不会包含在交付物中,并将继承自服务器 API。提供与 API 相关服务的服务器还提供了与内置实现匹配的正确支持的版本和默认值。
自 Java EE 6 以来,Java EE 有两种主要版本:Web 版本和完整版本。Web 版本是一个轻量级版本,与完整版本相比,规格大约少一半。Web 版本仅支持 Web 应用程序和war文件。本书的大部分内容将使用 Web 版本服务器,因此我们将我们的应用程序打包为war:
<packaging>war</packaging>
由于我们需要 Java 8,不要忘记在构建中配置 Java 源和目标版本。这可以通过不同的方式完成,但将maven-compiler-plugin配置如下是一种有效的方法:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.6.1</version>
<configuration>
<source>1.8</source>
<target>1.8</target>
</configuration>
</plugin>
持久层
我们的数据模型将是简单的:一个报价将与一个客户相关联。这意味着一个客户可以查看一组报价,而报价可以被一组客户看到。在用例方面,我们希望能够货币化我们的 API 并让客户为访问某些报价价格付费。为此,我们需要为每个客户创建一种报价的白名单。
JPA 使用一个名为persistence.xml的描述符,位于资源(或 WEB-INF)的 META-INF 存储库中,它定义了EntityManager的实例化方式,这是一个允许操作我们模型的类。以下是我们的应用程序的示例:
<persistence xmlns="http://xmlns.jcp.org/xml/ns/persistence"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="
http://xmlns.jcp.org/xml/ns/persistence
http://xmlns.jcp.org/xml/ns/persistence/persistence_2_2.xsd"
version="2.2">
<persistence-unit name="quote">
<class>com.github.rmannibucau.quote.manager.model.Customer</class>
<class>com.github.rmannibucau.quote.manager.model.Quote</class>
<exclude-unlisted-classes>true</exclude-unlisted-classes>
<properties>
<property name="avax.persistence.schema
-generation.database.action" value="create"/>
</properties>
</persistence-unit>
</persistence>
数据库和 Java 代码之间的链接是通过实体完成的。实体是一个普通的 Java 对象(POJO),它被javax.persistence注解装饰。它们主要定义了数据库和 Java 模型之间的映射。例如,@Id标记了一个必须与数据库标识符匹配的 Java 字段。
下面是我们的Quote实体的一个示例:
@Entity
public class Quote {
@Id
@GeneratedValue
private long id;
private String name;
private double value;
@ManyToMany
private Set<Customer> customers;
// getters/setters
}
这个简单的模型隐式定义了一个具有三个列(ID, NAME, VALUE)(大小写可能取决于数据库)的QUOTE表,以及一个用于管理与CUSTOMER表关系的表,默认命名为QUOTE_CUSTOMER。
同样的精神,我们的Customer实体仅定义了标识符和名称作为列,以及与Quote实体的反向关系:
@Entity
public class Customer {
@Id
@GeneratedValue
private long id;
private String name;
@ManyToMany(mappedBy = "customers")
private Set<Quote> quotes;
// getters/setters
}
在这里重要的是要注意模型中的关系。我们稍后会处理这个问题。
服务层
由于本书的目的是讨论性能而不是如何编写 Java EE 应用程序,我们不会在这里详细说明整个服务层。然而,为了确保我们对所处理内容的共同理解,我们将用一个服务来展示代码。
我们使用 JTA 1.2 和 JPA 2.2 来在数据库和 Java 模型之间建立连接。因此,负责管理Quote持久性的QuoteService组件可以如下所示:
@Transactional
@ApplicationScoped
public class QuoteService {
@PersistenceContext
private EntityManager entityManager;
public Optional<Quote> findByName(final String name) {
return entityManager.createQuery("select q from Quote q where
q.name = :name", Quote.class)
.setParameter("name", name)
.getResultStream()
.findFirst();
}
public Optional<Quote> findById(final long id) {
return Optional.ofNullable(entityManager.find(Quote.class, id));
}
public long countAll() {
return entityManager.createQuery("select count(q) from Quote
q", Number.class)
.getSingleResult()
.longValue();
}
public Quote create(final Quote newQuote) {
entityManager.persist(newQuote);
entityManager.flush();
return newQuote;
}
// ... other methods based on the same model
}
JPA 可能在事务性上下文中使用,也可能不使用,这取决于你进行的操作类型。当你读取数据时,你通常可以在不需要任何事务的情况下完成,直到你需要一些懒加载。然而,当你写入数据(插入/更新/删除实体)时,JPA 需要一个正在运行的事务来执行操作。这是为了确保数据的一致性,但也对代码有一些影响。为了遵守这一要求,并保持一个活跃的事务,我们在方法上使用@Transactional而不是依赖于企业 Java Bean 3.2(EJB 3.2),这样我们就可以重用 CDI(例如@ApplicationScoped,这将避免每次注入时创建一个新的实例)的力量。
我们的查找器非常简单,直接使用EntityManager API。Java 8 在这个代码中带给我们的唯一新功能是使用Optional包装结果的能力,这提供了一种程序化的方式来处理实体是否存在,而不是依赖于空值检查。具体来说,调用者可以使用我们的查找器这样:
final int quoteCount = getCustomer().getCountFor("myquote");
final double quotesPrice = quoteService.findByName("myquote")
.map(quote -> quote.getValue() * quoteCount)
.orElse(0);
这种代码将条件分支隐藏在流畅的 API 之后,这使得代码更加易于表达和阅读,同时 lambda 表达式保持足够的小。
最后,我们在代码中使用了内联查询,而不是像在@NamedQuery API 中那样的静态查询。
JAX-RS 层
如果我们退后一步,思考应用程序将执行哪些操作,我们可以识别出其中的一些:
-
HTTP 通信处理
-
负载(反)序列化
-
路由
-
服务调用
由于关注点分离原则,或者简单地说,由于层与层之间的技术约束,在 JAX-RS/前端层和 CDI/业务层之间使用数据传输对象(DTO)是非常常见的。当然,这个陈述也可以应用于业务子层,但在这个书中,我们只会在 JAX-RS 层这样做。为了在书中使其明显,我们将 JAX-RS 模型前缀为Json。查看以下代码片段:
@JsonbPropertyOrder({"id", "name", "customerCount"})
public class JsonQuote {
private long id;
private String name;
private double value;
@JsonbProperty("customer_count")
private long customerCount;
// getters/setters
}
在这个上下文中,前端层的角色是将大部分逻辑委托给服务层,并将业务模型转换为前端模型(对于许多现代应用来说,这几乎可以看作是 Java 到 JavaScript 的转换):
@Path("quote")
@RequestScoped
public class QuoteResource {
@Inject
private QuoteService quoteService;
@GET
@Path("{id}")
public JsonQuote findById(@PathParam("id") final long id) {
return quoteService.findById(id) // delegation to the business
layer
.map(quote -> { // the model conversion
final JsonQuote json = new JsonQuote();
json.setId(quote.getId());
json.setName(quote.getName());
json.setValue(quote.getValue());
json.setCustomerCount(ofNullable(quote.getCustomers())
.map(Collection::size).orElse(0));
return json;
})
.orElseThrow(() -> new
WebApplicationException(Response.Status.NO_CONTENT));
}
// other methods
}
我们将 JAX-RS 的@ApplicationPath设置为/api,以确保我们的端点部署在/api子上下文中。
WebSocket 层
为什么使用 JAX-RS 和 WebSocket?它们不是有相同的作用吗?实际上并不完全相同,事实上,在同一个应用程序中使用两者变得越来越普遍,即使 WebSocket 仍然相对较新。
JAX-RS(以及更一般地,HTTP/1 和全新的 HTTP/2)通常是面向 Web 应用的。理解这一点,它通常用于需要与所有浏览器兼容的用户界面应用。它也常用于无法假设太多关于网络设置的环境。更具体地说,在无法假设网络设置的环境中,代理将允许 WebSocket 连接正常工作(要么完全阻止它们,要么过早地断开它们)。HTTP 基于的解决方案在许多情况下都很有意义,例如尝试针对一个市场,其中客户端可以用任何语言(Java、Python、Ruby、Go、Node.js 等)开发。事实上,这项技术今天在全球范围内传播,并且与无状态连接配合良好,这使得它更容易上手,因此比 WebSocket 更易于访问,因为 WebSocket 需要客户端开发者进行一些维护。
然而,WebSocket 适合那些有更高性能或反应性约束、需要维护状态以处理业务用例,或者你只是想从服务器推送信息而不需要客户端操作(如轮询)的情况。
当你开始使用如 WebSocket 这样的连接协议时,首先要定义的是你自己的通信协议:你发送/接收的消息格式以及消息的顺序(如果需要)。
我们的 WebSocket 层将负责使客户端能够快速访问报价价格。因此,我们将对客户端的请求做出反应(它将包含我们想要获取价格的报价名称)并且我们将提供两块信息:是否找到了报价以及当前价格(如果存在)。
然后,你需要选择一个格式来准备通过 WebSocket 发送的内容。在这里,选择通常由客户端(服务的消费者)、需求、性能和实现简便性之间的权衡来指导。在我们的案例中,我们将考虑我们的客户端可以用 Java 以及 JavaScript 编写。这就是为什么我们将使用 JSON。
为了总结协议,以下是一个完整的通信往返过程,如图所示:

在我们的案例中,通信协议基于单一的消息类型,因此客户端/服务器通信看起来像以下步骤:
-
客户端将连接到服务器。
-
客户端将根据其符号(名称/标识符)请求报价的价格 N 次。
-
假设没有 I/O 错误或超时,客户端将触发断开连接,从而结束通信。
在代码方面,我们需要多个 Java EE 组件,并且需要以下内容来将它们组合在一起:
-
显然是 WebSocket API
-
JSON-B(我们可以使用 JSON-P,但它不太友好)用于 Java 到 JSON 的转换
-
CDI,用于将 WebSocket 连接到业务层
为了简单起见,我们可以对负载进行建模。我们的请求只有一个name属性,因此 JSON-B 允许我们这样定义它:
public class ValueRequest {
private String name;
// getter/setter
}
在另一边(即响应),我们必须返回一个带有报价价格的value属性和一个标记value是否填充的found布尔值。在这里,JSON-B 也允许我们直接将此模型与一个普通的 POJO 进行映射:
public static class ValueResponse {
private double value;
private boolean found;
// getters/setters
}
现在,我们需要确保 WebSocket 能够按需反序列化和序列化这些对象。规范定义了Encoder和Decoder API 来实现这个目的。由于我们将使用 JSON-B 作为我们的实现后端,我们可以直接使用这些 API 的(I/O)流版本(称为TextStream)来实现它。实际上,在这样做之前,我们需要获取一个Jsonb实例。考虑到我们已经在 CDI 中创建了一个并使其可用,我们可以在我们的编码器中简单地注入该实例:
@Dependent
public class JsonEncoder implements Encoder.TextStream<Object> {
@Inject
private Jsonb jsonb;
@Override
public void encode(final Object o, final Writer writer) throws EncodeException, IOException {
jsonb.toJson(o, writer);
}
// other methods are no-op methods
}
由于 JSON-B API 非常适合这种用法,解码部分现在开发起来很快,我们将注意这一部分是针对ValueRequest的,因为我们需要指定要实例化的类型(与编码部分相比,编码部分可以动态确定它):
@Dependent
public class RequestDecoder implements Decoder.TextStream<ValueRequest> {
@Inject
private Jsonb jsonb;
@Override
public ValueRequest decode(final Reader reader) throws DecodeException, IOException {
return jsonb.fromJson(reader, ValueRequest.class);
}
// other methods are no-op methods
}
现在我们有了处理消息的方法,我们需要绑定我们的 WebSocket 端点并实现@OnMessage方法,以便根据我们的业务层找到价格并将其发送回客户端。在实现方面,我们将响应一个ValueRequest消息,尝试找到相应的报价,填充响应负载,并将其发送回客户端:
@Dependent
@ServerEndpoint(
value = "/quote",
decoders = RequestDecoder.class,
encoders = JsonEncoder.class)
public class DirectQuoteSocket {
@Inject
private QuoteService quoteService;
@OnMessage
public void onMessage(final Session session, final ValueRequest request) {
final Optional<Quote> quote = quoteService.findByName(request.getName());
final ValueResponse response = new ValueResponse();
if (quote.isPresent()) {
response.setFound(true);
response.setValue(quote.get().getValue()); // false
}
if (session.isOpen()) {
try {
session.getBasicRemote().sendObject(response);
}
catch (final EncodeException | IOException e) {
throw new IllegalArgumentException(e);
}
}
}
}
配置一些数据
到目前为止,我们有了我们的应用程序。现在,我们需要确保它有一些数据,然后继续评估其性能。
不深入业务细节,我们将分两步实现配置:
-
找到所有需要更新的符号
-
对于找到的每个符号,更新数据库中的价格
为了做到这一点,我们将使用两个公开的 web 服务:
-
www.cboe.com/publish/ScheduledTask/MktData/cboesymboldir2.csv,以找到一组符号 -
query1.finance.yahoo.com/v10/finance/quoteSummary/{symbol}?modules=financialData,以找到每个报价的当前价格
第一个是普通的 CSV 文件,我们将不使用任何库来解析它,以保持事情简单,因为该格式不需要特殊的转义/解析。第二个将返回一个 JSON 负载,我们可以直接使用 JAX-RS 2.1 客户端 API 读取。
这是我们检索数据的方式:
private String[] getSymbols(final Client client) {
try (final BufferedReader stream = new BufferedReader(
new InputStreamReader(
client.target(symbolIndex)
.request(APPLICATION_OCTET_STREAM_TYPE)
.get(InputStream.class),
StandardCharsets.UTF_8))) {
return stream.lines().skip(2/*comment+header*/)
.map(line -> line.split(","))
.filter(columns -> columns.length > 2 && !columns[1].isEmpty())
.map(columns -> columns[1])
.toArray(String[]::new);
} catch (final IOException e) {
throw new IllegalArgumentException("Can't connect to find symbols", e);
}
}
注意,我们直接读取由 HTTP 响应流支持的缓冲读取器。一旦提取了符号,我们就可以简单地遍历它们并请求每个报价的价格:
try {
final Data data = client.target(financialData)
.resolveTemplate("symbol", symbol)
.request(APPLICATION_JSON_TYPE)
.get(Data.class);
if (!data.hasPrice()) {
LOGGER.warning("Can't retrieve '" + symbol + "'");
return;
}
final double value = data.getQuoteSummary().getResult().get(0)
.getFinancialData().getCurrentPrice().getRaw();
final Quote quote = quoteService.mutate(symbol, quoteOrEmpty ->
quoteOrEmpty.map(q -> {
q.setValue(value);
return q;
}).orElseGet(() -> {
final Quote newQuote = new Quote();
newQuote.setName(symbol);
newQuote.setValue(value);
quoteService.create(newQuote);
return newQuote;
}));
LOGGER.info("Updated quote '" + quote.getName() + "'");
} catch (final WebApplicationException error) {
LOGGER.info("Error getting '" + symbol + "': " + error.getMessage()
+ " (HTTP " + (error.getResponse() == null ? "-" :
error.getResponse().getStatus()) + ")");
}
这段代码通过 JAX-RS 客户端 API 和 JSON-B 发送一个 HTTP 请求,它解包一个数据模型。然后,我们使用获得的数据更新我们的数据库报价,如果它已经存在;否则,我们使用这些数据创建数据库报价。
代码现在需要被连接起来以便执行。我们在这里有多种选择:
-
在启动时执行
-
定期执行
-
当端点被调用时执行
在本书的上下文中,我们将使用前两个选项。启动对我们来说很常见,即使它并不那么现实,因为一旦启动,我们就会得到一些数据。第二个选项将使用 EJB 3.2 的 @Schedule,它将每小时运行一次。
启动实现需要一个简单的 CDI 容器,当创建 @ApplicationScoped(在启动时)时调用之前的逻辑:
@ApplicationScoped
public class InitialProvisioning {
@Inject
private ProvisioningService provisioningService;
public void onStart(@Observes @Initialized(ApplicationScoped.class) final ServletContext context) {
provisioningService.refresh();
}
}
调度是通过企业 Java Bean @Schedule API 完成的,它允许我们通过一个注解请求容器定期执行一个方法:
@Singleton
@Lock(WRITE)
public class DataRefresher {
@Inject
private ProvisioningService provisioningService;
@Schedule(hour = "*", persistent = false, info = "refresh-quotes")
public void refresh() {
provisioningService.refresh();
}
}
在实际应用程序中,你可能希望配置刷新频率并使用 TimerService API 根据应用程序配置触发执行。同样,根据配置可以忽略启动执行以实现更快的启动。
应用程序摘要
在处理性能时,始终需要记住两点:
-
应用程序业务(应用程序做什么)
-
应用程序技术栈(应用程序是如何设计的)
即使你对这两个点的信息非常高级,但在处理性能之前,确保你知道它们。
让我们用我们的应用程序来做这个练习,并确保我们知道如何回答这两个问题。
应用程序业务
我们的应用程序负责向 HTTP 或 WebSocket 客户端提供报价价格。凭借其模型和客户/报价关系,它可以使我们提供(或不对客户提供)价格,如果我们添加权限或规则,例如。在这个阶段,重要的是要看到这两个实体之间存在关系,并且我们的应用程序可以访问这个关系以满足其业务需求并触发关系实体的隐式延迟加载。
数据基于两个外部 HTTP 源(CBOE 和 Yahoo)注入到系统中。第一个提供报价的符号字典,第二个提供价格。
应用程序设计
技术上,报价和价格的提供是异步完成的(不是在客户请求发送时)。它使用 JAX-RS 2.1 客户端检索数据,并尽可能快地将其插入数据库。
访问应用程序是通过 HTTP 或 WebSocket 实现的。在两种情况下,应用程序都使用 JSON 格式进行消息交换。
应用程序服务器
Java EE 定义规范,因此你可以找到几个不同的实现。每个主要供应商都有自己的服务器,但当然,对我们和 Java EE 来说,许多服务器是完全开源的。由于 Java EE 8 非常新,我们将使用 GlassFish,它是参考实现,因此是第一个符合规范(它必须与规范一起发布)的实现。然而,有很多替代方案(如 Apache TomEE、Wildfly、Payara、Liberty Profile 等),它们可能会在未来几个月内出现。
你可以从其网站下载 GlassFish (javaee.github.io/glassfish/download)。我们需要 5.x 版本来针对 Java EE 8,但由于其早期发布,本书的大部分内容将使用之前的版本。
如果你想将其与你的开发环境(以及 Maven)集成,你可以将 GlassFish 仓库添加到 pom.xml 中,如下所示:
<pluginRepository>
<id>maven-java-net</id>
<url>https://maven.java.net/content/groups/promoted/</url>
</pluginRepository>
在添加 GlassFish 插件时,不要忘记指定服务器的版本以覆盖默认版本,现在的默认版本相当老旧:
<plugin> <!-- glassfish.version = 5.0 -->
<groupId>org.glassfish.embedded</groupId>
<artifactId>maven-embedded-glassfish-plugin</artifactId>
<version>3.1.2.2</version>
<configuration>
<app>target/${project.build.finalName}</app>
<port>9090</port>
<contextRoot>${project.artifactId}</contextRoot>
</configuration>
<dependencies>
<dependency>
<groupId>org.glassfish.main.common</groupId>
<artifactId>simple-glassfish-api</artifactId>
<version>${glassfish.version}</version>
</dependency>
<dependency>
<groupId>org.glassfish.main.extras</groupId>
<artifactId>glassfish-embedded-all</artifactId>
<version>${glassfish.version}</version>
</dependency>
</dependencies>
</plugin>
使用这种设置,你可以运行以下命令来打包应用程序为 war 并在 GlassFish 中部署:
$ mvn package embedded-glassfish:run
要关闭服务器,请输入 X 和 ENTER。
测试应用程序
在我们从性能窗口开始工作我们的应用程序之前,让我们先熟悉一下它。我们不会浏览和测试所有端点,而只是检查如何使用 JAX-RS 层和 WebSocket 层获取价格。换句话说,我们将定义我们应用程序的两个客户用例。
此处的目标是确保我们知道如何使用应用程序来编写测试场景。为此,我们将在两个前端(HTTP 和 WebSocket)上手动执行一些请求。
以 JAX-RS 方式获取报价价格
我们之前看到的端点已部署在 /application_context/api/quote/{quoteId} 上,其上下文为网络应用程序,application_context。如果你使用了之前的设置,它很可能是 Maven 项目的 artifact ID。从现在开始,我们假设它是 quote-manager。
这是其中一个报价的返回结果:
$ curl -v http://localhost:9090/quote-manager/api/quote/8
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 9090 (#0)
> GET /quote-manager/api/quote/8 HTTP/1.1
> Host: localhost:9090
> User-Agent: curl/7.52.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: Undefined Product Name - define product and version info in config/branding 0.0.0
< X-Powered-By: Servlet/3.1 JSP/2.3 (Undefined Product Name - define product and version info in config/branding 0.0.0 Java/Oracle Corporation/1.8)
< Content-Type: application/json
< Content-Length: 54
<
* Curl_http_done: called premature == 0
* Connection #0 to host localhost left intact
{"id":8,"name":"JOBS","customer_count":0,"value":59.4}
这类应用程序通常需要一个索引端点来浏览报价(例如,在良好的用户界面或命令行界面中)。在我们的案例中,它是我们的 find all 端点,它通过查询参数支持分页。以下是它的使用方法和返回的数据类型:
$ curl -v http://localhost:9090/quote-manager/api/quote?from=0&to=5
* Trying 127.0.0.1...
* TCP_NODELAY set
* Connected to localhost (127.0.0.1) port 9090 (#0)
> GET /quote-manager/api/quote?from=0 HTTP/1.1
> Host: localhost:9090
> User-Agent: curl/7.52.1
> Accept: */*
>
< HTTP/1.1 200 OK
< Server: Undefined Product Name - define product and version info in config/branding 0.0.0
< X-Powered-By: Servlet/3.1 JSP/2.3 (Undefined Product Name - define product and version info in config/branding 0.0.0 Java/Oracle Corporation/1.8)
< Content-Type: application/json
< Content-Length: 575
<
{"total":10,"items":[{"id":1,"name":"FLWS","customer_count":0,"value":9.0},{"id":2,"name":"VNET","customer_count":0,"value":5.19},{"id":3,"name":"XXII","customer_count":0,"value":2.2},{"id":4,"name":"TWOU","customer_count":0,"value":50.1},{"id":5,"name":"DDD","customer_count":0,"value":12.56},{"id":6,"name":"MMM","customer_count":0,"value":204.32},{"id":7,"name":"WBAI","customer_count":0,"value":10.34},{"id":8,"name":"JOBS","customer_count":0,"value":59.4},{"id":9,"name":"WUBA","customer_count":0,"value":62.63},{"id":10,"name":"CAFD","customer_count":0,"value":14.42}]}
以 WebSocket 方式获取价格
WebSocket 端点部署在 /application_context/quote 上,一些交换可能看起来像以下这样:
connect> ws://localhost:9090/quote-manager/quote
send> {"name":"VNET"}
received< {"found":true,"value":5.19}
send> {"name":"DDD"}
received< {"found":true,"value":12.56}
disconnect>
Connection closed: Close status 1000 (Normal Closure)
在这个通信转储中,有趣的是连接持续了多个请求,并且它基于符号而不是标识符(与之前的 JAX-RS 示例相比)。
设置 MySQL
所有的前一部分在 Glassfish 中都将透明地工作,因为它可以在没有设置的情况下为你提供一个默认数据库,自 Java EE 7 以来就是这样。这个默认数据库是 Glassfish 的 Apache Derby。考虑到我们将很快开始处理性能问题,我们希望有一个最新的生产数据库。为了确保这一点,我们将设置 MySQL。
假设你已经为你的操作系统安装了 MySQL,并且它运行在localhost:3306(默认端口),我们需要创建一个新的数据库。让我们称它为quote_manager:
$ mysql -u root -p
Enter password: ******
...
mysql> create database quote_manager;
Query OK, 1 row affected (0.00 sec)
现在我们有了数据库,我们可以在 Glassfish 中配置它,并让 JPA 2.2 根据我们的模型为我们创建表。为此,我们需要在war包的WEB-INF文件夹中创建glassfish-resources.xml(在 Maven 项目中,将其放在src/main/webapp/WEB-INF中):
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE resources PUBLIC "-//GlassFish.org//DTD GlassFish Application Server 3.1 Resource Definitions//EN"
"http://glassfish.org/dtds/glassfish-resources_1_5.dtd">
<resources>
<jdbc-connection-pool allow-non-component-callers="false"
associate-with-thread="false"
connection-creation-retry-attempts="0"
connection-creation-retry-interval-in-seconds="10"
connection-leak-reclaim="false"
connection-leak-timeout-in-seconds="0"
connection-validation-method="auto-commit"
datasource-classname="com.mysql.jdbc.jdbc2.optional.MysqlDataSource"
fail-all-connections="false"
idle-timeout-in-seconds="300"
is-connection-validation-required="false"
is-isolation-level-guaranteed="true"
lazy-connection-association="false"
lazy-connection-enlistment="false"
match-connections="false"
max-connection-usage-count="0"
max-pool-size="10"
max-wait-time-in-millis="120000"
name="MySQLConnectinoPool"
non-transactional-connections="false"
pool-resize-quantity="2"
res-type="javax.sql.DataSource"
statement-timeout-in-seconds="-1"
steady-pool-size="8"
validate-atmost-once-period-in-seconds="0"
validation-table-name="DUAL" wrap-jdbc-objects="false">
<property name="URL" value="jdbc:mysql://localhost:3306/quote_manager"/>
<property name="User" value="root"/>
<property name="Password" value="password"/>
</jdbc-connection-pool>
<jdbc-resource jndi-name="java:app/jdbc/quote_manager" pool-name="MySQLConnectinoPool" enabled="true"/>
</resources>
或者,你也可以通过代码使用@DataSourceDefinition注解来完成,这比 GlassFish 的具体描述符更便携(这是我们今后将依赖的解决方案):
@DataSourceDefinition(
name = "java:app/jdbc/quote_manager",
className = "com.mysql.jdbc.Driver",
url = "jdbc:mysql://localhost:3306/quote_manager",
user = "root",
password = "password"
)
public class DataSourceConfiguration {
}
如果你重新编译并重新启动服务器,你会看到它已经创建了表,这要归功于我们的persistence.xml配置:
mysql> show tables;
+-------------------------+
| Tables_in_quote_manager |
+-------------------------+
| CUSTOMER |
| QUOTE |
| QUOTE_CUSTOMER |
| SEQUENCE |
+-------------------------+
如果你正在等待服务器启动并且保留了配置激活,你也会在QUOTE表中看到一些数据:
mysql> select * from QUOTE limit 10;
+----+-------+-------+
| ID | NAME | VALUE |
+----+-------+-------+
| 1 | FLWS | 9 |
| 2 | VNET | 5.19 |
| 3 | XXII | 2.2 |
| 4 | TWOU | 50.1 |
| 5 | DDD | 12.56 |
| 6 | MMM | 204.32|
| 7 | WBAI | 10.34 |
| 8 | JOBS | 59.4 |
| 9 | WUBA | 62.63 |
| 10 | CAFD | 14.42 |
+----+-------+-------+
结论
现在我们有了功能齐全的报价管理器应用程序,我们可以将其部署在 Java EE 8 服务器(这里使用 GlassFish)中,并将我们的数据存储在真实数据库(MySQL)中。
到目前为止,我们主要致力于使应用程序功能化。多亏了 Java EE 的高级 API,这并不那么困难,但了解我们使用了什么以及我们堆栈中每个元素的性能影响是很重要的,这样一旦你手头有了这些数据,你就能验证/无效化性能指标。
摘要
在本章中,我们创建了一个负责管理报价价格并允许客户端通过 HTTP 和 WebSockets 访问它们的程序。该应用程序使用纯 Java EE 代码(没有外部依赖)。我们还看到了如何将应用程序链接到数据库。我们使用了 MySQL 作为数据库,这是一个免费且非常常见的选项。
在下一章中,我们将更深入地探讨 Java EE 堆栈,并了解它在应用性能方面的作用及其含义。
第二章:查看底层 – 这个 EE 东西是什么?
Java EE 可以看起来像是一个部署的魔法工具。然而,实际上它只是 Java 代码。本章旨在揭开服务器的面纱,确保您了解您应该从应用程序的性能中期待哪些影响。由于涵盖整个 Java EE 领域相当不可能,本章将处理最常见的模式和主要规范。
在本章中,我们将探讨一些常用的规范,并检查它们的作用以及您应该期待对运行时的哪些影响。最后,您应该能够做到以下几件事情:
-
了解您可以从容器中期望的服务以及与之相关的高级开销
-
评估代码模式是否会影响性能
-
判断您的运行时(Java EE)开销是否正常
上下文和依赖注入 – 你对我的 beans 做了什么?
上下文和依赖注入(CDI)是 Java EE 的核心规范。其作用是 管理 您定义的 bean。它与称为 控制反转(IoC)的模式直接相关,它提供了一种在您的类之间实现松耦合的方法。目标是使方式灵活,以便当前实例相互链接。它还控制实例的生命周期和实例化。
IoC – 一个相当简单的例子
在探索 CDI 之前,让我们用一个非常简单的例子(我会说,一个 手工制作的例子)来说明什么是 bean 容器。
我们将使用一个具有 TimeService 的应用程序,它简单地提供了一个返回当前 LocalDateTime 的 now() 方法。
下面是它在代码中的样子:
public interface TimeService {
LocalDateTime now();
}
一个平凡的实现将依赖于本地的 now() 实现:
public class TimeServiceImpl implements TimeService {
@Override
public LocalDateTime now() {
return LocalDateTime.now();
}
}
但您可能还需要能够切换到模拟(例如,用于测试或另一个客户):
public class MockTimeService implements TimeService {
@Override
public LocalDateTime now() {
return LocalDateTime.of(2017, Month.SEPTEMBER, 4, 19, 0);
}
}
在代码方面,您可能会使用普通的工厂模式实现 switch:
public static class TimeServiceFactory {
public TimeService create() {
if (useDefault()) {
return new TimeServiceImpl();
}
return new MockTimeService();
}
}
然后,您需要在调用者处到处使用工厂,这影响很大,尤其是在您需要向 create() 方法添加参数时。为了解决这个问题,您可以将所有应用程序实例放在一个地方,我们将称之为 Container:
public class Container {
private final Map<Class<?>, Class<?>> instances = new HashMap<>();
public <A, I extends A> Container register(final Class<A> api,
final Class<I> implementation) {
instances.put(api, implementation);
return this;
}
public <T> T get(final Class<T> api) {
try {
return api.cast(
ofNullable(instances.get(api))
.orElseThrow(() -> new
IllegalArgumentException("No bean for api
<" + api.getName() + ">"))
.getConstructor()
.newInstance());
} catch (final Exception e) {
throw new IllegalArgumentException(e);
}
}
}
这是一个非常简单且平凡的实现。但一旦完成,您只需在启动类中注册所有应用程序的 bean,所有代码都将依赖于 Container 来检索实例。换句话说,类的查找是集中的。这也意味着更新更简单:
public class Main {
public static void main(final String[] args) {
final Container container = new Container()
.register(TimeService.class, TimeServiceImpl.class)
/*other registers if needed*/;
final TimeService timeService =
container.get(TimeService.class);
System.out.println(timeService.now());
}
}
在开始处理 CDI 之前,您可以在容器之上添加服务,因为实例是由 Container 创建的。例如,如果您想记录对已注册 API 方法的任何调用,您可以按以下方式更改 get(Class<?>) 方法:
public <T> T get(final Class<T> api) {
try {
final Object serviceInstance = ofNullable(instances.get(api))
.orElseThrow(() -> new IllegalArgumentException("No
bean registered for api <" + api.getName() + ">"))
.getConstructor()
.newInstance();
return api.cast(Proxy.newProxyInstance(api.getClassLoader(),
new Class<?>[]{api}, new LoggingHandler(serviceInstance,
api)));
} catch (final Exception e) {
throw new IllegalArgumentException(e);
}
}
整个逻辑将在LoggingHandler中实现,它将完全用日志调用装饰已注册实例的逻辑。换句话说,对代理实例的每次方法调用都将转发到处理程序:
public class LoggingHandler implements InvocationHandler {
private final Object delegate;
private final Logger logger;
public LoggingHandler(final Object delegate, final Class<?> api) {
this.delegate = delegate;
this.logger = Logger.getLogger(api.getName());
}
@Override
public Object invoke(final Object proxy, final Method method, final
Object[] args) throws Throwable {
logger.info(() -> "Calling " + method.getName());
try {
return method.invoke(delegate, args);
} catch (final InvocationTargetException ite) {
throw ite.getTargetException();
} finally {
logger.info(() -> "Called " + method.getName());
}
}
}
现在,如果你调用TimeService.now(),你将能够观察到相应的输出。使用默认的日志设置,它看起来像这样:
sept. 03, 2017 4:29:27 PM com.github.rmannibucau.container.LoggingHandler invoke
INFOS: Calling now
sept. 03, 2017 4:29:27 PM com.github.rmannibucau.container.LoggingHandler invoke
INFOS: Called now
单独来看,它并不那么有用,但如果你添加一些度量(计时)、参数日志记录等,它可以变得非常整洁。此外,请记住,你可以将添加到代理上的处理程序链接起来。
这对性能意味着什么?嗯,这意味着我们对完全控制的方法(用户方法)的简单调用可以执行真正不同的操作;它将由于Container类而变慢,而不是由于用户代码。如果你对此表示怀疑,请考虑用户方法实现为空且处理程序暂停几分钟的情况。当然,EE 实现不会这样做,但它会在最终用户代码之上添加一些复杂性。
CDI 的主要功能
与我们的小型容器相比,CDI 是一个非常完整的规范,具有许多特性。然而,CDI 的工作方式与容器类似,只是它在启动时扫描classloader应用程序以查找 bean,而不是需要手动注册。
要了解 CDI 如何影响您应用程序的性能,我们将详细说明 CDI 的一些主要功能,解释服务器必须执行的工作以提供它们。
注入
如果你查看我们的报价管理应用程序,你可能已经注意到QuoteService被注入到QuoteResource或DirectQuoteSocket中。我们正好处于 CDI 容器的 IoC 区域。在这里,算法全局看起来如下(伪代码):
Object createInstance() {
Object[] constructorArguments = createConstructorArguments(); <1>
Object instance = createNewInstance(constructorArguments); <2>
for each injected field of (instance) { <3>
field.inject(instance);
}
return prepare(instance); <4>
}
为了履行其角色,CDI 需要实例化一个实例并初始化它。为此,它按照以下步骤进行,从而为您提供现成的实例:
-
CDI 允许从构造函数参数、通过字段注入或通过设置器注入进行注入。因此,在实例化实例之前,CDI 需要解决所需的参数并为每个参数获取一个实例。
-
现在,容器可以提供构造函数参数;它只是从 bean 构造函数创建当前实例。
-
现在容器有一个实例,它会填充其字段/设置器注入。
-
如果需要,实例将被包装在一个代理中,添加所需的服务/处理程序(CDI 语义中的拦截器/装饰器)。
在性能方面,这种逻辑对我们以及我们可以在高性能环境和应用程序中依赖 CDI 的方式有一些影响。现在简单的 bean 实例化需要看起来简单但实际上可能很昂贵的操作,这可能是由于它们必须执行的实际工作,如分配内存或使用元编程,或者由于它们隐藏的复杂性:
-
大多数步骤都涉及到一些反射(即 Java 反射),因此容器必须缓存所有内容以避免在反复检索反射数据时浪费时间。
-
步骤 1 和 步骤 3 可以意味着为其他实例调用
createInstance(),这意味着如果没有注入创建实例的复杂度为 1,那么带有 N 个注入的实例创建复杂度将是1+N。如果 N 个注入有 M 个注入,那么将是1+NxM。
范围
CDI 的一个非常整洁的特性是为你处理范围生命周期。具体来说,你使用@ApplicationScoped和@RequestScoped装饰你的 bean,bean 的生命周期要么绑定到应用程序(它是一个单例),要么绑定到请求持续时间(这意味着你可以有与并发请求一样多的不同实例)。
范围实现被称为上下文,上下文主要负责查找正确的上下文实例或创建它。应用程序范围的实例将在整个应用程序共享的单个映射中查找。然而,请求范围的实例也将通过ServletRequestListener与请求生命周期关联的ThreadLocal中查找。
对性能的影响是相当直接的:
-
上下文设置可能很昂贵(取决于范围)并可能添加一些你可能不需要的开销。实际上,如果你没有
@RequestScopedbean,你不需要ServletRequestListener实例(即使它不是很昂贵)。 -
每次上下文需要时重新创建你的 bean 将触发我们在上一部分看到的进程,以及 bean 的生命周期钩子(
@PostConstruct和@PreDestroy)。
拦截器/装饰器
拦截器是 CDI 在 bean 之上添加自定义处理器的途径。例如,我们的日志处理器在 CDI 中将是这个拦截器:
@Log
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class LoggingInterceptor implements Serializable {
@AroundInvoke
public Object invoke(final InvocationContext context) throws Exception {
final Logger logger = Logger.getLogger(context.getTarget().getClass().getName());
logger.info(() -> "Calling " + context.getMethod().getName());
try {
return context.proceed();
} finally {
logger.info(() -> "Called " + context.getMethod().getName());
}
}
}
装饰器做同样的工作,但它们会根据它们实现的接口自动应用,并注入当前实现。它们不需要绑定(例如,使用@Log将方法放在上面以激活LoggingInterceptor),但它们更具体于一组类型。
在性能方面,拦截器/装饰器显然会增加一些逻辑,因此会增加一些执行时间。但它还增加了一个更恶性的开销:上下文创建。这部分取决于你的服务器使用的 CDI 实现(Weld、OpenWebBeans、CanDI 等)。然而,如果你没有拦截器,容器不需要创建上下文,因此也不需要填充它。大多数上下文创建都很便宜,但getParameter()方法,它代表方法的参数,可能很昂贵,因为它需要将堆栈调用转换为数组。
CDI 实现在这里有多个选择,我们不会逐一介绍。这里需要记住的重要方程是以下内容:
business_code_execution_time + interceptors_code_execution_time < method_execution_time
如果你只有不做什么的拦截器,你通常可以假设容器尽可能正确地处理它。如果你将它与一个你需要手动完成所有工作的框架进行比较,你可能会看到这个开销。
就其本身而言,相关的开销仍然是可接受的,并不大,以至于在维护/复杂性 versus 性能权衡中,你不需要在你的代码中使用拦截器。然而,当你开始添加大量的拦截器时,你需要确保它们也得到了良好的实现。这意味着什么?为了理解,我们需要退后一步看看拦截器是如何使用的。
要将拦截器与实现链接起来,你需要使用我们所说的拦截器绑定,这是你的拦截器的标记注解(用 @InterceptorBinding 装饰)。到目前为止没有大问题,但这个绑定通常包含一些配置,使得拦截器行为可配置。
如果我们使用我们的日志拦截器,日志名称是可配置的:
@InterceptorBinding
@Retention(RUNTIME)
@Target({TYPE, METHOD})
public @interface Log {
/**
* @return the logger name to use to trace the method invocations.
*/
@Nonbinding
String value();
}
现在,LoggingInterceptor 需要获取回值,这个值将被传递给日志工厂以获取我们的拦截器将用于装饰实际豆调用(bean invocation)的日志实例。这意味着我们只需修改之前的实现,如下面的代码片段所示,以尊重日志配置:
@Log("")
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class LoggingInterceptor implements Serializable {
@AroundInvoke
public Object invoke(final InvocationContext context) throws Exception {
final String loggerName = getLoggerName();
final Logger logger = Logger.getLogger(loggerName);
logger.info(() -> "Calling " + context.getMethod().getName());
try {
return context.proceed();
} finally {
logger.info(() -> "Called " + context.getMethod().getName());
}
}
}
所有棘手的部分都在 getLoggerName()。一个坏的和脆弱的实现——因为它依赖于简单的反射而不是 CDI 元模型——但常见的实现如下:
private String getLoggerName(InvocationContext context) {
return ofNullable(context.getMethod().getAnnotation(Log.class))
.orElseGet(() -> context.getTarget().getClass().getAnnotation(Log.class))
.value();
}
为什么它脆弱?因为没有保证类处理(class handling)是有效的,因为你可以得到一个代理实例并忽略类型使用(stereotype usage)。这很糟糕,因为它在每次调用时都使用反射,而 JVM 并没有针对这种使用进行优化。实现者应该只调用一次 getAnnotation。
关于性能,更好的实现是确保我们不在每次调用时都使用反射,而只使用一次,因为 Java 模型(Class 元数据)在一般情况下在运行时不会改变。为了做到这一点,我们可以使用 ConcurrentMap,它将在内存中保存已经计算过的名称,并避免在调用相同方法时重复计算:
private final ConcurrentMap<Method, String> loggerNamePerMethod = new ConcurrentHashMap<>();
private String getLoggerName(InvocationContext context) {
return loggerNamePerMethod.computeIfAbsent(context.getMethod(), m -> ofNullable(m.getAnnotation(Log.class))
.orElseGet(() -> context.getTarget().getClass().getAnnotation(Log.class))
.value());
}
它只是为每个方法缓存日志记录器名称并一次性计算。这样,在第一次调用之后不再涉及反射;相反,我们依赖于缓存。ConcurrentHashMap 是一个很好的候选者,并且与同步结构相比,其开销是可以忽略不计的。
为了快速执行,我们是否只需要确保拦截器缓存元数据?实际上,这还不够。记住,拦截器是具有强制作用域的豆(beans):@Dependent。这个作用域意味着 每次需要时创建。在拦截器的上下文中,这意味着 每次创建被拦截的豆时都创建拦截器的一个实例。
如果你考虑一个 @RequestScoped 豆,那么它的拦截器将为每个请求创建,这将完全违背其目的。
为了解决这个问题,不要在拦截器中缓存,而是在一个@ApplicationScoped的豆中缓存,该豆被注入到拦截器中:
@ApplicationScoped
class Cache {
@Inject
private BeanManager beanManager;
private final ConcurrentMap<Method, String> loggerNamePerMethod = new ConcurrentHashMap<>();
String getLoggerName(final InvocationContext context) {
return loggerNamePerMethod.computeIfAbsent(context.getMethod(), mtd -> {
// as before
});
}
}
@Log("")
@Interceptor
@Priority(Interceptor.Priority.APPLICATION)
public class LoggingInterceptor implements Serializable {
@Inject
private Cache cache;
@AroundInvoke
public Object invoke(final InvocationContext context) throws Exception {
final String loggerName = cache.getLoggerName(context);
final Logger logger = Logger.getLogger(loggerName);
logger.info(() -> "Calling " + context.getMethod().getName());
try {
return context.proceed();
} finally {
logger.info(() -> "Called " + context.getMethod().getName());
}
}
}
这个简单的技巧确保我们的缓存本身是@ApplicationScoped的,因此每个应用程序只计算一次。如果您想确保在运行时根本不计算它,甚至可以强制通过AfterDeploymentValidation事件的观察者中的 CDI 扩展来初始化它(但这会对性能产生较小的影响)。
为了总结这部分,请注意,现在的规范现在依赖于拦截器来提供其功能和集成(安全 API、JTA、JSF、JAX-RS 等)。EJB 规范在 Java EE 7 之前提供 JTA 集成(由@Transactional取代)和安全 API 直到 Java EE 8(由安全 API 取代)。这是一个临时的实现这些集成的实现(如本章开头提到的我们的Container),但它严格等同于拦截器功能的使用。至于性能,这两种实现(EJB 和基于 CDI 的)通常非常接近。
事件
CDI 事件在应用程序中全局提供事件总线。它们可以是同步的或异步的。为了让您有一个概念,以下是一个可能的代码示例:
@ApplicationScoped
public class LifecycleManager {
@Inject
private Event<Starting> startingEvent;
public void starting() {
final Starting event = new Starting();
startingEvent.fire(event);
startingEvent.fireAsync(event);
}
}
由于这两种类型的调用都是互斥的,我们可以在这里注意的是,这些片段调用fire()和fireAsync(). 要能够针对所有观察者,您需要调用两者。这意味着相关的逻辑将是两倍。
不深入到不影响我们性能的细节,这两种情况具有相同的解析机制:
-
根据事件类型解决观察者。
-
移除与火灾类型(异步或同步)不匹配的观察者。
-
按优先级排序观察者。
-
处理调用。
同步和异步案例之间的区别是第 4 点。在同步情况下,这意味着调用观察者,而在异步情况下,这意味着异步调用并返回代表所有调用结果的CompletionStage。
影响性能的部分是观察者和调用的解析,这可能需要一些豆类解析。
我们已经看到了豆类解析,所以让我们深入探讨观察者解析。实际上,实现是特定于您所使用的供应商。但是,由于无法使用静态分析来实现这部分,解析是在运行时通过每个事件类型的缓存来完成的。请注意,缓存在很大程度上取决于实现。大多数只会缓存原始类型事件。
这具体意味着,如下所示的无泛型调用,将比实现泛型并强制 CDI 容器进行更多解析的调用要快得多:
event.fire(new MyEvent());
在代码方面,为了让您与前面的示例进行比较,具有泛型的代码将完全相同,只是事件将是参数化的:
event.fire(new MyEvent<String>());
然后,一旦你有了潜在的观察者集,你需要根据调用者为事件配置的限定符来减少这个集合。这也意味着需要一些反射,多多少少是缓存的,具体取决于实现。
最后,一些运行时检查是通过供应商必须通过的测试集强制执行的,这样我们就可以声称符合规范。
所有这些步骤都由供应商根据他们可能收到的投诉进行不同程度的优化。但在所有这些中,你可能会遇到在运行时为每个事件的触发执行所有操作的代码路径,这在性能方面可能会成为一个痛点。
动态查找
CDI 的另一个重要特性是能够控制 Bean 的懒加载或解析。这是通过Provider<?>和Instance<?> API 实现的。Instance是一个Provider,允许你在运行时解析一个 Bean。Provider是一个实例包装器,允许你决定何时实例化底层实例。
看一下下面的代码片段:
@ApplicationScoped
public class DynamicInstance {
@Inject
private Provider<MyService> myServiceProvider;
@Inject
private Instance<MyService> myServices;
public MyService currentService() {
return myServiceProvider.get(); <1>
}
public MyService newService(final Annotation qualifier) {
return myServices.select(qualifier).get(); <2>
}
}
让我们看看前面代码片段的底层机制:
-
调用
Provider.get()将触发底层实例(这里为MyService)的创建。它延迟了注入或实例化的实例化,使其条件化。请注意,这取决于 Bean 的作用域,并且一个正常作用域的 Bean 不会从这种使用中获得太多好处。 -
调用
Instance.select(...)将使 Bean 定义根据注入点变得更加具体。在这种情况下,我们从一个具有隐式@Default限定符的 Bean 类型(MyService*)开始,并用作为参数传递的限定符替换隐式限定符。然后,我们解析 Bean 并获取其实例。这对于动态和条件性地切换实现是有用的。
由于Instance是一个Provider,它们的实现共享相同的代码,这意味着它们的性能将是相同的。
现在的问题是,使用程序化查找与直接注入相比的成本是什么?是更贵还是不贵?从实现的角度来看,代码相当相似,它必须解析 Bean 以进行实例化,然后实例化它,这样我们就可以非常接近注入。我们将忽略那些对性能影响不大的小差异。这里有一个问题是它的使用:如果你注入了一个Provider并为其每次使用进行解析,那么你将大大增加在解析和实例化与仅使用已解析和创建的实例之间花费的时间。
JAX-RS – servlet 路由器
即使 JAX-RS 并非完全绑定到 HTTP,并且可以在 JMS、WebSockets 等上使用,我们在这里只考虑 HTTP 的情况,尤其是它运行在 servlet 规范之上(这是最常见的情况)。
JAX-RS 的目标是提供基于 API 的命令模式以实现 HTTP 通信。换句话说,它通过 Java 模型抽象 I/O。你可以将其视为一个 HTTP Java 对象绑定解决方案。这就是QuoteResource所使用的。
JAX-RS 的作用是提供所有必要的工具,使 servlet 抽象在大多数情况下可以直接使用。为此,它提供了以下功能:
-
一个路由层,允许开发者直接根据请求的路径映射请求
-
一个序列化层,允许将 Java 对象转换为 HTTP 模型和流
-
一个异常处理层,可以将异常映射到 HTTP 响应
路由器
JAX-RS 是面向命令的。这意味着请求必须绑定到一个 Java 方法。为此,匹配会考虑请求的多个参数:
-
补丁
-
Accept 头
-
Content-Type 头
这里是路由的简化算法:
-
根据路径找到匹配请求的类(这是一个类似于正则表达式的逻辑)。
-
从步骤 1中找到的类中,根据路径找到匹配请求的方法。(这类似于步骤 1,但应用于具有子资源处理的方法。)
-
从步骤 2中找到的方法,根据 MIME 类型(Accept/Content-Type 头)来处理请求。这一级别解析媒体类型以处理头部的服务质量选项(q、qs 等)。
这不是一个复杂的算法,但它相当动态,并且取决于传入的请求。因此,大多数情况下,它由提供者在运行时完成,可能会增加一点开销,这在基准测试中可以注意到。
Marshalling
(反)序列化是将 Java 对象写入通信格式的过程。它通常是将对象转换为 XML 或 JSON 有效负载的部分,但实际上可以是任何格式,包括二进制格式。
这种转换在实现中通常是同步的,并且根据你使用的模型和激活的序列化器,可能会很昂贵。与你自己序列化想要读取/返回的有效负载的 servlet API 相比,这里任务由框架完成,因此有点隐藏。
在这个阶段的一个关键点是确保被操作的对象几乎没有逻辑,并且初始化/读取速度快。如果你不遵守这个点,你可能会长时间保持 HTTP 流,这会严重影响你的可伸缩性。在更普遍的做法中,你可能会面临一些 JPA 的懒加载数据,这可能会失败或根据 JPA 提供程序和配置导致意外的连接使用。另一个坏情况是在开始写入之前,先计算一些昂贵的值,然后再继续写入,因此迫使序列化过程暂停并延迟写入。这不仅对请求线程池有直接影响,也对 HTTP I/O 有影响。
与用于匹配要调用的方法(参见上一部分)的算法精神相同,JAX-RS 运行时必须解析要使用的提供者(根据您是读取还是写入,使用MessageBodyReader或MessageBodyWriter),以便与 Java 模型建立联系。在这里,这种解析同样依赖于传入的请求(或正在构建的响应)和媒体类型头,即使它是可缓存的并且通常很快,但也不像预期的那样平坦。
过滤器和拦截器
JAX-RS 2.0 添加了ContainerRequestFilter和ContainerResponseFilter来修改请求上下文。它在方法调用周围执行,但已经通过了方法解析。从高层次来看,它可以被视为 CDI 拦截器,但仅限于 HTTP 层。这些过滤器在执行大量逻辑之前不会显著影响性能,而且有几个地方是放置逻辑的好位置。一个非常常见的例子是根据 HTTP 头验证安全令牌或登录用户。在调查应用程序正在做什么时,不要对看到这种组件感到惊讶。
同样地,ReaderInterceptor和WriterInterceptor拦截MessageBodyReader或MessageBodyWriter. 它们的目的是封装输入/输出流以添加一些支持,例如 GZIP 压缩。然而,由于我们接近当前的 I/O,如果负载很大或算法复杂,我们需要注意不要在这里添加太多逻辑。实际上,由于流操作被频繁调用,一个实现不当的包装器可能会影响性能。
@Suspended 或异步操作
JAX-RS 2.1 获得了一个全新的反应式 API 来与 Java 8 CompletionStage 集成,但服务器也有一个很好的集成来变得反应式:@Suspended。例如,QuoteResource的findAll方法可能看起来如下:
@Path("quote")
@RequestScoped
public class QuoteResource {
@Inject
private QuoteService quoteService;
@Resource
private ManagedExecutorService managedExecutorService;
@GET
public void findAll(@Suspended final AsyncResponse response, <1>
@QueryParam("from") @DefaultValue("0") final int from,
@QueryParam("to") @DefaultValue("10") final int to) {
managedExecutorService.execute(() -> { <2>
try {
final long total = quoteService.countAll();
final List<JsonQuote> items = quoteService.findAll(from, to)
.map(quote -> {
final JsonQuote json = new JsonQuote();
json.setId(quote.getId());
json.setName(quote.getName());
json.setValue(quote.getValue());
json.setCustomerCount(ofNullable(quote.getCustomers())
.map(Collection::size).orElse(0));
return json;
})
.collect(toList());
final JsonQuotePage page = new JsonQuotePage();
page.setItems(items);
page.setTotal(total);
response.resume(page); <3>
} catch (final RuntimeException re) {
response.resume(re); <3>
}
});
}
// ...
}
在 JAX-RS 方法的同步版本中,返回的实例是响应负载。然而,当变为异步时,返回的实例在 JAX-RS 2.0 中不再用作负载;唯一的选择是使用AsyncResponse JAX-RS API 来让容器通知请求处理的状况。自从 JAX-RS 2.1(Java EE 8)以来,你也可以返回一个 Java 8 CompletionStage 实例,这为你提供了相同的钩子,服务器可以与之集成以通知调用的成功或失败。在任何情况下,这两种 API 都隐含了相同类型的逻辑:
-
@Suspended注解标记了一个AsyncResponse类型的参数以供注入。这是您用来通知 JAX-RS 您已完成执行并让 JAX-RS 恢复 HTTP 请求的回调持有者。如果您使用CompletionStageAPI 版本,您不需要此参数,可以直接几乎以相同的方式使用您的CompletionStage实例。 -
当响应的计算是异步的时候,这种异步 API 是有意义的。因此,我们需要在线程池中提交任务。在 EE 8 中,正确完成这个任务的最佳方式是依赖于 EE 并发实用工具 API,因此使用
ManagedExecutorService。 -
一旦计算完成,使用
resume()发送响应(正常有效载荷或throwable),这将使用ExceptionMappers将其转换为有效载荷。
使用这种模式,你需要考虑到除了 HTTP 线程池之外,还有一个其他的线程池。它将在不同层面上产生影响,我们将在稍后处理,但一个重要的观点是,增加线程数量并不一定意味着在所有情况下都能提高性能,对于快速执行,你甚至可以降低性能。
JPA – 数据库链接
Java 持久化 API(JPA)是数据库的链接(对于我们在第一章中创建的报价应用,是 MySQL)。它的目标是使应用程序能够将数据库模型映射到 Java 对象。好处是我们可以像使用任何对象一样使用数据库。
例如,考虑以下表格,它与我们在第一章中创建的报价应用在数据库中的表示相匹配:

前面的表格可以通过 JPA 注解转换为以下 Java 对象:

虽然表是 扁平的,但在 JPA 中映射它们相当直接,但随着模型复杂性的增加,你会越来越多地意识到两个对立的世界:构建一个优秀的 Java 模型可能会导致一个糟糕的数据库模型,或者相反。为什么?因为它们并不完全共享相同的哲学,可能会导致一些反模式。
例如,在我们的模型中,我们将 Quote 与 Customer 映射链接起来。由于一个客户可以有多个报价(反之亦然),我们使用了 @ManyToMany 关系。如果你检查由 JPA 生成的数据库,你可能会惊讶地看到一个没有模型化的表:

如果你打开 QUOTE_CUSTOMER 表模型,它相当简单:

如你所见,它只是在 QUOTE 和 CUSTOMER 表之间建立了一个链接。这是我们会在数据库端手动做的,除了我们会模型化这个表(它不会是隐式的)并且可能添加一些由关系拥有的属性(这是我们当前 Java 模型无法做到的)。
当然,你总是可以模型化这个连接表,并通过 @ManyToOne 关系将其链接到 Quote 和 Customer,如果你需要更多的灵活性或者更接近数据库模型的话。
这个例子在两个层面上都很有趣:
-
由于中间存在这个连接表,JPA 提供商会如何获取客户的报价呢?
-
模型是对称的:客户可以获取他们可以访问的报价,我们可以从报价中访问允许的客户。在 Java 中,它将通过
quote.getCustomers()和customer.getQuotes()来翻译。它们是否做的是同一件事?在性能方面它们是否相似?在 Java 中,它们看起来真的很相似,对吧?
要深入了解提供者的作用,我们必须首先检查提供者如何使用一些与对象相关的代码和查询语言在数据库端实际工作,而数据库端使用的是不同的范式。为此,我们将首先研究我们的 Java 代码是如何转换为原生 SQL 的,然后检查建模如何影响性能。
从 JPA 到数据库
JPA 让您用纯 Java 表示您的数据库。换句话说,它让您将关系模型表示为对象模型。这在开发和维护中非常常见,但在某个时候,尤其是在您将验证性能时,您将需要检查映射器(JPA 实现)正在做什么,以及它是如何将对象代码/模型转换为关系模型的(SQL)。
当您检查 JPA 调用代码时,您通常会有以下内容:
final Quote quote = entityManager.find(Quote.class, id);
....
entityManager.persist(quote);
对于更复杂的查询,它看起来像以下这样:
final Number count = entityManager.createQuery("select count(q) from Quote q", Number.class);
我不会在本部分讨论命名查询与这种查询之间的区别,但重要的是,该模型是基于对象/Java 的。即使是 JPQL 查询也是与对象相关,而不是纯 SQL。
这导致了 JPA 提供者的主要作用:将所有代码从对象/Java 模型转换为关系/SQL 模型。
为了理解这一点,我们将配置我们的服务器上的 JPA 提供者以记录它所做的工作。由于我们使用 GlassFish,我们需要配置 Eclipselink,它是 JPA 提供者。为此,我们只需在持久化单元中添加以下属性:
<property name="eclipselink.logging.level" value="FINEST"/>
<property name="eclipselink.logging.logger" value="JavaLogger"/>
此配置将激活 Eclipselink 在日志记录器的FINEST级别记录大量信息。要查看这些信息,我们需要确保FINEST日志级别被记录在某处,而不是默认跳过。为此,您需要将 Eclipselink 日志记录器级别配置为FINEST。这样,Eclipselink 将以日志记录器输出的级别进行记录。您可以在 GlassFish 中这样做,将以下行添加到您的logging.properties文件中:
org.eclipse.persistence.level = FINEST
注意,如果我们使用在第一章中设置的 maven 插件Money – The Quote Manager Application来运行 GlassFish,它将回退到 JVM 的logging.properties,您需要修改$JAVA_HOME/jre/lib/logging.properties或启动服务器时设置另一个配置。以下是激活控制台日志记录的潜在内容:
# output configuration - console here
java.util.logging.ConsoleHandler.level = FINEST
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
# global configuration (default)
.level = INFO
.handlers = java.util.logging.ConsoleHandler
# eclipselink specific logging level
org.eclipse.persistence.level = FINEST
最后,在启动服务器时使用此文件,只需设置系统属性java.util.logging.config.file(假设您将文件放在src/main/glassfish/conf/logging.properties),如下所示:
MAVEN_OPTS="-Djava.util.logging.config.file=src/main/glassfish/conf/logging.properties" mvn package embedded-glassfish:run
日志记录器名称使用以下模式:
org.eclipse.persistence.session./file:<path to the webapp>/WEB-INF/classes/_<entity simple name in lowercase>.[sql|query]
现在,如果您启动服务器,您将看到更多几行:
...
Sep 09, 2017 5:21:51 PM org.eclipse.persistence.session./file:/home/rmannibucau/dev/quote-manager/target/quote-manager-1.0-SNAPSHOT/WEB-INF/classes/_quote.sql
FINE: SELECT ID, NAME, VALUE FROM QUOTE WHERE (NAME = ?)
bind => [1 parameter bound]
...
Sep 09, 2017 5:41:53 PM org.eclipse.persistence.session./file:/home/rmannibucau/dev/quote-manager/target/quote-manager-1.0-SNAPSHOT/WEB-INF/classes/_quote.sql
FINE: INSERT INTO QUOTE (ID, NAME, VALUE) VALUES (?, ?, ?)
bind => [3 parameters bound]
....
Sep 09, 2017 5:44:26 PM org.eclipse.persistence.session./file:/home/rmannibucau/dev/quote-manager/target/quote-manager-1.0-SNAPSHOT/WEB-INF/classes/_quote.sql
FINE: SELECT t1.ID, t1.NAME FROM QUOTE_CUSTOMER t0, CUSTOMER t1 WHERE ((t0.quotes_ID = ?) AND (t1.ID = t0.customers_ID))
bind => [1 parameter bound]
这些行是由我们的 JPA 提供商(此处为 EclipseLink)在每次向数据库发出查询时生成的。这些查询使用绑定参数。这在两个层面上都很有趣。第一个层面是关于安全性的,旨在防止 SQL 注入 - 注意出于安全原因,默认情况下不会记录值。如果要将它们记录为绑定参数的数量,可以在持久化单元属性中设置 eclipselink.logging.parameters 为 true。第二个有趣的后果是直接与性能相关,并且提供者可以使用预编译语句而不是每次创建查询时都创建语句。结合可以缓存这些预编译语句的数据源池,与每次需要时才创建它们的实现相比,执行语句的成本非常低。
根据您的 JPA 提供商,您需要更改属性以激活查询日志。例如,Hibernate 和 OpenJPA 使用其他属性和记录器名称。或者,某些容器或 JDBC 驱动程序允许您在另一个级别进行配置。例如,在 Apache TomEE 中,您可以直接在 DataSource 资源中设置 LogSQL=true。
有趣的是看到我们在 Java 中编写的内容对 SQL 侧面的影响。
INSERT 情况很简单,直接将 JPA 模型转换为相应的 SQL 语句,将所有值插入到相应的数据库中:
INSERT INTO QUOTE (ID, NAME, VALUE) VALUES (?, ?, ?)
SELECT 也是一个直接绑定,它通过实体的标识符上的子句选择所有列:
SELECT ID, NAME, VALUE FROM QUOTE WHERE (ID = ?)
在这里,JPA 提供者的作用非常明显;它将 Java 与 SQL 链接起来,这意味着以下内容:
-
将 JPA API 和 JPQL 转换为当前的 SQL。请注意,在所有 JPA 提供商中,都有一个数据库 SQL 语言的概念,以便它们可以处理数据库的特定细节(例如列类型或分页)。EclipseLink 称之为 platform,Hibernate 称之为 dialect,OpenJPA 称之为 dictionary。
-
处理 Java 到数据库的映射:数据库列名转换为字段名,表名转换为类名,等等。
然而,如果您在通过 JAX-RS 端点查询报价时仔细查看日志,可能会感到惊讶:
SELECT t1.ID, t1.NAME FROM QUOTE_CUSTOMER t0, CUSTOMER t1 WHERE ((t0.quotes_ID = ?) AND (t1.ID = t0.customers_ID))
它从哪里来?如果您稍作调查,您会很快在 JAX-RS 层中识别出这一行:
json.setCustomerCount(ofNullable(quote.getCustomers()).map(Collection::size).orElse(0));
它做了什么?它只是设置了与 Quote 链接的客户数量。触发这个附加查询的部分是什么?对关系集合的简单调用触发了它。在我们的例子中,它是 size():
quote.getCustomers().size();
由于 Quote 和 Customer 之间的关系是延迟加载的,这条简单的语句将触发 EclipseLink 的附加查询。有趣的是,如果您检查 JAX-RS 资源,它不是 @Transactional,并且这个查询可能会根据 JPA 提供商而失败,因为延迟处理必须在事务中进行。
提供者足够聪明,不会触发任何查询,只是调用 getCustomers(). 但在调用返回集合的任何方法,如这里的 size() 时,它会这样做。根据提供者,null 可能是可能的,也可能不是,这就是为什么原始代码假设它可以返回 null。
我们将在另一章中讨论建模,但使关系 eager 的明显解决方案并不是真正的解决方案,因为你会慢慢地加载所有对象图,这样做可能会导致性能问题,甚至内存问题。所以尽量抵制这种诱惑。
当你在玩 JPA 和 SQL 时,我建议你禁用 EclipseLink 的默认共享缓存,因为它很容易隐藏查询(稍后我们将讨论为什么即使在生产中也要禁用它)。这可以通过添加以下属性到你的持久化单元中来实现:
<property name="eclipselink.cache.shared.default" value="false"/>
模型和影响
本节并不打算讨论所有情况;其他专注于 JPA 的书籍做得很好。为了避免做可能对性能产生负面影响的事情,这部分将向你展示 JPA 所做的抽象确实需要一些关注。
为了说明这个说法,我们将重用 Customer/Quote 关系。由于它是一个 ManyToMany,它依赖于一个连接表。以下是模型的表示:

用例是当你想要访问关系的另一侧时:来自 客户 (getQuotes()) 的 引用 或相反 (getCustomers().size()).
在这里,提供者将找到所有在连接表中具有当前实体标识符的实体。
这听起来非常合理,但它如何影响性能呢?如果你检查 MySQL 中连接表的结构,你将立即看到一些细微的差异:

quotes_ID 列有一个索引,而 customers_ID 列则没有。不要被图片和事实所欺骗,即两个列都有黄色的键。主键是两个列的组合键,因此索引不是无用的,它允许我们快速从 quotes_ID 中选择行。为什么 quotes_ID 有索引而 customers_ID 没有?因为 Quote 实体是关系的所有者。然而,通过 Quote 标识符选择列总是比通过 Customer 标识符选择列要快。
现在有趣的部分是比较这两个调用:
quote.getCustomers()
customer.getQuotes()
第一次调用将从已加载的引用中加载客户,而第二次调用将加载与已加载客户相关的引用。
现在让我们看看相应的生成的 SQL 将会是什么。第一次调用将被转换为以下语句:
SELECT t1.ID, t1.NAME FROM QUOTE_CUSTOMER t0, CUSTOMER t1 WHERE ((t0.quotes_ID = ?) AND (t1.ID = t0.customers_ID))
第二次调用 (customer.getQuotes()) 将被转换为以下内容:
SELECT t1.ID, t1.NAME, t1.VALUE FROM QUOTE_CUSTOMER t0, QUOTE t1 WHERE ((t0.customers_ID = ?) AND (t1.ID = t0.quotes_ID))
连接是通过关系的已知侧面进行的,这意味着包含关系的实体(实体集)。然而,我们看到了连接表的两个列中只有一个有索引。这意味着一个侧面将比另一个侧面慢。如果您使用双向关系,您应该确保关系的所有者要么是以下之一:
-
那个比另一个使用得多的选项(如果存在巨大差异)
-
那个将返回比另一个更小实体集的选项
这只是一个例子,说明了一个非常快的模型如何影响性能。这是一个适用于任何建模的一般性陈述。无论如何,由于 JPA 使建模变得非常简单,并且与数据库相关的程度不高,因此更容易出错。
Java 事务 API
Java 事务 API(JTA)是负责提供确保您数据在广泛意义上一致性的 API 的元素。在我们的报价管理器中,它仅应用于数据库数据,但可以应用于 JMS 消息,如果使用连接器,则可能应用于文件等。
不深入细节和协议,其想法是在多个系统之间确保要么所有提交要么所有回滚,而不是介于两者之间,以确保系统的致性(这是混合 NoSQL 系统的一个常见问题)。
为了做到这一点,JTA 使用我们所说的两阶段提交协议:
-
要求所有系统准备提交,这意味着系统必须验证并确保它将在下一阶段能够提交
-
要求所有系统实际进行提交
许多事务管理器或服务器针对单个资源进行了优化,以限制所有相关的开销。
在我们的报价管理器应用程序中,我们只有一个数据库,因此我们应该从大多数服务器中受益于这些优化。尽管如此,我们仍然使用 JTA 骨干,不回退到 JPA 事务管理(RESOURCE_LOCAL),后者更快。
在 JTA 中需要了解的重要信息是,事务绑定到一个线程上。每个资源都有其表示和标识符,一个完整的生命周期(参见XAResource)。有一个事务绑定注册表来存储数据(有点像a@TransactionScoped豆)以及与事务生命周期集成的监听器。
所有这些在内存和 CPU 周期方面都不成立,但如果需要,可以证明它是合理的,要么因为您有多个系统,要么因为您使用服务器 JTA 监控(您很少使用带有RESOURCE_LOCAL i*n 管理 UI 的监控)。
服务器资源
在多个层级上,服务器为您的应用程序提供了一些资源。在我们的报价管理器中,我们通过其 JNDI 名称将数据源注入到持久化单元中:
<jta-data-source>java:app/jdbc/quote_manager</jta-data-source>
此数据源也可以在代码的任何其他地方注入:
@Resource(lookup = "java:app/jdbc/quote_manager")
private DataSource datasource;
但服务器管理的资源更多。资源很重要,因为它们由服务器提供和处理,但由应用程序使用。换句话说,这是一种从应用程序外部控制应用程序行为的方式。它使你能够在不关心配置的情况下开发,并在以后调整它或根据部署应用程序的环境对其进行调整。下表列出了最有用的 JavaEE 资源类型子集,这些类型可能会影响你的性能,如果你的应用程序使用其中一些,你可能需要留意。
| 资源类型 | 描述 | 示例 |
|---|---|---|
ManagedExecutorService |
一个 EE ExecutorService,用于确保在自定义异步任务中继承 EE 上下文。对于链接到 JAX-RS @Suspended 或第三方库等非常有用。 |
@Resource
private ManagedExecutorService mes;
|
ManagedScheduledExecutorService |
接近ManagedExecutorService,它重用ScheduledExecutorService API 并添加 EE 集成。 |
|---|
@Resource
private ManagedScheduledExecutorService mses;
|
DataSource |
如前所述,它允许通过提供DataSource实例来连接到数据库。 |
|---|
@Resource
private DataSource ds;
|
XADataSource |
与DataSource相同,但支持两阶段提交。 |
|---|
@Resource
private XADataSource ds;
|
Queue |
JMS 队列,它定义了一个队列类型的目的地。在配置方面,其名称可以区分逻辑名称(应用程序)和实际名称(部署)。 |
|---|
@Resource
private Queue queue;
|
Topic |
与Queue相同,但用于类型为topic的目的地。 |
|---|
@Resource
private Topic topic;
|
ConnectionFactory |
定义了与 JMS 集成的方式以及获取 连接(或 Java EE 7 以来的 JMSContext)。 |
|---|
@Resource
private ConnectionFactory cf;
|
存在其他类型的资源,但这些都是与应用程序外部链接的主要资源,以及与性能相关的配置,如池配置。
数据源配置
为了说明配置,让我们使用在报价管理器中依赖的配置:数据源。如第一章中所示,货币 - 报价管理器应用程序,你可以这样定义数据源:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE resources PUBLIC "-//GlassFish.org//DTD GlassFish Application Server 3.1 Resource Definitions//EN"
"http://glassfish.org/dtds/glassfish-resources_1_5.dtd">
<resources>
<1>
<jdbc-connection-pool allow-non-component-callers="false"
associate-with-thread="false"
connection-creation-retry-attempts="0"
connection-creation-retry-interval-in-seconds="10"
connection-leak-reclaim="false"
connection-leak-timeout-in-seconds="0"
connection-validation-method="auto-commit"
datasource-classname="com.mysql.jdbc.jdbc2.optional.MysqlDataSource"
fail-all-connections="false"
idle-timeout-in-seconds="300"
is-connection-validation-required="false"
is-isolation-level-guaranteed="true"
lazy-connection-association="false"
lazy-connection-enlistment="false"
match-connections="false"
max-connection-usage-count="0"
max-pool-size="10"
max-wait-time-in-millis="120000"
name="MySQLConnectinoPool"
non-transactional-connections="false"
pool-resize-quantity="2"
res-type="javax.sql.DataSource"
statement-timeout-in-seconds="-1"
steady-pool-size="8"
validate-atmost-once-period-in-seconds="0"
validation-table-name="DUAL" wrap-jdbc-objects="false">
<property name="URL" value="jdbc:mysql://localhost:3306/quote_manager"/>
<property name="User" value="root"/>
<property name="Password" value="password"/>
</jdbc-connection-pool>
<2>
<jdbc-resource jndi-name="java:app/jdbc/quote_manager" pool-name="MySQLConnectinoPool" enabled="true"/>
</resources>
此 XML 配置定义了 JPA 提供者将使用的数据源,这得益于两个声明:允许容器创建数据源实例,并允许 JPA 提供者找到此数据源:
-
池定义了如何创建、缓存和验证数据库连接。
-
通过其 JNDI 名称将池与应用程序之间的链接,以便应用程序可以使用它——这就是 JPA 如何查找实例的方式
属性是数据源实例(基于配置的类)的配置,但jdbc-connection-pool属性主要是池配置。
非常重要的是要注意,配置取决于服务器。例如,在 Wildly 中,你会使用这种类型的声明:
<datasources>
<xa-datasource jndi-name="java:jboss/quote_manager" pool-name="QuoteManagerPool">
<driver>mysql</driver>
<xa-datasource-property name="ServerName">localhost</xa-datasource-property>
<xa-datasource-property name="DatabaseName">quote_manager</xa-datasource-property>
<pool>
<min-pool-size>10</min-pool-size>
<max-pool-size>50</max-pool-size>
</pool>
<security>
<user-name>root</user-name>
<password>secret</password>
</security>
<validation>
<valid-connection-checker class-
name="org.jboss.jca.adapters.jdbc.extensions.mysql
.MySQLValidConnectionChecker"></valid-connection-checker>
<exception-sorter class-
name="org.jboss.jca.adapters.jdbc.extensions.mysql.MySQLExceptionSorter"></exception-sorter>
</validation>
</xa-datasource>
<drivers>
<driver name="mysql" module="com.mysql">
<xa-datasource-class>com.mysql.jdbc.jdbc2.optional.MysqlXADataSource</xa-datasource-class>
</driver>
</drivers>
</datasources>
在这里,我们再次发现了一个属性部分和一个池部分。然而,它不再包含属性,而是使用纯标签。
在 Apache TomEE 中,相同的资源声明看起来像:
<Resource id="quote_manager" type="DataSource">
JdbcDriver = com.mysql.jdbc.Driver
JdbcUrl = jdbc:mysql://localhost:3306/quote_manager?tcpKeepAlive=true
UserName = root
Password = secret
ValidationQuery = SELECT 1
ValidationInterval = 30000
NumTestsPerEvictionRun = 5
TimeBetweenEvictionRuns = 30 seconds
TestWhileIdle = true
MaxActive = 50
</Resource>
在这里,配置不是完全基于 XML,但它与属性(如java.util.Properties)混合,这些属性包含池配置和连接信息,这些信息将被传递给 tomcat-jdbc 或 commons-dbcp2 连接池库。
值得注意的是整体思路。大多数服务器共享相同的配置类型,以下是您需要关注的关键配置条目:
| 配置类型 | 描述 |
|---|---|
| 最大池大小 | 池可以创建的连接数。这是一个关键的配置,必须与您在部署和数据库最大连接配置中需要的可扩展性保持一致。 |
| 最大等待时间 | 调用者等待多长时间才能从池中获得超时。对于性能来说,不激活它(0)并不坏,以确保您能够识别出池太小。例如,如果您设置为 10 秒,基准测试可能会变慢,因为所有调用者都在等待连接。 |
| 空闲超时 | 连接空闲时可以保持多少次。 |
| 验证 | 连接如何验证,这对于确保连接在池中保持有效且未损坏非常重要。例如,MySQL 默认情况下会在 8 小时后关闭每个连接,因此如果您的池不更新连接,您将遇到错误。验证类型很重要,因为它通常可以通过后台线程定期执行或在借用或释放连接时主动执行。所有这些都会对一致性和/或性能产生影响,因此这是一个权衡选择,如果您可以依赖您的数据库,那么通常有一个后台驱逐器比一个主动的驱逐器更好。 |
| 最小(或稳定)池大小 | 池应强制执行的最低大小。目标是确保当应用程序空闲并接收到新请求时,它不需要在那个时刻创建连接,而可以重用现有的一个,因为创建连接是一个昂贵的操作。 |
| 初始(或稳定)池大小 | 创建资源时创建的连接数(通常在启动时)。在 GlassFish 中,这合并了最小池大小(steady-pool-size)。 |
关于资源的最后一点是,大多数服务器允许以多种方式配置它们:
-
纯配置文件(通常基于 XML)。
-
一个命令行界面。
-
一个 REST API。
-
一个用户界面。例如,这里是一个 Glassfish JDBC 池配置的截图,您将找到我们讨论的所有参数:

Java EE 和性能
作为提醒,这本书不是关于 Java EE 角色的,所以我们不能详细说明所有规范,但了解 Java EE 是什么以及它的角色对于能够平静地开始工作在 Java EE 性能方面是非常重要的。
很常见,一行小的注释或代码可以隐藏很多逻辑。实体管理器就是一个很好的例子:大部分的方法都隐藏了一些 SQL 生成和执行,这不是一个简单的操作。
随着 CDI 在应用程序中的标准化,对具有简单复杂性的方法的简单调用可能意味着:
-
验证调用(BeanValidation),如果对象图很大,可能会产生影响
-
验证已登录用户及其权限(安全 API),这有时会根据配置和实现与外部系统进行通信
-
集成多个外部系统(JTA)等
所有这些功能都可以使用 CDI 拦截器完成,并且是向方法中虚拟添加的额外逻辑。
确保您了解服务器
因此,在您开始调查应用程序性能之前或在进行性能分析时,了解服务器做了什么对于了解您应该期望的性能至关重要。在运行时,服务器是您应用程序的一部分。这意味着如果服务器有错误(它仍然是一个软件,所以即使经过广泛测试也可能有错误或问题),或者有性能瓶颈,您将直接受到影响。
一些服务器可以嵌入到您的应用程序中,而另一些则不行。然而,在任何情况下,您都需要确保验证您的应用程序(以及您的服务器)以全面了解您的运行时,并在需要时能够对其产生影响。
在这里,您服务器的选择将非常关键。您可能需要问自己,如果服务器出现错误或性能瓶颈,您将如何应对。以下是一些您在基准测试之前或开始开发时可以调查的标准:
| 标准 | 注释 |
|---|---|
| 服务器是否开源? | 如果服务器是开源的,您将能够检查您识别的问题与源代码,并对其进行验证。您还可以使用补丁重新编译它,并且可能不需要等待服务器团队修复问题,而是自己修复它,这在基准测试中如果有一些相关成本(如定位服务器或专用场地)可能非常有意义。 |
| 服务器是否受支持? | 拥有一个您支付以修复性能问题(或错误)的公司可能也很重要。然而,请注意,如果您支付不足,一些服务器可能会非常慢地响应,这在基准测试期间帮助不大。如果您选择这种解决方案,请确保有适当的 SLA 或选择开源解决方案。 |
| 应用程序是否可移植? | 如果应用程序是可移植的,您将能够比较服务器并使用最快的那个。即使自从 Java EE 6 以来这变得更容易,您也需要确保在开发过程中是这种情况。但如果一个服务器版本有性能问题,这可能是值得的。 |
直到最近,Java EE 的哲学是托管应用程序。这就是应用服务器名称的由来。这种意图至今仍然有效,即确保服务器由不同于应用程序的团队管理(通常是运营团队和开发团队)。
然而,随着 Docker 和可嵌入容器(Apache TomEE、Wildfly Swarm、Payara micro 等)的出现,运营责任开始被重新考虑,开发者对服务器的控制越来越多。这意味着您将问自己同样的问题(我如何轻松地修补我的服务器?),但也意味着您需要一位专家开发者,无论是来自您的开发团队还是来自计算机支持公司。
确保您了解您的应用程序
如果之前不够明确,了解服务器为您的应用程序做了什么至关重要。在开发阶段这已经很重要,但当您开始关注性能时,这更是必须的。这意味着您需要足够了解应用程序,以便知道它将使用服务器中的哪部分以及这对您的性能有何影响。
换句话说,您不仅需要完全理解您应用程序的使用案例,还需要了解实现它所使用的技术。一个简单的例子是,如果您的应用程序使用了 JPA 的RESOURCE_LOCAL模式,但您看到很多 JTA 的使用,那么您需要找出原因。如果您没有这种洞察力,您可能会认为应用程序使用了 JTA,并且这是可以的。然而,这种事实可能意味着某些配置不当,这不仅可能影响应用程序的行为,还可能影响其原始性能甚至可扩展性。
了解使用了哪些规范部分也非常重要。为了说明这一点,我们再次使用 JPA。JPA 与 Bean Validation 集成。这意味着每次您持久化/合并一个实体时,该实体都会被验证以确保它通过了模型约束。这是一个很好的特性,但如果您在应用程序的外部(例如 JAX-RS)验证模型,那么您很少(在理论上,如果应用程序做得正确,永远不会)需要在内部重新验证它(JPA)。这意味着 Bean Validation 层在这里是无用的,可以禁用。这个特定的例子是通过更新persistence.xml并在正确的持久化单元中添加validation-mode标签来完成的:
<validation-mode>NONE</validation-mode>
确保您了解您的资源
正确调整资源(数据库、线程池等)至关重要。自 Java EE 6 以来,一些资源可以在应用程序中定义。例如,可以使用以下方式定义DataSource:
@DataSourceDefinition(
name = "java:app/jdbc/quote_manager",
className = "com.mysql.jdbc.Driver",
url = "jdbc:mysql://localhost:3306/quote_manager",
user = "root",
password = "password"
)
public class DataSourceConfiguration {
}
这通常不是一个好主意,因为您无法外部配置它(它是硬编码的)。因此,您通常会结束于在服务器特定的文件或 UI 中配置资源。
这是在应用程序中应该避免的良好实践。但是,在应用程序之外,Java EE 没有定义任何方式或标准来配置服务器。一切都是供应商特定的。然而,你需要调整它!因此,确保你知道这一点至关重要:
-
你的应用程序需要哪些资源
-
如何在服务器中创建它们并配置它们
这对于应用程序来说是一个很好的开始,但资源通常与一个外部方面(如数据库)相关联。在这里,了解资源本身、它的配置以及如果需要的话如何调整它,将非常重要。一个非常简单的例子是数据库上你可以使用的连接数。如果你只能使用 20 个连接,就没有必要在应用程序中配置 100 个,这会生成大量错误并减慢应用程序的速度,或者根据池的配置,甚至可能导致它失败。
摘要
在本章中,你了解到 Java EE 服务器的角色是使应用程序的开发更加容易和快速,提供即插即用的服务和实现。我们浏览了一些常见示例,详细说明了它们在代码和性能方面的含义。我们看到了 JPA 自动、安全、正确地处理语句创建,如果你的代码没有足够接近数据设计,可能会暗示一些未优化的查询。这是一个很好的例子,说明 Java EE 旨在让你尽可能容易地构建最佳应用程序,尽管你需要注意一些点(通常与设计相关),以确保你满足性能要求。
在这一点上,我们有一个应用程序(第一章,货币 - 引用管理器应用程序),我们知道它做什么,以及 Java EE 服务器如何帮助它(本章)。因此,在着手性能之前,我们需要能够对其进行测量。这正是我们下一章将要讨论的内容。
第三章:监控您的应用程序
当涉及到应用程序的性能时,您很快就需要知道应用程序在做什么,并获取性能指标。在本章中,我们将介绍几种获取应用程序洞察的方法。
因此,在本章中,我们将学习如何监控应用程序的行为,以便能够将其与我们观察到的响应时间和执行时间进行比较。因此,这将向您展示以下内容:
-
如何向现有应用程序添加监控或分析
-
如何读取与应用程序监控相对应的重要数据
-
如何确保应用程序性能得到监控,并且任何意外变化都是可见的
Java 工具,了解我的应用程序在做什么
当您将应用程序视为黑盒时,有两个关键因素与性能直接相关:
-
内存使用:如果消耗了过多的内存,它可能会减慢应用程序的速度,甚至使其无法正常工作
-
CPU 时间:如果操作太慢,它将消耗大量的 CPU 周期,并影响整体性能
在不使用太多外部工具(除了Java 开发工具包(JDK)和/或操作系统工具)的情况下,您可以轻松地提取大量信息并开始性能分析工作。
jcmd 命令——这个小巧的命令行实用工具功能强大
自 Java 8 以来,JDK 已经附带jcmd命令,该命令允许您使用与您要检查的实例相同的用户/组在本地 Java 实例上执行命令。
jcmd的使用,尽管基于命令,但相当简单。为了理解它,我们首先将使用我们在第一章中看到的命令启动我们的报价管理器应用程序,Money – The Quote Manager Application:
mvn clean package embedded-glassfish:run
现在,在另一个控制台中,只需执行jcmd。在我的系统中,它将输出以下内容:
$ jcmd
4981 com.intellij.idea.Main
7704 sun.tools.jcmd.JCmd
7577 org.codehaus.plexus.classworlds.launcher.Launcher clean package embedded-glassfish:run
5180 org.jetbrains.idea.maven.server.RemoteMavenServer
第一列是程序的进程 ID(PID),接下来是启动命令(主命令和参数)。由于我们使用 maven 启动了服务器,我们可以通过 maven 主命令(org.codehaus.plexus.classworlds.launcher.Launcher)或与我们所启动的命令完全匹配的参数(clean package embedded-glassfish:run)来识别它。
如果您启动一个独立的 GlassFish,您可能会看到以下类似的行:
7877 com.sun.enterprise.glassfish.bootstrap.ASMain -upgrade false -domaindir /home/dev/glassfish5/glassfish/domains/domain1 -read-stdin true -asadmin-args --host,,,localhost,,,--port,,,4848,,,--secure=false,,,--terse=false,,,--echo=false,,,--interactive=true,,,start-domain,,,--verbose=false,,,--watchdog=false,,,--debug=false,,,--domaindir,,,/home/dev/glassfish5/glassfish/domains,,,domain1 -domainname domain1 -instancename server -type DAS -verbose false -asadmin-classpath /home/dev/glassfish5/glassfish/lib/client/appserver-cli.jar -debug false -asadmin-classname com.sun.enterprise.admin.cli.AdminMain
这个输出相当详细,但您可以识别出主要(第一个字符串)引用了glassfish,并且您可以找到 domains 目录来区分多个实例。
仅为了给您另一个想法,如果您使用 Apache Tomcat 或 TomEE,您将使用以下行来识别它:
8112 org.apache.catalina.startup.Bootstrap start
现在,我们已经有了我们的 Java 进程的 PID;我们可以将其传递给jcmd:
jcmd <PID> help
例如,对于我们之前的 maven GlassFish 实例,它看起来如下所示:
jcmd 7577 help
输出应该看起来如下所示:
7577:
The following commands are available:
JFR.stop
JFR.start
JFR.dump
JFR.check
VM.native_memory
VM.check_commercial_features
VM.unlock_commercial_features
ManagementAgent.stop
ManagementAgent.start_local
ManagementAgent.start
GC.rotate_log
Thread.print
GC.class_stats
GC.class_histogram
GC.heap_dump
GC.run_finalization
GC.run
VM.uptime
VM.flags
VM.system_properties
VM.command_line
VM.version
help
如你所见,输出基本上是一个你可以使用jcmd调用的命令列表。其中许多命令是信息性的,例如VM.version(这将仅记录你正在使用的 JVM),但有些命令是实际的操作,例如GC.run(这将调用System.gc())。关于性能,我们感兴趣的是Thread.print,这是jstack的替代品。GC 数据命令,如GC.class_histogram,与垃圾收集数据相关,而JFR命令与Java 飞行记录器相关。
让我们从最基本但也许也是最重要的命令开始:Thread.print。这将允许我们通过挖掘应用程序的当前线程堆栈来了解应用程序正在做什么。
Thread.print
如果你执行Thread.print命令,输出将看起来像以下内容:
$ jcmd 7577 Thread.print
7577:
2017-09-10 16:39:12
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.144-b01 mixed mode):
"....." #xxx [daemon] prio=xxx os_prio=xxx tix=0x.... nid=0x.... [condition]
java.lang.Thread.State: XXXXX
at ......
at ......
...
"....." #xxx [daemon] prio=xxx os_prio=xxx tix=0x.... nid=0x.... [condition]
java.lang.Thread.State: XXXXX
at ......
at ......
...
"....." #xxx [daemon] prio=xxx os_prio=xxx tix=0x.... nid=0x.... [condition]
java.lang.Thread.State: XXXXX
at ......
at ......
...
由于重现这个命令的完整输出将占用整个章节,它已经被某种形式的线程堆栈的骨架所取代。这里重要的是要识别出每个以引号开头的行开始的块是一个线程。
因此,转储重复了这个模式:
"thread_name" #thread_id_as_int [daemon if the thread is daemon] prio=java_priority os_prio=native_priority tid=thread_id_pointer_format nid=native_id [state]
thread_stack_trace
当服务器空闲时——也就是说,当它没有处理任何请求或执行任何计划中的任务时——我们可以确定大多数线程只是在等待一个任务(在线程池中):
"dol-jar-scanner" #50 daemon prio=5 os_prio=0 tid=0x00007f3b7dd0a000 nid=0x1ddf waiting on condition [0x00007f3ae6bae000]
java.lang.Thread.State: WAITING (parking)
at sun.misc.Unsafe.park(Native Method)
- parking to wait for <0x00000000877529a8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1074)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1134)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:748)
要理解这个转储,你需要了解ExecutorService是如何工作的。它基本上是通过创建带有任务称为Workers的线程来实现的,每个工作可以从队列中获取一些任务(为了简化问题)。在这里我们可以看到以下内容:
-
ThreadPoolExecutor$Work,这意味着我们处于线程池任务处理器中 -
LinkedBlockingQueue.take,这意味着线程正在等待一个新任务
我们也可以在这个转储中识别到 I/O 层的一些传入请求,例如等待一个套接字连接到 NIO Selector:
"http-listener-kernel(1) SelectorRunner" #27 daemon prio=5 os_prio=0 tid=0x00007f3b7cfe7000 nid=0x1dc8 runnable [0x00007f3b1eb7d000]
java.lang.Thread.State: RUNNABLE
at sun.nio.ch.EPollArrayWrapper.epollWait(Native Method)
at sun.nio.ch.EPollArrayWrapper.poll(EPollArrayWrapper.java:269)
at sun.nio.ch.EPollSelectorImpl.doSelect(EPollSelectorImpl.java:93)
at sun.nio.ch.SelectorImpl.lockAndDoSelect(SelectorImpl.java:86)
- locked <0x000000008675cc20> (a sun.nio.ch.Util$3)
- locked <0x000000008675cc10> (a java.util.Collections$UnmodifiableSet)
- locked <0x000000008675c1f8> (a sun.nio.ch.EPollSelectorImpl)
at sun.nio.ch.SelectorImpl.select(SelectorImpl.java:97)
at org.glassfish.grizzly.nio.DefaultSelectorHandler.select(DefaultSelectorHandler.java:115)
at org.glassfish.grizzly.nio.SelectorRunner.doSelect(SelectorRunner.java:339)
at org.glassfish.grizzly.nio.SelectorRunner.run(SelectorRunner.java:279)
at org.glassfish.grizzly.threadpool.AbstractThreadPool$Worker.doWork(AbstractThreadPool.java:593)
at org.glassfish.grizzly.threadpool.AbstractThreadPool$Worker.run(AbstractThreadPool.java:573)
at java.lang.Thread.run(Thread.java:748)
这里重要的一行是epollWait(如果你熟悉操作系统原生)或者Selector*.select(如果你更熟悉 Java 代码的 Java 端,这意味着它在等待一个连接)。
现在,如果我们向我们的应用程序注入一些请求(让我们使用 Apache Bench 或AB来对我们的findById端点执行一些GET请求),我们可以看到一些实际上正在工作的线程。(注意,由于长度较长,为了避免有多个页面的线程堆栈跟踪,[...]已被缩短):
"http-listener(3)" #23 daemon prio=5 os_prio=0 tid=0x00007f3b7d063800 nid=0x1dc4 runnable [0x00007f3b1ef7d000]
java.lang.Thread.State: RUNNABLE
[...]
at com.sun.enterprise.connectors.ConnectionManagerImpl.internalGetConnection(ConnectionManagerImpl.java:254)
[...]
at com.sun.gjc.spi.base.AbstractDataSource.getConnection(AbstractDataSource.java:115)
at org.eclipse.persistence.sessions.JNDIConnector.connect(JNDIConnector.java:135)
[...]
at org.eclipse.persistence.queries.ObjectLevelReadQuery.executeDatabaseQuery(ObjectLevelReadQuery.java:1221)
at org.eclipse.persistence.queries.DatabaseQuery.execute(DatabaseQuery.java:911)
at org.eclipse.persistence.queries.ObjectLevelReadQuery.execute(ObjectLevelReadQuery.java:1180)
at org.eclipse.persistence.queries.ReadAllQuery.execute(ReadAllQuery.java:464)
[...]
at org.eclipse.persistence.indirection.IndirectSet.size(IndirectSet.java:624)
[...]
at java.util.Optional.map(Optional.java:215)
at com.github.rmannibucau.quote.manager.front.QuoteResource.findById(QuoteResource.java:48)
[...]
at org.glassfish.jersey.servlet.WebComponent.service(WebComponent.java:370)
at org.glassfish.jersey.servlet.ServletContainer.service(ServletContainer.java:389)
[...]
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:256)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:160)
[...]
at org.glassfish.grizzly.http.server.HttpHandler.runService(HttpHandler.java:206)
at org.glassfish.grizzly.http.server.HttpHandler.doHandle(HttpHandler.java:180)
[...]
at org.glassfish.grizzly.threadpool.AbstractThreadPool$Worker.doWork(AbstractThreadPool.java:593)
at org.glassfish.grizzly.threadpool.AbstractThreadPool$Worker.run(AbstractThreadPool.java:573)
at java.lang.Thread.run(Thread.java:748)
有其他类型的线程堆栈,但这个尤其有趣,因为我们能识别出我们的大多数端点堆栈。记住,我们正在调用一个 JAX-RS 端点,该端点调用 JPA 以查找一个将依赖于 DataSource 连接到当前数据库的报价。我们可以用 org.glassfish.jersey lines 识别 JAX-RS 层,用 org.eclipse.persistence lines 识别 JPA 层,用我们自己的包(在这个例子中是 com.github.rmannibucau)识别我们的应用程序,以及用 ConnectionManager lines 识别数据源连接检索。我们还可以确定 Jersey(GlassFish 的 JAX-RS 实现)部署在 Tomcat 上,这要归功于 org.apache.catalina 包(但仅限于应用程序管道管理)和 Grizzly 用于 I/O 处理(org.glassfish.grizzly 包)。
这项分析很有趣,因为它显示了你在 Java EE 中需要注意的事情:Java EE 定义了 API,但运行时实际上运行的是实现。你很少在线程转储中看到 javax.* 条目,所以你可能需要检查你的服务器使用的是哪个实现,以便使你的分析更容易、更快。
现在的问题是,我们能从这个堆栈中得出什么结论吗?是的,当然可以!我们可以得出结论,我们的应用程序通过了我们预期的堆栈。然而,从性能的角度来看,这并不意味着什么。影响的是你看到相同堆栈被调用的频率。具体来说,如果你看到在特定调用中有 30 个线程在 100 个线程中等待,这可能意味着这是一个优化的好地方。如果堆栈甚至在该行旁边添加了 BLOCKED,这意味着你需要确保应用程序在这里锁定是正常的,也许需要改变一些东西(无论是代码还是配置)。
在进入下一节之前,请记住,你可以用多种方式获得相同类型的输出。jstack 工具是另一个你可以用来做更多或更少相同事情的 Java 工具,但一个有趣的提示是使用 Linux(或 Windows)的本地工具来获取确切的信息。如果你有 JRE(没有开发工具的 Java)而不是 JDK,以下是如何在 Linux 上操作的:
kill -3 $JAVA_SERVER_PID
内存
GC.class_histogram 命令允许你获取堆直方图。我们将在接下来的章节中处理这个问题。但为了快速总结,堆是大多数 Java 对象将去的地方。因此,了解它是如何被使用的是很重要的。
如果我们在我们的进程中执行 GC.class_histogram 命令,输出将如下所示:
$ jcmd 7577 GC.class_histogram
7577:
num #instances #bytes class name
----------------------------------------------
1: 192795 16202648 C
2: 10490 4667040 [B
3: 191582 4597968 java.lang.String
4: 38779 3412552 java.lang.reflect.Method
5: 20107 2243296 java.lang.Class
6: 70045 2241440 java.util.HashMap$Node
7: 24429 2078312 [Ljava.util.HashMap$Node;
8: 47188 1887520 java.util.LinkedHashMap$Entry
9: 28134 1745104 [Ljava.lang.Object;
38: 2175 121800 com.sun.tools.javac.file.ZipFileIndex$DirectoryEntry
39: 1890 120960 com.mysql.jdbc.ConnectionPropertiesImpl$BooleanConnectionProperty
1739: 6 192 java.util.regex.Pattern$3
2357: 1 96 com.sun.crypto.provider.SunJCE
2478: 4 96 org.glassfish.jersey.server.AsyncContext$State
2548: 1 88 org.glassfish.ejb.startup.EjbDeployer
2558: 2 80 [Lcom.mysql.jdbc.StringUtils$SearchMode;
2649: 2 80 org.glassfish.kernel.embedded.EmbeddedDomainPersistence
2650: 2 80 org.glassfish.persistence.jpa.PersistenceUnitInfoImpl
2652: 1 80 org.hibernate.validator.internal.engine.ConfigurationImpl
2655: 5 80 org.jboss.weld.manager.BeanManagerImpl
2678: 1 72 [Lorg.glassfish.jersey.uri.UriComponent$Type;
2679: 2 72 [Lsun.security.jca.ProviderConfig;
2680: 1 72 com.github.rmannibucau.quote.manager.model.Quote
2689: 3 72 com.sun.enterprise.container.common.impl.ComponentEnvManagerImpl$FactoryForEntityManagerWrapper
2770: 3 72 org.eclipse.persistence.jpa.jpql.parser.TableExpressionFactory
6925: 1 16 sun.reflect.ReflectionFactory
Total 1241387 61027800
在这里,它是一个部分输出(在多个地方被截断),因为对于这本书来说太冗长了。如果我们发现我们了解的大多数环境,那么注意以下事项是很重要的:
-
com.mysql是我们应用程序使用的 JDBC 驱动程序 -
com.github.rmannibucau是我们的应用程序(特别是报价实体) -
com.sun.enterprise是用于 GlassFish 服务器的 -
org.jboss.weld是 GlassFish 的 CDI 容器 -
org.hibernate.validator是用于 GlassFish 实现的 Bean 验证 -
sun、com.sun、java等 JVM 相关
现在,一个重要的事情是能够解释这些数据。第一列不是很重要,但接下来的两列是。如表格标题所述,它们代表实例的数量及其字节大小。
如果你同时在你的服务器上运行多个并发请求,并过滤输出以查看你的引用实体,你可以看到以下内容:
138: 591 42552 com.github.rmannibucau.quote.manager.model.Quote
这行意味着当前堆中有 591 个Quote实例,占用 42,552 字节。
这意味着这是一个可以在服务器运行时实时检查的统计信息。但正如命令帮助中所述,它会影响服务器(减慢其速度),因此你只能用于调整目的。
GC.class_histogram命令的最后有趣的数字是堆的总大小,这是最后打印的数字。在我们的前一个输出中,它是 61,027,800 字节(大约 61 MB)。
JVisualVM – JVM 监控的 UI 界面
jcmd命令是一个优秀的命令行工具,但有点原始。然而,JVM 提供了额外的工具来提供与性能相关的度量,特别是 CPU 和内存。JVisualVM和JConsole是两个包含在 JDK(不是 JRE)中的工具。由于两者非常相似,我们本节只处理JVisualVM,但大部分信息和工具也可以与JConsole一起使用。
要启动JVisualVM,你只需要执行同名的命令:
$ $JAVA_HOME/bin/jvisualvm
一旦启动,你将看到jvisualvm的欢迎屏幕:

一旦你在左侧选择了 JVM,右侧窗格将显示关于 JVM 的信息。它组织在标签页中:
-
概览:这提供了关于 JVM(进程 ID、主类、参数、Java 版本、系统属性等)的高级信息。
-
监控:这提供了 CPU 使用率、内存使用率(特别是堆)、加载的类数量和线程数量的概述。
-
线程:这提供了 JVM 管理的现有线程的实时视图,并显示了线程状态随时间的变化(是否空闲或活跃)。以下是截图:

传奇故事位于右下角,并使用颜色帮助此视图可读:绿色代表运行,紫色代表睡眠,黄色代表等待,红色代表分叉,橙色代表监控。
有趣的是绿色块。这是线程正在执行某事的时候。以 http-listener(x)线程为例,你可以看到它们是橙色和绿色的。橙色部分是线程等待请求的时候,绿色部分是它们在提供服务的时候。这个视图必须与线程转储(或线程堆栈视图)结合使用,以确保等待的线程实际上正在等待某些相关的事情(例如等待某些 I/O),这是应用程序无法控制的。
- 样本采集器:这个标签非常有趣,允许你捕获服务器在 CPU 和内存方面的操作。我们发现了一些与
jcmd相关的信息,但使用起来更简单。你只需要点击 CPU 或内存按钮,jvisualvm就会开始捕获相关信息。以下是捕获一些样本后你将得到的内存视图:

这个视图非常接近jcmd的 GC 直方图命令;你将找到类名、相应的字节数和实例数。你可以使用与你的应用程序相关的任何模式在底部过滤可见的类;在这个截图中,我们通过 Quote 进行了过滤。
如果你捕获了一些 CPU 样本,视图将集中在方法和它们的执行时间上:

第一列是方法标识符,其他列显示了相应方法的对应时间。Self Time 是方法本身的时间。Self Time (CPU)与 Self Time 相同,但忽略了等待时间(锁等)。同样适用于 Total Time 列。Self Time 列和 Total Time 列之间主要区别是什么?Total Time 列包括进一步的方法调用,而 Self Time 列不包括。
当你在 CPU 视图上时,你可以点击线程转储来获取线程转储,与jcmd相同,但它在jvisualvm中直接可访问。
- 分析器:这是 JVM 视图的最后一个选项卡,提供与采样选项卡大致相同的视图。主要区别在于它捕获数据的方式。如果你在分析器中点击和看到第一份数据之间的时间相当长,请不要担心。而采样选项卡只是定期从 JVM(内存或线程堆栈)中获取快照并从中生成近似统计,分析器选项卡则修改类(实际字节码)以捕获准确数据。这意味着采样开销不是很大,但如果它影响到整个代码库,包括快速方法(默认情况下都会进行仪器化),则分析开销可能会很大。如果你想获得精确的度量,你需要使用分析器,但建议你勾选设置复选框,精确调整你想要获取度量信息的类,而不是使用默认设置,因为默认设置范围太广,可能会影响系统。
如何远程连接
本地连接很简单,因为jvisualvm只需本地查找正在运行的 JVM。但为了远程连接,你需要做一些额外的设置。
所有通信都依赖于 JMX,因此你需要设置一个远程 JMX 连接。这依赖于所谓的连接器(可以看作是一个小型嵌入式 JMX 服务器)。有多种协议可用,但默认情况下,它们依赖于 RMI 通信和系统属性的配置。
要添加这些系统属性,最快和最简单的方法如下:
-Dcom.sun.management.jmxremote.port=1234
-Dcom.sun.management.jmxremote.ssl=false
-Dcom.sun.management.jmxremote.authenticate=false
它将在 1234 端口上启用 JMX 并禁用 SSL 和安全功能。为了性能,我们不需要更多,但如果你想在生产环境中使用它,你可能需要配置安全和 SSL。有关如何操作的更多详细信息,你可以参考 Oracle 网站上的docs.oracle.com/javase/8/docs/technotes/guides/management/agent.html。
一旦配置完成,你只需在左侧树中的本地项上右键单击,选择添加 JMX 连接,并填写相关信息(主机/端口以及如果你已配置安全性的潜在凭证)。
Java Mission Control
自从 Java 7u40 版本起,JDK 已经包含了 Java 飞行记录器工具。如果你记得jcmd中可用的命令,你有一些JFR.*选项,这些选项与这个工具直接相关。它允许你捕获一组 JVM 事件。它与Java Mission Control(JMC)结合使用,使你能够分析和利用 JVM 事件。
启动它很简单:
$ $JAVA_HOME/bin/jmc
一旦启动,你将看到一个欢迎屏幕;视图看起来与jvisualvm视图相似,左侧列出了可用的进程:

您可以使用与jvisualvm相同的提示来识别进程。如果您不太确定,请不要犹豫,使用jps -v命令,它将显示每个正在运行的 JVM 的命令行及其 PID(这将允许您在 JMC 中识别括号中的数字)。
一旦您已识别出您的进程,您可以右键单击它并选择“启动 JMX 控制台”,以获得类似于jvisualvm且针对所选 JVM 的视图:

您可以找到 CPU(处理器)、内存和线程信息,以及 MBean 视图,这是 JVM 以标准方式导出内部数据的方式。
一个有趣的事情是当您转到“诊断命令”选项卡时,您将认出列出的jcmd命令:

此面板允许您直接从 UI 执行jcmd命令。在这里,我们感兴趣的是Java 飞行记录器(JFR)命令,因为我们想获取更多关于我们的 JVM 的信息。
在前面的屏幕截图中,您可能已经注意到左侧树中有一个飞行记录器项。它为这些命令提供了一个用户界面。然而,如果您点击“开始录音”,您将得到以下错误:

要使用 Java 飞行记录器,您需要将以下选项添加到您的 JVM 中:
-XX:+UnlockCommercialFeatures -XX:+FlightRecorder
这两个选项将激活 Java 飞行记录器功能。要将它们添加到 GlassFish 中,您可以编辑$GLASSFISH_HOME/glassfish/domains/domain1/config/domain.xml文件,并在jvm-options之后将其添加到java-config块中。或者,您可以使用create-jvm-options命令行的glassfish命令。无论如何,您都需要在此修改后重新启动(或启动)您的服务器。
如果您想使用我们的 maven GlassFish 进行测试,您只需将它们添加到MAVEN_OPTS:
$ MAVEN_OPTS="-XX:+UnlockCommercialFeatures -XX:+FlightRecorder" mvn embedded-glassfish:run
现在选项已在 JVM 上激活;您可以返回 Java 任务控制台,在“启动飞行记录器”项上点击开始录音。它将询问您存储录音的文件位置以及录音的时长或限制(大小/年龄)。最后,您可以选择是否要分析您的服务器或只是监控它。在这里,差异在于相关的开销。现在让我们选择分析。然后您可以点击“下一步”并选择您想要监控的内容。一个重要的参数是堆参数,但如果您继续通过向导,您将看到您可以精确地自定义您要监控的内容,包括 I/O。一旦一切配置妥当,只需点击“完成”。它将开始录音并在完成后打开。
首次使用时,请选择 1 分钟作为录音时长;这将避免您等待过长时间。
录音完成后,您应该得到一个类似于以下视图的视图,显示捕获的数据:

从顶部看,我们可以看到事件时间线。您可以点击它来细化时间选择。计数器显示了内存和 CPU 捕获的摘要。最后,在底部,您有 CPU 和内存图。
使这个工具比之前的工具更先进的是,您可以在代码标签页(在这个工具中标签位于左侧)中可视化代码热点,并在单个工具中查看 I/O。内置的 JDK 也使得使用它相当容易,而开销并不重要(如果您选择连续监视,一个对应的是统计信息可能不会非常准确,但足够接近,以便给您一个大致的概念)。这个工具的一个主要优势是代码标签页的调用树视图。它允许您通过堆栈将方法执行时间成本与方法调用关联起来。例如,当服务器运行时,这个捕获显示我们的findAll方法成本主要与我们将每个报价映射的方式有关,这需要使用 JPA 层(eclipselink)和数据库:

此视图是调查应用程序热点的一种真正很好的方式。它有点将线程转储和性能视图(有时称为路径跟踪)合并在一起,并允许您直接访问成本较高的操作。
GlassFish 临时监视
许多服务器都有内置的监视功能。这高度取决于服务器,但它可以在不使用其他工具的情况下提供一些有趣的见解。当您无法控制机器或没有权限访问/配置服务器时,这非常宝贵。
为了说明这种监视,让我们使用我们的 Java EE 参考实现:GlassFish。
一旦使用正常的./bin/asadmin start-domain命令启动,您可以使用此附加命令激活监视:
$ ./bin/asadmin enable-monitoring
Command enable-monitoring executed successfully.
如果您想禁用监视,确实有一个对称的命令:
$./bin/asadmin disable-monitoring
您可以使用get命令列出可用的监视器:
$ ./bin/asadmin get server.monitoring-service.*
server.monitoring-service.module-monitoring-levels.cloud=OFF
server.monitoring-service.module-monitoring-levels.cloud-elasticity=OFF
server.monitoring-service.module-monitoring-levels.cloud-orchestrator=OFF
server.monitoring-service.module-monitoring-levels.cloud-tenant-manager=OFF
server.monitoring-service.module-monitoring-levels.cloud-virt-assembly-service=OFF
server.monitoring-service.module-monitoring-levels.connector-connection-pool=OFF
server.monitoring-service.module-monitoring-levels.connector-service=OFF
server.monitoring-service.module-monitoring-levels.deployment=OFF
server.monitoring-service.module-monitoring-levels.ejb-container=OFF
server.monitoring-service.module-monitoring-levels.http-service=OFF
server.monitoring-service.module-monitoring-levels.jdbc-connection-pool=OFF
server.monitoring-service.module-monitoring-levels.jersey=HIGH
server.monitoring-service.module-monitoring-levels.jms-service=OFF
server.monitoring-service.module-monitoring-levels.jpa=OFF
server.monitoring-service.module-monitoring-levels.jvm=OFF
server.monitoring-service.module-monitoring-levels.orb=OFF
server.monitoring-service.module-monitoring-levels.security=OFF
server.monitoring-service.module-monitoring-levels.thread-pool=OFF
server.monitoring-service.module-monitoring-levels.transaction-service=OFF
server.monitoring-service.module-monitoring-levels.web-container=OFF
server.monitoring-service.module-monitoring-levels.web-services-container=OFF
server.monitoring-service.dtrace-enabled=false
server.monitoring-service.mbean-enabled=true
server.monitoring-service.monitoring-enabled=true
Command get executed successfully.
此输出显示,Jersey 的监视级别为HIGH,但其他监视器处于禁用状态(OFF)。
另一种选择是使用管理 UI(默认情况下在http://localhost:4848,对于独立安装)。转到左侧树中的配置部分,您将有一个监视项,您可以访问相同的条目:

在表格左侧选择您想要的对应模块的级别将激活相关的监视。一旦监视被激活,通常需要重新启动服务器,以便 GlassFish 能够考虑它。
完成后,您可以通过左侧树的监视数据项访问相关信息:

在这里,您可以查看监控的实例。(如果您使用独立的 GlassFish,您可能只有一个条目。)“查看监控数据”列将允许您选择您想要查看的数据。例如,如果您点击“应用程序”,您将获得相应的屏幕,其中填写了信息,具体取决于您之前激活的监控级别。以下是一个示例截图:

根据应用的不同,这会有所不同。然而,对于我们(一个 JAX-RS 服务)来说,即使它只提供高级信息,请求统计块也是有趣的。我们可以用它来监控最大响应时间和错误计数。仅凭它本身,可能不足以提高性能,但它将使我们能够将其与客户端信息进行比较;然后我们可以轻松地获取和验证我们的性能测试。
需要记住的是,服务器通常为最近的生产监控提供汇总的性能数据,而不是性能调整。这并不意味着它是无用的,而是您只能依靠临时的监控来验证您的性能测量管道(简单来说,就是您的客户端或请求注入器)。
库监控您的应用程序
我们看到了 JVM 为我们提供的工具以及服务器给我们提供的性能提示,但还有很多库旨在帮助您提高性能。
计数器、仪表、计时器以及更多
最著名的库可能是来自 Dropwizard 的Metrics(metrics.dropwizard.io),但所有库都共享更多或更少的相同类型的 API。度量值围绕几个重要概念展开:
-
仪表:这些提供了在特定时间点的值度量。它们旨在构建时间序列。最著名的例子是 CPU 或内存使用情况。
-
计数器:这些是长值,通常与仪表相关联,以构建时间序列。
-
直方图:这种结构允许您计算围绕值的统计信息,例如,请求长度的平均值或百分位数。
-
计时器:这些有点像直方图;它们基于一个度量值计算其他度量。在这里,目标是获取关于值速率的信息。
-
健康检查:这些与性能关系不大;它们允许您验证资源(如数据库)是否正在运行。如果资源不工作,健康检查会抛出警告/错误。
所有这些库都提供了不同的方式来导出/公开收集到的数据。常见的配置与 JMX(通过 MBeans)、Graphite、Elasticsearch 等相关,或者只是控制台/日志作为输出。
这些概念如何与性能联系起来?对我们来说最重要的功能将是仪表和计数器。仪表将使我们能够确保服务器运行良好(例如,CPU 不总是达到 100%,内存得到良好释放等)。计数器将使我们能够衡量执行时间。它们还将使我们能够在针对多个实例进行测试时导出聚合存储中的数据,这样你就可以检测一个实例对另一个实例的潜在副作用(例如,如果你有任何聚类的话)。
具体来说,我们希望衡量我们代码的一些重要部分。在极端情况下,如果你对应用程序一无所知,你可能会想要衡量代码的所有部分,然后在你对应用程序有更多了解时再对其进行细化。
为了非常具体地说明我们试图实现的目标,我们希望用这种模式来替换应用程序方法:
@GET
@Path("{id}")
public JsonQuote findById(@PathParam("id") final long id) {
final Timer.Context metricsTimer = getMonitoringTimer("findById").time();
try {
return defaultImpl();
} finally {
metricsTimer.stop();
}
}
换句话说,我们希望用计时器包围我们的业务代码,以收集关于我们执行时间的统计数据。你可以尝试的一个常见且简陋的解决方案是使用日志记录器来完成。它通常看起来如下:
@GET
@Path("{id}")
public JsonQuote findById(@PathParam("id") final long id) {
final long start = System.nanoTime();
try {
return defaultImpl();
} finally {
final long end = System.nanoTime();
MONITORING_LOGGER.info("perf(findById) = " +
TimeUnit.NANOSECONDS.toMillis(end - start) + "ms");
}
}
上述代码手动测量方法的执行时间,然后,将带有描述性文本的结果输出到特定的日志记录器中,以识别与之相关的代码部分。
在这样做的时候,你将遇到的问题是,你将无法获得任何关于你所衡量的统计数据,并且需要预处理你收集的所有数据,这将延迟使用指标来识别应用程序的热点并对其进行工作。这看起来可能不是一个大问题,但因为你很可能在基准测试阶段多次这样做,你将不希望手动进行。
然后,其他问题都与这样一个事实有关,即你需要将此类代码添加到你想要衡量的所有方法中。因此,你会在代码中添加监控代码,这通常是不值得的。如果你只是临时添加它以获取指标并在之后移除,那么影响会更大。这意味着你将尽可能避免这种工作。
最后一个问题是你可能会错过服务器或库(依赖)数据,因为你不拥有这段代码。这意味着你可能会花费数小时数小时地工作在一个代码块上,而这个代码块实际上并不是最慢的。
代码仪表化
立即的问题是如何在不修改代码的情况下对想要衡量的代码进行仪表化? 第一个目标是避免在代码中过于侵入,同时也要避免为了基准测试的持续时间而影响整个应用程序。第二个目标是能够切换仪表化,并能够将其停用,以便在不进行监控的情况下测量应用程序(尤其是如果你将其放在每个地方),并忽略你获取的指标上的相关开销。
现在,在 Java 和 Java EE 状态下,您有几种方法可以对代码进行插装。我们将浏览其中大部分,但这里是对您所拥有的选择的概述:
- 选择 1 – 手动:在这个解决方案中,您使用您所依赖的监控框架的Factory包装您使用的实例,返回的实例被包装在一个监控代理中(新实例委托给原始实例)。具体来说,它可以看起来像以下这样:
@ApplicationScoped
public class QuoteResource {
@Inject
private QuoteService service;
@PostConstruct
private void monitorOn() {
service = MonitoringFactory.monitor(service);
}
}
从我们之前讨论的内容来看,这有一个缺点,即影响代码并限制插装仅限于您拥有的(或可以修改的)代码。然而,它的一个重大优势是它简单易集成,并且可以与任何类型的代码(由 EE 容器管理或不管理)一起工作。具体来说,大多数监控库都将拥有此类实用程序,并且通常在其他的集成中内部使用它。
- 选择 2 – 通过 CDI(或拦截器 API):将逻辑注入到服务中的 Java EE 标准方式是使用拦截器。我们将在专门的部分详细说明它是如何工作的,但总体思路是将方法标记为被监控。在这里,限制将是您需要通过 CDI 容器访问您想要监控的代码。然而,在编码方面,它比前一种解决方案的影响要小。
如果您的应用程序依赖于 Spring,Spring 框架有相同类型的工具(在他们的文档中称为AOP)。因此,即使激活的方式略有不同,同样的概念也适用。
- 选择 3 – 通过 javaagent:javaagent 是插装代码的最强大方式。缺点是您需要直接在 JVM 上配置它,而优点是您可以监控几乎所有的类(除了 JVM 本身的少数几个类)。
一些容器(例如 Tomcat/TomEE 等)允许您配置java.lang.instrument.ClassFileTransformer。这基本上使您能够在加载时(动态地)执行字节码插装。这使得您能够享受到几乎与 javaagent 相同的强大功能,但您将无法对容器——以及可能的部分 JVM——进行插装,而只能对应用程序的类进行插装。然而,它仍然比 CDI 插装更强大,因为它可以看到应用程序的所有类,而不仅仅是 CDI 处理的那些类。
CDI 插装
如果我们再次关注 Metrics 库,我们会发现几个 CDI 集成。全局的想法是在代码上装饰一些注解,并自动获取与执行代码关联的度量。显然,这种方式会影响您的代码(例如使用github.com/astefanutti/metrics-cdi):
@Transactional
@ApplicationScoped
public class QuoteService {
@PersistenceContext
private EntityManager entityManager;
@Timed(name = "create")
public Quote create(final Quote newQuote) {
entityManager.persist(newQuote);
entityManager.flush();
return newQuote;
}
}
@Timed注解将自动将方法执行包装在 Metrics 计时器中,因此将提供关于方法执行时间的统计信息。与@Timed注解关联的拦截器的相关代码非常接近以下逻辑:
private Object onInvocation(InvocationContext context) throws Exception {
Timer.Context time = findTimer(context).time();
try {
return context.proceed();
} finally {
time.stop();
}
}
这正是我们想要实现的目标,但它有一个我们还没有考虑到的陷阱:异常处理。为了理解这一点,我们可以比较在已退役的项目(称为 Apache Sirona)中使用的代码,该项目具有以下不同实现的功能:
protected Object doInvoke(final InvocationContext context) throws Throwable {
final Context ctx = before(context);
Throwable error = null;
try {
return proceed(context);
} catch (final Throwable t) {
error = t;
throw t;
} finally {
if (error == null) {
ctx.stop();
} else {
ctx.stopWithException(error);
}
}
}
这里重要的是要注意,在异常的情况下代码路径会发生变化。从统计学的角度来看,这意味着失败将会有与成功调用不同的标记在指标报告中。这一点很重要,因为失败的执行时间很少与成功可比较,即使是对于简单的方法。让我们从一个简单的报价管理应用程序的查找示例中观察这一点。以下是我们要调查的行:
entityManager.find(Quote.class, id)
一个正常的带有有效 ID 的调用大约在 6 到 7 毫秒(在我的参考机器上,使用我的个人配置)。EntityManager#find方法可以接受任何类型的标识符,因此如果我们传递错误类型(例如,String而不是long),那么调用应该编译并执行。Eclipselink 会抛出异常,但性能影响是很有趣的:0 毫秒!确实,这个例子非常极端,是一个错误,但如果你在端点上有一些速率限制或在方法开始时有一些合理性检查,你可能会观察到相同的影响。
这意味着如果你使用的框架将所有调用(无论是否有错误)放入同一个桶中,你可能会获得非常好的性能,但应用程序可能会非常慢,因为成功/失败的平均值使得数据看起来很好。
实现你自己的可配置监控拦截器
实现 CDI 拦截器并不复杂。因此,如果你找不到符合你期望的库,你可能想自己实现。它可以从两个方面直接影响你使用监控的方式:
-
能够根据你所在的情况控制你使用的计数器。这包括成功/失败处理,但也可能是与租户相关的(如果你的应用程序正在处理多个租户)。如果你不使用与租户完全相同的系统(例如,一个可能比另一个慢的数据库),这可能非常重要。
-
能够配置要监控的 Bean。是的,使用 CDI,你也可以避免装饰你想要监控的 Bean,而是从配置中自动完成。
创建 CDI 拦截器的第一步是拥有 CDI 所说的拦截器绑定。这是你将在你的 Bean 上使用的注解,它将标记方法为被监控。这里有一个简单的例子:
@InterceptorBinding
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface Monitored {
}
这是一个普通的注解,你可以将其放在方法中(或类中以标记所有方法为监控)。唯一特别的是它的@InterceptorBinding标记。
然后,要将这个拦截器绑定到实际的拦截器实现,你需要创建一个具有相同注解的拦截器:
@Interceptor
@Monitored
@Priority(Interceptor.Priority.PLATFORM_BEFORE)
public class MonitoredInterceptor implements Serializable {
@AroundInvoke
@AroundTimeout
public Object monitor(final InvocationContext invocationContext) throws Exception {
final Context ctx = newContext(invocationContext);
Exception error = null;
try {
return invocationContext.proceed();
} catch (final Exception t) {
error = t;
throw t;
} finally {
if (error == null) {
ctx.stop();
} else {
ctx.stopWithException(error);
}
}
}
}
被@AroundInvoke装饰的方法将处理方法调用,而被@AroundTimeout装饰的方法也将支持 EJB 定时器回调(@Timeout)。
注意,如果你还想监控构造函数,你也可以这样做,但你需要实现一个@AroundConstruct方法(与我们的monitor方法有类似的实现)。我们的拦截器自动被@Priority装饰,因此它被启用,你不需要在beans.xml中激活它。
使用这段代码,你可以用@Monitored装饰任何方法,并且假设你的Context存储了指标,你将在使用的报告解决方案中获得你的数据。
然而,编写自定义实现的一个目标也是能够配置它。使用 CDI,可以通过Extension来实现。全局思路将是观察应用程序类型/方法,如果配置为要监控,我们将自动添加@Monitored。这样,我们将在应用程序中没有代码影响,并且我们可以通过简单地更改我们的配置来轻松激活/停用监控。对于配置,我们可以从以下performance.properties资源开始(注意,它将很容易更改为应用程序外的特定文件):
public class PerformanceExtension implements Extension {
private final Annotation monitored = new
AnnotationLiteral<Monitored>() {};
private final Properties configuration = new Properties();
private boolean enabled;
void loadConfiguration(final @Observes BeforeBeanDiscovery
beforeBeanDiscovery) {
try (final InputStream configStream =
Thread.currentThread().getContextClassLoader()
.getResourceAsStream("performances.properties")) {
if (configStream != null) {
configuration.load(configStream);
}
} catch (final IOException ioe) {
throw new IllegalArgumentException(ioe);
}
enabled =
Boolean.parseBoolean(configuration.getProperty("enabled",
"true"));
}
<A> void processAnnotatedType(final @Observes
ProcessAnnotatedType<A> pat) {
if (!enabled) {
return;
}
final String beanClassName =
pat.getAnnotatedType().getJavaClass().getName();
if(Boolean.parseBoolean(configuration.getProperty(beanClassName
+ ".monitor", "false"))) {
pat.setAnnotatedType(new WrappedAnnotatedType<>
(pat.getAnnotatedType(), monitored));
}
}
}
此代码使用BeforeBeanDiscovery事件(CDI 生命周期的开始)来加载我们的配置。在这里,你可以从任何地方读取。一个小优化是有一个特殊的键来检查扩展是否已激活。如果它设置为除了 true 之外的其他值,那么我们将跳过所有其他事件。如果它被启用,我们将通过ProcessAnnotatedType事件观察所有发现类型。如果 bean 应该被监控(我们这里的测试非常简单,我们只是检查类名后缀monitor是否在我们的配置中为 true),那么我们将覆盖AnnotatedType,保留所有其信息,但将@Monitored添加到类的注解集合中。
你也可以在方法级别上做完全相同的事情,通过AnnotatedType#getMethods返回的AnnotatedMethod包装。逻辑是相同的;你只需要多一个配置级别(针对方法)。
WrappedAnnotatedType实现是一个简单的代理实现,除了注解访问器,这里使用了一个新集合而不是原始集合:
public class WrappedAnnotatedType<A> implements AnnotatedType<A> {
private final AnnotatedType<A> delegate;
private final Set<Annotation> annotations;
public WrappedAnnotatedType(final AnnotatedType<A> at, final
Annotation additionalAnnotation) {
this.delegate = at;
this.annotations = new HashSet<Annotation
>(at.getAnnotations().size() + 1);
this.annotations.addAll(at.getAnnotations());
this.annotations.add(additionalAnnotation);
}
@Override
public Set<Annotation> getAnnotations() {
return annotations;
}
@Override
public <T extends Annotation> T getAnnotation(final Class<T>
annotationType) {
for (final Annotation ann : annotations) {
if (ann.annotationType() == annotationType) {
return annotationType.cast(ann);
}
}
return null;
}
@Override
public boolean isAnnotationPresent(final Class<? extends
Annotation> annotationType) {
return getAnnotation(annotationType) != null;
}
// other methods fully delegate the invocations to the delegate
instance
}
如你所见,唯一的逻辑在getAnnotation和构造函数中,其中创建了一个新集合的注解来替换原始集合。
最后,为了启用Extension并让 CDI 找到它,我们只需将它的全限定名放在我们项目资源中的META-INF/services/javax.enterprise.inject.spi.Extension。
一旦将此扩展添加到您的应用程序中(如果您想将其开发为库,只需将jar文件添加到您的war包内部即可),您可以通过performances.properties来配置它。
在我们的案例中,监控我们的报价服务看起来是这样的:
# activate the monitoring
enabled = true
# monitor the QuoteService class
com.github.rmannibucau.quote.manager.service.QuoteService.monitor = true
更重要的是,您可以在要监控的类旁边添加一行。不要忘记在更新此文件时重启,因为配置和 CDI 模型包装仅在启动时完成。
Javaagent – 一种复杂但强大的仪器化
如果您再次回到指标,您甚至可以找到现有的 javaagents,尽管它们可能不多,因为编写代理稍微复杂一些。
Javaagent 是 JVM 提供的主方法的一种特殊类型,它允许您注册ClassFileTransformer,这是一种在类被加载之前修改类字节码的方法。换句话说,您将编写一些代码并编译它,但 JVM 永远不会执行它。相反,它将执行代码的重写版本。
我们不会在这里详细说明如何做。实际上,这比编写拦截器更复杂(您需要处理类加载器,使用 ASM 库或等效的低级字节码编写,等等)。然而,重要的是要看到 javaagent 的范围是 JVM——不是应用程序,不是容器,而是整个 JVM。由于技术原因,正如您可能猜到的,您不能对所有的 JVM 类进行仪器化,但可以对 javaagent 启动后加载的所有类进行仪器化(这已经足够远了)。
使用 javaagent 进行java.net.HttpURLConnection的仪器化是一个使用 javaagent 进行仪器化的好例子。
此类通常用于实现 Java HTTP 客户端,但它通常被库(如 JAX-RS 客户端实现)隐藏。因此,如果您不能测量这个特定的类,您就很难获得当前的请求和框架时间。
这就是 javaagent 将比 CDI 或 Spring 仪器化更强大的原因。
为了让您了解您可以使用 javaagent 做什么,我们将在报价管理应用程序中配置 Sirona 项目。
Sirona javaagent
为了保持简单易懂,我们将使用 maven,但您可以在任何应用程序服务器上遵循相同的步骤,因为 javaagent 是在 JVM 上设置的,而不是在特定的服务器上。
第一步是下载 javaagent jar文件。要使用 maven 这样做,您只需在pom.xml中添加依赖项 maven 插件即可:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.0.2</version>
<executions>
<execution>
<id>sirona</id>
<goals>
<goal>copy</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>com.github.rmannibucau.sirona</groupId>
<artifactId>sirona-javaagent</artifactId>
<version>0.6</version>
<type>jar</type>
<classifier>shaded</classifier>
<overWrite>true</overWrite>
<outputDirectory>${project.basedir}</outputDirectory>
<destFileName>sirona-javaagent.jar</destFileName>
</artifactItem>
</artifactItems>
</configuration>
</execution>
</executions>
</plugin>
它被配置为下载 sirona-javaagent 阴影 JAR(全部在一个包中)。现在,如果您执行此 maven 命令,您应该能在您的 maven 项目中获得 javaagent JAR:
$ mvn dependency:copy@sirona
一旦执行此命令,您应该能在 pom 旁边找到sirona-javaagent.jar文件。
现在我们有了 javaagent,我们需要对其进行配置。为了简化,Sirona 支持当前目录中的sirona.properties配置文件,因此我们将使用它。以下是它将包含的内容以激活应用程序中的监控:
com.github.rmannibucau.sirona.javaagent.listener.CounterListener.includes=prefix:com.github.rmannibucau.quote.manager
com.github.rmannibucau.sirona.javaagent.listener.CounterListener.excludes=container:jvm
com.github.rmannibucau.sirona.store.counter.CounterDataStore=com.github.rmannibucau.sirona.store.counter.CsvLoggingCounterDataStore
com.github.rmannibucau.sirona.csvlogging.counter.period=5000
com.github.rmannibucau.sirona.csvlogging.counter.clearAfterCollect=true
com.github.rmannibucau.sirona.javaagent.path.tracking.activate=false
与CounterListener相关的配置是关于监控的范围:哪些被仪器化,哪些没有被仪器化。在这里,我们只是仪器化我们的应用程序包,并让 Sirona 忽略 JVM 类(container是内置排除集的别名)。然后,我们配置CounterDataStore,其中存储指标。在这个例子中,我们使用日志风格(指标将被输出到日志中)和 CSV 格式。这是最简单的方法,但您也可以配置它以将数据输出到 Elasticsearch、Graphite 或任何外部系统。然后,我们配置我们的存储以每 5 秒(5000 毫秒)记录一次——这主要是为了演示,但在现实生活中,您可能希望等待一分钟左右。接下来,我们请求在收集后清除存储。这一点意味着每次记录数据时,数据都会重置。它避免了启动数据对运行时数据产生副作用。最后,最后一行禁用了 Sirona 的路径跟踪功能,该功能是 javaagent 内置的,但在这里我们不需要它。
现在一切配置就绪,我们只需确保我们的应用程序已准备好运行(如果您有疑问,可以重新执行mvn clean package)然后使用 JVM 上的 javaagent 启动它(如果使用 maven 启动 GlassFish,则使用 maven;如果使用独立实例,则直接使用 GlassFish):
MAVEN_OPTS="-javaagent:sirona-javaagent.jar" mvn embedded-glassfish:run
如您所见,添加 javaagent 就像在 JVM 上添加-javaagent选项一样简单,后面跟着 JAR 的路径。
如果代理是原生开发的,而不是用 Java 编写的,命令将非常相似(但使用-agentlib)。这就是您如何区分 Java 和原生代理,但原则是相同的。
一旦启动了服务器,如果您等待几秒钟(~5 秒,如我们的配置),您将开始获得一些与 Sirona 收集的指标相关的输出:
sept. 23, 2017 12:16:32 PM com.github.rmannibucau.sirona.store.counter.LoggingCounterDataStore pushCountersByBatch
INFOS: "com.github.rmannibucau.quote.manager.service.ProvisioningService$Data.getQuoteSummary()";"performances";70;1;3045.0;157.0;842.514285714286;58976.0;622.1554571298391
sept. 23, 2017 12:16:32 PM com.github.rmannibucau.sirona.store.counter.LoggingCounterDataStore pushCountersByBatch
INFOS: "com.github.rmannibucau.quote.manager.service.QuoteService$Proxy$_$$_WeldSubclass.weld$$$46()";"performances";1;1;6054.0;6054.0;6054.0;6054.0;0.
输出格式取决于日志记录器配置。默认情况下,它并不那么花哨,但如果您配置了您的日志记录器,您将获得纯 CSV 输出。在 Sirona 中,默认情况下,日志记录器名称将是com.github.rmannibucau.sirona.counters。如果您想在没有特定格式化模式的情况下配置此特定日志记录器到特定文件,您将必须使用日志记录器名称而不是类名。
为了让我们更简单,我们只需更改 JVM 中的SimpleFormatter格式(它将影响使用此格式器的所有日志记录器):
MAVEN_OPTS="-Djava.util.logging.SimpleFormatter.format=%5\$s%6\$s%n -javaagent:sirona-javaagent.jar"
注意,根据您的操作系统,您可能需要(或不需)转义美元符号,就像上一个例子中那样(它是针对 Linux 的)。
一旦服务器以这种新配置启动,输出将更易于阅读:
"com.github.rmannibucau.quote.manager.service.QuoteService$Proxy$_$$_WeldSubclass.weld$$$94()";"performances";1;1;4347.0;4347.0;4347.0;4347.0;0.0
"com.github.rmannibucau.quote.manager.service.QuoteService$Proxy$_$$_WeldSubclass.weld$$$98()";"performances";1;1;3842.0;3842.0;3842.0;3842.0;0.0
"com.github.rmannibucau.quote.manager.service.QuoteService$Proxy$_$$_WeldSubclass.weld$$$102()";"performances";1;1;4186.0;4186.0;4186.0;4186.0;0.0
"com.github.rmannibucau.quote.manager.model.Quote._persistence_new(org.eclipse.persistence.internal.descriptors.PersistenceObject)";"performances";11;1;15760.0;4272.0;8134.545454545455;89480.0;3191.1807959949983
"com.github.rmannibucau.quote.manager.service.QuoteService.mutate(java.lang.String,java.util.function.Function)";"performances";10;1;1.3095653E7;5517805.0;9319597.6;9.3195976E7;2502831.398655584
"com.github.rmannibucau.quote.manager.service.ProvisioningService$Data.getQuoteSummary()";"performances";70;1;7909.0;519.0;1455.4142857142854;101879.0;1239.6496056226922
这里有趣的是,你可以直接将其导入 CSV 编辑器,包括 Microsoft Excel 或 LibreOffice Calc,并处理数据(排序、比较等)。
为了有效地处理数据,你需要知道列代表什么。对于这个特定的数据存储,以下是标题列表:
-
计时器/计数器名称
-
计时器/计数器角色(性能表示执行时间被测量,失败表示发生了异常)
-
击中次数(指示在测量窗口中方法被调用的频率)
-
最大并发性(指示在测量窗口中方法的最大并发调用)
-
最大值(给出最大执行时间)
-
最小值(给出最小执行时间)
-
平均值(给出平均执行时间)
-
总和(给出所有执行时间的总和)
-
窗口中所有执行时间的标准差
在你调查寻找方法瓶颈以深入挖掘(为了优化性能)的过程中,你必须考虑多个数据集。第一个数据将是总和。如果你按总和(降序)排序,第一个方法将是消耗你应用大量时间的那个。然而,你需要验证它与击中次数的关系。例如,如果你只有一个击中,那么你知道缓存这个方法的数据将不会有所帮助。标准差(或比较最小/最大范围)也会给你关于方法行为的想法。如果范围很大,那么你需要调查这个方法做了什么,以及为什么它有时快有时慢。
一旦你找到了一个好的方法来调查,你可以重复使用我们之前提到的工具来深入研究这个方法。拥有这种程度的信息开始工作通常更容易处理,并且更侧重于应用概述而不是详细视图,后者可能难以(或耗时)组织。从详细视图开始钻取性能数据总是比从详细视图开始更容易。
现在,为了展示 javaagent 有多强大,我们将暂时更改我们的 sirona 配置。我们将排除 Oracle 包(只是为了覆盖默认排除,即整个 JVM),并将包括HttpURLConnection。
我们应用的目标可以是比较我们在配置过程中花费的时间和当前网络成本,因为我们无法优化它,因为它与环境相关联,我们假设在基准测试阶段它是恒定的。
现在配置看起来是这样的:
com.github.rmannibucau.sirona.javaagent.listener.CounterListener.includes=prefix:com.github.rmannibucau.quote.manager,\
prefix:sun.net.www.protocol.http.HttpURLConnection
com.github.rmannibucau.sirona.javaagent.listener.CounterListener.excludes=prefix:oracle
com.github.rmannibucau.sirona.store.counter.CounterDataStore=com.github.rmannibucau.sirona.store.counter.CsvLoggingCounterDataStore
com.github.rmannibucau.sirona.csvlogging.counter.period=5000
com.github.rmannibucau.sirona.csvlogging.counter.clearAfterCollect=true
com.github.rmannibucau.sirona.javaagent.path.tracking.activate=false
只有前两行发生变化,你可以看到 JVM 不再被排除在外,以便能够对 sum 包进行仪器化,而HttpUrlConnection现在被包括在仪器化类的白名单中。
我们重新启动我们的服务器,并在配置后,我们得到这些新的输出:
"sun.net.www.protocol.http.HttpURLConnection.plainConnect()";"performances";1;1;1.288844214E9;1.288844214E9;1.288844214E9;1.288844214E9;0.0
"sun.net.www.protocol.http.HttpURLConnection.getNewHttpClient(java.net.URL,java.net.Proxy,int)";"performances";1;1;1.288132398E9;1.288132398E9;1.288132398E9;1.288132398E9;0.0
....
配置更改包括 JVM HTTP 客户端监控,我们现在可以了解实际网络中花费的部分时间以及客户端代码本身花费的时间,包括重试和所有嵌入的逻辑。这是没有 javaagent 就无法获得的信息。
现代 架构倾向于鼓励微服务。这意味着你将主要将你的整体系统拆分为具有明确责任分离的子系统。这涉及到很多问题,例如处理跨不同系统的交易需求(就像在它那个时代所做的那样),添加多个远程通信,这会减慢整体过程,等等,但这也带来了允许你更快地开发系统并更轻松地投入生产的优势。总是存在权衡。
在任何情况下,如果你专注于性能,你现在可能必须处理这样的系统,因此需要知道哪些工具可以帮助你。
主要有两种解决方案可以帮助你很多:
-
数据聚合:所有应用程序的所有数据都将聚合到单个系统中。例如,之前捕获的 N 个实例的执行时间将存储在单个 数据库(例如 InfluxDB 或 Elasticsearch)中。
-
跟踪:整个系统将传播单个 事务 ID(也称为 请求 ID),这将使你能够识别跨所有系统和你所处的阶段(例如管道中的第三个系统)的请求(用户操作)。
SQL 监控
在许多应用程序中,大部分时间都将被 SQL 查询的执行所占用。因此,监控它们非常重要。你可以使用之前的技术,但也有一些专门的方法来监控它们。
通常,想法是替换你使用的本地驱动程序(例如 Oracle、MySQL 等)为监控驱动程序,该驱动程序将包装默认驱动程序并将所有逻辑委托给原始驱动程序,并在其之上添加一些指标。
例如,使用 sirona JDBC 驱动程序(repo.maven.apache.org/maven2/com/github/rmannibucau/sirona/sirona-jdbc/0.6/) 作为我们的数据源,我们将以这种方式定义应用程序数据源:
@DataSourceDefinition(
name = "java:app/jdbc/quote_manager",
className = "com.github.rmannibucau.sirona.jdbc.SironaDriver",
url = "jdbc:sirona:mysql://localhost:3306/quote_manager?delegateDriver=com.mysql.jdbc.Driver",
user = "root",
password = "password"
)
public class DataSourceConfiguration {
}
驱动程序的类名现在是监控类,URL 也略有变化以配置监控驱动程序。在这里,使用 Sirona,你在本地驱动程序 URL 之前和 jdbc: 前缀之后附加 sirona,并将 delegateDriver 查询参数添加到 URL 中,其值为本地驱动程序的类名。
一旦完成,Sirona 将自动为每个语句创建计数器并将其添加到其报告中。
这种解决方案与预定义语句配合得非常好,因为你将重用相同的 键(SQL 值)。这通常是任何 JPA 提供商所做的事情。
这种在 Java 和数据库之间的可视化可以帮助确定慢查询。这类实现有很多。只需选择你喜欢的,比如 Sirona、Jamon、JavaSimon、Log4jJDBC、P6Spy 以及其他。
数据聚合
微服务——或者更普遍地说,具有小范围的服务——通常是快速移动的应用程序,并且很容易在全局系统中添加/删除它们。在这种情况下,性能需要与任何兄弟服务的变更(这可能会通过过度使用或误用影响中心服务)相媲美且可验证。
能够对所有系统有一个集中视角是理解如何优化另一个应用程序可以使你的应用程序运行更快的关键。这个陈述的推论是,当你依赖于另一个应用程序,而这个应用程序对于你的服务级别协议来说太慢时,你需要尽快意识到这一点。一旦确定,你可以添加缓存或替代方式,使你的应用程序对其他应用程序的依赖性降低,并运行得更快。
并非总是可能——例如,在我们的报价管理应用程序中,我们无法获取有关 Yahoo 的数据——但在微服务结构中,你通常会得到公司政策或至少是讨论和实施它的联系人。
在实践中,这主要关于同意一种识别应用程序的方式(这只是在整体系统中定义一个约定,由所有子系统共享)以及放入聚合器中的数据格式。例如,你可以说你将使用Company-ID HTTP 头作为请求标识符,日志格式将是${Company-Id} | ${Tenant-Id} | ${Machine-Id} | ${Execution-Time} | ${action/method} | ${message}。
这只是一个简单的例子,但想法是能够快速浏览跨应用程序的日志。
一旦你知道你将记录什么,你需要选择一个系统来存储你的数据。在这里,你有许多选择,但不要忘记检查你能否在数据存储后利用这些数据。这意味着你需要确保你有一个好的用户界面,它将满足你对存储的期望。
最知名的是这些:
-
Elastic 堆栈:它基于 Elasticsearch 来存储数据,Kibana 来可视化数据。它是免费的。
-
Splunk:这是一个专门用于数据聚合的自定义堆栈。
-
Grafana:它主要是一个 UI 工具,但它可以连接到大多数监控数据库,包括 Elasticsearch、Graphite 或 InfluxDB。
跟踪
跟踪有多种选择(Zipkin、Dapper 等),但其中一些似乎已经成为了主流。其中之一是 OpenTracing 倡议(opentracing.io/)。所有这些或多或少都基于 span 的设计。
全球理念是让每个事务的参与者用跨度标记他们的存在。跨度包含一个标识符,一些关于调用的元数据以及执行时间。标识符通常由多个值组成,代表整体跟踪标识符(请求标记)、跨度标识符,以及通常的父标识符。
当正确安装时,跟踪发生在客户端和服务器端,因此你可以全面了解系统处理,并且它与系统各部分的处理时间相关联。这确实是为了确保你的系统的每个部分都得到了适当的配置——每次你退出或进入系统时,你必须设置好以处理相关的跟踪。这包括 HTTP 客户端/服务器,以及 JDBC 或 NoSQL 驱动程序。
至于监控库,这依赖于存储,但也有本地实现(有点像我们在讨论 Sirona javaagent 时提到的 CSV 记录器),你可以用来测试你的配置或作为无法使用真实监控数据库时的后备方案。然而,使用此类系统的本地输出会使你的工作更加困难和耗时,因为它实际上是将多个数据聚合以获得整体视图。你需要理解的是,你不必犹豫投资建立一个专门用于数据收集的服务器。这不仅有助于性能,还有助于追踪你的系统。因此,这是一项值得的投资!
APM
你可以在市场上找到称为应用性能管理(APM)的工具。这些工具确实是监控工具中的劳斯莱斯,允许你完全追踪应用程序的所有步骤,回到过去了解发生了什么,并快速推断支持问题的原因。付费方案通常还包括基础设施,这并非微不足道,因为它涉及到大量数据操作。
技术上,它们重用了之前的一些技术,但确实是一个一站式解决方案,这使得它们非常有价值。然而,它们通常很昂贵,很少开源。
尽管如此,你仍然可以找到一些开源实现,例如 PinPoint(github.com/naver/pinpoint)、InspectIT(github.com/inspectIT/inspectIT)和 Glowroot(glowroot.org/)。在领先的商业解决方案方面,你可以找到 New Relic (newrelic.com/java)、DripStat (dripstat.com)或 DynaTrace (www.dynatrace.com/)。
摘要
在这部分,我们看到了许多收集关于 JVM 和应用程序信息的方法。我们还看到,JVM 本身提供了一套工具,以提供有关内存、CPU 和垃圾回收的信息。其中大部分可以通过命令行(在没有 UI 的情况下进行基准测试时可能非常有用)获取,但它们也附带了一些用户界面,一旦您能够连接到 JVM,就可以轻松获取信息。这些工具之一是 JMC:它提供了大量信息,甚至允许您深入到方法调用中,以获得您应用程序的详细视图。
然而,这还不够,您可能需要获取有关池使用情况的服务器信息,在这种情况下,服务器可以为您提供有关配置问题(例如配置过小的池)的一些更多信息。然后,一系列库允许您以更高效和面向性能的方式获取监控信息,这使您能够在没有深入了解或任何假设的情况下调查应用程序。请注意,这些工具(如 Metrics 或 Sirona)还聚合了更多数据,并且通常具有针对服务器的插件,这可以防止您使用特定于服务器的监控来获得更全面的视角。
最后,我们看到了在多系统应用程序中,您需要确保您能够监控您的应用程序以及与之链接的应用程序,以便您可以识别对您自己的应用程序的影响,并尝试减少它们的影响。
所有这些工具,在某种程度上,都有某种重叠,但它们都满足不同的需求,并回答了不同之间的权衡,包括易用性、信息完整性和投资。根据您对正在工作的应用程序的了解,以及您可以为代码和基准测试平台的架构投资多少,您将选择一个解决方案。
在下一章中,我们将探讨资源对应用程序及其性能的影响。我们将了解 Java 内存管理和服务器资源处理,例如DataSource和ConnectionFactory。
第四章:应用优化 – 内存管理和服务器配置
我们现在知道如何获取我们应用程序的性能信息。从高级执行时间到深入容器内部,我们可以确定代码的哪个部分在拖慢我们的进度。
然而,这主要关于我们的代码或堆栈(Java EE 容器)。还有其他标准可能会影响同一台机器的性能(考虑到 CPU 和内存是固定的)。
在本章中,我们将调查以下内容:
-
JVM 如何管理内存并自动释放未使用的对象
-
比较不同的选项以释放 JVM 提供的内存
-
看看服务器配置如何也会影响性能
Java 和内存
Java 是一种高级语言,这意味着它在为你做很多工作。如今,大多数语言都在做这件事(如 Scala、Go,甚至最近的 C++ 更新),但为了理解内存挑战,我们需要回到早期的编程时代,并比较两个简单的代码段。
第一个是一个简化的我们的配置服务版本,直接从我们的报价管理应用程序中取出:
public void refresh() {
final Client client = ClientBuilder.newClient();
try {
final String[] symbols = getSymbols(client);
for (String symbol : symbols) {
final Data data = client.target(financialData)
.resolveTemplate("symbol", symbol)
.request(APPLICATION_JSON_TYPE)
.get(Data.class);
quoteService.createOrUpdate(new UpdateRequest(data));
}
} finally {
client.close();
}
}
变量使用情况值得观察。关于 Java 变量作用域,client 对整个 refresh 方法都是可用的,symbols 数组在 try 块中可用。因此,for 循环和 data 只是对循环的一次迭代。然而,我们从未真正显式地分配任何对象内存;我们可以调用 new 来引用构造函数,但我们没有内存视图、指针或大小。
如果我们将相同的代码块与需要管理内存的版本进行比较,它将如下所示:
public void refresh() {
final Client client = ClientBuilder.newClient();
try {
final String[] symbols = getSymbols(client);
try {
for (String symbol : symbols) {
final Data data = client.target(financialData)
.resolveTemplate("symbol", symbol)
.request(APPLICATION_JSON_TYPE)
.get(Data.class);
try {
final UpdateRequest updateRequest = new
UpdateRequest(data);
try {
quoteService.createOrUpdate(updateRequest);
} finally {
releaseMemory(updateRequest);
}
} finally {
releaseMemory(data);
}
}
} finally {
releaseMemory(symbols);
}
} finally {
client.close();
}
}
即使这个例子假设 client.close() 处理释放,代码也会变得更加复杂,这是不可能的。实际上,每个分配的对象都需要调用 releaseMemory() 函数来释放分配的结构。这也意味着我们不应该错过任何调用。否则,我们就会泄漏内存。前面的代码示例使用了大量的嵌套 try/finally 来保证这一点。
我们应该从这个简单的例子中学到什么?我们应该学到的是 Java 允许开发者大多数情况下不必关心内存管理。如果你通过 JNI 使用一些本地集成,例如,你可能仍然需要处理它。为了确保应用程序表现良好且不会泄漏——这对于不应该重新启动的服务器来说很重要——JVM 提供了几个内存管理解决方案。这是透明地完成的,但直接影响了性能,因为内存分配对进程来说很敏感,内存释放也有一些挑战。
垃圾收集器
垃圾收集器是 JVM 中处理内存的部分。为了使其非常简单,它就是释放一些未使用对象持有的内存并将此内存空间重新分配给新对象的部分。
这一部分取决于你使用的 JVM,但所有算法都使用类似的逻辑。因此,了解它在高层次上是如何工作的非常重要,然后你可以调查你特定 JVM 的具体细节。
在本书的背景下,我们将限制自己讨论 HotSpot JVM(即 Oracle 的那个)。
堆内存分为两个主要空间:年轻代和老生代。年轻代本身又分为多个空间:Eden 和幸存者(有两个幸存者)。这两个代也都有一个虚拟空间,主要存在是为了支持垃圾收集操作或代大小调整。
自 Java 8 以来,永久空间(在 Java 7 中使用)已被移除,并由元空间取代。它的作用是在内存中保存应用程序的元数据,例如类(名称、注解、字段等)。如果你还记得之前关于如何监控应用程序的章节,你可能会想到jcmd GC.class_stats命令,它提供了关于这个内存空间的信息。在性能方面,确保这个空间在 JVM 热稳定后保持恒定非常重要。具体来说,这意味着一旦我们执行了应用程序所有可能的代码路径,我们不应该看到分配给该空间的内存有太多变化。如果你在那之后仍然看到它有显著移动,你可能有一个泄漏或类加载器问题,在继续工作之前你需要调查这些问题。
从现在起,我们只处理堆。这是你在开始为生产部署或基准测试调整应用程序时需要从其开始的内存部分。为了总结我们刚才讨论的内容,你可以用以下图表来可视化内存的分割方式:

整体思路是首先填充第一个区域(年轻代中的Eden区域)——你可以将其想象为与请求相关的对象,并使用一个心理模型来可视化——一旦填满,垃圾收集器会将仍然被使用的对象移动到下一个区域(Survivor 1、Survivor 2,最后是Tenured区域)。这是对代的高层次理解。这样分割内存的目的是,当垃圾收集器需要运行时,它可以在小于完整内存的区域内工作,并且可以对每个区域应用不同的算法,从而提高效率。请记住,你进入老生代越深,在应用运行时内存中存活的对象就越多。
年轻代和老年代之间主要的不同之处在于垃圾收集器对应用程序的影响方式。当在年轻代工作并运行时,它将执行所谓的小收集。这会遍历相应的区域,通常很快,并且对应用程序性能的影响很小。
然而,当一个收集器在旧代执行时,它被称为主要收集器,通常会导致应用程序阻塞。这意味着在收集器运行期间,您的应用程序可能无法响应,这会严重影响性能。
现在有几个更详细的方法可以深入了解 JVM 垃圾收集器的工作方式,并且它们都对性能有影响。
垃圾收集器算法
多年来,垃圾收集器算法得到了增强,现在有多种算法可供选择。它们匹配多种类型的应用程序,并且根据产品或多或少进行了适应:
-
串行收集器:这是一个单线程实现,是客户端机器(32 位或单处理器)的默认算法。
-
并行收集器:串行收集器算法适应服务器资源(快速 CPU 和大内存大小)。并行收集器是服务器机器(>= 2 处理器)的默认收集器。
-
并行压缩收集器:这允许并行处理老年代。
-
并发标记清除(CMS)收集器:使用此收集器时,老年代将与应用程序并行处理。
-
垃圾优先收集器(G1):这种收集器与应用程序同时进行,目标是本书中我们处理的服务器应用程序。这将是 Java 9 的默认收集器,但已经在 Java 8 中可用。
串行收集器
要强制使用串行收集器,您需要将以下选项添加到您的 JVM 中:
-XX:+UseSerialGC
一旦将此选项添加到 JVM 中,您将有一个使用单个线程的垃圾收集器,并且可能会锁定应用程序以进行收集。
第一次收集会将仍然使用的对象从伊甸园移动到第一个空的幸存者空间。如果使用对象太大,它们将直接移动到老年代。然后,幸存者 1(也称为幸存者来源)空间中的较年轻对象如果空间允许,将移动到幸存者 2(也称为幸存者目标)空间;否则,它们将直接移动到老年代。较老的对象将移动到老年代。
一旦完成所有这些移动,伊甸园和幸存者空间(之前已满)就可以释放。请注意,幸存者空间在角色上被反转,这意味着幸存者空间始终为空。
这里有一种表示算法的方法:

我们从一个状态开始,其中伊甸园已满,第一个幸存者有一些对象:

在第一阶段,如果它们适合(两个小绿色块),则使用的对象将从伊甸园移动到第二个幸存者,或者如果太大(最后的大块),则直接移动到持久空间:

现在,相同的逻辑应用于第一个幸存者空间,因此我们有小块使用的对象移动到另一个幸存者,而大块则移动到持久空间。在这个阶段,你仍然可以在第一个幸存者中找到未使用的对象:

最后一步是释放未使用的内存:在我们的例子中是伊甸园和第一个幸存者。在下一个周期中,将遵循完全相同的逻辑,但两个幸存者空间将颠倒。
这个算法主要关注年轻代的管理,但它本身并不足够,因为它会很快填满老一代。因此,一个完整的周期需要一个名为标记-清扫-压缩的第二个算法,它应用于持久空间。
如你所猜,该算法有三个阶段:
-
标记:在这个阶段,收集器识别出仍然被使用的实例,并标记相关的内存空间
-
清扫:上一步中未标记的内存被释放
-
压缩:上一步可能已经在内存空间中创建了空洞,因此收集器将其压缩,确保所有对象都并排放置以实现更快的访问
你可以用以下图表来可视化它——对于一个单独的内存区域:

假设我们的初始状态是前面的图表。我们有五个对象填充空间。第一步是将仍然使用的对象标记,以便能够移除剩余的(不再使用的)。可以用以下图表来表示:

在这个说明中,较暗的块是不再使用的,较亮的块是仍然使用的。现在我们已经从标记阶段进行,我们将执行清扫阶段并移除未使用的块:

到目前为止,在内存量方面我们做得很好。这意味着我们已经释放了我们能释放的所有内存,我们几乎可以在这里停止收集。然而,正如你在这张图中看到的,有一些空闲空间的空洞。
一个问题是,如果我们需要分配一个大对象,那么它可以分成多个内存区域,内存访问可能会变慢。为了优化这一点,我们有了最后一步,即压缩:

在这一最后步骤之后,内存得到了优化(压缩);所有对象都并排放置,我们在最后得到了最大的可用内存区域空闲。
对于服务器(别忘了我们在这本书中讨论的是 Java EE),这通常不是最快的垃圾收集器模式,但理解这些概念非常重要。如果你编写一个 Java EE 客户端(例如使用 JAX-RS 客户端 API),它仍然适用于你的最终交付,这不会需要大量的内存分配。
并行和并行压缩收集器
并行收集器接近于串行收集器(这就是为什么理解串行算法很重要)。主要区别将在于它如何设置收集。我们看到了在串行收集中,单个线程负责收集,但在并行收集器中,多个线程承担这个角色。这意味着你可以期望通过线程数(理论上)来减少 停止世界 的持续时间(当垃圾收集器强制应用程序停止响应并执行其任务时)。
这可以通过在 JVM 中添加此选项来激活/强制(这应该是服务器机器的默认设置):-XX:+UseParallelGC。
并行收集器可以通过几个 JVM 选项进行调整,但以下是一些你可能想要自定义的选项:
-
-XX:MaxGCPauseMillis=N:你将值(N)配置为你希望垃圾收集器暂停的最大持续时间。请注意,这只是一个提示,并不能保证你将获得期望的结果,但它可以作为第一次尝试是有趣的。同时,请记住,这将优化垃圾收集器暂停的持续时间(也许,不是应用程序的吞吐量,这可能是服务器的一个问题)。最后,你可能需要手动调整堆大小和比率,但开始测试这个 JVM 选项可以给你一些关于如何进行的提示。 -
-XX:GCTimeRatio=N:这也是一个提示,允许你请求应用程序时间中不超过 1/(1+N) 的时间用于垃圾收集。这是为了优化应用程序的吞吐量——这通常是服务器的情况。这不是最容易理解配置,所以让我们用一个小的例子来说明。如果你将 N 设置为 19,那么应用程序时间的 1/20(5%)将分配给垃圾收集。默认值是 99,所以只有 1% 的应用程序时间分配给垃圾收集。 -
-XX:ParallelGCThreads=N:这允许你配置分配给并行垃圾收集的线程数。
这些标志没有魔法值,但一旦你确定了应用程序的内存需求,调整它们以与应用程序要求相一致是非常有趣的。
值得注意的是,垃圾收集器可以根据配置的比率调整代的大小,试图尊重它。这是通过增加代的大小来减少收集时间来实现的。默认情况下,代的大小增加 20%,减少 8%。在这里,你也可以通过一些 JVM 标志来自定义这种调整。
有很多标志,它们的名称和支持的值可能取决于您使用的 JVM。由于它们不是可移植的标志,您可能需要根据您的 JVM 文档进行检查。对于 Hotspot,您可以使用以下命令:
java -XX:+PrintFlagsFinal -version
此命令打印 Java 版本(仅为了防止任何错误)以及 JVM 标志,允许您列出所有标志,这是我们关注的重点部分。一旦您获得输出,您只需过滤您想要的标志。在我们的情况下,我们想要自定义增加/减少生成大小的方式,因此我们可以使用grep命令(在 Unix 上)通过Generation关键字来过滤标志:
$ java -XX:+PrintFlagsFinal -version 2>&amp;1 | grep Generation
uintx TenuredGenerationSizeIncrement = 20 {product}
uintx TenuredGenerationSizeSupplement = 80 {product}
uintx TenuredGenerationSizeSupplementDecay = 2 {product}
bool UseAdaptiveGenerationSizePolicyAtMajorCollection = true {product}
bool UseAdaptiveGenerationSizePolicyAtMinorCollection = true {product}
uintx YoungGenerationSizeIncrement = 20 {product}
uintx YoungGenerationSizeSupplement = 80 {product}
uintx YoungGenerationSizeSupplementDecay = 8 {product}
+PrintFlagsFinal允许您列出选项、它们的值(等于号之后)、它们的类型(第一个字符串)以及标志类型(在大括号中)。
如果您向 JVM 添加其他选项(如-client或-server),它将调整值以反映这些标志。
如您在前面的截图中所见,您可以使用-XX:YoungGenerationSizeIncrement来在需要时自定义年轻代增加的百分比,以尊重配置的比率。YoungGenerationSizeSupplement是启动时使用的年轻代大小增加的补充,而衰减标志是补充值的衰减因子。实际上,老年代也有类似的配置。
这些配置非常高级,您必须在执行之前确保您能解释为什么调整它们;否则,您可能会弄乱您的 JVM 配置,并使您的应用程序表现不佳。
最后,并行 GC 仍然使用单个线程进行老年代收集。
现在,还有一个压缩并行收集器。它与年轻代中的并行收集器相同,但在老年代中,算法略有不同。它接近于标记/清除/压缩算法,除了它将空间分成更多区域,以便收集器可以并行地在这上面工作。然后,清除阶段被一个总结阶段所取代,其中检查密度以请求压缩。最后,压缩是并行完成的。要使用此选项,您需要激活另一个 JVM 标志:-XX:+UseParallelOldGC。此选项对于具有大堆的应用程序来说应该是好的,这对于处理大量并发请求并使用某些缓存机制的应用程序来说是这种情况。
并发标记清除(CMS)
CMS 的目标是减少 GC 暂停,使您能够在应用程序运行时执行 GC。为此,它使用多个线程。主要思想是在老年代充满之前释放一些内存,并且 GC 需要暂停应用程序线程。如果这种情况发生得太频繁,您可能需要调整应用程序调优(CMS 配置)以尽可能避免这种情况,并保持其正确行为。您可以通过检查verbose:gc输出和Concurrent Mode Failure消息来识别此类问题。
与标准的标记阶段相比,CMS算法将暂停应用程序两次:第一次暂停标记从内存图根直接可达的对象,第二次暂停识别在并发跟踪阶段遗漏的对象。这个并发跟踪阶段将占用应用程序不能再使用的资源,并且吞吐量可能会略微下降。
一个集合主要有两个触发器:
-
一种基于内存历史统计信息的超时机制,并添加了一个安全边界以避免并发模式失败,这代价非常高。这个安全边界可以通过
-XX:CMSIncrementalSafetyFactory=N标志根据百分比进行控制。 -
基于剩余的持久空间大小。这个触发器可以通过
-XX:CMSInitiatingOccupancyFaction=N进行控制,其中N是如果满了应该触发收集的持久空间的百分比。默认情况下,它是 92%(N=92)。
在调查长时间的 GC 暂停时,你可能需要调整安全因子,并可能调整占用分数选项,以查看提前(或延迟)触发 GC 是否有所帮助。
关于这个算法的最后一件事是,没有压缩,所以随着时间的推移,内存访问可能会变慢。
垃圾收集器第一代(G1)
垃圾收集器第一代(Garbage First collector)是最新的实现。它是一种服务器端实现,旨在减少暂停时间,并与应用程序并发工作。它引入了一种新的堆可视化方式。
堆被分成固定大小的区域。G1 开始并发标记区域。在这个阶段之后,它知道哪些区域几乎为空,然后开始从这些区域收集内存,使 G1 能够快速且不费劲地获得大量内存。这就是这个算法名称的由来。然后,在压缩阶段,G1 可以将多个区域的对象复制到单个区域,以确保其效率。
关于 G1 实现的一个重要事项是,它基于统计信息,即使模型在实际应用中相当准确,执行过程中也可能出现一些小故障。
G1 将堆分成区域的事实也意味着它适用于使用大量内存(Oracle 声称超过 6 GB)且需要很短暂停时间(小于 0.5 秒)的应用程序。这意味着从 CMS 切换到 G1 并不总是值得的;在切换到 G1 之前,你应该确保满足以下标准之一:
-
大约 50%的堆被活动数据使用
-
与 CMS 算法相比,统计信息并不那么准确(如果分配率变化很大)
-
你已经识别出长时间的 GC 暂停
G1 还有一些 JVM 标志,您可以使用它们来定制垃圾收集器应采取的行为。例如,-XX:MaxGCPauseMillis 设置您接受的暂停时间(统计上再次),而 -XX:ConcGCThreads=N 定义用于标记区域的并发线程数(建议设置为并行 GC 线程数的约 25%)。默认设置旨在针对最常见的用例,但您可能需要调整配置以适应您的应用程序及其使用(或重用)内存的方式。
再次,激活 G1 收集器需要其自己的 JVM 标志:-XX:UseG1GC。
常见内存设置
我们看到了内存的收集方式,以及有很多调整选项(再次提醒,不要忘记检查您特定的 JVM 选项)。然而,有一些非常常见的内存设置,在精细调整收集器之前,您可能希望自定义这些设置。以下是一个包含这些内存设置的小表格:
| 选项 | 描述 |
|---|---|
-Xmx<size> |
分配给堆的最大内存大小,例如,-Xmx1g 允许堆增长到 1GB |
-Xms<size> |
分配给堆的起始内存大小,例如,-Xms512m 将 512 MB 分配给堆 |
-Xss<size> |
栈大小(可以避免 StackOverflowError) |
-XX:SurvivorRatio=N |
Eden 和幸存空间之间的比率 |
-XX:MinHeapFreeRatio=N 和 -XX:MaxHeapFreeRatio=N |
触发堆调整的比率,最小标志将触发堆增加,而最大标志将触发堆大小减少 |
根据我们之前看到的,这意味着调整 JVM 内存可以导致一大组选项/标志,但不要害怕,这只是掌握其应用程序的问题。以下是一个完整的标志集示例:
-Xms24G -Xmx24G -XX:+UseG1GC -XX:MaxGCPauseMillis=150 -XX:ParallelGCThreads=16 -XX:ConcGCThreads=4 -XX:InitiatingHeapOccupancyPercent=70 ....
此命令是针对具有大量可用内存的服务器的常见服务器内存配置:
-
为堆分配 24 GB(由于最小和最大内存大小设置为相同的大小,因此是固定的)。
-
强制 JVM 使用 G1 收集器(准确,因为我们使用了超过 6 GB 的堆)并自定义 G1 配置。它定义了一个目标最大 GC 暂停时间为 150 毫秒,并要求 G1 使用 16 个并行线程进行内存收集,并在收集后使用四个线程标记区域。
-
最后,如果堆占用率达到 70%,将开始收集周期。
对于服务器来说,这不是一个坏的起始设置(您可以稍微增加线程数,但不要太多,因为它们可以在您的应用程序运行时同时使用);如果可以接受,增加最大 GC 暂停时间。
您可能调整最多的参数是堆大小(在先前的示例中为 24 GB),这取决于您应用程序的需求。
调试 GC 行为
现在我们知道了如何非常精细地调整 JVM 内存,我们需要了解我们的应用程序做了什么,以便能够调整配置。为此,JVM 提供了几个专门工具。
对于内存来说,最常见且可能最有用的工具是-verbose:gc选项,你可以在启动 JVM 时传递它。它将输出内存信息。如果我们激活它在我们的引用应用程序中,你将很快看到这些类型的行:
[GC (Allocation Failure) 41320K->14967K(153600K), 0.0115487 secs]
[GC (Metadata GC Threshold) 44213K->20306K(153600K), 0.0195955 secs]
[Full GC (Metadata GC Threshold) 20306K->13993K(139264K), 0.0596210 secs]
[GC (Allocation Failure) 77481K->23444K(171008K), 0.0081158 secs]
[GC (Metadata GC Threshold) 69337K->23658K(207360K), 0.0094964 secs]
[Full GC (Metadata GC Threshold) 23658K->20885K(248320K), 0.0792653 secs]
[GC (Allocation Failure) 144789K->27923K(252416K), 0.0078509 secs]
[GC (Allocation Failure) 155923K->36753K(252416K), 0.0174981 secs]
[GC (Metadata GC Threshold) 68430K->37173K(275968K), 0.0146621 secs]
[Full GC (Metadata GC Threshold) 37173K->31086K(321536K), 0.1868723 secs]
我们可以区分两种类型的行,对应不同的代区收集:
-
GC: 这是一个年轻代收集器
-
Full GC: 这是一个老年代收集器
每次收集都与一个原因相关联:
-
分配失败:GC 被要求运行,因为没有更多的内存可用
-
元数据 GC 阈值:元空间阈值已达到
然后,你可以看到内存的调整;例如,41320K->14967K(153600K)表示使用的内存从约 41M 调整到约 15M。括号中的数字是可用空间(这里约 150MB)。
不要害怕看到 GC 经常运行,甚至出现完整的 GC 行。虽然它们的执行速度快,而且你看到内存大小是可以接受的,但这根本不是问题。然而,如果执行时间长,即使收集后内存仍然很高,那么你可能需要调整 GC 或更新应用程序以确保它快速且可靠地返回。
如果你通过 JMX 激活这个输出,你可以转到java.lang:type=Memory MBean 并将Verbose属性设置为true。
如果你想要更多关于 GC 的详细信息,你可以添加-XX:+PrintGCDetails选项,这样行就会更加详细:
[GC (Allocation Failure) [PSYoungGen: 145920K->16360K(162304K)] 177095K->54877K(323072K), 0.0152172 secs] [Times: user=0.03 sys=0.01, real=0.02 secs]
我们可以识别之前的信息,但也有一些新的数据:
-
PSYoungGen:这是收集器类型;这个值表示小 GC -
(
X->Y(Z))之后的调整显示了年轻代的大小 -
最后的调整是整个堆的调整
-
最后,持续时间以用户时间、系统时间和实际时间来表示
如果你想要将所有这些信息(日志行)保存到文件中,你可以添加一个带有文件路径的标志,JVM 将把这个输出写入文件而不是控制台:
-Xloggc:/path/to/output.log -XX:+PrintGCDetails
这将允许你离线分析 GC 行为。有一些工具可以解析这个输出并直接可视化它。其中之一是GCViewer,你可以在github.com/chewiebug/GCViewer/wiki/Changelog找到它。一旦下载了 JAR 文件,你可以直接用 Java 运行它:
java -jar gcviewer-1.35.jar
然后,只需在界面中打开output.log文件,你应该会这样可视化你的 GC:

这个工具有两个主要有趣的功能:
-
从日志输出推导出的时间图
-
右侧的统计标签(显示 GC 的统计摘要和暂停的次数和持续时间)
这最后的信息可以让你在添加一些缓存或与主应用程序并行执行的某些后台任务后验证应用程序的行为。
这将使您能够确保应用程序的一部分对另一部分在内存方面的影响,并且如果内存受到新功能的过度影响,可能会发现性能问题。
这里是与图表颜色相关的图例:

我们在前面部分讨论的大多数信息都可以找到,特别是集合、世代等。请注意,Y轴可以读取两个单位(时间和内存大小),具体取决于您正在查看哪个图表。您可能会过滤打印的图表以清楚地查看它,但您将在 GC 运行时找到您需要查看的所有信息。
堆转储
有时,您可能需要获取堆转储以调查内存中的内容以及为什么垃圾回收(GC)运行如此频繁或如此长时间。为此,JDK 提供了一个名为jmap的工具,允许您从 Java PID 中获取转储:
jmap -dump:format=b,file=/path/to/dump.hprof <PID>
此命令将停止应用程序(类似于停止世界暂停)并将所有实例写入配置的文件中。使用jvisualvm打开输出文件将使您能够调查实例,特别是实例的数量和相应的分配大小。
它可能看起来像这样:

通常,您应该主要看到 JVM 类,如char[]、String、Map等。
如果您双击一个类型,您将看到实例。例如,在我们的报价管理器转储中的String上,我们将有一个类似这样的视图:

在这里,我们可视化与请求相关的String实例(请求 URL 或其子部分,例如协议、主机或端口)。因此,我们可以推断出这些实例与请求及其解析相关。这是完全正常的,但如果您开始看到很多自己的类在类、视图和/或几个类中,但分配大小巨大,您可以双击这些类型以获取实例详细信息以确定为什么会发生这种情况。
如果我们双击JsonQuotePage,我们将看到类似这样的内容:

左侧(实例列表)的实例引用对我们帮助不大,但单个实例的详细视图(右侧)显示了实际的数据结构及其值。
作为提醒,我们的 POJO 页面模型如下所示:
@JsonbPropertyOrder({"total", "items"})
public class JsonQuotePage {
private long total;
private List<JsonQuote> items;
// getters/setters
}
我们的结构(类)有两个字段:total和items,我们可以在从转储中获得的实例的详细视图中找到这两个条目。如图所示,我们甚至可以浏览items值。这将帮助您确定是否存在特定值,以及您是否需要自定义用于检索数据的查询。
Java EE 和资源
使用 Java EE,你可能会遇到很多绑定到请求上的易变对象(生命周期较短)。如果我们以QuoteResource为例,我们首先会分配我们的 JSON 模型,然后是我们的实体模型,等等。所有这些实例仅用于请求,不再需要其他。因此,垃圾收集器会迅速收集它们。垃圾收集器对于这种动态应用来说相当不错。然而,这并不意味着我们没有长期存在的实例。即使没有应用程序缓存,服务器也会缓存大量元数据以确保其正常运行和快速运行。一个例子是 CDI 容器,它会将所有 bean 的元数据保存在内存中,以确保当应用程序请求时可以创建它们。这会占用内存,并且只有在应用程序未部署时才会释放。这意味着通过调整应用程序的内存,你还需要确保调整服务器的内存。正如之前已经解释过的,在性能调整中,应用程序不仅由你的代码组成,还包括服务器代码;否则,你将错过大部分逻辑,你无法准确调整应用程序。
通常,服务器会添加一些长期存在的实例以确保服务器层不会减慢你的应用程序。然而,服务器为你实现的一种特定逻辑会直接影响应用程序的性能,你需要注意:资源。
我们在应用程序服务器中称为资源的东西通常是由服务器管理的。这通常是你在应用程序外部想要能够配置的东西,即使 Java EE 6 引入了一些@XXXDefinition注解,以便能够从应用程序本身执行此操作。目标是注入一个预先配置并且适应当前环境的实例。它通常也与外部资源或与机器资源直接相关。由于下一章将介绍线程,在这里,我们将关注外部资源。
在最常用资源列表的顶部,我们发现DataSource。这代表了我们报价应用程序中连接到数据库(如 MySQL)的连接。在深入研究DataSource案例之前,你可以遇到以下一些其他知名资源:
-
并发资源(
ManagedExecutorService)用于处理并发。我们将在下一章中处理它们。 -
DataSource,它连接到数据库。 -
JMS 资源,例如
ConnectionFactory,它连接到代理,以及Queue和Topic,它们代表目的地。 -
Java 邮件
Session,它允许你与邮箱交互(通常用于从应用程序发送邮件)。 -
资源适配器,它提供了一种以 EE 方式处理输入/输出的方式(与安全性和事务集成)。
所有这些资源都可以在服务器中进行配置——大多数来自应用程序——并允许操作团队(或负责部署的人员)调整应用程序使用的资源。这也让开发者不必关心配置,并确保部署足够可定制。
数据源
为了说明这一点,我们将参考我们的DataSource示例。在第一章中,我们配置了一个池和数据源。数据源只是应用程序在 JNDI 中使用的一个名称,用于查找数据源,并在其 JPA 层中使用它。这基本上就是数据源:一个连接工厂。这里关键的是连接的管理方式。
大多数情况下,生产数据源是远程进程,或者至少需要网络连接。如果你记得我们的 MySQL 连接 URL,我们使用了以下:
jdbc:mysql://localhost:3306/quote_manager
这是一个非常常见的开发配置,并且它连接到localhost. 因此,网络成本是机器本地环路的成本,这通常被优化得非常快。在实际部署中,你更有可能连接到以下:
jdbc:mysql://application.mysql.company.com:3306/quote_manager
这个 URL 将标记一个远程主机,获取连接将涉及一些实际延迟。确实,它不会像互联网连接那样慢,因为网络有很多交换机、路由器和跳数。然而,通过网络始终意味着一些毫秒级的延迟。
数据源(这通常也适用于 JMS 资源)有趣的地方在于与连接关联的协议是一个连接协议。理解一旦获得连接,发送命令就会更快,因为你不必创建另一个连接。
如果你运行了报价管理器应用程序,你可能会得到一些这样的警告:
Sat Sep 30 17:06:25 CEST 2017 WARN: Establishing SSL connection without server's identity verification is not recommended. According to MySQL 5.5.45+, 5.6.26+ and 5.7.6+ requirements SSL connection must be established by default if explicit option isn't set. For compliance with existing applications not using SSL the verifyServerCertificate property is set to 'false'. You need either to explicitly disable SSL by setting useSSL=false, or set useSSL=true and provide truststore for server certificate verification.
这个警告是由 MySQL 驱动程序发出的,并鼓励你使用 SSL 进行网络通信。这主要是出于安全原因,但 SSL 连接甚至更慢。你可以查看 HTTP2 协议(在 Servlet 4.0 版本中引入,包含在 Java EE 8 中),它引入了推送协议以避免浏览器通过 SSL 进行过多的连接,因为随着现代应用程序拥有大量网络资源,这已经开始变得非常慢。
这意味着反复重用相同的连接总是一个好主意,因为它将减少实际的连接步骤,并让应用程序发出命令并执行其业务。
为了解决这个连接问题,服务器将数据源配置为连接池。一个池是一组可重用的连接。每个服务器都有自己的池和自己的配置,这些配置或多或少是高级的,但都遵循相同的逻辑:
-
一些大小配置:在服务器空闲时保持内存中的连接数量(最小值),可以创建的连接数量(最大值),连接可以等待多长时间,等等
-
一些驱逐配置:确定一个连接应该从池中移除的方式
驱逐非常重要,因为池与长时间运行的连接相关。因此,你需要能够透明地移除损坏的连接。例如,MySQL 服务器默认情况下在 8 小时后丢弃连接。所以,如果你运行你的应用程序 8 小时又 1 分钟,如果没有驱逐,所有请求都将开始失败。对于数据源,这通常是通过ValidationQuery完成的,这是一个定时执行的 SQL 查询。如果这个查询失败,它被认为是验证连接无效并从池中移除。定时通常也可以配置,主要可以意味着三个时刻:在连接被共享并分配给应用程序之前,当连接被归还到池中时(应用程序已经完成),或者在后台线程中连接空闲时。
选择是在你接受因为连接尚未被后台线程驱逐而导致一些请求失败和你接受它稍微慢一点,因为每次连接都需要验证之间权衡。你甚至可以混合所有这些验证类型。这里一个非常重要的点是确保验证查询执行非常快。永远不要使用SELECT * from CUSTOMER,这可能会返回成千上万(或更多)行。始终使用一个常量结果集查询。对于 MySQL,建议你使用SELECT 1,这将只返回1。每个数据库都有这种查询,所以请检查它,并确保它在你的池中配置得当。
你确实会发现根据你所依赖的池子,会有其他配置,但它们对性能的影响较小,所以我们在这里不会详细说明。
关于连接池的一个关键点是——无论是数据源连接池还是其他类型的连接池——确保你不会等待它们。如前所述,如果你在等待连接,那么你的速度会比应有的速度慢,因为连接池应该避免这种情况。这意味着在性能方面,你不必犹豫,只需要求你的连接池永远不要等待连接。这个参数通常被称为最大等待时间。将其设置为 0 或非常小可能导致你的应用程序开始失败,并抛出异常,表示应用程序无法在配置的时间内检索到连接。这正是我们想要的!这意味着连接池对于应用程序的需求来说太小了,因此你需要增加连接池的大小。或者,你也可以减少发送到服务器的负载。这并不意味着在生产环境中你不应该配置任何等待时间。这是一个可以确保你响应客户的参数,但它可能会增加你的响应时间。如果这是可以接受的,一旦调整了应用程序,你就不必犹豫地增加这个值,但在做之前,请确保你没有使用错误配置的连接池。
如果你的连接池大小过小,你可能会在堆栈跟踪中看到它,或者在调用时设置的监控中看到,例如allocateConnection或getConnection,具体取决于你的服务器。
在调整大小方面,简单地设置尽可能多的连接可能很有吸引力,但你需要记住几个重要点:
-
数据库连接数可能有限。永远不要将所有连接都分配给你的应用程序集群,并确保你可以保留至少几个用于维护目的。例如,如果你的 MySQL 允许 152 个连接(MySQL 的默认
max_connections为 151,而mysqld允许max_connections+1个实际连接),那么你的应用程序作为一个集群,可以使用 140 个连接。 -
连接池的处理需要你处理并发。你的应用程序将从所有线程请求连接,然后容器池需要为所有线程提供不同的连接,以确保没有跨线程连接使用的数据损坏。另一方面,在应用程序运行的同时也会发生回收,可能会锁定连接池(这接近 GC 算法问题)。需要处理的池工作越少,它的表现就越好,对应用程序的影响就越小。
这意味着不过度调整连接池也很重要。这通常是一个基于你的应用程序和目标 SLA 的启发式方法。如果你对必须使用的连接池大小没有头绪,你可以从将连接池的最大大小设置为最大并发线程数的 25%(例如 HTTP 连接池大小)开始,然后逐渐增加,直到不再出现错误,最大等待时间为 0。
Java EE 和连接池
Java EE 在其堆栈中涉及许多池类型。以下是在调整应用程序时你可能想查看的池类型总结。每次调整逻辑都将基于相同的逻辑:没有等待时间,并从中等大小开始,以避免过度分配资源并过度影响应用程序。
| 池类型 | 描述 |
|---|---|
DataSource 池 |
这处理和回收数据库连接。 |
ConnectionFactory 池 |
这处理与代理的 JMX 连接。 |
@Stateless 池 |
这处理无状态实例的回收。如今,当无状态实例用作穷人的节流实现(最大实例 = 最大并发性)或实例访问某些不安全的资源时,这些资源非常昂贵且难以实例化,它就变得相关。它可以被视为针对应用程序需求的 Java EE 池 API。 |
| HTTP 线程池 | 这些线程用于处理请求。它们通常有一些兄弟线程来接受连接(选择器线程)。 |
| 管理的线程池 | 应用程序可以使用这些线程池来执行自定义任务。它们通常用于反应式编程,以继承反应堆栈中的 EE 功能,如 RxJava。 |
| 资源适配器/ JCA 连接器池 | JCA 规范定义了多个池:以依赖方式配置但与其他池具有相同原则的实例池,以及使用 WorkManager 的线程池,这是在 EE 并发工具方式中,由服务器注入到应用程序(连接器此处)的用户线程池。如今,应用程序很少使用 JCA 连接器,但如果你继承了其中一个,请确保它已良好调整并与你的服务器集成。 |
Java EE 和 HTTP 池
即使 Java EE 容器越来越多地用于守护程序和独立应用程序,但大多数开发的应用程序仍然是 Web 应用程序,因此要么作为其他服务器的客户端使用 HTTP,要么作为服务器本身。
在最后一句话中,Java EE 容器 包括广泛的嵌入式容器,如 TomEE Embedded、Apache Meecrowave、WildFly Swarm 等,并不仅限于独立容器或完整配置的服务器。
这意味着 Java EE 配置将不得不处理 HTTP 配置。它需要在多个级别(网络、HTTP 缓存等)以及多个层次(服务器/HTTP 连接器、客户端连接池、SSL 调优等)进行处理。
我们将在第五章 深入探讨此细节,扩展:线程和影响,以及第六章 懒加载,缓存你的数据中详细介绍相关配置。
Java EE 隐含特性
Java EE 哲学始终是 即开即用。然而,它可能对性能有一定影响,因为有时功能被激活,而你的应用程序根本不需要它们。如果你知道你不需要一个功能,请不要犹豫去禁用它。
例如,在 persistence.xml, 我们通过添加以下行禁用了 bean 验证集成:
<validation-mode>NONE</validation-mode>
这样可以避免 JPA 提供者添加用于验证实体的 bean 验证监听器,从而在不影响应用程序的情况下节省一些 CPU 周期。如果你检查我们应用程序的链,我们使用 JAX-RS(具有 bean 验证集成)然后是 JPA(具有另一个 bean 验证集成)。
验证的规则可以是始终在数据进入系统时(对我们来说是 JAX-RS)进行验证,而不是在内部进行验证。实际上,从应用程序逻辑的角度来看是多余的。这就是为什么在 JPA 层禁用它是可以的。
摘要
在本章中,我们看到了 Java 如何管理内存,以及如何影响它以优化和适应你的应用程序需求。我们还看到了 Java EE 服务器提供的资源如何帮助你节省时间,因为它们不仅使你能够跳过使用之间的重新连接时间,而且意味着对服务器内存和 CPU 的专用调整。
本章背后的想法是确保你拥有调查任何内存问题的关键和知识,并且能够在不迷失方向或使用一些随机数字的情况下调整内存和资源。
此外,这部分可能是最不可移植的部分,并且将与你在部署中使用的 JVM(对于内存)和服务器(对于资源)相关。所有概念仍然适用,但调整它们的方式可能不同,因为这不是一个标准化的东西——即使 JVM 调优的变化不如服务器配置那么大。然而,不要犹豫去查看你的 JVM 或服务器文档,并确保在进入基准测试阶段之前已经阅读过,以免在测试你事先不知道的选项时浪费时间。
在下一章中,我们将看到如何充分利用 Java EE 并发编程,以及它与 Java EE 线程模型的关系,以确保你的应用程序可以扩展。内存和 CPU 是服务器在机器上使用的两种最核心的资源:我们刚刚看到了内存资源,现在我们将通过线程研究来处理 CPU。
第五章:扩展 - 线程及其影响
可扩展性一直是 Java 的关注点,因此,在 Java 1.0 版本中引入了与线程相关的 API。其理念是能够从最新的硬件更新中受益,以便并行处理应用程序。
能够并行处理多个请求对于 Java EE 服务器进行扩展至关重要,但在我们现代的 Java 世界中,您还需要能够控制自己的线程。此外,Java EE 引入了所需的 API,以便在良好的条件下进行操作。
在本章中,我们将探讨以下主题:
-
Java EE 线程模型
-
线程间的数据一致性
-
Java EE 隐藏的线程使用
-
如何将响应式编程与 Java EE 编程模型集成
Java EE 线程模型
Java EE 哲学长期以来一直能够为用户提供一个定义良好且安全的编程模型。这就是为什么大多数 Java EE 默认设置都是关于线程安全的,以及像企业 JavaBeans(EJB)这样的规范默认阻止自定义线程使用。这并不意味着 Java EE 完全忽视了线程,但显式地从一个应用程序中使用线程池并不很自然。此外,大多数时候,采用的编码风格要么违反 Java EE 的(严格)规则,要么非常冗长。
在详细说明 Java EE 新增的 API 以帮助您开发并发应用程序之前,让我们先看看基本的 Java EE 模型以及它如何已经可以帮助您进行扩展。
如果我们回顾 Java EE 8(完整配置)中包含的规范,我们会得到一个长长的列表。现在,如果我们检查哪些规范使用线程,列表将会缩短,我们可以在它们之间找到一些共同点。以下是一个表格,试图表示规范是否管理专用线程以及它们是否明确与线程交互(通过使用提供的线程处理跨线程调用)或简单地使用调用者(上下文)线程:
| 规范 | 管理专用线程 | 与线程交互 | 注释 |
|---|---|---|---|
| EJB 3.2 | 是 | 是 | @Asynchronous 允许您在专用线程池中执行任务。与@Singleton一起使用@Lock允许您控制 bean 的线程安全性。 |
| Servlet 4.0 | 是 | 是 | 默认情况下,每个请求都在容器提供的单个线程中执行。当使用AsyncContext时,您可以在自定义线程中执行任务,并在稍后从另一个线程恢复请求。 |
| JSP 2.3/JSP Debugging 1.0 | 否 | 否 | 继承自 servlet 模型。 |
| EL 3.0 | 否 | 否 | 使用调用者上下文。 |
| JMS 2.0 | 是 | 否 | 本身,JMS 可以被视为一种特定的连接器(如 Connector 1.7),但在这个案例中要特别说明。JMS 有两种使用方式:在服务器端和客户端。服务器端通常是一个网络服务器,等待连接。这就是将使用专用线程的地方(如任何套接字服务器)。然后,处理将完全委托给连接器。在客户端,它通常继承自调用上下文,但也使用自定义线程,因为它按设计是异步的。因此,它需要自己的线程池来处理 JMS 规范的这一部分。 |
| JTA 1.2 | 否 | 是 | JTA 不管理线程,而是 绑定 其上下文到线程。具体来说,当事务开始时,它仅对初始线程有效。 |
| JavaMail 1.6 | 是 | 否 | JavaMail 作为你的 Java 代码与发送/接收邮件方式之间的桥梁,其实现再次与套接字相关联,因此,它通常依赖于专用线程。 |
| Connector 1.7 | 是 | 是 | Connector 规范是与外部系统交互的标准方式(即使连接器实现通常只处理单向,双向方式)。然而,它通常在两层中使用专用线程,第一层与网络交互相关,第二层与容器交互相关,通常通过 WorkManager 进行,它是 Java EE 并发实用工具规范 的前身。像 JTA 一样,它也使用与线程相关联的上下文信息。最后,由于它与 JTA 交互,交互的一部分按设计绑定到线程上。 |
| Web Services 1.4 / JAX-RPC 1.1 | 否 | 否 | Web 服务通常只是继承自 servlet 上下文线程模型。 |
| JAX-RS 2.1 | 是 | 是 | JAX-RS 继承自 servlet 上下文模型,但自从 JAX-RS 2.0 以来,服务器可以异步处理请求,这得益于 Servlet 3.0 的 AsyncContext。在这种情况下,当请求完成时,开发者必须通知容器,并且与线程交互,因为这通常是在与 servlet 线程不同的线程中完成的。在客户端,JAX-RS 2.1 现在有一个反应式 API,能够使用自定义线程进行执行。 |
| WebSocket 1.1 | 是/否 | 是/否 | 通常,WebSocket 规范被设计为在 servlet 规范之上实现,这实际上是 Java EE 的核心传输。然而,在几个情况下,可能需要使用一些针对 WebSocket 需求(长连接)的线程定制。这部分高度依赖于容器。最后,可能需要一些自定义的 WebSocket 线程来处理连接驱逐等问题,但这对接端用户和性能的影响较小。 |
| JSON-P 1.1 / JSON-B 1.0 | 否 | 否 | 这个规范(JSON 低级 API 和 JSON 绑定)没有任何线程相关操作,并且简单地执行在调用者上下文中。 |
| Java EE 并发工具 1.0 | 是 | 是 | 并发工具主要具有定义 Java EE 线程池 的能力,实际上它们管理自定义线程。它还通过 ContextService 透明地促进某些上下文的传播,例如安全、JNDI 上下文、事务等。请注意,然而,传播不是标准的,您可能需要查看服务器文档以了解它确切的功能。 |
| 批处理 1.0 | 是 | 否 | JBatch 按设计是异步的。当你启动一个批处理时,调用在批处理完成之前返回,因为它可能非常长。为了处理这种行为,JBatch 有自己的线程池。 |
| JAXR 1.0 | 否 | 否 | 这个规范很少使用,并且已经过时(在 Java 引入 nio 之前)。作为一个客户端,它不使用自定义线程。 |
| Java EE 管理规范 1.1(或 1.2) | 是/否 | 否 | 这个规范允许您与服务器及其定义(资源和应用)交互。它使用另一种传输技术,因此通常需要专用线程。 |
| JACC 1.5 | 否 | 否 | 这是一个将授权策略与 Java EE 容器链接的规范。它是上下文执行的。 |
| JASPIC 1.1 | 否 | 否 | 这是一个安全规范,也是上下文执行的。 |
| Java EE 安全 API 1.0 | 否 | 否 | 这是 Java EE 的最后一个安全 API,使其非常实用,但它仍然与调用者上下文相关。 |
| JSTL 1.2 | 否 | 否 | 继承自 servlet 模型。 |
| Web 服务元数据 2.1 | 否 | 否 | 这主要涉及 Web 服务的注解,所以没有特定的线程模型。 |
| JSF 2.3 | 否 | 否 | 继承自 servlet 线程模型(这是一个简化,但对于本书的上下文来说足够好了)。 |
| 常见注释 1.3 | 否 | 否 | 只是一组由其他规范复用的 API,没有直接绑定在这里的特殊行为。 |
| Java 持久化 2.2 (JPA) | 否 | 否 | 继承自调用者上下文。 |
| Bean 验证 2.0 | 否 | 否 | 继承自调用者上下文。 |
| 拦截器 1.2 | 否 | 否 | 继承自调用者上下文。 |
| Java EE 上下文和依赖注入 2.0 (CDI) | 是 | 是 | CDI 2.0 支持异步事件,这些事件依赖于一个专用的线程池。CDI 关于 上下文,也将上下文数据绑定到线程,例如 @RequestScoped 上下文。 |
| Java 1.0 依赖注入 (@Inject) | 否 | 否 | 这主要是 CDI 的一组注解,所以这里没有真正的线程相关行为。 |
如果我们回顾所有线程的使用情况,我们可以区分以下类别:
-
异步用法:使用线程不阻塞调用者执行的规范(例如 JAX-RS 客户端、批处理 API、CDI 异步事件等)
-
需要线程进行选择器(部分接受连接)和请求处理的网络相关实现
在代码上下文中,这通常与代码的外出层有关。确实,网络是应用程序的外出,但异步使用也在此意义上,因为它们将执行分成两个分支:继续执行的调用上下文和一个不再与调用者相关的新分支。
这对你意味着什么?当你负责一个应用程序时,至少从性能或配置的角度来看,你需要清楚了解应用程序的线程执行路径(当应用程序使用一个不同于受影响的线程时,当请求进入系统时)。这也适用于像微服务这样的跨系统架构,你需要跟踪执行上下文的分解。
线程数据和一致性
在具体到 Java EE 之前,重要的是退一步理解并发对编程模型的影响。
并发数据访问
当单个线程访问数据时,访问总是线程安全的,一个线程在另一个线程读取数据时不可能修改数据。当你增加线程数量,并且多个线程可以访问相同的数据结构实例时,一个线程可能会读取正在被修改的数据,或者可能发生两个并发修改,导致不一致的结果。
这里是一个用两个线程表示这个问题的模式。请记住,Java EE 服务器通常处理大约 100 到 1000 个线程,因此影响更为显著:

在这个简单的例子中,我们有一个线程(Thread 1)正在设置数据,这些数据应该是一个复杂结构。同时,我们还有另一个线程(Thread 2)正在访问这些数据。在先前的图中,非常细的黑线代表线程的生命周期,而粗黑线则代表线程上下文中的方法执行。蓝色方框代表数据设置/获取的执行时间,红色区域代表两个线程在数据使用上的重叠。换句话说,你可以认为垂直单位是时间。
为了理解没有保护的代码可能产生的影响,让我们用以下简单代码具体化数据结构:
public class UnsafeData {
private boolean initialized = false;
private String content;
public void setData(String value) {
content = value;
initialized = true;
}
public String getData() {
if (!initialized) {
throw new IllegalStateException("structure not initialized");
}
return content;
}
}
这个简单的结构旨在存储一个String值并处理一个状态(initialized),这允许结构在未初始化时防止对数据的访问。
如果我们将这个结构应用到我们之前的图片时间线上,可能的情况是线程 2调用getData失败并抛出IllegalStateException,而setData方法被调用并设置了initialized变量。换句话说,结构(getData)在变化时被访问,因此其行为与数据的完整状态不一致。在这种情况下,错误并不严重,但如果你考虑另一个例子,比如求和某些值,你将只会得到错误的数据:
public class Account {
private double value; // + getter
public void sum(double credit, double debit) {
value += credit;
value += debit;
}
}
如果sum的执行和value的访问同时进行,那么账户的值将会错误,报告可能会不一致,验证(可能考虑信用+借记=0)将失败,使这个账户出现错误。
如果你再进一步看看并集成一些 EE 功能,你会很快明白情况会更糟。让我们以一个 JPA 实体为例,比如Quote,并假设两个线程正在不同地修改实体:一个线程修改价格,另一个线程修改名称。当每个线程更新实体时会发生什么?数据库无法同时处理这两个更新,所以如果没有失败,则最后一个更新将获胜,只有其中一个更新会被考虑。
JPA 提供了乐观锁和悲观锁来正确解决上述问题。一般来说,尽量使用乐观锁,直到你真正需要悲观锁。实际上,即使它可能需要你处理重试逻辑,它也会给你更好的性能。
Java 和线程安全
这一节并不打算解释 Java 提供的所有确保代码线程安全的解决方案,只是给你一些关于 Java Standalone API 的亮点,如果需要,你可以在 Java EE 应用程序中重用。
同步
当然,这是最古老的解决方案,但在 Java 中仍然可用,synchronized关键字允许你确保方法不会被并发执行。你只需在你的方法定义中添加它,如下所示:
public class SafeData {
private boolean initialized = false;
private String content;
public synchronized void setData(String value) {
content = value;
initialized = true;
}
public synchronized String getData() {
if (!initialized) {
throw new IllegalStateException("structure not
initialized");
}
return content;
}
}
这与我们所看到的结构完全相同。但现在,多亏了添加到每个方法的synchronized关键字,对方法的调用将强制并发调用其他同步方法等待第一个方法执行完毕后再执行。具体来说,它将像单线程一样链式执行方法。
synchronized关键字与一个实例相关联,所以两个不同实例即使被同步也不会相互锁定。也可以将synchronized用作代码块,并将实例传递给要同步的实例。如果你这样做,并传递一个静态实例,那么你将锁定所有实例,这可能会阻止应用程序在公共代码路径上扩展。
锁
Java 1.5 通过 java.util.concurrent 包引入了 Lock 接口,该包包含许多与并发相关的类。它实现了与 synchronized 相同的目标,但允许您手动控制同步代码的范围。
Lock 和 synchronized 的性能在不同版本的 Java 中有所不同,但最新版本已经取得了很大的进步。一般来说,如果您不优化计算算法,选择其中一个或另一个将导致结果相似。然而,如果同时访问实例的并发线程数量很高,通常最好使用 Lock。
由于 Lock 是一个接口,它需要一个实现。JRE 提供了一个默认实现,称为 ReentrantLock。替换 synchronized 块的方式如下:
final Lock lock = new ReentrantLock();
lock.lock();
try {
doSomeBusinessLogic();
} finally {
lock.unlock();
}
锁的实例化是通过 new 关键字直接完成的。我们在此处指出,ReentrantLock 可以接受一个布尔参数来请求它尊重锁调用的公平顺序(默认为 false,在性能方面通常足够好)。一旦您有一个已锁定的实例,您可以通过调用 lock() 方法来确保您是唯一执行代码的人。一旦您完成受保护的代码,您可以通过调用 unlock() 来释放当前线程,让另一个线程执行其代码。此外,请注意,所有这些锁定逻辑都假设锁是在线程之间共享的。因此,实例化通常在每个实例中只进行一次(在构造函数或 @PostConstruct 方法中)。
调用 unlock() 方法至关重要;否则,其他锁定线程将永远不会被释放。
Lock API 更常见的用法是将锁定和解锁调用分成两个。例如,以 Java EE 的使用为例,你可以在请求开始时锁定一个实例,在请求结束时解锁它,以确保单个请求正在访问它。这可以通过 Servlet 3 中的监听器实现,即使是异步请求也可以。但您将没有可以包围的块;相反,您将拥有多个回调,您需要将其与以下内容集成:
public class LockAsyncListener implements AsyncListener {
private final Lock lock;
public LockAsyncListener(Lock lock) {
this.lock = lock;
this.lock.lock();
}
@Override
public void onStartAsync(AsyncEvent event) throws IOException {
// no-op
}
private void onEnd() {
lock.unlock();
}
@Override
public void onComplete(AsyncEvent event) throws IOException {
onEnd();
}
@Override
public void onTimeout(AsyncEvent event) throws IOException {
onEnd();
}
@Override
public void onError(AsyncEvent event) throws IOException {
onEnd();
}
}
将此监听器添加到 AsyncContext 后,锁定将遵循请求的生命周期。使用方式可能如下所示:
public class SomeServlet extends HttpServlet {
@Override
protected void service(HttpServletRequest req, HttpServletResponse
resp)
throws ServletException, IOException {
final AsyncContext asyncContext = req.startAsync();
final SomeInstance instance = getInstance();
asyncContext.addListener(new
LockAsyncListener(instance.getLock()));
execute(asyncContext, instance)
}
}
一旦获得 AsyncContext,我们将其添加到锁监听器上并执行请求。当请求因超时、异常或简单地说,正常终止时,锁定将被释放。
这种使用同步块的实现相当困难,通常需要一些解决方案。这是一个 Lock API 更强大的例子。
我们在此处不会详细说明,但 ReadWriteLock API 为您提供了两个锁的持有者:一个用于保护读访问,另一个用于写访问。目标是让读访问可以并行进行,并确保只有在单个线程访问数据时才进行写访问。
java.util.concurrent
虽然 Java 独立并发编程的详细覆盖超出了本书的范围,但了解 Java 7 和 Java 8 在这个领域有很多增强是很重要的。所以不要犹豫,去查看它的包。在有趣的类中,我们可以注意以下内容:
-
CountDownLatch:这是一种简单而有效的方式,确保 N 个线程正在等待另一个线程拥有的条件(有点像赛跑中的起跑器)。 -
Semaphore:这允许你表示和实现权限桶。最有趣的部分是你可以增加和减少相关的计数器。它可以是一种实现防波堤解决方案的简单方式。 -
CyclicBarrier:这是一种在代码的某些点上同步多个线程的方式。它的 API 很有趣,因为它允许你添加可以在所有线程上执行的共享逻辑。它可以被视为CountDownLatch的对立面。 -
Phaser:这是一种比CyclicBarrier和CountDownLatch更灵活的屏障实现。 -
Atomic*:这是一种原子更新数据实例的方式。
易变数据
最后,为了总结这部分 Java 独立并发编程的内容,有必要记住为什么volatile关键字很重要。这个关键字允许你请求 JVM 每次访问数据时刷新它读取的值。
使用非常简单;只需在字段声明中添加volatile关键字:
public class SomeData {
private volatile int value;
}
要理解为什么这个关键字会改变一切,你需要记住 JVM 可以有一些与线程相关的缓存字段值(这是一种非常底层的缓存,与我们将在下一节看到的内容无关)。像前一个片段中那样添加这个关键字强迫我们绕过这个缓存。它应该会慢一些。然而,单独使用时通常很快,所以通常值得这样做。
Java EE 和线程
正如我们在本章开头看到的,Java EE 可以静默地使用线程。任何线程的使用都很重要,即使依赖于 Java EE 实现也是好的,因为这是你不需要维护的代码。不识别线程的问题是你可能会遇到你的上下文(ThreadLocal)不可用或带有错误值的情况。不识别线程的另一个陷阱是你可能会滥用线程,在机器上消耗比所需更多的资源。让我们回顾一些此类用法的代表性案例。
CDI 异步事件
CDI 2.0 引入了异步事件的观念。这是一种为调用者异步触发事件的方式。以下是一个示例用法:
public class AsyncSender {
private static final Logger log =
Logger.getLogger(AsyncSender.class.getName());
@Inject
private Event<MyEvent> sender;
public void send(final MyEvent event) {
final CompletionStage<MyEvent> cs = sender.fireAsync(event);
cs.thenRun(() -> {
// some post processing once all observers got notified
});
}
}
这个代码片段通过fireAsync发送异步事件。这个 API 有趣的部分是它返回CompletionStage,这使你能够在事件通知所有异步观察者之后链式调用一些逻辑。
异步事件仅通知异步观察者。它使用一个新的观察者标记:@ObserverAsync。以下是一个签名示例:
public void onEvent(@ObservesAsync MyEvent event);
CDI 处理此类事件提交的方式是通过使用 CompletionFuture.all 或通过在单个异步线程中链式异步观察者(在 Weld 中可配置;OpenWebBeans 仅支持第一种解决方案)。无论如何,它将单个未来提交到 CDI 线程池。尽管池配置尚未完全标准化,但重要的是要知道它在所有容器中都是可行的,并且如果你依赖它,它是应用程序的重要调优配置。
几个容器将默认使用 JVM 的通用 ForkJoin 池,这并不具备很好的可扩展性。因此,你可能需要提供一个专门针对应用程序使用的自定义线程池。
在用户代码方面,优先选择接受 NotificationOptions 实例签名的 fireAsync 方法(通常回退到默认容器池)是很重要的。这样做将允许你提供一个自定义池:
sender.fireAsync(event, NotificationOptions.ofExecutor(myExecutor));
此签名允许你传递一个自定义池,并对其进行适当的调优(例如,使用 Java EE 并发工具)。它还将允许你通过事件指定所使用的池,并避免将它们全部放入同一个桶中。实际上,如果存在一些使用依赖关系,这可能会潜在地锁定你的应用程序!
关于此 API 的最后一个提示是,如果你修改了事件,请确保通过 CompletionStage 获取新状态时同步事件。你可以使用我们之前讨论过的任何 Java 独立技术。
EJB @Asynchronous
EJB 是最早的 Java EE 规范之一。当时,它是受到最多关注和功能最丰富的规范。现在,它正逐渐被 CDI 和集成所取代,以及其他规范,如 JTA、JPA 等。然而,它仍然包含一些在其他 Java EE 中找不到的有用功能。
EJB 也有异步解决方案。与 CDI 相比,它更为直接,因为你可以将一个方法标记为异步:
@Asynchronous
public Future<MyResult> execute() {
return new AsyncResult<>(getResult());
}
@Asynchronous 请求服务器在 EJB 线程池中执行任务。该方法可以返回 void,但如果需要返回值,则必须返回 Future。在 Java 8 中,返回 CompletionFuture 很容易。然而,由于这个 API 在那时之前就已经设计好了,最简单的方法是返回规范提供的 AsyncResult,并将你想要返回的实际值传递给它。请注意,容器将包装返回的 Future 值以添加特定的规范处理,因此即使你在代码中选择了该实现,你也不能将其转换为 CompletionFuture。
在这里,池配置高度依赖于服务器,但通常是可以工作的,并且很重要,这取决于应用程序中此 API 的使用情况。如果您的应用程序大量使用它,但容器只提供两个线程和一个小池队列,那么您将无法扩展很远。
Java EE 并发工具
Java EE 7 引入了一个名为 EE 并发工具的新规范,用于 Java EE。主要目标不仅是为了提供一个从 EE 应用程序中处理线程的方法,而且还包括处理 EE 上下文传播,例如安全、事务等。
当使用此 API 时,请记住,传播的上下文高度依赖于容器。然而,与自定义线程池管理相比,此 API 是一个非常好的选择,因为配置在应用程序之外,并且对于容器来说是标准的,并且因为它使您能够从本节中将要看到的 API 中受益。
在规范级别非常巧妙的事情是重用标准 API,例如 ExecutorService、ScheduledExecutorService 等。这使开发者能够将它们用作 SE API 的替代品。特别是,它使您能够透明地与第三方库集成。
例如,您可以与 RxJava (github.com/ReactiveX/RxJava) 集成,就像您与任何线程池做的那样:
@ApplicationScoped
public class RxService {
@Resource(lookup = "java:global/threads/rx")
private ManagedExecutorService mes;
public Flowable<String> getValues() throws Exception {
final WebSocketContainer container =
ContainerProvider.getWebSocketContainer();
final WebSocketPublisher publisher = new WebSocketPublisher();
container.connectToServer(new Endpoint() {
@Override
public void onOpen(final Session session, final
EndpointConfig config) {
session.addMessageHandler(publisher);
}
@Override
public void onClose(final Session session, final
CloseReason closeReason) {
publisher.close();
}
}, URI.create("ws://websocket.company.com"));
final Scheduler eeScheduler = Schedulers.from(mes);
return Flowable.fromIterable(publisher)
.debounce(1, MINUTES, eeScheduler);
}
}
此代码将 Java EE WebSocket API 与 RxJava Flowable API 集成。全局思想是让消费者处理 WebSocket 消息,而无需知道它来自 WebSocket。这使得测试更容易(通过用模拟替换 WebSocket 层)并且使代码与 WebSocket 层解耦。
在我们的服务中,我们注入 ManagedExecutorService,它主要是由容器管理的 ExecutorService,并通过 Scheduler API 将其包装在 RxJava 的线程池 API 中。然后我们就完成了;我们可以使用任何依赖于 Java EE 线程和上下文的 RxJava 的异步操作。在之前的代码片段中,它允许我们在一行中减少消息(限制每单位时间内发出的消息数量)。
技术上,我们实现 Iterator<> 以与 RxJava 集成,但我们也可以使用 Future 或 Flowable API 支持的任何其他类型。迭代器是与 RxJava 集成的部分。然而,为了与 WebSocket API 集成,我们还可以实现 MessageHandler,这允许我们查看传入的消息并在之前的代码片段中的端点注册它。以下是一个潜在的处理程序实现:
public class WebSocketPublisher implements
Iterable<String>, Iterator<String>, MessageHandler.Whole<String> {
private final Semaphore semaphore = new Semaphore(0);
private final List<String> messages = new ArrayList<>();
private volatile boolean closed;
public void close() {
closed = true;
semaphore.release();
}
@Override
public void onMessage(final String message) {
synchronized (messages) {
messages.add(message);
}
semaphore.release();
// it case we are currently locked in hasNext()
}
@Override
public boolean hasNext() {
if (closed) {
return false;
}
try {
semaphore.acquire();
synchronized (messages) {
return !closed && !messages.isEmpty();
}
} catch (final InterruptedException e) {
return false;
}
}
@Override
public String next() {
synchronized (messages) {
return messages.remove(0);
}
}
@Override
public Iterator<String> iterator() {
return this;
}
}
我们的发布者将接收到的消息堆叠起来,然后通过Iterator<> API 提供服务。正如我们在上一节中看到的,它需要一些同步,以确保我们能够正确回答迭代器合约。具体来说,如果连接未关闭或我们没有收到任何消息,我们无法在hasNext()中返回任何内容。否则,它将停止迭代。
ManagedExecutorService
作为快速提醒,ExecutorService是 Java Standalone 的标准线程池抽象。ManagedExecutorService是 Java EE 版本。如果你比较这两个 API,你会注意到它继承了其独立兄弟的所有功能,但它增加了审计功能:提交的任务(Runnable)可以实现ManagedTask API,这将给任务关联一个监听器,该监听器会通知任务的阶段(Submitted、Starting、Aborted和Done)。
ManagedTask 全局给容器以下内容:
-
ManagedTask 使用的监听器
-
一组属性用于自定义执行行为。定义了三个主要标准属性,并且可以在所有容器上便携使用:
-
javax.enterprise.concurrent.LONGRUNNING_HINT:这允许容器更改长时间运行的线程设置,将任务完成(使用其他线程优先级或可能使用专用线程) -
javax.enterprise.concurrent.TRANSACTION:这可以取SUSPEND值,这将挂起当前事务(如果有)并在任务完成后恢复它 -
USE_TRANSACTION_OF_EXECUTION_THREAD:这传播调用线程的事务
-
如果你不能使你的任务实现ManagedTask,那么你也有*bridge*适配器来通过ManagedExecutors工厂将一个普通任务连接到一个监听器:
Runnable runnable = ManagedExecutors.managedTask(myTask, myListener);
这个简单的调用将创建Runnable,因此你可以将其提交给ManagedExecutorService,它也实现了带有myListener监听器的ManagedTask。实际上,还有用于Callable的包装工厂方法,具有我们之前提到的属性,以确保它覆盖了所有ManagedTask API 的功能。
在整体平台一致性方面,这个 API 倾向于使 EJB @Asynchronous成为遗留功能。
ManagedScheduledExecutorService
ManagedScheduledExecutorService是 Java EE API 的ScheduledExecutorService。像ExecutorService一样,它集成了ManagedTask API。
然而,这个与调度相关的 API 更进一步,提供了两个新的方法来根据专用 API(Trigger)调度任务——Runnable或Callable。这个 API 允许你以编程方式处理调度,并避免依赖于固定的时间间隔或延迟。
即使从理论上讲,这个调度 API 可以分布式实现,并且被设计为支持它,但它通常只实现本地支持。然而,当不需要集群时,它是一个很好的 EJB @Schedule或TimerService的替代品,这在实践中实际上很常见。
Java EE 线程
EE 并发工具还提供了一个 Java EE 的ThreadFactory,它创建的是ManageableThread而不是普通的线程。主要区别在于它们提供了一个isShutdown()方法,允许你了解当前线程是否正在关闭,并因此,如果确实正在关闭,退出进程。ManagedExecutors.isCurrentThreadShutdown()允许你直接测试这个标志,并自动处理线程的转换。这意味着一个长时间运行的任务可以按以下方式实现:
while (running && !ManagedExecutors.isCurrentThreadShutdown()) {
process();
}
你可能会认为仅测试线程状态就足够了,但你仍然需要一个应用程序状态来确保你与应用程序生命周期集成。不要忘记线程可以绑定到容器而不是应用程序部署时间。此外,根据你为线程定义的策略,你可以在运行时将其驱逐,可能通过容器的管理 API。
ContextService
EE 并发工具的最后一个有趣的 API 是ContextService。它允许你创建基于接口的代理,继承自Managed* API 的上下文传播。你可以将其视为在不受你控制的独立线程池中使用管理线程池功能的一种方式:
Runnable wrappedTask = contextService.createContextualProxy(myTask, Runnable.class);
framework.execute(wrappedTask);
在这里,我们将我们的任务包装在一个上下文代理中,并通过我们无法控制的框架提交包装后的任务。然而,执行仍然会在调用者的 EE 上下文中完成(例如,相同的 JNDI 上下文),使用另一个框架并不会影响很大。
这个ContextService仅限于代理接口,并且不支持像 CDI 那样进行子类代理。Java EE 明白现代开发是由各种框架和堆栈组成的,它无法控制一切。这种趋势通过引入一种新的 API 来体现,这种 API 可以轻松与其他系统集成并支持任何用例,而不是引入大量新的 API,这些 API 可能不会很好地进化。
在性能和监控方面非常重要——它不仅允许你轻松跟踪调用和应用程序行为,还可以通过缓存优化应用程序,正如我们将在下一章中看到的。
EJB @Lock
前一部分展示了 EJB 的@Asynchronous和@Schedule可以通过某些程度地替换为新的 EE 并发工具。然而,还有一些 EJB API 在没有自己编码的情况下不容易替换,@Lock API 就是其中之一。
整体思路是确保 EJB(@Singleton)拥有的数据在线程安全的环境中访问。
事实上,此 API 仅限于单例 EJB,因为没有在并发环境中可用的单个实例,锁定就没有意义。
使用方法很简单,您只需用@Lock装饰一个方法或 bean,并根据访问类型传递READ或WRITE参数:
@Singleton
public class MyLocalData {
private Data data;
@Lock(READ)
public Data get() {
return data;
}
@Lock(WRITE)
public void set(Data data) {
this.data = data;
}
}
如果您还记得关于 Java Standalone 的部分,它与synchronized的使用非常相似,因为我们在一个块上定义了锁。然而,其语义更接近ReadWriteLock API。这是 API 设计的一个意愿,因为这是它通常实现的方式。那么,为什么混合这两种风格(块和读写 API)呢?这使您能够在读取时进行扩展,同时保持 API 非常简单(绑定到块)。然而,它已经适合很多情况!
在性能方面,重要的是要知道您可以通过@AccessTimeout将其与超时混合:
@Singleton
@AccessTimeout(500)
public class MyLocalData {
// as before
}
在这里,我们要求容器在 500 毫秒后未能获取锁(读取或写入)时,通过ConcurrentAccessTimeout异常失败。
对于一个响应式应用程序,正确配置超时至关重要,并且对于确保应用程序不会因为所有线程都在等待响应而以巨大的响应时间启动非常重要。这意味着您必须定义超时情况下的回退行为。换句话说,您需要定义超时以确保您符合 SLA,但您还需要定义在超时时应该做什么,以避免在服务器过载时出现 100%的错误。
微 profile 倡议是由大多数 Java EE 供应商创建的,主要基于 CDI。因此,即使它不是 Java EE 的一部分,它也能很好地与之集成。其主要目标是微服务,因此它们定义了一个 bulkhead API 和其他并发解决方案。然而,如果@Lock对于您的需求来说过于简单,它是一个有趣的解决方案。
HTTP 线程
HTTP 层(服务器和客户端)涉及网络和连接。因此,它们需要在服务器端处理客户端连接以及在客户端侧进行潜在的反应式处理时使用一些线程。让我们逐一了解这些特定设置,这些设置直接影响到您应用程序的可扩展性。
服务器端
任何 Web 请求的入口点是 HTTP 容器。在这里,服务器配置始终依赖于服务器,但大多数供应商将共享相同的概念。重要的是调整这部分,以确保您的应用程序的输出不会无意中过度限制;否则,您将无端地限制应用程序的可扩展性。
例如,对于 GlassFish,您可以在 UI 管理界面或相应的配置文件中配置 HTTP 连接器。以下是它的样子:

这个页面实际上是在讨论 HTTP 连接器的调整(而不是绑定地址、端口或支持的 SSL 加密算法)。相应的配置总结在下表中:
| 配置名称 | 描述 |
|---|---|
| 最大连接数 | 这是保持活动模式下每个客户端的最大请求数量。这与其他 Java EE 服务器相比,这不是服务器支持的最大连接数。 |
| 超时 | 这是连接在保持活动模式下空闲一段时间后可以断开的时间长度。这里同样,这是一个基于客户端的配置,而不是像大多数其他服务器中的请求超时。 |
| 请求超时 | 这是请求将在客户端超时并失败的时间长度。 |
| 缓冲区大小/长度 | 缓冲区用于读取输入流中的数据。调整此大小以避免内存溢出将显著提高性能,因为服务器将不再需要创建一个新的易失性缓冲区来读取数据。如果应用程序做了很多事,这种调整可能很难做。权衡是不要使用太多内存,并避免意外的分配。因此,你越接近最常见请求的大小(实际上略大于此值),你的表现就越好。 |
| 压缩 | 压缩主要用于基于浏览器的客户端(支持 GZIP)。如果资源的尺寸超过最小配置尺寸,它将自动压缩配置的 MIME 类型的内容。具体来说,它可以影响一个 2MB 的 JavaScript 文件(这在今天已不再罕见)。这将使用一些 CPU 资源来进行压缩,但基于文本的资源(HTML、JavaScript、CSS 等)的空间节省通常是值得的,因为网络持续时间将大大减少。 |
这些参数主要关于网络优化,但对于确保应用程序保持响应性至关重要。它们还影响着 HTTP 线程的使用方式,因为不当的调整可能会给服务器带来更多的工作。现在,在 GlassFish(以及大多数服务器)中,你还可以配置 HTTP 线程池。以下是相应的屏幕截图:

GlassFish 的线程池配置非常常见——其大小(最大/最小)、队列大小(即使池已满,也可以添加的任务数量),以及超时(如果线程未被使用,则从池中移除)。
当你在基准测试你的应用程序时,确保你监控应用程序的 CPU 使用率和线程堆栈(或根据你监控服务器/应用程序的方式,进行剖析),以识别不良配置。例如,如果你看到 50%的 CPU 使用率和一些活跃的线程,那么你可能需要增加池的大小。总体目标是使 CPU 使用率非常高(85-95%),并且服务器的响应时间几乎保持不变。请注意,不建议将 CPU 使用率提高到 100%,因为那时,你会达到机器的限制;性能将不再相关,你将只会看到响应时间无限增加。
这是对任何可能变得非常重要的线程池的一般规则,尤其是在转向响应式编程时。因此,始终尝试使用与它们在应用程序中扮演的角色相对应的前缀来命名应用程序的线程,以确保你可以在线程转储中识别它们。
客户端端
自从 JAX-RS 2.1 以来,客户端已经被设计成响应式的。作为一个快速提醒,这里是什么样子:
final Client client = ClientBuilder.newClient();
try {
CompletionStage<Response> response =
client.target("http://google.com")
.request(TEXT_HTML_TYPE)
.rx()
.get();
} finally {
client.close();
}
这是对 JAX-RS 客户端 API 的正常使用,除了对rx()的调用,它将响应包装到CompletionStage中。唯一的兴趣是变得异步;否则,它将只是另一个层,用户体验的提升微乎其微。
实现处理异步调用的方式取决于实现方式,但在 Jersey(参考实现)和 Java EE 容器中,你将默认使用托管执行器服务。请注意,在 EE 容器之外,Jersey 将创建一个非常大的线程池。
这种配置是您应用程序的关键,因为每个客户端都应该有一个不同的池,以确保它不会影响应用程序的其他部分,并且因为线程使用可能不同,可能需要不同的约束。然而,这尚未标准化,因此您需要检查您的服务器使用哪种实现以及如何使用配置。一般来说,客户端配置可以通过客户端的属性访问,所以并不难。但是,有时您可能受到容器集成的限制。在这种情况下,您可以将调用包装到自己的池中,并且不要使用rx() API 来完全控制它。
为了总结本节,我们可以在一段时间后(Java EE 8 和这个新的 JAX-RS 2 API)期待rx()方法将直接使用 NIO API 实现,因此,它在网络层面上真正变得响应式,而不仅仅是通过另一个线程池。
我们刚刚看到 Java EE 带来了处理应用程序线程的正确解决方案,但现代开发往往需要新的范式。这些修改需要应用程序开发方式的小幅改变。让我们通过这些新模式之一来了解一下。
响应式编程和 Java EE
响应式编程允许你的代码被调用而不是调用你的代码。你可以将其视为基于事件而不是过程的。以下是一个比较两种风格的示例:
public void processData(Data data) {
if (validator.isValid(data)) {
service.save(data);
return data;
}
throw new InvalidDataException();
}
这是一个非常简单且常见的业务方法实现,其中我们调用两个服务:validator和service。第一个服务将通过检查数据是否存在于数据库中、值是否在预期范围内等来验证数据,而第二个服务将实际处理更新(例如数据库)。
这种风格的问题在于数据验证和持久化被绑定在单个processData方法中,该方法定义了整个执行环境(线程、上下文等)。
在响应式风格中,它可以重写为用链替换同步调用:
public Data processData(Data data) {
return Stream.of(data)
.filter(validator::isValid)
.peek(service::save)
.findFirst()
.orElseThrow(() -> new InvalidDataException("..."));
}
在这个例子中,我们使用了 Java 8 流 API,但使用像 RxJava 这样的响应式库通常更有意义;你将在下一段中理解原因。此代码与上一个代码执行相同的功能,但它通过定义链来编排调用,而不是直接进行调用。
这个模式有趣的地方在于你将逻辑(validator、service)与其使用方式(前一个例子中的流)分开。这意味着你可以丰富调用的编排方式,如果你考虑我们之前看到的 RxJava 示例,你就可以立即想到在不同的线程中执行每个方法。
这种模式的一个常见用例是当响应时间比使用的资源更重要时。换句话说,如果你不介意消耗更多的 CPU 周期,只要这有助于减少你响应客户端所需的时间,那么你可以实施这种模式。如果你正在与多个并发数据提供者一起工作,或者如果你需要联系多个远程服务来处理数据,那么你将一开始就并发执行这三个调用。一旦你有了所有响应,你将执行实际的处理。
为了说明这一点,你可以假设数据有一个合同标识符、一个客户标识符和一个与相应实体关联的账户标识符,这些标识符通过三个不同的远程服务关联。此类情况的同步实现可能如下所示:
Customer customer = findCustomer(data);
Contract contract = fincContract(data);
Account account = findAccount(data);
process(customer, contract, account);
这将有效。然而,假设远程调用大约需要 10 毫秒,那么你的方法处理数据将需要超过 30 毫秒。
你可以通过并发执行三个请求来对其进行一点优化:
public void processData(Data data) {
final CompletableFuture<Customer> customer = findCustomer(data);
final CompletableFuture<Contract> contract = findContract(data);
final CompletableFuture<Account> account = findAccount(data);
try {
CompletableFuture.allOf(customer, contract, account).get();
processLoadedData(customer.get(), contract.get(), account.get());
} catch (final InterruptedException | ExecutionException e) {
throw handleException(e);
}
}
在这种情况下,你将减少调用持续时间到大约 10 毫秒。然而,你将阻塞线程 10 毫秒(三个并行调用)。CompletableFuture.allOf(...).get()这一行等待所有三个异步操作(CompletableFutures)完成,使得线程无法用于其他请求/处理。
直接影响是,即使你的 CPU 可能什么都没做(即,如果你当时获取线程转储,你正在等待 I/O),你也不会扩展,并且无法处理许多并发请求。
增强这种方法的方式是确保主线程不被阻塞,并且只有在所有数据都接收后才开始处理:
public void processData(Data data) {
final CompletableFuture<Customer> customer = findCustomer(data);
final CompletableFuture<Contract> contract = findContract(data);
final CompletableFuture<Account> account = findAccount(data);
CompletableFuture.allOf(customer, contract, account)
.thenRun(() -> {
try {
processLoadedData(customer.get(), contract.get(),
account.get());
} catch (final InterruptedException | ExecutionException
e) {
throw handleException(e);
}
});
}
在这种情况下,我们仍然并行执行我们的三个远程调用——如果你需要访问 EE 功能,可能是在一个管理执行器服务中——然后,我们等待所有三个结果按顺序检索,以便进行我们的处理。然而,我们只是在整个数据可读时注册我们的处理,我们不会阻塞线程等待这个就绪状态;因此,我们将能够同时服务更多的请求。
在走向响应式编程时,重要的是尽可能避免同步,以确保任何线程时间都是活跃的处理。当然,它有限制,比如一些 JDBC 驱动程序仍然是同步的,它将阻塞线程等待 I/O 操作。然而,随着微服务的普及,如果不注意它,很容易给代码添加很多延迟,并降低应用程序的可伸缩性。
在心理上表示这种编程方式的方法是将 CPU 使用率想象成一个大的队列,队列中的每个元素都是一些活跃的计算时间消费者(即,一个任务)。然后,你的程序只是一个大事件循环轮询这个任务队列并执行任务。结果是什么?——几乎没有被动时间,只有活跃时间!
事实上,异步意味着所有的工作都将变为异步(线程处理、上下文切换、队列管理和同步)。即使大多数这些任务都是隐藏的,并且由堆栈为你完成,这也可能使 CPU 过载并减慢应用程序的速度,与在单个线程中执行相同的代码相比。这是真的,这意味着你不能为每个调用都使用这种模式。你需要确保你在相关的时候使用它,当有可能被动使用 CPU(阻塞时间、睡眠等)的时候。尽管如此,如果你遵守这个模式,你应该能够比在所有地方都保持同步更好地处理并发。当然,这是一个妥协,因为如果你有一个后台任务(例如,每天执行一次的计划任务),你不会关心等待时间,因为它只涉及一个线程。这种编程方式只有在准确的位置使用时才会产生效益,但如果你遵守这种使用方式,你将真正获得更合理的最终行为。然而,不要忘记,它带来了更多的复杂性,因为跟踪在 Java 中不再那么自然(堆栈跟踪几乎不再有用,因为你如果没有使用线程跟踪解决方案,就没有完整的堆栈)。
消息传递和 Java EE
消息传递模式涉及多个理论,但在这个部分,我们将主要关注异步风格。这种模式的一个例子是演员风格。演员是某种可以接收消息、向其他演员发送消息、创建其他演员以及指定下一个接收到的消息的行为的实体。
理解底层概念的基础非常重要:
-
全局通信依赖于一个异步总线
-
演员当前的消息处理基于一个状态(有点像内部状态机)
-
演员可以创建其他演员来处理一条消息
使用这种模式,强烈建议使用不可变消息以避免任何并发问题以及难以调试的行为(或非确定性行为)在演员流程中发生。
Java EE 并不提供现成的处理这种模式所有功能的工具,但其中大部分功能已经存在:
-
CDI 提供了一个总线
-
CDI(异步)观察者是一些豆类,因此你可以拥有一个状态机
-
代理链(新演员)可以通过与消息绑定的 CDI 上下文来处理
当然,与真正的演员系统相比,这仍然是一个简陋的实现,但它已经为你提供了一个坚实的模式来避免线程的被动使用,顺便说一下,当创建应用程序的内部架构时,你应该考虑这一点。
摘要
在这一章中,你看到了 Java EE 如何确保你可以并行化应用程序的计算,并使你的应用程序更好地扩展并处理多个并发请求。
使用 Java 独立同步机制、Java EE 线程管理解决方案和 API,你可以充分利用你的硬件并轻松地与第三方库集成。
现在我们已经看到了与 CPU 相关的内容,我们需要了解机器的另一个主要资源,你可以利用它来使你的应用程序行为更好:内存。当处理无法优化并且对应用程序影响太大时,通常的解决方案就是尽可能跳过它。这样做最常见——也许也是最有效的方法——就是确保数据只计算一次并在有效期间重复使用。这就是缓存介入游戏的地方,这也是我们下一章将要讨论的内容。
第六章:懒惰;缓存你的数据
在上一章中,我们看到了如何通过我们的处理器并行处理我们的请求并更准确地减少我们的响应时间。
然而,显然最有效率和快速的方式显然是不做任何事情。这正是缓存试图做到的,它允许你使用内存来跟踪已经处理过的结果,并在需要时快速读取它们。
在本章中,我们将讨论以下主题:
-
缓存是什么,它是如何工作的,以及它在什么情况下是有趣的
-
应该使用哪种类型的缓存:本地缓存与远程缓存
-
JCache - Java EE 的标准 API
缓存挑战
为了确保我们在实施缓存时记住我们针对的模式,让我们用一个简单的例子来说明,这个例子来自我们的引用管理应用程序。我们的目标将是使我们的 按符号查找 端点更快。当前的逻辑看起来像以下伪代码片段:
Quote quote = database.find(symbol);
if (quote == null) {
throw NotFoundException();
}
return convertToJson(quote);
在这个代码片段中我们只有两个操作(在数据库中查找并将数据库模型转换为 JSON 模型)。你好奇你在缓存什么:数据库查找结果、JSON 转换,还是两者都缓存?
我们稍后会回到这部分,但为了简单起见,这里我们只缓存数据库查找。因此,我们新的伪代码可能看起来像以下这样:
Quote quote = cache.get(symbol);
if (quote == null) {
quote = database.find(symbol);
if (quote != null) {
cache.put(symbol, quote);
}
}
if (quote == null) {
throw NotFoundException();
}
return convertToJson(quote);
这基本上和之前的逻辑一样,只是我们尝试在到达数据库之前从缓存中读取数据,如果我们到达数据库并找到记录,然后我们会将其添加到缓存中。
事实上,如果你在数据库中没有找到引用,你也可以进行缓存,以避免发出一个不会返回任何结果的数据库查询。这取决于你的应用程序是否经常遇到这类请求。
因此,我们现在在我们的应用程序中需要考虑一个包含我们数据库数据的缓存层。我们可以用以下图表来可视化这种结构变化:

这张图表示了 应用程序 通过 缓存 和 数据库。连接(箭头)到 缓存 的线比到 数据库 的线更粗,这代表我们的假设是 缓存 访问比 数据库 访问更快。因此,访问 缓存 比访问 数据库 更便宜。这意味着我们想要比访问 数据库 更频繁地访问 缓存 来找到我们的引用。最后,这张图表示了 缓存 和 数据库 在同一个 层 中,因为有了这种解决方案——即使 缓存 访问应该非常快——你现在有两个数据源。
缓存是如何工作的?
什么是缓存?单词 缓存 实际上非常通用,隐藏了许多不同的风味,它们并不针对相同的需求。
然而,所有缓存实现都在原则上有一个共同的基础。
-
数据可以通过键访问
-
缓存提供了一些表示存储值有效性的驱逐机制
-
缓存依赖于内存存储首先。
缓存键。
大多数缓存 API 在行为上非常接近映射——你可以使用put(key, value)来存储一些数据,并通过get(key)调用使用相同的键检索它。
这意味着可以使用 JRE 的ConcurrentMap实现一个穷人的缓存。
private final ConcurrentMap<Long, Optional<Quote>> quoteCache = new ConcurrentHashMap<>();
@GET
@Path("{id}")
public JsonQuote findById(@PathParam("id") final long id) {
return quoteCache.computeIfAbsent(id, identifier ->
quoteService.findById(identifier))
.map(this::convertQuote)
.orElseThrow(() -> new WebApplicationException(Response.Status.NO_CONTENT));
}
在这个实现中,我们将数据库访问封装在并发映射访问中,只有当数据不在缓存中时才会触发数据库访问。请注意,我们缓存了Optional,这也表示我们没有在数据库中拥有数据。因此,即使数据不存在,我们也会绕过 SQL 查询。
这种类型的实现是可行的,但它没有任何驱逐策略,这意味着你将在应用程序的生命周期内保持相同的数据,这意味着数据库中的更新将被完全绕过。如果你的数据不是常量,请不要在生产环境中使用它。
这类结构(让我们称它们为映射)使用的重要部分是键的选择。缓存实现会尽可能减少锁定。它们甚至可以是无锁的,这取决于实现方式。因此,你可以假设缓存会自行扩展,但关键是你的,你需要确保它得到良好的实现。
缓存使用键通常有多种策略,但最知名和常用的如下:
-
按引用:通过引用相等性来测试相等性,这意味着你需要使用相同的键实例来查找值。按照设计,它仅限于本地缓存。
-
按合同:这使用键的
equals和hashCode方法。 -
按值:这与按合同相同,但在将其放入缓存时也会复制键。它确保如果键是可变的,并且在将数据放入缓存后以某种方式发生了变化,它不会影响缓存,这可能会因为错误的
hashCode赋值而损坏。
hashCode的使用通常需要影响缓存存储结构中单元格的键/值对。它使得数据在结构中的分布,这将使得访问更快。如果键的hashCode在键被分配到单元格后发生变化,那么即使equals实现正确,数据也无法找到。
大多数情况下,你会使用按合同解决方案(或按值,取决于实现),因为按引用在 Web 应用程序中很少工作,因为键的引用通常绑定到请求,并且随着每个请求而变化。这意味着你必须定义你的数据键是什么,你必须实现equals和hashCode。
在这种约束下,你需要注意两个非常重要的后果:
-
这些方法必须执行得快。
-
这些方法一旦数据放入缓存后就必须是常量。
为了理解这意味着什么,让我们基于我们的Quote实体,将其计算结果放入我们的缓存中,作为计算的天然键(例如,我们缓存一些与报价相关的新闻)。作为提醒,以下是我们的实体结构:
@Entity
public class Quote { // skipping getters/setters
@Id
@GeneratedValue
private long id;
@NotNull
@Column(unique = true)
private String name;
private double value;
@ManyToMany
private Set<Customer> customers;
}
如果你使用你的 IDE 生成equals和hashCode方法,你可能会得到以下实现:
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Quote quote = (Quote) o;
return id == quote.id && Double.compare(quote.value, value) == 0 &&
Objects.equals(name,
quote.name) && Objects.equals(customers, quote.customers);
}
@Override
public int hashCode() {
return Objects.hash(id, name, value, customers);
}
这是一个非常常见的实现,但它考虑了所有字段。对于一个 JPA 实体来说,由于以下原因,这是一个灾难:
-
如果标识符没有受到影响会发生什么?如果实体在实体被放入缓存之后持久化,你将失去缓存的好处或者再次缓存(使用另一个哈希值)。
-
当访问
customers时会发生什么?这是一个懒加载的关系,所以如果在hashCode或equals之前没有被触及,那么它将加载这个关系,这肯定不是我们想要的。 -
如果
value——与标识符无关的实体任何状态——发生变化会发生什么?缓存的使用也将被错过。
JPA 是一个标识符真正重要的案例,即使没有缓存。但是有了缓存,这一点更加明显。所有这些观察都导致了一个事实,即缓存中的每个键都必须基于一个自然标识符,这个标识符应该是不可变的,或者如果你收到一个修改键假设的事件,你必须确保你清除缓存条目。在 JPA 的情况下,自然标识符是 JPA 标识符(Quote的id),但它也必须从第一次使用时受到影响。这就是为什么,在大多数情况下,好的技术标识符是基于 UUID 算法,并在新创建的实体实例化时受到影响。修正后,我们的equals和hashCode方法将如下所示:
@Override
public boolean equals(Object o) {
if (this == o) {
return true;
}
if (o == null || getClass() != o.getClass()) {
return false;
}
Quote quote = (Quote) o;
return id == quote.id;
}
@Override
public int hashCode() {
return Long.hashCode(id);
}
这些实现只考虑id值,并假设它早期受到影响,因此可以作为缓存中的键安全使用。
几种数据库依赖于键/值范式来确保良好的性能和高效的存储。然而,与缓存的主要区别将是缓存中数据的易变性,缓存不是为了存储数据而设计的,而数据库将确保数据的持久性,即使它是一个最终一致性的数据库。
清除策略
清除策略是使缓存与数据库或Map不同的地方。它使你能够定义数据如何自动从缓存存储中删除。这非常重要,因为如果你缓存从数据库中取出的某些参考数据,那么数据库存储可能比机器上可用的内存存储更大,因此,如果没有清除,你最终会填满内存,并得到OutOfMemoryException,而不是你期望从缓存添加中获得的性能提升。
有几种清除策略,但主流的分类却很少:
-
最近最少使用(LRU)
-
先进先出(FIFO)
-
随机
-
最少使用(LFU)
只有 LRU、FIFO 和过期是真正的主流;其他的一些高度依赖于你的提供商能力。
最少最近使用(LRU)
LRU 策略基于缓存元素的使用情况。一些统计数据被维护以能够根据最后使用日期对元素进行排序,当需要淘汰时,缓存只需按顺序遍历元素,并按相同的顺序淘汰它们。
你可以将其想象为缓存维护一个数据(存储)的映射和一个数据(或键)使用列表。以下是一个序列,帮助你可视化它:
| 操作 | 缓存存储(未排序) | 淘汰列表(已排序) |
|---|---|---|
| - | [] | [] |
| 添加键/值 E1 | [E1] | [E1] |
| 添加键/值 E2 | [E1, E2] | [E1, E2] |
| 添加键/值 E3 | [E1, E2, E3] | [E1, E2, E3] |
| 读取 E2 | [E1, E2, E3] | [E1, E3, E2] |
这里需要注意的重要一点是,每次使用(put、get等)都会首先将元素放入淘汰列表。这意味着当执行淘汰操作时,它将最后移除这个元素。从行为上来说,LRU 会导致在缓存中保留最常使用的元素尽可能长时间,这正是缓存效率最高的时候。然而,这也意味着缓存必须维护一个淘汰列表状态,这可以通过多种方式完成(通过列表、淘汰时排序、动态矩阵等)。由于它需要额外的工作,这会影响性能或内存使用,这将对应用程序/缓存不再适用。
先进先出(FIFO)
FIFO 算法是 LRU 算法的一个简单版本,旨在通过牺牲一点准确性来避免 LRU 算法的缺点。具体来说,它将绕过使用统计,仅依赖于进入缓存的时间——有点像你在超市排队等待时的情况。
这里是一个类似于我们用来描述 LRU 算法的插图:
| 操作 | 缓存存储(未排序) | 淘汰列表(已排序) |
|---|---|---|
| - | [] | [] |
| 添加键/值 E1 | [E1] | [E1] |
| 添加键/值 E2 | [E1, E2] | [E1, E2] |
| 添加键/值 E3 | [E1, E2, E3] | [E1, E2, E3] |
| 读取 E2 | [E1, E2, E3] | [E1, E3, E2] |
这里主要的区别是最后一个条目,它不会影响 E2 和 E3 之间的淘汰顺序。你可以将其视为更新不会改变淘汰时间。
随机
如你所猜,这种淘汰策略会随机选择条目进行删除。它看起来效率不高,因为删除最常使用条目的概率更高,从而降低缓存效率。然而,在某些情况下,它可能是一个不错的选择。这种策略的主要优点是它不依赖于任何淘汰顺序维护,因此执行速度快。
在使用它之前,请确保它确实比其他方法效率低:几乎比 LRU 低 20%,这是通过实验得出的。
最少使用频率(LFU)
你在缓存中能遇到的最后一个常见算法是 LFU 算法。和 LRU 算法一样,这个版本也维护了缓存访问的统计信息。
与 LRU 的主要区别是,它使用频率统计而不是基于时间的统计。这意味着如果 E1 被访问了 10 次而 E2 被访问了 5 次,那么 E2 将在 E1 之前被删除。
这个算法的问题是,如果你在短时间内有快速的访问率,那么你可能会删除比在非常短的时间内经常使用的元素更频繁使用的元素。因此,最终的缓存分布可能不是那么理想。
缓存删除触发器
之前的算法定义了在触发删除时如何选择要删除的项目。这意味着如果未触发删除,它们就毫无意义。删除触发器可以是多种类型,但主要的是以下几种:
-
大小:缓存的大小可以是几种类型,例如缓存的实际对象数量或内存大小(以位或字节为单位)。
-
过期:你可以与每个元素关联一个生命周期。当生命周期到达时,该元素应该从缓存中删除(移除)。请注意,此参数不是严格的,如果缓存没有使用足够快的后台线程来快速删除元素,则元素可以留在内存中并在访问期间被移除。然而,作为客户端(缓存用户),你不应该注意到这一点。
这是高级配置。然而,每个缓存实现都有很多不同的版本,混合了各种元素。例如,你可以配置一个缓存以支持在内存中保持 100 万个对象,缓存内存的最大大小为 10 MB,如果对象不适合内存,则可以使用 1 GB 的磁盘空间(溢出到磁盘策略)。这种高级配置可能会影响每个元素的不同生命周期,因此缓存可以在达到这个生命周期时从缓存中删除元素。最后,你可以将这个每个元素的生命周期与 10 分钟的全球最大生命周期策略相关联。
如果你浏览你的缓存实现提供商,你会识别出很多配置选项,重要的是不要尝试复制粘贴现有应用程序中的缓存配置,除非你确保你处于类似的场景中。
策略是从简单开始,如果应用程序需要或者从中获得性能提升,再复杂化配置。例如,激活数据的磁盘溢出可能会降低与访问后端相比的性能,特别是如果后端连接相当快且磁盘已经高度使用。
从一个简单的 LRU 策略开始,具有最大内存大小或对象大小通常是最高效的选择。
缓存存储 – 是内存还是不是
缓存的想法是保留实例以便更快地提供服务,而不是重建它们或从慢速后端获取它们。首选的存储是堆,因为它是一种快速访问解决方案。然而,许多缓存供应商允许其他策略。大多数时候,它将通过服务提供者接口(SPI)来实现可插拔,因此你经常会看到很多实现。以下是你可能找到的一些小列表:
-
硬盘
-
关系型数据库管理系统(RDBMS)数据库(MySQL、Oracle 等)
-
NoSQL 数据库(MongoDB、Cassandra、一种特定的缓存服务器存储类型、网络缓存等)
在讨论这些扩展的使用之前,不要忘记这通常是一种针对缓存的特定方式来使用后端。例如,硬盘实现中在内存中保持键、在磁盘上存储值以保持数据的快速查找,并确保内存使用符合配置的情况并不少见。这意味着你并不总是能够使用这些溢出策略来持久化缓存数据。
问题是,如果溢出导致使用另一个后端,为什么直接去数据所在的主后端会更有用且更高效呢?这有几个答案,并且随着我们现在看到的微服务趋势,这些答案变得越来越准确。
通过这种缓存方式的主要两个原因如下:
-
提供更可靠的访问数据的方式,即使主要的后端不可靠(且由你无法控制的另一个应用程序拥有)。
-
在无需完全重写应用程序以考虑访问限制(如速率限制)的情况下,解决访问限制问题。例如,如果你访问 GitHub API,你将无法在某个端点上每分钟进行超过 30 次请求,所以如果你的应用程序需要每分钟进行 1,500 次访问,你需要在你的端存储相应的数据。在这里,缓存可以很巧妙,因为它允许根据速率限制、时间单位和你的应用程序通过输出来放置适应的驱逐策略。
使用分布式解决方案(如集中式 RDBMS 或分布式数据库如 NoSQL)将允许你在节点之间共享数据,并避免在主后端上进行与节点数量一样多的查询。例如,如果你的集群中有 100 个你的应用程序节点,并且你缓存了键,myid,那么你将通过内存存储请求后端 100 次以获取myid数据。而使用分布式存储,你只需从其中一个节点缓存一次,然后只需从这个分布式存储中读取,这仍然比主后端快。
尽管使用溢出可能非常有吸引力,但不要忘记它通常比内存缓存慢(我们常说内存访问时间是 1,磁盘访问时间是 10,网络访问时间是 100)。有一些替代策略允许你积极地将数据推入内存,而不是依赖于溢出(懒加载)读取,如果你的集群负载均衡没有使用任何亲和性,这可能是有益的。
数据一致性
我们现在可以在所有集群节点上设置我们的缓存;然而,问题是我们的应用程序是否仍然可以工作。为了回答这个问题,我们将考虑一个非常简单的案例,其中两个请求是并行执行的:
| 节点 1 | 节点 2 |
|---|---|
| 在时间 t1 将数据 1 放入缓存 | - |
| 在时间 t1 将数据 1 放入缓存 | |
| 在时间 t3 访问数据 1 | 在时间 t3 访问数据 1 |
通过这个简单的时间线,我们可以立即看出,使用本地内存缓存可能导致不一致,因为节点可能不会同时缓存数据(缓存通常是懒加载的,所以缓存是在第一次请求或机器启动时填充的,如果是积极加载的,这可能导致两种情况下都可能存在潜在的不一致数据)。
如果数据被缓存,通常意味着不需要最新的数据。这真的是一个问题吗?——是的,可能是一个问题。事实上,如果你在没有亲和性的负载均衡(从业务逻辑的角度看是随机的,这是按负载或轮询负载均衡器的情况),那么你可能会陷入这样的情况:
| 节点 1 | 节点 2 |
|---|---|
| 在时间 t1 将数据 1 放入缓存 | |
| 在时间 t2 更新数据 1 | |
| 在时间 t3 将数据 1 放入缓存 | |
| 在时间 t4 获取并将数据 2 放入缓存 | |
| 在时间 t5 将数据 2 放入缓存 | |
| 在时间 t6 访问数据 1 | 在时间 t6 访问数据 1 |
| 在时间 t7 访问数据 2 | 在时间 t7 访问数据 2 |
我们现在处于与之前相同的情况,但现在我们可以在业务逻辑中使用两种数据(数据 1 和数据 2),并缓存两者。然后,为了识别问题,我们必须考虑数据 1 和数据 2 是逻辑上相关的(例如,数据 1 是一张发票,数据 2 是一份包含价格的合同)。在这种情况下,如果你验证数据(数据 1 和数据 2),处理可能会失败,因为数据在不同的时间和不同的节点上被缓存,这会提供更多的数据一致性保证(因为单个节点将访问单个缓存,因此与其当前状态保持一致)。
换句话说,以保证即使在服务器并发的情况下应用程序仍然可以工作的方式缓存数据非常重要。这个陈述的直接含义是在基准测试期间不要到处放置缓存,只有在证明其有用时才添加,同时避免破坏应用程序。
在溢出存储中,这种情况更糟,因为溢出可以局限于节点(例如硬盘),这导致你使用三个数据来源。
通常,参考数据是我们首先缓存的数据类型。这是很少变化的数据,比如不应该每天更改的合同。这有助于应用程序运行得更快,因为部分数据将具有快速访问。然而,它不会破坏应用程序,因为动态数据仍然从主要来源(例如数据库)中查找。总体而言,您将最终拥有一个混合查找设置,其中部分数据从缓存中读取,另一部分从主要后端读取。
HTTP 和缓存
实现 HTTP 服务器是 Java EE 的主要目的之一。使用 Servlet API、JAX-RS 或甚至 JAX-WS,您可以轻松地在 HTTP 上公开数据,而不必关心传输。
然而,HTTP 定义了一种缓存机制,这在优化客户端行为时值得考虑。
与服务器的常见通信将如下所示:

服务器发起请求,客户端在头部和有效载荷(可能非常大)中发送一些数据(上一个架构中是 JSON 有效载荷)。但不要忘记,您的 Web 应用程序可能还会提供图像和其他类型的资源,这些资源很快就会变得很大。
为了避免每次都需要这样做,即使没有任何变化(通常图片不会经常变化),HTTP 规范定义了一组头部,有助于识别未更改的资源。因此,客户端不需要读取有效载荷,只需重新使用它已经拥有的即可。
即使这种缓存数据的方式主要是为了与资源和浏览器一起使用,也没有什么阻止你在 JAX-RS 客户端中重用这些相同的机制,以避免获取频繁访问的数据,确保你总是最新的。
Cache-Control
Cache-Control是一个头部,有助于处理缓存。它定义了请求/响应应使用的策略。在响应中使用时,它定义了客户端应该如何缓存数据;在请求中使用时,它定义了服务器在策略方面可以发送回什么。
此头部支持多个值,当兼容时可以连接,用逗号分隔。以下是您可以使用来定义客户端上数据缓存方式的值:
| 值 | 描述 |
|---|---|
| public/private | 这定义了缓存是公共的还是私有的。私有缓存是专门为单个客户端设计的,而公共缓存是由多个客户端共享的。 |
| no-cache | 这将缓存的条目定义为过时的,并强制从服务器再次加载数据。 |
| no-store | 这防止了非易失性存储——没有磁盘或持久存储。 |
| no-transform | 这要求额外的网络层,如代理,以保持有效载荷原样。 |
| max-age= |
这定义了数据可以缓存多长时间(0 表示永不),例如,max-age=600表示 10 分钟的缓存。 |
| max-stale=<持续时间(秒)> | 这通知服务器,在此范围内,过时的响应是可以接受的。例如,max-stale=600 允许服务器从 9 分钟前提供数据,即使服务器的策略是 5 分钟。 |
| min-fresh=<持续时间(秒)> | 这请求一个在 N 秒内有效的响应。请注意,这并不总是可能的。 |
| min-vers=<值> | 这指定了用于缓存的 HTTP 协议版本。 |
| must-revalidate | 缓存的数据将联系服务器(与一个 If-Modified-Since 头相关联)以验证缓存的数据。 |
| proxy-revalidate | 这与 must-revalidate 相同,但用于代理/网关。 |
这里是一个你可以使用的头值示例,用于不缓存数据:
Cache-Control: no-cache, no-store, must-revalidate
这是你可以用于敏感数据以避免保留和透明地重用它们的配置类型。一个 登录 端点通常会这样做。
ETag
ETag 头的存在对于这个头比其值更重要,其值不应该被浏览器读取。然而,其值通常是 W/<内容长度>-<最后修改时间>,其中 内容长度 是资源的大小,最后修改时间 是其最后修改的时间戳。这主要是因为它对服务器来说很容易生成且是无状态的,但它可以是任何东西,包括一个随机字符串。
这个头值可以用作强验证器。开头有 W/ 标记它为一个弱验证器,这意味着多个资源可以具有相同的值。
该值与其他头一起用作标识符,例如,Other-Header: <etag>。
与 If-None-Match 一起使用时,该头以逗号分隔的形式接受一个 Etag 值列表,对于上传也可以是 *。如果服务器没有匹配任何资源(或已上传的 PUT/POST 有效负载),则请求将被处理;否则,它将为读取方法(GET,HEAD)返回 HTTP 304 响应,对于其他方法返回 412(预条件失败)。
一个与这种逻辑相关的有趣头是 If-Modified-Since。它将允许你做几乎相同的事情,但基于日期,如果你没有资源的 Etag。它通常与服务器返回的 Last-Modified 值相关联。
Vary
Vary 头定义了如何决定是否可以使用缓存的响应。它包含一个逗号分隔的头列表,这些头必须不变,以便决定是否可以使用缓存。
让我们以这两个 HTTP 响应为例:
|
HTTP/1.1 200 OK
App-Target: desktop
....
|
HTTP/1.1 200 OK
App-Target: mobile
....
|
两个响应都是相同的,除了 App-Target 头。如果你添加缓存,桌面或移动请求将导致提供相同的有效负载,如果已缓存。
现在,如果响应被修改,如下面的片段所示,添加 Vary 头,每种 App-Target 都不会重用其他一种的缓存:
|
HTTP/1.1 200 OK
App-Target: desktop
Vary: App-Target
....
|
HTTP/1.1 200 OK
App-Target: mobile
Vary: App-Target
....
|
这样,桌面和移动体验都可以使用不同的资源。例如,服务器可以根据App-Target值使用不同的文件夹。
HTTP 缓存和 Java EE API
Java EE 没有为 HTTP 缓存定义一个专门的 API(或规范),但它提供了一些辅助工具。
配置它的更直接(和低级)方式是使用 Servlet 规范,它抽象了 HTTP 层:
public class NoStoreFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse
response, FilterChain filterChain)
throws IOException, ServletException {
final HttpServletResponse httpResponse =
HttpServletResponse.class.cast(response);
httpResponse.setHeader("Cache-Control", "no-store");
filterChain.doFilter(request, response);
}
}
使用这个过滤器,Cache-Control值将防止缓存数据被持久存储。要激活它,只需将其添加到你的web.xml中或在服务器中(如果你不想修改你的应用程序):
<web-app
xsi:schemaLocation="
http://xmlns.jcp.org/xml/ns/javaee
http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd"
version="4.0">
<filter>
<filter-name>NoCacheFilter</filter-name>
<filter-class>com.company.NoCacheFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>NoCacheFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
</web-app>
使用这个 XML 声明,而不是@WebFilter,允许你在不同的映射(URL)上重用相同的过滤器,而无需重新声明或修改代码。之前的声明将此过滤器放在所有 Web 应用程序上。对于仅保护 Web 服务的应用程序来说,这可能是个好主意。
如果你需要一个更高级的 API,你可以使用 JAX-RS API,它提供了一个CacheControl API。但对于某些特定的头信息,即使在使用 JAX-RS Response而不是HttpServletResponse时,你仍然需要降低到更低的级别:
@Provider
public class NoStoreFilter implements ContainerResponseFilter {
@Override
public void filter(ContainerRequestContext containerRequestContext,
ContainerResponseContext
containerResponseContext)
throws IOException {
containerResponseContext.getHeaders().putSingle("Cache
-Control", "no-store");
}
}
这个 JAX-RS 过滤器将与之前的 Servlet 过滤器做相同的事情,但以 JAX-RS 的方式。现在,如果你在你的端点返回一个Response,你可以直接使用CacheControl API:
@GET
public Response get() {
CacheControl cacheControl = new CacheControl();
cacheControl.setNoCache(true);
return Response.ok("...")
.cacheControl(cacheControl)
.build();
}
这段代码将一个缓存控制策略与响应关联,这将转换为实际的 HTTP 响应中的头信息。
HTTP 2 承诺
Servlet 4.0 规范引入了 HTTP/2 支持,这对于 Java EE 和许多应用程序来说都是新的。其想法是能够积极地将一些资源推送到客户端。以下是一个基本示例,以给你一个高层次的概念:
@WebServlet("/packt")
public class PacktServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
PushBuilder pushBuilder = req.newPushBuilder();
pushBuilder
.path("images/packt.png")
.addHeader("content-type", "image/png")
.push();
// serve the response which can use images/packt.png
}
}
这个 Servlet 将提前开始推送资源images/packt.png。这将使浏览器能够在它提供的响应中依赖它(可能是 HTML 页面),而无需客户端稍后加载图像。
这将使应用程序能够更加反应灵敏,因为所有操作都在单个连接中完成。因此,它比打开多个连接以获取多个资源要快,但这并不意味着你不需要缓存。正如你可以在前面的代码片段中看到的那样,头信息是按资源支持的,所以你仍然可以使用我们之前看到的按资源的方法来使资源加载更快,即使在 HTTP/2 上也是如此。
JCache – Java EE 的标准缓存
JCache 被添加到 Java EE 中,以使应用程序和库能够以标准方式与缓存交互。因此,它有两种类型的 API 和功能:
-
一个用于读写数据的程序化缓存 API
-
一个 CDI 集成,可以自动将数据放入缓存
设置 JCache
要使用 JCache,你可能需要将其添加到你的应用程序中——或者根据你如何部署它,添加到你的服务器中——因为并非所有服务器都在它们的分发中包含它。使用 Maven,你可以添加这个依赖项:
<dependency>
<groupId>javax.cache</groupId>
<artifactId>cache-api</artifactId>
<version>1.0.0</version>
</dependency>
然后你唯一需要做的就是选择一个实现并将其添加进去。最常见的是以下这些:
-
Apache Ignite
-
JBoss Infinispan
-
Apache JCS
-
Ehcache
-
Oracle Coherence
-
Hazelcast
像往常一样,选择提供商是一个多标准选择,你可能需要考虑以下因素:
-
性能
-
提供商强制你采用的依赖堆栈(它可能与你最大的其他库冲突)
-
提供商拥有的扩展(其中一些甚至不支持 CDI)
-
你可以从它那里获得的社区和支持
然而,使用 JCache API,提供商实现不应影响你的代码。所以,它不会影响你,你可以开始设置 JCache 并在以后更改提供商。
如果你选择的提供商不支持 CDI,JCS 提供了一个cdi模块,它允许你添加 CDI 集成,而无需使用 JCS 缓存实现,而是使用你提供的实现。
编程 API
可以使用Caching工厂非常快速地访问 JCache 缓存:
Cache<String, Quote> quotes = Caching.getCache("packt.quotes", String.class, Quote.class);
quotes.put(symbol, quote);
getCache方法直接返回一个Cache实例,允许你写入/读取数据。API 随后接近于普通的Map语义。然而,这仅在缓存已存在时才有效;否则,getCache调用将失败。
要了解 JCache 是如何工作的,我们需要看看实例是如何管理的。这种设计在 Java EE 中相当常见,通常效率很高:
-
一个工厂方法为你提供一个提供商实例(API 和实现之间的链接)
-
提供商给你一个管理器,它存储实例并避免为每个请求创建它们
-
管理器允许你创建和获取缓存实例
以下是代码中的样子:
final ClassLoader loader = Thread.currentThread().getContextClassLoader(); // Caching.getDefaultClassLoader()
final CachingProvider cachingProvider = Caching.getCachingProvider(loader);
final CacheManager cacheManager = cachingProvider.getCacheManager(cachingProvider.getDefaultURI(), loader, new Properties());
final Cache<String, Quote> cache = cacheManager.createCache("packt.quotes", new MutableConfiguration<String, Quote>()
.setTypes(String.class, Quote.class)
.setStoreByValue(false));
cachingProvider.close();
缓存工厂将在第 2 行提供一个提供商,但我们传递了一个类加载器作为参数来加载提供商以供未来潜在使用。我们可以使用Caching.getDefaultClassLoader(),但根据环境,你可能得到一个不同于你应用程序的类加载器。因此,通常更明智的做法是手动传递你自己的应用程序类加载器。然后,我们将从我们刚刚检索到的提供商创建CacheManager。getCacheManager方法接受三个参数,主要关于如何配置缓存。URI 可以使用提供商的getDefaultURI()方法默认为提供商的默认值。这是指向供应商特定配置文件的路径(URI)。加载器是用于管理/缓存使用的类加载器,属性是用于以供应商特定方式配置缓存的键/值列表。然后,一旦我们有一个管理器,createCache()允许你定义缓存名称及其配置。
注意,这里有两种配置类型:
-
通过 URI 和属性传递给管理器的特定实现配置
-
通过
createCache()方法传递的 JCache 配置
JCache 配置
JCache 配置实现了 javax.cache.configuration.Configuration 或更常见的是 javax.cache.configuration.CompleteConfiguration。此规范提供了 MutableConfiguration 实现,它提供了一个流畅的 DSL 来配置配置。以下是主要入口点:
| 配置 | 描述 |
|---|---|
| 键类型/值类型 | 允许强制键/值遵守类型。如果键或值不遵守配置类型,则会被拒绝(put 将失败)。 |
| 值存储 | 如果为真,它将复制值以防止它们可变(这是在按引用存储模式中的情况)。按引用存储更快,但在这种情况下,建议确保键/值对在您的应用程序中是不可变的。 |
| 缓存条目配置监听器 | JCache 为所有缓存事件(条目创建、更新、删除、过期)提供了一些监听器,并注册配置监听器允许注册此类监听器并定义其行为——触发事件的条目(监听器事件过滤器),监听器是否同步,以及如果存在,监听器是否应提供数据的旧值。最后一个参数旨在避免在不需要时触发网络通信(对于分布式缓存)。 |
| 缓存加载器/写入器工厂 | JCache 提供了加载器和写入器机制。目标是能够在数据尚未在缓存中时,从外部源(如数据库)填充缓存,并且与相同或另一个外部存储同步。在您的应用程序中,这意味着您只访问缓存,但您的数据可以持久化。这在代码方面是一个范式转变,其中缓存是您数据的真相来源。 |
| 管理启用 | 为每个缓存注册一个 JMX MBean,以公开缓存配置。 |
| 统计启用 | 为每个缓存注册一个 JMX MBean,以公开缓存统计信息(命中、未命中、删除等),并允许重置统计信息。这非常有帮助,以验证您的缓存是否有用(如果您只得到未命中,则缓存只是增加了开销,并且从未按预期使用)。 |
| 读写通过 | 如果配置了,则激活读取器/写入器。 |
CDI 集成
JCache 规范(以及因此完整的实现)包含 CDI 集成。其想法是让您能够缓存数据,而无需处理 Cache 的所有粘合剂。
CDI 集成提供了四个与 CDI 一起使用的操作:
-
@CacheResult: 这可能是最有用的功能,它将缓存方法结果,并在后续调用中从缓存中提供服务。 -
@CacheRemove: 这将从缓存中删除数据。 -
@CacheRemoveAll: 这将删除引用缓存中的所有数据。 -
@CachePut: 这会将数据添加到缓存中。它依赖于@CacheValue,它标记一个参数以标识要缓存的价值。
如果我们想在我们的服务中缓存我们的报价,我们只需用@CacheResult装饰我们的查找方法:
@CacheResult
public Quote findByName(final String name) {
return ...;
}
添加@CacheResult注解将允许您从该方法的第二次调用中使用缓存,并绕过我们之前使用的 JPA 查找。
注意,这里我们不是缓存一个可选值,正如我们的原始签名那样,这将工作但不可序列化。作为 JDK 的一部分,如果我们需要该约束将值分布到缓存集群,我们可能会遇到麻烦。在实践中,尽量不缓存可选值,并且永远不要缓存那些被延迟评估且不可重用的流。
缓存配置
所有这些注解都共享相同类型的配置,您可以在其中定义是否在方法执行前后执行相应的操作,在发生异常时缓存的行为(是否跳过缓存操作?),缓存名称以及如何解析要使用的缓存和键。
虽然第一组参数很容易理解,但让我们关注缓存解析,这在 CDI 中有点特别,因为您不是自己启动缓存,而是简单地引用它。
在程序化方法中,我们看到了缓存配置是通过一个CompleteConfiguration实例完成的。如何在 CDI 上下文中提供它?
所有这些注解都接受两个重要参数:
-
cacheName:这表示用于操作的缓存名称。请注意,如果没有明确设置,默认情况下它基于方法的限定名。 -
cacheResolverFactory:这是检索缓存实例的方式。
缓存解析器工厂提供从方法元数据到缓存解析器的访问,以执行与注解相关的操作,或者在抛出异常且注解配置要求在CacheResult#exceptionCacheName设置的情况下缓存异常的缓存解析器。
缓存解析器只是缓存的一个上下文工厂。以下是一个简化的实现:
@ApplicationScoped
public class QuoteCacheResolver implements CacheResolver {
@Inject
private CacheManager manager;
@Override
public <K, V> Cache<K, V> resolveCache(CacheInvocationContext<?
extends Annotation> cacheInvocationContext) {
try {
return
manager.getCache(cacheInvocationContext.getCacheName());
} catch (final CacheException ce) {
return manager.createCache(cacheInvocationContext.getCacheName(), new MutableConfiguration<>());
}
}
}
此实现是一个 CDI bean,允许您重用 CDI 功能,并尝试从上下文缓存名称检索现有缓存;如果不存在,则创建一个新实例。这样做是为了避免在运行时传递 catch 块——它只会发生一次。
事实上,为了使此实现工作,您需要在某处生成缓存管理器:
@ApplicationScoped
public class JCacheConfiguration {
@Produces
@ApplicationScoped
CacheManager createCacheManager() {
return ....;
}
void releaseCacheManager(@Disposes CacheManager manager) {
manager.close();
}
}
这是一个普通的 CDI 生产者,相关的代码可以重用我们在程序化 API 部分看到的代码。
使用 CDI 和提取解析器有趣的地方在于,您可以轻松地与任何配置集成。例如,要从${app.home}/conf/quote-manager-cache.properties读取配置,您可以使用此缓存解析器工厂的实现:
@ApplicationScoped
public class QuoteManagerCacheResolverFactory implements CacheResolverFactory {
private Map<String, String> configuration;
@Inject
private CacheManager manager;
@Inject
private Instance<Object> lookup;
@PostConstruct
private void loadConfiguration() {
configuration = ...;
}
@Override
public CacheResolver getCacheResolver(final CacheMethodDetails<?
extends Annotation> cacheMethodDetails) {
return doGetCache(cacheMethodDetails, "default");
}
@Override
public CacheResolver getExceptionCacheResolver(final
CacheMethodDetails<CacheResult> cacheMethodDetails) {
return doGetCache(cacheMethodDetails, "exception");
}
private CacheResolver doGetCache(final CacheMethodDetails<? extends
Annotation> cacheMethodDetails, final String qualifier) {
final MutableConfiguration cacheConfiguration =
createConfiguration(cacheMethodDetails, qualifier);
return new CacheResolver() {
@Override
public <K, V> Cache<K, V> resolveCache(final
CacheInvocationContext<? extends Annotation>
cacheInvocationContext) {
try {
return manager.getCache(cache);
} catch (final CacheException ce) {
return manager.createCache(cache,
cacheConfiguration);
}
}
};
}
}
使用这个框架,我们可以看到缓存解析器工厂像任何 CDI 豆一样接收注入,它在一个@PostConstruct方法中读取配置以避免每次都读取(但这不是强制性的,只是表明它确实可以利用 CDI 功能),当需要提供缓存时,它使用我们之前看到的策略创建(参见简单实现)。
为了完整,我们需要看看我们是如何读取配置的。它可以像读取properties文件一样简单:
final Properties cacheConfiguration = new Properties();
final File configFile = new File(System.getProperty("app.home", "."), "conf/quote-manager-cache.properties");
if (configFile.exists()) {
try (final InputStream stream = new FileInputStream(configFile)) {
cacheConfiguration.load(stream);
} catch (IOException e) {
throw new IllegalStateException(e);
}
}
// potentially create defined caches
configuration = cacheConfiguration.stringPropertyNames().stream()
.collect(toMap(identity(), cacheConfiguration::getProperty));
代码并不复杂,相当常见,但技巧在于将Properties转换为Map,这避免了在运行时同步,可能会在创建不同缓存时无理由地稍微减慢运行时速度。
要有一个功能性的实现,最后缺少的东西是如何创建缓存配置。这主要是将配置转换为缓存配置实例的问题。以下是一个潜在的实现:
private MutableConfiguration createConfiguration(final String configurationPrefix) {
final MutableConfiguration cacheConfiguration = new
MutableConfiguration<>();
cacheConfiguration.setStoreByValue(Boolean.getBoolean(
configuration.getOrDefault(configurationPrefix +
"storeByValue", "false")));
cacheConfiguration.setStatisticsEnabled(Boolean.getBoolean(
configuration.getOrDefault(configurationPrefix +
"statisticsEnabled", "false")));
cacheConfiguration.setManagementEnabled(Boolean.getBoolean(
configuration.getOrDefault(configurationPrefix +
"managementEnabled", "false")));
final String loader = configuration.get(configurationPrefix +
"loaderCdiName");
if (loader != null) {
cacheConfiguration.setReadThrough(true);
CacheLoader<?, ?> instance = lookup.select(CacheLoader.class,
NamedLiteral.of(loader)).get();
cacheConfiguration.setCacheLoaderFactory(new
FactoryBuilder.SingletonFactory<>(instance));
}
final String writer = configuration.get(configurationPrefix +
"writerCdiName");
if (writer != null) {
cacheConfiguration.setWriteThrough(true);
CacheWriter<?, ?> instance = lookup.select(CacheWriter.class,
NamedLiteral.of(writer)).get();
cacheConfiguration.setCacheWriterFactory(new FactoryBuilder.SingletonFactory<>(instance));
}
return cacheConfiguration;
}
创建缓存配置时,我们依赖于MutableConfiguration并直接从已加载的属性中读取值。技巧在于获取像读取器或写入器这样的实例。这可以通过 CDI 的Instance<Object>实现,这可以看作是一个通用的 CDI 查找;如果您愿意,也可以直接使用BeanManager。在这个实现中,我们从 CDI 名称查找读取器/写入器,因此我们需要提供@Named("...")字面量。自从 CDI 2.0 以来,您可以使用NamedLiteral API,这将为您创建相应的注解实例。最后,读取器/写入器需要通过一个工厂传递给 JCache 运行时,但 JCache 提供了一个单例工厂实现,防止您创建自己的。
@CacheDefaults
@CacheDefaults允许您在缓存级别定义缓存名称、解析器工厂和要使用的键生成器。它防止了在所有方法上都需要执行此操作,如果它们都共享相同的设置:
@ApplicationScoped
@CacheDefaults(
cacheName = "packt.quotes",
cacheResolverFactory = AppCacheResolverFactory.class,
cacheKeyGenerator = QuoteCacheGenerator.class
)
public class CachedQuoteService {
@Inject
private QuoteService service;
@CachePut
public Quote create(final Quote newQuote) {
return service.create(newQuote);
}
@CacheRemove
public Quote delete(final Quote quote) {
return service.delete(quote);
}
}
这个类将逻辑委托给一个专门的服务,有两个方法使用 JCache CDI 集成。这两个方法都使用在类级别上完成的相同共享配置,依赖于@CacheDefaults设置。它防止了必须以这种方式编写代码:
@ApplicationScoped
public class CachedQuoteService {
@Inject
private QuoteService service;
@CachePut(
cacheName = "packt.quotes",
cacheResolverFactory = AppCacheResolverFactory.class,
cacheKeyGenerator = QuoteCacheGenerator.class
)
public Quote create(final Quote newQuote) {
return service.create(newQuote);
}
@CacheRemove(
cacheName = "packt.quotes",
cacheResolverFactory = AppCacheResolverFactory.class,
cacheKeyGenerator = QuoteCacheGenerator.class
)
public Quote delete(final Quote quote) {
return service.delete(quote);
}
}
在更简单的版本中,缓存配置是通过方法复制的,这降低了可读性。
缓存键
现在,我们能够控制我们的缓存并激活我们的缓存操作在方法上;我们错过了什么?——控制用于缓存的键的方式。例如,让我们看看以下方法:
@CacheResult
public Quote findQuote(String symbol)
在这里,自然键是符号,所以如果 JCache 能自动完成这个任务,那就太好了,对吧?它是缓存,但规则稍微复杂一些,因为如果您为create方法应用相同的推理,那么它就不起作用了:
@CacheResult
public Quote create(String symbol, double price)
在这里,我们希望结果被缓存,但如果findQuote()必须匹配create()方法,那么我们必须有一种方法来请求 JCache 只使用symbol作为键。
为了做到这一点,JCache 依赖于@CacheKey API。以下是一些规则:
-
如果没有
@CacheKey,则使用所有参数 -
如果某个参数使用了
@CacheValue但没有@CacheKey,则使用所有参数,除了装饰了@CacheValue的那个 -
如果某些参数(>= 1)装饰了
@CacheKey,则使用它们
换句话说,我们的create方法应该看起来像以下这样:
@CacheResult
public Quote create(@CacheKey String symbol, double price)
这样,由于之前的规则,findQuote和create方法使用相同的键,基于符号—基于,因为缓存的键不是直接作为参数传递的值。这主要是因为它可能是由多个参数组成的键,所以你需要将它们包装在单个对象中。实际的关键类型是GeneratedCacheKey,它只是强制实现序列化,并实现equals和hashCode,正如我们在本章开头提到的理由。
JCache 实现将默认提供一个遵守这些规则的实现,但在某些情况下,您可以优化或希望自定义键。在我们的例子中,一个普通的字符串键,我们可以优化GeneratedCacheKey以完全依赖于字符串的特定功能,这允许缓存hashCode。以下是实现:
public class StringGeneratedCacheKey implements GeneratedCacheKey {
private final String value;
private final int hash;
public StringGeneratedCacheKey(final String value) {
this.value = value;
this.hash = value.hashCode();
}
@Override
public boolean equals(final Object o) {
return this == o ||
o != null && getClass() == o.getClass() &&
Objects.equals(value, StringGeneratedCacheKey.class.cast(o).value);
}
@Override
public int hashCode() {
return hash;
}
}
由于缓存访问是通过哈希索引访问存储单元,如果代理参数的哈希码计算时间较长或需要通过复杂的图进行,那么优化哈希可能是值得的。同样的逻辑也适用于equals。
现在,我们有了我们键的优化版本;我们需要启用它。这是通过缓存注解(@CacheDefaults)和cacheKeyGenerator()成员来完成的。它允许我们引用一个键生成器。在这里,同样,它可以是 CDI bean,它提供了方法的环境信息,因此你可以实例化键:
@ApplicationScoped
public class SingleStringCacheKeyGenerator implements CacheKeyGenerator {
@Override
public GeneratedCacheKey generateCacheKey(final
CacheKeyInvocationContext<? extends Annotation> context) {
return new StringGeneratedCacheKey(String.class.cast(
context.getKeyParameters()[0].getValue()));
}
}
这是一个非常简单的实现;直接提取方法(假设的)单个键参数并将其转换为字符串以实例化我们优化的生成缓存键。然后,为了使用它,我们只需在缓存注解中引用这个类:
@CacheResult(cacheKeyGenerator = SingleStringCacheKeyGenerator.class)
public Quote findByName(final String name) {
return ...;
}
确保生成器实现与方法签名匹配非常重要。通常,在这个最后的片段中,如果我们把name参数改为long,那么我们需要更改键生成器;否则,它将失败。然而,生成器假设键参数类型的情况并不少见,因为它通常与优化它们的用法相关联。
缓存一次
如果回顾我们的报价管理应用,一个请求会经过以下层次:
-
Servlet
-
JAX-RS
-
服务层(
@Transactional) -
JPA
你可以在所有层添加一些缓存(例如 JCache,而不是 HTTP,后者更像是客户端数据管理解决方案)。在 Servlet 层面,你可以使用请求作为键来缓存响应。在 JAX-RS 中,你可以以更面向业务的方式做同样的事情。在服务层,你可以使用 CDI JCache 集成。而在 JPA 中,你可以使用第 2 级缓存,这可以通过 JCache 或特定提供者的实现来实现——这通常只需要配置设置,这样 API 就不是非常关键。
然而,如果你在所有层配置缓存,很可能会有一部分缓存是无用的,并且由于所有层无法访问相同的信息,你将为了微小的收益或没有任何收益而重复缓存。用一个极端的例子来说,如果你在 Servlet 层缓存请求的响应,一旦数据进入缓存,JAX-RS/service/JPA 层就永远不会被调用,因此在这些层设置缓存是无用的。这并不意味着在这些层应该避免缓存,因为服务层使用一些缓存也可以使一些后台任务受益(例如,使用 JBatch 开发的批处理任务使用一些参考数据)。
尽管如此,缓存你应用程序最外层的输出将给你带来最佳的性能提升,因为它将绕过更多层。例如,在 Servlet 层缓存响应将绕过 JAX-RS,从而绕过 JAX-RS 的路由和序列化步骤,而在服务层缓存相同的数据将保持通过 JAX-RS 层执行这些步骤。
这里没有普遍的规则,因为这是在内存占用(接近你的数据)之间的权衡。你通常使用的内存越少,键处理就会越简单(因为你不会在 Servlet 层累积其他数据,如 HTTP 头)。你最好做的事情是考虑你的应用程序以及它如何使用数据,然后通过比较基准来验证缓存设置。
总结
在本章结束时,你将拥有增强应用程序性能所需的所有关键。我们看到了如何将正确的数据发送到浏览器,以便不必加载缓存数据,如何使用 Java EE API(JCache)设置缓存,以及你需要考虑的缓存挑战,以避免降低性能。
在分布式系统中能够缓存数据非常重要,因为任何网络调用都会对性能产生很大影响。现在我们知道了如何缓存,我们可以进入分布式系统的下一个层次,看看如何在一个更广泛的系统中控制性能。这正是我们下一章要讲的内容——如何在系统开始失败或运行速度比平时慢时,使系统能够容错并避免影响所有应用程序。
第七章:具有容错性
多年来,Java EE 一直致力于将尽可能多的应用程序放入单个应用服务器中,但现在这种做法已经改变。现在,在容器实例中部署单个应用程序并减小应用程序大小以处理单一责任变得更加常见。这种范式转变的直接影响是,整个系统现在由比以前多得多的应用程序组成,我们越来越依赖于远程通信。
在这种情况下,一个应用程序的性能直接取决于另一个应用程序,因此限制应用程序之间的副作用变得非常重要。为了确保您的应用程序能够识别其环境的影响并能够在这种约束下工作,我们将在本章中介绍以下主题:
-
客户端和服务器上的负载均衡
-
失败转移
-
电路断路器
-
使用隔离舱
-
超时处理
它肯定会失败,毫无疑问!
在开发应用程序时,我们通常花费大部分时间在通过代码路径上,因为代码路径赋予了应用程序实际的功能。然而,不要忘记所有意外情况也很重要。试图解决我们无法控制的事情可能听起来很奇怪,但在这里,我们的想法是遵循墨菲定律,它通常被总结如下:任何可能出错的事情,最终都会出错。这并不意味着系统永远不会工作,但它的意思是,如果存在潜在的问题,它最终会成为你的现实。
在现代系统和 Java EE 部署方面,典型的后果是你可能会失去与相关资源或应用程序的连接。你还可以希望解决另一个常见的故障案例,即 JVM 失败(没有更多内存、操作系统问题等),但这与基础设施(可能是 Kubernetes)有关,并且超出了本书的范围。
我们将通过一个非常简单的系统来阐述这一点,其中三个 Java EE 应用程序是串联的:

在这样的架构中,我们可以假设一个前端层暴露了一些客户功能或 API。然后,前端应用程序将实际逻辑委托给另一个团队拥有的内部系统。最后,内部系统依赖于一个数据系统,该系统同样由另一个团队拥有和开发。
使用这种类似的架构并不罕见,但与外部系统一起使用。在这种情况下,你通常有一个支持电话号码,但它很少像打电话给同事那样高效,这使得你在系统失败的情况下更加依赖这个系统/公司。
对于这样的系统来说,重要的是如果数据失败(因为数据工程师进行了一个未按预期工作的升级),那么所有内部系统都将开始失败,因为数据不再响应。递归地,前端系统将因为内部系统失败而失败。
你可能会认为这只会使系统效率低下,并且与性能无关。实际上并非如此。在先前的架构中,数据系统看起来相当集中。如果公司增加第二个内部系统(我们可以称之为internal2),那么我们可以假设数据存储的负载将翻倍。然而,如果数据没有为负载增加而调整大小,它将回答得更慢,并且可能会返回更多的错误。在这里,所有消费者服务,包括传递性服务,都将开始变慢,因为它们依赖于数据系统。
这不是你可以真正避免的事情。你可以限制意外故障的影响,但几乎不可能保证它不会发生。如果你是一家大公司,有一个负责所有应用程序的操作团队,这类问题可能会根据优先级得到解决,性能下降将不如系统故障重要。当一个分布式系统像这样开始失败时,每个组件通常会因为关系而缓慢失败,这就是为什么所有应用程序都会被监控团队视为红色,这并不帮助他们解决问题,因为只有系统的一部分(在这个例子中是我们的数据系统)正在失败。这就是为什么确保你的系统准备好失败,可以确保在出现问题时系统修复得更快,如果某些相关应用程序失控,对系统其他部分的影响也会降低。
负载均衡 – 选择最佳方案
负载均衡是关于定义如何选择处理请求的后端节点。它可以在服务器或客户端上进行,但策略总体上是相同的。它主要是客户端或服务器的部署问题,因为当负载均衡器是一个实例(软件)时,你实际上在最终客户端和你的中间件之间添加了一个客户端。
在非常高的层面上,负载均衡器可以简化如下:

全球的想法是在客户端和服务器之间添加一层,这将根据不同的策略来协调请求如何分布到服务器。这张图展示了四个客户端通过负载均衡器调用相同的应用程序,负载均衡器将请求处理委托给三个服务器(一个服务器将处理四个客户端请求中的两个)。
这是一种常见的服务器端负载均衡表示,但它也可以应用于客户端。唯一的区别是负载均衡器将在客户端的 JVM(Java 虚拟机)内部部署,而不是通过网络协议(如 websocket、HTTP 等)接收传入的请求,而是从 JVM 内部(正常方法调用)接收。
透明负载均衡 – 客户端与服务器
使用服务器负载均衡器的主要优势是客户端实际上并不关心负载均衡器。具体来说,所有客户端都将使用相同的端点(比如说,quote-manager.demo.packt.com),而负载均衡器将分配请求而不需要了解任何客户端信息。这在基础设施方面非常重要,因为你可以更新你的基础设施而无需通知或更新客户端(如果客户端不属于你自己的系统,这可能是不可能的)。
例如,如果你一开始使用两台机器,然后在一个月后决定添加第三台机器,因为负载增加或为了支持黑色星期五的额外负载,那么你只需将这台第三台机器注册到负载均衡器上,它将把工作负载分配到三台机器上,而不是只有两台。反之亦然:如果你需要对一台机器进行维护,你可以将其从负载均衡器后面的集群中移除,假设移除一台机器仍然可以支持负载,但在基础设施的规模阶段应该考虑到这一点。然后,离线进行维护,完成后,只需将机器重新添加到集群中即可。
因此,这种分析听起来像是服务器负载均衡是最好的解决方案,也是应该选择的一个。然而,如果你拥有客户端,现代系统有高效的客户端负载均衡器(这在面向微服务的系统中通常是情况)。是什么让服务器负载均衡策略比客户端负载均衡更好?——事实是服务器可以在不通知客户端的情况下进行更新。这意味着如果客户端从服务器/后端更改中自动更新,那么我们在服务器端也实现了同样的效果。在实践中,这是通过一个服务注册表来完成的,它可以列出你可以用来联系服务的 URL 列表。在实践中,客户端负载均衡器将联系这个注册表服务以获取特定服务的端点列表,并定期使用与上一章中看到的池配置策略相似的配置策略更新这个列表。这意味着这个注册表服务必须是可靠的,并且可能需要使用服务器负载均衡器解决方案,但然后,所有其他服务都可以使用点对点连接(无需中间负载均衡器实例)。从应用程序的影响来看,这意味着添加(或删除)服务器必须意味着(去)注册到注册表,而不是负载均衡器,但在两种情况下都是同样的工作。
到目前为止,我们看到客户端和服务器负载均衡都可以实现类似的功能,那么有什么可以区分它们呢?你可以使用两个主要标准来在两者之间进行选择:
-
谁负责基础设施和负载均衡?如果是开发(运维)团队,这两种解决方案都能很好地工作。然而,如果你在一个将开发和运维分开成不同团队的公司工作,你可能会更倾向于将这部分工作委托给运维团队,因此使用服务器负载均衡器,他们将完全控制,而不会影响应用程序开发。
-
你想在负载均衡器内部实现什么样的逻辑?服务器端负载均衡器已经实现了最常见的策略,并且通常还提供了一种小型的脚本语言,你可以用它来自定义。然而,如果你有一个非常定制的策略(可能依赖于你的应用程序状态),那么你将需要在客户端编写负载均衡策略。
总结来说,客户端负载均衡在开发方面影响更大,因为你需要在客户端处理它,这意味着在所有客户端而不是服务器端的一个实例上,但它为你提供了针对非常高级需求的真实更多权力。
常见策略
如何分配请求是负载均衡器的核心部分。在本节中,我们将介绍你在配置负载均衡器时可能会遇到的最常见的解决方案。
轮询算法
轮询算法无疑是所有策略中最为人所知的。它将集群(服务器)中可用的成员列表视为一个环,并在每次请求到来时连续遍历这个环。
例如,如果你有三个服务器(server1.company.com,server2.company.com,server3.company.com),以下是第一个请求的处理方式:
| 请求编号 | 选择的服务器 |
|---|---|
| 1 | server1.company.com |
| 2 | server2.company.com |
| 3 | server3.company.com |
| 4 | server1.company.com |
| 5 | server2.company.com |
| 6 | server3.company.com |
| 7 | server1.company.com |
| ... | ... |
你会注意到,为了实现公平的分配,负载均衡器策略必须在每次选择服务器时锁定或同步列表。还有其他这种算法的变体,其实现是无锁的,但分布的公平性不能完全保证。然而,请记住,这通常不是你真正关心的事情,因为你希望有一个看起来像是公平的解决方案。
随机负载均衡
随机负载均衡也针对服务器列表进行操作,但每次请求到来时,它会随机选择一个。如果随机实现是均匀分布的,它会导致接近轮询解决方案的分布。然而,它可能具有更好的可扩展性,因为它不需要同步列表来选择要使用的当前服务器。
链接到故障转移
我们将在下一节中更多地讨论故障转移,但在此处提到负载均衡可以用来实现故障转移策略是很重要的。在这里,目标将是如果当前服务器失败,尝试将请求发送到另一台机器。这可以看作是轮询,但与使用每个请求作为触发器来迭代主机列表(以更改目标实例)不同,失败将是触发器。以下是一个使用故障转移策略的示例序列,考虑到我们与轮询部分相同的三个主机:
| 请求编号 | 选择的服务器 | 状态 |
|---|---|---|
| 1 | server1.company.com |
正常 |
| 2 | server1.company.com |
正常 |
| 3 | server1.company.com |
正常 |
| 4 | server1.company.com |
正常 |
| 5 | server1.company.com |
正常 |
| 6 | server2.company.com |
正常 |
| 7 | server2.company.com |
正常 |
| ... | ... |
如前表所示,每个请求都使用相同的宿主(server1.company.com),直到请求失败(请求编号 5),在这种情况下,算法将迭代主机列表并开始使用 server2.company.com。
事实上,这个算法有一些变体。例如,失败的请求可以与列表中的下一个主机重试(或不重试),或者你可以配置在切换主机之前等待的失败次数,甚至可以配置失败的含义(默认情况下通常是 5xx HTTP 状态,但你也可以将其配置为任何 HTTP 状态> 399,或者基于响应的标题或其他部分来做出这个选择)。
粘性会话
粘性会话路由通常用于业务用例。其想法是在会话启动时始终将客户端路由到同一后端服务器。Java EE 通过 SessionTrackingMode 定义了三种会话模式:
-
COOKIE:会话通过其标识符(
JSESSIONID)在 cookie 中跟踪,因此它击中浏览器(客户端)并随每个请求在 cookie 中发送。 -
URL:
JSESSIONID通过 URL 发送到客户端。例如:http://sample.packt.com/quote-manager/index.html;jessionid=1234 -
SSL:这使用 HTTPS 原生机制来识别会话。
每次跟踪都是通过在客户端和服务器之间传递一个共享的 标识符 来实现的。如果在两者之间添加一个负载均衡器,如果不针对同一主机,通信可能会中断。以下是表示这个陈述的图表:

此图表示一个客户端服务两个请求。第一个请求(1)将击中负载均衡器,它将请求重定向到服务器 1(1'),请求处理将在服务器 1上创建一个会话。这意味着第一个请求的响应将创建一个JSESSIONID(或其 SSL 替代品)。现在,客户端发出第二个请求(2),在这里,负载均衡器根据策略的粘性将请求重定向到第二个服务器(2')。在服务器 2上的处理过程中,应用程序试图访问在第一个请求期间创建的会话信息(例如,已识别的用户),但由于我们切换了节点,会话不在这里。因此,请求处理失败(红色交叉)。
为了确保此工作流程正常工作,有两种主要解决方案:
-
确保会话状态在所有节点之间分布和共享。此解决方案在服务器之间设置了一种类型的分布式存储空间。这通常意味着大量的延迟(如果同步完成),或者在异步完成时,可能会出现一些潜在的遗漏(故障),这可能导致与先前方案相同的问题。它还意味着要配置除服务器默认会话处理之外的解决方案,该处理默认情况下仅限于本地。
-
确保负载均衡器在创建会话后始终击中相同的后端节点。这就是我们所说的粘性会话模式。负载均衡器将检查是否存在
JSESSIONID(或 SSL 连接),如果存在,则存储创建它的节点。如果它再次在请求中看到此标识符,它将重定向请求到同一节点,忽略任何分布策略。
这意味着粘性会话模式通常与另一种策略相结合,该策略将定义分布,因为粘性会话仅在已经为客户端服务了一个请求之后才适用。
调度算法
调度算法是基于某些统计标准的一类广泛策略。这里的想法是更准确地关于负载分布的方式,考虑到后端服务器上的可用资源。最常用的标准如下:
-
按请求:分布基于节点服务的请求数量。请注意,每个服务器节点可以与这种分布相关联一个权重,以偏置分布,如果一台机器不如其他机器强大。
-
按流量:这与先前的分布类似,但不是计数请求,而是使用传输的字节数。
-
按繁忙程度:这也是基于请求数量,但仅限于活动数量。选择最不繁忙的节点。
-
心跳:这本身不是一个分发解决方案,而更像是一种替代评估解决方案。它使用心跳或代理来评估节点所承受的负载,并根据这些信息将负载分配给可以处理最多负载的节点。这通常是一种时间统计,因此它是动态和自适应的。
负载均衡或代理 – 额外功能
即使您设置了负载均衡以使用之前的一种策略来分配负载,负载均衡器解决方案通常是一个完整的代理,因此也提供了额外的功能。这通常更多地涉及服务器中间件而不是客户端,但它仍然非常有趣。从后端(服务器)的角度来看,您可以获得的部分功能如下:
-
压缩:您的后端服务器可以提供纯文本,而负载均衡器/代理层将自动在文本资源(HTML、JavaScript、CSS 等)上添加 GZIP 压缩。由于客户端(浏览器)到负载均衡器的通信通常比负载均衡器到服务器/后端通信慢,这将允许您为这些请求节省宝贵的时间。
-
TCP 缓冲:在这里,想法是在负载均衡器层缓冲后端服务器发送的响应,以减轻后端的负担,并让它处理其他请求。这对于缓慢的客户端很有用,它们会在后端保持连接,但不会引起任何处理/计算工作。
-
HTTP 缓存:在上一节中,我们看到了 HTTP 定义了一些缓存。代理层可以免费为您处理,而无需请求后端服务器。这通常仅涉及静态资源,在这种情况下被移动到代理层。
-
加密:代理层可以加密请求的一部分,以防止最终用户了解足够关于后端的信息,从而理解其工作方式或甚至访问一些敏感数据。
当负载均衡器层添加比通信/网络导向更偏向于商业化的功能时,我们通常称之为网关。然而,从技术上来说,它基本上是一种类似的中间件。以下是您可以在网关中找到的功能:
-
安全处理:负载均衡器层可以验证请求(通常来自标题)中的权限。
-
版本处理:根据传入的请求,路由(后端请求的端点)可以改变,使我们能够自动处理后端点的版本。
-
速率限制:这限制了后端访问的速率,无论是通过应用程序还是按用户,如果认证与速率限制相关联。这通常以每单位时间允许的请求数量来表示,例如,每分钟 1,000 个请求。
-
并发限制:这控制了可以并行/同时发送的请求数量。至于速率限制,它可以针对整个应用程序或每个用户(或相关其他单位)进行。例如,512 个请求。
如你所见,有几个特性,而且它们都不与性能相关。然而,根据你的最终环境,大多数特性都会对性能产生影响。例如,HTTP 缓存将允许你的后端服务器处理更多的实际负载,因此更容易进行扩展。速率/并发限制特性可以使你控制性能,并确保在意外负载情况下不会降低性能,但其他特性,如安全性特性,如果网关层可以使用硬件加密解决方案而不是通常在 Java EE 应用程序中使用的软件加密,可能会对你的性能产生非常强烈的影响。
在这里需要记住的重要一点是将应用程序视为一个系统解决方案,而不是试图将所有内容都放在应用程序中,仅仅因为这样做更容易或更便携。依赖于经过良好优化的硬件解决方案,与优化软件解决方案相比,将产生良好的性能,尤其是在安全性和加密方面,这会影响你应用程序的可移植性。
备用
在分布式系统中,确保你知道如何处理故障非常重要。Java EE 应用程序越来越多地连接到其他系统,它们面临越来越多的挑战,因此了解在发生故障转移时如何处理非常重要。
备用的第一个含义确实是“故障转移”。它可以重新表述为在主系统失败时切换到备用系统的能力。在 Java EE 应用程序中,有很多地方可以设置这个,但它们都与外部系统相关:
-
数据库:如果数据库连接失败,如何仍然处理请求?
-
JMS:如果代理失败,怎么办?
-
其他网络 API(如 SOAP 或 REST API):如果远程服务器宕机,怎么办?
-
WebSocket:如果目标服务器关闭连接或失败,怎么办?
通常情况下,每次你的应用程序依赖于它无法控制的东西(即外部系统),它可能需要一个备用计划,以便在主要解决方案不再响应或工作时仍然能够正常工作。
处理故障转移有几种方法,它们要么依赖于选择另一个系统,要么基于某些默认/缓存实现。
切换到另一个系统
备用的最简单实现是在发生错误时切换到另一个系统。这就是我们在上一节中看到的负载均衡。我们能够实施备用的唯一条件是能够识别系统遇到的错误。
这里使用 Java EE 的 JAX-RS 客户端 API 的示例来说明这个逻辑:
@ApplicationScoped
public class ExternalServiceClient {
@Inject
private Client client;
public Data get() {
return Stream.of("http://server1.company.com",
"http://server2.company.com")
.map(server -> {
try {
return client.target(server)
.path("/api/quote")
.request(APPLICATION_JSON_TYPE)
.get(Data.class);
} catch (final WebApplicationException wae) {
if (supportsFailover(wae)) {
return null;
}
throw wae;
}
})
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() -> new IllegalStateException("No
available target
server"));
}
}
这个片段用Stream替换了对远程 API 的直接调用。在这里使用Stream比使用Collection更复杂,因为流的叶子将触发(按元素)执行流程,并允许我们在遇到最终条件时提前停止迭代。具体来说,它防止我们在不相关的情况下迭代所有元素,这正是我们想要的故障转移。在实现方面,这里是流程:
-
从服务器端,我们调用我们想要的端点。
-
我们处理服务器的响应。如果它需要故障转移,我们返回
null;否则,我们保持调用的默认行为。 -
我们从流程中移除了
null响应,因为前一步定义null为故障转移条件。 -
我们使用第一个可用的响应作为有效响应,这将避免对所有服务器进行调用。
-
如果所有服务器都失败,那么我们抛出
IllegalStateException。
之前片段中缺少的是评估我们想要故障转移的方式。在之前的代码中,我们是基于WebApplicationExceptinon来做出这个决定的,所以客户端可以在出错时抛出异常。supportsFailover()的默认实现只是返回true,但我们可以做得更复杂一些:
private boolean supportsFailover(final WebApplicationException wae) {
final Response response = wae.getResponse();
if (response == null) { // client error, no need to retry
return false;
}
return response.getStatus() > 412;
// 404, 412 are correct answers we don't need to retry
}
这仍然是一个简单的实现,但我们使用 HTTP 状态码来重试,只有当其值大于 412 时,这意味着如果得到 HTTP 404(未找到)或 HTTP 412(预处理失败),我们不会重试,因为这两种情况都会导致向另一个服务器发送相同的请求并得到相同的响应。
当然,你可以自定义很多这种逻辑(这甚至可能取决于服务),但幸运的是,Java EE 为你提供了所有你需要的东西。
本地回退
之前的故障转移实现是考虑到如果主系统失败,有一个替代系统可以联系。这并不总是情况,你可能在出错的情况下希望用本地默认值替换远程调用。这种解决方案在 hystrix 框架中可用,可以使用我们之前看到的并发工具与 Java EE 集成。
默认逻辑的高级逻辑如下:
try {
return getRemoteResponse();
} catch (final UnexpectedError error) {
return getLocalResponse();
}
这实际上是对之前实现的泛化。你不需要看到要联系的主机列表,而是需要将远程调用视为一系列任务。具体来说,我们可以这样重写:
@ApplicationScoped
public class ExternalServiceClient {
@Inject
private Client client;
public Data get() {
return Stream.<Supplier<Data>>of(
() -> getData("http://server1.company.com"),
() -> getData("http://server1.company.com"))
.filter(Objects::nonNull)
.findFirst()
.orElseThrow(() -> new IllegalStateException("No
available remote
server"));
}
private Data getData(final String host) {
try {
return client.target(host)
.path("/api/quote")
.request(APPLICATION_JSON_TYPE)
.get(Data.class);
} catch (final WebApplicationException wae) {
if (supportsFailover(wae)) {
return null;
}
throw wae;
}
}
// supportsFailover() as before
}
在这里,我们只是将客户端调用移动到方法中,并用调用流替换了我们的主机流。流逻辑完全相同——也就是说,它将取列表中的第一个有效结果。
这种解决方案的直接收益是,由于你传递的是任务给故障转移逻辑,而不是主机,你可以按自己的意愿实现每个任务。例如,如果你想默认使用硬编码的值,你可以这样做:
Stream.<Supplier<Data>>of(
() -> getData("http://server1.company.com"),
() -> new Data("some value"))
使用此定义,我们首先尝试联系http://server1.company.com,如果失败,我们将在本地创建一个Data实例。
在看到可以使用哪些策略进行这些回退之前,让我们先花一点时间看看它在代码组织方面可以意味着什么。
回退代码结构
当您只是处理服务器之间的故障转移时,它并不复杂,因为您可能已经在Client类中有主机列表,并且遍历它们几乎是自然的。现在,当我们遍历不同的实现时,这就不那么自然了,我们需要一个编排器豆。具体来说,对于之前的例子,它首先调用远程服务,然后回退到本地硬编码的实例化,我们需要以下内容:
-
远程客户端
-
本地模拟实现
-
一个门面(编排)服务,它被用于各个地方,因此我们可以利用这个故障转移逻辑
当您开始集成大量服务时,这并不方便。
Microprofile 来拯救
Microprofile 在其范围内包含一个规范,帮助您以“标准”方式处理您的回退逻辑。该规范允许在方法失败的情况下定义回退方法引用或处理程序。以下是一个例子:
@Fallback(method = "getDataFallback")
public Data getData() {
return client.target("http://server1.company.com")
.request(APPLICATION_JSON_TYPE)
.get(Data.Class);
}
private Data getDataFallback() {
return new Data(...);
}
在这里,您将从封装服务的所有消费者中调用getData,如果方法失败,微 Profile 回退处理将自动调用getDataFallback。
此实现还支持@Retry,它允许您定义在回退到回退处理/方法之前,您将执行主方法(在之前的例子中为getData)多少次。
这个 API 很简洁,但将不同的实现耦合在一起,因为主方法和次要方法通过@Fallback API 链接。
故障转移扩展
使用 CDI,您可以定义一个小扩展,该扩展将自动处理故障转移,就像我们之前处理流一样,无需太多努力。该扩展将由两个主要部分组成:
-
它将识别特定逻辑的所有实现
-
它将注册一个豆,以正确顺序使用故障转移逻辑链接实现
为了做到这一点,我们需要一些 API 元素:
-
为了找到一个服务实现,我们将使用
@Failoverable标记接口方法,以标识我们需要为该接口创建一个故障转移实现;我们还将使用此注解来标记实现。 -
为了对服务进行排序,我们将使用
@Priority。为了简单起见,我们将只使用优先级值作为排序顺序。
从用户的角度来看,这里有一个之前的例子:
|
@Failoverable
public interface GetData {
Data fetch();
}
|
@ApplicationScoped
@Priority(0)
@Failoverable
public class RemoteGetData {
@Inject
private Client client;
@Override
public Data fetch() {
return client.
....
get(Data.class);
}
}
|
@ApplicationScoped
@Priority(1000)
@Failoverable
public class LocalGetData {
@Override
public Data fetch() {
return new Data(...);
}
}
|
接口定义了我们想要支持故障转移的方法。然后,我们有两种不同的实现及其优先级。这种组织方式将允许我们添加另一种策略并将其轻松自动地插入链中,而无需修改所有其他实现,并尊重 CDI 的松耦合。现在,任何用户都可以将GetDatabean 注入任何服务并调用fetch()以实现自动故障转移。
这个例子没有为方法定义任何参数,但这并不是限制,你可以用这种策略使用任何方法,即使是复杂的。
在调用者代码方面,它看起来就像任何 CDI bean 调用:
public class MyService {
@Inject
private GetData dataService;
public void saveData() {
final Data data = dataService.fetch();
doSave(data);
}
// implement doSave as you need
}
就这样!不需要为最终用户实现GetData故障转移;这是由扩展完成的。
现在我们已经看到了 API 的样子,让我们看看 CDI 如何使我们轻松地做到这一点。
第一步是定义我们的 API;唯一不在 Java EE 中的 API 是@Failoverable。它是一个普通的注解,没有特别之处,除了它必须能够应用于接口:
@Target(TYPE)
@Retention(RUNTIME)
public @interface Failoverable {
}
然后,我们只需要一个扩展来识别带有此注解的接口的实现,对它们进行排序,并为每个接口定义一个 bean:
public class FailoverExtension implements Extension {
private final Map<Class<?>, Collection<Bean<?>>> beans = new
HashMap<>();
private final Annotation failoverableQualifier = new
AnnotationLiteral<Failoverable>() {
};
// ensure our @Failoverable annotation is qualifying the beans who
used this
annotation
// to avoid any ambiguous resolution during the startup
void addQualifier(@Observes final BeforeBeanDiscovery
beforeBeanDiscovery) {
beforeBeanDiscovery.addQualifier(Failoverable.class);
}
// find all API we want to have support for failover
void captureFailoverable(@Observes
@WithAnnotations(Failoverable.class) final
ProcessAnnotatedType<?> processAnnotatedType) {
final AnnotatedType<?> annotatedType =
processAnnotatedType.getAnnotatedType();
final Class<?> javaClass = annotatedType.getJavaClass();
if (javaClass.isInterface() &&
annotatedType.isAnnotationPresent(Failoverable.class)) {
getOrCreateImplementationsFor(javaClass);
}
}
// find all implementations of the failover API/interfaces
void findService(@Observes final ProcessBean<?> processBean) {
extractFailoverable(processBean)
.ifPresent(api ->
getOrCreateImplementationsFor(api).add(processBean.getBean()));
}
// iterates over all API and create a new implementation for them
which is
added
// as a new CDI bean with @Default (implicit) qualifier.
// to do that we use the new CDI 2.0 configurator API (addBean())
which allows
// us to define a bean "inline".
void addFailoverableImplementations(@Observes final
AfterBeanDiscovery
afterBeanDiscovery, final BeanManager beanManager) {
beans.forEach((api, implementations) ->
afterBeanDiscovery.addBean()
.types(api, Object.class)
.scope(ApplicationScoped.class)
.id(Failoverable.class.getName() + "(" + api.getName() + ")")
.qualifiers(Default.Literal.INSTANCE, Any.Literal.INSTANCE)
.createWith(ctx -> {
final Collection<Object> delegates =
implementations.stream()
.sorted(Comparator.comparingInt(b -> getPriority(b,
beanManager)))
.map(b -> beanManager.createInstance()
.select(b.getBeanClass(),
failoverableQualifier).get())
.collect(toList());
final FailoverableHandler handler = new
FailoverableHandler(delegates);
return Proxy.newProxyInstance(api.getClassLoader(), new
Class<?>[
]{api}, handler);
}));
beans.clear();
}
// helper method to extract the priority of an implementation
// to be able to sort the implementation and failover properly
// on lower priority implementations
private int getPriority(final Bean<?> bean, final BeanManager
beanManager) {
final AnnotatedType<?> annotatedType =
beanManager.createAnnotatedType(bean.getBeanClass());
return
Optional.ofNullable(annotatedType.getAnnotation(Priority.class))
.map(Priority::value)
.orElse(1000);
}
// if the api doesn't have yet a "bucket" (list) for its
implementations
// create one, otherwise reuse it
private Collection<Bean<?>> getOrCreateImplementationsFor(final Class
api) {
return beans.computeIfAbsent(api, i -> new ArrayList<>());
}
// if the bean is an implementation then extract its API.
// we do it filtering the interfaces of the implementation
private Optional<Class> extractFailoverable(final ProcessBean<?>
processBean) {
return
processBean.getBean().getQualifiers().contains(failoverableQualifier)
?
processBean.getBean().getTypes().stream()
.filter(Class.class::isInstance)
.map(Class.class::cast)
.filter(i -> i.isAnnotationPresent(Failoverable.class))
.flatMap(impl -> Stream.of(impl.getInterfaces()).filter(i ->
i !=
Serializable.class))
.findFirst() : Optional.empty();
}
}
此扩展有四个主要入口点:
-
captureFailoverable:这将确保任何@Failoverable接口都被注册,并且即使没有服务实现它,也会自动获得默认实现。它避免了在部署时出现“bean 未找到”错误,而是会抛出我们的故障转移实现异常,以确保对所有 bean 的一致异常处理。请注意,它仅在包含接口的模块的扫描模式包括接口(即,不是annotated)时才有效。如果不是,我们可能在部署期间遇到UnsatisfiedResolutionException或等效异常。 -
findService:这会捕获所有@Failoverable接口的实现。 -
addFailoverableImplementations:这会添加一个带有@Default限定符的 bean,实现@Failoverable接口。 -
addQualifier:这只是在我们的@FailoverableAPI 上添加了一个限定符,以避免模糊解析,因为所有服务(实现)都将实现相同的 API,我们希望使用@Default限定符(隐式限定符)来使用我们的门面。请注意,我们也可以在注解上添加@Qualifier。
此外,为了注册此扩展,别忘了创建一个包含该类完全限定名称的META-INF/services/javax.enterprise.inject.spi.Extension文件。
门面 bean 的实现是通过代理完成的。所有故障转移逻辑都将传递给处理程序,处理程序作为输入接收一个代表我们在findService中确定的实现者的代理列表:
class FailoverableHandler implements InvocationHandler {
private final Collection<Object> delegates;
FailoverableHandler(final Collection<Object> implementations) {
this.delegates = implementations;
}
@Override
public Object invoke(final Object proxy, final Method method, final
Object[]
args) throws Throwable {
for (final Object delegate : delegates) {
try {
return method.invoke(delegate, args);
} catch (final InvocationTargetException ite) {
final Throwable targetException =
ite.getTargetException();
if (supportsFailover(targetException)) {
continue;
}
throw targetException;
}
}
throw new FailoverException("No success for " + method + "
between " +
delegates.size() + " services");
}
private boolean supportsFailover(final Throwable targetException) {
return
targetException.getClass().isAnnotationPresent(Failoverable.class);
}
}
这种实现可能是最直接的:
-
代理列表已经排序(参见上一个扩展)。
-
它遍历代理并尝试对每个代理进行调用;第一个成功的是返回值。
-
如果没有调用成功,则抛出
FailoverException,这包括没有提供实现的情况(即delegates列表为空)。 -
如果抛出异常,会测试是否应该发生故障转移,并使用下一个代理。在这个实现中,这是通过确保异常上有
@Failoverable注解来完成的,但也可以测试一些众所周知的异常,例如WebApplicationException或IllegalStateException等。
回退处理 - 缓存,一种替代解决方案
在前一小节中,我们看到了如何使用另一种策略来处理回退,这可以是一个硬编码的默认值,或者可以是一种计算服务的方法,包括如何从另一个提供商联系另一个服务。这是故障转移的直接实现。
然而,如果你退一步思考你为什么要设置一些故障转移机制,你会意识到这是为了确保即使外部系统出现故障,你的服务也能运行。因此,还有一个解决方案,严格来说它不是一个故障转移,但实现了相同的目标,那就是缓存。在前一节中,我们看到了 JCache 如何帮助你的应用更快地运行,使你能够绕过计算。从外部系统缓存数据也使你更加健壮,并且可能防止你为它们实现故障转移机制。
让我们用一个非常简单的例子来说明这一点。在我们的报价应用(第一章,货币 - 报价管理应用)中,我们从 CBOE 获取要使用的符号列表,并查询 Yahoo!Finance 以获取每个报价的价格。如果这两个服务中的任何一个出现故障,那么我们就不会收到任何价格更新。然而,如果我们已经执行过这个逻辑一次,那么价格和符号列表将存储在我们的数据库中,这可以看作是一种持久缓存。这意味着即使后台更新过程失败,我们的应用仍然可以通过我们的 JAX-RS 端点为客户端提供服务。如果我们想更进一步,可以将这个逻辑更新为在 CBOE 服务不再可用时回退到选择数据库中的所有符号,这将允许应用至少获取价格更新,并且比如果整个更新过程因为 CBOE 故障而失败要更准确。
更普遍地说,如果一个远程系统并不完全可靠,并且数据可以被缓存,这意味着数据会被定期(重新)使用,并且对于你的业务来说可能有点过时,那么缓存是故障转移的一个非常好的替代方案。
在实现方面,你有两个主要选项:
-
手动处理缓存和回退(不推荐)
-
使用缓存作为数据源,如果数据过时或缺失则回退到外部系统
第一个选项是直接的故障转移处理,但回退实现基于缓存访问。实际上,这种解决方案认为您将在主源工作时会填充缓存;否则,回退将只返回null。
第二个选项可以通过我们在上一部分看到的解决方案实现,无论是使用 JCache CDI 集成还是将Cache API 作为应用程序中的主源手动使用。您会注意到,这反转了故障转移范式,因为主源(远程系统)成为次要的,因为首先检查缓存。但这就是缓存的工作方式,如果远程系统支持缓存,您将大多数时候从中获得更多的好处。
要提供缓存,您可以使用@CacheResult API,但不要忘记添加skipGet=true以仅提供缓存而不绕过逻辑。例如,它可以看起来像这样:
@ApplicationScoped
public class QuoteServiceClient {
@Inject
private Client client;
@CacheResult(skipGet = true)
public Data fetch() {
return client.target(....)....get(Data.class);
}
}
强制 JCache 跳过与@CacheResult关联的拦截器的获取阶段,使您在方法成功时可以将结果放入缓存,但如果结果已经在缓存中,则不使用缓存数据。因此,如果此服务与一个后备服务链式连接,该后备服务从缓存中读取数据,它将正确实现基于缓存数据的故障转移。
然而,请注意这里有一个技巧——您需要使用正确的缓存名称和键。为此,不要犹豫,也可以使用另一个依赖于 JCache 的方法:
@ApplicationScoped
public class QuoteServiceCache {
@CacheResult(cacheName =
"com.company.quote.QuoteServiceClient.fetch()")
public Data fetch() {
return null;
}
}
实现相当简单;如果它不在缓存中,则返回null来表示没有数据。另一种实现可能是抛出异常,具体取决于您想提供的调用者行为。然后,为了确保我们使用与之前主服务相同的缓存,我们使用之前方法的名字命名缓存。在这里,我们使用了主服务的默认名称,并将其设置为次要服务,但您也可以使用更面向业务的缓存名称,通过我们在第六章中看到的cacheName配置来实现。懒一点;缓存你的数据。
现在,如果我们回到以缓存为第一的解决方案,反转主次源,我们可以稍作不同的实现。如果缓存是源,我们仍然可以使用 CDI 集成,但缓存的提供(现在是次要源)可以通过本地的 JCache 机制完成。具体来说,我们的服务可以看起来如下:
@ApplicationScoped
public class QuoteServiceClient {
@Inject
private Client client;
@CacheResult
public Data fetch() {
return client.....get(Data.Class);
}
}
这是使用它的标准方式,但还有一种替代方式可以更好地与手动缓存处理配合使用——即不使用 CDI 集成。我们不是将方法作为后备并缓存其结果,而是在配置缓存时程序化地设置缓存懒加载的方式。在这种情况下,我们的服务可以变成以下这样:
@ApplicationScoped
public class QuoteServiceClient {
@CacheResult
public Data fetch() {
return null;
}
}
是的,你正确地看到了:我们甚至没有在服务中实现加载逻辑!那么它去哪里了?这个服务将触发 cache.get(...),所以当数据不可用时,我们需要在调用 get() 时注入我们的数据。为此,我们可以使用 CacheLoader API,该 API 在缓存本身上初始化。
要配置缓存,你可以使用自定义的 CacheResolver(有关更多详细信息,请参阅上一章),它将 CacheLoader 设置到缓存配置中:
new MutableConfiguration<>()
.setCacheLoaderFactory(new FactoryBuilder.SingletonFactory<>(new QuoteLoader()))
加载器的实现现在可以是以下内容:
@ApplicationScoped
public class QuoteLoader implements CacheLoader<QuoteGeneratedCacheKey, Quote> {
@Inject
private QuoteClient client;
@Override
public Quote load(QuoteGeneratedCacheKey generatedCacheKey) throws
CacheLoaderException {
return client.load(key.extractSymbol());
}
@Override
public Map<QuoteGeneratedCacheKey, Quote> loadAll(final Iterable<?
extends
QuoteGeneratedCacheKey> iterable) throws CacheLoaderException {
return StreamSupport.stream(
Spliterators.spliteratorUnknownSize(iterable.iterator(),
Spliterator.IMMUTABLE), false)
.collect(toMap(identity(), this::load));
}
}
loadAll 方法只是委托给 load 方法,所以它并不很有趣,但在某些情况下,你可以一次性批量加载多个值,并且有不同实现是有意义的。load 方法将加载委托给 CDI 容器。我们可以认为在这里我们调用远程服务时没有进行故障转移。
这个解决方案的重要点是有一个自定义的 GeneratedKey 键,以便能够解包它并提取业务键(在先前的示例中是 extractSymbol()),以便能够执行实际业务。作为对上一章的快速回顾,GeneratedKey 是从 JCache CDI 集成中的方法签名中推导出的键,因此你需要确保你可以使用 @CacheResult 与此类键一起工作。正如我们在第六章中看到的,要懒散;缓存你的数据,使用自定义的 CacheKeyGenerator 允许你满足这个解决方案的要求。
在使用方面,你何时应该使用 CacheLoader 而不是表现得像隐式缓存加载器的方法实现?当你不使用 CDI 集成时,缓存加载器更有意义,因为在这种情况下,你操作一个更自然的键(例如,对于符号的字符串)并得到相同的行为:
Cache<String, Quote> quotes = getNewQuoteCacheWithLoader();
Quote pckt = quotes.get("PCKT");
如果缓存被设置为在缓存中不存在数据时从远程服务加载数据,那么这个片段的第二行将调用远程服务并透明地使用数据初始化缓存。
这种缓存使用方式也适用于远程服务的情况,该服务受到速率限制。这将允许你比没有缓存时更多地依赖其数据。例如,如果服务每分钟只接受 1000 次带有你的凭证的请求,那么使用缓存,你每分钟可以调用它 10000 次。
断路器
断路器涉及允许应用程序在已知或估计为失败时禁用代码路径。
例如,如果你调用一个远程服务并且这个服务已经失败了 10 次,那么你可以这样说:不要再次调用这个服务 5 分钟。主要思想是在可能的情况下绕过错误。
断路器通常有三个状态:
-
关闭(CLOSED):系统被认为是正常工作的,所以使用它(默认情况)。
-
打开(OPEN):系统被认为是无法工作的,所以绕过它。
-
半开式(HALF-OPEN):系统必须重新评估。尝试一个调用:如果失败,则回到开放(OPEN)状态;否则,进入关闭(CLOSED)状态。
然后,从一个状态到另一个状态的所有条件都是可配置的。例如,触发关闭(CLOSED)状态的是什么取决于您如何配置它(可以是异常、HTTP 状态、超时等)。当系统进入半开(HALF-OPEN)状态时,情况也适用——可以是超时、请求数量等。
有多种断路器实现方式可用,但 Java EE 中最知名的是这些项目:
-
Hystrix
-
Failsafe
-
Microprofile fault-tolerant specification -
commons-lang3项目。
使用断路器对于确保系统健康非常重要,以确保即使某个功能不正常,系统也能始终保持健康。然而,它也可以用于性能,因为它会在系统开始失败时保持它们受控,避免连锁反应,即一个失败系统与另一个系统之间的每个连接都意味着另一个系统也会失败。为了确保断路器的影响符合预期,您需要将其与两个解决方案相关联:
-
一个故障转移解决方案,以确保您的系统(尽可能)正确地运行。
-
一个监控解决方案,以确保您正确报告您不再完全功能正常,以便支持/运维团队能够高效工作,并使您的断路器在失败系统修复后自动恢复。
Bulk head
Bulk head 是一种设计模式,旨在确保系统的任何部分都不会对其他系统区域产生深远影响。这个名字来源于在船只上常用的一个解决方案,以确保如果船体有洞,它不会导致船沉没。

例如,这里第二个区域有一个洞,水进入了该部分,但船不会沉没,因为其他部分是隔离的。
泰坦尼克号使用了这种技术,但隔离并没有从底部到顶部完全完成,以保障乘客和船员舒适。我们都知道那个选择的结果。这就是为什么,如果您选择隔离,确保它是完整的很重要;否则最好不要做任何事情。
这对应用程序意味着什么?这意味着每个可能导致你的应用程序崩溃的服务都应该与其他服务隔离开。这种隔离主要关于执行和因此服务调用的执行环境。具体来说,通常关于哪个池(线程、连接等)和哪个上下文要加载。为了能够隔离服务,你需要能够识别你正在调用的服务。如果我们以我们的报价管理应用程序为例,我们可以识别出quote finder服务,它可以与quote update服务隔离开,例如。这是隔离的业务标准,但在实践中,你通常会想更进一步,隔离服务的执行,包括使用租户。使用不同池为不同租户是常见的情况。这实际上通常与不同的合同条款有关。
为了在 Java EE 应用程序中说明这个概念,我们将更新我们的QuoteResource#findId方法以应用此模式。第一步将是将调用与 servlet 容器上下文/线程隔离开。为此,我们将使方法异步,使用 JAX-RS 的@Suspended API 和 Java EE 并发实用工具线程池:
@Resource(name = "threads/quote-manager/quote/findById")
private ManagedExecutorService findByIdPool;
@GET
@Path("{id}")
public void findById(@PathParam("id") final long id,
@Suspended final AsyncResponse response) {
findByIdPool.execute(() -> {
final Optional<JsonQuote> result = quoteService.findById(id)
.map(quote -> {
final JsonQuote json = new JsonQuote();
json.setId(quote.getId());
json.setName(quote.getName());
json.setValue(quote.getValue());
json.setCustomerCount(ofNullable
(quote.getCustomers())
.map(Collection::size).orElse(0));
return json;
});
if (result.isPresent()) {
response.resume(result.get());
} else {
response.resume(new
WebApplicationException(Response.Status.NO_CONTENT));
}
});
}
在这里,我们创建并配置了一个专用的池,用于应用的一部分,称为threads/quote-manager/quote/findById。findById方法在这个专用池中执行其原始逻辑。使用 JAX-RS 异步 API,我们手动resume请求响应,因为容器不再处理逻辑的执行,而是我们自己执行。
这种实现仅在线程池有一个最大大小时才有效,以确保执行受控。如果你使用无界线程池,这根本不能帮助你控制应用程序。
有其他方法实现 bulkhead,不依赖于不同的线程,例如使用我们在线程章节中看到的Semaphore,但它们不允许你将应用程序逻辑与容器线程隔离开。因此,它可能会对整体应用程序(或者如果你使用相同的 HTTP 容器线程池,甚至可能是跨应用程序)产生副作用。使用无线程相关实现的主要优势是它通常更快,即使它没有像基于线程的实现那样隔离执行。在这里,再次确保对应用程序进行基准测试,以了解哪种实现最适合你的情况。
超时
确保对性能的控制并确保性能有界(你的应用程序不会变得非常慢)的最后一个非常重要的标准与超时有关。
即使你并不总是看到它们,应用程序到处都有超时:
-
HTTP 连接器,或者更一般地说,任何网络连接器都有超时设置,以强制释放长时间连接的客户端。
-
数据库通常也有超时。这可能是一个客户端(网络)超时或服务器端设置。例如,MySQL 默认情况下会切断任何持续超过 8 小时的联系。
-
线程池可以处理执行时间过长的超时。
-
JAX-RS 客户端支持供应商特定的超时配置,以避免稍后阻塞网络。
配置超时可以确保如果系统中的某些事情开始出错,包括远程系统运行缓慢或无响应,你将能够以正确(或者至少是有界的)持续时间做出响应。当然,在考虑你使用它们是同步而不是并发的情况下,你将累积所有超时,但至少,你将知道请求在你的系统中可以持续的最大持续时间。
无超时代码的超时
为那些未设计为处理超时的方法添加超时的一个技巧是使用线程池。线程池允许你执行任务并在一定时间内等待它们。当然,这意味着你将阻塞调用线程,但你将阻塞它一段时间:
return threadPool.submit(() -> {
// execute your logic
}).get(10, TimeUnit.SECONDS);
此代码将计算某个值或如果持续超过 10 秒则抛出TimeoutException。将任何代码包裹在这样的代码块中将允许你为任何类型的代码处理超时。然而,这并不意味着包裹的代码在 10 秒后结束;它只是意味着调用者不再等待它。然而,任务仍然被提交,并且可以无限期地占用一个线程。为了防止你需要保持对Future的引用,submit方法将返回并取消任务,允许你中断执行线程:
Future<?> task = null;
try {
task = threadPool.submit(() -> {
// some logic
});
task.get(10, TimeUnit.SECONDS);
} catch (final TimeoutException te) {
if (task != null) {
task.cancel(true);
}
}
现在,如果我们遇到超时,我们可以取消正在运行的任务,以避免任务泄露,并且即使我们有超时,也可能填满线程池。
如果你想在超时时处理一些故障转移,你可以在捕获TimeoutException的代码块中添加它。
摘要
在本章中,我们看到了正确处理应用程序中的故障是确保你可以继续控制系统的响应时间并在任何情况下保持其控制的关键。这对于任何系统都是正确的,但随着微服务的普及,对于使用这种架构的系统来说更是如此。
好吧,定义如何部署你的 Java EE 应用程序以及确保你控制你系统的所有潜在故障是一项全职工作;我们常常忘记工作在应用程序的主要代码路径上,但这样做可以确保生产部署和行为是合理的。
本章向您展示了处理容错的一些常见模式,这些模式在某种程度上是透明和可靠的。
记录日志是系统出现问题时另一个重要的因素。它将使你能够调查发生了什么,并确定需要修复的问题。然而,过度记录日志或缺乏反思可能会非常昂贵。这正是我们下一章将要讨论的内容:确保你正确地利用日志来考虑性能因素。
第八章:日志记录器和性能——一种权衡
日志可能是应用程序最重要的部分之一,无论它使用什么技术。为什么是这样?因为没有日志,你不知道应用程序在做什么,也不知道为什么应用程序以某种特定方式表现。
当然,我们在第三章,“监控您的应用程序”,中看到了如何对应用程序进行配置以获取有关 JVM 和应用程序的一些监控信息,但这非常技术性,主要是性能或跟踪导向的。这很重要,但通常不足以帮助操作和支持团队,他们通常更喜欢从更高层次查看应用程序跟踪。这就是日志记录介入的地方。然而,正确使用和配置它是很重要的,这样你就不影响应用程序的性能。
你可能认为 Java EE 和日志记录没有直接关系,但实际上,确保你理解在 EE 环境中日志记录正在做什么可能更为重要。从 EE 容器中获得的主要东西是服务。服务是代码,因此具有与你的代码相同的约束。你可以使用任何 Java EE 库,大多数服务,如果不是所有服务,都会使用一些日志记录器来让你了解发生了什么,而无需查看代码。甚至有些供应商的编码实践要求你在每次方法开始和结束时都进行日志记录。简而言之,日志记录器和日志语句无处不在,无论你是否在自己的代码库中编写了它们,因为它们存在于库代码库中。
因此,在本章中,我们将涵盖:
-
何时使用日志记录
-
日志语句所暗示的工作
-
一些常见且有用的与性能相关的日志记录模式
-
一些知名的日志记录库以及如何在 EE 环境中配置它们
我记录,你记录,他们记录
我们何时使用日志记录?这个非常技术性的问题应该这样重新表述:我们何时提供有关应用程序正在做什么的信息?这正是日志记录所用的;它让用户知道应用程序在某个时刻做了什么。
这是我们作为开发者经常忘记的事情,因为我们专注于我们正在实现的功能。但如果应用程序打算投入生产,确保操作团队能够非常高效地与之合作是至关重要的。不要忘记,如果开发需要六个月,那么生产可能持续几年,而事故的成本远远高于生产启动前的微小延迟。因此,投资于一个能够传达足够信息的系统通常是值得的。
然而,记录日志并不是一个可以轻视的任务。所有的困难都关于:
-
设计对代码知识贫乏的人——或者没有——有意义的消息
-
确保无论发生什么情况,消息都被记录下来
-
记录默认情况下在代码中可能不可见的错误情况
让我们花一点时间来处理这个最后的情况,因为它在 EE 应用中越来越常见,尤其是在微服务环境中——一个 REST 端点中的 HTTP 客户端:
@Path("quote")
@ApplicationScoped
public class QuoteEndpoint {
@Inject
private Client client;
@GET
@Path("{id}")
public Quote getQuote(@PathParam("id") final String id) {
return client.target("http://remote.provider.com")
.path("quote/{id}")
.resolveTemplate("id", id)
.request(APPLICATION_JSON_TYPE)
.get(Quote.class);
}
}
这是在微服务基础设施中非常常见的模式,一个服务调用另一个服务。这个特定的实现是一个扁平代理(没有额外的逻辑),但它可以用来隐藏一些机器到机器的凭证或安全机制,例如。在这里需要确定的是,一个 JAX-RS 客户端在 JAX-RS 端点(getQuote)中被调用。现在,当你考虑错误处理时,这样的代码可能会发生什么?
如果客户端调用失败是因为服务器返回错误(让我们假设 HTTP 404 是因为 ID 无效),那么客户端get()调用将抛出javax.ws.rs.NotFoundException。由于客户端调用周围没有异常处理,你的端点将抛出相同的异常,这意味着服务器端的 JAX-RS 将是一个 HTTP 404,因此,你的端点将抛出相同的异常。
这可能正是你想要的——例如,在代理的情况下——但在实现方面并不很好,因为当你从客户端(最终客户端,而不是端点客户端)收到 HTTP 404 响应时,你如何知道是端点还是远程服务出了问题?
通过稍微改变端点的实现来减轻这种副作用的方法,如下面的代码片段所示:
@GET
@Path("{id}")
public Quote getQuote(@PathParam("id") final String id) {
try {
return client.target("http://remote.provider.com")
.path("quote/{id}")
.resolveTemplate("id", id)
.request(APPLICATION_JSON_TYPE)
.get(Quote.class);
} catch (final ClientErrorException ce) {
logger.severe(ce.getMessage());
throw ce;
}
}
这不是一个完美的实现,但至少现在,在服务器日志中,你将能够识别出调用失败是由于远程服务上的错误。这已经比沉默好多了。在现实生活中,你可以通知最终客户端错误不是你的责任,或者使用另一个状态码或添加一个头信息,允许客户端根据你实现的服务类型来识别它。然而,不变的是,至少记录错误允许你的应用程序提供足够的信息,以便你调查问题的来源。然后,你可以在其基础上进行的所有增强(日志格式、MDC 等)主要是为了让信息更容易找到和快速分析。
记录日志是一种简单的方式——可能也是最简单的方式——从 JVM 向其外部进行通信。这也可能是为什么它在任何应用的各个层都得到了广泛使用。你可以确信,大多数(如果不是所有)库和容器都依赖于某个日志记录器。通常,这也是你与容器的第一次接触。当你以空状态启动它时,你首先看到的是以下输出:
[2017-10-26T17:48:34.737+0200] [glassfish 5.0] [INFO] [NCLS-LOGGING-00009] [javax.enterprise.logging] [tid: _ThreadID=16 _ThreadName=RunLevelControllerThread-1509032914633] [timeMillis: 1509032914737] [levelValue: 800] [[
Running GlassFish Version: GlassFish Server Open Source Edition 5.0 (build 23)]]
[2017-10-26T17:48:34.739+0200] [glassfish 5.0] [INFO] [NCLS-LOGGING-00010] [javax.enterprise.logging] [tid: _ThreadID=16 _ThreadName=RunLevelControllerThread-1509032914633] [timeMillis: 1509032914739] [levelValue: 800] [[
Server log file is using Formatter class: com.sun.enterprise.server.logging.ODLLogFormatter]]
[2017-10-26T17:48:35.318+0200] [glassfish 5.0] [INFO] [NCLS-SECURITY-01115] [javax.enterprise.system.core.security] [tid: _ThreadID=15 _ThreadName=RunLevelControllerThread-1509032914631] [timeMillis: 1509032915318] [levelValue: 800] [[
Realm [admin-realm] of classtype [com.sun.enterprise.security.auth.realm.file.FileRealm] successfully created.]]
这里真正重要的是不是输出的内容,而是你能够从日志配置中控制它。Java EE 容器在实现上并不统一,但大多数都依赖于Java Util Logging(JUL),这是 Java 标准日志解决方案。我们稍后会回到日志实现。但为了继续给你一个为什么不是直接通过控制台输出或文件管理来做的概念,我们将打开 GlassFish 配置。
GlassFish,依赖于 JUL,使用logging.properties配置文件。如果你使用默认的 GlassFish 域,你将在glassfish/domains/domain1/config/logging.properties文件中找到它。如果你打开这个文件,你会看到以下这些行:
handlers=java.util.logging.ConsoleHandler
handlerServices=com.sun.enterprise.server.logging.GFFileHandler
java.util.logging.ConsoleHandler.formatter=com.sun.enterprise.server.logging.UniformLogFormatter
com.sun.enterprise.server.logging.GFFileHandler.formatter=com.sun.enterprise.server.logging.ODLLogFormatter
com.sun.enterprise.server.logging.GFFileHandler.file=${com.sun.aas.instanceRoot}/logs/server.log
com.sun.enterprise.server.logging.GFFileHandler.rotationTimelimitInMinutes=0
com.sun.enterprise.server.logging.GFFileHandler.flushFrequency=1
java.util.logging.FileHandler.limit=50000
com.sun.enterprise.server.logging.GFFileHandler.logtoConsole=false
com.sun.enterprise.server.logging.GFFileHandler.rotationLimitInBytes=2000000
com.sun.enterprise.server.logging.GFFileHandler.excludeFields=
com.sun.enterprise.server.logging.GFFileHandler.multiLineMode=true
com.sun.enterprise.server.logging.SyslogHandler.useSystemLogging=false
java.util.logging.FileHandler.count=1
com.sun.enterprise.server.logging.GFFileHandler.retainErrorsStasticsForHours=0
log4j.logger.org.hibernate.validator.util.Version=warn
com.sun.enterprise.server.logging.GFFileHandler.maxHistoryFiles=0
com.sun.enterprise.server.logging.GFFileHandler.rotationOnDateChange=false
java.util.logging.FileHandler.pattern=%h/java%u.log
java.util.logging.FileHandler.formatter=java.util.logging.XMLFormatter
#All log level details
javax.org.glassfish.persistence.level=INFO
javax.mail.level=INFO
org.eclipse.persistence.session.level=INFO
我们不会在这里进入 JUL 的配置方式,但我们可以从这个片段中识别出,日志抽象允许我们:
-
配置日志(消息)的去向。我们可以看到
GFFileHandler指向server.log文件,例如,但ConsoleHandler也被设置了,这与我们在控制台中看到日志的事实是一致的。 -
配置日志级别,我们稍后会详细说明这一点;在非常高的层面上,它允许你选择你想要保留或不要的日志。
如果实现没有使用日志抽象,你就没有选择输出的(处理程序)和级别选择将是针对每个案例的(不是标准化的),这将使操作团队的工作变得更加困难。
日志框架和概念
有很多日志框架,这可能是一个集成者面临的挑战,因为当你集成的库越多,你就越需要确保日志记录器是一致的,并且可能需要将它们输出到同一个地方。然而,它们都共享相同的基本概念,这些概念对于了解如何正确使用日志记录器以及如果不注意它们的用法,它们如何以不好的方式影响应用程序性能是非常重要的。
这些概念在不同的框架中可能有不同的名称,但为了识别它们,我们将在这本书中使用 JUL 的名称:
-
日志记录器
-
日志记录器工厂
-
LogRecord
-
处理程序
-
过滤器
-
格式化器
-
级别
日志记录器
日志记录器是日志框架的入口点。这是你用来写入消息的实例。API 通常有一组辅助方法,但必需的 API 元素包括:
-
允许将级别与消息一起传递。
-
允许传递一个预先计算的消息,该消息是计算所需消息的(它可以是一个带有一些变量的模式或 Java 8 的
Supplier<>)。在后一种情况下,目标是如果不需要就不评估/插值消息,如果消息是隐藏的,就避免支付这种计算的代价。 -
允许将
Exception(主要针对错误情况)与消息关联。
日志记录器使用的最常见例子可能是:
logger.info("Something happent {0}", whatHappent);
这个日志调用与以下行等价,但避免了如果不需要就进行连接:
logger.info("Something happent" + whatHappent);
然后,它还会触发将消息(String)发送到最终输出(控制台、文件等)。这可能看起来并不重要,但想想在第二章中看到的多个和复杂的层中,你可以在单个请求中调用多少个日志记录器。避免所有这些小操作可能很重要,尤其是在一般情况下,你不仅仅是一个简单的连接,而是在复杂对象上有多个连接。
日志工厂
日志工厂通常是一个实用方法(静态),为你提供一个日志记录器实例。
大多数情况下,它看起来像这样:
final Logger logger = LoggerFactory.getLogger(...);
// or
final Logger logger = Logger.getLogger(...);
根据日志框架,日志工厂要么是一个特定的类,要么是Logger类本身。但在所有情况下,它都为你提供了一个Logger实例。这个工厂方法的参数可能会变化,但通常是一个String(许多库允许Class快捷方式),可以用来配置日志级别,就像我们在前面的 JUL 配置文件中看到的那样。
为什么日志框架需要一个工厂,为什么不允许你使用普通的new来实例化日志记录器?因为日志记录器实例的解析方式可能取决于环境。不要忘记,大多数日志消费者(使用日志的代码)可以部署在许多环境中,例如:
-
一个具有平坦类路径的独立应用程序
-
一个具有分层类加载器(树)的 JavaEE 容器
-
一个具有图类加载的 OSGI 容器
在所有日志框架中,总是可以配置配置解析的方式,从而确定日志记录器的实例化方式。特别地,一旦你有一个容器,你将想要处理全局容器配置和每个应用程序配置,以便使一个应用程序配置比默认配置更具体。为此,容器(或当实现足够通用时为日志框架)将实现自定义配置解析,并让日志框架使用此配置实例化一个日志记录器。
通常,在一个 EE 容器中,你将得到每个应用程序不同的日志配置。如果应用程序没有提供任何配置,则将使用容器配置。以 Apache Tomcat 实现为例,它默认会读取conf/logging.properties,而对于每个应用程序,如果该文件存在,它将尝试读取WEB-INF/logging.properties。
LogRecord
LogRecord是日志消息结构。它封装了比你传递的String消息更多的数据,允许你获取我们经常在日志消息中看到的信息,例如:
-
日志级别
-
可选的,一个日志序列号
-
源类名
-
源方法名
-
消息确实
-
线程(通常通过其标识符而不是其名称来识别,但最后一个并不总是唯一的)
-
日志调用日期(通常自 1970 年以来以毫秒为单位)
-
可选地,与日志调用关联的异常
-
日志记录器名称
-
可选地,如果日志记录器支持国际化,则可以是一个资源包
-
可选地,一个调用上下文,例如基于自定义值(MDC)或当前 HTTP 请求的一组上下文数据
在这个列表中,我们找到我们看到的信息(如消息),但还包括与日志调用关联的所有元数据,例如调用者(类和方法)、调用上下文(例如日期和线程),等等。
因此,正如我们将看到的,是这条*记录*被传递到日志链中。
处理程序
有时称为Appender,处理程序是输出实现。它们是接收上一部分的LogRecord并对其进行某种操作的那些。
最常见的实现包括:
-
FileHandler:将消息输出到文件中。 -
一个
MailHandler:通过邮件发送消息。这是一个特定的处理程序,不应用于大量消息,但它可以与一个*特定*的日志记录器一起使用,该日志记录器专门用于在特定情况下发送一些消息。 -
ConsoleHandler用于将消息输出到控制台。 -
还有更多,例如
JMSHandler、ElasticsearchHandler、HTTPHandler等。
在任何情况下,处理程序都将数据发送到*backend*,它可以是一切,并且日志框架始终确保您可以在需要扩展默认处理程序时插入自己的实现。
过滤器
一个Filter是一个简单的类,允许您让LogRecord通过或不通过。在 Java 8 生态系统中,它可以被视为一个Predicate<LogRecord>;这个类自从 Java 1.4 版本以来就在 Java 中,远在Predicate被创建之前。
它通常与特定的Handler绑定。
格式化器
一个Formatter是一个接受LogRecord并将其转换为String的类。它负责准备要发送到后端的内容。想法是将*写作*和*格式化*关注点分开,允许您重用一部分而无需创建一个新的Handler。
同样,它通常与特定的Handler绑定。
级别
级别是一个简单的概念。它是日志记录的元数据,也是我们刚刚查看的大多数日志组件。主要思想是能够将日志记录级别与记录通过的组件级别进行比较,如果不兼容则跳过消息。常见的(排序)日志级别包括:
-
OFF:不直接用于日志记录,但通常仅由其他组件使用,它禁用任何日志消息。 -
SEVERE(或ERROR):最高的日志级别。它旨在在发生严重问题时使用。如果组件级别不是OFF,则记录条目将被记录。 -
WARNING:通常在发生错误时使用(但不会阻止应用程序工作);如果组件级别不是OFF或SEVERE,则记录条目将被记录。 -
INFO:许多应用程序的默认日志级别,用于通知我们发生了正常但有趣的事情。 -
CONFIG:不是最常用的级别,它旨在用于与配置相关的消息。在实践中,应用程序和库通常使用INFO或FINE。 -
FINE,FINER,FINEST,DEBUG:这些级别旨在提供关于应用程序的低粒度信息。消息计算可能很昂贵,通常不打算在生产环境中启用。然而,当调查问题时,它可能是一个非常有用的信息。 -
ALL:不用于日志记录本身,但仅用于组件级别,它允许记录任何消息。
日志级别按顺序排序(与整数相关联),如果所有组件级别都低于日志记录级别,则日志记录级别是活动的。例如,如果组件级别是INFO或FINE,则将记录WARNING消息。
日志调用
在查看如何使用一些常见模式将记录器正确集成到您的应用程序之前,让我们看看记录器调用将触发什么。
一个简单的记录器调用,如logger.info(message),可以检查以表示为以下步骤的等价:
-
检查记录器级别是否处于活动状态;如果不是,则退出
-
创建日志记录(设置消息和级别,初始化日志源、类、方法等)
-
检查消息是否被
Filter过滤;如果过滤器过滤了它,则退出 -
对于所有处理器,将日志记录发布到处理器:
-
检查处理器级别与日志记录级别的匹配情况,如果不兼容则退出(注意:此检查通常执行多次;因为它只是一个整数比较,所以很快)
-
格式化日志记录(将其转换为
String) -
将格式化后的消息写入实际的后端(文件、控制台等)
-
这种对单个记录器调用的逐层深入分析显示了关于记录器的许多有趣之处。首先,使用记录器并在所有日志组件中设置级别,允许日志框架在级别不兼容的情况下绕过大量逻辑。这在记录器级别是正确的,可能在过滤器级别,最终在处理器级别。然后,我们可以确定有两个层次,逻辑依赖于配置,处理时间将是这些元素复杂性的函数,包括过滤和格式化。最后,实际工作——通常链中最慢的部分——是与后端交互。具体来说,与硬件(你的硬盘)交互时,在文件中写入一行比链中的其他部分要慢。
过滤器
在 JUL 中,没有默认的过滤器实现。但您可以在其他框架或 JUL 扩展中找到一些常见的过滤器:
-
基于时间的过滤器:
-
如果日志消息不在时间范围内,则跳过它
-
如果日志消息的年龄超过一些配置的持续时间,则跳过它(这取决于链中过滤器之前的工作以及机器是否有热点峰值)
-
-
基于映射诊断上下文(MDC)的过滤器:通常,如果 MDC 值匹配(例如,如果 MDC['tenant']是hidden_customer),则跳过该消息
-
节流:如果你知道你使用的手柄无法支持每秒超过 1,000 条消息,或者如果实际的后端,例如数据库,无法支持更多,那么你可以使用过滤器来强制执行这个限制
-
基于正则表达式的:如果消息不匹配(或匹配)正则表达式,则跳过
这些例子只是你可能会遇到的潜在过滤器的一个简短列表,但它说明了复杂性可能重要或不太重要,因此过滤器层的执行时间可能快或慢。
格式化器
对于过滤器,有几个格式化器,并且由于它实际上关于如何将日志记录——日志 API——转换为后端表示(字符串),所以它可能更昂贵或更便宜。为了获得一个高级的概念,这里有一些例子:
-
XML:将日志记录转换为 XML 表示形式;它通常使用字符串连接并记录所有记录信息(记录器、线程、消息、类等)。
-
简单:只是日志级别和消息。
-
模式:基于配置的模式,输出被计算。通常,日志框架允许你在该模式中包含线程标识符或名称、消息、日志级别、类、方法、如果有异常则包括异常,等等。
默认的 JUL 模式是%1$tb %1$td, %1$tY %1$tl:%1$tM:%1$tS %1$Tp %2$s%n%4$s: %5$s%6$s%n。这个模式导致这种类型的输出:
oct. 27, 2017 6:58:16 PM com.github.rmannibucau.quote.manager.Tmp main
INFOS: book message
我们在这里不会详细说明语法,但它重用了SimpleFormatter背后的 Java java.util.Formatter语法,该语法用于输出。此实现将以下参数传递给格式化器:
-
日志事件的日期
-
日志事件的来源(
类方法) -
记录器名称
-
级别
-
消息
-
异常
关于这种最后一种格式化器的有趣之处在于,它允许你自定义输出并根据自己的需求更改其格式。例如,你可以在两行上使用默认格式,但你也可以设置格式为%1$tb %1$td, %1$tY %1$tl:%1$tM:%1$tS %1$Tp %2$s %4$s: %5$s%6$s%n,然后输出将会是:
oct. 27, 2017 7:02:42 PM com.github.rmannibucau.quote.manager.Tmp main INFOS: book message
最大的优势是真正根据你的需求自定义输出,并可能匹配像 Splunk 或 Logstash 这样的日志转发器。
要在 JUL 中激活此模式,你需要设置系统属性"-Djava.util.logging.SimpleFormatter.format=<the pattern>"。
使用处理器
在处理性能问题时,定义你希望在日志方面做出的权衡是很重要的。一个有趣的指标可以是将没有任何活动日志语句的应用程序与你想保留的(用于生产)进行比较。如果你发现启用日志后性能急剧下降,那么你可能有一个配置问题,要么是在日志层中,要么更常见的是在与后端的使用(如过度使用远程数据库)有关。
处理器,作为退出应用程序的部分,是需要最多关注的。这并不意味着其他层不重要,但它们通常检查起来更快,因为它们往往会导致恒定的评估时间。
处理器的实现有几种,但在公司中拥有特定实现并不罕见,因为你可能想要针对特定的后端或进行定制集成。在这些情况下,你必须确保它不会引入一些瓶颈或性能问题。为了说明这一点,我们将使用一个例子,其中你想要以 JSON 格式将日志记录发送到 HTTP 服务器。对于每条消息,如果你发送一个请求,那么你可以并行发送多个请求作为线程,并且你将为每个日志调用支付 HTTP 延迟。当你想到一个方法可以有多个日志调用,并且一个日志可以有多个处理器(你可以在控制台、文件和服务器中记录相同的消息),那么你很快就会理解这种按消息同步 首次 实现不会长期扩展。
正是因为所有后端集成,它们都暗示着远程操作,所以都在使用替代实现,并且通常支持将消息批量发送(一次发送多个消息)。然后,处理器的消息接收只是触发在 栈 中的添加,稍后,另一个条件将触发实际请求(在我们之前的例子中是 HTTP 请求)。在性能方面,我们将高延迟实现转换为低延迟操作,因为操作的速度就像将对象添加到队列中一样快。
日志组件和 Java EE
使用 Java EE 来实现日志组件可能会有诱惑力。虽然并非不可能,但在这样做之前,有一些要点需要考虑:
-
JUL 并不总是支持使用容器或应用程序类加载器加载类,因此你可能需要一个上下文加载容器或应用程序类的门面实现。换句话说,你并不总是能够程序化地依赖于 CDI,但你可能需要一些反射,而这会带来你想要最小化的成本。所以,如果你能的话,确保保留你的 CDI 查找结果。
-
在前几章中,我们探讨了 Java EE 层。确保你不会依赖于对日志实现来说过于沉重的某些东西,以避免受到所有这些工作的影响,并避免通过你的记录器隐藏你有一个应用程序下的应用程序的事实。
-
日志上下文不受控制。通常,你不知道何时使用记录器。因此,如果你使用 CDI 实现一个组件,确保你使用所有上下文都有的功能。具体来说,如果你没有使用
RequestContextController来激活作用域,就不要使用@RequestScoped。此外,确保你只在一个用于 EE 上下文的记录器上配置了EE组件。
做一个日志-EE 桥接不是不可能的,但对于日志记录,我们通常希望非常高效且尽可能原始。更确切地说,它更像是一个潜在的回退方案,如果你不能修改应用程序的话,而不是默认的相反方案。从现实的角度来看,最好发送你观察到的 EE 事件,并从观察者那里调用记录器,而不是相反。
日志模式
有一些重要的日志模式你可以利用来尝试最小化隐含的日志开销,而不会从功能角度获得任何好处。让我们来看看最常见的几个。
测试你的级别
关于日志消息最重要的东西是其级别。这是允许你高效忽略消息——以及它们的格式化/模板化——的信息,如果它们最终会被忽略的话。
例如,考虑这个依赖于不同级别日志记录器的这个方法:
public void save(long id, Quote quote) {
logger.finest("Value: " + quote);
String hexValue = converter.toHexString(id);
doSave(id, quote);
logger.info("Saved: " + hexValue);
logger.finest("Id: " + hexValue + ", quote=" + quote);
}
这种方法混合了调试和信息日志消息。尽管如此,在大多数情况下,调试消息不太可能被激活,但如果你将信息级别作为可选消息使用,那么对警告级别进行相同的推理。因此,在大多数情况下记录调试消息是没有意义的。同样,计算这些消息的连接也是无用的,因为它们不会被记录。
为了避免这些计算——别忘了在某些情况下toString()可能很复杂,或者至少计算起来long——一个常见的模式是自己在应用程序中测试日志级别,而不是等待日志框架去完成:
public void save(long id, Quote quote) {
if (logger.isLoggable(Level.FINEST)) {
logger.finest("Value: " + quote);
}
String hexValue = converter.toHexString(id);
doSave(id, quote);
logger.info("Saved: " + hexValue);
if (logger.isLoggable(Level.FINEST)) {
logger.finest("Id: " + hexValue + ", quote=" + quote);
}
}
简单地将很少记录的消息包裹在isLoggable()条件块中,将对日志记录器级别进行快速测试,并绕过消息计算,以及大多数情况下绕过所有日志链,确保性能不会因调试语句而受到太大影响。
自 Java 8 以来,有一个有趣的替代模式:使用Supplier<>来创建消息。它提供了一种计算消息而不是消息本身的方法。这样,由于 lambda 与相关签名兼容,代码更加紧凑。但无论如何,字符串评估的成本并没有支付:
logger.finest(() -> "Value: " + quote);
在这里,我们传递了一个 lambda 表达式,仅在日志语句通过了第一个测试(即日志记录器的日志级别)时才计算实际的消息。这确实非常接近之前的模式,但仍然比通常的isLoggable()实现中的整数测试要昂贵一些。然而,开销并不大,代码也更简洁,但通常足够高效。
如果你多次使用相同的日志级别,在方法开始时一次性将日志级别检查因式分解可能是有价值的,而不是在相同的方法中多次调用它。你使用日志抽象越多,因此经过的层级越多,这一点就越正确。由于这是一个非常简单的优化——你的 IDE 甚至可以建议你这样做——你不应该犹豫去实施它。尽管如此,不要在类级别(例如,将可记录的测试存储在@PostConstruct中)这样做,因为大多数日志实现都支持动态级别;你可能会破坏该功能)。
在你的消息中使用模板
在上一节中,我们探讨了如何绕过消息的日志记录。在 JUL 中,这通常是通过调用具有日志级别的日志方法来完成的,但有一个更通用的日志方法称为log,它可以接受级别、消息(如果你对消息进行国际化,则是一个资源包中的键)以及一个对象数组参数。此类方法存在于所有框架中,其中大多数也会提供一些特定的签名,具有一个、两个或更多参数,以使其使用更加流畅。
在任何情况下,想法是使用消息作为模式,并使用参数来赋值消息中的某些变量。这正是此日志模式使用的功能:
logger.log(Level.INFO, "id={0}, quote={1}", new Object[]{ id, quote });
此日志语句将用Object[]数组中的第 i 个值替换{i}模板。
使用此模式很有趣,因为它避免了在不需要时计算实际的字符串值。从代码影响的角度来看,这似乎比之前的isLoggable()检查要好,对吧?实际上,这取决于日志实现。JUL 不支持它。但是,对于支持参数而不使用数组的框架,它们可以进行一些优化,从而使这个假设成立。然而,对于 JUL 或所有需要创建数组以存储足够参数的情况,这是不正确的。你必须创建数组的事实具有影响,因此如果你不需要它,最好是跳过它,这意味着回退到之前的模式或基于Supplier<>的 API。
异步或非异步?
由于现代对扩展性的要求,需要增强日志记录器以支持更高的消息速率,但仍然需要减少对应用程序性能本身的影响。
减少日志记录延迟的第一步是使处理程序异步。在 JUL 中这还不是标准功能,但你可以找到一些提供该功能的库——一些容器,如 Apache TomEE,甚至提供开箱即用的功能。这个想法与我们关于处理程序的章节中描述的完全一样,计算日志记录的最小上下文,然后在调用者线程中的 队列 中推送记录,然后在另一个线程(或线程,取决于后端)中实际 存储/发布 消息。
这种模式已经解决了大多数关于性能的日志记录器影响,但一些日志框架(如 Log4j2)更进一步,使日志记录器本身异步。由于过滤(有时还有格式化)现在是完全异步完成的,因此调用者的持续时间大大缩短,性能影响也大大降低(仍然,考虑到你有足够的 CPU 来处理这个额外的工作,因为你并行执行更多的代码)。
如果你添加一些基于环形缓冲区模式的现代实现来处理异步,就像 Log4j2 使用 disruptor (lmax-exchange.github.io/disruptor/) 库所做的那样,那么你将有一个非常可扩展的解决方案。线程越多,这种实现的冲击力就越显著,甚至与异步处理程序(log4j2 的追加器)相比。
日志实现 – 选择哪一个
日志是你在计算机科学中可以遇到的最早的话题之一,但它也是被多次解决的问题之一。理解你将找到许多关于日志的框架。让我们快速看一下它们,看看它们有时是如何相关的。
日志门面 – 好坏参半的想法
日志门面是像 SLF4J (www.slf4j.org/)、commons-logging、jboss-logging 或更近期的 log4j2 API (logging.apache.org/log4j/2.x/) 这样的框架。它们的目的是提供一个统一的 API,可以与任何类型的日志实现一起使用。你必须真正将其视为一个 API(就像 Java EE 是一个 API 一样),而日志框架则是实现(就像 GlassFish、WildFly 或 TomEE 是 Java EE 实现)。
这些门面需要一种方式来找到它们必须使用的实现。你可能遇到几种策略,如下所示:
-
例如,SLF4J 将查找所有实现在其分发中提供的特定类(在 SLF4J 中称为 绑定),一旦实例化,它将给 SLF4J API 提供与最终实现(JUL、Log4J、Logback 等)的链接。这里的问题是,你无法在同一个类加载器中拥有多个实现,你也不能只配置你想要的那个。
-
Commons-logging 将读取配置文件以确定选择哪个实现。
-
基于全局系统属性配置的硬编码默认值,允许选择要使用的实现。
使用日志外观通常是一个好主意,因为它允许你的代码,或者你使用的库的代码,与日志实现解耦,将实现的选择委托给应用程序打包者或部署者。它允许你在所有情况下运行它,而无需你在开发期间关心它。
即使忽略存在多个此类 API 实现的事实,这已经使事情变得复杂,但根据你所使用的实现,它们的用法并不那么优雅。一些实现将需要填写一些代价高昂的参数。
最好的例子是计算源类和方法。在几个实现中,这将通过创建一个 Exception 来获取其关联的堆栈跟踪,并在丢弃已知的日志框架调用者之后,推断出业务调用者。在 Java EE 环境中,由于它使用的堆栈以提供简单的编程模型,异常堆栈可能非常大且 慢(分配数组)。这意味着每个日志消息都会稍微减慢一点,以实现这个外观 API 和实际使用的日志实现之间的桥梁。
因此,检查你使用的此类外观实现可能是有价值的。对于最常见的一个,SLF4J,有两个非常知名的实现与 API 紧密结合:
-
Logback:API 的本地实现
-
Log4j2:具有 SLF4J 直接实现(绑定)
日志框架
如前所述,你可能会遇到几个日志框架。最著名的是,你可能已经听说过的,包括:
-
Log4j1:历史上事实上的标准,逐渐被 log4j2 取代。
-
Log4j2:可能是今天最先进的实现之一。支持异步日志记录和环形缓冲区集成。
-
Logback:SLF4J 的本地实现。在 log4j2 完成之前,这可能是最佳选择。
-
Java Util Logging:JVM 标准日志 API。虽然不是最先进的 API,但它可以工作,并且不需要任何依赖项,尽管如此,你可能需要一些自定义集成(处理程序)以用于生产。检查你的服务器,它可能已经提供了一些解决方案。
这些都共享我们刚刚讨论过的概念,但可能有一些小的差异。为了让你更快地使用它们,我们将快速浏览这些实现,以定义框架使用的语义,并展示如何配置每个实现。当你进行基准测试时,了解如何配置日志并确保它不会因为配置不当而减慢性能是非常重要的。
Log4j
Logj4 (1.x) 使用 org.apache.log4j 基础包。以下是针对 log4j1 语义调整的我们讨论过的日志概念:
| 概念 | 名称 |
|---|---|
| 记录器 | 记录器 |
| 日志工厂 | 记录器 |
| 处理程序 | 追加器 |
| 过滤器 | 过滤器 |
| 格式化器 | 布局 |
| 级别 | 级别 |
大多数概念与 JUL 相同,但有一些不同的名称。
在使用方面,它与 JUL 相同,除了它使用另一个包:
final Logger logger = Logger.getLogger("name.of.the.logger");
logger.info("message");
与之前的不同之处在于配置。它使用类路径中的log4j.properties或log4j.xml(默认情况下),其外观如下:
log4j.rootLogger=DEBUG, stdout
log4j.appender.stdout=org.apache.log4j.ConsoleAppender
log4j.appender.stdout.layout=org.apache.log4j.PatternLayout
log4j.appender.stdout.layout.ConversionPattern=%-4r [%t] %-5p %c %x - %m%n
log4j.com.packt.quote = WARN
使用此示例配置,根日志记录器(默认)级别为DEBUG,它将使用stdout追加器。追加器使用ConsoleAppender;它将在System.out上记录消息,并使用自定义模式(ConversionPattern)的格式布局。com.packt.quote包的日志级别设置为WARN。因此,使用此名称或此包子名称的日志记录器将仅记录WARN和ERROR消息。
Log4j2
Log4j2 明显受到了 Log4j1 的启发,但被完全重写。它在行为和性能方面仍然有一些差异。以下是 log4j2 的概念映射:
| 概念 | 名称 |
|---|---|
| 日志记录器 | 日志记录器 |
| 日志工厂 | 日志管理器 |
| 处理器 | 追加器 |
| 过滤器 | 过滤器 |
| 格式化器 | 布局 |
| 级别 | 级别 |
配置有一些回退,但默认文件在类路径中查找,并称为log4j2.xml。它使用与 Log4j1 的 XML 版本不同的语法,基于 Log4j2 的新插件系统,以获得更简洁的语法(更少的技术性):
<?xml version="1.0" encoding="UTF-8"?>
<Configuration status="WARN">
<Appenders>
<Console name="stdout" target="SYSTEM_OUT">
<PatternLayout pattern="%d{HH:mm:ss.SSS} [%t] %-5level %logger{36} -
%msg%n"/>
</Console>
</Appenders>
<Loggers>
<Root level="debug">
<AppenderRef ref="stdout"/>
</Root>
</Loggers>
</Configuration>
这与上一节中的配置类似,但它使用这种新的语法,该语法依赖于插件名称(例如,在 log4j2 中,Console是控制台追加器的名称)。尽管如此,我们仍然有相同的结构,其中日志记录器在日志记录器块中定义,具有特定的根日志记录器,并且追加器有自己的块,通过引用/标识符(ref)与日志记录器链接。
Log4j2 具有其他一些不错的功能,如配置的热重载、JMX 扩展等,这些都值得一看。它可以帮助你在基准测试期间更改日志配置而无需重新启动应用程序。
Logback
Logback 是 SLF4J 的原生实现,是一个高级日志实现。以下是它与我们所讨论的概念的映射:
| 概念 | 名称 |
|---|---|
| 日志记录器 | 日志记录器(来自 SLF4J) |
| 日志工厂 | 日志工厂(来自 SLF4J) |
| 处理器 | 追加器 |
| 过滤器 | 过滤器 |
| 格式化器 | 编码器/布局 |
| 级别 | 级别 |
注意,logback 还有将消息链接到byte[]的Encoder概念。
默认情况下,配置依赖于类路径中的logback.xml文件,其外观如下:
<configuration>
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="debug">
<appender-ref ref="STDOUT" />
</root>
</configuration>
这是一种接近之前配置(log4j1,log4j2)的配置,并识别相同类型的配置,除了encoder层包裹pattern。这主要是因为编码器将传递一个byte[]值给追加器,而模式将传递一个String给编码器,这使得实现更容易组合,即使很少使用。
JUL
我们使用 JUL 来命名我们讨论的概念,因此我们不需要为概念创建映射表。然而,了解 JUL 的配置方式很有趣,因为它被广泛应用于许多容器中。
高级的 JUL 使用一个LogManager,这是记录器工厂(隐藏在Logger.getLogger(...)记录器工厂后面)。
LogManager是从传递给java.util.logging.config.class系统属性的类实例化的。如果没有设置,将使用默认实现,但请注意,大多数 EE 容器会覆盖它以支持额外的功能,例如每个应用程序的配置,例如,或自定义配置,通常是动态的,并通过一个很好的 UI 进行管理。
配置位于系统属性java.util.logging.config.file中,或者回退到${java.jre.home}/lib/logging.properties——请注意,Java 9 使用了conf文件夹而不是lib。此属性文件的语法与我们之前看到的相同:
handlers= java.util.logging.FileHandler, java.util.logging.ConsoleHandler
java.util.logging.FileHandler.pattern = %h/java%u.log
java.util.logging.FileHandler.limit = 50000
java.util.logging.FileHandler.count = 1
java.util.logging.FileHandler.formatter = java.util.logging.XMLFormatter
java.util.logging.ConsoleHandler.level = INFO
java.util.logging.ConsoleHandler.formatter = java.util.logging.SimpleFormatter
.level = INFO
com.packt.level = SEVERE
在代码片段的末尾,我们确定了如何为包设置级别(键只是后缀为.level,值是级别名称)。同样的逻辑也适用于默认的记录器,它有一个空的名字,因此其级别是通过.level键直接设置的。handlers键获取要配置的处理程序列表(以逗号分隔)。它通常是处理程序的完全限定名。然后,中间的两个块,从处理程序名称开始,是处理程序配置。它通常使用点分表示法(<handler class>.<configuration> = <value>),但这是可选的,因为处理程序可以通过LogManager访问所有属性。
Apache Tomcat/TomEE 的ClassLoaderLogManager允许你使用一个以数字开头的自定义前缀值来前缀处理程序。它使你能够定义 N 次相同的处理程序,具有不同的配置,这是 JUL 默认不支持的功能,JUL 只能定义一个处理程序一次。
选择实现
如果你可以选择你的实现,你应该检查你想要发送日志的地方,并选择已经做了你所需要的工作的实现,或者具有非常接近功能的实现。然后,如果有多个实现符合你的要求,你需要检查哪一个是最快的。
从 log4j2 或 logback 开始通常是不错的选择。然而,在实践中,您很少有这样的选择,您的一部分堆栈是由您的环境(依赖项和容器)强加的。因此,您很可能还需要配置 JUL。在这种情况下,一个好的选择是检查您是否可以重用您的容器骨干,而无需在代码上依赖于您的容器(也就是说,您可以使用 JUL API 并委托给容器,例如 JUL 配置)。然后,您需要评估 JUL 是否满足您在性能方面的运行时需求。JUL 在互联网上收到了很多负面评价,但它对于许多异步将日志记录到文件的应用程序来说已经足够了。在没有评估您具体需求的情况下,不要忽视它。它可以在很多情况下避免配置头痛和依赖。
另一个标准可以是将所有日志重定向到同一日志堆栈的简便性。在这方面最好的之一是 log4j2,它几乎支持所有集成(SLF4J,commons-logging,log4j1,log4j2,等等)。
如果您使用基于文件的处理器/追加器,这可能是最常见的情况,您还应该查看轮换策略。这通常是可配置的,最常见的方法包括:
-
每日:每天,处理器都会为新的日志文件创建一个新的日志文件(例如,mylog.2017-11-14.log,mylog.2017-11-15.log,等等)。
-
在重启时:每次服务器重启时,都会创建一个新的日志文件。请注意,这种策略对于批处理实例或没有长时间运行的实例除外。
-
按大小:如果日志文件大小超过某些磁盘空间,则创建一个新的文件。
注意,所有这些策略通常可以混合或累积。只有 JUL 不支持这一点,但容器通常会尝试填补这一空白。并不是因为您的容器使用 JUL,您就没有这个功能。在拒绝 JUL 作为 API 之前,不要犹豫去查看您的容器日志配置并对其进行调查。
摘要
在本章中,我们探讨了为什么日志记录对于获得良好且易于监控的级别很重要。我们还看到,日志语句必须尽可能减少对性能的影响,以免抵消您在应用程序的其他部分可能已经进行的优化和编码。
本章为您提供了一些常见且简单的模式,可以帮助您尽可能依赖日志框架,以确保您保持良好的性能。
最后,我们注意到在您的应用程序中可能需要配置多个实现,但它们都将共享相同的概念,并且可以依赖于单个 API,甚至来自多个 API 的单个实现。
到这本书的这一部分,您已经了解了 Java EE 的功能以及如何编码和监控应用程序。现在,是时候看看您应该如何对待基准测试了。这将是下一章的主题。
第九章:基准测试您的应用程序
在前面的章节中,我们看到了如何开发一个 Java EE 应用程序,以确保它可以通过多线程、异步处理、资源池化等方式进行扩展。我们还看到了如何获取应用程序的性能和资源(CPU、内存)使用指标,并通过 JVM 或容器调优以及更激进的技巧,如向应用程序添加缓存来优化性能。
在这个阶段,你应该能够着手处理性能问题。然而,这并不意味着你在进入生产环境时就不会遇到惊喜。主要原因是我们之前讨论的工作很少在足够接近生产或最终部署环境的环境中完成。
为了避免这些惊喜,可以(或应该)组织基准测试,但这并不像把之前学到的所有东西都放在一起那么简单。大多数时候,这需要更多的准备,如果你不想在生产时浪费宝贵的时间,你应该意识到这一点。
在本章中,你将准备一个基准测试,包括以下要点:
-
基准测试是什么
-
准备基准测试
-
基准测试期间的迭代
-
基准测试之后要做什么
基准测试——验证您的应用程序是否符合服务等级协议(SLA)
基准测试通常在您需要遵守服务水平协议(SLA)时发挥作用。SLA 可能更明确或更不明确。通常,您可能对 SLA 有一个非常模糊的定义,例如“应用程序必须提供良好的用户体验”,或者您可能在合同中有非常精确的描述,例如“应用程序必须支持黑色星期五周末和每天 1000 万用户,并且每个用户操作必须在不到一秒内完成”。甚至还有一些标准来描述 SLA,例如Web 服务等级协议(WSLA)来定义如何衡量和公开您的 SLA。
在任何情况下,如果确定了 SLA,尤其是在合同中未达到 SLA 时有补偿的情况下,确保在项目中进行基准测试阶段以增加生产时的性能是非常重要的。
书本的下一章和最后一章将处理对性能的持续评估,并帮助你持续进行评估,避免这种阶段效应。尽管如此,由于基准测试所需的底层基础设施限制,仍然常见有一个专门阶段,因此在本章中我们将考虑这种情况。
到目前为止,你知道你需要验证应用程序的性能,你的项目经理或你自己已经计划了基准测试。但这项任务具体是什么?
基准,基准,基准
在性能方面的工作并不均匀。我们在上一节中看到了这一点;有很多工具在做这件事,每个工具都会提供或多或少的信息,但也会对实际性能产生或多或少的影响。例如,对应用程序的所有代码进行仪器化以获取所有层级的指标会使应用程序非常慢,但报告非常丰富。相反,仅对某些部分——如出站部分——进行仪器化不会对应用程序产生太大影响,但报告只会给你一组非常小的数据。这意味着根据你工作的层级,你将不会使用相同的工具来确保你拥有正确的信息级别。
因此,我们可以区分多种潜在的基准类型:
-
算法基准:你开发了一些代码部分,并希望验证性能是否正确或是否存在瓶颈。
-
层基准:你开发一个层——持久化层、前端层等——并希望在添加另一个层或将其与其他应用程序部分集成之前确保性能是正确的。
-
规模基准:你得到应用程序性能的数字,以确定要使用的机器数量。这与水平扩展直接相关——这不像垂直扩展那样顺畅,因为性能不能是线性的。请注意,这正是大数据框架基于的逻辑,以分发它们的工作。
-
可交付成果基准:这是验证应用程序(交付)和最终应用程序的性能是否符合预期的基准(SLA)。
当然,我们可以将你可以做的基准测试分成更精确的类别,但这些都是你在大多数项目中会遇到的三种。每种基准测试都会使用不同的工具,并且有不同的准备步骤和输出。然而,每一种都会将标准(一个或多个)与预期进行验证。
在之前的基准测试中,我们可以清楚地将标准分为两个非常高级的类别,但这种划分将对你的基准测试驱动方式产生巨大影响:
-
开发基准
-
可交付成果基准
即使基准测试是在交付时进行的,根据定义,这种划分意味着我们确定的两个第一类基准测试是关于验证代码是否正确完成,因此它属于一般性的开发者工作,并且很少从开发本身中分离出来。层基准通常在多个开发迭代中进行;它仍然是一个开发基准,因为它仍然是在内部验证应用程序,而不是通常暴露给最终用户的东西。可交付成果基准是确保最终性能对最终用户(或合同)可接受。因此,它与之前的基准测试类别不同,因为你需要有一个足够完整的可交付成果来进行测试。
在影响方面,你将主要在可交付的基准测试上工作的事实意味着你将无法在自己的机器上完成它。你想要的是验证你的性能与合同,因此你需要在应用安装的机器上验证应用。
在这一点上,重要的是不要混淆用于验证 SLA 的基准测试和用于确定应用所需基础设施规模的基准测试。两者几乎看起来一样,并且以相同的方式组织。但在规模基准测试的情况下,你将定义一个基础设施(机器功率、内存等)并测量性能,然后推断如果你水平扩展需要多少台机器。然而,SLA 基准测试已经假设基础设施是固定的,然后你只需验证性能以符合 SLA。在实践中,两者通常同时进行,这导致了这两种类型基准测试之间的混淆。这主要源于开发者或项目经理对应用所需基础设施有一个想法,因此规模基准的起始基础设施接近目标基础设施,然后游戏就只是验证性能以符合期望。尽管如此,如果你开始规模基准测试,那么你将需要另一个基准测试阶段来验证 SLA,这可以被视为第二个基准测试。永远不要忘记你所在的阶段;否则,你可能会同时改变太多参数,失去对当前应用状态的跟踪(如我们稍后所见,能够比较基准测试迭代至关重要)。
准备你的基准测试
准备基准测试可能是你将要做的最重要的任务。实际上,如果你错过了它,基准测试将注定是失败的,毫无用处。即使任务在总体上并不复杂,它们也不会自行完成。所以请花些时间确保在基准测试开始之前完成它们。
定义基准测试标准
基准测试总是进行以确保我们遇到一个指标。因此,基准准备的第一步是明确地定义这个指标。
定义一个指标意味着明确地定义要测量的内容和如何测量它。
定义指标
定义要测量的内容意味着定义测量的范围。换句话说,当指标开始和结束时。这听起来可能很简单,但不要忘记我们在一个多层环境中工作,而且如果你的监控定义得不好,你可能会错过一些层。
这里有一些例子,基于我们的 quote-manager 应用,如果没有很好地定义指标的界限,可能会导致错误地验证应用:
-
使用 CDI 拦截器测量端点执行持续时间:你错过了 JAX-RS 层
-
使用 JAX-RS 过滤器测量端点执行持续时间:你错过了 servlet 层
-
如果度量标准是请求的处理时间,则使用 servlet 过滤器来测量端点执行持续时间:你会错过容器处理
这些例子都是服务器端错误,但说明了明确度量标准并不像看起来那么简单,因为提到的三种解决方案既简单又很有诱惑力。
有一种情况更糟:客户端。当度量标准是客户端度量标准——对于 SLA 来说通常是这种情况,因为在这种情况下,如果服务器对客户端来说运行得快,我们通常不关心服务器做了什么——那么度量定义就非常重要。客户端情况意味着一些你并不总是能控制的底层基础设施。因此,确保定义做得好将避免歧义和与基准测试的客户或审查者的潜在分歧。以下是一些对同一度量标准的不同解释的例子:
-
客户端执行时间是从一个连接到应用服务器的客户端测量的
-
客户端执行时间是从一个连接到应用服务器前端的负载均衡器的客户端测量的
-
客户端执行时间是从一个连接到 API 网关的客户端测量的,该网关将调用重定向到负载均衡器
-
客户端执行时间是从一个连接到另一个广域网(WAN)中的代理的客户端测量的,该代理将请求路由到 API 网关等等
这些每一行都在前一行的基础上增加了一个基础设施层,因此,为客户端增加了一些延迟。它们都测量客户端执行时间。这就是为什么在开始基准测试应用程序之前,精确地定义基础设施,以及更重要的是度量标准是如何设计的,非常重要。
定义接受标准
一旦你明确了一个度量标准,你需要根据这个度量标准定义标准,这将使基准测试得到验证或被拒绝——你的应用程序是否足够快,以至于可以将其重新表述为高层次?
通常,这是一个可以根据度量标准表示为时间单位或百分比的数字。如果这个度量值低于(或高于)这个数字,那么基准值将被拒绝。
大多数情况下,度量标准不是自足的,需要一些额外的参数才能以可测量的方式定义接受标准。以下是一些常见的例子:
-
对于64 个并发用户,客户端执行时间必须低于 1 秒
-
当每秒接收128 条消息时,客户端延迟必须低于 250 毫秒
-
数据插入数据库的插入率必须高于每秒1,500 条记录,对于两个连接
在这些例子中,粗体表达的是我们建立标准的度量标准,而斜体的是在定义的标准上下文中固定的另一个潜在度量标准(下划线数字)。
当然,可以在同一标准中使用超过两个指标,甚至让它们同时变化。然而,这会导致复杂的接受标准,并且通常总是可以根据使用常数的接受标准来重新表述它们。不要犹豫,从输入中重建一个标准数据库,以确保它们易于验证和测量。这种重新表述的一个非常简单的例子是将 客户端执行时间必须低于 1 秒,对于 1 到 64 个并发用户 改为 客户端执行时间必须低于 1 秒,对于 64 个并发用户。这种改变并不严格等价,你需要验证第一个陈述,但第二个短语更容易处理,特别是如果你需要一些调整。使用这个更简单的版本开始工作,并对你的一些指标进行粗略估计,然后一旦通过,只需验证原始的即可。
一个之前没有提到的标准是 时间。通常,所有标准都是为 无限 持续时间定义的。这意味着你需要确保一旦达到它们,它们将被尊重足够长的时间,以假设在一段时间后不会退化。这是在准备你的工具时需要考虑的事情,因为许多因素可能会降低性能:
-
在插入一定数量的记录后变慢的数据库
-
一个配置不当且开始变得比其配置太大的缓存
-
一个调优不当的内存,等等
所有这些因素并不总是能阻止你在 短时间内 达到你的性能,但它们很可能会在一段时间后降低性能。
这里的想法将是确保你可以将接受标准与某些环境指标相关联,例如内存行为。例如,你可以将你的标准接受与内存使用和/或垃圾收集器配置文件相关联,如下所示:

假设 X 轴是时间,Y 轴是使用的内存,那么这个配置文件显示垃圾回收是规则的(几乎每一条垂直线),并且内存使用是有界和规则的,因为它没有超过代表最大值的红线,即使在经过几个周期之后也是如此。
这种定义很少是自足的,因为它隐含地定义了这种验证发生在应用程序已经达到我们测量的标准,并且 一些时间 已经过去。尽管如此,这比仅仅测量标准而不验证长时间运行的实例的结果是否为真要好。
事实上,最好的情况是能够测试应用程序数天,但这通常成本高昂且不可行。如果你不能这样做,使用这种策略和高级验证通常是一个好的备选方案。
定义场景
定义场景与标准相关联,但消除了恒定约束。这允许你定义更复杂的案例,其中所有指标可以同时变化。
一个常见的例子是将用户(客户端)数量设为一个随时间移动的变量:响应时间将从 5 个用户到 1,000 个用户保持恒定,每 10 秒增加 5 个用户。
一个场景通常非常接近实际应用程序的使用,但如果你没有立即遇到它,它也更难处理,因为你不再在恒定负载下运行应用程序。这就是为什么它们更多地被视为验证检查点而不是工作标准。
定义环境
一旦你知道你想要测试什么,你需要在某个地方设置你的应用程序,以便能够验证它并可能优化它。
这听起来可能很显然,但在这里,你必须对此点非常严格,并在与最终环境相似的环境中基准测试你的应用程序。这意味着什么?相同的机器,相同的网络设置,相同的负载均衡策略,相同的后端/数据库,等等。
设置的任何部分都必须与你的运行位置相匹配。否则,在生产环境中部署时,你可能会遇到一些你本应该提前看到并评估的意外因素。最好的情况是应用程序无法正常工作,这通常是通过部署后进行的某些烟雾测试来识别的。最坏的情况是应用程序可以正常工作,但其可扩展性或性能受到意外因素的影响。我们很少在生产环境中进行性能测试。因此,限制由于环境引起的潜在错误因素非常重要。
机器
在安装任何应用程序之前,你需要一台机器。根据我们刚才所说的,这台机器必须接近最终版本,但在机器的背景下这意味着什么?
机器通常被视为其资源:
-
CPU:应用程序可以使用的计算能力
-
内存:应用程序可以使用的空间
-
磁盘空间:应用程序可以依赖的本地存储
你的第一个选择将是选择与生产机器相同的 CPU/内存/磁盘。然而,在采取这一步骤之前,请确保该机器没有被其他服务(如另一个应用程序)共享,因为这可能会在资源(CPU、内存、磁盘等)方面完全值得 1-1 的选择,因为资源将被其他应用程序消耗。也就是说,如果应用程序与其他软件共享资源,你需要找到一种方法来估计应用程序可用的资源并将它们限制在这个数量,或者隔离这两个应用程序以确保每个应用程序都将有一个定义良好的资源集。
如果你依赖 Docker/Kubernetes 进行部署,这些建议同样适用,但它们不再是在机器级别,而是在pod级别。此外,确保你的 JVM 配置为支持 pod(或容器)设置,这需要一些 JVM 调整来使用 cgroup 配置而不是整个机器设置——Java 默认设置。
网络
现在,网络在部署中是一个非常重要的因素。如果你的应用是自给自足的,那么它并不是非常关键,但这种情况几乎从未发生过。所有应用要么有一个 HTTP 层(通过 UI 或 Web 服务),要么有一个(远程)数据库,或者远程连接到其他应用。在微服务架构中,这一点变得更加关键,因为一些库甚至被设计来更具体地处理这部分工作(包括回退、bulhead 和并发,正如我们在前面的章节中看到的)。
在这个背景下,能够依赖一个好的网络非常重要。与机器选择的精神相同,你必须选择一个与生产网络相当的网络。假设材料几乎相同,这意味着你将选择具有相同吞吐量的网络,但这还不够。
当与网络一起工作时,还有两个其他标准需要迅速考虑,以避免意外:
-
机器/主机之间的距离:如果远程服务远,那么延迟会增加,依赖于这些服务的代码将会变慢。确保你在接近生产环境的条件下进行基准测试——相同的延迟和响应时间——这对于能够依赖你获得的数字非常重要。
-
网络使用情况:如果其他应用大量使用网络,你的新应用可用的带宽将会减少,性能将会很快受到影响。基准测试中的一个常见错误是有一个专门用于基准测试的网络,而在生产中它与其他一些应用共享。确保你在这里有一个一致的设置,将避免在部署期间出现大的惊喜。
数据库和远程服务
如果你的应用使用远程服务,这可能是一个经典的关系型数据库管理系统数据库(RDBMS),一个 NoSQL 数据库,或者另一个应用,确保你在现实条件下进行基准测试非常重要。具体来说,如果我们回到我们的 quote-manager 应用,它使用 RDBMS 数据库,我们不应该使用本地 MySQL 进行测试,如果我们的生产数据库将是 Oracle 实例。我们的想法是尽可能接近现实——生产环境将获得的延迟。
在某些情况下,你(或你的公司)可能拥有其他服务/数据库,并且可以调整它们以提高其可扩展性。但在其他情况下,你使用外部服务,这些服务无法优化,例如在 quote-manager 应用程序中使用 CBOE 和 Yahoo! Finance。在任何情况下,总是更明智地去找其他节点(服务/数据库)的管理员,请求使其更快。意识到在生产环境中因为你的设置与基准测试时不同而导致运行缓慢,这会减慢你的速度并对你产生更大的影响。
这并不意味着模拟外部服务是愚蠢的。在优化你自己的应用程序的阶段,它可能非常有用,因为它可以使外部服务交互尽可能快。然而,你必须确保在执行验证基准测试时移除模拟。
如果你使应用程序能够配置为使用模拟或快速替代系统,不要忘记在启动时写入一条日志消息(INFO 或 WARNING 级别),以确保你可以在以后找到这些信息。它可以节省你很多时间,并避免你因为不确定实际的运行设置而重新运行基准测试。它可以帮助你避免实际运行设置与基准测试设置不一致导致的问题。
在基准测试期间,特别是在调整阶段,你可能会配置你的池(连接池)。因此,确保你可以依赖数据库(或服务)的可扩展性是很重要的。目标是避免在拥有 1,024 个连接的池中成功通过基准测试,然后意识到在生产环境中你只能使用 20 个连接(20 是数据库接受的连接数上限)。
不仅仅是要考虑数据库/服务类型,不仅仅是版本,不仅仅是环境(操作系统、机器),你需要确保数据库的配置是从生产实例复制过来的(或者,如果你处于调整阶段,确保你使用的最终配置可以被复制到生产实例)。
服务器
我们在讨论一个 Java EE 应用程序——但如果谈论到打包,它可以推广到任何应用程序。因此,我们将应用程序部署到服务器中。即使是嵌入式应用程序,在其交付物中也打包(捆绑)了一个服务器。与所有先前的点一样,与目标系统(生产环境)保持一致是很重要的。
具体来说,这意味着如果你的生产环境使用 Apache TomEE 或 GlassFish,你不应该针对 WildFly 服务器进行测试。
如果我们谈论的是开发和打包的方式,服务器离您的应用程序并不远。这意味着它嵌入了几种由服务器供应商选择的库。直接的含义是服务器版本嵌入了几种库版本。由于 Java EE 介于 15 到 30 个规范之间,它至少与打包在一起的库一样重要。因为它是软件,您无法避免版本之间的某些变化——尤其是在新规范早期阶段——因此,您应该尝试确保您不仅使用与生产环境中相同的服务器,而且使用相同的版本。
这个声明应该扩展到您应用程序之外的所有代码。它可以包括您的 JDBC 驱动程序,直接部署在容器中,甚至是一些基础设施/运营团队服务。
一旦您选择了服务器,您需要确保您使用一个与生产环境尽可能接近的设置(配置)。这包括您需要的日志配置(通常,如果您使用日志聚合器,您可能需要一个特定的日志格式)以及部署到其上的资源。如果您从基础设施服务自动部署资源,确保您将它们全部部署以具有相同的线程使用率、网络使用率(如果它涉及远程资源,如 JMS)等等。
最后(并且与机器选择相关),确保设置与生产环境一致。如果您在生产环境中登录到固态硬盘(SSD)磁盘,确保在基准测试期间登录到 SSD。
自动化您的场景
一旦您有了场景,您只需描述它们,并手动执行它们,对于简单的场景,您可以轻松地编写脚本或代码。但大多数时候,您需要自动化它们。自动化它们的优点是可以按需运行(一键点击),因此,测试和重新测试它们很容易,而不需要大量投资。
有几种工具可以自动化场景,它们主要取决于您需要测试的场景。我们将介绍一些您可以使用的主流工具,如果您不知道从哪里开始的话。
JMeter
Apache JMeter (jmeter.apache.org/) 是一个历史悠久的解决方案,用于负载测试应用程序。它支持多种模式,并且完全用 Java 编写,这使得它对大多数 Java 开发者来说易于集成和使用。它支持应用程序使用的主要连接:
-
HTTP/HTTPS,SOAP/REST for JavaEE,NodeJs 等等
-
FTP
-
JDBC
-
LDAP
-
JMS
-
邮件
-
TCP 等等
对您来说最有趣的是,您将能够测试您的 Java EE 应用程序,以及其他后端,从而可以比较(例如数据库和应用程序的性能),以便有可能报告数据库是瓶颈。
它提供了一个很好的用户界面,看起来像这样:

这个界面是为了构建你的测试计划(场景)而设计的;它允许你无需任何配置或对工具的深入了解即可创建它。如果你从命令行启动软件,你甚至会有一个警告消息说不要用它进行实际的负载测试,而应该使用命令行界面(CLI)进行实际运行。
然后,一旦你启动了 JMeter,你将构建一个由步骤组成的测试计划。它将允许你配置线程和定义客户端数量的方式,向场景添加一些断言(输出验证),并在步骤之间重用变量(例如,第一步可以获取用于验证下一个请求的 OAuth2 令牌,甚至处理测试的预热)。你可以在计划中添加的元素中,有一些报告允许你获取你期望从基准测试中得到的输出数据,例如错误百分比、最小/最大持续时间、吞吐量、KB/秒等:

这张截图代表了 JMeter 的聚合报告,其中包含了关于计划执行或其子部分的统计数据。这里有趣的是错误率(前一个例子中的 100%),这还允许你验证执行是否足够好,也就是说,没有太多错误表明我们没有测试任何东西。
一旦你的计划定义好了,你可以将其保存为.jmx文件(JMeter 默认扩展名),这将允许你重新播放它。到那时,你应该能够本地测试你的场景(稍微改变计划的 URL 以调整到你的本地实例),但你还不能测试集群。
最后,对于实际的负载测试,你需要使用 JMeter 的远程测试解决方案。它将允许你编排多个客户端节点(通常称为注入器,因为它们将向系统中注入请求)。主要优势是:
-
你不再依赖于你的本地机器了
-
你可以控制客户端使用哪些网络(它可以与服务器相同,也可以不同)
-
你可以使用N个客户端机器进行水平扩展,而不是一个
最后一点非常重要,因为涉及到网络使用。在进行 HTTP 请求时,你将使用机器网络,其中一个最限制性的标准将是每个节点的客户端数量。客户端越多,它们在全球范围内就会越慢,因为它们为其他客户端添加了噪声。也就是说,在启动完整运行之前,确保正确地调整注入器的大小,以确定每个注入器节点可以使用的客户端数量,而不会受到基础设施的限制。在实际部署中,你很少会为单个机器拥有大量的客户端。因此,在某些情况下,每台机器只有一个或两个客户端是可以接受的。
如果你想要下载 JMeter,你可以访问 Apache 网站上的下载页面(jmeter.apache.org/download_jmeter.cgi)。
Gatling
Gatling (gatling.io/)是 JMeter 的替代品。你将找到与 JMeter 相同的功能(当然,有一些差异,但这里我们不会列出它们)。主要区别在于你通过脚本编写场景,而不是配置它们,无论是 XML 文件还是通过一个漂亮的 UI 进行可视化配置。
脚本基于领域特定语言(DSL)并且依赖于 Scala 语言。这可能会让 Java 开发者感到阻碍,因为如果你从未进行过任何 Scala 开发,Scala 可能不太友好。然而,这是 Gatling 相对于 JMeter 的优势;它是一个基于 Akka-Netty 的负载测试解决方案。这意味着它是用试图在自身核心中实现无锁技术的技术编写的,并使注入器代码可扩展。如果你要求 JMeter 扩展到太多用户,它被认为在设计上会自我限制。实际上,这并不是一个很大的限制,因为我们之前看到的那样,你通常会从基础设施的角度进行扩展以可靠地测试你的应用程序。然而,在开发和一些高度扩展的应用程序中,这很有趣,因为你不需要那么多机器就能达到注入器相同的可扩展性水平。
这通常是我们进行基准测试时容易忘记的点,这就是为什么在测试之前准备它很重要的原因;以确保注入器不会限制基准测试。否则,你测试的是客户端/注入器而不是服务器/应用程序。
只是为了给你一个概念,这里是一个简单的 Gatling 脚本:
package packt
import io.gatling.core.Predef._
import io.gatling.http.Predef._
import scala.concurrent.duration._
class QuotesSimulation extends Simulation {
val httpConf = http.baseURL("http://benchmark.test.app.quote
-manager.io")
val quotesScenario = scenario("QuotesScenario")
.exec(http("quotes")
.get("/quote-manager/api/quote"))
setUp(
quotesScenario.inject(rampUsers(64) over (60 seconds))
).protocols(httpConf)
}
这个简单的脚本定义了一个名为QuotesScenario的场景。它将请求我们的findAll报价端点。
如果你将此脚本放在$GATLING_HOME/user-files/simulations/packt/QuotesSimulation.scala,请注意,Scala 使用与 Java 中相同的包的概念,因此你需要与simulations文件夹相比正确的嵌套文件夹。然后你可以运行$GATLING_HOME/bin/gatling.sh,它将扫描并编译之前文件夹内的文件以找到模拟,并要求你选择要启动的模拟:
$ ./bin/gatling.sh
GATLING_HOME is set to /home/rmannibucau/softwares/gatling-charts-highcharts-bundle-2.3.0
18:12:52.364 [WARN ] i.g.c.ZincCompiler$ - Pruning sources from previous analysis, due to incompatible CompileSetup.
Choose a simulation number:
[0] computerdatabase.BasicSimulation
[1] computerdatabase.advanced.AdvancedSimulationStep01
[2] computerdatabase.advanced.AdvancedSimulationStep02
[3] computerdatabase.advanced.AdvancedSimulationStep03
[4] computerdatabase.advanced.AdvancedSimulationStep04
[5] computerdatabase.advanced.AdvancedSimulationStep05
[6] packt.QuotesSimulation
6
Select simulation id (default is 'quotessimulation'). Accepted characters are a-z, A-Z, 0-9, - and _
quotessimulation
Select run description (optional)
Test our quotes endpoint
Simulation packt.QuotesSimulation started...
computerdatabase模拟是默认的;我们的模拟是最后一个。一旦选择,Gatling 将请求有关模拟的一些元数据,例如其id和description。
第一次启动 Gatling 时,启动可能会比较长,因为它将编译模拟——有一些默认分布的示例。
当模拟运行时,你将在控制台中看到一些进度(而与 JMeter 相比,你可以在测试的 UI 中获取它并实时查看报告):
================================================================================
2017-11-01 18:14:49 50s elapsed
---- Requests ------------------------------------------------------------------
> Global (OK=54 KO=0 )
> quotes (OK=54 KO=0 )
---- QuotesScenario ------------------------------------------------------------
[############################################################## ] 84%
waiting: 10 / active: 0 / done:54
================================================================================
这些小报告显示了测试的进度。我们可以确定我们配置的模拟已经完成了 84%,代表了 54/64 个请求(用户),并且已经过去了 50 秒。
一旦测试完成,控制台会生成一个小报告:
Simulation packt.QuotesSimulation completed in 59 seconds
Parsing log file(s)...
Parsing log file(s) done
Generating reports...
================================================================================
---- Global Information --------------------------------------------------------
> request count 64 (OK=64 KO=0 )
> min response time 20 (OK=20 KO=- )
> max response time 538 (OK=538 KO=- )
> mean response time 63 (OK=63 KO=- )
> std deviation 63 (OK=63 KO=- )
> response time 50th percentile 53 (OK=53 KO=- )
> response time 75th percentile 69 (OK=69 KO=- )
> response time 95th percentile 98 (OK=98 KO=- )
> response time 99th percentile 280 (OK=280 KO=- )
> mean requests/sec 1.067 (OK=1.067 KO=- )
---- Response Time Distribution ------------------------------------------------
> t < 800 ms 64 (100%)
> 800 ms < t < 1200 ms 0 ( 0%)
> t > 1200 ms 0 ( 0%)
> failed 0 ( 0%)
================================================================================
这个报告包含了关于执行和响应时间分布的统计数据。
最后,Gatling 会生成一个 HTML 报告(默认情况下)。它的位置记录在程序末尾,就在它退出之前。你可以用任何浏览器打开它,看起来是这样的:

回到报告中,我们在页面中心找到了统计数据(在左侧,橙色显示)和一些详细指标。它们之间包括请求数量、响应时间分布(允许你查看响应时间是否在可接受范围内,或者响应时间是否足够稳定以满足目标用户),等等。你可以在之前的屏幕截图中看到有两个标签页:全局和详情。详情标签页左侧有一个小菜单(在我们的屏幕截图中带有引号),允许你按步骤深入到模拟/场景的细节。引号引用的是我们在模拟中定义的http请求的名称。
Gatling 有很多更多功能和组合场景的方式,由于它是代码,所以也非常灵活。这不是本书的主题,但不要犹豫,深入了解一下。
再次提醒,不要忘记注入器机器(放置模拟客户端进程的机器,即这里的 Gatling 进程)可能不够强大,或者带宽不足,无法进行高度扩展。因此,你需要将注入器分布在几台机器上,以在一般情况下达到正确的用户数量。
Gatling 像 JMeter 一样支持这种模式,即使它需要更多的工作。该过程在他们的网站上解释(gatling.io/docs/2.3/cookbook/scaling_out/),但在高层次上,你将在多个节点上运行模拟,然后收集它们的输出并在执行后进行汇总。
其他工具
你可以使用许多其他工具来定义你的场景;甚至一些 DIY 解决方案也可以使用。在所有情况下,你应该确保它足够扩展,以免限制你的基准测试,因为你想要测试的是你的应用程序,而不是基准测试工具。
确保你有支持
当你开始基准测试时,你可能会遇到一些问题,例如:
-
网络设置没有正确完成
-
框架/库/你的应用程序/注入器的 bug
-
远程服务或数据库没有吸收足够的负载,等等
在所有可能遇到的情况中——假设你的软件中的任何砖块都可能有问题——你应该能够找到可以求助的人来帮助你修复问题,或者至少快速评估它。这尤其重要,如果基准测试的一部分需要一些费用(如果你在租用一些机器、咨询等)。这里的想法是尽可能快地消除任何阻碍,不要在细节上浪费时间。
将你的优化基准测试时间框起来
评估基准是按设计划分时间段的;你运行基准测试并报告数据。尽管如此,优化基准则更为模糊。具体来说,你可能会因为一个简单的网络服务所使用的层以及所有可以测试的小调整选项(从网络配置,通过 JVM 内存,到缓存解决方案)而花费整整一年时间进行优化。
因此,在开始基准测试和优化应用程序之前,定义你将花费多少时间进行基准测试是非常重要的。这也可以与租赁期相关联,可能需要与运营和开发团队一起进行估算阶段。但如果你不这样做,风险是你会在细节上花费大量时间,而没有充分利用基准测试。
即使这是一个高级近似,帕累托原则也可以用来尝试优化基准测试时间。具体来说,尝试进行 20%的优化,这将为你提供 80%的应用程序提升。然后,如果你有时间,你可以继续优化剩余的 20%。
安装和重置程序
虽然听起来可能很明显,但在开始基准测试之前,你必须知道如何正确安装你的应用程序,并将其与其他系统(数据库、其他应用程序等)相互连接。这部分应该写成文档,以确保在需要时容易找到,并且至少在基准测试之前测试过一次。
我们经常忘记的部分是重置部分,如果在场景中这部分也能自动化,那就太理想了。这主要是确保每个执行都是可重复的,并且执行结果可以比较。
基准测试迭代
在上一节中,我们为以高效的方式开始基准测试准备了所有需要的东西。现在我们需要看看在基准测试期间我们将如何工作。
第一件重要的事情是确立我们在这里只处理优化迭代,而不是评估迭代,后者是直接的——你运行场景并收集报告。
迭代
这可能是自然的,但你会对你的优化进行迭代。这意味着你会反复运行相同的测试来衡量变化的结果——例如,增加池大小。
这种工作结构的直接影响是,你需要准备好以有组织的方式存储大量的报告。有许多解决方案,这主要取决于你习惯使用的工具。但至少在非常高的层面上,你需要存储:
-
基准测试报告。
-
基准测试日期(为了能够排序,通常在之后重放迭代是有用的)。
-
基准测试配置(你可以存储完整的配置,或者将其写入一个文件,例如命名为
CHANGES.txt的文件,其中列出你从上次运行中更改了什么)。请注意,在这里包括外部系统(如数据库)的更改是很重要的,因为它们可以直接影响你的性能。
在存储方面,您只需在您的硬盘上使用一个benchmark文件夹,并为每个迭代创建一个包含先前信息的文件夹。文件夹名称可以包含日期。一个常见的模式是<迭代编号>_<日期>_<简短描述>,例如001_2017-11-14_increasing-pool-size。使用数字(用0填充)将允许您使用操作系统的排序来排序文件夹。日期为您提供另一个切入点——当有人告诉您“昨天,它运行得更好”,例如。最后,简短描述使您更容易识别报告以进行比较。
这不是强制性的,但如果您有一个小工具(如脚本或小型 Java 程序)解析报告和配置并将它们存储在索引中,您可以更容易地找到数据,并且您将获得更强大的搜索功能。同样,如果您已经完成了解析数据的工作,您可以轻松实现一个小型的diff工具来比较两个报告,这将允许您显示配置更改和对性能——报告的影响。通常,报告是可视的。因此,能够合并两个报告可以使您比使用两个窗口更有效地(视觉上)进行比较。
一次只更改一个设置
当您调整应用程序时,重要的是要确定一个设置是否是增强性能的因素,并据此将其视为重要或不重要。
如果您在单次运行中更改多个设置,您将无法确定哪个设置触发了变化,或者您甚至可能通过使用另一个不良设置来抵消一个良好的设置,从而错过一个优化因素。
抵制同时更改一切的诱惑,并尝试保持一种科学的方法,一次只更改一个设置。
在每次运行之间重置任何状态
在上一节中,我们看到了您必须准备与生产数据库可以处理的数据一样多的数据,但同时也不要忘记在每次运行之间重置数据库状态。
如果您不这样做,风险是您将在每次运行之间减慢执行速度,并使您所做的优化完全不可见。这是因为数据库有一种大小限制(相当大),但在您进行基准测试时,您通常会非常快速地插入大量数据,因此达到这个限制并不令人惊讶。一旦达到这个大小限制,数据库效率降低,性能下降。为了确保您可以在相同条件下运行,以便比较运行并验证一些调整选项,您必须确保数据库在每次运行之间具有相同的数据。所以,您应该确保数据库在每次运行之间具有相同的数据。
这个解释使用了数据库作为主要说明,因为它是最常见的陷阱,但它适用于您系统的任何部分。
预热您的系统
进行基准测试的另一个关键步骤是不要在冷系统上测量数据。原因是 Java EE 应用程序通常旨在是长期运行的软件;因此,通常会有一个已经运行了数周或数小时并进行了优化的热系统。这些优化可以生效:
-
JVM 即时(JIT)编译:这将优化一些常见的代码路径。你还可以调查 JVM 的
-XX:-TieredCompilation选项来预编译代码,但你在一些服务器上可能会遇到一些问题。 -
你可以使用一些缓存,因此一旦缓存包含了所有你测试的数据,应用程序将会更快。
-
如果你使用了一些外部系统,你可能需要进行一些你之后会重复使用的昂贵连接(SSL 连接很慢,安全连接很慢等等)。
在实际测量开始之前进行一些预热迭代非常重要,这样可以隐藏所有这些初始化,并仅测量应用程序的最终性能。
基准测试之后
一旦你完成了基准测试,你应该有一个包含你运行情况的数据库(我们之前提到的包含报告、配置等的文件夹)。现在,为了确保你进行基准测试有明确的原因,有一些行动你应该采取。
在本节中,我们假设你将在基准测试之后执行这些步骤,但你也可以在基准测试本身进行。这样呈现是因为这是一些你可以离线执行的任务,如果你有一些与基准测试相关的成本,这些是可以轻易推迟的任务。
编写报告
通常,在这个阶段,你已经收集了基准测试期间你付出的辛勤劳动所对应的所有数据。将这些数据汇总到报告中非常重要。报告将主要解释调查(为什么你更改了一些设置等等)并展示运行结果。
当然,你可以忽略那些无用的运行(性能没有显著变化),但将那些对应于性能提升或下降的运行整合进来总是很有趣的。
报告的最后部分应该解释如何正确配置服务器以供生产使用。这可以在报告中直接完成,或者指向另一个文档,如产品参考指南或应用程序的白皮书。
这里是一个报告的高级结构:
-
应用程序描述:应用程序做什么。
-
指标:如果你有一些不太明显或具体的指标,请在这里解释它们(在下一部分之前)。
-
场景:你的测试做了什么。
-
基础设施/环境:你如何部署机器和外部系统,你如何设置监控等等。
-
注入:你如何刺激应用程序(例如,你可以解释你有N个 JMeter 节点)。
-
运行情况:你进行的所有相关迭代及其结果。
-
结论:你从基准测试中保留了什么?应该使用哪种配置?你还可以提及一些你没有时间运行测试。
更新你的默认配置
即使,如前所述,最终的配置是报告的一部分,它也不会阻止你将基准测试中得出的所有良好实践传播到代码库中。目标是减少强制配置。
例如,如果你发现你需要 1 秒的超时时间而不是默认的 30 秒才能获得良好的性能,直接在代码库中将默认值更新为 1 秒将避免在配置被遗忘时出现不良性能。这部分是默认可用性和性能之间的权衡,但通常你仍然可以通过这样做来提高默认用户/操作团队的经验。
如果你有某些配置脚本或 Dockerfile,不要忘记如果相关的话也更新它们。
审查结论
根据你的结论,你可能需要与开发人员或其他成员交叉检查基准测试的结果是否有效。
例如,如果你在我们的报价管理器中推断出你需要缓存报价,那么你可能希望验证:
-
如果从业务角度来看可以缓存它们(你可以与你的产品负责人或经理核实)。
-
你可以缓存它们多长时间,因为你可能希望在某个时候更新价格
另一个常见的例子是验证你是否可以绕过或更改保护应用程序某些部分的方式,因为安全层太慢(例如,从 OAuth2 切换到 HTTP 签名,或某种认证机制到网络安全)。
一旦结论得到验证,你还可以提取与原始 SLA 相关的报告部分,并让您的客户或您所报告的人进行验证。
丰富你的项目待办事项
在某些情况下,你可能在代码中发现了某些问题。它们可能或可能不会影响性能,但无论如何,你需要创建相应的任务来修复它们。
如果你在基准测试期间使用了某些热修复或补丁,别忘了在报告中提及并引用它们,以便让人们跟踪是否真正修复了这些问题。请注意,这可能与外部库或容器有关,而不仅仅是你的应用程序。
你在跨团队工作得越多,这个阶段就越重要。否则,你会得到一份报告,其中 SLA 得到了满足,但产品永远无法遵守,因为增强功能从未整合到主流源代码中。
摘要
在本章中,我们了解到基准是在确保你能最大限度地从基准时间中获益之前需要准备的东西,并且它需要一些组织工作。我们还看到,为了有用,你需要从已完成的工作中提取出它所蕴含的结论。这实际上是一个科学程序——但很容易——如果你想优化你的时间,你需要尊重它。
下一章和最后一章将进一步深入探讨如何缩短开发和基准之间的距离,以达到持续的绩效评估,使你的基准不再有害,因为一切都已经准备就绪并且处于控制之下。
第十章:持续性能评估
我们在前一章中看到,驱动基准测试需要工作,并且不是一个小的操作。然而,它并不能解决软件开发的所有需求,因为你只是偶尔进行它。此外,在两次基准测试之间,你可能会遇到巨大的性能回归,而这些回归没有被验证应用程序功能和行为的单元测试捕捉到。
为了解决这个问题,尝试添加针对性能的专用测试是一个好主意。这正是本章的内容。因此,我们将学习:
-
你可以使用哪些工具来测试性能
-
你如何为性能设置一些持续集成
-
在这种背景下如何编写性能测试
编写性能测试——陷阱
性能测试有一些挑战,当你编写测试时需要考虑,以避免出现大量误报结果(实际性能可接受但测试未通过的情况)。
你最常遇到的问题包括:
-
如何管理外部系统:我们知道外部系统在当今的应用程序中非常重要,但在测试期间拥有它们并不总是微不足道的。
-
如何确保确定性:持续集成/测试平台通常是共享的。你如何确保你有必要的资源,以便有确定性的执行时间,并且不会被拖慢,因为另一个构建正在使用所有可用资源?
-
如何处理基础设施:为了进行端到端测试,你需要多个注入器(客户端)和可能多个后端服务器。如果你使用像Amazon Web Services(AWS)这样的云平台,你如何确保它们可用,同时又不至于过于昂贵?
你可以将性能测试的设置看作是一个基准准备阶段。主要区别在于你不应长期依赖外部咨询,并且你将确保基准迭代几乎完全自动化——几乎因为如果测试失败,你仍然需要采取一些行动,否则拥有持续系统就没有意义了。
编写性能测试
性能测试存在于 Java EE 应用的各个阶段,因此,编写性能测试有几种方法。在本节中,我们将探讨几种主要方法,从最简单的一个(算法验证)开始。
JMH – OpenJDK 工具
Java Microbenchmark Harness(JMH)是由 OpenJDK 团队开发的一个小型库——是的,就是那个做 JVM 的团队——它使你能够轻松地开发微基准测试。
微基准测试设计了一个基准测试,针对应用程序非常小的一部分。大多数情况下,你可以将其视为单元基准测试,与单元测试进行类比。
然而,在设置性能测试时,这是一个重要的因素,因为它将允许您快速识别由最近更改引入的关键性能回归。想法是将每个基准测试与一小部分代码相关联,而基准测试将包含几个层次。因此,如果基准测试失败,您将花费大量时间来识别原因,而不是简单地检查相关的代码部分。
使用 JMH 编写基准测试
在能够使用 JMH 编写代码之前,您需要将其添加为项目的依赖项。以下示例我们将使用 Maven 语法,但 Gradle 等其他构建工具也有相应的语法。
首先要做的是将以下依赖项添加到您的pom.xml文件中;我们将使用test范围,因为依赖项仅用于我们的性能测试,而不是“主”代码:
<dependencies>
<!-- your other dependencies -->
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
<scope>test</scope> <!-- should be provided but for tests only this
is more accurate -->
</dependency>
</dependencies>
写这本书的时候,jmh.version属性可以取1.19的值。jmh-core依赖项将为您提供 JMH 本身及其基于注解的 API,而jmh-generator-annprocess则提供了一个注解处理器框架——在编译测试类/基准测试时使用,它将生成一些索引文件,这些文件由运行时用于执行基准测试和基准测试类本身。
状态
一旦您有了正确的依赖项,您就可以开发您的基准测试。API 非常直观。它使用状态的概念,状态与基准测试相关联,并具有生命周期和(易失性)存储。状态的生命周期是通过标记方法来定义的:
-
@Setup用于执行初始化任务 -
@Teardown用于在设置方法中释放任何创建的资源
状态类也可以包含带有@Param装饰的字段,使它们具有上下文化和可配置性(例如,根据执行启用不同的目标 URL 等)。
状态类用@State标记,它作为参数接受基准测试状态实例的作用域:
-
Benchmark意味着状态将在 JVM 的作用域内是一个单例。 -
Group定义了每个组的状态为一个单例。组是在执行期间将多个基准测试方法(场景)放入同一线程桶的方法。 -
Thread定义了每个线程的状态为一个单例(有点类似于 CDI 中的@RequestScoped)。
注意,您可以通过将运行时传递给@Setup和@Teardown以不同的Level来稍微改变生命周期管理方式:
-
Trial:这是默认的,正如我们刚才解释的,基准测试被视为一个整体 -
Iteration:生命周期在每次迭代(预热或测量)中执行 -
Invocation:状态实例的生命周期在每次方法调用中执行;实际上不推荐这样做,以避免一些测量错误
因此,一旦您有了状态对象,您就定义包含基准测试的类,这些基准测试实际上就是标记了@Benchmark的方法。然后,您可以在方法上添加几个注解来自定义基准测试的执行:
-
@Measurement:用于自定义基准的迭代次数或持续时间。 -
@Warmup:与@Measurement相同,但用于预热(这是一种基准的预执行,不计入测量,目标是只测量热 JVM 上的指标)。 -
@OutputTimeUnit:用于自定义用于指标的单元。 -
@Threads:用于自定义用于基准的线程数。你可以将其视为用户数。 -
@Fork:基准将在另一个 JVM 中执行,以避免测试副作用。这个注解允许你向分叉的 JVM 添加自定义参数。 -
@OperationsPerInvocation:如果你的基准方法中有一个循环,这个选项(一个数字)将标准化测量。例如,如果你在基准中执行了五次相同的操作,并将此值设置为五,那么执行时间将除以五。 -
@Timeout:它允许你定义基准执行的最大持续时间。如果超过这个时间,JMH 将中断线程。 -
@CompilerControl:用于自定义注解处理器生成代码的方式。对于我们的用例,你很少需要这个选项,但在调整某些代码部分时,测试它可能很有趣。
创建你的第一个 JMH 基准
在所有这些理论之后,这里是一个使用 JMH 开发的基本基准:
public class QuoteMicrobenchmark {
@Benchmark
public void compute() {
//
}
}
在这种情况下,我们有一个名为compute的单个基准场景。它不使用任何状态,也没有自定义任何线程或分支计数。
实际上,这还不够,你通常会需要一个状态来获取服务。所以,它会更像这样:
public class QuoteMicrobenchmark {
@Benchmark
public void compute(final QuoteState quoteState) {
quoteState.service.findByName("test");
}
@State(Scope.Benchmark)
public static class QuoteState {
private QuoteService service;
@Setup
public void setup() {
service = new QuoteService();
}
}
}
在这里,我们创建了一个嵌套的QuoteState实例,它将负责为我们获取服务,并将其注入到基准方法(compute)中,并使用它来获取我们的服务实例。这避免了每次迭代创建一个实例,因此避免了考虑容器启动持续时间。
这种实现方式在需要实际容器之前效果良好。但是,当你需要容器时,它将需要一些模拟,这绝对是你应该去除的东西——即使是对于单元测试——因为它不会在代码完全不使用 Java EE 的情况下(这也意味着在这种情况下你不需要模拟任何东西)代表接近真实部署的任何内容。
如果你使用支持独立 API 的 CDI 2.0 实现——并且你的应用程序不需要更多用于你想要测试的内容——那么你可以将状态更改为启动/停止 CDI 容器,并使用新的 CDI 独立 API 查找你需要的服务:
@State(Scope.Benchmark)
public class QuoteState {
private QuoteService service;
private SeContainer container;
@Setup
public void setup() {
container = SeContainerInitializer.newInstance().initialize();
service = container.select(QuoteService.class).get();
}
@TearDown
public void tearDown() {
container.close();
}
}
在这里,设置启动container——你可以在调用initialize()之前自定义要部署的类——然后使用container实例 API 查找QuoteService。tearDown方法只是正确关闭容器。
然而,在 GlassFish 中,你不能使用那个新 API。但是有一个来自 Java EE 6 的EJBContainer,它允许你结合CDI类做同样的事情:
@State(Scope.Benchmark)
public class QuoteState {
private QuoteService service;
private EJBContainer container;
@Setup
public void setup() {
container = EJBContainer.createEJBContainer(new HashMap<>());
service = CDI.current().select(QuoteService.class).get();
}
@TearDown
public void tearDown() {
container.close();
}
}
这与之前完全相同的逻辑,只是基于EJBContainer API 启动container。这看起来很棒,但它并不总是与所有容器都兼容。一个陷阱是,如果你没有任何 EJB,一些容器甚至不会尝试部署应用程序。
你可以找到几种解决方案,但更明智的解决方案是检查你是否真的需要完整的容器或只是子集——比如只是 CDI——在这种情况下,你只需启动这个子集(Weld 或 OpenWebBeans 仅用于 CDI,使用之前的状态)。如果你真的需要一个完整的容器,并且你的供应商不支持上述两种启动容器的方法,你也可以使用供应商特定的 API,模拟容器(但请注意,你将绕过一些执行时间成本),如果与你的最终容器足够接近,也可以使用另一个供应商,或者使用第三方容器管理器,如arquillian容器 API。
黑洞将成为一颗恒星
JMH 提供了一个特定的类——org.openjdk.jmh.infra.Blackhole——它可能看起来很奇怪,因为它主要只允许你consume一个实例。它是通过将其注入到基准的参数中检索的:
public void compute(final QuoteState quoteState, final Blackhole blackhole) {
blackhole.consume(quoteState.service.findByName("test"));
}
为什么不处理方法返回的值呢?记住,Oracle JVM 有一个称为即时编译(JIT)的功能,它会根据代码路径的统计数据在运行时优化代码。如果你不调用那个consume方法,你可能会最终测量不到你想要的实际代码,而是这种代码的一些非常优化的版本,因为,在大多数情况下,在你的基准方法中,你会忽略部分返回值,因此可以被死代码消除规则优化。
运行基准测试
JMH 提供了一个友好的 API 来运行基准测试。它基于定义执行选项,这些选项大致上是你可以与基准方法关联的选项(我们在上一节中通过注解看到的)和一个包含/排除列表,以选择要运行的类/方法。然后,你将这些选项传递给运行器并调用其run()方法:
final Collection<RunResult> results = new Runner(
new OptionsBuilder()
.include("com.packt.MyBenchmark")
.build())
.run();
在这里,我们通过包含我们想要包含的基准类,从OptionsBuilder构建一个简单的Options实例。最后,我们通过具有与运行器相同名称的方法运行它,并将基准的结果收集到一个集合中。
将 JMH 与 JUnit 集成
JMH 没有官方的 JUnit 集成。尽管如此,自己实现它并不难。有许多可能的设计,但在这个书的背景下,我们将做以下事情:
-
我们的集成将通过 JUnit 运行器进行
-
基准类将通过提取测试类的嵌套类来识别,这将避免使用任何扫描来查找基准类或显式列出
-
我们将引入
@ExpectedPerformances注解,以便能够根据执行添加断言
结构上,使用这种结构的微基准测试将看起来像这样:
@RunWith(JMHRunner.class)
public class QuoteMicrobenchmarkTest {
@ExpectedPerformances(score = 2668993660.)
public static class QuoteMicrobenchmark {
@Benchmark
@Fork(1) @Threads(2)
@Warmup(iterations = 5) @Measurement(iterations = 50)
@ExpectedPerformances(score = 350000.)
public void findById(final QuoteState quoteState, final
Blackhole blackhole) {
blackhole.consume(quoteState.service.findById("test"));
}
// other benchmark methods
}
public static class CustomerMicrobenchmark {
// benchmark methods
}
}
一旦你有了整体测试结构,你只需要在正确的嵌套类中添加基准测试本身。具体来说,一个基准测试类可以看起来像这样:
public static/*it is a nested class*/ class QuoteMicrobenchmark {
@Benchmark
@Fork(1) @Threads(2)
@Warmup(iterations = 5) @Measurement(iterations = 50)
@ExpectedPerformances(score = 350000.)
public void findById(final QuoteState quoteState, final Blackhole
blackhole) {
blackhole.consume(quoteState.service.findById("test"));
}
@Benchmark
@Fork(1) @Threads(2) @Warmup(iterations = 10)
@Measurement(iterations = 100)
@ExpectedPerformances(score = 2660000.)
public void findByName(final QuoteState quoteState, final Blackhole
blackhole) {
blackhole.consume(quoteState.service.findByName("test"));
}
@State(Scope.Benchmark)
public static class QuoteState {
private QuoteService service;
@Setup public void setup() { service = new QuoteService(); }
}
}
在这个测试类中,我们有两个基准测试类,它们使用不同的状态(我们 EE 应用程序中的不同服务),并且每个类都可以有不同的基准测试方法数量。执行由使用@RunWith设置的运行器处理,使用标准的 JUnit(4)API。我们将在所有基准测试上注意@ExpectedPerformances的使用。
如果你已经迁移到 JUnit 5,或者正在使用 TestNG,可以实现类似的集成,但你会使用 JUnit 5 的扩展,可能还会使用 TestNG 的抽象类或监听器。
在介绍如何实现该运行器之前,我们必须有这个@ExpectedPerformances注解。它是允许我们断言基准测试性能的注解。因此,我们至少需要:
-
一个分数(持续时间,但不指定单位,因为 JMH 支持自定义报告单位)
-
分数上有一定的容差,因为你不可能两次得到完全相同的分数
为了做到这一点,我们可以使用这个简单的定义:
@Target(METHOD)
@Retention(RUNTIME)
public @interface ExpectedPerformances {
double score();
double scoreTolerance() default 0.1;
}
如你所见,我们告诉用户定义一个分数,但我们使用默认的分数容差0.1。在我们的实现中,我们将它视为百分比(10%)。这将避免在机器负载不稳定时运行作业时频繁失败。不要犹豫,降低这个值,甚至可以通过系统属性使其可配置。
要使我们的上一个片段正常工作,我们需要实现一个 JUnit 运行器。这是一个设计选择,但你也可以使用规则,暴露一些程序性 API 或不暴露。为了保持简单,我们在这里不会这样做,而是将整个基准测试设置视为通过注解完成的。然而,对于实际项目来说,启用环境(系统属性)以自定义注解值可能很有用。一个常见的实现是使用它们作为模板,并在 JVM 中将所有数值乘以一个配置的比率,以便在任何机器上运行测试。
我们的运行器将具有以下四个角色:
-
找到基准测试类
-
验证这些类的模型——通常,验证每个基准测试都有一个
@ExpectedPerformances -
运行每个基准测试
-
验证期望,如果出现回归,使测试失败
对于我们来说,扩展ParentRunner<Class<?>>更简单。我们可以使用BlockJUnit4ClassRunner,但它基于方法,而 JMH 只支持按类过滤执行。所以,我们暂时坚持使用它。如果你在每个嵌套类中只放一个基准测试,那么你可以模拟按方法运行的运行行为。
我们需要做的第一件事是找到我们的基准测试类。使用 JUnit 运行器 API,你可以通过getTestClass()访问测试类。要找到我们的基准测试,我们只需要通过getClasses()检查该类的嵌套类,并确保该类至少有一个方法上的@Benchmark来验证它是一个 JMH 类:
children = Stream.of(getTestClass().getJavaClass().getClasses())
.filter(benchmarkClass ->
Stream.of(benchmarkClass.getMethods())
.anyMatch(m -> m.isAnnotationPresent(Benchmark.class)))
.collect(toList());
我们遍历测试类的所有嵌套类,然后只保留(或过滤)至少有一个基准方法的那部分。请注意,我们存储结果,因为我们的跑步者需要多次使用这些结果。
然后,验证过程就像遍历这些类及其基准方法,验证它们是否有@ExpectedPerformances注解:
errors.addAll(children.stream()
.flatMap(c -> Stream.of(c.getMethods()).filter(m ->
m.isAnnotationPresent(Benchmark.class)))
.filter(m ->
!m.isAnnotationPresent(ExpectedPerformances.class))
.map(m -> new IllegalArgumentException("No
@ExpectedPerformances on " + m))
.collect(toList()));
在这里,为了列出 JUnit 验证的错误,我们为每个用@Benchmark注解但没有@ExpectedPerformances注解的方法添加一个异常。我们首先通过将类转换为基准方法的流来实现,然后只保留没有@ExpectedPerformances注解的方法以保持集合视图。
最后,跑步者代码的最后关键部分是将一个类转换为实际的执行:
final Collection<RunResult> results;
try {
results = new Runner(buildOptions(benchmarkClass)).run();
} catch (final RunnerException e) {
throw new IllegalStateException(e);
}
// for all benchmarks assert the performances from the results
final List<AssertionError> errors = Stream.of(benchmarkClass.getMethods())
.filter(m -> m.isAnnotationPresent(Benchmark.class))
.map(m -> {
final Optional<RunResult> methodResult = results.stream()
.filter(r ->
m.getName().equals(r.getPrimaryResult().getLabel()))
.findFirst();
assertTrue(m + " didn't get any result",
methodResult.isPresent());
final ExpectedPerformances expectations =
m.getAnnotation(ExpectedPerformances.class);
final RunResult result = results.iterator().next();
final BenchmarkResult aggregatedResult =
result.getAggregatedResult();
final double actualScore =
aggregatedResult.getPrimaryResult().getScore();
final double expectedScore = expectations.score();
final double acceptedError = expectedScore *
expectations.scoreTolerance();
try { // use assert to get a common formatting for errors
assertEquals(m.getDeclaringClass().getSimpleName() +
"#" + m.getName(), expectedScore, actualScore,
acceptedError);
return null;
} catch (final AssertionError ae) {
return ae;
}
}).filter(Objects::nonNull).collect(toList());
if (!errors.isEmpty()) {
throw new AssertionError(errors.stream()
.map(Throwable::getMessage)
.collect(Collectors.joining("\n")));
}
首先,我们执行基准持有类并收集结果。然后,我们遍历基准方法,并对每个方法,我们提取结果(主要结果标签是方法名)。最后,我们提取我们的@ExpectedPerformances约束并将其与测试的主要结果进行比较。这里的技巧是捕获断言可以抛出的AssertionError,并将它们全部收集到一个列表中,然后转换为另一个AssertionError。你可以按你想要的方式格式化消息,但这样做可以保持标准 JUnit 的错误格式。这里的另一个提示是将基准类和方法放入错误消息中,以确保你可以识别哪个基准失败了。另一种方式是引入另一个注解,为每个基准使用自定义名称。
现在我们已经审视了所有的技术细节,让我们将它们整合起来。首先,我们将定义我们的跑步者将使用Class子类,这些子类将代表我们嵌套的每个类:
public class JMHRunner extends ParentRunner<Class<?>> {
private List<Class<?>> children;
public JMHRunner(final Class<?> klass) throws InitializationError {
super(klass);
}
@Override
protected List<Class<?>> getChildren() {
return children;
}
@Override
protected Description describeChild(final Class<?> child) {
return Description.createTestDescription(getTestClass().getJavaClass(), child.getSimpleName());
}
在父构造函数(super(klass))执行的代码中,JUnit 将触发一个测试验证,其中我们计算之前看到的子类,以便在getChildren()中返回它们,并让 JUnit 处理所有我们的嵌套类。我们实现describeChild以让 JUnit 将一个Description与每个嵌套类关联,并实现与 IDE 的更平滑集成(目标是当你运行测试时在树中显示它们)。为了计算子类并验证它们,我们可以使用这个 JUnit 的collectInitializationErrors实现——使用这个钩子可以避免在每个测试类中多次计算:
@Override
protected void collectInitializationErrors(final List<Throwable>
errors) {
super.collectInitializationErrors(errors);
children = Stream.of(getTestClass().getJavaClass().getClasses())
.filter(benchmarkClass -> Stream.of(benchmarkClass.getMethods())
.anyMatch(m -> m.isAnnotationPresent(Benchmark.class)))
.collect(toList());
errors.addAll(children.stream()
.flatMap(c -> Stream.of(c.getMethods())
.filter(m -> m.isAnnotationPresent(Benchmark.class)))
.filter(m ->
!m.isAnnotationPresent(ExpectedPerformances.class))
.map(m -> new IllegalArgumentException("No
@ExpectedPerformances on " + m))
.collect(toList()));
}
然后,我们需要确保我们可以正确地运行我们的子类(基准)。为此,我们扩展了另一个 JUnit 钩子,该钩子旨在运行每个子类。我们主要关心的是确保 JUnit 的@Ignored注解支持我们的子类:
@Override
protected boolean isIgnored(final Class<?> child) {
return child.isAnnotationPresent(Ignore.class);
}
@Override
protected void runChild(final Class<?> child, final RunNotifier
notifier) {
final Description description = describeChild(child);
if (isIgnored(child)) {
notifier.fireTestIgnored(description);
} else {
runLeaf(benchmarkStatement(child), description, notifier);
}
}
在 runChild() 中,我们通过添加作为我们测试执行的代码(基于 JMH 运行器,但封装在 JUnit Statement 中以使其能够与 JUnit 通知器集成)来委托给 JUnit 引擎执行。现在,我们只需要这个执行实现(benchmarkStatement)。这是通过以下代码来完成类的:
private Statement benchmarkStatement(final Class<?> benchmarkClass) {
return new Statement() {
@Override
public void evaluate() throws Throwable {
final Collection<RunResult> results;
try {
results = new Runner(buildOptions(benchmarkClass)).run();
} catch (final RunnerException e) {
throw new IllegalStateException(e);
}
assertResults(benchmarkClass, results);
}
};
}
// all options will use JMH annotations so just
include the class to run
private Options buildOptions(final Class<?> test) {
return new OptionsBuilder()
.include(test.getName().replace('$', '.'))
.build();
}
这重用了我们之前看到的所有内容;buildOptions 方法将强制 JMH 运行器使用基准测试上的注解来配置执行,我们一次只包含一个测试。最后,我们像之前解释的那样实现了 assertResults 方法:
public void assertResults(final Class<?> benchmarkClass, final
Collection<RunResult> results) {
// for all benchmarks assert the performances from the results
final List<AssertionError> errors =
Stream.of(benchmarkClass.getMethods())
.filter(m -> m.isAnnotationPresent(Benchmark.class))
.map(m -> {
final Optional<RunResult> methodResult = results.stream()
.filter(r ->
m.getName().equals(r.getPrimaryResult().getLabel()))
.findFirst();
assertTrue(m + " didn't get any result",
methodResult.isPresent());
final ExpectedPerformances expectations =
m.getAnnotation(ExpectedPerformances.class);
final RunResult result = results.iterator().next();
final BenchmarkResult aggregatedResult =
result.getAggregatedResult();
final double actualScore =
aggregatedResult.getPrimaryResult().getScore();
final double expectedScore = expectations.score();
final double acceptedError = expectedScore *
expectations.scoreTolerance();
try { // use assert to get a common formatting for errors
assertEquals(m.getDeclaringClass().getSimpleName() + "#" +
m.getName(), expectedScore, actualScore, acceptedError);
return null;
} catch (final AssertionError ae) {
return ae;
}
}).filter(Objects::nonNull).collect(toList());
if (!errors.isEmpty()) {
throw new AssertionError(errors.stream()
.map(Throwable::getMessage)
.collect(Collectors.joining("\n")));
}
}
}
现在,使用这个运行器,你可以在主构建中使用 surefire 或 failsafe 执行测试,并确保如果你的性能回归很大,构建将不会通过。
这是一个简单的实现,你可以通过多种方式丰富它,例如通过模拟每个基准测试一个子项来在 IDE 和 surefire 报告中获得更好的报告(只需在第一次遇到封装类时运行它,然后存储结果并对每个方法进行断言)。你还可以断言更多结果,例如次要结果或迭代结果(例如,没有迭代比 X 慢)。最后,你可以实现一些“活文档”功能,添加一个 @Documentation 注解,该注解将被运行器用于创建报告文件(例如,在 asciidoctor 中)。
ContiPerf
JMH 的一个替代方案是 ContiPerf,它稍微简单一些,但更容易使用。你可以通过以下依赖关系将其添加到你的项目中:
<dependency>
<groupId>org.databene</groupId>
<artifactId>contiperf</artifactId>
<version>2.3.4</version>
<scope>test</scope>
</dependency>
由于我们已经花费了大量时间在 JMH 上,我们不会在这本书中详细说明它。但简单来说,它基于 JUnit 4 规则。因此,它可以与其他规则结合并排序,这得益于 JUnit RuleChain,这使得它非常强大,所有轻量级 EE 容器都拥有基于 JUnit 的测试堆栈,例如 TomEE 或 Meecrowave 等。
ContiPerf 的一个很大的优势是它与 JUnit 模型对齐:
-
它基于标准的 JUnit
@Test方法标记 -
你可以重用 JUnit 标准的生命周期(
@BeforeClass、@Before等等) -
你可以将它与其他 JUnit 功能(运行器、规则等)结合使用
从结构上来说,一个测试可以看起来是这样的:
public class QuoteMicrobenchmarkTest {
private static QuoteService service;
@Rule
public final ContiPerfRule rule = new ContiPerfRule();
@BeforeClass
public static void init() {
service = new QuoteService();
}
@Test
@Required(throughput = 7000000)
@PerfTest(rampUp = 100, duration = 10000, threads = 10, warmUp =
10000)
public void test() {
service.findByName("test").orElse(null);
}
}
我们立即识别出 JUnit 结构,有一个 @BeforeClass 初始化测试(你可以在这里启动容器,如果需要的话,在 @AfterClass 中关闭它),以及一个 @Test,它是我们的基准/场景。与 JUnit 测试的唯一区别是 ContiPerfRule 和 @Required 以及 @PerfTest 注解。
@PerfTest 描述了测试环境——多少线程、多长时间、多少迭代次数、预热持续时间等。
另一方面,@Required 描述了要进行的断言(验证)。它相当于我们 JMH 集成中的 @ExpectedPerformances。它支持大多数常见的验证,如吞吐量、平均时间、总时间等。
Arquillian 和 ContiPerf – 这对恶魔般的组合
在 JMH 部分,我们遇到了启动容器有时很难且并不直接的问题。由于 ContiPerf 是一个规则,它与 Arquillian 兼容,可以为你完成所有这些工作。
Arquillian 是由 JBoss(现在是 Red Hat)创建的一个项目,用于抽象化容器背后的 服务提供者接口(SPI),并将其与 JUnit 或 TestNG 集成。想法是像往常一样从你的 IDE 中运行测试,而不必担心需要容器。
在高层次上,它要求你定义要部署到容器中的内容,并使用与 JUnit(或 TestNG 的抽象类)一起使用的 Arquillian 运行器。多亏了扩展和增强器的机制,你可以将大多数你需要的内容注入到测试类中,例如 CDI 容器,这对于编写测试来说非常方便。以下是一个示例:
@RunWith(Arquillian.class)
public class QuoteServicePerformanceTest {
@Deployment
public static Archive<?> quoteServiceApp() {
return ShrinkWrap.create(WebArchive.class, "quote-manager.war")
.addClasses(QuoteService.class,
InMemoryTestDatabaseConfiguration.class)
.addAsWebResource(new ClassLoaderAsset("META
-INF/beans.xml"), "WEB-INF/classes/META-INF/beans.xml")
.addAsWebResource(new ClassLoaderAsset("META-
INF/persistence.xml"), "WEB-INF/classes/META-
INF/persistence.xml");
}
@Inject
private QuoteService service;
@Before
public void before() {
final Quote quote = new Quote();
quote.setName("TEST");
quote.setValue(10.);
service.create(quote);
}
@After
public void after() {
service.deleteByName("TEST");
}
@Test
public void findByName() {
assertTrues(service.findByName("TEST").orElse(false));
}
}
此代码片段说明了 Arquillian 的使用及其常见特性:
-
正在使用
Arquillian运行器;这是启动容器(一次)、部署应用程序(默认情况下按测试类)、执行测试(继承自 JUnit 默认行为)、在执行完所有测试后卸载应用程序,并在测试执行后关闭容器的魔法。 -
静态的
@Deployment方法返回一个Archive<?>,描述了要部署到容器(应用程序)中的内容。你不需要部署完整的应用程序,如果你愿意,可以按测试更改它。例如,在我们的示例中,我们没有部署我们的DataSourceConfiguration,它指向 MySQL,而是部署了一个InMemoryDatabaseConfiguration,我们可以假设它使用嵌入式数据库,如 derby 或 h2。 -
我们直接将 CDI 注入到测试中,我们的
QuoteService。 -
测试的其余部分是一个标准的 JUnit 测试,具有其生命周期(
@Before/@After)和测试方法(@Test)。
如果你发现构建存档过于复杂,有一些项目,例如 TomEE 的 ziplock 模块,通过重用 当前 项目元数据(例如 pom.xml 和编译后的类)来简化它,这允许你通过单次方法调用:Mvn.war() 来创建存档。一些容器,包括 TomEE,允许你一次性部署每个存档。但如果你的容器不支持,你可以使用 Arquillian 套件扩展来实现几乎相同的结果。总体目标是将你的测试分组,一次性部署你的应用程序,并在测试执行持续时间上节省宝贵的时间。
Arquillian 还允许我们更进一步,从容器内部(如前一个示例所示)或使用 @RunAsClient 注解从客户端角度执行测试。在这种情况下,你的测试不再在容器内部执行,而是在 JUnit JVM(这可以相同或不同,取决于你的容器是否使用另一个 JVM)中执行。无论如何,将 Arquillian 与 ContiPerf 集成可以让你在没有太多烦恼的情况下验证性能。你只需在你想验证的方法上添加 ContiPerf 规则和注解即可:
@RunWith(Arquillian.class)
public class QuoteServicePerformanceTest {
@Deployment
public static Archive<?> quoteServiceApp() {
final WebArchive baseArchive =
ShrinkWrap.create(WebArchive.class, "quote-manager.war")
.addClasses(QuoteService.class)
.addAsWebResource(new ClassLoaderAsset("META
-INF/beans.xml"), "WEB-INF/classes/META-INF/beans.xml")
.addAsWebResource(new ClassLoaderAsset("META
-INF/persistence.xml"), "WEB-INF/classes/META
-INF/persistence.xml");
if (Boolean.getBoolean("test.performance." +
QuoteService.class.getSimpleName() + ".database.mysql")) {
baseArchive.addClasses(DataSourceConfiguration.class);
} else {
baseArchive.addClasses(InMemoryDatabase.class);
}
return baseArchive;
}
@Rule
public final ContiPerfRule rule = new ContiPerfRule();
@Inject
private QuoteService service;
private final Collection<Long> id = new ArrayList<Long>();
@Before
public void before() {
IntStream.range(0, 1000000).forEach(i -> insertQuote("Q" + i));
insertQuote("TEST");
}
@After
public void after() {
id.forEach(service::delete);
}
@Test
@Required(max = 40)
@PerfTest(duration = 500000, warmUp = 200000)
public void findByName() {
service.findByName("TEST");
}
private void insertQuote(final String name) {
final Quote entity = new Quote();
entity.setName(name);
entity.setValue(Math.random() * 100);
id.add(service.create(entity).getId());
}
}
这几乎与之前的测试相同,但我们添加了更多数据以确保数据集大小不会在很大程度上影响初始化数据库时使用的 @Before 方法中的性能。为了将测试与 ContiPerf 集成,我们在方法和 ContiPerf 规则上添加了 ContiPerf 注解。你最后可以看到的一个技巧是在归档创建中设置系统属性,可以根据 JVM 配置切换数据库。它可以用来测试多个数据库或环境,并验证你是否与所有目标平台兼容。
要能够运行此示例,你需要在你的 pom 中添加这些依赖项——考虑到你将针对 GlassFish 5.0 进行测试,你需要更改容器依赖项和 arquillian 容器集成:
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.junit</groupId>
<artifactId>arquillian-junit-container</artifactId>
<version>1.1.13.Final</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.jboss.arquillian.container</groupId>
<artifactId>arquillian-glassfish-embedded-3.1</artifactId>
<version>1.0.1</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.glassfish.main.extras</groupId>
<artifactId>glassfish-embedded-all</artifactId>
<version>5.0</version>
<scope>test</scope>
</dependency>
JUnit 是我们使用的测试框架,我们导入其 Arquillian 集成(arquillian-junit-container)。然后,我们导入我们的 arquillian 容器集成(arquillian-glassfish-embedded-3.1)和 Java EE 容器,因为我们使用嵌入式模式(glassfish-embedded-all)。如果你计划使用它,别忘了添加 ContiPerf 依赖项。
JMeter 和构建集成
在前面的部分中,我们了解到 JMeter 可以用来构建针对你的应用程序执行的场景。它也可以通过编程方式执行——毕竟它是基于 Java 的——或者通过其 Maven 集成之一执行。
如果你使用其 Maven 插件来自 lazerycode (github.com/jmeter-maven-plugin/jmeter-maven-plugin),你甚至可以配置远程模式以进行真正的压力测试:
<plugin>
<groupId>com.lazerycode.jmeter</groupId>
<artifactId>jmeter-maven-plugin</artifactId>
<version>2.2.0</version>
<executions>
<execution>
<id>jmeter-tests</id>
<goals>
<goal>jmeter</goal>
</goals>
</execution>
</executions>
<configuration>
<remoteConfig>
<startServersBeforeTests>true</startServersBeforeTests>
<serverList>jmeter1.company.com,jmeter2.company.com</serverList>
<stopServersAfterTests>true</stopServersAfterTests>
</remoteConfig>
</configuration>
</plugin>
此代码片段定义了 jmeter 插件使用 jmeter1.company.com 和 jmeter2.company.com 进行负载测试。服务器将在运行计划之前/之后初始化和销毁。
不深入插件的具体细节——你可以在相关的 GitHub wiki 上找到它们——该插件默认使用存储在项目中的 src/test/jmeter 中的配置。这就是你可以放置你的场景(.jmx 文件)的地方。
这个解决方案的挑战是提供 jmeter[1,2].company.com 机器。当然,你可以创建一些机器并让它们运行,尽管这样做并不是管理 AWS 机器的好方法,而且最好是在构建过程中启动它们(允许你在多个分支上同时进行并发构建,如果需要的话)。
对于这种需求,有几种解决方案,但最简单的方法可能是在一个 CI 平台上安装 AWS 客户端(或插件),并在 Maven 构建相应的命令来提供机器、设置机器主机为构建属性并将它传递给 Maven 构建之前/之后启动它。这需要您对插件配置进行变量化,但不需要复杂,因为 Maven 支持占位符和系统属性输入。尽管如此,从您的机器上运行测试可能很困难,因为您需要为自己提供将要使用的机器。因此,这减少了项目的可共享性。
不要忘记确保关闭实例的任务始终被执行,即使测试失败,否则您可能会泄露一些机器,并在月底收到账单惊喜。
最后,由于 JMeter 是一个主流解决方案,您可以轻松找到原生支持它并为您处理基础设施的平台。主要的有:
-
BlazeMeter (
www.blazemeter.com/) -
Flood.IO (
flood.io/) -
Redline.13 (
www.redline13.com/)
如果您在 CI 上没有专用机器,不妨看看他们的网站、价格,并将它们与您直接使用 AWS 可以构建的内容进行比较。这可以帮助您解决性能测试经常遇到的环境和基础设施问题。
Gatling 和持续集成
与 JMeter 类似,Gatling 有自己的 Maven 插件,但它还提供了一些 AWS 集成伴侣插件 (github.com/electronicarts/gatling-aws-maven-plugin),这些插件与 Maven 本地集成。
这是官方 Gatling Maven 插件声明在您的 pom.xml 中的样子:
<plugin>
<groupId>io.gatling</groupId>
<artifactId>gatling-maven-plugin</artifactId>
<version>${gatling-plugin.version}</version>
<executions>
<execution>
<phase>test</phase>
<goals>
<goal>execute</goal>
</goals>
</execution>
</executions>
</plugin>
第一个插件(1)定义了如何运行 Gatling。默认配置将在 src/test/scala 中查找模拟。
此设置将在本地运行您的模拟。因此,您可能会迁移到非官方插件,但与 AWS 集成,以便能够控制注入器。以下是声明可能的样子:
<plugin>
<groupId>com.ea.gatling</groupId>
<artifactId>gatling-aws-maven-plugin</artifactId>
<version>1.0.11</version>
<executions>
<execution>
<phase>test</phase>
<goals>
<goal>execute</goal>
</goals>
</execution>
</executions>
</plugin>
此插件将在 AWS 上集成 Gatling。它需要一些 AWS 配置(如您的密钥),但您通常会在 pom.xml 之外配置它们,以免将凭证放在公共位置——在 settings.xml 中的配置文件中的属性是一个好的开始。以下是您需要定义的属性:
<properties>
<ssh.private.key>${gatling.ssh.private.key}</ssh.private.key>
<ec2.key.pair.name>loadtest-keypair</ec2.key.pair.name>
<ec2.security.group>default</ec2.security.group>
<ec2.instance.count>3</ec2.instance.count>
<gatling.local.home>${project.build.directory}/gatling-charts
-highcharts-bundle-2.2.4/bin/gatling.sh</gatling.local.home>
<gatling.install.script>${project.basedir}/src/test/resources/install-
gatling.sh</gatling.install.script>
<gatling.root>gatling-charts-highcharts-bundle-2.2.4</gatling.root>
<gatling.java.opts>-Xms1g -Xmx16g -Xss4M
-XX:+CMSClassUnloadingEnabled -
XX:MaxPermSize=512M</gatling.java.opts>
<!-- Fully qualified name of the Gatling simulation and a name
describing the test -->
<gatling.simulation>com.FooTest</gatling.simulation>
<gatling.test.name>LoadTest</gatling.test.name>
<!-- (3) -->
<s3.upload.enabled>true</s3.upload.enabled>
<s3.bucket>loadtest-results</s3.bucket>
<s3.subfolder>my-loadtest</s3.subfolder>
</properties>
定义所有这些属性并不能阻止你通过系统属性来更改它们的值。例如,设置 -Dec2.instance.count=9 将允许你更改注入器的数量(从三个变为九个)。第一组属性(ec2 属性)定义了如何创建 AWS 实例以及创建多少个实例。第二组(Gatling 属性)定义了 Gatling 的位置以及如何运行它。第三组定义了要运行的模拟。最后,最后一组(s3 属性)定义了测试结果的上传位置。
此配置尚未启用,因为它尚未自给自足:
-
它依赖于尚未安装的 Gatling 发行版(
gatling.local.home) -
它依赖于我们尚未创建的脚本(
install-gatling.sh)
为了能够不依赖于本地的 Gatling 安装,我们可以使用 Maven 来下载它。为此,我们只需要 Maven 的依赖插件:
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.0.2</version>
<executions>
<execution>
<id>download-gatling-distribution</id>
<phase>generate-test-resources</phase>
<goals>
<goal>unpack</goal>
</goals>
<configuration>
<artifactItems>
<artifactItem>
<groupId>io.gatling.highcharts</groupId>
<artifactId>gatling-charts-highcharts-bundle</artifactId>
<version>2.2.4</version>
<classifier>bundle</classifier>
<type>zip</type>
<overWrite>false</overWrite>
<outputDirectory>${project.build.directory}/gatling</outputDirectory>
<destFileName>gatling-charts-highcharts-bundle
-2.2.4.jar</destFileName>
</artifactItem>
</artifactItems>
<outputDirectory>${project.build.directory}/wars</outputDirectory>
<overWriteReleases>false</overWriteReleases>
<overWriteSnapshots>true</overWriteSnapshots>
</configuration>
</execution>
</executions>
</plugin>
此配置将 Gatling 发行版提取到target/gatling/gatling-charts-highcharts-bundle-2.2.4文件夹中,并在插件运行时使用它。
对于脚本,你可以使用这个,它是为 Fedora 准备的。然而,如果你在 EC2 上选择另一个镜像,它很容易适应任何发行版:
#!/bin/sh
# Increase the maximum number of open files
sudo ulimit -n 65536
echo "* soft nofile 65535" | sudo tee --append /etc/security/limits.conf
echo "* hard nofile 65535" | sudo tee --append /etc/security/limits.conf
sudo yum install --quiet --assumeyes java-1.8.0-openjdk-devel.x86_64 htop screen
# Install Gatling
GATLING_VERSION=2.2.4
URL=https://repo.maven.apache.org/maven2/io/gatling/highcharts/gatling-charts-highcharts-bundle/${GATLING_VERSION}/gatling-charts-highcharts-bundle-${GATLING_VERSION}-bundle.zip
GATLING_ARCHIVE=gatling-charts-highcharts-bundle-${GATLING_VERSION}-bundle.zip
wget --quiet ${URL} -O ${GATLING_ARCHIVE}
unzip -q -o ${GATLING_ARCHIVE}
# Remove example code to reduce Scala compilation time at the beginning of load test
rm -rf gatling-charts-highcharts-bundle-${GATLING_VERSION}/user-files/simulations/computerdatabase/
此脚本执行三个主要任务:
-
增加 ulimit 以确保注入器可以使用足够的文件处理器,而不会受到操作系统配置的限制
-
安装 Java
-
从 Maven 中心下载 Gatling,提取存档(就像我们使用之前的 Maven 插件所做的那样),但是在不需要使用 Maven 的注入器机器上,最后清理提取的存档(移除示例模拟)
如果你需要任何依赖项,你需要创建一个 shade(并使用默认的jar-with-dependencies作为分类器)。你可以使用maven-assembly-plugin和single目标来实现这一点。
部署你的(基准)应用程序
在前两个部分中,我们学习了如何处理注入器,但如果你可能的话,还需要将应用程序部署到测试专用的实例上进行测试。这并不意味着你需要忘记上一章中我们讨论的内容,例如确保机器在生产中使用,以及其他可能影响的服务正在运行。相反,你必须确保机器(们)做你所期望的事情,而不是一些意外的、会影响数据/测试的事情。
在这里,再次使用云服务来部署你的应用程序可能是最简单的解决方案。最简单的解决方案可能依赖于某些云 CLI(例如 AWS CLI 或aws命令),或者你可以使用云提供商的客户端 API 或 SDK 编写的main(String[])。
根据您是否自己编写部署代码(或不是),与 Maven(或 Gradle)构建的集成将更容易或更难。作为您项目的一部分,exec-maven-plugin 可以让您在 Maven 生命周期中精确地集成它。大多数情况下,这将在性能测试之前完成,但在测试编译之后,甚至在打包应用程序之后(如果您将性能测试保持在同一模块中,这是可行的)。
如果您不自己编写部署代码,您将需要定义您的性能构建阶段:
-
编译/打包项目和测试。
-
部署应用程序(并且如果需要,别忘了重置环境,包括清理数据库或 JMS 队列)。
-
开始性能测试。
-
取消部署应用程序/关闭创建的服务器(如果相关)。
使用 Maven 或 Gradle,很容易跳过一些任务,无论是通过标志还是配置文件,因此您最终可能会得到这样的命令:
mvn clean install # (1)
mvn exec:java@deploy-application # (2)
mvn test -Pperf-tests # (3)
mvn exec:java@undeploy-application #(4)
第一个命令 (1) 将构建完整的项目,但绕过性能测试,因为我们默认没有激活 perf-tests 配置文件。第二个命令将使用基于 AWS SDK 的自定义实现将应用程序部署到目标环境,例如,可能从头开始创建它。别忘了记录您所做的一切,即使您正在等待某事,否则您或其他人可能会认为进程挂起了。然后,我们运行性能测试 (3),最后我们使用与 (2) 对称的命令取消部署应用程序。
使用此类解决方案时,您需要确保 (4) 在 (2) 执行后立即执行。通常,您将强制执行它始终执行,并在预期环境不存在时处理快速退出条件。
要编排这些步骤,您可以查看 Jenkins 管道功能:它将为您提供很多灵活性,以直接方式实现此类逻辑。
进一步的内容超出了本书的范围,但为了给您一些提示,部署可以依赖于基于 Docker 的工具,这使得在云平台上部署变得非常容易。然而,别忘了 Docker 不是一个配置工具。如果您的 配方(创建实例的步骤)不简单(从仓库安装软件、复制/下载您的应用程序并启动它),那么您可以考虑投资于 chef 或 puppet 等配置工具,以获得更大的灵活性、更强的功能和避免黑客攻击。
持续集成平台和性能
Jenkins 是最常用的持续集成平台。虽然有一些替代品,如 Travis,但 Jenkins 的生态系统以及扩展的便捷性使其在 Java 和企业应用中成为明显的领导者。
我们在性能构建/测试执行平台上想要解决的首要问题是构建的隔离,显然目标是确保获得的数字不受其他构建的影响。
要做到这一点,Jenkins 提供了几个插件:
-
Amazon EC2 容器服务插件 (
wiki.jenkins.io/display/JENKINS/Amazon+EC2+Container+Service+Plugin): 允许您在基于 Docker 镜像创建的专用机器上运行构建(测试)。 -
限制并发构建插件 (
github.com/jenkinsci/throttle-concurrent-builds-plugin/blob/master/README.md): 允许您控制每个项目可以执行多少个并发构建。具体来说,为了性能,我们希望确保每个项目只有一个。
在配置方面,您需要确保性能测试以准确的配置执行:
-
通常使用 Jenkins 调度,但可能不是每次提交或拉取请求时都这样做。根据项目的关键性、稳定性和性能测试的持续时间,可能是每天一次或每周一次。
-
之前使用的插件或等效插件已正确配置。
-
如果构建失败,它会通知正确的渠道(邮件、Slack、IRC 等)。
确保存储运行历史记录以便进行比较也很重要,尤其是如果您不与每个提交一起运行性能测试,这将给出确切的提交,引入回归。为此,您可以使用另一个 Jenkins 插件,该插件正是为了存储常见性能工具的历史记录:性能插件 (wiki.jenkins.io/display/JENKINS/Performance+Plugin)。此插件支持 Gatling 和 JMeter,以及一些其他工具。这是一个很好的插件,允许您直接从 Jenkins 可视化报告,这在调查某些更改时非常方便。更重要的是,它与 Jenkins 管道脚本兼容。
摘要
在本章中,我们介绍了一些确保您的应用程序性能处于控制之下并限制在进入基准测试阶段或更糟的生产阶段时遇到意外不良惊喜的风险的常见方法。每周(甚至每天)为波动(临时)基准测试设置简单的测试或完整的环境是非常可行的步骤,一旦支付了入门成本,就可以使产品以更高的质量水平交付。
在理解了如何使用 Java EE 仪器化您的应用程序以便您能专注于业务后,如何监控和仪器化您的应用程序以优化您的应用程序,以及如何通过一些调整或缓存来提升您的应用程序后,我们现在知道如何自动控制性能回归,以便能够尽快修复它们。
因此,您现在已经涵盖了与性能相关的所有产品或库创建部分,并且您能够交付高性能的软件。您做到了!


浙公网安备 33010602011771号