Java并发编程漫谈

我们由2个定律来开启本次漫谈

摩尔定律

摩尔定律是由英特尔(Intel)创始人之一戈登·摩尔(Gordon Moore)于1965年提出来的。其内容为:当价格不变时,集成电路上可容纳的晶体管数目,约每隔18个月便会增加一倍,性能也将提升一倍。换言之,每一美元所能买到的电脑性能,将每隔18个月翻两倍以上。这一定律揭示了信息技术进步的速度。

关于摩尔定律,也通常被称为计算机第一定律,相信大家早就听说过,甚至非计算机相关人员也耳熟能详。我的个人看法是摩尔定律的商业意义要大于实际意义。毕竟是intel的创始人提出的, 从现在来看,还是有一些打击排挤竞争对手之嫌,这个定律的潜台词是:如果18个月后,你无法生产出晶体管集成度高一倍,性能提高一倍的CPU,那么,你将被市场淘汰。这个门槛其实是挺高的,我个人认为是被人为拉高了的,而且我也相信,18个月,这个时间,还是很有可能是戈登摩尔经过精心“计算”后得出的数据。最终结果是,到目前为止,商用CPU市场基本上被Intel统治着,当然了,移动平台情况目前稍有不同。但不管怎么样,摩尔定律在计算机方面做出的贡献还是非常巨大的,商业竞争行为毕竟直接加快了CPU技术的升级改造。

 

阿姆达尔(Amdahl)定律

阿姆达尔定律是由IBM公司的计算机体系结构师吉恩·阿姆达尔在1967年发表的论文中提出的。简单的说,它描述的是:不可并行计算的存在是很重要的,因为它将限制并行化的潜在好处。包括阿姆达尔在内的许多人对该定律的解释表明使用大量的处理器求解问题只能获得有限的成功。概括地讲,阿姆达尔定律并不否定并行计算的价值。相反,它提醒我们要想达到并行性能就必须考虑整个程序。可以用一下比较恰当的比喻使读者能够更加清楚的理解这个定律:一个人从A到B需要走一个确定的路程,为60公里。但他在前30公里的速度为20km/h,所以无论他在后半程怎么加速,整个行程的平均速度也达不到60km/h。

关于阿姆达尔定律,估计熟悉他的人不多。这个定律的意义在于,它给人们指出了一条不同于摩尔定律的,提高计算机性能的另一条道路,尽管这条道路并不太好走。

 

现代计算机体系结构

从计算机诞生那天起,冯.诺依曼体系结构占据着主导地位,但是当今,冯.诺依曼体系结构只能近似描述多处理器体系结构的计算机,其实并行逻辑结构已经被归结为非冯诺依曼体系结构。

Amdahl定律通过系统中并行化与串行化的比重来描述多处理器系统能获得的运算加速能力,摩尔定律则用于描述处理器晶体管数量与运行效率之间的发展关系。这两个定律的更替代表了近年来硬件发展从追求处理器频率到追求多核心并发处理的发展过程。并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力最有力的武器。

我们从宏观上来看看目前的现代计算机。摩尔定律的最早提出时间是1965年,阿姆达尔定律比他要晚2年。但是,阿姆达尔定律真正主导商用CPU发展却是在其提出40年后,也就是大约在2007年前后。这是为什么呢?我想大概是2个原因:

  1. 单纯的提高CPU运行速度(也就主频)的方法比设计并行架构要容易一些,换个比较商业化的说法是,研发成本相对较低。
  2. 计算机软件的设计者们还没有完全准备好通过并行设计来提高软件性能,也许他们一开始就被惯坏了,在太长的时间里已经习惯了通过提高CPU主频的方式来改善当前糟糕的软件性能。其实这个我认为也是主要原因。

 

面对并行计算,你准备好了吗?

尽管并行计算的概念在计算机理论中由来已久,但真正了解程序并行底层机制的人并不太多。其实这也算正常,过去的许多年间,大家对并行的认知也是在经历一个从无到有,从陌生到精通的过程。在Java领域,这个一过程也在通过Java虚拟机规范渐渐完善,逐步传递到每一个Java开发工程师的思想中。

并行计算的推广是从硬件逐步传递到软件的,不管你是否准备好了,也不管你是否喜欢这样。你都必须被强制的接受,因为这一切都是摩尔定律即将走到尽头带来的恶果。一般的商业软件生产者希望尽可能多的聚焦于具体的业务逻辑,而不希望在计算机底层技术上做过多的纠缠。

现在的情况已经很明显了,CPU主频提升减缓,并且有逐步停滞的迹象。随之而来的是CPU核数的指数增加。每个人都已经必须接受并发软件设计的思想和方法了,已经没有退路了。这个曾经是高端服务器领域的专家才能涉足的技术,如今已经全面且迫切的向普通程序员开放了。回想一下,在那个CPU主频飙涨的年代,并行编程还只是一个可选的高级选项,如今其实已经成为了一个必选项了。为什么?后面讲性能时我们会详细解释下。

这里先扯一个题外话,现在我看到很多关于硬件测评,特别是手机测评的文章,里面会提到某某牌xxx手机,是双核的,跑某个常用软件时和四核的一样快,于是得出结论,这个双核的已经够用了,买四核的是浪费,类似的言论在PC评测里面也经常能看到。我想说的是,类似言论,糊弄一下普通消费者也就够了,但作为广大软件技术人员来说,如果你编写的程序在高核数机器上跑的没有低核数机器上“快”,那只能说明这个软件在设计上的可伸缩性上太差了,换个通俗点的说法,你设计的这个软件没有能力去驾驭多出来的CPU并行计算能力,问题是出现在软件设计,而不是四核没有双核划算。后面谈性能时再详细讨论这个可伸缩性的问题。

 

进入正题,先来面对一个问题

我们先来看一个简单的Java问题:存在多个线程中访问变量x,其中某个线程有如下语句:

         x = 3;

问题:在什么条件下,其他线程读取x时,能看到x值为3 ?

这个问题看似好像不是一个问题,因为估计应该是100%的人,在第一次写java多线程代码时是绝对不会去考虑这个问题的。在完整解答这个问题之前,我们先来回答几个和硬件相关的问题。

 

先来回答几个硬件相关的问题

  • CPU能直接操作内存吗?
  • CPU中的寄存器,多级缓存的意义?
  • 多CPU,多核,超线程技术之间有什么的区别?
  • 缓存一致性协议是什么?
  • 乱序执行优化是什么?

问题挺多的,我们一个一个的看。

首先,在任何一个年代,CPU的运行频率和内存的运行频率都是不对等的,当然,内存和硬盘也同样是这样,简单的说,就是CPU比内存快,内存比硬盘快,这个快不是快一点两点,目前阶段早已达到好几个数量级的差别了。所以,CPU一般不直接读写内存,而是使用多级高速缓存。寄存器则是CPU内核直接使用的存储器,理论上,寄存器的读写速度和CPU速度应该一致。好了,我们一口气回答了2个问题,再看下一个。

一般来说,多CPU系统是真正的并行系统,每颗CPU都有各自的缓存系统。

其次,多核CPU一般是各个内核有独立的1级和2级缓存,但共享3级缓存,和多CPU并行系统已经非常接近了,但多核的设计和制造成本要比多CPU系统低不少。

最后,来看下超线程技术,也就是Intel常说的HT技术,它使用的是2个逻辑内核映射到一个物理内核上,缓存则是进行逻辑划分,而不是物理划分。所以超线程CPU实质上不算是一个真正的物理并行系统。

打个比方,你如果购买了双核CPU相当于请了2个厨师帮你做菜,如果你购买了一个超线程CPU相对于请了一个左右手能同时做菜的厨师。

实际上,程序员并不需要特别在意是物理内核还是逻辑内核,现在的并行系统构建中,这3种技术正处于融合阶段,也就是说,在某个并行系统中,可能同时存在这3种CPU技术,比如,一个物理服务器上可能同时存在2个物理cpu,每个物理CPU有2个物理内核,每个物理内核都使用超线程技术。那么,在这个系统中,同时可以运行8个线程,这个系统通常也被称为8路并行处理系统。

好了,接着,我们再来看看什么是缓存一致性?缓存是为了弥补cpu和内存速度不一致而引入的一个功能性部件,这个功能性部件能够很好的解决内存拖cpu后腿的问题,但同时也引入了另一个复杂问题,缓存和内存数据不一致问题。

因为缓存中的数据实质上是内存中的数据副本,cpu对缓存中的数据副本做各种计算操作后的数据需要在一个适当的时候回写到内存中。在cpu计算过程中,缓存中的数据和内存中得数据可能处于不一致状态,即缓存和内存之间存在一致性问题。

缓存一致性协议就是为了解决这个问题而制定的,缓存一致性协议定义了cpu访问缓存时必须遵守的协议,不同类型的CPU系统可能会使用不一样的协议。需要指出的是,这些协议不是消除所有不一致场景,如果那样的话,就和直接访问内存没有什么区别,这些协议都会定义有限的一致性规则,CPU访问缓存时,只需要保证协议规定的一致性规则即可。

这些协议制定时,都会针对cpu访问缓存场景做特定分析,协议不会定义得很严,以免cpu被拖太多后腿,也不会定义得太松,以免应用层不好实现一致性需求。这里应用层是指软件实现。

我们再来看看乱序执行,这时一种CPU运行态优化技术,CPU内部会有很多运算单元,比如整型运算单元,还有浮点运算单元等。CPU可以根据指令运行过程中的具体情形,在不影响最终运行结果的情况下,调整指令执行顺序,这种优化技术可以使CPU内部运算单元尽量被充分利用到,从而提高运行效率。

这里值得注意的地方是,CPU会智能的判断什么情况下乱序执行不会影响到最终结果,但这里,CPU只能以串行化为基准进行判断,无法顾忌多路并行的情况。也就是说,一个内核只能在这个内核内部进行乱序执行优化,对于其他内核则处于一个不可见状态。

大家想一下,这样的话,对并行计算不是在添乱吗?对,在这里,无利不早起定律又一次发挥了决定性作用,根据Intel的权威测试,针对同一个CPU,禁止乱序执行时比开启乱序执行时,性能降低了约30%。这个已经说明了一切,好了,我们再回到Java上来看看。

 

Java内存模型(JMM)

  • 主内存
  • 工作内存
  • 指令重排序
  • 八种原子操作
  • Happens-before原则(先行发生原则)

首先需要解释一下什么是Java内存模型,JVM规范中试图定义一种Java内存模型用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的并发效果。

恩,这个做法和Java的许多跨平台特性类似,想想,如果没有这层JMM做得屏蔽,让Java开发人员直接面对底层硬件平台的各种稀奇古怪的内存系统和缓存一致性协议,那将是非常可怕的。

幸运的是在Java中,只需要面对一种,并且是相当简单的(相对于底层平台而言),那些复杂的多平台适配,就留给那些可怜的JVM提供商吧。我有时候真觉得SUN的JVM应该拿出来卖点钱才对,错了,现在是oracle了。

这里插一个话题,C++中一定也存在类似的问题,那么C++在并发时,是怎么去解决内存一致性这些问题的呢?这将是另外一个有趣的话题。

好了,我们还是回到主题,来看下JMM都定义了些什么,我们一个个来看。

首先,主内存,这个是全进程共享的,其次,工作内存的定义为每条线程所独享的,即私有的,不同线程的工作内存相互独立,并不可以相互访问。

怎么样,这个定义是否有点似曾相识,是不是有点像堆和栈的定义。请各位千万不要把这两个东西混为一谈,这两种东西都是在聊内存相关的问题,但不是在同一个层次上聊。所以不会故意存在相关性,即使有,也没人在意这种相关性。

好了,我们接着看规定,刚才的还没完,下一个规定是线程不能直接访问主内存,所有对主内存的访问都只能间接通过工作内存进行。怎么样,又有点似曾相识吧,这个和刚才聊的CPU的缓存系统很像吧。

其实这里是存在相关性的,实际上,JMM就是为了抽象描述CPU和缓存系统以及内存之间的访问关系的。所以如果刚才的那套CPU缓存系统如果你大致搞懂了,那么这里的理解也就简单了。

主内存可以直接类比内存,工作内存可以直接类比缓存,同样,指令重排序也是可以类比乱序执行优化。下面,来看看为了支持JMM,JVM规范设计的8种原子指令。

 

八种原子操作

  • 锁定(lock)
  • 解锁(unlock)
  • 读取(read)
  • 载入(load)
  • 使用(use)
  • 赋值(assign)
  • 存储(store)
  • 写入(write)

这8个指令的用处我们简单介绍一下,lock和unlock只作用于主内存,用于对内存数据的加锁解锁,主要可以用来支持Java语法中同步的实现。

Read,load,use这3个指令用于读取数据,read作用于主内存,读取一个值,load作用于工作内存,把read读到的值写入工作内存,use作用于工作内存,从工作内存中读取一个值提交给执行引擎。

主内存中的任何一个变量,要进入到CPU的执行内核都要进行这样一个操作流程。注意,这些指令都是原子操作,这里的潜台词是read和load这两个操作放在一起并不是原子的。也就是说,这两个指令之间会有可能插入其他指令。

另外,JMM还规定read和load必须成对出现,这个规定还是好理解的,因为这两个指令本来就是合起来干一件事情的,把一个变量从主内存搬到工作内存,按理说,把这两个指令合成一个指令形成原子操作更合理。JMM没有这么定义我想应该是和硬件特性相关的。

最后一个需要注意的地方是,use和read,load之间并没有要求要一起出现,即可以read,load只出现一次,而use出现多次。这很好理解,cpu可以多次访问同一个缓存值,而不是每次访问缓存时都从主内存再捞一下最新的数据。当然,这个也不是一定的,后面会介绍一个东西打破这个规定。

读数据介绍完了,写数据的过程就是读的逆过程,这里就不在复述了。

最后来看一个直观的。

 

好,了解了这8个指令,让我们再次来看一下开始的那个问题。

 

再次来面对同一个问题

存在多个线程中访问变量x,其中某个线程有如下语句:

         x = 3;

问题:在什么条件下,其他线程读取x时,能看到x值为3 ?

现在大家能根据刚才讲的东西,回答这个问题吗?

恩,怎么样?还是无法解答,是吧,不过,我们现在虽然还不能回答这个问题,但相信大家思想上都应该有这样一个转变,第一,这个问题并没有想象中那么简单,第二,这个问题的答案肯定不是任何时候都可以观察到。

很好,我们刚才已经了解了主内存,工作内存的划分,也了解了这个划分所带来的8个原子指令,但我们还是无法回答这个问题,因为我们缺少了一个东西,下面我们来介绍一下缺的这个东西。

 

Happens-before原则(先行发生原则)

  1. 程序次序法则,在线程内,如果A一定在B之前发生,则happen before。
  2. 监视器法则,对一个监视器的解锁一定发生在后续对同一监视器加锁之前。
  3. Volatile变量法则:写volatile变量一定发生在后续对它的读之前。
  4. 线程启动法则:Thread.start一定发生在线程中的动作之前。
  5. 线程终结法则:线程中的任何动作一定发生在括号中的动作之前(其他线程检测到这个线程已经终止,从Thread.join调用成功返回,Thread.isAlive()返回false)。
  6. 中断法则:一个线程调用另一个线程的interrupt一定发生在另一线程发现中断之前。
  7. 终结法则:一个对象的构造函数结束一定发生在对象的finalizer之前。
  8. 传递性:A发生在B之前,B发生在C之前,A一定发生在C之前。

happens-before,也叫先行发生原则,好像比较高级的样子,看起来也比较形式话,简单的说,它就是java版的缓存一致性协议,其重要性不言而喻,它的地位相当于是java内存一致性领域的宪法,在java内存一致性上遇到的所有问题,最终都需要通过它来进行裁决。缺了它,像刚才那种问题是没有办法进行判断分析的,更谈不上回答了。

