JVM 简单理解就是运行 Java 等语言的“操作系统”,没有 JVM,Java 程序就无法运行,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种类型:
- Class文件内容里的常量池
- 运行时常量池(Runtime Constant Pool)
- 各个包装类型里实现的常量池,例如 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 | // 此种赋值方式称为字符串字面量,如果你学过 Rust 语言,很好理解 |
基本类型的包装类和常量池
Byte、Short、Integer、Long、Character、Boolean 这 6 种包装类和 String 都各自实现了自己的常量池。Float 和 Double 这两个浮点类型没有实现常量池。
字符串常量池(String pool)的实例
1 | String str1 = "jhlz"; |
当以上代码运行时,JVM会到字符串常量池查找 “jhlz” 这个字面量对象是否存在:
- 存在:则返回该对象的引用给变量 str1 。
- 不存在:则创建一个对象,同时将引用返回给变量 str1 。(JDK8之后,对象实例直接存储在字符串常量池里)
1 | String str1 = "jhlz"; |
对于使用了 new 创建的字符串对象,如果想要将这个对象添加到字符串常量池,可以使用 intern() 方法。
1 | String str1 = "jhlz"; |
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 | jmap ‐dump:format=b,file=eureka.hprof 14660 |
第三方工具
- NetBeans Profiler
- JProfiler
- GC Viewer
- Arthas
- MAT,Memory Analyzer Tool,一个基于Eclipse的内存分析工具,是一个快速、功能丰富的Java heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗
- GChisto,一款专业分析gc日志的工具
三、JVM 垃圾收集收集器(garbage collector)
垃圾回收是 Java 中用于取消分配的未使用内存的机制,它主要是清除未使用对象占用的空间。为了释放未使用的内存,垃圾回收器会跟踪所有在使用的对象,并将对象的其余部分标记为垃圾。
JVM 附带了各种垃圾回收选项,以支持各种部署选项。这样,我们可以灵活地选择要用于应用程序的垃圾回收器。随着新的 Java 版本的出现,支持的 GC 算法和默认收集器通常会发生变化。
不同 open JDK 版本的默认 GC(从 Java8 开始)
注意,不同的 JDK 采用的 GC 和实现算法都不同,例如 C4 GC 就是 Azul JVM 的。open jdk 中没有,本文所说的都是 open-jdk 中支持的。
1 | # 使用命令查看默认使用的 GC |
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)。
- 数组边界检查消除