JVM内存区域以及程序运行时数据在内存中的变化

JVM内存区域

image

JVM运行时数据区

定义:JVM在执行JAVA程序的时候会把它所管理的区域划分为若干个不同的虚拟区域进行管理。

JAVA引以为豪的就是他的自动化内存管理机制。相比于C++的手动管理以及难以理解的指针来说,JAVA程序写起来就方便很多。所以要深入理解JVM就要先深入理解内存虚拟化的概念。

在JVM中,内存主要划分为堆,栈,方法区。

同时以线程的角度来划分也可以划分为线程私有区与线程共享区。

线程私有区:单独的一个线程对应单独的一片区域,线程与线程之间互不打扰。

线程共享区:被所有线程共享,且只有一份。

此处还涉及一个直接内存的概念。这个内存不属于JVM的运行时数据区,但也会频繁被使用。假设计算机的内存有8个G,虚拟机划分了5个G,那么直接内存就是剩下的3个G,JVM此时可以借助一些工具来使用直接内存。

JAVA的方法运行与虚拟机栈

线程私有

虚拟机栈

栈的数据结构:先进后出(FILO)的数据结构

虚拟机栈的作用:在JVM执行的过程中,存储了当前线程所运行的方法,以及方法内的数据,指令,返回地址。

虚拟机栈是基于线程的:哪怕单个线程中只有一个main()方法,也是以线程的方式运行的。在线程的生命周期中,所有参与计算的数据会频繁的出入栈。因此,虚拟机栈的生命周期和线程是一样的。

虚拟机栈的大小:JVM会为每个线程的虚拟机栈分配固定大小的内存,一般是1M。(-Xss参数)。所以虚拟机栈所能容纳的栈帧一定是有限的。若栈帧不断地进栈而不出栈,最终会导致当前线程虚拟机栈的内存空间耗尽。典型的如无结束条件的递归函数调用。

image.png

栈帧及其四大区域

在每个方法被调用的时候都会生成一个栈帧,并且入栈。一旦方法调用完毕就出栈,并且释放内存。栈帧用于存储局部变量表,操作数栈,动态连接,方法出口等信息。所谓的栈内存先进后出,指的就是栈帧压栈。image.png

局部变量表

顾名思义,存放我们的局部变量(方法中的变量)的。它的长度是32位,主要存放我们JAVA的八大基础类型。一般32位的都可以放下。64位使用高低字节,占两个也可以存放下。一般的方法创建出对象,我们在此只需要创建一个引用地址即可。

当进入一个方法时,这个方法需要栈帧中分配多大的局部变量表空间是完全确定的。在方法运行期间不会改变局部变量表的大小。注意:此处说的大小是指槽的数量

操作数栈

存放JAVA执行操作数的,它也是一个先进后出的栈。操作数栈就是用来操作数的。操作的元素可以是任意JAVA数据类型。所以当方法刚开始的时候,操作数栈是空的。

操作数栈我把它理解为JVM执行引擎的一个工作区。也就是方法在执行,才会对操作数栈进行操作,如果代码不执行,操作数栈就是空的。(个人理解:对于每个独立的栈帧,操作数栈就像我们的程序一样负责处理逻辑,局部变量表就像数据库一样存储数据)。

动态连接

JAVA语言特性多态(后续结合class与执行引擎以及方法调用的动静态分派章节一起会详细记录,此处先大概叙述一下)。

首先,看到了动态连接,那么是不是会猜一下是不是有静态连接呢?没错,所谓"连接",简单来说指的就是方法的调用。那为什么我们的栈帧中只有动态连接,没有静态连接呢?因为静态链接是在类加载阶段就确定不变,运行期一定不会变。而动态方法是类加载期间不确定,而运行时才确定下来。这里又牵扯到虚,非虚方法。动,静态分派的概念。之后的文章中会详细讲解。注:这里的确定下来指符号引用转换为直接引用,也就是后续运行时访问不需要做任何处理,直接就可以获取地址。

完成出口(返回地址)

当一个方法开始执行时,可能有两种方式退出该方法:

