JVM 原理分析

本贴最后更新于 3025 天前,其中的信息可能已经事过景迁

1. 类加载的过程

加载->连接(验证->准备->解析)->初始化->使用->卸载
  • 加载
    1、通过一个类的全限定名来获取其定义的二进制字节流。
    2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
    3、在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
    注意 : 虚拟机规范之中并没有规定要从哪里加载二进制字节流.所以就有开发人员玩出了很多有创造力的花样.比如从 jar 包,war 包中加载.网络中获取,applet 就是例子.运行时动态计算生成,用的是动态代理技术.由其他文件生成.典型的就是 jsp 应用.
  • 验证
    验证的目的是为了确保 Class 文件中的字节流包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。不同的虚拟机对类验证的实现可能会有所不同,但大致都会完成以下四个阶段的验证:文件格式的验证、元数据的验证、字节码验证和符号引用验证。
  • 准备
    准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配。对于该阶段有以下几点需要注意:
    1、这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
    2、这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。
  • 解析
    解析阶段是虚拟机将常量池中的符号引用转化为直接引用的过程.有大招!!!
    注意 : 虚拟机规范并没有规定解析阶段发生的具体时间.只要求了在执行 anewarray/checkcast/getfield/getstatic/instanceof
    invokedynamic/invokeintercace/invokespecial/invokestatic/
    invokevirtualnew 等 16 个用于操作符号引用的字节码指令之前,先对他们所使用的符号引用进行解析.所以虚拟机实现可以根据需要判断是在类加载阶段对常量池中的符号引用进行解析还是在符号引用被用到的时候再去解析.对同一个符号引用多次进行解析是一件很常见的事,除了 invokedynamic 指令以外,虚拟机实现可以对第一次的解析结果进行缓存.避免重复解析的动作.
    对于 invokedynamic 指令来说,上面的规则不成立.这个指令本来就是用于支持动态语言的.目前仅适用 java 语言的时候不会生成这条字节码指令,他所对应的引用称为 动态调用点限定符.这里的动态是指程序运行到这条指令的时候,解析动作才能进行.在加载刚刚完成,还没有执行程序之前进行的解析都是静态的.
  • 初始化
    初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的 Java 程序代码。

JVM 动态语言支持

  1. 什么是动态语言
    变量类型检查在运行期进行而不是编译期.

Bootstrap classLoader 不是任何类的父类加载器,用 c 实现
双亲委派模型的双亲就是指 ExtClassLoaderAppClassLoader
总结一下,下面是三种类加载器加载类文件的地方:

  • Bootstrap 类加载器 – JRE/lib/rt.jar
  • Extension 类加载器 – JRE/lib/ext 或者 java.ext.dirs 指向的目录
  • Application 类加载器 – CLASSPATH 环境变量, 由-classpath 或-cp 选项定义,或者是 JAR 中的 Manifest 的 classpath 属性定义.

类加载器的工作原理基于三个机制:委托、可见性和单一性

2. 总结

整个类加载过程中,除了在加载阶段用户应用程序可以自定义类加载器参与之外,其余所有的动作完全由虚拟机主导和控制。到了初始化才开始执行类中定义的Java程序代码

3. 垃圾收集基础算法

  • 引用计数法 (Reference Counting)
    引用计数器的实现很简单,对于一个对象 A,只要有任何一个对象引用了 A,则 A 的引用计数器就加 1,当引用失效时,引用计数器就减 1。只要对象 A 的引用计数器的值为 0,则对象 A 就不可能再被使用。
    但是引用计数器有一个严重的问题,即无法处理循环引用的情况。因此,在 Java 的垃圾回收器中没有使用这种算法。由于垃圾对象间相互引用,从而使垃圾回收器无法识别,引起内存泄漏。
  • 标记-清除算法 (Mark-Sweep)
    标记-清除算法将垃圾回收分为两个阶段:标记阶段和清除阶段。标记所有从根节点开始的较大对象.该算法最大的问题是存在大量的空间碎片,因为回收后的空间是不连续的。
  • 复制算法 (Copying)
    将现有的内存空间分为两快,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后,清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾回收。如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大。因此在真正需要垃圾回收的时刻,复制算法的效率是很高的。又由于对象在垃圾回收过程中统一被复制到新的内存空间中,因此,可确保回收后的内存空间是没有碎片的。该算法的缺点是将系统内存折半。
    Java 的新生代串行垃圾回收器中使用了复制算法的思想。新生代分为 eden 空间、from 空间、to 空间 3 个部分。其中 from 空间和 to 空间可以视为用于复制的两块大小相同、地位相等,且可进行角色互换的空间块。from 和 to 空间也称为 survivor 空间,即幸存者空间,用于存放未被回收的对象。
  • 标记-压缩算法 (Mark-Compact)
    复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在年轻代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。
    这种方法既避免了碎片的产生,又不需要两块相同的内存空间,因此,其性价比比较高。
  • 分代 (Generational Collecting)
    根据每块内存区间的特点,使用不同的回收算法,以提高垃圾回收的效率
    年轻代就选择效率较高的复制算法
    老年代的回收使用与新生代不同的标记-压缩算法

