学习笔记【Java 虚拟机④】内存模型


若文章内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系博主删除。


总目录



前言


  • 参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

(四)内存模型


19.Java 内存模型


19.1.基本定义


很多人将 java 内存结构java 内存模型 傻傻分不清,

java 内存模型Java Memory ModelJMM)的意思。

简单的说,JMM 定义了一套在多线程读写共享数据时(成员变量、数组)时,对数据的可见性、有序性、和原子性的规则和保障


相关视频:https://www.bilibili.com/video/BV16J411h7Rd?p=134

JMM 定义了主存、工作内存抽象概念,底层对应着 CPU 寄存器、缓存、硬件内存、CPU 指令优化等。

JMM 体现在以下几个方面

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

19.2.主内存和工作内存


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


Java 内存模型的主要目的是定义程序中各种变量的访问规则

  • 即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节

此处的变量(Variables)与 Java 编程中所说的变量有所区别

  • 它包括了实例字段、静态字段和构成数组对象的元素
  • 但是不包括局部变量与方法参数
  • 因为后者是线程私有的,不会被共享,自然就不会存在竞争问题。

为了获得更好的执行效能

  • Java 内存模型并没有限制执行引擎使用处理器的特定寄存器或缓存来和主内存进行交互
  • 也没有限制即时编译器是否要进行调整代码执行顺序这类优化措施。

注意:此处请读者注意区分概念

  • 如果局部变量是一个 reference 类型,它引用的对象在 Java 堆中可被各个线程共享
  • 但是 reference 本身在 Java 栈的局部变量表中是线程私有的

Java 内存模型规定了所有的变量都存储在 主内存Main Memory)中

  • 此处的主内存与介绍物理硬件时提到的主内存名字一样,两者也可以类比,但物理上它仅是虚拟机内存的一部分

每条线程还有自己的 工作内存Working Memory,可与前面讲的处理器高速缓存类比)

  • 线程的工作内存中保存了被该线程使用的变量的主内存副本
    • 有部分读者会对这段描述中的 “副本” 提出疑问
    • 如 “假设线程中访问一个 10 MB 大小的对象,也会把这 10 MB 的内存复制一份出来吗?”
      • 事实上并不会如此
      • 这个对象的引用、对象中某个在线程访问到的字段是有可能被复制的
      • 但不会有虚拟机把整个对象复制一次。
  • 线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。
    • 根据《Java 虚拟机规范》的约定,volatile 变量依然有工作内存的拷贝
      • 但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般
      • 因此这里的描述对于 volatile 也并不存在例外。

不同的线程之间也无法直接访问对方工作内存中的变量线程间变量值的传递均需要通过主内存来完成


在这里插入图片描述

线程、主内存、工作内存三者的交互关系

在这里插入图片描述

处理器、高速缓存、主内存间的交互关系(共享内存多核系统)

这两者(此处所讲的主内存和之前所讲的 Java 内存区域中的部分)基本上是没有任何关系的

  • 这里所讲的主内存、工作内存与之前所讲的 Java 内存区域中的 Java 堆、栈、方法区 等并不是同一个层次的对内存的划分

如果两者一定要勉强对应起来

  • 那么从变量、主内存、工作内存的定义来看,主内存主要对应于 Java 堆中的对象实例数据部分
    • 除了实例数据,Java 堆还保存了对象的其他信息
    • 对于 HotSpot 虚拟机 来讲
      • Mark Word(存储对象哈希码、GC 标志、GC 年龄、同步锁等信息)
      • Klass Point(指向存储类型元数据的指针)
      • 以及一些用于字节对齐补白的填充数据(如果实例数据刚好满足 8 字节对齐,则可以不存在补白)。
  • 而工作内存则对应于虚拟机栈中的部分区域

从更基础的层次上说

  • 主内存直接对应于物理硬件的内存
  • 而为了获取更好的运行速度
    • 虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中
    • 因为程序运行时主要访问的是工作内存

19.3.内存间交互操作


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


关于主内存与工作内存之间具体的交互协议,

即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,


Java 内存模型中定义了以下 8 种操作来完成。

Java 虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的。

  • lock锁定
    • 作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
  • unlock解锁
    • 作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read读取
    • 作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用。
  • load载入
    • 作用于工作内存的变量,它把 read 操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use使用
    • 作用于工作内存的变量
    • 它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign赋值
    • 作用于工作内存的变量
    • 它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store存储
    • 作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的 write 操作使用。
  • write写入
    • 作用于主内存的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中。

注意:对于 doublelong 类型的变量来说,loadstorereadwrite 操作在某些平台上允许有例外


如果要把一个变量从主内存拷贝到工作内存,那就要按顺序执行 readload 操作;

如果要把变量从工作内存同步回主内存,就要按顺序执行 storewrite 操作。

注意:Java 内存模型只要求上述两个操作必须按顺序执行,但不要求是连续执行。

也就是说 readload 之间、storewrite 之间是可插入其他指令的,

如对主内存中的变量 a、b 进行访问时,一种可能出现的顺序是 read aread bload bload a


除此之外,Java 内存模型还规定了在执行上述 8 种基本操作时必须满足如下规则:

  • 不允许 readloadstorewrite 操作之一单独出现
    • 即不允许一个变量从主内存读取了但工作内存不接受,或者工作内存发起回写了但主内存不接受的情况出现。
  • 不允许一个线程丢弃它最近的 assign 操作,即变量在工作内存中改变了之后必须把该变化同步回主内存。
  • 不允许一个线程无原因地(没有发生过任何 assign 操作)把数据从线程的工作内存同步回主内存中。
  • 一个新的变量只能在主内存中 “诞生”,不允许在工作内存中直接使用一个未被初始化(loadassign)的变量
    • 换句话说就是对一个变量实施 usestore 操作之前,必须先执行 assignload 操作。
  • 一个变量在同一个时刻只允许一条线程对其进行 lock 操作,但 lock 操作可以被同一条线程重复执行多次,多次执行 lock 后,只有执行相同次数的 unlock 操作,变量才会被解锁。
  • 如果对一个变量执行 lock 操作,那将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行 loadassign 操作以初始化变量的值。
  • 如果一个变量事先没有被 lock 操作锁定,那就不允许对它执行 unlock 操作,也不允许去 unlock 一个被其他线程锁定的变量。
  • 对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 storewrite 操作)。

这 8 种内存访问操作以及上述规则限定,再加上稍后会介绍的专门针对 volatile 的一些特殊规定,就已经能准确地描述出 Java 程序中哪些内存访问操作在并发下才是安全的。

这种定义相当严谨,但也是极为烦琐,实践起来更是无比麻烦。

可能部分读者阅读到这里已经对多线程开发产生恐惧感了。

后来 Java 设计团队大概也意识到了这个问题,将 Java 内存模型的操作简化为 readwritelockunlock 四种。

但这只是语言描述上的等价化简,Java 内存模型的基础设计并未改变。

然而即使是这四操作种,对于普通用户来说阅读使用起来仍然并不方便。

不过读者对此无须过分担忧。

除了进行虚拟机开发的团队外,大概没有其他开发人员会以这种方式来思考并发问题,我们只需要理解 Java 内存模型的定义即可。


19.4.volatile 型变量


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


Java 内存模型为 volatile 专门定义了一些特殊的访问规则。

在介绍这些比较拗口的规则定义之前,先用一些不那么正式,但通俗易懂的语言来介绍一下这个关键字的作用。


当一个变量被定义成 volatile 之 后,它将具备两项特性:

  • 第一项:保证此变量对所有线程的可见性
  • 第二项:禁止指令重排序优化。

19.4.1.保证变量对所有线程的可见性


这里的 “可见性” 是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。

而普通变量并不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。

比如,线程 A 修改一个普通变量的值,然后向主内存进行回写;

另外一条线程 B 在线程 A 回写完成了之后再对主内存进行读取操作,新变量值才会对线程 B 可见。


关于 volatile 变量的可见性,经常会被开发人员误解。

他们会误以为下面的描述是正确的:“volatile 变量对所有线程是立即可见的,对 volatile 变量所有的写操作都能立刻反映到其他线程之中。

换句话说,volatile 变量在各个线程中是一致的,所以基于 volatile 变量的运算在并发下是线程安全的”。

这句话的论据部分并没有错,但是由其论据并不能得出 “基于 volatile 变量的运算在并发下是线程安全的” 这样的结论。

volatile 变量在各个线程的工作内存中是不存在一致性问题的(从物理存储的角度看,各个线程的工作内存中 volatile 变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题),但是 Java 里面的运算操作符并非原子操作,这导致 volatile 变量的运算在并发下一样是不安全的。

由于 volatile 变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁来保证原子性:

  • 加锁:使用 synchronizedjava.util.concurrent 中的锁或原子类
  • 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
  • 变量不需要与其他的状态变量共同参与不变约束。

volatile 的适用于一个写线程,多个读线程的情况。


19.4.2.禁止指令重排序优化


普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。

因为在同一个线程的方法执行过程中无法感知到这点,这就是 Java 内存模型中描述的所谓 “线程内表现为串行的语义”(Within-Thread As-If-Serial Semantics)。


现在我们来看看在众多保障并发安全的工具中选用 volatile 的意义——它能让我们的代码比使用其他的同步工具更快吗?

在某些情况下,volatile 的同步机制的性能确实要优于锁(使用 synchronized 关键字或 java.util.concurrent 包里面的锁);

但是由于虚拟机对锁实行的许多消除和优化,使得我们很难确切地说 volatile 就会比 synchronized 快上多少。

如果让 volatile 自己与自己比较,那可以确定一个原则:volatile 变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢上一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

不过即便如此,大多数场景下 volatile 的总开销仍然要比锁来得更低。

我们在 volatile 与锁中选择的唯一判断依据仅仅是 volatile 的语义能否满足使用场景的需求。


19.4.2.对于该类型变量的特殊规则


本节的最后,我们再回头来看看 Java 内存模型中对 volatile 变量定义的特殊规则的定义。

假定 T 表示一个线程,VW 分别表示两个 volatile 型变量,

那么在进行 readloaduseassignstorewrite 操作时需要满足如下规则:

  • 只有当线程 T 对变量 V 执行的前一个动作是 load 的时候,线程 T 才能对变量 V 执行 use 动作;
    并且,只有当线程 T 对变量 V 执行的后一个动作是 use 的时候,线程 T 才能对变量 V 执行 load 动作。
    线程 T 对变量 Vuse 动作可以认为是和线程 T 对变量 Vloadread 动作相关联的,必须连续且一起出现。
    这条规则要求在工作内存中,每次使用 V 前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量 V 所做的修改。
    
  • 只有当线程 T 对变量 V 执行的前一个动作是 assign 的时候,线程 T 才能对变量 V 执行 store 动作;
    并且,只有当线程 T 对变量 V 执行的后一个动作是 store 的时候,线程 T 才能对变量 V 执行 assign 动作。
    线程 T 对变量 Vassign 动作可以认为是和线程 T 对变量 Vstorewrite 动作相关联的,必须连续且一起出现。
    这条规则要求在工作内存中,每次修改 V 后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量 V 所做的修改。
    
  • 假定动作 A 是线程 T 对变量 V 实施的 useassign 动作,
    假定动作 F 是和动作 A 相关联的 loadstore 动作,
    假定动作 P 是和动作 F 相应的对变量 Vreadwrite 动作;
    与此类似,
    假定动作 B 是线程 T 对变量 W 实施的 useassign 动作,
    假定动作 G 是 和动作 B 相关联的 loadstore 动作,
    假定动作 Q 是和动作 G 相应的对变量 Wreadwrite 动作。
    如果 A 先于 B,那么 P 先于 Q
    这条规则要求 volatile 修饰的变量不会被指令重排序优化,从而保证代码的执行顺序与程序的顺序相同。
    

