谈谈对Volatile修饰符的理解

高并发所涉及的的知识点主要是在 JUC 里面,即 java.util.concurrent (java并发包)

volatile是低配版、乞丐版的Synchronized;

一、volatile是Java虚拟机提供的轻量级的同步机制

这里就要联想一下,重量级的同步机制:synchronized

主要有三大特性:

  • 保证可见性

  • 不保证原子性

  • 禁止指令重排

当然光知道这三点完全没用,好需要深入理解,在深入理解这三小点之前,我们先插入一个知识点,JMM;


二、谈谈你对JMM的理解

JMM的意思:Java Memory Model(java内存模型)

  • JMM 本身是一种抽象的概念并不是真实存在,它描述的是一组规定或规范,通过这组规范定义了程序中各个变量(包括示例字段、静态字段和构成数组对象的元素)的访问方式。

  • JMM关于同步的规定:

    1、线程解锁前,必须把共享变量的值刷新回主内存中;

    2、线程加锁前,必须读取主内存中的最新值到自己的工作内存;

    3、加锁解锁是同一把锁;

由于JVM运行程序的实体是线程,而每个线程创建时,JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有的变量都存储在主内存当中,主内存是共享内存区域,所有线程都可以访问,但是线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存(私有数据区域),线程中的通信(传值)必须通过主内存来完成,其简要访问过程如下:

1.png

(内存模型图)

  • JMM是大多数多线程开发所要遵守的规范,它规定:

    1、可见性;

    2、原子性;

    3、有序性;

所以可以看到,使用volatile基本上可以满足 JMM 三大特性中的两个,但是它不能保证 JMM中所要求的原子性;


三、代码验证 volatile 对JMM三大特性的的支持情况:

1、保证可见性

通过前面对JMM的介绍,我们知道:

各个线程对主内存中共享变量的操作都是各个线程各自拷贝到自己的工作内存进行操作后再写回到主内存中的;这就可能导致一个问题:

在AAA线程修改了共享变量X的值但还未来得及写回主内存的时候,另一个线程BBB又对主内存中的同一个共享变量X进行操作,但此时由于AAA线程中的共享变量X对线程BBB来说是不可见的,BBB线程根本就不知道AAA已经修改了共享变量X的值,这种工作内存和主内存之间的同步延迟现象就造成了可见性问题

代码如下:

package com.jiguiquan.www;

import java.util.concurrent.TimeUnit;

/**
 * 1、验证vaoltile的可见性
 * 	1.1、假如 int number = 0; number 变量之前根本没有添加volatile关键字修饰,也就是没有可见性;
 * 	1.2、volatile int number = 0; 为number变量增加volatile修饰符,此时,number变量就是对其他线程可见的,具有可见性;
 * @author jiguiquan
 *
 */
public class VolatileDemo {
	public static void main(String[] args) {  //main是一切方法运行的入口,也是默认存在的线程
		MyData myData = new MyData();
		
		new Thread(() -> {
			System.out.println(Thread.currentThread().getName()+"\t come in");
			try {
				//Thread.sleep(3000); 同下面的方法
				TimeUnit.SECONDS.sleep(3);
			} catch (InterruptedException e) {
				e.printStackTrace();
			}
			myData.addTo60();  //3秒后将这个字改为60
			System.out.println(Thread.currentThread().getName()+"\t update number value:" + myData.number);
		}, "AAA").start();
		
		//第二个线程就是我们的main线程
		while(myData.number == 0) {
			//main线程就一直在这里等待,直到number值不再等于0
		}
		
		System.out.println(Thread.currentThread().getName()+"\t mission is over, main get number value:"+ myData.number);
	}
}

class MyData {
	volatile int number = 0;
	
	public void addTo60() {
		this.number = 60;
	}
}

1.1情况运行的结果如下:

image.png

1.2情况运行的结果

image.png

由此可见,当int number 变量之前根本没有添加volatile关键字修饰的时候,即使AAA线程已经将number修改为了60,由于各线程间变量的不可见性,main线程也不知道,它一直傻傻地拿着number = 0,导致主线程陷入死循环,程序无法结束;

