JVM学习笔记

本文所有内容来于:http://stuq.com/a/100ww

java代码是如何执行的
  • java代码是运行于java虚拟机上的,通过java虚拟机实现了跨平台,并且java虚拟机帮助程序员处理了容易出错的事务,比如内存管理。
  • java虚拟机会解释执行java字节码,并且对于热点代码会采用即时编译(Just-In-Time compilation,,JIT),即将一个方法中包含的所有字节码编译成机器码后再执行。如下图所示:
    JVM学习笔记_第1张图片
    JVM如何执行字节码
  • Java 虚拟机将运行时内存区域划分为五个部分,分别为方法区、堆、PC 寄存器、Java 方法栈和本地方法栈。如下图所示:
    JVM学习笔记_第2张图片
    JVM内存划分
JVM如何加载类

  java引用类型分为四种:类、接口、数组类和泛型参数。其中泛型参数会在编译过程中被擦除。因此 Java 虚拟机实际上只有前三种。在类、接口和数组类中,数组类是由 Java 虚拟机直接生成的,其他两种则有对应的字节流(接口,类)。

  • 加载:指的是查找字节流,数组类由JVM生成,所以这一过程可以省了。类加载是通过类加载器完成的。在 Java 虚拟机中,类的唯一性是由类加载器实例以及类的全名一同确定的。即便是同一串字节流,经由不同的类加载器加载,也会得到两个不同的类。类加载通过双亲委派模型,先由父类加载,父类加载不了再由子类加载。除了启动类加载器,类加载器都继承自java.lang.ClassLoader。类加载器分为:
    1:启动类加载器:由C++编写,不对应于任何对象。加载JRE/lib目录下的JAR包和虚拟机参数 -Xbootclasspath 指定的类。
    2:扩展类加载器:父类加载器是启动类加载器,负责加载JRE 的 lib/ext 目录下 jar 包中的类(以及由系统变量 java.ext.dirs 指定的类)。
    3:应用类加载器:父类加载器是扩展类加载器,它负责加载应用程序路径下的类。这里的应用程序路径,便是指虚拟机参数 -cp/-classpath、系统变量 java.class.path 或环境变量 CLASSPATH 所指定的路径。
  • 链接:是指将类合并至JVM中,使之能够执行的过程。分为验证,准备,解析。
    1:验证阶段:主要是保证加载的类满足JVM的约束,也是为了保证JVM的安全性。
    2:准备阶段:为被加载类的静态字段分配内存。只是分配内存,具体的初使化,则在初使化阶段。在这个阶段也会构造类的方法表
    3:解析阶段(非必须):在 class 文件被加载至 Java 虚拟机之前,这个类无法知道其他类及其方法、字段所对应的具体地址,甚至不知道自己方法、字段的地址。因此,每当需要引用这些成员时,Java 编译器会生成一个符号引用。在运行阶段,这个符号引用一般都能够无歧义地定位到具体目标上。解析阶段的目的,正是将这些符号引用解析成为实际引用。如果符号引用指向一个未被加载的类,或者未被加载类的字段或方法,那么解析将触发这个类的加载(但未必触发这个类的链接以及初始化。)
  • 初使化:初使化静态变量(由static修饰的变量)并执行static代码块。所有的static代码块会放到同一方法中,并命名为,这个方法会由JVM加锁保证同步。类的初使化时机:
    1:当虚拟机启动时,初始化用户指定的主类;
    2:当遇到用以新建目标类实例的 new 指令时,初始化 new 指令的目标类;
    3:当遇到调用静态方法的指令时,初始化该静态方法所在的类;
    4:当遇到访问静态字段的指令时,初始化该静态字段所在的类;
    5:子类的初始化会触发父类的初始化;
    6:如果一个接口定义了 default 方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化;
    7:使用反射 API 对某个类进行反射调用时,初始化这个类;
    8:当初次调用 MethodHandle 实例时,初始化该 MethodHandle 指向的方法所在的类。
