侧边栏壁纸
博主头像
Dioxide-CN博主等级

茶边话旧,看几许星迢露冕,从淮海南来。

  • 累计撰写 51 篇文章
  • 累计创建 49 个标签
  • 累计收到 21 条评论

目 录CONTENT

文章目录

Java高级编程:volatile 与 synchronized

Dioxide-CN
2022-10-27 / 0 评论 / 7 点赞 / 125 阅读 / 8,578 字
温馨提示:
本文最后更新于 2022-10-28,若内容或图片失效,请留言反馈。部分素材来自网络,若不小心影响到您的利益,请联系我们删除。

volatile 与 sychornized

1 volatile 关键字概览

1.1 多线程下变量的不可见性

1.1.1 概述

在多线程并发执行下,多个线程修改共享的成员变量,会出现一个线程修改了共享变量的值后,另一个线程不能直接看到修改后的变量的最新值。

1.1.2 常见案例

public class VolatileExample {
    public static void main(String[] args) {
        Threads th = new Threads();
        th.start();
        
        while(true) {
	        // 无法读取子线程修改后的值
            if (th.getFlag()) {
                System.out.println("主线程访问到 flag 变量");
            }
        }
    }
}

class Threads extends Thread {
    private boolean flag = false;
    
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        // 在线程内部修改 flag 的值
        flag = true;
    }
    
    public boolean getFlag() {
        return flag;
    }
}

1.2 变量不可见性内存语义

概述:在介绍多线程并发修改变量不可见现象的原因之前,需要了解回顾一下 Java 的内存模型(和 Java 并发编程有关的模型):JMM(Java Memory Model)
JMM:Java 内存模型,是 Java 虚拟机规范中所定义的一种内存模型,Java 内存模型是标准化的,屏蔽掉了底层不用计算机的区别。
Java 内存模型描述了 Java 程序中各种变量(线程共享变量)的访问规则,以及在 JVM 中将变量存储到内存和从内存中读取变量这样的底层细节。

JMM 具有以下规定:

  • 所有的共享变量都存储于主内存。这里所说的变量指的是实例变量和类变量。不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。
  • 每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本
  • 线程对变量的所有的操作(读、取)都必须在工作内存中完成,而不能直接读写主内存中的变量
  • 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

本地内存和主存的关系:
image-1666920818444

问题分析:

子线程tt 工作内存主内存主线程mainmain 工作内存flag = falseflag = trueflag = falseflag = trueflag = false
  1. 子线程 t 从主内存读取到数据放入其对应的工作内存
  2. 将 flag 的值更改为 true,但是这个时候 flag 的值还没有写回主内存
  3. 此时 main 方法读取到了 flag 的值为 false
  4. 当子线程 t 将 flag 的值歇会去后,但是 main 函数里面的 while(true) 调用的是系统中更底层的代码,速度快,快到没有时间再去读取主内存中的值

所以 while(true) 读取到的值一直是 false。(如果有一个时刻 main 线程从主内存中读取到了主内存中 flag 的最新值,那么 if 语句就可以执行,main 线程何时从主内存中读取最新的值是无法控制的)

1.3 变量不可见性解决方案

概述:如何实现在多线程下访问共享变量的可见性:也就是实现一个线程修改变量后,对其他线程可见?

  1. 方案一:使用 synchronized 关键字加锁
  2. 方案二:使用 volatile 关键字
    其中,volatile 保证不同线程对共享变量操作的可见性,也就是说一个线程修改了 volatile 修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。所以,volatile 修饰的变量可以在多线程并发修改下,实现线程间变量的可见性

2 volatile 的其他特性

2.1 volatile 特性概述

volatile 总体概览:volatile 可以实现并发下共享变量的可见性,除了 volatile 可以保证可见性外,volatile 还具备如下一些突出的特性:

  1. volatile 的原子性问题:volatile 补鞥呢保证原子性操作
  2. 禁止指令重排序:volatile 可以防止指令重排序操作

