深入浅出:JVM 的架构与运行机制

一、什么是JVM

1、什么是JDK、JRE、JVM

  • JDK是 Java语言的软件开发工具包,也是整个java开发的核心,它包含了JRE和开发工具包
  • JRE,Java运行环境,包含了JVM和Java的核心类库(Java API)
  • JVM,Java虚拟机,它是运行在操作系统之上的,它与硬件没有直接的交互

2、说的通俗点,JVM到底是什么?

JVM 是一个虚拟的计算机,但它并不是真正的物理机器,而是在你的电脑上运行的一个软件。它的主要任务是执行 Java 程序。你可以把它想象成一个翻译官,负责把 Java 程序的代码翻译成你的电脑能够理解并执行的指令。

Java 语言有一个著名的口号:"一次编写,到处运行"。所谓"一次编码,随处运行"正是基于不同系统下的jvm帮你掩盖了系统之间接口的差异

jdk是开发人员的工具包,它包含了java的运行环境和虚拟机,而一次编写到处运行就是基于jvm

3、总结

JVM就是一套软件,不管在什么平台上都可以安装,安装好之后,就可以运行我们的Java程序,并且是在任意平台上都可以,平台之间的接口差异那都是JVM去做的,无需我们关心。

二、JVM整体架构

1、Java程序如何被运行的

1.源码编译:通过Java源码编译器将Java代码编译成JVM字节码(.class文件)

2.类加载:通过ClassLoader及其子类来完成JVM的类加载

3.类执行:字节码被装入内存,进入JVM虚拟机,被解释器解释执行

2、JVM模型

由上面的图可以看出,JVM虚拟机中主要是由三部分构成,分别是类加载子系统、运行时数据区、执行引擎

类加载子系统

JVM把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被JVM直接使用的Java类型。

运行时数据区

JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。

这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁。

执行引擎

执行引擎用于执行JVM字节码指令,主要有两种方式,分别是解释执行和编译执行,区别在于,解释执行是在执行时翻译成虚拟机指令执行,而编译执行是在执行之前先进行编译再执行。

解释执行启动快,执行效率低。编译执行,启动慢,执行效率高。

垃圾回收器就是自动管理运行数据区的内存,将无用的内存占用进行清除,释放内存资源。

本地方法库、本地库接口

在jdk的底层中,有一些实现是需要调用本地方法完成的(使用c或c++写的方法),就是通过本地库接口调用完成的。比如:System.currentTimeMillis()方法。、

三、class到底长什么样子?

这是我们的测试案例

java 复制代码
/*
* 基本类结构
* */
public class ClassStruct {

    private static String name = "JVM";


    private static final int age = 18;

    public static void main(String[] args) {
        System.out.println("Hello " + name);
    }

}

. java文件编译之后,就会产生一个.class文件

我们将上面这个编译后的.class文件打开,就长下面这样

class文件是一个二进制文件,转化后是16进制展示,实际上class文件就是一张表,它由以下数据项构成,这些数据项从头到尾严格按照以下顺序排列:

下面我们对这些数据项逐一介绍

魔数

固定的"CAFEBABE",巧记"咖啡宝宝"

版本号

34,换成10进制就是52

jdk的版本标记映射关系:

可以看到就是采用的jdk8进行编译的

常量池

常量池记录了jvm内的一堆常量信息,这部分由 【2个字节的常量池计数器】 + 【n个cp_info结构】组成

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。 字面量比较接近于 Java 语言层面的常量概念,如文本字符串、声明为 final 的常量值等。 而符号引用则属于编译原理方面的概念,包括了下面三类常量: 类和接口的全限定名(Fully Qualified Name)、字段的名称和描述符(Descriptor)、方法的名称和描述符

常量池计数器

标注后面有多少个,对应个数的cp_info

CP_INFO

cp_info有多种类型:

  • 直接类型,存的就是当前值,这种像Integer,Long等长度都是确定的
  • 引用类型,存的是指向其他位置的指针

我们可以看看真实的CP_INFO条目的内容

bash 复制代码
javap -v ClassStruct.class

Utf8对应的就是CONSTANT_Utf8_INFO, String对应的就是CONSTANT_String_INFO

其他信息

常量池之后,是紧挨的一系列信息,这些信息大同小异,无非就是值、或者引用

  • 访问标记:public abstract 等信息
  • 类索引,class类型,最终指向一个utf8,标记当前类的名字
  • 父类,同上
  • 接口,2字节记录数量,后面记录多个接口类型
  • 接下来是字段、方法、属性,都是2字节记录后面多少个,后面紧跟对应的结构体类型

四、运行时数据区深度剖析

字节码只是一个二进制文件存放在那里。要想在jvm里跑起来,先得有个运行的内存环境。

也就是我们所说的jvm运行时数据区。