20.原子性


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

原子性Atomicity

由 Java 内存模型来直接保证的原子性变量操作包括 readloadassignusestorewrite 这六个

我们大致可以认为,基本数据类型的访问、读写都是具备原子性的

  • 例外就是 longdouble 的非原子性协定,读者只要知道这件事情就可以了,无须太过在意这些几乎不会发生的例外情况。

如果应用场景需要一个更大范围的原子性保证(经常会遇到)

  • Java 内存模型还提供了 lockunlock 操作来满足这种需求
  • 尽管虚拟机未把 lockunlock 操作直接开放给用户使用
  • 但是却提供了更高层次的字节码指令 monitorentermonitorexit 来隐式地使用这两个操作。
  • 这两个字节码指令反映到 Java 代码中就是同步块 synchronized 关键字,因此在 synchronized 块之间的操作也具备原子性。

20.1.问题提出


两个线程对初始值为 0 的静态变量一个做自增,一个做自减,各做 5000 次,结果是 0 吗?

public class Demo4_1 {

    static int i = 0;
    static Object obj = new Object();

    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i++;
            }
        });

        Thread t2 = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                i--;
            }
        });
        
        /* 
         * run():封装线程执行的代码,直接调用,相当于普通方法的调用
         * start():启动线程。然后由 JVM 调用此线程的 run() 方法 
         */
        t1.start();
        t2.start();
        
        // join():等待该线程死亡
        t1.join();
        t2.join();
        
        System.out.println(i);
    }
}

上面的程序运行后的结果可能是正数、负数、零。


20.2.问题分析


以上的结果可能是正数、负数、零。

这是因为 Java 中对静态变量的自增,自减并不是原子操作。

例如对于 i++ 而言(i 为静态变量),实际会产生如下的 JVM 字节码指令

getstatic i 			// 获取静态变量 i 的值
iconst_1 				// 准备常量 1
iadd 					// 加法
putstatic i 			// 将修改后的值存入静态变量 i

对应的 i- - 也是如此

getstatic i 			// 获取静态变量 i 的值
iconst_1 				// 准备常量 1
isub 					// 减法
putstatic i 			// 将修改后的值存入静态变量 i

而 Java 的内存模型如下,完成静态变量的自增,自减需要在主存和线程内存中进行数据交换

在这里插入图片描述


如果是单线程以上 8 行代码是顺序执行(不会交错)没有问题

// 假设 i 的初始值为 0
getstatic i 			// 线程1-获取静态变量 i 的值 线程内i=0
iconst_1 				// 线程1-准备常量 1
iadd 					// 线程1-自增 线程内 i=1
putstatic i 			// 线程1-将修改后的值存入静态变量 i 静态变量 i=1
getstatic i 			// 线程1-获取静态变量 i 的值 线程内 i=1
iconst_1 				// 线程1-准备常量 1
isub 					// 线程1-自减 线程内 i=0
putstatic i 			// 线程1-将修改后的值存入静态变量 i 静态变量 i=0

但多线程下这 8 行代码可能交错运行

出现负数的情况

// 假设 i 的初始值为 0
getstatic i 			// 线程1-获取静态变量i的值 线程内 i=0
getstatic i 			// 线程2-获取静态变量i的值 线程内 i=0
iconst_1 				// 线程1-准备常量1
iadd 					// 线程1-自增 线程内 i=1
putstatic i 			// 线程1-将修改后的值存入静态变量 i 静态变量 i=1
iconst_1 				// 线程2-准备常量 1
isub 					// 线程2-自减 线程内 i=-1
putstatic i 			// 线程2-将修改后的值存入静态变量 i 静态变量 i=-1

出现正数的情况

// 假设 i 的初始值为 0
getstatic i 			// 线程1-获取静态变量i的值 线程内 i=0
getstatic i 			// 线程2-获取静态变量i的值 线程内 i=0
iconst_1 				// 线程1-准备常量 1
iadd 					// 线程1-自增 线程内 i=1
iconst_1 				// 线程2-准备常量 1
isub 					// 线程2-自减 线程内 i=-1
putstatic i 			// 线程2-将修改后的值存入静态变量 i 静态变量 i=-1
putstatic i 			// 线程1-将修改后的值存入静态变量 i 静态变量 i=1

20.3.synchronized


synchronized同步关键字

语法

synchronized(对象){
	要作为原子操作代码
}

synchronized 解决并发问题

static int i = 0;
static Object obj = new Object();

public static void main(String[] args) throws InterruptedException {
    Thread t1 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
                i++;
            }
        }
    });
    Thread t2 = new Thread(() -> {
        for (int j = 0; j < 5000; j++) {
            synchronized (obj) {
                i--;
            }
        }
    });
    
    t1.start();
    t2.start();
    
    t1.join();
    t2.join();
    
    System.out.println(i);
}

如何理解呢?

