Java多线程——共享模型之管程

本文深入探讨Java多线程中的关键概念和技术,包括共享模型、synchronized解决方案、线程安全分析、Monitor概念、Wait/Notify机制、活跃性问题及ReentrantLock特性。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

一、共享带来的问题

1.1 临界区

  • 一个程序运行多个线程本身是没有问题的
  • 问题会出现在多个线程访问共享资源
    • 多个线程读共享资源不会发生问题
    • 多个线程对共享资源读写操作时发生指令交错,会出现问题
  • 一段代码块内若存在对共享资源的多线程读写操作,则称这段代码为临界区

代码示例:

static int counter = 0;
static void increment() {
    // 临界区
    counter++;
}
// 这里的counter++虽然只有一行代码,但不是源自操作,这实际上是多步操作,而且是可被中断的;
// counter可被拆违三步操作:取出counter值,执行counter+1,将结果赋值给counter

1.2 竞态条件

多个线程在临界区内执行,由于代码的执行序列不同而导致结果无法预测,称之为发生了竞态条件

二、synchronized解决方案

为了避免临界区的竞态条件发生,有多种手段可以达到目的:

  • 阻塞式的解决方案:synchronizedLock
  • 非阻塞式的解决方案:原子变量

本次使用阻塞式的解决方案:synchronized,来解决上述问题,俗称【对象锁】,它采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其他线程在想获取这个【对象锁】时就会阻塞住。这样就能保证拥有锁的线程可以安全的执行临界区内的代码,不用担心线程上下文切换问题。

ps:虽然Java中互斥和同步都可以采用synchronized关键字来完成,但他们还是有区别的:

  • 互斥是保证临界区的竞态条件发生,同一时刻只能由一个线程执行临界区代码
  • 同步是由于线程执行的先后、顺序不同,需要一个线程等待其他线程运行到某个点

2.1 synchronized

语法

synchronized(对象) {
	临界区
}

代码示例

static int counter = 0;
// 创建一个公共对象来作为锁对象
static final Object lock = new Object();

public static void main(String[] args) throws InterruptedException {
    // 线程t1
    Thread t1 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (lock) {
                counter++;
            }
        }
    }, "t1");
    // 线程t2
    Thread t2 = new Thread(() -> {
        for (int i = 0; i < 5000; i++) {
            synchronized (lock) {
                counter--;
            }
        }
    }, "t2");

    t1.start();
    t2.start();
    t1.join();
    t2.join();
    log.info("counter: {}", counter);
}

2.2 synchronized加在方法上

  1. 加在成员方法上
public class Test {
	//在方法上加上synchronized关键字
	public synchronized void test() {
	
	}
	//等价于
	public void test() {
		synchronized(this) {
		
		}
	}
}
  1. 加在静态方法上
public class Test {
	//在静态方法上加上synchronized关键字
	public synchronized static void test() {
	
	}
	//等价于
	public void test() {
		synchronized(Test.class) {
		
		}
	}
}

三、变量的线程安全分析

成员变量和静态变量是否线程安全?

  • 如果它们没有共享,则线程安全
  • 如果它们被共享了,根据它们的状态是否能够改变,又分为两种情况
    • 如果只有读操作,则线程安全
    • 如果有读写操作,则这段代码是临界区,需要考虑线程安全

局部变量是否线程安全?

  • 局部变量是线程安全的
  • 但局部变量引用的对象则未必
    • 如果该对象没有逃离方法的作用范围,它是线程安全的
    • 如果该对象逃离方法的作用范围,需要考虑线程安全问题

成员变量不是线程安全的,但有一些线程安全的类

  • String
  • Integer
  • StringBuffer
  • Random
  • Vector
  • Hashtable
  • java.util.concurrent包下的类

这里说它们是线程安全的是指,多个线程调用它们同一实例的某个方法时,是线程安全的。举个例子

Hashtable<String, String> table = new Hashtable<>();

new Thread(() -> {
    table.put("key", "value1");
}).start();
new Thread(() -> {
    table.put("key", "value2");
}).start();

