NIO

48 阅读16分钟

Netty铺垫-NIO

non-blocking io 非阻塞 IO 因为是为了学习netty而学习的NIO,所以在很多地方做了省略.

三大组件

channel & buffer

channel就是读写数据的双向通道,可以从channel将数据读入buffer,也可以将buffer的数据写入channel,而之前的stream要么是输入,要么是输出,channel比stream更为底层。 常见的Channel有:

  • FileChannel
  • DatagramChannel
  • SocketChannel
  • ServerSocketChannel

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

  • ByteBuffer
    • MappedByteBuffer
    • DirectByteBuffer
    • HeapByteBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer
  • CharBuffer

Selector

selector但从字面意思不好理解,需要结合服务器的设计眼花来理解他的用途

多线程版设计

image.png 多线程版缺点:

  • 内存占用高
  • 线程上下文切换成本高
  • 只适合连接数少的场景

线程池版设计

image.png 线程池版缺点:

  • 阻塞模式下,线程仅能处理一个socket连接
  • 仅适合短连接场景

selector版设计

selector的作用就是配合一个线程来管理多个channel,获取这些channel上发生的事件,这些channel工作在非阻塞模式下,不会让线程吊死在一个channel上。适合连接数特别多,但流量低的场景。 image.png 调用selector的select()会阻塞直到channel发生了读写就绪事件,这些事件发生select方法就会返回这些事件交给thread来处理。

ByteBuffer

ByteBuffer正确使用步骤

:::tips

  1. 向buffer写入数据,例如调用channel.read(buffer)
  2. 调用flip切换至读模式
  3. 从buffer读取数据,例如调用buffer.get()
  4. 调用clear()或compact()切换至写模式
  5. 重复1~4步骤 :::
public class TestByteBuffer {

    public static void main(String[] args) {
        // FileChannel
        //获取Channel
        // 1. 输入输出流 2. RandomAccessFile
        try (FileChannel channel = new FileInputStream("data.txt").getChannel()) {
            //准备缓冲区
            ByteBuffer buffer = ByteBuffer.allocate(10);

            while (channel.read(buffer) != -1){
                // 从channel读取数据向Buffer写入

                //打印 buffer内容
                buffer.flip();// 切换到读模式

                while(buffer.hasRemaining()){ //是否还有剩余数据
                    byte b = buffer.get();
                    System.out.println((char) b);
                }

                buffer.clear();//切换到写模式
            }

        } catch (IOException e) {
        }
    }
}

ByteBuffer结构

ByteBuffer有以下重要属性:

  • capacity
  • position
  • limit

一开始 image.png 写模式下,positon是写入位置,limit等于容量,下图表示写入了4个字节后的状态 image.png flip动作发生后,position切换为读取位置,limit切换为读取限制。 image.png 读取4个字节后,状态 image.png clear动作发生后,状态 image.png compact方法,是把未读完的部分向前压缩,然后切换至写模式 image.png

ByteBuffer常见方法

  • 分配空间

可以使用allocate方法为ByteBuffer分配空间,其他buffer类也有该方法

ByteBuffer buffer = ByteBuffer.allocate(10); 
//java.nio.heapByteBuffer 在堆内存,读写效率较低,收到垃圾回收(GC)的影响

ByteBuffer buffer = ByteBuffer.allocateDirect(10); 
//java.nio.DirectByteBuffer 在直接内存(系统内存),读写效率高(少一次拷贝),不会受到垃圾回收的影响,,分配内存的效率比较低,使用不当会造成内存泄露

  • 向buffer写入数据

有两种办法

  • 调用channel的read方法
  • 调用buffer自己的put方法
int len = channel.read(buffer);

buffer.put((byte) 127);
  • 从buffer读取数据

同样有两种方法

  • 调用channel的write方法
  • 调用buffer的get方法
int len = channel.write(buffer);

byte b = buffer.get();

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

  • 可以调用rewind方法将postion重新置为0

  • 或者调用get(int i )方法获取索引i的内容,他不会移动读指针

  • mark和rese

mark 做一个标记,记录 postion 位置,reset 是将 postion 重置到 mark 的位置

  • ByteBuffer与字符串互转
// 1. 字符串转为ByteBuffer
        ByteBuffer buffer1 = ByteBuffer.allocate(16);
        buffer1.put("hello".getBytes());

        //2. Charset
        ByteBuffer buffer2 = StandardCharsets.UTF_8.encode("hello");//会自动切换到读模式

        //3. wrap
        ByteBuffer buffer3 = ByteBuffer.wrap("hello".getBytes());//自动切换到读模式

        //ByteBuffer转为字符串
        String str1 = StandardCharsets.UTF_8.decode(buffer2).toString();

