为什么一个还没毕业的大学生能够把 IO 讲的这么好?(三)

简介: Java IO 是一个庞大的知识体系,很多人学着学着就会学懵了,包括我在内也是如此,所以本文将会从 Java 的 BIO 开始,一步一步深入学习,引出 JDK1.4 之后出现的 NIO 技术,对比 NIO 与 BIO 的区别,然后对 NIO 中重要的三个组成部分进行讲解(缓冲区、通道、选择器),最后实现一个简易的客户端与服务器通信功能。

我们来看一个简单的例子

public Class Main {
    public static void main(String[] args) {
         // 分配内存大小为11的整型缓存区
        IntBuffer buffer = IntBuffer.allocate(11);
        // 往buffer里写入2个整型数据
        for (int i = 0; i < 2; ++i) {
            int randomNum = new SecureRandom().nextInt();
            buffer.put(randomNum);
        }
        // 将Buffer从写模式切换到读模式
        buffer.flip();
        System.out.println("position >> " + buffer.position()
                           + "limit >> " + buffer.limit() 
                           + "capacity >> " + buffer.capacity());
        // 读取buffer里的数据
        while (buffer.hasRemaining()) {
            System.out.println(buffer.get());
        }
        System.out.println("position >> " + buffer.position()
                           + "limit >> " + buffer.limit() 
                           + "capacity >> " + buffer.capacity());
    }
}

执行结果如下图所示,首先我们往缓冲区中写入 2 个数据,position 在写模式下指向下标 2,然后调用 flip() 方法切换为读模式,limit 指向下标 2,position 从 0 开始读数据,读到下标为 2 时发现到达 limit 位置,不可继续读。

25.png

整个过程可以用下图来理解,调用 flip() 方法以后,读出数据的同时 position 指针不断往后挪动,到达 limit 指针的位置时,该次读取操作结束。

26.png

介绍完缓冲区后,我们知道它是存储数据的空间,进程可以将缓冲区中的数据读取出来,也可以写入新的数据到缓冲区,那缓冲区的数据从哪里来,又怎么写出去呢?接下来我们需要学习传输数据的介质:通道(Channel)


通道(Channel)


上面我们介绍过,通道是作为一种连接资源,作用是传输数据,而真正存储数据的是缓冲区,所以介绍完缓冲区后,我们来学习通道这一块。

通道是可以双向读写的,传统的 BIO 需要使用输入/输出流表示数据的流向,在 NIO 中可以减少通道资源的消耗。

27.png

image.gif

通道类都保存在 java.nio.channels 包下,我们日常用到的几个重要的类有 4 个:

IO 通道类型 具体类
文件 IO FileChannel(用于文件读写、操作文件的通道)
TCP 网络 IO SocketChannel(用于读写数据的 TCP 通道)、ServerSocketChannel(监听客户端的连接)
UDP 网络 IO DatagramChannel(收发 UDP 数据报的通道)

可以通过 getChannel() 方法获取一个通道,支持获取通道的类如下:

  • 文件 IO:FileInputStream、FileOutputStream、RandomAccessFile
  • TCP 网络 IO:Socket、ServerSocket
  • UDP 网络 IO:DatagramSocket


示例:文件拷贝案例


我们来看一个利用通道拷贝文件的例子,需要下面几个步骤:

  • 打开原文件的输入流通道,将字节数据读入到缓冲区中
  • 打开目的文件的输出流通道,将缓冲区中的数据写到目的地
  • 关闭所有流和通道(重要!)

这是一张小菠萝的照片,它存在于d:\小菠萝\文件夹下,我们将它拷贝到 d:\小菠萝分身\ 文件夹下。

28.png

public class Test {
 /** 缓冲区的大小 */
    public static final int SIZE = 1024;
    public static void main(String[] args) throws IOException {
        // 打开文件输入流
        FileChannel inChannel = new FileInputStream("d:\小菠萝\小菠萝.jpg").getChannel();
        // 打开文件输出流
        FileChannel outChannel = new FileOutputStream("d:\小菠萝分身\小菠萝-拷贝.jpg").getChannel();
        // 分配 1024 个字节大小的缓冲区
        ByteBuffer dsts = ByteBuffer.allocate(SIZE);
        // 将数据从通道读入缓冲区
        while (inChannel.read(dsts) != -1) {
            // 切换缓冲区的读写模式
            dsts.flip();
            // 将缓冲区的数据通过通道写到目的地
            outChannel.write(dsts);
            // 清空缓冲区,准备下一次读
            dsts.clear();
        }
        inChannel.close();
        outChannel.close();
    }
}