只需要简单地位int number变量增加 volatile修饰符,就可解决此问题;

由此可见,volatile可有效保证可见性;

2、不保证原子性

2.1、原子性指的是什么?

不可分割,完整性,也就是某个线程正在做某个具体业务的时候,中间不可以被加塞或者被分割,需要整体完整

要么同时成功,要么同时失败;

代码演示:

package com.jiguiquan.www;

/**
 * 2、验证volatile不保证原子性:
 * 	2.1、原子性指的是什么?
 * 		不可分割,完整性,也就是某个线程正在做某个具体业务的时候,中间不可以被加塞或者被分割,需要整体完整,
 * 		要么同时成功,要么同时失败 
 * 	2.2、volatile是否可以保证原子性?
 * 		不可以;
 * 
 * @author jiguiquan
 *
 */
public class VolatileDemo {
	public static void main(String[] args) {
		MyData2 myData2 = new MyData2();
		
		for (int i = 0; i < 20; i++) {
			new Thread(()->{
				//启动20个线程,每个线程名为i,每个线程中执行number++方法1000次
				for (int j = 0; j < 1000; j++) {
					myData2.addPlusPlus();
				}
			},String.valueOf(i)) .start();
		}
		
		//等待上面20个线程都执行完成后,再用main线程读取最终结果打印
		while (Thread.activeCount() > 2) {
			Thread.yield(); //礼让
		}
		
		System.out.println(Thread.currentThread().getName()+"\t finally number value:" + myData2.number);
	}
}

class MyData2 {
	volatile int number = 0;
	
	//注意,此时number前面是加了volatile修饰符的,依然不保证原子性
	public void addPlusPlus() {
		number++;
	}
}

运行结果如下:

image.png

由此可见,运行结果根本不是20 x 1000 = 20000,明显有问题,就是因为number++操作根本不是原子操作,而且即使加上了volatile修饰符也无法解决此问题

这就证明了 volatile 根本无法保证原子性

2.2、why?

因为number++并不是原子操作,实际可以被拆分为3步走:

从主内存中拷贝变量到工作空间,修改变量值,写回主内存,

但是由于不是原子操作,所以在这过程中,有可能某个线程修改了某个数据的时候,还没来得及写回主内存,就被其他线程加塞了,自己被挂起了,然后其他线程将0修改为1后,刚刚被挂起的线程又恢复了,继续向主内存中写入number = 1,这样,就覆盖了刚刚的1,造成了数据丢失,这里明显少加了一次1,最终结果自然就远小于2000;

2.3、如何解决多线程中原子性的问题:

2.3.1:在addPlusPlus方法上增加 synchronized 锁

public synchronized void addPlusPlus() {
	number++;
}

运行结果如下:

image.png

但是杀鸡焉用牛刀,不要随随便便地就是用 synchronized 锁;

2.3.2、使用JUC下的原子类AtomicInteger代替基本类Integer

package com.jiguiquan.www;

import java.util.concurrent.atomic.AtomicInteger;

/**
 * 2、验证volatile不保证原子性:
 * 	2.1、原子性指的是什么?
 * 		不可分割,完整性,也就是某个线程正在做某个具体业务的时候,中间不可以被加塞或者被分割,需要整体完整,
 * 		要么同时成功,要么同时失败 
 * 	2.2、volatile是否可以保证原子性?
 * 		不可以;
 * 	2.3、why?
 * 		因为number并不是原子操作,实际可以被拆分为3步走:
 * 		从主内存中拷贝变量到工作空间,修改变量值,写回主内存,
 * 		但是由于不是原子操作,所以在这过程中,有可能某个线程修改了某个数据的时候,
 * 		还没来得及写回主内存,就被其他线程加塞了,自己被挂起了,
 * 		然后其他线程将0修改为1后,刚刚被挂起的线程又恢复了,继续向主内存中写入number = 1,
 * 		这样,就覆盖了刚刚的1,造成了数据丢失,这里明显少加了一次1,最终结果自然就远小于2000
 * 	2.4、既然volatile修饰符不保证原子性,那又如何解决多线程的原子性问题?
 * 		2.4.1、在方法上增加synchronized锁,但是杀鸡焉用牛刀;
 * 		2.4.2、使用JUC下的原子类AtomicInteger代替基本类Integer;
 * @author jiguiquan
 *
 */
