[netty basics] Java NIO three-piece set

There are three core objects to master in NIO: Buffer, Selector and Channel.

1. Buffer zone

1. Buffer operation basic API

The buffer is actually an array. In the NIO library, all data is processed with the buffer. When reading data, it is read from the buffer; when writing data, it is also written to the buffer; any time the data in NIO is accessed, it is put into the buffer. In a stream-oriented I/O system, all data is directly written or directly read into the Stream object.

public class IntBufferDemo {
    
    
    public static void main(String[] args) {
    
    
        //1-----------------
        // 分配新的int缓冲区,参数为缓冲区容量
        // 新缓冲区的当前位置将为零,其界限(限制位置)将为其容量。它将具有一个底层实现数组,其数组偏移量将为零。
        IntBuffer buffer = IntBuffer.allocate(8);

        for (int i = 0; i < buffer.capacity(); ++i) {
    
    
            int j = 2 * (i + 1);
            // 将给定整数写入此缓冲区的当前位置,当前位置递增
            buffer.put(j);
        }
        // 重设此缓冲区,将限制设置为当前位置,然后将当前位置设置为0
        buffer.flip();
        // 查看在当前位置和限制位置之间是否有元素
        while (buffer.hasRemaining()) {
    
    
            // 读取此缓冲区当前位置的整数,然后当前位置递增
            int j = buffer.get();
            System.out.print(j + "  ");
        }
    }
}

In NIO, all buffer types inherit from the abstract class Buffer. The most commonly used one is ByteBuffer. For the basic types in Java, there is basically a specific Buffer type corresponding to it. The inheritance relationship between them is shown in the figure below. Show.
insert image description here

 

2. The basic principle of Buffer

The Buffer buffer object has some built-in mechanisms that can track and record the status changes of the buffer. If we use the get() method to get data from the buffer or use the put() method to write data into the buffer, the buffer status will be caused. The change.

In the buffer, the most important attributes are the following three, which work together to track changes in the internal state of the buffer.

  • position: Specifies the index of the next element to be written or read. Its value is automatically updated by the get()/put() method. When a new Buffer object is created, the position is initialized to 0.
  • limit: Specifies how much data still needs to be taken out (when writing from the buffer to the channel), or how much space can be put in the data (when reading from the channel into the buffer).
  • Capacity: Specifies the maximum data capacity that can be stored in the buffer, in fact, it specifies the size of the underlying array, or at least specifies the capacity of the underlying array that we are allowed to use.

There are some relative size relationships between the above three attribute values: 0<=position<=limit<=capacity.

If we create a new ByteBuffer object with a capacity of 10, the position is set to 0, and the limit and capacity are set to 10 during initialization. In the process of using the ByteBuffer object in the future, the value of the capacity will not change again, while other Both will vary with use.

import java.io.FileInputStream;
import java.nio.*;
import java.nio.channels.*;

/**
 * 了解buffer基本原理的例子
 */
public class BufferDemo {
    
    


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

        /**
         *初始化buffer :
         *  分配一个10个大小缓冲区,说白了就是分配一个10byte大小的数组
         */
        ByteBuffer buffer = ByteBuffer.allocate(10);
        output("初始化", buffer);


        /**
         * 用io流创建管道
         */
        String userDir = System.getProperty("user.dir");
        String path = userDir + "/network-example/src/main/resources/netty-file/test.txt";
        //这用的是文件IO处理
        FileInputStream fin = new FileInputStream(path);
        //创建文件的操作管道
        FileChannel fc = fin.getChannel();


        /**
         * 1. 将管道的数据写入buffer中
         * 底层其实调用了buffer的put
         */
        fc.read(buffer);
        output("调用read()", buffer);

        //锁定:读取buffer的范围
        buffer.flip();
        output("调用flip()", buffer);

        //判断有没有可读数据
        while (buffer.remaining() > 0) {
    
    
            //get:一个字节一个字节的调用
            //每调用一次,Postion就更新一次
            byte b = buffer.get();
            System.out.println(((char) b));
        }
        output("调用get()", buffer);

        //解锁:所有位置恢复到初始化之前
        buffer.clear();
        output("调用clear()", buffer);