JVM如何执行方法调用

  Java 虚拟机识别方法的关键在于类名、方法名、方法的参数类型以及返回类型。在同一个类中,如果同时出现多个名字相同且描述符也相同的方法,那么 Java 虚拟机会在类的验证阶段报错
  Java 虚拟机与 Java 语言不同,它并不限制名字与参数类型相同,但返回类型不同的方法出现在同一个类中,对于调用这些方法的字节码来说,由于字节码所附带的方法描述符包含了返回类型,因此 Java 虚拟机能够准确地识别目标方法。
  Java 虚拟机中关于方法重写的判定同样基于方法描述符。也就是说,如果子类定义了与父类中非私有、非静态方法同名的方法,那么只有当这两个方法的参数类型以及返回类型一致,Java 虚拟机才会判定为重写。对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法来实现 Java 中的重写语义
  Java 虚拟机中的静态绑定指的是在解析时便能够直接识别目标方法的情况,而动态绑定则指的是需要在运行过程中根据调用者的动态类型来识别目标方法的情况。
  Java 字节码中与调用相关的指令共有五种:
1:invokestatic:用于调用静态方法,编译期就可以确定调用的方法。
2:invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。编译期就可以确定调用的方法。
3:invokevirtual:用于调用非私有实例方法,需要在运行期确定需要调用的方法。
4:invokeinterface:用于调用接口方法,需要在运行期确定需要调用的方法。
5:invokedynamic:用于调用动态方法。
  在编译过程中,我们并不知道目标方法的具体内存地址。因此,Java 编译器会暂时用符号引用来表示该目标方法。这一符号引用包括目标方法所在的类或接口的名字,以及目标方法的方法名和方法描述符。符号引用存储在 class 文件的常量池之中。根据目标方法是否为接口方法,这些引用可分为接口符号引用和非接口符号引用。如果虚方法(invokevirtual)调用指向一个标记为 final 的方法,那么Java虚拟机也可以静态绑定该虚方法调用的目标方法。
  Java 虚拟机中采取了一种用空间换取时间的策略来实现动态绑定。它为每个类生成一张方法表(类加载的链接阶段实现),用以快速定位目标方法。方法表分为虚方法表(invokevirtual调用)与接口方法表(invokeinterface)调用。方法表本质上是一个数组,每个数组元素指向一个当前类及其祖先类中非私有的实例方法。方法表满足两个特质:

  • 子类方法表中包含父类方法表中的所有方法;
  • 子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

  方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用而言,实际引用将指向具体的目标方法。对于动态绑定的方法调用而言,实际引用则是方法表的索引值(实际上并不仅是索引值)。在执行过程中,Java 虚拟机将获取调用者的实际类型,并在该实际类型的虚方法表中,根据索引值获得目标方法。这个过程便是动态绑定。Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。Java 虚拟机所采用的单态内联缓存将纪录调用者的动态类型,以及它所对应的目标方法。当碰到新的调用者时,如果其动态类型与缓存中的类型匹配,则直接调用缓存的目标方法。否则,Java 虚拟机将该内联缓存劣化为超多态内联缓存,在今后的执行过程中直接使用方法表进行动态绑定

