Java:java学习笔记之volatile关键字的简单理解和使用

Posted JMW1407

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Java:java学习笔记之volatile关键字的简单理解和使用相关的知识,希望对你有一定的参考价值。

volatile关键字

1、定义

  • volatile是Java提供的一种轻量级的同步机制。Java 语言包含两种内在的同步机制:同步块(或方法)volatile 变量
  • 相比于synchronized(synchronized通常称为重量级锁),volatile更轻量级,因为它不会引起线程上下文的切换和调度。
  • 但是volatile 变量的同步性较差(有时它更简单并且开销更低),而且其使用也更容易出错。

2、并发编程的3个基本概念

原子性、可见性 & 有序性

2.1、原子性

  • 定义: 即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
  • 原子性是拒绝多线程操作的,不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作。
  • 简而言之,在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。

例如 a=1是原子性操作,但是a++a +=1就不是原子性操作。Java中的原子性操作包括:

  • (1)基本类型的读取和赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作。
  • (2)所有引用reference的赋值操作
  • (3)java.concurrent.Atomic.* 包中所有类的一切操作

2.2、可见性

定义:指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

2.2.1、Java的内存模型JMM以及共享变量的可见性

Java虚拟机规范试图定义一种Java内存模型(JMM),来屏蔽掉各种硬件和操作系统的内存访问差异,让Java程序在各种平台上都能达到一致的内存访问效果。

  • 简单来说,由于CPU执行指令的速度是很快的,但是内存访问的速度就慢了很多,相差的不是一个数量级,所以搞处理器的那群大佬们又在CPU里加了好几层高速缓存。

JMM操作变量的时候不是直接在主存进行操作的,而是每个线程拥有自己的工作内存,在使用前,将该变量的值copy一份到自己的工作内存,读取时直接读取自己的工作内存中的值.写入操作时,先将修改后的值写入到自己的工作内存,再讲工作内存中的值刷新回主存.

JMM定义了线程和主内存之间的抽象关系:

  • 共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory)
  • 本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。

对于普通的共享变量来讲:

  • 线程A将其修改为某个值发生在线程A的本地内存中,此时还未同步到主内存中去;
  • 而线程B已经缓存了该变量的旧值,所以就导致了共享变量值的不一致。

解决这种共享变量在多线程模型中的不可见性问题,较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,比较合理的方式其实就是volatile

需要注意的是,JMM是个抽象的内存模型,所以所谓的本地内存,主内存都是抽象概念,并不一定就真实的对应cpu缓存和物理内存

2.2.1.1、举例说明

在线程执行时,首先会从主存中read变量值,再load到工作内存中的副本中,然后再传给处理器执行,执行完毕后再给工作内存中的副本赋值,随后工作内存再把值传回给主存,主存中的值才更新。 使用工作内存和主存,虽然加快的速度,但是也带来了一些问题。比如看下面一个例子:

i = i + 1;

假设i初值为0,当只有一个线程执行它时,结果肯定得到1,当两个线程执行时,会得到结果2吗?这倒不一定了。可能存在这种情况:

线程1: load i from 主存    // i = 0
        i + 1  // i = 1
线程2: load i from主存  // 因为线程1还没将i的值写回主存,所以i还是0
        i +  1 //i = 1
线程1:  save i to 主存
线程2: save i to 主存

如果两个线程按照上面的执行流程,那么i最后的值居然是1了。如果最后的写回生效的慢,你再读取i的值,都可能是0,这就是缓存不一致问题。

2.2.2、可见性

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。Java提供了volatile来保证可见性,当一个变量被volatile修饰后,表示着线程本地内存无效,当一个线程修改共享变量后他会立即被更新到主内存中,其他线程读取共享变量时,会直接从主内存中读取。

当然,synchronizeLock都可以保证可见性。synchronizedLock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。

2.3、有序性

定义:即程序执行的顺序按照代码的先后顺序执行。

