Jvm

Jvm相关知识

更新于2020-08-26

Posted by ZBX on April 6, 2020

JVM

多态

多态的用途在于对设计和架构的复用,定义功能和组件时定义接口,实现可以留在之后的流程中。

实现机制

​ 在JVM执行Java字节码时,类型信息被存放在方法区中,通常为了优化对象调用方法的速度,方法区的类型信息中增加一个指针,该指针指向一张记录该类方法入口的表(称为方发表),表中的每一项都是指向相应方法的指针。

​ 方法表的构造:方法表中最先存放的是Object类的方法,接下来是该类的父类的方法,最后是该类本身的方法。如果之类改写了父类的方法,那么子类和父类的那些同名方法共享一个方法表项,都被认作是父类的方法。

注意这里只有非私有的实例方法才会出现,并且静态方法也不会出现在这里,原因很容易理解:静态方法跟对象无关,可以将方法地址直接引用,而不像实例方法需要间接引用。

更深入地讲,静态方法是由虚拟机指令invokestatic调用的,私有方法和构造函数则是由invokespecial指令调用,只有被invokevirtual和invokeinterface指令调用的方法才会在方法表中出现。

由于以上方法的排列特性(Object——父类——子类),使得方法表的偏移量总是固定的。例如,对于任何类来说,其方法表中equals方法的偏移量总是一个定值,所有继承某父类的子类的方法表中,其父类所定义的方法的偏移量也总是一个定值。

前面说过,方法表中的表项都是指向该类对应方法的指针,这里就开始了多态的实现:

假设Class A是Class B的子类,并且A改写了B的方法method(),那么在B的方法表中,method方法的指针指向的就是B的method方法入口。

而对于A来说,它的方法表中的method方法则会指向其自身的method方法而非其父类的(这在类加载器载入该类时已经保证,同时JVM会保证总是能从对象引用指向正确的类型信息)。

结合方法指针偏移量是固定的以及指针总是指向实际类的方法域,我们不难发现多态的机制就在这里:

在调用方法时,实际上必须首先完成实例方法的符号引用解析,结果是该符号引用被解析为方法表的偏移量。虚拟机通过对象引用得到方法区中类型信息的入口,查询类的方法表,当将子类对象声明为父类类型时,形式上调用的是父类方法,此时虚拟机会从实际类的方法表(虽然声明的是父类,但是实际上这里的类型信息中存放的是子类的信息)中查找该方法名对应的指针(这里用“查找”实际上是不合适的,前面提到过,方法的偏移量是固定的,所以只需根据偏移量就能获得指针),进而就能指向实际类的方法了。

我们的故事还没有结束,事实上上面的过程仅仅是利用继承实现多态的内部机制,多态的另外一种实现方式:实现接口相比而言就更加复杂,原因在于,Java的单继承保证了类的线性关系,而接口可以同时实现多个,这样光凭偏移量就很难准确获得方法的指针。所以在JVM中,多态的实例方法调用实际上有两种指令:

invokevirtual指令用于调用声明为类的方法;

invokeinterface指令用于调用声明为接口的方法。

当使用invokeinterface指令调用方法时,就不能采用固定偏移量的办法,只能老老实实挨个找了(当然实际实现并不一定如此,JVM规范并没有规定究竟如何实现这种查找,不同的JVM实现可以有不同的优化算法来提高搜索效率)。我们不难看出,在性能上,调用接口引用的方法通常总是比调用类的引用的方法要慢。这也告诉我们,在类和接口之间优先选择接口作为设计并不总是正确的,当然设计问题不在本文探讨的范围之内,但显然具体问题具体分析仍然不失为更好的选择。

内存区域

方法区,堆,虚拟机栈,本地方法栈,PCR

对象的内存布局

  • 对象头
  • 实例数据
  • 对齐填充
    • 任何对象的大小都必须是8字节的整数倍

对象的访问定位

主流的访问方式主要有使用句柄和直接指针两种:

  • 如果使用句柄访问的话,Java堆中将可能会划分出一块内存在作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要被想修改。
  • 如果使用直接地址访问的话,Java堆中对象的内存布局就必须考虑如何放置访问类型数据的相关信息,reference中存储的直接就是对象地址,如果只是访问对象本身的话,就不需要多一次间接访问的开销。速度快,节省一次指针定位的开销,HotSpot主要使用这种进行对象访问。

OOM

HotSpot虚拟机不支持栈的动态扩展。

Metaspace的组成

Metaspace由两大部分组成:Klass Metaspace和NoKlass Metaspace。

  • Klass Metaspace
    • Klass Metaspace就是用来存klass的,就是class文件在jvm里的运行时数据结构
    • 这部分默认放在Compressed Class Pointer Space中,是一块连续的内存区域,
  • NoKlass Metaspace
    • NoKlass Metaspace专门来存klass相关的其他的内容,比如method,constantPool等,可以由多块不连续的内存组成。

垃圾回收

对象已死?

引用计数算法

可达性分析算法

