深入浅出JVM(二)内存模型


前言

前面介绍完一个类加载到Jvm中,接下来将探讨Jvm内存模型。上篇博客有举过一个不是很严谨的例子,把类加载到Jvm中,比作人进食的一个过程。那么今天就来解剖人体,看看里面到底存在什么?又分别有什么用?


JDK1.7版本JVM内存模型详解

关于Jvm内存模型,也就是运行时数据区。强烈建议自己画出来,甚至多画几遍。这里我贴出jdk1.7版本的jvm运行时数据区,jdk1.8和jdk1.7模型不同,但万变不离其宗,如果能搞懂1.7版本的,拿下1.8版本的内存模型也是不在话下。
在这里插入图片描述

1、程序计数器

一看我画的图,这个程序计数器好像体积最大的样子,实际上它是一块较小的内存空间。它是当前线程执行字节码的信号指示器,存的是下一步执行指令的地址。好比马路上的指示牌,告诉你接下来往哪走。通常的一些指令有跳转、循环、异常处理等。

它之所以是线程私有,是因为我们平常的多线程是通过线程轮流快速切换并分配处理器执行时间的方式来实现的,在任何一个确定的时候,一个处理器都只会执行一条线程中的指令。为了线程切换后能够恢复到正确的执行位置,每一个线程都需要有一个独立的程序计数器。

当前线程执行的是一个java方法时,那么计数器记录的时正在执行的虚拟机字节码指令的地址,这个前面也是说到过。当当前线程执行的不是java方法而是native方法时,这个时候计数器的值则为空,native就是本地方法,用C语言写的方法,一般本地方法都是金字塔的基脚。通过上面得知,该区域存数据情况。因此这个内存区域,是唯一一个java虚拟机规范中没有规定任何OOM(内存溢出)异常情况的区域

我感觉关于程序计数器了解到这里就够了,这小节内容相对来说很少也容易理解,所以引入后面会多次提到的OOM(内存溢出)的概念。很多人搞不清楚内存溢出和内存泄漏是咋回事。
OOM内存溢出,程序申请了一个内存后,但是区域的内存空间确不够了,比如我new了一个对象,该对象1M大,且要存放在堆中的。但是堆中能用的内存小于1M,此时就会报OOM。
在讲内存泄漏前,先打比方概括下内存溢出,比如我们D盘,还有10个G,我们要放11个G的视频进去,此时“程序就会报OOM”。通常情况下,我们可以清理D盘,把不必要的东西删了,(在程序中这种清理是自动的也就是后面会讲到的GC机制)。但是有些文件,你用管理员的身份都无法删除,无法对其内存空间进行管理,这个就是内存泄漏。它占了内存,却无法对它进行清理(管理)。

内存泄漏,是指程序在申请内存后,无法释放已申请的内存空间。而内存泄漏很常见,你想想一个庞大的体系,和一个复杂的机制。再GC的时候,有些算法失误,或者其他特殊情况导致的内存泄漏也是在情理之中的。但是如果一直出现内存泄漏的话,那么就很可能引发OOM。比如我按原理论给它分配内存,但它又不受我管控,导致它越来越大,堆积起来,就会导致OOM。

2、Java虚拟机栈和本地方法栈

栈,是描述程序方法执行的内存模型。之所以把Java虚拟机栈和本地方法栈写在一起,是因为他们的区别很小。本地方法栈执行的是native方法,也就是C语言编写的最底层的方法。而Java虚拟机栈执行的是Java方法。

2.1、Java虚拟机栈

前面说到,栈,是描述程序方法执行的内存模型。而Java虚拟栈则是描述Java方法的内存模型。官方解释:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程

栈结构图

同样也是强烈建议能够多画几遍,把栈的结构掌握好。我们从官方解释的一段子开始咬文嚼字。

2.1.1、栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)[1]的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。这也是段很官方的一段解释,很容易发现,对于栈帧的解释和对栈的解释几乎一样,这是因为栈帧是栈的一个”子集”。栈帧的入栈是往下,出栈是往上。这种数据结构好比一个弹夹。一般来说最先压进去的最后出,注意是一般来说,出栈顺序并不等于压栈的倒序。这里引用一个题,帮助理解其数据结构。

在一个栈的输入序列为12345 下面哪个不可能是栈的输出序列?

A. 23415 B.54132 C.23145 D.15432

第二个。54132不可能。

23415------>1进栈,2进栈,2出栈,3进栈,3出栈,4进栈,4出栈,1出栈,5进栈,5出栈
23145------>1进栈,2进栈,2出栈,3进栈,3出栈,1出栈,4进栈,4出栈,5进栈,5出栈
15432------>1进栈,1出栈,2进栈,2进栈,4进栈,5进栈,5出栈,4出栈,3出栈,2出栈

