先说特点:
1 保证了所修饰变量的内存可见性
2 防止指令重排
3 修改的变量不具有原子性
可见性
可见性: 某线程修改共享变量的指令对其他线程来说是可见的,它反映的是指令执行的实时透明度。简单来说:当一个线程修改了变量的值,新的值会立刻同步到主内存当中。而其他线程读取这个变量的时候,也会从主内存中拉取最新的变量值。
每个线程都有独占的内存区域,入操作栈、本地变量表等。线程的本地内存保存了引用变量在堆内存的副本,线程对变量的所有操作都在本地内存区域中进行,执行结束后再同步到堆内存中去。这里必然有个时间差,在这个时间差内,该线程对副本的操作,对于其他线程来说是不可见的。
这里解释一下,比如线程A对变量的修改,都是先在本地内存修改,修改后再同步到堆内存去,若此时,线程B在线程A还未同步到堆内存去,去执行读操作,则读到的值是旧值,而不是A同步到堆内存的值。
而valatile修饰变量时,意味着任何堆变量的操作都会在内存中进行,不会产生副本,以保证共享变量的可见性,局部阻止了指令重排的发生。
举例代码:
calss LazyInitDemo{
private static TransactionService service = null;
public static TransactionService getTransactionService() {
if(service == null) {
synchronized(this) {
if(service ==null) {
// 如果返回对象不为空,按还初始化完成情况呢
service = new TransactionService();
}
}
}
return service;
}
}
上面代码会出现问题,如果使用者在调用getTransactionService()时,有可能会得到初始化未完成的对象。
原因:首先肯定跟Java虚拟机的编译优化有关,对于Java编译器来说,初始TransactionService实例和对象地址写到service字段并非原子操作, 且这两个阶段的执行顺序是未定义的。假设某个线程执行new getTransactionService()时,构造方法还未被调用时,编译器仅仅为该对象分配了内存空间并设置默认值。此时另外一个线程调用getTransactionService()方法,由于service!=null,但此时service对象还没有赋予真正有效的值,从而无法取得正确的service单例对象。这就是著名的双重检查锁定问题,即对象引用在没有同步的情况下进行读操作,导致用户可能会获取未构造完成的对象。
对此问题,一种较为简单的解决方案就是用volatile关键字修饰目标遍历,这样service就限制了编译器堆它的相关读写操作,防止对它的读写操作进行指令重排,确定对象实例化之后才返回引用。
防止指令重排
指令重排是指JVM在编译Java代码的时候,或者CPU在执行JVM字节码的时候,对现有的指令顺序进行重新排序。
指令重排的目的是为了在不改变程序执行结果的前提下,优化程序的运行效率。需要注意的是,这里所说的不改变执行结果,指的是不改变单线程下的程序执行结果。
然而,指令重排是一把双刃剑,虽然优化了程序的执行效率,但是在某些情况下,会影响到多线程的执行结果。我们来看看下面的例子:
例如如下代码:
boolean contextReady = false;
在线程A中执行:
context = loadContext();
contextReady = true;
在线程B中执行:
while( ! contextReady ){
sleep(200);
}
doAfterContextReady (context);
以上程序看似没有问题。线程B循环等待上下文context的加载,一旦context加载完成,contextReady == true的时候,才执行doAfterContextReady 方法。
但是,如果线程A执行的代码发生了指令重排,初始化和contextReady的赋值交换了顺序:
boolean contextReady = false;
在线程A中执行:
contextReady = true;
context = loadContext();
在线程B中执行:
while( ! contextReady ){
sleep(200);
}
doAfterContextReady (context);
这个时候,很可能context对象还没有加载完成,变量contextReady 已经为true,线程B直接跳出了循环等待,开始执行doAfterContextReady 方法,结果自然会出现错误。
修改的变量不具有原子性
看代码:
/**
* 描述: volatile特性验证
*
* @author pengjie_yao
* @date 2019/7/19 10:12
*/
public class VolatileTest {
private static volatile long count = 0L;
private static final int NUMBER = 10000;
public static void main(String[] args) {
Thread subtractThread = new SubtractThread();
subtractThread.start();
for (int i = 0; i < NUMBER; i++) {
count++;
}
//等待减法线程结束
while (subtractThread.isAlive()) {
System.out.println("count最后的值为:" + count);
}
}
private static class SubtractThread extends Thread {
@Override
public void run() {
for (int i = 0; i < NUMBER; i++) {
count--;
}
}
}
}
多次执行之后,发现结果不为0.如果在count++和count--两处加锁操作,结果才会相同。对于读写操作的字节码如下:
getstatic //读取静态变量(count)
iconst_1 //定义常量1
iadd //count增加1
putstatic //把count结果同步到主内存
虽然每一次执行 getstatic 的时候,获取到的都是主内存的最新变量值,但是进行iadd的时候,由于并不是原子性操作,其他线程在这过程中很可能让count自增了很多次。这样一来本线程所计算更新的是一个陈旧的count值,自然无法做到线程安全.
所以对于说法“volatile是轻量级的同步方式”是错误的说法,它只是轻量级的线程操作可见方式,并非同步方式,如果是多写场景,一定会产生线程安全问题。如果是一写多读的并发场景例如CopyOnWriteArrayList,它在修改数据时会把整个集合的数据全部复制,对写操作加锁,修改完成后,再用setArray()把array指向新的集合。使用volatile可以使线程尽快地感知array修改,不进行指令重排,操作后堆其他线程可见。
public class CopyOnWriteArrayList<E>
implements List<E>, RandomAccess, Cloneable, java.io.Serializable
private transient volatile Object[] array;
final void setArray(Object[] a) {
array = a;
}
.....
}
那么继续无法保证原子性,那么如何才能保证原子性呢,有兴趣可以看我另外一篇介绍CAS的文章