暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

final域内存语义

川聊架构 2021-11-26
299

-     写final域重排序规则    -

    对于final域, 编译器和处理器要遵守两个重排序的规则:

  1. 写final 域的重排序规则禁止把final域的写重排序到构造函数之外

    1. JMM禁止编译器把final域的写重排序到构造函数之外

  2. 编译器会在final域的写之后, 构造函数return之前, 插入一个storestore屏蔽。这个屏蔽禁止处理器把final域的写重排序到构造函数之外。

    先看一段代码在分析:

public class FinalExample {
int i; // 普通变量
final int j; // final变量
static FinalExample obj;
public 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 域
}
}

   比如 writer()obj = new FinalExample()包含两个步骤:

  1. 构建一个FinalExample类型的对象; 

  2. 把这个对象的引用赋值给引用变量obj。

这时写普通域操作被编译器排序到构造器之外:

  •  线程A写final域的操作,被写final的重排序规则“限定”在构造函数之内,读线程B正确读取了final变量初始化之后的值

  •  写final域的重排序规则可以确保,在对象引用未任意线程可见之前,对象final域已经正确初始化了。而普通域不具有这个保障

-     读final域重排序规则    -

    读final域的重排序,在一个线程中,初次读对象引用与初次读对象包含final域, JMM禁止处理器重排序这两个操作(这个规则仅仅针对处理器)。编译器会在读final操作前面插入一个loadload屏障。

    初次读对象引用与初次读该对象包含final域,这两个操作之间存在间接依赖关系, 由于编译器遵守间接依赖关系。因此编译器不会重排序这两个操作。大多数处理器也会遵守间接依赖,也不会重排序这两个操作,但少数处理器允许存在间接依赖关系的操作做重排序(alpha处理器), 这个规则专门用来针对这种处理器。

    reader()包含3个操作:

  1. 初次读引用变量obj.

  2. 初次读引用变量obj指向对象的普通域j;

  3. 初次读引用变量obj指向对象的final域i;

    读对象的普通域的操作被处理器重排序到读对象引用之前。读普通域时,该域还没被写线程写入,这是一个错误的读取操作, 而读final域的重排序规则会把读对象final域的操作“限定”在读对象引用之后, 此时刻final域已经被现场初始化过,这是一个正确的读取操作。

    读final域的重排序规则可以确保,在读一个对象的final域之前, 一定会先读包含这个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 writeOne(){ // 写线程A执行
obj = new FinalReferenceExample();
}
public static void writeTwo(){ // 写线程B执行
obj.intArray[0] = 2;
}
public static void reader(){ // 读线程C执行
if(obj != null){
int temp1 = obj.intArray[0];
}
}
}

前提

    假设线程A先执行writeOne() ,执行完后线程B执行writeTwo() 或 线程C执行reader() :

  1. final域写入 intArray = new int[1];

  2. 对final域引用的对象的成员域的写入intArray[0] = 1;

  3. 被构造的对象的引用赋值给某个引用变量 obj = new FinalReferenceExample();

     结论:1,3 不能重排序, 2,3 不能重排序

说明

    线程B和线程C是竞争关系。JMM可以确保线程C至少能看到线程A在final引用对象的成员域的写入,即C至少能看到数组下标0的值为1. 线程B对数据的写入, 线程C 可能看到也可能看不到。JMM不保证线程B的写入对线程C可见,因为写线程B和读线程C之间存在数据竞争。

    如果想确保线程C看到些线程对数据元素的写入, 线程B和线程C之间需要使用同步原语(lock或volatile) 来确保内存可见性。

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;
}
}
}

说明

    线程A执行writer()方法,线程B执行reader方法。这里的操作2 (this引用在此”逸出”) 使得对象还未完成构造前线程B可见,无法看到final域被初始化后的值。因为这里的操作1 (i=1) 和操作2(obj = this) 之间可能被重排序。

    在构造函数返回前, 被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有被初始化。在构造函数返回后, 任意线程都将保证看到final域正确初始化之后的值。

final语义在处理器中实现

    X86处理器不会对写-写操作做重排序, 在X86处理器中,写final域需要的storestore屏障被省略掉。由于X86处理器不会对存在间接依赖关系的操作做重排序。所以在X86处理器中,读final域需要的loadload屏障也会被省略掉。

end: 在X86处理器中 final域的读/写不会插入任何内存屏障


-     作者介绍    -

若水
架构师一枚,现就职于小米小爱开放平台,一个才貌双全的美女码农,平常喜欢总结知识和刷算法题,经常参加LeetCode算法周赛。
文章转载自川聊架构,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论