JavaIO、网络IO模型

171 阅读10分钟

最近在学习Netty 就顺便复习一下IO和自己对几种网络IO模型的理解

JavaIO

IO是指Input/Output,即输入和输出。以内存为中心:

  • Input指从外部读入数据到内存,例如,把文件从磁盘读取到内存,从网络读取数据到内存等等。
  • Output指把数据从内存输出到外部,例如,把数据从内存写入到文件,把数据从内存输出到网络等等。

Stream Stream 是数据序列。在 Java 中,Stream由字节(byte)组成。 IO流是一种顺序读写数据的模式,特点是单向流动,数据类似自来水一样在水管中流动,因此叫做IO流。

InputStream/OutputStream

InputStream 代表的是输入字节流,OutputStream 代表的是输出字节流。是最基本的两种IO流。这两种IO流是以字节(byte)为最小单位,因此也称为字节流。其工作原理如下如

image.png

OutputStream

OutputStream 类是一个抽象类。它是表示输出字节流的所有类的超类。输出流接受输出字节并将其发送到某个接收器。 以下是该类的层次结构图。 image.png 该超类涉及以下方法

方法描述
public void write(int)throws IOException用于将字节写入当前输出流。
public void write(byte[])throws IOException用于将字节数组写入当前输出。
public void flush()throws IOException刷新当前输出流。
public void close()throws IOException用于关闭当前输出流。

InputStream InputStream 类是一个抽象类。它是表示输入字节流的所有类的超类。

image.png

该超类涉及以下方法

方法描述
public abstract int read()throws IOException从输入流中读取数据的下一个字节。它在文件末尾返回 -1。
public int available()throws IOException返回可从当前输入流读取的字节数的估计值。
public void close()throws IOException用于关闭当前输入流。

Writer/Reader

如果需要读写的是字符,并且字符不全是单字节表示的ASCII字符,那么,按照char来读写显然更方便,这种流称为字符流

Java提供了ReaderWriter表示字符流,字符流传输的最小数据单位是char

例如,我们把char[]数组Hi你好这4个字符用Writer字符流写入文件,并且使用UTF-8编码,得到的最终文件内容是8个字节,英文字符Hi各占一个字节,中文字符你好各占3个字节:

0x48
0x69
0xe4bda0
0xe5a5bd

反过来,我们用Reader读取以UTF-8编码的这8个字节,会从Reader中得到Hi你好这4个字符。
因此,ReaderWriter本质上是一个能自动编解码的InputStreamOutputStream
使用Reader,数据源虽然是字节,但我们读入的数据都是char类型的字符,原因是Reader内部把读入的byte做了解码,转换成了char。使用InputStream,我们读入的数据和原始二进制数据一模一样,是byte[]数组,但是我们可以自己把二进制byte[]数组按照某种编码转换为字符串。究竟使用Reader还是InputStream,要取决于具体的使用场景。如果数据源不是文本,就只能使用InputStream,如果数据源是文本,使用Reader更方便一些。WriterOutputStream是类似的。

上面篇幅中IO读写主要是数据序列在同一台机器上流转的过程即面对文件的编程。硬盘、内存中数据读取到我们的程序中或者通过读取其他地方的数据写入到磁盘中。机器与机器之间通信也是通过网络IO的方式进行通信。一台机器中数据如果想传输到另一台机器上,首先这个两个机器需要通过网络协议进行握手建立连接,连接成功后就开始开始传输数据。应用程序会通过约定好的网络协议将数据和其他协议头进行打包通过网卡发送出去。接着数据会经过路由器->交换机->网线(光纤)->运营商->网线(光纤)->路由器->网卡->另外一台机器等阶段的传输。当然这里就涉及了网络编程相关的知识了。网络数据的连接、数据传输可以阅读《网络是怎么连接的》 这本书。

网络IO

网络IO 个人理解就是不同服务器之间的通信。网络IO的硬件基础是网卡,数据到达网卡之后,最终会到达应用程序的内存。因为程序是无法方法网卡的,所以需要借助操作系统做中转解析。在操作系统的IO主要是通过字节数据来对数据进行封装和传输。

