【java多线程】从单例模式到Java内存模型

268 阅读7分钟

前言

兄弟姐妹们,今天开始继续讲Java多线程,不过今天不直接讲,先从设计模式中最常见的单例模式讲起,然后在讲Java的内存模型JMM等等。

单例模式

既然讲到了单例模式,我们就顺便复习一下中最基础的设计模式。 单例模式顾名思义,也就是方法类只会初始化一次,至于什么时机初始化就分为饿汉式和懒汉式,Spring的bean默认都是单例的。下面用代码讲讲。 最基本就是懒汉式,就是我啥也不管,直接赋值初始化。

public class LazySingleton {
	// 私有构造类,没有其他方式初始化
	private LazySingleton() {}
	
	private final static HungrySingleton instance = new LazySingleton();
	
	public static LazySingleton getInstance() {
		return instance;
	}
}

懒汉式有个缺点就是可能方法不会被调用,但是又消耗资源去初始化。有另一种就是调用的时候才会初始化,这也就叫饿汉式。

public class HungrySingleton {
	private static HungrySingleton instance;
	// 私有构造类,没有其他方式初始化
	private HungrySingleton () {}
	
	public static HungrySingleton getInstance() {
		if(null == instance) {
			instance = new HungrySingleton();
		}
		return instance;
	}
}

但是呢,按着上面这样写并发会有问题,如果初始化时多个请求同时进来的话,可能会类初始化多次,简单处理的话直接上个synchronized锁。直接加synchronized锁会有个问题,每次请求进来都会加锁,这样效率就低了,但是我只需要初始化的时候加锁就行,那么有另一种方法叫:双重检测同步延迟加载,代码如下。

public class DoudleCheckSingleton {
	private static DoudleCheckSingleton instance;
	
	// 私有构造类,没有其他方式初始化
	private DoudleCheckSingleton () {}
	
	public static DoudleCheckSingleton getInstance() {
		if(null == instance) {
			synchronized(DoudleCheckSingleton.class) {
				if(null == instance) {
					instance = new DoudleCheckSingleton();
				}
			}
		}
		return instance;
	}
}

到这里就万事大吉了吗,并不是,这个其实也是有问题的,由于Java内存模型JMM存在指令重排序的操作,初始化的过程中,instance已经不为null了,这样获取到的instance的实例是不完整的,在JDK5之后可以使用关键词volatile 解决这个问题,接下来我们来探讨这个问题。

Java内存模型

想要理解上面的问题,我们首先要理解下Java虚拟机定义的内存模型,也称作JMM。

操作系统内存模型

在现代操作系统这本书我们都学过,CPU读取计算的速度远远要超过内存,所以设计了高速缓存Cache,以提高计算速度,如下图。不过这也带来了“缓存一致性”问题,解决这个问题有很多协议,最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。 在这里插入图片描述 ## JMM内存模型 相同的,Java虚拟机为了屏蔽掉各种硬件和操作系统的内存访问差异,定义了一套可以兼容的Java内存模型,在JDK1.5(JSR-133)发布之后,这个就逐渐成熟起来。类比内存和高速缓存,JMM也分为主内存和工作内存,每个线程在工作内存内都有主内存拷贝而来的变量,线程对变量的操作都是在工作内存进行的,并不能直接操作主内存。 插一嘴,这里讲的主内存和工作内存只是个抽象概念,在物理上并不存在。硬要对应的话,主内存应该是Java堆中数据,工作内存应该存储在寄存器和高速缓存中。 在这里插入图片描述

内存间操作

既然线程对变量的操作都是在工作内存进行的,那么从主内存拷贝到工作内存,从工作内存同步到主内存是如何操作的呢?JMM定义了下面8个操作,同时这些操作是原子性的。

  • 锁定(lock) 在主内存给某个变量上锁,只能给某个线程操作
  • 解锁(unlock) 解锁顾名思义
  • 读取(read) 从主内存读取某个变量
  • 载入(load) 将主内存读取到的装载到工作内存
  • 使用(use) 当虚拟机执行需要使用某个变量(工作内存中)的字节码指令时,执行引擎会从工作内存取值
  • 赋值(assign) 当虚拟机执行赋值操作(对于工作内存中的变量)字节码指令时,执行引擎会刷新工作内存中的值
  • 存储(store) 将工作内存中变量转递给主内存
  • 写入(write) 将‘存储’到的变量值刷新主内存中对应的值。

如果工作内存想拷贝主内存的值,就要顺序执行readload指令,同理从工作内存同步到主内存也要顺序执行storewrite指令。同时这8个操作还要满足一定的规则,这里不赘述了,也好理解,总结起来就是,拷贝和同步的两个操作是一定要执行的,不会就执行一个操作;一个线程没有执行assign时是无法同步到主内存的。 关于锁的操作,规则如下。 在这里插入图片描述 重复执行lock对应的也就是可重入锁。第二点和第四点是重点,也是理解关键词volatile的关键,下次在讨论这个。 画个图,理解一下上面的八个操作。 在这里插入图片描述讨论到这里,我们就可以解决一个经典的问题:为什么i++多线程不安全呢?答案很简单,在没有任何处理下,多线程执行i++操作时,某个线程读取到i值并不是最新的,写入主内存就不是最新的,那么多线程执行1000次i++最后结果必然不会是1000。

指令重排序

计算机执行程序时,并不是按照代码写的顺序执行的,在最终结果一致的情况下,指令爱怎么排序就这么排序。这里就可以回到我们单例模式举的例子,为什么双重检测同步延迟加载线程不安全呢?其实类初始化时并不是简单的赋值语句,实际上虚拟机执行了多个指令操作,大致如下。

  1. 给Singleton的实例分配内存
  2. 初始化Singleton的构造器
  3. 将instance对象指向分配的内存空间
public class DoudleCheckSingleton {
	private static DoudleCheckSingleton instance;
	
	// 私有构造类,没有其他方式初始化
	private DoudleCheckSingleton () {}
	
	public static DoudleCheckSingleton getInstance() {
		if(null == instance) {
			synchronized(DoudleCheckSingleton.class) {
				if(null == instance) {
					// 类初始化
					instance = new DoudleCheckSingleton(); 
				}
			}
		}
		return instance;
	}
}

虚拟机执行类初始化时,并不是按照1、2、3顺序执行的,当发生指令重排序时,例如3、1、2,此时instance已经不为null了,举例线程A初始化时发生上述指令重排序时,时间片结束后,类初始化并没有完成,当线程B判断instance不为null,返回了一个不完整的类。

happens before原则

happens before原则可以说是最重要的原则,我看了很多篇博客也没整明白,看明白了再更新博客把。

打脸时刻

按照上面的理解,我自己写了个demo,我测试了很多遍,双重检测并没有失效,看了很多篇博客,看到一句才理解到这个原因。 测试代码以及执行结果如下: 在这里插入图片描述 我开了100个线程去执行程序,并不会出现这种现象,按照代码写的话,test的值应该可能为0才对,可是我执行了很多次,结果都一样,这让我产生了怀疑,然后找到了下面这句话,不知道这句话的可靠性但是能解释这种现象。 在这里插入图片描述在这里插入图片描述

小结

今天从单例模式出发,DCL问题引入到了JMM,也就是Java内存模型,中间涉及到了很多其他的知识,我总结得也不是很完善,希望大家批评指教。

参考资料

《Java并发编程的艺术》

《深入理解Java虚拟机:JVM高级特性与最佳实践》

单例模式与双重检测

用happen-before规则重新审视DCL

DCL的问题和解决方法