太叼了,从demo、图文、底层代码深入底层揭秘JVM的工作原理,感觉又行了!


9527
9527 2023-11-28 10:37:19 48118
分类专栏: 资讯

正文

JDK体系结构图

    相信这张图大家应该都不陌生, 在刚开始学习java得到时候或多或少的都看过, 一起简单的回顾一下

图片

    JDK(Java Development Kit)是Java开发环境的核心组件,它包含了用于开发、编译和运行Java程序的工具和库。JDK的体系结构图可以帮助我们理解JDK的组成部分和它们之间的关系。

  1. Java Language(Java语言):指的是Java编程语言本身,包括语法、关键字、数据类型、控制流等。Java语言提供了丰富的特性和功能,使得开发者可以通过简洁、面向对象的方式来编写程序。

  2. Tools&Tool APIs:JDK提供了多种开发工具,用于编译、调试、性能分析等任务并且提供了各种工具相关的API,开发者可以使用这些API来扩展和定制开发工具的功能。

  3. Deployment(部署):指的是将Java应用程序部署到生产环境中,并确保其正常运行的过程。

  4. User Interface Toolkits(用户界面工具包):JDK包含了多种用户界面工具包,用于创建图形用户界面(GUI)应用程序。

  5. Integration Libraries(集成库):JDK中包含了一些用于与其他系统集成的库,例如数据库连接库(JDBC)和远程方法调用库(RMI)。

  6. Other Base Libraries(其他基础库):JDK还提供了许多其他基础库,包括输入输出库、网络库、安全库等,用于支持各种应用程序开发需求。比如java.io;java.net;java.security等。

  7. Java Virtual Machine(Java虚拟机):作为JDK的核心组件,Java虚拟机负责执行Java程序的字节码。当开发者编写并编译好Java程序后,Java虚拟机负责加载字节码文件并执行其中的指令,从而实现程序的运行。

Java语言的跨平台特性

图片

说到java语言最为突出的特点就是跨平台性,或者也可以用一次编译到处运行来形容,我们实际上是编写针对Java虚拟机的字节码,而不是针对特定操作系统或硬件架构的机器码,这些字节码可以被任何支持Java标准的虚拟机所解释和执行,因此同一份Java程序可以在Windows、Linux、Mac等各种操作系统上运行,而无需进行修改。

Java虚拟机模型图

图片

jvm整体可以分为三块,分别是类装载子系统以及运行时数据区(内存模型),字节码执行引擎,最核心的还是内存模型 , 最终我们编写的java代码要运行的话,其实是执行它所对应的class文件,这个class文件是由类装载子系统加载到内存模型中的,然后最终通过字节码执行引擎(c++实现)执行这些代码,那么具体是怎么把class文件加载到内存模型中的我前两篇文章已经讲过了。

其中, 内存模型里面由堆、栈(线程)、本地方法栈、方法区(元空间)、程序计数器等这五块组成,这五块也都是一块一块的内存区域。

栈(线程)

oracle官网是Java Virtual Machine Stacks,Java虚拟机栈,但是我个人更喜欢把它叫做线程栈,只要开始运行程序,就会有一个主线程会去运行的我们的主方法,也就是main方法,这个时候会Java虚拟机会立刻在线程栈中开辟一块独立的属于这个线程的内存空间,用来存放运行过程中使用的局部变量等数据,再来一个线程也是一样的道理。不同的线程即使执行的是同一份代码,但依然会有属于自己的空间来存局部变量等数据。这个就叫做栈内存空间(下图并没有展示局部变量存放位置,在下面说明栈帧的时候展示)

图片

那么, 栈内部的结构还是比较复杂的, 其中就有一个叫栈帧,一个方法对应一个栈帧

比如当运行main方法的时候,会从总线程栈中开辟一块单独的属于main线程的内存空间,然后会在main线程的内存空间开辟一块内存叫做栈帧,用来存放局部变量,不同的局部变量的作用域是在当前范围 , 这样的话就相当于把它们给隔开了, 如下图

图片

