JUC并发—volatile和synchronized原理

53 阅读24分钟

1.volatile关键字的使用例子

(1)volatile关键字的使用场景

如果多个线程共用一个共享变量,有的线程写、有的线程读,那么可能会导致有的线程没法及时读到其他线程修改的变量值。volatile关键字可让某线程修改变量后,其他线程立即看到该变量的修改值。

 

(2)volatile关键字的使用例子

public class VolatileDemo {
    //如果没有volatile,那么读取的线程可能一直读到旧值而不打印,CPU缓存模型的数据一致性导致的
    static volatile int flag = 0;

    public static void main(String[] args) {
        new Thread() {
            public void run() {
                int localFlag = flag; 
                while(true) {
                    if (localFlag != flag) {
                        System.out.println("读取到了修改后的标志位:" + flag);  
                        localFlag = flag;
                    }
                }  
            };         
        }.start();
      
        new Thread() {
            public void run() {
                int localFlag = flag;  
                while(true) { 
                    System.out.println("标志位被修改为了:" + ++localFlag); 
                    flag = localFlag;
                    try {
                        TimeUnit.SECONDS.sleep(2); 
                    } catch (Exception e) {
                        e.printStackTrace(); 
                    }
                }
            };         
        }.start();
    }
}

(3)volatile关键字的理解路径

一.CPU缓存模型

二.Java内存模型JMM

三.原子性、可见性、有序性

四.volatile的作用

五.volatile的底层原理

六.volatile案例

 

2.主内存和CPU的缓存模型

CPU如果频繁读写主内存,那么就会导致CPU的计算性能比较差。所以现代的计算机,一般都会在CPU和内存之间加几层高速缓存。这样每个CPU就可以直接操作自己对应的高速缓存,从而不需要直接和主内存进行频繁的通信,保证了CPU的计算效率。

 

3.CPU高速缓存的数据不一致问题

主内存的数据会被加载到CPU高速缓存里,CPU后续会读写自己的高速缓存。但多线程并发运行时,就可能引发各个CPU高速缓存里的数据不一致问题。

 

上面volatile的例子,在没有volatile修饰flag的时候:负责执行线程0的CPU一开始会将主内存的flag值读到其高速缓存。之后在执行线程0的指令时,便会在该CPU的高速缓存里读取flag的值。此时该CPU无法感知负责执行线程1的其他CPU对其高速缓存的flag的修改。因为负责执行线程1的CPU可能没有将其修改的缓存值及时刷新回到主内存,或者负责执行线程0的CPU可能没有主动更新主内存的最新值到其高速缓存中。

 

所以CPU的缓存模型,在多线程并发运行时可能存在数据不一致的问题。也就是各个CPU的高速缓存和主内存没有及时同步,同一个数据在各CPU可能都不一样,从而导致数据的不一致。

 

4.总线锁和缓存锁及MESI缓存一致性协议

(1)总线锁和缓存锁机制

一.什么是总线

所谓总线,就是CPU与内存和输入输出设备传输信息的公共通道。当CPU和内存进行数据交互时,必须经过总线来传输。

 

二.总线锁

所谓总线锁就是:如果某个CPU要修改主内存的某数据,那么就往总线发出一个Lock#信号。这个信号能够确保主内存只有该CPU可以访问,其他CPU的请求会被阻塞。这就使得同一时刻只有一个CPU能够访问主内存,从而解决缓存不一致问题。

 

所以总线锁可以理解为:当一个CPU往总线发出一个Lock#信号时,其他CPU的的请求将会被阻塞,于是该CPU就能独占主内存(共享内存)了。

 

总线锁把CPU和内存之间的通信锁住了,从而使得锁定期间,其他CPU不能操作其他内存地址的数据。所以总线锁虽然解决了缓存不一致的问题,但却大幅降低了CPU的利用率,于是CPU使用缓存锁来替代总线锁。

 

三.缓存锁

如果当前CPU访问的数据已经缓存在其他CPU的高速缓存中,那么当前CPU便不会在总线上发出一个Lock#信号,而是采用MESI缓存一致性协议来保证多个CPU的缓存一致性。

 

