NIO,BIO,AIO

48 阅读9分钟

NIO,BIO,AIO

阻塞IO,非阻塞IO,IO多路复用

  • 阻塞IO:发起 I/O 请求后,会一直阻塞,直到数据就绪并完成拷贝
  • 非阻塞IO:发起 I/O 请求后立即返回,需要轮询是否准备好数据
  • IO多路复用:一个线程监控多个 I/O
  • 异步IO:I/O 操作由操作系统内核完成,完成后主动通知应用程序(通过回调),无需轮询或阻塞,当 I/O 完成时自动调用对应方法处理数据。

NIO,BIO,AIO

  • BIO (Blocking I/O):同步阻塞 I/O 模式。Stream
  • NIO (New I/O):同步非阻塞模式。
  • AIO (Asynchronous I/O):异步非阻塞 I/O 模型。

Java基础网络编程里面就属于BIO,由于是同步阻塞,为了处理多个连接请求通常会为每个连接开辟一个线程。采用线程池也避免不了线程仅能处理一个 socket 连接。

  • 弊端:消耗大量线程资源,频繁的上下文切换,突发的流量会导致程序或者系统崩溃

I/O多路复用:Linux系统中用于同时监控多个文件描述符状态变化的方法。大部分Java应用都是直接部署在Linux上,而NIO使用了Linux提供的多路复用接口。

  • select: 用位图来表示文件描述符集合,线程轮询的所有的文件描述符是否有事件发生。每次调用都会出现一次位图在用户空间和内核空间的拷贝。
  • poll:解决select文件描述符数量有限的问题,依然有拷贝。
  • epoll:使用共享内存技术避免拷贝的性能损失,使用红黑树管理文件描述符,事件触发时高效查询描述符,然后根据编程监听的事件放入到就绪队列中,用户线程每次调用都会直接拿到就绪的文件描述符。 水平触发:poll和epoll默认模式,只要文件描述符处于"就绪"状态,就会持续通知(如数据没有读取完毕,描述符依然处于"就绪"状态) 边缘触发:文件描述符状态发生变化时通知一次

短链接和长连接

  • 短连接:建立TCP连接后,进行一次数据发送和响应后就关闭 HTTP/1.0的请求头中Connection默认为close,也就是短链接
  • 长连接:建立TCP连接后,进行了多次发送和响应才关闭 HTTP/1.0请求头中Connection默认为keep-alive,也就是长连接

零拷贝

数据传递时减少拷贝次数提升效率,inux下有系统调用sendfile() 提供零拷贝接口。

在Java中

传统文件数据拷贝流向(目标文件为网卡或者磁盘)

文件 → 内核缓冲区 → 用户空间缓冲区 → 内核缓冲区 → 目标文件
    (DMA拷贝)    (CPU拷贝)      (CPU拷贝)    (DMA拷贝)
  • 4次拷贝,2次cpu状态切换(上下文切换)

零拷贝:Java中channel的transferTo()能够实现0拷贝

文件 → 内核缓冲区 → 目标文件
    (DMA拷贝)    (DMA拷贝)
  • 避免了内核缓冲区到用户缓冲区的拷贝,以及cpu状态切换(上下文切换)

在Java中JVM 堆内的 byte[] 地址不固定的,因为GC会移动对象。在操作系统层面,IO的需要传递一个明确的内存用于存放数据,所以Java的又多了一层堆外和堆内内存的数据拷贝。

源文件 → 内核页缓存 → 临时堆外缓冲区 → JVM堆内byte[] → 临时堆外缓冲区 → 内核页缓存 → 目标文件
  (DMA)     (CPU)           (CPU)             (CPU)           (CPU)         (DMA)

可以通过DirectByteBuffer 将堆外内存映射到 jvm 内存中来直接访问使用,减少这次拷贝。GC在回收DirectByteBuffer时,DirectByteBuffer会自己释放掉对应的直接内存。

