写在前面的话,因为作者是Java出身,所以会用Java来举例子,但是原理是共通的,其他语言的开发者也可以放心观看~
谈到java的锁就避不开synchronized关键字
如果是在面试中,面试官问你,请说下synchronized和ReentrantLock:
熟读八股文的你可能张口就来,synchronized是基于对象的非公平可重入的互斥锁,ReentrantLock是公平的可重入互斥锁
用户态的自旋,锁升级过程等吟唱内容
背的更多一些的可能还可以说出它的底层实现是基于mutex原语(一种底层系统调用,提供了最基本的互斥能力)实现的,jvm还通过内存屏障(一种防止指令重排的CPU指令,是保证可见性的关键)防止指令重排的机制等等内容。
但你知道吗?
以Linux的系统锁举例,它也是基于mutex的实现(如futex:一种Linux提供的混合态锁机制)
而且也futex包含了在用户态的自旋操作
而且它也依赖内存屏障
这么看似乎不管java还是linux都用不同的方式实现了相似的东西:
那我们可以大胆的推断,使用相似的解决方案,一定是为了解决某种共同的问题——只要实现锁就必须解决的问题!
我将其总结为实现锁的必须要解决的三要素即:
- 锁状态的可见性(可见性)
- 锁状态变动时的原子性(原子性)
- 解决锁的竞争调度问题(调度优化)
值得注意的是,这三大要素并非完全并列:
可见性和原子性是基础手段,它们是实现一个锁必须实现的基础目标。
而竞争调度问题则是评价一个锁是否优秀的指标
只要实现了可见性原子性就能实现一个锁,但只有解决了竞争调度的问题才算实现了一个优秀的锁!
理解了这三点,就等于掌握了理解所有锁机制的万能钥匙,接下来我们所有基于锁的论述都会基于这个三要素展开思考
一:使用前先验证
在正式讲解之前,让我们用最基础的linux锁futex来验证这个三要素:
1:首先futex依赖内存屏障(CPU的指令),保证锁状态值能从CPU缓存中及时刷新,被其他核心看到,保证了锁状态的可见性
2:futex依赖CPU级别的CAS指令,和内核态中的锁机制来保证原子性
3: futex会让线程在用户态下自旋,有竞争时才切换至内核态挂起线程,这就是futex的竞争调度策略
其中如果没有1和2的保证,futex根本不能称之为锁,而3的存在则让futex区别于其他的锁实现
可能对于一些专有名词,如内核态,用户态,CAS等内容你还不太理解
不过没关系!
至少我们已经知道linux确实使用了一些技术来解决我们提出的三要素问题,并且解决了这些问题后,才算实现了一个futex锁。
二:验证后进行推广
现在让我们将三要素推广到大家更熟悉的Java锁synchronized,来看下它是如何实现的:
通过一些常见的技术文章我们可以知道:
synchronized 在编译后变成 monitor enter(加锁)和monitor exit(解锁)两个指令
其中monitor enter操作时,会使用acquire内存屏障保证同步到主内存最新的数据,同时获取到锁监视器(ObjectMonitor),修改锁状态在markword中的标志位(锁状态)
使用 monitor exit操作则会使用release内存屏障,保证工作内存的数据同步到主内存同时释放锁监视器(ObjectMonitor),修改锁状态在markword中的标志位(锁状态)
现在让我们根据三要素来分析一下上面这段话:
让我们提取一下关键字,acquire内存屏障与release内存屏障,这个其实就是我们常说的jvm的内存屏障
为什么要通过内存屏障刷新到主存?
和我们上面讲的futex的内存屏障一个作用,保证可见性
只不过这里从让其他核心看到变成了让其他线程看到(实际上让其他线程看到本质上也是让cpu不同核心上的线程能够共享数据,但是为了减少心智负担,后面在java中的线程分析将不再单独解释)。
“同时获取到锁监视器(ObjectMonitor),修改锁状态在markword中的标志位”怎么理解呢?
保证原子性!
实际上monitor指令去修改标志位时会调用cpu级别的cas指令或是mutex原语来保证修改ObjectMonitor时的原子性。ObjectMonitor则存储了锁状态(包括持有状态、当前是偏向锁、轻量锁、重量锁等相关信息)。
最后synchronized作为jvm提供的基础锁,那必然是一个优秀的锁,让我们看下它是如何解决竞争调度问题
我们都知道1.8之后synchronized增加了锁升级的过程,升级的条件是什么呢?就是竞争!
当锁不存在竞争时,synchronized默认处于偏向锁状态(修改ObjectMonitor的状态为偏向锁),这个时候它只检查线程id是不是对应这个锁。(我赌你不会有线程冲突)
当竞争不严重的时候,synchronized就会升级到轻量锁状态(修改ObjectMonitor的状态为轻量锁),这个时候jvm不会立刻挂起线程,而是会先尝试通过CAS操作(保障原子性)能不能拿到锁,拿不到再进行自适应自旋等待,然后继续尝试拿锁,还没成功就继续升级(万一轮到我了呢)
只有当竞争严重时,synchronized才会升级至重量锁(修改ObjectMonitor的状态为重量锁),jvm会调用底层mutex原语,,并初始化一个ObjectMonitor监视器对象,将竞争失败的线程放入该对象的_entryList队列中挂起,等待唤醒后再抢锁(做不了我女朋友先当你男闺蜜)
什么是策略?锁升级的过程就是synchronized的策略,就是它的竞争策略!
从我们对synchronized的竞争策略分析我们明显可以看出,策略是可以改变的:
比如说重量锁时,如果我们不直接让它自旋等待,而是先看下_entrylist中有没有线程被阻塞,如果有线程已经阻塞我们就不自旋了,直接将自己挂起,然后我们唤醒时严格根据挂起的顺序唤醒线程——那恭喜你发明出了公平锁!
那么java中有没有使用这种策略的呢?
当然有,那就是——ReentrantLock
我们知道ReentrantLock是基于AQS实现的锁。
那什么是AQS?
AQS包括了两个核心:
- 使用volatile定义的锁状态
- AQS内部维护了一个双向的线程等待队列(它是CLH队列的一种变体)
一个字段表示状态,一个队列保存等待线程,这么说是不是有一点熟悉?
我们上面分析synchronized的时候是不是说过,重量级锁的实现中是不是也有一个ObjectMonitor对象用来存储状态,有一个_entryList用来存储阻塞队列。
实际上:状态字段+队列,这是一种相当通用的模型,记住这句话后面要考的哦。
既然ReentrantLock基于AQS实现,那让我们直接用三要素去理解下AQS:
-
可见性:AQS通过volatitle来保证锁状态在线程中的可见性。
-
原子性:AQS通过调用CPU级别的CAS指令来保证锁状态修改的原子性。
基本所有基于AQS实现的锁对象都是如此保证可见性和原子性的。
那么竞争策略呢?
就像我们说的竞争策略决定了一个锁的优秀程度。
现在可以加上一句,正因为竞争策略的不同,才出现了我们所知道的在不同场景下能够产生优秀效果的锁。
像我们所知的公平锁、非公平锁、共享锁、互斥锁、可重入锁、不可重入锁、信号量(Semaphore)、栅栏(CountDownLatch)等等。
实际上都是因为在不同场景下使用了不同的竞争策略的才产生的不同命名,有时候它们还可以组合起来在一个锁上实现多种策略。
因此,AQS将策略实现的逻辑抽象为接口,交给不同的子类来实现,也就出现了我们平常见到的各类AQS锁。
比如说ReentrantLock,它就同时实现支持公平、非公平、互斥、可重入等策略,并让我们可以通过参数指定不同的策略来使用。
而像Semaphore、CountDownLatch这些,其实本质上是和ReentrantLock同样的东西,不过它们为了特殊场景修改了state字段的语义。
让state字段从表示"重入次数"变成了表示"许可证数量"或"剩余计数"
看到这里,相信以后遇到的各类单机锁都无法困住你了,因为大多数锁实际上都是在使用了不同竞争策略后,给它起了个新名字,我们完全可以通过这个框架去分析它。
但是......
分布式锁呢?三要素是否也适用于分布式锁呢?
如果让你实现一个分布式锁你会怎么想?
模仿AQS的状态+队列思想是否可行?
我们可以通过某个对所有节点都可见的服务来存储锁状态
然后通常在业务里通过循环等待的方式抢锁。
这个结构熟悉吗?那些循环等待的进程像不像非公平锁的等待队列?
但需要注意的是:
在分布式中我们没有cpu的原子指令来保证锁状态变动的原子性,也没有内存屏障来保证锁状态的可见性
在这个新的战场上,我们依然要解决老问题(原子性、可见性),但手段完全不同:
原子性:不再依赖CPU指令,而是依赖第三方系统提供的原子操作(如Redis的SETNX命令、Lua脚本,ZooKeeper的节点操作)。
可见性:不再依赖内存屏障,而是依赖第三方存储系统的数据持久化与复制机制。一个服务写入的锁状态,必须能通过该系统的协议被其他服务查询到。
竞争调度(互斥):这不再是简单地挂起线程。由于没有中央队列,通常采用‘客户端自旋’(定期重试)的方式,或者依赖第三方系统的高级特性(如ZooKeeper的临时顺序节点和Watch机制)来实现更高效的阻塞与唤醒。”
我会在下一篇文章详细介绍分布式环境下怎么实现的锁的三要素,并将它们和单机锁进行关联对应。
敬请期待