> 文章列表 > 简明易懂的JVM理解

简明易懂的JVM理解

简明易懂的JVM理解

文章目录

  • 简明易懂的JVM和GC理解
  • 写在前面
  • Java虚拟机(JVM)的组成
    • 基本介绍
    • 结构
    • 加载子系统(ClassLoader SubSystem)
      • 介绍
      • 类加载过程
        • 类加载过程小结
      • 双亲委派模型(Parent-Delegation Model)
        • 简介
        • 优点
        • Java9的类加载的委派关系变动
        • 双亲委派模型小结
    • 运行时数据区(Runtime Data Areas)
      • 介绍
      • 程序计数器 (Program Counter Register)
        • 程序计数器小结
      • 虚拟机栈 (JVM Stacks)
        • 虚拟机栈小结
      • 本地方法栈(Native Method Stacks)
      • Java堆 (Java Heap)
        • Java对象堆栈小结
      • 方法区
        • 方法区小结
      • 直接内存
        • 直接内存小结
    • 执行引擎 (Execution Engine)
      • 介绍
      • 执行引擎的结构
      • 解释器(Interpreter)
      • 即时编译器(Just In Time Compiler)
      • 中间代码生成器
      • 分析器(Profiler)
      • 垃圾收集器(Garbage Collerctor)
      • 执行引擎的工作过程
      • 方法调用
      • 简明总结
        • 参考文献

简明易懂的JVM和GC理解

写在前面

写这篇文章是为了用简明易懂的写法,尽可能的在较短的篇幅内写出对Java虚拟机和内存垃圾策略的理解。解析Java虚拟机和GC策略的文章很多,有些讲的还很深入。但是对平时不常接触Java虚拟机和GC策略的人来说,有些过于晦涩了,概念很多,层次复杂,既不方便理解,也难以记忆。作者也有这方面难题,对Java虚拟机有一定了解,但在面试等场合很难有条理的讲解清楚,因此用此文以简洁的写法予以呈现。目标就是读完此文后,对JVM和GC有个简明的,全局的,有序的理解,对面试时一些八股文的问题可以有条理的解答。

需要快速了解的可以只看章节介绍和章节小结。

本文参考多个文章,已在结尾予以注明,需要更详细内容的可以通过链接学习。

Java虚拟机(JVM)的组成

基本介绍

