乐趣区

关于后端:怎样解决高并发下的IO瓶颈

大家好,我是易安。

说起 Java I/O,置信你肯定不生疏。你可能应用 I / O 操作读写文件,也可能应用它实现 Socket 的信息传输…这些都是咱们在零碎中最常遇到的和 I / O 无关的操作。

咱们都晓得,I/ O 的速度要比内存速度慢,尤其是在当初这个大数据时代背景下,I/ O 的性能问题更是尤为突出,I/ O 读写曾经成为很多利用场景下的零碎性能瓶颈,不容咱们漠视。

明天,咱们就来深刻理解下 Java I/ O 在高并发、大数据业务场景下暴露出的性能问题,从源头动手,学习优化办法。

什么是 I /O

I/ O 是机器获取和替换信息的次要渠道,而流是实现 I / O 操作的次要形式。

在计算机中,流是一种信息的转换。流是有序的,因而绝对于某一机器或者应用程序而言,咱们通常把机器或者应用程序接管外界的信息称为输出流(InputStream),从机器或者应用程序向外输入的信息称为输入流(OutputStream),合称为输出 / 输入流(I/O Streams)。

机器间或程序间在进行信息替换或者数据交换时,总是先将对象或数据转换为某种模式的流,再通过流的传输,达到指定机器或程序后,再将流转换为对象数据。因而,流就能够被看作是一种数据的载体,通过它能够实现数据交换和传输。

Java 的 I / O 操作类在包 java.io 下,其中 InputStream、OutputStream 以及 Reader、Writer 类是 I / O 包中的 4 个根本类,它们别离解决字节流和字符流。如下图所示:

在首次浏览 Java I/ O 流文档的时候,我有过这样一个疑难,“ 不论是文件读写还是网络发送接管,信息的最小存储单元都是字节,那为什么 I / O 流操作要分为字节流操作和字符流操作呢?

咱们晓得字符到字节必须通过转码,这个过程十分耗时,如果咱们不晓得编码类型就很容易呈现乱码问题。所以 I / O 流提供了一个间接操作字符的接口,不便咱们平时对字符进行流操作。上面咱们就别离理解下“字节流”和“字符流”。

1. 字节流

InputStream/OutputStream 是字节流的抽象类,这两个抽象类又派生出了若干子类,不同的子类别离解决不同的操作类型。如果是文件的读写操作,就应用 FileInputStream/FileOutputStream;如果是数组的读写操作,就应用 ByteArrayInputStream/ByteArrayOutputStream;如果是一般字符串的读写操作,就应用 BufferedInputStream/BufferedOutputStream。具体内容如下图所示:

2. 字符流

Reader/Writer 是字符流的抽象类,这两个抽象类也派生出了若干子类,不同的子类别离解决不同的操作类型,具体内容如下图所示:

传统 I / O 的性能问题

咱们晓得,I/ O 操作分为磁盘 I / O 操作和网络 I / O 操作。前者是从磁盘中读取数据源输出到内存中,之后将读取的信息长久化输入在物理磁盘上;后者是从网络中读取信息输出到内存,最终将信息输入到网络中。但不论是磁盘 I / O 还是网络 I /O,在传统 I / O 中都存在重大的性能问题。

1. 屡次内存复制

在传统 I / O 中,咱们能够通过 InputStream 从源数据中读取数据流输出到缓冲区里,通过 OutputStream 将数据输入到外部设备(包含磁盘、网络)。你能够先看下输出操作在操作系统中的具体流程,如下图所示:

  • JVM 会收回 read() 零碎调用,并通过 read 零碎调用向内核发动读申请;
  • 内核向硬件发送读指令,并期待读就绪;
  • 内核把将要读取的数据复制到指向的内核缓存中;
  • 操作系统内核将数据复制到用户空间缓冲区,而后 read 零碎调用返回。

在这个过程中,数据先从外部设备复制到内核空间,再从内核空间复制到用户空间,这就产生了两次内存复制操作。这种操作会导致不必要的数据拷贝和上下文切换,从而升高 I / O 的性能。

2. 阻塞

在传统 I / O 中,InputStream 的 read() 是一个 while 循环操作,它会始终期待数据读取,直到数据就绪才会返回。 这就意味着如果没有数据就绪,这个读取操作将会始终被挂起,用户线程将会处于阻塞状态。

