【Lock】锁的几种特性

Posted by 西维蜀黍 on 2019-03-01, Last Modified on 2021-09-21

我们来介绍一下与锁相关的几个概念。

  • 可重入锁(Reentrant Lock)
  • 公平锁(fair locking)和非公平锁(unfair locking)
  • 可中断锁(Interruptable Lock)
  • 可限时锁(Timed Lock)
  • 读写锁(Read-Write Lock)
  • 自旋锁(Spin Lock)
    • 适应性自旋锁(Adaptive Spinning Lock)
  • 独享锁(Exclusive Lock) VS 共享锁(Shared Lock)
  • 偏向锁(Biased Lock)

可重入锁(Reentrant Lock)

如果锁具备可重入性(Reentracy),则称作为可重入锁(Reentrant Lock)。**可重入锁(Reentrant Lock)**允许一个线程可以反复得到相同的一把锁,

synchronized、 ReentrantLock 和 ReentrantReadWriteLock 都是可重入锁。

本质上,可重入锁有一个与锁相关的获取计数器,如果拥有锁的某个线程尝试再次得到锁,那么获取计数器就加1。最终,当锁被释放两次后,该锁才会真正被释放。

可重入锁对于锁基于线程的分配,而不是基于方法调用的分配。举个简单的例子,当一个线程执行到某个 synchronized 方法时,比如说 method1,而在 method1 中会调用另外一个 synchronized 方法 method2,此时线程不必重新去申请锁,而是可以直接执行 method2 方法。

以代码说明:

class MyClass {
	public synchronized void method1() {
    	method2();    
    }
	public synchronized void method1() {
		...
    }
}

上述代码中的两个方法 method1 和 method2 都是 synchronized 方法。

假如某一时刻,线程A执行到了 method1 ,此时线程A获取了这个对象的锁,而由于 method2 也是synchronized方法。假如 synchronized 不具备可重入性,此时当线程A 进入 method2 方法的调用时,需要重新申请锁。但是这就会造成一个问题,因为线程A已经持有了该对象的锁,而又在申请获取该对象的锁,这样就会线程A一直等待永远不会获取到的锁。

公平锁(Fair Lock)和非公平锁(Unfair Lock)

Concept

何谓公平性(fairness),是针对获取锁而言的,如果一个锁是公平的,那么锁的获取顺序就应该符合线程请求锁时的绝对时间顺序,满足 FIFO。换句话说,等待时间最久的线程(最先请求的线程)会先获得该锁。

满足公平性的锁,就称为公平锁(fair locking),反之,称为非公平锁(unfair locking)

在 Java 中,synchronized 就是非公平锁,它无法保证等待的线程获取锁的顺序。

而对于 ReentrantLock 和 ReentrantReadWriteLock,它在默认情况下是非公平锁,但是可以设置为公平锁。

公平锁 VS 非公平锁

公平锁每次获取到锁为同步队列中的第一个节点,保证请求资源时间上的绝对顺序,而非公平锁有可能刚释放锁的线程下次继续获取该锁,则有可能导致其他线程永远无法获取到锁,造成“饥饿”现象(starvation)。

公平锁为了保证时间上的绝对顺序,需要频繁的上下文切换,而非公平锁会降低一定的上下文切换,降低性能开销。因此,ReentrantLock 默认选择的是非公平锁,则是为了减少一部分上下文切换,保证了系统更大的吞吐量

所以,我们经常会将一个mutex设计为非公平锁,而又尽可能地不让它发生starvation,并引入一个机制来处理当starvation发生时的情况,以尽可能的减少starvation的时长。

可中断锁(Interruptable Lock)

可中断锁 (Interruptible Lock),顾名思义,就是可以被中断持有的锁

比如,某一线程A正在执行锁中的代码,另一线程B正在等待获取该锁,由于等待时间过长,线程B急着想处理一个优先级相对更高的事务,我们可以让线程A中断自己正在持有的锁,或者线程B中断线程A正在持有的锁,这就是可中断锁(Interruptable Lock)

在 Java 中,synchronized 就不是可中断锁,而 ReentrantLock 和 ReentrantReadWriteLock 都是可中断锁。

可中断锁解决死锁

可中断锁可以帮助解决死锁(deadlock)问题,比如:

import java.util.concurrent.locks.ReentrantLock;