Java虚拟机(JVM)是什么?官方解释JVM 是 Java Virtual Machine 的缩写,它是一个虚构出来的计算机,一种规范。通过在实际的计算机上仿真模拟各类计算机功能实现...
作者的理解,Java虚拟机就是运行Java程序的基础环境,开发人员编写的Java程序并不直接在操作系统上运行,而是交由JVM运行,每个Java应用程序启动都是先调用启动了一个JVM环境然后由JVM环境再去执行用户编写的Java程序。这也是Java得以跨平台运行的秘密,Java虚拟机对用户屏蔽了不同操作系统乃至硬件架构的区别,用户只需适配Java JVM自己的规范即可。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-fL5e0XmZ-1676968052619)(link “https://my-blog-to-use.oss-cn-beijing.aliyuncs.com/2019-11/d947f91e44c44c6c80222b49c2dee859-new-image19a36451-d673-486e-9c8e-3c7d8ab66929.png”)]

对于现代程序员,JVM的概念应该很好理解。现在主流语言都有类似的虚拟机(运行时)结构,比如C#有.net CLR,javascript有nodejs,go有go runtime等。
而且有许多直观的实例能作为对比,譬如在windows-PC电脑上玩安卓手游需要下载安卓模拟器,可以简单的认为安卓模拟器就是充当了一个安卓程序(Java程序)在PC上跨平台运行的运行时(虚拟机),没有这个虚拟机,则为安卓开发的程序不可能在windows-PC上跑起来。

结构

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-giNGxgOv-1676968052619)(link “https://img-my.csdn.net/uploads/201209/29/1348934141_8447.jpg”)]

如上面的架构图所示,JVM分为三个主要子系统:

  • 类加载子系统
  • 运行时数据区
  • 执行引擎

类加载子系统(ClassLoader SubSystem)

介绍

上面说了,每个Java程序需要启动一个JVM环境来运行用户的Java程序,那么用户的程序是如何被Java虚拟机识别的呢?直接看用户写的程序,也不过是英文的文本文件。JVM 是不认识文本文件的,它只能识别按其规范格式生成的二进制文件。

简单来说,用户的程序需要先被编译成二进制的字节码文件,javac编译器便是这个步骤的主导,而JVM加载识别的也是字节码文件。比如一个用户编写的HelloWorld.java程序,javac编译器将其编译为二进制的HelloWorld.class文件,JVM再读入此字节码文件,编译在这里不详谈。

JVM接收字节码文件后,由类加载子系统加载字节码文件,它就像一个搬运工一样,会把所有的.class 文件全部搬进 JVM 里面来,并识别为JVM可运行的数据结构,那这些数据储存在哪里呢?
就是储存在下一节详谈的运行时数据区

官方的说法,类加载子系统动态加载,连接类信息,并在运行时(而非编译时)首次引用类时初始化类文件。

可以看到类加载子系统可以动态的加载类,所以Java程序中的类并不是程序启动后就一成不变的,而是可以根据需要,动态的加载和卸载类。Java动态代理,Spring事务,AOP切面编程都是利用这一特性实现其功能的。在程序初始化时,加载器会加载尽可能少的类资源,只有当它们真正需要的时候,才进行加载。这不仅缩短了程序初始化时间,而且减少了运行程序消耗的资源。

类加载过程

类加载过程:加载->连接->初始化。连接过程又可分为三步:验证->准备->解析

  • 加载
    应用程序的全部Java类都由类加载器加载到Java虚拟机之中,JVM类加载子系统提供了三个类加载器。启动类加载器,平台/扩展 类加载器,应用程序类加载器。
    三个类加载器是存在优先级层次结构的,其按层次去执行加载类的任务,这一按层次优先级加载的过程被称为双亲委派模型,是一个常见的八股文考点

    • 启动类加载器 BootStrap ClassLoader
      最顶层的类加载器
      有许多文章介绍:启动类加载器 BootStrap ClassLoader,C++实现,负责加载 %JAVA_HOME%/lib目录下的 jar 包和类或者被 -Xbootclasspath参数指定的路径中的所有类。

      这个介绍对Java9之前的JVM类加载器系统都适用,但是请注意,在Java9版本时,Java官方更改了JDK的类库结构,使用JPMS模块化系统替换了JDK中原有的类库结构,所以启动类加载器并不再时简单的加载%JAVA_HOME%/lib目录下的 jar 包了,而是根据规则加载JDK系统库目录下指定的模块jar包。
      并且,Java9之后启动类加载器现在是在JVM内部和Java类库共同协作实现的类加载器(不再是C++实现),但是为了与之前代码兼容,在获取启动类加载器的场景中仍然会返回null,而不会得到BootClassLoader实例。

      简单来说就是加载Java虚拟机系统的基础类库的类加载器。

    • 平台/扩展 类加载器 Platform/Extension ClassLoader
      中层的类加载器,上级为启动类加载器
      有许多文章介绍:扩展类加载器Extension ClassLoader主要负责加载 %JRE_HOME%/lib/ext 目录下的 jar 包和类,或被 java.ext.dirs 系统变量所指定的路径下的 jar 包。

      同样的,这个介绍对Java9之前的JVM类加载器系统都适用,而在Java9版本中,这个类加载发生了重大变化。扩展机制被移除了,扩展类加载器由于向后兼容性的原因被保留,不过被重命名为平台类加载器(platform classloader).可以通过ClassLoader的新方法getPlatformClassLoader()来获取。同样需要负责根据规则加载JDK系统库目录下指定的模块jar包。

      简单来说就是加载Java虚拟机系统的扩展类库的类加载器。

    • 应用程序类加载器 Application ClassLoader
      面向我们用户程序的加载器,上级为平台/扩展 类加载器
      又称为系统类加载器(System ClassLoader)
      负责加载当前应用classpath下的所有jar包和类。
      同样的在Java9版本中,此类加载器也不仅仅负责加载用户程序的类信息,同样需要负责根据规则加载目录下指定的模块jar包。


    除了以上三个JVM提供的类加载器,还有两种类加载器。

    • 线程上下文类加载器 ThreadContext ClassLoader
      很特殊的一种类加载器,相比启动类加载器,平台/扩展 类加载器,应用程序类加载器,以上三种它们是真实存在的类,而且遵从双亲委派模型。而线程上下文类加载器其实只是一个概念。线程上下文类加载器的作用是为了破坏Java类加载委托机制,使程序可以逆向使用类加载器。(建议先阅读双亲委派模型)

      Java中的类加载机制是双亲委派模型,即按照AppClassLoader → ExtendClassLoader → BootstrapClassLoader 的顺序,子ClassLoader将一个类加载的任务委托给父ClassLoader(父ClassLoader会再委托给父的父ClassLoader)来完成,只有父ClassLoader无法完成该类的加载时,子ClassLoader才会尝试自己去加载该类。所以越基础的类由越上层的ClassLoader进行加载,但如果基础类又要调用回用户的代码,那该怎么办?为了解决这个问题,Java设计团队只好引入了一个不太优雅的设计:Thread ContextClassLoader(线程上下文类加载器)

      Java服务提供者接口(Service Provider Interface,SPI)机制就存在以上场景。Java服务提供者接口机制,允许Java核心库只定义接口而不定义实现,在用户提供的包中提供实现类,作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。常见的 SPI 有 JDBC、JCE、JNDI、JAXP 和 JBI 等。

      既然SPI接口是在Java核心库中定义的,则当然由启动类加载器加载类信息。而用户提供的包中提供实现类在用户类路径中被加载,必然是用户类加载器加载类信息。根据Java规范,一个类由类加载器A加载,那么这个类依赖类也应该由相同的类加载器加载。作为加载SPI接口的启动类加载器无法加载 SPI 的实现类的,因为它限定了只能加载 Java 的核心库,并不能加载到放在用户目录的SPI实现类。它也不能代理给应用类加载器,因为它是应用类加载器的祖先类加载器,已经是最顶层的类加载了,不能委托给其他类加载器。也就是说,双亲委派模型无法解决这个问题。

      线程上下文类加载器通过线程类 java.lang.Thread中的方法getContextClassLoader()setContextClassLoader(ClassLoader cl)来获取和设置线程的上下文类加载器。如果你整个应用中都没有对此作任何处理,那么 所有的线程都会以应用类加载器作为线程的上下文类加载器。
      在线程中运行的代码可以通过此类加载器来加载类和资源。

    • 用户自定义类加载器
      除了上面3个JVM提供的类加载器之外,程序员还可以自己定义类加载器。显然,用户自定义类加载器也不是一个具体的类,而是一个概念,一种类加载器的统称。
      自定义类加载器是为了实现一些特殊目标,譬如隔离加载类,修改类加载模式,扩展加载源,防止源码泄漏等。譬如Apachetomcat,阿里云Pandora框架就使用了自定义类加载器实现了隔离加载类等功能。

  • 连接

    • 验证
      字节码验证程序将验证生成的字节码是否正确,如果验证失败,我们将收到验证错误。
    • 准备
      将为所有静态变量分配内存并为其分配默认值。
    • 解决
      将所有符号内存引用替换为“方法区域”中的原始引用。
  • 初始化

    这是类加载的最后阶段;在此,所有静态变量将被分配原始值,并且将执行类的静态块(static标签包围的部分)。

类加载过程小结

类加载过程包含三个步骤:加载->连接->初始化。

加载类信息由三个存在优先级的类加载器执行,分别为提供了的三个类加载器:启动类加载器,平台/扩展 类加载器,应用程序类加载器。

除了三个存在优先级的类加载器外,还有两种特殊的类加载器,线程上下文类加载器和用户自定义类加载器。线程上下文类加载器目的是支持Java 服务扩展SPI机制,实现逆向使用类加载器,是一种概念而不是实体。用户自定义类加载器目的是实现用户自定义的特殊类加载能力。

类加载器是3+2组合,三个JVM提供的,2种特殊的。

双亲委派模型(Parent-Delegation Model)

常见八股考点

双亲委派模型Parent-Delegation Model其实是个错译,因为类加载器里并没有双亲,如父亲类或者母亲类,只有一个“单亲”,即parent亲级加载器或者说上级加载器。因此翻译成优先级委派模型或者上级委派模型比较合适,目前只能说将错就错吧

简介

每一个Java类都有一个对应它的类加载器。类加载器是通过类的全限定名(或者说绝对路径)来找到一个class文件。

  • 1.JVM在类加载时会默认使用双亲委派模型。即在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载。
  • 2.加载的时候,首先会把该请求委派给父类加载器的 loadClass() 处理,因此所有的请求最终都应该传送到顶层的启动类加载器 BootstrapClassLoader 中。
  • 3.当父类加载器无法处理时,才由自己来处理。当父类加载器为 null 时,会使用启动类加载器 BootstrapClassLoader 作为父类加载器。
  • 4.如果最底层类加载器仍然没有找到所需要的class文件,则抛出异常。(即java.lang.ClassNotFoundException错误)

优点

确保类的全局唯一性。

可以避免类的重复加载(JVM 区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类)。

如果你自己写的一个类与核心类库中的类重名,会发现这个类可以被正常编译,但永远无法被加载运行。因为你写的这个类不会被应用类加载器加载,而是被委托到顶层,被启动类加载器在核心类库中找到了。如果没有双亲委托机制来确保类的全局唯一性,谁都可以编写一个java.lang.Object类放在classpath下,那应用程序就乱套了。

从安全的角度讲,通过双亲委托机制,Java虚拟机总是先从最可信的Java核心API查找类型,可以防止不可信的类假扮被信任的类对系统造成危害。

Java9的类加载的委派关系变动

当平台以及应用程序类加载器收到类加载的请求的时候,在委派给父类加载器之前,要先判断该类是否能够归属到某一个系统模块中,如果可以找到这样的归属关系,就要优先委派给负责该模块的加载器完成加载。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-aIA6yuou-1676968052619)(link “https://upload-images.jianshu.io/upload_images/23797822-e925eb9e9a715678.png?imageMogr2/auto-orient/strip|imageView2/2/w/1062/format/webp.webp”)]

双亲委派模型小结

Java中的类加载机制是双亲委派模型,即按照AppClassLoader → SystemClassLoader → BootstrapClassLoader 的顺序,子ClassLoader将一个类加载的任务委托给父ClassLoader(父ClassLoader会再委托给父的父ClassLoader)来完成,只有父ClassLoader无法完成该类的加载时,子ClassLoader才会尝试自己去加载该类。越基础的类由越上层的ClassLoader进行加载。

双亲委派模型可以确保类的全局唯一性,保证Java程序运行的安全。

运行时数据区(Runtime Data Areas)

介绍

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-pYq2qQOp-1676968052620)(link “https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL2xlaXl1MTk5Ny9QaWNCZWRAbWFzdGVyL2Jsb2dzL3BpY3R1cmVzL2phdmElRTUlODYlODUlRTUlQUQlOTglRTclQkIlOTMlRTYlOUUlODQucG5n?x-oss-process=image/format,png”)]

