并发编程
volatile
当声明共享变量为volatile后,对这个变量的读/写将会很特别。JMM堆volatile专门定义了一些特殊的访问规则。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。
概述
- 轻量级synchronized
- 在使用恰当情况下,比synchronized的使用和执行成本更低。它不会引起线程的上下文切换与调度
- 保证共享变量的可见性
- 可见性:当一个线程修改一个共享变量的值,另外一个线程能读到这个修改的值
volatile的内存语义
可见性
volatile变量特性:
- 可见性:对一个volatile变量的读,总是能看到(任意线程)对这个volatile变量最后的写入。
- 原子性:对任意单个volatile变量的读/写具有原子性,但类似于volatile++这种复合操作不具有原子性。
理解volatile特性的一个好方法是把对volatile变量的单个读/写,看成是使用同一个锁对这些单个读/写操作做了同步。
示例代码:
1 | class VolatileFeaturesExample { |
假设有多个线程分别调用上面程序的3个方法,这个程序在语义上和下面程序等价。
1 | class VolatileFeaturesExample { |
禁止指令重排序优化
普通的变量只能保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量的赋值操作的顺序与程序代码中的执行顺序一致。
而volatile禁止了指令重排序优化
内存屏障:
对于volatile修饰的变量,赋值后会多执行一个lock addl操作,这个操作相当于一个内存屏障,指重排序时不能把后面的指令重排序到内存屏障前的位置。
指令重排序是CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理,但是如果指令间有依赖则不会重排。当进行volatile变量赋值,则会将缓存数据写入内存,即此时前面所有操作都已经执行完成,因此无法越过内存屏障。
JMM实现
为实现volatile,JMM限制编译器重排序与处理器重排序
volatile重排序规则表:
- 第三行最后一个单元格的意思是:在程序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
- 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
- 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
- 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能。为此,JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略。
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
适用性
volatile只保证了可见性,因此只适用于一下规则的运算场景
- 运算结果不依赖变量的当前值,或者能保证只有单一的线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
volatile写-读建立的happens-before关系
volatile对线程的内存可见性的影响比volatile自身的特性更为重要。JDK5开始,volatile变量的写-读可以实现线程间的通信。
从内存语义的角度来说
- volatile的写-读与锁的释放-获取有相同的内存效果。
- volatile写和锁的释放有相同的内存语义。
- volatile读与锁的获取有相同的内存语义。
1 | class VolatileExample { |
当线程A执行writer(),线程B执行reader(),则根据happens-before
- 根据程序次序规则,1 before 2,3 before 4
- 根据volatile规则,2 before 3
- 根据传递性规则,1 before 4
即这里A线程写一个volatile变量后,B线程读同一个volatile变量。
A线程在写volatile变量之前所有可见的共享变量,在B线程读同一个volatile变量后,将立即变得对B线程可见。
volatile写-读的内存语义
volatile写内存语义:当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存。
volatile读的内存语义:当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。
图为线程B读同一个volatile变量后,共享变量的状态示意图。
总结
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出了(其对共享变量所做修改的)消息。
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息。
- 线程A写一个volatile变量,随后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。
JSR-133为什么要增强volatile的内存语义
在JSR-133之前的旧Java内存模型中,虽然不允许volatile变量之间重排序,但旧的Java内存模型允许volatile变量与普通变量重排序。
为了提供一种比锁更轻量级的线程之间通信的机制,JSR-133专家组决定增强volatile的内存语义:严格限制编译器和处理器对volatile变量与普通变量的重排序,确保volatile的写-读和锁的释放-获取具有相同的内存语义。
synchronized
synchronized在编译后会在同步块前后分别形成monitorenter
与monitorexit
两个字节码指令。这两个字节码都需要一个reference
类型的参数来指明要锁定和解锁的对象,如果Java中synchronized明确指定了对象参数,即这个对象的reference,如果没有指定即根据修饰的是实例方法还是类方法,去取相应的对象实例或Class对象来作为锁对象。
原子变量与非阻塞同步机制
非阻塞算法用底层的原子机器指令代替锁来确保数据在并发访问中的一致性。与基于锁的方案相比,非阻塞算法在设计和实现上要复杂很多,但它们在可伸缩性和活跃性上有巨大的优势,由于非阻塞算法可以使多个线程在竞争相同的数据时不会发生阻塞,因此能够在粒度更细的层次上进行协调,并极大减少调度开销
在非阻塞算法中不存在死锁与其他活跃性问题,Java中可以使用原子变量类来构建高效的非阻塞算法,原子变量也可以用作一种更好的volatile类型变量
锁的劣势
当多个线程同时请求锁,此时JVM需要借助OS的功能,此时一些线程将被挂起并在稍后恢复运行,线程恢复执行时,还需等待其他线程执行完它们的时间片才能被调度执行。即存在很大的开销与较长时间的中断。
当一个线程在等待锁时不能做任何其他事情,让一个线程在持有锁情况下被延迟执行(缺页、调度延迟等),则所有需要锁的线程将无法执行。并且可能出现优先级反转问题。
与锁相比,volatile
是一种更轻量级的同步机制,但是虽然提供了可见性,但不能用于构建原子的复合操作。
硬件对并发的支持
独占锁是一种悲观的技术,而对于细粒度的操作,乐观锁更加高效,可以在不发生干扰的情况下完成更新操作,需要借助冲突检查机制来判断在更新过程中是否存在来自其他线程的干扰。
- 测试并设置 Test and Set
- 获取并增加 Fetch and Increment
- 交换 Swap
- 比较并交换 Compare and Swap CAS
- ABA问题,如果要解决ABA问题可以使用Atomic,但是可能互斥同步更高效一些。
- 加载链接/条件存储 Load-Linked/Store-Conditional LL/SC
非阻塞算法
如果在某种算法中,一个线程的失败或挂起不会导致其他线程也失败或挂起,即称为非阻塞算法。如果在算法的每个步骤张都存在某个线程能够执行下去,这种算法也被称为无锁算法。
在非阻塞算法中,多个线程竞争一个CAS,总会有一个线程在竞争中胜出并执行。非阻塞算法中通常不会出现死锁与优先级反转。但是可能会出现饥饿与活锁问题。
非阻塞的栈
创建非阻塞算法的关键在于,找出如何将原子修改的范围缩小到单个变量上,同时还要维护数据的一致性。
push
创建一个新节点,该节点的Next域指向当前栈顶- 使用CAS将节点放入栈顶,如果插入时栈顶节点没有变化,那么成功
- 否则根据栈当前状态重试更新
锁的内存语义
锁可以让临界区互斥执行。