常见的网络IO模型 同步阻塞、同步非阻塞、同步多路复用、异步阻塞、异步非阻塞。

在《unix网络编程-卷I》 中,有介绍这几种IO模型。

在操作系统中一个输入(输出)操作通常包含两个不同的阶段

  • 等待数据准备好
  • 从内核向进程复制数据

如图所示:

image.png

同步阻塞I/O

结合数据在操作系统中数据流转两个阶段,这里的阻塞是指用户进程在系统调用时,如果没有连接和数据可读,用户进程在不停的轮询文件描述符,而系统调用(read())则处于睡眠状态,直至网络中有数据传输过来唤醒系统调用。同步则代表同一时间内这个用户线程只能处理一个网络连接事件,如果有多个网络连接事件则需要等待。具体流程如图:

image.png

这里就是java中传统的网络模型 BIO。在用户程序调用accept()方法处于一个阻塞的状态。直到有数据可以接受、写入。

代码体现

客户端代码

package com.chou.normal.nio.netio.block;

import com.chou.utils.ByteBufferUtil;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;

/**
 * @Author Chou
 * @Description BIO客户端
 * @ClassName Server
 * @Date 2023/6/11 13:53
 * @Version 1.0
 **/
@Slf4j
public class Server {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        ArrayList<SocketChannel> socketChannels = new ArrayList<>();
        ByteBuffer buffer = ByteBuffer.allocate(16);
        while (true) {
            log.debug("connecting ... ");
            // accept() 是个阻塞方法,如果没有客户端连接过来,会一直等待阻塞在这里
            // 可以使用configureBlocking() 来设置是否阻塞模式 默认true
            SocketChannel sc = ssc.accept();
            log.debug("connected ...{}",sc);
            socketChannels.add(sc);
            for (SocketChannel channel : socketChannels) {
                log.debug("before read ...");
                // read() 方法和accept() 方法同理,
                int read = channel.read(buffer);
                if (read > 0) {
                    buffer.flip();
                    ByteBufferUtil.debugAll(buffer);
                    buffer.clear();
                    log.debug("after read ...");
                }
            }
        }
    }
}

同步非阻塞I/O

这里的同步和同步阻塞模型中的同步一个意思,主要在于非阻塞,这里的非阻塞指用户进程把sockets设置成非阻塞时在通知内存空间,当所有请求的I/O操作非得把本进程投入睡眠才能完成时,不要把本进程投入睡眠,而是返回一个错误。即不阻塞系统调用这个线程。

image.png

图中在等待数据时,系统调用线程是处于非阻塞状态,当然在复制数据时进程是处于阻塞状态的。所以这里的阻塞和非阻塞通常指的是对于网络连接的处理。客户端代码

代码体现

服务器代码

package com.chou.normal.nio.netio.unblock;

import com.chou.utils.ByteBufferUtil;
import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.ArrayList;
import java.util.Objects;

/**
 * @Author Chou
 * @Description 
 * @ClassName Server
 * @Date 2023/6/11 13:53
 * @Version 1.0
 **/
@Slf4j
public class UnBlockServer {
    public static void main(String[] args) throws IOException {
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.bind(new InetSocketAddress(8080));
        // 设置channel 为非阻塞模式
        ssc.configureBlocking(false);
        ArrayList<SocketChannel> socketChannels = new ArrayList<>();
        ByteBuffer buffer = ByteBuffer.allocate(16);
        while (true) {
            log.debug("connecting ... ");
            // accept() 是个阻塞方法,如果没有客户端连接过来,会一直等待阻塞在这里
            // 可以使用configureBlocking() 来设置是否阻塞模式 默认true
            if (Objects.nonNull(ssc)){
                SocketChannel sc = ssc.accept();
                // 设置成非阻塞模式
                sc.configureBlocking(true);
                log.debug("connected ...{}",sc);
                socketChannels.add(sc);
            }

            for (SocketChannel channel : socketChannels) {
                log.debug("before read ...");
                // read() 方法和accept() 方法同理,
                int read = channel.read(buffer);
                if (read > 0) {
                    buffer.flip();
                    ByteBufferUtil.debugAll(buffer);
                    buffer.clear();
                    log.debug("after read ...");
                }
            }
        }
    }
}

