并发编程_同步工具_1_volatile、cas

177 阅读13分钟

并发编程中的同步,基本上来就会接触到volatile、synchronized以及cas几个概念。之前对它们的梳理是存在一些问题的,这次解决下之前没有想通的问题。

volatile

volatile的作用

volatile帮助解决问题体现在两个方面: 1.保证线程可见性 2.禁止指令重排序

保证线程可见性

1、引出可见性问题

/**
 * @author : wuwensheng
 * @date : 14:07 2022/8/7
 */
@Slf4j
public class TestVolatile {

    static boolean canStop = false;

    void readCanStop() {
        while (!canStop) {

        }
        System.out.println("can stop is true now");
    }

    public static void main(String[] args) {
        TestVolatile testVolatile = new TestVolatile();
        new Thread(testVolatile::readCanStop, "异步线程1").start();
        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            log.info("InterruptedException exception,should dispose it.");
        }

        canStop = true;
    }

}

上图中,在异步线程1中调用了TestVolatile类的成员方法readCanStop,readCanStop不断读取canStop这个boolean变量的值,如果这个值中途变为true,那么退出这个while循环。

主线程先沉睡1秒,留给异步线程1优先于自己的大概一秒的执行时间吗,随后将canStop更新为true。理想状态下,主线程将canStop更新为true的一瞬间,readCanStop中的while循环就应该停止。 那么下面咱们运行一下看看结果:

image.png

程序一直未停止,当然这是不符合预期的。解决这个问题当然不难,有不止一种方法可以解决。但是有味道的事情是,为什么主线程更新了canStop这个值,异步线程1却无法读取到呢?

之前相信过以下这种说法:

image.png

   每个CPU对共享的操作都是将内存中的共享变量复制一份副本到自己高速缓存中,然后对这个副本进行操作。如果没有
   正确的同步,即使CPU0修改了某个变量,这个已修改的值还只是存在于副本中,此时CPU1需要使用到这个变量,从内
   存中读取的还是修改前的值,这就是其中一种可见性问题。
   

但是,此处的异步线程无法读取到canStop的状态,到底是否属于这种情况呢?做个实验,咱们再加入一个线程,在canStop变量修改的前后都读取下这个变量,看看修改后到底能否读取到。

image.png

运行的结果如下,异步线程2在canStop变量被主线程更新为true之后可以成功读取到:

image.png

从程序运行的结果来看,上面的那种说法在此处是不成立的。那么原因究竟是怎么样的. 此处的异步线程读取不到canStop的值问题恰好出在这个while循环上。

假设开始的时候,异步线程1读取物理内存的stop值为false,那么异步线程1while条件满足进入下一次循环,一直读取
false一直循环,这样while循环读取的次数是非常多的,
正常编译字节码使用的是解释器,
当循环到达一定次数 ,此时JIT编译器对于热点的代码(频繁调用,反复执行)做出优化,这个循环被编译为
while(!false),
导致主线程给stop赋值改成true,即使写回主内存,异步线程1也没有机会感知到stop的变化。

程序的执行将变为下图所示:

image.png

来证明下这个结论,方法就是将JIT编译器关掉,这里需要使用到jvm参数:-Xint。程序成功退出while循环证明咱们的结论没问题。

image.png

2、如何解决可见性的问题

首先,明确一点,解决这个问题的方案不止一种。 最常见的就是最轻盈解决可见性问题的方法,增加volatile关键字。

image.png

那么volatile是如何解决这里的可见性问题的呢?

首先回忆下jmm规范

JMM即为JAVA 内存模型(java memory model)。因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑 
有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。
Java内存模型,就是为了屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5 
开始的JSR-133发布后,已经成熟和完善起来。

具体的规定如下:

内存交互操作有8种,虚拟机实现必须保证每一个操作都是原子的,不可在分的(对于double和long类型的变量来说,load、store、read和write操作在某些平台上允许例外)

  • lock (锁定):作用于主内存的变量,把一个变量标识为线程独占状态
  • unlock (解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
  • read (读取):作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
  • load (载入):作用于工作内存的变量,它把read操作从主存中变量放入工作内存中
  • use (使用):作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
  • assign (赋值):作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
  • store (存储):作用于主内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的write使用
  • write  (写入):作用于主内存中的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

有点抽象,举个例子:

image.png 如上面这张图所示,一个变量被某个线程操作的过程是这样的。首先,这个int类型的变量a一定被声明在主存中,然后,当线程A想要操作这个变量a的时候,首先read读取存在于主存中的变量a,然后将它load到工作内存中。之后,改变这个变量值的时候,use这个变量a使得它被传递到线程A的执行引擎中,之后执行引擎工作结束之后assign这个变量a到工作内存中。最终当变量在线程A的工作内存操作结束之后,执行write和store将变量a刷回主存。

volatile主要是对其中部分指令做了处理: 要求要use(使用)一个变量的时候必需load(载入),要载入的时候必需从主内存read(读取)这样就解决了读的可见性。 写操作是把assign和store做了关联(在assign(赋值)后必需store(存储))。store(存储)后write(写入)。 也就是做到了给一个变量赋值的时候一串关联指令直接把变量值写到主内存。 就这样通过用的时候直接从主内存取,在赋值到直接写回主内存做到了内存可见性 Java内存模型这2个操作必须顺序执行,但不保证连续执行,即在指令之间可以插入其它指令。

但是Java内存模型规定了一些必要的规则

  1. 不允许read 、load 、 store、 write单独出现,即不允许一个变量读取到工作内存,但没有变量接收的情况
  2. 不允许一个线程丢弃它的assign操作,即变量在工作内存改变必须同步回主内存
  3. 不允许一个线程无原因(没有发生assgin赋值操作)把数据从线程的工作内存同步会主内存
  4. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用未被初始化的变量
  5. 一个变量同一时刻只允许一条线程对其进行Lock锁定,但Lock操作可以被同一线程重复执行
  6. 如果对一个变量执行Lock锁定,会清空工作内存中该副本的值,即执行引擎使用该值会重新load assgin操作初始化该值
  7. 如果一个变量事先没有被Lock锁定,那就不允许进行Unlock操作,也不允许Unlock其它线程锁定的变量
  8. 对一个变量执行Unlock操作,必须先把此变量值同步回主内存(store write操作)

synchronized关键字等方案也可以解决可见性问题,此处不再展开。

禁止指令重排序

1、引出指令重排的问题

DCL问题是探讨指令重排的经典案例,咱们直接拿这个来聊。

代码很简单,如下:

public class SingletonTest {
    private static volatile SingletonTest singletonTest = null;


    public static SingletonTest getInstance() {
        if (singletonTest == null) {
            synchronized (SingletonTest.class) {
                if (singletonTest == null) {
                    singletonTest = new SingletonTest();
                }
            }
        }
        return singletonTest;
    }

    @Override
    public int hashCode() {
        return super.hashCode();
    }

    @Override
    public boolean equals(Object obj) {
        return super.equals(obj);
    }

    public static void main(String[] args) {
        for (int i = 0; i < 100; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    System.out.println(Thread.currentThread().getName()+SingletonTest.getInstance().hashCode());
                }
            },String.valueOf(i+":")).start();
        }
    }
}

八股文回答下有可能出现的基本问题;

  1. 为什么要有第一个null判断,因为如果不为null,那么之后的创建不需要进行。
  2. 为什么要用synchronized锁住创建对象的方法,因为依旧可能有多个线程通过第一个null,使用synchronized以保证线程顺序执行并最终只有第一个幸运儿创建实例。
  3. 为什么有第二个null判断,保证依次争抢锁的多个线程仅有一个执行最终的创建。

这里最好玩的问题是为什么要使用volatile,相信大部分人都会回答出防止指令重排序。 接下来就看看到底啥指令重排了volatile又是怎么起到禁止重排的作用的。

2、解决DCL中的指令重排序

在java中创建一个对象是简单的,绝大多数时候new就行了,但实际创建一个对象在底层还要经过更加复杂的不止一条的指令。

image.png

上图中我创建了一个Object对象,我们用jclasslib看下底层这个创建过程将如何执行。

image.png 5条指令才能完成这个对象的最终创建,其中比较重要的有三条;

image.png

这三条指令的含义从上到下分别是:

  1. 申请空间并赋值默认值

  2. 执行即构造方法,之后赋值初始值

  3. 此对象生成到堆并且建立虚拟机栈和这个对象的联系

那么现在有两个线程同时进入这段代码,线程1申请完空间之后,直接来到第三部分建立实例和指针的联系。即执行了指令重排,即先建立关联再执行构造。那么如果恰好线程2在线程1执行完建立关联还未执行构造的时候进来。线程2可能拿着instance就去使用了,但是构造实际还未执行。那么此时就大概率存在bug了。如下图所示:

image.png

这种bug往往是致命的,难以复现也难以调试。如何避免呢,给实例加上volatile加以修饰即可。

image.png

至于volatile是如何做到禁止指令重排的,这比较复杂。

任何两个汇编的指令,如果可以满足单线程执行的最终一致性,执行顺序都可以被调换。as-if-serial。

为什么cpu要对各个指令做出调换呢? 为了提升效率。例如cpu在同一线程需要执行一个内存读取操作,耗时1s,还需要执行一个add操作,耗时5ms。cpu很大可能在内存读取操作的过程中将add操作执行完成,这样最大程度地保证了cpu的使用率。

