1 什么是线程安全?
一段程序在多线程环境下始终能表现出正确的行为和计算结果就是线程安全的。
程序包括输入、运算、输出三个阶段,一段有意义的程序通常可以归类为:只有输入-运算,只有运算-输出,输入-运算-输出。计算的中间结果会保存在内存中,中间结果是上一个阶段的输出,也是下一个阶段的输入。输入值是“观察”所得,运算是“执行”过程。
线程安全问题的产生本质上是多线程环境下“先观察后执行”操作(先输入后运算),发生错误的点位于“观察”、“观察——执行期间”两个点。“观察”时存在可见性问题,“观察——执行期间”存在原子性问题。
可见性问题:应该被观察到的没有观察到,不应该观察到的被观察到。分别对应数据失效(读到的是旧的值,其他线程有新值在工作空间还没来得及更新到主内存)、对象发布逸出(对象未完成创建就被其他对象持有并使用)
原子性问题:一个操作的中间数据因其他线程的交叉执行而中途受到干扰就会产生原子性问题,交叉点就在共享数据。如果这个操作如同原子无法被继续拆分(物理上不准确),那么交叉执行现象就不可能存在,此时的多线程是平行的互不干扰。
图1 观察点到开始执行都是并发问题产生的位置
对于只有一个“观察——执行”阶段的程序来说,只要保证了观察点的观察结果正确,那么这段程序一定是线程安全的(可见性)。一个操作通常有多次“观察——执行”阶段,上述逻辑推广到多阶段操作,只要在可见性得到保证的基础上保证输入到执行结束期间的中间数据不被干扰,那么也是线程安全的(原子性)。
可见,线程安全始终围绕观察点——共享变量来讨论。
2 怎样保证线程安全?
保证可见性的手段:volatile关键字可以通过阻止指令重排序保障可见性,synchronized通过加锁读最新值、解锁更新最新值到主内来存保障可见性,不可变对象保证可见性,如final关键字一定程度保证对象不可变(final关键字修饰的变量的指向不能改变,但指向的对象的内容可变)。
保证原子性的手段:使用concurrent.atomic包下的原子运算类保证原子性,使用synchronized、各种锁以串行方式通过保证原子性。
注意:可见性和原子性同时得到保证才能保证线程安全。这就是为什么不能用volatile关键字保证线程安全的原因——它只能保证可见性,无法保证原子性。
终极手段——不共享变量:可见性和原子性问题不复存在,但是通常只能在小范围做到。Ad-hoc线程封闭(不借助语言特性,用编程方式实现),栈封闭(方法调用是在栈帧里,天然就是栈封闭),ThreadLocal类(各个线程拷贝一份仅供自己使用的变量)。
线程不安全的坑:我们以为是原子操作但实际不是的操作:i++,i--,64位long/double读写;对象不正确发布(this泄漏)
一个号称线程安全的类在不恰当的使用方式下也是线程不安全的,这是因为使用者的非原子操作。(《深入理解Java虚拟机》P388)
一个对象被怎样蹂躏是很难预料到的,我们不能决定别人如何看待我们,唯有做好自己。这就是线程安全的类的设计理念。
线程安全的类根据线程安全的强弱程度被分为5种:不可变、绝对线程安全(例如只有一个同步方法的类,或者只读的类)、相对线程安全(该类每个方法独立来看都是线程安全的)、线程兼容(调用者额外进行同步也是线程安全的,但类本身线程不安全)、线程独立(对立的矛盾)。
绝大部分线程安全类都是相对线程安全的。
3 线程安全的类怎样设计?
设计成前3种,但大部分时候我们只能做好自己。
不可变对象作为一种特殊的线程安全类,它一定是线程安全的:①对象创建后状态就不能被修改,②对象的所有属性都是final修饰的,③对象是正确创建的(没有this引用逸出)。
找出对象的所有变量,找出原子操作的需求,综合运用可见性、原子性、不共享变量三类策略。
这里的变量包括变量的变量,变量范围越小越容易找出不变性条件,如果变量全是基本数据类型则变量范围很小,否则例如包括hashMap则范围很大。
以上是策略,以下是技巧:
①实例封闭和线程安全性委托:A类是B类的一个变量(属性),如果A类不安全,B类对A类进行了安全性同步就是实例封闭(加壳),反过来,如果A类线程安全,B类的方法直接调用A类的方法就属于线程安全性委托(代理)。这两种情况下B类都是线程安全的。
②扩展线程安全的类。
最后,做好类的说明文档。