BIO、NIO和AIO

一.引言

何为IO

涉及计算机核心(CPU和内存)与其他设备间数据迁移的过程,就是I/O。数据输入到计算机内存 的过程即输入 ,反之输出到外部存储(比如数据库,文件,远程主机) 的过程即输出I/O 描述了计算机系统与外部设备之间通信的过程。

  • 磁盘I/O
    • 输入:就是从磁盘读取数据到内存
    • 输出:将内存中的数据写入磁盘
  • 网络I/O
    • 输入:从网络中的另一台计算机或服务器获取数据,并将其加载到本地内存中
    • 输出:将本地内存中的数据发送到网络中的其他计算机或服务器

IO的过程

根据大学里学到的操作系统相关的知识:为了保证操作系统的稳定性和安全性,一个进程的地址空间划分为 用户空间(User space)内核空间(Kernel space )

像我们平常运行的应用程序 都是运行在用户空间 ,只有内核空间 才能进行系统态级别的资源有关的操作 ,比如文件管理、进程通信、内存管理 等等,因为这些都是比较危险的操作,不可以由应用程序乱来,只能交给底层操作系统来。也就是说,我们想要进行 IO 操作,只能发起系统调用 请求操作系统 来间接访问内核空间

我们在平常开发过程中接触最多的就是 磁盘 IO(读写文件)网络 IO(网络请求和响应)

应用程序的视角来看的话,我们的应用程序对操作系统的内核发起 IO 调用(系统调用),操作系统负责的内核执行具体的 IO 操作。也就是说,我们的应用程序实际上只是发起了 IO 操作的调用而已,具体 IO 的执行是由操作系统的内核来完成的。

当应用程序发起 I/O 调用后,会经历两个步骤(IO执行):

  1. 数据准备 :内核等待 I/O 设备准备好数据**,** 即操作系统将外部数据加载到内核缓冲区
  2. 数据拷贝 :内核将数据从内核缓冲区 拷贝到用户进程缓冲区

Java的3种网络IO模型

Java中提供的IO有关的API,也是依赖操作系统层面的IO操作实现的。在Java中,主要有三种IO模型,分别是阻塞IO(BIO)、非阻塞IO(NIO)和 异步IO(AIO)。

可以把Java中的BIO、NIO和AIO理解为是Java语言对操作系统的5种IO模型的封装(在Linux(UNIX)操作系统中,共有五种IO模型,分别是:阻塞IO模型、非阻塞IO模型、IO复用模型、信号驱动IO模型以及异步IO模型)。程序员在使用这些API的时候,不需要关心操作系统层面的知识,也不需要根据不同操作系统编写不同的代码。只需要使用Java的API就可以了。

阻塞和非阻塞IO

上面已经说过,应用程序的IO实际是分为两个步骤,IO调用和IO执行。IO调用是由进程发起,IO执行是操作系统的工作。操作系统的IO情况决定了进程IO调用是否能够得到立即响应。

  • 阻塞IO:如果操作系统尚未准备好数据,当前进程或线程一直等待直到其就绪
  • 非阻塞IO:如果操作系统尚未准备好数据,进程或线程并不一直等待其就绪,而是可以做其他事情。进程/线程会周期性地轮询或查询IO操作的状态,以确定数据是否就绪。

非阻塞IO 需要进程/线程自己负责查询IO状态 ;而阻塞IO 则是操作系统负责 在数据就绪时唤醒进程/线程。

异步和同步IO

  • 同步IO:同步IO是指程序发起IO操作后,程序会一直等待直到IO操作完成,然后再继续执行后续的代码。
  • 异步IO:异步IO是指程序发起IO操作后,它可以继续执行其他任务,而不必等待IO操作完成。当IO操作完成后,程序会得到通知,可以处理已完成的IO操作。异步IO可以提高程序的并发性和响应性,因为它允许程序在等待IO的同时执行其他任务。

自己的理解:我感觉阻塞和非阻塞IO 针对的是操作系统未准备好数据时 进程的处理方式,是等待还是不等待。异步和同步IO针对的是**IO操作未完成时(IO操作包括数据准备和数据拷贝两步骤)**进程的处理方式,是等待还是不等待。

