关于java:线程同步机制

10次阅读

共计 7467 个字符,预计需要花费 19 分钟才能阅读完成。

  • 锁概述
  • 外部锁:synchronized
  • 显式锁:Lock
  • 内存屏障
  • 轻量级同步机制:volatile 关键字
  • 单例模式线程平安问题
  • CAS
  • static 与 final

锁概述

  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. 内存屏障下的排序规定(实线代表可排序, 虚线代表不可排序)

轻量级同步机制: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 只能解决有序性问题,即保障拿到的变量是初始化实现的,然而它并不能保障可见性。
正文完
 0