您的位置 首页 java

线程同步专题

一、锁的概述

1、什么是锁

锁就好比对共享对象访问的许可证,只有拿到许可证的才能使用,而且许可证只有一张。

获得锁之后和释放锁之前这一段代码叫做 临界区 。临界区的概念说的还挺多的,倒也不难理解,就是这个名词需要好好消化一下。

锁具有 排他性 ,也称为互斥性。锁可以分为内部锁(基于 synchronized 实现)和显式锁(基于 java .concurrent.locks. lock 接口实现)

2、锁的作用

  • 原子性:原子性是依靠锁的排他性(互斥)来实现的,一段代码如果加了锁,说明当前只有一个 线程 可以执行,既然只有一个线程,肯定没有其他线程干扰,所以能保证原子性
  • 可见性:获取了锁之后,锁会隐式的进行 写线程的冲刷处理器缓存 及读线程的刷新处理器缓存的操作,这两步就可以保证可见性了
  • 有序性:既然只有一个线程,且还对其他线程可见,肯定就能保证其有序性。

这里注意的是: 尽管锁能保证有序性,但是临界区内的内存操作还是可能会重排序 ,但这种重排序还是能保证当前线程安全的。

锁的互斥性和可见性使得临界区的代码能读取到共享数据的最新值。

锁的使用是要有一定条件的

  • 使用的必须是同一把锁
  • 不管是读还是写,都必须持有锁才能操作

3、可重入锁

一个线程持有了该锁以后,如果还能继续成功申请该锁,这种就叫做可重入锁(re-entrant)。

可重入锁是一个对象,这个对象持有一个 计数器 ,初始值为0,每次线程获取到一次锁,计数器+1,释放一次锁,计数器-1,第一次这个线程获取锁的时候,肯定会因为竞争的关系,造成一部分开销,后续再获取锁就只需要对计数器进行加减即可。

二、两种锁的细节

1、synchronized关键字

内部锁属于非公平锁,显式锁支持公平锁和非公平锁。

一个锁保护的共享数据多,我们称该锁的粒度大,反之称为该锁的粒度小。

锁是有开销的,比如锁的申请和释放的开销,还有因为锁导致的上下文切换的开销。线程的申请和释放内部锁需要由 Java 虚拟机来实施,这也是为什么synchronized叫做内部锁。

锁住的对象一般使用private final修饰 ,因为锁住的变量一旦修改,可能导致最后各个线程访问的锁不同,引起竞态。

  private  final Object lock = new Object();
复制代码  

同步实例方法相当于以this为引导锁的实例块。

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

Java给每个内部锁分配一个入口集(Entry Set),用来记录等待内部锁的线程。这样每个线程被唤醒去获取锁,没获取到再回到入口集。

2、Lock接口

lock接口的方法:

可以通过 构造方法 设置是不是公平锁:

 ReentrantLock(boolean fair);
复制代码  

可以通过tryLock来判断是否获取到锁,然后实现尝试申请锁的操作,这样就可以避免一个线程可能因为代码错误而长时间不释放锁,造成其他线程的等待。

比较synchronized和Lock

内部锁可以定义在方法上,这样可以实现锁的申请和释放,但是也仅此而已。显式锁可以在某个方法申请锁,在另一个方法中释放锁。

内部锁一般用于普通的场景,因为比较简单。

3、读写锁

读写锁 包括读锁和写锁。

基本是这样一个规则:

 private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); 
private final Lock readLock = rwLock. readLock(); 
private final Lock writeLock = rwLock. writeLock();
复制代码  

这种一般适用于:读线程操作相比于写线程更加频繁,或者读线程的时间更长的场景。

4、同步机制的原理

我们前面介绍了线程的同步是需要这样两个步骤:

  • 写线程在释放锁的时候,进行冲刷处理器缓存(也就是确保写的操作全部完成)
  • 读线程在获取锁的时候,进行刷新处理器缓存(也就是确保读到了写线程做的更新)

有了这两步,才能确保线程同步。

Java虚拟机怎么实现这两个步骤的呢?

这里就涉及到一个很重要的知识—— 内存屏障

内存屏障相当于是一堵墙,插入于指令之间,从而确保指令不会被编译器或处理器重排序,这样就能够确保有序性。

同时内存屏障可以保障可见性,可以保障冲刷处理器缓存、刷新处理器缓存的执行。

内存屏障的分类

