Java 并发 (4) -- volatile 关键字

1. 简介

  1. volatile 是 Java 虚拟机提供的一种轻量级的同步机制,volatile 关键字有两个作用:

    1. 保证被 volatile 修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被 volatile 修饰共享变量的值时,新值总是可以被其他线程立即得知
    2. 禁止指令重排序
  2. volatile 的可见性在 JMM 层面的实现是,当写一个 volatile 变量时,JMM 会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个 volatile 变量时,JMM 会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile 变量正是通过这种 写-读 的方式实现对其他线程的可见性

  3. volatile 的内存语义(即它的可见性和禁止指令重排序)都是通过内存屏障来实现的;内存屏障又称为内存栅栏,它是一个 CPU 指令,它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性;由于编译器和处理器都能执行指令重排优化,如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止了内存屏障前后的指令进行重排序优化;Memory Barrier 的另外一个作用就是强制刷新各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本

  4. 自己知道就行:volatile 可以保证原子操作的变量操作的线程安全,但不能保证像 i++ 这种分多步完成的操作的线程安全

  5. volatile 与 synchronized 的区别:(5)

    1. volatile 的本质是告诉 JVM 当前变量在寄存器(工作内存)中的值是不确定的,需要从主存中读取;synchronized 则是锁定当前变量,只有当前线程可以访问该变量,其他线程会被阻塞
    2. volatile 只能作用于变量;synchronized 则可以作用于变量 、方法和代码块
    3. volatile 仅能实现变量的修改可见性,不能保证原子性;而 synchronized 则可以保证变量的修改可见性和原子性。
    4. volatile 不会造成线程的阻塞;synchronized 可能会造成线程的阻塞。
    5. volatile 标记的变量不会被编译器优化;synchronized 标记的变量可以被编译器优化。

2. 精讲

1. volatile 的作用

volatile 是 Java 虚拟机提供的轻量级的同步机制,volatile 关键字有如下两个作用:

  1. 保证被 volatile 修饰的共享变量对所有线程总是可见的,也就是当一个线程修改了一个被 volatile 修饰共享变量的值时,新值总是可以被其他线程立即得知
  2. 禁止指令重排序

1. volatile 保证可见性

关于 volatile 的可见性作用,我们必须意识到被 volatile 修饰的变量对所有线程总是立即可见的,对 volatile 变量的所有写操作总是能立刻反应到其他线程中,但是 volatile 并不保证被其修饰的变量的运算操作在多线程环境中是安全的,如下:

public class VolatileVisibility {
    public static volatile int i =0;
 
    public static void increase(){
        i++;
    }
}

正如上述代码所示,i 变量的任何改变都会立马反应到其他线程中。但是如果存在多条线程同时调用 increase()方法的话,就会出现线程安全问题,毕竟 i++ 操作并不具备原子性,该操作是先读取值,然后写回一个新值,相当于原来的值加上 1,分两步完成(volatile 不保证原子性)。如果第二个线程在第一个线程读取旧值和写回新值期间读取 i 的域值,那么第二个线程就会与第一个线程一起看到同一个值,并执行相同值的加 1 操作,这也就造成了线程安全失败。因此对于 increase() 方法必须使用 synchronized 修饰,以便保证线程安全,需要注意的是一旦使用 synchronized 修饰方法后,由于 synchronized 本身也具备与 volatile 相同的特性,即可见性,因此在这样种情况下就完全可以省去 volatile 修饰变量:

public class VolatileVisibility {
    public static int i =0;
 
    public synchronized static void increase(){
        i++;
    }
}

现在来看另外一种场景,可以使用 volatile 修饰变量达到线程安全的目的,如下:

public class VolatileSafe {
 
    volatile boolean close;
 
    public void close(){
        close = true;
    }
 
    public void doWork(){
        while (!close){
            System.out.println("safe....");
        }
    }
}

由于对于 boolean 变量 close 值的修改属于原子性操作(只有一步),因此可以通过使用 volatile 修饰变量 close,使用该变量对其他线程立即可见,从而达到线程安全的目的。

