欢迎访问shiker.tech

请允许在我们的网站上展示广告

您似乎使用了广告拦截器,请关闭广告拦截器。我们的网站依靠广告获取资金。

JVM如何工作的?
(last modified Sep 16, 2023, 6:10 PM )
by
侧边栏壁纸
  • 累计撰写 176 篇文章
  • 累计创建 61 个标签
  • 累计收到 4 条评论

目 录CONTENT

文章目录

JVM如何工作的?

橙序员
2023-09-03 / 0 评论 / 0 点赞 / 213 阅读 / 8,343 字 / 正在检测百度是否收录... 正在检测必应是否收录...
文章摘要(AI生成)

Java编译器将Java源代码编译为字节码,类加载器将字节码加载到内存中并创建类的定义,解释器逐条解释执行字节码指令,即时编译器将字节码编译成本地机器码。

概述

一句话总结:JVM作为一个可以让java语言“一次编译,到处运行”的基石,首先通过javac编译器将我们编写的java文件编译为统一的字节码文件,然后这些字节码文件由类加载器由磁盘加载到内存中,加载完成后运行代码统一存储在方法区,之后再由java解释器/JIT编译器执行我们的字节码文件,通过运行时数据区来存储我们程序运行时创建的java线程,并为程序创建的对象分配内存。执行期间产生的废弃对象由垃圾收集器进行垃圾回收。其示意图简化如下:

image-20230903173639283

Q: java编译器、加载器、解释器和即时编译器的职责各是什么?

A: Java编译器、类加载器、解释器和即时编译器是Java虚拟机(JVM)中的关键组件,各自具有不同的职责和功能。以下是它们的主要职责:

1. Java编译器:

  • 职责: Java编译器负责将高级Java源代码(以.java文件扩展名结尾)编译成Java字节码(以.class文件扩展名结尾),这是一种平台无关的中间表示形式。
  • 工作流程: Java编译器首先对源代码进行语法和语义分析,以确保代码的正确性。然后,它将源代码翻译成等效的字节码表示形式。在这个过程中,编译器还会进行类型检查,以确保类型安全性。
  • 输出: Java编译器的输出是一个或多个字节码文件(.class文件),其中包含编译后的字节码以及与类和接口相关的信息。这些文件可以在JVM上运行。

2. 类加载器(Class Loader):

  • 职责: 类加载器负责将字节码文件加载到内存中,并在运行时创建Java类的定义和结构。
  • 工作流程: 类加载器将字节码文件从磁盘或网络加载到内存中,并解析字节码以创建类的定义。加载器还负责解决类的依赖关系,并确保不会加载同一个类的多个副本。
  • 特点: Java使用双亲委派模型,其中有多个类加载器层次结构,每个类加载器负责加载不同的类。这有助于隔离类,确保类的唯一性和安全性。

3. 解释器:

  • 职责: 解释器负责执行Java字节码,逐条解释并执行字节码指令。
  • 工作流程: 解释器将字节码指令逐一解释执行,将其翻译成本地机器码或执行相应的操作。这个过程是逐步的,通常相对较慢,因为需要在运行时解释每个指令。
  • 特点: 解释器具有跨平台性,因为它可以在不同操作系统上执行相同的字节码,但通常执行速度较慢,因为不会将字节码编译成本地机器码。

4. 即时编译器(JIT Compiler):

  • 职责: 即时编译器负责将Java字节码编译成本地机器码,以提高程序的执行速度。
  • 工作流程: JIT编译器会监视正在执行的代码,并将频繁执行的热点代码块即时编译成本地机器码。这个过程发生在程序运行时。
  • 特点: JIT编译器的主要优势在于它能够将热点代码优化为本地机器码,从而提高程序的性能。由于它只编译实际执行的代码块,因此能够更好地适应不同的工作负载。

这些组件在Java程序的执行过程中协同工作,负责编译、加载、解释和优化代码,以确保程序能够正确运行并获得最佳性能。这是Java虚拟机的核心功能之一,使Java成为一种跨平台的编程语言。