Java内存模型中的有序性可以总结为:

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

在Java内存模型中,为了效率是允许编译器和处理器对指令进行重排序,当然重排序不会影响单线程的运行结果,但是对多线程会有影响。Java提供volatile来保证一定的有序性。

最著名的例子就是单例模式里面的DCL(双重检查锁)。另外,可以通过synchronizedLock来保证有序性,synchronizedLock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。

3、锁的互斥和可见性

锁提供了两种主要特性:互斥(mutual exclusion) 和可见性(visibility)

(1)互斥性:互斥即一次只允许一个线程持有某个特定的锁,一次就只有一个线程能够使用该共享数据。

(2)可见性:它必须确保释放锁之前对共享数据做出的更改对于随后获得该锁的另一个线程是可见的。也即当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的。要使 volatile 变量提供理想的线程安全,必须同时满足下面两个条件:

  • a.对变量的写操作不依赖于当前值。
  • b.该变量没有包含在具有其他变量的不变式中。

实际上,这些条件表明,可以被写入 volatile 变量的这些有效值独立于任何程序的状态,包括变量的当前状态。

  • 事实上就是保证操作是原子性操作,才能保证使用volatile关键字的程序在并发时能够正确执行。

4、volatile变量的特性

4.1、保证可见性

具体描述

  • volatile修饰的属性保证每次读取都能读到最新的值

但不会 & 无法更新已经读了的值

原理

  • 线程A在工作内存中修改的共享属性值会立即刷新到主存,线程B/C/D每次通过读写栅栏来达到类似于直接从主存中读取属性值

1、只是类似,网上有些说volatile修饰的变量读写直接在主存中操作,这种说法是不对的,只是表现出类似的行为

2、读写栅栏是一条CPU指令;插入一个读写栅栏 = 告诉CPU & 编译器先于这个命令的必须先执行,后于这个命令的必须后执行(有序性)

3、读写栅栏另一个作用是强制更新一次不同CPU的缓存。例如,一个写栅栏会 把这个栅栏前写入的数据刷新到缓存,以此保证可见性

4.2、保证有序性

具体描述

  • 当对volatile修饰的属性进行读/写操作时,其前面的代码必须已执行完成 & 结果对后续的操作可见

原理

  • 重排序时,以volatile修饰属性的读/写操作代码行为分界线,读/写操作前面的代码不许排序到后面,后面同理不许排序到前面。由此保证有序性

4.2.1、指令重排

重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。重排序需要遵守一定规则:

(1)重排序操作不会对存在数据依赖关系的操作进行重排序。

比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。

(2)重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变

比如:a=1;b=2;c=a+b这三个操作,第一步(a=1)和第二步(b=2)由于不存在数据依赖关系, 所以可能会发生重排序,但是c=a+b这个操作是不会被重排序的,因为需要保证最终的结果一定是c=a+b=3

int a = 0;
bool flag = false;

public void write() 
    a = 2;              //1
    flag = true;        //2


public void multiply() 
    if (flag)          //3
        int ret = a * a;//4
    


假如有两个线程执行上述代码段,线程1先执行write,随后线程2再执行multiply,最后ret的值一定是4吗?结果不一定


例中的1和2由于不存在数据依赖关系,则有可能会被重排序,先执行status=true再执行a=2。而此时线程B会顺利到达4处,而线程Aa=2这个操作还未被执行,所以b=a+1的结果也有可能依然等于2

4.2.1.1、happens-before原则

JMM有一些内在的规律性,也就是说,没有任何方法可以保证有序,这通常称为发生在原则之前。