ByteBuffer directBuf = ByteBuffer.allocateDirect(4096);

Java BIO

基于Stream

// 服务器端
ServerSocket server = new ServerSocket(8080);
Socket client = server.accept(); // 阻塞等待连接
​
InputStream in = client.getInputStream();
OutputStream out = client.getOutputStream();
​
byte[] buffer = new byte[1024];
int len = in.read(buffer); // 阻塞直到有数据
out.write("Hello".getBytes()); // 阻塞直到数据写入

Stream

  • 单向,只读或者只写
  • 属于阻塞IO

Java NIO

  • 双向,配合buffer读写数据
  • 支持阻塞和非阻塞IO,支持selector
  • 支持零拷贝

channel :双向,能够读写数据

  • FileChannel:文件数据传输
  • DatagramChannel:UDP编程
  • SocketChannel:TCP
  • ServerSocketChannel:TCP主要用作服务器

buffer:缓冲区,配合channel使用,读取数据到channel,常用ByteBuffer

  • ByteBuffer: 实现类 MappedByteBuffer, DirectByteBuffer:ByteBuffer.allocateDirect(size) HeapByteBuffer:创建方式ByteBuffer.allocate(size)

ByteBuffer

非线程安全

  • compact : 保留没有读的,继续写入(切换到写)
  • clear:清除缓冲区数据(切换到写)
  • flip:切换读模式
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.io.FileInputStream;
​
public class SuperSimpleExample {
    public static void main(String[] args) {
        try {
            // 1. 创建水管(连接到文件的通道)
            FileChannel channel = new FileInputStream("test.txt").getChannel();
            
            // 2. 准备一个水桶(容量为10字节的缓冲区)
            ByteBuffer buffer = ByteBuffer.allocate(10);
            
            // 3. 用水管往水桶里接水(从通道读取数据到缓冲区)
            int bytesRead = channel.read(buffer);
            System.out.println("读取了 " + bytesRead + " 字节数据");
            
            // 4. 告诉水桶:现在我要从里面倒水了!(切换为读模式)
            buffer.flip();
            
            // 5. 从水桶里倒水(从缓冲区读取数据)
            System.out.println("文件内容是:");
            while (buffer.hasRemaining()) {
                System.out.print((char) buffer.get());
            }
            
            // 6. 关闭水管
            channel.close();
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

常用API

Bytebuffer buf = ByteBuffer.allocate(16); // 创建
int readBytes = channel.read(buf); // 用channel的read写入
buf.put((byte)127); // put写入
int writeBytes = channel.write(buf); // channel的write
byte b = buf.get();// get获取,get(int i),不会让position变化
buf.rewind(); // 重置position指针
buf.mark();//标记,调用reset后position会回到标记位置,rewind和flip会清除mark
​
// 字符串和缓冲区的转换
ByteBuffer buffer1 = StandardCharsets.UTF_8.encode("你好");
ByteBuffer buffer2 = Charset.forName("utf-8").encode("你好");
​
// channel一次性写多个buffer
channel.write(new ByteBuffer[]{d, e});
// 分散读取数据到多个buffer
channel.read(new ByteBuffer[]{a, b, c});

FileChannel

  • 通过 FileInputStream 获取的 channel 只能读
  • 通过 FileOutputStream 获取的 channel 只能写
  • 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式决定

读取数据

int readBytes = channel.read(buffer);
​
long pos = channel.position(); // -1 表示没有内容
channel.position(newPos); // 设置位置

写入数据

ByteBuffer buffer = ...;
buffer.put(...); // 存入数据
buffer.flip();   // 切换读模式
// channel的write无法保证一次性写完
while(buffer.hasRemaining()) {
    channel.write(buffer);
}
// position到达文件末尾,新内容和旧内容之间会有空洞(00)

关闭

// 文件关闭会间接关闭channel
channel.close();

其他

channel.size();//文件大小
channel.force(true);//立即写入磁盘
from.transferTo(0, from.size(), to); // 两个channel间传递数据 from 传递到 to , 而且采用零拷贝技术

SocketChannel/ServerSocketChannel

传输数据就往channel里面写即可

ServerSocketChannel

  • 创建channel:ServerSocketChannel.open();
  • 绑定端口:ssc.bind(new InetSocketAddress(8080));
  • 获取建立的连接channel:accept()在默认会阻塞等待连接,返回一个SocketChannel对象
  • 配置非阻塞:配置为非阻塞 ssc.configureBlocking(false);

SocketChannel

  • 创建:SocketChannel.open();
  • 连接:sc.connect(new InetSocketAddress("localhost", 8080));
  • 配置非阻塞:sc.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16);
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.configureBlocking(false);
ssc.bind(new InetSocketAddress(8080));
List<SocketChannel> channels = new ArrayList<>();
​
while (true) {
    // 接受新连接
    SocketChannel sc = ssc.accept();
    if (sc != null) {
        log.debug("connected... {}", sc);
        sc.configureBlocking(false);
        channels.add(sc);
    }
    
    // 记录需要关闭的连接
    List<SocketChannel> closedChannels = new ArrayList<>();
    
    for (SocketChannel channel : channels) {
        try {
            int read = channel.read(buffer);
            // -1说明客户端主动关闭了
            if (read == -1) {
                // 标记为需要关闭
                closedChannels.add(channel);
                continue;
            }
            
            if (read > 0) {
                buffer.flip();
                debugRead(buffer);
                buffer.clear();
                log.debug("after read...{}", channel);
            }
        } catch (IOException e) {
            // 发生异常,标记为需要关闭
            closedChannels.add(channel);
        }
    }
    
    // 清理已关闭的连接
    for (SocketChannel closedChannel : closedChannels) {
        log.debug("关闭连接: {}", closedChannel);
        try {
            closedChannel.close();
        } catch (IOException e) {
            // 忽略关闭异常
        }
        channels.remove(closedChannel);
    }
}

Selector

selector
thread
channel
channel
channel

selector:管理channel,当channel发生事件时,就会让线程进行处理

适用于连接数多,但是每个连接处理速度快

  • 创建: Selector.open();

  • 绑定Channel事件

    channel.configureBlocking(false); // 必须非阻塞
    SelectionKey key = channel.register(selector, 事件); // 绑定到对应的selector
    

    事件类型:

    connect - 客户端连接成功时触发
    accept - 服务器端成功接受连接时触发
    read - 数据可读入时触发,有因为接收能力弱,数据暂不能读入的情况
    write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
    
  • 监听事件

    int count = selector.select(); // 阻塞直到事件发生
    int count = selector.select(long timeout); // 事件发生或者超时
    //select:只有出现调用 selector.wakeup()/ selector.close()/selector 所在线程 interrupt/事件发生才会不阻塞==>selector被阻塞,注册的方法就执行不了了int count = selector.selectNow(); // 不阻塞
    
  • 获取事件

     Set<SelectionKey> keys = selector.selectedKeys();
     // SelectionKey 的一些api
     // key.isAcceptable() : 是否是accept的事件类型
     // key.channel() : 获取对应channel注意类型转换
     // key.isReadable() : 是否为读类型   
     // key.cancel() : 取消事件
     // key.attachment() :获取attachment//attachment是可以附加到 SelectionKey 上的任意对象
    // 创建一个 ByteBuffer 作为附件,这个在写操作时是很有用
    ByteBuffer buffer = ByteBuffer.allocate(16);  
    // 将这个 buffer 关联到 selectionKey 上
    SelectionKey scKey = sc.register(selector, 0, buffer); // 第三个参数就是 attachment
    
  • 处理事件:不处理下次任然和发生,不处理可以取消,nio 底层使用的是水平触发

    判断事件类型,通过key.channel()获取到channel后,完成对应处理逻辑,要从set中移除对应key,不然第二次处理时可能会有空指针一次(如读操作,对应channel可能已经被断开连接释放掉了)

Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
​
while (true) {
    selector.select(); // 阻塞直到有事件
  
    Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
    while (iter.hasNext()) {
        SelectionKey key = iter.next();
        iter.remove(); // 从集合中移除next对应的元素
        
        if (key.isAcceptable()) {
            // 处理新连接
            SocketChannel sc = ssc.accept();
            sc.configureBlocking(false);
            sc.register(selector, SelectionKey.OP_READ);
            
        } else if (key.isReadable()) {
            SocketChannel channel = (SocketChannel) key.channel();
            try {
                int read = channel.read(buffer);
                if (read == -1) {
                    // 连接关闭,自动清理
                    key.cancel();
                    channel.close();
                } else if (read > 0) {
                    buffer.flip();
                    debugRead(buffer);
                    buffer.clear();
                }
            } catch (IOException e) {
                // 异常时自动清理
                key.cancel();
                channel.close();
            }
        }
    }
}

为什么还需要写事件?

通常来说,监听到成功建立连接了,直接写即可。写事件主要原因是无法一次性将缓冲区数据全部写完。

每个TCP连接都有一个发送缓冲区,当这个缓冲区满时

  • 非阻塞模式:write() 立即返回,只写入能容纳的数据量
  • 阻塞模式:write() 会阻塞,直到所有数据被放入缓冲区

如果不用写事件,建立完连接后直接写使用buffer.hasRemaining()判断缓冲区是否还有数据,然后循环继续写,网络状况不好时会出现多次写入为0,意味着cpu在空转。

DatagramChannel

UDP是无连接的,客户端和服务端代码一致。

DatagramChannel channel = DatagramChannel.open();
channel.configureBlocking(false);
channel.socket().bind(new InetSocketAddress(9999));
​
Selector selector = Selector.open();
channel.register(selector, SelectionKey.OP_READ);
​
while (true) {
    selector.select(); // 阻塞直到有通道就绪(但不是 receive 阻塞)
    Iterator<SelectionKey> keys = selector.selectedKeys().iterator();
    
    while (keys.hasNext()) {
        SelectionKey key = keys.next();
        keys.remove();
        
        if (key.isReadable()) {
            ByteBuffer buf = ByteBuffer.allocate(1024);
            SocketAddress client = channel.receive(buf); // 此时肯定有数据,不会阻塞
            // 处理数据...
        }
    }
}

Java AIO

JVM会拉起一个守护线程去执行对应回调函数。

JVM线程

  • 用户线程:主动创建的线程(可以设置为守护线程)或者main线程
  • 守护线程:后台服务线程,如垃圾回收和AIO回调,当所有用户线程结束时,JVM 会强制终止所有守护线程并退出
public class AIOServer {
    public static void main(String[] args) throws Exception {
        AsynchronousServerSocketChannel server = AsynchronousServerSocketChannel.open()
                .bind(new InetSocketAddress(8080));
​
        System.out.println("AIO Server 启动,监听 8080 端口...");
​
        // 接受连接(异步)
        server.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
            @Override
            public void completed(AsynchronousSocketChannel client, Object attachment) {
                // 继续接受下一个连接
                server.accept(null, this);
​
                try {
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    // 异步读取数据
                    client.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                        @Override
                        public void completed(Integer result, ByteBuffer buf) {
                            buf.flip();
                            System.out.println("收到数据: " + new String(buf.array(), 0, result));
                            // 回写响应
                            client.write(ByteBuffer.wrap("Hello from AIO Server!".getBytes()));
                            client.close();
                        }
​
                        @Override
                        public void failed(Throwable exc, ByteBuffer buf) {
                            exc.printStackTrace();
                        }
                    });
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
​
            @Override
            public void failed(Throwable exc, Object attachment) {
                exc.printStackTrace();
            }
        });
​
        // 防止主线程退出
        System.in.read();
    }
}