Java final关键字的内存语义以及并发时long、double的特殊规则

70 阅读5分钟

小知识,大挑战!本文正在参与“程序员必备小知识”创作活动。

java中的final关键字赋予了对象特殊的内存语义,可用于实现线程安全,另外,多线程下在32位的虚拟机中对long、double类型变量的操作可能会有意想不到的表现。

1 final的内存语义

1.1 final域重排序规则

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

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。
  2. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

1.2 写 final 域的重排序规则

JMM 禁止编译器把 final 域的写重排序到构造函数之外。编译器会在 final 域的写之后,构造函数 return 之前,插入一个 StoreStore 屏障。就是这个屏障禁止处理器把 final 域的写重排序到构造函数之外。

  1. final 成员变量必须在声明的时候初始化或者在构造器中初始化,否则就会报编译错误。
  2. 被 final 修饰的字段在声明时或者构造器中,一旦初始化完成,那么在其他线程无须同步就能正确看见 final 字段的值。

1.3 读 final 域的重排序规则

  1. 初次读一个包含 final 域的对象的引用,与随后初次读这个 final 域,这两个操作之间不能重排序。编译器会在读 final 域操作的前面插入一个 LoadLoad 屏障。
  2. 当构造函数结束时,final 类型的值是被保证其他线程访问该对象时,它们的值是可见的。
  3. final 类型的成员变量的值,包括那些用 final 引用指向的 collections 的对象,是读线程安全而无需使用 synchronized 的。

1.4 final域为引用类型

对于引用类型,写final域的重排序规则对编译器和处理器增加了如下约束:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

2 long、double变量的特殊规则

Java内存模型要求lock、unlock、read、load、assign、use、store、write这8个操作都具有原子性,但是对于64位的数据类型(long和double),在模型中特别定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性,这点就是所谓的long和double的非原子性协定(Nonatomic Treatment ofdouble andlong Variables)。关于Java内存模型,可以看这篇文章:Java内存模型与happens-before原则详解

对于32位操作系统来说,单次次操作能处理的最长长度为32bit,而long类型8字节64bit,所以对long的读写都要两条指令才能完成。因此会把一个 64 位 long/ double 型变量的读 / 写操作拆分为两个 32 位的读 / 写操作来执行。如果真的这样,当多个线程共享一个并未声明为volatile的long或者double类型的变量,并同时对他们进行读取修改,那么某些线程可能会读到一些既非初始值也不是其他线程修改值的代表了“半个变量”的数据。

在这里插入图片描述

如上图所示,假设处理器 A 写一个 long 型变量,同时处理器 B 要读这个 long 型变量。处理器 A 中 64 位的写操作被拆分为两个 32 位的写操作,且这两个 32 位的写操作被分配到不同的写事务中执行。同时处理器 B 中 64 位的读操作被拆分为两个 32 位的读操作,且这两个 32 位的读操作被分配到同一个的读事务中执行。当处理器 A 和 B 按上图的时序来执行时,处理器 B 将看到仅仅被处理器 A“写了一半“的无效值。

因此需要使用volatile关键字来防止此类现象。volatile本身不保证获取和设置操作的原子性,仅仅保持修改的可见性。但是java的内存模型保证声明为volatile的long和double变量的get和set操作是原子的(存疑)。

Java语言规范文档:jls-17(docs.oracle.com/javase/spec…

  1. 对于64位的long和double,如果没有被volatile修饰,那么对其操作可以不是原子的。在操作的时候,可以分成两步,每次对32位操作。
  2. 如果使用volatile修饰long和double,那么其读写都是原子操作
  3. 对于64位的引用地址的读写,都是原子操作
  4. 在实现JVM时,可以自由选择是否把读写long和double作为原子操作
  5. 推荐JVM实现为原子操作

参考资料:

  1. 《JSR133规范》
  2. 《Java并发编程之美》
  3. 《实战Java高并发程序设计》
  4. 《Java并发编程的艺术》

如果有什么不懂或者需要交流,可以留言。另外希望点赞、收藏、关注,我将不间断更新各种Java学习博客!