【7】三大性质总结:原子性、可见性以及有序性

2020-4-13 08:35:57开始整理文章,原样复制

三大性质简介

在并发编程中分析线程安全的问题时往往需要切入点,那就是两个核心,三大性质

1
2
3
4
5
6
7
两个核心
1. JMM(Java内存模型)包括:主内存(系统内存)和工作内存(线程内存)
2. happens-before
三大性质
1. 原子性
2. 可见性
3. 有序性

关于synchronized和volatile已经讨论过了,就想着将并发编程中这两大神器在原子性 有序性 可见性上做一个比较,
当然这也是面试中的高频考点,值得注意。

synchronized: 具有原子性,有序性和可见性
volatile:具有有序性和可见性

原子性

原子性是指一个操作是不可中断的,要么全部执行成功要么全部执行失败,有着“同生共死”的感觉
及时在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所干扰。
我们先来看看哪些是原子操作,哪些不是原子操作,有一个直观的印象:

1
2
3
4
int a = 10; //1
a++; //2
int b=a; //3
a = a+1; //4

上面这四个语句中只有第1个语句是原子操作,将10赋值给线程工作内存的变量a,
而语句2(a++),实际上包含了三个操作:1. 读取变量a的值;2:对a进行加一的操作;3.将计算后的值再赋值给变量a,而这三个操作无法构成原子操作。
对语句3,4的分析同理可得这两条语句不具备原子性。

当然,java内存模型中定义了8种操作都是原子的,不可再分的。

  1. lock(锁定):作用于主内存中的变量,它把一个变量标识为一个线程独占的状态;
  2. unlock(解锁):作用于主内存中的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  3. read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便后面的load动作使用;
  4. load(载入):作用于工作内存中的变量,它把read操作从主内存中得到的变量值放入工作内存中的变量副本
  5. use(使用):作用于工作内存中的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作;
  6. assign(赋值):作用于工作内存中的变量,它把一个从执行引擎接收到的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作;
  7. store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送给主内存中以便随后的write操作使用;
  8. write(操作):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
    在这里插入图片描述
    在这里插入图片描述

上面的这些指令操作是相当底层的,可以作为扩展知识面掌握下。
那么如何理解这些指令了?
比如,把一个变量从主内存中复制到工作内存中就需要执行read,load操作,将工作内存同步到主内存中就需要执行store,write操作。
注意的是:java内存模型只是要求上述两个操作是顺序执行的并不是连续执行的
也就是说read和load之间可以插入其他指令,store和writer可以插入其他指令。
比如对主内存中的a,b进行访问就可以出现这样的操作顺序:read a,read b, load b,load a

由原子性变量操作read,load,use,assign,store,write,可以大致认为基本数据类型的访问读写具备原子性(例外就是long和double的非原子性协定)

synchronized

上面一共有八条原子操作,其中六条可以满足基本数据类型的访问读写具备原子性,还剩下lock和unlock两条原子操作。
如果我们需要更大范围的原子性操作就可以使用lock和unlock原子操作。
尽管jvm没有把lock和unlock开放给我们使用,但jvm以更高层次的指令monitorenter和monitorexit指令开放给我们使用,反应到java代码中就是synchronized关键字,也就是说synchronized满足原子性。

总结一下:synchronized是把一段锁住的代码当做原子,其他锁住同一个对象的线程就不可指向此段代码,因为要获取锁才可以执行。

volatile

1
2
3
4
5
6
7
8
9
10
11
2021-07-16 15:07:06 补充

简单的说,修改volatile变量分为四步:
1)读取volatile变量到local
2)修改变量值
3)local值写回
4)插入内存屏障,即lock指令,让其他线程可见

这样就很容易看出来,前三步都是不安全的,取值和写回之间,不能保证没有其他线程修改。原子性需要锁来保证。

这也就是为什么,volatile只用来保证变量可见性,但不保证原子性。

我们先来看这样一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class VolatileExample {
private static volatile int counter = 0;

public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
Thread thread = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++)
counter++;
}
});
thread.start();
}
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(counter);
}
}

开启10个线程,每个线程都自加10,000次,如果不出现线程安全的问题最终的结果应该就是:10*10,000 = 100,000;
可是运行多次都是小于100,000的结果,问题在于volatile并不能保证原子性
在前面说过counter++这并不是一个原子操作,包含了三个步骤:1.读取变量counter的值;2.对counter加一;3.将新值赋值给变量counter。
如果线程A读取counter到工作内存后,其他线程对这个值已经做了自增操作后,那么线程A的这个值自然而然就是一个过期的值,因此,总结果必然会是小于100,000的。

如果让volatile保证原子性,必须符合以下两条规则:
1.运算结果并不依赖于变量的当前值,或者能够确保只有一个线程修改变量的值;
2.变量不需要与其他的状态变量共同参与不变约束

