深入理解BIO、NIO、AIO线程模型


        IO模型就是说用什么样的通道进行数据的发送和接收,首先要明确一点:IO是操作系统与其他网络进行数据交互,JDK底层并没有实现IO,而是对操作系统内核函数做的一个封装,IO代码进入底层其实都是native形式的。Java共支持3种网络编程IO模式:BIO,NIO,AIO。下文进行介绍


1. BIO

        BIO(Blocking IO) 又称同步阻塞IO,一个客户端由一个线程来进行处理,伪代码如下:
在这里插入图片描述
可以看到服务端的线程阻塞在两个地方

  • 接受客户端请求的accept()函数
  • 读取数据的read()函数

其中读取数据的read()函数需要经过内核态 - 用户态的切换,read()的阻塞主要发生在

  1. 数据从网卡拷贝到内核态
  2. 数据从内核态拷贝到用户态
    在这里插入图片描述

        这就是传统的阻塞IO,如果客户端一直占用连接不发送数据,那么服务端将一直阻塞在read()函数上,无法接受其他客户端请求!为了解决这种阻塞,可以每次都创建一个新的进程或线程,去调用 read 函数,这样采用多线程的方式就不用阻塞在原客户端的 read 请求上。

        不过,这不叫非阻塞 IO,只不过用了多线程的手段使得主线程没有卡在 read 函数上不往下走罢了。操作系统为我们提供的 read 函数仍然是阻塞的。所以真正的非阻塞 IO,不能是通过我们用户层的小把戏,而是要恳请操作系统为我们提供一个非阻塞的 read 函数。
在这里插入图片描述
BIO代码示例

public class SocketServer {
    public static void main(String[] args) throws IOException {
        //创建socket连接,端口为9000
        ServerSocket serverSocket = new ServerSocket(9000);
        while (true) {
            System.out.println("等待连接。。");
            //阻塞方法
            Socket clientSocket = serverSocket.accept();
            System.out.println("有客户端连接了。。");
            
            //单线程连接,性能不好,下面开启多线程
            //handler(clientSocket);

            //开启多线程
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        handler(clientSocket);
                    } catch (IOException e) {
                        e.printStackTrace();
                    }
                }
            }).start();
        }
    }

    private static void handler(Socket clientSocket) throws IOException {
        byte[] bytes = new byte[1024];
        System.out.println("准备read。。");
        //接收客户端的数据,阻塞方法,没有数据可读时就阻塞
        int read = clientSocket.getInputStream().read(bytes);
        System.out.println("read完毕。。");
        if (read != -1) {
            System.out.println("接收到客户端的数据:" + new String(bytes, 0, read));
        }
        clientSocket.getOutputStream().write("HelloClient".getBytes());
        clientSocket.getOutputStream().flush();
    }
}

上边是BIO连接的示例代码,启动后可以通过telnet与 localhost 9000 建立连接,并发送字符串信息123,测试结果如下:
在这里插入图片描述
在这里插入图片描述
测试成功,但BIO现在已经用的不多了,因为它在大并发下有几个致命的缺点:

  1. 如果BIO使用单线程接受连接,则会阻塞其他连接,效率较低。
  2. 如果使用多线程虽然减弱了单线程带来的影响,但当有大并发进来时,会导致服务器线程太多,压力太大而崩溃。
  3. 就算使用线程池,也只能同时允许有限个数的线程进行连接,如果并发量远大于线程池设置的数量,还是与单线程无异
  4. 操作系统里read函数操作是阻塞操作,如果连接不做数据读写操作会导致线程阻塞,就是说只占用连接,不发送数据,则会浪费资源。比如线程池中500个连接,只有100个是频繁读写的连接,其他占着茅坑不拉屎,浪费资源!
  5. 另外多线程也会有线程切换带来的消耗

综上所述,BIO方式已经不适用于如下的大并发场景,仅适用于连接数目比较小且固定的架构。这种方式对服务器资源要求比较高,但BIO程序简单易理解。


2. NIO

        