JVM异常处理

  抛出异常可分为显式和隐式两种。显式抛异常的主体是应用程序,它指的是在程序中使用“throw”关键字,手动将异常实例抛出。隐式抛异常的主体则是Java 虚拟机,它指的是 Java 虚拟机在执行过程中,碰到无法继续执行的异常状态,自动抛出异常
  异常实例的构造十分昂贵。这是由于在构造异常实例时,Java 虚拟机需要生成该异常的栈轨迹(stack trace)。该操作会逐一访问当前线程的 Java 栈帧,并且记录下各种调试信息,包括栈帧所指向方法的名字,方法所在的类名、文件名,以及在代码中的第几行触发该异常
  在编译生成的字节码中,每个方法都附带一个异常表。异常表中的每一个条目代表一个异常处理器,并且由 from 指针、to 指针、target 指针以及所捕获的异常类型构成。这些指针的值是字节码索引(bytecode index,bci),用以定位字节码。
  当程序触发异常时,Java 虚拟机会从上至下遍历异常表中的所有条目。当触发异常的字节码的索引值在某个异常表条目的监控范围内,Java 虚拟机会判断所抛出的异常和该条目想要捕获的异常是否匹配。如果匹配,Java 虚拟机会将控制流转移至该条目 target 指针指向的字节码。如果遍历完所有异常表条目,Java 虚拟机仍未匹配到异常处理器,那么它会弹出当前方法对应的 Java 栈帧,并且在调用者(caller)中重复上述操作。在最坏情况下,Java 虚拟机需要遍历当前线程 Java 栈上所有方法的异常表。
  finally 代码块的编译比较复杂。当前版本 Java 编译器的做法,是复制 finally 代码块的内容,分别放在 try-catch 代码块所有正常执行路径以及异常执行路径的出口中。

JVM学习笔记_第3张图片
finally字节码

对象的内存布局

  通过 new 指令新建出来的对象(分存在堆中),它的内存其实涵盖了所有父类中的实例字段。也就是说,虽然子类无法访问父类的私有实例字段,或者子类的实例字段隐藏了父类的同名实例字段,但是子类的实例还是会为这些父类实例字段分配内存的。
  在 Java 虚拟机中,每个 Java 对象都有一个对象头(object header),这个由标记字段(Mark Word)和类型指针所构成。其中,标记字段用以存储 Java 虚拟机有关该对象的运行数据,如哈希码、GC 信息以及锁信息,而类型指针则指向该对象的类
  在 64 位的 Java 虚拟机中,对象头的标记字段占 64 位,而类型指针又占了 64 位。也就是说,每一个 Java 对象在内存中的额外开销就是 16 个字节。为了尽量较少对象的内存使用量,64 位 Java 虚拟机引入了压缩指针 的概念(对应虚拟机选项 -XX:+UseCompressedOops,默认开启),将堆中原本 64 位的 Java 对象类型指针压缩成 32 位,这样对象头就只占用 12位(原来占用16位)。
  默认情况下,Java 虚拟机堆中对象的起始地址需要对齐至 8 的倍数。如果一个对象用不到 8N 个字节,那么空白的那部分空间就浪费掉了。这些浪费掉的空间我们称之为对象间的填充(padding)。
&essp; 内存对齐不仅存在于对象与对象之间,也存在于对象中的字段之间。比如说,Java 虚拟机要求 long 字段、double 字段,以及非压缩指针状态下的引用字段地址为 8 的倍数。
  在默认情况下,Java 虚拟机中的32 位压缩指针可以寻址到 2 的 35 次方个字节,也就是 32GB 的地址空间(超过 32GB 则会关闭压缩指针)。
具体的内存布局可以参考:https://www.jianshu.com/p/3d38cba67f8b

JVM垃圾回收

  目前 Java 虚拟机的主流垃圾回收器采取的是可达性分析算法。这个算法的实质在于将一系列 GC Roots 作为初始的存活对象合集(live set),然后从该合集出发,探索所有能够被该集合引用到的对象,并将其加入到该集合中,这个过程我们也称之为标记(mark)。最终,未被探索到的对象便是死亡的,是可以回收的。GC Roots 包括(但不限于)如下几种:
