线程同步的艺术:探索JAVA主流锁的奥秘

708 阅读14分钟

最近偷闲对 JAVA 主流锁进行了一个整体的整理,也有人对主流锁称呼为内置锁。有需要的同学可以回复“主流锁”关键字,获取我整理的思维导图。

接下来,我会从不同的维度下不同的锁类型做一个简单的介绍,详细深入的欢迎留言交流。

一、线程要不要锁住同步资源

线程要不要锁住同步资源就是我们平时讲的并发策略,他们主要是在处理线程同步资源时的假设和操作方式上的不同。

1、线程需要锁住同步资源:悲观锁

  • 假设:悲观锁采取保守或者说是悲观的态度来处理并发控制。它假设在多线程环境下,每次对共享资源的访问都可能导致数据冲突或者数据不一致,因此在访问资源之前就需要先获取锁。
  • 操作方式:当一个线程想要访问共享资源时,它会首先尝试获取锁,如果锁已经被其他线程持有,那么这个线程就会被阻塞,直到锁被释放,一旦线程获取到锁,它就可以独占性地访问资源,期间其他试图获取锁的线程会被阻塞,在 JAVA 中,synchronized 关键字和 ReentrantLock 等都是悲观锁的实现方式。
  • 优点:悲观锁直接在访问资源前进行加锁,可以确保数据的一致性和完整性,避免了并发修改导致的数据错误。
  • 缺点:由于每次访问都需要获取锁,可能会导致大量的上下文切换和线程阻塞,尤其是在高并发场景下,这可能会严重影响系统的性能和吞吐量。

2、线程不需要锁住同步资源:乐观锁

  • 假设:乐观锁则持有一种乐观的态度,它假设在大部分场景下,多个线程同时访问共享资源并不会导致数据冲突,因此,乐观锁并不会在访问资源前进行加锁。
  • 操作方式:乐观锁通过通过版本号或者 CAS(Compare-and-Swap)等机制来实现,当一个线程想要更新共享资源时,它会首先检查资源的版本号或者当前值是否与它之前读取时的一致,如果一致,那么就进行更新并增加版本号,如果不一致,说明在此期间有其他线程已经修改了资源,那么这个线程的操作就会失败,通常会选择重试或者回滚。
  • 优点:乐观锁减少了锁的使用和上下文切换,使得在大多数没有冲突的情况下,系统的并发性能得到提高。
  • 缺点:乐观锁在高并发且冲突频繁的场景下可能会导致大量的重试和回滚操作,反而影响性能,此外,乐观锁的视线相比悲观锁更为复杂,需要额外的版本号或者 CAS 等支持。

总之,选择使用悲观锁还是乐观锁取决于具体的业务场景和性能需求,如果数据冲突概率较高,且对数据一致性要求严格,可以选择悲观锁,而在数据冲突较少,追求高性能并发的场景下,乐观锁是一个更好的选择。

二、锁住同步资源失败时线程要不要阻塞

线程在尝试获取同步资源失败时的两种不同处理策略:

1、阻塞(Blocking)

当线程尝试获取同步资源失败时,如果选择阻塞策略,线程会被操作系统挂起,并放入一个等待队列中。线程会停止执行,释放 CPU 资源,直到同步资源被释放并重新获得锁。在这种情况下,线程不会持续消耗 CPU 资源,但需要经历线程上下文切换的开销,这在高并发场景下可能会对系统性能产生影响。

2、不阻塞(Non-Blocking)

在不阻塞的场景下,线程在尝试获取同步资源失败时不进入等待状态,而是继续执行一段特定的代码,通常是一个循环,这个过程称为自旋(Spinning),自旋锁就是这种策略的具体实现。

自旋锁(Spin Lock)

自旋锁的基本思想是线程在获取锁失败时,不是立即进入阻塞状态,而是不断循环检查锁是否已经被释放,这种方式避免了线程上下文切换的开销,因为在许多情况下,锁的持有时间可能非常短,自旋等待可能比线程阻塞和唤醒更快。

适应性自旋锁(Adaptive Spin Lock)

适应性自旋锁是对基本自旋锁的一种优化,它根据系统的运行状况和历史数据动态调整自旋的次数,如果系统检测到锁的持有时间通过很短,或者当前 CPU 负载较低,那么线程可能会进行更多的自旋尝试,相反,如果锁的持有时间较长,或者系统处于高负载状态,那么线程可能在较少的自旋尝试后就选择进入阻塞状态,以减少无谓的 CPU 消耗和提高系统的整体效率。

