天天看点

通俗易懂的Java虚拟机内存模型

作者:Java狂人

1.概述

对于Java程序员来说,Java虚拟机提供了内存自动管理机制,因此可以在绝大部分情况下避免内存泄漏和内存溢出的情况,但是我们还是需要了解Java虚拟机是怎样对内存进行管理的,当遇到内存泄漏和内存溢出的问题时,解决起来才会得心应手。
通俗易懂的Java虚拟机内存模型

上面是Java运行时数据区域的划分,分为方法区(Method Area)、堆(Heap)、虚拟机栈(VM Stack)、本地方法栈(Native Method Stack)、程序计数器(Program Counter Register)。

2.程序计数器

程序计数器(Program Counter Register)是一块较小的内存空间。每个线程都有自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持一致。

在多线程中,必然会存在线程的上下文切换,所以需要记录各个线程正在执行的当前字节码指令地址,因此为每个线程分配一个程序计数器来记录这个指令地址。

如果线程正在执行的是一个Java方法,程序计数器记录的就是正在执行的虚拟机字节码指令的地址;如果正在执行的是本地(Native)方法,程序计数器的值则为Undefined。

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

3.Java虚拟机栈

与程序计数器一样,Java虚拟机栈(VM Stack)也是线程私有的,其生命周期与线程的生命周期一致。Java虚拟机会为每个线程创建一个Java虚拟机栈。

虚拟机栈描述的是Java方法执行时的线程内存模型,每个方法被执行时,Java虚拟机会为此创建一个栈帧,一个方法从被调用到结束的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。

因此,Java虚拟机对虚拟机栈的操作也就是以栈帧(Stack Frame)为单位的入栈和出栈的操作。

每一个栈帧都包含了局部变量表、操作数栈、动态连接、方法返回地址等信息。

一个线程中的方法调用链可能会很长,对于执行引擎来说,位于栈顶的栈帧才是有效的,其被称为当前栈帧(Current Stack Frame),与这个栈帧所关联的方法称为当前方法(Current Method)。

通俗易懂的Java虚拟机内存模型

可以使用-Xss来设置虚拟机栈内存的大小。不同操作系统对虚拟机栈内存的最小限制不同,64位Windows系统下,JDK11,最小栈内存大小为180k,如果低于这个限制,在程序启动时会给出一下提示

The Java thread stack size specified is too small. Specify at least 180k

在Java虚拟机规范中,此内存区域规定了两类异常情况:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError
  2. 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存,将抛出OutOfMemoryError

3.1 局部变量表

局部变量表(Local Variable Table)是一组变量值的存储空间,用于存储方法参数和方法内定义的局部变量。

局部变量表中的存储空间以局部变量槽(Local Variable Slot)为单位来表示。每个局部变量槽可以存储32bit长度的数据。

局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,该方法需要在栈帧中分配多大的局部变量表空间是完全确定的,方法运行期间不会改变局部变量表的大小。

3.2 操作数栈

操作数栈(Operand Stack):其每一个元素都可以是包括long和double在内的任意Java数据类型,其最大容量也是编译期间确定。

当一个方法被调用的时候,栈帧中的操作数栈是空的,只有在方法的执行过程中,才会有字节码指令向操作数栈中执行入栈和出栈的操作。

例如一个简单的加法运算,需要先从操作数栈中将需要执行运算的两个数值出栈,待运算执行完成后,再将运算结果入栈。

3.3 动态连接

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

这些符号引用一部分会在类加载的解析阶段转化为直接引用,另一部分会在运行期间转化为直接引用,这部分就称为动态连接。

3.4 方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法。

  • 执行引擎遇到任意一个方法返回的字节码指令
  • 在方法执行的过程中遇到异常

无论何种方式退出方法,都必须返回到最初方法被调用时的位置,程序才能继续往下执行。

4.本地方法栈

本地方法栈(Native Method Stack)与Java虚拟机栈的作用类似,其区别在于Java虚拟机栈为虚拟机执行Java方法服务,本地方法栈则为虚拟机使用到的本地方法服务。

在Java虚拟机规范中,此内存区域规定了两类异常情况:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError。
  2. 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存,将抛出OutOfMemoryError。

5.Java堆

对于Java应用程序来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。

Java堆是所有线程共享的一块内存区域,此内存区域存放着对象实例。在Java中,几乎所有的对象实例都在该内存区域分配内存,随着Java语言的不断发展,Java对象实例都分配在堆上也变得不那么绝对了。

在Java虚拟机规范中定义,如果Java堆中没有足够的内存来完成对象实例的分配,并且堆也无法扩展时,将抛出OutOfMemoryError。

6.方法区

方法区(Method Area)也是各个线程共享的内存区域。它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后代码缓存等数据。

在JDK8之前,HotSpot虚拟机设计团队选择把垃圾收集器的分代设计扩展至方法区,或者说使用“永久代”来实现方法区,这样使得HotSpot虚拟机的垃圾收集器能够像管理Java堆内存一样来管理这部分内存,省去专门为方法区编写内存管理的代码的工作。使用永久代来实现方法区的这种设计导致了Java应用更容易出现内存溢出的问题,因为永久代有最大内存上限,即使不设置也有默认大小。

到了JDK8,HotSpot废弃了永久代的概念,使用在本地内存中实现的元空间来代替。并且将字符串常量池移到了堆中。

在Java虚拟机规范中,如果该内存区域无法满足新的内存分配需求时,将抛出OutOfMemoryError。

6.1 运行时常量池

运行时常量池(Runtime Constant Pool)是方法区的一部分。运行时常量池中的大部分内容都来自虚拟机加载的Class文件。

Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,称作Class常量池,用于存放编译期生成的各种字面量与符号引用。

对于Class常量池中,字面量和符号引用的定义:

  • 字面量:文本字符串、被final修饰的常量值、基本数据类型的变量的值
  • 符号引用:类和接口的完全限定名、字段名称和描述符、方法名称和描述符
通俗易懂的Java虚拟机内存模型
在类加载过程中会将Class文件的信息以及Class常量池存放到方法区的运行时常量池中,其中文本字符串字面量比较特殊,在被调用时,会先去字符串常量池中进行比较,如果不存在,则将其添加到字符串常量池中;如果存在,则会返回字符串在字符串常量池中引用。

Java语言并不要求常量一定只有在编译期才能产生,即并非预置入Class常量池的内容才能进入方法区的运行时常量池,运行期间也可以将新的常量放入运行时常量池中,例如String类的intern()方法。

7.直接内存

直接内存(Direct Memory)并不是Java虚拟机运行时数据区域的一部分,也不是Java虚拟机规范中定义的内存区域。

直接内存的分配会受到本机总内存大小的限制,因此可能出现OutOfMemoryError。

8.HotSpot虚拟机对象

在了解了Java虚拟机的运行时数据区域之后,相信大家想更进一步了解这些内存区域中数据的其他细节,我们来看看HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。

8.1 对象的创建

我们知道,Java语言的一个特性就是面向对象编程,在Java程序的运行过程中随时都有对象被创建出来。在语言层面上,对象通常是通过new关键字来创建的(clone、反序列化除外),而在虚拟机中,对象的创建又会是怎样的一个过程呢?

1.执行new指令

当Java虚拟机执行一条字节码new指令时,检查对应的类是否已经被初始化。如果没有,那必须先执行相应的类加载过程。

2.为对象分配内存

对象所需内存的大小在类加载完成后就可以确定。

3.将分配到的内存空间都初始化为零值

使得对象的实例字段在Java代码中可以不赋初始值就可以直接使用,使程序能访问到这些字段的数据类型所对应的零值。

4.设置对象信息

例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的GC分代年龄等信息。这些信息都存储在对象的对象头(Object Header)之中。

5.构造对象

此时,对于虚拟机层面,一个新的对象已经产生了。但在语言层面,对象的创建才刚刚开始,即构造方法,Class文件中的<init>()方法还没有执行,所有的字段都为数据类型所对应的默认零值,对象所需要的其他资源和状态信息也还没有构造好。new指令之后会接着执行<init>()方法,对对象进行初始化,这样对象才算被完全构造出来。

8.2 对象的内存布局

在HotSpot虚拟机中,对象在堆内存中的存储布局可以划分为三个部分:

  • 对象头(Object Header)
  • 实例数据(Instance Data)
  • 对齐填充(Padding)

对象头包括两部分信息。一部分是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向锁线程ID、偏向时间戳等,也称作Mark Word。另一部分是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪个类的实例。

实例数据存储的是在对象中定义的各种类型的字段内容。

对齐填充,仅仅起着占位符的作用,由于HotSpot虚拟机的自动内存管理机制要求对象起始地址必须是8byte的整数倍,即任何对象的大小都必须是8byte的整数倍。对象头部分已经被精心设计为8byte的倍数,因此,如果实例数据部分没有对齐的话,就需要对齐填充来补全。

8.3 对象的访问定位

Java程序会通过栈上的reference数据来操作堆上的对象。由于reference类型只是一个指向对象的引用,而实际的访问方式主要是使用句柄和直接指针两种:

  • 句柄访问

如果使用句柄访问的话,Java堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据的地址信息,是一种间接访问

  • 直接指针访问

Java堆中的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的是对象地址。

9.内存分配策略

Java虚拟机提供的内存自动管理机制,实现了在创建对象自动给对象分配内存以及垃圾回收时自动回收分配给对象的内存。我们有必要了解内存分配的策略。

通俗易懂的Java虚拟机内存模型

9.1 对象优先在新生代的Eden区分配

对象优先在新生代的Eden区分配,当Eden区内存空间不足时,将触发一次Minor GC

9.2 大对象直接进入老年代

大对象是指需要占用大量连续内存空间的对象,大对象分配到Eden区的问题:

  • 大对象分配到分配到Eden区,容易触发Minor GC
  • 大对象在Eden区经MinorGC后存活,需要复制到Survivor区,复制开销大
-XX:PretenureSizeThreshold,指定大于该设置值的对象直接分配到老年代

9.3 长期存活的对象将进入老年代

对象的年龄值

  • 存储在对象头(Object Header)中
  • 每经历一次Minor GC并存活的对象,同时能被Survivor区容纳,其年龄值+1
  • -XX:MaxTenuringThreshold,设置最大年龄值
  • 经过过多次Minor GC,年龄值增加到15(默认值)时,就会被移动到老年代

9.4 空间分配担保

在进行Minor GC之前,虚拟机会检查老年代的最大可用的连续内存空间是否大于新生代中所有对象的总内存空间

  • 条件成立,直接进行Minor GC
  • 条件不成立,检查-XX:HandlePromotionFailure参数的设置值是否允许担保失败。允许担保失败,老年代最大可用连续内存空间大于历次晋升到老年代对象的平均大小,尝试一次Minor GC,担保成功,可以避免一次Full GC;不允许担保失败,进行Full GC

继续阅读