(三)JVM成神路之全面详解执行引擎子系统、JIT即时编译原理与分派实现

·  阅读 575

引言

执行引擎子系统是JVM的重要组成部分之一,在JVM系列的开篇曾提到:JVM是一个架构在平台上的平台,虚拟机是一个相似于“物理机”的概念,与物理机一样,都具备代码执行的能力。但虚拟机与物理机最大的不同在于:物理机的执行引擎是直接建立在处理器、高速缓存、平台指令集与操作系统层面上的,物理机的执行引擎可以直接调用各处资源对代码进行直接执行,而虚拟机则是建立在软件层面上的平台,它的执行引擎则是负责解释编译执行自身定义的指令集代码。同时,也正因Java设计出了JVM虚拟机的结构,从而才使得Java可以不受物理平台限制,能够真正实现“一次编译,到处执行”的理念。

对于执行引擎这块的知识,对于理解JVM是有很大帮助的,但JVM相关现有的文章/书籍资料对这块却少有提及或者泛泛而谈,本篇文章则是准备对JVM的执行引擎子系统进行全面的阐述。

一、机器码、指令集与汇编语言、高级语言的关系

在准备对JVM的执行引擎进行分析之前,首先得搞明白机器码、指令集、汇编语言以及高级语言之间的关系,只有当搞清楚这几者之间的关系后才能更好的弄懂JVM的执行引擎原理。

1.1、机器码

机器码也被称为机器指令码,也就是指各种由二进制编码方式表示的指令(011101、11110等),最开始的程序员就是通过这种方式编写程序,用这种方式编写出的代码可以直接被CPU读取执行,因为最贴近硬件机器,所以也是执行速度最快的指令。但因为这种指令和CPU之间是紧紧相关的,所以不同种类的CPU对应的机械指令也不同。同时,机械指令都是由二进制数字组成的指令,对于人来说,实在太过繁杂、难以理解且不容易记忆,容易出错,最终指令的方式代替了这种编码方式。

1.2、指令与指令集

由于机器码都是由0和1组成的指令代码,可读性实在太差,所以慢慢的推出了指令,用于替代机器码的编码方式。指令是指将机械码中特定的0和1组成的序列,简化为对应的指令,如INC、DEC、MOV等,从可读性上来说,对比之前的二进制序列组成的机器码要好上许多。但由于不同的硬件平台的组成架构也不同,所以往往在执行一个指令操作时,对应的机器码也不同,所以不同的硬件平台就算是同一个指令(如INC),对应的机器码也不同。

同时,正是因为不同的硬件平台支持的指令是有些稍许不同的,所以每个平台所支持的指令则被称为对应平台的指令集。比如X86架构平台对应的X86指令集、ARM架构平台对应的ARM指令集等。

1.3、汇编语言

前面虽然通过了指令和指令集的方式替代了之前由0和1序列组成的机器码,但指令的可读性相对来说还是比较差的,所以人们又发明了汇编语言。在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)以及标号(Label)代替指令或操作数的地址。在不同的平台,汇编代码对应不同的指令集,但由于计算机只认机器码,所以通过汇编语言编写的程序必须还要经过汇编阶段,变为计算机可识别的机器指令码才可执行。

1.4、高级语言

为了使得开发人员编写程序更为简易一些,后面就涌现了各种高级语言,如Java、Python、Go、Rust等。高级语言对比之前的机器码、指令、汇编等方式,可读性更高,代码编写的难度更低。但通过高级语言编写出的程序,则需要先经过解释或编译过程,先翻译成汇编指令,然后再经过汇编过程,转换为计算机可识别的机器指令码才能执行。
各语言与硬件平台的关系

OK~,简单的叙述了一下机器码、指令集与汇编语言、高级语言的关系,从这段阐述中可以得知,Java属于一门高级语言,在执行的时候需要将它编写的代码先编译成汇编指令,再转换为机械指令才能被计算机识别。但似乎我们在使用Java过程中,好像没有这个过程呀?这样因为什么原因呢?

这是因为Java存在JVM这个虚拟平台,JVM的主要任务是负责将javac编译后生成的字节码文件装载到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表和其他辅助信息,这些Java字节码指令是无法直接被OS识别的。那么一个Java程序可以在操作系统上跑起来的根本原因在于什么呢?答案是:依靠于JVM的执行引擎子系统。

二、初窥JVM执行引擎与源码编译原理

Java的执行引擎子系统的主要任务是将字节码指令解释/编译成对应平台上的本地机器指令,简单来说,JVM执行引擎是充当Java虚拟机与操作系统平台之间的“翻译官”的角色。

而目前主要的执行技术有:解释执行、静态编译、即时编译、自适应优化、芯片级直接执行,释义如下:

  • 解释执行:程序在运行过程中,只有当每次用到某处代码时,才会将某处代码转换为机器码交给计算机执行。
  • 静态编译:所谓的静态编译是指程序在启动前,先根据对应的硬件/平台,将所有代码全部编译成对应平台的机器码。
  • 即时编译:程序运行过程中,通过相关技术(如HotSpot中的热点探测)动态的探测出运行比较频繁的代码,然后在运行过程中,将这些执行比较频繁的代码转换机械码并存储下来,下次执行时则直接执行机器码。
  • 自适应优化:开始对所有的代码都采取解释执行的方式,并监视代码执行情况,然后对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行仔细优化。若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。
  • 芯片级直接执行:也就是直接编写机器码的方式,编写出的代码可以直接被CPU识别,读取后可以直接执行。

如上便是现有的一些执行技术,在其中解释执行属于第一代JVM,即时编译JIT属于第二代JVM,自适应优化(目前Sun的Hotspot采用这种技术)则吸取第一代JVM和第二代JVM的经验,采用两者结合的方式。而静态编译的技术在BEA公司的JRockit虚拟机以及JDK9的AOT编译器中都实现了,这样做的好处在于:执行性能堪称最佳,但缺点在于:启动的时间会很长,同时也打破了Java“一次编译,到处运行”的原则。