        //最后把管道关闭
        fin.close();
    }

    //把这个缓冲里面实时状态给答应出来
    public static void output(String step, ByteBuffer buffer) {
    
    
        System.out.println(step + " : ");
        //容量,数组大小
        System.out.print("capacity: " + buffer.capacity() + ", ");
        //当前操作数据所在的位置,也可以叫做游标
        System.out.print("position: " + buffer.position() + ", ");
        //锁定值,flip,数据操作范围索引只能在position - limit 之间
        System.out.println("limit: " + buffer.limit());
        System.out.println();
    }
}

 

2.1. put operation

Read some data from the channel to the buffer. Note that reading data from the channel is equivalent to writing
4 bytes of data to the buffer. The position is updated to 4, which is the next byte to be written. The index is 4, and the limit is still 10.

insert image description here

 

2.2. get operation

Before reading data from the buffer, the flip() method must be called.
The method will accomplish the following two things: First, set the limit to the current position value. The second is to set the position to 0. The following source code:

public final Buffer flip() {
    
    
    limit = position;
    position = 0;
    mark = -1;
    return this;
}

That is, lock the data range that exists in the current buffer area, starting from position and ending at limit.
insert image description here

Call the get() method to read data from the buffer and write it to the output channel. At this time, the position increases and the limit remains unchanged, but the position will not exceed the limit value, so after reading the 4 bytes written into the buffer before , the values ​​of position and limit are both 4, as shown in the figure below.
insert image description here
 

2.3. clear() returns the value of the initialized buffer

After reading data from the buffer, the value of limit remains at the value when the flip() method was called, and calling the clear() method can set all state changes to the values ​​at the time of initialization, as shown in the figure below.
insert image description here

 
 

3. buffer allocation

When creating a buffer object, the static method allocate() is called to specify the capacity of the buffer. In fact, calling the allocate() method is equivalent to creating an array of a specified size and wrapping it as a buffer object. Or we can directly wrap an existing array as a buffer object, the sample code is as follows.

/**
 * 手动分配缓冲区
 */
public class BufferWrap {
    
      
    
    public void myMethod() {
    
      
        // 分配指定大小的缓冲区  
        ByteBuffer buffer1 = ByteBuffer.allocate(10);  
          
        // 包装一个现有的数组  
        byte array[] = new byte[10];  
        ByteBuffer buffer2 = ByteBuffer.wrap( array );
    } 
}

 
 

4. buffer fragmentation

In NIO, a piece of the existing buffer is also cut out as a new sub-buffer, but the existing buffer and the created sub-buffer share data at the underlying array level, that is, the sub-buffer A region is equivalent to a view window of an existing buffer.

Calling the slice() method can create a sub-buffer, let's take a look at it through an example.

/**
 * 缓冲区分片
 */
public class BufferSlice {
    
    
    static public void main(String args[]) {
    
    
        ByteBuffer buffer = ByteBuffer.allocate(10);

        // 缓冲区中的数据0-9  
        for (int i = 0; i < buffer.capacity(); ++i) {
    
    
            buffer.put((byte) i);
        }

        // 创建子缓冲区  
        buffer.position(3);
        buffer.limit(7);
        ByteBuffer slice = buffer.slice();
        // 改变子缓冲区的内容  
        for (int i = 0; i < slice.capacity(); ++i) {
    
    
            byte b = slice.get(i);
            b *= 10;
            slice.put(i, b);
        }

        //遍历父buffer,也会遍历到更新的子buffer
        buffer.position(0);
        buffer.limit(buffer.capacity());
        while (buffer.remaining() > 0) {
    
    
            System.out.println(buffer.get());
        }
    }
}

 
 

5. read-only buffer

Read-only buffers are very simple, you can read them, but you cannot write to them. You can convert any regular buffer into a read-only buffer by calling the buffer's asReadOnlyBuffer() method. This method returns a buffer that is exactly the same as the original buffer and shares data with the original buffer, except that it is only read.

If the content of the original buffer has changed, the content of the read-only buffer will also change accordingly.

/**
 * 只读缓冲区
 */
