Java架构资源分享

Java架构资源分享

文章简介

分析volatile的作用以及底层实现原理,这也是大公司喜欢问的问题

内容导航

  1. volatile的作用
  2. 什么是可见性
  3. volatile源码分析

volatile的作用

在多线程中,volatile和synchronized都起到非常重要的作用,synchronized是通过加锁来实现线程的安全性。而volatile的主要作用是在多处理器开发中保证共享变量对于多线程的可见性。
可见性的意思是,当一个线程修改一个共享变量时,另外一个线程能读取到修改以后的值。接下来通过一个简单的案例来演示可见性问题

public class VolatileDemo {
    private /*volatile*/ static boolean stop=false; //添加volatile修饰和不添加volatile修饰的演示效果
    public static void main(String[] args) throws InterruptedException {
        Thread thread=new Thread(()->{
            int i=0;
            while(!stop){
                i++;
            }
        });
        thread.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
    }
}

  1. 定义一个共享变量 stop
  2. 在main线程中创建一个子线程 thread,子线程读取到 stop的值做循环结束的条件
  3. main线程中修改stop的值为 true
  4. 当 stop没有增加volatile修饰时,子线程对于主线程的 stop=true的修改是不可见的,这样将导致子线程出现死循环
  5. 当 stop增加了volatile修饰时,子线程可以获取到主线程对于 stop=true的值,子线程while循环条件不满足退出循环
    增加volatile关键字以后,main线程对于共享变量 stop值的更新,对于子线程 thread可见,这就是volatile的作用

什么是可见性,以及volatile是如何保证可见性的呢?

什么是可见性

在并发编程中,线程安全问题的本质其实就是 原子性、有序性、可见性;接下来主要围绕这三个问题进行展开分析其本质,彻底了解可见性的特性

  1. 原子性 和数据库事务中的原子性一样,满足原子性特性的操作是不可中断的,要么全部执行成功要么全部执行失败
  2. 有序性 编译器和处理器为了优化程序性能而对指令序列进行重排序,也就是你编写的代码顺序和最终执行的指令顺序是不一致的,重排序可能会导致多线程程序出现内存可见性问题
  3. 可见性 多个线程访问同一个共享变量时,其中一个线程对这个共享变量值的修改,其他线程能够立刻获得修改以后的值
    为了彻底了解这三个特性,我们从两个层面来分析,第一个层面是硬件层面、第二个层面是JMM层面

从硬件层面分析三大特性

原子性、有序性、可见性这些问题,我们可以认为是基于多核心CPU架构下的存在的问题。因为在单核CPU架构下,所有的线程执行都是基于CPU时间片切换,所以不存在并发问题 (在IntelPentium4开始,引入了超线程技术,也就是一个CPU核心模拟出2个线程的CPU,实现多线程并行)。

CPU高速缓存

线程设计的目的是充分利用CPU达到实时性的效果,但是很多时候CPU的计算任务还需要和内存进行交互,比如读取内存中的运算数据、将处理结果写入到内存。在理想情况下,存储器应该是非常快速的执行一条指令,这样CPU就不会受到存储器的限制。但目前技术无法满足,所以就出现了其他的处理方式。

图片描述

存储器顶层是CPU中的寄存器,存储容量小,但是速度和CPU一样快,所以CPU在访问寄存器时几乎没有延迟;接下来就是CPU的高速缓存;最后就是内存。

图片描述

高速缓存从下到上越接近CPU访问速度越快,同时容量也越小。现在的大部分处理器都有二级或者三级缓存,分别是L1/L2/L3, L1又分为L1-d的数据缓存和L1-i的指令缓存。其中L3缓存是在多核CPU之间共享的。

原子性