四、Monitor概念

Monitor是操作系统来提供的,可翻译成监视器管程

首先来看一下Java的对象头
以32位虚拟机为例
普通对象
在这里插入图片描述
数组对象
在这里插入图片描述
其中Mark Word结构为
在这里插入图片描述

64位虚拟机的Mark Word
在这里插入图片描述
每个Java对象都可以关联一个Monitor对象,如果使用synchronized给对象上锁(重量级)之后,该对象头的mark Word中就被设置只想Monitor对象的指针

Monitor结构如下
在这里插入图片描述

  • 刚开始Monitor中的Owner为null
  • 当Thread-2执行 synchronized(obj) 就会将 Monitor 的所有者 Owner 置为 Thread-2, Monitor中只能有一个 Owner
  • 在Thread-2 上锁的过程中,如果Thread-3, Thread-4, Thread-5 也来执行 synchronized(obj),就会进入 EntryList BLOCKED
  • Thread-2 执行完同步代码块的内容,然后唤醒 EntryList 中等待的线程来竞争锁,竞争时是非公平的
  • 图中 WaitSet 中的 Thread-0, Thread-1 是之前获得过锁,但条件不满足进入 WAITING 状态的线程

注意

  • synchronized 必须是进入同一对象的 monitor才有上述的效果
  • 不加 synchronized 的对象不会关联监视器,不遵循以上规则

五、Wait/Notify

原理
在这里插入图片描述

  • Owner 线程发现条件不满足时,调用 wait 方法,即可进入 WaitSet 变为 WAITING 状态
  • BLOCKED 和 WAITING 的线程都处于阻塞状态,不占用 CPU 时间片
  • BLOCKED 线程会在 Owner 线程释放锁时唤醒
  • WAITING 线程会在 Owner线程调用 notify 或 notifyAll 时唤醒,但唤醒后并不意味着立刻获得锁,仍需进入 EntryList 重新竞争

常用API介绍

  • obj.wait():让进入 object 监视器的线程到 waitSet 等待
  • obj.notify():让 object 上正在waitSet 等待的线程中挑一个唤醒
  • obj.notifyAll():让 object 傻姑娘正在 waitSet 等待的线程全部唤醒

它们都是线程之间进行协作的手段,都属于 Object 对象的方法。所以必须获得此对象的锁,才能调用这几个方法。

static final Object lock = new Object();

public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        synchronized (lock) {
            log.info("执行...");
            try {
                lock.wait();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.info("其他代码...");
        }
    }).start();
    new Thread(() -> {
        synchronized (lock) {
            log.info("执行...");
            try {
                lock.wait();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.info("其他代码...");
        }
    }).start();

    Thread.sleep(2000);
    log.info("唤醒 lock 上其他线程");
    synchronized (lock) {
	  //lock.notify();    // 随机唤醒lock上一个线程
        lock.notifyAll();   // 唤醒lock上所有线程
    }
}

六、wait notify的正确使用

sleep(long n)wait(long n)的区别

  1. sleep 是 Thread 的方法,而 wait 是 Object 的方法
  2. sleep 不需要强制和 synchronized 配合使用,但 wait 需要和 synchronized 一起使用
  3. sleep 在睡眠的同时,不会释放对象锁,但 wait 在等待的时候会释放对象锁
  4. 它们的状态都是 TIMED_WAITING

使用 wait 和 notify 时,一般会进行条件判断,不过不使用 if ,使用 while 进行判断,这是为了防止虚假唤醒,当条件真正满足时,才继续执行后续的代码

6.1 同步模式之保护性暂停

6.1.1 定义

保护性暂停,即 Guarded Suspension,用在一个线程等待另一个线程的执行结果

要点:

  • 有一个结果需要从一个线程传递到另一个线程,让他们关联同一个 GuardedObject
  • 如果有结果不断从一个线程到另一个线程可以使用消息队列(生产者/消费者)
  • 在JDK中,join 的实现、Future 的实现,采用的就是此模式
  • 因为要等待另一方的结果,因此归类到同步模式

