《Java并发编程实战》36到40讲学习总结

第36讲心得

该讲介绍了生产者-消费者模式。

  1. 生产者 - 消费者模式在编程领域的应用也非常广泛,前面我们曾经提到,Java 线程池本质上就是用生产者 - 消费者模式实现的,所以每当使用线程池的时候,其实就是在应用生产者 - 消费者模式。
  2. 生产者 - 消费者模式的核心是一个任务队列,生产者线程生产任务,并将任务添加到任务队列中,而消费者线程从任务队列中获取任务并执行。
  3. 从架构设计的角度来看,生产者 - 消费者模式有一个很重要的优点,就是解耦。解耦对于大型系统的设计非常重要,而解耦的一个关键就是组件之间的依赖关系和通信方式必须受限。在生产者 - 消费者模式中,生产者和消费者没有任何依赖关系,它们彼此之间的通信只能通过任务队列,所以生产者 - 消费者模式是一个不错的解耦方案。
  4. 除了架构设计上的优点之外,生产者 - 消费者模式还有一个重要的优点就是支持异步,并且能够平衡生产者和消费者的速度差异。在生产者 - 消费者模式中,生产者线程只需要将任务添加到任务队列而无需等待任务被消费者线程执行完,也就是说任务的生产和消费是异步的,这是与传统的方法之间调用的本质区别,传统的方法之间调用是同步的。
  5. 异步化处理最简单的方式就是创建一个新的线程去处理,那中间增加一个“任务队列”究竟有什么用呢?我觉得主要还是用于平衡生产者和消费者的速度差异。我们假设生产者的速率很慢,而消费者的速率很高,比如是 1:3,如果生产者有 3 个线程,采用创建新的线程的方式,那么会创建 3 个子线程,而采用生产者 - 消费者模式,消费线程只需要 1 个就可以了。Java 语言里,Java 线程和操作系统线程是一一对应的,线程创建得太多,会增加上下文切换的成本,所以 Java 线程不是越多越好,适量即可。而生产者 - 消费者模式恰好能支持你用适量的线程。
    
    //任务队列
    BlockingQueue<Task> bq=new
      LinkedBlockingQueue<>(2000);
    //启动5个消费者线程
    //执行批量任务  
    void start() {
      ExecutorService es=executors
        .newFixedThreadPool(5);
      for (int i=0; i<5; i++) {
        es.execute(()->{
          try {
            while (true) {
              //获取批量任务
              List<Task> ts=pollTasks();
              //执行批量任务
              execTasks(ts);
            }
          } catch (Exception e) {
            e.printStackTrace();
          }
        });
      }
    }
    //从任务队列中获取批量任务
    List<Task> pollTasks() 
        throws InterruptedException{
      List<Task> ts=new LinkedList<>();
      //阻塞式获取一条任务
      Task t = bq.take();
      while (t != null) {
        ts.add(t);
        //非阻塞式获取一条任务
        t = bq.poll();
      }
      return ts;
    }
    //批量执行任务
    execTasks(List<Task> ts) {
      //省略具体代码无数
    }

     

  6. 利用生产者 - 消费者模式还可以轻松地支持一种分阶段提交的应用场景。我们知道写文件如果同步刷盘性能会很慢,所以对于不是很重要的数据,我们往往采用异步刷盘的方式。我曾经参与过一个项目,其中的日志组件是自己实现的,采用的就是异步刷盘方式,刷盘的时机是:ERROR 级别的日志需要立即刷盘;数据积累到 500 条需要立即刷盘;存在未刷盘数据,且 5 秒钟内未曾刷盘,需要立即刷盘。在下面的示例代码中,可以通过调用 info()和error() 方法写入日志,这两个方法都是创建了一个日志任务 LogMsg,并添加到阻塞队列中,调用 info()和error() 方法的线程是生产者;而真正将日志写入文件的是消费者线程,在 Logger 这个类中,我们只创建了 1 个消费者线程,在这个消费者线程中,会根据刷盘规则执行刷盘操作,逻辑很简单,这里就不赘述了。
    
    class Logger {
      //任务队列  
      final BlockingQueue<LogMsg> bq
        = new BlockingQueue<>();
      //flush批量  
      static final int batchSize=500;
      //只需要一个线程写日志
      ExecutorService es = 
        Executors.newFixedThreadPool(1);
      //启动写日志线程
      void start(){
        File file=File.createTempFile(
          "foo", ".log");
        final FileWriter writer=
          new FileWriter(file);
        this.es.execute(()->{
          try {
            //未刷盘日志数量
            int curIdx = 0;
            long preFT=System.currentTimeMillis();
            while (true) {
              LogMsg log = bq.poll(
                5, TimeUnit.SECONDS);
              //写日志
              if (log != null) {
                writer.write(log.toString());
                ++curIdx;
              }
              //如果不存在未刷盘数据,则无需刷盘
              if (curIdx <= 0) {
                continue;
              }
              //根据规则刷盘
              if (log!=null && log.level==LEVEL.ERROR ||
                  curIdx == batchSize ||
                  System.currentTimeMillis()-preFT>5000){
                writer.flush();
                curIdx = 0;
                preFT=System.currentTimeMillis();
              }
            }
          }catch(Exception e){
            e.printStackTrace();
          } finally {
            try {
              writer.flush();
              writer.close();
            }catch(IOException e){
              e.printStackTrace();
            }
          }
        });  
      }
      //写INFO级别日志
      void info(String msg) {
        bq.put(new LogMsg(
          LEVEL.INFO, msg));
      }
      //写ERROR级别日志
      void error(String msg) {
        bq.put(new LogMsg(
          LEVEL.ERROR, msg));
      }
    }
    //日志级别
    enum LEVEL {
      INFO, ERROR
    }
    class LogMsg {
      LEVEL level;
      String msg;
      //省略构造函数实现
      LogMsg(LEVEL lvl, String msg){}
      //省略toString()实现
      String toString(){}
    }

     

 

