阅读 248

java 中的 NIO 和 IO 到底是什么区别?20 个问题告诉你答案

​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​​摘要:NIO 即 New IO,这个库是在 JDK1.4 中才引入的。NIO 和 IO 有相同的作用和目的,但实现方式不同,NIO 主要用到的是块,所以 NIO 的效率要比 IO 高很多。

本文分享自华为云社区《java中的NIO和IO到底是什么区别?20个问题告诉你答案【奔跑吧!JAVA】》,原文作者:breakDraw 。

NIO 即 New IO,这个库是在 JDK1.4 中才引入的。NIO 和 IO 有相同的作用和目的,但实现方式不同,NIO 主要用到的是块,所以 NIO 的效率要比 IO 高很多。

Q: NIO 和标准 IO 有什么区别?

A:

  • 标准 IO, 基于字节流和字符流进行操作,阻塞 IO。
  • NIO 基于通道 channel 和缓冲区 Buffer 进行操作,支持非阻塞 IO,提供选择器

JavaNIO 核心 3 组件:

Channels 通道

Q: 通道 Channel 对象能同时做读写操作吗?还是说需要像标准 IO 那样,需要同时创建 input 和 output 对象才能做读写操作?

A:通道 Channel 是双向的, 既可以从 channel 中读数据,也可以写数据。可以看到既能调用 read 也能调用 write,且需要依赖缓冲区 buffer。

FileChannel fileChannel = FileChannel.open(new File("a.txt").toPath());
    ByteBuffer buf = ByteBuffer.allocate(1024);
     fileChannel.read(buf);
     fileChannel.write(buf);
复制代码
  • 注意上图上,fileChannel.read(buf)是将 a.txt 里的数据读到 buf, 即 a.txt->buf
  • fileChannel.write(buf)是将 buf 里的数据写入到 a.txt 中, 即 buf->a.txt,不要搞反啦!
  • 通道和缓冲区的关系

Q: 通道支持异步读写吗

A:支持。

Q: 通道的读写是否必须要依赖缓冲区 buffer?

A: 一般都是依赖 buffer 的。 但也支持 2 个管道之间的传输,即管道之间直接读写。

String[] arr=new String[]{"a.txt","b.txt"};
FileChannel in=new FileInputStream(arr[0]).getChannel();
FileChannel out =new FileOutputStream(arr[1]).getChannel();
 
// 将a.txt中的数据直接写进b.txt中,相当于文件拷贝
in.transferTo(0, in.size(), out);
复制代码

常用的几种 Channel

  • FileChannel

Java NIO 中的 FileChannel 是一个连接到文件的通道。可以通过文件通道读写文件。FileChannel 无法设置为非阻塞模式,它总是运行在阻塞模式下

创建方式

RandomAccessFile    file = new RandomAccessFile("D:/aa.txt");
FileChannel    fileChannel = file.getChannel();
复制代码
  • SocketChannel

Java NIO 中的 SocketChannel 是一个连接到 TCP 网络套接字的通道。

支持非阻塞模式
socketChannel.configureBlocking(false)。

可以通过以下 2 种方式创建 SocketChannel:

打开一个 SocketChannel 并连接到互联网上的某台服务器。

一个新连接到达 ServerSocketChannel 时,会创建一个 SocketChannel

创建方式

SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("192.168.1.100",80));
复制代码
  • ServerSocketChannel

Java NIO 中的 ServerSocketChannel 是一个可以监听新进来的 TCP 连接的通道, 就像标准 IO 中的 ServerSocket 一样。

ServerSocketChannel 类在 java.nio.channels 包中。

SocketChannel 和 ServerSocketChannel 的区别: 前者用于客户端,后者用于服务端

创建方式:

ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket.bind(new InetSocketAddress(80));
serverSocketChannel.configureBlocking(false);
while(true){
    SocketChannel socketChannel = serverSocketChannel.accept();
    if(socketChannle != null)
        doSomething...
}
复制代码

Buffer 缓冲区

  • 我们真正要把数据拿到或者要写数据, 实际上都是通过 buffer 进行操作的。
  • 文件 <-> buffer<-> 数据
  • buffer 是 1 个即可读也可写的缓冲区,拥有读写 2 种模式。
  • buffer 的 capacity 属性限定了每个 buffer 的最大容量,下面的 1024 就是 capacity。

ByteBuffer buf = ByteBuffer.allocate(1024);

  • buffer 拥有 1 个 position 属性,表示当前的读写位置。
  • 往 buffer 中写数据时,position 就会增加。
  • position 最大值为 capacity-1
  • 把 fileChannel 对应文件里的数据 写入到 buffer,叫做写模式
  • 写之后,调用 flip,让 buffer 的 postion 置 0,此时相当于准备读取 buffer 里的数据(即调用 buffer.get()拿数据)

(这个模式的叫法个人也觉得不太好,很容易绕,你可以就记忆成: flip 就是从写模式转成读模式!)

