JAVA内存模型与线程安全

本文详细解析了Java内存模型(JMM)及其与硬件内存架构的关系,阐述了线程安全的概念,指出在多线程环境下,通过同步机制(如volatile、synchronized)解决并发问题,确保原子性、可见性和有序性。同时介绍了Java中相关关键字的作用,如volatile确保可见性和禁止指令重排,synchronized保证原子性和可见性。
摘要由CSDN通过智能技术生成

什么是线程安全?在<<深入理解Java虚拟机>>中看到的定义。原文如下:当多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替运行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获取正确的结果,那这个对象是线程安全的。


JAVA内存模型(JMM)

Java内存模型(即Java Memory Model,简称JMM)本身是一种抽象的概念,并不真实存在,它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有的数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝的自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存中的变量,工作内存中存储着主内存中的变量副本拷贝,前面说过,工作内存是每个线程的私有数据区域,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:

在这里插入图片描述
关于定义的理解是一个仁者见仁智者见智的事情。出现线程安全的问题一般是因为主内存和工作内存数据不一致性和重排序导致的,而解决线程安全的问题最重要的就是理解这两种问题是怎么来的,那么,理解它们的核心在于理解Java内存模型(JMM)。

在多线程条件下,多个线程肯定会相互协作完成一件事情,一般来说就会涉及到多个线程间相互通信告知彼此的状态以及当前的执行结果等,另外,为了性能优化,还会涉及到编译器指令重排序和处理器指令重排序。下面会一一来聊聊这些知识。

主内存

主要存储的是Java实例对象,所有线程创建的实例对象都存放在主内存中,不管该实例对象是成员变量还是方法中的本地变量(也称局部变量),当然也包括了共享的类信息、常量、静态变量。由于是共享数据区域,多条线程对同一个变量进行访问可能会导致线程安全问题。主内存中的数据为共享数据,线程之间主要通过读-写共享变量来完成隐式通信

需要注意的是,在主内存中的实例对象可以被多线程共享,倘若两个线程同时调用了同一个对象的同一个方法,那么两条线程会将要操作的数据拷贝一份到自己的工作内存中,执行完成操作后才刷新到主内存。简单示意图如下所示:

在这里插入图片描述
存在堆上的对象,可以被持有这个对象的引用的线程访问。如果两个线程同时访问同一个对象的私有变量,此时这两个线程所拥有的是"这个对象的私有拷贝"。 比如这里的Object3。

工作内存

主要存储当前方法的所有本地变量信息(工作内存中存储着主内存中的变量副本拷贝),每个线程只能访问自己的工作内存,即线程中的本地变量对其它线程是不可见的,就算是两个线程执行的是同一段代码,它们也会各自在自己的工作内存中创建属于当前线程的本地变量,当然也包括了字节码行号指示器、相关Native方法的信息。注意由于工作内存是每个线程的私有数据,线程间无法相互访问工作内存,因此存储在工作内存的数据不存在线程安全问题。


内存模型抽象结构

线程间协作通信可以类比人与人之间的协作的方式,在现实生活中,之前网上有个流行语“你妈喊你回家吃饭了”,就以这个生活场景为例,小明在外面玩耍,小明妈妈在家里做饭,做完饭后准备叫小明回家吃饭,那么就存在两种方式:

小明妈妈要去上班了十分紧急这个时候手机又没有电了,于是就在桌子上贴了一张纸条“饭做好了,放在…”小明回家后看到纸条如愿吃到妈妈做的饭菜,那么,如果将小明妈妈和小明作为两个线程,那么这张纸条就是这两个线程间通信的共享变量,通过读写共享变量实现两个线程间协作;

还有一种方式就是,妈妈的手机还有电,妈妈在赶去坐公交的路上给小明打了个电话,这种方式就是通知机制来完成协作。同样,可以引申到线程间通信机制。

通过上面这个例子,应该有些认识。在并发编程中主要需要解决两个问题:1. 线程之间如何通信;2.线程之间如何完成同步(这里的线程指的是并发执行的活动实体)。通信是指线程之间以何种机制来交换信息,主要有两种:共享内存和消息传递。这里,可以分别类比上面的两个举例。Java内存模型是共享内存的并发模型,线程之间主要通过读-写共享变量来完成隐式通信。如果程序员不能理解Java的内存模型,在编写并发程序时一定会遇到各种各样关于内存可见性的问题。


计算机硬件架构图

在这里插入图片描述
CPU:一个计算机一般有多个CPU,一个CPU还会有多核。因此意味着每个CPU可能都会运行一个线程,所以计算机出现多线程是很有可能的。

