JVM 技术内幕——HotSpot VM

JDK (包含 Java语言、工具及工具API、Java SE API、JVM) 是用于支持 Java 程序开发的最小环境,JRE (包含 Java SE API、JVM) 是支持 Java 程序运行的标准环境。JDK 默认内置 JVM 是 HotSpot VM。我们平时所提及的高性能 JVM 除了 HotSpot VM,还包括 BEA JRockit VM 和 IBM J9 VM 这类在通用平台上运行的商用虚拟机。

1.JVM运行时数据区

在 JVM 自动内存管理机制的帮助下,Java 不再需要为每一个 new 操作去写配对的 delete/free 代码,而且不容易出现内存泄漏和内存溢出问题。不过也正因为如此,一旦出现了内存泄漏和溢出方面的问题,如果不了解 JVM 是怎样使用内存的,那么排查错误将会是一项艰难的任务。

JVM 在执行 Java 程序时,会把它所管理的内存划分为不同的数据区,这些区域各有用途,有的区域随着虚拟机进程的启动而存在,有的区域则依赖用户线程的启动/结束而建立/销毁。

根据 JVM 规范 (Java SE 7) 规定,HotSpot VM 运行时数据区如下:

HotSpot VM 运行时数据区

作用内存溢出 (OOM)
所有线程共享,空间最大,在 JVM 启动时创建,唯一目的就是存放类对象实例、数组。堆是 GC 管理的主要区域,细分为 “新生代”、“老年代”。如果在堆中没有内存完成实例分配,并且堆也无法扩展时将发生 OOM。
方法区所有线程共享,属于堆的一个逻辑部分,用于存储已被 JVM 加载的类信息、常量、静态常量、即时编译器编译后的代码等数据。对于HotSpot VM,方法区也被称为"永久代"。当无法满足内存分配需求时将发生 OOM。
线程私有,是Java 方法执行的内存模型,每个方法被执行时都会创建一个栈帧,栈帧持有局部变量、操作数栈、动态链接、方法出口,方法调用执行的过程就是入栈到出栈的过程,当方法调用结束,栈帧被销毁,生命周期与线程相同。线程请求的栈深度大于 JVM 所允许的深度将发生 OOM;JVM 动态扩展时无法申请到足够的内存将发生 OOM。
本地方法栈与栈相似,只不过是native 方法执行的内存模型。同栈。
程序计数器线程私有,空间较小,作用是当前线程所执行字节码的行号指示器,是逻辑计数器,通过改变计数器的值来选取下一条要执行的字节码指令。只对 Java 方法计数,如果是 native 方法则计数器值为 Undefined 。不会发生 OOM。

发生 OOM 将会抛出 OutOfMemoryError 异常。

在 JDK1.7 的 HotSpot 中,已经把原本放在 “永久代” 的字符串常量池移到了堆中。

在 JDK1.8 的 HotSpot 中,使用元空间(MetaSpace)替代了 “永久代”,元空间使用本地内存,而 “永久代” 使用的是 JVM 的内存。

元空间相比 “永久代” 的优势:

  • 字符串常量池存在 “永久代” 中,容易出现性能问题和 OOM;
  • 类和方法的信息大小难以确定,给 “永久代” 的大小指定带来困难;
  • 永久代会为 GC 带来不必要的复杂,在 “永久代” 中,元数据可能会伴随 full gc 发生而移动;
  • 方便 HotSpot 与其他 JVM 的集成,因为 “永久代” 是 HotSpot 特有的。

虚拟机栈、本地方法栈、程序计数器 3 个区域随线程而生,随线程而灭。

除了这五部分,还包括:

功能JVM规范中内存溢出情景
运行时常量池方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分将在类加载后进入方法区的运行时常量池中存放。当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
直接内存不是JVM运行时数据区的一部分,也不是JVM规范中定义的内存区域。在JDK1.4中新加入了NIO类,引入类一种基于通道与缓冲区的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样能在一些场景下显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。本机内存的分配不会受到Java堆大小的限制,但是会受到本机总内存大小以及处理器寻址空间的限制。服务器管理员在配置JVM参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现OutOfMemoryError异常。

2.HotSpot VM在堆中对象的创建、内存布局、访问

1.对象的创建

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

2、在类加载检查通过后,JVM 将为新生对象分配内存(把一块确定大小的内存从 JVM 堆中划分出来)。对象所需内存的大小在类加载完成后便可完全确定。

3、内存分配完毕后,JVM 需要将分配到的内存空间都初始化为零值(不包括对象头)。然后 JVM 要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息,这些信息存放在对象的对象头(Object Header)之中。

4、到此,从 JVM 角度看,一个新的对象已经产生了,但从 Java 程序的角度看,对象创建才刚刚开始(init 方法还没有执行,所有的字段都还为零)。所以,一般来说(由字节码中是否跟随 invokespecial 指令所决定),执行 new 指令之后会接着执行 init 方法,把对象按照设定好的方式进行初始化,这样一个真正可用的对象才算完全产生出来。

