一、Java的I/O演进之路

Java共支持3种网络编程的I/O模型:BIO、NIO、AIO

BIO(Blocking I/O):
阻塞 + 同步(传统阻塞型),服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销
image.png

NIO(No Blocking IO
非阻塞 + 同步,服务器实现模式为一个线程处理多个请求(连接),即客户端发送的连接请求都会注册到多路复用器上,多路复用器轮询到连接有I/O请求就进行处理
image.png

AIO
AIO ,全称 Asynchronous IO ,也叫 NIO2 ,是一种非阻塞 + 异步的通信模式。在 NIO 的基础上,引入了新的异步通道的概念,并提供了异步文件通道和异步套接字通道的实现

二、BIO深入剖析

1、BIO概述

  • BIO(Blocking I/O)就是传统的Java IO编程,其相关的类和接口在java.io包下。
  • BIO是同步阻塞的,服务器实现模式为一个连接一个线程,即客户端有连接请求时服务器端就需要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)

image.png

2、BIO案例


服务端

  1. public class Server {
  2. public static void main(String[] args) throws IOException {
  3. System.out.println("===服务端启动===");
  4. // 1.定义一个ServerSocket对象进行服务端的端口注册
  5. ServerSocket ss = new ServerSocket(9999);
  6. // 2.监听客户端的Socket连接请求
  7. Socket socket = ss.accept();
  8. // 3.从Socket管道中得到一个字节输入流对象
  9. InputStream is = socket.getInputStream();
  10. // 4.把字节输入流包装成一个缓冲字符输入流
  11. BufferedReader br = new BufferedReader(new InputStreamReader(is));
  12. String msg;
  13. while ((msg = br.readLine()) != null) {
  14. System.out.println("服务端接收到:" + msg);
  15. }
  16. }
  17. }

客户端

  1. public class Client {
  2. public static void main(String[] args) throws IOException {
  3. System.out.println("===客户端启动===");
  4. // 1.创建Socket对象请求服务端的连接
  5. Socket socket = new Socket("127.0.0.1", 9999);
  6. // 2.从Socket对象中获取一个字节输出流
  7. OutputStream os = socket.getOutputStream();
  8. // 3.把字节输出流包装成一个打印流
  9. PrintStream ps = new PrintStream(os);
  10. ps.println("Hello World!服务端,你好!");
  11. ps.flush();
  12. }
  13. }

小结
在以上通信中,服务端会一直等待客户端的消息,如果客户端没有进行消息的发送,服务端将一直进入阻塞状态

3、伪异步I/O

1)、概述

伪异步I/O采用线程池和任务队列实现,当客户端接入时,将客户端的Socket封装成一个Task(该任务实现java.lang.Runnable线程任务接口)交给后端的线程池中进行处理。JDK的线程池维护一个消息队列和N个活跃的线程,对消息队列中Socket任务进行处理,由于线程池可以设置消息队列的大小和最大线程数,因此,它的资源占用是可控的,无论多少个客户端并发访问,都不会导致资源的耗尽和宕机
image.png

2)、代码实现

客户端

  1. public class Client {
  2. public static void main(String[] args) {
  3. try {
  4. Socket socket = new Socket("127.0.0.1", 9999);
  5. OutputStream os = socket.getOutputStream();
  6. PrintStream ps = new PrintStream(os);
  7. Scanner sc = new Scanner(System.in);
  8. while (true) {
  9. System.out.print("请说:");
  10. String msg = sc.nextLine();
  11. ps.println(msg);
  12. ps.flush();
  13. }
  14. } catch (IOException e) {
  15. e.printStackTrace();
  16. }
  17. }
  18. }


线程池处理类

  1. public class SocketServerPoolHandler {
  2. /**
  3. * 1.创建一个线程池的成员变量用于存储一个线程池对象
  4. */
  5. private ExecutorService executorService;
  6. /**
  7. * 2.创建这个类的时候就需要初始化线程池对象
  8. */
  9. public SocketServerPoolHandler(int maxThreadNum, int queueSize) {
  10. executorService = new ThreadPoolExecutor(3, maxThreadNum,
  11. 120, TimeUnit.SECONDS, new ArrayBlockingQueue<>(queueSize));
  12. }
  13. /**
  14. * 3.提供一个方法来提交任务给线程池的任务队列来暂存,等着线程池来处理
  15. */
  16. public void execute(Runnable target) {
  17. executorService.execute(target);
  18. }
  19. }