2.2 volatile 不保证原子性

原子性的定义:所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都得到了执行并且不会受到任何因素的干扰而中断,要么所有的操作都不执行

public class VolatileAtomicThread {  
    public static void main(String[] args) {  
        Runnable th = new TestThread();  
        for (int i = 1; i <= 100; i++) {  
            new Thread(th, "第"+i+"个线程").start();  
        }
    }
}
  
class TestThread implements Runnable {
    private int count = 0;
	
    @Override
    public void run() {  
        // 对变量进行 100 次自增操作  
        for (int i = 1; i <= 10000; i++) {  
            count++;  
            System.out.println(Thread.currentThread().getName() + " count >>> " + count);  
        }  
    }  
}

该段代码并不能保证结果一定是 1000000 可能是 999988 也可能是 999991 被 volatile 修饰的变量 count 为什么不能保障线程安全?

  • count++ 的操作本身就不具有原子性:自增的操作包括读取自身的值并对自身增加 1 是两个原子性操作的组合,而任意多次原子操作组成的操作集合并不具备原子性。
  • 正因为 count++ 不是一个圆形操作,也就意味着在对其中任意一步的原子性操作的同时都有可能会被其他线程中的原子操作阻塞导致内存不一致。

对多线程操作一个变量的过程进行剖析为什么会导致 2 次操作结果只有 1 次:

  1. 假设此时变量 x 的值是 100,线程 A 需要对该变量进行自增(x++)的操作,首先它需要从主内存中读取变量 x 的值。由于 CPU 的切换关系,此时 CPU 的执行权被切换到了线程 B 。A 线程就处于被阻塞状态,B 线程则处于运行状态。
  2. 线程 B 也需要从主内存中读取 x 变量的值,由于线程 A 还没有来得及对 x 值做任何修改因此此时 B 读取到的数据还是 100。
  3. 线程 B 工作内存中 x 执行了 +1 操作,但是还未来得及刷新到主内存中。
  4. 此时 CPU 的执行权又切换到了 A 线程上,由于此时线程 B 没有将工作内存中的数据刷新到主内存,因此 A 线程工作内存中的变量值还是 100,没有失效。
  5. A 线程对工作内存中的数据进行了 +1
  6. 线程 B 将 101 写入到主内存中。
  7. 线程 A 将 101 写入到主内存中。
  8. 虽然两个线程共计算了两次,但是只对A进行了1次的修改。

小结,volatile 在多线程中不具有原子性,但是在单线程单个读写操作下:

  1. 如果被操作对象的操作不具备原子性,则被操作对象的 volatile 也无法体现原子性。
  2. 如果被操作对象的操作具备原子性,则被操作对象的 volatile 也体现原子性。

所以在多线程环境下,如果想要保证数据的安全性,最好的解决方案就是加锁使用原子类对象

  1. 加锁
    上述案例中对 count 对象加入 synchronized 锁,那么 count++ 操作就是一个临界区的代码,临界区代码只能被一个线程占用(其中的原子操作无法被其他线程阻塞),所以 count++ 就又具有了原子性(可以一次性执行完成)。
synchronized (TestThread.class) {  
    count++;  
    System.out.println(...);  
}

此时就可以将 volatile 修饰去掉,因为这里的 synchronized 既保证了可见性有保证了原子性。

  1. 使用原子类对象
    从 JDK1.5 开始 Java 提供了 java.util.concurrent.atomic 包来管理所有原子类,这个包中的原子操作提供了一种用法更简单,性能更高效,线程更安全的一种变量更新方法。上述案例就可以将 count 改写为 AtomicInteger 类来保障原子性:
private AtomicInteger count = new AtomicInteger(0);

... for ...
	count.incrementAndGet(); // 通过原子操作来自增
...

2.3 volatile 禁止指令重排序