那么 JMM 是如何实现让 volatile 变量对其他线程立即可见的呢?实际上,当写一个 volatile 变量时,JMM 会把该线程对应的工作内存中的共享变量值刷新到主内存中,当读取一个 volatile 变量时,JMM 会把该线程对应的工作内存置为无效,那么该线程将只能从主内存中重新读取共享变量。volatile 变量正是通过这种 写-读 方式实现对其他线程可见性的(但其内存语义实现则是通过内存屏障,稍后会说明)

2. volatile 禁止指令重排序

volatile 关键字另一个作用就是禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象,关于指令重排优化上篇文章中已经详细分析过,这里主要简单说明一下 volatile 是如何实现禁止指令重排优化的。先了解一个概念,内存屏障(Memory Barrier)

内存屏障:又称内存栅栏,是一个CPU指令。它的作用有两个,一是保证特定操作的执行顺序,二是保证某些变量的内存可见性(利用该特性实现 volatile 的内存可见性)。由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memory Barrier 则会告诉编译器和 CPU,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止了内存屏障前后的指令进行重排序优化。Memory Barrier的另外一个作用是强制刷新各种 CPU 的缓存数据,因此任何 CPU 上的线程都能读取到这些数据的最新版本。总之, volatile 变量正是通过内存屏障实现其在内存中的语义,即可见性和禁止重排优化。下面看一个非常典型的禁止重排优化的例子 DCL,如下:

public class DoubleCheckLock {
 
	private static DoubleCheckLock instance; //真正的双检单例这里会加 volatile 修饰
	
	private DoubleCheckLock(){	
	}
	
	public static DoubleCheckLock getInstance(){		
		// 第一次检查
		if(instance == null){
			// 同步
			synchronized (DoubleCheckLock.class) {
				if(instance == null){
					// 多线程环境下可能会出现问题的地方
					instance = new DoubleCheckLock();
				}
			}
		}
		return instance;
	}
}

上述代码一个经典的单例的双重检测的代码。这段代码在单线程环境下并没有什么问题,但如果在多线程环境下就可能出现线程安全问题。原因在于某一个线程执行到第一次检测,读取到的 instance 不为 null 时,instance 的引用对象可能没有完成初始化。因为 instance = new DoubleCheckLock() 可以分为以下3步完成(伪代码):
在这里插入图片描述
由于步骤 2 和步骤 3 间可能会重排序,如下:
在这里插入图片描述
由于步骤 2 和步骤 3 不存在数据依赖关系,而且无论重排前还是重排后程序的执行结果在单线程中并没有改变,因此这种重排优化是允许的。但是指令重排只会保证串行语义的执行的一致性(单线程),但并不会关心多线程间的语义一致性。所以当一条线程访问 instance 不为 null 时,由于 instance 实例未必已初始化完成,也就造成了线程安全问题。那么该如何解决呢?很简单,我们使用 volatile 禁止 instance 变量被执行指令重排优化即可。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cqLNDz6Y-1588757486045)(4. Java 并发(5)- volatile 关键字.assets/1577932557089.png)]

2. volatile 写-读 建立的 happens - before 关系

从 JSR-133 开始,volatile 变量的写-读可以实现线程之间的通信。

从内存语义的角度来说,volatile 与监视器锁有相同的效果:

  1. volatile 写和监视器的释放有相同的内存语义;
  2. volatile 读与监视器的获取有相同的内存语义。

请看下面使用 volatile 变量的示例代码:

class VolatileExample {
    int a = 0;
    volatile boolean flag = false;
 
    public void writer() {
        a = 1;                     // 1
        flag = true;               // 2
    }
 
    public void reader() {
        if (flag) {                // 3
            int i =  a;            // 4
            ……
        }
    }
}

假设线程 A 执行 writer() 方法之后(不是同时调用,而是串行执行),线程 B 执行 reader() 方法。根据 happens-before 规则,这个过程建立的happens-before 关系可以分为三类:

  1. 根据程序次序规则,1 happens before 2; 3 happens before 4。
  2. 根据volatile规则,2 happens before 3。
  3. 根据happens before 的传递性规则,1 happens before 4。

上述 happens before 关系的图形化表现形式如下:
在这里插入图片描述
在上图中,每一个箭头链接的两个节点,代表了一个 happens before 关系。黑色箭头表示程序顺序规则;橙色箭头表示 volatile 规则;蓝色箭头表示组合这些规则后提供的 happens before 保证。