1. 正常完成出口
2. 异常完成出口
正常完成出口是指方法正常完成并退出,没有抛出任何异常(包括Java虚拟机异常以及执行时通过throw语句显示抛出的异常)。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者(调用它的方法),或者无返回值。具体是否有返回值以及返回值的数据类型将根据该方法返回的字节码指令确定。

异常完成出口是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。以一下代码为例:

image

image

很明显,当程序发生异常后,会继续执行catch中的内容。而方法之后的内容将被忽略。

无论是Java虚拟机抛出的异常还是代码中使用athrow指令产生的异常,只要在本方法的异常表中没有搜索到相应的异常处理器,就会导致方法退出。
无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法(调用层)执行状态。

方法退出过程实际上就等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者的操作数栈中,调整PC计数器的值以指向方法调用指令后的下一条指令。
一般来说,方法正常退出时,调用者的PC计数值可以作为返回地址,栈帧中可能保存此计数值。而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

image

程序正常

(调用程序计数器的地址回归用户线程)

三部曲:

1. 恢复上层方法的局部变量表和操作数栈

2. 把返回值(如果有的话)压入调用者栈帧的操作数栈中。

3. 调整程序计数器的值指向方法调用指令后面的一条指令。

程序异常

(通过异常处理表中的<非栈帧中的>)来处理。

出入栈栈帧变化

JAVA虚拟机栈存放的有局部变量表,操作数栈,动态连接以及完成出口,其中动态连接与完成出口搭配栈帧中的程序计数器,记录cpu的执行情况。因为CPU在多线程环境下是高速切换的状态,程序计数器会记录本线程本次执行到的位置,然后完成出口切出,等下次线程抢到执行权时,从程序计数的位置继续执行线程。此处的重点是局部变量表与操作数栈的协作。

image

此处代码在执行的过程中,以栈帧的角度分来分解程序的执行流程。入栈,出栈示意图。

image

程序计数器

只占用较小的内存空间,记录线程当前执行的行号,各线程之间相互独立,互不影响。

程序计数器只占用很小的内存空间,主要单独记录所属线程的所执行到的字节码地址。例如:分支,循环,跳转,异常,数据恢复等都依赖于程序计数器。由于JAVA是多线程语言,当CPU的线程数量超过核数时,线程之间会根据时间片轮循来争夺CPU资源。如果一个线程的时间片使用完了,或者因为其他原因资源被提前抢走了,那么这个线程的程序计数器就需要记录下一条运行的指令。程序计数器是JVM中唯一不会OOM的地方。

本地方法栈

由于JVM是JAVA虚拟机,所以内部有一套完整的指令执行流程。所以在运行JAVA方法的时候需要用到程序计数器。

当时本地方法栈(native修饰符)的方法时,这个方法不是JVM来执行,所以不需要程序计数器来执行。这是因为操作系统也有一个程序计数器,这个会记录本地方法区的代码的执行地址。所以如果执行到本地方法栈的方法时,虚拟机栈中的程序计数器会显示(Undefined)。

栈帧执行对内存的影响

反编译流程

对class进行反汇编 javap -c XXXX.class

字节码查看网址:https://cloud.tencent.com/developer/article/1333540 

反编译工具的使用

1.找到程序预编译后的.class文件

2.按住Shift+右键,打开DOS命令。

3.使用javap -c XXXX.class命令对class文件进行反汇编,执行结果如下。

image

image

流程:

0:x=1进入操作数栈

1:x=1进入局部变量表

2:y=2进入操作数栈

3:y=2进入局部变量表

4:z=1+2进入操作数栈(这里没有多的指令执行1+2)

5:z=3进入局部变量表

6:x=1从局部变量表进入操作数栈

7:y=2从局部变量表进入操作数栈

8:执行x+y(此时操作数栈会把值交给执行引擎执行,并把执行结果再返回操作数栈)

9:z=3从局部变量表进入操作数栈

10:执行(x+y)*z的结果(此时操作数栈会把值交给执行引擎执行,并把执行结果再返回操作数栈)

11:计算结果h进入局部变量表

