《Java 8实战》- Stream 详解

目录

使用流

数值流

构建流

Collectors

Collector

并行流


从支持数据处理操作的源生成的元素序列( 源:提供数据,如集合、数组、输入/输出资源。从有序集合生成流时会保留原有顺序)

中间操作:可以连接起来的流操作。返回流,出发触发终端操作否则不会进行任何处理,不消耗流

终端操作:关闭流的操作。从流的流水线生成结果,返回任何非流的值

流的使用一般包括三件事

  • 一个数据源来执行一个查询;

  • 一个中间操作链,形成一条流的流水线;

  • 一个终端操作,执行流水线,并能生成结果

 

使用流

  • 筛选
Stream<T> filter(Predicate<? super T> predicate);     // 1、filter 用谓词筛选(谓词:返回 boolean 的函数; Predicate<T> {boolean test(T t);}  )
Stream<T> distinct();                                 // 2、distinct 去重
Stream<T> limit(long maxSize);                        // 3、limit 截短(不超过给定长度)
Stream<T> skip(long n);                               // 4、skip 跳过指定个数的元素(元素不足时返回空流)
  • 映射
<R> Stream<R> map(Function<? super T, ? extends R> mapper);                         // map 接受一个函数并将其作用于每个元素,将元素映射成新元素 ( Function<T, R> { R apply(T t);} )
<R> Stream<R> flatMap(Function<? super T, ? extends Stream<? extends R>> mapper);   // 扁平化。把一个流中的每个值都换成另一个流,然后把所有流接起来成为一个流
// 实例:给定列表 [1,2,3] 和 [3,4] ,返回所有数对,即[(1, 3), (1, 4), (2, 3), (2, 4), (3, 3), (3, 4)]
List<Integer> numbers1 = Arrays.asList(1, 2, 3);
List<Integer> numbers2 = Arrays.asList(3, 4);
List<int[]> pairs = numbers1.stream()
														.flatMap(i -> numbers2.stream()
														.map(j -> new int[]{i, j}))
														.collect(toList());
  • 查找和匹配
boolean anyMatch(Predicate<? super T> predicate);    // 谓词至少匹配一个元素
boolean allMatch(Predicate<? super T> predicate);    // 谓词是否匹配所有元素
boolean noneMatch(Predicate<? super T> predicate);   // 谓词不匹配任何元素
Optional<T> findFirst();                             // 返回当前流中的第一个元素( Optional:容器类,代表值存在或不存在)
Optional<T> findAny();                               // 返回当前流中的任意元素

无状态操作:操作本身没有内部可变状态(map,filter 等)

有状态操作:操作的内部状态会影响操作结果(reduce、sum、max、sort、distinct 等)

  • 归约:把一个流中的元素结合起来得到一个值或对象
T reduce(T identity, BinaryOperator<T> accumulator);   // BinaryOperator<T> extends BiFunction<T,T,T>; BiFunction<T, U, R> {R apply(T t, U u);}
Optional<T> reduce(BinaryOperator<T> accumulator);
  • 《Java 8实战》5.5.1 实践
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.stream.Collectors;

import static java.util.Comparator.comparing;
import static java.util.stream.Collectors.toList;

