详解NIO Channel类

1,294 阅读11分钟

Java NIO中一个socket连接使用一个Channel来表示。从更广泛的层面来说,一个通道可以表示一个底层的文件描述符,例如硬件设备、文件、网络连接等。

Java NIO的通道可以更加细化。例如,不同的网络传输协议类型,在Java中都有不同的NIO Channel实现。

其中最为重要的四种Channel实现:

  • FileChannel:文件通道,用于文件的数据读写。
  • SocketChannel:套接字通道,用于套接字TCP连接的数据读写。
  • ServerSocketChannel:服务器套接字通道(或服务器监听通道),允许我们监听TCP连接请求,为每个监听到的请求创建一个SocketChannel通道。
  • DatagramChannel:数据报通道,用于UDP的数据读写。

1 FileChannel

1.1 获取FileChannel

  1. 可以通过文件的输入流、输出流获取FileChannel
@Test
public void getChannel(){
    try {
        // 创建一个文件输出流
        FileOutputStream fos = new FileOutputStream("D:\\h.txt");
        FileChannel channel1 = fos.getChannel();
        // 创建一个文件输入流
        FileInputStream fins = new FileInputStream("D:\\h.txt");
        FileChannel channel2 = fins.getChannel();
    } catch (FileNotFoundException e) {
        e.printStackTrace();
    }
}
  1. 也可以通过RandomAccessFile(文件随机访问)类来获取FileChannel实例,代码如下:
//创建RandomAccessFile随机访问对象
RandomAccessFile rFile = new RandomAccessFile("filename.txt","rw");
//获取文件流的通道(可读可写)
FileChannel channel = rFile.getChannel();

1.2 读取FileChannel

在大部分应用场景中,从通道读取数据都会调用通道的int read(ByteBuffer buf)方法,它把从通道读取的数据写入ByteBuffer缓冲区,并且返回读取的数据量。

public class FileChannelReadDemo01 {

  
    public static void main(String[] args) {
        testReadDataFromChannel();
    }

    public static void testReadDataFromChannel() {
        // 创建一个文件输出流
        RandomAccessFile  fos = null;
        FileChannel channel = null;
        try {
            fos = new RandomAccessFile ("D:\\h.txt","rw");
            channel = fos.getChannel();
            // 定义一个字节缓冲区域
            ByteBuffer buffer = ByteBuffer.allocate(1024);
            int length = -1;
            while ((length = channel.read(buffer)) != -1) {
                // 调用flip,buffer由写入变为读
                buffer.flip();
                byte[] array = buffer.array();
                System.out.print(new String(array, 0, length,"utf-8"));

            }
        } catch (IOException e) {
            e.printStackTrace();
        }finally {
            closeQuietly(fos);
            closeQuietly(channel);
        }

    }