1、运行时数据区的内存分布

运行时数据区是jvm中最为重要的部分,执行引擎频繁操作的就是它。类的初始化,以及后面我们讲的对象空间的分配、垃圾的回收都是在这块区域发生的。

根据《Java虚拟机规范》中的规定,在运行时数据区将内存细分为几个部分

线程私有的:Java虚拟机栈(Java Virtual Machine Stack)、程序计数器(Program Counter Register)、本地方法栈(Native Method Stacks)

大家共享的:方法区(Method Area)、Java堆区(Java Heap)

接下来我们分块详细来解读,每一块是做什么的,如果溢出了会发生什么事情

2、程序计数器

程序计数器

  • 每个线程一个。是一块较小的内存空间,它表示当前线程执行的字节码指令的地址。
  • 字节码解释器工作时,通过改变这个计数器的值来选取下一条需要执行的字节码指令,所以整个程序无论是分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 由于线程是多条并行执行的,互相之间执行到哪条指令是不一样的,所以每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为"线程私有"的内存。
  • 如果是native方法,这里为空

总结:程序计数器就是记录着当前线程的所有指令,以及当前执行到哪个指令了,下一步该执行哪个指令

在虚拟机规范中,没有对这块区域设定内存溢出规范,也是唯一一个不会溢出的区域

因为它不会溢出,所以我们没有办法给它造一个,但是从class类上可以找到痕迹。

回顾上面javap的反汇编,其中code所对应的编号就可以理解为计数器中所记录的执行编号。

3、虚拟机栈

JVM栈呢,包含许许多多的栈帧,每个栈帧包含四个部分:局部变量、操作数栈、动态链接、方法出口。

  1. 线程私有:每个线程都有自己的 JVM 栈,线程之间不能共享栈中的数据。
  2. 生命周期:JVM 栈的生命周期与线程相同,线程启动时创建,线程结束时销毁。
  3. 栈帧:每个方法调用都会创建一个新的栈帧,方法执行完毕后,对应的栈帧会被弹出栈。

JVM栈呢通常会产生两种异常

  1. StackOverflowError

    • 当线程请求的栈深度大于 JVM 所允许的最大深度时,抛出 StackOverflowError
    • 常见原因包括递归调用过深、线程栈大小设置不当等。
  2. OutOfMemoryError

    • 当线程栈所需的内存超过 JVM 堆内存限制时,抛出 OutOfMemoryError
    • 常见原因包括线程数量过多、单个线程栈大小过大等。

4、本地方法栈

  • 本地方法栈的功能和特点类似于虚拟机栈,均具有线程隔离的特点
  • 不同的是,本地方法栈服务的对象是JVM执行的native方法,而虚拟机栈服务的是JVM执行的java方法
  • 虚拟机规范里对这块所用的语言、数据结构、没有强制规定,虚拟机可以自由实现它
  • 甚至,hotspot把它和虚拟机栈合并成了1个

和虚拟机栈一样,也是两个:

如果是创建的栈的深度大于虚拟机允许的深度,抛出 StackOverFlowError

内存申请不够的时候,抛出 OutOfMemoryError

5、堆区

与上面的3个不同,堆是所有线程共享的!所谓的线程安全不安全也是出自这里。

在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里"几乎"所有的对象实例都在这里分配内存。

Java堆是垃圾收集器管理的内存区域,因此它也被称作"GC堆",这就是我们做JVM调优的重点区域部分。

堆区我们要分两个版本介绍,jdk1.7和1.8版本的略有不同

1.7版本的堆区

  • Young 年轻区(代)

    Young区被划分为三部分,Eden区和两个大小严格相同的Survivor区

    其中,Survivor 区分为两个部分,通常称为 S0 和 S1。每次GC 后,Eden 区中存活的对象会被移动到其中一个 Survivor 区,而另一个 Survivor 区则被清空。两个 Survivor 区轮流使用。

    在Eden区间变满的时候, GC就会将存活的对象移到空闲的Survivor区间中,根据JVM的策略,在经过几次(有一个阈值,默认15)垃圾收集后,仍然存活于Survivor的对象将被移动到下面的Tenured区间。

  • Tenured 年老区

    Tenured区主要保存生命周期长的对象,一般是一些老的对象,当一些对象在Young复制转移一定的次数以后,对象就会被转移到Tenured区,一般如果系统中用了application级别的缓存,缓存中的对象往往会被转移到这一区间。

  • Perm 永久区

    现在已经成为历史,Perm代主要保存类信息,class,method,filed等对象,这部份的空间一般不会溢出,除非一次性加载了很多的类

1.8版本的堆区

jdk1.8的内存模型是由2部分组成,年轻代 + 年老代。永久代被干掉,换成了Metaspace(元数据空间)

需要特别说明的是:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中(使用的是操作系统的内存)

