news 2026/9/17 20:23:01

Java线程(七):锁策略,锁优化策略,CAS解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Java线程(七):锁策略,锁优化策略,CAS解析

前言

hello hello💕,这里是洋不写bug~😄,欢迎大家点赞👍👍,关注😍😍,收藏🌹🌹
这篇博客会深度解析锁内部实现的规则,这些内容是比较偏理论的,在面试时考到的概率就比较高,在写代码的时候使用的并不多
这些内容已经算是线程进阶部分了,这也是Java线程部分的倒数第二篇博客🐵
🎆个人主页:洋不写bug的博客
🎆所属专栏:JavaEE学习
🎆铁汁们对于JavaEE的各种常用核心语法,都可以在上面的前端专栏学习,专栏正在持续更新中🏀,有问题可以写在评论区或者私信我哦~

1,锁策略

这篇博客中提到的锁策略主要是在实现一把锁的时候使用的,但是在工作中,基本上是不会让我们来实现一把锁的,这部分跟日常使用关系不大,主要会在面试中用到

1,乐观锁 VS 悲观锁

在使用锁的时候,预测这个锁遇到冲突的概率
如果预测遇到冲突的概率比较高,就称为“悲观锁”
如果预测遇到冲突的概率比较低,就称为“乐观锁”

有个概念,叫忙等(空转),也就是当线程遇到锁冲突的时候,线程不阻塞、不休眠、不让出 CPU,写死循环,一秒钟疯狂重复几十 / 上万次尝试抢锁,这样非常耗费cpu资源,但是有个好处,那就是一旦锁释放,能立刻拿到,速度快,几乎没有延迟



对于悲观锁,会经常发生锁冲突(多个线程抢一个锁),那线程竞争这把锁时,如果使用忙等的策略,每个线程都会一秒钟疯狂重复几十 / 上万次尝试抢锁,cpu资源就会崩溃卡死;
因此,对于悲观锁,线程抢锁失败就直接进入阻塞状态,不占用cpu资源去疯狂的抢锁,等别人用完锁再唤醒我即可,这样就可能有一些延迟,因为唤醒需要时间,线程被唤醒后,只是有重新参加锁竞争(比第一次阻塞前竞争要小),还会耗费一定时间



而对于乐观锁,认为锁遇到冲突的概率不高,没几个线程抢锁,竞争小,很快就能抢到,而且线程阻塞和唤醒也需要耗费资源,因此就直接让线程采用忙等的方式,循环尝试抢锁,这样延迟就会非常低(乐观锁就是竞争小,锁很快就会释放,稍微等一下就能拿到)

这里悲观锁和乐观锁的概念理解了,那接下来的所有概念基本上就都好理解了,因为基本上还是一个东西,只是不同讲法

2,重量级锁 VS 轻量级锁

悲观锁和乐观锁只是设计层面上的东西,在锁的实现时就把锁叫做重量级锁/轻量级锁
在日常中,也可以近似的把悲观锁和重量级锁,以及乐观锁和轻量级锁理解成一个东西



这里的重量和轻量指的时加锁开销的意思,重量级锁加锁开销大,轻量级锁加锁开销小
这里可能有的铁汁比较奇怪,那为什么悲观锁加锁的开销就大,乐观锁加锁开销就小呢?

原因如下:

  1. 悲观锁在设计的时候,里面要包含线程阻塞,唤醒的逻辑(线程状态切换),这些操作要依赖操作系统内核,开销是很高的
  2. 乐观锁在设计的时候,就简单了很多,不涉及线程状态切换,加锁和解锁只是简单的原子操作(解锁后不需要去唤醒其他线程),开销很小



因为悲观锁(重量级锁)和乐观锁(轻量级锁)的线程等待的方式是阻塞和自旋等待,因此,描述它们也可以称为阻塞锁和自旋锁

日常大家可以近似的把悲观锁/重量级锁/阻塞锁看成同个东西,把乐观锁/轻量锁/自旋锁看成一个东西,只是从不同的角度来描述的(严格理论上来说并不完全等价)

我们日常使用的sychronized采取的是自适应的方式,当锁竞争不激烈的时候,就会采取自旋锁的策略;当锁竞争比较激烈的时候,就会采取挂起等待的策略

3,公平锁 VS 不公平锁

这个是锁的获取规则的划分,跟前面的乐观/悲观锁,重量/轻量级锁的划分规则不是同一个维度

