Java 中的并发性

“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 中的并发工作原理。对于这个特定用例,异步解决方案似乎是最佳选择。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值