并发系列——基础篇(二)

第二章:并发编程的其他基础知识
 

1、什么是多线程并发编程用处?

多核 CPU 时代的到来打破了单核 CPU 对多线程效能的限制。 多个 CPU 意味着每个 线程可以使用自己的 CPU 运行,这减少了线程上下文切换的开销,但随着对应用系统性 能和吞吐量要求的提高,出现了处理海量数据和请求的要求,这些都对高并发编程有着迫 切的需求。

2、Java 中的线程安全问题

    线程安全问题是指当多个线程同时读写一个共享资源并且没有任何同步措施时,导致 出现脏数据或者其他不可预见的结果的问题。

    这就需要在线程访问共享变量时进行适当的同步,在 Java 中最常见的是使用关键宇 synchronized 进行同步。

3、Java 中共享变量的内存可见性问题

1、先看JMM模型:

Java 内存模型规定,将所有的变量都存放在主内存中,当线程使用变量时,会把主内存里面的变量复制到自己的工作空间或者叫作工作内存(即图中的私有内存),线程读写变量时操作的是自己工作内存中的变量。Java 内存模型是一个抽象的概念,那么在实际实现中线程的工作内存是什么呢?

    图中所示是一个双核 CPU 系统架构,每个核有自己的控制器和运算器,其中控制器 包含一组寄存器和操作控制器,运算器执行算术逻辅运算。每个核都有自己的一级缓存, 在有些架构里面还有一个所有 CPU 都共享的二级缓存。 那么 Java 内存模型里面的工作内存,就对应这里的 Ll 或者 L2 缓存或者 CPU 的寄存器。(线程的私有空间即对应实际寄存器/一二级缓存)

     当一个线程操作共享变量时, 它首先从主内存复制共享变量到 自己的工作内存, 然后 对工作内存里的变量进行处理, 处理完后将变量值更新到主内存。下面则举一例子对多线程下共享变量的内存可见性进行分析:

 假设线程 A 和线程 B 使用不同 CPU 执行,并且当前两级 Cache 都为空, 那么这时候由于 Cache 的存在,将会导致内存不可见问题。

  • 线程 A 首先获取共享变量 X 的值,由于两级 Cache 都没有命中 ,所以加载主 内存 中 X 的值,假如为 0。然后把 X=0的值缓存到两级缓存,线程 A 修改 X 的值为 1, 然后将其写入两级 Cache, 并且刷新到主内存。 线程 A 操作完毕后,线程 A 所在的 CPU 的两级 Cache 内和主内存里面的 X 的值都是 l 。
  • 线程 B 获取 X 的值,首先一级缓存没有命中,然后看二级缓存,二级缓存命中了 (有些架构的多核cpu的二级缓存是多cpu共享的,所以此时另一核的cpu可以读取到之前线程A缓存的X值), 所以返回 X= 1 ; 到这里一切都是正常的, 因为这时候主 内存中也是 X=l 。然后线 程 B 修改 X 的值为 2, 并将其存放到线程 2 所在的一级 Cache 和共享二级 Cache 中, 最后更新主内存中 X 的值为 2 : 到这里一切都是好的。
  • 线程 A 这次又需要修改 X 的值, 获取时一级缓存命中, 并且 X=l ,到这里问题就 出 现了,明明线程 B 已经把 X 的值修改为了 2,为何线程 A 获取的还是 l 呢? 这 就是共享变量的内存不可见问题, 也就是线程 B 写入的值对线程 A 不可见。
  •  

总结:多核cpu中存在一级缓存和各核cpu共享的二级缓存,假设每个线程对应一个cpu,此时如果线程1去取到主内存的共享变量并改变其值后存进对应cpu的一二级缓存,而当线程2同样去改变该共享变量时,改变后的值只更新到自己对应cpu的一级缓存和共享的二级缓存,此时线程1对应的cpu的一级缓存没有对该共享变量进行更新,所以就存在了所谓的内存可见1性问题。下面是对该过程画图助于理解该过程:(注意这里的工作内存实际还包括寄存器等)

                  

           

(X是一二级缓冲都存在?还是缓存/寄存器一份,而主内存一份?)(真正的cpu一般是三级缓存的,一般是最后一个缓存是共享缓存,而其他是独占缓存)