你可以把 obj 想象成一个房间,线程 t1,t2 想象成两个人。

当线程 t1 执行到 synchronized(obj) 时就好比 t1 进入了这个房间,并反手锁住了门,在门内执行 count++ 代码。

这时候如果 t2 也运行到了 synchronized(obj) 时,它发现门被锁住了,只能在门外等待。

当 t1 执行完 synchronized{} 块内的代码,这时候才会解开门上的锁,从 obj 房间出来。

t2 线程这时才可以进入 obj 房间,反锁住门,执行它的 count-- 代码。


注意

  • 上例中 t1 和 t2 线程必须用 synchronized 锁住同一个 obj 对象
  • 如果 t1 锁住的是 m1 对象,t2 锁住的是 m2 对象,就好比两个人分别进入了两个不同的房间,没法起到同步的效果。

20.可见性


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明


可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。

Java 内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是 volatile 变量都是如此。

普通变量与 volatile 变量的区别是,volatile 的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。

因此我们可以说 volatile 保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。


除了 volatile 之外,Java 还有两个关键字能实现可见性,它们是 synchronizedfinal

同步块的可见性是

  • 由 “对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中(执行 storewrite 操作)” 这条规则获得的。

final 关键字的可见性是指

  • final 修饰的字段在构造器中一旦被初始化完成,并且构造器没有把 “this” 的引用传递出去(this 引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到 “初始化了一半” 的对象),那么在其他线程中就能看见 final 字段的值。

如下方代码块所示,变量 i 与 j 都具备可见性,它们无须同步就能被其他线程正确访问。

public static final int i;
public final int j;

static {
    i = 0;
    // 省略后续动作
}

{
    // 也可以选择在构造函数中初始化
    j = 0;
    // 省略后续动作
}

20.1.退不出的循环


先来看一个现象,main 线程对 run 变量的修改对于 t 线程不可见,导致了 t 线程无法停止

static boolean run = true;

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while (run) {
            // ....
        }
    });
    t.start();
    Thread.sleep(1000);
    run = false; // 线程 t 不会如预想的停下来
}

20.2.问题分析


  1. 初始状态,t 线程刚开始从主内存读取了 run 的值到工作内存。

在这里插入图片描述


  1. 因为 t 线程要频繁从主内存中读取 run 的值,所以 JIT 编译器会将 run 的值缓存至自己工作内存中的高速缓存中,减少对主存中 run 的访问,提高效率

在这里插入图片描述


  1. 1 秒之后,main 线程修改了 run 的值(新值),并同步至主存
    t 是从自己工作内存中的高速缓存中读取这个变量的值,结果永远是旧值

在这里插入图片描述


20.3.volatile


20.3.1.可以保证可见性


volatile易变关键字

  • 它可以用来修饰成员变量和静态成员变量
  • 它可以避免线程从自己的工作缓存中查找变量的值
  • 必须到主存中获取它的值,线程操作 volatile 变量都是直接操作主存
/* ***************************************************** */
volatile static boolean run = true;
/* ***************************************************** */

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while (run) {
            // ....
        }
    });
    t.start();

    Thread.sleep(1000);
    run = false; // 线程 t 不会如预想的停下来
}

这个例子体现的实际就是可见性

它保证的是在多个线程之间,一个线程对 volatile 变量的修改对另一个线程可见的。

这个例从字节码理解是这样的:

getstatic run 		// 线程 t 获取 run true
getstatic run 		// 线程 t 获取 run true
getstatic run 		// 线程 t 获取 run true
getstatic run 		// 线程 t 获取 run true
putstatic run 		// 线程 main 修改 run 为 false, 仅此一次
getstatic run 		// 线程 t 获取 run false

20.3.2.不能保证原子性


但是 volatile 是不能保证原子性,它仅用在一个写线程,多个读线程的情况。

此处比较一下之前我们将线程安全时举的例子:

两个线程一个 i++ 一个 i-- ,只能保证看到最新值,不能解决指令交错

// 假设 i 的初始值为 0
getstatic i 		// 线程1-获取静态变量 i 的值 线程内 i=0
getstatic i 		// 线程2-获取静态变量 i 的值 线程内 i=0
iconst_1 			// 线程1-准备常量 1
iadd 				// 线程1-自增 线程内 i=1
putstatic i 		// 线程1-将修改后的值存入静态变量 i 静态变量 i=1
iconst_1 			// 线程2-准备常量 1
isub 				// 线程2-自减 线程内 i=-1
putstatic i 		// 线程2-将修改后的值存入静态变量 i 静态变量 i=-1

注意

synchronized 语句块既可以保证代码块的原子性,也同时保证代码块内变量的可见性。

但缺点是 synchronized 是属于重量级操作,性能相对更低


如果在前面示例的死循环中加入 System.out.println()

static boolean run = true;

public static void main(String[] args) throws InterruptedException {
    Thread t = new Thread(() -> {
        while (run) {
            // ....
            System.out.println(1);
        }
    });
    t.start();

    Thread.sleep(1000);
    run = false; // 线程 t 不会如预想的停下来
}

运行程序后发现:即使不加 volatile 修饰符,线程 t 也能正确看到对 run 变量的修改了,这是为什么?

这是底层中 synchronized 关键字起到的作用

// PrintStream 类
public void println(int x) {
    synchronized (this) {
        print(x);
        newLine();
    }
}

println 方法中添加了 synchronized 关键字的

要对当前的 PrintStream(即打印输出流),做一个同步

同步关键字(synchronized)可以防止当前线程从高速缓存中获取值,强制当前线程(t 线程)去读取主从中值,破坏了 JIT 的优化。