public class VolatileDemo {
	public static void main(String[] args) {
		MyData2 myData2 = new MyData2();
		
		for (int i = 0; i < 20; i++) {
			new Thread(()->{
				//启动20个线程,每个线程名为i,每个线程中执行number++方法1000次
				for (int j = 0; j < 1000; j++) {
					myData2.addPlusPlus();
					myData2.addMyAtomic();
				}
			},String.valueOf(i)) .start();
		}
		
		//等待上面20个线程都执行完成后,再用main线程读取最终结果打印
		while (Thread.activeCount() > 2) {
			Thread.yield(); //礼让
		}
		
		System.out.println(Thread.currentThread().getName()+"\t int type, finally number value:" + myData2.number);
		System.out.println(Thread.currentThread().getName()+"\t AtomicInteger finally number value:"+ myData2.atomicInteger);
	}
}

class MyData2 {
	volatile int number = 0;
	AtomicInteger atomicInteger = new AtomicInteger();  //默认为0
	
	//注意,此时number前面是加了volatile修饰符的,依然不保证原子性
	public void addPlusPlus() {
		number++;
	}
	public void addMyAtomic() {
		atomicInteger.getAndIncrement();  //相当于i++的原子类版本
	}
}

运行结果如下:

image.png

由此可见:

虽然volatile不保证原子性,但是不一定要使用synchronized才能解决原子性问题,尽量不要随随便便就是用synchronized锁;

通过配合原子类 AtomicInteger,我们一样能轻轻松松地解决原子性问题;

3、禁止指令重排(难点)

3.1、有序性,什么是有序性?

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排,一般分为以下三种:

image.png

单线程环境里面确保程序最终执行结果和代码顺序执行的结果一直;

处理器在进行重排序时必须要开率指令之间的数据依赖性

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是无法确定的,结果也无法预测

3.2、指令重排的两种情况:

指令重排案例1、

int a,b,x,y = 0
线程1 线程2
x = a y = b
b = 1 a = 2
结果:x = 0,  y = 0

如果编译器对这段代码执行了重排优化后,可能出现下列情况:

int a,b,x,y = 0
线程1 线程2
b = 1 a = 2
x = a y = b
结果:x = 2,  y = 1

很显然,这两种情况得到了不同的处理结果,这种指令重排的优化必定存在问题,所以我们需要禁止指令重排

指令重排案例2、

package com.jiguiquan.www;

/**
 ** 指令重排的第2中案例 
 * @author jiguiquan
 *
 */
public class ReSortSeqDemo {
	int a = 0;
	boolean flag = false;
	
	public void method1() {
		a = 1;
		flag = true;
	}
	
	public void method2() {
		if (flag) {
			a = a +5;
			System.out.println("a = " + a);
		}
	}
}

由于指令重排优化的存在,有可能会重排为flag = true;排到了a=1;变量声明之前;单线程中不存在问题,但是在多线程环境中,就有可能会发生,flag = true;声明刚发生,a = 1;声明还未完成,线程就被另一个线程加塞,自己挂起了;这时候另一个线程执行的method2方法,获取到的flag为true,没问题,但是此时a = 0;最终导致a 不等于预期的6。(有可能是5,甚至还有可能是1)

3.3、使用 volatile 禁止指令重排优化

volatile实现了禁止指令重排优化,从而避免了在多线程环境下程序出现乱序执行的现象:实现手段——借助内存屏障

先了解一个概念:内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  • 保证特定操作的顺序执行;

  • 保证某些变量的内存可见性(volatile的可见性也是利用该特性实现的)

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。内存屏障另外一个作用就是强行刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到这条数据的最新版本;

