Java NIO网络编程深入剖析【尚硅谷】

1、概述

Java NIO(New IO 或 Non Blocking IO)是从 Java 1.4 版本开始引入的一个新的IO API,可以替代标准的 Java IO API。NIO 支持面向缓冲区的、基于通道的 IO 操作。NIO 将以更加高效的方式进行文件的读写操作。

1)阻塞IO

通常在进行同步 I/O 操作时,如果读取数据,代码会阻塞直至有可供读取的数据。同样,写入调用将会阻塞直至数据能够写入。传统的 Server/Client 模式会基于 TPR(Thread per Request),服务器会为每个客户端请求建立一个线程,由该线程单独负责处理一个客户请求。这种模式带来的一个问题就是线程数量的剧增,大量的线程会增大服务器的开销。大多数的实现为了避免这个问题,都采用了线程池模型,并设置线程池线程的最大数量,这由带来了新的问题,如果线程池中有 100 个线程,而有100 个用户都在进行大文件下载,会导致第 101 个用户的请求无法及时处理,即便第101 个用户只想请求一个几 KB 大小的页面。传统的 Server/Client 模式如下图所示:

在这里插入图片描述

2)非阻塞IO(NIO)

NIO 中非阻塞 I/O 采用了基于 Reactor 模式的工作方式,I/O 调用不会被阻塞,相反是注册感兴趣的特定 I/O 事件,如可读数据到达,新的套接字连接等等,在发生特定事件时,系统再通知我们。NIO 中实现非阻塞 I/O 的核心对象就是 Selector,Selector 就是注册各种 I/O 事件地方,而且当我们感兴趣的事件发生时,就是这个对象告诉我们所发生的事件,如下图所示:

在这里插入图片描述

从图中可以看出,当有读或写等任何注册的事件发生时,可以从 Selector 中获得相应的 SelectionKey,同时从 SelectionKey 中可以找到发生的事件和该事件所发生的具体的 SelectableChannel,以获得客户端发送过来的数据。

非阻塞指的是 IO 事件本身不阻塞,但是获取 IO 事件的 select()方法是需要阻塞等待的

区别是阻塞的 IO 会阻塞在 IO 操作上,NIO 阻塞在事件获取上,没有事件就没有 IO, 从高层次看 IO 就不阻塞了。也就是说只有 IO 已经发生,那么我们才评估 IO 是否阻塞,但是 select() 阻塞的时候 IO 还没有发生,何谈 IO 的阻塞呢?

NIO 的本质是延迟 IO 操作到真正发生 IO 的时候,而不是以前的只要 IO 流打开了就一直等待 IO 操作。

IONIO
面向流(Stream Oriented)面向缓冲区(Buffer Oriented)
阻塞IO(Blocking IO)非阻塞IO(Non Blocking IO)
选择器(Selectors)

3)NIO概述

Java NIO 由以下几个核心部分组成:

  • Channels
  • Buffers
  • Selectors

虽然 Java NIO 中除此之外还有很多类和组件,但 Channel,Buffer 和 Selector 构成了核心的 API。其它组件,如 Pipe 和 FileLock,只不过是与三个核心组件共同使用的工具类。

(1)Channel

首先说一下 Channel,可以翻译成“通道”。Channel 和 IO 中的 Stream(流)是差不多一个等级的。只不过 Stream 是单向的,譬如:InputStream, OutputStream。而Channel 是双向的既可以用来进行读操作,又可以用来进行写操作。

NIO 中的 Channel 的主要实现有:FileChannelDatagramChannelSocketChannelServerSocketChannel,分别可以对应文件 IO、UDP 和 TCP(Server 和 Client)。

(2)Buffer

NIO 中的关键 Buffer 实现有:ByteBuffer,CharBuffer,DoubleBuffer,FloatBuffer,IntBuffer,LongBuffer,ShortBuffer,分别对应基本数据类型: byte,char,double,float,int,long,short

NIO中Buffer对应的基本数据类型
ByteBufferbyte
CharBufferchar
DoubleBufferdouble
FloatBufferfloat
IntBufferint
LongBufferlong
ShortBuffershort

(3)Selector

Selector 运行单线程处理多个 Channel,如果应用打开了多个通道,但每个连接的流量都很低,使用 Selector 就会很方便。例如在一个聊天服务器中。要使用Selector,得向 Selector 注册 Channel,然后调用它的 select()方法。这个方法会一直阻塞到某个注册的通道有事件就绪。一旦这个方法返回,线程就可以处理这些事件;如:新的连接进来、数据接收等

2、Channel

2.1、Channel概述

Channel 是一个通道,可以通过它读取和写入数据,它就像水管一样,网络数据通过Channel 读取和写入。通道与流的不同之处在于通道是双向的,流只是在一个方向上移动(一个流必须是 InputStream 或者 OutputStream 的子类),而且通道可以用于读、写或者同时用于读写。因为 Channel 是全双工的,所以它可以比流更好地映射底层操作系统的 API。

NIO 中通过 channel 封装了对数据源的操作,通过 channel 我们可以操作数据源,但又不必关心数据源的具体物理结构。这个数据源可能是多种的。比如,可以是文件,也可以是网络 socket。在大多数应用中,channel 与文件描述符或者 socket 是一一对应的。

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

在这里插入图片描述

在这里插入图片描述

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

通道接口允许以一种受控且可移植的方式来访问底层的 I/O 服务。

Channel 是一个对象,可以通过它读取和写入数据。拿 NIO 与原来的 I/O 做个比较,通道就像是流。所有数据都通过 Buffer 对象来处理。永远不会将字节直接写入通道中,相反,将数据写入包含一个或者多个字节的缓冲区。同样,不会直接从通道中读取字节,而是将数据从通道读入缓冲区,再从缓冲区获取这个字节。

Java NIO 的通道类似流,但又有些不同:

  • 全双工:既可以从通道中读取数据,又可以写数据到通道。但流的读写通常是单向的。
  • 异步:通道可以异步地读写。
  • Buffer:通道中的数据总是要先读到一个 Buffer,或者总是要从一个 Buffer 中写入。

正如上面所说,从通道读取数据到缓冲区,从缓冲区写入数据到通道。如下图所示:

在这里插入图片描述

在这里插入图片描述

2.2、Channel实现

Java NIO 中最重要的 Channel 的实现:

  • FileChannel

    从文件中读写数据

  • DatagramChannel

    通过 UDP 读写网络中的数据

  • SocketChannel

    通过 TCP 读写网络中的数据

  • ServerSocketChannel

    可以监听新进来的 TCP 连接,像 Web 服务器那样。对每一个新进来的连接都会创建一个 SocketChannel

2.3、FileChannel

1)介绍和示例

FileChannel 类可以实现常用的 read,write 以及 scatter/gather 操作,同时它也提供了很多专用于文件的新方法。这些方法中的许多都是我们所熟悉的文件操作。

public class FileChannelDemo1 {
    public static void main(String[] args) throws IOException {
        // 创建FileChannel
        RandomAccessFile file = new RandomAccessFile(".\\src\\res\\01.txt", "rw");
        //FileInputStream file = new FileInputStream(".\\src\\res\\01.txt");
        FileChannel channel = file.getChannel();

        // 创建Buffer
        ByteBuffer buf = ByteBuffer.allocate(1024);

        // 把数据读入Buffer
        int bytesRead = channel.read(buf);
        while (bytesRead != -1) {
            System.out.println("\n读取了:"+bytesRead);
            // 读写切换
            buf.flip();
            // 如果buf中有剩余数据就一直输出
            while(buf.hasRemaining()){
                System.out.print((char)buf.get());
            }
            buf.clear();
            // 继续下次读取
            bytesRead = channel.read(buf);
        }
        // file.close();
        // channel关闭后,file也会关闭
        channel.close();
        System.out.println("\n操作结束!");
    }
}

在这里插入图片描述

Buffer通常的操作

  • 将数据写入缓冲区
  • 调用 buffer.flip() 反转读写模式
  • 从缓冲区读取数据
  • 调用 buffer.clear() 或 buffer.compact() 清除缓冲区内容

2)打开FileChannel

在使用 FileChannel 之前,必须先打开它。但是,我们无法直接打开一个FileChannel,需要通过使用一个 InputStreamOutputStreamRandomAccessFile 来获取一个 FileChannel 实例

RandomAccessFile file = new RandomAccessFile(".\\src\\res\\01.txt", "rw");
//FileInputStream file = new FileInputStream(".\\src\\res\\01.txt");
//FileOutputStream file = new FileOutputStream(".\\src\\res\\01.txt");

FileChannel channel = file.getChannel();

3)从FileChannel读取数据

调用多个 read()方法之一,把数据从数据源,经过通道 FileChannel读取到缓冲区

ByteBuffer buf = ByteBuffer.allocate(1024);
int bytesRead = channel.read(buf);

1、分配一个 Buffer

读取的数据将被放到 Buffer 中

2、调用 FileChannel.read()方法

读取数据到 Buffer 中

read()方法返回的 int 值;表示了有多少字节被读到了 Buffer 中。如果返回-1,表示到了文件末尾

4)向 FileChannel 写数据

使用 FileChannel.write()方法向 FileChannel 写数据,该方法的参数是一个 Buffer

public static void write() throws IOException {
    FileOutputStream file = new FileOutputStream(".\\src\\res\\01.txt",true);
    FileChannel channel = file.getChannel();

    // 创建buffer,并存入数据
    ByteBuffer buf = ByteBuffer.allocate(1024);
    buf.clear();
    String str = "测试abc" + System.currentTimeMillis();
    buf.put(str.getBytes());
    // 读写转换
    buf.flip();

    // 如果buffer中有数据就写入通道
    while(buf.hasRemaining()){
        channel.write(buf);
    }

    file.close();
    channel.close();
}

在这里插入图片描述

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

5)关闭 FileChanne

用完 FileChannel 后必须将其关闭

channel.close();

6)position() 方法

有时可能需要在 FileChannel 的某个特定位置进行数据的读/写操作。可以通过调用 position() 方法获取FileChannel 的当前位置。也可以通过调用 position(long pos) 方法设置 FileChannel 的当前位置。

这里有两个例子:

// 获取当前位置
long pos = channel.position();
// 设置当前位置
channel.position(pos +123);

如果将位置设置在文件结束符之后,然后试图从文件通道中读取数据,读方法将返回 -1 (文件结束标志)

如果将位置设置在文件结束符之后,然后向通道中写数据,文件将撑大到当前位置并写入数据。这可能导致“文件空洞”,磁盘上物理文件中写入的数据间有空隙。

7)size() 方法

FileChannel 实例的 size()方法将返回该实例所关联文件的大小

long fileSize = channel.size();

8)truncate() 方法

可以使用 FileChannel.truncate()方法截取一个文件

截取文件时,文件中指定长度后面的部分将被删除

// 截取文件的前 1024 个字节
channel.truncate(1024);

9)force() 方法

FileChannel.force()方法将通道里尚未写入磁盘的数据强制写到磁盘上

出于性能方面的考虑,操作系统会将数据缓存在内存中,所以无法保证写入到 FileChannel 里的数据一定会即时写到磁盘上。要保证这一点,需要调用 force()方法。

force()方法有一个 boolean 类型的参数,指明是否同时将文件元数据(权限信息等)写到磁盘上

10)transferTo() 和 transferFrom() 方法

通道之间的数据传输:

  • 如果两个通道中有一个是 FileChannel,那可以直接将数据从一个 channel 传输到另外一个 channel
  • 在 SoketChannel 的实现中,SocketChannel 只会传输此刻准备好的数据(可能不足 count 字节)。因此,SocketChannel 可能不会将请求的所有数据(count 个字节)全部传输到 FileChannel 中。

在这里插入图片描述

