Java中的线程安全

169 阅读15分钟

线程安全的定义

在学习Java这门语言的时候,总是能到处听到“线程安全”这个词,感觉就是你不懂它,你就不配当Java程序员一样。

大家提到“线程安全”时,可能都能信誓旦旦地抛出几个概念(synchronized、volatile、Lock),口若悬河地介绍起来,但是一旦被问到“线程安全”到底是什么,可能就得支支吾吾了,因为要简短且准确地形容它太难了。

在《深入理解Java虚拟机》这本书中,借鉴《Java并发编程实战(Java Concurrency In Practice)》给出了如下的定义:

笔者认为《Java并发编程实战(Java Concurrency In Practice)》的作者Brian Goetz为“线程安全”做出了一个比较恰当的定义:“当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。”

这个定义就很严谨而且有可操作性,它要求线程安全的代码都必须具备一个共同特征:代码本身封装了所有必要的正确性保障手段(如互斥同步等),令调用者无须关心多线程下的调用问题,更无须自己实现任何措施来保证多线程环境下的正确调用。

我也认可这种说法,其中所提到的对象即可以是代码块,也可以是方法。只要在这些对象中,实现了严格的措施,去保证在多线程环境下,每次直接调用都能得到正确的输出,那这个对象就是线程安全的。

Java 中的线程安全

既然我们对线程安全有了一个定义,那么在 Java 中,线程安全是怎么体现的呢?

其实,在 Java 中的线程安全并非是非黑即白的概念,而是考虑到性能这一维度,将线程安全分为几个等级,从而让开发者按需使用。这几个等级可以分为五个:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

不可变

不可变对象一旦被构建出来且没有发生 this 引用逃逸,就不会再被更改,其对外的可见状态永远不会被改变,也就不需要考虑在多线程环境中会出现读写不一致的情况。

对于基本类型来说,只要被 final 关键字修饰,那么它就是不可变的。

对于对象来说,Java 中没有提供对其不可变性的保证,就需要对象自行保证其行为不会对其状态发生任何影响。典型的例子如 String 对象,String 中定义的任何方法都不会影响到对象中存储的原始数组(byte[]char[]),而是会返回一个新构造的 String 对象,如 substring()replace()concat()

保证对象不可变性的途径有很多种,其中一种就是对对象中带有状态的变量使用 final 进行修饰,这样在构造方法执行完毕后,该变量就是不可变的了,典型如 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.
 *
 * @deprecated
 * It is rarely appropriate to use this constructor. The static factory
 * {@link #valueOf(int)} is generally a better choice, as it is
 * likely to yield significantly better space and time performance.
 */
@Deprecated(since="9", forRemoval = true)
public Integer(int value) {
    this.value = value;
}

在 Java 中,类似 String 类那样支持不可变性的还有枚举类和 Number 类的部分子类,而 Number 子类中的原子类 AtomicIntegerAtomicLong 则是可变的。

Integer 对象中被 final 修饰的 value 是不是完全不可能被修改?

虽然 Java 中 final 关键词的目的就是为了保证不可变性,保证被 final 修饰的基本类型不会被修改,但是反射总是能给你带来例外,例如:

public static void main(String[] args) throws IOException, NoSuchFieldException, IllegalAccessException {
    Integer i = 6;
    Class<Integer> clazz = Integer.class;
    java.lang.reflect.Field field = clazz.getDeclaredField("value");
    field.setAccessible(true);
    field.set(i,1);
    System.out.println(field.get(i)); // 输出:1
    System.out.println(i); // 输出:1
}

原子类 AtomicInteger AtomicLong 为什么是可变的?

原子类是为了保证对共享变量的单次操作是原子性操作,且通过 volatile 关键字保证了操作的可见性和有序性,从而保证该变量的线程安全,其实质上需要变量是可变的。

而如果共享变量是不可变的,那么该共享变量就无法被修改,也就不需要考虑原子性问题。

可能不太恰当的比喻就是,交警为了十字路口不会出现塞车,他可以直接把十字路口封了(不可变),也可以通过指挥交通使道路通畅(原子类)。

绝对线程安全

其实 Java API 中标注自己是线程安全的类,大多数都不是绝对的线程安全,因为付出的代价会非常高昂而显得不切实际。

那么这个“绝对”有多绝对呢,“相对”又有多相对呢?

以 Vector 容器为例,Vector 类中的方法基本上都用 synchronized 关键字修饰,如 add()get()size()synchronized 修饰的方法可以被认为是线程安全的。

但是,如果一个方法使用到了同个对象的多个线程安全的方法,那这个方法是线程安全的吗?那这就不一定了。例如以下代码

private static Vector<Integer> vector = new Vector<Integer>();
public static void main(String[] args) {
    while (true) {
        
        for (int i = 0; i < 10; i++) {
            vector.add(i);
        }

        Thread removeThread = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < vector.size(); i++) {
                    vector.remove(i);
                }
            }
        });
        
        Thread printThread = new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < vector.size(); i++) {
                    System.out.println((vector.get(i)));
                }
            }
        });
        removeThread.start();
        printThread.start();
        
        //不要同时产生过多的线程,否则会导致操作系统假死
        while (Thread.activeCount() > 20);
    }
}

