[TOC]
Java NIO包含三个核心部分:
- Buffer
- Channel
- Selector
Buffer即缓冲区,暂存输入输出数据的区域;Channel通道是数据操作的工具,负责将数据读入缓冲区以及从缓冲区写入目的地;Selector则是使用IO复用模型的解决方案,支持单个线程维护管理多个通道。
Buffer
在Java传统版本的IO中,数据要么是读一个字节处理一个字节要么是读入一个字节数组中,然后一并处理。字节数组就相当于是一个Buffer。
一个Buffer对象时固定数量的数据的容器,其作用是作为一个存储器或者分段运输区。
Buffer 是一个对象, 它包含一些要写入或者刚读出的数据。 在 NIO 中加入 Buffer 对象,体现了新库与原 I/O 的一个重要区别。在面向流的 I/O 中,您将数据直接写入或者将数据直接读到 Stream 对象中。
在 NIO 库中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的。在写入数据时,它是写入到缓冲区中的。任何时候访问 NIO 中的数据,您都是将它放到缓冲区中。
缓冲区实质上是一个数组。通常它是一个字节数组,但是也可以使用其他种类的数组。但是一个缓冲区不 仅仅 是一个数组。缓冲区提供了对数据的结构化访问,而且还可以跟踪系统的读/写进程。
基本属性
- 容量(Capacity):缓冲区大小;
- 上限(Limit):缓冲区可用边界,越过上界的区域不可读写;
- 位置(Position):下一个读写的索引,随着读写递增;
-
标记(Mark):Mark一个位置,可以在之后设置位置为标记值再次读写。
初始化的buffer,limit和capacity相等,position为0,Mark未定义;容量固定,其他三个属性可变。
Buffer基本API
操作:
- put()
写入缓冲区
- get()
读取数据
- mark() && reset()
记录mark位置和返回mark的位置
- flip()
翻转,使得position=0,limit指向元素末尾的后一个位置(limit起初等于capacity)
public final Buffer flip() {
limit = position;
position = 0;
mark = -1;
return this;
}
- rewind
设置position=0
- clear()
不清除内部数据,仅仅将position设置为0,limit=capacity
public final Buffer clear() {
position = 0;
limit = capacity;
mark = -1;
return this;
}
- position(int pos)
设置position
- compact()
压缩缓冲区,将未读数据拷贝拷贝至缓冲区前端,position指向拷贝后的位置的后一个(position=未处理数据长度),limit=capacity
查看属性:
- position()
- capacity()
- limit()
- isReadOnly()
- hasRemaining()
是否有未处理数据
- remaining()
剩余未处理数据量
Buffer类层次结构
Buffer如何使用
Buffer创建
在Buffer类的结构中,public的类都是abstract的,故不能直接使用new来创建buffer。在NIO包中有abstract类的具体实现,但都是包访问权限。创建一个可用的Buffer类,使用抽象类的wrap()和allocate()或者allocateDirect()方法。
@Test
public void testBufferCreate() {
Buffer buf = CharBuffer.allocate(CAPACITY);
buf = IntBuffer.allocate(CAPACITY);
buf = ShortBuffer.allocate(CAPACITY);
buf = LongBuffer.allocate(CAPACITY);
buf = FloatBuffer.allocate(CAPACITY);
buf = DoubleBuffer.allocate(CAPACITY);
buf = ByteBuffer.allocate(CAPACITY);
buf = ByteBuffer.allocateDirect(CAPACITY);
buf = MappedByteBuffer.allocate(CAPACITY);
buf = MappedByteBuffer.allocateDirect(CAPACITY);
System.out.println(buf.capacity());
}
ByteBuffer和其子类支持allocateDirect(),即直接内存分配,不占用jvm内存。wrap方法支持通过包装一个对应数据类型的数组来返回一个Buffer对象。实际上Buffer只是个封装了的数组罢了。
在IO中,不论是面向流的设备还是面向块的设备,所有操作底层实际上都是面向字节的,在NIO中,channel实际上只接受字节缓冲区。所以主要学习和记录ByteBuffer。
数据存取
- 数据写入API(ByteBuffer)
// ByteBuffer提供多种数据类型的put方法,其他的Buffer类只有自身类型的put/get
public abstract ByteBuffer put(byte b);
public abstract ByteBuffer put(int index, byte b);
public ByteBuffer put(ByteBuffer src) {
public ByteBuffer put(byte[] src, int offset, int length) {
public final ByteBuffer put(byte[] src) {
public abstract ByteBuffer putChar(char value);
public abstract ByteBuffer putChar(int index, char value);
public abstract ByteBuffer putShort(short value);
public abstract ByteBuffer putShort(int index, short value);
public abstract ByteBuffer putInt(int value);
public abstract ByteBuffer putInt(int index, int value);
public abstract ByteBuffer putLong(long value);
public abstract ByteBuffer putLong(int index, long value);
public abstract ByteBuffer putFloat(float value);
public abstract ByteBuffer putFloat(int index, float value);
public abstract ByteBuffer putDouble(double value);
public abstract ByteBuffer putDouble(int index, double value);
- 数据读取API(ByteBuffer)
public abstract byte get();
public abstract byte get(int index);
public ByteBuffer get(byte[] dst, int offset, int length) {
public ByteBuffer get(byte[] dst) {
public abstract char getChar();
public abstract char getChar(int index);
public abstract short getShort();
public abstract short getShort(int index);
public abstract int getInt();
public abstract int getInt(int index);
public abstract long getLong();
public abstract long getLong(int index);
public abstract float getFloat();
public abstract float getFloat(int index);
public abstract double getDouble();
public abstract double getDouble(int index);
- 读写示例
@Test
public void testReadAndWrite() {
ByteBuffer bbf = ByteBuffer.allocate(100);
printInfo(bbf);
bbf.put("helloworld".getBytes());
printInfo(bbf);
// 翻转以读写
bbf.flip();
printInfo(bbf);
// 读取目的数组
byte[] dst = new byte[bbf.remaining()];
while (bbf.hasRemaining()) {
bbf.get(dst);
}
System.out.println(new String(dst));
printInfo(bbf);
}
private void printInfo(Buffer b) {
StringBuffer sb = new StringBuffer();
sb.append(getClass().getName()).append(b.hashCode()).append("[pos=").append(b.position()).append(" lim=").append(b.limit())
.append(" cap=").append(b.capacity()).append("]");
System.out.println(sb);
}
结果:
java.nio.HeapByteBuffer[pos=0 lim=100 cap=100]
java.nio.HeapByteBuffer[pos=10 lim=100 cap=100]
java.nio.HeapByteBuffer[pos=0 lim=10 cap=100]
helloworld
java.nio.HeapByteBuffer[pos=10 lim=10 cap=100]
可见flip方法的作用是将position置0,limit置实际元素个数。
其他操作
- 清空clear
@Test
public void testClear() {
CharBuffer cbuf = CharBuffer.allocate(CAPACITY);
cbuf.put("abcdefg");
printInfo(cbuf);
cbuf.clear();
printInfo(cbuf);
}
test.nio.BufferTest[pos=7 lim=100 cap=100]
test.nio.BufferTest[pos=0 lim=100 cap=100]
不会清除数据。
- 压缩compact
@Test
public void testCompact() {
CharBuffer cbuf = CharBuffer.allocate(CAPACITY);
cbuf.put("abcdefg");
printInfo(cbuf);
// 模拟读了三个元素
cbuf.flip();
cbuf.get();
cbuf.get();
cbuf.get();
printInfo(cbuf);
// 压缩缓冲区
cbuf.compact();
printInfo(cbuf);
}
test.nio.BufferTest[pos=7 lim=100 cap=100]
test.nio.BufferTest[pos=3 lim=7 cap=100]
test.nio.BufferTest[pos=4 lim=100 cap=100]
- 标记和重置标记mark&&reset
@Test
public void testMark() {
ByteBuffer bf = ByteBuffer.allocate(CAPACITY);
bf.put("hello".getBytes());
printInfo(bf);
bf.flip();
bf.get();
bf.get();
printInfo(bf);
bf.mark();
bf.get();
bf.get();
printInfo(bf);
bf.reset();
printInfo(bf);
bf.get();
printInfo(bf);
}
test.nio.BufferTest[pos=5 lim=100 cap=100]
test.nio.BufferTest[pos=2 lim=5 cap=100]
test.nio.BufferTest[pos=4 lim=5 cap=100]
test.nio.BufferTest[pos=2 lim=5 cap=100]
test.nio.BufferTest[pos=3 lim=5 cap=100]
- 复制缓冲区
@Test
public void testDuplicate() {
ByteBuffer bf = ByteBuffer.allocate(CAPACITY);
bf.put("hello".getBytes());
printInfo(bf);
ByteBuffer copy = bf.duplicate();
printInfo(copy);
copy.put("world".getBytes());
printInfo(bf);
printInfo(copy);
}
test.nio.BufferTest-528365601[pos=5 lim=100 cap=100]
test.nio.BufferTest-528365601[pos=5 lim=100 cap=100]
test.nio.BufferTest-432682959[pos=5 lim=100 cap=100]
test.nio.BufferTest-970291007[pos=10 lim=100 cap=100]
新缓冲区的内容将是该缓冲区的内容。 这个缓冲区内容的改变将在新缓冲区中可见,反之亦然; 两个缓冲区的位置,限制和标记值将是独立的。
新缓冲区的容量,限制,位置和标记值将与该缓冲区的相同。 当且仅当此缓冲区是直接的时,新缓冲区才是直接的,并且只有在此缓冲区是只读的情况下,它才会是只读的。
异常
@Test
public void testException() {
ByteBuffer bf = ByteBuffer.allocate(CAPACITY);
bf.put("h".getBytes());
bf.flip();
try {
bf.get();
bf.get();
// posotion >= limit时,会抛出java.nio.BufferUnderflowException
} catch (RuntimeException e) {
System.out.println(e);
}
bf.flip();
// 如果缓冲区数据无法填满目标数组,会抛出java.nio.BufferUnderflowException
byte[] dst = new byte[10];
try {
bf.get(dst);
} catch (RuntimeException e) {
System.out.println(e);
}
byte[] src = { 1, 2, 3 };
bf = ByteBuffer.allocate(2);
try {
// 如果缓冲区没有足够空间,会抛出java.nio.BufferOverflowException
bf.put(src);
} catch (RuntimeException e) {
System.out.println(e);
}
}
内存映射缓冲区
映射缓冲区是通过内存映射来存取数据元素的字节缓冲区,映射缓冲区通常是直接内存缓冲区(direct)。
MappedByteBuffer的使用和ByteBuffer类似。
Channel
Channel是NIO第二个重要的组成部分,它是数据存取的工具,类似传统IO的各种流。Channel用于在字节缓冲区和位于通道另一侧的实体(文件或者网络套接字)之间传输数据。
Channel是一个对象,可以通过它读取和写入数据,channel的数据读写必须和Buffer对象协作,准确的说是ByteBuffer,所有的写操作都是从buffer经由channel写出,所有的读操作都是经由channel读到buffer。
拿 NIO 与原来的 I/O 做个比较,通道就像是流。通道与流的不同之处在于通道是双向的。而流只是在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类), 而 通道 可以用于读、写或者同时用于读写。
因为它们是双向的,所以通道可以比流更好地反映底层操作系统的真实情况。特别是在 UNIX 模型中,底层操作系统通道是双向的。
正如前面提到的,所有数据都通过 Buffer 对象来处理。您永远不会将字节直接写入通道中,相反,您是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。
NIO 的创建目的是为了让 Java 程序员可以实现高速 I/O 而无需编写自定义的本机代码。NIO 将最耗时的 I/O 操作(即填充和提取缓冲区)转移回操作系统,因而可以极大地提高速度。
多数情况下,通道和操作系统的文件描述符和文件句柄有着一对一的关系。虽然通道比文件描述符更加广义,但是经常使用的多数通道都是连接到开放的文件描述符的。Channel类提供维持平台独立性所需的抽象过程,不过依然会模拟操作系统本身的IO性能。
文件描述符(File descriptor)是计算机科学中的一个术语,是一个用于表述指向文件的引用的抽象化概念
Channel顶层接口定义:
public interface Channel extends Closeable {
public boolean isOpen();
public void close() throws IOException;
}
Channe接口继承了Closeable,说明通道也可以使用try-with-resource结构来自动关闭。
流与块的比较
原来的 I/O 库(在 java.io.*中) 与 NIO 最重要的区别是数据打包和传输的方式。正如前面提到的,原来的 I/O 以流的方式处理数据,而 NIO 以块的方式处理数据。
面向流 的 I/O 系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。不利的一面是,面向流的 I/O 通常相当慢。
一个 面向块 的 I/O 系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的 I/O 缺少一些面向流的 I/O 所具有的优雅性和简单性。
Channel类层次结构
从上层接口可以发现Channel的一些属性:
- 可读(readable)和可写性(writable)
- 可中断性(Interruptble)
- 可选择(selectable)
从类图可以看出,核心应用的Channel主要分为两类:FileChannel和套接字Channel,分别对应文件IO和网络IO。套接字Channel有ServerSocketChannel(欢迎套接字)、SocketChannel(TCP)和DatagramChannel(UDP)。
需要注意的是,从Channel接口扩展的所有接口都是面向字节的。这也是前文说通道是连接字节缓冲区和实体的传输途径,通道只支持字节缓冲区。
FileChannel
使用通道
通道是访问IO的导管,IO可以广义地划分成两大类文件IO(面向块)和流IO,相应提供了两大类的Channel实现:FileChannel和套接字通道(套接字是面向流的)。
在1.4版本中,打开一个FileChannel的唯一方式是从一个文件流调用getChannel()。
通道会连接一个特定IO服务且通道实例的性能受到它所连接的IO服务的特征限制。一个连接到只读文件的Channel实例不能进行写操作,即使该实例有write方法。
文件IO通道(FileChannel)
FileChannel无法设置为非阻塞模式,它总是运行在阻塞模式下。现代操作系统都有复杂的缓存和预取机制,使得本地磁盘的IO操作的延迟很少。面向流的IO(如socketIO)的非阻塞范例对于面向文件的操作无多大意义,这是有文件IO本质上的不同造成的。对于文件IO,最强大之处在于异步IO,它允许一个进程可以从操作系统请求一个或多个IO操作而不必等待这些操作的完成,发起请求的进程之后会收到它请求的IO操作已完成的通知。
public abstract class FileChannel
extends AbstractInterruptibleChannel
implements SeekableByteChannel, GatheringByteChannel, ScatteringByteChannel
基于1.8版本,打开文件通道的方式:
- Channels类静态方法
- FileChannel静态open方法(@since1.7)
- IO包文件流getChannel()方法
- 示例
@Test
public void openFileChannelByStream() {
// 写通道
try (OutputStream os = Files.newOutputStream(testPath, StandardOpenOption.WRITE);
WritableByteChannel channel = Channels.newChannel(os);
InputStream is = Files.newInputStream(testPath, StandardOpenOption.READ);
ReadableByteChannel rc = Channels.newChannel(is);) {
ByteBuffer src = ByteBuffer.allocate(128);
src.put("hellohello".getBytes());
src.flip();
while (src.hasRemaining()) {
channel.write(src);// 一次write的最大长度是8192字节
}
// 读通道
ByteBuffer dst = ByteBuffer.allocate(128);
// 读入buffer
rc.read(dst);
printBuffer(dst);
} catch (IOException e) {
e.printStackTrace();
}
}
@Test
public void openFileChannel() {
// 可读写通道
try (FileChannel channel =
FileChannel.open(testPath, StandardOpenOption.READ, StandardOpenOption.WRITE);) {
ByteBuffer src = ByteBuffer.allocate(128);
// 写通道
src.put("姑苏城外寒山寺".getBytes());
src.flip();
while (src.hasRemaining()) {
channel.write(src);// 一次write的最大长度是8192字节
}
// 读通道
ByteBuffer dst = ByteBuffer.allocate(128);
// 需要手动改变channel的position,否则是从通道尾部开始读不会读到数据
channel.position(0);
channel.read(dst);
// 读buffer内容
printBuffer(dst);
} catch (IOException e) {
e.printStackTrace();
}
}
@Test
public void openFileChannelByGetChannel() {
try (RandomAccessFile raf = new RandomAccessFile(testPath.toFile(), "w");
FileChannel channel = raf.getChannel();) {
} catch (IOException e) {
e.printStackTrace();
}
}
private void printBuffer(ByteBuffer dst) {
dst.flip();
while (dst.hasRemaining()) {
byte[] dstarray = new byte[dst.remaining()];
dst.get(dstarray);
System.out.print(new String(dstarray));
}
System.out.println("");
}
Channels类还提供了channel和stream之间的转换方法(newInputStream、newOutputStream、newReader、newWriter);
以上示例使用的try-with-resource方式,没有显式调用close方法关闭channel或stream。
FileChannel的size方法
FileChannel实例的size()方法将返回该实例所关联文件的大小。如:
long fileSize = channel.size();
FileChannel的truncate方法
可以使用FileChannel.truncate()方法截取一个文件。截取文件时,文件将中指定长度后面的部分将被删除。如:
channel.truncate(1024);
这个例子截取文件的前1024个字节。
- FileChannel的force方法
FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上。出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入到FileChannel里的数据一定会即时写到磁盘上。要保证这一点,需要调用force()方法。
force()方法有一个boolean类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上。
transferTo()
transferTo()方法将数据从FileChannel传输到其他的channel中。transferFrom()
FileChannel的transferFrom()方法可以将数据从源通道传输到FileChannel中
聚集和发散
FileChannel实现了GatheringByteChannel, ScatteringByteChanne接口,所有支持聚集和发散操作:
聚集:一次将多个buffer内容聚集到channel
发散:从channel将数据发散到多个buffer中
主视角是channel。
@Test
public void testFileChannelGatheringScattering() {
// 可读写通道
try (FileChannel channel =
FileChannel.open(testPath, StandardOpenOption.READ, StandardOpenOption.WRITE);) {
ByteBuffer src0 = ByteBuffer.allocate(22);
ByteBuffer src1 = ByteBuffer.allocate(22);
ByteBuffer src2 = ByteBuffer.allocate(22);
ByteBuffer src3 = ByteBuffer.allocate(22);
ByteBuffer[] buffers = { src0, src1, src2, src3 };
src0.put("月落乌啼霜满天\n".getBytes());
src1.put("江枫渔火对愁眠\n".getBytes());
src2.put("姑苏城外寒山寺\n".getBytes());
src3.put("夜半钟声到客船\n".getBytes());
// 写通道 Gathering,buf put之后不需要flip,write方法会自行flip
channel.write(buffers);
// 读通道 Scattering
channel.read(buffers);
printBuffer(src0);
printBuffer(src1);
printBuffer(src2);
printBuffer(src3);
} catch (IOException e) {
e.printStackTrace();
}
}
内存映射文件和内存映射缓冲区
内存映射文件存取技术利用的是操作系统虚拟内存技术,采用内存映射将内核空间的buffer和用户空间的buffer(在api中是MappedByteBuffer)映射到同一块内存空间,从而减少一次数据拷贝操作,以此来提高IO性能。
因为,操作系统的虚拟内存可以自动缓存内存页,这些也是用系统内存来缓存的,不会消耗JVM内存。一旦一个内存页以及生效(从磁盘缓存进来),他就能以完全的硬件速度再次被访问而不需要再次调用系统命令来获取数据(也就是减少的那次数据拷贝操作,直接读内存区域)。
- 如何使用内存映射文件
@Test
public void testMappedFile() {
try (FileChannel channel =
FileChannel.open(testPath, StandardOpenOption.WRITE, StandardOpenOption.READ);
) {
MappedByteBuffer mappedBuffer = channel.map(MapMode.READ_WRITE, 0, channel.size());
System.out.println(mappedBuffer.isLoaded());
mappedBuffer.put("123123".getBytes()).flip();
channel.write(mappedBuffer);
} catch (IOException e) {
e.printStackTrace();
}
}
通过FileChannel的map方法创建MappedByteBuffer,MappedByteBuffer是直接内存的缓冲区(但它依然是ByteBuffer,是ByteBuffer的子类),当通过map创建好MappedByteBuffer后,buffer已经指向了虚拟内存中的文件位置,虚拟内存页并未装入内存,isLoaded()方法可以检测是否装入内存。如果这个时候执行读操作,会在操作系统层引起一个缺页错误使得页被换入内存,就可读了。
MappedByteBuffer有一个load方法将映射的虚拟内存页载入内存中,但是并不能保证所有的页都装入或常驻内存。
另外map方法没有对应的unmap方法,一个映射缓冲区没有绑定到创建它的通道上,通道的关闭不会破坏缓冲区,只有丢弃缓冲区本身才会破坏。
在FileChannel调用map时有一个MapMode参数,有三个常量值选项封装在枚举MapMode内(实际上MapMode类表面看不是枚举,但可以说它是枚举):
MapMode.READ_ONLY
MapMode.READ_WRITE
MapMode.PRIVATE
MapMode.PRIVATE
表示只需要一个写时拷贝的映射,在此模式下对buffer的put操作只会对本buffer生效,不会对底层的文件做如何修改,可以理解为只是获取一个底层文件的映射副本。
文件锁
要获取文件的一部分上的锁,要调用一个打开的 FileChannel 上的 lock() 方法。注意,如果要获取一个排它锁,必须以写方式打开文件。
RandomAccessFile raf = new RandomAccessFile( "usefilelocks.txt", "rw" );
FileChannel fc = raf.getChannel();
FileLock lock = fc.lock( start, end, false );
在拥有锁之后,您可以执行需要的任何敏感操作,然后再释放锁:
lock.release();
在释放锁后,尝试获得锁的其他任何程序都有机会获得它。
SocketChannel
如同FileChannel对应于1.0版本的文件处理的Stream,Socket通道是对应于socket编程的NIO特性。传统socket的输入输出是基于流的,NIO使用了通道来读写,这一点和FileChannel是一致的,从流读写转化为channel+Buffer的合作形式。
FileChannel不支持非阻塞特性,且是面向块的操作;Socket是面向流的,且Socket通道支持非阻塞特性,这使得它与NIO最核心的选择器构成强大的NIO网络编程模型。
Socket通道有三个:ServerSocketChannel、SocketChannel、DatagramChannel,每个通道都有一个关联的socket对象,但是反过来却不是,虽然在socket对象中有getChannel方法,但是如果这个socket是使用传统方式创建的的话,getChannel()返回的将是null。
Socket通道委派协议操作给对等的socket对象。
Socket通道可以运行在非阻塞模式下,默认情况下是运行在阻塞模式下的,非阻塞特性是和可选择性紧密相关的,服务器端的使用经常会考虑到非阻塞通道,结合Select可以使单个线程维护多个连接,而无须为连接创建多个线程或者使用线程池。设置非阻塞模式只需调用configureBlocking(false)
。
ServerSocketChannel和ServerSocket
ServerSocketChannel是一个基于通道的socket监听器,它和java.net.ServerSocket的功能类似,只是它支持非阻塞模式。其非阻塞特性表现在accept()方法上。在传统的ServerSocket上调用accept()会阻塞直到有连接到来,然后返回一个与连接相连的socket对象。但是在channel的非阻塞模式下,accept方法会立即返回null如果当前没有连接进来的话。在此模式下,若不借助选择器来管理,可以使用轮询来检查accept的结果。
如何获取和使用
- API
// 使用类的静态方法打开一个通道
ServerSocetChannel serverSocketChannel = ServerSocketChannel.open();
// 绑定端口(使用关联的socket对象)
serverSocketChannel.socket().bind(new InetSocketAddress(port), backlog);
// 设置非阻塞模式
serverSocketChannel.configureBlocking(false);
// 和ServerSocket不同的是,accept方法返回的是SocketChannel对象
SocketChannel sc = serverSocketChannel.accept(); // sc->null
如果没有serverSocketChannel.configureBlocking(false);
,就和传统的ServerSocket的效果一致了。
此模式下,可以轮询sc的值来处理连接,这是典型的同步非阻塞IO模型。
SocketChannel和Socket
像ServerSocket与ServerSocketChannel的对应关系一样。SocketChannel和Socket封装点到点、有序的TCP连接。
获取
除了从ServerSocketChannel的accept()返回,也可以使用静态open()方法创建对象。
- API
// 两种open
@Test
public void testSocketChannel() throws IOException {
SocketChannel sc = SocketChannel.open();
boolean conn = sc.connect(new InetSocketAddress(PORT));
System.out.println(conn);
System.out.println(sc.finishConnect());
sc.close();
// or,创建时会连接
sc = SocketChannel.open(new InetSocketAddress(PORT));
System.out.println(sc.isConnected());
sc.close();
}
connect
当connect()方法在非阻塞模式下调用时,SocketChannel提供并发连接:它发起对请求地址的连接并且立即返回值。如果返回true,说明连接建立,如果不能立即建立连接,connect()会返回false且并发地继续建立连接过程。
所以当返回false时,,就需要检查连接的结果。
使用finishConnect()
方法来完成连接过程,此方法在任何时候都可以安全地调用。在非阻塞模式下的SocketChannel调用finishConnect(),可能会出现以下情形:
- 如果connect()方法没有在此之前调用,会抛出NoConnectionPendingException;(调用connect()之后,可以调用isConnectPending()检查是否在并发连接);
- 连接过程正在进行,则直接返回false;
- 连接已经建立,返回true;
读写通道(和ByteBuffer交互)
/**
* @throws NotYetConnectedException If this channel is not yet connected
*/
public abstract int read(ByteBuffer dst) throws IOException;
public abstract long read(ByteBuffer[] dsts, int offset, int length)
throws IOException;
public final long read(ByteBuffer[] dsts) throws IOException {
return read(dsts, 0, dsts.length);
}
public abstract int write(ByteBuffer src) throws IOException;
public abstract long write(ByteBuffer[] srcs, int offset, int length)
throws IOException;
public final long write(ByteBuffer[] srcs) throws IOException {
return write(srcs, 0, srcs.length);
}
可以将SocketChannel当做一个文件进行读写。和面向块的文件读写不同的是,socket是面向流的,只有socket断开连接read才会返回-1(EOF);
作为Channel对象,其数据也是要通过Buffer的(ByteBuffer)。
DatagramChannel
DatagramChannel和DatagramSocekt是相关联的。
DatagramChannel是一个能收发UDP包的通道。因为UDP是无连接的网络协议,所以不能像其它通道那样读取和写入。它发送和接收的是数据包。
获取DatagramChannel
DatagramChannel dc = DatagramChannel.open();
发送数据
@Test
public void testDatagramSocket() throws IOException {
// 打开通道
DatagramChannel dc = DatagramChannel.open();
// 数据
byte[] buf = "HelloUDPServer哈哈哈哈或或".getBytes(StandardCharsets.UTF_8);
System.out.println(buf.length);
// 构建数据报对象
DatagramPacket dp = new DatagramPacket(buf, buf.length);
dp.setPort(UDPPORT);
dp.setAddress(InetAddress.getLocalHost());
// 通过关联socket对象发送数据报
dc.socket().send(dp);
// 关闭通道
dc.close();
}
@Test
public void testDatagramChannel() throws IOException {
DatagramChannel dc = DatagramChannel.open();
// 手动绑定本地端口号
dc.bind(new InetSocketAddress(56252));
ByteBuffer data = ByteBuffer.allocate(2048);
data.put("SocketChannel和Socket封装点到点、有序的TCP连接".getBytes(StandardCharsets.UTF_8));
data.flip();
// 注意此处没有使用DatagramPacket类,直接使用buffer;
dc.send(data, new InetSocketAddress(InetAddress.getLocalHost(), UDPPORT));
dc.close();
}
接收数据
receive()方法会将接收到的数据包内容复制到指定的Buffer. 如果Buffer容不下收到的数据,多出的数据将被丢弃。
public static void NIOServer() throws IOException {
DatagramChannel dc = DatagramChannel.open();
dc.bind(new InetSocketAddress(12345));
ByteBuffer buff = ByteBuffer.allocate(DATA_MAX_LENGTH);
while (true) {
// 阻塞模式
SocketAddress remteAddr = dc.receive(buff);
StringBuilder sb = new StringBuilder();
String msg = new String(buff.array(), StandardCharsets.UTF_8);
sb.append("via: ").append(remteAddr.toString()).append(" msg:").append(msg);
System.out.println(sb);
}
}
// 以前的方式
public static void Server() throws IOException {
@SuppressWarnings("resource")
DatagramSocket ds = new DatagramSocket(12345);
System.out.println(ds.isBound());
byte[] buf = new byte[1024];
DatagramPacket dp = new DatagramPacket(buf, 1024);
System.out.println("UDPServer start!");
while (true) {
ds.receive(dp);
String data = new String(dp.getData(), 0, dp.getLength(), StandardCharsets.UTF_8);
String ip = dp.getAddress().getHostAddress();
int port = dp.getPort();
System.out.println("ip地址:" + ip + " 端口号:" + port + " 消息:" + data);
}
}
连接
DatagramChannel的connect()语义和SocketChannel的connect()的语义是不同的,因为UDP本身不是面向连接的协议,所以不会真正建立连接。
此处的connect()是现在socket为两方通信,发往其他以及其他发来的数据都不会发送和接收。
gather和scatter
socket通道都实现了ScatteringByteChannel, GatheringByteChannel接口,所以支持聚集和发散操作。
IO多路复用——Selectable
IO多路复用是NIO最为重要的特性,非阻塞模式的Socket通道都支持此特性。
具体在选择器内容中介绍。
Selector
在传统BIO模型下的服务器中,服务器处理请求的基本模式就是accept()方法阻塞等待请求进来,有请求连接之后,创建一个线程(或者复用线程池的线程)去hold这个连接直到socket关闭。这个模型很简单,但是在应对大量请求涌入时会有性能问题,因为毕竟系统的线程是有限的,频繁创建和销毁线程的开销也是很大的。
在1.4出现的NIO中,主要的三个部分是Buffer、Channel和Selector,核心是Selector。Selector封装了操作系统层面的select函数来管理多个channel(socket),select本身是阻塞的,当它管理的某个通道就绪时,就会返回就绪的通道供给程序读写操作。对应应用程序来说,只需要单个线程便可以维护多个通道。这将带来很大的伸缩性。
什么是Selector
Selector(选择器)是Java NIO中能够检测一到多个NIO通道,并能够检查通道是否为诸如读写事件做好准备的组件。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接。
操作系统的一个重要功能是处理IO请求并通知各个线程它们的数据准备就绪了,Selector的就绪选择的处理是委托到具体的操作系统上的,选择器类提供了这种抽象,使得Java代码能以可移植的方式,请求底层的操作系统提供就绪选择服务。
基本而言,选择器是对select
,poll
,epoll
等本地调用或者类似的操作系统的特定系统调用的封装。
如何使用
Selector是要和非阻塞的SelectableChannel合作使用的,而Channel又是与Buffer相关联的,所以Selector是聚集了NIO各个部分的一个功能。
选择器逻辑
- 创建选择器
- 将通道注册到选择器上
- 让选择器监听注册的通道
- 处理选择器返回的就绪的通道
核心类
- Selector(选择器抽象)
- SelectionKey(就绪对象抽象)
- SelectableChannel(SocketChannel、ServerSocketChannel、DatagramChannel)
- ByteBuffer(数据读写缓冲区)
API
- 创建选择器
Selector selector = Selector.open();
- 注册通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
serverSocketChannel.socket().bind(new InetSocketAddress(port), backlog);
serverSocketChannel.configureBlocking(false);
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
register()的第二个参数是要监听的事件,当指定的事件就绪时,就会封装成一个SelectionKey从select返回。
- SelectionKey
SelectionKey对象封装了通道和选择器之间的关系。这个关系包含了选择器对其监听的事件类型(使用整型值表示),所关联的通道和对应的选择器,就绪类型和是否就绪等。
一个通道注册到选择器中后,就会被封装成一个SelectionKey对象,它是channel对于选择器的抽象表示。
支持的就绪选择事件:
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;
当通道关闭时,所有的相关的键会自动取消。当选择器关闭时,所有注册到选择器的通道都将注销。
- 选择
selector.select()
- 选择过程
已注册的键:key();
已选择(就绪)的键:selectedKeys();
已取消的键:cancel();
停止选择过程
- wakeup()
- close()
- interrupt()
示例
Selector selector = Selector.open();
channel.configureBlocking(false);
SelectionKey key = channel.register(selector, SelectionKey.OP_READ);
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();
}
}
参考资料:
- 深入浅出NIO之Channel、Buffer
- Ron Hitchens.Java NIO[M] O'Reilly,2002:22-51
- NIO入门
- Java NIO系列教程(七)FileChannel
- Java NIO系列教程(五) 通道之间的数据传输
- Java NIO系列教程(六) Selector
- Ron Hitchens.Java NIO