public static void transfer() throws IOException {
    // 创建通道1
    RandomAccessFile fromFile = new RandomAccessFile(".\\src\\main\\resources\\01.txt", "rw");
    FileChannel fromChannel = fromFile.getChannel();
    // 创建通道2
    RandomAccessFile toFile = new RandomAccessFile(".\\src\\main\\resources\\02.txt", "rw");
    FileChannel toChannel = toFile.getChannel();

    // 传输
    long position = 0L;
    long size = fromChannel.size();
    //toChannel.transferFrom(fromChannel, position, size);
    fromChannel.transferTo(position,size,toChannel);

    // 关闭通道
    fromChannel.close();
    toChannel.close();
    System.out.println("over!");
}

2.4、Socket 通道

1)重要特性

  • 新的 socket 通道类可以运行非阻塞模式并且是可选择的,可以激活大程序(如网络服务器和中间件组件)巨大的可伸缩性和灵活性

    再也没有为每个 socket 连接使用一个线程的必要了,也避免了管理大量线程所需的上下文交换开销。

    借助新的 NIO 类,一个或几个线程就可以管理成百上千的活动 socket 连接了,并且只有很少甚至可能没有性能损失。

    所有的 socket 通道类(DatagramChannel、SocketChannel 和 ServerSocketChannel)都继承了位于 java.nio.channels.spi 包中的 AbstractSelectableChannel

    这意味着可以用一个 Selector 对象来执行 socket 通道的就绪选择(readiness selection)。

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

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

  • socket 和 socket 通道之间的关系

    • 通道是一个连接 I/O 服务导管并提供与该服务交互的方法
    • 就某个 socket 而言,它不会再次实现与之对应的 socket 通道类中的 socket 协议 API
    • 而 java.net 中已经存在的 socket 通道都可以被大多数协议操作重复使用
    • 全部 socket 通道类(DatagramChannel、SocketChannel 和 ServerSocketChannel)在被实例化时都会创建一个对等 socket 对象。这些是来自 java.net 的类(Socket、ServerSocket 和DatagramSocket),它们已经被更新以识别通道。对等 socket 可以通过调用 **socket( )**方法从一个通道上获取。此外,这三个 java.net 类现在都有 getChannel( ) 方法
  • 要把一个 socket 通道置于非阻塞模式,要依靠所有 socket 通道类的公有超级类:SelectableChannel

    • 就绪选择(readiness selection)是一种可以用来查询通道的机制,该查询可以判断通道是否准备好执行一个目标操作,如读或写

    • 非阻塞 I/O 和 可选择性 是紧密相连的,那也正是管理阻塞模式的 API 代码要在SelectableChannel 超级类中定义的原因

    • 设置或重新设置一个通道的阻塞模式是很简单的,只要调用 **configureBlocking( )**方法即可,传递参数值为 true 则设为阻塞模式,参数值为 false 值设为非阻塞模式

    • 可以通过调用 **isBlocking( )**方法来判断某个 socket 通道当前处于哪种模式

    • 非阻塞 socket 通常被认为是服务端使用的,因为它们使同时管理很多 socket 通道变得更容易。但是,在客户端使用一个或几个非阻塞模式的 socket 通道也是有益处的

      例如,借助非阻塞 socket 通道,GUI 程序可以专注于用户请求并且同时维护与一个或多个服务器的会话。在很多程序上,非阻塞模式都是有用的

    • 偶尔地,也会需要防止 socket 通道的阻塞模式被更改。API 中有一个**blockingLock( )**方法,该方法会返回一个非透明的对象引用。返回的对象是通道实现修改阻塞模式时内部使用的。只有拥有此对象的锁的线程才能更改通道的阻塞模式

2)ServerSocketChannel

(1)概述
  • ServerSocketChannel 是一个基于通道的 socket 监听器

    它同java.net.ServerSocket 执行相同的任务,不过它增加了通道语义,因此能够在非阻塞模式下运行

  • bind() 绑定端口

    ServerSocketChannel 有 bind()方法


    由于 ServerSocketChannel 没有 bind()方法,因此有必要取出对等的 socket 并使用它来绑定到一个端口以开始监听连接。我们也是使用对等 ServerSocket 的 API 来根据需要设置其他的 socket 选项

  • accept() 监听连接

    同 java.net.ServerSocket 一样,ServerSocketChannel 也有 accept( )方法

    一旦创建了一个 ServerSocketChannel 并用对等 socket 绑定了它,然后就可以在其中一个上调用 accept()。

    • 如果选择在 ServerSocket 上调用 accept( )方法,那么它会同任何其他的 ServerSocket 表现一样的行为:总是阻塞并返回一个 java.net.Socket 对象
    • 如果选择在 ServerSocketChannel 上调用 accept( )方法,则会返回SocketChannel 类型的对象,返回的对象能够在非阻塞模式下运行

    换句话说:

    • ServerSocketChannel 的 accept()方法会返回 SocketChannel 类型对象,SocketChannel 可以在非阻塞模式下运行。 其它 Socket 的 accept()方法会阻塞返回一个 Socket 对象。

    • 如果ServerSocketChannel 以非阻塞模式被调用,当没有传入连接在等待时,ServerSocketChannel.accept( )会立即返回 null。正是这种检查连接而不阻塞的能力实现了可伸缩性并降低了复杂性。可选择性也因此得到实现。我们可以使用一个选择器实例来注册 ServerSocketChannel 对象以实现新连接到达时自动通知的功能。

public class ServerSocketChannelDemo {
    public static void main(String[] args) throws IOException, InterruptedException {
        // 定义端口
        int port = 9999;

        // 创建缓冲区,并存入数据
        String str = "Hello World!";
        ByteBuffer buf = ByteBuffer.wrap(str.getBytes());

        // 创建ServerSocketChannel
        ServerSocketChannel ssc = ServerSocketChannel.open();

        // 绑定端口
        //ssc.socket().bind(new InetSocketAddress(port));
        ssc.bind(new InetSocketAddress(port));

        System.out.println(ssc.isBlocking());
        // 设置阻塞模式
        // 非阻塞
        ssc.configureBlocking(false);

        // 监听
        while (true) {
            System.out.println("等待连接...");
            SocketChannel sc = ssc.accept();
            // 无连接
            if (sc == null) {
                System.out.println("null");
                Thread.sleep(2000);
            }
            // 有连接
            else {
                System.out.println("当前连接:" +
                        sc.getRemoteAddress());
                // 重置buffer指针
                buf.rewind();
                // 把buffer数据写入通道
                sc.write(buf);
                // 关闭
                sc.close();
            }
        }
    }
}

在这里插入图片描述
在这里插入图片描述

(2)打开 ServerSocketChannel
ServerSocketChannel ssc = ServerSocketChannel.open();
(3)关闭 ServerSocketChannel

通过调用 ServerSocketChannel 对象的 close() 方法来关闭 ServerSocketChannel

ssc.close();
(4)监听新的连接

通过 ServerSocketChannel.accept() 方法监听新进的连接。当 accept()方法返回时候,它返回一个包含新进来的连接的 SocketChannel。因此, accept()方法会一直阻塞到有新连接到达。

通常不会仅仅只监听一个连接,在 while 循环中调用 accept()方法

// 监听
while (true) {
    System.out.println("等待连接...");
    SocketChannel sc = ssc.accept();
    // 无连接
    if (sc == null) {
        System.out.println("null");
        Thread.sleep(2000);
    }
    // 有连接
    else {
        System.out.println("当前连接:" +
                           sc.getRemoteAddress());
        // 重置buffer指针
        buf.rewind();
        // 把buffer数据写入通道
        sc.write(buf);
        // 关闭
        sc.close();
    }
}
(5)阻塞模式

ServerSocketChannel默认是阻塞模式;会在 SocketChannel sc = ssc.accept() 位置阻塞等待

(6)非阻塞模式

ServerSocketChannel 可以设置成非阻塞模式

在非阻塞模式下,accept() 方法会立刻返回,如果还没有新进来的连接,返回的将是 null。 因此,需要检查返回的

SocketChannel 是否是 null

// 设置非阻塞模式
ssc.configureBlocking(false);

// accept() 立刻返回,不会阻塞等待
SocketChannel sc = ssc.accept();
if (sc == null){
    // 无连接
} else {
    // 有连接
}

3)SocketChannel

(1)SocketChannel介绍

Java NIO 中的 SocketChannel 是一个连接到 TCP 网络套接字的通道

A selectable channel for stream-oriented connecting sockets

以上是 Java docs 中对于 SocketChannel 的描述:

SocketChannel 是一种面向流连接sockets 套接字的可选择通道。

从这里可以看出:

  • SocketChannel 是用来连接 Socket 套接字
  • SocketChannel 主要用途用来处理网络 I/O 的通道
  • SocketChannel 是基于 TCP 连接传输
  • SocketChannel 实现了可选择通道,可以被多路复用
(2)SocketChannel特征
  • 对于已经存在的 socket 不能创建 SocketChannel
  • SocketChannel 中提供的 无参open 接口创建的 Channel 并没有进行网络级联,需要使用 connect 接口连接到指定地址
  • 未进行连接的 SocketChannle 执行 I/O 操作时,会抛出NotYetConnectedException
  • SocketChannel 支持两种 I/O 模式:阻塞式非阻塞式
  • SocketChannel 支持异步关闭
    • 如果 SocketChannel 在一个线程上 read 阻塞,另一个线程对该 SocketChannel 调用 shutdownInput,则读阻塞的线程将返回-1,表示没有读取任何数据;
    • 如果 SocketChannel 在一个线程上 write 阻塞,另一个线程对该SocketChannel 调用 shutdownWrite,则写阻塞的线程将抛出AsynchronousCloseException
  • SocketChannel 支持设定参数
    • SO_SNDBUF 套接字发送缓冲区大小
    • SO_RCVBUF 套接字接收缓冲区大小
    • SO_KEEPALIVE 保活连接
    • O_REUSEADDR 复用地址
    • SO_LINGER 有数据传输时延缓关闭 Channel (只有在非阻塞模式下有用)
    • TCP_NODELAY 禁用 Nagle 算法
