NIO详解

一、NIO介绍

  NIO (New lO)也有人称之为java non-blocking lO是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java lO API。

  NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区的、基于通道的IO操作。NIO将以更加高效的方式进行文件的读写操作

  NIO可以理解为非阻塞IO,传统的IO的read和write只能阻塞执行,线程在读写IO期间不能干其他事情,比如调用socket.read()时,如果服务器一直没有数据传输过来,线程就一直阻塞,而NIO中可以配置socket为非阻塞模式。

  NIO相关类都被放在java.nio包及子包下,并且对原java.io包中的很多类进行改写。

  NIO有三大核心部分: Buffer(缓冲区),Channel(通道),Selector(选择器)。

二、Buffer(缓冲区)

  • 缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存
  • 这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存
  • 缓冲区主要用于与NIO通道进行交互,数据是从通道读入缓冲区,从缓冲区写入通道中
  • 所有缓冲区都是Buffer抽象类的子类.

在这里插入图片描述

1、常见Buffer子类

  • ByteBuffer:用于存储字节数据(最常用)
  • ShortBuffer:用于存储Short类型数据
  • IntBuffer:用于存储Int类型数据
  • LongBuffer:用于存储Long类型数据
  • FloatBuffer:用于存储Float类型数据
  • DoubleBuffer:用于存储Double类型数据
  • CharBuffer:用于存储字符数据

ByteBuffer最常用,ByteBuffer三个子类的类图如下

在这里插入图片描述

1.1、HeapByteBuffer

  • 存储内存是在JVM堆中分配
  • 在堆中分配一个数组用来存放 Buffer 中的数据
public abstract class ByteBuffer
    extends Buffer
    implements Comparable<ByteBuffer>
{

	//在堆中使用一个数组存放Buffer数据
    final byte[] hb;
    ...
}
  • 通过allocate()方法进行分配,在jvm堆上申请堆上内存
  • 如果要做IO操作,会先从本进程的堆上内存复制到系统内存,再利用本地IO处理
  • 读写效率较低,受到 GC 的影响
ByteBuffer heapByteBuffer = ByteBuffer.allocate(1024);

1.2、DirectByteBuffer

  • DirectBuffer 背后的存储内存是在堆外内存(操作系统内存)中分配,jvm内存只保留堆外内存地址
public abstract class Buffer {
    //堆外内存地址
    long address;
    ...
}
  • 通过allocateDirect()方法进行分配,直接从系统内存中申请
  • 如果要作IO操作,直接从系统内存中利用本地IO处理
  • 使用直接内存会具有更高的效率,但是它比申请普通的堆内存需要耗费更高的性能
  • 读写效率高(少一次拷贝),不会受 GC 影响,分配的效率低
ByteBuffer directByteBuffer = ByteBuffer.allocateDirect(1024);

2、Buffer结构

ByteBuffer 有以下重要属性

  • 容量 (capacity) :作为一个内存块,Buffer具有一定的固定大小, 也称为"容量"
    • 缓冲区容量不能为负,并且创建后不能更改
  • 限制 (limit):表示缓冲区中可以操作数据的大小 (limit 后数据不能进行读写)
    • 缓冲区的限制不能为负,并且不能大于其容量
    • 写入模式,限制等于 buffer的容量
    • 读取模式下,limit等于写入的数据量
  • 位置 (position):下一个要读取或写入的数据的索引
    • 缓冲区的位置不能为负,并且不能大于其限制

ByteBuffer写入和读取原理

@Test
public void simpleTest() {
    // 1. 分配一个指定大小的缓冲区
    ByteBuffer buf = ByteBuffer.allocate(10);
    // 2. 利用put()存入数据到缓冲区中
    buf.put("data".getBytes());
    // 3. 切换读取数据模式
    buf.flip();
    // 判断缓冲区中是否还有元素
    while (buf.hasRemaining()) {
        // 4. 利用 get()读取单个字节
        byte b = buf.get();
        System.out.println("实际字节 " + (char) b);
    }
    // 清空缓冲区
    buf.clear();
}
输出结果:
实际字节 d
实际字节 a
实际字节 t
实际字节 a
  • 创建容量为10的ByteBuffer

在这里插入图片描述

  • 写模式下,position 是写入位置,limit 等于容量,下图表示写入了 4 个字节后的状态

