Java并发编程实战之互斥锁

本文详细探讨了Java并发编程中互斥锁的概念,如何使用synchronized关键字解决原子性问题,以及其对可见性和有序性的影响。同时,文章讲解了锁模型、死锁的预防策略,以及如何使用synchronized实现等待-通知机制。最后,讨论了安全性、活跃性与性能问题,并提供了优化并发程序的建议。
摘要由CSDN通过智能技术生成

Java并发编程实战之互斥锁

之前在《Java并发编程实战基础概要》中提到了原子性这一个概念,一个或者多个操作在 CPU 执行的过程中不被中断的特性,称为“原子性”。 那么原子性问题到底改如何解决呢?

如何解决原子性问题?

“原子性”的本质是什么?其实不是不可分割,不可分割只是外在表现,其本质是多个资源间有一致性的要求,操作的中间状态对外不可见。所以本质来说,解决原子性问题,是要保证中间状态对外不可见。(这一点需要细细品一品)

原子性问题的源头是线程切换,多个线程同时操作同一个变量。这样就会出现线程冲突的问题。所以需要一种机制保持在多核CPU下,同一个时刻只有一个线程更改某个共享变量。(其实没有共享变量,也不会存在并发问题),所以说如果我们能够保证对共享变量的修改是互斥的,那么就能保证原子性了。

锁模型

一谈到互斥,我们很自然就会想到了锁。首先我们把一段需要互斥执行的代码称为临界区。线程在进入临界区之前,首先尝试加锁 lock(),如果成功,则进入临界区,此时我们称这个线程持有锁;否则呢就等待,直到持有锁的线程解锁;持有锁的线程执行完临界区的代码后,执行解锁 unlock()

这个过程非常像办公室里高峰期抢占坑位,每个人都是进坑锁门(加锁),出坑开门(解锁),如厕这个事就是临界区。

上面的例子虽然挺形象的,但是容易忽略锁的两个很重要的点,分别是我们锁的是什么?和我们想要保护的又是什么?

  • 第一个问题,锁的到底是什么?我们单纯锁的是门吗?这么理解也没有错,但是想一想,实际上我们想要锁的是对这个厕所的使用,因为你不可能锁上这个厕所的门,去上另一个厕所,这样没任何意义。所以锁跟你想保护的东西是有一个对应关系的。所以对应到编程世界中,锁的其实是对共享变量的访问。
  • 第二个问题,我们想要保护的是什么?我们保护的其实就是我们即将要使用的这个厕所。对应到编程世界中,保护的就是共享变量。

所以对应编程世界中,锁和资源是有一个对应关系的,所以锁的模型如下:

在这里插入图片描述

Java synchronized 关键字

锁是一种通用的技术方案,Java 语言提供的 synchronized 关键字,就是锁的一种实现。synchronized 关键字可以用来修饰方法,也可以用来修饰代码块


class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object()void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  

我们可以通过synchronized关键字对我们的临界区进行加锁和解锁。但是我们从代码中并没有看到这个加锁和解锁的动作,这是因为这些操作是由Java编译器为我们加上的。

我们可以利用javap命令来查看生成的字节码文件,就可以看出来Java编译器会为我们synchronized 修饰的方法或代码块前后自动加上加锁 lock() 和解锁 unlock()

下面通过javap命令查看下面代码生成的字节码文件

public void method() {
     synchronized (this) {
         System.out.println("start");
     }
 }

字节码文件如下:

在这里插入图片描述

图中的monitorenter对应的就是加锁,而monitorexit对应的就是解锁

至于为什么会有两个monitorexit指令呢?

是因为对于synchronized关键字而言,javac在编译时,会生成对应的monitorentermonitorexit指令分别对应synchronized同步块的进入和退出,有两个monitorexit指令的原因是:为了保证抛异常的情况下也能释放锁,所以javac为同步代码块添加了一个隐式的try-finally,在finally中会调用monitorexit命令释放锁。

参考: 《Java锁synchronized关键字学习系列之重量级锁》

