我们好,我是王有志。重视王有志,一起聊技术,聊游戏,聊在外漂泊的日子。

今日咱们学习并发编程中另一个重要的关键字volatile,尽管面试中它的占比低于synchronized,但依旧是不行疏忽的内容。

关于volatile,我搜集到了8个常见考点,围绕运用,特色和完结原理。

1. volatile有什么效果?
2. 为什么多线程环境中会出现可见性问题?
3. synchronizedvolatile有哪些区别?
4. 详细描绘volatile的完结原理(涉及内存屏障)。
5. volatile有哪些特性?它是怎么确保这些特性的?
6. volatile确保线程间变量的可见性,是否意味着volatile变量便是并发安全的?
7. 为什么办法中的变量不需要运用volatile8. 重排序是怎么发生的?

本文从volatile运用开始,接着从源码视点剖析volatile的完结,通过对原理的剖析尝试解答以上问题。

volatile是什么

synchronized一样,volatileJava的供给的用于并发操控的关键字,不过它们之间也有比较显着的差异。

首先是运用方式:

  • synchronized可以润饰办法和代码块

  • volatile只能润饰成员变量

能力上volatile也更“弱”一些:

  • 确保被润饰变量的可见性

  • 制止被润饰变量发生指令重排

咱们略微修正关于线程你有必要知道的8个问题(上)中可见性问题的代码,运用volatile润饰变量flag

private static volatile boolean flag = true;
public static void main(String[] args) throws InterruptedException {
    new Thread(() -> {
        while (flag) {
        }
        System.out.println("线程:" + Thread.currentThread().getName() + ",flag状态:" + flag);
    }, "block_thread").start();
    TimeUnit.MICROSECONDS.sleep(500);
    new Thread(() -> {
        flag = false;
        System.out.println("线程:" + Thread.currentThread().getName() + ",flag状态:" + flag);
    }, "change_thread").start();
}

不难发现,block_thread解脱了,说明对flag的修正被其它线程“看见了”,这便是volatile确保可见性的体现。

接着修正《深化了解JMM和Happens-Before》中指令重排带来有序性问题的代码,同样运用volatile润饰变量instance

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

屡次实验后发现,不会再获取到未经初始化的instacne对象了,这便是volatile制止指令重排的体现。

Tips:再次强调,Happens-Before描绘的是行为成果间的关系

volatile的完结

以下内容基于JDK 11 HotSpot虚拟机,以X86架构的完结为主,会与ARM架构的完结比照。挑选这些的原因很简略,它们是各自范畴的“顶流”

volatile运用简略,功用易了解,但往往简略的背面隐藏着复杂的完结。和剖析synchronized的进程一样,从字节码开始,再到JVM的完结,力求从底层串联volatile,内存屏障与硬件之间的关系。

volatile在不同架构下的完结差异较大,看个比如,X86架构的templateTable_x86中getfield_or_static办法的完结:

void TemplateTable::getfield_or_static(int byte_no, bool is_static, RewriteControl rc) {
  // 省掉类型判别的代码
  __ bind(Done);
  // [jk] not needed currently
  // volatile_barrier(Assembler::Membar_mask_bits(Assembler::LoadLoad | Assembler::LoadStore));
}

ARM架构的templateTable_arm中getfield_or_static办法的完结:

void TemplateTable::getfield_or_static(int byte_no, bool is_static, RewriteControl rc) {
  // 省掉类型判别的代码
  __ bind(Done);
  if (gen_volatile_check) {
    Label notVolatile;
    __ tbz(Rflagsav, ConstantPoolCacheEntry::is_volatile_shift, notVolatile);
    volatile_barrier(MacroAssembler::Membar_mask_bits(MacroAssembler::LoadLoad | MacroAssembler::LoadStore), Rtemp);
    __ bind(notVolatile);
  }
}

X86架构下不需要对volatile类型进行特别处理,而ARM架构下,增加内存屏障确保了与X86架构一致的效果。这个比如是为了展现标准在不同CPU架构上的完结差异,别的提醒我们不要误将X86的完结当成标准,X86架构对重排序的束缚更强,能“天然”完结JMM标准中的某些要求,所以JVM层面的完结看起来会十分简略。

上面一直在说模板解说器,不过后边的内容我要用字节码解说器bytecodeInterpreter了。为什么不用模板解说器?由于模板解说器离OrderAccess太“远”了,而OrderAccess中内存屏障的详细解说是了解volatile原理的关键。

不过,咱们还是先花点时刻了解下X86架构下内存屏障assembler_x86的完结:

enum Membar_mask_bits {
  StoreStore = 1 << 3,
  LoadStore  = 1 << 2,
  StoreLoad  = 1 << 1,
  LoadLoad   = 1 << 0
};
void membar(Membar_mask_bits order_constraint) {
  if (os::is_MP()) {
    if (order_constraint & StoreLoad) {
      int offset = -VM_Version::L1_line_size();
      if (offset < -128) {
        offset = -128;
      }
      lock();
      addl(Address(rsp, offset), 0);
    }
  }
}

运用位掩码定义内存屏障的枚举,剖析倾向锁的时分就见到过位掩码的运用,要点在membar办法中最终两行代码:

lock();
addl(Address(rsp, offset), 0);

刺进了lock addl指令,它是X86架构下内存屏障完结的关键,orderAccess_linux_x86中的完结也是如此。

Tipsmembar办法是Memory Barrier(内存屏障)的缩写,别的也有称为Memory Fence(内存栅门)的,或许直接称为fence,反正屏障,栅门什么的杂乱无章的。

从字节码开始

运用双检锁单例模式生成的字节码:

public class com.wyz.keyword.keyword_volatile.Singleton
  static volatile com.wyz.keyword.keyword_volatile.Singleton instance;
    flags:(0x0048) ACC_STATIC, ACC_VOLATILE
  public static com.wyz.keyword.keyword_volatile.Singleton getInstance();
    Code:
      stack=2, locals=2, args_size=0
        24: putstatic     #7      // Field instance:Lcom/wyz/keyword/keyword_volatile/Singleton;
        37: getstatic     #7      // Field instance:Lcom/wyz/keyword/keyword_volatile/Singleton;

咱们看字节码中的关键部分:

  • 标记volatile变量的ACC_VOLATILE

  • 写入/读取静态变量时的指令getstaticputstatic

Java 11虚拟机标准第4章中是这样描绘ACC_VOLATILE的:

ACC_STATIC 0x0008 Declared static.

ACC_VOLATILE 0x0040 Declared volatile; cannot be cached.

虚拟机标准要求volatile润饰的变量不能被缓存。咱们知道,CPU高速缓存是带来可见性问题的“罪魁祸首”,不能被缓存就意味着杜绝了可见性问题,但并不意味着不运用缓存。

Java 11虚拟机标准第6章中也描绘了getstatic指令的效果:

Get static field from class.

putstatic指令的效果:

Set static field in class.

可以大致猜到JVM的完结volatile的方式,JVM中定义getstatic/putstatic指令对应的办法,并在办法中判别变量是否被标记为ACC_VOLATILE,然后进行特别逻辑处理。

Tips

  • 0x0048是ACC_STATICACC_VOLATILE结合的成果;

  • static变量,读取和写入是getfieldputfield两条指令。

字节码解说器的完结

这部分咱们只看putstatic的源码,前面模板解说器的部分也大致剖析了getstatic,剩余的就留给我们自行剖析了。

putstatic的完结在bytecodeInterpreter中第2026行:

CASE(_putfield):
CASE(_putstatic):
    {
      if ((Bytecodes::Code)opcode == Bytecodes::_putstatic) {
        // static的处理方式
      } else {
        // 非static的处理方式
      }
      // ACC_VOLATILE -> JVM_ACC_VOLATILE -> is_volatile()
      if (cache->is_volatile()) {
        // volatile变量的处理方式
        if (tos_type == itos) {
          obj->release_int_field_put(field_offset, STACK_INT(-1));
        }else {
           // 省掉了超多的类型判别
        }
        OrderAccess::storeload();
      } else {
        // 非volatile变量的处理方式
      }
    }

逻辑很简略,判别变量的类型,然后调用OrderAccess::storeload(),确保volatile变量的特性完结。

JVM的内存屏障

JVM在不同操作系统,CPU架构的基础上,构建了一套契合JMM标准的内存屏障,屏蔽了不同架构间的差异,完结了内存屏障的语义一致性。这部分要点解说JVM完结的4种首要内存屏障和介绍X86架构的完结以及硬件差异导致的不同。

来看orderAccess中对4种内存屏障的解说:

Memory Access Ordering Model

LoadLoad: Load1(s); LoadLoad; Load2

Ensures that Load1 completes (obtains the value it loads from memory) before Load2 and any subsequent load operations. Loads before Load1 may not float below Load2 and any subsequent load operations.

StoreStore: Store1(s); StoreStore; Store2

Ensures that Store1 completes (the effect on memory of Store1 is made visible to other processors) before Store2 and any subsequent store operations. Stores before Store1 may not float below Store2 and any subsequent store operations.

LoadStore: Load1(s); LoadStore; Store2

Ensures that Load1 completes before Store2 and any subsequent store operations. Loads before Load1 may not float below Store2 and any subsequent store operations.

StoreLoad: Store1(s); StoreLoad; Load2

Ensures that Store1 completes before Load2 and any subsequent load operations. Stores before Store1 may not float below Load2 and any subsequent load operations.

