JobPlus知识库 IT 软件开发 文章
并发二:JAVA内存模型

Java Memory Model

JAVA内存模型(Java Memory Model 简称JMM),定义了程序中各个共享变量的访问规则。

程序中的变量都存储在主内存中,而每个线程拥有自己的工作内存用来存放变量的拷贝,线程的读写操作是在各自的工作内存中进行的,操作的对象都是变量的拷贝,操作完毕后在刷新到主内存,JMM就是规定了工作内存和主内存之间变量访问的细节,通过保障原子性、有序性、可见性实现线程安全。

JVM Runtime Data Areas

JVM 运行时数据区(JVM Runtime Data Areas)定义了JVM运行期内存的管理划分。

JVM在运行时把内存划分成多个功能区,每个区域对应着不能的存储内容,生命周期,共享性质,GC策略等。可以看到,能被线程共享的是方法区和堆中的数据,也就是实例对象、数组和静态变量,这些共享数据受到JMM规范影响。而局部变量、方法参数、异常处理参数都在虚拟机栈中,这些数据为线程私有的,所以不受JMM规范影响。

原子性、可见性、有序性

原子性

数据库事务中也有原子性的定义,即一个操作是不可被中断的,要么全部成功,要么全部失败。

JMM中的原子操作是指一个操作不会被线程调度机制打断,一旦开始,就一直运行到结束,中间不会有任何线程切换(context switch)原子性可以保障读取到的某个属性的值是由一个线程写入的。 变量不会在同一时刻受到多个线程同时写入造成干扰。

如在32位的JVM中对64位long 或double 值的写操作是分成两次相邻的32位值写操作,在多线程的环境下,可能会有线程只读到了前32位,这种操作就是非原子性的,非原子性操作会受到多线程的干扰而产生结果混乱。

基本类型的单次读写操作是原子的,但是复合操作如,int i=0;i++,就是非原子性的。

JMM解决原子性的方式,volatile语义(保证变量单次操作的的原子性)、锁语义。

在共享内存模型中如果有一个线程对变量i进行了修改,在没有可见性保障的情况下,其他两个线程看到的i的值都是不确定的,变量i在数据争用的情况下不具备不可见性。

可见性是指一个线程对变量的值进行了修改,其他线程能够立即得知这个修改。可见性是保障多线程操作中数据一致性和结果正确性的基石。多线程环境䡋影响变量可见性的因素: 1)指令重排序 2)线程调度 3)工作内存和主内存没有及时刷新

JMM解决可见性的方式:fianl语义、volatile语义、锁语义。

有序性

现代CPU的计算速度远远高于内存的读写速度,CPU会采用高速缓存来抵消内存访问带来的延迟。甚至高速缓存也分成多级,最快的离CPU最近。但是速度还是远远低于CUP指令执行的速度,为了减少CACHE_WAIT,CPU会采用指令级并行重排序来提供执行效率,也可以叫做CPU乱序执行。

CUP的高速缓存与内存之间不是实时同步的,高速缓与高速缓间也不是实时同步,而是通过缓存一致性协议(MESI)将数据新到主内存,缓存和读写缓冲区之间也会通过指令重排序来优化数据的刷新。

JIT编译器也会在代码编译的时候对代码进行重新整理,最大限度的去优化代码的执行效率。

一段JAVA代码从执行到获得结果,其执行的顺序其实是经历了2个阶段三种重排序的优化:

as-if-serial语义

as-if-serial语义的意思指:所有的指令都可以为了优化而被重排序,但是必须保证最终执行的结果和重排序之前的结果是一致的,编译器和处理器都会保证单线程下的as-if-serial语义。主要遵守的规则是重排序不破坏数据的依赖关系,如下图,指令C依赖指令A和指令B,那么重排序只能在指令A和指令B之间发生。

数据依赖关系

as-if-serial保证了单线程环境下重排序之后程序执行结果的正确性,JVM在单线程的情况下会遵as-if-serial语义,无需担心重排序会干扰心内存可见。

happens-before原则