这两种策略各有优缺点,阻塞策略能够节省 CPU 资源,但在高并发和锁竞争激烈的场景下,频繁的线程上下文切换可能会成为性能瓶颈,而不阻塞的自旋锁和适应性自旋锁可以在锁持有时间短的情况下提高效率,但过度的自旋可能会导致 CPU 空转和资源浪费,因此,实际使用中需要根据具体的系统特性和应用场景来选择合适的锁策略。

三、多个线程竞争同步资源的流程细节区别

1、无锁(Lock-Free)

  • 在无锁的情况下,系统不适用任何显式的锁来保护共享资源,线程在修改资源时,采用原子操作或者 CAS 等技术来保证数据的一致性。
  • 当多个线程同时尝试修改资源时,只有一个线程的修改操作能成功,其他线程的修改操作会失败并需要重新尝试,这种重试机制通常通过循环和 CAS 操作实现,直到某个线程的修改操作成功为止。
  • 优点:无锁编程可以避免锁带来的上下文切换和阻塞等待,提高系统的并发性能。
  • 缺点:无锁编程的实现复杂度较高,且在高度竞争的场景下可能会导致大量的重试操作和 CPU 缓存失效,反而影响性能。

2、偏向锁(Biased Locking)

  • 偏向锁是 Java 虚拟机(JVM)的一种优化策略,用于处理只有一个线程频繁访问同步资源的情况。
  • 当一个线程首次获取到锁后,锁就会偏向于这个线程。后续该线程再次访问同一同步资源时,无需再进行任何同步操作,可以直接访问资源。
  • 如果有其他线程尝试获取已经被偏向的锁,那么偏向锁会升级为轻量级锁或重量级锁,以处理多线程竞争的情况。
  • 优点:偏向锁减少了线程获取和释放锁的开销,提高了单线程环境下程序的执行效率。
  • 缺点:偏向锁在多线程竞争环境下可能会增加额外的锁撤销和升级的开销。

3、轻量级锁(Lightweight Locking)

  • 轻量级锁用于处理多线程竞争同步资源但锁的持有时间较短的情况。
  • 当一个线程尝试获取轻量级锁时,它会在对象头中存储自己的线程 ID,并尝试将对象头的状态从无锁状态改为轻量级锁状态。
  • 如果此时有其他线程也尝试获取同一锁,但发现锁已被持有,那么这个线程会进入自旋状态,即不断循环检查锁是否已经被释放,而不会立即阻塞。
  • 自旋等待一段时间后(由 JVM 决定),如果锁仍未被释放,那么轻量级锁会升级为重量级锁,以防止过多的线程陷入自旋状态浪费 CPU 资源。
  • 优点:轻量级锁避免了线程的阻塞和唤醒开销,在锁竞争不激烈的情况下能够提高系统的并发性能。
  • 缺点:长时间的自旋等待可能会导致 CPU 空转和能源浪费,尤其是在锁持有时间较长或者多线程竞争激烈的情况下。

4、重量级锁(Heavyweight Locking)

  • 重量级锁是传统的互斥锁实现,用于处理多线程竞争同步资源且锁的持有时间可能较长的情况。
  • 当一个线程获取到重量级锁后,其他尝试获取锁的线程会被立即阻塞,并放入操作系统的等待队列中。
  • 当锁被释放时,操作系统会选择一个等待的线程唤醒并授予锁,然后该线程可以继续执行。
  • 优点:重量级锁确保了数据的一致性和完整性,适用于高并发和锁竞争激烈的场景。
  • 缺点:重量级锁的获取和释放涉及到线程的阻塞和唤醒,这会带来较大的上下文切换开销,降低系统的并发性能。

四、多个线程竞争锁时要不要排队

1、公平锁(Fair Lock)

  • 在公平锁的机制下,当多个线程竞争同一锁资源时,线程需要按照申请锁的顺序进行排队。
  • 当一个线程请求锁时,如果锁已经被其他线程持有,那么这个线程会被放入一个等待队列中,按照先来后到的顺序排列。
  • 当锁被释放时,锁的管理器会从等待队列中选择等待时间最长的线程授予锁,确保每个等待的线程都有公平的机会获得锁。
  • 优点:公平锁能够避免“饥饿”问题,即等待时间长的线程最终也能获得锁,保证了所有线程的公平性。
  • 缺点:由于每次锁的获取和释放都需要维护等待队列和检查等待线程,公平锁的性能相比非公平锁可能会略低一些。

