当咱们提到多线程、并发的时候,咱们就会回想起各种诡异的bug,比方各种线程平安问题甚至是利用解体,而且这些诡异的bug还很难复现。咱们不禁收回了灵魂拷问 “为什么代码测试环境运行好好的,一上线就不行了?”。 为了解决线程平安的问题,咱们的先辈们在编程语言中引入了各种各样新名词,就拿咱们相熟的Java为例,不仅java语言自带了synchronized、volatile、wait、notify… ,jdk中各种工具包也是层出不穷,就比方单一个Lock,就能够有很多种实现,甚至很多人都谈锁色变。

  为什么会呈现这种状况,咱们得先从CPU和主存(RAM)的关系说起。 上个世纪80年代,PC机衰亡的时候,CPU的运算速度只有不到1MHz。放当初你桌上的计算器都能够吊打了它了。那时候就是因为CPU运算慢,它对数据存取速度的要求也不那么高,顶多也就1微秒(1000ns)取一次数据,一次访存100ns对CPU来说也算不上什么。 然而这么多年过来了,CPU始终在沿着摩尔定律的路线一路狂奔,而内存拜访提早的速度却始终止步不前。(当然存储也有十分大的倒退,但次要体现在容量方面,而拜访延时自诞生初就没什么变动)。

  咱们来比照下CPU和内存过来几十年之间的倒退速率:

  能够看出,在过来40年里, CPU的运算速度增量了上千倍,而内存的拜访延时却没有太大的变动。 咱们就拿当今最先进CPU和内存举例,目前商用的CPU主频根本都是3GHz左右的(其实十多年前基本上就这个程度了),算下来CPU每做一次运算仅需0.3ns(纳秒)。而以后最先进的内存,拜访提早是100ns左右的,两头相差300倍。如果把CPU比作一个打工人的话,那么他的工作状态就会是干一天活而后休一年,这劳动的一年里等着内存里的数据过去(真是令人羡慕啊)。

  其实CPU的设计者早就意识到了这点,如果CPU真是干1休300的话,未免也太不高效了。在说具体解决方案前,我这里先额定说下内存,很多人会好奇为什么主存(RAM)的访问速度始终上不来? 这个精确来说其实只是DRAM内存的速度上不了。存储芯片的实现形式有两种,别离是DRAM和SRAM,SRAM的速度其实也始终尽可能跟着CPU在跑的。那为什么不必SRAM来制作内存?这个也很简略,就是因为它存储密度低而且巨贵(绝对于DRAM),所以出于老本考量当初内存条都是采纳DRAM的技术制作的。

  SRAM容量小老本高,但速度快,DRAM容量大成本低,但速度慢。这俩能不能搭配应用,舍短取长?论断是必定的,在计算机科学里有个”局部性原理“,这个原理是计算机科学畛域所有优化的基石。我这里就单从数据拜访的局部性来说,某个地位的数据被拜访,那么相邻于这个地位的数据更容易被拜访。那么利用这点,咱们是不是能够把以后最可能被用到的小局部数据存储在SRAM里,而其余的局部持续保留在DRAM中,用很小的一块SRAM来当DRAM的缓存,基于这个思路,于是CPU芯片里就有了Cache,CPU的设计者们感觉一层缓存不够,那就给缓存再加一层缓存,于是大家就看到当初的CPU里有了所谓的什么L1 Cache、L2 Cache, L3 Cache。

  存储示意图如下,实在CPU如右图(Intel I7某型号实物图):

  多级缓存的呈现,极大水平解决了主存访问速度和CPU运算速度的矛盾,但这种设计也带来了一个新的问题。CPU运算时不间接和主存做数据交互,而是和L1 Cache交互,L1 cache 又是和L2 Cache交互…… 那么肯定意味着同一份数据被缓存了多份,各层存储之间的数据一致性如何保障? 如果是单线程还好,毕竟查问同一时间只会在一个外围上运行。但当多线程须要操作同一份数据时,数据一致性的问题就凸显进去了,如下图,咱们举个例子。

  在上图中3个CPU外围各自的Cache别离持有了不同的a0值(先疏忽E和I标记),实际上只有Cache0里才持有正确的数值。这时候,如果CPU1或者CPU2须要拿着Cache中a0值去执行某些操作,那后果可想而知。如果想保障程序在多线程环境下正确运行,就首先得保障Cache里的数据能在"失当"的工夫生效,并且无效的数据也能被及时回写到主存里。

  然而CPU是不晓得以后时刻下哪些数据该生效、哪些该回写、哪些又是能够接着应用的。这个时候其实CPU的设计者也很犯难,如果数据频繁生效,CPU每次获取必须从主存里获取数据,CPU理论运算能力将回到几十年前的程度。如果始终不给不生效,就会呈现数据不统一导致的问题。于是CPU的设计者不干了:”这个问题我解决不了,我给你们提供一些能够保证数据一致性的汇编指令,你们本人去解决”。 于是大家就在intel、arm的开发手册上看到了像xchg、lock、flush……之类的汇编指令,C/C++语言和操作系统的开发者将这些封装成了volatile、atomic……以及各种零碎调用,JVM和JDK的开发者又把这些封装了我在文首说的那一堆关键词。 于是CPU的设计者为了晋升性能导致数据一致性的问题,最终还是推给了下层开发者本人去解决。

  作为下层的开发者们(比方咱们)就得判断,在多线程环境下那些数据操作必须是原子操作的,这个时候必须应用Unsafe.compareAndSwap()来操作。还有那些数据是不能被CPU Cache缓存的,这个时候就得加volatile关键词。极其状况下,你能够所有的操作搞成原子操作、所有的变量都申明成volatile,尽管这样确实能够保障线程平安,但也会因为主存拜访延时的问题,显著升高代码运行的速度。这个时候局部性原理又施展出其神奇的价值,在理论状况下,绝大多数场景都是线程平安的,咱们只须要保障某些要害操作的线程安全性即可。举个简略的例子,咱们在工作向多线程散发的时候,只须要保障一个工作同时只被分发给一个线程即可,而不须要保障整个工作执行的过程都是齐全线程平安的。

  作为Java开发者,Java和JDK的开发者们曾经帮咱们在很多场景下封装好了这些工具,比方咱们就拿ReentrantLock实现一个多线程计数器的例子来看。

  其中increment() 自身不是一个线程平安的办法,如果多个线程并发去调用,依然会呈现count值增长不精确的问题。但在lock的加持下,咱们能保障increment()办法同时只能有一个线程在执行。设想下,如果咱们把上述代码中的counter()办法换成一些更简单的办法,而齐全不须要在办法中去思考线程平安的问题,这不就实现了仅在要害操作上保障准确性就能保障全局的线程平安吗!而当咱们去深究lock的实现时,就会发现它底层也只是在tryAcquire中应用CAS设置了state值。

  在多线程编程中,加锁或加同步其实是最简略的,然而在什么时候什么中央加锁却是一件非常复杂的事件。你须要思考锁的粒度的问题,粒度太大可能影响性能,粒度过小可能导致线程平安的问题。还须要思考到加锁程序的问题,加锁程序不当可能会导致死锁。还要思考数据同步的问题,同步的数据越多,CPU Cache带来的性能晋升也就越少……

  从下面CPU的倒退变动咱们能够看到,古代CPU的实质其实也是一个分布式系统,很多时候仍须要编程者手动去解决数据不一致性的问题。当然随着编程语言的倒退,这些底层相干的货色也逐步对一般程序员变得更透明化,咱们是不是能够料想,将来是不是会有一门高性能、并且齐全不须要程序员关注数据一致性的编程语言呈现? 

  最初下面计数器代码给大家留一个思考题: 代码中的counter变量申明是否须要加volatile关键字?