【JVM】基础概念之为什么要使用JVM
1. 为什么要使用JVM
我们知道,Java语言具有“一次编译到处运行”的特性,那它是如何做到这一点的呢?就是借助JVM(Java Vritual Machine)来实现的
具体来说,程序员写的Java源代码(.java文件)经过javac编译器编译后会生成一份字节码文件(.class文件);字节码是一种中间代码(并非操作系统可以直接理解的机器码),它包含着JVM能理解的指令集;而JVM呢,可以执行这些指令集,也就是将这些指令转为机器码,这样操作系统能看懂了,就可以真正地运行这个程序
换句话讲,JVM就是在字节码和机器码之间做一个翻译的工作,不同平台的机器码存在区别,JVM存在适配不同平台的版本来解析字节码(生成的字节码都是相同的);正是因为所有平台的JVM都能解读同一份字节码,所以开发者只需编译一次就能在任何安装了JVM的平台上运行,这也就是“一次编译,到处运行”的本质
现在,我们回到最初的问题,为什么要使用JVM?因为它可以帮我们实现从字节码到机器码的平稳落地,保障Java跨平台的特性。此外,JVM内部还封装了内存自动管理、字节码校验、即使编译等底层细节,大大降低了程序员开发的成本,只需要专注于业务逻辑即可,这也是JVM不可获取的重要原因
2. 类加载
前面我们聊到,Java 源代码编译成字节码(.class 文件)后,JVM 会负责将字节码翻译成机器码,最终让程序跑起来。但这里有个关键问题:这些字节码文件是怎么进入 JVM 的?JVM 又是如何 “认识” 这些字节码里的类、方法和变量的?
这就涉及到JVM的另一个核心过程——类加载
简单来说,类加载就是JVM把字节码文件“读进来”,并转换成可以在内存中直接使用的类对象的过程
那它具体做了什么呢?大致可以分为以下三个部分
2.1 加载
加载,就是通过类全限定名(包名+类名)找到.class文件,读取对应的字节流
听起来有点抽象,我们来类比一下
你想要读一本电子书,首先你得找到这本书吧,根据什么找呢?书名。在JVM里面我们使用类全限定名来找,找到之后呢?你要是想读得下载下来吧,这就是读取对应的字节流
2.2 链接
链接过程中做了三件事
①验证字节码合法性
②为静态变量分配内存并设置默认值
③将符号引用(方法名)转换为直接引用(内存地址)
还是拿上面的例子,电子书下载完成,首先要看一下下得对不对,别想看A下成B了,或者想下pdf下成word了,或者本来是UTF-8编码的下载后成Unicode了等等;其次呢,就像一些电子书的“预制标签”,在没真正打开书之前,这些标签只是文字符号,不知道具体对应哪一页,需要先预留一部分空间来存储这些内容;接下来就需要把它们转换成实际的页码,这样你点的时候才能跳转到指定位置
2.3 初始化
执行静态代码块和静态变量的赋值操作,初始化遵循“父类优先于子类”,“静态代码块按定义顺序执行”的原则
也就是打开下载好的电子书之前,里面的内容需要先加载出来,比如书里的 “前言固定说明”(类似静态变量的显式赋值)和 “开篇必读提示”(类似静态代码块),得先按规则准备好,才能让读者翻开时看到完整内容
上述类加载的过程呢,在JVM中是通过类加载器来实现的,在这里就有一个非常经典的模型——双亲委派模型
2.4 双亲委派模型
它描述了类加载过程中,根据全限定名找到.class文件的过程,准确来说应该是“单亲委派模型”或者“父亲委派模型”
在双亲委派模型(Parent Delegation Model)中,最核心的三个类(更准确地说是 “类加载器”)是启动类加载器(Bootstrap ClassLoader)、扩展类加载器(Extension ClassLoader)、应用程序类加载器(Application ClassLoader)。它们构成了 Java 默认的类加载器层次结构,负责按规则加载不同来源的类
双亲委派模型的过程:
- 进行类加载,通过全限定类名找.class的时候,就会从ApplicationClassLoader开始,把加载类这样的任务委托给父亲进行,ExtensionClassLoader也不会立即进行查找,也是委托给父亲进行,BootstrapClassLoader只能自行进行类加载,根据类名,在标准库范围查看是否存在匹配的.class文件
- 如果BootstrapClassLoader没有找到,会把任务还给ExtensionClassLoader来查找.class文件,找到就正常加载,没找到也把任务还给ApplicationClassLoader,如果它也没找到.class文件,就抛出异常
形象一点来说 ,一个类要想被加载,先给你,你给你爸,你爸给你爷;爷爷没找到,还给爸爸,爸爸也没找到,还给你,你要是也没找到,抛异常
这样的类加载模型目的是约定优先级,收到一个类名后,先在标准库中找,再到扩展库中找,最后才是第三方库找
3. 执行字节码
JVM执行字节码的方式主要有两种:解释执行和即时编译
3.1 解释执行
通过解释器逐行翻译字节码为机器码并执行
好处在于这样启动会很快,不需要预热,适合冷启动或低频执行的代码
缺点是逐行解释的效率较低,对于高频执行的程序如果每次执行都要逐行解释效率会非常低
3.2 即时编译
为了解决解释执行的性能问题,JVM引入了即时编辑器(JIT),对热点代码(比如高频调用的方法、循环体)进行优化编译
如何判断一个方法是不是热点代码呢?JVM为每个方法维护两个计数器(在方法区的类信心中),共同决定是否为热点:
- 方法调用计数器:统计每个方法被调用的次数,被调用一次计数器+1,默认情况下计数达到10000次会触发JIT编译。这里还有一个特殊机制,计数器会定时衰减,每隔一段时间计数器值减半,来避免曾经高频但是现在低频的方法一直被视为热点
- 循环回边计数器:统计循环体的执行次数,每次循环执行到汇编指令计数器+1
如果方法包含循环,循环回边计数器同步累加,当“调用计数+回边计数”达标时,提前触发JIT编译
当代码被标记为热点代码时,JIT编译器直接将热点代码的字节码编译为本地机器码,并缓存到内存中
这一过程常用的优化手段有:常量折叠(1+2编译为3)、循环展开(减少循环跳转开销)、、逃逸分析(判断对象是否可栈上分配,减少GC压力 )
后续再次调用该热点代码时,JVM直接执行缓存的机器码,跳过解释过程,大幅提升效率
ok,下一节我们来介绍一个重头戏——垃圾回收机制