前言
文章内容输出来源:拉勾教育Java高薪训练营;
在面试中老是被问到说一下BIO、NIO、AIO。自己知道一个大概,但是说又说不清。刚好在训练营老师讲到了这部分,所以就整理下来。
概念
BIO/NIO/AIO 这些只是数据传输的输入输出流的一些形式而已。也就是说他们的本质就是输入输出流。只是存在同步异步,阻塞和非阻塞的问题。
同步异步
同步(synchronize)、异步(asychronize)是指应用程序和内核的交互而言的.
同步:指用户进程触发IO操作等待或者轮训的方式查看IO操作是否就绪。
举例:我们烧水,等待水烧开饮用。我们从开始烧水到水烧开,我们就一直等待或者过段时间来看下水烧开没有。这就是同步。
异步:当一个异步进程调用发出之后,调用者不会立刻得到结果。而是在调用发出之后,被调用者通过状态、通知来通知调用者,或者通过回调函数来处理这个调用。使用异步IO时,Java将IO读写委托给OS处理,需要将数据缓冲区地址和大小传给OS,OS需要支持异步IO操作
举例:我们烧水,用有提示的烧水壶。我们开始烧水后,就不用一直等待或者过段时间来查看了,我们可以做其他事情。当水烧好的时候,热水壶会给出提示,这个时候我们就知道水烧好了,可以饮用了。
阻塞非阻塞
阻塞和非阻塞是针对于进程访问数据的时候,根据 IO 操作的就绪状态来采取不同的方式。简单点说就是一种读写操作方法的实现方式. 阻塞方式下读取和写入将一直等待, 而非阻塞方式下,读取和写入方法会理解返回一个状态值.
我们还是烧水这个例子:
我烧水,一直在旁边候着,等待水烧开。这个就是同步阻塞。因为这段时间,我就做这一件事情。
我烧水,时不时过来看看,看烧开没。这个就是 同步非阻塞 。因为我只需过段时间来看下就可以了,这期间可以做其他事情。
我烧水,用烧水壶,不用自己来看。但是我也不做其他事,就在旁边候着。这个就是 异步阻塞。
我烧水,用烧水壶,不用自己来看。我做其他事情去了,等水好了通知我就行。这个就是 异步非阻塞。
BIO
上面我们知道了同步异步,阻塞非阻塞的概念,我们接下来就来分别看看 BIO、NIO、AIO 到底是啥。
BIO:同步阻塞的IO。B 为 blocking。‘’
也就是说,每一个请求请求对应一个线程。每个线程都会等待请求输入的 IO流。如果没有接收到,就一直处于阻塞的状态。知道获取到 IO 流就进行处理。
我们可以写一个例子。就最简单的socket 。同步阻塞方式。
我们创建一个服务端,在服务端有一下几步操作。
1、创建一个 ServerSocket。
2、死循环来等客户端发送的请求信息。
3、解析接收的信息。
4、发送返回信息。
代码如下:
public class Server {
public static void main(String[] args) {
try {
ServerSocket socket = new ServerSocket(8080);
while(true){
System.out.println("waiting......");
Socket accept = socket.accept();//同步阻塞
System.out.println("beging......");
new Thread(()->{
byte[] bytes = new byte[1024];
try {
int len = accept.getInputStream().read(bytes);//同步阻塞
String cilentMessage=new String(bytes,0,len);
System.out.println(cilentMessage);
String backMessage="server accepted message successful. message is "+cilentMessage;
accept.getOutputStream().write(backMessage.getBytes());
accept.getOutputStream().flush();
} catch (IOException e) {
e.printStackTrace();
}
}).start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
接下来我们来写一个客户端。客户端做这几件事。
1、持续读取控制台输入的信息,每次读取一行。
2、创建 socket 连接,连接到服务端。
3、向服务端发送消息。
4、接收服务端发送的消息。
5、解析服务端发送的消息。
6、关闭 socket 连接。
代码如下:
public class Client {
public static void main(String[] args) {
try {
System.out.println("client begin......");
Scanner scanner = new Scanner(System.in);
while (scanner.hasNext()){
Socket socket = new Socket("127.0.0.1",8080);
String message=scanner.nextLine();
socket.getOutputStream().write(message.getBytes());
socket.getOutputStream().flush();
System.out.println("server back message......");
byte[] bytes = new byte[1024];
int len = socket.getInputStream().read(bytes);
String backMessage=new String(bytes,0,len);
System.out.println(backMessage);
socket.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
分别启动服务端和客户端,测试效果如下:
BIO 是一种最简单的IO交互方式。实现方式也很简单,就是同步阻塞,就是当没有收到消息时,就会一直处于等待状态,不会做其他事情。所以效率不高,一般项目中不会使用。
NIO
NIO :同步非阻塞的IO (no-blocking IO) .
服务器实现模式为一个请求一个通道,即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有IO请求时才启动一个线程进行处理。
所以我们先来了解一些基本概念:
通道
NIO 引入的最重要的概念就是通道,Channel(通道)是指数据通道,也就是数据传输的通道。 数据可以从 buffer 中读取到 channel 中,也可以冲channel 中读取到 buffer 中。
缓冲区
buffer 缓存中像是一个缓存,暂时的存放数据,数据可以从 buffer 中读取到 channel 中,也可以冲channel 中读取到 buffer 中。
选择器
选择器是线程与通道之间的桥梁。使用选择器,借助单线程就可以对众多的 IO 通道进行监控和维护。
那 NIO 有什么特点呢?
我们上面可以看到,一个线程就可以处理多个通道。一个通道对应一个连接,所以当创建一个连接,都会注册到多路复用器,也就是 selector 。然后selector 通过轮寻的方式,每个一段时间执行有传输数据的通道。
有一个例子:就是幼儿园每个小孩上厕所都需要老师陪同。NIO就是,为整个幼儿园分配一个老师,老师会定期的询问,想要上厕所的孩子举手,然后统一带领这些孩子去上厕所。这样就避免了每个孩子上厕所,老师都要去一趟。通过多路复用,简化流程。
我们现在也手写一个NIO的例子,一样的分为客户端和服务端。我们先写服务端。
服务端
服务端主要做如下操作:
1、声明多路复用器
2、定义读写缓存区
3、编写初始化方法
4、编写启动类
5、编写执行方法
初始化 init 的方法中主要是
1、开启多路复用器
2、开启通道
3、设置非阻塞
4、绑定端口
5、注册通道
整体代码如下:
public class NIOServer extends Thread{
//1.声明多路复用器
private Selector selector;
//2.定义读写缓冲区
private ByteBuffer readBuffer = ByteBuffer.allocate(1024);
private ByteBuffer writeBuffer = ByteBuffer.allocate(1024);
//3.定义构造方法初始化端口
public NIOServer(int port) {
init(port);
}
//4.main方法启动线程
public static void main(String[] args) {
new Thread(new NIOServer(8888)).start();
}
//5.初始化
private void init(int port) {
try {
System.out.println("服务器正在启动......");
//1)开启多路复用器
this.selector = Selector.open();
//2) 开启服务通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//3)设置为非阻塞
serverSocketChannel.configureBlocking(false);
//4)绑定端口
serverSocketChannel.bind(new InetSocketAddress(port));
/**
* SelectionKey.OP_ACCEPT —— 接收连接继续事件,表示服务器监听到了客户连接,服务器可以接收这个连接了
* SelectionKey.OP_CONNECT —— 连接就绪事件,表示客户与服务器的连接已经建立成功
* SelectionKey.OP_READ —— 读就绪事件,表示通道中已经有了可读的数据,可以执行读操作了(通道目前有数据,可以进行读操作了)
* SelectionKey.OP_WRITE —— 写就绪事件,表示已经可以向通道写数据了(通道目前可以用于写操作)
*/
//5)注册,标记服务连接状态为ACCEPT状态
serverSocketChannel.register(this.selector, SelectionKey.OP_ACCEPT);
System.out.println("服务器启动完毕");
} catch (IOException e) {
e.printStackTrace();
}
}
public void run(){
while (true){
try {
//1.当有至少一个通道被选中,执行此方法
this.selector.select();
//2.获取选中的通道编号集合
Iterator<SelectionKey> keys = this.selector.selectedKeys().iterator();
//3.遍历keys
while (keys.hasNext()) {
SelectionKey key = keys.next();
//4.当前key需要从动刀集合中移出,如果不移出,下次循环会执行对应的逻辑,造成业务错乱
keys.remove();
//5.判断通道是否有效
if (key.isValid()) {
try {
//6.判断是否可以连接
if (key.isAcceptable()) {
accept(key);
}
} catch (CancelledKeyException e) {
//出现异常断开连接
key.cancel();
}
try {
//7.判断是否可读
if (key.isReadable()) {
read(key);
}
} catch (CancelledKeyException e) {
//出现异常断开连接
key.cancel();
}
try {
//8.判断是否可写
if (key.isWritable()) {
write(key);
}
} catch (CancelledKeyException e) {
//出现异常断开连接
key.cancel();
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
private void accept(SelectionKey key) {
try {
//1.当前通道在init方法中注册到了selector中的ServerSocketChannel
ServerSocketChannel serverSocketChannel = (ServerSocketChannel) key.channel();
//2.阻塞方法, 客户端发起后请求返回.
SocketChannel channel = serverSocketChannel.accept();
///3.serverSocketChannel设置为非阻塞
channel.configureBlocking(false);
//4.设置对应客户端的通道标记,设置次通道为可读时使用
channel.register(this.selector, SelectionKey.OP_READ);
} catch (IOException e) {
e.printStackTrace();
}
}
//使用通道读取数据
private void read(SelectionKey key) {
try{
//清空缓存
this.readBuffer.clear();
//获取当前通道对象
SocketChannel channel = (SocketChannel) key.channel();
//将通道的数据(客户发送的data)读到缓存中.
int readLen = channel.read(readBuffer);
//如果通道中没有数据
if(readLen == -1 ){
//关闭通道
key.channel().close();
//关闭连接
key.cancel();
return;
}
//Buffer中有游标,游标不会重置,需要我们调用flip重置. 否则读取不一致
this.readBuffer.flip();
//创建有效字节长度数组
byte[] bytes = new byte[readBuffer.remaining()];
//读取buffer中数据保存在字节数组
readBuffer.get(bytes);
System.out.println("收到了从客户端 "+ channel.getRemoteAddress() + " : "+ new String(bytes,"UTF-8"));
//注册通道,标记为写操作
channel.register(this.selector,SelectionKey.OP_WRITE);
}catch (Exception e){
}
}
//给通道中写操作
private void write(SelectionKey key) {
//清空缓存
this.readBuffer.clear();
//获取当前通道对象
SocketChannel channel = (SocketChannel) key.channel();
//录入数据
Scanner scanner = new Scanner(System.in);
try {
System.out.println("即将发送数据到客户端..");
String line = scanner.nextLine();
//把录入的数据写到Buffer中
writeBuffer.put(line.getBytes("UTF-8"));
//重置缓存游标
writeBuffer.flip();
channel.write(writeBuffer);
channel.register(this.selector,SelectionKey.OP_READ);
} catch (Exception e) {
e.printStackTrace();
}
}
}
在执行方法run 方法中,获取选中的通道编号,遍历判断,状态是否为可接受、可读或者可写。分别进行不同的操作。
客户端
客户端比较简单。
1、开启通道
2、连接到服务器
3、向通道中写入数据
4、解析通道中的数据。
public class NIOClient {
public static void main(String[] args) {
//创建远程地址
InetSocketAddress address = new InetSocketAddress("127.0.0.1",8888);
SocketChannel channel = null;
//定义缓存
ByteBuffer buffer = ByteBuffer.allocate(1024);
try {
//开启通道
channel = SocketChannel.open();
//连接远程远程服务器
channel.connect(address);
Scanner sc = new Scanner(System.in);
while (true){
System.out.println("客户端即将给 服务器发送数据..");
String line = sc.nextLine();
if(line.equals("exit")){
break;
}
//控制台输入数据写到缓存
buffer.put(line.getBytes("UTF-8"));
//重置buffer 游标
buffer.flip();
//数据发送到数据
channel.write(buffer);
//清空缓存数据
buffer.clear();
//读取服务器返回的数据
int readLen = channel.read(buffer);
if(readLen == -1){
break;
}
//重置buffer游标
buffer.flip();
byte[] bytes = new byte[buffer.remaining()];
//读取数据到字节数组
buffer.get(bytes);
System.out.println("收到了服务器发送的数据 : "+ new String(bytes,"UTF-8"));
buffer.clear();
}
} catch (IOException e) {
e.printStackTrace();
} finally {
if (null != channel){
try {
channel.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
}
测试
服务端接收到客户端的消息,并想客户端发送消息。
客户端:向服务端发送消息,接收服务器发送的消息。
AIO
异步非阻塞IO。A代表asynchronize
当有流可以读时,操作系统会将可以读的流传入read方法的缓冲区,并通知应用程序,对于写操作,OS将write方法的流写入完毕是操作系统会主动通知应用程序。因此read和write都是异步 的,完成后会调用回调函数。
使用场景:连接数目多且连接比较长(重操作)的架构,比如相册服务器。重点调用了OS参与并发操作,编程较复杂。
我们一样的来创建一个例子。和AIO 主要的不同还是异步非阻塞,也就是通过子线程进行读写。
服务端
服务端我们要做如下几件事。
1、声明多路复用选择器。
2、定义读写缓冲区
3、初始化选择器和通道
4、主线程启动。
我们先写一个 ServerMain 作为启动类。
public class ServerMain {
//定义一个选择器
private Selector selector;
/**
* 在初始化中要做一下如下操作:
* 1、开启多路复用器
* 2、开启服务通道
* 3、设置为非阻塞
* 4、绑定端口
* 5、标记选择器状态为可接受,表示可以接受通道注册到选择器上。
*/
private void init() {
try {
System.out.println("init......");
//开启多路复用器
selector = Selector.open();
//开启通道
ServerSocketChannel channel = ServerSocketChannel.open();
//设置为非阻塞
channel.configureBlocking(false);
//绑定端口
channel.bind(new InetSocketAddress(8080));
//标记选择器状态为可接受
/**
* SelectionKey.OP_ACCEPT —— 接收连接继续事件,表示服务器监听到了客户连接,服务器可以接收这个连接了
* SelectionKey.OP_CONNECT —— 连接就绪事件,表示客户与服务器的连接已经建立成功
* SelectionKey.OP_READ —— 读就绪事件,表示通道中已经有了可读的数据,可以执行读操作了(通道目前有数据,可以进行读操作了)
* SelectionKey.OP_WRITE —— 写就绪事件,表示已经可以向通道写数据了(通道目前可以用于写操作)
*/
channel.register(selector, SelectionKey.OP_ACCEPT);
System.out.println("init finished......");
} catch (IOException e) {
e.printStackTrace();
}
}
//定义启动方法。
public static void main(String[] args) {
ServerMain main = new ServerMain();
main.init();
main.process();
}
private void process(){
//轮寻
while(true){
try {
//每隔两秒中就轮寻一次选择器。
Thread.sleep(2*1000);
//通道选中的个数,至少有一个通道被选中才会执行。
int count = selector.select();
System.out.println("channel count is "+count);
Set<SelectionKey> selectionKeys = selector.selectedKeys();
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while(iterator.hasNext()){
//获取key
SelectionKey key = iterator.next();
//从迭代器中取出这个key
iterator.remove();
new Thread(new Server(selector,key)).start();
}
} catch (Exception e) {
e.printStackTrace();
}
}
}
}
在main 方法种做了两件事;初始化和创建子线程。
初始化
1、开启多路复用器
2、开启服务通道
3、设置为非阻塞
4、绑定端口
5、标记选择器状态为可接受,表示可以接受通道注册到选择器上。
创建子线程
1、每隔2s进行轮寻,从选择器中获取通道数量。
2、获取选中的通道编号集合
3、遍历
4、从迭代器中删除当前key
5、创建子线程
子线程才是真正进行 接收,读写操作的。我们创建一个Server 类如下:
public class Server extends Thread{
//定义一个选择器
private Selector selector;
//定义读写缓冲区
//定义读写缓冲区
private ByteBuffer readBuffer=ByteBuffer.allocate(1024);
private ByteBuffer writeBuffer=ByteBuffer.allocate(1024);
private SelectionKey key;
public Server(Selector selector,SelectionKey key){
this.selector=selector;
this.key=key;
}
@Override
public void run() {
try {
process();
} catch (Exception e) {
e.printStackTrace();
}
}
private void process() throws Exception {
try{
//判断key 是否有效
if (key.isValid()) {
//如果可以接受
if (key.isAcceptable()) {
System.out.println(Thread.currentThread().getName()+"accept");
accept(key);
}
//如果可读
if(key.isReadable()){
System.out.println(Thread.currentThread().getName()+"read");
read(key);
}
//如果可写
if(key.isWritable()){
System.out.println(Thread.currentThread().getName()+"write");
write(key);
}
}
}catch (CancelledKeyException e){
key.cancel();
}
}
/**
*给通道中写数据。从buffer 中给通道写数据。
* @param key
*/
private void write(SelectionKey key) throws IOException {
writeBuffer.clear();
SocketChannel channel = (SocketChannel) key.channel();
String message = channel.toString();
//写入缓冲区
writeBuffer.put(message.getBytes("UTF-8"));
//重置缓存游标
writeBuffer.flip();
//从缓冲区写入通道
channel.write(writeBuffer);
//重新标记为可读
channel.register(selector,SelectionKey.OP_READ);
}
/**
*使用通道读取数据。主要就是将通道中的数据读取到读缓存中。
* @param key
*/
private void read(SelectionKey key) throws IOException {
readBuffer.clear();
SocketChannel channel = (SocketChannel)key.channel();
int len = channel.read(readBuffer);
//如果通道没有数据
if(len==-1){
//关闭通道
key.channel().close();
//关闭key
key.cancel();
return;
}
//Buffer中有游标,游标不会重置,需要我们调用flip重置. 否则读取不一致
readBuffer.flip();
//创建有效字节长度数组
byte[] bytes = new byte[readBuffer.remaining()];
//读取buffer中数据保存在字节数组
readBuffer.get(bytes);
String clientMessage = new String(bytes, "UTF-8");
System.out.println("accepted client message are "+clientMessage);
//注册通道,标记为写操作
channel.register(selector,SelectionKey.OP_WRITE);
}
/**
*设置通道接受客户端数据,并设置通道为可读。
* @param key
*/
private void accept(SelectionKey key) throws IOException {
//1.获取通道
ServerSocketChannel socketChannel = (ServerSocketChannel) key.channel();
//阻塞方法,获取客户端的请求
SocketChannel channel = socketChannel.accept();
//设置为非阻塞
channel.configureBlocking(false);
//设置对应客户端的通道标记,设置次通道为可读时使用
channel.register(selector,SelectionKey.OP_READ);
}
}
这里设置了 读写缓存区,从父类中获取 选择器 和key 。通过key 判断当前通道的状态是可接收,可读还是可写。分别对应不用的方法。
可读操作,就是将 channel 中的数据读入到 buffer 中。
可写操作,将 buffer 中的数据读入到 channel 中,返回给客户端。
客户端
客户端比较简单,主要做以下几件事,
1、开启一个通道
2、连接到服务器
3、向通道中写入数据
4、接收通道返回的数据。
我们写一个client 类。
public class Client extends Thread{
private int index;
public Client(int index){
this.index=index;
}
@Override
public void run() {
process(index);
}
public void process(int i) {
InetSocketAddress inetSocketAddress = new InetSocketAddress(8080);
ByteBuffer buffer = ByteBuffer.allocate(1024);
//开启通道
try (SocketChannel channel = SocketChannel.open();){
//连接到远程服务器
channel.connect(inetSocketAddress);
buffer.clear();
String meaage = "client send message...."+i;
//写入缓存中
buffer.put(meaage.getBytes("UTF-8"));
buffer.flip();
//写入通道中
channel.write(buffer);
buffer.clear();
//读取服务端的数据
int len = channel.read(buffer);
if(len==-1){
return;
}
buffer.flip();
byte[] bytes = new byte[buffer.remaining()];
buffer.get(bytes);
String serverMessage = new String(bytes, "UTF-8");
System.out.println(i+" accepted message "+serverMessage);
} catch (IOException e) {
e.printStackTrace();
}
}
}
我们再来写一个主类,用来模拟生成度多个通道同时调用效果。
public class ClientMain {
public static void main(String[] args) {
for(int i=0;i<10;i++){
if(i%3==0){
try {
Thread.sleep(1000*1);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
new Client(i).start();
}
}
}
测试
好了,我们现在来启动测试下,先启动服务端。
启动后,没有通道就会处于阻塞状态。
接着我们启动客户端。
这个时候,服务端就可以接收到通道,然后就两秒钟获取一次,进行操作。
客户端的收到的服务端返回的信息。
总结
感兴趣的小伙伴可以动手实践下,印象更深哟