运行时数据区,顾名思义,就是JVM在运行一个Java程序时存储运行时所需的各种数据的区块。
上节讲了,类加载子系统将类加载到JVM后就存储在运行时数据区,所以运行时数据区也必然存在存储类信息的区块,这一区块即方法区。其他还有存储共享变量的堆区,代码运行时存放局部变量,方法内容的栈区等等。Java虚拟机在执行过程中会将所管理的运行时数据区内存划分为不同的区域,有的随着线程产生和消失,有的随着Java进程产生和消失,根据《Java虚拟机规范》的规定,运行时数据区分为以下数个区域:

  • 程序计数器 (Program Counter Register)
  • 虚拟机栈 (JVM Stacks)
  • Java堆 (Java Heap)
  • 方法区 (Method Area)
  • 直接内存 (Direct Memory)

程序计数器 (Program Counter Register)

程序计数器,又称之为程序计数寄存器。
就是当前线程所执行的字节码的行号指示器,通过改变计数器的值,来选取下一行指令,通过他来实现跳转、循环、恢复线程等功能。
在任何时刻,一个处理器内核只能运行一个线程,多线程是通过线程轮流切换,分配时间来完成的,这就需要有一个标志来记住每个线程执行到了哪里,这里便需用到程序计数器。每个处理器内核执行指令时都会同时修改程序计数器中的下一个指令号,所以即便发生了线程切换,当前线程被休眠,此时程序计数器中记录的是休眠时所执行的指令的下一个指令计数号。等处理器内核重新唤醒此线程时,仍能通过程序计数器从线程休眠的位置继续执行。

