Java NIO-Channel 概述

119 阅读13分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第12天,点击查看活动详情

Java NIO-Channel

2.1、Channel概述

Channel取”通道“的意思,可以通过它读取和写入数据,它支持读、写或者同时读写,是全双工的。

它不同于流,流是单向的,只能在一个方向上流动(一个流必须是InputStream或OutputStream的一个子类)

NIO 中通过 Channel 封装了对数据源的操作,通过 Channel 用户可以操作数据源,但又不必关心数据源的具体物理结构。这个数据源可能是多种的。比如,可以是文件,也可以是网络 socket。

Channel 用于在字节缓冲区和位于通道另一侧的实体(通常是一个文件或套接字)之间有效地传输数据。

Channel依赖 与缓冲区进行实现。

Channel源码:

public interface Channel extends Closeable {

    /**
     * 告诉这个频道是否打开。
	 * 返回:
true当且仅当此通道是开放的
     */
    public boolean isOpen();

    /**
     * 关闭此频道。
	 * 关闭通道后,任何进一步尝试对其调用 I/O 操作都将导致抛出ClosedChannelException 。
	 * 如果此通道已关闭,则调用此方法无效。
	 * 可以随时调用此方法。 但是,如果某个其他线程已经调用了它,则另一个调用将阻塞,直到第一次调用完成,之后它将无效地返回。
	 * 抛出:
IOException – 如果发生 I/O 错误
     */
    public void close() throws IOException;
}

与缓冲区不同,通道 API 主要由接口指定。不同的操作系统上通道实现(ChannelImplementation)会有根本性的差异,所以通道 API 仅仅描述了可以做什么。因此很自然地,通道实现经常使用操作系统的本地代码。

Channel是一个对象,可以通过它读取和写入数据。所有数据都通过 Buffer 对象来处理。用户永远不会将字节直接写入通道中,而是将数据写入包含一个或者多个字节的缓冲区。同样,您不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。

网络IO的形式:

2.2、Channel的四个实现

Channel有四个实现,分别对应了文件IO、UDP和TCP网络IO(包含客户端和服务器端)

2.3、FileChannel介绍和示例

FileChannel主要操作文件,对应文件IO

先简单了解一下Buffer的操作:

  • 将数据写入缓冲区

  • 调用buffer.flip() 反转读写模式

调换这个buffer的当前位置,并且设置当前位置是0。说的意思就是:将缓存字节数组的指针设置为数组的开始序列即数组下标0。这样就可以从buffer开头,对该buffer进行遍历(读取)了。

  • 从缓冲区读取数据

  • 调用buffer.clear()或 buffer.compact()清除缓冲区内容

FileChannel简单使用示例:

/**
     * 使用FileChannel读取数据到Buffer中
     * 步骤说明:
     * 1、创建FileChannel
     *  1.1、通过流创建或者RandomAccessFile创建,二者只是打开文件的方式不同
     * 2、创建Buffer
     * 3、写入Buffer
     * @param fileName
     */
    public static void read2Buffer(String fileName) throws Exception {
        // 1、创建FileChannel
        // // 通过流获取FileChannel
        // FileInputStream fin = new FileInputStream(fileName);
        // FileChannel finChannel = fin.getChannel();

        // 使用RandomAccessFile读写文件,支持随机读写,传入参数路径名和模式,
        RandomAccessFile raf = new RandomAccessFile(FileChannelDemo.fileName, "rw");
        // 获取FileChannel对象,也可以通过流获取
        FileChannel fileChannel = raf.getChannel();

        // 2、创建一个大小为1024的字节缓冲区
        ByteBuffer buf = ByteBuffer.allocate(1024);

        // 3、将FileChannel中的内容读到buf中,返回值是读取内容的擦汗高难度
        int bytesRead = fileChannel.read(buf);
        // 读取长度为-1时表明数据读取完毕
        while (bytesRead != -1) {
            System.out.println("读取了:" + bytesRead + "长度的的文件内容");
            // 调换这个buffer的当前位置,并且设置当前位置是0。说的意思就是:将缓存字节数组的指针设置为数组的开始序列即数组下标0。这样就可以从buffer开头,对该buffer进行遍历(读取)了。 
            buf.flip();
            // 如果缓冲区中有内容则输出
            while (buf.hasRemaining()) {
                // 这里是一个字节一个字节的输出
                System.out.println((char)buf.get());
            }
            // 清空缓冲区
            buf.clear();
            // 再次从FilChannel中读取数据到缓冲区中
            bytesRead = fileChannel.read(buf);
        }
        // 关闭文件
        raf.close();
        // 关闭通道
        fileChannel.close();
    }

