面试补习系列:
- 《面试补习》- JVM知识点大梳理
- 《面试补习》- Java锁知识大梳理
一、锁的分类
1、乐观锁和悲观锁
乐观锁就是乐观的认为不会发生冲突,用cas和版本号实现
悲观锁就是认为一定会发生冲突,对操作上锁
1.悲观锁
悲观锁,总是假设最坏的情况,每次去拿数据的时候都认为别人会修改,所以每次在拿数据的时候都会上锁,这样别人想拿这个数据就会阻塞直到它拿到锁。
1 | 复制代码传统的关系型数据库里边就用到了很多这种锁机制,比如行锁,表锁等,读锁,写锁等,都是在做操作之前先上锁。 |
适用场景:
比较适合写入操作比较频繁的场景,如果出现大量的读取操作,每次读取的时候都会进行加锁,这样会增加大量的锁的开销,降低了系统的吞吐量。
实现方式: synchronized
和Lock
2.乐观锁
每次去拿数据的时候都认为别人不会修改,所以不会上锁,但是在更新的时候会判断一下在此期间别人有没有去更新这个数据,可以使用版本号等机制
1 | 复制代码ABA问题(JDK1.5之后已有解决方案):CAS需要在操作值的时候检查内存值是否发生变化,没有发生变化才会更新内存值。但是如果内存值原来是A,后来变成了B,然后又变成了A,那么CAS进行检查时会发现值没有发生变化,但是实际上是有变化的。ABA问题的解决思路就是在变量前面添加版本号,每次变量更新的时候都把版本号加一,这样变化过程就从“A-B-A”变成了“1A-2B-3A”。 |
适用场景:
比较适合读取操作比较频繁的场景,如果出现大量的写入操作,数据发生冲突的可能性就会增大,为了保证数据的一致性,应用层需要不断的重新获取数据,这样会增加大量的查询操作,降低了系统的吞吐量。
实现方式:
1、使用版本标识来确定读到的数据与提交时的数据是否一致。提交后修改版本标识,不一致时可以采取丢弃和再次尝试的策略。
2、Java 中的 Compare and Swap 即 CAS ,当多个线程尝试使用 CAS 同时更新同一个变量时,只有其中一个线程能更新变量的值,而其它线程都失败,失败的线程并不会被挂起,而是被告知这次竞争中失败,并可以再次尝试。
3、在 Java 中 java.util.concurrent.atomic
包下面的原子变量类就是使用了乐观锁的一种实现方式 CAS 实现的。
2、公平锁/非公平锁
公平锁:
1 | 复制代码指多个线程按照申请锁的顺序来获取锁。 |
非公平锁:
1 | 复制代码指多个线程获取锁的顺序并不是按照申请锁的顺序,有可能后申请的线程比先申请的线程优先获取锁。 |
拓展线程饥饿
:
1 | 复制代码一个或者多个线程因为种种原因无法获得所需要的资源,导致一直无法执行的状态 |
实现方式: ReenTrantLock
(公平/非公平)
对于Java ReentrantLock
而言,通过构造函数指定该锁是否是公平锁,默认是非公平锁。非公平锁的优点在于吞吐量比公平锁大。
对于Synchronized
而言,也是一种非公平锁。由于其并不像ReentrantLock
是通过AQS(AbstractQueuedSynchronizer)
的来实现线程调度,所以并没有任何办法使其变成公平锁。
3、可重入锁
如果一个线程获得过该锁,可以再次获得,主要是用途就是在递归方面,还有就是防止死锁,比如在一个同步方法块中调用了另一个相同锁对象的同步方法块
实现方式: synchronized
、ReentrantLock
4、独享锁/共享锁
1 | 复制代码独享锁是指该锁一次只能被一个线程所持有。 |
实现方式:
独享锁: ReentrantLock
和 synchronized
贡献锁: ReadWriteLock
拓展:
互斥锁/读写锁 就是对上面的一种具体实现:
1 | 复制代码互斥锁:在Java中的具体实现就是ReentrantLock,synchronized |
对于Java ReentrantLock而言,其是独享锁。但是对于Lock的另一个实现类ReadWriteLock,其读锁是共享锁,其写锁是独享锁。读锁的共享锁可保证并发读是非常高效的,读写,写读 ,写写的过程是互斥的。独享锁与共享锁也是通过AQS来实现的,通过实现不同的方法,来实现独享或者共享。对于Synchronized而言,当然是独享锁
5、偏向锁/轻量级锁/重量级锁
基于 jdk 1.6 以上
偏向锁
指的是当前只有这个线程获得,没有发生争抢,此时将方法头的markword设置成0,然后每次过来都cas一下就好,不用重复的获取锁.指一段同步代码一直被一个线程所访问,那么该线程会自动获取锁。降低获取锁的代价
轻量级锁
:在偏向锁的基础上,有线程来争抢,此时膨胀为轻量级锁,多个线程获取锁时用cas自旋获取,而不是阻塞状态
重量级锁
:轻量级锁自旋一定次数后,膨胀为重量级锁,其他线程阻塞,当获取锁线程释放锁后唤醒其他线程。(线程阻塞和唤醒比上下文切换的时间影响大的多,涉及到用户态和内核态的切换)
实现方式: synchronized
6、分段锁
在1.7的concurrenthashmap中有分段锁的实现,具体为默认16个的segement数组,其中segement继承自reentranklock,每个线程过来获取一个锁,然后操作这个锁下连着的map。
实现方式:
1 | 复制代码我们以ConcurrentHashMap来说一下分段锁的含义以及设计思想,ConcurrentHashMap中的分段锁称为Segment, |
二、锁的底层实现
1、Synchronized
synchronized 关键字通过一对字节码指令 monitorenter/monitorexit 实现
前置知识:
1 | 复制代码对象头: |
对象头结构:
Monitor数据结构:
1 | 复制代码ObjectMonitor() { |
ObjectMonitor中有两个队列,_WaitSet
和 _EntryList
,用来保存ObjectWaiter
对象列表( 每个等待锁的线程都会被封装成ObjectWaiter对象),_owner
指向持有ObjectMonitor
对象的线程,当多个线程同时访问一段同步代码时,首先会进入 _EntryList
集合,当线程获取到对象的monitor
后进入 _Owner
区域并把monitor
中的owner
变量设置为当前线程同时monitor中的计数器count加1
.
若线程调用 wait() 方法,将释放当前持有的monitor,owner变量恢复为null,count自减1,同时该线程进入 WaitSe t集合中等待被唤醒。若当前线程执行完毕也将释放monitor(锁)并复位变量的值,以便其他线程进入获取monitor(锁)
1 | 复制代码这里比较复杂,但是建议仔细阅读,便于后续分析的时候理解 |
1.1、字节码实现
同步代码块:
1 | 复制代码public class SynchronizedTest { |
synchronized
关键字基于上述两个指令实现了锁的获取和释放过程:
monitorenter
指令插入到同步代码块的开始位置,
monitorexit
指令插入到同步代码块的结束位置.
线程执行到 monitorenter 指令时,将会尝试获取对象所对应的 Monitor 所有权,即尝试获取对象的锁。
1 | 复制代码当执行monitorenter指令时,当前线程将试图获取 objectref(即对象锁) 所对应的 monitor 的持有权,当 objectref 的 monitor 的进入计数器为 0,那线程可以成功取得 monitor,并将计数器值设置为 1,取锁成功。如果当前线程已经拥有 objectref 的 monitor 的持有权,那它可以重入这个 monitor (关于重入性稍后会分析),重入时计数器的值也会加 1。倘若其他线程已经拥有 objectref 的 monitor 的所有权,那当前线程将被阻塞,直到正在执行线程执行完毕,即monitorexit指令被执行,执行线程将释放 monitor(锁)并设置计数器值为0 ,其他线程将有机会持有 monitor 。 |
同步方法:
1 | 复制代码synchronized 方法则会被翻译成普通的方法调用和返回指令如: |
1 | 复制代码 //省略没必要的字节码 |
以下部分参考: JVM源码分析之synchronized实现
1.2、偏向锁获取
1 | 复制代码1、获取对象头的Mark Word; |
在大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,因此为了减少同一线程获取锁(会涉及到一些CAS操作,耗时)的代价而引入偏向锁。偏向锁的核心思想是,如果一个线程获得了锁,那么锁就进入偏向模式,此时Mark Word 的结构也变为偏向锁结构,当这个线程再次请求锁时,无需再做任何同步操作,即获取锁的过程,这样就省去了大量有关锁申请的操作,从而也就提供程序的性能。所以,对于没有锁竞争的场合,偏向锁有很好的优化效果,毕竟极有可能连续多次是同一个线程申请相同的锁。
注意 JVM 提供了关闭偏向锁的机制, JVM 启动命令指定如下参数即可
1 | 复制代码-XX:-UseBiasedLocking |
偏向锁的撤销:
1 | 复制代码偏向锁的 撤销(revoke) 是一个很特殊的操作, 为了执行撤销操作, 需要等待全局安全点(Safe Point), |
1.3、轻量级锁
在多线程交替执行同步块的情况下,尽量避免重量级锁引起的性能消耗,但是如果多个线程在同一时刻进入临界区,会导致轻量级锁膨胀升级重量级锁,所以轻量级锁的出现并非是要替代重量级锁。
1 | 复制代码1、获取对象的markOop数据mark; |
1.4、重量级锁
重量级锁通过对象内部的监视器(monitor)实现,其中monitor的本质是依赖于底层操作系统的Mutex Lock实现,操作系统实现线程之间的切换需要从用户态到内核态的切换,切换成本非常高。
锁膨胀过程:
1 | 复制代码1、整个膨胀过程在自旋下完成; |
Monitor 竞争:
1 | 复制代码1、通过CAS尝试把monitor的_owner字段设置为当前线程; |
其本质就是通过CAS设置monitor的_owner字段为当前线程,如果CAS成功,则表示该线程获取了锁,跳出自旋操作,执行同步代码,否则继续被挂起;
Monitor 释放:
当某个持有锁的线程执行完同步代码块时,会进行锁的释放,给其它线程机会执行同步代码,在HotSpot中,通过退出monitor的方式实现锁的释放,并通知被阻塞的线程.
1.5、锁优化内容
锁消除:
1 | 复制代码消除锁是虚拟机另外一种锁的优化,这种优化更彻底, |
锁粗化:
1 | 复制代码将多个连续的加锁、解锁操作连接在一起,扩展成一个范围更大的锁。 |
自旋锁:
1 | 复制代码线程的阻塞和唤醒,需要 CPU 从用户态转为核心态。频繁的阻塞和唤醒对 CPU 来说是一件负担很重的工作,势必会给系统的并发性能带来很大的压力。 |
锁升级:
2、ReetrantLock
2.1、Lock
1 | 复制代码 //加锁 |
在Java 1.5中,官方在concurrent并发包(J.U.C)
中加入了Lock接口,该接口中提供了lock()方法和unLock()方法对显式加锁和显式释放锁操作进行支持.
Lock 锁提供的优势:
1 | 复制代码可以使锁更公平。 |
2.2、AQS (AbstractQueuedSynchronizer)
AQS 即队列同步器。它是构建锁或者其他同步组件的基础框架(如 ReentrantLock、ReentrantReadWriteLock、Semaphore 等),J.U.C 并发包的作者(Doug Lea)期望它能够成为实现大部分同步需求的基础。
数据结构:
1 | 复制代码 //同步队列头节点 |
AQS 使用一个 int 类型的成员变量 state 来表示同步状态:
- 当
state > 0
时,表示已经获取了锁。 - 当
state = 0
时,表示释放了锁。
Node构成FIFO的同步队列来完成线程获取锁的排队工作
- 如果当前线程获取同步状态失败(锁)时,AQS 则会将当前线程以及等待状态等信息构造成一个节点(Node)并将其加入同步队列,同时会阻塞当前线程
- 当同步状态释放时,则会把节点中的线程唤醒,使其再次尝试获取同步状态
参考: 深入剖析基于并发AQS的(独占锁)重入锁(ReetrantLock)及其Condition实现原理
2.3、Sync
Sync
:抽象类,是ReentrantLock的内部类,继承自AbstractQueuedSynchronizer,实现了释放锁的操作(tryRelease()方法),并提供了lock抽象方法,由其子类实现。
NonfairSync
:是ReentrantLock的内部类,继承自Sync,非公平锁的实现类。
FairSync
:是ReentrantLock的内部类,继承自Sync,公平锁的实现类。
AQS、Sync 和 ReentrantLock 的具体关系图:
2.4、ReentrantLock 实现原理
构造函数:
1 | 复制代码public ReentrantLock() { |
ReentrantLock 提供两种实现方式,公平锁/非公平锁. 通过构造函数进行初始化 sync
进行判断当前锁得类型.
2.4.1、非公平锁(NonfairSync)
1 | 复制代码 final void lock() { |
先对同步状态执行CAS操作,尝试把state的状态从0设置为1,
如果返回true则代表获取同步状态成功,也就是当前线程获取锁成,可操作临界资源,如果返回false,则表示已有线程持有该同步状态(其值为1)
获取锁失败,注意这里存在并发的情景,也就是可能同时存在多个线程设置state变量,因此是CAS操作保证了state变量操作的原子性。返回false后,执行acquire(1)
方法
#acquire(int arg)
方法,为 AQS 提供的模板方法。该方法为独占式获取同步状态,但是该方法对中断不敏感。也就是说,由于线程获取同步状态失败而加入到 CLH 同步队列中,后续对该线程进行中断操作时,线程不会从 CLH 同步队列中移除。
acquire
代码:
1 | 复制代码 public final void acquire(int arg) { |
1、tryAcquire
尝试获取同步状态
1 | 复制代码 final boolean nonfairTryAcquire(int acquires) { |
2、acquireQueued
加入队列中,自旋获取锁
1 | 复制代码private Node addWaiter(Node mode) { |
流程图:
2.4.2、公平锁(FairSync)
与非公平锁不同的是,在获取锁的时,公平锁的获取顺序是完全遵循时间上的FIFO规则,也就是说先请求的线程一定会先获取锁,后来的线程肯定需要排队,这点与前面我们分析非公平锁的nonfairTryAcquire(int acquires)方法实现有锁不同,下面是公平锁中tryAcquire()方法的实现
1 | 复制代码 protected final boolean tryAcquire(int acquires) { |
2.4.3、解锁
1 | 复制代码//ReentrantLock类的unlock |
3、ReentrantReadWriteLock
构造函数:
1 | 复制代码Lock readLock(); |
java.util.concurrent.locks.ReentrantReadWriteLock
,实现 ReadWriteLock
接口,可重入的读写锁实现类。在它内部,维护了一对相关的锁,一个用于只读操作,另一个用于写入操作。只要没有 Writer
线程,读取锁可以由多个 Reader
线程同时保持。也就说说,写锁是独占的,读锁是共享的。
在 ReentrantLock 中,使用 Sync ( 实际是 AQS )的 int 类型的 state 来表示同步状态,表示锁被一个线程重复获取的次数。但是,读写锁 ReentrantReadWriteLock 内部维护着一对读写锁,如果要用一个变量维护多种状态,需要采用“按位切割使用”的方式来维护这个变量,将其切分为两部分:高16为表示读,低16为表示写。
分割之后,读写锁是如何迅速确定读锁和写锁的状态呢?通过位运算。假如当前同步状态为S,那么:
- 写状态,等于 S & 0x0000FFFF(将高 16 位全部抹去)
- 读状态,等于 S >>> 16 (无符号补 0 右移 16 位)。
1、readLock
1 | 复制代码 public final void acquireShared(int arg) { |
4、synchronized 和 ReentrantLock 异同?
相同点
1 | 复制代码都实现了多线程同步和内存可见性语义。 |
不同点
1 | 复制代码同步实现机制不同 |
本文转载自: 掘金