JUC - 并发编程的三大特性
JUC - 并发编程的三大特性
一、 原子性
1.1 什么是并发编程的原子性
原子性,即一组操作要么全部执行完毕,且在执行的过程中不会被别的因素打扰,要么全部不执行。
1.2 保证并发编程的原子性
1.2.1 synchronized
受 synchronized 保护的临界区需要获取锁之后才能进入,而在同一时刻只有一个线程能够获取锁,因此在 synchronized 内部修改共享变量的操作可以保证原子性。
public class Main {
private Object monitor = new Object();
private long accountA;
private long accountB;
public void transform(long n) {
synchronized(monitor) {
accountA = accountA - n;
accountB = accountB + n;
}
}
}
上述代码保证了同一时刻只有一个线程能够进入临界区做修改操作。
1.2.2 CAS
CAS:Compare And Swap,直译过来的意思是比较与交换。
CAS 操作需要两个值:旧值(操作前的值)和新值(操作后的值),有且仅当内存中(实际是缓存)的值等于旧值时(意味着状态没有被其他线程更新)才执行写入操作。
1.2.3 Lock 锁
同 synchronized 类似。
public class Main {
private static ReentrantLock lock = new ReentrantLock();
private long accountA;
private long accountB;
public void transform(long n) {
lock.lock();
try {
accountA = accountA - n;
accountB = accountB + n;
} finally {
lock.unlock();
}
}
}
1.2.4 ThreadLocal
原子性问题出现在多个线程操作同一个共享变量的情况,而 ThreadLocal 则为每个线程单独存储一个变量,因此不存在共享变量,进而解决了原子性问题,或者说不存在原子性问题。
二、 可见性
1. 什么是可见性
当一个线程修改共享变量时,其他线程能够立即看到共享变量修改后的状态。
2. 解决可见性的方式
2.1 volatile
volatile 是一个关键字,用于修饰成员变量(属性)。
如果一个成员变量被 volatile 修饰,相当于告诉 CPU 对于当前变量的操作不允许使用缓存,读取时必须从主存读取,写入时也必须写入主存。
volatile 的内存语义:
- volatile 属性被写:当写一个 volatile 变量,JMM 会将当前线程对应的CPU缓存中的内容立即刷新到主存中。
- volatile 属性被读:当读一个 volatile 变量,JMM 会将缓存中的内容设置为失效,必须去内存中重新读取变量。
被 volatile 关键字修饰的变量在转换成汇编语言后会被加 lock 前缀。
CPU 在执行指令遇到 lock 指令前缀时会做两件事情:
- 将当前处理器缓存中的数据写回主存。
- 当前变量在其他 CPU 缓存中的数据立即失效(缓存一致性协议,总线嗅探机制)。
总结:volatile 关键字保证了数据被修改后能够立即写入主存并被其他线程知悉,实现了一个线程的修改操作被其他线程可见。
2.2 synchronized
2.3 Lock
2.4 final
被 final 修饰的变量在初始化之后就不能被修改,因此也就不会出现可见性问题。
且 final 和 volatile 也是不能够同时使用的。
三、 有序性
1. 什么是有序性
CPU 在执行的时候会优化指令的执行顺序,导致指令执行的顺序和代码的顺序不一致而造成的结果错误的问题。
单例模式:
public class Singleton {
private Object obj;
public Object singleton() {
if (obj == null) {
obj = new Object();
}
return obj;
}
}
上述代码中 new 操作由三个步骤组成:
- 分配一块内存 M
- 在 M 上初始化对象
- 将 M 的内存地址返回
正常来说 CPU 执行的顺序应该是 1->2->3, 但是经过指令重排序后的执行顺序是 1->3->2, 这样就会导致一个情况的发生:CPU 将对象赋值 obj 的时候还未初始化,此时线程中断,其他线程调用 singleton 获取单例对象拿到的就是一个未经初始化的对象。
解决方法:对初始化操作加锁。
public class Singleton {
private Object obj;
public Object singleton() {
if (obj == null) {
synchronized(this) {
if (obj == null) {
obj = new Object();
}
}
}
return obj;
}
}
这里为什么获取锁进入临界区后又进行了一次空值判断呢?
public class Singleton {
private Object obj;
public Object singleton() {
if (obj == null) {
// 执行到此处时线程中断,其他线程开始执行后续的初始化操作。
synchronized(this) {
// 再次进入临界区后其他线程可能已经对 obj 对象初始化完成了,重新初始化就违反了单例的规则。
obj = new Object();
}
}
return obj;
}
}
2. as-if-serial
不论指令如何重排序,都要保证在单线程执行时结果是正确的。
为了遵守此语义,编译器和处理器不会对存在依赖关系的数据操作指令重排序。即不会对如下代码中的 command 3 做重排序,因为重排序后会影响最终结果。但是 command 1 和 command 2 的执行顺序可以改变。
// command 1
int a=1;
// command 2
int b=2;
// command 3
int c=a+b;
若将代码稍作修改,那么 command 1 和 command 2 的执行顺序也不能改变。
// command 1
int a=1;
// command 2
int b=a;
// command 3
int c=a+b;
3. happens-before
- 单一线程原则:同一线程中,书写在前面的操作 happens-before 书写在后面的操作。
- 管程锁原则:锁的 lock 操作 happens-before 锁的 unlock 操作。
- volatile 原则:对于一个 volatile 变量的写操作 happens-before 后续对这个变量的任意操作。
- 传递性原则:如果A操作 happen-before B操作,B操作happen-before C操作,那么A操作happen-before C操作。
- 线程启动的原则:同一个线程的start方法 happen-before 此线程的其它方法。
- 线程中断的原则:对线程interrupt 方法的调用 happen-before 被中断线程的检测到中断发送的代码
- 线程加入原则:Thread 对象的结束先行发生于 join() 方法返回。
- 对象终结原则:一个对象的初始化完成(构造函数执行结束)先行发生于它的 finalize() 方法的开始。
JMM 只有在不出现上述情况时,才不会触发指令重排效果
4. volatile
在指令之间添加内存屏障,屏障前的执行必须先执行,屏障后的指令必须后执行。
至于 volatile 和内存屏障的关系还有待研究。