1 学习内容
- 并发编程的三个重要特性
- JMM如何保证三大特性
- volatile关键字的原理和实现
- volatile关键字的使用场景
- volatile 和 synchronized 关键字的区别
2 具体内容
2.1 并发编程的三个重要特性
并发编程的三个重要特性,分别是原子性、有序性和可见性。
2.1.1 原子性
所谓的原子性是指在一次操作或者多次操作中,要么所有的操作全部都执行,并且不会受到任何因素的干扰而中断,要么所有操作都不执行。很常见的银行转账操作,小明给小康转1000元,包含两个基本操作:从小明的账号上减少1000元;小康的账号增加1000元,这两个操作必须符合原子性的操作,要么都成功要么都失败。
2.1.2 可见性
可见性是指,当一个线程对共享变量进行了修改,那么其他线程可以立即看到修改后的最新值
new Thread(()->
{
int localValue = init_value;
while(localValue < Max) {
if(init_value != localValue){
System.out.printf("the init_value is update to [%d] \n", init_value);
localValue = init_value;
}
}
}, "Reader").start();
根据上节的分析,Reader线程会将init_value从主内存缓存到CPU Cache中,也就是从主内存缓存到线程的本地内存中,Update线程对init_value的修改对Reader线程是不可见的。
2.1.3 有序性
所谓有序性是指程序代码在执行过程中的先后顺序,由于Java在编译器以及运行期优化,导致了代码执行顺序不一定按编写代码时的顺序,如
int x = 10; int y = 0; x++; y = 20;
上面这段代码定义了两个int变量 x 和 y,对 x 进行自增运算,对y进行赋值操作,看起来代码会顺序执行,但是JVM真正执行的时候不一定是这样顺序执行,如上代码有可能 y=20 会在x++前执行,这种情况就是指令重排序。处理器为了提高程序的运行效率,可能会对输入的代码指令做一定的优化,它不会保证代码就要按照代码编写的顺序来执行,但是它会保证程序运算结果是编码所期望的。
当然对指令重排序要严格遵守指令之间的数据依赖关系,假如有A和B两个运算,B运算中用到了数据A运算中输出的数据 ,则A和B运算不能重排序,A要先于B计算出来。
在多线程中如果有序性不能得到保证,就有可能出现问题,比如下面的代码:
private boolean initialized = false;
private Context context;
public Context load(){
if(!initialized){
context = loadContext();
initialized = true;
}
return context;
}
上面代码中使用boolean变量来控制context是否已经被加载过了,在单线程中,最终返回给使用者的context都是可用的。如果在多线程下发生了重排序,比如context = loadContext()的执行被重排序到 initialized = true 之后,那就完蛋了。比如第一个线程首先判断到 initialized = false ,因此准备执行context的加载,但是执行loadContext()方法之前二话不说就将initialized 置为 true,然后再执行loadContext()方法,那么如果另外一个线程也执行load方法,发现initialized已经为true了,则直接返回一个还未被成功加载的context,那么程序运行过程中势必会出现错误。
2.2 JMM如何保证三大特性
JVM采用内存模型的机制来屏蔽各个平台和操作系统之间内存访问的差异,以实现让Java程序在各个平台达到一致的内存访问效果,比如C语言的整型变量在某些平台是2个字节,在某些平台是4个字节,而Java在任何平台都是4个字节,这就是所谓的一致内存访问效果。
2.2.1JMM与原子性
在java语言中,对基本数据类型的变量赋值操作都是原子性的,对引用类型变量的赋值和读取操作也是原子性的,下面看几个例子:
- x = 10,赋值操作
x = 10是原子性操作,执行线程会将x=10写入工作内存中,然后再将其写入主内存 - y = x; 赋值操作
这条操作是非原子性的,每一步原子,合在一起不是原子操作,它包含如下几个重要的步骤:- 执行线程从主内存中读取 x 的值,然后将 其存入当前工作线程的 本地内存中
- 在执行线程的工作线程中修改 y 的值为 x , 然后将 y 的值写入主内存中,
- y++; 自增操作
这条操作是非原子性的,包含三个步骤:- 执行线程从主内存中读取 y 值,然后将其存入到线程的工作内存中
- 在执行线程的工作内存中 为 y 进行 加 1 操作
- 将 y 的值写入主内存中
- z = z + 1;加 1 操作(与自增操作等价)
这条操作是非原子性的,包含三个步骤:- 执行线程从主内存中读取 z 值,然后将其存入到线程的工作内存中
- 在执行线程的工作内存中 为 z 进行 加 1 操作
- 将 z 的值写入主内存中
综合上面四个例子,我们可以发现只有第一种操作具备原子性,其它均为非原子操作,
由此得出如下几个结论
- 多个原子操作在一起不再是原子操作
- 简单的读取与赋值操作是原子性的,将一个变量赋值给另外一个变量操作不是原子性的
- Java内存模型(JMM)只保证了基本读取和赋值的原子性操作,其它的均不保证,如果想要使得某些代码片段具备原子性,需要使用synchronized,或者JUC中的lock,如果想要使用int等自增操作具备原子操作,可以使用JUC包下的原子封装类型java.util.concurrent.atomic.*
总结:volatile 关键字不具备保证原子性的语义
2.2.2JMM与可见性
Java提供了三种方式保证可见性:
- 使用关键字volatile,当一个变量被volatile关键字修饰时,对于共享资源的读操作会直接从主内存中获取,当其他线程对共享资源进行了修改,则会导致当前线程的工作内存中的共享资源失效,从而必须从主内存中再次获取最新的值,对于共享资源的写操作当然也是先要修改工作内存,修改结束后会立即刷新回主内存中。
- 通过synchronized关键字保证可见性,synchronized关键字能够保证同一时刻只有一个线程获得锁,然后执行同步方法,并且还会确保在锁释放前,会将对变量的修改刷新到主内存中。
- 通过JUC提供的显式锁Lock保证可见性,Lock的lock方法能够保证在同一时刻只有一个线程获得锁然后执行同步方法,并且会确保在锁释放之前会将对变量的修改刷新到主内存中。
总结:volatile关键字具有保证可见性的语义
2.2.3JMM与有序性
在Java内存模型中,允许编译器和处理器对指令进行重排序,多线程中重排序会影响到程序的正确运行,Java提供了三种保证有序性的保证,具体如下:
- 使用volatile关键字保证有序性
- 使用synchronized关键字保证有序性
- 使用显式锁Lock来保证有序性
synchronized和Lock显式锁两者采用了同步的机制,同步代码在执行的时候与单线程下一样自然能够保证顺序性,Java内存模型具有具有一些天生的有序性规则,不需要任何同步手段就能保证有序性,这个规则就是happens-before原则。如果两个操作的执行次序无法从happens-before原则推到出来,那么它就无法保证有序性。
下面看看happens-before原则:
- 程序次序规则:在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生于编写在前面操做之后;
- 锁定规则:一个unlock操作要先行发生于同一个锁的lock操作(先释放再lock)如果同一个锁是锁定状态,那么先必须对其进行释放操作之后才能继续进行lock操作;
- volatile变量规则:对一个变量的写操作要早于这个变量之后的操作;
- 传递规则:如果操作A先于操作B,B操作先于C操作,则可以得出操作A早于C,说明了happens-before原则具备传递性;
- 线程启动原则:Thread对象的start()方法先行发生于该线程的任何操作动作前;
- 线程中断原则:对线程执行interrupt()方法肯定要优先于捕捉到中断信号,意思是如果线程收到了中断信号,那么在此之前必有interrupt();
- 线程的终结规则:线程中所有的操作都要先行发生于线程的终止检测,通俗的说线程任务执行、逻辑单元执行肯定要发生于线程的终止之前;
- 对象的终结规则:一个对象初始化的完成要先于finalize()方法之前(由生到死)
2.3 volatile关键字深入分析
/**
* 开启两个线程,一个进行读操作,一个线程负责写数据
* @author kangna
*
*/
public class VolatilFoo {
// init_value 最大值
final static int Max = 5;
//init_value 初始值
static int init_value = 0;
public static void main(String args[]){
/**
* 启动一个Reader线程,当发现init_value 和 local_value 不同时,则输出 init_value 被修改的信息
*/
new Thread(()->
{
int localValue = init_value;
while(localValue < Max) {
if(init_value != localValue){
System.out.printf("the init_value is update to [%d] \n", init_value);
localValue = init_value;
}
}
}, "Reader").start();
/**
* 启动一个Update线程,用于对init_value的修改,当local_value>=5时退出生命周期
*/
new Thread(()->
{
int localValue = init_value;
while(localValue < Max){
System.out.printf("the init_value will be change to [%d] \n", ++localValue);
init_value = localValue;
// 短暂
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "update").start();
}
}
2.3.1 volatile关键字语义
volatile修饰的实例变量或者类变量具有如下含义:
- 保证了不同线程之间对共享变量操作时的可见性,也就是说当一个线程修改volatile修饰的变量,另一个线程会立即看到最新的值
- 禁止对指令进行重排序
(1)理解volatile保证可见性
- 在volatileFoo例子中,Update线程对init_value变量的每一个修改都会使得Reader线程能够看到(happens-before第三条volatile变量规则:一个变量的写操作要早于对这个变量之后的读操作),其步骤如下:
-
Reader线程从主内存中获取init_value的值为0,并且将其缓存到本地内存中
-
Update线程将init_value的值在本地工作内存中修改为1,然后立即刷新至主内存中
-
Reader线程在本地工作内存中的init_value失效(反映到硬件上就是CPU的L1 或者 L2 的 Cache line失效)
-
由于Reader线程工作内存中的init_value失效,因此需要到主内存中重新读取init_value的值
-
(2)理解volatile保证顺序性
-
volatile关键字对顺序性的保证比较霸道一点,直接禁止JVM和处理器对volatile关键字修饰的指令重排序,但是对于volatile前后无依赖关系的指令可以随便怎么排序,比如
int x = 0; int y = 1; volatile int z = 20; x++; y--;
在语句volatile z = 20 之前,先执行 x 的定义还是先执行 y 的定义,我们并不关心,只要能够保证在执行到 z = 20的时候x = 0 , y =1 ,同理关于 x 的自增以及 y 的自减操作必须在 z = 20 以后才能发生。
(3)理解volatile不保证原子性
此处 我们 举一个例子。
package com.itheima.day01;
import java.util.concurrent.TimeUnit;
class MyCupThread implements Runnable {
/*
* 定义100个水杯
* 虽然 volatile 关键字可以保证 可见性和 有序性但是 当 休眠时间在 100000 毫米左右时 我们可以看出 volatile 关键字并不能保证原子性
*/
private static volatile int cupNum = 100;
@Override
public void run() {
int countNum = 0;
while (true) {
if (cupNum > 0) {
cupNum--; // 总数 减 1
countNum++;
System.out.println(Thread.currentThread().getName() + "卖出:" + countNum + "-------剩余:" + cupNum);
} else {
break;
}
try {
TimeUnit.MICROSECONDS.sleep(100_000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
}
public class ThreadLevel01_06 {
public static void main(String[] args) {
MyCupThread mt = new MyCupThread();
Thread t1 = new Thread(mt, "实体店");
Thread t2 = new Thread(mt, "官网");
t1.start();
t2.start();
}
}
我们可以看看运行结果:结果显然 不是我们想看到的。
官网卖出:55-------剩余:4
实体店卖出:55-------剩余:4
官网卖出:56-------剩余:3
实体店卖出:56-------剩余:2
官网卖出:57-------剩余:1
实体店卖出:57-------剩余:0
2.3.2 volatile的原理和实现机制
volatile关键字可以保证可见性和顺序性,那么它到底是怎么办到的呢?通过OpenJDK下unsafe.cpp源码阅读,会发现volatile修饰的变量存在于一个“lock;”的前缀
//Adding a lock prefix to an instruction on MP machine
#define Lock_IF_MP(mp) "cmp $0, " #tmp "; je lf; lock; 1: "
...
inline lint Atomic::compxchg (jint exchange_value, volatile jint* dest, jint compare_value){
int mp = os::is_MP();
_asm_volatile(Lock_IF_MP(%4) "compxchg %1,(%3)"
: "=a" (exchange_value)
: "r" (exchange_value), "a" (compare_value), "r"(dest), "r" (mp)
: "cc", "memory");
return exchange_value;
}
“lock;”前缀实际上是一个内存屏障,该内存屏障会为指令的执行提供如下几个保障:
- 确保指令重排序时不会将其后面的代码排到内存屏障之前
- 确保指令重排序时不会将其前面的代码排到内存屏障之后
- 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成
- 强制将线程工作内存中的值修改刷新至主内存中
- 如果是写操作,则会导致其它线程工作内存中的缓存数据失效
2.3.3 volatile的使用场景
虽然volatile有部分synchronized关键字的语义,但是volatile不可能完全替代synchronized关键字,因为volatile关键字不具备原子操作语义,我们在使用volatile关键字的时候也是充分利用它的可见性以及有序性特点。
(1)开关控制利用可见性的特点
开关控制中最常见的就是进行线程的关闭操作,我们来看一段代码:
public class ThreadCloseale extends Thread{
//volatile关键字保证了started线程的可见性
private volatile boolean started = true;
@Override
public void run(){
while(started){
//do work
}
}
public void shutdown(){
this.started = false;
}
}
当外部线程执行ThreadCloseable 的 shutdown 方法时,ThreadCloseable 会立即看到 started发生了变化(因为 ThreadCloseable 工作内存中的started失效了,不得不到主内存中重新获取)
如果started没有被volatile修饰,那么有可能外部线程在其工作内存中修改了started之后来不及及时刷新到主内存中,或者ThreadCloseable一直到自己工作内存中存取started变量,都有可能导致started = true 不生效,线程就会无法关闭。
(2)状态标记利用顺序性的特点
使用状态标记说明顺序性的特点,用之前context举例:
private boolean initialized = false;
private Context context;
public Context load(){
if(!initialized){
context = loadContext();
initialized = true;
}
return context;
}
(3)Singleton设计模式的double-check也是利用了顺序性的特点
public final class Singleton{
//实例变量
private byte[] data = new byte[1024];
private volatile static Singleton instance = null;
Connection conn;
Socket socket;
private Singleton{
this.conn // 初始化 conn
this.socket // 初始化 socket
}
public static Singleton getInstance{
// 当instance为 null 时, 进入同步代码块,同时该判断避免了每次都需要进入同步代码块,可以提高效率
if(null == instance){
// 只有一个线程可以获得Singleton.class 关联的monitor
synchronized(Singleton.class){
//判断如果 instance 为 null 创建
if(null == instance){
instance = new Singleton();
}
}
}
return instance;
}
}
后期我们会对单例模式进行分析,尤其分析volatile关键字对double-check的改进。
2.3.4 volatile 和 synchronized比较
通过对volatile关键字的学习和之前synchronizedde 的认识,在此总结一下两者的区别:
(1)使用上的区别
- volatile关键字只能用于修饰变量或者类变量,不能用于修饰方法以及方法参数和局部变量、常量等
- synchronized关键字不能用于对变量的修饰 ,只能用于方法或者代码块
- volatile修饰的变量可以为null,synchronized关键字同步代码块的monitor对象不能为null
(2)对原子性的保证
- volatile无法保证原子性
- 由于synchronized是一种排他的机制,因此被synchronized关键字修饰的同步代码是无法中途打断的,因此其能够保证代码的原子性
(3)对可见性的保证
- 两者均可以保证共享资源在多线程间的可见性,但是实现机制完全不同
- synchronized借助于JVM指令 monitor enter 和 monitor exit 对通过排他的方式使得同步代码串行化,在monitor exit时所有共享资源都会被刷新到主内存中
- 相比较于synchronized关键字volatile使用机器指令(偏硬件)“lock;”的方式迫使其它线程工作内存中的数据失效,不得到主内存中进行再次加载
(4)对有序性的保证
-
volatile关键字禁止JVM编译器以及处理器对其进行重排序,所以它能够保证有序性
-
虽然synchronized关键字所修饰的同步方法也可以保证顺序性,但是这种顺序性是以程序串行化执行换来的,在synchronized关键字所修饰的代码块中代码指令也会发生指令重排序的情况,比如:
synchronized(this){ int x = 10; int y =20; x++; y = y + 1; }
x 和 y 谁最先定义以及谁后定义,对程序来说没有任何的影响,另外 x 和 y 之间
也没有依赖关系,但是由于synchronized关键字同步的作用,在synchronized 的作用域结束时 x 必定是 11, y 必定是 21 , 也就是达到了最终输出结果和代码编写顺序的一致性。
(5)其它
volatile关键字不会陷入阻塞
synchronized关键字会使线程陷入阻塞
3 总结
- 并发的三个重要特征:原子性、有序性和可见性
- Java内存模型JMM如何保证并发的三个特征
- volatile关键字的原理和实现
- volatile关键字的使用场景
- volatile 和 synchronized 关键字的区别