java 单例 读写锁_你用对锁了吗?浅谈 Java “锁” 事

每个时代,都不会亏待会学习的人

大家好,我是yes。

本来打算继续写消息队列的东西的,但是最近在带新同事,发现新同事对于锁这方面有一些误解,所以今天就来谈谈“锁”事和 Java 中的并发安全容器使用有哪些注意点。

不过在这之前还是得先来盘一盘为什么需要锁这玩意,这得从并发 BUG 的源头说起。

并发 BUG 的源头

这个问题我 19 年的时候写过一篇文章, 现在回头看那篇文章真的是羞涩啊。

47bb89794994702047c90f34bf1c0787.png

让我们来看下这个源头是什么,我们知道电脑有CPU、内存、硬盘,硬盘的读取速度最慢,其次是内存的读取,内存的读取相对于 CPU 的运行又太慢了,因此又搞了个CPU缓存,L1、L2、L3。

正是这个CPU缓存再加上现在多核CPU的情况产生了并发BUG。

95ed8092ff4d801230c7c694a8305ac8.png

这就一个很简单的代码,如果此时有线程 A 和线程 B 分别在 CPU - A 和 CPU - B 中执行这个方法,它们的操作是先将 a 从主存取到 CPU 各自的缓存中,此时它们缓存中 a 的值都是 0。

然后它们分别执行 a++,此时它们各自眼中 a 的值都是 1,之后把 a 刷到主存的时候 a 的值还是1,这就出现问题了,明明执行了两次加一最终的结果却是 1,而不是 2。

这个问题就叫可见性问题。

在看我们 a++ 这条语句,我们现在的语言都是高级语言,这其实和语法糖很类似,用起来好像很方便实际上那只是表面,真正需要执行的指令一条都少不了。

高级语言的一条语句翻译成 CPU 指令的时候可不止一条, 就例如 a++ 转换成 CPU 指令至少就有三条。把 a 从内存拿到寄存器中;

在寄存器中 +1;

将结果写入缓存或内存中;

所以我们以为 a++ 这条语句是不可能中断的是具备原子性的,而实际上 CPU 可以能执行一条指令时间片就到了,此时上下文切换到另一个线程,它也执行 a++。再次切回来的时候 a 的值其实就已经不对了。

这个问题叫做原子性问题。

并且编译器或解释器为了优化性能,可能会改变语句的执行顺序,这叫指令重排,最经典的例子莫过于单例模式的双重检查了。而 CPU 为了提高执行效率,还会乱序执行,例如 CPU 在等待内存数据加载的时候发现后面的加法指令不依赖前面指令的计算结果,因此它就先执行了这条加法指令。

这个问题就叫有序性问题。

至此已经分析完了并发 BUG 的源头,即这三大问题。可以看到不管是 CPU 缓存、多核 CPU 、高级语言还是乱序重排其实都是必要的存在,所以我们只能直面这些问题。

而解决这些问题就是通过禁用缓存、禁止编译器指令重排、互斥等手段,今天我们的主题和互斥相关。

互斥就是保证对共享变量的修改是互斥的,即同一时刻只有一个线程在执行。而说到互斥相信大家脑海中浮现的就是锁。没错,我们今天的主题就是锁!锁就是为了解决原子性问题。

说到锁可能 Java 的同学第一反应就是 synchronized 关键字,毕竟是语言层面支持的。我们就先来看看 synchronized,有些同学对 synchronized 理解不到位所以用起来会有很多坑。

synchronized 注意点

我们先来看一份代码,这段代码就是咱们的涨工资之路,最终百万是洒洒水的。而一个线程时刻的对比着我们工资是不是相等的。我简单说一下IntStream.rangeClosed(1,1000000).forEach,可能有些人对这个不太熟悉,这个代码的就等于 for 循环了100W次。

0b6bee284b0161108d2d13b7aafa9b09.png

你先自己理解下,看看觉得有没有什么问题?第一反应好像没问题,你看着涨工资就一个线程执行着,这比工资也没有修改值,看起来好像没啥毛病?没有啥并发资源的竞争,也用 volatile 修饰了保证了可见性。

