java内存模型(JMM)

一、概述
Java内存模型即Java Memory Model,简称JMM。JMM定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM是整个计算机虚拟模型,所以JMM是隶属于JVM的。

如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。Java内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。原始的Java内存模型效率并不是很理想,因此Java1.5版本对其进行了重构,现在的Java8仍沿用了Java1.5的版本。
二、基础知识
并发编程模型的两个关键问题: 线程间如何通信, 以及线程之间如何同步.

通信: 交换信息的机制. 有两种常见的方式:

共享内存: 读写内存中的公共状态来进行隐式通信.
消息传递: 无公共状态, 通过发送消息来显示进行通信.

同步: 用以控制不同线程间操作发生的相对顺序的机制.

共享内存的通信机制下, 必须进行显式的同步.程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行。
消息传递的通信机制下, 消息的发送顺序隐式进行了同步.

JAVA 采用共享内存模型, 线程间的通信过程对外完全透明,wait和notify等方法必须配合synchronized关键字使用,其并没有传递有效数据,是共享内存模型中为了控制线程执行顺序的方法。Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。如果编写多线程程序的Java程序员不理解隐式进行的线程之间通信的工作机制,很可能会遇到各种奇怪的内存可见性问题。

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

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

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

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

三、Java内存模型
上面讲到了Java线程之间的通信采用的是共享内存模型,这里提到的共享内存模型指的就是Java内存模型(简称JMM),JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

Java内存模型(Java Memory Model,简称JMM),本身是种抽象的概念,并不是像硬件架构一样真实存在的;它描述的是一组规则或规范,通过这组规范定义了程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式。

由于JVM运行程序的实体是线程,而每个线程创建时,JVM都会为其创建一个工作内存(有些地方称为栈空间),用于存储线程私有数据,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完后再将变量写回主内存,不能直接操作主内存中的变量。

工作内存是每个线程的私有数据区域,因此不同线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成。
在这里插入图片描述

在 Java 中,所有实例域、静态域 和 数组元素存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数 和 异常处理器参数 不会在线程之间共享,它们不会有内存可见性问题,也不受内存模型的影响。
从上图来看,线程A与线程B之间如要通信的话,必须要经历下面2个步骤:

  1. 首先,线程A把本地内存A中更新过的共享变量刷新到主内存中去。
  2. 然后,线程B到主内存中去读取线程A之前已更新过的共享变量。

下面通过示意图来说明这两个步骤:
在这里插入图片描述

如上图所示,本地内存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程序员提供内存可见性保证。

上面也说到了,Java内存模型只是一个抽象概念,那么它在Java中具体是怎么工作的呢?为了更好的理解上Java内存模型工作方式,下面就JVM对Java内存模型的实现、硬件内存模型及它们之间的桥接做详细介绍。

四、JVM对Java内存模型的实现
在JVM内部,Java内存模型把内存分成了两部分:线程栈区和堆区,下图展示了Java内存模型在JVM中的逻辑视图:
在这里插入图片描述
JVM中运行的每个线程都拥有自己的线程栈,线程栈包含了当前线程执行的方法调用相关信息,我们也把它称作调用栈。随着代码的不断执行,调用栈会不断变化。

线程栈还包含了当前方法的所有本地变量信息。一个线程只能读取自己的线程栈,也就是说,线程中的本地变量对其它线程是不可见的。即使两个线程执行的是同一段代码,它们也会各自在自己的线程栈中创建本地变量,因此,每个线程中的本地变量都会有自己的版本。

所有原始类型(boolean,byte,short,char,int,long,float,double)的本地变量都直接保存在线程栈当中,对于它们的值各个线程之间都是独立的。对于原始类型的本地变量,一个线程可以传递一个副本给另一个线程,它们之间是无法共享的。

堆区包含了Java应用创建的所有对象信息,不管对象是哪个线程创建的,其中的对象包括原始类型的封装类(如Byte、Integer、Long等等)。不管对象是属于一个成员变量还是方法中的本地变量,它都会被存储在堆区。