public class StreamTest {
    public static void main(String[] args) {
        Trader raoul = new Trader("Raoul", "Cambridge");
        Trader mario = new Trader("Mario", "Milan");
        Trader alan = new Trader("Alan", "Cambridge");
        Trader brian = new Trader("Brian", "Cambridge");
        List<Transaction> transactions = Arrays.asList(
                new Transaction(brian, 2011, 300),
                new Transaction(raoul, 2012, 1000),
                new Transaction(raoul, 2011, 400),
                new Transaction(mario, 2012, 710),
                new Transaction(mario, 2012, 700),
                new Transaction(alan, 2012, 950)
        );

        List<Transaction> list1 = transactions.stream()
                .filter(t -> t.getYear() == 2011)
                .sorted(comparing(Transaction::getValue))
                .collect(toList());
        System.out.println("找出2011年发生的所有交易,并按交易额排序(从低到高):" + list1);

        List<String> list2 = transactions.stream()
                .map(t -> t.getTrader().getCity())
                .distinct()
                .collect(toList());
        System.out.println("交易员都在哪些不同的城市工作过:" + list2);

        List<Trader> list3 = transactions.stream()
                .map(Transaction::getTrader)
                .filter(t -> "Cambridge".equals(t.getCity()))
                .distinct()                                     // 从交易里面找交易员,隐含了去重的要求
                .sorted(comparing(Trader::getName))
                .collect(toList());
        System.out.println("查找所有来自于剑桥的交易员,并按姓名排序:" + list3);

        String names = transactions.stream()
                .map(t -> t.getTrader().getName())
                .distinct()                                     // 从交易里面找交易员,隐含了去重的要求
                .sorted()
                .collect(Collectors.joining());
        System.out.println("返回所有交易员的姓名字符串,按字母顺序排序:" + names);

       //Optional<Trader> trader = transactions.stream()         // 这里是我理解错了,以为找出一个在米兰工作的 Trader
       //        .map(Transaction::getTrader)
       //        .filter(t -> "Milan".equals(t.getCity()))
       //        .findAny();
       // System.out.println("有没有交易员是在米兰工作的:" + trader);
      
      	boolean milanBased =transactions.stream()               // 正确答案
                .anyMatch(transaction -> transaction.getTrader().getCity().equals("Milan"));
        System.out.println("有没有交易员是在米兰工作的:" + milanBased);

        transactions.stream()
                .filter(t -> "Cambridge".equals(t.getTrader().getCity()))
                .map(Transaction::getValue)
                .forEach(System.out::println);       //  打印生活在剑桥的交易员的所有交易额,直接打印不用再收集结果了

        Optional<Integer> value = transactions.stream()
                .map(Transaction::getValue)
                .reduce(Integer::max);
        System.out.println("所有交易中,最高的交易额是多少:" + value);

        Optional<Transaction> transaction = transactions.stream()
                .min(comparing(Transaction::getValue));            
        System.out.println("找到交易额最小的交易:" + transaction);
    }
}
public class Trader {
    private final String name;
    private final String city;

    public Trader(String n, String c) {
        this.name = n;
        this.city = c;
    }

    public String getName() {
        return this.name;
    }

    public String getCity() {
        return this.city;
    }

    @Override
    public String toString() {
        return "Trader:" + this.name + " in " + this.city;
    }
}
public class Transaction {
    private final Trader trader;
    private final int year;
    private final int value;

    public Transaction(Trader trader, int year, int value) {
        this.trader = trader;
        this.year = year;
        this.value = value;
    }

    public Trader getTrader() {
        return this.trader;
    }

    public int getYear() {
        return this.year;
    }

    public int getValue() {
        return this.value;
    }

    @Override
    public String toString() {
        return "{" + this.trader + ", " +
                "year: " + this.year + ", " +
                "value:" + this.value + "}";
    }
}
找出2011年发生的所有交易,并按交易额排序(从低到高):[{Trader:Brian in Cambridge, year: 2011, value:300}, {Trader:Raoul in Cambridge, year: 2011, value:400}]
交易员都在哪些不同的城市工作过:[Cambridge, Milan]
查找所有来自于剑桥的交易员,并按姓名排序:[Trader:Alan in Cambridge, Trader:Brian in Cambridge, Trader:Raoul in Cambridge]
返回所有交易员的姓名字符串,按字母顺序排序:AlanBrianMarioRaoul
有没有交易员是在米兰工作的:true
300
1000
400
950
所有交易中,最高的交易额是多少:Optional[1000]
找到交易额最小的交易:Optional[{Trader:Brian in Cambridge, year: 2011, value:300}]

 

数值流

原始类型特化流:IntStream、DoubleStream、LongStream,分别将流中的元素特化为 int、double、long,避免自动装箱

Optional 原始类型特化版本: OptionalInt、 OptionalDouble、OptionalLong

将特化流转回对象流:boxed()

  • 将流转化为特化版本
IntStream mapToInt(ToIntFunction<? super T> mapper);
LongStream mapToLong(ToLongFunction<? super T> mapper);
DoubleStream mapToDouble(ToDoubleFunction<? super T> mapper);

