JavaBase:IO

IO

I/O或者输入/输出:

  • 指的是计算机与外部世界或者一个程序与计算机的其余部分的之间的接口。它对于任何计算机系统都非常关键,因而所有I/O的主体实际上是内置在操作系统中的。
  • 针对不同的操作对象,可以划分为磁盘I/O模型、网络I/O模型、内存映射I/O、Direct I/O、数据库I/O等,只要具有输入输出类型的交互系统都可以认为是I/O系统,也可以说I/O是整个操作系统数据交换与人机交互的通道,这个概念与选用的开发语言没有关系,是一个通用的概念。

在如今的系统中I/O却拥有很重要的位置,现在系统都有可能处理大量文件,大量数据库操作,而这些操作都依赖于系统的I/O性能,也就造成了现在系统的瓶颈往往都是由于I/O性能造成的。

因此,为了解决磁盘I/O性能慢的问题,系统架构中添加了缓存来提高响应速度;或者有些高端服务器从硬件级入手,使用了固态硬盘(SSD)来替换传统机械硬盘;在大数据方面,Spark越来越多的承担了实时性计算任务,而传统的Hadoop体系则大多应用在了离线计算与大量数据存储的场景,这也是由于磁盘I/O性能远不如内存I/O性能而造成的格局(Spark更多的使用了内存,而MapReduece更多的使用了磁盘)。

因此,一个系统的优化空间,往往都在低效率的I/O环节上,很少看到一个系统CPU、内存的性能是其整个系统的瓶颈。

IO分类

同步与异步

  • 同步:同步就是发起一个调用后,被调用者未处理完请求之前,调用不返回。
  • 异步:异步就是发起一个调用后,立刻得到被调用者的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,被调用者通常依靠事件,回调等机制来通知调用者其返回结果。

同步和异步的区别最大在于异步的话调用者不需要等待处理结果,被调用者会通过回调等机制来通知调用者其返回结果。

阻塞与非阻塞

  • 阻塞:阻塞就是发起一个请求,调用者一直等待请求结果返回,也就是当前线程会被挂起,无法从事其他任务,只有当条件就绪才能继续。
  • 非阻塞:非阻塞就是发起一个请求,调用者不用一直等着结果返回,可以先去干其他事情。

img

IO分类

  • BIO(Blocking I/O):同步阻塞I/O模式。它是基于流模型实现的,交互的方式是同步、阻塞方式,也就是说在读入输入流或者输出流时,在读写动作完成之前,线程会一直阻塞在那里,它们之间的调用是可靠的线性顺序。
    • 这里使用那个经典的烧开水例子,这里假设一个烧开水的场景,有一排水壶在烧开水,BIO的工作模式就是,叫一个线程停留在一个水壶那,直到这个水壶烧开,才去处理下一个水壶。但是实际上线程在等待水壶烧开的时间段什么都没有做。
  • NIO(New I/O):同时支持阻塞与非阻塞模式,在Java 1.4引入。提供了ChannelSelectorBuffer等新的抽象,可以构建多路复用的、同步非阻塞IO程序,同时提供了更接近操作系统底层高性能的数据操作方式。
    • 那么什么叫做同步非阻塞?如果还拿烧开水来说,NIO的做法是叫一个线程不断的轮询每个水壶的状态,看看是否有水壶的状态发生了改变,从而进行下一步的操作。
  • AIO(Asynchronous I/O):异步非阻塞I/O模型,在Java1.7引入,是NIO的升级。异步非阻塞与同步非阻塞的区别在哪里?异步非阻塞无需一个线程去轮询所有IO操作的状态改变,在相应的状态改变后,系统会通知对应的线程来处理。
    • 对应到烧开水中就是,为每个水壶上面装了一个开关,水烧开之后,水壶会自动通知我水烧开了。

IO调用步骤

进程中的IO调用步骤大致可以分为以下四步:

  1. 进程向操作系统请求数据;
  2. 操作系统把外部数据加载到内核的缓冲区中;
  3. 操作系统把内核的缓冲区拷贝到进程的缓冲区;
  4. 进程获得数据完成自己的功能;

