结合Java内存模型理解synchronized、volatile和final关键字

 2019-12-10 16:16  阅读(1724)
文章分类:Java Core

在面试时,经常问到Java内存模型以及synchronized、volatile和final关键字。实际上他们是可以相互影响的两个知识点。

目录

一、Java内存模型

1.1 硬件的效率和一致性

1.2 工作内存与主内存

1.3 内存之间的交互操作

1.4 重排序

二、结合内存模型理解三个关键字

一、Java内存模型

参考资料:残雪余香-Java内存模型

1.1 硬件的效率和一致性

由于计算机的处理器运算和内存中的数据存储速度相差了几个数量级,我们会通过高速缓存来平衡双方的差异。每个处理器对自己的缓存进行直接读写,仅在必要的时候将数据写回内存。但这又引入一个新的问题,当内存中的数据存在于多个处理器缓存中时,要如何保证数据的一致性呢?

这时候通过MSI、MESI、MOSI及Dragon Protocol等协议可以达到一致性要求,而对于Java内存模型也是相同的道理。

20191210001706\_1.png

除此之外,为了使多个处理器资源都被充分利用,处理器会对输入代码做乱序执行,比如没有先后关系的代码块按照拓扑顺序执行,而不是代码写入的顺序。与处理器乱序优化的目的相似,Java中也会通过指令重排序来优化程序。

1.2 工作内存与主内存

简单的说,Java内存模型定义了程序中各个变量的访问规则。Java线程与内存的关系就好比处理器与内存的关系,每个Java线程独自享有本地工作内存,通过直接对工作内存中的变量进行读写操作来平衡Java线程运算和数据存储的巨大速度差异;而主内存中的变量是共享的,定义各个变量的访问规则也是为了使得程序变量能够满足一致性,运行能够得到所期望的结果。

不同线程无法访问彼此的工作内存,消息的传递只能通过主存来进行。

20191210001706\_2.png

1.3 内存间的交互操作

20191210001706\_3.png

关于主内存与工作内存之间的具体交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步到主内存之间的实现细节,Java内存模型定义了以下八种操作来完成:

  • lock(锁定):作用于主内存的变量,把一个变量标识为一条线程独占状态。
  • unlock(解锁):作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
  • read(读取):作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
  • assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
  • store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存中一个变量的值传送到主内存的变量中。

如果要把一个变量从主内存中复制到工作内存,就需要按顺寻地执行read和load操作,如果把变量从工作内存中同步回主内存中,就要按顺序地执行store和write操作。Java内存模型只要求上述操作必须按顺序执行,而没有保证必须是连续执行。

Java内存模型还规定了在执行上述八种基本操作时,必须满足如下规则:

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

1.4 指令重排序

在执行程序时为了提高性能,编译器和处理器经常会对指令进行重排序。重排序分成三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义放入前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

为了保证内存的可见性,Java编译器在生成指令时会适当插入内存屏障来保准指令的执行顺序(这也是在volatile关键字中所使用到的)内存屏障分为LoadLoad、LoadStore、StoreLoad和StoreStore四种。

20191210001706\_4.png

二、结合内存模型理解三个关键字

参考资料:fuzhongming05-从Java内存模型理解synchronized、volatile和final关键字

聊聊并发(一)深入分析Volatile的实现原理

2.1 volatile

volatile的意义:对于volatile所修饰的变量,其写操作对于其他所有线程都是可见的。可以理解为,当volatile修饰了某个变量,每次写操作,一定对之后所有读操作的线程可见。或者说,当volatile修饰了某个变量,每次读操作一定能读到先前最后一次变量的修改值。

**1)volatile是如何实现的呢?**当volatile修饰时:

① 被修改后的变量会立即刷入共享内存

处理器为了提高速度,不会和内存直接通讯,而将共享内存变量拷贝到自己的缓存中进行操作,但操作完之后又不知何时写回,所以如果声明了volatile就可以保证变量在修改后立即写回。