所以,程序计数器是线程私有的,每个线程都有自己的程序计数器。
程序计数器也是运行时数据区唯一不会出现内存溢出故障的区块。 想想也能理解,存储下一条指令号所需要的内存空间是固定的,在创建每个线程时已经预先分配好的情况下自然不会出现溢出。
注意这不代表,创建新的线程时不会出现内存溢出,只不过此时发生的是虚拟机栈溢出。

程序计数器小结

程序计数器其实是个计算机组成原理中的概念,处理器从程序计数器中取指令执行并改变程序计数器使指向其下一条指令的计数号,而Java虚拟机使用其作为线程的指令计数器。在Java虚拟机中只需要记住线程私有,储存下个指令号,不会内存溢出即可。

虚拟机栈 (JVM Stacks)

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ed4H0MG6-1676968052620)(link “https://imgconvert.csdnimg.cn/aHR0cHM6Ly9jZG4uanNkZWxpdnIubmV0L2doL2xlaXl1MTk5Ny9QaWNCZWRAbWFzdGVyL2Jsb2dzL3BpY3R1cmVzLyVFOCU5OSU5QSVFNiU4QiU5RiVFNiU5QyVCQSVFNiVBMCU4OC5wbmc?x-oss-process=image/format,png”)]

虚拟机栈就是日常所说的运行时栈,虚拟机栈是线程私有的,随线程生灭。虚拟机栈描述的是线程中的方法的内存模型。说人话就是方法真正执行时的数据存储区域,方法中的各步骤如何执行,每一个指令执行后的结果储存,都是在虚拟机栈中实现。虚拟机栈是线程私有的,随线程生灭。

对于每个线程,都会创建一个当前栈。当线程中的每个方法被执行的时候,都会在虚拟机当前栈中同步创建一个栈帧(stack frame),栈帧保存的方法内容是从方法区中该方法的元数据复制而来,当方法执行结束后,该栈帧取消。这一过程可以称之为虚拟机栈的入栈和出栈。正在被线程执行的方法称为当前线程方法,而该方法的栈帧就称为当前帧,执行引擎运行时只对当前栈帧有效。

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-FmTg7Y6e-1676968052620)(link “https://img-blog.csdnimg.cn/20191211183241890.png?x-oss-process=image/watermark,type_ZmFuZ3poZW5naGVpdGk,shadow_10,text_aHR0cHM6Ly9ibG9nLmNzZG4ubmV0L3UwMTA4ODYyMTc=,size_16,color_FFFFFF,t_70”)]

每个栈帧的包含如下的内容,

  • 局部变量表
    局部变量表中存储着全部的方法本地域内的局部变量,java基本数据类型(byte/boolean/char/int/long/double/float/short)为值变量,对象变量则为引用
  • 操作数栈
    入栈、出栈、复制、交换、产生消费变量,实际实现当前线程调用过程,Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。解释执行时对方法局部变量的操作就是在操作数栈中进行
  • 动态链接
    每次运行时动态链接的符号引用
  • 方法返回地址
    顾名思义,方法退出时从此地址返回上个栈帧的位置

Java虚拟机栈就是每个线程运行时的数据存储区。编程时的方法调用类似方法套方法,A方法调用B方法,B方法再调用C方法,像一个糖葫芦一样串起来。线程执行时自然执行到方法调用位置需要进入被调用的方法,待被调用的方法执行完毕后才能继续执行原有的代码,此时明显是一个后执行的方法先出结果的结构,自然想到后进先出的栈结构。
所以线程执行到方法调用的位置,会创建一个新的入栈对象(栈帧)用来存储被调用的方法数据,然后放入线程的运行栈(当前栈)中。执行引擎执行的就是当前帧,待被调用方法(当前帧)执行完毕,栈帧出栈,被调用方法退出。

虚拟机栈可能会抛出两种异常:

  • 如果线程请求的栈深度(包括当前栈和操作数栈)大于虚拟机所规定的栈深度,则会抛出StackOverFlowError即栈溢出
  • 如果虚拟机的栈容量可以动态扩展,那么当虚拟机栈申请不到内存时会抛出OutOfMemoryError即OOM内存溢出

虚拟机栈小结

每个线程独占的运行时数据空间叫做虚拟机栈,线程中调用的一个方法执行时生成的方法空间称为一个栈帧。

一个方法执行时肯定有它的本地变量,包括入参出参,中间值等,所以栈帧有本地变量表。方法中各个变量通过运算改变,运算在计算中就是取值运算返回结果,这一过程需要在栈中进行,所以栈帧有操作数栈。

方法中许多变量所指向的方法在程序初始化时不一定存在,前面说了类是动态加载的,所以栈帧中存在很多符号引用,在运行时通过链接到方法区中的运行时常量池,查找到引用的类方法的真正内存地址

方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回地址就是记录的上层方法的返回位置

虚拟机栈会发生内存溢出、栈溢出两种异常

本地方法栈(Native Method Stacks)

本地方法栈与虚拟机栈的作用是相似的,都会抛出OutOfMemoryErrorStackOverFlowError,都是线程私有的,主要的区别在于:
虚拟机栈执行的是java方法
本地方法栈执行的是机器原生(native)方法

Java堆 (Java Heap)

Java堆内存,又称对象堆。是虚拟机管理的内存中最大的一块,也是垃圾回收最频繁的一块区域。我们程序所有的对象实例都存放在堆内存中。当然由于java虚拟机的发展,堆中也多了许多东西,不止局限于Java对象,现在主要有:

  • 对象实例

    • 类初始化生成的对象
    • 基本数据类型的数组也是对象实例
  • 字符串常量池

    • 字符串常量池原本存放于方法区,jdk7开始放置于堆中。
    • 字符串常量池存储的是String对象的直接引用,而不是直接存放的对象,是一张String Table
      1、字符串的分配,和其他的对象分配一样,耗费高昂的时间与空间代价,作为最基础的数据类型,大量频繁的创建字符串,极大程度地影响程序的性能。
      2、JVM为了提高性能和减少内存开销,在实例化字符串常量的时候进行了一些优化。
      1)为字符串开辟一个字符串常量池,类似于缓存区。
      2)创建字符串常量时,首先检查字符串常量池是否存在该字符串。
      3)存在该字符串,返回引用实例,不存在,实例化该字符串并放入池中。
  • 静态变量

    • 静态变量是有static修饰的变量,jdk7时从方法区迁移至堆中
  • 线程分配缓冲区(Thread Local Allocation Buffer)

    • 线程私有,但是不影响java堆的共性,增加线程分配缓冲区是为了提升对象分配时的效率

