Java多并发(一)| 并发机制的底层原理(内存模型、重排序)

美团文档

Java内存访问重排序的研究

上下文切换

对于上下文,先来了解下时间片

CPU分配给各个线程的时间,时间非常短一般为几十毫秒,CPU通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务,在切换前要保存上一个任务的状态,以便下次来继续执行,所以任务从保存到在加载到过程就是一次上下文切换

  • 减少上下文切换
  1. 无锁并发编程:因为多线程争抢锁时会引起上下文切换(可以用一些方法来避免使用锁,如将数据的ID按照hash算法进行取模分段,不同的线程处理不同段的数据)
  2. CAS算法,用CAS来更新数据
  3. 使用尽量少的线程
  4. 协程:单线程里实现多个任务的调度,并在单线程里维持多个任务间的切换

Java内存模型(JSR-133自JDK5)

在并发编程中两个关键问题就是

  1. 线程之间如何通信:通信是指线程之间以何种机制来交换信息,线程之间的通信机制有两种:共享内存和消息传递
  2. 线程之间如何同步
    Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,Java线程之间的通信由Java内存模型(java memory model)控制,就是通过控制主内存与每个线程的本地内存之间的交互
  • 结构图

结构图中的共享内存其实就是堆内存,因为堆内存在线程之间共享;共享变量是实例域、静态域和数组元素,每个线程都有一个私有的本地 内存(Local Memory),本地内存中存储了该线程以读/写共享变量的副本。
在这里插入图片描述

1.happens-before(保证可见性)

1.1 定义
  • 概述

1. 如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在这个h-b关系;如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见
2. 两个操作之间存在h-b关系,并不意味着必须按照这个h-b顺序来执行,如果重排序后的结果与按h-b关系来执行的结果一致,那么JMM允许这种重排序

  • 定义所针对的
  1. 上面的1实际上是对程序员可见性的保证
  2. 2则是JMM对编译器和处理器重排序的约束条件(详情看下面流程图)
  • 重排序与h-b与JMM的关系

h-b禁止的重排序有以下两种:

  1. 对于会改变程序执行结果的重排序,JMM要求编译器和处理器必须禁止这种重排序。
  2. 对于不会改变程序执行结果的重排序,JMM对编译器和处理器不做要求(JMM允许这种 重排序)。
    在这里插入图片描述
1.2 规则
  • 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作。
  • 监视器锁规则:对一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则:对一个volatile域的写,happens-before于任意后续对这个volatile域的 读。
  • 传递性:如果A happens-before B,且B happens-before C,那么A happens-before C。
  • start()规则:如果线程A执行操作ThreadB.start()(启动线程B),那么A线程的 ThreadB.start()操作happens-before于线程B中的任意操作。
  • join()规则:如果线程A执行操作ThreadB.join()并成功返回,那么线程B中的任意操作 happens-before于线程A从ThreadB.join()操作成功返回。
  • 与JMM的关系
    在这里插入图片描述
1.3 与as-if-serial的关系

as-if-serial语义的意思是:不管怎么重排序(编译器和处理器为了提高并行度),(单线程) 程序的执行结果不能被改变。编译器、runtime和处理器都必须遵守as-if-serial语义。

  • 概述
  • as-if-serial语义保证单线程内程序的执行结果不被改变,happens-before关系保证正确同 步的多线程程序的执行结果不被改变。
  • as-if-serial语义给编写单线程程序的程序员创造了一个幻境:单线程程序是按程序的顺 序来执行的。happens-before关系给编写正确同步的多线程程序的程序员创造了一个幻境:正 确同步的多线程程序是按happens-before指定的顺序来执行的。
  • as-if-serial语义和happens-before这么做的目的,都是为了在不改变程序执行结果的前提 下,尽可能地提高程序执行的并行度。
  • 总结:

JMM对编译器和处理器的束缚已经尽可能少。从上面的分析可以看出,JMM其实是在遵 循一个基本原则:只要不改变程序的执行结果(指的是单线程程序和正确同步的多线程程序), 编译器和处理器怎么优化都行。例如,如果编译器经过细致的分析后,认定一个锁只会被单个 线程访问,那么这个锁可以被消除。再如,如果编译器经过细致的分析后,认定一个volatile变 量只会被单个线程访问,那么编译器可以把这个volatile变量当作一个普通变量来对待。这些 优化既不会改变程序的执行结果,又能提高程序的执行效率。

2.锁的内存语义