(2)MESI缓存一致性协议

该协议要求每个CPU都可以监听到总线上的数据事件并做出相应的处理。当某个CPU向总线发出请求时,其他CPU便能监听到总线收到的请求,从而可以根据当前缓存行的状态和监听的请求类型来更新缓存行状态。这其实也就是所谓的CPU嗅探机制。

 

这样MESI缓存一致性协议就能保证,在CPU的缓存模型下,就不会出现多线程并发读写变量,各CPU没有办法及时感知到的问题,也就是解决了CPU缓存的一致性问题。

 

(3)CPU、高速缓存、内存之间的关系总结

一.高速缓存可解决CPU与内存的速度矛盾

为了解决CPU与内存速度之间的矛盾,引入了高速缓存作为内存与CPU之间的缓冲。

 

这里的高速缓存其实就是三级缓存。每个CPU可能有多个物理核,每个物理核会有多个逻辑核。每个CPU都有自己的三级缓存,每个物理核都有自己的一二级缓存。

 

二.每个CPU都有自己的高速缓存

每个CPU都有自己的高速缓存,而它们又共享同一个主内存。当多个CPU的运算任务都涉及到同一块主内存区域时,将可能导致各自的高速缓存的数据不一致。为了解决这种缓存数据不一致,引入了如MESI这些缓存一致性协议(嗅探)。

 

三.CPU的乱序执行优化

为了使CPU内部的运算单元能尽量被充分利用,CPU可能会对输入代码进行乱序执行优化。与CPU的乱序执行优化类似,JVM的即时编译中也有指令重排序优化。

 

5.Java的内存模型JMM

(1)Java内存模型JMM简介

Java内存模型是用来屏蔽各种硬件和操作系统的内存访问差异的,以实现让Java程序在各种平台下都能达到一致的内存访问效果。

 

Java内存模型JMM的具体内容包括如下:

一.主内存与工作内存的关系

二.主内存与工作内存之间的交互

三.对于volatile变量的特殊规则

四.针对long和double型变量的特殊规则

五.原子性、可见性和有序性

六.Happens-Before原则(先行发生原则)

 

(2)主内存与工作内存的关系

一.JMM规定所有共享变量都存储在主内存

这里的共享变量只包括实例字段、静态字段和构成数组对象的元素。但不包括局部变量与方法参数,因为这些是线程私有的,不会共享。

 

二.每个线程都有自己的工作内存

这里的工作内存可以理解为各个CPU上的高速缓存,线程的工作内存中保存了被该线程使用的变量的主内存副本。线程对变量的读写操作必须在工作内存中进行,不能直接读写主内存的数据。线程之间无法直接访问对方工作内存中的变量,线程之间变量值的传递均需要通过主内存来完成。

 

三.主内存和工作内存分别存堆栈数据

主内存对应于Java堆中对象的实例数据,工作内存对应于虚拟机栈中的部分区域。对象包括对象头、实例数据、对象填充。

 

(3)主内存与工作内存的交互

JMM定义了8种操作来完成:一个变量如何从主内存拷贝到工作内存,如何从工作内存同步回主内存。这8种操作都是原子的、不可再分的。

 

一.read:读取主内存的变量值并传输到线程的工作内存中,配合load使用

二.load:把read操作从主内存读取到的变量值写入工作内存的变量副本中

三.use:从工作内存中读取出数据,然后交给执行引擎进行计算

四.assign:将执行引擎计算好的值赋值给工作内存中的变量

五.store:把工作内存中的变量值传输回主内存中,配合write使用

六.write:把store操作从工作内存中得到的变量值写入主内存的变量中

七.lock:作用于主内存中的变量,用来标识变量被某个线程独占

八.unlock:作用于主内存中的变量,用来释放锁定状态

在Java内存模型下,多线程并发运行依然存在CPU高速缓存不一致问题。线程1修改了flag之后write回主内存,线程2也还是没法感知到flag已修改。

 

(4)对于volatile变量的特殊规则

volatile变量对所有线程是立即可见的,对volatile变量的所有写操作都能立刻反映到其他线程之中,volatile变量在各个线程的工作内存中是不存在一致性问题的。

 

从物理存储角度看,各线程的工作内存的volatile变量也可存在不一致的情况。但由于各线程每次使用volatile变量前都刷新,执行引擎看不到不一致的情况。因此可以认为不存在不一致的问题。

 

volatile变量是禁止指令重排序优化的。

 

(5)针对long和double型变量的特殊规则

long合double的非原子性协定:允许虚拟机将没有被volatile修饰的64位数据的读写操作,划分为两次32位的操作来进行。即允许虚拟机自行选择是否要保证64位数据类型的:read、load、store和write四个操作的原子性。

 

如果有多个线程共享一个并未声明位volatile的long或double类型的变量,且同时对它们进行读取和修改,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的"半个变量"的值。这种情况很罕见,64位的JVM不会出现,但32位的JVM有可能出现。

 

6.JMM如何处理并发中的原子性可见性有序性

(1)并发过程中可能产生的三类问题

一.原子性问题

对于一行代码"n++",只要多个线程并发执行,都不保证该操作是原子性的。如果保证了该自增操作的原子性,那么下图线程1的i++为1,线程2的i++为2。

二.可见性问题

线程1修改完了主内存的某个变量值,线程2一直读取的是CPU高速缓存中的该变量值,线程1修改完的该变量值对线程2不可见。

 

三.有序性问题

有序性指的是程序按照代码的先后顺序执行。而编译器为了优化性能,有时候会改变程序中语句的先后顺序。即编译器和指令器有时为了提高代码执行效率,会将指令进行重排序。比如"a=1;b=2;",编译器优化后可能就变成了"b=7;a=6"。此时编译器虽然调整了语句的顺序,但不会影响最终结果。

 

例子一:

假设有两个线程,线程1先执行init()方法,线程2后执行doxxx()方法。由于init()方法的操作1和操作2没有依赖关系,所以编译器可能会对其重排序。经过编译器的重排序后,线程1可能会先执行操作2,然后再执行操作1。这时线程2在while循环中就会发现flag = true,于是执行execute()方法。但此时init()方法的prepare()还没执行完,从而引发代码逻辑异常。

public class ReOrderExample {
    boolean flag = false;

    //线程1先执行init()方法
    public void init() {
        //准备资源
        prepare();//操作1
        flag = true;//操作2 
    }
    
    //线程2后执行doxxx()方法
    public void doxxx() {
        while(!flag) {
            Thread.sleep(1000);
        }
        execute();//基于准备好的资源执行操作  
    }
    
    private void prepare() {
        ...    
    }
        
    //prepare()执行完才能保证execute()方法的逻辑执行正确
    private void execute() {
        ...    
    }
}

例子二:

在下面利用双重检查创建单例的代码中,Singleton.getInstance()方法会先判断inst是否为空。如果为空则锁定Singleton.class并再次检查inst是否为空,如果还为空那么就创建一个Singleton的对象实例。

 

其中"inst = new Singleton()"会执行三个指令:

指令1:分配对象的内存空间

指令2:初始化对象

指令3:设置inst变量指向指令1分配的内存地址

 

如果按照正常顺序来执行,那么是不会有问题的。但是由于指令2和指令3不存在依赖关系,所以编译器优化后可能进行重排序。于是执行顺序变为:指令1 -> 指令3 -> 指令2。那么就可能发生:在线程A刚把inst指向对应地址后,线程B获取到执行权。然后线程B便获取到一个没有初始化的对象,从而产生空指针异常。

public class Singleton {
    private static Singleton inst;
  
    private Singleton() {
            
    }
  
    public static Singleton getInstance() {
        if (inst == null) {
            synchronized(Singleton.class) {
                if (inst == null) {
                    //开辟空间,inst指向地址,初始化
                    inst = new Singleton();
                }
            }        
        }
        return inst;
    }
}

(2)JMM如何处理原子性

由JMM来直接保证的原子性变量操作包括:read、load、assign、use、store和write六个。

 

基本数据类型的访问、读写都是原子性的,例外就是long和double的非原子性协定。

 

JMM还提供了lock和unlock操作来满足更大范围的原子性保证。这是通过字节码指令monitorenter和monitorexit来隐式地使用这两个操作的,这两个字节码指令反映到Java代码中就是同步块(synchronized修饰的代码)。

 

(3)JMM如何处理可见性

可见性就是指当一个线程修改了共享变量的值,其他线程能立即得知该修改。

 

JMM是通过在变量被修改后将新值同步回主内存,在变量被读取前从主内存刷新变量值的方式来实现可见性的,无论普通变量还是volatile变量都是如此。

 

普通变量和volatile变量的区别是:volatile保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。也就是volatile保证了多线程操作时变量的可见性,而普通变量则不能保证。

 

除了volatile关键字,synchronized和final两个关键字也能实现可见性。

 

synchronized的可见性是通过如下这条规则获得的:对一个变量执行unlock操作之前,必须把变量先同步回主内存中。

 

final的可见性是指被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把引用this传递出去,那么其他线程就能看见final字段的值。

 

(4)JMM如何处理有序性

一.volatile和synchronized关键字可保证多线程操作的有序性

volatile的有序性是由于volatile变量禁止了指令重排序优化。

 

synchronized的有序性则是通过如下这条规则来获得的:一个变量在同一时刻只允许一个线程对其进行lock操作。

 

二.通过Happens-Before规则来保证多线程操作的有序性

Happens-Before规则指定了哪些操作是不能进行重排序的。

 

7.volatile如何保证可见性

(1)volatile型变量的特殊规则

一.volatile变量对所有线程都是立即可见的

对volatile变量的所有写操作都能立刻反映到其他线程之中,volatile变量在各个线程的工作内存中是不存在数据不一致性的问题。

 

从物理存储的角度看,各个线程的工作内存中,volatile变量也可能存在不一致。但由于各个工作线程在每次使用volatile变量之前都要先刷新其值,于是执行引擎便看不到不一致的情况,因此可以认为不存在不一致的问题。

 

二.volatile变量是禁止指令重排序优化的

指令重排序是指CPU将多条指令,不按程序规定的顺序,分开发送给各个相应的电路单元进行处理。可见,volatile型变量的特殊规则就规定了volatile变量对所有线程立即可见。

 

(2)volatile如何保证可见性

普通变量和volatile变量的区别是:volatile保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。也就是volatile保证了多线程操作时变量的可见性,而普通变量则不能保证。

如果flag变量是加了volatile关键字,那么当线程1通过assign操作将flag = 1写回工作内存时,会立即执行store和write操作将flag = 1同步到主内存。

 

同时还会让线程2的工作内存中的flag变量的缓存过期,这样当线程2后续从工作内存里读取flag变量的值时,发现缓存已经过期就会重新从主内存中加载flag = 1的值。

 

所以通过volatile关键字可以实现这样的效果:当一个线程修改了变量值,其他线程可以马上感知这个变量值。

 

8.volatile为什么无法保证原子性

(1)volatile型变量的特殊规则

一.volatile变量对所有线程都是立即可见的

对volatile变量的所有写操作都能立刻反映到其他线程之中,volatile变量在各个线程的工作内存中是不存在数据不一致性的问题的。

 

从物理存储的角度看,各个线程的工作内存中,volatile变量也可能存在不一致。但由于各个工作线程在每次使用volatile变量之前都要先刷新其值,于是执行引擎便看不到不一致的情况,因此可以认为不存在不一致的问题。

 

二.volatile变量是禁止指令重排序优化的

指令重排序是指CPU将多条指令,不按程序规定的顺序,分开发送给各个相应的电路单元进行处理。可见,volatile型变量的特殊规则并没有原子性方面的保证。

 

(2)volatile不能保证原子性的字节码解释

比如increase()方法只有一行代码,用javap反编译可知由4条字节码指令构成。当get_field指令把n的值取到操作栈顶时,volatile保证了n的值此时是最新的。但线程1执行iconst_1、iadd这些指令时,线程2可能已经把n的值改变了。于是此时线程1的操作栈顶的n值,就变成了过期数据,所以线程1执行put_field指令后就会把较小的n值同步回主内存中。

//n定义为初始值为0的静态变量
public volatile int n = 0;
public void increase() {
    n++;
}