synchronized锁的是代码块还是锁的是对象?从上面我们可以总结出synchronized锁的其实是对象

synchronized 里的加锁 lock() 和解锁 unlock() 锁定的对象是什么?

下面的代码我们看到只有 synchronized修饰代码块的时候,锁定了一个obj 对象,那 synchronized修饰方法的时候锁定的是什么呢?


class X {
  // 修饰非静态方法
  synchronized void foo() {
    // 临界区
  }
  // 修饰静态方法
  synchronized static void bar() {
    // 临界区
  }
  // 修饰代码块
  Object obj = new Object()void baz() {
    synchronized(obj) {
      // 临界区
    }
  }
}  
  • 当修饰静态方法的时候,锁定的是当前类的 Class 对象,在上面的例子中就是 Class X
class X {
  // 修饰静态方法
  synchronized(X.class) static void bar() {
    // 临界区
  }
}
  • 当修饰非静态方法的时候,锁定的是当前实例对象 this
class X {
  // 修饰非静态方法
  synchronized(this) void foo() {
    // 临界区
  }
}

到这里我们引申出另一个问题,当我们锁住了对象的时候,对象身上发生了什么变化,jvm如何知道这个对象被“锁“住了,关于这个题外话这里不多赘述,可以参考:《Java锁synchronized关键字学习系列之CAS和对象头》

Java synchronized 关键字 只能解决原子性问题?

并发会产生三大问题

  1. 原子性问题
  2. 可见性问题
  3. 有序性问题

前面我们一直在说,锁可以解决原子性问题,Java synchronized 关键字只能解决原子性问题吗?

答案肯定是否定的,前面在《Java并发编程实战基础概要》 说到了Java内存模型规范了Java虚拟机(JVM)如何提供按需禁用缓存和编译优化的方法。这些方法包括:volatile、synchronized和final关键字,以及Java内存模型中的Happens-Before规则

在这里插入图片描述

所以synchronized关键字还可以解决可见性问题(可以参考Happens-Before的锁定规则:对一个锁的解锁操作 Happens-Before于后续对这个锁的加锁操作)。但是以synchronized关键字不能完全解决有序性问题,因为synchronized关键字不能避免指令重排,所以我们在之前《Java并发编程实战基础概要》双重检验的单例模式中,必须加volatile来避免因为发生指令重排,返回错误实例。

如何正确使用Java synchronized 关键字?

正确使用Java synchronized 关键字主要是关注在synchronized锁定的对象跟受保护资源的关系。如何理解呢?举一个例子:

class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

从上面代码我们可以看出来synchronized 关键字锁定的是两个不同的对象,在之前我们讲过,synchronized 关键字修饰非静态方法的时候,锁定的是当前实例对象 this。而当修饰静态方法的时候,锁定的是当前类的 Class 对象。所以我们现在相当于用两个锁保护一个资源(一个共享变量value)。

在这里插入图片描述

从上图可以看出来,由于get() 方法和addOne()方法是两把不同的锁,说明执行addOne()方法的过程中可以执行get() 方法,并发性不能得到保证,所以这两临界区并不是互斥的,临界区 addOne() 对 value 的修改对临界区 get() 也没有可见性保证,这就导致并发问题了。

再举一个例子,下面这个例子是否正确使用synchronized 关键字

class SafeCalc {
  long value = 0L;
  long get() {
    synchronized (new Object()) {
      return value;
    }
  }
  void addOne() {
    synchronized (new Object()) {
      value += 1;
    }
  }
}

答案很明显是错误的使用,synchronized 关键字锁定的new object每次在内存中都是新对象,所以每次锁的都不是同一个对象,怎么做到互斥呢?

所以要真正使用好互斥锁,必须深入分析锁定的对象和受保护资源的关系。

锁和受保护资源的合理关联关系

直接给出结论:受保护资源和锁之间合理的关联关系应该是 N:1 的关系,也就是说可以用一把锁来保护多个资源,但是不能用多把锁来保护一个资源。

我们来举一个用一把锁来保护多个资源的例子。

