Open Wlazylion opened 3 years ago
无论是JAVA还是C++,并发都是比较难的,因为与计算机底层硬件密不可分,因为不确定性
课程大纲思路 1、并发编程的本质是解决什么问题? 别名,多线程编程 多线程存在线程与线程之间交互的问题 本质有三点(线程之间)
2、并发学什么? 2.1、JAVA层面,JMM模型,JAVA内存模型(JAVA线程内存模型) 并发特性:原子性、有序性、可见性 在屏蔽掉不同操作系统之间的差异的要求下,如何解决CPU寄存器与内存之间完成数据交互的问题 硬件层面:机械同感(人机合一?比如硬件层面如何去执行i+1的)、CPU缓存架构、缓存一致性协议 还有volatile、CAS等
学习思想比技术点更重要
2.2、线程,JAVA中的线程是一个内核级的线程 通过new Thread()创建线程,通过start方法启动,那么Thread的对象与普通的JAVA对象有什么区别?答:Thread对象使用了许多native的方法来实现 JVM不具备调度CPU的权限,JVM的进程无法获取到时间片,JAVA线程是内核级的线程,JAVA中的Thread对象必须在JVM底层绑定一个osThread对象,然后在内核中开启内核线程,比如Linux中的pthread_create;JAVA中的线程只是建立了了这样的一个映射绑定关系:(javaThread--------osThread--------phThread_create),这涉及到了从用户态切换至内核态的过程
2.3、锁机制 内置锁,synchronized,是jvm实现的,在1.6之后做了各种的优化,比如自适应自旋、偏向锁(用户态)、轻量级锁(用户态)、重量级锁(内核态)、object monitor机制 juc,独占锁,共享锁,读写锁,公平锁,非公平锁,AQS(同步队列、条件队列) 上面两块引出一个概念,抽象队列同步器(加锁目的:序列化的访问临界资源)。举例:线程执行不是串行的,而是并行的,三个线程同时访问一个资源,一个允许访问,另外两个同步阻塞,必须等待,这就是同步器的作用 等待唤醒机制,wait/notify,park/unpark
2.4、线程池,线程复用 线程的创建会有额外的内存开销的,线程池用于降低线程的额外开销,原因在于JAVA线程最终会映射到内核线程中
2.5、工具类,并发的容器(二十节前面的课程有讲解)
2.6、并行,Fork-Join框架 JAVA7之后从并发步入到并行时代
2.7、并发设计模式 不变性 copyonwrite 等待唤醒机制 生产者消费模式等
并发的好处与风险 并发的好处:压榨CPU多核处理能力 并发的风险: 1、性能问题(上下文频繁切换) 课程讲到:00:35:00前后,可以反复观看 线程之间切换需要涉及到 存储指令执行行号到寄存器、保存局部变量、加载指令到寄存器中,加载局部变量 这就涉及到保存现场、恢复现场(这一块可以于栈帧的入栈和出栈类比理解)
2、活跃性问题(饥饿、死锁、活锁) 什么是死锁? 幽默向:面试官问你什么是死锁,你回答如果你录取我,我告诉你什么是死锁,我不回答,你不录取我。你不录取我,我不回答,彼此都在等待和阻塞
synchronized之死锁例子 注意:synchronized上锁,会操作对象的对象头,设置其中的标记mark-word,不要浅显的理解为锁住代码块而已 `package thread;
public class DeadThread {
private static String a = "a";
private static String b = "b";
public static void main(String[] args) {
Thread threadA = new Thread(() -> {
synchronized (a) {
System.out.println("threadA进入a同步块,执行中...");
try {
Thread.sleep(2000);
synchronized (b) {
System.out.println("threadA进入b同步块,执行中...");
}
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}, "threadA");
Thread threadB = new Thread(() -> {
synchronized (b) {
System.out.println("threadB进入b同步块,执行中...");
synchronized (a) {
System.out.println("threadB进入a同步块,执行中...");
}
}
}, "threadB");
threadA.start();
threadB.start();
}
}`
使用jps查到进程号,比如13928 再使用jstack ,比如 jstack 13928,得到如下部分结果
"threadB": waiting to lock monitor 0x00000000035395a8 (object 0x000000076b16ef50, a java.lang.String), which is held by "threadA" "threadA": waiting to lock monitor 0x000000000353bee8 (object 0x000000076b16ef80, a java.lang.String), which is held by "threadB"
"threadB": at thread.DeadThread.lambda$main$1(DeadThread.java:27)
Found 1 deadlock.
解决死锁的办法之一是,停止其中一个线程
什么是饥饿? 饥饿也有可能造成死锁 通俗来说,线程A对一个资源B加了一把锁,继续执行,执行过程中优先级被调低了,那么其他线程就可能在继续执行(因为线程A的优先级降低了),此时线程A发现怎么样都抢占不到CPU的执行权,即时间片,导致其他要竞争资源B的线程一直在等待、阻塞,当前线程处于饥饿,其他线程处于死锁 windows操作系统是一个抢占式的,底层调度会根据线程的饥饿状态以及优先级去分配时间片,如果线程比较“饿”,优先级又较高,能更优先抢占到时间片
什么是活锁? 通俗来说,两个线程一直在谦让,没有干实事,一直在跑,这就是活锁
深入拓展,记录于:#10
3、线程安全问题 最简单解决办法:加锁 如何判断是否有线程安全:一,通过并发三大特性(原子性、有序性、可见性)来判断;二,先行发生原则(happens-before) 这一块知识点在《深入理解JAVA虚拟机》第二版12.3.6节中有,去看一下
并发编程基础概念 1、计算机组成原理 2、CPU缓存架构 3、进程与线程 4、并发与并行 5、线程上下文切换 6、编译原理 7、安全点 8、as-if-serial 9、happens-before 10、用户态与内核态 11、用户线程与内核线程 12、JVM线程调度:线程的创建、线程的状态 13、线程的生命周期 14、CAS原理 15、重量级锁 16、自旋锁 17、自适应自旋锁 18、轻量级锁 19、偏向锁 20、重量级锁降级机制的实现原理 21、逃逸分析 22、栈上分配
并发与并行 目标都是最大化CPU使用率
并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的 并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行
并发可以认为是一种程序的逻辑结构的设计模式,可以在一个核上执行,也可以在多个核上执行 并行只能在多个核上执行,不能在一个核上执行
下面的代码,修改load函数中doSomething(0);传入的值,从0到4,分别会出现能跳出循环和不能跳出循环两种情况 0,什么也不做,不能跳出循环 1,sleep,能跳出循环 2,使用System.out.println输出,能跳出循环 3,等待10微秒,不能跳出循环 4,等待20微秒,能跳出循环
`package thread;
public class VisibilityTest {
private boolean flag = true;
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 ++;
doSomething(0);
}
System.out.println(Thread.currentThread().getName() + "跳出循环:i=" + i);
}
public void doSomething(int x) {
switch (x) {
case 1: // 能跳出循环
try {
Thread.sleep(0);
} catch (InterruptedException e) {
e.printStackTrace();
}
break;
case 2: // 能跳出循环
System.out.println("====="); // synchronized
break;
case 3: // 不能跳出循环
shortWait(10000); // 10微秒
break;
case 4: // 能跳出循环
shortWait(20000); // 20微秒
break;
case 0: // 不能跳出循环
default: // 不能跳出循环
// do nothing
break;
}
}
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) {
VisibilityTest test = new VisibilityTest();
new Thread(() -> test.load(), "threadA").start();
try {
Thread.sleep(2000);
new Thread(() -> test.refresh(), "threadB").start();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}`
JMM内存模型
JSR-133: http://gee.cs.oswego.edu/dl/jmm/cookbook.html
这一块结合《深入理解JAVA虚拟机》第二版12.3节一起来看,其中讲到了8种抽象出来的原子性、不可再分的操作(lock、unlock、read、load、use、assign、store),用于理解volatile非常有用
JMM内存模型关键是要理解它的思想,在MQ、Redis等分布式框架中,都含有类似的模型概念,即一个主内存,N个工作内存,工作内存之间信息同步需要通过主内存
回答JMM模型,要把握两个点
上面是例子对应的JMM模型的工作原理
对于ThreadA,会经过read、load操作将flag读入工作内存(本地内存),然后use操作写入cpu core1(寄存器保存) 对于ThreadB,也会有上面的步骤。但最后是写入cpu core2(因为cpu core1一直在被占用),然后给flag赋值为false,再经过assign操作写回工作内存,注意,此时不会立马执行store、write操作,写回主内存,而是在某一时刻进行 此时,ThreadA继续读取flag,是从工作内存中加载的,不是从主内存中加载的
load函数中doSomething(0);传入的值,从0到4 0,什么也不做,while(flag)一直在进行,threadA的工作内存缓存一直有效,因此一直未从主内存中读取,一直是true 1,sleep,会让出CPU时间片,线程上下文切换(保存现场、恢复现场),因此会从主内存中重新读取flag,读到了false则跳出循环 注意:与sleep的时间没有关系,即时sleep0ms,也会让出CPU时间片 2,使用System.out.println输出,println方法实现内部有synchronized(this)操作,synchronized会保证可见性,因此会从主内存中读取flag。读到了false则跳出循环 3,等待10微秒,太短了,缓存未失效,因此一直未从主内存中读取,一直是true 4,等到20微秒,缓存失效了,因此会从主内存中读取,读到了false则跳出循环
给flag变量加上volatile关键词后
private volatile boolean flag = true;
上述0-4,执行都会跳出循环
上面例子表示了volatile关键字的含义之一:保证变量对所有线程的可见性
关于volatile的原理描述,于《深入理解JAVA虚拟机》第二版12.3.3节到12.3.6节,有很详细的说明,这里就不赘述了
我们来看一下volatile在汇编层面是如何工作的
上面“让人怀疑人生的例子”中,加一些VMOptions:-XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -Xcomp,然后运行主程序 (注意:Windows10下运行可能会报错,Could not load hsdis-amd64.dll,参考解决方案处理即可: https://www.cnblogs.com/niceboat/p/9786905.html )
运行结果很多,不放把结果重定向到一个1.txt文件中(通过IDEA的Debug Configuration可以做到),运行完成后,搜索文件内容"putfield flag",可以找到下面的内容
0x00000000034f167e: mov esi,0h
0x00000000034f1683: mov byte ptr [rdx+0ch],sil
0x00000000034f1687: lock add dword ptr [rsp],0h ;*putfield flag
; - thread.VisibilityTest::refresh@2 (line 8)
rsp是64位栈寄存器,关键在于lock add dword ptr [rsp](一般的书籍中是 lock addl $0x0,(%rsp) )
参考 fox老师课程资料/并发书籍/IA-32+架构软件开发人员手册+.pdf,2.6.5节控制处理器
并发程序在计算机中是如何执行的? 计算机组成原理 计算机组成大概有这么些硬件:CPU、内存、总线、显卡、USB、磁盘控制器、网卡驱动等(见总图上图)
CPU有三个单元:控制单元、计算单元、存储单元
程序是如何运行的,磁盘上的一个程序(文件),首先,需要先通过IO总线、内存总线读取到内存当中,然后,比如需要 private boolean flag = true; 的值,则通过内存总线、系统总线读取到CPU的寄存器(Registers)中
从内存读取到寄存器中,并非一步到位的,中间会有一个环节,叫做CPU高速缓存
CPU多级缓存结构 现在的CPU一般都是多核的,CPU Core1、CPU Core2、...,每个核中都有寄存器 以及 两级缓存(L1和L2),是核心独享的,外层还有一个第三级缓存(L3),是所有核心共享的(见总图左图)
查看Windows10的任务管理器如下 我的电脑,CPU有6个核,三级缓存(L1,L2,L3),L1和L2是核心独享的,L3是核心共享的,还可以看出每一级上升,存储空间越来越大
而对于多CPU架构,每个CPU都有一个独立的L3缓存(也是核心共享的),每个L3缓存与主内存RAM交互信息(见总图右图)
上面所述的CPU多级缓存结构,可以抽象成如下图形 说明一下,上面的图形,是抽象出来的,因此并非那么准确,如果是两个独立的CPU,那么L3缓存层也是独立的,但如果是一个CPU,两个独立的核(或者甚至就是一个核的不同表达),那么L3缓存层应该是共享的,可是图中画成独立的了,不过无伤大雅,理解L3缓存层的属性概念即可
这里提出一个问题,为什么需要高速缓存?主内存RAM直接与CPU寄存器交互,不好吗?
原因是CPU寄存器执行指令非常的快,如下图所示,条线长度和后面的数字,表示了每个元件的执行周期,比如主内存需要167个时间周期执行一次,如果CPU直接与主内存交互,会拖慢系统效率,因此加上了高速缓存,高速缓存存储了主内存刚使用过或循环使用的一部分数据,L1时间执行周期非常短,CPU只有4,只需要等待4个时间周期即可了 早期的CPU只有L1和L2缓存,后来发现性能还能提升,CPU设计上就多了一个L3共享缓存
接下来的描述可能会有些复杂,假设有两个线程Thread1和Thread2,都对同一个变量 i 进行加值操作,i 初始值是0,Thread1 让 i + 1,Thread2 让 i + 2,最终预期结果是 3
先来谈Thread1,CPU先问L1缓存,发现没有数据,再问L2缓存,再问L3缓存,最终问主内存RAM读取变量 i 的值(通过内存地址定位),值是0,将值读回寄存器存储(L3 -> L2 -> L1 -> 寄存器),然后对值操作+1,值变为1 同样的,对于Thread2(与Thread1是同时进行的),通过同样的方式,将 i 的值读取到寄存器中,然后对值操作+2,值变为2
此时,两个线程都在将值回写到主内存RAM,那么主内存中的 i 变量对应内存地址的值是多少呢?
可能是1,可能是2,不确定,要看哪个线程最后回写,但是!我们期望的结果是3,无论哪个线程最后回写,结果都不正确 (当然,也有可能线程1或2回写完,线程2或1才开始操作,这样结果就是3了) 上面的例子,就是线程不安全的典型例子,有不确定性,程序运行会乱套,结果不符合预期
如何解决线程不安全问题 上面的线程不安全问题,归根结底,是由于CPU多个高速缓存导致的(多说一下,MQ、Redis等架构也存在类似问题),总结为 缓存不一致问题
解决办法:总线锁 总线锁是锁住了总线,一个CPU读取总线时,其他CPU不允许读取,相当于把多核CPU执行变成了串行,这样明显会有性能问题
优化解决办法:锁缓存行(cacheline) 内存会分成一小块一小块的,每64个字节是一个缓存行,每8个缓存行是一个内存小块 (因此若数据超过64个字节,锁缓存行会失效,有例子吗?)
锁缓存行有一个协议:缓存一致性协议 缓存一致性协议有很多实现:MESI、MSI、MOESI 等
缓存一致性协议 M:修改 E:独占 S:共享 I:无效
关于缓存一致性协议,更深入内容记录于:#105
注意一点,volatile无法保证线程安全,具体例子和解释在《深入理解JAVA虚拟机》第二版12.3.3节中有非常详细的讲解,这里就不赘述了。简单说明下原因,volatile保证的是变量多所有线程的可见性,在一个线程读取一个变量时,一定是保证从主内存中读取到的值,但是,当修改一个变量的时候,volatile没法保证一个线程 在其他线程将数据同步回主内存后 再进行操作,这就出现了线程不安全了
再看JMM内存模型及volatile
是否觉得 “硬件架构多CPU多核缓存架构图” 与 “JMM内存模型” 有几分相似之处?
JVM会保证 JMM中的本地内存尽可能的映射到CPU高速缓存,JMM中的共享主内存尽可能的映射到主内存RAM
JAVA变量加了volatile后,汇编码中会多出一个lock指令(比如 lock add dword ptr [rsp]),触发缓存一致性协议
我个人理解,lock 就是锁缓存行,将缓存段标记为 Modified 状态,这样其他处理器会马上变为 Invalid 状态;若其他处理器希望读取该缓存行,要将 Modified 状态的处理器先标记回 Shared 状态,且 Modified 状态的处理器需要将内容写回主内存,这样就保证了可见性
lock指令同时含义了 可见性 与 禁止指令重排,本节课只讲了可见性,关于禁止指令重排,再下一节课讲,请见:
课程笔记:https://www.yuque.com/books/share/9f4576fb-9aa9-4965-abf3-b3a36433faa6/td13lh 练习题:https://www.yuque.com/books/share/9f4576fb-9aa9-4965-abf3-b3a36433faa6/gobeqv 书籍推荐:https://www.yuque.com/books/share/9f4576fb-9aa9-4965-abf3-b3a36433faa6/dkpb24 上课老师:fox老师 上课时间:2020-09-15
总结
死锁、活锁、饥饿上课有讲,记录于:#102 TODO:关于JMM,老师推荐了一个外文教程,可以开启中文翻译,去看一下:https://www.youtube.com/watch?v=Z4hMFBvCDV4 关于JMM,老师推荐了一本书:《Java并发编程实战》,已买,已下载英文原文PDF 关于缓存一致性协议,更深入内容记录于:#105