深入浅出Java Volatile

volatile在java中很常见,比如懒汉式单例。那为什么单例模式要加volatile呢?加volatile究竟有什么用呢?现在我们深入剖析一下volatile关键字

深入浅出Volatile(眼见为实篇)

  • volatile 内存可见性与指令重排序
    • 1 内存可见性
      • 1.1 什么是内存可见性
      • 1.2 volatile 关键字解决内存同步问题
    • 2 指令重排序
      • 2.1 什么是指令重排序
      • 2.2 volatile如何解决指令重排序
    • 3 DCL单例需不需要加volatile?
      • 3.1 DCL单例模式
      • 3.2 对象的创建过程(原因)
    • 4 总结

volatile 内存可见性与指令重排序

1 内存可见性

1.1 什么是内存可见性

Java 内存模型规定,对于多个线程共享的变量,存储在主内存当中每个线程都有自己独立的工作内存,并且线程只能访问自己的工作内存,不可以访问其它线程的工作内存工作内存中保存了主内存中共享变量的副本,线程要操作这些共享变量,只能通过操作工作内存中的副本来实现,操作完毕之后再同步回到主内存当中,其 JVM 模型大致如下图。

深入浅出Java Volatile_第1张图片

JVM 模型规定:1) 线程对共享变量的所有操作必须在自己的内存中进行,不能直接从主内存中读写; 2) 不同线程之间无法直接访问其它线程工作内存中的变量,线程间变量值的传递需要通过主内存来完成。

这样的规定可能导致的后果是:线程对共享变量的修改没有即时更新到主内存,或者线程没能够即时将共享变量的最新值同步到工作内存中,从而使得线程在使用共享变量的值时,该值并不是最新的。这就引出了内存可见性。

内存可见性(Memory Visibility)是指当某个线程正在使用对象状态,而另一个线程在同时修改该状态,需要确保当一个线程修改了对象状态后,其他线程能够看到发生的状态变化

可见性错误是指当读操作与写操作在不同的线程中执行时,我们无法确保执行读操作的线程能适时地看到其他线程写入的值,有时甚至是根本不可能的事情。

通过一个小程序,了解一下内存可见性的重要性。

public class TestVolatile {
	public static void main(String[] args) {
		ThreadDemo td = new ThreadDemo();
		new Thread(td).start();
		
		while(true){	//读到的一直是flag = false
			if(td.isFlag()){
				System.out.println("------------------");
				break;
			}
		}
		
	}
}

class ThreadDemo implements Runnable {

	private boolean flag = false;

	@Override
	public void run() {
		
		try {
			Thread.sleep(200);
		} catch (InterruptedException e) {
		}

		flag = true;
		
		System.out.println("flag=" + isFlag());

	}
	public boolean isFlag() {
		return flag;
	}
	public void setFlag(boolean flag) {
		this.flag = flag;
	}
}
//输出:
//flag=true
//注意没有输出"----------"
//即使 flag已经改成了true,但是主线程中的flag其实一直是false
//(工作内存中flag=false)

1.2 volatile 关键字解决内存同步问题

Java 提供了一种稍弱的同步机制,即 volatile 变量,用来确保将变量的更新操作通知到其它线程。当把共享变量声明为 volatile 类型后,线程对该变量修改时会将该变量的值立即刷新回主内存,同时会使其它线程中缓存的该变量无效,从而其它线程在读取该值时会从主内中重新读取该值(参考缓存一致性)。因此在读取 volatile 类型的变量时总是会返回最新写入的值。

volatile屏蔽掉了JVM中必要的代码优化(指令重排序),所以在效率上比较低

//上面的小程序如果这样设置:	
private volatile boolean flag = false;
//输出结果:
flag=true
------------------

2 指令重排序

2.1 什么是指令重排序

Java语言规范JVM线程内部维持顺序化语义,即只要程序的最终结果与它顺序化情况的结果相等,那么指令的执行顺序可以与代码逻辑顺序不一致,这个过程就叫做指令的重排序

指令重排序的意义:使指令更加符合CPU的执行特性,最大限度的发挥机器的性能,提高程序的执行效率。

简而言之,指令重排序就是CPU的优化过程(指令重排序不是一无是处的)