其实在Java刚诞生时,JDK1.0的时候,Java的定位是一门解释型语言,也就是将Java程序编写好之后,先通过javac将源码编译为字节码,再对生成的字节码进行逐行 解释执行 。但这样就导致了程序执行速度比较缓慢,启动速度也并不乐观,因为启动时需对于未编译的.java文件进行编译,而且编译之后生成的字节码指令也不能被计算机识别,还需要在执行时再经过一次 解释 后,才能变为计算机可识别的机器码指令,从而才能使得代码被机器执行。
经过如上分析,JDK1.0时的这种解释执行的缺点非常明显,Java为了做到“一次编译,到处运行”这个准则,将程序的综合性能大大拉低了,为什么呢?因为对比其他语言多了一个步骤。一般来说,一个Java程序想要运行,必须要经过 先编译,再解释 的过程才可以真正的执行。而我们此时再来看看其他语言的执行。

纯编译型语言:在程序启动时,将编写好的源码全部编译为所处平台的机械码指令。

特点:执行性能最佳,启动时间较长,移植性差,不同平台需要重新发包。

纯解释型语言:在程序运行过程中,需要执行某处代码时,再将该代码解释为平台对应的机械码指令,然后交由计算机执行。

特点:启动速度快,执行性能较差,移植性较好。

OK~,简单的看了一下解释型和编译型的语言特点之后,再回过头来想想1.0版本的Java,是不是发现Java因为虚拟机的存在,搞的不上不下的,卡在了中间。因为在Java程序运行时,既要编译源码,又要解释执行,所以最终导致执行性能一般,启动速度也一般。

再到后来,Java为了解决这个问题,在1.2的时候推出了一款后端编译器,也就是JIT即时编译器(后面分析),它可以支持在Java在执行过程中动态生成本地的机械码。现代的高性能JVM都是采用解释器与即使编译器共存的模式工作,所以Java也被称为“半解释半编译型语言”。

而本篇则会基于目前的HotSpot虚拟机对JVM的执行引擎进行分析,它的执行引擎中也采用解释器与即使编译器共存的模型工作,但这款虚拟机的执行模式采用的是 自适应优化 方案执行。

2.1、执行引擎工作过程

对于执行引擎而言,在《虚拟机规范》中曾提到了,要求所有厂商在实现时,输入输出都必须一致,也就是执行引擎接受的输入内容必须为字节码的二进制流数据,而输出的则必须为程序的执行结果。而执行引擎到底需要执行什么操作,完全是依赖与PC寄存器(程序计数器)的,每当执行引擎处理完一项指令操作后,程序计数器就需要更新下一条需要被执行的指令地址。

在执行Java方法过程中,执行引擎也有可能会根据栈帧中操作数栈的引用信息,直接去访问存储在堆中的Java对象实例数据,也有可能会通过实例对象的对象头中记录的元数据指针(KlassWord)去定位对象的类型信息,也就是会通过元数据指针去访问元数据空间(方法区)中的数据。如下图:
执行引擎工作过程

2.1.1、Java源码编译过程

在之前提及过,JVM只识别字节码文件,所以当编写好.java后缀的Java源码时,我们往往还需要通过javac这样的源码编译器(前端编译器),对Java代码进行编译生成.class后才能被JVM装载进内存,源码编译过程如下:
Java源码编译过程
编译是指将一种语言规范转化成另外一种语言规范,通常编译器都是将便于人理解的语言规范(编程语言)转化成机器容易理解的语言规范(由二进制序列组成的机械码)。比如C/C++或汇编语言都是将源代码直接编译成目标机器码。

javac作为Java语言的源码编译器,它编译的目的却不是为了针对于某个硬件平台进行编译的,而是为JVM进行编译,javac的任务就是将Java源代码转换为JVM可识别的字节码,也就是.java文件到.class文件的过程。对于怎么消除不同种类,不同平台之间的差异这个任务就交由JVM来处理,由JVM中的执行引擎来负责将字节码指令翻译成当前程序所在平台可识别的机械码指令。

javac编译过程具体释义如下:

  • ①词法分析:先读取源代码的字节流数据,然后根据源码语言的语法规则找出源代码中的定义的语言关键字,如if、else、while、for等,然后判断这些关键字的定义是否合法,对于合法的关键字生成用于语法分析的记号序列,同时创建符号表,将

所有的标识符记录在符号表中,这个过程就被称为词法分析。 - 符号表的作用:记录源代码中使用的标识符,收集每个表示符的各种属性信息。 - 词法分析的结果:从源代码中找出一些合法的Token流,生成记号序列。

  • ②语法分析:对词法分析后得到的Token流进行语法分析,就是依据源程序的语法规则,检查这些关键词组合在一起是否符合Java语言规范,比如if的后面是不是紧跟着一个布尔型判断表达式、else是否写在if后面等。对于符合规范的,组织上一步产生的记号序列生成语法树。
    • 语法分析的结果:形成一颗符合Java语言规定的抽象语法树。抽象语法树是一个结构化的语法表达形式,它的作用是把语言的主要词法用一个结构化的形式组织在一起,这棵语法树可以被后面按照新的规则再重新组织。
  • ③语义分析:经过语法分析后就不存在语法错误这些问题了,语义分析主要任务有两个,一个是对上步产生的语法树进行检查,其中包括类型检查、控制流检查、唯一性检查等,第二个则是将一些复杂的语法转换为更简单的语法,相当于把一些文言文、古诗、成语翻译成大白话的意思。比如将foreach转化为for循环、循环标志位替换为break等。
    • 语义分析的结果:简化语法后会生成一棵语法树,这棵语法树也就更接近目标语言的语法规则。
  • ④字节码生成:将简化后的语法树转换为Class文件的格式,也就是在该阶段会根据简化后的语法树生成字节码。
    • 字节码生成的结果:生成符合虚拟机规范的字节码数据。

经过如上过程后,编写程序时的.java源代码文件会被转换.class字节码文件,然后这些字节码会在启动时,被虚拟机的类加载机制装载进内存,当程序运行过程中,调用某个方法时,就会将对应的字节码指令交由执行引擎处理。

总的来说,Java代码执行的过程会主要分为三个阶段,分别为:源码编译阶段、类加载阶段以及类代码(字节码)执行阶段,接着我们再来分析一下执行阶段的过程。

2.1.2、执行引擎执行过程

