重拾Java基础知识:并发编程

前言

Java 并发的核心机制是 Thread 类,在该语言最初版本中, Thread (线程) 是由程序员直接创建和管理的。随着语言的发展以及人们发现了更好的一些方法,中间层机制 - 特别是 Executor 框架 - 被添加进来,以消除自己管理线程时候的心理负担(及错误)。 最终,甚至发展出比 Executor 更好的机制

术语“并发”,“并行”,“多任务”,“多处理”,“多线程”,分布式系统(可能还有其他)在整个编程文献中都以多种相互冲突的方式使用,并且经常被混为一谈。“并发”通常表示:不止一个任务正在执行。而“并行”几乎总是代表:不止一个任务同时执行。

并发

同时完成多任务。无需等待当前任务完成即可执行其他任务。“并发”解决了程序因外部控制而无法进一步执行的阻塞问题。最常见的例子就是 I/O 操作,任务必须等待数据输入(在一些例子中也称阻塞)。这个问题常见于 I/O 密集型任务。

并发就像你自己可以一分为二然后解决更多的问题一样简单。但是问题在于,我们来描述这种现象的任何模型最终都是泄漏抽象的(leaky abstraction)。

最大的影响之一取决于您是使用单核处理器还是多核处理器。如果你只有单核处理器,那么任务切换的成本也由该核心承担,将并发技术应用于你的系统会使它运行得更慢。

这可能会让你以为,在单核处理器的情况下,编写并发代码是没有意义的。然而,有些情况下,并发模型会产生更简单的代码,光是为了这个目的就值得舍弃一些性能。

假设一个制作蛋糕的工厂。我们以某种方式把制作蛋糕的任务分给了工人们,但是现在是时候让工人把蛋糕放在盒子里了。那里有一个盒子,准备存放蛋糕。但是在工人把蛋糕放进盒子之前,另一个工人就冲过去,把蛋糕放进盒子里,砰!这两个蛋糕撞到一起砸坏了。这是常见的“共享内存”问题,会产生所谓的竞态条件(race condition),其结果取决于哪个工人能先把蛋糕放进盒子里(通常使用锁机制来解决问题,因此一个工作人员可以先抓住一个盒子并防止蛋糕被砸烂)。

我们编写的并发程序大部分情况下都能正常运行,但是在一些情况下会失败。这些情况可能永远不会发生,或者在你在测试期间几乎很难发现它们。实际上,编写测试代码通常无法为并发程序生成故障条件。由此产生的失败只会偶尔发生,因此它们以客户投诉的形式出现。这是学习并发中最强有力的论点之一:如果你忽略它,你可能会受伤。

尽管 Java 8 在并发性方面做出了很大改进,但仍然没有像编译时验证 (compile-time verification) 或受检查的异常 (checked exceptions) 那样的安全网来告诉你何时出现错误。关于并发,你只能依靠自己,只有知识渊博、保持怀疑和积极进取的人,才能用 Java 编写可靠的并发代码。

并发为速度而生

在听说并发编程的问题之后,你可能会想知道它是否值得这么麻烦。答案是“不,除非你的程序运行速度不够快。”并且在决定用它之前你会想要仔细思考。不要随便跳进并发编程的悲痛之中。

速度问题一开始听起来很简单:如果你想要一个程序运行得更快,将其分解为多个部分,并在单独的处理器上运行每个部分。随着我们提高时钟速度的能力耗尽(至少对传统芯片而言),速度的提高是出现在多核处理器的形式而不是更快的芯片。为了使程序运行得更快,你必须学会利用那些额外的处理器(译者注:处理器一般代表 CPU 的一个逻辑核心),这是并发所带来的好处之一。

对于多处理器机器,可以在这些处理器之间分配多个任务,这可以显著提高吞吐量。强大的多处理器 Web 服务器通常就是这种情况,它可以在程序中为 CPU 分配大量用户请求,每个请求分配一个线程。

单处理器系统中性能改进的一个常见例子是事件驱动编程,特别是用户界面编程。考虑一个程序执行一些耗时操作,最终忽略用户输入导致无响应。如果你有一个“退出”按钮,你不想在你编写的每段代码中都检查它的状态(轮询)。这会产生笨拙的代码,也无法保证程序员不会忘了检查。没有并发,生成可响应用户界面的唯一方法是让所有任务都定期检查用户输入。通过创建单独的线程以执行用户输入的响应,能够让程序保证一定程度的响应能力。

实现并发的一种简单方式是使用操作系统级别的进程。与线程不同,进程是在其自己的地址空间中运行的独立程序。进程的优势在于,因为操作系统通常将一个进程与另一个进程隔离,因此它们不会相互干扰,这使得进程编程相对容易。相比之下,线程之间会共享内存和 I/O 等资源,因此编写多线程程序最基本的困难,在于协调不同线程驱动的任务之间对这些资源的使用,以免这些资源同时被多个任务访问。 有些人甚至提倡将进程作为唯一合理的并发实现方式,但遗憾的是,通常存在数量和开销方面的限制,从而阻止了进程在并发范围内的适用性(最终你会习惯标准的并发限制,“这种方法适用于一些情况但不适用于其他情况”)

并发会带来各种成本,包括复杂性成本,但可以被程序设计、资源平衡和用户便利性方面的改进所抵消。通常,并发性使你能够创建更低耦合的设计;另一方面,你必须特别关注那些使用了并发操作的代码。

并行

同时在多个位置完成多任务。这解决了所谓的 CPU 密集型问题:将程序分为多部分,在多个处理器上同时处理不同部分来加快程序执行效率。

上面的定义说明了这两个术语令人困惑的原因:两者的核心都是“同时完成多个任务”,不过并行增加了跨多个处理器的分布。更重要的是,它们可以解决不同类型的问题:并行可能对解决 I/O 密集型问题没有任何好处,因为问题不在于程序的整体执行速度,而在于 I/O 阻塞。而尝试在单个处理器上使用并发来解决计算密集型问题也可能是浪费时间。两种方法都试图在更短的时间内完成更多工作,但是它们实现加速的方式有所不同,这取决于问题施加的约束。