2.1 操作系统的非阻塞read函数

        上边说到,用户层无论是否采用多线程,操作系统的read函数本质上还是阻塞的,需要从操作系统层面找到一个非阻塞的 read 函数。操作系统提供了这样的功能,只需要在调用 read 前,将文件描述符设置为非阻塞即可(serverSocket.configureBlocking(false);)。

        这个 read 函数的效果是,如果没有数据到达时(到达网卡并拷贝到了内核缓冲区),立刻返回一个错误值(-1),而不是阻塞地等待。这样,就需要用户线程循环调用 read,直到返回值不为 -1,再开始处理业务。流程如下:
在这里插入图片描述
注意:

  • 数据还未到达网卡,或者到达网卡但还没有拷贝到内核缓冲区之前,这个阶段是非阻塞的。
  • 当数据已到达内核缓冲区,此时调用 read 函数仍然是阻塞的,需要等待数据从内核缓冲区拷贝到用户缓冲区,才能返回。

整体流程如下图
在这里插入图片描述

2.2 NIO 多路复用

        操作系统的read函数解决了读数据时,数据在 网卡 - 内核态 的阻塞问题。但面临多个客户端连接时,如果为每个客户端创建一个线程,服务器端的线程资源还是很容易被耗光。

        面对这种问题,有个聪明的办法就是可以每 accept 一个客户端连接后,将这个文件描述符(connfd)放到一个数组里。然后弄一个新的线程去不断遍历这个数组,调用每一个元素的非阻塞 read 方法,这样,我们就成功用一个线程处理了多个客户端连接。

        但这种做法看起来像多路复用(多个连接都用一个线程处理了嘛),其实不然,这种遍历方式也只是我们用户自己想出的小把戏,每次遍历遇到 read 返回 -1 时仍然是一次浪费资源的系统调用(用户态 - 内核态)。真正的多路复用其实是 把一批文件描述符的集合通过一次系统调用传给内核,只在内核态去遍历,用户态仅处理内核态的遍历结果,这样就减少了 用户态- 内核态的请求次数,从根本上解决了这个问题

        NIO的多路复用底层主要用的是Linux 内核函数(select,poll,epoll)来实现的。windows不支持epoll实现,windows底层是基于winsock2的select函数实现的(不开源)。三种内核模型的区别如下所示!

selectpollepoll(jdk 1.5及以上)
操作方式遍历遍历回调
底层实现数组数组链表哈希表
IO效率每次调用都进行线性遍历,时间复杂度为O(n)每次调用都进行线性遍历,时间复杂度为O(n)事件通知方式,每当有IO事件就绪,系统注册的回调函数就会被调用,时间复杂度O(1)
最大连接有上限(1024个)无上限无上限

NIO 有三大核心组件:

  1. Buffer(缓冲区):buffer 底层就是个数组
  2. Channel(通道):channel 类似于流,每个 channel 对应一个 buffer缓冲区
  3. Selector(多路复用器):channel 会注册到 selector 上,由 selector 根据 channel 读写事件的发生将其交由某个空闲的线程处理

注意:NIO 的 Buffer 和 channel 都是既可以读也可以写,NIO的多路复用示意图如下:
在这里插入图片描述

应用场景:

  • NIO方式适用于连接数目多且连接比较短(轻操作) 的架构, 比如聊天服务器, 弹幕系统, 服务器间通讯,编程比较复杂


2.3 select模型

        select 是操作系统提供的系统调用函数,通过它,我们可以把一个文件描述符的数组发给操作系统, 让操作系统去遍历,确定哪个文件描述符可以读写, 然后告诉我们去处理
在这里插入图片描述

        select每次都会轮询所有的sockchannel看下哪个channel有读写事件,有的话就处理,没有就继续遍历,select和poll模型作为NIO的早期实现,存在一定弊端。下面是一段代码,简单表述一下他们的弊端!

第一个版本:NIO的早期版本模拟代码实现(select)

public class NioServer {

    // 保存客户端连接
    static List<SocketChannel> channelList = new ArrayList<>();

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

        // 创建NIO ServerSocketChannel,与BIO的serverSocket类似
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.socket().bind(new InetSocketAddress(9000));
        
        // 设置ServerSocketChannel为非阻塞, 配置为true,则和BIO类似
        serverSocket.configureBlocking(false);
        System.out.println("服务启动成功");

