【图解IO与Netty系列】Java世界里的NIO

发布于:2024-06-06 ⋅ 阅读:(129) ⋅ 点赞:(0)

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......
			}
		}
	}
}


大体逻辑是:

  1. 创建ServerSocketChannel,设置ServerSocketChannel的监听端口,设置ServerSocketChannel为非阻塞
  2. 打开Selector
  3. 把ServerSocketChannel注册到selector上,并且设置关注连接就绪事件
  4. while循环中调用selector.select()阻塞监听注册到selector中的多个Channel
  5. 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。


网站公告

今日签到

点亮在社区的每一天
去签到