背景
对于 Unix 中的五种 I/O 模型:
- 阻塞 I/O(blocking I/O)
- 非阻塞 I/O(non-blocking I/O)
- I/O 多路复用(I/O multiplxing)
- 信号驱动 I/O(signal driven I/O)
- 异步 I/O(asynchronous I/O)
除信号驱动 I/O 外,Java 对其它四种 I/O 模型都有所支持。
在服务端编程不断进化的过程中,出现了以下模式:
- 阻塞 I/O 模式(Blocking I/O)
- 阻塞 I/O (Blocking I/O) + 多线程(multithreading)
- 阻塞 I/O (Blocking I/O) + 线程池(Thread Pool)
- 非阻塞 I/O 模式(Non-blocking I/O)
- 异步 I/O 模式(Asynchronous I/O)
其中 Java 最早提供的 blocking I/O 即是阻塞 I/O,而 NIO 即是非阻塞 I/O (non-blocking I/O)。而通过 NIO 实现的 Reactor 模式即是 I/O 多路复用模型的实现,通过 AIO 实现的 Proactor 模式即是异步I/O模型的实现。
阻塞 I/O 模式(Blocking I/O)
使用阻塞 I/O 的服务器,一般使用 while(true)
循环,在一个线程中,逐个接受连接请求并读取数据,然后处理下一个请求。
代码实现如下所示:
public class IOServer {
private static final Logger LOGGER = LoggerFactory.getLogger(IOServer.class);
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(2345));
} catch (IOException ex) {
LOGGER.error("Listen failed", ex);
return;
}
try{
while(true) {
Socket socket = serverSocket.accept();
InputStream inputstream = socket.getInputStream();
LOGGER.info("Received message {}", IOUtils.toString(inputstream));
IOUtils.closeQuietly(inputstream);
}
} catch(IOException ex) {
IOUtils.closeQuietly(serverSocket);
LOGGER.error("Read message failed", ex);
}
}
}
阻塞 I/O (Blocking I/O) + 多线程(multithreading)
上例使用单线程逐个处理所有请求,缺点在于同一时间只能处理一个请求,而且在线程等待 I/O 而被阻塞的过程,不能充分利用 CPU 资源,最终能够对连接请求处理的吞吐率较低。
为此,我们使用多线程对阻塞 I/O 模型的改进,提出阻塞 I/O 模式 + 多线程模型,即为每个请求创建一个线程。一个连接建立成功后,创建一个单独的线程处理其 I/O 操作。
这是一种在传统的网络服务设计中的经典模式,而另外一种是线程池(我们在下文会介绍)。
该模型最大的问题就是缺乏弹性伸缩能力,当客户端并发访问量增加后,服务端的线程个数和客户端并发访问数呈 1:1 的正比关系,由于线程是 Java 虚拟机非常宝贵的系统资源,当线程数膨胀之后,系统的性能将急剧下降,随着并发访问量的继续增大,系统会发生线程堆栈溢出、创建新线程 失败等问题,并最终导致进程宕机或者僵死,不能对外提供服务。
代码实现如下所示:
public class IOServerMultiThread {
private static final Logger LOGGER = LoggerFactory.getLogger(IOServerMultiThread.class);
public static void main(String[] args) {
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(2345));
} catch (IOException ex) {
LOGGER.error("Listen failed", ex);
return;
}
try{
while(true) {
Socket socket = serverSocket.accept();
new Thread( () -> {
try{
InputStream inputstream = socket.getInputStream();
LOGGER.info("Received message {}", IOUtils.toString(inputstream));
IOUtils.closeQuietly(inputstream);
} catch (IOException ex) {
LOGGER.error("Read message failed", ex);
}
}).start();
}
} catch(IOException ex) {
IOUtils.closeQuietly(serverSocket);
LOGGER.error("Accept connection failed", ex);
}
}
}
阻塞 I/O (Blocking I/O) + 线程池(Thread Pool)
在上面的”阻塞 I/O 模式 + 多线程“ 模型中,虽然实现起来简单。但是,当连接数量达到上限时,再有用户请求连接,直接会导致资源瓶颈,严重的可能会直接导致服务器崩溃。
同时,线程不断地重复创建和销毁会带来的大量的性能开销。
为此,我们可以采用**线程池(thread pool)**来进行优化,即,采用线程池和任务队列可以实现一种叫做伪异步的 I/O 通信框架。
当有新的客户端接入的时候,将客户端的 Socket 封装成一个 Task(该任务实现 java.lang.Runnable 接口)投递到后端的线程池中进行处理,当这个 Task 处理完成后会被自动放回线程池中。JDK 的线程池维护一个消息队列和 N 个活跃线程对消息队列中的任务进行处理。由于线程池 可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机。
总结:重用线程避免了频率地创建和销毁线程带来的开销。
但是,阻塞 I/O 模式 + 线程池从根本上解决同步 I/O 导致的通信线程阻塞问题。下面我们就简单分析下如果通信 对方返回应答时间过长,会引起的级联故障。
代码实现如下所示:
public class IOServerThreadPool {
private static final Logger LOGGER = LoggerFactory.getLogger(IOServerThreadPool.class);
public static void main(String[] args) {
ExecutorService executorService = Executors.newFixedThreadPool(Runtime.getRuntime().availableProcessors());
ServerSocket serverSocket = null;
try {
serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress(2345));
} catch (IOException ex) {
LOGGER.error("Listen failed", ex);
return;
}
try{
while(true) {
Socket socket = serverSocket.accept();
executorService.submit(() -> {
try{
InputStream inputstream = socket.getInputStream();
LOGGER.info("Received message {}", IOUtils.toString(new InputStreamReader(inputstream)));
} catch (IOException ex) {
LOGGER.error("Read message failed", ex);
}
});
}
} catch(IOException ex) {
try {
serverSocket.close();
} catch (IOException e) {
}
LOGGER.error("Accept connection failed", ex);
}
}
}
潜在问题
在大量短连接的场景中性能会有提升,因为不用每次都创建和销毁线程,而是重用连接池中的线程。但在大量长连接的场景中,因为线程被连接长期占用,因而当有新连接请求到来时,可能没有可用的空闲线程来进行处理,最终导致较慢的服务响应时间(response time)。
虽然这种方法可以适用于小到中度规模的客户端的并发数,如果连接数超过 10000或更多,那么性能将很不理想。
非阻塞 I/O 模式(Non-blocking I/O)
背景
“阻塞I/O+线程池”网络模型虽然比”阻塞I/O+多线程”网络模型在性能方面有提升。但这两种模型都存在一个共同的问题:读和写操作都是同步阻塞的,面对大并发(持续大量连接同时请求)的场景,需要消耗大量的线程来维持连接。
因而,CPU 在大量的线程之间频繁切换,性能损耗很大。一旦单机的连接超过1万,甚至达到几万的时候,服务器的性能会急剧下降。
NIO 的 Selector 很好地解决了这个问题,用主线程(一个线程或者是 CPU 个数的线程)保持住所有的连接,管理和读取客户端连接的数据,将读取的数据交给后面的线程池处理,线程池处理完业务逻辑后,将结果交给主线程发送响应给客户端,少量的线程就可以处理大量连接的请求。
从IO到NIO
面向流 vs. 面向缓冲
Java IO 是面向流的,每次从流(InputStream/OutputStream)中读一个或多个字节,直到读取完所有字节,它们没有被缓存在任何地方。另外,它不能前后移动流中的数据,如需前后移动处理,需要先将其缓存至一个缓冲区。
Java NIO 面向缓冲,数据会被读取到一个缓冲区,需要时可以在缓冲区中前后移动处理,这增加了处理过程的灵活性。但与此同时,在处理缓冲区前,需要检查该缓冲区中是否包含有所需要处理的数据,并需要确保更多数据读入缓冲区时,不会覆盖缓冲区内尚未处理的数据。
阻塞 vs. 非阻塞
Java IO的各种流是阻塞的。当某个线程调用 read()
或 write()
方法时,该线程被阻塞,直到有数据被读取到或者数据完全写入。阻塞期间该线程无法处理任何其它事情。
Java NIO为非阻塞模式。读写请求并不会阻塞当前线程,在数据可读/写前当前线程可以继续做其它事情,所以一个单独的线程可以管理多个输入和输出通道。
选择器(Selector)
Java NIO 的**选择器(Selector)**允许一个单独的线程同时监视多个通道,可以注册多个通道到同一个选择器上,然后使用一个单独的线程来“选择”已经就绪的通道。这种“选择”机制为一个单独线程管理多个通道提供了可能。
零拷贝(Zero-copy)
Java NIO中提供的FileChannel拥有transferTo和transferFrom两个方法,可直接把FileChannel中的数据拷贝到另外一个Channel,或者直接把另外一个Channel中的数据拷贝到FileChannel。
该接口常被用于高效的网络/文件的数据传输和大文件拷贝。在操作系统支持的情况下,通过该方法传输数据并不需要将源数据从内核态拷贝到用户态,再从用户态拷贝到目标通道的内核态,同时也避免了两次用户态和内核态间的上下文切换,也即使用了“零拷贝”,所以其性能一般高于 Java I/O中提供的方法。
使用FileChannel的零拷贝将本地文件内容传输到网络的示例代码如下所示。
public class NIOClient {
public static void main(String[] args) throws IOException, InterruptedException {
SocketChannel socketChannel = SocketChannel.open();
InetSocketAddress address = new InetSocketAddress(1234);
socketChannel.connect(address);
RandomAccessFile file = new RandomAccessFile(
NIOClient.class.getClassLoader().getResource("test.txt").getFile(), "rw");
FileChannel channel = file.getChannel();
channel.transferTo(0, channel.size(), socketChannel);
channel.close();
file.close();
socketChannel.close();
}
}
异步 I/O 模式(Asynchronous I/O)
Java SE 7 版本之后,引入了异步 I/O (NIO.2) 的支持,为构建高性能的网络应用提供了一个利器。
http://tutorials.jenkov.com/java-nio/nio-vs-io.html
https://examples.javacodegeeks.com/core-java/nio/java-nio-asynchronous-channels-tutorial/
Netty - JDK原生NIO程序的问题
JDK原生也有一套网络应用程序API,但是存在一系列问题,主要如下:
NIO的类库和API繁杂,使用麻烦,你需要熟练掌握Selector、ServerSocketChannel、SocketChannel、ByteBuffer等
需要具备其它的额外技能做铺垫,例如熟悉Java多线程编程,因为NIO编程涉及到Reactor模式,你必须对多线程和网路编程非常熟悉,才能编写出高质量的NIO程序
可靠性能力补齐,开发工作量和难度都非常大。例如客户端面临断连重连、网络闪断、半包读写、失败缓存、网络拥塞和异常码流的处理等等,NIO编程的特点是功能开发相对容易,但是可靠性能力补齐工作量和难度都非常大
Reference
- Java I/O 模型的演进 - https://waylau.com/java-io-model-evolution/
- Java进阶(五)Java I/O模型从BIO到NIO和Reactor模式 - http://www.jasongj.com/java/nio_reactor/s
- Scalable IO in Java - http://gee.cs.oswego.edu/dl/cpjslides/nio.pdf
- Java NIO:浅析I/O模型 - https://www.cnblogs.com/dolphin0520/p/3916526.html
- 高性能网络编程(六):一文读懂高性能网络编程中的线程模型 - http://www.52im.net/thread-1939-1-1.html