算法的基本思路是通过一系列称为“GC Roots”的根对象作为起始节点点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,则证明此对象是不可能被使用的。

GCRoot对象

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区常量引用的对象,如String Table里的引用。
  • 本地方法栈JNI(Native)方法的应用。
  • JVM内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如NullPointException、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁持有的对象。
  • 反映JVM内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等。

生存还是死亡?

真正要宣告一个对象的死亡,至少要经历两次标记过程,如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记,随后进行一次筛选,筛选的条件是此对象是否有必须执行finalize()方法。假如对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将两种情况都视为“没有必要执行”。

回收方法区

判定一个类型是否属于“不再被使用的类”的条件就比较苛刻。需要同时满足下面三种条件:

  1. 该类的所有实例已经被回收
  2. 加载该类的类加载器已经被回收
  3. 该类对应的Class对象没有在任何地方被引用

类加载过程

加载阶段

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

连接阶段

  • 验证:确保被加载的类的正确性

  • 准备:为类的静态变量分配内存,并将其赋默认值

  • 解析:将常量池中的符号引用替换为直接引用(内存地址)的过程

初始化

  • 类的静态变量赋初值

  • 在编译生成class文件时,编译器会产生两个方法加于class文件中,一个是类的初始化方法clinit, 另一个是实例的初始化方法init。 clinit指的是类构造器,主要作用是在类加载过程中的初始化阶段进行执行,执行内容包括静态变量初始化和静态块的执行。 init指的是实例构造器,主要作用是在类实例化过程中执行,执行内容包括成员变量初始化和代码块的执行。

对象的创建

​ 当Jvm遇到一条字节码new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。

​ 在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从Java堆中划分出来。假设Java堆中内存是绝对规整的,所有被使用过的内存都被放在一边,空闲的内存被放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式成为”指针碰撞”。但如果并不规整 则是采用空闲链表。

​ 对象创建在虚拟机是非常频繁的行为,即使仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。两种可选方案:一种是对分配内存空间的动作进行同步处理,采用CAS配上失败重试的方式保证更新操作的原子性;另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为TLAB,那个 线程需要分配内存,就在哪个线程的TLAB中分配,只有本地缓冲区用完了,分配新的缓冲区才需要同步锁定。

​ 内存分配完成之后,虚拟机必须将分配到的内存空间都初始化为零值,如果使用TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行。

​ 接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例。根据虚拟机当前运行状态的不同,如是否启用偏向锁。

​ Class文件中<init()>方法,构造函数。

synchronized

原理

它解决的是多线程之间访问贡献资源的同步问题,它保证了在被它修饰的方法或代码或代码块同一时间只有一个线程执行。

因为java线程是映射到操作系统的线程之上的,所以暂停或唤醒线程都需要Java程序从用户态转换到内核态,这段时间消耗较长。

修饰同步代码块时,会在编译出来的字节码前后加上monitorentermonitorexit。ObjectMonitor

其实真正的锁应该是这个monitor cpp对象,synchronized锁的那个java对象起到的只是关联monitor的作用, 只不过我们身在java层面,无法感知到jvm层面monitor的作用,所以才称synchronized的java锁对象为锁。

每次执行monitorenter指令的时候,是将当前synchronized锁对象 关联的monitor的_recursions加1, 执行monitorexit指令的时候,将当前object对象关联的monitor的_recursions减1, 当_recursions为0的时候,就说明线程不再持有锁对象。

锁升级过程

锁共有四种状态:无锁状态、偏向锁状态、轻量级锁状 态和重量级锁状态。

  • 偏向锁
    • 当一个线程访问同步块并获取到锁时,会在对象头和栈帧中的锁记录中存储锁偏向的线程ID,该线程在进入和退出不需要CAS。如果获取失败,则需要在测试一下MarkWord中偏向锁的标识是否设置为1,如果设置了就用CAS将对象头的的偏向锁指向当前线程;如果没有则使用CAS竞争。
    • 偏向锁是等有竞争才释放,且需要等待全局安全点(这个时间点上没有正在执行的字节码)。栈中的锁记录和对象头的MarkWord要么重新偏向其他线程,要么恢复到无锁或者标记对象不合适作为偏向锁,最后唤醒暂停的线程。
  • 轻量级锁
    • JVM会先在当前线程的栈帧中创建用于存储锁记录的空间,并将对象头的MarkWord复制到锁记录中,称为displaced mark word。然后线程尝试使用CAS将对象头中MarkWord替换为指向锁记录的指针。如果成功,就获取到锁,如果失败则表明还有其他线程竞争锁,当前线程尝试自旋。
    • 解锁时,CAS将displaced MarkWord替换回到对象头,如果成功,则表明没有竞争发生,如果失败,表示有竞争,锁膨胀为重量级锁。

整个synchronized的实现本身就是基于CAS的 ,轻量级锁和偏向锁都是使用了CAS操作的,偏向锁是使用CAS修改java锁对象的对象头的markword的偏向线程ID,而轻量级锁是使用CAS修改整个MarkWord为指向线程栈帧的指针。