运行时数据区

本文作者:李敏,叩丁狼高级讲师。原创文章,转载请注明出处。

前言

为什么要了解虚拟机如何操作内存?

java与c/c++之间有一堵由内存动态分配和垃圾收集技术所围成的"高墙",墙外面的人想进去,墙里面的人却想出来.

对于java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去写配对的delete/free代码,不容易出现内存泄漏和内存溢出.有虚拟机管理内存,这一切看起来都很美好.但是,也正因为java程序员把内存控制的权力给了java虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎么样使用内存的,那么排查错误将会成为一项异常艰难的工作.

3.1 虚拟机内存模型

java虚拟机在执行java程序的过程中,会把所有它管理的内存划分为若干个不同的数据区域.这些区域都有各自的用途,.根据的规定,java虚拟机所管理的内存将会包括以下几个运行时数据区域:

1. 程序计数器

2. java虚拟机栈

3. 本地方法栈

4. java堆

5. 方法区

6. 运行时常量池

7. 直接内存
运行时数据区_第1张图片
image

3.1.1 程序计数器

Program Counter Register

内存空间小,线程私有.它可以看做是当前线程所执行的字节码的行号指示器.也就是说,线程主要是执行任务,而执行到哪里,需要使用程序计数器来记录.字节码解释器工作是就是通过改变这个计数器的值来选取下一条需要执行指令的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖计数器完成.

由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,所以,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,所以我们说,它是线程私有的.

如果线程正在执行一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器的值则为 (Undefined)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

3.1.2 虚拟机栈

java virtual Machine Stacks

线程私有,生命周期和线程一致。描述的是 Java 方法执行的内存模型:每个方法在执行时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行结束,就对应着一个栈帧从虚拟机栈中入栈到出栈的过程。

在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,因此一个栈帧需要分配多少内存,不会受程序运行时期变量数据的影响.

一个线程中的方法调用链可能会很长,很多方法都处于同时执行状态.对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,执行引擎运行的所有的字节码指令都只针对当前栈帧来进行操作的.

局部变量表

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量.

操作数栈

Operand Stack

是一个后进先出栈.其最大深度在编译的时候已经确定了.当一个方法刚刚开始执行的时候,这个方法的操作数占是空的,在方法的执行过程中,会有各种字节码指令往操作数占中写入和提取内容,这就是出栈/入栈动作.

另外,在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的,但大多数虚拟机的实现都会做一些优化处理,让两个栈帧出现部分重叠,让下面的栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用的时候就可以共用一部分数据.无须进行额外的参数的复制传递.

运行时数据区_第2张图片
image

动态连接

每一个栈帧都包含一个执行运行时常量池中该栈帧所属方法的引用.持有这个引用是为了支持方法调用过程中的动态连接.

这个引用是一个符号引用,不是方法实际运行的入口地址,需要动态的找到具体的方法入口.

这个特性给java带来了更强大的动态扩展能力,但也使得java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能够确定目标方法的直接引用.

方法返回地址

正常完成出口:方法正确执行,执行引擎遇到方法返回的指令,回到上层的方法调用者.

异常完成出口:方法执行过程中发生异常,并且没有处理异常,这样是不会给上层调用者产生任何返回值.

方法正常退出,将会返回程序结束其的值给上层方法,经过调整之后以指向方法调用指令后面的一条指令,继续执行上层方法.

3.1.3 本地方法栈

Native Method Stack

区别于Java 虚拟机栈的是,Java 虚拟机栈为虚拟机执行 Java 方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native 方法服务。也会有 StackOverflowError 和 OutOfMemoryError 异常。

3.1.4 堆

heap

对于绝大多数应用来说,这块区域是 JVM 所管理的内存中最大的一块。线程共享,主要是存放对象实例和数组。内部可以设置划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer, TLAB)。可以位于物理上不连续的空间,但是逻辑上要连续。

从内存回收的角度来看,由于现在收集器基本上都采用分代收集算法,所以对空间还可以细分为:新生代(年轻代),老年代(年老代).再细致一点,可以分为Eden空间,From Survivor空间, To Survivor空间.

不论如何划分,都与存放内容无关,都是存放的是对象实例,进一步划分的目的是为了更好的回收内存,或者更快的分配内存.

3.1.5 方法区

Method Area

属于共享内存区域,存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为“永久代”(Permanent Generation)(java8之前,使用永久代来实现方法区,在java8之后,废除永久代,将字符串常量池移动到堆中,并新增Meta space,直接在系统内存中.),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已。

Java虚拟机规范对这个区域的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这个区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是有必要的。

3.1.6 运行时常量池

Runtime Constant Pool

