引言
在多线程并发编程场景中,锁作为一种至关重要的同步工具,承担着协调多个线程对共享资源访问秩序的任务。其核心作用在于确保在特定时间段内,仅有一个线程能够对资源进行访问或修改操作,从而有效地保护数据的完整性和一致性。锁作为一种底层的安全构件,有力地防止了竞态条件和数据不一致性的问题,尤其在涉及多线程或多进程共享数据的复杂场景中显得尤为关键。
而了解锁的分类,能帮助我们何种业务场景下使用选择哪种锁。
基于锁的获取与释放方式分类
计划于所得获取与释放方式进行分类,Java中的锁可以分为:显式锁和隐式锁。
隐式锁
Java中的隐式锁(也称为内置锁或自动锁)是通过使用synchronized
关键字实现的一种线程同步机制。当一个线程进入被synchronized
修饰的方法或代码块时,它会自动获得对象级别的锁,退出该方法或代码块时则会自动释放这把锁。
在Java中,隐式锁的实现机制主要包括以下两种类型:
-
互斥锁(Mutex): 虽然Java标准库并未直接暴露操作系统的互斥锁提供使用,但在Java虚拟机对
synchronized
关键字处理的底层实现中,当锁竞争激烈且必须升级为重量级锁时,会利用操作系统的互斥量机制来确保在同一时刻仅允许一个线程持有锁,从而实现严格的线程互斥控制。 -
内部锁(Intrinsic Lock)或监视器锁(Monitor Lock): Java语言为每个对象内建了一个监视器锁,这是一个更高级别的抽象。我们可以通过使用
synchronized
关键字即可便捷地管理和操作这些锁。当一个线程访问被synchronized
修饰的方法或代码块时,会自动获取相应对象的监视器锁,并在执行完毕后自动释放,这一过程对用户透明,故被称为隐式锁。每个Java对象均与一个监视器锁关联,以此来协调对该对象状态访问的并发控制。
优点:
- 简洁易用:程序员无需手动管理锁的获取和释放过程,降低了编程复杂性。
- 安全性:隐式锁确保了线程安全,避免了竞态条件,因为一次只有一个线程能持有锁并执行同步代码块。
- 异常处理下的自动释放:即使在同步代码块中抛出异常,隐式锁也会在异常退出时被释放,防止死锁。
缺点:
- 锁定粒度:隐式锁的粒度通常是对象级别,这意味着如果一个大型对象的不同部分实际上可以独立地被不同线程访问,但由于整个对象被锁定,可能导致不必要的阻塞和较低的并发性能。
- 不灵活:相对于显示锁(如
java.util.concurrent.locks.Lock
接口的实现类),隐式锁的功能较有限,无法提供更细粒度的控制,如尝试获取锁、定时等待、可中断的获取锁等高级特性。 - 锁竞争影响:在高并发环境下,若多个线程竞争同一把锁,可能会引发“锁争用”,导致性能下降,特别是在出现锁链和死锁的情况下。
适用场景: 隐式锁适用于相对简单的多线程同步需求,尤其是在只需要保护某个对象状态完整性,且无需过多关注锁策略灵活性的场合。对于要求更高并发性和更复杂锁管理逻辑的应用场景,显示锁通常是一个更好的选择。
显式锁
显式锁是由java.util.concurrent.locks.Lock
接口及其诸多实现类提供的同步机制,相较于通过synchronized
关键字实现的隐式锁机制,显式锁赋予开发者更为精细和灵活的控制能力,使其能够在多线程环境中精准掌控同步动作。显式锁的核心作用在于确保在任何时刻仅有一个线程能够访问关键代码段或共享数据,从而有效防止数据不一致性问题和竞态条件。
相较于隐式锁,显式锁提供了更为多样化的锁操作选项,包括但不限于支持线程在等待锁时可被中断、根据先后顺序分配锁资源的公平锁与非公平锁机制,以及能够设定锁获取等待时间的定时锁功能。这些特性共同增强了显式锁在面对复杂并发场景时的适应性和可调控性,使之成为解决高度定制化同步需求的理想工具。
日常开发中,常见的显式锁分类有如下几种:
- ReentrantLock:可重入锁,继承自
Lock
接口,支持可中断锁、公平锁和非公平锁的选择。可重入意味着同一个线程可以多次获取同一线程持有的锁。 - ReentrantReadWriteLock:读写锁,提供了两个锁,一个是读锁,允许多个线程同时读取;另一个是写锁,同一时间内只允许一个线程写入,写锁会排斥所有读锁和写锁。
- StampedLock:带版本戳的锁,提供了乐观读、悲观读写模式,适合于读多写少的场景,可以提升系统性能。
优点:
- 灵活控制:显式锁提供了多种获取和释放锁的方式,可以根据实际需求进行选择,比如中断等待锁的线程,设置超时获取锁等。
- 性能优化:在某些特定场景下,显式锁可以提供比隐式锁更好的性能表现,尤其是当需要避免死锁或优化读多写少的情况时。
- 公平性选择:显式锁允许创建公平锁,按照线程请求锁的顺序给予响应,保证所有线程在等待锁时有一定的公平性。
缺点:
- 使用复杂:相较于隐式锁,显式锁需要手动调用
lock()
和unlock()
方法,增加了编程复杂性,如果不正确地使用(如忘记释放锁或未捕获异常导致锁未释放),容易造成死锁或其他并发问题。 - 性能开销:在某些简单场景下,显式锁的额外API调用和锁状态管理可能带来额外的性能开销,尤其当公平锁启用时,由于需要维护线程队列和线程调度,可能会影响整体性能。
- 错误可能性:由于显式锁的操作更加细致,因此更容易出错,开发者需要具备较高的并发编程意识和技能才能妥善使用。
基于对资源的访问权限
按照线程对资源的访问权限来分类,可以将锁分为:独占锁(Exclusive Lock)和共享锁(Shared Lock)。
独占锁
独占锁(Exclusive Lock),又称排他锁或写锁,是一种同步机制,它确保在任一时刻,最多只有一个线程可以获得锁并对受保护的资源进行访问或修改。一旦线程获得了独占锁,其他所有试图获取同一锁的线程将被阻塞,直到拥有锁的线程释放锁为止。独占锁主要用于保护那些在并发环境下会被多个线程修改的共享资源,确保在修改期间不会有其他线程干扰,从而维护数据的一致性和完整性。
对于独占锁就像图书馆里的某本书,这本书只有唯一的一本。当一个读者想要借阅这本书时,他会去图书管理员那里登记并拿到一个“借书凭证”(相当于独占锁)。此时,这本书就被锁定了,其他读者无法借阅这本书,直至第一个读者归还书本并交回“借书凭证”。这就像是线程获得了独占锁,只有拥有锁的线程可以修改或操作资源(书本),其他线程必须等待锁的释放才能执行相应的操作。
而独占锁的实现方式,主要有如下两种:
synchronized
关键字:通过synchronized
关键字实现的隐式锁,它是独占锁的一种常见形式,任何时刻只有一个线程可以进入被synchronized
修饰的方法或代码块。ReentrantLock
:可重入的独占锁,提供了更多的控制方式,包括可中断锁、公平锁和非公平锁等。
优点:
- 简单易用:对于
synchronized
关键字,语法简单直观,易于理解和使用。 - 线程安全:确保了对共享资源的独占访问,避免了并发环境下的数据竞争问题。
- 可重入性:像
ReentrantLock
这样的锁,支持同一个线程重复获取同一把锁,提高了线程间协作的便利性。
缺点:
- 粒度固定:对于
synchronized
,锁的粒度是固定的,无法动态调整,可能导致不必要的阻塞。 - 缺乏灵活性:隐式锁不能主动中断等待锁的线程,也无法设置超时等待。
- 性能瓶颈:在高度竞争的环境中,
synchronized
可能会造成上下文切换频繁,效率低下;而显式锁虽提供了更灵活的控制,但如果使用不当也可能导致额外的性能损失。