你用对锁了吗?浅谈 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 还会有锁消除的动作,通过逃逸分析判断实例对象是线程私有的,那么肯定是线程安全的,于是就会忽略对象里面的加锁动作,直接调用。

相关文章
|
17天前
|
Java
Java中ReentrantLock释放锁代码解析
Java中ReentrantLock释放锁代码解析
25 8
|
1月前
|
Java
Java并发编程中的锁机制
【2月更文挑战第22天】 在Java并发编程中,锁机制是一种重要的同步手段,用于保证多个线程在访问共享资源时的安全性。本文将介绍Java锁机制的基本概念、种类以及使用方法,帮助读者深入理解并发编程中的锁机制。
|
1月前
|
存储 Java 程序员
记一次synchronized锁字符串引发的坑兼再谈Java字符串
记一次synchronized锁字符串引发的坑兼再谈Java字符串
21 2
|
1月前
|
Java
深入了解Java中的锁机制
深入了解Java中的锁机制
|
17天前
|
Java 调度
Java中常见锁的分类及概念分析
Java中常见锁的分类及概念分析
16 0
|
9天前
|
安全 Java 调度
Java并发编程:深入理解线程与锁
【4月更文挑战第18天】本文探讨了Java中的线程和锁机制,包括线程的创建(通过Thread类、Runnable接口或Callable/Future)及其生命周期。Java提供多种锁机制,如`synchronized`关键字、ReentrantLock和ReadWriteLock,以确保并发访问共享资源的安全。此外,文章还介绍了高级并发工具,如Semaphore(控制并发线程数)、CountDownLatch(线程间等待)和CyclicBarrier(同步多个线程)。掌握这些知识对于编写高效、正确的并发程序至关重要。
|
10天前
|
Java
浅谈Java的synchronized 锁以及synchronized 的锁升级
浅谈Java的synchronized 锁以及synchronized 的锁升级
8 0
|
12天前
|
存储 缓存 Java
线程同步的艺术:探索 JAVA 主流锁的奥秘
本文介绍了 Java 中的锁机制,包括悲观锁与乐观锁的并发策略。悲观锁假设多线程环境下数据冲突频繁,访问前先加锁,如 `synchronized` 和 `ReentrantLock`。乐观锁则在访问资源前不加锁,通过版本号或 CAS 机制保证数据一致性,适用于冲突少的场景。锁的获取失败时,线程可以选择阻塞(如自旋锁、适应性自旋锁)或不阻塞(如无锁、偏向锁、轻量级锁、重量级锁)。此外,还讨论了公平锁与非公平锁,以及可重入锁与非可重入锁的特性。最后,提到了共享锁(读锁)和排他锁(写锁)的概念,适用于不同类型的并发访问需求。
43 2
|
13天前
|
Java 程序员 编译器
Java中的线程同步与锁优化策略
【4月更文挑战第14天】在多线程编程中,线程同步是确保数据一致性和程序正确性的关键。Java提供了多种机制来实现线程同步,其中最常用的是synchronized关键字和Lock接口。本文将深入探讨Java中的线程同步问题,并分析如何通过锁优化策略提高程序性能。我们将首先介绍线程同步的基本概念,然后详细讨论synchronized和Lock的使用及优缺点,最后探讨一些锁优化技巧,如锁粗化、锁消除和读写锁等。
|
14天前
|
Java 编译器
Java并发编程中的锁优化策略
【4月更文挑战第13天】 在Java并发编程中,锁是一种常见的同步机制,用于保证多个线程之间的数据一致性。然而,不当的锁使用可能导致性能下降,甚至死锁。本文将探讨Java并发编程中的锁优化策略,包括锁粗化、锁消除、锁降级等方法,以提高程序的执行效率。
13 4