前言

招行卡中心二面问到的面试题,当时答得磕磕绊绊,复盘一下。

问题分析

一个线程安全类的理想效果是,调用者在调用该类时,无需考虑类中变量是否线程共享、操作是否线程安全,不需要进行额外的同步操作。

这就意味着该类在设计时,需要考虑在多线程调用的情况下,哪些变量、哪些对变量的操作会受到影响,把这些元素集合起来,进行同步封装,使得调用者线程可以直接使用,尽可能地减少在多线程环境下产生意料之外结果的可能性。

设计思路

封装共享变量

找出共享、可变的字段

是否共享?

  • 访问权限:private、protected、public、default
  • 暴露了可供外界读取的接口:get方法

是否可变?

  • 基本数据类型还是引用数据类型?
  • 引用数据类型的可变性体现在两方面
    • 地址改变了;
    • 地址指向的变量内容改变了

如何控制一个变量不可变?

  • 使用final关键字

用锁来保护访问

找到所有共享、可变的字段之后,需要对所有暴露在外的接口进行同步处理

注意是所有!并不是只有写操作需要同步,读操作如果没有进行锁定,在多线程环境下有可能读到错误的值从而产生意料之外的结果。

一般,使用对象锁保证多线程对共享可变字段的访问时串行,示例:

1
2
3
4
5
6
7
8
9
10
11
public class Test {
private int i;

public synchronized int getI() {
return i;
}

public synchronized void setI(int i) {
this.i = i;
}
}

不变性条件

现实中有些字段之间是有实际联系的,比如说下边这个类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class SquareGetter {
private int numberCache; //数字缓存
private int squareCache; //平方值缓存

public int getSquare(int i) {
if (i == numberCache) {
return squareCache;
}
int result = i*i;
numberCache = i;
squareCache = result;
return result;
}

public int[] getCache() {
return new int[] {numberCache, squareCache};
}
}

这个类提供了一个很简单的getSquare功能,可以获取指定参数的平方值。但是它的实现过程使用了缓存,就是说如果指定参数和缓存的numberCache的值一样的话,直接返回缓存的squareCache,如果不是的话,计算参数的平方,然后把该参数和计算结果分别缓存到numberCachesquareCache中。

从上边的描述中我们可以知道,squareCache不论在任何情况下都是numberCache平方值,这就是SquareGetter类的一个不变性条件,如果违背了这个不变性条件的话,就可能会获得错误的结果。

在单线程环境中,getSquare方法并不会有什么问题,但是在多线程环境中,numberCachesquareCache都属于共享的可变字段,而getSquare方法并没有提供任何同步措施,所以可能造成错误的结果。假设现在numberCache的值是2,squareCache的值是3,一个线程调用getSquare(3),另一个线程调用getSquare(4),这两个线程的一个可能的执行时序是:

创建一个线程安全的类.drawio

两个线程执行过后,最后numberCache的值是4,而squareCache的值竟然是9,也就意味着多线程会破坏不变性条件

为了保持不变性条件,我们需要把保持不变性条件的多个操作定义为一个原子操作,即用锁给保护起来

我们可以这样修改getSquare方法的代码:

1
2
3
4
5
6
7
8
9
public synchronized int getSquare(int i) {
if (i == numberCache) {
return squareCache;
}
int result = i*i;
numberCache = i;
squareCache = result;
return result;
}

但是不要忘了将代码都放在同步代码块是会造成阻塞的,能不进行同步,就不进行同步,所以我们修改一下上边的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public int getSquare(int i) {

synchronized(this) {
if (i == numberCache) { // numberCache字段的读取需要进行同步
return squareCache;
}
}

int result = i*i; //计算过程不需要同步

synchronized(this) { // numberCache和squareCache字段的写入需要进行同步
numberCache = i;
squareCache = result;
}
return result;
}

虽然getSquare方法同步操作已经做好了,但是别忘了SquareGetter类getCache方法也访问了numberCachesquareCache字段,所以对于每个包含多个字段的不变性条件,其中涉及的所有字段都需要被同一个锁来保护,所以我们再修改一下getCache方法

1
2
3
public synchronized int[] getCache() {
return new int[] {numberCache, squareCache};
}

这样修改后的SquareGetter类才属于一个线程安全类。

使用volatile修饰状态

使用锁来保护共享可变字段虽然好,但是开销大。使用volatile修饰字段来替换掉锁是一种可能的考虑,但是一定要记住volatile是不能保证一系列操作的原子性的,所以只有我们的业务场景符合下边这两个情况的话,才可以考虑:

  • 对变量的写入操作不依赖当前值,或者保证只有单个线程进行更新。
  • 该变量不需要和其他共享变量组成不变性条件。

比方说下边的这个类:

1
2
3
4
5
6
7
8
9
10
11
12
public class VolatileDemo {

private volatile int i;

public int getI() {
return i;
}

public void setI(int i) {
this.i = i;
}
}

VolatileDemo中的字段i并不和其他字段组成不变性条件,而且对于可以访问这个字段的方法getIsetI来说,并不需要以来i的当前值,所以可以使用volatile来修饰字段i,而不用在getIsetI的方法上使用锁。

volatile关键字

  • 保证变量的可见性,不保证原子性;
  • 它不会引起线程上下文的切换和调度,相比于synchronized,是一种更轻量级的同步机制。

避免this引用逸出

我们先来看一段代码:

1
2
3
4
5
6
7
8
9
10
11
public class ExplicitThisEscape {

private final int i;

public static ThisEscape INSTANCE;

public ThisEscape() {
INSTANCE = this;
i = 1;
}
}

在构造方法中就把this引用给赋值到了静态变量INSTANCE中,而别的线程是可以随时访问INSTANCE的,我们把这种在对象创建完成之前就把this引用赋值给别的线程可以访问的变量的这种情况称为 this引用逸出,这种方式是极其危险的!,这意味着在ThisEscape对象创建完成之前,别的线程就可以通过访问INSTANCE来获取到i字段的信息,也就是说别的线程可能获取到字段i的值为0,与我们期望的final类型字段值不会改变的结果是相违背的。

所以千万不要在对象构造过程中使this引用逸出

上边的this引用逸出是通过显式将this引用赋值的方式导致逸出的,也可能通过内部类的方式神不知鬼不觉的造成this引用逸出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class ImplicitThisEscape {

private final int i;

private Thread t;

public ThisEscape() {
t = new Thread(new Runnable() {
@Override
public void run() {
// ... 具体的任务
}
});
i = 1;
}
}

虽然在ImplicitThisEscape的构造方法中并没有显式的将this引用赋值,但是由于Runnable内部类的存在,作为外部类的ImplicitThisEscape,内部类对象可以轻松的获取到外部类的引用,这种情况下也算this引用逸出

this引用逸出意味着创建对象的过程是不安全的,在对象尚未创建好的时候别的线程就可以来访问这个对象。虽然我们不确定客户端程序员会怎么使用这个逸出的this引用,但是风险始终存在,所以强烈建议千万不要在对象构造过程中使this引用逸出

参考链接