(3)SocketChannel使用
public class SocketChannelDemo {
    public static void main(String[] args) throws IOException {
        // 创建SocketChannel
        // 方式一:有参open()
        //SocketChannel sc1 = SocketChannel.open(new InetSocketAddress("www.baidu.com", 80));
        // 方式二:无参open()
        SocketChannel sc1 = SocketChannel.open();
        sc1.connect(new InetSocketAddress("www.baidu.com", 80));

        // 参数
        System.out.println("默认发送缓冲区大小:"
                + sc1.getOption(StandardSocketOptions.SO_SNDBUF));
        System.out.println("默认接收缓冲区大小:"
                + sc1.getOption(StandardSocketOptions.SO_RCVBUF));
        // 默认阻塞模式
        System.out.println("默认阻塞模式:" + sc1.isBlocking());
        System.out.println("保活连接:"
                + sc1.getOption(StandardSocketOptions.SO_KEEPALIVE));
        System.out.println("禁用 Nagle 算法:"
                + sc1.getOption(StandardSocketOptions.TCP_NODELAY));
        System.out.println("--------------");
        sc1.setOption(StandardSocketOptions.SO_SNDBUF, 4096);
        System.out.println("发送缓冲区大小:"
                + sc1.getOption(StandardSocketOptions.SO_SNDBUF));
        sc1.setOption(StandardSocketOptions.SO_KEEPALIVE, Boolean.TRUE);
        System.out.println("保活连接:"
                + sc1.getOption(StandardSocketOptions.SO_KEEPALIVE));
        System.out.println("--------------");
        System.out.println("打开:" + sc1.isOpen());
        System.out.println("连接:" + sc1.isConnected());
        System.out.println("正在连接:" + sc1.isConnectionPending());
        System.out.println("完成连接:" + sc1.finishConnect());
        System.out.println("--------------");
        // 阻塞模式
        // 设置阻塞模式
        sc1.configureBlocking(false);
        // 读取
        ByteBuffer buf = ByteBuffer.allocate(24);
        System.out.println("Reading...");
        sc1.read(buf);
        // 关闭
        sc1.close();

        System.out.println("--------------");
        System.out.println("打开:" + sc1.isOpen());
        System.out.println("连接:" + sc1.isConnected());
        System.out.println("正在连接:" + sc1.isConnectionPending());
        //System.out.println("完成连接:" + sc1.finishConnect());
        System.out.println("--------------");
        System.out.println("Read Over!");
    }
}
  1. 创建 SocketChannel

    • 方式一:有参open
    // 创建SocketChannel对象,并进行TCP连接
    SocketChannel sc1 = SocketChannel.open(new InetSocketAddress("www.baidu.com", 80));
    

    使用有参open(),既创建了SocketChannel对象,也进行了TCP连接

    • 方式二:无参open
    // 创建SocketChannel对象
    SocketChannel sc1 = SocketChannel.open();
    // 进行TCP连接
    sc1.connect(new InetSocketAddress("www.baidu.com", 80));
    

    使用无参open(),只是创建了SocketChannel对象,需要connect()进行了TCP连接

  2. 连接校验

    // 是否为 open 状态
    sc1.isOpen();
    // 是否已经被连接
    sc1.isConnected();
    // 是否正在进行连接
    sc1.isConnectionPending();
    // 是否已经完成连接
    sc1.finishConnect();
    
  3. 阻塞模式

    // 默认为阻塞模式:true
    sc1.isBlocking();
    // 设置为非阻塞模式
    sc1.configureBlocking(false);
    
  4. 读写

    ByteBuffer buf = ByteBuffer.allocate(24);
    // 把源端的数据通过SocketChannel读入buf
    sc1.read(buf);
    
  5. 设置和获取参数

    // 参数
    // 默认发送缓冲区大小:
    sc1.getOption(StandardSocketOptions.SO_SNDBUF);
    // 默认接收缓冲区大小:
    sc1.getOption(StandardSocketOptions.SO_RCVBUF);
    // 保活连接:
    sc1.getOption(StandardSocketOptions.SO_KEEPALIVE);
    // 禁用 Nagle 算法:
    sc1.getOption(StandardSocketOptions.TCP_NODELAY);
     
    // 发送缓冲区大小
    sc1.setOption(StandardSocketOptions.SO_SNDBUF, 4096);
    // 设置保活连接
    sc1.setOption(StandardSocketOptions.SO_KEEPALIVE, Boolean.TRUE);
    

4)DatagramChannel

Channel通道对应Socket
SocketChannelSocket
ServerSocketChannelServerSocket
DatagramChannelDatagramSocket
  • SocketChannel 模拟连接导向的流协议(如 TCP/IP),DatagramChannel 则模拟包导向的无连接协议(如 UDP/IP)
  • DatagramChannel 是无连接的,每个数据报(datagram)都是一个自包含的实体,拥有它自己的目的地址及不依赖其他数据报的数据负载
  • 与面向流的的 socket 不同,DatagramChannel 可以发送单独的数据报给不同的目的地址。同样,DatagramChannel 对象也可以接收来自任意地址的数据包。每个到达的数据报都含有关于它来自何处的信息(源地址)
(1)打开DatagramChannel
// 打开DatagramChannel
DatagramChannel sdc = DatagramChannel.open();
(2)发送数据

send(缓冲区,目的地址)

// 目的地址
InetSocketAddress ads = new InetSocketAddress("127.0.0.1", 10086);
// 发送的数据
String s = "测试abc" + System.currentTimeMillis();
// 缓冲区
ByteBuffer buf = ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8));
// 发送
sdc.send(buf, ads);
(3)接收数据
// 1、打开DatagramChannel   open()
// 2、绑定端口 bind()
// 3、定义接收缓冲区 buf
// 4、接收 receive()
SocketAddress sa = rdc.receive(buf);

返回SocketAddress对象,包含发送方的 ip、端口等信息

(4)连接

UDP 不存在真正意义上的连接,这里的连接是向特定服务地址用 read 和 write 接收发送数据包

见示例中的connect()方法

read() 和 write()只有在 connect()后才能使用,不然会抛NotYetConnectedException 异常。用 read()接收时,如果没有接收到包,会抛PortUnreachableException 异常。

(5)示例
public class DatagramSocketDemo {
    /**
     * send发送
     */
    @Test
    public void send() throws IOException, InterruptedException {
        // 打开DatagramChannel
        DatagramChannel sdc = DatagramChannel.open();

        // 目的地址
        InetSocketAddress ads = new InetSocketAddress("127.0.0.1", 10086);

        // 循环发送
        while (true) {
            String s = "测试abc" + System.currentTimeMillis();
            // 缓冲区
            ByteBuffer buf = ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8));
            // 发送
            sdc.send(buf, ads);

            System.out.println(s + "发送完毕!");
            Thread.sleep(2000);
        }

    }

    /**
     * receive接收
     */
    @Test
    public void receive() throws IOException {
        // 打开DatagramChannel
        DatagramChannel rdc = DatagramChannel.open();

        // 接收端口
        InetSocketAddress ads = new InetSocketAddress(10086);

        // 绑定端口
        rdc.bind(ads);

        // 缓冲区
        ByteBuffer buf = ByteBuffer.allocate(30);

        // 循环接收
        while (true) {
            // 清空缓冲区
            buf.clear();
            // 接收
            SocketAddress sa = rdc.receive(buf);
            // 缓冲区读写反转
            buf.flip();

            System.out.println(sa.toString());
            System.out.println(Charset.forName("utf8").decode(buf));
        }
    }

    /**
     * write、read、connect
     */
    @Test
    public void connect() throws IOException {
        // 打开DatagramChannel
        DatagramChannel rdc = DatagramChannel.open();

        // 定义接收端口
        InetSocketAddress ads = new InetSocketAddress(9999);

        // 绑定端口
        rdc.bind(ads);

        rdc.connect(new InetSocketAddress("127.0.0.1", 9999));

        // 发送
        String s = "abc测试" + System.currentTimeMillis();
        // 缓冲区
        ByteBuffer buf1 = ByteBuffer.wrap(s.getBytes(StandardCharsets.UTF_8));
        // 发送
        rdc.write(buf1);
        System.out.println(s + "发送完毕!");

        // 接收
        // 缓冲区
        ByteBuffer buf = ByteBuffer.allocate(128);
        // 循环接收
        while(true){
            // 清空缓冲区
            buf.clear();
            // 读取
            rdc.read(buf);
            // 读写反转
            buf.flip();

            System.out.println(rdc.getRemoteAddress());
            System.out.println(buf);
        }
    }

}

2.5、分散与聚集

1)概述

Java NIO 开始支持 scatter/gather,scatter/gather 用于描述从 Channel 中读取或者写入到 Channel 的操作

  • 分散(scatter)

从 Channel 中读取,是指在读操作时将读取的数据写入多个 buffer中

因此,Channel 将读取的数据”分散(scatter)”到多个 Buffer中

  • 聚集(gather)

    写入 Channel,是指在写操作时将多个 buffer 的数据写入同一个Channel

    因此,Channel 将多个 Buffer 中的数据“聚集(gather)”后发送到Channel

scatter / gather 经常用于需要将传输的数据分开处理的场合

例如:传输一个由消息头和消息体组成的消息,可能会将消息体和消息头分散到不同的 buffer 中,这样可以方便的处理消息头和消息体

2)Scattering Reads

Scattering Reads 是指数据从一个 channel 读取到多个 buffer 中

在这里插入图片描述

// 消息头缓冲区
ByteBuffer header = ByteBuffer.allocate(128);
// 消息体缓冲区
ByteBuffer body	= ByteBuffer.allocate(1024);
// 缓冲数组 
ByteBuffer[] bufferArray = { header, body};
// 读取
channel.read(bufferArray);

注意 多个buffer 首先被插入到数组,然后再将数组作为 channel.read() 的输入参数。

read()方法按照 buffer 在数组中的顺序将从 channel 中读取的数据写入到 buffer,当一个 buffer 被写满后,channel 紧接着向另一个 buffer 中写。

Scattering Reads 在移动下一个 buffer 前,必须填满当前的 buffer,这也意味着它不适用于动态消息(译者注:消息大小不固定)。换句话说,如果存在消息头和消息体,消息头必须完成填充(例如 128byte),Scattering Reads 才能正常工作

3)Gathering Writes

Gathering Writes 是指数据从多个 buffer 写入到同一个 channel

在这里插入图片描述

// 消息头缓冲区
ByteBuffer header = ByteBuffer.allocate(128);
// 消息体缓冲区
ByteBuffer body	= ByteBuffer.allocate(1024);
// 缓冲区数组
ByteBuffer[] bufferArray = {header, body};
// 写入
channel.write(bufferArray);

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

3、Buffer

3.1、Buffer简介

Java NIO 中的 Buffer 用于和 NIO 通道进行交互

数据是从通道读入缓冲区,从缓冲区写入到通道中的

在这里插入图片描述

  • 缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存

    这块内存被包装成 NIO Buffer 对象,并提供了一组方法,用来方便的访问该块内存。

  • 缓冲区实际上是一个容器对象,其实就是一个数组,在 NIO 库中,所有数据都是用缓冲区处理的

    在读取数据时,它是直接读到缓冲区中的; 在写入数据时,它也是写入到缓冲区中的;任何时候访问 NIO 中的数据,都是将它放到缓冲区中。

    而在面向流 I/O 系统中,所有数据都是直接写入或者直接将数据读取到 Stream 对象中。

  • 在 NIO 中,所有的缓冲区类型都继承于抽象类 Buffer,最常用的就是 ByteBuffer,对于 Java 中的基本类型,基本都有一个具体 Buffer 类型与之相对应,它们之间的继承关系如下图所示:

在这里插入图片描述

3.2、Buffer的基本用法

1)读写数据步骤

(1)写入数据到Buffer

当向 buffer 写入数据时,buffer 会记录下写了多少数据

(2)调用 flip() 读写反转

一旦要读取数据,需要通过flip()方法将 Buffer 从写模式切换到读模式

(3)从Buffer中读取数据

在读模式下,可以读取之前写入到 buffer的所有数据

(4)调用clear() 或 compact() 重置

一旦读完了所有的数据,就需要清空缓冲区,让它可以再次被写入。

有两种方式能清空缓冲区:调用 clear()或 compact()方法。

  • clear()方法会清空整个缓冲区。
  • compact()方法只会清除已经读过的数据。任何未读的数据都被移到缓冲区的起

始处,新写入的数据将放到缓冲区未读数据的后面。

2)读写示例

public class BufferDemo {
    public static void main(String[] args) throws IOException {
        bufferTest();
    }

    /**
     * buffer读写
     * @throws IOException
     */
    public static void bufferTest() throws IOException {
        // 目标文件路径
        String filePaht = ".\\src\\main\\resources\\03.txt";
        // 定义文件
        RandomAccessFile file = new RandomAccessFile(filePaht, "rw");
        // 获取通道
        FileChannel channel = file.getChannel();

        // 定义接收缓冲区
        ByteBuffer buf = ByteBuffer.allocate(32);

        // 单次读取数量
        int readNum = 0;
        // 如果没有读到文件结尾就循环读取;把读到的数据写入buf
        while((readNum = channel.read(buf)) != -1){
            // 读取反转:由写入转为读取
            buf.flip();
            // 如果buf中有数据
            if (buf.hasRemaining()){
                // 输出数据:utf8编码
                System.out.print(Charset.forName("utf8").decode(buf));
            }
            // 重置buf
            buf.clear();
            //buf.compact();
        }
        // 在关闭通道前,调用file,输出正常
        System.out.println(file.length());
        //file.close();
        // 关闭通道:通道关闭后,file也会关闭
        channel.close();

        // 关闭通道后,再调用file,会出异常;因为file也关闭了
        //System.out.println(file.length());
    }
}

在这里插入图片描述

3)IntBuffer示例

    /**
     * IntBuffer读写
     */
    public static void intBufferTest() {
        // 定义buffer
        IntBuffer buf = IntBuffer.allocate(8);
        // 写入buffer
        for (int i = 0; i < buf.capacity(); i++) {
            buf.put(2 * (i + 1));
        }
        // 读写反转
        buf.flip();
        // 读取
        while (buf.hasRemaining()) {
            System.out.print(buf.get() + " ");
        }
    }

