• 线程安全—可见性和有序性


     在并发编程中,需要处理的两个关键问题:线程之间如何通信以及线程之间如何同步

    通信是指线程之间以或者机制交换信息,java的并发采用的是共享内存模型,线程之间共享程序的公共状态,通过读写内存中的公共状态进行隐式通信。

    同步是是指程序中用于控制不同线程间操作发生相对顺序的机制。

    最开始首先应该知道计算机中的缓存在其中起的作用

    CPU Cache(高速缓存):由于计算机的存储设备与处理器的处理设备有着几个数量级的差距,所以现代计 算机都会加入一层读写速度与处理器处理速度接近相同的高级缓存来作为内存与处理器之间的缓冲,将运 算使用到的数据复制到缓存中,让运算能够快速的执行,当运算结束后,再从缓存同步到内存之中,这 样,CPU就不需要等待缓慢的内存读写了。

    主(内)存:一个计算机包含一个主存,所有的CPU都可以访问主 存,主存比缓存容量大的多(CPU访问缓存层的速度快于访问主存的速度!但通常比访问内存寄存器的速度还是要慢点)

    运作原理:通常情况下,当一个CPU要读取主存(RAM - Main Mernory)的时候,他会将主存中的数据读 取到CPU缓存中,甚至将缓存内容读到内部寄存器里面,然后再寄存器执行操作,当运行结束后,会 将寄存器中的值刷新回缓存中,并在某个时间点将值刷新回主存。

    为什么需要CPU Cache?

     答:CPU 的频率太快了,快到主存跟不上,这样在处理器时钟周期内,CPU常常需要等待主存,浪费资源。 所以cache 的出现,是为了缓解 CPU 和内存之间速度的不匹配问题 结构:cpu-> cache-> memory).

    什么是java的内存模型?

    共享变量:一个变量可以被多个线程使用,那么这个变量就是这几个线程的共享变量。
    Java Memory Model (JAVA 内存模型)描述线程之间如何通过内存(memory)来进行交互,描述了java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。 
    具体说来, JVM中存在一个主存区(Main Memory或Java Heap Memory),对于所有线程进行共享,但线程不能直接操作主内存中的变量,每个线程都有自己独立的工作内存(Working Memory),里面保存该线程使用到的变量的副本( 主内存中该变量的一份拷贝 )
    规定:线程对共享变量的读写都必须在自己的工作内存中进行,而不能直接在主内存中读写。不同线程不能直接访问其他线程的工作内存中的变量,线程间变量值的传递需要主内存作为桥梁。 
     
    Heap(堆):Java里的堆是一个运行时的数据区,堆是由垃圾回收来负责的。实例域、静态域、和数组元素都存储在堆内存中。
    Stack(栈):栈的优势是存取速度比堆要快,仅次于计算机里的寄存器。局部变量、方法参数、对象的引用存储在栈中。
     
     
    java内存模型的抽象结构图:
     
     

     每个线程之间共享变量都存放在主内存里面,每个线程都有一个私有的本地内存,本地内存是Java内存模型中抽象的概念,并不是真实存在的(他涵盖了缓存写缓冲区。寄存器,以及其他硬件的优化) 本地内存中存储了以读或者写共享变量的拷贝的一个副本。

    注意:由于工作内存(缓冲区)仅对自己的处理器可见,它会导致处理器质质性内存操作的顺序可能会与内存实际的操作顺序不一致,内存的操作顺序被重排序了,这是与后面讲的指令重排序不同的另一种重排序。

     
    什么是内存的可见性?
    可见性:一个线程对共享变量值得修改,能够及时的被其他线程看到 
    线程可见性原理: 
    线程一对共享变量的改变想要被线程二看见,就必须执行下面两个步骤:
    ①将工作内存1中的共享变量的改变更新到主内存中
    ②将主内存中最新的共享变量的变化更新到工作内存2中。
     
    指令重排序:代码书写的顺序与实际执行的顺序不同,指令重排序是编译器或处理器为了提高程序性能而做的优化。

    1.编译器优化的重排序(编译器优化)

    2.指令级并行重排序(处理器优化)

    3.内存系统的重排序(处理器优化)

    是不是所有的语句的执行顺序都可以重排呢?

    答案是否定的。为了讲清楚这个问题,先讲解另一个概念:数据依赖性

    什么是数据依赖性?

    如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖。数据依赖分下列三种类型:

    名称 代码示例 说明
    写后读 a = 1;b = a; 写一个变量之后,再读这个位置。
    写后写 a = 1;a = 2; 写一个变量之后,再写这个变量。
    读后写 a = b;b = 1; 读一个变量之后,再写这个变量。

    上面三种情况,只要重排序两个操作的执行顺序,程序的执行结果将会被改变。所以,编译器和处理器在重排序时,会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。也就是说:在单线程环境下,指令执行的最终效果应当与其在顺序执行下的效果一致,否则这种优化便会失去意义。这句话有个专业术语叫做as-if-serial semantics (as-if-serial语义)

    int num1=1;//第一行
    int num2=2;//第二行
    int sum=num1+num;//第三行

    • 单线程:第一行和第二行可以重排序,但第三行不行
    • 重排序不会给单线程带来内存可见性问题
    • 多线程中程序交错执行时,重排序可能会照成内存可见性问题。

    可见性分析:

    导致共享变量在线程间不可见的原因:

    1. 线程的交叉执行
    2. 重排序结合线程交叉执行
    3. 共享变量更新后的值没有在工作内存与主内存间及时更新

     

    重排序对多线程的影响
    class ReorderExample {
        int a = 0;
        boolean flag = false;
     
        public void writer() {
            a = 1;          // 1
            flag = true;    // 2
        }
     
        public void reader() {
            if (flag) {            // 3
                int i = a * a; // 4
            }
        }
    }
    flag变量是个标记,用来标识变量a是否已被写入。这里假设有两个线程A和B,A首先执行writer()方法,随后B线程接着执行reader()方法。线程B在执行操作4时,能否看到线程A在操作1对共享变量a的写入?

    答案是:不一定能看到。

    由于操作1和操作2没有数据依赖关系,编译器和处理器可以对这两个操作重排序;同样,操作3和操作4没有数据依赖关系,编译器和处理器也可以对这两个操作重排序。让我们先来看看,当操作1和操作2重排序时,可能会产生什么效果?

    执行顺序是:2 -> 3 -> 4 -> 1 (这是完全存在并且合理的一种顺序,如果你不能理解,请先了解CPU是如何对多个线程进行时间分配的)
     

    操作3和操作4重排序后,因为操作3和操作4存在控制依赖关系。当代码中存在控制依赖性时,会影响指令序列执行的并行度。为此,编译器和处理器会采用猜测(Speculation)执行来克服控制相关性对并行度的影响。以处理器的猜测执行为例,执行线程B的处理器可以提前读取并计算a*a,然后把计算结果临时保存到一个名为重排序缓冲(reorder buffer ROB)的硬件缓存中。当接下来操作3的条件判断为真时,就把该计算结果写入变量i中。

    我们可以看出,猜测执行实质上对操作3和4做了重排序。重排序在这里破坏了多线程程序的语义!

     
    同步(synchronization)就是指一个线程访问数据时,其它线程不得对同一个数据进行访问,即同一时刻只能有一个线程访问该数据,当这一线程访问结束时其它线程才能对这它进行访问。
    package com.xidian.count;
    
    import java.util.concurrent.CountDownLatch;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Semaphore;
    
    import com.xidian.annotations.ThreadSafe;
    
    import lombok.extern.slf4j.Slf4j;
    
    @Slf4j
    @ThreadSafe
    public class CountExample3 {
    
        // 请求总数
        public static int clientTotal = 5000;
    
        // 同时并发执行的线程数
        public static int threadTotal = 200;
    
        public static int count = 0;
    
        public static void main(String[] args) throws Exception {
            ExecutorService executorService = Executors.newCachedThreadPool();
            final Semaphore semaphore = new Semaphore(threadTotal);
            final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
            for (int i = 0; i < clientTotal ; i++) {
                executorService.execute(() -> {
                    try {
                        semaphore.acquire();
                        add();
                        semaphore.release();
                    } catch (Exception e) {
                        log.error("exception", e);
                    }
                    countDownLatch.countDown();
                });
            }
            countDownLatch.await();
            executorService.shutdown();
            log.info("count:{}", count);
        }
    
        private synchronized static void add() {
            count++;
        }
    }
    View Code

    volatile实现可见性

    volatile变量每次被线程访问时,都强迫从主内存中读取该变量的值,而当变量发生变化的时候都会强迫线程将最新的值刷新到主内存中。
    这样不同的变量总能看到最新的值。
    可以把volatile变量的单个读写,看成是使用同一个锁对这些单个读/写操作做了同步。

    volatile关键字:

    • 能够保证volatile变量的可见性
    • 只能保证单个volatile变量的原子性,对于volatile++这种复合操作不具有原子性
    深入来说:通过加入内存屏障和禁止重排序优化来实现的。
    • 对volatile变量执行写操作时,会在写操作后加入一条store屏障指令

      • store指令会在写操作后把最新的值强制刷新到主内存中。同时还会禁止cpu对代码进行重排序优化。这样就保证了值在主内存中是最新的。
    • 对volatile变量执行读操作时,会在读操作前加入一条load屏障指令

      • load指令会在读操作前把内存缓存中的值清空后,再从主内存中读取最新的值。

     

     

    package com.xidian.count;
    import java.util.concurrent.CountDownLatch;
    import java.util.concurrent.ExecutorService;
    import java.util.concurrent.Executors;
    import java.util.concurrent.Semaphore;
    
    import com.xidian.annotations.NotThreadSafe;
    
    import lombok.extern.slf4j.Slf4j;
    
    @Slf4j
    @NotThreadSafe
    public class CountExample4 {
    
        // 请求总数
        public static int clientTotal = 5000;
    
        // 同时并发执行的线程数
        public static int threadTotal = 200;
    
        public static volatile int count = 0;
    
        public static void main(String[] args) throws Exception {
            ExecutorService executorService = Executors.newCachedThreadPool();
            final Semaphore semaphore = new Semaphore(threadTotal);
            final CountDownLatch countDownLatch = new CountDownLatch(clientTotal);
            for (int i = 0; i < clientTotal ; i++) {
                executorService.execute(() -> {
                    try {
                        semaphore.acquire();
                        add();
                        semaphore.release();
                    } catch (Exception e) {
                        log.error("exception", e);
                    }
                    countDownLatch.countDown();
                });
            }
            countDownLatch.await();
            executorService.shutdown();
            log.info("count:{}", count);
        }
    
        private static void add() {
            count++;
            // 1、count 从主存中取出count的值
            // 2、+1  在工作内存中执行+1操作
            // 3、count 将count的值写回主存
            //及时将count用vilatile修饰,每次从主存中取到的都是最新的值,可是当多个线程同时取到最新的值,执行+1操作,当刷新到主存中的时候会覆盖结果,从而丢失一些+1操作
        }
    }
    View Code
    由程序运行结果可知,即使我们使用volatile修饰变量依然无法保证线程安全。
    那是为什么呢?

    volatile实现共享变量内存可见性有一个条件,就是对共享变量的操作必须具有原子性。比如 num = 10; 这个操作具有原子性,但是 num++ 或者num--由3步组成,并不具有原子性,所以是不行的。

    假如num=5,此时有线程A从主内存中获取num的值,并执行++,但在还未见修改写入主内存中,又有线程B取得num的值,对其进行++操作,造成丢失修改,明明执行了2次++,num的值却只增加了1.

    volatile不具有原子性,它不适用于计数的场景,那么它适用于什么场景呢?
    volatile使用条件:
    1. 对变量的写入操作不依赖其当前值

      • 不满足:number++、count=count*5
      • 满足:boolean变量、记录温度变化的变量等
    2. 该变量没有包含在具有其他变量的不变式中

      • 不满足:不变式 low<up

    综上,volatile特别适合用来做线程标记量,如下图

    synchronized和volatile的比较;

    • synchronized锁住的是变量和变量的操作,而volatile锁住的只是变量,而且该变量的值不能依赖它本身的值,volatile算是一种轻量级的同步锁
    • volatile不需要加锁,比synchronized更加轻量级,不会阻塞线程。
    • 从内存可见性角度讲,volatile读相当于加锁,volatilexie相当于解锁。
    • synchronized既能保证可见性,又能保证原子性,而volatile只能保证可见性,无法保证原子性。
    注:由于voaltile比synchronized更加轻量级,所以执行的效率肯定是比synchroized更高。在可以保证原子性操作时,可以尽量的选择使用volatile。在其他不能保证其操作的原子性时,再去考虑使用synchronized。

    有序性

    Happens-before原则,先天有序性,即不需要任何额外的代码控制即可保证有序性,java内存模型一个列出了八种Happens-before规则,如果两个操作的次序不能从这八种规则中推倒出来,则不能保证有序性。

    ※程序次序规则:一个线程内,按照代码执行,书写在前面的操作先行发生于书写在后面的操作。
    ※锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作
    ※volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作
    ※传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C
    ※线程启动原则:Thread对象的start()方法先行发生于此线程的每一个动作
    ※线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生
    ※线程终结规则:线程中所有的操作都先行发生于线程的终止检测,我们可以通过Thread.join()方法结束、Thread.isAlive()方法返回值手段检测到线程已经终止执行
    ※对象终结规则:一个对象的初始化完成先行发生于他的finalize()方法的开始

    第一条规则要注意理解,这里只是程序的运行结果看起来像是顺序执行,虽然结果是一样的,jvm会对没有变量值依赖的操作进行重排序,这个规则只能保证单线程下执行的有序性,不能保证多线程下的有序性。

    总结

  • 相关阅读:
    141. Linked List Cycle
    2. Add Two Numbers
    234. Palindrome Linked List
    817. Linked List Components
    《算法图解》之快速排序
    C++-对象指针的滥用
    C++学习书籍评价
    C++-随机数的产生
    Java-重载和重写区别剖析
    Qt- 图形界面应用程序的运行模式
  • 原文地址:https://www.cnblogs.com/xiangkejin/p/9249827.html
Copyright © 2020-2023  润新知