13:h=9从局部变量表进入操作数栈(此处不连续可能结果较大,用两个位置存放)

15:h=9从操作数栈返回给调用的栈帧

在JVM中,如果提到基于解释执行的方式是基于栈的执行引擎。基于栈的引擎说的就是操作数栈。

本地方法栈的意义

本地方法栈与虚拟机的功能类似。JAVA虚拟机用于管理JAVA函数的调用。本地方法栈用于管理本地方法的函数调用。但本地方法不是用JAVA写的。例如Object.hashcode()方法。

本地方法栈专门服务的是native修饰符修饰的方法。甚至可以把本地方法栈与虚拟机栈合二为一,虚拟机规范中没有强制规定,各版本虚拟机自由实现。HotSpot就将两个区域合二为一。

线程共享

方法区

方法区与堆空间类似,都是共享内存区。所以方法区是线程共享的。假如一个类在加载进JVM之前,两个线程都想访问方法区中的同一个信息。此时就只允许一个线程去加载它,另外的线程必须等待(JVM自己实现了锁功能,在未来学习的单例模式之延迟占位类模式就利用了这一点)。 而我们经常提到的永久代和元空间,指的都是方法区的实现。永久代的出现,HotSpot实现者想把它做的类似于堆内存,这样垃圾回收器可以像管理堆内存一样管理方法区。然而永久代的大小是有上限的,导致方法区更容易出现OOM问题。在JDK6以后,开发者花了大心思,舍弃了永久代转而使用元空间。对元空间的实现,虚拟机要求十分宽松。不强制要求设置内存上限,甚至可以不实现垃圾回收。避免了此区域无用对象未完全回收而导致的内存泄漏(不处理!不作为!)。

符号引用

关于符号引用和直接引用,很多博客与书籍中都讲的比较抽象。下面说一下我的理解。

首先class常量池并不是我们的运行时常量池。class常量池是存在于class文件中的一片内存,它的作用就是需要在类加载的过程中,把类的相关信息转移到我们的方法区中。我们在动态连接的过程中,由栈帧中的直接引用访问到具体方法区中的方法。但是栈帧的方法能访问class常量池的类信息么?

肯定不能,因为此时数据压根儿还没进入到JVM运行时数据区。那么当class常量池中的数据要加载到我们的方法区时,方法区也是有一个引用去专门识别class常量池中的方法的。当通过符号引用把class常量池中的数据加载入方法区后,符号引用就转化为直接引用,也就是实际分配了一个运行时数据区的地址。供我们运行时数据区中的引用直接访问。(符号引用可以是任意类型的字面量,但具体命名规则不需要我们知道。只要直到符号引用可以帮我们准确的从class常量池把类数据加载进运行时数据区就行)。

即兴发挥一下:class常量池就像人口贩子。不同的类信息就像他们抓住的小孩儿。人口贩子哪有心情一个一个记住你们叫什么。就索性把这群孩子叫成1号,2号,3号(符号引用)......。当有人来买孩子时,人口贩子说:“诶!那个2号,你过来一下”。买家看孩子甚是喜欢,于是买下来孩子。但是孩子是自己人了总不能还叫2号吧,于是孩子有了名字张三。以后家里人叫孩子就直接喊张三了(直接引用)

直接引用

直接引用就是一个可以访问到真实地址的引用。(张三,张三喊你呢!)

字面量

所谓的字面量即变量的值。字面量只能以"="右值出现。"="左值叫做常量或变量。

int i=0;// i 是变量, 0是字面量

final int a = 10;// a是常量,10是字面量

string str = "hellow world";//str是变量,hello world是字面量

常量池与运行时常量池

当类加载到内存的时候,JVM会将class文件常量池(这个文件常量池是在class文件中的,还没有到方法区)中的内容加载到运行时常量池中。在解析阶段,JVM会把符号引用解析成直接引用(对象的索引值)。

例如:类中的一个字符串常量在class文件中时,存放在class文件池常量中;在JVM加载完类之后,JVM会将这个常量从class文件常量池加载到JVM常量池中。并在解析过程中,指定该字符串对象的索引值。运行时常量池也是共享的,多个类共用一个运行时常量池。clss文件常量池中所存放的多个相同字面量的字符串在运行时常量池中只会存一份。

