【基础技术】- Java虚拟机核心知识梳理

梳理Java虚拟机相关核心知识,文章只是梳理核心知识点,具体细节请参考一下资料

JVM内存结构

  • JVM内部结构图

image

  • 如果将jvm内部结构按照线程相关划分,可以分为线程独享组件线程共享组件

jvm内部区域图

  • Java内存模型

image

线程相关组件

程序计数器

PC 指当前指令(或操作码)的地址,本地指令除外。如果当前方法是 native 方法,那么PC 的值为 undefined。所有的 CPU 都有一个 PC,典型状态下,每执行一条指令 PC 都会自增,因此 PC 存储了指向下一条要被执行的指令地址。JVM 用 PC 来跟踪指令执行的位置,PC 将实际上是指向方法区(Method Area)的一个内存地址。

栈(Stack)

每个线程拥有自己的栈,栈包含每个方法执行的栈帧。栈是一个后进先出(LIFO)的数据结构,因此当前执行的方法在栈的顶部。每次方法调用时,一个新的栈帧创建并压栈到栈顶。当方法正常返回或抛出未捕获的异常时,栈帧就会出栈。除了栈帧的压栈和出栈,栈不能被直接操作。所以可以在堆上分配栈帧,并且不需要连续内存。

  • 栈帧(Frame)

每次方法调用都会新建一个新的栈帧并把它压栈到栈顶。当方法正常返回或者调用过程中抛出未捕获的异常时,栈帧将出栈。更多关于异常处理的细节,可以参考下面的异常信息表章节。

每个栈帧包含:

  • 局部变量数组 局部变量数组包含了方法执行过程中的所有变量,包括 this 引用、所有方法参数、其他局部变量。对于类方法(也就是静态方法),方法参数从下标 0 开始,对于对象方法,位置0保留为 this。
  • 返回值
  • 操作数栈 操作数栈在执行字节码指令过程中被用到,这种方式类似于原生 CPU 寄存器。大部分 JVM 字节码把时间花费在操作数栈的操作上:入栈、出栈、复制、交换、产生消费变量的操作。因此,局部变量数组和操作数栈之间的交换变量指令操作通过字节码频繁执行。比如,一个简单的变量初始化语句将产生两条跟操作数栈交互的字节码。
  • 类当前方法的运行时常量池引用

Native栈

并非所有的 JVM 实现都支持本地(native)方法,那些提供支持的 JVM 一般都会为每个线程创建本地方法栈。如果 JVM 用 C-linkage 模型实现 JNI(Java Native Invocation),那么本地栈就是一个 C 的栈。在这种情况下,本地方法栈的参数顺序、返回值和典型的 C 程序相同。本地方法一般来说可以(依赖 JVM 的实现)反过来调用 JVM 中的 Java 方法。这种 native 方法调用 Java 会发生在栈(一般是 Java 栈)上;线程将离开本地方法栈,并在 Java 栈上开辟一个新的栈帧。

线程间共享

堆被用来在运行时分配类实例、数组。不能在栈上存储数组和对象。因为栈帧被设计为创建以后无法调整大小。栈帧只存储指向堆中对象或数组的引用。与局部变量数组(每个栈帧中的)中的原始类型和引用类型不同,对象总是存储在堆上以便在方法结束时不会被移除。对象只能由垃圾回收器移除。

为了支持垃圾回收机制,堆被分为了下面三个区域:

  • 新生代
    • 经常被分为 Eden 和 Survivor
  • 老年代
  • 永久代

java堆区域划分

  • 内存管理

对象和数组永远不会显式回收,而是由垃圾回收器自动回收。通常,过程是这样的:

  • 新的对象和数组被创建并放入老年代。
  • Minor垃圾回收将发生在新生代。依旧存活的对象将从 eden 区移到 survivor 区。
  • Major垃圾回收一般会导致应用进程暂停,它将在三个区内移动对象。仍然存活的对象将被从新生代移动到老年代。
  • 每次进行老年代回收时也会进行永久代回收。它们之中任何一个变满时,都会进行回收。

非堆内存

  • 永久代,包括:
    • 方法区
    • 驻留字符串(interned strings)
  • 代码缓存(Code Cache):用于编译和存储那些被 JIT 编译器编译成原生代码的方法。

方法区

方法区存储了每个类的信息,比如:

  • Classloader 引用
  • 运行时常量池
    • 数值型常量
    • 字段引用
    • 方法引用
    • 属性
  • 字段数据
    • 针对每个字段的信息
      • 字段名
      • 类型
      • 修饰符
      • 属性(Attribute)
  • 方法数据
    • 每个方法
      • 方法名
      • 返回值类型
      • 参数类型(按顺序)
      • 修饰符
      • 属性
  • 方法代码
    • 每个方法
      • 字节码
      • 操作数栈大小
      • 局部变量大小
      • 局部变量表
      • 异常表
      • 每个异常处理器
      • 开始点
      • 结束点
      • 异常处理代码的程序计数器(PC)偏移量
      • 被捕获的异常类对应的常量池下标