数值范围:生成一个范围的数值流

  • IntStream、LongStream 都有
IntStream range(int startInclusive, int endExclusive)         //  [a,b)
IntStream rangeClosed(int startInclusive, int endInclusive)   //  [a,b]

 

 

构建流

  • 1、由值创建流:Stream 静态方法
Stream<T> empty()          
Stream<T> of(T... values)   
    Stream<String> emptyStream = Stream.empty();
    Stream<String> stream = Stream.of("Java 8 ", "Lambdas ", "In ", "Action");
  • 2、由数组创建流:Arrays.stream()
Stream<T> stream(T[] array)   // 还有很多返回特化流的方法
    int[] numbers = {2, 3, 5, 7, 11, 13};
    int sum = Arrays.stream(numbers).sum();
  • 3、由文件生成流:Files
Stream<String> lines(Path path)
Stream<String> lines(Path path, Charset cs)
  	Stream<String> lines = Files.lines(Paths.get("data.txt"), Charset.defaultCharset())){
    long uniqueWords = lines.flatMap(line -> Arrays.stream(line.split(" ")))
                            .distinct()
                            .count();
  • 4、由函数生成流
Stream<T> iterate(final T seed, final UnaryOperator<T> f)  // iterate 接受初始值和应用在此初始值上的 Lambda (UnaryOperator 抽象方法 apply:T->T)
Stream<T> generate(Supplier<T> s)                          // generate 接受 Lambda 提供的新值 (Predicate<T> 抽象方法 test:T->boolean)
  	Stream.iterate(0, n -> n + 2)
  	Stream.generate(Math::random)

 

Collectors

collect:是一个终端操作,它接受的参数是将流中元素累积到汇总结果的各种方式(称为收集器),对流调用 collect 方法将对流中元素触发一个由 收集器Collector 参数化的归约操作

  • Stream.collect
R collect(Collector<? super T, A, R> collector);   // 归约操作接受一个收集器 Collector,由收集器参数化具体归约行为
R collect(Supplier<R> supplier,
          BiConsumer<R, ? super T> accumulator,
          BiConsumer<R, R> combiner);

Collector:收集器。此接口中方法的实现决定了如何对流执行归约操作

public interface Collector<T, A, R> {   // T 要收集的元素类型;A 累加器类型;R 返回结果类型
  Supplier<A> supplier();
  BiConsumer<A, T> accumulator();
  BinaryOperator<A> combiner();
  Function<A, R> finisher();
  Set<Characteristics> characteristics();
  public static<T, R> Collector<T, R, R> of(Supplier<R> supplier,BiConsumer<R, T> accumulator,BinaryOperator<R> combiner,Characteristics... characteristics);
  public static<T, A, R> Collector<T, A, R> of(Supplier<A> supplier,BiConsumer<A, T> accumulator,BinaryOperator<A> combiner,Function<A, R> finisher,Characteristics... characteristics);
  enum Characteristics {
        CONCURRENT,
        UNORDERED,
        IDENTITY_FINISH
    }
}

 

Collectors:提供了很多静态工厂方法, 可以方便地创建常见收集器Collector的实例

预定义收集器:从 Collectors 类提供的工厂方法创建的收集器

工厂:创建对象的对象,通常包含多个方法,用来创建这个工厂所能创建的各种类型的对象
工厂方法模式:定义一个创建对象的接口,定义专门创建对象的方法,让实现这个接口的类通过实现这个方法来创建具体类型的对象

  • Collectors   汇总
Collector<T, ?, Optional<T>> minBy(Comparator<? super T> comparator)   // 查找流中最小值
Collector<T, ?, Optional<T>> maxBy(Comparator<? super T> comparator)
Collector<T, ?, Integer> summingInt(ToIntFunction<? super T> mapper)   // 求和。ToIntFunction的抽象函数applyAsInt:T->int。另外还有summingLong,summingDouble
Collector<T, ?, Double> averagingInt(ToIntFunction<? super T> mapper)  // 求平均。另外还有averagingLong,averaingDouble
Collector<T, ?, IntSummaryStatistics> summarizingInt(ToIntFunction<? super T> mapper)  // 返回的IntSummaryStatistics类中包含了总和、平均值、最大值、最小值。另外还有summarizingLong,summarizinggDouble
Collector<CharSequence, ?, String> joining()   // 此方法返回的收集器会把对流中每一个对象应用 toString 方法得到的所有字符串连接成一个字符串
Collector<CharSequence, ?, String> joining(CharSequence delimiter)  // 接受元素之间的分界符做参数
Collector<CharSequence, ?, String> joining(CharSequence delimiter,CharSequence prefix,CharSequence suffix)  // 分界符、前缀、后缀
Collector<T, ?, Long> counting() { return reducing(0L, e -> 1L, Long::sum); }   // 返回一个收集器:接受 T 类型对象返回对象个数
<T, C extends Collection<T>> Collector<T, ?, C> toCollection(Supplier<C> collectionFactory)    // 返回一个收集器:将输入元素收集到一个新集合 Collection
Collector<T, ?, List<T>> toList()   // 返回一个收集器:将输入元素收集到 List
Collector<T, ?, Set<T>> toSet()     // 返回一个收集器:将输入元素收集到 Set

归约操作的工作原理:利用累积函数,把一个初始化为起始值的累加器,和把转换函数应用到流中每个元素上得到的结果不断迭代合并起来

  • Collectors   归约
Collector<T, ?, U> reducing(U identity,Function<? super T, ? extends U> mapper,BinaryOperator<U> op)  // 归约操作起始值,转换函数T->U,积累函数(U,U)->U
Collector<T, ?, Optional<T>> reducing(BinaryOperator<T> op)  // 三参数reducing的特殊情况,把流中第一个元素作起始值,恒等函数作转换函数

  • Collectors   分组
Collector<T, ?, Map<K, List<T>>> groupingBy(Function<? super T, ? extends K> classifier){return groupingBy(classifier, toList());}  // 分类函数Function:T->K。注入传入的Lambda或者方法引用的签名与抽象函数相符即可
Collector<T, ?, Map<K, D>> groupingBy(Function<? super T, ? extends K> classifier,Collector<? super T, A, D> downstream)  // 可用于多级分组,也可传递任意收集器给第二个参数
Collector<T,A,RR> collectingAndThen(Collector<T,A,R> downstream,Function<R,RR> finisher)   // 把收集器downstream返回的结果用finisher转换为另一种类型
Collector<T, ?, R> mapping(Function<? super T, ? extends U> mapper,Collector<? super U, A, R> downstream)  // mapper对流中元素江南西变换,downstream收集结果对象
// 多级分组实例
Map<Dish.Type, Dish> mostCaloricByType = menu.stream().collect(groupingBy(Dish::getType,   // 先按类型分组,再从collectingAndThen得到每个组的最高热量值
                                                                   collectingAndThen(      // 对收集器应用附加转换操作
                                                                       maxBy(comparingInt(Dish::getCalories)), // 转换maxBy返回的结果
                                                                       Optional::get)));   // 转换函数

分区分组的特殊情况,用谓词作为分类函数(称为分区函数)

  • Collectors   分区
Collector<T, ?, Map<Boolean, List<T>>> partitioningBy(Predicate<? super T> predicate)  // predicate:分区函数
Collector<T, ?, Map<Boolean, D>> partitioningBy(Predicate<? super T> predicate,Collector<? super T, A, D> downstream)  // 结果 Map 的 value 是收集器的返回结果(二级分区)

 

Collector

Collector 接口包含了一系列方法,为实现具体的归约操作(即收集器)提供了范本

累加器:在收集过程中用于累积部分结果的对象

public interface Collector<T, A, R> {     // T 要收集的元素类型;A 累加器类型;R 返回结果类型
  Supplier<A> supplier();                 // 建立新的结果容器,即创建一个空的累加器实例,Supplier 的 get:()->A
  BiConsumer<A, T> accumulator();         // 将元素添加到结果容器,即将元素加到累加器,累加器原位更新,BiConsumer 的 accept:(A,T)-void
  BinaryOperator<A> combiner();           // 合并两个结果容器,返回的函数定义了并行处理时子部分得到的累加器如何合并,BinaryOperator 的 apply:(A,A)->A
  Function<A, R> finisher();              // 对结果容器应用最终转换 Function 的 apply:A->R
  Set<Characteristics> characteristics(); // 返回一个不可变的Characteristics集合,它定义了收集器的行为
  public static<T, R> Collector<T, R, R> of(Supplier<R> supplier,BiConsumer<R, T> accumulator,BinaryOperator<R> combiner,Characteristics... characteristics);
  public static<T, A, R> Collector<T, A, R> of(Supplier<A> supplier,BiConsumer<A, T> accumulator,BinaryOperator<A> combiner,Function<A, R> finisher,Characteristics... characteristics);
  enum Characteristics {
        CONCURRENT,      // 归约结果不受流中项目的遍历和累积顺序的影响
        UNORDERED,       // accumulator函数可以从多个线程同时调用,且该收集器可以并行归约流(如果收集器没有标为UNORDERED,那它仅在用于无序数据源时才可以并行归)
        IDENTITY_FINISH  // 表明完成器方法返回的函数是一个恒等函数
    }
}

 

  • 使用 combiner 进行并行化归约的过程(图6-8):

 

并行流

并行流:把一个内容分成多个数据块,并用不同的线程分别处理每个数据块的流

parallel 方法:对顺序流调用方法 paralle 可将顺序流转换为并行流,这并不意味着流本身有任何实际变化,它只表示让调用 parallel 之后的所有操作都并行执行

sequential 方法:对并行流调用方法 sequential 可把它变为顺序流(一个流水线中调用多次 parallel、sequential 时,最后调用的那次 parallel 或 sequential 决定是并行还是顺序执行)

正确使用并行流 :错用并行流而产生错误的首要原因,就是使用的算法改变了某些共享状态

高效使用并行流

选择适当基准,通过测量性能来决定使用顺序流还是并行流

避免不必要的装箱拆箱操作

避免使用不适合并行流的操作:limit,findFirst等(依赖元素顺序的操作)

从流的操作流水线的总成本考虑

数据量太小不适合用并行流

考虑流背后的数据结构是否易于分解(ArrayList、IntStream.range、HashSet、TreeSet适合;LinkedList、Stream.iterate不适合)

流自身的特点,以及流水线的中间操作修改流的方式,都可能会改变分解过程的性能

考虑终端操作中合并步骤的代价大小

 

分支/合并框架:ExecutorService 接口的实现

分支/合并框架的目的:以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果

分支/合并任务是ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的工作线程

定义任务和子任务:创建 RecursiveTask<R> 的子类,实现其抽象方法 compute 把任务交到任务池

compute 方法定义流将任务拆成子任务的逻辑、无法拆分或不便再拆分时生成单个子任务结果的逻辑

  • 实例:用分支/合并框架执行并行求和
public static long forkJoinSum(long n) {                          // 利用分支/合并框架对前 n 个自然数求和
  long[] numbers = LongStream.rangeClosed(1, n).toArray();        // 生成包含前 n 个自然数的数组
  ForkJoinTask<Long> task = new ForkJoinSumCalculator(numbers);   // 定义任务的类 ForkJoinSumCalculator
  return new ForkJoinPool().invoke(task);                         // 把任务分配给线程池 ForkJoinPool
}

public class ForkJoinSumCalculator extends java.util.concurrent.RecursiveTask<Long> {   // 继承 RecursiveTask 来创建可以用于分支/合并框架的任务
  private final long[] numbers;    // 要求和的数组
  private final int start;         // 子任务处理的数组的起始和终止位置
  private final int end;
  public static final long THRESHOLD = 10_000;    // 不再将任务分解为子任务的数组大小
  
  public ForkJoinSumCalculator(long[] numbers) {  // 公共构造函数用于创建主任务
    this(numbers, 0, numbers.length);
  }
  
  private ForkJoinSumCalculator(long[] numbers, int start, int end) {   // 私有构造函数用于以递归方式为主任务创建子任务
    this.numbers = numbers;
    this.start = start;
    this.end = end;
  }
  
  @Override
  protected Long compute() {         // 覆盖 RecursiveTask 抽象方法
    int length = end - start;        // 该任务负责求和的部分的大小
    if (length <= THRESHOLD) {
    	return computeSequentially();  // 如果大小小于或等于阈值,顺序计算结果
    }
    ForkJoinSumCalculator leftTask = new ForkJoinSumCalculator(numbers, start, start + length/2);   // 创建一个子任务来为数组的前一半求和
    leftTask.fork();                 // 利用另一 个 ForkJoinPool 线程异步执行新创建的子任务创建一个任务
    ForkJoinSumCalculator rightTask = new ForkJoinSumCalculator(numbers, start + length/2, end);    // 创建一个任务为数组的后一半求和
    Long rightResult = rightTask.compute();      // 同步执行第二个子任务,有可能允许进一步递归划分
    Long leftResult = leftTask.join();           // 读取第一个子任务的结果,如果尚未完成就等待
    return leftResult + rightResult;             // 该任务的结果是两个子任务结果的组合
  }
  
  private long computeSequentially() {      // 在子任务不再可分时计算结果的简单算法
    long sum = 0;
    for (int i = start; i < end; i++) {{
    	sum += numbers[i];
    }
    return sum;
  }
}

 

使用分支/合并框架的最佳做法:

对一个任务调用join方法会阻塞调用方,直到该任务做出结果。因此有必要在两个子 任务的计算都开始之后再调用它。

不应该在RecursiveTask内部使用ForkJoinPool的invoke方法,应该始终直接调用compute或fork方法,只有顺序代码才应该用invoke来启动并行计算

对子任务调用fork方法可以把它排进ForkJoinPool。同时对左边和右边的子任务调用 的效率要比直接对其中一个调用compute低。这样做你可以为 其中一个子任务重用同一线程,从而避免在线程池中多分配一个任务造成的开销。

调试使用分支/合并框架的并行计算可能有点棘手。

不应理所当然地认为在多核处理器上使用分支/合并框架就比顺序计 算快

必须选择一个标准,来决定任务是要进一步 拆分还是已小到可以顺序求值

 

工作窃取:在池中的工作线程之间重新分配和平衡任务

问题:由于划分策略效率低、磁盘访问慢、需要外部服务协调执行等诸多原因造成的子任务完成时间不同,进而造成资源的浪费(理想情况是每个任务都用相同时间完成,让CUP内核同样繁忙)

解决:分支/合并框架工程用一种称为工作窃取(work stealing)的技术来解决这个问题

在实际应用中,任务差不多被平均分配到ForkJoinPool中的所有线程上。每个线程都为分 配给它的任务保存一个双向链式队列,每完成一个任务,就会从队列头上取出下一个任务开始执 行。当某个线程完成了分配给它的所有任务而其他的线程还很忙时,这个线程随机选了一个别的线程,从队列的尾巴上“偷走”一个任务。这个过程一直继续下去,直到所有的任务都执行完毕,所有的队列都清空。

 

Spliterator

Spliterator 为了并行执行而设计的用于遍历数据源中的元素的接口

public interface Spliterator<T> {                  // T:遍历的元素类型
  boolean tryAdvance(Consumer<? super T> action);  // 按序使用 Spliterator 中的元素,若还有其他元素要遍历则返回 true
  Spliterator<T> trySplit();                       // 把一些元素划出去分给第二个 Spliterator
  long estimateSize();                             // 估计还剩多少元素要遍历
  int characteristics();                           // 返回代表 Spliterator 本身特性集的编码
}
  • Spliterator 的特性 characteristics
ORDERED     元素有既定的顺序(例如List),因此Spliterator在遍历和划分时也会遵循这一顺序
DISTINCT    对于任意一对遍历过的元素x和y, x.equals(y)返回false
SORTED      遍历的元素按照一个预定义的顺序排序
SIZED       该Spliterator由一个已知大小的源建立(例如Set),因此estimatedSize()返回的是准确值
NONNULL     保证遍历的元素不会为null
IMMUTABLE   Spliterator的数据源不能修改。这意味着在遍历时不能添加、删除或修改任何元素
CONCURRENT  该Spliterator的数据源可以被其他线程同时修改而无需同步
SUBSIZED    该Spliterator和所有从它拆分出来的Spliterator都是SIZED
  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值