Java学习 - 锁

本文最后更新于 2024年12月5日 晚上

乐观锁与悲观锁

悲观锁

悲观锁,即以最差的情况假设每一次并发操作。简单来说,就是悲观地认为每一次并发操作都有可能受到其他线程的影响,导致数据不一致或错误。因此,悲观锁旨在一次只让一个线程进行操作,在操作完毕后再讲数据传递给下一个线程。

我们常见的synchronized关键字和ReentrantLock类就是悲观锁的一种表示形式,其能够保证代码块或者方法一次只会让一个线程执行,其余线程则需要等待锁释放。

优势

  • 资源的一致性高
  • 操作简单

劣势

  • 性能开销大
  • 死锁问题
  • 资源浪费问题

乐观锁

乐观锁,即认为每一次并发操作都不会出现冲突,或者概率很低。这种锁旨在让线程操作时不去上锁,而是在操作结束时对比预期值和真实值来决定操作是否成功。

我们常说的 CAS(Compare-And-Swap) 技术便是乐观锁的核心算法。Atomic类都是乐观锁的一种实现,其使用对数据的原子操作来确保共享资源的更新是原子的。

优势

  • 适合高并发高吞吐场景
  • 无锁
  • 没有死锁问题

劣势

  • 操作复杂
  • 无法保证资源一致性
  • 死循环风险: 乐观锁更新失败通常都会出现自旋,自旋操作也许会因为一直不正确或者错误操作导致永远无法结束

乐观锁的一个简单的实现方式

乐观锁的一个非常简单的实现方式是为数据添加一个版本号version

假如有两个线程A和B,且他们正尝试对一个共享资源进行读和写。

  • A读取资源值为1,获取预期expectVersionA = 1
  • B读取资源值为1,获取预期expectVersionB = 1
  • B尝试为资源值加1,对比currentVersion = 1 == expectVersionB
  • B成功操作,将version++
  • A尝试为资源值加1,对比curentVersion = 2 != expectVersionA
  • 由于当前version与预期version不相等,故A操作失败,执行自旋操作(重新再操作一遍)

可以看到,整个操作过程中没有用到锁(或者说用了自旋锁),期间也发生了冲突的问题,但是最终却依然能够保证共享资源的一致性。

但是现实场景中肯定不止这么简单,在有大量线程并发时,这种操作也可能会变得效率低下(大量自旋操作发生)。

synchronized(同步)

Java的synchronized关键字可以用来修饰:

  • 实例方法
  • 静态方法
  • 代码块

注意:synchronized无法用来修饰抽象方法。synchronized关键字是用于控制方法执行的同步,是在方法体内部实现线程同步的机制。抽象方法没有方法体,无法实现同步代码块和同步方法所需的逻辑。

早期的synchronized是一个重量级锁。在早期的 Java 版本中,synchronized关键字在底层实现上比较依赖操作系统提供的互斥锁(如通过monitorentermonitorexit指令与对象头中的锁标志位配合)。当一个线程获取锁时,它需要向操作系统请求互斥锁资源。这种方式涉及到用户态和内核态的切换,这是一个相对复杂且耗时的过程。

在Java 6之后对synchronized进行了大量优化:

  • 偏向锁(Biased Locking)
    • 原理:偏向锁的核心思想是如果一段同步代码一直被一个线程访问,那么这个线程会自动获取锁,并且在之后的执行过程中,这个锁会偏向这个线程,不需要每次都进行锁的获取和释放操作。例如,在一个单线程频繁访问同步代码块的场景下,偏向锁可以大大减少获取锁的开销。当一个线程第一次访问带有synchronized的代码块时,JVM 会在对象头中记录这个线程的 ID,表示这个锁偏向该线程。后续这个线程再次访问时,只要检查对象头中的线程 ID 与自己一致,就可以直接进入同步代码块。
    • 性能提升:通过减少不必要的锁获取和释放操作,偏向锁能够显著提高程序在单线程访问同步代码块场景下的性能。在很多实际应用中,有相当一部分同步代码块在一段时间内是被单线程访问的,偏向锁正好可以利用这一特性。
  • 轻量级锁(Lightweight Locking)
    • 原理:当出现多个线程竞争偏向锁时,偏向锁会升级为轻量级锁。轻量级锁是基于 CAS(Compare - And - Swap)操作实现的。CAS 操作是一种无锁的原子操作,它通过比较内存中的值和期望的值,如果相同则进行更新。在轻量级锁的场景下,线程会通过 CAS 操作尝试获取锁,如果获取成功,就可以进入同步代码块。如果 CAS 操作失败,说明有其他线程正在占用锁,此时线程不会立即阻塞,而是会自旋(不断尝试获取锁)一段时间。
    • 性能提升:相比于传统的重量级锁,轻量级锁避免了线程在竞争锁时立即进入阻塞状态。自旋操作在竞争不激烈的情况下,可以让线程在较短的时间内获取到锁,减少了线程上下文切换的次数,从而提高了性能。
  • 锁粗化(Lock Coarsening)
    • 原理:JVM 会自动检测到一连串连续的、对同一个对象加锁和解锁的操作,并将这些操作合并为一次范围更大的加锁和解锁操作。例如,在一个循环体内频繁地对同一个对象进行synchronized操作,JVM 可能会将这些多次的小范围锁操作合并为一个在整个循环体外的大范围锁操作。这样可以减少获取和释放锁的次数,降低开销。
  • 锁消除(Lock Elimination)
    • 原理:JVM 在编译阶段会通过逃逸分析来判断一个对象是否被多个线程访问。如果一个对象只在一个线程内被访问,即使代码中有synchronized操作,JVM 也会认为这个锁是不必要的,并将其消除。例如,在一个方法内部定义的局部对象,并且这个对象没有被方法返回或者传递给其他线程,那么对这个对象的synchronized操作就会被 JVM 消除。
    • 性能提升:通过避免不必要的锁操作,锁消除能够提高代码的性能,特别是在有大量局部对象使用synchronized操作的场景下。

Java Guide中看到了这段内容:关于偏向锁多补充一点:由于偏向锁增加了 JVM 的复杂性,同时也并没有为所有应用都带来性能提升。因此,在 JDK15 中,偏向锁被默认关闭(仍然可以使用 -XX:+UseBiasedLocking 启用偏向锁),在 JDK18 中,偏向锁已经被彻底废弃(无法通过命令行打开)。[1]

ReentrantLock

ReentrantLock,顾名思义,是一个可重入的互斥锁。所谓可重入,就是指同一个线程在获取了锁之后,可以再次获取该锁而不会被阻塞,这在递归调用或嵌套同步块的场景中非常有用。例如,一个线程在进入一个同步方法后,又在该方法内部调用了另一个同样被该锁保护的同步方法,如果是不可重入锁,此时线程将会被阻塞,导致死锁的发生;而ReentrantLock则允许这种情况,提高了代码的灵活性和安全性。

ReentrantLock实现了Lock接口,该接口定义了一系列用于获取锁、释放锁以及处理锁相关状态的方法,如lock()unlock()tryLock()等。通过这些方法,开发者可以更加精细地控制线程对共享资源的访问。

ReentrantLock特性

可重入性

如前所述,ReentrantLock的可重入性允许一个线程多次获取同一个锁。每次获取锁时,内部的计数器会递增,而每次释放锁时,计数器会递减,当计数器为 0 时,锁才真正被释放,其他线程才有机会获取该锁。

公平性与非公平性

ReentrantLock中有一个内部类,名为Sync,其有两个子类,FairSyncNonfairSync,分别意味着公平锁和非公平锁。

  • 公平模式:在公平模式下,线程获取锁的顺序遵循先来后到的原则。当锁被释放时,等待时间最长的线程将优先获取锁。这种模式可以避免线程饥饿现象,即某个线程长时间无法获取锁而一直处于等待状态。但是,公平模式的实现通常会带来一定的性能开销,因为需要维护一个等待线程的队列,并按照顺序唤醒线程。
  • 非公平模式:非公平模式下,线程获取锁时会先尝试直接获取,如果获取成功则直接进入临界区,而不会考虑是否有其他线程已经在等待。如果直接获取失败,才会像公平模式一样进入等待队列。这种模式的优点是减少了线程上下文切换和唤醒等待线程的开销,在高并发场景下可能会有更好的性能表现,但可能会导致某些线程长时间无法获取锁。

默认情况下ReentrantLock是非公平锁,但也可以在创建ReentrantLock实例时通过构造函数参数指定公平模式,例如:

1
ReentrantLock fairLock = new ReentrantLock(true); //创建一个公平锁。

可中断锁与不可中断锁

看名字也可以明显看出来,可中断锁就是在获取锁的过程中可以被中断,而不可中断所就是一旦申请了锁,便必须等到获取了锁之后才能做别的事情。

ReentrantLock支持锁中断操作。通过lockInterruptibly()方法获取锁时,如果线程在等待锁的过程中被中断,会抛出InterruptedException异常,从而允许线程响应中断信号并进行相应的处理。这在一些需要灵活控制线程等待时间或响应外部中断事件的场景中非常有用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
ReentrantLock lock = new ReentrantLock();
Thread thread = new Thread(() -> {
try {
// 尝试获取锁,如果被中断则抛出异常
lock.lockInterruptibly();
try {
// 临界区代码
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
// 处理中断异常
System.out.println("线程被中断");
}
});
thread.start();
// 中断线程
thread.interrupt();

ReentrantLock 与 synchronized 的对比

  • ReentrantLocksynchronized更加灵活,提供了诸如公平性选择、锁中断、条件变量等高级功能,而synchronized相对简单,仅提供基本的互斥和可重入功能。

  • ReentrantLock可以通过tryLock()方法尝试获取锁,立即返回获取锁是否成功的结果,而synchronized在获取锁失败时会一直阻塞线程。

  • 在低并发场景下,synchronized的性能与ReentrantLock(非公平模式)相近,因为ReentrantLock的非公平模式获取锁时也有一定的优化。

  • 在高并发场景下,如果对性能要求极高且能合理利用ReentrantLock的特性(如非公平模式减少线程上下文切换),ReentrantLock可能会有更好的性能表现;但如果使用公平模式且频繁地进行锁获取和释放操作,ReentrantLock可能会因为维护等待队列等操作而导致性能下降,而synchronized的性能相对稳定。

  • synchronized是 Java 语言的关键字,使用起来较为简洁,无需显式地创建锁对象和进行锁的释放操作(由虚拟机自动处理)。

  • ReentrantLock需要显式地创建锁对象,并且在finally块中手动释放锁,以确保锁的正确释放,否则可能会导致死锁或资源泄漏等问题,这增加了代码的编写和维护成本。

扩展一下思维

我们讨论了这么久锁机制,似乎目前每一个锁都只允许一个线程持有。当然,这也合理,毕竟目前我们讨论的情况都是对共享资源进行大量混合的并发操作的情况。

但是,实际上我们能够对共享资源能做的无非就是读和写两个行为,而在现实生活中,需要大规模读的情况往往远大于大规模写。而这显然是两种操作。

那么现在就可以这么想,为什么不为这两种行为分别分配一把锁呢?

这就是读写锁的由来。

读写锁

Java中实现了读写锁的有ReentrantReadWriteLockStampedLock。实际上就是为一个共享资源分配了两把锁,读锁和写锁。其中,读锁是共享锁,允许多个线程同时拥有。而写锁是独占锁,只能有一个线程拥有。

可以想象一下我们可以用这种锁来干什么:

  • 大量的读操作中有少许写操作:写操作的突然乱入也许会导致读的结果出现问题,但是我们可以将写锁与读锁互斥,并且让写锁的出现阻塞后续的读锁。也就是说,执行写操作的线程需要等到读操作的线程执行完毕,而同时也会阻塞后续所有线程。写锁被释放后,后续的读操作依然能够正常执行。
  • 大量的写操作:这时候的锁的综合表现会像是重量级锁。

想到这些,你也就差不多完成了对ReentrantReadWriteLock的理解。唯一需要加一句的是,已经获取了写锁的线程依然可以获取读锁,但是已经获取了读锁的线程却不允许获取写锁。想想为什么?

综合来说,读写锁机制会对性能带来一些提升,毕竟现在并不会限制读操作的并发访问。

然而,Java提出的StampedLock中引入了乐观读,旨在允许多个线程获取读锁的同时,也允许一个写线程获取写锁。这提高了并发性能,也是它比ReentrantReadWriteLock性能更好的原因。

引用


Java学习 - 锁
http://example.com/2024/12/04/Java学习/Java学习 - 锁/
作者
Clain Chen
发布于
2024年12月4日
许可协议