这两个概念混合在一起的一个主要原因是包括 Java 在内的许多编程语言使用相同的机制 - 线程来实现并发和并行。

我们甚至可以尝试以更细的粒度去进行定义(然而这并不是标准化的术语):

  • 纯并发:仍然在单个 CPU 上运行任务。纯并发系统比顺序系统更快地产生结果,但是它的运行速度不会因为处理器的增加而变得更快。
  • 并发-并行:使用并发技术,结果程序可以利用更多处理器更快地产生结果。
  • 并行-并发:使用并行编程技术编写,如果只有一个处理器,结果程序仍然可以运行(Java 8 Streams 就是一个很好的例子)。
  • 纯并行:除非有多个处理器,否则不会运行。

并行流

Java 8 流的一个显著优点是,在某些情况下,它们可以很容易地并行化。这来自库的仔细设计,特别是流使用内部迭代的方式 - 也就是说,它们控制着自己的迭代器。特别是,他们使用一种特殊的迭代器,称为 Spliterator,它被限制为易于自动分割。我们只需要念 .parallel() 就会产生魔法般的结果,流中的所有内容都作为一组并行任务运行。如果你的代码是使用 Streams 编写的,那么并行化以提高速度似乎是一种琐事。

public class ExecutorTest {
    public static void main(String[] args) {
        long startName = System.currentTimeMillis();
        Integer reduce = Stream.iterate(1, i -> i + 1).limit(1000).reduce(0, Integer::sum);
        long endOneTime = System.currentTimeMillis();
        System.out.println("顺序流执行所耗时间:"+(endOneTime - startName));
        reduce = Stream.iterate(1, i -> i + 1).limit(1000).parallel().reduce(0, Integer::sum);
        long endTwoTime = System.currentTimeMillis();
        System.out.println("并行流执行所耗时间:"+(endTwoTime - endOneTime));
        /** Output:
         * 顺序流执行所耗时间:64
         * 并行流执行所耗时间:11
         */
    }
}

经过测试,我们发现并行流对比顺序流的速度还是要快很多,是不是意味着并行流的速度永远都会要快呢?这是不一定的,这取决于你CPU的性能。并行流优势如此大,那么我们应该始终使用它吗?我们通过一个案例来讲解:

public class ExecutorTest {
    public static void main(String[] args) {
        Stream.iterate(1, i -> i + 1).limit(5).forEach(item->{
            System.out.println(Thread.currentThread().getName());
        });
        Stream.iterate(1, i -> i + 1).limit(5).parallel().forEach(item->{
            System.out.println(Thread.currentThread().getName());
        });
        /** Output:
         * main
         * main
         * main
         * main
         * main
         * main
         * ForkJoinPool.commonPool-worker-2
         * ForkJoinPool.commonPool-worker-4
         * ForkJoinPool.commonPool-worker-1
         * ForkJoinPool.commonPool-worker-3
         */
    }
}

我们发现顺序执行的情况下 主线程(main thread) 会完成所有工作,并行流的情况下会同时生成4个线程(通过 Runtime.getRuntime().availableProcessors()来获取当前计算机的CPU 内核数量)并在内部使用ForkJoin池创建和管理线程。并行流通过静态ForkJoinPool.commonPool() 方法创建ForkJoinPool实例。

上述代码我们可以看到仅通过添加parallel()方法即可轻松将顺序流转换为并行流。它更快吗?一个更好的问题是:什么时候开始有意义?当然不是这么小的一套;上下文切换的代价远远超过并行性的任何加速。很难想象什么时候用并行生成一个简单的数字序列会有意义。如果你要生成的东西需要很高的成本,它可能有意义 - 但这都是猜测。只有通过测试我们才能知道用并行是否有效。记住这句格言:“首先使它工作,然后使它更快地工作 - 只有当你必须这样做时。”

实际上,在许多情况下,并行流确实可以毫不费力地更快地产生结果。但正如你所见,仅仅将 parallel() 加到你的 Stream 操作上并不一定是安全的事情。在使用 parallel() 之前,你必须了解并行性如何帮助或损害你的操作。一个基本认知错误就是认为使用并行性总是一个好主意。事实上并不是。Stream 意味着你不需要重写所有代码便可以并行运行它。但是流的出现并不意味着你可以不用理解并行的原理以及不用考虑并行是否真的有助于实现你的目标。

终止耗时任务

并发程序通常使用长时间运行的任务。可调用任务在完成时返回值;虽然这给它一个有限的寿命,但仍然可能很长。可运行的任务有时被设置为永远运行的后台进程。你经常需要一种方法在正常完成之前停止 RunnableCallable 任务,例如当你关闭程序时。

最初的 Java 设计提供了中断运行任务的机制(为了向后兼容,仍然存在);中断机制包括阻塞问题。中断任务既乱又复杂,因为你必须了解可能发生中断的所有可能状态,以及可能导致的数据丢失。使用中断被视为反对模式,但我们仍然被迫接受。

InterruptedException,因为设计的向后兼容性残留。

任务终止的最佳方法是设置任务周期性检查的标志。然后任务可以通过自己的 shutdown 进程并正常终止。不是在任务中随机关闭线程,而是要求任务在到达了一个较好时自行终止。这总是产生比中断更好的结果,以及更容易理解的更合理的代码。

资源共享

你可以将单线程程序看作一个孤独的实体,在你的问题空间中移动并同一时间只做一件事。因为只有一个实体,你永远不会想到两个实体试图同时使用相同资源的问题。通过并发,事情不再孤单,但现在两个或更多任务可能会相互干扰。如果你不阻止这种冲突,你将有两个任务同时尝试访问同一个银行帐户,打印到同一个打印机,调整同一个阀门,等等。

资源竞争

在单线程系统中,你不需要考虑资源竞争,因为你永远不可能同时做多件事。当你有多个任务时,任何任务都可能同时读写 共享资源 。这揭示了 资源竞争 问题,这是处理任务时的主要陷阱之一。

防止这种冲突的方法就是当资源被一个任务使用时,在其上加锁。第一个访问某项资源的任务必须锁定这项资源,使其他任务在其被解锁之前,就无法访问它,而在其被解锁时候,另一个任务就可以锁定并使用它,以此类推。

