《java并发编程的艺术》笔记

第一章 并发编程的挑战

并发编程的目的是为了让程序运行得更快,但是不是更多的线程就能让程序最大限度的并发执行。1.上下文切换,2.死锁的问题,3.受限于软件和硬件的资源限制问题。

软件资源限制:有数据库的链接数和socket连接数等

硬件的资源限制有带宽的上传、下载速度、硬盘读写速度和CPU处理速度。

减少上下文切换的方法:

  • 无锁并发编程
  • CAS算法
  • 使用最少线程
  • 使用协程

避免死锁的方法:

  • 避免一个线程获得多个锁
  • 避免一个线程在锁内同时占有多个资源
  • 尝试使用定时锁带替代内部锁机制(比如lock.tryLock(timeout))
  • 对于数据库锁,加锁和解锁在一个数据库连接里

建议使用并发容器和工具类来解决并发问题

第二章 并发机制的底层实现原理

java代码在编译后会变成java字节码,字节码被类加载器加载到jvm当中,jvm执行字节码,最终需要转化为汇编指令在CPU上执行。

在多线程并发编程中synchronized和volatile都扮演着重要的角色,volatile是轻量级的synchronized。

volatile

特性:

  • 可见性
    如果对声明了volatile的变量进行写操作,jvm就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写会系统内存,同时根据处理器的缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的数据是否过期,过期就要设置无效重新加载。
  • 禁止指令重排

synchronized

实现同步的基础,java中每一个对象都可以作为锁:

  • 对于普通同步方法,锁是当前实例对象
  • 对于静态同步方法,锁是当前类的Class对象
  • 对于同步方法块,锁是Synchonized括号里面的配置对象

每个对象有一个monitor与之关联,获得monitor获得锁,代码块同步是使用monitorenter和monitorexit实现的,插入到同步代码块的前后位置。检查是否持有monitor所有权,即尝试获得对象的锁

偏向锁

为了解决统一线程多次活动一个锁,需要切换上下文等代价。是一种等到竞争出现才释放锁的机制

做法:

  • 在锁对象的对象头和栈帧的锁记录里存储锁偏向的线程ID
  • 如果检测没有偏向锁,检测Mark Word中的锁标识是否设置为1,如果没有设置就是用CAS竞争锁
  • 如果设置了1,就尝试用CAS将对象的偏向锁指向当前线程

轻量级锁
加锁:

  • 在当前线程的栈帧中创建用于存储锁记录的空间,将对象头中的Mark Word复制到锁记录中,然后尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针,失败则与其他线程竞争,尝试自旋获得锁。

解锁:

  • CAS将栈帧中存储的Mark Word替换回对象中,失败就代表存在竞争,膨胀为重量级锁
    锁升级过程

原子操作的实现:

处理器
使用基于缓存的加锁或基于总线的加锁

  • 基于总线。使用处理器提供的一个LOCK 信号,当一个处理器在总线上输出此信号时,其他处理器的请求被阻塞,该处理器独占总线。
  • 基于缓存。使用缓存一致性原则

java
使用锁和循环CAS的方式实现

  • 循环CAS,循环进行CAS直到成功。存在三大问题:
  1. ABA问题
  2. 循环时间长开销大
  3. 只能保证一个共享变量的原子操作,可以考虑把多个变量合成,或者使用锁
  • 使用锁。偏向锁,轻量级锁和互斥锁。除了偏向锁,其他实现锁的方式都使用了循环CAS获得和释放锁

第三章 java内存模型(看的比较粗)

as-if-serial语义

as-if-serial语义的意思是:不管怎么重排序,单线程程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义

HAPPENS-BEFORE简介

JSR-133使用happens-before的概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。

VOLATILE的内存语义

理解volatile变量的单个读/写,可以看成是使用同一个锁对这些单个读/写做了同步。

  • 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
  • 原子性。对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种操作不具有原子性