        while (true) {
            // 非阻塞模式accept方法不会阻塞,否则会阻塞
            // NIO的非阻塞是由操作系统内部实现的,底层调用了linux内核的accept函数
            SocketChannel socketChannel = serverSocket.accept();
            
            if (socketChannel != null) { // 如果有客户端进行连接
                System.out.println("连接成功");
                // 设置SocketChannel为非阻塞
                socketChannel.configureBlocking(false);
                // 保存客户端连接在List中
                channelList.add(socketChannel);
            }
            
            // 遍历连接进行数据读取
            Iterator<SocketChannel> iterator = channelList.iterator();
            
            while (iterator.hasNext()) {
                SocketChannel sc = iterator.next();
                ByteBuffer byteBuffer = ByteBuffer.allocate(128);
                // 非阻塞模式read方法不会阻塞,否则会阻塞
                int len = sc.read(byteBuffer);
                // 如果有数据,把数据打印出来
                if (len > 0) {
                    System.out.println("接收到消息:" + new String(byteBuffer.array()));
                } else if (len == -1) { // 如果客户端断开,把socket从集合中去掉
                    iterator.remove();
                    System.out.println("客户端断开连接");
                }
            }
        }
    }
}

从上述代码可以看到,NIO使用一个mian线程 + 一个数组 解决了BIO的痛点。具体解决方式如下

  1. 首先设置服务端连接的阻塞方式为false,代表非阻塞方式
  2. 非阻塞式接受客户端连接,意味着这段代码一直在轮询的跑,不会阻塞。
  3. 如果有客户端连接进来,就把这个连接放入list集合中
  4. 后续遍历list集合,使用内核非阻塞式读取数据(read函数)
  5. 读取完成再次轮询跑代码

测试如下:
在这里插入图片描述

        这种方式虽然解决了BIO的部分痛点,但并不是很完美。因为select模型的底层实现、io效率、最大连接数在面对高并发时还存在一定弊端!

  1. select 调用需要传入客户端连接的数组,需要拷贝一份到内核,高并发场景下这样的拷贝消耗的资源是惊人的。(epoll模型可优化为不复制)
  2. select 在内核层仍然是通过遍历的方式检查文件描述符的就绪状态,是个同步过程,只不过无系统调用切换上下文的开销。(epoll模型内核层可优化为异步事件通知)
  3. select 仅仅返回可读文件描述符的个数,具体哪个可读还是要用户自己遍历。(epoll模型可优化为只返回给用户就绪的文件描述符,无需用户做无效的遍历)


2.4 poll模型

        poll 也是操作系统提供的系统调用函数。它和 select 的主要区别就是,去掉了 select 只能监听 1024 个文件描述符的限制。


2.5 epoll模型

JDK1.5开始引入了epoll基于事件响应机制来优化NIO。epoll 主要就是针对select模型的三点弊端进行了改进:

  1. 内核中保存一份文件描述符集合,无需用户每次都重新传入,只需告诉内核修改的部分即可。
  2. 内核不再通过轮询的方式找到就绪的文件描述符,而是通过异步 IO 事件唤醒。基于事件响应的,类似于观察者模式!
  3. 内核仅会将有 IO 事件的文件描述符返回给用户,用户也无需遍历整个文件描述符集合。

具体,操作系统提供了这三个函数。

  • 第一步,创建一个 epoll 句柄

    	int epoll_create(int size);
    
  • 第二步,向内核添加、修改或删除要监控的文件描述符。

    	int epoll_ctl(
    	int epfd, int op, int fd, struct epoll_event *event);
    
  • 第三步,类似发起了 select() 调用

    	int epoll_wait(
    	int epfd, struct epoll_event *events, int max events, int timeout);
    

内部原理如下:
在这里插入图片描述

NIO第二个版本:使用epoll模型后的代码示例

public class NioSelectorServer {

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