在这里插入图片描述

  • flip 动作发生后,position 切换为读取位置,limit 切换为读取限制

在这里插入图片描述

  • 读取 4 个字节后,状态如下

在这里插入图片描述

  • clear 动作发生后,状态如下,然后切换至写模式

在这里插入图片描述

特别说明:compact方法,是把未读完的部分向前压缩,然后切换至写模式

在这里插入图片描述

3、常见方法

位置相关

  • int capacity() :返回 Buffer 的 capacity 大小
  • int limit() :返回 Buffer 的界限(limit) 的位置
  • int position() :返回缓冲区的当前位置 position
  • int remaining() :返回 position 和 limit 之间的元素个数
@Test
public void test1() {
    // 分配一个指定大小的缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);
    System.out.println(buf.position());// 0: 表示当前的位置为0
    System.out.println(buf.limit());// 1024: 表示界限为1024,前1024个位置是允许我们读写的
    System.out.println(buf.capacity());// 1024:表示容量大小为1024
    System.out.println(buf.remaining());// 1024:表示position和limit之间元素个数
}

读写相关

  • put(byte b):将给定单个字节写入缓冲区的当前位置
  • put(byte[] src):将 src 中的字节写入缓冲区的当前位置
  • put(int index, byte b):将指定字节写入缓冲区的索引 位置(不会移动 position)
  • boolean hasRemaining(): 判断缓冲区中是否还有元素
  • get() :读取单个字节
  • get(byte[] dst):批量读取多个字节到 dst 中
  • get(int index):读取指定索引位置的字节(不会移动 position)
@Test
public void test2() {
    ByteBuffer buf = ByteBuffer.allocate(10);
    // 默认写模式,写入数据
    buf.put("abcde".getBytes());
    System.out.println(buf.position());// 5: 当前位置5,表示下一个写入的位置是5
    System.out.println(buf.limit());// 10: 表示界限为10,前10个位置是允许写入的
    // 切换为读模式
    buf.flip();
    System.out.println(buf.position());// 0: 从0位置开始读取数据
    System.out.println(buf.limit());// 5: 表示界限为5,前5个位置是允许读取的
    // 读取两个字节
    byte[] dst = new byte[2];
    buf.get(dst);
    System.out.println(new String(dst, 0, 2)); // 输出:ab
    System.out.println(buf.position());// 2: 从2位置开始读取数据,因为0,1已经读取
    System.out.println(buf.limit());// 5: 表示界限为5,前5个位置是允许读取的
    // 根据索引读取,position不会移动
    byte b = buf.get(3);
    System.out.println((char) b); // 输出:d
    System.out.println(buf.position());// 2: 依然是2,没有移动
}

切换模式相关

  • Buffer flip() :将缓冲区的界限设置为当前位置, 并将当前位置重置为0(切换为读模式)
  • Buffer clear() :清空缓冲区(切换为写模式)
  • Buffer compact() :向前压缩未读取部分(切换为写模式)
@Test
public void test3() {
    ByteBuffer buf = ByteBuffer.allocate(10);
    // 默认写模式,写入数据
    buf.put("hello".getBytes());
    // 切换为读模式
    buf.flip();
    // 读取两个字节
    byte[] dst = new byte[2];
    buf.get(dst);
    System.out.println(buf.position());// 2: 当前位置2,前两个位置已经读取,读取下一个位置是2
    System.out.println(buf.limit());// 5: 表示界限为5,前5个位置是允许读取的
    // 向前压缩未读取,并切换为写模式
    buf.compact();
    System.out.println(buf.position());// 3: 当前位置3,因为之前有两个位置没有被读取,放到了最前面,写入的下一个位置是3
    System.out.println(buf.limit());// 10: 表示界限为10,前10个位置是允许写入的
}

修改Buffer相关

  • Buffer limit(int n):设置缓冲区界限为 n,并返回修改后的 Buffer 对象
  • Buffer position(int n) :设置缓冲区的当前位置为 n, 并返回修改后的 Buffer 对象

标记相关

  • Buffer mark(): 对缓冲区设置标记
  • Buffer reset() :将位置 position 转到以前设置的mark 所在的位置
  • Buffer rewind() :将位置设为为0, 取消设置的mark
