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

Posted xiangkejin

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了线程安全—可见性和有序性相关的知识,希望对你有一定的参考价值。

什么是java的内存模型?

共享变量:一个变量可以被多个线程使用,那么这个变量就是这几个线程的共享变量。
Java Memory Model (JAVA 内存模型)描述线程之间如何通过内存(memory)来进行交互,描述了java程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取出变量这样的底层细节。 具体说来, JVM中存在一个主存区(Main Memory或Java Heap Memory),对于所有线程进行共享,但线程不能直接操作主内存中的变量,每个线程都有自己独立的工作内存(Working Memory),里面保存该线程使用到的变量的副本( 主内存中该变量的一份拷贝 )
规定:线程对共享变量的读写都必须在自己的工作内存中进行,而不能直接在主内存中读写。不同线程不能直接访问其他线程的工作内存中的变量,线程间变量值的传递需要主内存作为桥梁。 
技术分享图片
技术分享图片
 
什么是内存的可见性?
可见性:一个线程对共享变量值得修改,能够及时的被其他线程看到 
线程可见性原理: 
线程一对共享变量的改变想要被线程二看见,就必须执行下面两个步骤:
①将工作内存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变量执行写操作时,会在写操作后加入一条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会对没有变量值依赖的操作进行重排序,这个规则只能保证单线程下执行的有序性,不能保证多线程下的有序性。

总结

技术分享图片




以上是关于线程安全—可见性和有序性的主要内容,如果未能解决你的问题,请参考以下文章

可见性原子性和有序性

什么是线程安全性?

深入理解JVM4——线程安全

并发特性

可见性原子性有序性

volatile为啥不能保证原子性