JVM 的介绍
JVM 本质上就是一个进程,你在电脑运行了 JVM ,跟运行了一个微信,一个qq音乐,一个浏览器差不多。差别是我们不用学习浏览器内核,但我们要学习JVM内部结构和原理,这有助于开发java程序,如果你学会垃圾回收器工作原理,是不是能开发出更好的Java程序?
JVM 运行有一个进程id ,没有端口,端口一般指的是网络端口,当启动一个spring boot 项目时,我们通常会指定一个 servlet.port: 8080 , 这是 tomcat 的端口,跟 JVM 没关系。(servlet 仅仅是 java 中的接口/规范,具体实现是由 tomcat、 Jetty 等web服务器实现)
JVM 的作用是让你的 windows/linux 电脑有了一个 java 命令,如:java --version 、 java HelloWorld ,用过这个 java HelloWorld命令的都知道,java 命令执行的是 .class 文件,编译命令是 javac HelloWorld.java 。 javac 不是JVM的一部分,它是 JDK 的编译器,一个独立进程,专门用来把 xxx.java 文件编译成字节码文件(.class)
我们下载的 JDK 包含的主要工具:
javac→ Java 编译器,独立进程java→ Java 启动器,启动 JVMjavadoc→ 生成文档jar→ 打包工具jconsole,jvisualvm→ 监控/调试工具- jstat 监控 JVM 各种运行时信息, 查看 GC 活动、堆内存使用、方法区信息等。
- jmap 输出堆的详细信息,包括对象分布、堆配置、GC 活动,支持堆 dump
跟 JDK 类似的包还有一个 JRE, 这个都不陌生哈,简单理解 JRE 是 JDK 的部分,JRE 有 JVM, 但是没有编译器,生成文档这些工具,因为 JRE 一般用来执行 jar 包,我们在springboot开发环境中打好的jar包,jar包内已经是 .class 文件,不需要编译器,一个 JRE 即可运行,所以 linux 拉取的是 JRE。 tomcat 是内嵌在 spring boot 中,maven 打包时打成可执行jar (胖 jar) 就会把 tomcat 打进jar 包。
这样就好理解 java 是跨平台的语言,只需要一个 JVM 和 .class 文件就能运行java语言,那么在windows、linux、 mac 都能安装JVM, 源码编译成字节码文件,形式为 jar 包,JVM 就可以执行它。所以只要操作系统能跑JVM,那么就能执行 Java
讲完了对 JVM 和 java 的介绍,我们来看看 JVM 是怎么执行 .class 文件的,学习 JVM 内部原理。
JVM 内部结构
先看一张图,这张图很清晰的说明了把 JAVA 文件转成字节码文件,经历类加载,进入 JVM 内存,以及执行引擎和本地方法库的结构都能看的很清楚。

补一个中文的图:

注意:
- 线程私有:程序计数器、虚拟机栈、本地方法区
- 线程共享:堆、方法区, 堆外内存(Java7的永久代或JDK8的元空间、代码缓存)
第一次看这图谁不一脸懵,除了 java -> class 流程外,其它一概不懂。
暂且理解成电脑里面启动了JVM进程,然后内存就分配一块空间给JVM 进程,在 JVM 内部把运行时数据区划分成大大小小多块逻辑内存,不知道干啥的,就是划分成这样一块一块的内存区域。然后一个一个学,这一块一块是干啥的。
或者可以这么粗略理解:
- 类加载器 → 把字节码加载到 方法区。
- 运行时数据区(堆、栈、方法区等) → 存放执行所需的数据。
- 执行引擎 → 根据 PC 寄存器指示的字节码指令,
-
- 从栈帧/堆取数据,
- 解释/编译成机器码,
- 交给 CPU 执行,
- 更新 PC 寄存器。
先学一个类加载入入门。
JVM 类加载机制
首先先看类加载吧,JVM 是如何加载字节码文件?
我把类加载过程和JVM逻辑内存结构串起来讲。
执行五个步骤:
- 加载: 根据类的全限定名(如
com.fency.HelloWorld)把.class文件读入内存,并在方法区(或叫做元空间 Metaspace)生成一个代表这个类的 Class 对象。 - 验证:那 .class 已经加载进来方法区了,执行引擎开始检查
.class文件是否合法、符合 JVM 规范,避免恶意或错误字节码破坏 JVM。包括文件格式验证、元数据验证、字节码验证、符号引用验证。 - 准备:为类的 静态变量(static 字段,存放在方法区) 分配内存,并设置 默认初始值(零值)。例如:
public static int a = 10;在这一步只是把a分配内存,赋值为默认的0,不会赋值10。 - 解析: 把类中的 符号引用(字面量,以字符串形式表示的类、方法、字段名)转换为 直接引用(指向内存中具体对象的引用/地址)。 简单理解就是给字面量一个地址,比如
com.fency.HelloWorld类,本质上是一个字面量(名字/标识),解析后就指向某个内存地址,就可以通过这个字面量操作内存了。方法区内部有一个常量池,符号引用存储在这里。 - 初始化:执行类构造器
<clinit>()方法,对静态变量赋初值、执行静态代码块。例如:上面a = 10就是在这一步赋值完成的。初始化可能会创建对象实例,对象实例存储在堆中。 执行类构造器<clinit>方法时使用虚拟机栈 + 程序计数器 。
简化总结就是:
读取.class ---> [方法区: 存类元数据]
|
v
验证 (字节码合法性)
|
v
准备 (分配静态变量内存, 默认值)
|
v
解析 (常量池符号引用 -> 直接引用)
|
v
初始化 (执行 <clinit>, 访问方法区+堆, 线程栈, PC)
类加载学完就进入到 JVM 运行时数据区了,一个一个学习,先来一个 程序计数器,这东西和 cpu 好像有点关系,先看看。
程序计数器
全程叫做程序计数寄存器(Program Counter Register) , 有没有想到 cpu 的寄存器? 没错,名字就来源于 cpu ,它们有点关联,并非一模一样。
JVM 计数器是在 JVM 程序 运行时数据区的一小块内存(非常小,可以忽略不计),记录下一条要执行的执行的字节码指令地址。