Mataspace和永久区的区别

堆区的内存分配策略

  1. 对象优先在 Eden 分配

    • 新创建的对象首先分配在 Eden 区。
    • 如果 Eden 区没有足够的空间,会触发 Minor GC。
  2. 大对象直接进入老年代

    • 大对象(如长字符串或大数组)可以直接进入老年代,以减少新生代的碎片化。
  3. 长期存活的对象进入老年代

    • 经过多次 Minor GC 仍然存活的对象会被晋升到老年代。
  4. 动态对象年龄判定

    • 如果 Survivor 区中相同年龄的所有对象大小总和大于 Survivor 区的一半,年龄大于或等于该年龄的对象可以直接进入老年代。

6、方法区

用于存储已被虚拟机加载的类信息、常量池、静态变量、即时编译器编译后的代码等数据。在 JDK 8 及之前的版本中,方法区通常被称为永久代(PermGen),而在 JDK 8 及之后的版本中,方法区被移到了元空间(Metaspace)

所以方法区只是一个逻辑概念,存放在哪里由虚拟机自己去决定

五、类加载器

通过字节码,我们了解了class文件的结构

通过运行数据区,我们了解了jvm内部的内存划分及结构

接下来,让我们看看,字节码怎么进入jvm的内存空间,各自进入那个空间,以及怎么跑起来。

1、加载

类的加载就是将class文件中的二进制数据读取到内存中,然后将该字节流所代表的静态数据结构转化为方法区中运行的数据结构,并且在堆内存中生成一个java.lang.Class对象作为访问方法区数据结构的入口。

Java中有哪些类加载器呢?

jvm提供了3个系统加载器,分别是Bootstrp loaderExtClassLoaderAppClassLoader

这三个加载器互相成父子继承关系

Bootstrap加载器是用C++语言写的,它在Java虚拟机启动后初始化