public class ReadOnlyBuffer {
    
    
    public static void main(String args[]) throws Exception {
    
    
        ByteBuffer buffer = ByteBuffer.allocate(10);

        // 缓冲区中的数据0-9  
        for (int i = 0; i < buffer.capacity(); ++i) {
    
    
            buffer.put((byte) i);
        }

        // 创建只读缓冲区  
        ByteBuffer readonly = buffer.asReadOnlyBuffer();

        // 改变原缓冲区的内容  
        for (int i = 0; i < buffer.capacity(); ++i) {
    
    
            byte b = buffer.get(i);
            b *= 10;
            buffer.put(i, b);
        }

        readonly.position(0);
        readonly.limit(buffer.capacity());

        // 只读缓冲区的内容也随之改变  
        while (readonly.remaining() > 0) {
    
    
            System.out.println(readonly.get());
        }
    }
}

If you try to modify the content of the read-only buffer, a ReadOnlyBufferException will be reported. Read-only buffers are useful for protecting data.

 

6. Direct buffer

A direct buffer is a buffer that allocates memory in a special way to speed up I/O. The description in the JDK documentation is: Given a direct byte buffer, the Java virtual machine will do its best to directly process it Perform native I/O operations.

That is, it tries to avoid copying the contents of the buffer to or from an intermediate buffer before (or after) each call to the underlying operating system's native I/O operation. To allocate a direct buffer, you need to call the allocateDirect() method, which is used in the same way as a normal buffer, as shown in the following file.

import java.io.*;
import java.nio.*;
import java.nio.channels.*;


/**
 * 直接缓冲区
 * Zero Copy 减少了一个拷贝的过程
 */
public class DirectBuffer {
    
    
    static public void main(String args[]) throws Exception {
    
    

        //io流创建输入源
        String userDir = System.getProperty("user.dir");
        String infile = userDir + "/network-example/src/main/resources/netty-file/test.txt";
        FileInputStream fin = new FileInputStream(infile);
        FileChannel fcin = fin.getChannel();

        //io流创建输出源
        String outfile = userDir + "/network-example/src/main/resources/netty-file/test-out.txt";
        FileOutputStream fout = new FileOutputStream(outfile);
        FileChannel fcout = fout.getChannel();

        // 使用allocateDirect
        ByteBuffer buffer = ByteBuffer.allocateDirect(1024);

        while (true) {
    
    
            //位置初始化
            buffer.clear();
            //buffer 写入
            int r = fcin.read(buffer);
            if (r == -1) break;
            buffer.flip();
            //buffer 写出:从零开始读
            fcout.write(buffer);
        }
    }
}

 

7. Memory Mapping

Memory mapping is a method of reading and writing file data that can be much faster than regular stream-based or channel-based I/O.
Only the portion of the file that is actually read or written is mapped into memory. Take a look at the sample code below.

/**
 * IO映射缓冲区
 */
public class MappedBuffer {
    
    
    static private final int start = 0;
    static private final int size = 26;

    static public void main(String args[]) throws Exception {
    
    
        String userDir = System.getProperty("user.dir");
        String infile = userDir + "/network-example/src/main/resources/netty-file/test.txt";
        RandomAccessFile raf = new RandomAccessFile(infile, "rw");
        FileChannel fc = raf.getChannel();

        //把缓冲区跟文件系统进行一个映射关联

        MappedByteBuffer mbb = fc.map(FileChannel.MapMode.READ_WRITE, start, size);
        //只要操作缓冲区里面的内容,文件内容也会跟着改变
        mbb.put(0, (byte) 98);  //a
        mbb.put(25, (byte) 122);   //z


        raf.close();
    }
}

 
 

2. Selector

The problem with TPR

The traditional Client/Server mode is based on TPR (Thread per Request). The server creates a thread for each client request, and the thread is solely responsible for processing a client request. One problem brought about by this mode is the sharp increase in the number of threads, and a large number of threads will increase the overhead of the server.

Solve the problem of TPR

In order to avoid this problem, most implementations adopt the thread pool model and set the maximum number of threads in the thread pool, which brings new problems. If there are 200 threads in the thread pool and 200 users are all When downloading a large file, the request of the 201st user cannot be processed in time, even if the 201st user only wants to request a page with a size of a few KB.

The traditional Client/Server mode is shown in the figure below.
insert image description here

 
The non-blocking I/O in NIO adopts the working method based on the Reactor mode. The I/O call will not be blocked, but the specific I/O event of interest is registered, such as the arrival of readable data, a new socket connection, etc. , when a specific event occurs, the system will notify the client.
The core object of non-blocking I/O in NIO is Selector, which is the place to register various I/O events, and when those events occur, Selector will tell us what happened, as shown in the figure below.

