NIO详解

时间:2024-04-20 07:05:18

文章目录

  • 一、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;
    ...
}
  • 通过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"); //