【Java NIO】

主要是对黑马的总结

一、三大组件简介

Channel 与 Buffer
Java NIO系统的核心在于: 通道(Channel)缓冲区 (Buffer)。 **
通道表示打开到IO设备(文件、套接字)的连接。若需要使用NIO系统,需要获取用于连接IO设备的
通道以及用于容纳数据的缓冲区**。然后操作缓冲区,对数据进行处理。
简而言之,通道负责传输,缓冲区负责存储.
**常见的channel有以下四种,**其中FileChannel主要用于文件传输,其余三种用于网络通信.

  • FileChannel
  • DatagramChannel UDP
  • SocketChannel TCP client,server
  • ServerSocketChannel TCP server

Buffer 有以下几种,其中使用较多的是byteBuffer

  • ByteBuffer
    • MappedByteBuffer
    • DirectByteBuffer
    • HeapByteBuffer
  • ShortBuffer
  • Int,Long,Float, Double,CharBuffer

在这里插入图片描述

1、Selector

在使用Selector之前,处理socket链接还有以下两种方法

使用多线程技术

为每个连接分别开辟一个线程,分别去处理对应的socket连接
在这里插入图片描述

存在问题:

  • 内存占用高
    • 每个线程都需要占用一定的内存,当连接较多时,会开辟大量线程,导致占用大量内存.
  • 线程上下文切换成本高 主要os记录线程状态,和切换线程结构.
  • 只适合连接数少的场景
    • 连接数过多,会导致创建很多线程,从而出现问题

使用线程池技术

既然多线程无法管理和切换成本高,我们能否采用线程池去处理,提高线程利用效率,减少线程。
在这里插入图片描述

存在的问题

  • 阻塞模式下,线程仅能处理一个连接
    • 线程池中的线程获取任务(task)后,只有当其执行完任务之后(断开连接后),才会取获取并执行下一个任务
    • 若socket连接一直未断开,则其对应的线程无法处理其他socket连接
  • 仅适合短连接场景
    • 短连接即建立连接发送请求并响应后就立即断开,使得线程池中的线程可以快速处理其他连接.

使用选择器

selector的作用其实就是配合一个线程来管理多个channel (FileChannel是阻塞式的,所以无法使用selector)
获取这些channel上的事件,这些channel工作在非阻塞模式下,当一个channel中没有执行任务时,可以去执行其他channel中的任务。适合连接数多,但流量较少的场景
在这里插入图片描述

若事件未就绪,调用selector的select() 方法会阻塞线程,直到channel发生了就绪事件。这些事件就绪后,select方法就会返回这些事件交给thread来处理.

2、ByteBuffer

使用案例

使用方式

  • 向buffer中写入数据,例如调用 channel.read(buffer)
  • 调用flip()切换至读模式
    • flip 会让buffer中 limit变为position,position变为0
  • 从buffer读取数据,例如调用buffer.get()
  • 调用clear() 或者 compact() 切换至写模式
    • 调用clear()时position=0, limit变为capacity
    • 调用compact()方式时,会将缓冲区中未读数据压缩到缓冲区前面
  • 重复以上步骤
使用byteBuffer读取文件中的内容
package com.xlg.component.nio;

import java.io.FileInputStream;
import java.nio.ByteBuffer;
import java.nio.channels.FileChannel;

import org.junit.Test;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
* @author wangqingwei
* Created on 2022-05-28
*/
public class ByteBufferTest {
    
    private static final Logger logger = LoggerFactory.getLogger(ByteBufferTest.class);
    private static final String INPUT_FILE_NAME = "src/testByteBufferText.txt";
    private static final int BUFFER_CAPACITY = 10;
    