2.4、FileChannel操作详解

打开FileChannel:

使用FileChannel读取数据之前需要先创建一个FileChannel实例,但我们无法直接创建,需要通过InputStream、OutputStream或者RandomAccessFile打开一个文件,然后使用getChannel方法获得。

		// 1、创建FileChannel
        // // 通过流获取FileChannel
        // FileInputStream fin = new FileInputStream(fileName);
        // FileChannel finChannel = fin.getChannel();

        // 使用RandomAccessFile读写文件,支持随机读写,传入参数路径名和模式,
        RandomAccessFile raf = new RandomAccessFile(FileChannelDemo.fileName, "rw");
        // 获取FileChannel对象,也可以通过流获取
        FileChannel fileChannel = raf.getChannel();

从FileChannel中读取数据:

创建一个ByteBuffer实例,通过FileChannel的read(Buffer)方法将数据读取到这个缓冲区中,返回值为读取的数据长度(字节为单位),为-1时表示读取结束。

		// 2、创建一个大小为1024的字节缓冲区
        ByteBuffer buf = ByteBuffer.allocate(1024);
        // 3、将FileChannel中的内容读到buf中,返回值是读取内容的擦汗高难度
        int bytesRead = fileChannel.read(buf);

向FileChannel写入数据:

同样需要一个Buffer实例,使用FileChannel的write(buffer)方法将数据写到FileChannel中。

注意FileChannel.write0是在 while 循环中调用的。因为无法保证 write0方法一次能向FileChannel 写入多少字节,因此需要重复调用 write()方法,直到 Buffer 中已经没有尚未写入通道的字节。

代码示例:

/**
     * 向Channel中写数据,然后写入到文件中
     * 步骤说明:
     * 1、创建FileChannel
     * 2、创建Buffer
     * 3、将要写入的内容写入Buffer
     * 4、反转buffer读写模式flip(),从Buffer中将数据写入到FileChannel
     *
     * @param fileName 写入文件的文件名
     */
    public static void write2Channel(String fileName) throws Exception {
        // 1、创建FileChannel
        // // 通过流获取FileChannel
        // FileInputStream fin = new FileInputStream(fileName);
        // FileChannel finChannel = fin.getChannel();

        // 使用RandomAccessFile读写文件,支持随机读写,传入参数路径名和模式,
        RandomAccessFile raf = new RandomAccessFile(fileName, "rw");
        // 获取FileChannel对象,也可以通过流获取
        FileChannel fileChannel = raf.getChannel();

        // 2、创建一个大小为1024的字节缓冲区
        ByteBuffer buf = ByteBuffer.allocate(1024);

        // 要写入的内容
        String content = "hello, FileChannel";
        // 清空缓冲区
        buf.clear();

        // 3、将要写入的内容写入到缓冲区
        buf.put(content.getBytes(StandardCharsets.UTF_8));
        // 调换这个buffer的当前位置,并且设置当前位置是0。说的意思就是:将缓存字节数组的指针设置为数组的开始序列即数组下标0。这样就可以从buffer开头,对该buffer进行遍历(读取)了。
        buf.flip();

        // 4、将缓冲区的内容写入到FileChannel,进而写入到文件中
        while (buf.hasRemaining()) {
            fileChannel.write(buf);
        }
        // 关闭文件以及Channel
        raf.close();
        fileChannel.close();
    }

写文件结果:

FileChannel其他重要方法:

FileChannel.position(int pos)方法:

FileChannel.size()方法

FileChannel.truncate(int length)方法:

FileChannel.force()方法:

FileChannel的transferTo和transferFrom方法:

tansferFrom:

/**
     * 测试FileChannel的transferFrom方法,将一个通道的数据传输到另一个数据。
     * 步骤说明:
     * 1、先建立两个通道,一个from通道,一个to通道
     * 2、使用transferFrom进行转化,将源通道的数据传输到另一个通道中
     */
    public static void transferChannel() throws Exception {
        // 建立源通道
        RandomAccessFile raf1 = new RandomAccessFile(FileChannelDemo.inputFileName, "r");
        FileChannel fromChannel = raf1.getChannel();
        // 建立新的通道
        RandomAccessFile raf2 = new RandomAccessFile(FileChannelDemo.toFromFileName, "rw");
        FileChannel raf2Channel = raf2.getChannel();

        // 定义转化开始的位置和大小
        long position = 0;
        long count = fromChannel.size();
        raf2Channel.transferFrom(fromChannel, 0, count);

        // 关闭文件和通道
        raf1.close();
        raf2.close();
        fromChannel.close();
        raf2Channel.close();
        System.out.println("转化完毕!");
    }

转化结果:fromChannel 中的所有内容都传送到了raf2Channel中

transferTo方法和transferFrom用法很相似,只是这里是使用to将当前通道中的指定长度内容注入到指定通道中:

/**
     * 测试FileChannel的transferTo方法,将一个通道的数据传输到另一个数据。
     * 步骤说明:
     * 1、先建立两个通道,一个from通道,一个to通道
     * 2、使用transferFrom进行转化,将源通道的数据传输到另一个通道中
     */
    public static void transferToChannel() throws Exception {
        // 建立源通道
        RandomAccessFile raf1 = new RandomAccessFile(FileChannelDemo.inputFileName, "r");
        FileChannel fromChannel = raf1.getChannel();
        // 建立新的通道
        RandomAccessFile raf2 = new RandomAccessFile(FileChannelDemo.toFromFileName, "rw");
        FileChannel raf2Channel = raf2.getChannel();

        // 定义转化开始的位置和大小
        long position = 0;
        long count = fromChannel.size();
        // 截取源通道fromChannel中的内容送入指定通道raf2Channel中
        fromChannel.transferTo(position, count, raf2Channel);

        // 关闭文件和通道
        raf1.close();
        raf2.close();
        fromChannel.close();
        raf2Channel.close();
        System.out.println("转化完毕!");
    }

转化结果:

2.5、Socket通道操作

这一部分内容的操作步骤和传统的Socket、ServerSocket很相似,API也类似,只是原理不同。

这里常用的读写操作,处理DatagramChannel无连接操作(不包括connect)使用send和receive之外,其他的都是用read和write

**传统的Socket:**原来的Socket连接进行传输数据时,一个线程管理一个Socket连接,当Socket连接过多时,会导致系统负载能力过大,影响用户体验。

新的Socket:新的 socket 通道类可以运行非阻塞模式并且是可选择的,可以激活大程序(如网络服务器和中间件组件)巨大的可伸缩性和灵活性。没有必要再为每个 socket 连接使用一个线程,也避免了管理大量线程所需的上下文交换开销。

1、借助新的 NIO 类,一个或几个线程就可以管理成百上千的活动 socket 连接并且只有很少甚至可能没有性能损失。所有的 socket 通道类(DatagramChannel、SocketChannel和ServerSocketChannel)都继承了位于java.nio.channels.spi包中的AbstractSelectableChannel。这意味着我们可以用一个Selector对象来执行socket 通道的就绪选择(readiness selection)

2、请注意 DatagramChannel和SocketChannel实现定义读和写功能的接口而ServerSocketChannel 不实现。ServerSocketChannel 负责监听传入的连接和创建新的 SocketChannel 对象,它本身从不传输数据。

2.5.1 ServerSocketChannel

ServerSocketChannel是一个基于通道的socket监听器,和ServerSocket作用类似,不过它增强了通道语义,能在非阻塞模式下运行。

使用方式和ServerSocket类似,SocketChannel与Socket对等,ServerSocketChannel监听端口,使用accept方法获取SocketChannel对象。

ServerSocketChannel没有bind方法,绑定地址、端口这些信息需要通过获得ServerSocketChannel上的socket(这里实际上应该是ServerSocket对象,不过方法名是socket),然后再调用bind方法。