@Test
public void test4() {
    ByteBuffer buf = ByteBuffer.allocate(10);
    // 默认写模式,写入数据
    buf.put("hello".getBytes());
    // 切换为读模式
    buf.flip();
    // 读取两个字节
    System.out.println((char) buf.get());
    buf.mark();
    System.out.println((char) buf.get());
    System.out.println((char) buf.get());
    buf.reset();
    System.out.println((char) buf.get());
    System.out.println((char) buf.get());
    System.out.println((char) buf.get());
    System.out.println((char) buf.get());
    // hello读完再读,抛异常java.nio.BufferUnderflowException
    // System.out.println((char) buf.get());
}
输出:
h
e
l
e
l
l
o

总结Buffer读写数据四个步骤

  1. 写入数据到Buffer
  2. 调用flip()方法,转换为读取模式
  3. 从Buffer中读取数据
  4. 调用buffer.clear()方法或者buffer.compact()方法清除缓冲区并转换为写入模式

4、字符串与ByteBuffer互转

public class TestByteBufferString {
    public static void main(String[] args) {
        // 字符串转为ByteBuffer
        // 方式一:put
        ByteBuffer buffer1 = ByteBuffer.allocate(16);
        buffer1.put("hello".getBytes());

        // 方式二:Charset
        ByteBuffer buffer2 = StandardCharsets.UTF_8.encode("hello");

        // 方式三:wrap
        ByteBuffer buffer3 = ByteBuffer.wrap("hello".getBytes());

        // ByteBuffer转为字符串
        // 方式一:Charset
        String str1 = StandardCharsets.UTF_8.decode(buffer1).toString();
        
        // 方式二:String
        String str2 = new String(buffer2.array(), 0, buffer2.limit());
    }
}

三、Channel(通道)

  传统流是单向的,只能读或者写,而NIO中的Channel(通道)是双向的,可以读操作,也可以写操作。

1、常见Channel实现类

  • FileChannel:用于读取、写入、映射和操作文件的通道
  • DatagramChannel:通过UDP读写网络中的数据通道
  • ServerSocketChannel和SocketChannel:通过TCP读写网络中的数据的通道
    • 类似于Socke和ServerSocket(阻塞IO),不同的是前者可以设置为非阻塞模式

2、FileChannel(文件通道)

  • FileChannel只能工作在阻塞模式下

2.1、常用方法

获取FileChannel

  不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者 RandomAccessFile 来获取 FileChannel,它们都有getChannel方法。

  • 通过FileInputStream获取的 channel 只能读
  • 通过FileOutputStream获取的 channel 只能写
  • 通过RandomAccessFile是否能读写根据构造RandomAccessFile时的读写模式决定
// 只能读
FileChannel channel1 = new FileInputStream("hello.txt").getChannel();
// 只能写
FileChannel channel2 = new FileOutputStream("hello.txt").getChannel();

// 以只读方式打开指定文件
FileChannel channel3 = new RandomAccessFile("hello.txt", "r").getChannel();
// 以读、写方式打开指定文件。如果该文件尚不存在,则尝试创建该文件
FileChannel channel4 = new RandomAccessFile("hello.txt", "rw").getChannel();

读取数据

  • int read(ByteBuffer dst):从Channel到中读取数据到ByteBuffer,返回值表示读到的字节数量-1表示到达了文件的末尾
  • long read(ByteBuffer[] dsts): 将Channel中的数据“分散”到ByteBuffer数组中
@Test
public void testRead() throws IOException {
    // 获取只读文件通道
    FileChannel channel = new RandomAccessFile("hello.txt", "r").getChannel();

    // 创建字节缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);

    // 循环读取通道中的数据,并写入到 buf 中
    while (channel.read(buf) != -1) {
        // 缓存区切换到读模式
        buf.flip();
        // 读取 buf 中的数据
        while (buf.position() < buf.limit()) {
            // 将buf中的数据追加到文件中
            System.out.println((char) buf.get());
        }
        // 清空已经读取完成的 buffer,以便后续使用
        buf.clear();
    }

    // 关闭通道
    channel.close();
}

写入数据

  • int write(ByteBuffer src):将ByteBuffer中的数据写入到Channel
  • long write(ByteBuffer[] srcs):将ByteBuffer数组中的数据“聚集”到 Channel
