java中的final和volatile详解

时间:2023-03-09 19:47:55
java中的final和volatile详解

  相比synchronized,final和volatile也是经常使用的关键字,下面聊一聊这两个关键字的使用和实现

1.使用

  final使用:

  • 修饰类表示该类为终态类,无法被继承
  • 修饰方法表示该方法无法重写,编译器可以内联编译
  • 修饰对象表示该对象引用一旦初始化后,无法被修改
  • 将参数传递到匿名内部类中,参数需要声明为final,其实外部类对与匿名内部类来说就是一个闭包,而java在匿名内部类中拷贝了一份,没有实现引用同步,所以要求参数不可变(参考:https://www.zhihu.com/question/21395848)

例子:

class FinalFieldExample {
final int x;
int y;
static FinalFieldExample f;
public FinalFieldExample() {
x = 3;
y = 4;
} static void writer() {
f = new FinalFieldExample();
} static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
}
}
}

  调用reader方法的线程保证了当f不为null时,x的值一定可以读取到,因为x声明为了final,而y则不一定

  volatile使用:

  • 一般修饰对象
  • 包含两个含义:可见性,禁止指令重排

JSR133 FAQ中例子1:

class VolatileExample {
int x = 0;
volatile boolean v = false;
public void writer() {
x = 42;
v = true;
} public void reader() {
if (v == true) {
//uses x - guaranteed to see 42.
}
}
}

  上边这个例子中,一个线程调用writer方法,一个线程调用reader发放,当先调用writer方法,后调用reader方法时,由于对象v声明为volatile,具有可见性,也就是一个线程的修改会立即在另一个线程中体现出来,因此reader方法中判定会为true,如果进入该分支后,保证x的值一定为42,因为volatile保证了禁止指令重排,所以writer中第一个赋值一定会在第二个赋值前执行。

JSR133 FAQ中例子2:

private volatile static Something instance = null;

public Something getInstance() {
if (instance == null) {
synchronized (this) {
if (instance == null)
instance = new Something();
}
}
return instance;
}

  以上是一个典型double-check locking例子,instance声明为volatile保证了构造Something对象的指令和赋值给instance的指令不会重排,这样的话当其他线程拿到instance的引用不为null时,instance已经初始化完毕了

2.规则和原理 

   在解释下面规则原理之前还是要在说明一下,编译器和处理器为了优化程序执行的速度,会对指令进行重排序,下面通过一个例子来说明:

public class PossibleReordering {
static int x = 0, y = 0;
static int a = 0, b = 0; public static void main(String[] args) throws InterruptedException {
Thread one = new Thread(new Runnable() {
public void run() {
a = 1; //1
x = b; //2
}
}); Thread other = new Thread(new Runnable() {
public void run() {
b = 1; //3
y = a; //4
}
});
one.start();other.start();
one.join();other.join();
System.out.println(“(” + x + “,” + y + “)”);
}

  一般可能认为,这个代码的执行结果可能有三种,分别是(1,0),(0,1),(1,1)(虽然这种情况没有跑出来)这三种情况,但是当连续执行10000多次的时候,发现居然有(0,0)这种情况,实际上这是因为指令在执行的时候发生了重排序,也就是说编译器和处理器会根据实际情况优化代码执行的顺序。指令重排序是以as if serial优化的,所以只要保证在单线程下,最后的执行结果一致即可。上面这个例子就是发生了重排序,如果步骤1和步骤2发生重排序,导致实际执行顺序为2->3->4->1,那么就会出现(0,0)

  JSR133(JMM)中对final域在重排序方面进行了约束,以保证final的正确使用

  final规则

  当final域为对象的时候,编译器和处理器需要遵循这两个重排序原则:

  1. 在构造函数中对一个final对象的写入,与后面的把构造对象的引用赋值给引用对象,这两个操作不得重排序
  2. 初次读取包含一个final对象的引用,和初次读取这个final对象,这两个操作不得重排序

  看下面的例子:

public class FinalExample {
int i; //普通变量
final int j; //final变量
static FinalExample obj; public void FinalExample () { //构造函数
i = 1; //写普通域
j = 2; //写final域
} public static void writer () { //写线程A执行
obj = new FinalExample ();
} public static void reader () { //读线程B执行
FinalExample object = obj; //读对象引用
int a = object.i; //读普通域
int b = object.j; //读final域
}
}

  第一条规则实际上表达的是对final域的写入不可以重排序到构造函数外,这一条本质上包含了下面两条规则:

  1. 针对编译器,编译器不会将构造函数中final域对写入重排序到构造函数外;
  2. 针对处理器,编译器会在构造函数返回结束前,加入一个storestore屏障(后续再详细解释),保证处理器不会将final域的写入重排序到构造函数外

  因此当线程B执行的时候(不考虑读取时候的重排序),当读取object引用时,对象内到final域已经初始化好了,可以正常读取,但是普通域可能没有初始化好

  第二条规则同样也需要在编译器和处理器层面去保证:

  1. 针对编译器,由于读对象的引用和对象引用中的final域,这两个操作存在关联关系,所以编译器不会重排序
  2. 针对处理器,编译器会在读取对象引用中的final域前,插入一个loadload屏障,保证读对象的引用和对象引用中的final域这两个操作不会重排序

  因此当线程B执行的时候,读取对象引用和读取对象中的普通域可能发生重排,而读取对象引用和对象中的final域不会,这样通过和第一条结合时候,对于final域,并发情况下,可以保证final域的正常读取