代码示例:

/**
     * ServerSocketChannel的简单使用,包括创建
     * 步骤说明:
     * 1、使用静态方法open打开ServerSocketChannel通道,创建ServerSocketChannel对象
     * 2、从通道上获得socket对象,然后绑定地址和端口信息,设置阻塞/非阻塞模式
     * 3、循环监听通道(accept方法),如果接收到通道中的SocketChannel客户端信息,就进行处理,接收不到(accept方法返回null)则打印提示信息
     *
     */
    public static void serverSocketChannelSimpleUse() throws Exception {
        // 封装一个信息到缓冲区中
        ByteBuffer byteBuffer = ByteBuffer.wrap("Hello,ServerSocketChannel".getBytes(StandardCharsets.UTF_8));
        // 设置一个端口号
        int port = 8888;

        // 1、打开一个ServerSocketChannel通道,就是获得一个ServerSocketChannel对象
        ServerSocketChannel ssc = ServerSocketChannel.open();

        // 2、获得socket对象并绑定信息
        ssc.socket().bind(new InetSocketAddress(port));

        // 3、循环监听通道
        while (true) {
            System.out.println("正在监听通道···");
            // 获得客户端SocketChannel
            SocketChannel sc = ssc.accept();
            // 非阻塞状态下,如果监听不到客户端的信息,则会返回一个null
            if (sc == null) {
                // 打印提示信息
                System.out.println("null\t没有客户端连接传入");
                // 休眠两秒
                Thread.sleep(2000);
            }else {
                System.out.println("客户端连接来自" + sc.socket().getRemoteSocketAddress());
                // 指针回到起始位置,因为之前已经写入了数据,指针变化,所以这里重新将缓冲区的指针提到起始位置
                byteBuffer.rewind();
                // 将缓冲区的数据写入客户端通道
                sc.write(byteBuffer);
                // 客户端通道关闭
                sc.close();
            }
        }
    }

2.5.2 SocketChannel

SocketChannel的作用:

  • SocketChanne是用来连接Socket套接字

  • SocketChannel主要用途用来处理网络I/O的通道

  • SocketChannel是基于TCP 连接传输

  • SocketChannel 实现了可选择通道,可以被多路复用的

SocketChannel特征:

获取参数:

代码示例:

/**
     * SocketChannel的简单实用,包括创建SocketChannel、建立连接等操作。
     * 步骤说明:
     * 1、使用open静态方法建立连接(相当于传统Socket方法new一个实例,再进行连接相同),有两种方式
     * 1.1、使用open方法时传入地址、端口等参数
     * 1.2、使用open方法时,不传入参数,再使用得到的SocketChannel实例调用connect方法。
     * 2、(可选)检验连接状态
     * 3、(可选)设置阻塞/非阻塞模式
     * 4、读写数据
     */
    public static void socketChannelSimpleUse() throws Exception {
        // 1、创建连接
        // 方式一:直接给open方法传递参数
        // SocketChannel sc = SocketChannel.open("www.baidu.com", 80));
        // 方式二:通过open方法获得SocketChannel实例,再使用connect进行连接
        SocketChannel sc = SocketChannel.open();
        sc.connect(new InetSocketAddress("www.baidu.com", 80));

        // 2、设置非阻塞
        sc.configureBlocking(false);

        // 3、读写数据
        ByteBuffer buffer = ByteBuffer.allocate(32);
        sc.read(buffer);
        sc.close();
        System.out.println("数据读取完毕");
    }

2.5.3 DatagramChannel

DatagramChannel是面向无连接协议UDP/IP的,每个数据包都是一个自包含的实体,包含自己地址+端口号和目标主机的目的地址+端口号,可以发送数据包到不同的地址,也可以接受来自其他地址的数据包,不区分客户端和服务器端。详细描述如下:

代码示例:

/**
 * @author ZhangChaojie
 * @Description: TODO(DatagramChannel功能测试)
 * @date 2021/12/5 11:25
 */
