Java NIO—测试可写事件,实现非阻塞写

468 阅读6分钟

测试demo

测试demo的思路和大致说明:

模拟用户在客户端上传文件,客户端向服务端写大量数据,服务端接收数据。

server端程序

@Slf4j
public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
        serverSocketChannel.configureBlocking(false);
        serverSocketChannel.bind(new InetSocketAddress("127.0.0.1", 9001));
        Selector selector = Selector.open();
        serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

        int readCount = 0;
        log.info("start");
        while (true) {
            int select = selector.select();
            if (select <= 0) {
                continue;
            }

            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> keysIterator = selectedKeys.iterator();
            while (keysIterator.hasNext()) {
                SelectionKey selectionKey = keysIterator.next();
                keysIterator.remove();

                if (selectionKey.isAcceptable()) {
                    SocketChannel socketChannel = ((ServerSocketChannel) selectionKey.channel()).accept();
                    if (socketChannel == null) {
                        log.info("accept null");
                        continue;
                    }

                    log.info("accept success:{}, selectorSame:{}", socketChannel.getRemoteAddress(), selector == selectionKey.selector());
                    socketChannel.configureBlocking(false);
                    SelectionKey newKey = socketChannel.register(selectionKey.selector(), SelectionKey.OP_READ);
                    ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
                    // 绑定一个ByteArrayOutputStream,将该SocketChannel读到的数据都写到byteArrayOutputStream中
                    newKey.attach(byteArrayOutputStream);
                    continue;
                }
                if (selectionKey.isReadable()) {
                    readCount++;
                    log.info("read round {} start", readCount);
                    SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                    // TODO 可以适当增大或缩小Buffer,控制读取次数
                    ByteBuffer byteBuffer = ByteBuffer.allocate(1024 * 128);
                    int readLen = socketChannel.read(byteBuffer);

                    if (readLen == -1) {
                        // 输出读取到的一部分数据
                        ByteArrayOutputStream outputStream = (ByteArrayOutputStream) selectionKey.attachment();
                        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(outputStream.toByteArray());
                        byte[] readBuffer = new byte[100];
                        byteArrayInputStream.read(readBuffer);
                        log.info("read round {} finished, dataSize:{}, data:{}", readCount, readBuffer.length, new String(readBuffer));
                        socketChannel.close();
                    } else if (readLen == 0) {
                        log.info("read nothing");
                        continue;
                    } else {
                        byte[] data = byteBuffer.array();
                        // 将读到的数据往输出流中写
                        ByteArrayOutputStream outputStream = (ByteArrayOutputStream) selectionKey.attachment();
                        outputStream.write(data);
                        log.info("read round {} end, curReadData:{}, readAllData:{}", readCount, data.length, outputStream.size());
                    }
                }
            }
        }
    }
}

client端程序

