Java内存模型,双重锁定

1.Java内存模型的基础

1.1并发编程模型的两个关键问题

在并发编程中,需要处理两个关键问题:线程之间如何通信及线程之间如何同步
通信是指线程之间以何种机制来交换信息。
在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递
在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。
在消息传递的并发模型里,线程之间没有公共状态,线程之间必须通过发送消息来进行显式通信。

同步是指程序中用于控制不同线程间操作发送相对顺序的机制。
在共享内存并发模型里,同步是显式进行的。程序员必须显示指定某个方法或者某段代码需要在线程之间互斥执行。
在小心传递的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式进行的。

因此Java的并发采用的是共享内存模型。
Java线程之间的通信是隐式进行,整个通信过程对程序员完全透明。
因此入编程多线程的Java程序员不理解隐式进行的线程之间的通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

1.2Java内存模型的抽象结构

在Java中,所有实例域,静态域和数组元素都存储在堆内容中,堆内存在线程之间共享。
局部变量、方法定义参数和异常处理器参数不会在线程之间共享,所以他们不会有内存可见性问题,也不受内存模型的影响。

Java线程之间的通信由Java内存模型(JMM)控制。JMM决定一个线程对共享变量的写入何时对另一个线程可见。
从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有的本地内存,本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器以及其他硬件和编译器优化。
在这里插入图片描述
如果线程A和B之间要通信,必须经历下面两个步骤

  1. 线程A把本地内存A中更新过的共享变量刷新到主内存中
  2. 线程B到主内存中去读取线程A之前已更新过的共享变量。
  3. 在这里插入图片描述
    本地内存A和本地内存B由主内存中共享变量X的副本。
    假设初始时,这三个内存中的x值都为0,。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。
    随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。
    从整体来看,这两个步骤实际上是线程A在向线程B发送消息,而且这个通信过程必须经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。

1.3从源代码到指令序列的重排序

在执行程序时,为了提供性能,编译器和处理器常常会对指令做重排序。重排序分为三种类型。

  1. 编译器优化的重排序。
    编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。
    现代处理器采用了指令级并行技术(ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。
    由于处理器使用缓存和读/写缓存区,这使得加载和存储操作看上去可能是乱序执行。
    在这里插入图片描述
    对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序。
    对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来静止特定类型的处理器重排序。
    JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性。

1.4并发编程模型的分类

现代处理器使用写缓冲区临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。
同时,通过以批处理的方式刷新缓冲区,以及合并写缓冲区中对同一内存地址的多次写,减少对内存总线的占用。
虽然写缓冲区有这么多好处,但每个处理器的写缓冲区,仅仅对它所在的处理器可见。这个特性对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读写操作顺序一致。
在这里插入图片描述
StoreLoad Barriers(写读)是一个全能型屏障。
它同时具有3个屏障的效果。
现代的多处理器大多支持该屏障。执行该屏障开销会很昂贵,因为当前处理器要把写缓冲区区中的数据全部刷新到内存中。

1.5happens-before简介

在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。
这里提高的两个操作即可以是在一个线程之内,也可以是在不同线程之间。

一个happens-before规则对应与一个或多个编译器和处理器重排序规则。对于Java程序员来说,happens-before规则简单易懂,它避免了程序员为了理解JMM提供的内存可见性保证而去学习复杂的重排序规则以及这里规则具体的实现方法。
常见的happens-before规则

  • 程序顺序规则。一个线程中的每个操作,happens-before于该线程中任意后续操作。
  • 监视器锁规则。对于一个锁的解锁,happens-before于随后对这个锁的加锁。
  • volatile变量规则。对于一个volatile域的写,happens-before于任意后续的这个volatile域的读。
  • 传递性。如果A happens-before B,且B happens-before C,那么A happens-before C。

2.双重检查锁定与延迟初始化

Java程序中,有时候可能需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。此时,程序员可能会采用延迟初始化。
但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。

public class UnsafeLazyInitialization{
	private static Instance instance;
	public static Instance getInstance(){
		if(instance==null){        //1.A线程执行
			instance = new Instance();//2.B线程执行
		}
		return instance;
	}
}
//双重检查锁定
public class DoubleCheckedLocking{
	private static Instance instance;
	public static Instance getInstance(){
		if(instance == null){
			synchronized(DoubleCheckedLocking.class){
				if(instance==null){
					instance = new Instance();
				}
			}
			return instance;
		}
	}
}

这个方法首先判断变量是否被初始化,没有被初始化,再去获取锁,然后再次判断是否被初始化。第二次判断的目的在于有可能其他线程获取过锁,已经初始化变量。第二次检查还未通过,才会真正初始化变量。
这个方法检查判定两次,并使用锁,所以形象称为双重锁定模式。这个方案缩小锁的范围,减少锁的开销,看起来很完美。
但是存在问题:
new实例背后的指令并不是原子指令。

//instance = new Singleton();可以分解成三行伪代码
memory = allocate();//1.分配对象的内存空间
ctorInstance(memory);//2.初始化对象
instance = memory;//3/设置instance指向刚分配的内存地址
//但是上述代码,可能会被重排序
memory = allocate();//1.设置对象的内存空间
instance = memory;//3.设置instance指向刚分配的内存地址
ctorInstance(memory);//2.初始化对象

需要注意的是在单线程程序中,虽然2和3被重排序了,但是并不会影响程序的执行结果,反而可以提高程序的执行性能。
但是在多线程情况下,线程A中如果被重排序,并且执行到3时候,该资源尚未被初始化,但是他已经分配了地址,instance不为空,而B线程此时看到的就是一个有地址指向的instance,但是此时的资源还未初始化。
解决方案

  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();
				}
			}
		}
	}
}

volatile主要包含两个功能:

  1. 保证可见性。使用volatile定义的变量,将会保证对所有线程的可见性。
  2. 禁止指令重排序优化。由于volatile禁止对象创建指令之间重排序,所以其他线程不会访问到一个未初始化的对象,从而保证安全性。

基于类初始化的解决方案

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

public class InstanceFactory{
	private static class InstanceHolder{
		public static Instance instance = new Instance();
	}
	
}
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
Java内存模型Java Memory Model,JMM)定义了Java程序在多线程环境下的内存访问规则。它规定了线程如何和主内存、本地内存以及其他线程进行通信。 JVM内存模型是指Java虚拟机(Java Virtual Machine,JVM)在执行Java程序时的内存布局和管理方式。JVM内存模型包括了堆内存、栈内存、方法区、直接内存等。 在Java内存模型中,主要有以下几个概念: 1. 主内存:所有线程共享的内存区域,包含了实例字段、静态字段以及数组元素。 2. 工作内存:每个线程独立的内存区域,包含了该线程使用的变量副本或者缓存。 3. 内存间的交互操作:线程之间通过读写主内存来进行通信。 4. 原子性、可见性和有序性:JMM保证了原子性(对基本类型的读写操作具有原子性)、可见性(一个线程对主内存的修改对其他线程是可见的)和有序性(在一个线程中,按照程序顺序执行)。 JVM内存模型主要包括以下几个部分: 1. 堆内存:用于存储对象实例,由垃圾回收器进行管理。 2. 栈内存:用于存储方法的局部变量和方法调用的信息。每个线程都有自己的栈内存。 3. 方法区:用于存储类的信息、常量、静态变量等。 4. 直接内存:在堆外分配内存,不受JVM管理,由操作系统进行管理。 需要注意的是,JVM内存模型是具体实现的一种规范,可以根据不同的JVM厂商进行优化和调整。而Java内存模型Java语言规范中定义的多线程内存访问规则,对于不同的JVM实现都是一样的。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值