Java内存模型
什么是内存模型
描述了多线程如何正确的通过内存进行交互和使用共享内存。 充当程序员和处理器之间的调和者
JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证。
指令重排序
-
为了提升程序执行的性能
-
有哪些重排序?
- 编译器优化的重排序;重新安排语句的执行顺序
- 指令级并行重排序;
- 内存系统的重排序
graph LR 源代码--->1:编译器优化重排序--->2:指令级并行重排序--->3:内存系统重排序--->最终执行的指令序列 -
通过在指令的特定位置插入内存屏障指令来禁止特定类型的处理器重排序。
屏障类型 指令示例 说明 LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据的装载先于Load2及所有后续装载指令 StoreStore Barriers Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存)先于Store2及所有后续存储指令的存储 LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据装载先于Store2及所有后续的存储指令刷新到内存 StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器变得可见(指刷新到内存)先于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令 -
-
as-if-serial语义 不管怎么重排序,(单线程)的执行结果都不能被改变。
代码:
double pi = 3.14; //A
double r = 2; //B
double area = pi * r * r; //C
数据依赖图示:
graph LR
A--->C;
B--->C;
重排序后数据依赖图示:
graph LR
A--->B--->C;
graph LR
B--->A--->C
happens-before
如果一个操作执行的结果需要对另外一个操作可见,那么这两个操作之间必须要存在happens-before关系
一些happens-before的规则
- 单线程顺序规则:一个线程中的每个操作,happens-before于线程中任意后续的操作
- 监视器锁规则: 对一个锁解锁的操作 happens-before 对这个锁加锁的操作
- volatile变量规则: 对一个volatile变量的写操作happens-before对这个变量的的读操作。
- start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的ThreadB.satrt()操作happens-before线程B中的任意操作。
- join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从ThreaB.join()操作成功返回
- 传递性: A happens-before B;B happens-before C ; A happens-before C;
Note:happens-before并不是说前一个操作必须要在后一个操作之前执行,而是要求前一个操作的执行结果要对后一个操作可见
顺序一致性
- 顺序一致性内存模型(强一致性)
- 一个线程中的所有操作都必须按照程序的顺序来执行
- 不管程序是否同步所有的线程都只能看到一个单一的操作执行顺序。在顺序一致性内存模型中,每个操作都必须原子执行且立刻对所有线程可见。
内存通过一个左右摇摆的开关,可以任意连接到一个线程,同时每一个线程必须按照程序的顺序来执行内存的读/写操作。
- 顺序一致性模型和JMM的区别
- 顺序一致性模型会保证所有线程只能看到一致的操作执行顺序,而JMM不保证所有线程能看到一致的操作执行顺序。
- 顺序一致性模型保证单线程下按照程序顺序之前不会发生指令冲排序,而JMM会发生指令重排
- JMM不保证对64位的long和double变量的写操作具有原子性,而是一致性模型保证对所有内存读写操作都具有原子性
volatile和synchronize
-
volatile的实现原理 java代码
instance = new Singleton();转为汇编代码
0x01a3de1d: movb $0×0,0×1104800(%esi); 0x01a3de24: lock addl $0×0,(%esp);有volatile变量修饰的共享变量进行写操作的时候会出一个Lock前缀的指令。
- 将当前处理器缓存行的数据写回到系统内存
- 这个写内存的操作会使在其他CPU缓存了该内存地址的数据无效
说明了如果一个变量被声明为是volatile的,那么在对这个变量进行写操作的时候,JVM就会向处理器发送一条Lock前缀的指令,将这个变量所在的缓存行的数据写回到系统内存,并且了保证其他线程从自身的缓存行中读取该共享变量导致数据不一致,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存的值是不是已经过期,当处理器发现自己的缓存行对应的内存地址被修改,会将当前处理器的缓存行设置为无效状态。从而会从主内存中读取数据
-
volatile解决的问题
- 可见性。对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入
- 原子性:对任意单个volatile变量的读/写操作具有原子性,但类似于volatile++这种复合操作不具有原子性
volatile long a = 1L; a = 2L; //具有原子性 a = a+1;// 不具有原子性 a++; //不具有原子性 -
volatile的写/读内存语义
class Test{ int a = 0; volatile boolean flag = false; public void write(){ a = 1; flag = true; } public void reader(){ if (flag){ a = a+1; } } }- 写的内存语义 当写一个volatile变量时,JMM会把线程对应的本地内存共享变量刷新到主内存中。
- 读的内存语义 当读一个volatile变量时,JMM会把改线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
思考问题:根据内存读写内存语义,当A线程正在刷新本地内存到主内存中的时候(还没完成)时,线程B读取主内存的数据就是旧数据了
-
锁的内存语义
- 获取锁:JMM把该线程对应的本地内存置为无效,从而被监视器保护的临界区代码必须从主内存中读取共享变量。
- 释放锁:JMM会把本地内存的数据刷新到主内存中
-
synchronize原理
添加了synchronize关键字的方法或者代码块,在汇编代码层面会添加一对监视器;
MonitorEnter和MonitorExit,MonitorEnter编译后插入到同步代码块的开始位置,而```MonitorExit``插入到方方法结束的处和异常处. -
synchronize锁升级
-
锁的状态
-
无锁状态
-
偏向锁状态
- 大多数情况下,锁不仅不存在多线程竞争,而且总是由同一个线程多次获得,为了让线程获得锁的代价更低引入偏向锁。当一个线程访问同步块并获取锁时,会在对象头和栈帧中的锁记录里面存储偏向的线程ID,以后该线程再进入和退出同步块时,不需要进行CAS操作来加锁和解锁,只是需要简单的判断一下对象头的Mark Word里是否存储这指向当前线程的偏向锁
- 如果程序通常情况下都是竞争状态的,可以通过JVM参数关闭偏向锁:
-XX:-UseBiasedLocking=false
-
轻量级锁
- 加锁 线程在执行同步块之前,JVM会先在当前线程的栈帧中创建用户存储锁记录的空间,并将对象头中Mark Word复制到锁记录中,然后尝试使用CAS将对象头中Mark Word替换为指向锁记录的指针。如果成功,当前线程获得锁,如果失败,表示其他线程竞争锁,当前线程便尝试使用自旋来获取锁。
- 解锁 轻量级锁解锁时,会使用原子的CAS操作将Mark Word替换回对象头,如果成功,表示没有竞争发生。如果失败,表示当前锁存在竞争,锁就会膨胀成重量级锁。
-
重量级锁
-
锁的优缺点的对比
锁 优点 缺点 适用场景 偏向锁 加锁和解锁不需要额外的消耗,和执行非同步方法相比仅存在纳秒级的差距 如果线程间存在锁竞争,会带来额外的锁撤销的消耗 适用于只有一个线程访问同步块场景 轻量级锁 竞争的线程不会阻塞,提高了程序的响应速度 如果始终得不到竞争的线程,使用自旋会消耗CPU 追求响应时间同步块执行速度非常快 重量级锁 线程竞争不使用自旋,不会消耗CPU 线程阻塞,响应时间缓慢 追求吞吐量,同步执行时间较长
-
-
volatile和synchronize的区别
- volatile保证的是可见性,而synchronize保证的是可见性和原子性
- volatile的使用比synchronize的执行成本更低,它不会引起线程上下文的切换和调度。
-
final关键字的内存语义
通过final域增加写和读重排序规则,可以为Java程序员提供初始化安全保证:只要对象是正确构造的(被构造对象的引用在构造函数中没有逸出)那么就不需要使用同步,就可以保证任意线程都可以看到这个final域在构造函数中被初始化之后的值。
原子性保证
-
CPU如何保证内寸操作的原子性
当处理器读取一个字节时,其他处理器不能访问这个字节的地址。- 总线锁定
处理器提供一个LOCK信号,当一个处理器在总线上输出此信号时,其他处理器的请求就会被阻塞,那么该处理器就可以独占内存;(锁定了处理和全部内存的通信) - 缓存锁定
在通常情况下我们只需要对某个内存地址的操作是原子性就可以了,但总线锁定锁定的是CPU和内存之间的通信,这使得锁定期间,其他处理器不能操作其他内存地址的数据,所以总线锁定的开销比较大。
- 总线锁定
-
Java如何实现原子性操作
-
使用CAS实现原子操作
-
CAS实现原子操作的一些问题 1.ABA问题
如果一个值原来为A,变成了B,之后又变成了A,那么使用CAS进行检查时会发现它的值没有发生变化,但实际上已经变化了。ABA问题的解决思路就是使用版本号,在变量前面追加一个版本号,每次更新的时候把版本号+1。
AtomicStampedRefrence解决了ABA问题。使用的时候,先比较当前引用(版本号)和预期的引用是否相等,并且检查当前的值和预期的值是否相等。如果都相等才能修改成功。2.循环时间长开销大 3.只能保证一个共享变量的原子操作
-
-
使用锁机制实现原子操作
锁机制保证了只有获得锁的线程才能够操作锁定的内存区域.JVM内部实现了很多中锁机制,有偏向锁、轻量级锁和互斥锁。除了偏向锁,JVM实现锁的方式都用了循环CAS,即当一个线程进入同步块的时候使用循环CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。
-
AQS
- 原理
使用一个int成员变量同步状态,通过内置的FIFO队列来完成资源获取线程的排队工作。
-
同步队列
-
独占式同步状态获取与释放
-
* 独占式同步状态获取和释放过程
在获取同步状态时,同步器维护了一个同步队列,获取状态失败的线程都会被加入到队列中并在队列中进行自旋;移除队列(或停止自旋)的条件是前驱节点为头节点并且成功的获取了同步状态。在释放同步状态时,同步器调用tryRelease(int arg)方法释放同步状态,然后唤醒头节点的后继节点
-
共享式同步状态
- 允许多个线程同时获取一把锁
- 它和独占锁的区别主要在与tryReleaseShared(int arg)方法必须通过CAS的方式来释放
-
ReetrantLock重入锁
-
公平性问题 什么是锁公平性:在绝对时间上,先对锁进行获取的请求一定先被满足,那么这个锁就是公平的,反之就是不公平的。说白的了就是先到先得
-
公平锁
-
获取锁
static final class FairSync extends Sync { private static final long serialVersionUID = -3000897897090466540L; final void lock() { acquire(1); } protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { //在同步队列中当前节点没有前驱节点,才能去抢占锁 if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } //这里就表示可以重入 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } } -
释放锁
protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }
-
-
非公平锁
-
获取锁
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } //这里就表示可以重入 else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; } -
释放锁
protected final boolean tryRelease(int releases) { int c = getState() - releases; if (Thread.currentThread() != getExclusiveOwnerThread()) throw new IllegalMonitorStateException(); boolean free = false; if (c == 0) { free = true; setExclusiveOwnerThread(null); } setState(c); return free; }
-
-
-
读写锁ReentrantReadWriteLock
-
特性
特性 说明 公平性选择 支持非公平(默认)和公平的锁获取方式、吞吐量还是非公平优于公平 重进入 该锁支持重进入,以读写线程为例:读线程在获取了读锁之后,能够再次获取读锁。而写线程在获取了写锁之后能够再次获取写锁,同时也能再次获取读锁 锁降级 遵循获取写锁、获取读锁再释放写锁的次序,写锁能够降级成为读锁 -
实现分析
-
读写状态的设计
-
写锁的获取和释放
-
读锁的获取和释放
-
锁降级
public void processData(){ try { writeLock.unlock(); flag = true; readLock.lock(); }finally { writeLock.unlock(); try { //使用最新的flag数据完成后续业务 }finally { readLock.unlock(); } } }解释下这个代码为什么存在降级这一回事?如果没有锁降级那么代码如下:
public void processData(){ try { writeLock.unlock(); flag = true; }finally { try { //使用最新的flag数据完成后续业务 }finally { writeLock.unlock(); } } }这样的话如果使用最新flag数据完成业务很耗时,那么此时一直占用写锁,就不能进行读了。
所以锁降级的目的还是为了保证数据可见性的前提下提升读的性能。
-
-
-
Condition接口
- 提供了类似于Object的监视器方法,与Lock配合可以实现等待通知模式。
- Condition和Object的监视器方法的对比
并发工具类
-
CountDownLatch 允许一个或者多个线程等待其他线程完成操作
-
CyclicBarrier
-
CountDownLatch和CyclicBarrier的区别 CountDownLatch计数器只能使用一次,而CyclicBarrier的计数器可以使用Reset()方法重置,CyclicBarrier还提供了其他有用的方法,比如
getNumberWaiting方法可以获得在Cyclic-Barrier阻塞的线程数量。 -
Semaphore 用于做流量控制,特别是
公有资源有限的应用场景,比如数据连接。假如有一个需求,需要读取几万个文件的时候,因为都是IO密集型任务,我们可以启动几十个线程并发地读取,但是读到内存后,还需存储到数据库中吗,而数据库的连接数只有10个,这时我们就必须控制只有10个线程同时获取数据库里连接保存数据,否则将会报错无法获取数据库连接 -
Exchanger Exchanger(交换者)是一个用于线程间协作的工具类。Exchanger用于进行线程间的数据交换。它提供一个同步点,在这个同步点,两个线程可以交换彼此的数据。这两个线程通过exchange方法交换数据,如果第一个线程先执行exchange()方法,它会一直等待第二个线程也执行exhcange方法,当两个线程都到达同步点时,这两个线程可以交换数据,将本线程生产出来的数据传递给对方
术语解释
| 术语 | 英文单词 | 术语描述 |
|---|---|---|
| 内存屏障 | memrory barriers | 是一组处理器指令,用于实现对内存操作的限制顺序 |
| 缓冲行 | cache line | 缓冲中可以分配的最小存储单位。处理器填写缓存线时会加载整个缓存线,需要使用多个主内存读周期 |
| 原子操作 | atomic operations | 不可中断的一个或一系列操作 |
| 缓存行填充 | cache line fill | 当处理器识别到从内存中读取操作数是可缓存的,处理器读取整个缓存行到适当的缓存(L1,L2,L3的或所有) |
| 缓存命中 | cache hit | 如果进行高速缓存填充操作的内存位置任然是下次处理器访问的地址时,处理器从缓存中读取操作数,而不是从内存读取 |
| 写命中 | write hit | 当处理器将操作数写回到一个内存缓存的区域时,它首先会检查这个缓存的内存地址是否在缓存行中,如果存在一个有效的缓存行,则处理器将这个操作数写回到缓存,而不是写回到内存,这个操作被称为写命中 |
| 写缺失 | write misses the cache | 一个有效的缓存行被写入到不存在的内存区域 |
| 比较并交换 | Compare and Swap | CAS操作需要输入两个数值,一个旧值(期望操作前的值)和一个新值,在操作期间先比较旧值有没有发生变化,如果没有发生变化,才交换成新值,发生了变化则不变换 |
| CPU流水线 | CPU pipeline | CPU流水线的工作方式就像工业生产上的装配流水线,在CPU中有5 |
| 内存顺序冲突 | Mermory oder violation | 内存顺序冲突一般是由假共享引起的,假共享是指多个CPU同时修改同一个缓存行的不同部分而引起其中的一个CPU的操作无效,当出现这个内存顺序冲突时,CPU必须清空流水线 |
相关引用和参考
Java并发编程艺术