对Volatile变量进行写操作时,

会在写操作后加入一条store屏障指令,将工作内存中的共享变量值刷新到主内存

1562998177204659.png

对Volatile变量进行读操作时,

会在读操作前加入一条load屏障指令,从主内存中读取共享变量

1562998240114778.png

线程安全性保证:

工作内存与主内存同步延迟现象导致的可见性问题:

我们可以使用synchronized或者volatile关键字修饰,他们都可以使一个线程修改后的变量立即对其他线程可见

对于指令重排导致的可见性问题和有序性问题:

我们可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止指令重排序优化;


四、你在哪些地方用到过volatile?

1、先来一个经典的,可在单线程中正常使用的单例模式:

package com.jiguiquan.www;

/**
 * 经典的单机版单例模式(懒汉式)修改为多线程版本 
 * 现在这是最经典的单线程可用的单例模式
 * @author jiguiquan
 *
 */
public class SingletonDemo {
	private static SingletonDemo instance = null;
	
	private SingletonDemo(){
		System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingletonDemo");
	}
	
	public static SingletonDemo getInstance() {
		if (instance == null) {
			instance = new SingletonDemo();
		}
		return instance;
	}
	
	public static void main(String[] args) {
		System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
		System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
		System.out.println(SingletonDemo.getInstance() == SingletonDemo.getInstance());
	}
}

运行结果如下:

image.png

构造方法只被执行了一次,每次获取到的instance指向的引用相同,单例模式无误!

2、上面的单例模式在单线程下运行没有问题,我们来实时多线程情况下,单例是否依然有效,在main方法中使用创建10个线程来执行:

package com.jiguiquan.www;

/**
 * 经典的单机版单例模式(懒汉式)修改为多线程版本 
 * 在main线程下,创建10个线程来执行获取示例的方法
 * @author jiguiquan
 *
 */
public class SingletonDemo {
	private static SingletonDemo instance = null;
	
	private SingletonDemo(){
		System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingletonDemo");
	}
	
	public static SingletonDemo getInstance() {
		if (instance == null) {
			instance = new SingletonDemo();
		}
		return instance;
	}
	
	//多线程下,情况发生了很大变化
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(() -> {
				SingletonDemo.getInstance();
			},String.valueOf(i)).start();
		}
	}
}

执行结果如下:

image.png

很显然,在多线程环境下,情况发生了很大改变,传统单例模式不再是单例模式,这肯定是不行的啊,必须解决;

3、解决办法:

3.1、在getInstance方法上增加synchronized锁,无疑是可以解决的

但是还是那句话,杀鸡焉用牛刀,在getInstance上增加synchronized后,整个创建单例的过程都被锁了,每个线程去执行getInstance都被锁,太影响性能,这么做虽然可以达到单例效果,但性能损失严重,不适宜;

3.2、DCL模式实现多线程环境下的单例模式

DCL = Double Check Lock双端检锁机制,或叫双重保护锁

package com.jiguiquan.www;

/**
 * 经典的单机版单例模式(懒汉式)修改为多线程版本 
 * 在main线程下,创建10个线程来执行获取示例的方法
 * @author jiguiquan
 *
 */
public class SingletonDemo {
	private static SingletonDemo instance = null;
	
	private SingletonDemo(){
		System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingletonDemo");
	}
	
	//DCL模式(Double Check Lock双端检锁机制,或叫双重保护锁)
	public static SingletonDemo getInstance() {
		if (instance == null) { //优点是,在已经有instance的情况下,根本就不用加Synchronized锁了
			//如果确实还没有instance,那么这个时候,增加一把锁,防止自己在创建实例的过程中,其他线程也在创建实例,造成多例
			synchronized ( SingletonDemo.class) { 
				if (instance == null) {  //再次检查是为了防止在上一次判断和加锁过程中被其他线程加塞了,万分小心
					instance = new SingletonDemo();
				}
			}
		}
		return instance;
	}
	