<<JSR-133:Java Memory Model and Thread Specification>>定义了如下happens-before规则:

  • 第1条规则(程序顺序规则):在一个线程里,所有的操作都是按顺序的,但是在JMM里其实只要执行结果一样,是允许重排序的,这边的happens-before强调的重点也是单线程执行结果的正确性,但是无法保证多线程也是如此。
  • 第2条规则(监视器规则):就是在加锁之前,确定这个锁之前已经被释放了,才能继续加锁。
  • 第3条规则:如果一个线程先去写一个变量,另外一个线程再去读,那么写入操作一定在读操作之前
  • 第4条规则,就是happens-before传递性

后面几条就不再一一赘述了。

4.2.1.2、解决方法:volatile关键字

使用volatile关键字修饰共享变量便可以禁止这种重排序。若用volatile修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序,volatile禁止指令重排序也有一些规则:

  • 即执行到volatile变量时,其前面的所有语句都执行完,后面所有语句都未执行。且前面语句的结果对volatile变量及其后面语句可见。

继续拿上面的一段代码举例:

int a = 0;
volatile bool flag = false;

public void write() 
   a = 2;              //1
   flag = true;        //2


public void multiply() 
   if (flag)          //3
       int ret = a * a;//4
   


这段代码不仅仅受到重排序的困扰,即使1、2没有重排序。3也不会那么顺利的执行的:

  • 假设还是线程1先执行write操作,线程2再执行multiply操作
  • 由于线程1是在工作内存里把flag赋值为true,不一定立刻写回主存

所以线程2执行时,multiply再从主存读flag值,仍然可能为false,那么括号里的语句将不会执行。 如果改成下面这样:

int a = 0;
volatile bool flag = false;

public void write() 
   a = 2;              //1
   flag = true;        //2


public void multiply() 
   if (flag)          //3
       int ret = a * a;//4
   

那么线程1先执行write,线程2再执行multiply。根据happens-before原则,这个过程会满足以下3类规则:

(1)程序顺序规则:

  • 1 happens-before 2;
  • 3 happens-before 4; (volatile限制了指令重排序,所以1 在2 之前执行)

(2)volatile规则:

  • 2 happens-before 3

(3)传递性规则:

  • 1 happens-before 4

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

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

4.3、不保证原子性

具体描述

  • volatile修饰的属性若在修改前已读取了值,那么修改后,无法改变已经复制到工作内存的值

即无法阻止并发的情况

// 变量a 被volatile修饰 
volatile static int a=0;
a++;
// 包含了2步操作:1 = 读取a、2= 执行a+1 & 将a+1结果赋值给a
// 设:线程A、B同时执行以下语句,线程A执行完第1步后被挂起、线程B执行了a++,那么主存中a的值为1
// 但线程A的工作内存中还是0,由于线程A之前已读取了a的值 = 0,执行a++后再次将a的值刷新到主存 = 1
// 即 a++执行了2次,但2次都是从0变为1,故a的值最终为1

5、volatile原理

volatile可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在JVM底层volatile是采用“内存屏障”来实现的。

