不畏浮云遮望眼,只缘身在最高层
前言
在开始前,先说为什么我从冯·诺依曼计算机体系,追溯到了JVM,一切原来如此。因为一颗剽悍种子发现的一个问题,就是多数人对待知识总想问是什么,却常常很少问为什么(或者说知道了是什么后也很少再问为什么),正如这篇所要畅谈的JVM,看了很多文章"什么是JVM",我其实楞是没搞懂什么是JVM,或者说为什么JVM是要这样子呢?
直到回到知识历史的起点,再来看现在知识,一切原来如此。
(理解知识背后一整条脉络远比纯粹记住硬邦邦知识更为深刻,因为前者会带来思考上的愉悦,而后者只会让人丧失乐趣。)
谁说的?我一颗剽悍的种子眼里饱含泪水说的。
所以我回到冯·诺依曼的计算机体系再到操作系统去追溯 JVM 来看看这整条脉络。
回顾计算机发展历史,我们都知道被称为“现代计算机之父”的冯·诺依曼在1945提出并奠定到至今的冯·诺依曼计算机体系,也就是计算机结构。所以要知道一个真相是到至今为止不管在物理机之上构建的是什么样的操作系统、还是操作系统上构建什么样的虚拟机,或者再上层的应用,追溯到源头都离不开这套体系下。(通俗的说我们被笼罩在这套体系之下)
(所以为什么你总能在JVM虚拟机身上看到计算机结构熟悉的身影。)
计算机结构思想
在计算机结构中由 输入设备、控制器、运算器、存储器、输出设备 这五大部分组成。而 控制器+运算器 就是我们再熟悉不过的CPU(中央处理器),存储器就是内存、磁盘等,输入设备例如:键盘、鼠标,输出设备如:显示器等。而整个计算机结构的运行如下图所示:
从上面可以看到,整个硬件已经布好了局,当一个程序被执行时,程序会被当成数据一样,所有组成结构都可以被看成数据/指令流、控制流(图下方箭头所示),程序就像数据(或者指令)一样,只要经过流就可以被牢牢操控。这也正是冯·诺依曼的思想 “程序应该像数据一样可以被存储”。
操作系统的本质
在计算机结构之上还有一层,那就是操作系统,操作系统可以说是虚拟机的鼻祖,因为操作系统目的就是:如何操控物理机器。也就是如何将现实的物理资源(CPU、内存、磁盘)虚拟化成一台能被你通过盯着屏幕、敲着键盘、划着鼠标就能操控现实的物理资源。
不错,操作系统本质就是将 “物理资源虚拟化”!
(想想元宇宙的起点是不是从操作系统这里开始了呢?)
在我们最常用的操作系统有Windows和Linux这两种,不过这两个操作系统之间是不兼容的,那么就会存在编写的程序并不能同时运行在两个操作系统上,而开发程序时就得要分别开发。那么就会触发人类(程序员)的第一生产力 懒!
千万别笑!人类发明蒸汽机以来,就从未停止过释放重复劳动力的工作,人类发明洗衣机替代洗衣、洗碗机替代洗碗、汽车替代马车,以及再到20世纪30年代就提出,但至今仍在火的自动驾驶即将替代司机。但是它们并不具备通用性,就像问鼎了围棋界的谷歌机器 AlphaGo ,就算再厉害也只会下围棋这一件事(围棋曾被认为是计算机没法逾越的天花板,但被AlphaGo战胜,不过AlphaGo也只是属于弱人工智能),可是从整个人类科技发展来看,人类正在一步步开发着具备更通用性的人工智能,也就是有一天超越强人工智能而跨越起点的超人工智能,相信未来程序员唯一能做可能就是一段对话:
程序员:“ 嘿 Siri*:帮我开发一个掘金,顺便帮我安排一个叫 一颗剽悍的种子 的家伙写一篇JVM。”
程序员:“嘿,对了,Siri,看文章前别忘了给那家伙一键三连。”
Siri:“f*ck 又提需求。”
程序员:“什么?”
Siri:“安排”
(扯远了题外话,希望你能Get到人类追求通用性(懒)远不止在JVM中体现。)
JVM
的全称Java Virtual Machine
也就是Java虚拟机,抛开Java虚拟机前缀Java,虚拟机其实就是在“虚拟(抽象)计算机”,也就是在操作系统之上再次虚拟出一台计算机,来屏蔽不同硬件和操作系统之间的差异(如果说操作系统是用户与物理资源之间的桥梁,那么JVM就是不同硬件和操作系统上的桥梁),JVM目的就是具备通用性,也就是我们常说的Java可以跨平台开发。
那么回到JVM这时容易懂了,JVM可以像计算机结构一样去运行所编写的程序,而JVM主要由:类装载器、运行时数据区、执行引擎、本地库接口这四个部分所构成。
我们知道一个可运行的程序其实不过是我们所定义的一个个 .java源代码文件
组成(这些文件其实就是我们程序员对于事物的抽象,然后交由计算机帮我们实现)。而JVM
只规定.class
字节码文件格式,这样的好处很明显,那就是不仅只是Java可以运行在JVM上,而是只要是能生成.class
字节码文件也都可以运行在JVM之上,所以Java虚拟机并不是Java语言专有的。JVM通过字节码存储格式统一了所有平台,而字节码就是构成了平台无关性的基石。如:Python、Ruby、Scala等语言只要能转为字节码格式,那么就都能运行在JVM上面(可以说JVM野心真大!)。
所以.java
文件想要在JVM
上被执行,就必须先由javac
编译器转成后.class
再交给Class Loader
类装载器去进行类的加载等。而这也相当于开始像计算机结构一样的输入设备正式的进去到整个机器内部。如下图所示:
类装载器执行过程
说到虚拟机类装载器,就不得不说类加载的机制,而一套机制就一定会有一个过程,也就是一个类进入入口(类装载器)到虚拟机中必经的整个生命周期:Loading 加载、Linking 连接、Initialization 初始化、Using 使用、Unlading 卸载。如下图所示:
但其中最主要的Class文件装载过程是加载、连接、初始化这三步。
加载
类加载过程的第一个过程毫无疑问是加载,如果虚拟机还没有加载过此类,会通过类加载器将字节码文件加载到内存中。当然也可以是从ZIP
压缩包,或者JAR、WAR
等格式,最终也不过是从中取出类文件而已。
连接
虽然任何二进制都可以是Class类型,但是只有JVM能够装载的Class文件类型才能运行在JVM之上。(也就是要符合虚拟机的规范的字节码文件才能通过)加载后是连接的过程(连接通俗的说就是将类文件与虚拟机建立关联),从上图可以看到连接的包括了三个过程:验证、准备、解析。 连接的第一步是 验证,验证会从四个阶段的检验依次进行:
验证
验证的四个阶段:
文件格式验证,其实就是检查是否符合Class文件规范,主要有:魔数检查、版本检查等等,例如:魔数检查就是看Class文件开头是否是 0xCAFEBABE
开头(魔数通俗的说就是打一个让JVM认识的标签)。版本检查就是看Class文件主次版本号是否能在当前版本虚拟机处理等等。
元数据验证,(元数据可能不好理解,其实元数据从字面意思上就是 描述数据的数据,所以元数据验证就是语义的检查)检查类中是否被final修饰、是否有继承了父类等等。
字节码验证,要做的就是确定程序中的语义是否合法且符合逻辑的(通过分析数据流和控制流),确保跳转指令指向正确位置,操作数类型合理等等。
符号引用验证,先说什么是符号引用?符号引用是以一组符号来描述所引用的目标,可以是任何字面量。(听到这你可能还不能Get到的话,暖男一颗剽悍的种子,下面举个例子,你就悟了)
在上面我们知道 .class
文件是经过编译器编译后的文件,而 .class
文件里面的内容就是字节码,通过字节码中记录着自己将要使用的其他类或方法等。
例如下面这段代码,我们定义一个字符串类型变量的 userName
,通过 System.out.println()
打印。
String userName = "一颗剽悍的种子";System.out.println(userName);
我们看上面代码编译成字节码后是什么样子的,如下所示:
0 ldc #2 <一颗剽悍的种子> 2 astore_1 3 getstatic #3 <java/lang/System.out : Ljava/io/PrintStream;> 6 aload_1 7 invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>10 return
可以看到在代码中最常见的表达式:
System.out.println(userName)
而转换后的字节码中是使用符号引用来“代替”表达:
invokevirtual #4 <java/io/PrintStream.println : (Ljava/lang/String;)V>
所以符号引用验证就是通过检查符号引用所“代替”的类、属性、方法是否存在且有权限被访问。
上面的验证东西很多,但是不管什么样的验证,都是为了 确保类符合 Java 规范与符合 JVM 规范,同时 避免危害到虚拟机的安全。
准备
连接的第二步是 准备,这个阶段主要做两件事,为通过验证了的类来分配内存空间并设置初始化。
如对于final static
修饰的变量,会直接赋值我们的定义值。可以看下面这段代码,会在准备阶段分配内存,并初始化值。
private final static String value = "一颗剽悍的种子"
各数据类型默认初始值,如下图所示:
(注意:上图中并没有 boolean
类型,Java中的 boolean
类型的底层实现实际上就是 int
类型,int
类型默认值 0
,对应的就是 boolean
类型默认值 false
。)
解析
连接的第三步是 解析,解析阶段的工作就是将 符号引用转为直接引用。因为在编译时类、方法等都是用符号引用来代替(所以为什么叫符号引用,符号只是个标识),而符号引用是并不知道这些数据所引用的 实际地址。
所以如果仅仅用符号引用就面临一个问题,就是 不能确定一定存在该对象。所以通过解析将符号引用转化为 JVM可直接获取的内存地址或指针,也就是 直接引用。
当解析将符号引用转成直接引用时,也就是目标必定已经在虚拟机的内存中存在(说白了用直接引用就是确定了存在该类、方法或属性)。
初始化
类装载过程中最后阶段是初始化。而这个阶段将会执行构造器<clinit>
方法,它是在通过我们前面提到过的Javac
将 .java
文件编译成 .class
字节码文件时,所有类初始化代码,也就是包括静态变量赋值语句、静态代码块、静态方法,收集在一起后成为 <clinit>()
方法。
简单的概括初始化目的就是 初始化给类静态变量或静态代码块为程序员自己所定义的值。
到此,类的加载过程就像冯·诺依曼计算机结构中的输入设备,负责将数据丢进了入口后就是真正到JVM内部(JVM运行时数据区)去操纵数据,直至将我们的想法通过代码最后交给机器来完成。
JVM运行时数据区
JVM运行时数据区主要分为 堆、程序计数器、方法区、虚拟机栈和本地方法栈 这五个分区。其中按 线程共享 和 线程私有 两类:
线程共享:堆、方法区。
线程私有:程序计数器、虚拟机栈、本地方法栈。
堆
在JVM 内存中最大的一块内存空间就是堆,而且堆也被所有线程共享,所以堆也几乎存储着所有的对象。堆被按年代进行划分为 新时代、 老年代 以及 持久代,新生代又接着被分为 Eden
(伊甸园区) 和 Surivor
(幸存区)。而 Surivor
进一步由 From Survivor
和 To Survivor
进行划分。
JDK 8之前以及之后堆按年代划分的变化,如下图所示:
方法区
方法区跟堆一样是线程共享的区域,当.class
字节码文件在JVM加载时会被分配到不同的数据结构,如常量池、方法、构造函数,同时也主要包括用来存储已被虚拟机加载的类相关信息(类信息又包括了类的版本、字段、方法、接口和父类等信息)都存放在方法区。
程序计数器
我们知道Java程序是多线程执行的,所以即想要能满足多个线程的交叉执行,又想要确保多个线程都能完整的执行完各自的工作,那么一旦出现被中断的线程,线程执行到哪条的内存地址(指令)就必须被保存下来,这样当被中断的线程恢复时就又可以接着执行下去。
而这就是程序计数器的工作,用来记录哪个线程当前执行到哪条指令。所以分支、循环、跳转、异常处理、线程恢复等都需要依赖计数器完成。(形象的说就是程序控制流的指示器)
虚拟机栈
虚拟机栈是线程私有的区域,所以不用关心数据一致性问题。当我们创建一个线程时,同样在JVM中也会创建一个与之对应的栈,称为虚拟机栈。
而虚拟机栈的内部其实是一个或多个的栈帧,每一个栈帧又都对应着一个Java方法的调用。
其运行过程是,当我们创建一个新方法同时,与之对应会在虚拟机栈中同样创建一个新的栈帧(当前栈帧)会被放在栈顶(只要是栈就会有栈顶和栈底),同时程序计数器也会指向这个当前栈地址。如下图所示:
在每个栈帧里又存储着方法的 局部变量表、操作数栈、动态链接、方法返回地址、附加信息 参与着方法的调用和返回。
所以 如果说堆解决了数据存储的问题,那么栈就是解决了程序如何运行的问题。
本地方法栈
本地方法栈是为了运行JVM本地方法(也就是 Native
方法)而准备的空间。而从字面上之所以被称为本地方法栈,也是因为 Natice
方法很多也都是由C语言所实现的。
如果说 “类加载子系统是计算机结构中的输入设备,那么运行时数据区就是计算机结构中的CPU(控制器和运算器)和存储器,那么最后的输出设备就是执行引擎。”
执行引擎
到了执行引擎,可以说是JVM最后的一个环节,从最先的一个Java源文件编译成.class
字节码文件经过了类加载子系统,也经过了上面的JVM运行时数据区,经过了这一整系列下来,通俗的说字节码文件已经被重新打碎重组成了一个可以由JVM所操控的一系列数据(再回看计算机结构,数据成流,被布好的局安排的明明白白),但是问题是代码并不能被执行。因为JVM并不是将高级语言直接转成机器指令,而是字节码,所以字节码的真正的运行得由执行引擎去将字节码翻译成机器指令后,由真正物理机去执行。
但是我们知道JVM只是对计算机的抽象,它的一切都只是建立在软件层面自行实现的。而物理计算机只认识机器码指令,这些机器码指令运行通过处理器、缓存、指令集和操作系统等构建了物理机的执行引擎。
所以JVM想要让Java程序运行起来,同样也是要在 “虚拟(通俗说是模拟)一个执行引擎”,而执行引擎的目的就是 将字节码指令解释(编译)为机器指令。
因为回到本质,真正干活做事的是物理机,所以执行引擎就是将字节码转成为物理机可执行的机器码(从用户看执行引擎就是一台翻译机)
我们使用的HotSpot VM是目前虚拟机的代表之一,它是集解释器和JIT即时编译器于一身 的架构。也就说Java虚拟机运行时,解释器和JIT(just in time 即时编译器) 互相协作。
解释器
在JVM早期使用的就是解释器(大多数语言同样也是),解释器就是在运行时逐行解释字节码转化成机器码再执行程序。(这也解释了上面所说,JVM为什么不直接将Java语言直接转化为机器指码指令直接就能在物理机上执行。而是要通过加多一层字节码文件来具备通用性,所以以这种 “加一个翻译器”的方式,来避免高级语言直接转成本地机器指令的耦合,重要的事情不要忘了,JVM虚拟机是一个概念,也不要忘了目的是具备通用性)
在Java发展路程中,从最早期的,也是最古老的字节码解释器。之后到了目前普遍使用的模板解释器。一共有两套解释执行器。字节码解释器是在执行时通过纯软件代码模拟字节码的执行,所以效率也非常低。而模板解释器是将每一条字节码和模板函数相关联,而模板函数能直接产生这条字节码执行时的机器码,从而达到提高解释器性能。
但是单凭字节码解释器效率还远不够,所以 为了追求一把即时速度的推背感,虚拟机又加上了JIT也就是即时编译器。
JIT 即时编译器
从上面我们知道在JVM执行引擎拥有字节码解释器之后又加入了JIT,而使用JIT对字节码转化为机器码指令时,关注的核心一点就是 程序中运行时被调用频繁的代码,被称为 “热点代码”。
而要找到这些 “热点代码”就需要使用到JIT的 热点探测。目前的Host Spot 的JVM采用的热点探测是基于 计数器热点探测。计数器热点探测很好理解,就是统计每个方法执行次数,当超过认为的热点阈值,那么就属于“热点代码”。
计数器热点探测被分为 方法调用计数器 和 回边计数器 两类。方法调用计数器用来统计代码调用次数,而回边计数器则用来统计循环执行次数。
方法调用的计数器除了递增,也同样有热度衰减,也就是当代码调用次数超过一定时间已经不足提交给JIT,那么调用计数器会递减。
心细的朋友会发现跟前面的 Redis的内存淘汰策略中的LFU算法很类似,在之前文章详尽的阐述,如果感兴趣可以去翻看,这里就不再赘述。
Redis是怎么解决缓存占满内存的?
而回边计数器的主要目的是为了触发 OSR (On StackReplacement)栈上编译。在一些循环周期较长代码会在循环时间内,会直接将代码替换执行缓存机器码。
本地方法接口与本地方法库
在JVM中如果需要与一些底层系统实现交互,那么就会使用到本地方法接口与本地方法库,也就是 Native Method
,本地方法接口与本地方法库其目很简单,就是借用到C
或C++
等其他语言的资源。
总结
我们从冯诺依曼计算机体系理解了计算机结构思想 程序应该像数据一样可以被存储,也就是说程序就像数据(或者指令)一样,只要经过像组件构建的流就可以被牢牢操控。而接着探究操作系统本质其实就是将物理资源虚拟化,可以说是用户与物理资源之间的桥梁。最后追溯到JVM其实就是在一台虚拟(抽象)的计算机,如果类加载子系统是计算机结构中的输入设备,那么运行时数据区就是计算机结构中的CPU(控制器和运算器)和存储器,那么到最终的输出设备就是执行引擎。
到此,JVM还有类加载的双亲委派机制,以及JVM的垃圾回收机制、JVM的堆栈等异常以及JVM配置参数等内容没有聊,因为这些内容都值得再独立拎出来细说。所以这一篇文章可以当成JVM的一个开篇,也可以说是JVM的一张地图。地图作用不是告诉你应该去哪的路标,而是能纵览整个全貌后,至于你对这整个知识是想怎么理解的都取决于的是你自己的思考。
~好了,这两个多星期就到这了。~
好了,其实是这一个多月就到这了。
(最后,我们至今还没有非冯·诺依曼体系下的新计算机结构,但不妨大胆设想未来,也许会在距实用还相去甚远的量子计算机上看到呢。)
我是一颗剽悍的种子,怕什么真理无穷,进一寸,有进一寸的欢喜。感谢各位朋友的:关注、点赞、收藏和评论 ,我们下回见!
创作不易,勿白嫖。
一颗剽悍的种子 | 文 【原创】
文章持续更新,可以关注微信公众号「 一颗剽悍的种子 」第一时间催更和阅读。
原文:https://juejin.cn/post/7098500546297856030