[并发基础篇]MESI协议,JMM,线程常见方法等

726 阅读14分钟

前言

我们在找工作时,经常在招聘信息上看到有这么一条:要求多线程并发经验。无论是初级程序员,中级程序员,高级程序员,也无论是大厂,小厂,并发编程肯定是少不了的。

但是网上很多博文直接上来就讲JUC,没有从基础出发,所以该篇旨在讲明并发基础,主要为计算机原理,线程常见方法,Java虚拟机方法的知识,为后面的学习保驾护航,话不多说,开始吧。


缓存一致性——MESI协议

CPU多级缓存官方概念

CPU在摩尔定律的指导下以每18个月翻一番的速度在发展,然而内存和硬盘的发展速度远远不及CPU,所以才引入了缓存的概念。我们可以从下图看出在CPU和主内存之间加了一个缓存,用来提升交互速度。


随着CPU的速率越来越快,人们对计算机性能要求越来越高,传统的缓存已经满足不了,所以引入了多级缓存,包括一级缓存,二级缓存,三级缓存,具体如图所示。


一级缓存:基本上都是内置在cpu内部,和cpu一个速度运行,能有效的提升cpu的工作效率。当然数量越多,cpu工作效率就会越高,但是由于cpu的内部结构限制了其大小,所以一级缓存的数据并不大。

二级缓存:主要作用是协调一级缓存和内存之间的工作效率。cpu首先用的是一级内存,当cpu的速度慢慢提升之后,一级缓存就不够cpu的使用量了,这就需要用到二级内存。

三级缓存:和一级缓存与二级缓存的关系差不多,是为了在读取二级缓存不够用的时候而设计的一种缓存手段,在有三级缓存cpu之中,只有大约百分之五的数据需要在内存中调取使用,这能提升cpu不少的效率,从而cpu能够高速的工作。

我们可以看下本机的缓存情况。


CPU多级缓存白话翻译

只有一级缓存情况:

我们可以将CPU当做我们本人,缓存区当做超市,主内存当做工厂,如果想要买东西(取数据)就先去超市(缓存区)买(取),如果超市(缓存区)没有,就去工厂(主内存)里面买(取)。

多级缓存情况:

我们可以将CPU当做本人,一级缓存当做楼下小区里面的小卖部,二级缓存当做普通超市,三级缓存当做大型超市,主内存当做工厂,如果想买东西先去楼下小卖部(一级缓存),小卖部(一级缓存)没有的话,就去普通超市(二级缓存),如果普通超市(二级缓存)还没有,就去大型超市(三级缓存),如果大型超市(三级缓存)还没有,就直接去工厂(主内存)取。这些缓存的出现使得我们不必每次都去工厂(主内存)买东西(取数据),节省了时间,提升了速度。

为什么需要CPU缓存

CPU速率太快,快到内存跟不上,在处理器处理周期内,CPU常常等待内存,造成资源的浪费。

缓存的意义

  • 时间局限性:如果某个数据被访问,在将来的某个时间也可能被访问。(白话翻译就是如果我今天买了薯片,那么以后我可能还会买薯片,毕竟是吃货O(∩_∩)O)
  • 空间局限性:如果某个数据被访问,那么他相邻的数据也有可能被访问。(白话翻译就是如果我今天买了薯片,那么我可以还会买其他膨化食品,毕竟他们两挨在一起)

带来的问题

对于多核系统来说, 每个核中缓存数据不一致的问题。

解决方式一——总线加锁(性能太低)

CPU从主内存读取数据到缓存区,并在总线对这个数据进行加锁,其他CPU无法去读写这个数据,直到这个CPU使用完数据,锁被释放了才访问。就比如我想去超市买一个辣条,但是张三也想买,在我买的过程中,就给辣条加了锁,张三根本碰不到辣条,我买的过程非常慢,那张三不急死啦嘛。

解决方式二——MESI协议(重点)

针对上面缓存数据不一致的情况,提出了MESI协议用以保证多个CPU缓存中共享数据的一致性,定义了缓存行Cache Line四个状态,分表是M(Modified),E(Exclusive),S(Share),I(Invalid)四种。

  • M(Modified修改):该行数据有效,数据被修改了,和内存中的数据不一致,数据只能存在于本缓冲区中。
  • E(Exclusive独占):这行数据有效,数据和内存中的数据一致,数据只存在于本Cache中。
  • S(Shared共享):这行数据有效,数据和内存中的数据一致,数据存在于很多Cache中。
  • I(Invalid无效):这行数据无效

MESI状态之间的迁移


这图一看是很懵逼的,咱慢慢来看哈,慢慢体会这些变化哈。