这里 A 线程写一个 volatile 变量后,B 线程读同一个 volatile 变量。A 线程在写 volatile 变量之所有可见的共享变量在 B 线程读同一个 volatile 变量后,将立即变得对 B 线程可见

3. volatile 读-写 的内存语义

volatile 写的内存语义如下:

  • 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量刷新到主内存。

以上面示例程序 VolatileExample 为例,假设线程 A 首先执行 writer() 方法,随后线程 B 执行 reader() 方法,初始时两个线程的本地内存中的 flag 和 a 都是初始状态。下图是线程 A 执行 volatile 写后,共享变量的状态示意图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-SK1OoD4l-1588757486049)(4. Java 并发(5)- volatile 关键字.assets/20181207193008994.png)]
如上图所示,线程 A 在写 flag 变量后,本地内存 A 中被线程 A 更新过的共享变量的值被刷新到主内存中。此时,本地内存 A 和主内存中的共享变量的值是一致的。

volatile 读的内存语义如下:

  • 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

下面是线程 B 读同一个 volatile 变量后,共享变量的状态示意图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-y7HTybZJ-1588757486050)(4. Java 并发(5)- volatile 关键字.assets/2018120719313872.png)]
如上图所示,在读 flag 变量时,本地内存 B 已经被置为无效。此时,线程 B 必须从主内存中读取共享变量。线程 B 的读取操作将导致本地内存 B 与主内存中的共享变量的值也变成一致的了。

如果我们把 volatile 写和 volatile 读这两个步骤综合起来看的话。在线程 B 读一个 volatile 变量后和线程 A 在写这个 volatile 变量之前,所有可见的共享变量的值都将立即变得对读线程 B 可见

  1. 线程A写一个 volatile 变量时,实质上是线程A向接下来将要读这个 volatile 变量的某个线程发出了(其对共享变量所在修改的)消息;
  2. 线程B读一个 volatile 变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息;
  3. 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

4. volatile 的内存语义

下面,让我们来看看 JMM 如何实现 volatile 写/读的内存语义。

前文我们提到过重排序分为编译器重排序和处理器重排序。为了实现 volatile 内存语义,JMM 会分别限制这两种类型的重排序类型。下面是 JMM 针对编译器制定的 volatile 重排序规则表:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wL7qIofb-1588757486051)(4. Java 并发(5)- volatile 关键字.assets/20181207193545486.png)]
举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为 volatile 写,则编译器不能重排序这两个操作。

从上表我们可以看出:

  • 当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后。
  • 当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
  • 当第一个操作是 volatile 写,第二个操作是 volatile 读时,不能重排序。

为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的 JMM 内存屏障插入策略:

  • 在每个 volatile 写操作的前面插入一个 StoreStore 屏障。
  • 在每个 volatile 写操作的后面插入一个 StoreLoad 屏障。
  • 在每个 volatile 读操作的面插入一个 LoadLoad 屏障。
  • 在每个 volatile 读操作的后面插入一个 LoadStore 屏障。

上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的 volatile 内存语义。

下面是保守策略下,volatile 写插入内存屏障后生成的指令序列示意图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-mwOcaZxE-1588757486052)(4. Java 并发(5)- volatile 关键字.assets/2018120719402518.png)]
上图中的 StoreStore 屏障可以保证在 volatile 写之前,其前面的所有普通写操作已经对任意处理器可见了。这是因为 StoreStore 屏障将保障上面所有的普通写在 volatile 写之前刷新到主内存。

这里比较有意思的是 volatile 写后面的 StoreLoad 屏障。这个屏障的作用是避免 volatile 写与后面可能有的 volatile 读/写操作重排序。因为编译器常常无法准确判断在一个 volatile 写的后面,是否需要插入一个 StoreLoad 屏障(比如,一个 volatile 写之后方法立即 return)。为了保证能正确实现 volatile 的内存语义,JMM 在这里采取了保守策略:在每个 volatile 写的后面或在每个 volatile 读的前面插入一个 StoreLoad 屏障。从整体执行效率的角度考虑,JMM 选择了在每个 volatile 写的后面插入一个 StoreLoad 屏障。因为 **volatile 写-读内存语义的常见使用模式是:一个写线程写 volatile 变量,多个读线程读同一个 volatile 变量。**当读线程的数量大大超过写线程时,选择在 volatile 写之后插入 StoreLoad 屏障将带来可观的执行效率的提升。从这里我们可以看到 JMM 在实现上的一个特点:首先确保正确性,然后再去追求执行效率。

