Java9 秘籍(四)
八、输入和输出
在应用中,经常需要获得和操作 I/O 终端。在今天的操作系统中,这通常意味着文件访问和网络连接。在以前的版本中,为了保持普遍的兼容性,Java 在采用良好的文件和网络框架方面进展缓慢。坚持“一次写入,随处读取”的原则,许多原始文件 I/O 和网络连接需要简单和通用。自从 Java 7 发布以来,开发人员一直在利用更好的 I/O API。
这些年来,文件和网络 I/O 已经发展成为一个处理文件、网络可伸缩性和易用性更好的框架。从网络输入输出版本 2 API (NIO.2)开始,Java 具有了监控文件夹、访问依赖于操作系统的方法以及创建可伸缩的异步网络套接字的能力。这是对处理输入和输出流以及序列化(和反序列化)对象信息的健壮库的补充。
在这一章中,我们将介绍演示不同输入和输出过程的配方。您将学习文件序列化、通过网络发送文件、文件操作等等。阅读完本章的食谱后,你将具备开发包含复杂输入和输出任务的应用的能力。
流和装饰模式
I/O 流是大多数 Java I/O 的基础,并且包括了几乎任何场合都可以使用的大量现成流,但是如果没有提供一些上下文,使用起来会非常混乱。流(像河流一样)表示数据的流入/流出。这么想吧。当您键入时,您创建了系统接收的字符流(输入流)。当系统产生声音时,它将声音发送到扬声器(输出流)。系统可能整天都在接收击键和发送声音,因此数据流可能在处理数据,也可能在等待更多的数据。
当一个流没有接收到任何数据时,它会等待(没有其他事情可做,对吗?).数据一进来,流就开始处理这些数据。然后,该流停止并等待下一个数据项的到来。这样一直持续下去,直到这条众所周知的河流变干(溪流被封闭)。
像河流一样,流可以相互连接(这就是装饰模式)。对于本章的内容,主要有两个您关心的输入流。其中一个是文件输入流,另一个是网络套接字输入流。这两个流是 I/O 程序的数据源。还有它们对应的输出流:文件输出流和网络套接字输出流(多有创意啊,不是吗?).就像水管工一样,你可以把它们连在一起,创造出新的东西。例如,您可以将一个文件输入流与一个网络输出流焊接在一起,通过网络套接字发送文件内容。或者,您可以反过来将网络输入流(传入的数据)连接到文件输出流(写入磁盘的数据)。在 I/O 术语中,输入流被称为源,而输出流被称为汇。
还有其他输入和输出流可以粘合在一起。比如有 BufferedInputStream,允许你成块读取数据(比一个字节一个字节地读取效率更高),DataOutputStream 允许你把 Java 原语写到一个输出流中(而不是只写字节)。最有用的流之一是 ObjectInputStream 和 ObjectOutputStream 对,它允许你序列化/反序列化对象(见方法 8-1)。
decorator 模式允许您不断地一起提取流,以获得许多不同的效果。这种设计的美妙之处在于,你实际上可以创建一个流,它可以接受任何输入并产生任何输出,然后可以与其他流一起抛出。
8-1.序列化 Java 对象
问题
您需要序列化一个类(保存该类的内容),以便以后可以恢复它。
解决办法
Java 实现了内置的序列化机制。您可以通过 ObjectOutputStream 类访问该机制。在以下示例中,saveSettings()方法使用 ObjectOutputStream 来序列化 Settings 对象,为将该对象写入磁盘做准备:
public class Ch_8_1_SerializeExample {
public static void main(String[] args) {
Ch_8_1_SerializeExample example = new Ch_8_1_SerializeExample();
example.start();
}
private void start() {
ProgramSettings settings = new ProgramSettings(new Point(10,10),
new Dimension(300,200), Color.blue,
"The title of the application" );
saveSettings(settings,"settings.bin");
ProgramSettings loadedSettings = loadSettings("settings.bin");
if(loadedSettings != null)
System.out.println("Are settings are equal? :"+loadedSettings.equals(settings));
}
private void saveSettings(ProgramSettings settings, String filename) {
try {
FileOutputStream fos = new FileOutputStream(filename);
try (ObjectOutputStream oos = new ObjectOutputStream(fos)) {
oos.writeObject(settings);
}
} catch (IOException e) {
e.printStackTrace();
}
}
private ProgramSettings loadSettings(String filename) {
try {
FileInputStream fis = new FileInputStream(filename);
ObjectInputStream ois = new ObjectInputStream(fis);
return (ProgramSettings) ois.readObject();
} catch (IOException | ClassNotFoundException e) {
e.printStackTrace();
}
return null;
}
}
它是如何工作的
Java 支持序列化,这是获取一个对象并创建一个字节表示的能力,该字节表示可用于以后恢复该对象。通过使用内部序列化机制,序列化对象的大部分设置都被处理了。Java 将把对象的属性转换成字节流,然后可以保存到文件中或通过网络传输。
注意
最初的 Java 序列化框架使用反射来序列化对象,所以如果序列化/反序列化太多,可能会有问题。有许多开源框架根据您的需要提供不同的权衡(速度与大小与易用性)。参见github.com/eishay/jvm-serializers/wiki/。
对于一个可序列化的类,它需要实现 serializable 接口,这是一个标记接口:它没有任何方法,而是告诉序列化机制你已经允许你的类被序列化。虽然从一开始就不明显,但序列化公开了类的所有内部工作方式(包括受保护的和私有的成员),所以如果您想对核发射的授权代码保密,您可能希望使包含此类信息的任何类都不可序列化。
该类的所有属性(也称为成员、变量或字段)都必须是可序列化的(和/或瞬态的,这一点我们稍后会谈到)。所有原语—int、long、double 和 float(以及它们的包装类)—以及 String 类,在设计上都是可序列化的。其他 Java 类可以根据具体情况进行序列化。例如,不能序列化任何 Swing 组件(像 JButton 或 JSpinner),也不能序列化 File 对象,但是可以序列化 Color 类(更准确地说是 awt.color)。
作为一个设计原则,您不希望序列化您的主类,而是希望创建只包含您希望序列化的属性的类。它将省去调试中的许多麻烦,因为序列化变得非常普遍。如果您将一个主要类标记为 serializable(实现 serializable ),并且该类包含许多其他属性,那么您也需要将这些类声明为 Serializable。如果您的 Java 类继承自另一个类,父类也应该是可序列化的。在父类不可序列化的情况下,父类的属性将不会被序列化。
如果您想将属性标记为不可序列化,您可以将其标记为瞬态。瞬态属性告诉 Java 编译器,您对保存/加载属性值不感兴趣,因此它将被忽略。有些属性很可能是瞬态的,比如缓存计算,或者总是实例化为相同值的日期格式化程序。
凭借序列化框架,静态属性是不可序列化的;静态类也不是。原因是静态类不能被实例化,尽管公共静态内部类可以被实例化。因此,如果您保存然后同时加载静态类,您将加载静态类的另一个副本,从而抛出 JVM 进行循环。
Java 序列化机制在幕后工作,转换和遍历类中标记为可序列化的每个对象。如果应用包含对象中的对象,甚至可能包含交叉引用的对象,序列化框架将解析这些对象,并且只存储任何对象的一个副本。然后每个属性被转换成 byte[]表示。字节数组的格式包括实际的类名(例如:com . somewhere . over . the . rainbow . preferences . user preferences),后面是属性的编码(这反过来可以编码另一个对象类及其属性等)。等等。,无限期。
出于好奇,如果您查看生成的文件(即使在文本编辑器中),您可以看到类名几乎是文件的第一部分。
注意
序列化非常脆弱。默认情况下,序列化框架生成一个流唯一标识符(SUID) ,它捕获关于类中出现了什么字段、它们的种类(公共/受保护)以及什么是瞬态的等信息。即使是对类的细微修改(例如,将 int 改为 long 属性)也会产生一个新的 SUID。用以前的 SUID 保存的类不能在新的 SUID 上反序列化。这样做是为了保护序列化/反序列化机制,同时也保护设计者。
您实际上可以告诉 Java 类使用特定的 SUID。这将允许您序列化类,修改它们,然后反序列化原始类,同时实现一些向后兼容性。您遇到的危险是反序列化必须是向后兼容的。重命名或移除字段将在反序列化类时生成异常。如果在 Serializable 类上指定自己的串行 Serializable,请确保每次更改该类时都进行一些向后兼容性的单元测试。一般来说,可以在下面的地方找到保持类向后兼容的更改:docs . Oracle . com/javase/9/docs/platform/serialization/spec/serial-arch . html。
由于序列化的本质,不要期望在反序列化对象时调用构造函数。如果在构造函数中有对象正常工作所需的初始化代码,则可能需要从构造函数中重构代码,以允许在构造后正确执行。原因是在反序列化过程中,反序列化的对象是在内部“还原”的(不是创建的),不调用构造函数。
8-2.更有效地序列化 Java 对象
问题
您希望序列化一个类,但希望使输出比通过内置序列化方法生成的产品更有效或更小。
解决办法
通过使对象实现可外部化的接口,可以指示 Java 虚拟机使用自定义的序列化/反序列化机制,如下例中的 readExternal/writeExternal 方法所提供的那样。
public class ExternalizableProgramSettings implements Externalizable {
private Point locationOnScreen;
private Dimension frameSize;
private Color defaultFontColor;
private String title;
// Empty constructor, required for Externalizable implementors
public ExternalizableProgramSettings() {
}
@Override
public void writeExternal(ObjectOutput out) throws IOException {
out.writeInt(locationOnScreen.x);
out.writeInt(locationOnScreen.y);
out.writeInt(frameSize.width);
out.writeInt(frameSize.height);
out.writeInt(defaultFontColor.getRGB());
out.writeUTF(title);
}
@Override
public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException {
locationOnScreen = new Point(in.readInt(), in.readInt());
frameSize = new Dimension(in.readInt(), in.readInt());
defaultFontColor = new Color(in.readInt());
title = in.readUTF();
}
// getters and setters omitted for brevity
}
它是如何工作的
Java 序列化框架为您提供了指定序列化对象的实现的能力。因此,它需要实现可外部化的接口来代替可序列化的接口。可外部化的接口包含两个方法:writeExternal(ObjectOutput out)和 readExternal(ObjectInput in)。通过实现这些方法,你告诉框架如何编码/解码你的对象。
writeExternal()方法将 ObjectOutput 对象作为参数传入。然后,该对象将允许您为序列化编写自己的编码。ObjectOutput 包含表 8-1 中列出的方法。
表 8-1。对象输出方法
|ObjectOutput
|
对象输出
|
描述
| | --- | --- | --- | | writeBoolean (boolean v) | booleanreadBoolean() | 读/写布尔原语。 | | writeByte(int v) | intreadByte() | 读取/写入一个字节。注意:Java 没有字节原语,所以使用 int 作为参数,但是只写最低有效字节。 | | writeShort(int v) | intreadShort() | 读取/写入两个字节。注意:只有两个最低有效字节会被写入。 | | writeChar(int v) | intreadChar() | 以 char 形式读/写两个字节(顺序与 writeShort 相反)。 | | writeInt (int v) | intreadInt() | 读/写一个整数。 | | writeLong(长 v) | intreadLong() | 读/写 long 类型。 | | writeDouble(双 v) | 双倍读数 | 读/写一个 double。 |
您可能选择实现可外部化接口而不是可序列化接口的一个原因是,Java 的默认序列化效率非常低。因为 Java 序列化框架需要确保每个对象(和依赖对象)都被序列化,所以它甚至会编写具有默认值或者可能为空和/或 null 的对象。实现外部化接口还提供了对如何序列化类的更细粒度的控制。在我们的例子中,可序列化版本创建了 439 字节的设置,而可外部化版本只有 103 字节!
注意
实现外部化接口的类必须包含一个空的(无参数)构造函数。
8-3.将 Java 对象序列化为 XML
问题
尽管您喜欢序列化框架,但您希望创建至少是跨语言兼容的(或人类可读的)东西。您希望使用 XML 保存和加载您的对象。
解决办法
在此示例中,XMLEncoder 对象用于对 Settings 对象进行编码,该对象包含程序设置信息并将其写入 settings.xml 文件。XMLDecoder 获取 settings.xml 文件并将其作为流读取,对 settings 对象进行解码。文件系统用于获得对机器文件系统的访问;FileOutputStream 用于将文件写入系统;FileInputStream 用于从文件系统中的文件获取输入字节。在本例中,这三个文件对象用于创建新的 XML 文件,并读取它们进行处理。
//Encoding
FileSystem fileSystem = FileSystems.getDefault();
try (FileOutputStream fos = new FileOutputStream("settings.xml"); XMLEncoder encoder =
new XMLEncoder(fos)) {
encoder.setExceptionListener((Exception e) -> {
System.out.println("Exception! :"+e.toString());
});
encoder.writeObject(settings);
}
// Decoding
try (FileInputStream fis = new FileInputStream("settings.xml"); XMLDecoder decoder =
new XMLDecoder(fis)) {
ProgramSettings decodedSettings = (ProgramSettings) decoder.readObject();
System.out.println("Is same? "+settings.equals(decodedSettings));
}
Path file= fileSystem.getPath("settings.xml");
List<String> xmlLines = Files.readAllLines(file, Charset.defaultCharset());
xmlLines.stream().forEach((line) -> {
System.out.println(line);
});
它是如何工作的
XMLEncoder 和 XMLDecoder 与序列化框架一样,使用反射来确定要写入哪些字段,但不是将字段写成二进制,而是写成 XML。要编码的对象不需要是可序列化的,但是它们需要遵循 Java Beans 规范。
Java Bean 是符合以下约定的任何对象的名称:
-
该对象包含一个公共空(无参数)构造函数。
-
该对象包含每个名为 get{Property}()和 set{Property}()的受保护/私有属性的公共 getters 和 setters。
XMLEncoder 和 XMLDecoder 将只编码/解码具有公共访问器(get{property},set{property})的 Bean 的属性,因此任何私有且没有访问器的属性都不会被编码/解码。
小费
在编码/解码时注册一个异常监听器是一个好主意。
XmlEncoder 创建一个正在序列化的类的新实例(记住它们必须是 Java Beans,所以它们必须有一个空的无参数构造函数),然后确定哪些属性是可访问的(通过 get{property},set{property})。并且如果新实例化的类的属性包含与原始类的属性相同的值(即,具有相同的默认值),则 XmlEncoder 不写该属性。换句话说,如果一个属性的默认值没有改变,XmlEncoder 不会把它写出来。这提供了在不同版本之间改变“缺省”值的灵活性。例如,如果在对对象进行编码时某个属性的默认值为 2,并且后来在默认属性从 2 更改为 4 后进行了解码,则解码后的对象将包含新的默认属性 4(这可能不正确)。
XMLEncoder 还跟踪引用。如果一个对象在对象图中出现不止一次(例如,一个对象在主类的映射中,但也作为 DefaultValue 属性出现),那么 xmlEncoder 将只对它编码一次,并通过在 XML 中放置一个链接来链接一个引用。XMLEncoder/XMLDecoder 比序列化框架宽容得多。解码时,如果属性类型被更改,或者被删除/添加/移动/重命名,解码将“尽可能多地”解码,同时跳过无法解码的属性。
建议不要持久化您的主类(即使 XMLEncoder 更宽容),而是创建简单的、保存基本信息的特殊对象,这些对象本身不执行许多任务。
8-4.创建套接字连接并通过网络发送可序列化的对象
问题
您需要打开一个网络连接,并从中发送/接收对象。
解决办法
使用 Java 新的输入输出 API 版本 2 (NIO.2)来发送和接收对象。以下解决方案利用了非阻塞套接字的 NIO.2 特性(通过使用未来任务):
public class Ch_8_4_AsyncChannel {
private AsynchronousSocketChannel clientWorker;
InetSocketAddress hostAddress;
public Ch_8_4_AsyncChannel() {
}
private void start() throws IOException, ExecutionException, TimeoutException, InterruptedException {
hostAddress = new InetSocketAddress(InetAddress.getByName("127.0.0.1"), 2583);
Thread serverThread = new Thread(() -> {
serverStart();
});
serverThread.start();
Thread clientThread = new Thread(() -> {
clientStart();
});
clientThread.start();
}
private void clientStart() {
try {
try (AsynchronousSocketChannel clientSocketChannel = AsynchronousSocketChannel.open()) {
Future<Void> connectFuture = clientSocketChannel.connect(hostAddress);
connectFuture.get(); // Wait until connection is done.
OutputStream os = Channels.newOutputStream(clientSocketChannel);
try (ObjectOutputStream oos = new ObjectOutputStream(os)) {
for (int i = 0; i < 5; i++) {
oos.writeObject("Look at me " + i);
Thread.sleep(1000);
}
oos.writeObject("EOF");
}
}
} catch (IOException | InterruptedException | ExecutionException e) {
e.printStackTrace();
}
}
private void serverStart() {
try {
AsynchronousServerSocketChannel serverSocketChannel = AsynchronousServerSocketChannel.open().bind(hostAddress);
Future<AsynchronousSocketChannel> serverFuture = serverSocketChannel.accept();
final AsynchronousSocketChannel clientSocket = serverFuture.get();
System.out.println("Connected!");
if ((clientSocket != null) && (clientSocket.isOpen())) {
try (InputStream connectionInputStream = Channels.newInputStream(clientSocket)) {
ObjectInputStream ois = null;
ois = new ObjectInputStream(connectionInputStream);
while (true) {
Object object = ois.readObject();
if (object.equals("EOF")) {
clientSocket.close();
break;
}
System.out.println("Received :" + object);
}
ois.close();
}
}
} catch (IOException | InterruptedException | ExecutionException | ClassNotFoundException e) {
e.printStackTrace();
}
}
public static void main(String[] args) throws IOException, ExecutionException, TimeoutException, InterruptedException {
Ch_8_4_AsyncChannel example = new Ch_8_4_AsyncChannel();
example.start();
}
}
它是如何工作的
基本上,套接字需要类型、IP 地址和端口。虽然套接字文学已经占据了整本书,但其主要思想非常简单。像邮局一样,套接字通信依赖于地址。这些地址用于传送数据。在本例中,我们选择了回送(运行程序的同一台计算机)地址(127.0.0.1),并选择了一个随机端口号(2583)。
新 NIO.2 的优势在于它本质上是异步的。通过使用异步调用,您可以扩展您的应用,而不必为每个连接创建数千个线程。在我们的例子中,我们接受异步调用并等待连接,为了这个例子,实际上使它成为单线程的,但是不要让这阻止你用更多的异步调用来增强这个例子。(查看本书多线程部分的方法。)
对于要连接的客户端,它需要一个套接字通道。NIO.2 API 允许创建异步套接字通道。一旦创建了套接字通道,它将需要一个地址来连接。socketChannel.connect()操作不会阻塞;相反,它返回一个 Future 对象(这与传统的 NIO 不同,在传统的 NIO 中,调用 socketChannel.connect()将一直阻塞,直到建立连接为止)。Future 对象允许 Java 程序继续它正在做的事情,并简单地查询已提交任务的状态。打个比方,你不再在前门等你的邮件到达,而是去做其他事情,定期“检查”邮件是否已经到达。Future 对象有 isDone()和 isCancelled()这样的方法,让您知道任务是完成了还是取消了。它还有 get()方法,允许您实际等待任务完成。在我们的示例中,我们使用 Future.get()来等待客户端连接的建立。
一旦建立了连接,我们就使用 Channels.newOutputStream()创建一个输出流来发送信息。使用装饰器模式,我们用 ObjectOutputStream 装饰 outputStream,最终通过套接字发送对象。
服务器代码稍微复杂一点。服务器套接字连接允许出现多个连接,因此它们用于监视或接收连接,而不是启动连接。因此,服务器通常异步等待连接。
服务器首先建立它监听的地址(127.0.0.1:2583)并接受连接。对 serverSocketChannel.accept()的调用返回另一个 Future 对象,该对象将为您提供如何处理传入连接的灵活性。在我们的例子中,服务器连接简单地调用 Future.get(),它将阻塞(停止程序的执行)直到连接被接受。
服务器获取套接字通道后,它通过调用 Channels.newInputStream(socket)创建一个 inputStream,然后用 ObjectInputStream 包装该输入流。然后,服务器继续循环并读取来自 ObjectInputStream 的每个对象。如果对象 received 的 toString()方法等于 EOF,服务器停止循环,连接关闭。
注意
使用 ObjectOutputStream 和 ObjectInputStream 发送和接收大量对象会导致内存泄漏。为了提高效率,ObjectOutputStream 保留了已发送对象的副本。如果要再次发送同一对象,ObjectOutputStream 和 ObjectInputStream 将不会再次发送同一对象,而是发送以前发送的对象 ID。这种行为或者只发送对象 ID 而不是整个对象会引发两个问题。
第一个问题是,当通过网络发送时,被就地更改(可变)的对象将不会在接收客户机中得到反映。原因在于,因为对象发送过一次,所以 ObjectOutputStream 认为对象已经被传输,并且将只发送 ID,从而否定对象自发送以来发生的任何更改。为了避免这种情况,不要对通过网络发送的对象进行更改。这条规则也适用于对象图中的子对象。
第二个问题是,因为 ObjectOutputStream 维护一个已发送对象及其对象 id 的列表,所以如果发送大量对象,则已发送对象到键的字典会无限增长,导致长时间运行的程序内存不足。为了缓解这个问题,您可以调用 ObjectOutputStream.reset(),这将清除已发送对象的字典。或者,您可以调用 ObjectOutputStream . write unshared()来不缓存 object output stream 字典中的对象。
8-5.获取 Java 执行路径
问题
你想得到 Java 程序运行的路径。
解决办法
调用系统类的 getProperty 方法。例如:
String path = System.getProperty("user.dir");
它是如何工作的
当 Java 程序启动时,JDK 会更新 user.dir 系统属性,以记录调用 JDK 的位置。该解决方案示例将属性名“user.dir”传递给 getProperty 方法,该方法返回值。
8-6.复制文件
问题
你需要把一个文件从一个文件夹复制到另一个文件夹。
解决办法
在缺省文件系统中,创建文件/文件夹所在的“to”和“From”路径,然后使用 Files.copy 静态方法在创建的路径之间复制文件:
FileSystem fileSystem = FileSystems.getDefault();
Path sourcePath = fileSystem.getPath("file.log");
Path targetPath = fileSystem.getPath("file2.log");
System.out.println("Copy from "+sourcePath.toAbsolutePath().toString()+
" to "+targetPath.toAbsolutePath().toString());
try {
Files.copy(sourcePath, targetPath, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
e.printStackTrace();
}
它是如何工作的
在新的 NIO.2 库中,Java 使用一个抽象层,允许对属于底层操作系统的文件属性进行更直接的操作。
getDefaults()获取可用的抽象系统,您可以在其上进行文件操作。例如,在 Windows 中运行这个示例将得到一个 WindowsFileSystem 如果您在 Linux 中运行这个例子,将会返回一个 LinuxFileSystem 对象;在 OS X 上,返回一个 MacOSXFileSystem。AllFileSystems 支持基本操作;此外,每个具体的文件系统都提供了对该操作系统独有特性的访问。
获得默认的文件系统对象后,您可以查询文件对象。在 NIO.2 文件中,文件夹和链接都称为路径。一旦获得了路径,就可以用它来执行操作。在此示例中,使用源路径和目标路径调用 Files.copy。最后一个参数指的是不同的复制选项。不同的复制选项取决于文件系统,因此请确保您选择的选项与您打算在其中运行应用的操作系统兼容。
8-7.移动文件
问题
您需要将文件从一个文件系统位置移动到另一个位置。
解决办法
如在方法 8-6 中,您使用默认文件系统创建“到”和“从”路径,并调用 Files.move()静态方法:
FileSystem fileSystem = FileSystems.getDefault();
Path sourcePath = fileSystem.getPath("file.log");
Path targetPath = fileSystem.getPath("file2.log");
System.out.println("Copy from "+sourcePath.toAbsolutePath().toString()+
" to "+targetPath.toAbsolutePath().toString());
try {
Files.move(sourcePath, targetPath);
} catch (IOException e) {
e.printStackTrace();
}
它是如何工作的
以与复制文件相同的方式,创建源和目标的路径。获得源路径和目标路径后,Files.move 会自动将文件从一个位置移动到另一个位置。Files 对象提供的其他方法如下:
-
Delete (path):删除文件(或文件夹,如果它是空的)。
-
Exists (path):检查文件/文件夹是否存在。
-
isDirectory (path):检查创建的路径是否指向一个目录。
-
isExecutable (path):检查文件是否是可执行文件。
-
isHidden (path):检查文件在操作系统中是可见还是隐藏。
8-8.创建目录
问题
您需要从 Java 应用创建一个目录。
解决方案 1
通过使用默认文件系统,您实例化了一个指向新目录的路径;然后调用 Files.createDirectory()静态方法,该方法创建路径中指定的目录。
FileSystem fileSystem = FileSystems.getDefault();
Path directory= fileSystem.getPath("./newDirectory");
try {
Files.createDirectory(directory);
} catch (IOException e) {
e.printStackTrace();
}
解决方案 2
如果使用*nix 操作系统,您可以通过调用 PosixFilePermission()方法来指定文件夹属性,该方法允许您在所有者、组和全局级别设置访问权限。例如:
FileSystem fileSystem = FileSystems.getDefault();
Path directory= fileSystem.getPath("./newDirectoryWPermissions");
try {
Set<PosixFilePermission> perms = PosixFilePermissions.fromString("rwxr-x---");
FileAttribute<Set<PosixFilePermission>> attr =
PosixFilePermissions.asFileAttribute(perms);
Files.createDirectory(directory, attr);
} catch (IOException e) {
e.printStackTrace();
}
它是如何工作的
Files.createDirectory()方法将路径作为参数,然后创建目录,如解决方案 1 所示。默认情况下,创建的目录将继承默认权限。如果您想在 Linux 中指定特定的权限,可以在 createDirectory()方法中使用 PosixAttributes 作为额外的参数。解决方案 2 演示了传递一组 PosixFilePermissions 来设置新创建的目录的权限的能力。
8-9.遍历目录中的文件
问题
你需要扫描目录中的文件。可能有包含更多文件的子目录。你想把这些包括在你的扫描中。
解决办法
使用 NIO.2 创建 FileVisitor 对象,并在其 visitFile 方法中执行所需的实现。接下来,获取默认的文件系统对象,并通过 getPath()方法获取您想要扫描的路径的引用。最后,调用 Files.walkFileTree()方法,传递路径和您创建的 FileVisitor。下面的代码演示了如何执行这些任务。
FileVisitor<Path> myFileVisitor = new SimpleFileVisitor<Path>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
System.out.println("Visited File: "+file.toString());
return FileVisitResult.CONTINUE;
}
};
FileSystem fileSystem = FileSystems.getDefault();
Path directory= fileSystem.getPath(".");
try {
Files.walkFileTree(directory, myFileVisitor);
} catch (IOException e) {
e.printStackTrace();
}
它是如何工作的
在 NIO.2 之前,尝试遍历目录树涉及到递归,并且根据实现的不同,这可能非常脆弱。获取文件夹中文件的调用是同步的,在返回之前需要扫描整个目录;对应用用户生成看似无响应的方法调用。使用 NIO.2,可以指定从哪个文件夹开始遍历,NIO.2 调用将处理递归细节。您提供给 NIO.2 API 的唯一一项是一个类,该类告诉它在找到文件/文件夹时做什么(SimpleFileVisitor 实现)。NIO.2 使用访问者模式,因此不需要预扫描整个文件夹,而是在遍历文件时处理文件。
SimpleFileVisitor 类作为匿名内部类的实现包括重写 visitFile(路径文件,BasicFileAttributesattrs()方法。当您重写此方法时,可以指定遇到文件时要执行的任务。
visitFile 方法返回 FileVisitReturn 枚举。然后,该枚举告诉 FileVisitor 要采取的操作:
-
继续:继续遍历目录树。
-
终止:停止遍历。
-
SKIP_SUBTREE:停止从当前树级别继续深入(仅当 preVisitDirectory()方法返回此枚举时有用)。
-
SKIP_SIBLINGS:跳过与当前目录在同一树级别的其他目录。
除了 visitFile()方法之外,SimpleFileVisitor 类还包含以下内容:
-
preVisitDirectory:在进入要遍历的目录之前调用。
-
postVisitDirectory:在遍历完一个目录后调用。
-
visitFile:在访问文件时调用,如示例代码所示。
-
visitFileFailed:如果无法访问文件,则调用该函数;例如,在 I/O 错误时。
8-10.查询(和设置)文件元数据
问题
您需要获得关于特定文件的信息,比如文件大小、它是否是一个目录等等。此外,您可能希望在 Windows 操作系统中将文件标记为存档或在*nix 操作系统中授予特定的 POSIX 文件权限(参考方法 8-8)。
解决办法
使用 Java NIO.2,只需调用 java.nio.file.Files 实用程序类上的方法,传递想要获取元数据的路径,就可以获取任何文件信息。您可以通过调用 Files.getFileAttributeView()方法来获取属性信息,并传递您想要使用的属性视图的特定实现。下面的代码演示了这些获取元数据的技术。
Path path = FileSystems.getDefault().getPath("./file2.log");
try {
// General file attributes, supported by all Java systems
System.out.println("File Size:"+Files.size(path));
System.out.println("Is Directory:"+Files.isDirectory(path));
System.out.println("Is Regular File:"+Files.isRegularFile(path));
System.out.println("Is Symbolic Link:"+Files.isSymbolicLink(path));
System.out.println("Is Hidden:"+Files.isHidden(path));
System.out.println("Last Modified Time:"+Files.getLastModifiedTime(path));
System.out.println("Owner:"+Files.getOwner(path));
// Specific attribute views.
DosFileAttributeView view = Files.getFileAttributeView(path,
DosFileAttributeView.class);
System.out.println("DOS File Attributes\n");
System.out.println("------------------------------------\n");
System.out.println("Archive :"+view.readAttributes().isArchive());
System.out.println("Hidden :"+view.readAttributes().isHidden());
System.out.println("Read-only:"+view.readAttributes().isReadOnly());
System.out.println("System :"+view.readAttributes().isSystem());
view.setArchive(false);
} catch (IOException e) {
e.printStackTrace();
}
它是如何工作的
与旧的 I/O 技术相比,Java NIO.2 在获取和设置文件属性方面具有更大的灵活性。NIO.2 将不同的操作系统属性抽象为一组“通用”属性和一组“特定于操作系统”的属性。标准属性如下:
-
isDirectory:如果是目录,则为 True。
-
isRegularFile:如果文件不是常规文件、文件不存在或无法确定它是否是常规文件,则返回 false。
-
issymbolick:如果链接是符号性的(在 Unix 系统中最普遍),则为 True。
-
isHidden:如果文件被认为隐藏在操作系统中,则为 True。
-
LastModifiedTime:文件上次更新的时间。
-
Owner:操作系统中文件的所有者。
此外,NIO.2 允许输入底层操作系统的特定属性。为此,首先需要获得一个表示操作系统文件属性的视图(在本例中,它是一个 DosFileAttributeView)。获得视图后,您可以查询和更改特定于操作系统的属性。
注意
AttributeView 仅适用于目标操作系统(不能在*nix 机器中使用 DosFileAttributeView)。
8-11.监视目录的内容变化
问题
您需要跟踪目录内容何时发生了变化(例如,添加、更改或删除了一个文件),并根据这些变化采取行动。
解决办法
通过使用 WatchService,您可以订阅文件夹中发生的事件的通知。在下面的示例中,我们订阅了 ENTRY_CREATE、ENTRY_MODIFY 和 ENTRY_DELETE 事件:
try {
System.out.println("Watch Event, press q<Enter> to exit");
FileSystem fileSystem = FileSystems.getDefault();
WatchService service = fileSystem.newWatchService();
Path path = fileSystem.getPath(".");
System.out.println("Watching :"+path.toAbsolutePath());
path.register(service, StandardWatchEventKinds.ENTRY_CREATE, StandardWatchEventKinds.ENTRY_DELETE, StandardWatchEventKinds.ENTRY_MODIFY);
boolean shouldContinue = true;
while(shouldContinue) {
WatchKey key = service.poll(250, TimeUnit.MILLISECONDS);
// Code to stop the program
while (System.in.available() > 0) {
int readChar = System.in.read();
if ((readChar == 'q') || (readChar == 'Q')) {
shouldContinue = false;
break;
}
}
if (key == null) continue;
key.pollEvents().stream()
.filter((event) -> !(event.kind() == StandardWatchEventKinds.OVERFLOW))
.map((event) -> (WatchEvent<Path>)event).forEach((ev) -> {
Path filename = ev.context();
System.out.println("Event detected :"+filename.toString()+" "+ev.kind());
});
boolean valid = key.reset();
if (!valid) {
break;
}
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
}
它是如何工作的
NIO.2 包含一个内置的轮询机制来监控文件系统中的变化。使用轮询机制允许您以指定的时间间隔等待事件并轮询更新。一旦事件发生,您就可以处理和消费它。消费事件告诉 NIO.2 框架,您已经准备好处理新事件。
若要开始监视文件夹,请创建一个 WatchService,您可以使用它来轮询更改。创建 WatchService 后,用一个路径注册 WatchService。路径代表文件系统中的一个文件夹。当 WatchService 在 path 中注册时,您定义想要监视的事件种类(参见表 8-2 )。
表 8-2。观察事件的类型
|WatchEvent
|
描述
| | --- | --- | | 泛滥 | 溢出的事件(忽略) | | 条目 _ 创建 | 创建了一个目录或文件 | | 条目 _ 删除 | 目录或文件已被删除 | | 录入 _ 修改 | 目录或文件已被修改 |
在用路径注册了 WatchService 之后,您可以“轮询”WatchService 的事件发生情况。通过调用 watchService.poll()方法,您将等待文件/文件夹事件在该路径上发生。使用 watchService.poll(int timeout,Timeunit timeUnit)将等到达到指定的超时时间后再继续。如果 watchService 接收到一个事件,或者超过了允许的时间,那么它将继续执行。如果没有事件并且超时,watchService.poll(int timeout)返回的 WatchKey 对象将为 null 否则,返回的 WatchKey 对象将包含已发生事件的相关信息。
因为许多事件可以同时发生(例如,移动整个文件夹或将一堆文件粘贴到一个文件夹中),所以 WatchKey 可能包含多个事件。通过调用 watchKey.pollEvents()方法,可以使用 WatchKey 获取与该键关联的所有事件。
watchKey.pollEvents()调用将返回可以迭代的 watchEvents 列表。每个 watchEvent 都包含有关该事件引用的实际文件或文件夹的信息(例如,整个子文件夹可能已被移动或删除),以及事件类型(添加、编辑、删除)。只有那些在 WatchService 上注册的事件才会被处理。您可以注册的事件类型在表 8-2 中列出。
一旦处理了一个事件,调用 EventKey.reset()是很重要的。重置将返回一个布尔值,确定 WatchKey 是否仍然有效。如果 WatchKey 被取消或其原始 WatchService 被关闭,它将变得无效。如果 eventKey 返回 false,您应该中断监视循环。
8-12.读取属性文件
问题
您希望为应用建立一些配置设置,并且希望能够手动或以编程方式修改这些设置。此外,您希望能够动态地更改一些配置,而无需重新编译和重新部署。
解决办法
创建一个属性文件来存储应用配置。使用 Properties 对象,为应用处理加载存储在属性文件中的属性。还可以在属性文件中更新和修改属性。下面的示例演示如何读取名为 properties.conf 的属性文件,加载值以供应用使用,最后设置属性并将其写入文件。
File file = new File("properties.conf");
Properties properties = null;
try {
if (!file.exists()) {
file.createNewFile();
}
properties = new Properties();
properties.load(new FileInputStream("properties.conf"));
} catch (IOException e) {
e.printStackTrace();
}
boolean shouldWakeUp = false;
int startCounter = 100;
String shouldWakeUpProperty = properties.getProperty("ShouldWakeup");
shouldWakeUp = (shouldWakeUpProperty == null) ? false : Boolean.parseBoolean(shouldWakeUpProperty.trim());
String startCounterProperty = properties.getProperty("StartCounter");
try {
startCounter = Integer.parseInt(startCounterProperty);
} catch (Exception e) {
System.out.println("Couldn't read startCounter, defaulting to " + startCounter);
}
String dateFormatStringProperty = properties.getProperty("DateFormatString", "MMM dd yy");
System.out.println("Should Wake up? " + shouldWakeUp);
System.out.println("Start Counter: " + startCounter);
System.out.println("Date Format String:" + dateFormatStringProperty);
//setting property
properties.setProperty("StartCounter", "250");
try {
properties.store(new FileOutputStream("properties.conf"), "Properties Description");
} catch (IOException e) {
e.printStackTrace();
}
properties.list(System.out);
它是如何工作的
Java Properties 类帮助您管理程序属性。它允许您通过外部修改(有人编辑属性文件)或使用 Properties.store()方法在内部管理属性。
Properties 对象既可以在没有文件的情况下实例化,也可以在有预加载文件的情况下实例化。Properties 对象读取的文件采用[name]=[value]的形式,并以文本形式表示。如果需要以其他格式存储值,则需要写入和读取字符串。
如果您希望在程序之外修改文件(用户直接打开文本编辑器并更改值),请确保对输入进行净化;比如修剪值以留出额外的空格,如果需要的话忽略大小写。
若要以编程方式查询不同的属性,请调用 getProperty(String)方法,传递要检索其值的属性的基于字符串的名称。如果找不到该属性,该方法将返回 null。或者,您可以调用 getProperty (String,String)方法,如果在 Properties 对象中没有找到该属性,它将返回第二个参数作为其值。如果文件中没有特定键的条目,那么指定默认值是一个很好的做法。
在查看生成的属性文件时,您会注意到前两行指出了文件的描述和修改日期。这两行以#开头,这在 Java 属性文件中相当于注释。处理文件时,Properties 对象将跳过任何以#开头的行。
注意
如果您允许用户直接修改您的配置文件,那么在从 properties 对象中检索属性时进行验证是非常重要的。属性值中最常见的问题之一是前导和/或尾随空格。如果指定布尔值或整数属性,请确保可以从字符串中解析它们。至少,在试图解析一个非常规值时捕获一个异常(并记录违规值)。
8-13.解压缩文件
问题
您的应用需要从压缩的。压缩文件。
解决办法
使用 Java.util.zip 包,您可以打开一个. zip 文件并遍历它的条目。在遍历条目时,可以为目录条目创建目录。类似地,当遇到文件条目时,将解压缩后的文件写入文件。下面几行代码演示了如何执行解压缩和文件迭代技术,如上所述。
ZipFile file = null;
try {
file = new ZipFile("file.zip");
FileSystem fileSystem = FileSystems.getDefault();
Enumeration<? extends ZipEntry> entries = file.entries();
String uncompressedDirectory = "uncompressed/";
Files.createDirectory(fileSystem.getPath(uncompressedDirectory));
while (entries.hasMoreElements()) {
ZipEntry entry = entries.nextElement();
if (entry.isDirectory()) {
System.out.println("Creating Directory:" + uncompressedDirectory + entry.getName());
Files.createDirectories(fileSystem.getPath(uncompressedDirectory +
entry.getName()));
} else {
InputStream is = file.getInputStream(entry);
System.out.println("File :" + entry.getName());
BufferedInputStream bis = new BufferedInputStream(is);
String uncompressedFileName = uncompressedDirectory + entry.getName();
Path uncompressedFilePath = fileSystem.getPath(uncompressedFileName);
Files.createFile(uncompressedFilePath);
try (FileOutputStream fileOutput = new FileOutputStream(uncompressedFileName)) {
while (bis.available() > 0) {
fileOutput.write(bis.read());
}
}
System.out.println("Written :" + entry.getName());
}
}
} catch (IOException e) {
e.printStackTrace();
}
它是如何工作的
要处理. Zip 存档文件的内容,请创建一个 ZipFile 对象。可以实例化一个 ZipFile 对象,将. zip 档案的名称传递给构造函数。创建对象后,您可以访问指定的. zip 文件信息。每个 ZipFile 对象将包含一个条目集合,这些条目代表归档中包含的目录和文件,通过遍历这些条目,您可以获得每个压缩文件的信息。每个 ZipEntry 实例都有压缩和未压缩的大小、名称和未压缩字节的输入流。
未压缩的字节可以通过生成 InputStream 读入字节缓冲区,然后(在我们的例子中)写入文件。使用 FileStream,可以确定在不阻塞进程的情况下可以读取多少字节。一旦读取了确定数量的字节,就将这些字节写入输出文件。这个过程一直持续到读取完总字节数为止。
注意
如果文件非常大,将整个文件读入内存可能不是一个好主意。如果您需要处理一个大文件,最好先将它以未压缩的格式写入磁盘(如示例所示),然后打开它并分块加载。如果您正在处理的文件不是很大(您可以通过检查 getSize()方法来限制大小),您可以将它加载到内存中。
8-14.管理操作系统进程
问题
您希望能够从 Java 应用中识别和控制本机操作系统进程。
解决办法
利用 Java 9 中增强的进程 API 来获取有关单个操作系统进程的信息或销毁它们。在本例中,我们将调用 ProcessHandle.info()方法来检索有关操作系统进程的信息。特别是,我们将查看当前正在运行的 JVM 进程,并从中启动另一个进程。最后,我们将询问新流程。
import java.lang.ProcessBuilder;
import java.lang.Process;
import java.time.Instant;
import java.time.Duration;
import java.time.temporal.ChronoUnit;
public class Recipe08_14 {
public static void printProcessDetails(ProcessHandle currentProcess){
//Get the instance of process info
ProcessHandle.Info currentProcessInfo = currentProcess.info();
if ( currentProcessInfo.command().orElse("").equals("")){
return;
}
//Get the process id
System.out.println("Process id: " + currentProcess.getPid());
//Get the command pathname of the process
System.out.println("Command: " + currentProcessInfo.command().orElse(""));
//Get the arguments of the process
String[] arguments = currentProcessInfo.arguments().orElse(new String[]{});
if ( arguments.length != 0){
System.out.print("Arguments: ");
for(String arg : arguments){
System.out.print(arg + " ");
}
System.out.println();
}
//Get the start time of the process
System.out.println("Started at: " + currentProcessInfo.startInstant().orElse(Instant.now()).toString());
//Get the time the process ran for
System.out.println("Ran for: " + currentProcessInfo.totalCpuDuration().orElse(Duration.ofMillis(0)).toMillis() + "ms");
//Get the owner of the process
System.out.println("Owner: " + currentProcessInfo.user().orElse(""));
}
public static void main(String[] args){
ProcessHandle current = ProcessHandle.current();
ProcessHandle.Info currentInfo = current.info();
System.out.println("Command Line Process: " + currentInfo.commandLine());
System.out.println("Process User: " + currentInfo.user());
System.out.println("Process Start Time: " + currentInfo.startInstant());
System.out.println("PID: " + current.getPid());
ProcessBuilder pb = new ProcessBuilder("ls");
try {
Process process = pb.start();
System.out.println(process);
process.children()
.forEach((p) ->{
System.out.println(p);
});
ProcessHandle pHandle = process.toHandle();
System.out.println("Parent of Process: " + pHandle.parent());
} catch (java.io.IOException e){
System.out.println(e);
}
}
}
结果:
Command Line Process: Optional[/Library/Java/JavaVirtualMachines/jdk1.9.0.jdk/Contents/Home/bin/java Recipe0814]
Process User: Optional[Juneau]
Process Start Time: Optional[2016-02-20T06:14:56.064Z]
PID: 10892
java.lang.ProcessImpl@7c30a502
Parent of Process: Optional.empty
它是如何工作的
Java 9 中的 process API 得到了增强,提供了获取有关操作系统进程的有价值信息的能力。API 中添加了 ProcessHandle 接口,提供了一个 info()方法,可用于查询指定的进程和检索更多信息。添加了许多其他有用的实用程序方法来获取有关指定进程的信息。
ProcessHandle。Info 对象是当前进程的信息快照,通过调用 ProcessHandle info()方法返回。ProcessHandle。Info 可以用来返回进程的可执行命令、进程开始时间以及其他一些有用的特性。表 8-3 显示了处理 Handle.Info 的不同方法。
页:1。ProcessHandle(处理程序处理程序)。关于
|方法
|
描述
| | --- | --- | | 参数() | 返回流程参数的字符串数组。 | | 命令 | 返回进程的可执行路径名。 | | 命令行() | 返回进程的命令行。 | | startInstant() | 返回进程的开始时间。 | | 总计持续时间() | 返回进程累计的总 CPU 时间。 | | 用户() | 返回运行进程的用户。 |
ProcessHandle 接口可用于返回信息,如进程子进程、PID(进程 ID)、父进程等等。它还可以用来确定一些有用的信息,比如进程是否还活着。表 8-4 显示了 ProcessHandle 的不同方法。
页:1。ProcessHandle(处理程序处理程序)
|方法
|
返回
|
描述
| | --- | --- | --- | | 所有进程() | 静态流 | 返回当前进程可见的所有进程的快照。 | | 儿童() | 流 | 返回当前进程的直接子进程的快照。 | | 比较() | (同 Internationalorganizations)国际组织 | 将一个 ProcessHandle 与另一个 process handle 进行比较,并返回顺序。 | | 当前() | 静态进程句柄 | 返回当前进程的 ProcessHandle。 | | 后代 _) | 流 | 返回当前进程后代的快照。 | | 销毁() | 布尔 | 请求终止当前进程。 | | destroyForcibly() | 布尔 | 请求强制终止当前进程。 | | 等于(对象) | 布尔 | 将当前进程与另一个对象进行比较,如果该对象不为空,则返回 true,并表示同一个系统进程。 | | getPid() | 长的 | 返回当前进程的本机进程 ID。 | | hashCode() | (同 Internationalorganizations)国际组织 | 返回当前 ProcessHandle 的哈希代码值。 | | 信息() | ProcessHandle.info | 返回当前进程的信息快照。 | | isalive() | 布尔 | 测试当前进程是否处于活动状态。 | | (长管道仪表流程图) | 静态可选 | 为现有流程返回可选的。 | | onExit() | 可完成的未来 | 返回当前进程的 CompleteableFuture 。 | | 父级() | 可选 | 为当前进程的父进程返回可选的。 | | 支持 NormalTermination() | 布尔 | 如果当前进程的实现包含支持正常进程终止的 destroy()方法,则返回 true。 |
要利用该 API,请调用 ProcessHandle.info()方法来检索 ProcessHandle.info 对象。然后,该对象可用于执行命令,或检索有关进程的信息。如果与 Process 和 ProcessBuilder 类一起使用,API 可用于生成、监控和终止操作系统进程。
摘要
本章演示了几个在 Java 中使用文件和网络 I/O 的例子。您了解了如何序列化文件以便将它们存储到磁盘上,还了解了如何使用 Java APIs 操作主机的文件系统。本章还讲述了如何读写属性文件,以及如何执行文件压缩。最后,本章介绍了 Java 9 中添加的流程 API 的新特性。
九、异常和日志记录
异常是描述程序中异常情况的一种方式。它们是发生了意想不到的事情的指示器。因此,异常可以有效地中断程序的当前流程,并发出需要注意的信号。因此,明智地利用异常的程序受益于更好的控制流,并且对用户来说变得更加健壮和有用。即便如此,不加选择地使用异常也会导致性能下降。
在 Java 中,异常可以由抛出或捕获。抛出异常包括向代码表明遇到了异常,使用 throw 关键字通知 JVM 在当前栈中找到任何能够处理这种异常情况的代码。捕捉异常包括告诉编译器可以处理哪些异常,以及应该监视代码的哪个部分以防止这些异常发生。这在 try/catch Java 块中表示(在方法 9-1 中描述)
所有异常都继承自 Throwable,如图 9-1 所示。从 Throwable 继承的类可以在 try/catch 语句的 catch 子句中定义。JVM 主要使用错误类来表示严重和/或致命的错误。根据 Java 文档,应用不被期望捕获错误异常,因为它们被认为是致命的(想象一下计算机着火了)。Java 程序中的大部分异常都是从 Exception 类继承的。
图 9-1。Java 中异常类层次结构的一部分
在 JVM 中有两种类型的异常:检查的和未检查的。检查的异常由方法强制执行。在方法签名中,您可以指定方法可以抛出的异常的种类。这要求该方法的任何调用方创建一个 try/ catch 块,该块处理方法签名中声明的异常。未检查的异常不需要如此严格的约定,并且可以在任何地方自由抛出,而无需强制实现 try/catch 块。尽管如此,未检查的异常(如配方 9-6 所述)通常是不鼓励的,因为它们会导致线程散开(如果没有任何东西捕捉到异常)和问题的不可见性。从 RuntimeExceptionare 继承的异常类被视为未检查异常,而直接从 Exception 继承的异常类被视为检查异常。
请注意,抛出异常的代价很高(与其他语言构造替代方法相比),因此抛出异常并不能很好地替代控制流。例如,您不应该抛出异常来指示方法调用的预期结果(比如像 isUsernameValid(字符串用户名)这样的方法)。更好的做法是调用方法并返回一个布尔值,而不是试图引发一个 InvalidUsernameException 来指示失败。
虽然异常在可靠的软件开发中起着重要的作用,但是异常的日志记录也同样重要。应用中的日志记录有助于开发人员理解正在发生的事件,而无需调试代码。在没有机会进行实时调试的生产环境中尤其如此。从这个意义上说,日志记录收集了正在发生的事情的线索(很可能是哪里出错了),并帮助您解决生产问题。许多开发人员选择利用结构化日志记录框架来为应用提供更健壮的日志记录。一个可靠的日志框架和一个合理的方法将会省去许多深夜工作时的疑惑,“发生了什么?”
Java 的日志已经非常成熟了。有许多开源项目被广泛接受为日志记录的事实上的标准。在本章的菜谱中,您将使用 Java 的日志框架和 Java 的简单日志外观(SLF4J)。这两个项目一起为大多数日志记录需求创建了一个足够好的解决方案。对于涉及 SLF4J 和 Log4j 的菜谱,下载 www.slf4j.org/()并将其放入项目的依… Java 9 版本中添加的低级 JVM 日志。
9-1.捕捉异常
问题
您希望优雅地处理代码中生成的任何异常。
解决办法
使用内置的 try/catch 语言构造来捕捉异常。通过将任何可能引发异常的代码块包装在 try/catch 块中来实现这一点。在下面的示例中,使用了一个方法来生成一个布尔值,以指示指定字符串的长度是否大于五个字符。如果作为参数传递的字符串为 null,则 length()方法会抛出一个 NullPointerException,并在 catch 块中被捕获。
private void start() {
System.out.println("Is th String 1234 longer than 5 chars?:"+
isStringShorterThanFiveCharacters("1234"));
System.out.println("Is th String 12345 longer than 5 chars?:"+
isStringShorterThanFiveCharacters("12345"));
System.out.println("Is th String 123456 longer than 5 chars?:"+
isStringShorterThanFiveCharacters("123456"));
System.out.println("Is th String null longer than 5 chars?:"+
isStringShorterThanFiveCharacters(null));
}
private boolean isStringShorterThanFiveCharacters(String aString) {
try {
return aString.length() > 5;
} catch (NullPointerException e) {
System.out.println("An Exception Occurred: " + e);
return false;
}
}
它是如何工作的
try 关键字指定包含的代码段有可能引发异常。catch 子句放在 try 子句的末尾。每个 catch 子句指定正在捕获哪个异常。如果没有为检查的异常提供 catch 子句,编译器将生成错误。两种可能的解决方案是添加一个 catch 子句,或者在封闭方法的 throws 子句中包含异常。任何被抛出但未被捕获的检查异常都将在调用栈中向上传播。如果这个方法没有捕捉到异常,执行代码的线程就会终止。如果终止线程是程序中唯一的线程,它终止程序的执行。
如果 try 子句需要捕获多个异常,可以指定多个异常,用竖线字符分隔。例如,下面的 try/catch 块可用于捕获 NumberFormatException 和 NullPointerException。
try {
// code here
} catch (NumberFormatException|NullPointerException ex) {
// logging
}
有关捕捉多个异常的更多信息,请参见配方 9-4。
注意
抛出异常时要小心。如果抛出的异常没有被捕获,它将在调用栈中向上传播;如果没有任何 catch 子句能够处理该异常,它将导致正在运行的线程终止(也称为解开)。如果你的程序只有一个主线程,一个未被捕获的异常将终止你的程序。
9-2.保证代码块被执行
问题
您希望编写当控件离开代码段时执行的代码,即使控件由于引发错误或代码段异常结束而离开。例如,您获得了一个锁,并希望确保正确地释放它。您希望在出现错误时释放锁,也希望在没有错误时释放锁。
解决办法
使用 try/catch/finally 块来正确释放您在代码段中获取的锁和其他资源。将您希望在不考虑异常的情况下执行的代码放入 finally 子句中。在该示例中,finally 关键字指定了一个将始终执行的代码块,而不管 try 块中是否引发了异常。在 finally 块中,通过调用 lock.unlock()来释放锁:
private void callFunctionThatHoldsLock() {
myLock.lock();
try {
int number = random.nextInt(5);
int result = 100 / number;
System.out.println("A result is " + result);
FileOutputStream file = new FileOutputStream("file.out");
file.write(result);
file.close();
} catch (FileNotFoundException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
} catch (Exception e) {
e.printStackTrace();
} finally {
myLock.unlock();
}
}
它是如何工作的
放置在 try/catch/finally 块的 finally 子句中的代码将始终被执行。在本例中,通过在函数开始时获取锁,然后在 finally 块中释放锁,可以保证无论是否抛出异常(检查或未检查),锁都将在函数结束时释放。总之,获得的锁应该总是在 finally 块中释放。在示例中,假设 mylock.unlock()函数调用不在 finally 块中(而是在 try 块的末尾);如果在这种情况下发生异常,对 mylock.unlock()的调用将不会发生,因为代码执行将在异常发生的位置中断。在这种情况下,锁将永远被获取,永远不会被释放。
警告
如果需要在方法上返回值,那么在 finally 块中返回值时要非常小心。finally 块中的 return 语句将始终执行,而不管 try 块中可能发生的任何其他 return 语句。
9-3.抛出异常
问题
如果应用中出现某种情况,您希望通过引发异常来中止当前代码路径的执行。
解决办法
使用 throw 关键字在发生这种情况时引发指定的异常。使用 throw 关键字,可以通知当前线程寻找 try/catch 块(在当前级别和栈上),它可以处理抛出的异常。在下面的示例中,如果传入的参数为 null,callSomeMethodThatMightThrow 将引发 NullPointerException。
private void start() {
try {
callSomeMethodThatMightThrow(null);
} catch (IllegalArgumentException e) {
System.out.println("There was an illegal argument exception!");
}
}
private void callSomeFunctionThatMightThrow(Object o) {
if (o == null) throw new NullPointerException("The object is null");
}
在此代码示例中,方法 callSomeMethodThatMightThrow 检查以确保向其传递了有效的参数。如果参数为 null,那么它会抛出一个 NullPointerException,表明这个方法的调用者用错误的参数调用了它。
它是如何工作的
throw 关键字允许您显式生成异常条件。当当前线程抛出异常时,它不会执行 throw 语句之外的任何内容,而是将控制转移到 catch 子句(如果有)或终止线程。
注意
当抛出异常时,请确保您打算这样做。如果异常在栈中向上传播时没有被捕获,它将终止正在执行的线程(也称为解开)。如果你的程序只有一个主线程,一个未被捕获的异常将终止你的程序。
9-4.捕捉多个异常
问题
应用中的代码块有可能引发多个异常。您希望捕获 try 块中可能出现的每个异常。
解决方案 1
在同一个块中可能遇到多个异常的情况下,可以指定多个 catch 子句。每个 catch 子句可以指定一个不同的异常来处理,这样每个异常都可以用不同的方式来处理。在下面的代码中,使用了两个 catch 子句来处理 IOException 和 ClassNotFoundException。
try {
Class<?> stringClass = Class.forName("java.lang.String");
FileInputStream in = new FileInputStream("myFile.log") ; // Can throw IOException
in.read();
} catch (IOException e) {
System.out.println("There was an exception "+e);
} catch (ClassNotFoundException e) {
System.out.println("There was an exception "+e);
}
解决方案 2
如果您的应用倾向于在单个块中抛出多个异常,那么可以使用竖线操作符(|)以相同的方式处理每个异常。在下面的示例中,catch 子句指定了用竖线(|)分隔的多个异常类型,以相同的方式处理每个异常。
try {
Class<?> stringClass = Class.forName("java.lang.String");
FileInputStream in = new FileInputStream("myFile.log") ;
// Can throw IOException
in.read();
} catch (IOException | ClassNotFoundException e) {
System.out.println("An exception of type "+e.getClass()+" was thrown! "+e);
}
它是如何工作的
有几种不同的方法来处理可能引发多个异常的情况。您可以指定单独的 catch 子句,以不同的方式处理每个异常。要以相同的方式处理每个异常,您可以使用一个 catch 子句,并用竖线操作符分隔每个异常。
注意
如果您在多个 catch 块中捕获一个异常(解决方案 1),请确保 catch 块是从最具体的到最一般的定义的。不遵循这一约定将会阻止更具体的块处理异常。当有 catch (Exception e)块时,这是最重要的,它可以捕获几乎所有的异常。
拥有一个 catch (Exception e)块——称为 catch-all 或 Pokémon 异常处理程序(必须捕获所有异常)——通常是一种糟糕的做法,因为这样的块将捕获每种异常类型,并对它们一视同仁。这就成了一个问题,因为该块可以捕获其他异常,这些异常可能发生在调用栈的更深处,而您可能不希望该块捕获这些异常(OutOfMemoryException)。最佳实践是指定每个可能的异常,而不是指定一个捕获所有异常的异常处理程序来捕获所有异常。
9-5.捕获未捕获的异常
问题
您想知道线程何时因未捕获的异常(如 NullPointerException)而终止。
解决方案 1
当创建一个 Java 线程时,有时您需要确保任何异常都被捕获并得到正确处理,以帮助确定线程终止的原因。为此,Java 允许您为每个线程或全局注册一个 ExceptionHandler()。下面的代码演示了一个基于每个线程注册异常处理程序的示例。
private void start() {
Thread.setDefaultUncaughtExceptionHandler((Thread t, Throwable e) -> {
System.out.println("Woa! there was an exception thrown somewhere! "+t.getName()+": "+e);
});
final Random random = new Random();
for (int j = 0; j < 10; j++) {
int divisor = random.nextInt(4);
System.out.println("200 / " + divisor + " Is " + (200 / divisor));
}
}
该线程中的 for 循环将正确执行,直到遇到异常,此时将调用 DefaultUncaughtExceptionHandler。UncaughtExceptionHandler 是一个函数接口,因此可以利用 lambda 表达式来实现异常处理程序。
解决方案 2
可以在特定线程上注册一个 UncaughtExceptionHandler。这样做之后,线程内发生的任何未被捕获的异常都将由 UncaughtExceptionHandler()的 uncaughtException()方法处理。例如:
private void startForCurrentThread() {
Thread.currentThread().setUncaughtExceptionHandler((Thread t, Throwable e) -> {
System.out.println("In this thread "+t.getName()+" an exception was thrown "+e);
});
Thread someThread = new Thread(() -> {
System.out.println(200/0);
});
someThread.setName("Some Unlucky Thread");
someThread.start();
System.out.println("In the main thread "+ (200/0));
}
在前面的代码中,在 currentThread 上注册了一个 UncaughtExceptionHandler。就像解决方案 1 一样,UncaughtExceptionHandler 是一个函数接口,因此可以利用 lambda 表达式来实现异常处理程序。
它是如何工作的
对于每个未被捕获的未检查异常,将调用 thread . defaultuncaughtexceptionhandler()。当 UncaughtExceptionHandler()处理异常时,这意味着没有适当的 try/catch 块来捕获异常。因此,异常在线程栈中一路冒泡。这是该线程终止前执行的最后一段代码。当在线程的或默认的 UncaughtExceptionHandler()上捕捉到异常时,线程将终止。UncaughtExceptionHandler()可用于记录关于异常的信息,以帮助查明异常的原因。
在第二个解决方案中,专门为当前线程设置了 UncaughtExceptionHandler()。当线程抛出一个未被捕获的异常时,它将冒泡到线程的 UncaughtExceptionHandler()。如果不存在,它将冒泡到 defaultUncaughtExceptionHandler()。同样,在这两种情况下,引发异常的线程都将终止。
小费
在处理多线程时,显式命名线程总是一个好的做法。这使得确切地知道哪个线程导致了异常变得更加容易,而不是必须跟踪一个像 Thread-##(未命名线程的默认命名模式)这样命名的未知线程。
9-6.使用 try/catch 块管理资源
问题
如果出现异常,您需要确保 try/catch 块中使用的所有资源都被释放。
解决办法
利用自动资源管理(ARM)特性,该特性可以用 try-with-resources 语句来指定。使用 try-with-resources 语句时,try 子句中指定的任何资源都会在块终止时自动释放。在下面的代码中,FileOutputStream、BufferedOutputStream 和 DataOutputStream 资源由 try-with-resources 块自动处理。
try (
**FileOutputStream fos = new FileOutputStream("out.log");**
**BufferedOutputStream bos = new BufferedOutputStream(fos);**
**DataOutputStream dos = new DataOutputStream(bos)**
) {
dos.writeUTF("This is being written");
} catch (Exception e) {
System.out.println("Some bad exception happened ");
}
它是如何工作的
在大多数情况下,您希望在块执行完成后,干净地关闭/释放在 try/catch 块中获取的资源。如果一个程序不关闭/释放它的资源或者不正确地这样做,资源可能会被无限期地获取,导致诸如内存泄漏之类的问题发生。大多数资源都是有限的(文件句柄或数据库连接),因此会导致性能下降(并引发更多异常)。为了避免这些情况,Java 提供了一种在 try/catch 块中发生异常时自动释放资源的方法。通过声明一个 try-with-resources 块,如果在该块中引发异常,则检查 try 块的资源将被关闭。大多数内置到 Java 中的资源将在 try-with-resources 语句中正常工作(有关完整列表,请参见 java.lang.AutoCloseable 接口的实现者)。此外,第三方实现者可以通过实现 AutoCloseable 接口来创建使用 try-with-resources 语句的资源。
try-with-resources 语句的语法包括 try 关键字,后跟一个左括号,然后是您希望在发生异常或块完成时释放的所有资源声明,最后以右括号结束。请注意,如果您试图声明一个没有实现 AutoCloseable 接口的资源/变量,您将会收到一个编译器错误。在右括号之后,try/catch 块的语法与普通块相同。
try-with-resources 特性的主要优点是它允许更干净地释放资源。通常当获取一个资源时,会有很多相互依赖(创建文件处理程序,它们被包装在输出流中,输出流被包装在缓冲流中)。在异常情况下正确关闭和释放这些资源需要检查每个依赖资源的状态并小心地释放它,而这样做需要编写大量代码。相比之下,try-with-resources 构造允许 JVM 处理好资源的适当处置,即使是在异常情况下。
注意
try-with-resources 块将始终关闭已定义的资源,即使没有引发异常。
9-7.创建异常类
问题
您希望创建一种新类型的异常,用于指示特定事件。
解决方案 1
创建一个扩展 java.lang. RuntimeException 的类,创建一个可以随时抛出的异常类。在下面的代码中,由 IllegalChatServerException 标识的类扩展了 RuntimeException,并接受一个字符串作为构造函数的参数。然后,当代码中发生指定的事件时,将引发异常。
class IllegalChatServerException extends RuntimeException {
IllegalChatServerException(String message) {
super(message);
}
}
private void disconnectChatServer(Object chatServer) {
if (chatServer == null) throw new IllegalChatServerException("Chat server is empty");
}
解决方案 2
创建一个扩展 java.lang.Exception 的类来生成一个检查异常类。需要在栈中捕获或重新抛出检查过的异常。在以下示例中,标识为 ConnectionUnavailableException 的类扩展了 java.lang.Exception,并接受一个字符串作为构造函数的参数。然后,代码中的方法会引发检查到的异常。
class ConnectionUnavailableException extends Exception {
ConnectionUnavailableException(String message) {
super(message);
}
}
private void sendChat(String chatMessage) throws ConnectionUnavailableException {
if (chatServer == null)
throw new ConnectionUnavailableException("Can't find the chat server");
}
它是如何工作的
有时候需要创建一个定制的异常,尤其是在创建 API 的时候。通常的建议是使用 JDK 提供的可用异常类之一。例如,使用 IOException 处理与 I/O 相关的问题,或者使用 IllegalArgumentException 处理非法参数。如果没有完全合适的 JDK 异常,您总是可以扩展 java.lang.Exception 或 java.lang.RuntimeException 并实现自己的异常系列。
根据基类的不同,创建异常类相当简单。扩展 RuntimeException 使您能够在任何时候抛出结果异常,而无需将其捕获到栈中。这是有利的,因为 RuntimeException 是一个更宽松的契约,但是如果没有捕获到异常,抛出这样的异常会导致线程终止。相反,扩展 Exception 允许您明确地强制任何引发异常的代码能够在 catch 子句中处理它。然后,契约强制检查的异常实现 catch 处理程序,这可能会避免线程终止。
实际上,我们不鼓励扩展 RuntimeException,因为它会导致糟糕的异常处理。我们的经验是,如果有可能从异常中恢复,您应该通过扩展 exception 来创建相关的异常类。如果不能合理地期望开发人员从异常中恢复(比如 NullPointerException),那么扩展 RuntimeException。
9-8.重新引发捕获的异常
问题
您的应用包含一个多批处理异常,并且您想要重新引发一个以前被捕获的异常。
解决办法
从 catch 块中抛出异常,它将在被捕获的同一类型上再次抛出该异常。在下面的示例中,在代码块中捕获异常,并将其重新引发给方法的调用方。
private void doSomeWork() throws **IOException, InterruptedException** {
LinkedBlockingQueue<String> queue = new LinkedBlockingQueue<>();
try {
FileOutputStream fos = new FileOutputStream("out.log");
DataOutputStream dos = new DataOutputStream(fos);
while (!queue.isEmpty()) {
dos.writeUTF(queue.take());
}
} catch (InterruptedException | IOException e ) {
e.printStackTrace();
**throw e;**
}
}
它是如何工作的
可以简单地抛出之前捕获的异常,JVM 会将异常冒泡到适当的类型。就像抛出检查异常的情况一样;它也必须在方法声明中定义。在这个解决方案的示例中,doSomeWork()方法抛出一个 IOException 和一个 InterruptedException,这导致调用代码执行一个 try-catch 来适当地处理抛出的异常。
9-9.在应用中记录事件
问题
您希望记录事件、调试消息、错误情况和应用中的其他事件。
解决办法
利用应用中的 SLF4J 和 Java 日志 API 来实现日志解决方案。以下示例首先创建一个名为 recipeLogger 的 logger 对象。在此示例中,SLF4J API 用于记录信息性消息、警告消息和错误消息:
private void loadLoggingConfiguration() {
FileInputStream ins = null;
try {
ins = new FileInputStream(new File("logging.properties"));
LogManager.getLogManager().readConfiguration(ins);
} catch (IOException e) {
e.printStackTrace();
}
}
private void start() {
loadLoggingConfiguration();
Logger logger = LoggerFactory.getLogger("recipeLogger");
logger.info("Logging for the first Time!");
logger.warn("A warning to be had");
logger.error("This is an error!");
}
它是如何工作的
在示例中,loadLogConfiguration()函数打开一个指向 logging.properties 文件的流,并将其传递给 java.util.logging.LogManager()。这样做可以将 java.util.logging 框架配置为使用 logging.properties 文件中指定的设置。然后,在解决方案的 start 方法中,代码获取一个名为 recipeLogger 的 logger 对象。该示例继续通过 recipeLogger 将消息记录到。关于实际测井参数的更多信息可在配方 9-10 中找到。
SLF4J 提供了一个通用的 API,它使用一个简单的外观模式来抽象底层的日志实现。SLF4J 可以用于大多数常见的日志记录框架,如 Java 日志记录 API (java.util.logging)、Log4j、Jakarta Commons Logging 等。在实践中,SLF4J 提供了选择(和交换)日志框架的灵活性,并允许使用 SLF4J 的项目快速集成到应用选择的日志框架中。
要在应用中使用 SLF4J,下载位于 www.slf4j.org/的 SLF4J 二进制文件。下载完成后,提取内容并将 slf4j-api-x.x.x.jar 添加到项目中。这是主要的。包含 SLF4J API 的 jar 文件(程序可以调用它来记录信息)。将 slf4j-api-x.x.x.jar 文件添加到项目中后,找到 slf4j-jdk14-x.x.x.jar 并将其添加到项目中。第二个文件表明 SLF4J 将使用 java.util.logging 类来记录信息。
SLF4J 的工作方式是,在运行时 SLF4J 扫描类路径并选择第一个。实现 SLF4J API 的 jar。在示例中,找到并加载了 slf4j-jdk14-x.x.x.jar。这个。jar 代表本地 Java 日志框架(称为 jdk.1.4 日志)。例如,如果您想使用另一个日志记录框架,那么可以将 slf4j-jdk14-x.x.x.jar 替换为所需日志程序的相应 slf4j 实现。例如,要使用 Apache 的 Log4j 日志框架,包括 slf4j-log4j12-x.x.x.jar。
注意
java.util.logging 框架由属性日志文件配置。
一旦配置了 SLF4J,就可以通过调用 SLF4J 日志记录方法在应用中记录信息。这些方法根据日志记录级别记录信息。然后,可以使用日志记录级别来过滤实际记录的消息。按日志级别过滤消息的功能非常有用,因为可能会记录大量信息或调试信息。如果需要对应用进行故障排除,可以更改日志记录级别,并且可以在日志中显示更多信息,而无需更改任何代码。通过级别过滤消息的能力被称为设置日志级别。每个日志框架引用都包含自己的配置文件,用于设置日志级别(除了其他内容之外,如日志文件名和日志文件配置)。在本例中,因为 SLF4J 使用 java.util.logging 框架进行日志记录,所以您需要为所需的日志记录配置 java.util.logging 属性。见表 9-1 。
表 9-1。日志记录级别
|记录级别
|
建议
| | --- | --- | | 微量 | 最不重要的日志事件 | | 调试 | 用于帮助调试的额外信息 | | 信息 | 用于日常记录消息 | | 警告 | 用于可恢复的问题,或怀疑发生错误设置/不标准行为的情况 | | 错误 | 用于异常、实际错误和您确实需要知道的事情 | | 致命的 | 最重要 |
注意
设置日志级别时,记录器将在该级别及以下级别进行记录。因此,如果日志记录配置将日志级别设置为 info,则将记录 Info、Warn、Error 和 Fatal 级别的消息。
9-10.旋转和清除日志
问题
您已经开始记录信息,但是记录的信息继续增长,失去控制。您希望在日志文件中只保留最后 250KB 的日志条目。
解决办法
使用 SLF4J 和 java.util.logging 来配置滚动日志。在本例中,名为 recipeLogger 的日志记录器用于记录许多消息。输出将生成滚动日志文件,其中包含重要的 Log0.log 文件中最新记录的信息。
loadLoggingConfiguration();
Logger logger = LoggerFactory.getLogger("recipeLogger");
logger.info("Logging for the first Time!");
logger.warn("A warning to be had");
logger.error("This is an error!");
Logger rollingLogger = LoggerFactory.getLogger("rollingLogger");
for (int i =0;i < 5000;i++) {
rollingLogger.info("Logging for an event with :"+i);
}
logging.properties file
handlers = java.util.logging.FileHandler
recipeLogger.level=INFO
.level=ALL
java.util.logging.FileHandler.formatter=java.util.logging.SimpleFormatter
java.util.logging.FileHandler.pattern=ImportantApplication%d.log
java.util.logging.FileHandler.limit=50000
java.util.logging.FileHandler.count=4
它是如何工作的
要控制日志文件的大小,请配置 java.util.logging 框架并指定滚动日志文件。选择“滚动日志文件”选项会将最新信息保存在 ImportantApplication0.log 中。越旧的信息将保存在 ImportantApplication1.log、ImportantApplication2.log 中,依此类推。当 ImportantApplication0.log 填充到您指定的限制(在此示例中为 50,000 字节)时,其名称将旋转到 ImportantApplicationLog1.log,其他文件的名称也将类似地向下旋转。要维护的日志文件的数量由 Java . util . logging . file handler . count 属性决定,该属性在本例中设置为 4。
logging.properties 文件首先定义 java.util.logging 框架将使用的处理程序。处理程序是负责记录消息的对象。在配方中指定了 FileHandler,它将消息记录到文件中。其他可能的处理程序有 ConsoleHandler(记录到 system.output 设备)、SocketHandler(记录到套接字)和 MemoryHandler(将日志保存在内存的循环缓冲区中)。还可以通过创建一个扩展处理程序抽象类的类来指定自己的处理程序实现。
接下来,定义日志记录级别。在日志框架中,有一个独立日志对象的概念。记录器可以有不同的配置(例如,不同的日志记录级别),并且可以在日志文件中识别。该示例将 recipeLogger 的级别配置为 info,而根日志记录器的级别为 ALL(Java . util . logging 框架中的根日志记录器在属性前没有任何前缀)。
logging.properties 文件的下一节定义了 FileHandler 配置。格式化程序指示如何将日志信息写入磁盘。simpleFormatter 以纯文本格式写入信息,其中一行表示日期和时间,一行表示日志记录级别,还有要记录的消息。格式化程序的另一个默认选项是 XMLFormatter,它将为每个日志事件创建包含日期、时间、记录器名称、级别、线程和消息信息的 XML 标记。您可以通过扩展 Formatter 抽象类来创建自定义格式化程序。
在格式化程序之后,定义了 fileHandler 模式。这指定了日志文件的文件名和位置(滚动日志号[0∾4]替换了%d)。Limit 属性定义日志在翻转之前可以有多少字节(50,000 字节 50kb)。计数定义了要保留的日志文件的最大索引(在本例中是 4)。
注意
伐木成本可能很高;如果您正在记录大量信息,您的 Java 程序将开始消耗内存(因为 java.util.logging 框架将尝试将所有需要写入磁盘的信息保存在内存中,直到可以刷新这些信息)。如果 java.util.logging 框架写入日志文件的速度跟不上创建日志条目的速度,您将会遇到内存不足的错误。最好的方法是只记录必要的信息,如果需要的话,在写出调试日志消息之前查看 Logger.isDebugEnabled()。可以从日志记录配置文件中更改日志记录级别。
9-11.记录异常
从前面的菜谱中,您学习了如何捕捉异常以及如何记录信息。这个菜谱会把这两个菜谱放在一起。
问题
您希望在日志文件中记录异常。
解决办法
将应用配置为使用 SLF4J。利用 try/catch 块在错误日志中记录异常。在下面的示例中,SLF4J 记录器用于记录异常处理程序中的消息。
static Logger rootLogger = LoggerFactory.getLogger("");
private void start() {
loadLoggingConfiguration();
Thread.setDefaultUncaughtExceptionHandler((Thread t, Throwable e) -> {
rootLogger.error("Error in thread "+t+" caused by ",e);
});
int c = 20/0;
}
private void loadLoggingConfiguration() {
FileInputStream ins = null;
try {
ins = new FileInputStream(new File("logging.properties"));
LogManager.getLogManager().readConfiguration(ins);
} catch (IOException e) {
e.printStackTrace();
}
}
它是如何工作的
该示例演示了如何结合使用 UncaughtExceptionHandler 和 SLF4J 来将异常记录到日志文件中。在记录异常时,最好包括显示异常抛出位置的栈跟踪。在示例中,一个线程包含一个 UncaughtExceptionHandler,它利用了一个包含日志记录器的 lambda 表达式。记录器用于将任何捕获的异常写入日志文件。
注意
如果异常被重复抛出,JVM 倾向于停止在异常对象中填充栈跟踪。这样做是出于性能原因,因为检索相同的栈跟踪会变得很昂贵。如果发生这种情况,您将会看到一个没有记录栈跟踪的异常。当发生这种情况时,检查日志以前的条目,看看是否抛出了相同的异常。如果以前抛出过相同的异常,则完整的栈跟踪将出现在该异常的第一个日志实例中。
9-12.使用统一 JVM 日志记录器进行日志记录
问题
您希望执行 JVM 进程的日志记录,并且希望对日志记录进行细粒度的控制。
解决办法
利用作为 Java 9 的一部分添加的统一 JVM 记录器实用程序。在下面的解决方案中,JVM logger 实用程序被配置为执行日志记录并指向操作系统上的文件。
要启动日志记录,请打开命令提示符或终端,并执行以下语句:
java -Xlog:all:file=test.txt:time,level
该语句将配置 JVM 将所有标记记录到一个名为 test.txt 的文件中。下一个例子演示了如何使用“gc”记录标记,使用“trace”级别记录标记,使用“uptime”修饰记录到 stdout。
java –Xlog:gc=trace:uptime
它是如何工作的
Java 9 的发布增强了 JVM 的日志功能,允许一个统一的系统提供细粒度的控制。在过去,记录 JVM 系统级组件可能会成为一项耗时的任务,因为很难查明许多问题的根本原因。更新的日志记录工具提供了以下功能:
-
用于记录各种 JVM 进程的常见命令行选项
-
标签分类
-
日志记录级别之间的差异
-
记录到文件的能力
-
文件旋转能力
-
动态配置
要配置 JVM 日志记录,请使用–Xlog 标志执行 java.exe,将选项附加到用冒号[:]分隔的标志。如果您希望为 JVM 的单次运行执行日志记录,请在调用 Java 应用时包含–Xlog 标志。
–Xlog 标志有几个可用选项,以下列格式指示记录“内容”和“位置”:
-Xlog[:option=<what:level>:<output>:<decorators>:<output-options>]
请注意,在该格式中,您可以指定–Xlog 而不带任何选项,以指示应该记录所有标记,并且所有记录级别都将转到 stdout。在该解决方案中,我们看到,要配置所有标记的日志记录,您还可以指定“all”选项。省略部分将默认使用“info”级别的标签集“all”忽略将默认为“信息”表 9-2 中列出了可用的装饰器,省略它们会默认为“正常运行时间”、“级别”、“标签”
表 9-2。Xlog 装饰者
|装饰者
|
描述
| | --- | --- | | 时间 | 当前时间和日期(ISO-8601) | | 正常运行时间 | 自 JVM 启动以来超过的时间量(秒和毫秒) | | 时间英里 | System.currentTimeMillis()输出 | | Uptimemillis | 自 JVM 启动以来已超过毫秒 | | 时间纳米 | System.nanoTime()输出 | | 上升趋势 | 自从 JVM 启动以来,纳秒被超越了 | | Pid | 进程标识符 | | 每日三次 | 线程标识符 | | 水平 | 相关日志消息级别 | | 标签 | 关联日志消息标记 |
支持三种类型的输出:标准输出、标准错误和文本文件。通过指定输出选项,可以将输出配置为旋转文件、限制文件大小等。可能的输出选项包括:
-
文件计数 =
-
文件大小 =
-
参数=值
可以通过 jcmd 诊断命令实用程序在运行时控制日志 API。命令行中可用的所有选项也可以通过该实用程序获得。
注意
使用–Xlog:help 开关可以获得关于 JVM 日志记录实用程序的帮助。该开关将打印用法语法和可用的标签、级别、装饰符和示例。
摘要
在这一节中,我们看了一下应用开发中最重要的阶段之一,异常处理。这些小节讨论了如何处理单个和多个异常,以及如何记录这些异常。有许多成熟的日志 API 可用于 JVM,我们在本章中讨论了 SLF4J。最后,我们看了一下 Java 9 中引入的统一 JVM 日志记录过程。
十、并发
并发是现代计算机编程中最难处理的话题之一;理解并发性需要抽象思维的能力,调试并发问题就像试图通过航位推算驾驶飞机一样。即便如此,随着 Java 的现代发布,编写无 bug 的并发代码变得更加容易(也更容易访问)。
并发是一个程序同时执行不同(或相同)指令的能力。一个被称为并发的程序能够被分割并在多个 CPU 上运行。通过编写并发程序,您可以利用当今的多核 CPU。您甚至可以看到 I/O 密集型单核 CPU 的优势。
在这一章中,我们介绍了并发任务最常见的需求——从运行后台任务到将计算拆分成工作单元。在这一章中,你会发现在 Java 中实现并发性的最新方法。
10-1.启动后台任务
问题
您有一个需要在主线程之外运行的任务。
解决办法
创建一个包含需要在不同线程中运行的任务的类实现。在任务实现类中实现一个 Runnable 接口,启动一个新线程。在下面的示例中,一个计数器用于模拟活动,因为一个单独的任务在后台运行。
注意
这个例子中的代码可以被重构以利用方法引用(参见第六章),而不是为新的线程实现创建一个内部类。然而为了清楚起见,匿名内部类已经被显示。
private void someMethod() {
Thread backgroundThread = new Thread(new Runnable() {
public void run() {
doSomethingInBackground();
}
},"Background Thread");
System.out.println("Start");
backgroundThread.start();
for (int i= 0;i < 10;i++) {
System.out.println(Thread.currentThread().getName()+": is counting "+i);
}
System.out.println("Done");
}
private void doSomethingInBackground() {
System.out.println(Thread.currentThread().getName()+
": is Running in the background");
}
如果代码执行多次,输出应该会不时地不同。后台线程将单独执行,因此它的消息在每次运行的不同时间打印。
如果使用 lambda 表达式,创建后台线程的相同代码可以编写如下:
Thread backgroundThread = new Thread(this::doSomethingInBackground, "Background Thread");
它是如何工作的
Thread 类允许在不同于当前线程的新线程(执行路径)中执行代码。线程构造函数需要一个实现 Runnable 接口的类作为参数。Runnable 接口只需要实现一个方法:public void run()。因此,它是一个函数接口,方便了 lambda 表达式的使用。当调用 Thread.start()方法时,它将依次创建新线程并调用 Runnable 的 run()方法。
JVM 中有两种类型的线程:用户和守护进程。用户线程会一直执行,直到它们的 run()方法完成,而守护线程可以在应用需要退出时终止。如果 JVM 中只有守护线程在运行,应用就会退出。当您开始创建多线程应用时,您必须意识到这些差异,并了解何时使用每种类型的线程。
通常,守护线程会有一个不完整的可运行接口;例如 while(真)循环。这允许这些线程在程序的整个生命周期中定期检查或执行某个条件,并在程序完成执行时被丢弃。相反,用户线程在活动时会执行并阻止程序终止。如果您碰巧有一个程序没有像预期的那样关闭和/或退出,您可能希望检查正在运行的线程。
若要将线程设置为守护进程线程,请在调用 thread.start()方法之前使用 thread.setDaemon(true)。默认情况下,线程实例被创建为用户线程类型。
注意
这个食谱展示了创建和执行一个新线程的最简单的方法。创建的新线程是一个用户线程,这意味着应用在主线程和后台线程都执行完之前不会退出。
10-2.更新(和迭代)地图
问题
您需要从多个线程更新一个 Map 对象,并且希望确保更新不会破坏 Map 对象的内容,并且 Map 对象始终处于一致的状态。您还希望在其他线程更新 Map 对象时遍历(查看)Map 对象的内容。
解决办法
使用 ConcurrentMap 更新映射条目。以下示例创建了 1,000 个线程。然后每个线程同时尝试修改映射。主线程等待一秒钟,然后继续遍历地图(即使其他线程仍在修改地图):
Set<Thread> updateThreads = new HashSet<>();
private void startProcess() {
ConcurrentMap<Integer,String> concurrentMap = new ConcurrentHashMap<>();
for (int i =0;i < 1000;i++) {
startUpdateThread(i, concurrentMap);
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
concurrentMap.entrySet().stream().forEach((entry) -> {
System.out.println("Key :"+entry.getKey()+" Value:"+entry.getValue());
});
updateThreads.stream().forEach((thread) -> {
thread.interrupt();
});
}
Random random = new Random();
private void startUpdateThread(int i, final ConcurrentMap<Integer, String> concurrentMap) {
Thread thread = new Thread(() -> {
while (!Thread.interrupted()) {
int randomInt = random.nextInt(20);
concurrentMap.put(randomInt, UUID.randomUUID().toString());
}
});
thread.setName("Update Thread "+i);
updateThreads.add(thread);
thread.start();
}
它是如何工作的
为了以并发方式在哈希表上执行工作,ConcurrentHashMap 允许多个线程同时安全地修改哈希表。ConcurrentHashMap 是一个哈希表,支持检索的完全并发性和更新的可调预期并发性。在本例中,1000 个线程在很短的时间内对地图进行了修改。ConcurrentHashMap 迭代器以及在 ConcurrentHashMap 上生成的流允许对其内容进行安全迭代。当使用 ConcurrentMap 的迭代器时,您不必担心在对其进行迭代时锁定 ConcurrentMap 的内容(并且它不会抛出 ConcurrentModificationExceptions)。
有关新添加方法的完整列表,请参考位于docs . Oracle . com/javase/9/docs/API/Java/util/concurrent/concurrent hashmap . html的在线文档。
注意
ConcurrentMap 迭代器虽然是线程安全的,但并不保证在迭代器创建后会看到条目被添加/更新。
10-3.仅当键不存在时,才在映射中插入键
问题
应用中的映射会不断更新,如果键不存在,您需要将键/值对放入其中。因此,您需要检查这个键是否存在,并且您需要确保其他线程不会同时插入同一个键。
解决办法
使用 concurrent map . puti absent()方法,可以确定映射是否被自动修改。例如,下面的代码使用方法在一个步骤中进行检查和插入,从而避免了并发问题:
private void start() {
ConcurrentMap<Integer, String> concurrentMap = new ConcurrentHashMap<>();
for (int i = 0; i < 100; i++) {
startUpdateThread(i, concurrentMap);
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
concurrentMap.entrySet().stream().forEach((entry) -> {
System.out.println("Key :" + entry.getKey() + " Value:" + entry.getValue());
});
}
Random random = new Random();
private void startUpdateThread(final int i, final ConcurrentMap<Integer, String> concurrentMap) {
Thread thread = new Thread(() -> {
int randomInt = random.nextInt(20);
String previousEntry = concurrentMap.putIfAbsent(randomInt, "Thread # " + i + " has made it!");
if (previousEntry != null) {
System.out.println("Thread # " + i + " tried to update it but guess what, we're too late!");
} else {
System.out.println("Thread # " + i + " has made it!");
}
});
thread.start();
}
当运行程序时,一些条目将被成功插入,而另一些则不会,因为键已经被另一个线程插入。注意,在这个例子中,startUpdateThread()接受一个最终的 int i 参数。将方法参数标记为 final 可确保该方法不会更改变量 I 的值。如果 I 的值在方法内部发生了更改,则从方法外部看不到这种更改。
它是如何工作的
并发更新地图是困难的,因为它涉及两个操作:一个检查然后行动类型的操作。首先,必须检查映射以查看其中是否已经存在一个条目。如果条目不存在,可以将键和值放入映射中。另一方面,如果键存在,则检索该键的值。为此,我们使用 ConcurrentMap 的 putIfAbsent 原子操作。这确保要么键存在,因此值不会被覆盖,要么键不存在,因此值被设置。对于 ConcurrentMap 的 JDK 实现,如果键没有值,putIfAbsent()方法将返回 null,如果键有值,则返回当前值。通过断言 putIfAbsent()方法返回 null,可以确保操作成功,并且在映射中创建了一个新条目。
在某些情况下,putIfAbsent()的执行效率可能不高。例如,如果结果是一个大型数据库查询,那么一直执行数据库查询,然后调用 putIfAbsent()就不会有效率。在这种情况下,您可以首先调用 map 的 containsKey()方法来确保该键不存在。如果它不存在,那么用昂贵的数据库查询调用 putIfAbsent()。可能有可能 putIfAbsent()没有放入条目,但是这种类型的检查减少了潜在的昂贵的值创建的数量。
请参见以下代码片段:
keyPresent = concurrentMap.containsKey(randomInt);
if (!keyPresent) {
concurrentMap.putIfAbsent(randomInt, "Thread # " + i + " has made it!");
}
在这段代码中,第一个操作是检查该键是否已经在映射中。如果是,它不会执行 putIfAbsent()操作。如果这个键不存在,我们可以继续执行 putIfAbsent()操作。
如果从不同的线程访问 map 的值,应该确保这些值是线程安全的。这在使用集合作为值时最为明显,因为它们可以从不同的线程访问。确保主映射是线程安全的将防止对映射的并发修改。然而,一旦获得了对映射值的访问权,就必须围绕映射值进行良好的并发实践。
注意
ConcurrentMaps 不允许空键,这与其非线程安全的表亲 HashMap(允许空键)不同。
10-4.遍历变化的集合
问题
您需要迭代集合中的每个元素。但是,其他线程会不断更新集合。
解决方案 1
通过使用 CopyOnWriteArrayList,您可以安全地循环访问集合,而不必担心并发性。在下面的解决方案中,startUpdatingThread()方法创建了一个新线程,它主动更改传递给它的列表。当 startUpdatingThread()修改列表时,使用 stream forEach()函数同时迭代它。
private void copyOnWriteSolution() {
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<String>();
startUpdatingThread(list);
list.stream().forEach((element) -> {
System.out.println("Element :" + element);
});
stopUpdatingThread();
}
解决方案 2
使用 synchronizedList()允许我们原子地改变集合。此外,synchronizedList()提供了一种在遍历列表时安全同步列表的方法(在流中完成)。例如:
private void synchronizedListSolution() {
final List<String> list = Collections.synchronizedList(new ArrayList<String>());
startUpdatingThread(list);
synchronized (list) {
list.stream().forEach((element) -> {
System.out.println("Element :" + element);
});
}
stopUpdatingThread();
}
它是如何工作的
Java 附带了许多并发收集选项。使用哪个集合取决于应用上下文中读操作与写操作的比较。如果与读取相比,写入发生在远处和中间,使用 copyOnWriteArrayList 实例是最有效的,因为它不会阻止 (stop)其他线程读取列表,并且迭代是线程安全的(迭代时不会引发 ConcurrentModificationException)。如果有相同数量的写入和读取,使用 SynchronizedList 是首选。
在解决方案 1 中,当您遍历列表时,CopyOnWriteArrayList 正在更新。因为这个配方使用了 CopyOnWriteArrayList 实例,所以在遍历集合时不需要担心线程安全(正如在这个配方中使用 stream 所做的那样)。值得注意的是,CopyOnWriteArrayList 在遍历它时提供了一个及时的快照。如果另一个线程在你迭代的时候修改了这个列表,那么修改后的列表在迭代的时候是不可见的。
注意
正确锁定取决于所使用的集合类型。使用 Collections.synchronized 返回的任何集合都可以通过集合本身锁定(synchronized(collection instance))。然而,一些更有效(更新)的并发集合(如 ConcurrentMap)不能以这种方式使用,因为它们的内部实现并不锁定对象本身。
解决方案 2 创建一个同步列表,它是通过使用 Collections helper 类创建的。Collection.synchronizedList()方法将 List 对象(可以是 ArrayList、LinkedList 或另一个 List 实现)包装到一个列表中,以同步对列表操作的访问。每次需要迭代一个列表时(无论是通过使用流、for 循环还是迭代器),都必须意识到列表迭代器的并发性。对 CopyOnWriteArrayList 进行迭代是安全的(如 Javadoc 中所指定的),但是 synchronizedList 迭代器必须手动同步(也在 collections . synchronized list . list 迭代器 Javadoc 中指定)。在这个解决方案中,列表可以在 synchronized(list)块中安全地迭代。当对列表进行同步时,在同步(列表)块完成之前,不会发生读取/更新/其他迭代。
10-5.协调不同的收藏
问题
您需要同时修改不同但相关的集合,并且希望确保在这些修改完成之前,其他线程看不到这些修改。
解决方案 1
通过对主体集合进行同步,可以保证集合可以同时更新。在下面的示例中,fulfillOrder 方法需要检查要履行的订单的库存,如果有足够的库存来履行订单,它需要将订单添加到 customerOrders 列表中。fulfillOrder()方法同步 inventoryMap,并在完成同步块之前修改 inventoryMap 和 customerOrders 列表。
private boolean fulfillOrder(String itemOrdered, int quantityOrdered, String customerName) {
synchronized (inventoryMap) {
int currentInventory = 0;
if (inventoryMap != null) {
currentInventory = inventoryMap.get(itemOrdered);
}
if (currentInventory < quantityOrdered) {
System.out.println("Couldn't fulfill order for "+customerName+" not enough "+itemOrdered+" ("+quantityOrdered+")");
return false; // sorry, we sold out
}
inventoryMap.put(itemOrdered,currentInventory - quantityOrdered);
CustomerOrder order = new CustomerOrder(itemOrdered, quantityOrdered, customerName);
customerOrders.add(order);
System.out.println("Order fulfilled for "+customerName+" of "+itemOrdered+" ("+quantityOrdered+")");
return true;
}
}
private void checkInventoryLevels() {
synchronized (inventoryMap) {
System.out.println("------------------------------------");
inventoryMap.entrySet().stream().forEach((inventoryEntry) -> {
System.out.println("Inventory Level :"+inventoryEntry.getKey()+" "+inventoryEntry.getValue());
});
System.out.println("------------------------------------");
}
}
private void displayOrders() {
synchronized (inventoryMap) {
customerOrders.stream().forEach((order) -> {
System.out.println(order.getQuantityOrdered()+" "+order.getItemOrdered()+" for "+order.getCustomerName());
});
}
}
解决方案 2
使用可重入锁,可以防止多个线程访问代码的同一个关键区域。在这个解决方案中,inventoryLock 是通过调用 inventoryLock.lock()获得的。任何试图获取 inventoryLock 锁的其他线程都必须等待,直到 inventoryLock 锁被释放。在 fulfillOrder()方法结束时(在 finally 块中),通过调用 inventoryLock.unlock()方法来释放 inventoryLock:
Lock inventoryLock = new ReentrantLock();
private boolean fulfillOrder(String itemOrdered, int quantityOrdered, String customerName) {
try {
inventoryLock.lock();
int currentInventory = inventoryMap.get(itemOrdered);
if (currentInventory < quantityOrdered) {
System.out.println("Couldn't fulfill order for " + customerName +
" not enough " + itemOrdered + " (" + quantityOrdered + ")");
return false; // sorry, we sold out
}
inventoryMap.put(itemOrdered, currentInventory - quantityOrdered);
CustomerOrder order = new CustomerOrder(itemOrdered, quantityOrdered, customerName);
customerOrders.add(order);
System.out.println("Order fulfilled for " + customerName + " of " +
itemOrdered + " (" + quantityOrdered + ")");
return true;
} finally {
inventoryLock.unlock();
}
}
private void checkInventoryLevels() {
try {
inventoryLock.lock();
System.out.println("------------------------------------");
inventoryMap.entrySet().stream().forEach((inventoryEntry) -> {
System.out.println("Inventory Level :" + inventoryEntry.getKey() + " " + inventoryEntry.getValue());
});
System.out.println("------------------------------------");
} finally {
inventoryLock.unlock();
}
}
private void displayOrders() {
try {
inventoryLock.lock();
customerOrders.stream().forEach((order) -> {
System.out.println(order.getQuantityOrdered() + " " +
order.getItemOrdered() + " for " + order.getCustomerName());
});
} finally {
inventoryLock.unlock();
}
}
它是如何工作的
如果您有不同的结构需要同时修改,您需要确保这些结构是自动更新的。一个原子操作指的是一组可以整体执行或者根本不执行的指令。原子操作只有在完成时才对程序的其余部分可见。
在解决方案 1 中(自动修改 inventoryMap 和 customerOrders 列表),您选择一个将锁定的“主体”集合(inventoryMap)。通过锁定主体集合,可以保证如果另一个线程试图锁定同一个主体集合,它将不得不等待,直到当前正在执行的线程释放对该集合的锁定。
注意
请注意,即使 displayOrders 不使用 inventoryMap,您仍然在它上面进行同步(在解决方案 1 中)。因为 inventoryMap 是主集合,所以即使是在辅助集合上完成的操作也仍然需要受到主集合同步的保护。
解决方案 2 更加明确,它提供了一个独立的锁,用于协调原子操作,而不是选择一个主体集合。锁定指的是 JVM 限制某些代码路径只能由一个线程执行的能力。线程试图获取锁(例如,锁是由 ReentrantLock 实例提供的,如示例所示),并且一次只能将锁授予一个线程。如果其他线程试图获取同一个锁,它们将被挂起(等待),直到锁可用。当当前持有该锁的线程释放它时,该锁变得可用。当一个锁被释放时,它可以被一个(且只有一个)等待该锁的线程获取。
默认情况下,锁并不“公平”换句话说,不保持请求锁的线程的顺序;这允许 JVM 中非常快速的锁定/解锁实现,并且在大多数情况下,使用不公平的锁通常是可以的。对于竞争非常激烈的锁,如果需要平均分配锁(使其公平),可以通过设置锁的 setFair 属性来实现。
在解决方案 2 中,调用 inventoryLock.lock()方法将获得锁并继续,或者将暂停执行(等待)直到可以获得锁。一旦获得锁,其他线程将无法在锁定的块中执行。在代码块的末尾,通过调用 inventoryLock.unlock()释放锁。
在使用锁对象(ReentrantLock、ReadLock 和 WriteLock)时,通常的做法是通过 try/finally 子句来使用这些锁对象。打开 try 块后,第一条指令是调用 lock.lock()方法。这保证了执行的第一条指令是获取锁。锁的释放(通过调用 lock.unlock())是在匹配的 finally 块中完成的。如果在获取锁时发生 RuntimeException,在 finally 子句中解锁可以确保不会“保留”锁并阻止其他线程获取它。
ReentrantLock 对象的使用提供了 synchronized 语句所没有的附加特性。例如,ReentrantLock 具有 tryLock()函数,该函数仅在没有其他线程拥有锁的情况下尝试获取锁(该方法不会让调用线程等待)。如果另一个线程持有锁,则该方法返回 false,但继续执行。最好使用 synchronized 关键字进行同步,并且只在需要 ReentrantLock 的特性时才使用它。有关 ReentrantLock 提供的其他方法的更多信息,请访问docs . Oracle . com/javase/9/docs/API/Java/util/concurrent/locks/reentrant lock . html。
小费
虽然这只是一本食谱,而且正确的线程技术也涵盖了它们自己的内容,但是提高对死锁的认识是很重要的。死锁发生在涉及两个锁时(并且在另一个线程中以相反的顺序获得)。避免死锁的最简单的方法是避免让锁“逃脱”这意味着锁在被获取时不应执行调用其他方法的代码,这些方法可能会获取不同的锁。如果不可能,在调用这样的方法之前释放锁。
应该注意的是,引用一个或两个集合的任何操作都需要由同一个锁来保护。依赖一个集合的结果来查询第二个集合的操作需要原子地执行;它们需要作为一个单元来完成,在操作完成之前,任何一个集合都不能改变。
10-6.将工作拆分到不同的线程中
问题
您的工作可以拆分到单独的线程中,并且希望最大限度地利用可用的 CPU 资源。
解决办法
使用 ThreadpoolExecutor 实例,它允许我们将任务分成离散的单元。在下面的示例中,创建了一个 BlockingQueue,其中包含一个 Runnable 对象。然后将它传递给 ThreadPoolExecutor 实例。然后通过调用 prestartAllCoreThreads()方法来初始化和启动 ThreadPoolExecutor。接下来,通过调用 shutdown()方法,然后调用 awaitTermination()方法,执行一次有序的关闭,其中执行所有以前提交的任务:
private void start() throws InterruptedException {
BlockingQueue<Runnable> queue = new LinkedBlockingQueue<>();
for (int i =0;i < 10;i++) {
final int localI = i;
queue.add((Runnable) () -> {
doExpensiveOperation(localI);
});
}
ThreadPoolExecutor executor = new ThreadPoolExecutor(10,10,1000,
TimeUnit.MILLISECONDS, queue);
executor.prestartAllCoreThreads();
executor.shutdown();
executor.awaitTermination(100000,TimeUnit.SECONDS);
System.out.println("Look ma! all operations were completed");
}
它是如何工作的
ThreadPoolExecutor 由两个组件组成:要执行的任务队列,以及告诉如何执行任务的执行器。队列中填充了 Runnable 对象,run()方法包含要执行的代码。
ThreadPoolExecutor 使用的队列是 BlockingQueue 接口的实现者。BlockingQueue 接口表示一个队列,如果队列中没有元素,队列的使用者将在该队列中等待(被挂起)。这是 ThreadPoolExecutor 高效工作所必需的。
第一步是用需要并行执行的任务填充队列。这是通过调用队列的 add()方法并向其传递一个实现 Runnable 接口的类来实现的。一旦完成,执行器就被初始化了。
ThreadPoolExecutor 构造函数有许多参数选项;解决方案中使用的是最简单的。表 10-1 有每个参数的描述。
表 10-1。ThreadPoolExecutor 的参数
|参数
|
描述
| | --- | --- | | 最小雇佣数量 | 提交任务时创建的最小线程数 | | MaximumPoolSize | 执行器将创建的最大线程数 | | KeepAliveTime | 等待线程在被释放之前等待工作的时间(只要活动线程的数量仍然大于 CorePoolSize) | | 时间单元 | 表示 KeepAliveTime 的单位(即 TimeUnit。秒,时间单位。毫秒) | | WorkQueue(工作队列) | 包含将由执行器处理的任务的阻塞队列 |
在 ThreadPoolExecutor 初始化之后,调用 prestartAllCore Threads()。此方法通过创建 CorePoolSize 中指定数量的线程来“预热”ThreadPoolExecutor,并在队列不为空时主动开始消耗队列中的任务。
调用 ThreadPoolExecutor 的 shutdown()方法,等待所有任务完成。通过调用此方法,ThreadPoolExecutor 被指示不接受来自队列的新事件(以前提交的事件将完成处理)。这是有序终止 ThreadPoolExecutor 的第一步。调用 awaitTermination()方法等待 ThreadPoolExecutor 中的所有任务完成。该方法将强制主线程等待,直到 ThreadPoolExecutor 队列中的所有 Runnables 都执行完毕。在所有的 Runnables 都被执行后,主线程将被唤醒并继续执行。
注意
需要正确配置 ThreadPoolExecutor 以最大限度地利用 CPU。对于一个执行器来说,最有效的线程数量取决于提交的任务类型。如果任务是 CPU 密集型的,那么拥有一个具有当前内核数量的执行器将是理想的。如果任务是 I/O 密集型的,执行器应该拥有比当前线程核心数更多的线程。I/O 绑定越多,线程数量就越多。
10-7.协调线程
问题
您的应用要求两个或多个线程协调一致地工作。
解决方案 1
通过线程同步的等待/通知,可以协调线程。在这个解决方案中,主线程等待 objectToSync 对象,直到数据库加载线程完成执行。一旦数据库加载线程完成,它就通知 objectToSync,等待它的任何人都可以继续执行。将订单加载到我们的系统时,也会发生同样的过程。主线程等待 objectToSync,直到 orders-loading 线程通过调用 objectToSync.notify()方法通知 objectToSync 继续。在确保装载了库存和订单之后,主线程执行 processOrder()方法来处理所有订单。
private final Object objectToSync = new Object();
private void start() {
loadItems();
Thread inventoryThread = new Thread(() -> {
System.out.println("Loading Inventory from Database...");
loadInventory();
synchronized (objectToSync) {
objectToSync.notify();
}
});
synchronized (objectToSync) {
inventoryThread.start();
try {
objectToSync.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
Thread ordersThread = new Thread(() -> {
System.out.println("Loading Orders from XML Web service...");
loadOrders();
synchronized (objectToSync) {
objectToSync.notify();
}
});
synchronized (objectToSync) {
ordersThread.start();
try {
objectToSync.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
processOrders();
}
解决方案 2
您可以使用 CountDownLatch 对象控制主线程何时继续。在下面的代码中,创建了一个初始值为 2 的 CountDownLatch 然后创建并启动两个线程,一个用于装载库存,另一个用于装载订单信息。当两个线程都完成执行时,它们调用 CountDownLatch 的 countDown()方法,该方法将闩锁的值减 1。主线程会一直等到 CountDownLatch 达到 0,然后继续执行。
CountDownLatch latch = new CountDownLatch(2);
private void start() {
loadItems();
Thread inventoryThread = new Thread(() -> {
System.out.println("Loading Inventory from Database...");
loadInventory();
latch.countDown();
});
inventoryThread.start();
Thread ordersThread = new Thread(() -> {
System.out.println("Loading Orders from XML Web service...");
loadOrders();
latch.countDown();
});
ordersThread.start();
try {
latch.await();
} catch (InterruptedException e) {
e.printStackTrace();
}
processOrders();
}
解决方案 3
通过使用 Thread.join(),您可以等待线程完成执行。下面的例子有一个装载库存的线程和另一个装载订单的线程。一旦每个线程被启动,对 inventoryThread.join()的调用将使主线程在继续之前等待 inventoryThread 完成执行。
private void start() {
loadItems();
Thread inventoryThread = new Thread(() -> {
System.out.println("Loading Inventory from Database...");
loadInventory();
});
inventoryThread.start();
try {
inventoryThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
Thread ordersThread = new Thread(() -> {
System.out.println("Loading Orders from XML Web service...");
loadOrders();
});
ordersThread.start();
try {
ordersThread.join();
} catch (InterruptedException e) {
e.printStackTrace();
}
processOrders();
}
它是如何工作的
Java 中有许多协调线程的方法,这些协调工作依赖于让线程等待的概念。当线程等待时,它会暂停执行(它不会继续执行下一条指令,并从 JVM 的线程调度器中删除)。如果一个线程正在等待,那么可以通过通知它来唤醒它。在 Java 的并发行话中,单词 notify 意味着线程将恢复执行(JVM 将线程添加到线程调度器中)。因此,在线程协调的自然过程中,最常见的事件序列是一个主线程等待,然后一个辅助线程通知主线程继续(或唤醒)。即便如此,等待线程仍有可能被其他事件中断。当线程被中断时,它不会继续执行下一条指令,而是抛出 InterruptedException,这是一种发出信号的方式,即使线程正在等待某件事情发生,也有一些其他事件发生了,需要线程的注意。下面的例子更好地说明了这一点:
BlockingQueue queue = new LinkedBlockingQueue();
while (true) {
synchronized (this) {
Object itemToProcess = queue.take();
processItem (itemToProcess);
}
}
如果您查看前面的代码,执行该代码的线程将永远不会终止,因为它会永远循环下去,并等待一个项目被处理。如果队列中没有项目,主线程会一直等待,直到另一个线程将项目添加到队列中。您不能优雅地关闭前面的代码(特别是如果运行循环的线程不是守护线程)。
BlockingQueue queue = new LinkedBlockingQueue();
while (true) {
synchronized (this) {
Object itemToProcess = null;
try {
itemToProcess = queue.take();
} catch (InterruptedException e) {
return;
}
processItem (itemToProcess);
}
}
新代码现在具有“逃离”无限循环的能力。从另一个线程中,可以调用 thread.interrupt(),它抛出 InterruptedException,然后由主线程的 catch 子句捕获。可以在该子句中退出无限循环。
中断异常是一种向等待(或休眠)线程发送额外信息的方式,以便它们可以处理不同的场景(例如,有序的程序关闭)。因此,每个将线程状态更改为 sleep/wait 的操作都必须由 try/catch 块包围,该块可以捕获 InterruptedException。在这种情况下,异常(InterruptedException)并不是真正的错误,而是一种在线程之间发出信号的方式,表明发生了需要注意的事情。
解决方案 1 展示了最常见(最古老)的协调形式。该解决方案要求让一个线程等待并暂停执行,直到另一个线程通知(或唤醒)该线程。
要使解决方案 1 起作用,发起线程需要获得一个锁。这个锁将成为“电话号码”,在这个号码上另一个线程可以通知发起线程醒来。在发起线程获得锁(电话号码)后,它继续等待。只要调用 wait()方法,锁就会被释放,从而允许其他线程获得同一个锁。然后,辅助线程继续获取锁(电话号码),然后通知(实际上,这就像拨打唤醒电话)发起线程。通知之后,发起线程恢复执行。
在解决方案 1 的代码中,锁是一个标识为 objectToSync 的虚拟对象。实际上,锁正在等待和通知的对象可以是 Java 中任何有效的实例对象;例如,我们可以使用 this 引用让主线程等待(在线程中,我们可以使用 Recipe 10_7_1.this 变量引用来通知主线程继续)。
使用这种技术的主要优点是控制等待谁以及何时通知的明确性(以及通知所有正在等待同一对象的线程的能力;参见下面的提示)。
小费
多个线程可以等待同一个锁(同一个电话号码被唤醒)。当一个辅助线程调用 notify 时,它将唤醒一个“等待”线程(关于哪个被唤醒是不公平的)。有时你需要通知所有的线程;您可以调用 notifyAll()方法,而不是调用 notify()方法。这主要用于准备许多线程来执行一些工作,但工作尚未完成设置的情况。
解决方案 2 使用更现代的通知方法,因为它涉及 CountDownLatch。设置时,指定闩锁的“计数”数。然后,主线程将通过调用 CountDownLatch 的 await()方法来等待(停止执行),直到闩锁倒计时到 0。当 latch 达到 0 时,主线程将被唤醒并继续执行。当工作线程完成时,调用 latch.countdown()方法,这将减少闩锁的当前计数值。如果 latch 的当前值达到 0,等待 CountDownLatch 的主线程将被唤醒并继续执行。
使用 CountDownLatches 的主要优点是可以同时生成多个任务,然后等待所有任务完成。(在解决方案示例中,我们不需要等到一个或另一个线程完成后再继续;都是启动的,当 latch 为 0 时,主线程继续。)
解决方案 3 提供了一种解决方案,在这种方案中,我们可以访问我们想要等待的线程。对于主线程来说,调用次线程的 join()方法就行了。那么主线程将等待(停止执行)直到次线程完成。
这种方法的优点是它不需要辅助线程知道任何同步机制。只要辅助线程终止执行,主线程就可以等待它们。
10-8.创建线程安全的对象
问题
您需要创建一个线程安全的对象,因为它将被多个线程访问。
解决方案 1
使用同步的 getters 和 setters,保护会改变状态的关键区域。在下面的示例中,创建了一个对象,其中的 getters 和 setters 针对每个内部变量进行了同步。通过使用同步(this)锁来保护关键区域:
class CustomerOrder {
private String itemOrdered;
private int quantityOrdered;
private String customerName;
public CustomerOrder() {
}
public double calculateOrderTotal (double price) {
synchronized (this) {
return getQuantityOrdered()*price;
}
}
public synchronized String getItemOrdered() {
return itemOrdered;
}
public synchronized int getQuantityOrdered() {
return quantityOrdered;
}
public synchronized String getCustomerName() {
return customerName;
}
public synchronized void setItemOrdered(String itemOrdered) {
this.itemOrdered = itemOrdered;
}
public synchronized void setQuantityOrdered(int quantityOrdered) {
this.quantityOrdered = quantityOrdered;
}
public synchronized void setCustomerName(String customerName) {
this.customerName = customerName;
}
}
解决方案 2
创建一个不可变的对象(一个一旦创建就不会改变其内部状态的对象)。在下面的代码中,对象的内部变量被声明为 final,并在构造时被赋值。这样做可以保证对象是不可变的:
class ImmutableCustomerOrder {
final private String itemOrdered;
final private int quantityOrdered;
final private String customerName;
ImmutableCustomerOrder(String itemOrdered, int quantityOrdered, String customerName) {
this.itemOrdered = itemOrdered;
this.quantityOrdered = quantityOrdered;
this.customerName = customerName;
}
public String getItemOrdered() {
return itemOrdered;
}
public int getQuantityOrdered() {
return quantityOrdered;
}
public String getCustomerName() {
return customerName;
}
public double calculateOrderTotal (double price) {
return getQuantityOrdered()*price;
}
}
它是如何工作的
解决方案 1 依赖于锁保护对对象所做的任何更改的原则。使用 synchronized 关键字是编写 synchronized (this)表达式的捷径。通过同步 getters 和 setters(以及任何其他改变对象内部状态的操作),可以保证对象是一致的。同样重要的是,任何作为一个单元发生的操作(比如同时修改两个集合的操作,如配方 10-5 中所列)都在对象的方法中完成,并使用 synchronized 关键字保护。
例如,如果一个对象提供了 getSize()方法和 getItemNumber(int index),那么编写下面的 object . getItemNumber(object . getSize()-1)是不安全的。尽管语句看起来很简洁,但另一个线程可以在获取大小和获取项目编号之间改变对象的内容。相反,创建 object.getLastElement()方法更安全,该方法自动计算大小和最后一个元素。
解决方案 2 依赖于不可变对象的属性。不可变对象不能改变它们的内部状态,不能改变它们的内部状态的对象(是不可变的)根据定义是线程安全的。如果由于某个事件而需要修改不可变对象,不要显式地更改其属性,而是用更改后的属性创建一个新对象。然后,这个新对象将取代旧对象,在将来请求该对象时,将返回新的不可变对象。这是迄今为止创建线程安全代码最简单(尽管冗长)的方法。
10-9.实现线程安全计数器
问题
您需要一个线程安全的计数器,以便它可以在不同的执行线程中递增。
解决办法
通过使用固有的线程安全原子对象,可以创建一个保证线程安全并具有优化的同步策略的计数器。在以下代码中,创建了一个 Order 对象,它需要一个唯一的 order ID,该 ID 是使用 AtomicLong incrementAndGet()方法生成的:
AtomicLong orderIdGenerator = new AtomicLong(0);
for (int i =0;i < 10;i++) {
Thread orderCreationThread = new Thread(() -> {
for (int i1 = 0; i1 < 10; i1++) {
createOrder(Thread.currentThread().getName());
}
});
orderCreationThread.setName("Order Creation Thread "+i);
orderCreationThread.start();
}
//////////////////////////////////////////////////////
private void createOrder(String name) {
long orderId = orderIdGenerator.incrementAndGet();
Order order = new Order(name, orderId);
orders.add(order);
}
它是如何工作的
AtomicLong(及其同类 AtomicInteger)是为在并发环境中安全使用而构建的。他们有方法自动递增(并获得)更改后的值。即使数百个线程调用 AtomicLong increment()方法,返回值也总是唯一的。
如果您需要做出决策和更新变量,请始终使用 AtomicLong 提供的原子操作;比如 compareAndSet。否则,你的代码就不是线程安全的(因为任何先检查后动作的操作都需要是原子的),除非你使用自己的锁从外部保护原子引用(见方法 10-7)。
下面的代码演示了几个需要注意的代码安全问题。首先,更改一个长整型值可以在两次内存写操作中完成(Java 内存模型允许),因此两个线程可能会在表面上看起来是线程安全的代码中重叠这两次操作。结果将是一个完全出乎意料的(很可能是错误的)长整型值:
long counter = 0;
public long incrementCounter() {
return counter++;
}
这段代码还受到不安全发布的影响,不安全发布指的是变量可能被本地缓存(在 CPU 的内部缓存中)并且可能不会被提交到主内存中。如果另一个线程(在另一个 CPU 中执行)碰巧从主存中读取变量,那么那个线程可能会错过第一个线程所做的更改。改变的值可能被第一线程的 CPU 缓存,并且还没有被提交到第二线程可以看到它的主存储器。为了安全发布,必须使用 volatile Java 修饰符(见download . Oracle . com/javase/tutorial/essential/concurrency/atomic . html)。
前面代码的最后一个问题是它不是原子的。尽管看起来只有一个操作来递增计数器,但实际上有两个操作发生在机器语言级别(检索变量,然后递增)。可能有两个或更多的线程获得了相同的值,因为它们都检索了变量,但还没有递增它。然后所有线程将计数器递增到相同的数字。
10-10.将任务分解成离散的工作单元
问题
您有一个受益于使用分治策略的算法,分治策略指的是将一个工作单元分解成两个独立的子单元,然后将这些子单元的结果拼凑在一起的能力。然后子单元可以被分解成更多的工作子单元,直到工作小到足以被执行。通过将工作单元分解成子单元,您可以毫不费力地利用当今处理器的多核特性。
解决办法
新的 Fork/Join 框架使得分而治之策略的应用变得简单明了。以下示例创建了一个生活游戏的表示。代码使用 Fork/Join 框架来加速从一代到下一代的每次迭代的计算:
////////////////////////////////////////////////////////////////
ForkJoinPool pool = new ForkJoinPool();
long i = 0;
while (shouldRun) {
i++;
final boolean[][] newBoard = new boolean[lifeBoard.length][lifeBoard[0].length];
long startTime = System.nanoTime();
GameOfLifeAdvancer advancer = new GameOfLifeAdvancer(lifeBoard, 0,0, lifeBoard.length-1, lifeBoard[0].length-1,newBoard);
pool.invoke(advancer);
long endTime = System.nanoTime();
if (i % 100 == 0 ) {
System.out.println("Taking "+(endTime-startTime)/1000 + "ms");
}
SwingUtilities.invokeAndWait(() -> {
model.setBoard(newBoard);
lifeTable.repaint();
});
lifeBoard = newBoard;
}
////////////////////////////////////////////////////////////////
class GameOfLifeAdvancer extends RecursiveAction{
private boolean[][] originalBoard;
private boolean[][] destinationBoard;
private int startRow;
private int endRow;
private int endCol;
private int startCol;
GameOfLifeAdvancer(boolean[][] originalBoard, int startRow, int startCol, int endRow, int endCol, boolean [][] destinationBoard) {
this.originalBoard = originalBoard;
this.destinationBoard = destinationBoard;
this.startRow = startRow;
this.endRow = endRow;
this.endCol = endCol;
this.startCol = startCol;
}
private void computeDirectly() {
for (int row = startRow; row <= endRow;row++) {
for (int col = startCol; col <= endCol; col++) {
int numberOfNeighbors = getNumberOfNeighbors (row, col);
if (originalBoard[row][col]) {
destinationBoard[row][col] = true;
if (numberOfNeighbors < 2) destinationBoard[row][col] = false;
if (numberOfNeighbors > 3) destinationBoard[row][col] = false;
} else {
destinationBoard[row][col] = false;
if (numberOfNeighbors == 3) destinationBoard[row][col] = true;
}
}
}
}
private int getNumberOfNeighbors(int row, int col) {
int neighborCount = 0;
for (int leftIndex = -1; leftIndex < 2; leftIndex++) {
for (int topIndex = -1; topIndex < 2; topIndex++) {
if ((leftIndex == 0) && (topIndex == 0)) continue; // skip own
int neighbourRowIndex = row + leftIndex;
int neighbourColIndex = col + topIndex;
if (neighbourRowIndex<0) neighbourRowIndex =
originalBoard.length + neighbourRowIndex;
if (neighbourColIndex<0) neighbourColIndex =
originalBoard[0].length + neighbourColIndex ;
boolean neighbour = originalBoard[neighbourRowIndex % originalBoard.length][neighbourColIndex % originalBoard[0].length];
if (neighbour) neighborCount++;
}
}
return neighborCount;
}
@Override
protected void compute() {
if (getArea() < 20) {
computeDirectly();
return;
}
int halfRows = (endRow - startRow) / 2;
int halfCols = (endCol - startCol) / 2;
if (halfRows > halfCols) {
// split the rows
invokeAll(new GameOfLifeAdvancer(originalBoard, startRow, startCol, startRow+halfRows, endCol,destinationBoard),
new GameOfLifeAdvancer(originalBoard, startRow+halfRows+1, startCol, endRow, endCol,destinationBoard));
} else {
invokeAll(new GameOfLifeAdvancer(originalBoard, startRow, startCol, endRow, startCol+ halfCols,destinationBoard),
new GameOfLifeAdvancer(originalBoard, startRow, startCol+halfCols+1, endRow, endCol,destinationBoard));
}
}
private int getArea() { return (endRow - startRow) * (endCol - startCol); }
}
它是如何工作的
Fork/Join 框架可用于将任务分解成离散的工作单元。解决方案的第一部分创建了一个 ForkJoinPool 对象。默认构造函数提供了合理的默认值(比如创建与 CPU 内核一样多的线程),并设置了一个入口点来提交各个击破的工作。虽然 ForkJoinPool 继承自 ExecutorService,但它最适合处理从 RecursiveAction 扩展而来的任务。ForkJoinPool 对象具有 invoke(RecursiveAction)方法,该方法将接受一个 RecursiveAction 对象并应用分治策略。
解决方案的第二部分创建 GameOfLifeAdvancer 类,该类扩展了 RecursiveAction 类。通过扩展 RecursiveAction 类,可以拆分工作。GameOfLifeAdvancer 类将人生板的游戏推进到了下一代。构造器取一个二维布尔数组(代表一个生命棋盘的游戏),一个起始行/列,一个结束行/列,一个目的二维布尔数组,在上面收集了推进生命棋盘游戏一代的结果。
GameOfLifeAdvancer 是实现 compute()方法所必需的。用这种方法,确定有多少工作要完成。如果工作足够小,则直接完成工作(通过调用 computed directly()方法并返回来实现)。如果工作不够小,该方法通过创建两个 gameoflifeadevancer 实例来拆分工作,这两个实例只处理当前 gameoflifeadevancer 工作的一半。这是通过将要处理的行数分成两个块或者将列数分成两个块来实现的。然后,通过调用 RecursiveAction 类的 invokeAll()方法,将两个 GameOfLifeAdvancer 实例传递给 ForkJoin 池。invokeAll()方法获取 GameOfLifeAdvancer 的两个实例(可以根据需要获取任意多个实例)并等待,直到它们都执行完毕(即 invokeAll()方法名称中–all 后缀的含义;它在返回控制之前等待提交的所有任务完成)。
通过这种方式,GameOfLifeAdvancer 实例被分解成新的 GameOfLifeAdvancer 实例,每个实例只处理人生棋盘游戏的一部分。每个实例在将控制权返回给调用者之前都要等待所有从属部分完成。由此产生的工作分工可以利用当今典型系统中可用的多个 CPU。
小费
ForkJoinPool 通常比 ExecutorService 更有效,因为它实现了工作窃取策略。每个线程都有一个工作队列要完成;如果任何线程的队列为空,该线程将从另一个线程队列中“窃取”工作,从而更有效地利用 CPU 处理能力。
10-11.跨多个线程更新公共值
问题
您的应用需要跨多个线程安全地维护一个求和值。
解决办法
利用 DoubleAdder 或 LongAdder 来包含在多个线程中求和的值,以确保安全处理。在下面的示例中,两个线程同时向 DoubleAdder 添加值,最后将值相加并显示出来。
DoubleAdder da = new DoubleAdder();
private void start() {
Thread thread1 = new Thread(() -> {
for (int i1 = 0; i1 < 10; i1++) {
da.add(i1);
System.out.println("Adding " + i1);
}
});
Thread thread2 = new Thread(() -> {
for (int i1 = 0; i1 < 10; i1++) {
da.add(i1);
System.out.println("Adding " + i1);
}
});
thread1.start();
thread2.start();
try {
System.out.println("Sleep while summing....");
Thread.sleep(10000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("The sum is: " + da.doubleValue());
}
}
结果:
Adding 0
Adding 1
Adding 2
Adding 3
Adding 4
Adding 5
Adding 6
Adding 7
Adding 0
Adding 8
Adding 9
Adding 1
Adding 2
Adding 3
Adding 4
Adding 5
Adding 6
Adding 7
Adding 8
Adding 9
The sum is: 90.0
它是如何工作的
在 Java 8 发布之前,在多线程中处理值时利用原子序数是很重要的。原子变量防止线程干扰,而不会像同步访问在某些情况下那样造成阻塞。Java 8 引入了一系列新的原子变量,提供了比标准原子变量更快的吞吐量。在大多数情况下,当可以跨多个线程访问和更新值时,Java . util . concurrent . atomic . double adder 和 Java . util . concurrent . atomic . long adder 类优于 AtomicDouble 和 AtomicLong。DoubleAdder 和 LongAdder 都扩展了数字,它们在对线程间的值求和时非常有用,尤其是在高度争用的情况下。
在该解决方案中,双加法器用于对两个不同线程的数字求和。使用 add()方法,各种数字被“加”到 DoubleAdder 值上。在线程有足够的时间执行它们的工作后,调用 doubleValue()方法以双精度形式返回所有值的总和。
DoubleAdder 和 LongAdder 类都包含类似的方法,尽管 LongAdder 包含两个额外的助手方法,用于递增和递减加法器的值。表 10-2 显示了每个类中包含的方法。
表 10-2。双加法器和长加法器方法
|方法
|
描述
| | --- | --- | | 添加() | 添加给定值。 | | 减量() | (仅限 LongAdder。)相当于 add(-1)。 | | doubleValue() | 将 sum()作为双精度值返回(在对 LongAdder 执行扩大基元转换后)。 | | floatValue() | 执行扩大基元转换后,将 sum()作为浮点值返回。 | | 增量() | (仅限 LongAdder。)相当于 add(1)。 | | intValue() | 执行收缩转换后,将 sum()作为 int 值返回。 | | longValue() | 将 sum()作为长整型值返回(在 DoubleAdder 上执行收缩转换后)。 | | 重置() | 将变量值重置为零。 | | 总和() | 返回当前的合计值。 | | sumThenReset() | 返回当前的合计值,然后将变量的值重置为零。 | | toString() | 返回求和值的字符串表示形式。 |
小费
DoubleAccumulator 和 LongAccumulator 类与 DoubleAdder 和 LongAdder 属于同一家族。这些类允许使用提供的函数来更新跨线程维护的一个或多个变量。这两个类都接受一个累加器函数作为第一个参数,一个标识作为第二个参数。当跨线程应用更新时,用于执行计算的变量集可以动态增长以减少争用。有关这些 Java 8 新增类的更多信息,请参考在线文档:docs . Oracle . com/javase/9/docs/API/Java/util/concurrent/atomic/package-summary . html。
10-12.异步执行多个任务
问题
您的应用需要以异步方式同时执行多个任务,这样任务之间就不会互相阻塞。
解决办法
利用 CompletableFuture 对象来表示当前正在执行的每个任务的状态。每个 CompletableFuture 对象将在指定的或应用确定的后台线程上运行,一旦完成,就向原始调用方法发出回调。
在下面的解决方案中,调用方法调用了两个长期运行的任务,一旦任务完成,它们都利用 CompletableFuture 来报告状态。
public class Recipe10_12 {
public static void main(String[] args) {
try {
CompletableFuture tasks = performWork()
.thenApply(work -> {
String newTask = work + " Second task complete!";
System.out.println(newTask);
return newTask;
}).thenApply(finalTask -> finalTask + " Final Task Complete!");
CompletableFuture future = performSecondWork("Java 9 is Great! ");
while(!tasks.isDone()){
System.out.println(future.get());
}
System.out.println(tasks.get());
} catch (ExecutionException | InterruptedException ex) {
}
}
/**
* Returns a CompleableFuture object.
* @return
*/
public static CompletableFuture performWork() {
CompletableFuture resultingWork = CompletableFuture.supplyAsync(
() -> {
String taskMessage = "First task complete!";
try {
Thread.sleep(1000);
} catch (InterruptedException ex) {
System.out.println(ex);
}
System.out.println(taskMessage);
return taskMessage;
});
return resultingWork;
}
/**
* Accepts a String and returns a CompletableFuture.
* @param message
* @return
*/
public static CompletableFuture performSecondWork(String message) {
CompletableFuture resultingWork = CompletableFuture.supplyAsync(
() -> {
String taskMessage = message + " Another task complete!";
try {
Thread.sleep(1000);
} catch (InterruptedException ex) {
System.out.println(ex);
}
return taskMessage;
});
return resultingWork;
}
}
结果:
First task complete!
First task complete! Second task complete!
Java 9 is Great! Another task complete!
First task complete! Second task complete! Final Task Complete!
它是如何工作的
Java 8 中添加了 CompletableFuture 来构建对异步任务的支持。CompletableFuture 是 Future 的扩展,它增加了许多方法来促进异步、事件驱动的编程模型,并且还允许随时设置值。后一种功能意味着 CompletableFuture 可以在需要之前创建,以防应用将来需要使用它。
创建 CompletableFuture 对象有两个选项,可以手动创建,也可以通过使用工厂方法来创建。手动创建 CompleteableFuture 遗嘱可以在不绑定到任何线程的情况下完成,这种策略在诸如应用需要未来将发生的事件的占位符的情况下会很有用。以下代码演示了如何手动创建 CompletableFuture:
final <CompletableFutureString> completableFuture = new CompletableFuture<>();
人们可以利用工厂生成 CompletableFuture 来返回一个面向特定任务或结果的对象。有许多不同的工厂方法可以调用来返回这样的对象。有些工厂方法接受参数,有些不接受。例如,CompletableFuture . run async(Runnable)方法返回 CompletableFuture,该方法首先执行提供的 Runnable,然后通过在 ForkJoinPool.commonPool()中运行的任务异步完成。runAsync()方法的另一个变体接受 Runnable 和 Executor,它首先执行提供的 Runnable,然后由给定 Executor 中的任务异步完成。
CompletableFuture 对象还包含许多与标准 Future 对象非常相似的方法。例如,isDone()、cancel()和 isCompletedExceptionally()方法都返回布尔值来指示对象的状态。通过调用接受 lambda 表达式和方法引用的 thenApply()方法,还可以用 CompletableFuture 来堆叠异步任务。这个配方的解决方案演示了如何利用 thenApply()方法从另一个调用异步任务。首先,执行名为 performWork()的 CompletableFuture 对象,然后执行 lambda,根据 performWork()中生成的字符串创建连接字符串。一旦第二个任务完成,就会调用另一个任务向字符串追加更多的文本。然后在一个循环中调用 future.get()方法,以便查看应用随时间推移而转换的字符串。最后,打印完全完成的任务的结果。
Java 9 为 CompletableFuture 增加了一些增强功能。通过维护一个触发和取消动作的线程,现在对延迟和超时有了更好的支持。它还保持了对子类化和一些实用方法的更好支持。
摘要
在开发应用时,理解并发的基本原理是很重要的。没有什么比成功地测试一个应用,然后在它发布到生产环境中时因为死锁而失败更糟糕的了。本章从基础开始,演示了如何产生一个后台任务。然后,本文介绍了处理并发性的各种技术,从创建线程到使用 Fork/Join 框架将工作分成离散的任务。最后,本章以 CompletableFuture 的覆盖范围和 Java 9 中该类的一些新增内容结束。