volatile

volatile在多处理器开发中保证了共享变量的“可见性”,但不保证“原子性”。可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。如果volatile变量修饰符使用恰当的话,它比synchronized的使用和执行成本更低,因为它不会引起线程上下文的切换和调度。

1. volatile的定义与实现原理

Java语言规范第3版中对volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致地更新,线程应该确保通过排他锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁要更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。在了解volatile实现原理之前,我们先来看下与其实现原理相关的CPU术语与说明。如下表格是CPU术语中相关的一些定义:

术语 英文单词 术语描述
内存屏障 memory barriers 是一组CPU指令,用于实现对内存操作的顺序性
缓存行 cache line 缓存中可以分配的最小储存单元。读写缓存时会加载整个缓存线(缓存线包含一个工作目录和一个缓存行)
缓存行填充 cache fill 当处理器识别到从内存读取操作是可缓存时,它会将整个缓存行填写到适当的缓存中(L1、L2、L3)
缓存命中 cache hit 当处理器进行下一次内存访问的地址,正好是缓存行操作的数据的内存地址,此时会从缓存行中读取数据(缓存线中的工作目录会保存缓存行的数据所在的内存地址)
写命中 write hit 当处理器将操作数写回内存时,它会检查内存地址是否在缓存行中,如果是则会将操作数写回缓存行而不是内存中
写缺失 write misses the cache 当处理器将操作数写回内存时,如果检查到内存地址不存在缓存行中,称之为写缺失。值得注意的是,此时处理器并不是直接将操作数写回主存,而是先从内存读到缓存行,再写回内存,写回时需要先判断cache是否已modified(MESI协议)
原子操作 atomic operations 不可中断的一个或一系列操作

有volatile变量修饰的共享变量进行写操作的时候会多出第一行汇编代码,Lock指令。它在多核处理器下会引发了两件事:

  1. 将当前处理器缓存行的数据写回到系统内存。
  2. 这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。

为了提高处理速度,处理器不直接和内存进行通信,而是先将系统内存的数据读到内部缓存(L1,L2或其他)后再进行操作,但操作完不知道何时会写到内存。如果对声明了volatile的变量进行写操作,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在缓存行的数据写回到内存。但是,就算写回到内存,如果其他处理器缓存的值还是旧的,再执行计算操作就会有问题。所以,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读到处理器缓存里。

2. Synchonized的实现原理

Synchonized关键字可以声明在方法或代码块上,分为三种情况:

  • 对于普通同步方法,锁是当前实例对象。
  • 对于静态同步方法,锁是当前类的Class对象。
  • 对于同步方法块,锁是Synchonized括号里配置的对象。

从JVM规范中可以看到Synchonized在JVM里的实现原理,JVM基于进入和退出Monitor对象来实现方法同步和代码块同步,但两者的实现细节不一样。

  • 方法的同步是通过ACC_SYNCHRONIZED标记符隐式实现同步的,具体实现大致如下:编译后同步方法的flags会包含:ACC_SYNCHRONIZED,JVM会要求调用同步方法的线程先请求锁;
  • 代码块同步是使用monitorenter和monitorexit指令实现的,实现大致如下:

monitorenter:每个对象都是一个监视器锁(monitor)。当monitor被占用时就会处于锁定状态,线程执行monitorenter指令时尝试获取monitor的所有权,如果monitor的进入数为0,则该线程进入monitor,然后将进入数设置为1,该线程即为monitor的所有者;如果线程已经占有该monitor,只是重新进入,则进入monitor的进入数加1(只有首先获得锁的线程才能允许继续获取多个锁)。 如果其他线程已经占用了monitor,则该线程进入阻塞状态,直到monitor的进入数为0,再重新尝试获取monitor的所有权。
monitorexit:执行monitorexit指令的线程必须是对象实例所对应的监视器的所有者,指令执行时,线程会先将进入次数-1,若-1之后进入次数变成0,则线程退出监视器(即释放锁),其他阻塞在该监视器的线程可以重新竞争该监视器的所有权。
随着Java SE 1.6中对Synchonized进行了优化,通过引入了偏向锁和轻量级锁从而减少了由于获得锁和释放锁带来的性能消耗,synchronized用的锁是存在Java对象头的Mark Word里。在运行期间,Mark Word里存储的数据会随着锁标志位的变化而变化:

锁状态 25bit 4bit 1bit 2bit
23bit 2bit 是否为偏向锁 锁标记
偏向锁 线程ID Epoch 对象分代年龄 1 01
轻量级锁 指向栈中锁记录的指针 00
重量级锁 指向互斥锁的指针 10
GC标记 11

1.偏向锁
大多数情况下,锁不仅不存在多线程竞争,而且总是由同一线程多次获得,为了让线程获得锁的代价更低而引入了偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里存储锁偏向的线程ID。
偏向锁使用了一种等到竞争出现才释放锁的机制,所以当其他线程尝试竞争偏向锁时,持有偏向锁的线程才会释放锁。偏向锁的撤销,需要等待全局安全点(在这个时间点上没有正在执行的字节码)。它会首先暂停拥有偏向锁的线程,然后检查持有偏向锁的线程是否活着,如果线程不处于活动状态,则将对象头设置成无锁状态;如果线程仍然活着,拥有偏向锁的栈会被执行,遍历偏向对象的锁记录,栈中的锁记录和对象头的Mark Word要么重新偏向于其他线程,要么恢复到无锁或者标记对象不适合作为偏向锁,最后唤醒暂停的线程。
2.轻量级锁
JVM会先在当前线程的栈桢中创建用于存储锁记录的空间,并将对象头中的Mark Word复制到锁记录中,官方称为Displaced MarkWord。然后线程尝试使用CAS将对象头中的Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋CAS操作来获取锁。如果成功,则表示没有竞争发生,如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
3.重量级锁
此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
4.锁升级流程图
https://www.processon.com/view/link/5fe30c591e08535fa5ddd73d