当前状态是Modified

  • 内核读取本地缓存中的值(local read):从缓存区中读取数据,状态不变,还是修改M
  • 本地内核写本地缓存中的值(local write):从缓存区中修改数据,状态不变,还是修改M
  • 其它内核读取其他缓存中的值(remote read):数据被写入内存,其他内存读取到最新数据,即为共享S
  • 其它内核更改其他缓存中的值(remote write):数据被写入内存,其他内存读取到最新数据,并修改和提交,此缓存区的状态为无效I

当前状态是Exclusive

  • 内核读取本地缓存中的值(local read):从缓存区中读取数据,状态不变,还是独占E
  • 本地内核写本地缓存中的值(local write):从缓存区中修改数据,即为修改M
  • 其它内核读取其他缓存中的值(remote read):数据被写入内存,其他内存读取到数据,即为共享S
  • 其它内核更改其他缓存中的值(remote write):数据被写入内存,其他内存读取到数据,并修改提交,即为无效I


当前状态是Share

  • 内核读取本地缓存中的值(local read):从缓存区中读取数据,状态不变,还是共享S
  • 本地内核写本地缓存中的值(local write):在缓存区中修改数据,即为修改M
  • 其它内核读取其他缓存中的值(remote read):数据被写入内存,其他内存读取数据,即为共享S
  • 其它内核更改其他缓存中的值(remote write):数据被写入内存,其他内存读取数据,并修改提交,即为无效I


当前状态是Invalid

  • 内核读取本地缓存中的值(local read):如果其他缓存里面没有这个值,状态即为独享E;如果其他缓存里有这个值,状态即为共享S
  • 本地内核写本地缓存中的值(local write):在缓存区中修改数据,即为修改M
  • 其它内核读取其他缓存中的值(remote read):其他核的操作与他无关,即为无效I
  • 其它内核更改其他缓存中的值(remote write):其他核的操作与他无关,即为无效I

并行和并发的区别

并发:同一时刻只能有一个指令执行,但多个指令被CPU轮换执行,因为时间间隔很短,会造成同时执行的错觉。

并行:同一时刻多条指令在多个处理器同时执行,不管是微观,还是宏观上,都是同时执行的。

举个例子,并发就是一个家庭主妇既要烧饭,也要带娃,也要打扫房间,如果每个事情只做一分钟,然后轮换,从宏观上来说,会造成同时执行的错觉。并行就是该家庭主妇请了两个保姆,一个专职负责烧饭,一个专职负责带娃,自己专职负责打扫卫生,不管从宏观还是微观上来看,他们都是同时执行的。

某位大佬曾经说两者的区别,并发是同一时间应对多件事情的能力,并行是同一时间去多件事情的能力。作为一个工科生,不知道如何夸大佬,只知道喊666。

进程和线程的关系

进程是用来加载指令,管理内存,执行语句的。

线程是进程的一部分,一个进程可以分为1个或多个线程。

网易云音乐的打开,就是开启了一个进程,而播放,查找,评论等都是线程。

线程之间的通信

线程之间的通信比较简单,可以通过他们的共享内存通信,具体可以看下面Java内存模式部分。

进程之间的通信

进程之间的通信比较复杂,对于同一台计算机而言,其通信称为IPC;对于不同计算机,其通信需要网络并遵循彼此约定的协议,如HTTP等。这部分偏硬件,咱也不敢说,咱也不敢问。

线程的状态(从硬件层面)

初始状态:新建new一个线程,还没有进行任何步骤,还未和硬件关联上。

可运行状态:当调用start方法,即进行可运行状态(就绪状态),但是这个时候还没获取到时间片,具体什么时候运行取决于硬件。

运行状态:当CPU分配的时间片到某个线程了,该线程即可进入运行状态。

阻塞状态:当线程调用阻塞API,线程并没有用到CPU,其进入阻塞状态。

终止状态:当一个线程运行结束了,即进入终止状态。


一些常见的线程操作

创建线程的三种方式

线程和任务合并

Thread thread=new Thread(){
      public void run(){
          System.out.println("开始");
      }
};

线程和任务分开

 Runnable runnable=new Runnable() {
            @Override
            public void run() {
                System.out.println("开始");

            }
        };

 Thread thread=new Thread(runnable);

FutureTask返回执行结果

FutureTask<String> futureTask=new FutureTask<String>(new Callable<String>() {
            @Override
            public String call() throws Exception {
                return "线程的返回值";
            }
        });

Thread thread=new Thread(futureTask);

线程启动start

thread.start();

这里start是进入就绪状态,即可运行状态,具体什么时候要看CPU。

等待线程运行结束join

