学习虚拟机更有助于深入理解JAVA这门语言,《深入理解java虚拟机》属于程序员必读书籍之一
本篇包括《什么是虚拟机》、《源代码到机器码》、《JVM内存结构》、《类的加载机制》四部分内容
[TOC]
什么是虚拟机
虚拟机是一种抽象化的计算机,通过软件模拟具有完整硬件系统功能的、运行在一个完全隔离环境中的完整计算机系统,能提供物理计算机的功能
java虚拟机是java语言的运行环境,屏蔽了与具体操作系统平台相关的信息,使得Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行
exe文件执行在windows系统上,而dmg文件执行在Mac OSX系统上,正因为操作系统的原因,使得两个后缀文件编译后的代码不能在其他系统平台上通用
但是我们知道,java代码编译后,可以在Linux系统上运行,同样也可以在windows系统上运行,同一份代码可以执行在不同系统上,这就是通过java虚拟机来实现的
java语言并不直接将代码编译成与某单一系统有关的机器码,而是编译成一种特定的语言规范 —— 字节码,但字节码并不被各个系统所识别,而是通过java虚拟机解析字节码文件的内容,将其翻译成各种操作系统能理解的机器码(java虚拟机相当于翻译官)
实际上java虚拟机运行的是字节码文件,不限于java语言,即使用其他的语言写的代码,用特定的编译器能生成符合字节码规范的字节码文件,也是可以在java虚拟机上运行
从源代码到机器码
编译器可分为三种:
- 前端编译器:Sun的javac、Eclipse JDT中的增量式编译器(ECJ)
- JIT(Just in time)及时编译器:HotSpot VM的 C1、C2编译器
- AOT编译器:GUN Complier for the java(GCJ)、Excelsior JET
1. 前端编译器:源代码到字节码
JDK的安装目录里有一个javac工具,将java代码翻译成字节码,这个工具叫做编译器,因为处于编译的前期,因此被称为前端编译器。
运行javac命令的过程,其实就是javac编译器解析java源代码,并生成字节码文件的过程,也就是把java语言规范转化为字节码语言规范
javac编译器可分为四个阶段:
- 一阶段:词法、语法分析。这个阶段JVM对源代码的字符进行一次扫描,最终生成一个抽象的语法树,也就是JVM会读懂代码想干嘛
- 二阶段:填充符号表,类之间是会相互引用的,但在编译阶段,无法确定具体的地址,所以会使用一个符号来替代。即对抽象的类或接口进行符号填充,等到类加载阶段,JVM会将符号替换成具体的内存地址
- 三阶段:注解阶段,java支持注解,这个阶段会对注解进行分析,根据注解的作用将其还原成具体的指令集
- 四阶段:分析与字节码生成,JVM根据上面几个阶段分析出来的结果,进行字节码的生成,最终生成class文件
2. JIT即时编译器:字节码到机器码
当源代码转化为字节码之后,其实要运行程序,有两种选择:
- 使用 Java 解释器解释执行字节码
- 使用 JIT即时编译器将字节码转化为本地机器代码
前种启动速度快但运行速度慢,后者启动速度慢但运行速度快
因为解释器不需要像 JIT 编译器一样,将所有字节码都转化为机器码,少去了优化的时间
而当 JIT 编译器完成第一次编译后,其会将字节码对应的机器码保存下来,下次可以直接使用,所以启动慢运行快
实际中通常采用两种结合的方式进行java代码的编译执行
在 HotSpot 虚拟机内置了两个即时编译器,分别称为 Client Compiler 和Server Compiler。这两种不同的编译器衍生出两种不同的编译模式,分别称之为(习惯叫法,不是官方说法):
- C1( Client Compiler) 编译模式
- C2(Server Compiler) 编译模式
C1 编译模式: 将字节码编译为本地代码,进行简单、可靠的优化,如有必要将加入性能监控的逻辑
C2 编译模式: 将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化
C1 编译模式做的优化相对比较保守,其编译速度相比 C2 较快
C2 编译模式会做一些激进的优化,并且会根据性能监控做针对性优化,所以其编译质量相对较好,但是耗时更长
对于 HotSpot 虚拟机来说,其一共有三种运行模式可选,分别是:
- 混合模式(Mixed Mode) 。即 C1 和 C2 两种模式混合起来使用,这是默认的运行模式。如果你想单独使用 C1 模式或 C2 模式,使用 -client 或 -server 打开即可
- 解释模式(Interpreted Mode)。即所有代码都解释执行,使用 -Xint 参数可以打开这个模式
- 编译模式(Compiled Mode)。 此模式优先采用编译,但是无法编译时也会解释执行,使用 -Xcomp 打开这种模式
在命令行中输入 java -version 可以看到使用的哪种运行模式
3. AOT 编译器:源代码到机器码
AOT 编译器,它能直接将源代码转化为机器码
AOT 编译器的基本思想:在程序执行前生成 Java 方法的本地代码,以便在程序运行时直接使用本地代码
但是 Java 语言本身的动态特性带来了额外的复杂性,影响了 Java 程序静态编译代码的质量
例如 Java 语言中的动态类加载,因为 AOT 是在程序运行前编译的,所以无法获知这一信息,所以会导致一些问题的产生
AOT 编译器从编译质量上来看,比不上 JIT 编译器。其存在的目的在于避免 JIT 编译器的运行时性能消耗或内存消耗,或者避免解释程序的早期性能开销
4. 总结
在 JVM 中有三个非常重要的编译器,它们分别是:前端编译器、JIT 编译器、AOT 编译器
前端编译器,最常见的就是我们的 javac 编译器,其将 Java 源代码编译为 Java 字节码文件。JIT 即时编译器,最常见的是 HotSpot 虚拟机中的 Client Compiler 和 Server Compiler,其将 Java 字节码编译为本地机器代码。而 AOT 编译器则能将源代码直接编译为本地机器码。这三种编译器的编译速度和编译质量如下
- 编译速度上,解释执行 > AOT 编译器 > JIT 编译器
- 编译质量上,JIT 编译器 > AOT 编译器 > 解释执行
在 JVM 中,通过这几种不同方式的配合,使得 JVM 的编译质量和运行速度达到最优的状态
JVM内存结构
《Java虚拟机规范》中用的是运行时数据区这个术语
运行时数据区:Java虚拟机定义了若干种程序运行期间会使用到的运行时数据区,其中有一些会随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是与线程一一对应的,这些与线程对应的数据区域会随着线程开始和结束而创建和销毁
- Xms:设置堆的最小空间大小
- Xmx:设置堆的最大空间大小
- XX:NewSize设置新生代最小空间大小
- XX:MaxNewSize设置新生代最大空间大小
- XX:PermSize设置永久代最小空间大小
- XX:MaxPermSize设置永久代最大空间大小
- Xss:设置每个线程的堆栈大小
JVM的内存结构分为公有和私有两部分:
公有: 所有线程都共享的部分,指 Java 堆、方法区、常量池
私有: 每个线程的私有数据,包括:PC寄存器、Java 虚拟机栈、本地方法栈
1. 公有部分:Java堆、方法区、常量池
1.1 Java堆(Heap): 对于大多数应用来说,堆是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。有些时候小对象会直接在栈上进行分配,这种现象称为「栈上分配」
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”。如果从内存回收的角度看,由于现在收集器基本都是采用的分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等
根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)
如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常
Java 堆根据对象存活时间的不同,Java 堆还被分为年轻代、老年代两个区域,年轻代还被进一步划分为 Eden 区(伊甸区)、From Survivor 0(幸存者区域)、To Survivor 1(幸存者区域) 区。如图所示
对象需要分配内存时,一个对象永远优先被分配在年轻代的 Eden 区,等到 Eden 区域内存不够时,Java 虚拟机会启动垃圾回收。此时 Eden 区中没有被引用的对象的内存就会被回收,而一些存活时间较长的对象则会进入到老年代。在 JVM 中有一个名为 -XX:MaxTenuringThreshold 的参数专门用来设置晋升到老年代所需要经历的 GC 次数,即在年轻代的对象经过了指定次数的 GC 后,将在下次 GC 时进入老年代(补充:System.gc() 只是标记需要做GC操作,实际不会立马执行)
虚拟机中的对象必然有存活时间长的对象,也有存活时间短的对象,这是一个普遍存在的正态分布规律。如果将其混在一起,那么因为存活时间短的对象有很多,那么势必导致较为频繁的垃圾回收。而垃圾回收时不得不对所有内存都进行扫描,但其实有一部分对象,它们存活时间很长,对它们进行扫描完全是浪费时间。因此为了提高垃圾回收效率,所以对堆再进行分区
根据IBM公司统计结果,默认的虚拟机配置,Eden:from :to = 8:1:1
1.2 方法区(Method Area) 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域。它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码(比如Spring使用IOC或者AOP创建Bean时,或者cglib、反射的形式动态生成class信息)等数据
方法区在不同版本的虚拟机有不同的表现形式,例如在 1.7 版本的 HotSpot 虚拟机中,方法区被称为永久代(Permanent Space),而在 JDK 1.8 中则被称之为 MetaSpace
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常
2. 私有部分:PC寄存器、Java 虚拟机栈、本地方法栈
2.1 PC寄存器: Program Counter Register 也叫程序计数器。是一块较小的内存空间,它的作用可以看做是当前线程所执行的字节码的行号指示器
字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
如果这个方法不是 native 方法,那么 PC 寄存器就保存 Java 虚拟机正在执行的字节码指令地址。如果是 native 方法,那么 PC 寄存器保存的值是 undefined。任意时刻,一条 Java 虚拟机线程只会执行一个方法的代码,而这个被线程执行的方法称为该线程的当前方法,其地址被存在 PC 寄存器中
多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间的计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存
此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域
2.2 Java 虚拟机栈 JVM Stacks,它的生命周期与线程相同,用来存储栈帧。虚拟机栈描述的是Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧(Stack Frame)用于存储局部变量表、操作栈、动态链接、方法出口等信息。每一个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。即存储局部变量与一些过程结果的地方
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)
其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小
在Java虚拟机规范中,对这个区域规定了两种异常状况:
- 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
- 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),当扩展时无法申请到足够的内存时会抛出OutOfMemoryError异常
2.3 本地方法栈:
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的Native方法服务。虚拟机规范中对本地方法栈中的方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一
与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常
2.4 追踪OutOfMemoryError
对内存结构清晰的认识同样可以帮助理解不同OutOfMemoryErrors:
Exception in thread “main”: java.lang.OutOfMemoryError: Java heap space
原因:对象不能被分配到堆内存中
Exception in thread “main”: java.lang.OutOfMemoryError: PermGen space
原因:类或者方法不能被加载到老年代。它可能出现在一个程序加载很多类的时候,比如引用了很多第三方的库
Exception in thread “main”: java.lang.OutOfMemoryError: Requested array size exceeds VM limit
原因:创建的数组大于堆内存的空间
Exception in thread “main”: java.lang.OutOfMemoryError: request <size> bytes for <reason>. Out of swap space?
原因:分配本地分配失败。JNI、本地库或者Java虚拟机都会从本地堆中分配内存空间
Exception in thread “main”: java.lang.OutOfMemoryError: <reason> <stack trace>(Native method)
原因:同样是本地方法内存分配失败,只不过是JNI或者本地方法或者Java虚拟机发现
JAVA类的加载机制
1. 什么是类的加载
类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个 java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口
类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误
加载.class文件的方式:
- 从本地系统中直接加载
- 通过网络下载.class文件
- 从zip,jar等归档文件中加载.class文件
- 从专有数据库中提取.class文件
- 将Java源文件动态编译为.class文件
2. 类的生命周期
JVM 虚拟机执行 class 字节码的过程可以分为七个阶段:加载、验证、准备、解析、初始化、使用、卸载
其中类加载的过程为:加载、验证、准备、解析、初始化五个阶段
在这五个阶段中,加载、验证、准备和初始化这四个阶段发生的顺序是确定的,而解析阶段则不一定,它在某些情况下可以在初始化阶段之后开始,这是为了支持Java语言的运行时绑定(也成为动态绑定或晚期绑定)
另外注意这里的几个阶段是按顺序开始,而不是按顺序进行或完成,因为这些阶段通常都是互相交叉地混合进行的,通常在一个阶段执行的过程中调用或激活另一个阶段
2.1 加载
加载阶段是类加载过程的第一个阶段。在这个阶段,JVM 的主要目的是将字节码从各个位置(网络、磁盘等)转化为二进制字节流加载到内存中,接着会为这个类在 JVM 的方法区创建一个对应的 Class 对象,这个 Class 对象就是这个类各种数据的访问入口
查找并加载类的二进制数据加载是类加载过程的第一个阶段,在加载阶段,虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取其定义的二进制字节流
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在Java堆中生成一个代表这个类的 java.lang.Class对象,作为对方法区中这些数据的访问入口
相对于类加载的其他阶段而言,加载阶段(准确地说,是加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,因为开发人员既可以使用系统提供的类加载器来完成加载,也可以自定义自己的类加载器来完成加载
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需的格式存储在方法区之中,而且在Java堆中也创建一个 java.lang.Class类的对象,这样便可以通过该对象访问方法区中的这些数据
2.2 连接之验证
当 JVM 加载完 Class 字节码文件并在方法区创建对应的 Class 对象之后,JVM 便会启动对该字节码流的校验,只有符合 JVM 字节码规范的文件才能被 JVM 正确执行
验证阶段大致完成4个检验动作:
- 文件格式验证:验证字节流是否符合Class文件格式的规范;例如:是否以 0xCAFEBABE(魔数)开头、主次版本号是否在当前虚拟机的处理范围之内、常量池中的常量是否有不被支持的类型
- 元数据验证:对字节码描述的信息进行语义分析(注意:对比javac编译阶段的语义分析),以保证其描述的信息符合Java语言规范的要求;例如:这个类是否有父类,除了 java.lang.Object之外
- 字节码验证:通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的
- 符号引用验证:确保解析动作能正确执行
当代码数据被加载到内存中后,虚拟机就会对代码数据进行校验,看看这份代码是不是真的按照JVM规范去写的。验证阶段对程序运行期没有影响,可以考虑采用 -Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间
2.3 连接之准备
当完成字节码文件的校验之后,JVM 便会开始为类变量分配内存并初始化。这里需要注意两个关键点,即内存分配的对象以及初始化的类型
- 内存分配的对象。Java 中的变量有「类变量」和「类成员变量」两种类型,「类变量」指的是被 static 修饰的变量,而其他所有类型的变量都属于「类成员变量」。在准备阶段,JVM 只会为「类变量」分配内存,而不会为「类成员变量」分配内存。「类成员变量」的内存分配需要等到初始化阶段才开始
1
2
3
public static int age = 18; // 为类变量赋值,int类型的默认值0,而不是18
public static final int num = 3; //static final修饰的,在准备阶段即被赋值为3
public String name = "Ekko";
- 初始化的类型。在准备阶段,JVM 会为类变量分配内存,并为其初始化。但是这里的初始化指的是为变量赋予 Java 语言中该数据类型的零值,而不是用户代码里初始化的值
还需要注意的是:
- 对基本数据类型来说,对于类变量(static)和全局变量,如果不显式地对其赋值而直接使用,则系统会为其赋予默认的零值,而对于局部变量来说,在使用前必须显式地为其赋值,否则编译时不通过
- 对于同时被static和final修饰的常量,必须在声明的时候就为其显式地赋值,否则编译时不通过;而只被final修饰的常量则既可以在声明时显式地为其赋值,也可以在类初始化时显式地为其赋值,总之,在使用前必须为其显式地赋值,系统不会为其赋予默认零值
- 对于引用数据类型reference来说,如数组引用、对象引用等,如果没有对其进行显式地赋值而直接使用,系统都会为其赋予默认的零值,即null
- 如果在数组初始化时没有对数组中的各元素赋值,那么其中的元素将根据对应的数据类型而被赋予默认的零值
2.4 连接之解析
当通过准备阶段之后,JVM 针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符 7 类符号引用(符号引用就是一组符号来描述目标,可以是任何字面量)进行解析。这个阶段的主要任务是将其在常量池中的符号引用替换成直接其在内存中的直接引用(直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄)
2.5 初始化
到了初始化阶段,用户定义的 Java 程序代码才真正开始执行。在这个阶段,JVM 会根据语句执行顺序对类对象进行初始化
在Java中对类变量进行初始值设定有两种方式:
- 声明类变量是指定初始值
- 使用静态代码块为类变量指定初始值
JVM初始化步骤
- 假如这个类还没有被加载和连接,则程序先加载并连接该类
- 假如该类的直接父类还没有被初始化,则先初始化其直接父类
- 假如类中有初始化语句,则系统依次执行这些初始化语句
类初始化时机
- 创建类的实例,也就是new的方式
- 访问某个类或接口的静态变量,或者对该静态变量赋值
- 调用类的静态方法
- 反射(如 Class.forName(“com.nchu.Test”))
- 初始化某个类的子类,则其父类也会被初始化
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
2.6 使用
当 JVM 完成初始化阶段之后,JVM 便开始从入口方法开始执行用户的程序代码
2.7 卸载
当用户程序代码执行完毕后,JVM 便开始销毁创建的 Class 对象,最后负责运行的 JVM 也退出内存。这个阶段也只是了解一下就可以
Java虚拟机结束生命周期的几种情况:
- 执行了 System.exit()方法
- 程序正常执行结束
- 程序在执行过程中遇到了异常或错误而异常终止
- 由于操作系统出现错误而导致Java虚拟机进程终止
3. 类加载器
其中BootstrapLoader(引导类加载器)是用C语言实现的,找不到一个确定的返回父Loader的方式,获取该加载器的时候是返回null
AppClassLoader和ExtClassLoader都是在sun.misc.Launcher里定义的,启动过程如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public Launcher() {
Launcher.ExtClassLoader var1;
try {
var1 = Launcher.ExtClassLoader.getExtClassLoader();
} catch (IOException var10) {
throw new InternalError("Could not create extension class loader", var10);
}
try {
this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
} catch (IOException var9) {
throw new InternalError("Could not create application class loader", var9);
}
Thread.currentThread().setContextClassLoader(this.loader);
String var2 = System.getProperty("java.security.manager");
if (var2 != null) {
SecurityManager var3 = null;
if (!"".equals(var2) && !"default".equals(var2)) {
try {
var3 = (SecurityManager)this.loader.loadClass(var2).newInstance();
} catch (IllegalAccessException var5) {
} catch (InstantiationException var6) {
} catch (ClassNotFoundException var7) {
} catch (ClassCastException var8) {
}
} else {
var3 = new SecurityManager();
}
if (var3 == null) {
throw new InternalError("Could not create SecurityManager: " + var2);
}
System.setSecurityManager(var3);
}
}
注意:这里父类加载器并不是通过继承关系来实现的,而是采用组合实现的
Java虚拟机的角度,只存在两种不同的类加载器:
- 启动类加载器:它使用C++实现(这里仅限于Hotspot,也就是JDK1.5之后默认的虚拟机,有很多其他的虚拟机是用Java语言实现的),是虚拟机自身的一部分
- 所有其它的类加载器:这些类加载器都由Java语言实现,独立于虚拟机之外,并且全部继承自抽象类 java.lang.ClassLoader,这些类加载器需要由启动类加载器加载到内存中之后才能去加载其他的类
Java开发人员的角度,大致分为三种:
- 启动类加载器: BootstrapClassLoader,负责加载存放在 JDK\jre\lib(JDK代表JDK的安装目录,下同)下,或被 -Xbootclasspath参数指定的路径中的,并且能被虚拟机识别的类库(如rt.jar,所有的java.开头的类均被 BootstrapClassLoader加载)。启动类加载器是无法被Java程序直接引用的
- 扩展类加载器: ExtensionClassLoader,该加载器由 sun.misc.Launcher$ExtClassLoader实现,它负责加载 JDK\jre\lib\ext目录中,或者由 java.ext.dirs系统变量指定的路径中的所有类库(如javax.开头的类),开发者可以直接使用扩展类加载器
- 应用程序类加载器: ApplicationClassLoader,该类加载器由 sun.misc.Launcher$AppClassLoader来实现,它负责加载用户类路径(ClassPath)所指定的类,开发者可以直接使用该类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器
JVM类加载机制
- 全盘负责,当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
- 父类委托,先让父类加载器试图加载该类,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类
- 缓存机制,缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区寻找该Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区。这就是为什么修改了Class后,必须重启JVM,程序的修改才会生效
4. 类的加载
类加载有三种方式:
- 命令行启动应用时候由JVM初始化加载
- 通过Class.forName()方法动态加载,默认会执行初始化块
- 通过ClassLoader.loadClass()方法动态加载,不会执行初始化块
5. 双亲委派模型
双亲委派模型的工作流程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类
双亲委派机制:
- 当 AppClassLoader加载一个class时,它首先不会自己去尝试加载这个类,而是把类加载请求委派给父类加载器ExtClassLoader去完成
- 当 ExtClassLoader加载一个class时,它首先也不会自己去尝试加载这个类,而是把类加载请求委派给BootStrapClassLoader```去完成
- 如果 BootStrapClassLoader加载失败(例如在 $JAVA_HOME/jre/lib里未查找到该class),会使用 ExtClassLoader来尝试加载
- 若ExtClassLoader也加载失败,则会使用 AppClassLoader来加载,如果 AppClassLoader也加载失败,则会报出异常 ClassNotFoundException
双亲委派模型为防止内存中出现多份同样的字节码,保证Java程序安全稳定运行
补充
JAVA类包括五大成员:属性、方法、构造器、代码块、内部类
初始化块又被称为代码块,属于类中的第四大成员。本质上是一个方法,它也有方法体,但没有方法名,没有参数,没有返回值,而且也不是通过对象或类名显式调用,而是通过隐式调用,是构造器的一种补充
1
2
3
[修饰符]{
方法体;
}
- 修饰符只能是static,使用static修饰的初始化块称为静态初始化块,没有static的称为普通初初始化块
- 方法体中可以为任意逻辑语句,包含输入、输出、变量、运算等
优点:
- 和构造器很像,都是用域初始化信息
- 当多个构造器中有重复的语句,可以往上提取到初始化块中,提高代码的复用性
特点:
调用时机:
- 静态初始化块:加载类
- 普通初始化块:创建对象
静态初始化块只会调用一次,随着类的加载而加载(因为类只加载一次)
普通初始化块可以调用多次,随着对象的创建而创建
一个类中可以有多个静态初始化块和多个普通初始化块
静态初始化块执行早于普通初始化块
同一个类型的初始化块的执行顺序取决于定义的先后顺序
执行顺序:
静态初始化块、静态属性初始化 > 普通初始化块、普通属性初始化 > 构造器
继承关系的执行顺序:
爷爷类 静态初始化块、静态属性初始化块 > 父亲类 静态初始化块、静态属性初始化块 > 儿子类 静态初始化块、静态属性初始化块 > 爷爷类 普通初始化块、普通属性初始化 > 构造器 > 父亲类 普通初始化块、普通属性初始化 > 构造器 > 儿子类 普通初始化块、普通属性初始化 > 构造器 >
静态初始化块中遵循静态成员的特点,只能直接访问静态成员
初始化位置:
普通的常量属性:初始化必须在声明时 或 构造器 或 普通代码块 静态的常量属性:初始化必须在声明时 或 静态代码块