Q: buffer 调用 flip()方法从写模式切换到读模式时,position 会变成多少?

A: 变为 0。

ByteBuffer buf = ByteBuffer.allocate(1024);
    // 数据读到buf中,并返回数量,每次最多读1024个
    int byteRead = fileChannel.read(buf);
    // 输出byteRead的数量,最多为1024
    System.out.println("position=" + buf.position()+", byteRead=" + byteRead);
    
    buf.flip();
    // 切换到读模式了,输出0
    System.out.println("position=" + buf.position());
复制代码
  • buffer 拥有 1 个 limit 属性。
  • 写模式下,buffer 的 limit 就是 buffer 的 capacity。

Q: 当 buffer 从写模式切换到读模式时,limit 为多少?

A: 每次切换前都要调用 flip(),切换后,limit 为写模式中的 position。

 int byteRead = fileChannel.read(buf);
        // 输出1024
        System.out.println("limit=" + buf.limit() + ",postion=" + buf.position());
        System.out.println("切换到读模式");
        buf.flip();
        // 输出byteRead数量
        System.out.println("limit=" + buf.limit());
复制代码

​结果如下

Q: 向 buf 缓冲区写数据的方式有哪些?

A:

  • int byteRead = fileChannel.read(buf);

从通道中读数据到 buf 中, 即相当于向 buf 缓冲区中写数据。

  • buf.putChar(‘a’);

手动向 buf 中写入字符 a, postion 加 1。

Q: 从 buf 缓冲区读数据的方式有哪些?

  • int bytesWrite = fileChannel.write(buf)

buf 中的数据写入到管道,即相当于 fileChannel 读取 buf 中的数据。

  • byte getByte = buf.get()

手动读取 1 个 buf 中的字符,postion 加 1.

Q: 手动修改当前缓冲区的 postion 的方法有哪些?

A:

  • rewind() 将 postion 设置为 0
  • mark() 可以标记 1 个特定的位置, 相当于打标记, 在一顿操作后,可通过 reset()回到之前 mark()的位置(就像你需要 mark 我的这几篇博文一样!)

Q:1 个 channel 管道支持多个 buffer 吗?

A: 支持。 通道的 write 和 read 方法都支持传入 1 个 buffer 数组,会按照顺序做读写操作。

Buffer 的种类:

Buffer 的另外 3 个方法:

  • warp:

根据一个 byte[]来生成一个固定的 ByteBuffer 时,使用 ByteBuffer.wrap()非法的合适。他会直接基于 byte[]数组生成一个新的 buffer,值也保持一致。

  • slice:

得到切片后的数组。

  • duplicate:

调用 duplicate 方法返回的 Buffer 对象就是复制了一份原始缓冲区,复制了 position、limit、capacity 这些属性

  • 注意!

以上 warp\slice\duplicte 生成的缓冲区 get 和 put 所操作的数组还是与原始缓冲区一样的

所以对复制后的缓冲区进行修改也会修改原始的缓冲区,反之亦然

因此 duplicte、slice 一般是用于操作一下 poistion\limit 等处理,但是原内容不会去变他,否则就会引起 原缓冲器的修改。

Selector

selector 可用来在线程中关联多个通道,并进行事件监听。

Q: 在 NIO 中 Selector 的好处是什么?

A:

  • 可以用更少的线程来管理各个通道。
  • 减少线程上下文切换的资源开销。

Q: Selector 支持注册哪种类型的通道?

A:支持非阻塞的通道。通道要在注册前调用 channel.configureBlocking(false) 设置为非阻塞。例如 FileChannel 就没办法注册,他注定是阻塞的。而 socketChannel 就可以支持非阻塞。

Q: Selector 注册时,支持监听哪几种事件,对应的常量是什么?(啊最不喜欢记忆这种东西了…)

A:共有 4 种可监听事件

  • Connect 成功连接到 1 个服务器,对应常量 SelectionKey.OP_CONNECT
  • Accept 准备好接收新进入的连接, 对应常量 SelectionKey.OP_ACCEPT
  • Read, 有数据可读,对应常量 SelectionKey.OP_READ
  • Write 接收到往里写的数据, 对应常量 SelectionKey.OP_WRITE

如果希望对该通道监听多种事件,可以用"|"位或操作符把常量连接起来。

int interestingSet = Selectionkey.OP_READ | Selectionkey.OP_WRITE;
Selectionkey key = channel.register(selector,interestingSet)
复制代码
  • SelectionKey 键表示了一个特定的通道对象和一个特定的选择器对象之间的注册关系

Q: Selector 维护的 SelectionKey 集合共有哪几种?

A:共有三种。

1.已注册的所有键的集合(Registered key set)

所有与选择器关联的通道所生成的键的集合称为已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过 keys()方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引发
java.lang.UnsupportedOperationException。

2.已选择的键的集合(Selected key set)