Ok,宪法有了,那法官在哪?没错,就是你和各位Java开发者。

好了,我们现在来介绍一下这个规则,以及如何使用。

首先,JMM为程序中的所有操作定义了一个偏序关系,称之为Happens-before。简单的说,要想保证执行操作A能够看到操作B所产生的结果,注意,这里是不区分线程内还是线程外的操作。那么操作A和操作B之间必须满足Happens-before关系。

如果不满足,那么JVM可以对这两个操作任意重排序,换句话说,只要不满足Happens-before,就无法判断AB操作谁先谁后,就更不能保证操作A能看到操作B所产生的结果了。

注意一下,这里说的操作是一个很笼统的概念,操作可以是一条指令,也可以使多条,一个操作也可以包含多个操作。

Happens-before的每一条规则都涉及多个操作,在描述先后关系时,指的是操作间的先后关系,对于操作内的先后无保证。对于这点,其实并不影响Happens-before的使用,必要时,我们可以利用Happens-before的传递性来进一步搞清楚操作内的先后顺序。这个过程有点像是是在进行递归。这个后面再进一步了解。

好了,我们先来看看第一个规则,程序次序法则,这个法则是最基础的,也是最容易理解和使用的,即在同一个线程中,A操作在B操作前面(源代码的方法中的语句顺序),则A操作一定发生在B操作之前。B操作自然看得到A操作所产生的结果。这是必须的,否则还编个屁的程序呀。

再来看下监视器法则,这个法则规定了同步时的顺序保证,这里注意一下,上一个法则是线程内的,这一个法则和后面提到的都是描述线程间的。

再来看看volatile变量法则,这个东西值得多讲一下。

volatile修饰符可以用于成员变量的定义,不管是static还是非static的。这个修饰符的作用可能很多人都搞不清楚,即使是写了很多年java代码的人也是这样。

其实volatile的作用就如同这个法则的解释一样,我也是理解了这个法则后才算真正明白了volatile的用法。

这个法则只说明了一个顺序,写volatile变量发生在读之前,它表明了,只要写了volatile变量,任何其它线程读这个volatile变量时都可以实时的取到最新的写入值。

不好懂,好吧,我们进一步解释深入一点,还记的read load use吧,当时说可以read load出现一次,而use可以出现多次,这两组指令可以没有因果关系,但最后我又说,这个也不是恒定的,后面会介绍一个东西打破这个规则,这个东西就是volatile修饰符。

volatile修饰的变量要求read load use要做为一个整体出现。这也就表示,使用一个volatile变量时,都会触发从主内存重新捞一下这个变量的值到工作内存中,写过程也是一下,只是方向相反而已。

虽然还有另一种说法,说把一个变量定义为volatile后,那么这个变量的访问将直接到主内存进行,不会经过工作内存。虽然这个说法也可以解释volatile的行为。但经过我查证,不存在这码事,JVM也不太可能专门为volatile打破JVM工作内存和主内存的规则。更不可能专门为此事定义一个直接读写主内存的指令。

好了,经过前3个规则,后面的规则就基本比较好理解了,我就不一一叙述了,不熟悉的同学需要反复理解一下。

注意:这里最后的传递性这一项是很重要,前面说的所有规则,都可以借由传递而形成无数种扩展规则,用于解释各种各样的操作顺序问题。这里举个最常见的例子,同步。

针对同一个锁,假设操作A在解锁前,操作B在加锁后,现在是没有一条单独的规则可以用来判断AB执行顺序的,但我们可以根据规则1,规则2并通过传递性来确定操作A先于操作B之前发生。

首先,操作A和解锁在同一个线程,根据规则1,操作A要先于解锁,再根据规则2,解锁要先用加锁,所以操作A先用加锁,这时传递性,后面呢,加锁又先于操作B,接着传递,最后得出操作A先于操作B。这就是传递性的应用。

这个例子也说明,同步是解决内存可见性上最常见的一种手段。我们目前讲的都是并发底层原理,后续我们讲并发高层机制时,会有很多机会利用happens-before判断并发可见性问题的。

 

