Java「线程安全」思考与梳理
前言
本文着重讲述「为什么」,主要是自己对「线程安全」相关问题的思考和梳理。
至于文章中涉及到的众多「并发编程」基础知识点、专有名词,网上一搜便是,就不在这当搬运工了。
所以,借用宋宝华前辈的一句话:
本文适合:已经读了很多「线程安全」的资料,但是没打通脉络的读者;
本文不适合:
完全不知道
「线程安全」是什么的读者,和完全知道
「线程安全」是什么的读者
0、理清概念 --「线程安全」
说起并发编程,要注意“线程安全”已经是老生常谈。
但“线程安全”的准确定义,却少有看见满意的答案。倒不是因为这个问题有多难,而是给它下的定义很难兼顾到“准确性”和“接地气”这两方面。
先看 wiki中文版 的解释:
wiki中文版 强调“公用变量”,但“代码块/方法”的「原子性」也需要正确处理。
再看 百度百科 的解释:
其实我觉得这已经描述得不错了😂非要扣细节的话,就是「并行」「并发」这里用得不严谨。 「并发」(包括CPU只有1个物理核心的情况)也可能有「线程安全」问题。
可以看到,wiki相对“接地气”一点,但不全面。百度百科挺准确的,但抽象程度较高。
这里,我尽量保证“准确性”,给「线程安全」的定义是:
在多线程环境下,多次调用一段代码,都能给出正确的执行结果。不会因 线程调度、公用变量一致性、指令重排 等因素而造成错误结果。
"线程调度、公用变量一致性、指令重排" 其实就是对应于「原子性」、「可见性」、「有序性」,前者是后者的 成因。
1、如何保证代码是「线程安全」的?
需考虑三点:
「原子性」、「可见性」、「有序性」
其中:
对于「一段代码」:需要考虑「原子性」、「有序性」
对于「变量」:需要考虑「可见性」
特殊地,long 和 double 的赋值,要考虑是否原子操作:
JVM规范定义了几种原子操作:
- 基本类型(long 和 double 除外)赋值,例如:int n = m;
- 引用类型赋值,例如:List<String> list = anotherList 。
long 和 double 是64位数据,JVM 没有明确规定64位赋值操作是不是一个原子操作,不过在x64平台的JVM是把 long 和double的赋值作为原子操作实现的。
既然JVM没有给保证,为了保险一点,建议:对于多线程下操作 long 和 double, 可以借助 java.util.concurrent.atomic.* 的类,例如 AtomicLong
简单介绍这三个元素
「原子性」 (Atomicity):
定义:
一段代码、一段指令,必须是 同一个线程 执行完后,其他线程才能进入执行。
也就是
一段代码(代码块、方法Method)
是 互斥地 执行的。
例子:
最经典的莫过于“银行转账问题”。
比如A和B同时向C转账10万元。如果转账操作不具有原子性,A在向C转账时,读取了C的余额为20万,然后加上转账的10万,计算出此时应该有30万,但还未来及将30万写回C的账户,此时B的转账请求过来了,B发现C的余额为20万,然后将其加10万并写回。然后A的转账操作继续——将30万写回C的余额。这种情况下C的最终余额为30万,而非预期的40万。
「可见性」 (Visibility):
定义:
一个线程对
公共变量
的修改,其他线程能够正确读取到「修改后」的值。
例子 (引用别人写的优秀例子):
//实现累加的功能
public class Counter {
volatile int i = 0;
public void add() {
i++;
}
}
//累加器的测试类
public class CounterTest {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
for (int i = 0; i < 8; i++) {
new Thread(() -> {
for (int j = 0; j < 10000; j++) {
counter.add();
}
System.out.println("done...");
}).start();
}
Thread.sleep(6000L);
//期望的结果是:i*j 为循环次数 = 80000
System.out.println(counter.i);
}
}
输出的结果:
done...
done...
done...
done...
done...
done...
done...
done...
27661
执行的结果基本每次都不同,反正都是远小于预期值80000
「有序性」 (Ordering):
定义:
程序按照 「代码的先后」 顺序执行
这句话乍听上去好像有点”无厘头“😂,程序不就是按照「代码的先后」顺序执行的吗??
在单线程下,可以这么认为。但在多线程下,不一定,因为可能会经过「指令重排」而改变执行顺序。
在单线程,JVM会给你保证:单线程程序,即使经过「指令重排」,也能保证不会影响执行结果 -- 这就是所谓的「as-if-series 语义」
。as-if-series,就是字面意思:“仿佛是顺序(执行)的”
也许是我们都太习惯单线程环境的「有序性」了,仿佛是理所当然,所以,多线程的「有序性」反而是最容易忽略的。
例子1:
这是《Android 源码设计模式解析与实战》一书中,单例模式里,介绍的 Double check lock(DCL) 写法 (在没有加 volatile
的情况下)
public static Singleton instance;
public static Singleton getInstance() {// 加同步锁,通过该函数向整个系统提供实例
if (null == instance) {//第一次判断,当 instance 为 null 时,则实例化对款,否则直接返回对象
synchronized (Singleton.class) {//同步锁
if (null == instance) {// 第二次判断
instance = new Singleton();// 实例化对象
}
}
}
return instance;//返回已存在的对象
}
第一次判空是为了性能,后续减少进入synchronized锁;第二次判空是进入synchronized锁后,进行的严格判断。这么写,看似性能
和安全性
都无懈可击了?
但是它不保证 「有序性」!!
以下是详细分析:
instance = new Singleton()这段代码会编译成多条指令,大致上做了3件事:
(1)给Singleton实例分配内存
(2)调用Singleton()构造函数,初始化成员宇段
(3)将instance对象指向分配的内存(此时instance就不是null啦~)
上面的
(2) 和(3) 的顺序无法得到保证的
,也就是说,JVM可能先初始化实例字段,再把instance指向具体的内存实例;也可能先把instance指向内存实例,再对实例进行初始化成员字段
考虑这种情况:
一开始,第一个线程执行 instance = new Singleton( );
JVM先指向一个堆地址;
而此时,又来了一个线程2,它发现instance不是null,就直接拿去用了,但是堆里面对单例对象的初始化井没有完成,最终出现错误~
解决方法:instance变量加上 volatile
关键字。因为,volatile不仅保证「可见性」,还能通过禁止「指令重排」来保证「有序性」。
PS:看过非常多文章,他们基本都没搞懂这例子中的「有序性」问题,因为他们强调“在这里加 volatile 能解决这个问题”是因为 “volatile保证instance每次都从主内存中读取” -- 这分明说的是「可见性」的问题嘛。
后来重温《Android源码设计模式解析与实战》才知道,这是书中原话,所以这个误解才流传甚广
例子2:
public class Reordering {
private static boolean flag;
private static int num;
public static void main(String[] args) {
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
while (!flag) {
Thread.yield();
}
System.out.println(num);
}
}, "t1");
t1.start();
num = 5; ①
flag = true; ②
}
}
以上代码最终输出的值正常情况下是 5,但如果上述 ① ,② 两行指令发生重排序,那么结果是有可能为 0 的,从而导致我们观察到的数据不一致的现象发生
2、 「线程安全」这个问题,是如何产生的?
虽说,JVM的一个重要意义是「屏蔽具体硬件差异」。但如果真的不稍微了解一下,就直接去看所谓的「原子性」、「可见性」、「有序性」的详细特性、看他们的解决方案,肯定会感觉干巴巴的,彷如空中楼阁。即使勉强看完了,其实也不能真的吸收理解。
所以,了解「线程安全」的物理成因(也就是「原子性」、「可见性」、「有序性」各自的物理成因),意义还是很大的。
先说核心:
线程调度
导致的 「原子性」 问题
CPU高速缓存
导致的 「可见性」 问题
指令重排
导致的 「有序性」 问题
2.1、线程调度
导致的 「原子性」 问题
“线程是最小的调度单位”。并且这种调度是“不可控”的,执行中的线程随时被切换掉(即使方法Method执行到一半)。
2.2、CPU高速缓存
导致的 「可见性」 问题:
JMM(Java Memory Model) Java内存模型:
实际上的CPU高速缓存
JMM(Java Memory Model)中的「线程私有的本地内存」,就是物理上的 「CPU高速缓存」(通常是指L1级L2级。L3级通常是多个CPU核心共享的)。
多线程的情况下线程并不一定是在同一个CPU上执行,它们如果
同时操作
一个共享变量,但因为在不同的CPU执行所以他们只能查看和更新自己CPU缓存里的变量值,线程各自的执行结果对于别的线程来说是不可见的
,所以在并发的情况下会因为这种缓存不可见的情况会导致问题出现。
2.3、指令重排
导致的 「有序性」 问题
指令重排 分为三种
编译期重排:编译源代码时,编译器依据对上下文的分析,对指令进行重排序,以之更适合于CPU的并行执行。
运行期重排:CPU在执行过程中,动态分析依赖部件的效能,对指令做重排序优化。
内存重排:程序执行一段代码,写一个普通的共享变量,其可能先被写到缓冲区然后再被写到主内存,此时指令完成的时间就被推迟了。实际表现就是内存重排。
「as-if-serial」语义
保证了单线程内的「有序性」。但在多线程环境,则只有「happens-before」
来保证特定场景的「有序性」了
as-if:“好像” 的意思。
serial:“顺序的” 的意思。
也就是字面意思,“看起来好像是 顺序执行的"
「as-if-serial」语义
的意义:对程序员保证:不管怎么重排序,单个线程内的执行结果不会被改变
使程序员不必担心单个线程中重排序的问题
3、为什么要让开发者来处理「线程安全」,JVM来保证「线程安全」不行吗?
总的来说,之所以导致「线程安全」问题,都是因为一些“取巧”手段的极限优化。
线程本质上是增加并行的任务数量来提升CPU的利用率(导致「原子性」),
缓存是通过把IO时间减少来提升CPU的利用率(导致「可见性」),
而指令顺序优化的初衷就是想通过调整CPU指令的执行顺序和异步化的操作,来提升CPU执行指令任务的效率(导致「有序性」)。
这些极限优化手段收益巨大,即使在多线程下会有「线程安全」问题,也值得去做。
而开发者需要做的,是根据具体业务逻辑
,对关键逻辑代码
保证其「线程安全」。
具体来说:
保证「原子性」就是说:线程的概念很好,但这段代码不要搞并发执行了,降低点任务并发量(不是从更底层禁止CPU线程调度
,只是说即使发生线程切换,也不能执行改段代码)
保证「可见性」就是说:缓存很好,但缓存就会带来缓存一致性的问题。这个变量太核心太重要了,这个变量的最新值要马上写回主存,其他线程的副本也要马上失效,其他线程要读的话请以主存为准
。
保证「有序性」就是说:指令优化很好,真的。但你只对单线程内
有「as-if-series」的完全保证,对我多线程之间
却只有「happens-bofore」几种场景的保证。所以,对于不能被重排序的代码,我们开发者只能运用「happens-bofore」里的规则(包括synchronized、volatile)
去告诉编译器和硬件:这些指令不要进行指令重排,给我添幺蛾子哦。
额外延伸:不是已经有MESI协议
在CPU底层来保证「共享变量」在多核心的数据一致性了吗?为什么上层的Java层面还会有「可见性」问题?为什么还需要定义 volatile 关键字去解决这个问题呢?是多此一举吗?
这个问题困扰了我一段时间,甚至搜索出来的答案也感觉晦涩难懂。大多数答案是在说“MESI协议是底层硬件协议,volatile是Java语言的保证,底层硬件协议并不足够
保证 Java语言层面的「可见性」。因为中间还有编译、指令重排等步骤”。好像好有道理,但说实话,还是比较抽象。
最后看到一个很好的答案:
1、缓存可见性的问题: 解决缓存可见性问题,本质上是要解决一个CPU修改了数据如何让其他CPU知道,然后多个CPU同时修改缓存数据如何保证他们操作的有序性。
2、通过总线保证一致性:通过总线嗅探机制,一个CPU修改了缓存其它CPU会收到对应的通知,从而解决了一个CPU修改了数据其他CPU不知道的问题,通过总线仲裁来保证多个CPU同时修改数据的顺序性,并且总线天然的独占性也保证了多个操作的互斥。
3、MESI协议: 因为通过总线来从主存读取数据的性能太慢,所以需要
减少通过总线
去读取主存的数据,尽量保证读取数据从自己或其他CPU缓存获得,修改数据尽量把多个操作合并为一个操作,所以MESI就通过对缓存数据的4个状态标记,来标识当前缓存行所处于的场景,针对不同的场景来执行不同的策略,最后达到缓存数据的一致性。4、MESI协议的优化:MESI在修改数据的时候必须先广播,然后等待其他所有CPU都把数据标记失效后,才能进行数据的修改操作,这个过程比较耗费时间,所以为了提升CPU的利用率,同时减少广播等待的时间,就增加了store buffer 和失效队列来进行优化。
5、内存屏障: 虽然对于MESI优化,提升了整体的性能,但是同样也带来了一个问题,由原来的数据
强一致性变成了弱一致性
,从而导致在某些情况CPU缓存仍然会存在不一致的情况。所以就需要一种机制来手动的禁用
这种因为MESI优化带来的某些场景数据不一致的情况。 但是我们还是要乐观一点,因为绝大部分情况我们程序都不会存在问题,所以这种优化还是有意义的,这种个别的场景就需要我们的程序员来识别,然后通过内存屏障来保证数据的一致性。6、JMM: 因为内存屏障是操作系统级别的指令,而不同的操作系统,内存屏障的指令又不一样,为了避免程序员花费太多的精力在这些内存屏障指令上,所以Java就封装了
一套java的内存屏障
,把不同操作系统的指令都封装在内,对程序员暴露的是一套统一的指令规范。java的内存模型(JMM)中的规范-- 完整的解析过程:zhuanlan.zhihu.com/p/84500221
我尝试进一步提炼其「因果逻辑链」:
1.物理上多个CPU高速缓存,必定产生多份副本,进而有副本间数据一致性的问题
2.要统一协调多副本间数据,必定要有总线
3.完全靠总线锁,效率太低。于是有了MESI协议
4.
完全执行MESI协议,效率还是太低
。于是做了优化:用了一点准确性换取更好的性能。5.对于特定的
需要完全准确性的变量
,可以通过内存屏障来禁止优化
可以看到,这个解释的逻辑链条十分完整,一环扣一环。另外真心感叹,这么一小个技术点,真要认真探究,还是大有学问的。
4、Java中具体的「线程安全」实现手段
虽然从理论的角度来说,的确是需考虑「原子性」、「可见性」、「有序性」这三点。但这有点抽象。
如果从实际一点的“「线程安全」具体的实现手段”
这个角度来看,则有如下几点:
4.1、 加锁(管控
其他线程)
通过 「锁模型」、或者说 「管程模型」 来实现。
具体来说,又分两种方式:
1、synchronized关键字
2、Lock接口的实现类(如ReentrantLock)
「管程模型」
不说清楚「管程模型」,就不可能说清楚「锁模型」。毕竟「锁模型」 是「管程模型」的具体实现!
换句话,Java中的 synchronized关键字
和 Lock接口的实现类(如ReentrantLock)
都是基于管程模型的同步机制。
为什么会有「管程模型」:
Linux信号量机制
是一种IPC方式,是用来解决多进程间
的互斥同步的,本质上是一个并发安全的「数值」。但信号量作为IPC方式,用来解决
多线程
问题,实际中并不好用。于是有了对开发者更加友好的并发编程模型:管程
「管程模型」定义:
在Java并发编程中,管程模型(Monitor Model)是一种用于实现
并发访问共享资源
的同步机制。管程模型将共享资源封装在一个称为管程(Monitor)的对象中,通过限制对共享资源的访问来控制并发执行。管程模型主要由 两部分 组成:
互斥访问
和条件变量
。互斥访问:在管程中,共享资源只能通过互斥访问来防止多个线程同时修改数据。互斥访问是
通过在管程的入口和出口处设置互斥锁(Mutex)来实现的
。当一个线程进入管程时,它需要获取互斥锁,确保在管程内部对共享资源的独占访问。当线程离开管程时,它会释放互斥锁,允许其他线程进入管程。条件变量:条件变量用于协调多个线程之间的同步。当一个线程需要
等待某个条件满足
时,它可以使用条件变量阻塞自己,并释放互斥锁。当其他线程改变了条件并通知了条件变量后,阻塞的线程将被唤醒,并重新获取互斥锁进入管程。
上面是从抽象的角度去论述的。具体一点,比方说synchronized关键字,那么:
“互斥访问” 就是对应于:代码块/方法的入口和出口
“条件变量” 就是对应于:wait()、notify()/notifyAll()
「管程模型」意义:
「管程模型」是在信号量机制上进行改进的井发编程模型,解决了信号量在临界区的PV 操作上配对的麻烦,极大降低了使用和理解成本
。
说回这两种具体手段:
1.monitor lock/隐式锁/内置锁: synchronized
最常见,最易用,但灵活程度低点、可优化空间小
尽管JVM没有把(lock和unlock 原子操作指令) 开放给我们使用,但jvm以更高层次的字节码指令(monitorenter 和 monitorexit 指令) 开放给我们使用;在编译成字节码后,synchronized代码块的起始/结束处,就会插入 monitorenter/monitorexit 指令
。
2.显式锁:各种Lock实现类
Lock 是JUC包下的一个 接口。
实现类众多,满足不同场景不同需求。最常见的是ReentrantLock。
灵活程度高点、可优化空间大。
甚至,你可以自定义合适业务逻辑的Lock(高阶玩法),反正,JUC下各种Lock实现类也是用Java语言、依赖JDK来实现的嘛。
总的来说,synchronized锁、Lock实现类,都能保证「原子性」、「可见性」、「有序性」
。
「原子性」:由「管程模型」来保证
「可见性」、「有序性」:由「happens-before」中的“monitor规则”来保证。“monitor规则”适用于 synchronized锁、Lock实现类 两大类的锁。
值得一提:两种锁的实现原理完全不同。
甚至实现的层级也不同
:synchronized锁 需要JVM引擎实现,在每个对象的对象头中,存有synchronized锁的相关状态信息(所以也叫「对象锁」);
而Lock的实现是在JDK,是普通的Java代码,你可以很方便在AS点进去看源码。
4.2、CAS策略 ( 不管控
其他线程)
对于单个变量的操作
,简单如“i++;”,在多线程下都会有「原子性」
问题。因为“i++;”包含三个指令:
1、读取i值 2、计算i+1 3、将i+1的值写回原内存地址
3个指令,途中被线程调度的话,就会有「原子性」
问题。这种场景就需要CAS策略来帮忙。
CAS,Compare and Swap,本质是一种策略,是指“比较并交换值”这一组动作
。(很多人误以为
CAS=自旋,其实是“一直CAS失败并一直重试”才等于自旋)。
诶诶诶,等等,我刚才说CAS“比较并交换值”是一组动作
,一组动作就是多个指令,也会有「原子性」
问题啊!!为什么说这种场景可以用CAS呢??
因为这里的CAS策略,一般具体指java.util.concurrent.atomic
包中的原子类,如AtomicInteger
,AtomicLong
等。它们可以在多线程环境下安全地操作一个数值
,即保证其「原子性」
。
虽然,各种Lock实现类都基于AQS,AQS最核心逻辑实际上又基于CAS,严格来说Lock实现类也算「CAS策略」的实现。但这些Lock的底层实现都太深入了,距离“开发者使用”的层面太远。
原子类(如AtomicInteger)则非常直白地使用了CAS策略,它的主要方法例如incrementAndGet(),都是直接调用Unsafe.getAndAddInt()。
Unsafe类则是提供了一些可以直接操作内存
等底层操作的方法。但Unsafe类则又太过底层,一般开发者不会直接用它。
所以,考虑「CAS策略」做具体手段时,一般指原子类(如AtomicInteger)。
更深入一点:原子类能保证「原子性」
的能力,又是怎么来的呢??
刚才说了,在「指令级别」这么底层的层面,就已经决定了多个操作是有「原子性」
问题的,所以,要解决问题仍然得在「指令级别」这个层面。
具体来说,cas的实现需要「硬件指令集」的支撑,在 jdk1.5后虚拟机 才可以使用 处理器提供的cmpxchg指令实现。
4.3、volatile( 不管控
其他线程)
保证变量在多个线程中的「可见性」
、「有序性」
但是注意,它不能保证
复合操作(例如自增或自减)的原子性。那这种场景,刚才说的原子类
(如AtomicInteger.incrementAndGet())则能派上用场了。
5、一个疑问:Happens-before
到底是保证「可见性」还是「有序性」?
这个问题困扰了我很久,而且看到的绝大部分文章都写得很含糊。有些强调「可见性」,有些强调「有序性」,更多的是两者皆有并且前后描述矛盾(大概率那些作者本身都没弄懂😑,都是东拼西凑的文字)。
但说实话,这个问题还真不好搜出答案。
最后,我从Oracle文档
里看到Happens-before这样的描述:
所以,Happens-before是同时保证「可见性」和「有序性」
的
转载自:https://juejin.cn/post/7278244851040059453