当使用多线程时,当多个线程同时操作同一个变量时,由于竞争条件(race condition)可能破坏该变量的状态,导致一致性问题,而如果多线程之间依赖同一资源,则各线程之间可能会陷入 Liveness Hazards
线程安全即在多线程的环境下, 无论线程以何种方式使用该对象, 都不会引起错误, 并且对该对象的使用者而言无需额外添加同步或其它条件
确保一致性
确保线程间的一致性,最有效的方法是减少(或避免)多线程之间的数据(状态)共享,对于共享的数据(状态),则应保证在同一时刻对各线程的可见性(visible),保证一致性常用的方发包括:
- 使用
ThreadLoacl<T>
将数据限制在一个线程内,从而避免在多线程之间共享数据 - 通过发布不可变对象(Immutable objects)来防止其它线程对数据的修改
- 通过
volatile
修饰单一数据或使用java.util.concurrent.atomic
来操作数据 - 锁机制
常见的并发模型包括:通过加锁的方式来保证在多个进程(线程)之间的共享数据的一致性,如:Java,通过进程间的通讯共享数据的一致性(CSP,Communicating Sequential Process)如:Go
ThreadLocal
ThreadLoacl
将变量的访问限制在当前线程内,不允许变量在多个线程间共享,其内部实现是 JVM 为每个线程维护一个 ThreadLocalMap
,这个 map 的 key 是 ThreadLocal
实例本身(弱引用),value 是 ThreadLocal 存储的对象
ThreadLocal 的内存泄漏的根本原因是
ThreadLocal
的生命周期跟线程一样长,所以如果线程不结束(或没有显式的调用ThreadLocal.remove()
方法),那ThreadLocal
就会一直存在,导致ThreadLocal
中存储的对象得不到回收,因此造成内存泄露。
不可变对象
构造不可变对象的方法:
-
将对象的所有域(fields)都声明成
private
-
不提供任何修改域的方法
-
使对象的类不可继承
-
不直接返回一个对象中的域,如:
public Complex add(Complex c) { return new Complex(re + c.re, im + c.im); }
原子操作
单个变量的一致性问题是由于复合操作(compound actions)引起的,如:check-then-act(常见的延迟加载),read-modify-write(常见的 ++
操作),compare-and-swap 和 put-if-absent(常见的集合操作)。Java 提供了两种方式:volatile
关键字和 java.util.concurrent.atomic
,来保证对单个变量操作的原子性,其中 volatile
关键字可以保证当一个线程对 volatile
修饰的变量修改时,其修改对其他线程可见,但 volatile
不能保证复合操作的原子性。 java.util.concurrent.atomic
通过 CAS 操纵来保证对单个变量的操作的原子性
Volatile
JMM(Java Memory model)保证 volatile 变量的值不会被缓存,并且其还会保证对 volatile 变量的操作顺序(happens-before),使用 volatile 变量场景包括:
- 用
volatile
修饰long
或double
变量,使其能按原子类型来读写。JVM 中double
和long
都是 64 位,因此 JVM 对这两种类型的读是分为两部分的,第一次读取第一个 32 位,然后再读剩下的 32 位,这个过程不是原子的,使用volatile
型的long
或double
来保证读写的原子性。 -
volatile
的另一个作用是提供内存屏障(memory barrier)。简单的说,就是当你写一个volatile
变量之前,Java 内存模型会插入一个写屏障(write barrier),读一个volatile
变量之前,会插入一个读屏障(read barrier)。意思就是说,在写一个volatile
域时,能保证任何线程都能看到你写的值,同时,在写之前,也能保证任何数值的更新对所有线程是可见的。
Atomic
Java 的 java.util.concurrent.atomic
支持 CAS 操作, 包括:
- 标量(scalars),
AtomicInteger
,AtomicLong
,AtomicBoolean
,和AtomicReference
- 域更新器(field updaters),
AtomicIntegerFieldUpdater<T>
,AtomicLongFieldUpdater<T>
,AtomicReferenceFieldUpdater<T,V>
- 数组(arrays,
volatile
只能保证数组引用),AtomicIntegerArray
,AtomicLongArray
,AtomicReferenceArray<E>
- 成对的对象(boxed pairs),解决 ABA 问题,
AtomicMarkableReference<V>
和AtomicStampedReference<V>
,AtomicMarkableReference
类描述的一个<Object,Boolean>
的对,可以原子的修改 Object 或者 Boolean 的值,这种数据结构在一些缓存或者状态描述中比较有用。这种结构在单个或者同时修改 Object/Boolean 的时候能够有效的提高吞吐量。而AtomicStampedReference
类维护带有整数标志(<Object,int>
)的对象引用,可以用原子方式对其进行更新。其实就是对对象(引用)的一个并发计数。但是与AtomicInteger
不同的是,此数据结构可以携带一个对象引用(Object),并且能够对此对象和计数同时进行原子操作。
CAS(Compare-And-Swap,比较并交换操作),CAS 是极轻量级的操作,由处理器(硬件)直接实现,Intel 处理器通过 cmpxchg 系列指令实现,而 PowerPC 处理器通过加载并保留和条件存储的指令实现。CAS 操作包含三个操作数:内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。通常将 CAS 用于同步的方式是从地址 V 读取值 A,执行多步计算来获得新值 B,然后使用 CAS 将 V 的值从 A 改为 B。如果 V 处的值还是 A 则更新为 B,则 CAS 操作成功(read-modify-write),否则,则证明其他线程(同时)修改了地址 V,此时算法可以对该操作重新计算。
ABA 问题,一个线程 t1 从变量 V 中取出值 A,这时候另一个线程 t2 也从取出值 A,并且 t2 对变量 V 进行了一些操作变成了 B,然后 t2 又将 V 数据变成 A,这时候线程 t1 进行 CAS 操作发现内存中仍然是 A,然后 t1 操作成功。尽管线程 t1 的CAS操作成功,但是不代表这个过程就是没有问题的。
锁机制
Java 的锁机制既可以保证数据的可见性,也可以保证对数据操作的原子性,Java 通过 synchronized
块和 Lock
和 ReadWriteLock
接口来提供锁机制
其中 synchronized
块是 Java 的内置加锁机制,一个 synchronized
块包括一个锁对象(monitor/intrinsic lock)和一段由其保护的代码块,对 synchronized
方法,其持有的锁对象是方法所在的对象,如果是 static
方法,则其持有的锁对象是其 Class
对象,synchronized
块具有以下特点:
- 互斥(mutex)性,同一时间至多只有一个线程可以获得锁
- JVM 保证
synchronized
块总是在其调用者之前执行(happens-before) - 通过允许同一线程可重入(reentrancy)
synchronized
块避免继承关系中的死锁
在使用
synchronized
块时,要注意锁对象必须明确且一致
但 synchronized
块存在以下局限
- 如果一个被
synchronized
保护的线程正在等待获得锁,则该线程不可能被中断,可能引起死锁 - 获取和释放锁必须是成对出现的,即
synchronized
必须与调用者在同一代码块中 -
synchronized
块保证了基本的统计公平(被阻塞的线程终会获得锁),但不保证需求公平,线程无法按队列顺序获取锁
由于这些局限,Java 提供了更具灵活性的锁机制:Lock
和 ReadWriteLock
接口
-
ReentrantLock
(implementLock
),其可以设置锁超时(tryLock(long timeout, TimeUnit unit)
,通过超时避免死锁)也可以在获取锁的过程中可以响应中断(lockInterruptibly()
,线程在等待获取锁的过程中,如果收到了中断请求,抛出InterruptedException
响应中断) -
ReentrantReadWriteLock
(implementReadWriteLock
),读写锁允许一次有多个 reader 或单个 writer 访问资源,但 reader 和 writer 不能同时访问资源。
使用锁的最佳实践:只有在 synchronized
块无法满足需求时,才考虑使用 Lock
, 如果使用 Lock
最后一定要释放锁
Lock lock = ...
lock.lock();
try {
// do something
} finally {
lock.unlock();
}
避免 Liveness Hazard
常见的 Liveness Hazards 问题包括:
- 饥饿(Starvation),如:程序中的无限循环、线程无限等待资源和滥用线程的优先级可能导致的线程一直抢占资源等
- 活锁(Livelock),如:在消息队列中没有处理失败的消息,导致失败消息又被重置于队列的头部重新处理(还会失败),从而导致无限的循环
- 死锁(Dead lock),该问题最常见,而死锁的诱因可总结为:多线程之间以不同的顺序请求锁(locking-order)导致它们产生相互依赖关系,如:
T1 -> has lock A -> try to request lock B -> wait forever
T2 -> has lock B -> try to request lock A -> wait forever
在实际开发过程中,以下两种方式容易产生死锁,且不易发现
- 在方法 A 持有锁的时候,调用另一个需要锁的方法 B,而方法 B 又恰好需要方法 A 的锁
// Warning: deadlock-prone!
class Taxi {
@GuardedBy("this") private Point location, destination;
private final Dispatcher dispatcher;
public Taxi(Dispatcher dispatcher) {
this.dispatcher = dispatcher;
}
public synchronized Point getLocation() {
return location;
}
public synchronized void setLocation(Point location) { // hold the taxi lock
this.location = location;
if (location.equals(destination))
dispatcher.notifyAvailable(this); // hold the dispatcher lock, the outer method was invoked
}
}
class Dispatcher {
@GuardedBy("this") private final Set<Taxi> taxis;
@GuardedBy("this") private final Set<Taxi> availableTaxis;
public Dispatcher() {
taxis = new HashSet<Taxi>();
availableTaxis = new HashSet<Taxi>();
}
public synchronized void notifyAvailable(Taxi taxi) {
availableTaxis.add(taxi);
}
public synchronized Image getImage() { // hold the dispatcher lock
Image image = new Image();
for (Taxi t : taxis)
image.drawMarker(t.getLocation()); // hold the taxi lock, the outer method was invoked
return image;
}
}
- 以方法的参数作为锁,导致加锁过程依赖于参数的传入顺序,如:
public void transferMoney(Account fromAccount, Account toAccount, DollarAmount amount) {
synchronized (fromAccount) {
synchronized (toAccount) {
// ...
}
}
}
transferMoney(myAccount, yourAccount, 10); // Thread A
transferMoney(yourAccount, myAccount, 20); // Thread B
诊断和避免死锁
可以通过生成 Thread 的 dump 文件来诊断死锁,而避免死锁可以从下面几个方面考虑:
- 控制锁的范围,越小越好
- 保证多个线程加锁和解锁的顺序一致
- 通过压力测试模仿大量的随机的参数顺序来探测死锁
- 使用 open-call 接口,即对外发布的对象方法不要持有锁
- 使用定时锁
网友评论