class Account {
  // 账户余额  
  private Integer balance;
  // 账户密码
  private String password;

  // 取款
  synchronized void withdraw(Integer amt) {
    if (this.balance > amt){
        this.balance -= amt;
      }
  } 
  // 查看余额
  synchronized Integer getBalance() {
     return balance;
  }

  // 更改密码
  synchronized void updatePassword(String pw){
    this.password = pw;
  } 
  // 查看密码
  synchronized String getPassword() {
     return password;
  }
}

从上面代码可以看出来,我们是使用当前实例this来管理Account类中所有的资源。所以会导致取款、查看余额、修改密码、查看密码这四个操作都是串行的,所以不会有并发问题。但是却会产生另一个问题,就是性能太差了。

我们可以稍微修改一下,使用两把锁,让取款和修改密码是可以并行的,因为这两个行为互不干扰。

class Account {
  // 锁:保护账户余额
  private final Object balLock
    = new Object();
  // 账户余额  
  private Integer balance;
  // 锁:保护账户密码
  private final Object pwLock
    = new Object();
  // 账户密码
  private String password;

  // 取款
  void withdraw(Integer amt) {
    synchronized(balLock) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 查看余额
  Integer getBalance() {
    synchronized(balLock) {
      return balance;
    }
  }

  // 更改密码
  void updatePassword(String pw){
    synchronized(pwLock) {
      this.password = pw;
    }
  } 
  // 查看密码
  String getPassword() {
    synchronized(pwLock) {
      return password;
    }
  }
}

用不同的锁对受保护资源进行精细化管理,能够提升性能,这种锁还有个名字,叫细粒度锁。所以我们上锁的时候需要考虑锁的粒度。

所以我们在上锁的时候,应该分析多个资源的关系。如果资源之间没有关系,每个资源一把锁就可以了。如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。

死锁

前面说到使用细粒度锁可以提高并行度,是性能优化的一个重要手段。但是使用细粒度锁是有代价的,这个代价就是可能会导致死锁。

死锁的一个比较专业的定义是:一组互相竞争资源的线程因互相等待,导致“永久”阻塞的现象。

下面举一个死锁的例子:

public class T {
    private Object o1 = new Object();
    private Object o2 = new Object();

    public void m1() {
        synchronized (o1) {
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            synchronized (o2) {
                System.out.println("如果出现这句话表示没有死锁");
            }
        }

    }

    public void m2() {
        synchronized(o2) {

            synchronized (o1) {
                System.out.println("如果出现这句话表示没有死锁");
            }

        }

    }
    public static void main(String[] args) {
        T t=new T();
        new Thread(t::m1).start();
        new Thread(t::m2).start();
    }
}

上面这个例子死锁是怎么发生的呢?假设当线程1持有锁对象o1,然后当线程2持有锁对象o2的时候;然后线程1需要对对象o2加锁,但是因为线程2已经对对象o2加锁了,所以线程1需要等待线程2解除锁占用。然后线程2同样需要对对象o1加锁,但是因为线程1已经对对象o1加锁了,所以线程2同样要等待线程1解除锁占用。所以现在就出现了线程1和线程2互相在等待对方解除锁占用,于是就出现了死锁。

预防死锁

那我们如何去预防死锁呢?

那如何避免死锁呢?要避免死锁就需要分析死锁发生的条件,只有以下这四个条件都发生时才会出现死锁:

  • 互斥:一个资源每次只能被一个进程(或者线程)使用。进程(或者线程)对所分配到的资源不允许其他进程(或者线程)进行访问,若其他进程(或者线程)访问该资源,只能等待,直至占有该资源的进程(或者线程)使用完成后释放该资源
  • 占有且等待:进程(或者线程)获得一定的资源之后,又对其他资源发出请求,但是该资源可能被其他进程(或者线程)占有,此时请求阻塞,但又对自己获得的资源保持不放
  • 不可抢占:是指进程(或者线程)已获得的资源,在未完成使用之前,不可被剥夺,只能在使用完后自己释放
  • 循环等待:线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待

所以我们想要避免死锁,其实只要破坏掉上面其中一个条件即可。但是第一个条件互斥是没办法破坏的,因为我们用锁的初衷就是为了互斥,所以我们需要从其他三个条件下手。

破坏占有且等待条件

要破坏这个条件,可以一次性申请所有资源。上面的例子一次性申请所有的资源,就相当于一次性加锁了o1和o2对象,解锁的时候也是一次性解锁了o1和o2对象,所以上面的例子可以改成下面这种方式

public class T {
    private Object o1 = new Object();

