[笔记] Java 并发 – 线程安全性 & 对象的共享

By | 6月 18, 2018

线程安全性

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

竞态条件 Race Condition

当某个计算的正确性取决于多个线程的交替执行时序时,那么就会发生竞态条件。
大多数竞态条件的本质——基于一种可能失效的观察结果来做出判断或者执行某个计算。这种类型的竞态条件称为“先检查后执行”。
另外,要保持状态的一致性,就需要在单个原子操作中更新所有相关的状态变量。

重入

Java 中的 synchronized 是可重入的,因此如果某个线程试图获得一个已经由它自己持有的锁,那么这个请求就会成功。“重入”意味着获取锁的操作的粒度是“线程”,而不是“调用”。
重入的一种实现方法是,为每个锁关联一个获取计数值和一个所有者线程。当计数值为 0 时,这个锁就被认为是没有任何线程持有。当线程请求一个未被持有的锁时,JVM将记下锁的持有者,并且将获取计数值设置为 1。如果同一个线程再次获取这个锁,计数值将递增,而当线程退出同步代码块时,计数器会相应的递减。当计数值为 0 时,这个锁将释放。

对象的共享

同步除了原子性,还有一个重要的方面是内存可见性。我们需要防止某个线程正在使用某个对象状态的时候而另一个线程在同时修改该状态,而且希望确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化。
通常情况下,我们无法确保执行读操作的线程能够适时地看到其他线程写入的值,为了确保多个线程之间对内存写入操作的可见性,必须使用同步机制。
当线程在没有同步的情况下读取变量,可能会得到一个失效的值,但至少这个值是由之前某个线程设置的值,而不是一个随机值。这种安全性保证也被称为最低安全性。
最低安全性适用于绝大部分变量,但是有一个例外:非 volatile 类型的 64 位数值变量(double/long)。因为 JVM 允许将 64 位的读操作或写操作分解为两个32位操作,所以很有可能会得到一个完全错误的值。

加锁和可见性

加锁的含义不仅仅局限于互斥的行为,还包括了上面提到的内存可见性。为了确保所有线程都能看到共享变量的最新值,所有执行读操作或者写操作的线程都必须在同一个锁上同步。

volatile 变量

当把变量声明为 volatile 类型之后,编译和运行时都会注意到这个变量是共享的,因此不会将该变量上的操作与其他内存操作一起重排序。
volatile 变量不会被缓存在寄存器或者对其他处理器不可见的地方,因此在读取 volatile 类型的变量时总会返回最新写入的值。
volatile 是一种比 sychronized 关键字更轻量级的同步机制。加锁机制既可以确保可见性又可以确保原子性,但 volatile 变量只能确保可见性。
volatile 不是很常用,使用它需要满足下面的所有条件:

  • 对变量的写入操作不依赖变量的当前值,或者你能确保只有单个线程更新变量的值
  • 该变量不会与其他状态变量一起纳入不变性条件中
  • 在访问变量时不需要加锁

一个典型的应用场景是用作状态标记位,如下:

volatile boolean asleep;
...
    while (!asleep)
        count();

对象的发布与逸出

“发布一个对象”,指的是使对象能够在当前作用域之外的代码中使用。
但是如果我们在对象构造完成之前就发布了该对象,或者发布了内部状态,那么这种情况就称为“逸出”。
一个导致逸出的反例:

class UnsafeStates {
    private String[] states = new String[] {"AK", "AL"};
    public String[] getStates() {
        return states;
    }
}

上面的代码会导致任何调用者都能够通过 getStates() 来获取 states 对象,并且可以修改这个数组的内容。所以, states 已经逸出了它所在的作用域,因为这个本应该是私有的变量已经被发布了。
另外一个比较隐蔽的反例是,不正确的构造过程导致 this 引用的逸出:

public class ThisEscape {
    public ThisEscape(EventSource source) {
        source.registerListener(
                new EventListener() {
                    public void onEvent(Event e) {
                        doSomething(e);
                    }
                }
        );
    }
}