在这里插入图片描述

3.3、capacity、position和limit

  • capacity:容量

    作为一个内存块,Buffer 有一个固定的大小值,也叫“capacity”

    只能往里写capacity 个 byte、long,char 等类型

    一旦 Buffer 满了,需要将其清空(通过读数据或者清除数据)才能继续写数据往里写数据

  • position:指针位置

    • 向Buffer中写数据时,position 表示写入数据的当前位置,position 的初始值为0。当一个 byte、long 等数据写到 Buffer 后, position 会向下移动到下一个可插入数据的 Buffer 单元。position 最大可为 capacity – 1(因为 position 的初始值为0)
    • 从Buffer中读数据时,position 表示读入数据的当前位置,如 position=2 时表示已开始读第 3 个 byte,或从第 3 个 byte 开始读取。通过 ByteBuffer.flip()切换到读模式时 position 会被重置为 0,当 Buffer 从 position 读入数据后,position 会下移到下一个可读入的数据 Buffer 单元。
  • limit:界限

    • 写数据时,limit 表示可对 Buffer 最多写入多少个数据。写模式下,limit 等于Buffer 的 capacity。
    • 读数据时,limit 表示 Buffer 里有多少可读数据(not null 的数据),因此能读到之前写入的所有数据(limit 被设置成已写数据的数量,这个值在写模式下就是position)
属性写模式读模式
capacity容量容量
position写入数据的当前位置读取数据的当前位置
limit最多可写入的数据量有多少可读数据(not null)

在这里插入图片描述

3.4、Buffer的类型

  • ByteBuffer
  • MappedByteBuffer
  • CharBuffer
  • DoubleBuffer
  • FloatBuffer
  • IntBuffer
  • LongBuffer
  • ShortBuffer

这些 Buffer 类型代表了不同的数据类型。可以通过 char,short,int,long,float 或 double 类型来操作缓冲区中的字节

3.5、Buffer分配和写数据

1)分配 allocate()

要想获得一个 Buffer 对象首先要进行分配。 每一个 Buffer 类都有一个 allocate 方法

// 分配一个可存储48字节的Buffer
ByteBuffer buf1 = ByteBuffer.allocate(48);

// 分配一个可存储1024字符的Buffer
CharBuffer buf2 = ByteBuffer.allocate(1024);

2)写数据 read()、put()

  • read():从通道Channel读取,并写入Buffer
int bytesRead = inChannel.read(buf);
  • put():用Buffer的put()方法写入
buf.put(127);

put 方法有很多版本,允许你以不同的方式把数据写入到 Buffer 中。例如, 写到一个指定的位置,或者把一个字节数组写入到 Buffer

3)flip()

  • flip 方法将 Buffer 从写模式切换到读模式

  • 调用 flip()方法会将 position 设回 0,并将 limit 设置成之前 position 的值

    position 现在用于标记读的位置,limit 表示之前写进了多少个 byte、char 等 (现在能读取多少个 byte、char 等)。

3.6、从Buffer中读取数据

  • write():从Buffer中读取数据,并写入Channel
int bn = inChannel.write(buf);
  • get():用Buffer的方法读取
byte b = buf.get();

get 方法有很多版本,允许以不同的方式从 Buffer 中读取数据。

例如,从指定position 读取,或者从 Buffer 中读取数据到字节数组。

3.7、Buffer几个方法

1)rewind()

将 position 设回 0,limit 保持不变

可以重读 Buffer 中的所有数据。limit 保持不变,仍然表示能从 Buffer 中读取多少个元素(byte、char 等)

2)clear() 与 compact()

  • clear()

    position 将被设回 0,limit 被设置成 capacity 的值

    相当于Buffer 被清空了。Buffer 中的数据并未清除,只是这些标记告诉我们可以从哪里开始往 Buffer 里写数据。

  • compact()

    将所有未读的数据拷贝到 Buffer 起始处。然后将 position 设到最后一个未读元素后面。limit 属性依然像 clear()方法一样,设置成 capacity。现在Buffer 准备好写数据了,但是不会覆盖未读的数据。

区别positionlimit
rewind()0保持不变
clear()0= capacity
compact()最后一个未读元素后面= capacity

3)mark() 与 reset()

  • mark()

    标记 Buffer 中的一个特定 position

  • reset()

    恢复到之前标记的 position

3.8、缓冲区操作

1)缓冲区分片 slice()

在 NIO 中,除了可以分配或者包装一个缓冲区对象外,还可以根据现有的缓冲区对象来创建一个子缓冲区,即在现有缓冲区上切出一片来作为一个新的缓冲区,但现有的缓冲区与创建的子缓冲区在底层数组层面上是数据共享的,也就是说,子缓冲区相当于是现有缓冲区的一个视图窗口。

调用 ==slice()==方法可以创建一个子缓冲区。

    /**
     * 缓冲区分片
     */
    public static void slice(){
        // 创建Buffer
        IntBuffer buf = IntBuffer.allocate(10);
        // 写入
        for (int i = 0; i < buf.capacity(); i++) {
            buf.put(i);
        }
        // 分片
        buf.position(3);
        buf.limit(7);
        IntBuffer sliceBuf = buf.slice();
        // 子缓冲区写入
        for (int i = 0; i < sliceBuf.capacity(); i++) {
            int s = sliceBuf.get(i);
            s *= 10;
            sliceBuf.put(s);
        }
        // 输出
        buf.position(0);
        buf.limit(buf.capacity());
        while(buf.remaining() > 0){
            System.out.print(buf.get() + " ");
        }
    }

在这里插入图片描述

2)只读缓冲区 asReadOnlyBuffer()

只读缓冲区非常简单,可以读取它们,但是不能向它们写入数据。可以通过调用缓冲区的 ==asReadOnlyBuffer()==方法,将任何常规缓冲区转换为只读缓冲区,这个方法返回一个与原缓冲区完全相同的缓冲区,并与原缓冲区共享数据,只不过它是只读的

如果原缓冲区的内容发生了变化,只读缓冲区的内容也随之发生变化

    /**
     * 只读缓冲区
     */
    public static void readOnlyBuffer() {
        // 创建Buffer
        IntBuffer buf = IntBuffer.allocate(10);
        // 写入
        for (int i = 0; i < buf.capacity(); i++) {
            buf.put(i);
        }
        // 输出位置
        System.out.println("buf位置1:"+buf.position() + " " + buf.limit());

        // 得到只读缓冲区
        IntBuffer readOnlyBuffer = buf.asReadOnlyBuffer();
        System.out.println("只读buf位置1:"+readOnlyBuffer.position() + " " + readOnlyBuffer.limit());


        // 不能向只读缓冲区写数据,会抛异常
//        for (int i = 0; i < readOnlyBuffer.capacity(); i++) {
//            readOnlyBuffer.put(i*10);
//        }

        System.out.println("buf位置2:"+buf.position() + " " + buf.limit());

        // 写入新数据
        for (int i = 0; i < buf.capacity(); i++) {
            buf.put(i,i * 10);
        }
        System.out.println("buf位置3:"+buf.position() + " " + buf.limit());
        System.out.println("只读buf位置2:"+readOnlyBuffer.position() + " " + readOnlyBuffer.limit());

        // 重新新位置
        readOnlyBuffer.position(0);
        readOnlyBuffer.limit(readOnlyBuffer.capacity());
        System.out.println("只读buf位置3:"+readOnlyBuffer.position() + " " + readOnlyBuffer.limit());
        // 输出
        while(readOnlyBuffer.hasRemaining()){
            System.out.print(readOnlyBuffer.get() + " ");
        }
    }

在这里插入图片描述

如果尝试修改只读缓冲区的内容,则会报 ReadOnlyBufferException 异常。

只读缓冲区对于保护数据很有用。在将缓冲区传递给某个对象的方法时,无法知道这个方法是否会修改缓冲区中的数据。创建一个只读的缓冲区可以保证该缓冲区不会被修改。

只可以把常规缓冲区转换为只读缓冲区,而不能将只读的缓冲区转换为可写的缓冲区。

3)直接缓冲区 allocateDirect()

直接缓冲区是为加快 I/O 速度,使用一种特殊方式为其分配内存的缓冲区,JDK 文档中的描述为:给定一个直接字节缓冲区,Java 虚拟机将尽最大努力直接对它执行本机I/O 操作。也就是说,它会在每一次调用底层操作系统的本机 I/O 操作之前(或之后),尝试避免将缓冲区的内容拷贝到一个中间缓冲区中或者从一个中间缓冲区中拷贝数据。

要分配直接缓冲区,需要调用 ==allocateDirect()==方法,而不是 allocate()方法,使用方式与普通缓冲区并无区别

    public static void bufferDirect() throws IOException {
        // 输入通道
        String inFilePath = ".\\src\\main\\resources\\04.txt";
        FileInputStream fis = new FileInputStream(inFilePath);
        FileChannel fiChannel = fis.getChannel();

        // 输出通道
        String outFilePath = ".\\src\\main\\resources\\04_.txt";
        FileOutputStream fos = new FileOutputStream(outFilePath);
        FileChannel foChannel = fos.getChannel();

        // 直接缓冲区
        ByteBuffer buf = ByteBuffer.allocateDirect(64);

        // 复制        
        long a = fiChannel.size();
        int b = buf.capacity();
        System.out.println("文件大小:" + a + "字节");
        System.out.println("缓冲区大小:" + b + "字节");
        System.out.println("共需要复制:" + (int)Math.ceil(a/(float)b));
        int i = 0;
        // 如果还有数据可以写入buf,就循环操作
        while (fiChannel.read(buf) != -1) {
            i++;
            // 读写反转
            buf.flip();
            // 从buf中读取数据,写入通道,并存入文件
            foChannel.write(buf);
            // 清空buf
            buf.clear();
        }
        System.out.println("实际复制次数:" + i);
        System.out.println("复制完成");
        
        // 关闭
        fiChannel.close();
        foChannel.close();
    }

非浮点数相除,如果有小数,且需要保留小数时,把除数或被除数中至少一个转为浮点型即可

如:

int a = 20;
int b = 6;
int c = a/b; // 3
float d = (float)a/b; // 3.3333333

4)内存映射文件I/O

内存映射文件 I/O 是一种读和写文件数据的方法,它可以比常规的基于流或者基于通道的 I/O 快的多

内存映射文件 I/O 是通过使文件中的数据出现为内存数组的内容来完成的,这其初听起来似乎不过就是将整个文件读到内存中,但是事实上并不是这样。一般来说,只有文件中实际读取或者写入的部分才会映射到内存中

    public static void mapIO() throws IOException {
        int start = 0;
        int size = 1024;

        RandomAccessFile raf = new RandomAccessFile(".\\src\\main\\resources\\04.txt",
                "rw");
        FileChannel fc = raf.getChannel();
        MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE,start, size);

        mbb.put(0, (byte) 97);
        mbb.put(1023, (byte) 122);

        fc.close();
    }

4、Selector

4.1、Selector简介

1)Selector 和 Channel的关系

Selector 一般称为选择器 ,也可以翻译为 多路复用器

它是 Java NIO 核心组件中的一个,用于检查一个或多个 NIO Channel(通道)的状态是否处于可读、可写。由此可以实现单线程管理多个 channels,也就是可以管理多个网络链接。

在这里插入图片描述

使用 Selector 的好处: 使用更少的线程来就可以来处理通道了, 相比使用多个线程,避免了线程上下文切换带来的开销。

2)可选择通道

  • 不是所有的 Channel 都可以被 Selector 复用的

    判断 Channel 能被 Selector 复用的前提:是否继承了抽象类 SelectableChannel

    如果继承了 SelectableChannel,则可以被复用,否则不能

  • SelectableChannel 类提供了实现通道的可选择性所需要的公共方法

    它是所有支持就绪检查的通道类的父类

    所有 socket 通道,都继承了 SelectableChannel 类,都是可选择的,包括从管道(Pipe)对象的中获得的通道

    FileChannel 类,没有继承 SelectableChannel,因此是不可选择通道,不能被选择器复用

  • 一个通道可以被注册到多个选择器上,但对每个选择器而言只能被注册一次

    通道和选择器之间的关系,使用注册的方式完成

    SelectableChannel 可以被注册到 Selector 对象上,在注册的时候,需要指定通道的哪些操作是 Selector 感兴趣的

