并发编程三大特性:可见性,原子性,有序性

137 阅读13分钟

前言

可见性、原子性、有序性是并发编程中非常重要的三个特性,正确理解了这三个特性对我们开发多线程程序有很大的帮助。

可见性

可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。我们看下面这个例子。

public class QAXTechShare {
   public static void main(String[] args) {
    ThreadDemo demo = new ThreadDemo();
    demo.start();
    while (true){
        if(demo.isFlag()){
            System.out.println("flag状态改变成功");
            break;
        }
    }
}
static class ThreadDemo extends Thread {
    private  boolean flag = false;
    public boolean isFlag() {
        return flag;
    }
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("flag="+flag);
    }
}

image.png

我们会发现,永远都不会输出flag状态改变成功这一段代码,按道理线程改了flag变量,主线程也能访问到的呀?为什么访问不到呢,要解决这个问题我们要先了解一下现代计算机的内存模型和JVM的内存模型。

现代计算机内存模型

我们知道运行的程序数据都是存放在内存中的,但是为了保证处理器的处理速度,处理器是不会直接和内存进行通信的,而是会先将内存中的值读取到处理器内部缓存(L1,L2,L3)中,然后再进行相关的处理。如图所示

image-20221112130451721.png

每个处理器先将内存中的值读取到内部缓存中,然后使用这个内部缓存的值来进行计算,计算完成以后再将值刷新回内存。由于每个cpu都是操作自己缓存中的数据,所以这种情况下就容易发生缓存不一致的问题,每个处理器中的缓存值不同,从而导致结果计算错误。

所以说在多处理器的情况下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是不是过期了,当处理器发现自己缓存内的内存地址被修改了,就会将当前处理器的缓存设置为无效状态,然后重新从系统内存中读取数据到缓存中。

Java内存模型

Java内存模型(JavaMemoryModel)描述了Java程序中各种变量(线程共享变量)的访问规则。

所有的共享变量都存储于主内存,除此以外每一个线程还存在自己的工作内存,线程的工作内存中保留了被线程使用的共享变量的副本,如下图。

Java内存模型.png JMM规定 线程对变量的所有的操作(读,取)都必须在工作内存中完成,而不能直接读写主内存中的变量。也正是由于JMM的这种特点,导致了刚刚我们看见的,多线程之间数据不可见的问题出现。

Java可见性的解决方案

加锁

 public static void main(String[] args) {
        ThreadDemo demo = new ThreadDemo();
        demo.start();
        while (true){
            synchronized (obj) {
                if (demo.isFlag()) {
                    System.out.println("flag状态改变成功");
                    break;
                }
            }
        }
    }

image-20221112151111742.png

将变量转变为volatile修饰

static class ThreadDemo extends Thread {
    private volatile boolean flag = false;
    public boolean isFlag() {
        return flag;
    }
​
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("flag=" + flag);
    }
}

我们看执行结果,依然输出成功。 image.png

为什么加锁和使用volatile可以解决可见性问题呢?

因为某一个线程进入synchronized代码块后,线程会获得锁(通过monitor监视器对象),然后清空工作内存,从主内存拷贝共享变量最新的值到工作内存成为副本,执行完相应的操作后,将修改后的副本的值刷新回主内存中,线程释放锁。而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。

当一个变量被volatile修饰以后,底层会禁用掉cpu的缓存,会保证每次写入的值直接写入到主内存中,每次读取时直接从主内存中进行读取。

原子性

并发编程的原子性是指在多个线程一起执行的时候,一个操作一旦开始,就不会被其他线程所打断。

为什么会存在原子性问题:线程切换

在并发编程中,往往设置的线程数目会大于CPU数目,而每个CPU在同一时刻只能被一个线程使用。CPU资源的分配采用了时间片轮转策略,也就是给每个线程分配一个时间片,线程在这个时间片内占用CPU的资源来执行任务。当占用CPU资源的线程执行完任务后,会让出CPU的资源供其他线程运行,这就是任务切换,也叫做线程切换或者线程的上下文切换。

