线程同步机制

锁概述

  1. 一个线程在访问共享数据的时候必须要申请获得相应的锁(相当于许可证),线程只有在获得相应的"许可证"后才能访问共享数据,一个"许可证"同时只能被一个线程访问,访问完毕后线程需要释放相应的锁(交还许可证)以便于其它线程进行访问,锁申请后到锁释放前的这段代码被称作为临界区.
  2. 内部锁:synchronized 显示锁:ReentrantLock
  3. 可见性是由写线程冲刷处理器缓存和读线程刷新处理器缓存两个动作保证的,在使用锁的时候,会在获取取锁之前进行刷处理器缓存动作,在释放锁后进行冲刷处理器缓存动作.
  4. 尽管锁能保证有序性,但临界区内的操作仍然可能重排序,因为临界区内的操作对其它线程是不可见的 ,这意味着即使临界区的操作会发生重排序但是并不会造成有序性问题.
  5. 可重入性:一个线程在拥有这个锁的时候能否继续获取这把锁,如果可以,我们把这把锁称可重入锁

    void metheadA(){
      acquireLock(lock); // 申请锁lock
    
      // 省略其他代码
      methodB();
      releaseLock(lock); // 释放锁lock
    }
    
    void metheadB(){
      acquireLock(lock); // 申请锁lock
    
      // 省略其他代码
      releaseLock(lock); // 释放锁lock
    }
  6. 锁泄露:锁被获取后,一直未释放.

内部锁:synchronized

  1. 内部锁得使用方式

    同步代码块
    synchronized(lock){
      ......
    }
    ==============================
    同步方法
    public synchronized void method(){
      ......
    }
    等同于
    public void method(){
      synchronized(this){
        ......
      }
    }
    ==============================
    同步静态方法
    class Example{
      public synchronized static void method(){
        ......
      }
    }
    等同于
    class Example{
      public static void method(){
        synchronized(Example.class){
          ......
        }
      }
    }
    
  2. 内部锁并不会导致锁泄露,这是因为java编译器(javac)在将同步代码块编译成字节码得时候,对临界区内的可能抛出但是程序代码又未捕获的异常进行了特殊处理,这使得即使临界区的代码块抛出异常也不会影响内部锁的正常释放.
  3. java虚拟机会为每一个内部锁维护一个入口集(Entry Set)用于维护申请这个锁的等待线程集合,多个线程同时申请获取锁的时候只有一个线程会申请成功,其它的失败线程并不会抛出异常,而是会被暂停(变成Blocked状态)等待,并存入到这个入口集当中,待拥有锁的这个线程执行完毕,java虚拟机会从入口集当中随机唤醒一个Blocked线程来申请这把锁,但是它也并不一定能够获取到这把锁,因为此时它可能还会面临着其它新的活跃线程(Runnable)来争抢这把锁.