它主要负责加载以下路径的文件:

  • %JAVA_HOME%/jre/lib/*.jar

  • %JAVA_HOME%/jre/classes/*

  • -Xbootclasspath参数指定的路径

ExtClassLoader是用Java写的,具体来说就是 sun.misc.Launcher$ExtClassLoader

ExtClassLoader主要加载:

  • %JAVA_HOME%/jre/lib/ext/*
  • ext下的所有classes目录
  • java.ext.dirs系统变量指定的路径中类库

AppClassLoader也是用Java写成的,它的实现类是 sun.misc.Launcher$AppClassLoader,另外我们知道ClassLoader中有个getSystemClassLoader方法,此方法返回的就是它。

  • 负责加载 -classpath 所指定的位置的类或者是jar文档
  • 也是Java程序默认的类加载器

双亲委派机制

类加载器加载某个类的时候,因为有多个加载器,甚至可以有各种自定义的,他们呈父子继承关系。

这给人一种印象,子类的加载会覆盖父类,其实恰恰相反!

与普通类继承属性不同,类加载器会优先调父类的load方法,如果父类能加载,直接用父类的,否则最后一步才是自己尝试加载,从源代码上可以验证。

我们看一下ClassLoader.loadClass()方法:

java 复制代码
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
        synchronized (getClassLoadingLock(name)) { 
            // 首先,检测是否已经加载 
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                //如果没有加载,开始按如下规则执行:
                long t0 = System.nanoTime();
                try {
                    if (parent != null) { 
                        //重点!父加载器不为空则调用父加载器的loadClass 
                        c = parent.loadClass(name, false);
                    } else { 
                        //父加载器为空则调用Bootstrap Classloader 
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) { 
                }
                if (c == null) {
                    long t1 = System.nanoTime(); 
                    //父加载器没有找到,则调用findclass,自己查找并加载
                    c = findClass(name); 
                }
            }
            if (resolve) { 
                resolveClass(c);
            }
            return c;
        }
    }

为什么要有双亲委派机制

  1. 保证类的唯一性 :通过双亲委派机制,确保了类的唯一性。例如,Java 核心类库中的类(如 java.lang.Object)只会被启动类加载器加载一次,避免了不同类加载器加载同一个类而导致的冲突和不一致问题。

  2. 防止类的重复加载:每个类加载器都有自己的命名空间,通过双亲委派机制,确保了同一个类在不同的类加载器中只加载一次,提高了类加载的效率,减少了内存开销。

  3. 增强安全性:双亲委派机制确保了核心类库的类不会被用户自定义的类加载器加载,从而防止了恶意代码替换核心类库中的关键类,增强了系统的安全性。

2、验证

加载完成后,class里定义的类结构就进入了内存的方法区。

而接下来,验证是连接阶段的第一步。实际上,验证和上面的加载是交互进行的(比如class文件格式验证)。

文件格式的验证

这个好理解,就是验证加载的字节码是不是符合规范

  • 是不是CAFEBABYE开头
  • 主次版本号是否在当前jvm虚拟机可运行的范围内
  • 常量池类型对不对
  • 有没有其他不可识别的信息
  • ......等

元数据验证

到java语法级别了。这个阶段主要验证属性、字段、类关系、方法等是否合规

  • 是否有父类?除了Object其他类必须有
  • 是否继承了不该被继承的类,比如final
  • 是不是抽象类,是的话,方法都完备了没
  • 字段有没问题?是不是覆盖了父类里的final
  • ......等

字节码验证

最复杂的一个阶段。

等等,字节码前面不是验证过了吗?咋还要验证?

上面的验证是基本字节表格式验证。而这里主要验证class里定义的方法,看方法内部的code是否合法。

  • 类型转换是不是有问题?
  • 指令是否跳到了方法外的字节码上?
  • ......

符号引用验证

最后一个阶段。

这个阶段也好理解,我们上面的字节码解读时,知道字节码里有的是直接引用,有的是指向了其他的字节码地址。

而符号引用验证的就是,这些引用的对应的内容是否合法。

  • utf8里记了某个类的名字,这个类存在不?
  • 方法或字段引用,这些方法在对应的类里存在不存在?
  • 类、字段、方法等上面的可见性是否合法
  • ......

3、准备

这个阶段为class中定义的各种类变量分配内存,并赋初始值。

所做的事情好理解,但是要注意几点:

  • 类变量 = 静态变量
  • 实例变量 = 实例化new出来的那些

理论上这些值都在方法区里,但是注意,方法区本身就是一个逻辑概念。

1.6里,在永久代

1.8以后,静态类变量如果是一个对象,其实它在堆里。这个上面我们讲方法区的时候验证过。

java 复制代码
//普通类变量:在准备阶段为它开了内存空间,但是它的value是int的初始值,也就是 0!
//而真正的123赋值,是在类构造器,也就是下面的初始化阶段
public static int a = 123;

//final修饰的类变量,编译成字节码后,是一个ConstantValue类型
//这种类型,在准备阶段,直接给定值123,后期也没有二次初始化一说
public static final int b = 123;

4、解析

解析阶段开始解析类之间的关系,需要关联的类也要被加载。

这涉及到:

  • 类或接口的解析:类相关的父子继承,实现的接口都有哪些类型?
  • 字段的解析:字段对应的类型?
  • 方法的解析:方法的参数、返回值、关联了哪些类型
  • 接口方法的解析:接口上的类型?

5、初始化

最后一个步骤,经过这个步骤后,类信息完全进入了jvm内存,直到它被垃圾回收器回收。

前面几个阶段都是虚拟机来搞定的。我们也干涉不了,从代码上只能遵从它的语法要求。

而这个阶段,是赋值,才是我们应用程序中编写的有主导权的地方

在准备阶段,jvm已经初始化了对应的内存空间,final也有了自己的值。但是其他类变量,是在这里赋值完成的。

也就是我们说的:

java 复制代码
public static int a = 123;  

注意:

1)类变量与实例变量的区分

注意一件事情!

这里所说的初始化是一个class类加载到内存的过程,所谓的初始化值得是类里定义的类变量。也就是静态变量。

这个初始化要和new一个类区分开来。new的是实例变量,是在执行阶段才创建的。

2)实例变量创建的过程

当我们在方法里写了一段代码,执行过程中,要new一个类的时候,会发生以下事情:

  • 在方法区中找到对应类型的类信息
  • 在当前方法栈帧的本地变量表中放置一个reference指针
  • 在堆中开辟一块空间,放这个对象的实例
  • 将指针指向堆里对象的地址,完工!
相关推荐
桂月二二1 小时前
Java与容器化:如何使用Docker和Kubernetes优化Java应用的部署
java·docker·kubernetes
liuxin334455662 小时前
学籍管理系统:实现教育管理现代化
java·开发语言·前端·数据库·安全
小马爱打代码2 小时前
设计模式详解(建造者模式)
java·设计模式·建造者模式
栗子~~2 小时前
idea 8年使用整理
java·ide·intellij-idea
2301_801483693 小时前
Maven核心概念
java·maven
Q_19284999063 小时前
基于Spring Boot的电影售票系统
java·spring boot·后端
东阳马生架构3 小时前
JVM实战—3.JVM垃圾回收的算法和全流程
jvm
我要学编程(ಥ_ಥ)4 小时前
初始JavaEE篇 —— 网络原理---传输层协议:深入理解UDP/TCP
java·网络·tcp/ip·udp·java-ee
就爱学编程4 小时前
重生之我在异世界学编程之C语言:数据在内存中的存储篇(下)
java·服务器·c语言
yuanbenshidiaos4 小时前
C++--------------树
java·数据库·c++