public class DatagramChannelDemo {
    /**
     * DatagramChannel的简单使用,包括创建DatagramChannel、传输数据、建立连接等
     * 步骤说明:
     * 1、使用open方法创建DatagramChannel,这里设置端口等信息的方式同ServerSocketChannel类似,需要获得socket之后使用bind方法
     * 2、传输数据
     * 2.1、接收数据,需要监听指定端口号,绑定改端口号,然后使用receive方法接收其他主机发送过来的数据包实体
     * 2.1.1、定义一个缓冲区,receive(buffer)将数据读到缓冲区中,再通过缓冲区做后续操作
     * 2.2、发送数据,将需要发送的封装,包括数据内容和地址+端口号,使用send发送,自动打包成一个数据包实体
     * <p>
     * DatagramChannel也能建立连接,指定机器使用connect建立连接,这个建立连接是为了向指定服务器上通过read和write方法传输数据。
     */

    // 发送数据
    @Test
    public void sendDatagram() throws Exception {
        // 1、创建DatagramChannel
        DatagramChannel dc = DatagramChannel.open();
        // 2、设置一个目的地址
        InetSocketAddress sendAddress = new InetSocketAddress(Inet4Address.getLocalHost(), 10086);

        // 循环发送数据
        while (true) {
            // 3、将发送数据读进缓冲区
            ByteBuffer buffer = ByteBuffer.wrap("我是发送端,张三".getBytes(StandardCharsets.UTF_8));
            // 4、send方法发送数据到指定地址
            dc.send(buffer, sendAddress);
            System.out.println("数据已发送");
            // 休眠两秒
            TimeUnit.SECONDS.sleep(2);
        }
    }

    // 接收数据
    @Test
    public void receiveDatagram() throws Exception {
        // 1、创建DatagramChannel
        DatagramChannel dc = DatagramChannel.open();
        // 监听地址
        InetSocketAddress receiveAddress = new InetSocketAddress(10086);
        // 2、绑定这个地址,获取这个地址上发送过的数据
        dc.socket().bind(receiveAddress);
        // 3、定义一个接受数据的缓冲区
        ByteBuffer buffer = ByteBuffer.allocate(1024);

        // 循环接收数据,这个循环实际上是一直再监听端口
        while (true) {
            // 接收数据,返回的是socket套接字连接地址,数据在buffer中
            SocketAddress socketAddress = dc.receive(buffer);
            // 修改buffer中指针位置
            buffer.flip();
            System.out.println("socket套接字地址:" + socketAddress.toString());
            System.out.println("接收到的数据:" + Charset.forName("UTF-8").decode(buffer));
        }
    }

    // connect连接测试
    @Test
    public void connectTest() throws Exception {
        // 1、创建DatagramChannel
        DatagramChannel connCahnnel = DatagramChannel.open();
        // 监听地址
        InetSocketAddress inetAddress = new InetSocketAddress(10086);
        // 2、绑定这个地址,获取这个地址上发送过的数据,为了监听这个端口才绑定
        connCahnnel.socket().bind(inetAddress);

        // 连接
        connCahnnel.connect(new InetSocketAddress("127.0.0.1", 10086));

        // 发送数据给connect连接的主机
        connCahnnel.write(ByteBuffer.wrap("我是发送端,张三".getBytes(StandardCharsets.UTF_8)));

        // 读取数据的缓冲区
        ByteBuffer readBuffer = ByteBuffer.allocate(1024);

        // 读数据
        while (true) {
            readBuffer.clear();
            connCahnnel.read(readBuffer);
            readBuffer.flip();
            System.out.println(Charset.forName("UTF-8").decode(readBuffer));
        }
    }

    public static void main(String[] args) throws Exception {
        // // 发送数据
        // sendDatagram();
        // // 接收数据
        // receiveDatagram();
    }
}

2.6、分散和聚集(Scatter&Gather)

2.6.1、分散

原理描述:

具体操作:

2.6.1、聚集

原理描述:

具体操作:

image.png

buffers数组是write()方法的入参,write()方法会按照 buffer在数组中的顺序,将数据写入到channel,注意只有position和limit之间的数据才会被写入。因此,如果一个buffer的容量为 128byte,但是仅仅包含58byte的数据,那么这58byte的数据将被写入到channel中。因此与Scattering Reads相反,Gathering Writes能较好的处理动态消息。