简单点讲就是每个线程都有自己的工作内存,线程A将主内存的X同步到自己的工作内存,此时线程B修改了主内存的X的值,而线程A的工作内存没有去更新主内存的X值,则导致线程A每次使用的X值都是旧值不是主内存里的值

为了解决该内存可见性问题,java用了volatile关键字(指定直接去主内存里取)/加锁(每次清除工作内存的缓存值再去主内存找新值)来解决该问题。后面会讲到。

4、Java 中的 synchronized 关键字

    synchronized 块是 Java 提供的一种原子性内置锁, Java 中的每个对象都可以把它当作 一个同步锁来使用 , 这些 Java 内置的使用者看不到的锁被称为内部锁,也叫作监视器锁。线程的执行代码在进入 synchronized 代码块前会自动获取内部锁,这时候其他线程访问该 同步代码块时会被阻塞挂起。拿到内部锁的线程会在正常退出同步代码块或者抛出异常后 或者在同步块内调用了该内置锁资源的 wait 系列方法时释放该内置锁。内置锁是排它锁,也就是当一个线程获取这个锁后,其他线程必须等待该线程释放锁后才能获取该锁。
    另外,由于 Java 中的线程是与操作系统的原生线程一一对应的,所以当阻塞一个线程时,需要从用户态切换到内核态执行阻塞操作,这是很耗时的操作,而 synchronized 的 使用就会导致上下文切换。(即上下文切换耗时)

synchronized关键字可以解决内存可见性的问题:
    前面介绍了共享变量内存可见性问题主要是由于线程的工作内存导致的,下面我们来 讲解 synchronized 的一个内存语义,这个内存语义就可以解决共享变量内存可见性问题。 进入 synchronized 块的内存语义是把在 synchronized 块内使用到的变量从线程的工作内存 中清除,这样在 synchronized 块内使用到该变量时就不会从线程的工作内存中获取,而是 直接从主内存中获取。 退出 synchronized 块的内存语义是把在 synchronized 块内对共享变 量的修改刷新到主内存。
    其实这也是加锁和释放锁的语义,当获取锁后会清空锁块内本地内存中将会被用到的 共享变量,在使用这些共享变量时从主内存进行加载,在释放锁时将本地内存中修改的共 享变量刷新到主内存。
    除了可以解决共享变量 内存可见性问题外, synchronized 经常被用来实现原子性操作。另外请注意, synchronized 关键字会引起线程上下文切换并带来线程调度开销。所以可以用volatile解决可见性问题(更加高效率,但volatile不保证原子性,所以要搭配CAS进行才能保证线程安全)


这里只对该关键字进行简单的介绍,后面会写一篇关于synchronized关键字的底层原理分析。

5、Java 中的 volatile 关键字

    上面介绍了使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因 为它会带来线程上下文的切换开销。 对于解决内存可见性问题, Java 还提供了一种弱形式 的同步,也就是使用 volatile 关键字。 该关键字可以确保对一个变量的更新对其他线程马 上可见。 当一个变量被声明为 volatile 时,线程在写入变量时不会把值缓存在寄存器或者 其他地方,而是会把值刷新回主内存。 当其他线程读取该共享变量时-,会从主内存重新获 取最新值,而不是使用当前线程的工作内存中的值。 volatile 的内存语义和 synchronized 有 相似之处,具体来说就是,当线程写入了 volatile 变量值时就等价于线程退出 synchronized 同步块(把写入工作内存的变量值同步到主内存),读取 volatile 变量值时就相当于进入同 步块 (先清空本地内存变量值,再从主内存获取最新值)。

总结:volatile是保证共享变量每次获取都去主内存获取,每次更新jvm会发送lock指令将缓存行立即更新到主内存中(会通过缓存,只是让其立即刷新到主内存中)。而 synchronized是被修饰的代码块线程的工作内存的缓存会被清除,从而每次获取共享变量都去主内存里面获取,在退出代码块后会把修改刷新到主内存中。两者都保证了内存可见性,但synchronized上下文切换更加耗费开销但可以保证线程安全。而volatile只能保证内存可见性,而不能保证原子性(线程不安全)

