《并发编程的艺术》阅读笔记第三章,先从底层讲起。
基础不牢,地动山摇。
线程间通信和线程间同步
线程通信机制:
Java的并发采用的是共享内存模型。
java中所有的实例域、静态域和数组元素都存储在堆内存中;局部变量、方法定义参数、异常处理器参数不会在线程之间共享
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程读/写共享变量的副本。
本地内存是JMM的一个抽象概念,并不真实存在,涵盖了缓存、写缓冲区、寄存器以及其他的硬件和编译器优化
线程A与线程B之间要通信必须经过两个步骤:
JMM通过控制主内存与每个线程的本地内存之间的交互来为java程序员提供内存可见性保证
java最终执行的指令序列:
源码--------》编译器优化重排序-------》指令级并行重排序----------》内存系统重排序---------》最终的指令序列
第一个属于==编译器重排序==,后面两个属于==处理器的重排序==,JMM通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证
对于编译器,JMM的编译器会禁止特定类型的编译器重排序。==对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定的内存屏障指令==,从而禁止特定类型的处理器重排序。
load:读缓冲区
store:写缓冲区
四种内存屏障:
LoadLoad Barriers
:确保load1的数据先于laod2及后续所有load指令进行装载StoreStore Barriers
:确保store1的数据对其他处理器的可见性先于store2及后续所有存储指令LoadStore Barriers
:确保load装载先于store的存储刷新到内存StoreLoad Barriers
:该屏障前的指令全部完成之后才会执行后面的指令(开销大)程序员必须遵循的编程原则
==JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。==
其中一些顺序规则(针对程序员):
happens-before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前,,并不意味着前一个操作必须要在后一个操作之前执行!
前一个要求对后一个操纵可见不是意味着前一个操作必须在后一个操作之前执行?
JMM要求如果A happens-before B那么首先必须保证执行结果(A的执行结果对B **不一定可见** )必须和逻辑中的happens-before相同,其次,A的排序顺序在B之前(这里是指重排序之前的顺序,后期允许通过重排序调整A和B的操作顺序),但是最终结果必须和逻辑上的保持一致,因此从表面上看是按序执行了
数据依赖性
在单线程程序中,对存在控制依赖的操作重排序不会改变执行结果;但在多线程程序中,对存在控制依赖的操作重排序,可能会改变程序的执行结果。
详见30页的例子
as-if-serial
不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义
如果程序是正确同步的,程序的执行将具有顺序一致性(Sequentially Consistent)——即程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同
未同步程序在顺序一致性模型中给虽然整体执行顺序是无序的但是所有线程都只能看见一个一致的的整体执行顺序。 之所以能得到这个保证是因为顺序一致性内存模型中的每个操作必须立即对任意线程可见
JMM就没有这个保证。因为只有当前线程把本地内存中写过的数据刷新到主内存之后,这个写操作才能对其他线程可见。
JMM的处理逻辑:临界区内的代码可以重排序;JMM会在退出临界区和进入临界区这两个关键时间点做一些特别处理,使得线程在这两个时间点具有与顺序一致性模型相同的内存视图
JMM的具体实现的基本方针:在不改变(正确同步的)程序执行结果的前提下,尽可能地为编译器和处理器的优化打开方便之门
顺序一致性内存模型和JMM区别
处理器总线工作机制
在计算机中,数据通过总线在处理器和内存之间传递。每次处理器和内存之间的数据传递都是通过一系列步骤来完成的
总线事务包括读事务(Read Transaction)和写事务(Write Transaction)。读事务从内存传送数据到处理器,写事务从处理器传送数据到内存,每个事务会读/写内存中一个或多个物理上连续的字
注意,在JSR-133之前的旧内存模型中,一个64位long/double型变量的读/写操作可以被拆分为两个32位的读/写操作来执行。从JSR-133内存模型开始(即从JDK5开始),仅仅只允许把一个64位long/double型变量的写操作拆分为两个32位的写操作来执行,任意的读操作在JSR-133中都必须具有原子性(即任意读操作必须要在单个读事务中执行)。
volatile变量特性:
可见性:对一个volatile变量的读,总是能看到(任意线程)对这个变量最后的写入
原子性:对任意单个volatile变量的读、写具有原子性(包括long、double),但类似volatile++
这种复合操作不具有原子性。
volatile写-读的内存语义:
写:当写一个volatile变量时,JMM会把线程对应的==本地内存中的共享变量值刷新到主内存==
读:当读一个volatile变量时,JMM会把该线程==对应的本地内存置为无效==。线程接下来从主内存中读取共享变量
volatile读的时候必须保证主内存中的数据不会更改,因此volatile读如果是第一个操作怎不能实现重排序
volatile写的时候必须保证之前的数据能正常写入主内存,因此volatile写如果是第二个操作的话不能实现重排序
普通读写是在本地内存,但是有概率会写入主内存,因此具有随机性
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
JMM内存屏障插入策略(保守!):
volatile写操作
前面插入StoreStore
屏障volatile写操作
后面插入StoreLoad
屏障volatile读操作
后面插入一个LoadLoad
、一个LoadStore
为什么volatile写操作之前不用插入loadstore来避免普通读和volatile写之间的重排序?
猜测:内存屏障之间存在包含关系,比如storeload就可以实现其他三个所有功能
volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升
实际情况编译器在生成字节码的时候可以优化选择是否添加内存屏障,但是注意一般最后一个storelaod不能省略,因为无法判断return后是否还会有下一个volatile读/写出现
不同的处理器平台也会对内存屏障做出优化
在功能上,锁比volatile更强大;在可伸缩性和执行性能上,volatile更有优势
锁的释放和获取的内存语义(和volatile一样)
锁内存语义的实现:
ReentrantLock
的实现依赖于Java同步器框架AbstractQueuedSynchronizer
(本文简称之为AQS
)。AQS
使用一个整型的volatile
变量(命名为state
)来维护同步状态,这个volatile
变量是ReentrantLock
内存语义实现的关键。
ReentrantLock锁分为公平锁和非公平锁
公平锁
现在tryAcqyuire方法有点变化,在查看是不是第一个获取锁的对象处添加了hasQueuedPredecessors()方法,一个用来查询是否有线程比现在线程等待时间更长
非公平锁
调用CAS:如果当前状态值等于预期值,则以原子方式将同步状态设置为给定的更新值。
公平锁和非公平锁语义总结:
可以看出:锁释放-获取的内存语义的实现至少有下面两种方式
CAS是如何同时具有volatile读和volatile写的内存语义的?
多处理器环境,会为cmpxchg指令加上lock前缀,单处理器不用加(单处理器会维护自身的顺序一致性)
上面2、3两点具有的内存屏障效果,足以同时实现volatile读和volatile写的内存语义
volatile
==CAS
的原子条件更新来实现线程之间的同步==volatile
的读、写和CAS
所具有的volatile
读和写的内存语义来实现线程之间的通信==(1)写:在==构造函数内对==一个final域的写入,与==随后把这个被构造对象的引用赋值给一个引用变量==,这两个操作不能重排序
(2)读:初次读一个包含final域的对象的引用,与==随后初次读这个final域==,这两个操作不能重排序。
==禁止把final域的写重排序到构造函数之外==包含两方面:
1、编译器: JMM禁止编译器把final域的写重排序到构造函数之外
2、处理器: 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障禁止处理器把final域的写重排序到构造函数之外
上述规则可以确保:
在对象引用为任意线程可见之前,对象的final域已经被正确初始化过了,而普通域不具有这个保障。
处理器:在一个线程中,初次读对象引用与初次读该对象所包含的final域,JMM禁止处理器重排序这两个操作
编译器:编译器会在读final域操作的前面插入一个LoadLoad
屏障·
上述重排序规则可以确保:在读一个对象的final域之前,一定会先读包含这个final域的对象的引用(针对少数处理器存在间接依赖关系的操作做重排序)
对于引用类型,写final域的重排序规则增加下面的约束:
在构造函数内对一个final引用对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作不能重排序。
对于存在数据竞争的线程无法保证可见性
在构造函数返回前,被构造对象的引用不能为其他线程所见,因为此时的final域可能还没有初始化 (构造函数内可能发生重排序)。
==(在引用变量为任意线程和可见之前,引用变量所指向的对象的final域必须要正确初始化,这是写final域的重排序规则)==
as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺序执行的。
happens-before关系给编写正确同步的多线程程序员创造了一个幻境:正确同步的多线程程序是按happens-before指定的顺序执行的。
这么做的目的:为了在不改变程序的执行结果的前提下,尽可能地提高程序执行的并行度。
完整hgappen-before规则
双重检查锁定
问题描述:(一般是单例模式)在上锁之前加一个非空判断,上锁后再次非空判断,实现双重检查锁定
原因:在构造实例时,对象引用指针的操作和初始化操作可能会被重排序,这就导致在if(instance==null)的时候认为对象已经创建,但这个时候还没有进行初始化
1.分配对象的内存空间
2.初始化对象
3.设置instance指向内存空间
4.初次访问对象
3和2可能会被重排序,导致1342这样的问题
一开始单例模式一旦出现并发,就可能出现初始化两个对象的问题
后来选择加锁,加锁能解决问题,但是出现严重的性能开销
后来就选择在加锁前加一层非空判断,这样就可以只在第一次初始化,后期不用加锁但
是正是由于添加了一层非空判断,导致多线程在进行这个判断的时候,**读取操作跳过了等待时间直接读取对象,但此时由于锁内空间的重排序,导致此时对象还没有初始化完成。**从而造成严重的后果
解决方式:
volatile。利用volatile的语义禁止重排序。在单例的懒汉模式中,必须给实例添加volatile修饰符
优势: ==除了可以对静态字段实现延迟初始化外,还可以对实例字段实现延迟初始化。==
基于类初始化的解决方案,使得线程访问之前就完成初始化
JVM在类的初始化阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在
执行类的初始化期间,JVM会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。
备注:类初始化的几种情况
1)T是一个类,而且一个T类型的实例被创建。
2)T是一个类,且T中声明的一个静态方法被调用。
3)T中声明的一个静态字段被赋值。
4)T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
5)T是一个顶级类(Top Level Class,见Java语言规范),而且一个断言语句嵌套在T内部被执行。
初始化阶段
第1阶段:通过在Class对象上同步(即获取Class对象的==初始化锁==),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。
==此时只有一个线程给能获得初始化锁==
第2阶段:线程A执行类的初始化,,同时线程B在初始化锁对应的condition
上等待
初始化instance过程(==可能发生重排序,但是对于其他线程不可见==)
第3阶段:线程A设置初始化完成标志,然后唤醒在condition中等待的所有线程。
第4阶段:线程B结束类的初始化处理。
线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放初始化锁;
线程B在第4段的B1获取==同一个初始化锁==,并在第4阶段的B4之后才开始访问这个类
第5阶段:线程C执行类的初始化的处理。
线程A在第2阶段的A1执行类的初始化,并在第3阶段的A4释放锁;``
线程C在第5阶段的C1获取==同一个锁==,并在在第5阶段的C4之后才开始访问这个类
常见 处理器内存模型
Total Store Ordering内存模型(TSO)
放松程序中==写-读==操作的顺序
Total Store Ordering内存模型(PSO)
继续放松程序中==写-写==操作的顺序
Relaxed Memory Order内存模型(RMO)
继续放松程序中==读-写==操作的顺序
PowerPC内存模型
继续放松==读-读==操作的顺序
所有处理器内存模型都允许写-读重排序,原因:它们都使用了写缓存区.
写缓存区可能导致写-读操作重排序
由于写缓存区仅对当前处理器可见,这个特性导致当前处理器可以比其他处理器先看到临时保存在自己写缓存区中的写。
同处理器内存模型一样,越是追求执行性能的语言,内存模型设计得会越弱。
按程序类型可分为
单线程程序:
不会出现内存可见性问题
正确同步的多线程程序
具有顺序一致性(程序的执行结果与该程序在顺序一致性内存模型中的执行结果相同)
未同步/未正确同步的多线程程序
JMM为它们提供了最小安全性保障:线程执行时读取到的值,要么是之前某个线程写入的值,要么是默认值(0、null、false)。
但最小安全性并不保证线程读取到的值,一定是某个线程写完后的值。==最小安全性保证线程读取到的值不会无中生有的冒出来,但并不保证线程读取到的值一定是正确的==。
JDK1.5之后对volatile的内存语义、final的内存语义做了增强