java并发编程-volatile底层分析与面试总结

前言

写博客即是为了记录自己的学习历程,也希望能够遇到志同道合的朋友一起学习。文章在撰写过程中难免有疏漏和错误,欢迎指出文章的不足之处!更多内容请关注: 小布丁

一、现代计算机内存结构

1、 计算机内存组成结构

现代计算机简单内存模型-图一

1.1 CPU读取存储器数据大致过程:

  1. CPU寄存器分为多种寄存器,比如指令寄存器(IR)、程序计数器(PC)、地址寄存器(AR)、数据寄存器(DR)、累加寄存器(AC)、程序状态字寄存器(PSW)等,如果CPU读取的数据在数据寄存器中,很简单直接读取即可。
  2. CPU读取一级缓存L1中的数据,先锁定L1中的缓存行,读取数据,解锁缓存行。
  3. CPU读取二级缓存L2中的数据,先去L1中去取,没有取到。再去L2中查找,找到以后锁定数据的缓存行,复制到L1中区,解锁L2,锁定L1中数据的缓存行,读取L1的数据,解锁L1。
  4. CPU读取L3中的数据,和L2类似,先锁定L3,复制到L2,解锁L3,锁定L2,复制L2到L1,解锁L2,锁定L1,读取L1,最后解锁L1。
    从以上数据的读取过程可以看出,空间大小L3>L2>L1,而且最主要的就是L2有L1的全部数据,L3有L2的全部数据。
  5. CPU读取内存,通知内存控制器占用总线带宽,通知内存加锁,发起内存读请求,等待回应,回应数据保存到L3(如果没有就到L2),再从L3/2到L1,再从L1到CPU,之后解除总线锁定。

2、缓存一致性协议(MESI)

在多处理器任务系统中,每个处理器都有自己的高速缓存并同时共享同一主内存。基于高速缓存很好的解决了处理器与内存的速度矛盾,但同时有带来了新的问题:缓存一致性问题。在多个处理器所运行的任务都涉及到同一块主存区域时,将可能导致缓存到的数据不一致的问题。为了解决这个问题,多个处理器缓存数据时都遵循一些协议,这类协议有MSI、MESI、MOSI等。

2.1 MESI状态解释:

状态描述监听任务
M 修改 (Modified)该Cache line有效,数据被修改了,和内存中的数据不一致,数据只存在于本Cache中。缓存行必须时刻监听所有试图读该缓存行相对就主存的操作,这种操作必须在缓存将该缓存行写回主存并将状态变成S(共享)状态之前被延迟执行。
E 独享、互斥 (Exclusive)该Cache line有效,数据和内存中的数据一致,数据只存在于本Cache中。缓存行也必须监听其它缓存读主存中该缓存行的操作,一旦有这种操作,该缓存行需要变成S(共享)状态。
S 共享 (Shared)该Cache line有效,数据和内存中的数据一致,数据存在于很多Cache中。缓存行也必须监听其它缓存使该缓存行无效或者独享该缓存行的请求,并将该缓存行变成无效(Invalid)。
I 无效 (Invalid)该Cache line无效。

2.2 MESI状态转换过程:

总线嗅探(Snooping) 这是一个跟踪每个缓存行的状态的缓存子系统。该系统使用一个称为 “总线动态监视” 或者称为“总线嗅探” 的技术来监视在系统总线上发生的所有事务,以检测缓存中的某个地址上何时发生了读取或写入操作,可以理解成消息总线的一个监听任务。

假设线程1运行在CPU1上,线程2运行在CPU2上,对于这两个线程对计算机主内存的数据交汇主要分以下三种情形,同时去读,一个读一个写,同时写,情况如下:
6. C1 read、C2 read:C1去读取主内存中的值,会将当前缓存行置成E(独享),并通过消息总线的嗅探机制时刻监听主内存的状态,如果监听到C2对主内存有读的操作,那么对于C1、C2的缓存行的状态均为S(共享)。状态变换E->S.
7. C1 write、C2 read:如果C2需要将新的值写入主内存,那么C1的会将自身状态先置成M(修改),修改完主内存被加载到自身的缓存行以后那么C1的状态为E(独占),C2嗅探到主内存的值修改,那么C2的状态置成I(无效)。如果C2需要重新读取主内存的值,那么C1会嗅探到C2的读操作,那么此时C1和C2的状态都为S(共享)。
8. C1 write、C2 write:如果两个CPU同时需要去修改主内存,都要去锁定主内存,那么计算机会进行裁决,一个指令周期之内只能有一个CPU对主内存进行修改。假设计算机裁决C1能够去修改,那么C1是M(修改),C2是I(无效),当C1修改完毕为E(独占)。如果C2需要重新修改主内存的数据,那么此时C2的状态为M(修改),修改完毕S(共享),C1嗅探到主内存改变,C1也变成S(共享)。

