在工作的过程中,说起“多线程”,大家往往都觉得恐惧,因为人类天生喜欢事物按顺序发展,一步一个节点,达到可控的地步。
“多线程”恰好背道而驰,他使得逻辑能够并行执行,在提高效率的同时也让大家对于混乱不可控的场面头皮发麻。
多线程的问题是非常难查的,特别是当不同逻辑的线程操控相同资源的时候,资源的状态经常会出现意想不到的结果。
这里给大家举一个比较常见的例子:帮会
帮会的共建功能相信大家不陌生,其内容就是通过扣除玩家身上的资源,增加帮会资源的等级。我们分析一下这个过程。
但实际上,过程往往不会这么简单。我们需要先拿出帮会当前的数据,判断是否可以升级,然后判断玩家身上资源是否足够,如果足够,则扣除,扣除完毕,修改帮会资源。其过程如下。
那么整个过程,每个环节都可能出错(不管是逻辑上的错误,还是运行时抛异常),我们的逻辑就会停在那里,比如,玩家资源不足,比如帮会已经无法升级了,比如你代码写错抛空指针异常了。
以上的逻辑,如果运作在多线程环境下,情况会恶劣更多。因为根据多线程的特性,当前线程在执行到上图逻辑任何节点时,其他线程可能已经把某些数值修改了。
比如,玩家A拿到帮会仓库等级为2(满级为3),判断帮会仓库可以升级,突然,线程被打断,玩家B做了同样的判断,并且提交了资源,使得帮会仓库升级为3。
线程回到玩家A,玩家A在玩家B执行之前已经通过了判断的代码,所以他扣除了资源,但是在升级帮会的时候,因为资源不足以使得帮会仓库达到3级,所以,得出的结论,帮会等级还是为2,于是他修改了数据,帮会等级变回了2。如图所示。
我这里只是列举了一种情况,其实现实中问题不是这么考虑的,应该是,在多线程环境下,代码的任何地方都可能被打断,你上下文的变量可能不是最新的。
上面讲的情况是建立在,帮会为独立线程,玩家为独立线程的情况下。
介绍完可能造成的问题,我要分两个方面说接下来的内容,1.为什么说不要违背线程的初衷?2.怎么真正的理解多线程?3.怎么做到多线程敏感?
1.为什么说不要违背线程的初衷?
线程的初衷是什么?让出计算资源。
我们每天都在面临着最优选择的计算。
a.银行排队办手续10分钟
b.处理一份工作邮件需要4分钟
c.吃饭需要5分钟
一个很节约时间的人会怎么做,肯定是边排队,边吃东西,边回复邮件了。
人类做工作总会倾向于最高效的方式,从而做出的选择是尽可能多的利用劳动力。
所谓“让出计算资源”就是不要“干等着”,让他们随时随地做事。
这个时候有人就想了,如果我有分身术该多好,让他们同时去做这些事,那么这些事很快就完成了,而不用把时间浪费在等待上,注意,是浪费在“等待”上。
所以,在用多线程之前,请思考一下,我为什么要另外线程来完成这件工作,是不是其他有其他的事情“干等着”,浪费了时间,浪费了计算资源(如CPU)。
当然,另开线程完成工作也不一定完全是因为不想“等待”。还要一些情况也可以考虑另开线程。
a.我不想让其他逻辑的状态影响我,比如,其他逻辑抛出了异常而未捕获,导致代码无法向下继续执行
b.我想保证我的数据只能被我开的线程影响,只能通过传递命令的方式在我的线程来执行逻辑来获取或修改我的数据,避免多线程造成的数据错误
线程不是越多越好,线程的切换是有性能开销的,如无必要,勿增线程。不要低估现代计算机的计算能力,单线程也能很好的完成任务,开线程大多数原因只是因为防止阻塞。
Redis最开始就是单线程运行的,效率不俗。虽然说6.0引入了多线程,但大家可以去看看,他引入多线程的原因绝对是没有违背线程初衷的,因为他是为了解决网络IO问题,IO其实在“等待”这种情况中占绝大部分,比如,读写硬盘、数据库,网络请求等等。
2.怎么真正的理解多线程?
网络上经常会有让你用多个线程做++操作,然后阐释多线程会造成什么问题。
int a=0;
线程1:++a;
线程2:++a
问线程1和线程2分别对a做++操作100次,最后a是多少?答案是:不确定。
这里我不绕弯子了,因为这里不是重点,我直接解释一下。
多线程虽然说是并行操作,但在CPU时间维度来说,他其实是在不停切换,不停调度,他就像一个多动症一样,一会去执行你的代码,一会去执行他的代码,由于CPU太快了,给我们的感觉就像是在并行一样。
新手同学看到这里,他以为他了解多线程了。因为他可以解释刚才的变量a为什么结果会不确定了。因为你看啊,假设线程1拿到a=0,他刚好要做++操作的时候,线程1被打断,线程2介入,他拿到的a也是0,但是接下来他顺利的对a做了++操作,于是a变成了1。这时候又轮到线程1了,线程也对a做了++操作,a也变成了1,结果两个线程这一趟执行下来,a还是1。
等等!这不是和上面帮会那个图一样的吗?哈哈,确实是拿刚才的图改的,他们确实是一样的。
好了,我现在想问大家一个问题,我省去拿a的过程,我在每次操作的时候,都去拿最新的a,这样不就行了吗?
是的,理论上是对的,但是,为什么你会生出“我的拿最新的a”这个想法呢。难道我程序里写的a++中的a不是用的上面声明的a?我并没有把a先“获取”到变量t,然后对t做操作,然后再把t的值复制给a啊。
int a=0;
线程1:{
int t=a;
a=++t;
}
线程2:{
int t=a;
a=++t;
}
所以,既然一切都是串行的,为什么a还可能不是最新的a呢?
因为线程都会有一个自己空间。可能我的叫法不专业,大家可以去百度下:内存模型
线程中使用到的变量,并不是时时读取主空间的值,而是拿过来,先缓存,以后的操作全是用的这个缓存,所以变量a可能不是最新的。
所以,人们为了增强性能,在各个地方都做了缓存,而缓存会引起不一致的结果。
现在明白了线程空间这个坑了,在思考多线程问题的时候,也知道了,线程的变量可能并不是最新的。
那总得有一个办法让我们拿到最新的值啊,不然这怎么玩儿?还真有,在java中有一歌volatile的关键字,他能让你总能拿到最新的变量值。其原理,我不赘述,大家去百度百度。
volatile好多同学也一知半解,总有些人认为,我在变量上加一个volatile就会避免多线程问题,但真正懂线程的同学都会先关心volatile到底修饰的是一个什么变量,下面我给两种情况。
volatile Person person = new Person();
有些同学这么写,就觉得person对象里的东西都线程安全了,其实不然,volatile到底保证的是一个什么东西,他都没搞清楚,就在乱用,他只能保证变量值是最新的,但这里变量的值到底是什么?答案很明显,对象指针。volatile并没有做错什么,他保证了变量的值是最新的,但你确认为他会确保你person对象里的变量总是最新的,大错特错哈!
类似的还有
volatile Map<String,Person> map = new HashMap<>();
同样的道理,volatile只保证了map这个引用是最新的,并不能保证Map这个接口是线程安全的。
有些傻乎乎的同学又要说了,我学习过java的并发包,里面有个ConcurrentHashMap,这个可是线程安全的,我把HashMap替换成ConcurrentHashMap不就行了?
volatile Map<String,Person> map = new ConcurrentHashMap<>();
这种同学面试直接pass,ConcurrentHashMap只是保证这个集合的操作是线程安全的,他怎么能管得着你Person里面的变量呢?
别看这些错误愚蠢,但在实际审代码的过程中,简直不要太常见,说实话,我没见几个真正懂多线程的。
OK,正确的用法是
volatile int a = 0;
volatile保证了a是最新的。
那么问题又来了,刚才我们说的CPU就像多动症一样跳来跳去,看似并行,其实串行,你们发现没,我们只是在讨论单核CPU,但是现在的计算机,谁还是单核的呢?
多核CPU那就真真正正是并行执行了。
说了这么多,下面有一个面试题,看下亲们能不能准确回答,这也是我以前经常问别人的,正确率低得令人发指……
解释一下如下代码,线程1和线程2,同时for循环100次,count的值最终是多少
int count=0;
线程1:{
count++;
}
线程2:{
count++;
}
count并不会为200,因为变量count并非线程安全,我们加一个volatile就解决。其实不然。我们翻译下代码。
int count=0;
线程1:{
int t=count;
count=count+1;
return t;
}
线程2:{
int t=count;
count=count+1;
return t;
}
如果不了解这个地方为什么结果还是不对,那他肯定不了解线程原来还有一个自己的变量副本。
虽然我们加了volatile关键字,但count++本身并不是一个原子操作,他还是会被打断,因为在做真正的+1操作之前,线程副本里的a已经被读取了,就算他是一个最新的值,但依然会出问题。
所以,我们在考虑到多线程问题的时候,更重要的是考虑这一串操作是不是原子的。
原子性操作针对CPU指令来说,其实跟数据库事务差不多意思,要么全部执行成功,要么就不执行。
volatile关键字更多的使用场景是N个读,一个写。
感觉本来想好好细细的讲一下,发现自己表达水平有限,如果有不清楚的亲们,可以留言问我。
3.怎么做到多线程敏感?
做到线程敏感,首先就是要随时想到,我这段代码需要不要考虑多线程环境。
如果需要考虑到多线程环境,变量的来源是否可靠,我的变量是否会被他人肆意修改。
更多的是,我这段代码是否具有原子性。
尽量不要多线程操作,每个线程维护自己的私有变量,通过命令方式把逻辑拿到自己线程来执行。如果非要跨线程调用,例如帮会和玩家,则遵守一个高频阻塞低频的原则,即优先让低频线程等待高频线程执行完毕,自己再执行,比如帮会线程等待玩家线程执行扣除完毕,再做操作,保证串行。尽量不要用synchronized关键字,虽然做了优化。最大的灾难就是满屏synchronized,最后死锁了还不知道为什么。
有一个面试原题,我给大家看一下。
1. 线程1,2不断对整型a进行+1操作并打印,怎么保证打印值的连续性?
2. 线程1,2,3对整型a只读,线程4对整型a可读写,怎么保证读取的线程获取最新的a?