锁的内存语义(SYNCHRONIZED)

  • 释放锁的内存语义
    当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。

  • 获取锁的内存语义
    当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使临界区的代码必须从主内存中读取共享变量。

  • 锁内存语义的实现
    ReentrantLock的实现依赖于Java同步去框架AbstractQueuedSynchronzier(AQS)。AQS使用一个整形的volatile变量(Lock实现内存语义的关键)来维护同步状态。

FINAL域的内存语义

  • 在构造函数内对一个final域的写入,与随后把这个构造对象的引用复制给一个引用变量,这两个操作之间不能重排序。
  • 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序

双重检查锁定与延迟初始化

//有问题的代码
 public static Singleton getInstance() {  //双重检查与延迟加载
        if (instance == null) {
            synchorinzed(lock) {
                if (instance == null)
                    instance = new Singleton();//根源在这一句
            }
        }
        return instance;
    }

根源在于重排序

   instance = new Singleton(); 这一行代码可以拆分成三句。

       memory = allocate();  //1.分配对象的内存空间

       initInstance(memory); //2.初始化对象

       instance = memroy;    //3.设置instance引用指向这块内存

上面的代码中,2和3可能会被重排序。

    另外一个并发线程B可能拿到的是没有被A线程初始化的对象。

解决方法:

  • 禁止指令重排序。所以使用 volatile 关键字修饰instance对象
  • 让这个可能会重排序的代码对其它线程不可见。(基于类初始化)

基于类初始化的单例解决方案

public class StaticInnerClassSingleton {
    private static class InnerClass{      //利用静态内部类,类初始化锁
        private static StaticInnerClassSingleton staticInnerClassSingleton = new StaticInnerClassSingleton();
    }
    public static StaticInnerClassSingleton getInstance(){
        return InnerClass.staticInnerClassSingleton;//这里将导致InnerClass被初始化
    }
 private StaticInnerClassSingleton(){
    }
}

第四章Java并发编程基础

什么是线程

进程:现代操作系统运行一个程序时,会为其创建一个进程。(进程可以认为是运行着的程序,活着的程序)。

线程:现代操作系统的最新调度单位就是线程,线程也称为轻量级进 程(Light Weight Process)。一个进程中可以包含多个线程,这些线程有自己的计数器、堆栈、和局部变量属性,并且能够访问共享的内存变量

线程状态及转换

  • java将操作系统中的运行和就绪两个状态合并称为运行状态,阻塞状态是线程阻塞在进入synchronized关键字修饰的方法或代码块(竞争锁)时的状态。

  • 但是阻塞在java.concurrent包中Lock接口的线程状态却是等待状态,因为Lock接口对于阻塞的实现使用了LockSupport类中相关的方法。

线程间的通信

  • volatile(修饰一个表示程序是否运行的成员变量)与synchronized关键字
  • 等待/通知机制
    1)使用wait()、notify()、notifyAll()方法都需要先对调用对象加锁。(即锁对象应该为调用对象)
    2)调用wait()方法后,线程状态由RUNNING变为WAITTING,将锁释放,并将当前线程放到对象的等待队列。
    3)notify()或notifyAll()方法调用后,不会立刻释放锁,需要等待调用notify()、notifyAll()的线程释放锁之后,等待线程才可能会拿到锁。
    4)notify()将对象的等待队列中的一个线程随机地移到同步对象,notifyAll()将等待队列中的全部线程都移到同步队列,然后使它们争抢锁,被移动的状态由WAITING变为BLOCKED。
    5)从wait()返回的前提是获取调用对象的锁。

  • 管道输入输出流
  • Thread.join()
    如果一个线程A执行了thread.join(),含义:当前线程A等待thread线程终止后才从thread.join()返回。
  • ThreadLocal的使用

第五章:Java中的锁

Lock接口

使用synchronized可以隐式的获得锁,支持重进入,先获得后释放,固化了所的过程,Lock接口可控性更强

队列同步器(AbstractQueueSynchronizer)
是用来构建锁或者其他同步组件的基础框架,提供了getState(),setState()和compareAndSetState来进行操作。