    public void m1() {
        synchronized (o1) {
            try {
                Thread.sleep(10000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }

            System.out.println("如果出现这句话表示没有死锁");
        }

    }

    public void m2() {
        synchronized (o1) {
                System.out.println("如果出现这句话表示没有死锁");
            }
    }
    public static void main(String[] args) {
        T t=new T();
        new Thread(t::m1).start();
        new Thread(t::m2).start();
    }
}

上面这种方式直接使用了一个锁,这种肯定是不会有死锁的。

或者我们还是锁定两个不同的对象,我们还可以这么改造

class M {

  private List<Object> list = new ArrayList<>();

  public synchronized boolean lock(Object o1, Object o2) {
    if (list.contains(o1) || list.contains(o2)) {
      return false;
    } else {
      list.add(o1);
      list.add(o2);
    }
    return true;
  }

  public synchronized void unlock(Object o1, Object o2) {
    list.remove(o1);
    list.remove(o2);
  }
}

class T {

  private Object o1 = new Object();
  private Object o2 = new Object();
  private M m = new M();

  public void m1() {
    while (!m.lock(o1, o2)) {

    }
    try {
      synchronized (o1) {
        try {
          Thread.sleep(10000);
        } catch (InterruptedException e) {
          e.printStackTrace();
        }

        synchronized (o2) {
          System.out.println("如果出现这句话表示没有死锁");
        }
      }
    } finally {
      m.unlock(o1, o2);
    }

  }

  public void m2() {
    while (!m.lock(o1, o2)) {

    }
    try {
      synchronized (o2) {
        synchronized (o1) {
          System.out.println("如果出现这句话表示没有死锁");
        }

      }
    } finally {
      m.unlock(o1, o2);
    }

  }

  public static void main(String[] args) {
    T t = new T();
    new Thread(t::m1).start();
    new Thread(t::m2).start();
  }
}

从上面代码可以看出来,我们抽取了一个类M来同时申请多个资源,从而破坏了占有且等待条件

破坏不可抢占条件

破坏不可抢占条件看上去很简单,核心是要能够主动释放它占有的资源,这一点 synchronized 是做不到的。Java 在语言层次确实没有解决这个问题,但是java.util.concurrent 这个包下面提供的 Lock类中的tryLock(long, TimeUnit) 方法,可以帮我们在一段时间尝试获取锁,所以可以轻松解决这个问题的

破坏循环等待条件

破坏这个条件,需要对资源进行排序,然后按序申请资源,这样就不会出现两个线程交错加锁的情况。上面的情况就是因为我们申请资源其实不是顺序的,也就是加锁不是顺序的,T1加锁的是o1然后o2,T2加锁的是o2然后o1。 如果T1和T2都是加锁o1然后o2,其实就不会有这种问题。

class T {
  private Object o1 = new Object();
  private Object o2 = new Object();

  public void m1() {
    synchronized (o1) {
      try {
        Thread.sleep(10000);
      } catch (InterruptedException e) {
        e.printStackTrace();
      }

      synchronized (o2) {
        System.out.println("如果出现这句话表示没有死锁");
      }
    }

  }

