jvm
垃圾回收算法
这个高级课程,其实讲的就是运行时的内存模型,常见的垃圾回收算法和垃圾回收期有哪些?以及如何进行线上调优。
JVM内存分配与回收
0.堆得分配
年轻代:1/3
Eden 8/10
survivor 2/10
from 1/10
to 1/10
老年代:2/3
元数据区:
分配在物理机的内存。jdk1.8。动态扩展,根据FUll GC情况,做相应的增减,分配大了就减少,分配小了,就增加。
jdk1.8之前叫永久代,
jdk1.6有永久代,常量池在永久代。
jdk1.7有永久代,常量池在方法去。
jdk1.8没有永久代,常量池在元空间。
为什么会有元数据区:官方解释:移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代
JVM的内存分配与垃圾回收
1.对象优先在Eden区分配
Eden区不够,则进行一次垃圾回收,Minor GC(新生代的垃圾回收)。
老年代GC(full gc)通常伴随着一个Minor GC .
打印垃圾回收日志 增加参数 添加的参数: -XX:+PrintGCDetails 如果是tomcat 则增加在java_option这个文件里。
分配担保机制:Eden区和survior区没有足够的空间分配进行分配,那么就会把新生代对象提前转移到老年代。(分配担保意思是至少要保证老年代有足够的内存存放对象。)
2.大对象直接进入老年代
大对象就是需要大量连续内存空间的对象(比如:字符串、数组)。虚拟机会提供一个参数来设置对象的大小,超过这个大小的对象直接分配在老年代。
为什么要这样呢?
为了避免为大对象分配内存时由于分配担保机制带来的复制而降低效率。
3.长期存活的对象将进入老年代
如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为1.对象在 Survivor中每熬过一次MinorGC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就会被晋升到老年代中。对象晋升到老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold 来设置。
2.如何判断对象可以被回收
2.1引用计数法
给对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计数器就减1;任何时候计数器为0的对象就是不可能再被使用的。这个方法实现简单,效率高,但是目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间相互循环引用的问题
2.2可达性分析算法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的。
GC Roots根节点:类加载器、Thread、虚拟机栈的本地变量表、static成员、常量引用、本地方法栈的变量等等
2.3 finalize()方法最终判定对象是否存活
即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历再次标记过程。
标记的前提是对象在进行可达性分析后发现没有与GC Roots相连接的引用链。
1. 第一次标记并进行一次筛选。
筛选的条件是此对象是否有必要执行finalize()方法。
当对象没有覆盖finalize方法,或者finzlize方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”,对象被回收。
2. 第二次标记
如果这个对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为:F-Queue的队列之中,并在稍后由一条虚拟机自动建立的、低优先级的Finalizer线程去执行
如果对象要在finalize()中成功拯救自己----只要重新与引用链上的任何的一个对象建立关联即可,譬如把自己赋值给某个类变量或对象的成员变量,
2.4 如何判断一个常量是废弃常量
假如在常量池中存在字符串 "abc",如果当前没有任何String对象引用该字符串常量的话,就说明常量 "abc" 就是废弃常量,如果这时发生内存回收的话而且有必要的话,"abc" 就会被系统清理出常量池
2.5 如何判断一个类是无用的类
判定一个类是否是“无用的类”的条件则相对苛刻许多。类需要同时满足下面3个条件才能算是 “无用的类” :
该类所有的实例都已经被回收,也就是 Java 堆中不存在该类的任何实例。
加载该类的 ClassLoader 已经被回收。
该类对应的 java.lang.Class 对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样不使用了就会必然被回收。
3.垃圾回收算法
3.1 标记-清除算法
算法分为“标记”和“清除”阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它是最基础的收集算法,效率也很高,但是会带来两个明显的问题:
1. 效率问题
2. 空间问题(标记清除后会产生大量不连续的碎片)
3.2 复制算法
为了解决效率问题,“复制”收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
3.3 标记-整理算法
根据老年代的特点特出的一种标记算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一段移动,然后直接清理掉端边界以外的内存。
3.4 分代收集算法
新生代使用复制算法,老年代使用标记清楚算法。
新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择“标记-清除”或“标记-整理”算法进行垃圾收集。
常见的JVM命令:
jps:
显示当前系统的java进程情况及进程id
Jinfo
查看正在运行的Java应用程序的扩展参数
jinfo -flags 查看jvm的参数(使用了什么垃圾收集器等垃圾回收信息)
jinfo -sysprops 查看java系统参数
Jstat
jstat命令可以查看堆内存各部分的使用量,以及加载类的数量。命令的格式如下:
jstat [-命令选项] [vmid] [间隔时间/毫秒] [查询次数]
注意:使用的jdk版本是jdk8.
jstat ‐class 6219 查看Class加载统计 加载未加载class的数量,占用空间大小,时间,等
jstat ‐compiler 6219 查看编译统计 编译的数量失败数量等等。
jstat ‐gc 6219 查看垃圾回收统计 每个代,每个区(Eden区等)的大小,垃圾回收次数,消耗时间。
jstat -gccapacity 111 查看堆内存统计 各个代的大小及收集次数等信息。
此外还有命令可以查看各个代的垃圾回收和内存统计。
jmp
通过jstat可以对jvm堆的内存进行统计分析,而jmap可以获取到更加详细的内容, 如:内存使用情况的汇总、对内存溢出的定位与分析。
map ‐histo <pid> | more map ‐histo <pid> ./log.txt 查看实例个数及占用大小内存。(正常的项目一般会有很多Byte对象,因为String的底层就是由byte构成的)
jmap ‐heap 6219 查看堆的内存使用情况。
jmap ‐dump:format=b,file=dumpFileName <pid> 将内存使用情况dump到文件中。
也可以设置内存溢出自动dump文件 #参数如下:‐Xms8m ‐Xmx8m ‐XX:+HeapDumpOnOutOfMemoryError XX:HeapDumpPath=./ (dump文件存放的路径)
1.jhat命令查看dump文件。设置端口号,读取文件,通过浏览器可以查看相应的信息。还可以使用查询语句查询相关的信息对象信息(比如字符串大于100长度的信息)
2.通过MAT工具查看dump文件
MAT(Memory Analyzer Tool),一个基于Eclipse的内存分析工具,是一个快速、功能丰富的JAVA heap分析工具,它可以帮助我们查找内存泄漏和减少内存消耗。使用内存分析工具从众多的对象中进行分析,快速的计算出在内存中对象的占用大小,看看是谁阻止 了垃圾收集器的回收工作,并可以通过报表直观的查看到可能造成这种结果的对象。
具体查看:将dump文件导入mat工具,他会列出可疑的问题,通过详情列表(details)可以查看可能出现问题的对象。
3.还可以使用visualVM工具查看
jstack
jstack的作用是将正在运行的jvm的线程情况进行快照,并且打印出来 jstack <pid>
有些时候我们需要查看下jvm中的线程执行情况,比如,发现服务器的CPU的负载突然增高了、出现了死锁、死循环等,我们该如何分析呢?
由于程序是正常运行的,没有任何的输出,从日志方面也看不出什么问题,所以就需要 看下jvm的内部线程的执行情况,然后再进行分析查找出原因。
这个时候,就需要借助于jstack命令了,jstack的作用是将正在运行的jvm的线程情况进行快照,并且打印出来
jstack找出占用cpu最高的堆栈信息。可以通过VisualVM工具,或者公司内部的工具。
1,使用命令top -p <pid> ,显示你的java进程的内存情况,pid是你的java进程号,比如4977
2,按H,获取每个线程的内存情况
3,找到内存和cpu占用最高的线程tid,比如4977
4,转为十六进制得到 0x1371 ,此为线程id的十六进制表示
5,执行 jstack 4977|grep -A 10 1371,得到线程堆栈信息中1371这个线程所在行的后面10行
6,查看对应的堆栈信息找出可能存在问题的代码
VisualVM工具的使用
就是jdk自带的图形化工具,囊括了上述所有的命令。点击工具不同的地方,就会执行对应的指令(jps jinfo ....),能够快速方便的查看。
1. 查看本地进程
2. 查看CPU、内存、类、线程运行信息。如果有内存异常信息,也会直接有提示。(图灵)
3. 查看线程详情,也可以将线程情况dump. 如果有死锁,会直接提示有死锁。(图灵)
4. 查看远程服务的信息。(jvisualvm远程连接服务需要在远程服务器上配置host(连接ip 主机名),并且要关闭防火墙)
JVM由三个主要的子系统构成
类加载器子系统
运行时数据区(内存结构)
执行引擎
类加载的几个阶段:
加载,通过类加载器查找字节流,创建类的过程。
将字节流中的静态存储结构转化为方法区的运行时数据结构
在java堆中生成一个代表这个类的class对象,作为方法区这些数据的访问入口.
验证,确保被加载类能够满足java虚拟机的约束条件
准备,
为被加载的静态字段分配内存,并赋予初始值。
构造跟类层次相关的数据结构,比如方法表(深入理解java虚拟机中叫虚方法表)
解析,
将符号引用解析成为实际引用
字段和方法的内存地址
实际上非虚方法才会指向内存的实际引用
虚方法会指向虚方法表的索引
初始化
被final修饰的静态字段,并且数据类型是基本的数据类型或者字符串,会被标记位常量。其余的静态字段和静态代码块会被java编译器一同放进<clinit>方法中(类构造器)。 --实例构造器为<init>
在初始化阶段,会为常量值字段赋值,并执行<clinit>方法。多个线程同时初始化一个类,会通过加锁的方式来确保<clinit>方法只会被执行一次。
非静态字段,在类实例化时(构造函数赋值)
类什么时候才会被初始化
1.虚拟机启动的时候,初始化用户指定的类。
2.new 一个对象
3.调用静态方法
4.调用静态字段
5.初始化子类,会初始化父类。
6.反射
java运行时的数据区域。
本地方法栈(线程私有):
登记native方法,在Execution Engine执行时加载本地方法库
程序计数器(线程私有):
就是一个指针,指向方法区中的方法字节码(用来存储指向下一条指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不记。
方法区(线程共享):
类的所有字段和方法字节码,以及一些特殊方法如构造函数,接口代码也在此定义。简单说,所有定义的方法的信息都保存在该区域,静态变量+常量+类信息(构造方法/接口定义)+运行时常量池都存在方法区中,虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的应该是与 Java 堆区分开来。
Java栈(线程私有):
Java线程执行方法的内存模型,一个线程对应一个栈,每个方法在执行的同时都会创建一个栈帧(用于存储局部变量表,操作数栈,动态链接,方法出口等信息)不存在垃圾回收问题,只要线程一结束该栈就释放,生命周期和线程一致
JVM对该区域规范了两种异常:
1) 线程请求的栈深度大于虚拟机栈所允许的深度,将抛出StackOverFlowError异常
2) 若虚拟机栈可动态扩展,当无法申请到足够内存空间时将抛出OutOfMemoryError,通过jvm参数–Xss指定栈空间,空间大小决定函数调用的深度
运行时栈帧结构见下面两节。
java字节码:
java字节码指令很多,没有记住的必要,因为平时也用不到,如果真的有用到相关的指令,查询字节码表就可以了。
操作数栈;在为方法分配栈帧的时候,会开开辟一块额外空间作为操作数栈。用来存放操作数以及返回结果。(进行计算的中转站)
执行操作数栈的相关的指令的时候,要求该指令的的操作数已经被压入操作数栈中。执行指令的时候,jvm将该指令所需的操作数栈弹出,并将指令的结果压入栈中。
局部变量表(区) 是一个数组:依次存放了this,方法入参,方法内部的局部变量。当遇到操作局部变量的相关指令的时候,会指明局部变量的相关下标。
Java 方法的栈桢分为操作数栈和局部变量区。通常来说,程序需要将变量从局部变量区加载至操作数栈中,进行一番运算之后再存储回局部变量区中。
Java 字节码可以划分为很多种类型,如加载常量指令,操作数栈专用指令,局部变量区访问指令,Java 相关指令,方法调用指令,数组相关指令,控制流指令,以及计算相关指令。
运行时栈帧结构
栈帧,是一种数据结构,进行方法调用和方法执行
方法的从调用开始,到执行完成的过程,对应一个栈帧在虚拟机栈里面从入栈到出栈的过程.
局部变量表 操作数栈 动态链接 方法的返回地址
栈顶栈帧是有效的,当前栈帧,当前方法,执行引擎所运行的字节码指令都只针对当前栈进行操作.
局部变量表
存放方法参数,方法局部变量
Class文件Code属性的max_locals确定了局部变量的大小
变量槽Slot最小单位,32位或者64位,可随着处理器,操作系统,虚拟机不同而改变.
若slot大小32位,long和double64位需要使用到2个slot
第0位索引,默认存储this
slot可重用,PC计数器的值超出变量的作用域,则变量对应的slot会让出来.
操作数栈
操作栈
先入后出,
Class文件Code属性的max_stacks确定了操作栈的深度.
每个元素可以是任意数据类型,32位占用栈容量1,62位占用2.
操作栈中元素必须与字节码指令的序列严格匹配.
概念模型中,栈帧之间是相互独立的.实际上,会有重叠部分,下面栈帧的部分操作数栈,与上面栈帧部分局部变量表重叠在一起,在进行方法调用的时候可共用一部分数据.
动态连接:
栈帧包含一个引用(栈帧所属方法的引用),这个引用在运行时常量池中.
class文件中常量池有符号引用,字节码中,方法调用指令就以常量池中指向方法的符号引用为参数.(即方法调用指令的参数就是符号引用)
类加载时或者第一次使用的时候,符号引用转为直接引用,静态解析
运行期间转化为直接引用,动态链接.
方法返回地址
退出方法的两种方式:
正常完成出口:执行引擎遇到方法返回的字节码指令
PC计数器作为方法的返回地址.
异常完成出口:代码遇到异常,没有做任何处理.
异常处理器表来确定,栈帧不会保存这部分信息.
退出时可能执行的操作
恢复调用者的局部变量表和操作数栈,
将返回值压入调用者栈帧的操作数栈中,
调整PC计数器的值,指向方法调用指令后面的一条指令.
附加信息
虚拟机规范没有定义,但是实际的虚拟机实现会附加的一些信息.比较调试的相关信息.
类加载器
类和类加载器
由加载这个类的加载器和类确定这个类在虚拟机的唯一性.
双亲委派模型
种类
启动类加载器 c++实现(HotSpot) 虚拟机的一部分
所有其他类加载器 java实现 独立于虚拟机,在虚拟机的外部 继承抽象类ClassLoader.
开发人员角度分
启动类加载器
扩展类加载器
应用程序类加载器(系统类加载器)
除了顶层的启动类加载器,其余的类加载器都要有父类加载器.
父子关系不用继承关系来实现,用组合关系(一种设计模式)来复用父类加载器的代码
不是强制性约束模型.
工作过程:
类加载器收到类加载请求,自己不处理,而是把请求委派给父类加载器处理,每个层次的类加载器都是如此.直到启动类加载器.当父类加载器没法处理时,子加载器才会尝试自己去加载.
好处:
保证了系统类的准确性
保证了程序稳定.
实现原理,
检查类是否被加载
没有被加载,则调用父类的loadClass方法加载,
如果父类加载器为空,则调用启动类加载器加载
如果父类加载器加载失败则抛出ClassNotFoundException异常
执行自身的findClass方法
破坏双亲委派模型
1.重写loadClass方法
双亲委派模型在jdk1.2引入,ClassLoader在jdk.0存在.为了向前兼容,添加findclass方法.不推荐重写loadClass方法.重写loasClass方法就可以破坏双亲委派模型.
2.使用线程上下文类加载器
基础类调用用户代码
JNDI服务,启动类加载器调用ClassPath的代码
JDBC
父类加载器请求
3.OSGI代码热部署.
类加载器不是双亲委派模型,而是网状接口
有一系列的加载规则.
jvm是如何进行方法的调用的?
方法重载:方法名相同,参数类型不同的一组方法间的关系,叫做方法重载。在编译阶段就能够确定具体调用哪个重载方法。
1.不考虑基本数据类型的拆箱装箱,也不考虑可变长参数。
2.考虑拆箱装箱,不考虑可变长参数。
3.考虑拆箱装箱和可变长参数。
方法重写:子类方法跟父类的方法名参数类型一样,叫做方法重写。会根据调用者的动态类型选取实际的目标方法。
java虚拟机会根据方法描述符判断方法是否重写。非私有,非静态,参数类型,返回值相同才会被jvm判定为重写。
方法描述符:用于描述方法的参数类型和返回值类型
静态绑定:在类加载的解析阶段能够确认目标方法。
invokestatic 静态方法
invokespecial 私有方法、构造方法、实现接口的默认方法,super关键字调用父类的实例方法或者构造器。
动态绑定:在运行时才确定的目标方法。有点编译看左边(左边的类有没有相关的方法),运行看右边(右边的对象有没有重写该方法)的味道。
invokevirtual 非私有实例方法(会编译成动态绑定类型)
invokeinterface 接口方法
invokedynamic 动态方法
类加载的解析阶段详解:
如何转换?:对于类和接口,将符号引用解析成为实际引用的流程有所差异,但是大体是。
先找自己类中符合的方法,没有就找父类,直到Objec类。还是没有,就找接口非静态,非私有方法。找不到就报错。
对于静态绑定的方法而言,实际引用就是目标方法的内存地址,对于动态绑定的方法,实际引用就是方法表的索引。
虚方法
编译为这两个invokevirtual invokeinterface指令的方法,叫做虚方法。
被final修饰的虚方法,可以静态绑定。其余的虚方法,依靠方法表实现
方法表
在类加载的解析阶段,会构造一个方法表
方法表包含自己和父类的所有方法。子方法表的方法索引值与重写的父类方法的索引值相同(重写时索引指向自己的方法)。
java虚拟机在运行时,根据调用者的实际类型,在该类型的方法表中找到要执行的方法。
虚方法调用的优化(即时编译优化)
内联缓存
java虚拟机只采用了单态内联缓存。只缓存一种动态类型和目标方法。命中则返回目标方法,否则退化为方法表查找。
方法内联
将目标方法复制到发起调用的方法中,避免了发生真实的方法调用。
异常:
异常的种类,throwable下面分Exception,Error.
Exception:程序(开发人员写的程序)可捕获并能处理的异常;
Error:程序不可捕获。比如说,类定义错误,jvm虚拟机错误。
异常开销大:当发现异常时,虚拟机会访问当前线程的所有栈帧,并记录所在的类名,方法,行号等信息。
jvm是如何捕获异常的:
方法体里面的代码被编译成字节码文件之后,会有个属性表,属性表记录了各种字节码指令,其中一个异常属性。
异常属性记录了从哪里到哪里的代码,发了异常,要跳到哪里去处理。from to taget 异常类型。
finally的代码块,会被复制三分,
try区域的正常执行路径出口。
catch区域的正常执行路径出口。
try区域触发,但没有被catch区域捕获到的异常,以及catch区域触发的异常。
finnally如果后面还有代码,会有一个goto字节指令,跳转到相应的地方执行。
当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java 栈上所有方法的异常表。
java7的suppressed异常,以及语法糖。
try..finally的异常是为了解决使用资源忘了关闭的问题。
如果catch内抛出了异常会被finally捕获,捕获的是最新的异常,原因的异常会丢失。因此
1.suppereed允许开发人员将一个异常附于另一个异常之上。
2.try-with-resources可精简资源关闭,资源类实现AutoCloseable接口。
3.支持捕获多种异常,异常之间用|分隔开。
注意事项:如果try catch finally,都有return语句,会返回哪个区域的值?
方法的返回值i是基本数据类型,try代码块reture i回去,在finnally 重新赋值i,返回的值也是try里i值(操作栈的值是try里面的值)。如果finnally有return语句,一定会是返回finnally的值,因此此时操作栈的值是finnally里的值(不同的版本可能不同)。
反射:
在程序运行时,另一种方式的调用构造方法,方法,字段的行为。应用在框架,java集成开发环境idea中。
方法反射调用的实现:
method实例指向方法的地址值(类加载到虚拟机中,方法会加载到方法区,有明确的地址),反射调用传入准备好的参数,调用进入目标方法。
每次调用invoke,都会通过一个委派实现调用本地方法实现反射调用。当调用达到一定的次数(15次)之后,就会动态生成字节码实现(相当于生成了一个类),下次调用的时候,委派实现就会调用这个动态实现去完成方法的调用。
本地方法实现和动态实现的切换过程叫做inflation。可通过参数-Dsun.reflect.noInflation=true进行开启与关闭
反射调用的开销
class.forName调用本地方法
占用堆内存,是的GC频繁。(invoke()是个变长参数方法Obeject数组,每次都要生成新的数组。)
通过方法内联和逃逸分析。即时编译优化方法内联:反射调用被内联了,即时编译的逃逸分析将新建的Objec数组判定为不会逃逸对象(即时编译器可以选择栈分配甚至是虚拟分配,也就是不占用堆空间),因此不会触发垃圾回收。假设invoke()方法外创建数组,无法优化访问数组的操作。
基本数组类型拆箱装箱(Objec数组,基本类型要拆箱装箱)
java缓存了-128到127,还可手动调整缓存范围
本地方法实现消耗性能
开启反射调用inflation机制。直接使用动态实现来调用方法
方法多个有不同的动态实现,可能会造成方法内联的失效,逃逸分析不再起作用。
生产环境有多个不同的动态实现,对于 invokevirtual 或者 invokeinterface,Java 虚拟机会记录下调用者的具体类型(称之为类型 profile),java虚拟机无法同时记录这么多类,可能会造成方法内联失效,逃逸分析不再起效
每次反射都会检查目标方法的权限
方法内联:指的是编译器在编译一个方法时,将某个方法调用的目标方法也纳入编译范围内,并用其返回值替代原方法调用这么个过程
invokedynamic在jvm的实现
jdk1.7引入,invokedynamic是方法句柄的字节码指令。只关心方法的参数类型和返回类型,不关心所在的类和方法名。
和反射一样,面临无法内联的情况。
java对象在内存的分布
(该节涉及到很多其他知识,并且实际应用中用不到,深入理解要花很多时间。我们只需要知道,为了节省64位电脑的空间,jvm会进行指针的压缩,减小对象占用大小。也会进行字段重排序。而这些背后,设计地址寻址,都有一定的规则,都可对jvm进行一些配置。太深,后期再学。)
创建对象的方式有:new,克隆,反序列化,Unsafe.allocateInstance
每次new一个对象,都会在构造器中调用父类构造器。子类对象包含所有的父类字段,只不过,父类的私有字段,子类对象不允许访问
压缩指针:
java对象头:标记字段,类型指针
标记字段:哈希码,GC信息,锁信息
类型指针:指向该对象的类。
对象头的标记字段和类型指针各站64位,额外开销大。
通过指针压缩,减少对象的内存使用量。
那么压缩自后,对象怎么寻址,会有一套自己的算法。一般来说对象的起始地址需要对齐至8的倍数(内存对齐)。用不到8的对象,空白空间浪费。
除了对象的内存对齐,字段也会进行内存对齐。原因是让字段只出现在同意cpu缓存行。不对齐的话,读取时要替换两个缓存行,存储时,会污染两个缓存行,影响程序执行效率。
字段重排序,
重新分配字段的先后顺序。
JMM,
JMM,java内存模型,通过happens-before可以解决由cpu缓存、即时编译器和处理器的指令重排序带来的有序性和可见性问题。
底层实现:
通过内存屏障(对于代码来说,会转换成具体的字节码指令)来禁止重排序。
内存屏障对于即时编译器来说,他会针对每一个happens-before规则,向正在编译的目标方法中插入相应的读读,读写,写读,以及写写操作。对处理器来说,会导致缓存刷新操作。
即时编译器根据根据不同的底层架构,转换成不同的cpu指令。(除了写读内存屏障会被替换成具体的指令,其余的内存屏障都是空操作。)
对于volatile字段,不允许将写之前的内存访问重排序至其后。也不允许读操作之后的内存访问重排序至其之前。写读操作,会强制刷新处理器的写缓存。
锁,volatile,final字段。
锁:解锁强制刷新缓存
volatile:保证了可见性和有序性,读多写少场景。
final:被final修饰的字段,其他线程只能看到已经初始化的final实例字段。
synchronized的实现和锁优化,
synchronized实现。
在同步代码块的前后会生成两条字节码指令,monitorenter,monitorexit。
当执行monitorenter指令,如果获取到锁对象,那么就将锁的计数器+1,获取不到对象,就会进入阻塞状态。
当执行monitorexeit指令,将锁的计数器-1,计数器为0的时候,就会释放锁。
重量级锁:synchronized就是重量级锁。
线程在获取不到锁的时候,线程就进入阻塞状态,直到锁被释放,线程被唤醒。
线程阻塞唤醒,这些操作涉及操作系统调用,要从操作系统的用户态切换到内核态。开销大。
锁优化:
自旋:
获取不到锁,在处理器空跑,并询问锁是否被释放。缺点是,浪费cpu资源,进入阻塞的线程获取锁的优先级降低。
自适应自旋,根据以前获取锁的经历,如果曾经获取到锁,那么自旋时间就短一些,否则时间长一些。
轻量级锁:
重量级锁,要不断的在内核态和用户态切换,开销比较大。
轻量级锁可以避免线程在内核态和用户态中切换,减少性能的开销。(根据使用经验,数据在同步周期内都是不存在数据竞争的)
实现方法:
java对象头包含:标记字段(mark word),类型指针。标记字段其中有两位是存储锁标志位。01未锁定,00轻量级锁定,10膨胀重量级锁定,11GC,标志位的不同,mark word存储了不同的内容。
当线程要获取锁的时候,会把锁对象的mark work复制到线程的栈帧中的一个空间(叫做 锁记录),
加锁的时候,jvm会使用CAS将mark word更新为指向锁记录的指针,如果更新成功,则获取锁(mark word标志位更新为00),更新不成功,如果已经是当前线程获取,那么继续执行后续代码,如果当前线程没有持有锁,那么锁就会膨胀为重量级锁(10)。
解锁的时候, Mark word替换回锁记录数据,如果替换成功,同步过程完成,如果替换不成功,则说明其他线程持有锁,在释放锁的同时,会唤醒其他线程。
偏向锁:
就是访问代码块不加锁,直到有其他线程来获取锁,才会对代码块进行加锁。
当锁对象第一次被线程获取后,会将mark word的标志位设置为01,然后将线程的id,记录在mark word中。持有偏向锁的线程进入同步代码块,不会进行加锁,当有其他线程尝试获取锁,此时就会撤销偏向锁,变成未锁定状态,或者轻量级锁。
即时编译:
即时编译器在程序的执行效率做了大量的优化,背后涉及了编译原理以及大量的优化算法,每种算法的背后都是由神仙写的一篇篇优秀的论文。我们的工作更像是应用工程师,站在这些神仙们的肩膀上开发功能,在实际应用中并不会用到这些编译和算法,因此只是做了一些肤浅的了解,知道了有这么个东西。我们也不应该做深入的了解,性价比也是不高,平时用不到,很快就忘了。
即时编译:
反复执行的热点代码会被即时编译成机器码,直接运行在底层硬件之上,提升了程序的执行效率。
分层编译:
执行时间长,对峰值性能有要求的程序,采用c2,(服务端即时编译)
执行时间短,对启动性能有要求的程序,采用c1,(客户端即时编译)
即时编译的触发:
当方法的调用次数以及循环回边的执行次数达到一定的条件之后,就会触发即时编译。
osr编译,
除了以方法为单位进行即时编译,还存在着以循环为单位的即时编译(OSR)。循坏回边计数器就是触发这种类型的编译。
OSR可以在程序执行过程中,动态地替换掉java方法栈帧,从而使得程序能够在非方法入口处进行解释执行和编译后的代码之间的切换。
Profiling [ˈprəʊfaɪlɪŋ]
在分层编译的0,2,3三层中都会进行profiling,收集能够反映程序运行状态的数据。比如有:
方法的调用次数,
循环回边的次数。当这些次数达到一定的条件之后,会触发即时编译。
此外,0层和3层,还会收集用于4层c2编译的数据,分支profiling和类型profiling。0层只有在等待c1编译的方法太多,才会进行profiling,否则是在c1代码中进行profiling(java虚拟机认为该方法可能被c2编译)
分支profiling:跳转次数,不跳转次数
类型prolifing:非私有实例方法调用指令,强转类型,instanceof指令,数组存储指令
分支Profile优化
即时编译器可以将从未执行过的分支剪掉,以避免编译这些很有可能用不到的代码,从而节省编译时间以及部署代码所有消耗的内存空间。
还可以计算每条程序执行路径的概率,以便某些编译器优化优先处理概率较高的路径。
类型profile优化
instanceof 改为判断输入的类型是否为profiling的类型,是的话,执行类似分支的优化。不是的话,去优化
方法调用类型,对方法的条件去虚化内联。
去优化
分支profile和类型profile是基于假设做的优化,从而精简控制流以及数据流。当假设失败的时候会执行去优化。
去优化。从执行即时编译生成的机器码切换回解释执行。
在生成的机器码中,即时编译器在假设失败的地方插入一条call指令,调用jvm去优化的方法,去优化方法更改栈上的返回地址,并不再返回即时编译器生成的机器码中。
即时编译器在编译过程中机器码和字节码之间的映射关系。当根据映射关系创建好对应的解释执行栈后,jvm采用osr技术,动态替换栈上的内容,并在目标字节码出开始解释执行。
即时编译器还可以根据产生去优化的原因来决定是否保留这一份机器吗,以及何时重新编译对应的java方法。
即时编译器的中间表达式形成:
编译原理课中,编译器分为前端和后端,前端会对所输入的程序进行词法分析,语法分析,语义分析,然后生成中间表达形式,也就是IR,后端会进行IR的优化,然后生成目标代码。
即时编译器将锁输入的java字节码转换成SSA IR,以便进行优化。
示例:
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
具体来说,C2和Graal采用的是一种名为Sea-of-Nodes的IR,其特点用IR节点来代表程序中的值,并且将源程序基于变量的计算转换为基于值的计算。
方法内联:
含义:把目标方法复制到发起调用的方法中,避免了真实的方法调用。
编译器最重要的优化手段之一。
优点:消除方法的调用成本。为其他优化手段建立良好的基础。
即时编译器做了一些努力,否则如果只是按照经典编译原理的优化理论(内联的方法时确定的),大多数方法无法进行内联。
因为虚方法的调用是在运行是才确定具体调用哪个方法。编译器无法确定调用具体的方法,只有运行才确定具体的方法。
为了解决这个问题,引入了“类型继承关系分析”CHA。用于确定目前已经加载的类中,某个接口是否有多于一种实现,某个类是否存在子类、子类是否为抽象类等信息。
对于非虚方法,直接内联。
对于虚方法,查询CHA,(关键点,证明虚方法是唯一的,profile得来的)
只有一个版本,进行内联。属于激进优化,预留逃生门。如果加载导致继承关系发生了变化的新类。抛弃已经编译的代码,返回解释执行。
多于一个版本,使用内联缓存。
内联缓存原理:没发生方法调用,缓存为空,当第一次调用发生后,缓存记录下方法接收者的版本信息。每次调用比较接收版本信息。一样,继续内联,不一样,找找虚方法表进行方法分派。
那具体是怎么做的呢?
即时编译器既可以在解析过程中替换方法调用字节码,也可以在 IR 图中替换方法调用 IR 节点。这两者都需要将目标方法的参数以及返回值映射到当前方法来
逃逸分析:
不是直接优化代码的手段,而是为其他优化手段提供依据的分析技术。
含义:分析对象动态作用域。有方法逃逸(对象作为其他方法的入参),线程逃逸(赋值了给类变量)。
如果对象不会逃逸,逃到方法外,或者逃到线程外。可进行如下优化。
栈上分配。对象不在堆上分配内存,在栈上分配内存,随着栈帧的出栈而销毁。避免垃圾回收的性能消耗。
同步消除。变量不会逃逸到线程之外,可取消同步举措。
标量替换。程序不直接创建对象,而是直接创建被方法使用到的成员变量。
字节码执行引擎
概述:
虚拟机 物理机 代码执行能力
虚拟机如何找到正确的方法,如何执行方法内的字节码,执行代码时涉及的内存结构.
执行引擎
方法调用
确定调用哪个方法.
然而
Class文件只存储符号引用,不是直接引用,
因此
需要在类加载或者运行期间才能调用
解析
类加载解析阶段,会有部分符号引用转化为直接引用.
方法在程序运行前有一个可确定的调用的版本,并且是在运行期间是不可以改变的.
编译可知,运行不可知.
静态方法,私有方法,不能通过继承或者别的方式重写出其他版本
四条字节码指令
invokestatic:调用静态方法
invokespecial:调用实例构造器<init>方法,私有方法和父类方法.
nvokevirtual:调用所有的虚方法. final修饰的方法.
invokeinterface:调用接口方法,会在运行是在确定一个实现此接口的对象.
分派
重载,重写中,虚拟机是如何确定正确的目标方法
静态分派
重载例子,
Human man = new Man();
Human变量的的静态类型
Man变量的实际类型.
静态类型和实际类型在程序中都可以发生一些变化
静态类型的变化仅在使用时发生(类型强转),变量本身的静态类型不会被改变.最终静态类型是在编译器可知的.
实际类型变化在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么.
虚拟机(编译器)在重载的时候,是通过静态类型选用哪个重载版本,而不是实际类型.静态类型在编译器是可知的.所以在编译阶段,编译器就根据静态类型,选择了重载版本.
静态分派:依赖静态类型来定位方法执行版本的分派动作.
然而重载版本并不是唯一的.因为字面量没有显式的静态类型.
动态分派
重写例子.
invokevirtual指令运行是解析过程步骤
找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C.
在类型C中找到与常量中的描述符和简单名称都相符的方法,进行权限校验,如果通过则返回这个方法的直接引用,查找结束;不通过则抛异常.
否则,按照继承关系从下往上一次对c的各个父类进行上一步认证.
都没找到则抛异常.
invokevirtual指令在运行期确定接受者的实际类型,然后把方法的符号引用变成直接引用.
基于栈的字节码解释执行引擎
执行方法里面的字节码指令
解释执行.
解释执行器,即时编译器
编译过程:词法分析,语法分析-->抽象语法树
java编译器(独立于java虚拟机),生成抽象树及之前的步骤的实现.-->字节指令流.
基于栈的指令集与基于寄存器的指令集
基于栈的指令集架构
java源码-->java编译器 -->字节流
大部分零地址指令,依赖操作数栈工作.
优缺点:可移植性,代码紧凑,编译器实现简单(不需要考虑空间分配),执行速度慢,指令数量多,栈是实现在内存中(会有栈顶缓存优化)
基于寄存器的指令集
x86指令集,即pc支持的指令集架构
依赖寄存器工作.
优缺点:程序受硬件约束.执行快.
基于栈的解释器执行过程
过程
入操作数栈,
从操作数栈中取出放入局部变量表
每执行一条字节码指令,程序计数器加1
iadd把操作数栈的前两个栈顶元素出栈,然后相加,再入栈.
ireturn将操作数栈顶的整形数据返回给方法的调用者.
涉及
操作数栈,中间变量都是以操作数栈进栈出栈为信息交换途径.
局部变量表
程序计数器
说明:
以上仅仅是概念模型,实际上虚拟机会有优化.
参考博文:
https://blog.csdn.net/kai46385076/article/details/92849266

浙公网安备 33010602011771号