编译产生的class文件

一个class文件通常由魔数、版本号、常量池、索引集合、字段表集合、方法表集合、属性表集合组成。img

其中魔数为java class文件的统一标识。版本号为javac编译器的编译标识,用来判断是否可以由当前版本jdk进行加载执行。

常量池中包含该类文件中的字面量和符号引用。字面量包括文本字符串、final修饰的常量值、数字和字面值等。符号引用包含类和接口引用、字段引用、方法引用等。

索引集合包含了该类的访问标志、类索引、父类索引、接口索引,接口索引由接口计数器和接口信息数据区构成(单继承多实现的来源)。类加载器和垃圾回收机制通过索引集合确定该类是否被加载或是否可回收

字段表负责存储类中定义的成员变量信息。包含字段的访问修饰符、名称、描述符和属性列表

方法表用于存储类中定义的方法的信息,包括类的构造方法、成员方法等。方法表中的每个方法项包括方法的名称、描述符以及字节码指令。字节码指令部分存储在方法项的Code属性下,它告诉Java虚拟机(JVM)如何执行方法。方法表中的的属性表用于存储与类、字段或方法相关的附加信息,这些信息通常是由编译器、工具或注解生成的。字节码指令通常存放在类文件的方法表(Method Table)中,而不是属性表(Attribute Table)。方法表包括了类中所有方法的信息,其中就包括了方法的字节码指令,这些指令描述了方法的实际代码实现。

综上,一个java类在编译后产生的类文件其实是由包含了字面量和引用的常量池、用于标识类路径的索引、存储成员变量的字段表、以及存储方法以及方法字节码指令的方法表和存储成员变量的属性表构成的。

类文件的加载

类的加载是将存储在磁盘中的类文件加载到运行时数据区中,而加载主要由类加载器实现,由加载、验证、准备、解析、初始化这几步构成。

加载:在加载的过程中,JVM主要做3件事情

  • 通过一个类的全限定名来获取定义此类的二进制字节流(class文件) 在程序运行过程中,当要访问一个类时,若发现这个类尚未被加载,并满足类初始化的条件时,就根据要被初始化的这个类的全限定名找到该类的二进制字节流,开始加载过程
  • 将这个字节流的静态存储结构转化为方法区的运行时数据结构
  • 在内存中创建一个该类的java.lang.Class对象,作为方法区该类的各种数据的访问入口

验证:保证二进制字节流中的信息符合虚拟机规范,并没有安全问题。

准备:准备阶段主要完成两件事情:

  • 为已在方法区中的类的静态成员变量分配内存
  • 为静态成员变量设置初始值,初始值为0、false、null等

解析:解析是虚拟机将常量池的符号引用替换为直接引用的过程。

初始化:调用类初始化方法的过程,完成对static修饰的类变量的手动赋值还有主动调用静态代码块。

负责加载的加载器

双亲委派模型:JVM通过双亲委派模型进行类的加载,当然我们也可以通过继承java.lang.ClassLoader实现自定义的类加载器。这样可以避免重复加载,当父亲已经加载了该类的时候,就没有必要子ClassLoader再加载一次。JVM在判定两个class是否相同时,不仅要判断两个类名是否相同,而且要判断是否由同一个类加载器实例加载的。 只有两者同时满足的情况下,JVM才认为这两个class是相同的。就算两个class是同一份class字节 码,如果被两个不同的ClassLoader实例所加载,JVM也会认为它们是两个不同class。

破坏双亲委派模型:因为在某些情况下父类加载器需要加载的class文件由于受到加载范围的限制,父类加载器无法加载到需要的文件,这个时候就需要委托子类加载器进行加载。

初始化的时机