Java 以提供关键字 synchronized 的形式,为防止资源冲突提供了内置支持。当任务希望执行被 synchronized 关键字保护的代码片段的时候,Java 编译器会生成代码以查看锁是否可用。如果可用,该任务获取锁,执行代码,然后释放锁。

共享资源一般是以对象形式存在的内存片段,但也可以是文件、I/O 端口,或者类似打印机的东西。要控制对共享资源的访问,得先把它包装进一个对象。然后把任何访问该资源的方法标记为 synchronized 。 如果一个任务在调用其中一个 synchronized 方法之内,那么在这个任务从该方法返回之前,其他所有要调用该对象的 synchronized 方法的任务都会被阻塞。

你应该什么时候使用同步呢?可以永远 Brian 的同步法则。

如果你正在写一个变量,它可能接下来被另一个线程读取,或者正在读取一个上一次已经被另一个线程写过的变量,那么你必须使用同步,并且,读写线程都必须用相同的监视器锁同步。

如果在你的类中有超过一个方法在处理临界数据,那么你必须同步所有相关方法。如果只同步其中一个方法,那么其他方法可以忽略对象锁,并且可以不受惩罚地调用。这是很重要的一点:每个访问临界共享资源的方法都必须被同步,否则将不会正确地工作。

volatile 关键字

volatile 可能是 Java 中最微妙和最难用的关键字。幸运的是,在现代 Java 中,你几乎总能避免使用它,如果你确实看到它在代码中使用,你应该保持怀疑态度和怀疑 - 这很有可能代码是过时的,或者编写代码的人不清楚使用它在大体上(或两者都有)易变性(volatile) 或并发性的后果。

使用 volatile的理由:可见性、有序性

  • 原子性:一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行,在多线程并发状态下volatile 修饰的变量是无法保证线程安全的,可以采用synchronized关键字、Lock锁AtomicInteger达到预期目的。

示例代码:通过一个简单案例来介绍volatilesynchronizedLock锁AtomicInteger它们的区别

public class ThreadTest implements Runnable{
    int a= 0;
    volatile int b = 0;
    public int c = 0;
    public  AtomicInteger d = new AtomicInteger();
    Lock lock = new ReentrantLock();
    private void lockMethod(){
        lock.lock();
        a++;
        lock.unlock();
    }
    private void volatileMethod(){
        b++;
    }
    private synchronized void syncMethod(){
        c++;
    }
    private void atomicMethod(){
        d.incrementAndGet();
    }
    @Override
    public void run() {
        for (int j = 0; j < 1000; j++) {
            lockMethod();
            volatileMethod();
            syncMethod();
            atomicMethod();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadTest test = new ThreadTest();
        for (int i = 0; i < 10; i++) {
            Thread thread = new Thread(test);
            thread.start();
        }
        Thread.sleep(1000);
        System.out.println("lock锁,a="+test.a);
        System.out.println("volatile,b="+test.b);
        System.out.println("synchronized,c="+test.c);
        System.out.println("AtomicInteger,d="+test.d);
        /** Output: 
         * lock锁,a=10000
         * volatile,b=9989
         * synchronized,c=10000
         * AtomicInteger,d=10000
         */
    }
}
  • 可见性:线程之间的可见性,一个线程修改的状态对另一个线程是可见的。强制线程主体直接从主内存中读取数据。越过各自的缓存,这样就能保证每次拿到的都是最新的数据。

示例代码:没有使用volatile和使用volatile进行对比。

没有使用volatile,线程一直在运行中;

public class ExecutorTest {
    public static void main(String[] args) throws InterruptedException {
        ThreadTest test = new ThreadTest();
        Thread thread = new Thread(test);
        thread.start();
        test.flag = true;
        /** Output:
         * 执行run方法
         */
    }
}
public class ThreadTest implements Runnable {
    public boolean flag = false;
    @Override
    public void run() {
        System.out.println("执行run方法");
        while (!flag){}
        System.out.println("结束");
    }
}

使用volatile,一个线程修改数据后,另一个线程数据也被更新;

public class ExecutorTest {
    public static void main(String[] args) throws InterruptedException {
        ThreadTest test = new ThreadTest();
        Thread thread = new Thread(test);
        thread.start();
        TimeUnit.SECONDS.sleep(5);
        test.flag = true;
        /** Output:
         * 执行run方法
         * 结束
         */
    }
}
public class ThreadTest implements Runnable {
    public volatile boolean flag = false;
    @Override
    public void run() {
        System.out.println("执行run方法");
        while (!flag){}
        System.out.println("结束");
    }
}

当并发时,有时不清楚 Java 什么时候应该将值从主内存刷新到本地缓存 — 而这个问题称为 缓存一致性 ( cache coherence )。每个线程都可以在处理器缓存中存储变量的本地副本。将字段定义为 volatile 可以防止这些编译器优化,这样读写就可以直接进入内存,而不会被缓存。一旦该字段发生写操作,所有任务的读操作都将看到更改。如果一个 volatile 字段刚好存储在本地缓存,则会立即将其写入主内存,并且该字段的任何读取都始终发生在主内存中。

  • 有序性:程序执行的顺序按照代码的先后顺序执行。只要结果不会改变程序表现,Java 可以通过重排指令来优化性能。然而,重排可能会影响本地处理器缓存与主内存交互的方式,从而产生细微的程序 bug 。直到 Java 5 才理解并解决了这个无法阻止重排的问题。现在,volatile 关键字可以阻止重排 volatile 变量周围的读写指令。这种重排规则称为 happens before 担保原则 。

示例代码:

public class ThreadTest implements Runnable {
    int one, two, three, four, five, six;
    volatile int volaTile;
    @Override
    public void run() {
        one = 1;
        two = 2;
        three = 3;
        volaTile = 92;
        int x = four;
        int y = five;
        int z = six;
    }
}

例子中 one,two,three 变量赋值操作就可以被重排,只要它们都发生在 volatile 变量写操作之前。同样,只要 volatile 变量写操作发生在所有语句之前, x,y,z 语句可以被重排。这种 volatile (易变性)操作通常称为 memory barrier (内存屏障)。 happens before 担保原则确保 volatile 变量的读写指令不能跨过内存屏障进行重排。

happens before 担保原则还有另一个作用:当线程向一个 volatile 变量写入时,在线程写入之前的其他所有变量(包括非 volatile 变量)也会刷新到主内存。当线程读取一个 volatile 变量时,它也会读取其他所有变量(包括非 volatile 变量)与 volatile 变量一起刷新到主内存。尽管这是一个重要的特性,它解决了 Java 5 版本之前出现的一些非常狡猾的 bug ,但是你不应该依赖这项特性来“自动”使周围的变量变得易变性 ( volatile )的 。如果你希望变量是易变性 ( volatile )的,那么维护代码的任何人都应该清楚这一点。

什么时候使用volatile

volatile 应该在何时适用于变量:

  1. 该变量同时被多个任务访问。
  2. 这些访问中至少有一个是写操作。
  3. 你尝试避免同步 (在现代 Java 中,你可以使用高级工具来避免进行同步)。

举个例子,如果你使用变量作为停止任务的标志值。那么该变量至少必须声明为 volatile (尽管这并不一定能保证这种标志的线程安全)。否则,当一个任务更改标志值时,这些更改可以存储在本地处理器缓存中,而不会刷新到主内存。当另一个任务查看标记值时,它不会看到更改。

如果你尝试使用 volatile ,你可能更应该尝试让一个变量线程安全而不是引起同步的成本。因为 volatile 使用起来非常微妙和棘手,所以我建议根本不要使用它;相反,请使用 java.util.concurrent.atomic 里面类之一。它们以比同步低得多的成本提供了完全的线程安全性。

如果你正在尝试调试其他人的并发代码,请首先查找使用 volatile 的代码并将其替换为Atomic 变量。除非你确定程序员对并发性有很高的理解,否则它们很可能会误用 volatile

volatile关键字 与 synchronized关键字相比,volatile关键字轻量级的同步只能作用于属性,synchronized关键字保证原子性。

原子类

Java 5 引入了专用的原子变量类,例如 AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference 等。这些提供了原子性升级。这些快速、无锁的操作,它们是利用了现代处理器上可用的机器级原子性。目的就是为了解决基本类型操作的非原子性导致在多线程并发情况下引发的问题。

示例代码:

public class ThreadTest implements Runnable {
    public AtomicInteger a = new AtomicInteger();
    @Override
    public void run() {
        System.out.println("当前线程:"+Thread.currentThread().getName());
        for (int i = 0; i < 1000; i++) {
            a.incrementAndGet();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ThreadTest threadTest = new ThreadTest();
        for (int i = 0; i < 2; i++) {
            Thread thread = new Thread(threadTest);
            thread.start();
        }
        Thread.sleep(1000);
        System.out.println(threadTest.a);
        /** Output:
         *  当前线程:Thread-0
         *  当前线程:Thread-1
         *  2000
         */
    }
}

这些都是对单一字段的简单示例; 当你创建更复杂的类时,你必须确定哪些字段需要保护,在某些情况下,你可能仍然最后在方法上使用 synchronized 关键字。

无锁集合

集合 章节强调集合是基本的编程工具,这也要求包含并发性。因此,早期的集合比如 VectorHashtable 有许多使用 synchronized 机制的方法。当这些集合不是在多线程应用中使用时,这就导致了不可接收的开销。在 Java 1.2 版本中,新的集合库是非同步的,而给 Collection 类赋予了各种 static synchronized 修饰的方法来同步不同的集合类型。虽然这是一个改进,因为它让你可以选择是否对集合使用同步,但是开销仍然基于同步锁定。 Java 5 版本添加新的集合类型,专门用于增加线程安全性能,使用巧妙的技术来消除锁定。

无锁集合有一个有趣的特性:只要读取者仅能看到已完成修改的结果,对集合的修改就可以同时发生在读取发生时。这是通过一些策略实现的。为了让你了解它们是如何工作的,我们来看看其中的一些。

复制策略

使用“复制”策略,修改是在数据结构一部分的单独副本(或有时是整个数据的副本)上进行的,并且在整个修改过程期间这个副本是不可见的。仅当修改完成时,修改后的结构才与“主”数据结构安全地交换,然后读取者才会看到修改。

CopyOnWriteArrayList ,写入操作会复制整个底层数组。保留原来的数组,以便在修改复制的数组时可以线程安全地进行读取。当修改完成后,原子操作会将其交换到新数组中,以便新的读取操作能够看到新数组内容。 CopyOnWriteArrayList 的其中一个好处是,当多个迭代器遍历和修改列表时,它不会抛出 ConcurrentModificationException 异常,因此你不用就像过去必须做的那样,编写特殊的代码来防止此类异常。

CopyOnWriteArraySet 使用 CopyOnWriteArrayList 来实现其无锁行为。

ConcurrentHashMapConcurrentLinkedQueue 使用类似的技术来允许并发读写,但是只复制和修改集合的一部分,而不是整个集合。然而,读取者仍然不会看到任何不完整的修改。ConcurrentHashMap 不会抛出concurrentmodificationexception 异常。

比较并交换 (CAS)

在 比较并交换 (CAS) 中,你从内存中获取一个值,并在计算新值时保留原始值。然后使用 CAS 指令,它将原始值与当前内存中的值进行比较,如果这两个值是相等的,则将内存中的旧值替换为计算新值的结果,所有操作都在一个原子操作中完成。如果原始值比较失败,则不会进行交换,因为这意味着另一个线程同时修改了内存。在这种情况下,你的代码必须再次尝试,获取一个新的原始值并重复该操作。

举例:线程A、B对某个数据(C)进行修改,A、B线程先获取这个数据(C),假设B线程对这个数据(C)进行了修改(D),此时A线程对这个数据(C)进行修改的时候,先将之前获取的数据(C)与修改后的数据(D)进行匹配,发现不相等,不会进行替换。

示例代码:下面这段代码是简单CAS算法实现,但是有ABA的问题。

public class ThreadTest {
    public static AtomicInteger atomicInteger = new AtomicInteger(100);
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            boolean b = atomicInteger.compareAndSet(100, 99);
            System.out.println(Thread.currentThread().getName()+"修改结果:"+b);
            b = atomicInteger.compareAndSet(99, 100);
            System.out.println(Thread.currentThread().getName()+"修改结果:"+b);
        },"线程一").start();
        Thread.sleep(1000);
        new Thread(()->{
            boolean b = atomicInteger.compareAndSet(100, 99);
            System.out.println(Thread.currentThread().getName()+"修改结果:"+b);
        },"线程二").start();
        /** Output:
         *  线程一修改结果:true
         *  线程一修改结果:true
         *  线程二修改结果:true
         */
    }
}