分散读和集中写

Scattering Reads

分散读取,有一个文本文件3parts.txt :::tips onetwothree ::: 使用如下方式读取,可以将数据填充至多个Buffer

try (RandomAccessFile file = new RandomAccessFile("helloword/3parts.txt", "rw")) {
            FileChannel channel = file.getChannel();
            ByteBuffer a = ByteBuffer.allocate(3);
            ByteBuffer b = ByteBuffer.allocate(3);
            ByteBuffer c = ByteBuffer.allocate(5);
            channel.read(new ByteBuffer[]{a,b,c});
            a.flip();
            b.flip();
            c.flip();
        } catch (IOException e) {
            e.printStackTrace();
        }

GatheringWrites

集中写入

    	//集中写入
        ByteBuffer b1 = StandardCharsets.UTF_8.encode("hello");
        ByteBuffer b2 = StandardCharsets.UTF_8.encode("world");
        ByteBuffer b3 = StandardCharsets.UTF_8.encode("你好");

        try (RandomAccessFile r = new RandomAccessFile("data2.txt", "rw")) {
            FileChannel channel = r.getChannel();
            long write = channel.write(new ByteBuffer[]{b1, b2, b3});

        } catch (IOException e) {
            e.printStackTrace();
        }

黏包半包

黏包半包分析

image.png 黏包:因为tcp是在传输时为了提高效率,可能会把多个消息合并在一起发送,就出现了黏包 半包:因为缓冲区满了,消息没有发送完,留到了下一次发送,就出现了半包。

黏包半包解析

image.png image.png

文件编程

filechannel

注:

只能工作在阻塞模式下

方法简介

获取

image.png

读取

image.png

写入

image.png

关闭

image.png

位置

image.png

大小

image.png

强制写入

image.png

传输数据

image.png transferTo //底层会利用操作系统的零拷贝进行优化,代码简介并且效率高

传输数据大于2g

上面方法一次只能传输2g大小的数据,如果大于2g可以分多次传输。

Path

jdk7引入了Path和Paths类

  • Path用来表示文件路径
  • Paths是工具类,用来获取Path实例
Path source = Paths.get("1.txt");
source.normalize() // 正常化路径

Files

检查文件是否存在

Files.exists(path);

创建一级目录

Files.createDirectory(path)

//如果目录已存在,会抛异常
//不能一次创建多级目录,否则会抛异常

创建多级目录

Files.createDirectories(path);

拷贝文件

Files.copy(source,target);
// 如果文件已存在,会抛异常
// 若果希望用source覆盖掉target,需要用StandardCopyOption来控制
Files.copy(source,target,StandardCopyOption.REPLACE_EXISTING);

删除文件

Files.delete(path);
// 如果文件不存在,会抛异常

删除目录

Files.delete(path);
//如果目录还有内容,会抛异常

遍历目录文件

public class TestFilesWalkFileTree {

    public static void main(String[] args) throws IOException {

        AtomicInteger dirCount = new AtomicInteger();
        AtomicInteger fileCount = new AtomicInteger();
        //遍历文件夹
        Files.walkFileTree(Paths.get("D:\\devlop\\jdk\\OpenJDK21"),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);
        System.out.println("文件:"+fileCount);
    }
}

使用walkFileTree对拷文件夹

        //使用Files API完成文件夹的对拷
        String source = "D:\\data";
        String target = "D:\\data-copy";

        Files.walkFileTree(Paths.get(source),new SimpleFileVisitor<>(){
            @Override
            public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs) throws IOException {
                String targetName = dir.toString().replace(source, target);
                //如果是目录,则创建目录到目标文件夹
                Files.createDirectory(Paths.get(targetName));
                return super.preVisitDirectory(dir, attrs);
            }

            @Override
            public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {
                String targetName = file.toString().replace(source, target);
                //如果是文件,则拷贝
                Files.copy(file,Paths.get(targetName));
                return super.visitFile(file, attrs);
            }
        });

网络

网络编程

阻塞 VS 非阻塞

阻塞

  • 在没有数据可读时,包括数据复制过程中,线程必须阻塞等待,不会占用cpu,但线程相当于闲置
  • 32位jvm一个线程320k,64位jvm一个线程1024k,为了减少线程数,需要采用线程池技术
  • 但即便用了线程池,如果有很多连接建立,但长时间inactive,会阻塞线程池中所有线程