@Test
public void testRead() throws IOException {
    // 获取写文件通道
    FileChannel channel = new FileOutputStream("hello.txt").getChannel();

    // 将ByteBuffer数据写到通道
    channel.write(ByteBuffer.wrap("abc".getBytes()));

    // 强制将数据刷出到物理磁盘
    channel.force(false);

    // 关闭通道
    channel.close();
}

其他

  • long position() :返回此通道的文件位置
  • long size() :返回此通道的文件的当前大小
  • void force(boolean metaData) :强制将所有对此通道的文件更新写入到存储设备中
  • FileChannel position(long p) :设置此通道的文件位置
  • FileChannel truncate(long s) :将此通道的文件截取为给定大小
@Test
public void testOther() throws IOException {
    // 获取写文件通道
    FileChannel channel = new FileOutputStream("hello.txt").getChannel();

    System.out.println(channel.position());// 0:当前位置为0,表示下次写入的位置为0
    System.out.println(channel.size());// 0:文件大小为0

    // 写入3个字符到 hello.txt 文件中
    channel.write(ByteBuffer.wrap(("abc").getBytes()));

    System.out.println(channel.position());// 3:当前位置为3,表示下次写入的位置为3
    System.out.println(channel.size());// 3:文件大小为3,因为写入3个字符

    channel.position(5);// 设置当前位置为5,表示下次写入的位置为5

    // 再写入123,此时会跳过索引3和4,写入索引5
    channel.write(ByteBuffer.wrap(("123").getBytes()));

    // 将数据刷出到物理磁盘
    channel.force(false);

    // 关闭通道
    channel.close();
}

输出结果:索引3和4的位置为空,这是应该特殊字符吧

在这里插入图片描述

2.2、复制(transferTo/transferFrom)

  • 两个方式都能实现复制的功能
/**
 * 方法一(目标文件调用者)
 */
@Test
public void transferFrom() throws Exception {
    // 1、字节输入管道
    FileInputStream is = new FileInputStream("hello.txt"); // 源文件输入流
    FileChannel fromChannel = is.getChannel();
    // 2、字节输出流管道
    FileOutputStream fos = new FileOutputStream("hello的副本.txt"); // 目标文件输出流
    FileChannel toChannel = fos.getChannel();
    // 3、复制
    toChannel.transferFrom(fromChannel, fromChannel.position(), fromChannel.size());
    fromChannel.close();
    toChannel.close();
}

/**
 * 方法二(资源文件调用者)
 */
@Test
public void transferTo() throws Exception {
    // 1、字节输入管道
    FileInputStream is = new FileInputStream("hello.txt"); // 源文件输入流
    FileChannel fromChannel = is.getChannel();
    // 2、字节输出流管道
    FileOutputStream fos = new FileOutputStream("hello的副本.txt"); // 目标文件输出流
    FileChannel toChannel = fos.getChannel();
    // 3、复制
    fromChannel.transferTo(fromChannel.position(), fromChannel.size(), toChannel);
    fromChannel.close();
    toChannel.close();
}
  • 超过2g大小的文件传输(因为超过2g,多出的部分会丢失)
  • 循环复制,每次30MB(FileUtils.copyFile(final File srcFile, final File destFile)方法的内部实现)
@Test
public void transferFromBig() throws IOException {
    // 使用try-with-resources语句确保流在使用完毕后被正确关闭
    try (FileInputStream fis = new FileInputStream("hello.txt"); // 源文件输入流
         FileChannel input = fis.getChannel(); // 获取源文件的文件通道
         FileOutputStream fos = new FileOutputStream("hello的副本.txt"); // 目标文件输出流
         FileChannel output = fos.getChannel()) { // 获取目标文件的文件通道
        final long size = input.size(); // 获取源文件的大小
        long pos = 0;
        long count;
        // 循环读取源文件内容,直到全部复制完毕
        while (pos < size) {
            // 计算剩余待复制的字节数
            final long remain = size - pos;
            // 根据剩余字节数决定本次要复制的字节数,最多30MB
            count = remain > 1024 * 1024 * 30 ? 1024 * 1024 * 30 : remain;
            // 从源文件通道复制数据到目标文件通道
            final long bytesCopied = output.transferFrom(input, pos, count);
            if (bytesCopied == 0) {
                // 如果没有复制任何数据,跳出循环
                break;
            }
            // 更新已复制的字节位置
            pos += bytesCopied;
        }
    }
}