服务端

  1. public class Server {
  2. public static void main(String[] args) {
  3. try {
  4. // 1.注册端口
  5. ServerSocket ss = new ServerSocket(9999);
  6. // 2.定义一个循环接收客户端的Socket连接请求
  7. // 初始化一个线程池对象
  8. SocketServerPoolHandler poolHandler = new SocketServerPoolHandler(3, 10);
  9. while (true) {
  10. Socket socket = ss.accept();
  11. // 3.把Socket对象交给一个线程池进行处理
  12. // 把Socket封装成一个任务对象交给线程池处理
  13. Runnable target = new ServerRunnableTarget(socket);
  14. poolHandler.execute(target);
  15. }
  16. } catch (IOException e) {
  17. e.printStackTrace();
  18. }
  19. }
  20. }
  1. public class ServerRunnableTarget implements Runnable {
  2. private Socket socket;
  3. public ServerRunnableTarget(Socket socket) {
  4. this.socket = socket;
  5. }
  6. @Override
  7. public void run() {
  8. // 处理接收的客户端Socket通信需求
  9. try {
  10. InputStream is = socket.getInputStream();
  11. BufferedReader br = new BufferedReader(new InputStreamReader(is));
  12. String msg;
  13. while ((msg = br.readLine()) != null) {
  14. System.out.println("服务端接收到:" + msg);
  15. }
  16. } catch (IOException e) {
  17. e.printStackTrace();
  18. }
  19. }
  20. }

运行结果
启动服务端并启动多个客户端发送消息,由于核心线程数=最大线程数=3,当客户端数>3时,客户端的Socket任务会到线程池的阻塞队列中等待,关闭客户端,当客户端数<=3时,Socket任务将会被服务端处理

3)、小结

  • 伪异步I/O采用了线程池实现,因此避免了为每个请求创建一个独立线程造成线程资源耗尽的问题,但由于底层依然是采用的同步阻塞模型,因此无法从根本上解决问题
  • 如果单个消息处理的缓慢,或者服务器线程池中的全部线程都被阻塞,那么后续Socket的I/O消息都将在队列中排队。新的Socket请求将被拒绝,客户端会发生大量连接超时

三、NIO深入剖析

1、NIO概述

  • NIO(Non-Blocking IO)是从Java 1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区的、基于通道的IO操作。NIO将以更加高效的方式进行文件的读写操作。NIO可以理解为非阻塞IO,传统的IO的read和write只能阻塞执行,线程在读写IO期间不能干其他事情,比如调用socket.read()时,如果服务器一直没有数据传输过来,线程就一直阻塞,而NIO中可以配置socket为非阻塞模式
  • NIO相关类都被放在java.nio包及子包下,并且对原java.io包中的很多类进行改写
  • NIO有三大核心部分:Channel(通道)、Buffer(缓冲区)、Selector(选择器)
  • Java NIO的非阻塞模式,使一个线程从某通道发送请求或者读取数据,但是它仅能得到目前可用的数据,如果目前没有数据可用时,就什么都不会获取,而不是保持线程阻塞,所以直至数据变的可以读取之前,该线程可以继续做其他的事情。非阻塞写也是如此,一个线程请求写入一些数据到某通道,但不需要等待它完全写入,这个线程同时可以去做别的事情
  • 通俗理解:NIO是可以做到用一个线程来处理多个操作的。假设有1000个请求过来,根据实际情况,可以分配20或者80个线程来处理。不像之前的阻塞IO那样,非得分配1000个

2、NIO和BIO的比较

  • BIO以流的方式处理数据,而NIO以块的方式处理数据,块I/O的效率比流I/O高很多
  • BIO是阻塞的,NIO则是非阻塞的
  • BIO基于字节流和字符流进行操作,而NIO基于Channel(通道)和Buffer(缓冲区)进行操作,数据总是从通道读取到缓冲区中,或者从缓冲区写入到通道中。Selector(选择器)用于监听多个通道的事件(比如:连接请求,数据到达等),因此使用单个线程就可以监听多个客户端通道 | NIO | BIO | | —- | —- | | 面向缓冲区(Buffer) | 面向流(Stream) | | 非阻塞(Non Blocking IO) | 阻塞IO(Blocking IO) | | 选择器(Selector) | |

3、NIO三大核心原理示意图

NIO有三大核心部分:Channel(通道)、Buffer(缓冲区)、Selector(选择器)

Buffer缓冲区
缓冲区本质上是一块可以写入数据,然后可以从中读取数据的内存。这块内存被包装成NIO Buffer对象,并提供了一组方法,用来方便的访问该块内存。相比较直接对数组的操作,Buffer API更加容易操作和管理