同步队列,依赖内部的FIFO双向队列来完成同步状态的管理,当前线程获取同步状态失败时,同步器将当前线程和等待状态等信息加入同步队列,同时阻塞线程。

还有些内容,需要仔细再阅读

重入锁ReentrantLock

包含synchronized的基本功能,还加入了公平性、等待可中断、等待条件等功能。

公平锁可以减少饥饿发生的概率,等待越久的请求越容易优先满足,但是会频繁切换线程,降低吞吐量,所以非公平锁是默认。在获得锁的条件增加了在同步队列中该节点是否有前驱节点的判断。

如何实现:

判断当前线程是否为获得锁的线程来决定操作是否成功,如是则增加同步状态值返回true,释放锁的时候要减少状态值。

读写锁 ReentrantReadWriteLock

一对锁,同一时刻可以允许多个读线程访问,但是写线程访问时,所有的读线程和写线程阻塞。

  • 状态设计
    用一个整型变量代表状态,高16位表示读,低16位表示写。

  • 写锁的获取与释放
    是一个支持重进入的排它锁。如果当前读锁已经被获取或者写锁在其他线程手里,阻塞。
    如果存在读锁,写锁不能获取。原因在于,写锁要保证操作对读锁可见,如果存在读锁的时候对写锁获取,那么正在运行的其他线程就无法得知当前写线程的操作。

  • 读锁的获取与释放
    支持重进入的共享锁。没有其他写线程就可以访问。

  • 锁降级
    保持住写锁,再获得读锁,随后先释放写锁。一个线程有写锁是可以加读锁的
    适用于读优先于写,如数据库的读写,读线程一来写线程就要降级

Condition接口

需要首先获得锁,主要使用await和signal方法。调用await(),当前线程会释放锁,其他线程调用Condition对象的signal()方法,当前线程才从await()返回,并且已获得了锁。

第六章:Java并发容器和框架

ConcurrentHashMap

  • 在并发环境下,HashMap的put操作会引起死循环。因为多线程会导致HashMap的Entry链表形成环形数据结构,使得Entry的next节点永远不为空。HashMap的线程不安全主要体现在下面两个方面:
    1.在JDK1.7中,当并发执行扩容操作时会造成环形链和数据丢失的情况。
    2.在JDK1.8中,在并发执行put操作时会发生数据覆盖的情况
  • HashTable容器使用synchronized来保证线程安全,但在线程竞争激烈的情况下HashTable的效率非常低下。因为当一个线程访问HashTable的同步方法时,其他线程访问HashTable的同步方法时,可能会进入阻塞或轮询状态。如线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。
  • 锁分段技术
    ConcurrentHashMap是由Segment数组结构和HashEntry数组结构构成。Segment是一种可重入锁。HashEntry是用于存储键值对数据。
    一个ConcurrentHashMap有一个Segment数组,每个Segment数组中又有一个HashEntry数组,每个HashEntry是一个链表。
  • get()
    先进行一次再散列,然后使用这个散列值通过散列运算定位到Segment。get操作不需要加锁,所以高效,这需要将get里面使用的共享变量都定义为volatile类型。对volatile字段的写操作先于读操作。

定位Segment是hashcode再散列之后值的高位,而定位HashEntry是直接使用再散列之后的值。

(hash>>> sgementShift ) & segmentMask
hash & (tab.length - 1)   //为什么扩容是二倍增长的:使可以通过位运算确定索引,效率更高@[TOC]
  • put()
    定位Segment之后
  1. 判断是否需要对HashEntry数组扩容
    Segment是在插入元素之前判断,HashMap是在插入之后判断。创建一个两倍的数组,在散列插入新数组,只对某个segment扩容
  2. 定位添加元素的位置,放进去

