自己动手写把”锁”之---JMM和volatile

时间:2021-10-28 04:15:29
一、JAVA内存模型
关于Java内存模型的文章,网上真的数不胜数。在这里我就不打算说的很详细、很严谨了。只力求大家能更好的理解和运用,为后边的技术点做铺垫。
内存模型并不是Java独有的概念,而是我们的计算机硬件平台的一个概念。内存模型描述了程序中变量如何在从内存读出、以及何时写会内存的底层细节。
我们知道,程序运行其实就是CPU和内存的频繁交互的过程。随着CPU的快速发展,CPU的执行速度越来越快,但是内存却很难跟上CPU的执行速度,为了解决这一矛盾,CPU厂商就为每颗CPU加了高速缓存,用来缓解这个速度不匹配的问题。因此,CPU和内存的交互变成了这个样子:
自己动手写把”锁”之---JMM和volatile
自己动手写把”锁”之---JMM和volatile
以上只是在CPU和内存之间加了个高速缓存,其实也还没什么问题。那内存模型这个概念是怎么产生的呢?继续往下看。
CPU虽然在不停的发展,但单个CPU的主频速度不可能无限制的增长,为了进一步提高计算性能就引入了多核技术。由于每个cpu都有自己的高速缓存,当多个CPU操作同一个内存数据时,就产生了缓存不一致的问题。如下图:
自己动手写把”锁”之---JMM和volatile
自己动手写把”锁”之---JMM和volatile
为了解决这个不一致的问题,就需要处理器在运行时要遵循某些协议,这类协议包括MSI、MESI、MOSI等等。到这里就有了内存模型这个概念,它就是用来描述数据在各个高级缓存以及内存之间的交互细节。不同的硬件处理器架构,就会有不同的内存模型。所以用c/c++开发多线程程序时,就需要考虑不同操作平台下的内存模型。
所幸我们是学Java的,Java平台为了屏蔽不同硬件平台的不同内存模型给开发人员带来的成本,引入了Java内存模型,即JAVA Memory Model,简称JMM。
要想深入掌握JAVA多线程并发编程,Java内存模型是必须要了解的。Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。直白点说就是:同一个变量,被多个cpu上执行的多个线程访问,每个cpu的高速缓存都缓存了这个变量,当某个线程修改了高速缓存里的变量,何时通知给其他的cpu线程让它可见,以及何时将变量同步回内存(主存)。如下图:
自己动手写把”锁”之---JMM和volatile
自己动手写把”锁”之---JMM和volatile
Java虚拟机的内存模型和计算机硬件的内存模型基本一致。在Java内存模型中,分为线程私有的本地内存和线程共享的主内存,线程在读写变量时会把主内存里的变量缓存到本地内存,换句话说,本地内存存放了主内存中变量的副本。主内存和本地内存其实是一种逻辑上的划分,并不是实际的物理内存。
这里需要强调一下,这里的变量指的是分配到堆上的变量,即线程之间可以共享的变量。本地变量是线程私有的,所以不会有可见性问题。
二、volatile
Java内存模型中说到了线程间共享变量的可见性问题。可见性问题其实就是缓存不一致的问题。如下图:
自己动手写把”锁”之---JMM和volatile
自己动手写把”锁”之---JMM和volatile
线程B读取变量X,并缓存到了自己的本地内存中,线程A也将变量X缓存到本地内存中并修改为2,这时线程B并不知道变量X修改为2。这就是线程间不可见的问题。为了解决这个问题,就引入了volatile关键字,被volatile修饰的变量将不会在本地内存缓存,线程直接通过主内存来读写变量。虽然解决了不可见的问题,但也是以牺牲性能为代价的。
volatile关键字相信你已经理解了,但是在Java中volatile并不仅仅是这个功能。在这里我通过与c语言中的volatile对比扩展下。
有的时候我们可能会面临这么个场景,线程1执行某些业务逻辑,线程2判断线程1是否执行完,执行完了则线程2执行另一个逻辑,如下伪代码:
自己动手写把”锁”之---JMM和volatile
自己动手写把”锁”之---JMM和volatile
我们通过一个flag变量来标识线程1是否执行完相关逻辑,为了保证flag的改变对线程2可见,这里使用了volatile关键字修饰。如果这个伪代码采用Java实现,这是没问题的,如果c实现,则就会有坑。
这个坑主要是源于指令重排。为了提高执行效率减少内存的交互,编译器会根据情况对执行的指令做一个重排序。所以线程1中执行相关业务逻辑后,再将flag设置为true的逻辑,极有可能重排为:先设置flag=true然后再执行相关业务逻辑。这也是c语言为啥不提倡使用volatile的原因。
但是为什么在Java中就不会有这个坑呢,难道Java没有指令重排序吗?
当然不是,Java也会有重排序,不过Java对volatile做了如下的极大增强:
  • 所有对volatile变量的写操作之前的针对其他变量的读写操作,经过编译器、cpu优化后,都不会被重排到对voltile变量的写操作之后。
  • 所有对volatile变量的读操作之后的针对其他变量的读写操作,经过编译器、cpu优化后,都不会被重排到对voltile变量的读操作之前。
面试中,有面试官比较喜欢问这么一个问题:能否用volatile修饰的整数变量n,通过n++操作实现计数的功能?这个问题就是考查应试者对volatile的理解。我这里简单地说一下。
答案肯定是不能。volatile实现的是线程间共享变量的可见性,并不是原子性操作。++操作其实可以拆分为这么几个步骤:
  1. 读取主内存里的变量
  2. cpu完成变量的++,然后写会主内存。
所以可以想象这么一个执行顺序:
  1. 线程A读取volatile变量X=0
  2. 线程B读取volatile变量X=0
  3. 线程A完成++操作,然后将X=1写回主存。
  4. 线程B也完成++操作将X=1写回主存。
在这么一个执行顺序下,对X进行了++两次,但值却只增加了1。
关于如何实现原子性操作,我将在下一节进行讨论。