重排序:为了提高性能,编译器和处理器尝尝会对既定的代码的执行顺序进行指令重排序。
为什么要重排序:一个好的内存模型实际上会放松对处理器和编译器规则的舒服,也就是说软件技术和硬件技术都为同一个目标而进行奋斗 -> 在不改变程序执行结果的前提下,尽可能条执行效率。JMM 对底层尽量减少约束,使其能够发挥自身优势。因此,在执行程序时,为了提高性能,编译器和处理器尝尝会对指令进行重排序。一般重排序分为以下三种:

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序:线代处理器采用了指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序中执行的。
源代码1:编译器优化重排序2:指令级并行重排序3:内存系统重排序最终执行的指令序列

重排序的好处:重排序可以提高处理的速度。

a = 3; b = 2; a = a + 1;
/** 不进行重排序 **/
1. -> Load a -> Set to 3 -> Store a
2. -> Load b -> Set to 2 -> Store b
3. -> Load a -> Set to 4 -> Store a

a = 3; a = a + 1; b = 2;
/** 重排序 **/
1. -> Load a -> Set to 3 -> Set to 4 -> Store a
2. -> Load b -> Set to 2 -> Store b

重排序虽然可以提高执行的效率,但是在并发执行下,JVM 虚拟机底层并不能保证重排序下带来的安全性等问题

public class VolatileAtomicThread {  
    public static int i = 0, j = 0;  
    public static int a = 0, b = 0;  
	  
    public static void main(String[] args) throws InterruptedException {  
        int count = 0;  
        while (true) {  
            count++;  
            i = 0; j = 0; a = 0; b = 0;  
			  
            Thread one = new Thread(() -> {  
                a = 1; i = b;  
            });  
			  
            Thread two = new Thread(() -> {  
                b = 1; j = a;  
            });  
			  
            one.start(); two.start();  
            one.join(); two.join();  
			  
            String result = "第" + count + "次 ( i=" + i + ", j=" + j + " )";  
            System.out.println(result);  
            if (i == 0 && j == 0) {  
                break;  
            }  
        }  
    }  
}

可能出现的结果及其重排序后的情况:

  1. i=0, j=1:a=1 i=b(0) b=1 j=a(1)
  2. i=1, j=0:b=1 j=a(0) a=1 i=b(1)
  3. i=1, j=1:b=1 a=1 i=b(1) j=a(1)
  4. i=0, j=0:i=b(0) j=a(0) a=1 b=1
第1次 ( i=0, j=1 )
第2次 ( i=0, j=1 )
...
第2751次 ( i=0, j=1 )
第2752次 ( i=1, j=0 )
...
第2761次 ( i=0, j=1 )
第2762次 ( i=0, j=0 )

Process finished with exit code 0

按照以前的观点:代码执行的顺序是不会改变的,也就是第一个线程是 a=1 是在 i=b 之前执行的,第二个线程 b=1 是在 j=a 之前执行的。
发生了重排序:在线程1和线程2内部的两行代码的实际执行顺序和代码在 Java 文件中的顺序是不一致的,代码指令并不是严格按照代码顺序执行的,他们的顺序改变了,即发生了重排序。
但是使用 volatile 来修饰 i j a b 之后可以禁止重排序,从而实现业务的安全性保障线程安全。其中 volatile 又是通过 内存屏障(内存栅栏) 方法来实现了禁用重排序的功能。

3 volatile 内存语义

3.1 volatile 读写建立的 happens-before 关系

3.1.1 概述

为了提高速度,JVM 会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患,如:指令重排序导致的多个线程之间的不可见性
从 JDK1.5 开始,提出了 happens-before 的概念,通过这个概念来阐述操作之间的内存可见性。如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在 happens-before 关系。这里提到的两个操作既可以是在一个线程之内,也可以是在不同线程之间。

所以为了解决多线程的可见性问题,就推出了 happens-before 原则,让线程之间遵守这些原则。编译器还会优化我们的语句,所以等于是给了编译器优化的约束。不能让它优化的过分了。
简单的说:happens-before 应该翻译成 -> 前一个操作的结果可以被后续的操作获取。将白点就是前面一个操作变量 a 赋值为 1,那么后面的一个操作肯定能知道 a 已经变成了 1。

