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

简介: 本文介绍了 Java 中的锁机制,包括悲观锁与乐观锁的并发策略。悲观锁假设多线程环境下数据冲突频繁,访问前先加锁,如 `synchronized` 和 `ReentrantLock`。乐观锁则在访问资源前不加锁,通过版本号或 CAS 机制保证数据一致性,适用于冲突少的场景。锁的获取失败时,线程可以选择阻塞(如自旋锁、适应性自旋锁)或不阻塞(如无锁、偏向锁、轻量级锁、重量级锁)。此外,还讨论了公平锁与非公平锁,以及可重入锁与非可重入锁的特性。最后,提到了共享锁(读锁)和排他锁(写锁)的概念,适用于不同类型的并发访问需求。

最近偷闲对 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)


  • 排他锁也称为独占锁,它不允许多个线程同时获取和持有同一把锁,一旦某个线程获取了排他锁,其他试图获取该锁的线程将会被阻塞,直到持有锁的线程释放锁。
  • 排他锁主要用于保护临界区的代码,确保在某一时刻只有一个线程能够修改共享资源,从而保证数据的一致性和完整性。
  • 在排他锁机制下,当一个线程获取锁进行写操作时,其他所有线程(包括读操作线程)都无法获取该锁,必须等待锁被释放。
  • 优点:排他锁能够简单有效地防止数据冲突和不一致,适用于对数据完整性和一致性要求较高的场景。
  • 缺点:排他锁可能导致线程阻塞和上下文切换的开销,降低了系统的并发性能。


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


相关文章
|
12天前
|
安全 Java 测试技术
Java并行流陷阱:为什么指定线程池可能是个坏主意
本文探讨了Java并行流的使用陷阱,尤其是指定线程池的问题。文章分析了并行流的设计思想,指出了指定线程池的弊端,并提供了使用CompletableFuture等替代方案。同时,介绍了Parallel Collector库在处理阻塞任务时的优势和特点。
|
14天前
|
缓存 Java
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
本文介绍了几种常见的锁机制,包括公平锁与非公平锁、可重入锁与不可重入锁、自旋锁以及读写锁和互斥锁。公平锁按申请顺序分配锁,而非公平锁允许插队。可重入锁允许线程多次获取同一锁,避免死锁。自旋锁通过循环尝试获取锁,减少上下文切换开销。读写锁区分读锁和写锁,提高并发性能。文章还提供了相关代码示例,帮助理解这些锁的实现和使用场景。
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
|
21天前
|
安全 Java
java 中 i++ 到底是否线程安全?
本文通过实例探讨了 `i++` 在多线程环境下的线程安全性问题。首先,使用 100 个线程分别执行 10000 次 `i++` 操作,发现最终结果小于预期的 1000000,证明 `i++` 是线程不安全的。接着,介绍了两种解决方法:使用 `synchronized` 关键字加锁和使用 `AtomicInteger` 类。其中,`AtomicInteger` 通过 `CAS` 操作实现了高效的线程安全。最后,通过分析字节码和源码,解释了 `i++` 为何线程不安全以及 `AtomicInteger` 如何保证线程安全。
java 中 i++ 到底是否线程安全?
|
8天前
|
安全 Java 开发者
深入解读JAVA多线程:wait()、notify()、notifyAll()的奥秘
在Java多线程编程中,`wait()`、`notify()`和`notifyAll()`方法是实现线程间通信和同步的关键机制。这些方法定义在`java.lang.Object`类中,每个Java对象都可以作为线程间通信的媒介。本文将详细解析这三个方法的使用方法和最佳实践,帮助开发者更高效地进行多线程编程。 示例代码展示了如何在同步方法中使用这些方法,确保线程安全和高效的通信。
29 9
|
5天前
|
安全 Java 开发者
Java多线程编程中的常见问题与解决方案
本文深入探讨了Java多线程编程中常见的问题,包括线程安全问题、死锁、竞态条件等,并提供了相应的解决策略。文章首先介绍了多线程的基础知识,随后详细分析了每个问题的产生原因和典型场景,最后提出了实用的解决方案,旨在帮助开发者提高多线程程序的稳定性和性能。
|
11天前
|
存储 安全 Java
Java多线程编程的艺术:从基础到实践####
本文深入探讨了Java多线程编程的核心概念、应用场景及其实现方式,旨在帮助开发者理解并掌握多线程编程的基本技能。文章首先概述了多线程的重要性和常见挑战,随后详细介绍了Java中创建和管理线程的两种主要方式:继承Thread类与实现Runnable接口。通过实例代码,本文展示了如何正确启动、运行及同步线程,以及如何处理线程间的通信与协作问题。最后,文章总结了多线程编程的最佳实践,为读者在实际项目中应用多线程技术提供了宝贵的参考。 ####
|
8天前
|
监控 安全 Java
Java中的多线程编程:从入门到实践####
本文将深入浅出地探讨Java多线程编程的核心概念、应用场景及实践技巧。不同于传统的摘要形式,本文将以一个简短的代码示例作为开篇,直接展示多线程的魅力,随后再详细解析其背后的原理与实现方式,旨在帮助读者快速理解并掌握Java多线程编程的基本技能。 ```java // 简单的多线程示例:创建两个线程,分别打印不同的消息 public class SimpleMultithreading { public static void main(String[] args) { Thread thread1 = new Thread(() -> System.out.prin
|
11天前
|
Java
JAVA多线程通信:为何wait()与notify()如此重要?
在Java多线程编程中,`wait()` 和 `notify()/notifyAll()` 方法是实现线程间通信的核心机制。它们通过基于锁的方式,使线程在条件不满足时进入休眠状态,并在条件满足时被唤醒,从而确保数据一致性和同步。相比其他通信方式,如忙等待,这些方法更高效灵活。 示例代码展示了如何在生产者-消费者模型中使用这些方法实现线程间的协调和同步。
26 3
|
10天前
|
安全 Java
Java多线程集合类
本文介绍了Java中线程安全的问题及解决方案。通过示例代码展示了使用`CopyOnWriteArrayList`、`CopyOnWriteArraySet`和`ConcurrentHashMap`来解决多线程环境下集合操作的线程安全问题。这些类通过不同的机制确保了线程安全,提高了并发性能。
|
11天前
|
Java
java小知识—进程和线程
进程 进程是程序的一次执行过程,是系统运行的基本单位,因此进程是动态的。系统运行一个程序即是一个进程从创建,运行到消亡的过程。简单来说,一个进程就是一个执行中的程序,它在计算机中一个指令接着一个指令地执行着,同时,每个进程还占有某些系统资源如CPU时间,内存空间,文件,文件,输入输出设备的使用权等等。换句话说,当程序在执行时,将会被操作系统载入内存中。 线程 线程,与进程相似,但线程是一个比进程更小的执行单位。一个进程在其执行的过程中产生多个线程。与进程不同的是同类的多个线程共享同一块内存空间和一组系统资源,所以系统在产生一个线程,或是在各个线程之间做切换工作时,负担要比
22 1
下一篇
无影云桌面