抱歉,您的浏览器无法访问本站

本页面需要浏览器支持(启用)JavaScript


了解详情 >

image-20200625230224224

1 NIO概述

1.1 定义

java.nio全称java non-blocking IO,是指JDK1.4 及以上版本里提供的新api(New IO) ,为所有的原始类型(boolean类型除外)提供缓存支持的数据容器,使用它可以提供非阻塞式的高伸缩性网络(来源于百度百科)。

1.2 为什么使用NIO

在上面的描述中提到,是在JDK1.4以上的版本才提供NIO,那在之前使用的是什么呢?答案很简单,就是BIO(阻塞式IO),也就是我们常用的IO流。

BIO的问题其实不用多说了,因为在使用BIO时,主线程会进入阻塞状态,这就非常影响程序的性能,不能充分利用机器资源。但是这样就会有人提出疑问了,那我使用多线程不就可以了吗?

但是在高并发的情况下,会创建很多线程,线程会占用内存,线程之间的切换也会浪费资源开销。

而NIO只有在连接/通道真正有读写事件发生时(事件驱动),才会进行读写,就大大地减少了系统的开销。不必为每一个连接都创建一个线程,也不必去维护多个线程。

避免了多个线程之间的上下文切换,导致资源的浪费。

2 NIO的三大核心

NIO的核心 对应的类或接口 应用 作用
缓冲区 java.nio.Buffer 文件IO/网络IO 存储数据
通道 java.nio.channels.Channel 文件IO/网络IO 运输
选择器 java.nio.channels.Selector 网络IO 控制器

2.1缓冲区(Buffer)

2.1.1 什么是缓冲区

我们先看以下这张类图,可以看到Buffer有七种类型。

Buffer是一个内存块。在NIO中,所有的数据都是用Buffer处理,有读写两种模式。所以NIO和传统的IO的区别就体现在这里。传统IO是面向Stream流,NIO而是面向缓冲区(Buffer)。

2.1.2 常用的类型ByteBuffer

一般我们常用的类型是ByteBuffer,把数据转成字节进行处理。实质上是一个byte[]数组。

1
2
3
4
5
6
7
8
9
10
11
public abstract class ByteBuffer extends Buffer implements Comparable<ByteBuffer>{
//存储数据的数组
final byte[] hb;
//构造器方法
ByteBuffer(int mark, int pos, int lim, int cap, byte[] hb, int offset) {
super(mark, pos, lim, cap);
//初始化数组
this.hb = hb;
this.offset = offset;
}
}

2.1.3 创建Buffer的方式

主要分成两种:JVM堆内内存块Buffer、堆外内存块Buffer。

创建堆内内存块(非直接缓冲区)的方法是:

1
2
3
4
5
6
//创建堆内内存块HeapByteBuffer
ByteBuffer byteBuffer1 = ByteBuffer.allocate(1024);

String msg = "java技术爱好者";
//包装一个byte[]数组获得一个Buffer,实际类型是HeapByteBuffer
ByteBuffer byteBuffer2 = ByteBuffer.wrap(msg.getBytes());

创建堆外内存块(直接缓冲区)的方法:

1
2
//创建堆外内存块DirectByteBuffer
ByteBuffer byteBuffer3 = ByteBuffer.allocateDirect(1024);

2.1.3.1 HeapByteBuffer与DirectByteBuffer的区别

其实根据类名就可以看出,HeapByteBuffer所创建的字节缓冲区就是在JVM堆中的,即JVM内部所维护的字节数组。而DirectByteBuffer直接操作操作系统本地代码创建的内存缓冲数组

DirectByteBuffer的使用场景:

  1. java程序与本地磁盘、socket传输数据

  2. 大文件对象,可以使用。不会受到堆内存大小的限制。

  3. 不需要频繁创建,生命周期较长的情况,能重复使用的情况。

HeapByteBuffer的使用场景:

除了以上的场景外,其他情况还是建议使用HeapByteBuffer,没有达到一定的量级,实际上使用DirectByteBuffer是体现不出优势的。

2.1.3.2 Buffer的初体验

接下来,使用ByteBuffer做一个小例子,熟悉一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public static void main(String[] args) throws Exception {
String msg = "java技术爱好者,起飞!";
//创建一个固定大小的buffer(返回的是HeapByteBuffer)
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
byte[] bytes = msg.getBytes();
//写入数据到Buffer中
byteBuffer.put(bytes);
//切换成读模式,关键一步
byteBuffer.flip();
//创建一个临时数组,用于存储获取到的数据
byte[] tempByte = new byte[bytes.length];
int i = 0;
//如果还有数据,就循环。循环判断条件
while (byteBuffer.hasRemaining()) {
//获取byteBuffer中的数据
byte b = byteBuffer.get();
//放到临时数组中
tempByte[i] = b;
i++;
}
//打印结果
System.out.println(new String(tempByte));//java技术爱好者,起飞!
}

这上面有一个flip()方法是很重要的。意思是切换到读模式。上面已经提到缓存区是双向的既可以往缓冲区写入数据,也可以从缓冲区读取数据。但是不能同时进行,需要切换。那么这个切换模式的本质是什么呢?

2.1.4 三个重要参数

1
2
3
4
5
6
//位置,默认是从第一个开始
private int position = 0;
//限制,不能读取或者写入的位置索引
private int limit;
//容量,缓冲区所包含的元素的数量
private int capacity;

那么我们以上面的例子,一句一句代码进行分析:

1
2
3
String msg = "java技术爱好者,起飞!";
//创建一个固定大小的buffer(返回的是HeapByteBuffer)
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

当创建一个缓冲区时,参数的值是这样的:

image-20200625122337215