2.3 缓存行失效的场景

  1. 数据的实际存储长度大于该缓存行的长度
  2. 早期的CPU并不支持缓存一致性协议

二、JAVA内存模型分析

1、JMM内存组成结构

java的内存模型其实和计算机的内存模型很相似,也有自己的本地内存,也存在类似计算机的主内存,还有类似消息中线的JMM控制器,大致结构如下:
JMM内存模型
其工作原理也是和计算机底层的工作原理类似,说白了就是复制主内存的数据到各自的工作内存然后计算完毕后与主内存做数据交换的过程。

2、java内存模型内存交互操作流程

  1. java对于内存的八大原子操作

    • lock(锁定):作用于主内存的变量,把一个变量标记为一条线程独占状
    • unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
    • read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
    • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
    • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
    • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋给工作内存的变量
    • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
    • write(写入):作用于工作内存的变量,它把store操作从工作内存中的一个变量的值传送到主内存的变量
  2. Java内存模型内存交互操作
    在这里插入图片描述注意:把一个变量从主内存中复制到工作内存中,就需要按顺序地执行read和load操作,如果把变量从工作内存中同步到主内存中,就需要按顺序地执行store和write操作。但Java内存模型只要求上述8大操作(原子操作)必须按顺序执行,而没有保证必须是连续执行。

  3. Java内存模型内存同步规则

    • 不允许一个线程无原因地(没有发生过任何assign操作)把数据从工作内存同步回主内存中
    • 不允许read和load、store和write操作之一单独出现,即不允许一个变量从主内存读取了但工作内存不接受,或者从工作内存发起回写了但主内存不接受的情况出现。
    • 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或者assign)的变量。即就是对一个变量实施use和store操作之前,必须先自行assign和load操作
    • 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以被同一线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁。lock和unlock必须成对出现
    • 如果对一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量之前需要重新执行load或assign操作初始化变量的值
    • 如果一个变量事先没有被lock操作锁定,则不允许对它执行unlock操作;也不允许去unlock一个被其他线程锁定的变量
    • 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(执行store和write操作)

3、并发编程中的相关特性简介

  • 原子性(volatile不能保证):即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行
    举个很简单的例子,对于现有代码片段int a = b + 1,CPU在内存中的操作过程简单分析如下:
    1.先复制b在内存中的到工作内存
    2.进行a = b + 1的计算
    3.将计算结果写回a的主内存
    而对于CPU来讲,这三个步骤并不是连续执行的,CPU的执行顺序完全取决于线程对于CPU取得的时间片段,有可能执行完1以后去执行其他线程的程序后回来接着执行2和3。但是volatile并不能够保证线程的原子性,如下代码:

    public class JUCTest {
    	public static volatile int t = 0;
    	public static void main(String[] args) {
    		Thread[] threads = new Thread[10];
            for(int i = 0; i < 10; i++){
                //每个线程对t进行1000次加1的操作
                threads[i] = new Thread(new Runnable(){
                    @Override
                    public void run(){
                        for(int j = 0; j < 1000; j++){
                            t = t + 1;
                        }
                    }
                });
                threads[i].start();
            }
            //等待所有累加线程都结束
            while(Thread.activeCount() > 1){
                Thread.yield();
            }
            //打印t的值
            System.out.println("执行结果:"+t);
        }
    }
    执行结果: 9446
    

    仔细分析上面的代码,10个线程,每个线程对于对volatile修饰的变量t加1000次,如果能够保证原子性即每次进行取值、加1、写值回内存应该是原子操作每个线程能够保证一次成功,中途不会执行其它线程,那么最后的执行结果应该是1000*10=10000, 然而实际执行结果并不是这样。

  • 有序性(volatile保证):程序执行的顺序按照代码的先后顺序执行,禁止进行指令重排序
    有序性是指对于单线程的执行代码,我们总是认为代码的执行是按顺序依次执行的,这样的理解并没有毛病,毕竟对于单线程而言确实如此,但对于多线程环境,则可能出现乱序现象,因为程序编译成机器码指令后可能会出现指令重排现象,重排后的指令与原指令的顺序未必一致,要明白的是,在Java程序中,倘若在本线程内,所有操作都视为有序行为,如果是多线程环境下,一个线程中观察另外一个线程,所有操作都是无序的,前半句指的是单线程内保证串行语义执行的一致性,后半句则指指令重排现象和工作内存与主内存同步延迟现象

  • 可见性(volatile保证):可见性指的是当一个线程修改了某个共享变量的值,其他线程是否能够马上得知这个修改的值
    对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题

