IO 是主存和外部设备 ( 硬盘、终端和网络等 ) 拷贝数据的过程。 IO 是操作系统的底层功能实现,底层通过 I/O 指令进行完成。 所有语言运行时系统提供执行I/O较高级别的工具。在java编程中,标准低版本IO使用流的方式完成I/O操作,所有的I/O都被视为单个的字节流动,称为一个Stream的对象一次移动一个字节。
- NIO有如下特性:
- 为所有的原始类型提供(Buffer)缓存支持;
- 字符集编码解决方案(Charset);
- Channel : 一个新的原始I/O抽象;
- 支持锁和内存映射文件的文件访问接口;
- 提供多路(non-bloking)非阻塞式的高伸缩性网路I/O。
- NIO4个关键的抽象数据类型:
- Buffer:它是包含数据且用于读写的线形表结构。其中还提供了一个特殊类用于内存映射文件的I/O操作。
- Charset:它提供Unicode字符串影射到字节序列以及逆影射的操作。
- Channels:包含socket,file和pipe三种管道,它实际上是双向交流的通道。
- Selector:它将多元异步I/O操作集中到一个或多个线程中。
1. Buffer(缓冲区)
标准IO基于字节流和字符流进行操作,而NIO基于通道(Channel)和缓冲区(Buffer)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。
通道Channel是对原IO中流的模拟,所有数据都要通过通道进行传输;Buffer实质上是一个容器对象,发送给通道的所有对象都必须首先放到一个缓冲区中。
1.1 Buffer
缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不仅仅是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。
简单的说Buffer是:一块连续的内存块,是NIO数据读或写的中转地。
1.2 Buffer类图
1.3 Buffer在JDK中实现
抽象类,共有5个属性,分别是mark、position、limit、capacity、address
并且还有一行注释:// Invariants: mark <= position <= limit <= capacity
一个 buffer 主要由 position、limit、capacity 三个变量来控制读写的过程。
参数 | 读模式 | 写模式 |
---|---|---|
position | 当前写入的单位数据的数量 | 当前读入的单位数据的数量 |
limit | 代表最多能写多少单位的数据量,默认和capacity一致 | 代表最多能读多少单位的数据量,和之前写入的数据量一致 |
capacity | Buffer的容量 | Buffer的容量 |
1.4 Buffer方法
属性操作
最基本的对应属性操作的方法,查看源码知道要得到当前Buffer的limit值使用public final int limit()方法,设定limit的值使用public final Buffer limit(int)方法,其它属性有对应的方法。
public final Buffer flip() : 用于将写模式转换成读模式
limit = position; //将limit设置为刚才写入的位置
position = 0; //将position设置为0从头开始读
mark = -1;
return this;
public final Buffer clear() : 用于清空缓冲区,准备再次被写入,limit设置为capacity,position设置为0
public final Buffer rewind() : 源码实现为position=0,mark=-1。目的是为了重复读。
public fremaininginal int () : return limit - position;
public final boolean hasRemaining() : return position < limit;
继承自Buffer的重要类ByteBuffer类中的方法:
首先可以看到在ByteBuffer类中多了三个属性,一个byte数组型的,一个int型的offset,还有一个boolean型的isReadOnly,两个构造函数均是Package-private型的。
产生ByteBuffer对象
-
方法一:ByteBuffer bbuf = ByteBuffer.allocate(1024); 查看源码知道allocate执行这样一句话: return new HeapByteBuffer(int capacity, int capacity); 而HeapByteBuffer又是ByteBuffer的子类,并且在HeapByteBuffer的构造方法中执行的是这样一个语句: super(-1, 0, lim, cap, new byte[cap], 0); 也就是说调用的还是ByteBuffer中的构造方法,包范围内使用。这个方法做了如下工作,首先调用Buffer的构造方法,依次初始化mark、position、limit、capacity,然后初始化ByteBuffer的属性byte数组,接着初始offset,这样使用allocate方法就可以构造出一个ByteBuffer对象了。
-
方法二:ByteBuffer bbuf = ByteBuffer.wrap(new Byte[1024] array, 0, 1024); 这个方法比较好用的一点是当这个Byte数组已经存在的话,直接传入这个Byte数组,然后传入起始值和结束值即可。默认wrap实现是初始值传入为0,结束值传入为Byte数组的长度array.length。
ByteBuffer其它重要方法
get(byte[] dst) 或者 get(byte[] dst, int offset, int len) (该方法是用来获取当前ByteBuffer中的指定位置的数据并赋值给dst,最终返回当前对象本身。方法实现时第一步检查参数是否合法,调用的是checkBounds静态包范围私有方法。然后检查len是否大于remaining,接着对dst数组循环赋值,最终返回该对象。)
put(byte[] src) 或者 put(byte[] src, int offerset, int len) (该方法和上面的一对get方法类似,功能是将已有的byte数组从0位置开始放入当前的ByteBuffer中,最终返回ByteBuffer本身。)
put(ByteBuffer src) (该方法将src的remaining逐个放入当前ByteBuffer中,最终返回当前ByteBuffer。)
除此之外还有类型化的get方法,例如getInt(), getFloat(), getShort()等。
1.5 Buffer更多
缓冲区分片:slice()方法根据现有的缓冲区创建一种子缓冲区,新的缓冲区与原缓冲区共享部分数据。
只读缓冲区:可以通过调用缓冲区的 asReadOnlyBuffer() 方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区(并与其共享数据),只不过它是只读的。只读缓冲区对于保护数据很有用。没有办法将只读缓冲区改变为可写的。 下面例子对缓冲区进行分片,并操作数据:
//产生一个ByteBuffer实例
ByteBuffer buffer = ByteBuffer.allocate( 10 );
//对该ByteBuffer实例进行初始化
for (int i=0; i<buffer.capacity(); ++i) {
buffer.put( (byte)i );
}
//修改buffer的position(起点)和limit(终点)
buffer.position( 3 );
buffer.limit( 7 );
//对缓冲区进行分片
ByteBuffer slice = buffer.slice();
//对分片的数据进行操作
for (int i=0; i<slice.capacity(); ++i) {
byte b = slice.get( i );
b *= 11;
slice.put( i, b );
}
//重新定位并输出结果
buffer.position( 0 );
buffer.limit( buffer.capacity() );
while (buffer.remaining()>0) {
System.out.println( buffer.get() );
}
运行结果:
0
1
2
33
44
55
66
7
8
9
直接或者间接缓冲区:直接缓冲区可以加快I/O的读写速度,使用allocateDirect(int capacity)产生一个直接缓冲区。
内存映射文件:下面代码将一个 FileChannel (它的全部或者部分)映射到内存 中。将文件的前1024个字节映射到内存中: MappedByteBuffer mbb = fc.map( FileChannel.MapMode.READ_WRITE, 0, 1024 );
2 Channel(通道)
2.1 Channel
Channel 是一个对象,可以通过它读取和写入数据。拿 NIO 与原来的 I/O 做个比较,通道就像是流。
所有数据都通过 Buffer 对象来处理。您永远不会将字节直接写入通道中,相反,您是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
简单的说Channel是:数据的源头或者数据的目的地,用于向buffer提供数据或者读取buffer数据,并且对I/O提供异步支持。
2.2 Channel类图
java.nio.channels.Channel是一个公共的接口,所有子Channel均实现了该接口,在java.nio.channels包中还实现了Channels、FileLock、SelectionKey、Selector、Pipe等比较好用的类。包含socket,file和pipe三种管道,它实际上是双向交流的通道。
2.3 Channel在JDK中实现
在Channel接口中共定义了两个方法
public boolean isOpen(); //Tells whether or not this channel is open
public void close() throws IOException(); //Close this channel
FileChannel : 使用以下三个方法可以得到一个FileChannel的实例
FileInputStream.getChannel()
FileOutputStream.getChannel()
RandomAccessFile.getChannel()
上面提到Channel是数据的源头或者数据的目的地,用于向bufer提供数据或者从buffer读取数据。那么在实现了该接口的子类中应该有相应的read和write方法。
在FileChannel中有以下方法可以使用:
public long read(ByteBuffer[] dsts)
Reads a sequence of bytes from this channel into the given buffers.
public long write(ByteBuffer[] srcs)
Writes a sequence of bytes to this channel from the given buffers.
附加:文件锁定 FileChannel提供两种方法获得FileLock
FileLock lock();
FileLock lock(long position, long size, boolean size);
使用方法举例:
要获取文件的一部分上的锁,您要调用一个打开的 FileChannel 上的 lock() 方法。注意,如果要获取一个排它锁,您必须以写方式打开文件。
RandomAccessFile raf = new RandomAccessFile( "filelocks.txt", "rw" );
FileChannel fc = raf.getChannel();
FileLock lock = fc.lock( start, end, false );
在拥有锁之后,您可以执行需要的任何敏感操作,然后再释放锁:
lock.release();
SocketChannel : 使用以下两个方法得到一个SocketChannel的实例
SocketChannel.open(); //打开一个socket channel
SocketChannel.open(SocketAddress remote);
//调用上面的方法,并connect(remote) 例子代码:
InetSocketAddress socketAddress = new InetSocketAddress(“www.baidu.com”,80);
SocketChannel sc = SocketChannel.open(socketAddress);
sc.read(buffer);
buffer.flip();
buffer.clear();
sc.write(bufer);
DatagramChannel : 与其它的Channel有相同或者相似的方法。
3 Charset(字符集)
3.1 Charset(字符集)
在java.nio.charset包中共提供了Charset、CharsetDecoder、CharsetEncoder、CodeResult、CodingErrorAction五个类,均继承自Object类,其中Charset实现了Comparable接口,其它类均为自身实现。
Java中的字符使用unicode编码,每个字符占用两个字节。字节码本身只是一些数字,放在正确的上下文中可以被正确的解析。向ByteBuffer中存放数据时需要考虑字符集的编码方式,从中读取时需要考虑字符集的解码。
要读和写文本需要分别使用CharsetDecoder(解码器)和CharsetEncoder(编码器)。
编码:百科中这样定义,编码(coding)是在一个主题或单元上为数据存储,管理和分析的目的而转换信息为编码值(典型地如数字)的过程。在密码学中,编码是指在编码或密码中写的行为。n位二进制数可以组合成2的n次方个不同的信息,给每个信息规定一个具体码组,这种过程也叫编码。数字系统中常用的编码有两类,一类是二进制编码,另一类是二—十进制编码。
(1)如何得到一个CharSet?
在JDK源码中提供两种方式得到一个CharSet实例:
CharSet cs = CharSet.forName(“编码方式”);
CharSet cs = CharSet.defaultCharSet();
第一种方法返回一个指定字符格式的CharSet,第二种方法返回当前虚拟机默认的字符编码格式的CharSet。
(2)如何使用CharSet?
得到一个CharSet实例后,我们需要创建一个编码器和一个解码器,使用下面方法进行创建:
CharSetDecoder decoder = cs.newDecoder();
CharSetEncoder encoder = cs.newEncoder();
接着我们把ByteBuffer传递给decoder进行编码,返回一个CharBuffer:
CharBuffer cb = decoder.decode(inputData);
然后我们可以使用encoder进行解码返回一个ByteBuffer:
ByteBuffer outputData = encoder.encode(cb);
接下来可以进行写等其它操作。
4 Selector
4.1 Selector
在传统的IO模式中,需要启动多个线程以阻塞的方式监听Socket,等待数据到达并进行处理。在NIO中,Selector相当于一个事件管理器,可以向它注册不同连接的多个事件,并以一定间隔询问Selector是否有注册的事件发生,再进行后续的处理。这样只需要一个阻塞的线程,就可以管理所有的连接了。
Selector管理的事件包含两个要素:
- 事件所对应的连接,以SocketChannel作为描述。
- 事件的类型,SelectionKey.OP_* 常量之一。 两个要素唯一确定一个发生的事件。
当调用 int n = selector.select() 方法时,返回值n就是新产生的事件个数。调用 selector.selectedKeys() 获取具体事件集合,每一个事件对应一个 SelectionKey 对象。通过 SelectionKey 获取对应的 SocketChannel 以及事件的类型,接下来就可以进行建立连接、读取数据等操作。
Java NIO引入了选择器的概念,选择器用于监听多个通道的事件(比如:连接打开,数据到达)。因此,单个的线程可以监听多个数据通道。
异步IO是一种没有阻塞的读写数据的方法,通常,在代码进行 read() 调用时,代码会阻塞直至有可供读取的数据。同样,write() 调用将会阻塞直至数据能够写入。 异步 I/O 的一个优势在于,它允许您同时根据大量的输入和输出执行 I/O。同步程序常常要求助于轮询,或者创建许许多多的线程以处理大量的连接。使用异步 I/O,您可以监听任何数量的通道上的事件,不用轮询,也不用额外的线程。
异步 I/O 中的核心对象名为 Selector。Selector 就是您注册对各种 I/O 事件的地方,而且当那些事件发生时,就是这个对象告诉您所发生的事件。
Selector本身为抽象类,AbstractSelector是Selector类的抽象实现类,具体的实现类更加底层(SelectorImpl,位于sun.nio.ch);Selector即为”选择器”,支撑了NIO的多路复用.
Selector不能直接创建,需要通过Selector.open()获得,该方法将使用系统默认的选择器提供者创建新的选择器(SelectorProvider),可以通过选择器的close方法关闭它. 通过SelectionKey对象来表示可选择通道(SelectableChannel)到选择器的注册.SelectionKey由Selector维护.
通常一个Channel只会被注册到一个Selector,一个Selector可以“监测”多个Channel;事实上Channel可以注册在任意一个Selector上,ServerSocketChannel和SocketChannel可以共用一个Selector,也可以各自使用不同的。
- 第一步:创建一个Selector
Selector selector = Selector.open();
- 第二步:打开一个远程连接
InetSocketAddress socketAddress = new InetSocketAddress("www.baidu.com", 80); SocketChannel sc = SocketChannel.open(socketAddress); sc.configureBlocking(false);
- 第三步:选择键,注册
SelectionKey key = sc.register(selector, SelectionKey.OP_CONNECT);
注册时第一个参数总是当前的这个selector。 注册读事件:SelectionKey key = sc.register(selector, SelectionKey.OP_READ); 注册写事件:SelectionKey key = sc.register(selector, SelectionKey.OP_WRITE);
- 第四步:内部循环处理
int num = selector.select(); Set selectedKeys = selector.selectedKeys(); Iterator it = selectedKeys.iterator(); while (it.hasNext()) { SelectionKey key = (SelectionKey)it.next(); // ... deal with I/O event ... }
首先,我们调用 Selector 的 select() 方法。这个方法会阻塞,直到至少有一个已注册的事件发生。当一个或者更多的事件发生时, select() 方法将返回所发生的事件的数量。该方法必须首先执行。
接下来,我们调用 Selector 的 selectedKeys() 方法,它返回发生了事件的 SelectionKey 对象的一个 集合 。
我们通过迭代 SelectionKeys 并依次处理每个 SelectionKey 来处理事件。对于每一个 SelectionKey,您必须确定发生的是什么 I/O 事件,以及这个事件影响哪些 I/O 对象。
-
第五步:监听事件并做出处理 SelectionKey中共定义了四种事件,OP_ACCEPT(socket accept)、OP_CONNECT(socket connect)、OP_READ(read)、OP_WRITE(write)。
-
第六步:删除处理过的SelectionKey 在处理 SelectionKey 之后,我们几乎可以返回主循环了。但是我们必须首先将处理过的 SelectionKey 从选定的键集合中删除。
如果我们没有删除处理过的键,那么它仍然会在主集合中以一个激活的键出现,这会导致我们尝试再次处理它。
我们调用迭代器的 remove() 方法来删除处理过的 SelectionKey:it.remove();
参考资料
官网 https://docs.oracle.com/javase/7/docs/api/java/nio/package-summary.html
java nio学习笔记 http://blog.csdn.net/tsyj810883979/article/details/6876594
epoll 浅析以及 nio 中的 Selector http://www.importnew.com/24794.html
NIO-Selector类详解 http://shift-alt-ctrl.iteye.com/blog/1840411
Java NIO 的前生今世 之四 NIO Selector 详解 https://segmentfault.com/a/1190000006824196
Java NIO系列教程 http://ifeve.com/selectors/
深入浅出NIO Socket实现机制 http://www.jianshu.com/p/0d497fe5484a
Java NIO(6): Selector https://zhuanlan.zhihu.com/p/27434028
Java NIO浅析 https://tech.meituan.com/nio.html
Java NIO系列学习笔记(五) - Selector http://www.hifreud.com/2017/04/18/java-nio-05-selector/