insert image description here

It can be seen from the figure that when any registered event such as reading or writing occurs, the corresponding SelectionKey can be obtained from the Selector (the select method needs to be called continuously, and the select method is blocked), and at the same time from the SelectionKey. Find the event that occurred and the specific SelectableChannel where the event occurred to obtain the data sent by the client.

 

Writing a server handler using non-blocking I/O in NIO can be roughly divided into the following three steps.

(1) Register the event of interest to the Selector object;
(2) Obtain the event of interest from the Selector;
(3) When the time event occurs, perform corresponding processing.

Register events of interest to the Selector object.

/**
 * //相当于开启一个server端,并绑定为一个端口,用于客户端向此发起请求
 * //其中这个server端,是非阻塞的,selector帮助server端监听事件,并当事件到来时处理事件
 */
private Selector getSelector() throws IOException {
    
    
    //创建selector对象
    Selector selector = Selector.open();

    //创建 可选择通道并配置为非阻塞式
    
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
    serverSocketChannel.configureBlocking(false);

    //给通道绑定指定端口
    ServerSocket socket = serverSocketChannel.socket();
    InetSocketAddress inetSocketAddress = new InetSocketAddress(9999);
    socket.bind(inetSocketAddress);

    //向selector注册感兴趣的事件:select会监听accept事件
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
    return selector;

}

Get the interested event from the Selector, and start listening through a continuous loop. When the SelectionKey can be obtained, it means that an event has occurred, and then process the event through the process(key) method.

public void listen() {
    
    
    System.out.println("listen on " + this.port + ".");
    try {
    
    
        //轮询主线程
        while (true) {
    
    
            //首先调用select()方法,该方法会阻塞,直到至少有一个事件发生
            selector.select();
            //获取所有的事件
            Set<SelectionKey> keys = selector.selectedKeys();
            Iterator<SelectionKey> iter = keys.iterator();
            //同步的体现:因为每次只能拿一个key,每次只能处理一种状态
            while (iter.hasNext()) {
    
    
                SelectionKey key = iter.next();
                iter.remove();
                //每一个key代表一种状态时间,进行处理: 数据就绪、数据可读、数据可写 等等等等
                process(key);
            }
        }
    } catch (IOException e) {
    
    
        e.printStackTrace();
    }
}

In non-blocking I/O, the inner loop pattern basically follows this approach. First call the select() method, which will block until at least one event occurs, then use the selectedKeys() method to obtain the SelectionKey where the event occurred, and then use the iterator to loop.

 
The last step is to write corresponding processing codes according to different events.

//具体办业务的方法,坐班柜员
//每一次轮询就是调用一次process方法,而每一次调用,只能干一件事,即在同一时间点,只能干一件事
private void process(SelectionKey key) throws IOException {
    
    
    //针对于每一种状态给一个反应
    if (key.isAcceptable()) {
    
    
        ServerSocketChannel server = (ServerSocketChannel) key.channel();
        //这个方法体现非阻塞,不管你数据有没有准备好,你给我一个状态和反馈
        SocketChannel channel = server.accept();
        //一定一定要记得设置为非阻塞
        channel.configureBlocking(false);
        //当数据准备就绪的时候,将状态改为可读
        key = channel.register(selector, SelectionKey.OP_READ);
    } else if (key.isReadable()) {
    
    
        //从多路复用器中拿到客户端的引用
        SocketChannel channel = (SocketChannel) key.channel();
        int len = channel.read(buffer);
        if (len > 0) {
    
    
            buffer.flip();
            String content = new String(buffer.array(), 0, len);
            key = channel.register(selector, SelectionKey.OP_WRITE);
            //在key上携带一个附件,一会再写出去
            key.attach(content);
            System.out.println("读取内容:" + content);
        }
    } else if (key.isWritable()) {
    
    
        SocketChannel channel = (SocketChannel) key.channel();
        String content = (String) key.attachment();
        channel.write(ByteBuffer.wrap(("输出:" + content).getBytes()));
        channel.close();
    }
}

Here it is judged whether to accept requests, read data or write events, and do different processing respectively.

