一文看懂 Java NIO 读写文件

 2022-07-25

Java NIO(New IO)是一个可以替代标准Java IO API的IO API(从Java 1.4开始),Java NIO提供了与标准IO不同的IO工作方式。很多小伙伴可能和我一样,对于习惯了用IO来操作文件之后,对这个日渐流行的新东西会有不少的疑惑,那么阅读本文吧,和我一起打开NIO的大门,学习NIO操作文件。

一、NIO与IO的区别

下面有一个概要的区别图,图下面会有区别的描述。

IO NIO
面向流 面向缓冲
阻塞IO 非阻塞IO
无 选择器

Java IO中最为核心的一个概念是流(Steam),面向流的编程。流是信息的载体。IO中的一个流要么是输入流,要么是输出流,不可能同时是输入流和输出流。Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。

NIO是面向缓冲,数据读取到一个它稍后处理的缓冲区,需要时可在缓冲区中前后移动。这就增加了处理过程中的灵活性。Java NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。

二、NIO的核心组件

202207252350334841.png

NIO的核心包含三个重要组件:

1、Channel通道

Channel可以理解为IO的Stream流,每次读取数据,都是从通道中读取,写数据也是写入到通道,直接对接Buffer缓冲区。常见的通道有FileChannel,是文件的通道,用来读取文件,本文着重将这个通道。还有DataChannel,通过UDP读写网络数据,SocketChannel通过TCP读写网络数据,ServerSocketChannel监听新进来的TCP连接,对每一个新进来的TCP连接,都会建立一个SocketChannel。

通道可以实现双向读写,比如说用RandomAccessFile类获取文件读写,调用RandomAccessFile.getChannel()方法,获取的就是读写双向的通道。代码如下两行。

    RandomAccessFile randomAccessFile = new RandomAccessFile(path.toFile(),"rw");
    FileChannel fileChannel = randomAccessFile.getChannel();

下面这两种情况下获取的通道,只能单向的操作,请看代码。

    FileOutputStream fos = new FileOutputStream(path.toFile());
    FileChannel fileChannel1 = fos.getChannel();
    FileInputStream fis = new FileInputStream(path.toFile());
    FileChannel fileChannel2 = fis.getChannel();

这里分别通过字节输出流和字节输入流获取了通道,这两个通道都分别有write()和read()方法,但字节输出流创建的通道调用读取的方法时候就会报错,同理,输入流调用写的方法也会报错,什么原因呢?

原来是打开文件的权限不同导致的。从 FileInputStream 对象的getChannel( )方法获取的 FileChannel 对象是只读的,虽然FileChannel 实现了 ByteChannel 接口,看起来是双向的。但是在这样的通道上调用 write( )方法将抛出未经检查的NonWritableChannelException 异常,因为 FileInputStream 对象总是以 read-only 的权限打开文件。

2、Buffer缓冲区

缓冲区:缓冲区实质上是一个数组。最常用的缓冲区类型是ByteBuffer,对应Java的基本类型都有一种缓冲区区:

缓冲区类型:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

类似IO中的BufferedInputStream等,NIO操作文件的核心方式就是Channel+Buffer,下面直接上一段代码,看一下缓冲区如何和通道结合起来使用。

        // 1. 获取数据源 和 目标传输地的输入输出流(此处以数据源 = 文件为例)
        FileInputStream fin = new FileInputStream(infile);
        FileOutputStream fout = new FileOutputStream(outfile);
    
        // 2. 获取数据源的输入输出通道
        FileChannel fcin = fin.getChannel();
        FileChannel fcout = fout.getChannel();
    
         // 3. 创建 缓冲区 对象:Buffer(共有2种方法)
         // 方法1:使用allocate()静态方法
         ByteBuffer buff = ByteBuffer.allocate(256);
         // 上述方法创建1个容量为256字节的ByteBuffer
         // 注:若发现创建的缓冲区容量太小,则重新创建一个大小合适的缓冲区
    
         // 方法2:通过包装一个已有的数组来创建
         // 注:通过包装的方法创建的缓冲区保留了被包装数组内保存的数据
         ByteBuffer buff = ByteBuffer.wrap(byteArray);
    
         // 额外:若需将1个字符串存入ByteBuffer,则如下
         String sendString="你好,服务器. ";
         ByteBuffer sendBuff = ByteBuffer.wrap(sendString.getBytes("UTF-16"));
    
        // 4. 从通道读取数据到缓冲区
        // 注:若 以读取到该通道数据的末尾,则返回-1
        fcin.read(buff);
    
        // 5. 传出数据准备:将缓存区的读模式 转换->> 写模式
        buff.flip();
    
        // 6. 从缓冲区中读取数据写入到通道
        fcout.write(buff);
    
        // 7. 重置缓冲区
        // 目的:重用现在的缓冲区,即 不必为了每次读写都创建新的缓冲区,在再次读取之前要重置缓冲区
        // 注:不会改变缓冲区的数据,只是重置缓冲区的主要索引值
        buff.clear();