观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令,lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  • (1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;
  • (2)它会强制将对缓存的修改操作立即写入主存;
  • (3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

6、volatile的使用

  • 由于volatile保证可见性和有序性,被volatile修饰的共享属性一般并发读/写没有问题,可看做是一种轻量级的synchronized实现
  • volatile只可以用来修饰变量,不可以修饰方法以及类

这里举几个比较经典的场景:

  • 状态标记量,就是前面例子中的使用.
  • 一次性安全发布.双重检查锁定问题(单例模式的双重检查).
  • 独立观察.如果系统需要使用最后登录的人员的名字,这个场景就很适合.
  • 开销较低的“读-写锁”策略.当读操作远远大于写操作,可以结合使用锁和volatile来提升性能.

注意事项

  • volatile并不能保证操作的原子性,想要保证原子性请使用synchronized关键字加锁.

6.1、volatile不适用的场景

1.volatile不适合复合操作

例如,inc++不是一个原子性操作,可以由读取、加、赋值3步组成,所以结果并不能达到10000。

  • inc++是个复合操作,包括读取inc的值,对其自增,然后再写回主存。


按道理来说结果是10000,但是运行下很可能是个小于10000的值。

问题1、有人可能会说volatile不是保证了可见性啊,一个线程对inc的修改,另外一个线程应该立刻看到啊!

  • 可是这里的操作inc++是个复合操作啊,包括读取inc的值,对其自增,然后再写回主存。
  • 假设线程A,读取了inc的值为10,这时候被阻塞了,因为没有对变量进行修改,触发不了volatile规则。
  • 线程B此时也读读inc的值,主存里inc的值依旧为10,做自增,然后立刻就被写回主存了,为11。
  • 此时又轮到线程A执行,由于工作内存里保存的是10,所以继续做自增,再写回主存,11又被写了一遍。
  • 所以虽然两个线程执行了两次increase(),结果却只加了一次。

问题2、有人说,volatile不是会使缓存行无效的吗?

  • 但是这里线程A读取到线程B也进行操作之前,并没有修改inc值,所以线程B读取的时候,还是读的10。

问题3、又有人说,线程B将11写回主存,不会把线程A的缓存行设为无效吗?

  • 但是线程A的读取操作已经做过了啊,只有在做读取操作时,发现自己缓存行无效,才会去读主存的值,所以这里线程A只能继续做自增了。

综上所述,在这种复合操作的情景下,原子性的功能是维持不了了。但是volatile在上面那种设置flag值的例子里,由于对flag的读/写操作都是单步的,所以还是能保证原子性的。

要想保证原子性,只能借助于synchronized,Lock以及并发包下的atomic的原子操作类

  • 即对基本数据类型的自增(加1操作)自减(减1操作)、以及加法操作(加一个数)减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

2.解决方法

1)采用synchronized

(2)采用Lock


(3)采用java并发包中的原子操作类,原子操作类是通过CAS循环的方式来保证其原子性的

6.2、volatile适用的场景距举例

6.2.1、单例模式(懒汉式)的双重锁要加volatile

public class TestInstance
	private volatile static TestInstance instance;
	
	public static TestInstance getInstance()        //1
		if(instance == null)                        //2
			synchronized(TestInstance.class)        //3
				if(instance == null)                //4
					instance = new TestInstance();   //5
				
			
		
		return instance;                             //6
	


需要volatile关键字的原因是,在并发情况下,如果没有volatile关键字,在第5行会出现问题。instance = new TestInstance();可以分解为3行伪代码

a. memory = allocate() //分配内存
 
b. ctorInstanc(memory) //初始化对象
 
c. instance = memory //设置instance指向刚分配的地址

上面的代码在编译运行时,可能会出现重排序从a-b-c排序为a-c-b。在多线程的情况下会出现以下问题:

  • 当线程A在执行第5行代码时,B线程进来执行到第2行代码。
  • 假设此时A执行的过程中发生了指令重排序,即先执行了a和c,没有执行b。
  • 那么由于A线程执行了c导致instance指向了一段地址,所以B线程判断instance不为null,会直接跳到第6行并返回一个未初始化的对象

6.2.2、状态量标记

就如上面对flag的标记

int a = 0;
volatile bool flag = false;

public void write() 
    a = 2;              //1
    flag = true;        //2


public void multiply() 
    if (flag)          //3
        int ret = a * a;//4
    

这种对变量的读写操作,标记为volatile可以保证修改对线程立刻可见。比synchronized,Lock有一定的效率提升。

7、总结

参考

1、鲜为人知的关键字volatile
2、Java volatile关键字最全总结:原理剖析与实例讲解(简单易懂)
3、Java面试官最爱问的volatile关键字

以上是关于Java:java学习笔记之volatile关键字的简单理解和使用的主要内容,如果未能解决你的问题,请参考以下文章

java之volatile关键字学习

java之volatile关键字学习

volatile 学习笔记

java笔记java中的volatile关键字

java并发之volatile关键字

Java并发机制之Volatile关键字