java并发编程(4) 有序性 - 聊聊单例模式和volatile的关系


前言

在多线程中,实现单例最简单的方式除了枚举,就是双重锁鉴定了,如何了解单例模式的应该都知道单例模式要配合 volatile 关键字一起使用,那么为什么要这样?其实这和其中的重排序有关,这篇文章就来讨论讨论这个问题,当然在这之前你必须要有重排序的相关知识。文章参考《Java并发编程的艺术》这本书,这篇文章和 volatile ,以及 指令重排序 一起看效果更好。


1. 双重检查锁的单例模式

这种单例模式在之前设计模式的一篇文章中已经介绍过了,这里就直接给出代码

//单线程
public class SingletonTest {
    public static void main(String[] args) {
        single instance = single.getInstance();
        single instance1 = single.getInstance();
        //com.jianglianghao.single@14ae5a5
        System.out.println(instance);
        //com.jianglianghao.single@14ae5a5
        System.out.println(instance1);

    }
}

class single{

    private static volatile single instance;

    private single(){}

    public static single getInstance(){
        if(instance == null){					//1
            synchronized (single.class){		//2
                if(instance == null){			//3
                    instance = new single();	//4
                }
            }
        }
        return instance;
    }
}

不知道你会不会觉得奇怪为什么需要加上 volatile 这个关键字。我们来看上面的代码,第一次判断 if 的时候不为 null,那么就不需要执行下面的加锁和初始化操作,因此第一个 if 是用来减少加锁操作的,提高性能。

我们假设上面的代码没有 volatile 修饰,看起来好像也没什么问题

  • 多线程在试图同一时间创建对象的时候,双重 if 和锁检查会限制只有一个线程能成功进入加锁方法
  • 在对象创建好后,其他线程直接调用 getInstance 方法就可以拿到单例对象了



2. 重排序的影响(源)

其实问题就出在第四步 instance = new single(),这一步可以分解为下面三步:

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

下面来说说上面的数据依赖关系,很明显,上面的三行代码中,1 和 2以及 1 和 3以及 2 和 3 有数据依赖关系,但是在没有加上 volatile 的情况下,2 和 3是可以被重排序的,重排序之后的指令:

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


1. 单线程下

根据《The Java Language Specification,Java SE 7 Edition》(后文成为 Java 语言规范),所有线程在执行的时候必须遵守 intra-thread semantics(线程内语义)。 intra-thread semantics 保证不会改变单线程内的程序执行结果。 换句话说就是单线程中不会禁止违反结果的重排序。

我们来看看上面这段代码在单线程下的运行是怎么样的。

  1. 分配对象的内存空间
  2. 让 instance 执行分配的空间
  3. 初始化
  4. 线程访问这个变量

四步中其实单线程下没有问题,因为指向分配的空间以及初始化这两步就算换过来我们也会得到一个有初始值的对象。所以这个重排序在单线程下是没有影响的,最终拿到的结果都是一样的,JMM 也允许这种重排序。

为了更好理解 intra-thread semantics,我们来看下面这张图:
在这里插入图片描述
从上面可以看出来,只要我们保证 2 在 4 之前执行,即使 2 和 3 重排序了对程序的执行结果也没有影响。



2. 多线程下

但是上面的重排序在多线程情况下就有问题了,来看下面这张图:
在这里插入图片描述
这张图中可以看到线程 B 这时候拿到的只是一个还没有被初始化的对象。Java内存模型的 intra-thread semantics 保证操作2(初始化对象)一定排在操作4(访问instance 变量)之前。对于线程 A 来说结果并没有变化,获取道德还是一个初始化了的值,但是对于线程 B 而言,重排序导致拿到了一个未初始化的对象。

试想一下,如果没有重排序,那么线程 B 判断 instance 为空的时候就会返回了,因为这时候的instance还没有指向初始化好的空间。

从上面就可以看出了,要想解决这种情况,我们可以通过下面两种办法:

  1. 不允许 2 和 3 重排序
  2. 允许 2 和 3 进行重排序,但是不允许其他线程 “看到” 这个重排序



3. 使用 volatile 的方案

这种方案对应了上面的 不允许 2 和 3 重排序,volatile 关键作用就是禁止两个具有依赖关系(数据依赖和控制依赖)的两个操作进行重排序。
在这里插入图片描述


4. 基于类初始化的解决方案

1. 总体概述

再给出方法之前,首先理解初始化的一个特性:

JVM 在类的初始化阶段(就是在 Class 被加载之后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM 会去获取一个锁。这个锁可以同步多个线程对同一个类的初始化。

基于这个特性,可以使用另一种方法实现(Initialization On Demand Holder odiom)

public vlass InstanceFactory{
	private static class InstanceHolder(){
		public static Instance instance = new Instance();
	}
	
	public static Instance getInstance(){
			return InstanceHolder.instance; //这里导致InstanceHolder类被初始化
	}
}

假设现在两个线程并发执行 getInstance() 方法,下面使执行的示意图:
在这里插入图片描述

这个方法解决的实质是:使用锁来保证了两个线程之间的线程通信,线程 A 使用锁来初始化的过程中,线程 B 是获取不到锁的,这时候线程A可以完整地执行完对象地初始化操作。就算里面有重排序,但是线程B是不知道的。所以在线程A执行完成之后,对象已经被创建好了,此时线程B获取到锁,在执行的时候返回的就是一个创建好了的对象。这种方法就是单例模式的第五种创建方法,使用静态内部类延迟创建。



2. 初始化一个类的几种情况

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段的时候。根据Java的语言规范,在首次发生下列任意一种情况时,一个类或者接口类型 T 将立刻被初始化。

  1. T 是一个类,而且一个 T 类型的实例被创建 T a = new T();
  2. T 是一个类。且 T 中声明的一个静态方法被调用 T.getInstance()
  3. T 中声明的一个静态字段被赋值 T.name = "static变量初始化"
  4. T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段(不是在加载的时候就被初始化了)。System.put.paint(T.name);
  5. T 是一个顶级类,而且一个断言语句嵌套在 T 内部被执行



3. 实现的一些细节

在 InstanceFactory 示例代码中,首次执行 getInstance()方法的线程将导致 InstanceHolder 类被初始化。在 Java 多线程情况下,要想确保线程安全的问题以及对象被成功初始化,那么就得用一些同步处理。

Java 语言规定,对于每一个类或者接口 C,都有一个唯一的初始化锁 LC 与之对应。从 C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 确保每个线程都要获取一次锁,来确定这个类到底被初始化没。实际上,Java 语言规范允许 JVM 的具体实现在这里做一些优化。

下面通过五个步骤来说明类初始化的过程:

  1. 通过在 Class 对象上同步(获取 Class 对象的初始化锁),来控制类或者接口的初始化。这个获取锁的线程会一直等待,直到当前线程能获取到这个初始化锁。你可以理解为 synchronized。

    • 假设这时候 Class 对象还没有初始化(初始化状态是state,此时被标记为 state = noInitialization),且有两个线程 A 和 B 试图同时初始化这个 Class 对象。
      在这里插入图片描述
      下面通过一个表来说明上面的图:
      在这里插入图片描述

  1. 线程 A 执行类的初始化,同时线程 B 在初始锁对应的 condition 上等待

    在这里插入图片描述
    在这里插入图片描述


  1. 线程A初始化完成后设置state=initialized,然后唤醒在 condition 中等待的所有线程

    在这里插入图片描述
    在这里插入图片描述


  1. 线程B获取锁,检测到state = initialized,证明此时类已经被初始化了,线程B释放锁
    在这里插入图片描述
    在这里插入图片描述
    在这里插入图片描述
    线程A在第二阶段的 A1 就初始化了这个类,在第三阶段的 A4 释放了初始化锁,线程B在第四阶段的 B1 获取同一个初始化锁,并且在 B4 之后才开始访问这个类。而之前的 B2和 B3 都是在检测这个锁有没有被初始化,这个过程中存在一个 happens - before 规则:线程A执行类的初始化的时候对共享变量的写入(类的静态初始化和初始化类中声明的静态字段),线程B一定可以看到。

  1. 线程C执行类的初始化操作
    其实不难猜出,步骤还是那四个,获取锁-检查有没有被初始化-释放锁
    在这里插入图片描述
    在这里插入图片描述

    在第三阶段过后,类已经初始化了,所以后面的其他线程只需要四步就可以了。同样,对于这个操作,也有一个 happens-before 规则:线程 A 执行类的初始化的写入操作,线程 C 一定能看到
    在这里插入图片描述



5. 对比

通过对比 volatile 的双重检查锁的方案和基于类初始化的方案,有以下不同点:

  • 基于类初始化的方法更加简洁
  • 基于 volatile 的双重检查锁的方案可以对静态字段进行延迟初始化之外,还可以对实例字段延迟初始化

字段延迟初始化降低了初始化类或者创建实例的开销,需要用的时候才进行加载,实现一个懒加载的效果。但增加了访问被延迟初始化的字段的开销。正常情况下,正常的初始化要优于延迟初始化。基于上面,得出两个结论:

  1. 如果需要对实例字段使用线程安全的延迟初始化,就使用 volatile 的方案
  2. 如果需要对静态字段使用线程安全的延迟初始化,就是用基于类初始化的方案







如有错误,欢迎指出!!!

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值