被加载进内存的字节码最终执行是由执行引擎来负责的,但JVM的执行引擎并不能真正的执行字节码指令,而是将字节码指令翻译成本地机械指令交由物理机的执行引擎来真正的执行的。整体流程如下:
JVM执行引擎执行过程
一般而言,在字节码被加载进内存之后,都会经过如上几个步骤才会被翻译成本地的机械指令执行,但这几个优化步骤却并不是必须的,如果不需要也可以在程序启动时通过JVM参数关闭。但综合而言,虽然优化的过程会耗费一些时间,但这样却能够大大的提升程序在执行时的速度,所以总归而言利大于弊。

OK~,从上图中可以看出,执行引擎的入口的数据是字节码文件,而在HotSpot虚拟机中对于Class文件结构的定义如下:

struct ClassFile {
    u4 magic;                // 识别Class文件格式,具体值为0xCAFEBABE
    u2 minor_version;        // Class文件格式副版本号
    u2 major_version;        // Class文件格式主版本号
    u2 constant_pool_count;  // 常量表项个数
    cp_info **constant_pool; // 常量表,又称变长符号表
    u2 access_flags;         // Class的声明中使用的修饰符掩码
    u2 this_class;           // 常数表索引,索引内保存类名或接口名
    u2 super_class;          // 常数表索引,索引内保存父类名
    u2 interfaces_count;     // 超接口个数
    u2 *interfaces;          // 常数表索引,各超接口名称
    u2 fields_count;         // 类的域个数
    field_info **fields;     // 域数据,包括属性名称索引
    u2 methods_count;        // 方法个数
    method_info **methods;   // 方法表:包括方法名称索引/方法修饰符掩码等
    u2 attributes_count;         // 类附加属性个数
    attribute_info **attributes; // 类附加属性数据,包括源文件名等
};
复制代码

任何.java后缀的Java源码经过编译后都会生成为符合如上格式的class字节码文件。执行引擎接收的输入格式也为如上格式的class文件,不过值得注意一提的是:JVM不仅仅只接收.java文件编译成的.class文件,对于所有符合如上格式规范的字节码文件都可以被JVM接收执行。

HotSpot虚拟机是基于栈式的,也就代表着执行引擎在执行方法时,执行的是一个个的栈帧,栈帧中包含局部变量表、操作数栈、动态链接以及方法返回地址等描述方法的相关信息。但执行引擎在虚拟机运行时,只会执行最顶层的栈帧,因为最顶层的栈帧是当前需要执行的方法,执行完当前方法后会弹出顶部的栈帧,然后将下一个栈帧(新的顶部栈帧)拿出继续执行。
刚刚提到了方法的相关信息被存储在栈帧中,而栈帧的方法信息是从class字节码文件中读出来的,每个方法通过结构体method_info来描述,如下:

struct method_info
{
    u2 access_flags;         //方法修饰符掩码
    u2 name_index;           //方法名在常数表内的索引
    u2 descriptor_index;     //方法描述符,其值是常数表内的索引
    u2 attributes_count;     //方法的属性个数
    attribute_info **attributes;    //方法的属性表(局部变量表)
};
复制代码

method_info中存在一个attribute_info类型的成员attributes,该成员就是平时所说的局部变量表,其内也存放着方法参数和方法内的局部变量,当方法是实例方法时,局部变量表的第0位会被用来传递方法所属对象的引用,即this。Java虚拟机执行引擎是基于栈式的,栈就是操作数栈,操作数栈的深度也是记录在方法属性集合的Code属性中,同时attributes成员中也记录着局部变量表所需的空间大小。

下面来个简单的例子感受一下执行引擎执行的过程:

/* ------Java代码------ */
public int add(){
    int a = 3;
    int b = 2;
    int c = a + b;
    return c;
}

/* ------javap -c -v -p 查看到的字节码(省略描述方法的字节码)------ */
0: iconst_3 // 将3放入操作数栈顶
1: istore_1 // 写出操作数栈顶部元素,并将其放在局部变量表中索引为1的位置
2: iconst_2 // 将2放入操作数栈顶
3: istore_2 // 写出操作数栈顶部元素,并将其放在局部变量表中索引为2的位置
4: iload_1  // 从局部变量表中加载索引位置=1的数据值
5: iload_2  // 从局部变量表中加载索引位置=2的数据值
6: iadd     // 弹出操作栈顶的两个元素并进行 加 操作(3 + 2)
7: istore_3 // 将加之后的结果刷写到局部变量表中索引为3的位置
8:iload_3  // 从局部变量表中加载索引位置=3的数据值
8: ireturn  // 将加载的c返回
复制代码

对于如上过程中,前四条分配指令就不分析了,重点分析一下后面的运算过程,也就是c=a+b这个过程,具体执行如下:

  • ①数据a从局部变量表经过总线传输到操作数栈
  • ②数据b从局部变量表经过总线传输到操作数栈
  • ③数据a从操作数栈经过总线传输给CPU
  • ④数据b从操作数栈经过总线传输给CPU
  • CPU计算完成后,将结果通过数据总线传输到操作数栈
  • ⑥运算结果从操作数栈经过总线传输到CPU
  • CPU将数据经过总线传输到局部变量表赋值给c
  • ⑧将计算后的结果从局部变量表索引为3的位置加载到操作数栈
  • ⑨最后使用ireturn指令将计算后的结果c返回给方法的调用者

如上便是栈式虚拟机的执行过程,其中所提到的局部变量表会在编译器确定长度,也就是等于一个this加上三个局部变量,长度最终为4。当程序执行到方法定义的那行代码时,局部变量表中会被依次填入数据:this、3、2,同时程序计数器会跟着代码的执行位置不断更新,当执行完add操作后,会将数据a+b的结果5再填入局部变量表。

三、详解JVM执行引擎子系统

在第二阶段,咱们简单的分析了一下Java代码的编译过程以及执行过程,同时在前面也提到了,Java是使用解释器+编译器共存的模式工作的,也就代表着JVM执行引擎子系统中,是包含了解释器和编译器的,如下图:
JVM执行引擎子系统
Java虚拟机的执行引擎子系统中包含两种执行器,分别为解释器和即时编译器。当执行引擎获取到由javac编译后的.class字节码文件后,在运行时是通过解释器(Interpreter)转换成最终的机械码执行。另外为了提升效率,JVM加入了一种名为 JIT即时编译 的技术,即时编译器的目的是为了避免一些经常执行的代码被解释执行,JIT会将整个函数编译为平台本地的机械码,从而在很大程度上提升了执行的效率。