Channel(通道)
Java NIO的通道类似流,但又有些不同:既可以从通道中读取数据,又可以写数据到通道。但流的(input或output)读写通常是单向的。通道可以非阻塞读取和写入通道,通道可以支持读取或写入缓冲区,也支持异步地读写

Selector选择器
Selector是一个Java NIO组件,可以能够检查一个或多个NIO通道,并确定哪些通道已经准备好进行读取或写入。这样,一个单独的线程可以管理多个channel,从而管理多个网络连接,提高效率

image.png

  • 每个channel都会对应一个Buffer
  • 一个线程对应Selector,一个Selector对应多个channel(连接)
  • 程序切换到哪个channel是由事件决定的
  • Selector会根据不同的事件,在各个通道上切换
  • Buffer就是一个内存块,底层是一个数组
  • 数据的读取写入是通过Buffer完成的,BIO中要么是输入流,或者是输出流,不能双向,但是NIO的Buffer是可以读也可以写
  • Java NIO系统的核心在于:通道(Channel)和缓冲区(Buffer)。通道表示打开到IO设备(例如:文件、套接字)的连接。若需要使用NIO系统,需要获取用于连接IO设备的通道以及用于容纳数据的缓冲区。然后操作缓冲区,对数据进行处理。简而言之,Channel负责传输,Buffer负责存取数据

4、NIO核心一:缓冲区(Buffer)

1)、Buffer概述

Buffer是一个用于特定基本数据类型的容器。由java.nio包定义的,所有缓冲区都是Buffer抽象类的子类。Java NIO中的Buffer主要用于与NIO通道进行交互,数据是从通道读入缓冲区,从缓冲区写入通道中的
image.png

2)、Buffer类及其子类

Buffer就像一个数组,可以保存多个相同类型的数据。根据数据类型不同,有以下Buffer常用子类:

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

上述Buffer类都采用相似的方法进行管理数据,只是各自管理的数据类型不同而已。都是通过如下方法获取一个Buffer对象:

  1. static XxxBuffer allocate(int capacity) //创建一个容量为capacity的XxxBuffer对象

3)、缓冲区的基本属性

  • 容量(capacity):作为一个内存块,Buffer具有一定的固定大小,也称为容量,缓冲区容量不能为负,并且创建后不能更改
  • 限制(limit):表示缓冲区中可以操作数据的大小(limit后数据不能进行读写)。缓冲区的限制不能为负,并且不能大于其容量。写入模式,限制等于buffer的容量。读取模式下,limit等于写入的数据量
  • 位置(position):下一个要读取或写入的数据的索引。缓冲区的位置不能为负,并且不能大于其限制
  • 标记(mark)与重置(reset):标记是一个索引,通过Buffer中的mark()方法指定Buffer中一个特定的position,之后可以通过调用reset()方法恢复到这个position
  • 标记、位置、限制、容量遵守以下不变式:0 <= mark <= position <= limit <= capacity


image.png

4)、Buffer常见方法

  1. Buffer clear() //清空缓冲区并返回对缓冲区的引用(缓冲区中的数据依然存在,但是处于被遗忘状态)
  2. Buffer flip() //为将缓冲区的界限设置为当前位置,并将当前位置重置为0
  3. int capacity() //返回Buffer的capacity大小
  4. boolean hasRemaining() //判断缓冲区中是否还有元素
  5. int limit() //返回Buffer的界限(limit)的位置
  6. Buffer limit(int n) //将设置缓冲区界限为n,并返回一个具有新limit的缓冲区对象
  7. Buffer mark() //对缓冲区设置标记
  8. int position() //返回缓冲区的当前位置position
  9. Buffer position(int n) //将设置缓冲区的当前位置为n,并返回修改后的Buffer对象
  10. int remaining() //返回position和limit之间的元素个数
  11. Buffer reset() //将位置position转到以前设置的mark所在的位置
  12. Buffer rewind() //将位置设为0,取消设置的mark

5)、缓冲区的数据操作

Buffer所有子类提供了两个用于数据操作的方法:get()和put()方法
获取Buffer中的数据:

  1. get() //读取单个字节
  2. get(byte[] dst) //批量读取多个字节到dst中
  3. get(int index) //读取指定索引位置的字节(不会移动position)

放到入数据到Buffer中:

  1. put(byte b) //将给定单个字节写入缓冲区的当前位置
  2. put(byte[] src) //将src中的字节写入缓冲区的当前位置
  3. put(int index, byte b) //将指定字节写入缓冲区的索引位置(不会移动position)

