【JAVA核心知识】6.2: JAVA NIO

1 多路复用IO模型

NIO一说Non Blocking IO,一说New IO,关注点不同这个不做深究。
NIO采用的是多路复用IO模型,值得一提的是多路复用IO是一种IO模型,NIO只是JAVA上的实现,所以NIO是多路复用IO,但是多路复用IO不一定是NIO。
epoll和select就是操作系统API方面多路复用IO的不同实现方式,select是最初的实现方案,后面出现了更强大的epoll代替select成为目前操作系统普遍使用的方案。NIO用的是select还是epoll取决于操作系统如何实现的多路复用IO,而不是说epoll和select是两种不同的IO模型。
JDK的epoll实现存在bug,它会导致Selector空轮询,最终导致CPU 100%。官方声称在JDK1.6版本的update18修复了该问题,但是有消息说直到JDK1.7版本该问题仍旧存在,只不过该BUG发生概率降低了一些而已。该Bug产生的原因是轮询线程因为其他原因被唤醒时,轮询结果为空依然会不断轮询导致CPU使用率100%。
Netty针对这种情况进行了优化:对Selector的select操作周期进行统计,每完成一次空的select操作进行一次计数,若在某个周期内连续发生N次空轮询,则触发了epoll死循环bug。重建Selector,判断是否是其他线程发起的重建请求,若不是则将原SocketChannel从旧的Selector上去除注册,重新注册到新的Selector上,并将原来的Selector关闭。
关于多路复用IO模型,在上一篇6.1: JAVA IO基础中已经提过:多路复用IO模型中Selector会不断的去轮询Socket的状态,且轮询动作并非用户线程完成,而是在内核中进行的,只有socket有真正的读写事件时,才调用实际的IO读写操作。系统不需要为每个socket都建立新的线程以监听其状态,自然也不必去维护这些线程,只需要建立一个线程管理Selector即可。无论有真正的IO事件才占用资源进行IO操作,还是无需进行额外的线程维护,亦或是内核轮询都使得多路复用IO模型在管理的链接数较多时拥有更大的优势。
单线程轮询的机制也带了一定的缺点:如果一个socket的响应耗时较长,那么就会影响到后续socket的轮询,此时若后续存在有IO事件的socket,那么其响应就会受到影响。

2 NIO的三大核心

NIO主要有三大核心部分:Channel(通道),Buffer(缓冲区),Selector。传统IO是基于字节流,字符流进行操作的。NIO是基于Channel,Buffer进行操作的,数据总是从通道读取到缓冲区,或者由缓冲区读入通道中。Selector则用于监听多个通道的事件,以此实现单个线程监听多个通道。
在这里插入图片描述

2.1 Buffer

2.1.1 Buffer简介

Buffer-缓冲区,可以将其看做容器,以数组形式存储数据。NIO中无论写入数据还是读出数据都是针对缓冲区的(写入数据是写入Buffer,读出数据也是从Buffer中读出)。

2.1.2 直接缓冲区与非直接缓冲区

Buffer分为非直接缓冲区和直接缓冲区两类。

2.1.2.1 非直接缓冲区

是指缓冲区使用的是JVM管理的内存区域。对于非直接缓冲区,因为任何IO操作都需要操作系统在内核态完成,JVM则属于用户态进程,因此JVM要进行IO操作,需要交给操作系统完成,而因为非直接缓冲区使用的是JVM管理的内存区域,而JVM管理的内存区域并不要求物理地址连续,只需要逻辑地址连续即可。但是JVM维护的逻辑地址操作系统并不知道,且操作系统的IO操作是针对内存块来说的,也就是说数据需要在一块物理地址连续的内存上。因此要想让操作系统完成IO操作就需要将JVM中的非直接缓冲区的数据复制一份到操作系统直接管理的一块物理地址连续的内存上。

2.1.2.2 直接缓冲区

直接缓冲区则是直接向操作系统单纯申请的一块物理地址连续的内存区域,严格上来说这块内存区域并不属于JVM,JVM只是有暂时的使用权。做个比喻就是:JVM管理的内存区域是政府分配给张三的土地,直接内存区域则是张三向政府租用的一块土地。
直接缓冲区在进行IO操作时无需进行复制操作,但是相对的其分配和释放的代价都比非直接缓冲区昂贵。

