0 前言
究竟什么是线程安全? 简单的说, 如果你的代码在多线程下执行和在单线程下执行永远都能获得一样的结果, 那么你的代码就是线程安全的. 那么, 当进行多线程编程时, 我们又会面临哪些线程安全的问题呢? 又是要如何去解决的呢?
1 线程安全特性
1.1 原子性
跟数据库事务的原子性概念差不多, 即一个操作 (有可能包含有多个子操作) 要么全部执行(生效), 要么全部都不执行(都不生效).
关于原子性, 一个非常经典的例子就是银行转账问题:
比如: A 和 B 同时向 C 转账 10 万元. 如果转账操作不具有原子性, A 在向 C 转账时, 读取了 C 的余额为 20 万, 然后加上转账的 10 万, 计算出此时应该有 30 万, 但还未来及将 30 万写回 C 的账户, 此时 B 的转账请求过来了, B 发现 C 的余额为 20 万, 然后将其加 10 万并写回. 然后 A 的转账操作继续 -- 将 30 万写回 C 的余额. 这种情况下 C 的最终余额为 30 万, 而非预期的 40 万.
1.2 可见性
可见性是指, 当多个线程并发访问共享变量时, 一个线程对共享变量的修改, 其它线程能够立即看到. 可见性问题是好多人忽略或者理解错误的一点.
CPU 从主内存中读数据的效率相对来说不高, 现在主流的计算机中, 都有几级缓存. 每个线程读取共享变量时, 都会将该变量加载进其对应 CPU 的高速缓存里, 修改该变量后, CPU 会立即更新该缓存, 但并不一定会立即将其写回主内存 (实际上写回主内存的时间不可预期). 此时其它线程(尤其是不在同一个 CPU 上执行的线程) 访问该变量时, 从主内存中读到的就是旧的数据, 而非第一个线程更新后的数据.
这一点是操作系统或者说是硬件层面的机制, 所以很多应用开发人员经常会忽略.
1.3 有序性
有序性指的是, 程序执行的顺序按照代码的先后顺序执行. 以下面这段代码为例:
- boolean started = false; // 语句 1
- long counter = 0L; // 语句 2
- counter = 1; // 语句 3
- started = true; // 语句 4
从代码顺序上看, 上面四条语句应该依次执行, 但实际上 JVM 真正在执行这段代码时, 并不保证它们一定完全按照此顺序执行.
处理器为了提高程序整体的执行效率, 可能会对代码进行优化, 其中的一项优化方式就是调整代码顺序, 按照更高效的顺序执行代码.
讲到这里, 有人要着急了 -- 什么, CPU 不按照我的代码顺序执行代码, 那怎么保证得到我们想要的效果呢? 实际上, 大家大可放心, CPU 虽然并不保证完全按照代码顺序执行, 但它会保证程序最终的执行结果和代码顺序执行时的结果一致.
2 线程安全问题
2.1 竞态条件与临界区
线程之间共享堆空间, 在编程的时候就要格外注意避免竞态条件. 危险在于多个线程同时访问相同的资源并进行读写操作. 当其中一个线程需要根据某个变量的状态来相应执行某个操作的之前, 该变量很可能已经被其它线程修改.
也就是说, 当两个线程竞争同一资源时, 如果对资源的访问顺序敏感, 就称存在 竞态条件. 导致竟态条件发生的代码称作 临界区.
- /**
- * 以下这段代码就存在竞态条件, 其中 return ++count 就是临界区.
- */
- public class Obj
- {
- private int count;
- public int incr()
- {
- return ++count;
- }
- }
2.2 死锁
死锁: 指两个或两个以上的进程 (或线程) 在执行过程中, 因争夺资源而造成的一种互相等待的现象, 若无外力作用, 它们都将无法推进下去. 此时称系统处于死锁状态或系统产生了死锁, 这些永远在互相等待的进程称为死锁进程.
关于死锁发生的条件:
互斥条件: 线程对资源的访问是排他性的, 如果一个线程对占用了某资源, 那么其他线程必须处于等待状态, 直到资源被释放.
请求和保持条件: 线程 T1 至少已经保持了一个资源 R1 占用, 但又提出对另一个资源 R2 请求, 而此时, 资源 R2 被其他线程 T2 占用, 于是该线程 T1 也必须等待, 但又对自己保持的资源 R1 不释放.
不剥夺条件: 线程已获得的资源, 在未使用完之前, 不能被其他线程剥夺, 只能在使用完以后由自己释放.
环路等待条件: 在死锁发生时, 必然存在一个 "进程 - 资源环形链", 即:{p0,p1,p2,...pn}, 进程 p0(或线程)等待 p1 占用的资源, p1 等待 p2 占用的资源, pn 等待 p0 占用的资源.(最直观的理解是, p0 等待 p1 占用的资源, 而 p1 而在等待 p0 占用的资源, 于是两个进程就相互等待).
2.3 活锁
活锁: 是指线程 1 可以使用资源, 但它很礼貌, 让其他线程先使用资源, 线程 2 也可以使用资源, 但它很绅士, 也让其他线程先使用资源. 这样你让我, 我让你, 最后两个线程都无法使用资源.
关于 "死锁与活锁" 的比喻:
死锁: 迎面开来的汽车 A 和汽车 B 过马路, 汽车 A 得到了半条路的资源(满足死锁发生条件 1: 资源访问是排他性的, 我占了路你就不能上来, 除非你爬我头上去), 汽车 B 占了汽车 A 的另外半条路的资源, A 想过去必须请求另一半被 B 占用的道路(死锁发生条件 2: 必须整条车身的空间才能开过去, 我已经占了一半, 尼玛另一半的路被 B 占用了),B 若想过去也必须等待 A 让路, A 是辆兰博基尼, B 是开奇瑞 QQ 的屌丝, A 素质比较低开窗对 B 狂骂: 快给老子让开, B 很生气, 你妈逼的, 老子就不让(死锁发生条件 3: 在未使用完资源前, 不能被其他线程剥夺), 于是两者相互僵持一个都走不了(死锁发生条件 4: 环路等待条件), 而且导致整条道上的后续车辆也走不了.
活锁: 马路中间有条小桥, 只能容纳一辆车经过, 桥两头开来两辆车 A 和 B,A 比较礼貌, 示意 B 先过, B 也比较礼貌, 示意 A 先过, 结果两人一直谦让谁也过不去.
2.4 饥饿
饥饿: 是指如果线程 T1 占用了资源 R, 线程 T2 又请求封锁 R, 于是 T2 等待. T3 也请求资源 R, 当 T1 释放了 R 上的封锁后, 系统首先批准了 T3 的请求, T2 仍然等待. 然后 T4 又请求封锁 R, 当 T3 释放了 R 上的封锁之后, 系统又批准了 T4 的请求......,T2 可能永远等待.
关于 "饥饿" 的比喻:
在 "首堵" 北京的某一天, 天气阴沉, 空气中充斥着雾霾和地沟油的味道, 某个苦逼的临时工交警正在处理塞车, 有两条道 A 和 B 上都堵满了车辆, 其中 A 道堵的时间最长, B 相对堵的时间较短, 这时, 前面道路已疏通, 交警按照最佳分配原则, 示意 B 道上车辆先过, B 道路上过了一辆又一辆, A 道上排队时间最长的却没法通过, 只能等 B 道上没有车辆通过的时候再等交警发指令让 A 道依次通过, 这也就是 ReentrantLock 显示锁里提供的不公平锁机制(当然了, ReentrantLock 也提供了公平锁的机制, 由用户根据具体的使用场景而决定到底使用哪种锁策略), 不公平锁能够提高吞吐量但不可避免的会造成某些线程的饥饿.
3 如何确保线程安全特性
3.1 如何确保原子性
3.1.1 锁和同步
常用的保证 Java 操作原子性的工具是 锁和同步方法(或者同步代码块). 使用锁, 可以保证同一时间只有一个线程能拿到锁, 也就保证了同一时间只有一个线程能执行申请锁和释放锁之间的代码.
- public void testLock () {
- lock.lock();
- try{
- int j = i;
- i = j + 1;
- } finally {
- lock.unlock();
- }
- }
与锁类似的是同步方法或者同步代码块. 使用非静态同步方法时, 锁住的是当前实例; 使用静态同步方法时, 锁住的是该类的 Class 对象; 使用静态代码块时, 锁住的是 synchronized 关键字后面括号内的对象. 下面是同步代码块示例:
- public void testLock () {
- synchronized (anyObject){
- int j = i;
- i = j + 1;
- }
- }
无论使用锁还是 synchronized, 本质都是一样, 通过锁或同步来实现资源的排它性, 从而实际目标代码段同一时间只会被一个线程执行, 进而保证了目标代码段的原子性. 这是一种以牺牲性能为代价的方法.
3.1.2 CAS(compare and swap)
基础类型变量自增 (i++) 是一种常被新手误以为是原子操作而实际不是的操作. Java 中提供了对应的原子操作类来实现该操作, 并保证原子性, 其本质是利用了 CPU 级别的 CAS 指令. 由于是 CPU 级别的指令, 其开销比需要操作系统参与的锁的开销小. AtomicInteger 使用方法如下:
- AtomicInteger atomicInteger = new AtomicInteger();
- for(int b = 0; b <numThreads; b++) {
- new Thread(() -> {
- for(int a = 0; a <iteration; a++) {
- atomicInteger.incrementAndGet();
- }
- }).start();
- }
3.2 如何确保可见性
Java 提供了 volatile 关键字来保证可见性. 当使用 volatile 修饰某个变量时, 它会保证对该变量的修改会立即被更新到内存中, 并且将其它线程缓存中对该变量的缓存设置成无效, 因此其它线程需要读取该值时必须从主内存中读取, 从而得到最新的值.
volatile 适用场景: volatile 适用于不需要保证原子性, 但却需要保证可见性的场景. 一种典型的使用场景是用它修饰用于停止线程的状态标记. 如下所示:
- boolean isRunning = false;
- public void start () {
- new Thread( () -> {
- while(isRunning) {
- someOperation();
- }
- }).start();
- }
- public void stop () {
- isRunning = false;
- }
在这种实现方式下, 即使其它线程通过调用 stop()方法将 isRunning 设置为 false, 循环也不一定会立即结束. 可以通过 volatile 关键字, 保证 while 循环及时得到 isRunning 最新的状态从而及时停止循环, 结束线程.
3.3 如何确保有序性
上文讲过编译器和处理器对指令进行重新排序时, 会保证重新排序后的执行结果和代码顺序执行的结果一致, 所以重新排序过程并不会影响单线程程序的执行, 却可能影响多线程程序并发执行的正确性.
Java 中可通过 volatile 在一定程序上保证顺序性, 另外还可以通过 synchronized 和锁来保证顺序性.
synchronized 和锁保证顺序性的原理和保证原子性一样, 都是通过保证同一时间只会有一个线程执行目标代码段来实现的.
除了从应用层面保证目标代码段执行的顺序性外, JVM 还通过被称为 happens-before 原则隐式地保证顺序性. 两个操作的执行顺序只要可以通过 happens-before 推导出来, 则 JVM 会保证其顺序性, 反之 JVM 对其顺序性不作任何保证, 可对其进行任意必要的重新排序以获取高效率.
happens-before 原则(先行发生原则), 如下:
传递规则: 如果操作 1 在操作 2 前面, 而操作 2 在操作 3 前面, 则操作 1 肯定会在操作 3 前发生. 该规则说明了 happens-before 原则具有传递性.
锁定规则: 一个 unlock 操作肯定会在后面对同一个锁的 lock 操作前发生. 这个很好理解, 锁只有被释放了才会被再次获取.
volatile 变量规则: 对一个被 volatile 修饰的写操作先发生于后面对该变量的读操作.
程序次序规则: 一个线程内, 按照代码顺序执行.
线程启动规则: Thread 对象的 start()方法先发生于此线程的其它动作.
线程终结原则: 线程的终止检测后发生于线程中其它的所有操作.
线程中断规则: 对线程 interrupt()方法的调用先发生于对该中断异常的获取.
对象终结规则: 一个对象构造先于它的 finalize 发生.
4 关于线程安全的几个为什么
平时项目中使用锁和 synchronized 比较多, 而很少使用 volatile, 难道就没有保证可见性?
锁和 synchronized 即可以保证原子性, 也可以保证可见性. 都是通过保证同一时间只有一个线程执行目标代码段来实现的.
锁和 synchronized 为何能保证可见性?
根据 JDK 7 的 Java doc http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/package-summary.html#MemoryVisibility 中对 concurrent 包的说明, 一个线程的写结果保证对另外线程的读操作可见, 只要该写操作可以由 happen-before 原则推断出在读操作之前发生.
既然锁和 synchronized 即可保证原子性也可保证可见性, 为何还需要 volatile?
synchronized 和锁需要通过操作系统来仲裁谁获得锁, 开销比较高, 而 volatile 开销小很多. 因此在只需要保证可见性的条件下, 使用 volatile 的性能要比使用锁和 synchronized 高得多.
既然锁和 synchronized 可以保证原子性, 为什么还需要 AtomicInteger 这种的类来保证原子操作?
锁和 synchronized 需要通过操作系统来仲裁谁获得锁, 开销比较高, 而 AtomicInteger 是通过 CPU 级的 CAS 操作来保证原子性, 开销比较小. 所以使用 AtomicInteger 的目的还是为了提高性能.
还有没有别的办法保证线程安全?
有. 尽可能避免引起非线程安全的条件 -- 共享变量. 如果能从设计上避免共享变量的使用, 即可避免非线程安全的发生, 也就无须通过锁或者 synchronized 以及 volatile 解决原子性, 可见性和顺序性的问题.
synchronized 即可修饰非静态方式, 也可修饰静态方法, 还可修饰代码块, 有何区别?
synchronized 修饰非静态同步方法时, 锁住的是当前实例; synchronized 修饰静态同步方法时, 锁住的是该类的 Class 对象; synchronized 修饰静态代码块时, 锁住的是 synchronized 关键字后面括号内的对象.
来源: https://juejin.im/post/5b35af3151882574aa5f69c5