前言
前面讲过使用 synchronized 关键字来解决 "线程安全" 问题, 其本质是将 "并行" 执行改 "串行", 也就是所谓的 "同步", 前面也讲过这种方式的代价较高. 在 java 中还提供一种弱化版的同步机制: volatile 变量.
为什么说是弱化版的同步机制呢? 首先看下在使用 synchronized 关键字保证的 (强) 同步机制的三个特性说起: 原子性, 可见性, 有序性, 也就是说使用 synchronized 加锁可以同时保证程序执行过程中的原子性, 可见性, 有序性.
1, 原子性:
这个特性更事务处理中的原生性有点类似: 单个或多个操作是作为整体一起执行, 要么全部执行, 要么都不执行. 但也有区别: 事务里强调的是回滚, 而并发编程中强调的是 "作为不可拆分的整体执行". 这里提到 "单个操作" 和 "多个操作".
操作系统中的 "单个操作" 是原子性的, 在 java 中 "单个操作" 是原子性操作的有:
除 long 和 double 之外的基本类型的赋值操作, 比如 int i=1;
所有引用类型的赋值操作, 比如 Object obj=xx;
原子 API java.concurrent.Atomic.* 包中的类对应的操作, 比如 AtomicInteger 的自增操作 getAndIncrement;
这里需要注意的是 long 和 double 的赋值有可能不是原子性的, 它们在 java 中占 8 个字节, 一个字节 8bit, 一共就是 64 个 bit. 在 32 位的操作系统中, 每次原子赋值只能对 32bit 进行操作, 也就是说在 32 位的操作系统中对 long 和 double 的赋值其实是两个操作."多个操作" 的原子性, 只能通过加锁方式来保证.
"多个操作" 的原子性, 前面已经提到了可以通过 synchronized 关键字或者 Lock(新锁 API) 加锁来实现. 通过串行的方式, 保证每次只有一个线程在执行 "多个操作", 让同步代码块或同步方法看起来是一个不可分割的整体.
需要注意的是 i++,i--,++i,--i 等都不是原子性操作, i++ 可以拆分为 i+1 操作和对 i 重新复制操作.
另外通过 new 创建对象也不是原子操作, 一共有三个操作: 分配内存空间; 初始化对象; 指向该对象的内存地址.
2, 可见性:
这是一个相对来说比较难以理解的概念, 其它类似文章中的说法是 "变量值" 在工作内存与主存之间的同步不一致, 会导致可见性问题. java 的内存结构分为: 方法区, 堆区, vm 栈, 本地方法栈, 程序计数器. 这里要说的重点是 vm 栈 , 方法区, 堆区, 所谓 "工作内存" 其实就是每个线程对应的 "vm 栈" 内存, 所谓 "主存" 可以理解为方法区和堆区. 线程, vm 栈, 方法区, 堆区 它们之间的关系如下:
线程 1 在执行某个方法时, 会创建一个 vm 栈, 该方法中使用了一个 "方法区" 中的静态变量, 此时会读取一份方法区中变量值作为副本 放入 vm 栈内存中. 假设现在有另外一个线程 2 改变了方法区中该静态变量值, 在线程 1 的 vm 栈中其实存放的还是 "旧值", 示意图如下:
(这里只是以静态变量为例, 如果是对象的成员变量主存就是堆区)
可以看到线程 1 中 i 的值始终是 0, 线程 2 中的值是 1(主存中的值也变为 1), 这就出现两个线程中读取同一个变量时, 出现不一致现象, 这就是 java 并发编程中的 "可见性" 问题.
在 java 中解决可见性问题的方案, 有两种: 第一种就是前面提到的 "加锁", 把并行操作变量 i 的值 改为 "串行", 由于同一时刻只有一个线程在操作主存, 所以不存在两个线程看到的值不一致的问题; 第二种办法就是对 i 变量采用 volatile 关键字修饰, 如下:
1. public volatile static int i=0;
与加锁方式不同的是, volatile 关键字只保证 "可见性", 而加锁的方式可以同时保证: 原子性, 可见性, 有序性, 所以是 volatile 关键字 "弱化版" 的同步机制. 并且复出的性能代价也比加锁方式小很多, 因为此时多线程可以照常 "并行" 执行.
volatile 的核心思想就是, 告诉各个线程在读取这个变量时, 每次都从主存中读取, 从而保证线程中每次获取到的都是最新值, 以解决 "可见性" 问题; 而不是只读一次放入 vm 栈副本中, 以后使用时都直接读取副本. 对线程执行来说, 从 vm 栈中获取数据的性能肯定比每次都从主存读取性能要好, 所以使用 volatile 关键字也有些许性能损失, 但仍能保证多线程并行执行, 相对加锁方式来说 性能会有大幅度提高. 使用 volatile 修饰后, i 变量在多个线程中的可见性示意图如下:
可以看到, 在同一时刻多个线程中看到的 i 值是相同. 但不是所有的情况都可以使用 volatile 关键字, 由于 volatile 关键字只能保证 "可见性", 事实上它只适用少有的几种情况. 关于 volatile 关键字的适用场景放到最后讲. 接着看第三个并发问题 "有序性":
3, 有序性:
所谓有序性就是代码的执行顺序是从前往后依次执行. 我们期望的代码执行顺序是我们编码的顺序, 比如在同一个方法中有下列代码:
- int i=0;// 语句 1
- int j=0; // 语句 2
- i=i+1; // 语句 3
- j=j+1; // 语句 4
我们期望的执行顺序是: 语句 1, 语句 2, 语句 3, 语句 4 顺序执行, 但在 jvm 的真实实现中有可能是: 语句 1, 语句 3, 语句 2, 语句 4. 问什么呢 jvm 要这样实现呢? 这又回到 "vm 栈" 的入栈和出栈问题, 我们都知道 "栈" 的数据结构是 "先进先出".
如果按照: 语句 1, 语句 2, 语句 3, 语句 4 顺序执行, 首先是变量 i 入栈 --> 然后变量 i 出栈 --> 变量 j 入栈 --> 变量 j 出栈 --> 变量 i 再入栈并执行 + 1 操作 --> 变量 i 再出栈 --> 变量 j 再入栈并执行 + 1 操作 --> 变量 j 出栈.
如果按照: 语句 1, 语句 3, 语句 2, 语句 4 执行, 首先变量 i 入栈 --> 执行 + 1 操作 出栈 --> 变量 j 入栈 --> 执行 + 1 操作 出栈. 可以看到如果采用这种方式, 会减少入栈出栈的操作次数, 这就是 jvm 在不影响执行结果的前提下 (这里指的单线程), 为了优化变量的入栈和出栈, 对执行的代码重新排序, 也就是所谓的 "指令重排". 指令重排的依据是: 执行效率最优; 执行有依赖关系的必须提前执行, 满足这两个条件即可. 比如前面语句中必须要先执行语句 1, 才能执行语句 3.
需要注意的是有个限定 "不影响执行结果的前提", 这里指的是单线程, 在多线程并发执行的情况下可能出现意想不到的结果, 比如:
- 1. public class Main1 {
- 2. boolean flag=false;
- 3. Source source = null;
- 5. public void getConnect(){
- 6. source=getSource();// 语句 A
- 7. flag=true;// 语句 B
- 8.
- }
- 10. public void doSelect(){
- 11. if(flag == true){
- 12. source.getMsg();
- 13.
- }
- 14.
- }
- 15.
- }
语句 A,B 由于没有依赖, 可能发生指令重排.
但在单线程下先执行 getConnect() 方法, 再执行 doSelect(), 程序没有任何问题.
在多线程环境下就不同了, 假设线程 1 执行 getConnect() 方法; 同时线程 2 执行 doSelect() 方法, 由于语句 A,B 执行重排, 这时可能出现空指针 (当然这里也可能是由于 "可见性" 导致).
volatile 关键字可以一定程度上消除指令重排 即: 在 volatile 变量之前和之后的指令会被分割开, 比如下列语句:
- int i=0;// 语句 1
- int j=0; // 语句 2
- flag=ture;//flag 是 volatile 变量
- i=i+1; // 语句 3
- j=j+1; // 语句 4
上述语句只可能出现语句 1,2 重排, 语句 3,4 重排. 相当于在 volatile 变量处建立了一道屏障, 这就是所谓的 "内存屏障".
并发编程中的 "有序性" 问题, 指的就是在多线程环境下由于指令重排导致的程序执行的不一致问题 (即线程安全问题). 解决有序性问题, 有两种办法:
1, 使用 synchronized 或 Lock 加锁: 前面说过, 指令重排在单线程中不会影响执行结果, 通过加锁并行改串行, 串行本质上就是单线程执行的变体.
2, 在某些场景下可以使用 volatile 变量, 使用 volatile 变量可以一定程度上消除 "指令重排", 一定程度上保证 "有序性".
注意两者的区别, 加锁本质上没有消除 "指令重排".
再聊 volatile
相对于加锁来说 volatile 是 java 中轻量版的 "同步机制", 主要表现在 volatile 无法保证多个操作的 "原子性", 只能保证 "可见性" 和防止 "指令重排". 典型错误使用 volatile 场景一:
- 1. public class Main1 {
- 2. volatile int num = 0;
- 4. public void plus(){
- 5. num++;// 非原子操作 多线程环境下存在线程安全问题
- 6.
- }
- 8. public void doSelect(){
- 9. num--;// 非原子操作 多线程环境下存在线程安全问题
- 10.
- }
- 11.
- }
也就是说如果要使用 volatile 保证线程安全, 那 volatile 修饰的变量必须只进行原子性操作, 即修饰的变量只能进行如下操作:
除 long 和 double 之外的基本类型的赋值操作, 比如 int i=1;
所有引用类型的赋值操作, 比如 Object obj=xx;
原子 API java.concurrent.Atomic.* 包中的类对应的操作, 比如 AtomicInteger 的自增操作 getAndIncrement;
另一错误使用 volatile 场景, 就是错误的认为 new Object() 是原子性操作. 还记得双重检查单例模式的实现么, 如果 new Object() 是原子操作的话, 多线程下的单例模式是这样:
- 1. public class Singleton2 {
- 3. // 注意必须是 volatile 修饰, 保证多线程下数据的可见性
- 4. private volatile static Singleton2 singleton2 = null;
- 6. private Singleton2(){
- 8.
- }
- 10. public static Singleton2 getInstance(){
- 11. if(singleton2 == null){
- // 第一重检查
- 12. ingleton2 = new Singleton2();
- 13.
- }
- 14. return singleton2;
- 15.
- }
- 16.
- }
这是错误的实现方式, 由于 new Singleton2() 其实包含三个操作, 多个操作要保证原子性, 只能通过加锁实现, 不再累述.
所以 volatile 相对加锁来说性能虽好, 但真实的运用场景却很少, 典型场景有两种: 第一种就是做开关标记; 第二种就是配合加锁实现 "双重检查加锁单例模式".
来源: http://www.jianshu.com/p/03f99b5e6574