关于NIO这部分,除了《Java编程思想》中的介绍还有两份资料我觉得很好:一是《深入Java Web技术内幕》第2章的部分,二是并发编程网上Jakob JenkovNIO系列教程翻译,读完之后受益匪浅。
1. NIO是什么:
非阻塞IO是NIO的一大特点,那它是怎么实现的呢,Selector和它的名字所反映的一样起到选择和调度的作用,它所调度的就是Channel(通道),Channel包括:FileChannel,SocketChannel,ServerSocketChannel,DatagramChannel,它们负责对指定的资源进行访问读写,其中除了FileChannel都具有非阻塞的功能,ServerSocketChannel相当于一个服务器程序,它有accept方法监听和接受客户端的请求,而SocketChannel则对应于一个具体的Socket连接,它可以通过ServerSocketChannel的accept方法来得到,我们可以通过read和write对该连接信道进行读写,DatagramChannel是UDP数据报通信方式。在传统的IO中,accept,read,write方法都是阻塞的方式进行的,也就是说accept方法负责接受一个客户端请求,在未接受到一个请求之前线程就会阻塞在此处不能进行,因此要同时打开多个ServerSocketChannel必须要有多个线程支持,在非阻塞模式下,accept,read,write可以在直接返回,让其他任务可以执行,这样我们可以在一个线程中同时处理多个Channel,那你可能会问,accept直接返回了,请求来的时候如何在去调用accept接受呢,这就需要Selector来调度了,Selector的select()方法是阻塞的,它可以同时监听对Channel的操作请求,将请求转发到对应的channel中,从总体上看,把每个Channel中阻塞等待的行为统一移到了Selector,从而我们可以在单线程中同时处理多个信道的读写任务。Buffer缓存则是我们对Channel进行读写的工具,它还提供了Char,Int等多种不同的视图让我们可以以不同的方式读写数据,也提供了Heap和Direct直接内存两种缓存存储方式。
下面我们对这3个关键的“部件”进行详细的分析,当然我们应当明白不同的技术有不同的使用场景,这里为了突出NIO的特点我们集中于单线程(或少量线程)非阻塞的方式,它使用与高并发数据量处理少而简单的场景。
2. Selector:
结合上面的论述,可以看到Selector起到了代替多个Channel监听感兴趣事件发生的作用,这让我很容易想起一个设计模式——观察者模式,在这里Selector是Obserable,Channel是Observer,Channel要向Selector注册自己对哪些事情感兴趣,当事件发生时,Selector通知对应的Channel。
这里注册有个两个部分:哪个channel和指定的事件,SelectionKey包含了注册的要素:
2.1 SelectionKey(注册感兴趣的事,监听返回准备好的事,它关联一个Selector和Channel,我为什么忍不住想到迪米特法则,中毒太深...):
操作事件:
SelectionKey.OP_CONNECT
SelectionKey.OP_ACCEPT
SelectionKey.OP_READ
SelectionKey.OP_WRITE
因为SelectionKey包含5个部分:
(1)interest集合和ready集合一样包含有一些方便判断的方法,可以看api或源码;
(2)ready集合;
(3)channel引用;
(4)Selector引用;
(5)attach附加对象(可选);
2.2 Selector的重要方法:
(1)select方法:包括select(),selectNow(非阻塞),select(long timeout)返回int,有多个个ready的Channel;
(2)selectedKeys方法:返回ready的Channel的selectionKey集合,遍历它们,根据readyOps集合处理对应事件;
(3)wakeUp方法:从select阻塞中唤醒;
(4)close方法:是所用selectionKey无效,也就释放了对Channel们的引用不影响垃圾回收啦;
2.3 Selector使用示例:
public class SelectorSample {
private List<SelectableChannel> channels;
private boolean isListening = true;
public SelectorSample(List<SelectableChannel> channels) {
this.channels = channels;
}
public void doHandle() {
try(Selector selector = Selector.open()) {
for(SelectableChannel channel : channels) {
channel.configureBlocking(false); //非阻塞
channel.register(selector, SelectionKey.OP_ACCEPT | SelectionKey.OP_CONNECT
| SelectionKey.OP_READ | SelectionKey.OP_WRITE);
}
while(isListening) {
int ready = selector.select();
if(ready == 0) continue;
Set<SelectionKey> selectionKeys = selector.keys();
for(Iterator<SelectionKey> iterator = selectionKeys.iterator(); iterator.hasNext();) {
SelectionKey key = iterator.next();
if(key.isAcceptable()) {
System.out.println("doSomething when be acceptable");
} else if(key.isConnectable()) {
System.out.println("doSomething when be able to connect");
} else if(key.isReadable()) {
System.out.println("doSomething when be readable");
} else if(key.isWritable()) {
System.out.println("doSomething when be writable");
}
iterator.remove(); //注意要从就绪集合中删除,下次就绪有selector添加
}
}
} catch (IOException e) {
e.printStackTrace();
}
}
}
2. Channel :
Channel的体系中有:SelectableChannel和InterruptiableChannel,前者继承自后者,之前说过FileChannel是不可以非阻塞的它属于InterruptiableChannel,而其他3种进一步属于SelectableChannel。
2.1 如何打开通道:
2.2 ServerSocketChannel:
线程安全;
public abstract class ServerSocketChannel
extends AbstractSelectableChannel
implements NetworkChannel
它的主要功能就是监听的某个地址和端口上的套接字请求,并打开SocketChannel;
2.3 SocketChannel:
public abstract class SocketChannel
extends AbstractSelectableChannel
implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, NetworkChannel
ByteChannel实现了WritableChannel和ReadableChannel,因此它是可读写的;
Scatter/Gatther分别实现了将一个channel的内容读到多个buffer(一个Buffer满了才能读到下一个)和多个Buffer写到一个Channel的功能;
NetworkChannel:绑定到地址/端口的能力;
我们可以通过它来进行一个端到端的,有连接的套接字通信;
2.4 DatagramChannel:
线程安全的;
public abstract class DatagramChannel
extends AbstractSelectableChannel
implements ByteChannel, ScatteringByteChannel, GatheringByteChannel, MulticastChannel
与SocketChannel接口上的不同在与MulticastChannel,它是NetworkChannel子类;增加了多播的功能,使得我们可以使用基于UDP套接字的多播功能;
2.5 Pipe:
管道一般可以在两个线程中进行单向的数据传输,它有两个嵌套类:SinkChannel和SourceChannel,分别负责在一个线程(发送者)写入和在另一个线程中读取:
public static abstract class SinkChannel
extends AbstractSelectableChannel
implements WritableByteChannel, GatheringByteChannel
public static abstract class SourceChannel
extends AbstractSelectableChannel
implements ReadableByteChannel, ScatteringByteChannel
基于上面的论述,相信已经可以很清除的明白它们各自有什么功能了:都可以非阻塞,一个负责写,并且可以将多个Buffer一起写入,一个负责读,可以从Channel中将数据读入多个Buffer;
2.6 FileChannel:
这个Channel是阻塞的,是操作磁盘文件一种方式,它和之前几个Channel大不相同,所以我要将它和下面要介绍的DirectByteBuffer(MappedByteBuffer)一起讨论。
3. Buffer:
总的来说,具体工作中使用Buffer的次数要远远多与Selector和Channel,我们通过它对Channel进行具体的读写操作。
之前说过NIO的特点之一就是面向缓存,我们在使用Buffer时都是基于一块分配指定的大小的固定内存进行操作的,只有两种分配方式:Heap和Direct,它们的区别下面会详细说明。无论我们进行视图转换(CharBuffer/IntBuffer等等),还是compact压缩,还是duplicate复制、slice切片,都是最初的allocate分配那一块内存。
3.1 Buffer的基本属性和重要方法:
capacity:容量;
limit:可操作的限制位置;
position:下一个操作位置;
mark:标记;
address:使用direct内存时的内存地址;
capacity,limit,position这3个属性是我们进行操作最关键和常用的,结合操作方法我们来看看它们的使用细节:
flip:limit=position,position=0;这个方法通常在从通道中读取数据后使用,这样我们可以再从Buffer中读取数据;或者在Buffer写入数据后调用,让通道写入;
rewind:position=0,mark=-1;
clear:position=0,limit=capacity,mark=-1;通常在重新从
remaining:limit-position;用于检查是否还有数据;
mark和reset:mark标记,mark=position;reset复位,position=mark(mark>0时),注意它并不会修改mark值;
compact:将原来(limit-position)未处理完的数据复制到开头,再将position移到数据的下一个位置,limit=capacity,这个方法是进行压缩,去掉已处理过的数据,主要 为了接下来将数据写入Buffer,注意,它只是在原数组上进行复制的,没有新分配空间;
另一个你可能需要注意的是equals方法和compareTo方法,它们比较的是limit-position之间的大小;
3.2 Buffer的体系结构:
来看看Buffer的体系:
Channel都是基于字节的,我们一般也从ByteBuffer开始;
ByteBuffer具体实现(分配):
ByteBuffer有两个分配方法(它们返回HeapByteBuffer和DirectByteBuffer都是default包权限,我们无法直接使用它们):
allocate:HeapByteBuffer,从JVM堆中分配,收到JVM垃圾回收处理机制管理,实际上就是为了一个固定的byte[];
allocateDirect:DirectByteBuffer,使用JNI在native内存中分配,那怎么回收直接内存呢,DirectBuffer(DirectByteBuffer的接口),可以返回Cleaner,通过它我们可以释放直接内存,否则你就只能等待Full GC的发生来释放它了;
Buffer的视图:
在类图中我们可以看到有CharBuffer,IntBuffer,另外还有FloatBuffer,DoubleBuffer,ShortBuffer,LongBuffer以及MappedBuffer(特别的,内存映射);
它们实际上都是由ByteBuffer而产生,操作同一块内存,只是读取的方式不一样,以HeapByteBuffer和CharBuffer为例我们来看看它们是怎么完成“视图”的使命的。
转换方法:
ByteBuffer.asCharBuffer();
HeapByteBuffer中是这样实现的:
public CharBuffer asCharBuffer() {
int size = this.remaining() >> 1;
int off = offset + position();
return (bigEndian
? (CharBuffer)(new ByteBufferAsCharBufferB(this,
-1,
0,
size,
size,
off))
: (CharBuffer)(new ByteBufferAsCharBufferL(this,
-1,
0,
size,
size,
off)));
}
考虑到字顺这里有两个实现,新建一个ByteBufferAsCharBufferB还是操作那一块内存,只是我们换了一组capacity,limit,mark,position来操作;
而在具体的get/put方法中:
public char get() {
return Bits.getCharB(bb, ix(nextGetIndex()));
}
是通过Bits这个工具类来进行不同基本类型的读取和操作。
整个事情就是这样,基于字节ByteBuffer,考虑字顺用不同的方式去读写同一块内存;
PS:对于CharBuffer和ByteBuffer之间的转换,涉及到编解码,Charset有ByteBuffer = encode(CharBuffer)和CharBuffer = decode(ByteBuffer);
4. FileChannel
4.1 打开FileChannel:
public final FileChannel getChannel() {
synchronized (this) {
if (channel == null) {
channel = FileChannelImpl.open(fd, path, true, rw, this);
}
return channel;
}
}
FileOutputStream:
public FileChannel getChannel() {
synchronized (this) {
if (channel == null) {
channel = FileChannelImpl.open(fd, path, false, true, append, this);
}
return channel;
}
}
FileInputStream:
public FileChannel getChannel() {
synchronized (this) {
if (channel == null) {
channel = FileChannelImpl.open(fd, path, true, false, this);
}
return channel;
}
}
正如RandomAccessFile(可以seek方式前后读写文件),FileInputStream和FileOutputStream本身的差异一样,它们也具有不同的特点;
4.2 FileChannel的独特的方法:
4.3 transfer:
4.4 FileChannel中的内存映射:
static MappedByteBuffer newMappedByteBuffer(int var0, long var1, FileDescriptor var3, Runnable var4) {
if(directByteBufferConstructor == null) {
initDBBConstructor();
}
try {
MappedByteBuffer var5 = (MappedByteBuffer)directByteBufferConstructor.newInstance(new Object[]{new Integer(var0), new Long(var1), var3, var4});
return var5;
} catch (IllegalAccessException | InvocationTargetException | InstantiationException var7) {
throw new InternalError(var7);
}
}
这里可以看到显然是通过直接内存的方式,但是这段代码是在FileChannel的map方法中调用的,它和allocateDirect是有区别的;要理解直接内存和内存映射中的原理需要一些重要的基础知识,才能真正弄清楚HeapByteBuffer,DirectByteBuffer,和map之间的区别。
4.5 不同读取方式的对比:
public class DirectMemoryTest {
private static final String TEST_FILE = "/home/yjh/test.file";
public static void testNormal(String TEST_FILE) {
System.out.print("Normal ");
try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
FileChannel fileChannel = inputStream.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocate(1024);
while(fileChannel.read(buffer) != -1) {
buffer.flip();
buffer.clear();
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void testDirect(String TEST_FILE) {
System.out.print("Direct ");
try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
FileChannel fileChannel = inputStream.getChannel()) {
ByteBuffer buffer = ByteBuffer.allocateDirect(1024);
while(fileChannel.read(buffer) != -1) {
buffer.flip();
buffer.clear();
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void testMapped(String TEST_FILE) {
System.out.print("Mapped ");
try(FileInputStream inputStream = new FileInputStream(TEST_FILE);
FileChannel fileChannel = inputStream.getChannel()) {
MappedByteBuffer buffer = fileChannel.map(FileChannel.MapMode.READ_ONLY, 0, fileChannel.size());
byte[] b = new byte[1024];
while(buffer.get(b).position() < buffer.limit()) {
}
} catch (IOException e) {
e.printStackTrace();
}
}
public static void test(Consumer<String> consumer) {
long startTime = System.currentTimeMillis();
consumer.accept(TEST_FILE);
long endTime = System.currentTimeMillis();
System.out.println("Time consume: " + (endTime - startTime));
}
public static void main(String[] args) {
test(DirectMemoryTest::testNormal);
test(DirectMemoryTest::testDirect);
test(DirectMemoryTest::testMapped);
}
}
我对三种方式分别读取整个test文件的内容,该文件大小为500M,运行结果(单位:ms):
Direct Time consume: 800
Mapped Time consume: 222