引言
对于大多数人而言,并发亦近矣,亦远矣。
如果你问一个程序猿,“你知道并发吗?”。
估计不少人会说,“恩,知道个大概吧!”。
如果此时你再继续追问下去,可能得到的仍然会是一些千篇一律的答案。比如,“并发应该就是多个线程一起运行”、“并发的时候应该加锁,加synchronized关键字”,“并发的时候采用时间片轮询的方式”等等诸如此类的答案。
其实大多数人都是知道并发的,但却大部分是一知半解,远是因为大部分人还都只停留在初级阶段,包括现在刚入门的LZ本人。如果写一个简单的并发程序,大部分猿友们估计都能胜任,不过若是稍微复杂一点的,可能就会出现很多问题,或者自以为没有问题。
线程安全
线程安全这个词汇实在是折磨人,它给人一种错觉,让你仿佛很轻松的理解了它,但实则是一个典型的笑面虎,背后冷不丁就给你一刀,让你血溅职场。
我们先来看下这个词语组成的词汇都有哪些,首先后面可以加一“性”字,此为线程安全性。另外,如果后面加“类”或者“程序”,就组成了线程安全类或者是线程安全程序。很显然,线程安全性是类和程序的属性,就像一个类或者程序的其它属性一样,例如扩展性、维护性等等。
到现在重点就出来了,到底什么是线程安全性?从字面上看,线程安全性就是一个类或者程序在多线程的环境中运行是安全的。可是这显然是废话,重点还是落在了安全性上面。怎么才能称作是安全的?
LZ这里先贴出一个比较官方的解释,接下来再和各位猿友侃侃大山。安全性是指,某个类的行为与其规范完全一致。那么我们现在就可以将整句话连起来了,也就是说,线程安全性就是指,一个类或者程序在多线程的环境下,其行为与规范完全一致的特性。
有的猿友可能会说,“我们开发从来都没有规范的,OK?既然如此,何来与规范一致一说?”。是的,只是如果哪位猿友心里冒出这么一句话的话,说明你对这里的“规范”两字理解错误了,这里的规范可不是指的编码规范。LZ举个简单的例子来说明,这个规范的意思是什么。
看一下上面这个类,它表示一个整数区间,对于一个区间来讲,我们自然而然的有一些规则,比如区间左边的值必须小于或者等于右边的值。在上面的类当中,我们也在很多地方限制着客户端的输入,试图保持这种规则(但是在多线程环境下,我们这种约束将显得非常薄弱)。
我们说这种规则就是上面提到的规范,也就是说对于Region类来说,始终保持它是一个有效的区间,就是它的规范。因此对于Region类来说,它的线程安全性就是指它可以在多线程的环境下保持它是一个有效的区间(left小于等于right)。对于Region是一个有效的区间这件事来说,其实就相当于在说in方法不能永久返回false。如果我们更加抽象点来说,就是说方法的行为应该与预期的一致。
由此我们可以看出,一个类或程序的规范,就是指它能够始终保持一定的约束条件。比如一个应用类的stop方法,在客户端调用后,必须能够保证应用被正确关闭等等,这些方法的使用说明其实就是一种规范。
线程安全类
安全性如果被满足,它就是一个线程安全的类。有一些可描述的规律
1、无状态的对象一定是线程安全的。
这一条规律实在是太有用了,很多时候,我们的代码处于多线程的环境下,而我们往往苦恼于这些代码的安全性。此时,如果你的类是无状态的,那么你就可以高枕无忧的在多线程环境下使用它。
为什么说无状态的对象一定是线程安全的?
一个对象如果没有状态,则意味着对象不存在运行时状态的改变,因此无论是单线程还是多线程的情况下,都不会使对象处于不正确的状态。大多数时候,无状态的对象就是一堆代码的持有者而已,它每一个方法的变量都封闭在独立的线程当中,线程相互之间无法共享变量,因此它们也无法互相影响各自的行为。因此,在多线程的环境下,我们首先推荐的就是无状态对象。
下例就是一个无状态对象,它没有任何域,自然也就没有状态。
2、不可变对象一定是线程安全的。
比如String就是典型的不可变对象。不可变对象的不可变性与无状态的对象非常相似,只是无状态对象通过不添加任何状态保持对象在运行时状态的不可变性,而不可变对象则通常通过final域来强制达到这一特性,不过要注意的是,如果final域指向的是可变对象,则该对象依然可能是可变的。
比如一个List的包装类,如果提供了对List的操作,那么既然内部的List是final类型的,该对象依然是可变的,我们看下面的例子。
这个类其实有时候是有用的,尽管它很简单,但是它可以弥补JDK1.5加入泛型的弊病,比如remove方法的参数是Object。但是很可惜,它唯一的域是final类型的,但却不是不可变的。因为我们提供了add和remove方法,这些方法依然可以改变这个类的状态,因为list的状态就是它的状态。倘若我们在构造函数中加入一些初始化的元素,并且去掉add和remove方法,那么尽管该类引用了可变的非线程安全的类,但它依然是不可变的,也就是说依然是线程安全的。
3、除了以上两种对象,我们通常都需要使用加锁机制来保证对象的线程安全性。
这一条基本上道出了大部分的情况,很多时候,我们无法将一个可能处于多线程环境的对象设计成以上两种,这时就需要我们进行合适的加锁机制来保证它的线程安全性。通常情况下,我们希望一个对象是无状态的或者不可变的,这可以大大降低程序的复杂性,请尽量这么做。
加锁机制(何时加锁)
保证类的线程安全性,就是两件事,第一件是何时加锁,第二件是如何加锁。
关于何时加锁这个问题,我们主要关注以下几点来决定,这些内容都是并发的精髓。
1、原子性
原子性,个操作要么就做完,要么就没开,不存在做了一部分的情况.这个简单的理解其实有一个重大漏洞,那就是这个操作是针对什么层次来说的,这将直接影响我们的判断。比如下面这个被用的烂透了的例子,万年的自增。
i++;
从编程语言的层次来讲,它是一个原子操作,因为它只有一句代码,如果你去调试这行代码,它一定无法执行一半或一部分。但是如果从汇编语言的层次来讲,它就不是一个原子操作,因为它有好几条指令(看过计算机原理系列的猿友应该非常清楚),既然有好几条指令,那么就意味着i++这个操作在汇编层次,可以存在做了一部分的情况。
对于原子性的层次定义,一般应该以CPU提供的指令集为准,至少我们认为,一个指令是无法拆分的操作。从这个角度来看,我们Java当中大部分看似原子性的操作,其实都不是原子操作,比如刚才提到的自增、赋值操作等等。如果在并发环境中,一个操作无法保证其原子性,可能就需要进行加锁操作。
1.1、竞态条件
原子性密切相关的概念——竞态条件: 操作的正确性取决于多线程之间指令执行的顺序。
看了上面的定义,大部分猿友估计会唏嘘不已,因为多线程之间指令执行的顺序完全是不定的。如果我们考虑一个多线程程序可能的指令执行顺序,或许会得到10种、100种甚至更多种可能,而我们的程序可能在其中几种情况下执行是正确的,也就是说,我们的程序正确的概率可能为1/10、1/100甚至1/1000000。
惊呆了,这是中彩票的概率吧?
我们可以这么去想,当你中了500万的彩票时,你的程序或许就能正确执行了。程序的正确性完全取决于“运气”,这就是典型的竞态条件。比如下面这个更典型的单例模式当中经常出现的方式。
instance是否为单例,取决于指令执行的顺序。举一个极端的例子,假设10个线程同时运行这个方法,如果这10个线程每一个都判断完instance是否为null之后挂起,那这10个线程在再次被唤醒时都将会去执行new的操作,我们假设每个线程的new和return操作都会一起执行完,然后才把CPU让给其它线程。最终的结果会是,这10个线程得到了10个不一样的实例。各位猿友可以执行一下下面这个简单的测试程序,它将开启100个线程同时执行getInstance方法。
以上的测试共执行1万次,这是为了加大出错几率。基本上,你总能看到以下这样的输出。
{[SingletonObject@16930e2][SingletonObject@7259da]}
这说明在一次测试中,生成了两个SingletonObject对象(可能会有更多,LZ运行了一小会就见到一次14个的)。可以看出,并不是这10000次测试都会出错,相对来说,出错的概率还是非常小的。这正是竞态条件的发生形式,在一定的指令执行序列下,程序就会出错,比如单例模式实际上变成了非单例的情况。
1.2、复合操作
顾名思义,非原子性的操作,两者具有互斥性,也就是说,一个操作要么属于原子操作,要么属于复合操作。上面的if块就是一个典型的复合操作,根据某一个变量的值,决定下一步的行为。通常情况下,使用同步关键字(synchronized)可以使得复合操作变成原子操作,但我们往往更推荐使用现有的类库去实现原子性。
比如一个并发的计数器,就可以写成如下形式。
线程安全类来实现一个并发计数器,这省去了我们很多工作,比如自增并返回、递减并返回这些复合操作(实际上AtomicInteger提供了很多常用的复合操作,并保证原子性)。这样做的好处是,不容易出错,性能可能更高(比如ConcurrentHashMap),分析起来更简单。实际上,我们包装了一个线程安全的类,使之成为了另外一个线程安全的类。
2、可见性
可见性这玩意实在是太奇葩了,以至于亮瞎了LZ的一双氪金人眼。为了把可见性写的更神秘一点,LZ先给出一个简单的例子。
Integer是不可变对象,乍一看好像是线程安全的,很抱歉,这个类依然不是线程安全的。可见性不能保证,因此在多线程环境下,如果一个线程设置了value的值为100,那么另外一个线程或许会看不到100这个值。
为何会这样呢?
回想一下计算机原理当中的内容,曾经无数次的接触过寄存器与存储器,在汇编级别的代码当中,我们会发现,很多变量的赋值是不会反应到存储器当中的,它们有时候一直存在于寄存器当中。这样一来,可见性就好解释了,有时候一个线程A去读取一个变量,这时候它会瞄准存储器的某一个位置进行读取操作,它或许会期待另外一个线程B去改变存储器的值,但事实往往是,另外一个线程B只是把值隐藏在了寄存器,而导致线程A永远看不到这个更新后的值。
还有另外一种情况是,编译器会将现有的程序进行乱序重组,或许表面看起来,我们是先给一个变量赋值,然后又在另外一个线程去读取它,但事实可能是我们先去读取了这个变量,然后才进行的赋值。
不管是哪种情况,一旦牵扯到可见性,就说明程序的行为是不可预见的。换句话说,我们的程序如果想要正确的运行,和中彩票是一个概念,需要一定的概率才能发生,这当然是我们不能容忍的。
因此,我们必须保证一个对象的可见性,否则在共享一个对象时,就会非常的危险。对于上面这个简单的整数类,我们只要给get/set方法加上synchronized关键字,就可以保证它的可见性。这是由于synchronized关键字不仅保证了同步机制,更重要的是禁用了乱序重组以及保证了值对存储器的写入,这样就可以保证可见性。
加锁机制(如何加锁)
上面主要回答了各位我们应该在何时加锁,看似很复杂,但其实更难的还是在如何加锁的问题上。因为如果不考虑简单性或者性能等一些问题,给一个类的全部方法加上synchronized关键字就可以确保这个类的线程安全性。但是很显然,这种做法很多时候是不可取的,除非你想收到上级的“夸奖”。
如果一个多线程环境下的类无法做成无状态或者是不可变对象,那么我们就只能尝试去做一些同步机制,来保证它的线程安全性,或者说保证它可以正常工作。这个问题很难一概而论,不过在绝大多数情况下,我们秉持这样一个原则去进行同步,那就是总是用同一个锁去保护需要协变的状态。
这一句话显然无法概括所有加锁的情况,但是却是LZ个人感觉能解决大部分问题的方法。接下来LZ就举一个简单的例子,比如上面的区间类,它当中就有一些明显的协变状态(协变状态是LZ个人起的名字,意思是想指那些需要相互协助变化的状态)。我们接下来就尝试将上面的区间类变成线程安全的类。
方法非常简单,我们只是简单的给三个方法加上了synchronized关键字,但不可否认的是,它现在已经是一个线程安全的类(我们对toString的显示要求不高,因此不进行同步)。这个类当中很显然left和right变量是一组协变状态,它们两个之间需要相互协助的变化,而不可以单独进行改变。
其实在现实当中,这样的协变状态有很多。比如我们常用的ArrayList,它当中就有一个Object数组和一个size标识,这两个状态很明显是需要协变的,一旦object数组有所变化,size就要跟随着变化,这样的话在多线程当中使用时,就需要将二者使用同一个锁进行同步(一般情况下,我们会使用当前对象充当这个锁,即this关键字)。
如果一个方法当中,并不全是协变状态,我们就可以进行局部同步(使用synchronized同步块),这样就可以减少性能的损失,但也要保证一定的简单性,否则的话,这段程序维护起来会非常头疼。
这里我们为了尽可能的保证程序的性能,所以使用了同步块,在进行输出语句的调用时,并不会将当前对象锁定。众所周知,JAVA在I/O方面的处理是比较慢的,因此在同步的语句当中,我们应当尽量的将I/O语句移出同步块(当然还包括其它的一些处理较慢的语句)。
这里LZ再举一个非常常见的例子,就是对于循环一个列表的处理,以下这段代码节选自JDK1.6当中Observable类(观察者模式当中的被观察者父类)。
可以看到,这个方法的任务是通知所有的观察者,也就是说,需要循环obs这个list列表,并挨个调用update方法。但是这里并没有直接循环obs这个列表,而是使用了一个临时变量arrLocal,并获取到obs的一个快照(snapshot)进行循环。这就是为了保证同步的情况下,尽量的提高性能,因为update方法当中可能会有一些很占用时间的操作,这样的话,如果我们直接对obs循环期间进行同步,那么就可能会导致被观察者被锁定相当长的一段时间。