显式锁:Lock

  1. 内部锁只支持非公平锁,显式锁既可以支持公平锁,也可以支持非公平锁(默认非公平锁).
  2. 公平锁往往会带来额外的开销,因为,为了"公平"原则,多数情况下虚拟机会增加线程的切换,这样就会增加相比于非公平锁来说更多的上下文切换。因此,公平锁适合于线程会占用锁时间比较长的任务,这样不至于导致某些线程饥饿.
  3. Lock的使用方法

    lock.lock()
    try{
      ......
    }catch(Exception e){
      ......
    }finally{
      lock.unlock()
    }
  4. synchronized和Lock的区别

    • synchronized是java的内置关键字属于jvm层面,Lock是java的一个类
    • Lock.tryLock()可以尝试获取锁,但是,synchronized不能
    • synchronized可以自动释放锁,Lock得手动unlock
    • synchronized是非公平锁,Lock可以设置为公平也可以设置为非公平
    • Lock适合大量同步代码,synchronized适合少量同步代码
  5. 读写锁:一个读线程持有锁得情况下允许其它读线程获取读锁,但是不允许写线程获取这把锁,写线程持有这个锁的情况下不允许其它任何线程获取这把锁.
  6. 读写锁的使用

    class Apple{
      ReadWriteLock lock = new ReentrantReadWriteLock();
      Lock writeLock = lock.writeLock();
      Lock readLock = lock.readLock();
      
      private BigDecimal price;
      
      public double getPrice(){
        double p;
        readLock.lock();
        try{
          p = price.divide(new BigDecimal(100)).doubleValue();
        }catch(Exception e){
          ...
        }finally{
          readLock.unLock();
        }   
        return double;
      }
      
      public void setPrice(double p){
        writeLock.lock();
        try{
          price = new BigDecimal(p);
        }catch(Exception e){
          ...
        }finally{
          writeLock.unLock();
        }
      }
    }
  7. 读写锁适合以下场景:

    • 读操作比写操作更频繁
    • 读线程持有的时间比较长
  8. 锁降级:一个线程再持有写锁的情况下可以申请将写锁降级为读锁.

    public class ReadWriteLockDowngrade {
      private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
      private final Lock readLock = rwLock.readLock();
      private final Lock writeLock = rwLock.writeLock();
    
      public void operationWithLockDowngrade() {
        boolean readLockAcquired = false;
        writeLock.lock(); // 申请写锁
        try {
          // 对共享数据进行更新
          // ...
          // 当前线程在持有写锁的情况下申请读锁readLock
          readLock.lock();
          readLockAcquired = true;
        } finally {
          writeLock.unlock(); // 释放写锁
        }
    
        if (readLockAcquired) {
          try {
          // 读取共享数据并据此执行其他操作
          // ...
    
          } finally {
          readLock.unlock(); // 释放读锁
          }
        } else {
          // ...
        }
      }
    }
  9. 不支持锁升级的原因 - 因为存在同时有多个线程拥有读锁的情况,所以锁升级的过程中,可能发生死锁.

    假设有A和B两个读线程获取的是同一把读锁,那么A线程想升级为写锁,等到B线程释放读锁只之后B线程就可以升级成功.但是如果A线程想升级的同时B也想升级那么,它们俩会同时等待对方释放读锁,这样的话就会造成一种对峙局面,即一种典型的死锁.

内存屏障

  1. 内存屏障是指两个指令插入到一段指令的两侧从而起到"屏障"的编译器 处理器重排序的作用.
  2. 内部锁的申请和释放对应的字节码指令分别是 MonitorEnter 和 MonitorExit
  3. 由可见性可以将内存屏障划分为 加载屏障(Load Barrier)和存储屏障(Store Barrier),加载屏障的作用是刷新处理器缓存,存储屏障的作用是冲刷处理器缓存.

    java虚拟机会在MonitorEnter指令之后的临界区开始的之前的地方插入一个加载屏障保证其它线程对于共享变量的更新能够同步到线程所在处理器的高速缓存当中.同时,也会在MonitorExit指令之后插入一个存储屏障,保证临界区的代码对共享变量的变更能及时同步.

  4. 按照有序性可以将内存屏障划分为 获取屏障(Acquire Barrier)和释放屏障(Release Barrier),获取屏障会禁止临界区指令与临界区之前的代码指令发生重排序,释放屏障会禁止临界区指令和临界区之后的代码指令发生重排序

    java虚拟机会在MonitorEnter指令之后插入一个获取屏障,在MonitorExit指令之前插入一个释放屏障.

  5. 内存屏障下的排序规则(实线代表可排序,虚线代表不可排序)

    线程同步机制_第1张图片