需要注意的是我这里写了一个FILO(First In Last Out), 这个栈和数据结构中的栈是相同的,它有一个特点就是: 后进先出(Last In First Out : LIFO)或者是先进后出(First In Last Out : FILO),同时呢它也是一个线性表。

栈帧同时也是会销毁的,如下图片,内容写到了注释中了

图片

还有一种情况就是递归调用

 

栈帧内部其实除了放局部变量,还有许多的东西,比如局部变量表、操作数栈、动态链接、方法出口等,这几个是比较重要的,当然还有一些其它的东西。把四个部分进分别进行解释,主打一个细致。

图片

局部变量表

JVM(Java虚拟机)中的局部变量表(Local Variable Table)是用于存储方法中的局部变量信息的数据结构。每个方法在编译时都会分配一个局部变量表,用于存储该方法中定义的局部变量、方法参数和临时变量。

局部变量表是一种数组结构,每个局部变量在表中都有一个槽位(Slot)来存储其值。槽位的索引从0开始,按照顺序分配给方法中的局部变量。不同类型的局部变量需要占用不同数量的槽位,例如int类型占用1个槽位,long和double类型占用2个槽位。那么也就是说64位系统中一个局部变量表中的一个槽位存储4字节大小的数据

在方法执行过程中,局部变量表被用于存储方法中的各个局部变量的值。在方法执行的不同阶段,局部变量表的槽位可能会被不同的变量使用或者释放,并且会随着方法的执行而动态改变。

局部变量表的大小在编译时确定,并且在方法的生命周期内保持不变。它是在编译时确定的,因此无法在运行时动态改变局部变量表的大小。

操作数栈

JVM(Java虚拟机)的操作数栈(Operand Stack)是用于执行Java字节码指令的一块内存区域。它被设计为一个后进先出(LIFO)的栈结构,用于存储和操作数据。

在JVM执行方法时,每个方法都会创建一个帧(Frame),其中包含局部变量表和操作数栈。操作数栈用于执行方法中的计算操作,包括常量操作、算术运算、方法调用等。

操作数栈的操作包括以下几种:

  • 压栈(Push):将数据压入操作数栈顶。例如,将常量、局部变量中的值或方法返回值压入操作数栈。

  • 弹栈(Pop):从操作数栈顶弹出数据。例如,将数据赋给局部变量、参与计算操作或方法传递参数。

  • 复制(Duplicate):复制操作数栈顶的数据并压入栈顶。例如,复制栈顶的值用于多个计算操作。

  • 交换(Swap):交换操作数栈顶的两个值。例如,用于实现变量交换或条件判断。

  • 扩展(Extend):扩展操作数栈顶的值。例如,将int类型的值扩展为long类型的值。

操作数栈的大小是固定的,由编译器在编译阶段确定。在执行方法时,JVM会根据字节码指令对操作数栈进行读取和操作。如果操作数栈溢出(Overflow)或栈为空时进行弹栈(Underflow)操作,JVM会抛出相应的异常。

我们用几行代码来进行分析: 

图片

这段代码编译之后呢如下所示

图片

当然, 每一个编码都有自己的含义, 它是属于jvm的汇编语言, 然后我们使用javap对Math类的字节码文件进行反汇编来查看, 反汇编之后呢应该对照着jvm指令手册来查看, 想要jvm指令手册的可以通过阅读文末标题为"写在最后"的内容,联系我

图片

这几行指令对应

int a = 1;
int b = 2;

对照jvm指令手册, 进行简单, iconst_1 就表示把int类型的常量压入操作数栈, 也就是把 1 放入操作数栈中 

图片

然后isStore_1 , 将int类型的值存入局部变量1中 , 局部变量表类似于数组 , 这里的1 指的是局部变量表的索引 , 也就是将1从操作数栈中出栈, 然后放入下标为1的局部变量表中, 就是a=1这个赋值的过程.

可能会有人好奇, 为什么放入下标为1的位置,  不应该是0吗? 

其实jvm会默认存放一个this进去,在实例方法中表示当前对象的引用,  所以就是1了 , 那么局部变量b也是相同的道理

图片

到这里的话需要先插入一个概念 , 就是程序计数器

程序计数器

