并发编程之七:java内存模型(JMM)、volatile


学习java内存模型,主要解决的问题是:多个线程并发访问时的 原子性、可见性、有序性
在上一章已经学习过了Monitro,主要关注的就是访问共享变量时,保证临界区代码的 原子性(临界区内的代码在执行时不被线程上下文切换所干扰)
这一章我们进一 步深入学习共享变量在多线程间的[ 可见性]问题与多条指令执行时的[ 有序性]问题

java内存模型

JMM即Java Memory Model,它从java的层面进行了抽象,定义了主存、工作内存抽象概念,底层对应着CPU寄存器、缓存、硬件内存、CPU指令优化等。这些优化比如我们使用synchronized进行加锁等,避免了程序员对底层直接操作,那样太复杂麻烦。
一句话:所谓的java内存模型,它就是把整个的java内存上划分成了主存和工作内存。
主存:所有线程都共享的数据,例如静态成员变量或者成员变量。
工作内存:线程私有的数据,如局部变量
JMM体现在以下几个方面

  • 原子性-保证指令不会受到线程上下文切换的影响
  • 可见性-保证指令不会受cpu缓存的影响
  • 有序性-保证指令不会受cpu指令并行优化的影响

原子性

在上一章已经学习过了Monitro,主要关注的就是访问共享变量时,保证临界区代码的原子性(临界区内的代码在执行时不被线程上下文切换所干扰)
这一章我们进一 步深入学习共享变量在多线程间的[可见性]问题与多条指令执行时的[有序性]问题

可见性

内存可见性问题:一个线程对主存得东西进行了修改,另一个线程不可见。

解决共享变量可见性问题(volatile、synchronized)

看以下代码,当设置run = false;的时候确实是将run改成了false,那它就该退出循环,但是while循环的代码并没有停止,这是为啥呢?

在这里插入图片描述

下面我们就以java内存模型的角度来看这个问题。所谓的java内存模型,它就是把整个的java内存上划分成了主存和工作内存。主存就是共享的数据比如静态变量,工作内存就是每个线程私有的数据。
在这里插入图片描述

在这里插入图片描述

这就是内存可见性问题:一个线程对主存得东西进行了修改,另一个线程不可见。
对应这里得主方法对run进行了修改,线程t不可见(线程t读得还是自己工作内存中得数据)

解决方法1:volatile(易变)
它可以用来修饰成员变量和静态成员变量,他可以避免线程从自己的工作缓存中查找变量的值,必须到主存中获取它的值,线程操作volatile变量都是直接操作主存。
问题:局部变量呢?局部变量是线程私有得不是共享得,所以不存在这个问题,所以不能被volatile修饰。
一句话:被volatile修饰得得变量,在被线程读取得时候,就只能从主内存中读取,不能从工作内存中读取

volatile static boolean run = true; 

加上volatile之后再运行上面那段代码,就不会有问题了。当run在主方法中被修改为false之后,while就会结束循环。

解决方法2:synchronized其实也能解决这个问题。
在这里插入图片描述
由此可见volatile和synchronized都能保证共享变量得可变性。但是还是有区别得,synchronized属于重量级锁性能上会有损耗,而volatile不是,所以处理可见性的问题上,推荐使用volatile。

可见性vs原子性

前面例子体现的实际就是可见性,volatile保证的是在多个线程之间,一个线程对volatile变量的修改对另-一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况:
不能保证原子性的理解,可见下面的【同步模式之Balking(犹豫)模式】
上例从字节码理解是这样的:
在这里插入图片描述

比较一下之前我们将线程安全时举的例子:两个线程一个i++一个i–,只能保证看到最新值,不能解决指令交错的问题。