已注册的键的集合的子集。这个集合的每个成员都是相关的通道被选择器(在前一个选择操作中)判断为已经准备好的,并且包含于键的 interest 集合中的操作。这个集合通过 selectedKeys()方法返回(并有可能是空的)。不要将已选择的键的集合与 ready 集合弄混了。这是一个键的集合,每个键都关联一个已经准备好至少一种操作的通道。每个键都有一个内嵌的 ready 集合,指示了所关联的通道已经准备好的操作。键可以直接从这个集合中移除,但不能添加。试图向已选择的键的集合中添加元素将抛出
java.lang.UnsupportedOperationException。

3.已取消的键的集合(Cancelled key set)

已注册的键的集合的子集,这个集合包含了 cancel()方法被调用过的键(这个键已经被无效化),但它们还没有被注销。这个集合是选择器对象的私有成员,因而无法直接访问。

注册之后, 如何使用 selector 对准备就绪的通道做处理:

1. 调用 select()方法获取已就绪的通道,返回的 int 值表示有多少通道已经就绪

2. 从 selector 中获取 selectedkeys

3. 遍历 selectedkeys

4. 查看各 SelectionKey 中 是否有事件就绪了。

5. 如果有事件就绪,从 key 中获取对应对应管道。做对应处理

类似如下,一般都会启 1 个线程来 run 这个 selector 监听的处理:

while(true) {
    int readyNum = selector.select();
    if (readyNum == 0) {
        continue;
    }
 
    Set<SelectionKey> selectedKeys = selector.selectedKeys();
    Iterator<SelectionKey> it = selectedKeys.iterator();
		
    while(it.hasNext()) {
        SelectionKey key = it.next();
        if(key.isAcceptable()) {
            // 接受连接
        } else if (key.isReadable()) {
            // 通道可读
        } else if (key.isWritable()) {
            // 通道可写
        }
 
        it.remove();
    }
}
复制代码

Q:select()方法其实是阻塞方法,即调用时会进入等待,直到把所有通道都轮询完毕。如果希望提前结束 select(),有哪些方法?

A:有 2 个办法:

wakeup(), 调用后,select()方法立刻返回。

close(), 直接关闭 selector。

PS: 之前说 NIO 是非阻塞 IO,但为什么上面却说 select()方法是阻塞的?

  • 其实 NIO 的非阻塞,指的是 IO 不阻塞,即我们不会卡在 read()处,我们会用 selector 去查询就绪状态,如果状态 ok 就。
  • 而查询操作是需要时间,因此 select()必须要把所有通道都检查一遍才能告诉结果,因此 select 这个查询操作是阻塞的。

其他

Q: 多线程读写同一文件时,如何加锁保证线程安全?

A:使用 FileChannel 的加锁功能。

RandomAccessFile randFile = new RandomAccessFile(target, "rw");
FileChannel  channel = randFile.getChannel();
// pos和siz决定加锁区域, shared指定是否是共享锁
FileLock fileLock = channel.lock(pos , size , shared);
if (fileLock!=null) {
	do();
   // 这里简化了,实际上应该用try-catch
	fileLock.release();
}
复制代码

​Q: 如果需要读 1 个特大文件,可以使用什么缓冲区?

A:使用 MappedByteBuffer。这个缓冲区可以把大文件理解成 1 个 byte 数组来访问(但实际上并没有加载这么大的 byte 数组,实际内容放在内存+虚存中)。主要通过 FileChannel.map(模式,起始位置,区域)来生成 1 个 MappedByteBuffer。然后可以用 put 和 get 去处理对应位置的 byte。

int length = 0x8FFFFFF;//一个byte占1B,所以共向文件中存128M的数据
try (FileChannel channel = FileChannel.open(Paths.get("src/c.txt"),
		StandardOpenOption.READ, StandardOpenOption.WRITE);) {
	MappedByteBuffer mapBuffer = channel.map(FileChannel.MapMode.READ_WRITE, 0, length);
	for(int i=0;i<length;i++) {
		mapBuffer.put((byte)0);
	}
	for(int i = length/2;i<length/2+4;i++) {
		//像数组一样访问
		System.out.println(mapBuffer.get(i));
	}
}
复制代码

三种模式:

  • MapMode.READ_ONLY(只读): 试图修改得到的缓冲区将导致抛出 ReadOnlyBufferException。
  • MapMode.READ_WRITE(读/写): 对得到的缓冲区的更改会写入文件,需要调用 fore()方法
  • MapMode.PRIVATE(专用): 可读可写,但是修改的内容不会写入文件,只是 buffer 自身的改变。

Q:NIO 中 ByteBuffer, 该如何根据正确的编码,转为对应的 CharBuffer

A:利用 Charset 的 decode 功能。

ByteBuffer byteBuffer = ...;
Charset charset = Charset.forName("UTF-8");
CharBuffer charBuffer = charset.decode(byteBuffer);
复制代码

如果是 CharBuffer 转 ByteBuffer, 就用 charset.encode。

点击关注,第一时间了解华为云新鲜技术~

文章分类
代码人生
文章标签