首先需要知道内部锁的申请和释放在Java虚拟机上是执行的这样的指令:moniterenter和moniterexit。这两个指令加于锁的前和后。

  • 按照可见性的保障可以分为两种:
    • 加载屏障(Load Barrier):它的作用就是刷新处理器缓存。会在moniterenter指令之后加入加载屏障
    • 存储屏障(Store Barrier):它的作用就是冲刷处理器缓存。会在moniterexit指令之后加入存储屏障

上面可见性的保障是以Load Barrier和Store Barrier来保障的,上面说了加锁之后,使用了加载屏障,进行刷新处理器缓存操作,这样保证了值是最新值。同样释放锁以后,使用存储屏障,进行冲刷处理器缓存操作,保证所作的操作都加载到高速缓存中。这样必然保证了可见性。

就好比我们去租房子住,没人租的时候,可能会有很多人进进出出,随手丢个垃圾呀,随手涂涂写写呀,这就相当于在对这个房间进行修改操作。然后我们租了这间屋子,我们就持有了这个屋子的锁(别人就不能随便进来了),我们进来以后看到了别人对这间屋子的修改(就相当于 使用加载屏障,刷新处理器缓存操作 ),然后现在这间屋子的东西就进入临界区了,所有东西只有我们可以修改,我们修改了屋子的装潢和布置,等我们退租了以后(释放了锁,别人就能进来了),这个时候别人就读取到了我们对屋子的修改(相当于使用了 存储屏障,冲刷了处理器缓存 )。

  • 按照有序性的保障来划分同样分为两种:
    • 获取屏障(Acquire Barrier):它的作用是在一个读操作之后加上获取屏障,从而保证读取到的变量不会和后面的代码重排序
    • 释放屏障(Release Barrier):它的作用是在一个写操作之前加上释放屏障,从而保证修改的变量操作不会和前面任何的读写操作产生代码重排序

我们在获取锁之后会在临界区前加上一个获取屏障,确保临界区的代码不会排到临界区以前;同样在释放锁之前,临界区加上一个释放屏障,也确保临界区代码不会跑到临界区之后。这样临界区中的代码不会跑出去,而临界区之内的代码又保证没有其他代码访问,也不会被重排,就保障了有序性。

基本顺序如下:

5、锁与重排序

如果使用锁,所有代码都不能重排序了,那就太耗费性能了,其实临界区外部的代码也是可以重排进临界区内的,但临界区内的代码是绝对不允许排到临界区外面的。但是重排要确保满足貌似串行语义的规则,因为重排是不能牺牲业务来保障性能。

看一下上面这个图,实线是可能允许的操作(满足貌似串行语义),虚线是不允许的操作。

当然,有这么几个规则需要满足:

  • 规则1:临界区内的操作允许被重排序到临界区之外(即临界区前或者临界区后)
  • 规则2:临界区内的操作之间允许被重排序
  • 规则3:临界区外(临界区前或者临界区后)的操作之间可以被重排序
  • 规则4:锁申请(MonitorEnter)与锁释放(MonitorExit)操作不能被重排序
  • 规则5:两个锁申请操作不能被重排序
  • 规则6:两个锁释放操作不能被重排序
  • 规则7:临界区外(临界区前、 临界区后)的操作可以被重排到临界区之内

锁的申请和释放是不能被重排的,因为它们牵扯到到底哪个线程先执行,哪个线程后执行的问题,如果重排序,会造成写操作覆盖。如果和我们期望的不一样,肯定得到的值也就不一样。同样的,锁的申请和释放如果重排序了,可能造成原本应该串行执行的代码,被两个线程争用,造成 死锁 的情况。本来应该一手拿筷子一手拿勺子,结果筷子自己拿到了,勺子让别人拿走了,饭怎么吃?

三、volatile关键字

1、作用

volatile关键字的作用如下:

  • 保障可见性、有序性
  • 同样保障long、double类型的变量读写操作的原子性(正如并发初步专题中提到的那样)
  • volatile 的变量不会被存在 寄存器 等其他处理器看不到的地方

注意这里对long、double类型的变量的读写操作是原子性(比如:volatile long a = 10),而对于赋值操作却不能保证(对于像下面a = a+1这种就不能保证)

2、原理

对于volatile修饰变量的写操作前加上一个释放屏障、而在写操作之后加上一个存储屏障。

volatile变量 的写操作