4. JVM 垃圾回收器分类

  • 新生代串行收集器
    串行收集器主要有两个特点:第一,它仅仅使用单线程进行垃圾回收;第二,它独占式的垃圾回收。
  • 老年代串行收集器
  • 并行收集器
  • 新生代并行回收 (Parallel Scavenge) 收集器
  • 老年代并行回收收集器
  • CMS 收集器
  • G1 收集器 (Garbage First)

4.1 CMS 收集器

CMS 是 Concurrent Mark Sweep 的缩写,意为并发标记清除.使用的是标记-清除算法,同时它又是一个使用多线程并发回收的垃圾收集器。以获得最低回收停顿时间为目标的收集器.

回收过程分为四步:

  • 初始标记(独占)
  • 并发标记
  • 重新标记(独占)
  • 并发清除
初始标记和重新标记都需要暂停java程序。
初始标记:只是标记一下gc roots能直接关联到的对象。速度很快
并发标记:gc roots tracing的过程,需要时间长
重新标记:为了修正并发标记阶段用户程序继续运作而导致标记变动的那不部分记录。初始标记和重新标记是独占系统资源的,而整个过程中最耗时的并发标记和并发清除阶段都是和用户线程一起工作的。所以总体上说,CMS垃圾收集器的回收过程是和用户线程并发执行的。
  • 优点: 并发收集、低停顿

缺点

1.对cpu资源敏感。在并发阶段,占用了一部分线程进行垃圾收集导致程序变慢。cms默认的收集线程是(cpu + 3) / 4,当cpu不足4个时,对用户线程影响很大。解决办法:增量式并发收集器的变种,就是在标记、清除阶段用户线程和gc线程交替运行。虽然收集时间变长了,但是对用用户线程的影响比较小
2.无法处理浮动垃圾。标记阶段之后产生的垃圾当前gc线程无法处理。由于垃圾收集阶段用户线程还要运行,因此还要留出足够的内存空间供用户线程使用。因此cms不能等到老年代完全被填满的时候才开始收集。默认设置是老年代使用68%的激动垃圾回收。
3.会产生大量的空间碎片。解决方案:`-XX:+UseCMSCompactAtFullCollection` 参数可以使 CMS 在垃圾收集完成后,进行一次内存碎片合并整理。内存碎片的整理并不是并发进行的.`XX:CMSFullGCsBeforeCompaction` 参数可以用于设定进行多少次 CMS 回收后,进行一次内存压缩。

4.2 G1 收集器 (Garbage First)

当今收集器技术发展最前沿的成果.G1 是一款面向服务端应用的垃圾收集器.目标是取代 cms 垃圾收集器.G1 的特性是

  • 并行与并发.充分利用多核的优势,使用多个 cpu 缩短用户线程的停顿时间.原先通过停顿用户线程执行的 gc 动作,仍然用并发的方式来执行
  • 分代收集.分代的概念依然保留.没啥特别的
  • 空间整合.类似标记整理或者复制算法,不会产生碎片.
  • 可预测的停顿.降低停顿是 g1 和 cms 共同的关注点.g1 除了关注停顿外,还能建立可预测的停顿时间模型,能让用户指定在一个时间片内,垃圾收集

将 java 堆划分成多个大小相等的独立区域(regin),虽然也有新生代和老年代的概念,但两者不再是物理隔离的了.他们都是一部分 regin 的集合.

g1 为什么能建立可预测的停顿时间模型?

因为有计划的避免在整个 java 堆中进行全区域的垃圾收集.会追踪各个 regin 里垃圾堆积的价值大小.维护一个优先队列.先回收回收价值最大的区域.
理论上很好理解,但是在实际应用中情况是很复杂的.比如说一个对象分配在 region 中,有可能会与整个堆中的任意对象发生引用关系,那么要判断对象是否存活的时候,岂不是要扫描整个堆?.答案是.虚拟机使用 rememeredSet 来避免全表扫描
每个 regin 中都有一个对应的 Remembered Set, 虚拟机发现引用类型的数据进行写操作时,会检查 reference 引用的对象是否处于不同的 region 中,如果是,则通过 cardTable 把相关引用信息记录到被引用对象所属的 region 的 Remembered Set

附录: GC 相关参数总结

1. 与串行回收器相关的参数
-XX:+UseSerialGC:在新生代和老年代使用串行回收器。
-XX:+SuivivorRatio:设置 eden 区大小和 survivor 区大小的比例。
-XX:+PretenureSizeThreshold:设置大对象直接进入老年代的阈值。当对象的大小超过这个值时,将直接在老年代分配。
-XX:MaxTenuringThreshold:设置对象进入老年代的年龄的最大值。每一次 Minor GC 后,对象年龄就加 1。任何大于这个年龄的对象,一定会进入老年代。
2. 与并行 GC 相关的参数
-XX:+UseParNewGC: 在新生代使用并行收集器。
-XX:+UseParallelOldGC: 老年代使用并行回收收集器。
-XX:ParallelGCThreads:设置用于垃圾回收的线程数。通常情况下可以和 CPU 数量相等。但在 CPU 数量比较多的情况下,设置相对较小的数值也是合理的。
-XX:MaxGCPauseMills:设置最大垃圾收集停顿时间。它的值是一个大于 0 的整数。收集器在工作时,会调整 Java 堆大小或者其他一些参数,尽可能地把停顿时间控制在 MaxGCPauseMills 以内。
-XX:GCTimeRatio:设置吞吐量大小,它的值是一个 0-100 之间的整数。假设 GCTimeRatio 的值为 n,那么系统将花费不超过 1/(1+n) 的时间用于垃圾收集。
-XX:+UseAdaptiveSizePolicy:打开自适应 GC 策略。在这种模式下,新生代的大小,eden 和 survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,以达到在堆大小、吞吐量和停顿时间之间的平衡点。
3. 与 CMS 回收器相关的参数
-XX:+UseConcMarkSweepGC: 新生代使用并行收集器,老年代使用 CMS+串行收集器。
-XX:+ParallelCMSThreads: 设定 CMS 的线程数量。
-XX:+CMSInitiatingOccupancyFraction:设置 CMS 收集器在老年代空间被使用多少后触发,默认为 68%。
-XX:+UseFullGCsBeforeCompaction:设定进行多少次 CMS 垃圾回收后,进行一次内存压缩。
-XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收。
-XX:+CMSParallelRemarkEndable:启用并行重标记。
-XX:CMSInitatingPermOccupancyFraction:当永久区占用率达到这一百分比后,启动 CMS 回收 (前提是-XX:+CMSClassUnloadingEnabled 激活了)。
-XX:UseCMSInitatingOccupancyOnly:表示只在到达阈值的时候,才进行 CMS 回收。
-XX:+CMSIncrementalMode:使用增量模式,比较适合单 CPU。
4. 与 G1 回收器相关的参数
-XX:+UseG1GC:使用 G1 回收器。
-XX:+UnlockExperimentalVMOptions:允许使用实验性参数。
-XX:+MaxGCPauseMills:设置最大垃圾收集停顿时间。
-XX:+GCPauseIntervalMills:设置停顿间隔时间。
5. 其他参数
-XX:+DisableExplicitGC: 禁用显示 GC。

5 内存分配和回收策略

  • 对象优先在 eden 分配
  • 大对象直接进入老年代.比如很长的字符串和数组
  • 长期存活的对象进入老年代.有个对象年龄计数器,熬过一次 minor gc,对象年龄加 1,年龄增加到一定程度进入老年代(默认 15)
  • 动态对象年龄判断.Survivor 中的对象满足同年龄(比如 N)对象所占空间达到了 Survivor 总空间的一半的时候,那么年龄大于或者等于 N 的对象都可以进入老年代
  • 空间分配担保.在 minor gc 之前,虚拟机会检查老年代最大可用连续空间是否大于 eden 区的所有对象总空间,如果这个条件成立,那么这次 minor gc 是安全的.如果不成立,则会查询 HandlePromotionFailure 是否允许失败担保
    a.如果允许,那么会继续检查老年代最大可用连续空间是否大于历次晋升老年代对象的平均大小
    ①.如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的
    ②.如果小于,进行一次 Full GC.
    b.如果不允许,也要改为进行一次 Full GC.