//使用nio来理解阻塞模式 单线程
        //0. ByteBuffer
        ByteBuffer buffer = ByteBuffer.allocate(16);

        //1. 创建服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();

        //2. 绑定监听端口
        ssc.bind(new InetSocketAddress(8080));

        //3. 连接集合
        List<SocketChannel> channels = new ArrayList<>();
        while (true){
            //4. accept建立与客户端的连接,SocketChannel 用来与客户端之间通信
            SocketChannel sc = ssc.accept();
            channels.add(sc);
            // 5. 接收客户端发送的数据
            for (SocketChannel channel : channels) {
                channel.read(buffer);//阻塞方法,客户端发送消息后才会继续执行
                buffer.flip();
                System.out.println(buffer.get());
                buffer.clear();
            }
        }
//在阻塞模式下,一个方法的调用都会影响别的方法执行,所以在阻塞模式下,用一个线程来处理多个链接不是一个正确的方式,可以选择用线程池

非阻塞

  • 在某个Channel没有可读事件时,线程不必阻塞,他可以去处理其他有可读事件的Channel
  • 数据复制过程中,线程实际还是阻塞的(AIO改进的地方)
  • 写数据时,线程只是等待数据写入Channel即可,无需等待Channel通过网络把数据发送出去
//使用nio来理解非阻塞模式 单线程
        //0. ByteBuffer
        ByteBuffer buffer = ByteBuffer.allocate(16);

        //1. 创建服务器
        ServerSocketChannel ssc = ServerSocketChannel.open();

        //2. 绑定监听端口
        ssc.bind(new InetSocketAddress(8080));

        ssc.configureBlocking(false); //切换服务器到非阻塞模式

        //3. 连接集合
        List<SocketChannel> channels = new ArrayList<>();
        while (true){
            //4. accept建立与客户端的连接,SocketChannel 用来与客户端之间通信
            SocketChannel sc = ssc.accept(); 
            // 非阻塞,线程还会继续运行,只不过sc==null
            
            sc.configureBlocking(false); //将sc设为非阻塞模式
            channels.add(sc);
            
            // 5. 接收客户端发送的数据
            for (SocketChannel channel : channels) {
                channel.read(buffer);
                //非阻塞模式,线程仍然会继续运行,如果没有读到数据,read返回0
                buffer.flip();
                System.out.println(buffer.get());
                buffer.clear();
            }
        }

多路复用

线程必须配合Selector才能完成对多个Channel可读写事件的监控,这称之为多路复用。

  • 多路复用仅针对网络IO、普通文件IO没法利用多路复用
  • 如果不用Selector的非阻塞模式,那么Channel读取到的字节很多时候都是0,而Selector保证了有可读事件才去读取
  • Channel输入的数据一旦准备好,会触发Selector的可读事件

使用selector处理accept事件

public static void main(String[] args) throws IOException {
        //1. 创建 selector , 管理多个 channel
        Selector selector = Selector.open();

        ByteBuffer buffer = ByteBuffer.allocate(16);
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);

        //2. 建立selector 和 channel 的联系(注册)
        // selectionKey 就是将来事件发生后,通过它可以知道事件和哪个channel的事件
            //事件类型 1.accept 会在有连接请求时触发
                    //2. connect 是客户端,连接建立后触发的事件
                    //3. read 可读事件 客户端向服务端发消息
                    //4. write 可写事件
        SelectionKey sscKey = ssc.register(selector, 0, null);
        //key 只关注 accept 事件
        sscKey.interestOps(SelectionKey.OP_ACCEPT);

        ssc.bind(new InetSocketAddress(8080));
        while (true){
            //3. select 方法,没有事件发生,线程阻塞,有事件,线程才会恢复运行
            // selcet 再有事件未处理时,不会阻塞如果拿到事件不想处理,可以用cancel方法将事件取消
            selector.select();
            //4. 处理事件
                //selectionKeys 内部包含了所有发生的事件
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            //要在集合遍历的时候删除,要用迭代器遍历,先拿到集合的迭代器
            Iterator<SelectionKey> iter = selectionKeys.iterator();

            while (iter.hasNext()){
                SelectionKey key = iter.next();
                ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                channel.accept();
            }
        }
    }

使用selector处理read事件

