深入理解volatile的内存语义,并通过volatile写一个线程安全的单例

前言:

对缓存一致性协议以及内存屏障和happens-before原则不太了解的小伙伴建议先百度google维基一下或移步的我的另一篇博客 《浅谈缓存一致性原则和Java内存模型(JMM)》之后再看这篇博客更好理解哦 在研究Java并发包之前一直以为volatile关键字只是一个打辅助的,之后发现J.U.C包的底层就是依靠volatile关键字和CAS实现的。那我们现在就来看一看volatile关键字吧~

一.volatile写/读的内存语义

**volatile写的内存语义:** 当写一个变量的时候,JMM会把该线程的私有内存中的共享变量值更新到主内存中,并将其他线程中的值置为无效的; **volatile读的内存语义:** 当读一个变量的时候,JMM会先判断是否私有空间内的值是否失效,若失效,线程接下来会从主存中读取变量。 **总结一下volatile的写/读语义:** 当一个线程对另外一个volatile关键字修饰的共享变量x进行了写操作,那么在别的线程读到这个共享变量x的时候读到的值一定是更新过的有效值。 如果将这个过程模拟一下可以认为是线程间的一个线程通信,在线程A进行了volatile变量的写操作后,告知B线程该变量的值已经失效,需要从主内存中去读: ![这里写图片描述](https://img-blog.csdn.net/20180806204223553?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Nkcl96ZA==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70) 但是由于JMM是以**共享变量方式**进行通信的,不能通过消息传递的方式实现,接下来看一下JMM是如何保证volatile的内存语义的。

二.volatile的内存语义的实现方式

1.通过缓存一致性原则

线程A对共享变量x进行了一次写操作,此时线程B要读这个变量,需要保证线程间的可见性。JMM通过缓存一致性协议保证线程间的可见性(想深入了解缓存一致性协议的朋友可以看一下我之前的一片博客了解一下缓存一致性协议) 总的来说,volatile关键字修饰的共享变量转为汇编代码会有一条带有lock修饰的指令。这条指令在底层是通过“缓存锁定”的方式保证线程修改的值会立即刷新道主存中,处理器的“嗅探”技术保证了只要有一个线程对内存中的值进行了修改,就将缓存中的更新前的值置为失效,从而保证了线程间的可见性。

2.通过内存屏障方式

在编译过程中,编译器和处理器会对我们的代码进行一定的优化措施比如指令重排序从而达到提高代码的执行效率的目的。在指令重排序中就可能使得读操作会跑到写操作前面而出现数据“脏读”的现象,从Java源代码到最终实际执行的指令序列,会经历下面三种重排序: ![这里写图片描述](https://img-blog.csdn.net/20180806212331176?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Nkcl96ZA==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70) 1属于编译器重排序,2和3属于处理器重排序。对于编译器重排序,JMM的编译器会禁止特定类型的编译器重排序,为程序员提供一致的内存可见性保证。为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。 下表是JMM针对编译器制定的volatile重排序规则表: ![这里写图片描述](https://img-blog.csdn.net/2018080622045733?watermark/2/text/aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3Nkcl96ZA==/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70) 从表中可以看到,
  • 当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
  • 当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
  • 当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。

为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。内存屏障有四种,JMM可以让这四种内存屏障都插入(保守策略,对优化有很多没有必要的限制),但是这样效率会很低,结合上表以及在volatile写后插入StoreLoad内存屏障的功能是对volatile的写和之后的读写操作都不会进行重排序,JMM选择了采用在volatile写后插入一个StoreLoad内存屏障,并且StoreLoad屏障会让写入的值立即刷新到主存中。因为volatile写-读内存语义的常见使用模式是:一个写线程写volatile变量,多个读线程读同一个volatile变量。当读线程的数量大大超过写线程时,选择在volatile写之后插入StoreLoad屏障将带来可观的执行效率的提升。
处理器也可以对重排序和内存屏障进行优化,比如X86的处理器只会对写-读操作进行重排序,所以对于x86计算机来说,会自动省略掉除StoreLoad之外的三中内存屏障。
深入理解volatile的内存语义,并通过volatile写一个线程安全的单例_第1张图片
从这可以看出来,通过以上两种方式就可以保证volatile关键字修饰的变量在多线程并发环境中不会出现读写错误,可以保证线程间可见性和有序性,可见性主要是通过缓存一致性协议保证的,有序性则主要是通过内存屏障保证的。但是volatile关键字是不能保证原子性的,只能保证在读写单操作时是原子性的,比如一个自增操作就是一个读+计算+写三步完成的,所以说不能保证原子性。

三.volatile写一个线程安全的单例模式(懒汉式)

先看一个什么线程安全机制都没有的单例:
public class UnsafeLazyInitialization {
	private static Instance instance;
	public static Instance getInstance() {
		if (instance == null)      // 1
			instance = new Instance(); // 2
	return instance;        // 3
	}
}

比如有两个线程都在执行该方法,可能出现【这只是一种可能】

A线程执行1
B线程执行1
A线程执行2
A线程执行3
B线程执行2
B线程执行3

这样就会拿到两个不同的实例,这并不是单例想要的。
这就出现了第二种:写一个getInstance()用来获取单例,在这个方法上使用synchronized关键字加锁

public class SafeLazyInitialization {
	private static Instance instance;
	public synchronized static Instance getInstance() {
		if (instance == null)
			instance = new Instance();
		return instance;
	}
}

这虽然满足了单例的要求,但是如果有大量的线程都在获取这个实例,无论这个单例是否已经被初始化过,都要进行加锁释放锁的操作,那效率就太低啦
之后出现了第三种:双重检查锁定(Double-Checked Locking)

public class DoubleCheckedLocking {
	private static Instance instance;
	public static Instance getInstance() { // 1
		if (instance == null) { // 2:第一次检查
			synchronized (DoubleCheckedLocking.class) { // 3:加锁
				if (instance == null) // 4:第二次检查
					instance = new Instance(); // 5:创建一个Instance实例
			}
		}
		return instance;
	}
}

这是,调用getInstance方法时,如果发现单例已经初始化过了,就直接返回,如果没有这个对象才去获得锁然后创建这个单例。拿到锁之后还是需要二次判断,因为可能在线程A进行4操作时,线程B刚拿到锁正在创建对象,随后线程A去拿锁时线程B创建了所需的实例并释放了锁。
这时候可能会觉得已经大功告成了,但是因为会有指令重排序,new Instance操作分为以下三步:

memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory);  // 2:初始化对象
instance = memory;  // 3:设置instance指向刚分配的内存地址

在某些JIT编译器上可能会对2和3进行重排序:

memory = allocate();  // 1:分配对象的内存空间
instance = memory;  // 3:设置instance指向刚分配的内存地址,此时没有初始化!
ctorInstance(memory);  // 2:初始化对象

Java语言规范明确指出,重排序不能导致单线程情况下的执行结果与原先不同,将2和3重排序会提高性能,但是在多线程情况下如果对2和3进行重排序可能会出现一下情况:
深入理解volatile的内存语义,并通过volatile写一个线程安全的单例_第2张图片
在这种情况下,线程B就会拿到一个null对象。
那么现在就有两种解决方法:
1.不允许2和3重排序
2.不允许别的线程看到2和3的重排序
根据volatile的有序性可以保证2和3在线程间不被重排序:

public class SafeDoubleCheckedLocking {
	private volatile static Instance instance;
	public static Instance getInstance() {
		if (instance == null) {
			synchronized (SafeDoubleCheckedLocking.class) {
				if (instance == null)
					instance = new Instance(); // instance为volatile
			}
		}
		return instance;
	}
}

深入理解volatile的内存语义,并通过volatile写一个线程安全的单例_第3张图片
这样就不会出现判断instance不为null,但还没有为对象分配空间而导致的安全问题了。通过volatile修饰共享变量达到禁止指令重排从而保证线程安全的方式。还有另外一个让别的线程看不到的方式可以采用静态内部类方式保证,这里就不多说啦~


注:本文参考《Java并发编程的艺术》

你可能感兴趣的:(#,Java编程,#,细说Java并发编程)