Java堆既可以是固定大小的,也可以是可扩展的(通过参数-Xmx和-Xms设定),如果堆无法扩展或者无法分配内存时也会报OutOfMemoryError即OOM内存溢出

Java对象堆栈小结

Java对象堆主要

方法区

方法区是网上文章比较混乱的部分,有的文章也提到了不同的实现,如永久带实现,元空间实现,一般都比较混乱,没头没尾的。比如为什么叫实现,实现了什么,怎么还有不同的实现?

具体是这样的,方法区是【Java虚拟机规范】规定的储存Java类数据信息的内存空间,但是【Java虚拟机规范】只规定了标准,并没有要求怎么做到。所以不同的虚拟机有不同的实现,不关注Java虚拟机的同学可能不清楚,但确实Java虚拟机是有很多种实现的,过去有的三大免费商用虚拟机实现:Sun HotSpot,BAE JRockit,IBM J9和收费的商用虚拟机实现Azul Zing。随着Java8版本以后OpenJDK的快速发展,又诞生了一些各式的虚拟机,例如阿里集团自用的aliJDK(龙井JDK)。所以这一节的内容是区分不同虚拟机的。

最常用的Sun/Oracle的HotSpot虚拟机的方法区在Java8版本之前,是用永久带(PermSize)这一结构实现的,放在JVM内存中,受JVM永久带大小参数的限制。