再来回顾一下释放屏障和存储屏障操作的作用:

  • 释放屏障保障的是其写操作不会和前面的代码发生重排序,什么意思呢?就是说我写操作之前的代码不会变到写操作后面去,前面可能有对volatileValue这个变量的一些读写操作,你们先读写完之后,我要开始赋值了,这样我赋值操作就是现在最新的操作
  • 存储屏障保障的是冲刷处理器缓存,也就是我这个操作要被加载

有了上面两个屏障的管理,volatile变量的读写操作就能保证有序性和可见性。该变量写之前,所有操作都已经完成,并且不会被重排序到它的后面,然后执行完写操作,又执行了存储屏障去冲刷了处理器缓存,确保了最终的值的更新。

volatile变量的读操作

回顾一下加载屏障和获取屏障操作的作用:

  • 加载屏障的作用就是刷新处理器缓存,也就是确保我们拿到的值是新值
  • 获取屏障的作用就是读到的代码不会和后面的代码重排序,从而保证自己拿到的值是正确的

总结

简单的理解如下:

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

这里注意:如果volatile是数组变量,仅对数组的引用起作用,保障它的可见性和禁止指令重排,不能保证它内部的元素。

3、volatile的开销

volatile的开销比锁要小一些,因为它 不牵扯读写操作造成的上下文切换 。所以它是一种更加 轻量级的锁

四、CAS

1、CAS简介

CAS(compare and swap)称为比较并交换,这也是处理并发安全问题的一种方式。相比之下加锁(synchronized和Lock接口)属于悲观锁(每一次操作都要串行执行,确保其不会有并发问题);而CAS属于 乐观锁 (通过比较和交换的方式,进行操作)。

CAS可以将read-modify-write和check-and-act等操作转换为 原子操作

这个怎么理解呢?比如count++操作明明不是一个原子性操作,但是我们通过CAS,判断它的值是否是我们预期的,来实现操作的原子性。

2、原子类

我们前面介绍过了,volatile只能保证读写的原子性,不能保证像这种自增的原子性。那么我们就可以使用AtomicXXXX的原子类来实现。

这些类中都使用了CAS的策略,随便看一个,比如AtomicInteger类中的getAndAddInt()方法。

 public final int getAndAddInt(Object var1,  long  var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1,  var 2, var5, var5 + var4));

    return var5;
}
复制代码  

compareAndSwapInt()写的清清楚楚。原子类到时候单开个专题介绍~

3、对象的发布

对象发布的意思其实就是被当前作用域以外的线程访问到了。最常见的有以下几种方式:

  • public修饰的变量:public int a = 10;这样所有人都可以访问a变量了
  • 非private方法中操作对象并返回
 //比如定义了一个map对象是private修饰的
private Map<String, Integer > map = new HashMap<>();

//然后在非private方法中进行了操作并返回
public Map<String,Integer> getMap(){
    return this.map;
}
复制代码  

这样虽然对象是private修饰的,但是实际上方法中操作并返回,还是其他类也可以访问到。

  • 创建内部类使得当前对象能被内部类访问到,这里用到了this
 public  void  startTask(final Object task){ 
     Thread  t = new Thread( 
        new Runnable(){ 
            @Override 
            public void run(){ 
                // 省略其他代码 
            } 
        }); t. start(); 
}
复制代码  
  • 通过方法调用把对象传给外部方法
 public class Obj {
    //创建map对象,并赋值
    private  static  Map<String,String> map = new HashMap<>();

    public Map<String,String> getMap(){
        map.put("张三","我是张三");
        return map;
    }
}
复制代码  

让其他类获取:

 public class ObjTest {
    public static Map<String,String> getObj(){
        Obj obj = new Obj();
        Map<String, String> map = obj.getMap();
        return map;
    }

    public static void main(String[] args) {
        Map<String, String> map = getObj();
        System.out.println(map.get("张三"));
    }
    
    //outprint:我是张三
}
复制代码  

4、如何避免逸出

对象逸出就是发布过后的结果和我们预期的不一致,可以使用下面的方法:

  • 使用static关键字修饰引用该对象的变量
  • 使用final关键字修饰引用该对象的变量
  • 使用volatile关键字修饰引用该对象的变量
  • 使用AtomicReference来引用该对象
  • 对访问该对象的代码进行加锁。

作者:老猫念诗
链接:

文章来源:智云一二三科技

文章标题:线程同步专题

文章地址:https://www.zhihuclub.com/186336.shtml

关于作者: 智云科技

热门文章

网站地图