JVM(Java虚拟机)的程序计数器(Program Counter Register)是一块较小的内存区域,它可以看作是当前线程所执行的字节码的行号指示器。在JVM中,每个线程都有自己独立的程序计数器,它是线程私有的,即每个线程都有自己的程序计数器,也就是说每个线程栈独有的 , 互不影响。

图片

程序计数器的作用包括以下几个方面:

  1. 字节码行号指示器:程序计数器存储了当前线程正在执行的字节码指令的地址或行号,即JVM下一步将要执行的指令的位置。

  2. 分支、循环、异常处理:程序计数器也被用于保存方法间的调用和返回地址,以及各种条件分支、循环跳转、异常处理等信息。

  3. 线程恢复:程序计数器在线程切换时用于恢复执行现场,确保线程能够在正确的地方继续执行。

由于程序计数器是线程私有的,因此不会出现线程安全问题。在Java虚拟机规范中,对程序计数器的访问是隐式的,它不需要进行垃圾回收,也不会发生内存溢出(OutOfMemoryError)的情况。

需要注意的是,程序计数器是JVM中唯一一个在《Java虚拟机规范》中没有规定任何OutOfMemoryError情况的区域。这是因为程序计数器只是一个辅助性的数据结构,其内存分配在JVM启动时就已经确定,并且不会产生内存泄漏问题。

4: iload_1
5: iload_2

再来看这两行代码. 程序计数器是会存储行号的 , 也就是将要执行的代码在jvm上内存的地址, 这里可以认为是4 或者5 , 反正就是前面的编号, 因为内存地址不好展示.

程序计数器是会变动的, 每执行完一行代码, 字节码执行引擎都会立马去修改它, 比如刚开始它是1(内存地址) , 执行完第一行代码之后, 字节码执行引擎马上修改为2(内存地址), 

为什么字节码执行引擎会修改?

因为代码(比如Math.class)最终都是加载到方法区的, 当然, 它最终呈现的是元数据信息, 而不是class文件 , 然后字节码执行引擎是会执行的, 所以你执行到哪个位置, 它当然是知道的, 所以就是字节码执行引擎每执行一行代码, 都会把程序计数器的值修改

为什么要设计程序计数器?

说白了就是多线程, 当我线程a将要执行第四行时, 突然CPU的时间片被另一个线程优先级高的线程b抢占了, 当前线程a就要挂起了 , 挂起了之后然后线程b执行完之后线程a就会恢复执行, 这个时候如果没有程序计数器, 怎么知道刚刚执行到了第几行.

  •  
  •  
4: iload_15: iload_2

接着这两行代码说, 分别是 : 从局部变量1中装载int类型值以及从局部变量2中装载int类型值 , 说白了就是从局部变量表下标为1的位置和下标为2的位置取出, 然后放入操作数栈中

图片

紧接着就是iadd, 表示执行int类型的加法 , 也就是(a + b)这段代码 , 执行过程就是从操作数栈中拿出2 和 1, 然后进行+运算,

 

图片

然后放回操作数栈 , 执行完这行代码之后 , 程序计数器变的值为7

图片

然后是 bipush  10, 将一个8位带符号整数压入栈 , 也就是把10压入操作数栈

图片

接下来就是imul , 也就是执行int类型的乘法 , 这个过程和上面+是一样的, 这里就不演示过程了 , 最终结果如下.

图片

图片

这里7直接成为9 , 因为10这个值也是要占用内存地址的 , 所以行号8就相当于是10的内存地址

这个计算的过程都是在CPU内部完成的, 也就是相当于把3和10 弹出操作数栈到CPU寄存器, 然后CPU进行运算

istore_3也是相同的道理, 局部变量表开辟一块空间, 然后把c放进去(istore_1和istore_2也是相同道理) , 然后把刚刚计算放到操作数栈的30弹出栈, 放到局部变量表下标为3的位置

图片


iload_3  # 从局部变量3中装载int类型值

ireturn : 从方法中返回int类型的数据

操作数栈说的简单一点就是 程序在运行过程中一块临时的存放或者中转存放操作数据的内存空间

-----接操作数栈来说明

动态链接

方法出口