运行结果如下:

Exception in thread "Thread-132" java.lang.ArrayIndexOutOfBoundsException:Array index out of range: 17
    at java.util.Vector.remove(Vector.java:777)
    at org.fenixsoft.mulithread.VectorTest$1.run(VectorTest.java:21)
    at java.lang.Thread.run(Thread.java:662)

从运行结果中我们可以知道,这是索引越界导致的异常,之所以会越界,是因为有的线程持有的索引 i 对应的元素被其他元素删除了,导致索引 i 不可用。

如果要保证这段代码的正常运行,就需要手动添加一些同步措施,例如在操作容器前对容器加锁:

Thread removeThread = new Thread(new Runnable() {
    @Override
    public void run() {
        synchronized (vector) {
            for (int i = 0; i < vector.size(); i++) {
                vector.remove(i);
            }
        }
    }
});

Thread printThread = new Thread(new Runnable() {
    @Override
    public void run() {
        synchronized (vector) {
            for (int i = 0; i < vector.size(); i++) {
                System.out.println((vector.get(i)));
            }
        }
    }
});

但是这样的话,这两个线程就相当于在串行执行,在执行效率上肯定就比不过并发执行,这就是绝对线程安全所付出的代价。

相对线程安全

相对线程安全就是我们通常提到的线程安全,它保证的是针对对象的单次操作是线程安全的,在进行调用时不需要进行额外的同步措施,但这些操作形成的序列在多线程环境中并无法保证对象的线程安全,需要在调用端进行额外的同步措施来保证。

在 Java 中绝大多数声称线程安全的类都属于相对线程安全,如:VectorConcurrentHashMapCopyOnWriteArrayList 等集合。

线程兼容

线程兼容是指对象本身不是线程安全的,但是可以通过调用端使用额外的同步措施保证对象在多线程环境中可以安全地被使用。我们通常说一个类不是线程安全的,就是说这个类是线程兼容的。

在 Java API 中大部分的类都是线程兼容的,如:ArrayListHashMap 等集合。

线程对立

线程对立指的是在调用端无论使用怎样的同步措施,都无法在多线程环境下安全使用的代码。由于 Java 天生就支持多线程的特性,线程对立这种排斥多线程的代码很少出现,而且通常都是有害的,应该尽量避免。

一个例子就是 Thread 类的 suspend() resume() 方法,如果两个线程同时持有一个线程对象,一个尝试去中断线程,一个尝试去恢复线程,在并发进行的情况下,无论调用时是否进行了同步,目标线程都存在死锁风险——假如suspend()中断的线程就是即将要执行resume()的那个线程,那就肯定要产生死锁了。也正是这个原因,suspend()resume()方法都已经被声明废弃了。常见的线程对立的操作还有System.setIn()Sytem.setOut()System.runFinalizersOnExit()等。

Java 实现线程安全的方式

虽然在 Java 中提供了很多线程安全的容器,但这些容器的实现也需要借助 Java 中提供的工具来实现线程安全,我们也可以借助这些工具来写出线程安全的对象。这些工具根据其使用的手段分为互斥同步和非阻塞同步。

互斥同步

互斥同步(Mutual Exclusion & Synchronization)是一种最常见也是最主要的并发正确性保障手段,它的思想是通过确保同一时刻只有一个线程可以访问临界资源来保证线程安全。

1. synchronized

在 Java 中,最基本的互斥同步手段就是 synchronized 关键字,这是一种针对代码块的同步语法。

synchronized 关键字在被 Javac 编译后,会在代码块前后分别形成 monitorentermonitorexit 这两个字节码指令,这两个字节码指令都需要一个 reference 类型的参数来指定要锁定和解锁的对象。这个对象可以在代码中指定,也可以根据 synchronized 修饰的方法类型来指定,如果是实例方法则为当前对象实例 this,如果是类方法则为当前类的 Class 对象。

具体来说,在执行 monitorenter 指令时,会先尝试获取指定对象的锁,如果该对象没有被其他线程锁定,或者当前线程已经持有了该对象的锁,则把该对象的锁计数器的值加一;而在执行 monitorexit 指令时,会将该对象的锁计数器的值减一,如果计数器的值变为零,则把锁释放掉。

如果在执行 monitorenter 指令时,获取锁失败,那么该线程会被阻塞等待,直到请求的对象锁被其他线程释放。

由此可以得知 synchronized 关键字有以下两个特性:

  1. 可重入:同一线程可以重复进入其已经获得锁的代码块
  2. 不可中断:线程获取锁的过程即使一直阻塞等待,也无法主动中断等待

synchronized 锁一开始出现时又被称为重量级锁,因为 Java 线程是映射到操作系统的原生内核线程之上的,要对线程进行操作就不可避免地需要进行用户态和内核态的转换,这种转化所需要的 CPU 时间成本很大,甚至可能大于获得锁后同步代码块的执行成本。