3.1、解释器(Interpreter)

当Java程序运行时,在执行一个方法或某处代码时,会找到.class文件中对应的字节码,然后会根据定义的规范,对每条需执行的字节码指令逐行解释,将其翻译成平台对应对应的本地机械码执行。当一条字节码指令被解释执行完成后,紧接着会再根据PC寄存器(程序计数器)中记录的下一条需被执行指令,读取并再次进行解释执行操作。

在HotSpot虚拟机中,解释器主要由Interpreter模块和Code模块构成,Interpreter模块实现了解释执行的核心功能,Code模块主要用于管理解释器运行时生成的本地机械指令。

3.2、JIT即时编译器(Just In Time Compiler)

由于解释器实现简单,并且具备非常优异的跨平台性,所以现在的很多高级语言都采用解释器的方式执行,比如Python、Rust、JavaScript等,但对于编译型语言,如C/C++、Go等语言来说,执行的性能肯定是差一筹的,而前面不止一次提到过:Java为了解决性能问题,所以采用了一种叫做JIT即时编译的技术,也就是直接将执行比较频繁的整个方法或代码块直接编译成本地机器码,然后以后执行这些方法或代码时,直接执行生成的机器码即可。

OK~,那么对于上述中 执行次数比较频繁的代码 判断基准又是什么呢?答案是:热点探测技术。

3.3、热点代码探测技术

HotSpot VM的名字就可以看出这是一款具备热点代码探测能力的虚拟机,所谓的热点代码也就是指调用次数比较多、执行比较频繁的代码,当某个方法的执行次数在一定时间内达到了规定的阈值,那么JIT则会对于该代码进行深度优化并将该方法直接编译成当前平台对应的机器码,以此提升Java程序执行时的性能。

一个被多次调用执行的方法或一处代码中循环次数比较多的循环体都可以被称为 热点代码 ,因此都可以通过JIT编译为本地机器指令。

3.3.1、栈上替换

纵观所有编程语言,类似于C/C++、GO等编译型语言,都属于静态编译型,也就是指在程序启动时就会将所有源代码编译为平台对应的机器码,但JVM中的JIT却属于动态编译器,因为对于热点代码的编译是发生在运行过程中的,所以这种方式也被称之为 栈上替换(On Stack Replacement),在有的地方也被称为OSR替换。

3.3.2、方法调用计数器与回边计数器

前面提到过:“一个被多次调用执行的方法或一处代码中循环次数比较多的循环体都可以被称为 热点代码”,那么一个方法究竟要被调用多少次或一个循环体到底要循环多少遍才可被称为热点代码呢?必然会存在一个阈值,而JIT又是如何判断一段代码的执行次数是否达到了这个阈值的呢?主要依赖于热点代码探测技术。

在HotSpotVM中,热点代码探测技术主要是基于计数器实现的。HotSpot中会为每个方法创建两个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter),方法调用计数器主要用于统计方法被调用的次数,回边计数器主要用于统计一个方法体中循环体的循环次数。

方法调用计数器

方法调用计数器的阈值在Client模式下默认是1500次,在Server模式下默认是10000次,当一段代码的执行次数达到这个阈值则会触发JIT即时编译。当然,如果你对这些缺省(默认)的数值不满意,也可以通过JVM参数-XX :CompileThreshold来自己指定。
JIT方法调用计数器编译原理
如上,当一个方法被调用执行时,会首先检查该方法是否已经被JIT编译过了,如果是的话,则直接执行上次编译后生成的本地机器码。反之,如果还没有编译,则先对方法调用计数器+1,然后判断计数器是否达到了规定的阈值,如果还未达到阈值标准则采用解释器的模式执行代码。如果达到了规定阈值则提交编译请求,由JIT负责后台编译,后台线程编译完成后会生成本地的机器码指令,这些指令会被放入Code Cache中缓存起来(热点代码缓存,存放在方法区/元数据空间中),当下次执行该方法时,直接从缓存中读取对应的机械码执行即可。

回边计数器

回边计数器的作用是统计一个方法中循环体的执行次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge)。与方法调用计数器一样,当执行次数达到某个阈值后,也会触发OSR编译。如下图:
JIT回边计数器编译原理
OK~,回边计数器的编译过程和方法调用计数器的相差无几,唯一值得一提的就是:不管是方法调用计数器还是回边计数器,在提交OSR编译请求的那次执行操作,还是依旧会采用解释器执行,而不会等到编译操作完成后去执行机器码,因为这样耗费的时间比较长,只有下次再执行该代码时才会执行编译后的机器码。

3.3.3、热度衰减

一般而言,如果以缺省参数启动Java程序,那么方法调用计数器统计的执行次数并不是绝对次数,而是一个相对的执行频率,也代表是指方法在一段时间内被执行的次数。当超过一定的时间,但计数器还是未达到编译阈值无法提交给JIT即时编译器编译时,那此时就会对计数器进行减半,这个过程被称为方法调用计数器的热度衰减(Counter Decay),而这段时间则被称为方法调用计数器的半衰周期(Counter Half Life Time)。

而发生热度衰减的动作是在虚拟机GC进行垃圾回收时顺带进行的,可以通过参数-XX:-UseCounterDecay关闭热度衰减,这样可以使得方法调用计数器的判断基准变为绝对调用次数,而不是以相对执行频率作为阈值判断的标准。不过如果关闭了热度衰减,就会导致一个Java程序只要在线上运行的时间足够长,程序中的方法必然绝大部分都会被编译为本地机器码。

同时也可以通过-XX:CounterHalfLifeTime参数调整半衰周期的时间,单位为秒。

