深入理解JVMJVM内存模型

编程

各版本的差异

JDK1.6

在JDK1.6 的时候运行时常量池在方法区中

JDK1.7

在JDK1.7 的时候运行时常量池在堆中

JDK1.8

在JDK1.8 的时候,JVM内存模型直接将方法区移到了本地内存中,叫元数据空间。该区域的内存大小就只受本机总内存的限制,但是当申请不到足够内存时也会报出

程序计数器

主要作用是:存储当前线程运行时的字节码行号,占用空间小且线程私有。

字节码解释器会通过改变程序计数器的值来选取下一条需要执行的字节码指令,并且分支(if)、循环、跳转、异常处理、线程恢复等基础功能都是基于程序计数器来实现的。

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

JAVA 虚拟机栈

线程私有,描述的是Java方法执行的内存模型,主要作用是:存储运行当前线程需要执行的所有方法所对应的栈帧。

一个线程栈的默认大小是1M,可用参数 –Xss调整大小,例如-Xss256k;

栈帧

栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是Java 虚拟机栈(Virtual Machine Stack)的栈元素。

每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口等信息。

每一个方法的执行过程,就是一个栈帧在虚拟机栈中从入栈到出栈的过程。

在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。

栈桢结构

下面是栈帧的一个概念结构图:

局部变量表

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

每个栈对应局部变量表的内存空间,在编译时期就已经确认了,不会随着程序的运行而发生改变。

局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,一个long或double类型数据会占用2个槽,一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据会占用一个槽。

  • reference:表示对象引用,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置,如:对象所属数据类型在方 法区中的存储的类型信息。
  • returnAddress:返回值类型,指向了一条字节码指令的地址。

虚拟机通过索引定位的方式来使用局部变量表,如果访问的是32位数据类型的变量,那么索引n就代表使用第n个槽,如果访问的是64位的数据类型变量,则会同时使用n和n+1两个槽。对于共同存放一个64位数据的两个槽是不允许单独访问其中任何一个的,如果单独访问会直接抛出异常。

为了尽可能节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量, 其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot就可以交给其他变量使用。并且Solt的复用会直接影响垃圾收集器的行为。我们通过下面一段代码来演示一下是如何影响的,我先向内存申请64M的空间,然后手动触发GC。通过设置虚拟机运行参数-verbose:gc可以使我们看到垃圾收集器的过程。

示例1:

public static void main(String[] args) throws InterruptedException {

{

byte[] placeholder = new byte[64 * 1024 * 1024];

}

System.gc();

}

运行结果:

Connected to the target VM, address: "127.0.0.1:53448", transport: "socket"

[GC (System.gc()) 73284K->66775K(247296K), 0.0016168 secs]

[Full GC (System.gc()) 66775K->66658K(247296K), 0.0059011 secs]

Disconnected from the target VM, address: "127.0.0.1:53448", transport: "socket"

我们可以发现placeholder变量虽然已经超出作用域,但是它对应的64M空间并没有被回收。

示例1:

public static void main(String[] args) throws InterruptedException {

{

byte[] placeholder = new byte[64 * 1024 * 1024];

}

int i = 0;

System.gc();

}

运行结果:

Connected to the target VM, address: "127.0.0.1:63864", transport: "socket"

[GC (System.gc()) 73284K->66775K(247296K), 0.0012476 secs]

[Full GC (System.gc()) 66775K->1122K(247296K), 0.0060915 secs]

Disconnected from the target VM, address: "127.0.0.1:63864", transport: "socket"

加上int i = 0;后,我们执行垃圾回收时,我们可以看出placeholder变量在超出作用域后,对应的64M空间被回收了。placeholder能否被回收的根本原因是:局部变量表中的Slot是否还存有关于placeholder数组对象的引用。在示例1中,placeholder原本所占用的Slot还没有被其他变量所复用,placeholder变量对应的局部变量表还仍然保持着对它的关联,所以GC无法回收它。如果这条语句的后续操作执行很耗时,就会造成大量空间浪费。int i = 0;的作用是用来打断placeholder变量和它对应的局部变量表之间的联系,在实际开发过程中我们可以通过placeholder = null来更加优雅的实现这一点。

操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out, LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中,和局部变量表不同的是,它不是通过索引来访问,而是通过标准的栈操作—压栈和出栈—来访问的。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其他方法的时候是通过操作数栈来进行参数传递的。

举个例子,整数加法的字节码指令iadd在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会将这两个int值出栈并相加,然后将相加的结果入栈。

Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。

动态链接

Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocation Completion)。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。

方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。

异常情况

  • StackOverflowError:当请求的栈深度大于虚拟机所允许的最大深度时会抛出此异常,方法的循环调用,递归调用等情况;
  • OutOfMemoryError:当虚拟机栈在动态扩容的过程中无法申请到足够的内存时会报此异常,不建议虚拟机栈无线扩容;

相关参数

–Xss:设置一个线程栈的大小,默认大小是1M,例如-Xss256k;

本地方法栈

本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,线程私有,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。

Native方法称为本地方法。在java源程序中以关键字“native”声明,不提供函数体。其实现使用非Java语言在另外的文件中编写,编写的规则遵循Java本地接口的规范(简称JNI)。

