并发编程的三个重要特性
-
原子性
原子性指在一次的操作或多次的操作中,要么所有操作全部执行且不会受到任何因素的干扰而中断,要么所有操作全部不执行
-
可见性
当一个线程对共享变量进行修改,另外的线程可以立刻看到修改后的新值。
-
有序性
有序性是指程序代码在被执行过程中的先后顺序。Java编译器以及运行期的优化,导致代码的执行顺序未必就是在编写代码时的顺序。
关于volatile下面有必要说一下内存与CPU相关的知识
CPU Cache模型
CPU的处理速度和内存访问速度之间差距很大,通过直连内存的方式会导致CPU资源受到限制,为了降低CPU整体的吞吐量,可在CPU和内存之间增加缓存的设计,目前缓存的数量可增加到3级,最靠近CPU的缓存称为L1,依次是L2,L3和主内存。CPU缓存模型如下:
Cache的出现为了解决CPU直接访问内存效率低下的问题,CPU将运算所需的数据从主内存复制一份到CPU Cache中,CPU在计算时就可以直接对CPU Cache中的数据进行读取和写入,运算结束后,再将CPU Cache中的最新数据刷新回主内存中。
CPU缓存一致性问题
缓存的出现极大提高CPU的吞吐能力,但也引入了缓存不一致的问题,为了解决缓存不一致性的问题,通常主流的解决方法有两种:
-
通过总线加锁的方式
-
通过缓存一致性协议
缓存一致性协议的大致思想:当CPU在操作Cache中的数据时,如果发现该变量是一个共享变量,在其他CPU Cache中存在一个副本,当进行如下操作时:
- 读取操作:不做任何处理,只将Cache中的数据读取到寄存器中
- 写入操作:发出信号通知其他CPU将该变量的Cache line置为无效状态,其他CPU进行该变量读取时,必须在主存中再次获取。
关于volatile关键字
为了确保共享变量能够准确和一致性的更新,线程应该确保通过排他锁单独获取这个变量。Java提供了volatile关键字,如果一个字段被声明为volatile,多线程内存模型确保所有线程看到这个变量的值是一致的。
JMM内存模型
java内存模型决定了一个线程对共享变量的写入何时对其他线程可见,java内存模型定义了线程和内存之间的抽象关系
- 共享变量存储于主内存中,每个线程都可以访问。
- 每个线程都有私有的工作内存或称为本地内存。
- 工作内存只存储该线程对共享变量的副本
- 线程不能直接操作主内存,只有先操作工作内存后才能写入主内存。
- 工作内存和Java内存模型一样也是一个抽象的概念,其实并不存在,它涵盖了缓存、寄存器、编译器优化以及硬件等。
在前面所说的并发编程的三大特性,JMM如何保证这三大特性
-
JMM与原子性
先介绍几种简单的赋值操作
(1) x = 1
(2) y = x
(3) y++
(4) z = z+1
在上面的四类赋值操作中,只有第一种赋值操作具有原子性,其余均不具备原子性。可以得出以下结论
-
多个原子性的操作在一起就不再是原子性操作
-
简单的读取与赋值是原子性,将一个变量赋值给另外一个变量的操作就不具备原子性
-
JMM只保证基本读取和赋值的原子性操作,其余的均不能保证,如果要使某些代码段具备原子性,需要使用关键字synchronized,或JUC中的lock。如果想要使得int等类型自增操作具备原子性封装类型atomic.*
最后volatile关键字不具备保证原子性的语义
在Java中可以通过锁和循环CAS的方式来实现原子操作
-
使用循环**
CAS
**实现原子性操作JVM中的CAS操作利用处理器提供的指令**
CMPCHG
**实现,自旋CAS实现的基本思路是循环进行CAS操作直到成功为止。 -
CAS实现原子类操作的三大问题
-
ABA问题
因为CAS需要在操作值的时候,检查值有没有变化,如果没有发生变化则更新,但是如果一个值原来是A,变成了B,又变成了A,那么使用CAS进行检查的时候会发现它的值没有发生变化,但是实际发生了变化,ABA问题的解决思路就是使用版本号。在变量前面追加版本号,每次变量更新的时候把版本号加1,JDK中通过使用原子类**
AtomicStampedReference
**解决ABA问题 -
循环时间长开销大
自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销。如果JVM能支持处理器提供的**
pause
**指令,那么效率在一定程度上提升 -
只能保证一个共享变量的原子操作
把多个共享变量合并成一个共享变量来操作。
i = 2 j = a 合并 => ij = 2a
然后CAS来操作ij,JDK提供了AtomicReference类来保证引用对象之间的原子性,就可以把多个变量放入一个对象里来进行CAS操作
使用锁机制实现原子性操作
- 锁机制保证了只有获得锁的线程才能够操作锁定内存的区域。JVM内部实现了很多锁机制,有偏向锁、轻量锁、互斥锁。JVM实现锁的方式都用了循环CAS,即当一个线程进入同步块的时候使用CAS的方式来获取锁,当它退出同步块的时候使用循环CAS释放锁。
-
-
-
JMM与可见性
Java通过三种方式来保证可见性
-
使用关键字volatile,当一个变量被volatile关键字修饰时,对于共享资源的读操作会直接在主内存中进行(也可能会缓存到工作内存中,当其他线程对该共享资源进行修改,则会导致当前线程在工作内存中的共享资源失效,必须要在主内存中再次获取),对于共享资源的写操作当然是先要修改工作内存,但是修改结束后会立刻将其刷新进主内存中。
-
通过synchronized关键字能够保证可见性,synchronized关键字能够保证同一时刻只有一个线程获得锁,然后执行同步方法,并且还会确保在锁释放之前,会将对变量的修改刷新到主内存中。
-
通过JUC提供的显式锁Lock也能保证可见性,Lock的lock方法能够保证在同一时刻只有一个线程获得锁然后执行同步方法,并且会确保在锁释放(Lock的unlock方法)之前会将对变量的修改刷新到主内存中。
volatile关键字具有保证可见性的语义
-
-
JMM与有序性
在JMM中允许编译器和处理器对指令进行重排序,在当线程下并不会引起什么问题。在多线程下,重排序会影响到程序的正确运行。
Java提供了三种保证有序性的方式
- 使用volatile关键字来保证有序性。
- 使用synchronized关键字来保证有序性。
- 使用显式锁Lock来保证有序性
在JMM中具备一些自带的有序性规则,不需要任何同步手段就能够保证有序性,这规则称之为Happens-before原则。
重排序是什么
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
三种类型的重排序
-
编译器优化重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
-
指令级并行的重排序
现代处理器采用指令级并行技术来将多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
-
内存系统的重排序
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作来起来可能是在乱序执行
Happens-Before规则
- 程序次序规则:在一个线程内,代码按照编写时的次序执行,编写在后面的操作发生于编写在前面的操作之后。虚拟机还是可能会对程序代码的指令进行重排序,只要确保在一个线程内最终的结果和代码顺序执行的结果一致即可。
- 锁定规则:一个unlock操作要先行发生于对同一个锁的lock操作
- volatile变量规则:对一个变量的写操作要早于对这个变量的读操
- 传递规则:如果操作A先于操作B,B操作先于C操作,则A操作一定先于C操作,说明Happens-Before
- 线程启动规则:Thread对象的start()方法先行发生于线程的任何动作,只有start后的线程才能真正运行,否则Thread只是一个对象。
- 线程中断规则:对线程执行interrupt()方法,肯定要优先于捕获到中断信号。
- 线程的终结规则:线程中断所有的操作都要先行发生于线程的终止检测,线程的任务执行,逻辑单元执行肯定要发生于线程死亡之前。
- 对象的终结规则:一个对象初始化的完成先行发生于finalize()方法之前。
volatile关键字具有保证顺序性的语义
volatile写读的内存语义
-
volatile写的内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
-
volatile读的内存语义
当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量
volatile写读的内存语义总结:
- 线程A写一个volatile变量,实质上是线程A向接下来将要读这个volatile变量的某个线程发出消息
- 线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(在写这个volatile变量之前对共享变量所做修改的)消息
- 线程A写一个volatile变量,随后线程B读这个volatile变量,过程实质上是线程A通过主内存向线程B发送消息
volatile关键字语义
被volatile修饰的实例变量或类变量具备两层语义:
- 保证不同线程之间对共享变量操作时的可见性,也就是说当一个线程修改volatile修饰变量,另外一个线程立即可看到新的值。
- 禁止对指令进行重排序操作。
volatile的原理和实现机制
volatile关键字可以保证可见性以及顺序性,是如何做到的?
观察OpenJDK源码就知道,被volatile修饰的变量存在于一个"lock;"前缀
Lock前缀指令在多核处理器下会引发两件事
- 将当前处理器缓存行的数据写回到系统内存
- 写回内存操作会使在其他CPU里缓存了该内存地址的数据无效
“lock;”前缀实际上相当于一个内存屏障,该屏障会为指令提供几个保障:
- 确保指令重排序时不会将后面的代码排到内存屏障之前
- 确保指令重排序时不会将其前面的代码排到内存屏障之后
- 确保在执行到内存屏障修饰的指令时前面的代码全部执行完成
- 强制在线程工作内存中值的修改刷新至主内存中
- 如果是写操作,则会导致其他线程工作内存中的缓存数据失效
volatile和synchronized的区别
-
使用上的区别
- volatile关键字只能用于修饰实例变量和类变量,不能用于修饰方法以及方法参数和局部变量,常量等
- synchronized关键字不能用于对变量的修饰,只能用于修饰方法或语句块
- volatile修饰的变量可以为null,synchronized关键字同步语句块的monitor对象不能为null
-
对原子的保证
-
volatile无法保证原子性
-
由于synchronized是一种排他机制,因此被synchronized关键字修饰的同步代码块是无法被中途打断,因此其能够保证代码的原子性
-
-
对可见性的保证
- 俩者均可以保证共享资源在多线程间的可见性,但是实现机制完全不同
- synchronized借助JVM指令monitorentry和monitorexit对通过排他的方式使得同步代码块串行化,在monitorexit时所有共享资源都将会被刷新到主内存中。
- volatile使用机器指令“lock;”的方式迫使其他线程工作内存中的数据失效,必须到主内存中进行再次加载。
-
对有序性的保证
- volatile关键字禁止JVM编译器以及处理器对其进行重排序,所以它能够保证有序性
- 虽然synchronized关键字所修改的同步方法也可以保证顺序性,但是这种顺序性是以程序的串行化执行换来的,在synchronized关键字所修改的代码块中代码指令也会发生指令重排序的情况
-
其他
- volatile不会使线程陷入阻塞
- synchronized关键字会使线程进入阻塞状态