一般而言,如果项目规模不大,并且上线后很长一段时间不需要进行版本迭代的产品,都可以尝试把热度衰减关闭掉,这样可以使得Java程序在线上运行的时间越久,执行性能会更佳。只要线上运行的时间足够长,到后面可以与C编写的程序性能相差无几甚至超越(因为C/C++需要手动管理内存,管理内存是需要耗费时间的,但Java程序在执行程序时却不需要担心内存方面的问题,会有GC机制负责)。

3.3.4、其他的热点探测技术

在前面分析中,我们得知了,在HotSpot中的热点代码探测是基于计数器模式实现的,但是除开计数器的方式探测之外,还可以基于采样(sampling)以及踪迹(Trace)模式对代码进行热点探测。

  • 采样探测:采用这种探测技术的虚拟机会周期性的检查每个线程的虚拟机栈栈顶,如果一些在检查时经常出现在栈顶的方法,那么就代表这个方法经常被调用执行,对于这类方法可以判定为热点方法。
    • 优点:实现简单,可以很轻松的判定出热度很高(调用次数频繁)的方法。
    • 缺点:无法实现精准探测,因为检查是周期性的,并且有些方法中存在线程阻塞、休眠等因素,会导致有些方法无法被精准检测。
  • 踪迹探测:采用这种方式的虚拟机是将一段频繁执行的代码作为一个编译单元,并仅对该单元进行编译,该单元由一个线性且连续的指令集组成,仅有一个入口,但有多个出口。也就代表着:基于踪迹而编译的热点代码不仅仅局限在一个单独的方法或者代码块中,一条踪迹可能对应多个方法,代码中频繁执行的路径就可能被识别成不同的踪迹。
    • 优点:这种方式实现可以使得热点探测拥有更高精度,可以避免将一块代码块中所有的代码都进行编译的情况出现,能够在很大程序上减少不必要的编译开销。因为无论是采样探测还是计数器探测的方式,都是以方法体或循环体作为编译的基本单元的。
    • 缺点:踪迹探测的实现过程非常复杂,难度非常高。

而HotSpot虚拟机采用的计数探测的方式,实现难度、编译开销与探测精准三者之间会有一个很好的权衡。三种探测技术比较如下:

  • 实现难度:采样探测 < 计数探测 < 踪迹探测
  • 探测精度:采样探测 < 计数探测 < 踪迹探测
  • 编译开销:踪迹探测 < 计数探测 < 采样探测

3.4、JVM为何不移除解释器?

在前面分析了JIT即时编译器,可以很直观的感受到,如果程序以纯JIT编译器的方式执行,性能方面绝对会超出解释器+编译器混合的模式,但为何虚拟机中至今也不移除解释器,还要用解释器来拖累Java程序的性能呢?就如在开篇中提到的JRockit虚拟机中,就移除了解释器模块,字节码文件全部依靠即时编译器执行。

主要有两个原因,一个是为了保证Java的绝对跨平台性,另一个则是为了保证启动速度,考虑综合性能。
①保证绝对的跨平台性:如果将解释器从虚拟机中移除就代表着:每到一个不同的平台,比如从Windows迁移到Linux环境,那么JIT又要重新编译,生成对应平台的机器码指令才能让Java程序执行。但如果是解释器+JIT编译器混合的模式工作就不需要担心这个问题,因为前期可以直接由解释器将字节码指令翻译成当前所在的机械码执行,解释器会根据所在平台的不同,翻译出平台对应的机器码指令。这样从而使得Java具备更强的跨平台性。
②保证Java启动速度,考虑综合性能:因为如果移除了解释器模块,那么就代表着所有的字节码指令需要在启动时全部先编译为本地的机械码,这样才能使得Java程序能够正常执行。不过如果想在启动时将整个程序中所有的字节码指令全部编译为机器码指令,需要的时间开销是非常巨大的,如果把解释器从JVM中移除,那么会导致一些需要紧急上线的项目可能编译都需要等半天的时间。

综上所述,虚拟机移除解释器有移除后的隐患,当然,如果移除了也有移除之后的好处,比如前面提到的JRockitVM中,就移除了解释器模块,从而使它获取了一个“史上最快”虚拟机的称号。

而HotSpot中采用的是解释器+JIT即时编译器混合的模式,这种模式的好处在于:在Java程序运行时,JVM可以快速启动,前期先由解释器发挥作用,不需要等到编译器把所有字节码指令编译完之后才执行,这样可以省去很大一部分的编译时间。后续随着程序在线上运行的时间越来越久,JIT发挥作用,慢慢的将一些程序中的热点代码替换为本地机器码运行,这样可以让程序的执行效率更高。同时,因为HotSpotVM中存在热度衰减的概念,所以当一段代码的热度下降时,JIT会取消对它的编译,重新更换为解释器执行的模式工作,所以HotSpot的这种执行模式也被成为“自适应优化”执行。

当然,我们在程序启动时也可以通过JVM参数自己指定执行模式:

①-Xint:完全采用解释器模式执行程序。
②-Xcomp:完全采用即时编译器模式执行程序。如果即时编译器出现问题,解释器会介入执行。
③-Xmixed:采用解释器+JIT即时编译器的混合模式共同执行(默认的执行方式)。

3.5、热冷机流量迁移注意事项

通过上述的分析之后,我们可以得到一个结论:

编译执行的方式性能会远远超出解释执行。

这句话听起来好像是废话,因为是个明眼人就能看出这个结论,但实则不然。此时我们可以从系统架构的角度思考一下这个结论,对于系统整体而言,这个结论有什么不同吗?是有的,如下:

既然编译执行比解释执行的效率要高,那么就代表着程序如果处于编译执行的周期内,系统的吞吐量要比解释执行期间高很多。而Java现在默认的虚拟机HotSpot并不是一开始就是编译执行的,而是在运行过程中通过JIT即时编译器进行动态编译的。

所以现在又可以得到一个简单的结论,Java程序的机器可以简单分为两种状态:

  • 热机:长时间在线上运行Java程序的机器,程序中很多代码都已经被JIT编译为了本地机器码指令。
  • 冷机:刚刚启动的Java程序的机器,所有代码还是处于解释执行的阶段。

从上面的分析中可以得知:机器在热机状态可以承受的流量负载会远远超出冷机状态。如果程序以热机状态切换流量到冷机状态的机器时,可能会导致冷机状态的服务器因无法承载流量而假死。

