基础篇:详解锁原理,volatile+cas、synchronized的底层实现
目录
- 随着多进程多线程的出现,对共享资源(设备,数据等)的竞争往往会导致资源的使用表现为随机无序
- 例如:一个线程想在控制台输出"I am fine",刚写到"I am",就被另一线程抢占控制台输出"naughty",导致结果是"I am naughty";对于资源的被抢占使用,我们能怎么办呢?当然不是凉拌,可使用锁进行同步管理,使得资源在加锁期间,其他线程不可抢占使用
1 锁的分类
- 悲观锁
- 悲观锁,每次去请求数据的时候,都认为数据会被抢占更新(悲观的想法);所以每次操作数据时都要先加上锁,其他线程修改数据时就要等待获取锁。适用于写多读少的场景,synchronized就是一种悲观锁
- 乐观锁
- 在请求数据时,觉得无人抢占修改。等真正更新数据时,才判断此期间别人有没有修改过(预先读出一个版本号或者更新时间戳,更新时判断是否变化,没变则期间无人修改);和悲观锁不同的是,期间数据允许其他线程修改
- 自旋锁
- 一句话,魔力转转圈。当尝试给资源加锁却被其他线程先锁定时,不是阻塞等待而是循环再次加锁
- 在锁常被短暂持有的场景下,线程阻塞挂起导致CPU上下文频繁切换,这可用自旋锁解决;但自旋期间它占用CPU空转,因此不适用长时间持有锁的场景
2 synchronized底层原理
- 代码使用synchronized加锁,在编译之后的字节码是怎样的呢
截取部分字节码,如下
字节码出现了4: monitorenter和14: monitorexit两个指令;字面理解就是监视进入,监视退出。可以理解为代码块执行前的加锁,和退出同步时的解锁
- 那monitorenter和monitorexit,又背着我们干了啥呢?
- 执行monitorenter指令时,线程会为锁对象关联一个ObjectMonitor对象
- 每个线程都有两个ObjectMonitor对象列表,分别为free和used列表,如果当前free列表为空,线程将向全局global list请求分配ObjectMonitor
- ObjectMonitor的owner、WaitSet、Cxq、EntryList这几个属性比较关键。WaitSet、Cxq、EntryList的队列元素是包装线程后的对象-ObjectWaiter;而获取owner的线程,既为获得锁的线程
- monitorenter对应的执行方法
- monitorexit对应的执行方法
void ATTR ObjectMonitor::exit(TRAPS)...
代码太长,就不贴了。主要是recursions减1、count减少1或者如果线程不再持有owner(非重入加锁)则设置owner为null,退锁的持有状态,并唤醒Cxq队列的线程
总结
- 线程遇到synchronized同步时,先会进入EntryList队列中,然后尝试把owner变量设置为当前线程,同时monitor中的计数器count加1,即获得对象锁。否则通过尝试自旋一定次数加锁,失败则进入Cxq队列阻塞等待
- 线程执行完毕将释放持有的owner,owner变量恢复为null,count自减1,以便其他线程进入获取锁
- synchronized修饰方法原理也是类似的。只不过没用monitor指令,而是使用ACC_SYNCHRONIZED标识方法的同步
- synchronized是可重入,非公平锁,因为entryList的线程会先自旋尝试加锁,而不是加入cxq排队等待,不公平
3 Object的wait和notify方法原理
- wait,notify必须是持有当前对象锁Monitor的线程才能调用 (对象锁代指ObjectMonitor/Monitor,锁对象代指Object)
- 上面有说到,当在sychronized中锁对象Object调用wait时会加入waitSet队列,WaitSet的元素对象就是ObjectWaiter
调用对象锁的wait()方法时,线程会被封装成ObjectWaiter,最后使用park方法挂起
而当对象锁使用notify()时
- 如果waitSet为空,则直接返回
- waitSet不为空从waitSet获取一个ObjectWaiter,然后根据不同的Policy加入到EntryList或通过
Atomic::cmpxchg_ptr
指令自旋操作加入cxq队列或者直接unpark唤醒
- Object的notifyAll方法则对应
voidObjectMonitor::notifyAll(TRAPS)
,流程和notify类似。不过会通过for循环取出WaitSet的ObjectWaiter节点,再依次唤醒所有线程
4 jvm对synchronized的优化
-
偏向锁
- 未加锁的时候,锁标志为01,包含哈希值、年龄分代和偏向锁标志位(0)
- 施加偏向锁时,哈希值和一部分无用内存会转化为锁主人的线程信息,以及加锁时的时间戳epoch,此时锁标志位没变,偏向锁标志改为1
- 加锁时先判断当前线程id是否与MarkWord的线程id是否一致,一致则执行同步代码;不一致则检查偏向标志是否偏向,未偏向则使用CAS加锁;未偏向CAS加锁失败和存在偏向锁会导致偏向锁膨胀为轻量级锁,或者重新偏向
- 偏向锁只有遇到其他线程竞争偏向锁时,持有偏向锁的线程才会释放锁,线程不会主动去释放偏向锁
-
轻量级锁
-
重量级锁
- 重量级锁就是上面介绍到synchronized使用监视器Monitor实现的锁机制
- 竞争线程激烈,锁则继续膨胀,变为重量级锁,也是互斥锁,锁标志位为10,MarkWord其余内容被替换为一个指向对象锁Monitor的指针
-
自旋锁
- 减少不必要的CPU上下文切换;在轻量级锁升级为重量级锁时,就使用了自旋加锁的方式
-
锁粗化
- 多次加锁操作在JVM内部也是种消耗,如果多个加锁可以合并为一个锁,就可减少不必要的开销
- 锁消除
- 删除不必要的加锁操作,如果变量是独属一个线程的栈变量,加不加锁都是安全的,编译器会尝试消除锁
- 开启锁消除需要在JVM参数上设置
-server -XX:+DoEscapeAnalysis -XX:+EliminateLocks
- 其他锁优化方法
- 分段锁,分段锁也并非一种实际的锁,而是一种思想;ConcurrentHashMap是学习分段锁的最好实践。主要是将大对象拆成小对象,然后对大对象的加锁操作变成对小对象加锁,增加了并行度
5 CAS的底层原理
- 在
volatile int i = 0; i++
中,volatile类型的读写是原子同步的,但是i++却不能保证同步性,我们该怎么呢? - 可以使用synchronized加锁;还有就是用CAS(比较并交换),使用乐观锁的思想同步,先判断共享变量是否改变,没有则更新。下面看看不同步版本的CAS
在jdk是有提供同步版的CAS解决方案,其中使用了UnSafe.java的底层方法
我们再来看看本地方法,Unsafe.cpp中的compareAndSwapInt
在Linux的x86,Atomic::cmpxchg方法的实现如下
到这一步,可以总结到:jdk提供的CAS机制,在汇编层级,会禁止变量两侧的指令优化,然后使用cmpxchg指令比较并更新变量值(原子性),如果是多核则使用lock锁定(缓存锁、MESI)
6 CAS同步操作的问题
- ABA问题
- 线程X准备将变量的值从A改为B,然而这期间线程Y将变量的值从A改为C,然后再改为A;最后线程X检测变量值是A,并置换为B。但实际上,A已经不再是原来的A了
- 解决方法,是把变量定为唯一类型。值可以加上版本号,或者时间戳。如加上版本号,线程Y的修改变为A1->B2->A3,此时线程X再更新则可以判断出A1不等于A3
- 只能保证一个共享变量的原子操作
- 只保证一个共享变量的原子操作,对多个共享变量同步时,循环CAS是无法保证操作的原子
7 基于volatile + CAS 实现同步锁的原理
- CAS只能同步一个变量的修改,我们又应该如何用它来锁住代码块呢?
- 先说说实现锁的要素
- 1 同步代码块同一时刻只能有一个线程能执行
- 2 加锁操作要happens-before同步代码块里的操作,而代码块里的操作要happens-before解锁操作
- 3 同步代码块结束后相对其他线程其修改的变量是可见的 (内存可见性)
- 要素1:可以利用CAS的原子性来实现,任意时刻只有一个线程能成功操作变量
- 先设想CAS操作的共享变量是一个关联代码块的同步状态变量,同步开始之前先CAS更新状态变量为加锁状态,同步结束之后,再CAS状态变量为无锁状态
- 如果期间有第二个线程来加锁,则会发现状态变量为加锁状态,则放弃执行同步代码块
- 要素2:使用volatile修饰状态变量,禁止指令重排
- volatile保证同步代码里的操作happens-before解锁操作,而加锁操作happens-before代码块里的操作
- 要素3:还是用volatile,volatile变量写指令前后会插入内存屏障
- volatile修饰的状态变量被CAS为无锁状态前,同步代码块的脏数据就会被更新,被各个线程可见
8 LockSupport了解一下
- LockSupport是基于Unsafe类,由JDK提供的线程操作工具类,主要作用就是挂起线程,唤醒线程。Unsafe.park,unpark操作时,会调用当前线程的变量parker代理执行。Parker代码
- 在Linux系统下,用的POSIX线程库pthread中的mutex(互斥量),condition来实现线程的挂起、唤醒
- 注意点:当park时,counter变量被设置为0,当unpark时,这个变量被设置为1
- unpark和park执行顺序不同时,counter和cond的状态变化如下
- 先park后unpark; park:counter值不变,但会设置一个cond; unpark:counter先加1,检查cond存在,counter减为0
- 先unpark后park;park:counter变为1,但不设置cond;unpark:counter减为0(线程不会因为park挂起)
- 先多次unpark;counter也只设置为为1
9 LockSupport.park和Object.wait区别
- 两种方式都有具有挂起的线程的能力
- 线程在Object.wait之后必须等到Object.notify才能唤醒
- LockSupport可以先unpark线程,等线程执行LockSupport.park是不会挂起的,可以继续执行
- 需要注意的是就算线程多次unpark;也只能让线程第一次park是不会挂起
10 AbstractQueuedSynchronizer(AQS)
- AQS其实就是基于volatile+cas实现的锁模板;如果需要线程阻塞等待,唤醒机制,则使用LockSupport挂起、唤醒线程
- 线程会先尝试获取锁,失败则封装成Node,CAS加入同步队列的尾部。在加入同步队列的尾部时,会判断前驱节点是否是head结点,并尝试加锁(可能前驱节点刚好释放锁),否则线程进入阻塞等待
在AQS还存一个ConditionObject的内部类,它的使用机制和Object.wait、notify类似
- 每个Condition对象内部包含一个Node元素的FIFO条件队列
- 当一个线程调用Condition.await()方法,那么该线程将会释放锁、构造Node加入条件队列并进入等待状态
- 调用Condition.signal时,获取条件队列的首节点,将其移动到同步队列并且利用LockSupport唤醒节点中的线程。随后继续执行wait挂起前的状态,调用acquireQueued(node, savedState)竞争同步状态
- volatile+cas机制保证了代码的同步性和可见性,而AQS封装了线程阻塞等待挂起,解锁唤醒其他线程的逻辑。AQS子类只需根据状态变量,判断是否可获取锁,是否释放锁成功即可
- 继承AQS需要选性重写以下几个接口
11 ReentrantLock的原理
- ReentrantLock实现了Lock接口,并使用内部类Sync(Sync继承AbstractQueuedSynchronizer)来实现同步操作
- ReentrantLock内部类Sync
- Sync的子类NonfairSync和FairSync都重写了tryAcquire方法
- 其中NonfairSync的tryAcquire调用父类的nonfairTryAcquire方法, FairSync则自己重写tryAcquire的逻辑。其中调用hasQueuedPredecessors()判断是否有排队Node,存在则返回false(false会导致当前线程排队等待锁)
12 AQS排他锁的实例demo
再来看看Sync的代码
13 使用锁,能防止线程死循环吗
- 答案是不一定的;对于单个资源来说是可以做的;但是多个资源会存在死锁的情况,例如线程A持有资源X,等待资源Y,而线程B持有资源Y,等待资源X
- 有了锁,可以对资源加状态控制,但是我们还需要防止死锁的产生,打破产生死锁的四个条件之一就行
- 1 资源不可重复被两个及以上的使用者占用
- 2 使用者持有资源并等待其他资源
- 3 资源不可被抢占
- 4 多个使用者形成等待对方资源的循环圈
14 ThreadLocal是否可保证资源的同步
- 当使用ThreadLocal声明变量时,ThreadLocal为每个使用该变量的线程提供独立的变量副本,每一个线程都可以独立地改变自己的副本,而不会影响其它线程所对应的副本
- 从上面的概念可知,ThreadLocal其实并不能保证变量的同步性,只是给每一个线程分配一个变量副本
关注公众号,大家一起交流
参考文章
- objectMonitor.cpp
- Moniter的实现原理
- JVM源码分析之Object.wait/notify实现
- Java对象头与锁
- LockSupport中park与unpark基本使用与原理
- Java并发之Condition
__EOF__
本文作者:潜行前行
本文链接:https://www.cnblogs.com/cscw/p/13769404.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
本文链接:https://www.cnblogs.com/cscw/p/13769404.html
关于博主:评论和私信会在第一时间回复。或者直接私信我。
版权声明:本博客所有文章除特别声明外,均采用 BY-NC-SA 许可协议。转载请注明出处!
声援博主:如果您觉得文章对您有帮助,可以点击文章右下角【推荐】一下。您的鼓励是博主的最大动力!
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 开发者必知的日志记录最佳实践
· SQL Server 2025 AI相关能力初探
· Linux系列:如何用 C#调用 C方法造成内存泄露
· AI与.NET技术实操系列(二):开始使用ML.NET
· 记一次.NET内存居高不下排查解决与启示
· Manus重磅发布:全球首款通用AI代理技术深度解析与实战指南
· 被坑几百块钱后,我竟然真的恢复了删除的微信聊天记录!
· 没有Manus邀请码?试试免邀请码的MGX或者开源的OpenManus吧
· 【自荐】一款简洁、开源的在线白板工具 Drawnix
· 园子的第一款AI主题卫衣上架——"HELLO! HOW CAN I ASSIST YOU TODAY