Java内存模型(JMM)总结

JMM对Java开发人员是具有指导意义的,其中最为重要的是Happens-before原则。它对于Java开发人员判断内存可见性问题是权威的解释。

如果从可见性角度来理解,可以把JMM的Happens-before原则类比为硬件中的缓存一致性协议。

 

练习

了解了JMM后,我们用happens-before来做一个简单的练习,巩固一下成果。说是练习,其实也是一个很常用的东西,大家一定都玩过。跟着一步步来,很简单的。

 

单例模式

private static Resource res = new Resource();

public static Resource getResouce() {
     return res;
}

上面这个是最简单的单例模式,很多人应该都用过吧,getResouce方法能在任何线程中调用,结果也正确。但这个单例有个问题,成员属性res的初始化是放在构造过程中的,如果成员属性res的初始化是个重量级动作,非常耗时,那么会影响整个对象的初始化效率,进而有可能影响整个软件的启动时间。另外,也有可能res对象占用内存较多。假使getResouce方法在运行过程中并没有地方调用,那么前面的开销都浪费了,即使有调用,那么调用前的那段时间内,前面的开销也是浪费了。不管怎么样,我们都有很多理由要使用延迟初始化技术。这种技术,或者叫这种思想被广泛使用与软件领域,需要用的时候,再去向系统申请你要的资源是一个很好的开发习惯。

很好,下面我们来继续改进单例模式,采用延迟初始化单例。

 

延迟初始化

private static Resource res = null;

public static Resource getResouce() {
     if (res == null) {
           res = new Resource()
     }

     return res;
}

很常见的一种延迟初始化方法,不过有个问题,这样做线程安全吗?通过我们上面讲的,显然是线程不安全的。那么怎么办呢?最常见的是下面的一种方法。 

 

同步的延迟初始化

private static Resource res = null;

public static synchronized Resource getResouce() {
     if (res == null) {
          res = new Resource();
     }

     return res;
}

很好,加上synchronized后就完全线程安全的,但又会引入一个问题:这样做不划算,为什么?同步加锁的开销在第一次调用getResouce方法时是起到作用的,也就是值了,第一次以后的每次调用同步加锁都是没有必要的,也就是不值,不要小看同步加锁的开销,在这种单例方法中,同步的开销往往要大于方法调用本身的系统开销。那怎么办呢?我们继续优化。

 

双重检查加锁模式(DCL)

 1 private static Resource res = null;
 2 
 3 public static Resource getResouce() {
 4      if (res == null) {
 5           synchronized (Xxxxx.class) {
 6                if (res == null) {
 7                     res = new Resource();
 8                }
 9           }
10      }
11 
12      return res;
13 }

 

这样处理就看起来好多了,不是吗?如果你对前面讲的JMM已经完全掌握的话可能已经看出来了,这里存在内存一致性问题,所以这样处理会带来线程安全问题。实际上,双重检查加锁模式(DCL)是一个著名的反模式,也就是说这是一个反面教材。没看懂的同学也不要急,我稍微点拨一下就明白了,这里存在的问题是第4行代码中的res在多线程情况下存在内存可见性问题,因为对于这里的res无法适用于Happens-before法则,也就是说它会破坏res的内存一致性,虽然这里没有写动作。也许有的同学会认为这里只是简单的判空,有和没有的关系,应该不会有什么问题,这样想就错了,这里最坏的情况会是12行中返回的res仅仅是一个初始化了一半的对象,原因很简单,因为对象初始化动作不是原子的,第7行虽然在同步块的保护中,但第4行没有,第7行在乱序执行的优化下完全有可能先完成res的赋值指令,再初始化相应数据,即使没有乱序优化,在工作内存和主内存中的数据不一致也完全有可能引起这个问题。

好了,这个话题就谈到这,欢迎评论。

转载请注明:http://www.cnblogs.com/apollolee/articles/3148507.html,谢谢!

 

posted @ 2013-06-21 21:13  菠萝梨  阅读(169)  评论(0)    收藏  举报