原子操作

2023-01-10 10:04:27 浏览数 (1)

原子操作

原子操作类型

原子操作是指一个或者多个不可再分割的操作。这些操作的执行顺序不能被打乱,这些步骤也不可以被切割而只执行其中的一部分(不可中断性)。在 Java 中通过原子操作来完成工作内存主内存的交互,其中原子操作又可分为如下几类:

操作

作用目标

功能

lock

主内存

把变量标识为线程独占状态

unlock

主内存

解除独占状态

read

主内存

把一个变量的值从主内存传输到线程的工作内存

load

工作内存

把 read 操作传过来的变量值放入工作内存的变量副本中

use

工作内存

把工作内存当中的一个变量值传给执行引擎

assign

工作内存

把一个从执行引擎接收到的值赋值给工作内存的变量

store

工作内存中的变量

把工作内存的一个变量的值传送到主内存中

write

主内存中的变量

把 store 操作传来的变量的值放入主内存的变量中

代码语言:javascript复制
// 这是一个原子操作
int i = 1;

// 这不是原子操作,i  是一个多步操作,而且是可以被中断的
// i  可以被分割成3步,第一步读取i的值,第二步计算i 1;第三部将最终值赋值给i
// 在原子类方法 incrementAndGet() 就使用了 Unsafe 类来解决这些问题
i  ;

通过指令 javap -v Main.class 对字节码文件进行分析:

代码语言:javascript复制
public static void main(java.lang.String[]);
	0: iconst_1              // 将整型常量1压入操作数栈中
	1: istore_1              // 将常量1从操作数栈存储到局部变量表的第2个位置
	2: iinc          1, 1    // 将局部变量第2个位置的值加上1
	5: return

显然,字节码指令 iinc 1, 1 在操作系统中完成了多个操作已经超出了原子操作的定义。而 iconstistore 必然是连续指令所以不可能发生重排序,且 int i = 1; 主要由 iconst 指令完成,具有原子性。

实现原子操作

在 Java 中实现原子操作的方法就是使用 CAS 方法,CAS 是 Compare and swap(比较并交换)的简称,这个操作是硬件级操作,在硬件层面保证了操作的原子性。CAS 有 3 个操作数,内存值 value ,旧的预期值 expect ,要修改的新值 update 。当且仅当预期值 expect 和内存值 value 相同时,将内存值 value 修改为 update ,否则什么都不做。Java 中的 sun.misc.Unsafe 类提供了 compareAndSwapIntcompareAndSwapLong 等几个方法实现 CAS。

基于 CAS 的原理,Java 在 JUC 包中实现了一系列原子类的操作 AtomicInteger AtomicLong 等。

volatile与原子操作

volatile 只能保障可见性和有序性不能保障原子性的原因在于其特殊规则:

  • read、load、use 动作必须连续出现
  • assign、store、write 动作必须连续出现

所以,使用 volatile 修饰变量能够保证:

  • 每次读取前必须先从主内存刷新最新的值
  • 每次写入后必须立即同步回主内存当中

也就是说,volatile 关键字修饰的变量看到的随时是自己的最新值,所以线程 1 中对变量 value 的最新修改,对线程 2 是可见的。也因为 volatile 要求三个连续的操作,所以禁用了指令重排序,但同时也失去了原子性的特点(即单一的原子操作)。

而 volatile 关键字通过“内存屏障”来防止指令被重排序。为了实现 volatile 的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。然而,对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此,JMM 采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:

  1. 在每个 volatile 写操作前面 插入一个 StoreStore 屏障。
  2. 在每个 volatile 写操作后面 插入一个 StoreLoad 屏障。
  3. 在每个 volatile 读操作前面 插入一个 LoadLoad 屏障。
  4. 在每个 volatile 读操作后面 插入一个 LoadStore 屏障。

对象逃逸与原子操作

对象逃逸是指当一个对象还没有构造完成时,就使它被其他线程所见。造成以上的原因就是因为在一个线程中对一个对象的实例化不是一个原子操作。对象逃逸在饿汉式、懒汉式单例中较为常见,以其中实例化对象为例:

代码语言:javascript复制
instance = new Singleton(); // 在多线程中该操作最容易发生对象逃逸

通过指令 javap -v Main.class 对字节码文件进行分析:

代码语言:javascript复制
public static void main(java.lang.String[]);
	0: new                 // 在堆上为对象分配内存空间并将地址压入操作数栈顶
	3: dup                 // 复制操作数栈栈顶元素再将这个其压入栈顶
	4: invokespecial       // 调用实例初始化方法<init>:()V并弹出之前入栈对象的地址
	7: astore_1            // 指令将对象地址赋值给 index 为 1 的变量
	8: return

一个对象的实例化过程必然经过分配内存、栈内复制、地址赋值操作,在操作上就已经不具备原子性了,所以在多线程中才有可能被重排序,进而导致对象先进行了地址赋值而地址指向的内容还未完成实例化导致空指针的异常。

重排序与原子操作

编译器和处理器可能会对操作做重排序,在重排序时,编译器和处理器会遵守数据依赖性,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序。这里所说的数据依赖性仅针对单个处理器中执行的指令序列单个线程中执行的操作而言,不同处理器之间和不同线程之间的数据依赖性不会被编译器和处理器考虑。

因而多线程和多核的操作本身是无法预料的,所以在不被 final 域限制、加锁、volatile 修饰的情况下一系列原子操作也不一定会发生重排序,而原子操作是最小执行的操作单位无法再进行拆分被重排序了。

因而在多线程开发和并发编程中,发生数据交互和数据操作时一定要考虑指令重排序问题带来的空指针、缓存不一致等严重的问题。

0 人点赞