你好呀,我是歪歪。
前几天在某技术平台上看到别人提的关于 Synchronized 的一个用法问题,我觉得挺有意思的,这个问题其实也是我三年前面试某公司的时候遇到的一个真题,当时不知道面试官想要考什么,没有回答的特别好,后来研究了一下就记住了。
所以看到这个问题的时候觉得特别亲切,准备分享给你一起看看:
首先为了方便你看文章的时候复现问题,我给你一份直接拿出来就能跑的代码,希望你有时间的话也把代码拿出来跑一下:
public class SynchronizedTest {
public static void main(String[] args) {
Thread why = new Thread(new TicketConsumer(10), "why");
Thread mx = new Thread(new TicketConsumer(10), "mx");
why.start();
mx.start();
}
}
class TicketConsumer implements Runnable {
private volatile static Integer ticket;
public TicketConsumer(int ticket) {
this.ticket = ticket;
}
@Override
public void run() {
while (true) {
System.out.println(Thread.currentThread().getName() + "开始抢第" + ticket + "张票,对象加锁之前:" + System.identityHashCode(ticket));
synchronized (ticket) {
System.out.println(Thread.currentThread().getName() + "抢到第" + ticket + "张票,成功锁到的对象:" + System.identityHashCode(ticket));
if (ticket > 0) {
try {
//模拟抢票延迟
TimeUnit.SECONDS.sleep(1);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName() + "抢到了第" + ticket-- + "张票,票数减一");
} else {
return;
}
}
}
}
}
程序逻辑也很简单,是一个模拟抢票的过程,一共 10 张票,开启两个线程去抢票。
票是共享资源,且有两个线程来消费,所以为了保证线程安全,TicketConsumer 的逻辑里面用了 synchronized 关键字。
这是应该是大家在初学 synchronized 的时候都会写到的例子,期望的结果是 10 张票,两个人抢,每张票只有一个人能抢到。
但是实际运行结果是这样的,我只截取开始部分的日志:
截图里面有三个框起来的部分。
最上面的部分,就是两个人都在抢第 10 张票,从日志输出上看也完全没有任何毛病,最终只有一个人抢到了票,然后进入到第 9 张票的争夺过程。
但是下面被框起来的第 9 张票的争夺部分就有点让人懵逼了:
why抢到第9张票,成功锁到的对象:288246497
mx抢到第9张票,成功锁到的对象:288246497
为什么两个人都抢到了第 9 张票,且成功锁到的对象都一样的?
这玩意,超出认知了啊。
这两个线程怎么可能拿到同一把锁,然后去执行业务逻辑呢?
所以,提问者的问题就浮现出来了。
我们先来看一个问题。
首先,我们从日志的输出上已经非常明确的知道,synchronized 在第二轮抢第 9 张票的时候失效了。
经过理论知识支撑,我们知道 synchronized 失效,肯定是锁出问题了。
如果只有一把锁,多个线程来竞争同一把锁,synchronized 绝对是不会有任何毛病的。
但是这里两个线程并没有达成互斥的条件,也就是说这里绝对存在的不止一把锁。
这是我们可以通过理论知识推导出来的结论。
先得出结论了,那么我怎么去证明“锁不止一把”呢?
能进入 synchronized 说明肯定获得了锁,所以我只要看各个线程持有的锁是什么就知道了。
那么怎么去看线程持有什么锁呢?
jstack 命令,打印线程堆栈功能,了解一下?
这些信息都藏在线程堆栈里面,我们拿出来一看便知。
在 idea 里面怎么拿到线程堆栈呢?
这就是一个在 idea 里面调试的小技巧了,我之前的文章里面应该也出现过多次。
首先为了方便获取线程堆栈信息,我把这里的睡眠时间调整到 10s:
跑起来之后点击这里的“照相机”图标:
点击几次就会有对应点击时间点的几个 Dump 信息:
由于我需要观察前两次锁的情况,而每次线程进入锁之后都会等待 10s 时间,所以我就在项目启动的第一个 10s 和第二个 10s 之间各点击一次就行。
为了更直观的观察数据,我选择点击下面这个图标,把 Dump 信息复制下来:
复制下来的信息很多,但是我们只需要关心 why 和 mx 这两个线程即可。
这是第一次 Dump 中的相关信息:
mx 线程是 BLOCKED 状态,它在等待地址为 0x000000076c07b058 的锁。
why 线程是 TIMED_WAITING 状态,它在 sleeping,说明它抢到了锁,在执行业务逻辑。而它抢到的锁,你说巧不巧,正是 mx 线程等待的 0x000000076c07b058。
从输出日志上来看,第一次抢票确实是 why 线程抢到了:
从 Dump 信息看,两个线程竞争的是同一把锁,所以第一次没毛病。
好,我们接着看第二次的 Dump 信息:
这一次,两个线程都在 TIMED_WAITING,都在 sleeping,说明都拿到了锁,进入了业务逻辑。
但是仔细一看,两个线程拿的锁是不相同的锁。
mx 锁的是 0x000000076c07b058。
why 锁的是 0x000000076c07b048。
由于不是同一把锁,所以并不存在竞争关系,因此都可以进入 synchronized 执行业务逻辑,所以两个线程都在 sleeping,也没毛病。
然后,我再把两次 Dump 的信息放在一起给你看一下,这样就更直观了:
如果我用“锁一”来代替 0x000000076c07b058,“锁二”来代替 0x000000076c07b048。
那么流程是这样的:
why 加锁一成功,执行业务逻辑,mx 进入锁一等待状态。
why 释放锁一,等待锁一的 mx 被唤醒,持有锁一,继续执行业务。
同时 why 加锁二成功,执行业务逻辑。
从线程堆栈中,我们确实证明了 synchronized 没有生效的原因是锁发生了变化。
同时,从线程堆栈中我们也能看出来为什么锁对象 System.identityHashCode 的输出是一样的。
第一次 Dump 的时候,ticket 都是 10,其中 mx 没有抢到锁,被 synchronized 锁住。
why 线程执行了 ticket--
操作,ticket 变成了 9,但是此时 mx 线程被锁住的 monitor 还是 ticket=10 这个对象,它还在 monitor 的 _EntryList 里面等着的,并不会因为 ticket 的变化而变化。
所以,当 why 线程释放锁之后,mx 线程拿到锁继续执行,发现 ticket=9。
而 why 也搞到一把新锁,也可以进入 synchronized 的逻辑,也发现 ticket=9。
好家伙,ticket 都是 9, System.identityHashCode 能不一样吗?
按理来说,why 释放锁一后应该继续和 mx 竞争锁一,但是却不知道它在哪搞到一把新锁。
那么问题就来了:锁为什么发生了变化呢?
经过前面一顿分析,我们坐实了锁确实发生了变化,当你分析出这一点的时候勃然大怒,拍案而起,大喊一声:是哪个瓜娃子动了我的锁?这不是坑爹吗?
按照我的经验,这个时候不要急着甩锅,继续往下看,你会发现小丑竟是自己:
抢完票之后,执行了 ticket--
的操作,而这个 ticket 不就是你的锁对象吗?
这个时候你把大腿一拍,恍然大悟,对着围观群众说:问题不大,手抖而已。
于是大手一挥,把加锁的地方改成这样:
synchronized (TicketConsumer.class)
利用 class 对象来作为锁对象,保证了锁的唯一性。
经过验证也确实没毛病,非常完美,打完收工。
但是,真的就收工了吗?
其实关于锁对象为什么发生了变化,还隔了一点点东西没有说出来。
它就藏在字节码里面。
我们通过 javap 命令,反查字节码,可以看到这样的信息:
Integer.valueOf
这是什么玩意?
让人熟悉的 Integer 从 -128 到 127 的缓存。
也就是说我们的程序里面,会涉及到拆箱和装箱的过程,这个过程中会调用到 Integer.valueOf
方法。具体其实就是 ticket--
的这个操作。
对于 Integer,当值在缓存范围内的时候,会返回同一个对象。当超过缓存范围,每次都会 new 一个新对象出来。
这应该是一个必备的八股文知识点,我在这里给你强调这个是想表达什么意思呢?
很简单,改动一下代码就明白了。
我把初始化票数从 10 修改为 200,超过缓存范围,程序运行结果是这样的:
很明显,从第一次的日志输出来看,锁都不是同一把锁了。
这就是我前面说的:因为超过缓存范围,执行了两次 new Integer(200) 的操作,这是两个不同的对象,拿来作为锁,就是两把不一样的锁(注意这里的程序是去掉了static)。
再修改回 10,运行一次,你感受一下:
从日志输出来看,这个时候只有一把锁,所以只有一个线程抢到了票。
因为 10 是在缓存范围内的数字,所以每次是从缓存中获取出来,是同一个对象。
我写这一小段的目的是为了体现 Integer 有缓存这个知识点,大家都知道。但是当它和其他东西揉在一起的时候因为这个缓存会带来什么问题,你得分析出来,这比直接记住干瘪的知识点有效一点。
但是...
我们的初始票是 10,ticket--
之后票变成了 9,也是在缓存范围内的呀,怎么锁就变了呢?
如果你有这个疑问的话,那么我劝你再好好想想。
10 是 10,9 是 9。
虽然它们都在缓存范围内,但是本来就是两个不同的对象,构建缓存的时候也是 new 出来的:
为什么我要补充这一段看起来很傻的说明呢?
因为我在网上看到其他写类似问题的时候,有的文章写的不清楚,会让读者误认为“缓存范围内的值都是同一个对象”,这样会误导初学者。
总之一句话:请别用 Integer 作为锁对象,你把握不住。
但是...
但是,我写文章的时候在 stackoverflow 上也看到了一个类似的问题。
这个哥们的问题在于:他知道 Integer 不能做为锁对象,但是他的需求又似乎必须把 Integer 作为锁对象。
https://stackoverflow.com/questions/659915/synchronizing-on-an-integer-value
我给你描述一下他的问题。
首先看标号为 ① 的地方,他的程序其实就是先从缓存中获取,如果缓存中没有则从数据库获取,然后再放到缓存里面去。
非常简单清晰的逻辑。
但是他考虑到并发的场景下,如果有多个线程同一时刻都来获取同一个 id,但是这个 id 对应的数据并没有在缓存里面,那么这些线程都会去执行查询数据库并维护缓存的动作。
对应查询和存储的动作,他用的是 fairly expensive 来形容。
就是“相当昂贵”的意思,说白了就是这个动作非常的“重”,最好不要重复去做。
所以只需要让某一个线程来执行这个 fairly expensive 的操作就好了。
于是他想到了标号为 ② 的地方的代码。
用 synchronized 来把 id 锁一下,不幸的是,id 是 Integer 类型的。
在标号为 ③ 的地方他自己也说了:不同的 Integer 对象,它们并不会共享锁,那么 synchronized 也没啥卵用。
其实他这句话也不严谨,经过前面的分析,我们知道在缓存范围内的 Integer 对象,它们还是会共享同一把锁的,这里说的“共享”就是竞争的意思。
但是很明显,他的 id 范围肯定比 Integer 缓存范围大。
那么问题就来了:这玩意该咋搞啊?
我看到这个问题的时候想到的第一个问题是:上面这个需求我好像也经常做啊,我是怎么做的来着?
想了几秒恍然大悟,哦,现在都是分布式应用了,我特么直接用的是 Redis 做锁呀。
根本就没有考虑过这个问题。
如果现在不让用 Redis,就是单体应用,那么怎么解决呢?
在看高赞回答之前,我们先看看这个问题下面的一个评论:
开头三个字母:FYI。
看不懂没关系,因为这个不是重点。
但是你知道的,我的英语水平 very high,所以我也顺便教点英文。
FYI,是一个常用的英文缩写,全称是 for your information,供参考的意思。
所以你就知道,他后面肯定是给你附上一个资料了,翻译过来就是:Brian Goetz 在他的 Devoxx 2018 演讲中提到,我们不应该把 Integer 作为锁。
你可以通过这个链接直达这一部分的讲解,只有不到 30s秒的时间,随便练练听力:https://www.youtube.com/watch?v=4r2Wg-TY7gU&t=3289s
那么问题又来了?
Brian Goetz 是谁,凭什么他说的话看起来就很权威的样子?
Java Language Architect at Oracle,开发 Java 语言的,就问你怕不怕。
同时,他还是我多次推荐过的《Java并发编程实践》这本书的作者。
好了,现在也找到大佬背书了,接下来带你看看高赞回答是怎么说的。
前部分就不详说了,其实就是我们前面提到的那一些点,不能用 Integer ,涉及到缓存内、缓存外巴拉巴拉的...
关注划线的部分,我加上自己的理解给你翻译一下:
如果你真的必须用 Integer 作为锁,那么你需要搞一个 Map 或 Integer 的 Set,通过集合类做映射,你就可以保证映射出来的是你想要的明确的一个实例。而这个实例,就可以拿来做锁。
然后他给出了这样的代码片段:
就是用 ConcurrentHashMap 然后用 putIfAbsent 方法来做一个映射。
比如多次调用 locks.putIfAbsent(200, 200),在 map 里面也只有一个值为 200 的 Integer 对象,这是 map 的特性保证的,无需过多解释。
但是这个哥们很好,为了防止有人转不过这个弯,他又给大家解释了一下。
首先,他说你也可以这样写:
但这样一来,你就会多产生一个很小成本,就是每次访问的时候,如果这个值没有被映射,你都会创建一个 Object 对象。
为了避免这一点,他只是把整数本身保存在 Map 中。这样做的目的是什么?这与直接使用整数本身有什么不同呢?
他是这样解释的,其实就是我前面说的“这是 map 的特性保证的”:
当你从 Map 中执行 get() 时,会用到 equals() 方法比较键值。
两个相同值的不同 Integer 实例,调用 equals() 方法是会判定为相同的 。
因此,你可以传递任何数量的 "new Integer(5)" 的不同 Integer 实例作为 getCacheSyncObject 的参数,但是你将永远只能得到传递进来的包含该值的第一个实例。
就是这个意思:
汇总一句话:就是通过 Map 做了映射,不管你 new 多少个 Integer 出来,这多个 Integer 都会被映射为同一个 Integer,从而保证即使超出 Integer 缓存范围时,也只有一把锁。
除了高赞回答之外,还有两个回答我也想说一下。
第一个是这个:
不用关心他说的内容是什么,只是我看到这句话翻译的时候虎躯一震:
skin this cat ???
太残忍了吧。
我当时就觉得这个翻译肯定不太对,这肯定是一个小俚语。于是考证了一下,原来是这个意思:
免费送你一个英语小知识,不用客气。
第二个应该关注的回答排在最后:
这个哥们叫你看看《Java并发编程实战》的第 5.6 节的内容,里面有你要寻找的答案。
巧了,我手边就有这本书,于是我翻开看了一眼。
第 5.6 节的名称叫做“构建高效且可伸缩的结果缓存”:
好家伙,我仔细一看这一节,发现这是宝贝呀。
你看书里面的示例代码:
不就和提问题的这个哥们的代码如出一辙吗?
都是从缓存中获取,拿不到再去构建。
不同的地方在于书上把 synchronize 加在了方法上。但是书上也说了,这是最差的解决方案,只是为了引出问题。
随后他借助了 ConcurrentHashMap、putIfAbsent 和 FutureTask 给出了一个相对较好的解决方案。
你可以看到完全是从另外一个角度去解决问题的,根本就没有在 synchronize 上纠缠,直接第二个方法就拿掉了 synchronize。
看完书上的方案后我才恍然大悟:好家伙,虽然前面给出的方案可以解决这个问题,但是总感觉怪怪的,又说不出来哪里怪。原来是死盯着 synchronize 不放,思路一开始就没打开啊。
书里面一共给出了四段代码,解决方案层层递进,具体是怎么写的,由于书上已经写的很清楚了,我就不赘述了,大家去翻翻书就行了。
没有书的直接在网上搜“构建高效且可伸缩的结果缓存”也能搜出原文。
我就指个路,看去吧。
好了,那本文的技术部分就到这里啦。