3、ServerSocketChannel和SocketChannel(TCP网络通道)

3.1、阻塞模式

  • 阻塞模式下,相关方法都会导致线程暂停
    • ServerSocketChannel.accept 会在没有连接建立时让线程暂停
    • SocketChannel.read 会在没有数据可读时让线程暂停
    • 阻塞的表现其实就是线程暂停,暂停期间不会占用cpu,线程相当于闲置什么也不能做
  • 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
  • 但多线程下,有新的问题,体现在以下方面
    • 32位jvm一个线程320k,64位jvm一个线程1024k,如果连接数过多,必然导致OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低
    • 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接

服务端

  • 默认情况与Socke和ServerSocket一样,是阻塞IO,accept和read为阻塞方法
  • 当没有客户端连接时,线程会阻塞在accept()方法,等待客户端的连接
  • 当客户端连接,当没有发送数据时,线程会阻塞在read()方法,等待客户端的发送
@Test
public void server() throws IOException {
    ByteBuffer buffer = ByteBuffer.allocate(16);
    // 1. 创建一个ServerSocketChannel通道
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 2. 绑定监听端口
    serverSocketChannel.bind(new InetSocketAddress(8080));
    // 3. 连接集合
    List<SocketChannel> channels = new ArrayList<>();
    while (true) {
        // 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
        log.debug("connecting...");
        SocketChannel sc = serverSocketChannel.accept(); // 阻塞方法,线程停止运行
        log.debug("connected... {}", sc);
        channels.add(sc);
        // 遍历连接集合
        for (SocketChannel channel : channels) {
            // 5. 接收客户端发送的数据
            log.debug("before read... {}", channel);
            channel.read(buffer); // 阻塞方法,线程停止运行,等待客户端发消息读取
            buffer.flip(); // 转为读模式
            // 打印出响应信息
            while (buffer.hasRemaining()) {
                System.out.print((char) buffer.get());
            }
            buffer.clear(); // 清空缓冲区,转为写模式
            log.debug("after read...{}", channel);
        }
    }
}

客户端

@Test
public void client() throws IOException {
    // 创建一个SocketChannel通道,并连接到本地的8080端口
    SocketChannel socketChannel = SocketChannel.open();
    socketChannel.connect(new InetSocketAddress("localhost", 8080));
    socketChannel.write(ByteBuffer.wrap("a".getBytes()));
    System.in.read();
}

3.2、非阻塞模式

  • 非阻塞模式下,相关方法都会不会让线程暂停
    • 在ServerSocketChannel.accept没有连接建立时,会返回null,继续运行
    • 在SocketChannel.read没有数据可读时,会返回0,但线程不会阻塞
  • 但非阻塞模式下,即使没有连接建立和可读数据,线程仍然在不断运行,白白浪费了cpu

服务端

  • 设置ServerSocketChannel和SocketChannel.configureBlocking(false)即为非阻塞模式
  • 这种情况程序不会阻塞,程序一直运行,也就代表着cpu一刻不停,不论是否有新连接和数据读取
  • 下文通过Selector解决浪费cpu的问题
@Test
public void server() throws IOException {
    ByteBuffer buffer = ByteBuffer.allocate(16);
    // 1. 创建了服务器
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.configureBlocking(false); // 非阻塞模式
    // 2. 绑定监听端口
    serverSocketChannel.bind(new InetSocketAddress(8080));
    // 3. 连接集合
    List<SocketChannel> channels = new ArrayList<>();
    while (true) {
        // 4. accept 建立与客户端连接, SocketChannel 用来与客户端之间通信
        // 非阻塞,线程还会继续运行,如果没有连接建立,但sc是null
        SocketChannel socketChannel = serverSocketChannel.accept();
        if (socketChannel != null) {
            log.debug("connected... {}", socketChannel);
            socketChannel.configureBlocking(false); // 非阻塞模式
            channels.add(socketChannel);
        }
        for (SocketChannel channel : channels) {
            // 5. 接收客户端发送的数据
            // 非阻塞,线程仍然会继续运行,如果没有读到数据,read 返回 0
            int read = channel.read(buffer);
            if (read > 0) {
                buffer.flip();
                // 打印出响应信息
                while (buffer.hasRemaining()) {
                    System.out.print((char) buffer.get());
                }
                buffer.clear();
                log.debug("after read...{}", channel);
            }
        }
    }
}