public class IntLock implements Runnable{
    public static ReentrantLock lock1 = new ReentrantLock();
    public static ReentrantLock lock2 = new ReentrantLock();
    int lock;
    /**
     * 控制加锁顺序,产生死锁
     */
    public IntLock(int lock) {
        this.lock = lock;
    }
    public void run() {
        try {
            if (lock == 1) {
                lock1.lockInterruptibly(); // 如果当前线程未被中断,则获取锁。
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock2.lockInterruptibly();
                System.out.println(Thread.currentThread().getName()+",执行完毕!");
            } else {
                lock2.lockInterruptibly();
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                lock1.lockInterruptibly();
                System.out.println(Thread.currentThread().getName()+",执行完毕!");
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            // 查询当前线程是否保持此锁。
            if (lock1.isHeldByCurrentThread()) {
                lock1.unlock();
            }
            if (lock2.isHeldByCurrentThread()) {
                lock2.unlock();
            }
            System.out.println(Thread.currentThread().getName() + ",退出。");
        }
    }
    public static void main(String[] args) throws InterruptedException {
        IntLock intLock1 = new IntLock(1);
        IntLock intLock2 = new IntLock(2);
        Thread thread1 = new Thread(intLock1, "线程1");
        Thread thread2 = new Thread(intLock2, "线程2");
        thread1.start();
        thread2.start();
        Thread.sleep(1000);
        thread2.interrupt(); // 中断线程2,且释放线程2持有的所有锁,同时放弃所有锁申请
    }
}

上述例子中,线程 thread1 和 thread2 启动后,thread1 立刻先占用 lock1,等待 500ms 后,再占用 lock2;thread2 反之,立刻先占 lock2,等待 500ms 后,后占 lock1。这便形成 thread1 和 thread2 之间的死锁。

当代码运行到 Thread.sleep(1000); 时,main 线程处于休眠(sleep)状态,两线程此时处于死锁的状态,thread2.interrupt(); 导致 thread2 被中断(interrupt),且释放 thread2 持有的所有锁,故 thread2 会放弃对 lock1 的申请。这个操作导致 thread1 最终顺利获得 lock2,从而最终解开了死锁。

可限时锁(Timed Lock)

除了可中断锁外,**可限时锁(Timed Lock)**也可以在有些情况下避免死锁的发生。

ReentrantLock 是一种可限时锁(Timed Lock),它提供了 tryLock() 方法,并传入一个超时值作为参数。当在到达超时时间后,仍然没有获得锁,则放弃对锁的持有申请。

在 Java 中,synchronized就不是可限时锁,而 ReentrantLock 和 ReentrantReadWriteLock 都是可限时锁。

读写锁(Read-Write Lock)

读写锁是一种 design pattern,refer to https://swsmile.info/post/design-pattern-concurrency-read-write-pattern/.

读写锁(Read-Write Lock)将对一个资源(比如文件)的访问分成了2个锁,一个读锁和一个写锁。

  • 多个读锁之间是不需要互斥的(因为读操作不会改变数据,如果上了锁,反而会影响效率);
  • 而写锁和写锁之间需要互斥。也就是说,如果只是读数据,就可以多个线程同时读,但是如果要写数据,就必须互斥,使得同一时刻只有一个线程在操作。

正因为有了读写锁,才使得多个线程之间的读操作不会发生冲突。

ReadWriteLock 就是一个读写锁,它是一个接口,而 ReentrantReadWriteLock 实现了这个接口。

可以通过 readLock() 获取读锁,通过 writeLock() 获取写锁。

自旋锁(Spinlock)

自旋锁也是一种实现互斥(mutual exclusion)的方式,相比经典的互斥锁(Mutex)会在因无法进入临界区而进入阻塞状态(因此互斥锁是一个阻塞锁),因而放弃CPU。

而自旋锁(Spin Lock)是指当一个线程在申请获取锁的时候,如果锁已经被其它线程获取,那么该线程将循环等待,然后不断地判断锁是否能够被成功获取,直到获取到锁后,才会退出循环。

在这种情况中,尝试获取锁的线程一直处于 Running 状态,但是并没有执行任何有效的任务,即紧密循环(tight loop),这个过程也称为忙等待(busy waiting)。

Refer to https://swsmile.info/post/Spinlock/

独享锁(Exclusive Lock) VS 共享锁(Shared Lock)

独享锁(Exclusive Lock)也叫排他锁,是指该锁一次只能被一个线程所持有。如果线程T对数据A加上排它锁后,则其他线程不能再对A加任何类型的锁。获得排它锁的线程即能读数据又能修改数据。JDK中的synchronized和JUC中Lock的实现类就是互斥锁。

共享锁(Shared Lock)是指该锁可被多个线程所持有。典型的就是ReentrantReadWriteLock里的读锁,它的读锁是可以被共享的,但是它的写锁确每次只能被独占。

Refer to https://swsmile.info/post/lock-exclusive-lock-vs-shared-lock/

Reference