Java线程教程(三)之volatile关键字与synchronized区别

目录

一、基本概念

二、volatile的使用

三、留意复合类操作

四、synchronized与volatile辨析

4.1重排序与happen-before规则

4.2 happen-before规则

4.3 内存屏障指令(memory barriers)

4.4 volatile实现原理 

五、volatile关键字语义

 

一、基本概念

先补充一下概念:Java 内存模型中的可见性、原子性和有序性。

原子性:原子性是指多线程一起执行时,一个线程操作开始后不会被其他线程干扰,操作不可被中断;

  • synchronizd临界区执行具有原子性;
  • volatile仅仅保证对单个volatile变量的操作具有原子性;

可见性:一个线程修改共享变量时,其他线程能够立即知道这个修改;

  • 单线程:不存在内存可见性问题;
  • 多线程:Java通过volatilesynchronizedfinal关键字实现可见性;
    • volatile:valatile变量保证变量新值立即被同步回主存,每次读取valtile变量都立即从主存刷新;
    • synchronized:对变量进行解锁前,将对应变量同步回内存;
    • final:final字段一旦初始化完毕,并且this引用没有发生逃逸,其他线程立即看到final字段值;

有序性:线程内操作有序进行,线程间操作有序进行;

  • Java通过volatilesynchronized保证线程间操作的有序性
    • volatile通过禁止重排序实现有序性;
    • synchronized通过声明临界区,保证线程互斥访问,实现有序性;、、

二、volatile的使用

volatile是Java提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同synchronized相比(synchronized通常称为重量级锁),volatile更轻量级,相比使用synchronized所带来的庞大开销,倘若能恰当的合理的使用volatile,自然是美事一桩。

  为了能比较清晰彻底的理解volatile,我们一步一步来分析。首先来看看如下代码

代码一:

class ThreadVolatileDemo extends Thread {
	
	public  boolean flag = true;
	
	@Override
	public void run() {
		System.out.println("开始执行子线程...");
		while(flag){
		}
		System.out.println("结束子线程...");
	}
	
	public void setRunning(boolean flag){
		this.flag = flag;
	}

}

public class ThreadVolatile{
	
	public static void main(String[] args) throws InterruptedException {
		ThreadVolatileDemo demo = new ThreadVolatileDemo();
		demo.start();
		Thread.sleep(2000);
		demo.setRunning(false);
	}
}

运行结果:

上面的输出可能会让人有些疑惑,可以理解。因为倘若在单线程模型里,先运行setRunning方法,再执行run方法,自然是可以正确输出"结束子线程..."的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量flag来说,线程之间是不可见的,读取的是副本,没有及时读取到主内存结果。也就是说,线程读取没有及时读到“主内存”中的flag值。那么什么是可见性呢?

  所谓可见性,是指当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的。很显然,上述的例子中是没有办法做到内存可见性的。

解决办法使用Volatile关键字将解决线程之间可见性, 强制线程每次读取该值的时候都去“主内存”中取值

代码二:


class ThreadVolatileDemo extends Thread {
	
	public volatile boolean flag = true;
	
	@Override
	public void run() {
		System.out.println("开始执行子线程...");
		while(flag){
		}
		System.out.println("结束子线程...");
	}
	
	public void setRunning(boolean flag){
		this.flag = flag;
	}

}

public class ThreadVolatile{
	
	public static void main(String[] args) throws InterruptedException {
		ThreadVolatileDemo demo = new ThreadVolatileDemo();
		demo.start();
		Thread.sleep(2000);
		demo.setRunning(false);
	}
}

运行结果:

上面的例子只需将flag声明为volatile,即可保证在主线程将其修改为false时,子线程可以立刻得知

volatile具备两种特性,第一就是保证共享变量对所有线程的可见性。将一个共享变量声明为volatile后,会有以下效应:

1.当写一个volatile变量时,JMM会把该线程对应的本地内存中的变量强制刷新到主内存中去;

2.这个写会操作会导致其他线程中的缓存无效。

我们按照上一篇的博客Java的内存模型进行解释该问题,