方法出口,指的是当一个方法执行完成或者因为异常而退出时,控制流程的转移位置。具体来说,有两种可能的方法出口:正常完成出口和异常完成出口。

在正常完成出口中,JVM遇到任意一个方法返回的字节码指令,此时该方法就被认为是正常完成了。如果当前方法正常完成,则根据当前方法返回的字节码指令,这时有可能会有返回值传递给方法调用者。

而在异常完成出口中,这个方法在执行过程中抛出了异常并且没有进行任何处理。在这种情况下,JVM会立即停止当前的执行流程,并转移到与该异常对应的处理程序。

图片

就比如compute执行完之后要回到main方法继续执行。那我怎么知道是回到main()方法呢?就是当时再调用math.compute()方法时,就把main()方法执行的现场,也就是执行的位置或者是执行到了哪一行,这些相关的位置都存到了方法出口中,也就是我根据方法出口中的数据 , 我知道接下来返回到main()方法里面之后需要从哪一行继续往下执行

图片

main方法的局部变量表

main()方法的局部变量表和我们之前讲的有一点点不一样,在

Math math = new Math();

之后,main()方法的局部变量表保存的是Math这个对象在堆中的地址,并非"math", 这个"math"是被放到了方法区的

图片

那么,到了这一步之后,栈和堆的关系也就自然而然出来了(注意栈和堆中间的三个箭头)

图片

也就是说,站内部是有很多很多的局部变量,如果这些局部变量都是在对象类型的,那他们的值就都是在堆上面的。那么栈里面放的基本都是这些对象在堆中的内存地址。

方法区

方法区,是JVM在启动时创建的一个独立于Java堆的内存空间。它用于存储已被加载的类信息、常量、静态变量等数据。此外,方法区也负责存储编译器生成的各种字节码指令。

与Java堆一样,方法区是各个线程共享的内存区域。这就意味着,在多线程环境下,如果一个线程修改了方法区内的数据,其他线程可以立即看到这些变化。

值得注意的是,方法区的实际物理内存空间中和Java堆区一样都可以是不连续的。这一点与Java堆不同,Java堆中的对象通常是连续存放的。同时,方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展。

然而,尽管方法区在JVM的运行时数据区中扮演着重要角色,但是虚拟机规范并没有明确规定方法区的实现方式。因此,不同的JVM实现可能会有不同的方法区实现方式。例如,在HotSpot JVM中,方法区就是被称作“永久代”(Permanent Generation)的区域。

就比如在类的内部,有一个静态变量

private static final User user = new User();

这个user是会被放到方法区的,然后这个时候堆中会创建这个User对象,但是放的不是user这个值,而是User对象所对应的地址,和上面的math是一回事的

图片

那到了这一步,方法区和堆的关系是不是也就出来了,就是说白了, 如果方法区中有好多的静态变量,如果有一部分静态变量的值是对象,那么对应的这一块静态变量的内存空间中放的就是这个对象再堆中的地址

图片

本地方法栈

在JVM中,每个线程都有自己的本地方法栈。这个栈与虚拟机栈所发挥的作用非常相似,其核心区别在于服务对象不同。具体来说,虚拟机栈是为虚拟机执行Java方法(也就是字节码)服务的,而本地方法栈则是为虚拟机使用到的本地(Native修饰的)方法服务的。

当一个线程执行到一个本地方法时,JVM会将当前线程的本地方法栈压入操作数栈中,并设置好返回地址等信息。然后,JVM会调用本地方法所在的动态链接库,并将参数传递给它。最后,JVM从动态链接库中获取返回值,并将其存储到操作数栈中。

图片

堆和方法区是线程共享的,而线程栈和本地方法栈以及程序计数器都是线程独有的

图片

  1. JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation)

  2.  

  3. 非堆内存就一个永久代(Permanent Generation)

  4.  

  5. 年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。

  6.  

  7. 老年代占堆内存的2/3,年轻代占堆内存的1/3,当然这个比例是可以调整的

