线程同步机制

线程同步机制一套用于协调线程间的数据访问及活动的机制,该机制用于保障线程安全以及实现这些线程的共同目标。
广义上讲,Java提供的线程同步机制包括,锁、volatile关键字、final关键字、static关键字以及一些相关的API。

线程安全产生的前提是多个线程并发访问共享变量、共享资源。使用锁保障线程安全的思路是将多个线程对共享数据的并发访问转换为串行访问。

使用锁保障线程安全的原理:
一个线程在访问共享数据之前需要请求获得相应锁(锁的获得),一个锁同一时间只能被一个线程持有。只有持有锁的线程才能访问共享变量。在线程持有锁之后到释放锁这段时间所执行的代码被称为临界区。因此,共享数据只允许在临界区内进行访问,临界区一次只能被一个线程执行。

锁的性质:
锁具有排他性,即一个锁一次只能被一个线程持有。因此这种锁被称为排他锁或者互斥锁。

锁的划分:

  1. 内部锁:通过synchronized关键字实现的
  2. 显式锁:通过java.concurrent.locks.Lock接口实现的。

锁的作用

锁能够保护共享数据以实现线程安全,其作用包括保障原子性、保障可见性和保障有序性。

  1. 如何保障原子性:
    锁是通过互斥保障原子性的。当获得锁的线程执行临界区域期间其它线程不能插手共享数据,这使得临界区代码所执行的操作具有不可分割的特性,即原子性。
  2. 如何保障可见性
    3可见性的保障是通过在写线程冲刷处理器缓存和读线程刷新处理器缓存来实现的。Java中线程获得锁隐含着刷新处理器缓存的动作。锁的释放隐含责冲刷处理器缓存的操作。

使用锁保障线程安全的条件(同时满足):

  1. 这些线程在访问同一组共享数据的时候必须使用同一个锁。
  2. 这些线程中的任意一个线程,即使其仅仅是读取这组共享数据而没有对齐进行更新的话,也需要在读取时持有相应的锁。

与锁相关的几个重要概念

  1. 可重入性:如果一个线程持有一个锁的时候还能够继续成功申请该锁,那么我们称该锁是可重入的。否则,称为非可重入的。
  2. 锁的争用与调度:锁可以被看座多线程程序访问共享数据时所需要持有的一种排他性资源。
  3. 锁的粒度:一个锁实例能保护得共享数据得数量大小被称为该锁得粒度。锁的粒度过粗会导致线程在申请锁的时候需要进行不必要的等待。

锁的开销及其可能导致的问题

锁的开销包括申请和释放锁所产生的开销,以及锁可能导致的上下文切换的开销。

可能导致的问题:

  1. 锁泄露。锁泄露是指一个线程获得某个锁之后,由于程序的错误缺陷导致该锁一直无法被释放而导致其它线程一直无法获得该锁的现象。
  2. 不正确使用可能会导致死锁、锁思等线程活性故障。

内部锁

Java平台中任何一个对象都有唯一一个与之关联的锁。这种锁被称为监视器或者内部锁。内部锁是一种排他锁,他能够保障原子性、可见性和有序性。内部锁通过synchronized实现。内部锁不会导致锁泄漏。

作为锁句柄的变量通常采用final关键字修饰。因为锁句柄变量的值一旦改变,会导致执行同一个同步块同一个个线程实际上使用不同的锁。
例如:

1
2
//推荐方式
private final Object lock=new Object();

同步静态方法相当于以当前类对象为引导锁的同步块。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Test{
public static synchronized void method(){
//访问共享数据
}

}
//等价写法
public class Test{
public static void method(){
synchronized(Test.class){
//访问共享数据
}
}
}

显式锁

n1ppGD.png

使用显式锁实现循环递增序列号生成器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class sequenceUtils{
private final Lock lock=new ReentrantLock();
private short seq=0;

public int get(){
lock.lock();
try {
if(seq>=999){
seq=0;
return seq;
}
seq++;
return seq;
}finally {
lock.unlock();
}

}
}