下面是在保守策略下,volatile 读插入内存屏障后生成的指令序列示意图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-wpdQQ3PA-1588757486053)(4. Java 并发(5)- volatile 关键字.assets/20181207194313474.png)]
上图中的 LoadLoad 屏障用来禁止处理器把上面的 volatile 读与下面的普通读/volatile 读重排序。LoadStore 屏障用来禁止处理器把上面的 volatile 读与下面的普通写/volatile 写重排序。

上述 volatile 写和 volatile 读的内存屏障插入策略非常保守。在实际执行时,只要不改变 volatile 写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:

class VolatileBarrierExample {
    int a;
    volatile int v1 = 1;
    volatile int v2 = 2;
 
    void readAndWrite() {
        int i = v1;           // 第一个volatile读
        int j = v2;           // 第二个volatile读
        a = i + j;            // 普通写
        v1 = i + 1;           // 第一个volatile写
        v2 = j * 2;           // 第二个 volatile写
    }// 其他方法
}

针对 readAndWrite() 方法,编译器在生成字节码时可以做如下的优化:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-xerXWyyM-1588757486054)(4. Java 并发(5)- volatile 关键字.assets/20181207195804514.png)]
注意,最后的 StoreLoad 屏障不能省略。因为第二个 volatile 写之后,方法立即 return。此时编译器可能无法准确断定后面是否会有 volatile 读或写,为了安全起见,编译器常常会在这里插入一个 StoreLoad 屏障。

上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以 x86 处理器为例,上图中除最后的 StoreLoad 屏障外,其它的屏障都会被省略。

前面保守策略下的 volatile 读和写,在 x86 处理器平台可以优化成:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-sNNUkkt8-1588757486055)(4. Java 并发(5)- volatile 关键字.assets/20181207200021392.png)]
x86 处理器仅会对写-读操作做重排序。x86 不会对读-读 、读-写和写-写操作做重排序,因此在 x86 处理器中会省略掉这三种操作类型对应的内存屏障。在 x86 中,JMM 仅需在 volatile 写后面插入一个 StoreLoad 屏障,即可正确实现 volatile 写-读的内存语义。这意味着在 x86 处理器中,volatile 写的开销比 volatile 读的开销会大很多(因为执行 StoreLoad 屏障开销会比较大)

1. JSR-133 为什么要增强 volatile 的内存语义

在 JSR-133 之前的旧 Java 内存模型中,虽然不允许 volatile 变量之间重排序,但旧的 Java 内存模型允许 volatile 变量与普通变量之间重排序。在旧的内存模型中,VolatileExample 示例程序可能被重排序成下列时序来执行:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-7D8xNnSX-1588757486056)(4. Java 并发(5)- volatile 关键字.assets/20181207200434935.png)]
在旧的内存模型中,当 1 和 2 之间没有数据依赖关系时,1 和 2 之间就可能被重排序(3 和 4 类似)。其结果就是:读线程 B 执行 4 时,不一定能看到写线程 A 在执行 1 时对共享变量的修改。

因此在旧的内存模型中 ,volatile 的写-读没有监视器的释放-获取所具有的内存语义。为了提供一种比监视器锁更轻量级的线程之间通信的机制,JSR-133 专家组决定增强 volatile 的内存语义:严格限制编译器和处理器对 volatile 变量与普通变量的重排序,确保 volatile 的写-读和监视器的释放-获取一样,具有相同的内存语义。从编译器重排序规则和处理器内存屏障插入策略来看,只要 volatile 变量与普通变量之间的重排序可能会破坏 volatile 的内存语意,这种重排序就会被编译器重排序规则和处理器内存屏障插入策略禁止。

由于 volatile 仅仅保证对单个 volatile 变量的读/写具有原子性,而监视器锁的互斥执行的特性可以确保对整个临界区代码的执行具有原子性。在功能上,监视器锁比 volatile 更强大;在可伸缩性和执行性能上,volatile 更有优势。如果读者想在程序中用 volatile 代替监视器锁,请一定谨慎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值