java虚拟机有自己的内存模型(Java Memory Model,JMM),JMM可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各种平台下都能达到一致的内存访问效果。JMM决定一个线程对共享变量的写入何时对另一个线程可见,JMM定义了线程和主内存之间的抽象关系:共享变量存储在主内存(Main Memory)中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这三者之间的交互关系如下

Java线程教程(三)之volatile关键字与synchronized区别_第1张图片

需要注意的是,JMM是个抽象的内存模型,所以所谓的本地内存,主内存都是抽象概念,并不一定就真实的对应cpu缓存和物理内存。当然如果是出于理解的目的,这样对应起来也无不可。

  大概了解了JMM的简单定义后,问题就很容易理解了,对于普通的共享变量来讲,比如我们代码一中的flag,主线程将其修改为false这个动作发生在主线程的本地内存中,此时还未同步到主内存中去;而子线程缓存了flag的初始值true,此时可能没有观测到flag的值被修改了,所以就导致了上述的问题(“结束子线程...”一直不输出)。那么这种共享变量在多线程模型中的不可见性如何解决呢?比较粗暴的方式自然就是加锁,但是此处使用synchronized或者Lock这些方式太重量级了,有点炮打蚊子的意思。比较合理的方式其实就是volatile

三、留意复合类操作

但是需要注意的是,我们一直在拿volatile和synchronized做对比,仅仅是因为这两个关键字在某些内存语义上有共通之处,volatile并不能完全替代synchronized,它依然是个轻量级锁,在很多场景下,volatile并不能胜任。看下这个例子:

public class ThreadVolatile extends Thread {
	
	private static volatile int count;
	
	@Override
	public void run() {
		add();
	}
	
	public void add(){
		for(int i=0;i<100;i++){
			count++;
		}
		System.out.println(count);
	}
	
	public static void main(String[] args) throws InterruptedException {
		ThreadVolatile[] arr = new ThreadVolatile[10];
		for(int i=0;i<10;i++){
			arr[i] = new ThreadVolatile();
		}
		for(int i=0;i<10;i++){
			arr[i].start();
		}
	}
}

运行结果:

Java线程教程(三)之volatile关键字与synchronized区别_第2张图片

针对这个示例,一些人可能会觉得疑惑,如果用volatile修饰的共享变量可以保证可见性,那么结果不应该是1000么?

问题就出在count++这个操作上,因为count++不是个原子性的操作,而是个复合操作。我们可以简单讲这个操作理解为由这三步组成:

  1.读取

  2.加一

  3.赋值

  所以,在多线程环境下,有可能线程A将count读取到本地内存中,此时其他线程可能已经将count增大了很多,线程A依然对过期的count进行自加,重新写到主存中,最终导致了count的结果不合预期,而是小于1000。

解决count++操作的原子性问题

针对count++这类复合类的操作

一种、可以使用synchronized同步来保证其原子性

一种、Atomic方式可以使用java并发包中的原子操作类原子操作类是通过循环CAS的方式来保证其原子性的。

synchronized方式

public class ThreadVolatile extends Thread {
	
	private static volatile int count;
	
	private static Object obj = new Object();
	
	@Override
	public void run() {
//		synchronized (obj) {
			add();			
//		}
	}
	
	public synchronized static void add(){
		for(int i=0;i<100;i++){
			count++;
		}
		System.out.println(count);
	}
	
	public static void main(String[] args) throws InterruptedException {
		ThreadVolatile[] arr = new ThreadVolatile[10];
		for(int i=0;i<10;i++){
			arr[i] = new ThreadVolatile();
		}
		for(int i=0;i<10;i++){
			arr[i].start();
		}
	}
}

运行结果:

Java线程教程(三)之volatile关键字与synchronized区别_第3张图片

Atomic方式代码

public class ThreadVolatile extends Thread {
	
	//使用原子操作类
    public static AtomicInteger count = new AtomicInteger(0);
	
	
	@Override
	public void run() {
		add();			
	}
	
	public void add(){
		for(int i=0;i<100;i++){
			count.incrementAndGet();
		}
		System.out.println(count);
	}
	
	public static void main(String[] args) throws InterruptedException {
		ThreadVolatile[] arr = new ThreadVolatile[10];
		for(int i=0;i<10;i++){
			arr[i] = new ThreadVolatile();
		}
		for(int i=0;i<10;i++){
			arr[i].start();
		}
	}
}