//javap反编译后的字节码
get_field
iconst_1
iadd
put_field

严格来说,volatile并不是轻量级的锁或者是轻量级同步机制。因为对于n++这样的基本操作,加了volatile关键字也无法保证原子性。而锁和同步机制,如synchonized或者lock是可以保证原子性的。

 

9.volatile如何保证有序性

(1)Happens-Before规则介绍

Happens-Before规则指定了两个操作间的执行顺序。如果一个操作Happens-Before另一个操作,那么第一个操作的执行结果将对第二个操作可见,而且第一个操作的执行顺序排在第二个操作之前。

 

两个操作之间存在Happens-Before关系,并不意味着实际就是按Happens-Before规则指定的顺序来执行的。如果重排序后的执行结果与按Happens-Before规则执行的结果一致,那么JMM是允许这种重排序的。

 

As-If-Serial保证了单线程内程序的执行结果不被改变(不管怎么重排序),Happens-Before保证了多线程下的程序执行结果不被改变(不管怎么重排序)。这两者都是为了不改变程序执行结果的前提,尽可能提高程序执行的并行度。

 

总结:

虽然编译器、指令器可能会对代码进行重排序,但要遵守一定的规则。Happens-Before规则就是限制了不能随便重排序,如果不符合Happens-Before规则,那么就可以按编译器、指令器要求重排序。

 

(2)Happens-Before规则详情

Happens-Before就是先行发生的意思。

一.程序次序规则

一个线程中的每个操作,先行发生于该线程中的任意后续操作。

二.锁规则

对一个锁的unlock操作先行发生于后面对该锁的lock操作。

三.volatile变量规则

对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作。

四.传递规则

如果操作A先行发生于操作B,而操作B又先行发生于操作C,那么就可以得出操作A先行发生于操作C。

五.start()规则

如果线程A执行线程B的start()方法启动线程B,那么线程A执行线程B的start()方法这一操作,先行发生于线程B的任意操作。

六.join()规则

如果线程A执行线程B的join()方法并成功返回,那么线程B的任意操作先行发生于,线程A执行线程B的join()方法的这一操作。

 

(3)volatile如何保证有序性

一.Happens-Before规则的volatile变量规则

程序中的代码如果满足上面这8条规则,就一定会保证指令的顺序。但是如果没满足上面的8条规则,那么就可能会出现指令重排。

 

如对一个volatile变量的写操作先行发生于后面对这个volatile变量的读操作。

 

二.volatile型变量的特殊规则

volatile型变量会禁止指令重排序优化。在有序性问题的例子一中,使用volatile修饰flag能禁止重排序避免逻辑异常。在有序性问题的例子二中,使用volatile修饰instance能禁止重排序避免异常。

 

10.volatile的原理(Lock前缀指令 + 内存屏障)

(1)Lock前缀指令 + MESI实现可见性

如果对volatile关键字修饰的变量执行写操作,那么JVM就会向CPU发送一条Lock前缀指令,将这个变量所在的缓存行数据写回到主内存中。

 

同时根据MESI缓存一致性协议,各个CPU会通过嗅探在总线上传播的数据,来检查该变量的缓存值是否过期。如果发现过期,CPU就会将该变量所在的缓存行设置成无效状态。后续当这个CPU要读取该变量时,就会从主内存中加载最新的数据。

 

所以Lock前缀指令 + MESI缓存一致性协议实现了volatile型变量的可见性。Lock前缀指令会引起将volatile型变量所在的缓存行数据写回到主内存,MESI缓存一致性协议可让CPU检查出哪些缓存被修改,同时令缓存失效。

 

(2)通过内存屏障实现禁止指令重排序

一.通过内存屏障来禁止某些指令重排序

加了volatile关键字的变量,可以保证前后的一些代码不会被指令重排。那么这个是如何做到的呢?volatille是如何保证有序性的呢?

 

为了保证内存可见性,Java编译器会在生成指令序列的适当位置,插入内存屏障指令来禁止特定类型的指令重排序。

 

二.JMM的4种内存屏障指令

一.LoadLoad屏障

Load1;LoadLoad;Load2