让我们来看一下结果,我截取了一部分。

c19436528fb0bbbc3e4a933f256f198a.png

可以看到首先有 log 打出来就已经不对了,其次打出来的值竟然还相等!有没有出乎你的意料之外?有同学可能下意识就想到这就raiseSalary在修改,所以肯定是线程安全问题来给raiseSalary 加个锁!

请注意只有一个线程在调用raiseSalary方法,所以单给raiseSalary方法加锁并没啥用。

这其实就是我上面提到的原子性问题,想象一下涨工资线程在执行完yesSalary++还未执行yourSalary++时,比工资线程刚好执行到yesSalary != yourSalary 是不是肯定是 true ?所以才会打印出 log。

再者由于用 volatile 修饰保证了可见性,所以当打 log 的时候,可能yourSalary++已经执行完了,这时候打出来的 log 才会是yesSalary == yourSalary。

所以最简单的解决办法就是把raiseSalary() 和 compareSalary() 都用 synchronized 修饰,这样涨工资和比工资两个线程就不会在同一时刻执行,因此肯定就安全了!

45dffede7d6c0183e027e1ec463747dd.png

看起来锁好像也挺简单,不过这个 synchronized 的使用还是对于新手来说还是有坑的,就是你要关注 synchronized 锁的究竟是什么。

比如我改成多线程来涨工资。这里再提一下parallel,这个其实就是利用了 ForkJoinPool 线程池操作,默认线程数是 CPU 核心数。

54ad6e7d37312f8642c00d491eaf29e4.png

由于 raiseSalary() 加了锁,所以最终的结果是对的。这是因为 synchronized 修饰的是yesLockDemo实例,我们的 main 中只有一个实例,所以等于多线程竞争的是一把锁,所以最终计算出来的数据正确。

那我再修改下代码,让每个线程自己有一个 yesLockDemo 实例来涨工资。

bb4c4959c4ce8b87ddf02612ae511281.png

你会发现这锁怎么没用了?这说好的百万年薪我就变 10w 了??这你还好还有 70w。

这是因为此时我们的锁修饰的是非静态方法,是实例级别的锁,而我们为每个线程都创建了一个实例,因此这几个线程竞争的就根本不是一把锁,而上面多线程计算正确代码是因为每个线程用的是同一个实例,所以竞争的是一把锁。如果想要此时的代码正确,只需要把实例级别的锁变成类级别的锁。

很简单只需要把这个方法变成静态方法,synchronized 修饰静态方法就是类级别的锁。

ca8f3b7744b8af70e1e88dcd72185cd7.png

还有一种就是声明一个静态变量,比较推荐这种,因为把非静态方法变成静态方法其实就等于改了代码结构了。

c3b8b3f955c360b9acd0e7f535b1a7fe.png

我们来小结一下,使用 synchronized 的时候需要注意锁的到底是什么,如果修饰静态字段和静态方法那就是类级别的锁,如果修饰非静态字段和非静态方法就是实例级别的锁。

锁的粒度

相信大家知道 Hashtable 不被推荐使用,要用就用 ConcurrentHashMap,是因为 Hashtable 虽然是线程安全的,但是它太粗暴了,它为所有的方法都上了同一把锁!我们来看下源码。

4919856119fd8472e841fb63dccdf828.png

你说这 contains 和 size 方法有啥关系? 我在调用 contains 的时候凭啥不让我调 size ? 这就是锁的粒度太粗了我们得评估一下,不同的方法用不同的锁,这样才能在线程安全的情况下再提高并发度。

但是不同方法不同锁还不够的,因为有时候一个方法里面有些操作其实是线程安全的,只有涉及竞争竞态资源的那一段代码才需要加锁。特别是不需要锁的代码很耗时的情况,就会长时间占着这把锁,而且其他线程只能排队等着,比如下面这段代码。

62afaf1cf58e829b9e370f0ebe43da87.png

很明显第二段代码才是正常的使用锁的姿势,不过在平时的业务代码中可不是像我代码里贴的 sleep 这么容易一眼就看出的,有时候还需要修改代码执行的顺序等等来保证锁的粒度足够细。

