JAVA IO模型

159 阅读10分钟

JAVA IO模型

BIO

Blocking IO,同步并阻塞方式,应用程序向OS请求网络IO操作,然后会等待IO操作完成。客户端与服务器端连接,一个连接创建一个线程。

适用于连接数目比较小且固定的架构,对服务器要求比较高

简单流程:

  1. 服务端启动一个ServerSocket。
  2. 客户端启动Socket对服务端通讯,默认情况下服务端对每个客户建立一个线程。
  3. 客户端发送请求后,先检测服务端是否有线程响应,没有则等待或被拒绝。
  4. 有响应,客户端线程等待请求结束返回响应,再继续执行。

NIO

Non-Blocking IO,同步非阻塞,一个线程处理多个请求,客户端发送的连接请求会注册到多路复用器上,多路复用器轮询到IO请求会处理

适用于连接数目多且连接比较短的架构,比如聊天服务器,弹幕系统,服务器间通讯。

Selector、Channel和Buffer的关系

  • 每个Channel都会对应一个Buffer
  • Selector对应一个线程,一个线程对应多个Channel
  • 程序切换到哪个channel是由事件决定的。
  • Selector会根据不同的事件在各个通道切换
  • Buffer就是一个内存块,底层有个数组
  • 数据的读取写入是通过Buffer,可以双向读写,需要filp方法切换
  • channel是双向的,可以返回底层操作系统的情况,比如Linux,底层操作系统的通道就是双向的

image-20221114201053624

Buffer

Buffer是一个抽象类,子类有:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

在子类中有一个数组hb用于缓冲的实现。

 // Invariants: mark <= position <= limit <= capacity
     // 标记,调用mark()可以将position的值赋给mark,reset()恢复position
     private int mark = -1;
     // 位置,下一个要读或写的偏移量
     private int position = 0;
     // 缓冲区当前的终点,不能对大于limit的位置修改,limit位置可以修改
     private int limit;
     // 容量,可容纳最大数据量,不可修改
     private int capacity;

Channel

  • 通道可以同时进行读写
  • 通道可以实现异步读写数据
  • 通道可以从缓冲读,可以写到缓冲

Channel是一个接口

 public interface Channel extends Closeable {
 ​
     public boolean isOpen();
 ​
     public void close() throws IOException;
 ​
 }

常用的Channel类有FileChannel(文件读写)、DatagramChannel(UDP数据读写)、ServerSocketChannel和SocketChannel(这俩TCP数据读写)

在各种channel所依托的流如果关闭其read()方法会返回-1,其余返回0

Selector

用一个线程处理多个客户端连接,就会用到选择器,Selector可以检测多个注册的通道上是否有事件发生(多个Channel以事件的方式可以注册到同一个Selector)

只有在 连接/通道 真正有读写事件发生时,才会进行读写,大大减少了系统开销,避免了多线程上下文切换导致的开销。

Selector是一个抽象类,常用方法有

  • open( ):得到一个选择器对象
  • selectedKeys( ):获得内部集合所有等待IO操作的selectionKey
  • select(long timeout):监控所有注册通道,当有IO操作发生时将对应的SelectionKey加入到内部集合并且返回,参数超时时间,超时返回有事件发生的SelectionKey
  • select():阻塞直到注册的通道有事件到达,返回有事件发生的SelectionKey
  • selcetNow():不阻塞,立马返回
  • wakeuo():唤醒selector
  • keys():返回当前所有注册在selector中channel的selectionKey

一个 SelectionKey 表示了一个特定的channel通道对象和一个特定的selector选择器对象之间的注册关系。

🛑SelectionKey在被轮询后需要remove(),selector不会自己删除selectedKeys()集合中的selectionKey,如果不人工remove(),将导致下次select()的时候selectedKeys()中仍有上次轮询留下来的信息,这样必然会出现错误。

🛑注册过的channel信息会以SelectionKey的形式存储在selector.keys()中。keys()中的成员是不需要被删除的(以此来记录channel信息)。

