多线程的一些基本概念
多线程笔记(一)
1. sleep()方法和yield()方法
-
共同点:让当前线程释放cpu资源,让其他线程来运行
-
不同点:调用sleep()方法后,线程进入到TIMED_WAITING状态,等待超时后进入RUNNABLE状态,开始抢占CPU资源。调用yield()方法后,线程进入RUNNABLE状态,直接开始抢占CPU资源。
2. 偏向锁和可重入锁的区别
-
偏向锁是指一个线程访问同步块的时候,第一次会获取锁,在没有其他线程竞争锁的情况下再访问同步块不需要再获取锁,直接访问同步块。节省了获取锁和释放锁的开销。
-
可重入锁是指一个线程访问同步块1需要锁A并获得锁A,接下来访问另一个同步块2也需要锁A,在线程持有锁A的情况下访问同步块2时不需要再获取锁。
3. wait, notify/notifyAll注意事项
- wait, notify/notifyAll 必须放到同步块或者同步方法里面去执行
- 注意使用锁的对象来调用wait, notify/notifyAll
- 其底层使用的是Monitor机制,wait过后线程会进入monitor对象的对应的WaitSet
- 调用wait方法后就会释放锁
- 调用notify/notifyAll方法后并不会立即获得锁。要等前面的线程释放锁之后再去争抢锁
4. Java内存模型(JMM)
- 所有变量(共享的)都存储再主内存中,每个线程都有自己的工作内存,工作内存中保存该线程使用到的变量的主内存副本拷贝
- 线程对变量的所有操作(读,写)都应该再工作内存中完成
- 不同线程不能相互访问工作内存,交互数据要通过主内存
内存之间的交互操作
-
lock: 锁定,把变量表示为线程独占,作用于主内存变量
-
read: 读取,把变量值从主内存读取到工作内存
-
load: 载入,把read读取到的值放入工作内存的变量副本中
-
use: 使用,把工作内存中的一个变量的值传递给执行引擎
-
assign: 赋值,把从执行引擎接收到的值赋给工作内存里面的变量
-
store: 存储,把工作内存中的一个变量的值传递到主内存中
-
write: 写入,把store进来的数据存放如主内存的变量中
-
unlock: 解锁,把锁定的变量释放,别的线程才能使用,作用于主内存变量
内存间交互操作的规则
- 不运行read和load;store和write操作之一单独出现,以上两个操作必须按顺序执行,但不保证连续执行,也就是说,read和load;store和write之间是可以插入其他指令的
- 不允许一个线程丢弃它的最近的assign操作,即变量在工作内存中改变了之后必须把该变化同步回主内存
- 不允许一个线程无原因地(没有发生过任何assign操作)把数据从线程的工作内存同步回主内存中
- 一个新的共享变量只能从主内存中“诞生”,不允许在工作内存中直接使用一个未被初始化的变量,也就是对一个变量实施use和store操作之前,必须先执行过了load和assign操作
- 一个共享变量在同一时刻只允许一个线程对其执行lock操作,但lock操作可以被同一个线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会被解锁
- 如果对一个共享变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用这个变量前,需要重新执行load
- 如果一个共享变量没有被lock操作锁定,则不允许对它执行unlock操作,也不能unlock一个被其他线程锁定的共享变量
- 对一个共享变量执行unlock操作之前,必须先把此变量同步回主内存(执行store和write操作)
5. 并发编程三大特性
原子性:一个操作要么全部执行成功,要么全部执行失败
可见性:一个线程修改了共享变量之后,其他线程能够立刻看到这个修改
有序性:程序执行的顺序是按照代码的逻辑先后循序来执行的
6. 重排序
编译器或处理器为了优化程序的执行性能,对指令执行的顺序重新排列
目的:尽可能减少寄存器的读取和存储次数,复用寄存器存储的数据
分类
- 编译器重排序:编译器再不改变程序在单线程环境下运行的语义的前提下,重新安排语句的执行顺序
- 指令重排序:处理器将多条指令并行执行,如果不存在数据依赖,处理器可以改变语句对应的指令的执行顺序
- 内存系统重排序:处理器使用缓存和读写缓冲区,使得数据的加载存储操作看上去是乱序执行的
数据依赖
如果两个操作访问同一个共享变量,而且两个操作里面有一个为写操作,那么这两个操作直接就存在数据依赖性。
具有数据依赖性的指令是不会被重排的
数据依赖的分类:
- 读后写:读一个变量过后,再写这个变量
- 写后写:写一个变量过后,再写这个变量
- 写后读:写一个变量过后,再读这个变量
as-if-serial语义
不管有没有重排序,也不关心如何进行重排序,单线程环境下,程序的执行结果不会被改变。
7. happens-before
-
如果一个操作happens-before另一个操作,那么第一个操作的执行结果将对第二个操作可见(保障可见性)
且第一个操作的执行顺序排在第二个操作之前(JMM对程序员做出的一个逻辑保障,并不是代码指令真正的执行保障)
-
即使两个操作之间存在happens-before关系,并不意味着Java平台的实现必须要按照happens-before关系指定的顺序来执行
第一条是JMM对程序员做出的逻辑保障
第二条是JMM对编译器,处理器进行重排序的约束原则:只要不改变程序的执行结果(不管是单线程还是多线程),爱怎么排怎么排
happens-before规则
-
程序顺序规则:一个线程中的每个操作 happens-before 该线程中的任意后续操作
-
监视器锁规则:对一个锁的解锁操作 happens-before 随后对这个锁的加锁(就是先释放锁,后加锁)
-
volatile变量规则:对一个volatile修饰的字段进行的写操作 happens-before 任意后续对这个字段进行的读操作
-
传递性:如果A happens-before B,且B happens-before C,那么A happens-before C
-
start规则:如果线程A里执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.start()操作 happens-before 于线程B中的任意操作
-
join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreadB.join()操作成功返回。
8. 内存屏障
内存屏障是一种屏障指令,它使得处理器或编译器对屏障指令的前面和后面所发出的内存操作,执行一个排序的约束。也叫内存栅栏或栅栏指令
作用
- 阻止屏障两边的指令重排序
- 写数据的时候加了屏障的话,强制把写缓冲区的数据刷回到主内存中
- 读数据的时候加了屏障的话,让工作内存或CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据
分类
读屏障:Load Barrier: 在读指令之前插入读屏障,让工作内存或CPU高速缓存当中缓存的数据失效,重新到主内存中获取新的数据
写屏障:Store Barrier: 在写指令之后插入写屏障,强制把缓冲区的数据刷回到主内存中
9. 重排序与内存屏障
JVM本身为了保证可见性:
对于编译器的重排序,JMM会根据重排序的规则,禁止特定类型的编译器重排序
对于处理器的重排序,Java编译器在生成指令序列的适当位置,插入内存屏障指令,来禁止特定类型的处理器排序
JMM的内存屏障
- LoadLoad Barriers:
示例:Load1; LoadLoad; Load2
禁止重排序,访问Load2的读取操作一定不会重排到Load1之前。由于在读指令之前插入读屏障,所有会保证Load2在读取的时候,自己缓存内相应数据失效,Load2会重新到主内存中获取最新的数据
- LoadStore Barriers:
示例:Load1; LoadStore; Store2
禁止重排序,一定是Load1读取数据完成后,才能让Store2写操作的数据写入到主内存
- StoreStore Barries:
示例:Store1; StoreStore; Store2
禁止重排序,一定是Store1的数据写入主内存后,才能让Store2写操作的数据写入主内存。由于在写指令之后插入写屏障,所以会保证Store1写出的数据强制刷回到主内存中
- StoreLoad Barries:
示例:Store1; StoreLoad; Load2
禁止重排序,一定是Store1的数据写入主内存后,才能让Load2读取数据。由于在写指令之后插入写屏障,所以会保证Store1写出的数据强制刷回到主内存中。由于在读指令之前插入读屏障,所有会保证Load2在读取的时候,自己缓存内相应数据失效,Load2会重新到主内存中获取最新的数据。
为什么说StoreLoad Barries是最重(和内存交互次数多,交互延迟较大)的?
因为其既要保证读屏障也要保证写屏障
扩展
这些屏障指令并不是处理器真实的执行指令,它们知识JMM定义出来的,跨平台的指令。因为不同硬件实现内存屏障的方式并不相同,JMM为了屏蔽这种底层硬件平台的不同,抽象出了这些内存屏障指令,在运行的时候,由JVM来为不同的平台生成相应的机器码。这些内存屏障指令,在不同的硬件平台上,可能会做一些优化,从而只支持部分的JMM的内存屏障指令
10. Volatile关键字
volatile修饰的变量有如下特点:
- 保证可见性
- 不保证原子性
- 禁止指令重排
-
对一个volatile修饰的变量进行读操作的话,总是能够读到这个变量的最新的值,也就是这个变量最后被修改的值
-
一个线程修改了volatile修饰的变量的值的时候,那么这个变量的新的值,会立即刷新回到主内存中
-
一个线程去读取volatile修饰的变量的时候,该变量在工作内存中的数据无效,需要重新到主内存去读取最新的数据
volatile内存语义
volatile写的内存语义:写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量的值刷新到主内存中
volatile读的内存语义:读一个volatile变量时,JMM会把线程对应的工作内存中的共享变量数据设置为无效的,然后从主内存中去读共享变量最新的数据
volatile内存语义的实现
-
字节码层面:
它影响的是Class内的Field的 falgs ,添加了一个ACC_VOLATILE。JVM在把字节码生成为机器码的时候,发现操作的是volatile变量的话,就回根据JMM的要求,在相应的位置去插入内存屏障
-
JMM层面:
第一个操作 | 第二个操作(普通读写) | 第二个操作(volatile读) | 第二个操作(volatile写) |
---|---|---|---|
普通读写 | 不允许重排序 | ||
volatile读 | 不允许重排序 | 不允许重排序 | 不允许重排序 |
volatile写 | 不允许重排序 | 不允许重排序 |
volatile写之前的操作都禁止重排序到volatile之后
volatile读之后的操作都禁止重排序到volatile之前
volatile写之后的volatile读,禁止重排序
为了实现volatile内存语义,按如下方式插入内存屏障
(1)在每个volatile写操作的前面插入一个StoreStore屏障
(2)在每个volatile写操作的后面插入一个StoreLoad屏障
(3)在每个volatile读操作的后面插入一个LoadLoad屏障
(4)在每个volatile读操作的后面插入一个LoadStore屏障
- 处理器层面:
CPU执行机器码指令的时候,是使用 lock 前缀指令来实现volatile的功能的
lock指令相当于内存屏障,功能也类似于内存屏障的功能
(1)首先对总线/缓存加锁,然后去执行后面的指令,最后释放锁,同时把CPU高速缓存的数据刷新回到主内存
(2)在lock锁住总线/缓存的时候,其他CPU的读写请求就会被阻塞,直到锁被释放。lock过后的写操作,让会其他CPU的高速缓存中相应的数据失效,这样后续这些CPU在读取数据的时候,就会从主内存去加载最新的数据