在说NIO以前,先来讲说IO的读写原理。咱们都知道Java中的IO流能够分为网络IO流和文件IO流,前者在网络中使用,后者在操做文件时使用。但实际上两种流区别并非太大,对于操做系统来讲区别仅仅是和硬盘打交道仍是和网卡打交道。
其次,咱们直接操控的是Jvm虚拟机,虚拟机是运行在操做系统上的、用户层面的进程,jvm虚拟机并不能直接操控底层硬件(这也是为何Java不多用来作坏事的缘由之一),而是向系统进行发出调用申请。
所以,当Jvm运行到IO流的read方法后会向系统发出read系统调用,由系统使用硬盘驱动来从硬盘读取数据(这只是个简单的比喻,实际状况是有点复杂的)。须要注意的是系统并不会直接把数据从硬盘复制到Jvm内存中,而是把数据先复制到一个“内核缓冲区”的地方。咱们使用字节流时都会new一个byte数组做为缓冲区,这个缓冲区是用户缓冲区,内核中也存在这样一个缓冲区。因此一个常规的IO流读取文件的过程是这样的:硬盘 -> 内核缓冲区 -> 用户缓冲区(Jvm内存,也就是byte数组)
,写操做也是一样的道理。
当内核没有准备好数据的时候,整个用户进程是阻塞的,直到系统吧数据从内核缓存移动到jvm内存中后整个进程才会继续运行下去。系统从本地文件读取数据时可能会快一点,可是当从网卡读取数据时因为网络延迟的存在,其效率会很是低,而且一个线程只能处理一个网络请求。
若是有多个客户端访问时虽然能够开多线程来处理,可是线程是一种“很是贵”的资源,不管线程是否工做,虚拟机会为每一个线程至少保留128K~1M的空间,而且当线程多了以后,线程之间争抢资源、CPU频繁切换不一样线程会致使整个系统都效率低下(切换线程须要保存当前线程上下文,浪费CPU性能)。html
什么是NIO:java
NIO的官方解释是:NIO stands for non-blocking I/O(并不是某些人所说的 new IO),直译就是非阻塞IO。须要说明的是Java中的NIO并不属于非阻塞IO模型,而是IO复用模型,不过一样实现了非阻塞状态。linux
与普通IO的不一样数据库
普通的IO的核心是Stream
,NIO的核心是Buffer
( 缓存区)、Channel
(通道)和Selector
(选择器)。编程
为何使用NIOsegmentfault
须要明白的是NIO解决了网络中一个线程只能处理单个链接的痛点。还能够减小文件传输时CPU在存储中反复拷贝的反作用,即减小了系统内核和用户应用程序地址空间这二者之间进行数据拷贝,这就是零拷贝(zero copy)技术)。设计模式
那么什么是Buffer
(缓存区)、Channel
(通道)和Selector
(选择器)呢?
Buffer这个比较好理解,就是一个用来存放数据的地方,即缓冲区。Channel则有点像流,不过Channel是双向的,数据能够从Buffer读取到channel中,也能够从channel中写入到buffer。数组
Selector则是选择器,用来对多个channel进行筛选,进而挑出能够处理的channel,这样就把多线程级别的处理降级为单线程的轮询,不用咱们手动维护线程池而交给selector来处理。须要注意的是调用selector的select()方法后若是没有可用的channel,此时该线程是阻塞的。缓存
Buffer(缓冲区)和使用普通IO流时建立的byte数组区别并不大,只不过封装成了类并添加了一些特有的方法。
Buffer的翻译是什么?缓冲啊,缓冲区是干什么的?存取数据呗,怎么存?put、get呀。所以Buffer的核心方法即是put()
和get()
。
同时,Buffer维护了四个变量来描述这个数据缓冲区的状态:安全
直接用起来大概就是这个样子:
//使用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的类型:
想说咋没有StringBuffer?😏
StringBuffer在java.lang包里啊喂(#`O′),StringBuilder、StringBuffer是可变字符串,StringBuilder更快可是线程不安全,StringBuffer慢一点可是线程安全。😆
Channel的做用正如它的翻译:渠道,渠道的做用是什么,运输水呀,在程序中数据就像水同样。(通常将Channel翻译为通道)
下面是JAVA NIO中的一些主要Channel的实现:
可见既包括了网络流也包括了文件流。
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(开始位置,数据量,接收数据的通道);
两个方法使用起来差异不大。
在说Selector以前先来简单认识一下常见的网络IO模型。
通常来讲网络IO有5种模型:
其中前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的做用了。
下面我将演示一次一个客户端和服务端的一次通讯:
服务端
//服务端使用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是一个能收发 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有一个 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是必不可少的。
简单总结下都有什么东西:
附录:
对于ByteBuffer读取中文时会乱码的问题,这里有一个解决方案:
《Java NIO下使用ByteBuffer读取文本时解决UTF-8几率性中文乱码的问题》
不过看起来不是特别好使就是了。
参考: