目录
- 一、乐观锁
- 二、悲观锁
- 三、自旋锁
- 3.1 自旋锁的优缺点:
- 3.2 自旋锁的时间阈值:
- 3.3 自旋锁的开启:
- 四、Synchronized 同步锁
- 4.1 Synchronized 作用范围:
- 4.2 Synchronized 核心组件:
- 4.3 Synchronized 实现:
- 4.4 Synchronize 补充:
- 五、ReentractLock 锁
- 5.1 Lock 接口的主要方法:
- 5.2 ReentrantLock 与 synchronized 对比:
- 5.3 Condition 接口
- 5.4 ReentrantLock 使用示例1:基本使用
- 5.5 ReentrantLock 使用示例2:生产者-消费者模式
- 5.6 Condition 类与 Object 类锁方法的区别:
- 5.7 lock()、tryLock()、lockInterruptibly() 的区别:
- 六、Semaphore 信号量
- 6.1 实现互斥锁(Semaphore=1)
- 6.2 Semaphore 使用示例:
- 6.3 Semaphore 与 ReentrantLock 对比:
- 七、AtomicInteger 等原子操作
- 八、可重入锁(递归锁)
- 九、公平锁(Fair)与非公平锁(Nonfair)
- 十、ReadWriteLock 读写锁
- 10.1 读锁:
- 10.2 写锁:
- 十一、共享锁与独占锁
- 11.1 独占锁:
- 11.2 共享锁:
- 11.3 常见的独占锁、共享锁:
- 十二、锁升级
- 12.1 锁升级
- 12.2 重量级锁
- 12.3 轻量级锁
- 12.4 偏向锁
- 十三、分段锁
- 十四、锁优化的手段
- 14.1 减少锁持有的时间
- 14.2 减小锁粒度
- 14.3 锁分离
- 14.4 锁粗化
- 14.5 锁消除
前言:
- 多线程一直是很多中级Java开发的面试核心提问点,在高并发环境下,线程安全的处理一直是如履薄冰。本文针对各种常见的 Java 锁进行了整理归纳,包括:
- 乐观锁、悲观锁、自旋锁、Synchronized同步锁、ReentrantLock、Semaphore信号量、AtomicInteger、可重入锁、公平锁与非公平锁、ReadWriteLock读写锁、共享锁与独占锁、锁升级、锁优化。
一、乐观锁
乐观锁
是一种乐观思想,即认为 读多写少,遇到并发的可能性低,每次去拿数据的时候都认为别人不会修改,所以不会上锁。
乐观锁的实际操作过程:
- 在更新的时候会判断一下在此期间别人有没有去更新这个数据,采取在写时先读出当前版本号,然后加锁操作(跟上一次的版本号比较,如果一样则更新),如果失败则要重复
读
->比较
->写
的操作。
Java 中的乐观锁基本都是通过 CAS
操作实现的,CAS 是一种更新的原子操作:比较当前值跟传入值是否一样,一样则更新,否则失败。
二、悲观锁
悲观锁
就是一种悲观思想,即认为 写多,遇到并发写的可能性高,每次去拿数据的时候都认为别人会修改,所以 每次在读写数据的时候都会上锁,这样别人想读写这个数据就会 block 直到拿到锁。
Java中的悲观锁就是 Synchronized
,AQS 框架下的锁则是先尝试 CAS 乐观锁去获取锁,获取不到,才会转换为悲观锁,如:ReentrantLock。
三、自旋锁
自旋锁
原理非常简单,如果持有锁的线程能在很短时间内释放所资源,那么那些等待竞争所的线程就不需要做内核态
和用户态
之间的切换进入阻塞挂起状态,它们只需要等一等(自旋),等持有锁的线程释放锁后即可立即获取锁,这样就避免用户线程和内核的切换消耗。
线程自旋是需要消耗 CPU 的,说白了就是让 CPU 在做无用功,如果一直获取不到锁,那线程也不能一直占用 CPU 自旋做无用功,所以需要设定一个 自旋等待的最大时间。
如果持有锁的线程执行时间 超过 自旋等待的最大时间仍没有释放锁,就会导致其它争用锁的线程在最大等待时间内还是获取不到锁,这时争用线程会 停止自旋进入阻塞状态。
3.1 自旋锁的优缺点:
- 优点: 自旋锁 尽可能的减少线程的阻塞,这 对于锁的竞争不激烈,且占用所时间非常短的代码块来说性能可以大幅度地提升,因为自旋的消耗会小于线程阻塞挂起再唤醒操作的消耗,这些操作会导致 线程发生两次上下文切换。
- 缺点: 如果锁的竞争的竞争激烈,或者持有锁的线程需要长时间占用锁执行同步块,这时候就不适合使用自旋锁了,因为自旋锁在获取锁前一直都是占用 CPU 做无用功,占着 XX 不 XX,同时 如果有大量线程在竞争一个锁,会导致获取锁的时间很长,线程自旋的消耗大于线程阻塞挂起操作的消耗,其他需要 CPU 的线程又不能获取到 CPU,造成 CPU 的浪费。所以这种情况我们要关闭自旋锁。
3.2 自旋锁的时间阈值:
自旋锁的目的是为了占着 CPU 的资源不释放,等到获取锁立即进行处理。但是如何去选择自旋的执行时间呢?如果自旋执行时间太长,会有大量的线程处于自旋状态占用 CPU 资源,进而会影响整体系统的性能。因此自旋周期的选择额外重要。
JVM 对于自选周期的选择,jdk1.5 时这个限度是写死的,在 jdk1.6引入了 适应性自旋锁
,适应性自旋锁意味着自旋的时间不再是固定的了,而是 由前一次在同一个锁上的自旋时间以及锁的拥有者的状态来决定,基本认为一个线程上下文切换的时间是最佳的一个时间,同时 JVM 还针对当前 CPU 的符合情况做了较多的优化,如果平均负载小于 CPUs(CPU核数)则一直自旋,如果有超过 CPUs/2 (一半的CPU核数)个线程正在自旋,则后来线程直接阻塞,如果正在自旋的线程发现 Owner(当前持有锁的线程)发生了变化则延迟自旋时间(自旋计数)或进入阻塞,如果 CPU 处于节点模式则停止自旋,自旋时间的最坏情况是 CPU 的存储延迟(CPU A 存储了一个数据,到 CPU B 得知这个数据直接的时间差),自旋会适当放弃线程优先级之间的差异。
3.3 自旋锁的开启:
-
JDK1.6 中通过
-XX:+UseSpinning
开启;-XX:PreBlockSpin=10
为自旋次数; -
JDK1.7 后,去掉此参数,由 jvm 控制。
四、Synchronized 同步锁
synchronized
它可以把任意一个非 NULL 的对象当作锁。它属于独占式的悲观锁,同时属于可重入锁。
4.1 Synchronized 作用范围:
- 作用于 普通方法 时,锁住的时对象的实例(this);
- 作用于 静态方法 时,锁住的是 Class 实例,又因为 Class 的相关数据存储在永久代 PermGen(jdk1.8 则是 metaspace),永久代是全局共享的,因此静态方法锁相当于类的一个全局锁,会锁所有调用该方法的线程。
- 作用于 对象实例 时,锁住是所有以该对象为锁的代码块。它有多个队列,当多个线程一起访问某个对象监视器的时候,对象监视器(
Monitor
)会将这些线程存储在不同的容器中。
4.2 Synchronized 核心组件:
1)Wait Set
:存储调用 wait() 方法被阻塞的线程。
2)Contention List
:竞争队列,所有请求锁的线程首先被放在这个竞争队列中。
3)Entry List
:Contention List 中那些有资格成为候选资源的线程被移动到 Entry List 中;
4)OnDeck
:任意时刻,最多只有一个线程正在竞争所资源,该线程被成为 OnDeck;
5)Owner
:当前已经获取到锁资源的线程被成为 Owner;
6)!Owner
:当前释放锁的线程。
4.3 Synchronized 实现:
- JVM 每次从队列的尾部取出一个数据用于锁竞争候选者(OnDeck),但是并发情况下,ContentionList 会被大量的并发线程进行 CAS 访问,为了降低对尾部元素的竞争,JVM 会将一部分线程移动到 EntryList 中作为候选竞争线程。
- Owner 线程会在 unlock 时,将 ContentionList 中的部分线程迁移到 EntryList 中,并指定 EntryList 中的某个线程为 OnDeck 线程(一般是先进去的那个线程)。
- Owner 线程并不直接把锁传递给 OnDesk 线程,而是把锁竞争的权利交给 OnDeck,OnDeck 需要重新竞争锁。这样 虽然牺牲了一些公平性,但是能极大地提升系统的吞吐量,在 JVM 中,也把这种选择行为称之为 “竞争切换”。
- OnDeck 线程获取到锁资源后会变为 Owner 线程,而没有得到锁资源的仍然停留在 EntryList 中。如果 Owner 线程被 wait 方法阻塞,则转移到 WaitSet 队列中,直到某个时刻通过 notify 或者 notifyAll 唤醒,会重新进去 EntryList 中。
- 处于 ContentionList、EntryList、WaitSet 中的线程都处于阻塞状态,该阻塞是由操作系统来完成的(Linux 内核下采用 pthread_mutex_lock 内核函数实现的)。
4.4 Synchronize 补充:
- synchronized 是非公平锁。Synchronized 在线程进入 ContentionList 时,等待的线程会先尝试自旋获取锁,如果获取不到就进入 ContentionList,这明显对于已经进入队列的线程是不公平的。还有一个不公平的事情就是自旋获取锁的线程还可能直接抢占 OnDeck 线程的锁资源。
- 每个对象都有个
monitor
对象,加锁就是在竞争 monitor 对象,代码块加锁是在前后分别加上monitorenter
和monitorexit
指令来实现的,方法加锁是通过一个标记为来判断的。 - synchronized 是一个重量级操作,需要调用操作系统相关接口,性能是低效的,有可能给线程加锁消耗的时间比有用操作消耗的时间更多。
- JDK1.6 中,对 synchronized 锁进行了很多的优化,有适应自旋、锁消除、锁粗化、偏向锁及轻量级锁等,效率有了本质上的提高。在之后推出的 Java1.7 与 1.8 中,均对该关键字的实现机理做了优化,引入了偏向锁和轻量级锁。都是在对象头中有标记为,不需要经过操作系统加锁。
- 锁可以从偏向锁升级到轻量级锁,再升级到重量级锁,这种升级过程叫做
锁膨胀
,也叫锁升级
。 - JDK1.6中,默认是开启偏向锁和轻量级锁,可以通过
-XX:-UseBiasedLocking
来禁用偏向锁。
五、ReentractLock 锁
ReentrantLock
集成接口 Lock 并实现了接口中定义的方法,他是一种 可重入锁,除了能完成 synchronized 所能完成的所有工作外,还提供了诸如可响应终端所、可轮询锁请求、定时锁等避免多线程死锁的方法。
5.1 Lock 接口的主要方法:
void lock()
:执行此方法时,如果锁处于空闲状态,当前线程将获取到锁。相反,如果锁已经被其他线程持有,将禁用当前线程,直到当前线程获取到锁。boolean tryLock()
:如果锁可用,则获取锁,并立即返回 true,否则返回 false。该方法和 lock() 的区别在于,tryLock() 只是 “试图” 获取锁,如果锁不可用,不会导致当前线程被禁用,当前线程仍然继续往下执行代码。而 lock() 方法则是一定要获取到锁,如果锁不可用,就一直等待,在未获得锁之前,当前线程并不继续向下执行。boolean tryLock(long time, TimeUnit unit)
:如果锁在给定时间内没有被另一个线程保持,则获取该锁。void unlock()
:执行此方法时,当前线程将释放持有的锁。锁只能由持有者释放,如果线程并不持有锁,却执行该方法,可能导致异常的发生。Condition newCondition()
:条件对象,获取等待通知组件。该组件和当前的锁绑定,当前线程只有获取了锁,才能调用该组件的 await() 方法,而调用后,当前线程将释放锁。getHoldCount()
:查询当前线程保持此锁的次数,也就是执行此线程执行 lock 方法的次数。getQueueLength()
:返回正等待获取此锁的线程估计数,比如启动 10 个线程,1个线程获得锁,此时返回的是9。getWaitQueueLength(Condition condition)
:返回等待与此锁相关的给定条件的线程估计数。比如 10 个线程,用同一个 condition 对象,并且此时这 10 个线程都执行了 condition 对象的 await 方法,那么此时执行此方法返回 10。hasWaiters(Condition condition)
:查询是否有线程等待此锁有关的给定条件(condition)。即对于指定 condition 对象,查询有多少线程执行了 condition.await() 方法。hasQueueThread(Thread thread)
:查询给定线程是否等待获取此锁。hasQueueThreads()
:是否有线程等待此锁。isFair()
:该锁是否为公平锁。isHeldByCurrentThread()
:当前线程是否保持锁锁定,线程的执行 lock() 方法的前后分别是 false 和 true。isLock()
:此锁是否有任意线程占用。lockInterruptibly()
:如果当前线程未被中断,获取锁。
5.2 ReentrantLock 与 synchronized 对比:
- ReentrantLock 通过方法
lock()
与unlock()
来进行加锁与解锁操作, 与 synchronized 结束后会被 JVM 自动解锁的机制不同,ReentractLock 加锁后需要手动进行解锁。为了避免程序出现异常而无法正常解锁的情况,使用 ReentractLock 必须在 finally 控制块中进行解锁操作。 - ReetrantLock 相比 synchronized 的优势是 可中断、公平锁、多个锁。这种情况下需要使用 ReentrantLock。
5.3 Condition 接口
Condition
接口是ReentrantLock
的一部分,它提供了比 Object 监视器方法(如wait()
和notify()
)更高级的线程等待/唤醒机制。Condition 允许更精确地控制线程间的同步,特别是当需要基于特定条件进行等待时。
Condition 接口通常与 ReentrantLock 一起使用,以实现更复杂的线程间协作模式,如:生产者-消费者模式、读者-写者模式等。下面介绍 3 个 Condition 的方法:
-
await()
:方法会使当前线程进入等待状态,并释放 ReentrantLock 锁,直到其他线程调用了相应的signal()
或signalAll()
方法时才会被唤醒。 -
signal()
:方法用于唤醒一个等待在该 Condition 上的线程。(注意:signal() 不会立即唤醒线程,而是将其放入就绪队列,由操作系统调度器决定何时真正唤醒。)
-
signalAll()
:方法会唤醒所有等待在该 Condition 上的线程。通常在需要所有等待线程都参与接下来的执行流程时使用。
5.4 ReentrantLock 使用示例1:基本使用
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;public class ReentrantLockExample {/*** 初始化锁*/