因此,JVM 也对其进行了一些优化,例如在线程获取锁失败后,不会立即通知操作系统阻塞该线程,而是容许该线程自旋等待一段时间,以避免频繁切入内核态。后续有增加了偏向锁和轻量级锁这两种锁类型来降低获取锁的成本,使得锁操作的成本大大降低。

2. Lock

由于 synchronized 的局限性,Java 类库中提供了另一种全新的互斥同步手段,即java.util.concurrent.locks.Lock接口,它关注的粒度比代码块更小,使得 Lock 锁的实现十分灵活。

重入锁(ReentrantLock)是Lock接口最常见的一种实现,顾名思义,它与synchronized一样是可重入的。在基本用法上,ReentrantLock也与synchronized很相似,只是代码写法上稍有区别而已。不过,ReentrantLocksynchronized相比增加了一些高级功能:

  1. 等待可中断:正在等待锁的线程可以选择放弃等待,转而处理其他任务。
  2. 公平锁:公平锁指的是等待同一个锁中的线程按照申请锁的时间顺序依次获得锁,而非公平锁在锁释放后,所有等待中的锁都有机会获得锁。synchronized 是一种非公平锁,而 ReentrantLock 可以是公平锁或非公平锁。不过公平锁会导致性能下降,影响吞吐量。
  3. 锁可以绑定多个条件:一个ReentrantLock对象可以同时绑定多个Condition对象。在synchronized中,锁对象的wait()跟它的notify()或者notifyAll()方法配合可以实现一个隐含的条件,如果要和多于一个的条件关联的时候,就不得不额外添加一个锁;而ReentrantLock则无须这样做,多次调用newCondition()方法即可。

3. synchronized 和 Lock 的取舍

  1. 虽然 synchronized 是重量级锁,但在 JDK6 之后进行了大量优化,引入了偏向锁、轻量级锁等,大大提高了 synchronized 的性能,现在基本上和 Lock 的性能持平。
  2. synchronized 是在 Java 语法层面上实现的同步,编码上相对清晰简单。而 Lock 接口相对比较复杂。如果只是需要简单的同步功能,更推荐 synchronized。
  3. synchronized 可以自动释放锁,而 Lock 接口需要程序员自己在 finally 块中释放锁。
  4. Lock 接口实现的锁粒度更细,功能更丰富,实现更灵活,在一些复杂的同步场景下,更推荐使用 Lock 接口。

非阻塞同步

互斥同步面临的主要问题是,进行线程阻塞和唤醒所带来的性能开销,因此这种同步也被称为阻塞同步。归根结底,互斥同步是一个比较悲观的并发策略,它总认为共享资源的竞争是无时不在的,因此所有对共享资源的操作都必须加锁,而这个过程会带来很多开销,如用户态到内核态的转换和锁计数器的维护。

那么可以采取一些乐观的策略吗?

当然可以,我们可以先不管三七二十一,先对共享数据进行操作,再判断操作期间是否有其他线程竞争,如果没有就操作成功,否则再采取一些补救措施,如进行不断地重试,直到没有其他线程介入为止。

这种策略不需要锁,也就不需要阻塞线程。

但是这种策略需要硬件指令集的支持,因为数据操作和数据竞争检测,这两个步骤需要具备原子性。如果不使用互斥同步来实现原子性,那么只能靠硬件来实现了。

如果这里再使用互斥同步来保证就完全失去意义了,所以我们只能靠硬件来实现这件事情,硬件保证某些从语义上看起来需要多次操作的行为可以只通过一条处理器指令就能完成,这类指令常用的有:

  • 测试并设置(Test-and-Set);
  • 获取并增加(Fetch-and-Increment);
  • 交换(Swap);
  • 比较并交换(Compare-and-Swap,下文称CAS);
  • 加载链接/条件储存(Load-Linked/Store-Conditional,下文称LL/SC)。

而在 Java 中,最终暴露出来的是 CAS 操作,我们只讨论 CAS 指令。

在 Java 中,可以调用 Unsafe 对象的方法来进行 CAS 操作,其会在更新一个变量的值之前,通过变量实际的内存地址获取变量在物理内存中的实际值,对比更新前保留的值是否发生了变化,如果没有发生变化,说明没有其他线程在当前变量更新期间操作这个值,就将变量更新后的值保存到物理内存中,并向调用者返回成功;如果发生了变化,则返回失败。

调用者在调用 CAS 操作失败后,可以选择继续使用 CAS 操作更新值,或者转而执行其他任务。

这样就能减少加锁带来的成本,在读多写少的场景十分适合使用。

总结

  1. 当多个线程同时访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那就称这个对象是线程安全的。
  2. Java 中的线程安全并非绝对的线程安全,而是基于性能考虑被分成了五个等级,来适用不同开发者的需求。
  3. Java 中实现线程安全的手段有互斥同步和非阻塞同步,互斥同步通过加锁来完成,非阻塞同步通过 CAS 操作来完成。