在大量连贯申请的状况下,应用这种形式没有问题,响应速度也很高。但在产生大量连贯申请时,就须要创立大量监听线程,这时如果线程没有数据就绪就会被挂起,而后进入阻塞状态。一旦产生线程阻塞,这些线程将会一直地争夺 CPU 资源,从而导致大量的 CPU 上下文切换,减少零碎的性能开销。

如何优化 I / O 操作

面对以上两个性能问题,不仅编程语言对此做了优化,各个操作系统也进一步优化了 I /O。JDK1.4 公布了 java.nio 包(new I/ O 的缩写),NIO 的公布优化了内存复制以及阻塞导致的重大性能问题。JDK1.7 又公布了 NIO2,提出了从操作系统层面实现的异步 I /O。上面咱们就来理解下具体的优化实现。

1. 应用缓冲区优化读写流操作

在传统 I / O 中,提供了基于流的 I / O 实现,即 InputStream 和 OutputStream,这种基于流的实现以字节为单位解决数据。

NIO 与传统 I/O 不同,它是基于块(Block)的,它以块为根本单位解决数据。在 NIO 中,最为重要的两个组件是缓冲区(Buffer)和通道(Channel)。Buffer 是一块间断的内存块,是 NIO 读写数据的中转地。Channel 示意缓冲数据的源头或者目的地,它用于读取缓冲或者写入数据,是拜访缓冲的接口。

传统 I / O 和 NIO 的最大区别就是传统 I / O 是面向流,NIO 是面向 Buffer。Buffer 能够将文件一次性读入内存再做后续解决,而传统的形式是边读文件边解决数据。尽管传统 I / O 前面也应用了缓冲块,例如 BufferedInputStream,但依然不能和 NIO 相媲美。应用 NIO 代替传统 I / O 操作,能够晋升零碎的整体性能,成果空谷传声。

2. 应用 DirectBuffer 缩小内存复制

NIO 的 Buffer 除了做了缓冲块优化之外,还提供了一个能够间接拜访物理内存的类 DirectBuffer。一般的 Buffer 调配的是 JVM 堆内存,而 DirectBuffer 是间接调配物理内存 (非堆内存)。

咱们晓得数据要输入到外部设备,必须先从用户空间复制到内核空间,再复制到输出设备,而在 Java 中,在用户空间中又存在一个拷贝,那就是从 Java 堆内存中拷贝到长期的间接内存中,通过长期的间接内存拷贝到内存空间中去。此时的间接内存和堆内存都是属于用户空间。

你必定会在想,为什么 Java 须要通过一个长期的非堆内存来复制数据呢?如果单纯应用 Java 堆内存进行数据拷贝,当拷贝的数据量比拟大的状况下,Java 堆的 GC 压力会比拟大,而应用非堆内存能够减低 GC 的压力。

