【Java】多线程 - 线程安全(Thread Safety)

Posted by 西维蜀黍 on 2019-02-27, Last Modified on 2021-09-21

背景

线程安全是多线程领域的问题,线程安全可以简单理解为一个方法或者一个实例可以在多线程环境中使用而不会出现问题。

产生线程不安全的原因

在同一程序中运行多个线程本身不会导致问题,问题在于多个线程访问了相同的资源。如,同一内存区(变量,数组,或对象)、系统(数据库,web services等)或文件。实际上,这些问题只有在一或多个线程同时向这些资源进行写操作时,才可能发生。

只要资源没有发生变化,多个线程读取相同的资源仍然是安全的。

竞态条件(Race condition) & 临界区(Critical Regions)

当两个线程同时修改同一资源时,就存在竞态条件(Race condition) 。导致竞态条件发生的代码区称作临界区(Critical Regions)

通过在临界区中使用适当的同步机制(synchronization),比如互斥锁(mutex),就可以避免竞态条件。

线程安全(Thread Safety)

当一个可变(mutable)对象会被多个线程访问时,就要考虑这个线程是不是需要被设计成线程安全的。

一个类会被称为**线程安全(thread-safe)**的,当它被从多个线程访问,而且无论这些线程如何被调度(scheduling)和交叉(interleaving)执行,而且在调用代码(calling code)中不需要额外的同步(synchronization)或者其他协调(coordiantion)机制,它的行为仍然正确(若预期执行)。

换句话说,线程安全的类封装(encapsulate)了已经需要的同步机制,因此客户端或者说调用者不再需要关注或者提供这些同步机制。

不同场景的线程安全讨论

基础类型的局部变量

局部变量存储在线程自己的栈中。也就是说,局部变量永远也不会被多个线程共享。所以,基础类型的局部变量是线程安全的。下面是基础类型的局部变量的一个例子:

public void someMethod(){
  int threadSafeInt = 0;
  threadSafeInt++;
}

引用类型的局部变量

引用类型的局部变量和基础类型的局部变量情况不同。

尽管引用本身(本质上是一个指向堆空间的指针)没有被共享,但引用所指的对象并没有存储在线程的栈内,而是存在该线程所属的进程中的堆(Heap)里。注意,该进程的所有子线程均可以访问这个。所以,对于引用类型的局部变量,有可能是线程安全的,也有可能是线程不安全的。

那么怎样才是线程安全的呢?

如果在某个方法中创建的对象(及它的成员变量)不会被其他线程访问到,那么它就是线程安全的。本质上,是因为不存在该对象被多个线程同时读写的情况(因为只能被一个线程访问)。

下面是一个线程安全的局部引用样例:

public void someMethod(){
  LocalObject localObject = new LocalObject();
  localObject.callMethod();
  method2(localObject);
}

public void method2(LocalObject localObject){
  localObject.setValue("value");
}

上面样例中,每个执行 someMethod() 的线程都会创建自己的 LocalObject 对象,并赋值给 localObject 引用。因此,这里的 LocalObject 是线程安全的。事实上,整个 someMethod() 都是线程安全的。即使将 LocalObject 作为参数传给同一个类的其它方法或其它类的方法时,它仍然是线程安全的。

当然,如果 LocalObject 通过某些方法被传给了别的线程,那它就不再是线程安全的了。

不可变(immutable)的共享资源

当多个线程同时访问同一个资源,并且其中的一个或者多个线程对这个资源进行了写操作,才会产生竞态条件。但是,如果多个线程同时读取同一个资源,并不会产生竞态条件。

因此,我们可以通过创建不可变(immutable)的共享对象来保证对象在线程间共享时不会被修改,从而实现线程安全

如下示例:

public class ImmutableValue{
    private int value = 0;

    public ImmutableValue(int value){
        this.value = value;
    }

    public int getValue(){
        return this.value;
    }
}

类库中大多数基本数值类如 Integer、String 和 BigInteger 都是不可变的。


final 关键字

Java 中,如果共享数据是一个基本数据类型,那么只要在定义时使用 final 关键字修饰它,就可以保证它是不可变的。

如果共享数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行。如java.lang.String类的对象,它是典型的不可变对象,调用它的API 不会影响原来的值,只会返回一个新构建的对象。

保证对象行为不影响自己状态的途径有很多种,其中最简单的就是把对象中带有状态的变量都声明为final。

例如如下面 java.lang.Integer 构造函数所示的,它通过将内部状态变量value定义为final来保障状态不变:

/**
     * The value of the {@code Integer}.
     *
     * @serial
     */
    private final int value;

    /**
     * Constructs a newly allocated {@code Integer} object that
     * represents the specified {@code int} value.
     *
     * @param   value   the value to be represented by the
     *                  {@code Integer} object.
     */
    public Integer(int value) {
        this.value = value;
    }

在 Java API 中符合不可变要求的类型,除了上面提到的 String 之外,常用的还有枚举类型,以及 java.lang.Number 的部分子类,如 Long 和 Double 等数值包装类型,BigInteger 和 BigDecimal 等大数据类型;但同为 Number 的子类型的原子类 AtomicInteger 和 AtomicLong 则并非不可变的。

总结

反过来,当多个线程可以同时访问一个可变状态(mutable state)变量,且没有同步机制时,这个程序就会有 bug,我们通过以下任何一种方式解决:

  • 不要在线程之间共享状态变量(state variable);
  • 使得状态变量的状态不可改变(immutable);
  • 增加同步机制,比如互斥锁(mutexes)。

绝对线程安全

绝对的线程安全完全满足 Brian GoetZ 给出的线程安全的定义,这个定义其实是很严格的,一个类要达到“不管运行时环境如何,调用者都不需要任何额外的同步措施”通常需要付出很大的代价。在 Java API 中标注自己是线程安全的类,大多数都不是绝对的线程安全。我们可以通过 Java API 中一个不是“绝对线程安全”的线程安全类来看看这里的“绝对”是什么意思。

如果说 java.util.Vector 是一个线程安全的容器,相信所有的 Java 程序员对此都不会有异议,因为它的 add()、get() 和 size() 这类方法都是被 synchronized 修饰的,尽管这样效率很低,但确实是安全的。

但是,即使它所有的方法都被修饰成同步,也不意味着调用它的时候永远都不再需要同步手段了。

Java 中实现线程安全的方法

在 Java 多线程编程当中,Java 本身提供了多种实现线程安全的方式:

  • 最简单的方式,使用 synchronized 关键字来引入互斥锁(mutexes)来保证互斥访问(mutual exclusion),互斥锁是一种悲观锁(pessimistic locking)
  • 使用 java.util.concurrent.locks 包中的锁,它们同样也是互斥锁(mutexes)
  • 使用信号量(Semaphore)
  • 对于共享变量为基本数据类型时,可使用 java.util.concurrent.atomic 包中的原子类(atomic classes),例如 AtomicInteger;
  • 使用线程安全的集合,比如ConcurrentHashMap;
  • 当只需要保证变量可见性(而不需要保证原子性时),使用 volatile 关键字,volatile 关键字并不是一种锁机制,因而效率相对比锁要高。

Reference