11

你好呀,我是歪歪。

前几天在思否上看到别人提的关于 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 张票,且成功锁到的对象都一样的?

这玩意,超出认知了啊。

这两个线程怎么可能拿到同一把锁,然后去执行业务逻辑呢?

所以,提问者的问题就浮现出来了。

  • 1.为什么 synchronized 没有生效?
  • 2.为什么锁对象 System.identityHashCode 的输出是一样的?

为什么没有生效?

我们先来看一个问题。

首先,我们从日志的输出上已经非常明确的知道,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

但是,我写文章的时候在 stackoverflow 上也看到了一个类似的问题。

这个哥们的问题在于:他知道 Integer 不能做为锁对象,但是他的需求又似乎必须把 Integer 作为锁对象。

https://stackoverflow.com/que...

我给你描述一下他的问题。

首先看标号为 ① 的地方,他的程序其实就是先从缓存中获取,如果缓存中没有则从数据库获取,然后在放到缓存里面去。

非常简单清晰的逻辑。

但是他考虑到并发的场景下,如果有多个线程同一时刻都来获取同一个 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...

那么问题又来了?

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 节的名称叫做“构建高效且可伸缩的结果缓存”:

好家伙,我仔细一看这一节,发现这是宝贝呀。

你看书里面的示例代码:

.png)

不就和提问题的这个哥们的代码如出一辙吗?

都是从缓存中获取,拿不到再去构建。

不同的地方在于书上把 synchronize 加在了方法上。但是书上也说了,这是最差的解决方案,只是为了引出问题。

随后他借助了 ConcurrentHashMap、putIfAbsent 和 FutureTask 给出了一个相对较好的解决方案。

你可以看到完全是从另外一个角度去解决问题的,根本就没有在 synchronize 上纠缠,直接第二个方法就拿掉了 synchronize。

看完书上的方案后我才恍然大悟:好家伙,虽然前面给出的方案可以解决这个问题,但是总感觉怪怪的,又说不出来哪里怪。原来是死盯着 synchronize 不放,思路一开始就没打开啊。

书里面一共给出了四段代码,解决方案层层递进,具体是怎么写的,由于书上已经写的很清楚了,我就不赘述了,大家去翻翻书就行了。

没有书的直接在网上搜“构建高效且可伸缩的结果缓存”也能搜出原文。

我就指个路,看去吧。

本文已收录至个人博客,欢迎来玩:

https://www.whywhy.vip/

why技术
2.2k 声望6.8k 粉丝