NIO概述
阻塞IO
通常在进行同步I/O操作时,如果读取数据,代码会阻塞直到有可供读取的数据。同样,写入调用将会阻塞直到数据能够写入。传统的Server/Client模式会基于TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。
非阻塞IO(NIO)
NIO中的非阻塞采用了基于Reactor模式的工作方式,I/O调用不会被阻塞,只注册感兴趣的特定I/O事件,如可读数据到达、新的套接字连接等等。在发生特定事件时,系统再进行通知。NIO中实现非阻塞I/O的核心对象就是Selector,Selector就是注册各种I/O事件的地方,而当感兴趣的事件发生时,就是这个对象告知我们所发生的事件。
如图,当有读或写等任何注册的事件发生时,可以从Selector中获得相应的SelectionKey,同时从SelectionKey中可以找到发生的事件和该事件所发生的具体的SelectableChannel,以获得客户端发送过来的数据。
非阻塞IO指的是IO事件本身不阻塞,但获取IO事件的select()方法是需要阻塞等待的
区别是:阻塞IO会阻塞在IO操作上,NIO阻塞在事件获取上,没有事件就没有IO,从高层次看,IO就不阻塞了。
IO | NIO |
---|---|
面向流(Stream Oriented) | 面向缓冲区(Buffer Oriented) |
阻塞IO(Blocking IO) | 非阻塞IO(Non Blocking IO) |
(无) | 选择器(Selectors) |
三个核心部分
Channel
Channel和IO中的Stream是差不多一个等级的,只是Stream是单向的,如InputStream只能输入、OutputStream只能输出,而Channel是双向的,既能读又能写。
NIO中的Channel主要实现有:FileChannel(文件IO)、DatagramChannel(UDP IO)、SocketChannel(TCP Client )、ServerSocketChannel(TCP Server)
Buffer
Buffer表示缓冲区,NIO中的关键Buffer实现有:ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer
Selector
Selector运行单个线程处理多个Channel,如果应用打开了多个通道,但每个连接的流量都很低,使用Selector就会很方便。要使用Selector,需要向Selector注册Channel,然后调用它的select()
方法,这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件。
Channel
Channel接口
isOpen()
:表示通道是否打开close()
:关闭通道
与缓冲区不同,通道API主要由接口指定,不同的操作系统上通道实现会有根本性差异,所以通道API仅仅描述了可以做什么。
Channel是一个对象,可以通过它读取和写入数据。拿NIO与原来的I/O做个比较,通道就像是流,所有数据都通过Buffer对象来处理。永远不会将字节直接写入通道中,而是需要将数据定稿包含一个或多个字节的缓冲区,同样地也不会直接从通道中读取字节。
Java NIO的Channel与流的不同:
- 既可以从Channel中读取数据,又可以写数据到Channel,而流的读写通常是单向的
- Channel可以异步地读写
- Channel中的数据总是要先读到一个Buffer或总是要从一个Buffer写入
Channel实现
FileChannel
从文件中读写数据
常用方法
int read(ByteBuffer dst)
:从Channel中读取数据到ByteBufferlong read(ByteBuffer[] dsts)
:将Channel中的数据“分散”到ByteBuffer[]- 分散:指的是从Channel中读取时将读取的数据写入到多个buffer中
int write(ByteBuffer srt)
:将ByteBuffer中的数据写入到Channellong write(ByteBuffer[] srcs)
:将ByteBuffer[] 中的数据“聚集”到Channel- 聚集:指的是向Channel中写数据时,将多个buffer的数据写入到同一个channel
- 分散和聚集通常用于需要将传输的数据分开处理的场合,如传输一个由消息头和消息体组成的消息,可能会将消息体和消息头分散到不同的buffer中,可以方便处理。
long position()
:返回此通道的文件位置FileChannel position(long p)
设置此通道的文件位置(设置后去写数据可能使得文件撑大到当前位置,进而导致“文件空洞”,磁盘上物理文件中写入的数据间有空隙)long size()
:返回此通道的文件的当前大小FileChannel truncate(long s)
:将此通道的文件截取为给定大小void force(boolean metaData)
:强制将所有对此通道的文件更新写入到存储设备中- 参数
metaData
表示是否将文件元数据(权限信息等)写到磁盘上
- 参数
long transferTo(long position, long count, WritableByteChannel target)
:可以将数据从FileChannel传输到其它通道target中long transferFrom(ReadableByteChannel src,long position, long count)
:可以将数据从源通道src传输到FileChannel中
示例
1 | // 创建FileChannel |
文件复制案例:
1 | try (RandomAccessFile file = new RandomAccessFile("test.txt", "rw"); |
获取实例
- 通过InputStream获取:
FileChannel channel = new FileInputStream("test.txt").getChannel();
- 通过OutputStream获取:
FileChannel channel = new FileOutputStream("test.txt").getChannel();
- 通过RandomAccessFile获取:
new RandomAccessFile("test.txt", "rw").getChannel()
读写数据
int len = channel.read(buffer);
:表示从channel中读取数据到buffer,返回值len表示读取的长度,当返回值为-1时表示到达文件末尾channel.write(buffer);
:表示将buffer中的数据写入到channel中
ServerSocketChannel
可以监听新进来的TCP连接,像Web服务器那样,对每个新进来的连接都会创建一个SocketChannel
注:Datagramchannel和SocketChannel实现定义读和写功能的接口而ServerSocketChannel不实现。ServerSocketChannel负责监听传入的连接和创建新的SocketChannel对象,本身不传输数据。
Socket和Socket通道之间的关系:
就某个Socket而言,它不会再次实现与之对应的socket通道类中的socket协议API,而java.net中已经存在的socket通道都可以被大多数协议操作重复使用。
常用方法
ServerSocketChannel open()
:静态方法,打开ServerSocketChannelclose()
:关闭连接SocketChannel accept()
:监听新的连接,- 在阻塞模式中,当
accept()
方法返回时会返回一个新进来的连接SocketChannel,因此accept()
方法会一直阻塞到有新连接到达; - 在非阻塞模式中,
accept()
方法会立即返回,如果还没有新进来的连接,则返回null
- 在阻塞模式中,当
SelectableChannel configureBlocking(boolean block)
:设置是否开启阻塞模式,false表示非阻塞模式,默认是true
示例
1 | try (ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();) { |
SocketChannel
通过TCP读写网络中的数据
常用方法
SocketChannelopen()
:静态方法,打开SocketChannelboolean isOpen()
:是否打开boolean isConnected()
:是否连接boolean isConnectionPending()
:是否正在进行连接boolean finishConnect()
:是否已经完成连接SelectableChannel configureBlocking(boolean block)
:设置是否开启阻塞模式,false表示非阻塞模式,默认是true<T> SocketChannel setOption(SocketOption<T> name, T value)
:设置参数,可选项如:StandardSocketOptions.SO_SNDBUF
:套接字发送缓冲区大小StandardSocketOptions.SO_RCVBUF
:套接字接收缓冲区大小StandardSocketOptions.SO_KEEPALIVE
:保活连接StandardSocketOptions.SO_REUSEADDR
:复用地址StandardSocketOptions.SO_LINGER
:有数据传输时延缓关闭Channel(只有在非阻塞模式下有用)StandardSocketOptions.TCP_NODELAY
:禁用Nagle算法- ......
<T> T getOption(SocketOption<T> name)
:获取参数
示例
1 | int port = 80; |
获取实例
SocketChannel.open(SocketAddress remote)
:同时创建SocketChannel并开启TCP连接```java SocketChannel socketChannel = SocketChannel.open(); socketChannel.connect(SocketAddress remote);
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
先创建Channel再TCP连接
### DatagramChannel
> 通过UDP读写网络中的数据
#### 常用方法
- `DatagramChannel open()`:静态方法,打开DatagramChannel
- `SocketAddress receive(ByteBuffer dst)`:接收UDP包存入到dst
- `int send(ByteBuffer src, SocketAddress target)`:将src中的数据发送到target
- `DatagramChannel connect(SocketAddress remote)`:用于建立“连接”,向特定地址发送数据
- UDP并不存在真正意义上的连接,此外的连接指的是用于向特定地址发送数据,而不是TCP中所说的连接
- `int read(ByteBuffer dst)`:读数据,需要在connect()之后使用
- `int write(ByteBuffer src)`:写数据,需要在connect()之后使用
#### 示例
```java
public class DatagramChannelTest {
@Test
public void sendDatagram(){
try(DatagramChannel sendChannel = DatagramChannel.open()){
InetSocketAddress address = new InetSocketAddress("127.0.0.1", 8888);
while(true){
ByteBuffer buffer = ByteBuffer.wrap("hello".getBytes(StandardCharsets.UTF_8));
sendChannel.send(buffer, address);
System.out.println("已经完成发送");
Thread.sleep(2000);
}
} catch (IOException | InterruptedException e) {
throw new RuntimeException(e);
}
}
@Test
public void receiveDatagram(){
try(DatagramChannel receiveChannel = DatagramChannel.open()){
receiveChannel.bind(new InetSocketAddress(8888));
ByteBuffer buffer = ByteBuffer.allocate(1024);
while (true){
buffer.clear();
SocketAddress socketAddress = receiveChannel.receive(buffer);
buffer.flip(); // 模式转换
System.out.println(socketAddress.toString() + ":" + StandardCharsets.UTF_8.decode(buffer));
}
} catch (IOException e) {
throw new RuntimeException(e);
}
}
}
获取实例
- ```java DatagramChannel server = DatagramChannel.open(); server.socket().bind(new InetSocketAddress(8888));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# Buffer
> 本质上是一块可以写入数据,可以从中读取数据的内存。
## Buffer的常用方法
- `Buffer flip()`:将Buffer从写模式切换到读模式,将limit设置成position,然后将position的值设置为0,在读模式下才能读取之前写入到Buffer中的所有数据
- `Buffer rewind()`:将position设回0,所以可以重新读Buffer中的所有数据,limit保持不变,仍然表示能从Buffer中读取的元素个数
- `Buffer clear()`:清空整个缓冲区
- `ByteBuffer compact()`:清空缓冲区,但只清除已经读过的数据,即将所有未读的数据拷贝到Buffer起始处,然后将position设置到最后一个未读元素正后面,limit属性依然像clear()方法一样设置成capacity
- `Buffer mark()`:标记Buffer中一个特定position
- `Buffer reset()`:恢复到`mark()`标记和position
- `boolean hasRemaining()`:判断缓冲区中是否还有内容
- `XxxBuffer.allocate(int capacity)`:分配容量为capacity的XxxBuffer对象
- `XxxBuffer.allocateDirect(int capacity)`:通过直接缓冲区方式分配容量并创建对象
- `buffer.put()`:向缓冲区存放数据
- `buffer.get()`:从Buffer中读数据
- `XxxBuffer slice()`:切片,从position到limit截取一个子缓冲区
## capacity、position和limit
- capacity:不管是在读模式还是写模式都是一样,表示Buffer的**固定的容量值**,一旦Buffer写满了就需要将其清空才能继续写
- position:
- 在写模式中,position表示**写入数据的当前位置**,position的初始值为0,当一个数据写到Buffer后,position会移动到下一个可写入数据的Buffer单元,所以最大值是capacity - 1
- 在读模式中,position表示**读入数据的当前位置**。通过`flip()`切换到读模式时,position会被重置为0,当Buffer从position读入数据后,position会移动到下一个可读入数据的Buffer单元
- limit:
- 在写模式中,limit表示可对Buffer**最多写入多少个数据**,写模式下,limit等于Buffer的capacity
- 在读模式中,limit表示Buffer里**有多少可读数据**,因此能读到之前写入的所有数据
## 缓冲区操作
### 缓冲区分片
在NIO中,除了可以分配或包装一个缓冲区对象外,还可以根据现有缓冲区对象来创建一个子缓冲区,即:在现有缓冲区上切出一片来作为一个新的缓冲区,但现有的缓冲区和子缓冲区在底层数组层面上是数据共享的,也就是说子缓冲区相当于是现有缓冲区的一个视图。
```java
ByteBuffer buffer = ByteBuffer.allocate(10);
for (int i = 0; i < buffer.capacity(); i++) {
buffer.put((byte)(i));
}
// 创建子缓冲区
buffer.position(3);
buffer.limit(7);
ByteBuffer children = buffer.slice();
for (int i = 0; i < children.capacity(); i++) {
byte b = children.get(i);
b *= 10;
children.put(i, b);
}
buffer.position(0);
buffer.limit(buffer.capacity());
while (buffer.hasRemaining()){
System.out.println(buffer.get());
}
// 0 1 2 30 40 50 60 7 8 9
只读缓冲区
只读缓冲区表示缓冲区只能读,不能写,通过asReadOnlyBuffer()
创建,创建的是一个与原缓冲区相同的缓冲区,并且与原缓冲区共享数据,只不过它是只读的,如果原缓冲区数据发生变化,则它也会随之变化。
1 | ByteBuffer buffer = ByteBuffer.allocate(10); |
直接缓冲区
直接缓冲区是为加快I/O速度,使用一种特殊方式为其分配内存的缓冲区,JDK文档中描述为:给定一个直接字节缓冲区,Java虚拟机将尽最大努力直接对它执行本机I/O操作。也就是说,它会直接在每次调用底层操作系统的本机I/O操作之前(或之后),尝试避免将缓冲区内容拷贝到一个中间缓冲区中或从一个中间缓冲区中拷贝数据。
1 | ByteBuffer buffer = ByteBuffer.allocateDirect(10); |
内存映射文件I/O
内存映射文件I/O是一种读和写文件数据的方法,它可以比常规的基于流或基于通道的I/O快的多。内存映射文件I/O是通过使文件中的数据出现为内存数组的内容来完成的。一般来说只有文件中实际读取或写入的部分才会映射到内存中,而非将整个文件读到内存中。
1 | static private final int start = 0; |
Selector
Selector的解释
Selector和Channel的关系
Selector一般称为选择器,也可以翻译成多路复用器,是Java NIO核心组件中的一个,用于检查一个或多个NIO Channel的状态是否处于可读、可写,进而实现单线程管理多个channels,也就是可以管理多个网络连接
使用Selector的好处在于:使用更少的线程就可以处理多个通道,避免了线程上下文切换带来的开销
可选择通道(SelectableChannel)
- 不是所有的Channel都可以被Selector利用的,如FileChannel就不能被选择器复用。判断一个Channel能否被Selector复用有一个前提是:判断它是否继承了一个抽象类SelectableChannel,如果继承了则可以被复用,否则就不行。
- SelectableChannel类提供了实现通道的可选择性所需要的公共方法,它是所有支持就绪检查的通道类的父类。所有Socket通道都继承了SelectableChannel类,都是可选择的,包括从管道(Pipe)对象中获得的通道。而FileChannel则不是可选通道
- 一个通道可以被注册到多个选择器上,但对每个选择器而言只能被注册一次。通道和选择器之间的关系通过注册建立。SelectableChannel可以被注册到Selector对象上,在注册的时候,需要指定通道的哪些操作是Selector需要关注的。
Channel注册到Selector
- 使用
Channel.register(Selector sel, int ops)
方法就可以将一个通道注册到一个选择器。- sel:指定通道要注册的选择器
- ops:指定选择器需要查询的通道操作,如果有多个操作类型则用"|"连接
- 可以供选择器查询的通道操作从类型来分,包含:
- 可读:SelectionKey.OP_READ
- 可写:SelectionKey.OP_WRITE
- 连接:SelectionKey.OP_CONNECT
- 接收:SelectionKey.OP_ACCEPT
- 选择器查询的不是通道的操作,而是通道的某个操作的一种就绪状态。
选择键(SelectionKey)
- Channel注册后,一旦通道处于某种就绪状态,就可以被选择器查询到。这个工作使用Selector的
select()
方法完成,select()
方法的作用就是对感兴趣的通道操作,进行就绪状态的查询 。 - Selector可以不断的查询Channel中发生的操作的就绪状态,并且挑选感兴趣的操作就绪状态。一旦通道有操作的就绪状态达成,并且是Selector感兴趣的操作,就会被Selector选中,放入选择键集合中。
- 一个选择键,首先是包含了注册的Selector的通道操作的类型,如:SelectionKey.OP_READ。也包含了特定的通道与特定的选择器之间的注册关系。
Selector的使用方法
1. Selector的创建
1 | Selector selector = Selector.open(); |
2. 注册Channel到Selector
1 | // 创建Selector |
注:
- 与Selector一起使用时,Channel必须处于非阻塞模式
- 一个通道并不是一定要支持所有的四种操作,如ServerSocketChannel支持Accept操作,而SocketChannel则不支持。可以通过validOps()方法来获得所有支持的操作集合
3. 轮询查询就绪操作
int select()
:阻塞到至少有一个通道在注册的事件上就绪,返回值表示自前一次select()到现在有多少通道变成就绪状态select(long timeout)
:和select()
一样,但最长阻塞时间为timeout毫秒selectNow()
:非阻塞,只要有通道就绪就立刻返回selectedKeys()
:已选择键集合
1 | // 创建Selector |
4. 停止选择的方法
选择器执行执行的过程,系统底层每次会依次询问每个通道是否已经就绪,这个过程可能会造成调用线程进行阻塞状态,有三种方式可以唤醒在select()
方法中阻塞的线程:
wakeup()
:让处在阻塞状态的select()
方法立即返回,该方法使得选择器上的第一个还没有返回的选择操作立即返回。如果当前没有进行中的选择操作,则下一次对select()方法的一次调用将立即返回close()
:关闭selector;该方法使得任何一个选择操作中阻塞的线程都被唤醒,同时使得注册到该Selector的所有Channel被注销,所有的键将被取消,但Channel本身并不会关闭
Pipe和FileLock
Pipe
Java NIO管道是2个线程之间的单向数据连接。Pipe有一个source通道和一个sink通道。数据会被写到sink通道,从source通道读取。
创建管道
1 | Pipe pipe = Pipe.open(); |
写入管道
1 | Pipe pipe = Pipe.open(); |
从管道中读取数据
1 | Pipe pipe = Pipe.open(); |
FileLock
文件锁在OS中很常见,如果多个程序同时访问、修改同一个文件,很容易因为文件数据不同步而出现问题。给文件加一个锁,同一时间只能有一个程序修改此文件,或程序都只能读此文件,这就解决了同步问题。
文件锁是进程级别的,不是线程级别的,只能解决多个进程并发访问、修改同一个文件的问题。
文件锁是当前程序所属的JVM实例持有的,一旦获取到文件锁,要调用
release()
或关闭对应的FileChannel对象或退出当前JVM都会释放这个锁。一旦某个进程(如JVM实例)对某个文件加锁,则在释放这个锁之前,此进程不能再对此文件加锁,就是说JVM实例在同一文件上的文件锁是不重叠的。
文件锁分类
- 排它锁:又叫独占锁,对文件加排它锁后其它进程不能读写此文件。
- 共享锁:对文件加共享锁后,其它进程也可以访问此文件,但这些进程都只能读此文件,不能写。只要有一个进程持有共享锁,此文件就只能读,不能写。
示例
1 | FileChannel fileChannel = new FileOutputStream("test.txt").getChannel(); |
获取文件锁的方法
lock()
:对整个文件加锁,默认为排它锁lock(long position, long size, boolean shared)
:自定义加锁方式,前2个参数指定要加锁的部分,第三个参数指定是否是共享锁trylock()
:对整个文件加锁,默认为排它锁trylock(long position, long size, boolean shared)
:自定义加锁方式
lock()
和trylock()
的区别:
lock()
是阻塞式的,如果未获取到文件锁,会一直阻塞当前进程,直到获取文件锁。
trylock()
是非阻塞式的,尝试获取文件锁,获取成功就返回锁对象,否则就返回null,而不会阻塞当前线程
其它常用方法
boolean isShared()
:判断此文件锁是否是共享锁boolean isValid()
:判断此文件锁是否还有效
其它
Path
Java Path接口是在Java7时添加到Java NIO包的,位于java.nio.file包中,所以Path接口全限定类名为java.nio.file.Path
Java Path实例表示文件系统中的路径。一个路径可以指向一个文件或一个目录。路径可以是绝对路径也可以是相对路径。
在许多方面,java.nio.file.Path接口类似于java.io.File类,但有一些差点,但大多情况下可以使用Path接口来替换File类的使用。
创建实例
Paths.get(String path);
:java.nio.file.Paths中的静态方法,根据路径字符串创建Path实例Paths.get(String basePath, String relativePath);
:根据basePath和relativePath创建相对basePath的相对路径实例
常用方法
Path normalize();
:路径标准化,移除所有在路径字符串之间的.
和..
并解析路径字符串所引用的路径。
Files
Java NIO Files类(java.nio.file.Files)提供了几种操作文件系统中的文件的方法。
常用方法
Path Files.createDirectories(Path dir,FileAttribute<?>... attrs)
:根据Path实例创建一个新目录Path Files.copy(Path source, Path target, CopyOption... options)
:将source路径对应的文件拷贝到target- CopyOption可选:
StandardCopyOption.REPLACE_EXISTING
:覆盖模式
- CopyOption可选:
Path Files.move(Path source, Path target, CopyOption... options)
:将source路径对应的文件移动到target,或重命名为targetvoid Files.delete(Path path)
:删除path对应的文件Path walkFileTree(Path start, Set<FileVisitOption> options, int maxDepth, FileVisitor<? super Path> visitor)
:包含递归遍历目录树功能- Path实例指向要遍历的目录,FileVisitor在遍历期间被调用
- FileVisitor是一个接口,必须自己实现FileVisitor接口,并将实例传递给
walkFileTree()
方法,在目录遍历过程中,FileVisitor实现的每个方法都将被调用。SimpleFileVisitor
类包含了FileVisitor接口中所有方法的默认实现,可直接传入。 - FileVisitor接口方法中,每个都返回一个FileVisitResult枚举实例,包含四个选项:
CONTINUE
:继续TERMINATE
:终止SKIP_SIBLING
:跳过同级SKIP_SUBTREE
:跳过子级
AsynchronousFileChannel
在Java7中,Java NIO中添加了AsynchronousFileChannel,也就是异步地将数据写入文件
创建AsynchronousFileChannel
static AsynchronousFileChannel open(Path file, OpenOption... options)
:通过静态方法open()
创建实例
options可选项:
StandardOpenOption.READ
:读StandardOpenOption.WRITE
:写StandardOpenOption.APPEND
:追加- ......
通过Future读数据
1 | Path path = Paths.get("D:\\test.txt"); |
通过CompletionHandler读数据
1 | Path path = Paths.get("D:\\test.txt"); |
通过Future写数据
1 | Path path = Paths.get("D:\\test.txt"); |
通过CompletionHandler写数据
1 | Path path = Paths.get("D:\\test.txt"); |
Charset(字符集)
java中使用Charset来表示字符集编码对象
常用静态方法
Charset forName(String charsetName)
:通过编码类型获取Charset对象SortedMap<String,Charset> availableCharsets()
:获取系统支持的所有编码方式Charset defaultCharset()
:获取虚拟机默认编码方式boolean isSupported(String charsetName)
:判断是否支持该编码类型
常用普通方法
String name()
:获取Charset对象的编码类型CharsetEncoder newEncoder()
:获取编码器对象CharsetDecoder newDecoder()
:获取解码器对象