本文转载自微信公众号「 学习Java的小姐姐」,转载本文请联系 学习Java的小姐姐公众号。
前言
我们在找工作时,经常在招聘信息上看到有这么一条:要求多线程并发经验。无论是初级程序员,中级程序员,高级程序员,也无论是大厂,小厂,并发编程肯定是少不了的。
但是网上很多博文直接上来就讲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("线程结束");
- }
- };
- //创建线程
- hread 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的基础等。