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

45次阅读

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

你好呀,我是歪歪。

前几天在思否上看到他人提的对于 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 作为锁对象。

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/

正文完
 0