如果内存仅轻量竞争,CAS操作几乎总是在没有重复尝试的情况下完成,因此它非常快。相反,synchronized 操作需要考虑每次获取和释放锁的成本,这要昂贵得多,而且没有额外的好处。随着内存竞争的增加,使用 CAS 的操作会变慢,因为它必须更频繁地重复自己的操作,但这是对更多资源竞争的动态响应。这确实是一种优雅的方法。

最重要的是,许多现代处理器的汇编语言中都有一条 CAS 指令,并且也被 JVM 中的 CAS 操作(例如 Atomic 类中的操作)所使用。CAS 指令在硬件层面中是原子性的,并且与你所期望的操作一样快。

ABA

CAS实现的过程是先取出内存中某时刻的数据,在下一时刻比较并替换,那么在这个时间差会导致数据的变化,此时就会导致出现“ABA”问题。

举例:线程A、B对某个数据(C)进行修改,A、B线程先获取这个数据(C),假设B线程对这个数据(C)进行了修改(D),然后又修改为(C),此时A线程对这个数据(C)进行修改的时候,CAS操作成功,但是不代表这个中间的过程是安全的。

示例代码:解决ABA的问题可以通过,版本号机制AtomicStampedReference

public class ThreadTest {
    public static AtomicStampedReference<Integer> atomicInteger = new AtomicStampedReference<Integer>(100,1);
    public static void main(String[] args) throws InterruptedException {
        new Thread(()->{
            int stamp = atomicInteger.getStamp();
            System.out.println(Thread.currentThread().getName()+"当前版本号:"+stamp);
            boolean b = atomicInteger.compareAndSet(100, 99,stamp,stamp+1);
            System.out.println(Thread.currentThread().getName()+"修改结果:"+b);
        },"线程一").start();
        Thread.sleep(1000);
        new Thread(()->{
            int stamp = atomicInteger.getStamp();
            System.out.println(Thread.currentThread().getName()+"当前版本号:"+stamp);
            boolean b = atomicInteger.compareAndSet(100, 99,stamp,stamp+1);
            System.out.println(Thread.currentThread().getName()+"修改结果:"+b);
        },"线程二").start();
        /** Output:
         *  线程一当前版本号:1
         *  线程一修改结果:true
         *  线程二当前版本号:2
         *  线程二修改结果:false
         */
    }
}

抽象队列同步器(AbstractQueuedSynchronizer,简称AQS)是用来构建锁或者其他同步组件的基础框架,它使用一个整型的volatile变量(命名为state)来维护同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作,JUC包中的ReentrantLockSemaphoreReentrantReadWriteLockCountDownLatch 等等几乎所有的类都是基于AQS实现的。。

乐观锁和悲观锁

  • 乐观锁:总是假设最好的情况,每次去拿数据的时候都认为别人不会修改,所以不会上锁(无锁算法),但是在更新的时候会判断一下,在此期间有没有别人去更新这个数据,可以使用版本号机制和CAS算法实现。乐观锁适用于多读的应用类型,这样可以提高吞吐量,在Javajava.util.concurrent.atomic包下面的原子变量类就是使用了乐观锁的一种实现方式CAS

两种方案:可以参考:CAS目录

  1. 采用版本号机制(version)。
  2. CAS(Compare-and-Swap,即比较并替换)算法实现
  • 悲观锁:总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁(共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程)。 在Java中,SynchronizedLock等独占锁的实现机制就是基于悲观锁思想。在数据库中也经常用到这种锁机制,如行锁,表锁,读写锁等,都是在操作之前先上锁,保证共享资源只能给一个操作(一个线程)使用。

示例代码:

public class ThreadTest implements Runnable{
    Lock lock = new ReentrantLock();
	//1.lock锁方法
    private void lockMethod(){
        lock.lock();
        try {
            //todo
        }finally {
            lock.unlock();
        }
    }
    //2.synchronized关键字方法
    private synchronized void syncMethod(){
        //todo
    }
    @Override
    public void run() {
        syncMethod();
    }
}

公平锁和非公平锁

  • 公平锁:指锁的分配机制是公平的,在并发环境中,每个线程在获取锁时会先查看此锁维护的等待队列,如果为空,或者当前线程是等待队列的第一个,就占有锁,否则就会加入到等待队列中优先排队等待的线程,按照FIFO的规则。优点是等待锁的线程不会饿死。缺点是整体吞吐效率相对非公平锁要低,等待队列中除第一个线程以外的所有线程都会阻塞,CPU唤醒阻塞线程的开销比非公平锁大。

示例代码:new ReentrantLock(true);默认情况下使用非公平锁,设置为true,可以看到线程之间的分配公平。

public class ThreadTest{

    Lock lock = new ReentrantLock(true);
    int poll = 10;