锁的释放-获取建立的happens-before关系

锁除了让临界区互斥执行外,还可以让释放锁的 线程向获取同一个锁的线程发送消息。会根据程序测序规则、监视器锁规则、happen-before传递性,分为三类的happen-before关系

锁的释放和获取的内存语义

  • 当线程释放锁时,JMM会把该线程对应的本地内存中的共享变量刷新到主内存中。与volatile写有相同的语义
  • 当线程获取锁时,JMM会把该线程对应的本地内存置为无效。从而使得被监视器保护的 临界区代码必须从主内存中读取共享变量。,与volatile读有相同的语义

锁释放与获取的总结

  • 线程A释放一个锁,实质上是线程A向接下来将要获取这个锁的某个线程发出了(线程A 对共享变量所做修改的)消息。
  • 线程B获取一个锁,实质上是线程B接收了之前某个线程发出的(在释放这个锁之前对共 享变量所做修改的)消息。
  • 线程A释放锁,随后线程B获取这个锁,这个过程实质上是线程A通过主内存向线程B发 送消息。

重排序

  • 概述

在执行程序时,为了提高性能,编译器和处理器常常会对指合做重排序。重排序分3种类:分为编译器重排序处理器重排序(包括指令级并行的重排序和内存系统的重排序)

分类

  1. 编译器优化的重排序:编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指今级并行的重排序:现代处理器采用了指今级并行技术来将多条指命重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指合的执行顺序。
  3. 存系统的重排序:由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

在这里插入图片描述

final关键字(域)的重排序规则
对于final域,编译器和处理器要遵守两个重排序规则。

  1. 在构造函数内对一个final域的写入,与随后把这个被构造对象的引用赋值给一个引用 变量,这两个操作之间不能重排序。

①.JMM禁止编译器把final域的写重排序到构造函数之外。
②. 编译器会在final域的写之后,构造函数return之前,插入一个StoreStore屏障。这个屏障 禁止处理器把final域的写重排序到构造函数之外。

  1. 初次读一个包含final域的对象的引用,与随后初次读这个final域,这两个操作之间不能 重排序。

编译器会在读final 域操作的前面插入一个LoadLoad屏障

1.内存屏障

  • 概述

为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁 止特定类型的处理器重排序。JMM把内存屏障指令分为4类

分类
在这里插入图片描述

  • 例子

在字节码序列中插入内存屏障,大致可以理解为禁令,禁止特定类型的处理器重排序,在JMM中优先保证的是正确性(语义)而不是效率,所以内存屏障插入策略非常保守

  • 在每个volatile写操作的前面插入一个StoreStore屏障:禁止上下写重排序。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障:防止上面写与下面写可能有的。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障:禁止上下读重排序。
  • 在每个volatile读操作的后面插入一个LoadStore屏障:禁止下面所有。在这里插入图片描述

2.双重锁定(DCL:double-checked locking)

  • 由来

当我们对懒汉式的线程安全进行优化后如下(没有优化前,A线程执行代码1的同时,B线程执行代码2。此时,线 程A可能会看到instance引用的对象还没有完成初始化),虽然延迟了初始化但由于synchronized 的引入当频繁调用会增加开销那么就会引入双重加锁来处理这种情况


public class Singleton {  
    private static Singleton instance;  
    private Singleton (){}  
    public static synchronized Singleton getInstance() {  
        if (instance == null) {  //A线程执行
            instance = new Singleton();  	//B线程执行
        }  
        return instance;  
    }  
}

  • 双重加锁代码
  • 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象。
  • 在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已创建好的对象。
public class DoubleCheckedLocking { // 1 
	private static Instance instance; // 2 
	public static Instance getInstance() { // 3 
		if (instance == null) { // 4:第一次检查 
			synchronized (DoubleCheckedLocking.class) { // 5:加锁 
				if (instance == null) // 6:第二次检查 
					instance = new Instance(); // 7:问题的根源出在这里 
				} // 8 
		} // 9 
		return instance; // 10 
	} // 11 
}
2.1 双重锁定出现的问题
  • 概述

双重检查锁定看起来似乎很完美,但这是一个错误的优化!在线程执行到第4行,代码读 取到instance不为null时,instance引用的对象有可能还没有完成初始化

  • 分析

我们将初始化对象可以细分为三个步骤:这三个步骤可能会被重排序导致初始化出现问题

  1. 分配对象内存空间
  2. 初始化对象
  3. 设置instance指向刚分配的内存地址
  • 问题根源的底层支持