客户端代码

package com.chou.normal.nio.netio.unblock;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;

/**
 * @Author Chou
 * @Description 
 * @ClassName Client
 * @Date 2023/6/11 20:41
 * @Version 1.0
 **/
public class UnBlockClient {
    public static void main(String[] args) throws IOException {
        SocketChannel sc = SocketChannel.open();
        sc.connect(new InetSocketAddress("127.0.0.1",8080));
        System.out.println("waiting ...");
    }
}

比较下同步阻塞模式和同步非阻塞模式,后者会耗费大量的CPU时间,因为它需要循环的去调用read()方法、持续轮询内核,即不停的切换用户和内核态。

同步I/O多路复用

多路复用机制中加入了Selector角色。在NIO对多路复用的实现中,Selector的作用主要是用来监听多个Channel或者说多个Socket连接,从而使得程序可以复用一个线程。目前支持I/O多路复用的系统调用有select,pselect,poll,epoll,在不同的操作系统中对多路复用有着不同的实现。Java中主要是借用NIO(非阻塞I/O)来对多路复用机制进行实现。

image.png

多路复用也是使用单个线程来处理网络连接,也有阻塞的部分,分别在select()和read()两个阶段被阻塞了。和同步阻塞岂不是一样? 个人看来区别还是在select()方法上,虽然是阻塞的但是它可以监听注册在Selector中的多个Channel 事件,如果有多个channel连接来到了服务器上,selector会全部接受下来,然后根据channel的事件类型分别的去处理。而不是像阻塞I/O那样需要等前面的事件处理完成才能处理下一个事件。(在select()后对事件的处理需要看底层操作系统对多路复用的实现?)

代码体现

客户端

package com.chou.normal.nio.netio.iomultiplexing;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.channels.SocketChannel;

/**
 * @Author Chou
 * @Description TODO
 * @ClassName Client
 * @Date 2023/6/11 20:41
 * @Version 1.0
 **/
public class Client {
    public static void main(String[] args) throws IOException {
        SocketChannel sc = SocketChannel.open();
        sc.connect(new InetSocketAddress("127.0.0.1",8000));
        System.in.read();
        System.out.println("waiting ...");
    }
}

服务端

package com.chou.normal.nio.netio.iomultiplexing;

import lombok.extern.slf4j.Slf4j;

import java.io.IOException;
import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.util.Iterator;
import java.util.Set;

import static com.chou.utils.ByteBufferUtil.debugAll;

/**
 * @Author Chou
 * @Description 处理消息边界问题(消息长度和服务设置byteBuffer 设置的长度不匹配 产生粘包/半包问题),采用http 的 ltv 的方式,处理消息边界问题
 *
 * @ClassName IoMuPlexServerMsgBoundaries
 * @Date 2023/6/13 23:05
 * @Version 1.0
 **/