总结:

  • synchronized 可以保证可见性,也可以保证原子性
  • volatile 只能保证可见性,不能保证原子性

21.有序性


参考书籍《深入理解 JAVA 虚拟机 | JVM 高级特性与最佳实践》 周志明

Java 内存模型的有序性在前面讲解 volatile 时也比较详细地讨论过了

Java 程序中天然的有序性可以总结为一句话:

  • 如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。
    • 前半句是指 “线程内似表现为串行的语义”(Within-Thread As-If-Serial Semantics
    • 后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。

Java 语言提供了 volatilesynchronized 两个关键字来保证线程之间操作的有序性

  • volatile 关键字本身就包含了禁止指令重排序的语义,
  • synchronized 则是由 “一个变量在同一个时刻只允许一条线程对其进行 lock 操作” 这条规则获得的
    • 这个规则决定了持有同一个锁的两个同步块只能串行地进入。

21.1.诡异的结果


int num = 0;
boolean ready = false;

// 线程 1 执行此方法
public void actor1(I_Result r) {
    if (ready) {
        r.r1 = num + num;
    } else {
        r.r1 = 1;
    }
}

// 线程 2 执行此方法
public void actor2(I_Result r) {
    num = 2;
    ready = true;
}

I_Result 是一个对象,有一个属性 r1 用来保存结果。

问:可能的结果有几种?

有人这么分析

  • 情况 1:线程 1 先执行,这时 ready = false,所以进入 else 分支结果为 1
  • 情况 2:线程 2 先执行 num = 2,但没来得及执行 ready = true,线程 1 执行,还是进入 else 分支,结果为 1
  • 情况 3:线程 2 执行到 ready = true,线程 1 执行,这回进入 if 分支,结果为 4(因为 num 已经执行过了)

但是还有一种情况:结果还有可能是 0

这种情况下是:线程 2 执行 ready = true,切换到线程 1,进入 if 分支,相加为 0,再切回线程 2 执行 num = 2

这种现象叫做指令重排,是 JIT 编译器在运行时的一些优化,这个现象需要通过大量测试才能复现。


这就需要借助到 java 并发压测工具 jcstress 了。

首先使用下面的命令创建一个骨架项目

mvn archetype:generate -DinteractiveMode=false -
DarchetypeGroupId=org.openjdk.jcstress -DarchetypeArtifactId=jcstress-java-test-archetype -DgroupId=org.sample -DartifactId=test -Dversion=1.0

之后提供如下测试类

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

//@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

    int num = 0;
    boolean ready = false;

    @Actor
    public void actor1(I_Result r) {
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }

}

执行

mvn clean install
java -jar target/jcstress.jar

会输出我们感兴趣的结果,摘录其中一次结果

*** INTERESTING tests
	Some interesting behaviors observed. This is for the plain curiosity.
  
	2 matching test results.
		[OK] test.ConcurrencyTest
	  (JVM args: [-XX:-TieredCompilation])
	Observed state		Occurrences  				 Expectation 		Interpretation
  			     0  		  1,729       ACCEPTABLE_INTERESTING 		!!!!
			     1 		 42,617,915  			      ACCEPTABLE 		ok
			     4 		  5,146,627 			 	  ACCEPTABLE 		ok
			   
			   
		[OK] test.ConcurrencyTest
      (JVM args: [])
	Observed state		Occurrences 				Expectation  		Interpretation
				 0 		   	  1,652 	ACCEPTABLE_INTERESTING   		!!!!
				 1    	 46,460,657  				ACCEPTABLE 	 		ok
				 4     	  4,571,072 				ACCEPTABLE   		ok

可以看到,出现结果为 0 的情况有 638 次,虽然次数相对很少,但毕竟是出现了。


21.2.解决办法


volatile 修饰的变量,可以禁用指令重排

import org.openjdk.jcstress.annotations.*;
import org.openjdk.jcstress.infra.results.I_Result;

//@JCStressTest
@Outcome(id = {"1", "4"}, expect = Expect.ACCEPTABLE, desc = "ok")
@Outcome(id = "0", expect = Expect.ACCEPTABLE_INTERESTING, desc = "!!!!")
@State
public class ConcurrencyTest {

    int num = 0;
    /* ************************************* */
    volatile boolean ready = false;
    /* ************************************* */

    @Actor
    public void actor1(I_Result r) {
        if (ready) {
            r.r1 = num + num;
        } else {
            r.r1 = 1;
        }
    }

    @Actor
    public void actor2(I_Result r) {
        num = 2;
        ready = true;
    }

}

结果

*** INTERESTING tests
	Some interesting behaviors observed. This is for the plain curiosity.


	0 matching test results.

21.3.有序性理解


JVM 会在不影响正确性的前提下,可以调整语句的执行顺序,思考下面一段代码

static int i;
static int j;

// 在某个线程内执行如下赋值操作
i = ...; // 较为耗时的操作
j = ...;

可以看到,至于是先执行 i 还是 先执行 j ,对最终的结果不会产生影响。

所以,上面代码真正执行时,既可以是

i = ...; // 较为耗时的操作
j = ...;

也可以是

j = ...;
i = ...; // 较为耗时的操作

这种特性称之为 指令重排,多线程下 指令重排 会影响正确性

例如著名的 double-checked locking 模式实现单例

public final 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 加锁,后续使用时无需加锁

但在多线程环境下,上面的代码是有问题的, INSTANCE = new Singleton() 对应的字节码为

0: new 										#2 // class cn/itcast/jvm/t4/Singleton
3: dup
4: invokespecial 							#3 // Method "<init>":()V
7: putstatic 								#4 // Field
INSTANCE:Lcn/itcast/jvm/t4/Singleton;