ConcurrentLinkedQueue

  • 实现线程安全队列的两种方式,阻塞方式(用锁),非阻塞方式(CAS)

  • 入队列
    入队节点添加到队列的尾部。
    第一步将入队节点设置为当前队列尾节点的下一个节点。
    更新tail。如果tail下一个不为null,更新为入队节点,如果为空,不移动(CAS)
    tail不总是尾节点,需要通过tail节点来找到尾节点。 HOPS,tail和尾节点的长度大于常量才更新。
    这种设计是通过增加对volatile节点的读操作,减少了写操作,写操作开销大,所以入队效率高。

  • 出队列
    从队列头返回一个节点。
    当队列头有元素,直接返回不更新head,当head为空,出队更新head,通过hops来减少cas更新head节点的消耗。

阻塞队列

  • 支持阻塞插入和移除的方法。
  • java里的阻塞队列:
    ArrayBlockingQueue,一个由数组结构组成的有界阻塞队列LinkedBlockingQueue,一个由链表组成的有界阻塞队列
    PriortyBlockingQueue,一个支持优先级排序的无界阻塞队列
    DealyQueue,使用优先级队列实现的无界阻塞队列
    SynchronousQueue,不存储元素的阻塞队列
    LinkedTransferQueue,由链表组成的无界阻塞队列
    LinkedBlockingDeque,由链表组成的双向阻塞队列

Fork/Join框架

把大任务切割成小任务,最后汇总结果

  • 工作窃取算法
    队列从别的队列窃取任务执行。把子任务分别放到不同的队列里,并为每个队列创建单独的线程。窃取从尾部拿任务

  • ForkJoinTask
    创建ForkJoin任务,继承其两个子类:
    RecursiveAction:用于没有返回结果的任务。
    RecursiveTask :用于有返回结果的任务。
    重写compute,是否足够细分,小任务join

  • ForkJoinPool,执行任务

  • 原理
    ForkJoinPool由ForkJoinTask数组和ForkJoinWorkerThread数组组成,ForkJoinTask数组负责存放程序提交给ForkJoinPool的任务,而ForkJoinWorkerThread数组负责执行这些任务。(类似于线程池的实现)

Java中的13个原子操作类

原子更新方式

  • 原子更新基本类型
  • 原子更新数组
  • 原子更新引用
  • 原子更新属性(字段)

原子更新基本类型

AtomicBoolean :原子更新布尔类型
AtomicInteger: 原子更新整型
AtomicLong: 原子更新长整型

原子更新数组

AtomicIntegerArray :原子更新整型数组里的元素
AtomicLongArray :原子更新长整型数组里的元素
AtomicReferenceArray : 原子更新引用类型数组的元素

原子更新引用类型

AtomicReference :原子更新引用类型
AtomicReferenceFieldUpdater :原子更新引用类型里的字段
AtomicMarkableReference:原子更新带有标记位的引用类型。可以原子更新一个布尔类型的标记位和应用类型

原子更新字段类

AtomicIntegerFieldUpdater:原子更新整型的字段的更新器
AtomicLongFieldUpdater:原子更新长整型字段的更新器
AtomicStampedReference:原子更新带有版本号的引用类型。该类将整型数值与引用关联起来,可用于原子的更新数据和数据的版本号,可以解决使用CAS进行原子更新时可能出现的ABA问题。

Java中的并发工具类

CountDownLatch

(Latch:门闩)

用于等待其他线程完成操作。一个功能更强大的 join().

CountDownLatch c = new CountDownLatch(2); // 等待两个[点]完成;
...
c.countDown(); // 第一个等待的操作完成;
...
c.countDown(); // 第二个等待的操作完成;

...
c.await(); // 等待两个操作完成;
...

CountDownLatch(N)等待N个点完成;这里说的N个点,可以是N个线程,也可以是一个线程里的N个执行步骤。

同步屏障:CyclicBarrier

让一组线程到达一个屏障(也可以叫同步点)时被阻塞,直到最后一个线程到达屏障时,屏障才会打开,所有被屏障拦截的线程才会继续运行。

CyclicBarrier c = new CyclicBarrier(2); // 屏障会拦截/等待两个线程;

// 在第一个线程中;
c.await(); // 当前线程(执行了某些操作后)到达屏障;