使用Buffer读写数据一般遵循以下四个步骤:

  1. 写入数据到Buffer
  2. 调用flip()方法,转换为读取模式
  3. 从Buffer中读取数据
  4. 调用buffer.clear()方法或者buffer.compact()方法清除缓冲区

    6)、Buffer案例

    ```java @Test public void test01() {

    1. // 1.分配一个缓冲区,容量设置成10
    2. ByteBuffer buffer = ByteBuffer.allocate(10);
    3. System.out.println(buffer.position()); // 0
    4. System.out.println(buffer.limit()); // 10
    5. System.out.println(buffer.capacity()); // 10
    6. System.out.println("--------------------");
    7. // 2.put()往缓冲区中添加数据
    8. String name = "hello";
    9. buffer.put(name.getBytes());
    10. System.out.println(buffer.position()); // 5
    11. System.out.println(buffer.limit()); // 10
    12. System.out.println(buffer.capacity()); // 10
    13. System.out.println("--------------------");
    14. // 3.flip()为将缓冲区的界限设置为当前位置,并将当前位置重置为0 可读模式
    15. buffer.flip();
    16. System.out.println(buffer.position()); // 0
    17. System.out.println(buffer.limit()); // 5
    18. System.out.println(buffer.capacity()); // 10
    19. System.out.println("--------------------");
    20. // 4.get()数据的读取
    21. char ch = (char) buffer.get();
    22. System.out.println(ch);
    23. System.out.println(buffer.position()); // 1
    24. System.out.println(buffer.limit()); // 5
    25. System.out.println(buffer.capacity()); // 10
    26. System.out.println("--------------------");

    }

```java
    @Test
    public void test02() {
        // 1.分配一个缓冲区,容量设置成10 put()往缓冲区中添加数据
        ByteBuffer buffer = ByteBuffer.allocate(10);
        String name = "hello";
        buffer.put(name.getBytes());
        System.out.println(buffer.position()); // 5
        System.out.println(buffer.limit()); // 10
        System.out.println(buffer.capacity()); // 10
        System.out.println("--------------------");

        // 2.clear()清除缓冲区中的数据 并没有真正清除数据,只是让position的位置恢复到初始位置,后续添加数据的时候才会覆盖每个位置的数据
        buffer.clear();
        System.out.println(buffer.position()); // 0
        System.out.println(buffer.limit()); // 10
        System.out.println(buffer.capacity()); // 10
        System.out.println((char) buffer.get()); // h
        System.out.println("--------------------");

        // 3.定义一个缓冲区
        ByteBuffer buf = ByteBuffer.allocate(10);
        String n = "hello";
        buf.put(n.getBytes());
        buf.flip();
        // 读取数据
        byte[] b = new byte[2];
        buf.get(b);
        System.out.println(new String(b));
        System.out.println(buf.position()); // 2
        System.out.println(buf.limit()); // 5
        System.out.println(buf.capacity()); // 10
        System.out.println("--------------------");

        buf.mark(); // 标记此刻这个位置 2

        byte[] b2 = new byte[3];
        buf.get(b2);
        System.out.println(new String(b2));
        System.out.println(buf.position()); // 5
        System.out.println(buf.limit()); // 5
        System.out.println(buf.capacity()); // 10
        System.out.println("--------------------");

        buf.reset(); // 回到标记位置
        if (buf.hasRemaining()) {
            System.out.println(buf.remaining()); // 3
        }
    }

7)、直接与非直接缓冲区

ByteBuffer可以是两种类型,一种是基于直接内存(也就是非堆内存);另一种是非直接内存(也就是堆内存)。对于直接内存来说,JVM将会在IO操作上具有更高的性能,因为它直接作用于本地系统的IO操作。而非直接内存,也就是堆内存中的数据,如果要作IO操作,会先从本进程内存复制到直接内存,再利用本地IO处理

从数据流的角度,非直接内存是下面这样的作用链:

本地IO—>直接内存—>非直接内存—>直接内存—>本地IO

而直接内存是:

本地IO—>直接内存—>本地IO

很明显,在做IO处理时,比如网络发送大量数据时,直接内存会具有更高的效率。直接内存使用allocateDirect()创建,但是它比申请普通的堆内存需要耗费更高的性能。不过,这部分的数据是在JVM之外的,因此它不会占用应用的内存。所以呢,当有很大的数据要缓存,并且它的生命周期又很长,那么就比较适合使用直接内存。只是一般来说,如果不是能带来很明显的性能提升,还是推荐直接使用堆内存。字节缓冲区是直接缓冲区还是非直接缓冲区可通过调用其isDirect()方法来确定

直接缓冲区使用场景

  • 有很大的数据需要存储,它的生命周期又很长
  • 适合频繁的IO操作,比如网络并发场景