关于java:蹲坑也能进大厂多线程系列volatilehappensbefore三级缓存

6次阅读

共计 4876 个字符,预计需要花费 13 分钟才能阅读完成。

前言

假期完结,开始下班了,又是丧气满满的一天,还好有花 Gie 的文章陪伴,不然我会寂寞到死的(臭不要脸)。

多线程曾经介绍了三篇多线程的很多知识点了,还没有看过的小伙伴记得花点工夫看下,本系列都是从浅到深,逐渐递进,不要想一口吃个瘦子哦,小心虚胖(手动狗头)。

狗剩子:花 GieGie~,来这么早。

我:文章还没写完呢,还有一堆小伙伴等着我呢,必定来得早啊。

狗剩子:那你还在这蹲坑

我:…….

本章完结就要完结 Java 内存模型的解说了,虽说完结,然而前面会始终贯通在各个知识点中,多线程比拟根底的知识点解说的差不多了, 本文正式引入了对 volatile 的剖析,接下来的几章也会对 线程池 CASThreadLocal 原子类 AQS 并发汇合 等逐渐解说,看完这一系列,谁还能与你争锋(仍旧狗头护体)。

啪啪啪 … 看我如何打脸

注释

我:狗儿,你昨天提及了主内存和工作内存,上次介绍有点太糙了,明天能够再认真说一下嘛

好的呀,我都依你。

说到 JMM 主内存和工作内存,咱们就必须要先理解一下CPU 缓存构造

Core0、Core1 代表两个核

L1:每个核上有两个 L1, 一个用于存数据(Data Cache),一个用于存指令(Instruction Cache)

先看上图,CPU 存在三级缓存L1/L2/L3,你可能会想 CPU 是闲着没事干,设计那么多层内存干啥,间接从主存(物理内存)中读写数据他不香吗,这样就省事多了。然而咱们要思考一下,CPU 办事效率十分高,处理速度和物理内存相比不在同一个层级,如果 CPU 每次的读写都间接和主存交互,这样会大大降低指令的执行速递,这也就引出了这三级缓存。

a = a + 1

举个简略的栗子,当线程执行这个语句时,会先从主存当中读取 变量 a 的值,拷贝到高速缓存中,而后 CPU 执行指令对 变量 a 进行 加 1 操作,并将数据写入缓存,最初将高速缓存中 变量 a 批改后的值刷新到主存当中。

拓展:线程在获取数据时首先会在最快的缓存中(L1)找数据,如果缓存没有命中(Cache miss) 则往下一级找, 直到三级缓存都找不到时,那只有向内存(Main Memoy)查找数据了,未命中的次数越多,耗时也就越长。

我:那这个和咱们 JMM 的内存构造有什么关系嘞?

Java 作为一门高级语言,屏蔽了这些底层细节,而是 JMM 定义了一套读写内存的标准。在 JMM 中,主内存和工作内存并非真正意义上的物理划分,而是 JMM 的一种形象,它将 L1L2 以及 寄存器 形象成工作内存,每个处理器只能进行独享,而 L3RAM 形象成主内存,在处理器之间进行共享。

JMM 对于主内存 / 工作内存的束缚:

  • 所有变量存储在主内存中,每个线程领有本人的工作内存,工作内存中的变量是主内存中拷贝的 正本
  • 线程不能间接操作主内存,只能通过批改本地内存,而后本地内存同步到主内存;
  • 线程之间不能间接进行通信,只能通过主内存进行直达;
  • 正是因为线程间这种通信形式,加上线程之间通信是有延时的,这也就导致了 可见性问题

我:咱们有什么方法能够解决可见性问题吗?

咱们能够通过 happens-before 准则来解决可见性问题。

我:(粗率了,竟然没有听过)那 … 那能够说说这个具体是指什么吗?

happens-before具体是指什么呢,我举个栗子:动作 A 产生在动作 B 之前,那动作 B 必定可能看见动作 A,这就是 happens-before 准则。