Java堆

Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块;所有线程共享;它唯一的目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术出现,导致一些对象的分配并没有在堆上

Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。Java堆无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。

  • 老年代:2/3 的堆空间
  • 年轻代:1/3 的堆空间
  • eden区:8/10 的年轻代
  • survivor0: 1/10 的年轻代
  • survivor1: 1/10 的年轻代

异常情况

如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

相关参数

  • -Xms:堆的最小值(初始值);
  • -Xmx:堆的最大值;
  • -Xmn:新生代的大小;
  • -XX:NewSize:新生代最小值(初始值);
  • -XX:MaxNewSize:新生代最大值;

方法区/元数据区域

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

JDK1.7及以前叫方法区或者永久代,这时该区域在JVM运行时数据区域中。在JDK1.8过后,废除了原来的方法区,在本地内存中直接开辟了一个空间来存储原来方法区中的数据,叫元数据区域,详见上图。

异常情况

当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常

相关参数

jdk1.7及以前:

  • -XX:PermSize:设置方法区最小值(初始值);
  • -XX:MaxPermSize:设置方法区最大值;

jdk1.8以后:

  • -XX:MetaspaceSize:表示metaspace首次使用不够而触发FGC的阈值,只对触发起作用;
  • -XX:MaxMetaspaceSize:用于设置metaspace区域的最大值。

运行时常量池

用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。在JDK1.7之前该区域在方法区中,在JDK1.7及以后该区域放到了堆内存中。

字面量

  • 文本字符串:String a = "abc",这个abc就是字面量;
  • 八种基本类型:int a = 1; 这个1就是字面量;
  • 声明为final的常量

符号引用

以一组符号来描述所引用的目标,比如:一个java类(假设为People类)被编译成一个class文件时,如果People类引用了Tool类,但是在编译时People类并不知道引用类的实际内存地址,因此只能使用符号引用来代替。

异常情况

当常量池无法再申请到内存时会抛出OutOfMemoryError异常。在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

直接内存

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

异常情况

服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

相关参数

  • -XX:MaxDirectMemorySize:设置直接内存大小(默认与堆内存最大值一样)。

JVM 内存参数说明

  • -Xss:设置一个线程栈的大小;
  • -Xms:堆的最小值(初始值);
  • -Xmx:堆的最大值;
  • -Xmn:年轻代的大小;
  • -XX:NewSize:年轻代最小值(初始值);
  • -XX:MaxNewSize:年轻代最大值;
  • -XX:NewRatio:设置年轻代和年老代的比值。如:为3,表示年轻代:年老代=1:3,年轻代占整个年轻代年老代和的1/4;
  • -XX:SurvivorRatio:年轻代中Eden区与两个Survivor区的比值。如:3,表示Eden:Survivor=3:2,一个Survivor区占整个年轻代的1/5;
  • -XX:PermSize: 方法区初始大小(JDK1.7及以前);
  • -XX:MaxPermSize: 方法区最大大小(JDK1.7及以前);
  • -XX:MetaspaceSize: 元数据区初始值(JDK1.8及以后);
  • -XX:MaxMetaspaceSize: 元数据区最大值(JDK1.8及以后);
  • -XX:MaxDirectMemorySize:直接内存大小,默认与堆内存最大值一样(-Xmx);

示例

JDK 1.7:

set JAVA_OPTS=-Xms1024m -Xmx1024m -Xss512k -XX:PermSize=128m -XX:MaxPermSize=256m -XX:NewSize=256m -XX:MaxNewSize=256m

JDK1.8 :

set JAVA_OPTS=-Xms1024m -Xmx1024m -Xss512k -XX:MetaspaceSize=128m -XX:MAXMetaspaceSize=256m -XX:NewSize=256m -XX:MaxNewSize=256m

查看JVM运行时参数

  • -XX:+PrintFlagsInitial 查看初始值
  • -XX:+PrintFlagsFinal 查看最终值(初始值可能被修改掉)
  • -XX:+UnlockExperimentalVMOptions 解锁实验性参数
  • -XX:+UnlockDiagnosticVMOptions 解锁诊断参数
  • -XX:+PrintCommandLineFlags 打印命令行参数

示例

java -XX:+PrintFlagsInitial -version

输出结果:

D:workspaceetcetc-credit-card>java -XX:+PrintFlagsInitial -version

[Global flags]

uintx AdaptiveSizeDecrementScaleFactor = 4 {product}

uintx AdaptiveSizeMajorGCDecayTimeScale = 10 {product}

uintx AdaptiveSizePausePolicy = 0 {product}

uintx AdaptiveSizePolicyCollectionCostMargin = 50 {product}

uintx AdaptiveSizePolicyInitializingSteps = 20 {product}

uintx AdaptiveSizePolicyOutputInterval = 0 {product}

uintx AdaptiveSizePolicyWeight = 10 {product}

uintx AdaptiveSizeThroughPutPolicy = 0 {product}

uintx AdaptiveTimeWeight = 25 {product}

bool AdjustConcurrency = false {product}

bool AggressiveOpts = false {product}

intx AliasLevel = 3 {C2 product}

bool AlignVector = true {C2 product}

bool UseLargePagesIndividualAllocation := false {pd product}

...

将结果输出到文本:

java -XX:+PrintFlagsInitial -version > flag.txt

=表示默认值;

:=表示被用户或JVM修改后的值

以上是 深入理解JVMJVM内存模型 的全部内容, 来源链接: utcz.com/z/512041.html

回到顶部