在多核CPU架构下,在同一时刻对同一共享变量执行 decl指令(递减指令,相当于i--,它分为三个过程:读->改->写,这个指令涉及到两次内存操作,那么在这种情况下i的结果是无法预测的。这就是原子性问题

其实这个问题稍微提炼一下,无非就是多线程并行访问同一个共享资源的时候的原子性问题,如果把问题放大到分布式架构里面,这个问题的解决方法就是锁。所以在CPU层面,提供了两种锁的机制来保证原子性

总线锁

如果多个处理器同时对同一共享变量进行 decl指令操作,那这个操作一定不是原子的,也就是执行的结果和预期结果不一致。如下图所示,我们期望的结果是3,但是有可能结果是2

图片描述

如果要解决这个问题,就需要是的CPU0在更新共享变量时,CPU1就不能操作缓存了该共享变量内存地址的缓存,所以处理器提供了总线锁来解决问题,处理器会提供一个LOCK#信号,当一个处理器在总线上输出这个信号时,其他处理器的请求会被阻塞,那么该处理器就可以独占共享内存

缓存锁

我们只需要保证 多个线程操作同一个被缓存的共享数据的原子性就行,所以只需要锁定被缓存的共享对象即可。所谓缓存锁是指被缓存在处理器中的共享数据,在Lock操作期间被锁定,那么当被修改的共享内存的数据回写到内存时,处理器不在总线上声明LOCK#信号,而是修改内部的内存地址,并通过 缓存一致性机制来保证操作的原子性。

每个CPU核心不仅仅知道自己的读写操作,也会监听其他Cache的读写操作
CPU的读取会遵循几个原则

  1. 如果缓存的状态是I,那么就从内存中读取,否则直接从缓存读取
  2. 如果缓存处于M或者E的CPU 嗅探到其他CPU有读的操作,就把自己的缓存写入到内存,并把自己的状态设置为S
  3. 只有缓存状态是M或E的时候,CPU才可以修改缓存中的数据,修改后,缓存状态变为M

可见性

CPU高速缓存以及指令重排序都会造成可见性问题,接下来从两个角度来分析

MESI优化带来的可见性问题

前面说过MESI协议,也就是缓存一致性协议。这个协议存在一个问题,就是当CPU0修改当前缓存的共享数据时,需要发送一个消息给其他缓存了相同数据的CPU核心,这个消息传递给其他CPU核心以及收到消息完成各自缓存状态的切换这个过程中,CPU会等待所有缓存响应完成,这样会降低处理器的性能。为了解决这个问题,引入了 StoreBufferes存储缓存。

处理器把需要写入到主内存中的值先写入到存储缓存中,然后继续去处理其他指令。当所有的CPU核心返回了失效确认时,数据才会被最终提交。但是这种优化又会带来另外的问题。
如果某个CPU尝试将其他CPU占有的共享数据写入到内存,消息提交给store buffer以后,当前CPU继续做其他事情,而如果后面的指令依赖于这个被写入内存的最新数据(由于store buffer还没有写入到内存),就会产生可见性问题(也就是值还没有更新到内存中,这个时候读取到的共享数据的值是错误的)。

Store Bufferes带来的CPU内存的乱序访问导致的可见性问题

Store Bufferes中的数据何时写入到内存中是不确定的,那么意味着这个过程的执行顺序也是不确定的,比如下面这个例子
exeToCPU0和exeToCPU1分别在两个独立的cpu核心上执行,假如CPU0 缓存了 isFinish这个共享变量,并且状态为(E->独占),而value可能是(S共享状态被其他CPU核心修改以后变为I(失效状态)。
这种情况下value的缓存数据变更路径为, value将失效状态需要响应给触发缓存更新的CPU核心,接着该CPU将 StoreBufferes写入到内存,这就会导致value会比isFinish更迟的抛弃存储缓存。那么就可能出现CPU1读取到了isFinish的值为true,而value的值不等于10的情况。
这种CPU的内存乱序访问,会带来可见性问题。

value = 3;
void exeToCPU0(){
  value = 10;
  isFinsh = true;
}
void exeToCPU1(){
  if(isFinsh){
    assert value == 10;
  }
}

CPU层面的内存屏障

什么是内存屏障?从前面的内容基本能有一个初步的猜想,内存屏障就是将 store bufferes中的指令写入到内存,从而使得其他访问同一共享内存的线程的可见性。
X86的memory barrier指令包括lfence(读屏障) sfence(写屏障) mfence(全屏障)

Store Memory Barrier(写屏障) 告诉处理器在写屏障之前的所有已经存储在存储缓存(store bufferes)中的数据同步到主内存,简单来说就是使得写屏障之前的指令的结果对屏障之后的读或者写是可见的
Load Memory Barrier(读屏障) 处理器在读屏障之后的读操作,都在读屏障之后执行。配合写屏障,使得写屏障之前的内存更新对于读屏障之后的读操作是可见的
Full Memory Barrier(全屏障) 确保屏障前的内存读写操作的结果提交到内存之后,再执行屏障后的读写操作

有了内存屏障以后,对于上面这个例子,我们可以这么来改,从而避免出现可见性问题

value = 3;
void exeToCPU0(){
  value = 10;
  storeMemoryBarrier(); //这个是一个伪代码,插入一个写屏障,使得value=10这个值强制写入到主内存中
  isFinsh = true;
}
void exeToCPU1(){
  if(isFinsh){
    loadMemoryBarrier();//伪代码,插入一个读屏障,使得cpu1从主内存中获得最新的数据
    assert value == 10;
  }
}

有序性

有序性简单来说就是程序代码执行的顺序是否按照我们编写代码的顺序执行,一般来说,为了提高性能,编译器和处理器会对指令做重排序,重排序分3类

  1. 编译器优化重排序,在不改变单线程程序语义的前提下,改变代码的执行顺序
  2. 指令集并行的重排序,对于不存在数据依赖的指令,处理器可以改变语句对应指令的执行顺序来充分利用CPU资源
  3. 内存系统的重排序,也就是前面说的CPU的内存乱序访问问题3.

也就是说,我们编写的源代码到最终执行的指令,会经过三种重排序

从JMM层面解决线程并发问题

从硬件层面的分析了解到原子性、有序性、可见性的本质以后,知道硬件层面针对这三个问题的解决办法,原子性是通过总线锁或缓存锁来实现,而有序性和可见性可以通过内存屏障来解决。那么在软件层面,如何解决原子性、有序性、可见性问题呢?答案就是: JMM(JavaMemoryModel)内存模型

硬件层面的原子性、有序性、可见性在不同的CPU架构和操作系统中的实现可能都不一样,而Java语言的特性是 write once,run anywhere,意味着JVM层面需要屏蔽底层的差异,因此在JVM规范中定义了JMM。

(JMM内存模型的抽象结构)

Java内存模型定义了线程和内存的交互方式,在JMM抽象模型中,分为主内存、工作内存;主内存是所有线程共享的,一般是实例对象、静态字段、数组对象等存储在堆内存中的变量。工作内存是每个线程独占的,线程对变量的所有操作都必须在工作内存中进行,不能直接读写主内存中的变量,线程之间的共享变量值的传递都是基于主内存来完成。
在JMM中,定义了8个原子操作来实现一个共享变量如何从主内存拷贝到工作内存,以及如何从工作内存同步到主内存,交互如下

顺序一致性

JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致,因为如果想要保证执行结果一致,意味着JMM需要进制处理器和编译器的优化,这对于程序的执行性能会产生很大的影响。所以在未同步程序的执行中,由于执行顺序的不确定性导致结果无法预测。我们可以使用同步原语比如 synchronized,volatile、final来实现程序的同步操作来保证顺序一致性

假如有两个线程A和B并行执行,A和B线程分别都有3个操作,在程序中的顺序是 A1->A2->A3, B1->B2->B3。
假设这两个程序没有使用同步原语,那么线程并行执行的效果可能是

此图来自并发编程的艺术

如果这两个程序使用了监视器锁来实现正确同步,那么执行的过程一定是

此图来自并发编程的艺术

重排序

CPU层面的内存乱序访问属于重排序的一部分,同时我们还提到了编译器的优化执行的重排序。重排序是一种优化手段,但是在多线程并发中,会导致可见性问题。
编译器的重排序是指,在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序来优化程序的性能.
编译器的重排序和CPU的重排序的原则一样,会遵守数据依赖性原则,编译器和处理器不会改变存在数据依赖关系的两个操作的执行顺序,比如下面的代码,这三种情况在单线程里面如果改变代码的执行顺序,都会导致结果不一致,所以重排序不会对这类的指令做优化,也就是需要满足 as-if-serial语义

//写后读
a=1;
b=1;
//写后写
a=1;
a=2;
//读后写
a=b;
b=1;

JMM层面的内存屏障

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障来禁止特定类型的处理器的重排序,在JMM中把内存屏障分为四类

屏障的作用这里就不重复再说了,实际上JMM层面的内存屏障就是对CPU层面的内存屏障指令做的包装,作用是通过在合适的位置插入内存屏障来保证可见性

JVM是如何在JMM层面解决原子性、有序性、可见性问题的呢?

相信通过上面的分析,基本上有了答案

  1. 原子性:Java中提供了两个高级指令 monitorenter和 monitorexit,也就是对应的synchronized同步锁来保证原子性
  2. 可见性:volatile、synchronized、final都可以解决可见性问题
  3. 有序性:synchronized和volatile可以保证多线程之间操作的有序性,volatile会禁止指令重排序

volatile源码分析

如果你看到这个章节了,意味着你对可见性有一个清晰的认识了,也知道JMM是基于禁止指令重排序来实现可见性的,那么我们再来分析volatile的源码,就会简单很多

基于最开始演示的这段代码作为入口

public class VolatileDemo {
    public volatile static boolean stop=false;
    public static void main(String[] args) throws InterruptedException {
        Thread thread=new Thread(()->{
            int i=0;
            while(!stop){
                i++;
            }
        });
        thread.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop=true;
    }
}

通过 javap-vVolatileDemo.class查看字节码指令

public static volatile boolean stop;
    descriptor: Z
    flags: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE
...//省略
 public static void main(java.lang.String[]) throws java.lang.InterruptedException;
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=3, locals=2, args_size=1
         0: new           #2                  // class java/lang/Thread
         3: dup
         4: invokedynamic #3,  0              // InvokeDynamic #0:run:()Ljava/lang/Runnable;
         9: invokespecial #4                  // Method java/lang/Thread."<init>":(Ljava/lang/Runnable;)V
        12: astore_1
        13: aload_1
        14: invokevirtual #5                  // Method java/lang/Thread.start:()V
        17: getstatic     #6                  // Field java/lang/System.out:Ljava/io/PrintStream;
        20: ldc           #7                  // String begin start thread
        22: invokevirtual #8                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
        25: ldc2_w        #9                  // long 1000l
        28: invokestatic  #11                 // Method java/lang/Thread.sleep:(J)V
        31: iconst_1
        32: putstatic     #12                 // Field stop:Z
        35: return

注意被修饰了volatile关键字的 stop字段,会多一个 ACC_VOLATILE的flag,在给 stop复制的时候,调用的字节码是 putstatic,这个字节码会通过BytecodeInterpreter解释器来执行,找到Hotspot的源码 bytecodeInterpreter.cpp文件,搜索 putstatic指令定位到代码

CASE(_putstatic):
        {
          u2 index = Bytes::get_native_u2(pc+1);
          ConstantPoolCacheEntry* cache = cp->entry_at(index);
          if (!cache->is_resolved((Bytecodes::Code)opcode)) {
            CALL_VM(InterpreterRuntime::resolve_get_put(THREAD, (Bytecodes::Code)opcode),
                    handle_exception);
            cache = cp->entry_at(index);
          }

#ifdef VM_JVMTI
          if (_jvmti_interp_events) {
            int *count_addr;
            oop obj;
            // Check to see if a field modification watch has been set
            // before we take the time to call into the VM.
            count_addr = (int *)JvmtiExport::get_field_modification_count_addr();
            if ( *count_addr > 0 ) {
              if ((Bytecodes::Code)opcode == Bytecodes::_putstatic) {
                obj = (oop)NULL;
              }
              else {
                if (cache->is_long() || cache->is_double()) {
                  obj = (oop) STACK_OBJECT(-3);
                } else {
                  obj = (oop) STACK_OBJECT(-2);
                }
                VERIFY_OOP(obj);
              }

              CALL_VM(InterpreterRuntime::post_field_modification(THREAD,
                                          obj,
                                          cache,
                                          (jvalue *)STACK_SLOT(-1)),
                                          handle_exception);
            }
          }
#endif /* VM_JVMTI */

          // QQQ Need to make this as inlined as possible. Probably need to split all the bytecode cases
          // out so c++ compiler has a chance for constant prop to fold everything possible away.

          oop obj;
          int count;
          TosState tos_type = cache->flag_state();

          count = -1;
          if (tos_type == ltos || tos_type == dtos) {
            --count;
          }
          if ((Bytecodes::Code)opcode == Bytecodes::_putstatic) {
            Klass* k = cache->f1_as_klass();
            obj = k->java_mirror();
          } else {
            --count;
            obj = (oop) STACK_OBJECT(count);
            CHECK_NULL(obj);
          }

          //
          // Now store the result
          //
          int field_offset = cache->f2_as_index();
          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));
            }
            OrderAccess::storeload();
          } else {
            if (tos_type == itos) {
              obj->int_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == atos) {
              VERIFY_OOP(STACK_OBJECT(-1));
              obj->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->byte_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ltos) {
              obj->long_field_put(field_offset, STACK_LONG(-1));
            } else if (tos_type == ctos) {
              obj->char_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == stos) {
              obj->short_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ftos) {
              obj->float_field_put(field_offset, STACK_FLOAT(-1));
            } else {
              obj->double_field_put(field_offset, STACK_DOUBLE(-1));
            }
          }
...//省略很多代码

其他代码不用管,直接看 cache->is_volatile()这段代码,cache是 stop在常量池缓存中的一个实例,这段代码是判断这个cache是否是被 volatile修饰, is_volatile()方法的定义在 accessFlags.hpp文件中,代码如下

public:
  // Java access flags
  ...//
  bool is_volatile    () const         { return (_flags & JVM_ACC_VOLATILE    ) != 0; }
  bool is_transient   () const         { return (_flags & JVM_ACC_TRANSIENT   ) != 0; }
  bool is_native      () const         { return (_flags & JVM_ACC_NATIVE      ) != 0; }

is_volatile是判断是否有 ACC_VOLATILE这个flag,很显然,通过 volatile修饰的stop的字节码中是存在这个flag的,所以 is_volatile()返回true
接着,根据当前字段的类型来给 stop赋值,执行 release_byte_field_put方法赋值,这个方法的实现在 oop.inline.hpp中


inline void oopDesc::release_byte_field_put(int offset, jbyte contents)     
{ OrderAccess::release_store(byte_field_addr(offset), contents); }

赋值的动作被包装了一层,看看 OrderAccess::release_store做了什么事情呢?这个方法的定义在 orderAccess.hpp中,具体的实现,根据不同的操作系统和CPU架构,调用不同的实现