当操作系统在把外部数据放到进程缓冲区的这段时间(即上述的第二,三步),如果应用进程是挂起等待的,那么就是同步IO,反之,就是异步IO,也就是AIO。

Java IO

单独的程序一般是让系统为它们完成大部分的工作。在Java编程中,直到最近一直使用的方式完成I/O。所有I/O都被视为单个的字节的移动,通过一个称为Stream的对象一次移动一个字节。流I/O用于与外部世界接触。它也在内部使用,用于将对象转换为字节,然后再转换回对象。

Java在I/O上也一直在做持续的优化,从JDK 1.4开始便引入了NIO模型,大大的提高了以往BIO模型下的操作效率。

BIO 同步阻塞

概述

是什么

这是最基本与简单的I/O操作方式,其根本特性是做完一件事再去做另一件事,一件事一定要等前一件事做完,这很符合程序员传统的顺序来开发思想,因此BIO模型程序开发起来较为简单,易于把握。

BIO可以通过多线程实现伪异步IO,但是当连接数极具上升也会带来性能瓶颈,原因是线程的上线文切换开销会在高并发的时候体现的很明显。

特性

  • 面向流。

在面向流的I/O中,可以将数据直接写入或者将数据直接读到Stream对象中。虽然Stream中也有Buffer开头的扩展类,但只是流的包装类,还是从流读到缓冲区。

  • 阻塞。

分类

传统的IO大致可以分为4种类型:

  • InputStream、OutputStream基于字节操作的IO。
  • Writer、Reader基于字符操作的IO。
  • File基于磁盘操作的IO。
  • Socket基于网络操作的IO。

优缺

优点:

  • 使用简单,将底层的机制都抽象成流。

缺点:

  • 性能不足。而且IO的各种流是阻塞的,这意味着,当一个线程调用read() 或 write()时,该线程被阻塞,直到有一些数据被读取,或数据完全写入。该线程在此期间不能再干任何事情了。

但是BIO如果需要同时做很多事情(例如同时读很多文件,处理很多tcp请求等),就需要系统创建很多线程来完成对应的工作,因为BIO模型下一个线程同时只能做一个工作,如果线程在执行过程中依赖于需要等待的资源,那么该线程会长期处于阻塞状态,我们知道在整个操作系统中,线程是系统执行的基本单位,在BIO模型下的线程阻塞就会导致系统线程的切换,从而对整个系统性能造成一定的影响。

应用

适用性

  • 如果我们只需要创建少量可控的线程,那么采用BIO模型也是很好的选择。

但如果在需要考虑高并发的WEB或者TCP服务器中采用BIO模型就无法应对了,如果系统开辟成千上万的线程,那么CPU的执行时机都会浪费在线程的切换中,使得线程的执行效率大大降低

实现

流程

主要面向使用BIO通信模型的服务端。

首先通常有一个独立的Accepter线程负责监听客户端的连接,一般通过while(true)的方式调用accept()方法等待接收客户端的连接方式监听请求。请求一旦接收到一个连接请求,就可以建立通信套接字在这个通信套接字上进行读写操作,此时不能再接收其他客户端连接请求,只能等待同当前连接的客户端的操作执行完成。

示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
int port = 4343; //端口号
// Socket 服务器端(简单的发送信息)
Thread sThread = new Thread(new Runnable() {
@Override
public void run() {
try {
ServerSocket serverSocket = new ServerSocket(port);
while (true) {
// 等待连接
Socket socket = serverSocket.accept();
Thread sHandlerThread = new Thread(new Runnable() {
@Override
public void run() {
try (PrintWriter printWriter = new PrintWriter(socket.getOutputStream())) {
printWriter.println("hello world!");
printWriter.flush();
} catch (IOException e) {
e.printStackTrace();
}
}
});
sHandlerThread.start();
}
} catch (IOException e) {
e.printStackTrace();
}
}
});
sThread.start();

