简要了解JVM的内存划分

概括

在如今大家使用的JVM中,一般都将运行时数据区划分为以下五块区域:

  • 方法区(Method Area)
  • 堆(Heap)
  • 虚拟机栈(VM Stack)
  • 本地方法栈(Native Method Stack)
  • 程序计数器(Program Counter Register)

这些区域,又可以按照其中的数据是否线程间可共享,将其划分为线程共享区线程独占区

其中,线程共享区包括:

  • 方法区

线程独占区包括:

  • 虚拟机栈
  • 本地方法栈
  • 程序计数器

整体结构如下图所示:
结构

线程共享区

线程共享区,也就是所有线程共用的一块内存区域,其中的数据可以被每个线程访问,在这部分区域的操作需要注意多线程下的并发安全问题

Java堆

关于Java堆,即使你完全不了解JVM,也应该听说过Java堆的概念,我们通过new来创建的对象就分配在这部分区域,也是最容易发生OutOfMemoryError(内存溢出错误)的地方

同时,因为这部分区域管理着我们所有创建对象的实例,而且是线程共享的区域,所以Java堆也是整个Java内存中最大的一块区域

关于其存放的内容,正式的描述是:

所有的对象实例和数组

其实数组也属于对象的一种,所以换句话说,Java堆存放着所有的(更严谨的说法是绝大多数)对象实例,因此我们Java的自动内存管理也主要作用于这块区域

虽然这部分区域属于线程共享区,但是仍可能在其中划分出线程私有的分配缓冲区(TLAB),也就是线程私有变量,但依然存放的是对象实例

同时,因为我们new出来的变量并不由我们手动释放内存,所以Java堆也是垃圾收集器作用的主要区域,为了提高效率,JVM会对Java堆做进一步的划分,这部分内容以及垃圾收集器的概念我会放在接下来的几篇文章中再进一步描述

刚才讲解的基本都是逻辑层面的概念,在物理层面,Java堆并不是一块连续的真实内存区域。我们都知道,连续内存的分配一般只会作用于小区域,像Java堆动不动几十m,甚至几个g的空间,一般都采用逻辑连续的结构,而真实物理的结构并不是连续的

Java堆默认是可以动态扩展的,可以通过-Xms和-Xmx两个虚拟机参数来分别调整初始堆大小和最大堆大小,但是动态扩展会额外消耗性能,所以一般为了使用方便,都会将这两个参数设为相同的值,比如:

-Xms:512m -Xmx 512m
方法区

如果你熟悉HotSpot虚拟机,就应该知道HotSpot在之前的版本中,使用了永久代来实现方法区,也就是说HotSpot将Java堆和方法区视为类似的区域,而实际上,这两块区域结构类似,但是存放的东西却十分不同,在方法区中,主要存放以下内容:

  • 被虚拟机加载的类信息
  • 常量
  • 静态变量
  • 被JIT编译后的代码等数据

一般我们提到方法区的时候,指的就是方法区中的运行时常量池,想了解这个运行时常量池的概念,首先就要了解常量池的概念,一般来说,常量池分为以下两种

  • 静态常量池:即class文件的常量池,保存着类的各种信息,包括字段和方法等,存放在编译生成的“.class”文件中
  • 运行时常量池:会在class文件加载后将其中的常量池载入方法区中,这部分区域就叫做运行时常量池

现在我们大致清楚了,也就是说方法区的常量池实际上就是“.class”中的常量池部分,我们平时所称的常量池实际上也是指方法区中的运行时常量池

我们可以看出这里的存放数据都有一个共同点,那就是基本不会在运行时修改的数据,也可以叫做常量。在这部分区域,依然可能会发生内存溢出的错误,所以我们可以通过-XX:MaxPermSize来设置方法区的容量上限

这部分区域存放的数据一般保持时间非常长,并不会像Java堆一样经常发生垃圾回收,方法区的垃圾回收一般只针对以下两种情况:

  • 常量池的回收
  • 类型的卸载