轻量级同步机制:volatile关键字

  1. volatile关键字的作用包括:保障可见性、保障有序性和保障long/double型变量读写操作的原子性。

    long和double这两种基本类型写操作非原子性的原因是它们在32位java虚拟机中的写操作都是分双32bit操作的,所以在java字节码当中,一个long或者double变量的写操作是要执行两步字节码指令.

  2. volatile变量不会被编译器分配到寄存器进行存储,对volatile的速写操作都是内存访问
  3. volatile关键字仅保证其修饰变量本身的读写原子性,如果要涉及其修饰变量的赋值原子性,那么这个赋值操作不能涉及任何共享变量,否则其操作就不具有原子性.

    A = B + 1

    若A是一个volatile修饰的共享变量,那么该赋值操作实际上是read-modify-write操作,如果B是一个共享变量那么在赋值的过程中B可能已经被修改,所以可能会出现线程安全问题,但是如果B是一个局部变量,那么则这个赋值操作将是原子性的.

  4. volatile保证变量读写的有序性原理与synchronized基本相同 - 在写操作前后增加相关的内存屏障(硬件基础和内存模型文章中有详细的内容)
  5. 如果被volatile修饰的是一个数组,那么volatile只对数组本身的操作起作用,而并不对数组元素的操作起作用。

    //nums被volatile修饰
    int num = nums[0];             //1
    nums[1] = 2;                    //2
    volatile int[] newNums = nums; //3

    如操作1实际上是两个子步骤①读取数组引用,这个子步骤是属于数组操作是volatile的读操作,所以可以读取到nums数组的相对新值,步骤②是在①的基础上计算偏移量取得nums[0]的值,它并不是一个volatile操作,所以不能保障其读取到的是一个相对新值。

    操作2可以分为①数组的读取操作和②数组元素的写操作,同样①是一个volatile读操作,但是②的写操作可能产生相应的问题。

    操作3相当于是用一个volatile数组更新另一个volatile数组的引用,所有操作都是在数组层面上的操作,所以不会产生并发问题。

  6. volatile的开销

    volatile变量的读、写操作都不会导致上下文切换,因此volatile的开销比锁要小。写一个volatile变量会使该操作以及该操作之前的任何写操作的结果对其他处理器是可同步的,因此volatile变量写操作的成本介于普通变量的写操作和在临界区内进行的写操作之间。读取volatile变量的成本也比在临界区中读取变量要低(没有锁的申请与释放以及上下文切换的开销),但是其成本可能比读取普通变量要高一些。这是因为volatile变量的值每次都需要从高速缓存或者主内存中读取,而无法被暂存在寄存器中,从而无法发挥访问的高效性。

单例模式线程安全问题

下面是一个经典的双重校验锁的单例实现

public class Singleton {
  // 保存该类的唯一实例
  private static Singleton instance = null;

  /**
   * 私有构造器使其他类无法直接通过new创建该类的实例
   */
  private Singleton() {
    // 什么也不做
  }
  /**
   * 获取单例的主要方法
   */
  public static Singleton getInstance() {
    if (null == instance) {// 操作1:第1次检查
      synchronized (Singleton.class) { //操作2
        if (null == instance) {// 操作3:第2次检查
          instance = new Singleton(); // 操作4
        }
      }
    }
    return instance;
  }
}

首先我们分析一下为什么操作1和操作2的作用

如果没有操作1和操作2,此时线程1来调用getInstance()方法,正在执行操作4时,同时线程2也来调用这个方法,有与操作4并没有执行完成所以,线程2可以顺利通过操作3的判断,这样就会出现问题,new Singleton()被执行了两次,这也就违背了单例模式的本意。

由于上述的的问题所以在操作3之前加一个操作2这样就会保证一次只会有一个线程来执行操作4,但是,这样就会造成每次调用getInstance()都要申请/释放锁会造成极大的性能消耗,所以需要在操作2之前加一个操作1就会避免这样的问题。

另外static修饰变量保证它只会被加载一次。

这样看来这个双重校验锁就完美了?

上面的操作4可以分为以下3个子操作

objRef = allocate(IncorrectDCLSingletion.class); // 子操作1:分配对象所需的存储空间
invokeConstructor(objRef); // 子操作2:初始化objRef引用的对象
instance = objRef; // 子操作3:将对象引用写入共享变量