  上面看到对final域对对象其实是基础类型,如果是引用类型呢

public class FinalReferenceExample {
final int[] intArray; //final是引用类型
static FinalReferenceExample obj; public FinalReferenceExample () { //构造函数
intArray = new int[1]; //
intArray[0] = 1; //
} public static void writerOne () { //写线程A执行
obj = new FinalReferenceExample (); //
} public static void writerTwo () { //写线程B执行
obj.intArray[0] = 2; //
} public static void reader () { //读线程C执行
if (obj != null) { //
int temp1 = obj.intArray[0]; //
}
}
}

  对于final域为引用对象的情况,编译器和处理器有下面对重排序限制:

  1. 在构造函数里对final域的引用对象中的成员的写入,和构造对象的引用的赋值操作,这两个操作不得重排序

  我们先执行线程A,再执行线程B、最后执行线程C,由于重排序的限制,步骤3与步骤1,步骤3与步骤2不可重排序,而步骤1和步骤2存在关联关系,因此线程C执行的时候可以正常读取到final域引用对象的成员值。而线程B的修改是否可以在线程C中读取到则不一定了,需要在线程B、C之间需要使用同步原语

  逃逸

  上面我们通过例子说明了一个问题,构造函数中的final域引用不可逃脱出构造函数,那么如果通过其他方式将构造对象暴露出去呢,请看下面这个例子:

public class FinalReferenceEscapeExample {
final int i;
static FinalReferenceEscapeExample obj; public FinalReferenceEscapeExample () {
i = 1; //1写final域
obj = this; //2 this引用在此“逸出”
} public static void writer() {
new FinalReferenceEscapeExample ();
} public static void reader {
if (obj != null) { //
int temp = obj.i; //
}
}
}

   上面这个例子中,final域的重排序限制无法限制步骤1和步骤2的重排序,那么就有可能出现逃逸现象,当reader线程执行时,可能无法正常访问到构造对象中final域初始化后的值

  volatile规则

  为了达到java跨平台的语言特性,需要将内存重新抽象,这样就诞生了jsr133,jsr133描述了java内存模型,屏蔽了底层实现的差异,保证相同的代码在不同平台上具有相同的表现。根据java内存模型(java memory model,简称JMM)的规定,可以简化为几个happen-before原则,happen-before前后两个操作不可重排序并且前者对后者内存可见:

  • 程序次序法则:线程中的每个动作A都happens-before于该线程中的每一个动作B,其中,在程序中,所有的动作B都能出现在A之后。
  • 监视器锁法则:对一个监视器锁(monitor)的解锁 happens-before于每一个后续对同一监视器锁的加锁,monitor为同步原语的实现方式。
  • volatile变量法则:对volatile域的写入操作happens-before于每一个后续对同一个域的读写操作,写入操作写入内存,读取操作缓存失效读取内存,保证可见性。
  • 线程启动法则:在一个线程里,对Thread.start的调用会happens-before于每个启动线程的动作。
  • 线程终结法则:线程中的任何动作都happens-before于其他线程检测到这个线程已经终结、或者从Thread.join调用中成功返回,或Thread.isAlive返回false。
  • 中断法则:一个线程调用另一个线程的interrupt happens-before于被中断的线程发现中断。
  • 终结法则:一个对象的构造函数的结束happens-before于这个对象finalizer的开始。
  • 传递性:如果A happens-before于B,且B happens-before于C,则A happens-before于C

  happen-before原则是对java内存模型对近似描述,更严谨的java模型定义参考jsr133。jsr133对volatile语意进行了扩展,特别是关于重排序这方面,具体限制如下:

java中的final和volatile详解

  第二项操作指的是第一项操作后面的所有操作,例如,普通的读写操作不可与之后的volatile变量的写操作重排序,参考上面volatile例子,留白的单元格表示在保证java语意不变的情况下可以重排序,例如,java语意不允许对同一个对象的读写重排序,但是对不同对对象的读写可以

  内存屏障

  内存屏障(memory barrier,也称作内存栏栅)是一种CPU指令,用于控制指令重排序和解决可见性问题

  内存屏障可以被分为以下几种类型

  • LoadLoad屏障:对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障:对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。它的开销是四种屏障中最大的。在大多数处理器的实现中,这个屏障是个万能屏障,兼具其它三种内存屏障的功能。

  上面的重排序规则可以通过内存屏障指令实现:

java中的final和volatile详解

  总的来说,内存屏障指令提供了两个方面的功能:

  1. 内存屏障指令前后指令不可重排序,具体重排序限制根据四种内存屏障指令不同而不同,具体含义参考上面的表格
  2. 如果是storeload或者storestore指令,要求volatile对象的写操作写入内存中,同时会导致其他CPU中的缓存行失效

  第一条,我们已经在上面阐明了,对于第二条功能是通过缓存一致性协议达到,缓存一致性协议在单机多核的情况下是通过硬件实现。最为出名的缓存一致性协议是Intel的MESI。

3、总结 

  final和volatile语意在jsr133中做了相应扩展,保证了其语意的正确性。正确理解其使用规则和编译器和处理器实现原理对我们日常工作有意义,不管是final还是volatile底层都依赖内存屏障技术,内存屏障技术(指令)最重要的功能就是对指令重排序对限制,对于volatile对语意中可见性语意,通过内存屏障技术和缓存一致性协议实现。

    

参考:

http://www.infoq.com/cn/articles/java-memory-model-6

http://www.cs.umd.edu/users/pugh/java/memoryModel/jsr-133-faq.html

https://tech.meituan.com/java-memory-reordering.html

http://www.cnblogs.com/dolphin0520/p/3920373.html