努力翻译下对4种首要的内存屏障的描绘:

  • LoadLoad,指令:Load1; LoadLoad; Load2。确保Load1在Load2及之后的读操作前完结读操作,Load1前的Load指令不能重排序到Load2及之后的读操作后;

  • StoreStore,指令:Store1; StoreStore; Store2。确保Store1在Store2及之后的写操作前完结写操作,且Stroe1写操作的成果对Store2可见,Store1前的Store指令不能重排序到Store2及之后的写操作后;

  • LoadStore,指令:Load1; LoadStore; Store2。确保Load1在Store2及之后的写操作前完结读操作,Load1前的Load指令不能重排序到Store2及之后的写操作后;

  • StoreLoad:指令:Store1; StoreLoad; Load2。确保Store1在Load2及之后的Load指令前完结写操作,Store1前的Store指令不能重排序到Load2及之后的Load指令后。

尽管翻译过来有些拗口,但了解起来并不困难,建议小伙伴们仔细阅览这部分注释(包括后边的内容)。

注释中可以看出,内存屏障确保了程序的有序性

X86架构的内存屏障完结

Linux渠道X86架构的完结orderAccess_linux_x86中对内存屏障的定义:

inline void OrderAccess::loadload()   { compiler_barrier(); }
inline void OrderAccess::storestore() { compiler_barrier(); }
inline void OrderAccess::loadstore()  { compiler_barrier(); }
inline void OrderAccess::storeload()  { fence();            }
inline void OrderAccess::acquire()    { compiler_barrier(); }
inline void OrderAccess::release()    { compiler_barrier(); }

完结十分简略,只有两个中心办法compiler_barrierfence

static inline void compiler_barrier() {
  __asm__ volatile ("" : : : "memory");
}
inline void OrderAccess::fence() {
  // 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
  compiler_barrier();
}

上述代码是GCC的扩展内联汇编方式,简略解说下compiler_barrier办法中的内容:

  • asm,刺进汇编指令;

  • volatile,制止优化此处的汇编指令;

  • meomory,汇编指令修正了内存,要重新读取内存数据。

接着是支撑storeload屏障的fence办法,和templateTable_x86的完结一样,中心是lock addl指令。lock前缀指令可以了解为CPU指令级的锁,对总线和缓存加锁,首要有两个效果:

  • Lock前缀指令会引起处理器缓存回写到内存

  • 处理器缓存回写到内存会导致其他处理器的缓存无效

实际上X86架构供给了内存屏障指令lfence,sfence,mfence等,但为什么不运用内存屏障指令呢?原因在fence的注释中:

mfence is sometimes expensive

mfence指令在性能上的开销较大。

好了,到这儿咱们已经可以得到X86架构下完结volatile特性的原理:

  • JVM的视点看,内存屏障供给了可见性和有序性的确保

  • X86的视点看,voaltile指令制止重排序,Lock指令引起缓存失效和回写

Tipsfence办法中AMD64和X86的处理略有差异,关于它们的渊源,可以参阅pansz大佬的知乎。

其他架构完结差异的原因

前面看到,X86架构和ARM架构的模板解说器中,getfield_or_static办法在运用内存屏障上发生了不合,ARM通过内存屏障抵达了“罗马”,而X86出生在“罗马”。

不难想到发生这种差异的原因,CPU架构对重排序的束缚不同,导致JVM需要运用不同的处理方式达到统一的效果。关于CPU答应的重排序,我“转移”了一张图:

11.硬核的volatile考点分析

该图来自介绍CPU缓存与内存屏障的经典文章《Memory Barriers:a Hardware View for Software Hackers》,尽管时代较为“长远”,但依旧值得阅览。原图中列标题是竖向,看起来并不方便,所以进行了简略的“视觉优化”。

从图中也可以看到,X86架构只答应“Stores Reordered After Loads”重排序,因此JVM中只对storeload进行了完结,至于其它屏障的特性则是由CPU自己确保的。

结语

volatile的内容太难写了,特性不难,源码也不难,但是讲内存屏障十分难。

说少了难以了解,说多了就“越界”,就成了写硬件的文章。因此在写硬件完结差异的策略是桌面端最常用的X86架构为主,并比照移动端最常用ARM架构的完结,尽量简短的解说volatile的完结。

实际上,内存屏障的部分还有acquirerelease两种单向屏障没有涉及到,我们可以自行了解。

那么,现在回到开始的题目中,相信你可以轻松的答复出前6道题目了吧?至于第8题,由于重排序的发生涉及到大量硬件内容,所以没有写,如果感兴趣的话,可以写个番外篇和我们分享我对重排序的了解,或许可以参阅我在volatile题解中的答复。


好了,今日就到这儿了,Bye~~