volatile机制探究

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/Armour__r/article/details/79782811

看了别人的几篇博客,觉得受益匪浅,同时有一点自己的想法,想记录下来,便有了这篇博文。

问题

多线程主要围绕的问题就是可见性,原子性和有序性这些特性。而使用volatile关键字修饰的变量,能够保证其在多线程之间的可见性,即每次读取到的volatile变量,一定是最新的数据。
volatile同时也会阻止进行语句重排。

从内存模型说起,在java虚拟机中,程序计数器 java虚拟机栈 本地方法栈都是线程私有的,方法区和java堆是线程公有的。各种数据在内存中存放的位置:

局部变量,是指在方法中定义的基本类型的变量和引用类型的指针,都在方法的栈内存中分配空间,当方法运行完成时,内存被施放。成员变量,在所在类未被初始化(能不能用初始化这个词?)时,作为类的字段信息存放在方法区中,类实例化成对象后,存放在堆中。静态变量(类变量)放在方法区中,常量存放在运行时常量池中(运行时常量池jdk1.7以后被放在了java堆中)

当java的线程需要对公有数据进行操作时,会将主内存中的数据拷贝到工作内存中再进行操作,执行成功后再将数据写回主内存中,这样的操作方式很容易发生在多个线程同时操作一个公有数据时,因为读写的不同步导致执行发生不可预料的错误,例如脏读。

在下面的这个示例程序中,有一个成员变量bChanged,这个变量会被一个线程不停修改,而在另一个线程中不做改动,只进行一个bChanged==!bChanged的判断。在没有使用volatile关键字时,bChanged==!bChanged判断的结果一直为false,不会执行任何输出。而当使用了volatile关键字修饰变量之后,会有输出出现。

这里只放有volatile的代码:

public class DirtyRead {

    public static volatile boolean bChanged = false;

    public static void main(String[] args) throws InterruptedException{
        new Thread(){
            public void run(){
                while(true){
                    if(bChanged == !bChanged){
                        System.out.println("bChanged == !bChanged");
                    }
                }
            }
        }.start();
        Thread.sleep(1);
        new Thread(){
            public void run(){
                while(true){
                    bChanged = !bChanged;
                }
            }
        }.start();
    }
}

执行后的输出:

bChanged == !bChanged
bChanged == !bChanged
bChanged == !bChanged
bChanged == !bChanged
···
···

做一个bChanged == !bChanged的判断竟然结果能为真,看起来很扯,但是其实这就是volatile这个关键字的作用。

两种特性:
1. 保证此变量对所有线程的可见性。即当某个线程修改了这个变量的值,这个新的值能够立刻同步到主内存中,且使其他线程中的缓存无效,其他线程使用这个值前会从主存刷新。(也就是说声明了volatile后,每次读变量的操作都是从内存中读,跳过了CPU cache)
2. 禁止指令重排序优化。

原理

处理器为了提高处理速度,不直接和内存进行通讯,而是会将系统内存的数据读到内部缓存(CPU L1L2 cache或者其他)之后再进行操作,操作完成后写回到内存的时间不确定。

对声明了volatile的变量进行写操作时,JVM会向处理器发送一条Lock前缀的指令(lock前缀的指令在多核处理器下会引发两件事,将当前处理器缓存行的数据写回到系统内存,这个写回内存的操作会引起在其他CPU里缓存了该内存地址的数据无效),将这个变量所在缓存行的数据写回系统内存。这一步就能够保证将对变量的修改更新到主存中。

但是这个时候其他处理器的缓存还是旧的,所以在多处理器的环境下,为了保证各个处理器缓存一致,每个处理器通过嗅探在总线上传播的数据来检查自己的缓存是否过期,当处理器发现自己缓存行对应的内存地址被修改了,就会将当前处理器的缓存行设置成无效状态,当处理器要对这个数据进行修改操作时,会强制重新从系统内存把数据堵到处理器缓存里。这一步保证了其他线程得到的变量都是新的。

这里参考了聊聊并发(一)深入分析Volatile的实现原理中对底层原理的描述,但是因为还是挺繁琐的,就自己简化了一下描述,想了解完整内容的可以去这个网址看一看。

缺陷

但是volatile只保证有序性和可见性,比起其他的同步方式例如synchronouse少了一个原子性。他只能保证在读取这个变量使用时能够得到最新的数据,由于没有原子性,并不能保证在使用的过程中值不变。比如在上面的那个例子中,bChanged == !bChanged有时会被判断为true,就是因为在java的指令执行时,先取得了第一个bChanged的值放进内存,然后取第二个bChanged的值,在这两次操作之中,bChanged的值在另一个线程中发生了改变,于是读取出来的值也就发生了变化,就出现了上面的那种情况。

为了更好地说明他不具备原子性,写了下面这个例子来说明:

public class VolatileDemo implements Runnable{

    volatile int i = 0;

    @Override
    public void run() {
        for(int j = 0; j < 10000; j++){
            i++;
        }
    }

    public static void main(String[] args) throws InterruptedException{
        VolatileDemo v = new VolatileDemo();
        Thread t1 = new Thread(v);
        Thread t2 = new Thread(v);
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        System.out.println("the value of i :"+v.i);
    }

}

执行结果:

the value of i :12630

虽然执行的结果有很大的随机性,但是多次执行以后发现这个值是远小于20000的。这就是因为i++这个操作不是原子操作,相当于i = i + 1,会进行取值、加一、赋值这几个操作,在他进入一次自增操作到自增结束的这段时间中,不会对主存中的数值进行修改,另一个线程在这个时间段中进行的自增操作读取的还是没有修改前的数值,导致这两次自增过后写入主存的是同一个值。就在报道上出现了偏差。

