深入拆解java虚拟机-笔记
Posted 5ycode
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了深入拆解java虚拟机-笔记相关的知识,希望对你有一定的参考价值。
java代码是怎么运行的?
jvm具体是怎么运行java字节码的?
在HotSpot里,有两种编译形式,
-
一种是解释执行,逐条将字节码翻译成机器码并执行。(无需等待编译)
-
一种是即时编译(Just in Time compilation)JIT ,将一个方法中包含的字节码编译成机器码再执行。(运行速度快)
HotSpot 默认采用混合模式,综合了两者的优点。先解释执行字节码,将其中反复执行的热点代码,以方法为单位进行即时编译。
即时编译器:
-
C1 又叫Client编译器,面向的是对启动性能有要求的或执行时间较短的,客户端GUI程序,采用的优化手段相对简单,因此编译时间较短;
-
C2 又叫Server编译器,面向的是对峰值性能有要求的服务端程序,采用的优化手段相对复杂,因此编译时间较长,生成的代码执行效率较高;
-
Graal 替代C2(通过参数:-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler 启用)
jdk7引入的分层编译(使用-XX:+TieredCompilation)的概念,综合了C1的启动性能优势和C2的峰值性能优势;从JDK7开始 hotspot默认采用分层编译,热点方法首先会被C1编译,而后热点方法中的热点会进一步被C2编译;
Hotspot即时编译是放在额外的编译线程中进行的。hotspot会根据cpu的数量设置编译线程的数目,并且按照1:2的比例配置给C1及C2编译器;
分层编译将java虚拟机的执行状态分为5个层级
-
0 解释执行
-
1 执行不带profiling的C1生成的机器码
-
2 执行仅带方法调用次数以及循环回边执行次数profiling的C1代码
-
3 执行带所有profiling的C1代码
-
4 执行C2代码
可以通过参数-XX:TieredStopAtLevel=1 来指定直接由一层c1来执行
在不启动分层编译的情况下,当方法的调用次数和循环回边的次数的和,超过由参数-XX:CompileThreshold指定的阈值时(使用C1时,该值为1500,使用C2时,该值为10000),便会触发即时编译。
在64位jvm中,默认编译线程的总数目是根据处理器数量来调整的( -XX:+CICompilerCountPerCPU 默认为true);可以通过参数-XX:+CICompilerCount=N 强制设定总编译线程数,这两个参数互斥,开启以后CICompilerCountPerCPU就为false。
OSR (On-Stack-Replcement))编译
决定一个方法是否为热点代码的因素:
-
方法调用次数
-
循环回边次数
OSR它指的是在程序执行过程中,动态地替换掉java方法栈帧,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。去优化(deoptimization)采用的就是OSR技术.
在不启用分层编译的情况下,触发OSR编译的阈值是-XX:CompileThreshold指定阈值的倍数。在启用分层编译的情况下,触发OSR编译的阈值是XX:TierXBackEdgeThreshold乘以系数
我们可以通过-XX:+PrintCompilation参数打印即时编译情况。
java虚拟是如何加载java类的?
-
编译 编译成字节码
-
装载 从字节码装载到虚拟机 双亲委派
-
jdk1.2出现的双亲委派,为了兼容以前的代码;
-
在SPI时jndi和mysql jdbc,通过线程上下文类加载器
-
OSGI 热部署时,平级加载;
-
JDK9模块化后优先模块内加载
-
Bootstrap Classloader 负责加载lib目录中的类库或通过-xbootclasspath添加的
-
Extension ClassLoader 负责加载lib\\ext目录或通过java.ext.dirs指定的
-
Application ClassLoader 负责加载用户路径上的类库
-
类的主动引用(一定会初始化)
-
类的被动引用(不会初始化)
-
创建实例(new 反射、克隆、反序列化)
-
调用类的静态成员(除final常量)和静态方法(使用invokestatic 初始化)
-
启动主类(main方法)
-
初始化子类(如果父类没有)
-
使用JDK7新加入的动态与支持时
-
当一个接口用了jdk8新加入的默认方法
-
通过子类引用父类的静态变量,不会导致子类初始化
-
通过数组定义类的引用,不会触发此类初始化
-
引用常量不会触发此类的初始化(常量在编译阶段就存入调用类的常量池了,直接就显性展示了)
-
将class字节码加载到内存中,将静态数据装载到对应的区域,在堆中生产一个代表这个类的Class对象,作为方法区类数据的访问入口
-
装载时机
-
类加载器
-
破坏双亲委派机制
-
验证 确保加载的类符合jvm规范
-
文件格式验证,主要验证字节码是否符合Class文件格式规范,在加载过程中验证,加载失败,无法进入方法区
-
元数据验证,对字节码描述的信息进行语义分析(父类是否允许继承,是否需要实现父类的方法)
-
字节码验证,通过数据流分析和控制流分析,确定程序的语义是合法、符合逻辑;
-
符号引用验证,这个阶段解析阶段执行
-
准备阶段 为类中定义的变量(静态变量)分配内存并设置初始值(分配了空间,并赋值了默认值,不同的默认值不一样),仅包含类变量,不包含实例变量。同时还会构造与该类相关联的方法表(用于实现动态绑定)。
目的:为被加载类的静态字段分配内存。普通类型对应的默认指如下图,对象默认值为null。
- 解析 java虚拟机将常量池内的符号引用替换为直接引用的过程。
在此之前加载的类无法知道其他类及方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。当引用这些成员时,java编译器会生成一个符号引用。在运行阶段,这个符号引用,能无歧义的定位到具体目标上。
解析阶段的目的:将符号引用解析为实际引用,如果符号引用指向一个未被加载的类,或者未被加载累的字段或方法,那么解析会触发这个类的加载(不一定会触发这个累的链接以及初始化)。
符号引用:以一组符号来描述引用的目标
直接引用(静态绑定):是可以直接指向目标的指针,相对变异量或者是一个能间接定位到目标的句柄。解析包括:
-
类或接口
-
字段
-
方法
-
接口方法
-
初始化 是类加载过程中的最后一个步骤,知道初始化才真正执行雷总编写的java代码。初始化执行的类构造器clinit方法的过程。clinit是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的。
-
被final修饰的静态字段(基本类型或字符串)被编译器标记为常量值;
-
为常量值字段赋值
-
jvm会通过加锁来确保累的方法仅被执行一次
-
使用
-
卸载
JVM 的静态绑定和动态绑定
java字节码与调用相关的指令:
-
invokestatic 用于调用静态方法
-
invokespecial 用于调用室友实例方法、构造器,以及使用super关键字调用父类的实例方法或构造器,和所实现接口的默认方法
-
invokevirtual 用于调用非私有实例方法
-
invokeinterface 用于调用接口方法
-
invokedynamick 用于调用动态方
调用指令的符号引用
符号引用存储在class文件的常量池中。
jvm识别方法的关键在于类名、方法名以及方法描述符。
方法描述符是由方法的参数类型以及返回类型构成,在同一个类中不会出现相同的方法描述符。
在编译过程中,我们并不知道目标方法的具体内存地址。因此,java编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。
根据目标方法是否为接口:引用分为接口符号引用和非接口符号引用。
Java 里所有非私有实例方法调用都会被编译成 invokevirtual 指 令,而接口方法调用都会被编译成 invokeinterface 指令
非接口符号引用查找步骤:
-
在类中查找符合名字以及描述符的方法;
-
没有找到继续在父类中搜索,直至Object类;
-
还没找到,在直接实现或间接实现的接口中搜索(这一步必须是非私有,非静态的)
接口符号引用查找步骤:
-
在接口中查找符合名字以及描述符的方法
-
如果没有找到,在object类中的公有实例方法中搜索;
-
还没找到,则在父接口中搜索(这一步必须是非私有,非静态的)
方法调用
java里所有非私有实例方法调用都会被编译成invokevirtual指令,而接口方法调用都会被编译成invokeinterface指令。这两种指令都属于虚方法调用
动态绑定: jvm需要根据调用者的动态类型,来确定虚方法调用的目标方法,这个过程叫动态绑定。
-
运行过程中根据调用者的动态类型来识别目标方法的情况;
-
重写被称为动态绑定
-
invokevirtual和invokeinterface的调用
-
空间换时间的策略实现动态绑定(每个类生成一个方法表,用以快速定位目标方法)
静态绑定:
-
解析时便能够直接识别目标方法
-
重载,编译时多动态都可以称为静态绑定
-
invokestatick 和 invokespecial
-
final修饰的方法
调用指令的符号引用 在编译过程中,并不知道目标方法的具体内存地址。因此,jvm会暂时用符号引用来表示目标方法,这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。在解析阶段需要替换为实际引用
方法表
在加载的准备阶段,会构造与该类相关联的方法表。
-
动态绑定实现的关键
-
本质是一个数组(在解析时已经知道了长度),每个数组元素指向一个当前类及其父类中非私有的实例方法。
-
子类方法表中包含父类方法表中的所有方法
-
子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同(同一个方法子类和父类索引值一样)
方法调用指令的符号引用会在执行之前解析成实际引用。
-
对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。
-
对于动态绑定实际引用则是方法表的索引值
内联缓存 (唯一多的是写缓存的开销)
是一种加快动态绑定的优化技术
缓存连续方法调用中调用者的动态类型,以及该类型对应的目标方法。当有内联缓存的时候,直接用内联缓存,当无内联缓存时,走基于方法表的动态绑定(主要耗费在寻找上)
大部分情况下,我们认为虚拟机内联缓存之后的一段时间内(缓存),方法调用的调用者动态类型保持一致。
多态优化时的术语
-
单态:指的是仅有一种状态的情况
-
多态:指的是有限数量状态的情况
-
超多态:指的是更多状态的情况。统筹我们用一个具体数值来区分多态和超多态,在这个数值之下,我们称之为多态
对于内联缓存来说有以下几种:
-
单态内联缓存:只缓存了一种动态类型以及它所对应的目标方法,命中直接返回
-
多态内联缓存:缓存了多个动态类型及其目标方法,需要逐个将所缓存的动态类型和当前动态类型进行比较(去匹配对应的,逐个查找)
-
超多态内联缓存: 放弃了优化,直接访问方法表,来动态绑定方法。
java虚拟机只采用单态内联缓存,当内联缓存没有命中时,jvm会使用方法表进行动态绑定。
任何方法调用,除非被内联,否则都会有固定开销,这些开销包括:保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。
JVM异常处理
异常的成本比较高,要记录整个栈帧的调用情况,以及调用方法在第几行
-
构造栈轨迹
-
逐一访问当前线程的java栈帧
-
记录各调用信息(包括栈帧指向的方法名,方法所在类、文件名等)
-
调用代码的行数
public class ExceptionTest {
public static void main(String[] args) {
try {
throw new ClassCastException("无法转化");
} catch (ClassCastException e) {
e.printStackTrace();
} finally {
System.out.println("finally");
}
}
}
// 对应的 Java 字节码
public class com.yxkong.exception.ExceptionTest {
public com.yxkong.exception.ExceptionTest();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
0: new #2 // class java/lang/ClassCastException 实例化一个ClassCastException
3: dup // 复制上面new指令生成的未经初始化的引用
4: ldc #3 // String 无法转化 加载字符串“无法转化”
6: invokespecial #4 // Method java/lang/ClassCastException."<init>":(Ljava/lang/String;)V 初始化ClassCastException实例
9: athrow // throw 一个异常
10: astore_1 // 将ClassCastException实例的数据保存到本地变量表
11: aload_1 // 从本地变量表中加载ClassCastException实例的数据
12: invokevirtual #5 // Method java/lang/ClassCastException.printStackTrace:()V 通过invokevirtual绑定
15: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
18: ldc #7 // String finally 加载字符串 “finally”
20: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
23: goto 37 // 跳转到指令地址37,就是return那里
26: astore_2 //
27: getstatic #6 // Field java/lang/System.out:Ljava/io/PrintStream;
30: ldc #7 // String finally
32: invokevirtual #8 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
35: aload_2
36: athrow
37: return
Exception table:
// 异常表条目
from to target type
// 表示监控索引[0,10) 中出现ClassCastException 该条目的 target指针是10
0 10 10 Class java/lang/ClassCastException
//表示异常[0,15) 中any 都会到target指针26
0 15 26 any
}
Java 7 引入了 Supressed 异常、try-with-resources,以及多异常捕获。后两者属于语法 糖,能够极大地精简我们的代码
public class SupressedTest implements AutoCloseable{
@Override
public void close() {
throw new RuntimeException("close is error");
}
public static void main(String[] args) {
try (SupressedTest test = new SupressedTest()){
throw new ClassCastException("try-with-resource");
}catch (Exception e){
e.printStackTrace();
}
}
}
执行以后输出结果
java.lang.ClassCastException: try-with-resource
at com.yxkong.exception.SupressedTest.main(SupressedTest.java:20)
Suppressed: java.lang.RuntimeException: close is error
at com.yxkong.exception.SupressedTest.close(SupressedTest.java:15)
at com.yxkong.exception.SupressedTest.main(SupressedTest.java:21)
jvm是如何实现反射的?
看下源码
public final class Method extends Executable {
@CallerSensitive
public Object invoke(Object obj, Object... args)
throws IllegalAccessException, IllegalArgumentException,
InvocationTargetException
{
if (!override) {
if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
Class<?> caller = Reflection.getCallerClass();
checkAccess(caller, clazz, obj, modifiers);
}
}
MethodAccessor ma = methodAccessor; // read volatile
if (ma == null) {
ma = acquireMethodAccessor();
}
return ma.invoke(obj, args);
}
}
MethodAccessor的实现有两种方式:见下图
见字知意:MethodAccessor有两种实现一种是本地方法实现,一种是委派实现。
在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。进入虚拟机后我们就拥有了Method实例所指向方法的具体地址。
在调用超过15 次之后,委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动生成的,它将直接使用 invoke 指令来调用目标方法。
方法的反射调用会带来不少性能开销,原因主要有三个:
-
变长参数方法导致的 Object 数组;
-
基本类型的自动装箱、拆箱
-
还有最重要的方法内联
动态实现和本地实现相比,其运行效率要快上 20 倍 。这是因为动态实现无需经过 Java到 C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上 3 到 4 倍 。
考虑到许多反射调用仅会执行一次,Java 虚拟机设置了一个阈值 15(可以通过 -Dsun.reflect.inflationThreshold= 来调整),当某个反射调用的调用次数在 15 之下时,采用本地实现;当达到 15 时,便开始动态生成字节码,并将委派实现的委派对象切换至动态实现,这个过程我们称之为 Inflation。
JVM是怎么实现invokedynamic的
jdk7 引入invokedynamic指令。允许应用程序将调用点链接至任意符合条件的方法上。具体来说,它将调用点(CallSite)抽象成一个java类,并将原本由java虚拟机控制的方法调用以及方法链接暴露给了应用程序。
在运行过程中,每一条invokedynamic指令将捆绑一个调用点,并且会调用改调用点所链接的方法句柄。
方法调用会被编译为invokestatic,invokespecial,invokevirtual,invokeinterface四种指令,这些指令与目标方法类名、方法名以及方法描述符的符号引用捆绑,在实际运行之前,java虚拟机将根据这个符号引用链接找到具体的目标方法。
方法句柄 MethodHandle
方法句柄是一个强类型的,能够被直接执行的引用。
-
只关心所指向的方法参数类型以及返回类型(方法签名)
-
该引用可以指向常规的静态方法或者实例方法,
-
也可以指向构造器或者字段
-
指向字段时实际指向包含字段的字节码的虚构方法,语义等价于目标字段的getter或者setter(实际上不是)
-
通过MethodHandles.Lookup 类来完成创建
-
调用方法句柄和原本对应的调用指令是一致的
方法句柄的查找
-
invokestatic调用静态方法,使用 Lookup.findStatic
-
invokevirutal调用的实例方法,以及用invokeinterface ,需要使用Lookup.findVirtual,采用动态绑定
-
invokespecial 调用的实例方法,用Lookup.findSpecial,采用静态绑定
invokedynamic将调用点抽象成一个java类
class Foo {
private static void bar(Object o) {
..
}
public static Lookup lookup() {
return MethodHandles.lookup();
}
}
// 获取方法句柄的不同方式
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);
方法句柄的操作
方法句柄的调用可分为两种:
-
严格匹配参数类型的invokeExact(特别是重载,不同的方法描述符最后调用的目标方法不一样)
-
自动适配参数类型invoke,会调用MethodHandle.asType 方法,生成一个适配器方法句柄,对传入的参数进行适配
-
支持增删改参数的操作,改调用asType,删除将部分参数抛弃再调用MethodHandles.dropArguments;增操作会传入的参数中插入额外的参数,再调用MethodHandle.bindTo ,java8 lambda
方法句柄的实现
启用-XX:+ShowHiddenFrames 这个参数来打印被 Java虚拟机隐藏了的栈信息,能发现DirectMethodHandle
invokedynamic 指令最终调用的是方法句柄,而方法句柄会将调用者当成第一个参数
方法句柄的调用和反射调用一样,都是间接调用,同样会面临无法内联的问题。
Java 8 的 Lambda 表达式
lambda 表达式是借助invokedynamic来实现的。
java编译器利用invokedynamic指令来生成实现了函数式接口的适配器
Java对象的内存布局
新建对象的方式:
-
new
-
反射
-
Object.clone(复制已有的数据)
-
反序列化(复制已有的数据)
-
unsafe.allocateInstance 未初始化实例字段
类默认有一个无参构造器,子类的构造器需要调用父类的构造器,调用无参构造器,可以隐式调用。
当我们调用一个构造器时,它将优先调用父类的构造器,直至Object类。
压缩指针
每个java对象都有一个对象头(Object header),对象头由标记字段和类型指针构成。
-
标记字段存储该对象的运行数据,如哈希码、GC信息以及锁信息
-
类型指针则指向该对象的类
64位的虚拟机中,对象头的标记字段占64位,而类型指针又占了64位。(64+64)/8 = 16 byte,以Integer对象为例,仅有一个int类型的私有字段,占4个字节,Integer对象额外内存开销至少是4倍。
默认情况下堆中对象的起始地址需要对齐8的倍数(即8N个字节)。如果对象无法填满,需要空着,空着的空间我们叫对象间的填充。-XX:ObjectAlignmentInBytes 内存对齐,默认为8,
为了尽量减少对象的内存使用量,64位虚拟机引入了压缩指针(-XX:+UseCompressedOops,默认开启)的概念,将堆中原本64位的java对象指针压缩为32位。ava虚拟机中的32位压缩指针,可以寻址到2的35次方个字节(32GB的地址空间),jvm内存超过32GB,就关闭压缩指针。
字段重排列
java虚拟机重新分配字段的先后顺序,以达到内存对齐的目的。压缩指针要求java虚拟机堆中对象的起始地址要对齐至8的倍数。重排列使得字段能够内存对齐;
-XX:FieldsAllocationStyle(默认值为1)
-
如果一个字段占拒4个字节,那么该字段的偏移量需要对齐至NC。如:Long类型的字段,尽管压缩后对象头为12个字节,Long类型字段的偏移量也只能是16(long 占8个直接64位),中间的4个直接便会浪费掉
-
子类所继承字段的偏移量,需要与父类对应字段的偏移量保持一致。
垃圾回收
将已经分配出去的,但不再使用的内存回收回来,以便能够再次分配。
java虚拟机是自动内存管理,将原本需要由开发人员手动回收的内存,交给垃圾回收器来自动回收。
引用计数和可达性分析
如何辨别对象可回收?
引用计数+可达性分析
引用计数法
为对象添加一个引用计数器,用来统计指向该对象的引用个数,一旦某个引用对象的引用计数器为0,则说明该对象死亡;
引用计数无法处理循环引用 会导致内存泄露
可达性分析算法
以一系列GC ROOTS作为初始的存活对象合集,从合集出发,探索所有能够被该集合引用到的对象,并mark(染色 三色标记 白(无引用) 黑(有引用) 灰(还有未触达的))。未mark的对象便是死亡的。
GC ROOT有哪些? https://www.zhihu.com/question/53613423/answer/135743258
是一组必须活跃的引用。live set
-
所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用;换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。
-
VM的一些静态数据结构里指向GC堆里的对象的引用,例如说HotSpot VM里的Universe里有很多这样的引用。
-
JNI handles,包括global handles和local handles
-
(看情况)所有当前被加载的Java类
-
(看情况)Java类的引用类型静态变量
-
(看情况)Java类的运行时常量池里的引用类型常量(String或Class类型)
-
(看情况)String常量池(StringTable)里的引用
可达性分析在多线程情况下,可能会更新以及访问过的对象的引用。从而误报或漏报。
STW(Stop-the-world)和 安全点(safepoint)
jvm中的STW 是通过安全点(safepoint)机制来实现的。当jvm接收到stw请求时,它便会等待所有的线程都达到安全点,才允许请求stw的线程进行独占的工作。
安全点的目的不是让其他线程停下,而是找到一个稳定的执行状态,在这个执行状态下,jvm的堆栈不会发生变化。这样垃圾回收期才能“安全”地执行可达性分析。
安全点是允许线程stop检测,会带来一定的开销,并不是所有的机器码块都会有安全点,jvm定义了一些可以作为安全点的位置:
-
生成代码的方法出口
-
非计数循环的循环回边
为什么不在每一个机器码基本块处插入安全点检测?
-
检测有一定的开销
-
即时编译器生成的机器码打乱了原本栈帧上的对象分布状况;
垃圾回收的三种方式
-
清除(sweep)
-
将死亡对象占用的内存标记为空闲,并记录在一个空闲列表中
-
会产生内存碎片
-
分配效率低(碎片的空间不连续,无法利用cpu的缓存机制)
-
压缩(compact)
-
把存活的对象聚集到内存区域的起始位置,留下连续空间;
-
解决内存碎片化的问题
-
性能开销较大
-
复制 copy
-
内存分为两小份,分别用from to来维护
-
解决碎片化问题
-
空间的使用效率低下
java虚拟机的堆划分
堆分为新生代和老年代,新生代又被分为Eden区以及两个大小相同的survivor区。
默认情况下jvm采用的是一种动态分配的策略(XX:+UsePSAdaptiveSurvivorSizePolicy)根据生成对象的速率以及survivor区使用情况动态调整Eden区和Survivor区。
也可以通过 -XX:SurvivorRatio 来固定比例,默认新生代(8Eden:1 survivor:1 survivor) /老年代20
由于堆空间是线程共享的,当并发较高的时候,对象创建占用空间,同步会影响效率,jvm会默认开启TLAB(Thread Local Allocation Buffer) 来预申请一块空间,避免并发锁竞争( -XX:+UseTLAB,默认开启)
当空间不够的时候,这个时候,就需要进行minor gc。当发生minor gc的时候,对象在Survivor分区来回复制,当复制达到15次(可调 -XX:+MaxTenuringThreshold)后,将Survivor区中还存活的对象晋升到老年代。如果单个survivor区已经被占用了50%( -XX:TargetSurvivorRatio),较高复制次数的对象也会被晋升到老年代。
minor gc过程中使用标记-复制算法,理想情况下eden区的对象都死亡了。因此在年轻代用标记-复制算法最好。
minor gc不对全堆进行gc回收,老年代的对象可能引用新生代的对象。这样一来又做了次全堆扫描。
为什么要分代?
-
GC的整个工作过程都要 STW
-
目的:缩短GC一次工作的时间长度,分代GC就是其中一个思路
-
基本假设:大部分对象的生命周期很短,没有死掉的对象很可能会存活很长时间;
-
分代也是建立在以上假说上的;
-
所以让新建的对象都创建在young gen里,然后频繁收集young ,很大一部分都能被收掉
-
所以young gen配置小,适合复制算法实现,不但能降低单次GC的时间长度,还可以提高GC的工作效率
https://www.zhihu.com/question/53613423/answer/135743258
卡表
hotspot用卡表解决老年代引用新生代的问题。整个堆划分为一个个大小为512字节的卡,并维护一个卡表,用来存储每张卡的标识位。这个标识代表对应的卡有无对新生代的引用。如果引用就认为这张卡是脏卡。
minor GC 伴随着存活对象的复制,而复制需要更新指向该对象的引用,在更新引用时又会设置所在的卡的标识位。
在进行minor gc时,通过扫描脏卡避免扫描整个老年代。脏卡的对象会加入到GC Roots里。
解释执行的代码可以好说,可以在写操作那块动态的增加位操作。在即时编译器生成的代码中,则需要插入额外的逻辑,这就是所谓的写屏障。
通过 -XX:+UseCondCardMark 来尽量减少写卡表的操作。
垃圾收回收器
新生代都是采用标记-复制算法
-
Serial 单线程
-
parallel scavenge 更加注重吞吐量
-
parallel New serial的多线程版
老年代
-
serial Old 标记-压缩 单线程
-
parallel Old 标记-压缩 多线程
-
CMS 标记-清除 并发,在并发收集失败的情况下,会使用上面两个压缩回收器进行一次回收,默认触发full gc之前先执行一次young gc(-XX:+ScavengeBeforeFullGC)
-
G1 标记-压缩,横跨新生代和老年代的垃圾回收期,将堆分成n多个region区域,每个区域都可以充当任何角色,回收最大价值的
-
zgc 标记-压缩 不超过10ms,每个阶段都是在并行
内存模型
java内存模型通过定义一系列的happens-before操作,让应用程序开发者能够轻易表达不同线程的操作之间的内存可见性。
应用程序执行时
-
即时编译器重排序
-
处理器的乱序执行
-
内存系统的重排序
这三种情况都会让程序的值与期望值不一样
即时编译器和处理器需要保障程序能够遵守as-if-serial属性(在单线程的情况下,要给程序一个顺序执行的假象,即经过重排序的执行结果要与顺序执行的结果一致)
内存模型与happens-before 关系
java5引入了明确定义的java内存模型。其中最重要的一个概念是happens-before关系。happens-before 关系是用来描述两个操作的内存可见性。如果操作m happens-before 操作n,那么m的结果对n可见。
jvm明确定义的happens-before关系:
-
解锁操作 happens-before 之后(时钟顺序先后)对同一把锁的加锁操作
-
volatile字段的写操作happens-before之后对同一字段的读操作;
-
线程的启动操作happens-before该线程的第一个操作
-
线程的最后一个操作happens-before它的终止事件
-
线程对其他线程的中断操作happens-before被中断线程所接收到的中断时间
-
构造器的最后一个操作happens-before析构器的第一个操作;
happens-before 关系具备传递性。
java内存模型的底层实现
java内存模型是通过内存屏障(memory barrier)来禁止重排序的。
对即时编译器来说,内存屏障将限制它所能做的重排序优化。对于处理器来说,内存屏障会导致缓存的刷新操作。
volatile 字段的另一个特性是即时编译器无法将其分配到寄存器里。换句话说,volatile 字段的每次访问均需要直接从内存中读写。
即时编译器会在 final 字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布(即将实例对象写入一个共享引用中)重排序至 final 字段的写操作之前。在X86_64 平台上,写写屏障是空操作
锁、volatile、final与安全发布
如果编译器能够通过逃逸分析证明某把锁仅被同一线程持有,那么它可以移除相应的加锁解锁潮州。
volatile可以看成一种轻量级的、不保证原子性的同步,其性能往往由于锁操作。使用volatile理想情况是读多写少,并且只有一个线程进行写操作
final涉及新建对象的发布问题,即时编译器会在final字段的写操作后插入一个写写屏障,以防某些优化将新建对象的发布重排序至final字段的写操作之前。
jvm如何实现synchronized的
通过javac javap 可以看到
public class SynchronizedDemo {
public void demo1(Integer age){
synchronized (this){
System.out.println("demo1:"+age);
}
}
public synchronized void demo2(Integer age){
System.out.println("demo2:"+age);
}
public static synchronized void demo3(Integer age){
System.out.println("demo3:"+age);
}
public static void main(String[] args) {
SynchronizedDemo demo = new SynchronizedDemo();
demo.demo2(18);
SynchronizedDemo.demo3(20);
}
}
javac SynchronizedDemo.java javap -c SynchronizedDemo.class
public class com.yxkong.synch.SynchronizedDemo {
public com.yxkong.synch.SynchronizedDemo();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public void demo1(java.lang.Integer);
Code:
0: aload_0
1: dup
2: astore_2
3: monitorenter
4: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
7: new #3 // class java/lang/StringBuilder
10: dup
11: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
14: ldc #5 // String demo1:
16: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
19: aload_1
20: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
23: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
26: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
29: aload_2
30: monitorexit
31: goto 39
34: astore_3
35: aload_2
36: monitorexit
37: aload_3
38: athrow
39: return
Exception table:
from to target type
4 31 34 any
34 37 34 any
public synchronized void demo2(java.lang.Integer);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: ldc #10 // String demo2:
12: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: aload_1
16: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
19: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
22: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
25: return
public static synchronized void demo3(java.lang.Integer);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: new #3 // class java/lang/StringBuilder
6: dup
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
10: ldc #11 // String demo3:
12: invokevirtual #6 // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
15: aload_0
16: invokevirtual #7 // Method java/lang/StringBuilder.append:(Ljava/lang/Object;)Ljava/lang/StringBuilder;
19: invokevirtual #8 // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
22: invokevirtual #9 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
25: return
public static void main(java.lang.String[]);
Code:
0: new #12 // class com/yxkong/synch/SynchronizedDemo
3: dup
4: invokespecial #13 // Method "<init>":()V
7: astore_1
8: aload_1
9: bipush 18
11: invokestatic #14 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
14: invokevirtual #15 // Method demo2:(Ljava/lang/Integer;)V
17: bipush 20
19: invokestatic #14 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
22: invokestatic #16 // Method demo3:(Ljava/lang/Integer;)V
25: return
}
-
当int取值-1~5采用iconst指令,
-
取值-128~127采用bipush指令 将常量压入栈中
-
取值-32768~32767采用sipush指令 将常量压入栈中
-
取值-2147483648~2147483647采用 ldc 指令将常量压入栈中
synchronized代码块使用monitorenter和多个monitorexit指令,java虚拟机需要确保获得的锁再正常执行的路径,以及异常执行路径上都能解锁。
monitorenter和monitorexit可以抽象为锁对象的拥有的计数器和一个持有该锁线程的指针。synchronized是可重入性锁。
重量级锁
Java线程的阻塞以及唤醒,都是依靠操作系统来完成的。符合posix接口的操作系统,是通过pthread的互斥锁(mutex)来实现的。这些涉及到系统调用,需要切换用户态到内核态,开销较大。
java虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮训锁是否被释放。
偏向锁只会在第一次请求时采用cas操作,在锁对象的标记中记录下当前线程的地址,在之后的运行过程中,持有该偏向锁的线程的加锁操作直接返回。主要针对锁被同一线程的情况。
如果某一类锁对象的总撤销数超过了一个阈值(对应 Java 虚拟机参数 -XX:BiasedLockingBulkRebiasThreshold,默认为 20),那么 Java 虚拟机会宣布这个类的偏向锁失效。具体做法是在每个类中维护一个epoch值,这个锁记录了偏向了几代。
轻量级锁采用cas操作,将锁对象的标记字段替换为一个指针,指向当前线程上的一块空间,存储着锁对象原本的标记字段。主要针对多个线程在不同时间段申请同一把锁的情况
java语法糖和java编译器
java语法糖是一种帮助开发人员提高开发效率的小甜点,将一些繁琐的事交给编译器来处理。
在计算机语言中添加某种语法,这种语法对功能没有影响,但是方便程序员使用。
在java的com.sun.tools.javac.main.JavaCompiler类中的compile()中有一个步骤就是调用desugar(),这个方法就是负责解语法糖的实现的。
java常用的语法糖
-
switch支持String与枚举 (jdk7开始,底层通过equeals()和hashcode()方法来实现) 避免hash冲突
-
泛型(类型擦除实现)泛型参数重载会有问题
-
自动装箱与拆箱 (装箱过程通过包装器valueOf实现,拆箱过程通过包装器*Value实现)
-
方法可变参数 (转变为数组,所以java的可变参数一定是同种类型,python可以)
-
枚举
-
内部类 (只是编译时的概念)
-
条件编译 (编译优化,将一些常量的条件优化掉)
-
断言 assert (jdk1.4 通过-enableassertions 或-ea 来开启)
-
数值字面量,jdk7中允许在数字之间插入任意多个下划线(编译会去掉,方便阅读,不影响计算)
-
for-each (编译后还是普通的for循环)
-
try-with-resource (jdk7开始,提供的关闭资源的语法糖,注意操作的对象是Closeable的)
-
lambda表达式(闭包,允许把函数作为一个方法的参数)
java字节码
操作数栈
在解释执行过程中,每当为java方法分配栈帧时,java虚拟机往往需要开辟一块额外的空间作为操作数栈,来存放计算的操作数以及返回结果。
-
执行每一条指令之前,java虚拟机要求改指令的操作数已被压入操作数栈中。
-
执行指令时,java虚拟机会将该指令所需的操作数弹出,并且将指令的结果重新压入栈中;
-
dup(dup2 两位操作,long和double)指令:复制栈顶元素,用于复制new指令所生成的未经初始化的引用
-
pop(pop2 两位操作,long和double):舍弃栈顶元素,调用一个方法确不用它的返回值。
常数加载指令
-
iconst 指令,加载 -1 ~ 5 之间的int值
-
bipush 指令,加载 -128 ~ 127之间的int值
-
sipush 指令,加载 -32768 ~ 32768之间的int值
-
ldc 指令 any int/long/float/double/reference 等 value
-
lconst 指令 0,1 的long值
-
fconst 指令 0,1,2 的float值
-
dconst 指令 0,1 的double值
-
aconst 指令,null
正常情况下,操作数栈的压入弹出都是一条条指令完成的。唯一的例外就是抛异常时,会清除操作数栈的所有内容,然后将异常压入操作数栈。
局部变量区
字节码程序将计算的结果缓存在局部变量区中。
存储在局部变量区的值,通常需要加载到操作数栈中,才可以进行计算
局部变量区访问指令表
-
iload istore 加载存储 int / boolean / byte / cahr /short
-
lload lstore 加载存储 long
-
fload fstore 加载存储 float
-
dload dstore 加载存储 double
-
aload astore 加载存储 reference 例如:aload 0 指的是加载第0个单元所存储的引用。
局部变量区访问指令
局部变量数组的加载、存储指令都需要指明所加载单元的下标。aload 0 指的是加载第0个单元所存储的引用
java字节码简介
Java 相关指令,包括各类具备高层语义的字节码,
-
new:后跟目标类,生成该类的未初始化的对象;
-
instanceof :后跟目标类,判断栈顶元素是否为目标类 / 接口的实例。是则压入 1,否则压入 0;
-
checkcast:后跟目标类,判断栈顶元素是否为目标类 / 接口的实例。如果不是便抛出异常;
-
athrow:将栈顶异常抛出;
-
monitorenter:为栈顶对象加锁
-
monitorexit:为栈顶对象解锁
-
字段访问指令
-
getstatic、putstatic 静态字段访问指令
-
getfield、putfield 实例字段访问指令
-
字段访问指令这四条指令均附带用以定位目标字段的信息,但所消耗的操作数栈元素皆不同。
-
方法调用指令,包括 invokestatic,invokespecial,invokevirtual,invokeinterface 以及 invokedynamic。
-
数组访问指令
-
newarray 新建基本类型数组
-
anewarray 新建引用类型数组
-
multianewarray 多维数组
-
arraylength 数组长度
-
数组加载指令*aload 引用为 aaload
-
数组存储指令*astore 引用未 aastore
-
流程控制指令
-
goto 无条件跳转指令
-
tableswitch密集的cases
-
lookupswtich 稀疏的cases
-
返回指令 return 以及对应类型的*return,引用未areturn
方法内联
在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。
-
不仅可以消除调用本身带来的性能开销
-
还可以进一步触发更多的优化;
-
常用的方法内联 getter和setter(正常需要保存执行位置,压入栈帧、访问字段、弹出栈帧,最后恢复执行,优化后只有字段访问)
方法内联条件
-
内联越多,执行效率越高
-
内联越多,编译时间越长
-
内联越多,生成的机器码越长(机器码会部署到code cache中,由参数 -XX:ReservedCodeCacheSize控制)
即时编译内联规则:
-
由-XX:CompileCommand 中的inline指令指定的方法以及@forceInline注解的方法,会强制内联
-
符号引用未被解析到,目标方法未被初始化,或目标方法是native方法,都无法内联
-
C2不支持内联超过9层的调用(通过 -XX:MaxInlineLevel调整)以及1层的直接递归调用(通过-XX:MaxRecursiveInlineLevel调整)
逃逸分析
逃逸分析是:一种确定指针动态范围的静态分析,它可以分析在程序的哪些地方可以访问到指针。
在java虚拟机的即时编译语境下,逃逸分析将判断新建的对象是否逃逸。即时编译器判断依据如下:
-
对象是否被存入堆中(静态字段或者堆中对象的实例字段)
-
对象是否被传入未知代码中。
ps: 一旦对象被存入堆中,其他线程便能获得该对象的引用。即时编译器也因此无法追踪所有使用该对象的代码位置。
基于逃逸分析的优化
即时编译器可以根据逃逸分析的结果进行以下优化:
-
锁消除
-
栈上分配 (并未实现,需要修改大量假设,对象只能堆分配,使用的是标量替换)
-
标量替换(实际使用)
锁消除
如果即时编译器能够证明锁对象不逃逸,那么该锁对象的加锁、解锁没有意义。这是因为其他线程并不能获得该锁对象,因此也不可能对其进行加锁。这种情况,即时编译器可以消除对该不逃逸对象的加锁、解锁操作。
传统编译器仅需要证明锁对象不逃逸出线程,便可进行锁消除。由于java虚拟机及时编译的限制,简化为锁对象不逃逸出当前编译的方法。
例如,下面的代码,锁在线程中没有什么意义,会被完全优化掉
synchronized(new Object()){
}
栈上分配(并未落地)
通常情况下java虚拟机中对象都是在堆上分配的,堆上的内容对所有线程可见。java虚拟机需要对所分配的堆内存进行管理,不引用以后需要回收占用的内存。
如果能够证明某些新建的对象不逃逸,那么java虚拟机完全可以将其分配到栈上。并且在new 语句所在的方法退出时,通过弹出当前方法的栈帧来自动回收所分配的内存空间。
标量替换(实际使用)
标量:仅能存储一个值的变量,比如局部变量。聚合量:能同时存储多个值,比如java对象。
标量替换:将原本对象的字段访问,替换为一个个局部变量的访问。
常用优化
字段访问优化
字段读取优化
即时编译器会优化实例字段以及静态字段访问,以减少中的内存访问数目。沿着控制流,缓存各个字段存储节点将要存储的值,或者字段读取节点所得到的值。
针对同一个字段反复读取,基本上都会将读取节点换成缓存值。
字段存储优化
即时编译器会消除冗余的存储节点,如:一个字段先后被存储了两次,两次存储之间没有对第一次存储内容进行读取,即时编译器会把第一个字段存储给消除掉。
这种情况大多数在迭代过程中不同的人隔着多行代码操作。
死代码消除
局部变量死存储 条件判断永远不会执行的代码
循环优化
-
循环无关代码外提(循环中不变的表达式或值提到循环外面)
-
循环展开:循环体中重复多次循环迭代,并减少循环次数的编译优化
-
循环判断外提
-
循环剥离
java虚拟机监控以及诊断工具
-
jps 打印所有正在运行的java进程
-
jstat 打印java进程的性能数据,通过jstat -options 查看有哪些参数(jstat gcutil pid 2000 10 每2秒打印pid的gc数据,打印10次,不加10就一直打)
-
jmap 分析虚拟中的对象 ,直接jmap就能看到相关的示例
-
jinfo 查看java进程的参数
-
jstack 打印栈轨迹
-
mat 查看jmap导出的二进制快照信息
-
jmc(java misssion control)
-
jprofile
java agent 与字节码注入
两种模式
-
通过命令 javaagent:jar包的方式在启动时加载,定义的premain方法先于程序的main方法执行,在MANIFEST.MF配置文件中指令premain的类
-
运行时通过attach命令远程远程加载
字节码注入
可以参考 https://github.com/yxkong/agent
以上是关于深入拆解java虚拟机-笔记的主要内容,如果未能解决你的问题,请参考以下文章