Java NIO是如何内部工作的?它是否在内部使用线程池?

21
Nio提供异步IO功能,这意味着调用线程在IO操作上不被阻塞。但是,我仍然困惑它是如何在内部工作的? 从这个答案中可以看出 - 只是有一个线程池,同步IO会被提交到其中。
JVM是否有线程池来执行实际的同步IO?对于Linux,有原生AIO支持- Java是否在内部使用它?AIO在操作系统层面上是如何工作的-它是否有线程池,但在操作系统层面上-或者有一些神奇的东西根本不需要线程?
总的来说,问题是-异步NIO是否使我们能够摆脱线程-还是只是同步IO的包装器,允许我们有固定数量的线程执行IO。

有一个默认的Executor,您可以覆盖它。执行器运行它们自己的线程池。请参见java.nio.channels.AsynchronousChannelGroup。但是,这仅用于调度完成。实际的异步I/O发生在内核中。 - user207421
3个回答

12
内核本身(无论是 Windows、Linux 还是其他更奇特的系统)负责执行非阻塞 I/O,而 nio 包中的 Java 类(例如 Channel 和 Selector)只是该 API 的相当低级别的翻译。
低级别的操作需要您创建线程才能正确执行。Java 中基本的 NIO 支持允许您调用一个方法,该方法会阻塞直到您感兴趣的任何数量的批量非阻塞通道中至少有一件事情发生。例如,您可以打开 1000 个表示网络套接字的通道,所有这些套接字都在等待“如果任何这些 1000 个打开的套接字上出现某些网络数据包,则我很感兴趣”,然后调用一个方法来说:“请休眠,直到发生有趣的事情”。如果您设置应用程序以调用此方法,然后处理所有有趣的事情,并返回调用此方法,则编写了一个效率相当低下的应用程序:CPU 倾向于拥有远远超过一个核心,除了一个核心外,所有核心都在睡觉什么也不做。正确的模型是拥有多个线程(或多或少每个核心一个)都运行相同的“唤醒我并给出有趣事物列表”的模型。除非您故意编写性能不佳的代码,否则无法摆脱线程。
因此,假设您已经设置好了:您拥有一个 8 核 CPU,并且您有 8 个线程运行“等待有趣的东西,处理具有活动数据的套接字”的循环。
想象一下,您的处理套接字代码的一部分被阻止。也就是说,它执行了某些操作,将导致 CPU 去检查其他要做的工作,因为它必须等待,比如等待网络或磁盘等。假设是因为您在其中放置了一些数据库查询,并且您没有意识到数据库查询使用了(可能是本地的,但仍然是)网络和磁盘。那将是非常糟糕的情况:您有充足的 CPU 资源来处理这 1000 个传入的请求,但是您的所有 8 个线程都在等待 DB 做事情,而虽然 CPU 可以分析数据包和响应,但它完全没有剩余的任务可做,因此会减速等待 DB 从磁盘中提取记录所需的时间。
很糟糕。因此,请不要调用阻塞代码。不幸的是,Java 中有大量的方法(无论是在 Java 核心库还是第三方库中)都会阻塞。它们往往没有记录。这个问题没有真正的解决办法。
一些库确实提供了解决方案,但如果有的话,它必须是“回调”形式:以DB查询示例为例,你需要做的是将该网络套接字告诉它,你现在至少不再对传入数据感兴趣(你已经在等待DB响应,尝试处理此套接字的更多传入数据没有意义);相反,你想要将DB连接本身关联为“如果此DB查询有响应准备好,我就感兴趣”。Java作为一种语言并不适合以这种方式编写,你最终会陷入“回调地狱”的境地,这就是JavaScript的工作方式。虽然有解决回调地狱的方案,但仍然很复杂,而且Java基本上不支持它们(例如,“yield”是一个可以帮助的东西,但Java不支持yield概念)。
最后,还有性能问题:为什么要摆脱线程?
线程有两个主要的惩罚:
  1. 上下文切换。当CPU必须跳到另一个线程时(因为它所在的线程需要等待磁盘或网络数据,因此现在无事可做),它需要跳到另一个代码位置,并确定加载到缓存中的内存表。
  2. 堆栈。就像几乎每个编程模型一样,有一个名为“堆栈”的内存位,其中包含局部变量和调用你的方法的位置(以及调用它的方法,一直到你的主要方法/线程运行方法)。如果你得到了一个堆栈跟踪,你正在看它的影响。在Java中,每个线程都有1个堆栈,所有堆栈的大小都相同。你可以使用-Xss JVM参数进行配置,最小值为1MB。这意味着,如果你想同时拥有4000个线程,那么需要4GB的堆栈,这是无法避免的(然后你需要更多的内存来处理堆等)。