有且只有四种情况必须先对类进行“初始化”:

  • 遇到 new getstatic putstaticinvokestatic 这四条指令时,如果对应的类没有初始化,则要对对应的类先进行初始化。这四个指令对应到我们java代码中的场景分别是:
    • new关键字实例化对象的时候;
    • 读取或设置一个类的静态字段(读取被final修饰,已在编译器把结果放入常量池的静态字段除外);
    • 调用类的静态方法时。
  • 使用java.lang.reflect包方法时对类进行反射调用的时候。
  • 初始化一个类的时候发现其父类还没初始化,要先初始化其父类。
  • 当虚拟机开始启动时,用户需要指定一个主类,虚拟机会先执行这个主类的初始化。

方法区和元空间

用于存储类相关的信息。存储了类文件的以下信息:

  1. 类的结构信息: 包括类的字段、方法、构造函数等。这些信息包括字段名称、类型、访问修饰符、方法的名称、参数列表、返回类型等。
  2. **类的运行时常量池:**存储了类中的常量(如字符串、数字、符号引用等)。运行时常量池用于支持类的运行时动态解析和常量池中的符号引用转换为直接引用。
  3. 静态字段: 静态字段的值。这些字段是与类本身相关联的,而不是与类的实例相关联的。
  4. 方法的字节码指令: 存储了每个方法的字节码指令,这些指令告诉JVM如何执行方法的操作。字节码是类文件中方法的实际代码实现。
  5. 类的构造方法: 存储了类的构造方法的字节码和相关信息,包括构造方法的访问修饰符、参数列表等。
  6. 异常处理器表: 存储了与方法关联的异常处理器表,用于处理方法中可能抛出的异常。
  7. 类的注解和注解参数: 包含了与类、字段、方法相关的注解信息以及注解参数的定义。
  8. 类的访问标志和访问控制信息: 存储了类的访问标志和相关的访问控制信息,这些信息指定了类的可见性和访问权限。

Q: 元空间和方法区的区别是什么

A: 元空间(Metaspace)和方法区(Method Area)都用于存储类的元信息,但它们有一些重要的区别:

  1. 内存管理方式:
    • 方法区: 方法区是Java虚拟机(JVM)的一部分,它通常位于堆内存之内,而且其内存大小受到堆内存大小的限制。垃圾收集器可以对方法区进行垃圾回收,但通常只回收不再使用的类和常量池项。
    • 元空间: 元空间是Java 8及更高版本引入的概念,它不再受到堆内存大小的限制。元空间通常使用本地内存(Native Memory)来存储类的元信息。元空间的内存管理是由操作系统来管理的,不再需要手动调整方法区的大小,也不容易发生内存溢出。
  2. 动态性:
    • 方法区: 方法区的大小在JVM启动时就被固定下来,并且通常需要手动调整大小以满足应用程序的需求。方法区不支持动态扩展。
    • 元空间: 元空间的大小可以根据应用程序的需要动态扩展,不再受到固定大小的限制。这使得元空间更加灵活,能够适应不同规模的应用程序。
  3. 垃圾回收:
    • 方法区: 方法区的垃圾回收通常只涉及类加载器的回收和一些无用的类的卸载,不太频繁。垃圾回收器在方法区执行的频率相对较低。
    • 元空间: 由于元空间使用了本地内存,垃圾回收不再是一个主要问题。不再需要手动进行永久代(方法区的前身)的调优或避免内存溢出问题。

总之,元空间和方法区都用于存储类的元信息,但元空间在内存管理、动态性和垃圾回收方面具有更多的优势和灵活性。因此,Java 8及更高版本的JVM推荐使用元空间来代替传统的方法区,以提高类加载和内存管理的效率。

Q: class常量池(静态常量池)、运行时常量池、字符串常量池区别是什么?

A: class常量池中存储的是符号引用,而运行时常量池存储的是被解析之后的直接引用。

class常量池存在于class文件中,运行时常量池和字符串常量池是存在于JVM内存中。

