JAVA 的内存模型是很复杂的,其中不仅包括操作系统底层的设计,更是和硬件设备(CPU 结构)密切相关,了解清楚 JAVA 的内存模型对于我们后面的学习,尤其是同步有非常大的帮助,要不有些地方会理解不通的。
JVM,JMM 概念
在开头我们先来了解下2个概念:
JVM - Java Virtual Machine,java 语言解释器,俗称虚拟机,负责内存的分配(堆栈内存),回收(GC),解析 class 为硬件运行的机器码
JMM - Java Memory Model ,Java内存模型<定义了Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式,线程之间内存刷新的关系,是隶属于JVM的
简单来说,JVM可以理解为 java 执行的一个操作系统,而操作系统的内存模型就是 JMM
JAVA 内存模型
废话不多说,看图,之前好多废话我都删了,我重新写的这里
- 内存主要分5块:堆 - 方法区 - 程序计数器 - 本地方法栈 - 虚拟机栈
- 堆 和 方法区 这2快内存是共享的, 程序计数器 + 本地方法栈 + 虚拟机栈 这3快内存加起来是组成了线程栈,我们刚学 java 时总是听说的内存分堆内存和栈内存,这个栈内存就是指的线程栈,每个线程都由自己的线程栈,自然线程栈就是私有的了,一个进程的内存中随着线程数量的增加,会有多个线程栈内存出现
- 堆内存 - 内存中最大的一块,被所有线程共享,存放的是对象实例,堆内存若是细分的话还可以细分为 新生代、老年代,新生代里还可以细分为:Eden、From Survivor、To Survivor,这是因为 GC 自动回收机制回收的就是堆内存,划分的这么清楚就是为了提高堆内存回收效率
- 方法区 Method Area - 线程共享的,线程安全的,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,常量池和静态池也在方法区内
- 程序计数器 - 是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
- 本地方法栈 Native Method - 原生方法就是用 Java 调非 Java 代码的接口,方法的实现由非 Java方法实现,比如C和C++,此处需要记一下,因为调用非 Java 方法也会涉及 GC 和 OOM
- Java虚拟机栈 - 线程私有的,它的生命周期与线程相同。虚拟机栈描述的是 Java 方法执行的内存模型,每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息
扩展:
JDK 1.6 采用的是 HotSpot 虚拟机,对象在内存中存储的布局可以分为三块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)
基本类型数据和引用类型数据的存储位置:
有个和经典的面试题:" java 中的基本数据类型一定存储在栈中的吗?”,这句话肯定是错误的。数据是存在栈中还是存在堆中,取决于类型在何处声明
有个例子:
int[] array=new int[]{1,2};
我们 new 了一个对象,new int[]{1,2} 这个对象存在堆中,也就是说 1,2 这两个基本数据类型也是存储在堆中,这解释了基本数据类型一定是存储在栈中的吗这个问题
JAVA 内部的数据传递
再来说下栈,栈是个统称,栈的内部是还可以再去细分的,看这个简单的图大家都能发现里面分好几块,要是看更详细的资料,大家会发现分的块还要多,不过这无助于我们理解多线程,我们只要记住栈是单个线程私有的,有多个线程在工作,那么内存中就会存在多个栈内存区域
如下图:
线程处理数据的顺序:
- 这里指引用对象
- 习惯上我们把 堆内存 称为主内存
- Thread 对象把要处理的对象从主内存中 copy 一份到当前线程所在的栈内存中
- 假如这个处理耗时2秒,那么2秒后更新栈内存中这个对象的数据副本
- 最后把栈内存中对象的数据副本再写回到主内存中,我们称之为:刷新到主内存
看图逻辑会清晰一些
线程处理数据的过程可能跟我们之前的想法有些出入,并不是直接操作堆内存中所在的数据,然后把数据自己 copy 了一份去处理,拿到结果再刷新堆内存中的数据。
这就造成一个问题,有个时间差,单线程没什么,一切按顺序执行,那么多线程呢,多线程怎么办呢,要是 Thread1 在处理数据时,Thread2 也在同时处理数据,2者同时往主内存中刷新数据,那么主线程会刷新成哪个线程的数据啊。再者就算 Thread1 和 Thread2 前后往主内存中刷新数据,那么结果也是不多啊,Thread1 先执行,那么 Thread2 在操作同一个数据时应该是在 Thread1 的结果基础上的,现在变成了 2个线程都在同一个数上操作,即便是前后写入结果到主内存中,结果也不是我们预期的。
举个例子,主内存有个对象,有个参数 money =1 ,Thread1 ++,Thread2 +2,那么 Thread1 和 Thread2 同时执行,那么 Thread1 和 Thread2 中 这个对象的 copy 副本中的 money 都是1 ,然后 Thread1 先写入数据到主内存,Thread2 紧随其后,此时 money 是 3,那么这个结果我们想要的吗,不是啊,我们想要的结果是 4 啊,我们想让 Thread1 先把 money 变成 2 ,然后Thread2 在 money =2 的基础上再加2 ,变成4
这个例子搞懂了,多线程中我们面临的问题也就是很清晰了,怎么保持主内存数据和栈内存中数据副本的相同,那么这个就得靠 JAVA 多线程的同步机制了
硬件层面内存模型
上文书说到 java 的内存模型不光收到 JAVA 底层影响,更是受到硬件影响
下图是一个简化的现代计算机硬件结构图:
现在的 CPU 都有多个核心,每个核心都有自己的寄存器和高速缓存器
每一个 cpu 都有一个 registers (注册机),然后就是 L1 cache 一级缓存,虽然不大因为 cpu 直接从这里读取内存所以这里非常快,然后就是 L2 二级缓存这里就是两个 core 来共享的,到了 3L 缓存就是所有 core 共享的内存区域,离 cpu 越远的缓存会访问速度降低同时容量变大
寄存器 register
每个cpu都会有一系列的寄存器registers在cpu的内存中,而且这些寄存器是很重要的。cpu在寄存器上进行计算操作比在主内存中进行计算要快的多。这是因为cpu访问寄存器的速度比访问内存要快得多。高速缓存器 cache
每个cpu也会有一个cpu的cache内存。这是因为cpu访问cache比访问内存的速度要快得多,但是却比访问的寄存器要慢一些,所以cache的速度是介于寄存器和内存的。一些cpu还有多级cache,比如(Level 1 and Level 2),但是这对于我们理解java内存模型关系不大,我们只需要cpu有三层内存结构,寄存器-cache-内存(RAM)
CPU 在处理对象时,和线程一样会把对象 Copy 一份放到自己的 cache 中,然后先修改自己 cache 中的对象数据副本,然后再刷新回主内存中。
我们可以把 CPU 的寄存器,高速缓存看成另一个线程中的栈内存,都是会产生数据同步问题的,这样就好理解了。
JVM 工作内存和主内存
还是接着上面说的,这段是后来加的,前面的说的差不多,但是这里说的更正式,更专业
JVM 把内存分割为:主内存 | 工作内存 2个部分:
每条线程都有自己的工作内存,工作内存中保存的是主存中某些变量的拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量,线程之间无法直接访问对方的工作内存中的变量,线程间变量的传递均需要通过主内存来完成
JVM规范定义了线程对内存间交互操作:
- Lock(锁定) - 作用于主内存中的变量,把一个变量标识为一条线程独占的状态。
- Read(读取) - 作用于主内存中的变量,把一个变量的值从主内存传输到线程的工作内存中。
- Load(加载) - 作用于工作内存中的变量,把read操作从主内存中得到的变量的值放入工作内存的变量副本中
- Use(使用) - 作用于工作内存中的变量,把工作内存中一个变量的值传递给执行引擎。
- Assign(赋值) - 作用于工作内存中的变量,把一个从执行引擎接收到的值赋值给工作内存中的变量。
- Store(存储) - 作用于工作内存中的变量,把工作内存中的一个变量的值传送到主内存中。
- Write(写入) - 作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
- Unlock(解锁) - 作用于主内存中的变量,把一个处于锁定状态的变量释放出来,之后可被其它线程锁定。
是不是有点看的眼花缭乱啦,仔细看这些其实都是顺序执行的操作,很好理解,知道就可,同样这些操作有自己的特性:
- read - load,store - write 都是成对进行的,不允许单一出现使用
- 不允许线程丢弃它最近的一个 assign 操作,即变量在工作内存被更改后必须同步改更改回主内存
- 工作内存中的变量在没有执行过 assign 操作时,不允许无意义的同步回主内存
多线程并发的核心其实就是对于资源的可见性和有序性的处理
- 可见性 - 对于可见性来说,什么时候把线程工作内存中的变量副本同步到主内存中完全是 JVM 自己实现系统决定的,我找了好多资料也没有明确说明的,更具上面例子的测试,我发现有时候对数据的修改会马上同步到主内存,有时候要等到线程上下文切换时才会更新数据。另外再说一点,使用 volatile 同样也会由工作内存的问题,区别是工作内存中的修改会马上立即同步到主内存
- 有序性 - 有序性这个大家应该都门清,就是严格保证代码按照我们的逻辑执行,上面的例子就是个反面典型,执行成啥样我们完全控制不了
通过上面这个例子,就明明白白带出了多线程我们关心什么,多个线程同时对相同资源的使用,只要我们的代码中类似上面要处理相同的资源,那么我们必须要采用合适的多线程测量,否则执行成啥样谁知道
经典的内存分配面试题
问: 在方法中创建变量String demoStr = new String("Test!") , 说一下这行代码中的信息保存在JVM的那些区域
答: 首先 String demoStr这个局部变量名(保存了内存地址)会被保存到虚拟机栈中的方法栈帧中的局部变量表,而通过new String(),创建的String对象会在堆中被创建,栈帧中保存的地址指向对堆内对应对象,而具体代码的信息则在方法区。若是新建局部变量为基本类型如int,此局部变量的值也会被保存在栈帧中。当方法结束时,栈帧会立刻释放栈帧中对堆中对象的引用。
一些模糊的概念
其实除了上面我们讲到的 java 进程内存分配问题,Linux 系统本身分配内存还是有讲的,比如我们在 AS 内存分析器上可以看到 native 内存,Fresco 那里听到的匿名共享内存,学习 Binder 了解到的用户空间,内核空间
我曾经对这几个部分也是不知所谓,学了 Binder 以后内核空间到时知道了,剩下 2 个是不好理解,看过:Android匿名共享内存(Ashmem)原理 这篇文章之后知道匿名共享内存用的也是内核控件,用来跨进程交换大型数据,原理是在内核空间申请一块空间,而后传递给不同进程该内存在内核空间的地址,然后各进程生成相应的内存映射,这个内核空间引用传递用的也是用 binder 实现的
匿名共享内存例子有一些,比如 WMS 传递显示数据给 SurfaceFlinger 集成进行图层混排生成最终的帧
native 内存就不是内核空间了,我们关心 native 内存是因为 Bitmap 存储位置的变化
Android 系统对 dalvik 的 vm heapsize 作了硬性限制,java 进程申请的 java 空间超过阈值时,就会抛出OOM异常(这个阈值可以是48M、24M、16M等,视机型而定)
也就是说,程序发生OMM并不表示RAM不足,而是因为程序申请的 java heap 对象超过了dalvik vm heapsize。也就是说,在RAM充足的情况下,也可能发生OOM
这样的设计似乎有些不合理,但是 Google为什么这样做呢?这样设计的目的是为了让Android系统能同时让比较多的进程常驻内存,这样程序启动时就不用每次都重新加载到内存,能够给用户更快的响应。迫使每个应用程序使用较小的内存,移动设备非常有限的RAM就能使比较多的app常驻其中
从上面我们知道 java 进程申请 java heap 内存有限制,但是使用 C++ 申请的内存是没有限制的,所以 8.0 之后 官方把 bitmap 就移到 native 内存中了
NativeAllocationRegistry 是 Android 8.0引入的一种辅助自动回收native内存的一种机制,当Java对象因为GC被回收后,NativeAllocationRegistry可以辅助回收Java对象所申请的native内存
最后
好了内存模型了解到这里就差不多了,我们知道了不管是线程中的栈内存空间还是 CPU 的寄存器和高速缓存都会操作从主内存的copy 的数据副本,因时间差和多线程,多 CPU 核心同时操作同一个对象数据,会产生数据错乱的数据同步问题。大家了解了多线程数据不同步产生的根本原因,那么本篇的内容就算是达到目的了,知道了为什么我们才好有的放矢不是。
吐槽下本篇文章真是不好写,反复找了不好资料,尤其是找图很麻烦啊,反复看了很多资料,书也看了3本:《开发艺术探索》《进阶之光》《从小工到专家》,就怕说错了误导大家。这个知识点我也是了解的不是非常深入,有错误请大家指正,我第一时间修改。
写的不好......想砸电脑......