当我们写一个静态方法,再在内部调用本方法时,这个时候就会一直压栈一直压,不会出栈。就会报StackOverflowError异常,栈溢出。也会报OOM异常,对于栈结构来说,是线程私有的。比如我们栈的内存是6G,假设一个线程占1G,开7个这样的线程,如果不考虑内存的情况下,会有七个这样的栈。但是现实是你的栈直接完犊子了,这时程序就会报OOM异常。这俩个异常比较有意思,一种是往下钻,钻到栈的最大深度就报StackOverflowError 。一种是往周围扩大,挤破内存报OOM。
另外值得一提的是,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。因为在编辑程序代码的时候,栈帧的元素已经确定了,也就是说其内存大小,在编辑期间就确定了。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

另外关于栈的俩种异常,还是有必要了解。
第一种,

2.1.2、局部变量表

局部变量表(Local Variable Table)是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。它是一种连续的槽结构。
在这里插入图片描述
这些槽就是用来存八大基本类型的数据和一些引用对象实例的数据,(boolean、byte、char、short、int、float、long、double、reference或returnAddress)。值得注意的是,String并不是存在局部变量表里,因为他不是八大基本类型,string的局部变量存放在运行时常量池,运行时常量池在jdk1.6 1.7 1.8的位置是都有变化的。这个后面介绍。

2.1.3、操作数栈

操作数栈,它存储的数据类型也局部变量表也是一样的。但它不是用来存储方法中的局部变量的。而是一种类似PC寄存器的指令,例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。另外和局部变量表不同的是,局部变量表是通过索引定位使用局部变量表,而操作数栈,是一种先入后出的栈。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中, 会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。

2.1.4、动态链接

每个栈帧都包含一个指向运行时常量池[1]中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)

3、方法区

前面讲的栈和pc计数器都是线程私有的,接下来的方法区以及堆都是线程共享的。
方法区:它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
什么是虚拟机加载的类信息呢?这个在上一篇博客有特别介绍过。在类的生命周期中,加载的三件事情,其中一件事情就是在方法区中生成一个代表这个类的java.lang.Class对象。而这个对象就是用来记录这个类的信息的类。
为了能够像管理Java堆一样管理内存,虚拟机使用永久代来实现方法区。方法区的限制其实非常宽松,可以设置它的大小也可以扩展,还可以对它不实现GC,虽然使用永久代来实现方法区,但进入方法去的数据并非永久存在,通常对这个区域GC收集的目标主要是对常量池的回收以及对类型的卸载。当存储的数据超过上限时,也会报OOM的错误。
在这里有必要简单介绍一下 永久代 老年代 新生代的概念

在内存模型中,以前很多人直接讲内存分为堆栈,PC计数器太小没考虑还说的过去,那方法区呢?其实jdk1.7之前,方法区还是属于堆中的一部分,而1.7版本。在堆中划分了一块内存给方法区,1.8版本中,方法区直接防止JVM内存外了,相关概念可以参考这里,而对于堆来说,在1.8之前,可以分为三个部分。分别是永久代、老年代、新生代。而新生代又分为伊甸园区和俩个幸存区。这里只是简单介绍,因为这是堆的结构,后面会详细介绍。

3.1、运行时常量池

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池。它是方法区的一部分,随着jdk的升级,它也跟着方法区的位置变化而变化。
运行时常量池和class文件常量池的区别。

class常量池 是在编译的时候每个class都有的,在编译阶段,存放的是常量的 符号引用
运行时常量池是在类加载完成之后,将每个class常量池 中的符号引用值转存到 运行时常量池 中,也就是说,每个class都有一个 运行时常量池 ,类在解析阶段 ,将 符号引用 替换成 直接引用 ,与 字符串常量池 中的引用值保持一致。
运行时常量池里的内容除了是class文件常量池里的内容外,还将class文件常量池里的符号引用转变为直接引用,而且运行时常量池里的内容是能动态添加的。不一定得在编译期间
才能产生,运行期间也能讲新的常量加入到池中,这是运行时常量池相对于Class文件常量池的另外一个最重要特征就是具备动态性。