public static void main(String[] args) throws IOException {
        //1. 创建 selector , 管理多个 channel
        Selector selector = Selector.open();

        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);

        //2. 建立selector 和 channel 的联系(注册)
        // selectionKey 就是将来事件发生后,通过它可以知道事件和哪个channel的事件
            //事件类型 1.accept 会在有连接请求时触发
                    //2. connect 是客户端,连接建立后触发的事件
                    //3. read 可读事件 客户端向服务端发消息
                    //4. write 可写事件
        SelectionKey sscKey = ssc.register(selector, 0, null);
        //key 只关注 accept 事件
        sscKey.interestOps(SelectionKey.OP_ACCEPT);

        ssc.bind(new InetSocketAddress(8080));
        while (true){
            //3. select 方法,没有事件发生,线程阻塞,有事件,线程才会恢复运行
            // selcet 再有事件未处理时,不会阻塞如果拿到事件不想处理,可以用cancel方法将事件取消
            selector.select();
            //4. 处理事件
                //selectionKeys 内部包含了所有发生的事件
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            //要在集合遍历的时候删除,要用迭代器遍历,先拿到集合的迭代器
            Iterator<SelectionKey> iter = selectionKeys.iterator(); // accept  read

            while (iter.hasNext()){
                SelectionKey key = iter.next();
                //如果一个key被处理了,一定要把key删除,否则下一次循环会报空指针 异常
                iter.remove();
                //5, 区分事件类型
                if (key.isAcceptable()){
                    ServerSocketChannel channel = (ServerSocketChannel) key.channel();
                    SocketChannel sc = channel.accept();
                    sc.configureBlocking(false);
                    SelectionKey scKey = sc.register(selector, SelectionKey.OP_READ, null);
                } else if (key.isReadable()) {
                    try {
                        SocketChannel channel = (SocketChannel)key.channel();
                        ByteBuffer buffer = ByteBuffer.allocate(16);
                        int read = channel.read(buffer);
                        //如果是正常断开,read方法返回值是-1
                        if (read == -1){
                            key.cancel();
                        }else {
                            buffer.flip();
                            System.out.println(buffer.get());
                            buffer.clear();
                        }
                    } catch (IOException e) {
                        e.printStackTrace();
                        key.cancel();
                        //因为客户端断开了,因此需要将key取消(从selector的keys集合中真正删除key)
                    }
                }
            }
        }
    }

selector处理客户端断开 :::tips 在客户端异常断开后服务器会抛异常,这时应该在catch代码块中取消key,当客户端正常断开时,服务端的key会触发一次read事件,返回值是-1,所以应该在read事件后对返回值进行判断,如果是-1的话则取消key,并且中断代码逻辑。代码示例如上面那段代码 ::: selector在处理完事件后一定要在迭代器中remove

selector写入内容过多问题 image.png :::tips 这种情况下,服务端一次性向客户端发送大量数据,但是操作系统的缓冲区是有限的,一次性并不能发送这么多的数据,所以程序会分多次向缓冲区中写入数据,这样虽然客户端最后接受的数据是完整的,但是传输的效率并不高,所以我们可以在缓冲区满的时候让线程去做别的事,当缓冲区空了的时候触发一次写事件,让线程再回来继续向缓冲区写数据。因为netty对这些都做了优化,所以具体的代码不展示。 :::

selector处理消息边界问题

image.png

  • 一种思路是固定消息长度,数据包大小一样,服务器按预定长度读取,缺点是浪费带宽。
  • 另一种思路是按分隔符拆分,缺点是效率低
  • TLV格式。即Type类型、Length长度、Value数据,类型和长度已知的的情况下,就可以方便获取消息大小,分配合适的buffer,缺点是buffer需要提前分配,如果内存过大,则影响server吞吐量
    • Http 1.1 是TLV格式
    • Http 2.0 是LTV格式

selector附件

可以把buffer和channel一起注册到selectionKey上

SelectionKey scKey = sc.register(selector, SelectionKey.OP_READ, buffer);

//获取附件
key.attachment();

扩容

:::tips 当发生时刻一中的情况时,需要对buffer进行扩容,具体操作就是创建一个新的bytebuffer,容量是原来的2倍,然后调用key.attach()方法吧新的buffer当做附件绑定到key上。因为在实际开发中会使用netty进行开发,而netty对nio做了优化,所以这里不展示具体代码。 :::

ByteBuffer大小分配

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

网络编程小结

网络编程小结

selector何时不阻塞:

:::tips

  • 事件发生时
    • 客户端发起连接请求,会触发accept事件
    • 客户端发送数据过来,客户端正常、异常关闭时,都会触发read事件,另外如果发送的数据大于buffer缓冲区会触发多次读取事件
    • channel可写,会触发write事件
    • 在linux下nio bug 发生时
  • 调用selector.wakeup() //唤醒阻塞在selector上的线程
  • 调用selector.close()
  • selector所在线程interrupt :::

多线程优化

前面的代码只有一个选择器,没有充分利用多核cpu,如何改进呢? 分两组选择器

  • 单线程配一个选择器,专门处理accept事件
  • 创建cpu核心数的线程,每个线程配一个选择器,轮流处理read事件
  • image.png
  • 如上图中BOSS线程只负责建立连接,而数据的读写操作分配给worker线程