在这里插入图片描述
注意
synchronized语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。但缺点是synchronized是属于重量级操作,性能相对更低。
如果在前面示例的死循环中加入System.out.println( 会发现即使不加volatile修饰符,线程t也能正确看
到对run变量的修改了,想一想为什么?
要是到为啥,当然是看源码,它是怎么实现的,底层还是synchronized
在这里插入图片描述
在这里插入图片描述

终止模式之两阶段终止模式(volatile实现)

这个模式我们之前用打断标记实现过,这里我们用volatile来实现一下,相当于对之前的用打断标记来实现的进行一种改进。
打断标记的解决方法
在这里插入图片描述

volatile解决方式
在这里插入图片描述
下面添加了打断方法的,也只是想让睡眠中的线程尽快的获取打断标记,但是这里我们不需要获取了,因为已经用了volatile了。如果不加打断那么每次都是线程睡1s之后再往下执行。
在这里插入图片描述

同步模式之Balking(犹豫)

保证某个方法只执行一次,下次再执行的时候就直接返回。不再执行方法里的代码。
犹豫模式,我在做某件事的时候,发现别人已经做过了,那我就不做了,此所谓犹豫
上面的代码修改以下就是犹豫模式。
在这里插入图片描述
问题:但是以上代码有个问题,比如线程1进入start方法,此时starting为false,然后线程1执行将starting 改为true,但是还没有写入,此时发生线程上下文切换,然后线程2进入start方法,于是此时starting还是false,于是就有问题了,其实还是多线程读共享变量的问题。

问题解决方案1:volatile
那这里我们用volatile行不行呢?
假如我们在startin变量定义的时候添加了volatile。
于是,线程1进入start方法执行到if的"}"的时候,此时starting还是false,然后线程2就又进来了,于是还是不满足我们的让start方法只执行一次的需求。
volatile只能保证线程看到最新的结果,但是你没修改之前,看到的还是前的值,所以volation解决的是变量可见性问题,而不能解决多线程安全问题。

问题解决方案2
一下代码,当线程1得到锁,进来执行,即使中间发生了线程上下文切换,但是在线程1没有释放锁之前,其它线程是进入不了synchronized代码块的也就没有上面的问题了,完美解决需求。

总结:我们上面提到的volatile解决两阶段终止模式时的stop变量添加volatile,因为只有一个线程对这个停止标记stop去写,而且只有一行代码,所以它(stop)只要保证可见性就可以了。但是start方法里的starting既涉及到了读又涉及到了写,而且时多行语句,既然是多行语句,那么我们要保证它的原子性,既然是原子性,那么就的用synchronized。
总结:前面例子体现的实际就是可见性,它保证的是在多个线程之间,一个线程对volatile变量的修改对另一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况:
不能保证原子性的理解
在这里插入图片描述

小优化,synchronized里的代码越多上锁时间越长,并发度越低。我们把红框中的代码放到synchronized外面也是可以的,线程1获得锁,去执行。然后上下文切换,线程2进入但是获取不到锁,也就无法向下执行。
在这里插入图片描述

同步模式之Balking(犹豫模式)应用场景

应用场景1:就是我们上面的例子,一个方法只开启一次
Balking (犹豫) 模式用在一个线程发现另 -一个线程或本线程已经做了某- -件相同的事,那么本线程就无需再做了,直接结束返回。
在这里插入图片描述
在同步代码块里的变量的可见性,可以由synchronized保证,但是不在同步代码块里的变量的可见性的由volatile来保证。

应用场景2:实现线程安全的单例

单例模式就是某个对象不管创建多少次,只创建一个实例。以下代码中的if就是犹豫模式的一个体现,如果不为空,也就是被创建过了,就直接返回,不会向下走。
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210720163013228.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L2

有序性

JM会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码
在这里插入图片描述
可以看到,至于是先执行i还是先执行j,对最终的结果不会产生影响。所以,上面代码真正执行时, 既可以是先给i赋值,也可以是先给j赋值。这种特性称之为指令重排多线程下[指令重排」会影响正确性。为什么要有重排指令这项优化呢?从CPU执行指令的原理来理解一下吧。其实就是为了实现指令级的并行效果,来达到提升效率的目的,比如烧水的同时可以写bug。

例如在某一个变量前添加了volatile之后,能保证它之前的代码不被指令重排。例如在actor2方法里的最后一行天啊及添加的变量前添加了volatile之后它之前的代码就不会被指令重排了。在这里插入图片描述

volatile原理

volatile原理
volatile的底层实现原理是内存屏障,Memory Barrier (Memory Fence)

  • 对volatile变量的写指令后会加入写屏障
  • 对volatile变量的读指令前会加入读屏障
  • 添加volatile的变量之前的代码,不会发生指令重排序。

使用场景1:可见性**,volatile保证的是在多个线程之间,一个线程对volatile变量的修改对另-一个线程可见,不能保证原子性,仅用在一个写线程,多个读线程的情况:
使用场景2:懒汉模式里保证第一层if的判断不发生指令重排序的问题。
其它情况不要滥用volatile。

1、如何保证可见性(读、写屏障)

以下的之前与之后,只得是添加了volatile关键字得代码得之前、之后
写屏障(sfence) 保证在该屏障之前的代码,对共享变量的改动,都同步到主存当中,并且不会发生指令重排
注意:这里如果一个变量加了volatile那么他之前的变量也都写到主存中,例如下面的num,它只是普通的变量但是也会被写入主存中。
并且写屏障之前的代码不会被指令重排。
在这里插入图片描述
而读屏障(lfence) 保证在该屏障之后的代码,对共享变量的读取,加载的是主存中最新数据
在这里插入图片描述
在这里插入图片描述

2、如何保证有序性

写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后

在这里插入图片描述
读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
在这里插入图片描述

在这里插入图片描述
总结:从读屏障、写屏障保证了指令不会被重排序

还是那句话,不能解决指令交错:

  • 写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证读跑到它前面去
  • 而有序性的保证也只是保证了本线程内相关代码不被重排序

注意1:例如以下流程,写屏障保证了i=1写入到主存,但是它不能保证人家t2线程啥时候去读,比如你刚要写,然后上下文切换了,t2就读了,所以t2读到的不是1。。。所以它并不能解决指令的交错。
注意2:读屏障也只能保证本线程内的代码不会指令重排序,但是线程之间这个代码谁先谁后是由cpu时间片决定的,你cpu时间片用完了,那就执行其它的线程了,就只能交错执行了。所以volatile保证有序性和可见性,但是不能保证原子性。
而synchronized这三种都可以做到:有序性,可见性,原子性。

在这里插入图片描述

double-checked locking问题

下面我们来分析一个犹豫没有正确认识到有序性导致的一个开发中的问题。
以著名的单例模式为例:
以下为懒汉式,所谓懒汉式即对象的实例不是一开始就创建出来的,而是第一次使用到时才创建。

public class Singleton {
    private Singleton() { }

    private static Singleton INSTANCE = null;

    public static synchronized Singleton getInstance() {
        if (INSTANCE == null) {
            INSTANCE = new Singleton();
        }
        return INSTANCE;
    }
}

以上代码等同于以下代码:

public class Singleton {
    private Singleton() {}

    private static Singleton INSTANCE = null;

    public static Singleton getInstance() {
        synchronized (Singleton.class) {
            if (INSTANCE == null) {
                INSTANCE = new Singleton();
            }
            return INSTANCE;
        }
    }
}

我们来分析一下上面的代码,线程每次进来都要进入到synchronized里,我们知道synchronized里的代码越多,加锁的时间就越,对性能的影响是比较高的。
以上代码如果第一次被调用,那么没问题,但是如果第二、三…次被调用,每次都会进入到同步代码块,虽然我们知道它有什么轻量级锁、偏向锁啊,但是性能还是比较低啊。但是其实我们的这个单例对象INSTANCE在没有被创建出来的时候需要对它进行保护,一旦创建出来就不需要对它进行保护了。所以问题就变成了,当它第一次被调用的时候我加互斥锁,当之后被调用的时候我就不用加锁了,于是就有人想出了2次判断即: double-checked locking。
如下代码:当首次创建有竞争时,线程们进入if,此时INSTANCE为空,于是线程1、2都执行到了synchronized,由线程1竞争到了锁资源,然后线程1进入,判断INSTANCE是空,然后new一个对象,然后线程1执行完毕,释放锁资源,此时线程2得到了锁资源,然后进入synchronized,判断INSTANCE是否为空,因为线程1已经new了一个对象并给INSTANCE 赋值,所以此时INSTANCE 不为空,于是线程2不会创建对象。然后直接返回静态成员变量INSTANCE。至于之后其其它程序再调用getInstance,在第一个if里就判断了不为空,直接返回这个单例对象,就不会走入synchronized里了,也就不会加锁了,于是达到了,首次访问会同步,而之后的使用没有synchronized,提升了代码的运行效率。

public class Singleton {
    private Singleton() { }

    private static Singleton INSTANCE = null;

    public static Singleton getInstance() {
        // 首次访问会同步,而之后的使用没有synchronized
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}

下面我们就看一下以上这个看似完美到无懈可击的代码到底有没有问题(有隐患的)
以上的实现特点是:

  • 懒惰实例化:用到时才创建
  • 首次使用getInstance(才使用synchronized加锁,后续使用时无需加锁
  • 有隐含的,但很关键的一点:第一个if使用了INSTANCE变量,是在同步块之外

第一if判断,它没有在同步代码之内所以它还是有可能被指令重排的,因为它没有收到同步代码块的保护。我们说过synchronized具有原子性、有序性、可见性,但是synchronized之外的代码就没有办法保证了。这个隐患的根源就是INSTANCE这个变量的有序性会产生问题。

但在多线程环境下,以上面的代码是有问题的,getInstance 方法对应的字节码为:
在这里插入图片描述

上图解释:线程1获取锁资源,然后实例化对象,先给INSTANCE赋值了new Singleton();,但是线程2刚好执行到第一层if,但是发生了指令重排,于是线程1的构造方法还没有调用完毕,然后就给线程2判断INSTANCE是否为空,注意此时已经给INSTANCE赋值了,但是构造函数没有执行完毕,于是if判断不为空,然后线程2就欢喜的拿着对象(这个构造韩式还没有执行完毕的对象)去搞事情了。

问题1:之前我们说过synchronized可以保证共享变量的原子性、可见性和有序性,特别是有序性(不会指令重排)但是有个前提条件,就是该变量的完全被synchronized包裹,但是上面这个代码里最外层的if (INSTANCE == null)并没有被包括。所以上面的代码,线程1进入了synchronized给共享变量赋值,但是线程2在外面的if里使用了该变量,并判断是否为空,你synchronized可以保证自己的有序性,但是你synchronized之外的代码synchronized就管不着了,于是synchronized之外的指令与之内的指令发生了指令重排。结论:synchronized可以保证共享变量的原子性、可见性和有序性

关键在于0: getstatic这行代码(第一层if)在monitor控制之外(不在synchronzied之内),它就像之前举例中不守规则的人,可以越过monitor读取INSTANCE变量的值,这时tl还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么t2拿到的是将是一个末初始化完毕的单例。对INSTANCE使用volatile修饰即可,可以禁用指令重排,但要注意在JDK 5以上的版本的volatile才会真正有效。
一句话:因为指令的重排,所以有可能在t2在使用的时候构造方法还没有运行完。

解决方案:其实解决方法写起来很简单,就是在变量的INSTANCE前加volatile,通过添加volatile使用写屏障来达到阻止 指令重排序,来解决了刚才的问题。

public class Singleton {
    private Singleton() { }
    // volatile防止指令重排
    private static volatile Singleton INSTANCE = null;

    public static Singleton getInstance() {
        // 不在方法上加synchronized
        // 1、减少synchronized内的代码块,提升效率
        // 2、不在方法上加synchronized,只有第一次调用的时候才加锁,提升效率
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                // 这里再次判断,是为了防止第一次调用getInstance方法时,多线程的并发时:单例对象不会被多次重新创建
                // 第一个if是没有锁的,多个线程进入要再次判断一下是否为空。例如初始化时,线程1得到锁,线程2阻塞,然后线程1创建完毕,释放锁资源。此时线程2被唤醒,然后获取锁资源,再次判断不为空,然后就不会创建了。如果不判断,线程2就直接创建对象了,这就不是单例了。
                if (INSTANCE == null) {
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
happens-before

happens-before它就是一套规则,规定了啥时候线程对共享变量的写,对于其它线程对于该共享变量的读是可见的。

happens-before规定了对共享变量的写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结,抛开以下happens-before规则,JMM并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。
下述列出的都是可以保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见。

以下中变量都是指成员变量或者静态变量。

1、线程解锁m之前对变量的写,对于接下来对m加锁的其它线程对该变量的读可见

以下代码synchronized能够保证共享变量的可见性、有序性、原子性,以下代码,变量的读、写全部在synchronized里,所以没有问题。
在这里插入图片描述

2、线程对volatile变量的写,对接下来其它线程对该变量的读可见
volatile将共享变量的值同步到主存中,并强制线程从主存中读取数据而不是线程自己的缓存,达到一个线程对共享变量的写,对于接下来的其他线程对该变量是可见的。
在这里插入图片描述

3、线程start前对变量的写,对该线程开始后对该变量的读可见。
线程启动之前就给变量赋值了,那么线程启动之后对于该变量肯定是可见的。
在这里插入图片描述

4、线程结束前对变量的写,对其它线程 【得知它结束后】 的读可见(比如其它线程调用tl.isAlive(或tl.join()等待它结束)
在这里插入图片描述

5、线程tl打断t2 (interrupt) 前t1对变量的写,对于其他线程得知t2被打断后对变量的读可见(通过
t2.interrupted或t2.isInterrupted)

在这里插入图片描述

6、对变量默认值(0, false, null) 的写,对其它线程对该变量的读可见
默认值的写入对其它线程对该变量的读是可见的,比如int的0, boolean的true/false, 对象的null,对其他线程的读是可见的。

7、具有传递性,如果x hb-> y 并且y hb->z 那么有x hb-> z,配合volatile的防指令重排,有下面的例子

7.1
t1里,因为x添加了volatile,那么t1里的代码在x = 20 之前的所有代码都会被同步到主存中,并且不会发生指令重排。
在这里插入图片描述

习题:以下单例是否安全

习题1:犹豫模式
balking(犹豫模式:某个方法执行一次)模式习题
希望doInit()方法仅被调用一次,下面的实现是否有问题,为什么?
在这里插入图片描述

解答:以上代码不对, initialized被volatile修饰了,但是该方法中对于该变量既有读又有写的操作,volatile不能保证代码的原子性。例如线程1进入if,发现initialized为false,继续执行,到该执行initialized= true但是还没有执行,线程上下文切换,然后t2进入方法,读到的initialized还是false,所以该doInit会被调用多次。所以上代码解决方法就是在init整个方法里添加synchronized,而不要用volatile
volatile的使用场景:一个线程写,多个线程读的情况来保证变量的可见性。

习题2:线程安全单例练习

单例模式有很多实现方法,饿汉、懒汉、静态内部类、枚举类,试分析每种实现下获取单例对象(即调用getInstance)时的线程安全,并思考注释中的问题

  • 饿汉式:类如载就会导致该单实例对象被创建
  • 懒汉式:类如载不会导致该单实例对象被创建,而是首次使用该对象时才会创建

实现1:
在这里插入图片描述
我们在本问题中考虑的单例线程安全,只考虑单例对象的创建或者获取时是否安全,不考虑对象自身的属性比如对象中的共享变量安全不安全,这些不考虑

问题1:加final怕它有子类,然后子类覆盖了它其中的一些方法,然后破坏它的单例。

问题2:对象的创建不光是通过new来创建,如果实现了序列化接口,意味着将来反序列化的时候也会生成对象,如果反序列化的话就相当于生成了另一个对象,所以就是破坏了单例。
在反序列化中,它一旦发现readResovle返回了一个对象它就会采用这个对象作为反序列化的结果,而不是反序列化时通过字节码生成的对象当成反序列化的结果。保证即使时反序列化了它使用的也是同一个对象而不是新的对象。
解决方法:在类中添加readResovle方法
在这里插入图片描述

问题3:如果不适用private而使用其它的访问修饰符,那么别的类就可以无线的创建它的对象。
设置为私有不能防止反射创建它的对象,私有方法,反射也可以调用。

问题4:没有线程安全问题,在静态成员变量初始化的时候给它创建了一个单例对象。静态成员变量的初始化操作是在类加载器中完成的,类加载器是由jvm来保证这个线程的安全性,是没有问题的。
补充:一加载就创建属于饿汉式,等到用的时候才创建就是懒汉式。

问题5:设置成方法可以对变量进行更好的封装(比如如果不写成方法就没办法实现懒汉模式了)、对创建对象时可以添加更多的控制、使用方法还可以支持泛型。

习题3:枚举类型安全
在这里插入图片描述

解答1:枚举类型编译后的代码见下图,本质就是内部的一个静态成员变量,且是被final修饰的。与习题2中的问题4是一样的,所以枚举类型的单例是安全的。
在这里插入图片描述

问题2:没有并发问题,因为它也是静态成员变量也是在类加载时创建的,类加载器是由jvm来保证这个线程的安全性,是没有问题的。见习题2,问题4.

问题3:枚举单例不能用反射来破坏单例。

问题4:不能,枚举类默认都是实现了序列化接口,问题1的反编译图,该单例类继承了Enum,Enum的源码又实现了序列化接口,所以枚举类是可以被序列化和反序列化的。但是枚举考虑到了反序列化时生成的对象,破坏单例的问题,所以枚举类自己已经处理过了。

问题5:属于饿汉式。一加载就创建属于饿汉式,等到用的时候才创建就是懒汉式。

问题6:可以加一些构造方法,然后处理的逻辑写在构造方法里就行了。

习题3:单例模式之懒汉式

在这里插入图片描述

习题3解答:以上代码时可以保证线程安全的,因为整个获取单例的方法上都加了F,并且共享变量INSTANCE完全被synchronized包括,所以synchronized可以保证该对该变量的原子性、有序性(防止指令重排)、可见性。但是注意synchronized不能添加在变量INSTANCE上,首先它是一个变量会被重新赋值,然后它赋的值还有可能是一个null,我们知道它锁住的是一个对象,但是一个null它就没法锁。
缺点:锁的范围有点大,并且每次调用都要加锁,效率比较低,优化方法可以参考上面小结:double-checked locking问题的分析。

习题4:单例模式之懒汉式
在这里插入图片描述

以上习题优化方法可以参考上面小结:double-checked locking问题的分析。

问题1:防止指令重排序。

问题2:1、减少synchronize锁住的代码块的范围,提升效率。2、只有在第一次调用的时候才进入synchronized,这样也是大大提升了代码的效率。

问题3:解决首次访问getInstance方法时多个线程并发的问题。第一个if是没有锁的,多个线程进入要再次判断一下是否为空。例如初始化时,线程1得到锁,线程2阻塞,然后线程1创建完毕,释放锁资源。此时线程2被唤醒,然后获取锁资源,再次判断不为空,然后就不会创建了。如果不判断,线程2就直接创建对象了,这就不是单例了。

习题5:

在这里插入图片描述

问题1:静态内部类的目的是为了懒汉式的一个创建,如果只是用外面的Singleton没有调用getInstance,那么这个静态内部类是不会被加载的,所以单例也不会被创建,当用到的时候才会加载所以是懒汉式。

问题2:以上代码为懒汉式,在用到的时候才会被加载,所以内部类在用到的时候也才会被加载,但是,类加载是由jvm来保证多线程的安全问题的,所以上面的代码也是线程安全的。也是我们比较推荐的一种懒汉式的实现方式。

本章小结

本章重点讲解了JMM中的

  • 可见性-由JVM缓存优化引起

  • 有序性-由JVM指令重排序优化引|起

  • happens-before规则

  • 原理方面

    1、CPU指令并行
    2、volatile
    
  • 模式方面

    1、两阶段终止模式的volatile改进
    2、同步模式之balking(犹豫模式:某段代码只执行一次)
    

个人学习笔记,不喜勿喷。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值