在这里插入图片描述

6.1.2 实现

public class GuardedObject {
    
    private Object response;
    private final Object lock = new Object();
    
    public Object get() {
        synchronized (lock) {
            // 条件不满足则等待
            while (response == null) {
                try {
                    lock.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            return response;
        }
    }
    
    public void complete(Object response) {
        synchronized (lock) {
            this.response = response;
            // 条件满足,通知等待线程
            lock.notifyAll();
        }
    }
}

应用
一个线程等待另一个线程的执行结果

publicstatic void main(String[] args) {
    GuardedObject guardedObject = new GuardedObject();
    new Thread(() -> {
        List<String> responses = new ArrayList<>();
        responses.add("test01");
        responses.add("test02");
        log.info("数据准备完毕");
        guardedObject.complete(responses);
    }).start();

    log.info("waiting...");
    Object response = guardedObject.get();
    log.info("get response: [{}] lines", ((List<String>)response).size());
}

6.1.3 带超时版的GuardedObject

如果要控制超时时间

public class GuardedObjectV2 {

    private Object response;
    private final Object lock = new Object();

    public Object get(long millis) {
        synchronized (lock) {
            // 1. 记录最初时间
            long begin = System.currentTimeMillis();
            // 2. 已经经历的时间
            long timePassed = 0;
            while (response == null) {
                long waitTime = millis - timePassed;
                log.info("waitTime: {}", waitTime);
                if (waitTime <= 0) {
                    log.info("break...");
                    break;
                }
                try {
                    lock.wait(waitTime);
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
                timePassed = System.currentTimeMillis() - begin;
                log.info("timePassed: {}, object is null {}", timePassed, response == null);
            }
            return response;
        }
    }

    public void complete(Object response) {
        synchronized (lock) {
            // 条件满足,通知等待线程
            this.response = response;
            log.info("notify...");
            lock.notifyAll();
        }
    }
}

join原理
join 内部实现就是这种保护性暂停方式,翻看 join 底层代码可知

public final synchronized void join(long millis) throws InterruptedException {
        long base = System.currentTimeMillis();
        long now = 0;

        if (millis < 0) {
            throw new IllegalArgumentException("timeout value is negative");
        }

        if (millis == 0) {
            while (isAlive()) {
                wait(0);
            }
        } else {
            while (isAlive()) {
                long delay = millis - now;
                if (delay <= 0) {
                    break;
                }
                wait(delay);
                now = System.currentTimeMillis() - base;
            }
        }
    }

6.1.4 多任务版GuardedObject

在这里插入图片描述
图中 Futures 就好比居民楼一层的信箱(每个信箱有房间编号),左侧的 t0,t2,t4 就好比等待邮件的居民,右侧的t1,t3,t5 就好比邮递员

如果需要在多个类之间使用GuarderObject 对象,作为参数传递不是很方便,因此设计一个用来解耦的中间类,这样不仅能够解耦【结果等待者】和【结果生产者】,还能够同时支持多个任务的管理

6.2 异步模式之生产者消费者

6.2.1 定义

要点:

  • 与前面的保护性暂停中的 GuardObject 不同,不需要产生结果和消费结果的线程一一对应
  • 消费队列可以用来平衡生产和消费的线程资源
  • 生产者仅负责产生结果数据,不关心数据该如何处理,而消费者专心处理结果数据
  • 消息队列是有容量限制的,满时不会再介入数据,空时不会再消耗数据
  • JDK中各种阻塞队列,采用的就是这种模式

在这里插入图片描述

6.2.2 实现

@Data
public class Message {
    private int id;
    private Object message;
}

@Data
@Slf4j
public class MessageQueue {
    private LinkedList<Message> queue;
    private int capacity;

    public MessageQueue(int capacity) {
        this.capacity = capacity;
        queue = new LinkedList<>();
    }

    public Message take() {
        synchronized (queue) {
            while (queue.isEmpty()) {
                log.info("没货了,wait");
                try {
                    queue.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
            Message message = queue.removeFirst();
            queue.notifyAll();
            return message;
        }
    }

    public void put(Message message) {
        synchronized (queue) {
            while (queue.size() == capacity) {
                log.info("库存已达上限,wait");
                try {
                    queue.wait();
                } catch (InterruptedException e) {
                    throw new RuntimeException(e);
                }
            }
        }
    }
}

七、Park & Unpark

7.1 基本使用

park 和 unpark 都是 LockSupport 类中的方法

  • park():暂停当前线程
  • unpark(暂停线程对象):恢复某个线程的运行

先 park 再 unpark

@Slf4j
public class Demo01 {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            log.info("start...");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.info("park...");
            LockSupport.park();
            log.info("resume...");
        }, "t1");
        t1.start();

        Thread.sleep(2000);
        log.info("unpark...");
        LockSupport.unpark(t1);
    }

    /**
     * output
     * 2022-12-11 16:23:11.618 INFO [t1] - start...
     * 2022-12-11 16:23:12.625 INFO [t1] - park...
     * 2022-12-11 16:23:13.624 INFO [main] - unpark...
     * 2022-12-11 16:23:13.628 INFO [t1] - resume...
     */
}

先 unpark 再 park

@Slf4j
public class Demo01 {

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            log.info("start...");
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            log.info("park...");
            LockSupport.park();
            log.info("resume...");
        }, "t1");
        t1.start();

