nio基础

nio简介与基本使用

1. NIO简介

Java NIO(New IO / Non Blocking IO) 是从java1.4版本开始引入的一个新的IO API,可以替代标准的Java IO API。NIO与原来的IO有同样的目的和作用,但是使用的方式完全不同,NIO支持面向缓冲区的,基于通道的IO操作,NIO将以更加高效的方式进行文件读写操作。

2.NIO和IO的主要区别

IONIO
面向流(Stream Oriented)面向缓冲区(Buffer Oriented)
阻塞IO(Blocking IO)非阻塞IO(Non Blocking IO)
(无)选择器Selecter

IO(面向流工作大致示意图):

sequenceDiagram
文件磁盘网络->>程序: 输入流
程序->>文件磁盘网络: 输出流

NIO (面向缓冲区工作大致示意图):

sequenceDiagram
文件磁盘网络->>程序: 通道【缓冲区】
程序->>文件磁盘网络: 通道【缓冲区】

通道(Channel)和缓冲区(Buffer)。通道表示打开到IO设备(例如:文件,套接字)的连接。若需要使用NIO系统,需要获取用于连接IO设备的通道以及用于容纳数据的缓冲区,然后操作缓冲区,对数据进行处理
通道时双向的,可以读,或者取,或者同时读取

3.缓冲区

缓冲区buffer的实现类似于数组,可以保存一个或多个相同类型的数据,主要类型如下(boolean类型除外)

  • ByteBuffer
  • CharBuffer
  • ShortBuffer
  • IntBuffer
  • LongBuffer
  • FloatBuffer
  • DoubleBuffer

上述缓冲区都继承自抽象类Buffer,主要的操作数据的方法继承自Buffer,所以操作上都是类似的,例如ByteBuffer(以下都已ByteBuffer为例):

ByteBuffer buf = ByteBuffer.allocate(1024);    //初始化一个1024字节大小ByteBuffer

缓冲区中四个重要属性

  • capacity:缓冲区最大数据容量,不能为负,且创建后不可更改
  • position:下一个读取或者写入数据的索引,位置不负,且不可以大于limit
  • limit: 第一个不应该读取或者写入的索引,即limit之后的数据不可读写,不能为负数且不能大于capacity
  • mark:标记是一个索引,通过Buffer中的mark()方法标记当前position位置,之后可以通过reset方法返回到这个position位置上

下面是Buffer类源码中对上述四个属性的定义和初始化

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

下面通过几个简单的代码来解释这四个属性的设计含义:

  1. allocate()方法执行初始化后,position,limit和capacity的位置:
@Test
public void test1(){
    //1.分配一个指定大小的缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);

    System.out.println("------------allocate--------");
    System.out.println(buf.position()); //0
    System.out.println(buf.limit());    //1024
    System.out.println(buf.capacity()); //1024
}

2.调用put像缓冲区写入数据“abcdef”后,position的由0变更为6,及指向下一个需要读入数据的开始位置
put方法有三个重载:

  • put(byte b):将给定单个字节写入缓冲区的当前位置
  • put(byte[] bs): 将给定的字符数组中所有元素放入缓冲区当前位置
  • put(int index, byte b):将指定字符写入index相应的索引位置
@Test
public  void test2(){
    String str = "abcdef";

    //1.分配一个指定大小的缓冲区
    ByteBuffer buf1 = ByteBuffer.allocate(1024);

    buf1.put(str.getBytes());             //调用put方法读入6个字符
    System.out.println(buf1.position()); //6
    System.out.println(buf1.limit());    //1024
    System.out.println(buf1.capacity()); //1024
}
  1. flip()方法 将缓冲区的界限设置为当前位置,并将当前位置重置为 0
@Test
public  void test3(){
    String str = "abcdef";

    //1.分配一个指定大小的缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);

    buf.put(str.getBytes());
    buf.flip();                          
    System.out.println(buf.position()); //0
    System.out.println(buf.limit());    //6
    System.out.println(buf.capacity()); //1024
}
  1. get方法,读取从position开始,limit结束的所有数据,get方法同样有多个重载
    • get() :从position开始读取单个字节
    • get(byte[] dst):从position开始批量读取多个字节到 dst 中
    • get(int index):从position读取指定索引位置的字节(不会移动 position)