按照写代码的主观意愿,可能期望是要么指令1先执行,要么指令3先执行,指令1先执行就不应该看到到指令4写入的值,如果是指令3先执行,就不应该看到指令2写入的值。

如果编译器或者执行CPU进行了重排序,指令4在指令1前先执行了,指令2在指令3之前执行了,就会出现r2 == 2和r1 == 1这种有违直觉的结果。然而,从单个线程的角度,指令1和指令2重排序是遵循as-if-serial语义的,不会影响该线程获得正确的结果。但是,从多线程的角度看,编译器或者指令重排序影响到了代码原本想要表达语义。

这个示例中指令1和指令2之间没有依赖关系遵循as-if-serial语义重排序,对单线程执行结果的正确性没有影响,但是多线程环境下,如果thread1执行完指令1,thread2执行,那i的值会出现有背预期的情况,因为thread1中对共享变量a的修改,对thread2是不可见的。

基于数据依赖性的as-if-serial语义无法保证多线程环境下,重排序之后程序执行结果的正确性。JMM中happens-before原则就是用来保障多线程环境下变量可见性的。

先行发生原则(happens-before)是JMM用来规定两个操作之间的偏序关系,这两个操作是可以跨线程的。happens-before中确定了8条规则,如果如果两个操作之间的关系可以从下列规则推导出来说明两个操作是有序的。

happens-before并不限定指令重排序,如果如果重排序之后的执行结果与按happens-before关系来执行的结果一致,那么JVM允许这种重排序。happens-before原则保证了前后两个操作间不会被重排序且后者对前者的内存是可见的。

happens-before八条规则:

1、程序次序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作(一个线程内保证语义的串行性)。

2、锁定规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。

3、volatile变量规则:volatile变量的写操作happens-before域后面对这个变量的读操作。

4、传递规则:如果A happens-before B且Bhappens-before C,那么A happens-before C。

5、线程启动规则:Thread对象的start()方法happens-before于此线程的每个一动作。

6、线程中断规则:对线程interrupt()方法的调用happens-before于被中断线程的代码检测到中断事件的发生。

7、线程终结规则:线程中所有的操作都happens-before于线程的终止。

8、对象终结规则:一个对象的初始化完成happens-before于他的finalize()方法的开始。

final volatile synchronized

内存屏障指令

内存屏障(memory barriers)是一组处理器指令,对内存操作的顺序进行限制。JMM内存语义底层是通过memory barriers实现的。

1、LoadLoad Barriers:示意指令Load1; LoadLoad; Load2,保证Load1先读取完毕,再执行Load2及后续读取指令。

2、StoreStore Barriers:示意指令Store1; StoreStore; Store2,保证Store1先刷新到内存(对其他处理器可见),再执行Store2及后续写入操作。

3、LoadStore Barriers:示意指令Load1; LoadStore; Store2,保证Load1先读取完毕,再执行Load2及后续写入指令刷新到内存。

4、StoreLoad Barriers:示意指令Store1; StoreLoad; Load2,保证Store1先刷新到内存(对其他处理器可见),再执行Load2及后续读取指令。在大多是处理器中,会先执行完屏障前的所有读写指令,再执行屏障后的内存访问指令。

final语义

this逃逸

在构造函数返回之前,其他线程就持有了该对象的引用,这种情况就叫this逃逸。

步骤1和步骤2之前没有重排序的限制规定,因此这两个操作是可以重排序的。如果出现了重排序那么执行”read”方法的线程就有可能读到final变量初始化之前的值,造成final变量未正确初始化。

另外在构造函数中注册事件监听,在构造函数中启动新线程都有可能会引发”this逃逸”。

final与重排序

JMM对final的重排序做了特殊的规定,并且在JSR-133做了增强,编译器和处理器在对final域进行重排序时候,都要遵循如下规则:

1、在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

禁止编译器重排序:编译器不能将final域的写操作重排序到构造函数之外;

禁止处理器重排序:编译器会在final与写之后,构造函数return之前插入个StoreStore屏障,用来禁止处理器将final域的写操作重排序到构造函数之外;