6. Java 运行时数据区

运行时数据区
runtime date regin

6.1 方法区

它存放的是类型信息,类的全限定名,当前类的直接父类的全限定名,这个类是接口类型, 类类型,还是枚举类型,类的访问修饰符信息,当前类型的超接口的全限定名,当前类型的常量池,字段信息,方法信息.一个到类的ClassLoader对象的引用,一个到表示该类的Class实例对象的引用,静态变量存储区

其实说来也简单,每个 class 都是被一个类加载器加载到方法区的, 类型信息中的到类的 ClassLoader 对象的引用, 表明了当前的类是被哪个类加载器加载的, 这个信息同时也标示了当前的类型的名称空间。

运行时常量池.存放字面量和符号引用.

6.2 堆

存放数组和对象
-Xms : 初始堆大小
-Xmx : 最大堆大小
-Xmn : 新生代大小

6.3 java 栈

保存着一个线程中的方法的调用状态. 一个 Java 线程的运行状态, 都由一个 Java 栈来保存. 每一方法对应一个栈帧

6.4 pc 寄存器

虚拟机执行字节码的行号指示器

6.5 本地方法栈

如果当前线程执行的代码是 C/C++ 写的本地代码, 那么这些方法就在本地方法栈中执行

6.6 对象的创建过程

  1. 当遇到一条 new 指令时,先在常量池中定位到这个类的符号引用,并检查是否被加载,解析,初始化过,没有的话,先执行类加载的过程.
  2. 为新生对象分配内存
  3. 将分配到的内存空间都初始化为零值
  4. 进行必要的设置

6.7 引用的类型

  • 强引用 : Object o=new Object(); // 强引用
  • 软引用 : SoftReference<String> softRef=new SoftReference<String>(str); 则内存空间足够,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
  • 弱引用 : WeakReference<String> abcWeakRef = new WeakReference<String>(str); 一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,如果这个对象是偶尔的使用,并且希望在使用时随时就能获取到,但又不想影响此对象的垃圾收集,那么你应该用 Weak Reference 来记住此对象
  • 虚引用 .为了对象被收集时收到一个系统通知

6.8 参数传递机制

  1. 基本类型变量的值传递,意味着变量本身被复制,并传递给 Java 方法。Java 方法对变量的修改不会影响到原变量。
  2. 引用的值传递,意味着对象的地址被复制,并传递给 Java 方法。Java 方法根据该引用的访问将会影响对象。

总的来说,copy 完没有重新 new 新的对象,此后的操作会影响原来的对象,反之不会.

7 性能分析工具

名词解释

  1. jps : java process status,虚拟机进程状态
  2. jstat : java statistics monitoring tool,收集虚拟机运行时数据
  3. jmap : memery map for java,内存转储快照(headdump 文件)
  4. jhat : JVM heap Dump browser,分析 headdump 文件
  5. jstack : stack trace for java,虚拟机的线程快照

7.1 jstat 举例

jstat -gcutil 6519 20 20: 关注 java 堆的情况,主要关注已使用空间占总空间的百分比
此处输入图片的描述
此处输入图片的描述
E:(Eden)
S0: (Survivor0)
S1: (Survivor1)
O : (Old ,老年代/)
P : (永久代,主要指方法区)

引用:

jvm 区域总体分两类,heap 区和非 heap 区。heap 区又分:Eden Space(伊甸园)、Survivor Space(幸存者区)、Tenured Gen(老年代-养老区)。 非 heap 区又分:Code Cache(代码缓存区)、Perm Gen(永久代)、Jvm Stack(java 虚拟机栈)、Local Method Statck(本地方法栈)。

8. 类文件结构

class 文件格式采用一种类似于 c 结构体的歪结构来存储数据.这种伪结构只有两种数据.无符号数和表.
无符号数 : 用来描述数组,索引引用/数量值等等.
: 由多个无符号数和其他表组成的符合数据类型.整个 class 文件本质就是一张表
1. 魔数和 class 文件的版本
每个 class 文件的头四个字节是魔数,验证文件是否是虚拟机可以接受的 class 文件格式.
紧接着的四个字节是 class 文件的版本号.java 的版本号从 45 开始
2. 常量池
占用 class 文件空间最大的数据项目之一.由于常量池中常量的数量是不固定的.因此在常量池的入口放了一个常量池容量计数器.
存放两类数据,字面量和符号引用.

  • 字面量接近于 java 语言方面的常量,比如文本字符串和 final 常量
  • 符号引用是编译原理里面的概念.与实际内存位置无关.只要无歧义的表达一个唯一值即可.