确保Load1数据的装载,先于Load2及所有后续装载指令的装载。也就是Load1对应的代码和Load2对应的代码,是不能指令重排的。

 

二.StoreStore屏障

Store1;StoreStore;Store2

确保Store1数据刷新到主内存,先于Store2及所有后续存储指令的存储。

 

三.LoadStore屏障

Load1;LoadStore;Store2

确保Load1数据的装载,先于Store2及所有后续的存储指令刷新到主内存。

 

四.StoreLoad屏障

Store1;StoreLoad;Load2

确保Store1数据刷新到主内存,先于Load2及所有后续装载指令的装载。

 

三.JMM制定的volatile重排序规则表

举例来说,第三行最后一个单元格的意思是:当第一个操作为普通变量的读或写时,如果第二个操作为volatile些,则编译器不能重排序这两个操作。

当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。该规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。

 

当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。该规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。

 

当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

 

四.JMM内存屏障的插入策略

volatile的作用就是对于volatile变量的读写操作,都会加入内存屏障。

每个volatile写操作前,加StoreStore屏障,禁止前面的普通写和它重排;

每个volatile写操作后,加StoreLoad屏障,禁止后面的volatile读/写和它重排;

每个volatile读操作后,加LoadLoad屏障,禁止后面的普通读和它重排;

每个volatile读操作后,加LoadStore屏障,禁止后面的普通写和它重排;

public class VolatileStoreStoreExample {
    int a = 0;
    volatile boolean flag = false;

    //线程1执行writer()方法
    public void writer() {
        a = 1;//普通写,操作1
        //加入StoreStore屏障,避免操作1和操作2发生重排序,影响线程2的执行结果
        flag = true;//volatile写,操作2
    }

    //线程2执行reader()方法
    public void reader() {
        if (flag) {//操作3
            int i = a * a;//操作4
            ...
        }
    }
}

public class VolatileStoreLoadExample {
    volatile int a = 0;
    volatile boolean flag = false;

    //线程1执行writer()方法
    public void writer() {
        a = 1;//volatile写,操作1
        //加入StoreLoad屏障,避免操作1和操作2发生重排序,影响线程2的执行结果
        flag = true;//volatile写,操作2
    }

    //线程2执行reader()方法
    public void reader() {
        if (flag) {//操作3
            int i = a * a;//操作4
            ...
        }
    }
}

11.双重检查单例模式的volatile优化

(1)双重检查单例模式的实现缺陷

在下面利用双重检查创建单例的代码中,Singleton.getInstance()方法会先判断inst是否为空,如果为空则锁定Singleton.class并再次检查inst是否为空,如果还为空就创建一个Singleton的对象实例。

 

其中"inst = new Singleton()"会执行三个指令:

指令1:分配对象的内存空间

指令2:初始化对象

指令3:设置inst变量指向指令1分配的内存地址

 

如果按照正常顺序来执行,那么是不会有问题的。但是由于指令2和指令3不存在依赖关系,所以编译器优化后可能进行重排序。于是执行顺序变为:指令1 -> 指令3 -> 指令2。那么就可能发生:在线程A刚把inst指向对应地址后,线程B获取到执行权。然后线程B便获取到一个没有初始化的对象,从而产生空指针异常。

public class Singleton {
    private static Singleton inst;
  
    private Singleton() {
            
    }
  
    public static Singleton getInstance() {
        if (inst == null) {
            synchronized(Singleton.class) {
                if (inst == null) {
                    //开辟空间,inst指向地址,初始化
                    inst = new Singleton();
                }
            }        
        }
        return inst;
    }
}

(2)双重检查单例模式的volatile优化

public class Singleton {
    private static volatile Singleton inst;
  
    private Singleton() {
            
    }
  
    public static Singleton getInstance() {
        if (inst == null) {
            synchronized(Singleton.class) {
                if (inst == null) {
                    //开辟空间,inst指向地址,初始化
                    inst = new Singleton();
                }
            }        
        }
        return inst;
    }
}

文章转载自: 东阳马生架构

原文链接: www.cnblogs.com/mjunz/p/187…

体验地址: www.jnpfsoft.com/?from=001YH