概述
Java的内存空间大致可以分为五个部分,分别是:本地方法栈,程序计数器,虚拟机栈,堆区,和方法区。
如图:
我们可以将上述五部分分为两个区域,一个是被所有线程共享的内存区域,另一个是仅被当前线程独占的内存区域。
线程共享区:方法区、堆
线程私有区:虚拟机栈、本地方法栈、程序计数器
所以对于在方法区和堆中的数据,我们考虑线程安全问题。
堆(Heap):
如图所示,它是被所有线程共享的一块区域。用于存放Java在运行过程中new出来的对象,几乎所有的对象实例都在这里分配内存。如果堆中没有内存完成实例分配,且堆也无法扩展时,会抛出OutOfMemoryError。
对于堆中的对象生命周期的管理由Java虚拟机的垃圾回收机制GC进行回收和统一管理。
类的非静态成员变量也放在堆区,其中基本数据类型直接保存值,而引用类型保存指向对象的引用,非静态成员变量在类的实例化时开辟空间并且初始化。
对于堆,其实从JVM的规范上来说,并没有严格意义上的分区,从不同角度来看,可以进行不同的逻辑划分,最常见的就是从垃圾回收的角度对堆内存进行划分
虚拟机栈(JVM Stack):
或者称作Java方法栈,它是为即时调用的方法开辟的空间,存放在运行期间用到的一些方法中的局部变量(基本数据类型的变量或者是指向其他对象的一些引用)。
因为方法执行时,被分配的内存就在栈中,当一段代码或者一个方法调用完毕后,栈中为这段代码所提供的基本数据类型或者对象的引用立即被释放。程序的执行对应方法的调用,而方法的调用实际上就对应着栈帧的入栈出栈
栈帧
什么是栈帧呢?可以认为是方法调用的一种封装
栈帧的生成时机
编译期间是无法确定Java方法栈的深度的,栈帧是根据实际情况,动态生成的。例如使用递归可能会出现的StackOverFlow异常,编译器是无法检测出来的。
栈帧的构成
-
局部变量表
- 主要存储方法的参数、定义在方法内的局部变量, 包括基本数据类型 (8大), 对象的引用地址,返回值地址。
- 局部变量表是一个数字数组,byte、short、char都会被转化为int,boolean类型 也会被转化为int,0代表false、非0代表true.
- 局部变量表的大小是在编译期间决定下来的,所以在运行时它的大小是不会变 的。
- 局部变量表中含有直接或者间接指向的引用类型变量时,不会被垃圾回收处理。
-
操作数栈 操作系统层面操作数是计算机指令的一部分,这里的是JVM层面的,用来存储操作数(大部分是方法内变量以及中间结果),方便指令顺序读取操作数。
例如,虚拟机的执行引擎在执行字节码指令时,或通过当前指令类型,从操作数栈中取出栈顶的操作数进行计算,然后将计算的结果入栈,接着执行后续指令。
如果虚拟机中存在多个栈帧,我们可以想象到先执行完的方法的返回值需要被当作是后执行方法的变量
例如:
运行时,A,B先后入栈。先执行栈帧B,m和n作为操作数入栈,求和后,将结果存入局部变量表。接着,这个中间结果又会成为栈帧A的操作数,所以需要再从栈帧B的局部变量表中将该值复制,进入栈帧A的操作数栈。
上述的过程虽然也能实现栈的功能,但可以有个优化,如图:
让下面栈帧的操作数栈和上面栈帧的部分局部变量表重叠在一起,使得在方法调用时可以共享一部分数据,无需进行额外的参数传递。
-
动态链接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用其他方法过程中的动态链接(Dynamic Linking)。 -
返回地址(Return Address)
分两种情况,正常,异常。无论通过哪种方式退出,在方法退出后都返回该方法被调用的位置。方法正常退出时,调用pc计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。如果异常退出,返回地址通过异常表来确定。
方法区(Method Area):
首先,方法区是Java虚拟机规范中的抽象概念,无论用的虚拟机是HotSPot还是JRockit等等,都有对应的方法区具体实现。
JDK8之前,HotSpot的开发者将面向堆的分代设计复用在方法区上,使用永久代作为HotSpot上方法区的实现,但是后来发现这种设计并不好,所以从JDK8开始,借鉴一些JRockit的设计思路,使用元空间来代替永久代作为新的实现。
所以永久代和元空间是方法区的不同实现。
那为什么用元空间代替永久代呢?
永久代实现方法区的缺点:
- 可能引起内存溢出。永久代的大小设置为多少,可以通过启动参数来指定,但其中存储的数据大小是动态变化的,若阈值设置的太小则可能导致频繁的类卸载或者内存溢出;设置太大又会造成内存浪费
- 永久代本身的设计较复杂,可能带来未知异常。它本身是面向堆设计的,其中的存储对象不是内存连续的,需要额外的存储信息以及对象查找机制来定位对象,较麻烦。
使用基于直接内存的元空间代替永久代就不会有这些问题
方法区用于存放.class二进制文件,包含了虚拟机加载的类信息、常量池(String字符串和final修饰的常量值等)、静态变量、即时编译后的代码、类的版本号等数据。它还有个名字叫做Non-Heap(非堆),目的是与Java堆区分开。
方法区是线程安全的。因为所有的线程都共享方法区,所以方法区里的数据访问必须被设计成线程安全的。
例如,假如同时有两个线程访问方法区中的同一个类,而这个类还没有被装入JVM,那么只允许一个线程去装载它,而其它线程必须等待。
同时如果静态成员变量的值或者常量值被修改了直接就会反应到其它类的对象中。
静态成员变量存储在方法区,非静态成员变量存储在堆区。
方法区的垃圾回收
这里并不算是重点,因为方法区的数据大部分需要稳定使用,一般不关注该区域的垃圾回收,但是这并不意味着完全不需要垃圾回收了 当方法区的内存占用到达一定阈值,还是需要回收的,不然也可能会抛出OOM的异常。
那么哪些信息可能会被回收?
比如说通过类加载进入方法区的类型信息 当内存紧张的时候可能会对小部分类进行卸载,被卸载的类需要再次使用的时候,就需要再次重新加载,再比如说提到的运行时常量池中的字符串常量值池,当内存紧张的时,也会对其进行部分回收。
常量池(Constant Pool):
常量池是方法区的一部分内存。
常量池在编译期间就将一部分数据存放于该区域,包含基本数据类型如int、long等以final声明的常量值,和String字符串。
运行时常量池
运行时常量池也是方法区的一部分,用于存放编译器生成的各种字面量和符号引用,这部分内容在类加载后存放到方法区的运行时常量池中。运行时常量池相对于class文件常量池的另外一个重要特征是具备动态性。主要存储数据如下:
编译期间产生的,主要是字节码中定义的静态信息。比如:
- 由字节码生成的Class对象
- 由字节码生成的字面量(编写代码时所定义的常量、自变量) 运行期间产生的,这部分比较灵活。比如:
- 运行时会将一部分符号引用转换为直接引用,那么这些直接引用可以存储进来
- 常见的字符串常量池 既然运行时常量池是方法区的一部分,自然也会受到方法区内存的限制。当常量池无法再申请到内存时会抛出OutOfMemoryError。
本地方法栈(Native Stack):
与java虚拟机栈发挥的作用相似,区别是java虚拟机栈是为虚拟机执行java方法的,本地方法栈是为虚拟机执行native方法 所谓native方法,是说非java语言实现的,往往是用C或cpp实现,和操作系统相关性高的底层函数。
程序计数器(PC):
在硬件层面上,PC就是一种寄存器,存储指令地址提供给处理器执行;而JVM层面上,它用来存储字节码的指令地址提供给执行引擎取值执行。 这两种程序计数器分别存在于硬件与软件中,实现不同,但是功能类似
附图
这里给大家整理了一些java引用对象,变量,常量的存储位置。
Java引用对象在内存中的存储位置
Java基本数据类型变量或常量在内存中的存储位置
END
大家在学习java的内存分区时,可以使用javap反编译class,得到可读性较好的字节码文件,通过一些指令帮助理解。后续有时间的话,本文会再次更新,附上一些例子。