@Test
public  void test4(){
    String str = "abcdef";

    //1.分配一个指定大小的缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);

    buf.put(str.getBytes());
    buf.flip();
    byte[] dst = new byte[buf.limit()];
    buf.get(dst);
    System.out.println(new String(dst,0,dst.length));

    System.out.println(buf.position()); //6
    System.out.println(buf.limit());    //6
    System.out.println(buf.capacity()); //1024
}
  1. rewind: 将position放置至初始位置
@Test
public  void test5(){
    String str = "abcdef";

    //1.分配一个指定大小的缓冲区
    ByteBuffer buf = ByteBuffer.allocate(1024);

    buf.put(str.getBytes());
    buf.flip();
    byte[] dst = new byte[buf.limit()];
    buf.get(dst);
    System.out.println(new String(dst,0,dst.length));

    System.out.println(buf.position()); //6
    System.out.println(buf.limit());    //6
    System.out.println(buf.capacity()); //1024

    buf.rewind();//将position放置至初始位置
    System.out.println(buf.position()); //0
    System.out.println(buf.limit());    //6
    System.out.println(buf.capacity()); //1024
}
  1. mark和reset用法
@Test
public void test7(){
    ByteBuffer buf = ByteBuffer.allocate(1024);
    buf.put("abcde".getBytes());

    buf.flip();

    byte[] dst = new byte[buf.limit()];
    buf.get(dst,0,2);
    System.out.println(new String(dst, 0 ,2 )); //ab
    System.out.println(buf.position());     //2

    //mark 标记一下
    buf.mark();
    buf.get(dst,2,2);
    System.out.println(new String(dst, 0 ,2 )); //ab
    System.out.println(buf.position());         //4

    //reset 重置下
    buf.reset();
    System.out.println(buf.position()); //2
}

8.hasRemainning()和remaining()

  • hasRemaining():缓冲区中是否还有可操作数据
  • remaining():剩余多少个数据
@Test
public void test8(){
    ByteBuffer buf = ByteBuffer.allocate(1024);
    buf.put("abcde".getBytes());

    buf.flip();

    byte[] dst = new byte[buf.limit()];
    buf.get(dst,0,2);
    System.out.println(new String(dst, 0 ,2 )); //ab
    System.out.println(buf.position());     //2

    if(buf.hasRemaining()){     //缓冲区中是否还有可操作数据
        System.out.println(buf.remaining());  //剩余多少个数据
    }
}

4.直接与非直接缓冲区:

字节缓冲区要么是直接的,要么是非直接的。如果为直接字节缓冲区,则 Java 虚拟机会尽最大努力直接在此缓冲区上执行本机 I/O 操作。也就是说,在每次调用基础操作系统的一个本机 I/O 操作之前(或之后),虚拟机都会尽量避免将缓冲区的内容复制到中间缓冲区中(或从中间缓冲区中复制内容)。

直接字节缓冲区可以通过调用此类的 allocateDirect() 工厂方法来创建。此方法返回的缓冲区进行分配和取消分配所需成本通常高于非直接缓冲区。直接缓冲区的内容可以驻留在常规的垃圾回收堆之外,因此,它们对应用程序的内存需求量造成的影响可能并不明显。所以,建议将直接缓冲区主要分配给那些易受基础系统的

本机 I/O 操作影响的大型、持久的缓冲区。一般情况下,最好仅在直接缓冲区能在程序性能方面带来明显好处时分配它们。

直接字节缓冲区还可以通过 FileChannel 的 map() 方法 将文件区域直接映射到内存中来创建。该方法返回MappedByteBuffer 。 Java 平台的实现有助于通过 JNI 从本机代码创建直接字节缓冲区。如果以上这些缓冲区中的某个缓冲区实例指的是不可访问的内存区域,则试图访问该区域不会更改该缓冲区的内容,并且将会在访问期间或稍后的某个时间导致抛出不确定的异常。