	//多线程下,情况发生了很大变化
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(() -> {
				SingletonDemo.getInstance();
			},String.valueOf(i)).start();
		}
	}
}

多次运行结果都如下:

image.png

此时,貌似多线程环境下的单例模式已经得到了完美的实现;

其实不然:这种情况下,也许运行100万次都正确,但是可靠性只是99.99%,因为为了性能和效果,底层有指令重排优化,存在潜在的多例隐患;

4、使用volatile完善DCL(双端检锁)单例模式,禁止指令重排优化;

分析:当某个线程执行第一次检测的时候,读取到的instance不为null的时候,instance的引用对象可能没有完成初始化

     instance = new SingletonDemo();  可以分为以下3步完成(伪代码)

memory = allocate();  //1、分配对象内存空间
instance(memory);  //2、初始化对象
instance = memory;  //3、设置instance指向刚分配的内存地址,此时instance != null

由于步骤2和步骤3不存在数据依赖关系,而且无论重排前还是重排后,程序的执行结果在单线程环境中都没有改变;

因此这种指令重排是被允许的,可能实际执行的指令顺序是这样的:

memory = allocate();  //1、分配对象内存空间
instance = memory;  //3、设置instance指向刚分配的内存地址,此时instance != null,但是对象还没有初始化完成
instance(memory);  //2、初始化对象

这边要好好地理解一番,只要执行完步骤3,那么此时整个系统中此instance就已经不为null了,那么其他线程在执行到这一步的时候,就会因为instance不为null,而不进行synchronized锁定代码块的操作了,而是直接获取instance使用,那么在使用的过程中必定会报对象尚未初始化的错误,因为此时的instance只是有个空壳子,真正的对象还没有入座,带来不必要的麻烦;

如果不允许指令重排的话,那么在执行到步骤3的时候,步骤2必定已经执行过了,那么就不会出现其他线程拿到一个尚未初始化过的非空instance对象;

为了解决上面的问题,我们只需要在 private static SingletonDemo instance = null;上增加一个volatile修饰符即可;

package com.jiguiquan.www;

/**
 * 经典的单机版单例模式(懒汉式)修改为多线程版本 
 * 在main线程下,创建10个线程来执行获取示例的方法
 * 在双端检锁(DCL)的情况下,为instance参数增加 volatile修饰符,避免指令重排带来的麻烦
 * @author jiguiquan
 *
 */
public class SingletonDemo {
	private static volatile SingletonDemo instance = null;
	
	private SingletonDemo(){
		System.out.println(Thread.currentThread().getName()+"\t 我是构造方法SingletonDemo");
	}
	
	//DCL模式(Double Check Lock双端检锁机制,或叫双重保护锁)
	public static SingletonDemo getInstance() {
		if (instance == null) { //优点是,在已经有instance的情况下,根本就不用加Synchronized锁了
			//如果确实还没有instance,那么这个时候,增加一把锁,防止自己在创建实例的过程中,其他线程也在创建实例,造成多例
			synchronized ( SingletonDemo.class) { 
				if (instance == null) {  //再次检查是为了防止在上一次判断和加锁过程中被其他线程加塞了,万分小心
					instance = new SingletonDemo();
				}
			}
		}
		return instance;
	}
	
	//多线程下,情况发生了很大变化
	public static void main(String[] args) {
		for (int i = 0; i < 10; i++) {
			new Thread(() -> {
				SingletonDemo.getInstance();
			},String.valueOf(i)).start();
		}
	}
}

即使执行1000万次,也不会出现安全隐患:

image.png

注意:

如果使用synchronized同步getInstance方法的时候,是不用担心指令重排的问题的,因为一个线程在执行此方法的时候,其他线程都在等待,根本不存在判断instance是否为空,拿到未初始化过的instance这个问题;

好好消化!!!

jiguiquan@163.com

文章作者信息...

留下你的评论

*评论支持代码高亮<pre class="prettyprint linenums">代码</pre>

相关推荐