而有时候又需要保证锁足够的粗,不过这部分JVM会检测到,它会帮我们做优化,比如下面的代码。

29e2dc06781c56b835e7e797a49e8b9a.png

可以看到明明是一个方法里面调用的逻辑却经历了加锁-执行A-解锁-加锁-执行B-解锁,很明显的可以看出其实只需要经历加锁-执行A-执行B-解锁。

所以 JVM 会在即时编译的时候做锁的粗化,将锁的范围扩大,类似变成下面的情况。

243331f9cb3151b59eac110960508916.png

而且 JVM 还会有锁消除的动作,通过逃逸分析判断实例对象是线程私有的,那么肯定是线程安全的,于是就会忽略对象里面的加锁动作,直接调用。

读写锁

读写锁就是我们上面提交的根据场景减小锁的粒度了,把一个锁拆成了读锁和写锁,特别适合在读多写少的情况下使用,例如自己实现的一个缓存。

ReentrantReadWriteLock

读写锁允许多个线程同时读共享变量,但是写操作是互斥的,即写写互斥、读写互斥。讲白了就是写的时候就只能一个线程写,其他线程也读不了也写不了。

我们来看个小例子,里面也有个小细节。这段代码就是模拟缓存的读取,先上读锁去缓存拿数据,如果缓存没数据则释放读锁,再上写锁去数据库取数据,然后塞入缓存中返回。

959e0e80fe74c92de92de64bd111ae60.png

这里面的小细节就是再次判断 data = getFromCache() 是否有值,因为同一时刻可能会有多个线程调用getData(),然后缓存都为空因此都去竞争写锁,最终只有一个线程会先拿到写锁,然后将数据又塞入缓存中。

此时等待的线程最终一个个的都会拿到写锁,获取写锁的时候其实缓存里面已经有值了所以没必要再去数据库查询。

当然 Lock 的使用范式大家都知道,需要用 try- finally,来保证一定会解锁。而读写锁还有一个要点需要注意,也就是说锁不能升级。什么意思呢?我改一下上面的代码。

184a4db12518c381971be47392cfabc0.png

但是写锁内可以再用读锁,来实现锁的降级,有些人可能会问了这写锁都加了还要什么读锁。

还是有点用处的,比如某个线程抢到了写锁,在写的动作要完毕的时候加上读锁,接着释放了写锁,此时它还持有读锁可以保证能马上使用写锁操作完的数据,而别的线程也因为此时写锁已经没了也能读数据。

其实就是当前已经不需要写锁这种比较霸道的锁!所以来降个级让大家都能读。

小结一下,读写锁适用于读多写少的情况,无法升级,但是可以降级。Lock 的锁需要配合 try- finally,来保证一定会解锁。

对了,我再稍稍提一下读写锁的实现,熟悉 AQS 的同学可能都知道里面的 state ,读写锁就是将这个 int 类型的 state 分成了两半,高 16 位与低 16 位分别记录读锁和写锁的状态。它和普通的互斥锁的区别就在于要维护这两个状态和在等待队列处区别处理这两种锁。

所以在不适用于读写锁的场景还不如直接用互斥锁,因为读写锁还需要对state进行位移判断等等操作。

StampedLock

这玩意我也稍微提一下,是 1.8 提出来的出镜率似乎没有 ReentrantReadWriteLock 高。它支持写锁、悲观读锁和乐观读。写锁和悲观读锁其实和 ReentrantReadWriteLock 里面的读写锁是一致的,它就多了个乐观读。

从上面的分析我们知道读写锁在读的时候其实是无法写的,而 StampedLock 的乐观读则允许一个线程写。乐观读其实就是和我们知道的数据库乐观锁一样,数据库的乐观锁例如通过一个version字段来判断,例如下面这条 sql。

e924195fb8c291b125dfa8920137708c.png

StampedLock 乐观读就是与其类似,我们来看一下简单的用法。

a2c9affd8d8be09cf6b170adc06a97cb.png

它与 ReentrantReadWriteLock 对比也就强在这里,其他的不行,比如 StampedLock 不支持重入,不支持条件变量。还有一点使用 StampedLock 一定不要调用中断操作,因为会导致CPU 100%,我跑了下并发编程网上面提供的例子,复现了。