JVM类加载机制

类文件结构

类加载过程

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。它们开始的顺序如下图所示:

jvm类加载过程

  • Classloader 引用

所有的类加载之后都包含一个加载自身的加载器的引用,反过来每个类加载器都包含它们加载的所有类的引用。

加载

  • 通过一个类的全限定名来获取其定义的二进制字节流。
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
  • 在 Java 堆中生成一个代表这个类的 java.lang.Class 对象,作为对方法区中这些数据的访问入口。
类加载器
  • Bootstrap 加载器一般由本地代码实现,因为它在 JVM 加载以后的早期阶段就被初始化了。bootstrap 加载器负责载入基础的 Java API,比如包含 rt.jar。它只加载拥有较高信任级别的启动路径下找到的类,因此跳过了很多普通类需要做的校验工作。
  • Extension 加载器加载了标准 Java 扩展 API 中的类,比如 security 的扩展函数。
  • System 加载器是应用的默认类加载器,比如从 classpath 中加载应用类。
  • 用户自定义类加载器也可以用来加载应用类。使用自定义的类加载器有很多特殊的原因:运行时重新加载类或者把加载的类分隔为不同的组,典型的用法比如 web 服务器 Tomcat。

jvm加载器

双亲委派模型
  • 双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。

双亲委派模型流程图

验证

  • 文件格式的验证:验证字节流是否符合 Class 文件格式的规范,并且能被当前版本的虚拟机处理,该验证的主要目的是保证输入的字节流能正确地解析并存储于方法区之内。经过该阶段的验证后,字节流才会进入内存的方法区中进行存储,后面的三个验证都是基于方法区的存储结构进行的。
  • 元数据验证:对类的元数据信息进行语义校验(其实就是对类中的各数据类型进行语法校验),保证不存在不符合 Java 语法规范的元数据信息。
  • 字节码验证:该阶段验证的主要工作是进行数据流和控制流分析,对类的方法体进行校验分析,以保证被校验的类的方法在运行时不会做出危害虚拟机安全的行为。
  • 符号引用验证:这是最后一个阶段的验证,它发生在虚拟机将符号引用转化为直接引用的时候(解析阶段中发生该转化,后面会有讲解),主要是对类自身以外的信息(常量池中的各种符号引用)进行匹配性的校验。

准备

  • 这时候进行内存分配的仅包括类变量(static),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在 Java 堆中。
  • 这里所设置的初始值通常情况下是数据类型默认的零值(如 0、0L、null、false 等),而不是被在 Java 代码中被显式地赋予的值。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行。符号引用就是一组符号来描述目标,可以是任何字面量

初始化

