Java八股文——并发编程「并发安全篇」
JUC包下你常用的类?
面试官您好,java.util.concurrent
(JUC)包是我在日常开发中处理并发问题时用得最多的工具集,它极大地提升了开发效率和程序的健壮性。如果要说最常用的,我可以按照功能把它们分成几类来介绍:
1. 锁与同步器 (Locks & Synchronizers)
这是保证线程安全和协作的基础。
ReentrantLock
(可重入锁)- 为什么用它? 当
synchronized
的功能无法满足需求时,我就会用ReentrantLock
。它提供了更高级的功能,比如:- 可中断的等待:
lockInterruptibly()
允许线程在等待锁的过程中响应中断,避免死等。 - 公平锁/非公平锁:可以构造公平锁,保证等待时间最长的线程优先获取锁,避免线程饥饿。
- 尝试获取锁:
tryLock()
可以立即返回或在指定时间内尝试获取锁,避免线程阻塞,让程序有更多的灵活性。 - 绑定多个Condition:可以实现更精细的线程等待/唤醒控制,比如在生产者-消费者模型中,可以有“队列不满”和“队列不空”两个独立的条件。
- 可中断的等待:
- 一句话总结:它是功能更强大、更灵活的
synchronized
替代品。
- 为什么用它? 当
CountDownLatch
(倒计时门闩)- 为什么用它?当我需要让一个或多个线程等待其他一组线程全部执行完毕后再继续执行时,
CountDownLatch
就是最佳选择。 - 场景举例:比如一个主线程需要等待多个子任务(如并行加载多个配置文件、并行调用多个远程服务)全部完成后,再进行数据汇总。主线程调用
latch.await()
阻塞,每个子任务完成后调用latch.countDown()
,直到计数器归零,主线程被唤醒。
- 为什么用它?当我需要让一个或多个线程等待其他一组线程全部执行完毕后再继续执行时,
CyclicBarrier
(循环屏障)- 为什么用它?它和
CountDownLatch
有点像,但功能更强大。它能让一组线程互相等待,直到所有线程都到达一个公共的屏障点,然后再一起继续执行。 - 关键区别:
CyclicBarrier
的计数器可以被重置和复用(所以叫“循环”屏障),而CountDownLatch
是一次性的。 - 场景举例:在进行多线程并行计算时,可以设置一个屏障,让所有线程计算完第一阶段后在此集合,然后由一个线程执行阶段性汇总,之后所有线程再一起进入第二阶段的计算。
- 为什么用它?它和
Semaphore
(信号量)- 为什么用它?当我需要控制同时访问某个特定资源的线程数量时,
Semaphore
就派上用场了。它就像一个许可证管理器。 - 场景举例:比如有一个服务只支持10个并发连接,我们就可以创建一个初始值为10的
Semaphore
。每个线程在访问服务前必须调用semaphore.acquire()
获取一个许可证,访问结束后调用semaphore.release()
归还许可证。这样就轻松地实现了限流。
- 为什么用它?当我需要控制同时访问某个特定资源的线程数量时,
2. 并发容器 (Concurrent Collections)
这些线程安全的容器,让我们在多线程环境下可以放心地操作集合。
ConcurrentHashMap
- 为什么用它?这几乎是所有需要线程安全Map场景的首选。相比于用
Collections.synchronizedMap()
包装的HashMap
,它的并发性能要高得多。 - 关键技术:在JDK 1.7中它通过分段锁,在JDK 1.8及以后通过
CAS
+synchronized
节点锁的方式,极大地降低了锁的粒度,允许多个线程同时进行读写操作。
- 为什么用它?这几乎是所有需要线程安全Map场景的首选。相比于用
BlockingQueue
(阻塞队列)- 为什么用它?这是实现生产者-消费者模式的利器,也是线程间通信的最佳实践之一。它屏蔽了所有底层的同步细节。
- 常用实现:
ArrayBlockingQueue
(基于数组的有界队列)和LinkedBlockingQueue
(基于链表的、容量可选的队列)。 - 场景举我例:在项目中,我会用它来做任务的缓冲池。核心业务线程快速地将任务放入队列(生产),而后端的工作线程池则从队列中取出任务慢慢处理(消费),实现了业务的解耦和削峰填谷。
3. 线程池与Future
(Executors & Future)
这是现代Java并发编程中管理线程的标准方式。
ExecutorService
/ThreadPoolExecutor
- 为什么用它?手动
new Thread()
管理混乱且开销大。线程池能够复用线程、控制并发数、管理线程生命周期,是必须使用的工具。 - 实践中,我不会直接用
Executors
的工厂方法(如newFixedThreadPool
),因为它们的内部队列可能是无界的,有OOM风险。我会根据业务需求,直接使用ThreadPoolExecutor
的构造函数,显式地指定核心线程数、最大线程数、队列类型和拒绝策略,做到对线程池的完全掌控。
- 为什么用它?手动
Future
/CompletableFuture
- 为什么用它?当我需要获取异步任务的执行结果,或者处理任务的异常时,就需要
Future
。 CompletableFuture
更是我的最爱。它相比Future
功能强大太多,支持非阻塞的回调、任务的编排和组合(比如thenApply
,thenCompose
,allOf
等)。- 场景举例:比如一个请求需要并行调用服务A、B、C,然后将它们的结果合并处理。使用
CompletableFuture
可以非常优雅地实现这种复杂的异步工作流,代码清晰且性能高。
- 为什么用它?当我需要获取异步任务的执行结果,或者处理任务的异常时,就需要
总而言之,JUC包提供了一套完整、高效的并发编程工具箱。在我的工作中,线程池和**CompletableFuture
用于宏观的任务调度和流程编排,并发容器(特别是ConcurrentHashMap
和BlockingQueue
)用于线程间的数据共享和通信,而同步器**(如ReentrantLock
, CountDownLatch
)则用于处理更精细、更底层的同步控制需求。
怎么保证多线程安全?
面试官您好,保证多线程安全是一个系统性的工程,它的核心目标是在多线程并发访问共享数据时,保证程序的行为和结果始终是正确、可预期的。在我看来,实现线程安全可以从三个层次来考虑:问题的根源、解决问题的工具,以及避免问题的设计思想。
第一层:理解线程不安全的根源 —— 并发三要素
要解决问题,首先要理解问题出在哪里。并发编程中,线程不安全的根源主要来自三个方面:
- 原子性(Atomicity):一个或多个操作,要么全部执行成功,要么一个都不执行,中间不能被任何其他线程干扰。一个最经典的反例就是
count++
,它包含了“读-改-写”三步,不是原子操作,很容易在高并发下出错。 - 可见性(Visibility):当一个线程修改了共享变量的值,其他线程能够立即看到这个修改。由于CPU缓存的存在,一个线程的修改可能停留在自己的工作内存中,对其他线程不可见,导致数据不一致。
- 有序性(Ordering):程序执行的顺序按照代码的先后顺序执行。但为了性能优化,编译器和处理器可能会对指令进行重排序。在单线程下没问题,但在多线程下就可能导致意想不到的逻辑错误,比如著名的双重检查锁定(DCL)单例问题。
第二层:掌握保证安全的工具 —— Java提供的同步机制
针对以上三个问题,Java提供了丰富的工具来解决:
synchronized
关键字:- 这是Java中最经典的同步机制。它通过保证在同一时刻,只有一个线程能进入被它修饰的代码块(临界区),从而同时解决了原子性和可见性问题。JVM的内存模型保证了在退出同步块时,会将工作内存的修改刷新到主内存。
volatile
关键字:- 这是一个轻量级的同步机制。它主要解决可见性和一定程度的有序性(通过禁止指令重排序)。但它不能保证原子性,所以
volatile
不适用于像count++
这样的复合操作。它非常适合用作状态标记,比如volatile boolean running = true;
。
- 这是一个轻量级的同步机制。它主要解决可见性和一定程度的有序性(通过禁止指令重排序)。但它不能保证原子性,所以
Lock
接口及其实现(如ReentrantLock
):- 这是JUC包提供的更强大、更灵活的锁机制。它同样能保证原子性和可见性。相比
synchronized
,它提供了可中断获取锁、可超时获取锁、公平锁以及绑定多个Condition
实现精准唤醒等高级功能。
- 这是JUC包提供的更强大、更灵活的锁机制。它同样能保证原子性和可见性。相比
- 原子类(
java.util.concurrent.atomic.*
):- 比如
AtomicInteger
,AtomicLong
等。它们专门用于解决单个变量的原子操作问题。其底层大多基于CAS(Compare-And-Swap) 这种乐观锁思想的无锁算法,性能通常比重量级的synchronized
要好,是实现原子计数等场景的首选。
- 比如
第三层:采用更优的设计思想 —— 从根本上避免竞争
“最好的锁就是不加锁”。除了使用工具去“堵”并发问题,更高明的做法是通过良好的设计来“疏导”或“避免”它们。
- 不可变(Immutability):
- 这是保证线程安全的最简单、最有效的方式。如果一个对象在创建后其状态就不能被修改,那么它天生就是线程安全的,可以被任意多线程自由共享,无需任何同步措施。
String
和Integer
等包装类就是典型的不可变对象。在设计自己的类时,应尽量将其设计为不可变类(所有字段final
,不提供setter
等)。
- 这是保证线程安全的最简单、最有效的方式。如果一个对象在创建后其状态就不能被修改,那么它天生就是线程安全的,可以被任意多线程自由共享,无需任何同步措施。
- 线程封闭(Thread Confinement):
- 思路是“不共享,就不需要同步”。把数据完全限制在单个线程内部,不与其他线程共享。
- 栈封闭:最简单的就是方法内的局部变量,它们存储在线程各自的栈上,天然是线程安全的。
ThreadLocal
:这是一个非常强大的工具。它为每个线程都提供了一个变量的副本,从而实现了“空间换时间”的思路,每个线程操作的都是自己的副本,互不干扰。非常适合用来存储每个线程独有的上下文信息,比如用户身份信息、数据库连接等。
- 使用JUC提供的线程安全容器:
- 在需要共享数据时,应优先使用JUC包提供的并发容器,而不是自己去同步
HashMap
或ArrayList
。比如:- 用
ConcurrentHashMap
替代Hashtable
或Collections.synchronizedMap()
。 - 用
CopyOnWriteArrayList
应对“读多写少”的列表场景。 - 用
BlockingQueue
实现生产者-消费者模式,它内部封装了所有复杂的同步和通信逻辑。
- 用
- 在需要共享数据时,应优先使用JUC包提供的并发容器,而不是自己去同步
总结
所以,当被问到如何保证线程安全时,我的思路是:
- 首选设计规避:优先考虑不可变对象和线程封闭(ThreadLocal),从根源上消除共享和竞争。
- 次选JUC工具:如果必须共享,优先使用JUC包提供的并发容器和原子类,它们是专家封装好的、高效且安全的选择。
- 最后才用锁:在需要保护复杂的业务逻辑、实现更精细的控制时,才考虑使用
synchronized
或Lock
。并且我会倾向于使用功能更强大的ReentrantLock
。
Java中有哪些常用的锁,在什么场景下使用?
面试官您好,Java中的锁机制非常丰富,我会从锁的设计思想、具体的锁实现以及特殊的锁策略这三个维度来介绍我常用的锁和它们的使用场景。
第一维度:从锁的设计思想上划分
首先,所有的锁都可以从思想上划分为两大派:悲观锁和乐观锁。
- 1. 悲观锁 (Pessimistic Locking)
- 思想:它总是假设最坏的情况,认为数据在被访问时,一定会有其他线程来修改,所以它在操作数据之前就会先加锁,确保在此期间只有自己能操作。
- 典型实现:Java中的
synchronized
关键字和java.util.concurrent.locks.ReentrantLock
都是悲观锁的典型实现。 - 使用场景:非常适合写多读少、并发冲突激烈的场景。因为如果冲突频繁,每次都乐观地重试,成本反而会更高。直接加锁一次性搞定,效率更高。比如,在对一个热点账户进行扣款操作时,使用悲观锁可以保证数据绝对的正确性。
- 2. 乐观锁 (Optimistic Locking)
- 思想:它非常乐观,认为数据在被访问时,别人基本不会来修改。所以它操作数据时不加锁,而是在提交更新时去检查,看数据在此期间有没有被别人改过。
- 典型实现:通常通过版本号(version)机制或CAS(Compare-And-Swap)操作来实现。JUC包下的原子类,如
AtomicInteger
,其核心就是CAS。 - 使用场景:非常适合读多写少、并发冲突不那么激烈的场景。它避免了加锁和解锁的开销,在高并发读取时性能优势巨大。比如,在更新一个商品库存时,如果大部分操作是查询库存,偶尔才有下单扣减,那么用乐观锁就非常合适。
第二维度:从具体的锁实现上划分
接下来,是我们日常编码中最常接触到的具体锁实现。
- 1.
synchronized
(内置锁/监视器锁)- 特点:这是Java语言层面的关键字,使用最简单,由JVM自动管理锁的获取和释放,不易出错。它背后有一个锁升级的优化过程,从偏向锁 -> 轻量级锁 -> 重量级锁,来适应不同的竞争情况,尽可能减少性能损耗。
- 使用场景:在并发度不高、同步逻辑简单、或者对性能要求不是极致的情况下,
synchronized
因其简单易用而成为首选。
- 2.
ReentrantLock
(可重入锁)- 特点:这是JUC包提供的一个更强大、更灵活的锁。相比
synchronized
,它提供了:- 可中断的等待(
lockInterruptibly
) - 可超时的尝试(
tryLock
) - 公平/非公平的选择:默认非公平,性能更好;公平锁则能避免线程饥饿。
- 绑定多个Condition,实现精准的线程唤醒。
- 可中断的等待(
- 使用场景:当
synchronized
的功能无法满足复杂的业务需求时,比如需要中断等待、或实现复杂的线程通信模型时,ReentrantLock
就是不二之选。
- 特点:这是JUC包提供的一个更强大、更灵活的锁。相比
第三维度:从特殊的锁策略上划分
除了上述通用锁,Java还提供了一些针对特定场景优化的锁策略。
- 1.
ReadWriteLock
(读写锁)- 特点:它将锁的功能一分为二:一个读锁和一个写锁。允许多个线程同时持有读锁(读读不互斥),但写锁是独占的(写写互斥,读写互斥)。
- 使用场景:非常明确,就是 “读多写少” 的场景。比如,系统配置的缓存,绝大多数时间都是在读取配置,偶尔才有一次后台更新。使用读写锁可以极大地提升系统的并发读取能力。
- 2. 自旋锁 (Spin Lock)
- 特点:这是一种锁的等待策略,而不是一种具体的锁。当线程尝试获取锁失败时,它不会立即阻塞自己(放弃CPU),而是在一个循环里不断地尝试获取锁(“自旋”)。
- 实现:轻量级锁和很多JUC工具的底层都用到了自旋。通常可以用CAS操作来实现。
- 使用场景:它赌的是锁被占用的时间非常短。如果锁很快就会被释放,自旋几次就能拿到锁,这就避免了线程上下文切换(挂起和唤醒)的巨大开销。但如果锁被长时间占用,自旋就会空耗CPU资源。所以,它适用于锁粒度非常小、同步代码块执行极快的场景。
通过这三个维度的划分,我们可以根据业务需求,从宏观的思想到具体的实现,再到特殊的优化策略,来选择最适合的锁,以达到性能和安全性的最佳平衡。
怎么在实践中用锁的?
面试官您好,在实践中用锁,对我来说不仅仅是简单地加上synchronized
关键字。它是一个系统性的决策过程,我会遵循一个清晰的思路,并遵循一些最佳实践。
我的核心指导思想是:能不用锁就不用锁,如果必须用,就选择最合适的锁,并尽可能减小锁的范围。
第一步:我首先会思考,能不能完全避免用锁?
这是我的首选策略,因为无锁的并发才是最高效、最安全的。
- 1. 使用
ThreadLocal
进行线程封闭- 场景:当每个线程需要维护自己独立的上下文信息时,我就会用
ThreadLocal
。比如,在Web应用中保存当前用户的身份信息,或者管理每个线程独立的数据库连接。 - 实践:我会在一个
Filter
或拦截器中,将用户信息从请求中解析出来后set
到一个ThreadLocal
变量里。这样,在整个业务逻辑处理链中,任何地方都可以方便地get
到当前线程的用户信息,而完全不需要考虑线程安全问题,因为它根本不共享。用完后,在finally
块中调用remove()
防止内存泄漏。
- 场景:当每个线程需要维护自己独立的上下文信息时,我就会用
- 2. 设计不可变对象(Immutable Objects)
- 场景:对于那些作为配置或数据载体的对象(DTOs/VOs),如果它们在创建后就不再需要被修改,我一定会把它们设计成不可变对象。
- 实践:我会给所有字段加上
final
关键字,不提供任何setter
方法,并在构造函数中完成所有初始化。一个不可变的对象,比如String
,天生就是线程安全的,可以在多线程之间自由传递和共享,无需任何同步措施。
第二步:如果必须共享数据,我会选择最轻量级的同步方式
- 使用原子类 (
AtomicInteger
,AtomicLong
等)- 场景:当我需要实现一个全局的计数器、或者更新一个状态标记时,我首先想到的就是原子类。
- 实践:比如,我要统计一个接口的实时调用次数。我会定义一个
private static final AtomicLong counter = new AtomicLong(0);
,每次接口被调用时,只需要执行counter.incrementAndGet()
。这比用synchronized
或ReentrantLock
来保护一个普通的long
变量要高效得多,因为它底层基于CAS无锁操作,避免了线程挂起和唤醒的开销。
第三步:当需要保护复杂逻辑时,才选择重量级锁
如果业务逻辑比较复杂,不是一个简单的原子操作能解决的,我才会考虑使用锁。
- 1.
synchronized
:简单、不易出错的首选- 场景:对于一些逻辑简单、并发竞争不那么激烈的场景,或者在一些遗留代码的维护中,
synchronized
是我的首选。 - 实践:比如,我需要保护一个方法,确保它内部对几个共享字段的修改是原子的。直接在方法上加
synchronized
关键字是最简单直接的做法,JVM会帮我处理好一切,不容易出错。
- 场景:对于一些逻辑简单、并发竞争不那么激烈的场景,或者在一些遗留代码的维护中,
- 2.
ReentrantLock
:功能更强大的选择- 场景:当
synchronized
的功能不够用时,我会毫不犹豫地选择ReentrantLock
。比如,我需要一个可中断的锁,或者需要一个带超时的尝试获取锁的功能,来避免线程死等。 - 实践与编码范式:使用
ReentrantLock
时,我一定会严格遵循try-finally
的编码范式,确保锁一定会被释放,这是铁律。
- 场景:当
private final ReentrantLock lock = new ReentrantLock();public void myComplexOperation() {lock.lock(); // 加锁try {// ------ 临界区开始 ------// 执行复杂的业务逻辑...// ------ 临界区结束 ------} finally {lock.unlock(); // 必须在finally块中释放锁}
}
- 3.
ReadWriteLock
:针对性优化的利器- 场景:当遇到一个典型的 “读多写少” 的资源时,比如一个应用内部的配置缓存。这个缓存可能几分钟甚至几小时才更新一次(写操作),但每秒钟都会被读取上千次(读操作)。
- 实践:这时我就会使用
ReentrantReadWriteLock
。读操作时,我获取readLock()
;写操作时,我获取writeLock()
。这样可以允许多个线程同时读取缓存,极大地提高了并发性能,同时又保证了写操作的独占和原子性。
最后,我会遵循两个重要的最佳实践:
- 尽可能减小锁的粒度:我不会无脑地把
synchronized
加在整个方法上。我会仔细分析,只在真正需要同步的几行代码上使用synchronized(this){...}
代码块。锁持有的时间越短,代码块越小,并发冲突的可能性就越低,系统吞吐量就越高。 - 警惕并避免死锁:在需要获取多把锁时,我会特别注意,强制所有线程都按照一个固定的、全局的顺序来获取锁。这是避免死锁最简单有效的手段。
通过这一整套的思考和实践,我能确保在项目中正确、高效且安全地使用锁。
Java 并发工具你知道哪些?
面试官您好,java.util.concurrent
(JUC)包是我在日常开发中处理并发问题时用得最多的工具集,它极大地提升了开发效率和程序的健壮性。如果要说我了解和常用的并发工具,我可以按照功能把它们分成几大类来介绍:
1. 线程池 (Executors)
这是现代Java并发编程的基石,用于管理和复用线程。
- 核心工具:
ThreadPoolExecutor
。 - 我的理解与实践:我不会直接用
Executors
的工厂方法(如newFixedThreadPool
),因为它们的内部队列可能是无界的,有OOM风险。我会根据业务需求,直接使用ThreadPoolExecutor
的构造函数,显式地指定核心线程数、最大线程数、存活时间、工作队列类型和拒绝策略,做到对线程池的完全掌控和精细化配置。 - 异步计算:与线程池配套使用的还有
Future
和CompletableFuture
。特别是CompletableFuture
,它支持非阻塞的回调和强大的任务编排能力(如thenApply
,thenCompose
,allOf
),是我实现复杂异步工作流的首选。
2. 同步协作工具 (Synchronizers)
这些工具用于协调线程间的执行顺序和状态。
CountDownLatch
(倒计时门闩)- 一句话概括:让一个或多个线程等待其他一组线程全部执行完毕。
- 场景举例:主线程需要等待多个子任务(如并行加载多个配置文件)都完成后,再进行数据汇总。
CountDownLatch
是一次性的。
CyclicBarrier
(循环屏障)- 一句话概括:让一组线程互相等待,直到所有线程都到达一个公共的屏障点,然后再一起继续执行。
- 关键特性:可循环使用(可
reset
),并且可以在到达屏障点时执行一个可选的Runnable
任务。 - 场景举例:多线程并行计算,分阶段进行,每个阶段开始前所有线程必须同步。
Semaphore
(信号量)- 一句话概括:控制同时访问某个特定资源的线程数量,常用于限流。
- 场景举例:一个服务只支持10个并发连接,我们就可以用一个初始值为10的
Semaphore
来控制并发访问。
Phaser
(移相器/定相器)- 一句话概括:功能更强大的
CyclicBarrier
和CountDownLatch
的结合体,它支持动态地调整参与协作的线程数量,并能将任务划分为多个阶段(Phase)进行。 - 场景举例:在一个动态的、分阶段的并发任务中,任务进行到一半时,可能会有新的子任务加入,或者有旧的子任务提前完成退出。
Phaser
可以很好地处理这种情况。
- 一句话概括:功能更强大的
3. 并发集合 (Concurrent Collections)
这些是线程安全的集合类,让我们在多线程环境下可以放心地操作。
ConcurrentHashMap
: 线程安全的哈希表,性能远超Hashtable
和synchronizedMap
,是并发场景下Map的首选。BlockingQueue
(阻塞队列): 实现生产者-消费者模式的利器,是线程间通信的最佳实践之一。常用的有ArrayBlockingQueue
和LinkedBlockingQueue
。CopyOnWriteArrayList
: “写时复制”的列表。适用于读多写少的场景。每次写操作(add, set, remove)都会复制一个新的底层数组,写操作开销大,但读操作完全无锁,非常高效。ConcurrentSkipListMap
: 一个基于跳表实现的、线程安全的、有序的Map。在需要高并发且保持key有序的场景下非常有用,性能优于用锁包装的TreeMap
。
4. 锁 (Locks)
除了synchronized
,JUC提供了更丰富的锁实现。
ReentrantLock
: 功能更强大、更灵活的synchronized
替代品,支持公平锁、可中断、可超时等。ReentrantReadWriteLock
: 读写锁,允许多个读线程并发访问,但在“读多写少”的场景下能极大地提升性能。
5. 原子类 (Atomics)
基于CAS(Compare-And-Swap)实现的无锁工具,用于单个变量的原子操作。
- 基本类型:
AtomicInteger
,AtomicBoolean
,AtomicLong
。 - 引用类型:
AtomicReference
,用于原子地更新对象引用。 - 数组类型:
AtomicIntegerArray
等。 - 字段更新器:
AtomicIntegerFieldUpdater
,可以在不改变原有类结构的情况下,为一个类的volatile
字段提供原子更新能力。 - 累加器:
LongAdder
,在高并发环境下,它的性能比AtomicLong
更好,因为它通过分散热点(分段cell计数)的方式减少了CAS冲突。在需要高性能计数的场景下,我优先使用LongAdder
。
在我的工作中,我会根据具体的并发场景,从这个丰富的工具箱中选择最合适的工具,以实现代码的简洁、高效和安全。
CountDownLatch 是做什么的讲一讲?
面试官您好,CountDownLatch
,中文通常翻译为“倒计时门闩”,是JUC包提供的一个非常实用的同步协作工具。
它的核心作用可以一句话概括:允许一个或多个线程等待,直到其他一组正在执行操作的线程全部完成为止。
1. 它的工作原理与核心API
CountDownLatch
的工作原理非常直观,就像一个反向的计数器:
- 初始化:在创建
CountDownLatch
实例时,我们需要给它一个初始的计数值(count)。这个计数值通常就代表了我们需要等待完成的任务数量。
// 比如,我们需要等待3个任务完成
CountDownLatch latch = new CountDownLatch(3);
- 等待 (
await
):一个或多个需要等待的线程,会调用latch.await()
方法。调用后,这些线程会立即进入阻塞状态,直到计数器减到零。 - 计数减一 (
countDown
):那组正在执行任务的线程,每当有一个任务完成时,它就需要调用latch.countDown()
方法。这个方法会将内部的计数器减一。 - 唤醒:当
countDown()
方法将计数器从1减到0的那一刻,所有因为调用await()
而阻塞的线程都会被立即唤醒,从阻塞状态变为可运行(RUNNABLE
)状态,然后继续执行它们后续的逻辑。
需要特别注意的一点是:CountDownLatch
是一次性的,它的计数器一旦减到零,就不能再被重置或复用了。如果需要循环使用的场景,就应该考虑CyclicBarrier
。
2. 它的典型使用场景
在我的实践中,CountDownLatch
主要用于以下两种经典的并发场景:
场景一:一个主线程等待多个子线程完成任务(最常用)
这是最经典的用法。比如,一个主任务需要依赖多个并行的子任务的结果才能继续。
- 举例:一个Web服务器启动时,需要并行地初始化多个组件,比如数据库连接池、缓存系统、消息队列客户端等。主启动线程必须等待所有这些组件都初始化成功后,才能宣布“服务器启动成功”并对外提供服务。
- 代码伪实现:
public void startServer() throws InterruptedException {int taskCount = 3;CountDownLatch latch = new CountDownLatch(taskCount);// 并行启动3个初始化任务new Thread(new InitTask("数据库连接池", latch)).start();new Thread(new InitTask("缓存系统", latch)).start();new Thread(new InitTask("消息队列客户端", latch)).start();System.out.println("主线程:等待所有初始化任务完成...");latch.await(); // 主线程在此阻塞,直到latch计数归零System.out.println("所有任务完成,服务器启动成功!");
}class InitTask implements Runnable {private final String taskName;private final CountDownLatch latch;// ... 构造函数 ...public void run() {System.out.println(taskName + " 开始初始化...");// ... 模拟耗时初始化 ...System.out.println(taskName + " 初始化完成!");latch.countDown(); // 完成一个任务,计数器减一}
}
场景二:多个线程等待一个统一的“起跑”信号
CountDownLatch
也可以反过来用,实现“发令枪”的效果。
- 举例:在做性能压测时,我需要模拟大量并发请求同时发出的场景。如果简单地用for循环创建并启动线程,由于线程启动有先后,无法做到真正的“同时”。
- 做法:可以创建一个计数值为1的
CountDownLatch
。所有压测线程创建后,立即调用latch.await()
,它们都会阻塞。当主线程准备好一切后,调用一次latch.countDown()
,所有等待的压测线程就会像听到发令枪一样,几乎在同一时刻被唤醒,开始并发执行。
3. 与join()
方法的区别
CountDownLatch
在“等待多个线程完成”这个场景上,比循环调用Thread.join()
要更灵活、更强大。
join()
必须等待线程执行结束。而countDown()
可以在线程的任何地方被调用,不一定非得是任务的终点,这提供了更大的灵活性。CountDownLatch
可以不关心执行任务的线程是谁,它只关心“任务完成”这个事件的数量。而join()
则强依赖于具体的Thread
对象。
总而言之,CountDownLatch
是一个非常简单但功能强大的同步工具,它将复杂的线程等待和协作问题,抽象成了一个易于理解的“倒计时”模型。
synchronized和reentrantlock及其应用场景?
面试官您好,synchronized
和 ReentrantLock
是Java中两种最核心的同步锁,它们都解决了多线程访问共享资源时的原子性和可见性问题,并且都是可重入的。但在我的实践中,我会根据场景的复杂度和对功能的需求来选择使用哪个。
我通常会从它们的核心区别和应用场景两个维度来阐述。
一、 核心区别
- 来源和本质
synchronized
: 是Java语言层面的关键字,由JVM直接支持。它的使用非常简单,编译器会自动在同步代码块前后生成monitorenter
和monitorexit
指令来完成锁的获取和释放,对开发者来说是隐式的。ReentrantLock
: 是JUC包下的一个API类(java.util.concurrent.locks.ReentrantLock
)。它需要我们手动编码来获取锁 (lock()
) 和释放锁 (unlock()
),并且必须在finally
块中释放锁,以防止异常导致锁无法释放。
- 功能丰富度
ReentrantLock
提供了远比synchronized
更丰富、更强大的功能,这正是它的核心优势所在。- 可中断的等待 (
lockInterruptibly
):synchronized
一旦进入等待状态,就只能死等,无法响应中断。而ReentrantLock
允许一个正在等待锁的线程响应中断请求,这在需要避免死锁或实现可取消任务时非常有用。 - 可超时的尝试 (
tryLock
):synchronized
没有获取到锁就会一直阻塞。ReentrantLock
提供了tryLock()
方法,可以立即返回是否获取到锁,或者在指定时间内尝试获取,如果超时还未获取到就返回false
。这使得程序可以根据情况决定是继续等待还是先去做别的事情,避免了线程无限期阻塞。 - 公平性选择 (Fairness):
ReentrantLock
在构造时可以传入一个布尔值来选择是创建公平锁还是非公平锁(默认)。- 公平锁:会按照线程请求锁的先后顺序来分配锁,能避免线程饥饿,但通常吞吐量较低。
- 非公平锁:允许新来的线程“插队”,可能会导致某些线程长时间获取不到锁,但整体吞吐量更高。
synchronized
则始终是非公平的。
- 绑定多个
Condition
:synchronized
只能与一个Object
的wait/notify
机制配合,所有线程都在同一个等待队列里。而一个ReentrantLock
可以创建多个Condition
对象,我们可以将不同类型的等待线程放入不同的Condition
队列中,实现分组等待和精准唤醒,这在复杂的生产者-消费者模型中非常有用。
- 可中断的等待 (
- 性能
- 在早期JDK版本(如1.5),
ReentrantLock
的性能确实优于synchronized
。 - 但随着JVM对
synchronized
的持续优化(如锁升级:偏向锁、轻量级锁、自旋锁等),在JDK 1.6及以后,两者的性能差距已经非常小。在没有激烈竞争或竞争时间很短的情况下,synchronized
的性能甚至可能更好,因为它有锁升级带来的优化。所以,性能不应再作为选择它们的主要依据。
- 在早期JDK版本(如1.5),
二、 应用场景与我的选择
基于以上区别,我的选择策略非常清晰:
- 场景一:优先使用
synchronized
- 当同步逻辑非常简单,且
synchronized
的功能已经足够满足需求时,我一定会优先使用它。 - **为什么?**因为它足够简单,语法上不易出错(JVM自动释放锁),代码可读性好。在简单的场景下,“杀鸡焉用牛刀”,引入
ReentrantLock
反而增加了代码的复杂性和出错的风险(比如忘记在finally
中unlock()
)。 - 例如:保护一个
getter/setter
方法,或者一个简单的复合操作,synchronized
是完美且正确的选择。
- 当同步逻辑非常简单,且
- 场景二:必须使用
ReentrantLock
- 当需要
synchronized
不具备的高级功能时,我才会选择ReentrantLock
。 - 具体场景包括:
- 需要可中断或可超时的锁获取:比如,在一个高并发的金融交易系统中,如果一个操作长时间获取不到锁,我不能让它无限期地等下去,需要设置一个超时时间,超时后就记录失败并返回,这时就必须用
tryLock(long timeout, TimeUnit unit)
。 - 需要实现公平锁:如果业务场景要求严格的先来后到,以防止某些线程被“饿死”,那么就必须使用公平版的
ReentrantLock
。 - 需要复杂的线程通信:当我实现一个复杂的生产者-消费者模型,比如一个缓冲区,既有“生产者等待缓冲区不满”的条件,又有“消费者等待缓冲区不空”的条件时,使用
ReentrantLock
配合两个独立的Condition
对象(notFull
和notEmpty
)会让逻辑变得非常清晰和高效。
- 需要可中断或可超时的锁获取:比如,在一个高并发的金融交易系统中,如果一个操作长时间获取不到锁,我不能让它无限期地等下去,需要设置一个超时时间,超时后就记录失败并返回,这时就必须用
- 当需要
总结
总而言之,我的决策树是:先看功能,再看简单性。
- 功能需求简单吗? -> 是 ->
synchronized
- 功能需求简单吗? -> 否,需要中断/超时/公平/多条件? -> 是 ->
ReentrantLock
在现代Java开发中,两者都是保证线程安全的利器,关键在于理解它们的特性,并为合适的场景选择合适的工具。
可中断锁和不可中断锁有什么区别?
面试官您好,可中断锁和不可中断锁最核心的区别在于:一个线程在尝试获取锁而被阻塞时,是否能够响应外部的中断(interrupt
)信号,从而提前结束等待。
1. 不可中断锁:synchronized
- 行为特征:
synchronized
就是一种典型的不可中断锁。一旦一个线程进入了等待获取synchronized
锁的状态(即BLOCKED
状态),它就只能**“一条道走到黑”**。 - 具体表现:它会一直阻塞,直到成功获取到锁为止。在此期间,无论其他线程如何调用这个等待线程的
interrupt()
方法,它都无动于衷,不会有任何响应。 - 带来的问题:这种“不撞南墙不回头”的特性,在某些情况下可能会导致问题。如果一个线程因为某些逻辑错误(比如死锁)而永远无法获取到锁,那么它就会永久地阻塞下去,无法被外部力量“解救”。
2. 可中断锁:ReentrantLock
- 行为特征:
ReentrantLock
则提供了更灵活的选择,它既可以表现为不可中断锁,也可以表现为可中断锁。 - 如何实现:
- 当我们调用
lock.lock()
方法时,它的行为和synchronized
一样,是一个不可中断的、死等的获取过程。 - 而当我们调用
lock.lockInterruptibly()
方法时,它就变成了一个可中断锁。
- 当我们调用
lockInterruptibly()
的工作流程:- 一个线程调用
lock.lockInterruptibly()
尝试获取锁。 - 如果锁是可用的,它会立即获取锁并返回。
- 如果锁被其他线程持有,该线程会进入等待队列并被阻塞。
- 关键点:在阻塞等待期间,如果其他线程调用了这个等待线程的
interrupt()
方法,那么这个等待的线程会立即被唤醒,并抛出一个InterruptedException
异常。它不再继续等待获取锁,而是可以去执行catch
块中的逻辑,比如记录日志、进行清理,或者直接结束。
- 一个线程调用
3. 为什么可中断性很重要?—— 解决死锁问题
可中断锁一个非常重要的应用场景就是避免和解决死锁。
- 想象一个死锁场景:线程A持有锁1,等待锁2;线程B持有锁2,等待锁1。
- 如果使用
synchronized
:两个线程都会进入BLOCKED
状态,并且对interrupt()
信号完全免疫。它们会永远地等待下去,除非重启JVM,否则无法打破僵局。 - 如果使用
ReentrantLock.lockInterruptibly()
:- 我们可以有一个第三方的监控线程,当它检测到死锁发生时,可以选择其中一个线程(比如线程A),并调用
threadA.interrupt()
。 - 线程A在
lockInterruptibly()
的等待中接收到中断信号,会立即抛出InterruptedException
。 - 在
catch
块中,我们可以让线程A释放它已经持有的锁1。 - 一旦锁1被释放,线程B就能获取到锁1,从而打破死锁的循环等待,让程序恢复正常。
- 我们可以有一个第三方的监控线程,当它检测到死锁发生时,可以选择其中一个线程(比如线程A),并调用
总结对比
特性 | 不可中断锁 (synchronized , lock.lock() ) | 可中断锁 (lock.lockInterruptibly() ) |
---|---|---|
等待行为 | 只能死等,直到获取锁 | 在等待时,可以响应中断信号 |
中断响应 | 无 | 抛出InterruptedException ,停止等待 |
灵活性 | 低 | 高,提供了退出的可能性 |
适用场景 | 绝大多数常规同步场景 | 需要可取消、可超时、或需要打破死锁等高级场景 |
所以,可中断锁为我们编写更健壮、更具响应性的并发程序提供了强大的能力,尤其是在处理复杂的锁交互和异常恢复时,它的价值尤为突出。
synchronized锁静态方法和普通方法区别?
面试官您好,这是一个非常基础但又极其重要的问题。synchronized
锁静态方法和普通方法,它们最核心、最本质的区别在于锁定的对象不同,这个根本区别导致了它们在作用范围和行为上的巨大差异。
1. 锁的对象不同 (The Root Cause)
- 锁普通方法 (实例方法):
synchronized
锁住的是当前方法所属的对象实例,也就是this
对象。- 这意味着,每一把锁都与一个具体的对象实例绑定。如果有多个该类的对象实例,那么每个实例都拥有自己独立的一把锁。
- 锁静态方法:
synchronized
锁住的是这个方法所属的类的Class
对象。- 在JVM中,无论一个类被创建了多少个对象实例,它的
Class
对象从始至终都只有唯一的一个。所以,锁静态方法实际上是锁住了这个全局唯一的Class
对象。
- 在JVM中,无论一个类被创建了多少个对象实例,它的
2. 作用范围与行为表现 (The Consequences)
正是因为锁的对象不同,导致了它们在多线程环境下的行为截然不同:
- 对于普通方法的锁(实例锁):
- 它的作用范围是实例级别的。只有当多个线程竞争同一个对象实例的
synchronized
方法时,才会发生互斥。 - 如果线程们访问的是不同对象实例的同一个
synchronized
方法,它们之间是互不影响、可以并行执行的,因为它们获取的是不同实例的锁。
- 它的作用范围是实例级别的。只有当多个线程竞争同一个对象实例的
- 对于静态方法的锁(类锁):
- 它的作用范围是类级别的,是全局性的。
- 无论我们创建了多少个该类的对象实例,只要一个线程进入了任意一个实例的
synchronized
静态方法,其他所有线程就都无法进入这个类的任何一个synchronized
静态方法,因为它们竞争的是同一把、全局唯一的Class
对象锁。
3. 一个生动的例子
我们可以用代码来直观地看一下:
class BankAccount {// 实例方法,锁的是 this 对象public synchronized void deposit(int amount) {// ... 存钱逻辑 ...}// 静态方法,锁的是 BankAccount.class 对象public static synchronized void printBankName() {// ... 打印银行名称 ...}
}// ---- 在多线程场景下 ----
BankAccount accountA = new BankAccount();
BankAccount accountB = new BankAccount();// 场景1: 两个线程分别操作不同实例的【实例方法】 -> 可以并行
new Thread(() -> accountA.deposit(100)).start();
new Thread(() -> accountB.deposit(200)).start(); // 不会阻塞,因为锁的是不同的对象(accountA, accountB)// -------------------------------------------------------------// 场景2: 两个线程操作任意实例的【静态方法】 -> 必须串行
new Thread(() -> accountA.printBankName()).start();
new Thread(() -> accountB.printBankName()).start(); // 会阻塞,因为它们竞争的是同一把锁(BankAccount.class)// -------------------------------------------------------------// 场景3: 一个线程操作实例方法,一个线程操作静态方法 -> 可以并行
new Thread(() -> accountA.deposit(100)).start();
new Thread(() -> BankAccount.printBankName()).start(); // 不会阻塞,因为它们获取的是两把完全不同的锁(accountA 和 BankAccount.class)
这个例子清晰地展示了:实例锁和类锁是两把完全不同的锁,它们互不干涉。
总结
所以,在选择锁静态方法还是普通方法时,我们需要问自己一个问题:我需要保护的资源是与单个实例相关的,还是与整个类所有实例共享的?
- 如果保护的是实例变量,那就用实例锁(锁普通方法)。
- 如果保护的是静态变量或者需要一个全局性的同步点,那就用类锁(锁静态方法)。
怎么理解可重入锁?
面试官您好,对于“可重入锁”,我的理解包含三个层面:它是什么、为什么需要它,以及它是如何实现的。
1. 它是什么?(What)
首先,可重入锁,也叫递归锁,它的核心定义是:允许同一个线程,在已经持有了一把锁的情况下,可以再次成功地获取这把锁,而不会因为自己已经持有锁而被自己阻塞。
简单来说,就是线程可以“重复进入”自己已经加锁的同步代码区。
2. 为什么需要它?(Why)
可重入性是解决一个非常常见场景的必要设计,那就是一个线程在持有锁的情况下,调用了另一个也需要同一把锁的方法。
我举一个最经典的例子:
class LoggingWidget {public synchronized void doSomething() {System.out.println("进入 doSomething(), 当前线程: " + Thread.currentThread().getName());// ... 其他业务逻辑 ...doLogging(); // 调用了另一个同步方法}public synchronized void doLogging() {System.out.println("进入 doLogging(), 当前线程: " + Thread.currentThread().getName());// ... 日志记录逻辑 ...}
}
- 想象一下,一个线程T1调用了
widget.doSomething()
方法。它首先获取了widget
对象的锁。 - 在
doSomething()
方法内部,它又调用了widget.doLogging()
方法。doLogging()
方法也需要获取widget
对象的锁。 - 如果锁不是可重入的,那么T1在尝试获取
doLogging()
的锁时,会发现这把锁已经被“某个线程”(其实就是它自己)持有了,于是它就会被阻塞,进入等待。这就造成了一个线程在等待自己释放锁的局面——死锁。
所以,可重入锁的设计,就是为了避免这种情况,保证了在面向对象编程中,一个类的方法可以自由地调用该类其他需要同步的方法,而不用担心死锁问题。
3. 它是如何实现的?(How)
基于计数器的机制,是可重入锁实现的核心。无论是synchronized
还是ReentrantLock
,它们都采用了类似的思想。
我以 ReentrantLock
为例,它的内部实现大致是这样的:
- 持有者记录与计数器:锁内部会维护两个关键信息:一个是当前持有锁的线程(owner),另一个是一个整数计数器(count),初始值为0。
- 加锁 (lock):
- 当一个线程来请求锁时,首先检查
count
是否为0。 - 如果
count
为0,说明锁未被持有,那么就将owner
设置为当前线程,并将count
设置为1。 - 如果
count
不为0,就检查owner
是不是当前线程自己。- 如果是自己(这就是可重入的关键),那么就只是简单地把
count
加1,然后直接返回。 - 如果不是自己,那么该线程就必须等待。
- 如果是自己(这就是可重入的关键),那么就只是简单地把
- 当一个线程来请求锁时,首先检查
- 解锁 (unlock):
- 解锁时,线程会把
count
减1。 - 只有当
count
减到0时,才意味着这个线程已经完全释放了这把锁(因为它可能加了多次锁)。这时,锁才会将owner
设置为null
,并真正地释放锁,让其他等待的线程有机会获取。
- 解锁时,线程会把
这个简单的“持有者判断 + 计数器”机制,就优雅地实现了锁的可重入性。
最后补充一点,Java中我们最常用的两种锁 synchronized
和ReentrantLock
都是可重入锁。这保证了我们在编写同步代码时的便利性和安全性。
synchronized 支持重入吗?如何实现的?
面试官您好,答案是肯定的,synchronized
关键字是支持可重入的。这意味着一个已经持有锁的线程,可以再次进入由同一个锁保护的其他同步代码块,而不会造成死锁。
它的可重入性是其底层Monitor(监视器)机制设计的一部分。要理解它的实现,我们需要深入到JVM层面,看看当一个线程获取synchronized
锁时,到底发生了什么。
每个Java对象都可以作为一个锁,这个锁的功能由对象内部的一个叫做Monitor的结构来实现。在HotSpot JVM中,这个Monitor是基于C++的ObjectMonitor
类实现的,它包含了几个关键部分来支持可重入:
_owner
字段:这个字段用于存放当前持有该Monitor的线程的指针。当一个线程成功获取锁后,_owner
就会指向这个线程。_recursions
或_count
字段:这是一个递归计数器,专门用于处理可重入。它的工作方式与您描述的status非常类似:- 当一个线程首次尝试获取一个无主(_owner为null)的锁时,JVM会记录下
_owner
为该线程,并将计数器_recursions
设置为1。该线程成功获取锁。 - 当这个已经持有锁的线程,再次进入由同一个Monitor锁保护的同步代码时(即重入),它会检查
_owner
是不是自己。 - 发现
_owner
就是自己,JVM不会阻塞它,而是简单地将_recursions
计数器加1。 - 每次线程退出一个同步代码块时,计数器
_recursions
就会减1。 - 只有当计数器减到0时,才表示该线程已经完全退出了所有由这个锁保护的代码。此时,它才会真正地释放锁,将
_owner
字段清空为null
,并唤醒其他等待队列中的线程来竞争锁。
- 当一个线程首次尝试获取一个无主(_owner为null)的锁时,JVM会记录下
这个“持有者线程判断 + 递归计数器”的设计,正是synchronized
实现可重入的核心。
信息存储在哪里?
值得一提的是,这些关于锁状态的信息(比如锁的类型、持有锁的线程ID等)并不总是直接存储在重量级的Monitor里。为了优化性能,JVM在对象的对象头(Object Header)中的Mark Word区域里,巧妙地存储了这些信息。
- 在无锁状态下,Mark Word存储的是对象的哈希码等信息。
- 当一个线程第一次获取锁,且没有竞争时,JVM会尝试使用偏向锁,直接在Mark Word里记录下持有锁的线程ID。如果这个线程再次进入,只需检查一下Mark Word里的线程ID是不是自己,如果是,连CAS操作都不需要,效率极高。
- 当有竞争时,锁会膨胀为轻量级锁,再到重量级锁。在重量级锁状态下,Mark Word会变成一个指向重量级锁(也就是那个Monitor)的指针,此时,
_owner
和_recursions
这些详细信息就在Monitor对象里进行管理了。
但无论锁处于哪个状态,其支持可重入的底层逻辑,始终是围绕着“记录锁的持有者,并对重入次数进行计数”这一核心思想来实现的。
syncronized锁升级的过程讲一下
面试官您好,synchronized
的锁升级机制是JVM为了提升其性能而引入的一项非常重要的优化。它的核心思想是:锁的状态不是一成不变的,而是会根据线程的竞争激烈程度,从低成本到高成本逐步升级,以尽可能地避免重量级锁带来的性能开销。
这个升级路径是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。这个过程是单向的,只能升级,不能降级(但在某些JVM实现中,处于安全点时可能会有锁降级的特殊情况,但一般不作为主要讨论点)。
下面我来详细讲解一下每个阶段的特点和升级过程:
1. 无锁状态
这其实是对象的初始状态。对象头的Mark Word中,锁标志位是01
,但并不包含任何锁信息。
2. 偏向锁 (Biased Locking)
- 目的:为了解决绝大多数情况下,锁不仅没有竞争,而且总是由同一个线程多次获得的场景。
- 如何工作:
- 当第一个线程(我们称之为T1)来获取锁时,JVM会通过CAS操作,将对象头Mark Word中的线程ID设置为T1的ID,并将锁标志位置为偏向锁状态。
- 之后,当T1再次来获取这个锁时,它只需要检查Mark Word里的线程ID是否是自己。如果是,它就直接获取锁,连CAS操作都不需要。这个成本极低。
- 升级时机(偏向锁的撤销):
- 当有另一个线程(T2)也来尝试获取这个锁时,偏向模式就失效了。
- JVM会暂停持有偏向锁的线程T1,检查T1是否还在执行同步代码块。
- 如果T1已经执行完了,就简单地将对象头恢复到无锁或匿名偏向状态,然后让T2去竞争,可能会升级为轻量级锁。
- 如果T1还在执行,那么锁就需要升级为轻量级锁。T1的栈帧中会创建锁记录,对象头的Mark Word会指向这个锁记录,然后T2开始自旋等待。
3. 轻量级锁 (Lightweight Locking)
- 目的:为了解决线程交替执行同步块的场景,即竞争不激烈,锁的持有时间通常很短。
- 如何工作:
- 在线程进入同步块时,JVM会在当前线程的栈帧中创建一个名为**“锁记录”(Lock Record)**的空间,用于拷贝对象头Mark Word的副本(官方称为Displaced Mark Word)。
- 然后,JVM会尝试使用CAS操作,将对象头的Mark Word更新为指向这个锁记录的指针。
- 如果CAS成功,该线程就获取了轻量级锁。
- 如果CAS失败,说明已经有其他线程持有了该锁。这时,当前线程并不会立即挂起,而是会进行自旋(Spinning),即执行一个空循环,期待持有锁的线程能很快释放锁。
- 升级时机:
- 自旋是消耗CPU的。如果自旋了一定次数(默认10次,或根据JVM自适应策略)后,仍然没有获取到锁,或者在自旋过程中又有新的线程也来竞争这个锁(即竞争加剧),那么轻量级锁就会升级为重量级锁。
4. 重量级锁 (Heavyweight Locking)
- 目的:应对高并发、高竞争的场景,即多个线程同时竞争锁,且锁的持有时间较长。
- 如何工作:
- 升级到重量级锁后,对象头的Mark Word会被修改为指向一个**重量级监视器(Monitor)**的指针。
- 此时,所有未能获取到锁的线程,都不再进行自旋,而是会被阻塞,并放入Monitor的等待队列中。它们会释放CPU,由操作系统来负责调度和唤醒。
- 优缺点:
- 优点:线程不再空耗CPU,节约了CPU资源。
- 缺点:线程的阻塞和唤醒需要从用户态切换到内核态,这个上下文切换的成本非常高,这也是为什么JVM要极力避免进入重量级锁状态的原因。
总结一下,synchronized
的锁升级机制,就像一个智能的交通警察,面对不同的车流量(线程竞争),采用不同的管制策略:没车时(无锁)不设岗,偶尔来一辆熟人车(偏向锁)直接放行,有两三辆车交替通过(轻量级锁)就自旋指挥一下,一旦堵车严重(重量级锁),就呼叫交警总部(操作系统)来统一调度。这是一个非常精妙的性能权衡设计。
JVM对Synchornized的优化?
面试官您好,JVM对synchronized
的优化是一个持续演进的过程,其核心目标是减少锁带来的性能开销。这些优化手段,我理解可以分为两大方向:一是“想办法减少锁的竞争”,二是“想办法让锁本身变得更轻快”。
方向一:减少或避免不必要的锁竞争(编译期优化)
这个方向的优化主要由JIT(即时编译器)在编译阶段完成,它通过逃逸分析等技术,在代码层面进行优化。
- 1. 锁消除 (Lock Elision)
- 是什么:JIT在分析代码时,如果发现某个锁对象根本不可能被其他线程访问到,它就断定这个锁是多余的,会直接把它“消除”掉。
- 为什么有效:最典型的例子就是在方法内部创建了一个局部对象并对其加锁。比如
new StringBuffer().append(...)
,这个StringBuffer
对象是方法私有的,其他线程根本拿不到,对它加锁完全没有意义。消除这种锁,就等于移除了无用的性能开销。
- 2. 锁粗化 (Lock Coarsening)
- 是什么:如果JIT发现有一系列连续的、针对同一个对象的加锁和解锁操作,它会把这些锁操作“合并”成一个更大范围的锁。
- 为什么有效:频繁地加锁和解锁也是有成本的。比如在一个循环里,每次循环都对同一个
StringBuilder
加锁追加一个字符,然后解锁。JIT会把这个锁的范围扩大到整个循环之外,变成“加一次锁 -> 执行完整个循环 -> 解一次锁”。这样就用一次加解锁的成本,替代了多次的成本,提升了性能。
方向二:让锁本身变得更轻、更快(运行时优化)
这个方向的优化是synchronized
性能提升的关键,主要体现在运行时的锁状态适应上。
- 3. 锁升级(也叫锁膨胀, Lock Escalation)
- 是什么:这是
synchronized
最核心的优化。它不再是“一刀切”的重量级锁,而是建立了一个从低到高的状态机:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。锁会根据竞争的激烈程度,自动地、单向地进行升级。 - 为什么有效:
- 在无竞争时,它会使用偏向锁,几乎没有开销。
- 在有少量、交替竞争时,它会升级为轻量级锁,通过自旋等待,避免了昂贵的线程上下文切换。
- 只有在竞争非常激烈时,才会不得已升级为重量级锁,通过操作系统来管理线程阻塞,虽然成本高,但避免了CPU的空转浪费。
- 这种“按需付费”的策略,使得
synchronized
在绝大多数场景下都能以接近无锁的性能运行。
- 是什么:这是
- 4. 自适应自旋锁 (Adaptive Spinning)
- 是什么:这个技术其实是轻量级锁阶段的一个重要组成部分。当一个线程获取轻量级锁失败时,它不会立即挂起,而是会“自旋”——执行一个空循环,期待锁能很快被释放。
- **“自适应”体现在哪里?**它的自旋次数不是固定的。JVM会根据上一次同一个锁的自旋情况和锁持有者的状态来智能地调整。
- 如果上一次自旋成功了,JVM会认为这次也很可能成功,就会允许它自旋更长的时间。
- 如果上一次自旋失败了,或者持有锁的线程正在运行,JVM就可能会减少自旋次数,甚至直接放弃自旋,尽快升级为重量级锁,避免空耗CPU。
- 为什么有效:自旋的成本远低于线程的挂起和唤醒。这种自适应的策略,就是在这两种成本之间做了一个智能的动态权衡,以期达到最佳的性能表现。
通过这四大优化手段的协同工作,现代JVM中的synchronized
已经不再是过去那个“笨重”的锁,而是一个非常智能、高效的同步工具。
synchronized 底层原理了解吗?
面试官您好,synchronized
作为Java的内置锁,其底层原理是一个涉及编译器、JVM、对象模型和操作系统协同工作的复杂但精巧的系统。我的理解主要包含以下几个层面:
第一层:从字节码看synchronized
在Java代码层面,我们使用synchronized
修饰方法或代码块。当Java编译器将.java
文件编译成.class
文件时,它会为synchronized
同步块生成两条特殊的字节码指令:
monitorenter
:在同步代码块的开始位置插入。当线程执行到这条指令时,它会尝试获取该对象关联的Monitor(监视器)的所有权。monitorexit
:在同步代码块的结束位置(正常结束)和异常位置都插入。这确保了无论代码是正常执行完毕还是中途抛出异常,锁都一定会被释放。
所以,synchronized
的锁的获取和释放,在底层是由JVM通过这两条字节码指令来控制的。
第二层:核心实现 - 对象监视器 (Monitor)
monitorenter
和monitorexit
指令操作的目标,就是对象监视器(Monitor),它也被称为“管程”。
- 每个Java对象天生都关联着一个Monitor。这个Monitor可以看作是一个同步工具,或者说是一个管理锁的“办公室”。
- 在HotSpot JVM中,Monitor是由C++的
ObjectMonitor
类实现的,它内部包含了几个关键的数据结构:_owner
:一个指针,指向当前持有该Monitor的线程。_recursions
:一个计数器,用于支持锁的可重入。当一个线程重入时,这个计数器会加1。_EntryList
:一个等待队列,所有尝试获取锁失败的线程,都会被放入这个队列中阻塞等待。_WaitSet
:另一个等待队列,当持有锁的线程调用了对象的wait()
方法后,它会释放锁并进入这个队列等待,直到被notify()
或notifyAll()
唤醒。
工作流程(重量级锁状态下):
- 线程执行
monitorenter
,尝试获取Monitor。 - 如果Monitor的
_owner
为null
,则该线程成功获取锁,将_owner
指向自己,_recursions
设为1。 - 如果
_owner
不为null
,检查_owner
是否为当前线程。如果是,说明是重入,_recursions
加1。 - 如果
_owner
是其他线程,则当前线程进入_EntryList
中挂起,等待被唤醒。 - 线程执行
monitorexit
,_recursions
减1。当计数器减到0时,线程释放锁(将_owner
设为null
),并唤醒_EntryList
中的一个等待线程来竞争锁。
第三层:性能优化的关键 - 锁升级
上面描述的Monitor是基于操作系统的互斥量(Mutex Lock)实现的,涉及到用户态和内核态的切换,成本很高,因此被称为重量级锁。在早期的Java版本中,synchronized
性能不佳正是因此。
从JDK 1.6开始,JVM引入了大量的优化,其中最重要的就是锁升级(或锁膨胀)机制。它的核心思想是“按需索取”,根据竞争的激烈程度,锁会自动从低成本状态向高成本状态升级。
这个升级路径是:无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁。
- 偏向锁 (Biased Locking)
- 场景:绝大多数情况下,锁不仅没有竞争,而且总是由同一个线程反复获取。
- 原理:当第一个线程获取锁时,JVM会把对象头(Mark Word)中的锁标志位设为“偏向锁”,并用CAS记录下这个线程的ID。之后,当这个线程再次进入同步块时,无需任何同步操作,只需检查一下线程ID是否匹配,效率极高。
- 轻量级锁 (Lightweight Locking)
- 场景:当出现另一个线程尝试竞争这个偏向锁时,偏向锁就会升级为轻量级锁。它适用于线程交替执行同步块、竞争不激烈的场景。
- 原理:竞争线程不会立即阻塞,而是会通过**自旋(Spinning)**的方式尝试获取锁。它通过CAS操作将对象头指向线程栈上的一个锁记录(Lock Record)。自旋避免了线程上下文切换的开销。
- 重量级锁 (Heavyweight Locking)
- 场景:如果自旋了一定次数后仍未获取到锁,或者有更多的线程加入竞争,轻量级锁就会升级为重量级锁。
- 原理:此时,就回到了我们上面讲的Monitor机制。所有等待的线程都会被阻塞,交由操作系统来调度,虽然节省了CPU,但上下文切换的成本很高。
信息的载体:对象头 (Object Header)
最后,所有这些锁的状态信息(是无锁、偏向锁、轻量级锁还是重量级锁)、持有锁的线程ID、锁的哈希码、GC年龄等,都存储在每个Java对象的对象头区域里的一个叫做Mark Word的空间里。JVM就是通过修改Mark Word的内容来完成锁状态的切换和记录的。
总结一下,synchronized
的底层原理,可以看作是一个以对象Monitor为核心,通过字节码指令来驱动,并利用对象头Mark Word作为信息载体,实现了一套从偏向锁到重量级锁的自适应升级策略的、高效的内置锁机制。
synchronized 的偏向锁为什么被废弃了?
面试官您好,您提出的这个问题非常好,它触及了Java并发技术演进的一个重要节点。是的,正如您所知,synchronized
的偏向锁在JDK 15中被默认禁用,并在后续版本中被逐步废弃。
要理解它为什么被废弃,我们首先要回顾一下它当初是为了解决什么问题而被设计的。
1. 偏向锁的“黄金时代”与设计初衷
偏向锁是在JDK 1.6中,作为synchronized
锁升级机制的一部分被引入的。它的设计初衷非常明确:优化“绝大多数情况下,锁不仅没有竞争,而且总是由同一个线程反复获取”的场景。
- 在那个时代,很多Java核心类库,比如您提到的
Hashtable
、Vector
,甚至是StringBuffer
,它们的方法都大量使用了synchronized
。 - 当这些类在单线程环境中使用时,每次调用同步方法都会产生不必要的锁开销。
- 偏向锁通过让锁“偏向”于第一个获取它的线程,使得该线程后续再次获取锁时,成本几乎为零(只需一次ID比较),极大地提升了这类场景下的性能。
2. 为什么偏向锁如今“英雄迟暮”?
随着技术的发展,偏向锁的“用武之地”越来越少,而它自身的“副作用”却越来越明显。这主要有两大原因,也正是官方JEP 374中提到的:
- 原因一:性能收益在现代应用中已不明显
- 现代并发工具的崛起:JUC包提供了大量更优秀的并发工具。我们现在有了性能极高的
ConcurrentHashMap
来替代Hashtable
,有ThreadLocal
来避免共享SimpleDateFormat
等。开发者们已经很少在需要高性能的场景下,还去依赖那些用synchronized
实现的旧集合了。 - “无锁”编程思想的普及:现代并发编程更倾向于使用CAS、
ThreadLocal
等无锁或避免锁的方案,从根本上绕开了synchronized
。 - 偏向锁的撤销成本高昂:偏向锁只在无竞争时才有优势。一旦出现线程竞争,就需要撤销偏向锁,这个过程非常昂贵。它需要等待JVM进入一个全局安全点(Global Safepoint),在这个点上所有应用线程都会被暂停。然后JVM才能安全地检查锁的状态并执行撤销操作。如果一个应用中,锁的竞争是常态,那么频繁的偏向锁获取和撤销,其开销甚至会超过直接使用轻量级锁,导致性能下降。
- 现代并发工具的崛起:JUC包提供了大量更优秀的并发工具。我们现在有了性能极高的
- 原因二:自身实现复杂,维护成本过高
- 偏向锁的实现逻辑非常复杂,它“侵入”了JVM中多个核心子系统,比如同步系统、垃圾回收、对象模型等。
- 这种复杂性使得JVM的开发和维护变得异常困难。任何对相关系统的修改,都必须小心翼翼地考虑对偏-向锁的影响。
- 对于OpenJDK的开发者来说,维护这个“历史遗留”的、收益越来越小的复杂功能,变成了一笔沉重的“技术债”。
3. 结论:一次理性的“断舍离”
所以,废弃偏向锁,可以看作是OpenJDK社区在 “收益”与“成本” 之间做出的一次理性的权衡。
在现代Java并发编程范式下,偏向锁带来的性能收益已经微乎其微,甚至在某些场景下会成为负累。而它高昂的维护成本,却实实在在地拖慢了JVM的演进。因此,移除它,简化同步系统的实现,对于整个Java生态的长远发展来说,是一件好事。
介绍一下AQS
面试官您好,AQS,全称是 AbstractQueuedSynchronizer
,它是JUC包中绝大多数锁和同步器的核心实现框架。
您可以把它理解为一个 “同步器开发的模板或蓝图” 。像我们常用的 ReentrantLock
、Semaphore
、CountDownLatch
、ReentrantReadWriteLock
等,它们的底层都是基于AQS来实现的。AQS帮助开发者屏蔽了大量复杂的底层细节,比如线程的排队、阻塞、唤醒以及CAS操作等,让我们能更专注于实现同步器本身的逻辑。
1. AQS的核心设计思想
AQS的核心思想是,如果被请求的共享资源是空闲的,那么就将当前请求资源的线程设置为有效的工作线程,并将共享资源设置为锁定状态。如果共享资源被占用,那么就需要一套线程阻塞等待以及被唤醒时锁分配的机制,这个机制AQS是用一个CLH(Craig, Landin, and Hagersten)队列的变体来实现的。
它的设计是基于模板方法模式的。
- AQS本身:提供了一个通用的、管理同步状态和线程排队的框架。它定义好了线程获取资源(
acquire
)和释放资源(release
)的顶级逻辑。 - 使用者(如
ReentrantLock
):则需要去继承AQS,并重写它提供的一些受保护的“模板方法”,来定义自己独特的“资源是否可被获取(tryAcquire
)”和“如何释放资源(tryRelease
)”的逻辑。
2. AQS的两个核心组件
AQS的内部实现主要依赖于两个核心组件:
volatile int state
(同步状态)- 这是一个被
volatile
修饰的整型变量,它代表了共享资源的同步状态。AQS的所有操作,本质上都是在原子地操作这个state
变量。 - 这个
state
的含义由具体实现AQS的子类来定义:- 在
ReentrantLock
中,state
表示锁的重入次数。state
为0表示未被锁定,大于0表示已被持有。 - 在
Semaphore
中,state
表示剩余的许可证数量。 - 在
CountDownLatch
中,state
表示还需要倒数的数量。
- 在
- 对
state
的修改都是通过CAS操作来保证原子性的。
- 这是一个被
- 一个FIFO双向队列 (CLH Queue)
- 这是一个用来管理所有等待获取资源的线程的队列。当一个线程请求资源失败时,AQS就会将这个线程和它的等待状态封装成一个
Node
节点,并将其加入到这个队列的尾部。 - 当持有资源的线程释放资源时,AQS会从队列的头部取出一个
Node
节点,唤醒它对应的线程,让它重新尝试获取资源。 - 这个队列的设计非常精妙,它基本上是无锁的,节点的加入和移除操作都是通过CAS来完成。
- 这是一个用来管理所有等待获取资源的线程的队列。当一个线程请求资源失败时,AQS就会将这个线程和它的等待状态封装成一个
3. AQS的两种资源共享模式
AQS定义了两种资源共享模式,来满足不同同步器的需求:
- 独占模式 (Exclusive Mode):资源在同一时刻只能被一个线程持有。这是最常见的模式。
- 对应方法:
tryAcquire(int)
、tryRelease(int)
、isHeldExclusively()
- 典型实现:
ReentrantLock
- 对应方法:
- 共享模式 (Shared Mode):资源在同一时刻可以被多个线程持有。
- 对应方法:
tryAcquireShared(int)
、tryReleaseShared(int)
- 典型实现:
Semaphore
(允许多个线程同时持有许可证)、CountDownLatch
(多个线程可以同时await
)、ReentrantReadWriteLock
的读锁。
- 对应方法:
4. 简单的工作流程(以独占模式为例)
- 一个线程调用
lock()
方法(比如ReentrantLock.lock()
)。 lock()
方法内部会调用AQS的acquire()
方法。acquire()
会首先调用子类实现的tryAcquire()
方法,尝试原子地将state
从0更新为1。- 如果
tryAcquire()
成功:代表获取锁成功,方法直接返回。 - 如果
tryAcquire()
失败:- AQS会将当前线程封装成一个
Node
节点,通过CAS操作加入到等待队列的尾部。 - 然后,通过
LockSupport.park()
方法将当前线程挂起,使其进入等待状态。
- AQS会将当前线程封装成一个
- 当持有锁的线程调用
unlock()
方法时,内部会调用AQS的release()
方法。 release()
会调用子类实现的tryRelease()
,原子地将state
减1。当state
变为0时,表示锁已完全释放。- 接着,AQS会查找等待队列的头部节点,并通过
LockSupport.unpark()
唤醒头节点对应的线程,让它重新开始竞争锁。
总结一下,AQS通过一个原子的state
变量来表示同步状态,通过一个FIFO队列来管理等待的线程,并利用模板方法模式,将通用的线程调度框架和具体的同步逻辑解耦,成为了构建JUC同步组件的强大基石。理解了AQS,就等于理解了JUC中半数以上并发工具的实现原理。
CAS和AQS有什么关系?
面试官您好,CAS 和 AQS 是Java并发编程中两个不同层面的概念,但它们之间有着非常紧密且重要的关系。我们可以这样理解:
CAS 是“砖块”,而 AQS 是用这些“砖块”搭建起来的“大厦框架”。
下面我来详细解释一下这个关系:
首先,CAS (Compare-And-Swap) 是什么?它处于什么层面?
- 它是一种思想,也是一种底层的原子操作。
- CAS是一种乐观锁的思想。它在更新一个值之前,会先比较内存中的当前值是否和它期望的值一样,如果一样,才执行更新。
- 这个“比较并交换”的过程,在底层是由CPU 的原子指令(如
cmpxchg
)来保证的。这意味着,CAS操作本身是硬件级别的原子性,非常高效,因为它避免了操作系统层面的线程阻塞和上下文切换。
- 它的层面:CAS是构建无锁(Lock-Free)数据结构和算法的最基础、最核心的原子原语。它处于JUC工具链的最底层。
其次,AQS (AbstractQueuedSynchronizer) 是什么?它处于什么层面?
- 它是一个上层的、用于构建锁和同步器的并发框架。
- AQS自己并不实现任何具体的同步逻辑,而是提供了一套完整的线程排队、阻塞、唤醒的机制,以及一个表示同步状态的
volatile int state
变量。 - 它通过模板方法模式,让开发者(比如
ReentrantLock
的作者)只需要关注如何定义和修改state
的含义,而无需关心复杂的线程调度问题。
- AQS自己并不实现任何具体的同步逻辑,而是提供了一套完整的线程排队、阻塞、唤醒的机制,以及一个表示同步状态的
- 它的层面:AQS是
ReentrantLock
,Semaphore
等具体同步工具的实现骨架,处于JUC工具链的中间层。
最后,它们之间至关重要的联系是什么?
AQS这座“大厦框架”之所以能够稳固地工作,完全依赖于CAS这块坚固的“砖块”。具体体现在:
- 原子地更新同步状态
state
- AQS的核心是对
state
变量的操作。比如,ReentrantLock
在尝试获取锁时,需要原子地将state
从0变为1。如果用普通赋值state = 1
,在多线程下肯定会出错。 - AQS正是通过调用CAS操作来完成对
state
的修改的。例如,它会使用compareAndSetState(0, 1)
这样的方法。因为CAS是原子性的,所以这保证了在高并发下,state
的更新是绝对线程安全的。
- AQS的核心是对
- 无锁地维护等待队列
- 当一个线程获取锁失败,需要进入等待队列时,AQS需要把它封装成一个
Node
节点并加入到队列的尾部。这个“入队”操作同样是在高并发环境下进行的。 - AQS非常巧妙地也使用了CAS操作,来原子地修改队列的
tail
指针,从而实现了一个无锁的队列操作,避免了为“管理等待线程”这件事本身再引入一把新锁。
- 当一个线程获取锁失败,需要进入等待队列时,AQS需要把它封装成一个
总结一下:
- 层次关系:CPU原子指令 -> CAS -> AQS -> JUC同步器(如ReentrantLock)。这是一个自底向上的构建关系。
- 依赖关系:AQS依赖CAS来保证其核心数据(
state
和等待队列)在并发修改时的原子性和线程安全性。没有CAS,AQS这个精巧的并发框架就无法搭建起来。
所以,我们可以说,CAS是AQS能够实现其所有功能的原子性保障和基石。
如何用 AQS实现一个可重入的公平锁?
面试官您好,我将结合具体的代码实现思路,来一步步构建它。
我们的目标是创建一个名为ReentrantFairLock
的类,它需要实现java.util.concurrent.locks.Lock
接口。
第一步:搭建整体框架 (外部类与内部Sync)
标准的做法是创建一个外部类ReentrantFairLock
,它负责暴露给用户lock()
、unlock()
等接口。其内部,我们会创建一个继承了AQS
的静态内部类,通常命名为Sync
,它来负责实现所有的同步逻辑。
import java.util.concurrent.locks.AbstractQueuedSynchronizer;
import java.util.concurrent.locks.Lock;public class ReentrantFairLock implements Lock {// 内部的AQS同步器实现private final Sync sync = new Sync();// Lock接口方法的实现,全部委托给内部的Sync来完成@Overridepublic void lock() {sync.acquire(1);}@Overridepublic void unlock() {sync.release(1);}// ... 其他Lock接口方法的实现,如tryLock, newCondition等 ...// ----------------- 核心的AQS实现 -----------------private static class Sync extends AbstractQueuedSynchronizer {// 在这里实现我们的核心逻辑}
}
第二步:实现tryAcquire
方法 (融合公平性与可重入性)
这是整个实现中最核心、最复杂的部分。我们需要在一个方法里,同时处理好公平性和可重入两个逻辑点。
// 在Sync类内部
@Override
protected boolean tryAcquire(int acquires) {final Thread currentThread = Thread.currentThread();int currentState = getState(); // 获取当前锁的重入次数if (currentState == 0) {// --- 情况1:锁未被持有,尝试获取 ---// 【公平性体现】: 在尝试获取锁之前,先检查等待队列中是否有比我更早的节点。// hasQueuedPredecessors()是AQS提供的方法,如果返回true,说明有线程比我先到,我必须排队。if (!hasQueuedPredecessors() && compareAndSetState(0, 1)) {// 如果队列里没人排我前面,并且我通过CAS成功设置state为1,说明我获取锁成功!setExclusiveOwnerThread(currentThread); // 记录下锁的持有者是我return true;}} else if (currentThread == getExclusiveOwnerThread()) {// --- 情况2:锁已被持有,检查是否是自己持有的(可重入性判断)---// 如果当前线程就是锁的持有者,那么这就是一次重入int nextState = currentState + 1;if (nextState < 0) { // 检查重入次数是否溢出throw new Error("Maximum lock count exceeded");}setState(nextState); // 增加重入次数return true;}// --- 情况3:获取锁失败 ---// (可能是state!=0且持有者不是我,或者state=0但我前面有人排队且CAS失败)return false; // 返回false,AQS框架会自动将我加入等待队列并挂起
}
这里的关键点,完美呼应了您的设计:
- 公平性:通过
!hasQueuedPredecessors()
这个前置条件,确保了只有在等待队列为空时,新来的线程才有机会去竞争锁。 - 可重入性:通过
currentThread == getExclusiveOwnerThread()
这个判断,实现了线程对自己持有锁的重入,并且用state
来计数。
第三步:实现tryRelease
方法
释放锁的逻辑相对简单,主要是减少重入计数,直到为0时才真正释放。
// 在Sync类内部
@Override
protected boolean tryRelease(int releases) {// 只有锁的持有者才能释放锁if (Thread.currentThread() != getExclusiveOwnerThread()) {throw new IllegalMonitorStateException();}int nextState = getState() - 1;boolean free = false;if (nextState == 0) {// 如果重入次数减到0,说明锁已完全释放free = true;setExclusiveOwnerThread(null); // 清除锁的持有者}setState(nextState); // 更新statereturn free; // 返回true,AQS框架会去唤醒等待队列的下一个节点
}
第四步:实现其他辅助方法
最后,再实现一些必要的辅助方法。
// 在Sync类内部
@Override
protected boolean isHeldExclusively() {// 判断当前线程是否是锁的独占持有者return getExclusiveOwnerThread() == Thread.currentThread();
}// 可以在外部类中提供isLocked等方法
public boolean isLocked() {return sync.isHeldExclusively();
}
通过以上四步,我们就实现了一个功能完备、逻辑清晰的可重入公平锁。这个过程充分展示了AQS作为同步器框架的强大威力:我们只需要专注于state
的含义以及tryAcquire
和tryRelease
的核心逻辑,而所有复杂的线程排队、挂起、唤醒工作,都由AQS框架为我们代劳了。
Threadlocal作用、核心原理、内部结构、潜在问题及解决方案
面试官您好,ThreadLocal
是Java中一个非常重要且独特的工具,我将从它的作用、核心原理、内部结构、潜在问题及解决方案这四个方面来详细阐述我的理解。
1. 作用:ThreadLocal
是用来做什么的?
ThreadLocal
的核心作用是提供线程内的局部变量。
- 一句话概括:它能在每个线程中都创建一个变量的副本,每个线程都只能操作自己的这个副本,从而实现了线程之间的数据隔离。
- **为什么需要它?**它的主要目的是解决在多线程环境下,既要共享对象,又要避免线程安全问题的场景。它提供了一种“空间换时间”的思路:不通过加锁来保证安全,而是为每个线程都提供一份独立的存储空间,让它们各用各的,互不干扰。
- 典型应用场景:
- 管理数据库连接:在DAO层,一个线程在处理一个请求的整个生命周期中,应该使用同一个数据库连接。我们可以把连接对象存入
ThreadLocal
,保证事务的一致性。 - 存储用户上下文信息:在Web应用中,一个请求对应一个线程。我们可以在请求入口处(如Filter或Interceptor)将用户信息存入
ThreadLocal
,那么在这个线程处理该请求的任何地方(Service、DAO等),都可以方便地获取到当前用户信息,避免了在方法间层层传递参数的麻烦。 SimpleDateFormat
的安全使用:SimpleDateFormat
是非线程安全的。每个线程可以通过ThreadLocal
持有一个自己的实例,从而安全地进行日期格式化。
- 管理数据库连接:在DAO层,一个线程在处理一个请求的整个生命周期中,应该使用同一个数据库连接。我们可以把连接对象存入
2. 核心原理:它是如何实现线程隔离的?
ThreadLocal
的实现原理非常巧妙,很多人会误以为是ThreadLocal
自身在存储数据,但实际上正好相反。
- 数据存储在线程中:每个
Thread
对象内部,都有一个名为threadLocals
的成员变量,它的类型是ThreadLocal.ThreadLocalMap
。这个Map
才是真正存储数据的地方。 ThreadLocal
作为Key:当我们通过一个ThreadLocal
对象(比如userThreadLocal
)来set
或get
数据时,这个ThreadLocal
对象本身,就充当了ThreadLocalMap
中的Key。- 工作流程:
- 调用
threadLocal.set(value)
时,它首先会获取当前线程(Thread.currentThread()
)。 - 然后从当前线程中取出它的
threadLocals
这个Map。 - 最后,以
threadLocal
对象自身为Key,要存的value
为Value,存入这个Map中。 - 调用
get()
时,过程类似,也是先拿到当前线程的Map,然后用threadLocal
对象作为Key去查找对应的Value。
- 调用
所以,ThreadLocal
本身不存数据,它只是一个“向导”或“钥匙”,帮助我们从当前线程自己的“小仓库”(ThreadLocalMap
)中存取数据。
3. 内部结构:ThreadLocalMap
的Key、Value和设计
ThreadLocalMap
:这是ThreadLocal
的一个静态内部类,它是一个定制版的HashMap
。Entry
:ThreadLocalMap
内部存储的是一个一个的Entry
对象,每个Entry
包含了Key和Value。- Key: 是一个对
ThreadLocal
对象的弱引用(WeakReference<ThreadLocal<?>>
)。 - Value: 是一个强引用,指向我们实际要存储的对象(比如用户信息、数据库连接)。
- Key: 是一个对
4. 潜在问题与解决方案:内存泄漏
这是ThreadLocal
最著名的面试考点。内存泄漏的风险,正是源于它ThreadLocalMap
中Entry
的特殊设计。
- 为什么会发生内存泄漏?
- 我们知道,
Entry
的Key(ThreadLocal
对象)是弱引用。这意味着,当外部不再有任何强引用指向这个ThreadLocal
对象时(比如userThreadLocal = null
),在下一次GC发生时,这个Key就会被回收,Entry
中的Key就变成了null
。 - 但是,
Entry
的Value是强引用。如果Key被回收了,而持有这个Entry
的线程又一直存活(比如线程池中的核心线程),那么这个Entry
本身以及它强引用的Value对象,就永远不会被回收,因为Thread
对象一直强引用着ThreadLocalMap
,ThreadLocalMap
又强引用着Entry
。 - 这就形成了一条 “断了头的”引用链:
Thread -> ThreadLocalMap -> Entry(key=null, value=强引用)
。这个强引用的value
就泄漏了。
- 我们知道,
ThreadLocal
的自我补救措施ThreadLocal
的设计者已经考虑到了这个问题。在调用get()
,set()
,remove()
等方法时,它会顺便检查ThreadLocalMap
中那些Key为null
的Entry
,并清理掉它们。- 但这是一种被动的、不确定的清理方式。如果一个
ThreadLocal
设置完值后,再也不被访问,那么这个清理机制就永远不会被触发。
- 我们的解决方案(最佳实践)
threadLocal.set(someValue);
try {// ... 使用 threadLocal ...
} finally {threadLocal.remove(); // 保证在任何情况下都能被清理
}
这样做,就能主动地将整个Entry
从ThreadLocalMap
中移除,彻底切断引用链,避免内存泄漏。
- 必须手动调用
remove()
方法! - 这是避免
ThreadLocal
内存泄漏的最根本、最可靠的办法。 - 我们应该养成一个好习惯:在
try-finally
代码块中使用ThreadLocal
,并在finally
块中,确保调用其remove()
方法。
如何跨线程传递 ThreadLocal 的值?
面试官您好,您提出的这个问题,直击了ThreadLocal
在现代异步编程中的一个核心痛点。常规的ThreadLocal
是无法跨线程传递值的,因为它的值严格与单个Thread
对象绑定。
要解决这个问题,正如您所说,主要有两种方案。
方案一:JDK原生的InheritableThreadLocal
(ITL)
这是Java提供的一个基础解决方案。
- 它的作用与原理:
InheritableThreadLocal
继承自ThreadLocal
。它的特殊之处在于,当一个线程(父线程)创建并启动一个新的子线程时,InheritableThreadLocal
会自动将父线程中存储的值,复制一份给子线程。- 这是如何实现的呢?在
Thread
类的构造函数中,有一个逻辑:如果父线程的inheritableThreadLocals
这个Map不为null
,它就会把这个Map中的所有值复制到新创建的子线程的inheritableThreadLocals
中。
- 适用场景:它只适用于那种 “父线程创建子线程” 的简单场景。
- 在线程池场景下的致命缺陷:
- 为什么会失效?线程池的核心机制是线程复用。当一个请求(我们称之为请求A)的父线程,通过线程池执行一个异步任务时,线程池可能会分配一个线程T1来执行,此时
InheritableThreadLocal
会将父线程的值复制给T1。 - 当T1执行完任务后,它不会被销毁,而是被归还给线程池。
- 随后,另一个完全不相关的请求(请求B)来了,它的父线程也提交了一个任务。线程池恰好又复用了刚才的线程T1来执行这个新任务。
- 问题来了:线程T1中依然保留着上一次请求A留下的、已经过时的
InheritableThreadLocal
值。这个新任务就会在不知情的情况下,使用了旧的、错误的数据,这会导致严重的数据污染和逻辑混乱。 - 所以,
InheritableThreadLocal
只在线程创建时复制一次,无法解决因线程复用带来的上下文传递问题。
- 为什么会失效?线程池的核心机制是线程复用。当一个请求(我们称之为请求A)的父线程,通过线程池执行一个异步任务时,线程池可能会分配一个线程T1来执行,此时
方案二:阿里巴巴的TransmittableThreadLocal
(TTL) (业界主流方案)
为了解决InheritableThreadLocal
在线程池等复杂异步场景下的问题,阿里巴巴开源了TransmittableThreadLocal
,它已经成为Java异步编程中传递上下文的事实标准。
- 它的核心思想:
- TTL并没有改变
ThreadLocal
的存储本质,而是通过代理/包装模式,在任务提交和任务执行这两个关键节点,手动地进行上下文的“捕获”与“恢复”。
- TTL并没有改变
- 工作流程:
- 装饰任务 (Decorate):当我们向线程池提交一个
Runnable
或Callable
任务之前,我们需要用TTL提供的工具类(如TtlRunnable.get(runnable)
)把它包装一下。 - 捕获上下文 (Capture):在包装的那一刻,TTL会**“捕获”当前父线程中所有TTL的值**,并把这些值存放在包装后的
TtlRunnable
对象内部。 - 提交任务:我们把这个包装后的
TtlRunnable
提交给线程池。 - 恢复上下文 (Replay):当线程池中的某个工作线程(比如T1)开始执行这个
TtlRunnable
时,在run()
方法执行之前,TTL会先执行一个“前置操作”:它将之前捕获的那个上下文,设置(replay) 到当前工作线程T1的ThreadLocal
里。 - 执行原始任务:然后,再执行我们原始的
run()
方法。此时,任务代码就能像在父线程中一样,正确地获取到上下文值了。 - 清理上下文 (Restore):在
run()
方法执行完毕后(无论正常结束还是抛异常),TTL会执行一个“后置操作”(通常在finally
块中),将T1的ThreadLocal
恢复到执行任务之前的状态,确保不会污染线程池中的这个线程。
- 装饰任务 (Decorate):当我们向线程池提交一个
- 如何与线程池集成:
- 为了避免每次提交任务都手动包装,TTL还提供了对
ExecutorService
的装饰器(TtlExecutors.getTtlExecutorService(executorService)
)。我们只需要用它包装一下我们创建的线程池,之后所有提交给这个线程池的任务,都会被自动地进行上下文的捕获和恢复。
- 为了避免每次提交任务都手动包装,TTL还提供了对
总结一下:
InheritableThreadLocal
:一个简单的、只适用于“父子线程创建”场景的工具。TransmittableThreadLocal
:一个强大的、通过“捕获-恢复”机制,完美解决了包括线程池在内的所有异步场景下上下文传递问题的工业级解决方案。在需要跨线程传递ThreadLocal
值的复杂项目中,使用TTL是必然的选择。
Java中想实现一个乐观锁,都有哪些方式?
面试官您好,在Java中实现乐观锁,其核心思想都是“在更新时不加锁,在提交时检查冲突”。根据实现层面的不同,我通常会把实现方式分为两大类:底层原子操作和上层业务逻辑实现。
第一类:底层原子操作 —— CAS (Compare-And-Swap)
这是最基础、最高效的乐观锁实现,是很多JUC工具的基石。
- 实现方式:
- CAS操作包含三个核心参数:内存地址V、预期原值A、新值B。它会原子地执行以下逻辑:如果内存地址V处的值等于预期原值A,那么就将该位置的值更新为新值B,并返回成功;否则,不做任何操作,并返回失败。
- 在Java中,我们通常不直接使用底层的CAS指令,而是使用
java.util.concurrent.atomic
包下的原子类,如AtomicInteger
、AtomicLong
、AtomicReference
等。这些类已经为我们封装好了CAS操作。
- 应用场景:
- 非常适合对单个共享变量进行原子更新的场景,比如实现一个线程安全的计数器、状态标记等。
- 例如,要实现一个无锁的计数器,只需使用
AtomicInteger
的incrementAndGet()
方法,其内部就是一个基于CAS的循环,不断尝试更新,直到成功。
- 优缺点:
- 优点:性能极高,因为它避免了线程阻塞和上下文切换。
- 缺点:
- ABA问题:如果一个值从A变为B,又变回了A,CAS会认为它没变过,但实际上它已经被修改了。虽然在大多数数值场景下ABA问题不影响结果,但在一些复杂的链式数据结构中可能是致命的。可以用
AtomicStampedReference
来解决,它额外增加了一个“版本戳”。 - 循环开销:如果竞争非常激烈,线程会长时间处于CAS自旋重试的状态,这会消耗大量CPU资源。
- 只能保证单个变量的原子性:对于涉及多个共享变量的复杂操作,CAS就无能为力了。
- ABA问题:如果一个值从A变为B,又变回了A,CAS会认为它没变过,但实际上它已经被修改了。虽然在大多数数值场景下ABA问题不影响结果,但在一些复杂的链式数据结构中可能是致命的。可以用
第二类:上层业务逻辑实现 —— 版本号与时间戳
当我们需要保护的是数据库中的一行记录,或者一个复杂的业务对象时,就需要通过在业务逻辑层面引入标识来实现了。
- 1. 版本号机制 (Versioning)
- 实现方式:这是最常用、最推荐的业务乐观锁实现。
- 在数据表或对象中增加一个整型或长整型的
version
字段。 - 读取数据时:将这个
version
字段的值一并读出。 - 更新数据时:在提交更新的SQL或操作中,将之前读到的
version
值作为更新条件之一。同时,将version
字段的值加一。 - SQL示例:
UPDATE products SET stock = stock - 1, version = version + 1 WHERE id = 123 AND version = 5;
- 在数据表或对象中增加一个整型或长整型的
- 工作流程:如果SQL执行后,返回的影响行数为1,说明更新成功(因为
version
匹配)。如果返回0,说明在我更新的这段时间里,数据已经被其他线程修改了(version
已经不是5了),本次更新失败。此时,业务代码可以根据需要选择重试(重新读取数据和新version
,再尝试更新)或向用户报错。 - 优点:非常可靠,逻辑清晰,能有效防止ABA问题,是业界标准实践。MyBatis-Plus等框架都内置了对乐观锁的支持。
- 实现方式:这是最常用、最推荐的业务乐观锁实现。
- 2. 时间戳机制 (Timestamping)
- 实现方式:原理与版本号类似,只是把
version
字段换成了一个timestamp
类型的字段,记录数据的最后修改时间。- SQL示例:
UPDATE products SET stock = stock - 1 WHERE id = 123 AND last_update_time = '2023-10-27 10:30:00';
- SQL示例:
- 优缺点:
- 优点:天然具有时间属性,能直观地看出数据的最后更新时间。
- 缺点:
- 精度问题:在并发量极高的情况下,可能会有多个事务在同一毫秒内发生,时间戳无法区分它们的先后顺序,可能导致更新冲突检测失效。
- 服务器时间同步问题:在分布式系统中,不同服务器的系统时钟可能存在微小差异,依赖时间戳可能会导致逻辑混乱。
- 结论:由于存在这些问题,版本号机制通常是比时间戳机制更健壮、更受推荐的选择。
- 实现方式:原理与版本号类似,只是把
总结一下,在Java中实现乐观锁:
- 如果是对内存中的单个变量进行同步,我会首选基于CAS的原子类。
- 如果是对数据库记录或复杂业务对象进行同步,我一定会选择版本号机制,这是最可靠和标准的做法。
Java 中 CAS 是如何实现的?
面试官您好,Java中的CAS(Compare-And-Swap)操作,它的实现是一个跨越了Java层、JVM层和硬件层的协作过程。我们可以自顶向下地来看它是如何实现的。
第一层:Java API层 ——sun.misc.Unsafe
类
在Java代码层面,我们通常不直接使用CAS,而是使用JUC包下的一些工具类,比如AtomicInteger
。我们以AtomicInteger.compareAndSet(int expect, int update)
为例,它的源码最终会调用到一个非常特殊的类——sun.misc.Unsafe
。
Unsafe
类:这个类位于sun.misc
包下,它提供了非常底层的、类似于C语言指针一样直接操作内存的能力。它绕过了Java的很多安全检查,因此被称为“不安全的”。但它也是实现Java中很多高性能操作的基石。Unsafe
类中提供了一系列compareAndSwap*
的native
方法,例如compareAndSwapInt
,compareAndSwapLong
等。这些方法就是Java层调用CAS操作的入口。
// AtomicInteger的compareAndSet方法源码(简化后)
public final boolean compareAndSet(int expect, int update) {// this: 当前AtomicInteger对象// valueOffset: value字段在对象内存中的偏移地址// expect: 期望值// update: 新值return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
这里的valueOffset
是一个静态变量,它在AtomicInteger
类加载时通过Unsafe.objectFieldOffset()
方法计算出来,代表了volatile int value
这个字段相对于对象起始地址的偏移量。
第二层:JVM内部实现层 ——native
方法的C++实现
Unsafe.compareAndSwapInt
是一个native
方法,它的具体实现是在JVM的C++源码中。
- 当Java代码调用这个
native
方法时,会进入JVM内部。 - JVM的C++代码会根据当前的操作系统和CPU架构,生成一段与平台相关的汇编代码。这段汇编代码最终会调用CPU提供的原子指令。
- 例如,在HotSpot JVM的源码中,这部分逻辑会最终导向一个名为
Atomic::cmpxchg
的C++函数。
第三层:操作系统与CPU硬件层 —— 原子指令
这是CAS操作能够保证原子性的根本所在。
Atomic::cmpxchg
这个C++函数,最终会调用一个CPU硬件级别的原子指令。- 在不同的CPU架构上,这个指令的名字可能不同:
- 在最常见的x86/x64架构(Intel, AMD)上,这个指令是
cmpxchg
(Compare and Exchange),并且通常会配合一个lock
前缀(如lock cmpxchg
)来保证其在多核环境下的原子性。lock
前缀的作用是锁定总线或缓存行,确保在指令执行期间,其他CPU核心不能访问这块内存,从而保证了操作的原子。 - 在其他架构上,可能有类似的指令,比如
LL/SC
(Load-Linked/Store-Conditional)。
- 在最常见的x86/x64架构(Intel, AMD)上,这个指令是
- 这个CPU指令的执行是不可中断的,它在一个时钟周期内完成“比较内存值和期望值”以及“如果相等则写入新值”这两个动作。这从硬件层面保证了CAS操作的原子性。
总结:整个调用链
所以,Java中CAS的实现可以总结为这样一条调用链:
JavaAtomicInteger.compareAndSet()
↓
sun.misc.Unsafe.compareAndSwapInt()
(native方法)
↓
JVM内部的C++实现 (如Atomic::cmpxchg
)
↓
操作系统层面的汇编指令
↓
CPU硬件的原子指令 (如lock cmpxchg
)
正是通过这样一层层的调用,Java才得以利用CPU硬件提供的原子能力,来实现高效、无锁的并发操作。这个设计充分体现了Java作为一种高级语言,是如何与底层硬件紧密协作的。
voliatle关键字有什么作用?
面试官您好,volatile
是Java中一个非常重要的关键字,它是一个轻量级的同步机制。它的核心作用主要体现在两个方面:
1. 保证可见性 (Visibility)
这是volatile
最直观的作用。
- 问题背景:在多核CPU架构下,每个线程都有自己的高速缓存(工作内存)。当一个线程修改共享变量时,它可能只是先更新了自己的缓存,而没有立即写回主内存。这就导致其他线程无法立即看到这个变更。
volatile
如何解决:- 对
volatile
变量的写操作:JVM会确保这个写操作的结果立即被刷新到主内存中。 - 对
volatile
变量的读操作:JVM会确保每次都从主内存中读取最新值,而不是使用线程自己的缓存。
- 对
- 通过这一写一读的强制规定,
volatile
就保证了共享变量在多线程之间的可见性。一个线程的修改,对其他线程来说是“立即可见”的。
2. 禁止指令重排序 (Ordering)
这是volatile
一个更底层、也更关键的作用,它保证了程序的有序性。
- 问题背景:为了提升性能,编译器和CPU可能会在不影响单线程最终结果的前提下,对指令的执行顺序进行调整,这就是指令重排序。但在多线程环境下,这种重排序可能会破坏程序的逻辑。
volatile
如何解决:volatile
通过在底层插入 内存屏障(Memory Barriers/Fences) 来禁止特定类型的指令重排序。具体来说:- 在一个
volatile
写操作之前,会插入一个StoreStore
屏障,确保它前面的所有普通写操作都已完成,不会被重排到它后面。 - 在一个
volatile
写操作之后,会插入一个StoreLoad
屏障,这是一个开销很大的屏障,它确保了volatile
的写对所有后续的读操作都可见,并且防止了后续的读被重排到它前面。 - 在一个
volatile
读操作之后,会插入一个LoadLoad
和LoadStore
屏障,确保它后面的所有读写操作都不会被重排到它前面。
- 在一个
- 通过这些内存屏障,
volatile
为我们建立了一个清晰的“happens-before”关系,保证了程序的执行顺序。
一个经典的实践场景:双重检查锁定(DCL)单例模式
volatile
在禁止指令重排序上的作用,在DCL单例模式中体现得淋漓尽致。
public class Singleton {private static volatile Singleton instance; // 【关键点】必须有 volatileprivate Singleton() {}public static Singleton getInstance() {if (instance == null) { // 第一次检查synchronized (Singleton.class) {if (instance == null) { // 第二次检查instance = new Singleton();}}}return instance;}
}
这里的关键在于instance = new Singleton();
这行代码,它并非原子操作,大致可以分为三步:
- 分配内存空间。
- 初始化对象。
- 将
instance
引用指向分配的内存地址。
如果没有volatile
,由于指令重排序,这三步的顺序可能变成1 -> 3 -> 2。
- 这会发生什么?一个线程T1执行到第3步时,
instance
已经不为null
了,但对象还没有被初始化。 - 此时,另一个线程T2调用
getInstance()
,它执行到第一次检查if (instance == null)
,发现instance
不为null
,于是直接返回了一个**“半初始化”**的、不完整的对象。当T2使用这个对象时,就会出现不可预料的错误。 volatile
关键字通过禁止这里的指令重排序,确保了操作必须严格按照1 -> 2 -> 3的顺序执行,从而杜绝了这个问题。
最后,必须强调volatile
的局限性
volatile
虽然强大,但它不能保证原子性。对于像i++
这样的复合操作(读-改-写),volatile
是无能为力的。在这种场景下,我们仍然需要使用synchronized
、Lock
或者AtomicInteger
来保证操作的原子性。
指令重排序的原理是什么?
面试官您好,指令重排序是现代处理器和编译器为了最大化指令级并行度、提升程序性能而采用的一种非常重要的优化手段。
它的原理可以从 “为什么能提效”和“重排序的规则” 这两个角度来理解。
1. 为什么指令重排序能提高性能?(The “Why”)
这主要是为了解决CPU和内存之间速度严重不匹配的问题,以及充分利用CPU内部的执行单元。
- CPU流水线与指令级并行:现代CPU都采用流水线技术来执行指令(取指、译码、执行、访存、写回)。如果指令之间严格按照代码顺序执行,一旦某条指令因为需要等待内存数据而“停顿”(stall),整个流水线都会被阻塞。
- 重排序的作用:重排序允许CPU在等待某条“慢指令”(如访存)时,先去执行后面那些不依赖于这条慢指令结果的“快指令”(如纯计算)。这样,CPU的执行单元就不会被闲置,整体的执行效率就大大提高了。可以把它想象成:你在等水烧开的时候,先去把菜洗了,而不是干等着。
2. 指令重排序的规则与限制 (The “How”)
重排序并不是随心所欲的,它必须遵循严格的规则,以保证程序的正确性。这些规则,正如您所说,核心有两点:
- 数据依赖性(Data Dependency):如果两个操作之间存在数据依赖关系,那么它们之间的顺序是绝对不能被重排序的。
- 什么是数据依赖? 简单说,就是后一个操作的输入,依赖于前一个操作的输出。比如:
int a = 1; // 写a
int b = a + 1; // 读a, 写b
这里,b
的计算依赖于a
的值,所以这两条指令之间存在数据依赖,它们的顺序永远不会被颠倒。
- as-if-serial 语义:这是对单线程程序的最终承诺。它规定,不管编译器和处理器如何对指令进行重排序,最终执行的结果必须和代码顺序执行的结果完全一样。
- 这个例子就很经典:
int a = 1; // 指令A
int b = 2; // 指令B
int c = a + b; // 指令C
根据数据依赖,A和C、B和C之间有依赖,不能重排。但A和B之间没有数据依赖,编译器和处理器就可以自由地将它们的顺序重排为 B -> A -> C。但无论怎么排,对于单线程来说,最终c
的结果永远是3,as-if-serial
语义得到了保证。
3. 重排序对多线程的影响与happens-before
as-if-serial
只保证了单线程的正确性,但在多线程环境下,这种看似无害的重排序就可能引发严重问题。
- 多线程下的问题:一个线程的重排序,可能会被另一个线程“观察”到,从而导致逻辑错误。最经典的例子就是使用
volatile
解决DCL单例问题,其根本就是为了禁止new Singleton()
内部的指令重排序。 - Java的解决方案:Happens-Before 原则
- 为了让程序员在多线程环境下能有一个可预期的、统一的内存模型,Java内存模型(JMM)提出了
happens-before
原则。 - 它是一系列规则的集合,定义了在多线程中,如果操作A
happens-before
操作B,那么A操作的结果对B操作是可见的,并且A操作的执行顺序在B操作之前。 - JMM向我们程序员承诺:只要两个操作之间存在
happens-before
关系,JVM就保证不会对它们进行重排序,并且保证前者的内存可见性。 - 我们平时用的
volatile
、synchronized
、Lock
、线程的start()
和join()
等,它们的同步语义,本质上都是在为我们建立happens-before
关系。
- 为了让程序员在多线程环境下能有一个可预期的、统一的内存模型,Java内存模型(JMM)提出了
总结一下,指令重排序是为了性能优化,它在保证单线程as-if-serial
语义和数据依赖不被破坏的前提下进行。但在多线程中,我们需要依赖volatile
、synchronized
等工具来建立 happens-before
关系,从而显式地告诉JVM哪些地方是“不许重排序的”,以此来保证多线程程序的正确性。
volatile可以保证线程安全吗?
面试官您好,关于volatile
能否保证线程安全,我的回答是:不能完全保证,它只能在特定的场景下保证线程安全。
volatile
关键字就像一把“轻量级的锁”,它提供了两个非常重要的特性:
- 保证可见性:这是
volatile
最核心的能力。它确保一个线程对volatile
变量的修改,能立即被其他线程看到。 - 保证有序性:它通过禁止指令重排序,来确保程序的执行顺序。
但是,volatile
有一个致命的短板,它无法保证原子性。
为什么volatile
不保证原子性?—— 以i++
为例
我们来看一个经典的多线程反例:volatile int i = 0;
,多个线程同时执行i++
。
i++
这个操作,看起来只是一行代码,但它在底层实际包含了三个步骤:
- 读取
i
的当前值(比如从主内存读到工作内存)。 - 在工作内存中对值进行加1操作。
- 将新值写回主内存。
现在,假设有两个线程A和B,初始时i
为0。
- 线程A读取了
i
的值(0)。 - 在线程A准备执行加1操作时,CPU时间片切换,线程B开始执行。
- 线程B也读取了
i
的值(因为A还没写回去,所以B读到的也是0)。 - 线程B执行了加1操作,并将结果1写回了主内存。此时主内存中的
i
是1。 - 线程A重新获得CPU,它基于自己之前读到的旧值0,执行加1操作,得到结果1,然后也将1写回主内存。
最终的结果是i
为1,但我们期望的结果应该是2。
在这个过程中,volatile
确实保证了线程B写回1后,线程A能立刻看到主内存的变化。但问题是,线程A已经完成了第一步“读取”操作,它不会因为主内存变了就重新去读。它会基于自己缓存的旧值继续计算,导致了线程不安全。
所以,volatile
只能保证在“读”和“写”这两个单一步骤上的可见性,但无法保证“读-改-写”这一系列复合操作的原子性。
那么,volatile
到底在什么场景下是线程安全的?
volatile
只有在满足以下两个条件时,才能保证线程安全:
- 对变量的写操作不依赖于其当前值。
- 这意味着,我们不能有像
i++
这样需要先读取旧值的操作。我们可以直接set
一个新值,比如running = false;
。
- 这意味着,我们不能有像
- 该变量没有包含在具有其他变量的不变式中。
- 这意味着,这个变量的状态是完全独立的,它的值不依赖于其他变量,其他变量的值也不依赖于它。
最典型的、也是最适合使用volatile
的场景,就是作为一个状态标记来控制线程的可见性。
public class TaskRunner implements Runnable {private volatile boolean running = true; // 使用volatile作为状态标记@Overridepublic void run() {while (running) {// ... 执行任务 ...}System.out.println("任务结束.");}public void shutdown() {this.running = false; // 从外部线程改变状态}
}
在这个例子中,shutdown()
方法对running
的写操作,不依赖running
的当前值,并且running
是独立的。因此,使用volatile
可以完美地、轻量级地保证线程安全。
结论
综上所述,volatile
是保证线程安全的“必要不充分条件”。它能解决可见性和有序性问题,但解决不了原子性问题。因此,在需要保证复合操作原子性的场景下,我们必须使用synchronized
、Lock
或者JUC
下的原子类(如AtomicInteger
)。
volatile和sychronized比较?
面试官您好,synchronized
和volatile
是Java并发编程中两个非常重要但定位完全不同的关键字。它们都用于解决多线程问题,但解决问题的角度和重量级完全不同。
我可以从以下几个核心维度来对它们进行比较:
1. 作用与能力 (功能覆盖范围)
synchronized
: 这是一个重量级的、全面的同步机制。它能同时保证三大特性:- 原子性:确保被它修饰的代码块在同一时间只能被一个线程执行。
- 可见性:保证在解锁前,对共享变量的修改会刷新到主内存,对其他线程可见。
- 有序性:同样能防止指令重排序。
- 简单来说,
synchronized
提供的是一种互斥访问的解决方案。
volatile
: 这是一个轻量级的同步机制,它的能力相对专一:- 保证可见性:这是它的核心能力,确保一个线程的修改能被其他线程立即看到。
- 保证有序性(通过禁止指令重排序)。
- 但它无法保证原子性。对于像
i++
这样的复合操作,volatile
是无能为力的。
一句话总结能力区别:synchronized
是“全能选手”,能保证原子、可见、有序;而volatile
是“特长生”,只负责可见和有序。
2. 实现原理与底层机制
synchronized
: 它是基于**JVM内置的监视器锁(Monitor)**实现的。它涉及到锁的获取和释放,背后有复杂的锁升级过程(偏向锁->轻量级锁->重量级锁)。在竞争激烈时,会涉及到线程的阻塞和唤醒,需要从用户态切换到内核态,开销较大。volatile
: 它的实现不依赖于锁,而是通过内存屏障(Memory Barriers)和CPU的缓存一致性协议来保证可见性和有序性。它不会导致线程阻塞,因此开销比synchronized
小得多。
3. 使用方式与范围
synchronized
: 它可以修饰方法(实例方法或静态方法)和代码块。它的作用范围是整个方法或指定的代码块。volatile
: 它只能修饰成员变量(实例变量或静态变量)。它的作用范围仅限于对这个变量的读写操作。
4. 性能开销
synchronized
: 由于可能涉及到线程上下文切换,它的性能开销相对较大,属于重量级操作。当然,现代JVM通过锁升级等优化,已经极大地降低了其在无竞争或低竞争场景下的开销。volatile
: 它的开销主要来自插入内存屏障和禁用缓存,相比synchronized
要小得多,属于轻量级操作。
总结与选型建议
对比维度 | synchronized | volatile |
---|---|---|
保证特性 | 原子性、可见性、有序性 | 可见性、有序性(无原子性) |
使用范围 | 方法、代码块 | 成员变量 |
底层实现 | JVM监视器锁 (Monitor) | 内存屏障 |
是否阻塞线程 | 是 (竞争激烈时) | 否 |
性能开销 | 较重 | 较轻 |
基于以上对比,我的选型策略非常明确:
- 能用
volatile
就不用synchronized
:如果我只是需要保证一个状态标记(如volatile boolean running
)在多线程间的可见性,并且对这个变量的写操作不依赖于它的当前值,那么volatile
就是最佳选择,因为它更轻量、性能更好。 - 需要保证原子性时,必须用
synchronized
(或Lock
,Atomic
类):当我要保护的是一个复合操作(如i++
、或者对多个变量的修改),或者需要实现互斥访问一段复杂的业务逻辑时,volatile
就完全不够用了,我必须使用synchronized
或JUC下的其他同步工具来保证操作的原子性。
简单来说,就是用最轻量、最合适的工具去解决恰好对应的问题。
什么是公平锁和非公平锁?
面试官您好,公平锁和非公平锁是锁的两种不同实现策略,它们的核心区别在于处理线程获取锁的顺序和机会。
1. 核心定义与行为差异
- 公平锁 (Fair Lock):非常“讲规矩”,遵循 先来后到(FIFO) 的原则。
- 行为:当一个线程请求锁时,如果锁是空闲的,它会先检查等待队列中是否有其他线程在排队。如果有,它就必须乖乖地到队尾去排队。只有当它排到队头时,它才有机会获取锁。
- 一句话概括:所有线程都必须排队,不允许插队。
- 非公平锁 (Non-fair Lock):比较“霸道”,信奉 “先抢了再说” 的原则。
- 行为:当一个线程请求锁时,不管等待队列里有没有其他线程,它都会首先尝试直接获取锁(插队)。如果运气好,正好锁被释放,它就直接抢占成功。只有在抢占失败后,它才会无可奈何地到队尾去排队。
- 一句话概括:新来的线程可以插队,抢不到再排队。
2. 优缺点与性能对比
正如您所说,这两种策略带来了截然不同的优缺点:
特性 | 公平锁 | 非公平锁 |
---|---|---|
优点 | 公平,能避免线程饥饿,所有等待的线程最终都能获取到锁。 | 吞吐量高,整体性能更好。 |
缺点 | 性能相对较低,吞吐量小,线程切换更频繁。 | 可能会导致线程饥饿,队列中的某些线程可能长时间得不到执行。 |
3. 为什么非公平锁通常性能更好?
这是一个关键的理解点。非公平锁之所以快,主要是因为它减少了线程的上下文切换次数。
- 我们来想象一个场景:线程A刚刚释放了锁,此时等待队列的队头是线程B,同时,一个新的线程C也正好来请求锁。
- 在公平锁模式下:必须唤醒线程B(这涉及到一次从内核态到用户态的上下文切换),让线程B获取锁。线程C则直接进入队列排队。
- 在非公平锁模式下:线程C会先尝试抢占。因为它此刻正在CPU上运行,它很有可能在线程B被完全唤醒之前,就成功地获取了锁。这样,就避免了唤醒线程B的这次昂贵的上下文切换。线程C直接干完活释放锁,然后可能又被下一个正在运行的线程抢到。
- 这种“连续获取”减少了CPU在线程挂起和唤醒之间的空档期,使得CPU的利用率更高,从而提升了整体的吞吐量。
4. 在Java中的具体实现
ReentrantLock
:它是一个非常灵活的实现,在构造时允许我们自己选择。new ReentrantLock()
或new ReentrantLock(false)
:创建的是非公平锁(默认)。new ReentrantLock(true)
:创建的是公平锁。
synchronized
:它是一种内置的、非公平锁。它不提供公平性的选择,其设计更侧重于性能和吞吐量。
5. 选型建议
基于以上分析,我的选型策略是:
- 在绝大多数情况下,使用默认的非公平锁。因为更高的吞吐量和性能通常是我们更关心的。线程饥饿问题在大多数业务场景中并不常见,或者影响不大。
- 只有在特定业务场景下,如果需要严格保证所有线程都能获得执行机会,防止某些重要任务被“饿死”时,我才会考虑使用公平锁。比如,一个任务调度系统,如果某些任务长时间得不到执行会导致业务失败,那么使用公平锁就是一种保障。
总而言之,公平与非公平是一种在 “公平性”和“性能” 之间的权衡,我们需要根据具体的业务需求来做出最合适的选择。
ReentrantLock是怎么实现公平锁的?
面试官您好,ReentrantLock
之所以能够实现公平锁,其秘诀完全在于它内部的AQS实现——FairSync
类,在尝试获取锁(tryAcquire
)的逻辑中,增加了一个关键的“排队检查”。
ReentrantLock
的公平性与非公平性,是通过内部两个不同的静态类来实现的:FairSync
(公平)和NonfairSync
(非公平),它们都继承自AQS。
下面,我来对比一下它们在核心方法tryAcquire
(由lock
方法调用)上的实现差异,这样就能清晰地看出公平性是如何实现的。
1. 非公平锁 (NonfairSync
) 的tryAcquire
逻辑
非公平锁的逻辑非常直接和“霸道”:
- 直接尝试抢占:当一个线程调用
lock()
时,它会一上来就直接尝试用CAS操作去获取锁(即将AQS的state
从0变为1)。 - 检查可重入:如果CAS失败(说明锁已被持有),它会接着检查当前持有锁的线程是不是自己。如果是,就增加重入次数。
- 宣告失败:如果以上两步都失败了(锁被别人持有,且不是自己),它才会返回
false
,然后由AQS框架将它加入等待队列。
核心特点:它给了新来的线程一个“插队”的机会,这就是“非公平”的体现。
2. 公平锁 (FairSync
) 的tryAcquire
逻辑
公平锁的逻辑则要“守规矩”得多,它在非公平锁的基础上,增加了一个至关重要的前置检查。
// 这是ReentrantLock中FairSync.tryAcquire的源码逻辑简化版
protected final boolean tryAcquire(int acquires) {final Thread current = Thread.currentThread();int c = getState();if (c == 0) {// 【关键差异点】在尝试CAS获取锁之前,先进行排队检查!if (!hasQueuedPredecessors() && compareAndSetState(0, 1)) {// 如果队列里没有比我更早的等待者,并且CAS成功,才算获取锁成功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;
}
我们可以看到,公平锁的tryAcquire
与非公平锁的核心差异就在于那个 !hasQueuedPredecessors()
的判断。
hasQueuedPredecessors()
:这是AQS提供的一个方法,它的作用是判断当前线程的节点之前,在等待队列中是否还有其他节点。简单来说,就是检查“有没有人在我前面排队”。- 公平锁的流程:
- 当一个线程来请求锁时,即使
state
是0(锁是空闲的),它也不会立即去CAS抢占。 - 它会**首先调用
hasQueuedPredecessors()
**来检查自己是不是队列的“准队头”(即前面没有人排队了)。 - 只有当它发现自己前面没有人排队时,它才会去尝试CAS获取锁。
- 如果它发现前面已经有其他线程在排队,那么即使锁是空闲的,它也会放弃获取,返回
false
,然后乖乖地被AQS框架加入到队尾。 - 可重入的逻辑和非公平锁是一样的,因为一个线程已经持有了锁,它进行重入是天经地义的,不算破坏公平性。
- 当一个线程来请求锁时,即使
总结
所以,ReentrantLock
实现公平锁的核心就在于:
通过在尝试获取锁(CAS)之前,增加一个hasQueuedPredecessors()
的前置判断,强制所有请求锁的线程都必须先检查等待队列。如果队列中已有等待者,新来的线程就必须排队,从而保证了锁的获取严格遵循FIFO的顺序。
这个小小的判断,就构成了公平与非公平的本质区别。
什么情况会产生死锁问题?如何解决?
面试官您好,死锁是并发编程中一个非常棘手的问题。我对它的理解包含三个层面:它产生的四个必要条件、如何预防和避免,以及一旦发生后如何排查。
第一部分:死锁产生的四个必要条件
一个死锁的产生,必须同时满足以下四个条件,缺一不可:
- 互斥条件 (Mutual Exclusion):一个资源在同一时刻只能被一个线程持有。这是锁的基本特性,无法破坏。
- 持有并等待条件 (Hold and Wait):一个线程在已经持有一个资源的情况下,又去请求另一个被其他线程持有的资源,并且在等待时,它并不释放自己已持有的资源。
- 不可剥夺条件 (No Preemption):线程已经获得的资源,在未使用完毕之前,不能被其他线程强行剥夺,只能由持有者自己主动释放。
- 环路等待条件 (Circular Wait):存在一个线程资源的环形等待链。比如,线程A等待线程B的资源,线程B又在等待线程A的资源,形成了一个闭环。
第二部分:如何预防和避免死锁 (How to Solve)
理解了产生的条件,我们就可以通过破坏后三个条件来预防和避免死锁。
- 破坏“持有并等待”条件
- 策略:实行 “一次性申请” 策略。要求线程在执行前,一次性地申请它所需要的所有资源。如果不能同时获得所有资源,就一个也不要,或者先释放已有的,等待下次机会。
- 实践:这种方式在某些场景下可行,但实现起来比较复杂,且可能降低资源的利用率,因为线程可能在很长一段时间内持有它暂时用不到的资源。
- 破坏“不可剥夺”条件
- 策略:允许资源被“剥夺”。当一个线程请求的资源被占用而无法获取时,它可以主动释放掉自己已经持有的资源,而不是死等。
- 实践:
ReentrantLock
的tryLock(long timeout, TimeUnit unit)
方法就是这种思想的体现。线程在指定时间内尝试获取锁,如果获取不到,就先放弃,去做别的事情或者稍后重试,而不是一直阻塞。这给了我们编码上的灵活性来避免死等。
- 破坏“环路等待”条件(这是最常用、最有效的策略)
- 策略:实行 “按序加锁”策略。规定所有线程在需要获取多个锁时,都必须按照一个固定的、全局统一的顺序来申请。
- 实践:这是我们日常开发中最常用、最简单的避免死锁的方法。例如,系统中有
Lock A
和Lock B
,我们规定,任何需要同时获取这两把锁的线程,都必须先获取A,再获取B。这样就从根本上打破了形成环路的可能性。 - 代码示例:
// 反面教材:可能导致死锁
// 线程1
synchronized(lockA) {synchronized(lockB) { ... }
}
// 线程2
synchronized(lockB) {synchronized(lockA) { ... }
}// 正确做法:按序加锁
// 线程1 和 线程2 都遵循先A后B的顺序
synchronized(lockA) {synchronized(lockB) { ... }
}
第三部分:死锁发生后的排查
如果线上系统不幸发生了死锁,我们需要有能力去快速定位它。
- 使用
jps
和jstack
命令:- 首先,使用
jps -l
找到疑似发生死锁的Java进程的ID(PID)。 - 然后,执行
jstack <PID>
命令,打印出该进程的所有线程堆栈信息。 jstack
非常智能,它会在输出的末尾,自动地检测并报告死锁。它会清晰地指出哪个线程持有了哪个锁,正在等待哪个锁,以及哪个线程造成了死锁的循环等待。
- 首先,使用
- 使用图形化工具:
- 像JConsole、VisualVM等JDK自带的图形化监控工具,都提供了线程监控和死锁检测的功能。连接到目标Java进程后,在“线程”选项卡里,通常会有一个“检测死锁”的按钮,点击后就能直观地看到死锁信息。
通过分析jstack
的输出或工具的报告,我们就能快速定位到导致死锁的代码位置,然后根据上面提到的预防策略去修复它。
共享锁和独占锁有什么区别?
面试官您好,共享锁和独占锁是两种不同维度的锁类型,它们的核心区别,正如您所说,在于同一时刻允许多个还是只允许一个线程持有锁。
1. 核心定义
- 独占锁 (Exclusive Lock):也叫互斥锁或写锁。它的特点是独占,即在任何时候,最多只能有一个线程持有该锁。当一个线程持有独占锁时,其他任何线程(无论想获取独占锁还是共享锁)都必须等待。
- 共享锁 (Shared Lock):也叫读锁。它的特点是共享,即在任何时候,可以有多个线程同时持有该锁。
2. 典型的实现与应用场景
在Java的JUC包中,这两种锁有非常典型的实现:
ReentrantLock
:它就是一种典型的独占锁。它的设计目的就是为了实现资源的互斥访问,保证在临界区内代码的原子性。- 应用场景:任何需要修改共享资源的场景,比如更新账户余额、修改订单状态等。只要涉及到“写”操作,通常都需要用独占锁来保证数据的一致性。
ReentrantReadWriteLock
(读写锁):这是一个非常巧妙的设计,它同时包含了两种锁:一个读锁(readLock()
)和一个写锁(writeLock()
)。- 它的读锁就是一种共享锁。
- 它的写锁就是一种独占锁。
- 应用场景:它专门用于优化**“读多写少”**的并发场景。比如,一个系统的配置缓存,绝大多数时间都是在被各个线程读取,偶尔才会有一次后台更新。在这种场景下:
- 多个线程可以同时获取读锁来并发地读取缓存,极大地提高了系统的吞吐量。
- 当有线程需要更新缓存时,它会获取写锁。一旦写锁被持有,所有其他线程(包括读线程和写线程)都必须等待,保证了更新的原子性和数据一致性。
3. 互斥关系总结
我们可以用一个表格来清晰地总结它们之间的互斥关系(以ReentrantReadWriteLock
为例):
已持有锁 \ 尝试获取锁 | 读锁 (共享) | 写锁 (独占) |
---|---|---|
无锁 | 成功 | 成功 |
读锁 (共享) | 成功(读读不互斥) | 失败(读写互斥) |
写锁 (独占) | 失败(写读互斥) | 失败(写写互斥) |
这个规则的核心就是:读读可以共享,但只要有写操作,就必须独占。
4. 其他领域的应用
这种“共享/独占”的思想,并不仅仅局限于JUC包。在数据库领域,它也是一个核心概念:
- 数据库的行级锁或表级锁,也分为共享锁(S锁)和排他锁(X锁)。
- 多个事务可以同时对同一行数据持有S锁(用于
SELECT
),但只要有一个事务想获取X锁(用于UPDATE
/DELETE
),就必须等待所有其他锁被释放。
总结一下,独占锁是为了解决 “写”操作的互斥问题,保证数据修改的安全性;而共享锁则是为了在保证安全的前提下,优化“读”操作的并发性能。在实际开发中,正确地识别业务场景是读多写少还是写多读多,并选择合适的锁类型,是并发性能优化的一个关键环节。
线程持有读锁还能获取写锁吗?
面试官您好,您提出的这个问题非常关键,它触及了ReentrantReadWriteLock
设计中的一个核心原则。
您的结论是完全正确的:
- 一个持有读锁的线程,不能再获取写锁。
- 一个持有写锁的线程,可以再获取读锁。
这种机制在ReentrantReadWriteLock
中,被称为支持锁降级,但不支持锁升级。
1. 为什么支持“锁降级”(写锁 -> 读锁)?
- 锁降级:指的是一个线程在持有写锁的情况下,再去获取读锁,然后在不释放写锁的情况下,先释放读锁(当然,通常是先获取读锁,再释放写锁)。
- 为什么允许? 因为写锁是独占的,当一个线程持有写锁时,它就是“全场唯一”的。此时,它再去获取一个读锁,并不会破坏任何数据一致性或引入竞争,因为它自己本来就拥有最高权限。
- 典型场景:一个线程需要更新一个共享数据,并在更新后立即读取这个更新后的数据,同时又不希望在“读”的过程中被其他写线程打扰。它可以:
- 获取写锁。
- 修改数据。
- 获取读锁(锁降级)。
- 释放写锁。
- 使用读锁来安全地读取数据。
- 释放读锁。
这个过程保证了数据从“写”到“读”的连续性和一致性。
2. 为什么不支持“锁升级”(读锁 -> 写锁)?
这是设计的关键所在。不允许持有读锁的线程直接获取写锁,是为了避免死锁。
- 锁升级:指的是一个线程在持有读锁的情况下,再去尝试获取写锁。
ReentrantReadWriteLock
的策略是,这种尝试会立即失败。 - 为什么会死锁? 我们来模拟一下如果“允许锁升级”会发生什么:
- 线程A获取了读锁。
- 线程B此时也来获取读锁。因为读锁是共享的,所以线程B也成功获取了读锁。
- 现在,线程A和线程B都持有读锁。
- 接着,线程A发现自己需要修改数据,于是它尝试去获取写锁(进行“锁升级”)。但写锁是独占的,它必须等待所有其他锁(包括线程B的读锁)被释放。于是,线程A开始等待线程B释放读锁。
- 与此同时,线程B也发现自己需要修改数据,它也去尝试获取写锁。同样,它也必须等待所有其他锁(包括线程A的读锁)被释放。于是,线程B开始等待线程A释放读锁。
- 僵局形成:线程A在等线程B,线程B也在等线程A。它们互相持有对方需要的资源(读锁),又都在等待对方释放资源,这就形成了一个完美的死锁。
结论
为了从根本上杜绝这种因“锁升级”而导致的死锁风险,ReentrantReadWriteLock
的作者(Doug Lea)在设计时就做出了一个明确的规定:想要获取写锁,必须先释放你持有的所有读锁。
所以,一个需要从“读”转换到“写”的线程,正确的做法是:
- 获取读锁。
- 读取数据。
- 完全释放读锁。
- 重新去竞争写锁(此时它和其他任何想获取写锁的线程是平等的)。
- 获取写锁后,修改数据。
- 释放写锁。
这个设计虽然在编码上看起来稍微麻烦一点,但它保证了系统的健壮性,避免了难以排查的死锁问题。
StampedLock 是什么?
面试官您好,StampedLock
是Java 8中引入的一个新的锁机制,可以看作是传统读写锁 (ReentrantReadWriteLock
) 的一个性能更优、功能更强大的“进化版”。
它的设计目标与读写锁类似,都是为了解决 “读多写少”场景下的性能问题,但它引入了一种全新的、非常独特的乐观读模式,从而在某些场景下能提供比ReentrantReadWriteLock
更高的吞吐量。
我通常从它的三种锁模式来理解它:
1. 写锁 (Write Lock)
- 行为:这是一种独占锁,其行为和
ReentrantReadWriteLock
的写锁非常相似。当一个线程获取了写锁,其他任何线程(无论是想获取写锁还是读锁)都必须等待。 - API:通过
writeLock()
获取锁,通过unlockWrite(long stamp)
释放锁。 - 返回值:
writeLock()
会返回一个long
类型的 “票据”(stamp)。这个票据在释放锁时必须用到,不能丢失。 - 与
ReentrantLock
的区别:StampedLock
的写锁是不可重入的。如果一个线程已经持有了写锁,再次尝试获取,就会导致死锁。这是它在设计上的一个重要取舍,为了性能牺牲了可重入性。
2. 悲观读锁 (Pessimistic Read Lock)
- 行为:这是一种共享锁,其行为也和
ReentrantReadWriteLock
的读锁类似。允许多个线程同时持有悲观读锁。当有线程持有写锁时,所有请求悲观读锁的线程都会被阻塞。 - API:通过
readLock()
获取锁,通过unlockRead(long stamp)
释放锁。 - 返回值:同样,
readLock()
也会返回一个long
类型的票据,用于解锁。 - 与
ReentrantReadWriteLock
的区别:它也是不可重入的。
3. 乐观读 (Optimistic Reading) —— 这是它的精髓
这是StampedLock
最核心、最创新的地方,也是它性能优势的主要来源。
- 思想:它基于一种非常乐观的假设——“在我读取共享数据的这极短的时间内,大概率不会有写操作发生”。
- 工作流程:
- 线程在不加任何锁的情况下,直接去读取共享变量。但在读取前,它会先调用
tryOptimisticRead()
方法,获取一个非0的“票据”(stamp)。 - 读取完共享变量后,它必须调用
validate(long stamp)
方法,来验证一下在它读取期间,是否有写操作发生过。 validate()
如何验证?它会检查从tryOptimisticRead()
到validate()
调用之间,有没有线程获取过写锁。如果没有,validate()
返回true
,说明这次乐观读是有效的,读取的数据是一致的。- 如果
validate()
返回false
,说明在读取期间,有写操作干扰,刚才读取的数据可能是脏数据。此时,乐观读失败,线程就需要 “升级” 为悲观读锁(调用readLock()
),然后重新读取数据。
- 线程在不加任何锁的情况下,直接去读取共享变量。但在读取前,它会先调用
- 为什么高效?
- 在绝大多数的“读”操作中,如果真的没有写竞争,那么线程从头到尾都没有真正地加锁,也没有使用CAS,开销极小,几乎和普通变量的读写一样快。
- 它用一次“验证”的成本,替代了传统读写锁中“加锁-解锁”的成本。只有在验证失败时,才退化为加悲观读锁的模式。
StampedLock
的缺点与注意事项
虽然性能优越,但StampedLock
的使用也更复杂,且存在一些“坑”:
- 不可重入:如前所述,它的所有锁模式都是不可重入的。
- 不支持
Condition
:它没有Condition
条件队列,无法实现wait/notify
那样的线程间协作。 - 使用复杂:解锁时必须使用正确的票据(stamp),并且需要自己处理好乐观读失败后“升级”为悲观读锁的逻辑,对开发者的要求更高。
- 可能导致写线程“饥饿”:如果读线程非常多,并且源源不断,可能会导致写线程长时间无法获取到写锁。
总结与选型
ReentrantReadWriteLock
:更稳定、更通用、功能更全面的读写锁。当需要可重入性或Condition
条件队列时,它是唯一的选择。StampedLock
:一个纯粹的、追求极致**“读性能”**的工具。当你的业务场景是极端的“读多写少”,且读操作的业务逻辑非常简单(没有复杂的重入需求),并且你愿意编写更复杂的代码来换取更高的吞吐量时,StampedLock
是一个非常值得尝试的选择。
简单来说,StampedLock
是用更复杂的编程模型和更少的通用功能,换取了在特定场景下的极致读性能。
Semaphore 有什么用?
面试官您好,Semaphore
,中文通常翻译为 “信号量”,是JUC包提供的一个非常实用的并发控制工具。
它的核心作用可以一句话概括:控制在同一时间内,能够访问某个特定资源的线程数量。
1. 一个生动的比喻:停车场与停车位
要理解Semaphore
,最好的方式就是把它想象成一个停车场的入口闸机。
Semaphore(int permits)
:在创建信号量时,我们会给它一个初始的 “许可证” (permits) 数量。这个数量就相当于这个停车场的总车位数。
// 创建一个只有5个车位的停车场信号量
Semaphore semaphore = new Semaphore(5);
semaphore.acquire()
(获取许可证):当一个线程(一辆车)想要访问资源(进入停车场)时,它必须先调用acquire()
方法。- 如果此时还有剩余的许可证(停车场有空位),
acquire()
会成功返回,许可证数量减1,线程可以继续执行。 - 如果许可证已经用完(停车场满了),那么调用
acquire()
的线程就会被阻塞,在闸机外排队等待。
- 如果此时还有剩余的许可证(停车场有空位),
semaphore.release()
(释放许可证):当一个线程完成了对资源的使用(车离开停车场)时,它必须调用release()
方法。- 这个方法会使许可证数量加1。
- 如果有其他线程正在因为
acquire()
而排队等待,那么其中一个线程就会被唤醒,成功获取到这个刚刚被释放的许可证。
2.Semaphore
的核心应用场景
基于以上原理,Semaphore
在实践中最核心的应用场景就是 “限流”。
- 场景举例:
- 数据库连接池:虽然我们通常直接使用如HikariCP这样的连接池组件,但其内部原理与
Semaphore
非常相似。一个拥有10个连接的数据库连接池,就可以看作是一个许可证数量为10的Semaphore
。任何线程想获取连接,都必须先从Semaphore
拿到一个“许可”。 - 控制对稀缺资源的并发访问:假设我们有一个调用第三方API的服务,但这个API提供商限制了我们的并发调用数不能超过20。我们就可以创建一个
new Semaphore(20)
。在每次发起API调用前,先acquire()
,调用完成后,在finally
块中release()
。这样就能简单、可靠地保证我们的并发调用数永远不会超过限制。 - 控制同时执行某个耗时操作的线程数:比如,一个系统需要对上传的图片进行压缩处理,这个操作非常消耗CPU和内存。为了防止并发量过大时,大量的压缩任务同时执行而导致服务器崩溃,我们可以用
Semaphore
来限制同时执行压缩的线程数量,比如new Semaphore(4)
,保证任何时候最多只有4个线程在做压缩工作。
- 数据库连接池:虽然我们通常直接使用如HikariCP这样的连接池组件,但其内部原理与
3. 与ReentrantLock
的区别
Semaphore
和ReentrantLock
虽然都是同步工具,但它们解决的问题维度完全不同。
ReentrantLock
(以及synchronized
):解决的是 “互斥”问题,它保证了任何时候最多只有一个线程能访问临界区。它的许可证数量,可以认为是固定的1。Semaphore
:解决的是 “并发量控制”问题,它允许多个线程同时访问资源,但会限制这个“同时”的数量。
总结一下,Semaphore
不是一个互斥锁,而是一个共享访问的控制器。当我们想限制对某个资源池或某个代码块的并发访问数量,而不是完全互斥地只允许一个线程访问时,Semaphore
就是最简单、最合适的工具。
Semaphore 的原理是什么?
面试官您好,Semaphore
的底层原理,和ReentrantLock
一样,都是 完全基于JUC的核心框架AQS (AbstractQueuedSynchronizer) 来构建的。
Semaphore
内部有两个实现了AQS的同步器:一个用于实现公平策略(FairSync
),一个用于实现非公平策略(NonfairSync
)。
它的核心原理,我们可以从AQS的两个关键组件来理解:state
变量和等待队列。
1. 对AQSstate
变量的巧妙运用
在Semaphore
中,AQS的那个volatile int state
变量,其含义被定义为:“当前可用的许可证数量”。
- 当我们创建一个
new Semaphore(5)
时,实际上就是把它内部AQS的state
值初始化为了5。
2.acquire()
(获取许可证) 的工作流程
当一个线程调用semaphore.acquire()
时,其内部会调用AQS的acquireSharedInterruptibly(1)
方法,这个方法会触发AQS的共享模式获取逻辑。我们以非公平模式为例,其核心步骤如下:
- 尝试直接获取 (
tryAcquireShared
):Semaphore
的NonfairSync
会重写tryAcquireShared
方法。这个方法会进入一个CAS自旋循环。- 在循环中,它会:
a. 首先,获取当前的state
值(即剩余许可证数量)。
b. 计算出如果获取成功后,新的state
值应该是多少(newState = state - 1
)。
c. 如果newState
小于0,说明许可证不够了,直接返回一个负数,表示获取失败。
d. 如果许可证够用,就通过CAS操作,尝试将state
的值从state
原子地更新为newState
。
e. 如果CAS成功,说明这个线程抢到了许可证,tryAcquireShared
返回一个正数,获取成功,acquire()
方法直接返回。
f. 如果CAS失败,说明在它计算和尝试更新的瞬间,有其他线程也抢先修改了state
。它就会继续下一次循环,重新读取state
再尝试。
- 获取失败,进入等待队列:
- 如果
tryAcquireShared
最终返回了失败(许可证不够),AQS框架就会接管。 - 它会将当前线程封装成一个
Node
(类型为SHARED
),并将其加入到AQS的等待队列中去排队。 - 然后,通过
LockSupport.park()
将该线程挂起,使其进入等待状态。
- 如果
3.release()
(释放许可证) 的工作流程
当一个线程调用semaphore.release()
时,其内部会调用AQS的releaseShared(1)
方法。
- 尝试释放 (
tryReleaseShared
):Semaphore
的同步器会重写tryReleaseShared
方法。这个方法同样会进入一个CAS自旋循环。- 在循环中,它会:
a. 获取当前的state
值。
b. 计算出释放后的新state
值(newState = state + 1
)。
c. 通过CAS操作,尝试将state
原子地更新为newState
。
d. 一旦CAS成功,tryReleaseShared
就返回true
,表示释放成功。
- 唤醒等待的线程:
- 当
releaseShared
方法检测到tryReleaseShared
成功后,AQS框架会去检查等待队列的头部。 - 如果发现队头有正在等待的节点,它就会通过
LockSupport.unpark()
唤醒队头节点对应的线程。 - 被唤醒的线程会再次尝试执行
tryAcquireShared
逻辑,去获取刚刚被释放的许可证。
- 当
公平锁与非公平锁的区别
- 非公平锁:在
acquire
时,会先自旋尝试抢占,抢不到再排队。 - 公平锁:在
acquire
时,会先检查等待队列中是否有比自己更早的线程(hasQueuedPredecessors()
)。如果有,就直接放弃抢占,乖乖排队。这保证了许可证的分配严格遵循FIFO顺序。
总结
所以,Semaphore
的原理可以总结为:
- 它巧妙地将AQS的
state
整数,用作了 “许可证计数器”。 acquire()
操作,本质上是一次对state
的原子减法。如果state
不够减,线程就进入AQS的队列等待。release()
操作,本质上是一次对state
的原子加法,并在成功后,触发AQS去唤醒队列中的等待线程。
整个过程,都是通过AQS提供的模板和CAS原子操作来实现的,既高效又线程安全。
CyclicBarrier 有什么用?
面试官您好,CyclicBarrier
,中文通常翻译为 “循环屏障”,是JUC包提供的一个非常强大的同步协作工具。
它的核心作用可以一句话概括:让一组线程能够互相等待,直到所有线程都到达一个公共的“屏障点”(Barrier Point),然后再“手拉手”地一起继续向后执行。
1. 一个生动的比喻:团队集合出游
要理解CyclicBarrier
,最好的方式就是把它想象成一个旅行团的导游在约定集合点。
new CyclicBarrier(int parties)
:导游宣布:“我们这个团总共有5个人(parties=5
),下午3点在公园门口集合。” 这个“5个人”就是屏障需要等待的线程数量。barrier.await()
(等待):- 团员们(线程)各自游玩,然后陆陆续续地到达了公园门口。每当一个团员到达,他就调用
await()
方法,然后开始原地等待。 - 导游(
CyclicBarrier
)会记录下“已到达人数”,比如现在到了3个,还差2个。
- 团员们(线程)各自游玩,然后陆陆续续地到达了公园门口。每当一个团员到达,他就调用
- 屏障被打破 (Barrier Broken):
- 当最后一个团员(第5个)到达并调用
await()
时,神奇的事情发生了:- 导游发现“人齐了!”
- 所有之前因为调用
await()
而正在等待的4个团员,会和这个刚到的第5个团员,几乎在同一时刻被全部唤醒。 - 然后,大家“手拉手”地一起离开公园门口,继续下一个行程。
- 当最后一个团员(第5个)到达并调用
- 循环使用 (Cyclic):
- 最关键的是,这个屏障是可循环使用的。当大家离开公园门口后,这个屏障会自动重置。导游可以再次宣布:“晚上7点,在餐厅门口集合。” 然后大家又可以开始新一轮的“各自行动 -> 到达 -> 等待 -> 集体出发”的循环。这就是“Cyclic”的含义。
2.CyclicBarrier
的核心应用场景
基于以上特性,CyclicBarrier
非常适合用于那些需要分阶段、且每个阶段开始前所有参与者都必须同步的并发任务。
- 最典型的场景:多线程并行计算
- 举例:我们需要用多个线程来处理一个大型矩阵的计算。这个计算可以分为多个步骤。比如,我们需要先并行计算出矩阵的每一行,然后等所有行都计算完毕后,再进行一个汇总计算,最后再基于汇总结果,并行地进行下一阶段的计算。
- 如何实现:
- 创建一个
CyclicBarrier
,参与方数量等于计算线程的数量。 - 每个线程在完成第一阶段的行计算后,都调用
barrier.await()
。 - 当所有线程都到达屏障后,它们会被同时唤醒,然后一起进入第二阶段的计算。
- 创建一个
- 可选的“栅栏动作” (Barrier Action)
CyclicBarrier
的构造函数还提供了一个可选的Runnable
参数:new CyclicBarrier(int parties, Runnable barrierAction)
。- 这个
barrierAction
会在所有线程都到达屏障,并且在新线程被唤醒之前,由最后一个到达屏障的线程来执行。 - 这非常有用! 在上面的矩阵计算例子中,我们可以把那个“汇总计算”的逻辑,就放在这个
barrierAction
里。这样,当所有行计算完成后,会自动由一个线程去执行汇总,汇总完毕后,其他线程再被唤醒进行下一阶段的工作,逻辑非常清晰。
3. 与CountDownLatch
的核心区别
特性 | CyclicBarrier | CountDownLatch |
---|---|---|
作用对象 | 让一组线程互相等待 | 让一个或多个线程等待另一组线程 |
可复用性 | 可循环使用(可reset ) | 一次性,计数器到0后无法重置 |
核心方法 | await() (使计数器加1,并阻塞) | await() (阻塞), countDown() (使计数器减1) |
功能 | 更侧重于 “同步”和“屏障” | 更侧重于 “等待”和“完成信号” |
总结一下,CountDownLatch
像是一个 “倒计时闹钟”,闹钟响了(计数到0),等待的人就被唤醒。而**CyclicBarrier
则像一个“集合点”**,只有当所有人都到齐了,大家才能一起出发。当我们需要让多个线程步调一致、分阶段地往前推进时,CyclicBarrier
就是最合适的工具。
CyclicBarrier 的原理是什么?
面试官您好,CyclicBarrier
的底层原理,核心是基于一个 ReentrantLock
和与之关联的一个 Condition
(名为trip
) 来构建的。它通过一个 计数器count
和 “代(Generation)” 的概念,实现了线程的屏障等待和循环复用。
我们可以从它的两个核心方法await()
和nextGeneration()
来剖析其工作流程。
1. 核心数据结构
在CyclicBarrier
的内部,有几个关键的成员变量:
private final ReentrantLock lock
: 一把全局的、独占的可重入锁,用于保护所有对内部状态的访问。private final Condition trip
: 一个与lock
绑定的条件队列。所有等待在屏障前的线程,都会在这个Condition
上await()
。private final int parties
: 构造时传入的参与方总数,也就是屏障的“目标人数”。private int count
: 一个倒计数的变量,初始值等于parties
。每当一个线程调用await()
,它就减1。private Generation generation
: 一个内部类,可以看作是“当前这一轮屏障”的标记。它有一个broken
标志位,用于表示当前这代屏障是否已被打破。
2.await()
方法的核心工作流程
当一个线程调用barrier.await()
时,它会执行以下一系列操作,并且所有这些操作都在lock
的保护下进行:
- 加锁:首先,线程会获取全局的
ReentrantLock
,确保对内部状态修改的原子性。
final ReentrantLock lock = this.lock;
lock.lock();
try {// ... 后续所有逻辑都在try块中 ...
} finally {lock.unlock();
}
- 检查屏障状态:线程会先检查当前这“代”的屏障是否已经被“打破”(broken)。如果已经被打破(比如因为其他线程超时或被中断),它就不会再等待,而是直接抛出
BrokenBarrierException
。 - 计数器减一:如果屏障完好,它会将
count
变量减1。
int index = --count;
- 判断是否为最后一个到达者:
- 如果是最后一个到达者 (
index == 0
):
a. 这意味着“人到齐了”。
b. 如果构造时传入了barrierAction
,那么这个“幸运的”最后一个线程会负责去执行这个barrierAction
。
c. 执行完barrierAction
(或如果没有的话),它会调用nextGeneration()
方法,开启下一代屏障,并唤醒所有在trip
这个Condition
上等待的线程。
d. 最后,它自己直接从await()
方法返回。 - 如果不是最后一个到达者 (
index > 0
):
a. 这意味着“人还没齐,我需要等待”。
b. 它会进入一个循环,在这个循环里调用trip.await()
方法。
c.trip.await()
会原子地释放lock
锁,并将当前线程放入trip
的等待队列中,使其进入WAITING
状态。
d. 它会一直在此等待,直到被最后一个到达的线程唤醒。被唤醒后,它会从await()
返回。
- 如果是最后一个到达者 (
3.nextGeneration()
方法:实现循环的关键
这个方法只会被最后一个到达的线程调用,它的作用是 “重置屏障,开启新的一轮”。
- 唤醒所有等待者:它会调用
trip.signalAll()
,这将唤醒所有在trip
这个Condition
上等待的线程。 - 重置计数器:它会将
count
的值重新设置为parties
。 - 创建新的Generation:它会创建一个新的
Generation
对象,替换掉旧的,标志着新一轮屏障的开始。旧的那代屏障就此作废。
4. 总结与CountDownLatch
的原理对比
CyclicBarrier
的原理核心:- 使用一把
ReentrantLock
来保证所有状态修改的互斥性。 - 使用一个
Condition
来管理所有线程的等待和集体唤醒。 - 通过一个倒数的
count
来判断是否所有人都已到达。 - 通过一个
Generation
对象 来处理屏障的“打破”状态,并通过nextGeneration()
方法实现屏障的 循环复用。
- 使用一把
- 与
CountDownLatch
原理的根本区别:CountDownLatch
的原理是基于AQS的共享模式。它的countDown()
操作是无锁的(通过CAS更新state
),await()
操作则是线程在AQS的同步队列上等待。它没有使用ReentrantLock
或Condition
。CyclicBarrier
则是基于Java层面的Lock
和Condition
构建的,它的await()
操作涉及到显式的加锁和在条件队列上等待。
正是这种实现上的不同,导致了它们在功能(是否可循环)和应用场景上的巨大差异。
参考小林coding和JavaGuide