Java中的原子性问题

在Java中,并发程序是基于多线程技术来编写的,这也会涉及到CPU的对于线程的切换问题,正是CPU中对任务的切换机制,导致了并发编程会出现原子性的问题。

在Java程序中,往往一个语句对应着CPU多条指令。

public class ThreadTest {

    private Long count;

    public Long getCount(){
        return count;
    }

    public void incrementCount(){
        count++;
    }
}

在cmd命令行进行编译

javap -c ThreadTest

得到如下结果

Compiled from "ThreadTest.java"
public class io.mykit.concurrent.lab01.ThreadTest {
  public io.mykit.concurrent.lab01.ThreadTest();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public java.lang.Long getCount();
    Code:
       0: aload_0
       1: getfield      #2                  // Field count:Ljava/lang/Long;
       4: areturn

  public void incrementCount();
    Code:
       0: aload_0
       1: getfield      #2                  // Field count:Ljava/lang/Long;
       4: astore_1
       5: aload_0
       6: aload_0
       7: getfield      #2                  // Field count:Ljava/lang/Long;
      10: invokevirtual #3                  // Method java/lang/Long.longValue:()J
      13: lconst_1
      14: ladd
      15: invokestatic  #4                  // Method java/lang/Long.valueOf:(J)Ljava/lang/Long;
      18: dup_x1
      19: putfield      #2                  // Field count:Ljava/lang/Long;
      22: astore_2
      23: aload_1
      24: pop
      25: return
}

可以看到,Java语言中短短的几行incrementCount()方法竟然对应着那么多的CPU指令。这些CPU指令我们大致可以分成三步。

  • 指令1:把变量count从内存加载的CPU寄存器。
  • 指令2:在寄存器中执行count++操作。
  • 指令3:将结果写入缓存(可能是CPU缓存,也可能是内存)。

在操作系统执行线程切换时,可能发生在任何一条CPU指令完成后,而不是程序中的某条语句完成后(程序中一条语句可能对应多条CPU指令!)。 假设有两个线程A、B同时执行count++,当线程A执行完指令1后,操作系统发生了线程切换,然后两个线程都执行count++操作后,得到的结果是1而不是2。这里,我们可以使用下图来表示这个过程。

image.png

由上图,我们可以看出:线程A将count=0加载到CPU的寄存器后,发生了线程切换。此时内存中的count值仍然为0,线程B将count=0加载到寄存器,执行count++操作,并将count=1写到内存。此时,CPU切换到线程A,执行线程A中的count++操作后,线程A中的count值为1,线程A将count=1写入内存,此时内存中的count值最终为1。

所以,如果在CPU中存在正在执行的线程,恰好此时CPU发生了线程切换,则可能会导致原子性问题,这也是导致并发编程频繁出问题的根源之一

JAVA如何保证原子性呢?答案就是上锁。

Java中提供了Synchronized关键字来对需要保证原子性的代码进行加锁。

我们来看看synchronize加锁的代码

public class SynchronizeDemo01 {
    Object obj = new Object();
     public void method() {
        synchronized (this) {
            System.out.println("demo1");
        }
    }
}

通过命令进行反编译以后:

java c SynchronizeDemo01.java
javap -verbose SynchronizeDemo01.class

我们可以得到如下的结果

同步代码块:

image.png

可以看到在同步代码块开始和结束阶段分别插入了monitorenter和monitorexit的指令,而这个指令是每一个对象头中都有的监视器锁能够保证操作的原子性。

有序性

有序性是指:按照代码的既定顺序执行。

学习编程的时候,我们大概都知道:程序是从上到下依次执行的。

public static void main(String[] args) {
    System.out.print(1);
    System.out.print(2);
    System.out.print(3);
}

