Java中的同步、互斥机制

概述

在Java多线程编程中,同步和互斥是一个不可避免的话题。Java为开发人员提供了以下几种锁机制:

  1. synchronized关键字
  2. Lock接口
  3. ReadWriteLock接口

这几种锁机制在日常编程中用的很多,但它们有什么联系和区别呢?

一、Synchronized

  1. synchronized关键字是Java内置的关键字,可以轻松实现临界区资源的同步互斥访问。synchronized关键字使用很简单,可以加在方法上或代码块上,用在方法声明中表示整个方法调用是互斥的,用在代码块上表示被包围的代码执行是互斥的,示例如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    public void consumer1(){
    //synchronize 用在代码块上,表示该代码块互斥
    synchronized (this) {
    resource -- ;
    System.out.println(" resource: " + resource);
    }
    }

    //synchronize用在方法上,表示整个方法调用互斥
    public synchronized boolean consumer2(){
    resource -- ;
    System.out.println("resource: " + resource);
    }

    //synchronize用在静态方法上,使用Class对象作为对象锁
    public static synchronized boolean consumer3(){
    resource -- ;
    System.out.println("resource: " + resource);
    }

    synchronized关键字需要一个锁对象,当代码执行到synchronized修饰的方法或包围的代码块时就会获取该锁对象的锁,本质上就是获取锁对象的监视器(monitor,可以理解为锁标记),如果获取到锁就继续执行临界区代码,否则就一直等待。如果synchronized修饰的是一个非静态方法,那么锁对象就是这个对象本身;如果synchronized修饰的是一个静态方法,那么锁对象就是这个类对象。同理,我们在使用synchronized代码块时,需要提供一个锁对象,一般可用this表示使用对象本身作为锁对象,当然也可以使用其他自定义对象,比如 new Object()。这里需要注意一点,不要使用String或常用的数字对象去作为锁对象,因为他们在JVM缓存在一个常量池中,是一个共享对象,如果多处使用这些对象作为锁对象,可能会导致不可预期的死锁。

  2. 可重入是锁的一个重要特性,它是指一个线程重复获取获取它 已经拥有的锁,如果可以获取到则表示该锁是可重入的,否则就是不可重入的。synchronized可重入的,示例如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    public class Test {
    private int resource = 100;
    public static void main(String[] args) throws InterruptedException {
    Test test = new Test();
    test.consumer1();
    }

    public synchronized void consumer1() { //第一次获取锁
    resource--;
    System.out.println("consumer1: " + resource);
    this.consumer2(); //调用另外一个synchronized修饰的方法
    }

    public synchronized void consumer2() { //第二次获取锁
    resource--;
    System.out.println("consumer2: " + resource);
    }
    }
    /**
    * 输出:
    * consumer1: 99
    * consumer2: 98
    */
  3. synchronized不可中断的,当一个线程因获取不到锁而进入阻塞状态时,这个线程就一直会阻塞下去,不会响应外界的中断信号,这也是synchronize的最大缺陷。

  4. 总结:

    • synchronized优点:使用简单,速度快(JVM底层支持,编译后会形成monitorentermonitorenter两条指令),自动释放锁,保证了互斥性和变量修改的可见性(一个线程对变量的修改对其他线程立即可见)。
    • synchronized缺点:synchronized获取锁的过程无法被中断,也不能尝试非阻塞、超时返回等策略获取锁,这在高并发环境下将会带来很大的性能损失。