怎么能防止指令重排序呢,硬件层面解决指令重排手段不止一种,例如上锁、内存屏障等。

内存屏障:依赖屏障指令,例如英特尔处理器的话主要包含这么几个指令(lfence、mfence、sfence)

1、lfence,是一种Load Barrier 读屏障 2、sfence, 是一种Store Barrier 写屏障 3、mfence, 是一种全能型的屏障,具备ifence和sfence的能力 4、 Lock前缀,Lock不是一种内存屏障,但是它能完成类似内存屏障的功能。 Lock会对CPU总线和高速缓存加锁,可以理解为CPU指令级的一种锁。它后面可以跟ADD,ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, DEC, INC, NEG, NOT, OR,SBB, SUB, XOR, XADD, and XCHG等指令。

java当然不能直接去跟硬件沟通,首先java排除的解决问题的代表必须是jvm,jvm的上面还有操作系统,再往后才是更下面的机器指令。

jvm本身规定了内存屏障的实现规范:

1658825244453.png

1658827404810.png

附带一句,除了使用volatile之外,如果说咱们能调动到jvm层面的内存屏障也可以禁止指令重排。如何调动到呢,unsafe。

3、volatile的底层到底如何完成禁止指令重排

有兴趣的话可以追踪下openjdk的源码,可以下载的:

bytecodeinterpreter.cpp

int field_offset = cache->f2_as_index();
int field_offset = cache->f2_as_index();
           if (cache->is_volatile()) {
             if (support_IRIW_for_not_multiple_copy_atomic_cpu) {
               OrderAccess::fence();
             }

内存屏障层面,是OrderAccess::fence在起作用。显然的是OrderAccess在实现内存屏障了,咱们再追踪一下:

来到OrderAccess_linux_x86.hpp中可以找到如下的代码,最终实现volatile要依赖于lock;addl 这个操作。请记住这个指令,lock;addl,下次面试官问指令重排到底如何实现,lock;addl

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();
}

cas

cas的通用流程如下:

image.png

cas的操作可以认为是无锁的,例如,内存中有一个变量叫m,当前线程1想将内存中的值m加1,如果这个操作借助cas将如何完成呢?

首先,在线程1从内存中读取m的时候,例如读取到m的值为0,那么拿到0之后++,在将m写回去的途中,会再看一下m的值是否依然为0,如果依然为0,那么写回去。如果不为0,那么从新获取m的值并重复上面的操作。

这里涉及到几个问题:

1、cas不保证在操作的时候不被其余线程打断,最终把值往回写的过程中如果有线程将变量修改了怎么办呢?

2、aba问题,例如变量开始值是E后面看到的最新值是M,E==M==0,但是这个0中间是被线程s1改为1又被线程s2改为0的。

cas的最终写入是原子性的

咱们追踪下原子类中cas是怎么表达的。例如AtomicInteger的getAndAdd(**),这也是大家常用的api:

image.png

调用的是unsafe的方法,点进去:

image.png

ok。调用UnSafe本类的compareAndSwapInt(**),

image.png compareAndSwapInt(**)是一个native方法,它在哪呢

来到openJdk的源码, dk8u: unsafe.cpp:

cmpxchg = compare and exchange。这个方法中显然最终起作用的是Atomic::cmpxchg

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
   UnsafeWrapper("Unsafe_CompareAndSwapInt");
   oop p = JNIHandles::resolve(obj);
   jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
   return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
 UNSAFE_END

jdk8u: atomic_linux_x86.inline.hpp 93行

inline jint     Atomic::cmpxchg    (jint     exchange_value, volatile jint*     dest, jint     compare_value) {
   int mp = os::is_MP();
   __asm__ volatile (LOCK_IF_MP(%4) "cmpxchgl %1,(%3)"
                     : "=a" (exchange_value)
                     : "r" (exchange_value), "a" (compare_value), "r" (dest), "r" (mp)
                     : "cc", "memory");
   return exchange_value;
 }

asm是汇编语言的标志,需要利用汇编的 LOCK_IF_MP cmpxchgl 两条指令完成最终的操作。cmpxchg1本身不具备原子性,原子性要依赖LOCK_IF_MP的支持。

1659690211672.png

lock最终实现比较复杂,锁缓存,锁总线等。

引入版本号解决aba问题

引入版本号 (AtomicStampedReference)即可规避aba问题(看业务场景是否需要此此操作),即每次查看变量我不止是想知道它的值是多少,它的版本我也想知道,如果第一次获取这个变量版本为1,第二次去获取最新值版本为2,那么认为它被别的线程进行了修改。

基础类型简单值一般不需要版本号。