3、volatile关键字底层分析

  1. volatile关键字语义

    • 保证被volatile修饰的共享变量对所有线程总数可见的,也就是当一个线程修改了一个被volatile修饰共享变量的值,新值总是可以被其他线程立即得知
    • 禁止指令重排序优化
  2. volatile关键字如何保证变量的可见性?
    先来看一段代码验证一下可见性的实现:

    public class VolatileVisibilitySample {
    	volatile boolean initFlag = false;
    
    	public void save() {
    		this.initFlag = true;
    		String threadname = Thread.currentThread().getName();
    		System.out.println("线程:" + threadname + ":修改共享变量initFlag,当前状态: "+initFlag);
    	}
    
    	public void load() {
    		String threadname = Thread.currentThread().getName();
    		while (!initFlag) {
    			// 线程在此处空跑,等待initFlag状态改变
    		}
    		System.out.println("线程:" + threadname + "当前线程嗅探到initFlag的状态的改变,当前状态: "+initFlag);
    	}
    
    	public static void main(String[] args) {
    		VolatileVisibilitySample sample = new VolatileVisibilitySample();
    		new Thread(new Runnable() {
    			@Override
    			public void run() {
    				try {
    					Thread.sleep(10);
    				} catch (InterruptedException e) {
    					e.printStackTrace();
    				}
    				sample.save();
    			}
    		}).start();
    		
    		new Thread(new Runnable() {
    			@Override
    			public void run() {
    				try {
    					Thread.sleep(10);
    				} catch (InterruptedException e) {
    					e.printStackTrace();
    				}
    				sample.load();
    			}
    		}).start();
    	}
    }
    控制台输出如下:
    线程:Thread-0:修改共享变量initFlag,当前状态: true
    线程:Thread-1当前线程嗅探到initFlag的状态的改变,当前状态: true
    

    同归上面代码可以看见线程一对于volatile修饰的变量修改,线程2是能够立即看见的,那么到底是怎么实现的呢?
    其实很简单,对于被volatile修饰的变量在进行读(load)之前会插入一个读屏障的内存指令,写(store)之后插入一个写的内存屏障,那么这就意味着:

    • 一旦你完成写入,任何访问这个字段的线程将 会得到最新的值。
    • 在你写入前,会保证所有之前发生的事已经发生,并且任何更新过的数据值也是可见的,因为内存屏障会把之前的写入值都刷新到缓存

    其实很简单理解,就是写之后会立即刷新到主内存触发缓存一致性协议,对于已经读取到旧值的线程会失效,会从新读取主内存的值到工作内存

  3. 现代计算机的内存模型对内存屏障的定义
    首先我们要理解JMM中的happens-before 原则:

    • 程序顺序原则,即在一个线程内必须保证语义串行性,也就是说按照代码顺序执行。
    • 锁规则 解锁(unlock)操作必然发生在后续的同一个锁的加锁(lock)之前,也就是说,如果对于一个锁解锁后,再加锁,那么加锁的动作必须在解锁动作之后(同一个锁)。
    • volatile规则 volatile变量的写,先发生于读,这保证了volatile变量的可见性,简单的理解就是,volatile变量在每次被线程访问时,都强迫从主内存中读该变量的值,而当该变量发生变化时,又会强迫将最新的值刷新到主内存,任何时刻,不同的线程总是能够看到该变量的最新值。
    • 线程启动规则 线程的start()方法先于它的每一个动作,即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start方法时,线程A对共享变量的修改对线程B可见
    • 传递性 A先于B ,B先于C 那么A必然先于C
    • 线程终止规则 线程的所有操作先于线程的终结,Thread.join()方法的作用是等待当前执行的线程终止。假设在线程B终止之前,修改了共享变量,线程A从线程B的join方法成功返回后,线程B对共享变量的修改将对线程A可见
    • 线程中断规则 对线程 interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测线程是否中断
    • 对象终结规则 对象的构造函数执行,结束先于finalize()方法

    对于现代计算机硬件层面来说提供了一系列的内存屏障:

    • lfence,是一种Load Barrier 读屏障
    • sfence, 是一种Store Barrier 写屏障
    • mfence, 是一种全能型的屏障,具备ifence和sfence的能力
    • Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD,ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR,SBB, SUB, XOR, XADD, and XCHG等指令

    不同硬件实现内存屏障的方式不同,Java内存模型屏蔽了这种底层硬件平台的差异,由JVM来为不同的平台生成相应的机器码。 JVM中提供了四类内存屏障指令:

    屏障类型指令示例说明
    LoadLoadLoad1; LoadLoad; Load2保证load1的读取操作在load2及后续读取操作之前执行
    StoreStoreStore1; StoreStore; Store2在store2及其后的写操作执行前,保证store1的写操作已刷新到主内存
    LoadStoreLoad1; LoadStore; Store2在stroe2及其后的写操作执行前,保证load1的读操作已读取结束
    StoreLoadStore1; StoreLoad; Load2保证store1的写操作已刷新到主内存之后,load2及其后的读操作才能执行
  4. volatile的内存屏障与有序性分析
    在被volatile修饰的变量并不是所有的读写操作都参与指令重排,具体要分情况,参考如下规则表:

    第一个操作第二个操作:普通读写第二个操作:volatile读第二个操作:volatile写
    普通读写可以重排可以重排不可以重排
    volatile读不可以重排不可以重排不可以重排
    volatile写可以重排不可以重排不可以重排

    举例来说,第二行最后一个单元格(加粗个单元格)的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作,伪代码如下:

    volatile static int  c=2;   //1
    		int a= 1;           //2
    		c=c+a;              //3
    

    比如这种情况就不会第1和第2进行重排
    从上图可以看出:

    • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
    • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前
    • 当第一个操作是volatile写,第二个操作是volatile读或写时,不能重排序
  • 总结:就是第一个操作如果是volatile读写,那么第二个操作不管是普通读写还是volatile读写都会禁止指令重排;如果第一个操作是普通读写,第二个操作如果是volatile写会禁止指令重排,其它可以指令重排
    很显然,volatile是通过插入内存屏障来禁止指令重排

