虚拟机3-输入层
类加载子系统是JVM的入口,负责把字节码文件加载到内存中。它不只限于硬盘上的class文件,网络或其他来源的字节流,只要符合JVM规范,都能被加载。什么是JVM规范?简单说,就是字节码得包含魔数、版本号、常量池、访问标识、字段表、方法表等这些标准结构。
# 类加载过程
类加载的过程分三个阶段:加载(Loading)、链接(Linking)和初始化(Initialization)。我来拆开讲讲。
第一步,加载。JVM启动时,程序需要某个类,类加载器先检查它是否已加载过。如果没加载过,就读取字节码的静态数据,把它放进内存。这一步就是单纯的“搬运工”,数据进来了,但还没生效。
第二步,链接,分三个小步骤:验证、准备、解析。验证是检查字节码符不符合规范,比如开头是不是“CAFEBABE”魔数,结构是不是完整。准备是为类的成员变量设默认值,比如int默认给0。解析稍微复杂点,就是把字节码里的符号引用(比如字符串表示的类名)转成内存里的直接引用(实际地址)。这步就像把地图上的地名变成具体坐标。
第三步,初始化。主要是执行类的构造器方法(<clinit>
),处理静态变量的赋值和静态块的逻辑。三个阶段走完,类就正式可用了,程序可以开始实例化对象。
总结一下,类加载子系统是JVM和外界交互的关键,把字节码变成内存里的可用数据,分加载、链接、初始化三个阶段,每步各司其职。
# 加载
加载阶段,顾名思义,就是把字节码文件读进JVM。具体干了三件事:第一,类加载器读取字节码的二进制流,简单说就是“读文件”。第二,把文件里的静态数据(比如字段、方法、常量池)解析出来,存到JVM内存的方法区。方法区是运行时数据区的核心,专门存类的描述信息。第三,在堆区生成一个java.lang.Class对象,作为方法区的访问入口。
举个例子:磁盘上有个A.class文件,程序里写了“new A()”,JVM一看内存里没这个类,就启动加载。类加载器读A的字节码,发现它有个父类BaseA,那就先加载BaseA。加载完后,BaseA和A的信息都放进方法区,各自在堆里生成一个Class对象。程序通过“A.class”拿到这个Class实例,再用“getField(‘age’)”从方法区提取字段“age”的描述。这就是加载的全过程。
综上所述,加载阶段是类加载的第一步,读字节码、存方法区、建Class对象,还得管父类加载。有个细节要注意:加载子类时,父类必须先加载。比如A依赖BaseA,BaseA没加载就没法继续。而且,所有类都隐式继承Object,所以Object是最先被加载的。
# 链接
我们聊聊类加载的第二阶段——链接(Linking)
链接阶段包括三个子步骤:验证、准备、解析,缺一不可。咱们一个个来说。
先说验证。验证就是检查字节码符不符合JVM规范,分四块:1. 文件格式校验,比如开头是不是“CAFEBABE”魔数,版本号、常量池、字段表这些格式对不对;2. 语义分析,看类、方法、字段的定义符不符合Java规则,比如有没有继承final类;3. 字节码验证,分析数据流和控制流,比如类型转换能不能成功,或者方法分支有没有漏掉return;4. 符号引用校验,检查字节码里提到的类名、方法名、字段名存不存在,不存在就报错,比如“ClassNotFoundException”。这步只看字节码本身,跟运行时无关。
再说准备。准备很简单,就是给类的静态变量(static)赋默认初始值。比如“public static int a = 100”,在准备阶段,a先被赋值为0,至于100,得等到后面的初始化阶段。不同类型有不同默认值:int是0,引用类型是null,就这么简单。
最后讲解析。解析是核心,把字节码里的符号引用变成直接引用。啥意思?符号引用是静态的文本,比如类A继承BaseA,字节码里用字符串记录“com.test.A”和“com.test.BaseA”的关系。加载后,内存里生成Class A和Class BaseA两个对象,通过指针动态关联起来,这就是直接引用。解析阶段处理四种东西:类、字段、方法、接口,都是把文本关系转成内存里的实际指针。
链接阶段是验证字节码合法性、准备默认值、解析引用关系,三步走完,字节码就从静态文件变成内存里能用的东西。
# 初始化
那我们继续聊聊类加载的最后一个阶段——初始化(Initialization)
初始化阶段的核心是执行类的构造器方法<clinit>
,全称“class initialization”。这个方法不是我们写的,而是编译器自动生成,专门用来完成类的初始化。啥叫初始化?就是给静态变量赋值和执行静态代码块。
有五个重点要掌握:
<clinit>
干啥? 它负责执行静态变量赋值和静态代码块。比如有个类A,里面有“static int start = 10”和一个static块,<clinit>
会把这两部分合起来变成字节码,按顺序执行。前面准备阶段赋的是默认值0,到这儿才赋10。- 子类和父类的顺序
子类初始化前,父类的
<clinit>
先跑。比如类A继承Base,运行时先执行Base的<clinit>
,再到A的。这是自然的,因为加载子类时父类得先加载。 - 没静态内容就不生成
<clinit>
如果类没静态变量也没静态块,<clinit>
压根不会生成。没东西要初始化,要它干嘛? - 怎么看加载过程? JVM有个选项“-XX:+TraceClassLoading”,启动时加上,能看到类加载的详细顺序。比如先加载Base,再加载A,初始化也是这个顺序,调试时很实用。
- 只初始化一次咋保证?
<clinit>
自带同步锁。多线程同时加载类,只有第一个线程能执行,其他的等着。第一个跑完,类就初始化好了,后面的线程直接用现成的Class对象,不重复执行。
举个例子:我写了个类A,继承Base,A有静态变量和块,main里用100个线程new A。加个10秒睡眠模拟阻塞,结果是父类先初始化,子类静态块只跑一次,后99个线程瞬间完成。因为<clinit>
加锁,初始化只发生一次。
# 类加载器的分类
类加载器是JVM里负责把字节码加载进内存的工具。根据加载的内容和位置,它分成四种:启动类加载器、扩展类加载器、应用程序类加载器和自定义类加载器。
启动类加载器
最底层,用C语言写,加载JVM核心类库,比如rt.jar
里的Object
、String
、Thread
。这些都在jre/lib
目录下,只有包名以java
、javax
、sun
开头的才加载,这是沙箱机制,防黑客乱加破坏性代码。注意,它不加载全部lib
里的包,只认固定的。扩展类加载器
用Java写,类名叫ExtClassLoader
,加载jre/lib/ext
目录下的扩展包,比如j3ec.jar
。平时开发用得少,但你可以把自己的包扔进ext
目录,它也会加载。上级是启动类加载器,但不是继承关系。应用程序类加载器
也叫系统类加载器,用Java写,类名叫AppClassLoader
,加载classpath
下的类和第三方包,比如Spring、Hibernate这些。跟我们开发最相关,默认就是它干活,上级是扩展类加载器。自定义类加载器
自己写,可以加载非classpath
或非jre
的类,甚至网络上的字节流。比如类不在文件里,是数据流,自定义加载器就能搞定。
怎么看类加载器长啥样?拿代码举例:
- 在
ClassLoaderSample
类里,用ClassLoaderSample.class.getClassLoader()
能拿到加载它的加载器。运行输出AppClassLoader
,因为它在classpath
下。换成j3ec.jar
里的类,输出ExtClassLoader
,因为在ext
目录。 String.class.getClassLoader()
输出null
,不是没加载器,而是启动类加载器用C写,JVM管不着,返回空。
总结:类加载器分四种,启动管核心,扩展管ext
,应用管classpath
,自定义管特殊场景。最常用的是应用程序类加载器。
# 自定义类加载器
先说为啥要自定义类加载器。平时开发基本用不到,但特殊场景得靠它:1. 字节码从网络来,不是文件,比如远程服务器传来的二进制流;2. 字节码不在jre/lib、ext或classpath里,系统加载器找不到;3. 安全需求,字节码加密传输,得解密后再加载。这些情况少见,99%时候启动、扩展、应用程序类加载器就够了。
那自定义类加载器咋写?核心是继承ClassLoader,重写findClass方法。两步:1. 自己指定路径读字节码二进制流,比如从C盘根目录读;2. 用defineClass把二进制流转成Class对象。比如我写了个MyClassLoader1,用FileInputStream读C盘的ClassSample字节码,存到字节数组,再丢给defineClass,就加载完了。MyClassLoader2代码一样,就名字不同。
重点来了,面试常问:“Class实例在JVM里全局唯一吗?”很多人直接说“是”,因为类加载会先查JVM里有没有,重复就不加载。但有个前提——得是同一个类加载器。如果不同加载器加载同一个字节码,结果就不一样。咋验证?我在测试类Application里用MyClassLoader1和MyClassLoader2各加载一次ClassSample,输出它们的Class对象和哈希码。结果呢?加载器不同,哈希码不同,说明是两个对象。所以,结论是:同一个类,不同加载器加载,在JVM里生成不同的Class实例;只有同一加载器下,Class实例才唯一。
# 双亲委派模型
双亲委派模型听着高大上,其实很简单。JVM加载类时,加载器把任务一级级往上推到顶层,再一级级往下试着加载,直到成功。举个例子:我定义个类A,加载时咋走?第一步,系统类加载器接到任务,不直接干活,而是往上推给扩展类加载器,扩展再推给引导类加载器,这叫“委派”。第二步,从顶往下试,引导类加载器一看,A不是rt.jar
里的核心类,pass;扩展类加载器一看,也不是ext
里的,pass;最后系统类加载器认领,因为A在classpath
下归它管,就加载了。
如果是Object
这种核心类呢?一样从系统类加载器往上推到引导类加载器,引导一看属于rt.jar
,直接加载,下面两层就不用动了。
为啥这么麻烦?两个好处:
- 避免重复加载。同一个加载器加载的类在JVM里全局唯一,像
Object
永远是引导类加载器搞定,不会多份乱跑。 - 沙箱安全。核心包以
java
、javax
、sun
开头,用户不能随便污染。比如你自定个java.lang.Customer
,双亲委派往上一推,沙箱机制发现包名不对,直接禁掉,保护系统。
面试题来了:“定义个java.lang.Customer
会咋样?”我在自己的工程试了下,包名设成java.lang
,跑程序报错:“不能用java
开头的包名”。为啥?系统类加载器管自定义类,但java.lang
是核心包,双亲委派推到顶,沙箱检查不通过,加载就被拦了。这题考你加载器分工、双亲委派流程和沙箱机制。
总结:双亲委派是先往上推任务,再往下试加载,保证类唯一和安全。面试问核心包污染,直接甩沙箱机制就行。