CPU Registers(寄存器):每个CPU都包含一系列的寄存器,它们是CPU内存的基础,CPU在寄存器上执行的 速度远大于在主存上执行的速度,这是因为计算机访问寄存器的速度远大于主存。

CPU Cache(高速缓存):由于计算机的存储设备与处理器的处理设备有着几个数量级的差距,所以现代计 算机都会加入一层读写速度与处理器处理速度接近相同的高级缓存来作为内存与处理器之间的缓冲,将运算使用到的数据复制到缓存中,让运算能够快速的执行,当运算结束后,再从缓存同步到内存之中,这 样,CPU就不需要等待缓慢的内存读写了主(内)存 。 一个计算机包含一个主存,所有的CPU都可以访问主存,主存比缓存容量大的多(CPU访问缓存层的速度快于访问主存的速度!但通常比访问内存寄存器的速度还是要慢点)。

通常情况下,当一个CPU要读取主存(RAM - Main Mernory)的时候,它首先会将主存中的数据读取到CPU缓存中,甚至将缓存内容读到内部寄存器里面,然后再寄存器执行操作,当运行结束后,会将寄存器中的值刷新回缓存中,并在某个时间点将值刷新回主存。


JMM与硬件内存架构的关系

通过对前面的硬件内存架构、Java内存模型以及Java多线程的实现原理的了解,我们应该已经意识到,多线程的执行最终都会映射到硬件处理器上进行执行,但Java内存模型和硬件内存架构并不完全一致。对于硬件内存来说只有寄存器、缓存内存、主内存的概念,并没有工作内存(线程私有数据区域)和主内存(堆内存)之分,也就是说Java内存模型对内存的划分对硬件内存并没有任何影响,因为JMM只是一种抽象的概念,是一组规则,并不实际存在,不管是工作内存的数据还是主内存的数据,对于计算机硬件来说都会存储在计算机主内存中,当然也有可能存储到CPU缓存或者寄存器中,因此总体上来说,Java内存模型和计算机硬件内存架构是一个相互交叉的关系,是一种抽象概念划分与真实物理硬件的交叉。

在这里插入图片描述


线程之间的通信

在这里插入图片描述
如果线程A和线程B要通信的话,必须要经历下面两个步骤

  1. 线程A把本地内存A中跟新过的共享变量刷新到主内存中去
  2. 线程B到主内存中去读取线程A更新过的共享变量

使用如下示意图更加清晰
在这里插入图片描述

假设这3个内存中x均为0,线程A执行时将更新后的值假设更新为1临时存放到自己的本地内存A中。 当线程A和B需要通信时,线程A首先会把自己本地内存中的修改后的值即1刷新到主内存中,此时主内存中x=1. 随后线程B到主内存中去读取线程A更新后的值,此时线程B的本地内存的x值也变成了1. 【正常情况 A先B后】

如果线A和线程B同时读取到主内存中的x值,均为0 ,线程A将x值更新为1,放到线程A本地内存,因为线程A和线程B它们之间的数据不可见,线程B并没有等线程A写回主内存之后做更新操作 ,此时线程B也做了同样的更新操作,这个时候线程B的本地内存中x也变成了1 ,因此当线程B操作完成将结果1写回主内存时计数就出现了错误【因为线程B并没有等线程A将更新后数据写会主内存】,正确的情况应该是线程B读取主内存中的1,然后更新为2,再次写会主内存,主内存最后的x=2. 这就引起了并发问题。

所以需要使用同步的手段去确保程序处理的准确性。

从整体上看,这两个步骤实质上是线程A向线程B发送消息,而且这个通信过程必须要经过主内存。 JMM通过控制主内存与每个线程的本地内存之间的交互,来提供内存可见性保证 。从横向去看看,线程A和线程B就好像通过共享变量在进行隐式通信。这其中有个意思的问题,如果线程A更新后数据并没有及时写回到主存,而此时线程B读到的是过期的数据,这就出现了“脏读”现象。可以通过同步机制(控制不同线程间操作发生的相对顺序)来解决或者通过volatile关键字使得每次volatile变量都能够强制刷新到主存,从而对每个线程都是可见的。


Java内存模型的同步八种操作

Java 内存模型定义了 8 个操作来完成主内存和工作内存的交互操作。

