java:JVM的内存结构和布局

class文件由JVM中的类加载器加载各个类的字节码文件,加载完毕之后会交由JVM执行引擎执行。在整个程序执行过程中,JVM会用一段内存空间来存储程序执行期间用到的数据和相关信息,这段内存空间被称作为运行时数据区,也就是JVM内存结构。java内存结构包括虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。方法区和堆是所有线程共享的内存区域;而java栈、本地方法栈和程序员计数器是运行是线程私有的内存区域:

一、堆

生命周期与虚拟机相同,堆处于物理上可以不使用连续的内存地址,但在逻辑上应该被视为连续的。被用来保存对象实例,几乎所有对象实例(包括数组)都要在堆上分配,但是并不是所有的对象都在这保存,深入理解java虚拟机中说道,随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配在堆上也逐渐变得不那么绝对了。由于现在有了逃逸分析技术,也可以将对象分配在栈上。

堆被分为两个部分:年轻代以及老年代,而年轻代又被划分为Eden区和Survivor区(Eden空间、From Survivor空间、To Survivor空间(空间分配比例是8:1:1)。在JDK8之前HotSpot选择在永久代中实现方法区,而方法区在逻辑上是独立于堆的,在物理上作为永久代则是堆的一部分。

在堆中分配内存的方法有碰撞指针(前提是区域内存规整,可以采用CAS原理加失败重试实现)和空闲列表(可以是不规整的内存,就是有一个表记录空闲的内存,然后分配后从该表中去除),堆空间不足时会出现OutOfMemoryError异常。

从内存分配的角度,被线程共享的堆可以划分出多个线程私有的分配缓冲区,但无论怎么划分存储的都是对象的实例,将java堆细分的目的也是为了更好的回收内存或更快的分配内存。

 

二、方法区

生命周期与虚拟机相同,可以不使用连续的内存地址,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,内部有运行时常量池存放的是编译期生产的各种字面量和符号引用

方法区是java虚拟机规范中定义的一种概念区域,规定了具有什么功能,但没有规定这个区域的执行细节。之前它在hotspot上被称为永久代,但是这两个并不等价,因为永久代是hotspot中的一个概念,其他jvm实现未必有。因为方法区本身作为Jvm的一种规范,而永久代则是hotspot对JVM规范的其中一种实现。JDK8之前hotspot在内存中划分出一块区域来存储类的元信息、类变量以及内部字符串(interned string)等内容,这种概念设计的目的是hotspot虚拟机将分代收集扩展至方法区。但是收集清理的效率并不高,有可能会导致永久代区域的内存溢出。所以在JDK8之后移除了方法区。

JDK8时移除了永久代但是方法区仍然存在,永久代被替换为了元空间。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间的最大区别在于:元空间并不在运行时数据区中,而是使用本地内存。因此元空间的大小仅受本地内存限制, 默认情况下元数据区的大小上限即为剩余物理内存的大小, 但是也可以指定最大元数据区大小。指定元数据区大小的参数为: -XX:MaxMetaspaceSize

原先永久代中类的元信息会被放入本地内存(也就是元空间),将类的静态变量和字符串常量池归入java堆中。这样HotSpot将会为类的元数据明确分配和释放本地内存。通过这种架构就能使用更多的本地内存。从一定程度上解决了原来在运行时生成大量类造成经常Full GC问题,如运行时使用反射、代理等。所以升级以后Java堆空间可能会增加。运行时常量池也被移回到了方法区中。

  1. 1.元空间:OpenJDK使用元空间存储其类元数据。它可以在Java VM进程的非Java堆内存占用中占很大一部分默认情况下,元空间自动增加其大小

  2. 2.堆内存:JVM进程内的内存,用于保存Java对象,并由JVM垃圾收集器维护。

  3. 3.本机内存/堆外内存:是在进程地址空间内分配的内存,该内存不在堆内,因此不会被Java垃圾收集器释放。

  4. 4.直接内存:类似于本地内存,但也意味着硬件中的基础缓冲区正在共享。例如,网络适配器或图形显示器中的缓冲区。这里的目标是减少相同字节在内存中被复制的次数。

 

三、虚拟机栈

生命周期与线程相同,使用连续的内存空间,是Java 方法执行的内存模型,每个线程有一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法返回出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。当前运行方法对应的栈帧叫做当前栈帧。

局部变量表中存储了编译期可知的各种Java虚拟机基本数据类型、对象引用和指向字节码指令地址的returnAddress类型,在栈帧中64位长度的long和double类型占用2个局部变量空间(Slot),其余数据类型只占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。​ 接下来操作数栈,其实在栈帧刚刚创建的时候,操作数栈是空的,java虚拟机可以从局部变量表或者对象的实例字段中,复制一些常量或者变量值到操作数栈中。也可以从操作数栈中取走数据。他的深度在编译期就已经确定了。 动态连接在线程中一个方法去调用另外一个方法,是通过符号引用来实现的,动态连接的作用就是把这个符号引用表示的方法转化为实际方法的直接引用。

如果一个线程请求的栈深度太深,比如出现无限递归就会不停的构造出栈帧,而每一层栈帧都占用一定空间,超出了虚拟机所允许的深度,就会出现StackOverFlowError,并且 Xss 规定了栈的最大空间,超出这个值就会报错。

如果说栈帧堆满了整个栈,会出现StackOverflowError(栈溢出)异常,栈也可以申请更大的内存,如果申请不到,会抛出OutOfMemoryError异常。

 

四、程序计数器

占用内存小,生命周期与线程相同,当前线程所执行的字节码的行号指示器,通过它可以获取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。如果线程正在执行的是一个Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Natvie 方法,这个计数器值则为空(Undefined)。

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

此内存区域是唯一一个在Java 虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

五、本地方法栈

与虚拟机栈所的作用是相似的,区别是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务(指非java方法)。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。

 

为什么要将堆和栈分开,栈不是也可以存储数据吗?

1、从软件设计角度分析,栈代表了处理逻辑,堆代表了数据,这样分开,使得处理逻辑更清晰。分而治之的思想,这种隔离、模块化的思想体现在软件中的很多地方。

2、堆和栈的分离,使得堆的内容可以被多个栈共享(即多个线程访问同一个对象)。这种共享的收益很多,这种共享提供了一种有效的数据交互方式(共享内存),另一方面,堆中共享的常量和缓存可以被所有栈访问,节省了内存。

3、栈因为运行是需要,比如保存系统运行的上下文,需要地址段的划分,由于栈只能向上增长,因此限制住栈存储内容的能力,而堆是根据需要可以动态增长的,因此栈和堆的拆分,使得堆动态增长成为可能,相应栈只需要记住堆中的一个地址即可。

猜你喜欢

转载自blog.csdn.net/ZytheMoon/article/details/105789732