区别图形示意:
clipboard

clipboard1

5.通道(Channel)

由java.nio.channel包定义,Channel表示IO源和目标打开的连接。Channel类似与传统的“流”。只不过Channel本身并不能直接访问数据,Channel只能与Buffer进行交互 。

方式一:
clipboard2

方式二:
clipboard3

方式三:(最优方式)(有专门的通道处理器,直接处理IO接口请求)
clipboard4

实现类

java.nio.channels.Channel 接口的实现类:

  • FileChannel : 用于读取,写入,映射和操作文件的通道
  • SocketChannel : 通过TCP读写网络中的数据通道
  • ServerSocketChannel 可以监听新进来的TCP连接,对每一个新进来的连接都会创建一个SocketChannel
  • DatagramChannel 通过UDP读写网络中的数据通道

获取NIO Channel的方法(三种)

  1. 通道的以下各各自的工厂方法getChannel()来获取通道
    • FileInputStream/FileOutputStream
    • randomAccessFile
    • DatagramSocket
    • Socket
    • ServerSocket
      简单例子:
Channel channel = new FileInputStream("a.text").getChannel();
  1. jdk1.7中针对各种通道提供了open方法
    例如FileChannel.open(...)
  2. jdk1.7中通过Files工具类的newByteChannel方法

demo代码,用FileChannel实现文件拷贝(非直接缓冲区)(后面的代码为了精简,就直接将异常抛出,不进行try-catch):

@Test
public void test1(){
    FileInputStream fis = null;
    FileOutputStream fos = null;

    FileChannel inChannel = null;
    FileChannel outChannel = null;

    try{
        fis = new FileInputStream("1.jpg");
        fos = new FileOutputStream("2.jpg");

        //获取通道
        inChannel = fis.getChannel();
        outChannel = fos.getChannel();

        //分配制定大小的缓冲区
        ByteBuffer buf = ByteBuffer.allocate(1024);

        //将通道中的数据写入缓冲区中
        while(inChannel.read(buf) != -1){
            buf.flip(); //切换读数据模式
            outChannel.write(buf);
            buf.clear();
        }
    }catch (IOException e){
        e.printStackTrace();
    }finally {
        if(outChannel != null){
            try{
                outChannel.close();
            }catch(IOException e){
                e.printStackTrace();
            }

        }
        if(inChannel != null){
            try{
                inChannel.close();
            }catch(IOException e){
                e.printStackTrace();
            }

        }
        if(fos != null){
            try{
                fos.close();
            }catch(IOException e){
                e.printStackTrace();
            }

        }
        if(fis != null){
            try{
                fis.close();
            }catch(IOException e){
                e.printStackTrace();
            }

        }
    }
}

直接缓冲区获取channel方式

@Test
public void test2()throws Exception{
    FileChannel inChannel = FileChannel.open(Paths.get("1.jpg"),StandardOpenOption.READ);
    FileChannel outChannel = FileChannel.open(Paths.get("3.jpg"),StandardOpenOption.WRITE,StandardOpenOption.READ,StandardOpenOption.CREATE);

    //内存映射文件
    MappedByteBuffer inByteBuffer = inChannel.map(FileChannel.MapMode.READ_ONLY, 0, inChannel.size());
    MappedByteBuffer outByteBuffer = outChannel.map(FileChannel.MapMode.READ_WRITE,0, inChannel.size());

    //直接操作缓冲区操作就可以,无需操作通道
    byte[] dst = new byte[inByteBuffer.limit()];
    inByteBuffer.get(dst);
    outByteBuffer.put(dst);

    inChannel.close();
    outChannel.close();
}

通道之间的数据传输(直接缓冲区的方式):transferTo和transferFrom

