这里有新鲜出炉的 Java 并发编程示例,程序狗速度看过来!
java 是一种可以撰写跨平台应用软件的面向对象的程序设计语言,是由 Sun Microsystems 公司于 1995 年 5 月推出的 Java 程序设计语言和 Java 平台(即 JavaEE(j2ee), JavaME(j2me), JavaSE(j2se))的总称。
volatile 是 Java 提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色. 这篇文章主要介绍了深入浅析 Java 中的 volatile, 需要的朋友可以参考下
内存可见性
volatile 是 Java 提供的一种轻量级的同步机制,在并发编程中,它也扮演着比较重要的角色。同 synchronized 相比(synchronized 通常称为重量级锁),volatile 更轻量级,相比使用 synchronized 时引起的线程上下文切换所带来的庞大开销,倘若能恰当的合理的使用 volatile,自然是美事一桩。
为了能比较清晰彻底的理解 volatile,我们一步一步来分析。首先来看看如下代码
- public class TestVolatile {
- boolean status = false;
- /**
- * 状态切换为true
- */
- public void changeStatus() {
- status = true;
- }
- /**
- * 若状态为true,则running。
- */
- public void run() {
- if (status) {
- System.out.println("running....");
- }
- }
- }
上面这个例子,在多线程环境里,假设线程 A 执行 changeStatus() 方法后, 线程 B 运行 run() 方法,可以保证输出 "running....." 吗?
答案是 NO!
这个结论会让人有些疑惑,可以理解。因为倘若在单线程模型里,先运行 changeStatus 方法,再执行 run 方法,自然是可以正确输出 "running...." 的;但是在多线程模型中,是没法做这种保证的。因为对于共享变量 status 来说,线程 A 的修改,对于线程 B 来讲,是 "不可见" 的。也就是说,线程 B 此时可能无法观测到 status 已被修改为 true。那么什么是可见性呢?
所谓可见性,是指当一条线程修改了共享变量的值,新值对于其他线程来说是可以立即得知的。很显然,上述的例子中是没有办法做到内存可见性的。
Java 内存模型
为什么出现这种情况呢,我们需要先了解一下 JMM(java 内存模型)
java 虚拟机有自己的内存模型(Java Memory Model,JMM),JMM 可以屏蔽掉各种硬件和操作系统的内存访问差异,以实现让 java 程序在各种平台下都能达到一致的内存访问效果。
JMM 决定一个线程对共享变量的写入何时对另一个线程可见,JMM 定义了线程和主内存之间的抽象关系:共享变量存储在主内存 (Main Memory) 中,每个线程都有一个私有的本地内存(Local Memory),本地内存保存了被该线程使用到的主内存的副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。这三者之间的交互关系如下
需要注意的是,JMM 是个抽象的内存模型,所以所谓的本地内存,主内存都是抽象概念,并不一定就真实的对应 cpu 缓存和物理内存。当然如果是出于理解的目的,这样对应起来也无不可。
大概了解了 JMM 的简单定义后,问题就很容易理解了,对于普通的共享变量来讲,比如我们上文中的 status,线程 A 将其修改为 true 这个动作发生在线程 A 的本地内存中,此时还未同步到主内存中去;而线程 B 缓存了 status 的初始值 false,此时可能没有观测到 status 的值被修改了,所以就导致了上述的问题。那么这种共享变量在多线程模型中的不可见性如何解决呢?比较粗暴的方式自然就是加锁,但是此处使用 synchronized 或者 Lock 这些方式太重量级了,有点炮打蚊子的意思。比较合理的方式其实就是 volatile
volatile 具备两种特性,第一就是保证共享变量对所有线程的可见性。将一个共享变量声明为 volatile 后,会有以下效应:
1. 当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的变量强制刷新到主内存中去;
2. 这个写会操作会导致其他线程中的缓存无效。
上面的例子只需将 status 声明为 volatile,即可保证在线程 A 将其修改为 true 时,线程 B 可以立刻得知
- volatile boolean status = false;
留意复合类操作
但是需要注意的是,我们一直在拿 volatile 和 synchronized 做对比,仅仅是因为这两个关键字在某些内存语义上有共通之处,volatile 并不能完全替代 synchronized,它依然是个轻量级锁,在很多场景下,volatile 并不能胜任。看下这个例子:
- package test;
- import java.util.concurrent.CountDownLatch;
- /**
- * Created by chengxiao on 2017/3/18.
- */
- public class Counter {
- public static volatile int num = 0;
- //使用CountDownLatch来等待计算线程执行完
- static CountDownLatch countDownLatch = new CountDownLatch(30);
- public static void main(String[] args) throws InterruptedException {
- //开启30个线程进行累加操作
- for (int i = 0; i < 30; i++) {
- new Thread() {
- public void run() {
- for (int j = 0; j < 10000; j++) {
- num++; //自加操作
- }
- countDownLatch.countDown();
- }
- }.start();
- }
- //等待计算线程执行完
- countDownLatch.await();
- System.out.println(num);
- }
- }
执行结果:
224291
针对这个示例,一些同学可能会觉得疑惑,如果用 volatile 修饰的共享变量可以保证可见性,那么结果不应该是 300000 么
问题就出在 num++ 这个操作上,因为 num++ 不是个原子性的操作,而是个复合操作。我们可以简单讲这个操作理解为由这三步组成:
1. 读取
2. 加一
3. 赋值
所以,在多线程环境下,有可能线程 A 将 num 读取到本地内存中,此时其他线程可能已经将 num 增大了很多,线程 A 依然对过期的 num 进行自加,重新写到主存中,最终导致了 num 的结果不合预期,而是小于 30000。
解决 num++ 操作的原子性问题
针对 num++ 这类复合类的操作,可以使用 java 并发包中的原子操作类原子操作类是通过循环 CAS 的方式来保证其原子性的。
- /**
- * Created by chengxiao on 2017/3/18.
- */
- public class Counter { //使用原子操作类
- public static AtomicInteger num = new AtomicInteger(0);
- //使用CountDownLatch来等待计算线程执行完
- static CountDownLatch countDownLatch = new CountDownLatch(30);
- public static void main(String[] args) throws InterruptedException {
- //开启30个线程进行累加操作
- for (int i = 0; i < 30; i++) {
- new Thread() {
- public void run() {
- for (int j = 0; j < 10000; j++) {
- num.incrementAndGet(); //原子性的num++,通过循环CAS方式
- }
- countDownLatch.countDown();
- }
- }.start();
- }
- //等待计算线程执行完
- countDownLatch.await();
- System.out.println(num);
- }
- }
执行结果
300000
关于原子类操作的基本原理,会在后面的章节进行介绍,此处不再赘述。
禁止指令重排序
volatile 还有一个特性:禁止指令重排序优化。
重排序是指编译器和处理器为了优化程序性能而对指令序列进行排序的一种手段。但是重排序也需要遵守一定规则:
1. 重排序操作不会对存在数据依赖关系的操作进行重排序。
比如:a=1;b=a; 这个指令序列,由于第二个操作依赖于第一个操作,所以在编译时和处理器运行时这两个操作不会被重排序。
2. 重排序是为了优化性能,但是不管怎么重排序,单线程下程序的执行结果不能被改变
比如:a=1;b=2;c=a+b 这三个操作,第一步(a=1)和第二步 (b=2) 由于不存在数据依赖关系,所以可能会发生重排序,但是 c=a+b 这个操作是不会被重排序的,因为需要保证最终的结果一定是 c=a+b=3。
重排序在单线程模式下是一定会保证最终结果的正确性,但是在多线程环境下,问题就出来了,来开个例子,我们对第一个 TestVolatile 的例子稍稍改进,再增加个共享变量 a
- public class TestVolatile {
- int a = 1;
- boolean status = false;
- /**
- * 状态切换为true
- */
- public void changeStatus() {
- a = 2; //1
- status = true; //2
- }
- /**
- * 若状态为true,则running。
- */
- public void run() {
- if (status) { //3
- int b = a + 1; //4
- System.out.println(b);
- }
- }
- }
假设线程 A 执行 changeStatus 后,线程 B 执行 run,我们能保证在 4 处,b 一定等于 3 么?
答案依然是无法保证!也有可能 b 仍然为 2。上面我们提到过,为了提供程序并行度,编译器和处理器可能会对指令进行重排序,而上例中的 1 和 2 由于不存在数据依赖关系,则有可能会被重排序,先执行 status=true 再执行 a=2。而此时线程 B 会顺利到达 4 处,而线程 A 中 a=2 这个操作还未被执行,所以 b=a+1 的结果也有可能依然等于 2。
使用 volatile 关键字修饰共享变量便可以禁止这种重排序。若用 volatile 修饰共享变量,在编译时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序
volatile 禁止指令重排序也有一些规则,简单列举一下:
1. 当第二个操作是 voaltile 写时,无论第一个操作是什么,都不能进行重排序
2. 当地一个操作是 volatile 读时,不管第二个操作是什么,都不能进行重排序
3. 当第一个操作是 volatile 写时,第二个操作是 volatile 读时,不能进行重排序
总结:
简单总结下,volatile 是一种轻量级的同步机制,它主要有两个特性:一是保证共享变量对所有线程的可见性;二是禁止指令重排序优化。同时需要注意的是,volatile 对于单个的共享变量的读 / 写具有原子性,但是像 num++ 这种复合操作,volatile 无法保证其原子性,当然文中也提出了解决方案,就是使用并发包中的原子操作类,通过循环 CAS 地方式来保证 num++ 操作的原子性。关于原子操作类,会在后续的文章进行介绍。大家敬请关注 PHPERZ 网站!
来源: http://www.phperz.com/article/17/1221/357933.html