java并发编程三特性与volatile
前言
前面讲过使用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关键字修饰,如下:
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。
需要注意的是有个限定“不影响执行结果的前提”,这里指的是单线程,在多线程并发执行的情况下可能出现意想不到的结果,比如:
public class Main1 { boolean flag=false; Source source = null; public void getConnect(){ source=getSource();//语句A flag=true;//语句B } public void doSelect(){ if(flag == true){ source.getMsg(); } } }
语句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场景一:
public class Main1 { volatile int num = 0; public void plus(){ num++;//非原子操作 多线程环境下存在线程安全问题 } public void doSelect(){ num--;//非原子操作 多线程环境下存在线程安全问题 } }
也就是说如果要使用volatile保证线程安全,那volatile修饰的变量必须只进行原子性操作,即修饰的变量只能进行如下操作:
除long和double之外的基本类型的赋值操作,比如int i=1;
所有引用类型的赋值操作,比如Object obj=xx;
原子API java.concurrent.Atomic.* 包中的类对应的操作,比如AtomicInteger 的自增操作getAndIncrement;
另一错误使用volatile场景,就是错误的认为new Object()是原子性操作。还记得双重检查单例模式的实现么,如果new Object()是原子操作的话,多线程下的单例模式是这样:
public class Singleton2 { //注意必须是volatile修饰,保证多线程下数据的可见性 private volatile static Singleton2 singleton2 = null; private Singleton2(){ } public static Singleton2 getInstance(){ if(singleton2 == null){//第一重检查 ingleton2 = new Singleton2(); } return singleton2; } }
这是错误的实现方式,由于new Singleton2()其实包含三个操作,多个操作要保证原子性,只能通过加锁实现,正确的实现方式详见这里,不再累述。
所以volatile相对加锁来说性能虽好,但真实的运用场景却很少,典型场景有两种:第一种就是做开关标记;第二种就是配合加锁实现“双重检查加锁单例模式”。
推荐阅读
-
Java并发编程之特性:原子性和可见性
-
Java并发编程:volatile关键字解析
-
[Java 并发编程实战] 设计线程安全的类的三个方式(含代码)
-
Java并发编程学习:线程安全与锁优化
-
Java并发编程-Lock锁与生产者消费者问题
-
Java并发编程:volatile关键字解析
-
JAVA并发编程(一):理解volatile关键字
-
JAVA并发编程(三):同步的辅助类之闭锁(CountDownLatch)与循环屏障(CyclicBarrier)
-
JAVA并发编程(五):创建线程的第三种方式:实现Callable接口
-
JAVA并发编程(六):线程本地变量ThreadLocal与TransmittableThreadLocal