运行时常量池具有动态性,java运行期间也可能将新的常量放入池中(String#intern())

字符串常量池逻辑上属于运行时常量池的一部分,但是它和运行时常量池的区别在于,字符串常量池是全局唯一的,而运行时常量池是每个类一个。

JIT编译器

在部分商用虚拟机中(如HotSpot),Java程序最初是通过解释器(Interpreter)进行解释执行的, 当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器

运行过程中会被即时编译器编译的“热点代码”有两类:

  1. 被多次调用的方法。
  2. 被多次执行的循环体。

当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。

当程序运行环境中内存资源限制较大(如部分嵌入式系统中),可以使用解释器执行节约内存, 反之可以使用编译执行来提升效率。

HotSpot虚拟机中内置了两个即时编译器:Client Complier和Server Complier,简称为C1、C2编译器,分别用在客户端和服务端。用Client Complier获取更高的编译速度,用Server Complier 来获取更好的编译质量。

JIT优化手段

公共子表达式的消除:如果一个表达式E已经 计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共 子表达式。对于这种表达式,没有必要花时间再对他进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。

方法内联:将方法调用直接使用方法体中的代码进行替换,这就是方法内联,减少了方法调用过程中压栈与入栈的开销。

逃逸分析:通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围(是否被外部方法所引用)从而决定是否要将这个对象分配到堆上。如果未逃逸则直接进行栈上分配,如果逃逸则进行堆上分配。

对象的栈上内存分配:JIT编译器可以在编译期间根据逃逸分析的结果,来决定是否可以将对象的内存分配从堆转化为栈。

标量替换:经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。

同步锁消除:基于逃逸分析,当加锁的变量不会发生逃逸,是线程私有的完全没有必要加锁。

运行时数据区

运行时数据区通常包括以下几个区域:

  1. 堆内存(Heap): 堆内存用于存储程序运行时创建的对象实例和数组。这是最常用的运行时数据区,它包含了大多数程序数据。
  2. 栈内存(Stack): 栈内存用于存储线程的方法调用和局部变量。每个线程都有自己的栈,用于跟踪方法的调用和控制流程。
  3. 本地方法栈(Native Method Stack): 本地方法栈用于存储调用本地方法(即由本地编程语言编写的方法)的信息。本地方法栈类似于栈内存,但用于本地方法调用。
  4. 程序计数器(Program Counter): 程序计数器用于跟踪当前线程执行的字节码指令地址。每个线程都有自己的程序计数器。
  5. 虚拟机栈(Virtual Machine Stack): 虚拟机栈也用于存储方法调用和局部变量,但它与栈内存不同。虚拟机栈是在方法调用时创建的,用于存储方法的局部变量和操作数栈。它的生命周期与方法调用相对应。

堆内存的分配

分配原则

优先在 Eden 分配,如果 Eden 空间不足虚拟机则会进行一次 MinorGC

大对象直接接入老年代,大对象一般指的是很长的字符串或数组

长期存活的对象进入老年代,每个对象都有一个age,当age到达设定的年龄的时候就会进 入老年代,默认是15岁。

分配方式:指针碰撞和空闲列表。指针碰撞用于内存地址连续的场景,而空闲列表用于内存地址不连续的场景。

栈内存的分配

栈内存为线程私有的空间,每个线程都会创建私有的栈内存。栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

垃圾回收

何时回收

Java的垃圾回收是由Java虚拟机(JVM)自动管理的过程,它会在以下情况下自动进行垃圾回收:

  1. 堆内存不足: 当堆内存中的空间不足以分配新对象时,JVM会触发垃圾回收。这通常是由于新生代或老年代的内存使用达到了某个阈值触发的。不同的垃圾收集器和JVM实现可能有不同的触发条件和策略。
  2. 程序显式调用System.gc()Runtime.getRuntime().gc() 虽然这不是强制要求,但程序可以显式调用这些方法来请求JVM进行垃圾回收。JVM可以选择是否立即响应此请求。
  3. 对象的可达性分析: JVM使用可达性分析算法来确定哪些对象不再被程序引用,然后回收这些不再被引用的对象。垃圾回收会周期性地执行可达性分析来回收不再被引用的对象。通常,JVM会选择在堆内存占用较低的时候执行垃圾回收,以减少性能影响。
  4. 空间分配担保: 在新生代的垃圾回收中,如果发现新生代没有足够的空间分配新对象,JVM会执行一次垃圾回收,以确保有足够的空间来分配新对象。这通常涉及将存活的对象移动到老年代。
  5. Full GC: Full GC(Full Garbage Collection)是一种特殊情况下的垃圾回收,它会清除整个堆内存,包括新生代和老年代。Full GC通常在老年代内存不足、永久代(或元空间)内存不足等情况下触发。

是否可回收-垃圾回收算法

判断一个对象是否可以回收通常依赖于一种称为"垃圾回收算法"的机制。不同的垃圾回收算法可能有不同的策略来确定对象是否可以被回收。通常情况下,垃圾收集器会使用以下一种或多种策略来判断对象的可达性和是否需要回收:

  1. 引用计数法(Reference Counting): 引用计数法是一种简单的垃圾回收算法,它为每个对象维护一个引用计数器,计数器记录了对象被引用的次数。当引用计数器降为零时,表示对象不再被任何引用指向,可以被回收。这种方法简单直观,但容易出现循环引用的问题,即两个或多个对象互相引用,导致它们的引用计数永远不会降为零。
  2. 可达性分析法(Reachability Analysis): 大多数现代垃圾回收器使用可达性分析法来判断对象是否可以回收。它从一组根对象开始,根对象是程序中已知的不会被回收的对象,例如虚拟机栈、本地方法栈、方法区中的静态变量等。然后,通过遍历对象之间的引用关系,找到所有可达的对象。如果一个对象不可达,即没有从根对象出发的引用链能够到达该对象,那么这个对象就被认为是不可达的,可以被回收。
  3. 分代回收策略: 许多垃圾回收器使用分代回收策略,将堆内存划分为不同的代,如新生代和老年代。根据对象的存活时间,将对象分配到不同的代中。一般情况下,新创建的对象会被分配到新生代,而经过多次垃圾回收仍然存活的对象会被提升到老年代。不同代使用不同的回收算法,新生代通常使用复制算法,老年代使用标记-清除算法。这种策略可以提高垃圾回收的效率。
  4. 引用类型: Java语言中有不同的引用类型,包括强引用、软引用、弱引用和虚引用。不同的引用类型对于垃圾收集的行为有不同的影响。例如,强引用表示对象是强可达的,只有当强引用不存在时,对象才会被回收。而软引用、弱引用和虚引用表示对象的可达性较弱,可能会在内存不足时被回收。

总之,垃圾收集器根据不同的算法和策略来判断对象是否可以回收,主要依赖于可达性分析引用类型。如果对象不再被任何引用指向,或者对象的引用链中不再与根对象相连,那么这个对象就被判定为不可达,可以被垃圾收集器回收释放内存

如何回收-垃圾收集算法

垃圾收集器回收一个对象通常经过以下步骤:

  1. 标记(Marking): 垃圾收集器首先从一组根对象开始,这些根对象通常是程序中不会被回收的对象,如虚拟机栈、本地方法栈、方法区中的静态变量等。然后,垃圾收集器通过可达性分析算法,遍历对象之间的引用关系,找到所有可达的对象。可达的对象被标记为存活对象,而未标记的对象则被认为是不可达的。
  2. 清除(Sweeping): 在标记阶段之后,垃圾收集器会对堆内存中的所有对象进行扫描,清除那些未标记为存活对象的对象。这些未标记的对象被认为是垃圾,可以被回收。清除操作会释放这些对象所占用的内存空间。
  3. 压缩(Compacting,可选): 在一些垃圾回收算法中,特别是针对堆内存的分代回收策略,还可能包括内存压缩的步骤。在内存压缩过程中,存活的对象被重新排列,以便堆内存中的空闲空间连续化。这有助于减少内存碎片,提高堆内存的空间利用率。
  4. 内存回收(Memory Reclamation): 垃圾收集器在清除垃圾对象后,将释放的内存空间返还给堆内存,使其可供程序再次使用。

而垃圾收集算法是用于识别和回收不再被程序引用的垃圾对象的方法和策略。不同的垃圾收集算法适用于不同的应用场景和性能需求。以下是一些常见的垃圾收集算法:

  1. 标记-清除算法(Mark and Sweep): 这是最基本的垃圾收集算法之一。它分为两个阶段:标记阶段和清除阶段。在标记阶段,垃圾收集器标记所有可达的对象。在清除阶段,垃圾收集器清除未被标记的对象。这个算法的主要问题是会产生内存碎片,影响内存分配的效率。
  2. 复制算法(Copying): 这个算法将堆内存分为两个区域:一个用于存储活动对象,另一个用于存储垃圾对象。在垃圾收集过程中,活动对象被复制到一个新的区域,垃圾对象被丢弃。这个算法避免了内存碎片,但需要额外的内存来存储复制后的对象。
  3. 标记-整理算法(Mark and Compact): 这个算法结合了标记-清除和内存压缩的思想。在标记阶段,垃圾收集器标记所有可达的对象。然后,在整理阶段,它将存活对象移动到堆内存的一端,以便空闲空间连续化,从而减少内存碎片。
  4. 分代回收算法(Generational Garbage Collection): 这个算法将堆内存分为不同的代,通常包括新生代和老年代。新生代存储年轻对象,而老年代存储存活时间较长的对象。垃圾收集器针对不同代使用不同的算法和频率,通常使用复制算法来回收新生代,而使用标记-整理或标记-清除算法来回收老年代。
  5. 引用计数算法(Reference Counting): 这个算法为每个对象维护一个引用计数器,记录对象被引用的次数。当引用计数降为零时,对象被认为是不再被引用的垃圾对象。这个算法容易实现,但容易出现循环引用问题。
  6. 并发垃圾收集算法(Concurrent Garbage Collection): 这些算法允许垃圾收集与应用程序线程并发执行,以减小垃圾收集对应用程序性能的影响。常见的并发垃圾收集算法包括CMS(Concurrent Mark-Sweep)、G1(Garbage-First)等。
  7. 分布式垃圾收集算法: 这些算法用于分布式系统中,用于管理多个节点上的垃圾收集工作。分布式系统中的垃圾收集需要解决更复杂的一致性和协调问题。

垃圾收集器

  1. Serial收集器: Serial垃圾收集器是最基本的垃圾收集器,它是单线程的,通常用于单线程的应用程序或小型应用程序。它使用复制算法来进行新生代的垃圾回收。
  2. Parallel收集器: Parallel垃圾收集器,也称为多线程垃圾收集器,用于多核处理器上的应用程序。它使用复制算法来进行新生代的垃圾回收,使用标记-清除算法进行老年代的垃圾回收。
  3. CMS收集器(Concurrent Mark-Sweep): CMS垃圾收集器是一种并发垃圾收集器,用于需要低停顿时间的应用程序。它在老年代使用标记-清除算法,尽量减少停顿时间。
  4. G1收集器(Garbage-First): G1垃圾收集器是一种面向大堆内存和低停顿时间的垃圾收集器。它将堆内存划分为多个小块,使用标记-整理算法进行垃圾回收。
  5. ZGC(Z Garbage Collector): ZGC是一种低延迟的垃圾收集器,专为需要极低停顿时间的大型应用程序设计。它使用可并发标记和整理算法。
  6. Shenandoah GC: Shenandoah GC是另一种低停顿时间的垃圾收集器,旨在减少停顿时间并最大程度地并发执行垃圾回收。
  7. Epsilon GC: Epsilon是一种实验性的垃圾收集器,它的目标是在不进行垃圾回收的情况下运行应用程序,通常用于性能测试和特殊用途。
  8. Zing JVM的C4 GC: Zing JVM的C4 GC是一种商业垃圾收集器,专注于极低停顿时间和高吞吐量。它适用于大规模、高性能的Java应用程序。
0

评论区