在jck7的文档中,规定所有 线程在执行Java程序时必须要遵守intra-thread semantics(线程内语义)intra-thread semantics保证重排序不会 改变单线程内的程序执行结果。换句话说,intra-thread semantics允许那些在单线程内,不会改 变单线程程序执行结果的重排序。上面3行伪代码的2和3之间虽然被重排序了,但这个重排序 并不会违反intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以 提高程序的执行性能。

但是在多线程的情况下就会出现B线程访问到一个未被初始化的对象,下面执行时序图可以很清晰的看到
在这里插入图片描述

2.2 基于问题的解决方案-volatile
  • 概述

我们能做的可以有两个方向的优化,这里volatile是不允许2和3的重排序

  1. 不允许2和3的重排序
  2. 允许2和3的重排序但是不允许其他线程看到这个重排序
  • 优化代码

这是利用volatile严格的重排序规则实现的延迟初始化

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; 
	 } 
 }
2.3 基于问题的解决方案-类初始化(登记式/静态内部类)
  • 概述

这种方式能达到双检锁方式一样的功效,但实现更简单。对静态域使用延迟初始化,应使用这种方式而不是双检锁方式。这种方式只适用于静态域的情况,双检锁方式可在实例域需要延迟初始化时使用。与饿汉式的单例模式要分清哦

基于什么情况类或接口T会被立刻初始化

  1. T是一个类,而且一个T类型的实例被创建。
  2. T是一个类,且T中声明的一个静态方法被调用。 (其实就是利用了这个)
  3. T中声明的一个静态字段被赋值。
  4. T中声明的一个静态字段被使用,而且这个字段不是一个常量字段。
  5. T是一个顶级类(Top Level Class,见Java语言规范的§7.6),而且一个断言语句嵌套在T 内部被执行。
  • 解释

根据上面类被初始化的情况,当InstanceFactory类被装载而InstanceHolder并没有被初始化,原因就是没有被使用到,当调用getInstance,才会显式装载 SingletonHolder 类,从而实例化 instance。从而达到延迟加载的效果 ,也许读者会有疑惑当多个线程都去争抢这个初始化去调用getInstance怎么办。其实这时候就有请锁登场(初始化锁LC),每个接口或类都只有唯一一个这么个初始化锁,当某个线程拿到这个锁(拿到锁会将锁中的一个字段state更新,然后释放锁,别的线程拿到锁就知道在进行初始化会直接释放然后进入锁对应的condition或直接使用对象因为类有可能已经被初始化完了嘛)就会去执行类的静态初始化和初始化类中声明的静态字段,这里由于锁的存在别的线程是看不到里面对初始化结果没有影响的重排序

  • 代码
public class InstanceFactory { 
	private static class InstanceHolder { 
		public static Instance instance = new Instance(); 
	}
	public static Instance getInstance() { 
		return InstanceHolder.instance ; // 这里将导致InstanceHolder类被初始化 
	}
}
2.4 两种解决方式的适用场景

字段延迟初始化降低了初始化类或创建实例的开销,但增加了访问被延迟初始化的字段 的开销。在大多数时候,正常的初始化要优于延迟初始化。

  • 如果确实需要对实例字段和对静态字段实现延迟初始化使用线程 安全的延迟初始化,请使用上面介绍的基于volatile的延迟初始化的方案;
  • 如果确实需要对静 态字段使用线程安全的延迟初始化,请使用上面介绍的基于类初始化的方案。
2.5 类的初始化过程与锁之间的配合

第一阶段

  • 通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁。

假设Class对象当前还没有被初始化(初始化状态state,此时被标记为state=noInitializa- tion)
在这里插入图片描述

第二阶段

  • 线程A执行类的初始化(1:分配内存空间。3:赋值给引用变量。2:初始化对象。),同时线程B在初始化锁对应的condition上等待。

happens-before关系将保证:线程A执行类的初始化时的写入操作(执行类的静态初始 化和初始化类中声明的静态字段),线程B一定能看到。
在这里插入图片描述

第三阶段(三阶段完成后已经完成类的初始化)

  • 线程A设置state=initialized,然后唤醒在condition中等待的所有线程。
    在这里插入图片描述

第四阶段

  • 线程B结束类的初始化处理
    在这里插入图片描述

第五阶段

  • 线程C执行类的初始化的处理。
    在这里插入图片描述
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值