以 orderAccess_linux_x86.inline.hpp为例,找到 OrderAccess::release_store的实现,代码如下

inline void     OrderAccess::release_store(volatile jbyte*   p, jbyte   v) { *p = v; }

可以看到其实Java的volatile操作,在JVM实现层面第一步是给予了C++的原语实现。c/c++中的volatile关键字,用来修饰变量,通常用于语言级别的 memory barrier。被volatile声明的变量表示随时可能发生变化,每次使用时,都必须从变量i对应的内存地址读取,编译器对操作该变量的代码不再进行优化

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

当调用 storeload屏障时,它会调用fence()方法

inline void OrderAccess::fence() {
  if (os::is_MP()) { //返回是否多处理器,如果是多处理器才有必要增加内存屏障
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    //__asm__ volatile 嵌入汇编指令
    //lock 汇编指令,lock指令会锁住操作的缓存行,也就是缓存锁的实现
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

os::is_MP()判断是否是多核,如果是单核,那么就不存在内存不可见或者乱序的问题 volatile:禁止编译器对代码进行某些优化.
Lock :汇编指令,lock指令会锁住操作的缓存行(cacheline), 一般用于read-Modify-write的操作;用来保证后续的操作是原子的
cc代表的是寄存器,memory代表是内存;这边同时用了”cc”和”memory”,来通知编译器内存或者寄存器内的内容已经发生了修改,要重新生成加载指令(不可以从缓存寄存器中取)
这边的read/write请求不能越过lock指令进行重排,那么所有带有lock prefix指令(lock ,xchgl等)都会构成一个天然的x86 Mfence(读写屏障),这里用lock指令作为内存屏障,然后利用asm volatile("" ::: "cc,memory")作为编译器屏障. 这里并没有使用x86的内存屏障指令(mfence,lfence,sfence),应该是跟x86的架构有关系,x86处理器是强一致内存模型

原因是:避免volatile写与后面可能有的volatile读/写操作重排序。因为编译器常常无法准确判断在一个volatile写的后面是否需要插入一个StoreLoad屏障。为了保证能正确实现volatile的内存语义,JMM在采取了保守策略:在每个volatile写的后面,或者在每个volatile读的前面插入一个StoreLoad屏障。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。从这里可以看到JMM在实现上的一个特点:首先确保正确性,然后再去追求执行效率

总结

综上分析可以得知,volatile是通过防止指令重排序来实现多线程对于共享内存的可见性。



 

12-26 19:33