在这里插入图片描述

3)Channel 注册到 Selector

  • 使用 Channel.register(Selector sel,int ops) 方法,将一个通道注册到一个选择器

    • Selector sel 指定通道要注册的选择器

    • int ops 指定选择器需要查询的通道操作

      可以供选择器查询的通道操作,从类型来分,包括以下四种:

      • 可读 : SelectionKey.OP_READ
      • 可写 : SelectionKey.OP_WRITE
      • 连接 : SelectionKey.OP_CONNECT
      • 接收 : SelectionKey.OP_ACCEPT

      如果 Selector 对通道的多操作类型感兴趣,可以用“位或”操作符来实现:

      比如:int key = SelectionKey.OP_READ | SelectionKey.OP_WRITE ;

  • 选择器查询的不是通道的操作,而是通道的某个操作的一种就绪状态

    什么是操作的就绪状态?

    一旦通道具备完成某个操作的条件,表示该通道的某个操作已经就绪,就可以被 Selector 查询到,程序可以对通道进行对应的操作

    比如:

    • 某个SocketChannel 通道可以连接到一个服务器,则处于“连接就绪”(OP_CONNECT)
    • 一个 ServerSocketChannel 服务器通道准备好接收新进入的连接,则处于“接收就绪”(OP_ACCEPT)状态
    • 一个有数据可读的通道,可以说是“读就绪”(OP_READ)
    • 一个等待写数据的通道,可以说是“写就绪”(OP_WRITE)

4)选择键(SelectionKey)

  • Channel 注册后,并且一旦通道处于某种就绪的状态,就可以被选择器查询到。这个工作用选择器 Selector 的 select() 方法完成。select 方法的作用:对感兴趣的通道操作,进行就绪状态的查询

  • Selector 可以不断的查询 Channel 中发生的操作的就绪状态,并且挑选感兴趣的操作就绪状态。一旦通道有操作的就绪状态达成,并且是 Selector 感兴趣的操作,就会被 Selector 选中,放入 选择键集合

  • 一个选择键,首先是包含了注册在 Selector 的通道操作的类型,比如:SelectionKey.OP_READ。也包含了特定的通道与特定的选择器之间的注册关系

    开发应用程序时,选择键是编程的关键

    NIO 的编程,就是根据对应的选择键,进行不同的业务逻辑处理

  • 选择键的概念,和事件的概念比较相似

    一个选择键类似监听器模式里边的一个事件

    由于 Selector 不是事件触发的模式,而是主动去查询的模式,所以不叫事件Event,而是叫 SelectionKey 选择键

4.2、Selector使用方法

1)Selector 的创建

通过调用 Selector.open()方法创建一个 Selector 对象

// 创建选择器
Selector selector = Selector.open();

2)注册 Channel 到 Selector

要实现 Selector 管理 Channel,需要将 Channel 注册到相应的 Selector 上

通过调用通道的 register() 方法会将它注册到一个选择器上

// 创建选择器
Selector selector = Selector.open();

// 创建通道
ServerSocketChannel ssc = ServerSocketChannel.open();

// 设置通道为非阻塞模式
ssc.configureBlocking(false);

// 为通道绑定连接
ssc.bind(new InetSocketAddress(9999));

// 将通道注册到选择器,关注接收状态
ssc.register(selector, SelectionKey.OP_ACCEPT);

注意点:

  • 与 Selector 一起使用时,Channel 必须处于非阻塞模式下,否则将抛出异常

    IllegalBlockingModeException。

    FileChannel 不能切换到非阻塞模式,不能与 Selector 一起使用,而套接字相关的所有的通道都可以

  • 一个通道,并非一定要支持所有的四种操作

    比如:服务器通道 ServerSocketChannel 支持 Accept 接受操作,而 SocketChannel 客户端通道则不支持。

    可以通过通道上的 validOps() 方法,来获取特定通道下所有支持的操作集合,返回int值

    [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aOKmbWc6-1638795406029)(img/image-20211202113230943.png)]

3)轮询查询就绪操作

  • 通过 Selector 的 select() 方法,可以查询出已经就绪的通道操作,这些就绪的状态集合,保存在 SelectionKey 对象的 Set 集合中

    Selector 几个重载的查询 select()方法:

    • select():阻塞到至少有一个通道的注册状态就绪
    • select(long timeout):和 select()一样,但最长阻塞时间为 timeout 毫秒
    • selectNow():非阻塞,不管通道的注册状态是否就绪就立刻返回
  • select() 方法返回的 int 值,表示有多少通道已经就绪

    即两次 select() 方法之间的时间段上,有多少通道变成就绪状态

    例如:

    首次调用 select()方法,如果有一个通道变成就绪状态,返回了 1,若再次调用select()方法,如果另一个通道就绪了,它会再次返回 1。

    如果对第一个就绪的 Channel 没有做任何操作,现在就有两个就绪的通道,但在每次 select() 方法调用之间,只有一个通道就绪了

  • 一旦调用 select() 方法,并且返回值不为 0 时,在 Selector 中有一个 selectedKeys() 方法,返回选择键集合,迭代集合的每一个选择键元素,根据就绪操作的类型,完成对应的操作

// 查询是否有状态就绪
//int n = selector.select();
//int n = selector.select(2000);
int n = selector.selectNow();
System.out.println(n);

// 获取选择键集合
Set<SelectionKey> selectionKeys = selector.selectedKeys();
// 遍历选择键集合(1)
for (SelectionKey key : selectionKeys) {
    if (key.isAcceptable()) {
        // 可接收
        System.out.println("可接收");
    } else if (key.isConnectable()) {
        // 可连接
        System.out.println("可连接");
    } else if (key.isReadable()) {
        // 可读
        System.out.println("可读");
    } else if (key.isWritable()) {
        // 可写
        System.out.println("可写");
    }
}

// 遍历选择键集合(2)
Iterator<SelectionKey> iterator = selectionKeys.iterator();
while (iterator.hasNext()){
    SelectionKey key = iterator.next();
    if (key.isAcceptable()) {
        // 可接收
        System.out.println("可接收");
    } else if (key.isConnectable()) {
        // 可连接
        System.out.println("可连接");
    } else if (key.isReadable()) {
        // 可读
        System.out.println("可读");
    } else if (key.isWritable()) {
        // 可写
        System.out.println("可写");
    }
    //iterator.remove();
}

4)停止选择的方法

选择器执行选择的过程,系统底层会依次询问每个通道是否已经就绪,这个过程可能会造成调用线程进入阻塞状态,可以有以下两种方式可以唤醒在 select() 方法中阻塞的线程

  • wakeup():通过调用 Selector 对象的 wakeup() 方法让处在阻塞状态的select() 方法立刻返回

    该方法使得选择器上的第一个还没有返回的选择操作立即返回

    如果当前没有进行中的选择操作,那么下一次对 select() 方法的一次调用将立即返回

  • close():关闭 Selector

    该方法使得任何一个在选择操作中阻塞的线程都被唤醒(类似 wakeup()),同时使得注册到该 Selector 的所有 Channel 被注销,所有的键将被取消,但是 Channel 本身并不会关闭

public class SelectorDemo {
    public static void main(String[] args) throws IOException {
        // 创建选择器
        Selector selector = Selector.open();

        // 创建通道
        ServerSocketChannel ssc = ServerSocketChannel.open();

        // 设置通道为非阻塞模式
        ssc.configureBlocking(false);

        // 为通道绑定连接
        ssc.bind(new InetSocketAddress(9999));

        // 通道支持的操作
        System.out.println(ssc.validOps());

        // 将通道注册到选择器,关注接收状态
        ssc.register(selector, SelectionKey.OP_ACCEPT);

        // 让处在阻塞状态的 select() 方法立刻返回
        selector.wakeup();
        // 查询是否有状态就绪
        int n = selector.select();
        //int n = selector.select(2000);
        //int n = selector.selectNow();
        System.out.println(n);

        // 获取选择键集合
        Set<SelectionKey> selectionKeys = selector.selectedKeys();
        // 遍历选择键集合(1)
        for (SelectionKey key : selectionKeys) {
            if (key.isAcceptable()) {
                // 可接收
                System.out.println("可接收");
            } else if (key.isConnectable()) {
                // 可连接
                System.out.println("可连接");
            } else if (key.isReadable()) {
                // 可读
                System.out.println("可读");
            } else if (key.isWritable()) {
                // 可写
                System.out.println("可写");
            }
        }

        // 遍历选择键集合(2)
        Iterator<SelectionKey> iterator = selectionKeys.iterator();
        while (iterator.hasNext()){
            SelectionKey key = iterator.next();
            if (key.isAcceptable()) {
                // 可接收
                System.out.println("可接收");
            } else if (key.isConnectable()) {
                // 可连接
                System.out.println("可连接");
            } else if (key.isReadable()) {
                // 可读
                System.out.println("可读");
            } else if (key.isWritable()) {
                // 可写
                System.out.println("可写");
            }
            //iterator.remove();
        }

        // 关闭通道
        ssc.close();
    }
}

4.3、示例

1)服务端

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.util.Set;

/**
 * @author 土味儿
 * Date 2021/12/3
 * @version 1.0
 */
@SuppressWarnings("all")
public class ServerDemo {
    public static void main(String[] args) throws IOException {
        // 1、获取服务端通道
        ServerSocketChannel ssc = ServerSocketChannel.open();
        // 2、绑定端口
        ssc.bind(new InetSocketAddress(9999));
        // 3、切换为非阻塞模式
        ssc.configureBlocking(false);
        // 4、创建缓冲区
        // ByteBuffer buf = ByteBuffer.allocate(1024);
        // 5、建立选择器
        Selector selector = Selector.open();
        // 6、注册通道及关注事件
        ssc.register(selector, SelectionKey.OP_ACCEPT);
        // 7、选择器轮询查询
        while (selector.select(5000) > 0) {
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            for (SelectionKey sk : selectionKeys) {
                // 可接收状态:表示服务端已做好准备,可以接收客户的连接了
                if (sk.isAcceptable()) {
                    // =====接收来自客户端的连接=====
                    // 获取客户端通道
                    SocketChannel sc = ssc.accept();
                    //if(sc != null){
                        // 设为非阻塞模式
                        sc.configureBlocking(false);
                        // 注册到选择器
                        sc.register(selector, SelectionKey.OP_READ);
                    //}
                    // 关闭通道:关闭后,后续读取操作无法进行
                    // sc.close();
                }
                // 可读状态:表示客户端已发送完毕,可以在服务端读取数据了
                else if (sk.isReadable()) {
                    // =====从客户端通道读取数据=====
                    // 获取客户端通道:如果前面客户端通道关闭,将会得到null
                    SocketChannel sc = (SocketChannel) sk.channel();
                    // 创建缓冲区
                    ByteBuffer readBuf = ByteBuffer.allocate(1024);

                    int rl = 0;
                    // 循环读取数据
                    while((rl = sc.read(readBuf)) > 0){
                        // 读写反转
                        readBuf.flip();
                        // 输出
                        System.out.println(new String(readBuf.array(),0,rl));
                        // 清空缓冲区
                        readBuf.clear();
                    }
                    // 关闭通道:关闭后将不能进行下次读取或接收
                    // sc.close();
                }
            }

            // 清理选择键集合:为下次轮询查询做准备
            selectionKeys.clear();
        }

        // 8、关闭:如果超出5秒没有事件发生,关闭通道
        ssc.close();

        System.out.println("over!");
    }
}

2)客户端

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SocketChannel;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.Scanner;

/**
 * @author 土味儿
 * Date 2021/12/3
 * @version 1.0
 */