② 这个写回的操作会导致其他缓存该内存地址的数据无效

但就算上述的数据立即写回,其它缓存器(Java内存模型中类比为工作内存)中的数据还是旧的,再执行计算操作就会有问题,所以在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了。

处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作的时候,会强制重新从系统内存里把数据读到处理器缓存里。

一个处理器的缓存回写到内存会导致其他处理器的缓存无效。在多核处理器系统中进行操作的时候,使用嗅探技术保证它的内部缓存,系统内存和其他处理器的缓存的数据在总线上保持一致。例如在Pentium和P6 family处理器中,如果通过嗅探一个处理器来检测其他处理器打算写内存地址,而这个地址当前处理共享状态,那么正在嗅探的处理器将无效它的缓存行,在下次访问相同内存地址时,强制执行缓存行填充。

实验观察:在x86处理器下通过工具获取JIT编译器生成的汇编指令来看看对Volatile进行写操作CPU会做什么事情。有volatile变量修饰的共享变量进行写操作的时候会多第二行汇编代码,通过查IA-32架构软件开发者手册可知,lock前缀就保证了上述所说的①缓存数据立即写回和②其它缓存的共享数据无效化。

20191210001706\_5.png

2)同时也要注意,volatile并不能保证复合操作的一致性,它仅保证原子操作的一致性。

比如 a++ 操作等价于:

public void add() {   
        temp = a;        
        temp = temp +1;  
        a = temp;         
    }

代码并不是一个原子操作,所以类似于 a++ 这样的操作会导致并发数据问题。 比如A线程取出a的值,但B线程在A写回++的a前也取出了旧的a值,那么两次更新后 a 仅增加了1。

volatile 变量的写被保证是可以被之后其他线程的读看到的,因此我们可以利用它进行线程间的通信。如:

volatile int a;
    public void set(int b) {
        a = b; 
    }
    public void get() {    
        int i = a; 
    }

线程A执行set()后,线程B执行get(),相当于线程A向线程B发送了消息。

2.2 synchronized

synchonized又叫做对象锁,对象锁是指Java为临界区synchronized(Object)语句指定的对象进行加锁。无论访问用synchronized修饰的对象还是属性,当前对象都会被加锁。有时候面试官会问到访问synchronized static修饰的方法和属性有什么不同,如果多一个static会锁住了这个类的所有对象,是以类为单位的锁。

public synchonized void add();
    public static synchronize void add();

synchronized的作用范围比volatile更广泛,所以它也支持像 ++ 操作这样的复合操作。并且会在锁释放时将工作内存中的引入的共享变量刷回到主内存。

我们可以利用这种互斥性来进行线程间通信。看下面的代码:

public synchronized void add() {
        a++; 
    }

    public synchronized void get() {  
        int i = a; 
    }

当线程A执行 add(),线程B调用get(),由于互斥性,线程A执行完add()后,线程B才能开始执行get(),并且线程A执行完add(),释放锁的时候,会将a的值刷新到共享内存中。因此线程B拿到的a的值是线程A更新之后的。

2.3 final

final关键字可以修饰变量、方法和类,我们这里只讨论final修饰的变量。final变量的特殊之处在于:

final 变量一经初始化,就不能改变其值。

这里的值对于一个对象或者数组来说指的是这个对象或者数组的引用地址。因此,一个线程定义了一个final变量之后,其他任意线程都可以拿到这个变量。但有一点需要注意的是,当这个final变量为对象或者数组时,

1、虽然我们不能将这个变量赋值为其他对象或者数组的引用地址,但是我们可以改变对象的域或者数组中的元素

2、线程对这个对象变量的域或者数据的元素的改变不具有线程可见性。

——————————————————————————————————————–

参考资料已在文章中给出链接,本文通过学习三篇引文总结出,如侵权可联系本人。

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> 结合Java内存模型理解synchronized、volatile和final关键字

相关推荐