未加join情况:

 Runnable runnable=new Runnable() {
            @Override
            public void run() {
                System.out.println("线程开始");
                try {
                    sleep(4000L);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                System.out.println("线程结束");
            }
        };
//创建线程
Thread thread=new Thread(runnable);
//启动线程
System.out.println("主线程开始");
thread.start();
System.out.println("主线程结束");

运行结果:



使用join的情况:

 Runnable runnable=new Runnable() {
       @Override
       public void run() {
           System.out.println("线程开始");
           try {
                sleep(4000L);
           } catch (InterruptedException e) {
                e.printStackTrace();
           }
           System.out.println("线程结束");
       }
 };
  //创建线程
  Thread thread=new Thread(runnable);
  //启动线程
  System.out.println("主线程开始");
  thread.start();
  thread.join();
  System.out.println("主线程结束");

运行结果:


没有用join方法的第一情况,主线程开始和主线程结束都在前面,并靠在一起,而线程开始和线程结束则在后面,因为他们是两个不同的线程,彼此互不干扰。而用了join方法的第二种情况,主线程结束在最后一行,因为join方法需要等待子线程结束后才能继续执行后面代码。

获取线程id,name,priority

 //创建线程
 Thread thread=new Thread(){
       public void run(){
           System.out.println("线程开始");
       }
 };
 //启动线程
 thread.start();

 System.out.println("id:"+thread.getId());
 System.out.println("name:"+thread.getName());
 System.out.println("priority:"+thread.getPriority());

运行结果:


Java内存模型——JMM

内存模型

跟多级缓存差不多意思,每个线程里面都有工作内存,其存储的是主内存中数据的副本,如下图。那如果主内存中有变量a=1,现在线程A,B,C都存了a=1的副本,线程A对其进行加1操作,并刷新到主内存。可是线程B,C并不知道这种情况,那么就出问题啦。那如何解决这个问题呢?下面将慢慢说,不急。


8种原子操作(概念)

下面罗列的是8种原子操作,大家大概看看,下面将详细描述。

  • read(读取):从主内存中读取数据
  • load(载入):将主内存读取到的数据写入工作内存
  • user(使用):从工作内存读取数据来计算
  • assign(赋值):将计算好的值重新赋值到工作内存中
  • store(存储):将工作内存数据写入主内存
  • write(写入):将store过去的变量值赋值给主内存中的变量
  • lock(锁定):将主内存变量加锁,标识为线程独占状态
  • unlock(解锁):将主内存变量解锁,解锁后其他线程可以锁定该变量

8种原子操作(举例)

咱以上面的例子画了个图,请原谅偶我笨,画的丑了点。


1.read读取:将主内存中的a=1读取出来。

2.load载入:将从主内存中a=1载入到线程A的工作内存中。

3.use使用:将线程A工作内存的a=1读取到,并进行自增操作。

4.assign赋值:将a=2写入到线程A的工作内存中。

5.store存储:将a=2存储到主内存中。

6.write写入:将a=2写入到主内存的a变量中。

7.lock锁定:在上面CPU缓存解决不一致的方法一中,线程A操作的时候,对主内存a变量进行加锁操作(lock),线程B根本读不了a变量。

8.unlock解锁:线程A操作解锁之后,对主内存a变量进行解锁操作(unlock),线程B可以读到a变量并对其操作。

注意:lock和unlock存在着一个性能问题,我们发现写的代码明明是多线程并发操作,但是底层还是串行化,并没有真正实现并发。

可见性原理


上面说的MESI协议是在总线那边实践的,线程A,B可以同时获取主内存a的值,a进行自增操作之后在进行操作6write写入的时候,会经过总线。线程B一直使用嗅探监控总线中自己感兴趣的变量a,一旦发现a值有修改,立刻将自己工作内存中a置为无效Invalid(利用MESI协议),并立刻从主内存中读取a值,这个时候总线中a还没有写入内存,所以有个短暂的lock过程,等到a写入内存了,进行unlock操作,线程B即可读取新的a值。

该过程虽然也有lock与unlock操作,但是锁的粒度降低啦。

并发的风险与优势

优势:

  • 速度方面:同时处理多个请求,响应更快,复杂的操作可以分成多个进程同时进行。
  • 设计方面:程序设计在某些情况下更简单,也可以有更多的选择。
  • 资源利用方面:CPU能够在等待IO的时候做一些其他的事情。

风险:

  • 安全性方面:多个线程共享数据时可能会产生与期望不相符的结果。
  • 活跃性方面:某个操作无法继续进行下去时,就会发生活跃性问题,比如死锁,节等问题。
  • 性能方面:线程过多时会使得:CPU频繁切换,调度时间增多;同步机制;消耗过多内存。

结语

看到这里的都是真爱,先行谢过。此篇是并发系列的基础,主要聊了硬件的MESI协议,原子的八种操作,线程和进程的关系,线程的一些基础操作,JMM的基础等。如果有什么错误,或者不对的地方,欢迎指正。


求个关注

走过路过不要错过,欢迎来围观,小姐姐陪你侃技术,唠五毛钱的嗑。


参考资料

Java并发编程入门与高并发面试

CPU多级缓存与缓存一致性

Java高并发编程精髓Java内存模型JMM详解全集