Java并发编程学习四:锁

一、锁的分类

Java中有着各种各样的锁,对于锁的分类也是多种多样,一把锁可能同时占有多个标准,符合多种分类。

对锁的常见分类有以下几个标准:

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

这三种锁特指 synchronized 锁的状态,JVM中通过在对象头中的 mark word 来表明锁的状态。这三个锁也刚好对应了JVM中对synchronized 锁升级的几个阶段:无锁→偏向锁→轻量级锁→重量级锁。

a. 偏向锁
如果自始至终,对于这把锁都不存在竞争,那么其实就没必要上锁,只需要打个标记就行了,这就是偏向锁的思想。

一个对象被初始化后,还没有任何线程来获取它的锁时,那么它就是可偏向的,当有第一个线程来访问它并尝试获取锁的时候,它就将这个线程记录下来,以后如果尝试获取锁的线程正是偏向锁的拥有者,就可以直接获得锁,开销很小,性能最好。

b. 轻量级锁
实际情况中,synchronized 中的代码是被多个线程交替执行的,而不是同时执行的,也就是说并不存在实际的竞争,或者是只有短时间的锁竞争,用 CAS 就可以解决,这种情况下,用完全互斥的重量级锁是没必要的。

轻量级锁是指当锁原来是偏向锁的时候,被另一个线程访问,说明存在竞争,那么偏向锁就会升级为轻量级锁,线程会通过自旋的形式尝试获取锁,而不会陷入阻塞。

c. 重量级锁
重量级锁是互斥锁,它是利用操作系统的同步机制实现的,所以开销相对比较大。当多个线程直接有实际竞争,且锁竞争时间长的时候,轻量级锁不能满足需求,锁就会膨胀为重量级锁。重量级锁会让其他申请却拿不到锁的线程进入阻塞状态。

从性能上来说,偏向锁性能最好,可以避免执行 CAS 操作。而轻量级锁利用自旋和 CAS 避免了重量级锁带来的线程阻塞和唤醒,性能中等。重量级锁则会把获取不到锁的线程阻塞,性能最差。

2. 可重入锁/非可重入锁

可重入锁指的是线程当前已经持有这把锁了,能在不释放这把锁的情况下,再次获取这把锁

不可重入锁指的是虽然线程当前持有了这把锁,但是如果想再次获取这把锁,也必须要先释放锁后才能再次尝试获取。

典型的可重入锁,就是ReentrantLock ,它是 Lock 接口最主要的一个实现类,reentrant 代表可重入。

3. 共享锁/独占锁

共享锁指的是我们同一把锁可以被多个线程同时获得,而独占锁指的就是,这把锁只能同时被一个线程获得。

最典型的案例就是读写锁,读写锁中的读锁,是共享锁,而写锁是独占锁。读锁可以被同时读,可以同时被多个线程持有,而写锁最多只能同时被一个线程持有。

4. 公平锁/非公平锁

公平锁的公平的含义在于如果线程现在拿不到这把锁,那么线程就都会进入等待,开始排队,在等待队列里等待时间长的线程会优先拿到这把锁,有先来先得的意思。

非公平锁就不那么“完美”了,它会在一定情况下,忽略掉已经在排队的线程,发生插队现象。注意,这里说的是在一定情况下(后续会介绍),非公平锁并不是任何时候都是非公平的。

5. 悲观锁/乐观锁

悲观锁的概念是在获取资源之前,必须先拿到锁,以便达到“独占”的状态,当前线程在操作资源的时候,其他线程由于不能拿到锁,所以其他线程不能来影响当前线程。

乐观锁恰恰相反,它并不要求在获取资源前拿到锁,也不会锁住资源;相反,乐观锁利用 CAS 理念,在不独占资源的情况下,完成了对资源的修改。

6. 自旋锁/非自旋锁

自旋锁的理念是如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁,这个循环过程被形象地比喻为“自旋”。

非自旋锁的理念就是没有自旋的过程,如果拿不到锁就直接放弃,或者进行其他的处理逻辑,例如去排队、陷入阻塞等。

7. 可中断锁/不可中断锁

不可中断锁是指一旦线程申请了锁,就没有回头路了,只能等到拿到锁以后才能进行其他的逻辑处理。典型的是synchronized关键字修饰的锁。

可中断锁是指当线程申请了锁,但后续又突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开。典型的是ReentrantLock。

二、悲观锁 VS 乐观锁

1. 悲观锁的思想

悲观锁的思想是,如果不锁住资源,别的线程就会来争抢,就会造成数据结果错误,所以悲观锁为了确保结果的正确性,会在每次获取并修改数据时,都把数据锁住,让其他线程无法访问该数据,这样就可以确保数据内容万无一失。

2. 乐观锁的思想

乐观锁的思想是,认为在操作资源的时候不会有其他线程来干扰,所以并不会锁住被操作对象,不会不让别的线程来接触它,同时,为了确保数据正确性,在更新之前,会去对比修改数据期间,数据有没有被其他线程修改过:如果没被修改过,就说明真的只有自己在操作,那就可以正常的修改数据;如果发现数据和一开始拿到的不一样了,说明其他线程在这段时间内修改过数据,就会放弃这次修改,并选择报错、重试等策略。乐观锁的实现一般都是利用 CAS 算法实现的。

3. 典型案例

悲观锁:synchronized 关键字和 Lock 接口
乐观锁:原子类
兼有两种锁:数据库。如果在 MySQL 选择 select for update 语句,那就是悲观锁,在提交之前不允许第三方来修改该数据;相反,可以利用一个版本 version 字段在数据库中实现乐观锁。在获取及修改数据时都不需要加锁,但是在获取完数据并计算完毕,准备更新数据时,会检查版本号和获取数据时的版本号是否一致,如果一致就直接更新,如果不一致,说明计算期间已经有其他线程修改过这个数据了,就可以选择重新获取数据,重新计算,然后再次尝试更新数据。

以下是SQL中实现乐观锁的示例

UPDATE student
    SET 
        name = ‘小李’,
        version= 2
    WHERE   id= 100
        AND version= 1

4. 使用场景

直观来看,悲观锁由于操作比较重量级,不能多个线程并行执行,而且还会有上下文切换等动作,性能不及乐观锁,所以应该尽量避免用悲观锁。

其实这种想法有些过于片面。虽然悲观锁确实会让得不到锁的线程阻塞,但是这种开销是固定的。悲观锁的原始开销确实要高于乐观锁,但是特点是一劳永逸,就算一直拿不到锁,也不会对开销造成额外的影响。

相反,反观乐观锁虽然一开始的开销比悲观锁小,但是如果一直拿不到锁,或者并发量大,竞争激烈,导致不停重试,那么消耗的资源也会越来越多,甚至开销会超过悲观锁。

因此悲观锁和乐观锁没有谁更好这一说法,只是各有适用的使用场景

悲观锁适合用于并发写入多、临界区代码复杂、竞争激烈等场景,这种场景下悲观锁可以避免大量的无用的反复尝试等消耗

乐观锁适用于大部分是读取,少部分是修改的场景,也适合虽然读写都很多,但是并发并不激烈的场景。在这些场景下,乐观锁不加锁的特点能让性能大幅提高

三、公平锁 VS 非公平锁

公平锁指的是按照线程请求的顺序,来分配锁;而非公平锁指的是不完全按照请求的顺序,在一定情况下,可以允许插队。非公平锁并不是任何时候都允许插队的,仅仅在"合适的时机"允许插队。

1. 什么时候能"插队"?

那么什么是"合适的时机"呢?假设当前线程在请求获取锁的时候,恰巧前一个持有锁的线程释放了这把锁,那么当前申请锁的线程就可以不顾已经等待的线程而选择立刻插队。但是如果当前线程请求的时候,前一个线程并没有在那一时刻释放锁,那么当前线程还是一样会进入等待队列。

那为什么要在"合适的时机"插队呢?假设A 持有一把锁,线程 B 请求这把锁,由于线程 A 已经持有这把锁了,所以线程 B 会陷入等待,在等待的时候线程 B 会被挂起,也就是进入阻塞状态。当线程 A 释放锁的时候,本该轮到线程 B 苏醒获取锁,但如果此时突然有一个线程 C 插队请求这把锁,会把这把锁给线程 C。因为因为唤醒线程 B 是需要很大开销的,很有可能在唤醒之前,线程 C 已经拿到了这把锁并且执行完任务释放了这把锁。相比于等待唤醒线程 B 的漫长过程,插队的行为会让线程 C 本身跳过陷入阻塞的过程,如果在锁代码中执行的内容不多的话,线程 C 就可以很快完成任务,并且在线程 B 被完全唤醒之前,就把这个锁交出去,这样是一个双赢的局面,对于线程 C 而言,不需要等待提高了它的效率,而对于线程 B 而言,它获得锁的时间并没有推迟,因为等它被唤醒的时候,线程 C 早就释放锁了,因为线程 C 的执行速度相比于线程 B 的唤醒速度。

2. 公平锁和非公平锁的优缺点
优势 劣势
公平锁 各线程公平,每个线程再等待一段时间后,总有执行的机会 更慢,吞吐量更小
非公平锁 更快,吞吐量大 有可能产生线程饥饿,即某些线程长时间内始终得不到执行
3. ReentrantLock 源码分析