深入浅出Java Volatile_第2张图片

  • 同样,眼见为实,乱序排序的证明:

    如果没有指令重排序,不可能出现x=0&&y=0

    package com.mashibing.jvm.c3_jmm;
    
    public class T04_Disorder {
        private static int x = 0, y = 0;
        private static int a = 0, b =0;
    
        public static void main(String[] args) throws InterruptedException {
            int i = 0;
            for(;;) {
                i++;
                x = 0; y = 0;
                a = 0; b = 0;
                Thread one = new Thread(new Runnable() {
                    public void run() {
                        //由于线程one先启动,下面这句话让它等一等线程two. 读着可根据自己电脑的实际性能适当调整等待时间.
                        //shortWait(100000);
                        a = 1;
                        x = b;
                    }
                });
    
                Thread other = new Thread(new Runnable() {
                    public void run() {
                        b = 1;
                        y = a;
                    }
                });
                one.start();other.start();
                one.join();other.join();
                String result = "第" + i + "次 (" + x + "," + y + ")";
                if(x == 0 && y == 0) {
                    System.err.println(result);
                    break;
                } else {
                    //System.out.println(result);
                }
            }
        }
    
    
        public static void shortWait(long interval){
            long start = System.nanoTime();
            long end;
            do{
                end = System.nanoTime();
            }while(start + interval >= end);
        }
    }
    

    运行结果:

    12985(0,0

    结果显示:第12985次出现了x=0&&y=0的情况(我反正运行这个程序等了很久),所以有指令重排序现象发生。

    但是多线程会出现线程安全问题,所以要禁止指令重排序。(为什么指令重排序会出现线程安全问题?这也是十分重要的问题,我们第3节会细讲)

2.2 volatile如何解决指令重排序

  1. java源码对变量加volatile

  2. JVM加内存屏障。而JVM是跑在操作系统上的,那么底层是怎么实现的?

  3. hotspot实现 lock; addl
    观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令。lock前缀指令其实就相当于一个内存屏障。内存屏障是一组处理指令,用来实现对内存操作的顺序限制。volatile的底层就是通过内存屏障来实现的。

orderaccess_linux_x86.inline.hpp

inline void OrderAccess::fence() {
  if (os::is_MP()) {
    // always use locked addl since mfence is sometimes expensive
#ifdef AMD64
    __asm__ volatile ("lock; addl $0,0(%%rsp)" : : : "cc", "memory");
#else
    __asm__ volatile ("lock; addl $0,0(%%esp)" : : : "cc", "memory");
#endif
  }
}

Lock多用于在多处理器中执行指令时对共享内存的独占使用。

它的作用是能够将当前处理器对应缓存的内容删除到内存,并使其他处理器对应的缓存失效。

另外还提供了有序的指令无法越过这个内屏障的作用。


3 DCL单例需不需要加volatile?

3.1 DCL单例模式

饿汉模式(没有啥花里胡哨的)

public class Girlfiriend extends Friend{
    private static final Girlfiriend GF=new Girlfiriend ();
    private Girlfiriend (){
    }
    public static Girlfiriend getGirlfriend(){
        return GF;  
    }
}

懒汉模式

public class Girlfiriend extends Friend{
    private static final Girlfiriend GF;
    private Girlfiriend (){
    }
    public static synchronized Girlfiriend getGirlfriend(){
        if(GF==null){
            GF = new Girlfriend();
        }
        return GF;  
    }
}

但是这种做法有一个缺点,不管是不是已经存在实例了,都会被锁阻塞。

妄图通过减少同步代码块的方式提高效率,实际上不可行:

public class Girlfiriend extends Friend{
    private static volatile Girlfiriend GF;
    private Girlfiriend (){
    }
    public static Girlfiriend getGirlfriend(){
        if(GF==null){//第一个线程判断为null,进来;第二个线程判断也是null,进来
            //第一个线程锁住,创建了新的对象。----第二个线程获得锁对象,又新创建了一个对象
            synchronized(Girlfiriend.class){
                GF = new Girlfriend();
            } //第一个线程执行完,释放锁对象,第二个线程执行;
        }
        return GF; 
    }
}

改进:双重检验锁

public class Girlfiriend extends Friend{
    private static final Girlfiriend GF;
    private Girlfiriend (){
    }
    public static Girlfiriend getGirlfriend(){
        if(GF==null){//第一个线程判断为null,进来;第二个线程判断也是null,进来
            //第一个线程锁住,创建了新的对象。----第二个线程获得锁对象
            synchronized(Girlfiriend.class){
                if(GF==null){//第一个线程再次判断是否为null,为null创建新对象;--第二个线程判断是否为null
                    GF = new Girlfriend();
                }
            } //第一个线程执行完,释放锁对象,第二个线程执行;
        }
        return GF; 
    }
}

所以为什么要加 volatile 呢?为什么指令重排序会产生问题呢?下面从对象创建过程来形象理解一下。

3.2 对象的创建过程(原因)

类加载过程就不展开了,简而言之就是:加载、链接(验证、准备、解析)、初识化过程。注:解析往往是发生在初始化之后的。
如果你对类加载过程不了解,可以看我之前写的文章:JVM类加载子系统
深入浅出Java Volatile_第3张图片

下面这幅图就很好的说明了指令重排序会出现线程安全问题:
深入浅出Java Volatile_第4张图片
准备、初识化、解析——》准备、解析、初识化


4 总结

volatile关键字最主要的作用是:

  1. 保证变量的内存可见性
  2. 局部阻止重排序的发生

可以将 volatile 看做一个轻量级的锁,但是又与锁有些不同:

  1. 对于多线程,不是一种互斥关系
  2. 不能保证变量状态的“原子性操作”

原子性指一个操作不能被打断,要么全部执行完毕,要么不执行。


参考B站马士兵:《Java多线程与高并发》

你可能感兴趣的:(JVM,Java)