    @Test
    public void testByteBufferRead() {
        // 1. 获取FileChannel
        try (FileChannel channel = new FileInputStream(INPUT_FILE_NAME).getChannel()) {
            // 2. 获取缓冲区
            ByteBuffer buffer = ByteBuffer.allocate(BUFFER_CAPACITY);
            StringBuilder bufferData = new StringBuilder();
            while (channel.read(buffer) > 0) {
                // 3. 切换模式, limit=position, position=0
                buffer.flip();
                // 4. buffer是否还有数据
                while (buffer.hasRemaining()) {
                    bufferData = bufferData.append((char)buffer.get());
                }
                // 5. 切换模式, limit=capacity, position=0
                buffer.clear();
            }
            System.out.println(bufferData);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

结果:

1234567890abc

核心属性

字节缓冲区的父类buffer中有几个核心属性, 如下

// Invariants: mark <= position <= limit <= capacity
private int mark = -1;
private int position = 0;
private int limit;
private int capacity;
  • capacity: 缓冲区的容量。通过构造函数赋于,一旦设置,无法更改.
  • limit: 缓冲区的界限。位于limit后的数据不可读写。缓冲区的限制不能为负值,并且不能大于容量
  • position: 下一个读写位置的索引(类似pc)。缓冲区的位置不能是负的,并且不能大于limit.
  • **mark: **记录当前的position的值。position被改变后,可以通过调用reset()方法恢复到mark的位置.

上面四个属性应满足:

mark <= position <= limit <= capacity

核心方法

put()方法
  • put()方法可以将一个数据放入缓冲区中
  • 进行该操作后,position的值+1, 指向下一个可以放入的位置。capacity = limit, 为缓冲区容量的值。

在这里插入图片描述

flip()方法
  • flip()方法会切换对缓冲区的操作模式. 由读->写,由写->读
  • 进行该操作之后
    • 如果写模式 -> 读模式, limit = position, postion = 0
    • 读 -> 写, 其实没有啥影响,put就行

在这里插入图片描述

get()方法
  • get()方法会读取缓冲区的一个值
  • 进行该操作后,position + 1, 如果超过limit会有异常
  • 注意: get(index) 方法不会改变position 的值.

在这里插入图片描述

rewind()方法
  • 改方法只能在读模式使用 ,position 归零

在这里插入图片描述

clear()方法
  • 各个属性恢复为最初的样子, position = 0, limit = capacity
  • 此时缓冲区的数据依旧存在,处于被遗忘状态,下次进行写操作就会覆盖.
    在这里插入图片描述
mark和reset()方法
  • mark() 会把当前position 给mark, 初始mark = -1
  • reset() 会把 position = mark , 其实就是回溯
compact() 方法

此方法是ByteBuffer的方法,而不是buffer的方法

  • compact会把未读完的数据向前压缩,然后切换到写模式
  • 数据前移后,原位置的值并未清零,写时会覆盖之前的值

在这里插入图片描述

clear 和 compact对比

clear只是对position、limit、mark进行重置,而compact在对position进行设置,以及limit、mark进行重置的同时,还涉及到数据在内存中拷贝(会调用arraycopy)。所以compact比clear更耗性能。
但compact能保存你未读取的数据,将新数据追加到为读取的数据之后;而clear则不行,若你调用了clear,则未读取的数据就无法再读取到了
所以需要根据情况来判断使用哪种方法进行模式切换

字符串与ByteBuffer的相互转换


方法一

编码: getByte, put入byteBuffer
解码: flip(), StandardCharsets.UTF_8.decode

   @Test
    public void testStrBuffer2ConvertVersion1() {
        String str = "hello nio!";
        String result;

        ByteBuffer buffer = ByteBuffer.allocate(10);
        buffer.put(str.getBytes());
        ByteBufferUtil.debugAll(buffer);

        buffer.flip();

        result = StandardCharsets.UTF_8.decode(buffer).toString();
        ByteBufferUtil.debugAll(buffer);
        System.out.println("version1 result : " + result);
    }

方法二

编码: StandardCharsets.UTF_8.encode 获取byteBuffer, 此时获取的buffer为读模式.
解码: StandardCharsets.UTF_8.decode

   @Test
    public void testStrBuffer2ConvertVersion2() {
        String str = "hello nio!";
        String result;

        final ByteBuffer buffer = StandardCharsets.UTF_8.encode(str);

        result = StandardCharsets.UTF_8.decode(buffer).toString();
        ByteBufferUtil.debugAll(buffer);
        System.out.println("version2 result : " + result);
    }

方法三:

编码: ByteBuffer.warp(str.getByte())
解码: StandardCharsets.UTF_8.decode

 @Test
    public void testStrBuffer2ConvertVersion3() {
        String str = "hello nio!";
        String result;

        final ByteBuffer buffer = ByteBuffer.wrap(str.getBytes());

        result = StandardCharsets.UTF_8.decode(buffer).toString();
        ByteBufferUtil.debugAll(buffer);
        System.out.println("version3 result : " + result);
    }

粘包和半包

现象

网络上有多条数据发送给服务端,数据之间使用 \n 进行分隔
但由于某种原因这些数据在接收时,被进行了重新组合,例如原始数据有3条为

  • Hello,world\n
  • I’m Nyima\n
  • How are you?\n

变成了下面的两个 byteBuffer (粘包,半包)

  • Hello,world\nI’m Nyima\nHo
  • w are you?\n
出现原因

粘包
发送方在发送数据时,并不是一条一条发送的,而是将数据整合在一起,达到一定数据量后一起发送,
这就会导致多条信息被放在一个缓冲区中被一起发送出去.
半包
接受方的缓冲区的大小是有限的,当接受方的缓冲区满了以后,就需要将**信息截断,**等缓冲区空了之后再继续放入数据。这就会发生一段完整的数据最后被截断的现象.

解决办法

发送定长包
发送包和包体长度 接受方先接到包体长度,然后接受包

特殊分隔符方式 但有些报文会误解析

  • 通过get(index)方法遍历ByteBuffer,遇到分隔符时进行处理。注意:get(index)不会改变position的值
    • 记录该段数据长度,以便于申请对应大小的缓冲区
    • 将缓冲区的数据通过get()方法写入到target中
  • 调用compact方法切换模式,因为缓冲区中可能还有未读的数据
  /**
     * 利用特殊符号分隔粘包
     */
    @Test
    public void processStickPck() {
        ByteBuffer buffer = ByteBuffer.allocate(32);
        // 模拟粘包+半包
        buffer.put("Hello,world\nI'm Nyima\nHo".getBytes());

        byte splitStr = '\n';

        stickPckSplit(buffer, splitStr);
        buffer.put("w are you?\n".getBytes());
        stickPckSplit(buffer, splitStr);
    }

    private void stickPckSplit(ByteBuffer buffer, byte splitStr) {
        // 切换为读模式
        buffer.flip();

        for (int index = 0; index < buffer.limit(); index++) {
            final byte element = buffer.get(index);
            if (element == splitStr) {
                int subLength = index - buffer.position();
                ByteBuffer subBuffer = ByteBuffer.allocate(subLength);
                for (int subIndex = 0; subIndex < subLength; subIndex++) {
                    subBuffer.put(buffer.get());
                }
                // 有一位是\n
                buffer.get();
                debugAll(subBuffer);
            }
        }
        // 因为有些数据还没有拿完
        buffer.compact();
//        debugAll(buffer);
    }

二、文件编程


1、FileChannel


工作模式

FileChannel 只能在阻塞模式下工作,所以无法搭配Selector

获取

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

  • FileInputStream 的channel 只能读
  • **FileOutputStream **的channel 只能写
  • RandomAccessFile 是否能读写根据构造 randomAccesFile 时的读写模式决定

读取

FileInputStream.getChannel.read(buffer) 把数据写入buffer中,

// 表示读到了多少字节, 若读到了文件末尾则返回给 -1 
int readBytes = channel.read(buffer);

while (channel.read(buffer) > 0) {
}

写入

因为channel也是有大小的,所以 write 方法并不能保证一次将 buffer 中的内容全部写入 channel。必须需要按照以下规则进行写入

// 通过hasRemaining()方法查看缓冲区中是否还有数据未写入到通道中
while(buffer.hasRemaining()) {
	channel.write(buffer);
}

关闭

通道需要close,一般情况通过try-with-resource进行关闭,最好使用以下方法获取strea以及channel,避免某些原因使得资源未被关闭

public class TestChannel {
    public static void main(String[] args) throws IOException {
        try (FileInputStream fis = new FileInputStream("stu.txt");
             FileOutputStream fos = new FileOutputStream("student.txt");
             FileChannel inputChannel = fis.getChannel();
             FileChannel outputChannel = fos.getChannel()) {
        }
    }
}

位置

channel 也有一个保存读取数据位置的属性,即position

long pos = channel.position();
// 可以通过position(int pos)设置channel中position的值
long newPos = ...;
channel.position(newPos);

设置当前位置时,如果设置为文件的末尾

  • 这时读取会返回 -1
  • 这时写入,会追加内容,但要注意如果 position 超过了文件末尾,再写入时在新内容和原末尾之间会有空洞(00)

强制写入

操作系统处于性能考虑,会将数据缓存,不是立即写入磁盘,而是等到缓存满了以后将所有的数据一次性的写入磁盘。可以调用**force(true) **方法将文件内容和元数据(文件的权限等信息) 立即写入磁盘.

2、两个Channel传输数据


tranferTo方法

使用tranferTo方法可以快速的、高效地将一个channel中的数据传输到另一个channel中
一次只能传输2G的内容.
transferTo 底层使用了零拷贝技术

    private static final String TRANSFER_TWO_CHANNEL_INPUT = FILE_PREFIX_NIO + "input.txt";
    private static final String TRANSFER_TWO_CHANNEL_OUT = FILE_PREFIX_NIO + "output.txt";
    /**
     * 使用transferTo方法可以快速、高效地将一个channel中的数据传输到另一个channel中,但一次只能传输2G的内容
     *
     * transferTo底层使用了零拷贝技术
     */
    @Test
    public void testTwoChannelTransfer() {
        try (FileChannel inputChannel = new FileInputStream(TRANSFER_TWO_CHANNEL_INPUT).getChannel();
                FileChannel outChannel = new FileOutputStream(TRANSFER_TWO_CHANNEL_OUT).getChannel();
                ) {
            // 参数:inputChannel的起始位置,传输数据的大小,目的channel
            // 返回值为传输的数据的字节数
            // transferTo一次只能传输2G的数据
            inputChannel.transferTo(0, inputChannel.size(), outChannel);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

当传输的文件大于2G时,需要使用以下方法进行多次传输

    /**
     * 当传输的文件大于2G时,需要使用以下方法进行多次传输
     */
    @Test
    public void testTwoChannelTransferV2() {
        try (FileChannel inputChannel = new FileInputStream(TRANSFER_TWO_CHANNEL_INPUT).getChannel();
                FileChannel outChannel = new FileOutputStream(TRANSFER_TWO_CHANNEL_OUT).getChannel();
        ) {
            long size = inputChannel.size();
            long capacity = inputChannel.size();
            while (capacity > 0) {
                // // transferTo返回值为传输了的字节数
                capacity -= inputChannel.transferTo(size - capacity, capacity, outChannel);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

3、Path 与 Paths


  • Path 用来表示文件路径
  • Paths 是用具类, 用来获取Path 实例
Path source = Paths.get("1.txt"); // 相对路径 不带盘符 使用 user.dir 环境变量来定位 1.txt

Path source = Paths.get("d:\\1.txt"); // 绝对路径 代表了  d:\1.txt 反斜杠需要转义

Path source = Paths.get("d:/1.txt"); // 绝对路径 同样代表了  d:\1.txt

Path projects = Paths.get("d:\\data", "projects"); // 代表了  d:\data\projects
  • . 代表了当前路径
  • … 代表了上一级路径
d:
	|- data
		|- projects
			|- a
			|- b

Path path = Paths.get("d:\\data\\projects\\a\\..\\b");
System.out.println(path);
System.out.println(path.normalize()); // 正常化路径 会去除 . 以及 ..

d:\data\projects\a\..\b
d:\data\projects\b

4、Files


查找

检查文件是否存在

Path path = Paths.get("helloword/data.txt");
sout(Files.exists(path));

创建

创建一级目录

Path path = Paths.get("helloword/d1");
Files.createDirectory(path);
  • 如果目录已存在,会抛异常 FileAlreadyExistsException
  • 不能一次创建多级目录,否则会抛异常 NoSuchFileException

创建多级目录用

Path path = Paths.get("helloword/d1/d2");
Files.createDirectories(path);

拷贝及移动

拷贝文件
Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/target.txt");

Files.copy(source, target);

如果文件已经存在, 会抛异常 FileAlreadyExistsException
如果希望用source 覆盖掉 target,需要用 StandardCopyOption 来控制.

Files.copy(source, target, StandardCopyOption.REPLACE_EXISTING);
移动文件
Path source = Paths.get("helloword/data.txt");
Path target = Paths.get("helloword/target.txt");

Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
  • StandardCopyOption.ATOMIC_MOVE 保证文件移动的原子性

删除

删除文件
Path target = Paths.get("helloword/target.txt");

Files.delete(target);
  • 如果文件不存在,会抛异常 NoSuchFileException
删除目录
Path target = Paths.get("helloword/d1");

Files.delete(target);
  • 如果目录还有内容,会抛异常 DirectoryNotEmptyException

遍历

可以使用Files工具类中的walkFileTree(Path, FileVisitor)方法,其中需要传入两个参数

  • Path:文件起始路径
  • FileVisitor:文件访问器,使用访问者模式
    • 接口的实现类SimpleFileVisitor有四个方法
      • preVisitDirectory:访问目录前的操作
      • visitFile:访问文件的操作
      • visitFileFailed:访问文件失败时的操作
      • postVisitDirectory:访问目录后的操作
public class TestWalkFileTree {
    public static void main(String[] args) throws IOException {
        Path path = Paths.get("F:\\JDK 8");
        // 文件目录数目
        AtomicInteger dirCount = new AtomicInteger();
        // 文件数目
        AtomicInteger fileCount = new AtomicInteger();
        Files.walkFileTree(path, new SimpleFileVisitor<Path>(){
            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                System.out.println("===>"+dir);
                // 增加文件目录数
                dirCount.incrementAndGet();
                return super.preVisitDirectory(dir, attrs);
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                System.out.println(file);
                // 增加文件数
                fileCount.incrementAndGet();
                return super.visitFile(file, attrs);
            }
        });
        // 打印数目
        System.out.println("文件目录数:"+dirCount.get());
        System.out.println("文件数:"+fileCount.get());
    }
}

三、网络编程


1、阻塞

  • 阻塞模式下,相关方法都会导致线程暂停
    • ServerSocketChannel.accept 会在没有连接建立时让线程暂停
    • SocketChannel.read 会在通道中没有数据可读时让线程暂停
    • 阻塞的表现其实就是线程暂停了,暂停期间不会占用 cpu,但线程相当于闲置
  • 单线程下,阻塞方法之间相互影响,几乎不能正常工作,需要多线程支持
  • 但多线程下,有新的问题,体现在以下方面
    • 32 位 jvm 一个线程 320k,64 位 jvm 一个线程 1024k,如果连接数过多,必然导致 OOM,并且线程太多,反而会因为频繁上下文切换导致性能降低
    • 可以采用线程池技术来减少线程数和线程上下文切换,但治标不治本,如果有很多连接建立,但长时间 inactive,会阻塞线程池中所有线程,因此不适合长连接,只适合短连接

服务端代码

客户端代码

private static final Logger logger = LoggerFactory.getLogger(NetTest.class);
    private static final int BLOCKING_SERVER_BUFFER_CAPACITY = 16;
    private static final int BLOCKING_SERVER_PORT = 8080;


    @Test
    public void blockingServer() {
        ByteBuffer buffer = ByteBuffer.allocate(BLOCKING_SERVER_BUFFER_CAPACITY);

        try (ServerSocketChannel server = ServerSocketChannel.open()) {

            server.bind(new InetSocketAddress(BLOCKING_SERVER_PORT));

            // 存放链接的集合
            List<SocketChannel> clientChannelList = Lists.newArrayList();
            while (true) {
                logger.info("before connecting...");
                // 没有连接时,会阻塞线程
                SocketChannel socketChannel = server.accept();
                logger.info("after connecting...");
                clientChannelList.add(socketChannel);

                for (SocketChannel channel : clientChannelList) {
                    logger.info("before reading...");
                    // 处理通道中的数据
                    // 当通道中没有数据可读时,会阻塞线程
                    channel.read(buffer);
                    buffer.flip();
                    ByteBufferUtil.debugRead(buffer);
                    buffer.clear();
                    logger.info("after reading...");
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @Test
    public void blockingClient() {
        try (final SocketChannel socketChannel = SocketChannel.open()) {
            socketChannel.connect(new InetSocketAddress(LOCAL_HOST, BLOCKING_SERVER_PORT));
            logger.info("waiting...");
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

运行结果

  • 客户端-服务器建立连接前:服务器端因accept阻塞

在这里插入图片描述

  • 客户端-服务器建立连接后,客户端发送消息前:服务器端因通道为空被阻塞

在这里插入图片描述

  • 客户端发送数据后,服务器处理通道中的数据。再次进入循环时,再次被accept阻塞

在这里插入图片描述

  • 之前的客户端再次发送消息**,服务器端因为被accept阻塞**,无法处理之前客户端发送到通道中的信息

在这里插入图片描述

此时只有再启动一个客户端,才能进入for channel 此时第一个channel中未处理的事件给处理了.
在这里插入图片描述

2、非阻塞

  • 可以通过ServerSocketChannel的configureBlocking(false)方法将获得连接设置为非阻塞的。此时若没有连接,accept会返回null
  • 可以通过SocketChannel的configureBlocking(false)方法将从通道中读取数据设置为非阻塞的。若此时通道中没有数据可读,read会返回-1

服务器代码如下

public class Server {
    public static void main(String[] args) {
        // 创建缓冲区
        ByteBuffer buffer = ByteBuffer.allocate(16);
        // 获得服务器通道
        try(ServerSocketChannel server = ServerSocketChannel.open()) {
            // 为服务器通道绑定端口
            server.bind(new InetSocketAddress(8080));
            // 用户存放连接的集合
            ArrayList<SocketChannel> channels = new ArrayList<>();
            // 循环接收连接
            while (true) {
                // 设置为非阻塞模式,没有连接时返回null,不会阻塞线程
                server.configureBlocking(false);
                SocketChannel socketChannel = server.accept();
                // 通道不为空时才将连接放入到集合中
                if (socketChannel != null) {
                    System.out.println("after connecting...");
                    channels.add(socketChannel);
                }
                // 循环遍历集合中的连接
                for(SocketChannel channel : channels) {
                    // 处理通道中的数据
                    // 设置为非阻塞模式,若通道中没有数据,会返回0,不会阻塞线程
                    channel.configureBlocking(false);
                    int read = channel.read(buffer);
                    if(read > 0) {
                        buffer.flip();
                        ByteBufferUtil.debugRead(buffer);
                        buffer.clear();
                        System.out.println("after reading");
                    }
                }
            }
        } catch (IOException e) {
            e.printStackTrace();
        }
    }
}

这样写有一个问题,因为设置了非阻塞,会一直执行while(true)中的代码,CPU一直处于忙碌状态,会使得性能变低,所以实际情况中不使用这种方法处理请求.

3、Selector


多路复用

单线程可以配合Selector 完成对多个Channel 可读写事件的监控,这称之为多路复用

  • 多路复用仅针对网络IO, 普通文件IO 无法利用多路复用
  • 如果不用Selector 的非阻塞模式,线程大部分时间都在做无用功,而Selector 能够保证
    • 有可连接事件时才去连接
    • 有可读事件才去读取
    • 有可写事件才去写入
      • 限于网络传输能力, Channel未必时时可写,一旦Channel 可写,会触发selector 的可写事件

4、使用及Accpet事件


要使用Selector实现多路复用,服务端代码如下改进

@Test
    public void testSelectorAccept() {
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress(SERVER_PORT));

            Selector selector = Selector.open();
            
            // 通道必须设置为非阻塞模式
            server.configureBlocking(false);
            // 将通道注册到选择器中,并设置感兴趣的事件
            server.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                // 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
                // 就绪事件数量
                final int readyEvents = selector.select();
                System.out.println("selector ready counts : " + readyEvents);

                final Set<SelectionKey> selectionKeys = selector.keys();

                final Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    final SelectionKey key = iterator.next();

                    // 判断key的类型
                    if(key.isAcceptable()) {
                        // 获得key对应的channel
                        ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                        System.out.println("before accepting...");

                        // 获取连接并处理,而且是必须处理,否则需要取消
                        SocketChannel socketChannel = channel.accept();
                        System.out.println("after accepting...");

                        // 处理完毕后移除
                        iterator.remove();
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

步骤解析

  • 获得选择器Selector
Selector selector = Selector.open()
  • 通道设置为非阻塞模式,并注册到选择器中,并设置感兴趣的事件
    • channel 必须工作在非阻塞模式
    • FileChannel 没有非阻塞模式, 所以不能 配合Selector 一起使用
    • 绑定的事件类型 可以有:
      • connect - 客户端链接成功时触发
      • accept - 服务器端成功接受连接时触发
      • read - 数据可读入时触发,有因为接受能力弱,数据暂不能读入的情况
      • write - 数据可写出时触发,有因为发送能力弱,数据暂不能写出的情况
// 通道必须设置为非阻塞模式
server.configureBlocking(false);
// 将通道注册到选择器中,并设置感兴趣的事件
server.register(selector, SelectionKey.OP_ACCEPT);
  • 通通Selector 监听事件,并获得就绪的通道个数,若没有通道就绪,线程就会阻塞.
    • 阻塞直到绑定事件发生
int count = selector.select();
  • 阻塞直到绑定事件发生,或者超时 (ms)
int count = selector.select(long timeout);
  • 不会阻塞,也就是不管有没有事件,立即返回,自己根据返回值检查是否有事件
int count = selector.selectNow();
  • 获取就绪事件并得到对应的通道,然后进行处理
// 获取所有事件
Set<SelectionKey> selectionKeys = selector.selectedKeys();
                
// 使用迭代器遍历事件
Iterator<SelectionKey> iterator = selectionKeys.iterator();

while (iterator.hasNext()) {
	SelectionKey key = iterator.next();
                    
	// 判断key的类型,此处为Accept类型
	if(key.isAcceptable()) {
        // 获得key对应的channel
        ServerSocketChannel channel = (ServerSocketChannel) key.channel();

        // 获取连接并处理,而且是必须处理,否则需要取消
        SocketChannel socketChannel = channel.accept();

        // 处理完毕后移除
        iterator.remove();
	}
}

事件发生后能否不处理
事件发生后,要么处理,要么取消,不能什么都不做,**否则下次该事件仍会触发,**这是因为nio底层使用的是水平触发.

5、Read 事件


  • 在Accept事件中,若有客户端与服务端建立了连接,需要将其对应的SocketChannel 设置为非阻塞,并注册到选择器中。
  • 添加read事件,触发后续读取操作.
 @Test
    public void testSelectorAcceptRead() {
        ByteBuffer buffer = ByteBuffer.allocate(19);
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress(SERVER_PORT));

            Selector selector = Selector.open();

            // 通道必须设置为非阻塞模式
            server.configureBlocking(false);
            // 将通道注册到选择器中,并设置感兴趣的事件
            server.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                // 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
                // 就绪事件数量
                final int readyEvents = selector.select();
                System.out.println("selector ready counts : " + readyEvents);

                final Set<SelectionKey> selectionKeys = selector.keys();

                final Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    final SelectionKey key = iterator.next();

                    // 判断key的类型
                    if (key.isAcceptable()) {
                        // 获得key对应的channel
                        ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                        System.out.println("before accepting...");
                        // 获取连接并处理,而且是必须处理,否则需要取消
                        SocketChannel socketChannel = channel.accept();
                        System.out.println("after accepting...");

                        // 设置为非阻塞模式,同时将连接的通道也注册到选择其中
                        socketChannel.configureBlocking(false);
                        socketChannel.register(selector, SelectionKey.OP_READ);

                        // 处理完毕后移除
                        iterator.remove();
                    } else if (key.isReadable()) {
                        SocketChannel channel = (SocketChannel)key.channel();
                        logger.info("before reading...");
                        channel.read(buffer);
                        logger.info("after reading...");

                        buffer.flip();
                        ByteBufferUtil.debugRead(buffer);
                        buffer.clear();

                        iterator.remove();
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

删除事件

**当处理完一个事件后,一定要调用迭代器的remove方法移除对应事件,否则会出现错误。**原因如下

  • 当调用了 server.register(selector, SelectionKey.OP_ACCEPT) 后,Selector 中维护了一个集合,用于存放SelectionKey以及其对应的通道.
// WindowsSelectorImpl 中的 SelectionKeyImpl数组
private SelectionKeyImpl[] channelArray = new SelectionKeyImpl[8];

public class SelectionKeyImpl extends AbstractSelectionKey {
    // Key对应的通道
    final SelChImpl channel;
    ...
}

在这里插入图片描述

  • 当选择器种的通道对应的事件发生后,selectionKey 会被放入另一个集合中,但是SelectionKey 不会自动移除,所以我们需要在处理完一个事件后,通过迭代器手动移除其中的selectionKey。否则会导致已经处理过的事件再次被处理,就会引发错误.

在这里插入图片描述

断开处理

当客户端与服务器之间的连接断开时,会给服务器端发送一个读事件,对异常断开和正常断开需要不同的处理.

  • 正常断开
    • 正常断开时,服务器端的channel.read(buffer) 方法的返回值为-1,所以当结束到返回值为-1时,需要调用key的cancel方法取消事件,并在取消后移除该事件.
int read = channel.read(buffer);
// 断开连接时,客户端会向服务器发送一个读事件,此时read的返回值为-1
if (read == -1) {
    // 取消该事件的处理
    key.cancel();
    channel.close();
} else {
    ...
}
iterator.remove();
  • 异常断开
    • 异常断开时,会抛出IOException 异常,在try-catch的catch 块中捕获异常并调用key 的cancel方法即可.

消息边界

不处理消息边界存在的问题
ByteBuffer buffer = ByteBuffer.allocate(4);
// 解码并打印
System.out.println(StandardCharsets.UTF_8.decode(buffer));

你�
��

这个是因为UTF_8模式下,一个汉字3个字节,此时缓冲区4字节,一次读事件无法处理完通道中的数据,所以一共会触发两次事件.

处理消息边界

传输的文本可能有以下三种情况

  • 文本大于缓冲区大小
    • 此时需要将缓冲区进行扩容
    • 发生半包现象
    • 发生粘包现象

在这里插入图片描述

解决思路大概有以下三种

  • **固定消息长度, ** 数据包大小一样,服务器按预定长度读取,当发送的数据较少时,需要将数据进行填充,直到长度与消息规定长度一致。缺点是浪费带宽.
  • 按照分隔符拆分,缺点是效率低,而且很容易造成无拆包.
  • TLV 格式。Type类型, Length 长度,Value数据(也就是在消息开头用一些空间存放后面数据的长度),如HTTP请求头中的Content-Type与Content-Length。类型和长度已知的情况下,就可以方便获取消息大小,分配合适的 buffer,缺点是 buffer 需要提前分配,如果内容过大,则影响 server 吞吐量
    • Http 1.1 是 TLV 格式
    • Http 2.0 是 LTV 格式

在这里插入图片描述

下文的消息边界处理方式为第二种:按分隔符拆分

附件与扩容

channel的 register 方法还有第三个参数: 附件, 可以向其中放入一个Object类型的对象,该对象会与登记的Channel 以及其对应的SelectionKey绑定,可以从SelectionKey获取到对应通道的附件.

public final SelectionKey register(Selector sel, int ops, Object att)

可通过SelectionKey的attachment()方法获得附件

ByteBuffer buffer = (ByteBuffer) key.attachment();

我们需要在Accept事件发生后,将通道注册到Selector中时,对每个通道添加一个ByteBuffer附件,让每个通道发生读事件时都使用自己的通道,避免与其他通道发生冲突而导致问题

// 设置为非阻塞模式,同时将连接的通道也注册到选择其中,同时设置附件
socketChannel.configureBlocking(false);
ByteBuffer buffer = ByteBuffer.allocate(16);
// 添加通道对应的Buffer附件
socketChannel.register(selector, SelectionKey.OP_READ, buffer);

当Channel中的数据大于缓冲区时,需要对缓冲区进行扩容操作。此代码中的扩容的判定方法:Channel调用compact方法后,的position与limit相等,说明缓冲区中的数据并未被读取(容量太小),此时创建新的缓冲区,其大小扩大为两倍。同时还要将旧缓冲区中的数据拷贝到新的缓冲区中,同时调用SelectionKey的attach方法将新的缓冲区作为新的附件放入SelectionKey中

// 如果缓冲区太小,就进行扩容
if (buffer.position() == buffer.limit()) {
    ByteBuffer newBuffer = ByteBuffer.allocate(buffer.capacity()*2);
    // 将旧buffer中的内容放入新的buffer中
    ewBuffer.put(buffer);
    // 将新buffer作为附件放到key中
    key.attach(newBuffer);
}

改造后的服务器代码如下

 /**
     * 升级后的考虑断开、数据半包粘包(split)
     */
    @Test
    public void testSelectorAcceptReadUpgrade() {
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress(SERVER_PORT));

            Selector selector = Selector.open();

            // 通道必须设置为非阻塞模式
            server.configureBlocking(false);
            // 将通道注册到选择器中,并设置感兴趣的事件
            server.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                // 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
                // 就绪事件数量
                final int readyEvents = selector.select();
                System.out.println("selector ready counts : " + readyEvents);

                final Set<SelectionKey> selectionKeys = selector.keys();

                final Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    final SelectionKey key = iterator.next();

                    // 判断key的类型
                    if (key.isAcceptable()) {
                        // 获得key对应的channel
                        ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                        System.out.println("before accepting...");
                        // 获取连接并处理,而且是必须处理,否则需要取消
                        SocketChannel socketChannel = channel.accept();
                        System.out.println("after accepting...");

                        // 设置为非阻塞模式,同时将连接的通道也注册到选择其中
                        socketChannel.configureBlocking(false);
                        ByteBuffer curChannelBuffer = ByteBuffer.allocate(BUFFER_CAPACITY);
                        // 每个通道发生读事件时都使用自己的通道
                        socketChannel.register(selector, SelectionKey.OP_READ, curChannelBuffer);

                        // 处理完毕后移除
                        iterator.remove();
                    } else if (key.isReadable()) {
                        SocketChannel channel = (SocketChannel)key.channel();
                        // 通过key获得附件(buffer)
                        ByteBuffer curBuffer = (ByteBuffer)key.attachment();
                        logger.info("before reading...");
                        final int read = channel.read(curBuffer);
                        if (read == -1) {
                            key.channel();
                            channel.close();
                        } else {
                            // 通过分隔符来分隔buffer中的数据
                            ByteBufferTest bufferTest = new ByteBufferTest();
                            bufferTest.stickPckSplit(curBuffer, SPLIT_STR);
                            // buffer太小了
                            if (curBuffer.position() == curBuffer.limit()) {
                                ByteBuffer newBuffer = ByteBuffer.allocate(curBuffer.capacity() * BUFFER_EXPRESSION_FACTOR);
                                curBuffer.flip();
                                newBuffer.put(curBuffer);
                                // 将新buffer放到key中作为附件
                                key.attach(newBuffer);
                            }
                        }
                        logger.info("after reading...");
                        iterator.remove();
                    }
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

ByteBuffer大小分配

  • 每个channel都需要记录可能被切分的消息,因为byteBuffer 不能被多个channel共同使用. 因此需要为每个channel 维护一个独立的byteBuffer
  • byteBuffer 不能太大,比如一个byteBuffer 1Mb的话,要支持百万连接就需要1Tb 内存,因此需要设计大小可变的byteBuffer.
  • 分配思路可以参考
    • 一种思路是首先分配一个较小的 buffer,例如 4k,如果发现数据不够,再分配 8k 的 buffer,将 4k buffer 内容拷贝至 8k buffer,优点是消息连续容易处理,缺点是数据拷贝耗费性能
    • 另一个思路是用多个数组组成buffer, 一个数组不够,把多出来的内容写入新的数组,与前面的区别是消息存储不连续解析复杂,优点是避免了拷贝引起的性能损耗.

6、 write事件


服务器通过Buffer向通道中写入数据时,可能因为通道容量小于Buffer中的数据大小,导致无法一次性将Buffer中的数据全部写入到Channel中,这时便需要分多次写入,具体步骤如下:

  • 执行一次写操作,向将buffer中的内容写入到SocketChannel中,然后判断Buffer 中是否还有数据
  • 若Buffer 中还有数据,则需要将SocketChannel 注册到Selector中,并关注写事件,同时将未写完的Buffer 作为附件一起放入到SelectionKey中.
int write = socket.write(buffer);
// 通道中可能无法放入缓冲区中的所有数据
if (buffer.hasRemaining()) {
    // 注册到Selector中,关注可写事件,并将buffer添加到key的附件中
    socket.configureBlocking(false);
    socket.register(selector, SelectionKey.OP_WRITE, buffer);
}
  • 添加写事件的相关操作 key.isWritable(). 对Buffer再次进行写操作.
    • 每次写后需要判断Buffer中是否还有数据 (是否写完)。若写完,需要移除SelectionKey中的buffer附件, 避免占用过多内存,同时还需移除对写事件的关注.
SocketChannel socket = (SocketChannel) key.channel();
ByteBuffer buffer = (ByteBuffer)key.attachment();
// 执行写操作
int write = socket.write(buffer);
sout(write); // 字节数
// 如果已经完成了写操作, 需要移除key中的附件, 同时不在对写事件感兴趣
if (!buffer.hasRemaining()) {
    key.attach(null);
    key.interestOps(0);
}

整体代码如下:


    /**
     * 升级后的考虑断开、数据半包粘包(split), write写事件
     * 因为通道的容量可能小于 buffer的大小, 一次处理不完
     */
    @Test
    public void testSelectorAcceptReadUpgradeAndWrite() {
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            server.bind(new InetSocketAddress(SERVER_PORT));

            Selector selector = Selector.open();

            // 通道必须设置为非阻塞模式
            server.configureBlocking(false);
            // 将通道注册到选择器中,并设置感兴趣的事件
            server.register(selector, SelectionKey.OP_ACCEPT);
            while (true) {
                // 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
                // 就绪事件数量
                final int readyEvents = selector.select();
                System.out.println("selector ready counts : " + readyEvents);

                final Set<SelectionKey> selectionKeys = selector.keys();

                final Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    final SelectionKey key = iterator.next();

                    // 判断key的类型
                    if (key.isAcceptable()) {
                        // 获得key对应的channel
                        ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                        System.out.println("before accepting...");
                        // 获取连接并处理,而且是必须处理,否则需要取消
                        SocketChannel socketChannel = channel.accept();
                        System.out.println("after accepting...");

                        // 写入data
                        StringBuilder dataBuilder = new StringBuilder();
                        Stream.iterate(0, s -> s + 1).limit(500000000).forEach(dataBuilder::append);

                        ByteBuffer buffer = StandardCharsets.UTF_8.encode(dataBuilder.toString());
                        // 先执行一次Buffer->Channel的写入,如果未写完,就添加一个可写事件
                        int write = socketChannel.write(buffer);
                        System.out.println(write);
                        // 通道中可能无法放入缓冲区中的所有数据
                        if (buffer.hasRemaining()) {
                            // 注册到Selector中,关注可写事件,并将buffer添加到key的附件中
                            socketChannel.configureBlocking(false);
                            socketChannel.register(selector, SelectionKey.OP_WRITE, buffer);
                        }
                    } else if (key.isReadable()) {
                        SocketChannel channel = (SocketChannel)key.channel();
                        // 通过key获得附件(buffer)
                        ByteBuffer curBuffer = (ByteBuffer)key.attachment();
                        logger.info("before reading...");
                        final int read = channel.read(curBuffer);
                        if (read == -1) {
                            key.channel();
                            channel.close();
                        } else {
                            // 通过分隔符来分隔buffer中的数据
                            ByteBufferTest bufferTest = new ByteBufferTest();
                            bufferTest.stickPckSplit(curBuffer, SPLIT_STR);
                            // buffer太小了
                            if (curBuffer.position() == curBuffer.limit()) {
                                ByteBuffer newBuffer = ByteBuffer.allocate(curBuffer.capacity() * BUFFER_EXPRESSION_FACTOR);
                                curBuffer.flip();
                                newBuffer.put(curBuffer);
                                // 将新buffer放到key中作为附件
                                key.attach(newBuffer);
                            }
                        }
                        logger.info("after reading...");
                    } else if (key.isWritable()) {
                        SocketChannel socket = (SocketChannel)key.channel();
                        ByteBuffer buffer = (ByteBuffer) key.attachment();
                        int write = socket.write(buffer);
                        System.out.println(write);
                        // 如果已经完成了写操作,需要移除key中的附件,同时不再对写事件感兴趣
                        if (!buffer.hasRemaining()) {
                            key.attach(null);
                            key.interestOps(0);
                        }
                    }
                    // 处理完毕后移除
                    iterator.remove();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

7、优化


多线程优化

充分利用多核CPU,分两组选择器

  • 单线程配一个选择器(Boss), 专门处理accept 事件
  • 创建cpu核心数的线程 (Worker),** 每个线程配一个选择器,轮流处理read事件**
思路
  • 创建一个负责处理accept 事件的Boss 线程,与多个负责处理read 事件的worker线程.
  • Boss 线程执行逻辑
    • 接受并处理accept 事件,当事件发生后,调用Worker的register(SocketChannel socket)方法,让woker去处理read事件,其中需要根据标识robin 去判断将任务分配给哪个worker.
// 创建固定数量的Worker
Worker[] workers = new Worker[4];
// 用于负载均衡的原子整数
AtomicInteger robin = new AtomicInteger(0);
// 负载均衡,轮询分配Worker
workers[robin.getAndIncrement()% workers.length].register(socket);
  • register(SocketChannel socket)方法会通过同步队列完成Boss线程与Worker线程之间的通信,让SocketChannel的注册任务被Worker线程执行。添加任务后需要调用selector.wakeup()来唤醒被阻塞的Selector
public void register(final SocketChannel socket) throws IOException {
    // 只启动一次
    if (!started) {
       // 初始化操作
    }
    // 向同步队列中添加SocketChannel的注册事件
    // 在Worker线程中执行注册事件
    queue.add(new Runnable() {
        @Override
        public void run() {
            try {
                socket.register(selector, SelectionKey.OP_READ);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    });
    // 唤醒被阻塞的Selector
    // select类似LockSupport中的park,wakeup的原理类似LockSupport中的unpark
    selector.wakeup();
}
  • Worker 线程执行的操作
    • 从同步队列中获取注册任务,并处理read事件
实现代码
 /**
     * 多线程版本.
     * 一个boss 监听accept
     * 多个worker 监听read
     */
    @Test
    public void threadsServer() {
        try (ServerSocketChannel server = ServerSocketChannel.open()) {
            Thread.currentThread().setName("boss");
            server.bind(new InetSocketAddress(SERVER_PORT));

            // 负责轮训检查accept
            final Selector boss = Selector.open();
            server.configureBlocking(false);
            server.register(boss, SelectionKey.OP_ACCEPT);

            // 创建固定数据量的worker
            Worker[] workers = new Worker[4];
            // 用于负载因子的 robin
            AtomicInteger robin = new AtomicInteger(0);
            for (int index = 0; index < workers.length; index++) {
                workers[index] = new Worker("worker-" + index);
            }

            while (true) {
                boss.select();
                Set<SelectionKey> selectionKeys = boss.selectedKeys();
                Iterator<SelectionKey> iterator = selectionKeys.iterator();
                while (iterator.hasNext()) {
                    SelectionKey key = iterator.next();
                    // BossSelector负责Accept事件
                    if (key.isAcceptable()) {
                        // 建立连接
                        final SocketChannel socketChannel = server.accept();
                        System.out.println("connected...");
                        socketChannel.configureBlocking(false);
                        // socket注册到Worker的Selector中
                        System.out.println("before read...");
                        // 负载均衡,轮询分配Worker
                        workers[robin.getAndIncrement()% workers.length].register(socketChannel);
                        System.out.println("after read...");
                    }
                    iterator.remove();
                }
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static final class Worker implements Runnable {
        private Thread thread;
        private volatile Selector selector;
        private String name;
        private volatile boolean started = false;
        /**
         * 同步队列, 用于boss线程和worker线程的通信
         */
        private ConcurrentLinkedQueue<Runnable> queue;

        public Worker(String name) {
            this.name = name;
        }

        public void register(SocketChannel socket) throws IOException {
            // 初始化
            if (!started) {
                thread = new Thread(this, name);
                selector = Selector.open();
                queue = new ConcurrentLinkedQueue<>();
                thread.start();
                started = true;
            }

            // 向同步队列中添加SocketChannel的注册事件
            // 在Worker线程中执行注册事件
            queue.add(() -> {
                try {
                    socket.register(selector, SelectionKey.OP_READ);
                } catch (ClosedChannelException e) {
                    e.printStackTrace();
                }
            });
            // 唤醒被阻塞的Selector
            // select类似LockSupport中的park,wakeup的原理类似LockSupport中的unpark
            selector.wakeup();
        }

        @Override
        public void run() {
            while (true) {
                try {
                    // 若没有事件就绪,线程会被阻塞,反之不会被阻塞。从而避免了CPU空转
                    // 就绪事件数量
                    final int select = selector.select();
                    System.out.println(select);

                    // 通过同步队列获得任务并运行
                    final Runnable task = queue.poll();
                    if (task != null) {
                        // 获得任务,执行注册操作
                        task.run();
                    }
                    final Set<SelectionKey> selectionKeys = selector.selectedKeys();
                    final Iterator<SelectionKey> iterator = selectionKeys.iterator();
                    while (iterator.hasNext()) {
                        final SelectionKey key = iterator.next();
                        // Worker只负责Read事件
                        if (key.isReadable()) {
                            // 简化处理,省略细节
                            SocketChannel socketChannel = (SocketChannel)key.channel();
                            ByteBuffer buffer = ByteBuffer.allocate(16);
                            socketChannel.read(buffer);
                            buffer.flip();
                            ByteBufferUtil.debugAll(buffer);
                        }
                        iterator.remove();
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }
    }

四、NIO与BIO


1、Stream 与 Channel

  • stream 不会自动缓冲数据,channel会利用系统提供的发送缓冲区、接受缓冲区(更为底层)
  • stream 仅支持阻塞API,channel 同时支持阻塞、非阻塞API,网络channel 可配合selector 实现多路复用
  • 二者**均为全双工, **即读写可以同时进行
    • 虽然stream 是单向流动的,但是它也是全双工的

2、IO模型


  • 同步: 线程自己去获取结果 (一个线程)
  • 异步: 线程自己不去获取结果,而是由其它线程返回结果 (至少两个线程)
    • 例如: 线程A 调用一个方法后,继续向下走,运行结果由线程B返回

当调用一次 channel.read 或 stream.read 后,会由 用户态 切换到 操作系统内核态来完成真正的数据读取,而读取又分为两个阶段, 分别为:

  • 等待数据阶段
  • 复制数据阶段

在这里插入图片描述

根据UNIX 网络编程 - 卷 I,IO模型主要有以下几种

阻塞 IO

在这里插入图片描述

  • 用户线程进行read操作时,需要等待操作系统执行实际的read操作,此期间用户线程是被阻塞的,无法执行其他操作

非阻塞IO

在这里插入图片描述

  • 用户线程在一个循环中一直调用read方法,若内核空间中还没有数据可读,立即返回
    • 只是在等待阶段非阻塞
  • 用户线程发现内核空间中有数据后,等待内核空间执行复制数据,待复制结束后返回结果

多路复用

在这里插入图片描述

Java中通过Selector实现多路复用

  • 当没有事件时,调用select方法会被阻塞住
  • 一旦有一个或多个事件发生后,就会处理对应的事件,从而实现多路复用

多路复用与阻塞IO的区别

  • 阻塞IO模式下,若线程因accept事件被阻塞,发生read事件后,仍需等待accept事件执行完成后,才能去处理read事件
  • 多路复用模式下,一个事件发生后,若另一个事件处于阻塞状态,不会影响该事件的执行

异步IO

在这里插入图片描述

  • 线程1调用方法后立即返回,不会被阻塞也不需要立即获取结果
  • 当方法的运行结果出来以后,由线程2将结果返回给线程1

3、零拷贝


零拷贝指的是数据无需拷贝到JVM内存中,同时具有以下三个优点

  • 更少的用户态与内核态的切换
  • 不利用cpu 计算,减少cpu缓存伪共享
  • 零拷贝适合小文件传输

传统 IO 问题

传统的 IO 将一个文件通过 socket 写出

File f = new File("helloword/data.txt");
RandomAccessFile file = new RandomAccessFile(file, "r");

byte[] buf = new byte[(int)f.length()];
file.read(buf);

Socket socket = ...;
socket.getOutputStream().write(buf);

内部工作流如下:
在这里插入图片描述

  • Java 本身并不具有IO 读写能力,因此read方法调用后,需要从Java 程序的用户态切换到内核态,调用操作系统(kernel) 的读能力,将数据读入 **内核缓冲区. **这期间用户线程阻塞,操作系统使用DMA (Direct Memory Access) 来实现文件读,期间也不会使用CPU

DMA 也可理解为硬件单元,用来解放 cpu 完成文件 IO

  • 内核态切换回用户态,将数据从内核缓冲区读入用户缓冲区 (即byte[] buf),这期间** CPU 会参与拷贝**,无法利用DMA
  • 调用write 方法,这时将数据从用户缓冲区 写入** socket缓冲区**,CPU 会参与拷贝
  • 接下来要向网卡写数据,这项能力 Java 又不具备,因此又得从用户态切换至内核态,调用操作系统的写能力,使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU

可以看到中间环节较多,java 的 IO 实际不是物理设备级别的读写,而是缓存的复制,底层的真正读写是操作系统来完成的

  • 用户态与内核态的切换发生了3次,这个操作比较重量级
  • 数据拷贝发生了 4 次

NIO优化

通过 DirectByteBuf

  • ByteBuffer.allocate(10)
    • 底层对应 HeapByteBuffer,使用的还是 Java 内存
  • ByteBuffer.allocateDirect(10)
    • 底层对应DirectByteBuffer,使用的是操作系统内存

在这里插入图片描述

大部分步骤与优化前一样,唯有一点: Java 可以使用DirectByteBuffer将堆外内存映射到Jvm 内存中来直接访问使用

  • 这块内存不受JVM 垃圾回收的影响,因此内存地址固定,有助于IO 读写
  • Java 中的 DirectByteBuf 对象仅维护了此内存的虚引用,内存回收分为两步
    • DirectByteBuffer 对象被垃圾回收,将虚引用加入引用队列
      • 当引用的对象ByteBuffer被垃圾回收以后,虚引用对象Cleaner就会被放入引用队列中,然后调用cleaner的clean 方法来释放直接内存
      • DirectByteBuffer的释放底层调用的是 Unsafe 的 freeMemory 方法
    • 通过专门线程访问引用队列,根据虚引用释放堆外内存
  • 减少了一次数据拷贝,用户态与内核态的切换次数没有减少

进一步优化1

以下两种方式都是零拷贝,即无需将数据拷贝到用户缓冲区中(JVM内存中)
底层采用了 linux 2.1 后提供的 sendFile 方法,Java 中对应着两个 channel 调用 transferTo/transferFrom 方法拷贝数据
在这里插入图片描述

  • Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 CPU
  • 数据从内核缓冲区传输到 socket 缓冲区,CPU 会参与拷贝
  • 最后使用 DMA 将 socket 缓冲区的数据写入网卡,不会使用 CPU

这种方法下:

  • 数据只发生了三次拷贝
  • 只发生了一次用户态与内核态的转换.

进一步优化2

linux 2.4 对上述方法再次进行了优化
在这里插入图片描述

  • Java 调用 transferTo 方法后,要从 Java 程序的用户态切换至内核态,使用 DMA将数据读入内核缓冲区,不会使用 CPU
  • 只会将一些 offset 和 length 信息拷入 socket 缓冲区,几乎无消耗
  • 使用 DMA 将 内核缓冲区的数据写入网卡,不会使用 CPU

整个过程仅只发生了1次用户态与内核态的切换,数据拷贝了 2 次

4、AIO


AIO 用来解决数据复制阶段的阻塞问题

  • 同步意味着,在进行读写操作时,线程需要等待结果,还是相当于闲置
  • 异步意味着,在进行读写操作时,线程不必等待结果,而是将来由操作系统来通过回调方式由另外的线程来获得结果

异步模型需要底层操作系统(Kernel)提供支持

  • Windows 系统通过 IOCP 实现了真正的异步 IO
  • Linux 系统异步 IO 在 2.6 版本引入,但其底层实现还是用多路复用模拟了异步 IO,性能没有优势
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值