前几天在某技术平台上看到别人提的关于 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 缓存范围时,也只有一把锁。
除了高赞回答之外,还有两个回答我也想说一下。
第一个是这个:
不用关心他说的内容是什么,只是我看到这句话翻译的时候虎躯一震:
第二个应该关注的回答排在最后:
这个哥们叫你看看《Java并发编程实战》的第 5.6 节的内容,里面有你要寻找的答案。
第 5.6 节的名称叫做“构建高效且可伸缩的结果缓存”:
不就和提问题的这个哥们的代码如出一辙吗?
都是从缓存中获取,拿不到再去构建。
不同的地方在于书上把 synchronize 加在了方法上。但是书上也说了,这是最差的解决方案,只是为了引出问题。
随后他借助了 ConcurrentHashMap、putIfAbsent 和 FutureTask 给出了一个相对较好的解决方案。
你可以看到完全是从另外一个角度去解决问题的,根本就没有在 synchronize 上纠缠,直接第二个方法就拿掉了 synchronize。
看完书上的方案后我才恍然大悟:好家伙,虽然前面给出的方案可以解决这个问题,但是总感觉怪怪的,又说不出来哪里怪。原来是死盯着 synchronize 不放,思路一开始就没打开啊。
书里面一共给出了四段代码,解决方案层层递进,具体是怎么写的,由于书上已经写的很清楚了,我就不赘述了。