提起java的io,我们都会提到传统io,nio。也会想到时下比较热门的netty这个io框架。
那传统io,是如何演变成nio的呢?它们之间有什么异同呢?
接下来我们将从传统io谈起,系统完整的解答io的相关问题。
一:传统io
只要学过io的人都知道,传统io处理读取的时候通常是需要创建多个线程来单独处理的。那为什么需要创建多个线程呢?既然想要搞清楚为什么使用多线程,那么我们先来看看在使用单线程的时候是个什么样的情况:
public class OioServer {
@SuppressWarnings("resource")
public static void main(String[] args) throws Exception {
//创建socket服务,监听10101端口
ServerSocket server=new ServerSocket(10101);
System.out.println("服务器启动!");
while(true){
//获取一个套接字(阻塞)
final Socket socket = server.accept();
System.out.println("来个一个新客户端!");
//业务处理
handler(socket);
}
}
/**
* 读取数据
* @param socket
* @throws Exception
*/
public static void handler(Socket socket){
try {
byte[] bytes = new byte[1024];
InputStream inputStream = socket.getInputStream();
while(true){
//读取数据(阻塞)
int read = inputStream.read(bytes);
if(read != -1){
System.out.println(new String(bytes, 0, read));
}else{
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}finally{
try {
System.out.println("socket关闭");
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
首先需要清楚,这段代码中有两个阻塞的点:
- server.accept(),该方法用于获取一个socket,在未接收到客户端连接的时候它会一直阻塞
- inputStream.read(bytes),当客户端连接成功后,程序阻塞等待读取数据发送的数据
然后我们来看看这段代码的执行顺序
- 当该代码运行后(即通信的服务端开启),程序在server.accept()处阻塞等待客户端连接
- 通过dos命令,输入telnet 127.0.0.1 9999连接服务端,这时程序返回一个socket并等待客户端发送数据
- 客户端进行数据读取,并处理数据。
在单线程的io中,在长连接的时候客户端的socket独占线程,这造成其他的客户端连接到该服务端,它也无法获取到socket并进行数据读取。
传统io单线程处理的弊端:一个服务端无法为多个客户端提供服务。
那如何处理这种状况呢?这个时候就引入了多线程,既然在数据读取的同时不能让其他客户端获取到socket,那么就让处理读写的操作独立出来让另一个线程去处理不就完了。看下面一段代码:
public class OioServer {
@SuppressWarnings("resource")
public static void main(String[] args) throws Exception {
ExecutorService newCachedThreadPool = Executors.newCachedThreadPool();
//创建socket服务,监听9999端口
ServerSocket server=new ServerSocket(9999);
System.out.println("服务器启动!");
while(true){
//获取一个套接字(阻塞)
final Socket socket = server.accept();
System.out.println("来个一个新客户端!");
newCachedThreadPool.execute(new Runnable() {
@Override
public void run() {
//业务处理
handler(socket);
}
});
}
}
/**
* 读取数据
* @param socket
* @throws Exception
*/
public static void handler(Socket socket){
try {
byte[] bytes = new byte[1024];
InputStream inputStream = socket.getInputStream();
while(true){
//读取数据(阻塞)
int read = inputStream.read(bytes);
if(read != -1){
System.out.println(new String(bytes, 0, read));
}else{
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}finally{
try {
System.out.println("socket关闭");
socket.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
可以看到,加入多线程后可以避免在单线程的时候不能为多个客户端提供服务的弊端。但试想一下,如果来一个客户端就新建一个线程为它服务,那线程多了你的电脑会不会爆炸掉?所以这种多线程的io也不是特别靠谱。当然如果是短连接(读写操作耗时短,处理完后socket关闭)的时候,这种处理还是可以的,对于长连接就不行了。
由此,对传统io进行改进,就得到了nio
二:Nio
nio被称为非阻塞的io。那它和传统io有什么区别呢?
先上两张图:
传统io:如果将整个服务端程序看成是一家餐厅的话,serverSocket就是大门,而客人是客户端,服务员就是处理读写操作的线程。传统的处理方式是,当大门(serverSocket),来一个客人(client),餐厅(sever)就为他分配一个服务员(thread),这一个服务员为一名客人服务,这样明显会造成人力浪费,是一种非常不可取的方式。
于是我们可能会想,如果一个服务员能够为多个客人服务就好了,所以接下来看nio是如何处理的:
还是刚才那个餐厅和服务员的比喻。nio新加入了几个概念,一个是channel(通道)相当于socket,一个是selector(多路复用器)。当大门(serverSocketChannel)检测到有新的客人(client)上门的时候,餐厅不再是直接给他派遣一个服务员,而是让他在服务员那里去登记一下(就像我们点餐一样,点好了拿着点餐的牌号,然后等着服务员根据订单给你上菜就好了),然后由一个服务员根据你登记的需要服务的事项(在nio中成为状态)为所有客人服务。这样一个服务端就可以为多个客户端提供服务了,大大的节约了资源。
上面是比喻的说法,接下来看看nio的具体实现:
public class NIOServer {
// 通道管理器
private Selector selector;
/**
* 获得一个ServerSocket通道,并对该通道做一些初始化的工作
*
* @param port
* 绑定的端口号
* @throws IOException
*/
public void initServer(int port) throws IOException {
// 获得一个ServerSocket通道
ServerSocketChannel serverChannel = ServerSocketChannel.open();
// 设置通道为非阻塞
serverChannel.configureBlocking(false);
// 将该通道对应的ServerSocket绑定到port端口
serverChannel.socket().bind(new InetSocketAddress(port));
// 获得一个通道管理器
this.selector = Selector.open();
// 将通道管理器和该通道绑定,并为该通道注册SelectionKey.OP_ACCEPT事件,注册该事件后,
// 当该事件到达时,selector.select()会返回,如果该事件没到达selector.select()会一直阻塞。
serverChannel.register(selector, SelectionKey.OP_ACCEPT);
}
/**
* 采用轮询的方式监听selector上是否有需要处理的事件,如果有,则进行处理
*
* @throws IOException
*/
public void listen() throws IOException {
System.out.println("服务端启动成功!");
// 轮询访问selector
while (true) {
// 当注册的事件到达时,方法返回;否则,该方法会一直阻塞
selector.select();
// 获得selector中选中的项的迭代器,选中的项为注册的事件
Iterator<?> ite = this.selector.selectedKeys().iterator();
while (ite.hasNext()) {
SelectionKey key = (SelectionKey) ite.next();
// 删除已选的key,以防重复处理
ite.remove();
handler(key);
}
}
}
/**
* 处理请求
*
* @param key
* @throws IOException
*/
public void handler(SelectionKey key) throws IOException {
// 客户端请求连接事件
if (key.isAcceptable()) {
handlerAccept(key);
// 获得了可读的事件
} else if (key.isReadable()) {
handelerRead(key);
}
}
/**
* 处理连接请求
*
* @param key
* @throws IOException
*/
public void handlerAccept(SelectionKey key) throws IOException {
ServerSocketChannel server = (ServerSocketChannel) key.channel();
// 获得和客户端连接的通道
SocketChannel channel = server.accept();
// 设置成非阻塞
channel.configureBlocking(false);
// 在这里可以给客户端发送信息哦
System.out.println("新的客户端连接");
// 在和客户端连接成功之后,为了可以接收到客户端的信息,需要给通道设置读的权限。
channel.register(this.selector, SelectionKey.OP_READ);
}
/**
* 处理读的事件
*
* @param key
* @throws IOException
*/
public void handelerRead(SelectionKey key) throws IOException {
// 服务器可读取消息:得到事件发生的Socket通道
SocketChannel channel = (SocketChannel) key.channel();
// 创建读取的缓冲区
ByteBuffer buffer = ByteBuffer.allocate(1024);
int read = channel.read(buffer);
if(read > 0){
byte[] data = buffer.array();
String msg = new String(data).trim();
try {
System.out.println("开始接收数据……");
Thread.sleep(10000);
} catch (InterruptedException e) {
// TODO Auto-generated catch block
e.printStackTrace();
}
System.out.println("服务端收到信息:" + msg);
//回写数据
ByteBuffer outBuffer = ByteBuffer.wrap("好的".getBytes());
channel.write(outBuffer);// 将消息回送给客户端
}else{
System.out.println("客户端关闭");
key.cancel();
}
}
/**
* 启动服务端测试
*
* @throws IOException
*/
public static void main(String[] args) throws IOException {
NIOServer server = new NIOServer();
server.initServer(8000);
server.listen();
}
}
这段代码有点长,但是仔细梳理下可以概括成一下几个步骤
- 初始化操作:该阶段会获取serverSocketChannel,并将它与selector绑定并赋予SelectionKey.OP_ACCEPT状态(即阻塞状态)
- 通过selector.select()进行事件检测,当事件到达的时候它返回,而没有事件到达的时候它会阻塞。
- 根据select()方法检测到的事件,获取对应事件的就绪的channel,并根据状态采取相应的操作
这里selector中的状态分为:
- SelectionKey.OP_CONNECT 连接状态
- SelectionKey.OP_ACCEPT 阻塞状态
- SelectionKey.OP_READ 可读
- SelectionKey.OP_WRITE 可写
注意:nio中最重要的方法就是selector.select(),该方法是一个阻塞的方法。它会检测事件和类型,它会将对应事件类型的就绪的channel随着selectedKey返回。比如说客户端要进行read操作,select()方法检测到该事件,它就会将selector中注册的并且read就绪的channel返回。而如果是一个刚连接的client,select()方法会返回ACCEPT状态的serverChannel进行新channel的注册。所以说在selector中的channel是在不断增长的。
看了nio的代码你可能有一个想法:感觉nio和单线程的传统io好像差不多
现在我们来理一理其中的区别,并且解释为什么同样是单线程nio能够做到传统io不能做到的事情。
看以下代码:
public static void handler(Socket socket){
try {
byte[] bytes = new byte[1024];
InputStream inputStream = socket.getInputStream();
while(true){
//读取数据(阻塞)
int read = inputStream.read(bytes);
if(read != -1){
System.out.println(new String(bytes, 0, read));
}else{
break;
}
}
} catch (Exception e) {
e.printStackTrace();
}
}
这是一段读取客户端传入数据的代码片段。可以看见传统单线程io中,我们如果要保持长时间读写,需要使用while(true)循环,线程被单一客户端所独占,这造其他客户端不能连接。而nio采用的是一种对象池的思想,这个时候线程是被selector独占的,这样就可以通过不断循环selector来处理多个客户端的连接。一个是被单一客户端独占线程,而另一个是被一个类似对象池的多路复用器独占线程,这就是为什么同样是单线程,nio能够处理多个客户端连接的原因。
总结:
- 传统io处理多个客户端时需要创建多个线程,而nio可以用单个线程处理多个客户端(至于nio如何用多个线程处理客户端请求,这个放在另一篇中讲,其实netty就是nio的多线程版)
- 传统io是阻塞的,nio是非阻塞的。可能有人会有疑问,selecetor.select()明明是一个阻塞的方法怎么nio是非阻塞的了。这里说的阻塞和非阻塞其实是指的socket或channel读取数据的方法是否阻塞。socket的读取方法是阻塞的,而channel的read方法是非阻塞的。
- 传统io和nio都是同步的,即都是按顺序执行的。
以上便是我对io的理解,希望能够帮助到你\(^o^)/~