显式锁的调度

ReentrantLock即支持非公平锁也支持公平锁。
ReentrantLock(boolean fair)该构造器可以在创建显式锁时执行相应的锁是否时公平锁(true 为公平锁)。

公平锁保障锁调度的公平性往往是以增加了线程的暂停和唤醒的可能性,即增加了上下文切换为代价。因此,公平锁适合于锁被持有的时间相对长或者线程申请锁的平均间隔时间相对长的情形。因为公平锁的开销比使用非公平锁的开销要大,因此显式锁默认使用非公平调度策略。

读写锁

读锁是共享的,写锁是排他的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class ReadWriteLockUsage{
private final ReadWriteLock rwLock=new ReentrantReadWriteLock();
private final Lock readLock=rwLock.readLock();
private final Lock writeLock=rwLock.writeLock();

public void reader(){
readLock.lock();
try {
//在此区域读取共享变量
}finally {
readLock.unlock();
}
}

public void writer(){
writeLock.lock();
try {
//在此区域访问(读,写)共享变量
}finally {
writeLock.unlock();
}
}

}

与普通的排他锁相比,读写锁在排他性方面较弱。在原子性、可见性和有序性保障方面和普通的排他锁是一致的。写线程在释放写锁所起到的作用相当于一个线程释放一个普通排他锁;读线程获得读锁相当于一个线程获得一个普通排他锁。

读写锁的使用场景:

  1. 只读操作比写操作要频繁的多
  2. 读线程持有锁的时间比较长。

锁的适用场景

适用场景:

  1. check-then-act操作:一个线程读取共享数据并在此基础上决定其下一个操作是什么
  2. read-modify-write操作:一个线程读取共享数据并在此基础上更新该数据。
  3. 多个线程对多个共享数据进行更新。

内存屏障

Java虚拟机借助内存屏障技术实现的刷新处理器缓存和冲刷处理器缓存。内存屏障是对一类针对内存读、写操作指令的跨处理器架构的比较底层的抽象。内存屏障时被插入到两个之间进行使用的,其作用是禁止编译器、处理器重排序从而保障有序性。为了实现禁止重排序的功能,这些指令也往往具有刷新处理器缓存、冲刷处理器缓存的副作用。

因为临界区内的任何读、写操作都无法被重排序到临界区之外。在锁的排他性作用之下,这使得临界区中执行的操作序列具有原子性。

编译器和处理器都必须遵守的重排序规则:

  1. 临界区内的操作不允许被重排序到临界区之外
  2. 临界区内的操作之间允许被重排序
  3. 临界区之外的操作之间可以被重排序
  4. 锁申请和锁释放的操作不能被重排序
  5. 两个锁申请操作不能被重排序
  6. 两个锁释放操作不能被重排序
  7. 临界区外的操作可以重排序到临界区之内。

volatile关键字

volatile关键字用于修i是共享可变变量。它可以保证可见性和有序性。所不同的时,在原子性方面它仅能保障写volatile变量操作的原子性,没有锁的排他性。

volatile的作用

volatile作用包括:保障可见性、保障有序性和保障long/double型变量读写操作的原子性。volatile关键字在原子性方面仅保障对被修饰的变量的读操作、写操作本身的原子性。写线程对volatile变量的写操作会产生类似于释放锁的效果。读线程对volatule变量的读操作会产生类似于获得锁的效果。因此volatile具有保障有序性(也利用了内存屏障)和可见性的作用。

volatile在有序性保障方面也可以从禁止重排序角度理解。

  1. 写volatile变量操作与该操作之前的任何读、写操作不会被重排序。
  2. 读volatile变量操作与该操作之后的任何读、写操作不会被重排序。

如果volatile关键字修饰数组,那么volatile关键字只能够对数组引用本身的操作起作用,而无法对数组元素的操作起作用。对于引用性volatile变量,volatile变量字只时保证读线程能够读取到一个指向对象的相对性的内存地址,而这个内存地址指向的对象的实例,静态变量值是否是相对新的则没有保障