使用场景

由我的上一段话可以知道,volatile这个关键字只是一种轻量级的同步机制,并不具备锁的特性,虽然不会阻塞线程的执行,但是也没办法保证多线程之间的安全性。所以这个关键字具有和普通的锁不同的使用场景。

在需要使用volatile变量的地方,主要看重的是其简易性,在某些情况下,volatile变量要比使用相应的锁简单得多。其次是性能的原因,某些情况下,volatile变量同步机制的性能要优于锁。(之所以说是在“某些情况下”,是因为对于操作的具体开销,很难做出准确的评价,因为单纯进行volatile和synchronouse的比较是很难的。但是,因为我们能够了解到在大多数的处理器架构下,volatile的读操作开销非常低,写操作的开销更高,虽然还是比获取锁低。所以,当读操作的次数远大于写操作 的次数时,使用volatile是更好的选择)

使用volatile的场景的条件:
1. 对变量的写操作不依赖于当前值。
2. 该变量没有包含在具有其他变量的不变式中。

通过这两个条件能得出一个结论,volatile适合用作对于变量当前状态的判断上,不能用来做多个线程之间的计数器。

状态标志

在当一个变量用作布尔状态标志时,为了确保及时获得最新的变量值,可以使用volatile关键词,同时由于作为状态判断标志,读的次数是远大于写的,效率上也能够满足要求。

一次性安全发布(one-time safe publication)

在缺乏同步的情况下,可能会遇到某个对象引用的更新值(由另一个线程写入)和该对象状态的旧值同时存在。本文最上面的示例代码就发生了这种情况。这里就不得不提到著名的双重检查锁定(double-checked-locking)问题了。

双重检查模式一般是在需要对实例域使用延迟初始化时,为了兼顾性能能考虑使用的一种方式。《Effective Java》这本书中也提到了这个东西,所以我们这里就用书里给的例子来加以说明。

//延迟初始化的同步方法实现,这是实现起来最简单的一种
//Lazy initialization of instance field - synchronized accessor
private FieldType field;

synchronized FieldType getField(){
    if(field == null){
        field = computeFieldValue();
    }
    return field;
}

但是在性能上,由于每次使用getField()方法都需要加锁,访问这个实例的开销会很大。

为了优化性能,缩小同步的范围,从对方法的同步缩小到对代码块进行加锁同步。这就是要使用双重检查模式的场景了。

private FieldType field;

FieldType getField(){
    if(field == null){
        cynchronized(this){
            if(field == null)
                field = computeFieldValue();
        }
    }
    return field;
}

然而上面这段代码实际上是不能够有效工作的。不起作用的原因在于编译器对指令进行了重新排序,在这篇文章中不对此内容做更细致的探讨。

但是为了避免这种情况的出现,在进行第二次检查锁定的同时,增加一个赋值语句来抵消指令重排的影响。

//Double-check idiom for lazy initialization of instance fields
private FieldType field;

FieldType getField(){
    if(field == null){
        FieldType result;
        cynchronized(this){
            result = field;
            if(result == null)
                field = result = computeFieldValue();
        }
    }
    return field;
}

但是实际上这种方式也是不起作用的,这个就触及到我的知识盲区了,看别人的说法是同步规则的问题,现在的我也不是太懂,先挖个坑,以后有机会在填。

所以双重检查模式还是使用volatile关键字来解决,因为在jdk5以后,增强了volatile的语义,不允许volatile读操作与其后面的读写操作进行指令重排序。所以使用volatile实现的代码版本:

private volatile FieldType field;

FieldType getField(){
    if(field == null){
        cynchronized(this){
            if(field == null)
                field = computeFieldValue();
        }
    }
    return field;
}

独立观察(independent observation)

这种方式模式是定期“公布”观察结果供程序内部使用。例如,如下代码的功能是使用身份验证机制记忆最后一次登录用户的名字,并使用lastUser来引用发布值,供程序其他部分调用,使用volatile的作用可以保证值能够得到及时的更新。

public class UserManager {
    public volatile String lastUser;

    public boolean authenticate(String user, String password) {
        boolean valid = passwordIsValid(user, password);
        if (valid) {
            User u = new User();
            activeUsers.add(u);
            lastUser = user;
        }
        return valid;
    }
}

volatile bean模式

就是将JavaBean中的成员变量用volatile修饰,这样做的原因是很多框架为易变数据的持有者(例如HttpSession)提供了容器,但是放入这些容器中的对象必须是线程安全的。

开销较低的读写锁策略

当读操作远远多于写操作是,可以结合使用内部所和volatile变量来减少公共代码路径的开销。因为volatile不具有原子性,所以写操作用锁,而读操作只需要保证可见性,于是可以用volatile来实现。

@ThreadSafe
public class CheesyCounter {
    // Employs the cheap read-write lock trick
    // All mutative operations MUST be done with the 'this' lock held
    @GuardedBy("this") 
    private volatile int value;

    public int getValue() { return value; }

    public synchronized int increment() {
        return value++;
    }
}

这里部分内容来自正确使用Volatile变量,有兴趣的可以去原博探究一下详细内容。

最后

volatile作为一种轻量级的同步方式,能够使用的应用场景十分有限,而且在同步的实现上相对比其他方式更容易出现错误,然而在适当的场景做出合理的使用,仍旧是可以达到提高性能的作用。所以,对volatile有足够的了解,找到合适的场景,两者缺一不可。

猜你喜欢

转载自blog.csdn.net/Armour__r/article/details/79782811