2、初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能重排序。

读对象引用,读对象的final域 这两个操作不能重排序。

3、在构造函数内对一个final引用的对象的成员域的写入,与随后在构造函数外把这个被构造对象的引用赋值给一个引用变量,这两个操作之间不能重排序。

final与可见性

final修饰的变量作为不可变变量,只要对象是正确构造的(没有this逃逸发生),不需要任何同步措施就可以保证任何线程都能读到变量在构造函数中被初始化之后的值。

volatile语义

volatile与原子性

volatile是否保障原子性是个有争议的话题,32位的JDK中volatile修饰后的long和double也具有原子性。但是volatile int i=0;i++;就不具备原子性,因此可以理解为对单次volatile变量的读写操作具有原子性,复合操作(如i++)不具有原子性。

volatile与重排序

为了实现volatile的语义,JMM限制了volatile的编译器重排序和处理器重排序,volatile变量编译器重排序规则

volatile变量编译器重排序规则

编译器会在volatile写操作前后插入StoreStore Barriers和StoreLoad Barriers,volatile读操作前后插入LoadLoad Barriers和LoadStore Barriers。通过插入内存屏障来限制处理器对volatile进行重排序。

volatile与可见性

通过volatile读写操作前后所插入的memory barriers就能够看出,volatile变量能够保证可见性。

synchronized语义

synchronized又被称为内置锁,线程进入同步代码块时会获得该锁,退出代码块自动释放锁,锁可以让线程在临界区互斥执行,呈现串行语义。所以synchronized可以解决任何并发情况下线程安全的问题,包含原子性,可见性,有序性。java中每一个对象都可以作为锁,根据synchronized修饰的目标不同锁定的对象有如下3中情况:

1、修饰普通方法,锁当前对象

2、修饰静态方法,锁当前类的class对象

3、修饰代码块,锁括号中的对象

对象头 

每一个对象都包含一个对象头,锁信息就保存在对象头MarkWord字段中。

内置锁的状态共有四种,分别是无锁状态、偏向锁状态、轻量级锁状态和重量级锁状态。锁状态的变化,会使MarkWord中存储的内容也跟着变化。

synchronized实现原理

JVM中内置锁的实现有偏向锁、轻量级锁、重量级锁。

偏向锁,是为了满足一个线程在无竞争条件下访问同步代码块而设置的锁,只需要在替换ThreadID时进行一次CAS操作。

偏向锁只有当其他线程竞争锁时,持有线程才释放锁。释放发生在全局安全点上(此刻无其他字节码执行),暂停持有线程,根据锁对象是否被锁定,将锁状态恢复至无锁状态,或膨胀到轻量级锁。

轻量级锁,是为了满足多线程交替执行步代码块而设置的锁。采用CAS指令和自旋来减少系统级别的互斥操作所带来的性能开销。如果有多个线程同时竞争一个锁,则会膨胀到重量级锁。

轻量级锁的释放也是采用CAS方式进行的,首先会尝试将DisplacedMark Word替换回当前对象的Mark Word,替换成功则轻量级锁释放成功,否则,说明其他线程在尝试获取轻量级锁则膨胀只重量级锁。

重量级锁,利用操作系统的MutexLock互斥锁机制实现,通过对象内的监视器(monitor)进行加锁操作。监视器monitor有时也被称作管程,JVM规范规定,每个对象和类在逻辑上都是和一个监视器关联。操作系统底层的MutexLock,使用操作系统的内核进行调度使线程在临界区互斥,线程状态切换成本很高,所以称为重量级锁。

进入同步代码块时,JVM底层使用指令entermonitor用来获取monitor的所有权,进行加锁。离开代码块时,指令leavemonitor用来释放对monitor占用,即解锁。

进入同步方法时,JVM会在方法的常量池中设置ACC_SYNCHRONIZED访问标志,并获取monitor对象,执行完毕会释放掉monitor对象,在此期间其他任何线程都无权获取这个monitor对象,处于阻塞状态。