当然我们一般new出来的对象绝大多数都是放在Eden区的,但是Eden区满了之后再往进放对象就会触发字节码执行引擎 就要启动一根后台线程 进行垃圾清理(回收一些无用的对象),也就是GC,这个GC叫Minor GC也就是Young GC,用来清理年轻代的

图片

关于GC以及每种GC的机制将在下一篇文章说明,这里不做阐述

然后把有用的对象放到s0区,然后把Eden区剩下的对象也就是没有用的对象进行清除,然后把有用对象的分代年龄+1,这个分代年龄存在对象头中

图片

然后程序还在运行,这个时候Eden区又放满了对象,然后又进行Minor GC

图片

但是这一次会把Eden和s0区的对象都进行GC,然后再把Eden和s0区有用的对象放到s1区, 然后分代年龄再次+1

图片

然后程序继续运行,这个时候Eden区又满了, 然后又进行Minor GC

图片

但是这一次会把Eden区和s1区的对象都进行GC,然后再把Eden和s1区有用的对象放到s0区, 然后分代年龄再次+1

图片

程序又继续运行,Eden区再次满的时候重复上面的步骤,反正就是Eden区满了,然后进行Minor GC,然后把有用的对象放到另一个Survivor区,然后分代年龄+1,然后清除无用对象

但是一个对象当分代年龄经过15还没有被清除之后,这个对象会被移动到老年代,但是不同的垃圾收集器的值是不一样的,但是一般都是15,

图片

老年代放满之后会进行Full GC,Full GC回收的是整个堆以及方法区都会回收,但是有些对象比较顽固的话,就肯可能导致Full GC也无法回收,最终导致OOM(下篇文章通过一个案例进行演示)

图片

 

但是在整个GC的过程中,不管是Minor GC还是FullGC,都有有可能会触发STW,也就是stop the world单词的缩写,就会停止用户线程然后进行GC,当然字节码执行引擎在GC的时候开启的是后台线程,用户线程就比如说是用户点击按钮然后后端代码对应执行操作的线程,但是如果触发了STW对于用户的感觉就是点击按钮之后网站突然卡了一下,但是这样的操作对于用户的体验以及系统的性能是有一定的影响的

图片

大体对象再堆中流转的过程就是这样的,后面的文章会接着进行讲解垃圾回收机制以及jvm调优,其实调优的主要目的就是减少Full GC的次数,因为在Full GC的过程中,它收集垃圾的时间比较长,所以触发STW之后的时间就比较长,但是如果Minor GC的时间比较长,也需要进行调整,但是Minor GC如果触发STW之后,时间是比较短暂的,关键优化的还是Full GC。

那么为什么要设计STW机制呢?

我们在GC的过程中,无非是需要找一些对象,比如说我们发生了Full GC,然后找到了一些非垃圾对象之后还要去找其他的对象,假如说现在没有STW 机制,那我们这个线程是不是可以继续执行?你虽然在做GC,但是因为没有STW机制,所以你的用户线程还在执行,当用户线程执行完毕之后,它对应的栈内存空间全部释放,意味着指针就没有了,然后之前找出的那一批非垃圾对象就已经变成垃圾对象,之前认为它们是非垃圾对象,但是GC没有结束,它们就又变成了垃圾对象,那整个堆里面几十万上百万的对象全部变成了垃圾对象,那这一次GC不就是白做了吗?因为GC的结果不确定。难道还要再回去遍历一次,再找一次垃圾对象吗?所以说jvm 底层在一些核心的步骤,比如说找对象的过程停止用户线程,先保证数据不发生变动。然后再进行操作。

那当然,不同的垃圾收集器收集垃圾的过程是不一样的,后续的文章继续分享。

JVM内存参数设置

图片

JDK1.8之前,方法区被称为永久代,JDK1.8之后被称为元空间,元空间实际上用的就是直接内存,也就是物理内存,那么这个内存也是可以设置的(‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M),如果不设置的话有可能会占满整个物理内存

Spring Boot程序的JVM参数设置格式(Tomcat启动直接加在bin目录下catalina.sh文件里):

java ‐Xms2048M ‐Xmx2048M ‐Xmn1024M ‐Xss512K ‐XX:MetaspaceSize=256M ‐XX:MaxMetaspaceSize=256M ‐jar microservice‐eurek a‐server.jar

