java并发编程之volatile关键字
Posted B-to-C
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java并发编程之volatile关键字相关的知识,希望对你有一定的参考价值。
0、基础概念
0.1、Java 内存模型中的可见性、原子性和有序性:
(1)可见性,是指线程之间的可见性,一个线程修改的状态对另一个线程是可见的;
(2)原子性,指的是这个操作是原子不可拆分的,不允许别的线程中间插队操作;
(3)有序性,指的是程序员编写的代码的顺序要和最终执行的指令保持一致。因为在Java内存模型中,允许编译器和处理器 对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
volatile要解决的就是可见性和有序性问题。
0.2、java内存模型原理
运行时内存中的线程共享区域和线程私有区域共同构成了JVM内存模型.
说明 : 这是JVM经典的内存模型.但是,在OpenJDk中,是没有本地方法栈的,本地和JVM共同使用Java方法栈.
方法区中是JDK1.7之前的方法区,1.8之后将常量池移出方法区.
(1)java内存模型概念
多线程下,共享变量的读写顺序是头等大事,内存模型就是多线程下对共享变量的一组读写规则,即共享变量值是否在线程间同步,代码可能的执行顺序等;
线程要操作共享变量时,需要从主内存读取到工作内存,改变值后需要从工作内存同步到主内存中。多线程的情况下,同步到主内存时遵循同步协议规范。
CPU级别的指令重排有两种操作 Load、Store:
Load 从缓存读取到寄存器中,如果一级缓存中没有,就会层层读取二级、三级缓存,最后才是 Memory;
Store 从寄存器运算结果写入缓存,不会直接写入 Memory,当 Cache line 将被 eject 时,会 writeback 到 Memory。
这两种操作可以产生四种乱序结果:
LoadLoad<------>LoadLoadBarriers, eg: Load1; LoadLoad; Load2 该屏障确保Load1数据的装载先于Load2及其后所有装载指令的的操作
LoadStore<------>LoadStore Barriers, eg: Store1; StoreStore; Store2 该屏障确保Store1立刻刷新数据到内存(使其对其他处理器可见)的操作先于Store2及其后所有存储指令的操作
StoreStore<----->StoreStore Barriers, eg: LoadStore Barriers 确保Load1的数据装载先于Store2及其后所有的存储指令刷新数据到内存的操作
StoreLoad<----->StoreLoad Barriers, eg: Store1; StoreLoad; Load2 该屏障确保Store1立刻刷新数据到内存的操作先于Load2及其后所有装载装载指令的操作。它会使该屏障之前的所有内存访问指令(存储指令和访问指令)完成之后,才执行该屏障之后的内存访问指令。
即:
LoadLoad + LoadStore = Acquire 即让同一线程内读操作之后的读写上不去,第一个 Load 能读到主存最新值;
LoadStore + StoreStore = Release 即让同一线程内写操作之前的读写下不来,后一个 Store 能将改动都写入主存;
StoreLoad 最为特殊,还能用在线程切换时,对变量的写操作 + 读操作做同步,只要是对同一变量先写后读,那 么屏障就能生效。
- 对于Acquire来说,保证Acquire后的读写操作不会发生在Acquire动作之前;
- 对于Release来说,保证Release前的读写操作不会发生在Release动作之后;
引申:
Intel为此四种可能的乱序结果提供三种内存屏障指令:
- sfence ,实现Store Barrior 会将store buffer中缓存的修改刷入L1 cache中,使得其他cpu核可以观察到这些修改,而且之后的写操作不会被调度到之前,即sfence之前的写操作一定在sfence完成且全局可见;
- lfence ,实现Load Barrior 会将invalidate queue失效,强制读取入L1 cache中,而且lfence之后的读操作不会被调度到之前,即lfence之前的读操作一定在lfence完成(并未规定全局可见性);
- mfence ,实现Full Barrior 同时刷新store buffer和invalidate queue,保证了mfence前后的读写操作的顺序,同时要求mfence之后写操作结果全局可见之前,mfence之前写操作结果全局可见;
- lock 用来修饰当前指令操作的内存只能由当前CPU使用,若指令不操作内存仍然由用,因为这个修饰会让指令操作本身原子化,而且自带Full Barrior效果;还有指令比如IO操作的指令、exch等原子交换的指令,任何带有lock前缀的指令以及CPUID等指令都有内存屏障的作用。
(2)Java内存模型分为主内存和线程工作内存两大类。
主内存:多个线程共享的内存。方法区和堆属于主内存区域。
线程工作内存:每个线程独享的内存。虚拟机栈、本地方法栈、程序计数器属于线程独享的工作内存。
Java内存模型规定,所有变量都需要存储在主内存中,线程需要时,在自己的工作内存保存变量的副本,线程对变量的所有操作都在工作内存中进行,执行结束后再同步到主内存中去。这里必然会存在时间差,在这个时间差内, 该线程对副本的操作,对于其他线程是不见的,从而造成了可见性问题。 但是,当对volatile变量进行写操作的时候,JVM会向处理器发送一条lock前缀的指令,将这个缓存中的变量回写到系统主存中。 同时,在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议。每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期,一旦发现过期就会将当前处理器的缓存行设置成无效状态,强制从主内存读取,这就保障了可见性。
而volatile变量,通过内存屏障可以禁止指令重排。从而实现指令的有序性。
(3)指令重排序:
(3.1)java编译器运行时指令重排序(实质是JVM的优化处理),java源码编译生成class文件后,JVM需要在运行时(runtime)将字节码文件(class文件)转化为操作系统能够执行的指令(JIT编译器),在转换的过程中jvm会对指令进行优化调整,以提高运行效率。
(3.2)CPU运行时指令重排序,cpu优化的方式,为避免处理器访问主内存的时间开销,处理器采用缓存机制(三层缓存)提高性能(缓存之间的数据一致性遵循协议规范),当CPU写缓存时,发现缓存区块正被其他CPU占用,为了提高CPU的处理性能,可能将后面的读缓存命令优先执行。
1、volatile的作用
一个线程共享变量(类的成员变量、类的静态成员变量等)被volatile修饰之后,就具有以下作用:
1)并发中的变量可见性(不同线程对该变量进行操作时的可见性),即一个线程修改了某个变量的值,则该新值对其他线程立即可见(可立即访问新值/立即强制写入主存);
2)禁止指令重排(包括java编译器和CPU运行时指令重排序);能够保证Volatile变量间的顺序性,编译器不会进行乱序优化。不过要注意与非volatile变量之间的操作,还是可能被编译器重排序的。
3)禁用缓存(java虚拟机规范)---子线程的工作内存(包括了CPU缓存);声明为volatile变量编译器会强制要求读内存,相关语句不会直接使用上一条语句对应的的寄存器内容,而是重新从内存中读取。
4)具有”不可优化”性,volatile告诉编译器,不要对这个变量进行各种激进的优化,甚至将变量直接消除,保证代码中的指令一定会被执行。
Java语言中voldatile变量可以被看作是一种轻量级的同步,因其还附带了acuire和release语义。实际上也是从JDK5以后才通过这个措施进行完善,其volatile 变量具有 synchronized 的可见性特性,但是不具备原子特性。Java语言中有volatile修饰的变量,赋值后多执行了一个“load addl $0x0, (%esp)”操作,这个操作相当于一个lock指令,就是增加一个完全的内存屏障指令,这点与C++实现并不一样。volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
2、volatile使用条件
Java实践中仅满足下面这些条件才应该使用volatile关键字:
- 变量写入操作不依赖变量当前值,或确保只有一个线程更新变量的值(Java可以,C++仍然不能)
- 该变量不会与其他变量一起纳入
- 变量并未被锁保护
3、相关现象分析
3.1)先看一段代码,
public class VolatileInfo { private static boolean flag = true; public static void main(String[] args) { new Thread(new Runnable() { @Override public void run() { long start = System.currentTimeMillis(); long end = 0; int index = 0; while (VolatileInfo.flag) { index++; end = System.currentTimeMillis(); if ((end-start)/1000==5) {//5秒之后结束任务 break; } } System.out.println("index:"+index); System.out.println("cost:"+(end-start)/1000+"s"); } }).start(); try { TimeUnit.SECONDS.sleep(2);//阻塞线程2秒 } catch (InterruptedException e) { e.printStackTrace(); } VolatileInfo.flag = false; System.out.println("条件置为false"); } }
输出结果:
条件置为false index:269460217 cost:5s
耗时5秒,并不是2秒后停止(注:静态变量flag用volatile 修饰后执行时间是2秒)。
3.2)问题分析:
1)主线程阻塞后修改的flag的值并没有及时写入到主存,子线程没有及时读取到flag值,导致循环继续执行---即存在缓存;
2)指令重排序,先从主内存中执行读的操作,陷入死循环,不再向主内存写入。
3.3)解决办法:
1)flag变量使用volatile关键词修饰;
2)使用Synchronized加同步锁(从主存中直接读取,相当于禁用缓存,参考4.3);
while (VolatileInfo.flag) { synchronized (this) { index++; } end = System.currentTimeMillis(); if ((end - start) / 1000 == 5) {// 5秒之后结束任务 break; } }
3)从eclipse中将jvm执行class文件的方式改为client(默认是server模式,jvm进行了一些优化调整);
4)从eclipse中配置添加关闭server优化的参数---此处请自行百度^_^; ---------- 禁止指令重排。
4、扩展知识点
4.1)java内存模型多线程情况下的工作内存和主内存同步协议的8种原子操作:
lock(锁定):作用于主内存,将主内存中的变量锁定,为一个线程所独有;
unlock(解锁):作用于主内存,解除lock的锁定,释放后的变量能够被其他线程访问;
read(读取):作用于主内存,将主内存中的变量读取到工作内存中,以便随后的load动作使用;
load(载入):作用于工作内存,它把read读取的值保存到工作内存中的变量副本中;
use(使用):作用于工作内存,它把工作内存中的值传递给线程代码执行引擎;
assign(赋值):作用于工作内存,它把从执行引擎处理返回的值重新赋值给工作内存中的变量;
store(存储):作用于工作内存,将变量副本中的值传送到主内存中,以备随后的write操作使用;
write(写入):作用于主内存,它把store传送值写到主内存的共享变量中。
4.2)java内存模型操作规范:
1)将一个变量从主内存复制到工作内存要顺序依次(不一定连续)执行read、load操作;
2)做了assign操作,必须同步回主内存等。
4.3)保证线程共享变量可见性的方式:
1)用final修饰的变量
2) Synchronized 同步锁
Synchronized规范, 进入同步块前,先清空工作内存中的共享变量,从主内存中重新加载; 解锁前必须把修改的共享变量同步回主内存。
锁机制,锁机制保护共享资源,只有获得锁的线程才能操作共享资源。
3) 用volatile修饰的变量
volatile语义规范,使用volatile修饰的变量时,必须从主内存中加载,并且read、load是连续的;修改volatile修饰的变量时,必须立即同步到主内存,并且store、write是连续的。
参考不少大佬资料,科技强国路上,共同努力,感谢阅读,转载请注明出处,谢谢:https://www.cnblogs.com/huyangshu-fs/p/10225898.html
以上是关于java并发编程之volatile关键字的主要内容,如果未能解决你的问题,请参考以下文章