大家好,我是Bivin,最近在重温并发编程方面的内容,发现有的小伙伴还是对并发编程中的原子性、可见性、有序性等问题不是很了解,这几个问题不仅是学习并发编程非常重要的基础内容,而且是近几年后端工程师面试的重点,今天Bivin就熬夜给大家认真聊聊有序性问题吧(现在晚上11点多了), 学完面试官交给你面试两天!对了,喜欢的话可以关注Bivin,后面会持续分享并发编程相关的内容哦!开干!
有序性问题
有序性的概念
有序性指的是程序能够按照代码的顺序来执行,而不会出现跳过代码或者指令执行的情况,比如当编写的代码被编译为CPU指令之后,按照第一条指令、第二条指令、第三条指令.....这样的顺序执行,这就是指令执行的有序性。
产生有序性问题的原因
计算机为了提高程序的编译性能和执行性能,可能就会将指令的执行顺序打乱,比如原来的指令顺序为 1 -> 2 -> 3,优化过后的指令顺序可能就变成了1 -> 3 -> 2,在单线程情况下,可以保证执行顺序被打乱的指令和执行顺序未被打乱的指令执行结果是一样的,但是在多线程情况下就没法保证结果一样了,可能会出现意想不到的问题,比如创建单例对象,在并发情况下就会由于指令顺序被打乱,导致创建多个实例,增加了资源的开销甚至引起意想不到的Bug。
有序性问题典型案例:单例模式多重检测加锁机制
谈到单例模式多重检测加锁机制就不得不说一下单例模式的相关内容了
创建型模式的概念
创建型模式指的就是将对象的创建和使用分离,在使用对象时无需关心对象的创建细节,从而降低系统的耦合度,让设计方案更加易于修改和扩展。
单例模式的概念
单例模式即一个类的对象实例只能创建一个的设计模式,这个类的对象在内存中只会有一个,并提供一个全局的访问点。
既然只能有一个对象实例,那么就不能让外部类new这个类,所以构造方法只能是私有的,一旦构造方法是私有的,那么创建实例对象这个工作就只有类自己来完成了,所以必须是自己创建自己的实例,且创建的实例是供外部使用的,所以还得把自己创建的实例提供给其他类使用。
单例模式存在的意义
- 保证一个类仅有一个实例对象,并提供一个访问它的全局访问点。
- 避免频繁的创建和销毁对象实例,节省系统资源
单例模式的特点
- 单例类只能有一个实例
- 单例类必须自己创建自己的实例
- 单例类必须发给所有其他对象提供这一实例
简单实现单例模式
java
public class Singleton {
/**
* <p> 定义一个私有的静态成员变量 </p>
**/
private static Singleton singleton = null;
/**
* <p> 创建一个私有的构造方法 </p>
**/
private Singleton() {}
/**
* <p> 创建单例对象 </p>
**/
public static Singleton getSingleton() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
该方式实现单例模式的问题
单线程情况下这种方式没有问题,但是在多线程情况下会创建多个对象实例,违背单例模式的初衷。
出现创建多个对象实例的原因 :
我们使用两个线程解释一下为什么会导致创建了多个实例,假设这两个线程分别是A和B。
- 线程A和线程B同时进入if(singleton == null)的判断,且拿到的结果都是true。
- 线程A开始执行 new Singleton(),但是由于网络或者其他原因,线程A实例化对象需要一些时间,此时线程B也开始执行new Singleton()并且很快完成了实例化对象的工作,内存中有了第一个Singleton的对象实例。
- 然后线程A在线程也完成了实例化对象的工作,内存中出现第二个Singleton的对象实例。
如何解决多线程环境下单例模式的问题?
懒汉式单例类
原理:懒汉式创创建单例类需要使用synchronized关键字修饰创建单例类的方法,保证每次只有一个线程能获取到锁并执行创建单例类的程序,保证了多线程环境下对象实例的唯一性。
java
public class Singleton {
/**
* <p> 定义一个静态成员变量 </p>
**/
private static Singleton singleton = null;
/**
* <p> 创建一个私有的构造方法 </p>
**/
private Singleton() {}
/**
* <p> 创建单例对象</p>
**/
synchronized public static Singleton getSingleton() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
}
优点 :无需在类加载的时候就创建对象实例,是一种按需加载的方式,即用到再创建,从资源利用的角度相比饿汉式资源利用率要高很多。
缺点:性能低,在多线程情况下,比如一个线程首先获取到了synchronized锁,进入临界区执行代码,那么其他线程就只能在临界区外阻塞等待,直到最先获取到锁的线程执行完程序并释放锁,其他线程才有机会获取到锁并进入临界区执行程序。这样就会导致高并发情况下系统性能大大降低,严重的甚至拖垮系统。不推荐!
饿汉式单例类
原理:在定义静态成员变量的时候就实例化单例类,所以在类加载的时候就创建了单例对象。当类被加载的时候,静态变量singleton会被初始化,此时会调用私有的构造函数进行单例类的初始化工作。这样就避免了再高并发环境下多个线程创建了多个实例对象的情况。
java
public class Singleton {
/**
* <p> 定义一个静态成员变量,实例化单例类 </p>
**/
private static Singleton singleton = new Singleton();
/**
* <p> 创建一个私有的构造方法 </p>
**/
private Singleton() {
}
/**
* <p> 创建单例对象</p>
**/
public static Singleton getSingleton() {
return singleton;
}
}
优点 :能避免多线程环境下创建多个实例对象的问题,代码实现简单,且从调用速度和反应速度角度来讲,都要优于懒汉模式。
缺点:由于单例类在类加载时就会被创建,不管用不用都会存在内存中,从资源利用率角度来讲,饿汉式不及懒汉式,而且在系统加载时由于饿汉式需要创建单例类,所以加载速度可能会比较慢。
如何既解决线程安全问题又不影响系统性能呢?
双重检测锁机制
java
public class Singleton {
/**
* <p> 定义一个静态成员变量 </p>
**/
private static Singleton singleton = null;
/**
* <p> 创建一个私有的构造方法 </p>
**/
private Singleton() {}
/**
* <p> 创建单例对象 </p>
**/
public static Singleton getSingleton() {
// 第一重检测
if (singleton == null) {
// 加锁,保证同一时刻只有一个线程能获取到Singleton资源
synchronized (Singleton.class) {
// 第二重检测
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
这种方式看似是没什么问题了,其实不然,单线程情况下确实没得问题,但是在多线程情况下,由于计算机为了提高编译性能会对程序指令进行重排优化,所以就可能会导致获取到的对象实例为空且不可用的问题发生。
为什么会出现这种问题?
对于上面的双重检测代码其实包括三个步骤,正常情况下应该是按照顺序执行的,顺序如下图所示:
但是由于CPU会对程序进行指令重排 ,所以顺序可能就会变为 分配内存空间 -> 将sigleton引用指向内存空间 -> 初始化对象,此时就会出现问题,步骤如下图所示:
我们使用两个线程解释一下为什么双重检测加锁机制还是会出现问题,假设这两个线程分别是A和B。
第一种情况:
- 线程A和线程B同时进入if(singleton == null)的判断。
- 假设线程A先获取到synchrionized锁,进入到synchrionized代码块,判断if (singleton == null)为true。
- 线程A在执行singleton = new Singleton()时,会在JVM中开辟一块空白的内存空间。
- 然后将singleton的引用指向内存空间,在没有进行对象初始化之前,发生了线程切换,线程A释放synchronized锁。
- 这时候等待的线程B获取到了锁,判断出singleton不为null,于是就没有进行对象初始化的工作,直接获取到了返回的singleton。
- 如果线程B使用了这个返回的singleton,就有可能出现问题,因为singleton并没有进行真正的初始化,是个空对象,使用过程中可能会发生意想不到的问题。
第二种情况:
- 线程A和线程B同时进入if(singleton == null)的判断。
- 假设线程A先获取到synchrionized锁,进入到synchrionized代码块,判断if (singleton == null)为true。
- 线程A在执行singleton = new Singleton()时,会在JVM中开辟一块空白的内存空间。
- 然后将singleton的引用指向内存空间,在没有进行对象初始化之前,发生了线程切换,线程A释放synchronized锁。
- 这时候等待的线程B才开始第一次singleton == null的判断,判断的结果是single不等于null,于是就直接返回了singleton。
- 如果线程B使用了这个返回的singleton,就有可能出现问题,因为singleton并没有进行真正的初始化,结果是个空对象,使用过程中可能会发生意想不到的问题。
如何解决双重检测加锁机制的问题呢?
volatile加双重检测锁机制
原理:volatile关键字可以禁止CPU对程序指令进行重排,从而避免在高并发环境下多个线程之间出现乱序执行情况,volatile禁止指令重排是通过内存屏障实现的。内存屏障本质上就是一条CPU指令,这个指令既可以保证共享变量的可见性,也可以保证指令执行的有序性。
java
public class Singleton {
/**
* <p> 定义一个静态成员变量,使用volatile关键字进行修饰 </p>
**/
private static volatile Singleton singleton = null;
/**
* <p> 创建一个私有的构造方法 </p>
**/
private Singleton() {
}
/**
* <p> 创建单例对象</p>
**/
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
优点:能解决问题且性能相比直接对getSingleton()加synchronized锁要好很多。
缺点:增加了代码的复杂性,性能也一般。
总结
volatile虽然可以解决可见性和有序性的问题,但是它解决不了原子性的问题,这也是volatile的局限性,有兴趣的自己学习一下。下期我分享原子性的问题,喜欢的可以点点关注。