@Slf4j
public class Client {
    public static void main(String[] args) throws IOException {
        SocketChannel socketChannel = SocketChannel.open();
        socketChannel.configureBlocking(false);
        socketChannel.connect(new InetSocketAddress("127.0.0.1", 9001));

        Selector selector = Selector.open();
        SelectionKey key = socketChannel.register(selector, SelectionKey.OP_CONNECT);

        // 创建要发送的数据
        StringBuilder sb = new StringBuilder();
        // 1MB的数据 TODO 可以适当增大或缩小发送数据量,控制发送次数
        for (int i = 0; i < 1024 * 1024 * 1; i++) {
            sb.append("a");
        }
        // 此处返回的ByteBuffer已经是「读模式」了,不用再调用flip方法
        ByteBuffer byteBuffer = Charset.defaultCharset().encode(sb.toString());
        // 将要写的数据绑定到selectionKey上
        key.attach(byteBuffer);

        int writeCount = 0;

        log.info("start write, position:{}, limit:{}, capacity:{}", byteBuffer.position(), byteBuffer.limit(), byteBuffer.capacity());
        while (true) {
            int select = selector.select();
            if (select <= 0) {
                continue;
            }

            Set<SelectionKey> selectedKeys = selector.selectedKeys();
            Iterator<SelectionKey> keyIterator = selectedKeys.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey selectionKey = keyIterator.next();
                keyIterator.remove();
                if (selectionKey.isConnectable()) {
                    // TODO 为啥必须调用finishConnect方法程序才正常?
                    log.info("connect success....{}", ((SocketChannel) selectionKey.channel()).finishConnect());
                    selectionKey.interestOps(SelectionKey.OP_WRITE);
                    continue;
                }

                if (selectionKey.isWritable()) {
                    writeCount++;
                    // 获取需要写的数据
                    ByteBuffer buffer = (ByteBuffer) selectionKey.attachment();
                    log.info("write round {} start, position:{}, limit:{}, capacity:{}", writeCount, buffer.position(), buffer.limit(), buffer.capacity());
                    // 如果有剩余的没写完继续写
                    if (buffer.hasRemaining()) {
                        SocketChannel sc = (SocketChannel) selectionKey.channel();
                        // 非阻塞写,当发送缓冲区满了就不可写了,此时会直接返回;若是阻塞IO,当发送缓冲区满了,写会阻塞直到所有数据都写到缓冲区
                        int write = sc.write(buffer);
                        log.info("write round {} end, writeData:{}, position:{}, limit:{}, capacity:{}", writeCount, write, buffer.position(), buffer.limit(), buffer.capacity());
                    } else {
                        log.info("write round {} finished", writeCount);
                        // TODO 一定要cancel吗?
//                        selectionKey.cancel();
                        socketChannel.close();
                    }
                }

            }
        }
    }
}

运行结果示例

server端运行日志:

11:50:07.189 [main] INFO com.peng.thirdjavanio.test.block.Server - start
11:50:10.702 [main] INFO com.peng.thirdjavanio.test.block.Server - accept success:/127.0.0.1:52978, selectorSame:true
11:50:10.737 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 1 start
11:50:10.739 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 1 end, curReadData:131072, readAllData:131072
11:50:10.739 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 2 start
11:50:10.739 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 2 end, curReadData:131072, readAllData:262144
11:50:10.740 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 3 start
11:50:10.740 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 3 end, curReadData:131072, readAllData:393216
11:50:10.740 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 4 start
11:50:10.740 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 4 end, curReadData:131072, readAllData:524288
11:50:10.740 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 5 start
11:50:10.741 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 5 end, curReadData:131072, readAllData:655360
11:50:10.741 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 6 start
11:50:10.741 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 6 end, curReadData:131072, readAllData:786432
11:50:10.741 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 7 start
11:50:10.741 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 7 end, curReadData:131072, readAllData:917504
11:50:10.741 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 8 start
11:50:10.741 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 8 end, curReadData:131072, readAllData:1048576
11:50:10.741 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 9 start
11:50:10.742 [main] INFO com.peng.thirdjavanio.test.block.Server - read round 9 finished, dataSize:100, data:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa

可以看到,服务端读了8次才读完数据,由于是非阻塞读,当接收缓冲区空了导致不可读时不会阻塞而是直接返回并返回本次读取的数据量

client端运行日志:

11:50:10.730 [main] INFO com.peng.thirdjavanio.test.block.Client - start write, position:0, limit:1048576, capacity:1153433
11:50:10.735 [main] INFO com.peng.thirdjavanio.test.block.Client - connect success....true
11:50:10.735 [main] INFO com.peng.thirdjavanio.test.block.Client - write round 1 start, position:0, limit:1048576, capacity:1153433
11:50:10.737 [main] INFO com.peng.thirdjavanio.test.block.Client - write round 1 end, writeData:261676, position:261676, limit:1048576, capacity:1153433
11:50:10.737 [main] INFO com.peng.thirdjavanio.test.block.Client - write round 2 start, position:261676, limit:1048576, capacity:1153433
11:50:10.737 [main] INFO com.peng.thirdjavanio.test.block.Client - write round 2 end, writeData:786900, position:1048576, limit:1048576, capacity:1153433
11:50:10.738 [main] INFO com.peng.thirdjavanio.test.block.Client - write round 3 start, position:1048576, limit:1048576, capacity:1153433
11:50:10.738 [main] INFO com.peng.thirdjavanio.test.block.Client - write round 3 finished