2.1.3 缓冲区带来的优势

传统IO面向流的基础使得其只能一次读取一个或者多个字节,直到读取完毕,读出的数据没有被缓存起来,因此其无法前后移动的去读取数据,如果想要前后移动的读取数据,需要手动的将读出的数据缓存起来。NIO基于缓冲区的概念则使得其天然具有这种特性,基于缓冲区的数据操作增加了处理过程中的灵活性。但是需要注意的是处理过程中的覆盖问题,避免新读入的数据将未处理的数据覆盖掉。

2.1.4 NIO包下的Buffer类

在当前NIO包下,Buffer是一个顶层父类,他是一个抽象类。他的子类有ByteBuffer,IntBuffer,CharBuffer,LongBuffer,DoubleBuffer,FloatBuffer,ShortBuffer。可以看到除了boolean,每个基础数据类型都有与之对应的Buffer类,这些子类也是抽象的。但是他们都提供了一个allocate(int)来返回一个指定长度的非直接缓冲区类别的Heap***Buffer类,和allocateDirect(int)来返回一个指定长度的直接缓冲区类别的的Direct***Buffer类。

2.2 Channel

2.2.1 Channel简介

Channel类似传统IO的流概念。不同的是流是单向的,比如InputStream表示输入流,只能进行读,OutputStream表示输出流只能进行写。而Channel是双向的,即可以进行读,又可以进行写。
值得注意的的是无论是读还是写都是不能直接对Channel进行操作,而是需要通过Buffer来进行操作。可以把数据理解为水,Buffer理解为蓄水池,Channel理解为蓄水池的管道,两个蓄水池的管道连接后,蓄水池也就连在一起了,这时要想输送水,需要先把水放入输入方蓄水池中,再经由输入方管道传输至接收方管道,最后进入接收方蓄水池,而不能直接破开管道往管道里面灌水。
在这里插入图片描述

2.2.2 NIO包下的Channel类

与Buffer类似,当前NIO下有一个顶层抽象父类Channel。他有四个不同的抽象子类分别对应不同的场景:

  • FileChannel:文件IO
  • DatagramChannel:UDP通信IO
  • SocketChannel:TCP 客户端通信IO
  • ServerSocketChannel:TCP服务端通信IO

2.3 Selector

Selector是NIO的核心。Selector能够检测多个注册在其上的Channel是否有事件发生,如果有事件发生,便记录这个事件,并在用户线程尝试获取事件时将所有已记录的事件返回。NIO能够管理多个连接,在有真正的IO事件时才占用资源进行IO操作,单线程管理多个Channel这些特性都是通过Selector实现的。
在Java NIO包下与Selector关联的一个关键类是SelectionKey。一个SelectionKey表示一个真正到达的IO事件。

3 NIO的基本使用 (例-证)

3.1 文件读取

下面是一个文件读取的小例子,展示了Channel和Buffer的一些简单使用:

package io;

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

public class NioFileDemo {

