线程安全,ADT或方法在多线程中要执行正确
具体来说,满足以下几个要求
(1) 不违反spec、保持RI
(2)与多少处理器、 如何调度线程,均无关
(3)不需要在spec中强制要求client满足某种“线程 安全”的义务
为什么要保证线程安全?
当不同线程访问同一数据或内存时,就可能会发生交错(interleaving)或竞争(Race conditions)关系。
每当这种情况发生时,程序就会发生一些意想不到的bug,而且一旦出现线程相关的bug是很难调试的。
(1)Interleaving 交错
交错,顾名思义,就是说在线程运行的过程中,多个线程同时运行相互交错。
而且,由于线程运行一般不是连续的,那么就会导致线程间的交错。
可以说,所有线程安全问题的本质都是线程交错的问题。
(2)Race Condition 竞争
竞争是发生在线程交错的基础上的。
当多个线程对同一对象进行读写访问时,就可能会导致竞争的问题。
程序中可能出现的一种问题就是,读写数据发生了不同步。
例如,我要用一个数据,在该数据修改还没写回内存中时就读取出来了,那么就会导致程序出现问题。
程序运行时有一种情况,就是程序如果要正确运行,
必须保证A线程在B线程之前完成(正确性意味着程序运行满足其规约)。
当发生这种情况时,就可以说A与B发生竞争关系。
线程之间的“竞争条件”:作用于同一个mutable数据上的多个线程,
彼此之间存在对该数据的访问竞争并导致interleaving,
导致postcondition可能被违反,这是不安全的。
(3)Time slicing 时间分片
虽然有多线程, 但只有一个核,每个时刻只能执行一个线程
通过时间分片,在多个进程/线 程之间共享处理器
其中的核心思想就是不让其他线程直接读或写这个线程中的数据。
核心思想: 线程之间不共享mutable数据类型
限制数据共享主要是在线程内部使用局部变量,因为局部变量在每个函数的栈内,每个函数都有自己的栈结构,互不影响,这样局部变量之间也互不影响。
如果局部变量是一个指向对象的引用,那么就需要检查该对象是否被限制住,如果没有被限制住(即可以被其他线程所访问),那么就没有限制住数据,因此也就不能用这种方法来保证线程安全。
例:public class Factorial {
/**
* Computes n! and prints it on standard output.
* @param n must be >= 0
*/
private static void computeFact(final int n) {
BigInteger result = new BigInteger("1");
for (int i = 1; i <= n; ++i) {
System.out.println("working on fact " + n);
result = result.multiply(new BigInteger(String.valueOf(i)));
}
System.out.println("fact(" + n + ") = " + result);
}
public static void main(String[] args) {
new Thread(new Runnable() { // create a thread using an
public void run() { // anonymous Runnable
computeFact(99);
}
}).start();
computeFact(100);
}
}
如该图,在主函数内开启了两个线程,线程调用的是相同的函数。
理论上来讲应该会发生竞争关系,然而在这里却没有。
因为在该函数中,共享的数据变量是i,n和result,这三个都是局部变量,
放在函数调用的栈内,每个函数调用时有不同的栈,因此也就有不同的i,n和result。
用图形的方式可以表示成下面这样:
由于每个函数都有自己的局部变量,那么每个函数就可以独立运行,更新它们自己的函数值,线程之间不影响结果。
由于局部变量的特点,即使对局部变量不做任何处理,线程安全也能得到保证。然而这对于全局变量并不适用。
例1.
// 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) {
simulator = new PinballSimulator();
}
return simulator;
}
}
这个类的形式是属于一种单例模式,在这里,构造器是私有的,
只能通过 getInstance 方法来获取这个类的实例,而且,这个类的实例只能有一个。
这个类运行起来看似不会出现什么问题,只返回一个实例。
尽管在返回前做了一次判断,然而在多线程程序中,这还是不安全的。
试想,如果有两个线程同时访问这个函数,又紧接着对实例是否为空进行了判断,
这时,对于这两个线程来讲,结果肯定都是空的,这时连个线程都去创建该类的对象并返回,
这样我们就会得到这个对象的两个实例,这就违反了这个类的规约,程序的正确性就无法得到保证。
例2.
// is this method threadsafe?
/**
* @param x integer to test for primeness; requires x > 1
* @return true if x is prime with high probability
*/
public static boolean isPrime(int x) {
if (cache.containsKey(x)) return cache.get(x);
boolean answer = BigInteger.valueOf(x).isProbablePrime(100);
cache.put(x, answer);
return answer;
}
private static Map cache = new HashMap<>();
在对cache进行是否包含x检测时,如果在这个时候也同样有两个线程同时对其进行访问,
如果一个线程包含了 x
,而后另一个线程紧接着将其中的 x
删除掉了,
这样一来,第一个线程的cache.get(x)
方法就不能得到 x
的值,即程序出现了问题,无法满足正确性。
因此,如果仅使用限制数据类型的方法来保证线程安全,就要避免使用全局变量。
不可变数据类型,指那些在整个程序运行过程中,指向内存的引用是一直不变的,通常使用final来修饰。
不可变数据类型通常来讲是线程安全的,但也可能发生意外。
使用不可变数据类型和不可变引用,避免多线程之间的race condition
但是,程序在运行过程中,有时为了优化程序结构,默默地将这个引用更改了。
此时,客户端程序员是不知道它被更改了,对于客户端而言,这个引用还是不可变的,但其实已经被悄悄更改了。
这时就会发生一些线程安全问题。
解决方案就是给这些不可变数据类型再增加一些限制:
这样就可以保证线程的安全了。
如果必须要用mutable的数据类型在多线程之间共享数据,要使用线程安全的数据类型。
一般来说,JDK同时提供两个相同功能的类,一个是thread safe,另一个不是。
原因:thread safe的类一般性能上受影响。
在java中最典型的线程安全的两个类型就是 StringBuffer
和 StringBuilder
StringBuffer在使用的过程中是线程安全的,但是速度慢;
StringBuilder在使用过程中线程不安全,但速度快。
官方建议尽量使用 StringBuilder ,以提高速度。
可以看到,java提供了线程安全的数据类型,但不仅仅这一种,还有很多,其中最主要的一个大类就是 Collection
类,像下面这样:
private static Map cache =Collections.synchronizedMap(new HashMap<>());
java提供了一个包装类,可以将线程不安全的类型进一步封装,变成线程安全的数据类型。
此外,需要注意的一点就是,在包装数据类型的时候,千万记住不要将引用留在外面,像这样:
private final Set roomNumbers = new HashSet<>();
private final Set floorplan =Collections.synchronizedSet(roomNumbers);
虽然使用了线程安全的封装类,但将 new HashSet<>()
的引用留在了外面,那么就可能发生一些不愉快的事情了。
在使用synchronizedMap(hashMap)之后,不要再把参数hashMap共享给其他线程,不要保留别名,一定要彻底销毁。
注意:即使在线程安全的集合类上,使用iterator也是不安全的,除非使用lock机制
---最复杂也最有价值的thread safe策略
前三种策略的核心思想:
—–> 避免共享
—–> 即使共享,也只能读/不可写(immutable)
—–> 即使可写 (mutable),共享的可写数据应自己具备在多线程之间协调的能力,即“使用线程安全的mutable ADT”
但是很多时候,无法满足上述三个条件…
程序员来负责多线程之间对mutable数据的共享操作,通过“同步”策略,避免多线程同时访问数据
使用锁机制,获得对数据的独家mutation权,其他线程被阻塞,不得访问
(1) Synchronized Blocks and Methods
Lock是Java语言提供的内嵌机制
每个object都有相关联的lock
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进行保护
/** SimpleBuffer is a threadsafe EditBuffer with a simple rep. */
public class SimpleBuffer implements EditBuffer {
private String text;
...
public SimpleBuffer() {
synchronized (this) {
text = "";
checkRep();
}
}
public void insert(int pos, String ins) {
synchronized (this) {
text = text.substring(0, pos) + ins + text.substring(pos);
checkRep();
}
}
public void delete(int pos, int len) {
synchronized (this) {
text = text.substring(0, pos) + text.substring(pos+len);
checkRep();
}
}
public int length() {
synchronized (this) {
return text.length();
}
}
public String toString() {
synchronized (this) {
return text;
}
}
}
在每个函数内部,加上 synchronized (this)
这样的话,就是将该对象锁住,
每个对象一个时间段只能调用这个对象中的一个方法,而不能同时调用多个,这样就保证了线程的安全性。
对synchronized的方法,多个线程执行它时不允许interleave,也就是说“按原子的串行方式执行”
Synchronized Statements/Block
任何共享的mutable变量/对象必须被lock所保护
涉及到多个mutable变量的时候,它们必须被同一个lock所保护
同时,使用 synchronized
的话,也要避免死锁,即任何两个线程之间,
对对象进行锁的顺序需要一致,如果相反,就很可能会出现死锁的现象。