JDK6之前内置锁synchronized只有MutexLock互斥锁机制实现,因其资源开销很大,对性能有一定影响被称为重量级锁,synchronized也就等同于重量级锁。之后的JDK对锁进行了一系列的优化,优化措施之一就是引入了基于CAS指令(CASCompare-And-Swap,CPU原子指令,汇编指令CMPXCHG。是一种乐观加锁策略,后续在J.U.C中总结)的偏向锁和轻量级锁。

运行过程中随着锁的竞争,偏向锁可以膨胀到轻量级锁,轻量级锁可以膨胀到重量级锁,膨胀的过程是单向的,即重量级锁不会降级到轻量级锁,轻量级锁也不会降级到偏向锁

synchronized 重入性

重入锁指一个线程可以多次获取自己已经持有的锁,比如一个线程在自己还没有释放锁的时候,再次进入临界区操作,如果锁不可重入,那么线程将会被自己锁死。

内置锁synchronized是一个可重入锁,即一个持有类/对象可重入锁的线程在调用本类/对象中其他synchronized方法/块或父类中的synchronized方法/块时,都不会被阻塞或者锁死。

可重入性是通过对象监视器中递归计数器(_recursion)实现的,初始值为0,表示没有线程持有锁,当有线程竞争到该锁,计数器为1,并阻塞其他线程竞争。如果持有线程再次竞争该锁时,计数器加1记录重入的次数。线程退出时,计数器会递减,值为0时释放锁。

内置锁重入计数器objectMonitor.cpp

synchronized 公平性

公平锁是指线程获得锁的顺序按照先进先出的原则进行的,即先发起锁站在的先排队的先得。非公平锁指每个线程都先要竞争锁,不管排队先后,所以后到的线程有可能无需进入等待队列直接竞争到锁。

线程饥饿是指CUP运行时间片长时间被其他线程占据而导致线程得不到CUP运行时间。公平锁不会造成线程饥饿,如果持有锁的时间较长或请求锁的平均间隔时间较长,可以考虑使用公平锁。

但是非公平锁的吞吐率是公平锁的好几倍。假如A线程持有锁,B线程请求这个锁,这时B线程会被挂起。当A线程释放锁时,B线程将会被唤醒,并尝试获取锁,于此同时C线程也在请求这个锁,有可能C线程会在B线程被唤醒之前已经使用完并释放这个锁了,所以非公平锁比公平锁的吞吐量高的原因就在于线程从被唤醒到获取锁有一定的时间延迟,非公平锁能充分利用这段延迟的时间

在JVM实现中,等待获取锁的线程放入等待_EntryList,_owner指向当前持有锁的线程,_owner被wait后进入阻塞队列_WaitSet,被notify后再次进入等待队列_EntryList,_owner会在解锁时从_EntryList队列头拉出一个等待线程作为备选线程,_owner会把锁竞争的权利交给备选线程,而不是直接把锁交给备选线程,备选线程能不能获取锁还得进行重新竞争,备选线程获取锁即成为_owner线程,获取不了锁则重新进入_EntryList队列等待。这就是JVM线程竞争切换机制的大致流程。

JVM线程竞争切换机制使得synchronized内置锁是一个非公平锁,也无法通过其他手段将其设置为公平锁。

小结

1、JMM 内存模型:定义了多线程共享变量的访问规则,通过原子性,有序性,可见性保障并发编程中的线程安全。

2、final:不可变对象,保证变量初始化时的有序性、正确初始化后(没有this逃逸)保障变量可见性。

3、volatile:保障变量单次操作的原子性、保障变量读写的有序性、保证可见性。

4、synchronized:锁语义对临界区进行互斥,保障原子性,有序性,可见性。

5、当不十分确定volatile是否能保证安全的情况下,尽量选择synchronized

如果觉得我的文章对您有用,请随意打赏。您的支持将鼓励我继续创作!

¥ 打赏支持
101人赞 举报
分享到
用户评价(0)

暂无评价,你也可以发布评价哦:)

扫码APP

扫描使用APP

扫码使用

扫描使用小程序