我画了一张图帮助你理解上面的这一个过程。

29.png

image.gif

有人会问,NIO 的文件拷贝和传统 IO 流的文件拷贝有何不同呢?我们在编程时感觉它们没有什么区别呀,貌似只是 API 不同罢了,我们接下来就去看看这两者之间的区别吧。


BIO 和 NIO 拷贝文件的区别


这个时候就要来了解了解操作系统底层是怎么对 IO 和 NIO 进行区别的,我会用尽量通俗的文字带你理解,可能并不是那么严谨。

操作系统最重要的就是内核,它既可以访问受保护的内存,也可以访问底层硬件设备,所以为了保护内核的安全,操作系统将底层的虚拟空间分为了用户空间内核空间,其中用户空间就是给用户进程使用的,内核空间就是专门给操作系统底层去使用的。

30.png

接下来,有一个 Java 进程希望把小菠萝这张图片从磁盘上拷贝,那么内核空间和用户空间都会有一个缓冲区

  • 这张照片就会从磁盘中读出到内核缓冲区中保存,然后操作系统将内核缓冲区中的这张图片字节数据拷贝到用户进程的缓冲区中保存下来,对应着下面这幅图

31.png

  • 然后用户进程会希望把缓冲区中的字节数据写到磁盘上的另外一个地方,会将数据拷贝到 Socket 缓冲区中,最终操作系统再将 Socket 缓冲区的数据写到磁盘的指定位置上。

32.png

image.gif

这一轮操作下来,我们数数经过了几次数据的拷贝?4 次。有 2 次是内核空间和用户空间之间的数据拷贝,这两次拷贝涉及到用户态和内核态的切换,需要CPU参与进来,进行上下文切换。而另外 2 次是硬盘和内核空间之间的数据拷贝,这个过程利用到 DMA与系统内存交换数据,不需要 CPU 的参与。

导致 IO 性能瓶颈的原因:内核空间与用户空间之间数据过多无意义的拷贝,以及多次上下文切换

操作 状态
用户进程请求读取数据 用户态 -> 内核态
操作系统内核返回数据给用户进程 内核态 -> 用户态
用户进程请求写数据到硬盘 用户态 -> 内核态
操作系统返回操作结果给用户进程 内核态 -> 用户态

在用户空间与内核空间之间的操作,会涉及到上下文的切换,这里需要 CPU 的干预,而数据在两个空间之间来回拷贝,也需要 CPU 的干预,这无疑会增大 CPU 的压力,NIO 是如何减轻 CPU 的压力?运用操作系统的零拷贝技术。


操作系统的零拷贝


所以,操作系统出现了一个全新的概念,解决了 IO 瓶颈:零拷贝。零拷贝指的是内核空间与用户空间之间的零次拷贝

零拷贝可以说是 IO 的一大救星,操作系统底层有许多种零拷贝机制,我这里仅针对 Java NIO 中使用到的其中一种零拷贝机制展开讲解。

在 Java NIO 中,零拷贝是通过用户空间和内核空间的缓冲区共享一块物理内存实现的,也就是说上面的图可以演变成这个样子。

33.png

image.gif这时,无论是用户空间还是内核空间操作自己的缓冲区,本质上都是操作这一块共享内存中的缓冲区数据,省去了用户空间和内核空间之间的数据拷贝操作