        Thread.sleep(1000);
        log.info("unpark...");
        LockSupport.unpark(t1);
    }

    /**
     * output
     * 2022-12-11 16:24:55.900 INFO [t1] - start...
     * 2022-12-11 16:24:56.901 INFO [main] - unpark...
     * 2022-12-11 16:24:57.905 INFO [t1] - park...
     * 2022-12-11 16:24:57.907 INFO [t1] - resume...
     */
}

7.2 特点

与 Object 的 wait & notify 相比

  • wait, notify 和 notifyAll 必须配合 Object Monitor 一起使用,而 park,unpark 不必
  • park & unpark 是以线程为单位来【阻塞】和【唤醒】线程,而 notify 只能随机唤醒一个等待线程,notifyAll 是唤醒所有等待线程,就不那么【精确】
  • park & unpark 可以先 unpark,而 wait & notify 不能先 notify

7.3 原理

每个线程都有自己的一个 Parker对象,由三部分组成_counter_cond_mutex
在这里插入图片描述

  1. 当前线程调用 Unsafe.park() 方法
  2. 检查 _counter,本情况为0,这时,获得 _mutex 互斥锁
  3. 线程进入 _cond 条件变量阻塞
  4. 设置 _counter = 0

在这里插入图片描述

  1. 调用 Unsafe.unpark(Thread_0)方法,设置 _counter 为1
  2. 唤醒 _cond 条件变量中的 Thread_0
  3. Thread_0 恢复运行
  4. 设置 _counter 为0
    在这里插入图片描述
  5. 调用 Unsafe.unpark(Thread_0)方法,设置 _counter 为1
  6. 当前线程调用 Unsafe.park() 方法
  7. 检查 _counter,本情况为 1,这时线程无需阻塞,继续运行
  8. 设置 _counter 为 0

八、活跃性问题

常见的活跃性问题由死锁活锁饥饿

8.1 死锁

死锁、是指两个或两个以上线程(or进程)执行过程中,因争夺资源而造成的一种互相等待的现象,若无外力作用干预,它们都死等下去,也都无法向下推进

产生死锁的必要条件

  • 互斥条件:所谓互斥即线程再某一时间独占资源
  • 请求与保持条件:一个线程因请求资源而阻塞时,对已获得的资源持有不放
  • 不剥夺条件:线程已获得资源,在未使用结束前,不能强行剥夺
  • 循环等待条件:若干线程之间形成一种头尾相连的循环等待资源关系

经典案例:哲学家就餐问题

8.2 活锁