DirectBuffer 则是间接将步骤简化为数据间接保留到非堆内存,从而缩小了一次数据拷贝。以下是 JDK 源码中 IOUtil.java 类中的 write 办法:

        if (src instanceof DirectBuffer)
            return writeFromNativeBuffer(fd, src, position, nd);

        // Substitute a native buffer
        int pos = src.position();
        int lim = src.limit();
        assert (pos <= lim);
        int rem = (pos <= lim ? lim - pos : 0);
        ByteBuffer bb = Util.getTemporaryDirectBuffer(rem);
        try {bb.put(src);
            bb.flip();
        // ...............

这里拓展一点,因为 DirectBuffer 申请的是非 JVM 的物理内存,所以创立和销毁的代价很高。DirectBuffer 申请的内存并不是间接由 JVM 负责垃圾回收,但在 DirectBuffer 包装类被回收时,会通过 Java Reference 机制来开释该内存块。

DirectBuffer 只优化了用户空间外部的拷贝,而之前咱们是说优化用户空间和内核空间的拷贝,那 Java 的 NIO 中是否能做到缩小用户空间和内核空间的拷贝优化呢?

答案是能够的,DirectBuffer 是通过 unsafe.allocateMemory(size) 办法分配内存,也就是基于本地类 Unsafe 类调用 native 办法进行内存调配的。而在 NIO 中,还存在另外一个 Buffer 类:MappedByteBuffer,跟 DirectBuffer 不同的是,MappedByteBuffer 是通过本地类调用 mmap 进行文件内存映射的,map() 零碎调用办法会间接将文件从硬盘拷贝到用户空间,只进行一次数据拷贝,从而缩小了传统的 read() 办法从硬盘拷贝到内核空间这一步。

3. 防止阻塞,优化 I / O 操作

NIO 很多人也称之为 Non-block I/O,即非阻塞 I /O,因为这样叫,更能体现它的特点。为什么这么说呢?

传统的 I / O 即便应用了缓冲块,仍然存在阻塞问题。因为线程池线程数量无限,一旦产生大量并发申请,超过最大数量的线程就只能期待,直到线程池中有闲暇的线程能够被复用。而对 Socket 的输出流进行读取时,读取流会始终阻塞,直到产生以下三种状况的任意一种才会解除阻塞:

  • 有数据可读;
  • 连贯开释;
  • 空指针或 I / O 异样。

阻塞问题,就是传统 I / O 最大的弊病。NIO 公布后,通道和多路复用器这两个根本组件实现了 NIO 的非阻塞,上面咱们就一起来理解下这两个组件的优化原理。

通道(Channel)

后面咱们探讨过,传统 I / O 的数据读取和写入是从用户空间到内核空间来回复制,而内核空间的数据是通过操作系统层面的 I / O 接口从磁盘读取或写入。

最开始,在应用程序调用操作系统 I / O 接口时,是由 CPU 实现调配,这种形式最大的问题是“产生大量 I / O 申请时,十分耗费 CPU“;之后,操作系统引入了 DMA(间接存储器存储),内核空间与磁盘之间的存取齐全由 DMA 负责,但这种形式仍然须要向 CPU 申请权限,且须要借助 DMA 总线来实现数据的复制操作,如果 DMA 总线过多,就会造成总线抵触。

通道的呈现解决了以上问题,Channel 有本人的处理器,能够实现内核空间和磁盘之间的 I / O 操作。在 NIO 中,咱们读取和写入数据都要通过 Channel,因为 Channel 是双向的,所以读、写能够同时进行。

多路复用器(Selector)

Selector 是 Java NIO 编程的根底。用于查看一个或多个 NIO Channel 的状态是否处于可读、可写。

Selector 是基于事件驱动实现的,咱们能够在 Selector 中注册 accpet、read 监听事件,Selector 会一直轮询注册在其上的 Channel,如果某个 Channel 下面产生监听事件,这个 Channel 就处于就绪状态,而后进行 I / O 操作。

一个线程应用一个 Selector,通过轮询的形式,能够监听多个 Channel 上的事件。咱们能够在注册 Channel 时设置该通道为非阻塞,当 Channel 上没有 I / O 操作时,该线程就不会始终期待了,而是会一直轮询所有 Channel,从而防止产生阻塞。

目前操作系统的 I / O 多路复用机制都应用了 epoll,相比传统的 select 机制,epoll 没有最大连贯句柄 1024 的限度。所以 Selector 在实践上能够轮询成千上万的客户端。

上面我用一个生活化的场景来举例, 看完你就更分明 Channel 和 Selector 在非阻塞 I / O 中承当什么角色,施展什么作用了。

咱们能够把监听多个 I / O 连贯申请比作一个火车站的进站口。以前检票只能让搭乘就近一趟发车的旅客提前进站,而且只有一个检票员,这时如果有其余车次的旅客要进站,就只能在站口排队。这就相当于最早没有实现线程池的 I / O 操作。

起初火车站降级了,多了几个检票入口,容许不同车次的旅客从各自对应的检票入口进站。这就相当于用多线程创立了多个监听线程,同时监听各个客户端的 I / O 申请。

最初火车站进行了降级革新,能够包容更多旅客了,每个车次载客更多了,而且车次也安顿正当,乘客不再扎堆排队,能够从一个大的对立的检票口进站了,这一个检票口能够同时检票多个车次。这个大的检票口就相当于 Selector,车次就相当于 Channel,旅客就相当于 I / O 流。

总结

Java 的传统 I / O 开始是基于 InputStream 和 OutputStream 两个操作流实现的,这种流操作是以字节为单位,如果在高并发、大数据场景中,很容易导致阻塞,因而这种操作的性能是十分差的。还有,输入数据从用户空间复制到内核空间,再复制到输出设备,这样的操作会减少零碎的性能开销。

传统 I / O 起初应用了 Buffer 优化了“阻塞”这个性能问题,以缓冲块作为最小单位,但相比整体性能来说仍然不尽人意。

于是 NIO 公布,它是基于缓冲块为单位的流操作,在 Buffer 的根底上,新增了两个组件“管道和多路复用器”,实现了非阻塞 I /O,NIO 实用于产生大量 I / O 连贯申请的场景,这三个组件独特晋升了 I / O 的整体性能。

本文由 mdnice 多平台公布

退出移动版