第38讲心得

该讲介绍了高性能限流器Guava RateLimiter。

  1. 下面是RateLimiter的使用示例。
    
    //限流器流速:2个请求/秒
    RateLimiter limiter = 
      RateLimiter.create(2.0);
    //执行任务的线程池
    ExecutorService es = Executors
      .newFixedThreadPool(1);
    //记录上一次执行时间
    prev = System.nanoTime();
    //测试执行20次
    for (int i=0; i<20; i++){
      //限流器限流
      limiter.acquire();
      //提交任务异步执行
      es.execute(()->{
        long cur=System.nanoTime();
        //打印时间间隔:毫秒
        System.out.println(
          (cur-prev)/1000_000);
        prev = cur;
      });
    }
    
    输出结果:
    ...
    500
    499
    499
    500
    499

     

  2.  Guava 采用的是令牌桶算法,其核心是要想通过限流器,必须拿到令牌。也就是说,只要我们能够限制发放令牌的速率,那么就能控制流速了。令牌桶算法的详细描述如下:令牌以固定的速率添加到令牌桶中,假设限流的速率是 r/ 秒,则令牌每 1/r 秒会添加一个;假设令牌桶的容量是 b ,如果令牌桶已满,则新的令牌会被丢弃;请求能够通过限流器的前提是令牌桶中有令牌。b 其实是 burst 的简写,意义是限流器允许的最大突发流量。比如 b=10,而且令牌桶中的令牌已满,此时限流器允许 10 个请求同时通过限流器。令牌桶这个算法,如何用 Java 实现呢?生产者 - 消费者模式似乎可以实现:一个生产者线程定时向阻塞队列中添加令牌,而试图通过限流器的线程则作为消费者线程,只有从阻塞队列中获取到令牌,才允许通过限流器。这个算法看上去非常完美,而且实现起来非常简单,如果并发量不大,这个实现并没有什么问题。可实际情况却是使用限流的场景大部分都是高并发场景,而且系统压力已经临近极限了,此时这个实现就有问题了。问题就出在定时器上,在高并发场景下,当系统压力已经临近极限的时候,定时器的精度误差会非常大,同时定时器本身会创建调度线程,也会对系统的性能产生影响。
  3. Guava 实现令牌桶算法,用了一个很简单的办法,其关键是记录并动态计算下一令牌发放的时间。示例代码如下所示,依然假设令牌桶的容量是 1。关键是 reserve() 方法,这个方法会为请求令牌的线程预分配令牌,同时返回该线程能够获取令牌的时间。其实现逻辑就是上面提到的:如果线程请求令牌的时间在下一令牌产生时间之后,那么该线程立刻就能够获取令牌;反之,如果请求时间在下一令牌产生时间之前,那么该线程是在下一令牌产生的时间获取令牌。由于此时下一令牌已经被该线程预占,所以下一令牌产生的时间需要加上 1 秒。
    
    class SimpleLimiter {
      //下一令牌产生时间
      long next = System.nanoTime();
      //发放令牌间隔:纳秒
      long interval = 1000_000_000;
      //预占令牌,返回能够获取令牌的时间
      synchronized long reserve(long now){
        //请求时间在下一令牌产生时间之后
        //重新计算下一令牌产生时间
        if (now > next){
          //将下一令牌产生时间重置为当前时间
          next = now;
        }
        //能够获取令牌的时间
        long at=next;
        //设置下一令牌产生时间
        next += interval;
        //返回线程需要等待的时间
        return Math.max(at, 0L);
      }
      //申请令牌
      void acquire() {
        //申请令牌时的时间
        long now = System.nanoTime();
        //预占令牌
        long at=reserve(now);
        long waitTime=max(at-now, 0);
        //按照条件等待
        if(waitTime > 0) {
          try {
            TimeUnit.NANOSECONDS
              .sleep(waitTime);
          }catch(InterruptedException e){
            e.printStackTrace();
          }
        }
      }
    }

     

  4. 如果令牌桶的容量大于 1,又该如何处理呢?按照令牌桶算法,令牌要首先从令牌桶中出,所以我们需要按需计算令牌桶中的数量,当有线程请求令牌时,先从令牌桶中出。具体的代码实现如下所示。我们增加了一个 resync() 方法,在这个方法中,如果线程请求令牌的时间在下一令牌产生时间之后,会重新计算令牌桶中的令牌数,新产生的令牌的计算公式是:(now-next)/interval,你可对照上面的示意图来理解。reserve() 方法中,则增加了先从令牌桶中出令牌的逻辑,不过需要注意的是,如果令牌是从令牌桶中出的,那么 next 就无需增加一个 interval 了。
    
    class SimpleLimiter {
      //当前令牌桶中的令牌数量
      long storedPermits = 0;
      //令牌桶的容量
      long maxPermits = 3;
      //下一令牌产生时间
      long next = System.nanoTime();
      //发放令牌间隔:纳秒
      long interval = 1000_000_000;
      
      //请求时间在下一令牌产生时间之后,则
      // 1.重新计算令牌桶中的令牌数
      // 2.将下一个令牌发放时间重置为当前时间
      void resync(long now) {
        if (now > next) {
          //新产生的令牌数
          long newPermits=(now-next)/interval;
          //新令牌增加到令牌桶
          storedPermits=min(maxPermits, 
            storedPermits + newPermits);
          //将下一个令牌发放时间重置为当前时间
          next = now;
        }
      }
      //预占令牌,返回能够获取令牌的时间
      synchronized long reserve(long now){
        resync(now);
        //能够获取令牌的时间
        long at = next;
        //令牌桶中能提供的令牌
        long fb=min(1, storedPermits);
        //令牌净需求:首先减掉令牌桶中的令牌
        long nr = 1 - fb;
        //重新计算下一令牌产生时间
        next = next + nr*interval;
        //重新计算令牌桶中的令牌
        this.storedPermits -= fb;
        return at;
      }
      //申请令牌
      void acquire() {
        //申请令牌时的时间
        long now = System.nanoTime();
        //预占令牌
        long at=reserve(now);
        long waitTime=max(at-now, 0);
        //按照条件等待
        if(waitTime > 0) {
          try {
            TimeUnit.NANOSECONDS
              .sleep(waitTime);
          }catch(InterruptedException e){
            e.printStackTrace();
          }
        }
      }
    }

     