SelectionKey

SelectionKey表示Selector与网络通道是注册关系

NIO非阻塞网络编程

实现:

  1. 客户端连接时,会通过ServerSocketChannel得到SocketChannel。

  2. 将SocketChannel注册到Selector上,register(Selector sel, int ops),一个Selector可以注册多个SocketChannel。

  3. 注册后返回一个SelectionKey。

     // 读事件
     public static final int OP_READ = 1 << 0;
     // 写事件
     public static final int OP_WRITE = 1 << 2;
     // 连接事件
     public static final int OP_CONNECT = 1 << 3;
     // 接受连接事件
     public static final int OP_ACCEPT = 1 << 4;
    
  4. Selector进行监听,select( )返回有事件发生的通道个数。

  5. 进一步得到各个SelectionKey。

  6. 在通过SelectionKey反向获取SocketChannel。

  7. 通过得到的Channel完成处理。

服务端实现

 @Slf4j(topic = "Server")
 public class NIOServer {
     public static void main(String[] args) throws IOException {
         //创建选择器(open是一个工厂方法)
         Selector selector = Selector.open();
         // 创建ServerSocketChannel绑定套接字
         ServerSocketChannel ssChannel = ServerSocketChannel.open();
         ssChannel.socket().bind(new InetSocketAddress("127.0.0.1", 8888));
         // 设置非阻塞
         ssChannel.configureBlocking(false);
         // 将通道注册到选择器上,接受连接事件操作
         ssChannel.register(selector, SelectionKey.OP_ACCEPT);
 ​
         // 循环监听
         while (true) {
             //监听事件,会阻塞直到有至少一个事件到达
             selector.select();
 ​
             //获取到达的事件
             Set<SelectionKey> keys = selector.selectedKeys();
             //使用迭代器遍历
             Iterator<SelectionKey> keyIterator = keys.iterator();
 ​
             while (keyIterator.hasNext()) {
 ​
                 SelectionKey key = keyIterator.next();
 ​
                 //对应OP_ACCEPT通道事件,客户端连接先执行这个代码
                 if (key.isAcceptable()) {
 ​
                     ServerSocketChannel ssChannel1 = (ServerSocketChannel) key.channel();
 ​
                     // 服务器会为每个新连接创建一个 SocketChannel
                     SocketChannel sChannel = ssChannel1.accept();
                     sChannel.configureBlocking(false);
                     log.info("socketChannel HashCode: {}", sChannel.hashCode());
 ​
                     // 这个新连接主要用于从客户端读取数据
                     sChannel.register(selector, SelectionKey.OP_READ, ByteBuffer.allocate(1024));
 ​
                 } else if (key.isReadable()) {
                     // key反向获取对应Channel
                     SocketChannel sChannel = (SocketChannel) key.channel();
                     // 拿到之前放进去的ByteBuffer
                     ByteBuffer buffer = (ByteBuffer) key.attachment();
                     sChannel.read(buffer);
                     // 读写反转limit=position,position = 0
                     buffer.flip();
                     log.info(new String(buffer.array(), buffer.position(), buffer.limit()));
                     sChannel.close();
                     
                 }
                 // 从SectionKeys集合中移除当前已处理的SelectionKey,重复操作
                 keyIterator.remove();
             }
         }
     }

客户端实现

 @Slf4j(topic = "Client")
 public class NiOClient {
     public static void main(String[] args) throws IOException, IOException {
 //        Socket socket = new Socket("127.0.0.1", 8888);
 //        OutputStream out = socket.getOutputStream();
 //        String s = "hello world";
 //        out.write(s.getBytes());
 //        out.close();
 ​
         SocketChannel socketChannel = SocketChannel.open();
         socketChannel.configureBlocking(false);
         InetSocketAddress inetSocketAddress = new InetSocketAddress("127.0.0.1",8888);
         if(!socketChannel.connect(inetSocketAddress)) {
             while (!socketChannel.finishConnect()){
             }
         }
         String str = "你好,啊";
         // 无需指定大小,直接就是你传进的byte数组大小
         ByteBuffer buffer = ByteBuffer.wrap(str.getBytes(StandardCharsets.UTF_8));
         socketChannel.write(buffer);
     }
 }

NIO与零拷贝

零拷贝

这要从Linux说起:Linux系统中一切皆文件,很多活动本质上都是读写操作。

一般的数据拷贝过程:

  1. 当应用程序读取磁盘数据时,调用read( )从用户态到内核态,该过程由cpu完成
  2. 之后CPU发送I/O请求,磁盘收到请求后开始准备数据
  3. 磁盘将数据传送到磁盘的缓冲区中,然后发送I/O中断
  4. CPU收到中断后开始拷贝数据,然后由read()返回,再从内核态转换成用户态

直接内存访问(Direct Memory Access)方式是一种硬件直接访问内存的一种方式:

  • 读数据;

    1. 调用read()函数,用户态切换内核态,状态切换一次;
    2. DMA控制器将数据从磁盘拷贝到内核缓冲区,1次DMA拷贝;
    3. CPU将数据从内核缓冲区复制到用户缓冲区,1次CPU拷贝;
    4. read()函数返回,用户态切换回用户态,2次状态切换;
  • 写数据;

    1. 调用write()函数,用户态切换内核态,1次切换;
    2. CPU将用户缓冲区数据拷贝到内核缓冲区,1次CPU拷贝;
    3. DMA将数据从内核缓冲区复制到套接字的缓冲区,1次DMA拷贝;
    4. write()函数返回,内核态切换回用户态,2次切换;

零拷贝是网络编程的关键,很多性能优化都离不开零拷贝

在java程序中,常用的零拷贝有mmap(内存映射)和sendFile

mmap内存映射

内存映射文件是 一种读写数据的方法,比常规的流或者通道读写要快,但是会有一些安全问题。

内存映射文件是一个文件到一块内存的映射。使用内存映射文件处理存储于磁盘的文件时,不比对文件执行IO操作。在Linux中,mmap实现了内核中读缓冲区域用户空间缓冲区的映射,从而实现二者的缓冲区共享。这样就减少了一次cpu拷贝。

  1. 用户进程通过mmap()向操作系统内核发起IO调用,用户态切换为内核态
  2. CPU利用DMA控制器,把数据从硬盘中拷贝到内核缓冲区。
  3. 内核态切换回用户态write()返回。
  4. 用户进程通过write()向操作系统内核发起IO调用,用户态切换为内核态
  5. CPU将内核缓冲区的数据拷贝到的socket缓冲区。
  6. CPU利用DMA控制器,把数据从socket缓冲区拷贝到网卡,内核态切换回用户态,write调用返回。

一次读+写4次上下文切换,3次数据拷贝

MappedByteBuffer 类继承自ByteBuffer,子类DirectByteBuffer内部维护了一个缓存数组偏移量arrayBaseOffset

FileChannel提供了map()方法把文件映射到虚拟内存,可以整个文件映射,也可以分段映射

 @Test
     public void mappedByteBufferTest() throws IOException {
         /*
          * MappedByteBuffer 可让文件直接在内存(堆外内存)修改,操作系统不需要拷贝一次
          */
         RandomAccessFile randomAccessFile = new RandomAccessFile("h1.txt", "rw");
         FileChannel fileChannel = randomAccessFile.getChannel();
         // FileChannel.MapMode.READ_WRITE 读写模式
         // 0 可以修改的起始位置
         // 映射到内存的大小,即将文件多少个字节映射到内存
         // 可直接修改的范围是0-5
         MappedByteBuffer mappedByteBuffer = fileChannel.map(FileChannel.MapMode.READ_WRITE, 0, 5);
 ​
         mappedByteBuffer.put(0, (byte) 'H');
         mappedByteBuffer.put(1, (byte) 'H');
         mappedByteBuffer.put(2, (byte) 'H');
         mappedByteBuffer.put(3, (byte) 'H');
         mappedByteBuffer.put(4, (byte) 'H');
 ​
         randomAccessFile.close();
     }

sendFile系统调用

建立两个文件之间的传输通道

  1. 用户进程发起sendfile系统调用用户态到内核态
  2. DMA控制器,把数据从硬盘中拷贝到内核缓冲区。
  3. CPU将读缓冲区中数据拷贝到socket缓冲区
  4. DMA控制器,异步把数据从socket缓冲区拷贝到网卡,
  5. 内核态到回用户态sendfile调用返回。

2次上下文切换,最少3次数据拷贝

AIO

Asynchronous I/O,异步非阻塞,无论是客户端的连接请求还是读写请求都会异步执行, 由操作系统完成后回调通知服务端程序启动线程去处理

适用于连接数目多且连接比较长的架构,相册服务器

BIONIOAIO
IO模型同步阻塞同步非阻塞(多路复用)异步非阻塞
编程难度简单复杂复杂
可靠性
吞吐量

NIO存在的问题

在NIO中,若Selector的轮询结果为空,也没有wakeup()或新消息处理,会发生空轮询导致CPU占用100%。

前置知识:

JDK1.5引入epoll机制。epoll是linux2.6内核的系统调用,设计目的就是替代select、poll线性复杂度的模型,epoll的时间复杂度是O(1)。epoll在高并发场景表现优秀。

文件描述符(File Descriptor——fd) :linux内核利用文件描述符来访问文件,打开显存文件或新建文件时,内核会返回一个文件描述符。读写文件也需要使用文件描述符来指定待读写的文件。

文件句柄:Windows下的概念,句柄是各种对象的标识符,它是一个非负整数,也用于定位文件数据在内存中的位置。

linux下的文件描述符其实就相当于windows下的句柄。文件句柄只是windows众多句柄中的一种类型而已。

对于操作系统会有这样一个需求:如何在一个进程(线程)中处理多个文件,或者监听多个文件的IO事件。

selectpollepoll都是操作系统中实现IO多路复用的方法。

select

select方法本质就是维护了一个文件描述符数组(32位系统MAX=1024,64位MAX=2048),依次实现IO多路复用,select会监视文件描述符的变化。

select()机制中提供了fd_set数据结构,实际上是一个long类型的数组,每个数组元素都能与以打开的文件描述符建立联系。

/proc/sys/fs/file-max指定了系统范围内所有进程可打开的文件的数量限制

select方法被调用,首先需要将fd_set从用户空间拷贝到内核空间,然后内核用poll机制(非多路复用的poll)返回一个fd准备就绪个数。方法返回后需要轮询fd_set,检查发生IO事件的fd。

缺陷:

  • 使用轮询,效率低。
  • 会导致用户空间和内核空间频繁拷贝数据。

poll

pollselect很类似,只不过poll维护的是一个链表,单个进程监听的fd不再有数量限制,但是和select相同的轮询和复制问题依然存在。

epoll

epoll全称EventPoll,是linux内核实现IO多路复用的模型。

在Linux中,selectorpoll监听文件描述符list,进行线性的查找,复杂度O(1)。

epoll使用了内核文件级别的回调机制,复杂度O(1)。

epoll基于事件驱动,给每个fd注册一个回调函数,当对应的fd有IO事件发生时就调用这个回调函数,将这个fd放入一个链表中,这样客户端可以直接从链表中获得发生IO事件的fd,从而达到O(1)级别的监听。

epoll有的三个系统调用:epoll_create, epoll_ctl, epoll_wait

JDK中epoll的实现

理论上无客户端连接时Selector.select() 方法会阻塞,但空轮询bug导致:即使无客户端连接,NIO照样不断的从select本应该阻塞的Selector.select()中wake up出来,导致CPU100%问题。