目录
前言
程序计数器
java虚拟机栈
本地方法栈
java堆
方法区
运行时常量池
前言
首先, java程序在被加载在内存中运行的时候, 会把他自己管理的内存划分为若干个不同的数据区域, 就比如你是一个你是一个快递员, 一堆快递过来需要你分拣, 这个时候, 你就需要根据投放的目的地来为其投递到不同的流水线上, 以方便下一步操作
JVM同样也是如此: java咋运行的时候, 会把class加载到内存中, 并将其解析成为java运行时的数据结构.
其中
- 所有线程共享的区域:
- 方法区
- 堆
- 线程隔离的数据库
- 虚拟机栈
- 本地方法栈
- 程序计数器
下面我们逐步来拆解各个区域的功能
程序计数器
程序计数器的内存区域是个非常小的区域, 它用来表示当前的线程所执行到的指令的位置, 方便线程切换之后恢复之前的运行状态.
在Java的虚拟机概念模型中指出, 通过字节码解释器来修改程序计数器中值, 来选取下一条需要执行的字节码指令.
程序计数器是程序控制的指示器, 里面很多内容, 例如分支, 循环, 异常处理, 线程恢复等都是依赖程序计数器完成.
java虚拟机的多线程是通过线程轮流切换, 分配处理器执行时间 的方式来实现的, 在任何一个确定的时刻, 一个处理器(多核处理器里面就是内核)只会执行一个线程中的一条指令, 因此一个线程切换成另外一个线程之后, 如果不保存别切换出处理器的线程的运行状态, 那么下次这个线程重新上处理器执行的时候, 就不知道上次运行到什么地方了, 就需重新运行.
因此按道理来说, 每一个线程都应该有自己的运行状态, 也就是每个线程应该有自己的程序计数器(内存中), 因此我们将这种类型的内存区域称之为, 线程私有内存
java虚拟机栈
同上述的程序计数器一样, 虚拟机栈也是线程私有的? 为什么是线程私有, 就应该先了解什么是虚拟机栈, 功能是什么
既然是线程私有的, 也应该同程序计数器一样, 他们的生命周期与线程的生命周期一样 , 与程序计数器不同的是, 虚拟机栈描述的是, java方法执行的线程内存模型:
每个方法被执行的时候,Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程.
解释:
- 局部变量表: 存放了 编译期间可知的各种java虚拟机基本数据类型(boolean、byte、char、short、int、float、long、double), 对象引用, 例如String, (reference类型,它并不等同于对象本身,可能是一个指向对象起始地址的引用指针), 和returnAddress类型(返回值)
- 局部变量所需的内存在编译期间完成分配, 进入一个方法时候, 这个方法需要在栈帧中分配多大的局部变量空间是完全确定的, 在运行期间是不会更改局部变量表的大小(变量槽*)
java虚拟机里面什么是变量槽?
- 在 Java 虚拟机(JVM)中,局部变量槽(local variable slots)是管理方法中局部变量和方法参数的关键机制。它们在方法调用时提供存储空间,用于存储不同类型的数据
- 每一个java方法都有一个局部变量表, 用于存放该方法中的局部变量, 方法参数和一些中间计算结果, 局部变量槽就是这个表中的单元, 每一个槽可以存放一个或者多个数据项, 取决于jVM实现的细节
局部变量槽的结构
- 局部变量表是一个数组, 每一个槽都可以存储一个数据项, 槽的数量和类型由方法的局部变量表的大小来决定, 这个大小在编译期间确定, 由jvm维护
- 基本数据类型: byte、short、int、char、float 和 boolean 类型通常占用一个槽(4 字节), long 和 double 类型占用两个槽(8 字节),因为它们的大小比单个槽大
- 对象引用: 对象引用(Object 类型的变量)通常占用一个槽(4 字节或 8 字节,取决于 JVM 实现)
类似于结构体的对齐
- long 和 double 类型的数据需要按 8 字节对齐,因此在局部变量表中,它们占用两个槽,并且后续的槽也会对齐,以避免对齐问题
使用
- 当方法被调用时,JVM 会创建一个新的栈帧,其中包括一个局部变量表, 这张表的大小由方法的 code 属性定义,并在方法调用时分配
- 在方法执行期间,局部变量槽用于存储方法参数和局部变量 , 字节码指令通过对这些槽的读写操作来执行计算
- 当方法执行完毕后,其栈帧会被弹出,局部变量槽的数据会被清除。栈帧的销毁会导致局部变量槽的空间释放
局部变量槽的示例
public void exampleMethod(int a, long b, double c) { int x = 10; double y = 20.5; }
在这个方法中,局部变量槽的分配可能如下:
- 槽 0:存储参数 a(int 类型,占用 1 个槽)。
- 槽 1 和 2:存储参数 b(long 类型,占用 2 个槽)。
- 槽 3 和 4:存储参数 c(double 类型,占用 2 个槽)。
- 槽 5:存储局部变量 x(int 类型,占用 1 个槽)。
- 槽 6 和 7:存储局部变量 y(double 类型,占用 2 个槽)。
意义
- 字节码指令操作局部变量槽
iload
、istore
:加载和存储int
类型数据。lload
、lstore
:加载和存储long
类型数据。dload
、dstore
:加载和存储double
类型数据。- 这些指令使用局部变量槽的索引来指定操作的数据项
- !!!局部变量槽属于栈帧的一部分,而栈帧则是 Java 虚拟机栈的一个组成部分
太抽象了听不懂? 接下来我使用main方法列举一个例子:
- 当你启动一个java程序的时候, JVM首先会启动一个main线程, 也称为主线程, 主线程负责执行main方法, main方法是程序的入口点, JVM从main方法开始执行代码
- 在执行main方法之前, JVM会为main方法创建一个栈帧, 这个栈帧会压入到JVM的栈中去, 也叫作虚拟机栈, 每一个线程都拥有自己独立的虚拟机栈(main方法的栈帧由main线程所有)
- main方法局部变量表会被初始化, 存储 main 方法的参数(通常是一个字符串数组 String[] args)。操作数栈被初始化为空
main
方法的字节码被逐条执行。每条指令都会操作局部变量表和操作数栈。例如,加载局部变量到操作数栈,执行加法运算,然后将结果存储回局部变量表- 如果 main 方法调用了其他方法,每调用一个方法,JVM 都会创建一个新的栈帧,并将其压入虚拟机栈中。
- 当 main 方法执行完毕,它的栈帧会被弹出虚拟机栈
- 如果 main 方法没有显式地返回值,它将返回 void,JVM 会清理 main 方法的栈帧,并可能终止程序的执行(如果这是程序的最后一个方法)
什么是栈深度?
指当前线程的虚拟机栈中栈帧的总数。栈深度从栈底到栈顶表示方法调用的嵌套层次, 例如main方法中调用其他方法, main线程就会给自己管理的java虚拟机栈中再创建一个栈帧, 栈深度增加 1,
但是栈的深度也不是能随意增加的 , 有一个上限, 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常(栈溢出)
但是有的java虚拟机的实现, 是可以动态扩容栈容量的, 此时如果动态扩展的时候, 没有足够的内存 , 就会抛出OutOfMemoryError 异常(内存溢出)
本地方法栈
本地方法栈和java虚拟机栈所发挥的作用是非常相似的, 区别在于, java虚拟机栈是为java方法服务的, 本地方法栈是只是为使用到的本地方法服务的.
这也就是为什么大家一开始学习java虚拟机的时候, 虽然学习了本地方法栈和java虚拟机栈之后还是很容易忘记, 是因为你没有了解其本质, 一个是为java方法服务, 所以叫java虚拟机栈, 一个为本地方法服务, 因此叫做本地方法栈. (其实我觉得把java虚拟机栈改名为java虚拟机方法栈, 或者java方法栈可能会更见名知意)
因为他两其实作用一样, 有的虚拟机甚至直接将java虚拟机栈和本地方法栈合二为一, 与虚拟机栈一样,本地方法栈也会在栈深度溢出或者栈扩展失败时分别抛出StackOverflowError和OutOfMemoryError异常
这里就不过多解释 ... ...
java堆
算的上是java运行时内存区域的老大了 ...
java堆区是 虚拟机管理的最大一块内存之一, java堆是被所有的线程共享的一块区域, 在java虚拟机启动时就被创建 , 此区域唯一的目的就是存放对象实例, java世界里面几乎所有的对象都在这里分配了内存.
这里几乎这个字眼, 说明后续的java虚拟机版本中, 可能存在不在堆中创建存储的对象.
java堆也是垃圾收集器管理的内存区域, 因此被称为GC 堆, GC全称Garbage Collected Heap, 翻译就是垃圾收集堆.
既然涉及到对象的回收, 那么就需要考虑几个问题(后面解答):
- 这里的回收是什么意思?
- 为什么要回收?
- 不同的对象, 回收的标准一样吗? 怎么对一个对象进行回收?
- 回收的结果是什么?
其实你可以猜想, 既然是回收, 如果在生活中, 你觉得什么东西是有必要回收的, 无非就是这几种:
- 过时的
- 很少用到的, 而且很占空间
- 没有价值的
- 不常用的
有一些java虚拟机的实现中, 就是基于常用性来进行回收的, 例如分代回收机制. 所以Java堆中经常会出现, 新生代, 老年代, 永久代, Eden空间, From Survivor空间, To Survivor空间等名词 (这些区域划分仅仅是一部分垃圾收集器的共同特性或者说设计风格而已,而非某个Java虚拟机具体实现的固有内存布局)
但是到了今天,垃圾收集器技术与十年前已不可同日而语,HotSpot里面也出现了不采用分代设计的新垃圾收集器,再按照上面的提法就有很多需要商榷的地方了. 但是无论是什么回收技术, 目的只是为了更好地回收内存,或者更快地分配内存
Java堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的,这点就像我们用磁盘空间去存储文件一样,并不要求每个文件都连续存放, 但对于大对象(典型的如数组对象),多数虚拟机实现出于实现简单、存储高效的考虑,很可能会要求连续的内存空间
Java堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实现的
怕你们忘记, 再看看这个图:
方法区
方法区同堆区都是线程共享的, 它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据.
在JDK1,8以前, 许多程序员, 都喜欢把方法区称之为 永久代, 但事实上, 本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择使用永久代来实现方法区而已. 这样就可以像管理堆内存那样, 省去专门为方法区编写管理代码的麻烦.
但是这种设计导致了一种问题, 那就是方法区内存溢出, 因为永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小.
考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了.
到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替
根据虚拟机规范:
- 方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集
运行时常量池
方法区的一部分. 在Class文件中, 除了类的版本, 字段, 方法, 接口等描述信息之外, 还有一项信息就是 常量池表, 用于存放编译期间生成的各种, 字面量和符号引用, 这部分内容将在类加载后存放到方法区的运行时常量池中.
既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常