JVM 是多线程的,每个线程都有私有且独立的程序计数器,要能记住“执行到哪条字节码了”,当程序挂起线程时,计数器保存当前的工作状态, 切换回来时,从 PC 寄存器指向的字节码继续执行。
顺便回顾 cpu 寄存器存储指令相关的线程信息,cpu 只有把数据装载到寄存器才能运行。
cpu 有一套寄存器,分别是:
- 程序计数器(PC Register)
存储 下一条要执行指令的地址。JVM 很像cpu, 不同的是 CPU 一个核心仅有一个计数器,当切换线程工作时,需要整套寄存器都刷入内存( 线程控制块(TCB/PCB) ),再切换,这种保存/恢复机制让 cpu 切换线程开销很大。而JVM 的计数器是线程私有,挂起线程无非就是不执行,寄存器保持不变即可,或者可以理解成 JVM 本身就是内存的一块区域,计数器本身存储在内存中。 - 指令寄存器(IR, Instruction Register)
存放 当前正在执行的指令。 - 通用寄存器(General-purpose Register)
存放运算时的 操作数 或 计算结果,例如EAX, EBX, RAX。 - 等等 ...
CPU 寄存器就是处理器的 工作台,CPU 执行指令时几乎所有信息都要放到寄存器里。 当cpu切换线程时,系统要做两件事:
- 保存 A 的上下文
-
- 把线程 A 用到的寄存器内容(PC、通用寄存器、栈指针等)保存到内存(通常是内核栈或进程控制块 PCB)。
- 这就像把 A 的工作台状态拍照存档。
- 恢复 B 的上下文
-
- 从内存里取出线程 B 上次保存的寄存器状态,写回寄存器。
- 相当于把 B 的工作台布置好,让 CPU 继续执行。
这就是为什么 线程切换开销大:寄存器内容要保存/恢复,可能还涉及缓存刷新。
顺便提一个问题,把 OS 的课串起来思考。
谁来决定 JVM 的线程切换? 或者说 JVM 内部有多个线程时,谁指挥它们运行/挂起?
答:操作系统的调度器!
JVM 调用了操作系统的底层 API , 操作系统接收到请求,调度器开始发力。
线程时间片都不陌生, 操作系统分配给线程一段任务执行时间(比如 1~10ms) ,如果是抢占式调度, 时间片用完,OS 强制保存线程上下文(寄存器、PC、栈指针等),换另一个线程运行。
这样电脑里的多个进程执行任务就不会等太久,不过你们女朋友不懂这个,以后就说自己的操作系统调度器的算法不合理,微信进程没有分配到时机片,导致消息回复晚了,这个解释高级的让她哑口无言!
综上所述,JVM 计数器的存在是因为 cpu 要不停的切换各个线程,切换会 JVM 的时候,JVM 有计数器,就知道从哪条指令继续执行。
JVM 的程序计数器是 线程私有的记录器,用来存放当前线程下一条要执行的字节码指令地址。 当操作系统调度导致线程挂起时,JVM 不需要额外保存/恢复计数器,因为它本来就是线程独占的。 这样保证了 JVM 多线程能够正确运行, 程序计数器的意义是保证任务执行的正确性。
983

被折叠的 条评论
为什么被折叠?