二.BIO

  • Java BIO 就是传统的 java io 编程,其相关的类和接口在 java.io
  • BIO(blocking I/O) :同步阻塞 IO 模型 ,即在读写数据过程中会发生阻塞现象,直至
    有可供读取的数据或者数据能够写入。
  • 服务器实现模式为 一个连接一个线程,即客户端有连接请求时服务器端就需 要启动一个线程进行处理,如果这个连接不做任何事情会造成不必要的线程开销,可以通过线程池机制改善(实现多个客户连接服务器)

映射到Linux操作系统中,这就是一种最简单的IO模型,即阻塞IO。 阻塞 I/O 是最简单的 I/O 模型,一般表现为进程或线程等待某个条件,如果条件不满足,则一直等下去。条件满足,则进行下一步操作。

BIO客户端、服务端通信实现

Server 服务端

java 复制代码
/**
    目标:实现服务端可以同时接收多个客户端的Socket通信需求。
    思路:是服务端每接收到一个客户端socket请求对象之后都交给一个独立的线程来处理客户端的数据交互需求。
 */
public class Server {
    public static void main(String[] args) {
        try {
            // 1、注册端口
            ServerSocket ss = new ServerSocket(9999);
            // 2、定义一个死循环,负责不断的接收客户端的Socket链接请求
            while(true){
                Socket socket = ss.accept();
                // 3、创建一个独立的线程来处理与这个客户端的socket通信需求。
                new ServerThreadReader(socket).start();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

ServerThreadReader 服务端与客户端保持通信的线程

java 复制代码
public class ServerThreadReader extends Thread {
    private Socket socket;
    public ServerThreadReader(Socket socket){
        this.socket = socket;
    }

    @Override
    public void run() {
        try {
            // 从socket对象中得到一个字节输入流
            InputStream is = socket.getInputStream();
            // 使用缓冲字符输入流包装字节输入流
            BufferedReader br = new BufferedReader(new InputStreamReader(is));
            String msg;
            while((msg = br.readLine())!=null){
                System.out.println(msg);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

Client 客户端

java 复制代码
/**
    客户端
 */
public class Client {
    public static void main(String[] args) {
        try {
            // 1、请求与服务端的Socket对象链接
            Socket socket = new Socket("127.0.0.1" , 9999);
            // 2、得到一个打印流
            PrintStream ps = new PrintStream(socket.getOutputStream());
            // 3、使用循环不断的发送消息给服务端接收
            Scanner sc = new Scanner(System.in);
            while(true){
                System.out.print("请说:");
                String msg = sc.nextLine();
                ps.println(msg);
                ps.flush();
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

三.NIO

Java NIO(non-blocking)是从Java 1.4版本开始引入的一个新的IO API,NIO 相关类都被放在 java.nio 包及子包下,并且对原 java.io 包中的很多类进行改写,可以替代标准的Java IO API。NIO与原来的IO有同样的作用和目的,但是使用的方式完全不同,NIO支持面向缓冲区 的、基于通道的IO操作。NIO将以更加高效的方式进行读写操作。

Java NIO(non-blocking) 映射的不是操作系统五大IO模型中的NIO模型(采用轮询的方式检查IO状态),而是另外的一种模型,叫做IO多路复用模型( IO multiplexing )。

IO复用模型核心思路: 系统给我们提供一类函数(如我们耳濡目染的select、 poll、epoll函数),它们可以同时监控多个 fd 的操作,任何一个返回内核数据就绪,应用进程再发起recvfrom系统调用。

文件描述符fd(File Descriptor)它是计算机科学中的一个术语,形式上是一个非负整数。当程序打开一个现有文件或者创建一个新文件时,内核向进程返回一个文件描述符.

目前支持 IO 多路复用的系统调用,有 select,epoll 等等。select 系统调用,目前几乎在所有的操作系统上都有支持。

  • select 调用:内核提供的系统调用,它支持一次查询多个系统调用的可用状态。几乎所有的操作系统都支持。
  • epoll 调用:属于 select 调用的增强版本,优化了 IO 的执行效率

Java 中的 NIO ,有一个非常重要的选择器 ( Selector ) 的概念,也可以被称为 多路复用器。通过它,只需要一个线程便可以管理多个客户端连接。当客户端数据到了之后,才会为其服务。

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

1. 三大组件

Channel & Buffer

channel 有一点类似于 流,它就是读写数据的双向通道 ,可以从 channel 将数据读入 buffer,也可以将 buffer 的数据写入 channel,而之前的 流 要么是输入,要么是输出,channel 比 流 更为底层

常见的 Channel 有

  • FileChannel (文件):从文件中读写数据。
  • DatagramChannel (UDP):能通过 UDP 读写网络中的数据。
  • SocketChannel(TCP Client):能通过 TCP 读写网络中的数据。
  • ServerSocketChannel(TCP Server):可以监听新进来的 TCP 连接,像 Web 服务器那样。对每一个新进来的连接都会创建一个 SocketChannel

buffer 则用来缓冲读写数据,常见的 buffer 有

  • ByteBuffer(用的最多
    • MappedByteBuffer
    • DirectByteBuffer
    • HeapByteBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer
  • CharBuffer

Selector

selector 的作用就是配合一个线程来管理多个 channel,获取这些 channel 上发生的事件,这些 channel 工作在非阻塞模式下,不会让线程吊死在一个 channel 上。适合连接数特别多,但流量低的场景(low traffic)

调用 selector 的 select() 会阻塞直到 channel 发生了读写就绪事件,这些事件发生,select 方法就会返回这些事件交给 thread 来处理

2.ByteBuffer

2.1ByteBuffer的使用

  1. 向 buffer 写入数据,例如调用 channel.read(buffer)
  2. 调用 flip() 切换至读模式
  3. 从 buffer 读取数据,例如调用 buffer.get()
  4. 调用 compact() 或 clear() 切换至**写模式,**compact()会自动压缩未读的,clear()则会直接清空
  5. 一次可能读不完,重复 1~4 步骤读,
java 复制代码
@Slf4j
public class TestByteBuffer {
    public static void main(String[] args) {
        // FileChannel 获得方式
        // 1. 输入输出流, 2. RandomAccessFile
        try (FileChannel channel = new RandomAccessFile("D:\\data.txt", "rw").getChannel()) {
            // 准备缓冲区,指定容量后不可更改
            ByteBuffer buffer = ByteBuffer.allocate(10);
            while(true) {
                // 从 channel 读取数据,向 buffer 写入
                int len = channel.read(buffer);
                log.debug("读取到的字节数 {}", len);
                if(len == -1) { // 没有内容了
                    break;
                }
                // 打印 buffer 的内容
                buffer.flip(); // 切换至读模式
                while(buffer.hasRemaining()) { // 是否还有剩余未读数据
                    byte b = buffer.get();//get()会改变读指针,但get(i)不会,直接根据索引查找位置
                    log.debug("实际字节 {}", (char) b);
                }
                buffer.clear(); // 切换为写模式
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

2.2ByteBuffer 结构

ByteBuffer的结构可以看成一个连续的数组,有以下重要属性

  • capacity:容量
  • position:起始位置
  • limit:写入/读取限制位置

一开始

写模式下,position 是写入位置,limit 等于容量,下图表示写入了 4 个字节后的状态

flip 动作发生后,position 切换为读取位置,limit 切换为读取限制

读取 4 个字节后,状态

clear 动作发生后,状态

compact 方法,是把未读完的部分向前压缩,然后切换至写模式

2.3ByteBuffer的常用方法

分配空间

分配容量后就不可修改

java 复制代码
ByteBuffer byteBuffer1 = ByteBuffer.allocate(容量);//class java.nio.HeapByteBuffer
ByteBuffer byteBuffer2 = ByteBuffer.allocateDirect(容量);//class java.nio.DirectByteBuffer

两种方法返回的实现类不同:

  • HeapByteBuffer:分配在 java 堆内存,读写效率较低,受到 GC(垃圾回收) 的影响
  • DirectByteBuffer:通过调用本地操作系统 的内存管理机制来分配堆外内存,读写效率高(不需要通过额外的复制操作将数据从堆内存复制到物理内存),不会受 GC 影响,但分配的效率低,并且如果释放不完全会造成内存泄漏

向 buffer 写入数据

有两种办法

  • 调用 channel 的 read 方法
  • 调用 buffer 自己的 put 方法

从 buffer 读取数据

同样有两种办法

  • 调用 channel 的 write 方法
  • 调用 buffer 自己的 get 方法

get 方法会让 position 读指针向后走,如果想重复读取数据

  • 可以调用 rewind 方法将 position 重新置为 0
  • 或者调用 get(int i) 方法获取索引 i 的内容,它不会移动读指针

字符串与 ByteBuffer 互转

两种方法:

java 复制代码
ByteBuffer buffer1 = StandardCharsets.UTF_8.encode("你好");
ByteBuffer buffer2 = Charset.forName("utf-8").encode("你好");

debug(buffer1);
debug(buffer2);

CharBuffer buffer3 = StandardCharsets.UTF_8.decode(buffer1);
System.out.println(buffer3.getClass());
System.out.println(buffer3.toString());

Buffer 是非线程安全的

分散读取、集中写入

2.4调试工具类

netty依赖

XML 复制代码
        <dependency>
            <groupId>io.netty</groupId>
            <artifactId>netty-all</artifactId>
            <version>4.1.51.Final</version>
        </dependency>
java 复制代码
import io.netty.util.internal.StringUtil;

import java.nio.ByteBuffer;

import static io.netty.util.internal.MathUtil.isOutOfBounds;
import static io.netty.util.internal.StringUtil.NEWLINE;


public class ByteBufferUtil {
    private static final char[] BYTE2CHAR = new char[256];
    private static final char[] HEXDUMP_TABLE = new char[256 * 4];
    private static final String[] HEXPADDING = new String[16];
    private static final String[] HEXDUMP_ROWPREFIXES = new String[65536 >>> 4];
    private static final String[] BYTE2HEX = new String[256];
    private static final String[] BYTEPADDING = new String[16];

    static {
        final char[] DIGITS = "0123456789abcdef".toCharArray();
        for (int i = 0; i < 256; i++) {
            HEXDUMP_TABLE[i << 1] = DIGITS[i >>> 4 & 0x0F];
            HEXDUMP_TABLE[(i << 1) + 1] = DIGITS[i & 0x0F];
        }

        int i;

        // Generate the lookup table for hex dump paddings
        for (i = 0; i < HEXPADDING.length; i++) {
            int padding = HEXPADDING.length - i;
            StringBuilder buf = new StringBuilder(padding * 3);
            for (int j = 0; j < padding; j++) {
                buf.append("   ");
            }
            HEXPADDING[i] = buf.toString();
        }

        // Generate the lookup table for the start-offset header in each row (up to 64KiB).
        for (i = 0; i < HEXDUMP_ROWPREFIXES.length; i++) {
            StringBuilder buf = new StringBuilder(12);
            buf.append(NEWLINE);
            buf.append(Long.toHexString(i << 4 & 0xFFFFFFFFL | 0x100000000L));
            buf.setCharAt(buf.length() - 9, '|');
            buf.append('|');
            HEXDUMP_ROWPREFIXES[i] = buf.toString();
        }

        // Generate the lookup table for byte-to-hex-dump conversion
        for (i = 0; i < BYTE2HEX.length; i++) {
            BYTE2HEX[i] = ' ' + StringUtil.byteToHexStringPadded(i);
        }

        // Generate the lookup table for byte dump paddings
        for (i = 0; i < BYTEPADDING.length; i++) {
            int padding = BYTEPADDING.length - i;
            StringBuilder buf = new StringBuilder(padding);
            for (int j = 0; j < padding; j++) {
                buf.append(' ');
            }
            BYTEPADDING[i] = buf.toString();
        }

        // Generate the lookup table for byte-to-char conversion
        for (i = 0; i < BYTE2CHAR.length; i++) {
            if (i <= 0x1f || i >= 0x7f) {
                BYTE2CHAR[i] = '.';
            } else {
                BYTE2CHAR[i] = (char) i;
            }
        }
    }

    /**
     * 打印所有内容
     * @param buffer
     */
    public static void debugAll(ByteBuffer buffer) {
        int oldlimit = buffer.limit();
        buffer.limit(buffer.capacity());
        StringBuilder origin = new StringBuilder(256);
        appendPrettyHexDump(origin, buffer, 0, buffer.capacity());
        System.out.println("+--------+-------------------- all ------------------------+----------------+");
        System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), oldlimit);
        System.out.println(origin);
        buffer.limit(oldlimit);
    }

    /**
     * 打印可读取内容
     * @param buffer
     */
    public static void debugRead(ByteBuffer buffer) {
        StringBuilder builder = new StringBuilder(256);
        appendPrettyHexDump(builder, buffer, buffer.position(), buffer.limit() - buffer.position());
        System.out.println("+--------+-------------------- read -----------------------+----------------+");
        System.out.printf("position: [%d], limit: [%d]\n", buffer.position(), buffer.limit());
        System.out.println(builder);
    }

    public static void main(String[] args) {
        ByteBuffer buffer = ByteBuffer.allocate(10);
        buffer.put(new byte[]{97, 98, 99, 100});
        debugAll(buffer);
    }

    private static void appendPrettyHexDump(StringBuilder dump, ByteBuffer buf, int offset, int length) {
        if (isOutOfBounds(offset, length, buf.capacity())) {
            throw new IndexOutOfBoundsException(
                    "expected: " + "0 <= offset(" + offset + ") <= offset + length(" + length
                            + ") <= " + "buf.capacity(" + buf.capacity() + ')');
        }
        if (length == 0) {
            return;
        }
        dump.append(
                "         +-------------------------------------------------+" +
                        NEWLINE + "         |  0  1  2  3  4  5  6  7  8  9  a  b  c  d  e  f |" +
                        NEWLINE + "+--------+-------------------------------------------------+----------------+");

        final int startIndex = offset;
        final int fullRows = length >>> 4;
        final int remainder = length & 0xF;

        // Dump the rows which have 16 bytes.
        for (int row = 0; row < fullRows; row++) {
            int rowStartIndex = (row << 4) + startIndex;

            // Per-row prefix.
            appendHexDumpRowPrefix(dump, row, rowStartIndex);

            // Hex dump
            int rowEndIndex = rowStartIndex + 16;
            for (int j = rowStartIndex; j < rowEndIndex; j++) {
                dump.append(BYTE2HEX[getUnsignedByte(buf, j)]);
            }
            dump.append(" |");

            // ASCII dump
            for (int j = rowStartIndex; j < rowEndIndex; j++) {
                dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]);
            }
            dump.append('|');
        }

        // Dump the last row which has less than 16 bytes.
        if (remainder != 0) {
            int rowStartIndex = (fullRows << 4) + startIndex;
            appendHexDumpRowPrefix(dump, fullRows, rowStartIndex);

            // Hex dump
            int rowEndIndex = rowStartIndex + remainder;
            for (int j = rowStartIndex; j < rowEndIndex; j++) {
                dump.append(BYTE2HEX[getUnsignedByte(buf, j)]);
            }
            dump.append(HEXPADDING[remainder]);
            dump.append(" |");

            // Ascii dump
            for (int j = rowStartIndex; j < rowEndIndex; j++) {
                dump.append(BYTE2CHAR[getUnsignedByte(buf, j)]);
            }
            dump.append(BYTEPADDING[remainder]);
            dump.append('|');
        }

        dump.append(NEWLINE +
                "+--------+-------------------------------------------------+----------------+");
    }

    private static void appendHexDumpRowPrefix(StringBuilder dump, int row, int rowStartIndex) {
        if (row < HEXDUMP_ROWPREFIXES.length) {
            dump.append(HEXDUMP_ROWPREFIXES[row]);
        } else {
            dump.append(NEWLINE);
            dump.append(Long.toHexString(rowStartIndex & 0xFFFFFFFFL | 0x100000000L));
            dump.setCharAt(dump.length() - 9, '|');
            dump.append('|');
        }
    }

    public static short getUnsignedByte(ByteBuffer buffer, int index) {
        return (short) (buffer.get(index) & 0xFF);
    }

2.5黏包、半包问题

黏包(Packet Concatenation)和半包(Incomplete Packet)问题是在网络通信中常见的两个问题。它们涉及到数据的传输和接收不完整或混淆的情况。

黏包问题(Packet Pasting) :黏包问题指的是在网络通信中,由于数据传输速度快于数据处理速度,多个数据包可能会在接收端被一次性接收到,导致它们被"黏"在一起,无法准确分辨每个数据包的界限。这可能会导致数据解析错误或混乱。

例如,发送端发送了两个数据包,但接收端可能会一次性接收到这两个数据包,从而形成一个"黏包"。解决这个问题的方法通常涉及在数据包中添加长度信息或特殊分隔符,以便接收端能够正确地切分数据包。

半包问题(Partial Packet) :半包问题是指在数据传输中,数据包没有完整地传输完成就被接收端接收到,造成接收到的数据包不完整,即"半包"。这可能会导致数据不完整或无法正确解析。

例如,发送端发送一个较大的数据包,但在传输过程中被切分成多个片段,接收端可能只接收到其中的一部分,导致数据不完整。解决这个问题的方法通常是在数据包中添加长度信息,确保接收端能够正确地等待和组装完整的数据包。

3.文件编程FileChannel

FileChannel 只能工作在阻塞模式下,其他与网络有关的Channel则有阻塞模式与非阻塞模式两种

3.1常用方法

获取

不能直接打开 FileChannel,必须通过 FileInputStream、FileOutputStream 或者RandomAccessFile 来获取 FileChannel,它们都有 getChannel 方法

  • 通过 FileInputStream 获取的 channel 只能读
  • 通过 FileOutputStream 获取的 channel 只能写
  • 通过 RandomAccessFile 是否能读写根据构造 RandomAccessFile 时的读写模式(rw)决定

读取

会从 channel 读取数据填充 ByteBuffer,返回值表示读到了多少字节,-1 表示到达了文件的末尾

java 复制代码
int readBytes = channel.read(buffer);

写入

java 复制代码
ByteBuffer buffer = ...;
buffer.put(...); // 存入数据
buffer.flip();   // 切换读模式

while(buffer.hasRemaining()) {
    channel.write(buffer);
}

在 while 中调用 channel.write 是因为 write 方法并不能保证一次将 buffer 中的内容全部写入 channel

关闭

channel 必须关闭,不过调用了 FileInputStream、FileOutputStream 或者 RandomAccessFile 的 close 方法会间接地调用 channel 的 close 方法

3.2两个Channel之间传输数据

超过 2g 大小的文件传输:

transferTo(起始位置,传输数,传输目标地)

java 复制代码
public class TestFileChannelTransferTo {
    public static void main(String[] args) {
        try (
                FileChannel from = new FileInputStream("data.txt").getChannel();
                FileChannel to = new FileOutputStream("to.txt").getChannel();
        ) {
            // 效率高,底层会利用操作系统的零拷贝进行优化
            long size = from.size();
            // left 变量代表还剩余多少字节
            for (long left = size; left > 0; ) {
                System.out.println("position:" + (size - left) + " left:" + left);
                left -= from.transferTo((size - left), left, to);
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}
相关推荐
自律的kkk7 天前
网络编程中的黏包和半包问题
java·开发语言·网络·网络编程·tcp·nio
power-辰南8 天前
Netty 常见面试题原理解析
java·开发语言·netty·nio
生活百般滋味,人生需要笑对。 --佚名11 天前
NIO 三大组件
java·开发语言·nio
鹏码纵横16 天前
如何解决 java.nio.charset.CoderMalfunctionError: 编码器故障错误问题?亲测有效的解决方法!
java·python·nio
智商低情商凑16 天前
NIO - selector简单介绍
java·开发语言·nio
智商低情商凑19 天前
Netty - NIO基础学习
nio
小猫猫猫◍˃ᵕ˂◍20 天前
NIO和零拷贝
linux·服务器·nio
程序猿进阶20 天前
ChannelInboundHandlerAdapter 与 SimpleChannelInboundHandler 的区别
java·开发语言·后端·面试·性能优化·netty·nio
材料苦逼不会梦到计算机白富美21 天前
多人聊天室 NIO模型实现
运维·服务器·nio
jupiter_88822 天前
Java NIO channel
java·服务器·nio