随着Oracle收购了Sun,然后又收购了BAE的JRockit虚拟机,Oracle决定在Java8版本时将JRockit和HotSpot两种虚拟机合并,而JRockit虚拟机是没有永久带的。因此在Java8的HotSpot虚拟机实现中才移除了永久带,转而用JRockit的元空间结构来实现【Java虚拟机规范】的方法区标准。这也是很多文章中说的Java7到Java8不同的方法区实现,但这仅针对HotSpot虚拟机。例如IBM/Eclipse OpenJ9虚拟机就根本没有元空间这种结构,J9虚拟机的方法区是放置于Java对象堆上的。

HotSpot虚拟机使用元空间(Meta Space)这一结构实现的方法区,直接放存储到本地内存中,不受JVM内存大小参数的限制(当然,如果物理内存被占满了或者达到JVM启动参数标定的元空间最大值,方法区也会报OOM),并且将原来放在方法区的字符串常量池和静态变量都转移到了Java堆中,方法区与其他区域不同的地方在于,方法区在编译期间和类加载完成后的内容有少许不同,不过总的来说分为这两部分:

  • 类元信息(Klass)
    类元信息在类编译期间放入方法区,里面放置了类的基本信息,包括类的版本、字段、方法、接口以及常量池表(Constant Pool Table)
  • 类文件常量池表(Constant Pool Table)存储了类在编译期间生成的字面量、符号引用,这些信息在类加载完后会被解析到运行时常量池中
    • 字面量 :Java语言中定义的常量,如使用final修饰的值
    • 符号引用 :表示JVM定义的Java关键字或基本类型与实际结构转换关系
  • 运行时常量池(Runtime Constant Pool)
    运行时常量池主要存放在类加载后被解析的字面量与符号引用,就是类被JVM加载后在JVM中的版本。
    常量池只有类文件在编译的时候才会产生,而且是存储在类文件中的。而运行时常量池是在方法区,而且可在JVM运行期间动态向运行时常量池中写入数据。