        // 创建NIO ServerSocketChannel
        ServerSocketChannel serverSocket = ServerSocketChannel.open();
        serverSocket.socket().bind(new InetSocketAddress(9000));
        // 设置ServerSocketChannel为非阻塞
        serverSocket.configureBlocking(false);
        // 打开Selector处理Channel,即创建epoll
        Selector selector = Selector.open();
        // 把ServerSocketChannel注册到selector上,并且selector对客户端accept连接操作感兴趣
        serverSocket.register(selector, SelectionKey.OP_ACCEPT);
        System.out.println("服务启动成功");

        while (true) {
            // 阻塞等待需要处理的事件发生
            selector.select();

            // 获取selector中注册的全部事件的 SelectionKey 实例
            Set<SelectionKey> selectionKeys = selector.selectedKeys();
            Iterator<SelectionKey> iterator = selectionKeys.iterator();

            // 遍历SelectionKey对事件进行处理
            while (iterator.hasNext()) {
                SelectionKey key = iterator.next();
                // 如果是OP_ACCEPT事件,则进行连接获取和事件注册
                if (key.isAcceptable()) {
                    ServerSocketChannel server = (ServerSocketChannel) key.channel();
                    SocketChannel socketChannel = server.accept();
                    socketChannel.configureBlocking(false);
                    // 这里只注册了读事件,如果需要给客户端发送数据可以注册写事件
                    socketChannel.register(selector, SelectionKey.OP_READ);
                    System.out.println("客户端连接成功");
                } else if (key.isReadable()) {  // 如果是OP_READ事件,则进行读取和打印
                    SocketChannel socketChannel = (SocketChannel) key.channel();
                    ByteBuffer byteBuffer = ByteBuffer.allocate(128);
                    int len = socketChannel.read(byteBuffer);
                    // 如果有数据,把数据打印出来
                    if (len > 0) {
                        System.out.println("接收到消息:" + new String(byteBuffer.array()));
                    } else if (len == -1) { // 如果客户端断开连接,关闭Socket
                        System.out.println("客户端断开连接");
                        socketChannel.close();
                    }
                }
                //从事件集合里删除本次处理的key,防止下次select重复处理
                iterator.remove();
            }
        }
    }
}

        在使用epoll模型之后,对简单版本的NIO做了优化处理,可以理解为在第一个版本的NIO上,又增加了一个就绪事件列表集合,这个集合中存放着有事件响应的连接,然后开启一个线程去监听这个集合,有元素的话就进行处理。

总结:

  • 一切的开始,都起源于这个 read 函数是操作系统提供的,而且是阻塞的,我们叫它 阻塞 IO。为了破这个局,程序员在用户态通过多线程来防止主线程卡死。
  • 后来操作系统发现这个需求比较大,于是在操作系统层面提供了非阻塞的 read 函数,这样程序员就可以在用户态用一个线程内通过while遍历,完成多个文件描述符的读取(如果read函数未就绪直接返回-1),这就是 非阻塞 IO
  • 但多个文件描述符的读取就需要遍历,当高并发场景越来越多时,用户态遍历的文件描述符也越来越多,相当于在 while 循环里进行了越来越多的系统调用。后来操作系统又发现这个场景需求量较大,于是又在操作系统层面提供了这样的遍历文件描述符的机制,这就是 IO 多路复用
  • 多路复用快的原因在于:把遍历操作强加到内核中,使得原来的 while 循环里多次系统调用,变成了一次系统调用 + 内核层遍历这些文件描述符。
  • 多路复用有三个函数,最开始是 select,然后又发明了 poll 解决了 select 文件描述符的限制,然后又发明了 epoll 解决 select 的三个不足。


2.6 深入hotpost源码分析epoll模型

        NIO第二个版本的代码中,与简单版本最主要的代码区别在以下三行代码

// 1. 打开Selector处理Channel,即创建epoll ,创建多路复用器
Selector selector = Selector.open();

// 2. 把ServerSocketChannel链接注册到selector多路复用器上,并且selector对客户端accept连接操作感兴趣
serverSocket.register(selector, SelectionKey.OP_ACCEPT);

// 3. 阻塞等待需要处理的事件发生
selector.select();

这三行代码内部是优化的核心,下面一一介绍!