其中 4 7 两步的顺序不是固定的。

也许 JVM 会优化为:先将引用地址赋值给 INSTANCE 变量后,再执行构造方法

如果之后两个线程 t1t2 按如下时间序列执行:

时间1 t1 线程执行到 INSTANCE = new Singleton();

时间2 t1 线程分配空间,为 Singleton 对象生成了引用地址(0 处)

时间3 t1 线程将引用地址赋值给 INSTANCE,这时 INSTANCE != null(7 处)

时间4 t2 线程进入 getInstance() 方法,发现 INSTANCE != null(synchronized 块外),直接返回 INSTANCE

时间5 t1 线程执行Singleton的构造方法(4 处)

这时 t1 还未完全将构造方法执行完毕,如果在构造方法中要执行很多初始化操作,那么 t2 拿到的是将是一个未初始化完毕的单例

INSTANCE 使用 volatile 修饰即可,可以禁用指令重排,但要注意在 JDK 5 以上的版本的 volatile 才会真正有效


21.4.happens-before


happens-before 规定了哪些写操作对其它线程的读操作可见,它是可见性与有序性的一套规则总结

抛开以下 happens-before 规则,JMM 并不能保证一个线程对共享变量的写,对于其它线程对该共享变量的读可见

  • 线程解锁 m 之前对变量的写,对于接下来对 m 加锁的其它线程对该变量的读可见
static int x;
static Object m = new Object();

new Thread(()->{
	synchronized(m) {
		x = 10;
	}
},"t1").start();

new Thread(()->{
	synchronized(m) {
		System.out.println(x);
	}
},"t2").start();
  • 线程对 volatile 变量的写,对接下来其它线程对该变量的读可见
volatile static int x;

new Thread(()->{
	x = 10;
},"t1").start();

new Thread(()->{
	System.out.println(x);
},"t2").start();
  • 线程 start 前对变量的写,对该线程开始后对该变量的读可见
static int x;
x = 10;

new Thread(()->{
	System.out.println(x);
},"t2").start();
  • 线程结束前对变量的写,对其它线程得知它结束后的读可见(比如其它线程调用 t1.isAlive()t1.join() 等待它结束)
static int x;

Thread t1 = new Thread(()->{
	x = 10;
},"t1");

t1.start();
t1.join();

System.out.println(x);
  • 线程 t1 打断 t2interrupt)前对变量的写,对于其他线程得知 t2 被打断后对变量的读可见(通过 t2.interruptedt2.isInterrupted
static int x;

public static void main(String[] args) {

	Thread t2 = new Thread(()->{
		while(true) {
			if(Thread.currentThread().isInterrupted()) {
				System.out.println(x);
				break;
			}
		}
	},"t2");
	
	t2.start();
	
	new Thread(()->{
		try {
			Thread.sleep(1000);
		} catch (InterruptedException e) {
			e.printStackTrace();
		}
		x = 10;
		t2.interrupt();
	},"t1").start();


	while(!t2.isInterrupted()) {
		Thread.yield();
	}

	System.out.println(x);
}
  • 对变量默认值(0,falsenull)的写,对其它线程对该变量的读可见

  • 具有传递性,如果 x hb-> y 并且 y hb-> z 那么有 x hb-> z

这套规则里说的变量都是指 成员变量 或 静态成员变量

22.CAS 与 原子类


22.1.CAS


CASCompare and Swap ,它体现的一种乐观锁的思想,比如多个线程要对一个共享的整型变量执行 +1 操作

// 需要不断尝试
while(true) {
	int 旧值 = 共享变量 ; // 比如拿到了当前值 0
	int 结果 = 旧值 + 1; // 在旧值 0 的基础上增加 1 ,正确结果是 1
	
	/*
	这时候如果别的线程把共享变量改成了 5,本线程的正确结果 1 就作废了,这时候
	compareAndSwap 返回 false,重新尝试,直到:
	compareAndSwap 返回 true,表示我本线程做修改的同时,别的线程没有干扰
	*/	
	if( compareAndSwap ( 旧值, 结果 )) {
		// 成功,退出循环
	}
}

获取共享变量时,为了保证该变量的可见性,需要使用 volatile 修饰。

结合 CASvolatile 可以实现无锁并发,适用于竞争不激烈、多核 CPU 的场景下。

  • 因为没有使用 synchronized,所以线程不会陷入阻塞,这是效率提升的因素之一
  • 但如果竞争激烈,可以想到重试必然频繁发生,反而效率会受影响

CAS 底层依赖于一个 Unsafe 类来直接调用操作系统底层的 CAS 指令

下面是直接使用 Unsafe 对象进行线程安全保护的一个例子(了解即可)

import sun.misc.Unsafe;
import java.lang.reflect.Field;

public class TestCAS {
	public static void main(String[] args) throws InterruptedException {
		DataContainer dc = new DataContainer();
		int count = 5;
		
		Thread t1 = new Thread(() -> {
			for (int i = 0; i < count; i++) {
				dc.increase();
			}
		});
	
		t1.start();
		t1.join();
	
		System.out.println(dc.getData());
	}
}

class DataContainer {
	private volatile int data;
	static final Unsafe unsafe;
	static final long DATA_OFFSET;
	
	static {
		try {
			// Unsafe 对象不能直接调用,只能通过反射获得
			Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
			theUnsafe.setAccessible(true);
			unsafe = (Unsafe) theUnsafe.get(null);
		} catch (NoSuchFieldException | IllegalAccessException e) {
			throw new Error(e);
		}
		
		try {
			// data 属性在 DataContainer 对象中的偏移量,用于 Unsafe 直接访问该属性
			DATA_OFFSET = unsafe.objectFieldOffset(DataContainer.class.getDeclaredField("data"));
		} catch (NoSuchFieldException e) {
			throw new Error(e);
		}
	}
	
	public void increase() {
		int oldValue;
		
		while(true) {
			// 获取共享变量旧值,可以在这一行加入断点,修改 data 调试来加深理解
			oldValue = data;
			// cas 尝试修改 data 为 旧值 + 1,如果期间旧值被别的线程改了,返回 false
			if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue + 1)) {
				return;
			}
		}
	}
	
	public void decrease() {
		int oldValue;
		while(true) {
			oldValue = data;
			if (unsafe.compareAndSwapInt(this, DATA_OFFSET, oldValue, oldValue - 1)) {
				return;
			}
		}
	}
	
	public int getData() {
		return data;
	}
}