我们很快就能得出上面程序运行的结果:123,它的结果绝不会是132、321或者213等等。

但是事实的真相是程序并不是严格按照顺序来依次执行的,在CPU级别指令是可以进行重排序的。

指令重排序

在执行程序的时候为了提升性能,编译器和处理器会自发的对程序进行指令重排序。

在单线程情况下,指令重排序可以保证最终执行的结果与程序顺序执行的结果一致,但是在多线程下就会存在很大的问题。

一个经典的案例就是使用双重检查机制来创建单例对象。例如,在下面的代码中,在getInstance()方法中获取对象实例时,首先判断instance对象是否为空,如果为空,则锁定当前类的class对象,并再次检查instance是否为空,如果instance对象仍然为空,则为instance对象创建一个实例。

public class SingleInstance {
    private static SingleInstance instance;
    
    public static SingleInstance getInstance(){
        if(instance == null){               
            synchronized (SingleInstance.class){
                if(instance == null){
                    instance = new SingleInstance();
                }
            }
        }
        return instance;
    }
}

假设此时有线程A和线程B两个线程同时调用getInstance()方法来获取对象实例,两个线程会同时发现instance对象为空,此时会同时对SingleInstance.class加锁,而JVM会保证只有一个线程获取到锁,这里我们假设是线程A获取到锁。则线程B由于未获取到锁而进行等待。接下来,线程A再次判断instance对象为空,从而创建instance对象的实例,最后释放锁。此时,线程B被唤醒,线程B再次尝试获取锁,获取锁成功后,线程B检查此时的instance对象已经不再为空,线程B不再创建instance对象。

上面的一切看起来很完美,但是这一切的前提是编译器或者解释器没有对程序进行优化,也就是说CPU没有对程序进行重排序。而实际上,这一切都只是我们自己觉得是这样的。

在真正高并发环境下运行上面的代码获取instance对象时,创建对象的new操作会因为编译器或者解释器对程序的优化而出现问题。也就是说,问题的根源在于如下一行代码。

instance = new SingleInstance();

对于上面的一行代码来说,会有3个CPU指令与其对应。

1.分配内存空间。

2.初始化对象。

3.将instance引用指向内存空间。

正常执行的CPU指令顺序为1—>2—>3,CPU对程序进行重排序后的执行顺序可能为1—>3—>2。此时,就会出现问题。

public class SingleInstance {
    private static SingleInstance instance;
    public static SingleInstance getInstance(){
        if(instance == null){               
            synchronized (SingleInstance.class){
                if(instance == null){
                   // instance = new SingleInstance();
                     1.分配内存空间。
		     2.初始化对象。
		     3.将instance引用指向内存空间。
                }
            }
        }
        return instance;
    }
}

经过指令重排序后

public class SingleInstance {
    private static SingleInstance instance;
    public static SingleInstance getInstance(){
        if(instance == null){               
            synchronized (SingleInstance.class){
                if(instance == null){
                   // instance = new SingleInstance();
                     1.分配内存空间。
		     3.将instance引用指向内存空间。
                     2.初始化对象。
                }
            }
        }
        return instance;
    }
}

Java有序性的解决方案

在Java中有一种非常简单的解决方案,就是将instance 声明为volatile类型的,就能够禁止指令重排序。

public class SingleInstance {
    private volatile static SingleInstance instance;  //声明为volatile类型
    public static SingleInstance getInstance(){ 
        if(instance == null){               
            synchronized (SingleInstance.class){
                if(instance == null){
                   // instance = new SingleInstance();
                     1.分配内存空间。
		     3.将instance引用指向内存空间。
                     2.初始化对象。
                }
            }
        }
        return instance;
    }
}

它的实现原理是Java编译器在生成指令序列的时候,会在代码中插入一段特定类型的内存屏障,通过内存屏障来禁止特定类型的处理器重排序。

内存屏障

