目录
- 澄清误解
- synchronized 与 ReentrantLock对比
- 乐观锁 vs 悲观锁
- 公平锁 vs 非公平锁
- synchronized的锁升级
- ReentrantLock的CLH队列
- 可重入
- 与CAS的关系
- 总结
前言: 上一篇在对比锁与volatile机制的时候,因为没有太多考虑synchronized 和ReentrantLock的区分,有一些关于锁的理解是有错误的。随着后续对这两个锁机制的理解,决定不在当初的文章上进行改动了,而是新写一篇记录学习过程。
至于什么叫“浅谈”?我已经预感到自己会对这个两个东西有逐渐深入的理解,可能后续还会有对于它们俩的不同版本、不同层次的理解,最开始的一篇,从浅谈开始吧。
澄清误解
上一篇我发现的主要存在几个地方对“锁”的误解,以下是我在学习了synchronized和ReentrantLock之后的修正。
误解1:锁 锁住的是共享内存,不是线程,不是代码逻辑。
这个是概念性的理解,但比较致命。首先ReentrantLock 它自己就是一个对象,甚至和你要保护的共享内存没有直接关系。从这个角度上,这个结论就是错的。另外,即使对于synchronized 来说,也不是锁住了它要保护的共享内存。synchronized(this/obj) 并不代表this/obj是它要上锁的内存,这里的this/obj只是一个锁标志位,不是说我的锁标志位(object) 是什么,我就锁住了什么object作为共享内存,实际上你可以用任何的object作为锁标志位,只是相同的object,代表同一把锁。
因此,锁,并不是锁住了共享内存,而是规范了“共享内存的访问途径”。至于它的实现方式,synchronized是一种用一个obj作为唯一的令牌,保证同一时间只有一个线程能执行后面的临界区代码块。
误解2:代码逻辑执行到syncronized/ReentrantLock锁时,会触发锁逻辑,去访问对象头。
这一个误解,来自于我基于synchronized关键字出发了。synchronized关键字的效果,是对某一个锁标志对象改变它的Markword。ReentrantLock则是它的实例化对象中的state字段作为标志位。这两者相似的地方是执行临界区前,都是会判断一个标志位,synchronized是访问obj 的mark word,lock.lock() 则是去访问lock 对象中的state。
误解3:锁是一种逻辑机制,而不是物理屏障
其实这里是没有误解的,只是我理解到了,但没有把这种理解应用到各个地方。
当我们使用锁的时候,实际上是进行了一种“规约先行”,定义了某种逻辑规范,如果程序遵守这个规范,则可以达到“上锁”的目的,如果代码没有遵循,那么其实是锁不住的。这就是说并不是物理屏障,不是说我对一段代码上了synchronized/ReentrantLock对象之后,它就是铜墙铁壁了。锁的效果,取决于代码是否按照这个约定去编写,比如锁失效问题,如果我用一把锁锁住了临界区,用另一把锁(或者干脆不用锁)对临界区中的共享内存做修改,很显然是可以立即成功的。因为这个时候修改操作没有遵守这个前面你上的那把锁的规范。
synchronized 与 ReentrantLock对比
都是锁机制,这里想从相似和对比的角度去理解。
乐观锁 vs 悲观锁
首先回顾一下什么是乐观锁和悲观锁 。
乐观锁:乐观锁本质上是无锁行为,进行检查-执行这一个原子操作,如果成功则执行成功,如果失败则说明线程抢占失败。
悲观锁:假设冲突一定会发生。于是先上锁,再执行。无论是否是怎样的竞争场景,执行代码前都对资源上锁。
synchronized和ReentrantLock都是悲观锁。
synchronized的使用就是在进行临界区前,必须先获得锁,如果锁被其他线程持有,则当前线程阻塞。但是注意,synchronized在退出同步块时,锁自动释放。
ReentrantLock 使用lock()方法获取锁,在进入try{}块的临界区前,也是要获取锁的,否则阻塞。ReentrantLock则在finally快中用unlock()手动释放。
公平锁 vs 非公平锁
先回顾一下什么是公平锁和非公平锁。
公平锁:对于抢占资源的线程,严格尊重FIFO规则进行阻塞和执行。即先阻塞的,先被唤起。
非公平锁:线程对资源的执行不严格遵守FIFO,即先阻塞的也有可能被后来的线程“插队”执行。
synchronized是非公平锁,且只是非公平锁。
ReentrantLock默认是非公平锁,可配置为公平锁。
synchronized的锁升级
很多程序员看八股文的时候都记得synchronized锁升级的概念,这东西背着也没意义,只是指导理解,在这里再简单说一下。
synchronized锁有4种状态,相应地mark word中关于锁的存储信息也不一样。
无锁 (No Lock) -> 偏向锁 (Biased Lock) -> 轻量级锁 (Lightweight Lock) -> 重量级锁 (Heavyweight Lock)。
synchronized作为JVM层级的实现,性能是它的首要考量。非公平锁的性能是优于公平锁的。当synchronized关键字升级为重量级锁时,此时markword 存的是Monitor对象的地址,这个Monitor对象里有一个字段是EntryList,抢锁失败的线程会进入EntryList队列阻塞,等待被唤起执行。但是这个EntryList不是严格FIFO的,当锁释放的时候,新来的线程回合EntryList的头线程一起竞争锁。
ReentrantLock的CLH队列
默认是非公平锁时,线程来了还是会先抢一下,和synchronized类似,如果抢到了,就立即执行,如果没抢到,再去排队。
ReentrantLock是基于AQS的,AQS作为一个基础数据结构,里面有一个CLH队列,名字不重要,好像是三个人的名字缩写。简单来说如果ReentrantLock配置为公平锁,那么等待线程进队列和出队列的过程是遵循FIFO的。
可重入
ReentrantLock从名字上就能看出它是可重入锁,其实synchronized也是可重入的。
先说syncrhonized 的,它是JVM层面对可重入的次数进行统计。依赖于一个C++对象中的字段_recursions:记录递归(重入)的次数。
ReentrantLock基于AbstractQueuedSynchronizer(AQS),AQS是一个数据结构,JUC包的基础数据结构。其中它有一个字段叫state,是int 型的。state=0,说明锁未被占用,state>=1记录了重入的次数。
与CAS的关系
在前面的解释中,适中有一个概念被隐去了,就是CAS。CAS这个魔法操作其实可以说在这两个锁机制里随处可见。因为它是最底层的CPU级别的原子指令,在最后解释。
CAS是什么Compare-And-Swap,一条原子性CPU指令,如果想改变变量V的值从A到B,先检查内存 V 里的值是不是 A?如果是,就把它改成 B;如果不是(说明被别人改过了),返回失败。
CAS这个工具很重要,首先它是原子性的,另外它是无锁的,有极高的效率。这两点决定了它在提成并发编程的效率性角度会被广泛应用。
CAS在锁中的应用场景
- synchronized和ReentrantLock在抢占锁的时候,都执行了CAS,即如果CAS成功,则表示抢占锁成功。
- synchronized和ReentrantLock在抢占失败的线程入队列时,也执行了CAS保证入队列的操作是线性单线程的。
自旋锁
基于CAS还有一种自旋锁,其实也是一种“无锁”,利用CAS的特点不断去抢占执行,直到成功执行,当然如果抢占不成功,会一直自旋,于是叫自旋锁。优点是没有线程的阻塞和唤起,不涉及内核切换,高效。缺点是CPU可能长时间空旋,浪费CPU资源,比较适用于简单的操作。
//对于单纯的计数器,并发安全可以用CAS实现,比锁效率高privatefinalAtomicLongbalance=newAtomicLong(0L);publicvoidupdateBalance(longdifference){longcurrentBalance;longnewBalance;do{currentBalance=this.balance.get();newBalance=currentBalance+difference;if(newBalance<0){thrownewInsufficientFundException();}}while(balance.compareAndSet(currentBalance,newBalance));}总结
两个锁机制,synchronized在JVM层,比较底层。ReentrantLock则在Java代码(JDK)层,偏引用层。
本篇比较了synchronized和ReentrantLock在并发编程的多个锁概念上的解释。仔细理解会发现,ReentrantLock在syncrhonized这种底层是线上其实借鉴了很多,或者说它们是相互影响的,对于锁机制的设计其实有非常多共通的设计模式。
后记:因为最近很喜欢和G老师、C老师进行学习,但发现它们的逻辑还是不够严谨的,它们产生幻觉,对我给出的结论表示赞同。那么对于理解的不好的地方,很容易被骗过,得到错误的知识。前面很多错误的理解就是这么来的。好在在这个过程里我是不断在思考的,这样就有纠错的可能。在我和AI一起学习的这段时间里,我的心得始终是,和它一起思考,而不是任由它给结论。