目录
线程安全
线程安全是指:运行结果是确定的。
导致不确定的主要源头是:可见性问题,有序性问题,原子性问题。
可见性问题
可见性:一个线程对共享变量的修改,另外一个线程能够立刻看到。
可见性问题由CPU缓存导致;
CPU缓存模型:
解决方案:根据程序逻辑,按需禁用缓存。
具体方法:
volatile、synchronized 和 final 三个关键字,以及六项 Happens-Before 规则。
volatile:告诉编译器,对这个变量的读写,不能使用CPU缓存,必须从内存中读取或者写入。
Happens-Before规则
Happens-Before:前面一个操作的结果对后续操作是可见的。
Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器优化后一定
遵守 Happens-Before 规则。
1. 程序的顺序性规则
按照程序顺序,前面的操作 Happens-Before 于后续的任意操作。
2. volatile 变量规则
这条规则是指对一个 volatile 变量的写操作, Happens-Before 于后续对这个 volatile 变量的
读操作。
3. 传递性
A Happens-Before B,且B Happens-Before C,那么A Happens-Before C。
2和3可以组合使用;
4. 管程中锁的规则
指对一个锁的解锁 Happens-Before 于后续对这个锁的加锁。
synchronized 是 Java 里对管程的实现。
管程中的锁在 Java 里是隐式实现的,在进入同步块之前,会自动加锁,而在代码块执行完会
自动释放锁,加锁以及释放锁都是编译器帮我们实现的。
5. 线程 start() 规则
主线程 A 启动子线程 B 后,子线程 B 能够看到主线程在启动子线程 B 前的操作。
6. 线程 join() 规则
主线程 A 等待子线程 B 完成(主线程 A 通过调用子线程 B 的 join() 方法实现),当子线程 B
完成后(主线程 A 中 join() 方法返回),主线程能够看到子线程的操作。当然所谓的“看到”,指的
是对共享变量的操作。
final修饰符
final修饰变量,表示变量不会发生变化,编译器可以充分优化;
不发生变化的变量,不存在写操作,所以不存在不可见性;
Synchronized
对公用资源加锁,加上Happens-Before约束也可以解决可见性问题;
有序性问题
有序性问题由编译优化导致;
解决方案:根据程序逻辑,按需禁用编译优化。
主要靠Happens-Before约束完成。
原子性问题
原子性:一个或者多个操作在 CPU 执行的过程中不被中断;
原子性问题由线程切换导致;
解决方案:解决原子性问题,是要保证中间状态对外不可见。通过互斥锁,让一块代码区域
只有一个线程能够执行。
具体方法:synchronized,并发包中的其他锁;
保护多个资源
如果资源之间没有关系,每个资源一把锁就可以了。
如果资源之间有关联关系,就要选择一个粒度更大的锁,这个锁应该能够覆盖所有相关的资源。
还要梳理出有哪些访问路径,所有的访问路径都要设置合适的锁。
细粒度锁的死锁问题
使用细粒度锁可以提高并行度,是性能优化的一个重要手段,但是会导致死锁。
死锁:多个线程因竞争资源互相等待,导致永久阻塞的现象。
下列四个条件都发生时才会出现死锁:
互斥,共享资源 X 和 Y 只能被一个线程占用;
占有且等待,线程 T1 已经取得共享资源 X,在等待共享资源 Y 的时候,不释放共享资源 X;
不可抢占,其他线程不能强行抢占线程 T1 占有的资源;
循环等待,线程 T1 等待线程 T2 占有的资源,线程 T2 等待线程 T1 占有的资源,就是循环等待。
破坏其中一个,就可以成功避免死锁的发生。
解决方案:
互斥这个条件我们没有办法破坏,因为用锁为的就是互斥。
1,占用且等待:一次性申请所有的资源,就不存在相互等待了;需要设置一个管理类来统一
获,取或者释放资源。
管理类统一获取多个资源时,在获取部分资源后,需要等待剩余资源,在等待的过程中:可
以通过循环来尝试获取剩余资源;
简单场景下,使用循环等待尝试获取资源即可;
复杂场景下,或者并发冲突量大的时候,循环等待这种方案就不适用了,可能要循环上万次
才能获取到锁,太消耗 CPU 了。
优化方案:如果线程要求的条件不满足,则线程阻塞自己,进入等待状态;当线程要求的条
件满足后,通知等待的线程重新执行。
2,不可抢占:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它
占有的资源,这样不可抢占这个条件就破坏掉了。synchronized不支持,并发包中的Lock支持上述
方案。
3,循环等待:按序申请资源来预防;
按序申请,资源在全局是有线性顺序的,申请的时候所有线程都按统一的顺序申请,这样线
性化后就不存在循环了。
评估操作成本,从中选择合适的方案。
活跃性问题
影响线程运行的除了死锁,还有活锁,饥饿。
死锁:一直等待下去;解决方案:打破死锁的条件。
活锁:一直没完没了地“谦让”下去;解决方案:随机等待。
饥饿:在CPU繁忙的情况下,优先级低的线程得到执行的机会很小,就可能发生线程“饥饿”;
持有锁的线程,如果执行的时间过长,也可能导致“饥饿”问题;
解决方案:
1,保证资源充足;
2,公平地分配资源,使用公平锁(主要方案);
3,是避免持有锁的线程长时间执行;
公平锁:一种先来后到的方案,线程的等待是有顺序的,排在等待队列前面的线程会优先获
得资源。
常见线程安全问题
数据竞争
当多个线程同时访问同一数据,并且至少有一个线程会写这个数据的时候,如果我们不采取
防护措施,那么就会导致并发 Bug。
竞态条件
对共享变量的单一读写操作,是线程安全的,但是组合操作,会出现结果不确定的问题;
例如:
变量的set()方法和get()方法,都是线程安全的;
但是set(get()+1)组合操作,不是线程安全的,一个线程执行set(get()+1)时,另一线程可能在
执行set方法,导致这个get方法获取的值不确定;
解决方法:get和set方法用同一把锁。
数据安全问题的解决方案
资源分配(互斥)篇章中提到的方法:
1,管程中的互斥锁;
2,无锁方案(不变模式,写时复制模式,线程本地变量模式,CAS和原子类);