深入理解volatile关键字

带着问题读文章

  • volatile 的作用是什么
  • java并发带来的问题:原子性、可见性、有序性
  • DCL是否需要加volatile
  • volatile是如何实现可见性的
  • volatile是如何防止指令重排的
  • volatile的应用场景有哪些

概念

并发编程有3大特性:原子性,可见性,有序性

  • 原子性:多个操作要么全部成功,要么全部失败
  • 可见性:多线程访问共享变量,一个线程对共享变量进行了修改,其他线程能够立刻看到修改后的值
  • 有序性:程序执行的顺序按照代码的顺序执行

volatile 是 Java 中的一个关键字,是轻量级的 synchronized,由于它不会引起线程上下文切换和调度,成本比 synchronized 低。主要的作用:

  • 保证了共享变量的可见性
  • 禁止指令重排,保证有序性

如何保证的可见性

什么是可见性?先了解一下线程间是如何通信的以及Java内存模型

线程间通信机制和同步机制
  • 通信机制

    • 共享内存:通过读-写内存中的公共状态进行隐式通信
    • 消息传递:线程之间没有公共状态,线程之间通过发送消息来显示通信
  • 同步机制
    控制不同线程间操作是按照一定的顺序进行的

    • 共享内存模型:同步是显示执行的,通过加锁来指定某个方法,某个代码块在线程之间互斥执行
    • 消息传递模型:消息的发送必须在消息 接收之前,因此同步是隐式执行的
内存模型

在这里插入图片描述
Java 线程之间通信由Java内存模型控制,JMM定义了线程和主存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存存储了该线程以读-写共享变量的副本。
在这里插入图片描述
线程间通信步骤:

  • 线程A将本地内存A中的更新过的共享变量刷到主内存中去
  • 线程B到主内存中读取线程A之前更新过的共享变量

可见性问题:
如果线程A工作内存中更新了变量并将数据刷回了主内存,而线程B工作内存中的缓存未失效,线程B使用的还是旧的值,这就导致了线程A中的变量对线程B不可见。

volatile 可见性语义

  • 当对volatile变量进行写操作后,JMM会把工作内存中最新的值强制刷回主内存
  • 写操作使其他线程工作内存中的缓存失效

如何禁止指令重排

什么是指令重排
为了提高性能,编译器和处理器常常会对指令进行重排序。
(1)编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
(2)指令级并行的重排序:现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
(3)内存系统的重排序:由于处理器使用缓存和读写缓冲区,这使得加载和存储操作看上去是在乱序执行。

指令重排会导致什么问题
重排序可能会导致多线程程序出现内存可见性问题。例如:

public class Singleton {
    public static Singleton instance;
    private Singleton() {
    }
    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

上述是一个DCL单例的代码,看着好像是没什么问题,其实在超高并发下是会存在一定问题的。首先我们需要知道当new一个对象的时候是分3个步骤执行的

  • (1)new 为对象在堆区开辟一块内存空间
  • (2)init 将对象进行初始化
  • (3)将内存空间的地址赋值给对应的引用

在编译器优化的情况下,步骤2和3可能会发生重排序,步骤变成如下:

  • (1)new 为对象在堆区开辟一块内存空间
  • (2)将内存空间的地址赋值给对应的引用
  • (3)init 将对象进行初始化

此时在原来的单例代码中,第一个线程执行 new Singleton() 时,发生了指令重排,先将对象内存空间地址赋值给了引用,第二个线程执行第二次 instance == null 时发现不为null,直接return了未进行初始化的对象

怎么禁止指令重排
用volatile修饰符来修饰变量,就可以禁止指令重排。java编译器在生成字节码时,会在指令系列中插入内存屏障来禁止特点类型的处理器重排序。

JMM层面的内存屏障:

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

硬件层面的内存屏障:
sfence:写屏障,在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见
lfence:读屏障,在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据
mfence:全能屏障,兼具读屏障,写屏障功能

实现原理

Java代码层面

用volatile修饰变量

public static volatile int num = 0;
字节码层面

编译成字节码后,num字段有一个访问标志:ACC_VOLATILE,供后续操作判断变量是否是volatile变量

HotSpot层面

在 bytecodeInterpreter.cpp文件中,通过is_volatile方法判断是否为volatile变量

if (cache->is_volatile()) {
  if (tos_type == itos) {
    obj->release_int_field_put(field_offset, STACK_INT(-1));
  } else if (tos_type == atos) {
    VERIFY_OOP(STACK_OBJECT(-1));
    obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
    OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
  } else if (tos_type == btos) {
    obj->release_byte_field_put(field_offset, STACK_INT(-1));
  } else if (tos_type == ltos) {
    obj->release_long_field_put(field_offset, STACK_LONG(-1));
  } else if (tos_type == ctos) {
    obj->release_char_field_put(field_offset, STACK_INT(-1));
  } else if (tos_type == stos) {
    obj->release_short_field_put(field_offset, STACK_INT(-1));
  } else if (tos_type == ftos) {
    obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
  } else {
    obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
  }
  // storeload
  OrderAccess::storeload();
}

bool is_volatile () const { 
	return (_flags & JVM_ACC_VOLATILE) != 0; 
}

对于 volatile 变量的执行,最后执行了 OrderAccess::storeload() 方法,跟踪源代码,看到内部执行了一个 fence 方法

inline void OrderAccess::storeload()  { fence(); }

fence() 方法不同操作系统实现方式不一样

// linux操作系统下的实现 orderAccess_linux_x86.inline.hpp
inline void OrderAccess::fence() {
  // 校验系统是否是多核,单核系统无需进行增加屏障指令
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}
// windows操作系统实现,orderAccess_windows_x86.inline.hpp
inline void OrderAccess::fence() {
#ifdef AMD64
  StubRoutines_fence();
#else
  if (os::is_MP()) {
    __asm {
      lock add dword ptr [esp], 0;
    }
  }
#endif // AMD64
}

在 fence 方法中 看到代码lock; addl $0,0(%%rsp) ,其中的addl $0,0(%%rsp) 是把寄存器的值加0,相当于一个空操作
lock 指令有两个功能