3.1.2 happens-before 规则

具体的一共有 6 大规则和性质:

  1. 程序顺序规则(单线程规则)
    • 解释:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
      • 同一个线程中前面的所有写操作对后面的操作可见。
  2. 锁规则(synchronized, Lock等)
    • 解释:堆一个锁的解锁,happens-before 于随后对这个锁的加锁。
      • 如果线程1解锁了 monitor a,接着线程2锁定了 monitor a,那么,线程1解锁a之前的写操作对线程2都可见(线程1和线程2可以是同一个线程)。
  3. volatile 变量规则
    • 解释:堆一个 volatile 域的写操作,happens-before 于任意后续对这个 volatile 域的读操作。
      • 如果线程1写入了 volatile 变量 v(临界资源),接着线程2读取了 v,那么,线程1写入 v 及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)。
  4. start() 规则
    • 解释:如果线程A执行启动线程B的操作 ThreadB.start(),那么A线程的 ThreadB.start() 操作 happens-before 于线程B中的任意操作。
      • 假定线程A在执行过程中,通过执行 ThreadB.start() 来启东线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。主意:线程B启动之后,线程A再对变量进行修改的操作对线程B未必可见。
  5. join() 规则
    • 解释:如果线程A执行操作 ThreadB.join() 并成功返回,那么线程B中的任意操作都 happens-before 于线程A从 ThreadB.join() 操作成功返回。
      • 线程A写入的所有变量,在任意其他线程B调用 A.join(),或者 A.isAlive() 成功返回后,都对 B 可见。
  6. 传递性
    • 解释:如果 A happens-before B,且 B happens-before C,那么 A happens-before C,即 (A h-b B) + (B h-b C) -> (A h-b C)

3.1.3 volatile 写读建立的 happens-before 规则

happens-before 有一个原则是:如果A是对 volatile 变量的写操作,B 是对同一个变量的读操作,则有 hb(A,B):

public class HappensBefore {  
    int a = 1, b = 2;  
	  
    protected void write() {  
        a = 3;  
        b = a;  
    }  
	  
    protected void read() {  
        System.out.println("b="+b+";a="+a);  
    }  
	  
    public static void main(String[] args) {  
        while (true) {  
            HappensBefore hb = new HappensBefore();  
            new Thread(hb::write).start();  
            new Thread(hb::read).start();  
        }  
    }  
}

对可能会出现的情况进行分析:

  1. b=3;a=3:write() 方法正常串行
  2. b=2;a=1:先执行了第二个读线程再执行了第一个写线程
  3. b=2;a=3:先执行了一半的读线程再执行了写线程最后再执行剩下的读线程
    读操作先执行线程延迟了一会儿,b=2进了常量池读进了println里面,写线程的操作太快,a=3在读a的引用之前又进了常量池所以读到了a=3
  4. b=3;a=1:没有 volatile 修饰的 b 导致了 a=3 操作不可见无法感知 a 的变化

针对第四种情况,依据 happens-before 原则,只需要给 b 加上 volatile 修饰符,那么 b 之前的写入操作将对读取 b 之后的代码可见,也就是说即使 a 不加 volatile,只要 b 读取到 3,那么 b 之前的操作就一定是可见的,此时就绝对不会出现 b=3 a=1 的情况了。

volatile 发生重排序的情况:

操作1(下) 操作2(右) 普通读/写 volatile读 volatile写
普通读/写 不允许
volatile读 不允许 不允许 不允许
volatile写 不允许 不允许
  • volatile 变量时,无论一个操作是什么,都不能重排序
  • volatile 变量时,无论一个操作是什么,都不能重排序
  • 先写 volatile 变量,后读 volatile 变量时,不能重排序

4 高频面试与总结

4.1 long 和 double 的原子性