四、Selector(选择器)

  • Java的NIO用非阻塞的IO方式,可以用一个线程,处理多个的客户端连接,就会使用到Selector(选择器)
  • Selector能够检测多个注册的通道上是否有事件发生,才会处理,如果没有事件发生,则处于阻塞状态,防止cpu浪费

在这里插入图片描述

1、Selector的应用

//1. 获取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//2. 切换非阻塞模式
serverSocketChannel.configureBlocking(false);
//3. 绑定连接
serverSocketChannel.bind(new InetSocketAddress(9898));
//4. 获取选择器
Selector selector = Selector.open();
//5. 将通道注册到选择器上, 并且指定“监听接收事件”
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

监听的事件类型(SelectionKey四个int常量)

  • :SelectionKey.OP_READ (1)
  • :SelectionKey.OP_WRITE (4)
  • 连接:SelectionKey.OP_CONNECT (8)
  • 接收:SelectionKey.OP_ACCEPT (16)

若注册时不止监听一个事件,则可以使用“位或”操作符连接

// 监听读和写事件
serverSocketChannel.register(selector, SelectionKey.OP_READ | SelectionKey.OP_WRITE);

2、多路复用

  • 单线程可以配合Selector完成对多个Channel可读写事件的监控,这称之为多路复用
  • 多路复用仅针对网络 IO、普通文件 IO 没法利用多路复用
  • 如果不用Selector的非阻塞模式,线程大部分时间都在做无用功,而Selector能够保证有事件发生cpu才运行

服务端

  • 一个服务端通道ServerSocketChannel和多个SocketChannel客户端通道注册到selector上
  • 当没有事件发生时,线程会阻塞再selector.select()方法,有事件发生,返回事件数量,进入while循环
  • selectionKey表示某个注册的客户端的接入或者读写事件
  • read()方法的三种返回值
    • 返回值大于0:读到了数据,直接对字节进行编解码
    • 返回值等于0:没有读到字节,属于正常场景,忽略
    • 返回值为-1:链路已经关闭,需要关闭SocketChannel释放资源
@Test
public void server() throws IOException {
    // 1.获取管道
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    // 2.设置非阻塞模式
    serverSocketChannel.configureBlocking(false);
    // 3.绑定端口
    serverSocketChannel.bind(new InetSocketAddress(8888));
    // 4.获取选择器
    Selector selector = Selector.open();
    // 5.将通道注册到选择器上,并且开始指定监听的接收事件
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    // 6.轮询已经就绪的事件
    // select方法, 没有事件发生,线程阻塞,有事件,线程才会恢复运行,返回事件数量
    // 事件发生后,要么处理,要么取消(cancel),不能什么都不做,否则即使item.remove(),selector.select()还是会获取到没处理的事件
    while (selector.select() > 0) {
        System.out.println("开启事件处理");
        // 7.获取选择器中所有注册的通道中已准备好的事件
        Iterator<SelectionKey> it = selector.selectedKeys().iterator();
        // 8.开始遍历事件
        while (it.hasNext()) {
            SelectionKey selectionKey = it.next();
            System.out.println("客户端通道事件对象key:" + selectionKey);
            // 9.判断这个事件具体是啥
            if (selectionKey.isAcceptable()) { // 客户端接入事件
                // 10.获取当前接入事件的客户端通道
                SocketChannel socketChannel = serverSocketChannel.accept();
                // 11.切换成非阻塞模式
                socketChannel.configureBlocking(false);
                // 12.将本客户端注册到选择器
                socketChannel.register(selector, SelectionKey.OP_READ);
            } else if (selectionKey.isReadable()) { // 读事件
                // 13.获取当前选择器上的读通道
                SocketChannel socketChannel = (SocketChannel) selectionKey.channel();
                // 14.读取
                ByteBuffer buffer = ByteBuffer.allocate(1024);
                /*
				 * read()方法的三种返回值
				 * 返回值大于0:读到了直接,对字节进行编解码
				 * 返回值等于0:没有读到字节,属于正常场景,忽略
				 * 返回值为-1:链路已经关闭,需要关闭SocketChannel释放资源
				 */
                int len = socketChannel.read(buffer);
                if (len > 0) {
                    buffer.flip(); // 转为读模式
                    System.out.println(new String(buffer.array(), 0, len));
                    buffer.clear(); // 清空缓冲区,转为写模式
                } else if(len < 0) {
                    // 如果读不到数据,取消事件
                    // 否则客户端断开时,len=-1,数据没有读取到也就是没有处理,会一直循环调用此读事件内容
                    selectionKey.cancel();
                    socketChannel.close();
                }
            }
            // 15.处理完毕后,移除当前事件
            it.remove();
        }
    }
}