关于元空间的JVM参数有两个:-XX:MetaspaceSize=N和 -XX:MaxMetaspaceSize=N

-XX:MaxMetaspaceSize:设置元空间最大值, 默认是-1, 即不限制, 或者说只受限于本地内存大小。 

-XX:MetaspaceSize:指定元空间触发Fullgc的初始阈值(元空间无固定初始大小), 以字节为单位,默认是21M,达到该值就会触发 full gc进行类型卸载, 同时收集器会对该值进行调整:如果释放了大量的空间, 就适当降低该值;如果释放了很少的空间, 那么在不超 过-XX:MaxMetaspaceSize(如果设置了的话) 的情况下, 适当提高该值(自动扩容或者缩容)。这个跟早期jdk版本的-XX:PermSize参数意思不一样

- XX:PermSize代表永久代的初始容量。由于调整元空间的大小需要Full GC,这是非常昂贵的操作,如果应用在启动的时候发生大量Full GC,通常都是由于永久代或元空间发生 了大小调整,基于这种情况,一般建议在JVM参数中将MetaspaceSize和MaxMetaspaceSize设置成一样的值,并设置得比初始值要大, 对于8G物理内存的机器来说,一般我会将这两个值都设置为256M。

-Xss: 当然,对于我们线程栈空间,也是有一个大小分配的, 这个值是给一个线程用的,也就是每个线程都会从总的栈空间中分出来一块小的线程栈空间,这个空间是有大小限制的,如果是一个循环嵌套的方法调用,它会在线程栈中不断分配栈帧,那么这个线程栈是有大小限制的,无限的分配肯定就会出现栈溢出的问题。以下是程序演示

StackOverflowError示例

public class StackOverflowTest {
        static int count = 0;
static void redo() {
count++;
redo();
}

public static void main(String[] args) {
try {
redo();
} catch (Throwable t) {
t.printStackTrace();
System.out.println(count);
}
}
}
运行结果:
java.lang.StackOverflowError
at com.liuxs.jvm.StackOverflowTest.redo(StackOverflowTest.java:12)
at com.liuxs.jvm.StackOverflowTest.redo(StackOverflowTest.java:13)
at com.liuxs.jvm.StackOverflowTest.redo(StackOverflowTest.java:13)

结论: 

-Xss设置越小count值越小,说明一个线程栈里能分配的栈帧就越少,但是对JVM整体来说能开启的线程数会更多

网站声明:如果转载,请联系本站管理员。否则一切后果自行承担。

本文链接:https://www.xckfsq.com/news/show.html?id=29063
赞同 0
评论 0 条
9527L0
粉丝 0 发表 10 + 关注 私信
上周热门
如何使用 StarRocks 管理和优化数据湖中的数据?  2950
【软件正版化】软件正版化工作要点  2872
统信UOS试玩黑神话:悟空  2833
信刻光盘安全隔离与信息交换系统  2728
镜舟科技与中启乘数科技达成战略合作,共筑数据服务新生态  1261
grub引导程序无法找到指定设备和分区  1226
华为全联接大会2024丨软通动力分论坛精彩议程抢先看!  165
2024海洋能源产业融合发展论坛暨博览会同期活动-海洋能源与数字化智能化论坛成功举办  163
点击报名 | 京东2025校招进校行程预告  163
华为纯血鸿蒙正式版9月底见!但Mate 70的内情还得接着挖...  158
本周热议
我的信创开放社区兼职赚钱历程 40
今天你签到了吗? 27
如何玩转信创开放社区—从小白进阶到专家 15
信创开放社区邀请他人注册的具体步骤如下 15
方德桌面操作系统 14
用抖音玩法闯信创开放社区——用平台宣传企业产品服务 13
我有15积分有什么用? 13
如何让你先人一步获得悬赏问题信息?(创作者必看) 12
2024中国信创产业发展大会暨中国信息科技创新与应用博览会 9
中央国家机关政府采购中心:应当将CPU、操作系统符合安全可靠测评要求纳入采购需求 8

加入交流群

请使用微信扫一扫!