ava内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这3个特征来建立的,我们来看下哪些操作实现了这3个特性。
原子性(atomicity):
由Java内存模型来直接保证原子性变量操作包括read, load, assign, use, store和write。大致可以认为基本数据类型的访问读写是具有原子性的。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足需求,尽管虚拟机没有把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。
的原子性就和数据库事务的原子性差不多,一个操作中要么全部执行成功或者失败。
只是保证了基本的原子性,但类似于 之类的操作,看似是原子操作,其实里面涉及到:
获取 i 的值。
自增。
再赋值给 i。
这三步操作,所以想要实现 这样的原子操作就需要用到 或者是 进行加锁处理。
如果是基础类的自增操作可以使用 这样的原子类来实现(其本质是利用了 级别的 的 指令来完成的)。
其中用的最多的方法就是: 以原子的方式自增。源码如下:
首先是获得当前的值,然后自增 +1。接着则是最核心的 来进行原子更新。
其逻辑就是判断当前的值是否被更新过,是否等于 ,如果等于就说明没有更新过然后将当前的值更新为 ,如果不等于则返回 进入循环,直到更新成功为止。
还有其中的 方法也很关键,返回的是当前的值,当前值用了 关键词修饰,保证了内存可见性。
可见性(visibility):可见性是指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步到主内存,无论是普通变量还是volatile变量都是如此,普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此,可以说volatile保证了多线程操作时变量的可见性,而普通变量不能保证这一点。
除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得的,而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把"this"的引用传递出去(this引用逃逸是一件很危险的事情,其他线程有可能通过这个引用访问到“初始化了一半”的对象),那在其他线程中就能看见final字段的值。
现代计算机中,由于 直接从主内存中读取数据的效率不高,所以都会对应的 高速缓存,先将主内存中的数据读取到缓存中,线程修改数据之后首先更新到缓存,之后才会更新到主内存。如果此时还没有将数据更新到主内存其他的线程此时来读取就是修改之前的数据。
如上图所示。
关键字就是用于保证内存可见性,当线程A更新了 volatile 修饰的变量时,它会立即刷新到主线程,并且将其余缓存中该变量的值清空,导致其余线程只能去主内存读取最新值。
使用 关键词修饰的变量每次读取都会得到最新的数据,不管哪个线程对这个变量的修改都会立即刷新到主内存。
和加锁也能能保证可见性,实现原理就是在释放锁之前其余线程是访问不到这个共享变量的。但是和 相比开销较大。
有序性:Java程序天然的有序性可以总结为一句话:如果本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内表现为串行的语义”,后半句是指“指令重排序”现象和“工作内存与主内存同步延迟”现象。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则获得的,这条规则决定了持有同一个锁的两个同步块只能串行地进入。
以下这段代码:
正常情况下的执行顺序应该是 。但是有时 为了提高整体的效率会进行指令重排导致执行的顺序可能是 。但是 也不能是什么都进行重排,是在保证最终结果和代码顺序执行结果一致的情况下才可能进行重排。
重排在单线程中不会出现问题,但在多线程中会出现数据不一致的问题。
Java 中可以使用 来保证顺序性, 也可以来保证有序性,和保证原子性的方式一样,通过同一段时间只能一个线程访问来实现的。
除了通过 关键字显式的保证顺序之外, 还通过 原则来隐式的保证顺序性。
其中有一条就是适用于 关键字的,针对于 关键字的写操作肯定是在读操作之前,也就是说读取的值肯定是最新的。
volatile 的应用
双重检查锁的单例模式
可以用 实现一个双重检查锁的单例模式:
这里的 关键字主要是为了防止指令重排。如果不用 ,,这段代码其实是分为三步:
分配内存空间。(1)
初始化对象。(2)
将 对象指向分配的内存地址。(3)
加上 是为了让以上的三步操作顺序执行,反之有可能第三步在第二步之前被执行就有可能导致某个线程拿到的单例对象还没有初始化,以致于使用报错。
控制停止线程的标记
这里如果没有用 volatile 来修饰 flag ,就有可能其中一个线程调用了 方法修改了 flag 的值并不会立即刷新到主内存中,导致这个循环并不会立即停止。
这里主要利用的是 的内存可见性。
总结一下:
关键字只能保证可见性,顺序性,不能保证原子性。
当一个变量定义为volatile之后,它具备两种特性:
保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
禁止指令重排序优化。
在X86处理器下通过工具获取 JIT编译器生成的汇编指令来看下volatile变量进行读写操作时CPU的行为:
Java 代码如下:
生成的汇编代码如下:
有volatile变量修饰的共享变量进行写操作的时候会多出第二行汇编代码,Lock前缀的指令在多核处理器下会引发了两件事件。
将当前处理器缓存的数据写回到系统内存。
这个写回内存的操作会使在其他CPU里缓存了该内存地址的数据无效。
再让我们从Java内存模型的角度分析下volatile变量。假定T表示一个线程,V和W分别表示两个volatile变量,那么在进行read, load, use, assign, store和write时需要满足以下三条规则:
只有当线程T对变量V执行的前一个动作是load时,T才能对V执行use; 并且,只有当T对V执行的后一个动作是use时,T才能对V执行load。T对V的use动作可以认为是和线程T对V的load,read动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值)。
只有当线程T对变量V执行的前一个动作是assign时,T才能对V执行store动作;并且,只有当T对变量V执行的后一个动作是store时,线程T才能对变量V执行assign动作。线程T对变量V的assign动作可认为是和线程T对变量V的store, write动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程可以看到自己对变量V所做的修改)。
假定动作A是线程T对变量V实施的use或assign操作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的变量V的read或write动作;类似的,假定动作B是线程T对变量W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的变量W的read或write动作。如果A先于B,那P先于Q(这条规则要求volatile修饰的变量不会被指令重排序优化,保证代码的执行顺序与程序的顺序相同)。
运行结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
变量不需要与其他的状态变量共同参与不变约束。
在某些情况下,volatile的同步机制性要优于锁。并且,volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
领取专属 10元无门槛券
私享最新 技术干货