在这里插入图片描述

  1. Lock(锁定):作用于主内存的变量,把一个变量标识变为一条线程独占状态
  2. Unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其它线程锁定
  3. Read(读取):作用于主内存的变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的load动作使用
  4. Load(载入):作用于工作内存的变量,它把Read操作从主内存中得到的变量值放入工作内存的变量副本中
  5. Use(使用):作用于工作内存的变量,把工作内存中的一个变量值传递给执行引擎
  6. Assign(赋值):作用于工作内存的变量,它把一个从执行引擎接受到的值赋值给工作内存的变量
  7. Store(存储):作用于工作内存的变量,把工作内存中的一个变量的值传送到主内存中,以便随后的write的操作
  8. Write(写入):作用于主内存的变量,它把Store操作从工作内存中一个变量的值传送到主内存的变量中

Java内存模型 - 同步规则

  1. 如果要把一个变量从主内存中赋值到工作内存,就需要按顺序得执行read和load操作,如果把变量从工作内 存中同步回主内存中,就要按顺序得执行store和write操作,但java内存模型只要求上述操作必须按顺序执行,没有保证必须是连续执行,也就是说Read和Load、Store和Write之间是可以插入其它指令的
  2. 不允许read和load、store和write操作之一单独出现
  3. 不允许一个线程丢弃它的最近assign的操作,即变量在工作内存中改变了之后必须同步到主内存中
  4. 不允许一个线程无原因地(也就是说必须有assgin操作)把数据从工作内存同步到主内存中
  5. 一个新的变量只能在主内存中诞生,不允许在工作内存中直接使用一个未被初始化(load或assign)的变量。即就是对一个变量实施use和store操作之前,必须先执行过了load和assign操作
  6. 一个变量在同一时刻只允许一条线程对其进行lock操作,但lock操作可以同时被一条线程重复执行多次,多次执行lock后,只有执行相同次数的unlock操作,变量才会解锁,lock和unlock必须成对出现
  7. 如果一个变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎中使用这个变量前需要重新执行 load或assign操作初始化变量的值
  8. 如果一个变量事先没有被lock操作锁定,则不允许它执行unlock操作,也不允许去unlock一个被其它线程锁定的变量
  9. 对一个变量执行unlock操作之前,必须先把此变量同步到主内存中(其实就是执行store和write操作之后)

内存模型的三大特性

原子性

Java 内存模型保证了 read、load、use、assign、store、write、lock 和 unlock 操作具有原子性,例如对一个 int 类型的变量执行 assign 赋值操作,这个操作就是原子性的。但是 Java 内存模型允许虚拟机将没有被 volatile 修饰的 64 位数据(long,double)的读写操作划分为两次 32 位的操作来进行,即 load、store、read 和 write 操作可以不具备原子性。

原子性指的是一个操作是不可中断的,即使是在多线程环境下,一个操作一旦开始就不会被其他线程影响。有点要注意的是,对于32位系统的来说,long类型数据和double类型数据(对于基本数据类型,byte,short,int,float,boolean,char读写是原子操作),它们的读写并非原子性的,也就是说如果存在两条线程同时对long类型或者double类型的数据进行读写是存在相互干扰的,因为对于32位虚拟机来说,每次原子读写是32位的,而long和double则是64位的存储单元,这样会导致一个线程在写时,操作完前32位的原子操作后,轮到B线程读取时,恰好只读取到了后32位的数据,这样可能会读取到一个既非原值又不是线程修改值的变量,它可能是“半个变量”的数值,即64位数据被两个线程分成了两次读取。但也不必太担心,因为读取到“半个变量”的情况比较少见,至少在目前的商用的虚拟机中,几乎都把64位的数据的读写操作作为原子操作来执行,因此对于这个问题不必太在意,知道这么回事即可。


可见性

对于串行程序来说,可见性是不存在的,因为我们在任何一个操作中修改了某个变量的值,后续的操作中都能读取这个变量值,并且是修改过的新值。但在多线程环境中可就不一定了,前面我们分析过,由于线程对共享变量的操作都是线程拷贝到各自的工作内存进行操作后才写回到主内存中的,这就可能存在一个线程A修改了共享变量x的值,还未写回主内存时,另外一个线程B又对主内存中同一个共享变量x进行操作,但此时A线程工作内存中共享变量x对线程B来说并不可见,这种工作内存与主内存同步延迟现象就造成了可见性问题,另外指令重排以及编译器优化也可能导致可见性问题,通过前面的分析,我们知道无论是编译器优化还是处理器优化的重排现象,在多线程环境下,确实会导致程序轮序执行的问题,从而也就导致可见性问题。


有序性

在本线程内观察,所有操作都是有序的。在一个线程观察另一个线程,所有操作都是无序的,无序是因为发生了指令重排序。在 Java 内存模型中,允许编译器和处理器对指令进行重排序,重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。


