一、NIO是什么
Java NIO有两种叫法:一种叫非阻塞IO(Non-blocking I/O),另一种也叫新的IO(New I/O),其实是同一个概念。它是一种同步非阻塞的I/O模型,也是I/O多路复用的基础,已经被越来越多地应用到大型应用服务器,成为解决高并发与大量连接、I/O处理问题的有效方式。
NIO是一种基于通道和缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存(区别于JVM的运行时数据区),然后通过一个存储在java堆里面的DirectByteBuffer对象作为这块内存的直接引用进行操作。这样能在一些场景显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
二、常见I/O模型对比
所有的系统I/O都分为两个阶段:等待就绪和操作。
举例来说,读函数,分为等待系统可读和真正的读;同理,写函数分为等待网卡可以写和真正的写。
需要说明的是等待就绪的阻塞是不使用CPU的,是在“空等”;而真正的读写操作的阻塞是使用CPU的,真正在”干活”,而且这个过程非常快,属于memory copy,带宽通常在1GB/s级别以上,可以理解为基本不耗时。
下图是几种常见I/O模型的对比:
以socket.read()为例子:
传统的BIO里面socket.read(),如果TCP RecvBuffer里没有数据,函数会一直阻塞,直到收到数据,返回读到的数据。
对于NIO,如果TCP RecvBuffer有数据,就把数据从网卡读到内存,并且返回给用户;反之则直接返回0,永远不会阻塞。
最新的AIO(Async I/O)里面会更进一步:不但等待就绪是非阻塞的,就连数据从网卡到内存的过程也是异步的。
换句话说,BIO里用户最关心“我要读”,NIO里用户最关心”我可以读了”,在AIO模型里用户更需要关注的是“读完了”。
NIO一个重要的特点是:socket主要的读、写、注册和接收函数,在等待就绪阶段都是非阻塞的,真正的I/O操作是同步阻塞的(消耗CPU但性能非常高)。
三、Java NIO组件
NIO主要有三大核心部分:Channel(通道),Buffer(缓冲区), Selector(选择器)。
传统IO是基于字节流和字符流进行操作(基于流),而NIO基于Channel和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择区)用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个线程可以监听多个数据通道。
Buffer
Buffer(缓冲区)是一个用于存储特定基本类型数据的容器。除了boolean外,其余每种基本类型都有一个对应的buffer类。Buffer类的子类有ByteBuffer, CharBuffer, DoubleBuffer, FloatBuffer, IntBuffer, LongBuffer, ShortBuffer 。
NIO中,对数据的读写,都是在Buffer中完成的,也就是说,同一个buffer我们可以先读后写, 它底层维护着一个数组,这个数组被三个重要的属性控制,有机的工作结合,使buffer可读可写。
Buffer是线程不安全的,并发访问需要同步。
三个重要属性
1、capacity: 容量
表示Buffer中元素的个数,一旦初始化,就不能再改变。因为Buffer类的对象在初始化时,会按照capacity分配内部的内存,内存分配好之后,它的大小当然不能再改变了。
写入的对象数量超过了capacity容量,缓冲区就满了,不能再写入了。
Buffer类是一个抽象类,Java不能直接用来新建对象。使用的时候,必须使用Buffer的某个子类,例如使用DoubleBuffer,则写入的数据是double类型,如果其capacity是100,那么我们最多可以写入100个double数据。
2、limit: 读写的限制
limit表示读写的最大上限。limit属性与缓冲区的读写模式有关。在不同的模式下,limit值的含义是不同的。 + 写模式: limit表示可以写入的最大上限。在刚进入写模式时,limit会被设置成缓冲区的capacity容量值,表示可以一直将缓冲区的容量写满 + 读模式: limit的含义为最多能从缓冲区中读取到多少数据。
- limit是第一个不能被读,或者第一个不能被写的元素的index
- limit永远不会是负数
- 永远不会超过capacity
3、position: 读写位置
- 指数组中下一个将要被读或者将要被写的元素的索引
4、mark:一个暂存属性,暂时保存position的值,方便后面的重复使用position值。调用mark()方法来设置mark=position,再调用reset()可以让position恢复到mark标记的位置即position=mark。
图解Buffer是如何维护的数组
① 一开始: 我们初始化它的大小为6 初始状态,Capacity和Limit都在最后一个不能被读或者不能被写的位置上。
② 接着我们读入两个数据.position跳转到下一个将被读的index
接下来,准备写把buffer中的数据写出去两个, 于是我们需要反转数值
buffer.flip();
反转的逻辑:
limit=position;
position=0;
于是从0写 ,写到哪个位置? 写到limi前。
③ 写完毕后,position=limit,如果再想读入新的数据,同样需要反转数据flip()。
但是此时,limit仍然是刚刚position的所在的最后的位置。
重要的方法
主要介绍Buffer类的几个重要方法,包含Buffer实例的获取、对Buffer实例的写入、读取、重复读、标记和重置等。
① allocate() 创建缓冲区
② put() 写入到缓冲区
③ flip() 翻转:将写模式翻转到读模式
④ get() 从缓冲区读取数据:调用flip方法,将缓冲区切换成读取模式。这时,可以开始从缓冲区中进行数据读取了。读数据很简单,调用get方法,每次从position的位置读取一个数据,并且进行相应的缓冲区属性的调整。
⑤ rewind() 倒带:已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。
⑥ mark() 和reset() :Buffer.mark()方法的作用是将当前position的值保存起来,放在mark属性中,让mark属性记住这个临时位置;之后,可以调用Buffer.reset()方法将mark的值恢复到position中
⑦ clear() 清空缓冲区:在读取模式下,调用clear()方法将缓冲区切换为写入模式。此方法会将position清零,limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。
使用Buffer类的基本步骤
- 使用创建子类实例对象的allocate()方法,创建一个Buffer类的实例对象。
- 调用put方法,将数据写入到缓冲区中
- 写入完成后,在开始读取数据前,调用Buffer.flip()方法,将缓冲区转换为读模式。
- 调用get方法,从缓冲区中读取数据。
- 读取完成后,调用Buffer.clear()或Buffer.compact()方法,将缓冲区转换为写入模式。
Channel
Channel(通道)表示到实体,如硬件设备、文件、网络套接字或可以执行一个或多个不同 I/O 操作(如读取或写入)的程序组件的开放的连接。
和老的IOI相比,通道和NIO流(非阻塞IO)主要有以下几点区别:
- OIO流一般来说是单向的(只能读或写),通道可以读也可以写。
- OIO流式读写阻塞的,而通道可以异步读写。
- 通道总是基于缓冲区Buffer来读写。
Channel的类型
下面列出Java NIO中最重要的几个Channel的实现:
- FileChannel(用于文件的数据读写)
- DatagramChannel(用于UDP的数据读写)
- SocketChannel(用于TCP的数据读写)
- ServerSocketChannel(允许我们监听TCP链接请求,每个请求会创建会一个SocketChannel。)
FileChannel
FileChannel 是操作文件的Channel,我们可以通过 FileChannel 从一个文件中读取数据,也可以将数据写入到文件中。
注意,FileChannel 不能设置为非阻塞模式。
(1) 打开FileChannel通道
RandomAccessFile aFile = new RandomAccessFile("test.txt","rw");
FileChannel inChannel = aFile.getChannel();
(2) 读取数据
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf);
(3) 写入数据
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining())
{
channel.write(buf);
}
(4) 关闭
channel.close();
当我们对 FileChannel 的操作完成后,必须将其关闭。
(5) 强制刷新磁盘
channel.force(true);
FileChannel的force()方法将所有未写入的数据从通道刷新到磁盘中。在你调用该force()方法之前,出于性能原因,操作系统可能会将数据缓存在内存中,因此您不能保证写入通道的数据实际上写入磁盘。
SocketChannel
有两种Socket通道,一个是客户端的SocketChannel,一个是负责服务器端的Socket通道ServerSocketChannel。SocketChannel与OIO中的Socket类对应,ServerSocketChannel对应于OIO中的ServerSocket类相NIO。
两种Socket通道新增的通道都支持阻塞和非阻塞两种模式。在阻塞模式下的通道的创建、关闭、读写操作如下:
(1) 创建
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1",80));
这个是客户端的创建。当一个服务器端的ServerSocketChannel 接受到连接请求时,也会返回一个 SocketChannel 对象。
(2) 读取
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = socketChannel.read(buf);
如果 read()返回 -1,那么表示连接中断了。
(3) 写入数据
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
while(buf.hasRemaining()) {
channel.write(buf);
}
(4) 关闭
socketChannel.close();
在非阻塞模式,我们可以设置 SocketChannel 为异步模式,这样我们的 connect,read,write 都是异步的了。
操作一:连接
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("127.0.0.1",80));
while(! socketChannel.finishConnect() ){
//wait,or do something else...
}
在异步模式中,或许连接还没有建立,socketChannel.connect 方法就返回了,因此我们不断的自旋,检查当前是否是连接到了主机。
操作二:非阻塞读写
在异步模式下,读写的方式是一样的.
在读取时,因为是异步的,因此我们必须检查 read 的返回值,来判断当前是否读取到了数据.
ServerSocketChannel
ServerSocketChannel 顾名思义,是用在服务器端的,可以监听客户端的 TCP 连接,例如:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
while(true){
SocketChannel socketChannel = serverSocketChannel.accept();
//do something with socketChannel...
}
关闭:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.close();
(1) 监听连接
我们可以使用ServerSocketChannel.accept()方法来监听客户端的 TCP 连接请求,accept()方法会阻塞,直到有连接到来,当有连接时,这个方法会返回一个 SocketChannel 对象::
while(true){
SocketChannel socketChannel = serverSocketChannel.accept();
//do something with socketChannel...
}
(2) 非阻塞模式
在非阻塞模式下,accept()是非阻塞的,因此如果此时没有连接到来,那么 accept()方法会返回null:
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(9999));
serverSocketChannel.configureBlocking(false);
while(true){
SocketChannel socketChannel = serverSocketChannel.accept();
if(socketChannel != null){
//do something with socketChannel...
}
}
DatagramChannel
DatagramChannel 是用来处理 UDP 连接的。
(1) 打开
DatagramChannel channel = DatagramChannel.open();
channel.socket().bind(new InetSocketAddress(9999));
(2) 读取数据
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
channel.receive(buf);
(3) 发送数据
String newData = "New String to write to file..." + System.currentTimeMillis();
ByteBuffer buf = ByteBuffer.allocate(48);
buf.clear();
buf.put(newData.getBytes());
buf.flip();
int bytesSent = channel.send(buf,new InetSocketAddress("example.com",80));
连接到指定地址
因为 UDP 是非连接的,因此这个的 connect 并不是向 TCP 一样真正意义上的连接,因此我们仅仅可以从指定的地址中读取或写入数据.
channel.connect(new InetSocketAddress("example.com",80));
Selector
Selector 一般称 为选择器 ,当然你也可以翻译为 多路复用器 。它是Java NIO核心组件中的一个,用于检查一个或多个NIO Channel(通道)的状态是否处于可读、可写。如此可以实现单线程管理多个channels,也就是可以管理多个网络链接。
使用Selector的好处在于: 使用更少的线程来就可以来处理通道了, 相比使用多个线程,避免了线程上下文切换带来的开销。
注意:
写就绪相对有一点特殊,一般来说,你不应该注册写事件。写操作的就绪条件为底层缓冲区有空闲空间,而写缓冲区绝大部分时间都是有空闲空间的,所以当你注册写事件后,写操作一直是就绪的,选择处理线程全占用整个CPU资源。所以,只有当你确实有数据要写时再注册写操作,并在写完以后马上取消注册。
Selector的使用方法
(1) Selector的创建
通过调用Selector.open()方法创建一个Selector对象,如下:
Selector selector = Selector.open();
(2) 注册Channel到Selector
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, Selectionkey.OP_READ);
注意:Channel必须是非阻塞的。
所以FileChannel不适用Selector,因为FileChannel不能切换为非阻塞模式,更准确的来说是因为FileChannel没有继承SelectableChannel。Socket channel可以正常使用。
SelectableChannel抽象类 有一个 configureBlocking() 方法用于使通道处于阻塞模式或非阻塞模式。
abstract SelectableChannel configureBlocking(boolean block)
register() 方法的第二个参数。这是一个“ interest集合 ”,意思是在通过Selector监听Channel时对什么事件感兴趣。可以监听四种不同类型的事件:
- Connect
- Accept
- Read
- Write
通道触发了一个事件意思是该事件已经就绪。比如某个Channel成功连接到另一个服务器称为“ 连接就绪 ”。一个Server Socket Channel准备好接收新进入的连接称为“ 接收就绪 ”。一个有数据可读的通道可以说是“ 读就绪 ”。等待写数据的通道可以说是“ 写就绪 ”。
这四种事件用SelectionKey的四个常量来表示:
SelectionKey.OP_CONNECT
SelectionKey.OP_ACCEPT
SelectionKey.OP_READ
SelectionKey.OP_WRITE
如果你对不止一种事件感兴趣,使用或运算符即可,如下:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
SelectionKey介绍
一个SelectionKey键表示了一个特定的通道对象和一个特定的选择器对象之间的注册关系。
key.attachment(); //返回SelectionKey的attachment,attachment可以在注册channel的时候指定。
key.channel(); // 返回该SelectionKey对应的channel。
key.selector(); // 返回该SelectionKey对应的Selector。
key.interestOps(); //返回代表需要Selector监控的IO操作的bit mask
key.readyOps(); // 返回一个bit mask,代表在相应channel上可以进行的IO操作。
(1) key.interestOps()
我们可以通过以下方法来判断Selector是否对Channel的某种事件感兴趣:
int interestSet = selectionKey.interestOps();
boolean isInterestedInAccept = (interestSet & SelectionKey.OP_ACCEPT) == SelectionKey.OP_ACCEPT;
boolean isInterestedInConnect = interestSet & SelectionKey.OP_CONNECT;
boolean isInterestedInRead = interestSet & SelectionKey.OP_READ;
boolean isInterestedInWrite = interestSet & SelectionKey.OP_WRITE;
(2) key.readyOps()
ready 集合是通道已经准备就绪的操作的集合。JAVA中定义以下几个方法用来检查这些操作是否就绪.
//创建ready集合的方法
int readySet = selectionKey.readyOps();
//检查这些操作是否就绪的方法
key.isAcceptable();//是否可读,是返回 true
boolean isWritable()://是否可写,是返回 true
boolean isConnectable()://是否可连接,是返回 true
boolean isAcceptable()://是否可接收,是返回 true
从SelectionKey访问Channel和Selector很简单。如下:
Channel channel = key.channel();
Selector selector = key.selector();
key.attachment();
可以将一个对象或者更多信息附着到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下:
key.attach(theObject);
Object attachedObj = key.attachment();
还可以在用register()方法向Selector注册Channel的时候附加对象。如:
SelectionKey key = channel.register(selector, SelectionKey.OP_READ, theObject);
从Selector中选择channel
选择器维护注册过的通道的集合,并且这种注册关系都被封装在SelectionKey当。
1、Selector维护的三种类型SelectionKey集合
- 已注册的键的集合(Registered key set):所有与选择器关联的通道所生成的键的集合称为已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过 keys() 方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引发java.lang.UnsupportedOperationException。
- 已选择的键的集合(Selected key set):所有与选择器关联的通道所生成的键的集合称为已经注册的键的集合。并不是所有注册过的键都仍然有效。这个集合通过 keys() 方法返回,并且可能是空的。这个已注册的键的集合不是可以直接修改的;试图这么做的话将引发java.lang.UnsupportedOperationException。
- 已取消的键的集合(Cancelled key set):已注册的键的集合的子集,这个集合包含了 cancel() 方法被调用过的键(这个键已经被无效化),但它们还没有被注销。这个集合是选择器对象的私有成员,因而无法直接访问。
当键被取消( 可以通过isValid( ) 方法来判断)时,它将被放在相关的选择器的已取消的键的集合里。注册不会立即被取消,但键会立即失效。当再次调用 select( ) 方法时(或者一个正在进行的select()调用结束时),已取消的键的集合中的被取消的键将被清理掉,并且相应的注销也将完成。通道会被注销,而新的SelectionKey将被返回。当通道关闭时,所有相关的键会自动取消(记住,一个通道可以被注册到多个选择器上)。当选择器关闭时,所有被注册到该选择器的通道都将被注销,并且相关的键将立即被无效化(取消)。一旦键被无效化,调用它的与选择相关的方法就将抛出CancelledKeyException。
2、select()方法介绍
在刚初始化的Selector对象中,这三个集合都是空的。 通过Selector的select()方法可以选择已经准备就绪的通道 (这些通道包含你感兴趣的的事件)。比如你对读就绪的通道感兴趣,那么select()方法就会返回读事件已经就绪的那些通道。下面是Selector几个重载的select()方法:
- int select():阻塞到至少有一个通道在你注册的事件上就绪了。
- int select(long timeout):和select()一样,但最长阻塞时间为timeout毫秒。
- int selectNow():非阻塞,只要有通道就绪就立刻返回。
select()方法返回的int值表示有多少通道已经就绪,是自上次调用select()方法后有多少通道变成就绪状态。之前在select()调用时进入就绪的通道不会在本次调用中被记入,而在前一次select()调用进入就绪但现在已经不在处于就绪的通道也不会被记入。例如:首次调用select()方法,如果有一个通道变成就绪状态,返回了1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回1。如果对第一个就绪的channel没有做任何操作,现在就有两个就绪的通道,但在每次select()方法调用之间,只有一个通道就绪了。
一旦调用select()方法,并且返回值不为0时,则 可以通过调用Selector的selectedKeys()方法来访问已选择键集合 。如下:
Set selectedKeys=selector.selectedKeys();
进而可以放到和某SelectionKey关联的Selector和Channel。如下所示:
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
if(key.isAcceptable()) {
// a connection was accepted by a ServerSocketChannel.
} else if (key.isConnectable()) {
// a connection was established with a remote server.
} else if (key.isReadable()) {
// a channel is ready for reading
} else if (key.isWritable()) {
// a channel is ready for writing
}
keyIterator.remove();
}
停止选择的方法
选择器执行选择的过程,系统底层会依次询问每个通道是否已经就绪,这个过程可能会造成调用线程进入阻塞状态,那么我们有以下三种方式可以唤醒在select()方法中阻塞的线程。
- wakeup()方法 :通过调用Selector对象的wakeup()方法让处在阻塞状态的select()方法立刻返回
该方法使得选择器上的第一个还没有返回的选择操作立即返回。如果当前没有进行中的选择操作,那么下一次对select()方法的一次调用将立即返回。 - close()方法 :通过close()方法关闭Selector,
该方法使得任何一个在选择操作中阻塞的线程都被唤醒(类似wakeup()),同时使得注册到该Selector的所有Channel被注销,所有的键将被取消,但是Channel本身并不会关闭。
四、传统IO和NIO的区别
传统的IO处理方式:一个线程处理一个网络连接
基于阻塞式I/O的多线程模型中,Server为每个Client连接创建一个处理线程,每个处理线程阻塞式等待可能达到的数据,一旦数据到达,则立即处理请求、返回处理结果并再次进入等待状态。由于每个Client连接有一个单独的处理线程为其服务,因此可保证良好的响应时间。但当系统负载增大(并发请求增多)时,Server端需要的线程数会增加,对于操作系统来说,线程之间上下文切换的开销很大,而且每个线程都要占用系统的一些资源(如内存)。因此,使用的线程越少越好。
但是,现代的操作系统和CPU在多任务方面表现的越来越好,所以多线程的开销随着时间的推移,变得越来越小了。实际上,如果一个CPU有多个内核,不使用多任务可能是在浪费CPU能力。
NIO处理方式:一个线程可以管理过个网络连接
NIO服务器端如何实现非阻塞?
服务器上所有Channel需要向Selector注册,而Selector则负责监视这些Socket的IO状态(观察者),当其中任意一个或者多个Channel具有可用的IO操作时,该Selector的select()方法将会返回大于0的整数,该整数值就表示该Selector上有多少个Channel具有可用的IO操作,并提供了selectedKeys()方法来返回这些Channel对应的SelectionKey集合(一个SelectionKey对应一个就绪的通道)。正是通过Selector,使得服务器端只需要不断地调用Selector实例的select()方法即可知道当前所有Channel是否有需要处理的IO操作。注:java NIO就是多路复用IO,jdk7之后底层是epoll模型。
五、Java NIO的简单实现
FileChannel的使用
1、写文件操作
/**
* 写文件
*/
public class FileChannelTest {
public static void main(String[] args) throws IOException {
String str = "test file channel, 测试file channel";
// 创建一个输出流
FileOutputStream fileOutputStream = new FileOutputStream("D://nio.txt");
// 得到file channel
FileChannel fileChannel = fileOutputStream.getChannel();
// 创建一个缓存buffer
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
// 将数据放到buffer中
byteBuffer.put(str.getBytes());
// 反转, read -> write
byteBuffer.flip();
// 将buffer中的数据写入到file channel
fileChannel.write(byteBuffer);
fileChannel.close();
}
}
2、读文件操作
/**
* 读文件
*/
public class FileChannelTest02 {
public static void main(String[] args) throws Exception {
File file = new File("D://nio.txt");
// 创建一个输入流
FileInputStream fileInputStream = new FileInputStream(file);
// 得到file channel
FileChannel fileChannel = fileInputStream.getChannel();
// 创建一个缓存buffer
ByteBuffer byteBuffer = ByteBuffer.allocate((int) file.length());
// 将file channel 中的数据读入到buffer中
fileChannel.read(byteBuffer);
byte[] array = byteBuffer.array();
String data = new String(array);
System.out.println(data);
fileChannel.close();
}
}
3、使用FileChannel拷贝文件
/**
* 文件的拷贝,用一个buffer完成的读写
*/
public class CopyFileTest03 {
public static void main(String[] args) throws Exception {
FileInputStream fileInputStream = new FileInputStream("src/1.txt");
FileChannel channel = fileInputStream.getChannel();
FileOutputStream fileOutputStream = new FileOutputStream("2.txt");
FileChannel channel1 = fileOutputStream.getChannel();
ByteBuffer buffer = ByteBuffer.allocate(1024);
/**
* 下面的代码存在bug , 只会读写一次,如果文件内容大于1024, 后面的内容就会丢失
*/
// // 将channel中的数据读到buffer中
// channel.read(buffer);
// buffer.flip();
// // 将缓冲区的数据读取到通道中
// channel1.write(buffer);
while (true) {
// 如果调用clear()方法,会出现死循环, read == 0 .
buffer.clear();
int read = channel.read(buffer);
System.out.println(read);
if (read == -1) {
break;
}
// 数据从buffer中写到channel中
buffer.flip();
channel1.write(buffer);
}
fileInputStream.close();
fileOutputStream.close();
}
}
4、调用FileChannel的API完成文件拷贝
/**
* 使用transferFrom 和 transferTo 拷贝文件
*
*/
public class CopyFileTest04 {
public static void main(String[] args) throws Exception {
FileInputStream fileInputStream = new FileInputStream("1.jpg");
FileChannel inputStreamChannel = fileInputStream.getChannel();
FileOutputStream fileOutputStream = new FileOutputStream("2.jpg");
FileChannel outputStreamChannel = fileOutputStream.getChannel();
// 文件拷贝
// outputStreamChannel.transferFrom(inputStreamChannel, 0, inputStreamChannel.size());
inputStreamChannel.transferTo(0, inputStreamChannel.size(), outputStreamChannel);
// 关闭通道和流
fileInputStream.close();
fileOutputStream.close();
}
}
注意:
读写同一个buffer时,需要flip();
读写1次buffer之后,需要clear(), 将buferr复位
Scatter / Gather
Channel 提供了一种被称为 Scatter/Gather 的新功能,也称为本地矢量 I/O。Scatter/Gather 是指在多个缓冲区上实现一个简单的 I/O 操作。正确使用 Scatter / Gather可以明显提高性能。
大多数现代操作系统都支持本地矢量I/O(native vectored I/O)操作。当您在一个通道上请求一个Scatter/Gather操作时,该请求会被翻译为适当的本地调用来直接填充或抽取缓冲区,减少或避免了缓冲区拷贝和系统调用;
Scatter/Gather应该使用直接的ByteBuffers以从本地I/O获取最大性能优势。
Selector选择器的模板代码
1、一个服务端的模板代码
有了模板代码我们在编写程序时,大多数时间都是在模板代码中添加相应的业务代码。
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress("localhost", 8080));
ssc.configureBlocking(false);
Selector selector = Selector.open();
ssc.register(selector, SelectionKey.OP_ACCEPT);
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();
}
}
2、客户端与服务端简单交互示例
① 服务端
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;
public class WebServer {
public static void main(String[] args) {
try {
ServerSocketChannel ssc = ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress("127.0.0.1", 8000));
ssc.configureBlocking(false);
Selector selector = Selector.open();
// 注册 channel,并且指定感兴趣的事件是 Accept
ssc.register(selector, SelectionKey.OP_ACCEPT);
ByteBuffer readBuff = ByteBuffer.allocate(1024);
ByteBuffer writeBuff = ByteBuffer.allocate(128);
writeBuff.put("received".getBytes());
writeBuff.flip();
while (true) {
int nReady = selector.select();
Set<SelectionKey> keys = selector.selectedKeys();
Iterator<SelectionKey> it = keys.iterator();
while (it.hasNext()) {
SelectionKey key = it.next();
it.remove();
if (key.isAcceptable()) {
// 创建新的连接,并且把连接注册到selector上,而且,
// 声明这个channel只对读操作感兴趣。
SocketChannel socketChannel = ssc.accept();
socketChannel.configureBlocking(false);
socketChannel.register(selector, SelectionKey.OP_READ);
}
else if (key.isReadable()) {
SocketChannel socketChannel = (SocketChannel) key.channel();
readBuff.clear();
socketChannel.read(readBuff);
readBuff.flip();
System.out.println("received : " + new String(readBuff.array()));
key.interestOps(SelectionKey.OP_WRITE);
}
else if (key.isWritable()) {
writeBuff.rewind();
SocketChannel socketChannel = (SocketChannel) key.channel();
socketChannel.write(writeBuff);
key.interestOps(SelectionKey.OP_READ);
}
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
② 客户端
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
public class WebClient {
public static void main(String[] args) throws IOException {
try {
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("127.0.0.1", 8000));
ByteBuffer writeBuffer = ByteBuffer.allocate(32);
ByteBuffer readBuffer = ByteBuffer.allocate(32);
writeBuffer.put("hello".getBytes());
writeBuffer.flip();
while (true) {
writeBuffer.rewind();
socketChannel.write(writeBuffer);
readBuffer.clear();
socketChannel.read(readBuffer);
}
} catch (IOException e) {
}
}
}
先运行服务端,再运行客户端,服务端会不断收到客户端发送过来的消息。
六、如何结合事件模型使用NIO同步非阻塞特性
传统的服务器端同步阻塞I/O处理(也就是BIO,Blocking I/O)的经典编程模型:
{
ExecutorService executor = Excutors.newFixedThreadPollExecutor(100);//线程池
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(8088);
while(!Thread.currentThread.isInturrupted()){//主线程死循环等待新连接到来
Socket socket = serverSocket.accept();
executor.submit(new ConnectIOnHandler(socket));//为新的连接创建新的线程
}
class ConnectIOnHandler extends Thread{
private Socket socket;
public ConnectIOnHandler(Socket socket){
this.socket = socket;
}
public void run(){
while(!Thread.currentThread.isInturrupted()&&!socket.isClosed()){死循环处理读写事件
String someThing = socket.read()....//读取数据
if(someThing!=null){
......//处理数据
socket.write()....//写数据
}
}
}
}
这是一个经典的每连接每线程的模型,之所以使用多线程,主要原因在于socket.accept()、socket.read()、socket.write()三个主要函数都是同步阻塞的,当一个连接在处理I/O的时候,系统是阻塞的,如果是单线程的话必然就挂死在那里;但CPU是被释放出来的,开启多线程,就可以让CPU去处理更多的事情。其实这也是所有使用多线程的本质: 1. 利用多核。 2. 当I/O阻塞系统,但CPU空闲的时候,可以利用多线程使用CPU资源。
现在的多线程一般都使用线程池,可以让线程的创建和回收成本相对较低。在活动连接数不是特别高(小于单机1000)的情况下,这种模型是比较不错的,可以让每一个连接专注于自己的I/O并且编程模型简单,也不用过多考虑系统的过载、限流等问题。线程池本身就是一个天然的漏斗,可以缓冲一些系统处理不了的连接或请求。
不过,这个模型最本质的问题在于,严重依赖于线程。但线程是很”贵”的资源,主要表现在:
- 线程的创建和销毁成本很高,在Linux这样的操作系统中,线程本质上就是一个进程。创建和销毁都是重量级的系统函数。
- 线程本身占用较大内存,像Java的线程栈,一般至少分配512K~1M的空间,如果系统中的线程数过千,恐怕整个JVM的内存都会被吃掉一半。
- 线程的切换成本是很高的。操作系统发生线程切换的时候,需要保留线程的上下文,然后执行系统调用。如果线程数过高,可能执行线程切换的时间甚至会大于线程执行的时间,这时候带来的表现往往是系统load偏高、CPU sy使用率特别高(超过20%以上),导致系统几乎陷入不可用的状态。
- 容易造成锯齿状的系统负载。因为系统负载是用活动线程数或CPU核心数,一旦线程数量高但外部网络环境不是很稳定,就很容易造成大量请求的结果同时返回,激活大量阻塞线程从而使系统负载压力过大。
所以,当面对十万甚至百万级连接的时候,传统的BIO模型是无能为力的。随着移动端应用的兴起和各种网络游戏的盛行,百万级长连接日趋普遍,此时,必然需要一种更高效的I/O处理模型。
NIO的读写函数可以立刻返回,这就给了我们不开线程利用CPU的最好机会:如果一个连接不能读写(socket.read()返回0或者socket.write()返回0),我们可以把这件事记下来,记录的方式通常是在Selector上注册标记位,然后切换到其它就绪的连接(channel)继续进行读写。
下面具体看下如何利用事件模型单线程处理所有I/O请求:
NIO的主要事件有几个:读就绪、写就绪、有新连接到来。
我们首先需要注册当这几个事件到来的时候所对应的处理器。然后在合适的时机告诉事件选择器:我对这个事件感兴趣。
其次,用一个死循环选择就绪的事件,会执行系统调用(Linux 2.6之前是select、poll,2.6之后是epoll,Windows是IOCP),还会阻塞的等待新事件的到来。新事件到来的时候,会在selector上注册标记位,标示可读、可写或者有连接到来。
注意,select是阻塞的,无论是通过操作系统的通知(epoll)还是不停的轮询(select,poll),这个函数是阻塞的。所以你可以放心大胆地在一个while(true)里面调用这个函数而不用担心CPU空转。
所以我们的程序大概的模样是:
interface ChannelHandler{
void channelReadable(Channel channel);
void channelWritable(Channel channel);
}
class Channel{
Socket socket;
Event event;//读,写或者连接
}
//IO线程主循环:
class IoThread extends Thread{
public void run(){
Channel channel;
while(channel=Selector.select()){//选择就绪的事件和对应的连接
if(channel.event==accept){
registerNewChannelHandler(channel);//如果是新连接,则注册一个新的读写处理器
}
if(channel.event==write){
getChannelHandler(channel).channelWritable(channel);//如果可以写,则执行写事件
}
if(channel.event==read){
getChannelHandler(channel).channelReadable(channel);//如果可以读,则执行读事件
}
}
}
Map<Channel,ChannelHandler> handlerMap;//所有channel的对应事件处理器
}
这个程序很简短,也是最简单的Reactor模式:注册所有感兴趣的事件处理器,单线程轮询选择就绪事件,执行事件处理器。
七、参考
https://www.cnblogs.com/ZhuChangwu/p/11150492.html
https://tech.meituan.com/2016/11/04/nio.html