运行结果:

Java线程教程(三)之volatile关键字与synchronized区别_第4张图片

四、synchronized与volatile辨析

  • volatile是线程同步的轻量级实现,只用于修饰变量,synchronized用于修饰方法和语句块;
  • 多线程访问volatile不会发生阻塞,但是synchronized会发生阻塞;
  • volatile保证数据的可见性,不保证原子性;synchronized保证数据的可见性和原子性;
  • volatile强调共享变量在多线程之间的可见性,synchronized强调多线程访问资源的同步性;

4.1重排序与happen-before规则

Java线程教程(三)之volatile关键字与synchronized区别_第5张图片

 

影响多线程有序性:重排序

  • 编译器重排序:编译器保证不改变单线程执行结果的前提下,可以调整多线程语句执行顺序;
  • 处理器重排序:如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;

JMM通过happen-before规则,底层禁止特定类型的编译器重排序和处理器重排序,保证内存的可见性和有序性;

4.2 happen-before规则

JMM为所有操作定义了一个偏序关系,称之为happen-before。在JMM中,如果A操作对B操作存在happen-before关系;A操作的执行结果全部对B操作可见;因此不同操作的时间顺序和先行发生规则没有关系,happen-before强调前者修改结果全部对后者可见;如果A,B操作不存在happen-before关系,JVM会对它们进行任意重排序;

JMM默认happen-before规则:

  • 程序顺序规则:一个线程的每个操作,先于该线程其他后续操作执行;
    • 线程的start()方法先于线程内其他方法执行,线程所有操作先于线程的终结操作;
  • 锁规则:对一个monitor的解锁必然先于对该monitor的加锁;
  • volatile变量规则:对volatile的写操作先于读操作;
  • 传递性:A先于B,B先于C,必然A先于C;

4.3 内存屏障指令(memory barriers)

内存屏障指令是一组处理指令,用来限制内存操作的顺序;

4.4 volatile实现原理 

volatile变量写,汇编指令会多出Lock前缀,Lock前缀在多核处理器下的作用:

  • 将当前处理器缓存行的数据写回主存;
  • 令其他CPU里缓存该内存地址的数据无效;

针对编译器重排序:JMM针对编译器指定了volatile重排序规则表,规定哪些先后操作不能进行编译器重排序:

Java线程教程(三)之volatile关键字与synchronized区别_第6张图片

针对处理器重排序:编译器在生成字节码指令时,通过在指令序列中插入内存屏障指令来禁止特定类型的处理器重排序,以实现volatile内存语义:volatile底层通过内存屏障指令实现

Java线程教程(三)之volatile关键字与synchronized区别_第7张图片

 

  • 在每个volatile变量写操作之前插入StoreStore屏障,之后插入StoreLoad屏障;
    • 之前插入StoreStore屏障:禁止volatile写之前的写操作与其重排序,保证之前的所有写操作都写回主存,对volatile写可见;
    • 之后插入StoreLoad屏障:禁止volatile写之后的读写操作与其重排序,实现volatile写结果对后续操作可见;
  • 在每个volatile变量读操作之后,接连插入LoadLoad屏障,LoadStore屏障;
    • 插入LoadLoad屏障:禁止volatile变量读之后的读操作与其重排序;
    • 插入LoadStore屏障:禁止volatile变量读之后的写操作与其重排序;
    • 通过插入两次内存屏障,实现volatile读结果对后续操作可见;

JMM通过上述内存屏障插入策略,保证在任意平台上volatile的内存语义一致;

五、volatile关键字语义

volatile用来修饰共享变量(成员变量,static变量)表明:

  • volatile变量写:当写一个volatile变量时,JMM会把所有线程本地内存的对应变量副本刷新回主存;
    • volatile写和解锁内存语义相同;
  • volatile变量读:当读一个volatile变量时,JMM会设置该线程的volatile变量副本(本地内存中)无效,线程只能从主存中读取该变量;
    • 保证了volatile变量读,总能看见对该volatile变量最后的修改;
    • volatile变量读和加锁内存语义相同;

通过上述机制,volatile保证共享变量一旦被修改,新值对所有线程可见;

 

 

 

 

 

 

你可能感兴趣的:(多线程)