    public static void closeQuietly(java.io.Closeable o) {
        if (null == o) {
            return;
        }
        try {
            o.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

1.3 写入FileChannel

把数据写入通道,在大部分应用场景中都会调用通道的write(ByteBuffer)方法,此方法的参数是一个ByteBuffer缓冲区实例,是待写数据的来源。

write(ByteBuffer)方法的作用是从ByteBuffer缓冲区中读取数据,然后写入通道自身,而返回值是写入成功的字节数。

对于入参buffer实例来说,需要从其中读取数据写入channel通道中,所以入参buffer必须处于读模式,不能处于写模式。

//如果buf处于写模式(如刚写完数据),需要翻转buf,使其变成读模式
buf.flip();
int outlength = 0;
//调用write()方法,将buf的数据写入通道
while ((outlength = outchannel.write(buf)) != 0) {
    System.out.println("写入的字节数:" + outlength);
}

1.4 关闭channel

当通道使用完成后,必须将其关闭。关闭非常简单,调用close()方法即可。

1.5 强制刷新到磁盘

在将缓冲区写入通道时,出于性能的原因,操作系统不可能每次都实时地将写入数据落地(或刷新)到磁盘,完成最终的数据保存。

在将缓冲区数据写入通道时,要保证数据能写入磁盘,可以在写入后调用一下FileChannel的force()方法。

1.6 【案例】 文件复制

public class FileNIOCopyDemo {

    public static void main(String[] args) throws IOException {
        // 源文件
        File srcFile = new File("D:\\h.txt");
        // 目标文件
        File destFile = new File("D:\\hd.txt");
        // 校验源文件是否存在
        fileIsExists(srcFile);
        // 创建目标文件
        createFile(destFile);

        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;
            // 创建一个ByteBuffer
            ByteBuffer buf = ByteBuffer.allocate(1024);
            // 读取文件
            while ((length = inChannel.read(buf)) != -1) {
                //翻转buf,变成成读模式
                buf.flip();

                int outlength = 0;
                // 从buffer中读取数据,然后写入到channe中
                while ((outlength = outchannel.write(buf)) != 0) {
                    System.out.println("写入字节数:" + outlength);
                }
                // 清除buf,变成写入模式
                buf.clear();
            }
            //强制刷新磁盘
            outchannel.force(true);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            closeQuietly(fis);
            closeQuietly(fos);
            closeQuietly(inChannel);
            closeQuietly(outchannel);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("一共耗时:【" + (endTime - startTime) + "】 ms");


    }

    private static void createFile(File destFile) throws IOException {
        if (!destFile.exists()) {
            destFile.createNewFile();
        }
    }

    private static void fileIsExists(File srcFile) throws FileNotFoundException {
        if (!srcFile.exists()) {
            throw new FileNotFoundException("source.file.not.exists");
        }
    }

    private static void closeQuietly(java.io.Closeable o) {
        if (null == o) {
            return;
        }
        try {
            o.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

一共耗时:【14】 ms

作为文件复制的程序来说,以上代码的效率不是最高的。更高效的文件复制可以调用文件通道的transferFrom()方法。

public class FileNIOFastCopyDemo {

    public static void main(String[] args) throws IOException {
        // 源文件
        File srcFile = new File("D:\\h.txt");
        // 目标文件
        File destFile = new File("D:\\hd.txt");
        // 校验源文件是否存在
        fileIsExists(srcFile);
        // 创建目标文件
        createFile(destFile);

        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();

            long size = inChannel.size();
            long pos = 0;
            long count = 0;
            while (pos < size) {
                //每次复制最多1024个字节,没有就复制剩余的
                count = size - pos > 1024 ? 1024 : size - pos;
                //复制内存,偏移量pos + count长度
                pos += outchannel.transferFrom(inChannel, pos, count);
            }
            //强制刷新磁盘
            outchannel.force(true);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            closeQuietly(fis);
            closeQuietly(fos);
            closeQuietly(inChannel);
            closeQuietly(outchannel);
        }
        long endTime = System.currentTimeMillis();
        System.out.println("一共耗时:【" + (endTime - startTime) + "】 ms");


    }

    private static void createFile(File destFile) throws IOException {
        if (!destFile.exists()) {
            destFile.createNewFile();
        }
    }

    private static void fileIsExists(File srcFile) throws FileNotFoundException {
        if (!srcFile.exists()) {
            throw new FileNotFoundException("source.file.not.exists");
        }
    }

    private static void closeQuietly(Closeable o) {
        if (null == o) {
            return;
        }
        try {
            o.close();
        } catch (IOException e) {
            e.printStackTrace();
        }
    }

}

2 SocketChannel

在NIO中,涉及网络连接的通道有两个:

  • 一个是SocketChannel,负责连接的数据传输;
  • 另一个是ServerSocketChannel,负责连接的监听。

其中,NIO中的SocketChannel传输通道与OIO中的Socket类对应,NIO中的ServerSocketChannel监听通道对应于OIO中的ServerSocket类。

ServerSocketChannel仅应用于服务端,而SocketChannel同时处于服务端和客户端。所以,对于一个连接,两端都有一个负责传输的SocketChannel。

无论是ServerSocketChannel还是SocketChannel,都支持阻塞和非阻塞两种模式。如何进行模式的设置呢?调用configureBlocking()方法,具体如下:

// 设置为非阻塞模式。
socketChannel.configureBlocking(false)
// 设置为阻塞模式。
socketChannel.configureBlocking(true)

在阻塞模式下,SocketChannel的连接、读、写操作都是同步阻塞式的,,在效率上与Java OIO面向流的阻塞式读写操作相同。

2.1 获取SocketChannel传输通道

  1. 通过SocketChannel静态方法open()获得一个套接字传输通道
  2. 然后将socket设置为非阻塞模式
  3. 最后通过connect()实例方法对服务器的IP和端口发起连接。

public static void main(String[] args) throws IOException {
   // SocketAddress address = new InetSocketAddress("www.baidu.com",80);
    SocketAddress address = new InetSocketAddress("localhost",8080);
    SocketChannel socketChannel = SocketChannel.open();
    socketChannel.configureBlocking(false);
    // 连接
    socketChannel.connect(address);
    // 在非阻塞情况下,与服务器的连接可能还没有真正建立,socketChannel.connect()方法就返回了,因此需要不断地自旋,检查当前是否连接到了主机
### 
    while (!socketChannel.finishConnect()){
        // //不断地自旋、等待,或者做一些其他的事情
        System.out.println("等待连接成功");
    }
    System.out.println("连接成功");
}

输出: 当地址是百度的时候(这个地址是真实存在的),所以会连接成功

等待连接成功
等待连接成功
等待连接成功
连接成功

当换成本地8080的时候(这个地址本地根本不存在),所以就会抛出异常

等待连接成功
等待连接成功
等待连接成功
Exception in thread "main" java.net.ConnectException: Connection refused: no further information
	at sun.nio.ch.SocketChannelImpl.checkConnect(Native Method)
	at sun.nio.ch.SocketChannelImpl.finishConnect(SocketChannelImpl.java:716)
	at study.wyy.nio.socketChannel.SocketChannelDemo01.main(SocketChannelDemo01.java:21**)**

如果是阻塞模式,那么connect方法就会阻塞直到连接上或者抛出异常

  1. 在服务端,如何获取与客户端对应的传输套接字呢?在连接建立的事件到来时,服务端的ServerSocketChannel能成功地查询出这个新连接事件,通过调用服务端ServerSocketChannel监听套接字的accept()方法来获取新连接的套接字通道:

2.2 读取SocketChannel传输通道

当SocketChannel传输通道可读时,可以从SocketChannel读取数据,具体方法与前面的文件通道读取方法是相同的。调用read()方法,将数据读入缓冲区ByteBuffer。

在读取时,因为是异步的,所以我们必须检查read()的返回值,以便判断当前是否读取到了数据。

  • read()方法的返回值是读取的字节数,如果是-1,那么表示读取到对方的输出结束标志,即对方已经输出结束,准备关闭连接。
  • 比较困难的是,在非阻塞模式下如何知道通道何时是可读的。这需要用到NIO的新组件——Selector通道选择器

2.3 写入SocketChannel传输通道

大部分应用场景都会调用通道的int write(ByteBufferbuf)方法。 **写入前需要读取缓冲区,要求ByteBuffer是读模式 **

2.4 关闭SocketChannel传输通道

在关闭SocketChannel传输通道前,如果传输通道用来写入数据,则建议调用一次shutdownOutput()终止输出方法,向对方发送一个输出的结束标志(-1)。然后调用socketChannel.close()方法,关闭套接字连接。

//调用终止输出方法,向对方发送一个输出的结束标志
socketChannel.shutdownOutput();
//关闭套接字连接
socketChannel.close();

2.5 一个小demo:发送文件

package study.wyy.nio.socketChannel;

import lombok.extern.slf4j.Slf4j;

import java.io.*;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.concurrent.TimeoutException;

/**
 * @author wyaoyao
 * @date 2021/6/9 9:20
 */
@Slf4j
public class NioFileSendClient {

    private final Charset charset = Charset.forName("utf-8");

    private final SocketChannel socketChannel;

    private final String remoteHost;

    private final int remotePort;


    public NioFileSendClient(String remoteHost, int remotePort) throws IOException, TimeoutException {
        this.remoteHost = remoteHost;
        this.remotePort = remotePort;
        InetSocketAddress inetSocketAddress = new InetSocketAddress(remoteHost, remotePort);
        socketChannel = SocketChannel.open();
        socketChannel.connect(inetSocketAddress);
        long timeout = System.currentTimeMillis() + 5000;
        while (!socketChannel.finishConnect()) {
            log.info("waiting to connect remote server [{}:{}]", remoteHost, remotePort);
            if (System.currentTimeMillis() >= timeout) {
                throw new TimeoutException("connect.time.out");
            }
        }
        log.info("success to connect remote server [{}:{}]", remoteHost, remotePort);
    }

    /**
     * send file to remote server
     *
     * @param sourceFilePath which file to send
     */
    public void sendFile(String sourceFilePath) {
        File srcFile;
        FileChannel fileChannel;
        try {
            srcFile = getFileByPath(sourceFilePath);
            fileChannel = new FileInputStream(srcFile).getChannel();
        } catch (FileNotFoundException e) {
            log.error("source file [{}] not exist while be return;", sourceFilePath);
            return;
        }
        ByteBuffer buffer =
                ByteBuffer.allocate(1024);
        try {
            // 发送文件名和长度
            sengFileNameAndLength(srcFile.getName(), socketChannel, buffer);
            // 发送文件长度
            sengFileLength(srcFile, socketChannel, buffer);
            // 发送文件内容
            sengFileContent(srcFile, socketChannel, fileChannel, buffer);
            log.info("send file success");
        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            close(fileChannel);
        }
    }

    public void close(Closeable x) {
        if (x != null) {
            try {
                x.close();
            } catch (Exception var2) {
            }
        }
    }

    private void sengFileContent(File srcFile, SocketChannel socketChannel, FileChannel fileChannel, ByteBuffer buffer) throws IOException {
        log.info("start to send file content");
        int len = 0;
        long progress = 0;
        while ((len = fileChannel.read(buffer)) != -1) {
            buffer.flip();
            socketChannel.write(buffer);
            buffer.clear();
            progress += len;
            log.info("| " + (100 * progress / srcFile.length()) + "% |");
        }
    }

    private void sengFileLength(File srcFile, SocketChannel socketChannel, ByteBuffer buffer) throws IOException {
        buffer.putLong(srcFile.length());
        // 调用filp方法: buffer由写入改为读取
        buffer.flip();
        socketChannel.write(buffer);
        // 清除buffer, buffer由读取改为写入
        buffer.clear();
        log.info("file length send finish; file length is [{}]", srcFile.length());
    }

    private void sengFileNameAndLength(String fileName, SocketChannel socketChannel, ByteBuffer buffer) throws IOException {
        ByteBuffer fileNameByteBuffer = charset.encode(fileName);
        int fileNameLen = fileNameByteBuffer.limit();

        buffer.putInt(fileNameLen);
        // 调用filp方法: buffer由写入改为读取
        buffer.flip();
        socketChannel.write(buffer);
        // 清除buffer, buffer由读取改为写入
        buffer.clear();
        // 发送文件名字
        socketChannel.write(fileNameByteBuffer);
        log.info("file name with length send finish; file name is {}", fileName);

    }

    private File getFileByPath(String sourceFilePath) throws FileNotFoundException {
        File file = new File(sourceFilePath);
        if (!file.exists()) {
            throw new FileNotFoundException("file.not.find");
        }
        return file;
    }
}

3 DatagramChannel

在Java中使用UDP传输数据比TCP更加简单。和socket的TCP不同,UDP不是面向连接的协议。使用UDP时,只要知道服务器的IP和端口就可以直接向对方发送数据。在Java NIO中,使用DatagramChannel来处理UDP的数据传输。

3.1 获取DatagramChannel

获取数据报通道的方式很简单,调用DatagramChannel类的open()静态方法即可。然后调用configureBlocking(false)方法,设置成非阻塞模式。

// 获取DatagramChannel
DatagramChannel channel = DatagramChannel.open();
//设置为非阻塞模式
datagramChannel.configureBlocking(false);
// 如果需要接收数据,还需要调用bind()方法绑定一个数据报的监听端口:
//调用bind()方法绑定一个数据报的监听端口
channel.socket().bind(new InetSocketAddress(18080));

3.2 从DatagramChannel读取数据

当DatagramChannel通道可读时,可以从DatagramChannel读取数据。和前面的SocketChannel读取方式不同,这里不调用read()方法,而是调用receive(ByteBufferbuf)方法将数据从DatagramChannel读入,再写入ByteBuffer缓冲区中。

// 创建缓冲区
ByteBuffer buf = ByteBuffer.allocate(1024);
// 从DatagramChannel读入,再写入ByteBuffer缓冲区
SocketAddress clientAddr= datagramChannel.receive(buf);

其返回值是SocketAddress类型,表示返回发送端的连接地址(包括IP和端口)。

3.3 写入DatagramChannel

向DatagramChannel发送数据,和向SocketChannel通道发送数据的方法是不同的。这里不是调用write()方法,而是调用send()方法。

//把缓冲区翻转为读模式
buffer.flip();
//调用send()方法,把数据发送到目标IP+端口
dChannel.send(buffer,  new InetSocketAddress("127.0.0.1",18899));
//清空缓冲区,切换到写模式
buffer.clear();

3.4 一个小demo

  1. 客户端发送数据
import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.text.SimpleDateFormat;
import java.util.Scanner;

/**
 * @author wyaoyao
 * @date 2021/6/21 14:08
 */
public class UDPClient {

    public static void send() throws IOException {
        // 获取DatagramChannel
        DatagramChannel datagramChannel = DatagramChannel.open();
        // 设置为非阻塞
        datagramChannel.configureBlocking(false);
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        System.out.println("请输入:");
        Scanner scanner = new Scanner(System.in);
        while (scanner.hasNext()) {
            String next = scanner.next();
            buffer.put((getNow() + " >> " + next).getBytes());
            // 发送数据前,记得设置buffer为读取模式
            buffer.flip();
            //通过DatagramChannel发送数据
            datagramChannel.send(buffer, new InetSocketAddress("127.0.0.1", 18899));
            buffer.clear();
        }
        datagramChannel.close();

    }

    public static String getNow() {
        SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
        //return sdf.format(new Date().getTime());
        return sdf.format(System.currentTimeMillis());
    }

    public static void main(String[] args) throws IOException {
        send();
    }

}
  1. 服务端接收数据
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.SocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.DatagramChannel;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.util.Iterator;
import java.util.Set;

/**
 * @author wyaoyao
 * @date 2021/6/21 14:35
 */
@Slf4j
public class UDPServer {


    public static void receive() throws IOException {
        //获取DatagramChannel数据报通道
        DatagramChannel datagramChannel = DatagramChannel.open();
        datagramChannel.configureBlocking(false);
        datagramChannel.bind(new InetSocketAddress(
                "127.0.0.1", 18899));
        System.out.println("服务启动。。。。。");

        // 开启一个通道选择器
        Selector selector = Selector.open();
        // 将通道注册到选择器
        datagramChannel.register(selector, SelectionKey.OP_READ);

        // 通过选择器查询IO事件
        while (selector.select() > 0) {
             ByteBuffer buffer = ByteBuffer.allocate(1024);
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();
            // 迭代IO事件
            while (iterator.hasNext()){
                SelectionKey selectionKey = iterator.next();
                // 可读事件,有数据到来
                if (selectionKey.isReadable()) {
                    // 读取DatagramChannel数据报通道数据
                    SocketAddress client = datagramChannel.receive(buffer);
                    buffer.flip();
                    System.out.println((new String(buffer.array(), 0, buffer.limit())));
                    buffer.clear();
                }
            }
            iterator.remove();
        }
        selector.close();
        datagramChannel.close();
    }

    public static void main(String[] args) throws IOException {
        receive();
    }
}

在服务端,首先调用了bind()方法绑定DatagramChannel的监听端口。当数据到来时调用了receive()方法,从DatagramChannel接收数据后写入ByteBuffer缓冲区中。