@Test
public void test3()throws Exception{
    FileChannel inChannel = FileChannel.open(Paths.get("1.jpg"),StandardOpenOption.READ);
    FileChannel outChannel = FileChannel.open(Paths.get("3.jpg"),StandardOpenOption.WRITE,StandardOpenOption.READ,StandardOpenOption.CREATE);

    inChannel.transferTo(0, inChannel.size(), outChannel);

    inChannel.close();
    outChannel.close();
}

6.分散(Scatter)与聚集(Gather)

分散读取(Scanttering Reads):将通道中的数据分散到多个缓冲区中,示意图如下

clipboard

注意:按照缓冲区的顺序,从Channel中读取的数据一次将buffer填满

聚集写入(Gathering Writes):将多个缓冲区中的数据聚集到一个通道中,示意图如下

clipboard1

注意:按照缓冲区顺序,写入position和limit之间的数据到Channel

分散读取和聚集写入代码操作代码实例:
即通过read和write函数操作缓冲区数组

@Test
public void test4()throws Exception{
    RandomAccessFile raf1 = new RandomAccessFile("1.txt","rw");

    //1.获取通道
    FileChannel channel1 = raf1.getChannel();

    //2.分配指定大小的缓冲区
    ByteBuffer buf1 = ByteBuffer.allocate(100);
    ByteBuffer buf2 = ByteBuffer.allocate(1024);

    //3.分散读取
    ByteBuffer[] bufs = {buf1,buf2};
    channel1.read(bufs);

    for(ByteBuffer byteBuffer : bufs){
        byteBuffer.flip();
    }
    System.out.println(new String(bufs[0].array(), 0, bufs[0].limit()));
    System.out.println("-----------------------");
    System.out.println(new String(bufs[1].array(), 0, bufs[1].limit()));

    //4.聚集写入
    RandomAccessFile raf2 = new RandomAccessFile("2.txt","rw");
    FileChannel channel2 = raf2.getChannel();

    channel2.write(bufs);
}

7.字符集Charset(编码和解码)

编码: 字符串->字符数组

解码:字符数组->字符串

@Test
public void test6()throws Exception {
    Charset cs1 = Charset.forName("GBK");

    //获取编码器
    CharsetEncoder ce = cs1.newEncoder();

    //获取解码器
    CharsetDecoder cd = cs1.newDecoder();

    CharBuffer cBuf = CharBuffer.allocate(1024);
    cBuf.put("苗总好帅");
    cBuf.flip();

    //将数据写入缓冲区之后对缓冲区编码
    ByteBuffer encode = ce.encode(cBuf);

    for(int i = 0;i<8;i++){
        System.out.println(encode.get());
    }

    //解码
    encode.flip();
    CharBuffer decode = cd.decode(encode);
    System.out.println(decode.toString());
}

结果:

-61
-25
-41
-36
-70
-61
-53
-89
苗总好帅

8.NIO阻塞与非阻塞概念

传统的io流都是阻塞式的,也就是说当一个线城调用read()或write()时,该线程被阻塞,直到有一些数据被读取或者写入,该线程在此期间不能执行其他任务。因此在网络通信进行IO操作时,由于线城会被阻塞,所以服务端必须为每一个客户端都提供一个独立的线程进行处理,当服务器段需要处理大量客户端时,性能急剧下降。

NIO是非阻塞式的,当线程从某个通道进行读写数据时,若没有数据可用时,该线程可以进行其他任务。线程通过将非阻塞IO的空闲时间用于其他通道上执行IO操作,所以单独的线程可以管理多个输入输出通道。因此NIO可以让服务器端使用一个或有限几个线程来同时处理连接到服务器端的所有客户端。

NIO单线程管理多个IO channel的核心方式是通过选择器(selector)同时监控多个SelectableChannel的IO状况。

clipboard2

  1. 一个server一个client阻塞式读写案例:

server端:

@Test
public void server()throws IOException{
    //1.获取通道
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

    FileChannel fileChannel = FileChannel.open(Paths.get("2.jpg"),StandardOpenOption.CREATE,StandardOpenOption.WRITE);

    //2.绑定连接
    serverSocketChannel.bind(new InetSocketAddress(9898));

    //3.获取客户端连接的通道,accept为阻塞函数,线程会再次等待
    SocketChannel socketChannel = serverSocketChannel.accept();

    //4.分配指定大小的缓冲区
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    //5.接受客户端数据,并保存到本地
    while(socketChannel.read(byteBuffer) != -1){
        byteBuffer.flip();
        fileChannel.write(byteBuffer);
        byteBuffer.clear();
    }

    fileChannel.close();
    serverSocketChannel.close();
}

client端:

@Test
public void client() throws IOException {
    //1.获取通道
    SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1",9898));

    FileChannel fileChannel = FileChannel.open(Paths.get("1.jpg"), StandardOpenOption.READ);

    //2分配制定大小的缓冲区
    ByteBuffer buffer = ByteBuffer.allocate(1024);

    //3.读取本地文件,并发送至服务端
    while(fileChannel.read(buffer) != -1){
        buffer.flip();
        socketChannel.write(buffer);
        buffer.clear();
    }

    socketChannel.close();
    fileChannel.close();
}
  1. 非阻塞是读写案例(ideal上对scanner的终端输入读取无法在Test上执行,eclipse运行上是正常的的)

server端

@Test
public void server()throws  IOException{
    //1.通道
    ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();

    //2.切换非阻塞模式
    serverSocketChannel.configureBlocking(false);

    //3.绑定连接
    serverSocketChannel.bind(new InetSocketAddress(9898));

    //4.获取选择器
    Selector selector = Selector.open();

    //5.注册通道 监婷接受状态
    serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

    //6.轮询式获取选择器上已经准备就绪的事件
    while (selector.select() > 0){
        //7.获取但钱选择器中所有注册的选择键(已就绪的监听事件)
        Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

        while(iterator.hasNext()){
            //8.迭代获取
            SelectionKey selectionKey = iterator.next();

            //9.判断具体是什么事件准备就绪
            if(selectionKey.isAcceptable()){
                //10.如果是接受就绪,就获取客户端连接
                SocketChannel socketChannel = serverSocketChannel.accept();

                //11.切换非阻塞模式
                socketChannel.configureBlocking(false);

                //12.将该通道注册到选择器上
                socketChannel.register(selector, SelectionKey.OP_READ);
            }else if (selectionKey.isReadable()){
                //13.获取当前选择器上读就绪状态的通道
                SocketChannel socketChannel = (SocketChannel)selectionKey.channel();

                //14.获取度的数据
                ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

                int len = 0;
                while((len = socketChannel.read(byteBuffer)) > 0){
                    byteBuffer.flip();
                    System.out.println(new String(byteBuffer.array(), 0, len));
                    byteBuffer.clear();
                }
            }

            //取消选择器注册
            iterator.remove();

        }

    }

}

client端

@Test
public void client()throws IOException {
    //1.获取通道
    SocketChannel socketChannel = SocketChannel.open(new InetSocketAddress("127.0.0.1",9898));

    //切换非阻塞模式
    socketChannel.configureBlocking(false);

    //3.分配1024字节大小的缓冲区
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);
    
    //4.发送数据给服务端
    Scanner scanner = new Scanner(System.in);

    while (scanner.hasNext()){
        String str = scanner.next();
        byteBuffer.put(new Date().toString().getBytes());
        byteBuffer.put(str.getBytes());
        byteBuffer.flip();//可读取模式
        socketChannel.write(byteBuffer);
        byteBuffer.clear();
    }

    socketChannel.close();
}

上述代码额外补充部分解释:

  1. Java NIO中的 ServerSocketChannel 是一个可以监听新进来的TCP连接的通道,就像标准IO中的ServerSocket一样
  2. 创建 Selector :通过调用 Selector.open() 方法创建一个 Selector。
Selector selector = Selector.open();
  1. 向选择器注册通道 xxxChannel.register(seleotr, ops) ops为监听的事件类型,例子如下
serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);
  1. 上一个小点中ops为选择器监听通道的事件类型,一共有四个类型, 可以由selectionKey的四个常量表示,四个常量源码定义如下:
public static final int OP_READ = 1 << 0;
public static final int OP_WRITE = 1 << 2;
public static final int OP_CONNECT = 1 << 3;
public static final int OP_ACCEPT = 1 << 4;

可以用位或运算符“|”来是的选择器同时监听此通道的多个事件,如:

serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT|SelectionKey.OP_READ);

SelectionKey的常用API

方法描述
int interestOps()获取感兴趣事件集合
int readyOps()获取通道已经准备就绪的操作的集合
SelectableChannel channel()获取注册通道
Selector selector()返回选择器
boolean isReadable()检测 Channal 中读事件是否就绪
boolean isWritable()检测 Channal 中写事件是否就绪
boolean isConnectable()检测 Channel 中连接是否就绪
boolean isAcceptable()检测 Channel 中接收是否就绪

Selector 的常用方法

方法描述
Set keys()所有的 SelectionKey 集合。代表注册在该Selector上的Channel
selectedKeys()被选择的 SelectionKey 集合。返回此Selector的已选择键集
SelectableChannel channel()获取注册通道
Selector selector()返回选择器
boolean isReadable()检测 Channal 中读事件是否就绪
boolean isWritable()检测 Channal 中写事件是否就绪
boolean isConnectable()检测 Channel 中连接是否就绪
boolean isAcceptable()检测 Channel 中接收是否就绪

9.DatagramChannel

Java NIO中的DatagramChannel是一个能收发UDP包的通道。

操作步骤:

  • 打开 DatagramChannel
  • 接收/发送数据

UDP的send和recieve的基本案例,和上述tcp类似:

@Test
public void send()throws IOException{
    DatagramChannel datagramChannel = DatagramChannel.open();

    datagramChannel.configureBlocking(false);

    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    Scanner scanner = new Scanner(System.in);

    while(scanner.hasNext()){
        String str = scanner.next();
        byteBuffer.put(new Date().toString().getBytes());
        byteBuffer.put(str.getBytes());
        byteBuffer.flip();
        datagramChannel.send(byteBuffer, new InetSocketAddress("127.0.0.1",9898));
        byteBuffer.clear();
    }
    datagramChannel.close();
}

@Test
public void receive()throws IOException{
    DatagramChannel datagramChannel = DatagramChannel.open();

    datagramChannel.configureBlocking(false);

    datagramChannel.bind(new InetSocketAddress(9898));

    Selector selector = Selector.open();

    datagramChannel.register(selector, SelectionKey.OP_READ);

    while(selector.select()>0){
        Iterator<SelectionKey> iterator = selector.selectedKeys().iterator();

        while(iterator.hasNext()){
            SelectionKey selectionKey = iterator.next();

            if(selectionKey.isReadable()){
                ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

                datagramChannel.receive(byteBuffer);
                byteBuffer.flip();
                System.out.println(new String(byteBuffer.array(), 0, byteBuffer.limit()));
                byteBuffer.clear();

            }
        }
        iterator.remove();
    }
}

10.管道(pipe)

Java NIO 管道是2个线程之间的单向数据连接。Pipe有一个source通道和一个sink通道。数据会被写到sink通道,从source通道读取。

123

实例代码:

@Test
public void testPipe() throws IOException{
    //1.获取管道
    Pipe pipe = Pipe.open();

    //2.将缓冲区的数据写入管道
    ByteBuffer byteBuffer = ByteBuffer.allocate(1024);

    Pipe.SinkChannel sinkChannel = pipe.sink();
    byteBuffer.put("通过单项管道发送数据".getBytes());
    byteBuffer.flip();
    sinkChannel.write(byteBuffer);

    //3.读取缓冲区中的数据
    Pipe.SourceChannel sourceChannel = pipe.source();
    byteBuffer.flip();
    int len = sourceChannel.read(byteBuffer);
    System.out.println(new String(byteBuffer.array(),0,len));

    sinkChannel.close();
    sourceChannel.close();
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值