6、Java 中的原子性操作

    所谓原子性操作,是指执行一系列操作时,这些操作要么全部执行, 要么全部不执行, 不存在只执行其中一部分的情况。 在设计计数器时一般都先读取当前值,然后+l , 再更新。 这个过程是读 改 写的过程,如果不能保证这个过程是原子性的,那么就会出现线程安 全问题。 如下代码是线程不安全的,因为不能保证++value 是原子性操作。

++value 实际上就是value=value+1,此时内存找到原先的value值,再去找1,然后再将旧value和1相加,而这些过程在操作中可能被其他线程进入干扰从而导致结果错误,即++value的过程是非原子性的。

用汇编的角度去看:

public class ThreadNotSaf eCount { 
      private Long value; 
      publiC Long getCount () { return value; }

      public void inc () { 
             ++value;
      }
} 

使用 Javap -c 命令查看汇编代码,如下所示。

publiC Void inc() ; 
Code : 
0: aload_0 
1 : dup 
2 : getfield #2 // Field value:J
5: lconst 1 
6: ladd 
7 : putfield #2 // Field value:J 
10 : return

    由此可见,简单的++value 由 2、 5、 6、 7 四步组成,其中第 2 步是获取当前 value 的 值并放入战顶, 第 5 步把常量 1放入战顶,第 6 步把当前战顶中两个值相加并把结果放入 技顶,第 7 步则把枝顶的结果赋给 value 变量。因此, Java 中简单的一句++value 被转换 为汇编后就不具有原子性了 。
  那么如何才能保证多个操作的原子性呢?最简单的方法就是使用 synchronized 关键字 进行同步,修改代码如下。

public class ThreadSafeCount { 
        private Long value; 

        //这里用synchronized来保证内存可见性
        public synchronized Long getCount() { return value; }

        //这里用synchronized来保证原子性
        public synchronized vo工d inc () { ++value; }
}

    使用 synchronized 关键宇的确可以实现线程安全性, 即内存可见性和原子性,但是 synchronized 是独占锁,没有获取内部锁的线程会被阻塞掉,而这里的 getCount 方法只是 读操作,多个线程同时调用不会存在线程安全问题。 但是加了关键宇 synchronized 后,同 一时间就只能有一个线程可以调用,这显然大大降低了并发性。 你也许会间,既然是只读 操作,那为何不去掉 getCount 方法上的 synchronized 关键字呢?其实是不能去掉的,别忘了这里要靠 synchronized 来实现 value 的 内存可见性。那么有没有更好的实现呢?答案是 肯定的。

下面将讲到的在内部使用非阻塞 CAS 算法实现的原子性操作类 AtomicLong 就是 一个不错的选择。

7、Java 中的 CAS 操作

    在 Java 中 , 锁在并发处理中占据了 一席之地,但是使用锁有一个不好的地方,就 是当一个线程没有获取到锁时会被阻塞挂起, 这会导致线程上下文的切换和重新调度开 销。 Java 提供了非阻塞的 volatile 关键字来解决共享变量的可见性问题, 这在一定程度 上弥补 了 锁带来的开销 问题,但是 volatile 只能保证共享变量的可见性,不能解决读 改一写等的原子性 问题。

    CAS 即 Compare and Swap,其是 JDK 提供的非阻塞原子性操作,它通过硬件保证了 比较 更新操作的原子性。 JDK 里面的 Unsafe 类提供了一系列的 compareAndSwap*方法, 下面以 compareAndSwapLong 方法为例进行简单介绍。

    • boolean compareAndSwapLong(Object obj,long valueOffset,long expect, long update)方 法 :

其中 compareAndSwap 的意思是比较并交换。 CAS 有四个操作数, 分别为 : 对象内存位置、 对象中该变量的偏移量(变量存储的在对象中的位置)、 变量预期值和新的值。 其操作含义是, 如果 对象 obj 中内存偏移量为 valueOffset 的变量值为 expect,则使用新的值 update 替换 旧的值 expect。 这是处理器提供的一个原子性指令。
 

虽然说CAS可以保证原子性,但是CAS存在ABA的问题:

ABA问题:假如线程A要去通过CAS修改变量X(初始值X为A),要先判断X当前值是否改变过,如果“未改变”,则更新之。但这并不能保证X没有被改变过:假如A修改X前,线程B修改了X的值(X改为B),然后又修改回来(再将X改为A),线程A的CAS操作仍能成功,但X实际上发生过改变。
        ABA 问题的产生是因为变量的状态值产生了环形转换,就是变量的值可以从 A 到 B, 然后再从 B 到 A。如果变量的值只能朝着一个方向转换,比如 A 到 B , B 到 C, 不构成环形,就不会存在问题。 JDK 中的 AtomicStampedReference 类给每个变量的状态值都配备了 一个时间戳, 从而避免了 ABA 问题的产生。(也可以用标记版本号的方法)

8、Unsafe 类

JDK的rt.jar 包中的UnSafe类提供了硬件级别的原子性操作,Unsafe类中的方法都是native方法,使用JNI的方式访问本地C++实现库。

Unsafe类可以直接操作内存,这是不安全的,如果是普通的调用的话,它会抛出一个SecurityException异常;只有由主类加载器加载的类才能调用这个方法。

见下:

public static Unsafe getUnsafe() {
    Class localClass = Reflection.getCallerClass();
    if(!VM.isSystemDomainLoader(localClass.getClassLoader())) {
        throw new SecurityException("Unsafe");
    } else {
        return theUnsafe;
    }
}

但通过万能的反射,还是可以使用到Unsafe类的:

Field f = Unsafe.class.getDeclaredField("theUnsafe");
f.setAccessible(true);
Unsafe unsafe = (Unsafe) f.get(null);

9、Java 指令重排序

    Java 内存模型允许编译器和处理器对指令重排序以提高运行性能, 并且只会对不存在 数据依赖性的指令重排序。 在单线程下重排序可以保证最终执行的结果与程序顺序执行的 结果一致,但是在多线程下就会存在问题。

单线程下:

int a = l ; (1)         int b = 2 ; (2 )            int c= a + b ; (3 ) 

此时第3步依赖1、2步的参数所以不能进行指令重排,而1、2不存在数据依赖性则可以进行指令重排,此时可以先知执行2再执行1。在单线程下指令重排没有问题得到的结果是一样的。但在多线程下就会出现问题:

多线程下:

private static int num = 0;
    private static boolean ready = false;

    public static void main(String[] args){

        Thread t1 = new Thread(new ReadTask());
        Thread t2 = new Thread(new WriteTask());

        t1.start();  //当ready为true时(初始为false),用来读取num的值(初始为0)
        t2.start();  //改变ready为true并将num赋值为1。

        try{
            Thread.sleep(100);
        }catch(InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("main exit!");
    }



    public static class ReadTask implements Runnable {

        @Override
        public void run() {
            while(true) {
                if(ready) {
                    System.out.println(num);
                    return;
                }
            }
        }
    }
    
    public static class WriteTask implements Runnable {
    
        @Override
        public void run() {
            num = 1; //(1)
            ready = true; //(2)
        }
    }

正常来讲,如果没有发生指令重排,则是先将num赋值为1,然后ready为true,最后结果是num为1;但当发生指令重排时第2步跳到第1步,此时读出来的结果为0。故此需要解决指令重排问题,此时volatile/sychronized(有序性)可以解决指令重排的问题。(至于是怎么解决的,原理?happend-before?锁又是怎么解决的?lock可以解决?后面解析)

(重排序在多线程下会导致非预期的程序执行结果,而使用 volatile 修饰 ready 就可以避免重排序和内存可见性问题。
写 volatile 变量时,可以确保 volatile 写之前的操作不会被编译器重排序到 volatile 写 之后。 读 volatile 变量时,可以确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。)

总结:总的来说共享变量的线程不安全,主要是其内存可见性、原子性、指令重排的问题,而sychronized具有内存可见性、原子性、有序性从而保证了共享变量的线程安全性;而volatile其解决了原子性、指令重排的问题却不具备有原子性的性质,此时则可用CAS解决原子性问题,所以volatile+CAS也是解决线程安全的一个方式,并且该方法比较前者上下文切换的消耗来看更具有效率。

10、伪共享

1、伪共享问题:

        为了解决计算机系统中主内存与 CPU 之间运行速度差问题,会在 CPU 与主内存之间 添加一级或者多级高速缓冲存储器(Cache)。这个 Cache 一般是被集成到 CPU 内部的, 所以也叫 CPU Cache(高速缓存,一般有三级缓存)。

cpu的三级缓存:

        CPU的速度要远远大于内存的速度,为了解决这个问题,CPU引入了三级缓存:L1,L2和L3三个级别,L1最靠近CPU,L2次之,L3离CPU最远,L3之后才是主存。速度是L1>L2>L3>主存。越靠近CPU的容量越小。CPU获取数据会依次从三级缓存中查找,如果找不到再从主存中加载。示意图如下:

当CPU要读取一个数据时,首先从一级缓存中查找,如果没有找到再从二级缓存中查找,如果还是没有就从三级缓存或内存中查找。一般来说,每级缓存的命中率大概都在80%左右,也就是说全部数据量的80%都可以在一级缓存中找到,只剩下20%的总数据量才需要从二级缓存、三级缓存或内存中读取,由此可见一级缓存是整个CPU缓存架构中最为重要的部分。

缓存行:

在 Cache 内部是按行存储的,其中每一行称为一个 Cache 行。 Cache 行 是 Cache 与主内存进行数据交换的单位(即每次缓存从主内存拿的都是以缓存行为单位的内存大小的数据), Cache 行的大小一般为 2 的幕次数字节。


伪共享:

CPU的缓存是以缓存行(cache line)为单位进行缓存的,当多个线程修改不同变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。

例如:线程1和线程2共享一个缓存行,线程1只读取缓存行中的变量1,线程2修改缓存行中的变量2,虽然线程1和线程2操作的是不同的变量,由于变量1和变量2同处于一个缓存行中,当变量2被修改后,缓存行失效(基于MSEI协议),线程1要重新从主存中读取,因此导致缓存失效,从而产生性能问题。

下面以两级缓存为例:

2、java如何解决伪共享问题:

解决伪共享最直接的方法就是填充(padding),例如下面的VolatileLong,一个long占8个字节,Java的对象头占用8个字节(32位系统)或者12字节(64位系统,默认开启对象头压缩,不开启占16字节)。一个缓存行64字节,那么我们可以填充6个long(6 * 8 = 48 个字节)(即手动将一个缓存行填充满)。这样就能避免多个VolatileLong共享缓存行。

public class VolatileLong {
    private volatile long v;
    // private long v0, v1, v2, v3, v4, v5  // 去掉注释,开启填充,避免缓存行共享
}

这是最简单直接的方法,Java 8中引入了一个更加简单的解决方案:@Contended注解:

@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD, ElementType.TYPE})
public @interface Contended {
    String value() default "";
}

Contended注解可以用于类型上和属性上,加上这个注解之后虚拟机会自动进行填充,从而避免伪共享。这个注解在Java8 ConcurrentHashMap、ForkJoinPool和Thread等类中都有应用。我们来看一下Java8中ConcurrentHashMap中如何运用Contended这个注解来解决伪共享问题。以下说的ConcurrentHashMap都是Java8版本。

ConcurrentHashMap中伪共享解决方案

ConcurrentHashMap的size操作通过CounterCell来计算,哈希表中的每个节点都对用了一个CounterCell,每个CounterCell记录了对应Node的键值对数目。这样每次计算size时累加各个CounterCell就可以了。<font color="#FF0000">ConcurrentHashMap中CounterCell以数组形式保存,而数组在内存中是连续存储的,CounterCell中只有一个long类型的value属性,这样CPU会缓存CounterCell临近的CounterCell,于是就形成了伪共享。</font>
ConcurrentHashMap中用Contended注解自动对CounterCell来进行填充:

/**
 * Table of counter cells. When non-null, size is a power of 2.
 */
private transient volatile CounterCell[] counterCells; // CounterCell数组,CounterCell在内存中连续,则会造成一个缓存行里有多个数组元素从而导致伪共享问题

public int size() {
    long n = sumCount();
    return ((n < 0L) ? 0 :
            (n > (long)Integer.MAX_VALUE) ? Integer.MAX_VALUE :
            (int)n);
}

// 计算size时直接对各个CounterCell的value进行累加
final long sumCount() {
    CounterCell[] as = counterCells; CounterCell a;
    long sum = baseCount;
    if (as != null) {
        for (int i = 0; i < as.length; ++i) {
            if ((a = as[i]) != null)
                sum += a.value;
        }
    }
    return sum;
}

