运行时数据区
??运行时数据区可以细分为五个模块:栈,堆,寄存器,方法区和本地方法栈,如下图所示。
1 JVM 的栈
1.1 栈的基本介绍
??基本概念:JVM的栈有两个,分别是虚拟机栈和本地方法栈。这里以虚拟机栈为例,本地方法栈和虚拟机栈基本相同。 ??栈的特点: ??a.对于每个线程,将创建单独的运行时栈。对于每个方法调用,将在栈存储器中产生一个条目,称为栈帧。所有局部变量将在栈内存中创建。栈区域是线程安全的,因为它不共享资源。 ??b.虚拟机栈是线程私有的,它的生命周期与线程相同(随线程而生,随线程而灭); ??c.如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态扩展,如果扩展时无法申请到足够的内存,就会抛出 OutOfMemoryError 异常;
1.2 栈帧
??定义:栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。它是虚拟机运行时数据区中的 java 虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。 ??栈帧初始化大小:在编译程序代码的时候,栈帧中需要多大的局部变量表内存,多深的操作数栈都已经完全确定了。 因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。 ??栈帧结构:如下图所示,在一个线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
1.3 栈帧-局部变量表
??在栈帧中,局部变量表占用了大部分的空间,那么接下来我们看下局部变量表的基本概念与特点。 ??基本概念:每个栈帧中都包含一组称为局部变量表的变量列表,用于存放方法参数和方法内部定义的局部变量。 ??特点: ??a.局部变量表的容量以变量槽(Variable Slot)为最小单位; ??b.局部变量表中的 Slot 是可重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码程序计数器的值已经超过了某个变量的作用域,那么这个变量相应的 Slot 就可以交给其他变量去使用,节省栈空间。
1.4 栈帧-操作数栈
??方法的执行操作在操作数栈中完成,每一个字节码指令往操作数栈进行写入和提取的过程,就是入栈和出栈的过程; ??操作数栈的每一个元素可以是任意的 Java 数据类型,32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2; ??简单的理解,操作数栈存放的是当前正在操作的变量,可以是局部变量或者对象实例字段中的常量和变量。比如执行到代码a=b+c时就会把a,b,c都存入到操作数栈来。
1.5 栈帧-动态链接
??动态链接保存的是一个引用或者说指针,它指向该栈帧所属方法在运行时常量池(JVM运行时数据区的)中的地址,它支持着Java的多态特性。 ??在 Class 文件的常量池(存储字面量和符号引用)中存有大量的符号引用(1. 类的全限定名,2. 字段名和属性,3. 方法名和属性),字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。 ??这些符号引用一部分会在类加载过程的解析阶段转化为直接引用(指向目标的指针、相对偏移量或者是一个能够直接定位到目标的句柄),称为静态解析。另外一部分将在运行期期间转化为直接引用,称为动态链接。
??看到这里的同学一定会产生和我一样的疑问,既然在类加载的链接解析阶段已经把符号引用解析为直接引用了,为什么还要来个动态链接? ??其实结合Java的多态特性就很好理解了,有些是在编译期可知且运行期保持不变的方法,有些则是要在运行期才能知道具体调用的方法,前者在类加载时就静态解析了,后者则需要运行时动态链接。
1.6 栈帧-返回地址
??方法返回地址是方法在PC寄存器中的值,也即是该方法的指令地址,方便执行引擎在执行完该方法后,回到该方法对应的指令行号,这样才能继续执行下去。 ??返回地址代表的是方法执行结束,当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令(例如:return),这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。 ??另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 throw 字节码指令产生的异常,只要在本方法的异常处理器表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。 ??方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整程序计数器的值以指向方法调用指令后面的一条指令等。
??学完栈之后,也许还有一个问题萦绕在你心头:我们都知道,new一个类之后,类的实例是保存在堆区的,但类的引用存在哪里呢? ??先说答案,在方法中new的对象引用存在该方法所属栈帧的局部变量表中,在方法外就看是在哪个类内部new的,在a类中new的b类的引用存放在a类的运行时常量池中。 ??我们new一个类要么是在方法内,要么是在方法外的某个类内,也就是下面代码中的a_test和b_test,他们的实例都保存在堆区,a_test的引用保存在该main方法所属栈帧的局部变量表中,b_test的引用保存在JVM_demo的运行时常量池中,该运行时常量池位于方法区,可以看完下面的方法区介绍再回头看看这个问题。
class test{
int a;
boolean b;
}
public class JVM_demo {
test b_test = new test();
public static void main(String[] args) {
test a_test = new test();
}
}
2 JVM 寄存器
??基本概念: ??每个线程启动的时候,都会创建一个 PC(Program Counter,程序计数器)寄存器。PC 寄存器里保存的是下一条将被执行指令的地址,供执行引擎读取,这里的地址可以是一个本地指针,也可以是在方法区中相对应于该方法起始指令的偏移量。
??特点: ??它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域; ??在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致; ??任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行方法的 JVM 指令地址:或者,如果是在执行 native 方法,则是未指定值(undefined); ??它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成; ??字节码解释器工作时就是通过改变这个计数器的值来选取下一个条需要执行的字节码指令; ??它是唯一一个在 Java 虚拟机规范中没有规定任何 OOM 情况的区域。
3 JVM 方法区
??定义:方法区,也称非堆(Non-Heap),是一个被线程共享的内存区域。其中主要存储加载的类字节码、class/method/field 等元数据对象、static-final 常量、static 变量、JIT 编译器编译后的代码等数据。另外,方法区包含了一个特殊的区域 “运行时常量池”。 ??对于习惯在 HotSpot 虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为 “永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC 分代收集扩展至方法区,或者说使用永久代来实现方法区而已。对于其他虚拟机(如 BEA JRockit、IBM J9 等)来说是不存在永久代的概念的。
3.1方法区存放的数据
??在讲解方法区内存放的数据之前,我们先通过示意图来直观的看下,方法区存放的数据与堆内存之间的关系。如下图所示:
??从图中可以看到,方法区存放了 ClassLoader 对象的引用,也存放了一个到类对象的引用,这两个引用的对象实例会存放到堆内存中。 ??类型全限定名:全限定名为 package 路径与类名称组合起来的路径; ??类型的直接超类的全限定名:父类或超类的全限定名; ??类型是类类型还是接口类型:判定当前类是 Class 还是接口 Interface; ??类型的访问修饰符:判断修饰符,如 pulic,private 等; ??类型的常量池:这部分会在下文进行讲解; ??字段信息:类中字段的信息; ??方法信息:类中方法的信息; ??静态变量:类中的静态变量信息; ??一个到类 ClassLoader 的引用:对 ClassLoader 的引用,这个引用指向堆内存; ??一个到 Class 类的引用:对对象实例的引用,这个引用指向堆内存。
3.2 运行时常量池
??先回顾下Class 文件结构中的常量池的相关知识。 ??Class 文件中的常量池: ??在 Class 文件结构中,最头的 4 个字节用于存储 Megic Number,用于确定一个文件是否能被 JVM 接受,再接着 4 个字节用于存储版本号,前 2 个字节存储次版本号,后 2 个存储主版本号,再接着是用于存放常量的常量池,由于常量的数量是不固定的,所以常量池的入口放置一个 u2 类型的数据 (constant_pool_count) 存储常量池容量计数值。
??常量池主要用于存放两大类常量:字面量(Literal)和符号引用量(Symbolic References)。更加具体的知识,同学们可以翻看之前相关的小节内容。
??回到正题,来看运行时常量池。 ??Tips:其实 Class 文件中的常量池与运行时常量池的关系非常容易理解,Class 文件中的常量池用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。简单总结来说,编译期使用 Class 文件中的常量池,运行期使用运行时常量池。
??运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用比较多的就是 String 类的 intern() 方法。
??常量池的优势 ??避免频繁的创建和销毁对象而影响系统性能,实现对象的共享。例如字符串常量池,在编译阶段就把所有的字符串文字放到一个常量池中。 ??节省内存空间:常量池中所有相同的字符串常量被合并,只占用一个空间。 ??节省运行时间:比较字符串时,== 比 equals () 快。对于两个引用变量,只用 == 判断引用是否相等,也就可以判断实际值是否相等。
3.3 方法区内存变更
??方法区的实现,虚拟机规范中并未明确规定,目前有 2 种比较主流的实现方式: ??HotSpot 虚拟机 1.8之前:在 JDK1.6 及之前版本,HotSpot 使用 “永久代(permanent generation)” 的概念作为实现,即将 GC 分代收集扩展至方法区。这种实现比较偷懒,可以不必为方法区编写专门的内存管理,但带来的后果是容易碰到内存溢出的问题(因为永久代有 - XX:MaxPermSize 的上限)。
??在 JDK1.7,HotSpot 逐渐改变方法区的实现方式,如 1.7 版本移除了方法区中的字符串常量池,但为发生本质的变化。
??HotSpot 虚拟机 1.8之后:1.8 版本中移除了方法区并使用 metaspace(元数据空间)作为替代实现。metaspace 占用系统内存,也就是说,只要不碰触到系统内存上限,方法区会有足够的内存空间。但这不意味着我们不对方法区进行限制,如果方法区无限膨胀,最终会导致系统崩溃。
4 JVM堆内存
4.1 什么是堆内存
??物理层面:从物理层面(硬件层面)来说,当 Java 程序开始运行时,JVM 会从操作系统获取一些内存,这些内存的一部分就是堆内存。 ??开发层面:从开发层面来说,堆内存通常在存储地址的底层,向上排列。当一个对象通过 new 关键字或通过其他方式创建后,对象从堆中获得内存。当对象不再使用了,被当做垃圾回收掉后,这些内存又重新回到堆内存中。 ??总结来说,堆内存是JVM启动时,从操作系统获取的一片内存空间,主要用于存放实例对象本身,创建完成的对象会放置到堆内存中。
4.2 堆内存结构
??堆内存从结构上来说分为年轻代(YoungGen)和老年代(OldGen)两部分; ??年轻代(YoungGen)又可以分为生成区(Eden)和幸存者区(Survivor)两部分; ??幸存者区(Survivor)又可细分为 S0区(from space)和 S1区 (to space)两部分。
??堆内存每个模块之间的关系及各自的特点概述如下: ??JVM 内存划分为堆内存和非堆内存,堆内存分为年轻代(YoungGen)、老年代(OldGen); ??年轻代又分为 Eden 和 Survivor 区。Survivor 区由 FromSpace 和 ToSpace 组成。Eden 区占大容量,Survivor 两个区占小容量,默认比例是 8:1:1; ??堆内存存放的是对象,垃圾收集器就是收集这些对象,然后根据 GC 算法回收;
4.3 堆内存的分代概念
??分代:将堆内存从概念层面进行模块划分,总体分为两大部分,年轻代和老年代。从物理层面将堆内存进行内存容量划分,一部分分给年轻代,一部分分给老年代。这就是我们所说的分代。 ??分代的意义:易于堆内存分类管理,易于垃圾回收。类似于我们经常使用的 Windows 操作系统,我们会将物理磁盘划出一部分存储空间作为用户系统安装盘(如 C 盘),我们还极大可能将剩余的磁盘空间划分为 C, D, E 等磁盘,用于存储同一类型的数据。 ??易于管理:对于堆空间的分代也是如此,比如新创建的对象会进入年轻代(YoungGen)的生成区(Eden),生命周期未结束的且可达的对象,在经历多次垃圾回收之后,会存放入老年代(OldGen),这就是分类管理; ??易于垃圾回收:将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及 GC 频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。
5 JVM 中堆的对象转移与年龄判断
??a.对象优先在Eden 区分配 Tips:标题中“优先”一次需要学习者认真品味,“优先” 意味着首先考虑,那么在一些特殊情况下,新创建的对象还是有可能不在Eden区分配的。 ??b.大对象直接进入老年代 ??上面提到,新创建的对象是优先存放入 Eden 区的,但是新创建的大对象会直接进入老年代。 ??什么是大对象: 10M 的对象算大吗?100M 的对象呢?什么是大对象?大对象的标准是可以由开发者定义的,我们的 JVM 参数中,能够通过 -XX:PretenureSizeThreshold 这个参数设置大对象的标准,可惜的是这个参数只对 Serial 和 ParNew 两款新生代收集器有效。 ??对于不能够设置 -XX:PretenureSizeThreshold 参数的JVM来说, Eden 区容量不够存放的对象就是所谓的大对象。
5.1 对象转移流程
??从上图中可以看出,新生成的非大对象首先放到年轻代 Eden 区,当 Eden 空间满了,触发 Minor GC,存活下来的对象移动到 Survivor0 区,Survivor0 区满后触发执行 Minor GC,Survivor0 区存活对象移动到 Suvivor1 区,这样保证了一段时间内总有一个 survivor 区为空。经过多次 Minor GC 仍然存活的对象移动到老年代。 ??如果新生成的是大对象,会直接将该对象存放入老年代。 ??老年代存储长期存活的对象,GC 期间会停止所有线程等待 GC 完成,所以对响应要求高的应用尽量减少发生 Major GC,避免响应超时。
5.2 对象年龄判断
??作用:JVM 通过判断对象的具体年龄来判别是否该对象应存入老年代,JVM通过对年龄的判断来完成对象从年轻代到老年代的转移。 ??对象年龄(Age)计数器:HotSpot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。 ??年龄增加:对象通常在 Eden 区里诞生,如果经过第一次 Minor GC 后仍然存活,并且能被Survivor容纳的话,该对象会被移动到 Survivor 空间中,并且将其对象年龄设为 1 岁。对象在Survivor区中每熬过一次 Minor GC,年龄就增加 1 岁。 ??年龄默认阈值:当它的年龄增加到一定程度(默认为15),就会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
ps:以上内容来自对慕课教程的学习与总结。
|