为什么不用直接引用?

java代码在编译的时候,并不像c/c++一样有连接的步骤,而是在虚拟机加载class文件的时候进行动态的连接,class文件中不会保存各个方法,字段的最终内存布局.这些字段/方法的符号引用不经过运行期转换的时候无法得到真正的内存入口地址.虚拟机运行时,需要从常量池中获取方法/字段的符号引用,再在类创建或者运行时解析具体的内存地址

3. 字段表集合
4. 方法表集合
5. 属性表集合

9. 字节码指令简介

1. 加载和存储指令
加载和存储指令用于将数据从栈帧的局部变量表和操作数栈之间来回传输。

  1. 将一个局部变量加载到操作数栈的指令包括:iload,iload_<n>,lload、lload_<n>、float、fload_<n>、dload、dload_<n>,aload、aload_<n>。
  2)将一个数值从操作数栈存储到局部变量标的指令:istore,istore_<n>,lstore,lstore_<n>,fstore,fstore_<n>,dstore,dstore_<n>,astore,astore_<n>
  3)将常量加载到操作数栈的指令:bipush,sipush,ldc,ldc_w,ldc2_w,aconst_null,iconst_ml,iconst_<i>,lconst_<l>,fconst_<f>,dconst_<d>
  4)局部变量表的访问索引指令:wide
一部分以尖括号结尾的指令代表了一组指令,如iload_<i>,代表了iload_0,iload_1等,这几组指令都是带有一个操作数的通用指令。

2. 运算指令

算术指令用于对两个操作数栈上的值进行某种特定运算,并把结果重新存入到操作栈顶。
    1)加法指令:iadd,ladd,fadd,dadd
    2)减法指令:isub,lsub,fsub,dsub
    3)乘法指令:imul,lmul,fmul,dmul
    4)除法指令:idiv,ldiv,fdiv,ddiv
    5)求余指令:irem,lrem,frem,drem
    6)取反指令:ineg,leng,fneg,dneg
    7)位移指令:ishl,ishr,iushr,lshl,lshr,lushr
    8)按位或指令:ior,lor
    9)按位与指令:iand,land
    10)按位异或指令:ixor,lxor
    11)局部变量自增指令:iinc
    12)比较指令:dcmpg,dcmpl,fcmpg,fcmpl,lcmp

3. 方法调用和返回指令

invokevirtual指令:调用对象的实例方法,根据对象的实际类型进行分派(虚拟机分派)。
invokeinterface指令:调用接口方法,在运行时搜索一个实现这个接口方法的对象,找出合适的方法进行调用。
invokespecial:调用需要特殊处理的实例方法,包括实例初始化方法,私有方法和父类方法
invokestatic:调用类方法(static)
方法返回指令是根据返回值的类型区分的,包括ireturn(返回值是boolean,byte,char,short和int),lreturn,freturn,drturn和areturn,另外一个return供void方法,实例初始化方法,类和接口的类初始化i方法使用。

4. 同步

JVM支持方法级同步和方法内部一段指令序列同步,这两种都是通过moniter实现的。
    方法级的同步是隐式的,无需通过字节码指令来控制,它实现在方法调用和返回操作中。虚拟机从方法常量池中的方法标结构中的ACC_SYNCHRONIZED标志区分是否是同步方法。方法调用时,调用指令会检查该标志是否被设置,若设置,执行线程持有moniter,然后执行方法,最后完成方法时释放moniter。
    同步一段指令集序列,通常由synchronized块标示,JVM指令集中有monitorenter和monitorexit来支持synchronized语义。
    结构化锁定是指方法调用期间每一个monitor退出都与前面monitor进入相匹配的情形。JVM通过以下两条规则来保证结结构化锁成立(T代表一线程,M代表一个monitor):
    1)T在方法执行时持有M的次数必须与T在方法完成时释放的M次数相等
    2)任何时刻都不会出现T释放M的次数比T持有M的次数多的情况

  • JVM

    JVM(Java Virtual Machine)Java 虚拟机是一个微型操作系统,有自己的硬件构架体系,还有相应的指令系统。能够识别 Java 独特的 .class 文件(字节码),能够将这些文件中的信息读取出来,使得 Java 程序只需要生成 Java 虚拟机上的字节码后就能在不同操作系统平台上进行运行。

    180 引用 • 120 回帖

相关帖子

1 回帖

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...
  • errorlife

    消灭 0 回复,顶一下干货