当执行到byteBuffer.put(bytes),当put()进入多少数据,position就会增加多少,参数就会发生变化:

image-20200625122640979

image-20200625123835657

接下来关键一步byteBuffer.flip(),会发生如下变化:

image-20200625122931713

image-20200625123004623

flip()方法的源码如下:

1
2
3
4
5
6
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}

为什么要这样赋值呢?因为下面有一句循环条件判断:

1
2
3
4
5
6
byteBuffer.hasRemaining();
public final boolean hasRemaining() {
//判断position的索引是否小于limit。
//所以可以看出limit的作用就是记录写入数据的位置,那么当读取数据时,就知道读到哪个位置
return position < limit;
}

接下来就是在while循环中get()读取数据,读取完之后。

image-20200625123623688

image-20200625123745018

最后当position等于limit时,循环判断条件不成立,就跳出循环,读取完毕。

所以可以看出实质上capacity容量大小是不变的,实际上是通过控制positionlimit的值来控制读写的数据。

2.2 管道(Channel)

首先我们看一下Channel有哪些子类:

常用的Channel有这四种:

FileChannel,读写文件中的数据。
SocketChannel,通过TCP读写网络中的数据。
ServerSockectChannel,监听新进来的TCP连接,像Web服务器那样。对每一个新进来的连接都会创建一个SocketChannel。
DatagramChannel,通过UDP读写网络中的数据。

Channel本身并不存储数据,只是负责数据的运输。必须要和Buffer一起使用。

2.2.1 获取通道的方式

2.2.1.1 FileChannel

FileChannel的获取方式,下面举个文件复制拷贝的例子进行说明:

image-20200625130742262

首先准备一个”1.txt”放在项目的根目录下,然后编写一个main方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public static void main(String[] args) throws Exception {
//获取文件输入流
File file = new File("1.txt");
FileInputStream inputStream = new FileInputStream(file);
//从文件输入流获取通道
FileChannel inputStreamChannel = inputStream.getChannel();
//获取文件输出流
FileOutputStream outputStream = new FileOutputStream(new File("2.txt"));
//从文件输出流获取通道
FileChannel outputStreamChannel = outputStream.getChannel();
//创建一个byteBuffer,小文件所以就直接一次读取,不分多次循环了
ByteBuffer byteBuffer = ByteBuffer.allocate((int)file.length());
//把输入流通道的数据读取到缓冲区
inputStreamChannel.read(byteBuffer);
//切换成读模式
byteBuffer.flip();
//把数据从缓冲区写入到输出流通道
outputStreamChannel.write(byteBuffer);
//关闭通道
outputStream.close();
inputStream.close();
outputStreamChannel.close();
inputStreamChannel.close();
}

执行后,我们就获得一个”2.txt”。执行成功。

image-20200625130945572

以上的例子,可以用一张示意图表示,是这样的:

image-20200625132433945

2.2.1.2 SocketChannel

接下来我们学习获取SocketChannel的方式。

还是一样,我们通过一个例子来快速上手:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public static void main(String[] args) throws Exception {
//获取ServerSocketChannel
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 6666);
//绑定地址,端口号
serverSocketChannel.bind(address);
//创建一个缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
while (true) {
//获取SocketChannel
SocketChannel socketChannel = serverSocketChannel.accept();
while (socketChannel.read(byteBuffer) != -1){
//打印结果
System.out.println(new String(byteBuffer.array()));
//清空缓冲区
byteBuffer.clear();
}
}
}

然后运行main()方法,我们可以通过telnet命令进行连接测试:

image-20200625134508044

通过上面的例子可以知道,通过ServerSocketChannel.open()方法可以获取服务器的通道,然后绑定一个地址端口号,接着accept()方法可获得一个SocketChannel通道,也就是客户端的连接通道。

最后配合使用Buffer进行读写即可。

这就是一个简单的例子,实际上上面的例子是阻塞式的。要做到非阻塞还需要使用选择器Selector

2.3 选择器(Selector)

Selector翻译成选择器,有些人也会翻译成多路复用器,实际上指的是同一样东西。

只有网络IO才会使用选择器,文件IO是不需要使用的。

选择器可以说是NIO的核心组件,它可以监听通道的状态,来实现异步非阻塞的IO。换句话说,也就是事件驱动。以此实现单线程管理多个Channel的目的。

2.3.1 核心API

API方法名 作用
Selector.open() 打开一个选择器。
select() 选择一组键,其相应的通道已为 I/O 操作准备就绪。
selectedKeys() 返回此选择器的已选择键集。

以上的API会在后面的例子用到,先有个印象。

3 NIO快速入门

3.1 文件IO

3.1.1 通道间的数据传输

这里主要介绍两个通道与通道之间数据传输的方式:

transferTo():把源通道的数据传输到目的通道中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) throws Exception {
//获取文件输入流
File file = new File("1.txt");
FileInputStream inputStream = new FileInputStream(file);
//从文件输入流获取通道
FileChannel inputStreamChannel = inputStream.getChannel();
//获取文件输出流
FileOutputStream outputStream = new FileOutputStream(new File("2.txt"));
//从文件输出流获取通道
FileChannel outputStreamChannel = outputStream.getChannel();
//创建一个byteBuffer,小文件所以就直接一次读取,不分多次循环了
ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());
//把输入流通道的数据读取到输出流的通道
inputStreamChannel.transferTo(0, byteBuffer.limit(), outputStreamChannel);
//关闭通道
outputStream.close();
inputStream.close();
outputStreamChannel.close();
inputStreamChannel.close();
}

transferFrom():把来自源通道的数据传输到目的通道。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public static void main(String[] args) throws Exception {
//获取文件输入流