@SuppressWarnings("all")
public class ClientDemo {
    public static void main(String[] args){
        // 1、获取通道
        SocketChannel sc = null;
        try {
            sc = SocketChannel.open();
            // 2、连接主机及端口:使用connect()连接,不是bind()绑定
            // sc.bind(new InetSocketAddress("127.0.0.1",9999));
            sc.connect(new InetSocketAddress("127.0.0.1", 9999));
            // 3、设置通道为非阻塞模式
            sc.configureBlocking(false);
            // 4、创建缓冲区
            ByteBuffer buf = ByteBuffer.allocate(1024);

            // 自定义输入
            Scanner scanner = new Scanner(System.in);
            String d = "";
            System.out.println("请输入发送内容...");
            while (scanner.hasNext()) {
                // 5、向缓冲区写入数据
                d = "[" + LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-mm-dd hh:mm:ss")) + "] " + scanner.nextLine();
                buf.put(d.getBytes());
                // 6、缓冲区读写模式切换
                buf.flip();
                // 7、把数据从缓冲区写入通道
                sc.write(buf);

                // 清空缓冲区
                buf.clear();
            }
            // 8、关闭通道:关闭后服务端将获取不到该通道
            // sc.close();
        } catch (IOException e) {
            // e.printStackTrace();
        } finally {
            System.out.println("over!");
            try {
                // 关闭
                sc.close();
            } catch (IOException ioException) {
                ioException.printStackTrace();
            }
        }
    }
}

在这里插入图片描述

在这里插入图片描述

3)注意点

  • 先启动服务端,再启动客户端
  • 设置非阻塞模式、连接或绑定、创建缓冲区;这三步顺序可以调换
  • 客户端
    • 使用connect()连接,不是bind()绑定
    • 数据写入后不要关闭通道,否则服务端得不到客户端通道,也就接收不到数据
    • 关闭操作应在发生IO异常,或接收到服务端关闭通知后再进行
  • 服务端
    • 借用select(int timeout)方法,实现服务端的关闭操作;在 while() 轮论后关闭
    • isAcceptable() 分支中不要关闭客户端通道,否则后续读取操作无法进行
    • isReadable() 分支中也不要关闭客户端通道,否则将不能进行下次读取或接收
    • 一次轮询查询后,要清理选择键集合,为下次做准备;selectionKeys.clear();

4.4、NIO 编程步骤总结

服务端

  1. 创建 ServerSocketChannel 通道,并绑定监听端口
  2. 设置 Channel 通道是非阻塞模式
  3. 创建 Selector 选择器
  4. 把 Channel 注册到 Socketor 选择器上,监听就绪状态
  5. 调用 Selector 的 select 方法(循环调用),监测通道的就绪状况
  6. 调用 selectKeys 方法获取就绪 channel 集合
  7. 遍历就绪 channel 集合,判断就绪事件类型,实现具体的业务操作
  8. 根据业务,决定是否需要再次注册监听事件,重复执行第三步操作

5、Pipe和FileLock

  • Java NIO 管道是两个线程之间的单向数据连接
  • Pipe 有一个 source 通道和一个sink 通道
  • 数据会被写到 sink 通道,从 source 通道读取

在这里插入图片描述

5.1、Pipe

1)创建管道

// 1、获取管道
Pipe pipe = Pipe.open();

2)写入管道

// 2、获取sink通道,用来传送数据
Pipe.SinkChannel sink = pipe.sink();
// 3、创建发送缓冲区,并写入数据
ByteBuffer buf1 = ByteBuffer.allocate(1024);
buf1.put("Hello World!测试".getBytes());
// 4、读写模式反转
buf1.flip();
// 5、sink发送数据:把buf1中的数据写入通道
sink.write(buf1);

可以循环写入,类似文件复制

3)从管道读取数据

// 6、获取source通道
Pipe.SourceChannel source = pipe.source();
// 7、创建接收缓冲区
ByteBuffer buf2 = ByteBuffer.allocate(1024);
// 8、读取数据,并输出
// 把通道中的数据读入buf2
int length = source.read(buf2);
System.out.println(new String(buf2.array(),0,length));

可以循环读取,类似文件复制

4)示例

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.Pipe;

/**
 * @author 土味儿
 * Date 2021/12/3
 * @version 1.0
 */
public class pipeDemo {
    public static void main(String[] args) throws IOException {
        // 1、获取管道
        Pipe pipe = Pipe.open();
        // 2、获取sink通道,用来传送数据
        Pipe.SinkChannel sink = pipe.sink();
        // 3、创建发送缓冲区,并写入数据
        ByteBuffer buf1 = ByteBuffer.allocate(1024);
        buf1.put("Hello World!测试".getBytes());
        // 4、读写模式反转
        buf1.flip();
        // 5、sink发送数据:把buf1中的数据写入通道
        sink.write(buf1);

        // 6、获取source通道
        Pipe.SourceChannel source = pipe.source();
        // 7、创建接收缓冲区
        ByteBuffer buf2 = ByteBuffer.allocate(1024);
        // 8、读取数据,并输出
        // 把通道中的数据读入buf2
        int length = source.read(buf2);
        System.out.println(new String(buf2.array(),0,length));

        // 9、关闭
        source.close();
        sink.close();
    }
}

5.2、FileLock

1)FileLock简介

  • 文件锁在 OS 中很常见,如果多个程序同时访问、修改同一个文件,很容易因为文件数据不同步而出现问题。给文件加一个锁,同一时间,只能有一个程序修改此文件,或者程序都只能读此文件,这就解决了同步问题
  • 文件锁是进程级别的,不是线程级别的。文件锁可以解决多个进程并发访问、修改同一个文件的问题,但不能解决多线程并发访问、修改同一文件的问题。使用文件锁时,同一进程内的多个线程,可以同时访问、修改此文件
  • 文件锁是当前程序所属的 JVM 实例持有的,一旦获取到文件锁(对文件加锁),要调用 release(),或者关闭对应的 FileChannel 对象,或者当前 JVM 退出,才会释放这个锁
  • 一旦某个进程(比如说 JVM 实例)对某个文件加锁,则在释放这个锁之前,此进程不能再对此文件加锁,就是说 JVM 实例在同一文件上的文件锁是不重叠的(进程级别不能重复在同一文件上获取锁)

2)文件锁分类

  • 排它锁独占锁

    对文件加排它锁后,该进程可以对此文件进行读写,该进程独占此文件,其他进程不能读写此文件,直到该进程释放文件锁

  • 共享锁

    某个进程对文件加共享锁,其他进程也可以访问此文件,但这些进程都只能读此文件,不能写。线程是安全的。只要还有一个进程持有共享锁,此文件就只能读,不能写

3)使用示例

文件锁只能通过FileChannel对象来使用

// 创建FileChannel对象;文件锁只能通过FileChannel对象来使用
FileChannel fc = new FileOutputStream".\\1.txt").getChannel();

// 对文件加锁
FileLock lock = fc.lock();

// 对文件进行一些读写操作
// ...

// 释放锁
lock.release();

4)获取文件锁方法

  • lock()

    对整个文件加锁,默认为排它锁

  • lock(long position, long size, booean shared)

    自定义加锁方式

    前 2 个参数指定要加锁的部分(可以只对此文件的部分内容加锁)

    第 3 个参数值指定是否是共享锁

  • tryLock()

    对整个文件加锁,默认为排它锁

  • tryLock(long position, long size, booean shared)

    自定义加锁方式

    参数含义与lock一样

如果指定为共享锁,则其它进程可读此文件,所有进程均不能写此文件,如果某进程试图对此文件进行写操作,会抛出异常

5)lock 与 tryLock的区别

  • lock 是阻塞式的,如果未获取到文件锁,会一直阻塞当前线程,直到获取文件锁
  • tryLock 和 lock 的作用相同,只不过 tryLock 是非阻塞式的,tryLock 尝试获取文件锁,获取成功就返回锁对象,否则返回 null,不会阻塞当前线程

6)FileLock 两个方法

  • boolean isShared()

    判断此文件锁是否是共享锁

  • boolean isValid()

    判断此文件锁是否还有效

在某些 OS 上,对某个文件加锁后,不能对此文件使用通道映射

7)完整示例

import java.io.*;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.channels.FileLock;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;

/**
 * @author 土味儿
 * Date 2021/12/4
 * @version 1.0
 */
public class FileLockDemo {
    public static void main(String[] args) throws IOException {
        String input = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd hh:mm:ss")) + " 测试\n";
        System.out.println("输入:" + input);

        // 创建缓冲区,并存入数据
        ByteBuffer buf = ByteBuffer.wrap(input.getBytes());
        // 文件路径
        String filePathStr = ".\\src\\main\\resources\\05.txt";
        Path path = Paths.get(filePathStr);
        // 文件通道
        FileChannel fc = FileChannel.open(path, StandardOpenOption.APPEND);
        // position位置
        if(fc.size()!=0){
            fc.position(fc.size() - 1);
        }
        // 文件锁
        // 1、阻塞模式;默认为独占锁
        //FileLock lock = fc.lock();
        // 2、阻塞模式;共享锁;只能读,不能写;写时会抛异常
        //FileLock lock = fc.lock(0,Long.MAX_VALUE,true);
        // 3、非阻塞模式;默认为独占锁;获取不到锁时,返回null,不阻塞
        FileLock lock = fc.tryLock();
        // 4、非阻塞模式;共享锁;只能读,不能写;写时会抛异常
        //FileLock lock = fc.tryLock(0, Long.MAX_VALUE, true);

        System.out.println("是否为共享锁:" + lock.isShared());

        // 把buf中的数据写入文件通道
        fc.write(buf);
        // 关闭
        fc.close();
        System.out.println("写操作完成!");

        // 读取数据
        System.out.println("=========流读取==========");
        readFileByStream(filePathStr);
        System.out.println("=========通道读取==========");
        readFileByChannel(filePathStr);
    }

    /**
     * 流读取
     * @param filePathStr
     * @throws IOException
     */
    private static void readFileByStream(String filePathStr) throws IOException {
        FileReader fr = new FileReader(filePathStr);
        BufferedReader br = new BufferedReader(fr);
        String line = "";
        while((line = br.readLine()) !=null){
            System.out.println(line);
        }
        br.close();
    }

    /**
     * 通道读取
     * @param filePathStr
     * @throws IOException
     */
    private static void readFileByChannel(String filePathStr) throws IOException {
        FileInputStream fis = new FileInputStream(filePathStr);
        FileChannel fc = fis.getChannel();
        ByteBuffer buf = ByteBuffer.allocate(1024);
        int length = 0;
        while((length = fc.read(buf)) > 0 ){
            //buf.flip();
            System.out.println(new String(buf.array(),0,length));
            buf.clear();
        }
        fc.close();
    }
}

6、其它

6.1、Path

1)Path简介

在这里插入图片描述

  • Java Path 接口是 Java NIO 更新的一部分,同 Java NIO 一起已经包括在 Java6 和 Java7 中。Java Path 接口是在 Java7 中添加到 Java NIO 的。Path 接口位于java.nio.file 包中,所以 Path 接口的完全限定名称为java.nio.file.Path
  • Java Path 实例表示文件系统中的路径。一个路径可以指向一个文件或一个目录。路径可以是绝对路径,也可以是相对路径。绝对路径包含从文件系统的根目录到它指向的文件或目录的完整路径。相对路径包含相对于其他路径的文件或目录的路径
  • 在许多方面,java.nio.file.Path 接口类似于 java.io.File 类,但是有一些差别。不过,在许多情况下,可以使用 Path 接口来替换 File 类的使用

2)创建Path实例

使用 java.nio.file.Path 实例必须创建一个 Path 实例。可以使用 Paths 类 (java.nio.file.Paths) 中的静态方法 Paths.get() 来创建路径实例

Path path = Paths.get(".\\src\\main\\resources\\06.txt");

Paths.get() 方法相当于是 Path 实例的工厂方法

3)创建绝对路径