属于方法区一部分,用于存放编译期生成的各种字面量和符号引用。内存有限,无法申请时抛出 OutOfMemoryError.

3.1.7 直接内存

Direct Memory

非虚拟机运行时数据区的部分.

在 JDK 1.4 中新加入 NIO (New Input/Output) 类,引入了一种基于通道(Channel)和缓存(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。可以避免在 Java 堆和 Native 堆中来回的数据耗时操作。

本机直接内存的分配不会受到java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存大小的限制.所以我们在配置虚拟机参数时,不要忽略直接内存,否则可能因为动态扩展导致出现OutOfMemoryError.

3.2 基于栈的执行过程分析

下面我们通过一个小程序,来分析一下,虚拟机中实际是如何执行代码的.

public int calc(){

    int a = 100;

    int b = 200;

    int c = 300;

    return (a + b) * c;

}

代码非常简单,我们可以直接使用javap命令(javap -c CalcTest.class > calc.txt),通过反汇编操作,来查看对应的字节码指令.

运行时数据区_第3张图片
image

查阅虚拟机字节码指令表,我们先将上面的反汇编代码翻译一下:

运行时数据区_第4张图片
image

我们从这段程序的执行中,也可以回过来再次认识栈结构.整个过程的中间变量都是以操作数栈的出栈,入栈为信息交换途径.

3.3 HotSpot虚拟机对象探秘

堆,是我们最实用的一块内存空间,分析完栈帧的执行过程之后,现在我们再来分析一下,虚拟机在java堆中对象的创建,布局和访问的过程.

3.3.1 对象的创建

Java是一门面向对象的语言,在运行过程中无时无刻都有对象的创建,在语言层面,仅仅是一个关键字new,那么在虚拟机中,对象是如何创建出来的呢?

检查类是否已经被加载:虚拟机遇到 new 指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已经被加载、解析和初始化过。如果没有,执行相应的类加载。

为新生对象分配内存:类加载检查通过之后,为新对象分配内存(内存大小在类加载完成后便可确认)。

如果堆内存绝对规整,使用指针碰撞.否则使用空闲列表,找到一块足够大的内存划分给对象实例.

运行时数据区_第5张图片
image

堆内存是否规整,主要是看GC回收了内存之后是否包含压缩或者整理功能.如果有,那么内存就比较规整.否则如果没有,创建对象就需要采用空闲列表的方式.

比如:

serial,ParNew等带有整理的收集器,可以使用指针碰撞.

CMS使用简单清除的算法,可以使用空闲列表.

如果线程支持在堆中都有私有的分配缓冲区(TLAB),这样可以很大程度避免在并发情况下频繁创建对象造成的线程不安全。

内存空间分配完成后会将整个空间都初始化为零值(不包括对象头).

接下来就是填充对象头,把对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息存入对象头。

执行 new 指令后,执行 init 方法(由字节码指令invokespecial决定,执行初始化方法)后,才算一份真正可用的对象创建完成.

3.3.2 对象的内存布局

在上文中,我们讲到一个步骤是,填充对象头.那什么是对象头呢?

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding).

对象头(Header)

包含两部分,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,根据不同的系统为数固定大小.官方称为 ‘Mark Word’。第二部分是类型指针,即对象指向它的类的元数据指针,虚拟机通过这个指针确定这个对象是哪个类的实例。另外,如果是 Java 数组,对象头中还必须有一块用于记录数组长度的数据,因为普通对象可以通过 Java 对象元数据确定大小,而数组对象不可以。

实例数据(Instance Data):程序代码中所定义的各种类型的字段内容(包含父类继承下来的和子类中定义的)。

对齐填充(Padding):不是必然需要,主要是占位,保证对象大小是某个字节的整数倍。

3.3.3 对象的访问定位

使用对象时,通过栈上的 reference 数据来操作堆上的具体对象

由于java虚拟机只规定要一个执行对象的引用,而没有规定以何种方式去定位.所以对象访问方式取决于虚拟机的实现.主流的方式有两种:

1.通过句柄访问.Java 堆中会分配一块内存作为句柄池。reference 存储的是句柄地址.

2.使用指针访问.reference 中直接存储对象地址.

比较:使用句柄的最大好处是 reference 中存储的是稳定的句柄地址,在对象移动(GC)是只改变实例数据指针地址,reference 自身不需要修改。直接指针访问的最大好处是速度快,节省了一次指针定位的时间开销。如果是对象频繁 GC 那么句柄方法好,如果是对象频繁访问则直接指针访问好.

HotSpot使用第二种方式进行对象访问的.

你可能感兴趣的:(运行时数据区)