浅析Java NIO
浅析Java NIO
前言
在说NIO之前,先来说说IO的读写原理。我们都知道Java中的IO流可以分为网络IO流和文件IO流,前者在网络中使用,后者在操作文件时使用。但实际上两种流区别并不是太大,对于操作系统来说区别仅仅是和硬盘打交道还是和网卡打交道。
其次,我们直接操控的是Jvm虚拟机,虚拟机是运行在操作系统上的、用户层面的进程,jvm虚拟机并不能直接操控底层硬件(这也是为什么Java很少用来做坏事的原因之一),而是向系统进行发出调用申请。
因此,当Jvm运行到IO流的read方法后会向系统发出read系统调用,由系统使用硬盘驱动来从硬盘读取数据(这只是个简单的比喻,实际情况是有点复杂的)。需要注意的是系统并不会直接把数据从硬盘复制到Jvm内存中,而是把数据先复制到一个“内核缓冲区”的地方。我们使用字节流时都会new一个byte数组作为缓冲区,这个缓冲区是用户缓冲区,内核中也存在这样一个缓冲区。所以一个常规的IO流读取文件的过程是这样的:硬盘 -> 内核缓冲区 -> 用户缓冲区(Jvm内存,也就是byte数组)
,写操作也是同样的道理。
当内核没有准备好数据的时候,整个用户进程是阻塞的,直到系统吧数据从内核缓存移动到jvm内存中后整个进程才会继续运行下去。系统从本地文件读取数据时可能会快一点,但是当从网卡读取数据时由于网络延迟的存在,其效率会非常低,并且一个线程只能处理一个网络请求。
如果有多个客户端访问时虽然可以开多线程来处理,但是线程是一种“非常贵”的资源,无论线程是否工作,虚拟机会为每个线程至少保留128K~1M的空间,并且当线程多了之后,线程之间争抢资源、CPU频繁切换不同线程会导致整个系统都效率低下(切换线程需要保存当前线程上下文,浪费CPU性能)。
何为NIO & 为什么使用NIO
什么是NIO:
NIO的官方解释是:NIO stands for non-blocking I/O(并非某些人所说的 new IO),直译就是非阻塞IO。需要说明的是Java中的NIO并不属于非阻塞IO模型,而是IO复用模型,不过同样实现了非阻塞状态。
与普通IO的不同
普通的IO的核心是Stream
,NIO的核心是Buffer
( 缓存区)、Channel
(通道)和Selector
(选择器)。
为什么使用NIO
需要明白的是NIO解决了网络中一个线程只能处理单个连接的痛点。还可以减少文件传输时CPU在存储中反复拷贝的副作用,即减少了系统内核和用户应用程序地址空间这两者之间进行数据拷贝,这就是零拷贝(zero copy)技术)。
那么什么是Buffer
(缓存区)、Channel
(通道)和Selector
(选择器)呢?
Buffer这个比较好理解,就是一个用来存放数据的地方,即缓冲区。Channel则有点像流,不过Channel是双向的,数据可以从Buffer读取到channel中,也可以从channel中写入到buffer。
Selector则是选择器,用来对多个channel进行筛选,进而挑出可以处理的channel,这样就把多线程级别的处理降级为单线程的轮询,不用我们手动维护线程池而交给selector来处理。需要注意的是调用selector的select()方法后如果没有可用的channel,此时该线程是阻塞的。
Buffer
Buffer(缓冲区)和使用普通IO流时创建的byte数组区别并不大,只不过封装成了类并添加了一些特有的方法。
Buffer的翻译是什么?缓冲啊,缓冲区是干什么的?存取数据呗,怎么存?put、get呀。因此Buffer的核心方法便是put()
和get()
。
同时,Buffer维护了四个变量来描述这个数据缓冲区的状态:
- 容量Capacity:缓冲区能够容纳的数据元素的最大数量。容量在缓冲区创建时被设定,并且永远不能被改变。(不能被改变的原因也很简单,底层是数组嘛)
- 上界Limit:缓冲区里的数据的总数(能够存或者读的界限),代表了当前缓冲区中一共有多少数据。
- 位置Position:下一个要被读或写的元素的位置。Position会自动由相应的 get( )和 put( )函数更新。
直接用起来大概就是这个样子:
//使用allocate()方法构建缓冲区,分配大小为128字节
ByteBuffer byteBuffer = ByteBuffer.allocate(128);
//写入数据
byteBuffer.put("Java".getBytes());
//切换模式
byteBuffer.flip();
while (byteBuffer.hasRemaining()){//Remaining : 剩余
System.out.println((char)byteBuffer.get());
}
看flip()
的源码就会发现也就这样(flip : 翻动):
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
想重新写入数据可以调用clear()
方法:
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
没错,clear后数据还在,只不过position归0不能读了。想重新读取可以调用rewind()
方法(rewind : 倒带):
public final Buffer rewind() {
position = 0;
mark = -1;
return this;
}
那如果读取到一半又想写入了怎么办呢?可以调用compact()
方法,这个方法可以将所有未读的数据拷贝到Buffer起始处。然后将position设到最后一个未读元素正后面。limit属性依然像clear()方法一样,设置成capacity。现在Buffer准备好写数据了,但是不会覆盖未读的数据。(compact : 紧凑)
调用position()
方法可以获得当前position的位置。
可能有同学发现了,上面我说这个类维护了四个变量来描述缓冲区,我却只列出了三个,并且在源代码中频繁出现了mark
这个关键字,没错,这就是第四个变量,用来当做作为一个标记。可以调用mark()
方法来标记此时的position的位置,然后调用reset()
方法将position回到此处,下面是源码:
public final Buffer mark() {
mark = position;
return this;
}
public final Buffer reset() {
int m = mark;
if (m < 0)
throw new InvalidMarkException();
position = m;
return this;
}
简单粗暴😂
Buffer的类型:
- ByteBuffer
- MappedByteBuffer
- CharBuffer
- DoubleBuffer
- FloatBuffer
- IntBuffer
- LongBuffer
- ShortBuffer
想说咋没有StringBuffer?😏
仔细想想StringBuffer在哪个包里?
StringBuffer在java.lang包里啊喂(#`O′),StringBuilder、StringBuffer是可变字符串,StringBuilder更快但是线程不安全,StringBuffer慢一点但是线程安全。😆
Channel
Channel的作用正如它的翻译:渠道,渠道的作用是什么,运输水呀,在程序中数据就像水一样。(一般将Channel翻译为通道)
下面是JAVA NIO中的一些主要Channel的实现:
- FileChannel(文件IO)
- SocketChannel(TCP IO)
- DatagramChannel(UDP IO)
- ServerSocketChannel(服务端监听TCP连接,并建立响应通道)
可见既包括了网络流也包括了文件流。
FileChannel & AsynchronousFileChannel
FileChannel是操作文件的流,可以由FileInputStream
、FileOutputStream
、RandomAccessFile
三个东东的getChannel()
方法来获取通道。
获得通道之后可以使用read(buffer)
和write(buffer)
来从通道读取数据到缓冲区或者把数据从缓冲区写入到数据。
并且,Channel不仅仅可以读/写一个缓冲区,还可以读写缓冲区数组。
不过很遗憾,FileChannel是阻塞的,上面所说的非阻塞值得是网络IO,文件IO只能运行在阻塞模式下。
并且由于可以自由操控FileChannel的position,在写入文件时会可能产生“文件空洞”,这可能会破坏文件。在效率上,使用通道进行文件拷贝和使用普通IO流进行拷贝差别并不大,甚至使用通道还会更麻烦一点(因为多一步从流获取通道的过程)。
我上面有说:NIO可以减少数据传输时CPU反复拷贝,这里贴篇文章吧:《通过零拷贝实现有效数据传输》,这篇文章的原理就是直接操作内核缓存,通道对通道,不经过用户缓冲区,因此可以提高IO效率,具体实现本文不再赘述。
文件IO的特性仅止于此了吗?并不是!
我们可以使用真正的异步IO(AIO)来进行文件的读写:AsynchronousFileChannel 类。要知道NIO是1.4版本加入的,而AsynchronousFileChannel 是1.7版本才加入的、真正的异步IO!
具体细节请看我的另一篇文章:《NIO前奏之Path、Files、AsynchronousFileChannel》
通道间传输
通道除了和缓冲交换数据之外还可以直接和通道交换数据。
transferFrom()
和transferTo()
:
使用起来大概是这个样子:
接收数据的通道.transferFrom(开始位置,数据量,发送数据的通道);
发送数据的通道.transferTo(开始位置,数据量,接收数据的通道);
两个方法使用起来差别不大。
网络IO模型
在说Selector之前先来简单认识一下常见的网络IO模型。
一般来说网络IO有5种模型:
- 同步阻塞IO(Blocking IO)
- 同步非阻塞IO(Non-blocking IO)
- IO多路复用(IO Multiplexing)
- 信号驱动IO(signal-driven IO)
- 异步IO(Asynchronous IO)
其中前4种为同步IO,只有第5个才是异步IO。
由于信号驱动模型使用不多,这里不再说明。
服务器端编程经常需要构造高性能的IO模型,常见的IO模型有四种:
(1)同步阻塞IO(Blocking IO)
首先,解释一下这里的阻塞与非阻塞:
阻塞IO,指的是需要内核IO操作彻底完成后,才返回到用户空间,执行用户的操作。阻塞指的是用户空间程序的执行状态,用户空间程序需等到IO操作彻底完成。传统的IO模型都是同步阻塞IO。在java中,默认创建的socket都是阻塞的。
其次,解释一下同步与异步:
同步IO,是一种用户空间与内核空间的调用发起方式。同步IO是指用户空间线程是主动发起IO请求的一方,内核空间是被动接受方。异步IO则反过来,是指内核kernel是主动发起IO请求的一方,用户线程是被动接受方。
(2)同步非阻塞IO(Non-blocking IO)
非阻塞IO,指的是用户程序不需要等待内核IO操作完成后,内核立即返回给用户一个状态值,用户空间无需等到内核的IO操作彻底完成,可以立即返回用户空间,执行用户的操作,处于非阻塞的状态。
简单的说:阻塞是指用户空间(调用线程)一直在等待,而且别的事情什么都不做;非阻塞是指用户空间(调用线程)拿到状态就返回,IO操作可以干就干,不可以干,就去干的事情。
非阻塞IO要求socket被设置为NONBLOCK。
强调一下,这里所说的NIO(同步非阻塞IO)模型,并非Java的NIO(New IO)库。
(3)IO多路复用(IO Multiplexing)
即经典的Reactor设计模式,有时也称为异步阻塞IO,Java中的Selector和Linux中的epoll都是这种模型。
(5)异步IO(Asynchronous IO)
异步IO,指的是用户空间与内核空间的调用方式反过来。用户空间线程是变成被动接受的,内核空间是主动调用者。
这一点,有点类似于Java中比较典型的模式是回调模式,用户空间线程向内核空间注册各种IO事件的回调函数,由内核去主动调用。
参考《10分钟看懂, Java NIO 底层原理》,具体细节可以看原博客,这里不再过多说明。
Selector
在知道了网络模型之后,我们就能理解Selector的作用了。
下面我将演示一次一个客户端和服务端的一次通信:
服务端
//服务端使用ServerSocketChannel,使用静态方法open()
ServerSocketChannel ssc = ServerSocketChannel.open();
//设置为非阻塞模式
ssc.configureBlocking(false);
//监听端口,可以不加IP地址,默认本地
ssc.socket().bind(new InetSocketAddress(8888));
//创建选择器,同样是静态方法
Selector selector = Selector.open();
//将通道注册到选择器中
ssc.register(selector, SelectionKey.OP_ACCEPT);//这里会返回一个选择键
//创建一个1024大小的缓冲
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true) {
int readyNum = selector.select();//这里会阻塞
if (readyNum == 0) {//正常情况下这里不可能为真
System.out.println("------------------");
continue;
}
Set<SelectionKey> selectionKeys = selector.selectedKeys();//获取选择键集,这里能获取到的是已就绪的选择键
Iterator<SelectionKey> it = selectionKeys.iterator();//键集迭代器
while (it.hasNext()) {
SelectionKey key = it.next();//选择键,一个键就对应着一个就绪的通道
it.remove();//获取之后需要移除,否则下次会继续迭代该键,然后发生空指针异常
if (key.isAcceptable()) {
// 接受连接
System.out.println("可接受...");
//通过key来获取通道
SocketChannel accept = ((ServerSocketChannel) key.channel()).accept();//这里需要进行强制转换
//设置为非阻塞
accept.configureBlocking(false);
//注册为可读
accept.register(selector, SelectionKey.OP_READ);
} else if (key.isReadable()) {
// 通道可读
SocketChannel clientChannel = (SocketChannel) key.channel();
System.out.println("有可读通道...");
buffer.clear();//这里一定要清空缓冲,否则第二次访问无法读取数据
while (clientChannel.read(buffer) > 0) {
buffer.flip();//改为读模式
byte[] bytes = new byte[buffer.limit()];
buffer.get(bytes);
System.out.println(new String(bytes));
buffer.clear();
}
//这里返回0代表还有数据但是没有读完,-1代表读取完毕
if (clientChannel.read(buffer) < 0) {
//就收完数据之后注册为可写
key.interestOps(SelectionKey.OP_WRITE);
}
} else if (key.isWritable()) {
// 通道可写
System.out.println("写");
SocketChannel channel = (SocketChannel) key.channel();
buffer.clear();
buffer.put("OVER".getBytes());
buffer.flip();
channel.write(buffer);
//写完之后记得关闭通道
channel.close();//这里也可以继续注册为可读
}
}
}
一个Selector面对的是多个channel,一个channel也可以注册多个selector(但是不推荐这么做),而描述selector和channel的关系的就是选择键SelectionKey。
register()
方法的第二个参数Selectionkey.OP_READ
代表该选择器对该通道的那个方面比较感兴趣,总共有四种时间类型:
Accept
接收事件,用SelectionKey.OP_ACCEPT
表示。(常量1 << 4
也就是16)Connect
连接事件,用SelectionKey.OP_CONNECT
表示。(常量1 << 3
也就是8)Write
可写事件,用SelectionKey.OP_WRITE
表示。(常量1 << 2
也就是4)Read
可读事件,用SelectionKey.OP_READ
表示。(常量1 << 0
也就是1)
如果一个通道不止对一种事件感兴趣,可以这么表达:SelectionKey.OP_READ | SelectionKey.OP_WRITE
(其实用+
也可以的)
需要注意的是:ServerSocketChannel只能注册OP_ACCEPT,SocketChannel只能注册OP_CONNECT、OP_WRITE、OP_READ。
通过选择键的interestOps()
方法可以获得感兴趣的集合的值,然后通过下面这种蹩脚的方式判断该键是否对某个事件感兴趣(没啥用):
int interestSet = selectionKey.interestOps();
//对可接受是否感兴趣
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
//或者
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) > 0;
具体原理是这些数都是2的幂,interestSet是2的幂的和。
实际我们处理的还是已经就绪的选择键,并通过下面的方法判断是否对某个事件感兴趣:
key.isAcceptable();
key.isConnectable();
key.isReadable();
key.isWritable();
既然选择键是选择器和通道的关系,那么选择键当然可以获得选择器和通道:
SelectableChannel selectableChannel = key.channel();
Selector selector = key.selector();
需要注意的是key.channel()
返回的是抽象父类,需要向下强制转换来使用。
register()
方法的第三个参数是一个附件,绑定到key上可以用来传递数据:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
该key可以获得或者是设置这个附件:
//添加新的附件会导致旧的附件丢失,也可以添加null来主动丢弃附件
selectionKey.attach(theObject);
//获取附件
Object attachedObj = selectionKey.attachment();//当然使用的时候还是需要向下转型的
整个过程是一个死循环,当没有注册过的就绪通道时,循环会在Selector.open()
这里阻塞,直到有就绪通道,方法的返回值是已就绪的通道数量。
选择器还有下面两种方法:
int select(long timeout)
设定最长阻塞时间(毫秒),时间到了之后会停止阻塞,如果没有可用通道会返回 0int selectNow()
立即返回,不阻塞
Selector的selectedKeys()
方法可以返回已就绪的通道的选择键集,然后对每个选择键进行不同的操作。
注意每次迭代的it.remove()
调用。Selector不会自己从已选择键集中移除SelectionKey实例。必须在处理完通道时自己移除。下次该通道变成就绪时,Selector会再次将其放入已选择键集中。
并且如果一个就绪通道被选择之后没有任何操作,那么下次改通道还会被选中。
上面两种不要搞混了,前者是内层while循环,后者是外侧while循环。
某个线程调用select()方法阻塞后,其它线程可以调用该选择器的wakeUp()
方法来让此次阻塞立即返回,如果在未阻塞的情况下调用该方法的话则会取消下次select()阻塞。
选择器不再使用之后可以调用close()
方法来关闭该选择器,这样会使注册过的SelectionKey失效,但不会使通道关闭。
至此服务端就介绍完毕了,下面是客户端,客户端可以使用nio,也可以使用普通的socket IO
NIO 客户端
@SuppressWarnings("all")
public class Client {
public static void main(String[] args) throws Exception {
//客户端创建的是SocketChannel通道,InetSocketAddress默认是本地地址
SocketChannel channel = SocketChannel.open(new InetSocketAddress(8888));
//设置为非阻塞模式
channel.configureBlocking(false);
//也可以用下面的方式创建通道
// SocketChannel channel1 = SocketChannel.open();
// channel1.configureBlocking(false);
// channel1.connect(new InetSocketAddress(8888));
// 上面这种写法有可能在连接上之前就返回了,所以需要使用channel1.finishConnect()来判断是否连接上了。
//创建缓冲
ByteBuffer buffer = ByteBuffer.allocate(1024);
//创建选择器
Selector selector = Selector.open();
//注册可写(客户端先发送数据)
//也可以注册可连接,然后在下面的循环里添加一个可连接分支,在分支里连接
channel.register(selector, SelectionKey.OP_WRITE);
while (true) {
int numReady = selector.select();
if (numReady == 0) {
continue;
}
//键集迭代器
Iterator<SelectionKey> it = selector.selectedKeys().iterator();
while (it.hasNext()) {
//获取选择键
SelectionKey key = it.next();
//迭代之后移该除选择键
it.remove();
if (key.isWritable()) {
System.out.println("可写");
SocketChannel channel1 = (SocketChannel) key.channel();
buffer.put("为美好的世界献上祝福".getBytes());
for (int i = 0; i < 3; i++) {
buffer.flip();
channel1.write(buffer);
System.out.println(i);
//模拟网络延迟
Thread.sleep(1000);
}
//这里需要通知服务端已经书写完毕,否则服务端会一直尝试读取
channel.shutdownOutput();
//然后把该通道注册为可读
key.interestOps(SelectionKey.OP_READ);
} else if (key.isReadable()) {
System.out.println("可读");
//读之前需要清空缓冲区,否则有时候会读不进去
buffer.clear();
SocketChannel readChannel = (SocketChannel) key.channel();
while (readChannel.read(buffer) > 0) {
buffer.flip();
byte[] bytes = new byte[buffer.limit()];
buffer.get(bytes);
System.out.println(new String(bytes));
buffer.clear();
}
//收到写入完毕的信号之后关闭通道,并结束。
if (readChannel.read(buffer) == -1) {
readChannel.close();
return;
}
}
}
//循环标记,没什么用
System.out.println("=================");
}
}
}
和服务端差不太多,没什么好说的。
BIO 客户端
Channel说白了就是对Socket的封装,让其可以配合选择器在单线程上处理多个连接,因此也可以使用普通Socket来连接 NIO 服务器。
普通IO的客户端就很简单了:
public class Client2 {
public static void main(String[] args) throws Exception {
Socket socket = new Socket("127.0.0.1",8888);
OutputStream os = socket.getOutputStream();
os.write("为美好的世界献上祝福".getBytes());
os.flush();
socket.shutdownOutput();
InputStream is = socket.getInputStream();
byte[] bytes = new byte[1024];
int len = is.read(bytes);
System.out.println(new String(bytes,0,len));
socket.shutdownInput();
is.close();
os.close();
socket.close();
}
}
至此,TCP相关的连接就说完了,下面是UDP的链接方法。
DatagramChannel
DatagramChannel是一个能收发 UDP 包的通道。
TCP与UDP效率比较:
TCP协议适用于对效率要求相对低,但对准确性要求相对高的场景下,或者是有一种连接概念的场景下;而UDP协议适用于对效率要求相对高,对准确性要求相对低的场景。
TCP与UDP应用场景:
TCP可以用于网络数据库,分布式高精度计算系统的数据传输;UDP可以用于服务系统内部之间的数据传输,因为数据可能比较多,内部系统局域网内的丢包错包率又很低,即便丢包,顶多是操作无效,这种情况下,UDP经常被使用。(大部分游戏都是UDP)
因为 UDP 是无连接的网络协议,所以不能像其它通道那样读取和写入。它发送和接收的是数据包。
代码还是比较简单的:
public class UDPServer {
public static void main(String[] args) throws Exception{
DatagramChannel channel = DatagramChannel.open();
//监听
channel.socket().bind(new InetSocketAddress(8888));
//设置非阻塞
channel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(1024);
//创建选择器
Selector selector = Selector.open();
//注册可读
channel.register(selector, SelectionKey.OP_READ);
while (selector.select()>0){
Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();
while (iterator.hasNext()){
SelectionKey key = iterator.next();
iterator.remove();
if (key.isReadable()){
System.out.println("可读");
DatagramChannel client = (DatagramChannel) key.channel();
buffer.clear();
client.receive(buffer);
buffer.flip();
System.out.println(new String(buffer.array(),0,buffer.limit()));
buffer.clear();
//这里不能关闭通道,否则第二次访问时接收不到数据
//client.close();
}
}
}
}
}
客户端:
public class UDPClient {
public static void main(String[] args) throws Exception{
DatagramChannel channel = DatagramChannel.open();
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("为美好的世界献上祝福".getBytes());
buffer.flip();
channel.send(buffer, new InetSocketAddress("127.0.0.1",8888));//并不知道服务器是否接收到
channel.close();
}
}
因为UDP是无连接的,所以数据没有保障,也就是说不打开服务端只打开客户端也不会报错。
不过DatagramChannel也可以调用connect()
方法,只不过这个不是真正的链接,只是相当于绑定了地址而已:
channel.connect(new InetSocketAddress("127.0.0.1", 8888));
这样就可以了调用其read
和write
方法了。
Pipe(管道)
Pipe管道可以在两个线程间进行单向数据传输,Pipe有一个 source 通道和一个 sink 通道。数据会被写到 sink 通道,从 source 通道读取:
代码很简单:
public class TestPipe {
public static void main(String[] args) throws Exception {
Pipe pipe = Pipe.open();
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("为美好的世界献上祝福".getBytes());
buffer.flip();
Pipe.SinkChannel sink = pipe.sink();
while (buffer.hasRemaining()) {
sink.write(buffer);
}
Pipe.SourceChannel source = pipe.source();
buffer.clear();
source.read(buffer);
buffer.flip();
System.out.println(new String(buffer.array(),0,buffer.limit()));
}
}
小结
NIO 的基础还是很重要的,这些东西对学习Netty是必不可少的。
简单总结下都有什么东西:
- Buffer
- Channel
- Selector
- ++++++++++
- ServerSocketChannel
- SocketChannel
- SelectionKey
- ++++++++++
- DatagramChannel
- Pipe
附录:
对于ByteBuffer读取中文时会乱码的问题,这里有一个解决方案:
《Java NIO下使用ByteBuffer读取文本时解决UTF-8概率性中文乱码的问题》
不过看起来不是特别好使就是了。
参考: