1 线程安全
之前介绍了线程之间的交错与竞争,线程之间的竞争条件是作用于同一个mutable数据上的多个线程,彼此之间存在对该数据的访问竞争并导致interleaving,导致post-condition可能被违反,这就导致了线程不安全。
线程安全:ADT或方法在多线程中要执行正确,不需要额外的协调。共有四种线程安全的策略,分别为限制数据共享(Confinement),共享不可变数据(Immutability),共享线程安全的可变数据(Thread data type)和同步机制(Synchronization):通过所得机制共享线程不安全的可变数据,变并行为串行。
1.1 Confinement
限制数据共享。核心思想:线程之间不共享mutable数据类型,包括变量和类。将可变数据限制在单一线程内部,避免竞争,不允许任何线程直接读写该数据,而且对象引用也必须是受限的。
因此,当线程中出现静态类或静态变量时,尽可能将其变为局部私有变量,否则,必须保证只有一个线程可以使用它,或者在客户端做出声明和限制。
Ex:
// This class has a race condition in it.
public class PinballSimulator {
private static PinballSimulator simulator = null;
// invariant: there should never be more than one PinballSimulator
// object created
private PinballSimulator() {
System.out.println("created a PinballSimulator object");
}
// factory method that returns the sole PinballSimulator object,
// creating it if it doesn't exist
public static PinballSimulator getInstance() {
if (simulator == null) {//(1)
simulator = new PinballSimulator();//(2)
}
return simulator;//(3)
}
}
上面这个例子中的getInstance方法是static,可以被多个线程所调用,可以假设有两个线程同时调用该静态方法,考虑第一个线程调用(1)处语句,第二个线程调用(3)处语句,尽管第二个线程已经new一个新的类,但是第一个线程仍然可能判断sumulator为空,因为Java不能保证线程之间读取已分配是瞬时的,读取缓存同样需要时间。
但是,如果一个ADT的rep中包含mutable的属性且多线程之间对其进行mutaor操作,那么就很难使用该策略来保证该ADT是线程安全的。
1.2 Immutability
Immutable数据通常是线程安全的,因此可以使用不可变数据类型和不可变引用,避免多线程之间的race condtion。
给出Immutability更广泛的定义:
- 没有mutaor的方法;
- 所有的fields是private,final;
- 没有representation exposure
- 没有突变,也不允许beneficent mutation,即使这些改变对用户可见
但是有时候却一定需要mutable类型的ADT,所以该方法在实用性上也存在一定问题
1.3 Using Threadsafe Data Types
如果必须要使用mutable的数据类型在多线程之间共享数据,要使用线程安全的数据类型。在JDK的类中,文档中明确指明哪些数据类型是threadsafe,但是一般线程安全的类性能上受影响。一个比较常见的例子就是StringBuffer和StringBuilder,在之前的实验中也有使用,这都是mutable类型,在单线程使用是一样的,不同的是StringBuffer是线程安全的,而StringBuilder不是。
集合类List,Set,Map等都是线程不安全的,不能用于多线程,Java API提供了进一步的decorator,这也就是装饰模式的一个应用。包装器将其所有的工作委托给指定的集合,在该集合提供的功能的基础上添加额外的功能。经过包装后的方法,使得Collection线程安全,对于包装后的Collection的每个操作调用,都是以原子形式执行,线程在运行期间,不会与其他操作交错。
private static Map<Integer,Boolean> cache=Collections.synchronizedMap(new HashMap<>());
需要注意:
- 不要绕过包装。即使对集合类进行了包装,其底层的集合仍然是可变的,引用它的代码可以绕过不变性,尽量避免进行底层引用。所以在使用synchronizedMap(new HashMap<>())之后,不要抱参数hashMap共享给其他线程,也不能保留别名。
- 迭代器(Itertors)不是线程安全的。即使在线程安全的集合类上,使用iterator也是不安全的,除非使用lock机制。
- 原子操作不足以避免竞争。当将多个原子操作放在一起时,仍有可能引起竞争,如
if(!list.isEmpty()){
String s=list.get(0);
...
}
1.4 Locks and Synchronization
先总结一下前三种策略的核心思想:
- 避免共享;即使共享,也只能读/不可写(immutable);即使可写(imutable),共享的可写数据应自己具备在多线程之间的协调的能力。
- 各自缺陷:不能全局rep共享数据;只能读共享数据;可以共享“读写”,但是只有单一方法安全,多个方法调用不安全
第四种策略是同步与锁,程序员负责多线程之间对mutable数据的共享操作,防止多线程同时访问共享数据。这里使用了一种锁的同步技术。使用锁机制,可以获得数据的独家mutation权,锁告诉编译器和处理器正在并发地使用共享内存,这样寄存器和缓存被刷新到共享存储中,从而确保所得所有者总是在查看最新数据,在这过程中其他线程被阻塞,不得访问。阻塞意味着线程等待,一直到一个打破这份等待。
关于Lock的两个操作:
- acquire,允许线程获得锁所有权。如果一个线程试图获取另一个线程当前拥有的锁,它将被阻塞,直到另一个线程释放该锁,同一时间只有一个线程拥有锁。
- release,释放意味着锁的拥有者放弃锁的拥有权
1.4.1 Locking
Lock是Java提供的一个内嵌机制,每个object都有相关联的lock,但是不能再Java的内部锁上调用acquire和release操作,可以使用synchronized语句获取语句块期间的锁。
Object lock =new Object();
synchronized (lock) { // thread blocks here until lock is free
// now this thread has the lock
balance = balance + 1;
// exiting the block releases the lock
}
拥有lock的线程可以独占式地执行该部分代码,而其他线程只能等待,这是一种“互斥”的行为。
Lock保护共享数据的访问。如果所有存取某数据变量被同一个锁锁定,则其存取就成为了原子型操作,不会被其他线程干扰。
在编写类的方法时,最方便的锁是对象实例本身,即this,用ADT自身做lock,也就是使用synchrinized(this)
/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
private String text;
...
public SimpleBuffer() {
synchronized (this) {
text = "";
checkRep();
}
}
...
public String toString() {
synchronized (this) {
return text;
}
}
}
由此产生一个Monitor pattern(监视模式):所有调用rep的方法都用同一个锁,即ADT本身,使得ADT的方法是互斥访问的。
除此之外,还有一个和synchronized(this)起相同作用的方法,可以添加synchronized到方法的描述中,Java把这个方法整体锁定
public class SimpleBuffer implements EditBuffer {
private String text;
...
public SimpleBuffer() {//构造方法无需添加synchronized关键字
text = "";
checkRep();
}
...
public synchronized String toString() {
return text;
}
}
1.4.2 Java内置锁三种使用方式
内置锁使用比较方便,不需要显示地获取和释放,任何一个对象都能作为一把内置锁,意味着出现synchronized关键字的地方,都有一个对象与之关联。
- 当synchronized作用于普通方法时,锁对象是this
- 当synchronized作用于静态方法时,锁对象是当前类的Class对象
- 当synchronized作用于代码块时,锁对象是synchronized(obj)中的obj
public synchronized void add(int t){//同步方法
this.v+=t;
}
public static synchronized void sub(int t){//同步静态方法
value-=t;
}
public int decrementAndGet{
synchronized(obj){//同步代码块
return --v;
}
}
1.4.3 Deadlock
使用锁的机制可以解开竞争条件,但同时锁又带来一个问题,因为使用锁需要线程等待,所有有可能进入两个线程相互等待的情况,因此两个线程都无法取进展。这就是死锁的情况:多个线程竞争lock,相互等待对方释放lock,这样就会进入一个依赖循环。
EX:一个用户类,rep包括姓名和其朋友的Set集合,包括添加朋友,删除朋友,查找朋友三个方法,这些方法都使用synchronized关键词修饰。
public class Wizard {
private final String name;
private final Set<Wizard> friends;
// Rep invariant:
// name, friends != null
// friend links are bidirectional:
// for all f in friends, f.friends contains this
// Concurrency argument:
// threadsafe by monitor pattern: all accesses to rep
// are guarded by this object's lock
public Wizard(String name) {
this.name = name;
this.friends = new HashSet<Wizard>();
}
public synchronized boolean isFriendsWith(Wizard that) {
return this.friends.contains(that);
}
public synchronized void friend(Wizard that) {
if (friends.add(that)) {
that.friend(this);
}
}
public synchronized void defriend(Wizard that) {
if (friends.remove(that)) {
that.defriend(this);
}
}
}
假设有两个线程在调用Wizard类中friend方法和defriend方法。
//two users
Wizard harry = new Wizard("Harry Potter");
Wizard snape = new Wizard("Severus Snape");
//Thread A
harry.friend(snope);
harrt.defriend(snape);
//Thread B
snape.friend(harry);
snape.defriend(harry);
当线程A要触发harry.friend(snope),而线程B要触发harry.friend(snope)时,线程A需要harry的锁,而线程B需要snope的锁,都在互相等待对方,因此都无法执行,这就是死锁的一个简单例子。
对于死锁的情况有几种解决的方法:
- 使用粗粒锁。多个类用一个锁,但会造成比较严重的性能损失
- 锁排序。对需要同时获取的锁进行排序,并确保所有的代码都按照该顺序获取锁
比如对于上述例子的friend函数,可以根据wizard类名字的字母表的顺序获取锁
public void friend(Wizard that) {
Wizard first, second;
if (this.name.compareTo(that.name) < 0) {
first = this; second = that;
} else {
first = that; second = this;
}
synchronized (first) {
synchronized (second) {
if (friends.add(that)) {
that.friend(this);
}
}
}
}
不过这种排序的方式在实践中有许多缺点。代码必须知道所有的锁。对于代码来说,在获得第一个锁之前,很难或者不可能确切知道它需要哪些锁
1.5 线程的一些函数
- Object.wait();释放某个对象的锁,将该对象加入到等待序列,等待也可以理解为阻塞。
- Object.notify();从等待序列中唤起一个线程
- Object.notifyAll();将等待序列中的所有线程唤起
notify和notifyAll方法智能作为此对象监视器所有着的线程调用。
一个线程调用其中的上述三者中的任何一个方法,对象必须已经锁定。
以下是线程的一个状态转换图
参考资料:MIT.6.031-software construction,课件