2.3.1. Selector selector = Selector.open();

    public static Selector open() throws IOException {
        return SelectorProvider.provider().openSelector();
    }
    
 ========================= SelectorProvider.provider() =======================
 
    public static SelectorProvider provider() {
        synchronized (lock) {
            if (provider != null)
                return provider;
            return AccessController.doPrivileged(
                new PrivilegedAction<SelectorProvider>() {
                    public SelectorProvider run() {
                            if (loadProviderFromProperty())
                                return provider;
                            if (loadProviderAsService())
                                return provider;
                            // 我们在Windows环境下,默认创建的是WindowsSelectorProvider,如下所示
                            provider = sun.nio.ch.DefaultSelectorProvider.create();
                            return provider;
                        }
                    });
        }
    }

======================= 默认创建的是WindowsSelectorProvider =======================
	  public static SelectorProvider create() {
	        return new WindowsSelectorProvider();
	    }

        进入open方法源码中,可以看到创建的是WindowsSelectorProvider,而项目发布一般都是在linux上,所以需要进入hotport源码中看一下Linux平台的实现。这中现象也是JVM的跨平台性的体现。进入hotpost源码发现如果是linux环境,则创建EpollSelectorProvider。也发现Linux上实现是基于Epoll模型!在这里插入图片描述
所以 SelectorProvider.provider().openSelector() 其实是EpollSelectorProvider.openSelector(),代码如下所示!

在这里插入图片描述
可以看到 EPollSelectorImpl 是具体的实现,那么在EPollSelectorImpl实例化时做了什么事情呢?
在这里插入图片描述
        在这个 EPollArray 包装类中调用了一个 epoll_create 的本地方法,创建了一个epoll对象,并返回一个非负数作为文件描述符,用于对epoll接口的所有后续调用。这个方法属于linux操作系统内核中的方法。至此Selector.open()方法解释完毕,总的来说就是创建了一个linux内核层面的Epoll对象!
在这里插入图片描述


2.3.2. serverSocket.register(selector, SelectionKey.OP_ACCEPT)

查看这个方法的源码,同样要使用LInux下的平台中的EpollSelectorProvider的register方法,核心代码如下:

在这里插入图片描述
这个方法是把当前连接注册到第一步EPollSelectorImpl实例化时生成的EPollArray的集合中,此时并没有事件驱动。直到 selector.select()方法调用linux内核函数epoll_ctl


2.3.3. selector.select()

这个select方法内部会调用doselect()方法,这个方法同样要使用LInux下的平台中的EpollSelectorProvider
在这里插入图片描述
在这个poll方法中,分别调用了Linux核心函数 epoll_ctlepoll_wait,下面分别解释其含义

  1. epoll_ctl: 负责真正去关联socketChannel 和 epoll实例(selector)的关系。如果客户端连接(socketChannel)有发送事件,就会把存在于EPollArray中的这个连接放入epoll实例中的就绪事件列表(rdlist)中去。
    其中这个放入就绪事件列表的操作是怎么做的呢?
    放入操作并非是java去做,每当服务端收到客户端的连接,且该链接有响应事件时,就是使用操作系统的硬中断功能把该连接放入就绪事件列表中去。后续遍历就绪事件列表中的元素即可,减少了无效遍历。
  2. epoll_wait:监听就绪事件列表中的元素,如果没有数据则阻塞。如果有数据,则把数据放入selectedKeys中,供java代码获取元素,响应事件!

在这里插入图片描述

2.7 NIO与redis

        Redis就是典型的基于epoll的NIO线程模型(nginx也是),epoll实例收集所有事件(连接与读写事件),把有数据交互的连接放入就绪事件列表中,由一个服务端线程连续处理所有就绪事件列表中的命令。
在这里插入图片描述
在ae_epoll.c源码中也发现有 epoll_createepoll_ctlepoll_wait,原理与上文讲的类似

问题一:为什么 redis 不建议用 bigkey?

        bigkey的big体现在单个value值很大,一般认为超过10KB就是bigkey。由于redis底层用的是NIO,多路复用一个线程,如果存在bigkey的话,这个bigkey就会占用这个线程较大的时间,导致其他连接的数据交互阻塞,所以不建议使用bigkey。注意:这里说的阻塞并不是 异步非阻塞的阻塞。