概述:在 Java 中,long 和 double 都是8个字节共64位,那么如果是一个32位的系统,读写 long 或 double 的变量时会涉及到原子性问题,因为32位系统要读完一个64位的变量需要分两步执行,每次读取32位,这样对 double 和 long 变量的赋值操作就会出现问题:如果两个线程同时写一个变量内存,一个进程写入低32位、而另一个线程写入高32位,这样就会导致最终的64位数据是无效的。

结论:如果是在64位的系统中,那么对64位的 long 和 double 的读写都是原子操作,即可以以一次性读写 long 或 double 的整个64bit。如果在32位的 JVM 上,long 和 double 就不是原子性操作。解决方案:需要使用 volatile 关键字来防止此类现象

  • 对于64位的 long 和 double,如果没有被 volatile 修饰,那么对其操作可以不是原子的。在操作的时候可以分成两步,每次对32位操作。
  • 如果使用 volatile 修饰 long 和 double,那么其读写都是原子操作。
  • 在实现 JVM 时,可以自由选择是否把读写 long 和 double 作为原子操作。
  • Java 中对于 long 和 double 类型的写操作不是原子操作,而是分成了高低32位两次写操作。读操作是否也分成了两个32位的读呢?在 JSR-133 之前的规范中,读也是分成了两个32位读,但是从 JSR-133(JDK1.5) 规范开始读操作也具有原子性。
  • Java 中对于其他类型的读写操作都是原子操作(除了 long 和 double 类型以外)。
  • 对于引用类型的读写操作都是原子操作,无论引用类型的世纪类型是32位的值还是64位的值。
  • Java 商业虚拟机已经解决了 long 和 double 的读写操作的原子性问题。

4.2 volatile 在双重检查加锁的单例中的应用

4.2.1 单例概述

单例是需要在内存中永远只能创建一个类的实例。单例对象的作用是:节约内存和保证共享计算的结果正确,以及方便管理。
单例模式的适用场景:

  • 全局信息类:例如任务管理器对象,或者徐亚一个对象记录整个网站的在线流量等信息。
  • 无状态工具类:类似于整个系统的入职对象等,我们只需要一个单例日志对象负责记录,管理系统日志信息。

8 种单例模式:

  1. 饿汉式单例:在获取单例对象之前对象已经创建完成。
  2. 懒汉式单例:在获取对象的时候才进行对象的创建。

4.2.2 饿汉式单例

  1. 饿汉式(静态常量)
public class Singeton01 {
	private static final Singleton01 INSTANCE = new Singleton01();
	
	private Singeton01() {}
	
	public static Singeton01 getInstance() {
		return INSTANCE;
	}
}
  1. 饿汉式(静态代码块)
public class Singeton02 {
	private static final Singleton02 INSTANCE;
	
	static {
		INSTANCE = new Singeton02();
	}
	
	private Singeton02() {}
	
	public static Singeton02 getInstance() {
		return INSTANCE;
	}
}

4.2.3 懒汉式单例

  1. 懒汉式(线程不安全)
public class Singleton03 {
	private static Singleton03 INSTANCE;
	
	private Singleton03() {}
	
	public static Singleton03 getInstance() {
		if (INSTANCE == null) {
			INSTANCE = new Singleton03();
		}
		return INSTANCE;
	}
}
  1. 懒汉式(synchronized 线程安全 性能低)
public class Singleton04 {
	private static Singleton04 INSTANCE;
	
	private Singleton04() {}
	
	public synchronized static Singleton04 getInstance() {
		if (INSTANCE == null) {
			INSTANCE = new Singleton04();
		}
		return INSTANCE;
	}
}

缺陷分析:对整个方法使用 synchronized 加锁来保障线程安全会降低性能,将线程阻塞在方法外部,增加内部判断的内存开销。并发情况下有且只能有一个线程正在进入获取单例对象方法。

  1. 懒汉式(synchronized 线程不安全)
public class Singleton05 {
	private static Singleton05 INSTANCE;
	
	private Singleton05() {}
	