之前我在开发过程中也曾遇到过这样的问题,某个服务因为要扩容,原本按照之前的集群规模计算,再扩容1/4之一左右的机器是可以承载新的流量的,但后面启动之后出现了问题,新启动的机器网关那边分配转发流量之后,立马就宕机了,最开始因为是第一次碰到这样的问题,以为是机器或者程序中代码的问题,最后排查发现都没问题,后来尝试将扩容的机器数量从原本计划的1/4增加到1/3之后,流量平滑的被迁移到了新的机器,没有再出现宕机的故障。

从上述这个案例中可以得知,如果直接将热机状态的流量迁移到冷机状态的机器是不可行的,所以一般在计划扩容时,想要流量平滑的切换到新的机器,一般有软硬件两种层面的解决方案,如下:
第一种方案是和上述案例中一样,采用更多的机器承载热机状态过来的流量,等后续这些刚启动的冷机变成热机状态了,可以再把多余的机器停掉。
第二种方案则是网关这边控制流量,先将一部分流量转发给刚启动的冷机,让刚启动的冷机先做预热,等运行一段时间之后再将原本计划的所有流量迁移到这些机器。

四、全面剖析JIT即时编译器

在Java的编译器中,大体可以分为三类:

  • ①前端编译器:类似于javac、JDT中的ECJ增量编译器等。就是指将.java的源代码编译成.class字节码指令的编译器。
  • ②后端编译器:也就是指JIT即时编译器,指把字节码指令编译成机器码指令的编译器。
  • 静态编译器:类似于Java9中的AOT编译器,是指把.java源代码直接编译为机器码指令的编译器。

在JVM运行过程中采用的解释器+编译器混合执行的模式,一般是指JIT编译器,在Java中对于静态编译器的应用还是比较少的。在HotSpot虚拟机中内嵌着两个JIT即时编译器,分别为Client CompilerServer Compiler,也就是通常所说的C1和C2编译器,JVM在64位的系统中默认采用的C2编译器,也就是Server Compiler编译器。不过同样的,在程序启动的时候也可以通过参数显式指定运行时到底采用哪种编译器,如下:

  • -client:指定JVM运行时采用C1编译器。
    • C1编译器会对字节码进行简单和可靠的优化,耗时比较短,追求编译速度。
  • -server:指定JVM运行时采用C2编译器。
    • C2编译器会对字节码进行激进优化,耗时比较长,追求编译后的执行性能。

两种编译器因为追求的方向不同,所以在优化时的过程也存在差异,下面来简单分析一下C1和C2编译器。

4.1、C1编译器(Client Compiler)

C1编译器主要追求稳定和编译速度,属于保守派,C1中常见的优化方案有几种:公共子表达式消除、方法内联、去虚拟化以及冗余消除等。

  • 公共子表达式消除:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那E的这次出现就成公共子表达式,可以用原先的表达式进行消除,直接使用上次的计算结果,无需再次计算。
  • 方法内联:将引用的方法代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程。
  • 去虚拟化:对唯一的实现类进行内联。
  • 冗余消除:通过对字节码指令进行流分析,将一些运行过程中不会执行的代码消除。
    • 空检测消除:将显式调用的NullCheck(空指针判断)擦除,改成ImplicitNullCheck异常信号机制处理。
    • 自动装箱消除:对于一些不必要的装箱操作会被消除,比如刚装箱的数据又在后面立马被拆箱,这种无用操作就会被消除。
    • 安全点消除:对于线程无法抵达或不会停留的安全点会进行消除。
    • 反射消除:对于一些可以正常访问无需通过反射机制获取的数据,会被改为直接访问,消除反射操作。

4.2、C2编译器(Server Compiler)

C2编译器则主要是追求编译后的执行性能,属于激进派,C2编译器建立在C1编译器的基础优化之上,除开使用了C1中的优化手段之外,还有几种基于逃逸分析的激进优化手段:标量替换、栈上分配以及同步消除等。

  • 逃逸分析:逃逸分析是建立在方法为单位之上的,判断变量作用域是否存在于其他栈帧或者线程中,如果一个成员在方法体中产生,但是直至方法结束也没有走出方法体的作用域,那么该成员就可以被理解为未逃逸。反之,如果一个成员在方法最后被return出去了或在方法体的逻辑中被赋值给了外部成员,那么则代表着该成员逃逸了,判断逃逸的方法被称为逃逸分析。
    • 也可以换个说法,建立在线程的角度来看:如果一条线程中的对象无法被另一条线程访问到,就代表该对象未逃逸。
    • 逃逸的作用域:
      • ①栈帧逃逸:当前方法内定义了一个局部变量逃出了当前方法/栈帧。
      • ②线程逃逸:当前方法内定义了一个局部变量逃出了当前线程能够被其他线程访问。
    • 逃逸类型:
      • 全局变量赋值逃逸:当前对象被赋值给类属性、静态属性
      • 参数赋值逃逸:当前对象被当作参数传递给另一个方法
      • 方法返回值逃逸:当前对象被当做返回值return
  • 标量替换:建立在逃逸分析的基础上使用基本量标量代替对象这种聚合量。
    • 标量:reference与八大基本数据类型就是典型的标量,泛指不可再拆解的数据。
    • 好处:
      • ①能够节省堆内存,因为进行标量替换之后的对象可以在栈上进行内存分配。
      • ②相对运行而言省去了去堆中查找对象引用的过程,速度会更快一些。
      • ③因为是分配在栈上,所以会随着方法结束和线程栈的弹出自动销毁,不需要GC的介入。
  • 栈上分配:对于未逃逸的对象使用标量替换进行拆解,然后将拆解后的标量分配在局部变量表中,从而减少实例对象的产生,减少堆内存的使用以及GC次数。
    • 决定一个对象能否在栈上分配的因素(两个都必须满足):
      • ①对象能够通过标量替换分解成一个个标量。
      • ②对象在栈帧级作用域不可逃逸。
  • 同步消除:在出现synchronized嵌套的情况下,如一个同步方法中调用另一个同步方法,那么第二个同步方法的synchronized锁会被消除,因为第二个方法只有获取到了第一个锁的线程才能访问,不存在线程并发安全问题。
    • 决定能否同步消除(满足一个即可):
      • ①当前对象被分配在栈上。
      • ②当前对象的无法逃出线程作用域。
  • 空检查剪支:经过流分析后,对于一些不会执行的Null分支判断会直接剪掉
    • 如一个参数在外部方法传递前已经做了非空检测了,但在内部方法中依旧又做了一次非空判断,那么对于内部的这个非空判断会被直接剪除掉。