2.对象的内存布局

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

对象头包含两部分信息:

一部分是用于存储对象自身的运行时数据,如哈希码 (HashCode)、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据在 32 位和 64 位的 JVM 中分别为 32 bit 和 64 bit,官方称之为 “Mark Word”。对象需要存储的运行时数据很多,其实已经超出了 32 bit 和 64 bit 结构所能记录的限度,但是对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到 JVM 的空间效率,Mark Word 被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。

另一部分是类型指针,即对象指向它的类元数据的指针,JVM 通过这个指针来确定这个对象是哪个类的实例。

3.HotSpot VM的JIT编译器

HotSpot VM 是 Sun JDK 和 OpenJDK 中所带的虚拟机。HotSpot VM 一开始就是准确式 GC,HotSpot VM 的热点代码探测能力可以通过计数器找出最具有编译价值的代码,然后通知 JIT 编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和 OSR(栈上替换)编译动作。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡,而且无须等待本地代码输出才能执行程序,即时编译的时间压力也相对减小,这样有助于更多的代码优化技术,输出质量更高的本地代码。

Java 的 “编译期” 是一段 “不确定” 的操作过程,常见的编译器包括:

编译器举例说明
前端编译器javac(java语言编写的程序)把*.java转变为*.class的过程
JIT编译器(即时编译器)HotSpot VM的C1、C2编译器(C++语言编写的程序)在运行期把字节码转变为机器码的过程
AOT编译器(静态提前编译器)GCJ、Excelsior JET直接把*.java文件编译成本地机器码的过程

在主流的商用虚拟机(Sun HotSpot、IBM J9)中,java程序最初是通过解释器(Interpreter)进行解释执行的,当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为 “热点代码”(Hot Spot Code)。为了提高热点代码的运行效率,在运行时,JIT编译器将会把这些代码编译成与本地平台相关的机器码,并进行各种优化。

JIT编译器编译性能的好坏、代码优化程度的高低是衡量一款商用虚拟机优秀与否的最关键的指标之一。

1.解释器与编译器

在主流的商用虚拟机(Sun HotSpot、IBM J9)中,都采用解释器与编译器并存的架构,解释器与编译器各有优势:

优势
解释器解释器可以首先发挥作用,省去编译的时间,立即执行。
编译器在程序运行后,随着时间推移,编译器逐渐发挥作用,把越来越多的代码编译成本地机器码,获取更高的执行效率。

在程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释执行节约内存,反之可以使用编译执行来提升效率。同时,解释器还可以作为编译器激进优化时的一个 “逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段。当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、出现 “罕见陷阱” 时可以通过逆优化退回到解释状态继续执行。解释器与编译器的交互如下:

----------------  -------即时编译------->  ----------------
|    解释器     |                          |      编译器   |
|  Interpreter |                          | C1、C2编译器   |
----------------  <-------逆优化----------  ----------------

2.JIT编译器

HotSpot VM内置了两个即时编译器:

编译器说明强制指定模式的参数
C1编译器(Client Compiler)-client
C2编译器(Server Compiler)-server

HotSpot VM默认采用解释器与其中一个编译器直接配合的方式工作,程序使用哪个,取决于虚拟机运行的模式。

无论是C1还是C2,解释器与编译器搭配使用的方式在虚拟机中称为 “混合模式”,用户可以使用参数 “-Xint” 强制虚拟机运行于 “解释模式”,这时编译器完全不介入工作,全部代码都使用解释执行。另外,也可以使用 “-Xcomp” 强制虚拟机运行于 “编译模式”,这时将优先采用编译方式执行程序。

3.分层编译策略

由于JIT编译器编译本地代码需要占用程序运行时间,要编译出优化程度更高的代码,所费的时间更长。而且想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能信息,这对解释器执行的速度也有影响。为了在程度启动响应速度与运行效率之间达到最佳平衡,HotSpot VM还会逐渐启动分层编译的策略,该策略在JDK1.7的Server模式中作为默认编译策略被开启。分层编译根据编译器编译、优化的规模与耗时,划分出不同的编译层次,其中包括:

说明
第0层程序解释执行,解释器不开启性能监控功能(Profiling),可触发第1层编译。
第1层也称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑。
第2层也称为C2编译,也是将字节码编译为本地代码,但是会启动一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

实施分层编译后,C1、C2编译器将会同时工作,许多代码都可能会被多次编译,用C1编译器获取更高的编译速度,用C2编译器来获取更好的编译质量,在解释执行的时候也无须再承担收集性能监控信息的任务。

4.编译对象与触发条件

热点代码有两类:

说明
被多次调用的方法JIT编译器会以整个方法作为编译对象,这种编译也是虚拟机中标准的JIT编译方法。
被多次执行的循环体JIT编译器会以整个方法作为编译对象(而不是单独的循环体)。这种编译方式因为发生在方法执行过程中,因此形象地称之为栈上替换(简称为OSR编译,即方法栈帧还在栈上,方法就被替换了)。

判断一段代码是否是热点代码,是否需要触发即时编译,这样的行为成为热点检测,目前主要的热点检测判定方式有两种:

说明优点缺点
基于采样的热点检测周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是"热点方法"。简单高效,还可以获取方法调用关系(将调用堆栈展开即可)很难精确确认一个方法的热度,容易受到线程阻塞或别的外界因素的影响而扰乱热点检测
基于计数器的热点检测为每个方法(甚至代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为是"热点方法"统计结果相对更加精确与严谨实现麻烦,不能直接获取方法调用关系

在HotSpot VM中使用的是第二种,因此它为每个方法准备了两类计数器:方法调用计数器、回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。

方法调用计数器的默认阈值在Client模式下是1500次,在Server模式下是10000次,这个阈值可以通过虚拟机参数 -XX:CompileThreshold 来指定。

常见问题:

1、JVM 三大性能调优参数 -Xms -Xmx -Xss 的含义?

JVM 参数说明建议
-Xms256mJVM 堆初始大小(新生代 + 老年代)建议配置
-Xmx2gJVM 堆最大大小(新生代 + 老年代)建议配置
-Xss256k线程堆栈大小,单位字节

通常会将 -Xms 和 -Xmx 设置成一样的,因为当堆不够用而发生扩容时,会发生内存抖动,影响程序运行时的稳定性。

2、Java 内存模型中堆和栈的区别?

内存分配策略不同,程序运行时,有三种内存分配策略,静态的、栈式的、堆式的。

  • 静态存储:指在编译时就能够确定每个数据目标在运行时的存储空间需求,所以在编译时候就能给分配固定的内存空间,这种存储不允许有可变数据结构存在,也不允许有嵌套或者递归存在,因为它们会导致无法计算准确的存储空间;
  • 栈式存储:在编译时对存储空间需求完全未知,只有到了运行模块入口前才能确定;
  • 堆式存储:编译时或运行模块入口时都无法确定,属于动态分配。

栈引用堆中对象、数组时,只需要在栈里定义变量保存堆中目标的首地址。

HotSpot VM 运行时数据区 new 开辟堆内存空间

堆和栈的主要区别:

  • 管理方式:栈自动释放,堆需要 GC;
  • 空间大小:栈比堆小;
  • 碎片:栈产生的碎片远小于堆;
  • 分配方式:栈支持静态和动态分配,而堆仅支持动态分配;
  • 效率:栈的效率比堆高。

3、元空间、堆、栈、本地方法栈、程序计数器间的联系,内存角度?

通过一个常见的代码分析一下:

public class Person {
    private String name;
    private void say() {
        System.out.println("say:" + name);
    }
    private void setName(String name) {
        this.name = name;
    }
    public static void main(String[] args) {
        int a = 1;
        Person person = new Person();
        person.setName("tom");
        person.say();
    }
}

当类通过 Class Loader 加载进 JVM 运行时数据区的时候,元空间便保存了 Person 的 Class 信息、say() 方法、setName() 方法、main() 方法,以及 name。由于调用了 System 类,所以也保存了 System 的 Class 信息以及该类的成员变量与方法;

当 Person 被 new 的时候,堆里面边存储了 Person 对象实例以及 String 对象实例 “tom”;

当程序执行的时候,会为 main 线程分配对应的栈、本地方法栈、程序计数器,栈里面会存有 String 类型的引用参数,保存了对应于堆中 “tom” 的地址引用,还存有 Person 对象的地址引用,此外,还有局部变量 a = 1,以及系统自带的 LineNo,用来记录代码的执行,以方便对程序进行追踪。

3、不同 JDK 版本之间的 String 类的 intern() 方法的区别?

intern() 是一个 native 方法。

String s = new String("a");
s.intern();

JDK 1.6:当调用 intern() 方法时,如果字符串常量池先前已经创建出该字符串对象,则返回池中的该字符串的引用。否则将此字符串对象添加到字符串常量池中,并返回该字符串对象的引用。

JDK 1.7 及以上:当调用 intern() 方法时,如果字符串常量池先前已经创建出该字符串对象,则返回池中的该字符串的引用。否则,如果该字符串对象已经存在于堆中,则将堆中对此对象的引用添加到字符串常量池中,并且返回该引用,如果堆中不存在,则在池中创建该字符串并返回其引用。

这是由于 JDK 1.7 及以上,字符串常量池从方法区(“永久代”)移动到了堆中。“永久代” 的内存极为有限,如果频繁调用 intern() 方法创建字符串对象,会使得字符串常量池被 “挤爆”,进而引发 OOM。这个也是 “永久代” 移动到堆中的一个原因。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值