Java世界里的NIO
Java的传统Socket编程
传统的Socket编程模型属于BIO模型,是一种同步阻塞式IO模型,最大的特点就是当ServerSocket调用accept方法如果没有客户端连接时会阻塞,当Socket调用read方法读取数据时如果客户端没有发送数据会阻塞。
Socket编程案例
public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
//阻塞方法
Socket clientSocket = serverSocket.accept();
System.out.println("有客户端连接了。。");
handle(clientSocket);
}
}
private static void handle(Socket clientSocket) throws IOException {
byte[] bytes = new byte[1024];
System.out.println("准备read。。");
//接收客户端的数据,阻塞方法,没有数据可读时就阻塞
int read = clientSocket.getInputStream().read(bytes);
System.out.println("read完毕。。");
if (read != ‐1) {
System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
}
clientSocket.getOutputStream().write("HelloClient".getBytes());
clientSocket.getOutputStream().flush();
}
}
//客户端代码
public class SocketClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 9000);
//向服务端发送数据
socket.getOutputStream().write("HelloServer".getBytes());
socket.getOutputStream().flush();
System.out.println("向服务端发送数据结束");
byte[] bytes = new byte[1024];
//接收服务端回传的数据
socket.getInputStream().read(bytes);
System.out.println("接收到服务端的数据:" + new String(bytes));
socket.close();
}
}
在服务端这边,首先创建一个ServerSocket并指定端口,然后在while循环中调用ServerSocket的accept方法等待客户端连接,调用accept方法之后,在有客户端连接之前,当前线程会处于阻塞状态。当接收到客户端连接之后,accept方法会返回一个Socket,一个Socket表示已建立的与一个客户端的一条TCP连接,此时我们调用handle方法处理,handle方法中我们调用Socket的getInputStream()获取一条网络输入流,然后调用该输入流的read()方法读取数据,当客户端没有发送数据时,调用read()方法又会导致当前线程阻塞。
当前线程被read()方法阻塞时,如果此时有新的客户端连接,由于无法调用accept方法,因此新客户端连接请求得不到响应。
于是,我们可以通过多线程优化:
public class SocketServer {
public static void main(String[] args) throws IOException {
ServerSocket serverSocket = new ServerSocket(9000);
while (true) {
System.out.println("等待连接。。");
//阻塞方法
Socket clientSocket = serverSocket.accept();
System.out.println("有客户端连接了。。");
new Thread(new Runnable() {
@Override
public void run() {
try {
handle(clientSocket);
} catch (IOException e) {
e.printStackTrace();
}
}
}).start();
}
}
private static void handle(Socket clientSocket) throws IOException {
byte[] bytes = new byte[1024];
System.out.println("准备read。。");
//接收客户端的数据,阻塞方法,没有数据可读时就阻塞
int read = clientSocket.getInputStream().read(bytes);
System.out.println("read完毕。。");
if (read != ‐1) {
System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
}
clientSocket.getOutputStream().write("HelloClient".getBytes());
clientSocket.getOutputStream().flush();
}
}
//客户端代码
public class SocketClient {
public static void main(String[] args) throws IOException {
Socket socket = new Socket("localhost", 9000);
//向服务端发送数据
socket.getOutputStream().write("HelloServer".getBytes());
socket.getOutputStream().flush();
System.out.println("向服务端发送数据结束");
byte[] bytes = new byte[1024];
//接收服务端回传的数据
socket.getInputStream().read(bytes);
System.out.println("接收到服务端的数据:" + new String(bytes));
socket.close();
}
}
Socket编程的优缺点
Socket编程模型的优点在于简单,代码也好理解。
但是缺点也很明显,首先是一个线程只能监听一个Socket,如果要监听多个Socket,则要开启多个线程,比较消耗服务器资源。其次Socket编程模型是BIO模式的IO操作,在没有客户端连接时调用accept()方法会阻塞当前线程,在客户端没有发送数据时调用read()方法也会阻塞当前线程,性能较低。
Java版的NIO介绍
由于Socket编程有以上种种缺点,从JDK1.4开始就推出了NIO的编程模型。NIO是非阻塞式IO操作,性能比起过去的原生Socket编程在性能上有很大提升。但是要注意的是,Java的NIO与Linux的NIO不是一个东西,Java的NIO底层使用的其实是IO多路复用。
三大核心对象Channel、Buffer、Selector
要学习Java的NIO,就要熟悉它的三大对象——Channel、Buffer、Selector。
Channel
Channel对象表示两节点间连接的通道,通道中流转的是数据。从通道一端的节点写入数据到通道,通道另一端节点就能收到数据;通道一端的节点从通道读取数据,如果对端有数据发送,则会从通道中读取到数据。可以看出,Channel是双向的,即可读也可写,而传统的BIO的IO流是单向,这就是Java中的NIO与BIO其中一个不同点。
Buffer
Java的NIO不是直接对Channel进行读写操作,而是要通过Buffer进行读写操作。当我们要读取Channel中的数据,必须先从Channel读取到Buffer中,然后我们再从Buffer中读取数据;当我们要向Channel中写入数据时,必须先写入到Buffer中,然后从Buffer写入到Channel中。Buffer与Channel一样,也是即可读也可写的。
Selector
Selector是一个多路复用器,底层是基于epoll的IO多路复用。Selector允许我们向其注册一个或多个Channel,并设置关注的事件类型(比如关注连接就绪事件、读就绪事件等)。
Channel注册到Selector之后,我们调用Selector的select()方法,它就会帮我们监听Channel是否有关注的事件发生。Selector的select()方法底层会触发epoll的IO多路复用,此时当前线程会阻塞,等待注册到Selector中的一个或多个Channel对应的关注事件就绪。
因此,Java的NIO其实使用的是IO多路复用,并不是Linux五种IO模型里的NIO。
NIO案例
public class NioSelectorServer {
public static void main(String[] args) throws IOException, InterruptedException {
// 创建NIO ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9000));
// 设置ServerSocketChannel为非阻塞
serverSocketChannel.configureBlocking(false);
// 打开Selector处理Channel,即创建epoll
Selector selector = Selector.open();
// 把ServerSocketChannel注册到selector上,并且为关注连接就绪事件
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("服务启动成功");
while (true) {
// 阻塞等待需要处理的事件发生
selector.select();
// 获取selector中注册的全部事件的 SelectionKey 实例
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
// 遍历SelectionKey对事件进行处理
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 处理SelectionKey......
}
}
}
}
大体逻辑是:
- 创建ServerSocketChannel,设置ServerSocketChannel的监听端口,设置ServerSocketChannel为非阻塞
- 打开Selector
- 把ServerSocketChannel注册到selector上,并且设置关注连接就绪事件
- while循环中调用selector.select()阻塞监听注册到selector中的多个Channel
- selector.selectedKeys()获取就绪的Channel对应的SelectionKey并处理
然后我们再单独看下对有事件就绪的SelectionKey的处理:
public class NioSelectorServer {
public static void main(String[] args) throws IOException, InterruptedException {
// ......
while (true) {
// ......
while (iterator.hasNext()) {
SelectionKey key = iterator.next();
// 如果是OP_ACCEPT事件,则进行连接获取和事件注册
if (key.isAcceptable()) {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
SocketChannel socketChannel = server.accept();
socketChannel.configureBlocking(false);
// 这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
socketChannel.register(selector, SelectionKey.OP_READ);
System.out.println("客户端连接成功");
} else if (key.isReadable()) { // 如果是OP_READ事件,则进行读取和打印
SocketChannel socketChannel = (SocketChannel) key.channel();
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
int len = socketChannel.read(byteBuffer);
// 如果有数据,把数据打印出来
if (len > 0) {
System.out.println("接收到消息:" + new String(byteBuffer.array()));
} else if (len == -1) { // 如果客户端断开连接,关闭Socket
System.out.println("客户端断开连接");
socketChannel.close();
}
}
//从事件集合里删除本次处理的key,防止下次select重复处理
iterator.remove();
}
}
}
}
可以看到,会判断一下SelectionKey对应的就绪事件是什么。如果有客户端连接上来,ServerSocketChannel中的连接事件就会就绪,此时我们会通过ServerSocketChannel的accept()方法获取到与客户端的连接对应的SocketChannel,然后我们会设置SocketChannel为非阻塞,注册到Selector中,并设置关注的事件类型是读就绪事件。客户端连接成功之后,如果发送数据,注册到Selector中的SocketChannel就会有读就绪事件发生,此时SocketChannel中就有了客户端发来的数据,但是我们要把SocketChannel中的数据写入到Buffer中,才能处理客户端发送过来的数据。
整体流程就是这样:
NIO的优缺点
Java的NIO的优点是性能比BIO要高,缺点就是使用太复杂了,有点反人类,因此才有了Netty,我们一般都会直接使用Netty而不会使用Java原生的NIO。