如果还是感觉很形象,那咱们再看个反例:两个线程(线程 1 线程 2 ),对于线程 1 执行的货色,线程 2 有时能够看到但有时候又看不到 ,这种状况下就不具备 happens-before。这里看过花 Gie 蹲坑系列文章的小伙伴,应该能够想到上篇文章咱们解说过可见性的一个案例,呈现第四种状况,b=3,a=1 的状况时《蹲坑也能进大厂》多线程系列 -Java 内存模型精讲,正是因为不具备 happens-before 准则。

我:说了 happens-before,那它都有哪些利用呢?

这里先简略的列举一下,小伙伴们大抵理解一下 happens-before 波及到的范畴有哪些就足够了, 前面会对每一项独自进行解说。

它的利用十分广,看上面这些分类,小伙伴们预计大部分都理解过的吧:

  • 单线程准则:

    单个线程中,依照程序的程序,前面的操作肯定能够看到后面的操作内容。

  • start():

    主线程 A 启动线程 B,线程 B 中能够看到主线程启动 B 之前的操作。

  • join():

    主线程 A 期待子线程 B 实现,当子线程 B 执行结束后,主线程 A 能够看到线程 B 的所有操作。

  • volatile
  • synchronizedLock
  • 工具类:

    • 线程平安容器:例如 CurreentHashMap
    • CountDownLatch
    • Semaphore
    • 线程池
    • Future
    • CyclicBarrier

synchronized线程池 等知识点,这里因为篇幅限度,前面都会一一解说,逐渐更新,有趣味的小伙伴们能够关注一下(花 Gie,明天的广告帮你打了,工资结一下吧)。

我:(老脸一粉)工资那个再说,你先说说 volatile,我还等着去搬砖呢。

首先 volatile 是一种同步机制,一旦一个共享变量(成员变量、动态成员变量)被 volatile 润饰之后,那么就具备了以下两个作用:

  • 可见性:就是说一个线程批改了某个变量的值,其余线程可能立马感知到该变量已被批改;
  • 禁止指令重排序。

  • 对于可见性,我这里举个栗子:
static boolean flag = true;

public static void main(String[] args) throws InterruptedException {
    // 线程 1
    new Thread(new Runnable() {
        @Override
        public void run() {while (flag){System.out.println("啥也不是!!!!");    
            }
        }
    }).start();
    Thread.sleep(10);
    // 线程 2
    new Thread(new Runnable() {
        @Override
        public void run() {flag = false;}
    }).start();}

这段代码用于进行一个线程,置信有不少小伙伴用到过,然而这并不是一个正确进行线程的办法,因为这里存在极小概率会进行线程失败。当 线程 1 更改了 flag 变量后,还没来得及将内容回写到主存当中,就被安顿做其余事件去了,此时 线程 1 并不能感知到线程 2 曾经对 flag 变量进行批改,因而会继续执行上来。

如果用 volatile 润饰 flag 变量,那就齐全能够防止这种状况呈现,起因有以下几点:

  • 应用 volatile 关键字会强制将批改的值立刻写入主存;
  • 应用 volatile 关键字的话,当线程 2 进行批改时,会导致线程 1 的工作内存中缓存变量 flag 的缓存行有效(也就是上文提到的 CPU 中 L1 或者 L2 缓存中对应的缓存行有效);
  • 因为线程 1 的工作内存中缓存变量 flag 的缓存行有效,所以线程 1 再次读取变量 flag 的值时会去主存读取。

因而 线程 2 批改 stop 值时(批改 线程 2 工作内存中的值,并将批改后的值写入内存),会使得 线程 1 的工作内存中缓存变量 stop 的缓存行有效,而后 线程 1 读取时,发现自己的缓存行有效,它会期待缓存行对应的主存地址被更新后,去对应的主存读取最新的值。


  • 禁止指令重排序

后面章节咱们提到编译器在解释代码时,理论执行的程序和咱们代码编写的程序很可能是不同的,直白的说就是编译器只保障执行后果和你想要的统一,但至于先执行哪句代码、后执行哪一句代码,我说了算。但这里仅仅是在单线程下比拟好用,一旦引入了多线程,就会呈现各种奇怪的问题。

这里举个简略的栗子:

//a、b 为非 volatile 变量
//flag 为 volatile 变量
 
a = 2;        // 语句 1
b = 0;        // 语句 2
flag = true;  // 语句 3
c = 4;         // 语句 4
d = -1;       // 语句 5 