活锁的场景比较少见,也很难模拟,就是在线程在没有被阻塞情况下,但由于某些条件未能满足,导致一直重复尝试->失败,尝试->失败,如此下去

活锁和死锁的区别:处于活锁的实体是在不断的改变状态,处于死锁的实体变现为等待;活锁可能自行解开,死锁不能自行解开。

8.3 饥饿

一个或多个线程,因为种种原因无法获得所需要的资源,导致一直无法执行的状态

java中导致饥饿的原因

  • 高优先级线程吞噬所有低优先级线程的CPU时间
  • 线程被永久阻塞在等待进入同步块的状态,因其他线程总是能在它之前持续地对该同步块进行访问
  • 线程在等待一个本身也处于永久等待完成的对象(比如调用这个对象的wait方法),因为其他线程总是被持续地唤醒

九、ReentrantLock

相对于 synchronized,它具备如下特点:

  • 可中断
  • 可以设置超时时间
  • 可以设置为公平锁
  • 支持多个条件变量

与 synchronized 一样,都支持可重入
基本语法:

// 获取锁
reentrantLock.lock();
try {
	// 临界区
	method();
} finally {
	// 释放锁
	reentrantLock.unlock();
}

9.1 可重入

可重入是指同一线程如果首次获得了这把锁,那么因为它是这把锁的拥有者,因此有权利再次获取这把锁;如果是不可重入锁,那么第二次获得锁时,自己也会被锁住

@Slf4j
public class Demo02 {

    static ReentrantLock lock = new ReentrantLock();

    public static void main(String[] args) {
        method1();
    }

    public static void method1() {
        lock.lock();
        try {
            log.info("execute method1");
            method2();
        } finally {
            lock.unlock();
        }
    }

    public static void method2() {
        lock.lock();
        try {
            log.info("execute method2");
            method3();
        } finally {
            lock.unlock();
        }
    }

    public static void method3() {
        lock.lock();
        try {
            log.info("execute method3");
        } finally {
            lock.unlock();
        }
    }

    /**
     * output
     * 2022-12-11 17:10:51.442 INFO [main] - execute method1
     * 2022-12-11 17:10:51.443 INFO [main] - execute method2
     * 2022-12-11 17:10:51.443 INFO [main] - execute method3
     */
}

9.2 可打断

@Slf4j
public class Demo02 {

    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();

        Thread t1 = new Thread(() -> {
            log.info("启动...");
            try {
                lock.lockInterruptibly();
            } catch (InterruptedException e) {
                e.printStackTrace();
                try {
                    Thread.sleep(500);
                } catch (InterruptedException ex) {
                    throw new RuntimeException(ex);
                }
                log.info("等待锁的过程被打断");
                return;
            }
            try {
                log.info("获得了锁");
            } finally {
                lock.unlock();
            }
        }, "t1");

        lock.lock();
        log.info("获得了锁");
        t1.start();
        try {
            Thread.sleep(1000);
            t1.interrupt();
            log.info("执行打断");
        } finally {
            lock.unlock();
        }
    }

    /**
     * output
     * 2022-12-11 17:24:02.847 INFO [main] - 获得了锁
     * 2022-12-11 17:24:02.849 INFO [t1] - 启动...
     * 2022-12-11 17:24:03.853 INFO [main] - 执行打断
     * java.lang.InterruptedException
     * 	at java.util.concurrent.locks.AbstractQueuedSynchronizer.doAcquireInterruptibly(AbstractQueuedSynchronizer.java:898)
     * 	at java.util.concurrent.locks.AbstractQueuedSynchronizer.acquireInterruptibly(AbstractQueuedSynchronizer.java:1222)
     * 	at java.util.concurrent.locks.ReentrantLock.lockInterruptibly(ReentrantLock.java:335)
     * 	at com.liu.concurrency.chapter03.Demo02.lambda$main$0(Demo02.java:16)
     * 	at java.lang.Thread.run(Thread.java:748)
     * 2022-12-11 17:24:04.360 INFO [t1] - 等待锁的过程被打断
     */
}

