Java中的I/O操作分为三种模式:同步阻塞式(BIO),同步非阻塞式(NIO),异步非阻塞式(AIO),下面主要讲解BIO和NIO。
BIO(Blocking IO):面向流传输(input/output),同步阻塞式I/O。
在JDK1.4之前,Java网络编程中使用java.net包中的api,数据读写则使用Socket类中I/O流来传输。
BIO概述:服务器实现模式为在接收到客户端连接请求之后,为每个客户端创建一个新的线程去处理;之所以使用多线程,是因为在进行I/O操作时,不知道套接字什么时候有可读/写数据,socket的accept()、read()、write()三个方法是阻塞的,在单线程应用程序中,意味着这个线程被阻塞期间,对所有的I/O请求处理都停顿;所以利用多核多线程的优势,让CPU去处理更多的事情。
通过多线程实现在不同的线程中去处理不同客户端的I/O请求(1:1)。
图中处理流程仅个人理解
ServerSocket serverSocket = new ServerSocket(8888); // 监听端口8888
try {
while (true) {
Socket socket = serverSocket.accept(); //阻塞式接收socket连接
new Thread(() -> { //创建一个新的线程
byte[] b = new byte[1024];
try {
InputStream in = socket.getInputStream();
int len = in.read(b); // 读取客户端发送的数据
System.out.println("接收到客户端数据:" + new String(b,0,len));
OutputStream out = socket.getOutputStream();
out.write("successful...".getBytes()); // 返回数据到客户端
//关闭连接
in.close(); out.close(); socket.close();
} catch (Exception e) {
e.printStackTrace();
}
}).start();
}
} catch (Exception e) {
e.printStackTrace();
} finally {
serverSocket.close();
}
以代码in.read(b)为例:
如果当前套接字中没有可读数据时,read方法会一直阻塞,直到发生如下三种事件:
- 有数据可读
- 可用数据已经读取完毕
- 发生空指针或者I/O异常
虽然使用多线程解决了多个客户端I/O请求服务端导致阻塞的问题,在活动连接数不是特别高的情况下,这种模型是不错的选择,但是当连接十万百万时,这种模型是无能为力的,因为严重依赖于线程,对于线程创建和开销、上下文切换,可能导致内存溢出系统不可用的情况。
这里顺便提一下(伪异步I/O):伪异步I/O的依然是采用同步阻塞模型,服务端线程池和队列的方式来处理客户端的连接,无论多少个客户端连接都不会导致内存溢出等问题,但是当线程池中线程的数量达到了最大值时,新的客户端连接会一直在队列中等待(线程池释放某个客户端线程的资源)线程池有空闲位置;如果队列数量满时,会造成大量客户端请求连接等待超时。
所以必然需要一种更高效的I/O处理模型:多路复用I/O。
NIO(官方New IO,又称Non Blocking IO):面向缓冲区(channel传输),同步非阻塞式I/O,多路复用器(选择器)
Channel:用来传输字节数据,可以进行双向传输(read/write)
Buffer:缓存区用来存储字节数据
Selector:多路复用器,所有的客户端连接请求(channel)注册到选择器上面,单个线程轮询处理多个channel连接
在JDK1.4,Windows下采用select模型,Linux下采用Linux I/O模型 epoll(有兴趣可以了解下Linux I/O模型);NIO编程直接使用java.nio包中的api,数据传输和读写则使用Channel和ByteBuffer。
NIO概述:服务端实现模式为一个请求(read/write)一个线程,客户端有连接请求会被注册到Selector中,多路复用器轮询到连接有I/O读写时才会启动一个线程去处理数据;基于事件模型单线程来处理所有I/O请求,只有当读写事件到来时启动线程去处理。
当Selector轮询到某个注册的channel中有read/write请求时,才会去启动一个线程去处理(如果使用轮询的线程去处理业务可能会阻塞耗时,影响事件接收)。
Selector的好处在于:单线程来处理更多的客户端连接请求(M:1)。
图中处理流程仅个人理解
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.configureBlocking(false);//设置为非阻塞模式
serverSocketChannel.bind(new InetSocketAddress(8888));//监听端口8888
//获取选择器
Selector selector = Selector.open();
//将通道注册到选择器,等待连接
serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);
//获取选择器中已经准备就绪的事件
while (selector.select() > 0) {
//获取当前选择器所有注册的监听事件
Iterator selectionKeys = selector.selectedKeys().iterator();
while (selectionKeys.hasNext()) {
//获取事件
SelectionKey sk = selectionKeys.next();
if (sk.isAcceptable()) {
//获取客户端连接
SocketChannel socketChannel = serverSocketChannel.accept();
socketChannel.configureBlocking(false); //切换非阻塞模式
socketChannel.register(selector,SelectionKey.OP_READ);//注册选择器为读模式
}else if(sk.isReadable()){ //(读就绪)有可读数据
new Thread(() -> {
//获取当前选择器上读就绪状态的通道
//读取客户端数据,这里省略
}).start();
}else if(sk.isWritable()){ //(写就绪)可写数据,一般不需要去注册该(可写)事件,在读取数据后写入即可
new Thread(() -> {
//获取当前选择器上写就绪状态的通道
//写入数据到客户端,这里省略
}).start();
}
selectionKeys.remove(); //移除通道中的事件
}
}
//关闭通道
serverSocketChannel.close();
代码中的selector.select()是阻塞的,会等待新事件的到来(事件分发器);I/O读写使用单独的线程处理。
SocketChannel的读写操作都是异步的,如果没有可读写的数据它不会同步等待,直接返回,它的read方法返回值有以下三种结果:
- 返回值大于0:读取到字节数
- 返回值等于0:没有读取到字节,可忽略
- 返回值为-1:客户端链路已经关闭,需要关闭SocketChannel,释放资源
1.JDK提供的java.nio包进行NIO编程会比较复杂(需要熟练掌握nio包和Java多线程),Selector空轮询Bug,ByteBuffer读写时需要使用flip()和clear()进行切换,包括字节数据出入站的编解码等问题,建议使用NIO框架(Netty,Mina)来进行开发。
2.传输数据量过大,读写过程长时,由于同步需要等待整个操作完成才会返回,这时需要考虑业务场景来使用。
BIO(同步阻塞I/O) | NIO(同步非阻塞I/O) | |
传输类型 | 面向流传输 | 面向缓冲区 |
内存开辟 | 堆内存 | 有直接内存 |
使用场景 | 连接数少、延迟低 | 并发高、数据量小 |