前言
并发在大型项目中的应用很广,可以有效提升应用性能,充分使用硬件资源。同时,也是Java面试的主要内容。
重排序
为了充分利用资源,减少中断,JVM引入了重排序的概念,即:
- 编译器优化重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序;
- 处理器指令级并行重排序,现代处理器采用了指令级并行技术将多条指令重叠执行,对于不存在数据依赖性的指令,处理器可以改变其执行顺序;
- 内存系统的重排序,由于处理器使用缓存和读/写缓冲区,这使加载和存储操作看上去可能是乱序执行的。
重排序可以提升程序的性能,但是,只能保证串行语句的一致性,不能保证多线程间语义的一致性。比如,指令重排序会导致多线程操作之间的不可见性,多线程下的重排序还会导致数据竞争问题,使有读写顺序的操作重排,改变了原来的意义。
note*:数据竞争:不同线程间存在读写顺序的操作,没有通过同步排序,导致执行结果与预期不符,则成为发生了数据竞争。
happens-before
happens-before规则,是对操作执行结果的一种保证,常规情况下,也是对操作顺序的一种保证。
happens-before 关系的定义:
- 如果一个操作 happens-before 另一个操作,那么第一个操作的执行结果就会对第二个操作可见;
- 两个操作之间如果存在 happens-before 关系,并不意味着 Java 平台的具体实现就必须按照 happens-before 关系指定的顺序来执行。如果重排序之后的执行结果,与按照 happens-before 关系来执行的结果一直,那么 JMM 也允许这样的重排序。
在 Java 中,对于happens-before 关系,有以下规定:
- 程序顺序规则:一个线程中的每一个操作,happens-before于该线程中的任意后续操作
- 监视器锁规则:对一个锁的解锁, happens-before于随后对这个锁的加锁
- volatile变量规则:对一个 volatile 域的写,happens-before与任意后续对这个volatile域的读
- 传递性:如果 A happens-before B,且 B happens-before C ,那么A happens-before C
- start规则:如果线程A执行操作 ThreadB.start()启动线程B,那么A线程的ThreadB.start()操作happens-before于线程B中的任意操作
- join规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作happens-before于线程A从 ThreadB.join()操作成功返回。
as-if-serial语义保证了单线程内重排序之后的执行结果和程序代码本身应该出现的结果一致,happens-before 关系保证了正确同步的多线程程序的执行结果不会被重排序改变。
顺序一致性与JMM模型
顺序一致性内存模型是一个理想状态下的理论参考模型,它为程序员提供了特别强的内存可见性保证,顺序一致性模型有两大特性:
- 一个线程中的所有操作必须按照程序的顺序来执行(也就是按照写的代码的顺序来执行);
- 不管程序是否同步,所有线程都只能看到一个单一的操作执行顺序。也就是说,在顺序一致性模型中,每个操作必须是原子性的,而且立刻对所有线程都是可见的。
保证操作对所有线程可见这一点会导致JVM的性能降低,JVM为了程序的性能,引入了重排序的概念,但是出现了线程安全的问题,JMM(Java Memory Model)的出现可以帮助解决这个问题。JMM的约定如下:
- 同步的多线程,也就是临界区内的操作,JMM允许重排序,但是不允许引入临界区外的代码,比如非同步的外部变量,这是同步锁的前提。
- 未同步的多线程,提供最小安全性:线程读取到的值,要么是之前某个线程写入的值,要么是默认值,不会无中生有,涉及共享变量,容易导致线程安全问题。
在外部,Java为我们提供了volatile,final,synchronized,Lock等来实现多线程下的同步;在内部,JMM通过happens-before规则,保证在性能优化的同时,操作的一致性和可见性,并在锁的帮助下,保证操作的原子性,从而保证正确同步位置的线程安全。
synchronized
synchronized是悲观锁的一种实现,是互斥锁。synchronized可以保证在同一时刻,只有一个线程可以执行某个方法或某个代码块,同时synchronized可以保证一个线程的变化可见(可见性),即可以代替volatile。
Java中每一个对象都可以作为锁,这是synchronized实现同步的基础:
- 普通同步方法(实例方法),锁是当前实例对象 ,进入同步代码前要获得当前实例的锁;
- 静态同步方法,锁是当前类的class对象 ,进入同步代码前要获得当前类对象的锁;
- 同步方法块,锁是括号里面的对象,对给定对象加锁,进入同步代码库前要获得给定对象的锁。
synchronized通过阻塞的方式保证了线程安全,并且每次加锁释放锁,都会降低性能,所以,有效控制synchronized操作的长度,选用更合适的同步机制是并发编程的关键。
volatile
volatile是轻量级的线程同步方式。JMM对多线程的happens-before规则保证了,volatile变量操作对线程们可见性,并能防止重排序,但是相比synchronized,volatile并不能保证操作的原子性。
volatile通过在读写操作前后添加内存屏障,完成了数据的及时可见性:
- LoadLoad屏障,两次读取间加入屏障;
- StoreStore屏障,两次写入间加入屏障;
- LoadStore屏障,读取写入间加入屏障;
- StoreLoad屏障,写入读取间加入屏障。
内存屏障保证了volatile及时将更新从缓存刷新到主存中,总线嗅探技术保证了其他CPU或线程能及时知道数据的修改。其他处理器通过嗅探总线上传播过来的数据监测自己缓存的值是否过期了,如果过期了,就将其置为无效,而当处理器对这个数据进行修改时,会重新从内存中把数据读取到缓存中进行处理。
在这种情况下,不同的CPU之间就可以感知其他CPU对变量的修改,并重新从内存中加载更新后的值,因此可以解决可见性问题。
volatile使用场景:
- 纯赋值操作,不适合++这种底层多指令的操作;
- 触发器,通过volatile将volatile变量操作之前的共享变量操作刷新到共享内存。
note*,在32位的JVM下,long、double的读写操作不是原子性的,需要通过volatile修饰才能保证操作的原子性,从JDK5开始,保证了操作的原子性。
CAS
CAS是一种典型的乐观锁。CAS严格来说不是锁,只是逻辑上的一种概念。乐观锁的主要步骤分为:冲突检测和数据更新。
CAS(Compare And Swap),即比较并交换,是解决多线程并行情况下使用锁造成性能损耗的一种机制。CAS操作包含三个操作数——内存位置(V)、预期原值(A)和新值(B)。如果内存位置的值与预期原值相匹配,那么处理器会自动将该位置值更新为新值。否则,处理器不做任何操作。无论哪种情况,它都会在CAS指令之前返回该位置的值。CAS有效地说明了“我认为位置V应该包含值A;如果包含该值,则将B放到这个位置;否则,不要更改该位置,只告诉我这个位置现在的值即可”。这其实和乐观锁的冲突检查+数据更新的原理是一样的。
可以简单理解为,如果将要更新的变量的值和线程从该位置取出值相同,则更新该值,对于比较失败的,重新进行计算,这样就可以防止多个线程同时对变量做出修改,保证了操作的原子性。
CAS存在着3大问题:
- ABA问题:如果目标值,经历A->B->A的变化,CAS并不能发现,从而判定错误的问题。可以通过版本号等方式,在变量前面追加上版本号,每次变量更新的时候把版本号加一,那么A-B-A 就会变成1A-2B-3A;
- 循环时间长开销大:自旋CAS如果长时间不成功,会给CPU带来非常大的执行开销,如果JVM能支持处理器提供的pause指令那么效率会有一定的提升,pause指令有两个作用,第一它可以延迟流水线执行指令(de-pipeline),使CPU不会消耗过多的执行资源,延迟的时间取决于具体实现的版本,在一些处理器上延迟时间是零。第二它可以避免在退出循环的时候因内存顺序冲突(memory order violation)而引起CPU流水线被清空(CPU pipeline flush),从而提高CPU的执行效率;
- 只能保证一个共享变量的原子操作:从Java1.5开始JDK提供了AtomicReference类来保证引用对象之间的原子性,可以把多个变量放在一个对象里来进行CAS操作。
note*:CAS的主要考点是ABA问题。
参考文章
【并发编程】【JDK源码】CAS与synchronized
[Java 并发]为什么会有重排序?和 happens-before 有啥关系
CAS操作确保原子性