// 在第二个线程中;
c.await(); // 当前线程(执行了某些操作后)到达屏障;

CyclicBarrier和CountDownLatch的区别

CountDownLatch的计数器只能用一次,而CyclicBarrier的计数器可以使用reset()方法重置。所以CyclicBarrier可以处理更复杂的业务场景。例如,如果计算发生错误,可以重置计数器,并让线程重新执行一次。

控制并发线程数的Semaphore

信号量,用来控制同时访问特定资源的线程数量。

Semaphore s = new Semaphore(10);
Executor threadPool = Executors.newFixedThreadPool(30);

for (int i = 0; i < 30; i++) {
    threadPool.execute(new Runnable() {
        @Override
        public void run() {
            try {
                s.acquire();
                System.out.println("Save Date");
                s.release();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    });
}

在代码中,虽然有30个线程在执行,但只允许10个并发执行。

线程间交换数据的Exchanger

Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exchange()方法,然后两个线程交换数据

Exchanger<String> exchanger = new Exchanger<>();
// 在线程A中;
try {
    String B = exchanger.exchange("A's data");
} catch (InterruptedException e) {
    e.printStackTrace();
}

// 在线程B中;
try {
    String A = exchanger.exchange("B's data");
} catch (InterruptedException e) {
    e.printStackTrace();
}

Java中的线程池

corePool

首先理解一个[corePool 核心池]的概念:核心池是一个线程池的基本/平均能力保障。在线程池的使用初期,随着任务的提交**,线程池会先尽快填满核心池——提交一个任务就创建一个线程,即使核心池中有空闲的线程**。如果线程池有温度的话,核心池就是线程池的“常温”

在这里插入图片描述

线程池的创建

我们可以通过ThreadPoolExecutor来创建一个线程池。

new ThreadPoolExecutor(corePoolSize, maximumPoolSize,
keepAliveTime, milliseconds,runnableTaskQueue, threadFactory,handler);
  • corePoolSize(线程池的基本大小):当提交一个任务到线程池时,线程池会创建一个线程来执行任务,即使其他空闲的基本线程能够执行新任务也会创建线程,等到需要执行的任务数大于线程池基本大小时就不再创建。如果调用了线程池的prestartAllCoreThreads方法,线程池会提前创建并启动所有基本线程。
  • runnableTaskQueue(任务队列):用于保存等待执行的任务的阻塞队列。可以选择以下几个阻塞队列.
    ArrayBlockingQueue:是一个基于数组结构的有界阻塞队列,此队列按 FIFO(先进先出)原则对元素进行排序。
    LinkedBlockingQueue:一个基于链表结构的阻塞队列,此队列按FIFO (先进先出) 排序元素,吞吐量通常要高于ArrayBlockingQueue。静态工厂方法Executors.newFixedThreadPool()使用了这个队列。
    SynchronousQueue:一个不存储元素的阻塞队列。每个插入操作(offer())必须等到另一个线程调用移除操作(poll()),否则插入操作一直处于阻塞状态,吞吐量通常要高于LinkedBlockingQueue,静态工厂方法Executors.newCachedThreadPool使用了这个队列。
    PriorityBlockingQueue:一个具有优先级得无限阻塞队列。
  • maximumPoolSize(线程池最大大小):线程池允许创建的最大线程数。如果队列满了,并且已创建的线程数小于最大线程数,则线程池会再创建新的线程执行任务。值得注意的是如果使用了无界的任务队列这个参数就没什么效果。
  • ThreadFactory:用于设置创建线程的工厂,可以通过线程工厂给每个创建出来的线程设置更有意义的名字,Debug和定位问题时非常又帮助。
  • RejectedExecutionHandler(饱和策略):当队列和线程池都满了,说明线程池处于饱和状态,那么必须采取一种策略处理提交的新任务。这个策略默认情况下是AbortPolicy,表示无法处理新任务时抛出异常。以下是JDK1.5提供的四种策略。
    AbortPolicy:直接抛出异常。
    CallerRunsPolicy:只用调用者所在线程来运行任务。
    DiscardOldestPolicy:丢弃队列里最近的一个任务,并执行当前任务。
    DiscardPolicy:不处理,丢弃掉。
    当然也可以根据应用场景需要来实现RejectedExecutionHandler接口自定义策略。如记录日志或持久化不能处理的任务。
  • keepAliveTime(线程活动保持时间):线程池的工作线程空闲后,保持存活的时间。所以如果任务很多,并且每个任务执行的时间比较短,可以调大这个时间,提高线程的利用率。
  • TimeUnit(线程活动保持时间的单位):可选的单位有天(DAYS),小时(HOURS),分钟(MINUTES),毫秒(MILLISECONDS),微秒(MICROSECONDS, 千分之一毫秒)和毫微秒(NANOSECONDS, 千分之一微秒)。

向线程池提交任务

向线程池提交任务的方法有两个,分别时execute()和submit()。

  • execute()方法用于提交不需要返回值的任务,所以无法判断任务是否被线程池执行成功。
  • submit()方法用于提交需要返回值的任务。线程池会返回一个fature类型的对象,通过这个对象,可以判断任务是否执行成功,并且可以通过fature的get()方法来获取返回值。调用get()方法会阻塞当前线程,直到工作线程返回结果。get(long timeout, TimeUnit unit)可设置超时返回。

关闭线程池

可以通过调用线程池的shutdown()或者shutdownNow()方法来关闭线程池。

  • shutdown()方法只是将线程池的状态设置成SHUTDOWN状态,然后中断所有没有正在执行任务的线程,已有的任务能够执行完。
  • shutdownNow()方法首先将线程池设置为STOP状态,然后尝试停止所有正在执行或暂停任务的线程,并返回等待执行任务的列表。

Executor框架

Executor框架的结构和成员

在这里插入图片描述
Executor框架主要由3大部分组成如下:

  • 任务:Runnable接口和Callable接口;
  • 任务的执行:Executor接口,继承Executor的ExecutorService接口,以及ExecutorService接口的两个实现类ThreadPoolExecutor和ScheduledThreadPoolExecutor;以及一个工具类:Executors;
  • 异步计算的结果:Future接口和Future接口的实现类FutureTask

ThreadPoolExecutor

ThreadPoolExecutor通常由工厂类Executors来创建。Executors可以创建3种类型的ThreadPoolExecutor:SingleThreadExecutor,FixedThreadPool,CachedThreadPool;

  • FixedThreadPool是使用固定线程数的线程池
    FixedThreadPool满足了资源管理的需求,可以限制当前线程数量。适用于负载较重的服务器环境。

  • SingleThreadExecutor使用单线程执行任务
    SingleThreadExecutor保证了任务执行的顺序,不会存在多线程活动。

  • CachedThreadPool是无界线程池
    CachedThreadPool适用于执行很多短期异步任务的小程序,适用于负载较轻的服务器。

ScheduledThreadPoolExecutor

它是ThreadPoolExecutor的子类且实现了ScheduledExecutorService接口,它可以在给定的延迟时间后执行命令,或者定期执行命令,它比Timer更强大更灵活。

Executors可以创建的ScheduledThreadPoolExecutor的类型有ScheduledThreadPoolExecutor和SingleThreadScheduledExecutor等

  • ScheduledThreadPoolExecutor具有固定线程个数,适用于需要多个后台线程执行周期任务,并且为了满足资源管理需求而限制后台线程数量的场景
  • SingleThreadScheduledExecutor具有单个线程
    它适用于单个后台线程执行周期任务,并且保证顺序一致执行的场景。
    ScheduledThreadPoolExecutor
    在给定延迟之后执行任务,或者定期执行任务。ScheduledThreadPoolExecutor的功能与Timer类似,但更强大、更灵活。Timer对应的是单个后台线程,而ScheduledThreadPoolExecutor可以在构造函数中指定多个对应的后台线程数。

FutureTask类

  • Runnable接口
  • Callable接口

Executors

在这里插入图片描述

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值