JVM 简单理解就是运行 Java 等语言的“操作系统”,没有 JVM,Java 程序就无法运行,JVM 自己“设计”了一套适合自己使用的内存结构。本篇文章就来整理一下,没有用到的就不写进来了,用过的或者学习的整理进来。

一、JVM内存模型

JVM内存模型图
JVM内存模型图

方法区和堆是所有线程共享的内存区域;而Java虚拟机栈、本地方法栈和程序计数器是运行是线程私有的内存区域。

  • Java堆(Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有 线程共享 的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
  • 元空间(Meta Space)/方法区(Method Area),方法区(Method Area)与Java堆一样,是各个 线程共享 的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 程序计数器(Program Counter Register)是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器。当线程发生切换时,记录上次线程挂起的位置,之后线程切换回来的时候,继续从上次记录的位置开始执行。
  • 虚拟机栈(JVM Stacks),与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
  • 本地方法栈(Native Method Stacks),本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。

说到方法区,不得不提一下“永久代”这个概念,尤其是在JDK8以前,许多Java程序员都习惯在HotSpot虚拟机上开发、部署程序,很多人都更愿意把方法区称呼为“永久代”(Permanent Generation),或将两者混为一谈。本质上这两者并不是等价的,因为仅仅是当时的HotSpot虚拟机设计团队选择把收集器的分代设计扩展至方法区,或者说使用永久代来实现方法区而已,这样使得 HotSpot的垃圾收集器能够像管理Java堆一样管理这部分内存,省去专门为方法区编写内存管理代码的工作。

但现在回头来看,当年使用永久代来实现方法区的决定并不是一个好主意,这种设计导致了Java应用更容易遇到内存溢出的问题(永久代有-XX:MaxPermSize的上限,即使不设置也有默认大小,而J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB限制,就不会出问题),而且有极少数方法 (例如String::intern())会因永久代的原因而导致不同虚拟机下有不同的表现。当Oracle收购BEA获得了JRockit的所有权后,准备把JRockit中的优秀功能,譬如Java Mission Control管理工具,移植到HotSpot虚拟机时,但因为两者对方法区实现的差异而面临诸多困难。

考虑到HotSpot未来的发展,在JDK 6的时候HotSpot开发团队就有放弃永久代,逐步改为采用本地内存(Native Memory)来实现方法区的计划了,到了JDK 7的HotSpot,已经把原本放在永久代的字符串常量池、静态变量等移出,而到了JDK 8,终于完全废弃了永久代的概念,改用与JRockit、J9一样在本地内存中实现的元空间(Meta-space)来代替,把JDK 7中永久代还剩余的内容(主要是类型信息)全部移到元空间中

以上内容摘自 《深入理解Java虚拟机第二版》 周志明

JNI(Java Native Interface)

JNI 是 Java Native Interface 的缩写,它提供了若干的API实现了Java和其他语言的通信(主要是C和C++)。

JNI 的适用场景:当我们有一些旧的库,已经使用C语言编写好了,如果要移植到Java上来,非常浪费时间,而JNI可以支持Java程序与C语言编写的库进行交互,这样就不必要进行移植了。或者是与硬件、操作系统进行交互、提高程序的性能等,都可以使用JNI。需要注意的一点是需要保证本地代码能工作在任何Java虚拟机环境。

一旦使用 JNI,Java 程序将丢失了 Java 平台的两个优点:

1、程序不再跨平台,要想跨平台,必须在不同的系统环境下程序编译配置本地语言部分。

2、程序不再是绝对安全的,本地代码的使用不当可能会导致整个程序崩溃。一个通用规则是,调用本地方法应该集中在少数的几个类当中,这样就降低了 Java 和其他语言之间的耦合。

常量池

要理解常量池,首先要知道,常量池分3种类型:

  1. Class文件内容里的常量池
  2. 运行时常量池(Runtime Constant Pool)
  3. 各个包装类型里实现的常量池,例如 String 类里面的字符串常量池(String Pool)

Class 常量池

Java 代码在经过编译器后,会生成一个 Class 文件,这个常量池就是Class文件里的一大段内容(通常是最大的一段内容),它主要存放着 字面量、符号引用 等信息,在 JVM 把 Class 文件加载完成后,Class 常量池里的数据会存放到运行时常量池中。

运行时常量池(Runtime Constant Pool)

运行时常量池是元空间/方法区(Method Area)的一部分,运行时常量池中存储的,是 基本类型的数据对象的引用,注意是对象的引用而不是对象实例本身哦。

Java 虚拟机在加载 Class 文件时,Class 文件内容里常量池的数据会放入运行时常量池。每一个加载好的 Class 对象里都会有一个运行时常量池。

字符串常量池(String Constant Pool)& 其他包装类型里实现的常量池

字符串由一个char[]构成(Java9 之后使用 byte[]),当我们的Java程序里频繁出现相同字面量的代码时,重复的创建和销毁对象是一件很浪费资源的事情,所以Java实现了一个字符串常量池。

JDK7 之后,字符串常量池从方法区迁移到了堆区,它的底层实现可以理解为是一个 HashTable。Java 虚拟机中只会存在一份字符串常量池。字符串常量池里,存放的数据可以是引用也可以是对象实例本身。

字符串常量池也具备运行时常量池动态性的特征,它支持运行期间将新的常量放入池中,这种特性被开发人员利用比较多的就是 String.intern() 方法。

上面的一堆都不重要,重要的是只要记住在 Java 中,字符串字面量都是存储在 字符串常量池 中的。例如:

1
2
// 此种赋值方式称为字符串字面量,如果你学过 Rust 语言,很好理解
String str = "abcd";

基本类型的包装类和常量池

Byte、Short、Integer、Long、Character、Boolean 这 6 种包装类和 String 都各自实现了自己的常量池。Float 和 Double 这两个浮点类型没有实现常量池。

字符串常量池(String pool)的实例

1
String str1 = "jhlz";

当以上代码运行时,JVM会到字符串常量池查找 “jhlz” 这个字面量对象是否存在:

  • 存在:则返回该对象的引用给变量 str1 。
  • 不存在:则创建一个对象,同时将引用返回给变量 str1 。(JDK8之后,对象实例直接存储在字符串常量池里)
1
2
3
4
5
6
7
String str1 = "jhlz";
String str2 = "jhlz";
System.out.println(str1 == str2);//str1 和 str2 都指向同一个对象,返回TRUE

// 使用 new 关键字创建 String 对象时,不管字符串常量池中是否有相同内容的对象的引用,新的字符串对象都会创建
String str3 = new String("jhlz");
System.out.println(str1 == str3); //返回FALSE

对于使用了 new 创建的字符串对象,如果想要将这个对象添加到字符串常量池,可以使用 intern() 方法。

1
2
3
4
5
String str1 = "jhlz";
String str2 = "jhlz";
String str3 = new String("jhlz");
String str4 = str3.intern();
System.out.println(str4 == str1);//返回TRUE

intern() 方法会检查字符串常量池中是否有与之匹配的对象,并做如下操作:

  • 存在:直接返回对象引用给interns变量。
  • 不存在:将这个对象引用加入到常量池,再返回对象引用给interns变量。

二、调优/异常查找工具

内置工具

jps、jmap、jstack、jstat、jinfo、jhat、jconsole 和 jvisualvm 等;

  • jps:JVM Process Status Tool,显示指定系统内所有的HotSpot虚拟机进程。
  • jstat:JVM statistics Monitoring是用于监视虚拟机运行时状态信息的命令,它可以显示出虚拟机进程中的类装载、内存、垃圾收集、JIT编译等运行数据。
  • jmap:JVM Memory Map命令用于生成heap dump文件。
  • jhat:JVM Heap Analysis Tool命令是与jmap搭配使用,用来分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器中查看jstack,用于生成java虚拟机当前时刻的线程快照。
  • jinfo:JVM Configuration info 这个命令作用是实时查看和调整虚拟机运行参数。
  • jstack:查找死锁。
1
2
3
4
5
6
7
8
jmap ‐dump:format=b,file=eureka.hprof 14660
# jstack pid -A 后面数字为显示的线程所在行的后面10行,最后的参数是线程id的16进制表示。
jstack 19663|grep -A 10 4cd0

jstat -gc pid

# 打印GC日志方法 %t:时间
java ‐jar ‐Xloggc:./gc‐%t.log ‐XX:+PrintGCDetails ‐XX:+PrintGCDateStamps ‐XX:+PrintGCTimeStamps ‐XX:+PrintGCCause ‐XX:+UseGCLogFileRotation ‐XX:NumberOfGCLogFiles=10 ‐XX:GCLogFileSize=100M xxxx.jar

第三方工具

  • NetBeans Profiler
  • JProfiler
  • GC Viewer
  • Arthas
  • MAT,Memory Analyzer Tool,一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗
  • GChisto,一款专业分析gc日志的工具

三、JVM 垃圾收集收集器(garbage collector)

垃圾回收是 Java 中用于取消分配的未使用内存的机制,它主要是清除未使用对象占用的空间。为了释放未使用的内存,垃圾回收器会跟踪所有在使用的对象,并将对象的其余部分标记为垃圾。

JVM 附带了各种垃圾回收选项,以支持各种部署选项。这样,我们可以灵活地选择要用于应用程序的垃圾回收器。随着新的 Java 版本的出现,支持的 GC 算法和默认收集器通常会发生变化。

GC
GC

不同 open JDK 版本的默认 GC(从 Java8 开始)

注意,不同的 JDK 采用的 GC 和实现算法都不同,例如 C4 GC 就是 Azul JVM 的。open jdk 中没有,本文所说的都是 open-jdk 中支持的。

1
2
# 使用命令查看默认使用的 GC
java -XX:+PrintCommandLineFlags -version

Java 8:默认使用并行 GC(Parallel Garbage Collector),引入了 G1,通过参数 -XX:+UseG1GC 显示调用;Java5 到 Java8 都是默认使用的这个,适用于多核和小堆。

Java 9 - 21:默认使用 G1;自 Java 11 起引入实验状态的 ZGC,Java 15 生产就绪;

  • Serial GC(串行 GC):串行回收器使用单个线程执行所有垃圾回收工作。默认情况下,在某些小型硬件和操作系统配置上会使用它,也可以使用选项 -XX:+UseSerialGC 显式启用它。

  • Parallel/Throughput GC(并行/吞吐量 GC):此收集器使用多个线程来加速垃圾回收。在 Java 版本 8 及更早版本中,它是服务器类计算机的默认值。我们可以使用 -XX:+UseParallelGC 选项显示启用。

  • Concurrent Mark Sweep (CMS) GC(并发标记扫描 GC):CMS 也是并发收集器。这意味着它与应用程序同时执行一些昂贵的工作。它旨在通过消除与并行和串行收集器的完整 GC 相关的长时间暂停来实现低延迟。
    我们可以使用选项 -XX:+UseConcMarkSweepGC 来启用 CMS 收集器。Java 团队从 Java 9 开始弃用了它,并在 Java 14 中完全删除了它。

  • G1 GC:G1 使用多个后台 GC 线程来扫描和清除堆,就像 CMS 一样。实际上,Java 核心团队将 G1 设计为对 CMS 的改进,通过额外的策略修补了它的一些弱点。G1 最适合具有非常严格的暂停时间目标和适度总体吞吐量的应用程序,例如交易平台或交互式图形程序等实时应用程序。
    除了增量和并发收集之外,它还跟踪以前的应用程序行为和 GC 暂停以实现可预测性。它更专注于在最高效的区域(大部分充满垃圾的区域)回收空间。因此,我们称其为 Garbage-First。自 Java7 update4 引入,从 Java 9 开始,G1 是服务器级机器的默认收集器。我们可以通过在命令行上提供 -XX:+UseG1GC 来显式启用它。

  • Z Garbage Collector (ZGC):ZGC 是一种可扩展的低延迟垃圾回收器。它甚至能在多 TB 堆上保持较低的暂停时间。它使用的技术包括参考着色、重定位、负载屏障和重映射。它非常适合服务器应用程序,因为在服务器应用程序中,大型堆很常见,而且需要快速的应用程序响应时间。它是在 Java 11 中作为实验性 GC 实现引入的。在 Java 15 中成为生产状态(非分代)。使用 -XX:+UseZGC 显示开启。到 Java21 引入分代版本,可以切换到分代版本。从 Java23 开始只有分代版本(默认),非分代版本已被弃用,后续会被移除。

GC 类型 优点 缺点
Serial GC 没有线程间通信开销,相对高效。
它适用于客户端级机器和嵌入式系统。
适用于数据集较小的应用程序。
即使在多处理器硬件上,如果数据集很小(最大 100 MB),它仍然是最有效的。
对于具有大型数据集的应用程序,它的效率不高。
不能利用多处理器硬件。
Parallel/Throughput GC 可以利用多处理器硬件
对于较大的数据集,它比串行 GC 更有效。
提供高总体吞吐量。
它尝试最小化内存占用。
应用程序在 stop-the-world 操作期间会导致较长的暂停时间。
它不能很好地扩展堆大小。
Concurrent Mark Sweep (CMS) GC 它非常适合低延迟应用程序,因为它可以最大限度地减少暂停时间。
它随堆大小扩展相对较好。
它可以利用多处理器计算机。
当数据集达到巨大大小或收集大量堆时,它变得相对低效。
它要求应用程序在并发阶段与 GC 共享资源。
可能存在吞吐量问题,因为总体上花费在 GC 操作上的时间更多。
总体而言,由于其大部分并发性质,它使用更多的 CPU 时间。
G1 GC(并行、并发、分代) 对于巨大的数据集,它非常有效。
它充分利用了多处理器计算机。
这是实现最低暂停时间目标的最有效方法。
当有严格的吞吐量目标时,这并不是最好的。
它要求应用程序在并发回收期间与 GC 共享资源。

参考:

四、GC 算法

标记清除

最简单的一种,对所有的内存区域使用不同的标记,例如,灰色 未引用;蓝色引用;紫色死亡。然后在堆内存用尽的情况下,使用垃圾收集器回收标记的内存(例如已经死亡的的色块内存区域)。

标记(mark):从根对象开始,标记所有可达的对象。
清除(sweep):遍历堆,释放未被标记的对象。

标记压缩

标记清除非常简单,但是有一个副作用:会有很多的内存碎片。标记压缩就是为了解决这个问题。一旦堆内存空间用尽后,就会把死亡对象删除,同时更新活着的对象引用,移动存活的对象以压缩堆,使用的内存被压缩到同一片区域,看起来就像是连续的,减少内存碎片化。 这样,剩下的未使用区域变成了连续的,会加快内存分配的效率。不过另一方面,更新引用时可能需要耗费一些时间。

标记复制

标记压缩虽然提升了分配效率,但由于更新引用时停顿时间较多,因此采用“空间换时间”的思想,把堆内存分为了两块,使用时只使用其中一块,另一块作为交换空间。在一块内存用尽的情况下,把使用的内存(活着的对象等)复制到另一块交换区域并清除原来的内存区。新区域空间用尽的情况下,再把使用的内存移动到另一区域。如此循环往复。

这样看的话标记复制算是综合了 标记压缩 和 标记清除 的优点,缺点就是内存占用空间较大。

分代回收

根据对象存活的时间不同分多块区域存放对象,就像人类活着的经历,有的人天生夭折,有的人风华正茂时遭遇意外,有的人纵享天伦。

JVM 中活着时间最短的对象首先放到 Eden 区,使用该区内存,这个区域较小,经过第一轮天灾(垃圾回收)还活着的话就复制到 survivor(幸存者) 区域,如果是经历的多轮天灾还活着的老怪物,则把他们放到老年代空间。eden 区 和 survivor 区合称新生代。

在内存使用完毕的情况下,程序停止、标记、压缩、复制、清除,循环进行,GC频率根据代的不同而不同。

引用计数

该算法为每个对象维护一个引用计数,当对象被引用时,计数加一,当引用失效时,计数减一。当计数为零时,对象被释放。

五、JVM经典优化技术

  • 方法内联:

方法内联就是把目标方法的代码原封不动地 “复制” 到发起调用的方法之中,避免发生真实的方法调用而已。但实际上Java虚拟机中的内联过程却远没 有想象中容易,甚至如果不是即时编译器做了一些特殊的努力,按照经典编译原理的优化理论,大多 数的Java方法都无法进行内联。
除了消除方法调用的成本之外,它更重要的意义是为其他优化手段建立良好的基础。

只有使用 invokespecial指令调用的私有方法、实例构造器、父类方法和使用invokestatic指令调用的静态方法才会 在编译期进行解析。除了上述四种方法之外(最多再除去被final修饰的方法这种特殊情况,尽管它使 用invokevirtual指令调用,但也是非虚方法,《Java语言规范》中明确说明了这点),其他的Java方法 调用都必须在运行时进行方法接收者的多态选择,它们都有可能存在多于一个版本的方法接收者,简 而言之,Java语言中默认的实例方法是虚方法。

  • 逃逸分析:

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术,它与类型继承关系分析一样,并不是直接优化代码的手段,而是为其他优化措施提供依据的分析技术。
逃逸分析的基本原理是:分析对象动态作用域,当一个对象在方法里面被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,这种称为方法逃逸;甚至还有可能被外部线程访问到,譬如赋值给可以在其他线程中访问的实例变量,这种称为线程逃逸;从不逃逸、方法逃逸到线 程逃逸,称为对象由低到高的不同逃逸程度。如果能证明一个对象不会逃逸到方法或线程之外(换句话说是别的方法或线程无法通过任何途径 访问到这个对象),或者逃逸程度比较低(只逃逸出方法而不会逃逸出线程),则可能为这个对象实 例采取不同程度的优化
比如栈上分配、标量替换、同步消除都是以此为基。

  • 公共子表达式消除

公共子表达式消除是一项非常经典的、普遍应用于各种编译器的优化技术,它的含义是:如果一个表达式E之前已经被计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么 E 的这次出现就称为公共子表达式。对于这种表达式,没有必要花时间再对它重新进行计算,只需要直 接用前面计算过的表达式结果代替E。
如果这种优化仅限于程序基本块内,便可称为局部公共子表达式消除(Local Common Subexpression Elimination),如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common Subexpression Elimination)。

  • 数组边界检查消除

本站由 江湖浪子 使用 Stellar 1.29.1 主题创建。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。