Java NIO中的Channel类
Channel
Channel翻译成通道,Channel的角色和OIO中的Stream(流)是类似的,在OIO(OIO的操作是阻塞的,而NIO的操作是非阻塞的)中,同一个网络连接会关联到两个流:一个是输入流(Input Stream),另一个是输出流(Output Stream),Java应用程序通过这两个流,不断地进行输入和输出的操作;
在NIO中,一个网络连接使用一个Channel(通道)表示,所有的NIO的IO操作都是通过连接Channel完成的,Channel的数据流向是双向的,既可以用来进行读操作,又可以用来进行写操作;
注:
Channel和Stream的一个显著的不同,Stream的数据流向是单向的,如InputStream是单向的只读流,OutputStream是单向的只写流;而Channel的数据流向是双向,既可以用来进行读操作,又可以用来进行写操作;
对于底层的物理链路,操作系统会为应用创建一个文件描述符(file descriptor),用于标识这种底层的物理链路;
一个Java对象有内存的数据结构和内存地址, 那么一个文件描述符(file descriptor)也有一个内核的数据结构和一个进程内的唯一编号来表示;然后操作系统会把这个文件描述提供给应用层,应用层通过对这个文件描述符(file descriptor)去对传输链路进行数据的读取和写入;
参考:https://en.wikipedia.org/wiki/File_descriptor
https://www.computerhope.com/jargon/f/file-descriptor.htm
Channel的理解
TCP/IP四层模型包含:应用层,传输层,网际层,网络接口层;其中,网络接口层,通常也称为数据链路层或数据链路,是TCP/IP模型的最底层;
参考:https://zh.wikipedia.org/wiki/%E7%BD%91%E7%BB%9C%E6%8E%A5%E5%8F%A3%E5%B1%82%E5%AE%89%E5%85%A8
NIO中的传输通道,实际上就是对底层的传输链路所对应的文件描述符(file descriptor)的一种封装,以SocketChannel为例,如下:
sun.nio.ch.SocketChannelImpl
SocketChannelImpl成员属性fd为文件描述符对象;
java.io.FileDescriptor
FileDescriptor成员属性fd为文件描述符的进程内的唯一编号;
对于两个Java应用通过NIO建立双向的连接(传输链路),它们各自都会有一个内部的文件描述符(file descriptor),代表这条连接的各自的应用;如下图:
NIO中的Channel的主要实现
NIO中的Channel的主要实现,如下:
- FileChannel
文件通道,用于文件的数据读写;
- DatagramChannel
数据报通道,用于UDP协议的数据读写;
- SocketChannel
套接字通道,用于Socket套接字TCP连接的数据读写;
- ServerSocketChannel
服务器套接字通道(或服务器监听通道),允许应用监听TCP连接请求,为每个监听到的请求,创建一个 SocketChannel套接字通道;
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 = r File.getChannel();
读取FileChannel通道
在大部分应用场景,从通道读取数据都会调用通道的java.nio.channels.FileChannel#read(java.nio.ByteBuffer)方法,它从通道读取到数据写入到 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缓冲区实例,是待写数据的来源;
java.nio.channels.FileChannel#write(java.nio.ByteBuffer)
该方法的作用是从 ByteBuffer缓冲区中读取数据,然后写入到通道自身,而返回值是写入成功的字节数;
注:该方法的入参是需要从其中读取数据写入到通道中,所以入参必须处于读取模式,不能处于写入模式;
示例如下:
// 如果buf处于写入模式(如刚写完数据),需要flip翻转buf,使其变成读取模式
buf.flip();
int outlength = 0;
// 调用write方法,将buf的数据写入通道
while ((outlength = outchannel.write(buf)) != 0) {
// buf中的数据处理
}
关闭通道
当通道使用完成后,必须将其关闭,调用close方法即可;
示例如下:
// 关闭通道
channel.close( );
强制刷新到磁盘
在将缓冲区写入通道时,出于性能原因,操作系统不可能每次都实时将写入数据刷到磁盘,完成最终的数据保存;
如果在将缓冲数据写入通道时,需要保证数据能落地写入到磁盘,可以在写入后调用一下FileChannel#force方法;
示例如下:
// 强制刷新到磁盘
channel.force(true);
FileChannel文件复制的示例
查看代码
public class FileChannelCopyDemo {
private final static Logger logger = LoggerFactory.getLogger(FileChannelCopyDemo.class);
/**
* 演示程序的入口函数
*
* @param args
*/
public static void main(String[] args) {
//演示复制资源文件
nioCopyResourceFile();
}
/**
* 复制两个资源目录下的文件
*/
public static void nioCopyResourceFile() {
String sourcePath = "/filename.txt";
String srcPath = getResourcePath(sourcePath);
logger.info("srcPath:{}", srcPath);
String destPathName = "copy";
String destPath = builderResourcePath(destPathName);
logger.info("destPath:{}", destPath);
String destFileName = "copy.txt";
nioCopyFile(srcPath, destPath, destFileName);
}
/**
* 复制文件
* @param srcPath
* @param destPath
* @param destFileName
*/
public static void nioCopyFile(String srcPath, String destPath, String destFileName) {
File srcFile = new File(srcPath);
File destDir = new File(destPath);
File destFile = new File(destDir, destFileName);
try {
if (!destDir.exists()) {
destDir.mkdir();
}
//如果目标文件不存在,则新建
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();
ByteBuffer buf = ByteBuffer.allocateDirect(1024);
//从输入通道读取到buf
while (inChannel.read(buf) != -1) {
//翻转buf,变成成读模式
buf.flip();
int outlength = 0;
//将buf写入到输出的通道
while ((outlength = outchannel.write(buf)) != 0) {
System.out.println("写入字节数:" + outlength);
}
//清除buf,变成写入模式
buf.clear();
}
//强制刷新磁盘
outchannel.force(true);
} finally {
outchannel.close();
fos.close();
inChannel.close();
fis.close();
}
long endTime = System.currentTimeMillis();
logger.info("复制毫秒数:{}", (endTime - startTime));
} catch (IOException e) {
e.printStackTrace();
}
}
private static String getResourcePath(String resName) {
URL url = FileChannelCopyDemo.class.getResource(resName);
String path = null;
if (null == url) {
path = FileChannelCopyDemo.class.getResource("/").getPath() + resName;
} else {
path = url.getPath();
}
String decodePath = null;
try {
decodePath = URLDecoder.decode(path, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
if (isWin()) {
return decodePath.substring(1);
}
return decodePath;
}
private static boolean isWin() {
String os = System.getProperty("os.name");
if (os.toLowerCase().startsWith("win")) {
return true;
}
return false;
}
/**
* 构建当前类路径下的 resName资源的完整路径
* url.getPath()获取到的路径被utf-8编码了
* 需要用URLDecoder.decode(path, "UTF-8")解码
*
* @param resName 需要获取完整路径的资源,需要以/打头
* @return 完整路径
*/
public static String builderResourcePath(String resName) {
URL url = FileChannelCopyDemo.class.getResource("/");
String path = url.getPath();
String decodePath = null;
try {
decodePath = URLDecoder.decode(path, "UTF-8");
} catch (UnsupportedEncodingException e) {
e.printStackTrace();
}
return decodePath + resName;
}
}
示例中新建的ByteBuffer是写入模式才可以作为inChannel的read方法的参数,read方法将从inChannel读到的数据写入到ByteBuffer,之后需要调用缓冲区的flip方法,将ByteBuffer从写入模式切换成读取模式,这才能作为outchannel的write方法的参数,以便从ByteBuffer读取数据,最终写入到outchannel输出通道;
SocketChannel套接字通道
在NIO中,涉及网络连接的通道有两个:一个是 SocketChannel负责连接的数据传输,另一个是 ServerSocketChannel负责连接的监听(即TCP协议交互);其中NIO中的SocketChannel传输通道,与OIO中的Socket类对应;NIO中的 ServerSocketChannel监听通道,对应于OIO中的ServerSocket类;
ServerSocketChannel仅仅应用于服务器端,而SocketChannel则同时处于服务器端和客户端;
TCP协议服务端与客户端的API交互如下:
无论是ServerSocketChannel,还是SocketChannel,都支持阻塞和非阻塞两种模式;通过调用AbstractSelectableChannel#configureBlocking方法设置是否阻塞;
// 设置为非阻塞模式
socketChannel.configureBlocking(false);
// 设置为阻塞模式
socketChannel.configureBlocking(true);
在阻塞模式下,SocketChannel通道的connect连接、read读、write写操作都是同步的和阻塞式的,在效率上与Java旧的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);
读取SocketChannel传输通道
当SocketChannel传输通道可读时,可以从SocketChannel读取数据,具体方法与前面的文件通道读取方法是相同的,调用read方法,将数据读入缓冲区 ByteBuffer;示例如下:
ByteBuffer buf = ByteBuffer.allocate(1024);
int bytesRead = socketChannel.read(buf);
写入到SocketChannel传输通道
大部分应用场景,数据写入到SocketChannel都会调用通道的write方法;示例如下:
// 写入前需要读取缓冲区,要求ByteBuffer是读取模式
buffer.flip();
socketChannel.write(buffer);
关闭SocketChannel传输通道
在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用一次shutdownOutput终止输出方法,向对方发送一个输出的结束标志,然后调用SocketChannel的close方法,关闭套接字连接;示例如下:
// 调用终止输出方法,向对方发送一个输出的结束标志
socketChannel.shutdownOutput();
// 关闭套接字连接
socketChannel.close();
DatagramChannel数据报通道
在Java中使用UDP协议传输数据,比TCP协议更加简单;与Socket套接字的TCP传输协议不同,UDP协议不是面向连接的协议;使用UDP协议时,只要知道服务器的 IP和端口就可以直接向对方发送数据;在Java NIO中,使用DatagramChannel数据报通道来处理 UDP协议的数据传输;
UDP协议客户端和服务端的API交互如下:
获取DatagramChannel数据报通道
获取数据报通道的方式很简单,调用DatagramChannel类的 open静态方法即可,然后调用configureBlocking方法,设置是否阻塞;示例如下:
// 获取DatagramChannel数据报通道
DatagramChannel channel = DatagramChannel.open();
// 设置为非阻塞模式
datagramChannel.configureBlocking(false);
不论是TCP还是UDP,对于服务端,还需要调用bind方法绑定一个数据报的监听端口,bind方法将IP地址相关信息与套接字关联起来;
创建socket时指定了它的地址族,但是没有指定使用该地址中的哪个具体socket地址,因此需要将一个socket和socket地址绑定;将一个socket与socket地址绑定称为socket命名;在服务器程序中,通常需要命名socket,只有命名后客户端才能知道该如何连接它;客户端通常不需要命名socket,而是采用匿名方式,即使用操作系统自动分配的socket地址;
实例如下:
// 调用bind方法绑定一个数据报的监听端口
channel.socket().bind(new InetSocketAddress(10000));
读取DatagramChannel数据报通道数据
当DatagramChannel通道可读时,可以从 DatagramChannel读取数据,与SocketChannel读取方式不同,这里不调用read方法,而是调用receive方法将数据从DatagramChannel读入,再写入到 ByteBuffer缓冲区中;示例如下:
// 创建缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
// 从DatagramChannel读入,再写入到ByteBuffer缓冲区
SocketAddress clientAddr= datagramChannel.receive(buf);
通道读取receive方法虽然读取了数据到buf缓冲区,但是其返回值是SocketAddress类型,表示返回发送端的连接地址(包括IP和端口);
写入DatagramChannel数据报通道
向DatagramChannel发送数据与向SocketChannel发送数据的方法是不同的,DatagramChannel这里不是调用write方法,而是调用send方法;示例如下:
// 把缓冲区翻转到读取模式
buffer.flip();
// 调用send方法,把数据发送到目标IP+端口
dChannel.send(buffer, new InetSocketAddress( 127.0.0.1",10000));
// 清空缓冲区,切换到写入模式
buffer.clear();
由于UDP是面向非连接的协议,因此在调用 send方法发送数据的时候,需要指定接收方的地址(IP和端口);
关闭 DatagramChannel数据报通道
直接调用close方法,即可关闭数据报通道;
dChannel.close();