• 关于Volatile(二)


    一.Volatile的使用

    1、防止重排序

    我们从一个最经典的例子来分析重排序问题。大家应该都很熟悉单例模式的实现,而在并发环境下的单例实现方式,我们通常可以采用双重检查加锁(DCL)的方式来实现。

    其源码如下:

    package com.paddx.test.concurrent;
    
    public class Singleton {
    public static volatile Singleton singleton; /** * 构造函数私有,禁止外部实例化 */ private Singleton() {}; public static Singleton getInstance() { if (singleton == null) { synchronized (singleton) { if (singleton == null) { singleton = new Singleton(); } } } return singleton; } }

    分析一下为什么要在变量singleton之间加上volatile关键字。要理解这个问题,先要了解对象的构造过程,实例化一个对象其实可以分为三个步骤:

    (1)分配内存空间。

    (2)初始化对象。

    (3)将内存空间的地址赋值给对应的引用。

    但是由于操作系统可以对指令进行重排序,所以上面的过程也可能会变成如下过程:

    (1)分配内存空间。

    (2)将内存空间的地址赋值给对应的引用。

    (3)初始化对象

    如果是这个流程,多线程环境下就可能将一个未初始化的对象引用暴露出来,从而导致不可预料的结果。

    因此,为了防止这个过程的重排序,我们需要将变量设置为volatile类型的变量。

    2、实现可见性

    可见性问题主要指一个线程修改了共享变量值,而另一个线程却看不到。引起可见性问题的主要原因是每个线程拥有自己的一个高速缓存区——线程工作内存。

    volatile关键字能有效的解决这个问题,看下下面的例子,就可以知道其作用:

    package com.paddx.test.concurrent;
    
    public class VolatileTest {
    int a = 1; int b = 2; public void change(){ a = 3; b = a; } public void print(){ System.out.println("b="+b+";a="+a); } public static void main(String[] args) { while (true){ final VolatileTest test = new VolatileTest(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test.change(); } }).start(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test.print(); } }).start(); } } }

    直观上说,这段代码的结果只可能有两种:b=3;a=3 或 b=2;a=1。不过运行上面的代码(可能时间上要长一点),会发现除了上两种结果之外,还出现了第三种结果:

    ......
    b=2;a=1
    b=2;a=1
    b=3;a=3
    b=3;a=3
    b=3;a=1
    b=3;a=3
    b=2;a=1
    b=3;a=3
    b=3;a=3
    ......

    为什么会出现b=3;a=1这种结果呢?正常情况下,如果先执行change方法,再执行print方法,输出结果应该为b=3;a=3。

    相反,如果先执行的print方法,再执行change方法,结果应该是 b=2;a=1。那b=3;a=1的结果是怎么出来的?

    原因就是第一个线程将值a=3修改后,但是对第二个线程是不可见的,所以才出现这一结果。

    如果将a和b都改成volatile类型的变量再执行,则再也不会出现b=3;a=1的结果了。

    3、保证原子性

    关于原子性的问题,上面已经解释过。volatile只能保证对单次读/写的原子性。这个问题可以看下JLS中的描述:

    17.7 Non-Atomic Treatment of double and long
    For the purposes of the Java programming language memory model, 
    a single write to a non
    -volatile long or double value is treated as two separate writes: one to each 32-bit half.
    This can result in a situation where a thread sees the first 32 bits of a 64-bit value from one write, and the second 32 bits from another write. Writes and reads of volatile long and double values are always atomic. Writes to and reads of references are always atomic, regardless of whether they are implemented as 32-bit or 64-bit values. Some implementations may find it convenient to divide a single write action on a 64-bit long or double value into two write actions on adjacent 32-bit values.
    For efficiency's sake,
    this behavior is implementation-specific; an implementation of the Java Virtual Machine is free to perform writes to long and double values atomically or in two parts. Implementations of the Java Virtual Machine are encouraged to avoid splitting 64-bit values where possible.
    Programmers are encouraged to declare shared 64-bit values as volatile or synchronize their programs correctly to avoid possible complications.

    这段话的内容跟前面的描述内容大致类似。因为long和double两种数据类型的操作可分为高32位和低32位两部分,因此普通的long或double类型读/写可能不是原子的。

    因此,鼓励大家将共享的long和double变量设置为volatile类型,这样能保证任何情况下对long和double的单次读/写操作都具有原子性。

    关于volatile变量对原子性保证,有一个问题容易被误解。现在通过下列程序来演示一下这个问题:

    package com.paddx.test.concurrent;
    
    public class VolatileTest01 {
    volatile int i; public void addI(){ i++; } public static void main(String[] args) throws InterruptedException { final VolatileTest01 test01 = new VolatileTest01(); for (int n = 0; n < 1000; n++) { new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test01.addI(); } }).start(); } Thread.sleep(10000);//等待10秒,保证上面程序执行完成 System.out.println(test01.i); } }

    大家可能会误认为对变量i加上关键字volatile后,这段程序就是线程安全的。大家可以尝试运行上面的程序。

    下面是我运行的结果:

    981

    可能每个人运行的结果不相同。不过应该能看出,volatile是无法保证原子性的(否则结果应该是1000)。原因也很简单,i++其实是一个复合操作,包括三步骤:

    (1)读取i的值。

    (2)对i加1。

    (3)将i的值写回内存。

    volatile是无法保证这三个操作是具有原子性的,可以通过AtomicInteger或者Synchronized来保证+1操作的原子性。

    注:上面几段代码中多处执行了Thread.sleep()方法,目的是为了增加并发问题的产生几率,无其他作用。

    把上面的代码改成以下任何一种都可以达到效果:

    采用synchronized:

    package com.paddx.test.concurrent;
    
    public class VolatileTest01 {
    
        volatile int i;
    
        public synchronized void addI(){
            i++;
        }
    
        public static void main(String[] args) throws InterruptedException {
            final  VolatileTest01 test01 = new VolatileTest01();
            for (int n = 0; n < 1000; n++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            Thread.sleep(10);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        test01.addI();
                    }
                }).start();
            }
    
            Thread.sleep(10000);//等待10秒,保证上面程序执行完成
    
            System.out.println(test01.i);
        }
    }

    采用Lock:

    package com.paddx.test.concurrent;
    
    public class VolatileTest01 {
    
        volatile int i;
    Lock lock = new ReentrantLock();
    public void addI(){ lock.lock(); try { i++; } finally{ lock.unlock(); } } public static void main(String[] args) throws InterruptedException { final VolatileTest01 test01 = new VolatileTest01(); for (int n = 0; n < 1000; n++) { new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(10); } catch (InterruptedException e) { e.printStackTrace(); } test01.addI(); } }).start(); } Thread.sleep(10000);//等待10秒,保证上面程序执行完成 System.out.println(test01.i); } }

    采用AtomicInteger:

    package com.paddx.test.concurrent;
    
    public class VolatileTest01 {
    
        public  AtomicInteger inc = new AtomicInteger();
    
        public void addI(){
           inc.getAndIncrement();
        }
    
        public static void main(String[] args) throws InterruptedException {
            final  VolatileTest01 test01 = new VolatileTest01();
            for (int n = 0; n < 1000; n++) {
                new Thread(new Runnable() {
                    @Override
                    public void run() {
                        try {
                            Thread.sleep(10);
                        } catch (InterruptedException e) {
                            e.printStackTrace();
                        }
                        test01.addI();
                    }
                }).start();
            }
    
            Thread.sleep(10000);//等待10秒,保证上面程序执行完成
    
            System.out.println(test01.i);
        }
    }

    在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、

    以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

    二.Volatile的使用优化

    著名的Java并发编程大师Doug lea在JDK7的并发包里新增一个队列集合类LinkedTransferQueue,他在使用Volatile变量时,用一种追加字节的方式来优化队列出队和入队的性能。

    追加字节能优化性能?这种方式看起来很神奇,但如果深入理解处理器架构就能理解其中的奥秘。

    先来看看LinkedTransferQueue这个类,它使用一个内部类类型来定义队列的头队列(Head)和尾节点(tail),

    而这个内部类PaddedAtomicReference相对于父类AtomicReference只做了一件事情,就将共享变量追加到64字节。

    我们可以来计算下,一个对象的引用占4个字节,它追加了15个变量共占60个字节,再加上父类的Value变量,一共64个字节。

    /** head of the queue */
    private transient final PaddedAtomicReference<QNode> head;
    
    /** tail of the queue */
    private transient final PaddedAtomicReference<QNode> tail;
    
    static final class PaddedAtomicReference <T> extends AtomicReference <T> {
    
        // enough padding for 64bytes with 4byte refs
        Object p0, p1, p2, p3, p4, p5, p6, p7, p8, p9, pa, pb, pc, pd, pe;
    
        PaddedAtomicReference(T r) {
            super(r);
        }
    }
    
    public class AtomicReference <V> implements java.io.Serializable {
        private volatile V value;
      //省略其他代码
    }

    为什么追加64字节能够提高并发编程的效率呢? 因为对于英特尔酷睿i7,酷睿, Atom和NetBurst, Core Solo和Pentium M处理器的L1,L2或L3缓存的高速缓存行是64个字节宽,

    不支持部分填充缓存行,这意味着如果队列的头节点和尾节点都不足64字节的话,处理器会将它们都读到同一个高速缓存行中,在多处理器下每个处理器都会缓存同样的头尾节点,

    当一个处理器试图修改头接点时会将整个缓存行锁定,那么在缓存一致性机制的作用下,会导致其他处理器不能访问自己高速缓存中的尾节点,

    而队列的入队和出队操作是需要不停修改头接点和尾节点,所以在多处理器的情况下将会严重影响到队列的入队和出队效率。

    Doug lea使用追加到64字节的方式来填满高速缓冲区的缓存行,避免头接点和尾节点加载到同一个缓存行,使得头尾节点在修改时不会互相锁定。

    那么是不是在使用Volatile变量时都应该追加到64字节呢?不是的。在两种场景下不应该使用这种方式。

    第一:缓存行非64字节宽的处理器,如P6系列和奔腾处理器,它们的L1和L2高速缓存行是32个字节宽。

    第二:共享变量不会被频繁的写。因为使用追加字节的方式需要处理器读取更多的字节到高速缓冲区,这本身就会带来一定的性能消耗,

              共享变量如果不被频繁写的话,锁的几率也非常小,就没必要通过追加字节的方式来避免相互锁定。

  • 相关阅读:
    浏览器内核
    前端必读:浏览器内部工作原理
    原生ajax
    MySQL数据备份之mysqldump使用
    Es6里面的解析结构
    zabbix 自定义key与参数Userparameters监控脚本输出
    nagios 在nrpe中自定义脚本
    nagios client 端的安装配置 以及 svr端对应的配置(转)
    nagios-4.0.8 安装部署
    zabbix 主动模式和被动模式配置文件对比
  • 原文地址:https://www.cnblogs.com/ZJOE80/p/12874600.html
Copyright © 2020-2023  润新知