目录
1、CAS(compare and swap) 的ABA问题
硬件模型
学习并发之前,我们要先简单了解一下计算的硬件模型。
程序加载到主存中之后,先加载到CPU高速缓存中待命,甚至会加载到CPU寄存器当中,让CPU进行处理。主存的处理速度是远远不如CPU的,所以中间加了一个CPU高速缓存,配合CPU的速度,定时和主存同步数据(因为主存跟不上CPU的频率,经常等待主存),另外为了让处理器内部运算单元尽可能的充分利用,处理器还会对输入的代码进行乱序执行,但是保证结果是一致的。而寄存器是CPU内存的基础,每个CPU都会包含一系列的寄存器。CPU在寄存器中的处理速度要比在主存中效率高。
CPU多级缓存
我们把CPU比喻成一个大型加工总部,内存为部件存储大仓库,而缓存就是总部与大仓库之间的小仓库,离CPU较近的小仓库是一级缓存,其次依次为二级缓存和三级缓存,当加工总部需要加工某个成品时候需要很多部件,这个时候缓存就是把所需要的部件提前从内存调出,存储在小仓库内,当总部加工需要某个部件时候就可以直接从最近的小仓库提取,就不必大费周章去内存大仓库调取。
既然存在多个缓存区域,那么怎么保证缓存数据的一致性呢?
MESI -CPU缓存一致性协议
MESI(modified Exclusive shared or invalid)是一种广泛使用的支持回写策略的缓存一致性协议。推荐文章
CPU缓存行使用4种状态进行标记,进而将数据进行同步或失效来保持多级缓存的一致。
状态转化图
M:被修改(modified) | 此数据被修改,但未同步到主存中 | 被写回到主存中,状态变成独享(E) |
E:独享(Exclusive) | 未被修改过,与主存一致的数据 | 被读取时,变成共享(S) |
S:共享(shared) | 被多个CPU缓存过,与主存一致 | 被修改之后,其他CPU中的此缓存失效(I) |
I:失效(Invalid) | 缓存无效 |
JAVA内存模型
Java Memory Model,JMM是JVM规范定义的一个抽象的概念。他屏蔽了Java程序在不同硬件和OS对内存访问的差异,实现了在不同平台上都能达到内存访问的一致性。主要是围绕如何处理原子性、可见性、顺序性这三个特征来设计的。
JVM的内存模型不同于JMM内存模型。JMM主要是为了定义程序中的共享变量的访问规则,即在虚拟机将变量存储(或取出)到主内存的底层细节。注意,像局部变量和方法参数这种线程私有的变量不包含在内。(可以把JMM的范围比作JVM中的堆存放的数据规则)。参考文章
那Java内存模型到底是什么呢?
说明:JVM规定共享变量都在主内存中产生,而JVM的每个线程都有自己的工作内存,是私有的,每个线程对共享变量修改的时候,都是在工作内存中对副本进行修改,然后经过JMM控制命令进而同步到主内存中来保持数据的一致性,而多个线程操作的这些同步变量都是经过主内存来进行传递同步的,有8种操作命令来完成。每种操作必须是原子性的。
8种操作
lock(锁定) | 锁定主内存中的变量,只能供一个线程用 |
unlock(解锁) | 释放主内存中的变量,其他线程才可以上锁 |
read(读取) | 把主内存的变量传输到线程的工作空间 |
load(载入) | 把read到工作空间的值,放到变量副本中 |
use(使用) | 把工作内存中的变量传递给执行引擎 |
assign(赋值) | 把执行引擎执行的结果赋值给变量副本 |
store(存储) | 把工作内存中的变量传递给主内存 |
write(写入) | 吧store到主内存中的变量值放到变量中 |
图例表示
这几种操作之间也有一定的同步规则(执行顺序)
- 如果要把一个变量从主内存传输到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存回写到主内存,就要顺序的执行store和write操作。对于普通变量,虚拟机只是要求顺序的执行,并没有要求连续的执行
- 不允许read和load、store和write操作之一单独出现
- 不允许一个线程丢弃最近的assign操作,也就是不允许线程在自己的工作线程中修改了变量的值却不同步/回写到主内存
- 不允许一个线程回写没有修改的变量到主内存
- 变量只能在主内存中产生,不允许在工作内存中直接使用一个未被初始化的变量,也就是没有执行load或者assign操作。也就是说在执行use、store之前必须对相同的变量执行了load、assign操作
- 一个变量在同一时刻只能被一个线程对其进行lock操作
- 对变量执行lock操作,就会清空工作空间该变量的值
- 不允许对没有lock的变量执行unlock操作
- 对一个变量执行unlock之前,必须先把变量同步回主内存中
但是对于volatile修饰的变量有一些特殊规则
线程的安全性
首先,多线程的意义是什么呢?目的在于最大限度的利用CPU资源。因为CPU速度太快了,但是我们IO速度,网络速度,数据库连接等跟CPU的速度相比较来说差太远,于是用多线程来减少CPU的空闲时间。但是在某个时刻内,CPU实际上只能执行一个线程,外部看起来是用了多线程,其实是操作系统对进程线程进行了管理,分配每个进程的时间,而每个进程内,程序自己处理线程的时间分配。就这样,多个线程来回进行切换调度。
凡事有利有弊,那么多线程自然也带来了一些缺憾需要弥补,那就是线程的安全性。经常谈的是原子性,可见性和有序性。
那么如何保证线程的安全性呢?
1)原子性
涉及问题
1、CAS(compare and swap) 的ABA问题
ABA问题:如果线程A对变量a初次读取的时候是1,并且在准备赋值的时候检查到它仍然是1,那这中间很可能出现一种情况是线程B刚好把变量a从1设置成2,又再次设置成1,所以线程A看到的初始值1很可能已经被修改过,会有问题。
如何解决?可以加版本号,推荐文章
2、synchronize
是通过底层系统指令来实现的,JDK1.6对其进行了优化,在其之前synchronize的性能很差,是重量级的锁,不如ReentranLock。但是1.6之后,引入了偏向锁等,提高了性能,在一般情况下还是建议使用synchronize。如果需要利用到Lock特性的时候,再使用Lock。
synchronize的作用有:①线程互斥访问同步代码 ②保证共享保证变量可见 ③有效解决重排序问题
synchronize主要解决的是执行控制的问题,实现线程间的互斥。另外他和volatile类似,也可以保证变量可见性。
下面我们详细看下synchronize的原理:
它是基于Monitor对象来实现的,Monitor是一种同步工具,可以把对的的方法互斥执行(只能有一个允许执行),Java中,每个对象都带了一把Monitor的监视器,当对象加了synchronize关键词之后,会调用Monitorenter和monitorexit两个字节码指令,表示这个线程对此对象上锁,监视器的值+1,线程使用完成之后,释放锁,-1。如果是同样的线程再次访问此对象,则可重入。
每一个线程都有一个可用monitor record列表,同时还有一个全局的可用列表。每一个被锁住的对象都会和一个monitor record关联(对象头的MarkWord中的LockWord指向monitor record的起始地址),同时monitor record中有一个Owner字段存放拥有该锁的线程的唯一标识,表示该锁被这个线程占用。
对象成为锁(被锁住)后,对象头里的mark word字段指向线程栈的Monitor Record对象(实现锁与线程的关联)
通过锁对象的mark word字段可以找到持有锁的线程(线程栈保存锁对象的对象头信息的Monitor Record数据结构)
JDK1.6之后的优化部分:
锁的状态有4种:无状态锁,偏向锁,轻量级锁,重量级锁,依次单向升级,不会降级。
1、引入偏向锁
目的:为了减少同一个线程获取锁(CAS操作,耗时)的代价而引入偏向锁。如果一个线程拿到锁,可重入(无需任何操作)省去了大量有关锁申请的操作,提高性能。
适用于长时间单一线程的访问,或者线程交替同步执行的情况。
如果多个线程同时来抢,就会变成轻量级锁。
2、轻量级锁
目的:减少锁切换状态、减少阻塞线程的概率。
多个线程同时访问,但竞争不多的情况。
多次CAS,如果没有得到锁,就自旋等待,会消耗CPU,继续拿锁。如果自旋超过一定次数,就变成重量级锁。
3、重量级锁
拿不到锁,不会自旋,不会消耗CPU。
synchronize等待环境机制
A线程上锁a对象时,会上锁,此时B线程来的话,会排队等待A线程,A线程需要通知B线程去再次拿锁。主要通过notify()、notifyAll()、wait()方法来实现。注意,这些一定是在同步代码块中才能执行这些操作,因为这些方法的前提是此对象上了锁,才需要被释放,等待,通知。否则会爆出IllegalMonitorStateException异常。
是因为调用这几个方法前必须拿到当前对象的监视器monitor对象,也就是说notify/notifyAll和wait方法依赖于monitor对象,在前面的分析中,我们知道monitor 存在于对象头的Mark Word 中(存储monitor引用指针),而synchronized关键字可以获取 monitor ,这也就是为什么notify/notifyAll和wait方法必须在synchronized代码块或者synchronized方法调用的原因
2)可见性
导致共享变量不可见的原因:
①线程交叉执行
②重排序
③共享变量更新之后的值没有在及时在主存和工作空间中及时更新
关键词:synchronize、volatile
1. sychronize是如何保证可见的呢?
JVM规定,线程解锁前,必须把共享变量的最新值刷新到主内存
线程加锁时,将情况工作内存中共享变量的值,从而使用共享变量时需要中主存中重新读取最新的值。
2.volatile如何保证可见?
通过内存屏障和禁止重排序优化来实现
内存屏障:对volatile变量写操作时,会在写操作后加一条store屏障指令,将本地内存中的共享变量值刷新到主内存中。
读操作时,会在之前加入一条load屏障指令,从主内存中读取共享变量。(类似synchronize)
重排序:
禁止写重排序
禁止读重排序
3)有序性
关于重排序问题:编译器在将Java代码编译成字节码的时候可能会对代码进行重排序,而CPU在执行机器指令的时候也可能会对其指令进行重排序。虽然单一线程中重排序不会影响到程序的执行结果,但是可能会在多线程执行的情况下,因为重排序出现问题,所以,我们需要用一些关键词来控制线程的有序性。
关键词:synchronize、volatile、Lock
volatile是通过禁止指令重排来实现有序性。
synchronize不能禁止,他是通过控制线程的执行顺序来达到有序性。