BIO与NIO
说明:本文讨论的io模型都是基于网络通讯socket上讨论的
BIO——阻塞IO模型
在网络通讯中,客户端先与服务端建立连接,由于服务端不知道客户端什么时候会发来数据,所以服务端不得不开启一个线程来接收客户端发来的消息,所以这个io过程中服务端会阻塞起来。在java传统的bio模型中,连接一旦建立,就会一直监听这个socket是否有数据传过来,下面看代码以及注释帮助理解。
package BIO;
import java.io.IOException;
import java.io.InputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* @Description:
* @ClassName: BIOServer
* @Author: yokna
* @Date: 2021/7/22 10:47
* @Version: 1.0
*/
public class BIOServer {
public static void main(String[] args) throws IOException {
//开启一个线程池
ExecutorService executorService = Executors.newCachedThreadPool();
ServerSocket serverSocket = new ServerSocket(6666);
System.out.println("服务启动");
while (true){
//此处会阻塞一次,等待用户端的连接
Socket accept = serverSocket.accept();
System.out.println("连接一个客户端");
executorService.execute(new Runnable() {
@Override
public void run() {
//这里会阻塞一次,具体看下面的方法中注释
handler(accept);
}
});
}
}
public static void handler(Socket socket) {
try {
System.out.println(Thread.currentThread().getId() + Thread.currentThread().getName());
byte[] bytes = new byte[1024];
//获取到socket的输入流
InputStream inputStream = socket.getInputStream();
while (true){
//这里是发生阻塞的根本原因,当系统调用read时,由于网络的不可预知性,对方什么时候能将数据传输过来是不可控制的,所以这里要一直阻塞住,读取客户端发送的数据
int read = inputStream.read(bytes);
if (read != -1){
System.out.println(new String(bytes,0,read));
}else {
break;
}
}
} catch (IOException e) {
e.printStackTrace();
}finally {
try {
socket.shutdownOutput();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
在实际的开发中,当一个连接过来,就开辟一个线程来处理这个连接,而这个连接在传输数据过程中是效率比较低,但是服务器的开销还必须要维持,当客户端多了以后,就需要有更多的线程来处理,这样就产生了线程之间上下文切换的问题,有的连接进来以后,并不会一直发送消息,所以不需要一直循环去read。
NIO 非阻塞的IO模型
在NIO之前,需要先了解mmap以及zerocopy
mmap以及bytebuffer,可以将堆外地址直接映射到java虚拟机上。
传统的io模型在调用read时,流程如下
可以看出,在发生一次read时,涉及到四次内核态到用户态的来回切换,以及两次数据拷贝,这种开销在高并发场景是致命的,往往我们只需要将文件通过socket发送给另一个用户,不需要对文件进行处理时,以上的上下文切换的代价以及数据拷贝的代价是我们需要额外付出的,有没有一种方法直接将文件直接发送给另一个用户呢?当然有,接下来就要引出零拷贝。
zerocopy
在零拷贝的模型中,我们直接通过内核态将data发送出去,不需要做上下文切换,代价仅在于将data通过DMA读入到内存中。但是这种方式仅限于data不需要被java程序处理,假如我们需要将data在程序中处理后再发给用户,这套方式显然不合适,好在有另一套机制mmap,既兼顾了性能,又能定制化操作data。
mmap与sendfile
mmap将堆外内存数据直接映射到java堆内(而非拷贝),这样就节省两次拷贝,但是上下文切换的代价是无法避免的。
bytebuffer
bytebuffer有三种不通的buffer类型,分别对应堆内空间,堆外空间,
HeadByteBuffer
使用ByteBuffer.allocate()创建,该ByteBuffer存在于堆空间,因此获得了GC的支持(可以被垃圾回收掉)以及进行了缓存的优化。但是他不是一段连续的内存空间,也就意味着如果你通过JNI的方式访问native代码,JVM会先拷贝到对其的buffer空间中。
DirectByteBuffer
使用ByteBuffer.allocateDirect()创建,JVM将会使用malloc()函数,分配堆空间之外的内存空间。好处是分配的内存空间是连续的,坏处是没有被JVM管理,这意味着你需要小心内存泄漏。
MappedByteBuffer
使用 FileChannel.map() 映射,分配堆空间之外的内存空间。本质上就是围绕mmap()的系统调用,让我们的java代码可以直接操纵映射的内存数据。
import java.io.File;
import java.io.FileNotFoundException;
import java.io.RandomAccessFile;
import java.nio.ByteBuffer;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.charset.StandardCharsets;
/**
* @Description:
* @ClassName: RandomIO
* @Author: yokna
* @Date: 2021/7/21 9:39
* @Version: 1.0
*/
public class RandomIO {
//支持读,支持写,且可以随机读取,seek会将指针指向seek的位置,然后下一次写入的时候会在seek指针指向的位置写入数据
public static void main(String[] args) throws Exception {
File file = new File("src/main/resources/test");
RandomAccessFile raf = new RandomAccessFile(file,"rw");
//拿到通道
FileChannel c1 = raf.getChannel();
// ByteBuffer三种创建方式
MappedByteBuffer map = c1.map(FileChannel.MapMode.READ_WRITE, 0, 1024);//mmap 内核里面的系统调用,少一次应用程序缓存空间到内存空间缓存的拷贝,在堆外空间内开辟了一个空间,可以跟内核共享
// ByteBuffer buffer = ByteBuffer.allocate(1024);//分配在堆上
// ByteBuffer buffer1 = ByteBuffer.allocateDirect(1024);//分配在堆外空间里,直接映射,内核可以直接访问到这个空间
// c1.write(buffer);
// 区别:堆外的数据如果想写磁盘,通过系统调用后,数据需要从用户空间内存拷贝到内核空间内存中
// 堆外mapbuffer的数据内核直接处理
map.put("hello world \n hello nnn \n good idea".getBytes(StandardCharsets.UTF_8));
map.put("hh".getBytes(StandardCharsets.UTF_8));
//此方法会将指针移动到pos位置
raf.seek(2);
//覆盖写,从指针位置往后依次覆盖写
raf.write("123455".getBytes(StandardCharsets.UTF_8));
}
}
NIO模型
有了以上的mmap概念以及java的bytebuffer实现mmap机制后,我们再来看NIO
为了解决bio在建立连接时的线程开销问题,我们引入了NIO模型,在NIO模式下,调用read,如果发现没数据已经到达,就会立刻返回-1, 并且errno被设为EAGAIN
。
nio一般配合io多路复用来使用,所谓IO多路复用,就是程序注册一组socket文件描述符给操作系统,表示“我要监视这些fd是否有IO事件发生,有了就告诉程序处理”。
1.Linux操作epoll_create,在内核层创建了一个数据表,接口会返回一个“epoll的文件描述符”指向这个表。相当于创建一个selector
2.Linux操作epoll_ctl 注册要监听的事件,JAVA中将channel注册进selector的底层系统调用
3.Linux操作epoll_wait 使用epoll_wait来等待事件的发生,selector.selecte()方法的底层系统调用
package JavaSocket;
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
/**
* @Description: 同步非阻塞
* @ClassName: NIO
* @Author: yokna
* @Date: 2021/7/21 11:02
* @Version: 1.0
*/
public class NIO {
public void nio() throws IOException {
//相当于serversocket
ServerSocketChannel ss = ServerSocketChannel.open();
//开启nio,非阻塞模型
ss.configureBlocking(false);
ss.bind(new InetSocketAddress(8089));
Selector selector = Selector.open();//epoll_create 系统调用,给一个ep_fd文件描述符,用来注册socket文件描述符
ss.register(selector, SelectionKey.OP_ACCEPT);//epoll_ctl(ep_fd,fd) 将文件描述符放到一颗红黑树上去
while (true){
int num = selector.select();//epoll_wait系统调用 如果内核发现socket事件,会将selector中的红黑树中的文件描述符放到一个链表中去
if (num == 0) continue;
//将选择器中的keys获取到,进行遍历,keys是不可变的,任何想尝试修改keys的操作都会引起异常
Set<SelectionKey> keys = selector.keys();
Iterator<SelectionKey> iter = keys.iterator();
while (iter.hasNext()){
SelectionKey key = iter.next();
if (key.isAcceptable()){
ServerSocketChannel cc = (ServerSocketChannel) key.channel();
SocketChannel sc = cc.accept();
sc.configureBlocking(false);
sc.register(selector,SelectionKey.OP_READ);
}else if (key.isReadable()){
SocketChannel c1 = (SocketChannel) key.channel();
ByteBuffer bf = ByteBuffer.allocate(1024);
c1.read(bf);
byte[] inData = new byte[1024];
bf.get(inData,0,bf.limit());
System.out.println(new String(inData,0,bf.limit()));
}
}
iter.remove();//处理完一个事件就要移除一个,否则多线程下会反复处理。
}
}
}
总结
BIO是阻塞的网络IO模型,阻塞表现在于连接建立以后,服务端无法知道客户端什么时候将数据发过来,且发送一次data需要四次上下文切换两次拷贝,代价大。
zerocopy是网络数据发送的一个优化,如果不需要对data进行修改,可以直接从内存中发送给对方,省去两次拷贝(从内存中拷贝到java程序中,再从java程序中拷贝到内存中)
mmap是直接堆外内存映射技术,将堆外内存映射到堆内空间,但jvm是无法管理这片地址空间的,mmap出现是为了解决data需要修改但又不想经过多次复制及上下文切换,mmap的代价主要表现为堆外内存地址空间jvm无法管理、仍然会有上下文切换的代价。在java中的实现是bytebuffer。
NIO一般与io多路复用联合使用,核心三大组件有bytebuffer、channel、selector。bytebuffer是为了减少拷贝次数以及上下文切换产生的,channel是服务器端与客户端之间连接的通道,selector是服务器端管理channel的机制,当channel中有数据过来时,会在selector中被发现,从而服务端可以很从容的处理网络io请求。整个过程是基于事件驱动的,通过不断轮询发现selector中的事件,然后再将发生事件的channel从selector中取出来,如果设计到服务器端给客户端发送data时,就使用bytebuffer快速发送。这一套组合拳打下来,打打提高了java网络通讯的速度,也大大提高了java程序的并发量!
为了减少拷贝次数以及上下文切换产生的,channel是服务器端与客户端之间连接的通道,selector是服务器端管理channel的机制,当channel中有数据过来时,会在selector中被发现,从而服务端可以很从容的处理网络io请求。整个过程是基于事件驱动的,通过不断轮询发现selector中的事件,然后再将发生事件的channel从selector中取出来,如果设计到服务器端给客户端发送data时,就使用bytebuffer快速发送。这一套组合拳打下来,打打提高了java网络通讯的速度,也大大提高了java程序的并发量!