多个线程竞争同一把锁,一个线程拿到锁,执行完任务释放后
这时候其他线程拿到锁有两种规则:

  1. 遵循先来后到的排队规则,先来排队的线程先拿到锁
  2. 大家一起抢,跟谁先来后来没关系,谁抢到算谁的,大家抢到锁的概率均等



最早提出这个概念的大佬,就把“先来后到”方式称作公平,把“概率均等”叫做非公平
那要实现公平锁,让线程按照先来后到的方式拿锁,就需要引入队列,记录各个线程来的顺序
我们常用的synchroinzed就是非公平锁

4,可重入锁 VS 不可重入锁

这个在前面的死锁博客中详细提到过,这里再简单解析下:

“可重入锁”就是当发现一个线程被加了多把同样的锁,并且这些锁还是嵌套的关系,那里面套的锁就不会触发阻塞,例如下面这段代码,第一层锁加上后,下面这层锁并不会产生阻塞,代码还会向下继续运行

for(inti=0;i<50000;i++){synchronized(locker){synchronized(locker){count++;}}}



区分锁看的是锁对象,“重入锁”机制就是让锁对象自身来保存使用该锁的线程的信息
Java中的对象,有一片存储区域保存对象属性,还有一片区域保存“对象头”,对象头是由JVM维护的,保存了这个对象的其他一些运行信息(例如加锁状态,哪个线程加了锁)

可重入锁可以在一定的情况下解决死锁的问题, synchronized就是可重入锁,不可重入锁就是没有这种功能的

5,读写锁 VS 互斥锁

synchronzed就是互斥锁,也就是两个线程不能同时使用一把锁,只能等一个用完,另一个线程才能用、

而读写锁就不是这样,读写锁中分为读锁和写锁,读锁和写锁是互斥的,写锁和写锁是互斥的,但是读锁和读锁之间并不互斥,两个线程可以同时读取,并不会产生安全问题

2,锁的优化策略

因为Java程序员的水平会有差别,一些程序员可能并不能正确的使用锁
为了保证这些程序员写的代码的执行效率差的不会太多,JVM就采取了一系列的优化策略,来优化代码🐵

1,锁升级

JVM会将synchronized分为无锁 -> 偏向锁 -> 自旋锁 -> 重量级锁四种状态
这四种状态只会升级,不会降级(越靠右等级越高)

无锁就是不加锁,状态升级解析如下:

  1. 偏向锁并不是真正的加锁,只是做个标记(做个标记要比加锁轻量很多) 那在整个过程中,如果没有其他线程来尝试竞争这个锁,偏向锁的状态就会一直保持,线程用完锁后修改标记即可(修改标记比正常的解锁要轻量很多)
  2. 如果有其他线程来竞争这个锁了,那偏向锁就要升级了,升级成自旋锁,这样如果竞争过其他线程,就用这个锁;如果竞争不过其他线程,这个锁就暂时不能使用了
  3. JVM内部会统计一个锁有多少个线程在等待获取,如果发现竞争大,等待获取的线程比较多,那这时候如果用自旋锁的话,那么多线程空等就会大量占用CPU资源,这时候就再次进行升级,升级成重量锁,一个线程使用锁时,其他线程阻塞,等待唤醒



可能有的铁汁会想:那为什么JVM不实现降级呢,竞争如果小了,进行降级会提高效率呀

可能是因为降级的资源开销比较大,或者会引入一些新的bug,JVM就没有实现降级

2,锁消除

有的代码程序员加了锁,但是JVM执行时发现这个地方没必要加锁,就会自动把锁给去掉

举个简单的例子:
StringBuilder是不带有synchronized的,线程不安全
StringBuffer是带有synchronized的,线程安全

有时候可能会用的不合适,在单线程环境下使用StringBuffer,那JVM就会把锁给去掉,提升效率

3,锁粗化

这里粗化的是锁的粒度

在加锁和解锁时,范围中的代码越多,锁的粒度就越粗,范围中的代码越少,锁的粒度就越细
例如下面代码中的这两个线程,t1线程锁的粒度就比t2线程锁的粒度要细

Threadt1=newThread(()->{for(inti=0;i<50000;i++){synchronized(locker){count++;}}});Threadt2=newThread(()->{synchronized(locker){for(inti=0;i<50000;i++){count++;}}});



因为加锁解锁也是比较耗时的,所以JVM就会分析情况,如果分析后觉得能提升效率,就会把多次加锁解锁优化成一次(如下图,把三次加锁解锁优化成一次)

3,CAS