    public void ticket(){
        lock.lock();
        try {
            if(poll <= 0){
                System.out.println(Thread.currentThread().getName()+"获取当前票数售空");
                return;
            }
            System.out.println(Thread.currentThread().getName()+"获取当前票数:"+poll--+",购买后还剩"+poll);
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ThreadTest threadTest = new ThreadTest();
        new Thread(()->{
            for (int i = 0; i < 5; i++) {
                threadTest.ticket();
            }
        },"线程一").start();
        new Thread(()->{
            for (int i = 0; i < 5; i++) {
                threadTest.ticket();
            }
        },"线程二").start();
        new Thread(()->{
            for (int i = 0; i < 5; i++) {
                threadTest.ticket();
            }
        },"线程三").start();
        /** Output:
         * 线程一获取当前票数:10,购买后还剩9
         * 线程一获取当前票数:9,购买后还剩8
         * 线程一获取当前票数:8,购买后还剩7
         * 线程一获取当前票数:7,购买后还剩6
         * 线程二获取当前票数:6,购买后还剩5
         * 线程一获取当前票数:5,购买后还剩4
         * 线程二获取当前票数:4,购买后还剩3
         * 线程三获取当前票数:3,购买后还剩2
         * 线程二获取当前票数:2,购买后还剩1
         * 线程三获取当前票数:1,购买后还剩0
         * 线程二获取当前票数售空
         * 线程三获取当前票数售空
         * 线程二获取当前票数售空
         * 线程三获取当前票数售空
         * 线程三获取当前票数售空
         */
    }
}
  • 非公平锁:多个线程加锁时直接尝试获取锁,有可能后申请的线程比先申请的线程优先获取锁,有几率导致其它线程获取不到锁。非公平锁的优点是可以减少唤起线程的开销,整体的吞吐效率高,因为线程有几率不阻塞直接获得锁,CPU不必唤醒所有线程。缺点是处于等待队列中的线程可能会饿死,或者等很久才会获得锁。

示例代码:

public class ThreadTest{

    Lock lock = new ReentrantLock();
    int poll = 10;

    public void ticket(){
        lock.lock();
        try {
            if(poll <= 0){
                System.out.println(Thread.currentThread().getName()+"获取当前票数售空");
                return;
            }
            System.out.println(Thread.currentThread().getName()+"获取当前票数:"+poll--+",购买后还剩"+poll);
        } catch (Exception e) {
            e.printStackTrace();
        }finally {
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        ThreadTest threadTest = new ThreadTest();
        new Thread(()->{
            for (int i = 0; i < 5; i++) {
                threadTest.ticket();
            }
        },"线程一").start();
        new Thread(()->{
            for (int i = 0; i < 5; i++) {
                threadTest.ticket();
            }
        },"线程二").start();
        new Thread(()->{
            for (int i = 0; i < 5; i++) {
                threadTest.ticket();
            }
        },"线程三").start();
        /** Output:
         * 线程一获取当前票数:10,购买后还剩9
         * 线程一获取当前票数:9,购买后还剩8
         * 线程一获取当前票数:8,购买后还剩7
         * 线程一获取当前票数:7,购买后还剩6
         * 线程三获取当前票数:6,购买后还剩5
         * 线程三获取当前票数:5,购买后还剩4
         * 线程三获取当前票数:4,购买后还剩3
         * 线程三获取当前票数:3,购买后还剩2
         * 线程三获取当前票数:2,购买后还剩1
         * 线程二获取当前票数:1,购买后还剩0
         * 线程二获取当前票数售空
         * 线程二获取当前票数售空
         * 线程二获取当前票数售空
         * 线程二获取当前票数售空
         * 线程一获取当前票数售空
         */
    }
}

独享锁和共享锁

  • 独享锁:独享锁也叫排他锁,指一次只能被一个线程所持有,避免了读/写冲突,如果一个线程对数据加上了排它锁后,那么其他线程就不能再对该数据加任何类型的锁。获得独享锁的线程既能读取数据又能修改数据。JDK中的synchronizejava.util.concurrent(JUC)包中Lock的实现类就是独享锁。

示例代码:

public class Test {
    static Lock lock = new ReentrantLock();
    public static void main(String[] args) {
        lock.lock();
        try{
            //todo
        }finally {
            lock.unlock();
        }
    }

}
  • 共享锁:可被多个线程所持有,放宽了加锁策略,如果一个线程对数据加上了共享锁后,那么其他线程只能对数据再加共享锁,不能加独占锁。允许多个执行读操作的线程同时访问共享资源,获得共享锁的线程只能读取数据, 不能修改数据。JDKReentrantReadwriteLock默认非公平锁)就是一种共享锁,读锁是共享锁,写锁是独享锁。

示例代码:你可以尝试把锁都给去掉,这样理解更加深刻。

public class Test {
    static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();

    Map<String,String> map = new HashMap<>();

    public void get(String key) {
        lock.readLock().lock();
        try {
            String s = map.get(key);
            System.out.println(Thread.currentThread().getName()+"读取:"+s);
            Thread.sleep(1000);
            System.out.println(Thread.currentThread().getName()+"读取完毕");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.readLock().unlock();
        }
    }

    public void set(String key,String value) {
        lock.writeLock().lock();
        try {
            System.out.println(Thread.currentThread().getName()+"赋值:"+value);
            Thread.sleep(1000);
            map.put(key,value);
            System.out.println(Thread.currentThread().getName()+"赋值完毕");
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            lock.writeLock().unlock();
        }
    }

    public static void main(String[] args) {
        Test test = new Test();
        for (int i = 0; i < 2; i++) {
            String v = i+"";
            new Thread(()->{
                test.set(v,v);
            }).start();
        }
        for (int i = 0; i < 2; i++) {
            String key = i+"";
            new Thread(()->{
                test.get(key);
            }).start();
        }
        /** Output:
         * Thread-0赋值:0
         * Thread-0赋值完毕
         * Thread-1赋值:1
         * Thread-1赋值完毕
         * Thread-2读取:0
         * Thread-3读取:1
         * Thread-3读取完毕
         * Thread-2读取完毕
         */
    }
}

互斥锁和读写锁

  • 互斥锁:互斥锁就是独享锁的一种常规类实现。是指某一资源同时只允许另一个访问者对齐进行访问,具有唯一性和排他性。
  • 读写锁:读写锁是共享锁的一种具体实现。读锁可以在没有写锁的时候被多个线程同时拥有,而写锁是独占的。写锁的优先级要高于读锁,一个获得了读锁的线程必须能看到前一个释放的写锁所更新的内容。

自旋锁和适应性自旋锁