线程安全需要解决的问题

原子性是指在一个操作中就是cpu不可以在中途暂停然后再调度,既不被中断操作,要不执行完成,要不就不执行。
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
有序性即程序执行的顺序按照代码的先后顺序执行。

可以理解为: 缓存一致性问题—>可见性问题。处理器优化会导致原子性问题的。指令重排即会导致有序性问题

为了保证共享内存的正确性(可见性、有序性、原子性),内存模型定义了共享内存系统中多线程程序读写操作行为的规范。通过这些规则来规范对内存的读写操作,从而保证指令执行的正确性。它与处理器有关、与缓存有关、与并发有关、与编译器也有关。他解决了CPU多级缓存、处理器优化、指令重排等导致的内存访问问题,保证了并发场景下的一致性、原子性和有序性。

内存模型解决并发问题主要采用两种方式:限制处理器优化和使用内存屏障。


JMM的作用

Java内存模型(Java Memory Model ,JMM)就是一种符合内存模型规范的,屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。

Java内存模型规定了所有的共享变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了该线程中是用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存。不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量的传递均需要自己的工作内存和主存之间进行数据同步进行。

JMM就作用于工作内存和主存之间数据同步过程。JMM规定了如何做数据同步以及什么时候做数据同步。

故: JMM是一种规范,目的是解决由于多线程通过共享内存进行通信时,存在的本地内存数据不一致、编译器会对代码指令重排序、处理器会对代码乱序执行等带来的问题。


JMM的API实现

Java中提供了很多和并发处理相关的关键字,比如volatile、synchronized、final、j.u.c包 等 ,这些关键字或者包就是Java内存模型封装了底层实现后,供开发者直接使用。

在理解了原子性,可见性以及有序性问题后,看看JMM是如何保证的,在Java内存模型中都提供一套解决方案供Java工程师在开发过程使用,如原子性问题,除了JVM自身提供的对基本数据类型读写操作的原子性外,对于方法级别或者代码块级别的原子性操作,可以使用synchronized关键字或者重入锁(ReentrantLock)保证程序执行的原子性。而工作内存与主内存同步延迟现象导致的可见性问题,可以使用synchronized关键字或者volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。对于指令重排导致的可见性问题和有序性问题,则可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。除了靠sychronized和volatile关键字来保证原子性、可见性以及有序性外,JMM内部还定义一套happens-before原则来保证多线程环境下两个操作间的原子性、可见性以及有序性。还使用了内存屏障来解决指令的重排序问题。


原子性 synchronized

在Java中可以使用synchronized来保证方法和代码块内的操作是原子性的。 为了保证原子性,synchronized提供了两个高级的字节码指令monitorenter和monitorexit 。


可见性 volatile 、 synchronized 、 final

Java内存模型是通过在变量修改后将新值同步写回主内存,在变量读取前从主内存刷新变量值的这种依赖主内存作为传递媒介的方式来实现的。

Java中的volatile关键字提供了一个功能,那就是被其修饰的变量在被修改后可以立即同步到主内存,被其修饰的变量在每次是用之前都从主内存刷新。因此,可以使用volatile来保证多线程操作时变量的可见性。

除了volatile,Java中的synchronized和final两个关键字也可以实现可见性 。


有序性 synchronized 、volatile

在Java中,可以使用synchronized和volatile来保证多线程之间操作的有序性。实现方式有所区别:

volatile关键字会禁止指令重排。synchronized关键字保证同一时刻只允许一条线程操作。


JMM的设计思想

在这里插入图片描述
JMM是语言级的内存模型,在我的理解中JMM处于中间层,包含了两个方面:(1)内存模型;(2)重排序以及happens-before规则。同时,为了禁止特定类型的重排序会对编译器和处理器指令序列加以控制。而上层会有基于JMM的关键字和J.U.C包下的一些具体类用来方便程序员能够迅速高效率的进行并发编程。


本文小结

CPU的多级缓存,编译器会对代码指令重排序、处理器会对代码乱序执行等问题会造成线程的安全问题,而JAVA的内存模型屏蔽了各种硬件和操作系统的访问差异的,保证了Java程序在各种平台下对内存的访问都能保证效果一致的机制及规范。Java中提供了很多和并发处理相关的关键字,比如volatile、synchronized、final、j.u.c包 等 ,这些关键字或者包就是Java内存模型封装了底层实现后,供开发者直接使用。帮助开发者来解决并发编程中的线程安全问题。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值