内存屏障是硬件层的概念,不同的硬件平台实现内存屏障的手段并不是一样,java通过屏蔽这些差异,统一由jvm来生成内存屏障的指令。

Java内存屏障主要有Load和Store两类。 

对Load Barrier来说,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据 
对Store Barrier来说,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存

对于Load和Store,在实际使用中,又分为以下四种:LoadLoad BarriersStoreStore BarriersLoadStore Barriers、  StoreLoad Barriers。 从该四个类型中我们可以看到他们都是由Load和Store的不同排序组成。而这两个指令是来自硬件的内容。

  • loadload:
    读读,确保Load1所要读入的数据能够在被Load2和后续的load指令访问前读入。

  • storestore:
    确保Store1的数据在Store2以及后续Store指令操作相关数据之前对其它处理器可见(例如向主存刷新数据)。

  • loadstore:
    确保Load1的数据在Store2和后续Store指令被刷新之前读取。在等待Store指令可以越过loads指令的乱序处理器上需要使用LoadStore屏障。

  • storeload:
    确保Store1的数据在被Load2和后续的Load指令读取之前对其他处理器可见。

JSR-133中定义了一套happens-before原则,能够保证我们并发编程的安全性。

HappensBefore原则

【原则一】程序次序规则

在一个线程中,按照代码的顺序,前面的操作总是Happens-Before后面的任意操作。 也就是说单线程内的所有操作总是有序的。

【原则二】volatile变量规则

对一个volatile变量的写操作,Happens-Before于后续对这个变量的读操作。 保证每一次读取的volatile值都是最新的值。

public class QAXTechShare {
   public static void main(String[] args) {
    ThreadDemo demo = new ThreadDemo();
    demo.start();
    while (true){
        if(demo.isFlag()){         //2. volatile读操作
            System.out.println("flag状态改变成功");
            break;
        }
    }
}
static class ThreadDemo extends Thread {
    private  boolean flag = false;
    public boolean isFlag() {
        return flag;
    }
    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;      // 1. volatile写操作
        System.out.println("flag="+flag);
    }
}

【原则三】传递规则

如果A Happens-Before B,并且B Happens-Before C,则A Happens-Before C。

【原则四】锁定规则

对一个锁的解锁操作 Happens-Before于后续对这个锁的加锁操作。 必须等到锁被释放以后才能够重新加锁。

public class Principle4 {
    private static Lock lock = new ReentrantLock();
    public static void main(String[] args) {
        lock.lock();    // 1. 加锁操作
        try{
            System.out.println("我是临界区代码");
        }catch (Exception e){
            
        } finally {
            lock.unlock();    // 2. 解锁操作
        }
        
        
    }
}

【原则五】线程启动规则

如果线程A调用线程B的start()方法来启动线程B,则start()操作Happens-Before于线程B中的任意操作。

public class Principle5 {

    public static void main(String[] args) {
        ThreadDemo demo = new ThreadDemo();
        demo.start();     // 1. 线程启动
    }
    
    static class ThreadDemo extends Thread {
        @Override
        public void run() {
            super.run();    // 2. 线程运行代码
        }
    } 
}

【原则六】线程终结规则

线程A等待线程B完成(在线程A中调用线程B的join()方法实现),当线程B完成后(线程A调用线程B的join()方法返回),则线程A能够访问到线程B对共享变量的操作。

【原则七】线程中断规则

对线程interrupt()方法的调用Happens-Before于被中断线程的代码检测到中断事件的发生。

【原则八】对象终结原则

一个对象的初始化完成Happens-Before于它的finalize()方法的开始。

总结

本文介绍了在并发编程中需要考虑的三个特性,这三个特性往往也是让我们编写出错误多线程代码的地方,并且基于此给出了Java对应的解决方案,通过加锁的方式可以正确的解决上述的问题。

参考文章:

  1. 原文链接:blog.csdn.net/qq_48241564…