在JDK1.7之前运行时常量池逻辑包含字符串常量池存放在方法区, 此时hotspot虚拟机对方法区的实现为永久代
在JDK1.7 字符串常量池被从方法区拿到了堆中, 这里没有提到运行时常量池,也就是说 字符串常量池被单独拿到堆,运行时常量池剩下的东西还在方法区, 也就是hotspot中的永久代
在JDK1.8 hotspot移除了永久代用元空间(Metaspace)取而代之, 这时候字符串常量池还在堆, 运行时常量池还在方法区, 只不过方法区的实现从永久代变成了元空间(Metaspace)
4.2String.intern在JDK6和JDK7之后的区别(重点)
JDK6和JDK7中该方法的功能是一致的,不同的是常量池位置的改变(JDK7将常量池放在了堆空间中),下面会具体说明。intern的方法返回字符串对象的规范表示形式。其中它做的事情是:首先去判断该字符串是否在常量池中存在,如果存在返回常量池中的字符串,如果在字符串常量池中不存在,先在字符串常量池中添加该字符串,然后返回引用地址

4、堆

这是jvm内存模型的重角色,所以放到最后压轴。它不仅仅是重角色,连块头也是最大的,占的内存最大,它存在的目的就是存放对象实例。和方法区相同的是,也是线程共享的一个区域。几乎所有对象都在这里分配内存(方法区中也提到过class类的实例),jvm描述的是所有对象实例以及数组都要在堆上分配,但道高一尺魔高一丈,总有方法能逃避堆上分配内存。
垃圾收集器管理的最大的区域就是堆,要再细一点的话,最大的区域就是堆中新生代的伊甸园区。这也是绝大部分对象的出生地,前面也有做过铺垫,提到过新生代、老年代、永久代。想要了解堆,它的结构图必不可少。

在这里插入图片描述
那么永久代哪去了呢?jdk1.7的时候,永久代通过方法区实现了,jdk1.8以后(包括1.8),永久代被移至jvm内存外去了。后面会总结jdk升级后内存模型的变化。
下面逐个介绍
伊甸园区(Eden):这几乎是所有对象的出生地(还有些大的对象,会直接分配到老年代中)。他占新生代的8/10,伊甸园区也是GC收集的主要场所,对象在这里基本上都是朝生夕灭,在这个区域,GC的力度和收获还是很大的。当该区域内存不够的时候就会触发Minor GC。关于三种GC之间区别这篇博客还是讲解的相当到位的。JVM 垃圾回收之Minor GC、Major GC和Full GC之间的区别

幸存form区(From Survivor):这个区域占新生代的1/10,前面说到,当伊甸园区满了后触发Minor GC,存活的对象就被复制到了该区域。注意是复制,不是直接过去。

幸存to区(To Survivor):从伊甸园复制到from区,这个时候to区绝对保证是空的,伊甸园区又满了,就这一时刻而言,目前情况伊甸园第二轮满,from区存在第一轮存活的对象,to区为空。下一时刻第二轮伊甸园的GC结束,目前情况 伊甸园区 空,from区空,to区存活第而轮from区的对象和第二轮from区(第二轮伊甸园复制过来的)的对象。第三轮GC前一刻,伊甸园区满,from区存在上轮to区的对象,to区为空。第三轮GC后,伊甸园和from区的对象又会复制到to区,如此重复。对对象而已,每次GC年龄都加一,默认超过15岁自动到老年代去。这个过程有些复杂,建议画图理解。
在这里插入图片描述

这也就是垃圾收集算法中的一种,复制算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,

老年代:占堆的2/3,它用来存GC后还长期存活的对象以及新生的大对象。老年代满了会触发Major GC。
堆暂时介绍到这里,介绍了堆的结构以及堆中内存分配的逻辑。包括垃圾收集算法的复制算法,后面还会介绍其他垃圾收集器以及其他垃圾收集算法。最后在总结一下jdk1.7与jdk1.8内存模型的变动。

5、jdk1.7与jdk1.8内存模型的变动

在这里插入图片描述
最大的变化就是方法区废弃了,而是用元数据区代替了永久代,由于永久代内存经常不够用或发生内存泄露,爆出异常,改用元空间就能使用直接内存从而得到改善。因为元空间不在jvm运行内存中,而是在直接内存中。这也是元数据和永久代最大的区别,元数据在直接内存中。
关于运行时常量池以及字符串常量池,在1.7中,就开始将他们在方法区中单独分割出来,他们原本属于方法区,但是1.7后存在堆中。逻辑上是属于方法区,但实际物理位置是存在堆中。
字符串常量池里的内容是在类加载完成,经过验证,准备阶段之后在堆中生成字符串对象实例,然后将该字符串对象实例的引用值存到string pool中。

总结

综上介绍了内存模型,也算得上是了解了jvm的“五脏六腑”,其身体的运行机制,后面会再做介绍。革命尚未成功,同志仍需努力。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值