一、概述
1. NIO与IO的区别
Java IO的各种流是阻塞的。这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。 Java NIO的非阻塞模式,使一个线程从某通道读取数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞。所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(channel)。
2. 核心组成
2.1 Buffers缓冲区
一个Buffer对象是固定数量的数据的容器。其作用是一个存储器,或者分段运输区,在这里数据可被存储并在之后用于检索。尽管缓冲区作用于它们存储的原始数据类型,但缓冲区十分倾向于处理字节。非字节缓冲区可以在后台执行从字节或到字节的转换,这取决于缓冲区是如何创建的。
2.1.1 flip()方法
Buffer 中的 flip() 方法涉及到 Buffer 中的capacity、position、limit三个概念:
- capacity:分配的缓冲容量/大小。
- position:读/写指针,表示当前读/写到什么位置。
- limit:在写模式下表示最多能写入多少数据,此时和capacity相同。在读模式下表示最多能读多少数据,此时和缓存中的实际数据大小相同。
flip():Buffer有两种模式,写模式和读模式。在写模式下调用flip()之后,Buffer从写模式变成读模式。那么limit就设置成了position当前的值(即当前写了多少数据),postion会被置为0,以表示读操作从缓存的头开始读,mark置为-1。
2.1.2 Buffer的基本用法
使用Buffer读写数据一般遵循以下四个步骤:
- 写入数据到Buffer
- 调用
flip()方法 - 从Buffer中读取数据
- 调用
clear()方法或者compact()方法
一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。有两种方式能清空缓冲区:调用clear()或compact()方法。
- clear():会清空整个缓冲区;position将被设回0,limit设置成capacity,换句话说,Buffer被清空了,其实Buffer中的数据并未被清楚,只是这些标记告诉我们可以从哪里开始往Buffer里写数据。
- compact():只会清除已经读过的数据,任何未读的数据都被移到缓冲区的起始处,新写入的数据将放到缓冲区未读数据的后面;
- mark():可以标记Buffer中的一个特定的position,之后可以通过调用reset()方法恢复到这个position。
- rewind():将position设回0,所以你可以重读Buffer中的所有数据。limit保持不变,仍然表示能从Buffer中读取多少个元素。
try {
RandomAccessFile aFile = new RandomAccessFile("/Users/dustinchen/Desktop/1.txt", "rw");
FileChannel inChannel = aFile.getChannel();
ByteBuffer buf = ByteBuffer.allocate(48);
int bytesRead = inChannel.read(buf); //read into buffer.
while (bytesRead != -1) {
// 从写模式切换到读模式
buf.flip();
while (buf.hasRemaining()) {
// 一次只读一个byte出来
System.out.print((char) buf.get());
}
// 清空buf为写准好准备
buf.clear();
// 循环读取数据到buf中
bytesRead = inChannel.read(buf);
}
aFile.close();
} catch (Exception e) {
e.printStackTrace();
}
下面是没有使用Channel和Buffer时的文件随机读写:
try {
RandomAccessFile raf = new RandomAccessFile("path", "r");
//获取RandomAccessFile对象文件指针的位置,初始位置是0
System.out.println("RandomAccessFile文件指针的初始位置:" + raf.getFilePointer());
raf.seek(100);//移动文件指针位置
byte[] buff = new byte[1024];
//用于保存实际读取的字节数
int hasRead = 0;
//循环读取
while ((hasRead = raf.read(buff)) > 0) {
//打印读取的内容,并将字节转为字符串输入
System.out.println(new String(buff, 0, hasRead));
}
} catch (Exception e) {
e.printStackTrace();
}
向Buffer中写数据:
- 从Channel读数据到Buffer :channel.read(buf)
- 通过Buffer的put()方法 :buf.put(newData.getBytes())
从Buffer中读取数据:
- 从Buffer写数据到Channel :channel.write(buf)
- 使用get()方法从Buffer中读取数据 :buf.get()
2.2 Channel通道
通道是一种途径,借助该途径,可以用最小的总开销来访问操作系统本身的 I/O 服务。缓冲区则是通道内部用来发送和接收数据的端点。通道channel充当连接I/O服务的导管。
与通道相关的有两个个关键接口:
- ReadableByteChannel:可读取字节的通道;
- WritableByteChannel:可写入字节的通道;
通道可以是单向或者双向的。一个 channel 类可能实现定义read()方法的 ReadableByteChannel 接口,而另一个 channel 类也许实现 WritableByteChannel 接口以提供 write()方法。实现这两种接口其中之一的类都是单向的,只能在一个方向上传输数据。如果一个类同时实现这两个接口,那么它是双向的,可以双向传输数据。
每一个 file 或 socket 通道都实现全部两个接口。从类定义的角度而言,这意味着全部 file 和 socket 通道对象都是双向的。这对于 sockets 不是问题,因为它们一直都是双向的,不过对于 files 却是个问题了。我们知道,一个文件可以在不同的时候以不同的权限打开。从 FileInputStream 对象的getChannel( )方法获取的 FileChannel 对象是只读的,不过从接口声明的角度来看却是双向的,因为FileChannel 实现 ByteChannel 接口。在这样一个通道上调用 write( )方法将抛出未经检查的NonWritableChannelException 异常,因为 FileInputStream 对象总是以 read-only 的权限打开文件。
通道可以在阻塞(blocking)或非阻塞(nonblocking)模式运行。非阻塞模式的通道永远不会让调用的线程休眠。请求的操作要么立即完成,要么返回一个结果表明未进行任何操作。只有面向流的(stream-oriented)的通道,如 sockets 和 pipes 才能使用非阻塞模式。
- FileChannel:从文件中读写数据;
- DatagramChannel:能通过UDP读写网络中的数据;
- SocketChannel:能通过TCP读写网络中的数据;
- ServerSocketChannel:可以监听新进来的TCP连接,像Web服务器那样,对每一个新进来的连接都会创建一个SocketChannel。
2.3 Selector选择器
选择器提供选择执行已经就绪的任务的能力,这使得多元I/O成为可能,就绪选择和多元执行使得单线程能够有效率的同时管理多个I/O通道(channels),简单言之就是selector充当一个监视者,您需要将之前创建的一个或多个可选择的通道注册到选择器对象中。
2.3.1 传统的socket监控
传统的监控多个 socket 的 Java 解决方案是为每个 socket 创建一个线程并使得线程可以在 read( )调用中阻塞,直到数据可用。这事实上将每个被阻塞的线程当作了 socket 监控器,并将 Java 虚拟机的线程调度当作了通知机制。这两者本来都不是为了这种目的而设计的。程序员和 Java 虚拟机都为管理所有这些线程的复杂性和性能损耗付出了代价,这在线程数量的增长时表现得更为突出。
2.3.2 选择器属性
(1)选择器(Selector)
选择器类管理着一个被注册的通道集合的信息和它们的就绪状态。通道是和选择器一起被注册的,并且使用选择器来更新通道的就绪状态。当这么做的时候,可以选择将被激发的线程挂起,直到有就绪的的通道。
(2)可选择通道(SelectableChannel)
SelectableChannel 可以被注册到 Selector 对象上,同时可以指定对那个选择器而言,那种操作是感兴趣的。一个通道可以被注册到多个选择器上,但对每个选择器而言只能被注册一次。
(3)选择键(SelectionKey)
选择键封装了特定的通道与特定的选择器的注册关系。选择键对象被SelectableChannel.register( ) 返回并提供一个表示这种注册关系的标记。选择键包含了两个比特集(以整数的形式进行编码),指示了该注册关系所关心的通道操作,以及通道已经准备好的操作。
二、带Selector的基本用法
1. Selector的创建
通过调用Selector.open()方法创建一个Selector,如下:
Selector selector = Selector.open();
2. Channel向Selector注册
为了将Channel和Selector配合使用,必须将channel注册到selector上:
channel.configureBlocking(false);
SelectionKey key = channel.register(selector,
Selectionkey.OP_READ);
与Selector一起使用时,Channel必须处于非阻塞模式下。这意味着不能将FileChannel与Selector一起使用,因为FileChannel不能切换到非阻塞模式。而套接字通道都可以。注意register()方法的第二个参数。这是一个“interest集合”,意思是在通过Selector监听Channel时对什么事件感兴趣。
2.1 interest集合
通道触发了一个事件意思是该事件已经就绪。所以,某个channel成功连接到另一个服务器称为“连接就绪”。一个server socket channel准备好接收新进入的连接称为“接收就绪”。一个有数据可读的通道可以说是“读就绪”。等待写数据的通道可以说是“写就绪”。可以监听四种不同类型的事件:
- SelectionKey.OP_CONNECT
- SelectionKey.OP_ACCEPT
- SelectionKey.OP_READ
- SelectionKey.OP_WRITE
如果你对不止一种事件感兴趣,那么可以用“位或”操作符将常量连接起来,如下:
int interestSet = SelectionKey.OP_READ | SelectionKey.OP_WRITE;
2.2 SelectionKey
当向Selector注册Channel时,register()方法会返回一个SelectionKey对象,它包含了最关键的配对关系,即Selector和Channel的配对,除此之外还包括其他有用的信息。也就是可以通过SelectionKey获取如下对象:
- Channel
- Selector
- interest集合
- ready集合
- 附加的对象(可选)
Channel + Selector
Channel channel = selectionKey.channel();
Selector selector = selectionKey.selector();
interest集合
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;
ready集合
ready 集合是通道已经准备就绪的操作的集合。在一次选择(Selection)之后,你会首先访问这个ready set:
int readySet = selectionKey.readyOps();
附加的对象
可以将一个对象或者更多信息附着到SelectionKey上,这样就能方便的识别某个给定的通道。例如,可以附加 与通道一起使用的Buffer,或是包含聚集数据的某个对象。使用方法如下:
selectionKey.attach(theObject);
Object attachedObj = selectionKey.attachment();
3. 通过Selector选择通道
- int select():选择一组键,其相应的通道已为 I/O 操作准备就绪。此方法执行处于阻塞模式的选择操作。仅在至少选择一个通道、调用此选择器的
wakeup方法,或者当前的线程已中断(以先到者为准)后此方法才返回。 - int select(long timeout):此方法执行处于阻塞模式的选择操作。仅在至少选择一个通道、调用此选择器的 wakeup 方法、当前的线程已中断,或者给定的超时期满(以先到者为准)后此方法才返回。
- int selectNow():此方法执行非阻塞的选择操作。如果自从前一次选择操作后,没有通道变成可选择的,则此方法直接返回零。
- Selector wakeup():某个线程调用select()方法后阻塞了,即使没有通道已经就绪,也有办法让其从select()方法返回。只要让其它线程在第一个线程调用select()方法的那个对象上调用Selector.wakeup()方法即可。阻塞在select()方法上的线程会立马返回。如果有其它线程调用了wakeup()方法,但当前没有线程阻塞在select()方法上,下个调用select()方法的线程会立即“醒来(wake up)”。
int值表示有多少通道已经就绪。
4. 完整示例
Selector selector = Selector.open();
channel.configureBlocking(false); // 必须将通道设置为非阻塞模式
SelectionKey key = channel.register(selector, SelectionKey.OP_READ); // 注册通道到Selector
while(true) {
int readyChannels = selector.select(); // 阻塞式选择
if(readyChannels == 0) continue;
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();
}
}
5. 并发性
择器对象是线程安全的,但它们包含的键集合不是。通过 keys( )和 selectKeys( )返回的键的集合是 Selector 对象内部的私有的 Set 对象集合的直接引用。这些集合可能在任意时间被改变。已注册的键的集合是只读的。如果您试图修改它,那么您得到的奖品将是一个java.lang.UnsupportedOperationException,但是当您在观察它们的时候,它们可能发生了改变的话,您仍然会遇到麻烦。Iterator 对象是快速失败的(fail-fast):如果底层的 Set 被改变了,它们将会抛出 java.util.ConcurrentModificationException,因此如果您期望在多个线程间共享选择器和/或键,请对此做好准备。您可以直接修改选择键,但请注意您这么做时可能会彻底破坏另一个线程的 Iterator。
如果在多个线程并发地访问一个选择器的键的集合的时候存在任何问题,您可以采取一些步骤来合理地同步访问。在执行选择操作时,选择器在 Selector 对象上进行同步,然后是已注册的键的集合,最后是已选择的键的集合,按照这样的顺序。已取消的键的集合也在选择过程的的第 1步和第 3 步之间保持同步(当与已取消的键的集合相关的通道被注销时)。
三、SocketChannel和ServerSocketChannel
这里使用SocketChannel来继续探讨NIO。NIO的强大功能部分来自于Channel的非阻塞特性,套接字的某些操作可能会无限期地阻塞。例如,对accept()方法的调用可能会因为等待一个客户端连接而阻塞;对read()方法的调用可能会因为没有数据可读而阻塞,直到连接的另一端传来新的数据。总的来说,创建/接收连接或读写数据等I/O调用,都可能无限期地阻塞等待,直到底层的网络实现发生了什么。慢速的,有损耗的网络,或仅仅是简单的网络故障都可能导致任意时间的延迟。然而不幸的是,在调用一个方法之前无法知道其是否阻塞。NIO的channel抽象的一个重要特征就是可以通过配置它的阻塞行为,以实现非阻塞式的信道。
channel.configureBlocking(false);
在非阻塞式信道上调用一个方法总是会立即返回。这种调用的返回值指示了所请求的操作完成的程度。例如,在一个非阻塞式ServerSocketChannel上调用accept()方法,如果有连接请求来了,则返回客户端SocketChannel,否则返回null。
这里先举一个TCP应用案例,客户端采用NIO实现,而服务端依旧使用IO实现:
public static void client() {
ByteBuffer buffer = ByteBuffer.allocate(1024);
SocketChannel socketChannel = null;
try {
// 打开SocketChannel
socketChannel = SocketChannel.open();
socketChannel.configureBlocking(false);
socketChannel.connect(new InetSocketAddress("10.10.195.115", 8080));
if (socketChannel.finishConnect()) { // 判断连接是否已经建立
int i = 0;
while (true) {
TimeUnit.SECONDS.sleep(1);
String info = "I'm " + i++ + "-th information from client";
buffer.clear();
buffer.put(info.getBytes());
buffer.flip();
while (buffer.hasRemaining()) {
System.out.println(buffer);
socketChannel.write(buffer);
}
}
}
} catch (IOException | InterruptedException e) {
e.printStackTrace();
} finally {
try {
if (socketChannel != null) {
socketChannel.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
public static void server() {
ServerSocket serverSocket = null;
InputStream in = null;
try {
serverSocket = new ServerSocket(8080);
int recvMsgSize = 0;
byte[] recvBuf = new byte[1024];
while (true) {
Socket clntSocket = serverSocket.accept();
SocketAddress clientAddress = clntSocket.getRemoteSocketAddress();
System.out.println("Handling client at " + clientAddress);
in = clntSocket.getInputStream();
while ((recvMsgSize = in.read(recvBuf)) != -1) {
byte[] temp = new byte[recvMsgSize];
System.arraycopy(recvBuf, 0, temp, 0, recvMsgSize);
System.out.println(new String(temp));
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (serverSocket != null) {
serverSocket.close();
}
if (in != null) {
in.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
Selector类可以用于避免使用阻塞式客户端中很浪费资源的“忙等”方法。例如,考虑一个IM服务器。像QQ或者旺旺这样的,可能有几万甚至几千万个客户端同时连接到了服务器,但在任何时刻都只是非常少量的消息。
需要读取和分发。这就需要一种方法阻塞等待,直到至少有一个信道可以进行I/O操作,并指出是哪个信道。NIO的选择器就实现了这样的功能。一个Selector实例可以同时检查一组信道的I/O状态。用专业术语来说,选择器就是一个多路开关选择器,因为一个选择器能够管理多个信道上的I/O操作。
传统的方式是这么处理多客户端的——循环地一个一个地去检查所有的客户端是否有I/O操作,如果当前客户端有I/O操作,则可能把当前客户端扔给一个线程池去处理,如果没有I/O操作则进行下一个轮询,当所有的客户端都轮询过了又接着从头开始轮询。这种方法是非常笨而且也非常浪费资源,因为大部分客户端是没有I/O操作,我们也要去检查;而Selector就不一样了,它是一种消息通知模型。它在内部可以同时管理多个I/O,当一个信道有I/O操作的时候,他会通知Selector,Selector就是记住这个信道有I/O操作,并且知道是何种I/O操作,是读呢还是是写呢还是接受新的连接。所以如果使用Selector,它返回的结果只有两种结果,一种是0,即在你调用的时刻没有任何客户端需要I/O操作,另一种结果是一组需要I/O操作的客户端,这是你就根本不需要再检查了,因为它返回给你的肯定是你想要的。这样一种通知的方式比那种主动轮询的方式要高效得多!
要使用选择器(Selector),需要创建一个Selector实例(使用静态工厂方法open())并将其注册(register)到想要监控的信道上(注意,这要通过channel的方法实现,而不是使用selector的方法)。最后,调用选择器的select()方法。该方法会阻塞等待,直到有一个或更多的信道准备好了I/O操作或等待超时。select()方法将返回可进行I/O操作的信道数量。现在,在一个单独的线程中,通过调用select()方法就能检查多个信道是否准备好进行I/O操作。如果经过一段时间后仍然没有信道准备好,select()方法就会返回0,并允许程序继续执行其他任务。
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;
public class ServerConnect {
private static final int BUF_SIZE = 1024;
private static final int PORT = 8080;
private static final int TIMEOUT = 3000;
public static void main(String[] args) {
selector();
}
public static void handleAccept(SelectionKey key) throws IOException {
ServerSocketChannel ssChannel = (ServerSocketChannel) key.channel();
SocketChannel sc = ssChannel.accept();
sc.configureBlocking(false);
sc.register(key.selector(), SelectionKey.OP_READ, ByteBuffer.allocateDirect(BUF_SIZE));
}
public static void handleRead(SelectionKey key) throws IOException {
SocketChannel sc = (SocketChannel) key.channel();
ByteBuffer buf = (ByteBuffer) key.attachment();
long bytesRead = sc.read(buf);
while (bytesRead > 0) {
buf.flip();
while (buf.hasRemaining()) {
System.out.print((char) buf.get());
}
System.out.println();
buf.clear();
bytesRead = sc.read(buf);
}
if (bytesRead == -1) {
sc.close();
}
}
public static void handleWrite(SelectionKey key) throws IOException {
ByteBuffer buf = (ByteBuffer) key.attachment();
buf.flip();
SocketChannel sc = (SocketChannel) key.channel();
while (buf.hasRemaining()) {
sc.write(buf);
}
buf.compact();
}
public static void selector() {
Selector selector = null;
ServerSocketChannel ssc = null;
try {
selector = Selector.open();
ssc = ServerSocketChannel.open();
ssc.socket().bind(new InetSocketAddress(PORT));
ssc.configureBlocking(false);
ssc.register(selector, SelectionKey.OP_ACCEPT);
while (true) {
if (selector.select(TIMEOUT) == 0) {
System.out.println("==");
continue;
}
// 轮询哪些事件已经就绪,并执行相应的业务操作
Iterator<SelectionKey> iter = selector.selectedKeys().iterator();
while (iter.hasNext()) {
SelectionKey key = iter.next();
if (key.isAcceptable()) {
handleAccept(key);
}
if (key.isReadable()) {
handleRead(key);
}
if (key.isWritable() && key.isValid()) {
handleWrite(key);
}
if (key.isConnectable()) {
System.out.println("isConnectable = true");
}
// 注意需要手动将对象从SelectedSet中移除
iter.remove();
}
}
} catch (IOException e) {
e.printStackTrace();
} finally {
try {
if (selector != null) {
selector.close();
}
if (ssc != null) {
ssc.close();
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
本文深入讲解Java NIO的核心概念及应用,包括与传统IO的区别、非阻塞模式的优势、Buffers缓冲区、Channels通道、Selectors选择器等关键组件的使用方法,并提供SocketChannel和ServerSocketChannel的实战案例。
1100

被折叠的 条评论
为什么被折叠?