1:Java 方法栈桢中的局部变量;
2:已加载类的静态变量;
3:JNI handles;
4:已启动且未停止的 Java 线程。
  Java 虚拟机中的 Stop-the-world 是通过安全点(safepoint)机制来实现的。当 Java 虚拟机收到 Stop-the-world 请求,它便会等待所有的线程都到达安全点,才允许请求 Stop-the-world 的线程进行独占的工作。安全点的初始目的并不是让其他线程停下,而是找到一个稳定的执行状态。在这个执行状态下,Java 虚拟机的堆栈不会发生变化。这么一来,垃圾回收器便能够“安全”地执行可达性分析。
  回收死亡对象的内存共有三种方式,分别为:会造成内存碎片的清除、性能开销较大的压缩、以及堆使用效率较低的复制。
  Java 虚拟机将堆划分为新生代和老年代。其中,新生代又被划分为 Eden 区,以及两个大小相同的 Survivor 区。如下图所示:

JVM学习笔记_第4张图片
堆内存划分
堆空间是线程共享的,JVM通过为每个线程预分配一块空间来避免线程间申请内存发生冲突。这项技术被称之为 TLAB(Thread Local Allocation Buffer,对应虚拟机参数 -XX:+UseTLAB,默认开启)。
  Java 虚拟机会记录 Survivor 区中的对象一共被来回复制了几次。如果一个对象被复制的次数为 15(对应虚拟机参数 -XX:+MaxTenuringThreshold),那么该对象将被晋升(promote)至老年代。另外,如果单个 Survivor 区已经被占用了 50%(对应虚拟机参数 -XX:TargetSurvivorRatio),那么较高复制次数的对象也会被晋升至老年代。
  因为 Minor GC 只针对新生代进行垃圾回收,所以在枚举 GC Roots 的时候, 它需要考虑从老年代到新生代的引用。为了避免扫描整个老年代,Java 虚拟机引入了名为卡表的技术,大致地标出可能存在老年代到新生代引用的内存区域。

JVM如下区域会发生OutOfMemoryError
  • 堆内存不足是最常见的 OOM 原因之一,抛出的错误信息是“java.lang.OutOfMemoryError:Java heap space”。
  • 而对于 Java 虚拟机栈和本地方法栈,如果我们写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError。
  • 对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 Intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:“java.lang.OutOfMemoryError: PermGen space”
  • 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:“java.lang.OutOfMemoryError: Metaspace”
  • 程序计数器是唯一一块不会抛出内存OutOfMemoryError 的区域,程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址;或者,如果是在执行本地方法,则是未指定值(undefined)
Java内存模型

  即时编译器(和处理器)需要保证程序能够遵守 as-if-serial 属性。通俗地说,就是在单线程情况下,要给程序一个顺序执行的假象。即经过重排序的执行结果要与顺序执行的结果保持一致。但这在多线程执行的情况下,就有可能出现意想不到的结果。
  Java 内存模型通过定义了一系列的 happens-before 操作,让应用程序开发者能够轻易地表达不同线程的操作之间的内存可见性
Java 内存模型还定义了下述线程间的 happens-before 关系。
1:解锁操作 happens-before 之后(这里指时钟顺序先后)对同一把锁的加锁操作。
2:volatile 字段的写操作 happens-before 之后(这里指时钟顺序先后)对同一字段的读操作。
3:线程的启动操作(即 Thread.starts()) happens-before 该线程的第一个操作。
4:线程的最后一个操作 happens-before 它的终止事件(即其他线程通过 Thread.isAlive() 或 Thread.join() 判断该线程是否中止)。
5:线程对其他线程的中断操作 happens-before 被中断线程所收到的中断事件(即被中断线程的 InterruptedException 异常,或者第三个线程针对被中断线程的 Thread.interrupted 或者 Thread.isInterrupted 调用)。
6:构造器中的最后一个操作 happens-before 析构器的第一个操作。
  在遵守 Java 内存模型的前提下,即时编译器以及底层体系架构能够调整内存访问操作,以达到性能优化的效果。如果开发者没有正确地利用 happens-before 规则,那么将可能导致数据竞争。
  Java 内存模型是通过内存屏障来禁止重排序的。对于即时编译器来说,内存屏障将限制它所能做的重排序优化。对于处理器来说,内存屏障会导致缓存的刷新操作。

