Loading... # JVM调优 ## JVM 基础知识 ### JVM 从编译到执行 #### Java 程序的执行过程 一个 Java 程序,首先经过 javac 编译成 .class 文件,然后 JVM 将其加载到方法区,执行引擎将会执行这些字节码。执行时,会翻译成操作系统相 关的函数。JVM 作为 .class 文件的翻译存在,输入字节码,调用操作系统函数。 过程如下:Java 文件-> 编译器 > 字节码->JVM-> 机器码。 JVM 全称 Java Virtual Machine ,也就是我们耳熟能详的 Java 虚拟机。它能识别 .class 后缀的文件,并且能够解析它的指令,最终调用操作系统上的 函数,完成我们想要的操作。 #### JVM、JRE 、JDK 的关系 JVM 只是一个翻译,把 Class 翻译成机器识别的代码,但是需要注意,JVM 不会自己生成代码,需要大家编写代码,同时需要很多依赖类库,这个时 候就需要用到 JRE。 JRE 是什么,它除了包含 JVM 之外,提供了很多的类库 (就是我们说的 jar 包,它可以提供一些即插即用的功能,比如读取或者操作文件,连接网络, 使用 I/O 等等之类的) 这些东西就是 JRE 提供的基础类库。JVM 标准加上实现的一大堆基础类库,就组成了 Java 的运行时环境,也就是我们常说的 JRE (Java Runtime Environment) 。 但对于程序员来说,JRE 还不够。我写完要编译代码,还需要调试代码,还需要打包代码、有时候还需要反编译代码。所以我们会使用 JDK ,因为 JDK 还提供了一些非常好用的小工具,比如 javac (编译代码) 、java 、jar (打包代码) 、javap (反编译 < 反汇编 >) 等。这个就是 JDK。 具体可以文档可以通过官网去下载:https://www.oracle.com/java/technologies/javase-jdk8-doc-downloads.html JVM 的作用是:从软件层面屏蔽不同操作系统在底层硬件和指令的不同。这个就是我们在宏观方面对 JVM 的一个认识。 同时 JVM 是一个虚拟化的操作系统,类似于 Linux 或者 Windows 的操作系统,只是它架在操作系统上,接收字节码也就是 class ,把字节码翻译成操作系统上的 机器码且进行执行。 ### 从跨平台到跨语言 **跨平台**:我们写的一个类,在不同的操作系统上 (Linux 、Windows 、MacOS 等平台) 执行,效果是一样,这个就是 JVM 的跨平台性。 为了实现跨平台型,不同操作系统有对应的 JDK 的版本。 https://www.oracle.com/java/technologies/javase/javase-jdk8-downloads.html ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb738903.png) **跨语言 (语言无关性)** :JVM 只识别字节码,所以 JVM 其实跟语言是解耦的,也就是没有直接关联,JVM 运行不是翻译 Java 文件,而是识别 class 文件,这个一般称之为字节码。还有像 Groovy 、Kotlin 、Scala 等等语言,它们其实也是编译成字节码,所以它们也可以在 JVM 上面跑,这个就是 JVM 的 跨语言特征。Java 的跨语言性一定程度上奠定了非常强大的 java 语言生态圈。 ### JVM 的发展 (非重点) #### 常见的 JVM 实现 **Hotspot**: 目前使用的最多的 Java 虚拟机。在命令行 java –version 。它会输出你现在使用的虚拟机的名字、版本等信息、执行模式。 ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb6b55a0.png) Jrocket:原来属于 BEA 公司, 曾号称世界上最快的 JVM ,后被 Oracle 公司收购,合并于 Hotspot **J9**: IBM 有自己的 java 虚拟机实现,它的名字叫做 J9. 主要是用在 IBM 产品 (IBM WebSphere 和 IBM 的 AIX 平台上) **TaobaoVM**: 只有一定体量、一定规模的厂商才会开发自己的虚拟机,比如淘宝有自己的 VM,它实际上是 Hotspot 的定制版,专门为淘宝准备的,阿里、天 猫都是用的这款虚拟机。 **LiquidVM**: 它是一个针对硬件的虚拟机,它下面是没有操作系统的 (不是 Linux 也不是 windows) ,下面直接就是硬件,运行效率比较高。 **zing**: 它属于 zual 这家公司,非常牛,是一个商业产品,很贵!它的垃圾回收速度非常快 (1 毫秒之内) ,是业界标杆。它的一个垃圾回收的算法后来被 Hotspot 吸收才有了现在的 ZGC。 ### JVM 整体知识模块 JVM 能涉及非常庞大的一块知识体系,比如内存结构、垃圾回收、类加载、性能调优、JVM 自身优化技术、执行引擎、类文件结构、监控工具等。 但是在所有的知识体系中,都或多或少跟内存结构有一定的关系: 比如垃圾回收回收的就是内存、类加载加载到的地方也是内存、性能优化也涉及到内存优化、执行引擎与内存密不可分、类文件结构与内存的设计有关系,监控工具也会监控内存。所以内存结构处于 JVM 中核心位置。也是属于我们入门 JVM 学习的最好的选择。 同时 JVM 是一个虚拟化的操作系统,所以除了要虚拟指令之外,最重要的一个事情就是需要虚拟化内存,这个虚拟化内存就是我们马上要讲到的 JVM 的内存区域。 #### JVM 的内存区域 ##### 运行时数据区域 运行时数据区的定义:Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存划分为若干个不同的数据区域 Java 引以为豪的就是它的自动内存管理机制。相比于 C++ 的手动内存管理、复杂难以理解的指针等,Java 程序写起来就方便的多。 所以要深入理解 JVM 必须理解内存虚拟化的概念。 在 JVM 中,JVM 内存主要分为堆、程序计数器、方法区、虚拟机栈和本地方法栈等。 同时按照与线程的关系也可以这么划分区域: **线程私有区域**:一个线程拥有单独的一份内存区域。 **线程共享区域**:被所有线程共享,且只有一份。 这里还有一个**直接内存**,这个虽然不是运行时数据区的一部分,但是会被频繁使用。你可以理解成没有被虚拟机化的操作系统上的其他内存 (比如操作 系统上有 8G 内存,被 JVM 虚拟化了 3G ,那么还剩余 5G , JVM 是借助一些工具使用这 5G 内存的,这个内存部分称之为直接内存) ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb71443d.jpg) ### JAVA 方法的运行与虚拟机栈 虚拟机栈是线程运行 java 方法所需的数据,指令、返回地址。其实在我们实际的代码中,一个线程是可以运行多个方法的。 比如: ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb7309b6.png) 这段代码很简单,就是起一个 main 方法,在 main 方法运行中调用 A 方法,A 方法中调用 B 方法,B 方法中运行 C 方法。 我们把代码跑起来,线程 1 来运行这段代码, 线程 1 跑起来,就会有一个对应 的虚拟机栈,同时在执行每个方法的时候都会打包成一个栈帧。 比如 main 开始运行,打包一个栈帧送入到虚拟机栈。 ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb72f8e8.jpg) C 方法运行完了,C 方法出栈,接着 B 方法运行完了,B 方法出栈、接着 A 方法运行完了,A 方法出栈,最后 main 方法运行完了,main 方法这个栈帧就 出栈了。 这个就是 Java 方法运行对虚拟机栈的一个影响。虚拟机栈就是用来存储线程运行方法中的数据的。而每一个方法对应一个栈帧。 #### 虚拟机栈 **栈的数据结构**:先进后出(FILO)的数据结构, **虚拟机栈的作用**:在 JVM 运行过程中存储当前线程运行方法所需的数据,指令、返回地址。 **虚拟机栈是基于线程的**:哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生 命周期是和线程一样的。 虚拟机栈的大小缺省为 1M ,可用参数 –Xss 调整大小,例如-Xss256k。 参数官方文档 (JDK1.8) :https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb7700c7.png) **栈帧**:在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦方法完成相应的调用,则出栈。 **栈帧大体都包含四个区域**:(局部变量表、操作数栈、动态连接、返回地址) 1. 局部变量表: 顾名思义就是局部变量的表,用于存放我们的局部变量的 (方法中的变量) 。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据 类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的 Object 对象,我们只需要存放它的一个引用地址即可。 2. 操作数据栈: 存放 java 方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的 java 数据类型,所 以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就空的。操作数栈本质上是 JVM 执行引擎的一个工作区,也就是方法在执行,才会对操作数栈进行操作,如果代码不不执行,操作数栈其实就是空的。 3. 动态连接: Java 语言特性多态 (后续章节细讲,需要结合 class 与执行引擎一起来讲) 。 4. 返回地址: 正常返回 (调用程序计数器中的地址作为返回) 、异常的话 (通过异常处理器表 < 非栈帧中的 > 来确定) 同时,虚拟机栈这个内存也不是无限大,它有大小限制,默认情况下是 1M。 如果我们不断的往虚拟机栈中入栈帧,但是就是不出栈的话,那么这个虚拟机栈就会爆掉。 ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb79a8c5.png) Exception in thread "main" java.lang.StackOverflowError #### 程序计数器 较小的内存空间,当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响。 程序计数器是一块很小的内存空间,主要用来记录各个线程执行的字节码的地址,例如,分支、循环、跳转、异常、线程恢复等都依赖于计数器。 由于 Java 是多线程语言,当执行的线程数量超过 CPU 核数时,线程之间会根据时间片轮询争夺 CPU 资源。如果一个线程的时间片用完了,或者是其它原因导致这个线程的 CPU 资源被提前抢夺,那么这个退出的线程就需要单独的一个程序计数器,来记录下一条运行的指令。 因为 JVM 是虚拟机, 内部有完整的指令与执行的一套流程,所以在运行 Java 方法的时候需要使用程序计数器 (记录字节码执行的地址或行号) ,如 果是遇到本地方法 (native 方法) ,这个方法不是 JVM 来具体执行,所以程序计数器不需要记录了,这个是因为在操作系统层面也有一个程序计数器, 这个会记录本地代码的执行的地址,所以在执行 native 方法时,JVM 中程序计数器的值为空(Undefined)。 另外程序计数器也是 JVM 中唯一不会 OOM(OutOfMemory)的内存区域。 #### 栈帧执行对内存区域的影响 对 class 进行反汇编 javap –c XXXX.class 字节码助记码解释地址:https://cloud.tencent.com/developer/article/1333540 ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb7d6175.jpg) 在 JVM 中,基于解释执行的这种方式是基于栈的引擎,这个说的栈,就是操作数栈。 **虚拟机栈**: 每个线程私有的,线程在运行时,在执行每个方法的时候都会打包成一个栈帧,存储了局部变量表,操作数栈,动态链接,方法出口等信息,然后放入 栈。每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。 栈的大小缺省为 1M ,可用参数 –Xss 调整大小,例如-Xss256k 在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分 配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。 **局部变量表**:顾名思义就是局部变量的表,用于存放我们的局部变量的。首先它是一个 32 位的长度,主要存放我们的 Java 的八大基础数据类型,一般 32 位就可以存放下,如果是 64 位的就使用高低位占用两个也可以存放下,如果是局部的一些对象,比如我们的 Object 对象,我们只需要存放它的一个引用 地址即可。 (基本数据类型、对象引用、returnAddress 类型) **操作数据栈**:存放我们方法执行的操作数的,它就是一个栈,先进后出的栈结构,操作数栈,就是用来操作的,操作的的元素可以是任意的 java 数据类 型,所以我们知道一个方法刚刚开始的时候,这个方法的操作数栈就是空的,操作数栈运行方法是会一直运行入栈/出栈的操作 **动态连接**:Java 语言特性多态 (需要类加载、运行时才能确定具体的方法,后续有详细的讲解) [动态分派](#_bookmark2) **完成出口** (返回地址): 正常返回:(调用程序计数器中的地址作为返回) 三步曲: 恢复上层方法的局部变量表和操作数栈、 把返回值 (如果有的话) 压入调用者栈帧的操作数栈中、 调整程序计数器的值以指向方法调用指令后面的一条指令、 异常的话: (通过异常处理表 < 非栈帧中的 > 来确定) ### 运行时数据区及 JVM 的整体内存结构 #### 本地方法栈 本地方法栈跟 Java 虚拟机栈的功能类似,Java 虚拟机栈用于管理 Java 函数的调用,而本地方法栈则用于管理本地方法的调用。但本地方法并不是 用 Java 实现的,而是由 C 语言实现的(比如 Object.hashcode 方法)。 本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。你甚至可以认为虚拟机栈和本地方法栈是同一个区域。 虚拟机规范无强制规定,各版本虚拟机自由实现 ,HotSpot 直接把本地方法栈和虚拟机栈合二为一 。 #### 方法区 方法区 (Method Area) 是可供各条线程共享的运行时内存区域。它存储了每一个类的结构信息,例如运行时常量池 ( Runtime Constant Pool) 字段和方法数据、构造函数和普通方法的字节码内容、还包括一些在类、实例、接口初始化时用到的特殊方法 方法区是 JVM 对内存的“逻辑划分” ,在 JDK1.7 及之前很多开发者都习惯将方法区称为“永久代” ,是因为在 HotSpot 虚拟机中,设计人员使用了永 久代来实现了 JVM 规范的方法区。在 JDK1.8 及以后使用了元空间来实现方法区。 ##### 元空间 方法区与堆空间类似,也是一个共享内存区,所以方法区是线程共享的。假如两个线程都试图访问方法区中的同一个类信息,而这个类还没有装入 JVM ,那么此时就只允许一个线程去加载它,另一个线程必须等待。 在 HotS pot 虚拟机、Java7 版本中已经将永久代的静态变量和运行时常量池转移到了堆中,其余部分则存储在 JVM 的非堆内存中,而 Java8 版本 已经将方法区中实现的永久代去掉了,并用元空间 (class metadata) 代替了之前的永久代,并且元空间的存储位置是本地内存。 元空间大小参数: jdk1.7 及以前 (初始和最大值) :-XX: PermSize;-XX:MaxPermSize; jdk1.8 以后 (初始和最大值) :-XX:MetaspaceSize; -XX:MaxMetaspaceSize jdk1.8 以后大小就只受本机总内存的限制 (如果不设置参数的话) JVM 参数参考:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html Java8 为什么使用元空间替代永久代,这样做有什么好处呢? 官方给出的解释是: 移除永久代是为了融合 HotSpot JVM 与 JRockit VM 而做出的努力,因为 JRockit 没有永久代,所以不需要配置永久代。 永久代内存经常不够用或发生内存溢出,抛出异常 java.lang.OutOfMemoryError: PermGen 。这是因为在 JDK1.7 版本中,指定的 PermGen 区大小为 8M ,由于 PermGen 中类的元数据信息在每次 FullGC 的时候都可能被收集,回收率都偏低,成绩很难令人满意;还有为 PermGen 分配多大的空间很难 确定,PermSize 的大小依赖于很多因素,比如,JVM 加载的 class 总数、常量池的大小和方法的大小等。 #### 运行时常量池 运行时常量池 (Runtime Constant Pool) 是每一个类或接口的常量池 ( Constant_Pool) 的运行时表示形式,它包括了若干种不同的常量:从编 译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。 运行时常量池是方法区的一部分。运行时常量池相对于 Class 常量池的另外一个重要特征是具备动态性 (Class 常量池在类加载章节会具体讲) 。 #### 堆 堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。 堆空间一般是程序启动时,就申请了,但是并不一定会全部使用。堆一般设置成可伸缩的。 随着对象的频繁创建,堆空间占用的越来越多,就需要不定期的对不再使用的对象进行回收。这个在 Java 中,就叫作 GC (Garbage Collection) 。 那一个对象创建的时候,到底是在堆上分配,还是在栈上分配呢?这和两个方面有关:对象的类型和在 Java 类中存在的位置。 Java 的对象可以分为基本数据类型和普通对象。 对于普通对象来说,JVM 会首先在堆上创建对象,然后在其他地方使用的其实是它的引用。比如,把这个引用保存在虚拟机栈的局部变量表中。 对于基本数据类型来说 (byte 、short 、int 、long 、float 、double 、char) ,有两种情况。 当你在方法体内声明了基本数据类型的对象,它就会在栈上直接分配。其他情况,都是在堆上分配。 堆大小参数: -Xms:堆的最小值; -Xmx:堆的最大值; -Xmn:新生代的大小; -XX: NewSize;新生代最小值; -XX: MaxNewSize:新生代最大值; 例如- Xmx256m ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb812312.png) #### 直接内存 (堆外内存) 直接内存有一种更加科学的叫法,堆外内存。 JVM 在运行时,会从操作系统申请大块的堆内存,进行数据的存储;同时还有虚拟机栈、本地方法栈和程序计数器,这块称之为栈区。操作系统剩余的 内存也就是堆外内存。 它不是虚拟机运行时数据区的一部分,也不是 java 虚拟机规范中定义的内存区域; 如果使用了 NIO, 这块区域会被频繁使用,在 java 堆内可以用 directByteBuffer 对象直接引用并操作; 这块内存不受 java 堆大小限制,但受本机总内存的限制,可以通过-XX: MaxDirectMemorySize 来设置 (默认与堆内存最大值一样) ,所以也会出现 OOM 异常。 小结: 1 、直接内存主要是通过 DirectByteBuffer 申请的内存,可以使用参数“MaxDirectMemorySize”来限制它的大小。 2 、其他堆外内存,主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申请的内存。 堆外内存的泄漏是非常严重的,它的排查难度高、影响大,甚至会造成主机的死亡。后续章节会详细讲。 同时,要注意 Oracle 之前[计划在 Java 9 中去掉 sun.misc.Unsafe](https://docs.google.com/document/d/1GDm_cAxYInmoHMor-AkStzWvwE9pw6tnz_CebJQxuUE/edit?pli=1) API 。这里删除 sun.misc.Unsafe 的原因之一是使 Java 更加安全,并且有替代方案。 目前我们主要针对的 JDK1.8 ,JDK1.9 暂时不放入讨论范围中,我们大致知道 java 的发展即可。 ## 深入理解 JVM 的内存区域 ### 深入理解运行时数据区 代码示例: ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb84d7ad.png) 1. JVM 向操作系统申请内存: JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间,根据内存大小找到具体的内存分配表,然后把内存段的起始地址和终止地 址分配给 JVM ,接下来 JVM 就进行内部分配。 2. JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小 -Xms30m -Xmx30m -Xss1m -XX: MaxMetaspaceSize=30m 3. 类加载 (类加载的细节后续章节会讲) : 这里主要是把 class 放入方法区、还有 class 中的静态变量和常量也要放入方法区 4. 执行方法及创建对象: 启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 student 对象,对象引用 student 就存放在栈中。 后续代码中遇到 new 关键字,会再创建一个 student 对象,对象引用 student 就存放在栈中。 ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb82b3d9.png) **总结一下 JVM 运行内存的整体流程** JVM 在操作系统上启动,申请内存,先进行运行时数据区的初始化,然后把类加载到方法区,最后执行方法。 方法的执行和退出过程在内存上的体现上就是虚拟机栈中栈帧的入栈和出栈。 同时在方法的执行过程中创建的对象一般情况下都是放在堆中,最后堆中的对象也是需要进行垃圾回收清理的。 ### 从底层深入理解运行时数据区 #### 堆空间分代划分 堆被划分为新生代和老年代 (Tenured) ,新生代又被进一步划分为 Eden 和 Survivor 区,最后 Survivor 由 From Survivor 和 To Survivor 组成。 ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb8299e0.png) #### GC 概念 GC- Garbage Collection 垃圾回收,在 JVM 中是自动化的垃圾回收机制,我们一般不用去关注,在 JVM 中 GC 的重要区域是堆空间。 我们也可以通过一些额外方式主动发起它,比如 System.gc(),主动发起。 (项目中切记不要使用) #### JHSDB 工具 JHSDB 是一款基于服务性代理实现的进程外调试工具。服务性代理是 HotS pot 虚拟机中一组用于映射 Java 虚拟机运行信息的,主要基于 Java 语言实现的 API 集合。 ##### JDK1.8 的开启方式 开启 HSDB 工具 Jdk1.8 启动 JHSDB 的时候必须将 sawindbg.dll ( 一般会在 JDK 的目录下) 复制到对应目录的 jre 下(注意在 win 上安装了 JDK1.8 后往往同级目录下有一个 jre 的目录) ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb85b67f.png) ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb890f32.png) 然后到目录:C:\Program Files\Java\jdk1.8.0_101\lib 进入命令行,执行 java -cp .\sa-jdi.jar sun.jvm.hotspot.HSDB ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb900eb8.png) ##### JDK1.9 及以后的开启方式 进入 JDK 的 bin 目录下,我们可以在命令行中使用 jhsdb hsdb 来启动它 #### 代码改造 VM 参数加入: -XX:+UseConcMarkSweepGC ![img](https://files.vps4cloud.com/i/2023/04/22/64438eb900a01.png) -XX:- UseCompressedOops ![img](https://files.vps4cloud.com/i/2023/04/26/64491e4a0b521.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c93942273.png) #### JHSDB 中查看对象 ##### 实例代码启动 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c987a6312.png) 因为 JVM 启动有一个进程,需要借助一个命令 jps 查找到对应程序的进程 ![img](https://files.vps4cloud.com/i/2023/04/26/6449226b6db3a.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c98b4ba37.png) 在 JHSDB 工具中 attach 上去 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9d11704b.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443d092c323a.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9d6d3a1a.png) ##### JHSDB 中查看对象 查看堆参数: ![img](https://files.vps4cloud.com/i/2023/04/22/6443d09d99aa7.png) 上图中可以看到实际 JVM 启动过程中堆中参数的对照,可以看到,在不启动内存压缩的情况下。堆空间里面的分代划分都是连续的。 再来查看对象: ![img](https://files.vps4cloud.com/i/2023/04/22/6443d0a7304c1.png) 这里可以看到 JVM 中所有的对象,都是基于 class 的对象 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9e351816.png) 全路径名搜索 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9ecac056.png) 双击出现这个 Teacher 类的对象,两个,就是 T1 和 T2 对象。 ![img](https://files.vps4cloud.com/i/2023/04/26/6449227337920.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9f051897.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491e864abcd.png) 最后再对比一下堆中分代划分可以得出为什么 T1 在 Eden,T2 在老年代 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9f554d8b.png) #### JHSDB 中查看栈 ![img](https://files.vps4cloud.com/i/2023/04/26/644918620dd62.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9fa40672.png) 从上图中可以验证栈内存,同时也可以验证到虚拟机栈和本地方法栈在 Hotspot 中是合二为一的实现了。 ![img](https://files.vps4cloud.com/i/2023/04/26/6449187528359.png) 当我们通过 Java 运行以上代码时,JVM 的整个处理过程如下: 1. JVM 向操作系统申请内存,JVM 第一步就是通过配置参数或者默认配置参数向操作系统申请内存空间。 2. JVM 获得内存空间后,会根据配置参数分配堆、栈以及方法区的内存大小。 3. 完成上一个步骤后, JVM 首先会执行构造器,编译器会在.java 文件被编译成.class 文件时,收集所有类的初始化代码,包括静态变量赋值语句、 静态代码块、静态方法,静态变量和常量放入方法区 4. 执行方法。启动 main 线程,执行 main 方法,开始执行第一行代码。此时堆内存中会创建一个 Teacher 对象,对象引用 student 就存放在栈中。 执行其他方法时,具体的操作:栈帧执行对内存区域的影响。[栈帧执行对内存区域的影响](#_bookmark1) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9fdede6d.png) #### 从底层深入理解运行时数据区 (总结) ### 深入辨析堆和栈 (重点) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a5e0872.jpg) 功能 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 以栈帧的方式存储方法调用的过程,并存储方法调用过程中基本数据类型的变量 (int 、short 、long 、byte 、float 、double 、boolean 、char 等) 以 及对象的引用变量,其内存分配在栈上,变量出了作用域就会自动释放; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 而堆内存用来存储 Java 中的对象。无论是成员变量,局部变量,还是类变量,它们指向的对象都存储在堆内存中; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a5e0872.jpg) 线程独享还是共享 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 栈内存归属于单个线程,每个线程都会有一个栈内存,其存储的变量只能在其所属线程中可见,即栈内存可以理解成线程的私有内存。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 堆内存中的对象对所有线程可见。堆内存中的对象可以被所有线程访问。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a5e0872.jpg) 空间大小 栈的内存要远远小于堆内存 ### 虚拟机内存优化技术 (非重点) #### 栈的优化技术——栈帧之间数据的共享 在一般的模型中,两个不同的栈帧的内存区域是独立的,但是大部分的 JVM 在实现中会进行一些优化,使得两个栈帧出现一部分重叠。 (主要体现在方 法中有参数传递的情况) ,让下面栈帧的操作数栈和上面栈帧的部分局部变量重叠在一起,这样做不但节约了一部分空间,更加重要的是在进行方法调用时就可以直接公用一部分数据,无需进行额外的参数复制传递了。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca034bd51.png) 使用 JHSDB 工具查看栈空间一样可以看到。 ![img](https://files.vps4cloud.com/i/2023/04/26/6449187be97b4.png) ### 内存溢出 (重点) #### 栈溢出 参数:-Xss1m , 具体默认值需要查看官网:[https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html#BABHDABI](https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html) ![img](https://files.vps4cloud.com/i/2023/04/26/64491880c6d43.png) HotS pot 版本中栈的大小是固定的,是不支持拓展的。 java.lang.StackOverflowError 一般的方法调用是很难出现的,如果出现了可能会是无限递归。 虚拟机栈带给我们的启示:方法的执行因为要打包成栈桢,所以天生要比实现同样功能的循环慢,所以树的遍历算法中:递归和非递归(循环来实现)都有存在的意义。递归代码简洁,非递归代码复杂但是速度较快。 OutOfMemoryError:不断建立线程,JVM 申请栈内存,机器没有足够的内存。 ( 一般演示不出,演示出来机器也死了) **同时要注意,栈区的空间 JVM 没有办法去限制的,因为 JVM 在运行过程中会有线程不断的运行,没办法限制,所以只限制单个虚拟机栈的大小。** #### 堆溢出 内存溢出: 申请内存空间,超出最大堆内存空间。 如果是内存溢出,则通过 调大 -Xms,-Xmx 参数。 如果不是内存泄漏,就是说内存中的对象却是都是必须存活的,那么久应该检查 JVM 的堆参数设置,与机器的内存对比,看是否还有可以调整的空间, 再从代码上检查是否存在某些对象生命周期过长、持有状态时间过长、存储结构设计不合理等情况,尽量减少程序运行时的内存消耗。 #### 方法区溢出 (1) 运行时常量池溢出 (2) 方法区中保存的 Class 对象没有被及时回收掉或者 Class 信息占用的内存超过了我们配置。 **注意 Class 要被回收,条件比较苛刻 (仅仅是可以,不代表必然,因为还有一些参数可以进行控制) :** 1. 该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。 2. 加载该类的 ClassLoader 已经被回收。 3. 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca0d02613.png) 代码示例: cglib 是一个强大的,高性能,高质量的 Code 生成类库,它可以在运行期扩展 Java 类与实现 Java 接口。 CGLIB 包的底层是通过使用一个小而快的[字节码](https://baike.baidu.com/item/字节码)处理框架 ASM,来转换字节码并生成新的类。除了 CGLIB 包,[脚本语言](https://baike.baidu.com/item/脚本语言)例如 Groovy 和 BeanShell, 也是使用 ASM 来生成 java 的字节码。当然不鼓励直接使用 ASM,因为它要求你必须对 JVM 内部结构包括 class 文件的格式和指令集都很熟悉。 #### 本机直接内存溢出 直接内存的容量可以通过 MaxDirectMemorySize 来设置 (默认与堆内存最大值一样) ,所以也会出现 OOM 异常; 由直接内存导致的内存溢出,一个比较明显的特征是在 HeapDump 文件中不会看见有什么明显的异常情况,如果发生了 OOM ,同时 Dump 文件很小,可 以考虑重点排查下直接内存方面的原因。 ### 常量池 #### Class 常量池(静态常量池) 在 class 文件中除了有类的版本、字段、方法和接口等描述信息外,还有一项信息是常量池 (Constant Pool Table) ,用于存放编译期间生成的各种字面 量和符号引用。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca0a562a4.png) **字面量**:给基本类型变量赋值的方式就叫做字面量或者字面值。 比如:Stringa=“ b” ,这里“b”就是字符串字面量,同样类推还有整数字面值、浮点类型字面量、字符字面量。 **符号引用** :符号引用以一组符号来描述所引用的目标。符号引用可以是任何形式的字面量,JAVA 在编译的时候一个每个 java 类都会被编译成一个 class 文件,但在编译的时候虚拟机并不知道所引用类的地址(实际地址) ,就用符号引用来代替,而在类的解析阶段 (后续 JVM 类加载会具体讲到) 就是为了把 这个符号引用转化成为真正的地址的阶段。 一个 java 类 (假设为 People 类) 被编译成一个 class 文件时,如果 People 类引用了 Tool 类,但是在编译时 People 类并不知道引用类的实际内存地址,因 此只能使用符号引用 (org.simple.Tool) 来代替。而在类装载器装载 People 类时,此时可以通过虚拟机获取 Tool 类的实际内存地址,因此便可以既将符号 org.simple.Tool 替换为 Tool 类的实际内存地址。 #### 运行时常量池 运行时常量池 (Runtime Constant Pool) 是每一个类或接口的常量池 ( Constant_Pool) 的运行时表示形式,它包括了若干种不同的常量: 从编译期可知的数值字面量到必须运行期解析后才能获得的方法或字段引用。 (这个是虚拟机规范中的描述,很生涩) 运行时常量池是在类加载完成之后,将 Class 常量池中的符号引用值转存到运行时常量池中,类在解析之后,将符号引用替换成直接引用。 运行时常量池在 JDK1.7 版本之后,就移到堆内存中了,这里指的是物理空间,而逻辑上还是属于方法区 (方法区是逻辑分区) 。 在 JDK1.8 中,使用元空间代替永久代来实现方法区,但是方法区并没有改变,所谓"Your father will always be your father"。变动的只是方法 区中内容的物理存放位置,但是运行时常量池和字符串常量池被移动到了堆中。但是不论它们物理上如何存放,逻辑上还是属于方法区的。 #### 字符串常量池 字符串常量池这个概念是最有争议的,虚拟机规范等很多正式文档,发现没有这个概念的官方定义,所以与运行时常量池的关系不 去抬杠,我们从它的作用和 JVM 设计它用于解决什么问题的点来分析它。 以 JDK1.8 为例,字符串常量池是存放在堆中,并且与 java.lang.String 类有很大关系。设计这块内存区域的原因在于:String 对象作为 Java 语言中重要的数据类型,是内存中占据空间最大的一个对象。高效地使用字符串,可以提升系统的整体性能。 所以要彻底弄懂,我们的重心其实在于深入理解 String。 ### String #### String 类分析 (JDK1.8) String 对象是对 char 数组进行了封装实现的对象,主要有 2 个成员变量:char 数组,hash 值。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca141e948.png) ##### String 对象的不可变性 了解了 String 对象的实现后,你有没有发现在实现代码中 String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。 我们知道类被 final 修饰代表该类不可继承,而 char[]被 final+private 修饰,代表了 String 对象不可被更改 Java 实现的这个特性叫作 String 对象的不可变性,即 String 对象一旦创建成功,就不能再对它进行改变。 Java 这样做的好处在哪里呢? 第一, 保证 String 对象的安全性。假设 String 对象是可变的,那么 String 对象将可能被恶意修改。 第二, 保证 hash 属性值不会频繁变更,确保了唯一性,使得类似 HashMap 容器才能实现相应的 key-value 缓存功能。 第三, 可以实现字符串常量池。在 Java 中,通常有两种创建字符串对象的方式,一种是通过字符串常量的方式创建,如 Stringstr=“abc” ;另一种是 字符串变量通过 new 形式的创建,如 String str = new String(“abc”)。 #### String 的创建方式及内存分配的方式 1 、String str=“abc”; 当代码中使用这种方式创建字符串对象时,JVM 首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中 被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。 (str 只是一个引用) ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca189a08c.png) 2 、String str = new String(“abc”) 首先在编译类文件时,"abc"常量字符串将会放入到常量结构中,在类加载时,“abc"将会在常量池中创建;其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc” 字符串,在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca1bd6ea1.png) 4 、 使用 new ,对象会创建在堆中,同时赋值的话,会在常量池中创建一个字符串对象,复制到堆中。 具体的复制过程是先将常量池中的字符串压入栈中,在使用 String 的构造方法是,会拿到栈中的字符串作为构方法的参数。 这个构造函数是一个 char 数组的赋值过程,而不是 new 出来的,所以是引用了常量池中的字符串对象。存在引用关系。 ```java public class Location { private String city; private String region; } ``` ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9ae5d21e.jpg) 4 、String str2= "ab"+ "cd"+ "ef"; 编程过程中,字符串的拼接很常见。前面我讲过 String 对象是不可变的,如果我们使用 String 对象相加,拼接我们想要的字符串,是不是就会产生多个 对象呢?例如以下代码: 分析代码可知:首先会生成 ab 对象,再生成 abcd 对象,最后生成 abcdef 对象,从理论上来说,这段代码是低效的。 编译器自动优化了这行代码,编译后的代码,你会发现编译器自动优化了这行代码,如下 String str= "abcdef"; 5 、大循环使用 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca22d9722.png) ##### intern String 的 intern 方法,如果常量池中有相同值,就会重复使用该对象,返回对象引用。 ![img](https://files.vps4cloud.com/i/2023/04/26/6449188e34483.png) 1 、new Sting() 会在堆内存中创建一个 a 的 String 对象,king"将会在常量池中创建 2 、在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。 3 、调用 new Sting() 会在堆内存中创建一个 b 的 String 对象。 4 、在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。 所以 a 和 b 引用的是同一个对象。 如果需要查看 String 的编译优化,需要使用到反编译工具,推荐 JD-GUI:http://java-decompiler.github.io/ ## 玩转 JVM 中的对象及引用 ### JVM 中对象的创建过程 (重点) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b042384.jpg) #### 对象的内存分配 虚拟机遇到一条 new 指令时,首先检查是否被类加载器加载,如果没有,那必须先执行相应的类加载过程。 类加载就是把 class 加载到 JVM 的运行时数据区的过程 (类加载后面有专门的专题讲) 。 ##### 1. 检查加载 首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用 (符号引用 :符号引用以一组符号来描述所引用的目标) ,并且检查类是否已经被加载、 解析和初始化过。 ##### 2.分配内存 接下来虚拟机将为新生对象分配内存。为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。 ###### 指针碰撞 如果 Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅 是把那个指针向空闲空间那边挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca2b8bea6.png) ###### 空闲列表 如果 Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单地进行指针碰撞了,虚拟机就必须维护一个列表,记录上 哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录,这种分配方式称为“空闲列表”。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca2e7dad3.png) 选择哪种分配方式由 Java 堆是否规整决定,而 Java 堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。 如果是 Serial 、ParNew 等带有压缩的整理的垃圾回收器的话,系统采用的是指针碰撞,既简单又高效。 如果是使用 CMS 这种不带压缩 (整理) 的垃圾回收器的话,理论上只能采用较复杂的空闲列表。 ###### 并发安全 除如何划分可用空间之外,还有另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情 况下也并不是线程安全的,可能出现正在给对象 A 分配内存,指针还没来得及修改,对象 B 又同时使用了原来的指针来分配内存的情况。 **CAS 机制** 解决这个问题有两种方案,一种是对分配内存空间的动作进行同步处理——实际上虚拟机采用 CAS 配上失败重试的方式保证更新操作的原子性; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b1e3e19.jpg) **分配缓冲** 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在 Java 堆中预先分配一小块私有内存,也就是本地线程分配缓冲 (Thread Local Allocation Buffer,TLAB) ,JVM 在线程初始化时,同时也会申请一块指定大小的内存,只给当前线程使用,这样每个线程都单独拥有一个 Buffer ,如果 需要分配内存,就在自己的 Buffer 上分配,这样就不存在竞争的情况,可以大大提升分配效率,当 Buffer 容量不够的时候,再重新从 Eden 区域申请一块 继续使用。 TLAB 的目的是在为新对象分配内存空间时,让每个 Java 应用线程能在使用自己专属的分配指针来分配空间,减少同步开销。 TLAB 只是让每个线程有私有的分配指针,但底下存对象的内存空间还是给所有线程访问的,只是其它线程无法在这个区域分配而已。当一个 TLAB 用满(分 配指针 top 撞上分配极限 end 了) ,就新申请一个 TLAB。 参数: -XX:+UseTLAB 允许在年轻代空间中使用线程本地分配块 (TLAB) 。默认情况下启用此选项。要禁用 TLAB ,请指定-XX:- UseTLAB。 https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca37bee10.png) ##### 3.内存空间初始化 (注意不是构造方法) 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值(如 int 值为 0 ,boolean 值为 false 等等)。这一步操作保证了对象 的实例字段在 Java 代码中可以不赋初始值就直接使用,程序能访问到这些字段的数据类型所对应的零值。 ##### 4.设置 接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息 (Java classes 在 Java hotspot VM 内部表示为类 元数据) 、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。 ##### 5.对象初始化 在上面工作都完成之后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视角来看,对象创建才刚刚开始,所有的字段都还为零值。 所以,一般来说,执行 new 指令之后会接着把对象按照程序员的意愿进行初始化(构造方法) ,这样一个真正可用的对象才算完全产生出来。 ### 对象的内存布局 (重点) ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca3a5eba1.png) 在 HotS pot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:对象头 (Header) 、实例数据 (Instance Data) 和对齐填充 (Padding) 。 对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码 (HashCode) 、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID 、偏向时间戳等。 对象头的另外一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。 如果对象是一个 java 数组,那么在对象头中还有一块用于记录数组长度的数据。 第三部分对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。 由于 HotS pot VM 的自动内存管理系统要求对对象的大小必须 是 8 字节的整数倍。当对象其他数据部分没有对齐时,就需要通过对齐填充来补全。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca3daab85.png) ### 对象的访问定位 建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。 目前主流的访问方式有使用句柄和直接指针两种。 #### 句柄 如果使用句柄访问的话,那么 Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类 型数据各自的具体地址信息。 使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动 (垃圾收集时移动对象是非常普遍的行为) 时只会改变句柄中的实 例数据指针,而 reference 本身不需要修改. #### 直接指针 如果使用直接指针访问, reference 中存储的直接就是对象地址。 这两种对象访问方式各有优势,使用直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销, 由于对象的访问在 Java 中非常频 繁,因此这类开销积少成多后也是一项非常可观的执行成本。 对 Sun HotS pot 而言,它是使用直接指针访问方式进行对象访问的。 ### 判断对象的存活 (重点) 在堆里面存放着几乎所有的对象实例,垃圾回收器在对对进行回收前,要做的事情就是确定这些对象中哪些还是“存活”着,哪些已经“死去” (死去 代表着不可能再被任何途径使用得对象了) **什么是垃圾?** C 语言申请内存:malloc free C++: new delete C/C++ 手动回收内存 Java: new Java 是自动内存回收,编程上简单,系统不容易出错。 手动释放内存,容易出两种类型的问题: 1 、忘记回收 2 、多次回收 没有任何引用指向的一个对象或者多个对象 (循环引用) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b4d153e.jpg) #### 引用计数法 在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加 1 ,当引用失效时,计数器减 1. ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca45419fd.png) Python 在用,但主流虚拟机没有使用,因为存在对象相互引用的情况,这个时候需要引入额外的机制来处理,这样做影响效率, ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca481938d.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca4eb5469.png) 在代码中看到,只保留相互引用的对象还是被回收掉了,说明 JVM 中采用的不是引用计数法。 #### 可达性分析 (面试时重要的知识点,牢记) 来判定对象是否存活的。这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为 引用链 (Reference Chain) ,当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的。 作为 GC Roots 的对象包括下面几种 (重点是前面 4 种) : ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b722684.jpg) 虚拟机栈 (栈帧中的本地变量表) 中引用的对象;各个线程调用方法堆栈中使用到的参数、局部变量、临时变量等。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b722684.jpg) 方法区中类静态属性引用的对象;java 类的引用类型静态变量。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b722684.jpg) 方法区中常量引用的对象; 比如:字符串常量池里的引用。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b861c71.jpg) 本地方法栈中 JNI (即一般说的 Native 方法) 引用的对象。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b861c71.jpg) JVM 的内部引用 (class 对象、异常对象 NullPointException 、OutofMemoryError ,系统类加载器) 。 (非重点) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b722684.jpg) 所有被同步锁(synchronized 关键)持有的对象。 (非重点) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b722684.jpg) JVM 内部的 JMXBean 、JVMTI 中注册的回调、本地代码缓存等 (非重点) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b722684.jpg) JVM 实现中的“临时性”对象,跨代引用的对象 (在使用分代模型回收只回收部分代的对象,这个后续会细讲,先大致了解概念) (非重点) 以上的回收都是对象,类的回收条件: 注意 Class 要被回收,条件比较苛刻,必须同时满足以下的条件 (仅仅是可以,不代表必然,因为还有一些参数可以进行控制) : 1、该类所有的实例都已经被回收,也就是堆中不存在该类的任何实例。 2 、 加载该类的 ClassLoader 已经被回收。 3 、 该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。 4 、 参数控制: ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca5920541.png) 废弃的常量和静态变量的回收其实就和 Class 回收的条件差不多。 #### Finalize 方法 即使通过可达性分析判断不可达的对象,也不是“非死不可” ,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是 没有找到与 GCRoots 的引用链,它将被第一次标记。随后进行一次筛选 (如果对象覆盖了 finalize) ,我们可以在 finalize 中去拯救。 代码演示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca56910cb.png) 运行结果: ![img](https://files.vps4cloud.com/i/2023/04/26/644918a78a6f0.png) 可以看到,对象可以被拯救一次(finalize 执行第一次,但是不会执行第二次) 代码改一下,再来一次。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca5dcf356.png) 运行结果: ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca61e8a45.png) 对象没有被拯救,这个就是 finalize 方法执行缓慢,还没有完成拯救,垃圾回收器就已经回收掉了。 所以建议大家尽量不要使用 finalize ,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了 finalize 方法!因为在 finalize 方法能做的工作,java 中有更好的,比如 try-finally 或者其他方式可以做得更好 ### 各种引用 #### 强引用 一般的 Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联 (与根可达) 还在,垃圾回收器就永远不会回收掉被引用的对象。 #### 软引用 SoftReference 一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出 (OuyOfMemory) 之前,这些对象就会被回收 (如果这次回收后还是没有足够的 空间,才会抛出内存溢出) 。参见代码: VM 参数 -Xms10m -Xmx10m -XX:+PrintGC ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca652edc2.png) 运行结果 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca69419d2.png) 例如,一个程序用来处理用户提供的图片。如果将所有图片读入内存,这样虽然可以很快的打开图片,但内存空间使用巨大,一些使用较少的图片浪费 内存空间,需要手动从内存中移除。如果每次打开图片都从磁盘文件中读取到内存再显示出来,虽然内存占用较少,但一些经常使用的图片每次打开都 要访问磁盘,代价巨大。这个时候就可以用软引用构建缓存。 #### 弱引用 WeakReference 一些有用 (程度比软引用更低) 但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC 发生时,不管内存够不够,都会被回收。 参看代码: ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca6c5a865.jpg) 注意:软引用 SoftReference 和弱引用 WeakReference ,可以用在内存资源紧张的情况下以及创建不是很重要的数据缓存。当系统内存不足的时候,缓存 中的内容是可以被释放的。 实际运用 (WeakHashMap 、ThreadLocal) #### 虚引用 PhantomReference 幽灵引用,最弱 (随时会被回收掉) 垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。 ### 对象的分配策略 #### 栈上分配 ##### 没有逃逸 (了解即可) 即方法中的对象没有发生逃逸。 **逃逸分析的原理**:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。 比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。 从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。 如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高 JVM 的效率 ##### 逃逸分析代码 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca6f90937.png) 这段代码在调用的过程中 Myboject 这个对象属于不可逃逸,JVM 可以做栈上分配 然后通过开启和关闭 DoEscapeAnalysis 开关观察不同。 开启逃逸分析 (JVM 默认开启) ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca727fb1e.png) 查看执行速度 ![img](https://files.vps4cloud.com/i/2023/04/26/644918b6ec866.png) 关闭逃逸分析 ![img](https://files.vps4cloud.com/i/2023/04/26/644918bdc7348.png) 查看执行速度 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca7542447.png) 测试结果可见,开启逃逸分析对代码的执行性能有很大的影响!那为什么有这个影响? ##### 逃逸分析 如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。 采用了逃逸分析后,满足逃逸的对象在栈上分配 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca7908486.png) 没有开启逃逸分析,对象都在堆上分配,会频繁触发垃圾回收 (垃圾回收会影响系统性能) ,导致代码运行慢 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca7be0e67.png) 代码验证 开启 GC 打印日志 -XX:+PrintGC 开启逃逸分析 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca7e636e2.png) 可以看到没有 GC 日志 关闭逃逸分析 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca810c03a.png) 可以看到关闭了逃逸分析,JVM 在频繁的进行垃圾回收 (GC) ,正是这一块的操作导致性能有较大的差别。 #### 对象优先在 Eden 区分配 大多数情况下,对象在新生代 Eden 区中分配。当 Eden 区没有足够空间分配时,虚拟机将发起一次 Minor GC。 #### 大对象直接进入老年代 大对象就是指需要大量连续内存空间的 Java 对象,最典型的大对象便是那种很长的字符串,或者元素数量很庞大的数组。 大对象对虚拟机的内存分配来说就是一个不折不扣的坏消息,比遇到一个大对象更加坏的消息就是遇到- -群“朝生夕灭”的“短命大对象”,我们写程序 的时候应注意避免。 在 Java 虚拟机中要避免大对象的原因是,在分配空间时,它容易导致内存明明还有不少空间时就提前触发垃圾收集,以获取足够的连续空间才能安置好 它们。 而当复制对象时,大对象就意味着高额的内存复制开销。 HotS pot 虚拟机提供了-XX: PretenureSizeThreshold 参数,指定大于该设置值的对象直接在老年代分配,这样做的目的就是避免在 Eden 区及两个 Survivor 区之间来回复制,产生大量的内存复制操作。 这样做的目的:1.避免大量内存复制,2.避免提前进行垃圾回收,明明内存有空间进行分配。 PretenureSizeThreshold 参数只对 Serial 和 ParNew 两款收集器有效。-XX: PretenureSizeThreshold=4m #### 长期存活对象进入老年区 HotS pot 虚拟机中多数收集器都采用了分代收集来管理堆内存,那内存回收时就必须能决策哪些存活对象应当放在新生代,哪些存活对象放在老年代中。 为做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器,存储在对象头中。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca84783a1.png) 如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1,对象在 Survivor 区中每熬过一次 Minor GC ,年龄就增加 1 ,当它的年龄增加到一定程度(并发的垃圾回收器默认为 15),CMS 是 6 时,就会被晋升到老年代中。 -XX: MaxTenuringThreshold 调整 #### 对象年龄动态判定 为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中 相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的 年龄 #### 空间分配担保 在发生 MinorGC 之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么 MinorGC 可以确保是安全 的。如果不成立,则虚拟机会查看 HandleP romotionFailure 设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历 次晋升到老年代对象的平均大小,如果大于,将尝试着进行一次 Minor GC ,尽管这次 MinorGC 是有风险的,如果担保失败则会进行一次 FullGC;如果小 于,或者 HandlePromotionFailure 设置不允许冒险,那这时也要改为进行一次 Full GC。 #### 本地线程分配缓冲(TLAB) 具体见章节[分配缓冲](#_bookmark5) ## 垃圾回收机制及算法 ### 垃圾回收基础知识 #### 什么是 GC Java 与 C++ 等语言最大的技术区别: 自动化的垃圾回收机制 (GC) 为什么要了解 GC 和内存分配策略 1 、面试需要 2 、GC 对应用的性能是有影响的; 3 、写代码有好处 栈:栈中的生命周期是跟随线程,所以一般不需要关注 堆:堆中的对象是垃圾回收的重点 方法区/元空间:这一块也会发生垃圾回收,不过这块的效率比较低,一般不是我们关注的重点 #### 分代回收理论 (重点) 当前商业虚拟机的垃圾回收器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的: 1 、 绝大部分的对象都是朝生夕死。 2 、 熬过多次垃圾回收的对象就越难回收。 根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca88bb31c.png) #### GC 分类 市面上发生垃圾回收的叫法很多,我大体整理了一下: 1 、 新生代回收 (Minor GC/Young GC) :指只是进行新生代的回收。 2 、 老年代回收 (Major GC/OldGC) :指只是进行老年代的回收。 目前只有 CMS 垃圾回收器会有这个单独的回收老年代的行为。 (MajorGC 定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法) 3 、 整堆回收 (Full GC) :收集整个 Java 堆和方法区(注意包含方法区) ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca8e7e237.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca91beb70.png) ### 垃圾回收算法 (重点) 垃圾回收算法的实现设计到大量的程序细节,并且每一个平台的虚拟机操作内存的方式都有不同,所以不需要去了解算法的实现,我们重点讲解 3 种算 法的思想。 #### 复制算法 (Copying) 将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使 用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收, 内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可, 实现简单,运行高效。只是这种算法的代价是将内存缩小为了原来的一半。 但是要注意: 内存移动是必须实打实的移动 (复制) ,所以对应的引用(直接指针)需要调整。 复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca9ac7278.png) ##### Appel 式回收 一种更加优化的复制回收分代策略:具体做法是分配一块较大的 Eden 区和两块较小的 Survivor 空间 (你可以叫做 From 或者 To ,也可以叫做 Survivor1 和 Survivor2) 专门研究表明,新生代中的对象 98% 是“朝生夕死”的,所以并不需要按照 1 :1 的比例来划分内存空间,而是将内存分为一块较大的 Eden 空间和两块较 小的 Survivor 空间,每次使用 Eden 和其中一块 Survivor[1] 。当回收时,将 Eden 和 Survivor 中还存活着的对象一次性地复制到另外一块 Survivor 空间上, 最后清理掉 Eden 和刚才用过的 Survivor 空间。 HotS pot 虚拟机默认 Eden 和 Survivor 的大小比例是 8 :1 ,也就是每次新生代中可用内存空间为整个新生代容量的 90% (80%+10%) ,只有 10% 的内存会被 “浪费” 。当然,98% 的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于 10% 的对象存活,当 Survivor 空间不够用时,需要 依赖其他内存 (这里指老年代) 进行分配担保 (Handle Promotion) ![img](https://files.vps4cloud.com/i/2023/04/22/6443ca9e810ce.png) #### 标记-清除算法 (Mark-Sweep 算法分为“标记”和“清除”两个阶段:首先扫描所有对象标记出需要回收的对象,在标记完成后扫描回收所有被标记的对象,所以需要扫描两遍。 回收效率略低,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率要低。 它的主要问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连 续内存而不得不提前触发另一次垃圾回收动作。 回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。 #### 标记-整理算法 (Mark-Compact) 首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端 边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。 我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用 对象的地方都需要更新 (直接指针需要调整) 。 所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。 ## JVM 中常见的垃圾回收器 在新生代中,每次垃圾回收时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成回收。 而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443caa1a3c1e.jpg) 请记住下图的垃圾收集器和之间的连线关系。 Oracle 官方也有对应英文解释 [https://docs.oracle.com/en/java/javase/13/gctuning/ergonomics.html#GUID-DB4CAE94-2041-4A16-90EC-6AE3D91EC1F1](https://docs.oracle.com/en/java/javase/13/gctuning/ergonomics.html) (比较难理解,基于 java13 的) ### Serial/Serial Old (了解即可) JVM 刚诞生就只有这种,最古老的,单线程,独占式,成熟,适合单 CPU ,一般用在客户端模式下。 这种垃圾回收器只适合几十兆到一两百兆的堆空间进行垃圾回收 (可以控制停顿时间再 100ms 左右) ,但是对于超过这个大小的内存回收速度很慢,所 以对于现在来说这个垃圾回收器已经是一个鸡肋。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443caa4871fe.jpg) #### 参数设置 -XX:+UseSerialGC 新生代和老年代都用串行收集器 Stop The World (STW) (重点) 单线程进行垃圾回收时,必须暂停所有的工作线程,直到它回收结束。这个暂停称之为“Stop The World”,但是这种 STW 带来了恶劣的用户体验,例如:应 用每运行一个小时就需要暂停响应 5 分。这个也是早期 JVM 和 java 被 C/C++ 语言诟病性能差的一个重要原因。所以 JVM 开发团队一直努力消除或降低 STW 的时间。 ### Parallel Scavenge (ParallerGC)/Parallel Old (重点) 为了提高回收效率,从 JDK1.3 开始,JVM 使用了多线程的垃圾回收机制,关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用 CPU 时间,尽快完成 程序的运算任务,主要适合在后台运算而不需要太多交互的任务。 所谓吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量=运行用户代码时间/ (运行用户代码时间 + 垃圾收集时间) ,虚拟机总 共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%。 该垃圾回收器适合回收堆空间上百兆~几个 G。 #### 参数设置 #### 开启参数 JDK1.8 默认就是以下组合 -XX:+UseParallelGC 新生代使用 Parallel Scavenge ,老年代使用 Parallel Old 收集器提供了两个参数用于精确控制吞吐量,分别控制的停顿时间的-XX: MaxGCPauseMillis 参数以及直接设置吞吐量大小的-XX:GCTimeRatio 参数。 https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html -XX:MaxGCPauseMillis ![img](https://files.vps4cloud.com/i/2023/04/26/644922f702532.png) 不过大家不要异想天开地认为如果把这个参数的值设置得更小一点就能使得系统的垃圾收集速度变得更快,垃圾收集停顿时间缩短是以牺牲吞吐 量和新生代空间为代价换取的:系统把新生代调得小一些,收集 300MB 新生代肯定比收集 500MB 快,但这也直接导致垃圾收集发生得更频繁,原来 10 秒 收集一次、每次停顿 100 毫秒,现在变成 5 秒收集一次、 每次停顿 70 毫秒。停顿时间的确在下降,但吞吐量也降下来了。 -XX:GCTimeRatio -XX:GCTimeRatio 参数的值则应当是一个大于 0 小于 100 的整数,也就是垃圾收集时间占总时间的比率,相当于吞吐量的倒数。 例如:把此参数设置为 19, 那允许的最大垃圾收集时占用总时间的 5% (即 1/(1+ 19)) , 默认值为 99 ,即允许最大 1% (即 1/(1+99))的垃圾收集时间 由于与吞吐量关系密切,ParallelScavenge 是“吞吐量优先垃圾回收器”。 -XX:+UseAdaptiveSizePolicy -XX:+UseAdaptiveSizePolicy (默认开启) 。这是一个开关参数, 当这个参数被激活之后,就不需要人工指定新生代的大小(-Xmn) 、Eden 与 Survivor 区的 比例(-XX:SurvivorRatio) 、 晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调 整这些参数以提供最合适的停顿时间或者最大的吞吐量。 ![img](https://files.vps4cloud.com/i/2023/04/26/644918d370971.png) ### ParNew (了解即可) 多线程垃圾回收器,与 CMS 进行配合,对于 CMS(CMS 只回收老年代) ,新生代垃圾回收器只有 Serial 与 ParNew 可以选。和 Serial 基本没区别,唯一的区 别:多线程,多 CPU 的,停顿时间比 Serial 少。 (在 JDK9 以后,把 ParNew 合并到了 CMS 了) 大致了解下搭配关系即可,后续版本已经接近淘汰。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cab1d2282.png) ### Concurrent Mark Sweep (CMS) ![img](https://files.vps4cloud.com/i/2023/04/22/6443caac68b73.jpg) 收集器是一种以获取最短回收停顿时间为目标的收集器。 目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务 的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS 收集器就非常符合这类应用的需求。 从名字 (包含“Mark Sweep” ) 上就可以看出,CMS 收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些, 整个过程分为 4 个步骤,包括: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b722684.jpg) 初始标记-短暂,仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b861c71.jpg) 并发标记-和用户的应用程序同时进行,进行 GC Roots 追踪的过程,标记从 GCRoots 开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理 (垃圾回收器线程和用户线程同时工作) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b861c71.jpg) 重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标 记阶段稍长一些,但远比并发标记的时间短。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b861c71.jpg) 并发清除 由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS 收集器的内存回收过程是与用 户线程一起并发执行的。 -XX:+UseConcMarkSweepGC ,表示新生代使用 ParNew ,老年代的用 CMS CPU 敏感:CMS 对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足 4 个时,CMS 对用户的影响较大。 浮动垃圾: 由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法 在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉。这一部分垃圾就称为“浮动垃圾”。 由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。 在 1.6 的版本中老年代空间使用率阈值(92%) 如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure ,这时虚拟机将临时启用 Serial Old 来替代 CMS。 会产生空间碎片:标记 - 清除算法会导致产生不连续的空间碎片 总体来说,CMS 是 JVM 推出了第一款并发垃圾收集器,所以还是非常有代表性。 但是最大的问题是 CMS 采用了标记清除算法,所以会有内存碎片,当碎片较多时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS 提供一个 参数:-XX:+UseCMSCompactAtFullCollection ,一般是开启的,如果分配不了大对象,就进行内存碎片的整理过程。 这个地方一般会使用 Serial Old ,因为 Serial Old 是一个单线程,所以如果内存空间很大、且对象较多时,CMS 发生这样情况会很卡。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cab6ea577.png) #### CMS 总结 CMS 问题比较多,所以现在没有一个版本默认是 CMS ,只能手工指定。但是它毕竟是第一个并发垃圾回收器,对于了解并发垃圾回收具有一定意义,所 以我们必须了解。 为什么 CMS 采用标记-清除,在实现并发的垃圾回收时,如果采用标记整理算法,那么还涉及到对象的移动 (对象的移动必定涉及到引用的变化,这个需 要暂停业务线程来处理栈信息,这样使得并发收集的暂停时间更长) ,所以使用简单的标记-清除算法才可以降低 CMS 的 STW 的时间。 该垃圾回收器适合回收堆空间几个 G~ 20G 左右。 在 JDK1.8 中,配置参数: ![img](https://files.vps4cloud.com/i/2023/04/22/6443cabb81975.jpg) ### Garbage First(G1) #### 设计思想 随着 JVM 中内存的增大,STW 的时间成为 JVM 急迫解决的问题,但是如果按照传统的分代模型,总跳不出 STW 时间不可预测这点。 为了实现 STW 的时间可预测,首先要有一个思想上的改变。G1 将堆内存“化整为零”,将堆内存划分成多个大小相等独立区域 (Region) ,每一个 Region 都可以根据需要,扮演新生代的 Eden 空间、Survivor 空间,或者老年代空间。回收器能够对扮演不同角色的 Region 采用不同的策略去处理,这样无论是 新创建的对象还是已经存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果。 #### Region Region 可能是 Eden,也有可能是 Survivor,也有可能是 Old,另外 Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。 G1 认为只要大小超过 了一个 Region 容量一半的对象即可判定为大对象。每个 Region 的大小可以通过参数-XX:G1HeapRegionSize 设定,取值范围为 1MB~32MB,且应为 2 的 N 次 幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的进行回收大多数情况下都把 Humongous Region 作为老年代的一部分来进行看待。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cabe11ecc.png) #### 参数设置 **开启参数** -XX:+UseG1GC **分区大小** -XX:+G1HeapRegionSize ![img](https://files.vps4cloud.com/i/2023/04/22/6443cac119ff7.png) 一般建议逐渐增大该值,随着 size 增加,垃圾的存活时间更长,GC 间隔更长,但每次 GC 的时间也会更长。 **最大** GC 暂停时间 MaxGCPauseMillis ![img](https://files.vps4cloud.com/i/2023/04/22/6443cac3c9a66.png) #### 运行过程 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cac681f86.jpg) G1 的运作过程大致可划分为以下四个步骤: **初始标记**( Initial Marking) 仅仅只是标记一下 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。 这个阶段需要停顿线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的,所以 G1 收集器在这个阶段实际并没有额外的停顿。 TAMS 是什么? 要达到 GC 与用户线程并发运行,必须要解决回收过程中新对象的分配,所以 G1 为每一个 Region 区域设计了两个名为 TAMS (Topat Mark Start) 的指针, 从 Region 区域划出一部分空间用于记录并发回收过程中的新对象。这样的对象认为它们是存活的,不纳入垃圾回收范围。 **并发标记**( Concurrent Marking) 从 GC Root 开始对堆中对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这阶段耗时较长,但可与用户程序并发执行。当对象图扫 描 完 成 以 后 , 并 发 时 有 引用 变 动 的 对 象 , 这 些 对 象 会 漏 标 ( 后 续 再 讲 三 色 标 记 的 时 候 会 细 讲 这 个 问 题 ) , 漏 标 的 对 象 会 被 一 个 叫 做 SATB(snapshot-at-the-beginning)算法来解决 (这个下节课会细讲) **最终标记**( Final Marking) 对用户线程做另一个短暂的暂停,用于处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。 筛选回收( Live Data Counting and Evacuation) 负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户所期望的停顿时间来制定回收计划,可以自由选择任意多个 Region 构 成回收集,然后把决定回收的那一部分 Region 的存活对象复制到空的 Region 中,再清理掉整个旧 Region 的全部空间。这里的操作涉及存活对象的移动, 是必须暂停用户线程, 由多条收集器线程并行完成的。 #### 特点 并行与并发:G1 能充分利用多 CPU 、多核环境下的硬件优势,使用多个 CPU (CPU 或者 CPU 核心) 来缩短 Stop-The-World 停顿的时间,部分其他收集器 原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。 分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式 去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。 空间整合:与 CMS 的“标记—清理”算法不同,G1 从整体来看是基于“标记—整理”算法实现的收集器,从局部 (两个 Region 之间) 上来看是基于“复 制”算法实现的,但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运 行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。 追求停顿时间: -XX: MaxGCPauseMillis 指定目标的最大停顿时间,G1 尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。 怎么玩? 该垃圾回收器适合回收堆空间上百 G 。一般在 G1 和 CMS 中间选择的话平衡点在 6~8G ,只有内存比较大 G1 才能发挥优势。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cacac31b8.jpg) ## 垃圾回收器整理 (重点) |收集器|收集对象和算法|收集器类型|说明|适用场景| | ----------------------------------| ---------------------------------------| -------------------| --------------------------------------------------------------------------------------------------------------------| -----------------------------------------------------------------------------------------------------------------| |Serial|新生代,复制算法|单线程||简单高效;适合内存不大的情况;| |ParNew|新生代,复制算法|并行的多线程收集 器|ParNew 垃 圾 收 集 器 是 Serial 收集器的多线程版 本|搭配 CMS 垃圾回收器的首选| |Parallel Scavenge 吞吐量优先收集器|新生代,复制算法|并行的多线程收集 器|类似 ParNew,更加关注吞 吐量,达到一个可控制的 吞吐量;|本身是 Server 级别多 CPU 机 器上的默认 GC 方式,主要 适合后台运算不需要太多交 互的任务;| |Serial Old|老年代,标记整理算法|单线程||Client 模式下虚拟机使用| |Parallel Old|老年代,标记整理算法|并行的多线程收集器|Parallel Scavenge 收集器的老年代版 本,为了配合 Parallel Scavenge 的面 向吞吐量的特性而开发的对应组合;|在注重吞吐量以及 CPU 资 源敏感的场合采用| |CMS|老年代,标记清除算法|并行与并发收集器|尽可能的缩短垃圾收集时用户线程 停止时间;缺点在于:<br />1. 内存碎片<br />2. 需要更多 cpu 资源 <br />3.浮动垃圾问题,需要更大的堆空间|重视服务的响应速度、系统 停顿时间和用户体验的互 联网网站或者 B/S 系统。互 联网后端 目前 cms 是主流 的垃圾回收器;| |G1|跨新生代和老年代;标记 整理 + 化整为零|并行与并发收集器|JDK1.7 才正式引入,采用分区回收的 思维,基本不牺牲吞吐量的前提下完 成低停顿的内存回收;可预测的停顿 是其最大的优势;|面向服务端应用的垃圾回 收器, 目标为取代 CMS| 并行:垃圾收集的多线程的同时进行。 并发:垃圾收集的多线程和应用的多线程同时进行。 注:吞吐量=运行用户代码时间/(运行用户代码时间 + 垃圾收集时间) 垃圾收集时间= 垃圾回收频率 * 单次垃圾回收时间 ## 垃圾回收器串讲及 HostSpot 的细节实现 (JVM 面试“核武器”) ### 并发标记与三色标记 #### 三色标记 在三色标记法之前有一个算法叫 Mark-And-Sweep (标记清除) 。这个算法会设置一个标志位来记录对象是否被使用。最开始所有的标记位都是 0 ,如果 发现对象是可达的就会置为 1 ,一步步下去就会呈现一个类似树状的结果。等标记的步骤完成后,会将未被标记的对象统一清理,再次把所有的标记位 设置成 0 方便下次清理。 这个算法最大的问题是 GC 执行期间需要把整个程序完全暂停,不能异步进行 GC 操作。因为在不同阶段标记清扫法的标志位 0 和 1 有不同的含义, 那么新增的对象无论标记为什么都有可能意外删除这个对象。对实时性要求高的系统来说,这种需要长时间挂起的标记清扫法是不可接受的。所以就需 要一个算法来解决 GC 运行时程序长时间挂起的问题,那就三色标记法。 三色标记最大的好处是可以异步执行,从而可以以中断时间极少的代价或者完全没有中断来进行整个 GC。 三色标记法很简单。首先将对象用三种颜色表示,分别是白色、灰色和黑色。 黑色:根对象,或者该对象与它的子对象都被扫描过。 灰色:对本身被扫描,但是还没扫描完该对象的子对象。 白色:未被扫描对象,如果扫描完所有对象之后,最终为白色的为不可达对象,既垃圾对象。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cace61851.jpg) #### 三色标记的问题 GC 并发情况下的漏标问题 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cad11e60b.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644923142c192.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cad3a409d.png) ##### CMS 中的解决方案 Incremental Update 算法 当一个白色对象被一个黑色对象引用 ,将黑色对象重新标记为灰色 ,让垃圾回收器重新扫描 ##### G1 中的解决方案 SATB(snapshot-at-the-beginning) 刚开始做一个快照,当 B 和 C 消失的时候要把这个引用推到 GC 的堆栈,保证 C 还能被 GC 扫描到,最重要的是要把这个引用推到 GC 的堆栈,是灰色对 象指向白色的引用,如果一旦某一个引用消失掉了,我会把它放到栈 (GC 方法运行时数据也是来自栈中) ,我其实还是能找到它的,我下回直接扫描他 就行了,那样白色就不会漏标。 对应 G1 的垃圾回收过程中的: 最终标记( Final Marking) 对用户线程做另一个短暂的暂停,用于处理并发阶段结后仍遗留下来的最后那少量的 SATB 记录(漏标对象)。 ##### 对比 SATB 算法是关注引用的删除。 (B->C 的引用) Incremental Update 算法关注引用的增加。 (A->C 的引用) G1 如果使用 Incremental Update 算法,因为变成灰色的成员还要重新扫,重新再来一遍,效率太低了。 所以 G1 在处理并发标记的过程比 CMS 效率要高,这个主要是解决漏标的算法决定的。 ### G1 中的技术细节 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cad6bb432.jpg) #### 跨代引用 堆空间通常被划分为新生代和老年代。 由于新生代的垃圾收集通常很频繁,如果老年代对象引用了新生代的对象,那么回收新生代的话,需要跟踪从老 年代到新生代的所有引用,所以要避免每次 YGC 时扫描整个老年代,减少开销。 #### RSet (记忆集) 记录了其他 Region 中的对象到本 Region 的引用, RSet 的价值在于使得垃圾收集器不需要扫描整个堆,找到谁引用了当前分区中的对象,只需要扫描 RSet 即可。 RSet 本身就是一个 Hash 表,如果是在 G1 的话,则是在一个 Region 区里面。 #### CardTable 由于做新生代 GC 时,需要扫描整个 OLD 区,效率非常低,所以 JVM 设计了 CardTable,如果一个 OLD 区 CardTable 中有对象指向 Y 区, 就将它设为 Dirty (标志位 1) , 下次扫描时,只需要扫描 CARDTABLE 上是 Dirty 的内存区域即可。 字节数组 CARDTABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page) 。 一般来说,卡页大小 都是以 2 的 N 次幂的字节数,假设使用的卡页是 2 的 10 次幂,即 1K, 内存区域的起始地址是 0x0000 的话,数组 CARD_TABLE 的第 0 、1 、2 号元素,分别 对应了地址范围为 0x0000~0x03FF 、0x0400 ~ 0x07FF 、0x0800~0x011FF 的卡页内存. #### 总结 这里描述的是 G1 处理跨代引用的细节,其实在 CMS 中也有类似的处理方式,比如 CardTable,也需要记录一个 RSet 来记录,我们对比一下,在 G1 中是每 一个 Region 都需要一个 RSet 的内存区域,导致有 G1 的 RSet 可能会占据整个堆容量的 20% 乃至更多。但是 CMS 只需要一份,所以就内存占用来说,G 占用的内存需求更大,虽然 G1 的优点很多,但是我们不推荐在堆空间比较小的情况下使用 G1 ,尤其小于 6 个 G。 ### 安全点与安全区域 #### 安全点 用户线程暂停,GC 线程要开始工作,但是要确保用户线程暂停的这行字节码指令是不会导致引用关系的变化。所以 JVM 会在字节码指令中,选一些指令, 作为“安全点”,比如方法调用、循环跳转、异常跳转等,一般是这些指令才会产生安全点。 为什么它叫安全点,是这样的,GC 时要暂停业务线程,并不是抢占式中断 (立马把业务线程中断) 而是主动是中断。 主动式中断是设置一个标志,这个标志是中断标志,各业务线程在运行过程中会不停的主动去轮询这个标志,一旦发现中断标志为 True,就会在自己最近 的“安全点”上主动中断挂起。 #### 安全区域 为什么需要安全区域? 要是业务线程都不执行 (业务线程处于 Sleep 或者是 Blocked 状态) ,那么程序就没办法进入安全点,对于这种情况,就必须引入安全区域。 安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区 城看作被扩展拉伸了的安全点。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cadd1e2b1.png) 当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这个线程了。 当线程要离开安全区域时,它要 JVM 是否已经完成了 (根节点枚举,或者其他 GC 中需要暂停用户线程的阶段) 1 、如果完成了,那线程就当作没事发生过,继续执行。 2 、否则它就必须一直等待, 直到收到可以离开安全区域的信号为止。 ### 低延迟的垃圾回收器 #### 垃圾回收器三项指标 传统的垃圾回收器一般情况下 内存占用、吞吐量、延时 只能同时满足两个。但是现在的发展,延迟这项的目标越来越重要。所以就有低延迟的垃圾回收器。 #### Eplison (了解即可) 这个垃圾回收器不能进行垃圾回收,是一个“不干活”的垃圾回收器,由 RedHat 退出,它还要负责堆的管理与布局、对象的分配、与解释器 的协作、与编译器的协作、与监控子系统协作等职责,主要用于需要剥离垃圾收集器影响的性能测试和压力测试。 #### ZGC (了解即可) 有类似于 G1 的 Region ,但是没有分代。 标志性的设计是染色指针 ColoredPointers (这个概念了解即可) ,染色指针有 4TB 的内存限制,但是效率极高,它是一种将少量额外的信息存储在指针上 的技术。 它可以做到几乎整个收集过程全程可并发,短暂的 STW 也只与 GC Roots 大小相关而与堆空间内存大小无关,因此考科一实现任何堆空间 STW 的时间小于 十毫秒的目标。 #### Shenandoah (了解即可) 第一款非 Oracle 公司开发的垃圾回收器,有类似于 G1 的 Region ,但是没有分代。 也用到了染色指针 ColoredPointers。 效率没有 ZGC 高,大概几十毫秒的目标。 ### GC 参数 #### GC 日志详解 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cae037154.jpg) #### GC 常用参数 -Xmn -Xms -Xmx –Xss 年轻代 最小堆 最大堆 栈空间 -XX:+UseTLAB 使用 TLAB ,默认打开 -XX:+PrintTLAB 打印 TLAB 的使用情况 -XX:TLABSize 设置 TLAB 大小 -XX:+DisableExplicitGC 启用用于禁用对的调用处理的选项 System.gc() -XX:+PrintGC 查看 GC 基本信息 -XX:+PrintGCDetails -XX:+PrintHeapAtGC -XX:+PrintGCTimeStamps 查看 GC 详细信息 每次一次 GC 后,都打印堆信息 启用在每个 GC 上打印时间戳的功能 -XX:+PrintGCApplicationConcurrentTime 打印应用程序时间(低) -XX:+PrintGCApplicationStoppedTime 打印暂停时长 (低) -XX:+PrintReferenceGC 记录回收了多少种不同引用类型的引用 (重要性低) -verbose:class 类加载详细过程 -XX:+PrintVMOptions 可在程序运行时,打印虚拟机接受到的命令行显示参数 -XX:+PrintFlagsFinal -XX:+PrintFlagsInitial 打印所有的 JVM 参数、查看所有 JVM 参数启动的初始值 (必须会用) -XX:MaxTenuringThreshold 升代年龄,最大值 15, 并行 (吞吐量) 收集器的默认值为 15 ,而 CMS 收集器的默认值为 6。 #### Parallel 常用参数 -XX:SurvivorRatio 设置伊甸园空间大小与幸存者空间大小之间的比率。默认情况下,此选项设置为 8 -XX:PreTenureSizeThreshold 大对象到底多大,大于这个值的参数直接在老年代分配 -XX:MaxTenuringThreshold 升代年龄,最大值 15, 并行 (吞吐量) 收集器的默认值为 15 ,而 CMS 收集器的默认值为 6。 -XX:+ParallelGCThreads 并行收集器的线程数,同样适用于 CMS ,一般设为和 CPU 核数相同 -XX:+UseAdaptiveSizePolicy 自动选择各区大小比例 #### CMS 常用参数 -XX:+UseConcMarkSweepGC 启用 CMS 垃圾回收器 -XX:+ParallelGCThreads 并行收集器的线程数,同样适用于 CMS ,一般设为和 CPU 核数相同 -XX:CMSInitiatingOccupancyFraction 使用多少比例的老年代后开始 CMS 收集,默认是 68%(近似值) ,如果频繁发生 SerialOld 卡顿,应该调小, (频繁 CMS 回 收) -XX:+UseCMSCompactAtFullCollection 在 FGC 时进行压缩 -XX:CMSFullGCsBeforeCompaction 多少次 FGC 之后进行压缩 -XX:+CMSClassUnloadingEnabled 使用并发标记扫描 (CMS) 垃圾收集器时,启用类卸载。默认情况下启用此选项。 -XX:CMSInitiatingPermOccupancyFraction 达到什么比例时进行 Perm 回收,JDK 8 中不推荐使用此选项,不能替代。 -XX:GCTimeRatio 设置 GC 时间占用程序运行时间的百分比 (不推荐使用) -XX:MaxGCPauseMillis 停顿时间,是一个建议时间,GC 会尝试用各种手段达到这个时间,比如减小年轻代 #### G1 常用参 -XX:+UseG1GC 启用 CMS 垃圾收集器 -XX:MaxGCPauseMillis 设置最大 GC 暂停时间的目标 (以毫秒为单位) 。这是一个软目标,并且 JVM 将尽最大的努力 (G1 会尝试调整 Young 区的块数来) 来实现它。默认情况下,没有最大暂停时间值。 |-XX:GCPauseIntervalMillis|GC 的间隔时间| | -------------------------| ---------------------------------------------------------------------------------------------------------------------| |-XX:+G1HeapRegionSize|分区大小,建议逐渐增大该值,1 2 4 8 16 32 。随着 size 增加,垃圾的存活时间更长,GC 间隔更长,但每次 GC 的时间也会更长| |-XX:G1NewSizePercent|新生代最小比例,默认为 5%| |-XX:G1MaxNewSizePercent|新生代最大比例,默认为 60%| |-XX:GCTimeRatioGC|时间建议比例,G1 会根据这个值调整堆空间| |-XX:ConcGCThreads|线程数量| -XX:InitiatingHeapOccupancyPercent 启动 G1 的堆空间占用比例,根据整个堆的占用而触发并发 GC 周 ## Class 文件结构及深入字节码指令 ### JVM 的无关性 与平台无关性是建立在操作系统上,虚拟机厂商提供了许多可以运行在各种不同平台的虚拟机,它们都可以载入和执行字节码,从而实现程序的“一次 编写,到处运行” https://www.oracle.com/technetwork/java/javase/downloads/jdk8-downloads-2133151.html 各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码 (ByteCode) 是构成平台无关性的基石,也是语言无关性的基础。Java 虚拟机不 和包括 Java 在内的任何语言绑定,它只与“Class 文件”这种特定的二进制文件格式所关联,Class 文件中包含了 Java 虚拟机指令集和符号表以及若干其他 辅助信息。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cae4de761.jpg) ### Class 类文件 (了解即可) Java 技术能够一直保持非常好的向后兼容性,这点 Class 文件结构的稳定性功不可没。Java 已经发展到 14 版本,但是 class 文件结构的内容,绝大部分在 JDK1.2 时代就已经定义好了。虽然 JDK1.2 的内容比较古老,但是 java 发展经历了十余个大版本,但是每次基本上知识在原有结构基础上新增内容、扩充 功能,并未对定义的内容做修改。 任何一个 Class 文件都对应着唯一一个类或接口的定义信息,但反过来说,Class 文件实际上它并不一定以磁盘文件的形式存在 (比如可以动态生成、或者 直接送入类加载器中) 。 Class 文件是一组以 8 位字节为基础单位的二进制流。 Class 文件结构这些内容在面试的时候很少有人问,因此大家学这个东西要当成一个兴趣去学,这个是自身内力提升的过程 #### 工具介绍 ##### Sublime 查看 16 进制的编辑器 ##### javap javap 是 JDK 自带的反解析工具。它的作用是将 .class 字节码文件解析成可读的文件格式。 在使用 javap 时我一般会添加 -v 参数,尽量多打印一些信息。同时,我也会使用 -p 参数,打印一些私有的字段和方法。 ##### jclasslib 如果你不太习惯使用命令行的操作,还可以使用 jclasslib ,jclasslib 是一个图形化的工具,能够更加直观的查看字节码中的内容。它还分门别类的对类中 的各个部分进行了整理,非常的人性化。同时,它还提供了 Idea 的插件,你可以从 plugins 中搜索到它。 jclasslib 的下载地址:https://github.com/ingokegel/jclasslib #### Class 文件格式 从一个 Class 文件开始,下图是一个 java 文件 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cae8ba3e6.png) 我们使用 Sublime 这个工具打开 class ![img](https://files.vps4cloud.com/i/2023/04/22/6443caeb04e7b.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443caed0e4a2.png) 整个 class 文件的格式就是一个二进制的字节流。 那么这个二进制的字节流就看谁来解释了,我做了一个 Xmind 文件。 各个数据项目严格按照顺序紧凑地排列在 Class 文件之中,中间没有添加任何分隔符,这使得整个 Class 文件中存储的内容几乎全部是程序运行的必要数 据,没有空隙存在。 Class 文件格式采用一种类似于 C 语言结构体的伪结构来存储数据,这种伪结构中只有两种数据类型:无符号数和表。 无符号数属于基本的数据类型,以 u 1 、u2 、u4 、u8 来分别代表 1 个字节 ( 一个字节是由两位 16 进制数组成) 、2 个字节、4 个字节和 8 个字节的无符号数,无符号数可以用来描述数字、索引引用、数量值或者按照 UTF-8 编码构成字符串值。 表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都习惯性地以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个 Class 文件本质上就是一张表。 #### Class 文件格式详解 Class 的结构不像 XML 等描述语言,由于它没有任何分隔符号,所以在其中的数据项,无论是顺序还是数量,都是被严格限定的,哪个字节代表什么含义, 长度是多少,先后顺序如何,都不允许改变。 按顺序包括: ##### 魔数与 Class 文件的版本 每个 Class 文件的头 4 个字节称为魔数 (Magic Number) ,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的 Class 文件。使用魔数而不是扩展 名来进行识别主要是基于安全方面的考虑,因为文件扩展名可以随意地改动。文件格式的制定者可以自由地选择魔数值,只要这个魔数值还没有被广泛 采用过同时又不会引起混淆即可。 ( ![img](https://files.vps4cloud.com/i/2023/04/22/6443caf24128b.png)) 紧接着魔数的 4 个字节存储的是 Class 文件的版本号:第 5 和第 6 个字节是次版本号 (MinorVersion) ,第 7 和第 8 个字节是主版本号 (Major Version) 。 Java 的版本号是从 45 开始的,JDK 1.1 之后的每个 JDK 大版本发布主版本号向上加 1 高版本的 JDK 能向下兼容以前版本的 Class 文件,但不能运行以后版 本的 Class 文件,即使文件格式并未发生任何变化,虚拟机也必须拒绝执行超过其版本号的 Class 文件。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443caf4c97b6.png)代表 JDK1.8 (16 进制的 34 ,换成 10 进制就是 52) ##### 常量池 常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项 u2 类型的数据,代表常量池容量计数值 (constant_pool_count) 。 与 Java 中语言习惯不一样的是,这个容量计数是从 1 而不是 0 开始的 ![img](https://files.vps4cloud.com/i/2023/04/22/6443caf0101bd.png) 常量池中主要存放两大类常量:字面量 (Literal) 和符号引用 (Symbolic References) 。 字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。 而符号引用则属于编译原理方面的概念,包括了下面三类常量: 类和接口的全限定名 (Fully Qualified Name) 、字段的名称和描述符 (Descriptor) 、方法的名称和描述符 ![img](https://files.vps4cloud.com/i/2023/04/22/6443caf7a1a5e.png) 我们就可以使用更加直观的工具 jclasslib ,来查看字节码中的具体内容 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cafa94dc3.png) ##### 访问标志 用于识别一些类或者接口层次的访问信息,包括:这个 Class 是类还是接口;是否定义为 public 类型;是否定义为 abstract 类型;如果是类的话,是否被 声明为 final 等 ##### 类索引、父类索引与接口索引集合 这三项数据来确定这个类的继承关系。类索引用于确定这个类的全限定名,父类索引用于确定这个类的父类的全限定名。由于 Java 语言不允许多重继承, 所以父类索引只有一个,除了 java.lang.Object 之外,所有的 Java 类都有父类,因此除了 java.lang.Object 外,所有 Java 类的父类索引都不为 0 。接口索引 集合就用来描述这个类实现了哪些接口,这些被实现的接口将按 implements 语句 (如果这个类本身是一个接口,则应当是 extends 语句) 后的接口顺序 从左到右排列在接口索引集合中 ##### 字段表集合 描述接口或者类中声明的变量。字段 (field) 包括类级变量以及实例级变量。 而字段叫什么名字、字段被定义为什么数据类型,这些都是无法固定的,只能引用常量池中的常量来描述。 字段表集合中不会列出从超类或者父接口中继承而来的字段,但有可能列出原本 Java 代码之中不存在的字段,譬如在内部类中为了保持对外部类的访问 性,会自动添加指向外部类实例的字段。 ##### 方法表集合 描述了方法的定义,但是方法里的 Java 代码,经过编译器编译成字节码指令后,存放在属性表集合中的方法属性表集合中一个名为“Code”的属性里面。 与字段表集合相类似的,如果父类方法在子类中没有被重写 (Override) ,方法表集合中就不会出现来自父类的方法信息。但同样的,有可能会出现由编译器自动添加的方法,最典型的便是类构造器“<clinit>”方法和实例构造器“<init>” ##### 属性表集合 存储 Class 文件、字段表、方法表都自己的属性表集合,以用于描述某些场景专有的信息。如方法的代码就存储在 Code 属性表中。 ### 字节码指令 字节码指令属于方法表中的内容: ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb008359f.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cafd8b530.png) Java 虚拟机的指令由一个字节长度的、代表着某种特定操作含义的数字 (称为操作码,Opcode) 以及跟随其后的零至多个代表此操作所需参数 (称为操 作数,Operands) 而构成。 由于限制了 Java 虚拟机操作码的长度为一个字节 (即 0~255) ,这意味着指令集的操作码总数不可能超过 256 条。 大多数的指令都包含了其操作所对应的数据类型信息。例如: iload 指令用于从局部变量表中加载 int 型的数据到操作数栈中,而 fload 指令加载的则是 float 类型的数据。大部分的指令都没有支持整数类型 byte 、char 和 short ,甚至没有任何指令支持 boolean 类型。大多数对于 boolean 、byte 、short 和 char 类型数据的操作,实际上都是使用相应的 int 类型作为运算类型 阅读字节码作为了解 Java 虚拟机的基础技能,有需要的话可以去掌握常见指令。 字节码助记码解释地址:https://cloud.tencent.com/developer/article/1333540 #### 加载和存储指令 用于将数据在栈帧中的局部变量表和操作数栈之间来回传输,这类指令包括如下内容。 将一个局部变量加载到操作栈:iload 、iload_<n>、lload 、lload_<n>、fload 、fload_<n>、dload 、dload_<n>、aload 、aload_<n>。 将一个数值从操作数栈存储到局部变量表:istore 、istore_<n>、lstore 、lstore_<n>、fstore 、fstore_<n>、dstore 、dstore_<n>、astore 、astore_ <n >。 将一个常量加载到操作数栈:bipush 、sipush 、ldc 、ldc_w 、ldc2_w 、aconst_null 、iconst_m 1 、iconst_<i>、lconst_<l>、fconst_<f>、dconst_<d>。 扩充局部变量表的访问索引的指令:wide。 #### 运算或算术指令 用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。 加法指令:iadd 、ladd 、fadd 、dadd。 减法指令:isub 、lsub 、fsub 、dsub。 乘法指令:imul 、lmul 、fmul 、dmul 等等 #### 类型转换指令 可以将两种不同的数值类型进行相互转换, Java 虚拟机直接支持以下数值类型的宽化类型转换 (即小范围类型向大范围类型的安全转换) int 类型到 long 、float 或者 double 类型。 long 类型到 float 、double 类型。 float 类型到 double 类型。 处理窄化类型转换 (Narrowing Numeric Conversions) 时,必须显式地使用转换指令来完成,这些转换指令包括:i2b 、i2c 、i2s 、l2i 、f2i 、f2l 、d2i 、d2l 和 d2f。 #### 创建类实例的指令 new。 #### 创建数组的指令 newarray 、anewarray 、multianewarray。 #### 访问字段指令 getfield 、putfield 、getstatic 、putstatic。 #### 数组存取相关指令 把一个数组元素加载到操作数栈的指令:baload 、caload 、saload 、iaload 、laload 、faload 、daload 、aaload。 将一个操作数栈的值存储到数组元素中的指令:bastore 、castore 、sastore 、iastore 、fastore 、dastore 、aastore。 取数组长度的指令:arraylength。 #### 检查类实例类型的指令 instanceof 、checkcast。 #### 操作数栈管理指令 如同操作一个普通数据结构中的堆栈那样,Java 虚拟机提供了一些用于直接操作操作数栈的指令,包括:将操作数栈的栈顶一个或两个元素出栈:pop、 pop2。 复制栈顶一个或两个数值并将复制值或双份的复制值重新压入栈顶:dup 、dup2 、dup_x 1 、dup2_x 1 、dup_x2 、dup2_x2。 将栈最顶端的两个数值互换:swap。 #### 控制转移指令 控制转移指令可以让 Java 虚拟机有条件或无条件地从指定的位置指令而不是控制转移指令的下一条指令继续执行程序,从概念模型上理解,可以认为控 制转移指令就是在有条件或无条件地修改 PC 寄存器的值。控制转移指令如下。 条件分支:ifeq 、iflt 、ifle 、ifne 、ifgt 、ifge 、ifnull 、ifnonnull 、if_icmpeq 、if_icmpne 、if_icmplt 、if_icmpgt 、if_icmple 、if_icmpge 、if_acmpeq 和 if_acmpne。 复合条件分支:tableswitch 、lookupswitch。 无条件分支:goto 、goto_w 、jsr 、jsr_w 、ret。 #### 方法调用指令 invokevirtual 指令用于调用对象的实例方法,根据对象的实际类型进行分派 (虚方法分派) ,这也是 Java 语言中最常见的方法分派方式。 invokeinterface 指令用于调用接口方法,它会在运行时搜索一个实现了这个接口方法的对象,找出适合的方法进行调用 invokespecial 指令用于调用一些需要特殊处理的实例方法,包括实例初始化方法、私有方法和父类方法。 invokestatic 指令用于调用类方法 (static 方法) 。 invokedynamic 指令用于在运行时动态解析出调用点限定符所引用的方法,并执行该方法,前面 4 条调用指令的分派逻辑都固化在 Java 虚拟机内部,而 invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。 方法调用指令与数据类型无关。 #### 方法返回指令 是根据返回值的类型区分的,包括 ireturn (当返回值是 boolean 、byte 、char 、short 和 int 类型时使用) 、lreturn 、freturn 、dreturn 和 areturn ,另外还有 一条 return 指令供声明为 void 的方法、实例初始化方法以及类和接口的类初始化方法使用。 #### 异常处理指令 在 Java 程序中显式抛出异常的操作 (throw 语句) 都由 athrow 指令来实现 #### 同步指令 有 monitorenter 和 monitorexit 两条指令来支持 synchronized 关键字的语义 ### 字节码指令——异常处理 每个时刻正在执行的当前方法就是虚拟机栈顶的栈桢。方法的执行就对应着栈帧在虚拟机栈中入栈和出栈的过程。 当一个方法执行完,要返回,那么有两种情况,一种是正常,另外一种是异常。 完成出口 (返回地址): 正常返回: (调用程序计数器中的地址作为返回) 三步曲: 恢复上层方法的局部变量表和操作数栈、 把返回值 (如果有的话) 压入调用者栈帧的操作数栈中、 调整程序计数器的值以指向方法调用指令后面的一条指令、 异常的话: (通过异常处理表 < 非栈帧中的 > 来确定) #### 异常机制 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb0580578.jpg) 如果你熟悉 Java 语言,那么对上面的异常继承体系一定不会陌生,其中,Error 和 RuntimeException 是非检查型异常 (Unchecked Exception) ,也就是 不需要 catch 语句去捕获的异常;而其他异常,则需要程序员手动去处理。 #### 异常表 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb0807ce6.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb0aceea6.png) 在 synchronized 生成的字节码中,其实包含两条 monitorexit 指令,是为了保证所有的异常条件,都能够退出。 可以看到,编译后的字节码,带有一个叫 Exceptiontable 的异常表,里面的每一行数据,都是一个异常处理器: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) from 指定字节码索引的开始位置 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) to 指定字节码索引的结束位置 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) target 异常处理的起始位置 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) type 异常类型 也就是说,只要在 from 和 to 之间发生了异常,就会跳转到 target 所指定的位置。 我可以看到,第一条 monitorexit (16) 在异常表第一条的范围中,如果异常,能够跳转到第 20 行 第二条 monitorexit (22) 在异常表第二条的范围中,如果异常,能够跳转到第 20 行 #### Finally 通常我们在做一些文件读取的时候,都会在 finally 代码块中关闭流,以避免内存的溢出。关于这个场景,我们再分析一下下面这段代码的异常表。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb0d9d76d.png) 上面的代码,捕获了一个 FileNotFoundException 异常,然后在 finally 中捕获了 IOException 异常。当我们分析字节码的时候,却发现了一个有意思的地 方:IOException 足足出现了三次。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb1233da4.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb1060d7c.png) Java 编译器使用了一种比较傻的方式来组织 finally 的字节码,它分别在 try 、catch 的正常执行路径上,复制一份 finally 代码,追加在正常执行逻辑的 后面;同时,再复制一份到其他异常执行逻辑的出口处。 再看一个例子 这段代码不报错的原因,都可以在字节码中找到答 ![img](https://files.vps4cloud.com/i/2023/04/26/644919129b939.png) 程序的字节码,可以看到,异常之后,直接跳转到序号 9 了。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb15c31c5.png) ### 字节码指令——装箱拆箱 #### 装箱拆箱 Java 中有 8 种基本类型,但鉴于 Java 面向对象的特点,它们同样有着对应的 8 个包装类型,比如 int 和 Integer ,包装类型的值可以为 null (基本类 型没有 null 值,而数据库的表中普遍存在 null 值。 所以实体类中所有属性均应采用封装类型) ,很多时候,它们都能够相互赋值。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb1977bb8.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb1cc8fc1.png) 通过观察字节码,我们发现: 1 、在进行乘法运算的时候,调用了 Integer.intValue 方法来获取基本类型的值。 2 、赋值操作使用的是 Integer.valueOf 方法。 3 、在方法返回的时候,再次使用了 Integer.valueOf 方法对结果进行了包装。 这就是 Java 中的自动装箱拆箱的底层实现 #### IntegerCache 但这里有一个陷阱问题,我们继续跟踪 Integer.valueOf 方法 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb2000639.png) 这个 IntegerCache ,缓存了 low 和 high 之间的 Integer 对象 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb233c679.png) 一般情况下,缓存是的- 128 到 127 之间的值,但是可以通过 -XX:AutoBoxCacheMax 来修改上限。 下面是一道经典的面试题,请考虑一下运行代码后,会输出什么结果 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb2f3b346.png) 一般情况下是是 true,false 因为缓存的原因。 (在缓存范围内的值,返回的是同一个缓存值,不在的话,每次都是 new 出来的) 当我加上 VM 参数 -XX:AutoBoxCacheMax=256 执行时,结果是 true,ture ,扩大缓存范围,第二个为 true 原因就在于此。 ### 字节码指令——数组 其实,数组是 JVM 内置的一种对象类型,这个对象同样是继承的 Object 类。我们使用代码来理解一下 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb2c277fb.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb350506e.png) #### 数组创建 可以看到,新建数组的代码,被编译成了 newarray 指令 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb39ec5b4.png) 数组里的初始内容,被顺序编译成了一系列指令放入: 令 sipush 将一个短整型常量值推送至栈顶; 令 iastore 将栈顶 int 型数值存入指定数组的指定索引位置。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491923b9ff0.png) 具体操作: 1 、 iconst_0,常量 0 ,入操作数栈 2 、 sipush 将一个常量 1111 加载到操作数栈 3 、 将栈顶 int 型数值存入数组的 0 索引位置 为了支持多种类型,从操作数栈存储到数组,有更多的指令:bastore 、castore 、sastore 、iastore 、lastore 、fastore 、dastore 、aastore。 #### 数组访问 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb3d5d7fa.png) 数组元素的访问,是通过第 28 ~ 30 行代码来实现的: 令 aload_1 将第二个引用类型本地变量推送至栈顶,这里是生成的数组; 令 iconst_2 将 int 型 2 推送至栈顶; 令 iaload 将 int 型数组指定索引的值推送至栈顶。 获取数组的长度,是由字节码指令 arraylength 来完成的 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb406b426.png) 获取数组长度的指令 arraylength ### 字节码指令——foreach 无论是 Java 的数组,还是 List ,都可以使用 foreach 语句进行遍历,虽然在语言层面它们的表现形式是一致的,但实际实现的方法并不同。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491929c208a.png) 数组:它将代码解释成了传统的变量方式,即 for(int i;i<length;i++) 的形式。 List 的它实际是把 list 对象进行迭代并遍历的,在循环中,使用了 Iterator.next() 方法。 使用 jd-gui 等反编译工具,可以看到实际生成的代码 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb441868b.png) ### 字节码指令——注解 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb48190ec.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb4b26a0f.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb4e25bd8.png) 无论是类的注解,还是方法注解,都是由一个叫做 RuntimeInvisibleAnnotations 的结构来存储的,而参数的存储,是由 RuntimeInvisibleParameterAnotations 来保证的 ### 字节码指令总 Java 的特性非常多,这里不再一一列出,但都可以使用这种简单的方式,从字节码层面分析了它的原理,一窥究竟。 比如异常的处理、finally 块的执行顺序; 以及隐藏的装箱拆箱和 foreach 语法糖的底层实现。 还有字节码指令,可能有几千行,看起来很吓人,但执行速度几乎都是纳秒级别的。Java 的无数框架,包括 JDK ,也不会为了优化这种性能对代码进行 限制。了解其原理,但不要舍本逐末,比如减少一次 Java 线程的上下文切换,就比你优化几千个装箱拆箱动作,速度来的更快一些。 ## 玩转类加载与类加载器 ### 一个类的生命周期 (重点) #### 类生命周期 7 个阶段 类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载(Loading) 、验证(Verification) 、准备(Preparation) 、解析(Resolution)、 初始化 (Initialization) 、使用 (Using) 和卸载 (Unloading) 7 个阶段。其中验证、准备、解析 3 个部分统称为连接 (Linking ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb5299752.jpg) #### 阶段顺序 加载、校验、准备、初始化和卸载这五个阶段的顺序是确定的,但是对于“解析”阶段则不一定,它在某些情况下可以在初始化之后再开始,这样做是 为了支持 java 的运行时绑定特征 (也称为动态绑定或晚期绑定) 。 #### 加载的时机 什么是需要开始类第一个阶段“加载”,虚拟机规范没有强制约束,这点交给虚拟机的具体实现来自由把控。 JVM 虚拟机的实现都是使用的懒加载,就是什么时候需要这个类了我才去加载,并不是说一个 jar 文件里面有 200 多个类,但实际我只用到了其中的一个 类,我不需要把 200 多个类全部加载进来。 (如果你自己写一个 JVM 倒是可以这么干! ) “加载 loading”阶段是整个类加载 (class loading) 过程的一个阶段。 加载阶段虚拟机需要完成以下 3 件事情: 1. 通过一个类的全限定名来获取定义此类的二进制字节流 2. 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。 3. 在内存中生成一个代表这个类的 java.lang.Class 对象,作为方法区这个类的各种数据的访问入口。 注意: 比如“通过一个类的全限定名来获取定义此类的二进制字节流”没有指定一定得从某个 class 文件中获取,所以我们可以从 zip 压缩包、从网络中 获取、运行时计算生成、数据库中读取、或者从加密文件中获取等等。 我们也可以通过前面的工具 JHSDB 可以看到,JVM 启动后,相关的类已经加载进入了方法区,成为了方法区的运行时结构。 JHSDB 怎么用?具体见 [JHSDB 工具](#_bookmark3) 1 、 Attarch 上 JVM 启动的进程 2 、 打开 Class Browser ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb559f6df.png) 3 、 可以看到很多 class 已经被加载进来了 4 、 找到 JVMObject ,注意!这里已经是内存了,所以说相关的类已经加载进入了方法区,成为了方法区的运行时结构。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb582e440.png) #### 验证 是连接阶段的第一步,这一阶段的目的是为了确保 Class 文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。但从整体 上看,验证阶段大致上会完成下面 4 个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。 ##### 文件格式验证 (非重点) 第一阶段要验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理。这一阶段可能包括下面这些验证点: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 是否以魔数 OxCAFEBABE 开头。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 主、次版本号是否在当前 Java 虚拟机接受范围之内。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 常量池的常量中是否有不被支持的常量类型(检查常量 tag 标志)。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 指向常量的各种索引值中是否有指向不存在的常量或不符合类型的常量。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) CONSTANT Utf8 info 型的常量中是否有不符合 UTF-8 编码的数据。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) Class 文件中各个部分及文件本身是否有被删除的或附加的其他信息。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) ...... 以上的部分还只是一小部分,没必要进行深入的研究。 总结一下: 这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证之后,这段字节流才被允许进人 Java 虚拟机内存的方法区中进行存储,所以后面 的三个验证阶段全部是基于方法区的存储结构 (内存) 上进行的,不会再直接读取、操作字节流了。 ##### 元数据验证 (非重点) 第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合《Java 语言规范》的要求,这个阶段可能包括的验证点如下: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 这个类是否有父类(除了 java.lang.Object 之外,所有的类都应当有父类)。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 这个类的父类是否继承了不允许被继承的类(被 final 修饰的类)。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的 final 字段,或者出现不符合规则的方法重载,例如方法参数都-致,但返回值类型却不同等)。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) ...... 以上的部分还只是一小部分,没必要进行深入的研究。 元数据验证是验证的第二阶段,主要目的是对类的元数据信息进行语义校验,保证不存在与《Java 语言规范》定义相悖的元数据信息。 ##### 字节码验证 (非重点) 字节码验证第三阶段是整个验证过程中最复杂的一一个阶段, 主要目的是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二 阶段对元数据信息中的数据类型校验完毕以后,这阶段就要对类的方法体(Class 文件中的 Code 属性)进行校验分析,保证被校验类的方法在运行时不会做 出危害虚拟机安全的行为,例如: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,例如不会出现类似于“在操作栈放置了一个 int 类型的数据,使用时却按 long 类型 来加载入本地变量表中”这样的情况。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 保证任何跳转指令都不会跳转到方法体以外的字节码指令上。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 保证方法体中的类型转换总是有效的,例如可以把-个子类对象赋值给父类数据类型,这是安全的,但是把父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) ...... 以上的部分还只是一小部分,没必要进行深入的研究。 如果一个类型中有方法体的字节码没有通过字节码验证,那它肯定是有问题的。 ##### 符号引用验证 (非重点) 最后一个阶段的校验行为发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段一解析阶段中发生。符号引用验证可以看 作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配性校验,通俗来说就是,该类是否缺少或者被禁止访问它依赖的某些外部类、方法、字段等资源。本阶段通常需要校验下列内容: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 符号引用中通过字符串描述的全限定名是否能找到对应的类。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 在指定类中是否存在符合方法的字段描述符及简单名称所描述的方法和字段。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 符号引用中的类、字段、方法的可访问性( private 、 protected. public 、 <package> ) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 是否可被当前类访问。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) ...... 符号引用验证的主要目的是确保解析行为能正常执行,如果无法通过符号引用验证,将会抛出异常。 ##### 验证 (总结) 验证阶段对于虚拟机的类加载机制来说,是一个非常重要的、 但却不是必须要执行的阶段,因为验证阶段只有通过或者不通过的差别,只要通过了验证, 其后就对程序运行期没有任何影响了。如果程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复 使用和验证过,在生产环境的实施阶段就可以考虑使用-Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。 https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.htm ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb5c0253a.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644923769bb6f.png) #### 准备 准备阶段是正式为类中定义的变量 (被 static 修饰的变量) 分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。 这个阶段中有两个容易产生混淆的概念需要强调一下: 首先,这时候进行内存分配的仅包括类变量 (被 static 修饰的变量) ,而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。 其次,这里所说的初始值“通常情况”下是数据类型的零值,假设一个类变量的定义为: public static int value=123; 那变量 value 在准备阶段过后的初始值为 0 而不是 123 ,因为这时候尚未开始执行任何 Java 方法,而把 value 赋值为 123 是后续的初始化环节。 基本数据类型的零值表 |数据类型|零值|数据类型|零值| | --------| ----------| ---------| -----| |int|0|boolean|false| |long|0L|float|0.0f| |short|(short) 0|double|0.0d| |char|‘\u0000’|reference|null| |byte|(byte)0||| #### 解析 解析阶段是 JVM 将常量池内的符号引用替换为直接引用的过程。 符号引用是一种定义,可以是任何字面上的含义,而直接引用就是直接指向目标的指针、相对偏移量。 直接引用的对象都存在于内存中,你可以把通讯录里的女友手机号码,类比为符号引用,把面对面和你吃饭的女朋友,类比为直接引用。 解析大体可以分为: (不重要) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 类或接口的解析 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 字段解析 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 类方法解析 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 接口方法解析 我们了解几个经常发生的异常,就与这个阶段有关。 java.lang.NoSuchFieldError 根据继承关系从下往上,找不到相关字段时的报错。 (字段解析异常) java.lang.IllegalAccessError 字段或者方法,访问权限不具备时的错误。 (类或接口的解析异常) java.lang.NoSuchMethodError 找不到相关方法时的错误。 (类方法解析、接口方法解析时发生的异常 #### 初始化 (重点) 初始化主要是对一个 class 中的 static{}语句进行操作 (对应字节码就是 clinit 方法) 。 <clinit> () 方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成 <clinit > () 方法。 初始化阶段,虚拟机规范则是严格规定了有且只有 6 种情况必须立即对类进行“初始化” (而加载、验证、准备自然需要在此之前开始) : 1. 遇到 new 、getstatic 、putstatic 或 invokestatic 这 4 条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化。生成这 4 条指令的最常见的 Java 代码场景是: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b722684.jpg) 使用 new 关键字实例化对象的时候。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b861c71.jpg) 读取或设置一个类的静态字段 (被 final 修饰、已在编译期把结果放入常量池的静态字段除外) 的时候 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9b861c71.jpg) 调用一个类的静态方法的时候。 2. 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化。 3. 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化。 4. 当虚拟机启动时,用户需要指定一个要执行的主类 (包含 main () 方法的那个类) ,虚拟机会先初始化这个主类。 5. 当使用 JDK 1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getStatic 、REF_putStatic 、REF_invokeStatic 的方法 句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。 6. 当一个接口中定义了 JDK1.8 新加入的默认方法 (被 default 关键字修饰的接口方法) 时,如果这个接口的实现类发生了初始化,那该接口要在其之前 被初始化。 ##### 案例分析 **案例代码** ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb60bf705.png) ![img](https://files.vps4cloud.com/i/2023/04/26/6449193e5aac3.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb6507bd2.png) ###### 案例 1 **——代码** ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb6a5a31a.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb6781c66.png) **——演示结果** ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb6d93123.png) **——结果解析** 如果通过子类引用父类中的静态字段,只会触发父类的初始化,而不会触发子类的初始化(但是子类会被加载) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb7018cf1.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644919489b570.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb7432148.png) ###### 案例 2 **——代码** ![img](https://files.vps4cloud.com/i/2023/04/26/6449194f25e7c.jpg) **——演示结果** ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb7a0679d.png) **——结果解析** 使用数组的方式, 不会触发初始化(触发父类加载,不会触发子类加载) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb77ba0e4.png) ###### 案例 3 **——代码** ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb7eac8da.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb823dfcf.png) **——演示结果** ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb853e031.png) **——结果解析** 使用数组的方式, 不会触发初始化(触发父类加载,不会触发子类加载) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb87c6ca0.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb8bcbd00.png) 为什么不会触发类加载,因为在编译的时候,常量数据已经进入自己类的常量池 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb8f27286.png) ###### 案例 4 **——代码** ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb9409234.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb96e806f.png) **——演示结果** ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb99a613a.png) **——结果解析** 如果使用常量去引用另外一个常量(这个值编译时无法确定,所以必须要触发初始化 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cb9cbec09.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cba04ba76.png) ###### 线程安全 虚拟机会保证一个类的 <clinit> () 方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类 的 <clinit> () 方法,其他线程都需要阻塞等待,直到活动线程执行 <clinit> () 方法完毕。如果在一个类的 <clinit> () 方法中有耗时很长的操作,就 可能造成多个进程阻塞。所以类的初始化是线程安全的,项目中可以利用这点。 ### 类加载器 整个类加载过程任务非常繁重,虽然这活儿很累,但总得有人干。类加载器做的就是上面 5 个步骤的事 (加载、验证、准备、解析、初始化) 。 #### JDK 提供的三层类加载器 (重点) ##### Bootstrap ClassLoader 这是加载器中的扛把子,任何类的加载行为,都要经它过问。它的作用是加载核心类库,也就是 rt.jar 、resources.jar 、charsets.jar 等。当然这些 jar 包 的路径是可以指定的,-Xbootclasspath 参数可以完成指定操作。 这个加载器是 C++ 编写的,随着 JVM 启动。 ##### Extention ClassLoader 扩展类加载器,主要用于加载 lib/ext 目录下的 jar 包和 .class 文件。同样的,通过系统变量 java.ext.dirs 可以指定这个目录。 这个加载器是个 Java 类,继承自 URLClassLoader ##### Application ClassLoader 这是我们写的 Java 类的默认加载器,有时候也叫作 System ClassLoader 。一般用来加载 classpath 下的其他所有 jar 包和 .class 文件,我们写的代码, 会首先尝试使用这个类加载器进行加载。 ##### Custom ClassLoader 自定义加载器,支持一些个性化的扩展功能。 #### 类加载器的问题 如果你在项目代码里,写一个 java.lang 的包,然后改写 String 类的一些行为,编译后,发现并不能生效。JRE 的类当然不能轻易被覆盖,否则会被别有 用心的人利用,这就太危险了。 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这 句话可以表达得更通俗一些:比较两个类是否“相等” ,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一 个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等。 这里所指的“相等” ,包括代表类的 Class 对象的 equals () 方法、isAssignableF rom () 方法、isInstance () 方法的返回结果,也包括使用 instanceof 关 键字做对象所属关系判定等情况。 #### 双亲委派机制 (重点) 双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承 (Inheritance)的关系来实现,而是都使用组合 (Composition) 关系来复用父加载器的代码。 使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是 Java 类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类 java.lang.Object ,它存放在 rt.jar 之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此 Object 类在 程序的各种类加载器环境中都是同一个类 。相反,如果没有使用双亲委派模型, 由各个类加载器自行去加载的话,如果用户自 己编写了一个称为 java.lang.Object 的类,并放在程序的 ClassPath 中,那系统中将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证,应用程序也将 会变得一片混乱。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cba4d49c0.jpg) 我们可以翻阅 JDK 代码的 ClassLoader#loadClass 方法,来看一下具体的加载过程。和我们描述的一样,它首先使用 parent 尝试进行类加载,parent 失 败后才轮到自己。同时,我们也注意到,这个方法是可以被覆盖的,也就是双亲委派机制并不一定生效 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cba77e0dd.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbaba3602.png) ### 自定义类加载器 #### Tomcat 类加载机制 tomcat 通过 war 包进行应用的发布,它其实是违反了双亲委派机制原则的。简单看一下 tomcat 类加载器的层次结构 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbae5f747.jpg) 对于一些需要加载的非基础类,会由一个叫作 WebAppClassLoader 的类加载器优先加载。等它加载不到的时候,再交给上层的 ClassLoader 进行加载。 这个加载器用来隔绝不同应用的 .class 文件,比如你的两个应用,可能会依赖同一个第三方的不同版本,它们是相互没有影响的 如何在同一个 JVM 里,运行着不兼容的两个版本,当然是需要自定义加载器才能完成的事。 那么 tomcat 是怎么打破双亲委派机制的呢?可以看图中的 WebAppClassLoader ,它加载自己目录下的 .class 文件,并不会传递给父类的加载器。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbb1187de.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbb4b2aa6.png) ![img](https://files.vps4cloud.com/i/2023/04/26/6449196e0a366.png) 但是,它却可以使用 SharedClassLoader 所加载的类,实现了共享和分离的功能。 但是你自己写一个 ArrayList ,放在应用目录里,tomcat 依然不会加载。它只是自定义的加载器顺序不同,但对于顶层来说,还是一样的。 #### SPI Java 中有一个 SPI 机制,全称是 Service Provider Interface,是 Java 提供的一套用来被第三方实现或者扩展的 API,它可以用来启用框架扩展和替换组件。 这个说法可能比较晦涩,但是拿我们常用的数据库驱动加载来说,就比较好理解了。在使用 JDBC 写程序之前,通常会调用下面这行代码,用于加载所 需要的驱动类。 Class.forName("com.mysql.jdbc.Driver") 这只是一种初始化模式,通过 static 代码块显式地声明了驱动对象,然后把这些信息,保存到底层的一个 List 中。这种方式我们不做过多的介绍,因为 这明显就是一个接口编程的思路 (这里不进行细讲) 。 但是你会发现,即使删除了 Class.forName 这一行代码,也能加载到正确的驱动类,什么都不需要做,非常的神奇,它是怎么做到的呢? MySQL 的驱动代码,就是在这里实现的。 路径:mysql-connector-java-8.0.11.jar!/META- INF/services/java.sql.Driver 里面的内容是:com.mysql.cj.jdbc.Driver ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbb7ccfd3.png) 通过在 META- INF/services 目录下,创建一个以接口全限定名为命名的文件 (内容为实现类的全限定名) ,即可自动加载这一种实现,这就是 SPI。 SPI 实际上是“基于接口的编程 + 策略模式 + 配置文件”组合实现的动态加载机制,主要使用 java.util.ServiceLoader 类进行动态装载。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbba6c9ca.jpg) 这种方式,同样打破了双亲委派的机制。 DriverManager 类和 ServiceLoader 类都是属于 rt.jar 的。它们的类加载器是 Bootstrap ClassLoader ,也就是最上层的那个。 而具体的数据库驱动,却属于业务代码,这个启动类加载器是无法加载的。这就比较尴尬了,虽然凡事都要祖先过问,但祖先没有能力去做这件事情, 怎么办? 跟踪代码,来看一下。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbc2e2138.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644919766a4a2.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbc5ce1fb.png) 通过代码你可以发现它把当前的类加载器,设置成了线程的上下文类加载器。那么,对于一个刚刚启动的应用程序来说,它当前的加载器是谁呢?也就 是说,启动 main 方法的那个加载器,到底是哪一个? 所以我们继续跟踪代码。找到 Launcher 类,就是 jre 中用于启动入口函数 main 的类。我们在 Launcher 中找到以下代码。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbc8692a4.png) 到此为止,事情就比较明朗了,当前线程上下文的类加载器,是应用程序类加载器。使用它来加载第三方驱动。 总结一下 第一, 可以让你更好的看到一个打破规则的案例(虽然应该是属于 BootStrap 类加载器加载的,但是还是在 app 类加载器去加载的它)。 第二, 第二,这个问题面试时出现的几率也是比较高的,你需要好好理解。 #### OSGi (了解) OSGi 曾经非常流行,Eclipse 就使用 OSGi 作为插件系统的基础。OSGi 是服务平台的规范, 旨在用于需要长运行时间、动态更新和对运行环境破坏 最小的系统。 OSGi 规范定义了很多关于包生命周期,以及基础架构和绑定包的交互方式。这些规则,通过使用特殊 Java 类加载器来强制执行,比较霸道。 比如,在一般 Java 应用程序中,classpath 中的所有类都对所有其他类可见,这是毋庸置疑的。但是,OSGi 类加载器基于 OSGi 规范和每个绑定包的 manifest.mf 文件中指定的选项,来限制这些类的交互,这就让编程风格变得非常的怪异。但我们不难想象,这种与直觉相违背的加载方式,这些都是由 专用的类加载器来实现的。 随着 JPMS 的发展 (JDK9 引入的,旨在为 Java SE 平台设计、实现一个标准的模块系统) ,现在的 OSGi ,深入研究意义已经不是很 大了。OSGi 是一个庞大的话题 (技术上) ,你只需要了解到,有这么一个复杂的东西,实现了模块化,每个模块可以独立安装、启动、停止、卸载,就 可以了。 OSGI 一般的公司玩不转,都是阿里这些大公司在用。从大家研究技术的角度上来,就算你去这些公司,再去学习也没问题 (阿里不可能要求一个小 厂出来的程序员对 OSGI 精通) 。主要精力还是把放在类加载、双亲委派,以及如何打破这些问题解决即可。 ## 方法调用的底层实现 我们写的代码,经过编译、经过类加载的各种阶段,进入了 JVM 的运行时数据区。 但作为程序员真正关心是代码的执行,代码的执行其实本质上是方法的执行,站在 JVM 的角度归根到底还是字节码的执行。 main 函数是 JVM 指令执行的起点,JVM 会创建 main 线程来执行 main 函数,以触发 JVM 一系列指令的执行,真正地把 JVM 跑起来。 接着,在我们的代码中,就是方法调用方法的过程,所以了解方法在 JVM 中的调用是非常必要的。 ### 方法调用的字节码指令 关于方法的调用,Java 字节码 共提供了 5 个指令,来调用不同类型的方法: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) invokestatic 用来调用静态方法; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) invokespecial 用于调用私有实例方法、构造器及 super 关键字等; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) invokevirtual 用于调用非私有实例方法,比如 public 和 protected ,大多数方法调用属于这一种; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) invokeinterface 和上面这条指令类似,不过作用于接口类; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) invokedynamic 用于调用动态方法。 ### 非虚方法 如果方法在编译期就确定了具体的调用版本,这个版本在运行时是不可变的,这样的方法称为非虚方法。 只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 语言里符合这个条件的方法共有静态方法、私有 方法、实例构造器、父类方法 4 种,再加上被 final 修饰的方法(尽管它使用 invokevirtual 指令调用) ,这 5 种方法调用会在类加载的时候就可以把符号引用 解析为该方法的直接引用。不需要在运行时再去完成。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) invokestatic 用来调用静态方法; ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbcc26072.png) ![img](https://files.vps4cloud.com/i/2023/04/26/6449197f0ccbd.png) 这个方法调用在编译期间就明确以常量池项的形式固化在字节码指令的参数之中了。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbcf5e848.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) invokespecial 用于调用私有实例方法、构造器及 super 关键字等; ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbd254e57.png) ### 虚方法 与非虚方法相反,不是非虚方法的方法就是虚方法。主要包括以下字节码中的两类 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) invokevirtual 用于调用非私有实例方法,比如 public 和 protected ,大多数方法调用属于这一种 (排除掉被 final 修饰的方法) ; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) invokeinterface 和上面这条指令类似,不过作用于接口类; 为什么叫做虚方法呢?就是方法在运行时是可变的。 很多时候,JVM 需要根据调用者的动态类型,来确定调用的目标方法,这就是动态绑定的过程;相对比,invokestatic 指令加上 invokespecial 指令,就属于静态绑定过程。 因为 invokeinterface 指令跟 invokevirtual 类似,只是作用与接口,所以我们只要熟悉 invokevirtual 即可。 #### 分派 要了解虚方法我们必须了解以下基础: Java 是一门面向对象的程序语言,因为 Java 具备面向对象的 3 个基本特征:继承、封装和多态。 分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在 Java 虚拟机之中是如何实现的 ##### 静态分派 多见于方法的重载。 (重载:一个类中允许同时存在一个以上的同名方法,这些方法的参数个数或者类型不同) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbd51edac.png) “Human”称为变量的静态类型 (Static Type) ,或者叫做的外观类型 (Apparent Type) ,后面的“Man”则称为变量的实际类型 (Actual Type) 。 静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型 是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。 代码中定义了两个静态类型相同但实际类型不同的变量,但虚拟机 (准确地说是编译器) 在重载时是通过参数的静态类型而不是实际类型作为判定 依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello (Human) 作为调用目标。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。 静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。 所以代码运行结果如下 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbd7bed8d.png) ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbda479d8.png) 总结:例子很简单,方法会根据你送入的参数有不同的表现形式,这个就是分派。 举个简单的例子:你在酒吧遇到一个你心动的人,但这个人看上去不男不女,你怎么去与他/她打招呼?这个时候我至少知道是一个人, 所以 老师打招呼说:地球人!你好,我是来自火星的 老师 (hello guy!) 。 我调用它最原始的外观类型 (至少是个人) ##### 动态分派 多见于方法的重写。 (重写:在子类中将父类的成员方法的名称保留,重新编写成员方法的实现内容,更改方法的访问权限,修改返 回类型的为父类返回类型的子类。) 另外一个例子: ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbdf1aa17.png) 重写也是使用 invokevirtual 指令,只是这个时候具备多态性。 invokevirtual 指令有多态查找的机制,该指令运行时,解析过程如下: ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 找到操作数栈顶的第一个元素所指向的对象实际类型,记做 c; ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 如果在类型 c 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法直接引用,查 找过程结束,不通过则返回 java.lang.IllegalAccessError; ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 否则,按照继承关系从下往上依次对 c 的各个父类进行第二步的搜索和验证过程; ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 如果始终没找到合适的方法,则抛出 java.lang.AbstractMethodError 异常,这就是 Java 语言中方法重写的本质。 另外一点,这个时候我如果结合之前课程中讲过虚拟机栈中栈中的内容,我就知道动态链接是干嘛的: invokevirtual 可以知道方法 call()的符号引用转换是在运行时期完成的,在方法调用的时候。部分符号引用在运行期间转化为直接引 用,这种转化就是动态链接。[栈帧执行对内存区域的影响](#_bookmark1) ###### 方法表 动态分派会执行非常频繁的动作,JVM 运行时会频繁的、反复的去搜索元数据,所以 JVM 使用了一种优化手段,这个就是在方法区中建立一个虚方法表。 使用虚方法表索引来替代元数据查找以提高性能。 在实现上,最常用的手段就是为类在方法区中建立一个虚方法表。虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那 子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会 替换为指向子类实现版本的入口地址。PPT 图中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cbe2533be.jpg) ##### 接口调用 invokeinterface 和 invokevirtual 指令类似,不过作用于接口类; ![img](https://files.vps4cloud.com/i/2023/04/22/6443cc1c5ee6d.png) #### Lambda 表达式 invokedynamic 这个字节码是比较复杂。和反射类似,它用于一些动态的调用场景,但它和反射有着本质的不同,效率也比反射要高得多。 ##### invokedynamic 这个指令通常在 Lambda 语法中出现,我们来看一下一小段代码: ![img](https://files.vps4cloud.com/i/2023/04/22/6443cc201b74b.png) 使用 javap -p -v 命令可以在 main 方法中看到 invokedynamic 指令 ![img](https://files.vps4cloud.com/i/2023/04/22/6443cc233c8a9.png) 另外,我们在 javap 的输出中找到了一些奇怪的东西: ![img](https://files.vps4cloud.com/i/2023/04/22/6443cc31e5569.png) BootstrapMethods 属性在 Java 1.7 以后才有,位于类文件的属性列表中,这个属性用于保存 invokedynamic 指令引用的引导方法限定符。 和上面介绍的四个指令不同,invokedynamic 并没有确切的接受对象,取而代之的,是一个叫 CallSite 的对象。 ##### 方法句柄 (MethodHandle ) 官方文档解释:https://docs.oracle.com/javase/7/docs/api/java/lang/invoke/MethodHandles.html invokedynamic 指令的底层,是使用方法句柄 (MethodHandle) 来实现的。方法句柄是一个能够被执行的引用,它可以指向静态方法和实例方法,以及虚 构的 get 和 set 方法,从以下案例中可以看到 MethodHandle 提供的一些方法。 ![img](https://files.vps4cloud.com/i/2023/04/26/644919906c079.png) MethodHandle 是什么?简单的说就是方法句柄,通过这个句柄可以调用相应的方法。 用 MethodHandle 调用方法的流程为 (1) 创建 MethodType,获取指定方法的签名 (出参和入参) (2) 在 Lookup 中查找 MethodType 的方法句柄 MethodHandle (3) 传入方法参数通过 MethodHandle 调用方法 ###### MethodType MethodType 表示一个方法类型的对象,每个 MethodHandle 都有一个 MethodType 实例,MethodType 用来指明方法的返回类型和参数类型。其有多个工厂方法的重载。 ![img](https://files.vps4cloud.com/i/2023/04/26/6449199531851.png) ![img](https://files.vps4cloud.com/i/2023/04/26/6449199823663.png) STATIC METHODTYPE METHODTYPE(CLASS<?> RTYPE) STATIC METHODTYPE METHODTYPE(CLASS<?> RTYPE, CLASS<?> PTYPE0) STATIC METHODTYPE METHODTYPE(CLASS<?> RTYPE, CLASS<?>[] PTYPES) STATIC METHODTYPE METHODTYPE(CLASS<?> RTYPE, CLASS<?> PTYPE0, CLASS<?>... PTYPES) STATIC METHODTYPE METHODTYPE(CLASS<?> RTYPE, LIST<CLASS<?>> PTYPES) STATIC METHODTYPE METHODTYPE(CLASS<?> RTYPE, METHODTYPE PTYPES) ###### Lookup MethodHandle.Lookup 可以通过相应的 findxxx 方法得到相应的 MethodHandle ,相当于 MethodHandle 的工厂方法。查找对象上的工厂方法对应于方法、 构造函数和字段的所有主要用例。 findStatic 相当于得到的是一个 static 方法的句柄 (类似于 invokestatic 的作用) ,findVirtual 找的是普通方法 (类似于 invokevirtual 的作用 ![img](https://files.vps4cloud.com/i/2023/04/26/6449199c18e3f.png) ###### invoke 其中需要注意的是 invoke 和 invokeExact,前者在调用的时候可以进行返回值和参数的类型转换工作,而后者是精确匹配的。 ![img](https://files.vps4cloud.com/i/2023/04/26/644919a146d26.png) 所以一般在使用是,往往 invoke 使用比 invokeExact 要多,因为 invokeExact 如果类型不匹配,则会抛错。 ###### Lambda 表达式的捕获与非捕获 当 Lambda 表达式访问一个定义在 Lambda 表达式体外的非静态变量或者对象时,这个 Lambda 表达式称为“捕获的” ![img](https://files.vps4cloud.com/i/2023/04/26/644919a4d5ff9.png) 那么“非捕获”的 Lambda 表达式来就是 Lambda 表达式没有访问一个定义在 Lambda 表达式体外的非静态变量或者对象 ![img](https://files.vps4cloud.com/i/2023/04/26/644919a84e743.png) Lambda 表达式是否是捕获的和性能悄然相关。一个非捕获的 lambda 通常比捕获的更高效,[非捕获的 lambda 只需要计算一次](http://mail.openjdk.java.net/pipermail/lambda-dev/2012-November/006867.html). 然后每次使用到它都会返 回一个唯一的实例。而捕获的 lambda 表达式每次使用时都需要重新计算一次,而且从目前实现来看,它很像实例化一个匿名内部类的实例。 lambda 最差的情况性能内部类一样, 好的情况肯定比内部类性能高。 Oracle 公司的性能比较的文档,详细而全面的比较了 lambda 表达式和匿名函数之间的性能差别。 lambda 开发组也有一篇 PPT 其中也讲到了 lambda 的性能(包括 capture 和非 capture 的情况) 。 lambda 最差的情况性能内部类一样, 好的情况肯定比内部 类性能高。 https://www.oracle.com/technetwork/java/jvmls2013kuksen-2014088.pdf http://nerds-central.blogspot.tw/2013/03/java-8-lambdas-they-are-fast-very-fast.html ###### 总结 Lambda 语言实际上是通过方法句柄来完成的,大致这么实现(JVM 编译的时候使用 invokedynamic 实现 Lambda 表达式,invokedynamic 的是使用 MethodHandle 实现的,所以 JVM 会根据你编写的 Lambda 表达式的代码,编译出一套可以去调用 MethodHandle 的字节码代 码,参考实例类:MethodHandleDemo) 句柄类型 (MethodType) 是我们对方法的具体描述,配合方法名称,能够定位到一类函数。访问方法句柄和调用原来的指令基本一致,但它的调用异常, 包括一些权限检查,在运行时才能被发现。 案例中,我们完成了动态语言的特性,通过方法名称和传入的对象主体,进行不同的调用,而 Bike 和 Man 类,可以没有任何关系。 可以看到 Lambda 语言实际上是通过方法句柄来完成的,在调用链上自然也多了一些调用步骤,那么在性能上,是否就意味着 Lambda 性能低呢?对于 大部分“非捕获”的 Lambda 表达式来说,JIT 编译器的逃逸分析能够优化这部分差异,性能和传统方式无异;但对于“捕获型”的表达式来说,则需要通过方法句柄,不断地生成适配器,性能自然就低了很多 (不过和便捷性相比,一丁点性能损失是可接受的) 。 invokedynamic 指令,它实际上是通过方法句柄来实现的。和我们关系最大的就是 Lambda 语法,我们了解原理,可以忽略那些对 Lambda 性能高低的 争论,同时还是要尽量写一些“非捕获”的 Lambda 表达式。 ## Java 语法糖及实现 ### Java 中的泛型 (重点) #### 泛型是什么 泛型,即“参数化类型”,最熟悉的就是定义方法时有形参,然后调用此方法时传递实参。 引入一个类型变量 T (其他大写字母都可以,不过常用的就是 T ,E ,K ,V 等等) ,并且用 <> 括起来,并放在类名的后面。泛型类 是允许有多个类型变量的。 按照约定,类型参数名称命名为单个大写字母,以便可以在使用普通类或接口名称时能够容易地区分类型参数。以下是常用的类 型参数名称列表: ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) E - 元素,主要由 Java 集合(Collections)框架使用。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) K - 键,主要用于表示映射中的键的参数类型。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) V - 值,主要用于表示映射中的值的参数类型。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) N - 数字,主要用于表示数字。 ![img](https://files.vps4cloud.com/i/2023/04/26/6449204579610.jpg) T - 类型,主要用于表示第一类通用型参数。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) S - 类型,主要用于表示第二类通用类型参数。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) U - 类型,主要用于表示第三类通用类型参数。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) V - 类型,主要用于表示第四个通用类型参数。 ##### 泛型类和泛型接口 可以为任何类、接口增加泛型声明 ![img](https://files.vps4cloud.com/i/2023/04/26/644919ad7d388.png) 泛型接口与泛型类的定义基本相同。 ![img](https://files.vps4cloud.com/i/2023/04/26/644919b126e18.png) ##### 泛型类和接口的使用 而实现泛型接口的类,有两种实现方法: 1 、未传入泛型实参时: ![img](https://files.vps4cloud.com/i/2023/04/26/644919b4706be.png) 在 new 出类的实例时,需要指定具体类型: ![img](https://files.vps4cloud.com/i/2023/04/26/644919b854b4c.png) 2 、传入泛型实参 ![img](https://files.vps4cloud.com/i/2023/04/26/644919bd253bb.png) 在 new 出类的实例时,和普通的类没区别。 ##### 泛型方法 ![img](https://files.vps4cloud.com/i/2023/04/26/644919c0ca95f.png) 泛型方法,是在调用方法的时候指明泛型的具体类型 ,泛型方法可以在任何地方和任何场景中使用,包括普通类和泛型类。 #### 为什么我们需要泛型? 通过两段代码我们就可以知道为何我们需要泛型 ![img](https://files.vps4cloud.com/i/2023/04/26/644919c458fd2.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644919c80f0ec.png) 实际开发中,经常有数值类型求和的需求,例如实现 int 类型的加法,有时候还需要实现 long 类型的求和,如果还需要 double 类型 的求和,需要重新在重载一个输入是 double 类型的 add 方法。 所以泛型的好处就是: ![img](https://files.vps4cloud.com/i/2023/04/26/644923fae9551.jpg) 适用于多种数据类型执行相同的代码 ![img](https://files.vps4cloud.com/i/2023/04/26/644923fae9551.jpg) 泛型中的类型在使用时指定,不需要强制类型转换 #### 虚拟机是如何实现泛型的? ##### 泛型擦除 Java 语言中的泛型,它只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型 (Raw Type ,也称为裸类 型) 了,并且在相应的地方插入了强制转型代码,因此,对于运行期的 Java 语言来说,ArrayList<int> 与 ArrayList<String> 就是同一 个类,所以泛型技术实际上是 Java 语言的一颗语法糖,Java 语言中的泛型实现方法称为类型擦除,基于这种方法实现的泛型称为伪泛 型。 将一段 Java 代码编译成 Class 文件,然后再用字节码反编译工具进行反编译后,将会发现泛型都不见了,程序又变回了 Java 泛型 出现之前的写法,泛型类型都变回了原生类型 (因为) ![img](https://files.vps4cloud.com/i/2023/04/26/644919d130663.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644919d449bfa.png) ##### 使用泛型注意事项 (了解即可,装 B 专用) ![img](https://files.vps4cloud.com/i/2023/04/26/644919d77f536.png) 上面这段代码是不能被编译的,因为参数 List<Integer> 和 List<String> 编译之后都被擦除了,变成了一样的原生类型 List<E>, 擦除动作导致这两种方法的特征签名变得一模一样 (注意在 IDEA 中是不行的,但是 jdk 的编译器是可以,因为 jdk 是根据方法返回值 + 方法名 + 参数) 。 ##### 弱记忆 JVM 版本兼容性问题:JDK1.5 以前,为了确保泛型的兼容性,JVM 除了擦除,其实还是保留了泛型信息(Signature 是其中最重要的 一项属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类 型的信息)----弱记忆 另外,从 Signature 属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的 Code 属性中的字节码进行擦除,实际 上元数据中还是保留了泛型信息,这也是我们能通过反射手段取得参数化类型的根本依据。 ### Stream (了解即可) #### 什么是 Stream? Java8 中,Collection 新增了两个流方法,分别是 Stream() 和 parallelStream() Java8 中添加了一个新的接口类 Stream ,相当于高级版的 Iterator ,它可以通过 Lambda 表达式对集合进行大批量数据操作,或 者各种非常便利、高效的聚合数据操作。 #### 为什么要使用 Stream? 在 Java8 之前,我们通常是通过 for 循环或者 Iterator 迭代来重新排序合并数据,又或者通过重新定义 Collections.sorts 的 Comparator 方法来实现,这两种方式对于大数据量系统来说,效率并不是很理想。 #### Stream 使用入门 SQL 的聚合操作 sorted 、filter 、map 等类似。我们在应用层就可以高效地实现类似数据库 SQL 的 Stream 不仅可以通过串行的方式实现数据操作,还可以通过并行的方式处理大批量数据,提高数据的处理效率 ![img](https://files.vps4cloud.com/i/2023/04/26/644919dba6fb7.png) 可以看到,如果实现同样的功能,那么使用循环的方式,代码行数将达到 6 行。 ![img](https://files.vps4cloud.com/i/2023/04/26/644919dec6e33.png) #### Stream 操作分类 官方将 Stream 中的操作分为两大类:终结操作 (Terminal operations) 和中间操作 (Intermediate operations) 。 中间操作会返回一个新的流,一个流可以后面跟随零个或多个中间操作。其目的主要是打开流,做出某种程度的数据映射/过滤, 然后会返回一个新的流,交给下一个操作使用。这类操作都是惰性化的 (lazy) ,就是说,仅仅调用到这类方法,并没有真正开始流的 遍历。而是在终结操作开始的时候才真正开始执行。 中间操作又可以分为无状态 (Stateless) 与有状态 (Stateful) 操作,无状态是指元素的处理不受之前元素的影响,有状态是指该 操作只有拿到所有元素之后才能继续下去。 终结操作是指返回最终的结果。一个流只能有一个终结操作,当这个操作执行后,这个流就被使用“光”了,无法再被操作。所以 这必定这个流的最后一个操作。终结操作的执行才会真正开始流的遍历,并且会生成一个结果。 终结操作又可以分为短路 (Short-circuiting) 与非短路 (Unshort-circuiting) 操作, 短路是指遇到某些符合条件的元素就可以得到最终结果, 非短路是指必须处理完所有元素才能得到最终结果。操作分类详情如下图所示 ![img](https://files.vps4cloud.com/i/2023/04/26/644919e20bdc4.jpg) ##### 演示案例 ![img](https://files.vps4cloud.com/i/2023/04/26/644919e4b056c.png) 因为 Stream 操作类型非常多,总结一下常用的 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) map():将流中的元素进行再次加工形成一个新流,流中的每一个元素映射为另外的元素。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) filter(): 返回结果生成新的流中只包含满足筛选条件的数据 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) limit():返回指定数量的元素的流。返回的是 Stream 里前面的 n 个元素。 ![img](https://files.vps4cloud.com/i/2023/04/26/6449240e0700c.jpg) skip():和 limit()相反,将前几个元素跳过 (取出) 再返回一个流,如果流中的元素小于或者等于 n ,就会返回一个空的流。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) sorted():将流中的元素按照自然排序方式进行排序。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) distinct():将流中的元素去重之后输出。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) peek():对流中每个元素执行操作,并返回一个新的流,返回的流还是包含原来流中的元素。 #### Stream 的底层实现 ##### Stream 操作叠加 一个 Stream 的各个操作是由处理管道组装,并统一完成数据处理的。 我们知道 Stream 有中间操作和终结操作,那么对于一个写好的 Stream 处理代码来说,中间操作是通过 AbstractPipeline 生成了一个中间操作 Sink 链表 当我们调用终结操作时,会生成一个最终的 ReducingSink,通过这个 ReducingSink 触发之前的中间操作,从最后一个 ReducingSink 开始,递归产生一个 Sink 链。如下图所示: ![img](https://files.vps4cloud.com/i/2023/04/26/644919e84891f.jpg) ##### Stream 源码实现 (不重要) ###### stream 方法 因为 names 是 ArrayList 集合,所以 names.stream() 方法将会调用集合类基础接口 Collection 的 Stream 方法 ![img](https://files.vps4cloud.com/i/2023/04/26/644919eb6ac0e.png) 然后,Stream 方法就会调用 StreamSupport 类的 Stream 方法, ![img](https://files.vps4cloud.com/i/2023/04/26/644919efbb4ff.png) Stream 方法中初始化了一个 ReferencePipeline 的 Head 内部类对象: ![img](https://files.vps4cloud.com/i/2023/04/26/644919f231dea.png) ###### filter 方法 再调用 filter ,这个方法都是无状态的中间操作,所以执行 filter 时,并没有进行任何的操作,而是分别创建了一个 Stage 来标识用 户的每一次操作。 而通常情况下 Stream 的操作又需要一个回调函数,所以一个完整的 Stage 是由数据来源、操作、回调函数组成的三元组来表示。 如下图所示,分别是 ReferencePipeline 的 filter 方法和 map 方法: ![img](https://files.vps4cloud.com/i/2023/04/26/644919f599a76.png) ###### max 方法 ![img](https://files.vps4cloud.com/i/2023/04/26/644919f8c12c1.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644919fdd4fc7.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a015bffa.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a041b288.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a07b1a7a.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a0adce2c.png) 当 Sink 链表生成完成后,Stream 开始执行,通过 spliterator 迭代集合,执行 Sink 链表中的具体操作。 java8 中的 Spliterator 的 forEachRemaining 会迭代集合,每迭代一次,都会执行一次 filter 操作,如果 filter 操作通过,就会触发 map 操作,然后将结果放入到临时数组 object 中,再进行下一次的迭代。完成中间操作后,就会触发终结操作 max。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a0e756b2.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a1134250.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a146aa12.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a17677fe.png) ##### Stream 并发 Stream 源码实现 (不重要) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a1accffc.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a1e3b668.png) 这里的并行处理指的是,Stream 结合了 ForkJoin 框架,对 Stream 处理进行了分片,Splititerator 中的 estimateSize 方法会估算出分 片的数据量。 通过预估的数据量获取最小处理单元的阈值,如果当前分片大小大于最小处理单元的阈值,就继续切分集合。每个分片将会生成一个 Sink 链表,当所有的分片操作完成后,ForkJoin 框架将会合并分片任何结果集。 #### Stream 的性能 ##### 常规数据迭代 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 100 的性能对比 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a222baea.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a2509294.png) 常规的迭代 > Stream 并行迭代 > Stream 串行迭代 为什么这样: 1 、常规迭代代码简单,越简单的代码执行效率越高。 2 、Stream 串行迭代,使用了复杂的设计,导致执行速度偏低。所以是性能最低的。 3 、Stream 并行迭代 使用了 Fork-Join 线程池,所以效率比 Stream 串行迭代快,但是对比常规迭代还是要慢 (毕竟设计和代码复杂) ##### 大数据迭代 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 一亿的数组性能对比 (默认线程池) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a281821d.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a2b8dc8e.png) 为什么这样: 1 、Stream 并行迭代 使用了 Fork-Join 线程池, 而线程池线程数为 cpu 的核心数 (我的电脑为 12 核) ,大数据场景下,能够利用多线 程机制,所以效率比 Stream 串行迭代快,同时多线程机制切换带来的开销相对来说还不算多,所以对比常规迭代还是要快 (虽然设计 和代码复杂) 2 、常规迭代代码简单,越简单的代码执行效率越高。 3 、Stream 串行迭代,使用了复杂的设计,导致执行速度偏低。所以是性能最低的。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 一亿的数组性能对比 (线程池数量=2) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a2ea43c9.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a319e345.png) 为什么这样: Stream 并行迭代 使用了 Fork-Join 线程池,大数据场景下,虽然利用多线程机制,但是线程池线程数为 2 ,所以多个请求争抢着执行任 务,想象对请求来说任务是被交替执行完成,所以对比常规迭代还是要慢 (虽然用到了多线程技术) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 一亿的数组性能对比 (线程池数量=240) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a3473f95.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a38394b3.png) 为什么这样: Stream 并行迭代 使用了 Fork-Join 线程池, 而线程池线程数为 240 ,大数据场景下,虽然利用多线程机制,但是线程太多,线程的上下 文切换成本过高,所以导致了执行效率反而没有常规迭代快。 ##### 如何合理使用 Stream? 我们可以看到:在循环迭代次数较少的情况下,常规的迭代方式性能反而更好;而在大数据循环迭代中, parallelStream (合理的线程 池数上) 有一定的优势。 但是由于所有使用并行流 parallelStream 的地方都是使用同一个 Fork-Join 线程池,而线程池线程数仅为 cpu 的核心数。 切记,如果对底层不太熟悉的话请不要乱用并行流 parallerStream (尤其是你的服务器核心数比较少的情况下) ## GC 调优基础知识之工具篇 ### JDK 为我们提供的工具 (重点) 这些工具在 windows 上,就是这些 exe ,其他的平台不同。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a3b66855.png) 在 linux 中,一般自带了 OpenJdk ,一般情况下 JPS 等命令不能用 ,要么选择去安装 JPS 等插件 ,要么把 OpenJdk 卸载 ,去重新安装 Oracle 的 JDK ,我推荐后者。 #### 命令行工具 ##### jps 列出当前机器上正在运行的虚拟机进程,JPS 从操作系统的临时目录上去找 (所以有一些信息可能显示不全) 。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a3e145a1.png) -q :仅仅显示进程, -m:输出主函数传入的参数. 下的 hello 就是在执行程序时从命令行输入的参数 -l: 输出应用程序主类完整 package 名称或 jar 完整名称. -v: 列出 jvm 参数, -Xms20m -Xmx50m 是启动程序指定的 jvm 参数 ##### jstat 是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI 图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。 常用参数: -class (类加载器) -compiler (JIT) -gc (GC 堆状态) -gccapacity (各区大小) -gccause (最近一次 GC 统计和原因) -gcnew (新区统计) -gcnewcapacity (新区大小) -gcold (老区统计) -gcoldcapacity (老区大小) -gcpermcapacity (永久区大小) -gcutil (GC 统计汇总) -printcompilation (HotSpot 编译统计) 比如说:我们要统计 GC ,就是垃圾回收,那么只需要使用这样的命令。 jstat-gc 13616 (这个 13616 是 JVM 的进程,通过 JPS 命令得到) ,这样统计出来是的实时值。 所以很多情况下,我们为了看变化值的,可以这么玩。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a4147253.png) 假设需要每 250 毫秒查询一次进程 13616 垃圾收集状况,一共查询 10 次,那命令应当是:jstat-gc 13616 250 10 S0C:第一个幸存区 (From 区) 的大小 S1C:第二个幸存区 (To 区) 的大小 S0U:第一个幸存区的使用大小 S1U:第二个幸存区的使用大小 EC:伊甸园 (Eden) 区的大小 EU:伊甸园 (Eden) 区的使用大小 OC:老年代大小 OU:老年代使用大小 MC:方法区大小 MU:方法区使用大小 CCSC:压缩类空间大小 CCSU:压缩类空间使用大小 YGC:年轻代垃圾回收次数 YGCT:年轻代垃圾回收消耗时间 FGC:老年代垃圾回收次数 FGCT:老年代垃圾回收消耗时间 GCT:垃圾回收消耗总时间 ##### jinfo 查看和修改虚拟机的参数 jinfo –sysprops 可以查看由 System.getProperties()取得的参数 jinfo –flag 未被显式指定的参数的系统默认值 jinfo –flags (注意 s) 显示虚拟机的参数 ###### VM 参数分类 JVM 的命令行参数参考:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html ![img](https://files.vps4cloud.com/i/2023/04/26/64491a4500c71.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a477b9eb.png) 1、标准: -开头,所有的 HotSpot 都支持 保证 Java 虚拟机 (JVM) 的所有实现都支持标准选项。它们用于执行常见操作,例如检查 JRE 版本,设置类路径,启用详细输出等 2 、 非 标准:-X 开头,特定版本 HotSpot 支持特定命令 非标准选项是特定于 Java HotS pot 虚拟机的通用选项,因此不能保证所有 JVM 实现都支持它们,并且它们可能会发生变化。这些选项以开头-X。 -Xms30m -Xmx30m -Xss1m ![img](https://files.vps4cloud.com/i/2023/04/26/64491a4a31bdf.png) 3、高级选项: 以开头-XX: 这些是开发人员选项,用于调整 Java HotSpot 虚拟机操作的特定区域,这些区域通常具有特定的系统要求,并且可能需要对系统配置参数的特权访问。也 不能保证所有 JVM 实现都支持它们,并且它们可能会发生变化。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a4d648a2.png) 在 windows 上可以通过以下 java -XX:+PrintFlagsFinal –version 查询所有-XX 的 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a4fb2b61.png) 注意:manageable 的参数,代表可以运行时修改。 演示例子如下: 首先我们得知 PrintGC 这个 XX 参数是可以运行时修改的。 jinfo –flag - [参数] pid 可以修改参数 Thread.**getAllStackTraces**(); ![img](https://files.vps4cloud.com/i/2023/04/26/64491f2b50408.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a52c9a7d.png) 2.通过 jinfo 查看参数,打印 GC 详情 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a558a9fc.png) 3.通过 jinfo 修改参数,打印 GC 详情 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a57c3ec1.jpg) 总结:通过 jinfo 命令,我可以在生产上临时打开一下 GC 日志或者进行一些数据的配置。 (不需要重启应用条件下) ,也是我们去排查问题的一个关键命 令。 ##### jmap 用于生成堆转储快照 ( 一般称为 heapdump 或 dump 文件) 。jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalize 执行队列、Java 堆和永 久代的详细信息,如空间使用率、当前用的是哪种收集器等。和 jinfo 命令一样,jmap 有不少功能在 Windows 平台下都是受限的,除了生成 dump 文件的 -dump 选项和用于查看每个类的实例、空间占用统计的-histo 选项在所有操作系统都提供之外,其余选项都只能在 Linux/Solaris 下使用。 -heap 打印 heap 的概要信息 jmap – heap <pid> ![img](https://files.vps4cloud.com/i/2023/04/26/64491a5b64f5c.png) Heap Configuration: ##堆配置情况,也就是 JVM 参数配置的结果[平常说的 tomcat 配置 JVM 参数,就是在配置这些] MinHeapF reeRatio = 40 ##最小堆使用比例 MaxHeapFreeRatio = 70 ##最大堆可用比例 MaxHeapSize = 2147483648 (2048.0MB) ##最大堆空间大小 NewSize = 268435456 (256.0MB) ##新生代分配大小 MaxNewSize = 268435456 (256.0MB) ##最大可新生代分配大小 OldSize = 5439488 (5.1875MB) ##老年代大小 NewRatio = 2 ##新生代比例 SurvivorRatio = 8 ##新生代与 suvivor 的比例 PermSize = 134217728 (128.0MB) ##perm 区 永久代大小 MaxPermSize= 134217728 ( 128.0MB) ##最大可分配 perm 区 也就是永久代大小 Heap Usage: ##堆使用情况【堆内存实际的使用情况】 New Generation (Eden + 1 Survivor Space): ##新生代 (伊甸区 Eden 区 + 幸存区 survior(1+2)空间) capacity = 241631232 (230.4375MB) ##伊甸区容量 used = 77776272 (74.17323303222656MB) ##已经使用大小 free = 163854960 ( 156.26426696777344MB) ##剩余容量 32.188004570534986% used ##使用比例 Eden Space: ##伊甸区 capacity = 214827008 (204.875MB) ##伊甸区容量 used = 74442288 (70.99369812011719MB) ##伊甸区使用 free = 140384720 ( 133.8813018798828MB) ##伊甸区当前剩余容量 34.65220164496263% used ##伊甸区使用情况 From Space: ##survior1 区 capacity = 26804224 (25.5625MB) ##survior1 区容量 used = 3333984 (3.179534912109375MB) ##surviror1 区已使用情况 free = 23470240 (22.382965087890625MB) ##surviror1 区剩余容量 12.43827838477995% used ##survior1 区使用比例 To Space: ##survior2 区 capacity = 26804224 (25.5625MB) ##survior2 区容量 used = 0 (0.0MB) ##survior2 区已使用情况 free = 26804224 (25.5625MB) ##survior2 区剩余容量 0.0% used ## survior2 区使用比例 PS Old Generation: ##老年代使用情况 capacity = 1879048192 ( 1792.0MB) ##老年代容量 used = 30847928 (29.41887664794922MB) ##老年代已使用容量 free = 1848200264 (1762.5811233520508MB) ##老年代剩余容量 1.6416783843721663% used ##老年代使用比例 -histo 打印每个 class 的实例数目, 内存占用,类全名信息. jmap – histo <pid> jmap – histo:live <pid> 如果 live 子参数加上后, 只统计活的对象数量. ![img](https://files.vps4cloud.com/i/2023/04/26/64491a5f5713c.png) 但是这样显示太多了,一般在 linux 上会这么操作 jmap –histo 1196 | head -20 (这样只会显示排名前 20 的数据) 不太重要的参数 -finalizerinfo 打印正等候回收的对象的信息,还有 jmap –clstats 这个命令最好也不要去使用 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a623a2c6.png) -dump 生成的堆转储快照 (比较重要) jmap -dump:live,format=b,file=heap.bin <pid> Sun JDK 提供 jhat (JVM Heap Analysis Tool) 命令与 jmap 搭配使用,来分析 jmap 生成的堆转储快照。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a64cc737.png) ##### jhat jhat dump 文件名 后屏幕显示“Server is ready. ”的提示后,用户在浏览器中键入 http://localhost:7000/就可以访问详情 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a679ccf2.png) 使用 jhat 可以在服务器上生成堆转储文件分析 ( 一般不推荐,毕竟占用服务器的资源,比如一个文件就有 1 个 G 的话就需要大约吃一个 1G 的内存资源) ##### jstack (Stack Trace for Java) 命令用于生成虚拟机当前时刻的线程快照。线程快照就是当前虚拟机内每一条线程正在执行的方法堆栈的集合,生成线程快照的主 要目的是定位线程出现长时间停顿的原因,如线程间死锁、死循环、请求外部资源导致的长时间等待等都是导致线程长时间停顿的常见原因。 在代码中可以用 java.lang.Thread 类的 getAllStackTraces () 方法用于获取虚拟机中所有线程的 StackTraceElement 对象。使用这个方法可以通过简单的几行 代码就完成 jstack 的大部分功能,在实际项目中不妨调用这个方法做个管理员页面,可以随时使用浏览器来查看线程堆栈。 (并发编程中的线程安全课程 中有具体的案例) 一般来说 jstack 主要是用来排查是否有死锁的情况,这块内容在并发编程中有详细的讲解。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a69dedf5.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a6c78677.png) #### 命令工具总结 (重点) ##### 生产服务器推荐开启 -XX:- HeapDumpOnOutOfMemoryError 默认关闭,建议开启,在 java.lang.OutOfMemoryError 异常出现时,输出一个 dump 文件,记录当时的堆内存快照。 -XX: HeapDumpPath=./java_pid<pid>.hprof 用来设置堆内存快照的存储文件路径,默认是 java 进程启动位置。 ##### 调优之前开启、调优之后关闭 -XX:+PrintGC 调试跟踪之打印简单的 GC 信息参数: -XX:+PrintGCDetails, +XX:+PrintGCTimeStamps 打印详细的 GC 信息 -Xlogger:logpath 设置 gc 的日志路,如: -Xlogger:log/gc.log , 将 gc.log 的路径设置到当前目录的 log 目录下. 应用场景:将 gc 的日志独立写入日志文件,将 GC 日志与系统业务日志进行了分离,方便开发人员进行追踪分析。 ##### 考虑使用 -XX:+PrintHeapAtGC , 打印推信息 参数设置: -XX:+PrintHeapAtGC 应用场景: 获取 Heap 在每次垃圾回收前后的使用状况 -XX:+TraceClassLoading 参数方法: -XX:+TraceClassLoading 应用场景:在系统控制台信息中看到 class 加载的过程和具体的 class 信息,可用以分析类的加载顺序以及是否可进行精简操作。 -XX:+DisableExplicitGC 禁止在运行期显式地调用 System.gc() #### 可视化工具 JMX (Java Management Extensions ,即 Java 管理扩展) 是一个为应用程序、设备、系统等[植入](https://baike.baidu.com/item/植入/7958584)管理功能的框架。JMX 可以跨越一系列异构操作系统平台、 [系统体系结构](https://baike.baidu.com/item/系统体系结构/6842760)和[网络传输协议](https://baike.baidu.com/item/网络传输协议/332131),灵活的开发无缝集成的系统、网络和服务管理应用。 管理远程进程需要在远程程序的启动参数中增加: * Djava.rmi.server.hostname= … .. -Dcom.sun.management.jmxremote -Dcom.sun.management.jmxremote.port=8888 -Dcom.sun.management.jmxremote.authenticate=false -Dcom.sun.management.jmxremote.ssl=false ##### Jconsole ![img](https://files.vps4cloud.com/i/2023/04/26/64491a715ca01.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a745d12e.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491f67ef598.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a77bb087.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a7e75cef.png) ##### visualvm 插件中心地址 [https://visualvm.github.io](https://visualvm.github.io/archive/uc/8u40/updates.xml.gz) 但是注意版本问题,不同的 JDK 所带的 visualvm 是不一样的,下载插件时需要下对应的版本。 一般来说,这个工具是本机调试用,一般生产上来说,你一般是用不了的 (除非启用远程连接) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a81ab557.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a8580760.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491f8c900ff.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a89026e5.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a8c85068.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a8fc4ef6.png) ### Arthas 官方文档参考 https://alibaba.github.io/arthas/ Arthas 是 Alibaba 开源的 Java 诊断工具,深受开发者喜爱。 Arthas 支持 JDK 6+ ,支持 Linux/Mac/Windows ,采用命令行交互模式,同时提供丰富的 Tab 自动补全功能,进一步方便进行问题的定位 和诊断 #### 下载和安装 不需要安装,就是一个 jar 包 curl -O https://alibaba.github.io/arthas/arthas-boot.jar java -jar arthas-boot.jar 启动 arthas 的 jar 包是 arthas-boot.jar #### 快速入门 1 、直接 java -jar arthas-boot.jar 。选择 attach 的进程绑定 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a92ebb7c.png) 3、 通过 jps 命令快速查找 java 进程,再次直接绑定 java -jar arthas-boot.jar pid 启动 arthas 工具 attach 到目标进程 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a95e9dc1.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a98602b2.png) 进入 arthas 后命令行前面出现标识 ![img](https://files.vps4cloud.com/i/2023/04/26/64491a9ac9f31.png) #### 常用命令 ##### Dashboard 注意在 arthas 中,有 tab 键填充功能,所以比较好用。但是这个界面是实时刷新的,一般 5s 刷新一次,使用 q 键退出刷新 (没有退出 arthasq) ![img](https://files.vps4cloud.com/i/2023/04/26/64491a9e60588.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491aa0b0989.png) ##### Thread 这个命令和 jstack 很相似,但是功能更加强大,主要是查看当前 JVM 的线程堆栈信息 同时可以结合使用 thread – b 来进行死锁的排查死锁。 参数解释: -n 指定最忙的前 n 个线程并打印堆栈 -b 找出阻塞当前线程的线程 -i 指定 cpu 占比统计的采样间隔,单位为毫秒 实战演示 thread – h 显示帮助 ![img](https://files.vps4cloud.com/i/2023/04/26/64491aa40e0a5.png) thread – b 找出阻塞当前线程的线程 ![img](https://files.vps4cloud.com/i/2023/04/26/64491aa6838bf.png) 如果有死锁,会有红色的字提醒着,这个阻塞的线程已经被另外一个线程阻塞。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491aa8b5e90.png) thread -i 1000 -n 3 每过 1000 毫秒进行采样,显示最占 CPU 时间的前 3 个线程 ![img](https://files.vps4cloud.com/i/2023/04/26/64491aab7a2c7.png) thread --state WAITING 查看处于等待状态的线程 ![img](https://files.vps4cloud.com/i/2023/04/26/64491aae3ad88.png) ##### JVM jvm ![img](https://files.vps4cloud.com/i/2023/04/26/64491ab09cd21.png) ##### Jad 反编译指定已加载类的源码 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ab3b3ea4.png) ##### [trace](https://alibaba.github.io/arthas/trace.html) 使用 trace 命令可以跟踪统计方法耗时。 继续跟踪耗时高的方法,然后再次访问 比如使用一个 Springboot 项 目 ( 当然 , 不想 Springboot 的话 ,你也可 以直接在 UserController 里 main 方法启动 ) 控制层 getUser 方法调用 了 userService.get(uid); ,这个方法中分别进行 check 、service 、redis 、mysql 等操作操作。就可以根据这个命令跟踪出来哪里的耗时最长。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ab678ab3.png) ##### [monitor](https://alibaba.github.io/arthas/monitor.html) 每 5 秒统计一次 cn.enjoyedu.demo.controller.DemoController 类的 test 方法执行情况 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ab8f31b1.png) ##### [watch](https://alibaba.github.io/arthas/watch.html) 观察方法的入参出参信息 # 查看入参和出参 $ watchcn.enjoyedu.demo.controller.DemoController test '{params[0],returnObj}' ![img](https://files.vps4cloud.com/i/2023/04/26/64491abc07258.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ac192221.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ac192b8a.png) #### 命令汇总 |命令|介绍| | ----| ---------------------------------------------------------------------------------------------------| |[dashboard](https://alibaba.github.io/arthas/dashboard.html)|当前系统的实时数据面板| |[thread](https://alibaba.github.io/arthas/thread.html)|查看当前 JVM 的线程堆栈信息| |[watch](https://alibaba.github.io/arthas/watch.html)|方法执行数据观测| |[trace](https://alibaba.github.io/arthas/trace.html)|方法内部调用路径,并输出方法路径上的每个节点上耗时| |[stack](https://alibaba.github.io/arthas/stack.html)|输出当前方法被调用的调用路径| |[tt](https://alibaba.github.io/arthas/tt.html)|方法执行数据的时空隧道,记录下指定方法每次调用的入参和返回信息,并能对这些不同的时间下 调用进行观测| |[monitor](https://alibaba.github.io/arthas/monitor.html)|方法执行监控| |[jvm](https://alibaba.github.io/arthas/jvm.html)|查看当前 JVM 信息| |[vmoption](https://alibaba.github.io/arthas/vmoption.html)|查看,更新 JVM 诊断相关的参数| |[sc](https://alibaba.github.io/arthas/sc.html)|查看 JVM 已加载的类信息| |[sm](https://alibaba.github.io/arthas/sm.html)|查看已加载类的方法信息| |[jad](https://alibaba.github.io/arthas/jad.html)|反编译指定已加载类的源码| |[classloader](https://alibaba.github.io/arthas/classloader.html)|查看 classloader 的继承树,urls ,类加载信息| |[heapdump](https://alibaba.github.io/arthas/heapdump.html)|类似 jmap 命令的 heap dump 功能| ### 动态追踪技术底层分析 (了解即可) 动态追踪技术是一个可以不用重启线上 java 项目来进行问题排查的技术,比如前面讲的 Arthas 就属于一种动态追踪的工具。它里面提 供的 [monitor](https://alibaba.github.io/arthas/monitor.html) 还有 [watch](https://alibaba.github.io/arthas/watch.html) 等命令就是动态的追踪技术。 当然我们学技术要知其然还要知其所以然,Arthas 工具的基础,就是 Java Agent 技术,可以利用它来构建一个附加的代理程序,用来 协助检测性能,还可以替换一些现有功能,甚至 JDK 的一些类我们也能修改,有点像 JVM 级别的 AOP 功能。 #### Java Agent 技术 既然作为 JVM 的 AOP ,就必须要有 AOP 的功能,所以 Java Agent 提供了两个类似于 AOP 的方法: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 一个方法叫做 premain 方法,可以在 main 运行之前的进行一些操作(Java 入口是一个 main 方法) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 一个是 agentmain 方法,是控制类运行时的行为 (Arthas 使用的就是这种) 但在一个 JVM 中,只会调用一个 ##### 怎么玩 已有一个项目案例 app: ![img](https://files.vps4cloud.com/i/2023/04/26/64491ac54895f.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ac8043c0.png) 要构建一个 agent 程序,大体可分为以下步骤: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 使用字节码增强工具,编写增强代码; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 在 manifest 中指定 Premain-Class/Agent-Class 属性; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 使用参数加载或者使用 attach 方式改变 app 项目中的内容; ###### 编写 Agent Java Agent 体现方式是一个 jar 包,使用 IDEA 创建一个默认的 maven 工程即可。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491aca481dd.png) 加入 maven 依赖,我们借用 javassist 完成字节码增强: ![img](https://files.vps4cloud.com/i/2023/04/26/64491accd1f4a.png) 创建一个普通的 Java 类,添加 premain 或者 agentmain 方法,它们的参数完全一样。 示例中使用的是 AgentApp ![img](https://files.vps4cloud.com/i/2023/04/26/64491acfa106e.jpg) Transformer --变形金刚,有一句话叫做:穷人靠变异 富人靠科技。Agent 就靠这个来变异。所以这个就是核心方法。 ###### 编写 Transformer 假如我们要统计某个方法的执行时间,使用 JavaAssist 工具来增强字节码。 比如就是 app 项目中 MainRun 类的 hello 方法。 那么步骤如下: 代码逻辑需要实现 ClassFileTransformer 接口,编写一个 Agent 类实现,然后在 transform 方法中实现以下逻辑: ![img](https://files.vps4cloud.com/i/2023/04/26/644920082c19d.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ad2d6e2c.png) ###### 打包 Agent MANIFEST.MF 文件(让外界知晓的) 具体路径在 src/main/resources/META- INF/MANIFEST.MF: ![img](https://files.vps4cloud.com/i/2023/04/26/64491ad5d2d05.png) maven 打包会覆盖这个文件,所以我们需要为它指定一个,用 manifestFile 来指定你的 MANIFEST.MF 文件。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ad88cfdd.png) 在命令行,执行 mvn install 安装到本地代码库 ![img](https://files.vps4cloud.com/i/2023/04/26/64492025b6090.png) 得到 Agent 的 jar 包。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491adb3cdc2.png) ###### 使用 使用方式取决于你使用的 premain 还是 agentmain Premain 直接命令行中加入参数即可,在 jvm 启动时启用代理。 java -javaagent:agent.jar MainRun 在 IDEA 中,可以将参数配置 jvm options 里。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491adf39276.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491adda660d.png) 测试一下: ![img](https://files.vps4cloud.com/i/2023/04/26/6449202c6f7f4.png) 执行后,直接输出 helloworld 。通过增强以后,还额外的输出了执行时间,以及一些 debug 信息。其中,debug 信息在 main 方法执行之前输出。 Agentmain 这种模式一般用在一些诊断工具上。使用 jdk/lib/tools.jar 中的工具类中的 Attach API,可以动态的为运行中的程序加入一些功能。它的主要运行步骤如下: ![img](https://files.vps4cloud.com/i/2023/04/26/64492457686f1.jpg) 获取机器上运行的所有 JVM 进程 ID; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 选择要诊断的 jvm; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 将 jvm 使用 attach 函数链接上; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 使用 loadAgent 函数加载 agent ,动态修改字节码; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 卸载 jvm。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ae225373.png) ##### Java Attach API Attach API 不是 Java 的标准 API ,而是 Sun 公司提供的一套扩展 API ,用来向目标 JVM ”附着” (Attach) 代理工具程序的。有了它, 开发者可以方便的监控一个 JVM,运行一个外加的代理程序。Attach API 只有 2 个主要的类,都在 com.sun.tools.attach 包(在 jdk 的 lib 目录下 tools.jar 里面)里面: VirtualMachine 代表一个 Java 虚拟机,也就是程序需要监控的目标虚拟机,提供了 JVM 枚举,Attach 动作和 Detach 动作 (Attach 动 作的相反行为,从 JVM 上面解除一个代理) 等等 ; VirtualMachineDescriptor 则是一个描述虚拟机的容器类,配合 VirtualMachine 类完成各种功能。 Java Attach API 是一个 API 接口,JDK 提供的,它可以将应用程序连接到另一个目标虚拟机。然后,您的应用程序可以将代理应用程序 装入目标虚拟机,例如,用于执行监视状态之类的任务。 JVM Attach API 功能上非常简单,主要功能如下 ![img](https://files.vps4cloud.com/i/2023/04/26/6449204579610.jpg) Attach 到其中一个 JVM 上,建立通信管道 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ed329d98.jpg) 让目标 JVM 加载 Agent 使用入门: 不过我在 IDE 使用它的时候,要注意到,这个 API 属于 JDK 的包,所以使用它项目中必须要引用它。 使用案例如下: 1 、首先项目中引用到 tools.jar(这个包在 JDK 的安装目录的 lib 下面) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ae5a7cad.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ae917b92.png) 1 、JVM 进程号,通过 jps 命令获取 ![img](https://files.vps4cloud.com/i/2023/04/26/64492462db31d.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491aec59bfa.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491aefe7abe.png) 但是问题来了,我们的思路上不单单应该是修改字节码,同时还有一个问题,我们只是达到了修改目标应用字节码的目标。 这个就类似于你安装了间谍到敌方,但是间谍在敌方宣传我方思想,这个是很明显的暴露,要怎么做到我安插的间谍只给我传输数据。 其实很简单,还是进行字节码的增强,只是不在敌方打印,只在把相关信息获取出来再传递给我方就可以了。 Arthas 它里面提供的 [monitor](https://alibaba.github.io/arthas/monitor.html) 还有 [watch](https://alibaba.github.io/arthas/watch.html) 等命令本质上就是通过这种字节码修改的方式,在目标应用中把相关的参数统计出来,然后再 传递到 Arthas 中即可,当然这个里面会涉及到数据的传递问题 (这里不做深入的探讨) . #### 借助 Btrace 手写动态追踪框架 ##### BTrace 是什么 什么是 BTrace 呢?BTrace 已经开源,项目描述极其简短: A safe, dynamic tracing tool for the Java platform. BTrace 是基于 Java 语言的一个安全的、可提供动态追踪服务的工具。BTrace 基于 ASM 、Java Attach API 、Instrument 开发,为用户提供 了很多注解。依靠这些注解,我们可以编写 BTrace 脚本 (简单的 Java 代码) 达到我们想要的效果,而不必深陷于 ASM 对字节码的操 作中不可自拔。 ASM 是什么吗?cglib 、Spring 等框架中对于字节码的操作就建立在 ASM 之上。 我们都知道,Spring 的 AOP 是基于动态代理实现的,Spring 会在运行时动态创建代理类,代理类中引用被代理类,在被代理的方法执 行前后进行一些神秘的操作。那么,Spring 是怎么在运行时创建代理类的呢?动态代理的美妙之处,就在于我们不必手动为每个需要 被代理的类写代理类代码,Spring 在运行时会根据需要动态地创造出一个类。这里创造的过程并非通过字符串写 Java 文件,然后编译 成 class 文件,然后加载。Spring 会直接“创造”一个 class 文件,然后加载,创造 class 文件的工具,就是 ASM 了 ##### BTrace 配置 首先要下载 btrace 的压缩包,并解压, ![img](https://files.vps4cloud.com/i/2023/04/26/64491af4924df.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491af2c763e.png) 配置环境变量 ![img](https://files.vps4cloud.com/i/2023/04/26/64491afab1f56.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b003d161.png) 打开 CMD 命令行,输入 btrace 出现帮助及配置成功 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b02d171b.png) ##### 怎么玩 准备一个 springboot 的工程,写一个 controller。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b059217e.png) 建立一个简单的工程,maven 的也可以,只是这个工程里面需要依赖 btrace 的三个包 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b081bf54.png) ###### 脚本 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b0a5e6c4.jpg) 在命令行执行 jps,和 btrace pid 脚本名.java ,如 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b0cbc1b7.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b0f330cf.png) 要注意,脚本中不能有任何的注释 (中文注释) 访问 controller ![img](https://files.vps4cloud.com/i/2023/04/26/64491b13713d9.png) 监控中出现监控结果 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b1165740.png) ###### 脚本语法 ####### OnMethod @On Method 可以指定 clazz 、method 、location。 由此组成了在什么时机 (location 决定) 监控某个类/某些类 (clazz 决定) 下的某个方法/某些方法(method 决定)。 拦截时机由 location 决定,当然也可为同一个定位加入多个拦截时机,即可以在进入方法时拦截、方法返回时拦截、抛出异常时拦截 ####### clazz clazz 支持,精准定位、正则表达式定位、 按 接 口 或 继 承 类 定 位 < 例 如 要 匹 配 继 承 或 实现 了 com.kite.base 的 接 口 或 基 类 的 , 只 要 在 类 前 加上 + 号 就 可 以 了 , 例 如 @On Method(clazz="+com.kite.base", method="doSome")>、 按注解定位 < 在前面加上 @ 即可,例如 @On Method(clazz="@javax.jws.WebService", method="@javax.jws.WebMethod")> method 支持精准定位、正则表达式定位、按注解定位 ####### location 1. Kind.Entry 与 Kind.Return 分别表示函数的开始和返回,不写 location 的情况下,默认为 Kind.Entry,仅获取参数值,可以用 Kind.Entry ,要获取返回值或执行时 间就要用 Kind.Return 2. Kind.Error, Kind.Throw 和 Kind.Catch, 表示异常被 throw 、异常被捕获还有异常发生但是没有被捕获的情况,在拦截函数的参数定义里注入一个 Throwable 的参数,代表异 常 3 、Kind.Call 表示被监控的方法调用了哪些其他方法,Kind.Line 监测类是否执行到了设置的行数 ###### 案例解释 ![img](https://files.vps4cloud.com/i/2023/04/26/6449206fd43c1.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b16b451e.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644920742ae72.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64492467daef9.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b1a237ed.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b1cbdab4.png) ![img](https://files.vps4cloud.com/i/2023/04/26/6449246cc497e.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b1fbafed.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b22085d4.jpg) 加入 TraceException,在里面不单单把返回错误显示出来,同时还在原有程序答应堆栈日志。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b2454d1a.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b26b8c23.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b29162cf.jpg) ##### BTrace 注解 BTrace 注解可以分为: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 类注解 @BTrace ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 方法注解如 @On Method ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 参数注解如:@ProbeClassName ###### 参数注解 @ProbeClassName 用于标记处理方法的参数,仅用户 @On Method, 该参数的值就是被跟踪的类名称 @ProbeMethodName 用于表姐处理方法的参数,仅用户 @On Method,该参数值是被跟踪方法名称 @Self 当前截取方法的封闭实例参数 @Return 当前截取方法的的返回值, 只对 location=@Location(Kind.RETURN) 生效 @Duration 当前截取方法的执行时间 @TargetInstance 当前截取方法内部调用的实例 @TargetMethodOrField 当前截取方法内部被调用的方法名 ###### 方法注解 ![img](https://files.vps4cloud.com/i/2023/04/26/644924711755f.png) 用于指定跟踪方法到目标类, 目标方法和目标位置 格式 ![img](https://files.vps4cloud.com/i/2023/04/26/6449255ee42b5.png)![img](file:///C:\Users\Administrator\AppData\Local\Temp\ksohtml15192\wps503.png) @Location 属性有: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) value 默认值为 Kind.ENTRY 即参数的入口位置 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) where 限定探测位置 默认值为 Where.BEFORE 也可以设置为 Where.AFTER ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) clazz ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) method ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) field ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) type ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) line @Kind 注解的值有 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) Kind.ENTRY-被 trace 方法参数 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) Kind.RETURN-被 trace 方法返回值 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) Kind.THROW -抛异常 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) Kind.ARRAY_SET, Kind.ARRAY_GET -数组索引 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) Kind.CATCH -捕获异常 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) Kind.FIELD_SET -属性值 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) Kind.LINE -行号 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) Kind.NEW -类名 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) Kind.ERROR -抛异常 @OnTimer 用于指定跟踪操作定时执行。value 用于指定时间间隔 @On Error 当 trace 代码抛异常或者错误时,该注解的方法会被执行.如果同一个 trace 脚本中其他方法抛异常,该注解方法也会被执行。 #### Btrace 的限制 BTrace 最终借 Instrument 实现 class 的替换。出于安全考虑,Instrument 在使用上存在诸多的限制,这就好比给一架正在飞行的飞机换 发动机一样一样的,因此 BTrace 脚本的限制如下: 不允许创建对象 不允许创建数组 不允许抛异常 不允许 catch 异常 不允许随意调用其他对象或者类的方法,只允许调用 com.sun.btrace.BTraceUtils 中提供的静态方法 ( 一些数据处理和信息输出工具) 不允许改变类的属性 不允许有成员变量和方法,只允许存在 static public void 方法 不允许有内部类、嵌套类 不允许有同步方法和同步块 不允许有循环 不允许随意继承其他类 (当然,java.lang.Object 除外) 不允许实现接口 不允许使用 assert 不允许使用 Class 对象 如此多的限制,其实可以理解。BTrace 要做的是,虽然修改了字节码,但是除了输出需要的信息外,对整个程序的正常运行并没有影 响。 ### 工具总结 其实作为 Java 的动态追踪技术,站在比较底层的角度上来说,底层无非就是基于 ASM、Java Attach API、Instrument 开发的创建。 Arthas 都是针前面这些技术的一个封装而已。 Btrace 功能虽然强大,但都是比较难入门,这就是为什么 Btrace 出来这么多年,还是只在小范围内被使用。相对来说,Arthas 显 的友好而且安全的多。 但无论工具如何强大,一些基础知识是需要牢固掌握的,否则,工具中出现的那些术语,也会让人一头雾水。 工具常变,但基础更加重要。如果你想要一个适应性更强的技术栈,还是要多花点时间在原始的排查方法上。 ## JVM 性能调优之内存优化与 GC 优化 JVM 调优是一个系统而又复杂的过程,但我们知道,在大多数情况下,我们基本不用去调整 JVM 内存分配,因为一些初始化的参数已经可以保证应用 服务正常稳定地工作了。 在应用服务的特定场景下,JVM 内存分配不合理带来的性能表现并不会像内存溢出问题这么突出。一般你没有深入到各项性能指标中去,是很难发现其 中隐藏的性能损耗。 ### 压测工具 AB Ab(ApacheBench) 测试工具是 Apache 提供的一款测试工具,具有简单易上手的特点,在测试 Web 服务时非常实用。 ab 一般都是在 Linux 上用。 安装非常简单,只需要在 Linux 系统中输入 yum-y install httpd-tools 命令,就可以了。 安装成功后,输入 ab 命令,可以看到以下信息: ![img](https://files.vps4cloud.com/i/2023/04/26/64491b302a779.png) ab 工具用来测试 post get 接口请求非常便捷,可以通过参数指定请求数、并发数、请求参数等 测试 get 请求接口 ab -c 10 -n 100 http://www.test.api.com/test/login?userName=test&password=test 测试 post 请求接口 ab -n 100 -c 10 -p 'post.txt' -T 'application/x-www-form-urlencoded' 'http://test.api.com/test/register' post.txt 为存放 post 参数的文档,存储格式如 usernanme=test&password=test&sex=1 参数的含义: -n :总请求次数 (最小默认为 1) ; -c :并发次数 (最小默认为 1 且不能大于总请求次数,例如:10 个请求,10 个并发,实际就是 1 人请求 1 次) ; -p :post 参数文档路径 (-p 和 -T 参数要配合使用) ; -T:header 头内容类型 (此处切记是大写英文字母 T) ; 输出中,性能指标参考 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b3313f01.png) Requests per second:吞吐率,指某个并发用户数下单位时间内处理的请求数; Time per request:上面的是用户平均请求等待时间,指处理完成所有请求数所花费的时间 / (总请求数 / 并发用户数) ; Time per request:下面的是服务器平均请求处理时间,指处理完成所有请求数所花费的时间 / 总请求数; Percentage of the requests served within a certain time:每秒请求时间分布情况,指在整个请求中,每个请求的时间长度的分布情况,例如有 50% 的请求 响应在 8ms 内,66% 的请求响应在 10ms 内,说明有 16% 的请求在 8ms~10ms 之间。 ### JVM 堆内存分配 ### JVM 内存分配的调优案例 一个高并发系统中的抢购接口,高峰时 5W 的并发请求,且每次请求会产生 20KB 对象 (包括订单、用户、优惠券等对象数据) 。 我们可以通过一个并发创建一个 1MB 对象的接口来模拟万级并发请求产生大量对象的场景,具体代码如下: ![img](https://files.vps4cloud.com/i/2023/04/26/64491b35afae5.png) #### AB 压测 对应用服务进行压力测试,模拟不同并发用户数下的服务的响应情况: 1 、10 个并发用户/10 万请求量(总) 2 、100 个并发用户/10 万请求量(总) 3 、1000 个并发用户/10 万请求量(总) ab -c 10 -n 100000 http://127.0.0.1:8080/jvm/heap ab -c 100 -n 100000 http://127.0.0.1:8080/jvm/heap ab -c 1000 -n 100000 http://127.0.0.1:8080/jvm/heap ##### 服务器信息 我本机起一台 Linux 虚拟机,分配的内存为 2G ,处理器数量为 2 个。具体信息如下图: ![img](https://files.vps4cloud.com/i/2023/04/26/64491b38aabce.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b3b4837f.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b3d800d2.png) ##### GC 监控 还有一句话,无监控不调优,所以我们需要监控起来。JVM 中我们使用 jstat 命令监控一下 JVM 的 GC 情况。 统计 GC 的情况。 jstat-gc 8404 5000 20 | awk '{print $13,$ 14,$15,$ 16,$17}' ![img](https://files.vps4cloud.com/i/2023/04/26/64491b4051294.png) ##### 堆空间监控 在默认不配置 JVM 堆内存大小的情况下,JVM 根据默认值来配置当前内存大小。 我们可以通过以下命令来查看堆内存配置的默认值: java -XX:+PrintFlagsFinal -version | grep HeapSize ![img](https://files.vps4cloud.com/i/2023/04/26/64491b4315520.png) 这台机器上启动的 JVM 默认最大堆内存为 480MB ,初始化大小为 32MB。 ##### 测试项目启动 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b4785ffa.png) 使用 jmap -heap <pid> 这种方式,我们看到这个 JVM 应用占据的堆空间大小 ##### 压测结果 ###### 1 、10 个并发用户/10 万请求量(总) 使用 AB 进行压力测试: ab -c 10 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b4a22f17.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b4c9174b.png) 统计 GC 情况 jstat -gc 9656 5000 20 | awk '{print $13,$ 14,$15,$ 16,$17 }' ![image-20230426211907890](https://files.vps4cloud.com/i/2023/04/26/644924cd728d0.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 用户的吞吐量大于在 1426/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) JVM 服务器平均请求处理时间 0.7ms 左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) JVM 服务器发生了 2700 多次 YGC,耗时 15 秒 ,还有 45 次 FGC,2.3 秒左右,加在一起 GC 耗时 17 秒 ###### 2 、100 个并发用户/10 万请求量(总) 使用 AB 进行压力测试: ab -c 100 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b52199dd.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 用户的吞吐量大于在 1262/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) JVM 服务器平均请求处理时间 0.8ms 左右 JVM 服务器发生了 2700 多次 YGC,耗时 30 秒 ,还有 56 次 FGC,3 秒左右,加在一起 GC 耗时 33 秒 ###### 3 、1000 个并发用户/10 万请求量(总) 使用 AB 进行压力测试: ab -c 1000 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b54b2c2f.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 用户的吞吐量大于在 1145/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) JVM 服务器平均请求处理时间 0.8ms 左右 JVM 服务器发生了 2700 多次 YGC,耗时 38 秒 ,还有 47 次 FGC,3 秒左右,加在一起 GC 耗时 42 秒 ##### 结果分析 GC 频率 高频的 FullGC 会给系统带来非常大的性能消耗,虽然 MinorGC 相对 FullGC 来说好了许多,但过多的 MinorGC 仍会给系统带来压力。 内存 这里的内存指的是堆内存大小,堆内存又分为年轻代内存和老年代内存。堆内存不足,会增加 MinorGC ,影响系统性能。 吞吐量 频繁的 GC 将会引起线程的上下文切换,增加系统的性能开销,从而影响每次处理的线程请求,最终导致系统的吞吐量下降。 延时 JVM 的 GC 持续时间也会影响到每次请求的响应时间。 ##### 调优方案 ###### 调整方案一 调整堆内存空间减少 GC:通过分析,堆内存基本被用完了,而且存在大量 MinorGC 和 FullGC ,这意味着我们的堆内存严重不足,这个时候我们需要调 大堆内存空间。 堆空间加大到 1.5G java -jar -Xms1500m -Xmx1500m jvm-1.0-SNAPSHOT.jar 使用 AB 进行压力测试: ab -c 10 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b57e188f.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 用户的吞吐量大于在 1205/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) JVM 服务器平均请求处理时间 0.83ms 左右 JVM 服务器发生了 800 次 YGC,耗时 33 秒 ,还有 1 次 FGC, 1 秒左右,加在一起 GC 耗时 34 秒 使用 AB 进行压力测试: ab -c 100 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b5c13e7b.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 用户的吞吐量大于在 989/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) JVM 服务器平均请求处理时间 1.01ms 左右 JVM 服务器发生了 800 次 YGC,耗时 46 秒 ,还有 8 次 FGC,6 秒左右,加在一起 GC 耗时 52 秒 使用 AB 进行压力测试: ab -c 1000 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b5f17d9b.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 用户的吞吐量大于在 749/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) JVM 服务器平均请求处理时间 1.3ms 左右 JVM 服务器发生了 800 次 YGC,耗时 66 秒 ,还有 8 次 FGC,9 秒左右,加在一起 GC 耗时 75 秒 ###### 调整方案二 java -jar -Xms1500m -Xmx1500m -Xmn1000m -XX:SurvivorRatio=8 jvm-1.0-SNAPSHOT.jar 使用 AB 进行压力测试: ab -c 10 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b6197890.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 用户的吞吐量大于在 1780/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) JVM 服务器平均请求处理时间 0.56ms 左右 JVM 服务器发生了 400 次 YGC,耗时 5.8 秒 ,还有 2 次 FGC,0.1 秒左右,加在一起 GC 耗时 6 秒 使用 AB 进行压力测试: ab -c 100 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b6458b95.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 用户的吞吐量大于在 1927/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) JVM 服务器平均请求处理时间 0.51ms 左右 JVM 服务器发生了 400 多次 YGC,耗时 11 秒 ,没有 FGC ,加在一起 GC 耗时 11 秒 使用 AB 进行压力测试: ab -c 1000 -n 100000 http://127.0.0.1:8080/jvm/heap ![img](https://files.vps4cloud.com/i/2023/04/26/64491b67228cf.png) 测试结果显示: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 用户的吞吐量大于在 1657/每秒左右 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) JVM 服务器平均请求处理时间 0.6ms 左右 JVM 服务器发生了 400 多次 YGC,耗时 14 秒 ,还 1 次 FGC,3 秒左右,加在一起 GC 耗时 17 秒 ##### 内存优化总结 |VM 参数配置|性能指标|10 个并发/10 万总请求|100 个并发/10 万总请求|1000 个并发/10 万总请求| | -----------------------------------------------------------------------------------| --------| ---------------------| ----------------------| -----------------------| |默认:最大堆内存为 480MB, 初始化大小为 32MB 。 Eden 区 103mFrom 、To 3~4MOLd =18|吞吐量|1426/s|1262/s|1145/s| |平均处理时间|0.7ms|0.8ms|0.8ms|| |GC 耗时|17s|33s|42s|| |最大堆内存为 1.5G, 初始化大小为 1.5G Eden 区 375m From 、To 62MOLd =1000M|吞吐量|1205/s|989/s|749/s| |平均处理时间|0.83ms|1.01ms|1.3ms|| |GC 耗时|34s|52s|75s|| |最大堆内存为 1.5G, 初始化大小为 1.5G Eden 区 800m|吞吐量|1780/s|1927/s|1657/s| |平均处理时间|0.56ms|0.51ms|0.6ms|| |From 、To 100MOLd =500M|GC 耗时|6s|11s|17s| 一般情况下,高并发业务场景中,需要一个比较大的堆空间,而默认参数情况下,堆空间不会很大。所以我们有必要进行调整。 但是不要单纯的调整堆的总大小,要调整新生代和老年代的比例,以及 Eden 区还有 From 区,还有 To 区的比例。 所以在我们上述的测试中,调整方案二,得到结果是最好的。在三种测试情况下都能够有非常好的性能指标,同时 GC 耗时相对控制也较好。 对于调整方案一,就是单纯的加大堆空间,里面的比例不适合高并发场景,反而导致堆空间变大,没有明显减少 GC 的次数,但是每次 GC 需要检索对象 的堆空间更大,所以 GC 耗时更长。 **方案二**:调整为一个很大的新生代和一个较小的老年代.原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而老年代尽存放长期存活对象。 由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC ,因此我们可以通过增大新生代空间来降低 Minor GC 的频率。 单次 Minor GC 时间是由两部分组成:T1 (扫描新生代) 和 T2 (复制存活对象) 。 **默认情况**:一个对象在 Eden 区的存活时间为 500ms ,Minor GC 的时间间隔是 300ms ,因为这个对象存活时间 > 间隔时间,那么正常情况下,Minor GC 的时间为 :T1+T2。 **方案一**:整堆空间加大,但是新生代没有增大多少,对象在 Eden 区的存活时间为 500ms ,Minor GC 的时间可能会扩大到 400ms ,因为这个对象存 活时间 > 间隔时间,那么正常情况下,Minor GC 的时间为 :T1*1.5 (Eden 区加大了) +T2 **方案二**:当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms ,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不 存在复制存活对象了,所以再发生 Minor GC 的时间为:即 T1*2 (空间大了) +T2*0 可见,扩容后,Minor GC 时增加了 T1 ,但省去了 T2 的时间。 在 JVM 中,复制对象的成本要远高于扫描成本。如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如 果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。 这个就解释了之前的内存调整方案中,方案一为什么性能还差些,但是到了方案二话,性能就有明显的上升。 #### 推荐策略 1. 新生代大小选择 ![img](https://files.vps4cloud.com/i/2023/04/26/644924d6c4dbc.jpg) 响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择).在此种情况下,新生代收集发生的频率也是最小 的. 同时,减少到达老年代的对象. ![img](https://files.vps4cloud.com/i/2023/04/26/644924de5b7d6.jpg) 吞吐量优先的应用:尽可能的设置大,可能到达 Gbit 的程度. 因为对响应时间没有要求,垃圾收集可以并行进行,一般适合 8CPU 以上的应用. ![img](https://files.vps4cloud.com/i/2023/04/26/644924de5b7d6.jpg) 避免设置过小. 当新生代设置过小时会导致:1. MinorGC 次数更加频繁 2.可能导致 MinorGC 对象直接进入老年代,如果此时老年代满了,会触发 FullGC. 2. 老年代大小选择 响应时间优先的应用:老年代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数.如果堆设置小了,可 以会造成内存碎 片,高回收频率以及应用暂停而使用传统的标记清除方式; 如果堆大了,则需要较长的收集时间.最优化的方案,一般需要参考以下数据获得: 并发垃圾收集信息、持久代并发收集次数、传统 GC 信息、花在新生代和老年代回收上的时间比例。 吞吐量优先的应用:一般吞吐量优先的应用都有一个很大的新生代和一个较小的老年代.原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而 老年代尽存放长期存活对象。 ### GC 优化 #### GC 性能衡量指标 **吞吐量**: 这里的衡量吞吐量是指应用程序所花费的时间和系统总运行时间的比值。我们可以按照这个公式来计算 GC 的吞吐量:系统总运行时间 = 应用程序耗时 +GC 耗时。如果系统运行了 100 分钟,GC 耗时 1 分钟,则系统吞吐量为 99% 。GC 的吞吐量一般不能低于 95%。 **停顿时间**: 指垃圾回收器正在运行时,应用程序的暂停时间。对于串行回收器而言,停顿时间可能会比较长;而使用并发回收器, 由于垃圾收集器和应用程序交替 运行,程序的停顿时间就会变短,但其效率很可能不如独占垃圾收集器,系统的吞吐量也很可能会降低。 **垃圾回收频率**: 通常垃圾回收的频率越低越好,增大堆内存空间可以有效降低垃圾回收发生的频率,但同时也意味着堆积的回收对象越多,最终也会增加回收时的停顿 时间。所以我们需要适当地增大堆内存空间,保证正常的垃圾回收频率即可。 #### 分析 GC 日志 通过 JVM 参数预先设置 GC 日志,几种 JVM 参数设置如下: -XX:+PrintGC 输出 GC 日志 -XX:+PrintGCDetails 输出 GC 的详细日志 -XX:+PrintGCTimeStamps 输出 GC 的时间戳 (以基准时间的形式) -XX:+PrintGCDateStamps 输出 GC 的时间戳 (以日期的形式,如 2013-05-04T21:53:59.234+0800) -XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息 -Xloggc:../logs/gc.log 日志文件的输出路径 ##### 案例 比如:导出前面测试案例中,默认情况下的 gc 日志 java -jar -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:./gclogs jvm- 1.0-SNAPSHOT.jar 1 、进行 1000 个并发用户/10 万请求量的压力测试,得到 gclogs 日志 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b6b376ac.png) java -jar -XX:+PrintGCDateStamps -XX:+PrintGCDetails -Xloggc:./gc2logs -Xms1500m -Xmx1500m -Xmn1000m -XX:SurvivorRatio=8 jvm-1.0-SNAPSHOT.jar 1 、进行 1000 个并发用户/10 万请求量的压力测试,得到 gc2logs 日志 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b6deac29.png) 使用日志工具 gcViewer 这个工具的具体使用:见 [https://github.com/chewiebug/GCViewer#readme](https://github.com/chewiebug/GCViewer) 我们就暂停这项进行对比: ![img](https://files.vps4cloud.com/i/2023/04/26/64491b702fa4c.jpg) 明显第一个暂停总耗时比第二个要多很多,一个是 58 秒,一个是 15 秒左右,相差很多,这个本质上也可以分析出来,对于系统来说,第二个的 GC 日志 情况更加的好。 还有一个在线的工具 Gceasyhttps://gceasy.io/ 这个大家简单了解即可。 #### GC 调优策略 ##### 降低 Minor GC 频率 由于新生代空间较小,Eden 区很快被填满,就会导致频繁 Minor GC ,因此我们可以通过增大新生代空间来降低 Minor GC 的频率。 单次 Minor GC 时间是由两部分组成:T1 (扫描新生代) 和 T2 (复制存活对象) 。 情况 1:假设一个对象在 Eden 区的存活时间为 500ms,Minor GC 的时间间隔是 300ms,因为这个对象存活时间 > 间隔时间,那么正常情况下,Minor GC 的时间为 :T1+T2。 情况 2 :当我们增大新生代空间,Minor GC 的时间间隔可能会扩大到 600ms ,此时一个存活 500ms 的对象就会在 Eden 区中被回收掉,此时就不 存在复制存活对象了,所以再发生 Minor GC 的时间为:即 T1*2 (空间大了) +T2*0 可见,扩容后,Minor GC 时增加了 T1 ,但省去了 T2 的时间。 在 JVM 中,复制对象的成本要远高于扫描成本。如果在堆内存中存在较多的长期存活的对象,此时增加年轻代空间,反而会增加 Minor GC 的时间。如 果堆中的短期对象很多,那么扩容新生代,单次 Minor GC 时间不会显著增加。因此,单次 Minor GC 时间更多取决于 GC 后存活对象的数量,而非 Eden 区的大小。 这个就解释了之前的内存调整方案中,方案一为什么性能还差些,但是到了方案二话,性能就有明显的上升。 ##### 降低 Full GC 的频率 由于堆内存空间不足或老年代对象太多,会触发 Full GC ,频繁的 Full GC 会带来上下文切换,增加系统的性能开销。 减少创建大对象:在平常的业务场景中,我们一次性从数据库中查询出一个大对象用于 web 端显示。比如,一次性查询出 60 个字段的业务操作,这种大对象如果超过年轻代最大对象阈值,会被直接创建在老年代;即使被创建在了年轻代,由于年轻代的内存空间有限,通过 Minor GC 之后也会进入到老 年代。这种大对象很容易产生较多的 Full GC。 增大堆内存空间:在堆内存不足的情况下,增大堆内存空间,且设置初始化堆内存为最大堆内存,也可以降低 Full GC 的频率。 ##### 选择合适的 GC 回收器 如果要求每次操作的响应时间必须在 500ms 以内。这个时候我们一般会选择响应速度较快的 GC 回收器,堆内存比较小的情况下 (<6G) 选择 CMS (Concurrent Mark Sweep) 回收器和堆内存比较大的情况下 (>8G) G1 回收器. #### 总结 GC 调优是个很复杂、很细致的过程,要根据实际情况调整,不同的机器、不同的应用、不同的性能要求调优的手段都是不同的,这些都需要大家平时去 积累,去观察,去实践。 一般调优的思路都是“测试 - 分析 - 调优”三步走。 最后,给大家提个醒,任何调优都需要结合场景,明确已知问题和性能目标,不能为了调优而调优,以免引入新的 Bug ,带来风险和弊端。 ## JVM 性能调优之预估调优与问题排查 ### JVM 调优分类 调优是一个很大的概念,简单说就是把系统进行优化,但是站在一个系统的角度,能够干的事情太多了,我们一般把 JVM 调优分成以下三类: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) JVM 预调优 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 优化 JVM 运行环境 (慢、卡顿等) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 解决 JVM 中的问题 (OOM 等) 调优中,现象最明显的是 OOM ,因为有异常抛出,当然它也只是作为调优的一部分。预调优和优化运行环境估计很多人做的就是服务器重启而已。 上节课《JVM 性能调优之内存优化与 GC 优化》主要就是做的第一种和第二种工作。所以这里我还是进行总结的说一下: #### JVM 预调优 业务场景设定 调优是要分场景的,所以一定要明显你调优项目的场景设定,像现在大家都是微服务架构了,服务拆分出来以后更加适合做场景设定。 比如这个服务就注重吞吐量,这个服务注重用户的体验 (用户的响应时间) 等等。 ##### 无监控不优化 这里的监控指的是压力测试,能够看到结果,有数据体现的,不要用感觉去优化,所有的东西一定要有量化的指标,比如吞吐量,响应时间,服务器资 源,网络资源等等。总之一句话,无监控不优化。 ###### 处理步骤 ####### 计算内存需求 计算内存需求,内存不是越大越好,对于一般系统来说,内存的需求是弹性的,内存小,回收速度快也能承受。所以内存大小没有固定的规范。 虚拟机栈的大小在高并发情况下可以变小。 元空间 (方法区) 保险起见还是设定一个最大的值 (默认情况下元空间是没有大小限制的) ,一般限定几百 M 就够用了,为什么说还限定元空间。 举例子:一台 8G 的内存的服务器,如果运行时还有其他的程序加上虚拟机栈加上元空间, 占用超过 6 个 G 的话,那么我们设定堆是弹性的 (max=4G) , 那么其实堆空间拓展也超不过 2G ,所以这个时候限制元空间还是有必要的。 ####### 选定 CPU 对于系统来说, CPU 的性能是越高越好,这个按照你的预算来定 (CPU 的成本很高) 。 尤其是现在服务器做了虚拟机化之后,虚拟机的性能指标不能单看虚拟化后的参数指标,更应该看实际物理机的情况 (老师一次服务器性能排查血 泪史) ####### 选择合适的垃圾回收器 对于吞吐量优先的场景,就只有一种选择,就是使用 PS 组合 (Parallel Scavenge+Parallel Old ) 对于响应时间优先的场景,在 JDK1.8 的话优先 G1 ,其次是 CMS 垃圾回收器。 ####### 设定新生代大小、分代年龄 上节中我们用实战给大家解释到了: 吞吐量优先的应用:一般吞吐量优先的应用都有一个很大的新生代和一个较小的老年代.原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而 老年代尽存放长期存活对象。 ####### 设定日志参数 -XX:+PrintGC 输出 GC 日志 -XX:+PrintGCDetails 输出 GC 的详细日志 -XX:+PrintGCTimeStamps 输出 GC 的时间戳 (以基准时间的形式) -XX:+PrintGCDateStamps 输出 GC 的时间戳 (以日期的形式,如 2013-05-04T21:53:59.234+0800) -XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息 -Xloggc:../logs/gc.log 日志文件的输出路径 注意:一般记录日志的是,如果只有一个日志文件肯定不行,有时候一个高并发项目一天产生的日志文件就上 T ,其实记录日志这个事情,应该是运维干 的事情。 日志文件帮助我们分析问题。 #### 优化 JVM 运行环境 (慢、卡顿等) 一般造成 JVM 卡或者慢的原因无非两个部分,一个是 CPU 占用过高,一个是内存占用过高。所以这个时候需要我们进行问题的排查,进行具体的故障分 析。 #### 解决 JVM 中的问题 (OOM 等) 前面章节讲过,见:[内存溢出(重点)](#_bookmark4) ### 亿级流量电商系统 JVM 调优 #### 亿级流量系统 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b75b2d27.png) 亿级流量系统,其实就是每天点击量在亿级的系统,根据淘宝的一个官方的数据分析。 每个用户一次浏览点击 20~40 次之间,推测出每日活跃用户 ( 日活用户) 在 500 万左右。 同时结合淘宝的一个点击数据,可以发现,能够付费的也就是橙色的部分 (cart) 的用户,比例只有 10% 左右。 90% 的用户仅仅是浏览,那么我们可以通过图片缓存、Redis 缓存等技术,我们可以把 90% 的用户解决掉。 10% 的付费用户,大概算出来是每日成交 50 万单左右。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b7901dd4.png) ##### GC 预估 如果是普通业务,一般处理时间比较平缓,大概在 3,4 个小时处理,算出来每秒只有几十单,这个一般的应用可以处理过来 (不需要 JVM 预估调优) 另外电商系统中有大促场景 (秒杀、限时抢购等) ,一般这种业务是几种在几分钟。我们算出来大约每秒 2000 单左右的数据, 承受大促场景的使用 4 台服务器 (使用负载均衡) 。每台订单服务器也就是大概 500 单/秒 我们测试发现,每个订单处理过程中会占据 0.2MB 大小的空间 (什么订单信息、优惠券、支付信息等等) ,那么一台服务器每秒产生 100M 的内存空间, 这些对象基本上都是朝生夕死,也就是 1 秒后都会变成垃圾对象。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b7c33827.png) 加入我们设置堆的空间最大值为 3 个 G ,我们按照默认情况下的设置,新生代 1/3 的堆空间,老年代 2/3 的堆空间。Eden:S0:S1=8: 1 :1 我们推测出,old 区=2G, Eden 区=800M,S0=S1=100M 根据对象的分配原则 (对象优先在 Eden 区进行分配) ,由此可得,8 秒左右 Eden 区空间满了。 每 8 秒触发一个 MinorGC (新生代垃圾回收) ,这次 MinorGC 时,JVM 要 STW ,但是这个时候有 100M 的对象是不能回收的 (线程暂停,对象需要 1 秒后都会变成垃圾对象) ,那么就会有 100M 的对象在本次不能被回收 (只有下次才能被回收掉) 所以经过本次垃圾回收后。本次存活的 100M 对象会进入 S0 区,但是由于另外一个 JVM 对象分配原则 (如果在 Survivor 空间中相同年龄所有对象大小的 总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄) 所以这样的对象本质上不会进去 Survivor 区,而是进入老年代 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b7eb36f3.jpg) 所以我们推算,大概每个 8 秒会有 100M 的对象进入老年代。大概 20*8=160 秒,也就是 2 分 40 秒左右 old 区就会满掉,就会触发一次 FullGC,一般来说, 这次 FullGC 是可以避免的,同时由于 FullGC 不单单回收老年代 + 新生代,还要回收元空间,这些 FullGC 的时间可能会比较长 (老年代回收的朝生夕死的对 象,使用标记清除/标记整理算法决定了效率并不高, 同时元空间也要回收一次,进一步加大 GC 时间) 。 所以问题的根本就是做到如何避免没有必要的 FullGC ##### GC 预估调优 我们在项目中加入 VM 参数: -Xms3072M -Xmx3072M -Xmn2048M -XX:SurvivorRatio=7 -Xss256K -XX:MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M -XX: MaxTenuringThreshold=2 -XX: ParallelGCThreads=8 -XX:+UseConcMarkSweepGC 1 、首先看一下堆空间:old 区=1G ,Eden 区=1.4G,S0=S1=300M ![img](https://files.vps4cloud.com/i/2023/04/26/64491b81366ec.png) 2 、那么第一点,Eden 区大概需要 14 秒才能填满,填满之后,100M 的存活对象会进入 S0 区 (由于这个区域变大,不会触发动态年龄判断) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b8713c00.png) 3 、再过 14 秒,Eden 区,填满之后,还是剩余 100M 的对象要进入 S1 区。但是由于原来的 100M 已经是垃圾了 (过了 14 秒了) ,所以,S1 也只会 有 Eden 区过来的 100M 对象,S0 的 100M 已经别回收,也不会触发动态年龄判断。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b89671db.jpg) 4 、反反复复,这样就没有对象会进入 old 区,就不会触发 FullGC, 同时我们的 MinorGC 的频次也由之前的 8 秒变为 14 秒,虽然空间加大,但是换来 的还是 GC 的总时间会减少。 5 、-Xss256K -XX: MetaspaceSize= 128M -XX:MaxMetaspaceSize= 128M 栈一般情况下很少用到 1M。所以为了线程占用内存更少,我们可以减少到 256K 元空间一般启动后就不会有太多的变化,我们可以设定为 128M ,节约内存空间。 6 、-XX:MaxTenuringThreshold=2 这个是分代年龄 (年龄为 2 就可以进入老年代) ,因为我们基本上都使用的是 Spring 架构,Spring 中很多的 bean 是 长期要存活的,没有必要在 Survivor 区过渡太久,所以可以设定为 2 ,让大部分的 Spring 的内部的一些对象进入老年代。 7 、-XX:ParallelGCThreads=8 线程数可以根据你的服务器资源情况来设定 (要速度快的话可以设置大点,根据 CPU 的情况来定,一般设置成 CPU 的整 数倍) ![img](https://files.vps4cloud.com/i/2023/04/26/64491b8c7101a.png) 8 、-XX:+UseConcMarkSweepGC 因为这个业务响应时间优先的,所以还是可以使用 CMS 垃圾回收器或者 G1 垃圾回收器。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b8edd22c.png) ## JVM 调优实战 ### 项目介绍 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b91b5662.png) ### 代码介绍 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b9474188.png) 在 Linux 服务跑起来 java -cp ref-jvm3.jar -XX:+PrintGC -Xms200M -Xmx200M ex13. FullGCProblem ### CPU 占用过高排查实战 1.先通过 top 命令找到消耗 cpu 很高的进程 id 假设是 2732 top 命令是我们在 Linux 下最常用的命令之一,它可以实时显示正在执行进程的 CPU 使用率、内存使用率以及系统负载等信息。其中上半部分显示的是 系统的统计信息,下半部分显示的是进程的使用率统计信息。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b9720d19.png) 2.执行 top -p 2732 单独监控该进程 3 、在第 2 步的监控界面输入 H ,获取当前进程下的所有线程信息 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b9a296c5.png) 4 、找到消耗 cpu 特别高的线程编号,假设是 2734 (要等待一阵) 5 、执行 jstack 123456 对当前的进程做 dump ,输出所有的线程信息 ![img](https://files.vps4cloud.com/i/2023/04/26/64491b9d93376.png) 6 将第 4 步得到的线程编号 11354 转成 16 进制是 0x7b ![img](https://files.vps4cloud.com/i/2023/04/26/64491ba04f31d.png) 也可以通过计算器来换算。 7 根据第 6 步得到的 0x7b 在第 5 步的线程信息里面去找对应线程内容 8 解读线程信息,定位具体代码位置 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ba2b66b8.png) 发现找是 VM 的线程占用过高,我们发现我开启的参数中,有垃圾回收的日志显示,所以我们要换一个思路,可能是我们的业务线程没问题,而是垃圾 回收的导致的。 (代码中有打印 GC 参数,生产上可以使用这个 jstat –gc 来统计,达到类似的效果) 是用于监视虚拟机各种运行状态信息的命令行工具。它可以显示本地或者远程虚拟机进程中的类装载、内存、垃圾收集、JIT 编译等运行数据,在没有 GUI 图形界面,只提供了纯文本控制台环境的服务器上,它将是运行期定位虚拟机性能问题的首选工具。 假设需要每 250 毫秒查询一次进程 13616 垃圾收集状况,一共查询 10 次,那命令应当是:jstat-gc 13616 250010 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ba6168a7.png) 使用这个大量的 FullGC 了 还抛出了 OUT Of Memory ![img](https://files.vps4cloud.com/i/2023/04/26/64491ba8ecb8a.png) S0C:第一个幸存区的大小 S1C:第二个幸存区的大小 S0U:第一个幸存区的使用大小 S1U:第二个幸存区的使用大小 EC:伊甸园区的大小 EU:伊甸园区的使用大小 OC:老年代大小 OU:老年代使用大小 MC:方法区大小 MU:方法区使用大小 CCSC:压缩类空间大小 CCSU:压缩类空间使用大小 YGC:年轻代垃圾回收次数 YGCT:年轻代垃圾回收消耗时间 FGC:老年代垃圾回收次数 FGCT:老年代垃圾回收消耗时间 GCT:垃圾回收消耗总时间 怎么办?OOM 了. 看到这个里面 CPU 占用过高是什么导致的? 是业务线程吗?不是的,这个是 GC 线程占用过高导致的。JVM 在疯狂的进行垃圾回收,(再回顾下之前的知识) ,JVM 中默认的垃圾回收器是多线程的,所以多线程在疯狂回收,导致 CPU 占用过高。 ### 内存占用过高内存占用过高思路 用于生成堆转储快照 ( 一般称为 heapdump 或 dump 文件) 。jmap 的作用并不仅仅是为了获取 dump 文件,它还可以查询 finalize 执行队列、Java 堆和永久代的详细信息,如空间使用率、当前用的是哪种收集器等。和 jinfo 命令一样,jmap 有不少功能在 Windows 平台下都是受限的,除了生成 dump 文件的 -dump 选项和用于查看每个类的实例、 空间占用统计的-histo 选项在所有操作系统都提供之外 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bac58a6b.png) 把 JVM 中的对象全部打印出来, 但是这样太多了,那么我们选择前 20 的对象展示出来, jmap –histo 1196 | head -20 ![img](https://files.vps4cloud.com/i/2023/04/26/64491baf18e57.png) ![img](https://files.vps4cloud.com/i/2023/04/26/6449218811505.png) 定位问题的关键,就是这条命令。 很多个 88 万个对象。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bb265c95.png) ##### 问题总结 (找到问题) 一般来说,前面这几行,就可以看出,到底是哪些对象占用了内存。 这些对象回收不掉吗?是的,这些对象回收不掉,这些对象回收不掉,导致了 FullGC,里面还有 OutOfMemory。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bb549fbc.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bb787efe.png) 任务数多于线程数,那么任务会进入阻塞队列,就是一个队列,你进去,排队,有机会了,你就上来跑。 但是同学们,因为代码中任务数一直多于线程数,所以每 0.1S,就会有 50 个任务进入阻塞对象,50 个任务底下有对象,至少对象送进去了,但是没执行。 所以导致对象一直都在,同时还回收不了。 为什么回收不了。Executor 是一个 GCroots ![img](https://files.vps4cloud.com/i/2023/04/26/64491bbab948a.png) 所以堆中,就会有对象 80 万个,阻塞队列中 80 万个任务,futureTask 。并且这些对象还回收不了。 ### 总结 在 JVM 出现性能问题的时候。 (表现上是 CPU100% ,内存一直占用) 1 、 如果 CPU 的 100% ,要从两个角度出发,一个有可能是业务线程疯狂运行,比如说想很多死循环。还有一种可能性,就是 GC 线程在疯狂的回收,因 为 JVM 中垃圾回收器主流也是多线程的,所以很容易导致 CPU 的 100% 2 、 在遇到内存溢出的问题的时候,一般情况下我们要查看系统中哪些对象占用得比较多,我的是一个很简单的代码,在实际的业务代码中,找到对应的 对象,分析对应的类,找到为什么这些对象不能回收的原因,就是我们前面讲过的可达性分析算法,JVM 的内存区域,还有垃圾回收器的基础,当然, 如果遇到更加复杂的情况,你要掌握的理论基础远远不止这些 (JVM 很多理论都是排查问题的关键) ### 常见问题分析 #### 超大对象 代码中创建了很多大对象 , 且一直因为被引用不能被回收,这些大对象会进入老年代,导致内存一直被占用,很容易引发 GC 甚至是 OOM #### 超过预期访问量 通常是上游系统请求流量飙升,常见于各类促销/秒杀活动,可以结合业务流量指标排查是否有尖状峰值。 比如如果一个系统高峰期的内存需求需要 2 个 G 的堆空间,但是堆空间设置比较小,导致内存不够,导致 JVM 发起频繁的 GC 甚至 OOM。 #### 过多使用 Finalizer 过度使用终结器 (Finalizer) ,对象没有立即被 GC ,Finalizer 线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的 CPU 时间较少,因此 它永远也赶不上主线程的步伐,程序消耗了所有的可用资源,最后抛出 OutOfMemoryError 异常。 #### 内存泄漏 大量对象引用没有释放,JVM 无法对其自动回收。 长生命周期的对象持有短生命周期对象的引用 例如将 ArrayList 设置为静态变量,则容器中的对象在程序结束之前将不能被释放,从而造成内存泄漏 #### 连接未关闭 如数据库连接、网络连接和 IO 连接等,只有连接被关闭后,垃圾回收器才会回收对应的对象。 #### 变量作用域不合理 例如,1.一个变量的定义的作用范围大于其使用范围,2.如果没有及时地把对象设置为 null #### 内部类持有外部类 Java 的非静态内部类的这种创建方式,会隐式地持有外部类的引用,而且默认情况下这个引用是强引用,因此,如果内部类的生命周期长于外部类的生命 周期,程序很容易就产生内存泄漏 如果内部类的生命周期长于外部类的生命周期,程序很容易就产生内存泄漏 (垃圾回收器会回收掉外部类的实例,但由于内部类持有外部类的引用,导 致垃圾回收器不能正常工作) 解决方法:你可以在内部类的内部显示持有一个外部类的软引用(或弱引用),并通过构造方法的方式传递进来,在内部类的使用过程中,先判断一下外部 类是否被回收; #### Hash 值改变 在集合中,如果修改了对象中的那些参与计算哈希值的字段,会导致无法从集合中单独删除当前对象,造成内存泄露 (有代码案例 Node 类) #### 内存泄漏经典案例 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bbf9ae6d.png) ### 代码问题 代码问题和内存泄漏很大的关系,如果观察一个系统,每次进行 FullGC 发现堆空间回收的比例比较小,尤其是老年代,同时对象越来越多,这个时候可 以判断是有可能发生内存泄漏。 ### 内存泄漏 程序在申请内存后,无法释放已申请的内存空间。 ### 内存泄漏和内存溢出辨析 内存溢出:实实在在的内存空间不足导致; 内存泄漏:该释放的对象没有释放,常见于使用容器保存元素的情况下。 如何避免: 内存溢出:检查代码以及设置足够的空间 内存泄漏:一定是代码有问题 往往很多情况下, 内存溢出往往是内存泄漏造成的。 ### 我们一般优化的思路有一个重要的顺序: 1. 程序优化,效果通常非常大; 2. 扩容,如果金钱的成本比较小,不要和自己过不去; 3. 参数调优,在成本、吞吐量、延迟之间找一个平衡点。 ## 玩转 MAT 分析内存泄漏 我们前面讲过,我们可以使用 jmap – histo 这种命令去分析哪些对象占据着我们的堆空间。但是那是比较容易分析的问题,如果是遇到内存情况比较复杂 的情况,命令的方式是看不出来的,这个时候我们必须要借助一下工具。当然前提是通过 jmap 命令把整个堆内存的数据 dump 下来。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bc246cfc.jpg) ### 内存分析工具 #### VisualVM ![img](https://files.vps4cloud.com/i/2023/04/26/64491bc579acc.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bc796ee7.png) 这个是上节的那个案例抛出了 OOM 后到处的内存的 dump 日志信息,我们可以导入 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bc9e89af.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bccd93bd.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bcfb6f0f.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bd24e97c.png) VisualVm 属于比较寒酸的工具,基本上跟 jmap 之类的命令没多少区别,它只是可以事后看,通过 dump 信息来看,里面没有多少可以做分析的功能。 #### MAT 简介 MAT 工具是基于 Eclipse 平台开发的,本身是一个 Java 程序,是一款很好的内存分析工具,所以如果你的堆快照比较大的话,则需要一台内存比较大的 分析机器,并给 MAT 本身加大初始内存,这个可以修改安装目录中的 MemoryAnalyzer.ini 文件。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bd647a16.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bd8bd842.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bdbb3b78.png) ##### 概要 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bdee6eb5.png) ##### 柱状图 ![img](https://files.vps4cloud.com/i/2023/04/26/64491be1a37f0.png) ### MAT 中的 Incoming/Outgoing References 在柱状图中,我们看到,其实它显示的东西跟 jmap – histo 非常相似的,也就是类、实例、空间大小。 但是 MAT 有一个专业的概念,这个可以显示对象的引入和对象的引出。 在 Eclipse MAT 中,当右键单击任何对象时,将看到下拉菜单。如果选择“ListObjects”菜单项,则会注意到两个选项: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) with incoming references 对象的引入 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) with outgoing references 对象的引出 #### 案例解释理解 ![img](https://files.vps4cloud.com/i/2023/04/26/64491be569b1c.png) 代码中对象和引用关系如下: 对象 A 和对象 B 持有对象 C 的引用 对象 C 持有对象 D 和对象 E 的引用 ![img](https://files.vps4cloud.com/i/2023/04/26/64491be9084e3.jpg) 我们具体分析对象 C 的 Incoming references 和 Outgoing references 。 1 、 程序跑起来 ![img](https://files.vps4cloud.com/i/2023/04/26/64491bec00e09.png) 2 、 MAT 连接上 (MAT 不单单只打开 dump 日志,也可以打开正在运行的 JVM 进程,跟 arthas 有点类似,效果是一样的,只是一个是动态的,一个是日 志导出那个时刻的) ![img](https://files.vps4cloud.com/i/2023/04/26/64491beedf513.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bf323e44.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bf65bf34.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bf91f91f.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bfc4c7f7.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491bff48c2b.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c03abf92.png) 对象 C 的 incoming references 为对象 A 、对象 B 和 C 的类对象 (class 我们再来分析下 outgoing reference ![img](https://files.vps4cloud.com/i/2023/04/26/64491c06c1954.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c0a40bd1.png) 对象 C 的 outgoing references 为对象 D 、对象 E 和 C 的类对象 (class) 这个 outgoing references 和 incoming references 非常有用,因为我们做 MAT 分析一般时对代码不了解,排查内存泄漏也好,排查问题也好,垃圾回收中有 一个很重要的概念,可达性分析算法,那么根据这个引入和引出,我就可以知道这些对象的引用关系,在 MAT 中我们就可以知道比如 A, B,C, D,E, F 之间的 引用关系图,便于做具体问题的分析。 ### MAT 中的浅堆与深堆 浅堆 (shallow heap) 代表了对象本身的内存占用,包括对象自身的内存占用,以及“为了引用”其他对象所占用的内存。 深堆 ( Retained heap) 是一个统计结果,会循环计算引用的具体对象所占用的内存。但是深堆和“对象大小”有一点不同,深堆指的是一个对象被垃圾回 收后,能够释放的内存大小,这些被释放的对象集合,叫做保留集 (Retained Set) 需要说明一下:JAVA 对象大小=对象头 + 实例数据 + 对齐填充 非数组类型的对象的 shallow heap shallow_size=对象头 + 各成员变量大小之和 + 对齐填充 其中,各成员变量大小之和就是实例数据,如果存在继承的情况,需要包括父类成员变量 数组类型的对象的 shallow size shallow size=对象头 + 类型变量大小*数组长度 + 对齐填充,如果是引用类型,则是四字节或者八字节 (64 位系统) , 如果是 boolean 类型,则是一个字节 注意:这里 类型变量大小*数组长度 就是实例数据,强调是变量不是对象本身 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c0d532b9.png) #### 案例分析 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c1012b02.jpg) 对象 A 持有对象 B 和 C 的引用。 对象 B 持有对象 D 和 E 的引用。 对象 C 持有对象 F 和 G 的引用。 Shallow Heap 大小 请记住:对象的 Shallow heap 是其自身在内存中的大小。 #### 引用变动的影响 在下面的示例中,让对象 H 开始持有对 B 的引用。注意对象 B 已经被对象 A 引用了。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c17c6540.jpg) 在这种情况下,对象 A 的 Retained heap 大小将从之前的 70 减小到 40 个字节。 如果对象 A 被垃圾回收了,则将仅会影响 C 、F 和 G 对象的引用。因此,仅对象 C 、F 和 G 将被垃圾回收。另一方面, 由于 H 持有对 B 的活动引 用,因此对象 B 、D 和 E 将继续存在于内存中。因此,即使 A 被垃圾回收,B 、D 和 E 也不会从内存中删除。因此,A 的 Retained heap 大小为:= A 的 shallow heap 大小 + C 的 shallow heap 大小 + F 的 shallow heap 大小 + G 的 shallow heap 大小 = 10 bytes + 10 bytes + 10 bytes + 10 bytes = 40 bytes. 总结:我们可以看到在进行内存分析时,浅堆和深堆是两个非常重要的概念,尤其是深堆,影响着回收这个对象能够带来的垃圾回收的效果,所以在内存分析中,我们往往会去找那些深堆比较的大的对象,尤其是那些浅堆比较小但深堆比较大的对象,这些对象极有可能是问题对象。 ### 使用 MAT 进行内存泄漏检测 如果问题特别突出,则可以通过 Find Leaks 菜单快速找出问题。 运行以下代码, 我们开始跑程序: ![img](https://files.vps4cloud.com/i/2023/04/26/64491c1b6442a.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c1ee1e86.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c218dd99.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c246acbb.png) 这里一个名称叫做 king-thread 的线程,持有了超过 99% 的对象,数据被一个 HashMap 所持有。 这个就是内存泄漏的点,因为我代码中对线程进行了标识,所以像阿里等公司的编码规范中为什么一定要给线程取名字,这个是有依据的,如果不取名 字的话,这种问题的排查将非常困难。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c2bcd6d6.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c2f4baa3.png) 所以,如果是对于特别明显的内存泄漏,在这里能够帮助我们迅速定位,但通常内存泄漏问题会比较隐蔽,我们需要做更加复杂的分析。 ### 支配树视图 支配树列出了堆中最大的对象,第二层级的节点表示当被第一层级的节点所引用到的对象,当第一层级对象被回收时,这些对象也将被回收。这个工具 可以帮助我们定位对象间的引用情况,以及垃圾回收时的引用依赖关系。 支配树视图对数据进行了归类,体现了对象之间的依赖关系。我们通常会根据“深堆”进行倒序排序,可以很容易的看到占用内存比较高的几个对象, 点击前面的箭头,即可一层层展开支配关系 (依次找深堆明显比浅堆大的对象) 。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c332b030.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c37b6715.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c3ae5d31.png) 从上图层层分解,我们也知道,原来是 king-thread 的深堆和浅堆比例很多 (深堆比浅堆多很多、一般经验都是找那些浅堆比较小,同时深堆比较大的对 象) 1 、 一个浅堆非常小的 king-thread 持有了一个非常大的深堆 2 、 这个关系来源于一个 HashMap 3 、 这个 map 中有对象 A ,同时 A 中引用了 B ,B 中引用了 C 4 、 最后找到 C 中里面有一个 ArrayList 引用了一个大数据的数组。 经过分析,内存的泄漏点就在此。一个线程长期持有了 200 个这样的数组,有可能导致内存泄漏。 ### MAT 中内存对比 我们对于堆的快照,其实是一个“瞬时态” ,有时候仅仅分析这个瞬时状态,并不一定能确定问题,这就需要对两个或者多个快照进行对比,来确定一 个增长趋势。 我们导出两份 dump 日志,分别是上个例子中循环次数分别是 10 和 100 的两份日志 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c3f9a28e.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c43bdfbc.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c47638da.png) 对比:打开柱状图,要注意通过包来分组快速找到我们项目中对象的类 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c4b4e542.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c4e8a4b8.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c5305904.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c57b73ac.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644921a28f9b2.png) 经过内存日志的对比,分析出来这个类的对象的增长,也可以辅助到问题的定位 (快速增加的地方有可能存在内存泄漏) ### 线程视图 想要看具体的引用关系,可以通过线程视图。线程在运行中是可以作为 GC Roots 的。我们可以通过线程视图展示了线程内对象的引用关系,以及方法调 用关系,相对比 jstack 获取的栈 dump ,我们能够更加清晰地看到内存中具体的数据。 我们找到了 king-thread ,依次展开找到 holder 对象,可以看到内存的泄漏点 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c5c541b9.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c5f37dd7.png) 还有另外一段是陷入无限循环,这个是相互引用导致的 (进行问题排查不用被这种情况给误导了,这样的情况一般不会有问题---可达性分析算法的解决了 相互引用的问题) 。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c623d31a.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c65d21d9.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c69c7562.png) ### 柱状图视图 柱状图视图,可以看到除了对象的大小,还有类的实例个数。结合 MAT 提供的不同显示方式,往往能够直接定位问题。也可以通过正则过滤一些信息, 我们在这里输入 MAT ,过滤猜测的、可能出现问题的类,可以看到,创建的这些自定义对象,不多不少正好一百个。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c6ccf92c.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c703f222.png) 右键点击类,然后选择 incoming ,这会列出所有的引用关系。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c7458cb7.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c7784312.png) #### Path To GC Roots 被 JVM 持有的对象,如当前运行的线程对象,被 systemclass loader 加载的对象被称为 GC Roots , 从一个对象到 GC Roots 的引用链被称为 Pathto GC Roots, 通过分析 Path to GC Roots 可以找出 JAVA 的内存泄露问题,当程序不在访问该对象时仍存在到该对象的引用路径 (这个对象可能内存泄漏) 。 再次选择某个引用关系,然后选择菜单“Path To GC Roots” ,即可显示到 GC Roots 的全路径。通常在排查内存泄漏的时候,会选择排除虚弱软等引用。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c7aed3b2.png) 使用这种方式,即可在引用之间进行跳转,方便的找到所需要的信息 (这里从对象反推到了线程 king-thread) ,也可以快速定位到有内存泄漏的问题代码。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c7e5412c.png) ### 高级功能—OQL MAT 支持一种类似于 SQL 的查询语言 OQL (Object Query Language) ,这个查询语言 VisualVM 工具也支持。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c816d308.png) 查询 A 对象: select * from ex14.ObjectsMAT$A 查询包含 java 字样的所有字符串: select * from java.lang.String s wheretoString(s) like".*java.*" OQL 有比较多的语法和用法,若想深入了解,可以了解这个网址 http://tech.novosoft-us.com/products/oql_book.htm ### 实战 通过程序做一份 OOM 之前的 dump 日志。 代码见 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c872f2e2.png) java -jar -XX:+HeapDumpOnOutOfMemoryError jvm- 1.0-SNAPSHOT.jar ab -c 10 -n 1000 http://127.0.0.1 :8080/jvm/mat ![img](https://files.vps4cloud.com/i/2023/04/26/64491c85497da.png) 程序在 OOM 之前导出了 dump 日志,如下图: ![img](https://files.vps4cloud.com/i/2023/04/26/64491c8b4e84d.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c8deb0d0.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c9106ccf.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c93d1b62.png) 在这里我们就可以很明显地查看到是 ThreadLocal 这块的代码出现了问题。 #### 原因分析 ThreadLocal 是基于 ThreadLocalMap 实现的,这个 Map 的 Entry 继承了 WeakReference ,而 Entry 对象中的 key 使用了 WeakReference 封装,也就是说 Entry 中的 key 是一个弱引用类型,而弱引用类型只能存活在下次 GC 之前。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491c97c3f33.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c9b1f848.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491c9e353e2.png) 当把 threadlocal 变量置为 null 以后,没有任何强引用指向 threadlocal 实例,所以 threadlocal 将会被 gc 回收。 当发生一次垃圾回收,ThreadLocalMap 中就会出现 key 为 null 的 Entry ,就没有办法访问这些 key 为 null 的 Entry 的 value ,如果当前线程再迟迟不结束的 话 (肯定不会结束) ,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value ,而这块 value 永远不会被访问到了,所以存在着内存泄露。如下图: ![img](https://files.vps4cloud.com/i/2023/04/26/64491ca15599d.png) 只有当前 thread 结束以后,current thread 就不会存在栈中,强引用断开,Current Thread 、Map value 将全部被 GC 回收 (但是这 种情况很难) 。最好的做法是不在需要使用 ThreadLocal 变量后,都调用它的 remove()方法,清除数据。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ca498479.png) ### 总结 可以看到,上手 MAT 工具是有一定门槛的,除了其操作模式,还需要对我们前面介绍的理论知识有深入的理解,比如 GC Roots 、各种引用级别等。 如果不能通过大对象发现问题,则需要对快照进行深入分析。使用柱状图和支配树视图,配合引入引出和各种排序,能够对内存的使用进行整体的摸底。 由于我们能够看到内存中的具体数据,排查一些异常数据就容易得多。 上面这些问题通过分析业务代码,也不难发现其关联性。问题如果非常隐蔽,则需要使用 OQL 等语言,对问题一一排查、确认。 ## 直接内存与 JVM 源码分析 ### 直接内存 (堆外内存) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ca81688c.png) 直接内存有一种叫法,堆外内存。 直接内存(堆外内存)指的是 Java 应用程序通过直接方式从操作系统中申请的内存。这个差别与之前的堆、栈、方法区,那些内存都是经过了虚拟化。所 以严格来说,这里是指直接内存。 #### 直接内存有哪些? ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 使用了 Java 的 Unsafe 类,做了一些本地内存的操作; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) Netty 的直接内存 (Direct Memory) ,底层会调用操作系统的 malloc 函数。 ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) JNI 或者 JNA 程序,直接操纵了本地内存,比如一些加密库; JNI 是 Java Native Interface 的缩写,通过使用 [Java](https://baike.baidu.com/item/Java/85979) 本地接口书写程序,可以确保代码在不同的平台上方便移植。 JNA (Java Native Access ) 提供一组 Java 工具类用于在运行期间动态访问系统本地库 (native library:如 Window 的 dll) 而不需要编写任何 Native/JNI 代码。 开发人员只要在一个 [java 接口](https://baike.baidu.com/item/java接口/4939170)中描述目标 native library 的函数与结构,JNA 将自动实现 Java 接口到 native function 的映射。 JNA 是建立在 JNI 技术基础之上的一个 Java 类库,它使您可以方便地使用 java 直接访问动态链接库中的函数。 原来使用 JNI ,你必须手工用 C 写一个动态链接库,在 C 语言中映射 Java 的数据类型。 JNA 中,它提供了一个动态的 C 语言编写的转发器,可以自动实现 Java 和 C 的数据类型映射,你不再需要编写 C 动态链接库。 也许这也意味着,使用 JNA 技术比使用 JNI 技术调用动态链接库会有些微的性能损失。但总体影响不大,因为 JNA 也避免了 JNI 的一些平台配置的开销。 #### 代码案例 1、 Unsafe 类,-XX:MaxDirectMemorySize 参数的大小限制对这种是无效的 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cac8b99a.png) 2、ByteBuffer 的这种方式,受到 MaxDirectMemorySize 参数的大小限制 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cb0aa6fb.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cb40db61.png) ### 为什么要使用直接内存 直接内存,其实就是不受 JVM 控制的内存。相比于堆内存有几个优势: 1 、减少了垃圾回收的工作,因为垃圾回收会暂停其他的工作。 2 、加快了复制的速度。因为堆内在 flush 到远程时,会先复制到直接内存 (非堆内存) ,然后再发送,而堆外内存相当于省略掉了这个工作。 3 、可以在进程间共享,减少 JVM 间的对象复制,使得 JVM 的分割部署更容易实现。 4 、可以扩展至更大的内存空间。比如超过 1TB 甚至比主存还大的空间。 ### 直接内存的另一面 直接内存有很多好处,我们还是应该要了解它的缺点: 1 、 堆外内存难以控制,如果内存泄漏,那么很难排查 2 、 堆外内存相对来说,不适合存储很复杂的对象。一般简单的对象比较适合。 ### 直接内存案例和场景分析 #### 内存泄漏案例 工作中经常会使用 Java 的 Zip 函数进行压缩和解压,这种操作在一些对传输性能较高的的场景经常会用到。 程序将会申请 1kb 的随机字符串,然后不停解压。为了避免让操作系统陷入假死状态,我们每次都会判断操作系统内存使用率,在达到 60% 的时候, 我们将挂起程序 (不在解压,只不断的让线程休眠) 通过访问 8888 端口,将会把内存阈值提高到 85%。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cb78e1d5.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cbaea177.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cbe9d071.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cc062818.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cc350a9a.png) 程序打包上传到 CenterOS 的服务器中 (服务器内存 4G) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cc6345dd.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cca4dcdf.png) 使用以下命令把程序跑起来: java -cp ref-jvm3.jar -XX:+PrintGC -Xmx1G -Xmn1G -XX:+AlwaysPreTouch -XX:MaxMetaspaceSize=10M -XX: MaxDirectMemorySize=10Mex15.LeakProblem ![img](https://files.vps4cloud.com/i/2023/04/26/64491ccd4f211.png) 参数解释: 分别使用 Xmx 、MaxMetaspaceSize 、MaxDirectMemorySize 这三个参数限制了堆、元空间、直接内存的大小。 AlwaysPreTouch 这个参数,在 JVM 启动的时候,就把它所有的内存在操作系统分配了,默认情况下,此选项是禁用的,并且所有页面都在 JVM 堆空间填 充时提交。我们为了减少内存动态分配的影响,把这个值设置为 True。 这个程序很快就打印一下显示,这个证明操作系统内存使用率,达到了 60%。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cd038ac3.png) 通过 top 命令查看,确实有一个进程占用了很高的内存, VIRT:virtual memory usage 虚拟内存 1 、 进程“需要的”虚拟内存大小,包括进程使用的库、代码、数据等 2 、假如进程申请 100m 的内存,但实际只使用了 10m ,那么它会增长 100m ,而不是实际的使用量 RES:resident memory usage 常驻内存 达到了 1.5G 如果申请 100m 的内存,实际使用 10m ,它只增长 10m ,与 VIRT 相反 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cd3c82be.png) #### 常规排查方式 按照之前的排查方式,如果碰到内存占用过高,我们使用 top 命令来跟踪,然后使用 jmap – heap 来显示 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cd682040.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cd93c82a.png) 我们发现这个 3468 的 java 进程, 占据的堆空间是比较小的,合计数远远小于 top 命令看到的 1.5G 我们怀疑是不是虚拟机栈占用过高。于是使用 jstack 命令来看下线程 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cdc27ed0.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cdeae975.png) 发现也就那么 10 来个左右的线程,这块占用的空间肯定也不多。 jmap -histo 3468 | head -20 显示占用内存最多的对象 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ce1b2265.png) 发现这个才 20 多 M ,没有达到 1.5G 发现不了,我们前面学过 MAT ,我们把内存 dump 下来,放到 MAT 中进行分析。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ce4a889a.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491ce6d91a8.png) 发现没什么问题?堆空间也好,其他空间也好,这些都没有说的那么大的内存 1.5G 左右。 #### 使用工具排查 这种情况应该是发生了直接内存泄漏。 如果要跟踪本地内存的使用情况,一般需要使用 NMT ##### NMT NativeMemoryTracking ,是用来追踪 Native 内存的使用情况。通过在启动参数上加入 -XX:NativeMemoryTracking=detail 就可以启用。使用 jcmd (jdk 自 带) 命令,就可查看内存分配。 Native Memory Tracking (NMT) 是 Hotspot VM 用来分析 VM 内部内存使用情况的一个功能。我们可以利用 jcmd (jdk 自带) 这个工具来访问 NMT 的数据。 NMT 必须先通过 VM 启动参数中打开,不过要注意的是,打开 NMT 会带来 5%- 10% 的性能损耗。 在服务器上重新运行程序: java -cp ref-jvm3.jar -XX:+PrintGC -Xmx1G -Xmn1G -XX:+AlwaysPreTouch -XX:MaxMetaspaceSize=10M -XX:MaxDirectMemorySize=10M -XX: NativeMemoryTracking=detail ex15.LeakProblem ![img](https://files.vps4cloud.com/i/2023/04/26/64491ce9e5217.png) jcmd $pid VM.native_memory summary 可惜的是,这个工具并一样很烂,看到我们这种泄漏的场景。下面这点小小的空间,是不能和 1~2GB 的内存占用相比的。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491ceb77d88.png) 其实问题排查到这里,很明显了,这块的问题排查超出了一般 java 程序员的范畴了 (说白了就是你做到这点就 OK 了,继续排查就是在干操作系统和其他 的语言相关的问题排查了) ,如果你有时间,有兴趣,我推荐你使用 perf 这个工具,这个工具安装很容易,但是容易遇到操作系统内核一些功能没支持, 也分析不了,这里我就不多去花时间去分析: Perf 安装:yum install perf #### 内存泄漏问题解决 主要的是解决问题。 这个程序可以访问服务器的 8888 端口,这将会把内存使用的阈值增加到 85% ,我们的程序会逐渐把这部分内存占满 curl http://127.0.0.1:8888 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cef6bd9b.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cf82630a.png) 特意这么做也是为了方便你的观察,通过内存的增长我们可以大致知道问题的点。 ##### 问题关键点 GZIPInputStream 使用 Inflater 申请堆外内存、我们没有调用 close() 方法来主动释放。如果忘记关闭,Inflater 对象的生命会延续到下一次 GC ,有一点 类似堆内的弱引用。在此过程中,堆外内存会一直增长。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491cfb6728a.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491cfd79711.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d0061786.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d02aa7a0.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d054a265.png) ##### 问题修复 调用 close() 方法来主动释放,放置内存泄漏 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d085acb9.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d0b2cc8a.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d0eec4b6.png) Top – p <pid> 查看,这个内存和堆内存的情况就比较符合 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d1129656.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d14952d3.jpg) 问题得到解决。 #### 直接内存总结 直接内存主要是通过 DirectByteBuffer 申请的内存,可以使用参数“MaxDirectMemorySize”来限制它的大小 其他堆外内存(直接内存) ,主要是指使用了 Unsafe 或者其他 JNI 手段直接直接申请的内存。这种情况下没有任何参数能够阻挡它们,要么靠它自己去释 放一些内存,要么等待操作系统对它来处理。所以如果你对操作系统底层以及内存分配使用不熟悉,最好不要使用这块,尤其是 Unsafe 或者其他 JNI 手 段直接直接申请的内存. 那为什么要讲呢,EhCache 这种缓存框架,提供了多种策略,可以设定将数据存储在非堆上。 还有像 RocketMQ 都走了堆外分配,所以我们又必须要去了解他。 ### JVM 源码分析 #### 使用 SourceInsight 来查看 OpenJDK 源代码 如何查看可以见一下文档。工具使用的 SourceInsight https://cloud.tencent.com/developer/article/1585224 这个地方注意一点,如果你要读全部的 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d18c46ed.png) 这个会耗时久点,但是可以关联全部的。 ![img](https://files.vps4cloud.com/i/2023/04/26/644921bd222c4.png) 如果我们没有通过-XX: MaxDirectMemorySize 来指定最大的堆外内存,那么默认的最大堆外内存是多少呢? 一般来说,如果没有显示的设置-XX: MaxDirectMemorySize 参数,通过 ByteBuffer 能够分配的直接内存空间大小就是堆的最大大小。 对应参数-Xmx ,真的是这么样吗? ![img](https://files.vps4cloud.com/i/2023/04/26/64491d1c0fe39.jpg) ##### 案例分析 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d1f94148.jpg) 1、VM 参数配置:-XX: MaxDirectMemorySize=100m ![img](https://files.vps4cloud.com/i/2023/04/26/64491d21ede52.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d2469a31.jpg) 2、 VM 参数配置:-XX:MaxDirectMemorySize=128m ![img](https://files.vps4cloud.com/i/2023/04/26/64491d28b8a4d.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d2bafb25.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/644921ce5e733.png) ![img](https://files.vps4cloud.com/i/2023/04/26/644921cc5471e.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d3038ba7.jpg) 这个地方居然报错了,果然童话里都是骗人的。 如果没有显示的设置-XX: MaxDirectMemorySize 参数,通过 ByteBuffer 能够分配的直接内存空间大小就是堆的最大大小。 (这句话明显有问题) ![img](https://files.vps4cloud.com/i/2023/04/26/644921dc94a80.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d32935b2.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/644921eb2eaba.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d3504a10.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/644921f8ea2e0.png) 所以案例 4 为什么会 OOM! ![img](https://files.vps4cloud.com/i/2023/04/26/644921ffd1b4a.png) ##### 源码分析 我们从代码出发,依次来找 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d38348f5.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64492208d3689.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d3bb4a5b.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d3e87321.jpg) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d41b45bd.jpg) 看到上面的代码之后不要误以为默认的最大值是 64M?其实不是的。 说到这个值得从 java.lang.System 这个类的初始化说起 上 面 这 个 方 法 在 jvm 启 动 的 时 候 对 System 这 个 类 做 初 始 化 的 时 候 执 行 的 , 因 此 执 行 时 间 非 常 早 , 我 们 看 到 里 面 调 用 了 sun.misc.VM.saveAndRemoveProperties(props): ![img](https://files.vps4cloud.com/i/2023/04/26/64491d454f997.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d4882301.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d4c2afd2.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d505157c.png) 这个地方是一个 native 方法,这个是一个本地方法,本地方法里面怎么实现的。 这个地方就需要看 JVM 的源码。 像这种本地方法,在 VM 的源码中一般都是会把包名加上,因为是给 java 用的所以前缀上还有一个 java。 大致推算出 (其实已经知道了) 是 JVM 的这个函数 :Java_java_lang_Runtime_maxMemory ![img](https://files.vps4cloud.com/i/2023/04/26/64491d53d26d8.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d56a0ec0.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d5aa1999.png) 这个容量其实就是每一个代的大小相加,比如 YGen+OldGen 之类 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d5d6cbf5.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d5f9d16c.png) 在这里可以看到,新生代的最大值 = 新生代的最大值 - 一个 survivor 的大小。 为什么会这样,因为在新生代采用复制回收算法,一个幸存者区域是浪费的,所以实际空间最大大小要减去一个交换器的大小。 而老年代是没有空间浪费的,所以还是全区域。 也得出我们设置的-Xmx 的值里减去一个 survivor 的大小就是默认的堆外内存的大小。 ##### 总结 读 JVM 的源码确实可以解决不少问题,但读 JVM 的源码门槛很高 (C++ 的基础) ,同时 JVM 的源码体系非常大,如果想学源码的话,可以参考今天这个 场景,从场景切入 (找几个简单的场景) ,读源码要有目的,这样才能做到有的放矢,才能真正的提高自己的技术水平。 ## 深入 JVM 优化技术及 JVM 历史回顾与未来 ### 解释执行与 JIT Java 程序在运行的时候,主要就是执行字节码指令,一般这些指令会按照顺序解释执行,这种就是解释执行。 那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码,如果按照解释执行,效率是非常低的。 以上的这些代码称为热点代码。 所以,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。 完成这个任务的编译器,就称为即时编译器 (Just In Time Compiler) ,简称 JIT 编译器。 ### 即时编译器类型 在 HotS pot 虚拟机中,内置了两个 JIT ,分别为 C1 编译器和 C2 编译器。 #### C1 编译器 C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求,C1 也被称为 Client Compiler。 #### C2 编译器 C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这种即时编译 也被称为 Server Compiler。 ### 热点代码 热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但 对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。 JVM 提供了一个参数“-XX: ReservedCodeCacheSize” ,用来限制 CodeCache 的大小。也就是说,JIT 编译后的代码都会放在 CodeCache 里。 如果这个空间不足,JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。 通过 java -XX:+PrintFlagsFinal –version 查询: ![img](https://files.vps4cloud.com/i/2023/04/26/64491d62eca69.png) #### 热点探测 (J9 使用过采样的热点探测技术,但是缺点是很难精确的确认一个方法的热度,所以这个技术没必要了解) 在 HotS pot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法” 虚拟机为每个方法准备了两类计数器:方法调用计数器 (Invocation Counter) 和回边计数器 (Back Edge Counter) 。在确定虚拟机运行参数的前提下,这 两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。 ##### 方法调用计数器 用于统计方法被调用的次数,方法调用计数器的默认阈值在 客户端模式下是 1500 次,在服务端模式下是 10000 次(我们用的都是服务端,java –version 查询) ,可通过 -XX: CompileThreshold 来设定 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d6513b47.png) 通过 java -XX:+PrintFlagsFinal –version 查询: ![img](https://files.vps4cloud.com/i/2023/04/26/64491d67efed7.png) ##### 回边计数器 用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge) ,该值用于计算是否触发 C1 编译的阈值, 在不开启分层编译的情况下,在服务端模式下是 10700。 回边计数器阈值 =方法调用计数器阈值 (CompileThreshold) × (OSR 比率 (OnStackReplacePercentage) -解释器监控比率 (InterpreterProfilePercentage) / 100 回边计数器阈值 =10000× (140-33) 其中 OnStackReplacePercentage 默认值为 140,InterpreterProfilePercentage 默认值为 33,如果都取默认值,那 Server 模式虚拟机回边计数器的阈值为 10700. 通过 java -XX:+PrintFlagsFinal –version 查询: ![img](https://files.vps4cloud.com/i/2023/04/26/64491d6a87cd5.png) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d6d5085d.png) 可通过 -XX: OnStackReplacePercentage=N 来设置; 建立回边计数器的主要目的是为了触发 OSR (On StackReplacement) 编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈 值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。 #### JVM 的运用 在 Java7 之前,需要根据程序的特性来选择对应的 JIT ,虚拟机默认采用解释器和其中一个编译器配合工作。 Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数 “-client”“-server” 强制指定虚拟机的即 时编译模式。 ### 分层编译 而在分层编译的情况下,-XX: CompileThreshold 指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边 计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。 而在分层编译的情况下,-XX: OnStackReplacePercentage 指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。 在 Java8 中,默认开启分层编译。 通过 java -version 命令行可以直接查看到当前系统使用的编译模式(默认分层编译) ![img](https://files.vps4cloud.com/i/2023/04/26/64491d7053983.png) 使用“-Xint”参数强制虚拟机运行于只有解释器的编译模式下 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d7261afd.png) 使用“-Xcomp”强制虚拟机运行于只有 JIT 的编译模式下 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d749d37f.png) JVM 的执行状态分为了 5 个层次:(不重要、了解即可) ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 第 0 层:程序解释执行,默认开启性能监控功能 (Profiling) ,如果不开启,可触发第二层编译; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 第 2 层:也称为 C1 编译,开启 Profiling ,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。 ### 编译优化技术 JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码. #### 方法内联 方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。 例如以下方法: ![img](https://files.vps4cloud.com/i/2023/04/26/64491d782e846.png) 最终会被优化为: ![img](https://files.vps4cloud.com/i/2023/04/26/64491d7b54895.png) JVM 会自动识别热点方法,并对它们使用方法内联进行优化。 我们可以通过 -XX:CompileThreshold 来设置热点方法的阈值。 但要强调一点,热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。 而方法体的大小阈值,我们也可以通过参数设置来优化: 经常执行的方法,默认情况下,方法体大小小于 325 字节的都会进行内联,我们可以通过 -XX:FreqInlineSize=N 来设置大小值; ![img](https://files.vps4cloud.com/i/2023/04/26/64491d7d79493.png) 不是经常执行的方法,默认情况下,方法大小小于 35 字节才会进行内联,我们也可以通过 -XX:MaxInlineSize=N 来重置大小值。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d7f8424a.png) ##### 代码演示 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d8295dd4.png) 设置 VM 参数:-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -XX:+PrintCompilation //在控制台打印编译过程信息 -XX:+UnlockDiagnosticVMOptions //解锁对 JVM 进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对 JVM 进行诊断 -XX:+PrintInlining //将内联方法打印出来 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d84c5791.jpg) 如果循环太少,则不会触发方法内联 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d87eade1.jpg) 热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联: ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a6b246e.jpg) 在编程中,避免在一个方法中写大量代码,习惯使用小方法体; ![img](https://files.vps4cloud.com/i/2023/04/22/6443c9a653a47.jpg) 尽量使用 final 、private 、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。 #### 锁消除 在非线程安全的情况下,尽量不要使用线程安全容器,比如 StringBuffer 。由于 StringBuffer 中的 append 方法被 Synchronized 关键字修饰,会使用到锁, 从而导致性能下降。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d9203844.png) 但实际上,在以下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其 它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。 ![img](https://files.vps4cloud.com/i/2023/04/26/644922547ba90.png) 下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这 个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d94b2747.png) ![img](https://files.vps4cloud.com/i/2023/04/26/6449225bdd8e9.png) -XX:+EliminateLocks 开启锁消除 (jdk1.8 默认开启,其它版本未测试) -XX:- EliminateLocks 关闭锁消除 我们把锁消除关闭---测试发现性能差别有点大 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d977d486.png) ![img](https://files.vps4cloud.com/i/2023/04/26/6449226218b4b.png) #### 标量替换 逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。 将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换 (前提是需要开启逃逸分析) 。 ![img](https://files.vps4cloud.com/i/2023/04/26/64491d9ba7711.png) -XX:+DoEscapeAnalysis 开启逃逸分析 (jdk1.8 默认开启) -XX:- DoEscapeAnalysis 关闭逃逸分析 -XX:+EliminateAllocations 开启标量替换 (jdk1.8 默认开启) -XX:- EliminateAllocations 关闭标量替换 最后修改:2023 年 06 月 05 日 © 允许规范转载 打赏 赞赏作者 支付宝微信 赞 如果觉得我的文章对你有用,请随意赞赏
10 条评论
你的文章让我感受到了快乐,每天都要来看一看。 https://www.4006400989.com/qyvideo/45850.html
《寻找卡米洛城》动画片高清在线免费观看:https://www.jgz518.com/xingkong/72546.html
《生如逆旅(蓝光高清版)》短片剧高清在线免费观看:https://www.jgz518.com/xingkong/160466.html
你的文章内容非常专业,让人佩服。 https://www.4006400989.com/qyvideo/67769.html
《第58届韩国百想艺术大赏》日韩综艺高清在线免费观看:https://www.jgz518.com/xingkong/121467.html
《传世宝藏》剧情片高清在线免费观看:https://www.jgz518.com/xingkong/63293.html
你的文章内容非常精彩,让人回味无穷。 https://www.yonboz.com/video/68762.html
看到你的文章,我仿佛感受到了生活中的美好。 https://www.4006400989.com/qyvideo/7204.html
你的才华让人瞩目,期待你的更多文章。 https://www.yonboz.com/video/23741.html
《唇上之歌》剧情片高清在线免费观看:https://www.jgz518.com/xingkong/67521.html