9.3 锁超时

立刻失败

@Slf4j
public class Demo02 {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        Thread t1 = new Thread(() -> {
            log.info("启动...");
            if (!lock.tryLock()) {
                log.info("获取立刻失败,返回");
                return;
            }
            try {
                log.info("获得了锁");
            } finally {
                lock.unlock();
            }
        }, "t1");
        lock.lock();
        log.info("获得了锁");
        t1.start();
        try {
            Thread.sleep(2000);
        } finally {
            lock.unlock();
        }
    }

    /**
     * output
     * 2022-12-11 17:34:50.784 INFO [main] - 获得了锁
     * 2022-12-11 17:34:50.786 INFO [t1] - 启动...
     * 2022-12-11 17:34:50.787 INFO [t1] - 获取立刻失败,返回
     */
}

超时失败

@Slf4j
public class Demo02 {
    public static void main(String[] args) throws InterruptedException {
        ReentrantLock lock = new ReentrantLock();
        Thread t1 = new Thread(() -> {
            log.info("启动...");
            try {
                if (!lock.tryLock(1, TimeUnit.SECONDS)) {
                    log.info("获取等待 1s 后失败,返回");
                    return;
                }
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
            try {
                log.info("获得了锁");
            } finally {
                lock.unlock();
            }
        }, "t1");
        lock.lock();
        log.info("获得了锁");
        t1.start();
        try {
            Thread.sleep(2000);
        } finally {
            lock.unlock();
        }
    }

    /**
     * output
     * 2022-12-11 17:48:36.919 INFO [main] - 获得了锁
     * 2022-12-11 17:48:36.921 INFO [t1] - 启动...
     * 2022-12-11 17:48:37.928 INFO [t1] - 获取等待 1s 后失败,返回
     */
}

9.4 公平锁

    public ReentrantLock() {
        sync = new NonfairSync();
    }
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

默认的ReentrantLock是非公平锁,可以通过new TeentrantLock(true)来获得公平锁

9.5 条件变量

synchronized 中也有条件变量,就是 waitSet,当条件不满足时进入 waitSet 等待

ReentrantLock 的条件变量比 synchronized 强大之处在于,它是支持多个条件变量的,这就好比

  • synchronized 是那些不满足条件的线程都在一间休息室等消息
  • 而 TeentrantLock支持多间休息室

使用要点

  • await 前需要获得锁
  • await 执行后,会释放锁,进入conditionObject 等待
  • await 的线程被唤醒(或打断、超时),需重新竞争lock锁
  • 竞争 lock 锁成功后,从 await 后继续执行

示例:一个可共享的缓冲区实现

public class BoundedBuffer {
    final Lock lock = new ReentrantLock();
    final Condition notFull = lock.newCondition();//写线程条件 
    final Condition notEmpty = lock.newCondition();//读线程条件 

    final Object[] items = new Object[15];
    int putptr/*写索引*/, takeptr/*读索引*/, count/*队列中存在的数据个数*/;

    public void put(Object x) throws InterruptedException {
        System .out.println("put wait lock");
        lock.lock();
        System.out.println("put get lock");
        try {
            while (count == items.length) {
                System.out.println("buffer full, please wait");
                notFull.await();
            }

            items[putptr] = x;
            if (++putptr == items.length) {
                putptr = 0;
            }
            ++count;
            System.out.println("--------------------"+x);
            notEmpty.signal();
        } finally {
            lock.unlock();
        }
    }

    public Object take() throws InterruptedException {
        System.out.println("take wait lock");
        lock.lock();
        System.out.println("take get lock");
        try {
            while (count == 0) {
                System.out.println("no elements, please wait");
                notEmpty.await();
            }
            System.out.println("--------------------被唤醒");
            Object x = items[takeptr];
            if (++takeptr == items.length) {
                takeptr = 0;
            }
            --count;
            notFull.signal();
            return x;
        } finally {
            lock.unlock();
        }
    }
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值