volatile关键字和Java内存模型

有目标就不怕路远。年轻人.无论你现在身在何方.重要的是你将要向何处去。只有明确的目标才能助你成功。没有目标的航船.任何方向的风对他来说都是逆风。因此,再遥远的旅程,只要有目标.就不怕路远。没有目标,哪来的劲头?一车尔尼雷夫斯基

导读:本篇文章讲解 volatile关键字和Java内存模型,希望对大家有帮助,欢迎收藏,转发!站点地址:www.bmabk.com,来源:原文

volatile关键字和Java内存模型

Java内存模型(Java Memory Model,JMM)

CPU的内存模型

CPU很快,内存很慢,为了充分利用CPU,出现了如下解决方案:

  • 出现了高速缓存

这里的内存,也是主内存(main memory),就是我们说的内存条;高速缓存,就是集成在CPU里的多少级的缓存。

引入高速缓存,也带来了”缓存一致性”问题。CPU不是直接读主内存的数据,它是读高速缓存。CPU有多个核,每个核有自己的高速缓存,就会出现A核改了变量值,同步到A核的高速缓存单没有同步到主内存,而B核里的高速缓存的变量值还是旧的。这就需要主内存作为桥梁。如图引入高速缓存的一致性协议:
image
(经过研究,这里的处理器字样,表示的是CPU,也就是说这幅图画的是多CPU的系统保持缓存一致性的问题)

  • 乱序执行(指令重排)

除了增加高速缓存之外,为了使得处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行(Out-Of-Order Execution)优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的。

与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有类似的指令重排序(Instruction Reorder)优化。

Java的内存模型

主内存和工作内存

Java虚拟机规范中试图定义一种Java内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景就必须针对不同的平台来编写程序。

类似上面提到的CPU内存模型,如下图。Java里的线程对变量的访问并不是直接访问主内存的,而是每个线程有自己的一份工作内存(working memory),这个工作内存就相当于 “高速缓存”。所以不同线程之间可能看到同一个变量的值,可能是不同的,即缓存一致性问题。

image

注意
这里的变量(Variables),指的是实例变量、类变量(即静态变量),但不包括局部变量(局部变量包括形参),因为后者是线程私有的,不会被其他线程共享自然没有资源竞争问题。

内存间交互操作

主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成

以下8种操作来完成,虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许有例外)可以搜索double long 非原子性继续了解

  • lock:锁定主内存,它把一个变量标识为一条线程独占的状态。
  • unlock:解锁主内存
  • read:读取主内存变量
  • load:将从主内存读取的变量值载入工作内存
  • use:从工作内存读取变量,并交给虚拟机执行引擎
  • assign:将虚拟机执行引擎的计算结果赋值给工作内存
  • store:读取工作内存的值
  • write:将store得到的值写入主内存

原子性、可见性与有序性

原子性(Atomicity)

一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。

哪些是原子操作,哪些不是原子操作,先有一个直观的印象:

int k = 5;  //代码1
k++;        //代码2
int j = k;  //代码3
k = k + 1;  //代码4

上面这4个代码中只有代码1是原子操作。

代码2:包含了三个操作。1.读取变量k的值;2.将变量k的值加1;3.将计算后的值再赋值给变量k。

代码3:包含了两个操作。1.读取变量k的值;2.将变量k的值赋值给变量j。

代码4:包含了三个操作。1.读取变量k的值;2.将变量k的值加1;3.将计算后的值再赋值给变量k。

注:实际编译成字节码后,这些代码的字节码条数跟我上面的操作数可能有出入,但为了更容易理解,并且这些操作已经总体上能说明问题,因此使用这些操作来分析。

synchronized关键字能保证原子性,因为单线程的不会被打断。

可见性(Visibility)

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。

先看下以下的例子,对可见性有一个直观的印象:

// 线程A执行的代码
int k = 0; //1
k = 5;     //2
// 线程B执行的代码
int j = k; //3

上面这个例子,如果线程A先执行,然后线程B再执行,j的值是多少了?

答案是无法确定,变量k被A线程修改后,A线程的工作内存是改了,能确定一定同步到主内存了吗? 如果没有,则线程B先从主内存获取变量k的值到其工作内存中,获取的值就是旧的。

synchronized关键字能保证可见性,是因为其规定了 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作) 这条规则。

final关键字也能保证可见性,因为是不可改变的(只要保证this不逃逸,详细搜索final可见性)

有序性(Ordering)

先看下以下的例子,对有序性有一个直观的印象:

int k = 0; 
int j = 1  
k = 5; //代码1
j = 6; //代码2

代码2不一定在代码之后执行,JVM可能会发生指令重排

有依赖性的,则不会发生指令重排,如下肯定是代码1在代码2之前执行

int k = 1; // 代码1
int j = k; // 代码2

volatile可以保证有序性,其包含禁止指令重排序的语意。
synchronized也可以保证有序性,是 一个变量在同一个时刻只允许一条线程对其进行lock操作 这条规则保证的