上面方法中,在声明缓冲区的时候,罗列了不同的方式,后面读写操作都是对接的缓冲区。那么中间的flip()和clear()到底是干什么的呢?下面讲解一下Buffer缓冲区的内部原理方便大家理解。

Buffer缓冲区分为三个重要的变量:

Position:

在从通道读取时,您将所读取的数据放到底层的数组中。 position 变量跟踪已经写了多少数据。更准确地说,它指定了下一个字节将放到数组的哪一个元素中。因此,如果您从通道中读三个字节到缓冲区中,那么缓冲区的 position 将会设置为3,指向数组中第四个元素。

同样,在写入通道时,您是从缓冲区中获取数据。 position 值跟踪从缓冲区中获取了多少数据。更准确地说,它指定下一个字节来自数组的哪一个元素。因此如果从缓冲区写了5个字节到通道中,那么缓冲区的 position 将被设置为5,指向数组的第六个元素。

Limit:

limit 变量表明还有多少数据需要取出(在从缓冲区写入通道时),或者还有多少空间可以放入数据(在从通道读入缓冲区时)。

position 总是小于或者等于 limit

Capacity:

缓冲区的 capacity 表明可以储存在缓冲区中的最大数据容量。实际上,它指定了底层数组的大小 ― 或者至少是指定了准许我们使用的底层数组的容量。limit 决不能大于 capacity

看了上面对三个变量的描述,在看一下buffer缓冲区操作的三个方法的代码:

    public final Buffer flip() {   
         limit = position;    
         position = 0;   
         mark = -1;   
         return this;   
     } 

flip():此方法将position的值赋给了limit,将position置为0,常用在读取通道中的数据到Buffer之后,要进行写操作之前调用,此时,缓冲区的数据的长度在position的位置,将position的值赋给limit,position置为0,可以一个字节不多一个字节不少的进行写的操作,保证数据的可靠性。

    public final Buffer clear() {   
        position = 0;     //设置为0
        limit = capacity;    //极限和容量相同
        mark = -1;   //取消标记
        return this;   
    } 

clear():此方法相当于给Buffer复位,但不会清除Buffer中的数据,调用这个方法使缓冲区为为新的通道读取或写入做准备。如果是写入,新的写入会覆盖旧的同一个位置的内容。

    public final Buffer rewind() {
        position = 0;
        mark = -1;
        return this;
        }

rewind() :使缓冲区为重新读取已包含的数据做好准备:它使限制保持不变,将位置设置为 0。和clear()类似,只是不改动限制。

以上三个方法都不对buffer内的数据作修改。

3、Selector选择器(略)

三、文件复制操作实战

    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.IOException;
    import java.nio.ByteBuffer;
    import java.nio.channels.FileChannel;
    /**
     *
     *@desc 复制文件
     */
    public class Test {
    
        public static void main(String[] args) throws IOException {
            // 定义源文件 & 目标文件
            String infile = "C:\\copy.sql";
            String outfile = "C:\\copy.txt";
    
            // 1. 获取数据源 和 目标传输地的输入输出流
            FileInputStream fin = new FileInputStream(infile);
            FileOutputStream fout = new FileOutputStream(outfile);
    
            // 2. 获取数据源的输入输出通道
            FileChannel fcin = fin.getChannel();
            FileChannel fcout = fout.getChannel();
    
            // 3. 创建缓冲区对象,容量初始化为1024
            ByteBuffer buff = ByteBuffer.allocate(1024);
            
            while (true) {
                // 4. 从通道读取数据 & 写入到缓冲区
                int r = fcin.read(buff);
                // 返回-1代表已读取到该通道数据的末尾,循环可以结束
                if (r == -1) {
                    break;
                }
                // 5. 传出数据准备:调用flip()方法  
                buff.flip();
                
                // 6. 从 Buffer 中读取数据 & 传出数据到目标的输入通道
                fcout.write(buff);
                // 7. 复位缓冲区
                buff.clear();
              }
            }
    
    }

以上是对NIO操作文件的一些原理和实战的讲解,对NIO感兴趣的可以看看,如有疑问,下方留言我会随时回复哦。