synchronized的临界区内是允许重排序的,JIT编译器可能把以上的操作重排序成 子操作1→子操作3→子操作 2,所以可能发生的情况是一个线程在执行到重排序后的操作4(1→3→2)的时候,线程刚执行完子操作3的时候(子操作2没有被执行),有其它的线程执行到操作1,那么此时instance ≠ null就会直接将其retuen回去,但是这个instance是没有被初始化的,所以会出现问题。

如果instance使用volatile关键字修饰,这种情况就不会发生,volatile解决了子操作2和子操作3的的重排序问题。

volatile还能避免这个共享变量不被存到寄存器当中,避免了可见性问题。

此外静态内部类和枚举类也可以安全的实现单例模式

public class Singleton {
  // 私有构造器
  private Singleton() {
  }

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

  public static Singleton getInstance() {
    return InstanceHolder.INSTANCE;
  }
}

上面是内部静态类的实现方式,InstanceHolder会在调用的时候加载因此这也是一种懒汉式的单例。

CAS

CAS是一种更轻量级的锁,它的主要实现方式是通过赋值前的比较实现的,比如i = i + 1操作,线程在将i+1的结果赋值给i之前会比较当前的i与i旧值(i+1之前记录的值)是否相同,若相同则认为i在这个过程中没有被其它线程修改过,反之就要废弃之前的i+1操作,重新执行。

这种更新机制是以CAS操作是一个原子操作为基础的,这一点直接由处理器来保障。但是CAS只能保证操作的原子性,不能保证操作的可见性(可见性得不到保证,有序性自然得不到保证)。

CAS可能会出现ABA问题也就是在i的初始值为 0 进行i + 1这个操作时,另一个线程将这个i变量修改为10,然后在这个过程中又有第三个线程将i修改回0,然后当线程在进行比较时发现i还是初始值,便将i+1的操作结果赋值给了i,这显然不是我们想要的情况。解决方法可以在对这个变量操作的时候加一个版本号,每一次对其进行修改后版本+1,这个我们就可以清楚的看到,变量有没有被其它线程改变过。

常用原子类的实现原理就是CAS

分组
基础数据型 AtomicInteger、AtomicLong、AtomicBoolean
数组型 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray
字段更新器 AtomicIntegerFieldUpdater、AtomicLongFieldUpdater、AtomicReferenceFieldUpdater
引用型 AtomicReference、AtomicStampedReference、AtomicMarkableReference

static与final

  1. 一个类被虚拟机加载后,该类的静态变量的值都仍然是默认值(引用类型变量为null,boolean类型为false),直到有一个静态变量第一次被访问时static代码块和变量才会被初始化。

    public class InitStaticExample {
        static class InitStatic{
            static String s = "hello world";
            static {
                System.out.println("init.....");
                Integer a = 100;
            }
        }
        public static void main(String[] args) {
            System.out.println(InitStaticExample.InitStatic.class.getName());
            System.out.println(InitStatic.s);
        }
    }
    =================结果=================
    io.github.viscent.mtia.ch3.InitStaticExample$InitStatic
    init.....
    hello world
    
  2. 对于引用型静态变量来说,任何一个线程拿到这个变量的时候他都是初始化完成的(这里和双重校验锁的错误时不一样的,虽然instance时静态变量,双重校验锁的Singleton对象并不是静态类,所以new Singleton()有未初始化的风险)。但是,static的这种可见性和有序性保障仅在一个线程初次读取静态变量的时候起作用。
  3. 当一个对象被发布到其它线程时,这个对象中的final变量总是初始化完成的(也保证引用变量时初始化完成的对象),保证了其它线程读取到的这个值不是默认值。final只能解决有序性问题,即保证拿到的变量是初始化完成的,但是它并不能保证可见性。

你可能感兴趣的:(java后端多线程并发)