41e388f67bfc948bc87fd9615c17a163.png

具体的原因这里不再赘述,文末会贴上链接,上面说的很详细了。

所以出来一个看似好像很厉害的东西,你需要真正的去理解它,熟悉它才能做到有的放矢。

CopyOnWrite

写时复制的在很多地方也会用到,比如进程 fork() 操作。对于我们业务代码层面而言也是很有帮助的,在于它的读操作不会阻塞写,写操作也不会阻塞读。适用于读多写少的场景。

例如 Java 中的实现 CopyOnWriteArrayList,有人可能一听,这玩意线程安全读的时候还不会阻塞写,好家伙就用它了!

你得先搞清楚,写时复制是会拷贝一份数据,你的任何一个修改动作在CopyOnWriteArrayList 中都会触发一次Arrays.copyOf,然后在副本上修改。假如修改的动作很多,并且拷贝的数据也很大,这将是灾难!

并发安全容器

最后再来谈一下并发安全容器的使用,我就拿相对而言大家比较熟悉的 ConcurrentHashMap 来作为例子。我看新来的同事好像认为只要是使用并发安全容器一定就是线程安全了。其实不尽然,还得看怎么用。

我们先来看下以下的代码,简单的说就是利用 ConcurrentHashMap 来记录每个人的工资,最多就记录 100 个。

c3de278ee5279f7a5937c72bba9e4f2c.png

最终的结果都会超标,即 map 里面不仅仅只记录了100个人。那怎么样结果才会是对的?很简单就是加个锁。

92fc9431010a62afe9f23f989ff9068c.png

看到这有人说,你这都加锁了我还用啥 ConcurrentHashMap ,我 HashMap 加个锁也能完事!是的你说的没错!因为当前我们的使用场景是复合型操作,也就是我们先拿 map 的 size 做了判断,然后再执行了 put 方法,ConcurrentHashMap 无法保证复合型的操作是线程安全的!

而 ConcurrentHashMap 合适只是用其暴露出来的线程安全的方法,而不是复合操作的情况下。比如以下代码

d968d1e4cef9b60589f40e14bf714c35.png

当然,我这个例子不够恰当其实,因为 ConcurrentHashMap 性能比 HashMap + 锁高的原因在于分段锁,需要多个 key 操作才能体现出来,不过我想突出的重点是使用的时候不能大意,不能纯粹的认为用了就线程安全了。

总结一下

今天谈了谈并发 BUG 的源头,即三大问题:可见性问题、原子性问题和有序性问题。然后简单的说了下 synchronized 关键字的注意点,即修饰静态字段或者静态方法是类层面的锁,而修饰非静态字段和非静态方法是实例层面的类。

再说了下锁的粒度,在不同场景定义不同的锁不能粗暴的一把锁搞定,并且方法内部锁的粒度要细。例如在读多写少的场景可以使用读写锁、写时复制等。

最终要正确的使用并发安全容器,不能一味的认为使用并发安全容器就一定线程安全了,要注意复合操作的场景。

4e51eef17931bd3155f4e0a735843f8c.png

当然我今天只是浅浅的谈了一下,关于并发编程其实还有很多点,要写出线程安全的代码不是一件容易的事情,就像我之前分析的 Kafka 事件处理全流程一样,原先的版本就是各种锁控制并发安全,到后来bug根本修不动,多线程编程难,调试也难,修bug也难。

因此 Kafka 事件处理模块最终改成了单线程事件队列模式,将涉及到共享数据竞争相关方面的访问抽象成事件,将事件塞入阻塞队列中,然后单线程处理。

所以在用锁之前我们要先想想,有必要么?能简化么?不然之后维护起来有多痛苦到时候你就知道了。

最后

之后继续开始写消息队列相关的包括 RocketMQ 和 Kafka,有不少同学在后台留言想和我深入的交流一下,发生点关系,我把公众号菜单加了个联系我,有需求的小伙伴可以加我微信。

扫码可关注我的公众号哦~

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/552092.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

word无法启动转换器recovr32_迅捷PDF转换器3.0.1Mod会员版