// 使用Contended注解自动进行填充避免伪共享
@sun.misc.Contended static final class CounterCell {
    volatile long value;
    CounterCell(long x) { value = x; }
}

<font color="##FF0000">需要注意的是@sun.misc.Contended注解在user classpath中是不起作用的,需要通过一个虚拟机参数来开启:-XX:-RestrictContended。</font>

总结

  1. CPU缓存是以缓存行为单位进行操作的。产生伪共享问题的根源在于不同的核同时操作同一个缓存行。
  2. 可以通过填充来解决伪共享问题,Java8 中引入了@sun.misc.Contended注解来自动填充。
  3. 并不是所有的场景都需要解决伪共享问题,因为CPU缓存是有限的,填充会牺牲掉一部分缓存。

这里提出一个疑问:MSEI可以保证缓存的一致性,那么为什么要用volatile关键字?

---------MSEI协议是cpu硬件层面的,是保证多核cpu的缓存一致性的问题;而volatile(JMM,包括其中的volatile,synchronized等关键字)解决的是内存一致性问题。缓存一致性只能解决cpu缓存的共享资源,而没法也保证cpu的寄存器的数据一致性。

至于MSEI协议、伪共享等详细可以看https://www.jianshu.com/p/a4358d39adac     https://www.cnblogs.com/yanlong300/p/8986041.html

至于volatile关键字的可以看https://juejin.im/post/5a2b53b7f265da432a7b821c  (后面还会写对于volatile的实现原理)

内存屏障?https://mp.weixin.qq.com/s?__biz=MzUzMDk3NjM3Mg==&mid=2247483755&idx=1&sn=50f80e73f46fab04d8a799e8731432c6&chksm=fa48da70cd3f5366d9658277cccd9e36fca540276f580822d41aef7d8af4dda480fc85e3bde4&token=1910810820&lang=zh_CN#rd

11、锁的概述

乐观锁与悲观锁

  • 乐观锁:认为数据在一般情况下不会造成冲突,所以在访问记录前不会加排他锁,而是在进行数据提交更新时才会对数据冲突进行检测并返回一定状态。用户需根据状态判断是否更新成功并进行相应操作。(CAS)
  • 悲观锁:对数据被外界修改持保守态度,认为数据很容易被其他线程更改,所以在数据被处理前进行加锁。(Syc、Lock)

公平锁与非公平锁

  • 公平锁:线程获取锁的顺序是按照请求锁的时间顺序决定的,先请求先得。
  • 非公平锁:不保证先请求的线程先获得锁。

注:在没有公平性需求的前提下尽量使用非公平锁,因为公平锁会带来额外性能开销。

独占锁与共享锁

  • 独占锁:任何时候都只有一个线程能得到锁,如ReentrantLock。
  • 共享锁:可以多个线程持有,如ReadWriteLock读写锁。

可重入锁

一个线程要再次获取自己已经获取了的锁时如果不被阻塞,则该锁为可重入锁。(锁中锁)

public class Test{

    public synchronized void f1() {
        System.out.println("f1...");
    }

    public synchronized void f2() {
        System.out.println("f2...");
        f1();
    }
}

上述代码中,调用f2()并不会造成阻塞,说明synchronized内部锁是可重入锁。

自旋锁

当前线程获取锁时,如果发现锁已经被其他线程占有,并不会马上阻塞自己,而是在不放弃CPU使用权的情况下,多次尝试获取,到一定次数后才放弃。目的是使用CPU时间换取线程阻塞与调度的开销。

本章主要介绍了并发编程的基础知识,为后面在高级篇讲解并发包源码打下了基础, 并结合图示形象地讲述了为什么要使用多线程编程,多线程编程存在的线程安全问题,以 及什么是内存可见性问题。然后讲解了 synchronized 和 volatile 关键字,并且强调前者既 保证内存的可见性又保证原子性,而后者则主要保证内存可见性,但是二者的内存语义很 相似。 最后讲解了什么是 CAS 和线程问同步 以及各种锁的概念,这些都为后面讲解 juc包源码奠定了基础。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值