首页 最新 热门 推荐

  • 首页
  • 最新
  • 热门
  • 推荐

NIO详解

  • 25-03-03 14:23
  • 2248
  • 6091
blog.csdn.net

文章目录

  • 一、NIO介绍
  • 二、Buffer(缓冲区)
    • 1、常见Buffer子类
      • 1.1、HeapByteBuffer
      • 1.2、DirectByteBuffer
    • 2、Buffer结构
    • 3、常见方法
    • 4、字符串与ByteBuffer互转
  • 三、Channel(通道)
    • 1、常见Channel实现类
    • 2、FileChannel(文件通道)
      • 2.1、常用方法
      • 2.2、复制(transferTo/transferFrom)
    • 3、ServerSocketChannel和SocketChannel(TCP网络通道)
      • 3.1、阻塞模式
      • 3.2、非阻塞模式
  • 四、Selector(选择器)
    • 1、Selector的应用
    • 2、多路复用
  • 五、零拷贝
    • 1、传统IO
    • 2、NIO优化
      • 2.1、DirectByteBuffer
      • 2.2、linux2.1提供的sendFile方法
      • 2.3、linux 2.4

一、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;
    ...
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 通过allocate()方法进行分配,在jvm堆上申请堆上内存
  • 如果要做IO操作,会先从本进程的堆上内存复制到系统内存,再利用本地IO处理
  • 读写效率较低,受到 GC 的影响
ByteBuffer heapByteBuffer = ByteBuffer.allocate(1024);
  • 1

1.2、DirectByteBuffer

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

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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 创建容量为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之间元素个数
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

读写相关

  • 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,没有移动
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22

切换模式相关

  • 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个位置是允许写入的
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17

修改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
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28

总结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());
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21

三、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();
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

读取数据

  • 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();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24

写入数据

  • 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();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14

其他

  • 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();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25

输出结果:索引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();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 超过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;
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27

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);
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30

客户端

@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();
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

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);
            }
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35

四、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);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10

监听的事件类型(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);
  • 1
  • 2

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();
        }
    }
}
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
  • 52
  • 53
  • 54
  • 55
  • 56
  • 57
  • 58
  • 59

客户端

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
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

五、零拷贝

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
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8

内部工作流程是这样的:

在这里插入图片描述

  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 内存中。零拷贝适合小文件传输。

文章知识点与官方知识档案匹配,可进一步学习相关知识
Python入门技能树首页概览426045 人正在系统学习中
注:本文转载自blog.csdn.net的冬天vs不冷的文章"https://blog.csdn.net/qq_35512802/article/details/137422901"。版权归原作者所有,此博客不拥有其著作权,亦不承担相应法律责任。如有侵权,请联系我们删除。
复制链接
复制链接
相关推荐
发表评论
登录后才能发表评论和回复 注册

/ 登录

评论记录:

未查询到任何数据!
回复评论:

分类栏目

后端 (14832) 前端 (14280) 移动开发 (3760) 编程语言 (3851) Java (3904) Python (3298) 人工智能 (10119) AIGC (2810) 大数据 (3499) 数据库 (3945) 数据结构与算法 (3757) 音视频 (2669) 云原生 (3145) 云平台 (2965) 前沿技术 (2993) 开源 (2160) 小程序 (2860) 运维 (2533) 服务器 (2698) 操作系统 (2325) 硬件开发 (2492) 嵌入式 (2955) 微软技术 (2769) 软件工程 (2056) 测试 (2865) 网络空间安全 (2948) 网络与通信 (2797) 用户体验设计 (2592) 学习和成长 (2593) 搜索 (2744) 开发工具 (7108) 游戏 (2829) HarmonyOS (2935) 区块链 (2782) 数学 (3112) 3C硬件 (2759) 资讯 (2909) Android (4709) iOS (1850) 代码人生 (3043) 阅读 (2841)

热门文章

101
推荐
关于我们 隐私政策 免责声明 联系我们
Copyright © 2020-2025 蚁人论坛 (iYenn.com) All Rights Reserved.
Scroll to Top