Channel(通道)类
NIO中一个连接就是用一个Channel(通道)来表示。大家知道,从更广泛的层面来说,一个通道可以表示一个底层的文件描述符,例如硬件设备、文件、网络连接等。然而,远远不止如此,除了可以对应到底层文件描述符,Java NIO的通道还可以更加细化。例如,对应不同的网络传输协议类型,在Java中都有不同的NIO Channel(通道)实现。
1 Channel(通道)的主要类型
这里不对纷繁复杂的Java NIO通道类型进行过多的描述,仅仅聚焦于介绍其中最为重要的四种Channel(通道)实现:FileChannel、SocketChannel、ServerSocketChannel、DatagramChannel。对于以上四种通道,说明如下:
(1)FileChannel文件通道,用于文件的数据读写。
(2)SocketChannel套接字通道,用于Socket套接字TCP连接的数据读写。(3)ServerSocketChannel服务器嵌套字通道(或服务器监听通道),允许我们监听TCP连接请求,为每个监听到的请求,创建一个SocketChannel套接字通道。(4)DatagramChannel数据报通道,用于UDP协议的数据读写。这个四种通道,涵盖了文件IO、TCP网络、UDP IO基础IO。下面从Channel(通道)的获取、读取、写入、关闭四个重要的操作,来对四种通道进行简单的介绍
2 FileChannel文件通道
可以通过文件的输入流、输出流获取FileChannel文件通道,示例如下:
//创建一条文件输入流
FileInputStreamfis = new FileInputStream(srcFile);
//获取文件流的通道
FileChannelinChannel = fis.getChannel();
//创建一条文件输出流
FileOutputStreamfos = new FileOutputStream(destFile);
//获取文件流的通道
FileChanneloutchannel = fos.getChannel();
也可以通过RandomAccessFile文件随机访问类,获取FileChannel文件通道:
// 创建RandomAccessFile随机访问对象
RandomAccessFileaFile = new RandomAccessFile("filename.txt", "rw");
//获取文件流的通道
FileChannelinChannel = aFile.getChannel();
2.读取FileChannel通道
在大部分应用场景,从通道读取数据都会调用通道的int read(ByteBufferbuf)方法,它从通道读取到数据写入到ByteBuffer缓冲区,并且返回读取到的数据量。
RandomAccessFileaFile = new RandomAccessFile(fileName, "rw");
//获取通道
FileChannelinChannel=aFile.getChannel();
//获取一个字节缓冲区
ByteBufferbuf = ByteBuffer.allocate(CAPACITY);
int length = -1;
//调用通道的read方法,读取数据并买入字节类型的缓冲区
while ((length = inChannel.read(buf)) ! = -1) {
//省略……处理读取到的buf中的数据
}
虽然对于通道来说是读取数据,但是对于ByteBuffer缓冲区来说是写入数据,这时,ByteBuffer缓冲区处于写入模式
3.写入FileChannel通道
写入数据到通道,在大部分应用场景,都会调用通道的int write(ByteBufferbuf)方法。此方法的参数——ByteBuffer缓冲区,是数据的来源。write方法的作用,是从ByteBuffer缓冲区中读取数据,然后写入到通道自身,而返回值是写入成功的字节数。
//如果buf刚写完数据,需要flip翻转buf,使其变成读取模式
buf.flip();
int outlength = 0;
//调用write方法,将buf的数据写入通道
while ((outlength = outchannel.write(buf)) ! = 0) {
System.out.println("写入的字节数:" + outlength);
}
此时的ByteBuffer缓冲区要求是可读的,处于读模式下。
4.关闭通道
当通道使用完成后,必须将其关闭。关闭非常简单,调用close方法即可。
//关闭通道
channel.close();
5.强制刷新到磁盘
在将缓冲区写入通道时,出于性能原因,操作系统不可能每次都实时将数据写入磁盘。如果需要保证写入通道的缓冲数据,最终都真正地写入磁盘,可以调用FileChannel的force()方法。
//强制刷新到磁盘
channel.force(true);
3 使用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();
FileInputStreamfis = null;
FileOutputStreamfos = null;
FileChannelinChannel = null;
FileChanneloutchannel = null;
try {
fis = new FileInputStream(srcFile);
fos = new FileOutputStream(destFile);
inChannel = fis.getChannel();
outchannel = fos.getChannel();
int length = -1;
ByteBufferbuf = ByteBuffer.allocate(1024);
//从输入通道读取到buf
while ((length = 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 {
//关闭所有的可关闭对象
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();
}
}
特别强调一下,除了FileChannel的通道操作外,还需要注意ByteBuffer的模式切换。新建的ByteBuffer,默认是写入模式,可以作为inChannel.read(ByteBuffer)的参数。inChannel.read方法将从通道inChannel读到的数据写入到ByteBuffer。
此后,需要调用缓冲区的flip方法,将ByteBuffer切换成读取模式,才能作为outchannel.write(ByteBuffer)方法的参数,从ByteBuffer读取数据,再写入到outchannel输出通道。
如此,便是完成一次复制。在进入下一次复制前,还要进行一次缓冲区的模式切换。ByteBuffer数据读完之后,需要将通过clear方法切换成写入模式,才能进入下一次的复制。
在示例代码中,外层的每一轮while循环,都需要两次模式ByteBuffer切换:第一次切换时,翻转buf,变成读取模式;第二次切换时,清除buf,变成写入模式。
上面的示例代码,主要的目的在于:演示文件通道以及字节缓冲区的使用。作为文件复制的程序来说,实战代码的效率不是最高的。
更高效的文件复制,可以调用文件通道的transferFrom方法。具体的代码,可以参见源代码工程中的FileNIOFastCopyDemo类,完整源文件的路径为:com.crazymakercircle.iodemo.fileDemos.FileNIOFastCopyDemo
4 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)设置为阻塞模式。
在阻塞模式下,SocketChannel通道的connect连接、read读、write写操作,都是同步的和阻塞式的,在效率上与Java旧的OIO的面向流的阻塞式读写操作相同。因此,在这里不介绍阻塞模式下的通道的具体操作。在非阻塞模式下,通道的操作是异步、高效率的,这也是相对于传统的OIO的优势所在。下面详细介绍在非阻塞模式下通道的打开、读写和关闭操作等操作。
1.获取SocketChannel传输通道
在客户端,先通过SocketChannel静态方法open()获得一个套接字传输通道;然后,将socket套接字设置为非阻塞模式;最后,通过connect()实例方法,对服务器的IP和端口发起连接。
//获得一个套接字传输通道
SocketChannelsocketChannel = 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();
//获取新连接的套接字通道
SocketChannelsocketChannel = server.accept();
//设置为非阻塞模式
socketChannel.configureBlocking(false);
强调一下,NIO套接字通道,主要用于非阻塞应用场景。所以,需要调用configureBlocking(false),从阻塞模式设置为非阻塞模式。
2.读取SocketChannel传输通道
当SocketChannel通道可读时,可以从SocketChannel读取数据,具体方法与前面的文件通道读取方法是相同的。调用read方法,将数据读入缓冲区ByteBuffer。
ByteBufferbuf = ByteBuffer.allocate(1024);
int bytesRead = socketChannel.read(buf);
在读取时,因为是异步的,因此我们必须检查read的返回值,以便判断当前是否读取到了数据。read()方法的返回值,是读取的字节数。如果返回-1,那么表示读取到对方的输出结束标志,对方已经输出结束,准备关闭连接。实际上,通过read方法读数据,本身是很简单的,比较困难的是,在非阻塞模式下,如何知道通道何时是可读的呢?这就需要用到NIO的新组件——Selector通道选择器,稍后介绍。
3.写入到SocketChannel传输通道
和前面的把数据写入到FileChannel文件通道一样,大部分应用场景都会调用通道的int write(ByteBufferbuf)方法。
//写入前需要读取缓冲区,要求ByteBuffer是读取模式
buffer.flip();
socketChannel.write(buffer);
4.关闭SocketChannel传输通道
在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用一次shutdownOutput()终止输出方法,向对方发送一个输出的结束标志(-1)。然后调用socketChannel.close()方法,关闭套接字连接。
//终止输出方法,向对方发送一个输出的结束标志
socketChannel.shutdownOutput();
//关闭套接字连接
IOUtil.closeQuietly(socketChannel);
5 使用SocketChannel发送文件的实践案例
下面的实践案例是使用FileChannel文件通道读取本地文件内容,然后在客户端使用SocketChannel套接字通道,把文件信息和文件内容发送到服务器。客户端的完整代码如下:
package com.crazymakercircle.iodemo.socketDemos;
//...
public class NioSendClient {
private Charset charset = Charset.forName("UTF-8");
/**
* 向服务器端传输文件
*/
public void sendFile() throws Exception {
try {
String sourcePath = NioDemoConfig.SOCKET_SEND_FILE;
String srcPath = IOUtil.getResourcePath(sourcePath);
Logger.info("srcPath=" + srcPath);
String destFile = NioDemoConfig.SOCKET_RECEIVE_FILE;
Logger.info("destFile=" + destFile);
File file = new File(srcPath);
if (! file.exists()) {
Logger.info("文件不存在");
return;
}
FileChannelfileChannel = new FileInputStream(file).getChannel();
SocketChannelsocketChannel = SocketChannel.open();
socketChannel.socket().connect(
InetSocketAddress(NioDemoConfig.SOCKET_SERVER_IP,
NioDemoConfig.SOCKET_SERVER_PORT));
socketChannel.configureBlocking(false);
while(! socketChannel.finishConnect() ){
//不断地自旋、等待,或者做一些其他的事情
}
Logger.info("Client成功连接服务器端");
//发送文件名称
ByteBufferfileNameByteBuffer = charset.encode(destFile);
socketChannel.write(fileNameByteBuffer);
//发送文件长度
ByteBuffer buffer = ByteBuffer.allocate
(NioDemoConfig.SEND_BUFFER_SIZE);
buffer.putLong(file.length());
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
//发送文件内容
Logger.info("开始传输文件");
int length = 0;
long progress = 0;
while ((length = fileChannel.read(buffer)) > 0) {
buffer.flip();
socketChannel.write(buffer);
buffer.clear();
progress += length;
Logger.info("| "+(100 * progress / file.length()) + "% |");
}
if (length == -1) {
IOUtil.closeQuietly(fileChannel);
//在SocketChannel传输通道关闭前,尽量发送一个输出结束标志到对端
socketChannel.shutdownOutput();
IOUtil.closeQuietly(socketChannel);
}
Logger.info("======== 文件传输成功 ========");
} catch (Exception e) {
e.printStackTrace();
}
}
public static void main(String[] args) {
NioSendClient client = new NioSendClient(); // 启动客户端连接
client.sendFile(); // 传输文件
}
}
以上代码中的文件发送过程:首先发送目标文件名称(不带路径),然后发送文件长度,最后是发送文件内容。代码中的配置项,如服务器的IP、服务器端口、待发送的源文件名称(带路径)、远程的目标文件名称等配置信息,都是从system.properties配置文件中读取的,通过自定义的NioDemoConfig配置类来完成配置。
在运行以上客户端的程序之前,需要先运行服务器端的程序。服务器端的类与客户端的源代码在同一个包下,类名为NioReceiveServer,具体参见源代码工程,我们稍后再详细介绍这个类。
6 DatagramChannel数据报通道
和Socket套接字的TCP传输协议不同,UDP协议不是面向连接的协议。使用UDP协议时,只要知道服务器的IP和端口,就可以直接向对方发送数据。在Java中使用UDP协议传输数据,比TCP协议更加简单。在Java NIO中,使用DatagramChannel数据报通道来处理UDP协议的数据传输。
1.获取DatagramChannel数据报通道
获取数据报通道的方式很简单,调用DatagramChannel类的open静态方法即可。然后调用configureBlocking(false)方法,设置成非阻塞模式。
//获取DatagramChannel数据报通道
DatagramChannel channel = DatagramChannel.open();
//设置为非阻塞模式
datagramChannel.configureBlocking(false);
如果需要接收数据,还需要调用bind方法绑定一个数据报的监听端口,具体如下:
//调用bind方法绑定一个数据报的监听端口
channel.socket().bind(new InetSocketAddress(18080));
2.读取DatagramChannel数据报通道数据
当DatagramChannel通道可读时,可以从DatagramChannel读取数据。和前面的SocketChannel的读取方式不同,不是调用read方法,而是调用receive(ByteBufferbuf)方法将数据从DatagramChannel读入,再写入到ByteBuffer缓冲区中。
//创建缓冲区
ByteBufferbuf = ByteBuffer.allocate(1024);
//从DatagramChannel读入,再写入到ByteBuffer缓冲区
SocketAddressclientAddr= datagramChannel.receive(buffer);
通道读取receive(ByteBufferbuf)方法的返回值,是SocketAddress类型,表示返回发送端的连接地址(包括IP和端口)。通过receive方法读数据非常简单,但是,在非阻塞模式下,如何知道DatagramChannel通道何时是可读的呢?和SocketChannel一样,同样需要用到NIO的新组件——Selector通道选择器,稍后介绍。
3.写入DatagramChannel数据报通道
向DatagramChannel发送数据,和向SocketChannel通道发送数据的方法也是不同的。这里不是调用write方法,而是调用send方法。示例代码如下:
//把缓冲区翻转到读取模式
buffer.flip();
//调用send方法,把数据发送到目标IP+端口
dChannel.send(buffer, new InetSocketAddress(NioDemoConfig.SOCKET_SERVER_IP,
NioDemoConfig.SOCKET_SERVER_PORT));
//清空缓冲区,切换到写入模式
buffer.clear();
由于UDP是面向非连接的协议,因此,在调用send方法发送数据的时候,需要指定接收方的地址(IP和端口)。
4.关闭DatagramChannel数据报通道
这个比较简单,直接调用close()方法,即可关闭数据报通道。
//简单关闭即可
dChannel.close();
使用DatagramChannel数据包通道发送数据的实践案例
下面是一个使用DatagramChannel数据包通到发送数据的客户端示例程序代码。其功能是:获取用户的输入数据,通过DatagramChannel数据报通道,将数据发送到远程的服务器。客户端的完整程序代码如下:
package com.crazymakercircle.iodemo.udpDemos;
//...
public class UDPClient {
public void send() throws IOException {
//获取DatagramChannel数据报通道
DatagramChanneldChannel = 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(NioDemoConfig.
SOCKET_SERVER_IP, NioDemoConfig.SOCKET_SERVER_PORT));
buffer.clear();
}
//操作四:关闭DatagramChannel数据报通道
dChannel.close();
}
public static void main(String[] args) throws IOException {
new UDPClient().send();
}
}
通过示例程序代码可以看出,在客户端使DatagramChannel数据报通道发送数据,比起在客户端使用套接字SocketChannel发送数据,简单很多。
接下来看看在服务器端应该如何使用DatagramChannel数据包通道接收数据呢?
下面贴出服务器端通过DatagramChannel数据包通道接收数据的程序代码,可能大家目前不一定可以看懂,因为代码中用到了Selector选择器,但是不要紧,下一个小节就介绍它。
服务器端的接收功能是:通过DatagramChannel数据报通道,绑定一个服务器地址(IP+端口),接收客户端发送过来的UDP数据报。服务器端的完整代码如下:
package com.crazymakercircle.iodemo.udpDemos;
//...
public class UDPServer {
public void receive() throws IOException {
//获取DatagramChannel数据报通道
DatagramChanneldatagramChannel = DatagramChannel.open();
//设置为非阻塞模式
datagramChannel.configureBlocking(false);
//绑定监听地址
datagramChannel.bind(new InetSocketAddress(NioDemoConfig.SOCKET
_SERVER_IP, NioDemoConfig.SOCKET_SERVER_PORT));
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();
}
}
在服务器端,首先调用了bind方法绑定datagramChannel的监听端口。当数据到来后,调用了receive方法,从datagramChannel数据包通道接收数据,再写入到ByteBuffer缓冲区中。除此之外,在服务器端代码中,为了监控数据的到来,使用了Selector选择器。