三、总结与面试分析

1. 总结

volatile是java提供的轻量级的线程间的同步机制,它只能保证并发编程中的线程间的可见性和有序性,不能保证原子性。volatile有两成语义:第一是volatile修饰的变量修改后其它线程课件;第二个就是禁止指令重排。JMM的内存模型大致就是复制主内存到各自的工作内存中进行计算操作,中间通过缓存一致性协议来达到线程安全。

2. 常见面试题

  • 谈谈volatile的特性

    • 保证线程对所有线程的可见性
    • 禁止指令重排
    • 不能保障线程的原子性
  • 说说并发编程的3大特性

    • 原子性
    • 有序性
    • 可见性
  • 什么是内存可见性,什么是指令重排序?

    • 可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。
    • 指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序
  • volatile是如何解决java并发中可见性的问题?

    • 底层是通过内存屏障实现的,volatile能保证修饰的变量后,可以立即同步回主内存,每次使用前立即先从主内存刷新最新的值。比如laod屏障,在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据。对于Store Barrier来说,在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。
  • volatile如何防止指令重排?

    • 在每个volatile写操作的前面插入一个StoreStore屏障。
    • 在每个volatile写操作的后面插入一个StoreLoad屏障。
    • 在每个volatile读操作的前面插入一个LoadLoad屏障。
    • 在每个volatile读操作的后面插入一个LoadStore屏障
      重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置

最后可以谈谈自己对JMM内存工作模型的理解,缓存一致性协议等等,以上是自己的个人理解加一部分网上资料整理,如有错误欢迎留言指正,不胜感激!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值