java并发系列-线程安全是什么,怎样实现?

1 概览

Java 支持多线程开箱即用。这意味着通过在分隔的工作者线程中并发执行字节码有能力改善应用的性能。

虽然多线程的功能非常强大,但是它也有成本。在多线程环境中,我们需要以线程安全的方式实现。这意味着不同的线程可以访问相同的资源而不用暴露错误的行文或者产生不可预测的结果。

这个编程方法就被称为线程安全。

这篇教程,我们将采用不同的方式来实现它。

2 无状态实现

大多数场景中,多线程应用的错误是线程间分享状态的错误方式引起。

因此,第一种方式是实现线程安全的方式是使用无状态实现。

为了更好地理解这种方式,我们来看看下面的一个简单的功能类,它有一个静态方法用于计算素数:

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

public static BigInteger factorial(int number) {
BigInteger f = new BigInteger("1");
for (int i = 2; i <= number; i++) {
f = f.multiply(BigInteger.valueOf(i));
}
return f;
}
}

factorial() 方法是无状态确定性函数(deterministic function)。给定值,总是返回相同的结果。

该方法不依赖外部状态也不维护状态。因此,它被认为是线程安全并可以安全地被多个线程同时调用。

所有线程可以安全地调用 factorial() 方法并获得期望结果,而不会干扰其他线程并且该方法的输出不会改变。

3 不可变实现

如果我们需要在线程间共享状态,则我们可以让创建的类不可变来保证线程安全。

不可变性是非常强大的,与语言无关的概念,它在 Java 中实现非常简单。

简单地讲,一个类实例在创建后,当它内部状态不能修改时,就是不可变的。

Java 中 创建不可变类最简单的方式是将所有字段声明为 privatefinal 并且不提供 setters 方法:

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

private final String message;

public MessageService(String message) {
this.message = message;
}

// standard getter

}

MessageService 对象是不可变的,当它创建后状态是不可修改的。因此,它是线程安全的。

再者,如果 MessageService 是可变的,但是线程只能以只读方式访问它,那么也是线程安全的。

因此,不可变性仅仅是另一种实现线程安全的方式。

4 Thread-Local fields

在面向对象编程中(OOP),对象实际上需要通过字段维护状态并通过方法实现行为。

如果我们实际上需要维持状态,我们可以创建类时让它的字段是 thread-local 的,使得线程间不分享状态,来达到线程安全。

我们可以简单创建这样的类,它的字段在 Thread 子类中简单地定义成 private 的来实现 thread-local。

例如,一个 Thread 子类保存了一个整型列表:

1
2
3
4
5
6
7
8
9
public class ThreadA extends Thread {

private final List<Integer> numbers = Arrays.asList(1, 2, 3, 4, 5, 6);

@Override
public void run() {
numbers.forEach(System.out::println);
}
}

而另外一个保存了一个字符串列表:

1
2
3
4
5
6
7
8
9
public class ThreadB extends Thread {

private final List<String> letters = Arrays.asList("a", "b", "c", "d", "e", "f");

@Override
public void run() {
letters.forEach(System.out::println);
}
}

两个实现中的类都有他们自己的状态,但是没有和其它线程分享。因此,这些类是线程安全的。

相似地,我们可以创建 ThreadLocal 类型的字段。

例如,下面的 StateHolder 类:

1
2
3
4
5
6
public class StateHolder {

private final String state;

// standard constructors / getter
}

我们简单地将它创建成为 thread-local 变量:

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

public static final ThreadLocal<StateHolder> statePerThread = new ThreadLocal<StateHolder>() {

@Override
protected StateHolder initialValue() {
return new StateHolder("active");
}
};

public static StateHolder getState() {
return statePerThread.get();
}
}

Thread-local 字段和普通的类字段很像,除了各个线程通过 setter/getter 访问到都是这个字段的单独初始化副本使得各个线程有它自己的状态。

5 同步集合

通过 collections framework 中的同步封装类集合,我们可以创建线程安全的 Collections。

示例如下:

1
2
3
4
5
Collection<Integer> syncCollection = Collections.synchronizedCollection(new ArrayList<>());
Thread thread1 = new Thread(() -> syncCollection.addAll(Arrays.asList(1, 2, 3, 4, 5, 6)));
Thread thread2 = new Thread(() -> syncCollection.addAll(Arrays.asList(7, 8, 9, 10, 11, 12)));
thread1.start();
thread2.start();

我们需要知道同步 Collections 在各个方法中使用内在锁(稍后看内在锁 intrinsic locking)。

这意味着这些方法同时只能由一个线程访问,当方法被第一个线程锁定时,其他线程会被阻塞。

因此,由于同步访问的潜在逻辑,同步过程会对性能有一定损耗。

6 并发集合

作为同步集合替代,我们可以使用并发集合创建线程安全集合。

Java 提供了 java.util.concurrent 包,它含有若干并发集合实现,比如 ConcurrentHashMap

1
2
3
4
Map<String,String> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("1", "one");
concurrentMap.put("2", "two");
concurrentMap.put("3", "three");

不像他们对应的同步实现,并发集合通过将他们的数据分割成片来实现线程安全。在 ConcurrentHashMap 中,线程可以在不同的 map 片上获得锁,所以多个线程可以同时访问 Map

并发集合的性能比同步集合要高得多,因此并发线程访问的内在优势。

值得提到的是同步和并发集合只是保证集合自身线程安全而不是内容。

7 原子对象

通过使用原子类(atomic classes)也能实现线程安全,包括 AtomicIntegerAtomicLongAtomicBooleanAtomicReference

原子类支持我们执行原子操作,它们是线程安全的,而不需使用同步。一个原子操作在单个机器级别操作中执行。

为了理解这种问题的解决方式,我们来看看下面的 Counter 类:

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

private int counter = 0;

public void incrementCounter() {
counter += 1;
}

public int getCounter() {
return counter;
}
}

假设在竞争条件下,两个线程同时访问 incrementCounter() 方法。

理论上,counter 字段的 final 值将会是 2 。但是我们对结果不确定,因为线程同时执行同一个代码块,增量操作不是原子的。

让我们通过使用 AtomicInteger 对象创建一个 Counter 类的线程安全的实现:

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

private final AtomicInteger counter = new AtomicInteger();

public void incrementCounter() {
counter.incrementAndGet();
}

public int getCounter() {
return counter.get();
}
}

为什么这个是线程安全的,因为 ++ 增量操作不是一个动作,而 incrementAndGet 是原子的。

8 同步方法

上面的方法对于集合和原始类型表现很好,而我们也常常需要比这个范围更大的控制。

因此,另外一个常见的实现线程安全的方式是实现同步方法。

简单地讲,只有一个线程可以同时访问某个同步方法,而其他访问该方法的线程会被阻塞,直到第一个线程执行结束或者抛异常。

我们现在通过同步方法来实现线程安全的 incrementCounter() 方法。

1
2
3
public synchronized void incrementCounter() {
counter += 1;
}

同步方法要在方法前面的前面加上 synchronized 关键字。

因为在某个时刻只有一个线程可以访问和执行 incrementCounter() 的同步方法,接着,其他线程也按照同样的方式进行。这样执行就不会出现重叠的问题。

同步方法依赖内在锁或者监督器锁的使用。内在锁是一个隐含的内部实体关联一个特定的类实例。

多线程上下文中,monitor 术语是一个角色的引用,该角色实现相关对象的锁在一组具体的方法或者声明上实行排他性访问。

当一个线程调用同步方法,它要获得内在锁。在该线程执行完方法,释放掉锁,才允许其他线程获取锁并访问方法。

我们可以在实例方法,静态方法和声明(同步声明)上实现同步。

9 同步声明

有时候,同步整个方法可能范围太大,因此我们只需要保证方法的部分代码是线程安全的。、

为了演示这样的场景,我们重构 incrementCounter() 方法:

1
2
3
4
5
6
public void incrementCounter() {
// additional unsynced operations
synchronized(this) {
counter += 1;
}
}

这个小例子展示了怎样创建同步声明。假设该方法执行加法操作,没有做同步。我们通过将操作包在同步代码块中实现相关状态修改部分的同步。

和同步方法不同,同步声明必须指定 object 提供内在锁,通常使用 this 引用。

同步代价很昂贵,通过这种方式,我们可以只同步一个方法的相关部分代码。

10 Volatile Fields

同步方法或代码块可以方便处理线程间的变量可见性问题。尽快如此,通常类字段的值可能被 CPU 缓存。因此,对特定字段持续更新,即使他们是同步的,也可能被其他线程不可见。

为了防止这种场景,我们使用 volatile 修饰类字段:

1
2
3
4
5
6
7
public class Counter {

private volatile int counter;

// standard constructors / getter

}

通过 volatile 关键字,我们指示 JVM 和编译器将变量 counter 保存到主内存区。通过这种方式,我们确保 JVM 每次读 counter 变量的值,都是从主内存区读取,而不是 CPU 缓存。同样地,JVM 每次写 counter 变量,也会写到主内存区。

更多地,使用 volatile 变量也会使得对给定线程可见的所有线程也都从主内存区读取。

思考下下面的例子:

1
2
3
4
5
6
7
8
public class User {

private String name;
private volatile int age;

// standard constructors / getters

}

这个例子中,每次 JVM 写 age volatile 到主内存区时,它也会将 非 volatile 的 name 变量写到主内存区中。这保证了两个变量的最新值都保存在主内存区中,因此对变量的连续更新也被其他线程可见。

简单地讲,如果一个线程读取一个 volatile 变量的值,对该线程可见的所有变量也将从主内存区读取。

volatile 变量提供的这种扩展保证称为 full volatile visibility guarantee

11 Extrinsic Locking

我们可以稍微增强 Counter 类的线程安全实现通过使用外部监控器锁代替内在锁。

外部锁在多线程环境中也提供了对共享资源的协调访问,但是它使用外部实体实施对资源的排他性访问:

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

private int counter = 0;
private final Object lock = new Object();

public void incrementCounter() {
synchronized(lock) {
counter += 1;
}
}

// standard getter

}

我们使用一个普通的 Object 实例创建一个外部锁。这种实现更好,因此它在锁级别更安全。

使用内在锁,同步方法和代码块依赖于 this 引用,攻击者可以通过请求内在锁引发死锁并触发服务拒绝(DoS)的情况。

与内在锁不同,外部锁使用一个私有实体,不能被外部访问。这使得攻击者请求锁并引发死锁变得困难。

12 Reentrant Locks

Java 提供了一组 Lock 实现的增强集合,它们的行为比上面提供的内在锁更精巧。

使用内在锁,锁获取模型非常严苛:一个线程获取锁,然后执行方法或者代码块,最后释放锁,然后其他线程才能获取锁并访问方法。

没有潜在的机制检查入队的线程并对等待最久的线程给予优先访问的权利。

ReentrantLock 实例可以实现上面的情况,使得入队线程避免经历 resource starvation 的情况:

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

private int counter;
private final ReentrantLock reLock = new ReentrantLock(true);

public void incrementCounter() {
reLock.lock();
try {
counter += 1;
} finally {
reLock.unlock();
}
}

// standard constructors / getter

}

ReentrantLock 构造方法接受一个可选 faimess boolean 参数。当设置为 true 时,多个线程尝试请求锁时, JVM 给予等待最久的线程优先获取锁的权利。

13 读写锁

另外一个用于线程安全的强大方法是使用 ReadWriteLock 实现。

ReadWriteLock 锁实际上使用一对相关锁,一个用于只读操作,另一个用于写操作。

于是,当没有线程写它的时候,所有线程都可以读取资源。进一步讲,写资源的线程将阻止其他线程读取它。

我们可以使用 ReadWriteLock 锁如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public class ReentrantReadWriteLockCounter {

private int counter;
private final ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();

public void incrementCounter() {
writeLock.lock();
try {
counter += 1;
} finally {
writeLock.unlock();
}
}

public int getCounter() {
readLock.lock();
try {
return counter;
} finally {
readLock.unlock();
}
}

// standard constructors

}

14 总结

这篇文章中,我们学习了 Java 中的线程安全并深入研究了不同方法怎样实现线程安全。


本文为译文,作者通过翻译达到学习目的。 原文链接 | 原文源码链接 | 本站源码链接