初始化是类加载过程的最后一步,到了此阶段,才真正开始执行类中定义的 Java 程序代码。在准备阶段,类变量已经被赋过一次系统要求的初始值,而在初始化阶段,则是根据程序员通过程序指定的主观计划去初始化类变量和其他资源,或者可以从另一个角度来表达:初始化阶段是执行类构造器 ()方法的过程

  • ()方法的执行规则:

    • 1、()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。

    • 2、()方法与实例构造器()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的()方法执行之前,父类的()方法已经执行完毕。因此,在虚拟机中第一个被执行的()方法的类肯定是java.lang.Object。

    • 3、()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成()方法。

    • 4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成()方法。但是接口鱼类不同的是:执行接口的()方法不需要先执行父接口的()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的()方法。

    • 5、虚拟机会保证一个类的()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的()方法,其他线程都需要阻塞等待,直到活动线程执行()方法完毕。如果在一个类的()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。

  • 类初始化时机

    • 遇到 newgetstaticputstaticinvokestatic 这四条字节码指令时,如果类还没有进行过初始化,则需要先触发其初始化。生成这四条指令最常见的 Java 代码场景是:使用 new 关键字实例化对象时、读取或设置一个类的静态字段(static)时(被 static 修饰又被 final 修饰的,已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法时。
    • 使用 Java.lang.refect 包的方法对类进行反射调用时,如果类还没有进行过初始化,则需要先触发其初始化。
    • 当初始化一个类的时候,如果发现其父类还没有进行初始化,则需要先触发其父类的初始化。
    • 当虚拟机启动时,用户需要指定一个要执行的主类,虚拟机会先执行该主类。

    虚拟机规定只有这四种情况才会触发类的初始化,称为对一个类进行主动引用,除此之外所有引用类的方式都不会触发其初始化,称为被动引用

Java 垃圾收集机制

垃圾对象判定

  • 引用计数算法:每个对象有一个引用计数属性,新增一个引用时计数加1,引用释放时计数减1,计数为0时可以回收。此方法简单,无法解决对象相互循环引用的问题。
  • 可达性分析(Reachability Analysis)根搜索算法:从GC Roots开始向下搜索,搜索所走过的路径称为引用链。当一个对象到GC Roots没有任何引用链相连时,则证明此对象是不可用的。不可达对象。

在Java语言中,GC Roots包括:

  • 虚拟机栈中引用的对象。
  • 方法区中类静态属性实体引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI引用的对象。

垃圾回收分析

我们对内存的分配策略明确以下三点:

  • 对象优先在 Eden 分配。
  • 大对象直接进入老年代。
  • 长期存活的对象将进入老年代。

对垃圾回收策略说明以下两点:

  • 新生代 GC(Minor GC):发生在新生代的垃圾收集动作,因为 Java 对象大多都具有朝生夕灭的特性,因此Minor GC 非常频繁,一般回收速度也比较快。
  • 老年代 GC(Major GC/Full GC):发生在老年代的 GC,出现了 Major GC,经常会伴随至少一次 Minor GC。由于老年代中的对象生命周期比较长,因此 Major GC 并不频繁,一般都是等待老年代满了后才进行 Full GC,而且其速度一般会比 Minor GC 慢 10 倍以上。另外,如果分配了 Direct Memory,在老年代中进行 Full GC时,会顺便清理掉 Direct Memory 中的废弃对象。

垃圾收集算法

标记 -清除算法

标记—清除算法是最基础的收集算法,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的根搜索算法中判定垃圾对象的标记过程

  • 缺点
    • 标记和清除过程的效率都不高
    • 产生空间碎片,会提前出发gc

复制算法

复制(Copying)的收集算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉

  • 缺点
    • 内存缩小为原来的一半,持续复制长生存期的对象则导致效率降低

标记-整理

根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存

分代收集算法

分代收集(Generational Collection)算法,把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法

垃圾收集器

如果说收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现

Serial收集器

串行收集器是最古老,最稳定以及效率高的收集器,可能会产生较长的停顿,只使用一个线程去回收

  • 参数控制 :
    • -XX:+UseSerialGC

ParNew收集器

ParNew收集器其实就是Serial收集器的多线程版本。新生代并行,老年代串行;新生代复制算法、老年代标记-压缩

  • 参数控制:
  • -XX:+UseParNewGC ParNew收集器
  • -XX:ParallelGCThreads 限制线程数量

Parallel收集器

Parallel Scavenge收集器类似ParNew收集器,Parallel收集器更关注系统的吞吐量。可以通过参数来打开自适应调节策略,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量;也可以通过参数控制GC的时间不大于多少毫秒或者比例;新生代复制算法、老年代标记-压缩

  • 参数控制:
    • -XX:+UseParallelGC 使用Parallel收集器+ 老年代串行

Parallel Old 收集器

Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器是在JDK 1.6中才开始提供

  • 参数控制:
    • -XX:+UseParallelOldGC使用Parallel收集器+ 老年代并行

CMS收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器

从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,

  • 整个过程分为4个步骤,包括:
    • 初始标记(CMS initial mark)
    • 并发标记(CMS concurrent mark)
    • 重新标记(CMS remark)
    • 并发清除(CMS concurrent sweep)
  • 优点: 并发收集、低停顿
  • 缺点: 产生大量空间碎片、并发阶段会降低吞吐量

  • 参数控制:
    • -XX:+UseConcMarkSweepGC 使用CMS收集器
    • -XX:+ UseCMSCompactAtFullCollection Full GC后,进行一次碎片整理;整理过程是独占的,会引起停顿时间变长
    • -XX:+CMSFullGCsBeforeCompaction 设置进行几次Full GC后,进行一次碎片整理
    • -XX:ParallelCMSThreads 设定CMS的线程数量(一般情况约等于可用CPU数量)

G1收集器

G1是目前技术发展的最前沿成果之一,HotSpot开发团队赋予它的使命是未来可以替换掉JDK1.5中发布的CMS收集器。与CMS收集器相比G1收集器有以下特点:

  • 空间整合,G1收集器采用标记整理算法,不会产生内存空间碎片。分配大对象时不会因为无法找到连续空间而提前触发下一次GC。
  • 可预测停顿,这是G1的另一大优势,降低停顿时间是G1和CMS的共同关注点,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为N毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒,这几乎已经是实时Java(RTSJ)的垃圾收集器的特征了。

常用命令工具

参考资料

总结

这篇文章说明jvm的主要知识点,这些也是为了以后能在jvm优化过程中,能够理解jvm调优参数,在遇到问题时,能够合理利用工具分析出内存泄露,进而跳转jvm参数达到一个最优的效果。 接下来就要熟悉如何通过工具分析问题可能发生的原因利用jvm命令工具查看当前虚拟机状态,通过设置tomcat参数导出dump日志分析gc的次数以及时间,熟悉jvm参数然后在做调整。 如何根据性能监控工具即使发现gc问题,并逐步排查解决

Search

    Post Directory