上面的代码在 ThisEscape class 构造的过程中,就隐式地发布了 ThisEscape 实例本身,因为 EventListener 中包含了对 ThisEscape 实例的引用。
所以重要的一点是:不要在构造过程中使 this 引用逸出
一个常见的错误是:在构造函数中启动一个线程。不管在构造函数中如何创建线程,this 引用都会被新创建的线程共享,在对象尚未完全构造之前,新的线程就可以看见它。所以可以在构造函数中创建线程,但不要启动,而是通过一个 start 或者 initialize 方法来启动。另外,在构造函数中调用一个可改写的实例方法时(既不是私有方法,也不是终结方法),同样会导致 this 引用在构造过程中逸出。
正确的解决方法时,使用一个私有的构造函数和一个公共的工厂方法,从而避免不正确的构造过程:

public class SafeListener {
    private final EventListener listener;
    private SafeListener() {
        listener = new EventListener() {
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
    }
    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

线程封闭

当数据仅在单线程中访问的时候,就不需要同步机制,那么这种情况下称之为线程封闭。
最简单的方式就是依靠局部变量,因为局部变量分配与当前线程的栈中,其他线程无法访问这个栈,所以会自动实现线程安全性。
另外一种不常用的方式是使用 volatile,但是需要确保只有单个线程对共享的 volatile 变量执行写入操作,那么就可以安全的在这些共享的 volatile 变量上执行“读取-修改-写入”操作,这种情况下,相当于将修改操作封闭在单个线程中以防止发生竞态条件,并且 volatile 变量的可见性保证了其他线程能看到最新的值。
除此之外,更规范的方式是使用 ThreadLocal,这个类能使线程中的某个值与保存值得对象关联起来。
ThreadLocal 提供了 get 和 set 等访问接口或方法,这些方法为每个使用该变量的线程都存有一份独立的副本,因此 get 总是返回由当前执行线程在调用 set 时设置的最新值。

private static ThreadLocal connectionHolder = new ThreadLocal() {
    public Connection initialValue() {
        return DriverManager.getConnection(DB_URL);
    }
};
public static Connection getConnection() {
    return connectionHolder.get();
}

当某个频繁执行的操作需要一个临时对象,例如一个缓冲区,而同时又希望避免在每次执行时都重新分配该临时对象的时候,就可以使用这项技术。
不过 ThreadLocal 变量类似于全局变量,使用的时候要格外小心。

不变性

一个不可变的对象一定是线程安全的。那么一个对象满足不可变需要满足哪些条件呢?

  • 对象创建以后其状态就不能修改
  • 对象的所有域都是 final 类型
  • 对象是正确创建的(在对象创建期间,this引用没有逸出)

另外“不可变的对象”和“不可变的对象引用”之间是存在差异的,保存在不可变对象中的程序状态仍然可以更新,即通过将一个保存新状态的实例来“替换”原有的不可变对象,这个在某些情况下很有用。
对于在访问和更新多个相关变量时出现的竞争条件问题,可以通过将这些变量全部保存在一个不可变对象中来消除。
如果是一个可变对象,那么就必须使用锁来确保原子性,但如果是一个不可变对象,那么当线程获得了这个对象的引用之后,就不必担心另一个线程会修改对象的状态。如果需要更新这些变量,那么可以创建一个新的容器对象,但其他使用原有对象的线程仍然会看到对象处于一致的状态。
下面的例子展示了如何通过 volatile 来发布一个不可变的对象:

// OneValueCache 是不可变对象,注意其中使用了 Arrays.copyOf,如果没有这个就不是不可变对象
class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;
    public OneValueCache(BigInteger i, BigInteger[] factors) {
        lastNumber = i;
        lastFactors = Arrays.copyOf(factors, factors.length);
    }
    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i)) {
            return null;
        } else {
            return Arrays.copyOf(lastFactors, lastFactors.length);
        }
    }
}
public class VolatileCachedFactorizer implements Servlet {
    private volatile OneValueCache cache = new OneValueCache(null, null);
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factor(i);
            cache = new OneValueCache(i, factors);
        }
        encodeIntoResponse(resp, factors);
    }
}

volatile 来确保可见性,所以上面的代码在没有使用锁的情况下仍然是线程安全的。

final 域

final 类型的域是不能修改的,而且它能确保初始化过程的安全性,从而可以不受限制的访问不可变对象,并在共享这些对象时无需同步。
“除非需要某个域时可变的,否则应将其声明为 final 域” 是一个良好的习惯。

发表回复

您的电子邮箱地址不会被公开。 必填项已用*标注