3. AIO

        AIO自JDK1.7以后才开始支持,是异步非阻塞的,客户端与服务端的连接(accept)、数据读写(read、write)不再由main线程去执行,而是开辟一个回调函数,当客户端与服务端建立连接时,把这个客户端的连接传入回调函数中,由服务端启动一个子线程去处理,这就完成了异步操作!适用于连接数较多且连接时间较长的应用。

AIO与BIO、NIO的不同之处在于:

  1. AIO是 异步非阻塞模型
  2. NIO是 同步非阻塞模型
  3. BIO是 同步阻塞模型

AIO代码示例

public class AIOServer {

    public static void main(String[] args) throws Exception {
    	//创建服务端
        final AsynchronousServerSocketChannel serverChannel =
                AsynchronousServerSocketChannel.open().bind(new InetSocketAddress(9000));

		//使用CompletionHandler异步处理客户端连接
        serverChannel.accept(null, new CompletionHandler<AsynchronousSocketChannel, Object>() {
            @Override
            public void completed(AsynchronousSocketChannel socketChannel, Object attachment) {
                try {
                    System.out.println("2--"+Thread.currentThread().getName());
                    
                    // 在此接收客户端连接,如果不写这行代码后面的客户端连接连不上服务端
                    serverChannel.accept(attachment, this);
                    System.out.println(socketChannel.getRemoteAddress());
                    ByteBuffer buffer = ByteBuffer.allocate(1024);
                    
                    //使用CompletionHandler异步读取数据
                    socketChannel.read(buffer, buffer, new CompletionHandler<Integer, ByteBuffer>() {
                        @Override
                        public void completed(Integer result, ByteBuffer buffer) {
                            System.out.println("3--"+Thread.currentThread().getName());
                            buffer.flip();
                            System.out.println(new String(buffer.array(), 0, result));
                            socketChannel.write(ByteBuffer.wrap("HelloClient".getBytes()));
                        }

                        @Override
                        public void failed(Throwable exc, ByteBuffer buffer) {
                            exc.printStackTrace();
                        }
                    });
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }

            @Override
            public void failed(Throwable exc, Object attachment) {
                exc.printStackTrace();
            }
        });

        System.out.println("1--"+Thread.currentThread().getName());
        Thread.sleep(Integer.MAX_VALUE);
    }
}

AIO作为异步非阻塞模型,理论上来说应该被广泛使用,但大多数公司并没有使用AIO,而是使用了netty,为什么?

  1. 首先AIO得底层实现仍使用Epoll,并没有很好的实现异步,在性能上对比NIO没有太大优势
  2. 其次AIO的代码逻辑比较复杂,且Linux上AIO还不够成熟
  3. Netty在NIO上做了很多异步的封装,是异步非阻塞框架


4. BIO、NIO、AIO的对比

在这里插入图片描述
一个关于同步异步阻塞非阻塞的段子:

老张爱喝茶,废话不说,煮开水。
出场人物:老张,水壶两把(普通水壶,简称水壶;会响的水壶,简称响水壶)。

  1. 老张把水壶放到火上,立等水开。(同步阻塞) 老张觉得自己有点傻
  2. 老张把水壶放到火上,去客厅看电视,时不时去厨房看看水开没有。(同步非阻塞) 老张还是觉得自己有点傻,于是变高端了,买了把会响笛的那种水壶。水开之后,能大声发出嘀~~~~的噪音。
  3. 老张把响水壶放到火上,立等水开。(异步阻塞)老张觉得这样傻等意义不大
  4. 老张把响水壶放到火上,去客厅看电视,水壶响之前不再去看它了,响了再去拿壶。(异步非阻塞)老张觉得自己聪明了。

所谓同步异步,只是对于水壶而言。

  1. 普通水壶,同步
  2. 响水壶,异步。
  3. 虽然都能干活,但响水壶可以在自己完工之后,提示老张水开了。这是普通水壶所不能及的。
    同步只能让调用者去轮询自己(情况2中),造成老张效率的低下。

所谓阻塞非阻塞,仅仅对于老张而言。

  1. 立等的老张,阻塞
  2. 看电视的老张,非阻塞。
  • 15
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 7
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 7
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值