什么是线程安全性
某个类的行为与其规范完全一致。当多个线程访问某个类时,这个类始终都能表现出正确的行为,那么就称这个类是线程安全的。换句话说就是当多个线程访问某个类时,不管运行时环境采用何种调度方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个都能表现出正确的行为,那么就称这个类时线程安全的。
一般来说,线程不安全的是指存储在状态变量(例如实例或静态域)中的数据,因为这类数据存在多个线程共享的情况。而下面的实例是一个线程安全的例子。
public class StatelessFactorizer implements Servlet {
public void service(ServletRequest req, ServletResponse resp) {
// codes
}
}
因为StatelessFactorizer类是无状态的:它既不包含任何域,也不包含任何对其他类中域的引用。计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问StatelessFactorizer的线程不会影响另外一个访问同一个StatelessFactorizer的线程的计算结果,因为两个线程没有共享状态。由于线程访问无状态对象的行为并不会影响其他线程中操作的正确性,因此无状态对象是线程安全的。
原子性
当我们在无状态对象中增加一个状态时,可能就会出现非线程安全的情况,如下
public class UnsafeCountingFactorizer implements Servlet {
private long count = 0;
public long getCount() { return count; }
public void service(ServletRequest req, ServletResponse resp) {
++count; // 对变量进行了自增操作
// other codes
}
}
线程不安全的原因是++count并不是一个原子操作,它包含了三个独立的操作:读取count的值,将值加1,然后将计算结果写入count,即“读取 —— 修改 —— 写入”,并且结果状态依赖于之前的状态。在并发编程中,这种由于不恰当的执行时序而出现不正确的结果叫做:竞争条件。
竞争条件
最常见的竞争条件类型是“先检查后执行”操作,即通过一个可能失效的观测结果来决定下一步的动作。
竞争条件并不是总是会产生错误,还需要某种不恰当的执行时序。
例如在没同步加锁之前单例模式中的懒汉式就存在竞争条件,如下
public class Singleton {
private Singleton() {}
private static Singleton single = null;
public static Singleton getInstance() {
if (single == null) {
single = new Singleton();
}
return single;
}
}
当有两个线程同时访问到getInstance()方法中,线程A看到single为空,因而创建一个新的Singleton实例,同时B也看到single为空,所以也创建了一个Singleton实例,那么在两次调用getInstance()可能得到不同的结果,即破坏了最初单例的初衷,出现线程不安全的情况。
复合操作
为了确保线程安全性,“先检查后执行”和“读取——修改——写入”等操作必须是原子的。将“先检查后执行”和“读取——修改——写入”等操作统称为复合操作:包含了一组必须一原子方式执行的操作以确保线程安全性。
加锁机制
内置锁
Java提供了一种内置的锁机制来支持原子性:同步代码块( Synchronized Block )。同步代码块包括两部分作为锁的对象引用,一个作为由这个锁保护的代码块。以关键字 synchronized 来修饰的加是一种横跨整个方法体的同步代码块,其中该同步代码块的锁就是方法调用所在的对象。静态的 synchronized 方法以 Class 对象作为锁。
synchronized (lock) {
// 访问或修改由锁保护的共享状态
}
public synchronized void method {
// 访问或修改由锁保护的共享状态
}
/* 一般推荐使用在对象加锁而不是在类加锁,因为在类加锁效率较慢,即使能够实现同步的目标 */
每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视器锁。线程在进入同步代码块之前会自动获得锁,并且在退出同步代码块自动释放锁。Java内置锁相当于一种互斥锁,最多只有一个线程能持有这种锁。例如当线程A尝试获取一个由B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。
重入
当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,由于内置锁是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。
重入的一种实现方法:为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置位1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器将会相应地递减。当计数值为0时,这个锁将被释放。
重入锁可用在继承中,如下。如果synchronized锁不是重入锁,那么例如A线程执行doSomething方法获取到了Widget的锁,然后调用super.doSomething时将无法获得Widget上的锁,因为这个锁已经被持有,从而线程将永远停顿下去,等待一个永远也无法获得的锁,也就是产生了死锁的现象。而重入锁则可避免死锁的产生。
// 父类
public class Widget {
public synchronized void doSomething() {
// codes
}
}
// 子类
public class LoggingWidget extends Widget {
public synchronized void doSomething() {
// codes
super.doSomething();
}
}
用锁来保护状态
- 对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,称状态变量是由这个锁保护的。
- 每个共享的和可变的变量都应该只由一个锁来保护。
- 对于每个包含多个变量的不变性条件,其中涉及的所有变量都需要由同一个锁来保护。
参考书籍:《Java并发编程实战》