原文:
zh.annas-archive.org/md5/2bf50d1e2a61626a8f3de4e5aae60b76
译者:飞龙
第十章:网络
在本章中,我们将介绍以下示例:
-
发出 HTTP GET 请求
-
发出 HTTP POST 请求
-
为受保护的资源发出 HTTP 请求
-
发出异步 HTTP 请求
-
使用 Apache HttpClient 发出 HTTP 请求
-
使用 Unirest HTTP 客户端库发出 HTTP 请求
介绍
Java 对与 HTTP 特定功能进行交互的支持非常原始。自 JDK 1.1 以来可用的HttpURLConnection
类提供了与具有 HTTP 特定功能的 URL 进行交互的 API。由于此 API 甚至在 HTTP/1.1 之前就存在,它缺乏高级功能,并且使用起来很麻烦。这就是为什么开发人员大多倾向于使用第三方库,如Apache HttpClient、Spring 框架和 HTTP API。
在 JDK 9 中,引入了一个新的 HTTP 客户端 API,作为孵化器模块的一部分,名称为java.net.http
,在 JEP 321 (openjdk.java.net/jeps/321
)下被提升为标准模块,这是最新的 JDK 11 版本的一部分。
关于孵化器模块的说明:孵化器模块包含非最终 API,这些 API 非常庞大,不够成熟,无法包含在 Java SE 中。这是 API 的一种测试版发布,使开发人员能够更早地使用 API。但问题是,这些 API 在较新版本的 JDK 中没有向后兼容性支持。这意味着依赖于孵化器模块的代码可能会在较新版本的 JDK 中出现问题。这可能是因为孵化器模块被提升为 Java SE 或在孵化器模块中被悄悄删除。
在本章中,我们将介绍如何在 JDK 11 中使用 HTTP 客户端 API,并介绍一些其他 API,这些 API 使用了 Apache HttpClient (hc.apache.org/httpcomponents-client-ga/
) API 和 Unirest Java HTTP 库 (unirest.io/java.html
)。
发出 HTTP GET 请求
在本示例中,我们将使用 JDK 11 的 HTTP 客户端 API 发出对httpbin.org/get
的GET
请求。
如何做…
- 使用其构建器
java.net.http.HttpClient.Builder
创建java.net.http.HttpClient
的实例:
HttpClient client = HttpClient.newBuilder().build();
- 使用其构建器
java.net.http.HttpRequest.Builder
创建java.net.http.HttpRequest
的实例。请求的 URL 应该作为java.net.URI
的实例提供:
HttpRequest request = HttpRequest
.newBuilder(new URI("http://httpbin.org/get"))
.GET()
.version(HttpClient.Version.HTTP_1_1)
.build();
- 使用
java.net.http.HttpClient
的send
API 发送 HTTP 请求。此 API 需要一个java.net.http.HttpRequest
实例和一个java.net.http.HttpResponse.BodyHandler
的实现:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
- 打印
java.net.http.HttpResponse
状态码和响应体:
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
此代码的完整代码可以在Chapter10/1_making_http_get
中找到。您可以使用运行脚本run.bat
或run.sh
来编译和运行代码:
工作原理…
在向 URL 发出 HTTP 调用时有两个主要步骤:
-
创建 HTTP 客户端以发起调用
-
设置目标 URL、所需的 HTTP 标头和 HTTP 方法类型,即
GET
、POST
或PUT
java.net.http.HttpClient with the default configuration:
HttpClient client = HttpClient.newHttpClient();
java.net.http.HttpClient:
HttpClient client = HttpClient
.newBuilder()
//redirect policy for the client. Default is NEVER
.followRedirects(HttpClient.Redirect.ALWAYS)
//HTTP client version. Defabult is HTTP_2
.version(HttpClient.Version.HTTP_1_1)
//few more APIs for more configuration
.build();
在构建器中还有更多的 API,例如用于设置身份验证、代理和提供 SSL 上下文,我们将在不同的示例中进行讨论。
java.net.http.HttpRequest:
HttpRequest request = HttpRequest
.newBuilder()
.uri(new URI("http://httpbin.org/get")
.headers("Header 1", "Value 1", "Header 2", "Value 2")
.timeout(Duration.ofMinutes(5))
.version(HttpClient.Version.HTTP_1_1)
.GET()
.build();
java.net.http.HttpClient
对象提供了两个 API 来发出 HTTP 调用:
-
您可以使用
HttpClient#send()
方法同步发送 -
您可以使用
HttpClient#sendAsync()
方法异步发送
send()
方法接受两个参数:HTTP 请求和 HTTP 响应的处理程序。响应的处理程序由java.net.http.HttpResponse.BodyHandlers
接口的实现表示。有一些可用的实现,例如ofString()
,它将响应体读取为String
,以及ofByteArray()
,它将响应体读取为字节数组。我们将使用ofString()
方法,它将响应Body
作为字符串返回:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
java.net.http.HttpResponse
的实例表示来自 HTTP 服务器的响应。它提供以下 API:
-
获取响应体(
body()
) -
HTTP 头(
headers()
) -
初始 HTTP 请求(
request()
) -
响应状态码(
statusCode()
) -
用于请求的 URL(
uri()
)
传递给send()
方法的HttpResponse.BodyHandlers
实现有助于将 HTTP 响应转换为兼容格式,例如String
或byte
数组。
发出 HTTP POST 请求
在本示例中,我们将查看通过请求体将一些数据发布到 HTTP 服务。我们将把数据发布到http://httpbin.org/post
的 URL。
我们将跳过类的包前缀,因为假定为java.net.http
。
如何做…
- 使用其
HttpClient.Builder
构建器创建HttpClient
的实例:
HttpClient client = HttpClient.newBuilder().build();
- 创建要传递到请求体中的所需数据:
Map<String, String> requestBody =
Map.of("key1", "value1", "key2", "value2");
- 创建一个
HttpRequest
对象,请求方法为 POST,并提供请求体数据作为String
。我们将使用 Jackson 的ObjectMapper
将请求体Map<String, String>
转换为纯 JSONString
,然后使用HttpRequest.BodyPublishers
处理String
请求体:
ObjectMapper mapper = new ObjectMapper();
HttpRequest request = HttpRequest
.newBuilder(new URI("http://httpbin.org/post"))
.POST(
HttpRequest.BodyPublishers.ofString(
mapper.writeValueAsString(requestBody)
)
)
.version(HttpClient.Version.HTTP_1_1)
.build();
- 使用
send(HttpRequest, HttpRequest.BodyHandlers)
方法发送请求并获取响应:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
- 然后我们打印服务器发送的响应状态码和响应体:
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
此代码的完整代码可以在Chapter10/2_making_http_post
中找到。确保在Chapter10/2_making_http_post/mods
中有以下 Jackson JARs:
-
jackson.databind.jar
-
jackson.core.jar
-
jackson.annotations.jar
还要注意Chapter10/2_making_http_post/src/http.client.demo
中可用的模块定义module-info.java
。
要了解此模块化代码中如何使用 Jackson JAR,请参阅第三章中的自下而上迁移和自上而下迁移示例,模块化编程。
运行脚本run.bat
和run.sh
,用于简化代码的编译和执行:
发出对受保护资源的 HTTP 请求
在本示例中,我们将查看调用已由用户凭据保护的 HTTP 资源。httpbin.org/basic-auth/user/passwd
已通过 HTTP 基本身份验证进行了保护。基本身份验证需要提供明文用户名和密码,然后 HTTP 资源使用它来决定用户身份验证是否成功。
如果您在浏览器中打开httpbin.org/basic-auth/user/passwd
,它将提示您输入用户名和密码:
将用户名输入为user
,密码输入为passwd
,您将被验证并显示 JSON 响应:
{
"authenticated": true,
"user": "user"
}
让我们使用HttpClient
API 实现相同的功能。
如何做…
- 我们需要扩展
java.net.Authenticator
并重写其getPasswordAuthentication()
方法。此方法应返回java.net.PasswordAuthentication
的实例。让我们创建一个类UsernamePasswordAuthenticator
,它扩展java.net.Authenticator
:
public class UsernamePasswordAuthenticator
extends Authenticator{
}
- 我们将在
UsernamePasswordAuthenticator
类中创建两个实例变量来存储用户名和密码,并提供一个构造函数来初始化它:
private String username;
private String password;
public UsernamePasswordAuthenticator(){}
public UsernamePasswordAuthenticator ( String username,
String password){
this.username = username;
this.password = password;
}
- 然后我们将重写
getPasswordAuthentication()
方法,返回一个用用户名和密码初始化的java.net.PasswordAuthentication
的实例:
@Override
protected PasswordAuthentication getPasswordAuthentication(){
return new PasswordAuthentication(username,
password.toCharArray());
}
- 然后我们将创建一个
UsernamePasswordAuthenticator
的实例:
String username = "user";
String password = "passwd";
UsernamePasswordAuthenticator authenticator =
new UsernamePasswordAuthenticator(username, password);
- 在初始化
HttpClient
时,我们提供UsernamePasswordAuthenticator
的实例:
HttpClient client = HttpClient.newBuilder()
.authenticator(authenticator)
.build();
- 创建一个对受保护的 HTTP 资源
httpbin.org/basic-auth/user/passwd
的HttpRequest
对象:
HttpRequest request = HttpRequest.newBuilder(new URI(
"http://httpbin.org/basic-auth/user/passwd"
))
.GET()
.version(HttpClient.Version.HTTP_1_1)
.build();
- 我们通过执行请求来获取
HttpResponse
,并打印状态码和请求体:
HttpResponse<String> response = client.send(request,
HttpResponse.BodyHandlers.ofString());
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
这个配方的完整代码可以在Chapter10/3_making_http_request_protected_res
中找到。您可以使用run.bat
或run.sh
脚本来运行代码:
它是如何工作的…
Authenticator
对象被网络调用使用来获取认证信息。开发人员通常扩展java.net.Authenticator
类,并重写其getPasswordAuthentication()
方法。用户名和密码要么从用户输入中读取,要么从配置中读取,并由扩展类用来创建java.net.PasswordAuthentication
的实例。
在这个配方中,我们创建了java.net.Authenticator
的扩展,如下所示:
public class UsernamePasswordAuthenticator
extends Authenticator{
private String username;
private String password;
public UsernamePasswordAuthenticator(){}
public UsernamePasswordAuthenticator ( String username,
String password){
this.username = username;
this.password = password;
}
@Override
protected PasswordAuthentication getPasswordAuthentication(){
return new PasswordAuthentication(username,
password.toCharArray());
}
}
然后将UsernamePasswordAuthenticator
的实例提供给HttpClient.Builder
API。HttpClient
实例利用这个验证器来获取用户名和密码,同时调用受保护的 HTTP 请求。
进行异步 HTTP 请求
在这个配方中,我们将看看如何进行异步的GET
请求。在异步请求中,我们不等待响应;相反,我们在客户端接收到响应时处理响应。在 jQuery 中,我们将进行异步请求,并提供一个回调来处理响应,而在 Java 的情况下,我们会得到一个java.util.concurrent.CompletableFuture
的实例,然后我们调用thenApply
方法来处理响应。让我们看看它是如何工作的。
如何做…
- 使用其构建器
HttpClient.Builder
创建HttpClient
的实例:
HttpClient client = HttpClient.newBuilder().build();
- 使用
HttpRequest.Builder
的构建器创建HttpRequest
的实例,表示要使用的 URL 和相应的 HTTP 方法:
HttpRequest request = HttpRequest
.newBuilder(new URI("http://httpbin.org/get"))
.GET()
.version(HttpClient.Version.HTTP_1_1)
.build();
- 使用
sendAsync
方法进行异步 HTTP 请求,并保留我们获取的CompletableFuture<HttpResponse<String>>
对象的引用。我们将使用这个对象来处理响应:
CompletableFuture<HttpResponse<String>> responseFuture =
client.sendAsync(request,
HttpResponse.BodyHandlers.ofString());
- 我们提供
CompletionStage
来处理响应,一旦前一个阶段完成。为此,我们使用thenAccept
方法,它接受一个 lambda 表达式:
CompletableFuture<Void> processedFuture =
responseFuture.thenAccept(response -> {
System.out.println("Status code: " + response.statusCode());
System.out.println("Response Body: " + response.body());
});
- 等待未来完成:
CompletableFuture.allOf(processedFuture).join();
这个配方的完整代码可以在Chapter10/4_async_http_request
中找到。我们提供了run.bat
和run.sh
脚本来编译和运行这个配方:
使用 Apache HttpClient 进行 HTTP 请求
在这个配方中,我们将使用 Apache HttpClient (hc.apache.org/httpcomponents-client-4.5.x/index.html
)库来进行简单的 HTTP GET
请求。由于我们使用的是 Java 9,我们希望使用模块路径而不是类路径。因此,我们需要将 Apache HttpClient 库模块化。实现这一目标的一种方法是使用自动模块的概念。让我们看看如何为这个配方设置依赖关系。
准备就绪
所有必需的 JAR 文件已经存在于Chapter10/5_apache_http_demo/mods
中:
一旦这些 JAR 文件在模块路径上,我们可以在module-info.java
中声明对这些 JAR 文件的依赖关系,该文件位于Chapter10/5_apache_http_demo/src/http.client.demo
中,如下面的代码片段所示:
module http.client.demo{
requires httpclient;
requires httpcore;
requires commons.logging;
requires commons.codec;
}
如何做…
- 使用其
org.apache.http.impl.client.HttpClients
工厂创建org.http.client.HttpClient
的默认实例:
CloseableHttpClient client = HttpClients.createDefault();
- 创建
org.apache.http.client.methods.HttpGet
的实例以及所需的 URL。这代表了 HTTP 方法类型和请求的 URL:
HttpGet request = new HttpGet("http://httpbin.org/get");
- 使用
HttpClient
实例执行 HTTP 请求以获取CloseableHttpResponse
的实例:
CloseableHttpResponse response = client.execute(request);
执行 HTTP 请求后返回的CloseableHttpResponse
实例可用于获取响应状态码和嵌入在HttpEntity
实现实例中的响应内容等详细信息。
- 我们使用
EntityUtils.toString()
来获取嵌入在HttpEntity
实现实例中的响应体,并打印状态码和响应体:
int statusCode = response.getStatusLine().getStatusCode();
String responseBody =
EntityUtils.toString(response.getEntity());
System.out.println("Status code: " + statusCode);
System.out.println("Response Body: " + responseBody);
此示例的完整代码可以在Chapter10/5_apache_http_demo
中找到。我们提供了run.bat
和run.sh
来编译和执行示例代码:
还有更多…
在调用HttpClient.execute
方法时,我们可以提供自定义的响应处理程序,如下所示:
String responseBody = client.execute(request, response -> {
int status = response.getStatusLine().getStatusCode();
HttpEntity entity = response.getEntity();
return entity != null ? EntityUtils.toString(entity) : null;
});
在这种情况下,响应由响应处理程序处理并返回响应体字符串。完整的代码可以在Chapter10/5_1_apache_http_demo_response_handler
中找到。
使用 Unirest HTTP 客户端库进行 HTTP 请求
在本示例中,我们将使用 Unirest HTTP (unirest.io/java.html
) Java 库来访问 HTTP 服务。Unirest Java 是一个基于 Apache 的 HTTP 客户端库的库,并提供了一个流畅的 API 来进行 HTTP 请求。
准备工作
由于 Java 库不是模块化的,我们将利用自动模块的概念,如第三章 模块化编程中所解释的。该库的 JAR 文件被放置在应用程序的模块路径上,然后应用程序通过使用 JAR 的名称作为其模块名称来声明对 JAR 的依赖关系。这样,JAR 文件就会自动成为一个模块,因此被称为自动模块。
Java 库的 Maven 依赖如下:
<dependency>
<groupId>com.mashape.unirest</groupId>
<artifactId>unirest-java</artifactId>
<version>1.4.9</version>
</dependency>
由于我们的示例中没有使用 Maven,我们已经将 JAR 文件下载到了Chapter10/6_unirest_http_demo/mods
文件夹中。
模块定义如下:
module http.client.demo{
requires httpasyncclient;
requires httpclient;
requires httpmime;
requires json;
requires unirest.java;
requires httpcore;
requires httpcore.nio;
requires commons.logging;
requires commons.codec;
}
操作步骤如下…
Unirest 提供了一个非常流畅的 API 来进行 HTTP 请求。我们可以按如下方式进行GET
请求:
HttpResponse<JsonNode> jsonResponse =
Unirest.get("http://httpbin.org/get")
.asJson();
可以从jsonResponse
对象中获取响应状态和响应体:
int statusCode = jsonResponse.getStatus();
JsonNode jsonBody = jsonResponse.getBody();
我们可以进行POST
请求并传递一些数据,如下所示:
jsonResponse = Unirest.post("http://httpbin.org/post")
.field("key1", "val1")
.field("key2", "val2")
.asJson();
我们可以按如下方式调用受保护的 HTTP 资源:
jsonResponse = Unirest.get("http://httpbin.org/basic-auth/user/passwd")
.basicAuth("user", "passwd")
.asJson();
该代码可以在Chapter10/6_unirest_http_demo
中找到。
我们提供了run.bat
和run.sh
脚本来执行代码。
还有更多…
Unirest Java 库提供了更多高级功能,例如进行异步请求、文件上传和使用代理。建议您尝试该库的不同功能。
第十一章:内存管理和调试
在本章中,我们将涵盖以下内容:
-
了解 G1 垃圾收集器
-
JVM 的统一日志记录
-
使用
jcmd
命令进行 JVM -
使用 try-with-resources 来更好地处理资源
-
为了改进调试,堆栈遍历
-
使用内存感知的编码风格
-
更好地使用内存的最佳实践
-
了解 Epsilon,一种低开销的垃圾收集器
介绍
内存管理是程序执行的内存分配过程,以及一些分配的内存不再使用后的内存重用。在 Java 中,这个过程被称为垃圾收集(GC)。GC 的有效性影响两个主要应用特性——响应性和吞吐量。
响应性是指应用程序对请求的快速响应程度。例如,一个网站返回页面的速度,或者桌面应用程序对事件的快速响应。自然地,响应时间越短,用户体验就越好,这是许多应用程序的目标。
吞吐量表示应用程序在单位时间内可以完成的工作量。例如,一个 Web 应用程序可以提供多少请求,或者一个数据库可以支持多少交易。数字越大,应用程序可能产生的价值就越大,可以容纳的用户数量也越多。
并非每个应用程序都需要具有最小的响应性和最大的可实现吞吐量。一个应用程序可能是异步提交并执行其他操作,不需要太多用户交互。可能也只有少数潜在的应用程序用户,因此低于平均水平的吞吐量可能已经足够了。然而,有些应用程序对这些特性中的一个或两个有很高的要求,并且无法容忍 GC 过程带来的长时间暂停。
另一方面,GC 需要偶尔停止任何应用程序执行,重新评估内存使用情况,并释放不再使用的数据。这些 GC 活动期间被称为停止-世界。它们持续的时间越长,GC 完成工作的速度越快,应用程序冻结的时间就越长,最终可能会足够大到影响应用程序的响应性和吞吐量。如果情况如此,GC 调优和 JVM 优化变得重要,并需要理解 GC 原则及其现代实现。
不幸的是,程序员经常忽略了这一步。试图改进响应性和/或吞吐量,他们只是增加了内存和其他计算能力,从而为最初较小的现有问题提供了增长的空间。扩大的基础设施,除了硬件和软件成本外,还需要更多的人来维护,最终证明需要建立一个专门的组织来维护系统。到那时,问题已经达到了几乎无法解决的规模,并且通过迫使他们为其余的职业生涯做例行的——几乎是琐碎的——工作,滋养了那些创造它的人。
在本章中,我们将重点关注Garbage-First(G1)垃圾收集器,这是自 Java 9 以来的默认收集器。然而,我们也会提到其他几种可用的 GC 实现,以对比和解释一些设计决策,这些决策使 G1 得以诞生。此外,它们可能比 G1 更适合某些应用程序。
内存组织和管理是 JVM 开发中非常专业和复杂的领域。本书不打算在这个层面上解决实现细节。我们的重点是 GC 的那些方面,可以通过设置 JVM 运行时的相应参数,帮助应用程序开发人员调整应用程序的需求。
GC 使用的两个内存区域是堆和栈。第一个由 JVM 用于分配内存和存储程序创建的对象。当使用new
关键字创建对象时,它位于堆中,并且对它的引用存储在栈中。栈还存储原始变量和当前方法或线程使用的堆对象的引用。栈以后进先出(LIFO)的方式操作。栈比堆小得多。
对于任何 GC 的略微简化但足够好的高层次视图是—遍历堆中的对象并删除那些在堆栈中没有引用的对象。
理解 G1 垃圾收集器
以前的 GC 实现包括串行 GC、并行 GC和并发标记-清除(CMS)收集器。它们将堆分成三个部分—年轻代、老年代或终身代和用于容纳大小为标准区域的 50%或更大的对象的巨大区域。年轻代包含大部分新创建的对象;这是最动态的区域,因为大多数对象的寿命很短,很快(随着它们的年龄)就有资格进行收集。术语年龄指的是对象存活的收集周期数。年轻代有三个收集周期—伊甸空间和两个幸存者空间,如幸存者 0(S0)和幸存者 1(S1)。对象会根据它们的年龄和其他一些特征移动到这些空间中,直到它们最终被丢弃或放入老年代。
老年代包含比一定年龄更老的对象。这个区域比年轻代大,因此这里的垃圾收集更昂贵,发生的频率也不如年轻代频繁。
永久代包含描述应用程序中使用的类和方法的元数据。它还存储字符串、库类和方法。
JVM 启动时,堆是空的,然后对象被推送到伊甸园。当它填满时,一个小的 GC 过程开始。它移除了未引用和循环引用的对象,并将其他对象移动到S0区域。
接下来的小 GC 过程将引用的对象迁移到S1,并增加了在上一次小集合中幸存的对象的年龄。在所有幸存的对象(不同年龄的对象)都移动到S1后,S0和伊甸园都变为空。
在下一次小集合中,S0和S1交换它们的角色。引用的对象从伊甸园移动到S1,从S1移动到S0。
在每次小集合中,已经达到一定年龄的对象被移动到老年代。正如我们之前提到的,老年代最终会被检查(经过几次小集合后),未引用的对象将从中移除,并且内存将被碎片整理。这种对老年代的清理被认为是一次大集合。
永久代由不同的 GC 算法在不同的时间进行清理。
G1 GC 做法略有不同。它将堆分成相等大小的区域,并为每个区域分配相同的角色—伊甸园、幸存者或老年代—但根据需要动态地改变具有相同角色的区域数量。这使得内存清理过程和内存碎片整理更加可预测。
准备就绪
串行 GC 在同一个周期内清理年轻代和老年代(串行地,因此得名)。在执行任务期间,它会停止世界。这就是为什么它适用于只有一个 CPU 和堆大小为几百 MB 的非服务器应用程序。
并行 GC 在所有可用核心上并行工作,尽管线程数量可以进行配置。它也会停止世界,只适用于可以容忍长时间冻结的应用程序。
CMS 收集器旨在解决长时间暂停的问题。它以不对旧一代进行碎片整理和在应用程序执行期间进行一些分析(通常使用 25%的 CPU)为代价。旧一代的收集在其占用空间达到 68%时开始(默认情况下,但此值可以配置)。
G1 GC 算法类似于 CMS 收集器。首先,它并发地识别堆中的所有引用对象并相应地标记它们。然后,它首先收集最空的区域,从而释放大量的空间。这就是为什么它被称为垃圾优先。因为它使用许多小的专用区域,它有更好的机会来预测清理一个区域所需的时间,并适应用户定义的暂停时间(G1 偶尔可能超出,但大多数情况下非常接近)。
G1 的主要受益者是需要大堆(6GB 或更多)且不能容忍长时间暂停(0.5 秒或更短)的应用程序。如果应用程序遇到太多或太长时间的暂停问题,可以从 CMS 或并行 GC(特别是旧一代的并行 GC)切换到 G1 GC 获益。如果不是这种情况,在使用 JDK 9 或更高版本时,切换到 G1 收集器不是必需的。
G1 GC 从年轻代开始收集,使用停顿世界暂停进行疏散(将年轻代内部和旧一代之间的对象移动)。当旧一代的占用达到一定阈值后,也会进行收集。旧一代中的一些对象是并发收集的,而一些对象是使用停顿世界暂停进行收集的。步骤包括以下内容:
-
幸存者区域(根区域)的初始标记,可能引用旧一代的对象,使用停顿世界暂停来完成
-
扫描幸存者区域以查找对旧一代的引用,与此同时应用程序继续运行
-
在整个堆上并发标记活动对象,同时应用程序继续运行
-
备注步骤完成了活动对象的标记,使用停顿世界暂停来完成
-
清理过程计算活动对象的年龄,释放区域(使用停顿世界暂停),并将它们返回到空闲列表(并发进行)
前面的序列可能会与年轻代疏散交错,因为大多数对象的生命周期很短,通过更频繁地扫描年轻代来释放大量内存更容易。
还有一个混合阶段,当 G1 收集已标记为大部分垃圾的年轻代和旧一代的区域时,以及巨大分配,当大对象被移动到或从巨大区域疏散时。
有一些情况下会执行完全 GC,使用停顿世界暂停:
-
并发失败:如果在标记阶段旧一代占满空间
-
提升失败:如果在混合阶段旧一代空间不足时发生
-
疏散失败:当收集器无法将对象提升到幸存者空间和旧一代时发生
-
巨大分配:当应用程序尝试分配一个非常大的对象时发生
如果正确调整,您的应用程序应该避免完全 GC。
为了帮助 GC 调优,JVM 文档(docs.oracle.com/javase/8/docs/technotes/guides/vm/gctuning/ergonomics.html
)描述了人体工程学如下:
“*自适应性是 JVM 和垃圾收集调整的过程,例如基于行为的调整,它提高了应用程序的性能。JVM 为垃圾收集器、堆大小和运行时编译器提供了平台相关的默认选择。这些选择符合不同类型应用程序的需求,同时需要较少的命令行调整。此外,基于行为的调整动态调整堆的大小,以满足应用程序的指定行为。”
如何做…
- 要了解 GC 的工作原理,请编写以下程序:
public class Chapter11Memory {
public static void main(String... args) {
int max = 99_888_999;
System.out.println("Chapter11Memory.main() for "
+ max + " is running...");
List<AnObject> list = new ArrayList<>();
IntStream.range(0, max)
.forEach(i -> list.add(new AnObject(i)));
}
private static class AnObject {
private int prop;
AnObject(int i){ this.prop = i; }
}
}
如您所见,它创建了 99,888,999 个对象,并将它们添加到List<AnObject> list
集合中。您可以通过减少对象的最大数量(max
)来调整它,以匹配您计算机的配置。
- 自 Java 9 以来,G1 GC 是默认收集器,因此如果对您的应用程序足够好,您无需设置任何内容。尽管如此,您可以通过在命令行上提供
-XX:+UseG1GC
来显式启用 G1:
java -XX:+UseG1GC -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
请注意,我们假设您可以构建一个可执行的.jar
文件并理解基本的 Java 执行命令。如果不行,请参考 JVM 文档。
其他可用的 GC 可以通过设置以下选项之一来使用:
-
-XX:+UseSerialGC
用于使用串行收集器。
-
-XX:+UseParallelGC
用于使用带有并行压缩的并行收集器(这使得并行收集器可以并行执行主要收集)。没有并行压缩,主要收集将使用单个线程执行,这可能会严重限制可伸缩性。通过-XX:+UseParallelOldGC
选项禁用并行压缩。 -
-XX:+UseConcMarkSweepGC 用于使用 CMS 收集器。
- 要查看 GC 的日志消息,请设置
-Xlog:gc
。您还可以使用 Unix 实用程序time
来测量完成作业所需的时间(该实用程序会发布输出的最后三行,因此如果您无法或不想使用它,则无需使用它):
time java -Xlog:gc -cp ./cookbook-1.0.jar com.packt.cookbook.ch11_memory.Chapter11Memory
- 运行上述命令。输出可能如下所示(实际值可能在您的计算机上有所不同):
如您所见,GC 经历了我们描述的大部分步骤。它从收集年轻代开始。然后,当List<AnObject> list
对象(请参阅前面的代码)变得太大(超过年轻代区域的 50%以上)时,为其分配内存到巨大区域。您还可以看到初始标记步骤、随后的重新标记和其他先前描述的步骤。
每行以 JVM 运行的时间(以秒为单位)开头,并以每个步骤花费的时间(以毫秒为单位)结尾。在屏幕截图的底部,我们看到了time
实用程序打印的三行:
-
real
是花费的挂钟时间量——自命令运行以来经过的所有时间(应与 JVM 正常运行时间值的第一列对齐)
-
user
是进程中所有 CPU 在用户模式代码(内核外)中花费的时间量;它更大是因为 GC 与应用程序并发工作。 -
sys
是 CPU 在进程内核中花费的时间量 -
user
+sys
是进程使用的 CPU 时间量
- 设置
-XX:+PrintGCDetails
选项(或只需在日志选项-Xlog:gc*
中添加*
)以查看有关 GC 活动的更多详细信息。在以下屏幕截图中,我们仅提供了与 GC 步骤 0 相关的日志的开头:
现在日志中有超过十几个条目,每个 GC 步骤都以记录User
、Sys
和Real
时间量(由time
实用程序累积的时间量)结束。您可以通过添加更多的短寿命对象来修改程序,例如,看看 GC 活动如何改变。
- 使用
-Xlog:gc*=debug
选项获取更多信息。以下仅为输出的一部分:
因此,您可以选择需要多少信息进行分析。
我们将在《JVM 统一日志记录》中讨论日志格式和其他日志选项的更多细节。
工作原理…
正如我们之前提到的,G1 GC 使用默认的人体工程学值,这些值对于大多数应用程序来说可能已经足够好了。以下是最重要的一些值的列表(<ergo>
表示实际值是根据环境人体工程学确定的):
-
-XX:MaxGCPauseMillis=200:保持最大暂停时间的值
-
-XX:GCPauseTimeInterval=:保持 GC 步骤之间的最大暂停时间(默认情况下未设置,允许 G1 在需要时连续执行垃圾收集)
-
-XX:ParallelGCThreads=:保持在垃圾收集暂停期间用于并行工作的最大线程数(默认情况下,从可用线程数派生;如果可用于进程的 CPU 线程数小于或等于八,它使用这个数字;否则,它将大于八的五分之八的线程添加到最终线程数中)
-
-XX:ConcGCThreads=:保持用于并发工作的最大线程数(默认设置为
-XX:ParallelGCThreads
除以四)。 -
-XX:+G1UseAdaptiveIHOP:表示启动堆占用应该是自适应的
-
-XX:InitiatingHeapOccupancyPercent=45:设置了最初的几个收集周期;G1 将使用老年代 45%的占用作为标记开始阈值
-
-XX:G1HeapRegionSize=:根据初始和最大堆大小保持堆区域大小(默认情况下,因为堆包含大约 2048 个堆区域,堆区域的大小可以从 1 到 32 MB 不等,并且必须是 2 的幂)
-
-XX:G1NewSizePercent=5 和-XX:XX:G1MaxNewSizePercent=60:定义了年轻代的总大小,它们作为当前 JVM 堆使用百分比在这两个值之间变化
-
-XX:G1HeapWastePercent=5:保持收集集候选对象中允许的未回收空间的百分比(如果收集集候选对象中的空闲空间低于此值,G1 将停止空间回收)
-
-XX:G1MixedGCCountTarget=8:保持空间回收阶段的预期长度(以收集次数计算)
-
-XX:G1MixedGCLiveThresholdPercent=85:保持老年代区域中存活对象占用的百分比,超过这个百分比的区域将不会在空间回收阶段被收集
一般来说,默认配置下 G1 的目标是“在高吞吐量下提供相对较小、均匀的暂停”(来自 G1 文档)。如果这些默认设置不适合您的应用程序,您可以改变暂停时间(使用-XX:MaxGCPauseMillis
)和最大 Java 堆大小(使用-Xmx
选项)。但请注意,实际的暂停时间在运行时不会完全匹配,但 G1 会尽力满足目标。
如果您想增加吞吐量,可以减少暂停时间目标或请求更大的堆。要增加响应性,改变暂停时间值。但请注意,限制年轻代大小(使用-Xmn
,-XX:NewRatio
或其他选项)可能会妨碍暂停时间控制,因为“年轻代大小是 G1 允许其满足暂停时间的主要手段”(来自 G1 文档)。
性能不佳的一个可能原因是由于老年代堆占用过高而触发了 Full GC。这种情况可以通过日志中出现*Pause Full (Allocation failure)*来检测到。通常发生在对象快速创建过多(无法及时回收)或者许多大型(巨大)对象无法及时分配的情况下。有几种推荐的处理这种情况的方法:
-
在出现过多的巨大对象的情况下,尝试通过增加区域大小,使用
-XX:G1HeapRegionSize
选项来减少它们的数量(当前选择的堆区域大小在日志开头打印出来)。 -
增加堆的大小。
-
通过设置
-XX:ConcGCThreads
增加并发标记线程的数量。 -
通过修改
-XX:G1ReservePercent
增加自适应 IHOP 计算中使用的缓冲区,或者通过-XX:-G1UseAdaptiveIHOP
和-XX:InitiatingHeapOccupancyPercent
手动设置禁用 IHOP 的自适应计算,促进更早的标记开始(利用 G1 基于更早应用行为做出决策的事实)。
只有在解决了完整的 GC 后,才能开始调整 JVM 以获得更好的响应和/或吞吐量。JVM 文档确定了以下情况需要调整响应性:
-
异常系统或实时使用
-
引用处理需要太长时间
-
仅年轻代收集需要太长时间
-
混合集合需要太长时间
-
高更新 RS 和扫描 RS 时间
通过减少总暂停时间和暂停频率来实现更好的吞吐量。请参考 JVM 文档以识别和建议减轻问题。
JVM 的统一日志记录
JVM 的主要组件包括以下内容:
-
类加载器
-
JVM 内存,运行时数据存储在其中;它分为以下几个区域:
-
堆栈区域
-
方法区域
-
堆区域
-
PC 寄存器
-
本地方法栈
-
执行引擎,包括以下部分:
-
解释器
-
JIT 编译器
-
垃圾收集
-
本地方法接口 JNI
-
本地方法库
现在可以使用统一日志记录所有这些组件的日志消息,并通过-Xlog
选项打开。
新日志系统的主要特点如下:
-
日志级别的使用——
trace
、debug
、info
、warning
、error
-
标识 JVM 组件、操作或特定感兴趣消息的消息标签
-
三种输出类型——
stdout
、stderr
和file
-
强制每行限制一个消息
准备工作
要一目了然地查看所有日志可能性,可以运行以下命令:
java -Xlog:help
以下是输出:
如您所见,-Xlog
选项的格式定义如下:
-Xlog[:[what][:[output][:[decorators][:output-options]]]]
让我们详细解释一下这个选项:
-
what
是tag1[+tag2...][*][=level][,...]
形式的标签和级别的组合。我们已经演示了当我们在-Xlog:gc*=debug
选项中使用gc
标签时,这个结构是如何工作的。通配符(*
)表示您想要查看所有具有gc
标签的消息(可能是其他标签中的一部分)。-Xlog:gc=debug
中缺少通配符表示您只想看到由一个标签(在本例中为gc
)标记的消息。如果只使用-Xlog
,日志将以info
级别显示所有消息。 -
output
设置输出类型(默认为stdout
)。 -
decorators
指示日志每行的开头将放置什么(在实际日志消息来自组件之前)。默认的decorators
是uptime
、level
和tags
,每个都包含在方括号中。 -
output_options
可能包括filecount=file count
和/或filesize=file size
,可选的 K、M 或 G 后缀。
总之,默认的日志配置如下:
-Xlog:all=info:stdout:uptime,level,tags
如何做…
让我们运行一些日志设置:
- 运行以下命令:
java -Xlog:cpu -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
没有消息是因为 JVM 不仅使用cpu
标签记录消息。该标签与其他标签结合使用。
- 添加
*
号并再次运行命令:
java -Xlog:cpu* -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
结果如下:
如您所见,cpu
标签只会显示垃圾收集执行所需的时间。即使我们将日志级别设置为trace
或debug
(例如-Xlog:cpu*=debug
),也不会显示其他消息。
- 使用
heap
标签运行命令:
java -Xlog:heap* -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
您将只收到与堆相关的消息:
但让我们仔细看看第一行。它以三个装饰符开头——uptime
、log level
和tags
——然后是消息本身,它以收集周期编号(在本例中为 0)开头,以及 Eden 区域的数量从 24 下降到 0(现在的数量为 9)的信息。这是因为(正如我们在下一行中看到的那样)幸存者区域的数量从 0 增加到 3,老年代的数量(第三行)增加到 18,而巨大区域的数量(23)没有改变。这些都是第一个收集周期中与堆相关的消息。然后,第二个收集周期开始。
- 再次添加
cpu
标签并运行:
java -Xlog:heap*,cpu* -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
如您所见,cpu
消息显示了每个周期的持续时间:
-
尝试使用通过
+
符号组合的两个标签(例如-Xlog:gc+heap
)。它只会显示具有这两个标签的消息(类似于二进制的AND
操作)。请注意,通配符将无法与+
符号一起使用(例如,-Xlog:gc*+heap
不起作用)。 -
您还可以选择输出类型和装饰符。实际上,装饰符级别似乎并不是非常信息丰富,可以通过明确列出仅需要的装饰符来轻松省略。考虑以下示例:
java -Xlog:heap*,cpu*::uptime,tags -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
注意如何插入两个冒号(::
)以保留输出类型的默认设置。我们也可以明确显示它:
java -Xlog:heap*,cpu*:stdout:uptime,tags -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
要删除任何装饰,可以将它们设置为none
:
java -Xlog:heap*,cpu*::none -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
新日志系统最有用的方面是标签选择。它允许更好地分析每个 JVM 组件及其子系统的内存演变,或者找到性能瓶颈,分析在每个收集阶段花费的时间——这两者对于 JVM 和应用程序调优都至关重要。
使用 JVM 的 jcmd 命令
如果打开 Java 安装的bin
文件夹,您可以在那里找到相当多的命令行实用程序,可用于诊断问题并监视使用Java Runtime Environment(JRE)部署的应用程序。它们使用不同的机制来获取它们报告的数据。这些机制特定于虚拟机(VM)实现、操作系统和版本。通常,这些工具的子集仅适用于特定问题。
在本示例中,我们将重点放在 Java 9 中引入的诊断命令,即命令行实用程序jcmd
。如果bin
文件夹在路径上,您可以通过在命令行上键入jcmd
来调用它。否则,您必须转到bin
目录,或者在我们的示例中在jcmd
之前加上bin
文件夹的完整路径或相对路径(相对于您的命令行窗口的位置)。
如果您输入它,而机器上当前没有运行 Java 进程,您将只收到一行,如下所示:
87863 jdk.jcmd/sun.tools.jcmd.JCmd
它显示当前只有一个 Java 进程正在运行(jcmd
实用程序本身),并且它具有进程标识符(PID)为 87863(每次运行时都会有所不同)。
让我们运行一个 Java 程序,例如:
java -cp ./cookbook-1.0.jar
com.packt.cookbook.ch11_memory.Chapter11Memory
jcmd
的输出将显示(具有不同 PID)以下内容:
87864 jdk.jcmd/sun.tools.jcmd.JCmd
87785 com.packt.cookbook.ch11_memory.Chapter11Memory
如您所见,如果没有任何选项输入,jcmd
实用程序将报告所有当前运行的 Java 进程的 PID。获取 PID 后,您可以使用jcmd
从运行该进程的 JVM 请求数据:
jcmd 88749 VM.version
或者,您可以避免使用 PID(并且不带参数调用jcmd
)通过引用应用程序的主类来引用该进程:
jcmd Chapter11Memory VM.version
您可以阅读 JVM 文档,以获取有关jcmd
实用程序及其用法的更多详细信息。
如何做…
jcmd
是一个允许我们向指定的 Java 进程发出命令的实用程序:
- 通过执行以下行,可以获取特定 Java 进程可用的
jcmd
命令的完整列表:
jcmd PID/main-class-name help
在PID/main-class
的位置,放置进程标识符或主类名称。该列表特定于 JVM,因此每个列出的命令都会从特定进程请求数据。
- 在 JDK 8 中,以下
jcmd
命令是可用的:
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
JDK 9 引入了以下jcmd
命令(JDK 18.3 和 JDK 18.9 没有添加新命令):
-
Compiler.queue
: 打印排队等待使用 C1 或 C2 编译的方法(分别排队)
-
Compiler.codelist
: 打印 n 个(已编译的)方法的完整签名、地址范围和状态(活动、非进入和僵尸),并允许选择打印到stdout
、文件、XML 或文本输出 -
Compiler.codecache
: 打印代码缓存的内容,即 JIT 编译器存储生成的本机代码以提高性能的地方 -
Compiler.directives_add file
: 从文件向指令栈顶部添加编译器指令 -
Compiler.directives_clear
: 清除编译器指令栈(仅保留默认指令) -
Compiler.directives_print
: 从顶部到底部打印编译器指令栈上的所有指令 -
Compiler.directives_remove
: 从编译器指令栈中移除顶部指令 -
GC.heap_info
: 打印当前堆参数和状态 -
GC.finalizer_info
: 显示终结器线程的状态,该线程收集具有终结器(即finalize()
方法)的对象 -
JFR.configure
: 允许我们配置 Java Flight Recorder -
JVMTI.data_dump
: 打印 Java 虚拟机工具接口数据转储 -
JVMTI.agent_load
: 加载(附加)Java 虚拟机工具接口代理 -
ManagementAgent.status
: 打印远程 JMX 代理的状态 -
Thread.print
: 打印所有带有堆栈跟踪的线程 -
VM.log [option]
: 允许我们在 JVM 启动后(可以通过使用VM.log list
查看可用性)在运行时设置 JVM 日志配置(我们在前面的配方中描述了) -
VM.info
: 打印统一的 JVM 信息(版本和配置)、所有线程及其状态的列表(不包括线程转储和堆转储)、堆摘要、JVM 内部事件(GC、JIT、安全点等)、加载的本机库的内存映射、VM 参数和环境变量,以及操作系统和硬件的详细信息 -
VM.dynlibs
: 打印动态库的信息 -
VM.set_flag
: 允许我们设置 JVM 的可写(也称为可管理)标志(请参阅 JVM 文档以获取标志列表) -
VM.stringtable
和VM.symboltable
: 打印所有 UTF-8 字符串常量 -
VM.class_hierarchy [full-class-name]
: 打印所有已加载的类或指定类层次结构 -
VM.classloader_stats
: 打印有关类加载器的信息 -
VM.print_touched_methods
: 打印在运行时已被访问(至少已被读取)的所有方法
正如您所看到的,这些新命令属于几个组,由前缀编译器、垃圾收集器(GC)、Java Flight Recorder(JFR)、Java 虚拟机工具接口(JVMTI)、管理代理(与远程 JMX 代理相关)、线程和VM表示。在本书中,我们没有足够的空间来详细介绍每个命令。我们只会演示一些实用命令的用法。
工作原理…
- 要获取
jcmd
实用程序的帮助,请运行以下命令:
jcmd -h
以下是命令的结果:
它告诉我们,命令也可以从-f
之后指定的文件中读取,并且有一个PerfCounter.print
命令,它打印进程的所有性能计数器(统计信息)。
- 运行以下命令:
jcmd Chapter11Memory GC.heap_info
输出可能看起来像这个屏幕截图:
它显示了总堆大小及其使用量,年轻代中区域的大小和分配的区域数量,以及Metaspace
和class space
的参数。
- 以下命令在您寻找失控线程或想了解幕后发生了什么时非常有帮助:
jcmd Chapter11Memory Thread.print
以下是可能输出的片段:
- 这个命令可能是最常用的,因为它提供了关于硬件、整个 JVM 进程以及其组件当前状态的丰富信息:
jcmd Chapter11Memory VM.info
它以摘要开始,如下所示:
接下来是一般的过程描述:
然后是堆的详细信息(这只是其中的一小部分):
然后打印编译事件、GC 堆历史、去优化事件、内部异常、事件、动态库、日志选项、环境变量、VM 参数以及运行进程的系统的许多参数。
jcmd
命令深入了解 JVM 进程,有助于调试和调整进程以获得最佳性能和最佳资源使用。
使用try-with-resources更好地处理资源
管理资源是很重要的。任何资源的错误处理(未释放)——例如保持打开的数据库连接和文件描述符——都可能耗尽系统的操作能力。这就是为什么在 JDK 7 中引入了try-with-resources语句。我们在第六章的示例中使用了它,数据库编程:
try (Connection conn = getDbConnection();
Statement st = createStatement(conn)) {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
}
作为提醒,这是getDbConnection()
方法:
Connection getDbConnection() {
PGPoolingDataSource source = new PGPoolingDataSource();
source.setServerName("localhost");
source.setDatabaseName("cookbook");
try {
return source.getConnection();
} catch(Exception ex) {
ex.printStackTrace();
return null;
}
}
这是createStatement()
方法:
Statement createStatement(Connection conn) {
try {
return conn.createStatement();
} catch(Exception ex) {
ex.printStackTrace();
return null;
}
}
这非常有帮助,但在某些情况下,我们仍然需要以旧的方式编写额外的代码,例如,如果有一个接受Statement
对象作为参数的execute()
方法,并且我们希望在使用后立即释放(关闭)它。在这种情况下,代码将如下所示:
void execute(Statement st, String sql){
try {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
} finally {
if(st != null) {
try{
st.close();
} catch (Exception ex) {
ex.printStackTrace();
}
}
}
}
正如您所看到的,其中大部分只是样板复制粘贴代码。
Java 9 引入的新try-with-resources语句通过允许有效地最终变量作为资源来解决了这种情况。
如何做…
- 使用新的try-with-resources语句重写前面的示例:
void execute(Statement st, String sql){
try (st) {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
}
}
正如您所看到的,它更加简洁和专注,无需反复编写关闭资源的琐碎代码。不再需要finally
和额外的try...catch
。
- 如果连接也被传递进来,它也可以放在同一个 try 块中,并在不再需要时立即关闭:
void execute(Connection conn, Statement st, String sql) {
try (conn; st) {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
}
}
它可能适合或不适合您应用程序的连接处理,但通常,这种能力是很方便的。
- 尝试不同的组合,例如以下:
Connection conn = getDbConnection();
Statement st = conn.createStatement();
try (conn; st) {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
}
这种组合也是允许的:
Connection conn = getDbConnection();
try (conn; Statement st = conn.createStatement()) {
st.execute(sql);
} catch (Exception ex) {
ex.printStackTrace();
}
新语句提供了更灵活的编写代码的方式,以满足需求,而无需编写关闭资源的代码行。
唯一的要求如下:
-
try
语句中包含的变量必须是 final 或有效最终
-
资源必须实现
AutoCloseable
接口,其中只包括一个方法:
void close() throws Exception;
它是如何工作的…
为了演示新语句的工作原理,让我们创建自己的资源,实现AutoCloseable
并以与之前示例中的资源类似的方式使用它们。
这是一个资源:
class MyResource1 implements AutoCloseable {
public MyResource1(){
System.out.println("MyResource1 is acquired");
}
public void close() throws Exception {
//Do what has to be done to release this resource
System.out.println("MyResource1 is closed");
}
}
这是第二个资源:
class MyResource2 implements AutoCloseable {
public MyResource2(){
System.out.println("MyResource2 is acquired");
}
public void close() throws Exception {
//Do what has to be done to release this resource
System.out.println("MyResource2 is closed");
}
}
让我们在代码示例中使用它们:
MyResource1 res1 = new MyResource1();
MyResource2 res2 = new MyResource2();
try (res1; res2) {
System.out.println("res1 and res2 are used");
} catch (Exception ex) {
ex.printStackTrace();
}
如果我们运行它,结果将如下:
请注意,在try
语句中列出的第一个资源最后关闭。让我们只做一个改变,并在try
语句中切换引用的顺序:
MyResource1 res1 = new MyResource1();
MyResource2 res2 = new MyResource2();
try (res2; res1) {
System.out.println("res1 and res2 are used");
} catch (Exception ex) {
ex.printStackTrace();
}
输出确认了引用关闭的顺序也发生了变化:
按照相反顺序关闭资源的规则解决了资源之间可能存在的最重要的依赖问题,但是由程序员定义关闭资源的顺序(通过在try
语句中按正确顺序列出它们)是程序员的责任。幸运的是,大多数标准资源的关闭都由 JVM 优雅地处理,如果资源按照不正确的顺序列出,代码不会中断。但是,按照创建顺序列出它们是一个好主意。
用于改进调试的堆栈遍历
堆栈跟踪在找出问题的根源时非常有帮助。当可能进行自动更正时,需要以编程方式读取它。
自 Java 1.4 以来,可以通过java.lang.Thread
和java.lang.Throwable
类访问当前堆栈跟踪。您可以在代码的任何方法中添加以下行:
Thread.currentThread().dumpStack();
您还可以添加以下行:
new Throwable().printStackTrace();
它将堆栈跟踪打印到标准输出。或者,自 Java 8 以来,您可以使用以下任一行达到相同的效果:
Arrays.stream(Thread.currentThread().getStackTrace())
.forEach(System.out::println);
Arrays.stream(new Throwable().getStackTrace())
.forEach(System.out::println);
或者您可以使用以下任一行提取调用者类的完全限定名称:
System.out.println("This method is called by " + Thread.currentThread()
.getStackTrace()[1].getClassName());
System.out.println("This method is called by " + new Throwable()
.getStackTrace()[0].getClassName());
所有上述解决方案都是可能的,因为java.lang.StackTraceElement
类代表堆栈跟踪中的堆栈帧。该类提供其他描述由此堆栈跟踪元素表示的执行点的方法,这允许以编程方式访问堆栈跟踪信息。例如,您可以在程序的任何位置运行此代码片段:
Arrays.stream(Thread.currentThread().getStackTrace())
.forEach(e -> {
System.out.println();
System.out.println("e="+e);
System.out.println("e.getFileName()="+ e.getFileName());
System.out.println("e.getMethodName()="+ e.getMethodName());
System.out.println("e.getLineNumber()="+ e.getLineNumber());
});
或者您可以在程序的任何位置运行以下内容:
Arrays.stream(new Throwable().getStackTrace())
.forEach(x -> {
System.out.println();
System.out.println("x="+x);
System.out.println("x.getFileName()="+ x.getFileName());
System.out.println("x.getMethodName()="+ x.getMethodName());
System.out.println("x.getLineNumber()="+ x.getLineNumber());
});
不幸的是,这些丰富的数据是有代价的。JVM 捕获整个堆栈(除了隐藏的堆栈帧),并且在程序堆栈跟踪的程序化分析嵌入主应用程序流程的情况下,可能会影响应用程序性能。与此同时,您只需要这些数据的一小部分来做出决策。
这就是新的 Java 9 类java.lang.StackWalker
以及其嵌套的Option
类和StackFrame
接口派上用场的地方。
准备就绪
StackWalker
类有四个重载的getInstance()
静态工厂方法:
-
StackWalker getInstance()
: 这是配置为跳过所有隐藏帧的实例,并且不保留调用者类引用。隐藏帧包含 JVM 内部实现特定的信息。不保留调用者类引用意味着在StackWalker
对象上调用getCallerClass()
方法会抛出UnsupportedOperationException
。 -
StackWalker getInstance(StackWalker.Option option)
: 这将创建一个具有给定选项的实例,指定它可以访问的堆栈帧信息。 -
StackWalker getInstance(Set<StackWalker.Option> options)
: 这将创建一个具有给定选项集的实例,指定它可以访问的堆栈帧信息。如果给定的集合为空,则该实例的配置与StackWalker getInstance()
创建的实例完全相同。 -
StackWalker getInstance(Set<StackWalker.Option> options, int estimatedDepth)
: 这将创建一个与前一个实例类似的实例,并接受estimatedDepth
参数,允许我们估计它可能需要的缓冲区大小。
以下是enum StackWalker.Option
的值:
-
StackWalker.Option.RETAIN_CLASS_REFERENCE
: 配置StackWalker
实例以支持getCallerClass()
方法,并且StackFrame
支持getDeclaringClass()
方法 -
StackWalker.Option.SHOW_HIDDEN_FRAMES
: 配置StackWalker
实例以显示所有反射帧和特定实现帧 -
StackWalker.Option.SHOW_REFLECT_FRAMES
: 配置StackWalker
实例以显示所有反射帧
StackWalker
类还有三个方法:
-
T walk(Function<Stream<StackWalker.StackFrame>, T> function)
: 这将给定的函数应用于当前线程的StackFrames
流,从堆栈顶部遍历帧。顶部帧包含调用此walk()
方法的方法。 -
void forEach(Consumer<StackWalker.StackFrame> action)
: 这对当前线程的StackFrame
流的每个元素执行给定的操作,从堆栈的顶部帧开始,这是调用forEach
方法的方法。此方法相当于调用walk(s -> { s.forEach(action); return null; })
。 -
Class<?> getCallerClass()
: 这获取调用了调用getCallerClass()
方法的方法的Class
对象。如果此StackWalker
实例未配置RETAIN_CLASS_REFERENCE
选项,则此方法会抛出UnsupportedOperationException
。
如何做…
创建几个类和方法,它们将相互调用,这样您就可以执行堆栈跟踪处理:
- 创建一个
Clazz01
类:
public class Clazz01 {
public void method(){
new Clazz03().method("Do something");
new Clazz02().method();
}
}
- 创建一个
Clazz02
类:
public class Clazz02 {
public void method(){
new Clazz03().method(null);
}
}
- 创建一个
Clazz03
类:
public class Clazz03 {
public void method(String action){
if(action != null){
System.out.println(action);
return;
}
System.out.println("Throw the exception:");
action.toString();
}
}
- 编写一个
demo4_StackWalk()
方法:
private static void demo4_StackWalk(){
new Clazz01().method();
}
从Chapter11Memory
类的主方法中调用此方法:
public class Chapter11Memory {
public static void main(String... args) {
demo4_StackWalk();
}
}
如果我们现在运行Chapter11Memory
类,结果将如下所示:
Do something
消息从Clazz01
传递并在Clazz03
中打印出来。然后Clazz02
将 null 传递给Clazz03
,并在action.toString()
行引起的NullPointerException
的堆栈跟踪之前打印出Throw the exception
消息。
它是如何工作的…
为了更深入地理解这里的概念,让我们修改Clazz03
:
public class Clazz03 {
public void method(String action){
if(action != null){
System.out.println(action);
return;
}
System.out.println("Print the stack trace:");
Thread.currentThread().dumpStack();
}
}
结果将如下所示:
或者,我们可以使用Throwable
而不是Thread
来获得类似的输出:
new Throwable().printStackTrace();
前一行产生了这个输出:
每个以下两行将产生类似的结果:
Arrays.stream(Thread.currentThread().getStackTrace())
.forEach(System.out::println);
Arrays.stream(new Throwable().getStackTrace())
.forEach(System.out::println);
自 Java 9 以来,可以使用StackWalker
类实现相同的输出。让我们看看如果我们修改Clazz03
会发生什么:
public class Clazz03 {
public void method(String action){
if(action != null){
System.out.println(action);
return;
}
StackWalker stackWalker = StackWalker.getInstance();
stackWalker.forEach(System.out::println);
}
}
结果如下:
它包含了传统方法产生的所有信息。然而,与在内存中生成和存储完整堆栈跟踪不同,StackWalker
类只带来了请求的元素。这已经是一个很大的优点。然而,StackWalker
的最大优势是,当我们只需要调用者类名时,而不是获取整个数组并仅使用一个元素,我们现在可以通过以下两行获取所需的信息:
System.out.println("Print the caller class name:");
System.out.println(StackWalker.getInstance(StackWalker
.Option.RETAIN_CLASS_REFERENCE)
.getCallerClass().getSimpleName());
上述代码片段的结果如下:
使用内存感知编码风格
在编写代码时,程序员有两个主要目标:
-
实现所需的功能
-
编写易于阅读和理解的代码
然而,在这样做的同时,他们还必须做出许多其他决定,其中之一是使用与标准库类和方法具有类似功能的类。在这个示例中,我们将带您了解一些考虑因素,以帮助避免浪费内存,并使您的代码风格具有内存感知能力:
-
注意在循环内创建的对象
-
使用延迟初始化,在使用之前创建对象,特别是如果有很大的可能性,这种需求根本不会出现
-
不要忘记清理缓存并删除不必要的条目
-
使用
StringBuilder
而不是+
运算符 -
如果符合您的需求,请使用
ArrayList
,然后再使用HashSet
(从ArrayList
到LinkedList
,HashTable
,HashMap
和HashSet
,内存使用量逐渐增加)
如何做…
- 注意在循环内创建的对象。
这个建议非常明显。在快速连续创建和丢弃许多对象可能在垃圾收集器重新利用空间之前消耗太多内存。考虑重用对象而不是每次都创建一个新对象。这里有一个例子:
class Calculator {
public double calculate(int i) {
return Math.sqrt(2.0 * i);
}
}
class SomeOtherClass {
void reuseObject() {
Calculator calculator = new Calculator();
for(int i = 0; i < 100; i++ ){
double r = calculator.calculate(i);
//use result r
}
}
}
前面的代码可以通过使calculate()
方法静态来改进。另一个解决方案是创建SomeOtherClass
类的静态属性Calculator calculator = new Calculator()
。但是静态属性在类第一次加载时就会初始化。如果calculator
属性没有被使用,那么它的初始化将是不必要的开销。在这种情况下,需要添加延迟初始化。
- 使用延迟初始化,在使用之前创建对象,特别是如果有很大的可能性某些请求可能永远不会实现这个需求。
在前面的步骤中,我们谈到了calculator
属性的延迟初始化:
class Calculator {
public double calculate(int i) {
return Math.sqrt(2.0 * i);
}
}
class SomeOtherClass {
private static Calculator calculator;
private static Calculator getCalculator(){
if(this.calculator == null){
this.calculator = new Calculator();
}
return this.calculator;
}
void reuseObject() {
for(int i = 0; i < 100; i++ ){
double r = getCalculator().calculate(i);
//use result r
}
}
}
在前面的示例中,Calculator
对象是一个单例 - 一旦创建,应用程序中就只存在一个实例。如果我们知道calculator
属性总是会被使用,那么就不需要延迟初始化。在 Java 中,我们可以利用静态属性在任何应用程序线程加载类时的第一次初始化。
class SomeOtherClass {
private static Calculator calculator = new Calculator();
void reuseObject() {
for(int i = 0; i < 100; i++ ){
double r = calculator.calculate(i);
//use result r
}
}
}
但是,如果初始化的对象很可能永远不会被使用,我们又回到了可以在单线程中实现的延迟初始化(使用getCalculator()
方法)或者当共享对象是无状态的且其初始化不消耗太多资源时。
在多线程应用程序和复杂对象初始化的情况下,需要采取一些额外措施来避免并发访问冲突,并确保只创建一个实例。例如,考虑以下类:
class ExpensiveInitClass {
private Object data;
public ExpensiveInitClass() {
//code that consumes resources
//and assignes value to this.data
}
public Object getData(){
return this.data;
}
}
如果前面的构造函数需要大量时间来完成对象的创建,那么第二个线程有可能在第一个线程完成对象创建之前进入构造函数。为了避免第二个对象的并发创建,我们需要同步初始化过程:
class LazyInitExample {
public ExpensiveInitClass expensiveInitClass
public Object getData(){ //can synchrnonize here
if(this.expensiveInitClass == null){
synchronized (LazyInitExample.class) {
if (this.expensiveInitClass == null) {
this.expensiveInitClass = new ExpensiveInitClass();
}
}
}
return expensiveInitClass.getData();
}
}
如您所见,我们可以同步访问getData()
方法,但在对象创建后不需要此同步,并且可能在高并发多线程环境中造成瓶颈。同样,我们可以只在同步块内部进行一次空值检查,但在对象初始化后不需要此同步,因此我们用另一个空值检查来减少瓶颈的机会。
- 不要忘记清理缓存并删除不必要的条目。
缓存有助于减少访问数据的时间。但缓存会消耗内存,因此有意义的是尽可能保持它小,同时仍然有用。如何做取决于缓存数据使用的模式。例如,如果你知道一旦使用,存储在缓存中的对象不会再次被使用,你可以在应用程序启动时(或根据使用模式定期)将其放入缓存中,并在使用后从缓存中删除:
static HashMap<String, Object> cache = new HashMap<>();
static {
//populate the cache here
}
public Object getSomeData(String someKey) {
Object obj = cache.get(someKey);
cache.remove(someKey);
return obj;
}
或者,如果您期望每个对象具有很高的可重用性,可以在第一次请求后将其放入缓存中:
static HashMap<String, Object> cache = new HashMap<>();
public Object getSomeData(String someKey) {
Object obj = cache.get(someKey);
if(obj == null){
obj = getDataFromSomeSource();
cache.put(someKey, obj);
}
return obj;
}
前面的情况可能导致缓存无法控制地增长,消耗太多内存,并最终导致OutOfMemoryError
条件。为了防止这种情况,您可以实现一个算法,限制缓存的大小 - 达到一定大小后,每次添加新对象时,都会删除一些其他对象(例如,最常用的对象或最少使用的对象)。以下是将缓存大小限制为 10 的示例,通过删除最常使用的缓存对象:
static HashMap<String, Object> cache = new HashMap<>();
static HashMap<String, Integer> count = new HashMap<>();
public static Object getSomeData(String someKey) {
Object obj = cache.get(someKey);
if(obj == null){
obj = getDataFromSomeSource();
cache.put(someKey, obj);
count.put(someKey, 1);
if(cache.size() > 10){
Map.Entry<String, Integer> max =
count.entrySet().stream()
.max(Map.Entry.comparingByValue(Integer::compareTo))
.get();
cache.remove(max.getKey());
count.remove(max.getKey());
}
} else {
count.put(someKey, count.get(someKey) + 1);
}
return obj;
}
或者,可以使用java.util.WeakHashMap
类来实现缓存:
private static WeakHashMap<Integer, Double> cache
= new WeakHashMap<>();
void weakHashMap() {
int last = 0;
int cacheSize = 0;
for(int i = 0; i < 100_000_000; i++) {
cache.put(i, Double.valueOf(i));
cacheSize = cache.size();
if(cacheSize < last){
System.out.println("Used memory=" +
usedMemoryMB()+" MB, cache=" + cacheSize);
}
last = cacheSize;
}
}
运行上面的示例,您会看到内存使用和缓存大小首先增加,然后下降,然后再次增加,然后再次下降。以下是输出的摘录:
Used memory=1895 MB, cache=2100931
Used memory=189 MB, cache=95658
Used memory=296 MB, cache=271
Used memory=408 MB, cache=153
Used memory=519 MB, cache=350
Used memory=631 MB, cache=129
Used memory=745 MB, cache=2079710
Used memory=750 MB, cache=69590
Used memory=858 MB, cache=213
我们使用的内存使用量计算如下:
long usedMemoryMB() {
return Math.round(
Double.valueOf(Runtime.getRuntime().totalMemory() -
Runtime.getRuntime().freeMemory())/1024/1024
);
}
java.util.WeakHashMap
类是一个具有java.lang.ref.WeakReference
类型键的 Map 实现。只有通过弱引用引用的对象在垃圾收集器决定需要更多内存时才会被回收。这意味着WeakHashMap
对象中的条目将在没有对该键的引用时被移除。当垃圾收集器从内存中移除键时,相应的值也会从地图中移除。
在我们之前的示例中,缓存键都没有在地图之外使用,因此垃圾收集器会自行删除它们。即使我们在地图之外添加对键的显式引用,代码的行为也是相同的:
private static WeakHashMap<Integer, Double> cache
= new WeakHashMap<>();
void weakHashMap() {
int last = 0;
int cacheSize = 0;
for(int i = 0; i < 100_000_000; i++) {
Integer iObj = i;
cache.put(iObj, Double.valueOf(i));
cacheSize = cache.size();
if(cacheSize < last){
System.out.println("Used memory=" +
usedMemoryMB()+" MB, cache=" + cacheSize);
}
last = cacheSize;
}
}
这是因为在之前的代码块中显示的iObj
引用在每次迭代后都被丢弃并被收集,因此缓存中的相应键也没有外部引用,垃圾收集器也会将其删除。为了证明这一点,让我们再次修改上面的代码:
private static WeakHashMap<Integer, Double> cache
= new WeakHashMap<>();
void weakHashMap() {
int last = 0;
int cacheSize = 0;
List<Integer> list = new ArrayList<>();
for(int i = 0; i < 100_000_000; i++) {
Integer iObj = i;
cache.put(iObj, Double.valueOf(i));
list.add(iObj);
cacheSize = cache.size();
if(cacheSize < last){
System.out.println("Used memory=" +
usedMemoryMB()+" MB, cache=" + cacheSize);
}
last = cacheSize;
}
}
我们创建了一个列表,并将地图的每个键添加到其中。如果我们运行上述代码,最终会得到OutOfMemoryError
,因为缓存的键在地图之外有强引用。我们也可以减弱外部引用:
private static WeakHashMap<Integer, Double> cache
= new WeakHashMap<>();
void weakHashMap() {
int last = 0;
int cacheSize = 0;
List<WeakReference<Integer>> list = new ArrayList<>();
for(int i = 0; i < 100_000_000; i++) {
Integer iObj = i;
cache.put(iObj, Double.valueOf(i));
list.add(new WeakReference(iObj));
cacheSize = cache.size();
if(cacheSize < last){
System.out.println("Used memory=" +
usedMemoryMB()+" MB, cache=" + cacheSize +
", list size=" + list.size());
}
last = cacheSize;
}
}
上面的代码现在运行得好像缓存键没有外部引用一样。使用的内存和缓存大小会增长,然后再次下降。但是列表大小不会下降,因为垃圾收集器不会从列表中删除值。因此,最终应用程序可能会耗尽内存。
然而,无论您限制缓存的大小还是让其无法控制地增长,都可能出现应用程序需要尽可能多的内存的情况。因此,如果有一些对应用程序主要功能不是关键的大对象,有时将它们从内存中移除以使应用程序能够生存并避免出现OutOfMemoryError
的情况是有意义的。
如果存在缓存,通常是一个很好的候选对象来释放内存,因此我们可以使用WeakReference
类来包装缓存本身:
private static WeakReference<Map<Integer, Double[]>> cache;
void weakReference() {
Map<Integer, Double[]> map = new HashMap<>();
cache = new WeakReference<>(map);
map = null;
int cacheSize = 0;
List<Double[]> list = new ArrayList<>();
for(int i = 0; i < 10_000_000; i++) {
Double[] d = new Double[1024];
list.add(d);
if (cache.get() != null) {
cache.get().put(i, d);
cacheSize = cache.get().size();
System.out.println("Cache="+cacheSize +
", used memory=" + usedMemoryMB()+" MB");
} else {
System.out.println(i +": cache.get()=="+cache.get());
break;
}
}
}
在上面的代码中,我们将地图(缓存)包装在WeakReference
类中,这意味着我们告诉 JVM 只要没有对它的引用,就可以收集此对象。然后,在每次 for 循环迭代中,我们创建一个new Double[1024]
对象并将其保存在列表中。我们这样做是为了更快地使用完所有可用内存。然后我们将相同的对象放入缓存中。当我们运行此代码时,它会迅速得到以下输出:
Cache=4582, used memory=25 MB
4582: cache.get()==null
这意味着垃圾收集器在使用了 25MB 内存后决定收集缓存对象。如果您认为这种方法太过激进,而且您不需要经常更新缓存,您可以将其包装在java.lang.ref.SoftReference
类中。如果这样做,缓存只有在所有内存用完时才会被收集——就在即将抛出OutOfMemoryError
的边缘。以下是演示它的代码片段:
private static SoftReference<Map<Integer, Double[]>> cache;
void weakReference() {
Map<Integer, Double[]> map = new HashMap<>();
cache = new SoftReference<>(map);
map = null;
int cacheSize = 0;
List<Double[]> list = new ArrayList<>();
for(int i = 0; i < 10_000_000; i++) {
Double[] d = new Double[1024];
list.add(d);
if (cache.get() != null) {
cache.get().put(i, d);
cacheSize = cache.get().size();
System.out.println("Cache="+cacheSize +
", used memory=" + usedMemoryMB()+" MB");
} else {
System.out.println(i +": cache.get()=="+cache.get());
break;
}
}
}
如果我们运行它,输出将如下所示:
Cache=1004737, used memory=4096 MB
1004737: cache.get()==null
没错,在我们的测试计算机上,有 4GB 的 RAM,因此只有在几乎用完所有内存时才会删除缓存。
- 使用
StringBuilder
代替+
运算符。
您可以在互联网上找到许多这样的建议。也有相当多的声明说这个建议已经过时,因为现代 Java 使用StringBuilder
来实现字符串的+
运算符。以下是我们实验的结果。首先,我们运行了以下代码:
long um = usedMemoryMB();
String s = "";
for(int i = 1000; i < 10_1000; i++ ){
s += Integer.toString(i);
s += " ";
}
System.out.println("Used memory: "
+ (usedMemoryMB() - um) + " MB"); //prints: 71 MB
usedMemoryMB()
的实现:
long usedMemoryMB() {
return Math.round(
Double.valueOf(Runtime.getRuntime().totalMemory() -
Runtime.getRuntime().freeMemory())/1024/1024
);
}
然后我们用StringBuilder
来达到同样的目的:
long um = usedMemoryMB();
StringBuilder sb = new StringBuilder();
for(int i = 1000; i < 10_1000; i++ ){
sb.append(Integer.toString(i)).append(" ");
}
System.out.println("Used memory: "
+ (usedMemoryMB() - um) + " MB"); //prints: 1 MB
正如你所看到的,使用+
运算符消耗了 71MB 的内存,而StringBuilder
仅在相同任务中使用了 1MB。我们也测试了StringBuffer
。它也消耗了 1MB,但比StringBuilder
执行稍慢,因为它是线程安全的,而StringBuilder
只能在单线程环境中使用。
所有这些都不适用于长字符串值,该值已被拆分为几个子字符串,以提高可读性。编译器将子字符串收集回一个长值。例如,s1
和s2
字符串占用相同的内存量:
String s1 = "this " +
"string " +
"takes " +
"as much memory as another one";
String s2 = "this string takes as much memory as another one";
- 如果需要使用集合,如果符合你的需求,选择
ArrayList
。从ArrayList
到LinkedList
、HashTable
、HashMap
和HashSet
,内存使用量逐渐增加。
ArrayList
对象将其元素存储在Object[]
数组中,并使用一个int
字段来跟踪列表的大小(除了array.length
)。由于这样的设计,如果有可能这个容量不会被充分使用,那么在声明时不建议分配一个大容量的ArrayList
。当新元素添加到列表中时,后端数组的容量会以 10 个元素的块递增,这可能是浪费内存的一个可能来源。如果这对应用程序很重要,可以通过调用trimToSize()
方法来缩小ArrayList
的容量到当前使用的容量。请注意,clear()
和remove()
方法不会影响ArrayList
的容量,它们只会改变其大小。
其他集合的开销更大,因为它们提供了更多的服务。LinkedList
元素不仅携带对前一个和后一个元素的引用,还携带对数据值的引用。大多数基于哈希的集合实现都专注于更好的性能,这往往是以内存占用为代价的。
如果集合的大小很小,那么选择 Java 集合类可能是无关紧要的。然而,程序员通常使用相同的编码模式,通过其风格可以识别代码的作者。因此,长远来看,找出最有效的构造并经常使用它们是值得的。但是,尽量避免使你的代码难以理解;可读性是代码质量的一个重要方面。
更好地使用内存的最佳实践
内存管理可能永远不会成为你的问题,它可能会成为你每一个清醒的时刻,或者你可能会发现自己处于这两个极端之间。大多数情况下,对于大多数程序员来说,这都不是问题,尤其是随着不断改进的垃圾回收算法。G1 垃圾收集器(JVM 9 中的默认值)绝对是朝着正确方向迈出的一步。但也有可能你会被要求(或者自己注意到)应用程序性能下降的情况,这时你就会了解你有多少能力来应对挑战。
这个示例是为了帮助你避免这种情况或成功摆脱它而做出的尝试。
如何做…
第一道防线是代码本身。在之前的示例中,我们讨论了释放资源的必要性,以及使用StackWalker
来消耗更少的内存。互联网上有很多建议,但它们可能不适用于你的应用程序。你需要监控内存消耗并测试你的设计决策,特别是如果你的代码处理大量数据,然后才决定在哪里集中你的注意力。
一旦你的代码开始做它应该做的事情,就测试和分析你的代码。你可能需要改变你的设计或一些实现的细节。这也会影响你未来的决策。任何环境都有许多分析器和诊断工具可用。我们在使用 jcmd 命令进行 JVM示例中描述了其中的一个,jcmd
。
了解您的垃圾收集器是如何工作的(参见了解 G1 垃圾收集器配方),并且不要忘记使用 JVM 日志记录(在JVM 的统一日志记录配方中描述)。
在那之后,您可能需要调整 JVM 和垃圾收集器。以下是一些经常使用的java
命令行参数(默认情况下,大小以字节指定,但您可以附加字母 k 或 K 表示千字节,m 或 M 表示兆字节,g 或 G 表示千兆字节):
-
-Xms size
:此选项允许我们设置初始堆大小(必须大于 1 MB 且是 1024 的倍数)。 -
-Xmx size
:此选项允许我们设置最大堆大小(必须大于 2 MB 且是 1024 的倍数)。 -
-Xmn size
或-XX:NewSize=size
和-XX:MaxNewSize=size
的组合:此选项允许我们设置年轻代的初始和最大大小。为了有效的 GC,它必须低于-Xmx size
。Oracle 建议将其设置为堆大小的 25%以上但低于 50%。 -
-XX:NewRatio=ratio
:此选项允许我们设置年轻代和老年代之间的比率(默认为两个)。 -
-Xss size
:此选项允许我们设置线程堆栈大小。不同平台的默认值如下: -
Linux/ARM(32 位):320 KB
-
Linux/ARM(64 位):1,024 KB
-
Linux/x64(64 位):1,024 KB
-
macOS(64 位):1,024 KB
-
Oracle Solaris/i386(32 位):320 KB
-
Oracle Solaris/x64(64 位):1,024 KB
-
Windows:取决于虚拟内存
-
-XX:MaxMetaspaceSize=size
:此选项允许我们设置类元数据区的上限(默认情况下没有限制)。
内存泄漏的明显迹象是老年代的增长导致完整 GC 更频繁地运行。要进行调查,您可以使用将堆内存转储到文件的 JVM 参数:
-
-XX:+HeapDumpOnOutOfMemoryError
:允许我们将 JVM 堆内容保存到文件中,但仅当抛出java.lang.OutOfMemoryError
异常时。默认情况下,堆转储保存在当前目录中,名称为java_pid<pid>.hprof
,其中<pid>
是进程 ID。使用-XX:HeapDumpPath=<path>
选项来自定义转储文件位置。<path>
值必须包括文件名。 -
-XX:OnOutOfMemoryError="<cmd args>;<cmd args>"
:允许我们提供一组命令(用分号分隔),当抛出OutOfMemoryError
异常时将执行这些命令。 -
-XX:+UseGCOverheadLimit
:调节 GC 占用时间比例的大小,超过这个比例会抛出OutOfMemoryError
异常。例如,并行 GC 将在 GC 占用时间超过 98%且恢复的堆不到 2%时抛出OutOfMemoryError
异常。此选项在堆较小时特别有用,因为它可以防止 JVM 在几乎没有进展的情况下运行。默认情况下已启用。要禁用它,请使用-XX:-UseGCOverheadLimit
。
了解 Epsilon,一种低开销的垃圾收集器
一个流行的 Java 面试问题是,您能强制进行垃圾收集吗? Java 运行时内存管理仍然不受程序员控制,有时会像一个不可预测的小丑一样打断本来表现良好的应用程序,并启动全内存扫描。它通常发生在最糟糕的时候。当您尝试在负载下使用短时间运行来测量应用程序性能时,后来意识到大量时间和资源都花在了垃圾收集过程上,并且在更改代码后,垃圾收集的模式变得与更改代码之前不同,这尤其令人恼火。
在本章中,我们描述了许多编程技巧和解决方案,可以帮助减轻垃圾收集器的压力。然而,它仍然是应用程序性能的独立和不可预测的贡献者(或减少者)。如果垃圾收集器能够更好地受控制,至少在测试目的中,或者可以关闭,那不是很好吗?在 Java 11 中,引入了一个名为 Epsilon 的垃圾收集器,称为无操作垃圾收集器。
乍一看,这看起来很奇怪——一个不收集任何东西的垃圾收集器。但它是可预测的(这是肯定的),因为它什么也不做,这个特性使我们能够在短时间内测试算法,而不用担心不可预测的暂停。此外,还有一整类需要在短时间内尽可能利用所有资源的小型短期应用程序,最好重新启动 JVM 并让负载均衡器执行故障转移,而不是尝试考虑垃圾收集过程中不可预测的 Joker。
它也被设想为一个基准过程,可以让我们估计常规垃圾收集器的开销。
如何做…
要调用无操作垃圾收集器,请使用-XX:+UseEpsilonGC
选项。在撰写本文时,它需要一个-XX:+UnlockExperimentalVMOptions
选项来访问新功能。
我们将使用以下程序进行演示:
package com.packt.cookbook.ch11_memory;
import java.util.ArrayList;
import java.util.List;
public class Epsilon {
public static void main(String... args) {
List<byte[]> list = new ArrayList<>();
int n = 4 * 1024 * 1024;
for(int i=0; i < n; i++){
list.add(new byte[1024]);
byte[] arr = new byte[1024];
}
}
}
正如您所看到的,在这个程序中,我们试图通过在每次迭代中向列表添加 1KB 数组来分配 4GB 的内存。与此同时,我们还在每次迭代中创建一个 1K 数组arr
,但不使用对它的引用,因此传统的垃圾收集器可以收集它。
首先,我们将使用默认的垃圾收集器运行前面的程序:
time java -cp cookbook-1.0.jar -Xms4G -Xmx4G -Xlog:gc com.packt.cookbook.ch11_memory.Epsilon
请注意,我们已将 JVM 堆内存限制为 4GB,因为出于演示目的,我们希望程序以OutOfMemoryError
退出。我们已经使用time
命令包装了调用以捕获三个值:
-
实际时间:程序运行的时间
-
用户时间:程序使用 CPU 的时间
-
系统时间:操作系统为程序工作的时间
我们使用了 JDK 11:
java -version
java version "11-ea" 2018-09-25
Java(TM) SE Runtime Environment 18.9 (build 11-ea+22)
Java HotSpot(TM) 64-Bit Server VM 18.9 (build 11-ea+22, mixed mode)
在您的计算机上,前面的命令的输出可能会有所不同。在我们的测试运行期间,当我们使用指定的java
命令参数执行前面的程序时,输出以以下四行开头:
Using G1
GC(0) Pause Young (Normal) (G1 Evacuation Pause) 204M->101M(4096M)
GC(1) Pause Young (Normal) (G1 Evacuation Pause) 279M->191M(4096M)
GC(2) Pause Young (Normal) (G1 Evacuation Pause) 371M->280M(4096M)
正如您所看到的,G1 垃圾收集器是 JDK 11 中的默认值,并且它立即开始收集未引用的arr
对象。正如我们所预期的那样,程序在OutOfMemoryError
后退出:
GC(50) Pause Full (G1 Evacuation Pause) 4090M->4083M(4096M)
GC(51) Concurrent Cycle 401.931ms
GC(52) To-space exhausted
GC(52) Pause Young (Concurrent Start) (G1 Humongous Allocation)
GC(53) Concurrent Cycle
GC(54) Pause Young (Normal) (G1 Humongous Allocation) 4088M->4088M(4096M)
GC(55) Pause Full (G1 Humongous Allocation) 4088M->4085M(4096M)
GC(56) Pause Full (G1 Humongous Allocation) 4085M->4085M(4096M)
GC(53) Concurrent Cycle 875.061ms
Exception in thread "main" java.lang.OutOfMemoryError: Java heap space
at java.base/java.util.Arrays.copyOf(Arrays.java:3720)
at java.base/java.util.Arrays.copyOf(Arrays.java:3689)
at java.base/java.util.ArrayList.grow(ArrayList.java:237)
at java.base/java.util.ArrayList.grow(ArrayList.java:242)
at java.base/java.util.ArrayList.add(ArrayList.java:485)
at java.base/java.util.ArrayList.add(ArrayList.java:498)
at com.packt.cookbook.ch11_memory.Epsilon.main(Epsilon.java:12)
时间实用程序产生了以下结果:
real 0m11.549s //How long the program ran
user 0m35.301s //How much time the CPU was used by the program
sys 0m19.125s //How much time the OS worked for the program
我们的计算机是多核的,因此 JVM 能够并行利用多个核心,很可能是用于垃圾收集。这就是为什么用户时间比实际时间长,系统时间也因同样的原因比实际时间长。
现在让我们用以下命令运行相同的程序:
time java -cp cookbook-1.0.jar -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC -Xms4G -Xmx4G -Xlog:gc com.packt.cookbook.ch11_memory.Epsilon
请注意,我们已添加了-XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC
选项,这需要 Epsilon 垃圾收集器。结果如下:
Non-resizeable heap; start/max: 4096M
Using TLAB allocation; max: 4096K
Elastic TLABs enabled; elasticity: 1.10x
Elastic TLABs decay enabled; decay time: 1000ms
Using Epsilon
Heap: 4096M reserved, 4096M (100.00%) committed, 205M (5.01%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 410M (10.01%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 614M (15.01%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 820M (20.02%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1025M (25.02%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1230M (30.03%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1435M (35.04%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1640M (40.04%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 1845M (45.05%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2050M (50.05%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2255M (55.06%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2460M (60.06%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2665M (65.07%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 2870M (70.07%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3075M (75.08%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3280M (80.08%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3485M (85.09%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3690M (90.09%) used
Heap: 4096M reserved, 4096M (100.00%) committed, 3895M (95.10%) used
Terminating due to java.lang.OutOfMemoryError: Java heap space
正如您所看到的,垃圾收集器甚至没有尝试收集被丢弃的对象。堆空间的使用量稳步增长,直到完全耗尽,并且 JVM 以OutOfMemoryError
退出。使用time
实用程序允许我们测量三个时间参数:
real 0m4.239s
user 0m1.861s
sys 0m2.132s
自然地,耗尽所有堆内存所需的时间要少得多,用户时间要比实际时间少得多。这就是为什么,正如我们已经提到的那样,无操作的 Epsilon 垃圾收集器对于那些必须尽可能快速但不会消耗所有堆内存或可以随时停止的程序可能是有用的。可能还有其他垃圾收集器不做任何事情可能有帮助的用例。