因为 flag 变量为 volatile 变量,那么在进行指令重排序的过程的时候,不会将语句 3 放到语句 1、语句 2 后面,也不会讲语句 3 放到语句 4、语句 5 前面。然而 要留神语句 1 和语句 2 的程序、语句 4 和语句 5 的程序是不作任何保障的。

并且 volatile 关键字能保障,执行到语句 3 时,语句 1 和语句 2 必然是执行结束了的,且语句 1 和语句 2 的执行后果对语句 3、语句 4、语句 5 是可见的。

我:这么说我就懂了,那 a++ 问题是不是也能依赖 volatile 解决呢?

这个咱们先看上面一段代码。

import java.util.concurrent.atomic.AtomicInteger;

public class volatileDemo implements Runnable {

   volatile int a;
   // 只有晓得该类在并发状态下进行自增或自减,是线程平安的即可
   AtomicInteger realCount = new AtomicInteger();

   public static void main(String[] args) throws InterruptedException {Runnable r =  new volatileDemo();
      Thread thread1 = new Thread(r);
      Thread thread2 = new Thread(r);
      thread1.start();
      thread2.start();
      thread1.join();
      thread2.join();
      // 应用 a ++ 失去的后果
      System.out.println(((volatileDemo) r).a);
      // 线程安全类失去的后果
      System.out.println(((volatileDemo) r).realCount.get());
   }
   @Override
   public void run() {for (int i = 0; i < 1000; i++) {
         a++;
         //realCount 减少 1
         realCount.incrementAndGet();}
   }
}

失去的后果为:

这个是为什么呢,我明明加上 volatile 了呀,你这个咋不好使了呢,骗子,退钱。

淡定 ….. 先别急,咱们先来扒开表面,深刻的理解一下 a++,这其实并非原子操作,而是蕴含了几个步骤:读取 a 的值,进行加 1 操作,最初把加好的值赋值给 a。

看到这里是不是就不奇怪了,因为 volatile 并不能保障 原子性

比方上面这个流程:

  • 线程 1 读取到 a 的值并执行完 + 1 动作(还未执最初的赋值)
  • 此时另一个线程 2 也读取到 a 的值并执行 + 1 动作;
  • 线程 1、线程 2 实现赋值,并将新值写回主存;
  • 能够看出线程 2 用于计算的 a 值仍旧为批改前的,所以等到线程 2 执行结束后,a 的值会少减少一次。

我:讲的很棒,必须加鸡腿,那你把 volatile 给小伙伴们总结一下吧?

总结下来有以下几点:

  • volatile 提供可见性。用于被多个线程共享的变量,保障被任意一个线程批改后,其余线程能立马获取到批改后的值;
  • volatile 不能代替synchronized,它不具备原子性和互斥性;
  • volatile 只作用于属性,能够禁止该属性被重排序;
  • volatile 提供了 happens-before 保障,也就是说对 volatile 变量进行批改后,其余线程都能获取到批改后的值。

总结

明天这章又和大家进一步探讨了 JMM,你是不是对它也有了一个新的意识呢,除此之外咱们还引入了新的知识点volatile,这个也是多线程中比拟根底且十分罕用的,十分有必要把握,肝了一天,篇幅有点长,小伙伴们肯定要耐下心看看。

下一章花 Gie 会持续介绍大家十分相熟的 synchronized,会不会和你认知的不一样呢,咱们下一章见。 心愿大家继续关注,为了大厂梦,咱们持续肝

点关注,防走丢

以上就是本期全部内容,如有纰漏之处,请留言指教,非常感谢。我是花 GieGie,有问题大家随时留言探讨,咱们下期见🦮。

文章继续更新,能够微信搜一搜「Java 开发零到壹」第一工夫浏览,后续会继续更新 Java 面试和各类知识点,有趣味的小伙伴欢送关注,一起学习,一起哈🐮🥃。

原创不易,你怎忍心白嫖,如果你感觉这篇文章对你有点用的话,感激老铁为本文 点个赞、评论或转发一下,因为这将是我输入更多优质文章的能源,感激!

正文完
 0