CAS也是锁优化策略中的一种,这部分内容比较多,在面试的时候出的概率也比较高,这里就单独写一个部分来聊

CAS就是Compare And Swap(比较和替换)的单词首字母拼写

CAS操作伪代码如下所示:

booleanCAS(内存地址V,预期值A,新值B){if(V里面存的值==A){// 比较:当前内存值是否等于预期值V里面存的值=B;// 赋值:如果相等,就把内存值改成新值Breturntrue;}returnfalse;}

CAS操作是通过一条cpu指令完成的,这也意味着这个操作是原子的,不需要加锁和解锁,这个操作的效率就非常高
CPU的特殊指令完成了CAS操作,操作系统封装了这个指令,形成了一个系统API,Java又封装了操作系统的API
整个操作被封装在unsafe中的,这个操作比较底层,可能不安全,因此一般不会直接用CAS来写到代码中,而是使用CAS在其他地方的应用

1,原子类的实现

CAS能够实现很多的原子类,在原子类中++和- -操作都是原子的,在Java文档中(链接如下),找到java.util.concurrent.atomic包,这个包中的类就都是原子类,这些原子类就是通过CAS来实现的

文档地址



接下来就试下Integer原子类的效果,创建一个对象count
注意,Java中是不支持运算符重载的,所以不能直接写count++,要用方法去++,这里count++的方法就是getAndIncrement(),使用原子类,就算不加锁,结果也还是正确的,线程的load和add操作并不会受插队的影响,如下所示:

importjava.util.concurrent.atomic.AtomicInteger;publicclassDemo33{privatestaticAtomicIntegercount=newAtomicInteger();publicstaticvoidmain(String[]args)throwsInterruptedException{Threadt1=newThread(()->{for(inti=0;i<50000;i++){count.getAndIncrement();}});Threadt2=newThread(()->{for(inti=0;i<50000;i++){count.getAndIncrement();}});t1.start();t2.start();t1.join();t2.join();System.out.println(count);}}



对于前置,后置++和- -,以及+=和赋值,都有对应的方法,如下所示

count.getAndIncrement();//count++count.incrementAndGet();//++countcount.getAndDecrement();//count--count.decrementAndGet();//--countcount.addAndGet(10);//count += 10count.getAndSet(10);//count = 10

这样的操作,不仅线程安全,而且效率很高,不涉及到阻塞,我们在开发中,如果有计数的需求,就要优先考虑原子类,而不是去自己加锁



那CAS操作是如何在原子类中被使用的呢,下面就来解析一下:

CAS伪码如下所示:

booleanCAS(内存地址V,预期值A,新值B){if(V里面存的值==A){// 比较:当前内存值是否等于预期值V里面存的值=B;// 赋值:如果相等,就把内存值改成新值Breturntrue;}returnfalse;}

就拿Integer原子类中的getAndIncrement方法来举例,在Java线程(三)博客中提到,之所以++操作会出现线程安全问题,就是因为不同线程在同时修改count时,load,add,save三个操作会进行插队,导致结果出错
CAS在getAndIncrement方法中如下所示:

首先会把value的值赋值给oldValue,这个oldValue就相当于一个寄存器,接着用CAS进行比较,CAS中会把value的值和oldValue的值进行比较,如果相等,那就把oldValue + 1(第三个参数)的值赋值给value,那就会有两种情况:

  1. 线程A在调用getAndIncrement时,线程B没有修改value中的值,那CAS判断的结果就是true,在CAS中让value = oldValue + 1,也就相当于是value++,while循环一次也不会执行,最后再返回oldValue的值(getAndIncrement的设定就是让value++,然后再返回修改前的oldValue的值)
  2. 线程A在调用getAndIncrement时,线程B刚好在修改value的值,线程A读取到的oldValue的值相当于说是错误的(load读取到了过时的数据)
    这时候在CAS中判断oldValue跟value不相等,就知道读取到的oldValue是错误的,返回false,进入while循环,重新把value赋值给oldValue(相当于重新load),接着一直进行while循环,直到oldValue等于value了(也就是说中间没有线程修改,load到的值是正确的),再进行第1步

2,CAS的ABA问题

就拿前面CAS实现Integer原子类来说,其实是有一些漏洞的
CAS就算返回ture(value 等于 oldValue),也是保证不了其他线程中间没有修改value的
例如其他线程先修改了一下value,又把value改了回来,也就是先把A修改成B,再把B修改成A,这时候CAS是判断不出来的

可能有的铁汁会想,这算什么漏洞呀,修改后再改过来,这不是还是没有修改吗
在绝大部分场景下,这都不会产生安全问题



但是在一些特别特别极端的情况下,还是可能会出现安全问题的
举个例子:

假设我们账户里的余额有1000块,我们去银行取500
注:这时候CAS就不是在while循环中了,一旦发现balance不等于oldBalance,就取款失败,终止交易,让客户重新取款



假设ATM机卡了,又创建出了一个新线程帮我们操作这个事情(在图形界面编程中,当窗口卡死无响应时,常用的操作都是创建新线程来处理),那这时候原来的老线程后面进行CAS时,就会判断出balance和oldBanlance不相等,就会终止操作,这时候仍然是安全的



但是如果有人在新线程和老线程之间往这个账户打了500块(这是一种比较极端的情况),那老线程就仍然会给balance扣款500,这时候就相当于取了500块,但是扣了两次500块,也就是扣了1000块

要解决ABA问题也很简单,可以定义一个类,包含版本号和余额
每次修改余额时,版本号都 +1 处理,每次CAS比较的时候,再比较下版本号是否相同,如果版本号变了,就重新读取balance的值,再进行CAS比较🐵

3,实现自旋锁

前面的锁策略中提到,自旋就是线程不停的尝试获取锁,这样获取锁的延迟就非常低,自旋锁也是通过CAS来实现的

创建一个线程变量owner,记录哪个线程当前拥有这把锁,如果这把锁没有线程使用的话,那owner就是null
线程自旋抢锁,就是通过while循环不断的执行CAS方法,判断这个锁现在是不是空的,当其他线程用完锁,那抢锁的线程就会快速占用这个锁,让this.owner = Thread.currentThread()

结语💕💕

在实际开发中,经常会有一些统计类的需求,例如这个服务器一天有多少用户访问,这个广告被点击了多少次,点击带来了多少的计费,这些操作,一般是不建议我们自己加锁的,建议通过原子类来实现,这样效率会比较高🐵

以上就是今天的所有内容啦~完结撒花~🥳🎉🎉

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 20:22:31

RDMA为什么快?协议选型、队列模型与编程实践全解析

简介&#xff1a;这是一份面向网络工程师、高性能计算与分布式存储开发者的RDMA技术调研PDF文档。文档从传统网络协议栈的痛点切入&#xff0c;系统梳理了RDMA零拷贝、内核旁路、CPU卸载三大核心优势&#xff0c;并对比Infiniband、RoCE、iWARP三种协议的演进脉络与适用场景&am…

作者头像 李华
网站建设 2026/9/17 20:19:26

Rivet RunnersApi 完全指南:使用 Rust SDK 列出与管理 Runner 状态

Rivet RunnersApi 完全指南&#xff1a;使用 Rust SDK 列出与管理 Runner 状态 【免费下载链接】actors Rivet Actors are the primitive for stateful workloads. Built for AI agents, collaborative apps, and durable execution. 项目地址: https://gitcode.com/GitHub_T…

作者头像 李华
网站建设 2026/9/17 20:17:24

管理后台功能需求文档模板v1.1:docx结构化编写与权限管理实践

简介&#xff1a;这是针对个人消费贷款申请管理后台的功能需求文档模板&#xff0c;面向产品经理、运营人员和开发技术人员&#xff0c;用于统一各方对后台功能与审批流程的认知&#xff0c;减少开发与沟通返工。模板以贷款申请到放款为主线&#xff0c;定义了贷款用户、业务员…

作者头像 李华
网站建设 2026/9/17 20:14:27

04. 如何自定义快捷菜单栏和工具栏? I ANSA 设计小诀窍系列

大家好。在使用ANSA进行网格划分时&#xff0c;高频操作往往分散在不同菜单下&#xff0c;频繁切换会显著拖慢工作效率。如果能将常用功能集中到自定义的选项卡和工具栏中&#xff0c;就可以大幅减少点击次数&#xff0c;让操作更顺手。本次分享将带你掌握ANSA中自定义快捷菜单…

作者头像 李华
网站建设 2026/9/17 20:14:13

Python爬取猫眼电影Top100:数据分析与可视化实战

简介&#xff1a;面向计算机专业学生与Python实战学习者的猫眼电影数据综合项目&#xff0c;覆盖网络爬虫、数据清洗、数据库存储、Web可视化等完整流程&#xff0c;适合作为毕业设计、课程设计或期末大作业。压缩包共86个文件&#xff0c;体积仅1.05MB&#xff0c;内部包含9个…

作者头像 李华