  public void m2() {
    synchronized(o1) {

      synchronized (o2) {
        System.out.println("如果出现这句话表示没有死锁");
      }

    }

  }
  public static void main(String[] args) {
    T t=new T();
    new Thread(t::m1).start();
    new Thread(t::m2).start();
  }
}
总结

但实际上开发过程中的案例肯定不会像我们举例的这么简单,具体问题具体分析,但是我们还是需要从这三个条件出发,去破坏掉我们这三个条件,才能够避免死锁的问题。

用 synchronized 实现等待 - 通知机制

前面我们在讲死锁的破坏占用且等待条件的时候,使用了一个死循环的方式来循环等待

while (!m.lock(o1, o2)) {

}

这种方案,在并发冲突大的场景(也就是可能很久都获取不到锁)不适用,因为这种场景下可能要循环上万次才能获取到锁,太消耗 CPU 了。

那有没有更好的方案呢?那就是使用"等待 - 通知机制"。怎么理解"等待 - 通知机制"呢?你可以类比于医院排队叫号。如果没有排队叫号系统,每个人都需要去问医生是不是轮到我了。而有了排队叫号,病人只需要等着医生把你叫过来,病人和医生是不是都省心省力了。

而在编程世界中,一个完整的“等待 - 通知机制”是这样的:线程首先获取互斥锁,当线程要求的条件不满足时,释放互斥锁,进入等待状态;当要求的条件满足时,通知等待的线程,重新获取互斥锁。

那在Java的世界中如何实现的“等待 - 通知机制”? Java 语言内置的 synchronized 配合 wait()notify()notifyAll() 这三个方法就能轻松实现。

我们看方法名称就可以知道,wait()顾名思义就是让线程等待,而、notify()notifyAll()就是唤醒线程。

那么wait()的实现机制是怎样的?

在这里插入图片描述

在并发程序中,当一个线程进入临界区后,由于某些条件不满足,需要进入等待状态,Java 对象的 wait() 方法就能够满足这种需求。如上图所示,当调用 wait() 方法后,当前线程就会被阻塞,并且进入到右边的等待队列中,这个等待队列也是互斥锁的等待队列。(这个等待队列和互斥锁是一对一的关系,每个互斥锁都有自己独立的等待队列) 线程在进入等待队列的同时,会释放持有的互斥锁,线程释放锁后,其他线程就有机会获得锁,并进入临界区了。

那线程要求的条件满足时,该怎么通知这个等待的线程呢?很简单,就是 Java 对象的 notify()notifyAll() 方法。当条件满足时调用 notify(),会通知等待队列(互斥锁的等待队列)中的线程,告诉它条件曾经满足过(因为notify() 只能保证在通知时间点,条件是满足的)。

在这里插入图片描述

wait()notify()notifyAll() 方法操作的等待队列是互斥锁的等待队列,所以如果 synchronized 锁定的是 this,那么对应的一定是 this.wait()this.notify()this.notifyAll();所以一定是要用锁定的对象去调用这三个方法。

这三个方法能够被调用的前提是已经获取了相应的互斥锁,所以我们会发现 wait()notify()notifyAll() 都是在 synchronized{}内部被调用的。如果在 synchronized{}外部调用,或者锁定的 this,而用 target.wait() 调用的话,JVM 会抛出一个运行时异常:java.lang.IllegalMonitorStateException

尽量使用 notifyAll()

notify() 是会随机地通知等待队列中的一个线程,而 notifyAll() 会通知等待队列中的所有线程。所以实际上使用 notify() 是可能存在风险的,它的风险在于可能导致某些线程永远不会被通知到。

可以参考《Java多线程高并发编程代码笔记(二)》中使用wait和notifyAll方法来实现的例子。

wait() 方法和 sleep() 方法

wait() 方法和 sleep() 方法都能让当前线程挂起一段时间,那它们的区别是什么?

主要的区别在于wait会释放所有锁而sleep不会释放锁资源,而且wait只能在同步方法和同步块中使用,而sleep任何地方都可以。

安全性、活跃性以及性能问题

编写并发程序的初衷是为了提升性能,但在追求性能的同时由于多线程操作共享资源而出现了安全性问题(并发出现原子性问题、可见性问题和有序性问题),当然并不是只要是多线程都会有安全性问题,而是有多线程操作共享资源,也就是共享会发生变化的数据,我们也叫数据竞争,在这种情况下才会有安全性问题。

为了解决安全性问题,我们开始用到了锁技术,一旦用到了锁技术就会出现了死锁,还有两种情况,分别是“活锁”和“饥饿”活跃性问题。

活锁:有时线程虽然没有发生阻塞,但仍然会存在执行不下去的情况,这就是所谓的“活锁。
可以类比现实世界里的例子,路人甲从左手边出门,路人乙从右手边进门,两人为了不相撞,互相谦让,路人甲让路走右手边,路人乙也让路走左手边,结果是两人又相撞了。
解决“活锁”的方案很简单,谦让时,尝试等待一个随机的时间就可以了。例如上面的那个例子,路人甲走左手边发现前面有人,并不是立刻换到右手边,而是等待一个随机的时间后,再换到右手边;同样,路人乙也不是立刻切换路线,也是等待一个随机的时间再切换。由于路人甲和路人乙等待的时间是随机的,所以同时相撞后再次相撞的概率就很低了。“等待一个随机时间”的方案虽然很简单,却非常有效,Raft 这样知名的分布式一致性算法中也用到了它。

参考来源:极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题

饥饿: 指的是线程因无法访问所需资源而无法执行下去的情况。
如果线程优先级“不均”,在 CPU 繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题。
解决“饥饿”问题的方案很简单,有三种方案:一是保证资源充足,二是公平地分配资源,三就是避免持有锁的线程长时间执行。这三个方案中,方案一和方案三的适用场景比较有限,因为很多场景下,资源的稀缺性是没办法解决的,持有锁的线程执行的时间也很难缩短。倒是方案二的适用场景相对来说更多一些。
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。
那如何公平地分配资源呢?在并发编程里,主要是使用公平锁。所谓公平锁,是一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获得资源。

参考来源:极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题

但是如果我们不恰当的使用锁,会导致了串行百分比的增加,由此又产生了性能问题。

那我们如何解决性能问题呢?

