Java并发编程 - 线程安全性
您目前处于:编程  2014年06月26日

线程或者锁在并发编程中的作用,类似于铆钉和工字梁在土木工程中的作用。在构建稳健的并发程序时,必须正确地使用线程和锁。要编写安全的代码,其核心在于要对状态访问操作进行管理,特别是对共享的(Shared)和可变的(Mutable)状态的访问。

"共享"意味着变量可以由多个线程同时访问,而"可变"则意味着变量的值在其生命周期内可以发生变化。要使得对象时线程安全的,需要采用同步机制来协同对对象可变状态的访问。

当多个线程访问某个状态变量并且其中一个线程执行写入操作时,必须采用同步机制来协同这些线程对变量的访问。Java中主要同步机制是关键字synchronized,它提供了一种独占的加锁方式,但"同步"这个术语还包括volatile类型的变量,显示锁以及原子变量。

如果当多个线程访问同一个可变的状态变量时没有使用合适的同步,那么程序就会出现错误。有三种方式可以修复这个问题:

1. 不在线程之间共享该状态变量
2. 将状态变量修改为不可变的变量
3. 在访问状态变量时使用同步

线程安全性

当多个线程访问某个类时,不管运行时环境采用何种调用方式或者这些线程将如何交替执行,并且在主调代码中不需要任何额外的同步或协同,这个类都能表现出正确的行为,那么就称这个类是线程安全的。

@ThreadSafe
public class StatelessFactorizer implements Servlet {
    public void service(ServletRequest req, ServletResponse rsp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

计算过程中的临时状态仅存在于线程栈上的局部变量中,并且只能由正在执行的线程访问。访问StatelessFactorizer 的线程不会影响另一个访问同一个StatelessFactorizer 的线程的计算结果,因为这两个线程并没有共享状态,就好像它们都在访问不同的实例。

原子性

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;

    public long getCount() {
        return count;
    }

    public void service(ServletRequest req, ServletResponse rsp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }
}
  • 竞态条件

  • 当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。

    在并发编程中,这种由于不恰当的执行时序而出现不正确的结果是一种非常重要的情况,它有一个正确的名字:竞态条件。最常见的竞态条件类型就是"先检查后执行"操作,即通过一个可能失效的观测结果来决定下一步动作。

  • 示例:延迟初始化中的竟态条件

  • 使用"先检查后执行"的一种常见情况就是延迟初始化。延迟初始化的目的是将对象的初始化操作推迟到实际被使用时才进行,同时要确保只被初始化一次。

    @NotThreadSafe
    public class LazyInitRace {
        private ExpensiveObject instance = null;
    
        public ExpensiveObject getInstance() {
            if(instance == null)
                instance = new ExpensiveObject();
            return instance;
        }
    }

    在LazyInitRace中包含了一个竞态条件,它可能会破坏这个类的正确性。假定线程A和线程B同时执行getInstance。A看到instance为空,因而创建一个新的ExpensiveObject实例。B同样需要判断instance是否为空。此时的instance是否为空,取决于不可预测的时序,包括线程调度方式,以及A需要花多长时间来初始化ExpensiveObject并设置instance。如果当B检查时,instance为空,那么在两次调用getInstance时可能会得到不同的结果,即时getInstance通常被认为是返回相同的实例。

  • 复合操作

  • 假定有两个操作A和B,如果从执行A的线程来看,当另一个线程执行B时,要么将B全部执行完,要么完全不执行B,那么A和B对彼此来说是原子的。原子操作是指,对于访问同一个状态的所有操作(包括该操作本身)来说,这个操作时一个以原子方式执行的操作。

    为了确保线程安全性,"先检查后执行"和"读取-修改-写入"等操作必须是原子的。我们将这类操作称为复合操作:包含了一组必须以原子方式执行的操作以确保线程安全性。

    @ThreadSafe
    public class UnsafeCountingFactorizer implements Servlet {
        private final AtomicLong count = new AtomicLong(0);
    
        public long getCount() {
            return count.get();
        }
    
        public void service(ServletRequest req, ServletResponse rsp) {
            BigInteger i = extractFromRequest(req);
            BigInteger[] factors = factor(i);
            count.incrementAndGet();
            encodeIntoResponse(resp, factors);
        }
    }

    在实际情况中,应尽可能地使用现有的线程安全对象来管理类的状态。与非线程安全的对象相比,判断线程安全对象的可能状态及其状态转换情况要更为容易,从而也更容易维护和验证线程安全性。

    加锁机制

  • 内置锁

  • Java提供了一中内置的锁机制来支持原子性:同步代码块(Synchronized Block)。每个Java对象都可以用做一个实现同步的锁,这些锁被称为内置锁或监视器锁。Synchronized方法以Class对象作为锁。

    Java的内置锁相当于一种互斥体(或互斥锁),这意味着最多只有一个线程能持有这种锁。当线程A尝试获取一个由线程B持有的锁时,线程A必须等待或者阻塞,直到线程B释放这个锁。如果B永远不释放锁,那么A也就永远地等待下去。

    由于每次只能有一个线程执行内置锁保护的代码块,因此,由这个锁保护的同步代码块会以原子方式执行,多个线程在执行该代码块时也不会相互干扰。

  • 重入

  • 当某个线程请求一个由其他线程持有的锁时,发出请求的线程就会阻塞。然而,如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。

    重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为0时,这个锁就被认为是没有被任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值置为1。如果同一个线程再次获取这个锁,计数值将递增,而线程退出同步代码块时,计数器会相应地递减。当计数值为0时,这个锁将被释放。

    public class Widget {
        public synchronized void doSomething() { ... }
    }
    public class LoggingWidget extends Widget {
        public synchronized  void doSomething() {
            super.doSomething();
        }
    }

    子类改写了父类的synchronized方法,然后调用父类的方法,此时如果没有可重入的锁,那么这段代码将产生死锁。

    用锁来保护状态

    访问共享状态的复合操作,都必须是原子操作以避免产生竟态条件。如果用同步来协调对某个变量的访问,那么在访问这个变量的所有位置上都需要使用同步。而且,当使用锁来协调对某个变量的访问时,在访问变量的所有位置上都要使用同一个锁。

    对于可能被多个线程同时访问的可变状态变量,在访问它时都需要持有同一个锁,在这种情况下,我们称状态变量是由这个锁保护的。

    对象的内置锁与其状态之间没有内在关联。当获取与对象关联的锁时,并不能阻止其他线程访问该对象,某个线程在获得对象的锁之后,只能阻止线程获得同一个锁。

    一个常见的加锁约定是,将所有的可变状态都封装在对象内部,并通过对象的内置锁对所有访问可变状态的代码路径进行同步,使得在该对象上不会发生并发访问。

    当某个变量由锁来保护时,这样就确保在同一时刻只有一个线程可以访问这个变量。

    虽然synchronized方法可以确保单个操作的原子性,但如果要把多个操作合并为一个复合操作,还需要额外的加锁机制。


转载请并标注: “本文转载自 linkedkeeper.com ”