	public static Singleton05 getInstance() {
		if (INSTANCE == null) {
			synchronized (Singleton05.class) {
				INSTANCE = new Singleton05();
			}
		}
		return INSTANCE;
	}
}

为什么线程不安全:若线程A、线程B同时进入判断方法内部,则等待上一个线程解锁后 INSTANCE 会再被重新构造,使得这期间对单例的设置全部失效。

其他隐患:构造对象方法的底层原子操作被重排序了,原本是先构造再把构造的对象的地址赋给引用,重排序后变为先赋空指针地址再构造对象并把对象的地址填入空指针中。

发生指令重排序拿到的是空地址单例线程1开始进入getInstance()方法成功判断为空加载锁将空地址赋给INSTANCE引用释放锁空地址中载入Singleton05对象return INSTANCE线程2开始INSTANCE不为空

线程2拿到的 INSTANCE 对象为一个空指针地址,在线程2中对 INSTANCE 的后续操作会抛出 java.lang.NullPointerException 异常。

  1. 懒汉式(volatile 双重检查模式 高性能)
public class Singleton06 {  
    // 防止构造重排序拿到空指针对象  
    private volatile static Singleton06 INSTANCE = null;  
    private Singleton06() {}  
	  
    public static Singleton06 getInstance() {  
        // 第一次检查单例对象是否已被构建  
        if (INSTANCE == null) {  
            synchronized (Singleton06.class) {  
                // 第二次检查防止二次构造覆盖  
                if (INSTANCE == null) {  
                    // 非原子操作  
                    INSTANCE = new Singleton06();  
                }  
            }  
        }  
        return INSTANCE;  
    }  
}

双重检查的优点:线程安全、延迟加载、效率较高。

4.2.4 静态内部类单例

public class Singleton07 {
	private Singleton07 {}
	
	private static class SingletonInstance {
		private static final Singleton07 INSTANCE = new Singleton07();
	}
	
	// 闭包暴露
	public static Singleton07 getInstance() {
		return SingletonInstance.INSTANCE;
	}
}
  1. 静态内部类是在被调用时才会被加载,这种方案实现了懒汉单例的一种思想,需要用到的时候采取创建单例。加上 JVM 的特性,这种方式又实现了线程安全的创建单例对象。
  2. 通过对比基于 volatile 的双重检查锁方案和基于类初始化方案的对比,我们会发现基于类初始化的方案的实现代码更加简介。但是基于 volatile 的双重建查锁方案有一个额外优势:除了可以对静态字段实现延迟加载初始化外,还可以对实例字段实现延迟初始化

4.2.5 枚举实现单例

public enum Singleton08 {
	INSTANCE;
	
	public void whatever() {}
}

4.3 volatile 的使用场景

4.3.1 纯赋值操作

概述:volatile 不适合做 a++ ++a 等操作,但适合做纯赋值操作,如:boolean flag = true; 具体案例如下:

import java.util.concurrent.atomic.AtomicInteger;

public class UserVolatile01 implements Runnable {
    // 定义一个 volatile 修饰的boolean 变量
    volatile boolean flag = false;
    // 定义一个原子类记录总的赋值次数
    AtomicInteger atomicInteger = new AtomicInteger();
	
    @Override
    public void run() {
        for (int i = 0; i < 10000; i++) {
            switchFlag();
            atomicInteger.incrementAndGet();
        }
    }
	
    public void switchFlag() {
        // flag = true; // 纯赋值操作是符合预期的
        flag = !flag; // 这样做不符合预期 可能 true 可能 false
    }
}

class Test {
    public static void main(String[] args) throws Exception {
        UserVolatile01 u = new UserVolatile01();
		
        // 创建两个线程执行赋值操作
        Thread t1 = new Thread(u);
        Thread t2 = new Thread(u);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
		
        // 等两个线程执行结束后再获取结果
        System.out.println(u.flag);
        System.out.println(u.atomicInteger);
    }
}