  • 自旋锁:持有锁的线程能在很短时间内释放锁资源,那么那些等待竞争锁的线程就不需要做内核态和用户态之间的切换进入阻塞挂起状态,等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换的消耗。如果锁的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,自旋锁长时间占用CPU获取不到,造成浪费。

示例代码:通过CAS进行自旋锁,当线程二获取锁替换时,发线程一未释放锁,替换中,所以线程二会一直循环,直至线程一释放后。

public class Test {
    AtomicReference atomicReference = new AtomicReference();

    public void getLock(){
        Thread thread = Thread.currentThread();
        System.out.println("当前线程:"+ thread.getName());
        while (!atomicReference.compareAndSet(null, thread)){
        }
    }

    public void unLock(){
        Thread thread = Thread.currentThread();
        System.out.println("解锁当前线程:"+ thread.getName());
        atomicReference.compareAndSet(thread,null);
    }

    public static void main(String[] args) {
        Test test = new Test();
        new Thread(()->{
            try {
                test.getLock();
                TimeUnit.SECONDS.sleep(5);
            } catch (Exception e) {
                e.printStackTrace();
            }finally {
                test.unLock();
            }
        },"线程一").start();

        new Thread(()->{
            test.getLock();
            test.unLock();
        },"线程二").start();
        /** Output:
         * 当前线程:线程一
         * 当前线程:线程二
         * 解锁当前线程:线程一
         * 解锁当前线程:线程二
         */
    }
}
  • 适应性自旋锁JVM对于自旋周期的选择,JDK1.5这个限度是一定的写死的,JDK1.6又引入了自适应自旋,适应性自旋锁意味着自旋的时间不在是固定的。而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也是很有可能再次成功,进而它将允许自旋等待持续相对更长的时间。如果对于某个锁,自旋很少成功获得过,那在以后尝试获取这个锁时将可能省略掉自旋过程,直接阻塞线程,避免浪费处理器资源。

可重入锁和非可重入锁

  • 可重入锁:可重入锁又名递归锁。是指在同一个线程在外层方法获取锁的时候,在进入内层方法会自动获取锁,一定程度避免死锁。其中ReentrantLock(名字就是可重入锁)synchronized 都是 可重入锁。

示例代码:代码中a()方法调用b()方法,如果一个线程调用了a()方法已经获取了锁再去调用b()方法就不需要再次去获取锁了,这就是可重入锁的特性。如果不是可重入锁的话b()方法可能不会被当前线程执行,可能造成死锁。

public class Test {

    public synchronized void a(){
        System.out.println(Thread.currentThread().getName()+"进入a()方法");
        b();
    }

    public synchronized void b(){
        System.out.println(Thread.currentThread().getName()+"进入b()方法");
    }

    public static void main(String[] args) {
        Test test = new Test();
        new Thread(()->{
            test.a();
        },"线程一").start();

        new Thread(()->{
            test.a();
        },"线程二").start();
        /** Output:
         * 线程一进入a()方法
         * 线程一进入b()方法
         * 线程二进入a()方法
         * 线程二进入b()方法
         */
    }
}
  • 非可重入锁:线程执行某个方法已经获取了该锁,那么在方法中尝试再次获取锁时,就会获取不到被阻塞。

示例代码:

public class Test {

    private boolean isLock = false;

    public synchronized void lock() throws InterruptedException {
        System.out.println(Thread.currentThread().getName()+"获取锁");
        if(isLock){
            System.out.println(Thread.currentThread().getName()+"已有锁");
            wait();
        }
        isLock = true;
    }

    public synchronized void unLock(){
        System.out.println(Thread.currentThread().getName()+"释放锁");
        if(isLock){
            System.out.println(Thread.currentThread().getName()+"释放锁成功");
            isLock = false;
            notify();
        }
    }

    public void retry() throws InterruptedException {
        lock();
        unLock();
    }

    public static void main(String[] args) {
        Test test = new Test();
        new Thread(()->{
            try {
                test.lock();
                test.retry();
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                test.unLock();
            }
        },"线程一").start();

        /** Output:
         * 线程一获取锁
         * 线程一获取锁
         * 线程一已有锁
         */
    }
}

锁升级(无锁、偏向锁、轻量级锁和重量级锁)

了解两个重要的概念:“Java对象头”、“Monitor(监视器)”。

  • Java对象头

把锁存在Java对象头里,以Hotspot虚拟机为例,Hotspot的对象头主要包括两部分数据:Mark Word(标记字段)、Klass Pointer(类型指针)。

Mark Word(标记字段):默认存储对象的HashCode,分代年龄和锁标志位信息。这些信息都是与对象自身定义无关的数据,所以Mark Word被设计成一个非固定的数据结构以便在极小的空间内存存储尽量多的数据。它会根据对象的状态复用自己的存储空间,也就是说在运行期间Mark Word里存储的数据会随着锁标志位的变化而变化。

Klass Pointer(类型指针): 对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

  • Monitor(监视器)

可以理解为一个同步工具或一种同步机制,通常被描述为一个对象。每一个Java对象就有一把看不见的锁,称为内部锁或者Monitor锁。Monitor是线程私有的数据结构,每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个Monitor关联,同时Monitor中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。

如同我们在自旋锁中提到的“阻塞或唤醒一个Java线程需要操作系统切换CPU状态来完成,这种状态转换需要耗费处理器时间。如果同步代码块中的内容过于简单,状态转换消耗的时间有可能比用户代码执行的时间还要长”。这种方式就是synchronized最初实现同步的方式,这就是JDK 6之前synchronized效率低的原因。这种依赖于操作系统Mutex Lock所实现的锁我们称之为“重量级锁”,JDK 6中为了减少获得锁和释放锁带来的性能消耗,引入了“偏向锁”和“轻量级锁”。

所以目前锁一共有4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁和重量级锁。锁状态只能升级不能降级。

