Java类是如何加载的?
这个问题还是很有意思,今天松哥来尝试和大伙梳理一下。
整体上来说,类的加载主要是下面这几个步骤:
上面这张图就是一个类的完整生命周期了,一共要经历加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)、卸载(Unloading)七个不同的步骤。
这七个步骤中,验证、准备和解析一般又统一称之为 Linking。
这是整体的流程,接下来,松哥就和大家来分析每一个具体的步骤都干了啥。
首先第一步 Loading,也就是加载类。
这里如果被面试官细问,有两个方向:
- 什么时候加载?
- 怎么加载?
先说类的加载时机。
如果需要一个权威的文档来说明问题,抱歉,官方没有任何文档来说明类在什么时候会被加载。但是,官方文档给出了六种类必须进行初始化的场景,毫无疑问,如果需要对类进行初始化,那么就必须先 Loading。
这六种场景分别是:
- new 一个类或者使用某一个类的静态属性/静态方法,给某个类的静态属性赋值等等,不过对于被 final 修饰的的 static 变量除外。
- 通过反射调用某个类的时候。
- 当要初始化某个类,但是发现其父类尚未初始化,那么就要去初始化父类(如果一个接口在初始化的时候发现其父类未初始化,这个时候并不会初始化其父类,只有在真正用到了其父类的时候,才会初始化)。
- main 方法所在的主类。
- 对于含有 default 方法的接口,如果该接口的实现类需要进行初始化,那么就会触发该类的加载。
- 最后一种情况和动态语言相关的,跟我们 Java 关系不大,这里就不讨论了(因为 Java 虚拟机不仅能跑 Java,也能跑 Groovy、Kotlin 等,所以虚拟机支持的内容会更加广泛一些)。
只有这六种场景会触发类的初始化,凡是不符合这六种情况的,都不会触发类的初始化。
这是类的加载时机问题。
那么怎么加载呢?这就涉及到类加载的双亲委派问题,这个问题网上有很多文章介绍,内容本身也不算难,这里松哥就不啰嗦了。
通过双亲委派找到具体的类加载器之后,接下来就要开始执行加载了。
加载主要干三件事。
- 通过类的全限定名来获取定义该类的二进制字节流。
❝
全限定名也就是类的全路径,例如 org.javaboy.HelloWorld 这种,通过这个名字去获取类的二进制字节流。去哪里获取呢?可以从磁盘上获取,这是我们最容易想到的,除了从磁盘上获取之外,也可以从网络获取,甚至可以在运行时通过动态计算生成,我们所熟知的 Java 动态代理就属于这种情况。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的 Class 对象,作为方法区这个类的各种数据的访问入口。
Linking 这个环节分为三个步骤,分别是:
- Verification
- Preparing
- Resolution
我们分别来看。
验证这个环节就要就是检查输入的二进制字节流是否符合要求。
正常来说,我们的 Java 代码写完之后会进行编译,有问题的话,编译阶段就报错了,等不到类加载阶段。
不过由于 JVM 读取的二进制字节流不一定是通过 Java 源代码编译后获取到的,也有可能是其他语言编译得到的,甚至可能有某个大神直接用二进制编辑器 0、1、0、1 这样敲出来的,所以站在 JVM 的角度,必须要对输入的二进制流进行校验,确保读取的数据没有问题。
验证的内容主要有这些:
- 魔数是否以 0xCAFEBABE 开始。
❝
魔数是 Class 文件的开始标记,这个位置是一个固定的字符,CAFE BABE。
松哥这里随便用二进制编辑器打开一个 Class 文件给大家看下:
- 主次版本号是否在当前 Java 虚拟机所能接受的范围内。
❝
CAFEBABE 后面紧跟着的是次版本号,次版本号后面紧跟着的是主版本号。以上图为例,次版本号为 0,主版本号 3D 转为十进制是 61。高版本的 JDK 可以向下兼容以前旧版本的 Class 文件,但是无法运行以后版本的 Class 文件,Class 文件的主版本号和 JDK 的关系如下图。
JDK 版本号Class 主版本号JDK 1963JDK 1862JDK 1761JDK 1660JDK 1559JDK 1458JDK 1357JDK 1256JDK 1155JDK 1054JDK 953JDK 852JDK 751JDK 6.050JDK 5.049JDK 1.448JDK 1.347JDK 1.246JDK 1.145.0-45.6
- 常量池中是否有不被支持的常量类型
- 当前类是否存在父类(所有类都应当有父类)?当前类是否继承了 final 类(不应当继承 final 类)?如果当前类不是抽象类,是否实现了其父类或者接口中要求实现的方法等等。
- 对字节码进行校验。
- 符号引用能否找到对应的类,符号引用中涉及到的类、字段、方法等的访问性是否满足要求。
❝
由于验证这块的环节非常复杂,流程也多,因此,如果自己有办法确认自己的代码是 OK 的,那么也可以使用 -Xverify:none 来关闭大部分的类验证,这样可以缩短虚拟机加载类的时间。
这里检查的内容其实非常多,官方文档足足有 100 多页,松哥这里就不逐一列举了,小伙伴们主要是知道这里的核心目的是检查并确保读入到内存中的字节流是没有问题的。
这一阶段主要是给类中的静态属性设置初始值。
例如定义了 public static int a = 5;,那么就会为该变量在内存中(堆)分配存储空间,并设置初始值(int 类型初始值是 0),注意这个时候并不会将 a 设置为 5,因为还没到最终的初始化阶段。
但是如果属性在定义的时候就已经定义为常量了,例如 public final static int a = 5;,则会直接给属性最终赋值。
接下来是解析,解析主要是将常量池内的符号引用替换为直接引用的过程。
什么是符号引用呢?
❝
符号引用是以一组符号来描述引用的目标,因为在编译阶段,虚拟机并不知道所引用的类的具体位置,因此就使用符号引用来代替。符号可以是任何字面量,只要在使用时能够无歧义的定位到目标即可。
什么是直接引用呢?
❝
直接引用就是一个可以直接指向目标的指针,相对偏移量等。
所以,符号引用转为直接引用其实就是原本是通过字符去引用某个变量,现在直接改为通过内存地址来访问该变量了。
解析的符号主要有七种,分别是类或者接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符。
松哥这里以一个类的解析为例,和小伙伴们简单说明一下这个解析过程。
假设当前类是 C1,当前类中存在一个符号引用 F,我们要将这个符号引用 F 解析为一个类 C2,那么流程是这样:
- 如果 C2 是一个普通对象而不是接口,那么 JVM 会把代表 F 的全限定名传递给 C1 的类加载器去加载这个类,当然,这个加载过程又是一整套的类加载流程。
- 如果 C2 是一个对象数组,那么首先按照第一步的方式先去加载数组中的元素类型,然后由虚拟机去生成一个代表该数组的对象。
就这样简单两个步骤,当然,在这个流程中,也会去检查 C1 是否具备对 C2 的访问权限,这个主要是检查 module 访问权限和类的访问权限。
接下来就是类的初始化阶段了,如果想让这个阶段更加具象化,那么这个阶段实际上是调用类的 clinit 方法,这个方法并不是开发者写的,而是由 javac 编译器自动生成的。
javac 自动生成的 clinit 方法主要是将静态变量赋值和静态代码块的相关内容合并起来。在执行 clinit 方法的过程中,并不会显式的调用父类的 clinit 方法,而是由虚拟机去确保在执行子类的 clinit 方法之前,父类的 clinit 方法已经被执行过了。
例如为 static 类型的变量赋值,就是在这个环节完成的。
最后就是 Using 和 Unloading 了,这块就简单了,不多说。
这样做优化,实现 0.059s 启动一个SpringBoot项目
最近自己用Spring Cloud Alibaba做了一个微服务架构的项目,部署的时候遇到了难题:内存不够。目前该项目有7个微服务,因为我只有一台阿里云的服务器(2C 4G),所以我只能把所有的微服务部署在一台服务器上,部署方式是使用docker制作springboot的fat jar镜像,每个微服务在不加任何JVM调优参数的情况下所占内存约500M。
由于是微服务所以肯定还要部署:nacos,除此之外还用到了redis、sentinel、rocketmq、elk等(mysql买的阿里云的),光是运行这些应用就占用内存2个多G,剩下的1个多G内存在部署4个微服务后就满了,于是开始对springboot应用的内存进行初步优化:
添加JVM参数优化内存大小
默认空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制;空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制。
因此服务器一般设置-Xms、-Xmx相等以避免在每次GC 后调整堆的大小。对象的堆内存由称为垃圾回收器的自动内存管理系统回收。
默认情况下,当 CPU 数量小于8, ParallelGCThreads 的值等于 CPU 数量,我的服务器是2C的所以这个参数可省略。配置完成后,启动服务发现内存确实变小了,由原来的500M降至100~200M,但不是我想要的效果,我期望的效果是达到几十M的级别。
经网上查阅大量资料得知可以使用Spring Native这门新技术来实现我的需求。(该技术正处于快速迭代阶段,变动较大,建议用于个人学习,不要用于生产)
SpringBoot项目使用Spring Native后:
- 应用启动速度特别快,毫秒级别
- 运行时更低的内存消耗,官方展示的含有Spring Boot, Spring MVC, Jackson, Tomcat的镜像大小是50M
- 为了达到前面的效果,代价是构建时间更长(即使是一个Hello Word构建也需要2分钟,不过主要取决于电脑配置,我的是2min左右)
简而言之就是为了提高Java在云原生的竞争力(个人理解)。
以下内容摘抄自GitHub上Spring Native的自述文件:
Spring Native 为使用GraalVM 原生映像编译器将 Spring 应用程序编译为原生可执行文件提供 beta 支持,以提供通常设计为打包在轻量级容器中的原生部署选项。实际上,目标是在这个新平台上支持几乎未修改的 Spring Boot 应用程序。
以下内容摘抄自其他博客:
近几年“原生”一词一直泛滥在云计算、边缘计算等领域中,而原生宠幸的语言也一直都是Golang,Rust等脱离Sandbox运行的开发语言。Java得益于上世纪流行的一次编译,到处执行的理念,流行至今,但也因为这个原因,导致Java程序脱离不了JVM运行环境,使得不那么受原生程序的青睐。在云原生泛滥的今天,臃肿的JVM使Java应用程序对比其他语言显得无比的庞大,各路大神也想了很多方式让Java变的更“原生”。
本次实战相关的环境信息如下:
- OS:Windows10 21H1
- IDE:IntelliJ IDEA 2021.2.3
- JDK:graalvm-ce-java11-21.3.0
- Maven:3.6.3
- Docker Desktop for Windows: 20.10.12
- Spring Boot:2.6.2
- Spring Native:0.11.1
从官方文档得知(上图)
使用 Spring Native 的应用程序应该使用 Java 11 或 Java 17 编译。
构建 Spring Boot 原生应用程序有两种主要方法:
- 使用Spring Boot Buildpacks 支持生成包含本机可执行文件的轻量级容器。
- 使用GraalVM 原生镜像 Maven 插件支持生成原生可执行文件。
经过各种踩坑后在本机上成功的使用了方法1和方法2。简单来说:
方法1就是在SpringBoot2.3后,可以使用spring-boot-maven-plugin插件来构建docker镜像,使用mvn spring-boot:build-image命令结合Docker的API来实现Spring Boot 原生应用程序的构建,成功执行后会直接生成一个docker镜像,然后run这个镜像就可以了,不用我们再写Dockerfile了,相关的参数配置都在pom.xml中配置(该插件的configuration标签下,和fabric8或spotify的docker-maven-plugin很相似)。
方法2不需要安装docker,但要安装Visual Studio,然后执行mvn -Pnative package命令后会生成一个可执行文件(.exe),运行即可。
主要区别如下
- 方法1需要安装Docker
- 方法2需要安装Visual Studio(需要用到部分单个组件:2个MSVC,1个Windows 10 SDK)
- 方法1是mvn spring-boot:build-image
- 方法2是mvn -Pnative package
因为每个微服务使用Docker部署而不是exe文件,所以方法1正好符合我的需求,所以后文使用Spring Boot Buildpacks的方式构建Spring Boot原生应用程序。
官方下载地址:
https://www.graalvm.org/downloads/
针对方法1的话,上面三张图好像只用配置JAVA_HOME就行,想一次成功的话建议3个都配,后续可以自行测试。扩展:最全的java面试题库
检验是否安装成功
打开新的cmd,输入以下命令,等待安装
这一步我执行失败了,解决方法就是从github上手动下载native-image,然后解压、安装
https://github.com/graalvm/graalvm-ce-builds/releases/download/vm-21.3.0/native-image-installable-svm-java11-windows-amd64-21.3.0.jar
jar用WinRAR也是可以解压的,解压后如下
在bin目录下打开cmd,输入以下命令,等待安装
具体步骤略,按照官方文档操作即可:
https://docs.docker.com/desktop/windows/install/
前面都是准备工作,这一步开始才是重点
首先快速创建一个Spring Boot项目,我命名为spring-native
完整的pom如下
本文介绍的是Spring Native0.11.1版本,其对应的Spring Boot版本必须是2.6.2,以上只是一个最基本的配置案例,实际开发中还需要在spring-boot-maven-plugin插件的configuration标签下配置其他许许多多的参数。
例如docker远程的地址和证书的路径、jvm调优参数、配置文件指定、docker镜像名端口仓库地址等等,最好的方法就是看spring-boot-maven-plugin的官方文档,这里以配置jvm参数为例
通过官方文档得知只需要在configuration标签下配置即可,例如
其他的配置参数还有很多。扩展:最全的java面试题库
官方文档:
https://docs.spring.io/spring-boot/docs/2.6.2/maven-plugin/reference/htmlsingle/#build-image
下载完相关依赖后,电脑风扇就开始呼呼的转,查看任务管理器发现CPU利用率100%,内存使用量飙升,最后稳定在90%+。
构建成功
查看所有镜像
spring-native就是构建的镜像
创建并运行容器
在Docker Desktop查看日志,发现应用成功启动,启动仅耗时。,也就是59ms,果然印证了Spring Native启动是毫秒级别这句话。
成功调用接口
在Docker Desktop查看占用内存,仅28M左右。
不使用Spring Native启动应用
启动耗时3s,占用内存高达511M,高下立判。
文章仅供参考,建议结合Spring Native官方最新文档学习。
https://docs.spring.io/spring-native/docs/current/reference/htmlsingle/index.html
原文链接:https://mp.weixin.qq.com/s/y6wRXxMDvy8r-fqmWjvI3Q
本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com
文章为作者独立观点不代本网立场,未经允许不得转载。