// Socket 客户端(接收信息并打印)
try (Socket cSocket = new Socket(InetAddress.getLocalHost(), port)) {
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(cSocket.getInputStream()));
bufferedReader.lines().forEach(s -> System.out.println("客户端:" + s));} catch (UnknownHostException e) {
e.printStackTrace();
} catch (IOException e) {
e.printStackTrace();
}

NIO 同步非阻塞

为什么要用

NIO本身是基于事件驱动的思想来实现的,采用Reactor模式,其目的就是解决BIO的大并发问题,而在BIO模型中,如果需要并发处理多个I/O请求,那就需要多线程来支持。

NIO不需要位每个Socket套接字分配一个线程,而可以在一个线程当中处理多个Socket套接字相关的工作。

NIO的创建目的是为了让Java程序员可以实现高速I/O而无需编写自定义的本机代码。NIO将最耗时的I/O操作(即填充和提取缓冲区)转移回操作系统,因而可以极大地提高速度。

基础概述

是什么

NIO中提供了ChannelSelectorBuffer等抽象。支持面向缓冲的,基于通道的IO操作方法。

NIO提供了与传统BIO模型中的SocketServerSocket相对应的SocketChannelServerSocketChannel两种不同的套接字通道实现,两种通道都支持阻塞和非阻塞两种模式。阻塞模式使用就像传统中的支持一样,比较简单,但是性能和可靠性都不好;非阻塞模式正好与之相反。对于低负载、低并发的应用程序,可以使用同步阻塞I/O来提升开发速率和更好的维护性;对于高负载、高并发的(网络)应用,应使用NIO的非阻塞模式来开发。

NIO的特性

  • Non-blocking IO。

非阻塞读:单线程中从通道读取数据到buffer,同时可以继续做别的事情,当数据读取到buffer中后,线程再继续处理数据。

非阻塞写:一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。

  • Buffer。

在NIO厍中,所有数据都是用缓冲区处理的。在读取数据时,它是直接读到缓冲区中的;在写入数据时,写入到缓冲区中。任何时候访问NIO中的数据,都是通过缓冲区进行操作。

最常用的缓冲区是ByteBuffer,一个ByteBuffer提供了一组功能用于操作byte数组。除了ByteBuffer,还有其他的一些缓冲区。

  • Channel。

NIO通过Channel(通道)进行读写。通道是双向的,可读也可写,而流的读写是单向的。无论读写,通道只能和Buffer交互。因为Buffer,通道可以异步地读写。

  • Selectors。

NIO有选择器,而IO没有。选择器用于使用单个线程处理多个通道。因此,它需要较少的线程来处理这些通道。线程之间的切换对于操作系统来说是昂贵的。 因此,为了提高系统效率选择器是有用的。

1566007549450

核心组件

  • Channel(通道)。
  • Buffer(缓冲区)。
  • Selector(选择器)。

优缺

  • 非阻塞。
  • buffer机制。
  • 流替代块。

缺陷

  • JDK的NIO底层由epoll实现,该实现饱受诟病的空轮询bug会导致cpu飙升100%。
  • 项目庞大之后,自行实现的NIO很容易出现各类bug,维护成本较高。

NIO的块机制

原来的I/O库(在java.io.*中)与NIO最重要的区别是数据打包和传输的方式。正如前面提到的,原来的I/O以流的方式处理数据,而NIO以块的方式处理数据。

  • 面向流的I/O系统一次一个字节地处理数据。一个输入流产生一个字节的数据,一个输出流消费一个字节的数据。为流式数据创建过滤器非常容易。链接几个过滤器,以便每个过滤器只负责单个复杂处理机制的一部分,这样也是相对简单的。
  • 不利的一面是,面向流的I/O通常相当慢。 一个面向块的I/O系统以块的形式处理数据。每一个操作都在一步中产生或者消费一个数据块。
  • 按块处理数据比按(流式的)字节处理数据要快得多。但是面向块的I/O缺少一些面向流的I/O所具有的优雅性和简单性。

NIO的buffer机制

NIO性能的优势就来源于缓冲的机制,不管是读或者写都需要以块的形式写入到缓冲区中。NIO实际上让我们对IO的操作更接近于操作系统的实际过程。

