并发编程之互斥

并发编程领域可以抽象为3个核心问题:分工、协作、互斥。

本文介绍互斥。

互斥是为了保证程序的正确性,用专业术语叫“线程安全”。并发程序里,当多个线程同时访问同一个共享变量的时候,结果是不确定的。而导致不确定的主要源头是可见性问题、有序性问题和原子性问题,为了解决这三个问题,Java 语言引入了内存模型,内存模型提供了一系列的规则,利用这些规则,我们可以避免可见性问题、有序性问题,但是还不足以完全解决线程安全问题。解决线程安全问题的核心方案还是互斥。所谓互斥,指的是同一时刻,只允许一个线程访问共享变量。加锁方案有Java语言里的synchronized、并发包里的各种Lock。无锁方案有Java并发包里提供的原子类。除此之外,还有一些其他的方案,原理是不共享变量或者变量只允许读。这方面,Java提供了Thread Local和final关键字,还有一种Copy-on-write的模式。

加锁

锁是实现互斥的一种通用技术方案,如Java提供的synchronized关键字和并发包里面的各种Lock,都是锁的实现。

synchronized

ReentrantLock

ReentrantLock是可重入锁,与synchronized的作用相同,但两者特性不同。

ReentrantLock可配置为公平锁和非公平锁。多个线程一起竞争AQS对象中的state变量。若state为0,说明锁目前空闲可用,需用CAS竞争锁;若state不为0,说明有线程占用着锁,需要将本线程加入队列,并挂起本线程,等待唤醒。

1
2
3
4
5
6
7
8
9
10
11
12
13
//ReentrantLock使用示例
class X {
private final ReentrantLock lock = new ReentrantLock();

public void m() {
lock.lock();
try {
//...
} finally {
lock.unlock()
}
}
}}

ReadWriteLock和StampedLock

读多写少是一个比较常见的并发场景,例如利用缓存优化性能。在这种场景下,如果使用读写不区分的锁(如ReentrantLock),性能会比较差。针对这种场景,Java并发包提供了读写锁(ReadWriteLock)。

读写锁应该遵守以下三条基本原则:

  1. 允许多个线程同时读共享变量;

  2. 只允许一个线程写共享变量;

  3. 如果一个写线程正在执行写操作,此时禁止读线程读共享变量。

读写锁与互斥锁的一个重要区别就是读写锁允许多个线程同时读共享变量,而互斥锁是不允许的,这是读写锁在读多写少场景下性能优于互斥锁的关键。但读写锁的写操作是互斥的,当一个线程在写共享变量的时候,是不允许其他线程执行写操作和读操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//ReadWriteLock使用例子
class Cache<K,V> {
final Map<K, V> m = new HashMap<>();
final ReadWriteLock rwl = new ReentrantReadWriteLock();
// 读锁
final Lock r = rwl.readLock();
// 写锁
final Lock w = rwl.writeLock();
// 读缓存
V get(K key) {
r.lock();
try { return m.get(key); }
finally { r.unlock(); }
}
// 写缓存
V put(K key, V value) {
w.lock();
try { return m.put(key, v); }
finally { w.unlock(); }
}
}

ReadWriteLock在使用中可能会造成写操作的饥饿。在非公平模式下,写操作需要等所有读操作都结束后才能进行,但在读操作大量涌入的情况下,写操作可能会一直阻塞,获取不到锁。为了解决这个问题,Java8引入了新读写锁StampedLock。

StampedLock支持三种锁模式,分别是:写锁、悲观读锁和乐观读。前两种锁与ReadWriteLock相同。StampedLock的性能之所以比ReadWriteLock要好,关键在于乐观读方式。ReadWriteLock支持多个线程同时读,但是当多个线程同时读的时候,所有的写操作会被阻塞;而StampedLock提供的乐观读,允许一个线程获取写锁,也就是说不是所有的写操作都被阻塞。

StampedLock的乐观读与数据库中的乐观锁思想一样,都是通过版本号的方式实现。

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
//StampedLock使用例子
class Point {
private int x, y;
final StampedLock sl = new StampedLock();
//计算到原点的距离
int distanceFromOrigin() {
//乐观读
long stamp = sl.tryOptimisticRead();
//读入局部变量
int curX = x, curY = y;
//读的过程,数据可能被修改
//需要判断这期间是否存在写操作
//如果存在,则sl.validate返回false
if (!sl.validate(stamp)){
//升级为悲观读锁
stamp = sl.readLock();
try {
curX = x;
curY = y;
} finally {
//释放悲观读锁
sl.unlockRead(stamp);
}
}
return Math.sqrt(
curX * curX + curY * curY);
}
}

无锁

加锁往往会带来较大的开销,为了优化这部分性能,可以使用无锁方案。Java中的原子类就是无锁的,比较并交换(CAS)是无锁方案的常用实现。

Java并发包里提供的原子类可分为五个类别:

  1. 原子化的基本数据类型
  2. 原子化的对象引用类型
  3. 原子化数组
  4. 原子化对象属性更新器
  5. 原子化的累加器

原子化的基本数据类型

相关实现有AtomicBoolean、AtomicInteger和AtomicLong,较为简单。

原子化的对象引用类型

相关实现有AtomicReference、AtomicStampedReference和AtomicMarkableReference,利用它们可以实现对象引用的原子化更新。对象引用的更新需要重点关注ABA问题,AtomicStampedReference和 AtomicMarkableReference这两个原子类可以解决 ABA 问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//AtomicStampedReference
//利用版本号解决ABA
boolean compareAndSet(
V expectedReference,
V newReference,
int expectedStamp,
int newStamp)

//AtomicMarkableReference
//版本号为boolean型
boolean compareAndSet(
V expectedReference,
V newReference,
boolean expectedMark,
boolean newMark)

原子化数组

相关实现有AtomicIntegerArray、AtomicLongArray和AtomicReferenceArray,这些原子类可以原子化地更新数组里面的每一个元素。

原子化对象属性更新器

相关实现有AtomicIntegerFieldUpdater、AtomicLongFieldUpdater和AtomicReferenceFieldUpdater,它们可以原子化地更新对象的属性,这三个方法都是利用反射机制实现的。更新的对象属性必须是volatile类型,这样才能保证可见性。

原子化的累加器

相关实现有DoubleAccumulator、DoubleAdder、LongAccumulator和LongAdder,这四个类仅仅用来执行累加操作,相比原子化的基本数据类型,速度更快。

并发需要注意的问题

安全性

安全性指的是多个线程读写同一份数据可能会导致程序不正确。

安全性的问题可使用互斥解决。

活跃性

活跃性指的是某个操作无法执行下去,又包括死锁、活锁、饥饿。

死锁

死锁指的是两个线程阻塞且在互相等待对方的锁。

死锁的产生需要同时满足以下四个条件:

  1. 互斥。共享资源X和Y只能被一个线程占用;
  2. 占有且等待。线程T1已经取得共享资源X,在等待共享资源Y的时候,不释放共享资源X;
  3. 不可抢占。其他线程不能强行抢占线程T1占有的资源;
  4. 循环等待。线程T1等待线程T2占有的资源,线程T2等待线程T1占有的资源,就是循环等待。

只要破坏其中的一个条件即可解除死锁。

我们加锁就是为了互斥,所以条件1不可破坏,我们考虑余下的三个条件。

  • 条件2-占有且等待:在一个临界区内申请所有的资源。例如在一个单例的同步方法中申请全部资源;

  • 条件3-不可抢占:锁住某个资源的线程可主动释放该资源。例如Lock接口可以支持中断、超时和非阻塞;

  • 条件4-循环等待:所有线程对资源的加锁都应该按照某个特定的顺序。

活锁

活锁指的是两个线程未阻塞,但也存在执行不下去的情况。例如以太网采用CSMA/CD协议解决冲突问题。

解决方案:等待随机的时间

饥饿

饥饿指的是线程得不到资源而一直执行不了。

解决方案:公平锁

性能

我们可以用Amdahl定律来描述性能,该定律代表了处理器并行运算之后效率提升的能力。我们可以从以下两个方面来提升并发编程的性能:

  1. 减少锁持有的时间
  2. 无锁技术:线程本地存储、写时复制、乐观锁

小结

总的来说,并发策略可以总结为以下三方面:避免共享、不变模式、管程及其他同步工具

Java并发包的作者Doug Lea在《Java并发编程:设计原则与模式》一书中给出了用锁的最佳实践:

1.永远只在更新对象的成员变量时加锁

2.永远只在访问可变的成员变量时加锁

3.永远不在调用其他对象的方法时加锁