重排序

下面介绍重排序的相关知识

什么是重排序

重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。

有哪些重排序
  • 编译器优化的重排序
  • 指令级并行的重排序
  • 内存系统的重排序
为什么要重排序

提高效率

重排序会导致错误的结果吗?

可能会。

  • 单线程下不会改变执行结果
  • 多线程下可能会改变执行结果
如何禁止重排序

可以通过插入内存屏障指令来禁止特定类型的处理器重排序。如使用volatile等

总结

关键字 保证
synchronized 原子性、可见性、有序性
volatile 可见性、有序性,不能保证原子性
final 可见性

先行发生(happens-benfore)

介绍happens-before是什么鬼

Java语言中有一个“先行发生”(happens-before)的原则。这个原则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,依靠这个原则,我们可以通过几条规则解决并发环境下两个操作之间是否可能存在冲突的所有问题。

现在就来看看“先行发生”原则指的是什么。先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、调用了方法等。这句话不难理解,但它意味着什么呢?我们可以举个例子来说明一下,如代码中所示的这3句伪代码。

//以下操作在线程A中执行
k=1;
//以下操作在线程B中执行
j=k;
//以下操作在线程C中执行
k=2;

假设线程A中的操作“k=1”先行发生于线程B的操作“j=k”,那么可以确定在线程B的操作执行后,变量j的值一定等于1,得出这个结论的依据有两个:一是根据先行发生原则,“k=1”的结果可以被观察到;二是线程C还没“登场”,线程A操作结束之后没有其他线程会修改变量k的值。现在再来考虑线程C,我们依然保持线程A和线程B之间的先行发生关系,而线程C出现在线程A和线程B的操作之间,但是线程C与线程B没有先行发生关系,那j的值会是多少呢?答案是不确定!1和2都有可能,因为线程C对变量k的影响可能会被线程B观察到,也可能不会,这时候线程B就存在读取到过期数据的风险,不具备多线程安全性。

下面是Java内存模型下一些“天然的”先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序。

  • 程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说,应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
  • 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而“后面”是指时间上的先后顺序。
  • volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”同样是指时间上的先后顺序。
  • 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
  • 线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
  • 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
  • 对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
    传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。

Java语言无须任何同步手段保障就能成立的先行发生规则就只有上面这些了,下面演示一下如何使用这些规则去判定操作间是否具备顺序性,对于读写共享变量的操作来说,就是线程是否安全,读者还可以从下面这个例子中感受一下“时间上的先后顺序”与“先行发生”之间有什么不同。

private int value=0;
pubilc void setValue(int value){ 
    this.value=value;
}
public int getValue(){ 
    return value;
}

上面的代码是一组再普通不过的getter/setter方法,假设存在线程A和B,线程A先(时间上的先后)调用了“setValue(1)”,然后线程B调用了同一个对象的“getValue()”,那么线程B收到的返回值是什么?

我们依次分析一下先行发生原则中的各项规则,由于两个方法分别由线程A和线程B调用,不在一个线程中,所以程序次序规则在这里不适用;由于没有同步块,自然就不会发生lock和unlock操作,所以管程锁定规则不适用;由于value变量没有被volatile关键字修饰,所以volatile变量规则不适用;后面的线程启动、终止、中断规则和对象终结规则也和这里完全没有关系。因为没有一个适用的先行发生规则,所以最后一条传递性也无从谈起,因此我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B中“getValue()”方法的返回结果,换句话说,这里面的操作不是线程安全的。

那怎么修复这个问题呢?我们至少有两种比较简单的方案可以选择:要么把getter/setter方法都定义为synchronized方法,这样就可以套用管程锁定规则;要么把value定义为volatile变量,由于setter方法对value的修改不依赖value的原值,满足volatile关键字使用场景,这样就可以套用volatile变量规则来实现先行发生关系

通过上面的例子,我们可以得出结论:一个操作“时间上的先发生”不代表这个操作会是“先行发生”,那如果一个操作“先行发生”是否就能推导出这个操作必定是“时间上的先发生”呢?很遗憾,这个推论也是不成立的,一个典型的例子就是多次提到的“指令重排序”,演示例子如下代码所示。

//以下操作在同一个线程中执行 
int i=1;
int j=2;

代码清单的两条赋值语句在同一个线程之中,根据程序次序规则,“int i=1”的操作先行发生于“int j=2”,但是“int j=2”的代码完全可能先被处理器执行,这并不影响先行发生原则的正确性,因为我们在这条线程之中没有办法感知到这点

上面两个例子综合起来证明了一个结论:时间先后顺序与先行发生原则之间基本没有太大的关系,所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准

volatile 详解

概述

当使用了volatile,有两个作用

  • 可见性。所有的线程访问到的共享变量都是最新的,即线程A修改了变量值,则变量值会立即同步到主内存,线程B获取的时候能检测到自己的工作内存是旧的,并重新从主内存获取
  • 禁止指令重排。即不能改变变量的赋值操作顺序,例如i++分为3个步骤,取原值,加1,赋值给i,这些步骤不能重排。