但是,非阻塞并不能很好地解决这些问题:
移动到另一个处理程序时,因为您已经用完要处理的数据,所以您需要进行上下文切换。这不是线程切换,但是您仍然需要跳转到完全不同的内存页面,而在现代架构中,访问不在缓存中的内存部分需要很长时间。您只是将“线程上下文切换”换成了“内存页面缓存上下文切换”,并且没有获得任何好处。
假设您是某种聊天应用程序,并且从其中一个连接的客户端接收要发送的消息。现在,您需要查询数据库,以查看此用户是否有权将此消息发布到其打算发送到的聊天频道,并且还要查看是否有其他关注模式设备需要更新。因为这是阻止操作,您希望在等待时跳转到另一个作业。但是,您需要在某个地方记住此状态:发送用户、消息、DB查询结果。在线程模型中,此数据会自动且隐含地由堆栈空间处理:如果您采用全NIO,您需要自行管理此过程,例如使用ByteBuffers。
是的,当您手动控制字节缓冲区时,您可以使它们恰好达到所需大小,通常远小于1MB,因此您可以通过这种方式处理更多的同时连接,或者您只需在服务器中添加一个64GB的内存条。
因此,实际结果如下:
1. NIO代码极难编写。使用抽象化工具,例如Grizzly或Netty,因为这是“火箭科学”。
2. 它很少更快。
3. 如果需要跟踪连接/文件/作业等数量的数据很低,则可以同时进行更多操作。
4. 这有点像使用汇编语言而不是C语言,因为您可以在手动执行垃圾收集而不是让Java为您执行时从理论上挤出更好的性能。但是,大多数人不使用汇编语言来编写程序,即使它在理论上更快。绝大多数Web应用程序都是使用高级语言编写的,例如Java、Python、Node.js或其他一些高级内容,而不是像C(++)或汇编语言这样的非托管语言。

1
这不是一个答案,因为它没有回答这个API是否使用线程池的问题。 - oᴉɹǝɥɔ
@oᴉɹǝɥɔ 第一句话说的是底层内核会处理它。我假设您具备足够的Java知识,特别是您知道ThreadPool是一个Java类,因此这第一行就立即回答了问题:不,它不会处理。 - rzwitserloot
@rzwitserloot,可以这样说吗?底层内核的系统调用(如epoll/kqueue)是使魔法成为可能的原因,只需将文件描述符列表提供给内核,它就会在准备好数据时通知您。 - zafar142003
1
@zafar142003 是的,我所知道的每个JVM实现都是这样做的。但请注意,各种XyzChannel实现往往在其规范中声明:“如果可能,将执行NIO操作”。如果规范本身“硬编码”了使用epoll/kqueue的概念,则任何没有它的OS/arch部署都无法拥有为其编写的JVM。尽管如此,对于所有实际目的而言:如果规范说它可以执行NIO,并且您正在使用linux、windows(甚至是mac),它将执行NIO操作。正如我的帖子所述,这通常只会给您带来痛苦和更少的性能。 - rzwitserloot
这个问题是关于异步I/O的。而这个答案则是关于阻塞和非阻塞I/O的。它们是三种不同的I/O模式。因此,这并没有回答这个问题。 - user207421
显示剩余2条评论

3
这个问题“Java NIO的内部工作原理是什么?”对于StackOverflow来说太宽泛了,但关于线程池的问题不是。
我创建了一个名为SimpleNet的网络框架,我想用它作为回答您问题的示例,因为它利用了诸如AsynchronousServerSocketChannelAsynchronousSocketChannel等类。
executor = new ThreadPoolExecutor(numThreads, numThreads, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>(), runnable -> {
    Thread thread = new Thread(runnable);
    thread.setDaemon(false);
    return thread;
});

executor.prestartAllCoreThreads();

channel = AsynchronousServerSocketChannel.open(AsynchronousChannelGroup.withThreadPool(executor));

在我项目中所示的代码片段中,您可以看到AsynchronousServerSocketChannel#open接受一个AsynchronousChannelGroup,您可以传递自定义的ThreadPoolExecutor(它是一个ExecutorService)。因此,回答您的问题:是的,即使使用Asynchronous* NIO类,也会使用线程池来处理I/O完成。请注意:一旦Project Loom完成并且Fibers接管了世界,这可能会发生变化。

3
谢谢您的回答,但我仍然不确定您是正确的。根据Javadoc https://docs.oracle.com/javase/7/docs/api/java/nio/channels/AsynchronousChannelGroup.html#withThreadPool(java.util.concurrent.ExecutorService),我理解这个线程池用于运行完成处理程序https://docs.oracle.com/javase/7/docs/api/java/nio/channels/CompletionHandler.html - 处理程序用于处理IO事件而不是实际的IO。我理解在node.js中我们有单个事件循环 - 在Java中,我们有能力在此线程池的线程中并发运行回调。 - Oleksandr Papchenko
@OleksandrPapchenko 是的,实际的输入/输出由操作系统处理。 - Jacob G.
所以,线程池中的线程必须等待操作系统处理实际的I/O吗? - Ashika Umanga Umagiliya

0
我会猜测它的工作原理,但我不确定。我认为当网络卡读取帧时,它会将帧写入专门为该硬件分配的系统定义的RAM部分,并引发中断请求到CPU。CPU会注意到中断被触发,并运行分配给该中断的例程,即网络驱动程序的软件。驱动程序将读取内存并将其转换为操作系统API所需的格式。现在,操作系统读取帧以及OSI模型中的所有帧,并将数据按连接组织在内存中。然后,操作系统提供用于访问这些数据的API。对于Windows,API称为重叠IO。现在,JVM将使用操作系统的API来确定是否有可用于它的数据。操作系统通知数据可用的方式取决于操作系统设计人员的制定方式。一个非常常见的实现是一个名为select的阻塞函数,您可以在其中给出套接字列表,如果您对读取可用性或写入可用性感兴趣,则select将取消阻塞,如果其中任何一个套接字具有事件,则取消阻塞。一旦取消阻塞,数据将被写入/读取到ByteBuffer中,然后由专门生成的线程调用完成处理程序。

问题是关于异步I/O,而不是非阻塞I/O。在现实世界中没有OSI。 - user207421
我认为异步是在非阻塞之上实现proactor模式的一层。如果你想了解如何从非阻塞转换到异步,可以看看C++中的boost asio是如何工作的。 - Lefteris E

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接