Java基本类型

基本类型如下图:

JVM学习笔记_第5张图片
java基本类型
尽管他们的默认值看起来不一样,但在内存中都是 0

  • Java 虚拟机规范中,boolean 类型被映射成 int 类型。具体来说,“true”被映射为整数 1,而“false”被映射为整数 0。Java 代码中的逻辑运算以及条件跳转,都是用整数相关的字节码来实现的
  • Java 的浮点类型采用 IEEE 754 浮点数格式。
  • 除 long 和 double 外,其他基本类型与引用类型在解释执行的方法栈帧中占用的大小是一致的(32位JVM占4个字节,64位JVM占8个字节),但它们在堆中占用的大小的确不同。在将 boolean、byte、char 以及 short 的值存入字段或者数组(存放堆数据时)单元时,Java 虚拟机会进行掩码操作。在读取时,Java 虚拟机则会将其扩展为 int 类型boolean与char因为没符号,高位直接以零填充,byte和short因为有符号,以符号位填充
  • boolean 字段和 boolean 数组比较特殊。在 HotSpot 中,boolean 字段占用一字节,而 boolean 数组则直接用 byte 数组来实现。为了保证堆中的 boolean 值是合法的,HotSpot 在存储时显式地进行掩码操作,也就是说,只取最后一位的值存入 boolean 字段或数组中
JVM实现反射

  在默认情况下,方法的反射调用为委派实现,委派给本地实现来进行方法调用。在调用超过 15 次之后(可以通过 -Dsun.reflect.inflationThreshold= 来调整),委派实现便会将委派对象切换至动态实现。这个动态实现的字节码是自动生成的,它将直接使用 invoke 指令来调用目标方法。动态实现和本地实现相比,其运行效率要快上 20 倍 。这是因为动态实现无需经过 Java 到 C++ 再到 Java 的切换,但由于生成字节码十分耗时,仅调用一次的话,反而是本地实现要快上 3 到 4 倍。反射调用的 Inflation 机制是可以通过参数(-Dsun.reflect.noInflation=true)来关闭的。这样一来,在反射调用一开始便会直接生成动态实现,而不会使用委派实现或者本地实现
  方法的反射调用会带来不少性能开销,原因主要有三个:变长参数方法导致的 Object 数组,基本类型的自动装箱、拆箱,还有最重要的方法内联

JVM实现synchronized

  当声明 synchronized 代码块时,编译而成的字节码将包含 monitorenter 和 monitorexit 指令。这两种指令均会消耗操作数栈上的一个引用类型的元素(也就是 synchronized 关键字括号里的引用),作为所要加锁解锁的锁对象。
  关于 monitorenter 和 monitorexit 的作用,我们可以抽象地理解为每个锁对象拥有一个锁计数器和一个指向持有该锁的线程的指针。当执行 monitorenter 时,如果目标锁对象的计数器为 0,那么说明它没有被其他线程所持有。在这个情况下,Java 虚拟机会将该锁对象的持有线程设置为当前线程,并且将其计数器加 1。在目标锁对象的计数器不为 0 的情况下,如果锁对象的持有线程是当前线程,那么 Java 虚拟机可以将其计数器加 1,否则需要等待,直至持有线程释放该锁。当执行 monitorexit 时,Java 虚拟机则需将锁对象的计数器减 1。当计数器减为 0 时,那便代表该锁已经被释放掉了。HotSpot 虚拟机中具体的锁实现分为:

  • 重量级锁: Java 虚拟机中最为基础的锁实现。在这种状态下,Java 虚拟机会阻塞加锁失败的线程,并且在目标锁被释放的时候,唤醒这些线程。Java 线程的阻塞以及唤醒,都是依靠操作系统来完成的开销非常大。为了尽量避免昂贵的线程阻塞、唤醒操作,Java 虚拟机会在线程进入阻塞状态之前,以及被唤醒后竞争不到锁的情况下,进入自旋状态,在处理器上空跑并且轮询锁是否被释放。如果此时锁恰好被释放了,那么当前线程便无须进入阻塞状态,而是直接获得这把锁。
  • 轻量级锁:对象头中的标记字段(mark word)。它的最后两位便被用来表示该对象的锁状态。其中,00 代表轻量级锁,01 代表无锁(或偏向锁),10 代表重量级锁,11 则跟垃圾回收算法的标记有关。当进行加锁操作时,Java 虚拟机会判断是否已经是重量级锁。如果不是,它会在当前线程的当前栈桢中划出一块空间,作为该锁的锁记录,并且将锁对象的标记字段复制到该锁记录中。然后,Java 虚拟机会尝试用 CAS(compare-and-swap)操作将锁对象的标记字段替换为一个指针,指向当前线程栈上的一块空间,存储着锁对象原本的标记字段
  • 偏向锁:在线程进行加锁时,如果该锁对象支持偏向锁,那么 Java 虚拟机会通过 CAS 操作,将当前线程的地址记录在锁对象的标记字段之中,并且将标记字段的最后三位设置为 101
