关于后端:当Synchronized遇到这玩意儿有个大坑要注意

35次阅读

共计 7275 个字符,预计需要花费 19 分钟才能阅读完成。

前几天在某技术平台上看到他人提的对于 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) 的操作,这是两个不同的对象,拿来作为锁,就是两把不一样的锁。
再批改回 10,运行一次,你感受一下:

从日志输入来看,这个时候只有一把锁,所以只有一个线程抢到了票。
因为 10 是在缓存范畴内的数字,所以每次是从缓存中获取进去,是同一个对象。
我写这一小段的目标是为了体现 Integer 有缓存这个知识点,大家都晓得。然而当它和其余货色揉在一起的时候因为这个缓存会带来什么问题,你得剖析进去,这比间接记住干瘪的知识点无效一点。
然而 …
咱们的初始票是 10,ticket– 之后票变成了 9,也是在缓存范畴内的呀,怎么锁就变了呢?
如果你有这个疑难的话,那么我劝你再好好想想。
10 是 10,9 是 9。
尽管它们都在缓存范畴内,然而原本就是两个不同的对象,构建缓存的时候也是 new 进去的:

为什么我要补充这一段看起来很傻的阐明呢?
因为我在网上看到其余写相似问题的时候,有的文章写的不分明,会让读者误认为“缓存范畴内的值都是同一个对象”,这样会误导初学者。
总之一句话:请别用 Integer 作为锁对象,你把握不住。
然而 …

stackoverflow
然而,我写文章的时候在 stackoverflow 上也看到了一个相似的问题。
这个哥们的问题在于:他晓得 Integer 不能做为锁对象,然而他的需要又仿佛必须把 Integer 作为锁对象。

stackoverflow.com/questions/6…

我给你形容一下他的问题。
首先看标号为 ① 的中央,他的程序其实就是先从缓存中获取,如果缓存中没有则从数据库获取,而后在放到缓存外面去。
非常简单清晰的逻辑。
然而他思考到并发的场景下,如果有多个线程同一时刻都来获取同一个 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 秒的工夫,轻易练练听力:www.youtube.com/watch?v=4r2…

那么问题又来了?
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 不放,思路一开始就没关上啊。
书外面一共给出了四段代码,解决方案层层递进,具体是怎么写的,因为书上曾经写的很分明了,我就不赘述了,大家去翻翻书就行了。
没有书的间接在网上搜“构建高效且可伸缩的后果缓存”也能搜出原文。
我就指个路,看去吧。

正文完
 0