volatile 特性

volatile并不能保证原子性
假设有个i++的操作,其对应的操作有三个步骤(一定要注意,看似i++是一个操作,其实是多个操作)

  1. 取i的值
  2. 计算得到i+1的值
  3. 将新的值赋值给i

(实际上是4条指令,即4个步骤,这里简化为3个步骤)

模拟并发出现问题:

时间 线程 操作
t0 A 获取i的值为8
t1 B 获取i的值为8
t2 A 计算i+1的值为9
t3 B 计算i+1的值为9,上一步并没有把值赋值给i因此B线程的工作内存和主内存的i值依然是8
t4 A 将i值赋值给自己的工作内存,同时同步给主内存,主内存i值现在是9
t5 B 将i值赋值给自己的工作内存,同时同步给主内存,主内存的i值还是9

这就是因为volitile不能保证操作是原子性的,最终导致多线程的情况下,依然会出现问题

注意,使用javap反编译i++的代码,可以看到实际上是由4条字节码指令构成的

getstatic // 获取静态变量race,并将值压入栈顶
iconst_1  // 将int值1推送至栈顶
iadd      // 将栈顶两个int型数值相加并将结果压入栈顶
putstatic // 为静态变量race赋值

以下代码证明volatile不能保证原子性导致问题,即运行结果不是期望的200000。

package com.test.cas;

import java.util.concurrent.CountDownLatch;

public class VolatileTest4 {
	private static volatile int race = 0;
	private static void incr() {
		race++;
	}
	public static void main(String[] args) throws InterruptedException {
		//CountDownLatch latch = new CountDownLatch(20);
		for (int i = 0; i < 20; i++) {
			new Thread(new Runnable() {
				@Override
				public void run() {
					for (int j = 0; j < 10000; j++) {
						incr();
					}
					
					//latch.countDown();
					
				}
			}).start();
		}
		
		// 等全部都运行完毕再继续执行主线程
		//latch.await();
		while(Thread.activeCount() >1) {
			Thread.yield();
		}
		
		System.out.println("race:" + race);
		
	}
}

volatile 的使用场景

1、状态flag

使用volatile来修饰状态标记量,使得状态标记量对所有线程是实时可见的,从而保证所有线程都能实时获取到最新的状态标记量,进一步决定是否进行操作。

例如常见的促销活动“秒杀”,可以用volatile来修饰“是否售罄”字段,从而保证在并发下,能正确的处理商品是否售罄。(其实分布式下更建议redis分布式锁)

volatile boolean flag = false;
while(!flag){
    doSomething();
}
public void setFlag() {
    flag = true;
}

2、双重检测机制实现单例

以下是利用了volatile的 有序性,禁止了指令重排,从而避免了非常极端的情况下的错误

单例

 1/**
 2 * 双重检测
 3 * @author JoonWhee
 4 * @Date 2017年12月31日
 5 */
 6public class Singleton {
 7    // 私有化构造函数
 8    private Singleton() {}  
 9    // volatile修饰单例对象
10    private static volatile Singleton instance = null;   
11    public static Singleton getInstance() { // 对外提供的工厂方法
12        if (instance == null) {     // 第一次检测
13            synchronized (Singleton.class) {    // 同步锁
14                if (instance == null) { // 第二次检测
15                    instance = new Singleton(); // 初始化
16                }
17            }
18        }
19        return instance;
20    }
21}

这里为什么单例使用了volatile?
因为instance = new Singleton();这行代码其实有3个步骤

memory = allocate(); // 1.分配对象的内存空间
ctorInstance(memory); // 2.初始化对象
instance = memory; // 3.设置instance指向刚才分配的内存地址

正常情况下,这3条执行时按顺序执行,双重检测机制就没有问题。但是CPU内部会在保证不影响最终结果的前提下对指令进行重新排序(不影响最终结果只是针对单线程,切记),指令重排的主要目的是为了提高效率。在本例中,如果这3条指令被重排成以下顺序:

memory = allocate(); // 1.分配对象的内存空间
instance = memory; // 3.设置instance指向刚才分配的内存地址
ctorInstance(memory); // 2.初始化对象

如果A线程走到12行,判断 if (instance == null),这时B线程执行 instance = new Singleton(); // 初始化,由于指令重排后,假设先执行 instance = memory;,还未来得及执行 ctorInstance(memory);就被切换到A线程,则A线程判断 instance == null 是不成立的,因为B线程已经先赋值了,但是A线程却返回了一个 “还未初始化的对象”。所以这里要用volatile

参考资料

非常好,虽然长,推荐,Java并发:volatile关键字详解

Java内存模型,JMM

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。

文章由极客之音整理,本文链接:https://www.bmabk.com/index.php/post/135328.html

(0)
飞熊的头像飞熊bm

相关推荐

发表回复

登录后才能评论
极客之音——专业性很强的中文编程技术网站,欢迎收藏到浏览器,订阅我们!