现在我们重新来拷贝文件,就会变成下面这个步骤:

  • 用户进程通过系统调用 read() 请求读取文件到用户空间缓冲区(第一次上下文切换),用户态 -> 核心态,数据从硬盘读取到内核空间缓冲区中(第一次数据拷贝
  • 系统调用返回到用户进程(第二次上下文切换),此时用户空间与内核空间共享这一块内存(缓冲区),所以不需要从内核缓冲区拷贝到用户缓冲区
  • 用户进程发出 write() 系统调用请求写数据到硬盘上(第三次上下文切换),此时需要将内核空间缓冲区中的数据拷贝到内核的 Socket 缓冲区中(第二次数据拷贝
  • 由 DMA 将 Socket 缓冲区的内容写到硬盘上(第三次数据拷贝),write() 系统调用返回(第四次上下文切换

整个过程就如下面这幅图所示。

34.png

image.gif

图中,需要 CPU 参与工作的步骤只有第③个步骤,对比于传统的 IO,CPU 需要在用户空间与内核空间之间参与拷贝工作,需要无意义地占用 2 次 CPU 资源,导致 CPU 资源的浪费。

下面总结一下操作系统中零拷贝的优点:

  • 降低 CPU 的压力:避免 CPU 需要参与内核空间与用户空间之间的数据拷贝工作
  • 减少不必要的拷贝:避免用户空间与内核空间之间需要进行数据拷贝

上面的图示可能并不严谨,对于你理解零拷贝会有一定的帮助,关于零拷贝的知识点可以去查阅更多资料哦,这是一门大学问。

介绍完通道后,我们知道它是用于传输数据的一种介质,而且是可以双向读写的,那么如果放在网络 IO 中,这些通道如果有数据就绪时,服务器是如何发现并处理的呢?接下来我们去学习 NIO 中的最后一个重要知识点:选择器(Selector)


选择器(Selectors)


选择器是提升 IO 性能的灵魂之一,它底层利用了多路复用 IO机制,让选择器可以监听多个 IO 连接,根据 IO 的状态响应到服务器端进行处理。通俗地说:选择器可以监听多个 IO 连接,而传统的 BIO 每个 IO 连接都需要有一个线程去监听和处理。

35.png


图中很明显的显示了在 BIO 中,每个 Socket 都需要有一个专门的线程去处理每个请求,而在 NIO 中,只需要一个 Selector 即可监听各个 Socket 请求,而且 Selector 并不是阻塞的,所以不会因为多个线程之间切换导致上下文切换带来的开销


36.png


image.gif

在 Java NIO 中,选择器是使用 Selector 类表示,Selector 可以接收各种 IO 连接,在 IO 状态准备就绪时,会通知该通道注册的 Selector,Selector 在下一次轮询时会发现该 IO 连接就绪,进而处理该连接。

Selector 选择器主要用于网络 IO当中,在这里我会将传统的 BIO Socket 编程和使用 NIO 后的 Socket 编程作对比,分析 NIO 为何更受欢迎。首先先来了解 Selector 的基本结构。

重要方法 方法解析
open() 打开一个 Selector 选择器
int select() 阻塞地等待就绪的通道
int select(long timeout) 最多阻塞 timeout 毫秒,如果是 0 则一直阻塞等待,如果是 1 则代表最多阻塞 1 毫秒
int selectNow() 非阻塞地轮询就绪的通道

在这里,你会看到 select() 和它的重载方法是会阻塞的,如果用户进程轮询时发现没有就绪的通道,操作系统有两种做法:

  • 一直等待直到一个就绪的通道,再返回给用户进程
  • 立即返回一个错误状态码给用户进程,让用户进程继续运行,不会阻塞

这两种方法对应了同步阻塞 IO同步非阻塞 IO ,这里读者的一点小的观点,请各位大神批判阅读

Java 中的 NIO 不能真正意义上称为 Non-Blocking IO,我们通过 API 的调用可以发现,select() 方法还是会存在阻塞的现象,根据传入的参数不同,操作系统的行为也会有所不同,不同之处就是阻塞还是非阻塞,所以我更倾向于把 NIO 称为 New IO,因为它不仅提供了 Non-Blocking IO,而且保留原有的 Blocking IO 的功能。

了解了选择器之后,它的作用就是:监听多个 IO 通道,当有通道就绪时选择器会轮询发现该通道,并做相应的处理。那么 IO 状态分为很多种,我们如何去识别就绪的通道是处于哪种状态呢?在 Java 中提供了选择键(SelectionKey)


选择键(SelectionKey)

在 Java 中提供了 4 种选择键:

  • SelectionKey.OP_READ:套接字通道准备好进行读操作
  • SelectionKey.OP_WRITE:套接字通道准备好进行写操作
  • SelectionKey.OP_ACCEPT:服务器套接字通道接受其它通道
  • SelectionKey.OP_CONNECT:套接字通道准备完成连接

在 SelectionKey 中包含了许多属性

  • channel:该选择键绑定的通道
  • selector:轮询到该选择键的选择器
  • readyOps:当前就绪选择键的值
  • interesOps:该选择器对该通道感兴趣的所有选择键

选择键的作用是:在选择器轮询到有就绪通道时,会返回这些通道的就绪选择键(SelectionKey),通过选择键可以获取到通道进行操作。

简单了解了选择器后,我们可以结合缓冲区、通道和选择器来完成一个简易的聊天室应用。


示例:简易的客户端服务器通信

先说明,这里的代码非常的臭和长,不推荐细看,直接看注释附近的代码即可。

我们在服务器端会开辟两个线程

  • Thread1:专门监听客户端的连接,并把通道注册到客户端选择器上
  • Thread2:专门监听客户端的其它 IO 状态(读状态),当客户端的 IO 状态就绪时,该选择器会轮询发现,并作相应处理
public class NIOServer {
    Selector serverSelector = Selector.open();
    Selector clientSelector = Selector.open();
    public static void main(String[] args) throws IOException {
        NIOServer server = nwe NIOServer();
        new Thread(() -> {
            try {
                // 对应IO编程中服务端启动
                ServerSocketChannel listenerChannel = ServerSocketChannel.open();
                listenerChannel.socket().bind(new InetSocketAddress(3333));
                listenerChannel.configureBlocking(false);
                listenerChannel.register(serverSelector, SelectionKey.OP_ACCEPT);
    server.acceptListener();
            } catch (IOException ignored) {
            }
        }).start();
        new Thread(() -> {
            try {
                server.clientListener();
            } catch (IOException ignored) {
            }
        }).start();
    }
}
// 监听客户端连接
public void acceptListener() {
    while (true) {
        if (serverSelector.select(1) > 0) {
            Set<SelectionKey> set = serverSelector.selectedKeys();
            Iterator<SelectionKey> keyIterator = set.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
                if (key.isAcceptable()) {
                    try {
                        // (1) 每来一个新连接,注册到clientSelector
                        SocketChannel clientChannel = ((ServerSocketChannel) key.channel()).accept();
                        clientChannel.configureBlocking(false);
                        clientChannel.register(clientSelector, SelectionKey.OP_READ);
                    } finally {
                        // 从就绪的列表中移除这个key
                        keyIterator.remove();
                    }
                }
            }
        }
    }
}
// 监听客户端的 IO 状态就绪
public void clientListener() {
    while (true) {
        // 批量轮询是否有哪些连接有数据可读
        if (clientSelector.select(1) > 0) {
            Set<SelectionKey> set = clientSelector.selectedKeys();
            Iterator<SelectionKey> keyIterator = set.iterator();
            while (keyIterator.hasNext()) {
                SelectionKey key = keyIterator.next();
    // 判断该通道是否读就绪状态
                if (key.isReadable()) {
                    try {
                        // 获取客户端通道读入数据
                        SocketChannel clientChannel = (SocketChannel) key.channel();
                        ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
                        clientChannel.read(byteBuffer);
                        byteBuffer.flip();
                        System.out.println(
                            LocalDateTime.now().toString() + " Server 端接收到来自 Client 端的消息: " +
                            Charset.defaultCharset().decode(byteBuffer).toString());
                    } finally {
                        // 从就绪的列表中移除这个key
                        keyIterator.remove();
                        key.interestOps(SelectionKey.OP_READ);
                    }
                }
            }
        }
    }
}

在客户端,我们可以简单的输入一些文字,发送给服务器

public class NIOClient {
    public static final int CAPACITY = 1024;
    public static void main(String[] args) throws Exception {
        ByteBuffer dsts = ByteBuffer.allocate(CAPACITY);
        SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1", 3333));
        socketChannel.configureBlocking(false);
        Scanner sc = new Scanner(System.in);
        while (true) {
            String msg = sc.next();
            dsts.put(msg.getBytes());
            dsts.flip();
            socketChannel.write(dsts);
            dsts.clear();
        }
    }
}

下图可以看见,在客户端给服务器端发送信息,服务器接收到消息后,可以将该条消息分发给其它客户端,就可以实现一个简单的群聊系统,我们还可以给这些客户端贴上标签例如用户姓名,聊天等级······,就可以标识每个客户端啦。在这里由于篇幅原因,我没有写出所有功能,因为使用原生的 NIO 实在是不太便捷。

37.png

我相信你们都是直接滑下来看这里的,我在写这段代码的时候也非常痛苦,甚至有点厌烦 Java 原生的 NIO 编程。实际上我们在日常开发中很少直接用 NIO 进行编程,通常都会用 Netty,Mina 这种服务器框架,它们都是很好地 NIO 技术,对 Java 原生的 NIO 进行了上层的封装、优化,简化开发难度,但是在学习框架之前,我们需要了解它底层原生的技术,就像 Spring AOP 的动态代理,Spring IOC 容器的 Map 容器存储对象,Netty 底层的 NIO 基础······

总结

NIO 的三大板块基本上都介绍完了,我没有做过多详细的 API 介绍,我希望能够通过这篇文章让你们对以下内容有所认知

  • Java IO 体系的组成部分:BIO 和 NIO
  • BIO 的基本组成部分:字节流,字符流,转换流和处理流
  • NIO 的三大重要模块:缓冲区(Buffer),通道(Channel),选择器(Selector)以及它们的作用
  • NIO 与 BIO 两者的对比:同步/非同步、阻塞/非阻塞,在文件 IO 和 网络 IO 中,使用 NIO 相对于使用 BIO 有什么优势


相关文章
|
存储 缓存 Java
为什么一个还没毕业的大学生能够把 IO 讲的这么好?(二)
Java IO 是一个庞大的知识体系,很多人学着学着就会学懵了,包括我在内也是如此,所以本文将会从 Java 的 BIO 开始,一步一步深入学习,引出 JDK1.4 之后出现的 NIO 技术,对比 NIO 与 BIO 的区别,然后对 NIO 中重要的三个组成部分进行讲解(缓冲区、通道、选择器),最后实现一个简易的客户端与服务器通信功能。
为什么一个还没毕业的大学生能够把 IO 讲的这么好?(二)
|
存储 NoSQL Java
为什么一个还没毕业的大学生能够把 IO 讲的这么好?(一)
Java IO 是一个庞大的知识体系,很多人学着学着就会学懵了,包括我在内也是如此,所以本文将会从 Java 的 BIO 开始,一步一步深入学习,引出 JDK1.4 之后出现的 NIO 技术,对比 NIO 与 BIO 的区别,然后对 NIO 中重要的三个组成部分进行讲解(缓冲区、通道、选择器),最后实现一个简易的客户端与服务器通信功能。
为什么一个还没毕业的大学生能够把 IO 讲的这么好?(一)
|
3月前
|
存储 Java
【IO面试题 四】、介绍一下Java的序列化与反序列化
Java的序列化与反序列化允许对象通过实现Serializable接口转换成字节序列并存储或传输,之后可以通过ObjectInputStream和ObjectOutputStream的方法将这些字节序列恢复成对象。
|
4月前
|
Java 大数据
解析Java中的NIO与传统IO的区别与应用
解析Java中的NIO与传统IO的区别与应用
|
2月前
|
Java 大数据 API
Java 流(Stream)、文件(File)和IO的区别
Java中的流(Stream)、文件(File)和输入/输出(I/O)是处理数据的关键概念。`File`类用于基本文件操作,如创建、删除和检查文件;流则提供了数据读写的抽象机制,适用于文件、内存和网络等多种数据源;I/O涵盖更广泛的输入输出操作,包括文件I/O、网络通信等,并支持异常处理和缓冲等功能。实际开发中,这三者常结合使用,以实现高效的数据处理。例如,`File`用于管理文件路径,`Stream`用于读写数据,I/O则处理复杂的输入输出需求。
|
3月前
|
Java 数据处理
Java IO 接口(Input)究竟隐藏着怎样的神秘用法?快来一探究竟,解锁高效编程新境界!
【8月更文挑战第22天】Java的输入输出(IO)操作至关重要,它支持从多种来源读取数据,如文件、网络等。常用输入流包括`FileInputStream`,适用于按字节读取文件;结合`BufferedInputStream`可提升读取效率。此外,通过`Socket`和相关输入流,还能实现网络数据读取。合理选用这些流能有效支持程序的数据处理需求。
41 2
|
3月前
|
XML 存储 JSON
【IO面试题 六】、 除了Java自带的序列化之外,你还了解哪些序列化工具?
除了Java自带的序列化,常见的序列化工具还包括JSON(如jackson、gson、fastjson)、Protobuf、Thrift和Avro,各具特点,适用于不同的应用场景和性能需求。
|
3月前
|
缓存 Java
【IO面试题 一】、介绍一下Java中的IO流
Java中的IO流是对数据输入输出操作的抽象,分为输入流和输出流,字节流和字符流,节点流和处理流,提供了多种类支持不同数据源和操作,如文件流、数组流、管道流、字符串流、缓冲流、转换流、对象流、打印流、推回输入流和数据流等。
【IO面试题 一】、介绍一下Java中的IO流
|
4月前
|
存储 缓存 Java
Java零基础入门之IO流详解(二)
Java零基础入门之IO流详解(二)
|
4月前
|
Java 大数据
解析Java中的NIO与传统IO的区别与应用
解析Java中的NIO与传统IO的区别与应用