一文看懂Java内存模型

引言

对于写过Java的程序员来说,Java优秀的内存处理机制会帮助码友及时清理垃圾优化内存,一定程度上避免了内存泄漏问题,但如果出现了内存泄漏还是有必要了解一下JVM的内存模型来处理泄漏的。本文将深入探讨Java的内存模型(Java Memory Model,简称JMM)。

序章

CPU缓存模型

要理解JMM需要先从CPU缓存模型指令重排序的问题说起,计算机的内存并不单单由表面上的内存条构成,在CPU内部拥有高速缓存。那么问题来了,这些高速缓存的设计初衷是什么呢?我们都知道内存的本质是对信息的高速暂存,内存条和CPU高速缓存(CPU Cache)同为内存,但是速度上CPU缓存比内存速度快。**CPU 缓存则是为了解决 CPU 处理速度和内存处理速度不对等的问题。**在CPU中,通常会分为L1(图中的ICache、DCache可见注解唠叨)、L2、L3级别甚至还有L4缓存,这里不作讨论,级别越高通常容量越小,速度越快。我们甚至可以把 内存看作外存的高速缓存,程序运行的时候我们把外存的数据复制到内存,在这过程中,由于内存的处理速度远远高于外存,这样提高了处理速度。

CPU Cache示意图如图:

请添加图片描述