小结:volatile 可以适合做多线程中的纯赋值操作:如果一个共享变量自始至终只被各个线程赋值,而没有其他的操作,那么就可以用 volatile 来替代 synchronized 或者替代原子变量,因为赋值操作本身具有原子性,而 volatile 又保证了可见性,所以线程安全。而 flag = !flag 不保障原子性 只保障了可见性。

4.3.2 触发器

概念:按照 volatile 的可见性和禁止重排序以及 happens-before 规则,volatile 可以作为刷新之前变量的触发器。我们可以将某个变量设置为 volatile 修饰,其他线程一旦发现该变量修改的值后,触发获取到的该变量之前的操作都将是最新的且可见

public class UserVolatile02 {  
    int a = 1, b = 2, c = 3;  
    volatile boolean flag = false;  
	
    public void write() {  
        a = 100;  
        b = 200;  
        c = 300;  
        flag = true;  
    }  
	
    public void read() {  
        // flag 被 volatile 修饰 充当了触发器 一旦值为 true 此处立即对变量之前的操作可见  
        // 即 a b c 修改后的值具有可见性  
        while (flag) {  
            System.out.println("a=" + a + " , b=" + b + " , c=" + c);  
        }  
    }  
	
    public static void main(String[] args) {  
        UserVolatile02 u = new UserVolatile02();  
        new Thread(u::write).start();  
        new Thread(u::read).start();  
    }
}

小结:volatile 可以作为刷新之前变量的触发器。我们可以将某个变量设置为 volatile 修饰,其他线程一旦发现该变量修改的值后,触发获取到的该变量之前的操作都将是最新的且可见的。

案例:在 IoC 中的容器A中的某个 volatile 变量发生改变后可以简介触发其他操作的可见性,使得在另一个线程中的容器B能够嗅探到来自容器A的变化

容器线程A容器线程B触发器信号容器对象初始化操作flag = falseflag = true继续执行嗅探容器对象初始化操作

4.4 volatile 与 synchronized

4.4.1 区别

  • volatile 只能修饰变量和类变量,而 synchronized 可以修饰方法,以及代码块。
// 错误的写法 synchronized 禁止用于修饰变量
synchronized int a = 1;

// 错误的写法 volatile 禁止用于修饰方法
public volatile write() {
	a = 100;
}
  • volatile 保证数据的可见性,但是不保证原子性(多线程进行写操作,不保证线程安全);而 synchronized 是一种排他(互斥)的机制。
  • volatile 用于禁止指令重排序,可以解决单例双重检查,防止产生对象初始化代码执行乱序的问题。
  • volatile 可以看做是轻量级的 synchronized,但是 volatile 不保证原子性。如果是对一个共享变量进行多线程的赋值,而没有其他操作,则可以使用 volatile 来替代 synchronized,因为赋值操作本身具有原子性,同时 volatile 又保障了可见性,所以进而保证了线程安全。(见4.3.1)

4.5 volatile 的总结

4.5.1 总体总结

  1. volatile 修饰符适用于以下场景:某个属性被多个线程共享,其中一个线程修改了此属性,其他线程需要立即响应并嗅探到该修改后的值(触发器),来实现轻量级的同步
  2. volatile 属性的读写操作都是无锁的,它不能替代 synchronized,因为他没有提供原子性和互斥性。因为无锁,不需要花费时间在获取锁和释放锁的操作上,所以说它是低成本的。
  3. volatile 只能作用于属性,我们用 volatile 修饰属性,这样 compilers 就不会对这个属性做指令重排序
  4. volatile 提供了可见性, 任何一个线程对其的修改将立马对其他线程可见。volatile 属性不会被线程缓存,始终从主存中进行读取。
  5. volatile 提供了 happens-before 保证,对 volatile 变量 v 的写入 happens-before 所有其他线程后续对 v 的读操作。
  6. volatile 可以使得 long 和 double 的赋值是原子的。
  7. volatile 可以在单例双重检查中实现可见性和禁止指令重排序,从而保证安全性
7

评论区