gitbook/Java性能调优实战/docs/99478.md
2022-09-03 22:05:03 +08:00

14 KiB
Raw Blame History

08 | 网络通信优化之I/O模型如何解决高并发下I/O瓶颈

你好,我是刘超。

提到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/ONIO适用于发生大量I/O连接请求的场景这三个组件共同提升了I/O的整体性能。

你可以在Github上通过几个简单的例子来实践下传统IO、NIO。

思考题

在JDK1.7版本中Java发布了NIO的升级包NIO2也就是AIO。AIO实现了真正意义上的异步I/O它是直接将I/O操作交给操作系统进行异步处理。这也是对I/O操作的一种优化那为什么现在很多容器的通信框架都还是使用NIO呢

期待在留言区看到你的见解。也欢迎你点击“请朋友读”,把今天的内容分享给身边的朋友,邀请他一起学习。