Java IO

514 阅读13分钟

Linux五种IO模型

一次I/O的完成的步骤

当进程发起系统调用时,这个系统调用就进入内核模式,然后开始I/O操作

I/O操作分为两个步骤;

  1. 磁盘把数据装载到内核的内存空间
  2. 内核的内存空间的数据copy到用户的内存空间中(此过程是I/O发生的地方)

对于socket流而言,

  1. 等待网络上的数据分组到达,然后被复制到内核的某个缓冲区
  2. 把数据从内核缓冲区复制到应用进程缓冲区

例如:使用钓鱼来模拟IO过程, 钓鱼操作,是鱼从鱼塘中转移到鱼篓的过程 ;

  1. 拿着鱼竿,等待鱼儿上钩(对应IO的第一阶段)

  2. 鱼儿上钩后,把鱼钓起来放入鱼篓中(对应IO第二阶段)

IO模型

阻塞式IO

上面的例子中,在等待鱼儿上钩的过程中,必须一直等待,什么也不能做,这种就是阻塞式IO;

最常见的IO模型,应用进程被阻塞,直到数据从内核缓冲区复制到应用进程缓冲区中才返回。

BIO

非阻塞式IO

在等待鱼儿上钩的过程中等的不耐烦,可以先去干别的事情,玩一下手机等,然后时不时的去看一下鱼竿,一旦发现有鱼儿上钩了,就把鱼钓上来,这就是轮询机制

应用进程执行系统调用时,返回一个状态码;需要不断的执行系统调用判断IO是否完成,这种方式为轮询(polling)

NIO

IO复用

为了保证钓鱼的成功率,我们会同时摆放多个鱼竿同时钓鱼;我们查看鱼竿状态的时候可以同时查看所有鱼竿的状态,然后把咬钩的鱼儿钓上来,节省时间

首先IO复用必须有多个进程,多个进程调用同一个select/poll,让select/poll进行系统调用(阻塞);这样我们可以把多个IO阻塞集中在一个select中,然后让select或者poll通知进程,执行第二阶段的调用

IO复用

事件(信号)驱动IO

为了避免频繁的查看鱼竿状态带来的时间损失,我们可以为鱼竿安装一个报警装置,当有鱼儿咬钩鱼竿就会报警,听到报警声我们就去把鱼钓起来;这种就是事件驱动;

通过系统调用sigaction 执行一个信号函数(非阻塞,立即返回),当数据准备好,会返回一个信号,通知进程执行第二阶段的调用

IO复用

异步IO

之前的钓鱼方式,最后还要我们自己手工收杆,手工把鱼放入鱼篓,这时候我们发明一种高科技鱼竿,可以自动钓鱼,感应到鱼儿上钩后自动收杆并自动把鱼放入鱼篓,整个第一阶段和第二阶段我们都可以干别的事,不用等待(阻塞),这种就是异步IO

告知内核启动某个操作,内核执行完成后,通知进程IO完成(第一、第二阶段都不阻塞),通过回调函数继续操作

AIO

BIO

BIO就是指IO,即传统的Blocking IO,即同步并阻塞的IO。这也是jdk1.4之前的唯一选择, 依赖于ServerSocket实现,即一个请求对应一个线程,如果线程数不够连接则会等待空余线程,或者拒绝连接