典型的公平锁和非公平锁的实现是ReentrantLock,该类默认就是非公平锁,当初始化ReentrantLock式传入false时就是公平锁,传入true时就是非公平锁。

ReentrantLock 类包含一个 Sync 类,这个类继承自AQS(AbstractQueuedSynchronizer)

public class ReentrantLock implements Lock, java.io.Serializable {

private static final long serialVersionUID = 7373984872572414699L;

/** Synchronizer providing all implementation mechanics */

private final Sync sync;

Sync 类的代码:

abstract static class Sync extends AbstractQueuedSynchronizer {

     ...}

而Sync 有公平锁 FairSync 和非公平锁 NonfairSync两个子类:

static final class NonfairSync extends Sync {

     ...}
static final class FairSync extends Sync {

     ...}

公平锁获取锁的源码如下:

protected final boolean tryAcquire(int acquires) {

    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {

        if (!hasQueuedPredecessors() && //这里判断了 hasQueuedPredecessors()
                compareAndSetState(0, acquires)) {

            setExclusiveOwnerThread(current);
            return true;
        }
    } else if (current == getExclusiveOwnerThread()) {

        int nextc = c + acquires;
        if (nextc < 0) {

            throw new Error("Maximum lock count exceeded");
        }
        setState(nextc);
        return true;
    }
    return false;
}

非公平锁获取锁的源码如下:

final boolean nonfairTryAcquire(int acquires) {

    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {

        if (compareAndSetState(0, acquires)) {

      //这里没有判断hasQueuedPredecessors()
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {

        int nextc = c + acquires;
        if (nextc < 0) // overflow
        throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

两者的区别在于,平锁在获取锁时多了一个限制条件:!hasQueuedPredecessors(),即判断在等待队列中是否已经有线程在排队了。一旦已经有线程在排队了,当前线程就不再尝试获取锁。

但是有一个方法比较特殊:tryLock() 方法,他并不遵循设定的公平原则。当有线程执行 tryLock() 方法的时候,一旦有线程释放了锁,即便是公平锁模式,这个正在 tryLock 的线程也能获取到锁。

查看tryLock()的源码:

public boolean tryLock() {

    return sync.nonfairTryAcquire(1);
}

发现它调用的是nonfairTryAcquire(),表明了是不公平的,和锁本身是否是公平锁无关。

四、synchronized VS Lock

1. 两种方式的对比

共同点:

  • 都是用来保护资源线程安全的
  • 都可以保证可见性。对于 synchronized 而言,线程 A 在进入 synchronized 块之前或在 synchronized 块内进行操作,对于后续的获得同一个 monitor 锁的线程 B 是可见的;对于Lock 而言,在解锁之前的所有操作对加锁之后的所有操作都是可见的。
  • synchronized 和 ReentrantLock 都可是可重入。

不同点:

  • 用法区别:

  • synchronized 关键字可以加在方法上,不需要指定锁对象(此时的锁对象为 this),也可以新建一个同步代码块并且自定义 monitor 锁对象;

  • 而 Lock 接口必须显示用 Lock 锁对象开始加锁 lock() 和解锁 unlock(),并且一般会在 finally 块中确保用 unlock() 来解锁,以防发生死锁。

  • 与 Lock 显式的加锁和解锁不同的是 synchronized 的加解锁是隐式的,尤其是抛异常的时候也能保证释放锁

  • 加解锁顺序不同:

  • 对于 Lock 而言如果有多把 Lock 锁,Lock 可以不完全按照加锁的反序解锁,比如我们可以先获取 Lock1 锁,再获取 Lock2 锁,解锁时则先解锁 Lock1,再解锁 Lock2,加解锁有一定的灵活度

  • synchronized 解锁的顺序和加锁的顺序必须完全相反,因为 synchronized 加解锁是由 JVM 实现的,在执行完 synchronized 块后会自动解锁,所以会按照 synchronized 的嵌套顺序加解锁,不能自行控制。

  • synchronized 锁不够灵活:

  • 一旦 synchronized 锁已经被某个线程获得了,此时其他线程如果还想获得,那它只能被阻塞,直到持有锁的线程运行完毕或者发生异常从而释放这个锁。如果持有锁的线程持有很长时间才释放,那么整个程序的运行效率就会降低,而且如果持有锁的线程永远不释放锁,那么尝试获取锁的线程只能永远等下去。

  • Lock 类在等锁的过程中,如果使用的是 lockInterruptibly 方法,那么如果觉得等待的时间太长了不想再继续等待,可以中断退出,也可以用 tryLock() 等方法尝试获取锁,如果获取不到锁也可以做别的事,更加灵活

  • 原理区别:

  • synchronized 是内置锁,由 JVM 实现获取锁和释放锁的原理,还分为偏向锁、轻量级锁、重量级锁

  • Lock 根据实现不同,有不同的原理,例如 ReentrantLock 内部是通过 AQS 来获取和释放锁的

  • 是否可以设置公平/非公平:

  • ReentrantLock 等 Lock 实现类可以根据自己的需要来设置公平或非公平,synchronized 则不能设置。

  • 性能区别:

  • 在 Java 5 以及之前,synchronized 的性能比较低

  • 在 Java 6 以后,因为 JDK 对 synchronized 进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,所以后期的 Java 版本里的 synchronized 的性能并不比 Lock 差。

2. synchronized 和 Lock如何选择?
  • 能不用最好既不使用 Lock 也不使用 synchronized,在许多情况下可以使用 java.util.concurrent 包中的机制,它会处理所有的加锁和解锁操作,也就是推荐优先使用工具类来加解锁
  • 如果某种场景下 synchronized 关键字更适合, 那么请尽量使用它,这样可以减少编写代码的数量,减少出错的概率。因为一旦忘记在 finally 里 unlock,代码可能会出很大的问题,而使用 synchronized 更安全
  • 如果特别需要 Lock 的特殊功能,比如尝试获取锁、可中断、超时功能等,才使用 Lock
3. synchronized 的原理

synchronized 的背后是利用 monitor 锁实现的,每个 Java 对象都可以用作一个实现同步的锁,这个锁也被称为内置锁或 monitor 锁,获得 monitor 锁的唯一途径就是进入由这个锁保护的同步代码块或同步方法,线程在进入被 synchronized 保护的代码块之前,会自动获取锁,并且无论是正常路径退出,还是通过抛出异常退出,在退出的时候都会自动释放锁。

a. synchronized 同步代码块
如下所示:

public class SynTest {

    public void synBlock() {

        synchronized (this) {

            System.out.println("lagou");
        }
    }
}

对SynTest 类进行操作,首先切换到SynTest 类的路径,执行 javac SynTest.java生成一个SynTest.class 的字节码文件,再执行 javap -verbose SynTest.class得到以下的反汇编:

public void synBlock();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=2, locals=3, args_size=1
         0: aload_0
         1: dup
         2: astore_1
         3: monitorenter
         4: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         7: ldc           #3                      // String lagou
         9: invokevirtual #4               // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        12: aload_1
        13: monitorexit
        14: goto          22
        17: astore_2
        18: aload_1
        19: monitorexit
        20: aload_2
        21: athrow
        22: return

synchronized 代码块实际上多了 monitorenter 和 monitorexit 指令,标红的第3、13、19行指令分别对应的是 monitorenter 和 monitorexit。这里有一个 monitorenter,却有两个 monitorexit 指令的原因是,JVM 要保证每个 monitorenter 必须有与之对应的 monitorexit,monitorenter 指令被插入到同步代码块的开始位置,而 monitorexit 需要插入到方法正常结束处和异常处两个地方,这样就可以保证抛异常的情况下也能释放锁

  • monitorenter
    可以理解为加锁,执行 monitorenter 的线程尝试获得 monitor 的所有权,会发生以下这三种情况之一:
    a. 如果该 monitor 的计数为 0,则线程获得该 monitor 并将其计数设置为 1。然后,该线程就是这个 monitor 的所有者。
    b. 如果线程已经拥有了这个 monitor ,则它将重新进入,并且累加计数。
    c. 如果其他线程已经拥有了这个 monitor,那个这个线程就会被阻塞,直到这个 monitor 的计数变成为 0,代表这个 monitor 已经被释放了,于是当前这个线程就会再次尝试获取这个 monitor。
  • monitorexit
    可以理解为释放锁,作用是将 monitor 的计数器减 1,直到减为 0 为止。代表这个 monitor 已经被释放了,已经没有任何线程拥有它了,也就代表着解锁,所以,其他正在等待这个 monitor 的线程,此时便可以再次尝试获取这个 monitor 的所有权。

b. synchronized 同步方法
与同步代码块不同,synchronized 同步方法并不是依靠 monitorenter 和 monitorexit 指令实现的,而是依赖一个叫作 ACC_SYNCHRONIZED 的 flag 修饰符。

示例代码如下:

public synchronized void synMethod() {

}

反汇编之后的结果如下:

public synchronized void synMethod();
    descriptor: ()V
    flags: ACC_PUBLIC, ACC_SYNCHRONIZED
    Code:
      stack=0, locals=1, args_size=1
         0: return
      LineNumberTable:
        line 16: 0

被synchronized 修饰的方法会有一个 ACC_SYNCHRONIZED 标志。当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。其他方面, synchronized 方法和刚才的 synchronized 代码块是很类似的,例如这时如果其他线程来请求执行方法,也会因为无法获得 monitor 锁而被阻塞。

4. Lock的用法

Lock 接口中关注加解锁的方法有以下5个:

public interface Lock {

    void lock();
    void lockInterruptibly() throws InterruptedException;
    boolean tryLock();
    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
    void unlock();
    Condition newCondition();
}

a. lock() 方法
lock() 是最基础的获取锁的方法。在线程获取锁时如果锁已被其他线程获取,则进行等待,是最初级的获取锁的方法。

对于Lock 接口而言,获取锁和释放锁都是显式的,lock 的加锁和释放锁都必须以代码的形式写出来,所以使用 lock() 时必须主动去释放锁,因此最佳实践是执行 lock() 后,首先在 try{} 中操作同步资源,如果有必要就用 catch{} 块捕获异常,然后在 finally{} 中释放锁。

Lock lock = ...;
lock.lock();
try{

    //获取到了被本锁保护的资源,处理任务
    //捕获异常
}finally{

    lock.unlock();   //释放锁
}

如果不遵守上述规范,就会让 Lock 变得非常危险,因为你不知道未来什么时候由于异常的发生,导致跳过了 unlock() 语句,使得这个锁永远不能被释放了,其他线程也无法再获得这个锁。

另外,lock() 方法不能被中断,这会带来很大的隐患:一旦陷入死锁,lock() 就会陷入永久等待。

b. tryLock() 方法
tryLock() 用来尝试获取锁,如果当前锁没有被其他线程占用,则获取成功,返回 true,否则返回 false,代表获取锁失败。

相比于lock()方法,tryLock() 方法功能更为强大,该方法会立即返回,即便在拿不到锁时也不会一直等待。该方法的典型使用如下:

Lock lock = ...;
if(lock.tryLock()) {

     try{

         //处理任务
     }finally{

         lock.unlock();   //释放锁
     } 
}else {

    //如果不能获取锁,则做其他事情
}

tryLock() 方法可以帮助解决死锁问题,案例如下:

public void tryLock(Lock lock1, Lock lock2) throws InterruptedException {

        while (true) {

            if (lock1.tryLock()) {

                try {

                    if (lock2.tryLock()) {

                        try {

                            System.out.println("获取到了两把锁,完成业务逻辑");
                            return;
                        } finally {

                            lock2.unlock();
                        }
                    }
                } finally {

                    lock1.unlock();
                }
            } else {

                Thread.sleep(new Random().nextInt(1000));
            }
        }
    }

上述代码中,如果不使用tryLock,当有两个线程同时调用这个方法,传入的 lock1 和 lock2 恰好是相反的,那么如果第一个线程获取了 lock1 的同时,第二个线程获取了 lock2,它们接下来便会尝试获取对方持有的那把锁,但是又获取不到,于是便会陷入死锁。

有了tryLock() 方法之后,首先会检测 lock1 是否能获取到,如果能获取到再尝试获取 lock2,但如果 lock1 获取不到也没有关系,我们会在下面进行随机时间的等待,这个等待的目标是争取让其他的线程在这段时间完成它的任务,以便释放其他线程所持有的锁,以便后续供我们使用,同理如果获取到了 lock1 但没有获取到 lock2,那么也会释放掉 lock1,随即进行随机的等待,只有当它同时获取到 lock1 和 lock2 的时候,才会进入到里面执行业务逻辑,比如在这里我们会打印出“获取到了两把锁,完成业务逻辑”,然后方法便会返回。

c. tryLock(long time, TimeUnit unit) 方法
tryLock(long time, TimeUnit unit)是tryLock()的重载方法,它与tryLock()的区别在于tryLock(long time, TimeUnit unit) 方法会有一个超时时间,在拿不到锁时会等待一定的时间,如果在时间期限结束后,还获取不到锁,就会返回 false;如果一开始就获取锁或者等待期间内获取到锁,则返回 true。

该方法与tryLock()一样也可以帮助解决死锁问题。

d. lockInterruptibly() 方法
该方法同样是用于获取锁的,如果这个锁当前是可以获得的,那么这个方法会立刻返回,但是如果这个锁当前是不能获得的(被其他线程持有),那么当前线程便会开始等待,除非它等到了这把锁或者是在等待的过程中被中断了,否则这个线程便会一直在这里执行这行代码。一句话总结就是,除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止。

lockInterruptibly() 是可以响应中断的,可以把它理解为超时时间是无穷长的 tryLock(long time, TimeUnit unit),因为 tryLock(long time, TimeUnit unit) 和 lockInterruptibly() 都能响应中断,只不过 lockInterruptibly() 永远不会超时。

这个方法本身是会抛出 InterruptedException 的,使用的时候,如果不在方法签名声明抛出该异常,那么就要写两个 try 块。如下所示:

public void lockInterruptibly() {

    try {

        lock.lockInterruptibly();
        try {

            System.out.println("操作资源");
        } finally {

            lock.unlock();
        }
    } catch (InterruptedException e) {

        e.printStackTrace();
    }
}

e. unlock() 方法
该方法是用于解锁的,对于ReentrantLock 而言,由于是可重入锁,,执行 unlock() 的时候,内部会把锁的“被持有计数器”减 1,直到减到 0 就代表当前这把锁已经完全释放了,如果减 1 后计数器不为 0,说明这把锁之前被“重入”了,那么锁并没有真正释放,仅仅是减少了持有的次数。

五、读写锁ReadWriteLock

1. 读写锁的获取规则
  • 当一个线程已经占有了读锁,那么其他线程如果想要申请读锁,可以申请成功;
  • 当一个线程已经占有了读锁,而且有其他线程想要申请获取写锁的话,是不能申请成功的,因为读写互斥;
  • 当一个线程已经占有了写锁,那么此时其他线程无论是想申请读锁还是写锁,都无法申请成功。

总结而言就是:读读共享,其他都互斥(写写、读写、写读)

2. ReentrantReadWriteLock

ReentrantReadWriteLock 是ReadWriteLock的实现类,分别有两个方法:readLock() 和 writeLock() 用来获取读锁和写锁

/**
 * 描述: 演示读写锁用法
 */
public class ReadWriteLockDemo {

    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
            false);
    private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
            .readLock();
    private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
            .writeLock();

    private static void read() {

        readLock.lock();
        try {

            System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
            Thread.sleep(500);
        } catch (InterruptedException e) {

            e.printStackTrace();
        } finally {

            System.out.println(Thread.currentThread().getName() + "释放读锁");
            readLock.unlock();
        }
    }

    private static void write() {

        writeLock.lock();
        try {

            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(500);
        } catch (InterruptedException e) {

            e.printStackTrace();
        } finally {

            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> read()).start();
        new Thread(() -> read()).start();
        new Thread(() -> write()).start();
        new Thread(() -> write()).start();
    }
}

运行结果如下:

Thread-0得到读锁,正在读取
Thread-1得到读锁,正在读取
Thread-0释放读锁
Thread-1释放读锁
Thread-2得到写锁,正在写入
Thread-2释放写锁
Thread-3得到写锁,正在写入
Thread-3释放写锁

相比于ReentrantLock 适用于一般场合,ReadWriteLock 适用于读多写少的情况,合理使用可以进一步提高并发效率。

3. 读写锁的插队策略

ReentrantReadWriteLock 和 ReentrantLock一样,都支持公平锁和非公平锁:

// 公平锁:
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);

// 非公平锁
ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);

在获取读锁时,线程会检查readerShouldBlock() 方法。同样,在获取写锁之前,线程会检查 writerShouldBlock() 方法,来决定是否需要插队或者是去排队。

当ReentrantReadWriteLock 是公平锁时:

final boolean writerShouldBlock() {

    return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {

    return hasQueuedPredecessors();
}

只要等待队列中有线程在等待,也就是hasQueuedPredecessors() 返回 true 的时候,那么 writer 和 reader 都会 block,也就是一律不允许插队。

当ReentrantReadWriteLock 是非公平锁时:

final boolean writerShouldBlock() {

    return false; // writers can always barge
}
final boolean readerShouldBlock() {

    return apparentlyFirstQueuedIsExclusive();
}

非公平锁时,对于想获取写锁的线程而言,由于返回值是 false,所以它是随时可以插队的,但是读锁却不一样,即便是非公平锁,只要等待队列的头结点是尝试获取写锁的线程,那么读锁依然是不能插队的。

那么为什么读锁不能像写锁一样,无脑支持插队呢?实际上不这样是为了防止线程"饥饿"。
假如有这样一个场景,线程 2 和线程 4 正在同时读取,线程 3 想要写入,但是由于线程 2 和线程 4 已经持有读锁了,所以线程 3 就进入等待队列进行等待。此时,如果不断有很多线程来执行读操作,允许插队的话,就会导致读锁长时间内不会被释放,线程3会长时间拿不到写锁,陷入"饥饿"状态。

以下是对读写锁的插队策略演示:

/**
 * 描述: 演示读锁不插队
 */
public class ReadLockJumpQueue {

    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
            .readLock();
    private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
            .writeLock();

    private static void read() {

        readLock.lock();
        try {

            System.out.println(Thread.currentThread().getName() + "得到读锁,正在读取");
            Thread.sleep(2000);
        } catch (InterruptedException e) {

            e.printStackTrace();
        } finally {

            System.out.println(Thread.currentThread().getName() + "释放读锁");
            readLock.unlock();
        }
    }

    private static void write() {

        writeLock.lock();
        try {

            System.out.println(Thread.currentThread().getName() + "得到写锁,正在写入");
            Thread.sleep(2000);
        } catch (InterruptedException e) {

            e.printStackTrace();
        } finally {

            System.out.println(Thread.currentThread().getName() + "释放写锁");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        new Thread(() -> read(),"Thread-2").start();
        new Thread(() -> read(),"Thread-4").start();
        new Thread(() -> write(),"Thread-3").start();
        new Thread(() -> read(),"Thread-5").start();
    }
}

运行结果:

Thread-2得到读锁,正在读取
Thread-4得到读锁,正在读取
Thread-2释放读锁
Thread-4释放读锁
Thread-3得到写锁,正在写入
Thread-3释放写锁
Thread-5得到读锁,正在读取
Thread-5释放读锁

4. 读写锁的升降级
1. 降级

所谓降级,是指当前线程在不释放写锁的情况下直接获取读锁。

以下是对锁的降级演示:

public class CachedData {

    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

    void processCachedData() {

        rwl.readLock().lock();
        if (!cacheValid) {

            //在获取写锁之前,必须首先释放读锁。
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {

                //这里需要再次判断数据的有效性,因为在我们释放读锁和获取写锁的空隙之内,可能有其他线程修改了数据。
                if (!cacheValid) {

                    data = new Object();
                    cacheValid = true;
                }
                //在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。
                rwl.readLock().lock();
            } finally {

                //释放了写锁,但是依然持有读锁
                rwl.writeLock().unlock();
            }
        }

        try {

            System.out.println(data);
        } finally {

            //释放读锁
            rwl.readLock().unlock();
        }
    }
}

processCachedData()方法中:

  • 先获取读锁,再判断缓存是否有效,有效则跳过整个if语句,不要更新缓存,在finally中释放读锁
  • 如果缓存失效,需要更新缓存,此时需要获取写锁。首先释放读锁,再获取读锁,finally中再释放写锁。
  • 在获取写锁后,判断缓存是否有效(可能有其他线程抢先更新了缓存),如果缓存无效,更新缓存。后续需要打印data,因此不能释放所有锁,此时选择降级,finally中在持有读锁的情况下释放读锁

那么为什么要用锁降级呢?可以一直使用写锁,最后再释放写锁呀?

答案是,可以一直使用写锁,但是没必要。因为使用写锁虽然可以保证线程安全,但代码中只有一处修改数据的地方:data = new Object(),后面对于data只是读取,一直采用写锁,就不能让多个线程同时来读取了,持有写锁是浪费资源的,降低了整体的效率。

2. 升级

所谓升级,是指当前在不释放读锁的情况下直接尝试获取写锁。ReentrantReadWriteLock 不支持升级,这会让程序直接阻塞,程序无法运行。

final static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();

public static void main(String[] args) {

    upgrade();
}

public static void upgrade() {

    rwl.readLock().lock();
    System.out.println("获取到了读锁");
    rwl.writeLock().lock();
    // 不会打印出该结果,升级失败
    System.out.println("成功升级");
}

那为什么不支持升级呢?

假如有A、B、C三个线程都已经持有读写锁,A想升级到写锁,根据读写锁的获取规则,就需要等待B和C都释放到所获取到的读锁。当B和C先后释放掉读锁后,A可以是哪里升级到写锁。

看起来是没什么问题。但是如果A和B都想升级到写锁呢?此时A和B都会需要等待对方释放读锁,导致死锁。

实际上,读写锁的升级是可行的,只需要保证每次只有一个线程可以升级,那么就可以保证线程安全。只是,ReentrantReadWriteLock 并不支持而已。

六、自旋锁

1. 自旋锁 vs非自旋锁

对于自旋锁,当获取锁失败时,线程并不会放弃CPU时间片,而是不断地再次尝试,直到成功为止。

对于非自旋锁,当获取锁失败时,会切换线程状态使线程休眠,释放时间片,CPU可以去做其他时间。而当锁释放后,CPU会将之前的线程恢复,线程再去尝试获取锁。如果失败,重复前面的步骤。

file

2. 自旋锁的优缺点

优点:
阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,执行时间也很短,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。此时采用自旋锁,就可以避免上下文切换等开销,提高了效率

缺点:
虽然避免了线程切换,但是自旋锁自身不断获取锁的操作也是一种开销,虽然这种开销低于线程切换,但是获取的次数越多,等待的时间越久,累积的开销就会超过线程切换的开销。当锁一直不能被释放时,不断尝试的操作只会白白浪费处理器资源。

3. AtomicLong 内部的自旋锁实现

在Java 1.5 版本及以上的并发包中,也就是 java.util.concurrent 的包中,里面的原子类基本都是自旋锁的实现。

以AtomicLong 的getAndIncrement 为例:

public final long getAndIncrement() {

    return unsafe.getAndAddLong(this, valueOffset, 1L);
}

其内部调用了调用了一个 unsafe.getAndAddLong方法

public final long getAndAddLong (Object var1,long var2, long var4){

    long var6;
    do {

        var6 = this.getLongVolatile(var1, var2);
    } while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
    return var6;
}

内部逻辑中,就是一个do-while的自旋操作,如果在修改过程中遇到了其他线程竞争导致没修改成功的情况,就会 while 循环里进行死循环,直到修改成功为止。

4. 自定义实现可重入的自旋锁
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.Lock;

/**
 * 描述: 实现一个可重入的自旋锁
 */
public class ReentrantSpinLock  {

    private AtomicReference<Thread> owner = new AtomicReference<>();

    //重入次数
    private int count = 0;

    public void lock() {

        Thread t = Thread.currentThread();
        if (t == owner.get()) {

            ++count;
            return;
        }
        //自旋获取锁
        while (!owner.compareAndSet(null, t)) {

            System.out.println("自旋了");
        }
    }

    public void unlock() {

        Thread t = Thread.currentThread();
        //只有持有锁的线程才能解锁
        if (t == owner.get()) {

            if (count > 0) {

                --count;
            } else {

                //此处无需CAS操作,因为没有竞争,因为只有线程持有者才能解锁
                owner.set(null);
            }
        }
    }

    public static void main(String[] args) {

        ReentrantSpinLock spinLock = new ReentrantSpinLock();
        Runnable runnable = new Runnable() {

            @Override
            public void run() {

                System.out.println(Thread.currentThread().getName() + "开始尝试获取自旋锁");
                spinLock.lock();
                try {

                    System.out.println(Thread.currentThread().getName() + "获取到了自旋锁");
                    Thread.sleep(4000);
                } catch (InterruptedException e) {

                    e.printStackTrace();
                } finally {

                    spinLock.unlock();
                    System.out.println(Thread.currentThread().getName() + "释放了了自旋锁");
                }
            }
        };
        Thread thread1 = new Thread(runnable);
        Thread thread2 = new Thread(runnable);
        thread1.start();
        thread2.start();
    }
}

七、JVM对锁的优化

1. 自适应自旋锁

在上面提到AtomicLong 内部实现自旋锁的方式,是do-while循环,如果自旋时间过长,那么性能开销是很大的,浪费了 CPU 资源。

在JDK 1.6 中引入了自适应的自旋锁来解决长时间自旋的问题。自适应意味着根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定自旋时间。如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。

2. 锁消除

经过逃逸分析之后,如果发现某些对象不可能被其他线程访问到,那么就可以把它们当成栈上数据,栈上数据由于只有本线程可以访问,自然是线程安全的,也就无需加锁,所以会把这样的锁给自动去除掉。

典型的案例是StringBuffer 的 append 方法:

@Override
public synchronized StringBuffer append(Object obj) {

    toStringCache = null;
    super.append(String.valueOf(obj));
    return this;
}

该方法被 synchronized 修饰的同步方法,因为它可能会被多个线程同时使用。但是,在大多数情况下,它只会在一个线程内被使用,如果编译器能确定这个 StringBuffer 对象只会在一个线程内被使用,就代表肯定是线程安全的,那么编译器便会做出优化,把对应的 synchronized 给消除,省去加锁和解锁的操作,以便增加整体的效率。

3. 锁粗化
public void lockCoarsening() {

    synchronized (this) {

        //do something
    }
    synchronized (this) {

        //do something
    }
    synchronized (this) {

        //do something
    }
}

在代码中,存在多个代码块加锁再解锁,这样是没有必要的。可以把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个 synchronized 块合并为一个较大的同步块。这样的话,在线程执行这些代码时,就无须频繁申请与释放锁了,这样就减少了性能开销。

但是,锁粗化不适用于循环的场景,仅适用于非循环的场景。

for (int i = 0; i< 1000; i++) {

    synchronized (this) {

        //do something
    }
}

在第一次循环的开始,就开始扩大同步区域并持有锁,直到最后一次循环结束,才结束同步代码块释放锁的话,这就会导致其他线程长时间无法获得锁。

3. 锁升级

这是JDK对synchronized 锁的优化策略,synchronized 锁会经历:无锁 --> 偏向锁 --> 轻量级锁 --> 重量级锁的升级过程。具体各个锁的含义可以参考第一部分锁的分类。采用锁的升级,可以大幅提高锁的性能。