    public static void main(String[] args) {

        File file = new File("source/nioFileDemoTxtFile.txt"); // 文件内容123
        try {
            FileInputStream fis = new FileInputStream(file);
            FileChannel fc = fis.getChannel();
            ByteBuffer cb = ByteBuffer.allocate(2); // 容量为2的Buffer
            System.out.println("---一阶段---");
            cb.flip(); // 重置已操作的
            do {
                while (cb.hasRemaining()) { // 是否还有需要处理的
                    System.out.println((char) cb.get()); 
                }
                cb.clear(); // 完全重置
                fc.read(cb); // 读取
                cb.flip(); // 重置已操作的
            } while (cb.hasRemaining()); // 是否还有需要处理的
            System.out.println("---二阶段---");
            cb.clear(); // 完全重置
            while (cb.hasRemaining()) { // 是否还有需要处理的
                System.out.println((char) cb.get()); 
            }
            fc.close();
            fis.close();
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

nioFileDemoTxtFile文件内容:

123

打印结果:

---一阶段---
1
2
3
---二阶段---
3
2

查看代码与执行结果,为什么read之前需要执行clear()方法,read之后需要执行flip()方法,以及阶段二的打印并没有进行read,却打印了数据呢?
要明白这个首先要知道clear()方法和flip()方法都做了什么?
在此之前先来看一下顶层类Buffer里维护的4个参数:

 private int mark = -1;
 private int position = 0;
 private int limit;
 private int capacity;

另外上面也提到过缓冲数据的存储是通过每个Buffer内部维护的数组来实现的。

  1. capacity参数,即调用allocate(int)方法时的入参,表示缓冲区的容量,进一步说表示Buffer维护数组的长度。
  2. limit参数,一个限制参数,capacity参数标识着Buffer的容量,limit参数则标识着Buffer的可用大小,Buffer的读写操作只能在0-limit之间进行。
  3. position参数,标识当前下标,即当前的读写操作所进行到的位置。也就表示着无论是read操作还是get操作都会使得position后移。
  4. mark参数,起标记作用,可以通过mark()方法执行将当前position赋值给mark变量来标记当前读取到的位置。public final Buffer mark() { mark = position; return this; }或者通过reset方法将mark赋值给position变量来回退到mark位置,如果mark小于0,则会抛出InvalidMarkException。public final Buffer reset() { int m = mark; if (m < 0) throw new InvalidMarkException(); position = m; return this;}

了解这几个参数的意义再回来看clear()方法和flip()方法就好理解的多了。
首先是clear()方法:

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

clear将position置0,limit置为capacity。根据这两个参数的定义,可以得到clear方法就使整个Buffer空间都处于可操作状态。如果是进行读操作就意味着整个Buffer重置为未读取状态,如果进行写操作则意味这个整个Buffer的数据都已失效,都可以进行覆盖。最后的mark=-1则是使之前作出的mark失效。
然后是flip()方法:

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

可以看到flip()方法和clear()方法的唯一区别就是对limit的赋值,flip方法将limit赋值为position,即当前读取位置,这就意味这个使用过flip方法后只有0-原position区间处于可操作状态,无论是读还是写。
至此,就可以知道为什么read之前需要进行clear,因为在执行read操作之前需要将整个Buffer变的可用,去承载新一轮的数据。
read之后进行flip则是因为clear操作并不会清空数组里的元素,而进行read操作后只有0-原position这段才是新读入的数据,理论上只需操作新数据即可。原position-limit这段可能是上一段的遗留数据,也就是脏数据。这个可以通过上面代码运行结果的的阶段2打印上,文件内容读取完毕后直接进行clear却打印出了最后的Buffer内容体现出来。
read之前需要进行clear进行read之后进行flip正是一般场景下所需要的,可以充分利用Buffer的空间以及避免脏数据的影响。但是并不是绝对的,明白了这两个方法就可以根据其效果来做自己需要的逻辑。

3.2 Socket(体现出NIO的特性与优势)

可以看到NIO在文件读取方面相较于传统IO并没有体现出太大的优势,也没有没有体现出单线程管理多个链接的特性优势,而这个部分使用了Selector的SocketIO则会充分体现出NIO相较于传统IO在管理多链接时的特性优势。
Server端:

package io;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.ServerSocketChannel;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Set;

public class NioServer {

    public static void main(String[] args) {
        try {
            ServerSocketChannel ssc = ServerSocketChannel.open(); // open服务端通道
            ssc.configureBlocking(false); // 设置成非阻塞的,NIO的轮询机制使得其只能监测非阻塞通道
            ssc.bind(new InetSocketAddress(8080)); // 服务端通道绑定一个端口以对外提供入口
            Selector selector = Selector.open(); // 创建一个选择器
            ssc.register(selector, SelectionKey.OP_ACCEPT); // 服务端通道让选择器帮忙监测一下自己通道的请求连接事件
            while (true) { // 一直循环
                int skeyNum = selector.select(50); // 获得选择器监测到的事件数量,阻塞超时时间50ms
                if (skeyNum > 0) { // 如果有事件的话
                    Set<SelectionKey> skSet = selector.selectedKeys(); // 获得所有事件的集合
                    Iterator<SelectionKey> skIter = skSet.iterator(); // 获得事件集合的迭代器
                    while (skIter.hasNext()) { // 轮询所有事件
                        SelectionKey selectionKey = (SelectionKey) skIter.next();
                        // 要把事件从事件集合里Remove掉,选择器只负责把事件放入Set,但是却不负责移除,
                        // 因为他不知道什么时间这个事件算结束。所以移除的操作要自己来做.
                        // 如果不移除,那这个事件就一直在事件集合里面,每次轮询都能取到他
                        skIter.remove(); 
                        if (selectionKey.isAcceptable()) { // 如果这个事件是请求连接性质的
                            accept(selectionKey); // 走请求连接的处理
                        }
                        if (selectionKey.isReadable()) { // 如果这个事件是数据读性质的
                            read(selectionKey);// 走数据读的处理
                        }
                    }
                }
                
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    private static void accept(SelectionKey selectionKey) throws Exception {
        System.out.println("服务端接收到连接请求...");
        // 获得连接请求事件的肯定是服务端的通道了,直接强转
        ServerSocketChannel serverSc = (ServerSocketChannel)selectionKey.channel();
        // 客户端的通道放在服务端通道里面传到服务端,这里用accept把客户端通道(实际的数据传输通道)拿过来
        SocketChannel clientSc = serverSc.accept();
        // 同样的设置成非阻塞
        clientSc.configureBlocking(false);
        // 把客户端通道注册到服务端的选择器上面,让服务端选择器帮忙监测一下这个通道的可读事件
        clientSc.register(selectionKey.selector(), SelectionKey.OP_READ);
        System.out.println("服务端接收到的连接请求监测成功...");
    }
    
    private static void read(SelectionKey selectionKey) throws Exception {
        System.out.println("服务端处理写事件...");
        // 都有读操作了,那这个事件肯定是服务端拿到的客户端通道(实际的数据传输通道)的,毕竟服务端通道可没监测读操作,强转一番
        SocketChannel clientSc = (SocketChannel)selectionKey.channel();
        // 下面这些就和之前文件读取示例一样了,就是把通道里面的数据都读出来,然后打印到控制台
        // 特意定了一个小容量的buffer,以模拟大数据量级的循环读取,设置太长的话要长的字符串,不够清晰
        // 因为Unicode编码是等长编码,一个字符占用2个字节,这里定义为4,一次读取两个字符
        ByteBuffer buffer = ByteBuffer.allocate(4);
        clientSc.read(buffer); // 读取,返回读取了多少字符,返回-1标识通道读取完毕
        buffer.flip(); // 重置已操作的
        StringBuffer strBuf = new StringBuffer();
        while (buffer.hasRemaining()) {// 是否还有需要处理的
            // 满载状态下直接用Array
            if (buffer.remaining() == buffer.capacity()) {
                strBuf.append(new String(buffer.array(),Charset.forName("Unicode")));
            } else {
                // 非满载状态,这里用copyOf是因为array()是返回所有字符,包括limit后面的,所以用copyOf进行阶段,只获得有效的:有效无效上面关于flip方法的部分有描述
                strBuf.append(new String(Arrays.copyOf(buffer.array(), buffer.remaining()),Charset.forName("Unicode")));
            }
            buffer.clear(); // 完全重置
            clientSc.read(buffer); // 继续读取,返回读取了多少字符,返回-1标识通道读取完毕
            buffer.flip(); // 重置已操作的
        }
        System.out.println("服务端这次事件数据读完了! 读取到的数据是:" + strBuf.toString());
        String sendMsg = "放心吧,你的数据我收到了!收到的数据是:" + strBuf.toString();
        System.out.println("下面我要给客户端写个响应,响应内容就是:" + sendMsg);
        // 这里直接写,写的操作还可以放在写事件里面,不过写事件有些特殊,放在Client端举例
        // 创建一个Buffer以进行写,前面提过的,数据想进通道只能从Buffer里面进的。
        ByteBuffer sendBuffer = ByteBuffer.wrap(sendMsg.getBytes(Charset.forName("Unicode")));
        clientSc.write(sendBuffer); // 写到通道里面
        System.out.println("服务端写完毕...");
    }
}

Client端:

package io;

import java.net.InetSocketAddress;
import java.nio.ByteBuffer;
import java.nio.channels.SelectionKey;
import java.nio.channels.Selector;
import java.nio.channels.SocketChannel;
import java.nio.charset.Charset;
import java.util.Arrays;
import java.util.Iterator;
import java.util.Set;

public class NioClient {

    public static void main(String[] args) {
        try {
            SocketChannel sc = SocketChannel.open(); // 打开一个套接字通道
            sc.configureBlocking(false); // 设置成非阻塞的,NIO的轮询机制使得其只能监测非阻塞通道
            System.out.println("客户端开始连接...");
            sc.connect(new InetSocketAddress("localhost", 8080)); // 往本机提供服务的端口连
            Selector selector = Selector.open(); // 打开一个客户的端的选择器
            sc.register(selector, SelectionKey.OP_CONNECT); // 让客户端的选择器帮忙监测一下连接成功的事件
            while (true) { // 一直循环
                int skeyNum = selector.select(50); // 获得选择器监测到的事件数量,阻塞超时时间50ms
                if (skeyNum > 0) { // 如果有事件的话
                    Set<SelectionKey> skSet = selector.selectedKeys(); // 获得所有事件的集合
                    Iterator<SelectionKey> skIter = skSet.iterator(); // 获得事件集合的迭代器
                    while (skIter.hasNext()) { // 轮询所有事件
                        SelectionKey selectionKey = (SelectionKey) skIter.next();
                        // 要把事件从事件集合里Remove掉,选择器只负责把事件放入Set,但是却不负责移除,
                        // 因为他不知道什么时间这个事件算结束。所以移除的操作要自己来做.
                        // 如果不移除,那这个事件就一直在事件集合里面,每次轮询都能取到他
                        skIter.remove(); 
                        if (selectionKey.isConnectable()) { // 如果通道已经连上那台机器了
                            sc.finishConnect(); // 那就不连了,如果不告诉通道不连了,这个通道会不断重复的去连.
                            System.out.println("客户端连接结束...");
                            // 连上的话就让客户端的选择器帮忙监测可读事件,
                            // 注意register方法是一个覆盖操作,这里register客户端选择器监测读写操作,上面的链接成功事件就不会监测
                            // 想同时监测两个事件,就用(SelectionKey.OP_READ | SelectionKey.OP_WRITE)或运算符合并替代第二个参数
                            sc.register(selector, SelectionKey.OP_WRITE | SelectionKey.OP_READ);    
                            // 既然连上了,那就给服务端发个数据吧。也不直接写入,用write事件的方法写入
                            // 然后这里,并没有显式的触发write事件,write事件其实也会被触发,
                            // 还对write的监听在write事件发生后一定要取消的,原因放到具体的write方法里面细说
                        }
                        if (selectionKey.isReadable()) { // 读的事件
                            read(selectionKey); // 进行读
                            
                            // interestOps是一个有趣的方法,作用是改变当前事件的事件类型为指定值,
                            // 有趣是因为如果我这里不continue,那么就会走到下面isWritable的判断逻辑上面,
                            // 然后就会出现一个selectionKey触发了两个事件。有点像switch没break.
                            // 注掉避免干扰
//                            selectionKey.interestOps(SelectionKey.OP_WRITE);
                        }
                        if (selectionKey.isWritable()) { // 写的事件
                            write(selectionKey); // 进行写
                        }
                    }
                }
                
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
    
    // 这个read操作和Server端的read差不多
    private static void read(SelectionKey selectionKey) throws Exception {
        System.out.println("客户端处理读事件...");
        // 客户端的选择器,直接强转
        SocketChannel clientSc = (SocketChannel)selectionKey.channel();
        // 下面这些就和之前Server端是一样,就是把通道里面的数据都读出来,然后打印到控制台
        // 特意定了一个小容量的buffer,以模拟大数据量级的循环读取,设置太长的话要长的字符串,不够清晰
        // 因为Unicode编码是等长编码,一个字符占用2个字节,这里定义为4,一次读取两个字符
        ByteBuffer buffer = ByteBuffer.allocate(4);
        clientSc.read(buffer); // 读取,返回读取了多少字符,返回-1标识通道读取完毕
        buffer.flip(); // 重置已操作的
        StringBuffer strBuf = new StringBuffer();
        while (buffer.hasRemaining()) {// 是否还有需要处理的
            // 满载状态下直接用Array
            if (buffer.remaining() == buffer.capacity()) {
                strBuf.append(new String(buffer.array(),Charset.forName("Unicode")));
            } else {
                // 非满载状态,这里用copyOf是因为array()是返回所有字符,包括limit后面的,所以用copyOf进行阶段,只获得有效的:有效无效上面关于flip方法的部分有描述
                strBuf.append(new String(Arrays.copyOf(buffer.array(), buffer.remaining()),Charset.forName("Unicode")));
            }
            buffer.clear(); // 完全重置
            clientSc.read(buffer); // 继续读取,返回读取了多少字符,返回-1标识通道读取完毕
            buffer.flip(); // 重置已操作的
        }
        System.out.println("收到服务端的数据了! 收到的数据是:" + strBuf.toString());
    }

    private static void write(SelectionKey selectionKey) throws Exception {
        System.out.println("客户端处理写事件...");
        // 客户端的选择器,直接强转
        SocketChannel clientSc = (SocketChannel)selectionKey.channel();
        String sendMsg = "点赞,评论,分享,收藏,关注--(疯狂暗示)";
        System.out.println("客户端开始写,内容为:" + sendMsg);
        // 除了像Server端一样注册write事件写外,直接写也是可以的,服务端依然能获得写事件
        // 除了用wrap之外,还可以用put,不过put之后需要flip
        ByteBuffer sendBuffer = ByteBuffer.allocate(1024); // 整一个Buffer
        // 写到Buffer里面,前面提过的,数据想进通道只能从Buffer里面进的。
        sendBuffer.put(sendMsg.getBytes(Charset.forName("Unicode")));
        sendBuffer.flip(); // 要记得flip,因为put会将position后移
        clientSc.write(sendBuffer); // 写到通道里面   
        System.out.println("客户端写完毕...");
        // 写操作的就绪条件为底层缓冲区有空闲空间,而写缓冲区绝大部分时间都是有空闲时间的,
        // 所以注册完写事件后,写事件一直是就绪的,所以,如果确实有数据要写时再注册写事件,
        // 并在写完之后马上取消注册.否则写事件就会一直发生,也就会一直循环执行写操作
        clientSc.register(selectionKey.selector(), SelectionKey.OP_READ);    
    }
}

Server端执行结果:

服务端接收到连接请求...
服务端接收到的连接请求监测成功...
服务端处理写事件...
服务端这次事件数据读完了! 读取到的数据是:点赞,评论,分享,收藏,关注--(疯狂暗示)
下面我要给客户端写个响应,响应内容就是:放心吧,你的数据我收到了!收到的数据是:点赞,评论,分享,收藏,关注--(疯狂暗示)
服务端写完毕...

Client端执行结果:

客户端开始连接...
客户端连接结束...
客户端处理写事件...
客户端开始写,内容为:点赞,评论,分享,收藏,关注--(疯狂暗示)
客户端写完毕...
客户端处理读事件...
收到服务端的数据了! 收到的数据是:放心吧,你的数据我收到了!收到的数据是:点赞,评论,分享,收藏,关注--(疯狂暗示)

在这种模式下,如果有多个客户端连接服务端,那么NIO的单线程管理,无需自己管理的内核轮询,有真正的IO事件发生才占用资源进行IO的特性优势就充分体现出来了。

4 JAVA NIO包

在这里插入图片描述

图片来源:第七城市



PS:
【JAVA核心知识】系列导航 [持续更新中…]
上篇导航:6.1: JAVA IO基础
下篇导航:6.3: JAVA AIO
欢迎关注…

参考资料:
Java buffer为什么要用flip()和clear()方法
java的直接缓冲和非直接缓冲区
Java NIO?看这一篇就够了!
什么是JAVA NIO
Java NIO 的前生今世 之四 NIO Selector 详解
SocketChannel—各种注意点
Java NIO原理与简单实现

评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

yue_hu

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值