// 服务端
public class BioServer {
    private int port; // 端口号
    private ServerSocket server; // 服务端Socket
    public BioServer(int port)   {
        this.port = port;
        try {
            this.server = new ServerSocket(port);
            log.debug("服务端启动-端口号:{}",port);
        } catch (IOException e) {
            e.printStackTrace();
        }
    } 
    public void listen() throws IOException, InterruptedException {
        // 循环监听
        while (true) {
            // 等待客户端连接,会一直阻塞
            // Socket拿到消息发送者的引用
            // 每次只能处理一个请求
            Socket client = server.accept();
            InputStream is = client.getInputStream();
            byte[] bytes = new byte[1024];
            int len = is.read(bytes);
            if (len > 0) {
                String msg = new String(bytes,0,len);
                log.debug("收到信息:{}",msg);
                Thread.sleep(1000); // 阻塞
            }
        }
    }
    public static void main(String[] args) {
        NewBioServer server = new NeNewBioServer080);
        try {
            server.listen();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}
// 客户端
public class BioClient {
    // 多线程模拟并发
    static ExecutorService pool = Executors.newFixedThreadPool(5);
    public static void main(String[] args) {
        for (int i = 0; i < 5; i++) {
            pool.execute(new SendInfo(i));
        }
        pool.shutdown();
    }
    static class SendInfo implements Runnable {
        private int index;
        public SendInfo(int index) {
            this.index = index;
        }
        @Override
        public void run() {
            try(Socket socket = new Socket("localhost",8080); // 连接到服务端
                OutputStream os = socket.getOutputStream()) {
                String name = this.index + ":" + UUID.randomUUID().toString();
                log.debug("发送数据:{}",name);
                os.write(name.getBytes());
            } catch (UnknownHostException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
客户端同时发送了5条数据
16:26:30 739 发送数据:2:a5521869-118a-477f-8641-c7f7cc7f5190
16:26:30 738 发送数据:0:f58ece26-6e8f-4b1b-9df1-a42f01cf8cd4
16:26:30 738 发送数据:3:3f36bc5b-b402-4088-b04c-c7d693d55410
16:26:30 738 发送数据:1:375643a8-aea5-429e-a46b-46596d52e070
16:26:30 738 发送数据:4:47ca8380-1bed-495c-94de-dbe6247ba1fb
服务端只能处理一条请求,每次阻塞1秒中
16:26:30 742 收到信息:0:f58ece26-6e8f-4b1b-9df1-a42f01cf8cd4
16:26:31 744 收到信息:4:47ca8380-1bed-495c-94de-dbe6247ba1fb
16:26:32 745 收到信息:2:a5521869-118a-477f-8641-c7f7cc7f5190
16:26:33 746 收到信息:3:3f36bc5b-b402-4088-b04c-c7d693d55410
16:26:34 746 收到信息:1:375643a8-aea5-429e-a46b-46596d52e070

根据上面的运行结果,服务器在同一时刻只能处理一个请求,其他的请求会被阻塞,反应到客户端到就是无响应,如何处理高并发呢,引入多线程,并利用线程池管理线程,把每个请求丢给一个线程处理

// 线程池处理IO
public class NewBioServer {
    private int port;
    private ServerSocket server;
    private ExecutorService pool = Executors.newFixedThreadPool(5);
    public NewBioServer(int port)   {
        this.port = port;
        try {
            this.server = new ServerSocket(port);
            log.debug("服务端启动-端口号:{}",port);
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    public void listen() throws IOException, InterruptedException {
        // 循环监听
        while (true) {
            // 等待客户端连接,会一直阻塞
            // Socket拿到消息发送者的引用
            // 用线程池处理每个请求
            Socket client = server.accept();
            pool.execute(new SocketHandler(client)); // 把用户请求丢给线程池
        }
    }
    public static void main(String[] args) {
        NewBioServer server = new NewBioServer(8080);
        try {
            server.listen();
        } catch (IOException e) {
            e.printStackTrace();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
	// 线程处理用户请求
    class SocketHandler implements Runnable {
        private Socket client;
        public SocketHandler(Socket client) {
            this.client = client;
        }
        @Override
        public void run() {
            try {
                InputStream is = client.getInputStream();
                byte[] bytes = new byte[1024];
                int len = is.read(bytes);
                if (len > 0) {
                    String msg = new String(bytes,0,len);
                    log.debug("线程{}->收到信息:{}",Thread.currentThread().getName(),msg);
                    Thread.sleep(1000);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }
}
16:40:17 210 发送数据:1:d122969a-15cb-4cf2-9bf8-04be9493b5a3
16:40:17 210 发送数据:3:966f0d1d-572d-40d9-ba50-5c413efbc6dc
16:40:17 210 发送数据:4:756eaca9-1c05-4823-bac7-62dfefc16e6a
16:40:17 210 发送数据:0:40a35cac-7144-4c4c-b061-0426c61deaca
16:40:17 210 发送数据:2:7b1fb857-3d7f-41bb-9d40-c1fa17cdea0e
// 多线程下,请求不会阻塞
16:40:17 213 线程pool-1-thread-4 ->收到信息:1:d122969a-15cb-4cf2-9bf8-04be9493b5a3
16:40:17 213 线程pool-1-thread-5 ->收到信息:4:756eaca9-1c05-4823-bac7-62dfefc16e6a
16:40:17 214 线程pool-1-thread-3 ->收到信息:3:966f0d1d-572d-40d9-ba50-5c413efbc6dc
16:40:17 214 线程pool-1-thread-2 ->收到信息:0:40a35cac-7144-4c4c-b061-0426c61deaca
16:40:17 214 线程pool-1-thread-1 ->收到信息:2:7b1fb857-3d7f-41bb-9d40-c1fa17cdea0e

可以看到多线程下,用户请求不会阻塞了,但是这种方式没有从根本是解决IO阻塞的问题, 而且会引发一些问题

  • 如果客户端网速很慢,服务端对应线程在读取请求文本时会阻塞很长时间,造成返回信息缓慢

  • 如果所有的线程都被IO阻塞,新的请求会进入阻塞队列,如果阻塞队列满了,无法接受新的请求

阻塞的问题根源

服务器线程发起一个accept动作,询问操作系统 是否有新的socket套接字信息从端口xx发送过来。

注意,是询问操作系统。也就是说socket套接字的IO模式支持是基于操作系统的

如果操作系统没有发现有套接字从指定的端口xx来,那么操作系统就会等待。这样serverSocket.accept()方法就会一直等待。这就是为什么accept()方法为什么会阻塞:它内部的实现是使用的操作系统级别的同步IO。

详细说明

NIO

BIO 与NIO 最重要的区别是数据打包和传输的方式,BIO 以流的方式处理数据,而NIO 以块的方式处理数据。

1.缓存区Buffer

在NIO中,所有的数据都是用缓存区处理的,缓冲区实质上是一个数组,最常用的是ByteBuffer;在NIO读取数据时,数据会先写入到缓存区,而不是直接读取数据流(读取数据流会因为网络等因素使线程阻塞);

缓冲区状态变量 capacity:最大容量; position:当前已经读写的字节数; limit:还可以读写的字节数。

2.通道Channel

Channel是一个通道,可以通过它来读取和写入数据;

通道是双向的,可以读、写或者同时读写,而流操作InputStream、OutputStream只能单向操作

3.多路复用器Selector

Selector会不断轮询注册在其上的Channel,如果某个Channel有新的读写事件,Channel的状态会发生变化,变为就绪状态,Selector轮询会发现,然后通过SelectionKey获取就绪Channel集合,然后进行IO操作

NIO进行服务端开发的步骤。

  1. 创建ServerSocketChannel, 配置它为非阻塞模式;
  2. 绑定监听,配置TCP参数,例如backlog大小;
  3. 创建一个独立的I/O线程,用于轮询多路复用器Selector;
  4. 创建Selector, 将之前创建的ServerSocketChannel 注册到Selector 上,监听SelectionKey.ACCEPT;
  5. 启动I/O线程,在循环体中执行Selector.select() 方法, 轮询就绪的Channel;
  6. 当轮询到了处于就绪状态的Channel时,需要对其进行判断,如果是OP_ ACCEPT 状态,说明是新的客户端接入,则调用ServerSocketChannel.accept() 方法接受新的客户端;
  7. 设置新接入的客户端链路SocketChannel为非阻塞模式,配置其他的一些TCP参数;
  8. 将SocketChannel注册到Selector, 监听OP_ READ操作位;
  9. 如果轮询的Channel为OP__READ,则说明SocketChannel中有新的就绪的数据包需要读取,则构造ByteBuffer对象,读取数据包:

AIO

异步IO则采用“订阅-通知”模式:即应用程序向操作系统注册IO监听,然后继续做自己的事情。当操作系统发生IO事件,并且准备好数据后,在主动通知应用程序,触发相应的函数

  • 和同步IO一样,异步IO也是由操作系统进行支持的。微软的windows系统提供了一种异步IO技术:IOCP(I/O CompletionPort,I/O完成端口);
  • Linux下由于没有这种异步IO技术,所以使用的是epoll(上文介绍过的一种多路复用IO技术的实现)对异步IO进行模拟。

BIO与NIO、AIO的区别

同步和异步

同步:调用者(客户端)发起一个请求后,被调用者(服务器)如果未处理完请求之前,请求都不会返回;

异步:调用者(客户端)发起一个请求后,立刻得到被调用者(服务器)的回应表示已接收到请求,但是被调用者并没有返回结果,此时我们可以处理其他的请求,被调用者通常依靠事件驱动,回调等机制来通知调用者取返回结果。

阻塞和非阻塞

阻塞:调用者发起一个请求后,需要一直等待请求结果返回,当前线程会被阻塞(挂起),无法执行别的任务,只有条件就绪才能继续运行

非阻塞:调用者发起一个请求后,不需要一直等待,可以先去执行别的任务

用例子理解一下概念,以银行取款为例:

  • 同步:自己亲自出马持银行卡到银行取钱(使用同步IO时,Java自己处理IO读写);
  • 异步:委托一小弟拿银行卡到银行取钱,然后给你(使用异步IO时,Java将IO读写委托给OS处理,需要将数据缓冲区地址和大小传给OS(银行卡和密码),OS需要支持异步IO操作API);
  • 阻塞:ATM排队取款,你只能等待(使用阻塞IO时,Java调用会一直阻塞到读写完成才返回);
  • 非阻塞:柜台取款,取个号,然后坐在椅子上做其它事,等号广播会通知你办理,没到号你就不能去,你可以不断问大堂经理排到了没有,大堂经理如果说还没到你就不能去(使用非阻塞IO时,如果不能读写Java调用会马上返回,当IO事件分发器会通知可读写时再继续进行读写,不断循环直到读写完成)
属性 同步阻塞IO(BIO) 伪异步IO 非阻塞IO(NIO) 异步IO(AIO)
客户端数:IO 线程数 1:1 M:N(M>=N) M:1 M:0
阻塞类型 阻塞 阻塞 非阻塞 非阻塞
同步 同步 同步 同步(多路复用) 异步
API 使用难度 简单 简单 复杂 一般
调试难度 简单 简单 复杂 复杂
可靠性 非常差
吞吐量

参考资料