方法区中的类元信息就是类加载器加载进JVM内存的类原始数据结构,可以认为仅为文本类型的定义信息。类文件常量池表通称常量池,保存了Java类的字面量和符号引用,可以认为是保存了类和变量,类和类之前的关系。
运行时常量池才是类被JVM加载后在JVM中可用的数据,虚拟机栈调用的类实际上是从运行时常量池中加载的。运行时常量池中的类数据已经分配了字面量,符号引用已经替换为类在内存中的真实地址,这时一个类才能被执行。

方法区会产生内存溢出OOM异常,在HotSpot虚拟机中当元空间加载的类过多时如果无法再分配内存空间就会溢出。

方法区小结

针对HotSpot虚拟机,方法区的实现由Java8版本之前的永久带(PermSize)转为Java8及之后的元空间实现(Meta Space)。元空间包括类元信息,类文件常量池表,运行时常量池三个部分。方法区会产生内存溢出。

直接内存

直接内存位于本地内存,不属于JVM内存,又称之为堆外内存(当然看了上面内容后可知对堆外内存这个名称也并不准确)。直接内存就是操作系统的原生内存(native堆),使用直接内存的原因就是为了提升性能,因为减少了JVM堆内存IO操作时从操作系统空间复制(native堆)到内存空间(JVM堆)的IO损耗。

可以使用Java内部库Unsafe来操作直接内存,此时直接内存的使用比较类似C语言,C++加中的本地内存分配,需要程序开发者管理内存的分配和销毁。当然这样做也非常危险,指针越界可不是闹着玩的。

在Java4(JDK1.4)中加入了NIO(New Input/Output)类,它可以使用native函数直接分配堆外内存,然后通过存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,这样可以在一些场景下大大提高IO性能,避免了在java堆和native堆来回复制数据。