CPU Cache是怎么工作的呢?现在有一份数据需要CPU进行运算,此时数据从DRAM中读取到CPU高速缓存中,通过CPU的运算后,再从Cache写回到DRAM中。如果你经常写多线程你就会发现,如果此时有多个线程运算这个数据就会导致内存中数据不一致的问题,比如在缓存中数据为 1 三个线程都运算 数据++ 再写回到RAM中,那么DRAM中的数据始终为2。实际上理想的结果是 3。为了解决内存缓存不一致的问题可以通过在Bus总线制定一致性协议解决(如 [MESI协议](“https://baike.baidu.com/item/MESI协议/22742331#:~:text=MESI协议是基于Invalidate的高速缓存一致性协议,并且是支持回写高速缓存的最常用协议之一。 它也被称为伊利诺伊州协议(由于其在伊利诺伊大学厄巴纳 - 香槟分校的发展)。,回写高速缓存可以节省很多通常在写入缓存上浪费的带宽。 回写高速缓存中总是存在脏状态,表示高速缓存中的数据与主存储器中的数据不同。 如果块驻留在另一个缓存中,则Illinois协议要求缓存在未命中时缓存传输。 该协议相对于MSI协议减少了主存储器事务的数量。 这标志着性能的显着改善。”)、MSI协议)协议原理本篇不详细讨论。高级语言程序员对于计算机来说常与操作系统打交道,而操作系统通过将硬件资源虚拟化同样也会存在内存缓存不一致的问题。主流操作系统通过**内存模型(Memory Model)**来规范化解决问题,无论Linux、Windows或是Unix都有特定的内存模型。

这里唠叨一下L1缓存中的I-Cache和D-Cache,分别为指令高速缓存(Instruction Cache,简称iCache)和数据高速缓存(Data Cache,简称dCache)。为什么会做这样的区分要把一块Cache分成指令缓存和数据缓存呢?原因是在指令和数据本身就有很大的不同。指令一般是固定的,不会被修改的,所以使用只读内存不仅降低了制造设计成本也优化了CPU的执行效率,而将其制造预算投入到DCache中也可以一定程度上优化CPU性能。CPU缓存对于CPU至关重要,两款架构,制造工艺,晶体管数目相同的核心,更多的SRAM(静态随机半导体存储器)也会对性能带来质的飞跃,而SRAM价格非常昂贵。

总线、MESI协议、MSI协议就不唠叨了,等博主心血来潮再肝篇文章细说一下总线和这两个一致性协议。

指令重排序

上面提完了CPU缓存模型,接下来介绍的是指令重排序。写算法的朋友应该经常会接触到代码优化,比如拿cpp写算法的时候会打开O2优化。其实这种优化方式就是在进行指令重排序,目的是为了提高执行速度,执行效率。简单来说,编译好的程序和你写的程序相比是一般是不同的。一种情况是代码层的优化,编译器通过对线程执行代码顺序在不改变语义的情况下进行改变以达到优化的效果;另一种情况是指令级的优化,现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。

所以指令重排序有以上两种情况:

  1. 编译器优化重排
  2. 指令并行重排

Java 源代码会经历 编译器优化重排 —> 指令并行重排 —> 内存系统重排 的过程,最终才变成操作系统可执行的指令序列。

JVM虚拟机内存结构

需要注意JVM内存结构和JMM内存模型完全是不一样的东西,注意不要混淆!这里介绍JVM虚拟机内存结构是为了与JMM做区分

JVM虚拟机内存结构图

请添加图片描述

在HotSopt虚拟机中,将本地方法栈与虚拟机栈合二为一

  • class文件二进制流

class文件二进制流又Java编译器对.java源文件编译后的二进制字节码文件为class文件,class文件流为该文件的字节流形式。

  • 类加载子系统

类加载子系统负责从文件系统中加载编译生成的字节码文件,加载的.class文件在文件的开头有特定的标识。但Class Loader只负责.class文件的加载,文件是否可以成功运行由执行引擎决定。加载的类型信息会存放于方法区方法区(Method Area)中不仅存放类信息,还会存放常量池信息,包含数字常量和字符串常量(JDK8后字符串常量池移到了堆空间中)。

请添加图片描述

  • 垃圾收集器 GC

内存溢出应该不少见,为了解决这一窘境,GC出现了。GC负责释放不再使用的对象的内存以免发生内存溢出。GC主要负责对方法区和堆中进行清理,对于线程私有区域会随着线程消亡,而对于方法区和堆中则存储废弃的类信息或者不再使用的对象,此时GC会负责清理这片区域

  • 方法区

方法区是各个线程共享的区域,它用于储存已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。加载的类信息包括:1、类型信息,2、类型的常量池,3、字段信息,4、方法信息,5、类变量,6、指向类加载器的引用,7、指向Class实例的引用,8、方法表

堆通常是Java虚拟机中最大的一块区域,几乎所有实例对象都在这里分配内存,也是GC会“常顾”的区域。对于GC来说,GC会采用分代收集算法,也就是说堆内存中还会有新生代和老年代的区域。根据相关资料描述,此处还有Eden空间、From Survivor空间、To Survivor空间等。

根据Java虚拟机规范中规定,Java堆可以在物理不连续的内存空间中,只要是逻辑上连续即可。在实现时也可以设定为可不可拓展空间,当前的主流的虚拟机都是支持可扩展的。

  • 程序计数器

读过《计算机组成原理》的码友应该不陌生,JMM中的程序计数器可以看作是当前线程所执行的字节码的行号指示器,在字节码解释器工作时,就是通过程序计数器中的值来获取下一条需要执行的字节码指令,分支、跳转、循环、异常处理、线程恢复等操作都需要依赖程序计数器。

  • 虚拟机栈

与程序计数器相似,本区域在线程私有区域内,为线程私有随着线程共存亡。当程序调用方法时就会生成栈帧(Stack Frame)用于存放所需的局部变量、操作栈、动态链接、方法出口。方法调用对应入栈,方法结束对应出栈。

在Java 虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError 异常;当前大部分的Java 虚拟机都可动态扩展,只不过Java 虚拟机规范中也允许固定长度的虚拟机栈,当扩展时无法申请到足够的内存时会抛出OutOfMemoryError 异常。

  • 本地方法栈

本地方法栈与虚拟机栈类似,不同的是本地方法栈属于JVM而本地栈属于操作系统。本地方法栈则是用于存储虚拟机需要调用的本地方法。Java虚拟机规范中并未对此有严格要求,甚至于有些虚拟机并没有本地方法栈,而是将二者合二为一,譬如Sun HotSpot。

  • 字节码执行引擎

JVM 根据 Class 文件中给出的字节码指令,基于栈解释器的一种执行机制。

JMM(Java Memory Model)

什么是JMM?

Java内存模型(Java Memory Model,简称JMM)是用于描述Java程序中多线程并发访问共享内存时的规范或规定。通过这组规范来定义程序中各个变量(包括实例字段、静态字段和构成数组对象的元素)的访问方式,以达到Java程序能够多平台的目标。

JMM的三个特性

可见性

在Java程序中,多个线程可能需要同时访问和修改共享变量。为了确保每个线程都能访问到其他线程对共享变量所做的修改,JMM提供了一系列规则。

例如:线程A对变量value的值修改后,线程B也能立刻更新value的值。

有序性

编译器为了对程序进行优化会对代码块进行指令重排序的操作,但这种操作可能会对多线程带来难以预期的结果,因此JMM规定了happens-before规则来确保指令重排序后多线程之间的安全,体现了JMM的有序性。

happens-before这个概念最早诞生于 Leslie Lamport 于 1978 年发表的论文《Time,Clocks and the Ordering of Events in a Distributed System》,该论文中提出了逻辑时钟的概念,逻辑时钟并不度量时间本身,而是区分事件发生的先后顺序,其本质就是定义了一种happens-before关系

happens-before六个规则

规则解释
程序顺序原则一个线程内、按照代码顺序,写在前面的操作happens-before后面的操作
监视器锁规则对一个锁的解锁,happens-before于随后这个锁加锁
volatile变量规则对一个volatile变量的写操作happens-before于后面volatile变量的写操作
start()规则Thread对象的start()方法happens-before于此线程的每一个动作
程序中断规则对线程interrupted()方法的调用先行于被中断线程的代码检测到中断事件的发生。对象finalize规则:一个对象的初始化完成先行于发生它的finalize()方法的开始。
join()规则如果线程A执行 ThreadB.join() 那么线程B中的任何操作都happens-before于A中的ThreadB.join()
原子性

JMM规定了某些操作具有原子性,具有原子性的操作不会被其他线程中断。举个例子,当执行读取或者写入时,这个操作是不允许被其他线程中断的。更多详见下文。

八大原子操作

操作名解释
lock(锁定)作用于主内存的变量,它把一个变量标识为一条线程独占的状态
unlock(解锁)作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
read(读取)作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用
load(载入)作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中
use(使用)作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作
assign(赋值)作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作
store(存储)作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用
write(写入)作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中

JMM和JVM内存结构的关系

JMM主要关注多线程对共享变量的访问和修改行为以及相关的可见性、有序性和原子性问题,而JVM的内存结构则描述了虚拟机在执行Java程序时所使用的内存空间组织方式以及各个内存区域的作用和特性。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值