你用对锁了吗?浅谈 Java “锁” 事(上)

简介: 你用对锁了吗?浅谈 Java “锁” 事(上)

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

大家好,我是yes。

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

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


并发 BUG 的源头


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


image.png

image.png


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

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


image.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次。


image.png

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

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


image.png

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

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

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

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

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


image.png

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

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


image.png

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

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


image.png


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

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

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


image.png


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


锁的粒度

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


image.png

image.png


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

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


image.png

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

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

image.png


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

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


image.png

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

相关文章
|
2月前
|
安全 Java 调度
Java编程时多线程操作单核服务器可以不加锁吗?
Java编程时多线程操作单核服务器可以不加锁吗?
46 2
|
15天前
|
缓存 Java
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
本文介绍了几种常见的锁机制,包括公平锁与非公平锁、可重入锁与不可重入锁、自旋锁以及读写锁和互斥锁。公平锁按申请顺序分配锁,而非公平锁允许插队。可重入锁允许线程多次获取同一锁,避免死锁。自旋锁通过循环尝试获取锁,减少上下文切换开销。读写锁区分读锁和写锁,提高并发性能。文章还提供了相关代码示例,帮助理解这些锁的实现和使用场景。
java中的公平锁、非公平锁、可重入锁、递归锁、自旋锁、独占锁和共享锁
|
1月前
|
Java
Java 中锁的主要类型
【10月更文挑战第10天】
|
2月前
|
存储 缓存 安全
【Java面试题汇总】多线程、JUC、锁篇(2023版)
线程和进程的区别、CAS的ABA问题、AQS、哪些地方使用了CAS、怎么保证线程安全、线程同步方式、synchronized的用法及原理、Lock、volatile、线程的六个状态、ThreadLocal、线程通信方式、创建方式、两种创建线程池的方法、线程池设置合适的线程数、线程安全的集合?ConcurrentHashMap、JUC
【Java面试题汇总】多线程、JUC、锁篇(2023版)
|
2月前
|
算法 Java 关系型数据库
Java中到底有哪些锁
【9月更文挑战第24天】在Java中,锁主要分为乐观锁与悲观锁、自旋锁与自适应自旋锁、公平锁与非公平锁、可重入锁以及独享锁与共享锁。乐观锁适用于读多写少场景,通过版本号或CAS算法实现;悲观锁适用于写多读少场景,通过加锁保证数据一致性。自旋锁与自适应自旋锁通过循环等待减少线程挂起和恢复的开销,适用于锁持有时间短的场景。公平锁按请求顺序获取锁,适合等待敏感场景;非公平锁性能更高,适合频繁加解锁场景。可重入锁支持同一线程多次获取,避免死锁;独享锁与共享锁分别用于独占和并发读场景。
|
1月前
|
安全 Java 开发者
java的synchronized有几种加锁方式
Java的 `synchronized`通过上述三种加锁方式,为开发者提供了从粗粒度到细粒度的并发控制能力,满足了不同场景下的线程安全需求。合理选择加锁方式对于提升程序的并发性能和正确性至关重要,开发者应根据实际应用场景的特性和性能要求来决定使用哪种加锁策略。
20 0
|
1月前
|
Java 应用服务中间件 测试技术
Java21虚拟线程:我的锁去哪儿了?
【10月更文挑战第8天】
38 0
|
2月前
|
Java 数据库
JAVA并发编程-一文看懂全部锁机制
曾几何时,面试官问:java都有哪些锁?小白,一脸无辜:用过的有synchronized,其他不清楚。面试官:回去等通知! 今天我们庖丁解牛说说,各种锁有什么区别、什么场景可以用,通俗直白的分析,让小白再也不怕面试官八股文拷打。
|
3月前
|
存储 Java
Java锁是什么?简单了解
在高并发环境下,锁是Java中至关重要的概念。锁或互斥是一种同步机制,用于限制多线程环境下的资源访问,确保排他性和并发控制。例如,超市储物柜仅能存放一个物品,若三人同时使用,则需通过锁机制确保每次只有一个线程访问。Java中可以通过`synchronized`关键字实现加锁,确保关键代码段的原子性,避免数据不一致问题。正确使用锁可有效提升程序的稳定性和安全性。
Java锁是什么?简单了解
|
3月前
|
小程序 Java 开发工具
【Java】@Transactional事务套着ReentrantLock锁,锁竟然失效超卖了
本文通过一个生动的例子,探讨了Java中加锁仍可能出现超卖问题的原因及解决方案。作者“JavaDog程序狗”通过模拟空调租赁场景,详细解析了超卖现象及其背后的多线程并发问题。文章介绍了四种解决超卖的方法:乐观锁、悲观锁、分布式锁以及代码级锁,并重点讨论了ReentrantLock的使用。此外,还分析了事务套锁失效的原因及解决办法,强调了事务边界的重要性。
108 2
【Java】@Transactional事务套着ReentrantLock锁,锁竟然失效超卖了
下一篇
无影云桌面