并发三大特性——可见性

简介: 并发三大特性——可见性

引言


熟悉并发的童鞋们都知道,并发编程有三大特性,分别是可见性、有序性、原子性,今天我们从一个demo中分析可见性,以及我们如何保障可见性。


JMM模型


在我们分析可见性之前,我们需要了解一个概念,就是JMM模型,也就是我们常说的java memory model .


java虚拟机规范中定义了Java内存模型,用于屏蔽掉各种硬件和操作系统的内存访问差异,以实现程序在各个平台上达到一致的并发效果,JMM规范了java虚拟机与计算机内存是如何协同工作的。规定了一个线程如何和何时可以看到由其他线程修改过后的共享变量的值,以及在必须时如何同步的访问共享变量。JMM描述的是一种抽象的概念,一组规则,通过这组规则控制程序中各个变量在共享数据区域和私有数据区域的访问方式。


14db8b82609ed9309b9527d5bb63f5df.png


上代码


package com.tuling.juc.service;
import com.tuling.concurrent.lock.UnsafeInstance;
import com.tuling.juc.Factory.UnsafeFactory;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.LockSupport;
/**
 * @Description
 * @Author zhenghao
 * @Date 2021/10/28 18:26
 **/
public class VisibilityTest {
    //volatile  是通过内存屏障来实现 可见性 ,汇编层面 会在执行的指令前面 增加  lock关键字
    //lock 关键字 可以起到和内存屏障一样的作用,但不是内存屏障
    //lock 前缀指令 会等待它之前所有的指令都完成,并且所有缓冲的写操作写会内存
    private volatile boolean flag = true;
    private int count = 0;
    public void refresh() {
        flag = false;
        System.out.println(Thread.currentThread().getName() + "修改flag");
    }
    public void load() {
        System.out.println(Thread.currentThread().getName() + "开始执行.....");
        int i = 0;
        while (flag) {
            i++;
            count++;
            //TODO  业务逻辑
            //等待足够长的时间的时候,本地内存中的变量值会被主动刷新到主内存中
            //没有这句代码的时候,程序不会中断,这是因为whil true 计算机任务flag这个
            //变量随时都会被用到,所以就不会刷新到主内存中
            //shortWait(1000000);
            //下面这种方式,是如何解决可见性呢?线程上下文切换 加载上下文
//            Thread.yield();//使当前线程由运行态 转换成 就绪态,让出CPU占用时间
            //通过内存屏障来实现 可见性
//            UnsafeFactory.getUnsafe().storeFence();
            // 底层使用到了 synchronized 该关键字 最后也是调用了 storeFence方法
            //通过 内存屏障 实现 可见性
//            System.out.println(count);
//            LockSupport.unpark( Thread.currentThread());
//
//            try {
//                Thread.sleep(1000);
//            } catch (InterruptedException e) {
//                e.printStackTrace();
//            }
            //总结 java保证 可见性 分为两种方式
            // 1、 内存屏障 jvm层面的 storeLoad内存屏障  === > X86 lock 替代了 mfence
            // 2、线程上下文切换Thread.yield();
        }
        System.out.println(Thread.currentThread().getName() + "跳出循环: i=" + i);
    }
    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
    public static void main(String[] args) throws Exception {
        VisibilityTest visibilityTest = new VisibilityTest();
        Thread threadA = new Thread(() -> visibilityTest.load(), "ThreadA");
        threadA.start();
        Thread.sleep(1000);
        Thread threadB = new Thread(() -> visibilityTest.refresh(), "ThreadB");
        threadB.start();
    }
}

一、运行上述代码结果如何?


从代码的写法上应该得到的结果是,运行一段时间后,程序会结束while 循环,并且打印出:跳出循环。但是当我们实际运行上面 结果的时候,程序会直接进入死循环,并不会结束。这就是我们常说的共享变量的可见性导致的,线程B对flag的修改,线程A并不能感知到。这样的代码在如果出现在实际业务中就会导致严重的bug。


       又JMM模型我们知道,这是因为在线程运行的时候,将flag变量都加载到了各自线程的本地内存中,而两个线程之间的通讯是通过主内存,所以我们如果想让线程B修改的变量的值,让线程A及时感知到,这就需要线程B对变量的修改及时刷新到主内存,并且其他线程本地内存对该变量的缓存失效。


二、解决方案


2.1 等待


我们从常理来推测,如果我们程序在短时间内不使用这个flag变量,理论上计算机会定时将变量从缓存中移除,毕竟每个线程的本地内存空间并不是很大,并且从一些常用的内存清理中间件来分析大概都是这个原理,所以我们在whil循环中增加 等待1ms,运行结果:程序跳出while 循环,达到可见性目的,这也说明计算机会将最近不是使用的变量从本地内存清除。具体等待多久才会达到这个效果和具体的硬件有关系,没有具体详细精准的时间。


2.2 线程上下文切换