第39讲心得

该讲介绍了 高性能网络应用框架Netty。

  1. 一旦调用了阻塞式 API,在 I/O 就绪前,调用线程会一直阻塞,也就无法处理其他的 socket 连接了。,利用非阻塞式 API 就能够实现一个线程处理多个连接了。那具体如何实现呢?现在普遍都是采用 Reactor 模式。
  2.  Handle 指的是 I/O 句柄,在 Java 网络编程里,它本质上就是一个网络连接。Event Handler 很容易理解,就是一个事件处理器,其中 handle_event() 方法处理 I/O 事件,也就是每个 Event Handler 处理一个 I/O Handle;get_handle() 方法可以返回这个 I/O 的 Handle。Reactor 模式的核心自然是 Reactor 这个类,其中 register_handler() 和 remove_handler() 这两个方法可以注册和删除一个事件处理器;handle_events() 方式是核心,也是 Reactor 模式的发动机,这个方法的核心逻辑如下:首先通过同步事件多路选择器提供的 select() 方法监听网络事件,当有网络事件就绪后,就遍历事件处理器来处理该网络事件。由于网络事件是源源不断的,所以在主程序中启动 Reactor 模式,需要以 while(true){} 的方式调用 handle_events() 方法。
    
    void Reactor::handle_events(){
      //通过同步事件多路选择器提供的
      //select()方法监听网络事件
      select(handlers);
      //处理网络事件
      for(h in handlers){
        h.handle_event();
      }
    }
    // 在主程序中启动事件循环
    while (true) {
      handle_events();

     

  3. Netty 中最核心的概念是事件循环(EventLoop),其实也就是 Reactor 模式中的 Reactor,负责监听网络事件并调用事件处理器进行处理。在 4.x 版本的 Netty 中,网络连接和 EventLoop 是稳定的多对 1 关系,而 EventLoop 和 Java 线程是 1 对 1 关系,这里的稳定指的是关系一旦确定就不再发生变化。也就是说一个网络连接只会对应唯一的一个 EventLoop,而一个 EventLoop 也只会对应到一个 Java 线程,所以一个网络连接只会对应到一个 Java 线程。一个网络连接对应到一个 Java 线程上,有什么好处呢?最大的好处就是对于一个网络连接的事件处理是单线程的,这样就避免了各种并发问题。
  4. Netty 中还有一个核心概念是 EventLoopGroup,顾名思义,一个 EventLoopGroup 由一组 EventLoop 组成。实际使用中,一般都会创建两个 EventLoopGroup,一个称为 bossGroup,一个称为 workerGroup。为什么会有两个 EventLoopGroup 呢?这个和 socket 处理网络请求的机制有关,socket 处理 TCP 网络连接请求,是在一个独立的 socket 中,每当有一个 TCP 连接成功建立,都会创建一个新的 socket,之后对 TCP 连接的读写都是由新创建处理的 socket 完成的。也就是说处理 TCP 连接请求和读写请求是通过两个不同的 socket 完成的。上面我们在讨论网络请求的时候,为了简化模型,只是讨论了读写请求,而没有讨论连接请求。在 Netty 中,bossGroup 就用来处理连接请求的,而 workerGroup 是用来处理读写请求的。bossGroup 处理完连接请求后,会将这个连接提交给 workerGroup 来处理, workerGroup 里面有多个 EventLoop,那新的连接会交给哪个 EventLoop 来处理呢?这就需要一个负载均衡算法,Netty 中目前使用的是轮询算法。
  5. 下面的示例代码基于 Netty 实现了 echo 程序服务端:首先创建了一个事件处理器(等同于 Reactor 模式中的事件处理器),然后创建了 bossGroup 和 workerGroup,再之后创建并初始化了 ServerBootstrap,代码还是很简单的,不过有两个地方需要注意一下。第一个,如果 NettybossGroup 只监听一个端口,那 bossGroup 只需要 1 个 EventLoop 就可以了,多了纯属浪费。第二个,默认情况下,Netty 会创建“2*CPU 核数”个 EventLoop,由于网络连接与 EventLoop 有稳定的关系,所以事件处理器在处理网络事件的时候是不能有阻塞操作的,否则很容易导致请求大面积超时。如果实在无法避免使用阻塞操作,那可以通过线程池来异步处理。
    
    //事件处理器
    final EchoServerHandler serverHandler 
      = new EchoServerHandler();
    //boss线程组  
    EventLoopGroup bossGroup 
      = new NioEventLoopGroup(1); 
    //worker线程组  
    EventLoopGroup workerGroup 
      = new NioEventLoopGroup();
    try {
      ServerBootstrap b = new ServerBootstrap();
      b.group(bossGroup, workerGroup)
       .channel(NioServerSocketChannel.class)
       .childHandler(new ChannelInitializer<SocketChannel>() {
         @Override
         public void initChannel(SocketChannel ch){
           ch.pipeline().addLast(serverHandler);
         }
        });
      //bind服务端端口  
      ChannelFuture f = b.bind(9090).sync();
      f.channel().closeFuture().sync();
    } finally {
      //终止工作线程组
      workerGroup.shutdownGracefully();
      //终止boss线程组
      bossGroup.shutdownGracefully();
    }
    
    //socket连接处理器
    class EchoServerHandler extends 
        ChannelInboundHandlerAdapter {
      //处理读事件  
      @Override
      public void channelRead(
        ChannelHandlerContext ctx, Object msg){
          ctx.write(msg);
      }
      //处理读完成事件
      @Override
      public void channelReadComplete(
        ChannelHandlerContext ctx){
          ctx.flush();
      }
      //处理异常事件
      @Override
      public void exceptionCaught(
        ChannelHandlerContext ctx,  Throwable cause) {
          cause.printStackTrace();
          ctx.close();
      }
    }

     

第40讲心得

本讲介绍了高性能队列Disruptor。

  1. Java SDK 提供了 2 个有界队列:ArrayBlockingQueue 和 LinkedBlockingQueue,它们都是基于 ReentrantLock 实现的,在高并发场景下,锁的效率并不高,那有没有更好的替代品呢?有,今天我们就介绍一种性能更高的有界队列:Disruptor。
  2. Disruptor 是一款高性能的有界内存队列,目前应用非常广泛,Log4j2、Spring Messaging、HBase、Storm 都用到了 Disruptor,那 Disruptor 的性能为什么这么高呢?Disruptor 项目团队曾经写过一篇论文,详细解释了其原因,可以总结为如下:内存分配更加合理,使用 RingBuffer 数据结构,数组元素在初始化时一次性全部创建,提升缓存命中率;对象循环利用,避免频繁 GC。能够避免伪共享,提升缓存利用率。采用无锁算法,避免频繁加锁、解锁的性能消耗。支持批量消费,消费者可以无锁方式消费多个消息。
  3. 相较而言,Disruptor 的使用比 Java SDK 提供 BlockingQueue 要复杂一些,但是总体思路还是一致的,其大致情况如下:在 Disruptor 中,生产者生产的对象(也就是消费者消费的对象)称为 Event,使用 Disruptor 必须自定义 Event,例如示例代码的自定义 Event 是 LongEvent;构建 Disruptor 对象除了要指定队列大小外,还需要传入一个 EventFactory,示例代码中传入的是LongEvent::new;消费 Disruptor 中的 Event 需要通过 handleEventsWith() 方法注册一个事件处理器,发布 Event 则需要通过 publishEvent() 方法。
    
    //自定义Event
    class LongEvent {
      private long value;
      public void set(long value) {
        this.value = value;
      }
    }
    //指定RingBuffer大小,
    //必须是2的N次方
    int bufferSize = 1024;
    
    //构建Disruptor
    Disruptor<LongEvent> disruptor 
      = new Disruptor<>(
        LongEvent::new,
        bufferSize,
        DaemonThreadFactory.INSTANCE);
    
    //注册事件处理器
    disruptor.handleEventsWith(
      (event, sequence, endOfBatch) ->
        System.out.println("E: "+event));
    
    //启动Disruptor
    disruptor.start();
    
    //获取RingBuffer
    RingBuffer<LongEvent> ringBuffer 
      = disruptor.getRingBuffer();
    //生产Event
    ByteBuffer bb = ByteBuffer.allocate(8);
    for (long l = 0; true; l++){
      bb.putLong(0, l);
      //生产者生产消息
      ringBuffer.publishEvent(
        (event, sequence, buffer) -> 
          event.set(buffer.getLong(0)), bb);
      Thread.sleep(1000);
    }

     

  4. ArrayBlockingQueue 使用数组作为底层的数据存储,而 Disruptor 是使用 RingBuffer 作为数据存储。RingBuffer 本质上也是数组,所以仅仅将数据存储从数组换成 RingBuffer 并不能提升性能,但是 Disruptor 在 RingBuffer 的基础上还做了很多优化,其中一项优化就是和内存分配有关的。CPU 的缓存就利用了程序的局部性原理:CPU 从内存中加载数据 X 时,会将数据 X 缓存在高速缓存 Cache 中,实际上 CPU 缓存 X 的同时,还缓存了 X 周围的数据,因为根据程序具备局部性原理,X 周围的数据也很有可能被访问。从另外一个角度来看,如果程序能够很好地体现出局部性原理,也就能更好地利用 CPU 的缓存,从而提升程序的性能。Disruptor 在设计 RingBuffer 的时候就充分考虑了这个问题。Disruptor 内部的 RingBuffer 也是用数组实现的,但是这个数组中的所有元素在初始化时是一次性全部创建的,所以这些元素的内存地址大概率是连续的,相关的代码如下所示。

    
    for (int i=0; i<bufferSize; i++){
      //entries[]就是RingBuffer内部的数组
      //eventFactory就是前面示例代码中传入的LongEvent::new
      entries[BUFFER_PAD + i] 
        = eventFactory.newInstance();
    }

    除此之外,在 Disruptor 中,生产者线程通过 publishEvent() 发布 Event 的时候,并不是创建一个新的 Event,而是通过 event.set() 方法修改 Event, 也就是说 RingBuffer 创建的 Event 是可以循环利用的,这样还能避免频繁创建、删除 Event 导致的频繁 GC 问题。

  5. 高效利用 Cache,能够大大提升性能,所以要努力构建能够高效利用 Cache 的内存结构。而从另外一个角度看,努力避免不能高效利用 Cache 的内存结构也同样重要。有一种叫做“伪共享(False sharing)”的内存布局就会使 Cache 失效,那什么是“伪共享”呢?伪共享和 CPU 内部的 Cache 有关,Cache 内部是按照缓存行(Cache Line)管理的,缓存行的大小通常是 64 个字节;CPU 从内存中加载数据 X,会同时加载 X 后面(64-size(X))个字节的数据。如何解决伪共享?每个变量独占一个缓存行、不共享缓存行就可以了,具体技术是缓存行填充。

    
    //前:填充56字节
    class LhsPadding{
        long p1, p2, p3, p4, p5, p6, p7;
    }
    class Value extends LhsPadding{
        volatile long value;
    }
    //后:填充56字节
    class RhsPadding extends Value{
        long p9, p10, p11, p12, p13, p14, p15;
    }
    class Sequence extends RhsPadding{
      //省略实现
    }

     

  6. Disruptor 采用的是无锁算法,很复杂,但是核心无非是生产和消费两个操作。Disruptor 中最复杂的是入队操作,所以我们重点来看看入队操作是如何实现的。对于入队操作,最关键的要求是不能覆盖没有消费的元素;对于出队操作,最关键的要求是不能读取没有写入的元素,所以 Disruptor 中也一定会维护类似出队索引和入队索引这样两个关键变量。Disruptor 中的 RingBuffer 维护了入队索引,但是并没有维护出队索引,这是因为在 Disruptor 中多个消费者可以同时消费,每个消费者都会有一个出队索引,所以 RingBuffer 的出队索引是所有消费者里面最小的那一个。下面是 Disruptor 生产者入队操作的核心代码,看上去很复杂,其实逻辑很简单:如果没有足够的空余位置,就出让 CPU 使用权,然后重新计算;反之则用 CAS 设置入队索引。

    ​
    
    class Proxy {
      boolean started = false;
      //采集线程
      Thread rptThread;
      //启动采集功能
      synchronized void start(){
        //不允许同时启动多个采集线程
        if (started) {
          return;
        }
        started = true;
        rptThread = new Thread(()->{
          while (!Thread.currentThread().isInterrupted()){
            //省略采集、回传实现
            report();
            //每隔两秒钟采集、回传一次数据
            try {
              Thread.sleep(2000);
            } catch (InterruptedException e){
              //重新设置线程中断状态
              Thread.currentThread().interrupt();
            }
          }
          //执行到此处说明线程马上终止
          started = false;
        });
        rptThread.start();
      }
      //终止采集功能
      synchronized void stop(){
        rptThread.interrupt();
      }
    }
    
    ​

     

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值