二、Lock

  1. Lock是Java5开始提供的一个JDK层面的用于控制同步互斥的接口,它位于java.util.concurrent.locks包下。此外,该包下还有Condition和ReadWriteLock两兄弟,它们都是为多线程同步、互斥服务的,其类图如下:

    我们可以看到Lock接口下一般供开发人员直接使用的实现是ReentrantLock,这个类基本解决了synchronized存在的不足,我们看一下Lock接口提供的方法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    public interface Lock {
    //开始获取锁(进入临界区,阻塞方法)
    void lock();
    //开始获取锁,同时可以响应中断事件,可在捕获InterruptedException异常后做后续处理(阻塞方法)
    void lockInterruptibly() throws InterruptedException;
    //尝试获取锁,无论成功或失败都立即返回(非阻塞)
    boolean tryLock();
    //尝试获取锁,如果成功立即返回,否则等待给定时间后返回,并且等待中还可以响应中断事件
    boolean tryLock(long var1, TimeUnit var3) throws InterruptedException;
    //释放锁
    void unlock();
    //条件变量,用于线程间通信及同步协作(生产者、消费者模型会用到)
    Condition newCondition();
    }

    通过Lock接口提供的方法我们看到Lock锁更加细粒度化,它可以让开发人员根据实际需求灵活处理获取锁期间的等待行为。使用Lock一定要手动释放锁,这是很重要的一点,如果处理不慎将会导致不可预期的死锁,一般为了可靠释放锁,会将unlock调用放在finally块中。

  2. 说完Lock接口我们来看一下它的实现ReentrantLock,从字面上就知道这个锁是一个可重入的,下面是该类提供的方法(仅列举部分方法):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    public class ReentrantLock implements Lock, Serializable {
    //构造方法,默认非公平锁
    public ReentrantLock() {...}
    //构造方法,传入一个boolean值指定是否需要公平锁
    public ReentrantLock(boolean var1) {...}
    //获取当前线程对该锁的持有数量(可重入特性)
    public int getHoldCount() {...}
    //是否被当前线程持有
    public boolean isHeldByCurrentThread() {...}
    //是否成功获取锁
    public boolean isLocked() {...}
    //是否公平锁
    public final boolean isFair() {...}
    //返回当前持有此锁的线程
    protected Thread getOwner() {...}
    //是否有可能正在等待获取此锁的线程
    public final boolean hasQueuedThreads() {...}
    //查询指定线程是否正在等待获取此锁
    public final boolean hasQueuedThread(Thread var1) {...}
    //返回可能获取此锁的等待线程数量
    public final int getQueueLength() {...}
    //返回可能正在等待此锁的线程集合
    protected Collection<Thread> getQueuedThreads() {...}
    }

    从上面的构造方法可知,ReentrantLock默认是非公平锁,但可根据需要配置成公平锁。除此之外,它还提供了一系列查询方法,用于查询当前锁的获取状态,这里不一一描述了。

  3. 基础用法:

    1. lock

      1
      2
      3
      4
      5
      6
      7
      8
      9
      Lock lock = new ReentrantLock(true);
      lock.lock(); //lock
      try {
      //do something
      }catch (Exception e){
      //handle exception
      }finally {
      lock.unlock(); //unlock
      }
    2. lockInterruptibly

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      public void doSomething() throws InterruptedException { 
      Lock lock = new ReentrantLock(true);
      lock.lockInterruptibly(); //向上抛出中断异常,也可以自己try cache处理
      try {
      //Do something
      }catch (Exception e){
      //handle exception
      }finally {
      lock.unlock(); //unlock
      }
      }
    3. tryLock:(两种使用方式)

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      //非阻塞
      public void doSomething() {
      Lock lock = new ReentrantLock(true);
      if(lock.tryLock()) { //尝试获取锁,这个调用不会阻塞
      try {
      //do something
      } catch (Exception e) {
      //handle exception
      } finally {
      lock.unlock(); //unlock
      }
      }else {
      //获取锁失败
      }
      }

      //阻塞,可设置超时时间,可中断
      public void doAnything() {
      Lock lock = new ReentrantLock(true);
      try {
      if(lock.tryLock(5, TimeUnit.SECONDS)) { //超时设为5s
      try {
      //do something
      } catch (Exception e) {
      //handle exception
      } finally {
      lock.unlock(); //unlock
      }
      }else {
      //获取锁失败
      }
      } catch (InterruptedException e) {
      //处理中断事件
      }
      }

      在上面的代码演示中,不难发现unlock操作都是放在finally语句中,而获取锁却不再对应try-catch中,这是因为对于非阻塞或可中断的获取锁方发来说,如果获取锁失败,后面再去调用unlock就会抛出IllegalMonitorStateException异常,这会带来不必要的麻烦,所以一般获取锁不成功就不会执行unlock。对于可中断的锁一般采用向上抛出,这是因为多线程环境下获取锁失败的后该是各线程自行采取相应处理策略,而不是由被调用者处理。