如Netty等一些框架就使用了通过DirectByteBuffer对象操作的存放在操作系统原生内存(native堆)的直接内存。通过NIO相关类管理的直接内存对象可以被JVM执行垃圾回收,显然比Unsafe操作来的安全的多,但是需要注意的是直接内存仅能在Full GC时被回收。譬如Netty框架就在使用后直接内存后显式调用System.gc()方法以促进Java虚拟机执行Full GC,如果此时虚拟机通过参数配置了禁用显式调用System.gc()则很有可能出现内存溢出异常。

直接内存小结

直接内存位于本地内存,不属于JVM内存,在物理内存耗尽的时候报OutOfMemoryError即OOM内存溢出异常

执行引擎 (Execution Engine)

介绍

分配给运行时数据区的字节码将由执行引擎执行。执行引擎读取字节码并逐段执行。

类加载系统负责装载字节码到Java虚拟机内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被Java虚拟机所识别的字节码指令、符号表,以及其他的辅助信息。

那么,如果想让一个 java 程序运行起来,执行引擎(Execution Engine) 的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM 中的执行引擎充当了将高级语言翻译为机器语言的译者。

执行引擎所执行的字节码指令完全由执行时线程的程序计数器决定。每当执行一项指令操作以后,程序计数器就会更新下一条需要被执行的指令地址。

当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在 Java 堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。

从外观上来看,所有的 Java 虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解释执行的等效过程,输出的是执行结果。

执行引擎的结构

  • 解释器(Interpreter)

    • 解释器解释字节码的速度较快,但执行速度较慢。解释器的缺点是,当多次调用一种方法时,每次都需要新的解释。
  • 即时编译器(Just In Time Compiler)

    • 即时编译器,即JIT编译器,它消除了解释器的缺点。执行引擎将使用解释器的帮助来转换字节码,但是当发现重复的代码时,它将使用JIT编译器,该编译器将编译整个字节码并将其更改为本地代码。此本地代码将直接用于重复的方法调用,从而提高系统的性能。
  • 中间代码生成器

    • 产生中间代码代码优化器 –负责优化上面生成的中间代码目标代码生成器 –负责生成机器代码或本机代码。
  • 分析器(Profiler)

    • 一个特殊的组件,负责查找热点,即是否多次调用该方法。确定为热点的方法,将被中间代码生成器,即时编译器编译为机器码,提高执行效率。
  • 垃圾收集器(Garbage Collerctor)

    • 收集并删除未引用的对象。Java虚拟机中自动进行的内存垃圾回收,就是由垃圾收集器(GC)自动管理,垃圾回收可以通过调用触发System.gc(),但不能保证执行。
  • Java本地接口(JNI)

    • JNI将与本地方法库进行交互,并提供执行引擎所需的本机库。
  • 本地方法库

    • 这是本地库的集合,这是执行引擎所需的。

解释器(Interpreter)

解释器在运行时采用逐行解释字节码执行程序,解释器真正意义上所承担的角色就是一个运行时“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。
当一条字节码指令被解释执行完成后,接着在根据程序计数器中记录的下一条需要被执行的字节码指令执行解释操作。
在 Java 的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。
字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。
而模版解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。在 HotSpot VM 中,解释器主要由解释执行(Interpreter) 模块和 本地指令缓存(Code) 模块组成。

  • 解释执行(Interpreter) 模块:实现了解释器的核心功能
  • 本地指令缓存(Code) 模块 : 用于管理 HotSpot VM 在运行时生成的本地机器指令。

由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如 Python 、Perl 、Ruby 等。这些被解释执行到语言也叫解释型语言,经常会听到有人说Java是半解释半编译型语言,那半解释就是指Java虚拟机使用执行引擎的解释器解释执行Java代码。解释器执行的性能较低,所以针对某些热点的代码段,Java虚拟机使用一种叫做即时编译的技术将整个热点的代码段编译成为机器码,可以大幅提升此段代码的执行性能。

即时编译器(Just In Time Compiler)

中间代码生成器

分析器(Profiler)

垃圾收集器(Garbage Collerctor)

执行引擎的工作过程

方法调用

简明总结


参考文献

1.大白话带你认识 JVM

2.这可能是最清晰易懂的 G1 GC 资料

3.Java 基础:JVM虚拟机结构

Zen Space