likes
comments
collection
share

Java「线程安全」思考与梳理

作者站长头像
站长
· 阅读数 56

前言

本文着重讲述「为什么」,主要是自己对「线程安全」相关问题的思考和梳理。

至于文章中涉及到的众多「并发编程」基础知识点、专有名词,网上一搜便是,就不在这当搬运工了。

所以,借用宋宝华前辈的一句话:

本文适合:已经读了很多「线程安全」的资料,但是没打通脉络的读者;

本文不适合:完全不知道「线程安全」是什么的读者,和完全知道「线程安全」是什么的读者

0、理清概念 --「线程安全」

说起并发编程,要注意“线程安全”已经是老生常谈。

但“线程安全”的准确定义,却少有看见满意的答案。倒不是因为这个问题有多难,而是给它下的定义很难兼顾到“准确性”和“接地气”这两方面。

先看 wiki中文版 的解释:

Java「线程安全」思考与梳理

wiki中文版 强调“公用变量”,但“代码块/方法”的「原子性」也需要正确处理。

再看 百度百科 的解释:

Java「线程安全」思考与梳理

其实我觉得这已经描述得不错了😂非要扣细节的话,就是「并行」「并发」这里用得不严谨。 「并发」(包括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内存模型:

Java「线程安全」思考与梳理

实际上的CPU高速缓存

Java「线程安全」思考与梳理

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「线程安全」思考与梳理

在Java并发编程中,管程模型(Monitor Model)是一种用于实现并发访问共享资源的同步机制。管程模型将共享资源封装在一个称为管程(Monitor)的对象中,通过限制对共享资源的访问来控制并发执行。

管程模型主要由 两部分 组成:互斥访问条件变量

互斥访问:在管程中,共享资源只能通过互斥访问来防止多个线程同时修改数据。互斥访问是通过在管程的入口和出口处设置互斥锁(Mutex)来实现的。当一个线程进入管程时,它需要获取互斥锁,确保在管程内部对共享资源的独占访问。当线程离开管程时,它会释放互斥锁,允许其他线程进入管程。

条件变量:条件变量用于协调多个线程之间的同步。当一个线程需要等待某个条件满足时,它可以使用条件变量阻塞自己,并释放互斥锁。当其他线程改变了条件并通知了条件变量后,阻塞的线程将被唤醒,并重新获取互斥锁进入管程。

上面是从抽象的角度去论述的。具体一点,比方说synchronized关键字,那么:

“互斥访问” 就是对应于:代码块/方法的入口和出口

“条件变量” 就是对应于:wait()、notify()/notifyAll()

「管程模型」意义:

「管程模型」是在信号量机制上进行改进的井发编程模型,解决了信号量在临界区的PV 操作上配对的麻烦,极大降低了使用和理解成本

说回这两种具体手段:

1.monitor lock/隐式锁/内置锁: synchronized

最常见,最易用,但灵活程度低点、可优化空间小

尽管JVM没有把(lock和unlock 原子操作指令) 开放给我们使用,但jvm以更高层次的字节码指令(monitorenter 和 monitorexit 指令) 开放给我们使用;在编译成字节码后,synchronized代码块的起始/结束处,就会插入 monitorenter/monitorexit 指令

Java「线程安全」思考与梳理

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包中的原子类,如AtomicIntegerAtomicLong等。它们可以在多线程环境下安全地操作一个数值,即保证其「原子性」

虽然,各种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这样的描述:

Java「线程安全」思考与梳理 docs.oracle.com/javase/spec…

所以,Happens-before是同时保证「可见性」和「有序性」

转载自:https://juejin.cn/post/7278244851040059453
评论
请登录