“Java 编程语言和 Java 虚拟机 (JVM) 被设计为支持并发编程,并且所有执行都在线程上下文中进行。”——维基百科
Java 从早期就支持并发编程。在 Java 1.1 之前,它甚至支持绿色线程(虚拟线程)。剧透警告!它将通过Project Loom再次回归。
并发编程一直是 Java 的核心,因为它针对的是多线程和多核 CPU。虽然goroutines
使用起来并不简单,但它功能强大且灵活,几乎适用于任何用例。虽然功能强大,但它也相当复杂,尤其是当你必须在线程之间访问数据时,因为 Java 中的默认机制(由于其 OOP 根源)是通过同步线程来使用共享状态并发。
线程是 Java 中并发和异步编程的核心。从 JDK 1.1 开始,这些线程将 1:1 映射到 OS 线程。由于起步较早,生态系统也拥有非常成熟的库,从 HTTP 服务器到并发消息处理器等等。异步编程在 Java 中出现得稍晚一些,虽然有构建块,但实际上只有从 Java 8 开始才可用,但它也已经成熟,现在拥有一个支持反应式编程和异步并发的强大生态系统。
Java 8 进行了大量改进和简化,使并发变得更加容易。例如,标准 Java API(如 Stream API)甚至提供了一种通过在复杂且 CPU 密集型管道上调用方法调用来轻松进行并行处理的方法。
使用 Java,可以进行多线程并发或并行编程以及异步编程。这意味着,我们可以混合搭配这些模型,以获得任何用例的最佳性能。
多线程
Java 提供了用于创建和管理 OS 线程的构建块作为标准库的一部分,并且还提供了使用锁和同步的共享状态并发所需的实现。默认情况下不提供消息传递并发,但可以使用Akka等外部库或使用Actor 模型实现来完成。但是,由于内存模型,开发人员必须确保并发程序中没有数据争用或内存泄漏。
为了使多线程更加高效,Java 提供了创建线程池并重用这些线程来提高吞吐量的方法。Project Loom 发布后,情况会变得更好,希望是 Java 17 或 18。从技术上讲,Java 在多线程方面拥有最成熟的生态系统之一,您最终使用的大多数 Java 框架都会在内部使用它来提高性能。
异步处理
从技术上讲,异步编程不是并发的一部分,但在实践中,它在许多用例中齐头并进,可以提高性能,并使资源使用效率更高。在 Java 中,异步编程是使用与并发/并行编程相同的构建块实现的。又名线程。在 Java 8 之前,由于复杂性以及缺乏 lambda、函数式编程支持、CompletableFuture 等功能,异步编程在 Java 中并不流行。
Java 的最新版本提供了异步编程所需的构建块,并提供了标准接口和实现。但请记住,使用异步编程模型会增加整体复杂性,而且生态系统仍在不断发展。还有许多流行的库和框架(如 Spring 和 RxJava)支持异步/反应式编程。
尽管 Java 仍然没有针对 async/await 的任何语法糖,但是有足够接近的替代方案,例如EA Async库。
基准测试
现在我们对 Java 中的并发特性有了一些基本的了解,让我们用 Java 构建一个简单的并发 Web 服务器。由于 Java 提供了多种实现此目的的方法,我们将构建两个示例应用程序并进行比较。本文撰写时使用的 Java 版本是最新版本 (16.0.1)。
多线程并发网络服务器
这个例子更接近我们在rust 章节中构建的 Rust 多线程示例,为了简洁起见,我省略了 import 语句。你可以在GitHub上找到完整的示例。我们java.net.ServerSocket
为此使用它。在这种情况下,我们没有使用任何外部依赖。
public class JavaHTTPServer {
public static void main(String[] args) {
var count = 0; // count used to introduce delays
// bind listener
try (var serverSocket = new ServerSocket(8080, 100)) {
System.out.println("Server is listening on port 8080");
while (true) {
count++;
// listen to all incoming requests and spawn each connection in a new thread
new ServerThread(serverSocket.accept(), count).start();
}
} catch (IOException ex) {
System.out.println("Server exception: " + ex.getMessage());
}
}
}
class ServerThread extends Thread {
private final Socket socket;
private final int count;
public ServerThread(Socket socket, int count) {
this.socket = socket;
this.count = count;
}
@Override
public void run() {
var file = new File("hello.html");
try (
// get the input stream
var in = new BufferedReader(new InputStreamReader(socket.getInputStream()));
// get character output stream to client (for headers)
var out = new PrintWriter(socket.getOutputStream());
// get binary output stream to client (for requested data)
var dataOut = new BufferedOutputStream(socket.getOutputStream());
var fileIn = new FileInputStream(file)
) {
// add 2 second delay to every 10th request
if (count % 10 == 0) {
System.out.println("Adding delay. Count: " + count);
Thread.sleep(2000);
}
// read the request first to avoid connection reset errors
while (true) {
String requestLine = in.readLine();
if (requestLine == null || requestLine.length() == 0) {
break;
}
}
// read the HTML file
var fileLength = (int) file.length();
var fileData = new byte[fileLength];
fileIn.read(fileData);
var contentMimeType = "text/html";
// send HTTP Headers
out.println("HTTP/1.1 200 OK");
out.println("Content-type: " + contentMimeType);
out.println("Content-length: " + fileLength);
out.println("Connection: keep-alive");
out.println(); // blank line between headers and content, very important!
out.flush(); // flush character output stream buffer
dataOut.write(fileData, 0, fileLength); // write the file data to output stream
dataOut.flush();
} catch (Exception ex) {
System.err.println("Error with exception : " + ex);
}
}
}
如您所见,我们绑定了一个 TCP 侦听器ServerSocket
到端口 8080,并侦听所有传入的请求。每个请求都在一个新线程中处理。
让我们使用 ApacheBench 运行基准测试。我们将发出 10000 个请求,其中 100 个并发请求。
❯ ab -c 100 -n 10000 http://127.0.0.1:8080/
This is ApacheBench, Version 2.3 Revision: 1879490
...
Document Path: /
Document Length: 176 bytes
Concurrency Level: 100
Time taken for tests: 20.326 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 2600000 bytes
HTML transferred: 1760000 bytes
Requests per second: 491.98 [#/sec] (mean)
Time per request: 203.262 [ms] (mean)
Time per request: 2.033 [ms] (mean, across all concurrent requests)
Transfer rate: 124.92 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 1 1.5 0 13
Processing: 0 201 600.0 1 2023
Waiting: 0 201 600.0 0 2023
Total: 0 202 600.0 1 2025
Percentage of the requests served within a certain time (ms)
50% 1
66% 2
75% 4
80% 6
90% 2000
95% 2001
98% 2003
99% 2006
100% 2025 (longest request)
如您所见,请求处理程序线程每处理 10 个请求就会休眠 2 秒。在实际场景中,线程池本身可能会成为瓶颈,您可能无法设置那么多线程,因为操作系统可能无法提供那么多线程,从而导致资源使用率增加和瓶颈。在这个简单的用例中,由于每个线程生成和处理请求的速度都非常快,因此我们不会遇到问题。
那么让我们看看是否有其他不存在这种瓶颈的解决方案。
异步并发 Web 服务器
为了简洁起见,我省略了 import 语句。您可以在GitHub上找到完整的示例。请注意,我们在这里不使用java.nio.channels.AsynchronousServerSocketChannel
任何外部依赖项。
public class JavaAsyncHTTPServer {
public static void main(String[] args) throws Exception {
new JavaAsyncHTTPServer().start();
Thread.currentThread().join(); // Wait forever
}
private void start() throws IOException {
// we shouldn't use try with resource here as it will kill the stream
var server = AsynchronousServerSocketChannel.open();
server.bind(new InetSocketAddress("127.0.0.1", 8080), 100); // bind listener
server.setOption(StandardSocketOptions.SO_REUSEADDR, true);
System.out.println("Server is listening on port 8080");
final int[] count = {0}; // count used to introduce delays
// listen to all incoming requests
server.accept(null, new CompletionHandler<>() {
@Override
public void completed(final AsynchronousSocketChannel result, final Object attachment) {
if (server.isOpen()) {
server.accept(null, this);
}
count[0]++;
handleAcceptConnection(result, count[0]);
}
@Override
public void failed(final Throwable exc, final Object attachment) {
if (server.isOpen()) {
server.accept(null, this);
System.out.println("Connection handler error: " + exc);
}
}
});
}
private void handleAcceptConnection(final AsynchronousSocketChannel ch, final int count) {
var file = new File("hello.html");
try (var fileIn = new FileInputStream(file)) {
// add 2 second delay to every 10th request
if (count % 10 == 0) {
System.out.println("Adding delay. Count: " + count);
Thread.sleep(2000);
}
if (ch != null && ch.isOpen()) {
// Read the first 1024 bytes of data from the stream
final ByteBuffer buffer = ByteBuffer.allocate(1024);
// read the request fully to avoid connection reset errors
ch.read(buffer).get();
// read the HTML file
var fileLength = (int) file.length();
var fileData = new byte[fileLength];
fileIn.read(fileData);
// send HTTP Headers
var message = ("HTTP/1.1 200 OK\n" +
"Connection: keep-alive\n" +
"Content-length: " + fileLength + "\n" +
"Content-Type: text/html; charset=utf-8\r\n\r\n" +
new String(fileData, StandardCharsets.UTF_8)
).getBytes();
// write the to output stream
ch.write(ByteBuffer.wrap(message)).get();
buffer.clear();
ch.close();
}
} catch (IOException | InterruptedException | ExecutionException e) {
System.out.println("Connection handler error: " + e);
}
}
}
如您所见,我们将异步侦听器绑定到端口 8080 并侦听所有传入请求。每个请求都在 提供的新任务中处理AsynchronousServerSocketChannel
。我们在这里不使用任何线程池,所有传入请求都是异步处理的,因此我们没有最大连接数的瓶颈。但您可能立即注意到的一件事是代码现在变得更加复杂。
让我们使用 ApacheBench 运行基准测试。我们将发出 10000 个请求,其中 100 个并发请求。
ab -c 100 -n 10000 http://127.0.0.1:8080/
This is ApacheBench, Version 2.3 <$Revision: 1879490 $>
...
Document Path: /
Document Length: 176 bytes
Concurrency Level: 100
Time taken for tests: 20.243 seconds
Complete requests: 10000
Failed requests: 0
Total transferred: 2770000 bytes
HTML transferred: 1760000 bytes
Requests per second: 494.00 [#/sec] (mean)
Time per request: 202.431 [ms] (mean)
Time per request: 2.024 [ms] (mean, across all concurrent requests)
Transfer rate: 133.63 [Kbytes/sec] received
Connection Times (ms)
min mean[+/-sd] median max
Connect: 0 0 0.6 0 5
Processing: 0 201 600.0 0 2026
Waiting: 0 201 600.0 0 2026
Total: 0 202 600.0 0 2026
Percentage of the requests served within a certain time (ms)
50% 0
66% 1
75% 3
80% 4
90% 2000
95% 2001
98% 2002
99% 2003
100% 2026 (longest request)
我们在这里得到了几乎相同的结果,这个版本甚至快了 100 毫秒。因此,对于这个特定的用例来说,这个版本似乎比多线程版本更高效,然而,代价是增加了复杂性。
结论
这种简单的基准测试并不能准确地表示所有并发用例。
相反,它是一个针对非常特殊用例的简单测试,一个仅提供文件的简单并发 Web 服务器。
目的是了解解决方案之间的差异,并了解 Java 中的并发工作原理。对于这个特定用例,异步解决方案似乎是最佳选择。