美文网首页就该这么学并发
05. 就该这么学并发 - 线程安全

05. 就该这么学并发 - 线程安全

作者: 码哥说 | 来源:发表于2020-07-10 16:43 被阅读0次

    前言

    通过前几章对“线程生命周期”的讲解, 相信大家对于线程有了基本的了解.

    本章,我们就来聊聊线程安全问题.

    线程安全

    一般我们说到线程安全, 都是针对多线程的(单线程不存在线程安全问题)

    多线程之所以会出现安全问题, 主要是因为

    存在同时访问同一个共享、可变资源的情况
    这种资源可以是:一个变量、一个对象、一个文件等

    • 共享
    意味着该资源可以由多个线程同时访问
    
    • 可变
    意味着该资源可以在其生命周期内被修改
    

    简而言之,如果

    代码在多线程下执行和在单线程下执行永远都能获得一样的结果,那么代码就是线程安全的

    为了多线程编程的安全, 我们就得先了解线程安全的3个特性.

    线程安全特性

    安全的线程应该有如下3个特性

    • 原子性
    一个操作或者多个操作,
    要么全部执行(执行的过程不会被任何因素打断);
    要么就都不执行
    

    举个经典的银行转账例子

    两个人 A 和 B , 各有1000的存款,
    A 转给 B 1000, 逻辑应该是

    A - 1000 //步骤1
    B + 1000 //步骤2

    试想,如果, 步骤1执行完后, 步骤2被打断了,
    那么1000元就不翼而飞了!
    所以,步骤1和步骤2操作应该是“捆绑”的,也就是我们说的“原子”的

    • 可见性
    当多个线程访问同一个变量时,
    一个线程修改了这个变量的值,
    其他线程能够立即看得到修改的值
    

    可见性主要由内存模型决定.

    JMM(Java内存模型)规定如下

    • java 的所有变量都存储在主内存中
    • 每个线程有自己独立的工作内存,保存了该线程使用到的变量副本,是对主内存中变量的一份拷贝
    • 每个线程不能访问其他线程的工作内存,线程间变量传递需要通过主内存来完成
    • 每个线程不能直接操作主存,只能把主存的内容拷贝到本地内存后再做操作(这是线程不安全的本质),然后写回主存

    所以, 针对JMM特性, 就会存在如下案例:

    public class Test {
        private static boolean isRuning = false;
        public static void main(String[] args) throws Exception {
            Thread thread = new Thread(new Runnable() {
                @Override
                public void run() {
                    while (!isRuning) {
                    }
                }
            });
            thread.start();
            Thread.sleep(100);
            isRuning = true;
            System.out.println("主线程运行完毕");
        }
    }
    

    我们的程序很简单,
    两个线程, 主线程和子线程;
    isRuning来控制子线程是否继续执行,
    然后主线程isRuning = true;
    按道理说, 子线程会立刻结束执行;
    但是你运行这段代码会发现,

    image.png
    程序根本不会结束!
    原因在于:
    子线程一直读取的是缓存的 isRuning=false, 而没有读取主存中的最新值true!
    • 有序性
    程序执行的顺序按照代码的先后顺序执行
    

    同样,举个例子

    int a = 10; //语句1
    int r = 2; //语句2
    a = a + 3; //语句3
    r = a * a; //语句4

    可以看出, 代码上语句2是在语句1后执行的,
    但其实, JVM执行时却不一定是按这个顺序执行的!
    为啥?
    因为JVM执行时可能会进行 “指令重排序

    指令重排序是处理器为了提高程序运行效率, 可能会对代码进行优化,
    它遵循as-if-serial语义(后续单独介绍)

    它不保证程序中各个语句的执行先后顺序同代码中的顺序一致, 但是它会保证(单线程)程序最终执行结果和代码顺序执行的结果是一致的.

    如上代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,所以顺序可以调整;

    而语句3和语句4,对语句1和语句2有依赖,他们的执行顺序不能打乱.
    所以, JVM真正执行时的顺序可能是
    语句1--->语句2--->语句3--->语句4
    语句2--->语句1--->语句3--->语句4
    而不可能有其它执行顺序

    要想并发程序正确地执行,必须要保证 原子性、可见性以及有序性, 只要有一个没有被保证,就有可能会导致程序运行不正确.

    重排序

    介绍线程安全特性的“有序性”时,提出了个“重排序”的概念,

    重排序是编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段

    重排序分3种类型:

    • 编译器优化的重排序
    编译器在不改变单线程程序语义的前提下,可以重新安排语义.
    
    • 指令级并行的重排序
    现代处理器采用了指令级并行技术来将多条指令重叠执行.
    如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序.
    
    • 内存系统的重排序
    由于处理器使用缓存和读/写缓冲区,
    这使得加载和存储操作看上去可能是在乱序执行.
    

    JMM(Java Memory Model, Java内存模型)中,允许编译器和处理器对指令进行重排序.

    从Java源代码到最终实际执行的指令序列,会分别经历下面3种重排序


    重排序过程

    重排序遵循as-if-serial语义.

    as-if-serial

    as-if-serial语义是

    不管怎么重排序, 单线程程序的执行结果不能被改变.
    编译器、runtime和处理器都必须遵守as-if-serial语义.
    为了遵守as-if-serial语义,
    编译器和处理器不会对存在数据依赖关系的操作做重排序, 因为这种重排序会改变执行结果.
    但是, 如果操作之间不存在数据依赖关系, 这些操作就可以被编译器和处理器重排序.

    这里所说的数据依赖性仅针对单个处理器中执行的指令序列和单个线程中执行的操作,不同处理器之间和不同线程之间的数据依赖性不被编译器和处理器考虑.

    这也是“单线程不存在线程安全问题”的原因!

    另外, 我们可以通过插入特定类型的内存屏障来禁止特定类型的编译器重排序 和 处理器重排序.

    内存屏障(Memory Barrier)

    内存屏障,又称内存栅栏,是一个CPU指令,
    通过内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行.

    基本上它是一条这样的指令

    • 保证特定操作的执行顺序

    • 影响某些数据(或则是某条指令的执行结果)的内存可见性

    编译器和CPU可以在保证输出结果一样的情况下对指令重排序,使性能得到优化.

    插入一个内存屏障, 相当于告诉CPU和编译器

    先于这个命令的必须先执行, 后于这个命令的必须后执行.

    内存屏障另一个作用是

    强制更新一次不同CPU的缓存

    例如

    一个写屏障会把这个屏障前写入的数据刷新到缓存,
    这样任何试图读取该数据的线程将得到最新值,
    而不用考虑到底是被哪个CPU核心或者哪颗CPU执行的.

    Java内存模型中,

    • volatile是基于内存屏障实现的

    如果一个变量是volatile修饰的,
    JMM会在写入这个变量之后插进一个写屏障指令,
    并在读这个字段之前插入一个读屏障指令,

    这意味着:
    一个线程写入变量X后,任何线程访问该变量都会拿到最新值.
    在写入变量X之前的写入操作,
    其更新的数据对于其他线程也是可见的.
    因为内存屏障会刷出cache中的所有先前的写入.

    • synchronized底层也是通过释放屏障和获取屏障的配对使用保障有序性, 加载屏障和存储屏障的配对使用保障可见性

    通过synchronized关键字包住的代码区域,当线程进入到该区域读取变量信息时,保证读到的是最新的值

    内存屏障分类

    CPU层面的内存屏障

    CPU内存屏障主要分为以下3类:

    • 写屏障(Store Memory Barrier)
    告诉处理器
    在写屏障之前的所有已经在缓存(store bufferes)中的数据同步到主内存,
    简单来说
    就是使得写屏障之前的指令的结果对写屏障之后的读或者写是可见的
    
    • 读屏障(Load Memory Barrier)
    处理器在读屏障之后的读操作,都在读屏障之后执行.
    配合写屏障,使得写屏障之前的内存更新, 对于读屏障之后的读操作是可见的
    
    • 全屏障(Full Memory Barrier)
    确保屏障前的内存读写操作的结果提交到内存之后,
    再执行屏障后的读写操作
    

    JMM层面的内存屏障

    JMM层面的内存屏障主要分为以下4类:

    • LoadLoad(LL)屏障
    对于这样的语句
    Load1; 
    LoadLoad; 
    Load2;
    在Load2及后续读取操作要读取的数据被访问前,
    保证Load1要读取的数据被读取完毕
    
    • StoreStore(SS)屏障
    对于这样的语句
    Store1; 
    StoreStore; 
    Store2;
    在Store2及后续写入操作执行前,
    保证Store1的写入操作对其它处理器可见
    
    • LoadStore(LS)屏障
    对于这样的语句
    Load1; 
    LoadStore; 
    Store2;
    在Store2及后续写入操作被刷出前,
    保证Load1要读取的数据被读取完毕
    
    • StoreLoad(SL)屏障
    对于这样的语句
    Store1; 
    StoreLoad; 
    Load2;
    在Load2及后续所有读取操作执行前,
    保证Store1的写入对所有处理器可见.
    它的开销是四种屏障中最大的.
    在大多数处理器的实现中, 这个屏障是个万能屏障,兼具其它三种内存屏障的功能
    

    欢迎关注我

    技术公众号 “CTO技术”

    相关文章

      网友评论

        本文标题:05. 就该这么学并发 - 线程安全

        本文链接:https://www.haomeiwen.com/subject/ohtbzhtx.html