三、ReadWriteLock

  1. JDK中除提供了基本的满足同步、互斥的Lock机制外,还提供了一种特殊的读写锁模型,该模型一定程度上降低了互斥要求,带来更好的性能体验。读写锁具体来说分为两方面:

    • ReadLock:读锁不同线程可以重复获取(与可重入概念不一样),即一个资源是可以并发读的,资源加了读锁后只能再加读锁。
    • WriteLock:写锁是完全互斥的,即一个资源加写锁后不能再施加其他锁,当然加了读锁的资源也不能加写锁。
  2. 读写锁在数据库中运用非常广泛,比如一行数据可以多个客户端读取,但不能同时写,也不能边读边写。下面演示读写锁的具体用法:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
    54
    55
    56
    57
    58
    59
    60
    61
    62
    63
    64
    65
    66
    67
    68
    public class LockTest {
    private ReadWriteLock readWriteLock;
    private List<String> resource; //模拟公共资源

    //初始化
    public LockTest() {
    readWriteLock = new ReentrantReadWriteLock();
    resource = new ArrayList<>();
    resource.add(Thread.currentThread().getName());
    }

    //主方法
    public static void main(String[] args) throws InterruptedException {
    LockTest lockTest = new LockTest();
    Thread r1 = new Thread(() -> { for(int i=0;i<5;++i) { lockTest.read();} }, "reader1");
    Thread r2 = new Thread(() -> { for(int i=0;i<5;++i) { lockTest.read();} }, "reader2");
    Thread w1 = new Thread(()->{ try{ lockTest.write(); } catch (InterruptedException e) {
    System.out.println("writer1被中断"); }}, "writer1");
    Thread w2 = new Thread(()->{ try{ lockTest.write(); } catch (InterruptedException e) {
    System.out.println("writer2被中断"); }}, "writer2");

    r1.start();
    r2.start();
    w2.start();
    w1.start();
    Thread.sleep(3);//等待3ms
    w1.interrupt(); //尝试中断writer1的阻塞
    }

    //读取资源
    public void read() {
    readWriteLock.readLock().lock();
    try {
    System.out.println(Thread.currentThread().getName() + ": " +resource);
    } catch (Exception e) {
    } finally {
    readWriteLock.readLock().unlock();
    }
    }

    //写入资源
    public void write() throws InterruptedException {
    readWriteLock.writeLock().lockInterruptibly();
    try {
    resource.add(Thread.currentThread().getName());
    System.out.println(Thread.currentThread().getName()+": add resource");
    Thread.sleep(1000);
    } catch (Exception e) {
    } finally {
    readWriteLock.writeLock().unlock();
    }
    }
    }

    /**输出:
    * reader2: [main]
    * reader1: [main]
    * writer2: add resource
    * writer1被中断
    * reader2: [main, writer2]
    * reader1: [main, writer2]
    * reader1: [main, writer2]
    * reader2: [main, writer2]
    * reader1: [main, writer2]
    * reader2: [main, writer2]
    * reader1: [main, writer2]
    * reader2: [main, writer2]
    */

    上面演示了并发环境下ReadWriteLock的使用,这里需要注意的是最终的输出不是一个固定结果,如果writer1在3ms内获取到锁就不会输出writer1被中断w1.interrupt();将会中断writer1里面的sleep),这个结果完全是随机的。虽然ReentrantReadWriteLock没实现Lock接口,但其内部类ReentrantReadWriteLock.WriteLockReentrantReadWriteLock.ReadLock实现了Lock接口,因此读写锁也具有Lock的全部特性。

四、相关概念

  1. 乐观锁/悲观锁

    乐观锁和悲观锁不是一种具体的锁,而是对待并发的一种态度。悲观锁认为对于同一资源的并发访问一定会发生修改操作,不加锁的并发访问一定会出问题,因此一定要加锁。乐观锁则认为并发访问很少发生资源修改操作,即使发生也会采用不断尝试的方式更新资源,不加锁的并发访问是不会出问题的。悲观锁适合写操作多的场景,乐观锁适合读操作多的场景。Java中各种加锁编程就属于悲观锁范围,而使用concurrent包下的AtomicXXX实现原子操作就属于乐观锁范围,因为Atomic类型是使用CAS算法实现原子操作的,并没有使用锁,属于无锁编程,一般来说乐观锁的性能好于悲观锁。

  2. 可重入锁

    可重入是只可重复递归调用的锁,在加锁的方法类可递归调用该方法,并不会发生死锁。ReentrantLocksynchronized都是可重入锁。

  3. 独占锁(排它锁) / 共享锁

    独占锁即一个锁只能一个线程占有,如ReentrantLockReentrantReadWriteLock.WriteLock就是独占锁。共享锁可被多个线程共享,如ReentrantReadWriteLock.ReadLock

  4. 公平锁 / 非公平锁

    公平锁是指获取锁的顺序跟申请锁的顺序一致,反之亦然。

  5. 分段锁

    分段锁是一种锁的设计,它的核心思想是减小加锁粒度。比如在JDK7中ConcurrentHashMap中就采用分段锁的思想,ConcurrentHashMap中有16个锁,每个散列桶由第N%16个锁来保护,所以一次加锁理论上(与数据分布均匀程度有关)只锁定整个Map的1/16的数据,其他部分的数据访问不受限制,ConcurrentHashMap最多可支持16个线程的并发写入。同样在MySQL中也有类似的设计出现,比如行锁就是一种分段锁,更新数据时只需要锁定特定行,其他行可供正常访问。

  6. 偏向锁 / 轻量级锁 / 重量级锁

    这3种分类非Java语言提供的特性,因此不做深入研究,可参考:偏向锁/轻量级锁/重量级锁

  7. 自旋锁

    自旋锁关注的是在获取锁的过程中线程处所处状态,当一个线程在获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待(不进入阻塞状态),然后不断的判断锁是否能够被成功获取,直到获取到锁才会退出循环。注意,自旋锁的设计是为了减少线程状态切换带来的开销(用户态->内核态,active->blocked),当加锁区域的代码执行的非常快时,该设计能大大提高性能。而加锁区域执行缓慢时则相反,自旋锁不会释放CPU资源,如果长时间处于自旋状态将严重拖累系统性能,所以是否采用自旋锁需要根据需求而定。

五、参考资料

  1. Java 并发:Lock 框架详解
  2. Java 种15种锁的介绍:公平锁,可重入锁,独享锁,互斥锁等等…
  3. javas的四种状态 无锁状态 偏向锁状态 轻量级锁状态 重量级锁状态
  4. 面试必备之深入理解自旋锁
  5. JDK文档
文章作者: Jack.Charles
文章链接: https://blog.zjee.me/2019/08/24/java-lock-util/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 江影不沉浮