  • 无锁:没有对资源进行锁定,所有的线程都能访问并修改同一个资源,但同时只有一个线程能修改成功。其实就是之前讲的乐观锁。
  • 偏向锁:偏向于第一个访问锁的线程,如果在运行过程中,只有一个线程访问加锁的资源,不存在多个线程竞争的情况,会在Mark Word里存储锁偏向的线程ID。在线程进入和退出同步块时不再通过CAS操作来加锁和解锁,而是检测Mark Word里是否存储着指向当前线程的偏向锁。引入偏向锁是为了在无多线程竞争的情况下尽量减少不必要的轻量级锁执行路径,因为轻量级锁的获取及释放依赖多次CAS原子指令,而偏向锁只需要在置换ThreadID的时候依赖一次CAS原子指令即可;偏向锁并不会主动释放,这样每次偏向锁进入的时候都会判断改资源是否是偏向自己的,如果是偏向自己的则不需要进行额外的操作,直接可以进入同步操作。如果在运行过程中,遇到了其他线程抢占锁,则持有偏向锁的线程会被挂起,JVM会消除它身上的偏向锁,将锁恢复到标准的轻量级锁。
  • 轻量级锁:由偏向所升级来的,偏向锁运行在一个线程进入同步块的情况下,当第二个线程加入锁争用的时候,偏向锁就会升级为轻量级锁; 在轻量级锁中如果发生多线程竞争,未持有锁的线程会自旋等待。若当前只有一个等待线程,则该线程通过自旋进行等待。但是当自旋超过一定的次数,或者一个线程在持有锁,一个在自旋,又有第三个来访时,轻量级锁升级为重量级锁。
  • 重量级锁:依赖于操作系统 Mutex Lock 所实现的锁我们称之为“重量级锁”,当锁为轻量级锁的时候,另一个线程虽然是自旋,但自旋不会一直持续下去,当自旋一定次数的时候,还没有获取到锁,就会进入阻塞,该锁膨胀为重量级锁。重量级锁会让其他申请的线程进入阻塞,性能降低。升级到重量级锁其实就是互斥锁了,Synchronize关键字内部实现原理就是锁升级的过程。

锁优化(锁粗化和锁消除)

  • 锁粗化:大部分情况下我们是要让锁的粒度最小化,锁的粗化则是要增大锁的粒度。将多个同步块的数量减少,并将单个同步块的作用范围扩大,本质上就是将多次上锁、解锁的请求合并为一次同步请求。

示例代码:循环内的操作需要加锁,我们应该把锁放到循环外面,否则每次进出循环,都进出一次临界区,效率是非常差的。


public class Test {

    private Object object = new Object();

    public void test() {
        for (int i = 0; i < 100; i++) {
            synchronized (object) {
            }
        }
    }
}
//修改后
public class Test {

    private Object object = new Object();

    public void test() {
        synchronized (object){
            for (int i = 0; i < 100; i++) {
                
            }
        }
    }
}
  • 锁消除:锁消除是指虚拟机编译器在运行时检测到了不可能被共享的对象,从而将这些锁进行消除。比如VectorStringBuffer这样的类,它们中的很多方法都是有锁的。当我们在一些不会有线程安全的情况下使用这些类的方法时,达到某些条件时,编译器会将锁消除来提高性能,

示例代码:如下StringBuffer却是一个局部变量,并且方法也并没有把StringBuffer返回,局部变量是在栈上的,栈是线程私有的,所以就算是多个线程访问也是线程安全的,那么此时StringBuffer中的同步操作就是没有意义的。

public class Test {
    public String test(String s1, String s2){
        StringBuffer stringBuffer = new StringBuffer();
        stringBuffer.append(s1);
        stringBuffer.append(s2);
        return stringBuffer.toString();
    }
}

分段锁

  • 分段锁:分段锁其实是一种锁的设计,并不是具体的一种锁。分段锁设计目的是将锁的粒度进一步细化,当操作不需要更新整个数组的时候,就仅针对数组中的一项进行加锁的操作。Java语言中CurrentHashMap底层就用了分段锁,使用Segmentput元素的时先通过hashcode来知道他要放在那一个分段中,然后对这个分段进行加锁,所以当多线程put的时候,只要不是放在一个分段中,就实现了真正的并行的插入。统计size的时候,可就是获取hashmap全局信息的时候,就需要获取所有的分段锁才能统计。

本章小结

需要并发的唯一理由是“等待太多”。这也可以包括用户界面的响应速度,但是由于 Java 用于构建用户界面时并不高效,因此 这仅仅意味着“你的程序运行速度还不够快”。

如果并发很容易,则没有理由拒绝并发。 正因为并发实际上很难,所以你应该仔细考虑是否值得为此付出努力,并考虑你能否以其他方式提升速度。

并发编程的主要缺点:

  1. 在线程等待共享资源时会降低速度。
  2. 线程管理产生额外 CPU 开销。
  3. 糟糕的设计决策带来无法弥补的复杂性。
  4. 诸如饥饿,竞速,死锁和活锁(多线程各自处理单个任务而整体却无法完成)之类的问题。
  5. 跨平台的不一致。 通过一些示例,我发现了某些计算机上很快出现的竞争状况,而在其他计算机上却没有。 如果你在后者上开发程序,则在分发程序时可能会感到非常惊讶。

另外,并发的应用是一门艺术。 并发性的主要困难之一是因为可能有多个任务共享一个资源(例如对象中的内存),并且你必须确保多个任务不会同时读取和更改该资源。 实际上你可以在单 CPU 机器上发现一些并发问题,但是在多线程实际上真的在并行运行的多 CPU 机器上,就会出现一些其他问题。

以下是并发编程的步骤:

  1. 不要使用它。想一些其他方法来使你写的程序变的更快。
  2. 如果你必须使用它,请使用CompletableFuture等高级工具。
  3. 不要在任务间共享变量,在任务之间必须传递的任何信息都应该使用 Java.util.concurrent 库中的并发数据结构。
  4. 如果必须在任务之间共享变量,请使用 java.util.concurrent.atomic 里面其中一种类型,或在任何直接或间接访问这些变量的方法上应用 synchronized。 当你不这样做时,很容易被愚弄,以为你已经把所有东西都包括在内。 说真的,尝试使用步骤 3。
  5. 如果步骤 4 产生的结果太慢,你可以尝试使用volatile 或其他技术来调整代码,但是如果你正在阅读本书并认为你已经准备好尝试这些方法,那么你就超出了你的深度。 返回步骤1。

扩展阅读

CompletableFuture的使用

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值