当前位置: 首页 > 后端技术 > Java

Synchronized遇到这个东西,有个大坑,注意!_0

时间:2023-04-01 14:24:46 Java

前几天在某技术平台看到有人提出的关于Synchronized的使用问题。知道面试官想考什么,自己答得不是很好,后来研究了想起来了。所以当我看到这个问题的时候,感觉很亲切,特分享给大家:首先,为了方便大家看文章的时候能够更容易的复现问题,我给大家一个代码可以直接运行。我希望你有时间。同样拿出代码运行一下:publicclassSynchronizedTest{publicstaticvoidmain(String[]args){Threadwhy=newThread(newTicketConsumer(10),"why");Threadmx=newThread(newTicketConsumer(10),"mx");为什么.start();mx.start();}}classTicketConsumerimplementsRunnable{privatevolatilestaticIntegerticket;publicTicketConsumer(intticket){this.ticket=ticket;}@Overridepublicvoidrun(){while(true){System.out.println(Thread.currentThread().getName()+"开始抢先"+ticket+"ticket,在对象被锁定之前:"+System.identityHashCode(ticket));synchronized(ticket){System.out.println(Thread.currentThread().getName()+"获取第一个"+ticket+"ticket,成功锁定对象:"+System.identityHashCode(ticket));timeunit.Seconds.sleep(1);}catch(interruptedExceptionE){e.printstacktrace();}System.out.println(thread.currentthRead().getName()"to"+"票数,票数减去一”);}else{返回;资源,并且有两个线程消费,所以为了保证线程安全,在TicketConsumer的逻辑中使用了synchronized关键字。这是大家初学synchronized时应该写的一个例子。预期结果是10张票,每张票两人抢,每张票只能一个人抢。但是实际运行结果是这样的,我只截取了最开始的日志:截图中有三个框起来的部分。最上面的部分是两个人在抢第10张票。从日志输出来看,没有任何问题。最后只有一个人抢到票,然后进入争夺第9张票的过程。但是下面框出的第9票的竞争有点迷惑:为什么要抢第9票,成功锁定的对象:288246497mx抢第9票,锁定成功的对象:288246497复制代码为什么两个人抢了第9张票成功锁定同一个对象?这东西是超乎认知的。这两个线程怎么才能拿到同一个锁,然后执行业务逻辑呢?于是,提问者的问题就出现了。1、为什么synchronized不生效?2、为什么锁对象System.identityHashCode的输出是一样的?为什么没有生效?我们先来看一个问题。首先,我们从日志输出中已经很清楚的知道,在第二轮抢到第9张票的时候synchronized失败了。有理论知识支撑,我们知道如果synchronized失败了,肯定是锁有问题。如果只有一个锁,多个线程竞争同一个锁,synchronized绝对没有错。但是这里的两个线程还没有达到互斥的条件,也就是说这里肯定不止一把锁。这是我们可以通过理论知识推导出来的结论。我们先下结论,那么如何证明“不止一把锁”呢?能进入synchronized就意味着一定要拿到锁,所以我只需要看每个线程持有什么锁就可以了。那么您如何查看线程持有什么锁呢?jstack命令,打印线程堆栈函数,懂吗?所有这些信息都隐藏在线程栈中,我们取出来就能看到。如何获取idea中的线程栈?这是idea中调试的一个小技巧,在我之前的文章中应该出现过很多次。首先,为了方便获取线程栈信息,我把这里的sleep时间调整为10s:运行后,点击这里的“相机”图标:点击几下,点击对应的会有几个Dump信息时间点:因为之前需要观察两次锁的情况下,每个线程进入锁后都会等待10s,所以我在项目启动的前10s和后10s之间只点击了一次。为了更直观的观察数据,我选择点击下方图标复制Dump信息:复制的信息很多,但我们只需要关心why和mx这两个线程即可。这是第一个Dump中的相关信息:mx线程处于BLOCKED状态,正在等待地址0x000000076c07b058的锁。why线程处于TIMED_WAITING状态,并且处于休眠状态,说明已经抢到了锁,正在执行业务逻辑。而它抢到的锁,巧合的是,正好是mx线程等待的0x000000076c07b058。从输出日志来看,why线程第一次抢票:从dump信息来看,两个线程竞争的是同一个锁,所以第一次没有错。ok,再看第二次dump信息:这次两个线程都在TIMED_WAITING,都在休眠,也就是说都拿到了锁,进入了业务逻辑。但是仔细一看,两个线程持有的锁是不同的锁。mx锁是0x000000076c07b058。为什么锁是0x000000076c07b048。由于不是同一个锁,不存在竞争,所以都可以进入synchronized执行业务逻辑,所以两个线程都在休眠,没有什么问题。然后,我把两个dump的信息放在一起给大家看,这样更直观:如果我用“lock1”代替0x000000076c07b058,用“lock2”代替0x000000076c07b048。然后流程如下:为什么加锁成功,执行业务逻辑,mx进入lock-wait状态。why释放锁1,等待锁1的mx唤醒,持有锁1,继续执行业务。同时,为什么锁2成功,业务逻辑执行。从线程栈上,我们确实证明了synchronized没有生效的原因是锁变了。同时从线程栈中也可以看出为什么锁对象System.identityHashCode的输出是一样的。第一次Dump的时候,票都是10,其中mx没有抢到锁,被synchronized锁了。why线程执行了ticket--操作,ticket变成了9,但是此时mx线程锁定的monitor还是ticket=10的对象,它还在monitor的_EntryList中等待,而它不会因为机票的变化而改变。所以why线程释放锁的时候,mx线程拿到锁继续执行,发现ticket=9。还有为什么还拿到了新锁,也可以进入synchronized的逻辑,发现ticket=9。好家伙,票是9,System.identityHashCode能不一样吗?按理来说,为什么mx释放了锁1之后还要继续和mx竞争锁1,却不知道自己从哪里得到了新的锁。那么问题来了:锁为什么变了?谁动了我的锁?经过前面的分析,我们确信锁确实变了。分析到这里,你大发雷霆,拍案叫绝,大喊:哪个瓜动了我的锁?这不是作弊吗?根据我的经验,这个时候不要急着甩锅。继续往下看,你会发现小丑就是你自己:你抢到票后,执行了ticket--操作,而这张票不就是你的锁对象吗?这时,你一拍大腿,恍然大悟,对围观的人说:没什么大不了的,只是握手而已。于是大手一挥,把加锁的地方改成这样:synchronized(TicketConsumer.class)拷贝代码使用class对象作为锁对象,保证了锁的唯一性。经验证,没有任何问题,完美无缺,大功告成。但是真的结束了吗?其实关于锁对象为什么变了,还有一点时间不用多说。它隐藏在字节码中。我们通过javap命令查看字节码,可以看到这样的信息:Integer.valueOf这是什么?熟悉的从-128到127的Integer的缓存。也就是说,在我们的程序中,会涉及到拆箱和装箱的过程,在这个过程中会调用到Integer.valueOf方法。具体来说就是ticket--的操作。对于Integer,当值在缓存范围内时,将返回相同的对象。当超出缓存范围时,每次都会创建一个新的对象。这应该是八股文必备的知识点。我在这里给你强调这个是什么意思?很简单,改一下代码就明白了。我把初始化票数从10改成了200,超出了缓存范围。程序运行结果如下:很明显,从第一条日志输出来看,这两个锁不是同一个锁。这就是我前面说的:因为超出了缓存范围,所以执行了两次newInteger(200)操作。这是两个不同的对象,作为锁使用,是两个不同的锁。改回10,运行一次,可以感觉到:从日志输出来看,此时只有一把锁,所以只有一个线程抢到了票。因为10是缓存范围内的数字,所以每次从缓存中取的都是同一个对象。我写这篇短文的目的是为了反映Integer有缓存这一事实,这是众所周知的。但是和其他东西混在一起的时候,你要分析这个缓存会造成什么问题,这比直接背干巴巴的知识点要有效。但是……我们初始的ticket是10,ticket--然后ticket变成了9,也在缓存范围内,锁怎么变了?如果你有这个疑问,那我劝你再想想。10就是10,9就是9,虽然都在缓存的范围内,但本来就是两个不同的对象,建缓存的时候也是新建的:为什么要加上这个看似傻乎乎的解释呢?因为在网上看到其他写类似问题的时候,有些文章没有写清楚,会让读者误以为“缓存范围内的值都是同一个对象”,误导初学者。一句话:请不要用Integer作为锁对象,你掌握不了。但是...stackoverflow但是,我在写文章的时候在stackoverflow上看到了一个类似的问题。这位哥们的问题是:他知道Integer不能作为锁对象,但是他的需求好像需要Integer作为锁对象。stackoverflow.com/questions/6...我将向您描述他的问题。先看标①的地方,他的程序其实是先从缓存中获取,如果不在缓存中,则从数据库中获取,然后放入缓存中。非常简单明了的逻辑。但是,他考虑到在并发场景下,如果多个线程同时获取同一个id,但是这个id对应的数据不在缓存中,那么这些线程都会执行查询数据库和维护数据库的动作。缓存。对于查询和存储的动作,他用“相当昂贵”来形容。意思是“非常昂贵”。说白了,这个动作很“重”,最好不要反复做。所以你只需要让某个线程执行这个相当昂贵的操作。于是他想到了标②处的代码。使用synchronized来锁定id,不幸的是,id是Integer类型。他还在标③的地方说:不同的Integer对象不共享锁,所以synchronized没用。事实上,他的话并不严谨。经过前面的分析,我们知道缓存范围内的Integer对象仍然会共享同一个锁。这里所说的“共享”,就是竞争。但是很明显,他的id范围一定要大于Integer缓存范围。所以问题是:我应该怎么处理这个东西?看到这个问题我第一个想到的问题是:我好像经常做上面的需求,我该怎么做?想了几秒,恍然大悟,哦,现在都是分布式应用,干脆用Redis当锁。根本没想过。如果现在不让redis的话,就是单体应用,怎么解决呢?在看高赞的回答之前,我们先看看这个问题下面的一条评论:前三个字母:FYI。看不懂也没关系,因为这不是重点。但是你知道,我的英语水平很高,所以我也顺便教了一些英语。FYI是常用的英文缩写,全称是foryourinformation,供参考之意。所以你知道,他后来肯定给你附上了一条信息,翻译过来就是:BrianGoetz在他的Devoxx2018演讲中提到我们不应该使用Integer作为锁。可以通过这个链接直接进入这部分讲解,练习听力只需要不到30秒:www.youtube.com/watch?v=4r2...那么问题又来了?BrianGoetz是谁,是什么让他听起来很有权威?开发Java语言的Oracle的JavaLanguageArchitect,问你怕不怕。同时,他也是我多次推荐过的一本书《Java并发编程实践》的作者。好吧,现在我找到大佬背书了,我就给大家看看高赞的回答是怎么说的。我不会详细介绍上一部分。其实就是我们前面提到的几点。不能用整数,涉及缓存内外。注意下划线的部分,我用自己的理解给你翻译一下:如果真的非要用Integer做锁,那你需要做一个Map或者一个SetofIntegers,通过collection类进行map,并且您可以确保映射的实例是您想要的特定实例。而这个例子可以作为一把锁。然后他给出了这样一个代码片段:使用ConcurrentHashMap然后使用putIfAbsent方法做一个map。例如多次调用locks.putIfAbsent(200,200),map中只有一个值为200的Integer对象。这是地图的特性保证的,不需要过多解释。不过这哥们很好。为了不让某些人拐弯抹角,他向大家解释了一遍。首先他说你也可以这样写:但是这样的话,你会产生一个小小的额外开销,就是每次访问的时候,如果value没有被映射,就会创建一个Object对象。为了避免这种情况,他只是将整数本身保存在Map中。这样做的目的是什么?这与直接使用整数本身有何不同?他是这样解释的,其实就是我前面说的“这是由map的特性保证的”:当你从Map中执行get()时,你会使用equals()方法来比较键值。两个具有相同值的不同Integer实例将通过调用equals()方法来判断是否相同。因此,您可以将“newInteger(5)”的任意数量的不同Integer实例作为参数传递给getCacheSyncObject,但您只会获得传入的包含该值的第一个实例。就是这个意思:一句话总结:Mapping是通过Map来完成的。不管你新出来多??少个Integer,这多个Integer都会映射到同一个Integer上,从而保证即使超出Integer缓存范围也只有一个锁。除了高赞的回答,还有两个回答我想说一下。第一条是这样的:不管他说什么,但是看到这句话的翻译我惊呆了:剥这只猫???太残忍了。当时就觉得这个翻译一定是错的,一定是有点俚语。于是研究了一下,原来是这个意思:我免费给你一点英文知识,不客气。第二个你要注意的答案在最后:这位哥们叫你看《Java并发编程实战》的5.6节,里面有你要找的答案。正好手边有这本书,就打开看了看。第5.6节称为“构建高效且可扩展的结果缓存”:伙计,我仔细查看了这一部分,它是一个瑰宝。你看了书上的示例代码:是不是和问问题的哥们的代码一模一样?它们都是从缓存中获取的,不能重新构建。不同的是书上给方法加上了synchronize。但是书中也说这是最糟糕的解决方案,只会导致问题。然后他借助ConcurrentHashMap、putIfAbsent和FutureTask给出了一个比较好的解决方案。可以看到换个角度解决了问题,完全没有synchronize的纠结,第二种方法直接去掉synchronize。看完书上的解决方案,恍然大悟:好家伙,上面给出的解决方案虽然可以解决这个问题,但是总感觉怪怪的,说不出哪里不对。原来是我死死盯着synchronize,一开始并没有打开idea。书中一共给出了四段代码,逐层递进求解。具体怎么写,既然书上已经写的很清楚了,就不赘述了。你可以只看书。如果没有书,也可以直接在网上搜索《构建高效可扩展的结果缓存》来查找原文。我给你指路,让我们看看。