大家好,我是易安!

Java 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中,无论是磁盘I/O还是网络I/O,都面临着重大的性能问题。

1.屡次内存复制

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

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

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

2.阻塞

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

在连贯申请较少的状况下,应用传统I/O形式通常不会呈现问题,响应速度也绝对较高。然而,在面临大量连贯申请时,零碎须要创立大量的监听线程。若线程没有数据就绪,它们将被挂起并进入阻塞状态。一旦线程阻塞,这些线程会一直抢夺CPU资源,从而导致大量的CPU上下文切换,进而减少零碎的性能开销。

如何优化I/O操作

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

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

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

与传统I/O不同,NIO是基于块(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,将缓冲块作为最小单位。尽管如此,相较于整体性能来说,传统I/O的改良仍不欠缺。

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

本文由mdnice多平台公布