如果这部分的回收出现异常,则会成为内存泄露的隐患,但是这就属于JVM要考虑的内容了

线程独占区

线程独占区,顾名思义就是每一个线程都有一个自己的线程独占区,线程之间不能相互访问,只能通过线程共享区中的内容来达到通信的效果

虚拟机栈

虚拟机栈,全程为Java虚拟机栈,即Java Virtual Machine Stacks,因为处于线程独占区,所以每一个线程都有一个自己的虚拟机栈,其创建和销毁伴随着线程的创建和销毁

这部分区域存放的主要内容是Java方法执行的内存模型,对没错,只存放这么一种内容,有人可能会问,Java方法的执行是怎么存放在内存中的?在Java虚拟机栈中,使用了栈帧这一结构,在栈帧中,存放了以下内容:

  • 局部变量表(Local Variable Table):存放方法参数和方法内部存放的局部变量,具体的大小在编译时就已经固定
  • 操作数栈(Operand Stack):字节码指令会向其中写入和提取数据,一个栈容量能存放一个32位数据类型,栈的深度在编译时已经固定
  • 动态链接(Dynamic Linking):包含一个指向运行时常量池中存放的该栈帧所属方法的引用,为了支持一部分符号变量在运行期间转换为直接引用的过程
  • 返回地址(Return Address):也叫方法出口,分为正常完成出口和异常完成出口
  • 附加信息:取决于虚拟机的具体实现,可能会附加一些调试使用的信息

根据以上信息,栈帧可以理解为描述方法执行的信息,这里需要与方法区常量池中存放的方法信息区分开,它们两者的关系就可以理解为类和对象实例的关系,一个程序中只有一个类,但是却可以创建很多这个类的对象实例,也就是说,我们每执行一个方法,就会创建一个栈帧,但是方法的信息数据却只有一份

既然是栈,就会经常发生入栈和出栈的情况,当方法开始执行时,入栈一个栈帧,方法执行结束后,就会有一个栈帧出栈

这部分的异常有以下两种情况:

  • StackOverflowError:栈溢出错误,原因是线程请求的栈深度大于虚拟机允许的深度,一般发生在递归过深的情况
  • OutOfMemoryError:内存溢出错误,原因是在虚拟机栈的动态扩展时,无法申请到足够的内存

关于虚拟机栈,需要记住的就是其中存放的内容含义,以及注意局部变量表和操作数栈的大小在运行期间是不会改变的,其容量大小在编译时就已经完成分配

本地方法栈

在具体分析之前,我们先需要明确本地方法的含义,如果我们点进过一些底层类中,经常会发现一些方法上带有native的修饰符,用于标志一个方法是本地方法,而不是Java方法,比如Object中的hashcode方法:

	public native int hashCode();

我们都知道Java方法是用Java代码写的,而本地方法则是用C/C++甚至其他的代码写成的,通过执行引擎与本地方法库接口的交互,来间接调用了其他语言的程序,这就极大提高了效率,因为C系语言的执行效率是相当高的,我们如果在Java源码中发现某个方法是native方法,那就可以毫无顾忌的进行调用,不需要考虑效率问题

明白了本地方法的含义,实际上本地方法栈也明白了,因为本地方法栈和Java虚拟机栈的唯一区别就是,其中保存的不是Java方法,而是本地方法,其余的栈帧结构完全一致

程序计数器

这部分区域也是最容易理解的一部分区域,用于表示当前线程执行的字节码的行号,学过计算机组成的应该很容易联想到计算机中的程序计数器,没错,这两个实际的作用是一致的,任何循环、跳转和分支等操作都需要依赖于程序计数器

同时需要注意一点,如果正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址,如果执行的是本地方法,则这个计数器的值为空。同时,这部分区域也不会发生任何内存的溢出错误

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章