2、非公平锁(Non-Fair Lock)

  • 非公平锁在多个线程竞争锁资源时,允许新到达的线程尝试“插队”,即在不考虑等待队列的情况下直接尝试获取锁。
  • 如果新到达的线程成功获取了锁,那么它就可以立即执行临界区的代码,而无需等待已经在等待队列中的线程。
  • 如果新到达的线程尝试获取锁失败(因为锁仍被其他线程持有),那么它才会被放入等待队列中,等待锁的释放。
  • 优点:非公平锁在某些场景下可能提供更高的并发性能,因为它允许线程在无须等待的情况下立即获取锁,减少了线程上下文切换的开销。
  • 缺点:非公平锁可能导致“饥饿”问题,即某些等待时间较长的线程可能长时间无法获得锁。此外,由于插队行为的存在,非公平锁的线程调度不确定性较大,可能影响系统的整体稳定性。

总之,公平锁和非公平锁各有优缺点,适用于不同的应用场景,公平锁更注重线程间的公平性和避免饥饿问题,适合于对系统稳定性要求较高的场景,而非公平锁则更关注并发性能,适合于锁竞争不激烈或者对响应时间要求较高的场景。在实际使用中,需要根据具体的业务需求和性能指标来选择合适的锁类型。

五、一个线程中的多个流程能不能获取同一把锁

1、可重入锁(Reentrant Lock)

  • 可重入锁允许同一个线程在持有锁的情况下再次请求并获取该锁。
  • 当一个线程已经获得了锁,并在执行过程中需要进入另一个也需要该锁的代码块时,由于使用的是可重入锁,所以这个线程可以再次成功获取锁,而不会被阻塞。
  • 在可重入锁中,每个锁都关联着一个计数器,每当线程获取锁时,计数器加一;当线程释放锁时,计数器减一。只有当计数器为零时,锁才会真正被释放给其他等待的线程。
  • 优点:可重入锁能够支持递归调用和复杂的多层同步结构,避免了死锁的情况,并且使得代码更加简洁和易于理解。
  • 缺点:相较于非可重入锁,可重入锁的实现可能会稍微复杂一些。

2、非可重入锁(Non-Reentrant Lock)

  • 非可重入锁不允许同一个线程在持有锁的情况下再次请求并获取该锁。
  • 当一个线程已经获得了锁,并在执行过程中需要进入另一个也需要该锁的代码块时,由于使用的是非可重入锁,所以这个线程会因为无法获取锁而被阻塞,导致死锁或者线程无法继续执行。
  • 在非可重入锁中,一旦锁被某个线程获取,那么在该线程释放锁之前,其他所有请求该锁的线程(包括获取锁的原始线程)都会被阻塞。
  • 优点:非可重入锁的实现相对简单。
  • 缺点:非可重入锁不支持递归调用和复杂的多层同步结构,容易引发死锁问题,而且在某些情况下可能导致线程无法继续执行,降低了系统的并发性和稳定性。

在实际编程中,大多数编程语言和框架模式使用可重入锁,因为能更好地处理复杂的同步场景并避免死锁问题,非可重入锁通常用于特定的低级同步操作或者对性能有极致要求的场景。

六、多个线程能不能共享一个锁

1、共享锁(Shared Lock)

  • 共享锁允许多个线程同时获取和持有同一把锁,主要用于读取共享数据的场景。
  • 当一个线程获取到共享锁后,其他线程也可以获取该锁进行读操作,但不能进行写操作,因为写操作需要独占锁。
  • 在共享锁机制下,多个线程可以同时读取共享资源,而不会相互阻塞,提高了系统的并发性能和资源利用率。
  • 优点:共享锁适用于读多写少的场景,能够提高系统的并行性和效率。
  • 缺点:由于共享锁允许并发读取,因此在存在写操作的情况下需要额外的机制来保证数据的一致性。

2、排他锁(Exclusive Lock)

  • 排他锁也称为独占锁,它不允许多个线程同时获取和持有同一把锁,一旦某个线程获取了排他锁,其他试图获取该锁的线程将会被阻塞,直到持有锁的线程释放锁。

  • 排他锁主要用于保护临界区的代码,确保在某一时刻只有一个线程能够修改共享资源,从而保证数据的一致性和完整性。

  • 在排他锁机制下,当一个线程获取锁进行写操作时,其他所有线程(包括读操作线程)都无法获取该锁,必须等待锁被释放。

  • 优点:排他锁能够简单有效地防止数据冲突和不一致,适用于对数据完整性和一致性要求较高的场景。

  • 缺点:排他锁可能导致线程阻塞和上下文切换的开销,降低了系统的并发性能。

在实际应用中,根据具体的业务需求和场景,可以选择使用共享锁、排他锁或者两者的组合(如读写锁)来实现线程间的同步和数据保护。共享锁常用于读取密集型的场景,而排他锁则更适用于写入或修改数据的场景,通过合理地选择和使用锁机制,可以平衡系统的并发性能和数据安全性。