如何设计一个线程安全类
前言
招行卡中心二面问到的面试题,当时答得磕磕绊绊,复盘一下。
问题分析
一个线程安全类的理想效果是,调用者在调用该类时,无需考虑类中变量是否线程共享、操作是否线程安全,不需要进行额外的同步操作。
这就意味着该类在设计时,需要考虑在多线程调用的情况下,哪些变量、哪些对变量的操作会受到影响,把这些元素集合起来,进行同步封装,使得调用者线程可以直接使用,尽可能地减少在多线程环境下产生意料之外结果的可能性。
设计思路
封装共享变量
找出共享、可变的字段
是否共享?
- 访问权限:private、protected、public、default
- 暴露了可供外界读取的接口:get方法
是否可变?
- 基本数据类型还是引用数据类型?
- 引用数据类型的可变性体现在两方面
- 地址改变了;
- 地址指向的变量内容改变了
如何控制一个变量不可变?
- 使用final关键字
用锁来保护访问
找到所有共享、可变的字段之后,需要对所有暴露在外的接口进行同步处理。
注意是所有!并不是只有写操作需要同步,读操作如果没有进行锁定,在多线程环境下有可能读到错误的值从而产生意料之外的结果。
一般,使用对象锁保证多线程对共享可变字段的访问时串行,示例:
1 | public class Test { |
不变性条件
现实中有些字段之间是有实际联系的,比如说下边这个类:
1 | public class SquareGetter { |
这个类提供了一个很简单的getSquare
功能,可以获取指定参数的平方值。但是它的实现过程使用了缓存,就是说如果指定参数和缓存的numberCache
的值一样的话,直接返回缓存的squareCache
,如果不是的话,计算参数的平方,然后把该参数和计算结果分别缓存到numberCache
和squareCache
中。
从上边的描述中我们可以知道,squareCache
不论在任何情况下都是numberCache
平方值,这就是SquareGetter
类的一个不变性条件,如果违背了这个不变性条件的话,就可能会获得错误的结果。
在单线程环境中,getSquare
方法并不会有什么问题,但是在多线程环境中,numberCache
和squareCache
都属于共享的可变字段,而getSquare
方法并没有提供任何同步措施,所以可能造成错误的结果。假设现在numberCache
的值是2,squareCache
的值是3,一个线程调用getSquare(3)
,另一个线程调用getSquare(4)
,这两个线程的一个可能的执行时序是:
两个线程执行过后,最后numberCache
的值是4,而squareCache
的值竟然是9,也就意味着多线程会破坏不变性条件。
为了保持不变性条件,我们需要把保持不变性条件的多个操作定义为一个原子操作,即用锁给保护起来。
我们可以这样修改getSquare
方法的代码:
1 | public synchronized int getSquare(int i) { |
但是不要忘了将代码都放在同步代码块是会造成阻塞的,能不进行同步,就不进行同步,所以我们修改一下上边的代码:
1 | public int getSquare(int i) { |
虽然getSquare
方法同步操作已经做好了,但是别忘了SquareGetter类
的getCache方法
也访问了numberCache
和squareCache
字段,所以对于每个包含多个字段的不变性条件,其中涉及的所有字段都需要被同一个锁来保护,所以我们再修改一下getCache方法
:
1 | public synchronized int[] getCache() { |
这样修改后的SquareGetter类
才属于一个线程安全类。
使用volatile修饰状态
使用锁来保护共享可变字段虽然好,但是开销大。使用volatile
修饰字段来替换掉锁是一种可能的考虑,但是一定要记住volatile
是不能保证一系列操作的原子性的,所以只有我们的业务场景符合下边这两个情况的话,才可以考虑:
- 对变量的写入操作不依赖当前值,或者保证只有单个线程进行更新。
- 该变量不需要和其他共享变量组成不变性条件。
比方说下边的这个类:
1 | public class VolatileDemo { |
VolatileDemo
中的字段i
并不和其他字段组成不变性条件,而且对于可以访问这个字段的方法getI
和setI
来说,并不需要以来i的当前值,所以可以使用volatile
来修饰字段i
,而不用在getI
和setI
的方法上使用锁。
volatile关键字
- 保证变量的可见性,不保证原子性;
- 它不会引起线程上下文的切换和调度,相比于
synchronized
,是一种更轻量级的同步机制。
避免this引用逸出
我们先来看一段代码:
1 | public class ExplicitThisEscape { |
在构造方法中就把this
引用给赋值到了静态变量INSTANCE
中,而别的线程是可以随时访问INSTANCE
的,我们把这种在对象创建完成之前就把this引用赋值给别的线程可以访问的变量的这种情况称为 this引用逸出,这种方式是极其危险的!,这意味着在ThisEscape
对象创建完成之前,别的线程就可以通过访问INSTANCE
来获取到i字段
的信息,也就是说别的线程可能获取到字段i
的值为0
,与我们期望的final
类型字段值不会改变的结果是相违背的。
所以千万不要在对象构造过程中使this引用逸出。
上边的this
引用逸出是通过显式将this
引用赋值的方式导致逸出的,也可能通过内部类的方式神不知鬼不觉的造成this
引用逸出:
1 | public class ImplicitThisEscape { |
虽然在ImplicitThisEscape
的构造方法中并没有显式的将this
引用赋值,但是由于Runnable
内部类的存在,作为外部类的ImplicitThisEscape
,内部类对象可以轻松的获取到外部类的引用,这种情况下也算this引用逸出
。
this引用逸
出意味着创建对象的过程是不安全的,在对象尚未创建好的时候别的线程就可以来访问这个对象。虽然我们不确定客户端程序员会怎么使用这个逸出的this引用,但是风险始终存在,所以强烈建议千万不要在对象构造过程中使this引用逸出。