下图展示了调用栈和本地变量都存储在栈区,对象都存储在堆区:
在这里插入图片描述
一个本地变量如果是原始类型,那么它会被完全存储到栈区。
一个本地变量也有可能是一个对象的引用,这种情况下,这个本地引用会被存储到栈中,但是对象本身仍然存储在堆区。

对于一个对象的成员方法,这些方法中包含本地变量,仍需要存储在栈区,即使它们所属的对象在堆区。
对于一个对象的成员变量,不管它是原始类型还是包装类型,都会被存储到堆区。

Static类型的变量以及类本身相关信息都会随着类本身存储在堆区。

堆中的对象可以被多线程共享。如果一个线程获得一个对象的引用,它便可访问这个对象的成员变量。如果两个线程同时调用了同一个对象的同一个方法,那么这两个线程便可同时访问这个对象的成员变量,但是对于本地变量,每个线程都会拷贝一份到自己的线程栈中。

下图展示了上面描述的过程:

在这里插入图片描述
五、硬件内存架构
我们应该都知道,计算机在执行程序的时候,每条指令都是在CPU中执行的,而执行的时候,又免不了要和数据打交道。而计算机上面的数据,是存放在主存当中的,也就是计算机的物理内存啦。

刚开始,还相安无事的,但是随着CPU技术的发展,CPU的执行速度越来越快。而由于内存的技术并没有太大的变化,所以从内存中读取和写入数据的过程和CPU的执行速度比起来差距就会越来越大,这就导致CPU每次操作内存都要耗费很多等待时间。

所以,人们想出来了一个好的办法,就是在CPU和内存之间增加高速缓存。缓存的概念大家都知道,就是保存一份数据拷贝。他的特点是速度快,内存小,并且昂贵。

再随着市场对CPU计算能力的需要,于是出现了多核CPU,每个核都有各自的缓存。
下图就简单描述了当代计算机硬件内存架构:
在这里插入图片描述

Java内存模型和硬件架构之间的桥接
现代计算机一般都有2个以上CPU,而且每个CPU还有可能包含多个核心。因此,如果我们的应用是多线程的话,这些线程可能会在各个CPU核心中并行运行。

在CPU内部有一组CPU寄存器,也就是CPU的储存器。CPU操作寄存器的速度要比操作计算机主存快的多。在主存和CPU寄存器之间还存在一个CPU缓存,CPU操作CPU缓存的速度快于主存但慢于CPU寄存器。某些CPU可能有多个缓存层(一级缓存和二级缓存)。计算机的主存也称作RAM,所有的CPU都能够访问主存,而且主存比上面提到的缓存和寄存器大很多。

当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存,进而在读取CPU缓存到寄存器。当CPU需要写数据到主存时,同样会先flush寄存器到CPU缓存,然后再在某些节点把缓存数据flush到主存。

正如上面讲到的,Java内存模型和硬件内存架构并不一致。硬件内存架构中并没有区分栈和堆,从硬件上看,不管是栈还是堆,大部分数据都会存到主存中,当然一部分栈和堆的数据也有可能会存到CPU寄存器中,如下图所示,Java内存模型和计算机硬件内存架构是一个交叉关系:
在这里插入图片描述
每一个线程运行起来的时候就会对应一个栈(线程栈),栈中存放的数据被当前

线程所独享(不会产生资源共享情况,所以线程是安全的)。而栈当中存放的是栈帧,

当线程调用方法时,就是形成一个栈帧,并将这个栈帧进行压栈操作。方法执行完后,