所有的系统I/O都分为两个阶段:等待就绪和操作。

举例来说:

  • 读函数,分为等待系统可读和真正的读。
  • 写函数分为等待网卡可以写和真正的写。

以socket为例:

  • 先从应用层获取数据到内核的缓冲区,然后再从内核的缓冲区复制到进程的缓冲区。
  • 所以实际上底层的机制也是不断利用缓冲区来读写数据的。即使传统IO抽象成了从流直接读取数据,但本质上也依然是利用缓冲区来读取和写入数据。
  • 所以,为了更好的理解NIO,我们就需要知道IO的底层机制,这样对我们将来理解Channel和Buffer就打下了基础。这里简单提一下,我们可以把Buffer就理解为内核缓冲区,所以不论读写,自然都要经过这个区域。
    • 读的话,先从设备读取数据到内核,再读到进程缓冲区。
    • 写的话,先从进程缓冲区写到内核,再从内核写回设备。

NIO的非阻塞机制

NIO抽象出了新的通道(Channel)作为输入输出的通道,并且提供了缓存(Buffer)的支持:

  • 在进行读操作时:
    • 需要使用Buffer分配空间。
    • 然后将数据从Channel中读入Buffer中。
  • 对于Channel的写操作:
    • 也需要现将数据写入Buffer。
    • 然后将Buffer写入Channel中。

NIO的非阻塞模式,使一个线程从某通道发送请求读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取。而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。 非阻塞写也是如此。一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情。 线程通常将非阻塞IO的空闲时间用于在其它通道上执行IO操作,所以一个单独的线程现在可以管理多个输入和输出通道(Channel)。

应用

适用性

实现

流程

读取数据与写数据

通常来说NIO中的所有IO都是从 Channel(通道)开始的。

  • 从通道进行数据读取:创建一个缓冲区,然后请求通道读取数据。
  • 从通道进行数据写入:创建一个缓冲区,填充数据,并要求通道写入数据。

数据读取和写入操作图示:

1565689676372

NIO使用了多路复用器(selector)机制,以socket使用来说,多路复用器通过不断轮询各个连接的状态,只有在socket有流可读或者可写时,应用程序才需要去处理它,在线程的使用上,就不需要一个连接就必须使用一个处理线程了,而是只是有效请求时(确实需要进行I/O处理时),才会使用一个线程去处理,这样就避免了BIO模型下大量线程处于阻塞等待状态的情景。

示例

如下是NIO方式进行文件拷贝操作的示例,见下图:

img

通过比较New IO的使用方式我们可以发现,新的IO操作不再面向Stream来进行操作了,改为了通道Channel,并且使用了更加灵活的缓存区类Buffer,Buffer只是缓存区定义接口,根据需要,我们可以选择对应类型的缓存区实现类。在java NIO编程中,我们需要理解以下3个对象Channel、Buffer和Selector。

  • Channel

首先说一下Channel即通道。Channel和IO中的Stream(流)是差不多一个等级的。只不过Stream是单向的,譬如:InputStreamOutputStream。而Channel是双向的,既可以用来进行读操作,又可以用来进行写操作,NIO中的Channel的主要实现有:FileChannelDatagramChannelSocketChannelServerSocketChannel;通过看名字就可以猜出个所以然来:分别可以对应文件IO、UDP和TCP(Server和Client)。

  • Buffer

NIO中的关键Buffer实现有:ByteBufferCharBufferDoubleBufferFloatBufferIntBufferLongBufferShortBuffer,分别对应基本数据类型:byte、char、double、float、int、 long、 short。当然NIO中还有MappedByteBuffer, HeapByteBuffer, DirectByteBuffer等这里先不具体陈述其用法细节。

说一下DirectByteBufferHeapByteBuffer的区别?

它们ByteBuffer分配内存的两种方式。HeapByteBuffer顾名思义其内存空间在JVM的heap(堆)上分配,可以看做是JDK对于byte[]数组的封装;而DirectByteBuffer则直接利用了系统接口进行内存申请,其内存分配在heap中,这样就减少了内存之间的拷贝操作,如此一来,在使用DirectByteBuffer时,系统就可以直接从内存将数据写入到Channel中,而无需进行Java堆的内存申请、复制等操作,提高了性能。