@Slf4j
public class IoMuPlexServerMsgBoundaries {
    public static void main(String[] args) throws IOException {
        // 创建一个selector 来管理多个 channel
        Selector selector = Selector.open();

        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        ssc.bind(new InetSocketAddress(8000));
        // channel 与 selector 建立联系。将channel 注册到 selector中
        // 注册之后会返回一个selectionKey 用于监听selector和channel 之间的事件
        SelectionKey selectionKey = ssc.register(selector, 0, null);
        // 设置key 关注的事件
        // 场件的事件有一下几种:
        // accept 会有连接请求时触发
        // connect 是客户端,连接建立后触发
        // read 可读事件
        // write 可写事件
        selectionKey.interestOps(SelectionKey.OP_ACCEPT);
        log.debug("register key ... {}", selectionKey);
        while (true) {
            // select 没有事件 是阻塞的,有事件才会恢复运行
            // select 在时间处理事,让不会阻塞,事件发生后需要处理或者调用cancel() 方法取消,不能置之不理。
            selector.select();
            // 获取所有的可用时间并返回一个集合(包含了所有发生的事件)
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = keys.iterator();
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                log.debug("key {}", key);
                // 区分事件类型
                if (key.isAcceptable()) {
                    // 如果是 accept 接受事件
                    ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                    SocketChannel sc = channel.accept();
                    sc.configureBlocking(false);
                    // 给每个socket中的事件设置一个独有的ByteBuffer,处理当前channel 的数据
                    ByteBuffer buffer = ByteBuffer.allocate(16);
                    SelectionKey scKey = sc.register(selector, 0, buffer);
                    scKey.interestOps(SelectionKey.OP_READ);
                    log.debug("{}", sc);
                } else if (key.isReadable()) {
                    //如果是 read 可读事件
                    try {
                        SocketChannel channel = (SocketChannel) key.channel();
                        ByteBuffer buffer = (ByteBuffer) key.attachment();
                        int read = channel.read(buffer);
                        if (read == -1) {
                            // 处理客户端正常断开后 read 事件完成,读取完数据cancel 事件
                            key.cancel();
                            channel.close();
                        } else {
                            split(buffer);
                            if (buffer.position() == buffer.limit()){
                                ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity() * 2);
                                buffer.flip();
                                newBuffer.put(buffer);
                                key.attach(newBuffer);
                            }
                        }
                    } catch (IOException e) {
                        //防止客户端非正常断开发生读事件导致客户端报错(水平触发)
                        e.printStackTrace();
                        key.cancel();
                    }

                }
                // 取消监听的key事件,nio 采用的是水平触发当事件状态没有改变时,select处于非阻塞状态
                //key.cancel();
                // 处理key 是需要将key 从selectedKeys 集合中删除,否则下次处理会出问题
                iterator.remove();
            }
        }
    }
    /**
     * 解决读取消息产生粘包问题
     * 网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔
     * 但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为
     * - Hello,world\n
     * - I'm zhangsan\n
     * - How are you?\n
     * 变成了下面的两个 byteBuffer (黏包,半包)
     * - Hello,world\nI'm zhangsan\nHo
     * - w are you?\n
     * @param source
     */
    public static void split(ByteBuffer source){
        // 切换成读模式
        source.flip();
        // 获取bytebuffer的读取限制limit,
        int oldLimit = source.limit();
        // 循环读取限制
        for (int i = 0; i < oldLimit; i++) {
            // 判断\n 位置
            if (source.get(i) == '\n') {
                //如果当前读到了\n位置把读取数据存入新建一个新的bytebuffer
                // 新byteBuffer 计算新的容量,
                ByteBuffer target = ByteBuffer.allocate(i + 1 - source.position());
                // 获取通过limit 获取新的byteBuffer
                ByteBuffer saveBuffer = (ByteBuffer) source.limit(i + 1);
                target.put(saveBuffer);
                debugAll(target);
                // 重新设置有内容数据为原始数据
                source.limit(oldLimit);
            }
        }
        // 将未读取的数据压缩
        source.compact();
    }
}

信号驱动

当进程发起一个IO操作,会向内核注册一个信号处理函数,然后进程返回不阻塞;当内核数据就绪时会发送一个信号给进程,进程便在信号处理函数中调用IO读取数据。

image.png

异步非阻塞(异步I/O)

首先来了解一下同步和异步的差异

  • 同步:线程自己去获取结果
  • 异步:线程自己不去获取结果,而是有其他线程发送结果(至少两个线程)

当用户进程发起一个IO操作,进程返回(不阻塞),但也不能返回结果;内核把整个IO处理完后,会通知进程结果。如果IO操作成功则进程直接获取到数据。

image.png

根据以上几种网络模型,异步阻塞概念有点说不通,个人理解异步使用了多线程,程序就不会被阻塞租了。

以下是五种网络I/O模型的比较

image.png