编译器桥接方法

  对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成桥接方法来实现 Java 中的重写语义。下机的图可以通过字节码看出是如何实现的:
JVM学习笔记_第6张图片
java class 实现桥接方法
方法内联

方法内联是指:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。以 getter/setter 为例,如果没有方法内联,在调用 getter/setter 时,程序需要保存当前方法的执行位置,创建并压入用于 getter/setter 的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。而当内联了对 getter/setter 的方法调用后,上述操作仅剩字段访问。

即时编译

  通常而言,代码会先被 Java 虚拟机解释执行,之后反复执行的热点代码则会被即时编译成为机器码,直接运行在底层硬件之上。即时编译器有C1,C2,Grral。

  • C1:通过-client指定,通常运用于执行时间较短,对启动性能有要求的程序。
  • C2:通过-server指定,对峰值性能有要求的程序,C2比C1的执行效率更快,但是编译时间更久。
  • Grral是一个实验性质的编译器,通过参数 -XX:+UnlockExperimentalVMOptions 启用。
  • Java 7 引入了分层编译(对应参数 -XX:+TieredCompilation)的概念,综合了 C1 的启动性能优势和 C2 的峰值性能优势。从 Java 8 开始,Java 虚拟机默认采用分层编译的方式。它将执行分为五个层次,
    1:0 层解释执行(也会收集程序的profiling);
    2:1 层执行没有 profiling 的 C1 代码;
    3:2 层执行部分 profiling 的 C1 代码;
    4:3 层执行全部 profiling 的 C1 代码;
    5: 4 层执行 C2 代码。
    其中profiling为运行时的程序的执行状态数据,比如循环调用的次数,方法调用的次数,分支跳转次数,类型转换次数等。
  • 即时编译是由方法调用计数器和循环回边计数器触发的。在使用分层编译的情况下,触发编译的阈值是根据当前待编译的方法数目动态调整的。
  • 基于分支 profile 的优化以及基于类型 profile 的优化都将对程序今后的执行作出假设。这些假设将精简所要编译的代码的控制流以及数据流。在假设失败的情况下,Java 虚拟机将采取去优化(从执行即时编译生成的机器码切换回解释执行),退回至解释执行并重新收集相关的 profile。
逃逸分析

  逃逸分析将判断新建的对象是否逃逸。即时编译器判断对象是否逃逸的依据,一是对象是否被存入堆中(静态字段或者堆中对象的实例字段),二是对象是否被传入未知代码中。
当发现一个对象只在某个方法里,或者这个方法的内联方法里,则可以认为这个对象是逃逸的。主要的优化有:1:锁消除,对逃逸的对象加锁是没有意义的。2:采用标量替换的技术将需要分配在堆上的对象直接在栈上采用变量的方式进行替换。

你可能感兴趣的:(JVM学习笔记)