22.2.乐观锁与悲观锁


CAS 是基于乐观锁的思想

  • 最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。

synchronized 是基于悲观锁的思想

  • 最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。

22.3.原子操作类


jucjava.util.concurrent)中提供了原子操作类,可以提供线程安全的操作

例如:AtomicIntegerAtomicBoolean 等,它们底层就是采用 CAS 技术 + volatile 来实现的。

可以使用 AtomicInteger 改写之前的例子:

// 创建原子整数对象
private static AtomicInteger i = new AtomicInteger(0);

public static void main(String[] args) throws InterruptedException {

	Thread t1 = new Thread(() -> {
		for (int j = 0; j < 5000; j++) {
			i.getAndIncrement(); // 获取并且自增 i++
			// i.incrementAndGet(); // 自增并且获取 ++i
		}
	});
	
	Thread t2 = new Thread(() -> {
		for (int j = 0; j < 5000; j++) {
			i.getAndDecrement(); // 获取并且自减 i--
		}
	});
	
	t1.start();
	t2.start();
	t1.join();
	t2.join();
	
	System.out.println(i);
}

输出结果是 0


23. synchronized 优化


Java HotSpot 虚拟机中

  • 每个对象都有对象头(包括 class 指针和 Mark Word)。
  • Mark Word 平时存储这个对象的哈希码分代年龄

当加锁时,这些信息就根据情况被替换为标记位线程锁记录指针重量级锁指针线程 ID 等内容


23.1.轻量级锁


如果一个对象虽然有多线程访问,但多线程访问的时间是错开的(也就是没有竞争),那么可以使用轻量级锁来优化。


这就好比:

学生(线程 A)用课本占座,上了半节课,出门了(CPU时间到),回来一看,发现课本没变,说明没有竞争,继续上他的课。

如果这期间有其它学生(线程 B)来了,会告知(线程 A)有并发访问,线程 A 随即升级为重量级锁,进入重量级锁的流程。

而重量级锁就不是那么用课本占座那么简单了,可以想象线程 A 走之前,把座位用一个铁栅栏围起来

假设有两个方法同步块,利用同一个对象加锁

static Object obj = new Object();

public static void method1() {
	synchronized( obj ) {
		// 同步块 A
		method2();
	}
}

public static void method2() {
	synchronized( obj ) {
		// 同步块 B
	}
}

每个线程都的栈帧都会包含一个锁记录的结构,内部可以存储锁定对象的 Mark Word

线程 1对象 Mark Word线程 2
访问同步块 A,把 Mark 复制到线程 1 的锁记录01(无锁)-
CAS 修改 Mark 为线程 1 锁记录地址01(无锁)-
成功(加锁)00(轻量锁)线程 1 锁记录地址-
执行同步块 A00(轻量锁)线程 1 锁记录地址-
访问同步块 B,把 Mark 复制到线程 1 的锁记录00(轻量锁)线程 1 锁记录地址-
CAS 修改 Mark 为线程 1 锁记录地址00(轻量锁)线程 1 锁记录地址-
失败(发现是自己的锁)00(轻量锁)线程 1 锁记录地址-
锁重入00(轻量锁)线程 1 锁记录地址-
执行同步块 B00(轻量锁)线程 1 锁记录地址-
同步块 B 执行完毕00(轻量锁)线程 1 锁记录地址-
同步块 A 执行完毕00(轻量锁)线程 1 锁记录地址-
成功(解锁) 01(无锁)-
-01(无锁)访问同步块 A,把 Mark 复制到线程 2 的锁记录
-01(无锁)CAS 修改 Mark 为线程 2 锁记录地址
-00(轻量锁)线程 2 锁记录地址 成功(加锁)
-

23.2.锁膨胀


如果在尝试加轻量级锁的过程中,CAS 操作无法成功

这时一种情况就是有其它线程为此对象加上了轻量级锁(有竞争),这时需要进行锁膨胀,将轻量级锁变为重量级锁。

static Object obj = new Object();

public static void method1() {
	synchronized( obj ) {
		// 同步块
	}
}
线程 1对象 Mark线程 2
访问同步块,把 Mark 复制到线程 1 的锁记录01(无锁)-
CAS 修改 Mark 为线程 1 锁记录地址01(无锁)-
成功(加锁)00(轻量锁)线程 1 锁记录地址-
执行同步块00(轻量锁)线程 1 锁记录地址-
执行同步块00(轻量锁)线程 1 锁记录地址访问同步块,把 Mark 复制到线程 2
执行同步块00(轻量锁)线程 1 锁记录地址CAS 修改 Mark 为线程 2 锁记录地址
执行同步块00(轻量锁)线程 1 锁记录地址失败(发现别人已经占了锁)
执行同步块00(轻量锁)线程 1 锁记录地址CAS 修改 Mark 为重量锁
执行同步块10(重量锁)重量锁指针阻塞中
执行完毕10(重量锁)重量锁指针阻塞中
失败(解锁)10(重量锁)重量锁指针阻塞中
释放重量锁,唤起阻塞线程竞争01(无锁)阻塞中
-10(重量锁)竞争重量锁
-10(重量锁)成功(加锁)
-