客户端

public static void main(String[] args) throws Exception {
    // 1、获取通道
    SocketChannel sChannel = SocketChannel.open(new InetSocketAddress("localhost", 8888));
    // 2、切换成非阻塞模式
    sChannel.configureBlocking(false);
    // 3、分配指定缓冲区大小
    ByteBuffer buf = ByteBuffer.allocate(1024);
    // 4、发送数据给服务端
    Scanner sc = new Scanner(System.in);
    while (true) {
        System.out.println("请说:");
        String msg = sc.nextLine();
        buf.put((msg).getBytes());
        buf.flip();
        sChannel.write(buf);
        buf.clear();
    }
}

五、零拷贝

1、传统IO

  • 传统的 IO 将一个文件通过 socket 写出
File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");

byte[] buf = new byte[(int)f.length()];
file.read(buf);

Socket socket = ...;
socket.getOutputStream().write(buf);

内部工作流程是这样的:

在这里插入图片描述

  1. java 本身并不具备 IO 读写能力,因此 read 方法调用后,要从 java 程序的用户态切换至内核态,去调用操作系统的读能力,将数据读入内核缓冲区。这期间用户线程阻塞,操作系统使用 DMA(可以理解为硬件单元)来实现文件读,其间也不会使用 cpu
  2. 内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区(即 byte[] buf),这期间 cpu 会参与拷贝,无法利用 DMA
  3. 调用write方法,这时将数据从用户缓冲区(byte[] buf)写入socket 缓冲区,cpu 会参与拷贝
  4. 接下来要向网卡写数据,这项能力 java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将socket 缓冲区的数据写入网卡,不会使用 cpu

java 的 IO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的

  • 用户态与内核态的切换发生了 3 次,这个操作比较重量级
  • 数据拷贝了共 4

2、NIO优化

2.1、DirectByteBuffer

在这里插入图片描述

  • java可以使用DirectByteBuffer将堆外内存(系统内存)映射到jvm内存中来直接访问使用
  • java中的DirectByteBuffer对象仅维护了此内存的虚引用

用户态与内核态的切换次数与数据拷贝次数

  • 用户态与内核态的切换发生了 3
  • 数据拷贝了共 3

2.2、linux2.1提供的sendFile方法

  • 进一步优化(底层采用了linux 2.1后提供的sendFile方法),java 中对应着两个channel调用 transferTo/transferFrom方法拷贝数据

在这里插入图片描述

  1. java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
  2. 数据从内核缓冲区传输到 socket 缓冲区,cpu 会参与拷贝
  3. 最后使用 DMA 将socket 缓冲区的数据写入网卡,不会使用 cpu

用户态与内核态的切换次数与数据拷贝次数

  • 用户态与内核态的切换发生了 1
  • 数据拷贝了共 3

2.3、linux 2.4

在这里插入图片描述

  1. java 调用 transferTo 方法后,要从 java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 cpu
  2. 只会将一些 offset 和 length 信息拷入socket 缓冲区,几乎无消耗
  3. 使用 DMA 将内核缓冲区的数据写入网卡,不会使用 cpu

用户态与内核态的切换次数与数据拷贝次数

  • 用户态与内核态的切换发生了 1
  • 数据拷贝了共 2

  整个过程仅只发生了一次用户态与内核态的切换,数据拷贝了 2 次。所谓的【零拷贝】,并不是真正无拷贝,而是在不会拷贝重复数据到 jvm 内存中。零拷贝适合小文件传输。

  • 104
    点赞
  • 50
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 62
    评论
评论 62
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

冬天vs不冷

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值