特别声明所有软件皆来源于网上收集整理,仅供学习与交流技术,不得用作其它用途,如有侵犯你的权益,请联系我们,我们将于24小时内进行删除,谢谢你的配合!1 迅捷PDF转换器作为一款专业实用的文件格式转换器,不仅…

项目管理知识体系指南_MP考前冲刺丨项目管理知识体系指南(PMBOK)串讲(11)...

第一单元:必考知识点08 项目质量管理(下)根本原因分析因果图因果图 Cause and Effect Diagram根本原因分析在被视为特殊偏差的不良结果与飞随机原因之间建立联系,基于这种联系,采取纠正措施,小区在控制图中呈现的特殊偏差。直方图…

nfa状态转换图正规式_0x02 从NFA到DFA

书接上文,上回说道NFA已经可以完全描述正则语言的全部内容。那么,我们在这一章探索一下一个比较复杂的正则表达式在用NFA做匹配的时候会有什么“不足“。NFA匹配的"不足"为了言之有物,不妨设要讨论的模式为d?(c(a|b)*)*(b|c)图1-1…

java filter教程_Java Web Filter 过滤器学习教程(推荐)

一、Filter简介Filter也称之为过滤器,它是Servlet技术中最激动人心的技术,WEB开发人员通过Filter技术,对web服务器管理的所有web资源:例如Jsp, Servlet, 静态图片文件或静态 html 文件等进行拦截,从而实现一些特殊的功…

vue修改计算属性的值_「Vue学习记录五」计算属性和侦听器

1&#xff1a; 计算属性&#xff1a; &#xff08;内置缓存机制&#xff09;当更改age的时候&#xff0c; fullName 函数不执行&#xff1b;当更改fristName的时候&#xff0c; fullName 函数才执行<div id "app"><span>{{fullName}}</span> <…

git为私有仓库设置密码_真香!在局域网下行云流水般使用git

最近公司要开发一个新的项目&#xff0c;开发一个新的项目就要有一个好的代码版本管理工具。对于代码开发版本控制工具&#xff0c;我们之前是使用svn这个代码版本控制工具&#xff0c;但是项目经理说统一使用git开发版本控制工具&#xff0c;来到这里我们一般会选择gitee或者g…

xss img onerror java_java后台防止XSS的脚本攻击

import java.util.regex.Pattern;//具体过滤关键字符public class XSSUtil {private static Pattern[] patterns new Pattern[]{// Script fragmentsPattern.compile("", Pattern.CASE_INSENSITIVE),// src...Pattern.compile("src[\r\n]*[\r\n]*\\\(.*?)\\\&…

网口监视报文工具_真是神器!这款网络排查工具!

常用的 ping&#xff0c;tracert&#xff0c;nslookup 一般用来判断主机的网络连通性&#xff0c;其实 Linux 下有一个更好用的网络联通性判断工具&#xff0c;它可以结合ping nslookup tracert 来判断网络的相关特性&#xff0c;这个命令就是 mtr。mtr 全称 my traceroute&…

snmp服务 2003 镜像_美国掌握全球70%根服务器,一旦对中国关闭,我们将无法上网?...

“如果在上网和男朋友(女朋友)之间只能选一个&#xff0c;你会选哪个&#xff1f;”曾经有媒体在街头做这样的调查&#xff0c;出人意料的是很多人都选择了“上网”&#xff1b;因为在现代年轻人看来&#xff0c;如果进入一个没有“不能上网”的生活实在太恐怖了&#xff0c;那…

java写入carbondata_Carbondata使用过程中遇到的几个问题及解决办法

本文总结了几个本人在使用 Carbondata 的时候遇到的几个问题及其解决办法。这里使用的环境是&#xff1a;Spark 2.1.0、Carbondata 1.2.0。必须指定 HDFS nameservices在初始化 CarbonSession 的时候&#xff0c;如果不指定 HDFS nameservices&#xff0c;在数据导入是没啥问题…

商品审核网页界面_Shopee新手指南:Shopee卖家中心用户界面介绍