逃逸的作用域:①栈帧逃逸:当前方法内定义了一个局部变量逃出了当前方法/栈帧。 ②线程逃逸:当前方法内定义了一个局部变量逃出了当前线程能够被其他线程访问。全局变量赋值逃逸:当前对象被赋值给类属性、静态属性参数赋值逃逸:当前对象被当作参数传递给另一个方法方法返回值逃逸:当前对象被当做返回值return

前面提到了,64位的JVM中都是默认使用C2编译器的,但实际上JDK1.6之后如果是64位的机器,默认情况下或显式指定了-server模式运行时,JVM会开启分层编译策略,也就是通过C1+C2相互协作共同处理编译任务。而分层编译大体的逻辑为:Java程序刚启动还处于冷机状态时,采用C1编译器进行简单优化,追求编译速度和稳定性,当JVM达到热机状态时,后面的编译请求则通过C2编译器进行全面激进优化,追求编译后执行时的性能和效率。

PS:两种不同的模式运行,热点代码缓存区大小也会不一样,Server模式下CodeCache的初始大小为2496KB,Client模式下CodeCache的初始大小为160KB,可以通过-XX:ReservedCacheSize参数指定CodeCache的最大大小。

4.3、其他的编译器

在JDK10的时,HotSpot加入了一种新的编译器:Graal编译器,该编译器的性能经过几代的更新后很快就追上了老牌的C2编译器,在JDK10中可以通过-XX: +UnlockExperimentalVMOptions -XX: +UseJVMCICompiler参数使用它。

五、分派(Dispatch)调用

在学习JavaSE的时候大家应该都学到了OOP的基本特征,也就是封装、继承与多态。而关于多态性在运行时到底是如何找到具体方法的,如重写和重载方法到底在运行时是如何确定具体调用那个方法的呢?也就是通过分派技术进行调用的。

5.1、方法调用

先来说说方法调用,方法调用和方法执行是不同的,方法调用阶段的主要任务是确定被调用方法的版本,这个版本是指要具体调用重载、重写情况下的哪一个方法,方法调用阶段并不会涉及到方法体中逻辑的执行。一般来说,.java文件经过前端编译器编译成.class文件后,所有的方法调用存储在class文件都是 符号引用 ,而并不是 直接引用(运行时方法在内存中的入口)

一般而言,方法的直接引用需要等到类加载中的解析阶段甚至运行时才可以确定,在类加载中的解析阶段能够被确认直接引用方法只有静态方法、final方法以及私有方法,因为这几类都是属于“编译器可知、运行期不可变”的方法,因为这几种方式定义的方法要么与类直接关联,要么外部不能访问以及不可修改,这就决定了他们不能通过重写的方法更改其方法版本,因此都可以直接在解析阶段确认直接引用,可以在类加载阶段进行解析。

在JVM虚拟机中提供了五条方法调用的指令:

  • invokestatic:调用静态方法
  • invokespecial:调用构造<init>构造方法、私有方法以及super()、super.xxx()父类方法
  • invokevirtual:调用所有的虚方法(静态、私有、构造、父类、final方法都属于非虚方法)
  • invokeinterface:调用接口方法,会在运行期间才能确定具体的实现类方法
  • invokedynamic:现在运行时期动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条指令,分派逻辑都是固化在JVM中的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的

一般而言,能够被invokestaticinvokespecial指令调用的方法都可以在解析阶段确定调用的具体版本信息,像静态、私有、构造、父类、final方法都符合调用条件,所以这些方法在类加载阶段就会把符号引用替换成直接引用。因为这些方法是一个静态的过程,在编译期间就能完全确定版本,无需将这些工作延迟到运行期间再去处理,而这类调用方式就被称为静态分派。但对于公开实例方法、非私有成员方法这些就无法在编译期确定版本,所以这些方法的调用方式被称为动态分派。同时根据方法的宗量数也可分为单分派多分派

方法宗量是指方法的所有者和参数,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。所以稍微总结一下,如下:

  • 非虚方法:指在类加载阶段可确定版本(可将符号引用转换为直接引用)的方法
  • 虚方法:指在类加载阶段无法确定版本(无法确定符号引用可以解析为哪个直接引用)的方法
  • 静态分派:编译期可以确定方法的版本,类加载阶段可以通过解析阶段完成版本判定
  • 动态分派:运行期才可确定方法版本,由JVM来确定方法的具体版本
  • 单分派:根据单个方法宗量进行方法版本选择
    • 动态分派的选择是依据方法接收者来选择版本的,所以动态分派属于单分派
  • 多分派:根据多个方法宗量进行方法版本选择
    • 静态分派是依据方法的接收者和参数两个宗量进行版本选择,因此静态分派属于多分派

5.2、静态分派

静态分派是指所有依赖于静态类型来定位方法执行版本的分派动作,静态分派发生在编译期,由编译器执行分派动作,所以静态分派并不是虚拟机来执行的。
静态类型是指什么?

User u = new Admin();

如上代码,User是变量u的静态类型(外观类型),而Admin是变量的实际类型。