常量池有许多概念。例如class常量池,字符串常量池,运行时常量池。

虚拟机规范以上定义只属于方法区,并没有规定虚拟机厂商的实现。

严格来说,Java中的常量池,实际上分为两种形态:静态常量池和运行时常量池。

1)所谓静态常量池,即*.class文件中的常量池,class文件中的常量池不仅仅包含字符串(数字)字面量,还包含类、方法的信息,占用class文件绝大部分空间。

     2)而运行时常量池,则是jvm虚拟机在完成类装载操作后,将class文件中的常量池载入到内存中,并保存在方法区中,我们常说的常量池,就是指方法区中的运行时常量池。

运行时常量池在JDK1,7之后被移动到堆内存中。这里的移动指的是物理空间,但逻辑上还是方法区。(方法区是逻辑分区)

永久代与元空间

JDK1.7之前用的是永久代。把字符串常量池的数据移动到了堆内存中实现。但是这样做容易造成性能问题以及内存溢出。永久代的大小是可以指定的,但是因为永久代依然占据着运行时数据区的内存,所以大小设置小了容易造成永久代溢出,如果设置大了容易造成老年代溢出,从而使垃圾回收变频繁导致效率降低。因此在之后的元空间把永久代的理论干掉了。元空间使用的是本地内存(直接内存),所以元空间理论上只受本地内存的大小影响。

扩展:当Orcle公司受够BEA获得了JRockit虚拟机所有权,准备把JRockit中优秀的功能移植到HotSpot中来。但JRockit没有永久代概念,因此给合并带来了很大的苦难。在JDK6之后,HoySpot开发团队决心下功夫把永久代的概念去除,逐渐改用为元空间。

元空间不再占用我们运行时数据区的内存,而是放到堆外内存中。也就是说只要机器的总内存够大,JVM就不会出现方法区的内存溢出。当然,无限制的使用依旧会造成操作系统的死亡。

简单来说,永久代和元空间指的都是方法区。

堆(Heap)

堆占用了JVM中内存空间最大的一部分,我们几乎申请的所有对象都是在堆内存中存储的。我们常说的垃圾回收,操作的对象就是堆。

堆空间一般是程序启动时就申请了,但是不一定会全部用完。堆一般设置成可伸缩的。(正常的堆随着程序启动一般只有很小。随着对象的不断创建而不断增大扩容)。

随着对象的频发创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在java中就叫做GC(Garbage Collection)。

那么一个对象在创建的时候到底是分配在堆还是栈上呢?

    对于基本类型来说:如果是方法体内声明了数据类型的对象(局部变量),他就会分配在栈上。如果是其他情况(例如成员变量),就会分配在堆中。

对于引用类型来说:new出来的对象会创建在堆中,而引用会保存在虚拟机栈中的局部变量表里。

扩展:那么new出来的对象一定在堆中么?

《Java虚拟机规范》中的描述是:“所有对象的实例以及数组都应当在堆上分配”。而随着即时编译技术的进步,尤其是逃逸分析技术的日渐强大,栈上分配,标量替换优化手段也已经导致这个说法不也不那么绝对了。

堆外内存(直接内存)

当JVM运行时会向操作系统申请大量内存进行数据存储。例如虚拟机栈,本地方法区,程序计数器。这块被称为栈区。操作系统剩余的内存也就是堆外内存。

他不是虚拟机运行时数据区的一部分。但受本机总内存限制。所以也会产生OOM、

小结:

1. 直接内存主要用过DirectByteBuffer申请的内存,可以使用参数“MaxDirectMemorySize”来限制大小(否则这个直到windows卡死也停不下来)

2. 堆外内存可以通过Unsafe或者其他JNI手段直接申请。堆外内存泄漏是很严重的。排查难度高,影响大,甚至造成主机死亡。

猜你喜欢

转载自blog.csdn.net/weixin_47184173/article/details/109550397