java虚拟机学习记录
一些重要的指令和工具
Java指令
Javap -v 打印某个类的常量池
Java -verbose:class 打印类加载的先后顺序
java -jar asmtools.jar jdis Foo.class (这里需要有 这个jar 包) 显示这个类文件的Java字节码
java字节码
不懂的可以查询这个网址
Chapter 6. The Java Virtual Machine Instruction Set (oracle.com)
我也在下面对常用的字节码做了解释
我们这里可能会看到很多这种东西, 这个实际上时Java的 字节码指令集。
这里是我找到的一些解释
将局部变量表中的变量压入到操作数栈
aload 是将引用变量压入到操作数栈中
iload 是将int 类型压入到操作数栈中
将常量池中的常量压入操作数栈中
根据数据类型和入栈内容的不同,此类又可以细分为 const 系列、push 系列和 Idc 指令
- const 系列,用于特殊的常量入栈,要入栈的常量隐含在指令本身
- push 系列,主要包括 bipush 和 sipush,前者接收 8 位整数作为参数,后者接收 16 位整数
- Idc 指令,当 const 和 push 不能满足的时候,万能的 Idc 指令就上场了,它接收一个 8 位的参数,指向常量池中的索引。
Idc_w
:接收两个 8 位数,索引范围更大。- 如果参数是 long 或者 double,使用
Idc2_w
指令。
将栈顶的数据出栈并装入局部变量表中
主要是用来给局部变量赋值,这类指令主要以 store 的形式存在。
- xstore_(x 为 i、l、f、d、a,n 默认为 0 到 3)
- xstore n(x 为 i、l、f、d、a)
xstore_ 和 xstore n 的区别在于,前者相当于只有操作码,占用 1 个字节;后者相当于由操作码和操作数组成,操作码占 1 个字节,操作数占 2 个字节,一共占 3 个字节。
算术指令
算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新压入操作数栈
分为下面两种形式
- 整型数据的运算指令
- 浮点数据的运算指令
需要注意的是,数据运算可能会导致溢出,比如两个很大的正整数相加,很可能会得到一个负数。但 Java 虚拟机规范中并没有对这种情况给出具体结果,因此程序是不会显式报错的。所以,大家在开发过程中,如果涉及到较大的数据进行加法、乘法运算的时候,一定要注意!
Java虚拟机提供了两种运算模式
- 向最接近数舍入:在进行浮点数运算时,所有的结果都必须舍入到一个适当的精度,不是特别精确的结果必须舍入为可被表示的最接近的精确值,如果有两种可表示的形式与该值接近,将优先选择最低有效位为零的(类似四舍五入)。
- 向零舍入:将浮点数转换为整数时,采用该模式,该模式将在目标数值类型中选择一个最接近但是不大于原值的数字作为最精确的舍入结果(类似取整)。
所有的算术指令
- 加法指令:iadd、ladd、fadd、dadd
- 减法指令:isub、lsub、fsub、dsub
- 乘法指令:imul、lmul、fmul、dmul
- 除法指令:idiv、ldiv、fdiv、ddiv
- 求余指令:irem、lrem、frem、drem
- 自增指令:iinc
类型转换指令
1)宽化,小类型向大类型转换,比如 int–>long–>float–>double
,对应的指令有:i2l、i2f、i2d、l2f、l2d、f2d。
- 从 int 到 long,或者从 int 到 double,是不会有精度丢失的;
- 从 int、long 到 float,或者 long 到 double 时,可能会发生精度丢失;
- 从 byte、char 和 short 到 int 的宽化类型转换实际上是隐式发生的,这样可以减少字节码指令,毕竟字节码指令只有 256 个,占一个字节。
2)窄化,大类型向小类型转换,比如从 int 类型到 byte、short 或者 char,对应的指令有:i2b、i2s、i2c;从 long 到 int,对应的指令有:l2i;从 float 到 int 或者 long,对应的指令有:f2i、f2l;从 double 到 int、long 或者 float,对应的指令有:d2i、d2l、d2f。
- 窄化很可能会发生精度丢失,毕竟是不同的数量级;
- 但 Java 虚拟机并不会因此抛出运行时异常。
对象的创建和访问指令
数组也是一种对象,但它创建的字节码指令和普通的对象不同。创建数组的指令有三种:
- newarray:创建基本数据类型的数组
- anewarray:创建引用类型的数组
- multianewarray:创建多维数组
普通对象的创建指令只有一个,就是 new
,它会接收一个操作数,指向常量池中的一个索引,表示要创建的类型。
- dup: 代表复制, 将栈顶元素,复制一次在放入到栈顶中
- 为什么new 之后, 会又一个 dup 指令,它的作用是什么? 实际上 这里的 需要给 对象进行初始化, 调用对象的init 方法
操作数栈管理指令
常见的操作数栈管理指令有 pop、dup 和 swap。
- 将一个或两个元素从栈顶弹出,并且直接废弃,比如 pop,pop2;
- 复制栈顶的一个或两个数值并将其重新压入栈顶,比如 dup,dup2,dup_×1,dup2_×1,dup_×2,dup2_×2;
- 将栈最顶端的两个槽中的数值交换位置,比如 swap。
这些指令不需要指明数据类型,因为是按照位置压入和弹出的。
字段访问指令
字段可以分为两类,一类是成员变量,一类是静态变量(static 关键字修饰的),所以字段访问指令可以分为两类:
- 访问静态变量:getstatic、putstatic。
- 访问成员变量:getfield、putfield,需要创建对象后才能访问。
控制转移指令
控制转移指令包括:
- 比较指令,比较栈顶的两个元素的大小,并将比较结果入栈。
- 条件跳转指令,通常和比较指令一块使用,在条件跳转指令执行前,一般先用比较指令进行栈顶元素的比较,然后进行条件跳转。
- 比较条件转指令,类似于比较指令和条件跳转指令的结合体,它将比较和跳转两个步骤合二为一。
- 多条件分支跳转指令,专为 switch-case 语句设计的。
- 无条件跳转指令,目前主要是 goto 指令。
比较指令
比较指令有:dcmpg,dcmpl、fcmpg、fcmpl、lcmp,指令的第一个字母代表的含义分别是 double、float、long。注意,没有 int 类型。
对于 double 和 float 来说,由于 NaN 的存在,有两个版本的比较指令。拿 float 来说,有 fcmpg 和 fcmpl,区别在于,如果遇到 NaN,fcmpg 会将 1 压入栈,fcmpl 会将 -1 压入栈。
条件跳转指令
这些指令都会接收两个字节的操作数,它们的统一含义是,弹出栈顶元素,测试它是否满足某一条件,满足的话,跳转到对应位置。
对于 long、float 和 double 类型的条件分支比较,会先执行比较指令返回一个整形值到操作数栈中后再执行 int 类型的条件跳转指令。
对于 boolean、byte、char、short,以及 int,则直接使用条件跳转指令来完成。
比较条件转指令
多条件分支跳转指令
主要有 tableswitch 和 lookupswitch,
前者要求多个条件分支值是连续的,它内部只存放起始值和终止值,以及若干个跳转偏移量,通过给定的操作数 index,可以立即定位到跳转偏移量位置,因此效率比较高;
后者内部存放着各个离散的 case-offset 对,每次执行都要搜索全部的 case-offset 对,找到匹配的 case 值,并根据对应的 offset 计算跳转地址,因此效率较低。
无条件跳转指令
goto 指令接收两个字节的操作数,共同组成一个带符号的整数,用于指定指令的偏移量,指令执行的目的就是跳转到偏移量给定的位置处。
前面的例子里都出现了 goto 的身影,也很好理解。如果指令的偏移量特别大,超出了两个字节的范围,可以使用指令 goto_w,接收 4 个字节的操作数。
函数 & 类型 的 指令表示
- 函数
对于 函数 来说
第一个是Method
后面跟着两个分别是 一个是 形式参数
后面就是函数的名称, 如果是 构造函数就是init
()V , ()代表 没有形式参数, V 代表void
- 类型 (也叫做类型签名)
- 对象类型使用(L ; 比如字符串类型Ljava/lang/String;
- 普通的数组类型的话就是 [X (多数情况下X代表基本数据类型的首字母大写) (对象数组就是上面那种方式)
- 对象数组类型就是[Lxxxx;
对于类型签名,Java字节码使用单字符来表示原始类型:
I
表示int
类型。F
表示float
类型。J
表示long
类型。Z
表示boolean
类型(以及byte
和short
,尽管它们不常用作方法签名)。
变量初始化的一个过程
iconst_1
:将整数值1
推送到操作栈上。在boolean
类型的上下文中,这个1
代表布尔值true
。istore_1
:将操作栈顶部的整数值(这里代表true
)存储到局部变量表的第1
个槽位。iload_1
:在后续的字节码中,这个指令将局部变量表中第1
个槽位的值重新加载到操作栈上,以便执行条件判断(如ifeq L14
)。ifeq
:如果等于零则跳转,当操作栈上的整数值等于零时,将执行跳转操作,转移到指定的指令位置
为什么不能只执行前两个步骤,而需要 iload_1
呢?原因在于字节码的操作栈是一个后进先出(LIFO)的栈,一旦值被推送到操作栈上,它就可以用来执行操作,然后被弹出。因此,一旦 istore_1
执行,操作栈上的值就会被弹出,不再存在于操作栈上
方法的局部变量验证
stack
和locals
后面跟随的数字是方法的局部变量表和操作栈的描述
stack <number>
:这个指令后面跟随的数字<number>
表示在方法执行期间,操作栈的最大深度。操作栈用于执行方法调用、算术运算、对象创建等操作,并临时存储这些操作的结果。这个数字提供了操作栈在方法执行过程中的最大使用情况,有助于JVM进行调用期间的栈空间分配。locals <number>
:这个指令后面跟随的数字<number>
表示局部变量表中最大的槽位数量。局部变量表用于存储方法的局部变量和方法参数。这个数字反映了在方法执行期间需要多少个局部变量槽位。
asmtool.jar包
用途:使得 “.class文件 -> 字节码指令(类似汇编语言)文件 -> .class文件”,并可以修改“字节码指令文件” 改变一个“.class文件”的运行结果。并重新生成class文件
过于折磨, 参考jar包链接
https://github.com/hengyunabc/hengyunabc.github.io/files/2188258/asmtools-7.0.zip
- 将class文件转换出字节码出来
java -jar asmtools.jar jdis Foo.class
- 将jasm后缀的文件 转换为 class文件 。 (注意再这个过程中, 我可以对jasm 文件进行修改, 从而达到修改字节码文件的效果)
java -jar asmtools.jar jasm Foo.jasm
awk指令
‘[pattern] {action}‘ (匹配的基本规则)
pattern:如果是0代表不匹配, 如果对于字符串的话null才不匹配。
action:代表要执行的指令。
如果没有写pattern 默认成立。
如果没有写action, 默认就是 {print $0}
注意这两个东西要被 分号包裹起来
常用变量-替换字段
$NF:代表该行的最后一个字段名。
$x:x需要用数字进行替换代表,代表第几个字段(0代表全部字段)
NR:代表当前所在行号
详细的pattern
直接字符匹配:如果要匹配的字符需要使用 // 包裹起来, 比如要匹配 aaa, 就需要使用
/aaa/
- 直接匹配还可以和替换字段结合 $NF ~ /F/ 表示最后一个字符是F
- awk ‘$NF ~ /F/‘ myfile
比较表达式匹配:$NF == “A”
特殊的pattern:BEGIN END ..
BEGIN 在读入文件之前匹配成功, 即为在读入文件之前这条 rule 就已经执行了.
END 在处理完文件之后才匹配陈宫, 即在处理完文件之后才会执行这条 rule.
awk 'BEGIN { n=5 } NR==n { print $0 } END { print $0 }' myfile
这个代表先给n赋值一个5, 然后匹配行号为n的记录,然后打印该行。
对于**
END
:这个模式在处理完所有输入之后执行。通常用于打印汇总结果、执行清理工作等。在这个例子中,END { print $0 }
表示在处理完所有输入后,打印最后处理的那条记录(行)**
模式范围:begpat,endpat 是由两个 pattern 组成, 每个 pattern可以是任意的非特殊类型(非BEGIN/END模式类型)的pattern类型(可以是正则,比较,常量等pattern).
- 大概的意思就是,先再begpat 找到开始的所在行记录, 然后后面匹配的行无论是否满足条件都会执行action,直到匹配第二个pattern成功的时候就结束 (包括最后一个pattern,他也会被打印)
awk 'NR==4, /555-3430/ { print $0}' myfile
详细的action
print
- 打印文本。- 示例:
awk '{print "Hello, World!"}' myfile.txt
- 示例:
printf
- 格式化打印文本。- 示例:
awk '{printf "%-10s %s\n", $1, $2}' myfile.txt
- 示例:
gsub
- 全局替换,替换所有匹配的模式。- 示例:
awk '{gsub(/old/, "new")} 1' myfile.txt
- 示例:
sub
- 替换第一个匹配的模式。- 示例:
awk '{sub(/first/, "initial")} 1' myfile.txt
- 示例:
split
- 根据分隔符分割字符串。- 示例:
awk '{split($1, arr, ","); print arr[1], arr[2]}' myfile.txt
- 示例:
match
- 查找字符串中第一个匹配的模式。- 示例:
awk '{if (match($1, /pattern/)) print "Match found"}' myfile.txt
- 示例:
tolower
- 将字符串转换为小写。- 示例:
awk '{print tolower($1)}' myfile.txt
- 示例:
toupper
- 将字符串转换为大写。- 示例:
awk '{print toupper($1)}' myfile.txt
- 示例:
length
- 返回字符串的长度。- 示例:
awk '{print length($1)}' myfile.txt
- 示例:
sprintf
- 根据指定的格式将数字转换为字符串。- 示例:
awk '{print sprintf("%02d", $1)}' myfile.txt
- 示例:
index
- 返回一个子串在字符串中首次出现的位置。- 示例:
awk '{print index($1, "sub")}' myfile.txt
- 示例:
sin
,cos
,atan2
- 三角函数。- 示例:
awk '{print sin($1), cos($1)}' myfile.txt
- 示例:
sqrt
- 开平方根。- 示例:
awk '{print sqrt($1)}' myfile.txt
- 示例:
int
- 向下取整。- 示例:
awk '{print int($1)}' myfile.txt
- 示例:
rand
- 生成一个随机数。- 示例:
awk '{print rand()}' myfile.txt
- 示例:
srand
- 设置随机数生成器的种子。- 示例:
awk 'BEGIN{srand(1)}{print rand()}' myfile.txt
- 示例:
system
- 执行 shell 命令。- 示例:
awk '{system("ls")}' myfile.txt
- 示例:
getline
- 从文件中读取一行。- 示例:
awk '{getline line < "file.txt"; print line}' myfile.txt
- 示例:
close
- 关闭文件。- 示例:
awk '... {getline line < "file.txt"; close("file.txt")}' myfile.txt
- 示例:
for
- 循环结构。- 示例:
awk 'BEGIN{for (i=1; i<=5; i++) print i}'
- 示例:
一些实战操作
awk '{a=1}1' myfile
他最后的意思是 a = 1, 然后打印所有的行
Java虚拟机的基本原理
JVM类加载的过程
加载
查看字节流
创建类
类加载器
启动类加载器
除了启动类加载器, 其他的加载器都是Java.lang.ClassLoader 的子类
jre/lib
扩展类加载器
jre/lib/ext
应用类加载器
自定义类加载器 : 可以通过对类文件进行加密, 来绕开前三种加载器对类文件进行加载, 起到有效的作用
再Java 9之后, 提出了平台加载器, 取代了原本的扩展类, 以及部分启动类加载器的功能
类的唯一性 是由 类加载器实例 以及类的全名一同确定的。
双亲委派模型
链接
验证
略
准备
给静态字段分配内存, 赋初值。 (还有创建 虚方法动态绑定 需要用的类方法表)
解析 : 将类中的符号引用转变为实际引用, 如果遇到指向的引用是一个未被加载的类,或者未被加载类或字段方法, 就会解析触发这个类的加载。
初始化
静态字段赋值
如果这个字段被final 修饰,如果这个类型是 字符串或者 基本类型就会Java编译器标记为常量值, 有Java虚拟机负责初始化, 否则,连同静态代码块被Java编译器合并放入到
方法里面执行 方法执行 (会通过加锁的方式来保证 只会执行一次) 类初始化的触发时机
MethodHandle 实例 初次调用 ,会初始化该MethodHandle指向的方法所在类
JVM的编译方式-HotSpot
解释编译和即时编译
- 对于从java 字节码 到 机器码的过程
再HotSpot 中 有 两种 方式 ,
解释编译:即逐条将字节码翻译成机器码并执行
即时编译:即将一个方法中包含的所有字节码编译成机器码后再执行。
它们的优点 和 缺点分别是什么
前者的优势在于无需等待编译,而后者的优势在于实际运行速度更快
混合模式
实际上,对于HotSpot 中 会采用混合模式, 他会先解释执行字节码, 然后将其中反复执行的热点代码, 以方法为单位进行即时编译
理论上 Java 有可能跑的比c++快的原因
我们可以举 多态的例子,如果 对于 一个虚方法, 它的实际目标方法只有一个, Java使用即时编译的方式, 可以直接规避虚方法的调用开销。 直接使用这个实际目标方法。 这就会比静态编译的c++要快。
当然上面的 两种方式只是java 其中的一种优化手段, 实际上 还有C1即时编译器, C2即时编译器。HotSpot 默认采用分层编译的方式:热点方法首先会被 C1 编译,而后热点方法中的热点会进一步被 C2 编译。
JVM方法调用
Java方法调用相关指令
具体来说,Java 字节码中与调用相关的指令共有五种。
- invokestatic:用于调用静态方法。
- invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
- invokevirtual:用于调用非私有实例方法。
- invokeinterface:用于调用接口方法。
- invokedynamic:用于调用动态方法。
重载的深入学习
- Java虚拟机分辨识别方法的 三个特征:类名、方法名、方法描述符。
重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:
- 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
- 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
- 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。
- 如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。(也就是说更加具体的, 更加接近血源关系的)
比如哦 一个 String 的 参数类型 和 一个Object 的 参数类型, 传入 一个 null, String 类型会更加合适
同时需要注意的是 重载的作用范围应该是当前类以及父类。
如果 子类 有一个方法的参数类型 返回类型 方法名 和 父类 相同,如果两个方法都是静态的, 那么子类方法就会隐去父类中的方法, 如果都不是,那么就会重写
:question: 编译器为了解决Java语言 和 Java虚拟机重写判断不同的情况使用了 什么方式来解决。
编译器会生成桥接方法来实现对Java中的重写语义
java语言经过前期编译器的桥接形成class。此处的桥接是将符合java语法定义的重写(如java语法重写允许返回协变类型,即子类在重写父类方法时可以返回父类方法中定义的返回体的子类), 桥接成符合java虚拟机规定的重写(方法描述符)
注意Java 语言重写的返回类型不能随便,必须和父类相同, 或者是协变返回类型。
从 Java 语言的角度方法参数 和 方法名相同就是 重载, 而对于 Java虚拟机来说 类名 方法名, 方法描述符相同才是重载。
因为Java虚拟机允许方法名和方法参数相同,但是返回类型不同的 方法相较于Java语言。 也就是说 返回类型 还得相同才算是重写
对于非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找。
- 在 C 中查找符合名字及描述符的方法。
- 如果没有找到,在 C 的父类中继续搜索,直至 Object 类。(说明子类可以调用父类的静态方法)
- 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。并且,如果目标方法在间接实现的接口中,则需满足 C 与该接口之间没有其他符合条件的目标方法。如果有多个符合条件的目标方法,则任意返回其中一个。
虚方法调用
虚方法调用: 实际上就是invokevirtual,invokeinterface指令, 这两个指令都属于虚方法调用。(实际上 就是非私有实例方法, 接口方法调用被编译成的指令)
:question: 什么是动态绑定:java 虚拟机根据 调用者传递的动态类型, 来确认虚方法调用的目标方法。
:question:如何优化调用的速度呢? Java使用了内联缓存的方式来加速动态绑定的速度。
方法表
方法表的本质就是一个数组, 然后它的每一个元素都会指向当前类以及祖先类中的非私有实例方法
- :question:它满足两个特点
- 子类方法表中包含父类方法表中的所有方法
- 子类方法表中的索引值,与它所重写的父类方法的索引值相同。
- 对于动态绑定而言, 最后实际引用就是方法表的索引值
JVM处理异常
mindmap root(JVM异常处理机制) 1.异常处理要素 2.异常的基本概念 3.Java虚拟机捕获异常 4.Java 7的新特性 5.总结与实践
JVM invokedynamic
:question: 什么是方法句柄
方法句柄是一个强类型的,能够被直接执行的引用,该引用可以指向常规的静态方法或者实例方法,也可以指向构造器或者字段。当指向字段时,方法句柄实则指向包含字段访问字节码的虚构方法,语义上等价于目标字段的 getter 或者 setter 方法
- MethodType(方法句柄类型) 是由方法的 返回类型和 参数类型构成
:question: 怎么创建方法句柄
MethodHandles.Lookup类来完成
1 | class Foo { |
:question: 如何获取方法句柄的两种方式?
方法句柄提供了 两种方式
使用反射API中的Method 来查找
根据类、方法名、以及方法句柄类型来查找。 (这种方式还需要根据调用的方法来区分 调用方法句柄的类型,
- 如果是想要invokestatic 来调用, 就需要使用Lookup.findStatic
- 对于用invokevirtual 调用的实例方法,以及用 invokeinterface 调用的接口方法,我们需要使用 findVirtual 方法;
- 对于用 invokespecial 调用的实例方法,我们则需要使用 findSpecial 方法。
1
2
3
4
5
6
7
8// 类的定义再上面
// 获取方法句柄的不同方式
MethodHandles.Lookup l = Foo.lookup();
// 具备Foo类的访问权限
Method m = Foo.class.getDeclaredMethod("bar", Object.class);
MethodHandle mh0 = l.unreflect(m);
MethodType t = MethodType.methodType(void.class, Object.class);
MethodHandle mh1 = l.findStatic(Foo.class, "bar", t);
:question: 虽然有权限问题,但是和反射api有所不同
它的权限检查是在句柄的创建过程完成的。因此 如果句柄被多次调用,那么就可以省下重复权限检查的开销
举个例子,对于一个私有字段,如果 Lookup 对象是在私有字段所在类中获取的,那么这个 Lookup 对象便拥有对该私有字段的访问权限,即使是在所在类的外边,也能够通过该 Lookup 对象创建该私有字段的 getter 或者 setter。
1 | class Foo { |
方法句柄 API 有一个特殊的注解类 @PolymorphicSignature。在碰到被它注解的方法调用时,Java 编译器会根据所传入参数的声明类型来生成方法描述符,而不是采用目标方法所声明的描述符。
方法句柄的 两种操作方式
再上面获取到的mh1, 或者mh0 变量
我们可以使用 mh.invokeExact(new Object()); 或者 mh1.invoke()来进行 操作。
invokeExact 方法, 对参数要求非常严格, 必须保证 方法描述符完全相同
invoke 方法 (会起到一个自动匹配的作用)
这是它背后的操作
MethodHandle.asType 它是一个适配器句柄, 先对传入的参数 进行适配, 然后再调用原方法句柄, 给调用者
:question: 方法句柄的实现
注意运行, 方法句柄的调用链会被隐藏, 所以需要使用
-XX:+ShowHiddenFrames
来隐藏栈信息
实际上,虚拟机会对invokeExact调用特殊处理,调至一个共享、与方法句柄类型相关的特殊适配器, 这个适配器就是LambdaForm
我们可以通过添加虚拟机参数将之导出成 class 文件(-Djava.lang.invoke.MethodHandle.DUMP_CLASS_FILES=true)
1 | final class java.lang.invoke.LambdaForm$MH000 { static void invokeExact_MT000_LLLLV(jeava.lang.bject, jjava.lang.bject, jjava.lang.bject); |
然后会调用invokeBasic 方法, 它同样会被Java虚拟机调用到方法句柄所持有的适配器中,(这里也是LambdaForm)然后它是会获取到MemberName类型的字段
并且以此为参数, 调用linkToStatic 方法,同样类似于上面的处理,他会根据传入的MemberName, 直接跳转到目标方法
然后的话, 对于前面的Invokers.checkCustomized, 当它的调用次数大于127的时候, 就会有一个优化:会为这个句柄生成特有的适配器, 他会认为方法句柄为常量, 然后直接获取到MemberName 。 接着就可以调用linkToStatic 方法。
Java对象的内存布局
dup指令:将操作数栈栈顶的元素弄了一个备份
为什么要进行备份:
0: new #16 // class jvm/fenixsoft/DynamicDispath$Man 3: dup 4: invokespecial #18 // Method jvm/fenixsoft/DynamicDispach$Man."<init>":()V 7: astore_1
一开始是new指令在堆上分配了内存并向操作数栈压入了指向这段内存的引用,之后dup指令又备份了一份,那么操作数栈顶就有两个,再后是调用invokespecial #18指令进行初始化,此时会消耗一个引用作为传给构造器的“this”参数,那么还剩下一个引用,会被astore_1指令存储到局部变量表中
显示调用分为两种
- this
- super
都需要作为构造器的第一条语句
压缩指针
*
mindmap root(对象头)) 标记字段 存储 Java 虚拟机有关该对象的运行数据(哈希码、GC 信息以及锁信息) 类型指针 指向该对象的类
- 这两个字段都是64位
- 但是我们可以
-XX:+UseCompressedOops
来压缩指针 这样就可以将对象头从16字节变成12字节
内存指针原理
打个比方,路上停着的全是房车,而且每辆房车恰好占据两个停车位。现在,我们按照顺序给它们编号。也就是说,停在 0 号和 1 号停车位上的叫 0 号车,停在 2 号和 3 号停车位上的叫 1 号车,依次类推。
原本的内存寻址用的是车位号。比如说我有一个值为 6 的指针,代表第 6 个车位,那么沿着这个指针可以找到 3 号车。
现在我们规定指针里存的值是车号,比如 3 指代 3 号车。当需要查找 3 号车时,我便可以将该指针的值乘以 2,再沿着 6 号车位找到 3 号车
内存对齐
- 内存对齐
-XX:ObjectAlignmentInBytes
8 - 在默认情况下,Java 虚拟机中的 32 位压缩指针可以寻址到 2 的 35 次方个字节,也就是 32GB 的地址空间(超过 32GB 则会关闭压缩指针)
:question: 字段内存对齐:让字段只出现同一CPU的缓存行中
如果不对齐的话, 那么读取的话就会存在跨缓存行的字段, 字段读取就得替换两个缓存行,影响效率
字段重排列
字段重排列,顾名思义,就是 Java 虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。Java 虚拟机中有三种排列方法(对应 Java 虚拟机选项 -XX:FieldsAllocationStyle,默认值为 1)
必须满足下面两个规则
- 如果一个字段占据 C 个字节,那么该字段的偏移量需要对齐至 NC。这里偏移量指的是字段地址与对象的起始地址差值
- 子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致
Java 虚拟机还会对齐子类字段的起始位置。对于使用了压缩指针的 64 位虚拟机,子类第一个字段需要对齐至 4N;而对于关闭了压缩指针的 64 位虚拟机,子类第一个字段则需要对齐至 8N。
- 注释@Contended, 用于解决字段虚共享之间的问题
虚共享是怎么回事呢?假设两个线程分别访问同一对象中不同的 volatile 字段,逻辑上它们并没有共享内容,因此不需要同步。然而,如果这两个字段恰好在同一个缓存行中,那么对这些字段的写操作会导致缓存行的写回,也就造成了实质上的共享。(volatile 字段和缓存行的故事我会在之后的篇章中详细介绍。)
Java 虚拟机会让不同的 @Contended 字段处于独立的缓存行中,因此你会看到大量的空间被浪费掉。具体的分布算法属于实现细节,随着 Java 版本的变动也比较大,因此这里就不做阐述了。
垃圾回收(上)
引用计数法 与 可达性分析
如果判断对象的 存亡
(引用计数法)统计对象的引用个数 (如果是0的话,那么对象就会被回收)
引用计数法还有一个重大的漏洞,那便是无法处理循环引用对象。
(可达性分析)这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。
可达性分析
:star: 一般而言,GC Roots 包括(但不限于)如下几种:
- Java 方法栈桢中的局部变量;
- 已加载类的静态变量;
- JNI handles;( Java Native Interface )
- 已启动且未停止的 Java 线程。
虽然 可达性不会出现引用分析的问题, 但是 可能会出现 误报和漏报的问题
漏报就是 引用 可能 没有了, 但是另外一个线程 没有把他回收
误报 就是引用 已经 没有了, 但是别的线程以为它还有, 所以直接时候, 导致 出现Java 虚拟机崩溃
漏报和误报的解决
Stop the World
Java虚拟机使用了STW的方式, 使得所有线程停止工作, 来让Java虚拟机的堆栈不会发送变化,(那么当前程序的状态我们称之为安全点)这样一来, 垃圾回收器便可以安全的执行可达性分析。
只要不离开安全点,Java虚拟机就可以再垃圾回收的同时,继续执行这段代码
类似于这种状态的有
执行 JNI 本地代码
Java 虚拟机仅需在 API 的入口处进行安全点检测(safepoint poll)
解释执行字节码
当有安全点请求时,执行一条字节码便进行一次安全点检测
执行即时编译器生成的机器码
生成机器码时,即时编译器需要插入安全点检测,以避免机器码长时间没有安全点检测的情况
具体实现为:在生成代码的方法出口以及非计数循环的循环回边(back-edge)处插入安全点检测。
线程阻塞
安全点检测的具体原理
当有安全点请求的时候, Java虚拟机会将安全点检测访问的内存所在位置设置为不可读,然后定义了一个segfault 处理器, 来截获因访问不可读内存而触发 segfault 的线程,并将它们挂起。
:question: 为什么不在每一条机器码或者每一个机器码基本块处插入安全点检测呢
第一,安全点检测本身也有一定的开销。不过 HotSpot 虚拟机已经将机器码中安全点检测简化为一个内存访问操作。在有安全点请求的情况下,Java 虚拟机会将安全点检测访问的内存所在的页设置为不可读,并且定义一个 segfault 处理器,来截获因访问该不可读内存而触发 segfault 的线程,并将它们挂起。
第二,即时编译器生成的机器码打乱了原本栈桢上的对象分布状况。在进入安全点时,机器码还需提供一些额外的信息,来表明哪些寄存器,或者当前栈帧上的哪些内存空间存放着指向对象的引用,以便垃圾回收器能够枚举 GC Roots。
垃圾回收器的三种方式
上面的可达性算法只是对垃圾进行标记, 然后, 就要开始对垃圾进行清理
清除(sweep)
即把死亡对象所占据的内存标记为空闲内存,并记录在一个空闲列表(free list)之中。当需要新建对象时,内存管理模块便会从该空闲列表中寻找空闲内存,并划分给新建的对象。
优点
原理简单
缺点
产生内存碎片,由于Java虚拟机的堆空间必须是连续分布的,所以可能出现 内存空间足够, 但是不能够进行分配的情况
压缩(compact)
即把存活的对象聚集到内存区域的起始位置,从而留下一段连续的内存空间。,
优点
这种做法能够解决内存碎片化的问题
缺点
但代价是压缩算法的性能开销
复制(copy)
即把内存区域分为两等分,分别用两个指针 from 和 to 来维护,并且只是用 from 指针指向的内存区域来分配内存。当发生垃圾回收时,便把存活的对象复制到 to 指针指向的内存区域中,并且交换 from 指针和 to 指针的内容
优点
这种做法能够解决内存碎片化的问题
缺点
堆空间的使用效率低下
垃圾回收(下)
分代回收算法
它的由来
- 多数的Java对象只存活了一小段的时间, 而存活下来的小部分Java对象会存活很长的时间
基于此提出了 分代回收算法
原理
将堆空间划分为新生代和老年代, 新建立的对象放在新生代, 当对象存活时间够长的时候, 转移到老年代。
对于新生代,我们猜测大部分的 Java 对象只存活一小段时间,那么便可以频繁地采用耗时较短的垃圾回收算法,让大部分的垃圾都能够在新生代被回收掉。
对于老年代,我们猜测大部分的垃圾已经在新生代中被回收了,而在老年代中的对象有大概率会继续存活。当真正触发针对老年代的回收时,则代表这个假设出错了,或者堆的空间已经耗尽了
当上面的情况 发送的时候, 就会进行一次全表扫描,耗时不计成本。
Java 虚拟机堆空间的划分以及回收过程
Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区
分配的方式
Eden区和Survivor 区的比例分配
默认情况是使用动态分配,(对应 Java 虚拟机参数 -XX:+UsePSAdaptiveSurvivorSizePolicy),根据生成对象的速率,以及 Survivor 区的使用情况动态调整 Eden 区和 Survivor 区的比例。
当然,你也可以通过参数 -XX:SurvivorRatio 来固定这个比例。但是需要注意的是,其中一个 Survivor 区会一直为空,因此比例越低浪费的堆空间将越高
TLAB技术
这个技术是用于解决多线程竞争堆内存分配问题的,核心原理是对分配一些连续的内存空间
:question:对于Eden区, 它是一个同步操作,可能同时多个线程申请空间,造成冲突问题?
解决方式是, Java虚拟机会预先分配多个内存。
:question:如果还是不够的话, 怎么解决(也就是希望提高分配的速度, 而不是造成线程阻塞)
答案是:这项技术被称之为 TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。
具体来说,每个线程可以向 Java 虚拟机申请一段连续的内存,比如 2048 字节,作为线程私有的 TLAB。
这个操作需要加锁,线程需要维护两个指针(实际上可能更多,但重要也就两个),一个指向 TLAB 中空余内存的起始位置,一个则指向 TLAB 末尾。
接下来的 new 指令,便可以直接通过指针加法(bump the pointer)来实现,即把指向空余内存位置的指针加上所请求的字节数
如果加法后空余内存指针的值仍小于或等于指向末尾的指针,则代表分配成功。否则,TLAB 已经没有足够的空间来满足本次新建操作。这个时候,便需要当前线程重新申请新的 TLAB。
:question:当Eden区的空间消耗完之后, 怎么办
这个时候 Java 虚拟机便会触发一次 Minor GC,来收集新生代的垃圾。存活下来的对象,则会被送到 Survivor 区
:question:当Survive区的空间消耗完成之后, 怎么办
当发生 Minor GC 时,Eden 区和 from 指向的 Survivor 区中的存活对象会被复制到 to 指向的 Survivor 区中,然后交换 from 和 to 指针,以保证下一次 Minor GC 时,to 指向的 Survivor 区还是空的
Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 - XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代
Eden 区 到 Survivors区
- 第一次GC(Minor GC):
- Eden区会定期进行垃圾回收,这个过程称为Minor GC。在Minor GC期间,JVM会检查Eden区中的对象,确定哪些对象仍然存活。
- 对象存活判断:
- 如果一个对象在Minor GC后仍然存活,它会被复制到Survivor区的其中一个区(S0或S1)。这个复制过程称为对象的“幸存”。
- 对象在Survivor区的移动:
- 当对象被复制到Survivor区后,它会在S0和S1之间来回移动。每次Minor GC后,存活的对象都会从Eden区或当前活动的Survivor区(非空的那个)复制到另一个Survivor区。
Survivors区的移动过程
- JVM 提供了一个参数, 设定为Survivors 赋值的过程次数,如果一个对象超过那个次数之后, 就会这个幸存对象放到 Tenured 区, 同时,如果当个Survivors 区 的 内存占用达到了50%, 也会将复制次数较高的对象 移动到Eden区。
- 发送GC的时候也会(实际上就是内存占用达到50%)
卡表操作 (现在认识的还不够深刻)
Minor GC 的另外一个好处是不用对整个堆进行垃圾回收。
但是,它却有一个问题(这只是我们的疑问,不是它真的又这个问题),那就是老年代的对象可能引用新生代的对象。也就是说,在标记存活对象的时候,我们需要扫描老年代中的对象。如果该对象拥有对新生代对象的引用,那么这个引用也会被作为 GC Roots
这样的话就得进行一次全表扫描操作。
为了 解决这个问题, Java虚拟机使用了卡表的方式, 对于堆空间分割成多个512字节的卡, 维护一个卡表,用来存储每个卡的标识位。 这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的
再进行MinorGC的时候,我们便可以不用扫描整个老年代,而是再卡表中寻找脏表,然后将 这些对象加入到Minor GC的GC Roots 中,当完成对所有脏卡的扫描之后, 虚拟机就会将所有脏表卡的标识位清零
同时再Minor GC 之前, 我们是不能确保脏卡中包含的指向新生代对象的引用。 其原因和如何设置卡的标识位有关。
首先,如果想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。这个操作在解释执行器中比较容易实现。但是在即时编译器生成的机器码中,则需要插入额外的逻辑。这也就是所谓的写屏障(write barrier,注意不要和 volatile 字段的写屏障混淆)
写屏障需要尽可能地保持简洁。这是因为我们并不希望在每条引用型实例变量的写指令后跟着一大串注入的指令。
因此,写屏障并不会判断更新后的引用是否指向新生代中的对象,而是宁可错杀,不可放过,一律当成可能指向新生代对象的引用
虽然写屏障不可避免地带来一些开销,但是它能够加大 Minor GC 的吞吐率( 应用运行时间 /(应用运行时间 + 垃圾回收时间) )。总的来说还是值得的。不过,在高并发环境下,写屏障又带来了虚共享(false sharing)问题[2]
写屏蔽的编译后的结果一条移位指令和一条存储指令。
1 | CARD_TABLE [this address >> 9] = DIRTY; |
- 这里的虚共享是指卡表中不同卡的标识位之间的虚共享问题
Java虚拟机中的垃圾回收器
针对新生代的垃圾回收器共有三个:Serial,Parallel Scavenge 和 Parallel New
针对老年代的垃圾回收器也有三个:刚刚提到的 Serial Old 和 Parallel Old,以及 CMS。Serial Old 和 Parallel Old 都是标记 - 压缩算法
G1(Garbage First)是一个横跨新生代和老年代的垃圾回收器。实际上,它已经打乱了前面所说的堆结构,直接将堆分成极其多个区域。每个区域都可以充当 Eden 区、Survivor 区或者老年代中的一个。它采用的是标记 - 压缩算法,而且和 CMS 一样都能够在应用程序运行过程中并发地进行垃圾回收。G1 能够针对每个细分的区域来进行垃圾回收。在选择进行垃圾回收的区域时,它会优先回收死亡对象较多的区域。这也是 G1 名字的由来
Java11 还引入了ZGC,宣称暂停时间不超过10ms
高效编译篇
多线程的三大特性: 原子性、有序性、可见性
Java内存模型
单线程 as-if-serial的作用
1 | int a=0, b=0; |
- 再多线程的情况下可能出现(r1, r2) = (0 , 0)的情况。
- 即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。
对于下面这段代码,编译器可以做出两种选择
在一开始便将 a 加载至某一寄存器中,并且在接下来 b 的赋值操作以及使用 b 的代码中避免使用该寄存器。
在真正使用 r2 时才将 a 加载至寄存器中。这么一来,在执行使用 b 的代码时,我们不再霸占一个通用寄存器,从而减少需要借助栈空间的情况
1 | int a=0, b=0; |
只在循环中添加了使用 r2,并且更新 a 的代码。由于对 b 的赋值是循环无关的,即时编译器很有可能将其移出循环之前,而对 r2 的赋值语句还停留在循环之中
1 | int a=0, b=0; |
再单线程环境下, 由于 as-if-serial的保证, 我们可以无须担心数据竞争的问题存在
而在多线程的环境下,就无法避免了, Java语言规范将其归咎为应用程序没有做出恰当的同步操作。
happens-before 关系
为了让应用程序能够避免数据竞争的干扰,Java5引入了happens-before,
它是用来用来描述两个操作的内存可见性的。如果操作 X happens-before 操作 Y,那么 X 的结果对于 Y 可见
- happens-before 不一定意味着前者一定在后者之前执行
在同一个线程中,字节码的先后顺序(program order)也暗含了 happens-before 关系:在程序控制流路径中靠前的字节码 happens-before 靠后的字节码。然而,这并不意味着前者一定在后者之前执行。实际上,如果后者没有观测前者的运行结果,即后者没有数据依赖于前者,那么它们可能会被重排序。
线程间的happens-before
- 解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。
- volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。
- 线程的启动操作(即 Thread.starts()) happens-before 该线程的第一个操作。
- 线程的最后一个操作 happens-before 它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)。
- 线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)。
- 构造器中的最后一个操作 happens-before 析构器的第一个操作。
happens-before 还具备传递关系
- 对于前面的两个 函数赋值的例子
拥有 happens-before 关系的两对赋值操作之间没有数据依赖,因此即时编译器、处理器都可能对其进行重排序。举例来说,只要将 b 的赋值操作排在 r2 的赋值操作之前,那么便可以按照赋值 b,赋值 r1,赋值 a,赋值 r2 的顺序得到(1,2)的结果。
那么如何解决这个问题呢?
将 a 或者 b 设置为 volatile 字段。比如说将 b 设置为 volatile 字段。假设 r1 能够观测到 b 的赋值结果 1。显然,这需要 b 的赋值操作在时钟顺序上先于 r1 的赋值操作。根据 volatile 字段的 happens-before 关系,我们知道 b 的赋值操作 happens-before r1 的赋值操作。
1 | int a=0; |
- 所以说解决这类问题的方式就是构造一个跨线程的的happens-before 关系。使得操作X之前的字节码结果对于Y可见
Java 内存模型的底层实现
Java 内存模型是通过内存屏障(memory barrier)来禁止重排序的。
- 对于即时编译器来说,它会针对前面提到的每一个 happens-before 关系,向正在编译的目标方法中插入相应的读读、读写、写读以及写写内存屏障。
以 volatile为例子,所插入的内存屏障将不允许 volatile 字段写操作之前的内存访问被重排序至其之后;也将不允许 volatile 字段读操作之后的内存访问被重排序至其之前
以我们日常接触的 X86_64 架构来说,读读、读写以及写写内存屏障是空操作(no-op),只有写读内存屏障会被替换成具体指令[2]。
在碰到内存写操作时,处理器并不会等待该指令结束,而是直接开始下一指令,并且依赖于写缓存将更改的数据同步至主内存(main memory)之中。强制刷新写缓存,将使得当前线程写入 volatile 字段的值(以及写缓存中已有的其他内存修改),同步至主内存之中。由于内存写操作同时会无效化其他处理器所持有的、指向同一内存地址的缓存行,因此可以认为其他处理器能够立即见到该 volatile 字段的最新值。
锁, volatile字段, final字段与安全发布
锁
前面提到,锁操作同样具备 happens-before 关系。具体来说,解锁操作 happens-before 之后对同一把锁的加锁操作。实际上,在解锁时,Java 虚拟机同样需要强制刷新缓存,使得当前线程所修改的内存对其他线程可见。需要注意的是,锁操作的 happens-before 规则的关键字是同一把锁。也就意味着,如果编译器能够(通过逃逸分析)证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁操作。因此也就不再强制刷新缓存。举个例子,即时编译后的 synchronized (new Object()) {},可能等同于空操作,而不会强制刷新缓存
volatile
volatile 字段可以看成一种轻量级的、不保证原子性的同步,其性能往往优于(至少不亚于)锁操作。然而,频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能。在 X86_64 平台上,只有 volatile 字段的写操作会强制刷新缓存。因此,理想情况下对 volatile 字段的使用应当多读少写,并且应当只有一个线程进行写操作。
volatile 字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile 字段的每次访问均需要直接从内存中读写
final
final 实例字段则涉及新建对象的发布问题。当一个对象包含 final 实例字段时,我们希望其他线程只能看到已初始化的 final 实例字段。
因此,即时编译器会在 final 字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(即, 将实例对象写入一个共享引用中)重排序至 final 字段的写操作之前。在 X86_64 平台上,写写屏障是空操作。 这是为了保证对象的安全发布
Java虚拟机怎么实现Synchronized
锁的抽象原理
当声明 synchronized 代码块时,编译而成的字节码将包含 monitorenter 和 monitorexit 指令
这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里的引用),作为所要加锁解锁的锁对象。
对于synchronized标记方法的时候, 你会看到字节码中方法的访问标记包括 ACC_SYNCHRONIZED。该标记表示在进入该方法时,Java 虚拟机需要进行 monitorenter 操作。而在退出该方法时,不管是正常返回,还是向调用者抛异常,Java 虚拟机均需要进行 monitorexit 操作。
1
2
3
4
5
6
7
8
9
10
11
12
13public synchronized void foo(Object lock) {
lock.hashCode();
}
// 上面的Java代码将编译为下面的字节码
public synchronized void foo(java.lang.Object);
descriptor: (Ljava/lang/Object;)V
flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED
Code:
stack=1, locals=2, args_size=2
0: aload_1
1: invokevirtual java/lang/Object.hashCode:()I
4: pop
5: returnmonitorenter 和 monitorexit的作用分别是什么
我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。
可重入锁出现的场景
如果存在多个 synchronized 方法, 它们相互调用, 那么就会出现重入的问题
重量级锁
重量级锁是 Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。
自旋
- 为了避免 争夺锁的线程直接进入到休眠的状态, 以及线程阻塞的问题,这里采取了自旋的方式,再处理器上空跑, 轮询锁释放,如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁 (Java 这里是自适应自旋,根据以往自旋等待时是否能够获得锁,来动态调整自旋的时间(循环数目))(CPU数量、上次自旋时间、CPU负载、平均自旋时间)
但是自旋带来了一个副作用
那便是不公平的锁机制。处于阻塞状态的线程,并没有办法立刻竞争被释放的锁。然而,处于自旋状态的线程,则很有可能优先获得这把锁
轻量级锁
多个线程在不同的时间段请求同一把锁,也就是说没有锁竞争。针对这种情形,Java 虚拟机采用了轻量级锁,来避免重量级锁的阻塞以及唤醒
- 对象头中的标记字段(mark word)。它的最后两位便被用来表示该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏向锁,偏向锁的后面三位是101),10 代表重量级锁,11 则跟垃圾回收算法的标记有关
当进行加锁操作时,
Java 虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中
然后,Java 虚拟机会尝试用 CAS(compare-and-swap)操作替换锁对象的标记字段。这里解释一下,CAS 是一个原子操作,它会比较目标地址的值是否和期望值相等,如果相等,则替换为一个新的值
假设当前锁对象的标记字段为 X…XYZ,Java 虚拟机会比较该字段是否为 X…X01 (判断是否是无锁)。
如果是,则替换为刚才分配的锁记录的地址。由于内存对齐的缘故,它的最后两位为 00。此时,该线程已成功获得这把锁,可以继续执行了。
如果不是 X…X01,那么有两种可能。
- 第一,该线程重复获取同一把锁。此时,Java 虚拟机会将锁记录清零,以代表该锁被重复获取。
- 第二,其他线程持有该锁。此时,Java 虚拟机会将这把锁膨胀为重量级锁,并且阻塞当前线程。
因为针对一个锁对象,每次有线程尝试获取锁也就是加锁,jvm都会针对该线程执行相同的代码策论,即使是已经获取锁的线程,也会在栈区再额外划出一块内存,再把当前锁对象的标记字段复制过来,但是复制完以后,要判断这个字段的值,如果不是01,那么要识别下当前线程是否已经获取锁,如果已经有了,那么新分配的这块内存就是多此一举,可以直接清掉或者回收
锁的释放过程
- 当进行解锁操作时,如果当前锁记录(你可以将一个线程的所有锁记录想象成一个栈结构,每次加锁压入一条锁记录,解锁弹出一条锁记录,当前锁记录指的便是栈顶的锁记录)的值为 0(前面说了 如果进入同一把锁的时候, 会将锁记录清零),则代表重复进入同一把锁,直接返回即可。
- 否则,Java 虚拟机会尝试用 CAS 操作,比较锁对象的标记字段的值是否为当前锁记录的地址。
- 如果是,则替换为锁记录中的值,也就是锁对象原本的标记字段。此时,该线程已经成功释放这把锁。
- 如果不是,则意味着这把锁已经被膨胀为重量级锁 (看上面锁竞争时候的解释)。此时,Java 虚拟机会进入重量级锁的释放过程,唤醒因竞争该锁而被阻塞了的线程
偏向锁
如果说轻量级锁针对的情况很乐观,那么接下来的偏向锁针对的情况则更加乐观:从始至终只有一个线程请求某一把锁
线程加锁
如果该锁对象支持偏向锁,那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为 101
请求锁
每当有线程请求这把锁,Java 虚拟机只需判断锁对象标记字段中:
- 最后三位是否为 101,
- 是否包含当前线程的地址,
- 以及 epoch 值(Java虚拟机全局epoch值)是否和锁对象的类的 epoch 值相同。
如果都满足,那么当前线程持有该偏向锁,可以直接返回。
epoch 是什么?
先从偏向锁的撤销讲起。当请求加锁的线程和锁对象标记字段保持的线程地址不匹配时(而且 epoch 值相等,如若不等,那么当前线程可以将该锁重偏向至自己),Java 虚拟机需要撤销该偏向锁。这个撤销过程非常麻烦,它要求持有偏向锁的线程到达安全点,再将偏向锁替换成轻量级锁
批量重偏向的特性
如果某一类锁对象的总撤销数超过了一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 Java 虚拟机会宣布这个类的偏向锁失效
宣布某个类的偏向锁失效时,Java 虚拟机实则将该类的 epoch 值加 1,表示之前那一代的偏向锁已经失效。而新设置的偏向锁则需 要复制新的 epoch 值
为了保证当前持有偏向锁并且已加锁的线程不至于因此丢锁,Java 虚拟机需要遍历所有线程的 Java 栈,找出该类已加锁的实例,并 且将它们标记字段中的 epoch 值加 1。该操作需要所有线程处于安全点状态。
批量撤销的特性
如果总撤销数超过另一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRevokeThreshold,默认值为 40),那么 Java 虚拟机会认为这个类已经不再适合偏向锁。此时,Java 虚拟机会撤销该类实例的偏向锁,并且在之后的加锁过程中直接为该类实例设置轻量级锁。
他会在上面那种阈值的基础上 在加上一条 撤销所有该类的偏向锁,之前只是针对新增添的锁对象不使用偏向锁。
对于偏向锁撤销的深入讨论可以看这个文章
(这个讲解的最详细)java - 偏向锁的【批量重偏向与批量撤销】机制 - 个人文章 - SegmentFault 思否
偏向过程的有趣解释, 如果A 和 B 结婚了, 那么B属于A, 而不能找隔壁老王,否则就得离婚(这里就是撤销锁, 这个时候会升级为轻量级锁), 但是当地政府觉得不对劲呀, 你这频繁离婚不行呀,(对于第20次,第21次没有这个权力)于是给它们一个再次结婚的机会, 但是对方的配偶必须不在(批量重偏向)(也就是当前线程不持有该锁对象,Epoch不更新, 而持有的线程的锁记录都会给epoch加上1, 同时呢会对锁Class 的epoch加1), 如果在有线程竞争锁的时候, 如果epoch 不同, 那么 就可以直接替换(直接重新结婚, 而不是离婚), 但是离婚还是过于频繁了, 于是直接干脆别结婚, 全部得给我离婚, 以后出生的小孩也不允许结婚。 (批量撤销)
Java语法糖 和 Java编译器
自动装箱 和 自己拆箱
基本类型和其包装类型之间的自动转换,也就是自动装箱、自动拆箱,是通过加入
[Wrapper].valueOf(如 Integer.valueOf)以及[Wrapper].[primitive]Value(如 Integer.intValue)方法调用来实现的
泛型的类型擦除
简单地说,那便是 Java 程序里的泛型信息,在 Java 虚拟机里全部都丢失了
- 如果继承某个类的泛型,经过类型擦除,所有的泛型参数都会变成所限定的继承类
1 | class GenericTest<T extends Number> { |
最后会变成下面这段
1 | T foo(T); |
- 虽然泛型在编译的时候会被删除, 但是, 我们可以利用泛型进行类型检查
桥接方法
1 | class Merchant<T extends Customer> { |
- 对于上面的代码块
- 显然上面代码是符合重写规范的,但是在Java虚拟机中经过类型擦除之后, 父类的方法描述符为 (LCustomer;)D,而子类的方法描述符为 (LVIP;)D。这显然不符合 Java 虚拟机关于方法重写的定义
- 为了保证Java字节码能够符合Java字节码的要求,Java编译器额外添加了一个桥接方法,这个桥接方法在字节码层面重写了父类的方法,并将调用子类的方法
1 | class VIPOnlyMerchant extends Merchant<VIP> |
这里的checkcast 就是向下转型的意思
该桥接方法的访问标识符除了代表桥接方法的 ACC_BRIDGE 之外,还有 ACC_SYNTHETIC。它表示该方法对于 Java 源代码来说是不可见的。当你尝试通过传入一个声明类型为 Customer 的对象作为参数,调用 VIPOnlyMerchant 类的 actionPrice 方法时,Java 编译器会报错,并且提示参数类型不匹配
即时编译(上)
分层编译模式
在 Java 7 以前,我们需要根据程序的特性选择对应的即时编译器。对于执行时间较短的,或者对启动性能有要求的程序,
我们采用编译效率较快的 C1,对应参数 -client。对于执行时间较长的,或者对峰值性能有要求的程序,
我们采用生成代码执行效率较快的 C2,对应参数 -server。
Java 7 引入了分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了 C1 的启动性能优势和 C2 的峰值性能优势。
分层编译将Java虚拟机的执行状态分为五个层次
- 解释执行;执行不带 profiling 的 C1 代码
- 执行仅带方法调用次数以及循环回边
- 执行次数 profiling 的 C1 代码;
- 执行带所有 profiling 的 C1 代码;
- 执行 C2 代码。
0 层解释执行,1 层执行没有 profiling 的 C1 代码,2 层执行部分 profiling 的 C1 代码,3 层执行全部 profiling 的 C1 代码,和 4 层执行 C2 代码。
Profiling 是通过收集程序运行时的信息来研究程序行为的动态分析方法
对于C1, C2它们构成了四层的状态, 对于1层和4层称之为终止状态, 如果编译后代码没有失效,那么 Java 虚拟机是不会再次发出该方法的编译请求的
如果方法的字节码数目比较少(如 getter/setter),而且 3 层的 profiling 没有可收集的数据。那么,Java 虚拟机断定该方法对于 C1 代码和 C2 代码的执行效率相同。在这种情况下,Java 虚拟机会在 3 层编译之后,直接选择用 1 层的 C1 编译。由于这是一个终止状态,因此 Java 虚拟机不会继续用 4 层的 C2 编译。
Java8默认开启分层编译, 并且如果你关闭了分层编译的话, 那么就会选择使用C2
如果你希望只是用 C1,那么你可以在打开分层编译的情况下使用参数
-XX:TieredStopAtLevel=1
。在这种情况下,Java 虚拟机会在解释执行之后直接由 1 层的 C1 进行编译。
即时编译的触发
由方法调用计数器和循环回边计数器触发的。在使用分层编译的情况下,触发编译的阈值是根据当前待编译的方法数目动态调整的
- 对于下面这段代码
1 | public static void foo(Object obj) { |
- 它对应的Java字节码是
1 | public static void foo(java.lang.Object); |
这段字节码是一个简单的 Java 方法,命名为
foo
,接受一个java.lang.Object
类型的参数。以下是对字节码的解释:
iconst_0
: 将整数常量0推送到栈顶。istore_1
: 将栈顶的整数值存储到局部变量1中。iconst_0
: 再次将整数常量0推送到栈顶。istore_2
: 将栈顶的整数值存储到局部变量2中。goto 14
: 无条件跳转到第14行。接下来是一个循环:
iload_1
: 将局部变量1的整数值加载到栈顶。iload_2
: 将局部变量2的整数值加载到栈顶。iadd
: 从栈顶弹出两个整数值,相加,并将结果推送到栈顶。istore_1
: 将栈顶的整数值存储到局部变量1中。iinc 2, 1
: 将局部变量2的值增加1。iload_2
: 将局部变量2的整数值加载到栈顶。sipush 200
: 将一个短整数常量200推送到栈顶。if_icmplt 7
: 比较栈顶的两个整数值,如果第二个值小于第一个值,则跳转到第7行。否则,继续执行下一条指令。return
: 返回。简单来说,这段字节码是一个循环,从0开始,每次循环将局部变量1增加局部变量2的值,直到局部变量2的值达到200为止,然后返回。
- 启用分层编译的时候阈值是动态调整的。
OSR编译
OSR 实际上是一种技术,它指的是在程序执行过程中,动态地替换掉 Java 方法栈桢,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换 (这个会在 去优化的地方用到)
决定一个方法是否为热点代码的因素
- 方法的调用方法
- 循环回边的执行次数
除了以方法为单位的即时编译, Java 虚拟机还存在着另一种以循环为单位的即时编译,叫做 On-Stack-Replacement(OSR)编译
即时编译(下)
Profiling
在通常情况下,我们不会在解释执行过程中收集分支 profile 以及类型 profile。只有在方法触发 C1 编译后,Java 虚拟机认为该方法有可能被 C2 编译,方才在该方法的 C1 代码中收集这些 profile
基于分支Profile优化
根据条件跳转指令的分支 profile,即时编译器可以将从未执行过的分支剪掉,以避免编译这些很有可能不会用到的代码,从而节省编译时间以及部署代码所要消耗的内存空间。此外,“剪枝”将精简程序的数据流,从而触发更多的优化。
- 一阶段
- 如果确保f 是 true 的时候
- 剪纸优化
基于类型profile优化
另外一个例子则是关于 instanceof 以及方法调用的类型 profile。下面这段代码将测试所传入的对象是否为 Exception 的实例,如果是,则返回它的系统哈希值;如果不是,则返回它的哈希值
1 | public static int hash(Object in) { |
判断对象是不是final修饰, 如果是 ,那么只需要比较测试对象的动态类型是否为final类型。
如果基于Integer , 他就会做出类型profile优化, (没看懂)
否则会进入到下面这种情况
去优化
Java 虚拟机给出的解决方案便是去优化,即从执行即时编译生成的机器码切换回解释执行
在生成的机器码中,即时编译器将在假设失败的位置上插入一个陷阱(trap)。
该陷阱实际上是一条 call 指令,调用至 Java 虚拟机里专门负责去优化的方法。
与普通的 call 指令不一样的是,去优化方法将更改栈上的返回地址,并不再返回即时编译器生成的机器码中
建议直接看 17 | 即时编译(下)-深入拆解 Java 虚拟机-极客时间 (geekbang.org)
有点晕
即时编译器的中间表达类型
IR 中间表达形式
编译器分为前端 和 后端
前端的作用:所输入的程序进行词法分析、语法分析、语义分析,然后生成中间表达形式(也即是IR)
后端的优化:IR 进行优化,然后生成目标代码。
Java 字节码本身并不适合直接作为可供优化的 IR。这是因为现代编译器一般采用静态单赋值(Static Single Assignment,SSA)IR。这种 IR 的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后才能使用。
不过,如果借助了 SSA IR,编译器则可以通过查找赋值了但是没有使用的变量,来识别冗余赋值。除此之外,SSA IR 对其他优化方式也有很大的帮助,例如常量折叠(constant folding)、常量传播(constant propagation)、强度削减(strength reduction)以及死代码删除(dead code elimination)等。
1
2
3
4
5示例:
x1=4*1024经过常量折叠后变为x1=4096
x1=4; y1=x1经过常量传播后变为x1=4; y1=4
y1=x1*3经过强度削减后变为y1=(x1<<1)+x1
if(2>1){y1=1;}else{y2=1;}经过死代码删除后变为y1=1
Sea-of-nodes
HotSpot 里的 C2 采用的是一种名为 Sea-of-Nodes 的 SSA IR。它的最大特点,便是去除了变量的概念,直接采用变量所指向的值,来进行运算。
在上面这段 SSA 伪代码中,我们使用了多个变量名 x1、x2、y1 和 y2。这在 Sea-of-Nodes 将不复存在。
IR图的指令
IR图中一些符号解释(以下是个人简单理解,仅供参考):
常量值:C(0)、C(1)。就是常量值1、2 (类型是i32)
参数值P(0)、P(1)。就是方法参数0和方法参数1=>上面int a,int b
Phi(IR节点1,IR节点2,内存类型)。(i32可能是说int 32位 ,方便分配内存吧?个人猜测老师指正)
- 过于复杂 , 后期再更新
HotSpot虚拟机的intrinsic
Indexof的优化
String.indexOf方法和自己实现的indexOf方法在字节码层面上差不多,为什么执行效率却有天壤之别呢?今天我们就来看一看
1 | public int indexOf(String str) { |
在 Java 9 之前,字符串是用 char 数组来存储的,主要为了支持非英文字符。然而,大多数 Java 程序中的字符串都是由 Latin1 字符组成的。也就是说每个字符仅需占据一个字节,而使用 char 数组的存储方式将极大地浪费内存空间。
Java 9 引入了 Compact Strings[1]的概念,当字符串仅包含 Latin1 字符时,使用一个字节代表一个字符的编码格式,使得内存使用效率大大提高。
关键在于对StringLatin1.indexOf方法的调用
唯一值得注意的便是方法声明前的 @HotSpotIntrinsicCandidate注解。
- 在 HotSpot 虚拟机中,所有被该注解标注的方法都是 HotSpot intrinsic。对这些方法的调用,会被 HotSpot 虚拟机替换成高效的指令序列。而原本的方法实现则会被忽略掉
1 |
|
X86_64 体系架构的 SSE4.2 指令集就包含一条指令 PCMPESTRI,让它能够在 16 字节以下的字符串中,查找另一个 16 字节以下的字符串,并且返回命中时的索引值。
因此,HotSpot 虚拟机便围绕着这一指令,开发出 X86_64 体系架构上的高效实现,并替换原本对StringLatin1.indexOf方法的调用
addExect的优化
在 Java 层面判断 int 值之和是否溢出比较费事。我们需要分别比较两个 int 值与它们的和的符号是否不同。如果都不同,那么我们便认为这两个 int 值之和溢出。对应的实现便是两个异或操作,一个与操作,以及一个比较操作。
- 在 X86_64 体系架构中,大部分计算指令都会更新状态寄存器(FLAGS register),其中就有表示指令结果是否溢出的溢出标识位(overflow flag)。因此,我们只需在加法指令之后比较溢出标志位,便可以知道 int 值之和是否溢出了。对应的伪代码如下所示
1 | public static int addExact(int x, int y) { |
bitCount优化
1 |
|
我们可以看到,Integer.bitCount方法的实现还是很巧妙的,但是它需要的计算步骤也比较多。在 X86_64 体系架构中,我们仅需要一条指令popcnt,便可以直接统计出 int 值中 1 的个数
intrinsic的方法内联
先略 (未来有空补上)
逃逸分析
逃逸分析是“一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针”
什么是逃逸
在 Java 虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译器判断对象是否逃逸的依据,
- 对象是否被存入堆中(静态字段或者堆中对象的实例字段),
- 对象是否被传入未知代码中。
前者很好理解:一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。
关于后者,由于 Java 虚拟机的即时编译器是以方法为单位的,对于方法中未被内联的方法调用,即时编译器会将其当成未知代码,毕竟它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中。因此,我们可以认为方法调用的调用者以及参数是逃逸的。
1 | public void forEach(ArrayList<Object> list, Consumer<Object> f) { |
可以看到,这段代码所新建的ArrayList$Itr实例既没有被存入任何字段之中,也没有作为任何方法调用的调用者或者参数。因此,逃逸分析将断定该实例不逃逸。
基于逃逸分析的优化
即时编译器可以根据逃逸分析的结果进行诸如
- 锁消除
- 栈上分配
- 标量替换
锁消除
我们先来看一下锁消除。如果即时编译器能够证明锁对象不逃逸,那么对该锁对象的加锁、解锁操作没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。在这种情况下,即时编译器可以消除对该不逃逸锁对象的加锁、解锁操作。
在介绍 Java 内存模型时,我曾提过synchronized (new Object()) {}会被完全优化掉。这正是因为基于逃逸分析的锁消除。由于其他线程不能获得该锁对象,因此也无法基于该锁对象构造两个线程之间的 happens-before 规则。
synchronized (escapedObject) {}则不然。由于其他线程可能会对逃逸了的对象escapedObject进行加锁操作,从而构造了两个线程之间的 happens-before 关系。因此即时编译器至少需要为这段代码生成一条刷新缓存的内存屏障指令
栈上分配
我们知道,Java 虚拟机中对象都是在堆上分配的,而堆上的内容对任何线程都是可见的。与此同时,Java 虚拟机需要对所分配的堆内存进行管理,并且在对象不再被引用时回收其所占据的内存。
如果逃逸分析能够证明某些新建的对象不逃逸,那么 Java 虚拟机完全可以将其分配至栈上,并且在 new 语句所在的方法退出时,通过弹出当前方法的栈桢来自动回收所分配的内存空间。这样一来,我们便无须借助垃圾回收器来处理不再被引用的对象。
标量替换
- 标量替换是将对象 分解为不可再分解的变量,如java内部的基本数据类型、引用类型等
- 也就是对象不会分配到堆内存上面,而是将成员变量拆解之后,作为栈帧的局部变量。这样随着方法调用结束,栈帧也会销毁。有效的减少了堆中创建对象及gc次数
- 同时因为这样对象的对象头也消失,所以可以节省空间
部分逃逸分析
部分逃逸分析是一种附带了控制流信息的逃逸分析。它将判断新建对象真正逃逸的分支,并且支持将新建操作推延至逃逸分支
比如下面这个例子
1 | public static void bar(boolean cond) { |