  1. 既然使用锁会带来性能问题,那最好的方案自然就是使用无锁的算法和数据结构了。在这方面有很多相关的技术,例如线程本地存储 (Thread Local Storage, TLS)、写入时复制 (Copy-on-write)、乐观锁等;Java 并发包里面的原子类也是一种无锁的数据结构;Disruptor 则是一个无锁的内存队列,性能都非常好
  2. 减少锁持有的时间。互斥锁本质上是将并行的程序串行化,所以要增加并行度,一定要减少持有锁的时间。这个方案具体的实现技术也有很多,例如使用细粒度的锁,一个典型的例子就是 Java 并发包里的 ConcurrentHashMap,它使用了所谓分段锁的技术。还可以使用读写锁,也就是读是无锁的,只有写的时候才会互斥。

参考来源:极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题

性能方面的度量指标有很多,有三个指标非常重要,就是:吞吐量、延迟和并发量。

  • 吞吐量:指的是单位时间内能处理的请求数量。吞吐量越高,说明性能越好。
  • 延迟:指的是从发出请求到收到响应的时间。延迟越小,说明性能越好。
  • 并发量:指的是能同时处理的请求数量,一般来说随着并发量的增加、延迟也会增加。所以延迟这个指标,一般都会是基于并发量来说的。例如并发量是 1000 的时候,延迟是 50 毫秒。

参考来源:极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题

参考

《Java并发编程实战基础概要》

Java多线程高并发编程代码笔记(一)

极客时间 《并发编程实战》—— 互斥锁(上):解决原子性问题

极客时间 《并发编程实战》—— 互斥锁(下):如何用一把锁保护多个资源?

极客时间 《并发编程实战》—— 互斥锁(下):如何用一把锁保护多个资源?

极客时间 《并发编程实战》—— 一不小心就死锁了,怎么办?

极客时间 《并发编程实战》—— 用“等待-通知”机制优化循环等待

极客时间 《并发编程实战》—— 安全性、活跃性以及性能问题

  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值