23.3.重量锁


重量级锁竞争的时候,还可以使用自旋来进行优化。

如果当前线程自旋成功(即这时候持锁线程已经退出了同步块,释放了锁),这时当前线程就可以避免阻塞。


Java 6 之后自旋锁是自适应的。

比如对象刚刚的一次自旋操作成功过,那么认为这次自旋成功的可能性会高,就多自旋几次;

反之,就少自旋甚至不自旋,总之,比较智能。

  • 自旋会占用 CPU 时间,单核 CPU 自旋就是浪费,多核 CPU 自旋才能发挥优势。
  • 好比等红灯时汽车是不是熄火,不熄火相当于自旋(等待时间短了划算),熄火了相当于阻塞(等待时间长了划算)
  • Java 7 之后不能控制是否开启自旋功能

自旋重试成功的情况

线程 1 (cpu 1 上)对象 Mark线程 2 (cpu 2 上)
-10(重量锁)-
访问同步块,获取 monitor10(重量锁)重量锁指针-
成功(加锁)10(重量锁)重量锁指针-
执行同步块10(重量锁)重量锁指针-
执行同步块10(重量锁)重量锁指针访问同步块,获取 monitor
执行同步块10(重量锁)重量锁指针自旋重试
执行完毕 10(重量锁)重量锁指针自旋重试
成功(解锁)01(无锁)自旋重试
- 10(重量锁)重量锁指针成功(加锁)
- 10(重量锁)重量锁指针执行同步块
-

自旋重试失败的情况

线程 1(cpu 1 上)对象 Mark线程 2(cpu 2 上)
-10(重量锁)-
访问同步块,获取 monitor10(重量锁)重量锁指针-
成功(加锁)10(重量锁)重量锁指针-
执行同步块10(重量锁)重量锁指针-
执行同步块10(重量锁)重量锁指针访问同步块,获取 monitor
执行同步块10(重量锁)重量锁指针自旋重试
执行同步块10(重量锁)重量锁指针自旋重试
执行同步块10(重量锁)重量锁指针自旋重试
执行同步块10(重量锁)重量锁指针阻塞
-

23.4.偏向锁


轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。

Java 6 中引入了偏向锁来做进一步优化:

只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS

  • 撤销偏向需要将持锁线程升级为轻量级锁,这个过程中所有线程需要暂停(STW
  • 访问对象的 hashCode 也会撤销偏向锁
  • 如果对象虽然被多个线程访问,但没有竞争,这时偏向了线程 T1 的对象仍有机会重新偏向 T2,重偏向会重置对象的 Thread ID
  • 撤销偏向和重偏向都是批量进行的,以类为单位
  • 如果撤销偏向到达某个阈值,整个类的所有对象都会变为不可偏向的
  • 可以主动使用 -XX:-UseBiasedLocking 禁用偏向锁

具体情况可以参考这篇论文:https://www.oracle.com/technetwork/java/biasedlocking-oopsla2006-wp-149958.pdf

假设有两个方法同步块,利用同一个对象加锁

static Object obj = new Object();

public static void method1() {
	synchronized( obj ) {
		// 同步块 A
		method2();
	}
}

public static void method2() {
	synchronized( obj ) {
		// 同步块 B
	}
}
线程 1对象 Mark
访问同步块 A,检查 Mark 中是否有线程 ID101(无锁可偏向)
尝试加偏向锁101(无锁可偏向)对象 hashCode
成功101(无锁可偏向)线程 ID
执行同步块 A101(无锁可偏向)线程 ID
访问同步块 B,检查 Mark 中是否有线程 ID101(无锁可偏向)线程 ID
是自己的线程 ID,锁是自己的,无需做更多操作101(无锁可偏向)线程 ID
执行同步块 B101(无锁可偏向)线程 ID
执行完毕101(无锁可偏向)对象 hashCode

23.5.其它优化


23.5.1.减少上锁时间


同步代码块中尽量短


23.5.2.减少锁的粒度


将一个锁拆分为多个锁提高并发度

例如:

  • ConcurrentHashMap
  • LongAdder 分为 basecells 两部分。
    • 没有并发争用的时候或者是 cells 数组正在初始化的时候,会使用 CAS 来累加值到 base
    • 有并发争用,会初始化 cells 数组,数组有多少个 cell,就允许有多少线程并行修改
      最后将数组中每个 cell 累加,再加上 base 就是最终的值
  • LinkedBlockingQueue 入队和出队使用不同的锁,相对于 LinkedBlockingArray 只有一个锁效率要高

23.5.3.锁粗化


多次循环进入同步块不如同步块内多次循环

另外 JVM 可能会做如下优化,把多次 append 的加锁操作粗化为一次(因为都是对同一个对象加锁,没必要重入多次)

new StringBuffer().append("a").append("b").append("c");

23.5.4.锁消除


JVM 会进行代码的逃逸分析

例如某个加锁对象是方法内局部变量,不会被其它线程所访问到。这时候就会被即时编译器忽略掉所有同步操作。


23.5.5.读写分离


CopyOnWriteArrayListConyOnWriteSet


参考文章链接


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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值