  • 将当前处理器缓存行的数据写回到系统内存
  • 这个写回内存的操作会使其他CPU里缓存该内存地址的数据失效

简单来说,通过 lock 指令实现缓存一致性

MESI缓存一致性

为了提高速度,处理器不直接跟内存进行通信,而是将内存中的数据读到内部缓存(L1,L2,L3),但操作完不知道何时会写入主内存
在这里插入图片描述
如果对声明了volatile的变量进行读写操作,JVM会向处理器发送一条Lock前缀指令,将这个变量所在缓存行的数据写回主内存,但是其他处理器上的缓存值还是旧的。

缓存行:缓存是分段(line)的,一个段对应一块存储空间,我们称之为缓存行,它是CPU缓存中可分配的最小存储单元,大小32字节、64字节、128字节不等,这与CPU架构有关,通常来说是64字节
CPU每次加载数据的时候,都是将整个缓存行加载到自己的缓存中,局部性原理

所以在多处理器下,为了保证各个处理器缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存值是不是失效了,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器缓存行设置成无效状态,当处理器对这个数据进行修改操作的时候,就会重新去主内存中读取最新的数据

缓存状态:Modify(修改)、Exclusive(独占),Share(共享)共、Invalid(无效)

伪共享问题

伪共享问题表现:并发的修改在一个缓存行中的多个独立变量,看起来是并发执行的,但实际在CPU处理的时候,是串行执行的,并发的性能大打折扣
在这里插入图片描述
如图所示,
(1)线程1想要修改变量X,线程2想要修改变量Y,X和Y落在同一个缓存行中
(2)线程1和线程2竞争缓存行,线程1将X值修改后,将缓存行值写回主内存,线程2重新从主内存读取新数据,
(3)线程2将Y值修改后,把缓存行写回主内存,线程1重新从主内存读取新数据。
这会来来回回的经过L3缓存,大大影响了性能。如果互相竞争的核心位于不同的插槽,就要额外横跨插槽连接,问题可能更加严重。
解决办法

  • 增加填充,将变量拆分到2个缓存行中,以空间换时间,在Disruptor中有用到
public static final long INITIAL_CURSOR_VALUE = Sequence.INITIAL_VALUE;
protected long p1, p2, p3, p4, p5, p6, p7;
  • JDK8,Contended 注解法,在ConcurrentHashMap有用到
@sun.misc.Contended static final class CounterCell {
   volatile long value;
    CounterCell(long x) { value = x; }
}
根据Lock指令再次看这张图

在这里插入图片描述
那么当写两条线程Thread-A与Threab-B同时操作主存中的一个volatile变量i时
Thread-A写了变量i,那么:

  • Thread-A发出LOCK#指令
  • 发出的LOCK#指令锁总线(或锁缓存行),同时让Thread-B高速缓存中的缓存行内容失效
  • Thread-A向主存回写最新修改的i

Thread-B读取变量i,那么:

  • Thread-B发现对应地址的缓存行被锁了,等待锁的释放,缓存一致性协议会保证它读取到最新的值

使用场景

  • Double Check单例
  • java.util.concurrent 下各个基础类和工具类,构成Java并发包的基础
    在这里插入图片描述

总结

  • volatile的作用:保证线程之间共享变量的可见性,禁止指令重排
  • 字节码层面使用了 ACC_VOLATILE 访问标志,在 HotSpot 源码上通过 is_volatile() 判断变量是否为 volatile 变量,通过指令lock; addl $0,0(%%rsp) 将数据刷回主内存,并让其他处理器中的缓存失效
  • 为解决CPU和内存之间处理数据的速度鸿沟,CPU引入了多级缓存,每次以缓存行为单位从主内存中加载数据,缓存到高速缓存中
  • 为了解决数据一致性问题,实现了缓存一致性协议,CPU基于嗅探机制来感知缓存是否被修改,如果被修改,则将其置为无效,下次使用数据重新从主内存中加载
  • 通过内存屏障来实现指令重排
  • synchronized与volatile都解决了共享变量的可见性问题,synchronized使用了独占锁,会造成其他线程阻塞,同时存在线程上下文切换和线程重新调度的开销;volatile是非阻塞的,不会造成线程上下文切换,但是不保证操作的原子性。

参考链接

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值