public class MultiThreadServer {

    public static void main(String[] args) throws IOException {
        Thread.currentThread().setName("boss");
        ServerSocketChannel ssc = ServerSocketChannel.open();
        ssc.configureBlocking(false);
        Selector boss = Selector.open();
        SelectionKey bossKey = ssc.register(boss, SelectionKey.OP_ACCEPT, null);
        ssc.bind(new InetSocketAddress(8080));
        //1. 创建固定数量的worker 并初始化
        Worker worker = new Worker("worker-0");
        while (true){
            boss.select();
            Iterator<SelectionKey> iterator = boss.selectedKeys().iterator();
            while(iterator.hasNext()){
                SelectionKey key = iterator.next();
                iterator.remove();
                if (key.isAcceptable()){
                    SocketChannel sc = ssc.accept();
                    sc.configureBlocking(false);
                    //2. 关联
                    worker.register(sc);
                }
            }
        }
    }


   static class Worker implements Runnable{
        private Thread thread;
        private Selector worker;
        private String name;
        private  volatile boolean start = false;
        private ConcurrentLinkedQueue<Runnable> queue = new ConcurrentLinkedQueue<>();
        public Worker(String name){
            this.name = name;
        }
        //初始化线程和selector
        public void register(SocketChannel sc) throws IOException {
            if (!start){
                thread = new Thread(this,name);
                thread.start();
                worker = Selector.open();
                start = true;
            }
            //向队列里添加了任务,但没有执行
            queue.add(()->{
                try {
                    sc.register(this.worker,SelectionKey.OP_READ,null);
                } catch (ClosedChannelException e) {
                    throw new RuntimeException(e);
                }
            });
        }

        @Override
        public void run() {
            while(true){
                try {
                    worker.select();
                    Runnable task = queue.poll();
                    if (task != null){
                        task.run();
                    }

                    Iterator<SelectionKey> iter = worker.selectedKeys().iterator();
                    while (iter.hasNext()){
                        SelectionKey key = iter.next();
                        iter.remove();
                        if (key.isReadable()){
                            ByteBuffer buffer = ByteBuffer.allocate(16);
                            SocketChannel channel = (SocketChannel) key.channel();
                            channel.read(buffer);
                        }
                    }
                } catch (IOException e) {
                    e.printStackTrace();
                }

            }
        }
    }
}

NIO VS BIO

stream VS channel

  • stream不会自动缓冲数据,channel会利用系统提供的发送缓冲区和接收缓冲区(更为底层)
  • stream仅支持阻塞API,channel同事支持阻塞、非阻塞API,网络channel可配合selecrtor实现多路复用
  • 二者均为全双工,即读写可以同时进行

IO模型

同步阻塞、同步非阻塞、多路复用、异步阻塞、异步非阻塞 当调用一次channel.read或stream.read后,会切换至操作系统内核态来完成真正的数据读取,而读取又分为两个阶段,分别为:

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

image.png

  • 阻塞IO :::tips 在阻塞模式下,当用户线程调用read方法后会切换到内核态,用户线程被阻塞,直到操作系统等待到数据并且复制完数据之后线程才会恢复。 :::

  • 非阻塞IO :::tips 在非阻塞模式下,用户线程调用read方法切换到内核态,如果此时操作系统还没有等待到数据,会直接返回一个结果,用户线程并不会被阻塞,但是需要频繁的调用read方法,也就是频繁的进行内核态的切换,也会浪费计算机的资源。 :::

  • 多路复用

image.png

  • 信号驱动
  • 异步IO :::tips 同步:线程自己去获取结果(一个线程) 异步:线程自己不去获取结果,而是由其他线程送结果(至少两个线程) :::

零拷贝

将本地文件拷贝到网络 image.png

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

NIO优化 通过DirectByteBuffer

  • DirectByteBuffer使用的操作系统内存(操作系统和用户内存都可以访问)

image.png image.png image.png

AIO(异步IO)

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

  • 同步意味着,在进行读写操作时,线程需要等待结果,还是相当于闲置

  • 异步意味着,在进行读写操作时,线程不必等待结果,而是将由操作系统来通过回到方式由另外的线程来获得结果 :::tips 异步模型需要底层操作系统提供支持

  • windows系统通过 IOCP 实现了真正的异步 IO

  • Linux 系统异步 IO 在2,6版本后引入,但其底层实现还是用多路复用模拟了异步IO,性能没有优势 ::: netty4.*版本还不支持异步IO,因为主要是为了学习netty而学的NIO,这里的异步IO就不多做赘述