文章目录
第三章 Java内存模型
框架图
Java内存模型的基础
并发编程模型的两个关键问题
问题:线程之间如何通信、线程之间如何同步。
通信:指线程之间怎么交换资源,即交流。现在有两种方法,一种是共享内存的方法,读、写的内容都是公用的,所以也是隐式通信;另一种是消息传递方法,线程之间发送消息来显式通信。
同步:控制不同线程间的操作的发生顺序(控制顺序是因为某种需要?)。在共享内存模型中,要显式指定哪些代码块在线程之间是互斥的,所以说是显式的;在消息传递模型中,消息的发送、接收顺序是固定的,所以不需要做什么,就是隐式的同步。
(顺序是如何控制的,通过锁的限制?同步队列?)
Java中使用的是共享内存模型。
Java内存模型的抽象结构
本地内存,缓存?
共享:所有的实例域、静态域、数组元素都在堆内存中,堆内存是在线程间共享的。(这些被称为共享变量)
不共享:局部变量、方法定义参数、异常处理参数。
内存划分:可以抽象的分成主内存和本地内存,共享变量是存放在主内存的,本地内存存放共享变量的副本,缓存、写缓冲区、寄存器、其他的硬件和编译器优化都在本地内存中。
内存模型抽象结构图:
不同线程通信步骤:
- A从本地写入主内存。
- B从主内存读取到本地内存。
通信过程必须经过主内存,为程序员提供了内存可见性保证。
从源代码到指令序列的重排序
目的:提高性能。
流程:
从源代码到最终执行的指令序列,分别经历下面三种重排序:
源代码 -> 1:编译器优化重排序 -> 2:指令级并行重排序 -> 3:内存系统重排序 -> 最终执行的指令序列
第一个是编译器重排序,二三是处理器重排序。重排序可能会导致多线程程序出现内存可见性问题。
(为什么会出现内存可见性问题)
并发编程模型的分类(No See)
happens-before简介
介绍:在JMM中,如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。
注意:happens-before并不意味着前一个操作必须在后一个操作前面执行(二者之间可以又别的工作,只要相对关系确定就行),只要前一个操作的执行结果对后一个可见就行(顺序对了,不可见也不行。)
好处:简单易懂,避免了为了理解JMM提供的内存可见性而去学习复杂的重排序和对应的实现方法。(是不是说不用学了?)
重排序(好像不是重点)
介绍:指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段
先看看最后一节吧。
对多线程影响:单线程程序中,对存在控制以来的操作重排序,不会影响结果;多线程中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。(控制以来就是,if else这样的判断条件吧)。
(所以volatile还是sync啥的限制了重排序?)
顺序一致性
(就是没有重排序?)
数据竞争与顺序一致性:两个线程都在操作同一变量,如果读和写都没有通过同步来排序,执行的结果就有问题,所以要保证程序执行的顺序一致性。(这个说明了内存可见性问题?顺序乱了可见性就出问题)
顺序一致性内存模型:
保证了内存可见性,两大特征:
- 线程中的操作必须按照程序的顺序执行。
- 不管程序是否同步,所有的线程只能看到一个单一的操作执行顺序,每个操作都必须原子执行且立刻堆所有线程可见。
模型图:
说明:顺序一致模型有一个单一的全局内存,在单一时间点上,只有一个线程可以访问内存,根据立即可见性,最终串行起来的程序执行顺序对于所有线程来说是一样的(主要是因为原子性和立即可见性)。
同步的情况:执行顺序没错,且对于所有线程展现的顺序都一样
不同步的情况:虽然不同线程之间的程序顺序串行起来无序,但是所有的线程见到的顺序都是一样的
与JMM相比:如果不同步,JMM不仅线程程序无序,最后对所有线程显示的顺序也是无序的,因为JMM并没有保证立即可见,在没刷新到主内存之前,写操作只有自己的线程可见,其他线程就会觉得根本没发生过。
同步程序的顺序一致性效果
代码:
二者执行时序:
JMM会在进入、退出临界区的时候做重排序,在临界区的里面顺序是不一样的,但出来后内存视图是和顺序一致性模型一样的,这样效率又高,又不改变结果。
JMM实现方针:在不改变程序执行结果的前提下,尽可能为编译器和处理器优化做方便。
未同步程序的执行特性
JMM:对未同步的只提供最小安全性,即线程执行时读取的值,要么是之前某个线程写入的,要么是默认。
在两个模型中的特性:
- 顺序一致能保证单程需中按照顺序来,JMM不保证未同步程序的执行结果与该程序在顺序一致性模型中的执行结果一致(要做重排序)(所以有些操作会对重排序有单独限制)。
- 立即可见性,同上。
- 顺序一致对所有内存读写操作都有原子性,而JMM不保证对64位的long和double型变量的写操作的原子性(因为在一些32位系统中,对64位开销比较大,所以可能会拆分成两个32位来操作。注意读操作是原子性的)
volatile的内存语义
特性:
- 锁的happens-before保证了释放锁和获取锁的两个线程之间的内存可见性。所以读volatile变量的进程总能看到最后的volatile的写入。(这个可见性是如何保证的?缓存控制?跟锁一样?)
- 对临界区的单个volatile变量的读/写代码有原子性,即使是long和double都有,但对复合操作没有原子性。
作用:
- volatile变量的写-读可以实现线程之间的通信。(通过共享内存控制flag之类的?)
- volatile的写-读与锁的释放-获取具有相同的内存效果。(是指内存的共享吧)
volatile内存语义的实现
volatile重排序规则表:
举例:第三行最后一个,当第一个操作时普通变量的读/写,第二个操作是volatile的写的时候,编译器不能重排序这两个操作。
重排序规律:以volatile读开头、以volatile写结尾、以volatile写开头读结尾的,都不能重排序,因为这几个顺序乱了的话,与内存的交互结果也不一样。
禁止重排序的方法:在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
基于保守策略的JMM内存屏障插入策略:
volatile写插入内存屏障后的指令序列(前后各一个):
- StoreStore:保障上面所有的普通写在volatile写之前刷新到主内存。
- StoreLoad:避免volatile写与后面可能有的volatile读/写操作重排序。
volatile读插入内存屏障后的指令序列(后面两个):
- LoadLoad:禁止把上面的volatile读与下面的普通读重排序。
- LoadStore:禁止把上面的volatile读与下面的普通写重排序。
对应指令如下:
为何要增强volatile的语义内存
解释:在旧的内存模型中,当1和2之间没有数据依赖关系的时候,1、2就有可能被重排序(3、4也有额可能),所以就导致:B线程执行4语句的时候,不一定能看到A线程1语句对共享变量的修改。(之前好像在哪见过重排序跟判断的关系?)
旧模型性质:volatile的写、读没有锁的释放、获取所具有的内存语义(对某段的锁定?)。
与锁的比较:volatile只对单个变量的读写具有原子性,而锁的互斥执行特性可以确保对整个临界区代码的执行具有原子性。
锁的内存语义
锁的作用:除了让临界区互斥执行外,还可以让释放锁的线程向获取同一个锁的线程发送消息。
happens-before:
writer线程释放锁之前所有的可见共享变量,在B获得同一个锁之后,立即对B线程可见(就是说在获取锁之前B都不能操作a)。
锁的获取和释放的内存语义
释放:会把本地内存里的变量刷新到主内存中。通过主内存给其他的想要获取同一锁的线程发该线程已经修改了共享变量的消息。同volatile写。
获取:此时会让该线程的本地内存里面的变量无效,强迫临界区代码从主内存重新读取共享变量。
锁的内存语义的实现
还是屏障那一套吧。
借助ReentrantLock
,这个类的实现依赖于同步框架AbstractQueuedSynchronizer
。
AQS使用整形的volatile变量来维护同步状态。
这块的目的就是想说明是如何利用volatile的,讲解下底层的Lock指令,以及重排序的问题。
ReentrantLock分为公平锁和非公平锁:
公平锁:
- lock()的轨迹:
ReentrantLock:lock()
->FairSync:lock()
->AQS(int arg)
->ReentrantLock:tryAcqurie(int acquires)
。 - 最后一步加锁源码:
protected final boolean tryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); // 对volatile的原子读操作 if (c == 0) { // 如果为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); // 对volatile的写操作 return true; } return false; }
getState()
:返回同步状态的当前值。这个操作有volatile读的内存语义。
setState(int newState)
:设置同步状态的值,这个操作有volatile写的内存语义。
compareAndSetState(int expect, int update)
:确认并更新。
setExclusiveOwnerThread(Thread thread)
:设置当前独占访问的进程。
getExclusiveOwnerThread()
:返回最后一个被setExclusiveOwnerThread
设置的进程,如果没有就返回null
。 - unlock()的轨迹:
RenntrantLock:unlock()
->AQS:release(arg)
->Sync:tryRelease(int releases)
- 最后一步释放锁源码:
protected final boolean tryRelease(int releases) { // 这里应当变成0吗?答:变成0才能释放,重入的锁要释放多次 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; }
- 总结:公平锁在释放的时候最后写volatile,获取的时候首先读volatile。
非公平锁:
- lock()的轨迹:
ReentrantLock:lock()
->NonfairSync:lock()
->AQS:compareAndSetState(int expect, int update)
。 - 最后一步源码:
protected final boolean compareAndSetState(int expect, int update) { // See below for intrinsics setup to support this return unsafe.compareAndSwapInt(this, stateOffset, expect, update); }
- CAS:上面的最后一个方法简称CAS,CAS同时具有volatile读和写的内存语义,编译器不能对CAS和CAS前面和后面的任意内存操作重排序。
- 释放锁:跟上面一样。
在这里倒是直到CAS是个啥了。
lock前缀:
(和CAS相关)
- 确保对内存的读、改、写操作的原子执行。
- 禁止该指令,与之前和之后的读和写指令重排序。
- 把写缓冲区中的所有数据刷新到内存中。
公平锁与非公平锁对比:
- 释放都要写volatile
- 公平获取的时候,读volatile
- 非公平获取,使用CAS更新volatile,同时有volatile读和写的语义。
concurrent包的实现
Java线程间的通信有以下四种方式:
- A线程写volatile变量,随后B线程读这个volatile变量
- A线程写volatile变量,随后B线程用CAS更新这个volatile变量
- A线程用CAS更新一个volatile变量,随后B线程用CAS更新这个volatile变量
- A线程用CAS更新一个volatile变量,随后B线程读这个volatile变量
concurrent包的一种通用化实现模式:
- 先声明共享变量为volatile。
- 使用CAS的原子条件更新来实现线程之间的同步。
- 同时,配合volatile的读写和CAS具有的volatile读写的内存语义来实现线程间的通信。
final域的内存语义
举例代码:
普通变量是用来举反例的。
final域的重排序遵守两个规则:
- 在构造函数中写final域,和后面的把这个构造对象引用赋值给另一个引用变量,这两个操作不能重排序。(先写final域,如果引用在前面,那这个引用对象没有final域,那就不对了)
- 初次读一个包含final域的对象的引用,和随后初次读其中的final域,这两个操作不能重排序。(先读的时候没引用,就不知道读谁的,也不行)
写final域的重排序规则
规则为禁止把final的写重排序到构造函数外边,包含两个方面:
- 在还没写的时候,禁止编译器把final域的写重排序到编译器外边。(很合理,搞到外边就丢了)
- 在final域的写之后,在构造器return之前,插入一个StoreStore屏障,这个作用也是禁止把final域的写重排序到外面。(为什么两个位置都有可能重排序?)
上面的代码的普通与并没有保障, 就可能出现如下的错误,因为A的重排序,B无法读取正确初始化的普通变量(此时先假设读final域是遵守规则的):
读final域的重排序规则
规则:初次读取对象引用和初次读取该对象包含的final域不能乱。
反例:
final域为引用类型
规则:在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外的把被构造的引用赋值给一个引用变量,不能重排序(还是先初始化,后赋值的意思)。
后面的没看
happens-before
JMM的设计
核心目标:一方面,为程序员提供足够的内存可见性保证;另一方面,对编译器和处理器的限制要尽可能地轻松。
要求:不能改变执行结果。
happens-before的好处:不仅简单易懂,也向程序员提供了足够强的内存可见性保证。
happens-before的作用:指定两个操作间的执行顺序。
happens-before关系的定义:
- 如果A happens-before B,则A的执行结果对B可见,且A的执行顺序排在B之前。
- 如果重排序后与按 happens-before 结果相同,那么这种重排序也不非法。
happens-before规则(No see)
双重检查锁定与延迟初始化
延迟初始化目的:降低初始化类和创建对象的开销。
双重检查锁定的错误
目的:推迟一些高开销的对象初始化操作,并且只有在使用这些对象的时候才进行初始化。
普通代码:存在问题
双重检查锁代码:同样存在问题
错误原因:
- 核心还是重排序的问题。
- 初始化过程可以分为三个小部分:
- 分配对象的内存空间:
memory = allocate();
- 初始化对象:
ctorInstance(memory);
- 设置instance指向刚分配的内存地址:
instance = memory;
- 分配对象的内存空间:
- 上面的2和3可能重排序,但是不会影响单线程的执行结果。
- 但是多线程的话,可能就会导致
instance
指针提前通过了null
判断,而此时后面的真正初始化部分还没完成,拿着一个未完成的指针return
,就报错了,时序表如下图:
基于volatile的解决方案
思路:本质上是禁止2和3的重排序。
方法:将instance
声明为volatile
型,(不过为什么这个会抑制重排序?)
代码:
时序图: