(一)初识Java内存模型

157 阅读9分钟

一、JVM内存模型

JVM内存模型: image.png

JVM内存主要分为以下部分:
1、虚拟机栈
2、堆区
3、方法区
4、程序计数器
5、本地方法栈
6、直接内存

二、各个内存模块详解

1、虚拟机栈

内存(Stack)的大体结构: image.png

虚拟机栈为线程独有,每个正在运行的线程,都有自己的线程栈。线程栈包含了当前正在执行的方法链/调用链上的所有方法的状态信息。

线程栈里面保存了调用链上正在执行的所有方法中的局部变量。
1、每个线程都只能访问自己的线程栈。
2、每个线程都不能访问(看不见)其他线程的局部变量。

即使两个线程正在执行完全相同的代码,但每个线程都会在自己的线程栈内创建对应代码中声明的局部变量。 所以每个线程都有一份自己的局部变量副本。

1、所有原生类型的局部变量都存储在线程栈中,因此对其他线程是不可见的。
2、线程可以将一个原生变量值的副本传给另一个线程,但不能共享原生局部变量本身。
3、堆内存中包含了 Java 代码中创建的所有对象,不管是哪个线程创建的。 其中也涵盖了包装类型(例如ByteIntegerLong等)。
4、不管是创建一个对象并将其赋值给局部变量, 还是赋值给另一个对象的成员变量, 创建的对象都会被保存到堆内存中。

栈帧内部结构: image.png

栈里是一个一个的栈帧(Frame),每个栈帧均代表一个方法。栈里栈帧的顺序代表这方法调用顺序,顶部栈帧为当前执行的最里层的方法,底部栈帧为最外层方法。

每启动一个线程,JVM 就会在栈空间栈分配对应的线程栈, 比如 1MB 的空间(-Xss1m)。

1.1 局部变量表:

存放编译期可知的各种基本数据类型、对象引用类型和returnAddress类型(指向一条字节码指令的地址:函数返回地址)。

long、double占用两个局部变量控件Slot。

局部变量表所需的内存空间在编译期确定,当进入一个方法时,方法在栈帧中所需要分配的局部变量控件是完全确定的,不可动态改变大小。

异常:线程请求的栈帧深度大于虚拟机所允许的深度—StackOverFlowError,如果虚拟机栈可以动态扩展(大部分虚拟机允许动态扩展,也可以设置固定大小的虚拟机栈),但是无法申请到足够的内存—OutOfMemorError。

1.2 操作数栈:

后进先出LIFO,最大深度由编译期确定。栈帧刚建立使,操作数栈为空,执行方法操作时,操作数栈用于存放JVM从局部变量表复制的常量或者变量,提供提取,及结果入栈,也用于存放调用方法需要的参数及接受方法返回的结果。

操作数栈可以存放一个jvm中定义的任意数据类型的值。

在任意时刻,操作数栈都一个固定的栈深度,基本类型除了long、double占用两个深度,其它占用一个深度

1.3 动态连接:

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。

这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。

1.4 方法返回地址:

当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。

方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。

注意:

堆中的所有对象,可以被所有线程访问, 只要他们能拿到对象的引用地址。
1、如果一个线程可以访问某个对象时,也就可以访问该对象的成员变量。
2、如果两个线程同时调用某个对象的同一方法,则它们都可以访问到这个对象的成员变量,但每个线程的局部变量副本是独立的。

2、堆区

堆及方法区结构图: image.png

堆内存是所有线程共用的内存空间,理论上大家都可以访问里面的内容。

但 JVM 的具体实现一般会有各种优化。比如将逻辑上的 Java 堆,划分为堆(Heap)和非堆(Non-Heap)两个部分.。这种划分的依据在于,我们编写的 Java 代码,基本上只能使用 Heap 这部分空间,发生内存分配和回收的主要区域也在这部分,所以有一种说法,这里的 Heap 也叫 GC 管理的堆(GC Heap)。

JVM采用分代设计理念,将 Heap 内存分为年轻代(Young generation)和老年代(Old generation, 也叫 Tenured)两部分。

年轻代又细分为新生代(Eden space)和存活区(Survivor space),在大部分 GC 算法中有 2 个存活区(S0, S1),在我们可以观察到的任何时刻,S0 和 S1 总有一个是空的, 但一般较小,也不浪费多少空间。

堆(Heap)及非堆关系图: image.png

具体实现对新生代还有优化,那就是 TLAB(Thread Local Allocation Buffer), 给每个线程先划定一小片空间,你创建的对象先在这里分配,满了再换。这能极大降低并发资源锁定的开销。

堆区结构在GC章节进行详细解析。

3、方法区

方法区结构图: image.png

Non-Heap也称为方法区,只是一般不归 GC 管理,里面划分为 3 个内存池。

  1. Metaspace, 以前叫持久代(永久代, Permanent generation), Java8 换了个名字叫 Metaspace. Java8 将方法区移动到了 Meta 区里面,而方法又是class的一部分和 CCS 交叉了?
  2. CCS, Compressed Class Space, 存放 class 信息的,和 Metaspace 有交叉。
  3. Code Cache, 存放 JIT 编译器编译后的本地机器代码。

4、程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie 方法,这个计数器值则为空(Undefined)。此 内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError 情况的区域。

程序计数器为虚拟机栈私有,一个线程栈一个程序计数器。

5、本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。 与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。

1、线程独有,每个线程一个
2、C、C++的方法在该区域
3、无法调优与管理,可不用理会

6、直接内存

JVM可以直接访问的内核空间的内存 (OS 管理的内存)

NIO ,提高效率,实现零拷贝(zero copy)