// windows系统
Path path1 = Paths.get("d:\\01.txt");

// Linux、MacOS系统
Path path2 = Paths.get("/home/jakobjenkov/myfile.txt");

在 Java 字符串中, \是一个转义字符,需要编写\,告诉 Java 编译器在字符串中写入一个\字符

如果在 Windows 机器上使用了从/开始的路径,那么路径将被解释为相对于当前驱动器

4)创建相对路径

使用 Paths.get(basePath,relativePath)方法创建一个相对路径

// 相对路径:d:\abc\xyz
Path path2 = Paths.get("d:\\abc", "xyz");
// 相对路径:d:\abc\xyz\03.txt
Path path3 = Paths.get("d:\\abc", "xyz\\03.txt");

5)Path.normalize()

Path 接口的 normalize() 方法可以使路径标准化

标准化意味着它将移除所有在路径字符串的中间的...代码,并解析路径字符串所引用的路径

Path path4 = Paths.get(".\\src\\main\\resources\\..\\07.txt");
System.out.println(path4);

Path path5 = path4.normalize();
System.out.println(path5);
.\src\main\resources\..\07.txt
src\main\07.txt

输出结果:标准化的路径不包含 resources\.. 部分

6.2、Files

Java NIO Files 类(java.nio.file.Files)提供了几种操作文件系统中的文件的方法。通常与java.nio.file.Path 实例一起工作

在这里插入图片描述

1)Files.createDirectory()

Files.createDirectory()方法,用于根据 Path 实例创建一个新目录

在这里插入图片描述

Path path = Paths.get(".\\src\\main\\resources\\abc");
try {
    Path newDir = Files.createDirectory(path);
} catch (FileAlreadyExistsException) {
    // 目录已存在
} catch (IOException e) {
    e.printStackTrace();
}

第一行创建表示要创建的目录的 Path 实例。在 try-catch 块中,用路径作为参数调用

Files.createDirectory()方法。如果创建目录成功,将返回一个 Path 实例,该实例指

向新创建的路径。

如果该目录已经存在,则是抛出一个 java.nio.file.FileAlreadyExistsException。如果

出现其他错误,可能会抛出 IOException。例如,如果想要的新目录的父目录不存

在,则可能会抛出 IOException

2)Files.copy()

复制文件

复制后原文件仍然存在

(1)不覆盖

从一个路径拷贝一个文件到另外一个目录

如果目标文件已经存在,则抛出一个 java.nio.file.FileAlreadyExistsException 异常。

如果有其他错误,则会抛出一个 IOException。例如,如果将该文件复制到不存在的

目录,则会抛出 IOException

        Path path1 = Paths.get(".\\src\\main\\resources\\1.wav");
        Path path2 = Paths.get(".\\src\\main\\resources\\abc\\1.wav");
        try {
            Path copy = Files.copy(path1, path2);
        } catch (FileAlreadyExistsException e) {
            // 目录已存在
        } catch (IOException e) {
            e.printStackTrace();
        }
(2)覆盖

如果目标文件已经存在,这个参数指示 copy() 方法覆

盖现有的文件

Path copy = Files.copy(path1, path2, StandardCopyOption.REPLACE_EXISTING);

3)Files.move()

移动文件 或者 重命名

move() 之后原文件不存在

Path path1 = Paths.get(".\\src\\main\\resources\\1.wav");
Path path2 = Paths.get(".\\src\\main\\resources\\11.wav");
try {
    Path move = Files.move(path1, path2);
} catch (FileAlreadyExistsException e) {
    // 目录已存在
} catch (IOException e) {
    e.printStackTrace();
}
Path move = Files.move(path1, path2, StandardCopyOption.REPLACE_EXISTING);

Files.move()的第三个参数。这个参数告诉 Files.move()方法来覆盖目标路径上的任何

现有文件

4)Files.delete()

删除一个文件或者目录

Path path = Paths.get(".\\src\\main\\resources\\02.txt");
try {
    Files.delete(path);
} catch (IOException e) {
    e.printStackTrace();
}

5)Files.walkFileTree()

  • Files.walkFileTree() 方法包含递归遍历目录树功能,将 Path 实例和 FileVisitor 作为参数。Path 实例指向要遍历的目录,FileVisitor 在遍历期间被调用

  • FileVisitor 是一个接口,必须自己实现 FileVisitor 接口,并将实现的实例传递给 walkFileTree() 方法。在目录遍历过程中,FileVisitor 实现的每个方法都将被调用。如果不需要实现所有这些方法,那么可以扩展 SimpleFileVisitor 类,它包含FileVisitor 接口中所有方法的默认实现(适配器模式)

  • FileVisitor 接口的方法中,每个都返回一个 FileVisitResult 枚举实例。

    FileVisitResult 枚举包含以下四个选项:

    • CONTINUE:继续
    • TERMINATE:终止
    • SKIP_SIBLING:跳过同级
    • SKIP_SUBTREE:跳过子级
/**
 * 遍历查找
 */
@Test
public void walkFileTree(){
    // 要查找的目录范围
    Path path = Paths.get(".\\src\\main\\resources");
    // 要查找的目标文件
    //String findFile = File.separator + "03.txt";
    String findFile = "03.txt";

    // 是否找到
    final boolean[] isExist = {false};

    try {
        Path path1 = Files.walkFileTree(path, new SimpleFileVisitor<Path>() {
            /**
             * 重载内部类方法
             * @param file
             * @param attrs
             * @return
             * @throws IOException
             */
            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                String fileString = file.toAbsolutePath().toString();

                // 如果目标文件和路径结尾相等,即为找到
                if (fileString.endsWith(findFile)) {
                    // 把查找标记为true
                    isExist[0] = true;
                    System.out.println("文件已找到!路径为:" + fileString);
                    // 中止退出
                    return FileVisitResult.TERMINATE;
                }
                // 继续下次查找
                return FileVisitResult.CONTINUE;
            }
        });
        if(!isExist[0]){
            System.out.println("没有找到!");
        }
    } catch (IOException e) {
        e.printStackTrace();
    }
}

6.3、AsynchronousFileChannel

1)创建AsynchronousFileChannel

通过静态方法 open()创建

// 1、得到文件路径
Path path = Paths.get(".\\src\\main\\resources\\03.txt");
// 2、创建AsynchronousFileChannel
AsynchronousFileChannel afc = null;
try {
    afc = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
} catch (IOException e) {
    e.printStackTrace();
}

AsynchronousFileChannel afc = AsynchronousFileChannel.open(path,StandardOpenOption.READ);

2)通过Future读取数据

在这里插入图片描述

把通道中的数据读到缓冲区,得到Future

Future future = afc.read(buf, 0);

@Test
public void futureRead() throws IOException {
    // 1、得到文件路径
    Path path = Paths.get(".\\src\\main\\resources\\03.txt");
    // 2、创建AsynchronousFileChannel
    AsynchronousFileChannel afc = null;
    try {
        afc = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
    } catch (IOException e) {
        e.printStackTrace();
    }
    // 3、创建缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);

    // 4、把通道中的数据读到缓冲区,得到Future
    Future<Integer> future = afc.read(buf, 0);

    // 5、判断是否读取完成
    while (!future.isDone()) {
    }

    // 6、输出
    buf.flip();
    System.out.println(new String(buf.array(), 0, buf.limit()));
    buf.clear();

    // 7、关闭
    afc.close();
}
  • 创建了一个 AsynchronousFileChannel
  • 创建一个 ByteBuffer,它被传递给 read() 方法作为参数,以及一个 0 的位置
  • 在调用 read() 之后,循环,直到返回的 isDone()方法返回 true
  • 读取操作完成后,数据读取到 ByteBuffer 中,然后打印到 System.out 中

3)通过CompletionHandler读取数据

在这里插入图片描述

@Test
public void completionHandlerRead() {
    // 1、得到文件路径
    Path path = Paths.get(".\\src\\main\\resources\\03.txt");
    // 2、创建AsynchronousFileChannel
    AsynchronousFileChannel afc = null;
    try {
        afc = AsynchronousFileChannel.open(path, StandardOpenOption.READ);
    } catch (IOException e) {
        e.printStackTrace();
    }
    // 3、创建缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);
    // 4、把通道中的数据读到缓冲区
    afc.read(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
        /**
             * 读取完成
             * @param result
             * @param attachment
             */
        @Override
        public void completed(Integer result, ByteBuffer attachment) {
            System.out.println("读取到:" + result);

            attachment.flip();
            System.out.println(new String(attachment.array(),0,attachment.limit()));
            attachment.clear();
        }

        /**
             * 读取失败
             * @param exc
             * @param attachment
             */
        @Override
        public void failed(Throwable exc, ByteBuffer attachment) {
            System.out.println("读取失败!");
        }
    });
}
  • 读取操作完成,将调用 CompletionHandler 的 completed() 方法
  • 对于 completed() 方法的参数传递一个整数,它告诉我们读取了多少字节,以及传递给 read() 方法的“附件”。“附件”是 read()方法的第三个参数。在本代码中,它是 ByteBuffer,数据也被读取
  • 如果读取操作失败,则将调用 CompletionHandler 的 failed() 方法

4)通过Future写数据

在这里插入图片描述

 @Test
public void futureWrite() throws IOException {
    // 1、得到文件路径
    Path path = Paths.get(".\\src\\main\\resources\\04.txt");
    // 2、创建AsynchronousFileChannel
    AsynchronousFileChannel afc = null;
    try {
        afc = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE);
    } catch (IOException e) {
        e.printStackTrace();
    }
    // 3、创建缓冲区
    //        ByteBuffer buf = ByteBuffer.allocate(1024);
    //        buf.put("测试abc".getBytes());
    //        buf.flip();
    ByteBuffer buf = ByteBuffer.wrap("测试xyz".getBytes());

    // 4、把缓冲区中的数据写入通道,得到Future
    Future<Integer> future = afc.write(buf, 0);

    // 5、判断是否写入完成
    while (!future.isDone()) {
    }

    // 6、输出
    System.out.println("写入完成!");

    // 7、关闭
    afc.close();
}
  • AsynchronousFileChannel 以写模式打开
  • 创建一个 ByteBuffer,并将一些数据写入其中
  • ByteBuffer 中的数据被写入到文件中
  • 检查返回的 Future,以查看写操作完成时的情况

注意,文件必须已经存在。如果该文件不存在,那么 write()方法将抛出一个

java.nio.file.NoSuchFileException

5)通过CompletionHandler写数据

在这里插入图片描述

@Test
public void completionHandlerWrite() {
    // 1、得到文件路径
    Path path = Paths.get(".\\src\\main\\resources\\04.txt");
    // 2、创建AsynchronousFileChannel
    AsynchronousFileChannel afc = null;
    try {
        afc = AsynchronousFileChannel.open(path, StandardOpenOption.WRITE);
    } catch (IOException e) {
        e.printStackTrace();
    }
    // 3、创建缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);
    buf.put("xyz测试".getBytes());
    buf.flip();
    // 4、把缓冲区中的数据写入通道
    afc.write(buf, 0, buf, new CompletionHandler<Integer, ByteBuffer>() {
        /**
             * 写入完成
             * @param result
             * @param attachment
             */
        @Override
        public void completed(Integer result, ByteBuffer attachment) {
            System.out.println("写入:" + result);
        }

        /**
             * 写入失败
             * @param exc
             * @param attachment
             */
        @Override
        public void failed(Throwable exc, ByteBuffer attachment) {
            System.out.println("写入失败!");
        }
    });
}
  • 写操作完成时,将会调用 CompletionHandler 的 completed()方法
  • 如果写失败,则会调用 failed()方法

6.4、字符集(Charset)

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

