JUC 锁

243 阅读20分钟

锁是一种常用的并发控制机制,用于保证一项资源在任何时候只能被一个线程使用,如果其他线程也要使用同样的资源,必须排队等待上一个线程使用完。

就相当于给资源加了一扇门,获取到锁的线程可以进入门里面使用资源,获取不到锁的线程在门外排队等候。

1、锁的 7 大分类

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

这三种锁特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态。

  • 偏向锁

如果这把锁一直都没有竞争,就没必要上锁,打个标记就行了

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

  • 轻量级锁

如果只有短时间的锁竞争,用 CAS 的方式就可以解决,这种情况下用完全互斥的重量级锁是没必要的。

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

  • 重量级锁

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

锁升级过程:无锁→偏向锁→轻量级锁→重量级锁。锁的升级过程是不可逆的,也就是重量级锁不能退化成轻量级锁,其他同理。

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

可重入锁/非可重入锁

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

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

ReentrantLocksynchronized 都是可重入锁。

共享锁/独占锁

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

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

公平锁/非公平锁

公平锁指的是排队获取锁,先来先得。

非公平锁指的是不排队,获取锁没有顺序。

悲观锁/乐观锁

悲观锁:在获取资源之前,必须先拿到锁,就是很悲观,意思就是如果我不拿到这个锁,那么我的操作就会有问题。

乐观锁:并不要求在获取资源前拿到锁,也不会锁住资源,就是很乐观。意思就是我不加锁我的操作也没问题。乐观锁可以利用 CAS 达到在不独占资源的情况下,完成对资源的操作。比悲观锁性能好。

自旋锁/非自旋锁

自旋锁:如果线程现在拿不到锁,并不直接陷入阻塞或者释放 CPU 资源,而是开始利用循环,不停地尝试获取锁。

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

可中断锁/不可中断锁

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

ReentrantLock 是一种典型的可中断锁,例如使用 lockInterruptibly 方法在获取锁的过程中,突然不想获取了,那么也可以在中断之后去做其他的事情,不需要一直傻等到获取到锁才离开。

2、悲观锁和乐观锁

悲观锁

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

乐观锁

乐观锁比较乐观,认为自己在操作资源的时候不会有其他线程来干扰,所以并不会锁住被操作对象,不会不让别的线程来接触它。

为了确保数据正确性,在更新之前,会去对比在我修改数据期间,数据有没有被其他线程修改过:如果没被修改过,就说明真的只有我自己在操作,那我就可以正常的修改数据;如果发现数据和我一开始拿到的不一样了,说明其他线程在这段时间内修改过数据,那说明我迟了一步,所以我会放弃这次修改,并选择报错、重试等策略。

乐观锁的实现一般都是利用 CAS 算法实现的(可能会出现 ABA 问题)。

典型案例

  • 悲观锁:synchronized 关键字和 Lock 接口

处理资源之前必须要先加锁并拿到锁,等到处理完了之后再解开锁,这就是非常典型的悲观锁思想。

  • 乐观锁:原子类

使用了乐观锁的思想,多个线程可以同时操作同一个原子变量,使用 CAS 算法来修改变量。

  • 两者都有:数据库

数据库中同时拥有悲观锁和乐观锁的思想。

MySQL 中执行 select for update 语句,那就是悲观锁,在提交之前不允许第三方来修改该数据,这当然会造成一定的性能损耗,在高并发的情况下是不可取的。

乐观锁就是我们可以自己定义一个字段 version(版本号),每次更新的时候比对这个版本号,版本号正确就更新,否则就失败。

注意点

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

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

使用场景

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

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

3、synchronized 和 monitor 锁

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

可通过 javap 反编译使用了 synchronized 关键字的代码查看是如何加锁的。

(1)同步代码块

同步代码块是使用 monitorentermonitorexit 指令实现的。

monitorenter 理解为加锁,执行 monitorexit 理解为释放锁。

(2)同步方法

同步方法会有一个 ACC_SYNCHRONIZED 标志。

当某个线程要访问某个方法的时候,会首先检查方法是否有 ACC_SYNCHRONIZED 标志,如果有则需要先获得 monitor 锁,然后才能开始执行方法,方法执行之后再释放 monitor 锁。

4、synchronized 和 Lock

相同点

  • synchronizedLock` 都是用来保护资源线程安全的。
  • 都可以保证可见性。(Java 内存模型)
  • synchronizedReentrantLock 都拥有可重入的特点。(ReentrantLockLock 接口的主要实现类)

不同点

  • 用法区别

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

Lock 接口必须显式地加锁 lock() 和解锁 unlock(),并且一般会在 finally 块中确保用 unlock() 来解锁,以防发生死锁。

Lock 显式的加锁和解锁不同的是 synchronized 的加解锁是隐式的,尤其是抛异常的时候也能保证释放锁(可用 javap 反编译字节码查看,JVM 帮我们做了加锁和解锁的操作)

  • 加解锁顺序不同

如果有多把 Lock 锁,Lock 可以不完全按照加锁的反序解锁。比如我们可以先获取 Lock1 锁,再获取 Lock2 锁,解锁时则先解锁 Lock1,再解锁 Lock2。

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

  • Locksynchronized 更灵活

一旦 synchronized 锁已经被某个线程获得了,此时其他线程如果还想获得,那它只能被阻塞,直到持有锁的线程运行完毕或者发生异常从而释放这个锁。

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

  • synchronized 锁只能同时被一个线程拥有,但是 Lock 锁没有这个限制

例如在读写锁中的读锁,是可以同时被多个线程持有的,可是 synchronized 做不到。

  • 原理区别

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

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

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

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

  • 性能区别

在 Java 5 以及之前,synchronized 的性能比较低,到了 Java 6 以后,发生了变化,因为 JDKsynchronized 进行了很多优化,比如自适应自旋、锁消除、锁粗化、轻量级锁、偏向锁等,所以后期的 Java 版本里的 synchronized 的性能并不比 Lock 差。

如何选择

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

5、Lock 接口

Lock 接口源码:

public interface Lock {
    
    void lock();

    boolean tryLock();

    boolean tryLock(long time, TimeUnit unit) throws InterruptedException;

    void unlock();

    Condition newCondition();
}

lock() 方法

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

tryLock() 方法

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

tryLock(long time, TimeUnit unit) 方法

这个方法和 tryLock() 类似,区别在于会有一个超时时间,在拿不到锁时会等待一定的时间,如果在时间期限结束后,还获取不到锁,就会返回 false;如果一开始就获取锁或者等待期间内获取到锁,则返回 true

lockInterruptibly() 方法

除非当前线程在获取锁期间被中断,否则便会一直尝试获取直到获取到为止。

顾名思义,lockInterruptibly() 是可以响应中断的。

unlock() 方法

用于解锁。

6、公平锁和非公平锁

公平锁指的是按照线程请求的顺序,来分配锁;而非公平锁指的是不完全按照请求的顺序,在一定情况下,可以允许插队。

一定情况是指:

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

为什么要设置非公平策略

公平是一种很好的行为,而非公平是一种不好的行为。所有为什么会有非公平这个策略呢?

假设线程 A 持有一把锁,线程 B 请求这把锁,由于线程 A 已经持有这把锁了,所以线程 B 会陷入等待,在等待的时候线程 B 会被挂起,也就是进入阻塞状态,那么当线程 A 释放锁的时候,本该轮到线程 B 苏醒获取锁,但如果此时突然有一个线程 C 插队请求这把锁,那么根据非公平的策略,会把这把锁给线程 C,这是因为唤醒线程 B 是需要很大开销的,很有可能在唤醒之前,线程 C 已经拿到了这把锁并且执行完任务释放了这把锁。相比于等待唤醒线程 B 的漫长过程,插队的行为会让线程 C 本身跳过陷入阻塞的过程,

所以 Java 设计者设计非公平锁,是为了提高整体的运行效率

公平锁和非公平锁对比

公平锁

(1)优点:各个线程公平平等,每个线程等待一段时间后,都有执行的机会。

(2)缺点:整体执行速度更慢,吞吐量更小。

非公平锁

(1)优点:整体执行速度更快,吞吐量更大。

(2)缺点:可能产生线程饥饿问题,也就是说如果一直有线程插队,那么在等待队列中的线程可能长时间得不到运行。

源码分析

在 Java 中,ReentranLock 类的构造方法可以指定是否为公平锁。

// true 表示公平锁,false 表示为非公平锁,有一个无参构造方法默认为非公平锁。
public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

公平锁

通过一路追踪,发现公平锁加锁的源码为 tryAcquire()

protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 判断等待队列中是否有线程在排队,如果有就不再尝试获取锁
        if (!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;
}

非公平锁

通过一路追踪,发现公平锁加锁的源码为 trynonfairTryAcquireAcquire()

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        //这里和公平锁的源码不一样,不需要查看队列中是否有线程排队,直接尝试获取锁
        if (compareAndSetState(0, acquires)) {
            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;
}

注意 tryLock() 方法

先看源码:

public boolean tryLock() {
    return sync.nonfairTryAcquire(1);
}

这就意味着不管你指定的是否是非公平锁,tryLock 方法默认是按照不公平的规则执行。

总结

公平锁就是会按照多个线程申请锁的顺序来获取锁,从而实现公平的特性。非公平锁加锁时不考虑排队等待情况,直接尝试获取锁,所以存在后申请却先获得锁的情况,但由此也提高了整体的效率。

7、读写锁 ReadWriteLock

如果多个读操作同时进行,其实并没有线程安全问题,我们可以允许让多个读操作并行,以便提高程序效率。

读写锁获取规则

  1. 如果有一个线程已经占用了读锁,则此时其他线程如果要申请读锁,可以申请成功。
  2. 如果有一个线程已经占用了读锁,则此时其他线程如果要申请写锁,则申请写锁的线程会一直等待释放读锁,因为读写不能同时操作。
  3. 如果有一个线程已经占用了写锁,则此时其他线程如果申请写锁或者读锁,都必须等待之前的线程释放写锁,同样也因为读写不能同时,并且两个线程不应该同时写。

读读共享、其他都互斥(写写互斥、读写互斥、写读互斥)。

Java 中,ReadWriteLock 接口就是读写锁接口。常用的实现类为:ReentrantReadWriteLock

public interface ReadWriteLock {
    
    Lock readLock();

    Lock writeLock();
}

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

读锁可以插队吗

第一种策略:允许插队

这种策略看上去增加了效率,但是有一个严重的问题,如果想要读取的线程不停地增加,就会导致读锁长时间内不会被释放,需要拿到写锁的线程会陷入“饥饿”状态,它将在长时间内得不到执行。

第二种策略:不允许插队

这种策略就是先来先得,不允许插队,这对于程序的健壮性是很有好处的。

ReentrantReadWriteLock 的实现选择了第二种策略。

锁的升降级

在不释放写锁的情况下,直接获取读锁,这就是读写锁的降级。

只能降级,不能升级。

8、自旋锁

“自旋”就是不停地循环,直到获取锁。而不像普通的锁那样,如果获取不到锁就进入阻塞。

image-20220211131902945

自旋锁的好处

阻塞和唤醒线程都是需要高昂的开销的,如果同步代码块中的内容不复杂,那么可能转换线程带来的开销比实际业务代码执行的开销还要大。

自旋锁用循环去不停地尝试获取锁,让线程始终处于 Runnable 状态,节省了线程状态切换带来的开销。

AtomicLong 源码

getAndSet() 方法:

public final long getAndSet(long newValue) {
    return unsafe.getAndSetLong(this, valueOffset, newValue);
}

实际就是调用了 getAndSetLong() 方法:这是一个经典的自旋锁

public final long getAndSetLong(Object var1, long var2, long var4) {
    long var6;
    do {
        var6 = this.getLongVolatile(var1, var2);
    } while(!this.compareAndSwapLong(var1, var2, var6, var4)); // while 循环一直自旋

    return var6;
}

缺点和适用场景

缺点

虽然避免了线程切换的开销,但是它在避免线程切换开销的同时也带来了新的开销,因为它需要不停得去尝试获取锁。

如果这把锁一直不能被释放,那么这种尝试只是无用的尝试,会白白浪费处理器资源。

适用场景

自旋锁适用于并发度不是特别高的场景,以及临界区比较短小的情况,这样我们可以利用避免线程切换来提高效率。

临界区很多的话意味着持有锁的时间会更久。

9、JVM 对锁的优化

JDK 1.6HotSopt 虚拟机对 synchronized 内置锁的性能进行了很多优化,包括自适应的自旋、锁消除、锁粗化、偏向锁、轻量级锁等。

自适应的自旋锁

“自旋”就是不释放 CPU,一直循环尝试获取锁。

自适应意味着自旋的时间不再固定,而是会根据最近自旋尝试的成功率、失败率,以及当前锁的拥有者的状态等多种因素来共同决定。

比如,如果最近尝试自旋获取某一把锁成功了,那么下一次可能还会继续使用自旋,并且允许自旋更长的时间;但是如果最近自旋获取某一把锁失败了,那么可能会省略掉自旋的过程,以便减少无用的自旋,提高效率。

自旋的时间不能我们设置,是 JVM 自己帮我们调度的。

锁消除

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

Java 虚拟机栈是线程私有的,也就是每个线程都有自己的虚拟机栈,执行的方法就抽象成栈帧,每个栈帧中保存了局部变量表、操作数栈、动态链接、方法出口等信息。

锁粗化

举个例子:

public void lockCoarsening() {

    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something
    }
    synchronized (this) {
        //do something
    }
}

如果我们把同步区域扩大,也就是只在最开始加一次锁,并且在最后直接解锁,那么就可以把中间这些无意义的解锁和加锁的过程消除,相当于是把几个 synchronized 块合并为一个较大的同步块。

锁粗化:就是把锁的范围扩大,同步区域变大。

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

这三种锁特指 synchronized 锁的状态,通过在对象头中的 mark word 来表明锁的状态。

  • 偏向锁

如果这把锁一直都没有竞争,就没必要上锁,打个标记就行了

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

  • 轻量级锁

如果只有短时间的锁竞争,用 CAS 的方式就可以解决,这种情况下用完全互斥的重量级锁是没必要的。

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

  • 重量级锁

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

锁升级过程:无锁→偏向锁→轻量级锁→重量级锁。锁的升级过程是不可逆的,也就是重量级锁不能退化成轻量级锁,其他同理。

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

JVM 默认优先使用偏向锁

拓展阅读

Java锁与线程的那些事

不可不说的Java“锁”事