Java并发编程之设计线程安全的类

3次阅读

共计 6627 个字符,预计需要花费 17 分钟才能阅读完成。

设计线程安全的类
前边我们对线程安全性的分析都停留在一两个可变共享变量的基础上,真实并发程序中可变共享变量会非常多,在出现安全性问题的时候很难准确定位是哪块儿出了问题,而且修复问题的难度也会随着程序规模的扩大而提升 (因为在程序的各个位置都可以随便使用可变共享变量,每个操作都可能导致安全性问题的发生)。比方说我们设计了一个这样的类:
public class Increment {
private int i;

public void increase() {
i++;
}

public int getI() {
return i;
}
}
然后有很多客户端程序员在多线程环境下都使用到了这个类,有的程序员很聪明,他在调用 increase 方法时使用了适当的同步操作:
public class RightUsageOfIncrement {

public static void main(String[] args) {
Increment increment = new Increment();

Thread[] threads = new Thread[20]; // 创建 20 个线程
for (int i = 0; i < threads.length; i++) {
Thread t = new Thread(new Runnable() {

@Override
public void run() {
for (int i = 0; i < 100000; i++) {
synchronized (RightUsageOfIncrement.class) {// 使用 Class 对象加锁
increment.increase();
}
}
}
});
threads[i] = t;
t.start();
}

for (int i = 0; i < threads.length; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

System.out.println(increment.getI());
}
}
在调用 Increment 的 increase 方法的时候,使用 RightUsageOfIncrement.class 这个对象作为锁,有效的对 i ++ 操作进行了同步,的确不错,执行之后的结果是:
2000000
可是并不是每个客户端程序员都会这么聪明,有的客户端程序员压根儿不知道啥叫个同步,所以写成了这样:
public class WrongUsageOfIncrement {

public static void main(String[] args) {
Increment increment = new Increment();

Thread[] threads = new Thread[20]; // 创建 20 个线程
for (int i = 0; i < threads.length; i++) {
Thread t = new Thread(new Runnable() {

@Override
public void run() {
for (int i = 0; i < 100000; i++) {
increment.increase(); // 没有进行有效的同步
}
}
});
threads[i] = t;
t.start();
}

for (int i = 0; i < threads.length; i++) {
try {
threads[i].join();
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
}

System.out.println(increment.getI());
}
}
没有进行有效同步的执行结果是 (每次执行都可能不一样):
1815025
其实对于 Increment 这个类的开发者来说,本质上是把对可变共享变量的必要同步操作转嫁给客户端程序员处理。有的情况下我们希望自己设计的类可以让客户端程序员们不需要使用额外的同步操作就可以放心的在多线程环境下使用,我们就把这种类成为线程安全类。其实就是类库设计者把一些在多线程环境下可能导致安全性问题的操作封装到类里边儿,比如 Increment 的 increase 方法,我们可以写成这样:
public synchronized void increase() {
i++;
}
也就是说把对可变共享变量 i 可能造成多线程安全性问题的 i ++ 操作在 Increment 类内就封装好,其他人直接调用也不会出现安全性问题。使用封装也是无奈之举:你无法控制其他人对你的代码调用,风险始终存在,封装使无意中破坏设计约束条件变得更难。
封装变量访问
找出共享、可变的字段
设计线程安全类的第一步就是要找出所有的字段,这里的字段包括静态变量也包括成员变量,然后再分析这些字段是否是共享并且可变的。
首先辨别一下字段是否是共享的。由于我们无法控制客户端程序员以怎样的方式来使用这个类,所以我们可以通过访问权限,也就是 public 权限、protected 权限、默认权限以及 private 权限来控制哪些代码是可以被客户端程序员调用的,哪些是不可以调用的。一般情况下,我们需要把所有字段都声明为 private 的,把对它们的访问都封装到方法中,对这些方法再进行必要的同步控制,也就是说我们只暴露给客户端程序员一些可以调用的方法来间接的访问到字段,因为如果直接把字段暴露给客户端程序员的话,我们无法控制客户端程序员如何使用该字段,比如他可以随意的在多线程环境下对字段进行累加操作,从而不能保证把所有同步逻辑都封装到类中。所以如果一个字段是可以通过对外暴露的方法访问到,那这个字段就是共享的。
然后再看一下字段是否是可变的。如果该字段的类型是基本数据类型,可以看一下类所有对外暴露的方法中是否有修改该字段值的操作,如果有,那这个字段就是可变的。如果该字段的类型是非基本数据类型的,那这个字段可变就有两层意思了,第一是在对外暴露的方法中有直接修改引用的操作,第二是在对外暴露的方法中有直接修改该对象中字段的操作。比如一个类长这样:
public class MyObj {
private List<String> list;

public void m1() {
list = new ArrayList<>(); // 直接修改字段指向的对象
}

public void m2() {
list[0] = “aa”; // 修改该字段指向对象的字段
}
}
代码中的 m1 和 m2 都可以算做是修改字段 list,如果类暴露的方法中有这两种修改方式中的任意一种,就可以算作这个字段是可变的。
小贴士:是不是把字段声明成 final 类型,该字段就不可变了呢?

如果该字段是基本数据类型,那声明为 final 的确可以保证在程序运行过程中不可变,但是如果该字段是非基本数据类型,那么需要让该字段代表的对象中的所有字段都是不可变字段才能保证该 final 字段不可变。
所以在使用字段的过程中,应该尽可能的让字段不共享或者不可变,不共享或者不可变的字段才不会引起安全性问题哈哈。
这让我想起了一句老话:只有死人才不会说话~
用锁来保护访问
确定了哪些字段必须是共享、可变的之后,就要分析在哪些对外暴露的方法中访问了这些字段,我们需要在所有的访问位置都进行必要的同步处理,这样才可以保证这个类是一个线程安全类。通常,我们会使用锁来保证多线程在访问共享可变字段时是串行访问的。
但是一种常见的错误就是:只有在写入共享可变字段时才需要使用同步,就像这样:
public class Test {
private int i;

public int getI() {
return i;
}

public synchronized void setI(int i) {
this.i = i;
}
}
为了使 Test 类变为线程安全类,也就是需要保证共享可变字段 i 在所有外界能访问的位置都是线程安全的,而上边 getI 方法可以访问到字段 i,却没有进行有效的同步处理,由于内存可见性问题的存在,在调用 getI 方法时仍有可能获取的是旧的字段值。所以再次强调一遍:我们需要在所有的访问位置都进行必要的同步处理。
使用同一个锁
还有一点需要强调的是:如果使用锁来保护共享可变字段的访问的话,对于同一个字段来说,在多个访问位置需要使用同一个锁。
我们知道如果多个线程竞争同一个锁的话,在一个线程获取到锁后其他线程将被阻塞,如果是使用多个锁来保护同一个共享可变字段的话,多个线程并不会在一个线程访问的时候阻塞等待,而是会同时访问这个字段,我们的保护措施就变得无效了。
一般情况下,在一个线程安全类中,我们使用同步方法,也就是使用 this 对象作为锁来保护字段的访问就 OK 了~。
封不封装取决于你的心情
虽然面向对象技术封装了安全性,但是打破这种封装也没啥不可以,只不过安全性会更脆弱,增加开发成本和风险。也就是说你把字段声明为 public 访问权限也没人拦得住你,当然你也可能因为某种性能问题而打破封装,不过对于我们实现业务的人来说,还是建议先使代码正确运行,再考虑提高代码执行速度吧~。
不变性条件
现实中有些字段之间是有实际联系的,比如说下边这个类:
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,如果不是的话,计算参数的平方,然后把该参数和计算结果分别缓存到 numberCache 和 squareCache 中。
从上边的描述中我们可以知道,squareCache 不论在任何情况下都是 numberCache 平方值,这就是 SquareGetter 类的一个不变性条件,如果违背了这个不变性条件的话,就可能会获得错误的结果。
在单线程环境中,getSquare 方法并不会有什么问题,但是在多线程环境中,numberCache 和 squareCache 都属于共享的可变字段,而 getSquare 方法并没有提供任何同步措施,所以可能造成错误的结果。假设现在 numberCache 的值是 2,squareCache 的值是 3,一个线程调用 getSquare(3),另一个线程调用 getSquare(4),这两个线程的一个可能的执行时序是:

两个线程执行过后,最后 numberCache 的值是 4,而 squareCache 的值竟然是 9,也就意味着多线程会破坏不变性条件。为了保持不变性条件,我们需要把保持不变性条件的多个操作定义为一个原子操作,即用锁给保护起来。
我们可以这样修改 getSquare 方法的代码:
public synchronized int getSquare(int i) {
if (i == numberCache) {
return squareCache;
}
int result = i*i;
numberCache = i;
squareCache = result;
return result;
}
但是不要忘了将代码都放在同步代码块是会造成阻塞的,能不进行同步,就不进行同步,所以我们修改一下上边的代码:
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 方法也访问了 numberCache 和 squareCache 字段,所以对于每个包含多个字段的不变性条件,其中涉及的所有字段都需要被同一个锁来保护,所以我们再修改一下 getCache 方法:
public synchronized int[] getCache() {
return new int[] {numberCache, squareCache};
}
这样修改后的 SquareGetter 类才属于一个线程安全类。
使用 volatile 修饰状态
使用锁来保护共享可变字段虽然好,但是开销大。使用 volatile 修饰字段来替换掉锁是一种可能的考虑,但是一定要记住 volatile 是不能保证一系列操作的原子性的,所以只有我们的业务场景符合下边这两个情况的话,才可以考虑:

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

比方说下边的这个类:
public class VolatileDemo {

private volatile int i;

public int getI() {
return i;
}

public void setI(int i) {
this.i = i;
}
}
VolatileDemo 中的字段 i 并不和其他字段组成不变性条件,而且对于可以访问这个字段的方法 getI 和 setI 来说,并不需要以来 i 的当前值,所以可以使用 volatile 来修饰字段 i,而不用在 getI 和 setI 的方法上使用锁。
避免 this 引用逸出
我们先来看一段代码:
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 引用逸出:
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 引用逸出。
总结

客户端程序员不靠谱,我们有必要把线程安全性封装到类中,只给客户端程序员提供线程安全的方法。
认真找出代码中既共享又可变的变量,并把它们使用锁来保护起来,同一个字段的多个访问位置需要使用同一个锁来保护。
对于每个包含多个字段的不变性条件,其中涉及的所有字段都需要被同一个锁来保护。
在对变量的写入操作不依赖当前值以及该变量不需要和其他共享变量组成不变性条件的情况下可以考虑使用 volatile 变量来保证并发安全。
千万不要在对象构造过程中使 this 引用逸出。

正文完
 0