1.Shopee各站点前台网页链接&#xff1a;2.Shopee各站点后台网页链接3.Shopee APP下载&#xff1a;安卓版下载链接&#xff1a;https://pan.baidu.com/s/1eSp8M1k#list/path%2Fios版&#xff1a;可在App Store中直接搜索下载使用。台湾站点ios版本请搜索关键字“虾皮”下载&…

pdf exe如何提取pdf文件_python应用:如何用python提取pdf文件中的文字

从pdf中提取文字&#xff0c;相信很多人都干过这事&#xff0c;怎么在python中实现呢&#xff0c;今天带大家看看。第一步导入库import PyPDF2第二步导入pdf文件pdf_file open(dataset/laban.1027.pdf, rb)第三步读取pdf并检查加密情况read_pdf PyPDF2.PdfFileReader(pdf_file…

java轻量分布式框架_5个强大的Java分布式缓存框架推荐

在开发中大型Java软件项目时&#xff0c;很多Java架构师都会遇到数据库读写瓶颈&#xff0c;如果你在系统架构时并没有将缓存策略考虑进去&#xff0c;或者并没有选择更优的 缓存策略&#xff0c;那么到时候重构起来将会是一个噩梦。本文主要是分享了5个常用的Java分布式缓存框…

python三次样条插值拟合的树行线_数学建模笔记——插值拟合模型(一)

啊好像距离上次写作又过了七天&#xff0c;啊好像我之前计划的一周两三篇&#xff0c;啊辣鸡小说毁我青春&#xff0c;啊我是一只可怜的鸽子。不管怎样&#xff0c;我又回来了&#xff0c;并坚定地更新着hhh。再过两三天就是我们学校数学建模选拔&#xff0c;再过八九天就是期末…

密度图的密度估计_不同类型的二维密度图小教程

R相关小教程链接&#xff1a;用R构建气泡图案例小教程【小教程】散点图、饼图怎么在我的文章中完美展示小教程热图在论文发表中完美呈现小教程R与密度、函数、变量的微妙关系北京市计算中心医用数据库建设解决方案更多内容&#xff0c;请关注“生信会议”公众号Different types…

python 找质数的个数_用Python打造一款文件搜索工具,所有功能自己定义!

一、前言大家好&#xff0c;又到了Python办公自动化系列。在日常的办公中&#xff0c;我们经常会从一堆不同格式的文件(夹)中搜索特定的文件&#xff0c;可能你是凭着记忆去找或是借助软件&#xff0c;但你有想过如何用Python实现吗&#xff1f;本文将基于几个常见的搜索操作讲…

nessus安装_Nessus忘记密码怎么办?

最近公司购买了Nessus&#xff0c;才安装好&#xff0c;然后隔天密码就忘了&#xff0c;唉&#xff0c;人老了呀&#xff0c;记性不行了。网上看了一下&#xff0c;还是有比较多的同学也遇到这个问题&#xff0c;现将密码重置方法&#xff0c;分享给大家。系统环境:操作系统&am…

graphpad prism画折线图_如何用Graphpad Prism 8作折线图

如何用Graphpad Prism 8作折线图如何用Graphpad Prism 8作折线图Prism 8 有8种数据类型&#xff0c;Prism数据表的格式决定可制作的图表种类和可执行的分析类型。选择一个数据表格式可以使Prism创建合适数据的数据表&#xff0c;然后创建所需的图形&#xff0c;执行适当的分析。…

sqlserver可视化工具_数据分析之基础分析工具篇(修订版)

原创&#xff1a;海峰996已经火了&#xff0c;你正在经历996吗&#xff0c;怎样才能避免&#xff0c;而又能在职场立足&#xff0c;工作效率是关键&#xff0c;那么先从选对工具开始吧。进入数据时代&#xff0c;大家都会进行或多或少的数据分析&#xff0c;那么现在的你正在使…

windows下python环境搭建_Linux/Windows下Python环境搭建步骤

Python环境搭建首先到官网&#xff08;www.python.org&#xff09;下载相应的安装版本。主要分为Windows和Linux两种&#xff1a; 一、Linux下Python环境搭建 一般情况下&#xff0c;Linux系统都已经预安装好Python&#xff0c;但是版本都比较低&#xff0c;需要安装新的版本方…