volatile变量的开销

volatile变量的读写都不会导致上下文切换。volatile变量的读写开销介于普通读写和临界区读写之间。

volatile关键字的使用场景

  1. 使用volatule关键字作为状态标志。
  2. 使用volatile保障可见性
  3. 使用volatile变量代替锁。(volatile关键字并非锁的代品)volatile关键字更适合于多个线程共享一个状态变量,而后者更适合于多个线程共享一组状态变量。
  4. 使用volatile实现简易版的读写锁
1
2
3
4
5
6
7
8
9
10
11
12
//使用volatile实现的简易读写锁
class Counter{
private volatile long count;
public long value(){
return count;
}
public void increment(){
synchronized (this){
count++;
}
}
}

实现线程安全的单例模式(延迟加载的单例模式)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Singletion{

private static Singletion instance=null;

private Singletion(){
//构造器私有化
}

public Singletion getInstance(){
//使用锁来确保线程安全
synchronized (Singletion.class){
if(instance==null){
instance=new Singletion();
}
return instance;
}
}
}

使用双重检查锁定的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Singletion{
//一定要使用volatile关键字
private static volatile Singletion instance=null;

private Singletion(){
//构造器私有化
}

public Singletion getInstance(){
if(instance==null){ //检查一
//第一次检查
synchronized (Singletion.class){
//第二次检查
if(instance==null){ //检查二
instance=new Singletion(); //操作三
}
}
}
return instance;
}
}

使用双重检查锁定的方法的分析:
操作三很明显不是原子操作。它可以拆分为以下几个独立子操作:

1
2
3
objRef=allocate(Singletion.class);//子朝左一:分配对象所需的空间
invokeConstructor(objRed);//子操作二;初始化objRef引用的对象
instance=objRef; //子操作三

根据锁的重排序规则,临界区内的操作可以在临界区内被重排序。因此可以将上述子操作重排序为:一 –> 三 –>二。由于锁对有序性的保障是有添加的,二操作一再读取instance变量时并没有加锁,因此上述重排序对操作一的执行线程是有影响的:该线程可能看到一个未初始化(或未初始化完毕)的实例,即变量instance的值不是null,这样可能导致程序出错,为了解决这个问题,可以将instance变量采用volatile修饰。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//基于静态内部类的单例模式实现
class Singletion{

private Singletion(){
//构造器私有化
}

private static class Instanceholder{
//保存外部类的唯一实例
final static Singletion INSTANCE=new Singletion();
}

public static Singletion getInstance(){
return Instanceholder.INSTANCE;
}

}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
//基于枚举类型的单例模式
class Test{
public static void main(String[] args) {
Singletion.INSTANCE.doSome();
}
}

public static enum Singletion{
INSTANCE; //相当于Singletion类的唯一实例
private Singletion(){
//构造器私有化
}
public void doSome(){
//业务方法
}
}

CAS与原子变量

CAS是对一种处理器指令的称呼。许多多线程相关的Java类库的最终实现都会借助CAS.

CAS

CAS是一个原子的if-then-act操作。起背后的假设是:当一个客户(线程)执行CAS操作的时候,如果变量V的当前值和用户请求CAS时提供的变量值A(即变量的旧值)是相等等,那么就说明其它线程并没有修改过当前V的值。执行CAS时如果没有其它线程修改过变量V的值,那么下手最快的客户(当前线程)就会强行将变量V的值更新为B(新值),而其它客户(线程)的更新请求则会失败。这些失败的客户(线程)通常可以选择再次尝试,指导成功。

1
2
3
4
5
6
7
boolean compareAndSwap(Variable V,Object A,Object B){
if(A==V.get()){ //检查变量值是否被其它线程修改过
V.set(B); //更新变量值
return true;
}
return false; //更新失败
}

基于CAS的算法的代码模板:

1
2
3
4
do{
oldvalue=V.get(); //读取共享变量V的旧值
newValue=calculate(oldvalue); //计算变量V的新值
}while(!compareAndSwap( V,oldValue,newValue))

值得注意的时,CAS只保障了共享变量更新这个操作的原子性,它并不保障可见性

原子变量类

原子变量类是基于CAS实现的能够保障对共享变量进行read-modigy-write更新操作的原子性和可见性的一组工具类。

n3I5dJ.png

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public enum  ThreadStudy04 implements Runnable {
//该类唯一的一个实例
INSTANCE;
private final AtomicBoolean initializating=new AtomicBoolean(false);

ThreadStudy04(){
//私有空构造器
}

public void init(){
//使用AtomicBoolean的CAS操作确保工作线程只会被创建一次
if(initializating.compareAndSet(false,true)){
//创建工作线程并启动
new Thread(this).start();
}
}

@Override
public void run() {

}
}

使用AtomicBoolean的compareAndSet方法来保证check-then-act操作的原子性,从而避免了锁的开销,又使代码更加简单。

CAS导致的ABA问题

CAS实现原子变量操作背后的一个假设是:共享变量的当前值与当前线程所提供的旧值相同,那么我们就认为这个变量没有被其它线程修改过。实际上,这个假设不一定总是成立的。

例如,可能存在这样一种情况:
对于共享变量V,当前线程看到它的值为A的那一刻,其它线程已经将其值更新为B,接着当前线程执行CAS的时候,该变量的值又被其它线程更新为A.也就说在这个过程中变量V的值已经被其它线程改变过了,但在当前线程看来却没有变过。
规避ABA问题的一种方案:
在共享变量的更新时引入一个修订号。这样即使共享变量经历了A–>B–>A的更新,但因为修订号的改变,我们依然能够准确的判断变量的值是否被其它线程修改过。

对象的发布与逸出

对象发布:是指使对象能够被其它作用域之外的线程访问。
对象发布的形式:

  1. 将对象的引入存储到public变量中,例如:
    public Map<String,Integer> registery=new HashMap<String,Integer>()
  2. 在非private方法中返回一个对象
1
2
3
4
private Map<String,Integer> register=new HashMap<String,Integer>();
public Map<String,Integer> getRegistery(){
return this.registery;
}
  1. 创建内部内使得当前对象能够被这个内部类使用
1
2
3
4
5
6
7
8
public void startTask(final Object task){
Thread t=new Thread(new Runnable(){
@Override
public void run(){
}
});
t.start();
}
  1. 通过方法调用将对象传递给外部方法。

当我们需要发布一个对象的时候旧需要注意与之相关的线程安全问题。

对象的初始化安全

static关键字在多线程环境下有特殊的含义,它能确保一个线程即使在未使用其它同步机制的情况下也总是可以读取到一个类的静态变量的初始值(不是默认值)。喆总可见性保障仅限于线程初次读取该变量。如果这个静态变量在相应的类初始化完毕之后被其它线程更新过,那么一个线程要读取该变量的相对新值仍然需要借助锁、volatile关键字等同步机制。

对于引用型静态变量,static关键字还能够保障一个线程读取到该变量的初始值时,这个值所指向的对象已经初始化完毕。

static关键字仅仅保障读线程能够读取到相应字段的初始值,而不是相对新值

当一个对象被发布到其它线程的时候,该对象的所有final字段都是初始化完毕的,而其它线程读取这些字段的时候所读取到的值都是相应字段的初始值。而非final字段没有这样的保障。

安全发布是指对象以一种线程安全的方式被发布。当一个对象的发布出现我们不期望的结果或者对象发布本身不是我们所期望的时候,我们旧称该对象逸出

对比总结

nJ17Z9.png

volatile关键字只能保障long/double型变量写操作的原子性

static关键字的可见性保障是指未经过其它同步操作总能读取到初始值,其提供的有序性和可见性保障仅仅在一个线程初次读取静态变量的时候起作用