进行出栈操作。这个栈帧里面包括(局部变量,操作数栈,指向当前方法对应类的常

量池引用,方法返回地址等信息)。
Java内存模型中规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存(可以与前面讲的处理器的高速缓存类比),线程的工作内存中保存了该线程使用到的变量到主内存副本拷贝(包括本地变量也是从主内存拷贝),线程对变量的所有操作(读取、赋值)都必须在工作内存中进行,而不能直接读写主内存中的变量。
线程启动时,在内存中分配栈空间,包含main方法的线程是主线程,启动时也会分配栈空间。线程代码执行过程中逐步加载,如在new等操作时,需要的类加载到主内存后,将部分数据存储到对应内存区,如statistic修饰的变量(堆,共享空间),代码语句放入方法区;如调用对象方法,则在栈中记录。线程的指令执行是由cpu执行的,因此在cpu执行时,提前将需要的部分数据拷贝到cpu缓存中,如果在CPU缓存中没有命中,则会在主内存中查询,主内存中找到,则复制到cpu缓存,主内存中没有,需要的是对象就在cpu缓存中为对象分配堆内存,在合适的时候将数据写入主内存。当线程释放了cpu,会保存上下文,此时需要把上下文信息,局部变量信息保存至主内存,因为有可能该线程后面切换到其他cpu上执行。

当对象和变量存储到计算机的各个内存区域时,必然会面临一些问题,其中最主要的两个问题是:

  1. 共享对象对各个线程的可见性
  2. 共享对象的竞争现象

共享对象的可见性
当多个线程同时操作同一个共享对象时,如果没有合理的使用volatile和synchronization关键字,一个线程对共享对象的更新有可能导致其它线程不可见。

想象一下我们的共享对象存储在主存,一个CPU中的线程读取主存数据到CPU缓存,然后对共享对象做了更改,但CPU缓存中的更改后的对象还没有flush到主存,此时线程对共享对象的更改对其它CPU中的线程是不可见的。最终就是每个线程最终都会拷贝共享对象,而且拷贝的对象位于不同的CPU缓存中。

下图展示了上面描述的过程。左边CPU中运行的线程从主存中拷贝共享对象obj到它的CPU缓存,把对象obj的count变量改为2。但这个变更对运行在右边CPU中的线程不可见,因为这个更改还没有flush到主存中:
在这里插入图片描述
要解决共享对象可见性这个问题,我们可以使用java volatile关键字。 Java’s volatile keyword. volatile 关键字可以保证变量会直接从主存读取,而对变量的更新也会立刻写到主存。

竞争现象
如果多个线程共享一个对象,如果它们同时修改这个共享对象,这就产生了竞争现象。

如下图所示,线程A和线程B共享一个对象obj。假设线程A从主存读取Obj.count变量到自己的CPU缓存,同时,线程B也读取了Obj.count变量到它的CPU缓存,并且这两个线程都对Obj.count做了加1操作。此时,Obj.count加1操作被执行了两次,不过都在不同的CPU缓存中。

如果这两个加1操作是串行执行的,那么Obj.count变量便会在原始值上加2,最终主存中的Obj.count的值会是3。然而下图中两个加1操作是并行的,不管是线程A还是线程B先flush计算结果到主存,最终主存中的Obj.count只会增加1次变成2,尽管一共有两次加1操作。
在这里插入图片描述

要解决上面的问题我们可以使用java synchronized代码块。synchronized代码块可以保证同一个时刻只能有一个线程进入代码竞争区,synchronized代码块也能保证代码块中所有变量都将会从主存中读,当线程退出代码块时,对所有变量的更新将会flush到主存,不管这些变量是不是volatile类型的。

  1. 共享变量存储于主内存中,每个线程都可以访问。
  2. 每个线程都有私有的工作内存,或称本地内存。这只是个逻辑概念,其实质是涵盖了寄存器、缓存、编译器优化和硬件等。
  3. 共享变量只以副本的形式,存储在本地内存中。
  4. 线程不能直接操作主内存,只有操作了本地内存中的副本,才能刷新到主内存中。
  5. 每个线程也不能操作其它线程的私有的本地内存
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值