可以看到,客户端写了2次才写完数据,由于是非阻塞写,第1次写时发送缓冲区满了导致不可写时不会阻塞而是直接返回并返回本次写入的数据量

一些疑问

客户端程序为什么必须调用finishConnect方法程序才会正常写数据

根据上述client程序测试,发现如果在 selectionKey.isConnectable() 这个分支中不调用 finishConnect 方法程序执行就不符合预期:后续不会正常执行写操作。why???追一下原因。

首先,要正确理解OP_CONNECT连接就绪事件,看官方源码注释:


public abstract class SelectionKey {
    /**
     * Operation-set bit for socket-connect operations.
     *
     * <p> Suppose that a selection key's interest set contains
     * <tt>OP_CONNECT</tt> at the start of a <a
     * href="Selector.html#selop">selection operation</a>.  If the selector
     * detects that the corresponding socket channel is ready to complete its
     * connection sequence, or has an error pending, then it will add
     * <tt>OP_CONNECT</tt> to the key's ready set and add the key to its
     * selected-key&nbsp;set.  </p>
     */
    public static final int OP_CONNECT = 1 << 3;
}

根据注释,可以看到OP_CONNECT连接就绪事件代表连接完成了或发生了错误。所以,当发生OP_CONNECT事件时不代表连接成功了,需要调用 finishConnect 方法来检测连接是否成功


看源码

finishConnect方法干了啥?

如下:

关键操作:连接成功时,this.state = 2


去源码中搜索看看啥时候会对state赋值:

  1. 创建时

  1. connect方法

  1. finishConnect方法

  1. kill方法

那为啥必须调用finishConnect方法Selector才能正确查询到读写事件?

看看Selector的select方法源码执行情况:

关键方法:updateSelectedKeys更新被选择键

看看SocketChannelImpl实现的translateAndSetReadyOps方法:

// 方法含义:翻译和设置就绪事件集
public boolean translateAndSetReadyOps(int var1, SelectionKeyImpl var2) {
    return this.translateReadyOps(var1, 0, var2);
}

// 方法含义:翻译就绪事件集
// 入参解释:根据debug发现,var1是底层IO多路复用监听到的事件;var2等于0;var3是当前SelectionKey对象
public boolean translateReadyOps(int var1, int var2, SelectionKeyImpl var3) {
    // 感兴趣事件集
    int var4 = var3.nioInterestOps();
    // 之前就绪的事件集
    int var5 = var3.nioReadyOps();
    int var6 = var2;
    if ((var1 & Net.POLLNVAL) != 0) {
        return false;
    } else if ((var1 & (Net.POLLERR | Net.POLLHUP)) != 0) {
        var3.nioReadyOps(var4);
        this.readyToConnect = true;
        return (var4 & ~var5) != 0;
    } else { // 发生正常IO事件
        // 判断是否发生了可读事件,可以看到必须this.state == 2
        if ((var1 & Net.POLLIN) != 0 && (var4 & 1) != 0 && this.state == 2) {
            // 添加可读事件
            var6 = var2 | 1;
        }

        // 判断是否发生了连接就绪事件,(this.state == 0 || this.state == 1)
        if ((var1 & Net.POLLCONN) != 0 && (var4 & 8) != 0 && (this.state == 0 || this.state == 1)) {
            // 添加连接就绪事件
            var6 |= 8;
            this.readyToConnect = true;
        }
        
        // 判断是否发生了可写事件,可以看到必须this.state == 2
        if ((var1 & Net.POLLOUT) != 0 && (var4 & 4) != 0 && this.state == 2) {
            // 添加可写事件        
            var6 |= 4;
        }

        // 设置就绪事件
        var3.nioReadyOps(var6);
        return (var6 & ~var5) != 0;
    }
}

根据源码可以看到,this.state必须等于2,才能正确添加可读和可写事件到「就绪事件集」中,所以必须调用finishConnect方法。

总结

根据源码可以看到:

  1. finishConnect方法会在连接成功时 this.state 设为2
  2. 对于SocketChannelImpl来说,this.state 必须等于2,才能正确添加可读和可写事件到「就绪事件集」中

所以,在使用Java NIO的SocketChannel时,客户端程序必须调用finishConnect方法Selector才能正确监听到读写事件,程序才能正常读写