In the I/O system before Java 1.4, all stream-oriented I/O systems are provided. The system processes data one byte at a time, one input stream produces one byte of data, and one output stream consumes one byte of data. Data, stream-oriented I/O is very slow;

In Java 1.4, NIO was introduced, which is a block-oriented I/O system. The system processes data in blocks. Each operation generates or consumes a database in one step. Processing data by blocks is better than processing data by bytes. Processing data is much faster.

 

3. Channel

A channel is an object through which data can be read and written, and of course all data is handled through the Buffer object.

We never write bytes directly to a channel, but instead write data (from the pipe) to a buffer containing one or more bytes. Likewise, bytes are not read directly from the channel, but data is read from the channel into a buffer, and the byte is retrieved from the buffer.

NIO provides a variety of channel objects, and all channel objects implement the Channel interface. The inheritance relationship between them is shown in the figure below.

insert image description here

1. Write data using NIO

Any time data is read, it is not read directly from the channel, but from the channel into the buffer.
Using NIO to read data can be divided into the following three steps.

(1) Obtain Channel from FileInputStream.
(2) Create Buffer.
(3) Load the data into Buffer, and then write to the file through the channel.

/**
 * 管道与buffer的配合1
 */
public class FileOutputDemo {
    
    
    static private final byte message[] = {
    
    83, 111, 109, 101, 32, 98, 121, 116, 101, 115, 46};

    static public void main(String args[]) throws Exception {
    
    
        String userDir = System.getProperty("user.dir");
        String infile = userDir + "/network-example/src/main/resources/netty-file/test.txt";
        FileOutputStream fout = new FileOutputStream(infile);

        FileChannel fc = fout.getChannel();
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        for (int i = 0; i < message.length; ++i) {
    
    
            buffer.put(message[i]);
        }
        buffer.flip();
        fc.write(buffer);
        fout.close();
    }
}

 

2. Read data using NIO

(1) Obtain Channel from FileInputStream.
(2) Create Buffer.
(3) Write data from Channel to Buffer, and then output

/**
 * buffer与channel
 * (1)从FileInputStream获取Channel。
 * (2)创建Buffer。
 * (3)将数据从Channel写入Buffer。
 */
public class FileInputDemo {
    
    


    static public void main(String args[]) throws Exception {
    
    
        String userDir = System.getProperty("user.dir");
        String infile = userDir + "/network-example/src/main/resources/netty-file/test.txt";
        FileInputStream fin = new FileInputStream(infile);

        // 获取通道  
        FileChannel fc = fin.getChannel();

        // 创建缓冲区  
        ByteBuffer buffer = ByteBuffer.allocate(1024);
        // 读取数据到缓冲区  
        fc.read(buffer);
        buffer.flip();

        while (buffer.remaining() > 0) {
    
    
            byte b = buffer.get();
            System.out.print(((char) b));
        }
        fin.close();
    }
}

From the examples of nio writing and reading data, we can know that the channel is used as a pipeline between the data source and the buffer, and realizes the ability of the buffer to connect data with the outside world.

 

3. Multiplexing I/O

Real-world scenarios for multiplexing

After the guest arrives at the store, he applies for a menu. After thinking about what you want to order, call the waiter.
The waiter stands beside him and records the contents of the guest's menu. The process of passing the menu to the chef should also be improved.
Not every menu is recorded and handed over to the back chef. The waiter can record many menus and hand them over to the chef at the same time.
In this way, the labor cost is the lowest for the boss;
for the guests, although they no longer enjoy the VIP service and have to wait for a period of time, these are all acceptable;
for the waiters, basically None of her time was wasted, maximizing her time utilization.

insert image description here

Technology selection:
Currently popular implementations of multiplexed I/O mainly include four types: select, poll, epoll, and kqueue. A comparison of some of their important features is shown in the table below.

insert image description here
Multiplexing I/O technology is most suitable for "high concurrency" scenarios. The so-called "high concurrency" means that at least thousands of connection requests are ready at the same time within 1ms. In other cases, multiplexing I/O technology can't play its advantages. In addition, using Java NIO for function implementation is more complicated than traditional socket implementation, so in practical applications, you need to choose technology according to your business needs.

 
 
Reference:
"Netty4 Core Principles and Handwritten RPC Framework"

Guess you like

Origin blog.csdn.net/hiliang521/article/details/131166370