静态分派的典型体现是方法重载(Overload),重载方法的特性是方法签名不同(方法名相同、参数列表不同。下面上个案例理解一下:

public class User{
    public void identity(VipUser vip){
        System.out.println("我是VIP会员用户....");
    }
    public void identity(AdminUser admin){
        System.out.println("我是管理员....");
    }
    public static void main(String[] args) {
        User user = new User();
        VipUser vip = new VipUser();
        user.identity(vip);
    }
}
class VipUser{}
class AdminUser{}
复制代码

如上源码,User中存在两个方法,identity(VipUser)重载了方法identity(AdminUser),编译器在解析名为identity的方法时,会根据其重载参数的静态类型(即外观类型或直接类型)来选择方法版本。

输出结果:我是VIP会员用户....
这个结果不难理解,因为在调用方法的时候:user.identity(vip)传入的参数静态类型为VipUser,所以编译器最终会找到identity(VipUser vip)方法。

如果参数为无类型的字面量(基本数据类型),那么编译器会在最大程度上去推导出字面量上最贴合的方法版本,如下:

public class User{
    public void print(char arg){
        System.out.println("char....");
    }
    public void print(long arg){
        System.out.println("long....");
    }
    public void print(int arg){
        System.out.println("int....");
    }
    // 省略其他方法.......
    public void print(char... arg){
        System.out.println("char... ....");
    }
    public static void main(String[] args) {
        User user = new User();
        user.print('a');
    }
}
// 输出结果:char....
复制代码

观察如上代码,输出结果为char....,这很正常,但如果我们把print(char arg)方法注释掉,再次执行会发生什么情况?报错?并不是,注释掉后再执行,如下:

输出结果:int....

从上面的执行结果中可以得知:虽然User类中没有了char类型参数的方法,但实际上编译器会通过参数自动转型帮你找到了一个“合适”的方法调用,转换路径如下:
char → int → long → float → double,经过如上过程还未找到符合要求的方法时,会自动将调用方法时传递的参数装箱为Character对象,如果还是未找到,会进一步查找Character类实现的接口Serializable,如果还未找到,会进一步查找Serializable的父类Object,如果还未找到则会再找到char...,所以总体查找路径如下:
char → int → long → float → double → Character → Serializable → Object → char...

实际上关于编译器的类型转换,推导最合适的方法调用这个点,大家了解一下有这个概念存在即可,实际开发过程中,代码不会写这么苛刻。

5.3、动态分派

动态分派是指在编译期无法通过静态类型判定出方法版本,需要在运行期间由虚拟机来判定方法调用的具体版本的方式。动态分派的典型体现是方法重写(Override),重写的概念是方法签名相同(方法名相同、参数列表相同),上个案例理解。如下:

public class User{
    public void identity(){
        System.out.println("我是用户....");
    }

    public static void main(String []args) {
        User user = new VipUser();
        user.identity();
    }
}

class VipUser extends User {
    public void identity(){
        System.out.println("我是VIP会员用户....");
    }
}
class AdminUser extends User{
    public void identity(){
        System.out.println("我是管理员....");
    }
}
// 输出结果:我是VIP会员用户....
复制代码

对于这个结果相信不会出乎大家的意料,那虚拟机在运行时又是如何定位到VipUser.identity()方法的呢?这里显然不是通过变量的静态类型进行的版本判定,因为静态类型为User的变量user调用identity执行后,最终执行的却是VipUser.identity()方法,这是什么原因呢?其实道理也非常简单,就是因为user变量的实际类型不同,那Java又是如何通过变量的实际类型来判定方法版本的?接下来进行逐步分析。

对于如上源码使用javap进行反编译后,现在来观察一下User.main()的字节码信息,如下:

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
  stack=2, locals=2, args_size=1
     0: new           #2   // class User$VipUser
     3: dup
     4: invokespecial #3   // Method User$VipUser."<init>":()V
     7: astore_1
     8: aload_1
     9: invokevirtual #4   // Method identity:()V
    12: return
  LineNumberTable:
    line 7: 0
    line 8: 8
    line 9: 12
复制代码

在字节码指令的第七行,通过了invokevirtual指令调用了VipUser.identity虚方法,运行时JVM的执行引擎对于invokevirtual指令进行解析,解析操作会分为如下几个步骤:

  • 找到操作数栈顶部的第一个元素,也就是指向变量user的实际类型,即VipUser
  • VipUser的方法表中查找名称和参数类型与invokevirtual指令调用的方法符号引用相同的方法
    • 找到了:代表VipUser类中存在方法identity()方法,判断是否具备方法的访问权限
      • 具备:将调用方法处的符号引用替换为该方法的直接引用
      • 不具备:抛出java.lang.IllegalAccessError错误
    • 没找到:
      • 继续自下向上的方式查找VipUser父类的方法表
        • 找到了:代表父类中有identity()方法,,判断是否具备方法的访问权限
          • 具备:将调用方法处的符号引用替换为该方法的直接引用
          • 不具备:抛出java.lang.IllegalAccessError错误
        • 还是没找到:代表调用的方法根本不存在,抛出java.lang.AbstractMethodError错误

由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。同时,这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

5.4、虚拟机中动态分派的实现

由于动态分派在运行时是频繁执行的动作。而且相当来说,动态分派的方法版本判定需要在类的元数据中搜索出符合要求的合适版本,性能开销也比较大,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。

综合考虑,一般的JVM实现中,都会为每个类在元数据空间(原方法区)中建立一个虚方法表,在解析invokevirtual指令时,使用方法表索引来代替查找元数据的开销,以此提高性能。
虚方法表中存放着各个类方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表中该方法的地址入口和父类中相同的方法入口地址是一致的,都指向父类的实现入口。如果子类重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。比如xxx类没有重写Object类的toString()方法,那么xxx类的虚方法表中toString()的入口地址则指向Object.toString()方法。
在虚拟机中,具有相同签名的方法,在父类、子类的虚方法表中都具有一样的索引号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按照索引转换出所需要的方法入口地址。
方法表一般在类加载中的连接阶段进行初始化,准备了类变量初始值之后,虚拟机会把该类的方法表也初始化完成。

当然,在C2编译器的执行模式下,也会存在一些不稳定的激进优化策略,比如内联缓存,基于“类型继承关系分析”技术的守护内联。

对于方法分派调用这块有些小伙伴看了可能会有些不理解,那么你只需要记住分派调用的目的是为了确定方法执行时的具体版本即可。同时,分派调用的过程实际上就是符号引用替换为直接饮用的过程,在有些地方也被称为方法绑定的过程。静态分派调用的方法也被称为早期绑定,因为在编译期间被调用的目标方法就已经知晓。动态分派调用的方法则被称为晚期绑定,被调用的在编译期是不可知的,必须要等到运行时才能与根据实际的类型进行绑定。

分类:
后端
标签:
收藏成功!
已添加到「」, 点击更改