3.1 Java NIO简介
在1.4版本之前,Java IO类库是阻塞IO;从1.4版本开始,引进了新的异步IO库,被称为Java New IO类库,简称为Java NIO。更多的人喜欢称Java NIO为非阻塞IO(Non-Blocking IO),称“老的”阻塞式Java IO为OIO(Old IO)。
Java NIO类库包含以下三个核心组件:
- Channel(通道)
- Buffer(缓冲区)
-
3.1.1 NIO和OIO的对比
OIO是面向流(Stream Oriented)的,NIO是面向缓冲区(Buffer Oriented)的
- OIO的操作是阻塞的,而NIO的操作是非阻塞的。
OIO没有选择器(Selector)的概念,而NIO有选择器的概念。
3.1.2 通道
在OIO中,同一个网络连接会关联到两个流:一个是输入流(Input Stream),另一个是输出流(Output Stream)。Java应用程序通过这两个流不断地进行输入和输出的操作。
在NIO中,一个网络连接使用一个通道表示,所有NIO的IO操作都是通过连接通道完成的。一个通道类似于OIO中两个流的结合体,既可以从通道读取数据,也可以向通道写入数据。3.1.3 选择器
选择器可以理解为一个IO事件的监听与查询器。通过选择器,一个线程可以查询多个通道的IO事件的就绪状态。
3.1.4 缓冲区
所谓通道的读取,就是将数据从通道读取到缓冲区中;所谓通道的写入,就是将数据从缓冲区写入通道中。缓冲区的使用是面向流进行读写操作的OIO所没有的,也是NIO非阻塞的重要前提和基础之一。
3.2 详解NIO Buffer类及其属性
NIO的Buffer本质上是一个内存块,既可以写入数据,也可以从中读取数据。Java NIO中代表缓冲区的Buffer类是一个抽象类,位于java.nio包中。
Buffer类是一个非线程安全类。3.2.1 Buffer类
Buffer类是一个抽象类,对应于Java的主要数据类型。在NIO中,有8种缓冲区类,分别是ByteBuffer、CharBuffer、DoubleBuffer、FloatBuffer、IntBuffer、LongBuffer、ShortBuffer、MappedByteBuffer。前7种Buffer类型覆盖了能在IO中传输的所有Java基本数据类型,第8种类型是一种专门用于内存映射的ByteBuffer类型。
3.2.2 Buffer类的重要属性
为了记录读写的状态和位置,Buffer类额外提供了一些重要的属性,其中有三个重要的成员属性:capacity(容量)、position(读写位置)和limit(读写的限制)。
capacity属性
Buffer类的capacity属性表示内部容量的大小。一旦写入的对象数量超过了capacity,缓冲区就满了,不能再写入了。
- position属性
Buffer类的position属性表示当前的位置。position属性的值与缓冲区的读写模式有关。在不同的模式下,position属性值的含义是不同的,在缓冲区进行读写的模式改变时,position值会进行相应的调整。
在写模式下,position值的变化规则如下:
(1)在刚进入写模式时,position值为0,表示当前的写入位置为从头开始。
(2)每当一个数据写到缓冲区之后,position会向后移动到下一个可写的位置。
(3)初始的position值为0,最大可写值为limit-1。当position值达到limit时,缓冲区就已经无空间可写了。
在读模式下,position值的变化规则如下:
(1)当缓冲区刚开始进入读模式时,position会被重置为0。
(2)当从缓冲区读取时,也是从position位置开始读。读取数据后,position向前移动到下一个可读的位置。
(3)在读模式下,limit表示可读数据的上限。position的最大值为最大可读上限limit,当position达到limit时表明缓冲区已经无数据可读。
模式的切换,可以调用flip()方法
在从写模式到读模式的翻转过程中,position和limit属性值会进行调整,具体的规则是:
(1)limit属性被设置成写模式时的position值,表示可以读取的最大数据位置。
(2)position由原来的写入位置变成新的可读位置,也就是0,表示可以从头开始读。
- limit属性
Buffer类的limit属性表示可以写入或者读取的数据最大上限,其属性值的具体含义也与缓冲区的读写模式有关。在不同的模式下,limit值的含义是不同的,具体分为以下两种情况:
(1)在写模式下,limit属性值的含义为可以写入的数据最大上限。在刚进入写模式时,limit的值会被设置成缓冲区的capacity值,表示可以一直将缓冲区的容量写满。
(2)在读模式下,limit值的含义为最多能从缓冲区读取多少数据。
总结:
属性 | 说明 |
---|---|
capacity | 容量,即可以容纳的最大数据量,在缓冲区创建时设置并且不能改变 |
limit | 读写的限制,缓冲区中当前的数据量 |
position | 读写位置,缓冲区中下一个要被读或写的元素的索引 |
mark | 调用mark() 方法来设置mark=position 在调用reset() 让position 恢复到mark 标记的位置 |
3.3 详解NIO Buffer类的重要方法
3.3.1 allocate()
在使用Buffer实例之前,我们首先需要获取Buffer子类的实例对象,并且分配内存空间。需要获取一个Buffer实例对象时,并不是使用子类的构造器来创建,而是调用子类的allocate()方法。
package com.crazymakercircle.bufferDemo;
import com.crazymakercircle.util.Logger;
import java.nio.IntBuffer;
public class UseBuffer
{
//一个整型的Buffer静态变量
static IntBuffer intBuffer = null;
public static void allocateTest()
{
//创建一个intBuffer实例对象
intBuffer = IntBuffer.allocate(20);
Logger.debug("------------after allocate------------------");
Logger.debug("position=" + intBuffer.position());
Logger.debug("limit=" + intBuffer.limit());
Logger.debug("capacity=" + intBuffer.capacity());
}
//省略其他代码
}
//allocatTest |> ------------after allocate------------------
//allocatTest |> position=0
//allocatTest |> limit=20
//allocatTest |> capacity=20
3.3.2 put()
在调用allocate()方法分配内存、返回了实例对象后,缓冲区实例对象处于写模式,可以写入对象,如果要把对象写入缓冲区,就需要调用put()方法。put()方法很简单,只有一个参数,即需要写入的对象,只不过要求写入的数据类型与缓冲区的类型保持一致。
package com.crazymakercircle.bufferDemo;
//省略import
public class UseBuffer
{
//一个整型的Buffer静态变量
static IntBuffer intBuffer = null;
//省略了创建缓冲区的代码,具体查看前面小节的内容和随书源码
public static void putTest()
{
for (int i = 0; i < 5; i++)
{
//写入一个整数到缓冲区
intBuffer.put(i);
}
//输出缓冲区的主要属性值
Logger.debug("------------after putTest------------------");
Logger.debug("position=" + intBuffer.position());
Logger.debug("limit=" + intBuffer.limit());
Logger.debug("capacity=" + intBuffer.capacity());
}
//省略其他代码
}
//输出结果
putTest |> ------------after putTest------------------
putTest |> position=5
putTest |> limit=20
putTest |> capacity=20
3.3.3 flip()
向缓冲区写入数据之后,是否可以直接从缓冲区读取数据呢?不能!这时缓冲区还处于写模式,如果需要读取数据,要将缓冲区转换成读模式。flip()翻转方法是Buffer类提供的一个模式转变的重要方法,作用是将写模式翻转成读模式。
package com.crazymakercircle.bufferDemo;
//省略import
public class UseBuffer
{
//一个整型的Buffer静态变量
static IntBuffer intBuffer = null;
//省略了缓冲区的创建、写入数据的代码,具体查看前面小节的内容和随书源码
public static void flipTest()
{
//翻转缓冲区,从写模式翻转成读模式
intBuffer.flip();
//输出缓冲区的主要属性值
Logger.info("------------after flip ------------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());
}
//省略其他代码
}
//输出结果
flipTest |> ------------after flipTest ------------------
flipTest |> position=0
flipTest |> limit=5
flipTest |> capacity=20
在读取完成后,如何再一次将缓冲区切换成写模式呢?
答案是:可以调用Buffer.clear()清空或者Buffer.compact()压缩方法,它们可以将缓冲区转换为写模式。
3.3.4 get()
调用flip()方法将缓冲区切换成读模式之后,就可以开始从缓冲区读取数据了。读取数据的方法很简单,可以调用get()方法每次从position的位置读取一个数据,并且进行相应的缓冲区属性的调整。
package com.crazymakercircle.bufferDemo;
//省略import
public class UseBuffer
{
//一个整型的Buffer静态变量
static IntBuffer intBuffer = null;
//省略了缓冲区的创建、写入、翻转的代码,具体查看前面小节的内容和随书源码
public static void getTest()
{
//先读2个数据
for (int i = 0; i< 2; i++)
{
int j = intBuffer.get();
Logger.info("j = " + j);
}
//输出缓冲区的主要属性值
Logger.info("---------after get 2 int --------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());
//再读3个数据
for (int i = 0; i< 3; i++)
{
int j = intBuffer.get();
Logger.info("j = " + j);
}
//输出缓冲区的主要属性值
Logger.info("---------after get 3 int ---------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());
}
//…
}
//省略其他代码
}
getTest |> ------------after get 2 int ------------------
getTest |> position=2
getTest |> limit=5
getTest |> capacity=20
getTest |> ------------after get 3 int ------------------
getTest |> position=5
getTest |> limit=5
getTest |> capacity=20
缓冲区是不是可以重复读呢?
答案是可以的,既可以通过倒带方法rewind()去完成,也可以通过mark()和reset()两个方法组合实现。
3.3.5 rewind()
已经读完的数据,如果需要再读一遍,可以调用rewind()方法。rewind()也叫倒带,就像播放磁带一样倒回去,再重新播放。
package com.crazymakercircle.bufferDemo;
//省略import
public class UseBuffer
{
//一个整型的Buffer静态变量
static IntBuffer intBuffer = null;
//省略了缓冲区的写入和读取等代码,具体查看前面小节的内容和随书源码
public static void rewindTest() {
//倒带
intBuffer.rewind();
//输出缓冲区属性
Logger.info("------------after rewind ------------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());
}
//省略其他代码
}
rewindTest |> ------------after rewind ------------------
rewindTest |> position=0
rewindTest |> limit=5
rewindTest |> capacity=20
从JDK中可以查阅Buffer.rewind()方法的源代码,具体如下:
public final Buffer rewind() {
position = 0; //重置为0,所以可以重读缓冲区中的所有数据
mark = -1; //mark被清理,表示之前的临时位置不能再用了
return this;
}
重复读取的示例代码如下:
package com.crazymakercircle.bufferDemo;
//省略import
public class UseBuffer
{
//一个整型的Buffer静态变量
static IntBuffer intBuffer = null;
//省略了缓冲区的写入和读取、倒带等代码,具体查看前面小节的内容和随书源码
public static void reRead() {
for (int i = 0; i< 5; i++) {
if (i == 2) {
//临时保存,标记一下第3个位置
intBuffer.mark();
}
//读取元素
int j = intBuffer.get();
Logger.info("j = " + j);
}
//输出缓冲区的属性值
Logger.info("------------after reRead------------------");
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());
}
//省略其他代码
}
3.3.6 mark()和reset()
mark()和reset()两个方法是配套使用的:Buffer.mark()方法将当前position的值保存起来放在mark属性中,让mark属性记住这个临时位置;然后可以调用Buffer.reset()方法将mark的值恢复到position中。
package com.crazymakercircle.bufferDemo;
//省略import
public class UseBuffer
{
//一个整型的Buffer静态变量
static IntBuffer intBuffer = null;
//省略了缓冲区的倒带、重复读取等代码,具体查看前面小节的内容和随书源码
//演示前提:
//在前面的reRead()演示方法中,已经通过mark()方法暂存了position值
public static void afterReset() {
Logger.info("------------after reset------------------");
//把前面保存在mark中的值恢复到position中
intBuffer.reset();
//输出缓冲区的属性值
Logger.info("position=" + intBuffer.position());
Logger.info("limit=" + intBuffer.limit());
Logger.info("capacity=" + intBuffer.capacity());
//读取并且输出元素
for (int i =2; i< 5; i++) {
int j = intBuffer.get();
Logger.info("j = " + j);
}
}
//省略其他代码
}
afterReset |> ------------after reset------------------
afterReset |> position=2
afterReset |> limit=5
afterReset |> capacity=20
afterReset |> j = 2
afterReset |> j = 3
afterReset |> j = 4
3.3.7 clear()
在读模式下,调用clear()方法将缓冲区切换为写模式。此方法的作用是:
(1)将position清零。
(2)limit设置为capacity最大容量值,可以一直写入,直到缓冲区写满。
package com.crazymakercircle.bufferDemo;
//省略import
public class UseBuffer
{
//一个整型的Buffer静态变量
static IntBuffer intBuffer = null;
//省略了缓冲区的创建、写入、读取等代码,具体查看前面小节的内容和随书源码
public static void clearDemo() {
Logger.info("------------after clear------------------");
//清空缓冲区,进入写模式
intBuffer.clear();
//这个示例程序运行之后,结果如下:
main |>清空
clearDemo |> ------------after clear------------------
clearDemo |> position=0
clearDemo |> limit=20
clearDemo |> capacity=20
3.3.8 使用Buffer类的基本步骤
(1)使用创建子类实例对象的allocate()方法创建一个Buffer类的实例对象。
(2)调用put()方法将数据写入缓冲区中。
(3)写入完成后,在开始读取数据前调用Buffer.flip()方法,将缓冲区转换为读模式。
(4)调用get()方法,可以从缓冲区中读取数据。
(5)读取完成后,调用Buffer.clear()方法或Buffer.compact()方法,将缓冲区转换为写模式,可以继续写入。
3.4 详解NIO Channel类
前面提到,Java NIO中一个socket连接使用一个Channel来表示。从更广泛的层面来说,一个通道可以表示一个底层的文件描述符,例如硬件设备、文件、网络连接等。然而,远不止如此,Java NIO的通道可以更加细化。例如,不同的网络传输协议类型,在Java中都有不同的NIO Channel实现。
最为重要的四种Channel实现:FileChannel
、SocketChannel
、ServerSocketChannel
、DatagramChannel
。
对于以上四种通道,说明如下:
(1)FileChannel
:文件通道,用于文件的数据读写。
(2)SocketChannel
:套接字通道,用于套接字TCP连接的数据读写。
(3)ServerSocketChannel
:服务器套接字通道(或服务器监听通道),允许我们监听TCP连接请求,为每个监听到的请求创建一个SocketChannel通道。
(4)DatagramChannel
:数据报通道,用于UDP的数据读写。
3.4.1 FileChannel
FileChannel(文件通道)是专门操作文件的通道。通过FileChannel,既可以从一个文件中读取数据,也可以将数据写入文件中。特别申明一下,FileChannel为阻塞模式,不能设置为非阻塞模式。
- 获取FileChannel
可以通过文件的输入流、输出流获取FileChannel
//创建一个文件输入流
FileInputStream fis = new FileInputStream(srcFile);
//获取文件流的通道
FileChannel inChannel = fis.getChannel();
//创建一个文件输出流
FileOutputStream fos = new FileOutputStream(destFile);
//获取文件流的通道
FileChannel outchannel = fos.getChannel();
也可以通过RandomAccessFile(文件随机访问)类来获取FileChannel实例
//创建RandomAccessFile随机访问对象
RandomAccessFile rFile = new RandomAccessFile("filename.txt","rw");
//获取文件流的通道(可读可写)
FileChannel channel = rFile.getChannel();
- 读取FileChannel
在大部分应用场景中,从通道读取数据都会调用通道的int read(ByteBuffer buf)方法,它把从通道读取的数据写入ByteBuffer缓冲区,并且返回读取的数据量。
RandomAccessFile aFile = new RandomAccessFile(fileName, "rw");
//获取通道(可读可写)
FileChannel channel = aFile.getChannel();
//获取一个字节缓冲区
ByteBuffer buf = ByteBuffer.allocate(CAPACITY);
int length = -1;
//调用通道的read()方法,读取数据并写入字节类型的缓冲区
while ((length = channel.read(buf)) != -1) {
//省略buf中的数据处理
}
以上代码中channel.read(buf)读取通道的数据时,对于通道来说是读模式,对于ByteBuffer缓冲区来说是写入数据,这时ByteBuffer缓冲区处于写模式。
- 写入FileChannel
把数据写入通道,在大部分应用场景中都会调用通道的write(ByteBuffer)方法,此方法的参数是一个ByteBuffer缓冲区实例,是待写数据的来源。
write(ByteBuffer)方法的作用是从ByteBuffer缓冲区中读取数据,然后写入通道自身,而返回值是写入成功的字节数。
//如果buf处于写模式(如刚写完数据),需要翻转buf,使其变成读模式
buf.flip();
int outlength = 0;
//调用write()方法,将buf的数据写入通道
while ((outlength = outchannel.write(buf)) != 0) {
System.out.println("写入的字节数:" + outlength);
}
在以上的outchannel.write(buf)调用中,对于入参buf实例来说,需要从其中读取数据写入outchannel通道中,所以入参buf必须处于读模式,不能处于写模式。
- 关闭通道
通道使用完成后,必须将其关闭。关闭非常简单,调用close()方法即可。
//关闭通道
channel.close();
- 强制刷新到磁盘
在将缓冲区写入通道时,出于性能的原因,操作系统不可能每次都实时地将写入数据落地(或刷新)到磁盘,完成最终的数据保存。
在将缓冲区数据写入通道时,要保证数据能写入磁盘,可以在写入后调用一下FileChannel的force()方法。
//强制刷新到磁盘
channel.force(true);
3.4.2 使用FileChannel完成文件复制的实战案例
下面是一个简单的实战案例:使用FileChannel复制文件。具体的功能是使用FileChannel将原文件复制一份,把原文件中的数据都复制到目标文件中。
package com.crazymakercircle.iodemo.fileDemos;
//省略import,具体请参见源代码工程
public class FileNIOCopyDemo {
public static void main(String[] args) {
//演示复制资源文件
nioCopyResouceFile();
}
/**
* 复制两个资源目录下的文件
*/
public static void nioCopyResouceFile() {
//源
String sourcePath = NioDemoConfig.FILE_RESOURCE_SRC_PATH;
String srcPath = IOUtil.getResourcePath(sourcePath);
Logger.info("srcPath=" + srcPath);
//目标
String destPath = NioDemoConfig.FILE_RESOURCE_DEST_PATH;
String destDecodePath = IOUtil.builderResourcePath(destPath);
Logger.info("destDecodePath=" + destDecodePath);
//复制文件
nioCopyFile(srcDecodePath, destDecodePath);
}
/**
* NIO方式复制文件
* @param srcPath 源路径
* @param destPath目标路径
*/
public static void nioCopyFile(String srcPath, String destPath){
File srcFile = new File(srcPath);
File destFile = new File(destPath);
try {
//如果目标文件不存在,则新建
if (!destFile.exists()) {
destFile.createNewFile();
}
long startTime = System.currentTimeMillis();
FileInputStream fis = null;
FileOutputStream fos = null;
FileChannel inChannel = null; //输入通道
FileChannel outchannel = null; //输出通道
try {
fis = new FileInputStream(srcFile);
fos = new FileOutputStream(destFile);
inChannel = fis.getChannel();
outchannel = fos.getChannel();
int length = -1;
//新建buf,处于写模式
ByteBufferbuf = ByteBuffer.allocate(1024);
//从输入通道读取到buf
while ((length = inChannel.read(buf)) != -1) {
//buf第一次模式切换:翻转buf,从写模式变成读模式
buf.flip();
int outlength = 0;
//将buf写入输出的通道
while ((outlength = outchannel.write(buf)) != 0) {
System.out.println("写入的字节数:" + outlength);
}
//buf第二次模式切换:清除buf,变成写模式
buf.clear();
}
//强制刷新到磁盘
outchannel.force(true);
} finally {
//关闭所有的可关闭对象
IOUtil.closeQuietly(outchannel);
IOUtil.closeQuietly(fos);
IOUtil.closeQuietly(inChannel);
IOUtil.closeQuietly(fis);
}
long endTime = System.currentTimeMillis();
Logger.info("base复制毫秒数:" + (endTime - startTime));
} catch (IOException e) {
e.printStackTrace();
}
}
3.4.3 SocketChannel
在NIO中,涉及网络连接的通道有两个:一个是SocketChannel,负责连接的数据传输;另一个是ServerSocketChannel,负责连接的监听。其中,NIO中的SocketChannel传输通道与OIO中的Socket类对应,NIO中的ServerSocketChannel监听通道对应于OIO中的ServerSocket类。
ServerSocketChannel仅应用于服务端,而SocketChannel同时处于服务端和客户端。所以,对于一个连接,两端都有一个负责传输的SocketChannel。
无论是ServerSocketChannel还是SocketChannel,都支持阻塞和非阻塞两种模式。如何进行模式的设置呢?调用configureBlocking()方法,具体如下:
(1)socketChannel.configureBlocking(false)设置为非阻塞模式。
(2)socketChannel.configureBlocking(true)设置为阻塞模式。
在非阻塞模式下,通道的操作是异步、高效的,这也是相对于传统OIO的优势所在。下面详细介绍在非阻塞模式下通道的获取、读写和关闭等操作。
- 获取SocketChannel传输通道
在客户端,先通过SocketChannel静态方法open()获得一个套接字传输通道,然后将socket设置为非阻塞模式,最后通过connect()实例方法对服务器的IP和端口发起连接。
//获得一个套接字传输通道
SocketChannel socketChannel = SocketChannel.open();
//设置为非阻塞模式
socketChannel.configureBlocking(false);
//对服务器的IP和端口发起连接
socketChannel.connect(new InetSocketAddress("127.0.0.1", 80));
在非阻塞情况下,与服务器的连接可能还没有真正建立,socketChannel.connect()方法就返回了,因此需要不断地自旋,检查当前是否连接到了主机:
while(! socketChannel.finishConnect() ){
//不断地自旋、等待,或者做一些其他的事情
}
在连接建立的事件到来时,服务端的ServerSocketChannel能成功地查询出这个新连接事件,并且通过调用服务端ServerSocketChannel监听套接字的accept()方法来获取新连接的套接字通道:
//新连接事件到来,首先通过事件获取服务器监听通道
ServerSocketChannel server = (ServerSocketChannel) key.channel();
//获取新连接的套接字通道
SocketChannel socketChannel = server.accept();
//设置为非阻塞模式
socketChannel.configureBlocking(false);
NIO套接字通道主要用于非阻塞的传输场景。所以,基本上都需要调用通道的configureBlocking(false)方法,将通道从阻塞模式切换为非阻塞模式。
- 读取SocketChannel传输通道
调用read()方法,将数据读入缓冲区ByteBuffer。
ByteBufferbuf = ByteBuffer.allocate(1024);
int bytesRead = socketChannel.read(buf);
在读取时,因为是异步的,所以我们必须检查read()的返回值,以便判断当前是否读取到了数据。read()方法的返回值是读取的字节数,如果是-1,那么表示读取到对方的输出结束标志,即对方已经输出结束,准备关闭连接。实际上,通过read()方法读数据本身是很简单的,比较困难的是在非阻塞模式下如何知道通道何时是可读的。
- 写入SocketChannel传输通道
大部分应用场景都会调用通道的int write(ByteBufferbuf)方法。
//写入前需要读取缓冲区,要求ByteBuffer是读模式
buffer.flip();
socketChannel.write(buffer);
- 关闭SocketChannel传输通道
在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用一次shutdownOutput()终止输出方法,向对方发送一个输出的结束标志(-1)。然后调用socketChannel.close()方法,关闭套接字连接。
//调用终止输出方法,向对方发送一个输出的结束标志
socketChannel.shutdownOutput();
//关闭套接字连接
IOUtil.closeQuietly(socketChannel);
3.4.4 使用SocketChannel发送文件的实战案例
下面的实战案例是使用FileChannel读取本地文件内容,然后在客户端使用SocketChannel把文件信息和文件内容发送到服务器。客户端的完整代码如下:
package com.crazymakercircle.iodemo.socketDemos;
//…
public class NioSendClient {
private Charset charset = Charset.forName("UTF-8");
/**
* 向服务端传输文件
*/
public void sendFile()
{
try
{
String sourcePath = NioDemoConfig.SOCKET_SEND_FILE;
String srcPath = IOUtil.getResourcePath(sourcePath);
Logger.debug("srcPath=" + srcPath);
String destFile = NioDemoConfig.SOCKET_RECEIVE_FILE;
Logger.debug("destFile=" + destFile);
File file = new File(srcPath);
if (!file.exists())
{
Logger.debug("文件不存在");
return;
}
FileChannel fileChannel = new FileInputStream(file).getChannel();
SocketChannel socketChannel = SocketChannel.open();
socketChannel.socket().connect(new InetSocketAddress("127.0.0.1",18899));
socketChannel.configureBlocking(false);
Logger.debug("Client 成功连接服务端");
while (!socketChannel.finishConnect())
{
//不断地自旋、等待,或者做一些其他的事情
}
//发送文件名称和长度
ByteBuffer buffer = sengFileNameAndLength(destFile, file, socketChannel);
//发送文件内容
int length = sendContent(file, fileChannel, socketChannel, buffer);
if (length == -1)
{
IOUtil.closeQuietly(fileChannel);
socketChannel.shutdownOutput();
IOUtil.closeQuietly(socketChannel);
}
Logger.debug("======== 文件传输成功 ========");
} catch (Exception e)
{
e.printStackTrace();
}
}
//方法:发送文件内容
public int sendContent(File file, FileChannel fileChannel,
SocketChannel socketChannel,
ByteBuffer buffer) throws IOException
{
//发送文件内容
Logger.debug("开始传输文件");
int length = 0;
long progress = 0;
while ((length = fileChannel.read(buffer)) > 0)
{
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
progress += length;
Logger.debug("| " + (100 * progress / file.length()) + "% |");
}
return length;
}
//方法:发送文件名称和长度
public ByteBuffer sengFileNameAndLength(String destFile,
File file,
SocketChannel socketChannel) throws IOException
{
//发送文件名称
ByteBuffer fileNameByteBuffer = charset.encode(destFile);
ByteBuffer buffer = ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);
//发送文件名称长度
int fileNameLen = fileNameByteBuffer.capacity();
buffer.putInt(fileNameLen);
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
Logger.info("Client 文件名称长度发送完成:", fileNameLen);
//发送文件名称
socketChannel.write(fileNameByteBuffer);
Logger.info("Client 文件名称发送完成:", destFile);
//发送文件长度
buffer.putLong(file.length());
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
Logger.info("Client 文件长度发送完成:", file.length());
return buffer;
}
}
3.4.5 DatagramChannel
在Java NIO中,使用DatagramChannel来处理UDP的数据传输。
- 获取DatagramChannel
获取数据报通道的方式很简单,调用DatagramChannel类的open()静态方法即可。然后调用configureBlocking(false)方法,设置成非阻塞模式。
//获取DatagramChannel
DatagramChannel channel = DatagramChannel.open();
//设置为非阻塞模式
datagramChannel.configureBlocking(false);
如果需要接收数据,还需要调用bind()方法绑定一个数据报的监听端口,具体如下:
//调用bind()方法绑定一个数据报的监听端口
channel.socket().bind(new InetSocketAddress(18080));
- 从DatagramChannel读取数据
当DatagramChannel通道可读时,可以从DatagramChannel读取数据。和前面的SocketChannel读取方式不同,这里不调用read()方法,而是调用receive(ByteBufferbuf)方法将数据从DatagramChannel读入,再写入ByteBuffer缓冲区中。
//创建缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
//从DatagramChannel读入,再写入ByteBuffer缓冲区
SocketAddress clientAddr= datagramChannel.receive(buf);
通道读取receive(ByteBufferbuf)方法虽然读取了数据到buf缓冲区,但是其返回值是SocketAddress类型,表示返回发送端的连接地址(包括IP和端口)。
- 写入DatagramChannel
向DatagramChannel发送数据,和向SocketChannel通道发送数据的方法是不同的。这里不是调用write()方法,而是调用send()方法。
//把缓冲区翻转为读模式
buffer.flip();
//调用send()方法,把数据发送到目标IP+端口
dChannel.send(buffer, new InetSocketAddress("127.0.0.1",18899));
//清空缓冲区,切换到写模式
buffer.clear();
- 关闭DatagramChannel
//简单关闭即可
dChannel.close();
3.4.6 使用DatagramChannel发送数据的实战案例
下面是一个使用DatagramChannel发送数据的客户端示例程序,功能是获取用户的输入数据,通过DatagramChannel将数据发送到远程的服务器。 ```java package com.crazymakercircle.iodemo.udpDemos; //… public class UDPClient { public void send() throws IOException {
} public static void main(String[] args) throws IOException {//获取DatagramChannel
DatagramChannel dChannel = DatagramChannel.open();
//设置为非阻塞
dChannel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);
Scanner scanner = new Scanner(System.in);
Print.tcfo("UDP客户端启动成功!");
Print.tcfo("请输入发送内容:");
while (scanner.hasNext()) {
String next = scanner.next();
buffer.put((Dateutil.getNow() + " >>" + next).getBytes());
buffer.flip();
//通过DatagramChannel发送数据
dChannel.send(buffer,new InetSocketAddress("127.0.0.1",18899));
buffer.clear();
}
//关闭DatagramChannel
dChannel.close();
} }new UDPClient().send();
接下来看看在服务端应该如何使用DatagramChannel接收数据。<br />服务端是通过DatagramChannel绑定一个服务器地址(IP+端口),接收客户端发送过来的UDP数据报。服务端的完整代码如下:
```java
package com.crazymakercircle.iodemo.udpDemos;
//…
public class UDPServer {
public void receive() throws IOException {
//获取DatagramChannel
DatagramChannel datagramChannel = DatagramChannel.open();
//设置为非阻塞模式
datagramChannel.configureBlocking(false);
//绑定监听地址
datagramChannel.bind(new InetSocketAddress("127.0.0.1",18899));
Print.tcfo("UDP服务器启动成功!");
//开启一个通道选择器
Selector selector = Selector.open();
//将通道注册到选择器
datagramChannel.register(selector, SelectionKey.OP_READ);
//通过选择器查询IO事件
while (selector.select() > 0) {
Iterator<SelectionKey> iterator =
selector.selectedKeys().iterator();
ByteBuffer buffer =
ByteBuffer.allocate(NioDemoConfig.SEND_BUFFER_SIZE);
//迭代IO事件
while (iterator.hasNext()) {
SelectionKeyselectionKey = iterator.next();
//可读事件,有数据到来
if (selectionKey.isReadable()) {
//读取DatagramChannel数据
SocketAddress client = datagramChannel.receive(buffer);
buffer.flip();
Print.tcfo(new String(buffer.array(), 0, buffer.limit()));
buffer.clear();
}
}
iterator.remove();
}
//关闭选择器和通道
selector.close();
datagramChannel.close();
}
public static void main(String[] args) throws IOException {
new UDPServer().receive();
}
}
3.5 详解NIO Selector
3.5.1 选择器与注册
简单地说,选择器的使命是完成IO的多路复用,其主要工作是通道的注册、监听、事件查询。一个通道代表一条连接通路,通过选择器可以同时监控多个通道的IO(输入输出)状况。选择器和通道的关系是监控和被监控的关系。
选择器提供了独特的API方法,能够选出(select)所监控的通道已经发生了哪些IO事件,包括读写就绪的IO操作事件。
在NIO编程中,一般是一个单线程处理一个选择器,一个选择器可以监控很多通道。所以,通过选择器,一个单线程可以处理数百、数千、数万甚至更多的通道。在极端情况下(数万个连接),只用一个线程就可以处理所有的通道,这样会大量地减少线程之间上下文切换的开销。
通道和选择器之间的关联通过register(注册)的方式完成。调用通道的Channel.register(Selector sel,int ops)
方法,可以将通道实例注册到一个选择器中。register方法有两个参数:第一个参数指定通道注册到的选择器实例;第二个参数指定选择器要监控的IO事件类型。
可供选择器监控的通道IO事件类型包括以下四种:
(1)可读:SelectionKey.OP_READ。
(2)可写:SelectionKey.OP_WRITE。
(3)连接:SelectionKey.OP_CONNECT。
(4)接收:SelectionKey.OP_ACCEPT。
//监控通道的多种事件,用“按位或”运算符来实现
int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE ;
什么是IO事件?
这里的IO事件不是对通道的IO操作,而是通道处于某个IO操作的就绪状态,表示通道具备执行某个IO操作的条件。
3.5.2 SelectableChannel
并不是所有的通道都是可以被选择器监控或选择的。判断一个通道能否被选择器监控或选择有一个前提:判断它是否继承了抽象类SelectableChannel(可选择通道),如果是,就可以被选择,否则不能被选择。
3.5.3 SelectionKey
通道和选择器的监控关系注册成功后就可以选择就绪事件,具体的选择工作可调用Selector的select()方法来完成。通过select()方法,选择器可以不断地选择通道中所发生操作的就绪状态,返回注册过的那些感兴趣的IO事件。
通过SelectionKey
不仅可以获得通道的IO事件类型,还可以获得发生IO事件所在的channel,另外,还可以获得selector
3.5.4 选择器使用流程
获取选择器实例。选择器实例是通过调用静态工厂方法open()来获取的
//调用静态工厂方法open()来获取Selector实例
Selector selector = Selector.open();
Selector的类方法open()的内部是向选择器SPI发出请求,通过默认的SelectorProvider(选择器提供者)对象获取一个新的选择器实例。Java中的SPI(Service Provider Interface,服务提供者接口)是一种可以扩展的服务提供和发现机制。Java通过SPI的方式提供选择器的默认实现版本。也就是说,其他的服务提供者可以通过SPI的方式提供定制化版本的选择器的动态替换或者扩展。
将通道注册到选择器实例。要实现选择器管理通道,需要将通道注册到相应的选择器上 ```java //获取通道 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); //设置为非阻塞 serverSocketChannel.configureBlocking(false); //绑定连接 serverSocketChannel.bind(new InetSocketAddress(18899)); //将通道注册到选择器上,并指定监听事件为“接收连接” serverSocketChannel.register(selector,SelectionKey.OP_ACCEPT);
FileChannel不能与选择器一起使用,因为FileChannel只有阻塞模式,不能切换到非阻塞模式;而socket相关的所有通道都可以<br />一个通道并不一定支持所有的四种IO事件。例如,服务器监听通道ServerSocketChannel仅支持Accept(接收到新连接)IO事件,而传输通道SocketChannel则不同,它不支持Accept类型的IO事件。<br />可以在注册之前通过通道的validOps()方法来获取该通道支持的所有IO事件集合。
3. 选出感兴趣的IO就绪事件(选择键集合)。
```java
//轮询,选择感兴趣的IO就绪事件(选择键集合)
while (selector.select() > 0) {
Set selectedKeys = selector.selectedKeys();
Iterator keyIterator = selectedKeys.iterator();
while(keyIterator.hasNext()) {
SelectionKey key = keyIterator.next();
//根据具体的IO事件类型执行对应的业务操作
if(key.isAcceptable()) {
//IO事件:ServerSocketChannel服务器监听通道有新连接
} else if (key.isConnectable()) {
//IO事件:传输通道连接成功
} else if (key.isReadable()) {
//IO事件:传输通道可读
} else if (key.isWritable()) {
//IO事件:传输通道可写
}
//处理完成后,移除选择键
keyIterator.remove();
}
}
处理完成后,需要将选择键从SelectionKey集合中移除,以防止下一次循环时被重复处理。SelectionKey集合不能添加元素,如果试图向SelectionKey中添加元素,则将抛出java.lang.UnsupportedOperationException异常。<br />用于选择就绪的IO事件的select()方法有多个重载的实现版本,具体如下:
- select():阻塞调用,直到至少有一个通道发生了注册的IO事件。
- select(long timeout):和select()一样,但最长阻塞时间为timeout指定的毫秒数。
selectNow():非阻塞,不管有没有IO事件都会立刻返回。
3.5.5 使用NIO实现Discard服务器的实战案例
Discard服务器的功能很简单:仅读取客户端通道的输入数据,读取完成后直接关闭客户端通道,并且直接抛弃掉(Discard)读取到的数据。 ```java package com.crazymakercircle.iodemo.NioDiscard; //… public class NioDiscardServer { public static void startServer() throws IOException {
//1.获取选择器
Selector selector = Selector.open();
//2.获取通道
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
//3.设置为非阻塞
serverSocketChannel.configureBlocking(false);
//4.绑定连接
serverSocketChannel.bind(new InetSocketAddress(18899));
Logger.info("服务器启动成功");
//5.将通道注册的“接收新连接”IO事件注册到选择器上
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
//6.轮询感兴趣的IO就绪事件(选择键集合)
while (selector.select() > 0) {
//7.获取选择键集合
Iterator<SelectionKey> selectedKeys = selector.selectedKeys().iterator();
while (selectedKeys.hasNext()) {
//8.获取单个的选择键,并处理
SelectionKey selectedKey = selectedKeys.next();
//9.判断key是具体的什么事件
if (selectedKey.isAcceptable()) {
//10.若选择键的IO事件是“连接就绪”,就获取客户端连接
SocketChannel socketChannel = serverSocketChannel.accept();
//11.将新连接切换为非阻塞模式
socketChannel.configureBlocking(false);
//12.将新连接的通道的可读事件注册到选择器上
socketChannel.register(selector, SelectionKey.OP_READ);
} else if (selectedKey.isReadable()) {
//13.若选择键的IO事件是“可读”,则读取数据
SocketChannel socketChannel = (SocketChannel) selectedKey.channel();
//14.读取数据,然后丢弃
ByteBufferbyteBuffer = ByteBuffer.allocate(1024);
int length = 0;
while ((length = socketChannel.read(byteBuffer)) >0)
{
byteBuffer.flip();
Logger.info(new String(byteBuffer.array(), 0, length));
byteBuffer.clear();
}
socketChannel.close();
}
//15.移除选择键
selectedKeys.remove();
}
}
//16.关闭连接
serverSocketChannel.close();
} public static void main(String[] args) throws IOException {
startServer();
} }
客户端首先建立到服务器的连接,发送一些简单的数据,然后直接关闭连接。客户端的DiscardClient代码更加简单
```java
package com.crazymakercircle.iodemo.NioDiscard;
//…
public class NioDiscardClient {
public static void startClient() throws IOException {
InetSocketAddress address =new InetSocketAddress("127.0.0.1",18899);
//1.获取通道
SocketChannel socketChannel = SocketChannel.open(address);
//2.切换成非阻塞模式
socketChannel.configureBlocking(false);
//不断地自旋、等待连接完成,或者做一些其他的事情
while (!socketChannel.finishConnect()) {
}
Logger.info("客户端连接成功");
//3.分配指定大小的缓冲区
ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
byteBuffer.put("hello world".getBytes());
byteBuffer.flip();
//发送到服务器
socketChannel.write(byteBuffer);
socketChannel.shutdownOutput();
socketChannel.close();
}
public static void main(String[] args) throws IOException {
startClient();
}
}