public class CharsetDemo {
    public static void main(String[] args) throws CharacterCodingException {
        // 1、获取Charset对象
        Charset charset = Charset.forName("utf8");

        // 2、建立缓冲区,准备数据
        CharBuffer buf = CharBuffer.allocate(1024);
        buf.put("测试abc");
        buf.flip();

        // 3、获取新的编码器
        //CharsetEncoder charsetEncoder = charset.newEncoder();

        // 4、编码
        //ByteBuffer buf1 = charsetEncoder.encode(buf);
        // 使用默认编码器
        ByteBuffer buf1 = charset.encode(buf);
        System.out.println("编码后:");
        for (int i = 0; i < buf1.limit(); i++) {
            System.out.println(buf1.get());
        }

        // 5、获取新的解码器
        //CharsetDecoder charsetDecoder = charset.newDecoder();

        // 6、解码
        buf1.flip();
        //CharBuffer buf2 = charsetDecoder.decode(buf1);
        // 使用默认解码器
        CharBuffer buf2 = charset.decode(buf1);
        System.out.println("解码后:");
        System.out.println(buf2);

        // 7、用其它字符格式解码
        Charset gbkChar = Charset.forName("gbk");
        buf1.flip();
        System.out.println(gbkChar.decode(buf1));

        // 8、获取Charset所支持的字符编码
        Map<String, Charset> scssm = Charset.availableCharsets();
        Set<Map.Entry<String, Charset>> entries = scssm.entrySet();
        for (Map.Entry<String, Charset> entry : entries) {
            System.out.println(entry + " : " + entry.getValue());
        }
    }
}

在这里插入图片描述

7、多人聊天室

在这里插入图片描述

在这里插入图片描述

1)服务端

超过 timeout 毫秒没有连接,关闭服务端

package chat.server;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.*;
import java.nio.charset.Charset;
import java.util.Set;

/**
 * @author 土味儿
 * Date 2021/12/6
 * @version 1.0
 * 聊天室服务端
 */
public class ChatServer {
    /**
     * 服务端入口
     * @param args
     */
    public static void main(String[] args) {
        startServer();
    }
    /**
     * 启动方法
     */
    private static void startServer(){
        // 服务端通道
        ServerSocketChannel ssc = null;
        try {
            // 1、创建选择器
            Selector selector = Selector.open();
            // 2、创建服务端通道
            ssc = ServerSocketChannel.open();

            // 3、绑定端口、设置为非阻塞模式
            ssc.bind(new InetSocketAddress(12345));
            ssc.configureBlocking(false);

            // 4、把通道注册到选择器
            ssc.register(selector, SelectionKey.OP_ACCEPT);
            System.out.println("服务器已启动成功!");

            // 5、循环查询就绪状态:10秒内仍没有连接就退出
            int timeout = 100000;
            //while(selector.select(timeout) > 0){
            while(selector.select() > 0){
                // 6、得到选择键集合,并遍历
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                for (SelectionKey sk : selectionKeys) {
                    // 6.1、可接收状态:表示服务端已做好准备,可以接收客户的连接了
                    if(sk.isAcceptable()){
                        // 处理可接收时的操作
                        acceptOperator(ssc,selector);
                    }
                    // 6.2、可读状态:表示客户端已发送完毕,可以在服务端读取数据了
                    if(sk.isReadable()){
                        // 处理可读时的操作
                        readOperator(selector,sk);
                    }
                }
                // 清理选择键集合:为下次轮询查询做准备
                selectionKeys.clear();
            }


        } catch (IOException e) {
            e.printStackTrace();
        } finally {
            // 关闭通道
            try {
                if(ssc != null) {
                    ssc.close();
                }
                System.out.println("服务端已关闭!");
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 可接收状态时的处理操作
     * @param ssc 服务端通道
     * @param selector 选择器
     * @throws IOException
     */
    private static void acceptOperator(ServerSocketChannel ssc,Selector selector) throws IOException {
        // 1、获取客户端通道 SocketChannel
        SocketChannel sc = ssc.accept();

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

        // 3、把通道注册到选择器上,监听可读状态
        sc.register(selector,SelectionKey.OP_READ);

        // 4、回复客户端
        ByteBuffer replyStr = Charset.forName("utf8").encode("欢迎进入聊天室!");
        sc.write(replyStr);
    }

    /**
     * 可读状态时的处理操作
     * @param selector 选择器
     * @param sk 选择键
     * @throws IOException
     */
    private static void readOperator(Selector selector,SelectionKey sk) throws IOException {
        // 1、从选择键SelectionKey获取已经就绪的客户端通道
        SocketChannel sc = (SocketChannel) sk.channel();

        // 2、创建Buffer
        ByteBuffer buf = ByteBuffer.allocate(1024);

        // 3、循环读取客户端消息
        //int readLength = 0;
        String message = "";
        //while((readLength = sc.read(buf)) > 0){
        while(sc.read(buf) > 0){
            // -----读取方法1-----
            // 切换buf读写模式
            // 调用 flip()方法会将 position 设回 0,并将 limit 设置成之前 position 的值:即readLength
            buf.flip();
            message += Charset.forName("utf8").decode(buf);

            // -----读取方法2-----
            // 缓冲区切片:只读从0到readLength的数据
            //buf.position(0);
            //buf.limit(readLength);
            //message += Charset.forName("utf8").decode(buf.slice());
        }

        // 4、把通道再次注册到选择器,监听可读状态
        sc.register(selector,SelectionKey.OP_READ);

        // 5、把客户端消息广播到其它客户端
        if(message.length() > 0){
            System.out.println(message);
            castOtherClient(message,selector,sc);
        }
    }

    /**
     * 给其它客户端广播消息
     * @param message 消息
     * @param selector 选择器
     * @param sc 自已的通道
     * @throws IOException
     */
    private static void castOtherClient(String message,Selector selector,SocketChannel sc) throws IOException {
        // 1、获取所有已经接入的通道的选择键
        Set<SelectionKey> keys = selector.keys();

        // 2、循环遍历:找出除了自已之外的其它客户端通道,并发送消息
        for (SelectionKey key : keys) {
            //System.out.println(key);
            // 获取当前选择键的通道
            Channel targetChannel = key.channel();
            // 向除了自已之外的其它客户端通道发送消息
            if(targetChannel instanceof SocketChannel &&
                    targetChannel != sc){
                // 发送消息
                ((SocketChannel)targetChannel).write(
                        Charset.forName("utf8").encode(message)
                );
            }
        }
    }
}

2)客户端

  • ChatClient.java

Scanner 会阻塞等待

package chat.client;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.Scanner;

/**
 * @author 土味儿
 * Date 2021/12/6
 * @version 1.0
 * 聊天室客户端
 */
public class ChatClient {
    /**
     * 客户端入口
     *
     * @param args
     */
    public static void main(String[] args) {
        //startClient();
    }

    /**
     * 启动方法
     */
    public void startClient(String name) {
        System.out.print(name + ",你好,");
        SocketChannel sc = null;
        try {
            // 1、创建选择器
            Selector selector = Selector.open();
            // 2、创建客户端通道,连接服务端
            sc = SocketChannel.open(new InetSocketAddress("127.0.0.1", 12345));
            // 3、设置为非阻塞模式
            sc.configureBlocking(false);
            // 4、把通道注册到选择器
            sc.register(selector, SelectionKey.OP_READ);
            // 5、创建新线程,接收消息
            new Thread(new ClientThread(selector)).start();

            // 6、向服务端发送消息
            Scanner scanner = new Scanner(System.in);
            String msg = "";
            while (scanner.hasNextLine()) {
                msg = scanner.nextLine();
                if (msg.length() > 0) {
                    sc.write(Charset.forName("utf8").encode(name + ":" + msg));
                }
            }
        } catch (IOException e) {
            //e.printStackTrace();
        } finally {
            // 关闭
            try {
                if (sc != null) {
                    sc.close();
                }
                System.out.println("客户端已关闭!");
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
  • ClientThread.java
package chat.client;

import java.io.IOException;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.Set;

/**
 * @author 土味儿
 * Date 2021/12/6
 * @version 1.0
 */
public class ClientThread implements Runnable{
    /**
     * 选择器
     */
    private Selector selector;

    /**
     * 构造器
     * @param selector
     */
    public ClientThread(Selector selector){
        this.selector = selector;
    }
    @Override
    public void run() {
        int timeout = 10000;
        try{
            //while(selector.select(timeout) > 0){
            while(selector.select() > 0){
                // 得到选择键集合,并遍历
                Set<SelectionKey> selectionKeys = selector.selectedKeys();
                for (SelectionKey sk : selectionKeys) {
                    // 可读状态:表示可以读取服务器端发送的数据了
                    if(sk.isReadable()){
                        // 处理可读时的操作
                        readOperator(selector,sk);
                    }
                }
                // 清理选择键集合:为下次轮询查询做准备
                selectionKeys.clear();
            }
        }catch (IOException e){
            e.printStackTrace();
        }
    }

    /**
     * 可读状态时的处理操作
     * @param selector 选择器
     * @param sk 选择键
     * @throws IOException
     */
    private void readOperator(Selector selector,SelectionKey sk) throws IOException {
        // 1、从选择键SelectionKey获取已经就绪的客户端通道
        SocketChannel sc = (SocketChannel) sk.channel();

        // 2、创建Buffer
        ByteBuffer buf = ByteBuffer.allocate(1024);

        // 3、循环读取客户端消息
        String message = "";
        while(sc.read(buf) > 0){
            // -----读取方法1-----
            // 切换buf读写模式
            // 调用 flip()方法会将 position 设回 0,并将 limit 设置成之前 position 的值:即readLength
            buf.flip();
            message += Charset.forName("utf8").decode(buf);
      }

        // 4、把通道再次注册到选择器,监听可读状态
        sc.register(selector,SelectionKey.OP_READ);

        // 5、输出消息
        if(message.length() > 0){
            System.out.println(message);
        }
    }
}
  • 模拟聊天用户

每个用户是独立的;startClient() 不能用静态方法,否则将共用客户端通道

package chat.client;

/**
 * @author 土味儿
 * Date 2021/12/6
 * @version 1.0
 */
public class Client1 {
    public static void main(String[] args) {
        new ChatClient().startClient("Tom");
    }
}
public class Client2 {
    public static void main(String[] args) {
        new ChatClient().startClient("Lucy");
    }
}
public class Client3 {
    public static void main(String[] args) {
        new ChatClient().startClient("Mary");
    }
}
  • 3
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
以下是一个使用 Java NIO(New I/O)进行网络编程的简单示例: ```java import java.io.IOException; import java.net.InetSocketAddress; import java.nio.ByteBuffer; import java.nio.channels.ServerSocketChannel; import java.nio.channels.SocketChannel; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; public class NIOExample { public static void main(String[] args) throws IOException { // 创建一个线程池用于处理客户端连接 ExecutorService executor = Executors.newFixedThreadPool(10); // 创建 ServerSocketChannel 并绑定端口 ServerSocketChannel serverSocketChannel = ServerSocketChannel.open(); serverSocketChannel.bind(new InetSocketAddress("localhost", 8080)); System.out.println("Server started on port 8080"); while (true) { // 接受客户端连接 SocketChannel socketChannel = serverSocketChannel.accept(); // 使用线程池处理客户端连接 executor.execute(() -> handleClient(socketChannel)); } } private static void handleClient(SocketChannel socketChannel) { try { ByteBuffer buffer = ByteBuffer.allocate(1024); // 读取客户端发送的数据 int bytesRead = socketChannel.read(buffer); while (bytesRead != -1) { buffer.flip(); while (buffer.hasRemaining()) { System.out.print((char) buffer.get()); } buffer.clear(); bytesRead = socketChannel.read(buffer); } // 响应客户端 String response = "Hello from server"; ByteBuffer responseBuffer = ByteBuffer.wrap(response.getBytes()); socketChannel.write(responseBuffer); // 关闭连接 socketChannel.close(); } catch (IOException e) { e.printStackTrace(); } } } ``` 这个示例创建了一个简单的服务器,监听本地的 8080 端口。当客户端连接时,会使用线程池处理连接,并读取客户端发送的数据。然后,服务器会向客户端发送 "Hello from server" 的响应,并关闭连接。 请注意,这只是一个简单的示例,实际的网络编程可能涉及更复杂的逻辑和处理。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

土味儿~

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值