有序性

synchronized

synchronized语义表示锁在同一时刻只能由一个线程进行获取,当锁被占用后,其他线程只能等待。
因此,synchronized语义就要求线程在访问读写共享变量时只能“串行”执行,因此synchronized具有有序性

volatile

在java内存模型中说过,为了性能优化,编译器和处理器会进行指令重排序;
也就是说java程序天然的有序性可以总结为:如果在本线程内观察,所有的操作都是有序的;如果在一个线程观察另一个线程,所有的操作都是无序的。

在单例模式的实现上有一种双重检验锁(DCL)(Double-checked Locking)的方式。
DCL问题:Thread1内部的指令重排却对Thread2产生了影响。
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
private Singleton() { }
private volatile static Singleton instance;
public Singleton getInstance(){
if(instance==null){
synchronized (Singleton.class){
if(instance==null){
instance = new Singleton();
}
}
}
return instance;
}
}

这里为什么要加volatile了?我们先来分析一下不加volatile的情况,有问题的语句是这条:

1
instance = new Singleton();

这条语句实际上包含了三个操作:1.分配对象的内存空间;2.初始化对象;3.设置instance指向刚分配的内存地址。
但由于存在重排序的问题,可能有以下的执行顺序:
在这里插入图片描述
如果2和3进行了重排序的话,线程B进行判断if(instance==null)时就会为true,而实际上这个instance并没有初始化成功,显而易见对线程B来说之后的操作就会是错得。
用volatile修饰的话就可以禁止2和3操作重排序,从而避免这种情况。
所以,volatile包含禁止指令重排序的语义,其具有有序性

可见性

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

  • 通过之前对synchronzed内存语义进行了分析,当线程获取锁时会从主内存中获取共享变量的最新值,释放锁的时候会将共享变量同步到主内存中。从而,synchronized具有可见性。
  • 同样的在volatile分析中,会通过在指令中添加lock指令,以实现内存可见性。因此, volatile具有可见性。可参考volatile可见性分析

自我总结

以下为个人理解

synchronized

1
2
3
4
5
6
7
8
9
10
11
12

简介:
一种锁机制,存在阻塞问题和性能问题
是不是锁了对象,其他线程就无法使用该对象?答:其他线程依然可用。除非它也锁这个对象才不可用。
它锁住的是对象,可以是实例对象,也可以是类的Class对象。
一个类的实例对象可以有多个,Class对象只有一个。
假设A线程上锁之后,在没解锁之前,其他线程不能执行对同一对象上锁的代码块,必须等待A线程解锁,自己才能获取到锁。

具有:原子性,有序性,可见性
原子性:synchronized是把一段锁住的代码当做原子,其他锁住同一个对象的线程就不可指向此段代码,因为要获取锁才可以执行。
有序性:线程按照单一线程执行的顺序进行。如果如果代码块看成原子,其他原子等待别的原子放锁,才可以获取锁,也就是串行化,有序性。
可见性:有了上面的有序性,synchronized保证获取锁时工作内存中的变量再去主内存找到最新的。

volatile

1
2
3
4
5
6
7
8
9
10
11
简介:
不是锁,所以不存在阻塞和性能问题,借助了内存屏障来帮助其解决可见性和有序性问题,
而内存屏障的使用还为其带来了一个禁止指令重排的附件功能,所以在有些场景中是可以避免发生指令重排的问题的。
被volatile修饰的变量能够保证每个线程能够获取该变量储存单元的值是最新的,从而避免出现数据脏读的现象。
volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的读。
作用于变量,也就是作用于变量的储存单元的值。

具有:有序性,可见性
原子性:没有。读取时只保证变量储存单元的值是最新的,写入的禁止重排序原则。
可见性:对volatile修饰的变量的写立即写入内存,读是立即读取内存最新的数据
有序性:对volatile的写先行于volatile的读。

synchronized与volatile

1
2
3
4
5
6
7
+ synchronized保证的有序性是多个线程之间的有序性,即被加锁的内容要按照顺序被多个线程执行。但是其内部的同步代码还是会发生重排序,
只不过由于编译器和处理器都遵循as-if-serial语义,所以我们可以认为这些重排序在单线程内部可忽略。
as-if-serial:不管怎么重排序,单线程程序的执行结果都不能被改变。编译器和处理器无论如何优化,都必须遵守as-if-serial语义。
+ DCL问题:Thread1内部的指令重排却对Thread2产生了影响。
+ synchronized是一种锁机制,存在阻塞问题和性能问题,而volatile并不是锁,所以不存在阻塞和性能问题。
+ volatile借助了内存屏障来帮助其解决可见性和有序性问题,而内存屏障的使用还为其带来了一个禁止指令重排
的附件功能,所以在有些场景中是可以避免发生指令重排的问题的。