既然如此,为什么不直接使用DirectByteBuffer,还要来个HeapByteBuffer?原因在于,DirectByteBuffer是通过full gc来回收内存的,DirectByteBuffer会自己检测情况而调用System.gc(),但是如果参数中使用了DisableExplicitGC那么就无法回收该块内存了,-XX:+DisableExplicitGC标志自动将System.gc()调用转换成一个空操作,就是应用中调用 System.gc() 会变成一个空操作,那么如果设置了就需要我们手动来回收内存了,所以DirectByteBuffer使用起来相对于完全托管于Java内存管理的Heap ByteBuffer来说更复杂一些,如果用不好可能会引起OOM。Direct ByteBuffer的内存大小受-XX:MaxDirectMemorySizeJVM 参数控制(默认大小64M),在DirectByteBuffer申请内存空间达到该设置大小后,会触发Full GC。

  • Selector

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

这里我们再来看一个NIO模型下的TCP服务器的实现,我们可以看到Selector正是NIO模型下TCP Server实现IO复用的关键,请仔细理解下段代码while循环中的逻辑,见下图:

img

Netty

Java NIO存在一些问题:

  • JDK的NIO底层由epoll实现,该实现饱受诟病的空轮询Bug会导致Cpu飙升100%。
  • 项目庞大之后,自行实现的NIO很容易出现各类Bug,维护成本较高,上面这一坨代码我都不能保证没有Bug。

Netty的出现很大程度上改善了JDK原生NIO所存在的一些让人难以忍受的问题。

AIO 异步非阻塞

Java AIO就是Java作为对异步IO提供支持的NIO.2,Java NIO2 (JSR 203)定义了更多的 New I/O APIs,提案2003提出,直到2011年才发布,最终在JDK 7中才实现。JSR 203除了提供更多的文件系统操作API(包括可插拔的自定义的文件系统),还提供了对socket和文件的异步I/O操作。同时实现了JSR-51提案中的socket channel全部功能,包括对绑定、option配置的支持以及多播multicast的实现。

概述

是什么

AIO的异步IO是基于事件和回调机制实现的,也就是应用操作之后会直接返回,不会堵塞在那里,当后台处理完成,操作系统会通知相应的线程进行后续的操作。

AIO采用Proactor模式,在读写时,只需要调用相应的read/write方法,并且需要传入CompletionHandler,动作完成后会调用CompetionHandler。

AIO是异步IO的缩写,虽然NIO在网络操作中,提供了非阻塞的方法,但是NIO的IO行为还是同步的。对于 NIO 来说,我们的业务线程是在IO操作准备好时,得到通知,接着就由这个线程自行进行IO操作,IO操作本身是同步的。

从编程模式上来看AIO相对于NIO的区别在于,NIO需要使用者线程不停的轮询IO对象,来确定是否有数据准备好可以读了,而AIO则是在数据准备好之后,才会通知数据使用者,这样使用者就不需要不停地轮询了。当然AIO的异步特性并不是Java实现的伪异步,而是使用了系统底层API的支持,在Unix系统下,采用了epoll IO模型,而windows便是使用了IOCP模型。关于Java AIO,本篇只做一个抛砖引玉的介绍,如果你在实际工作中用到了,那么可以参考Netty在高并发下使用AIO的相关技术。

总结

IO实质上与线程没有太多的关系,但是不同的IO模型改变了应用程序使用线程的方式,NIO与AIO的出现解决了很多BIO无法解决的并发问题,当然任何技术抛开适用场景都是耍流氓,复杂的技术往往是为了解决简单技术无法解决的问题而设计的,在系统开发中能用常规技术解决的问题,绝不用复杂技术,否则大大增加系统代码的维护难度,学习IT技术不是为了炫技,而是要实实在在解决问题。

参考

  1. 以Java的视角来聊聊BIO、NIO与AIO的区别?
  2. Snailclimb BIO-NIO-AIO