这钟方式典型的用法就是 Thread.yield(),让当前线程从运行态变成就绪态,交出cpu使用权。基本线程切换的时间大概为5ms-10ms之间,线程上下文切换会导致当前线程本地内存失效,当该线程再次获得CPU使用权的时候,从程序计数器中获得下一条执行的指令,并且从主内存中加载变量值,这种方式也解决了 可见性问题。


2.3 volatile关键字


这种方式是我们最常见的一种解决方式,那么为什么volatile关键字可以解决这个问题呢?因为这是jdk中自带的一个关键字,所以我们需要查看jdk源码才可以更好的理解


973aad79a283d909233503d0acb9b1d6.png


从源码中我们可以发现,该关键字调用了storeload()方法,从这个方法命我们就可以知道,这里开始调用内存屏障的实现了。

inline void OrderAccess::storeload()  { fence(); }
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
  }


以上代码中在x86处理器上的实现,在该处理其中利用 lock 实现类似内存屏障的效果,这种lock的实现方式效率更高。


lock前缀指令的作用


1. 确保后续指令执行的原子性。在Pentium及之前的处理器中,带有lock前缀的指令在执行期间会锁住总线,使得其它处理器暂时无法通过总线访问内存,很显然,这个开销很大。在新的处理器中,Intel使用缓存锁定来保证指令执行的原子性,缓存锁定将大大降低lock前缀指令的执行开销。


2. LOCK前缀指令具有类似于内存屏障的功能,禁止该指令与前面和后面的读写指令重排序。


3. LOCK前缀指令会等待它之前所有的指令完成、并且所有缓冲的写操作写回内存(也就是将store buffer中的内容写入内存)之后才开始执行,并且根据缓存一致性协议,刷新store buffer的操作会导致其他cache中的副本失效。


汇编层面的lock实现


我们可以通过增加jvm参数来观察


-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp


ba72d1cedaf1483083e2324cc23de0c9.png


从验证了可见性是用来了lock指令


其余的几种方式  System.out.println(count); 底层使用了 synchronized关键字,源码最后也是调用了storeFence方法,也是利用内存屏障来实现了可见性。包括Thead.sleep()等


2.4 将count 类型从int 更换为integer


这种方式其实是利用了final关键字,如果我们看过intege 源码定义的话,我们最后其实获得的是value值,而在integer中value 是通过final关键字定义的,所以说final关键字也可以解决可见性问题。


总结


所以我们将上面的各种解决方案进行总结,


如何保证可见性


通过 volatile 关键字保证可见性。

通过 内存屏障保证可见性。

通过 synchronized 关键字保证可见性。

通过 Lock保证可见性。

通过 final 关键字保证可见性

其实上面的这几种方式,从底层可以分文两个大的方案;


       1)线程上下文切换


       2)内存屏障   jvm层面的 storeLoad内存屏障 === > X86 lock 替代了 mfence

目录
相关文章
|
7月前
|
缓存
并发编程的三大特性之可见性
并发编程的三大特性之可见性
33 0
|
7月前
|
算法 安全 编译器
并发的三大特性
并发的三大特性
82 1
|
4月前
|
缓存 Java 数据库连接
更简的并发代码,更强的并发控制
更简的并发代码,更强的并发控制
|
5月前
|
算法 Java 编译器
多线程线程安全问题之系统层面的锁优化有哪些常见的策略
多线程线程安全问题之系统层面的锁优化有哪些常见的策略
|
5月前
|
Java
多线程线程安全问题之什么是锁的粒度,减少锁的粒度有哪些好处
多线程线程安全问题之什么是锁的粒度,减少锁的粒度有哪些好处
|
5月前
|
安全 Java 开发者
探索Java内存模型:可见性、有序性和并发
在Java的并发编程领域中,内存模型扮演了至关重要的角色。本文旨在深入探讨Java内存模型的核心概念,包括可见性、有序性和它们对并发实践的影响。我们将通过具体示例和底层原理分析,揭示这些概念如何协同工作以确保跨线程操作的正确性,并指导开发者编写高效且线程安全的代码。
|
5月前
|
存储 设计模式 监控
Java面试题:如何在不牺牲性能的前提下,实现一个线程安全的单例模式?如何在生产者-消费者模式中平衡生产和消费的速度?Java内存模型规定了变量在内存中的存储和线程间的交互规则
Java面试题:如何在不牺牲性能的前提下,实现一个线程安全的单例模式?如何在生产者-消费者模式中平衡生产和消费的速度?Java内存模型规定了变量在内存中的存储和线程间的交互规则
53 0
|
7月前
|
缓存 安全 Java
多线程的三大特性:原子性、可见性和有序性
多线程的三大特性:原子性、可见性和有序性
160 1
|
Java
并发三大特性
并发三大特性
41 0
|
安全 Java
【并发技术09】原子性操作类的使用
【并发技术09】原子性操作类的使用