说到类加载机制,又不得不提Java代码执行过程,源码(.java)文件被编译成字节码(.class)文件,再由Jvm进行后续处理。其实这个后续处理过程,就是JVM的类加载机制,简单来说,就是把.class文件装载到内存,进行校验、解析、转换和初始化,最终形成可以被虚拟机直接使用的Java类型。
这一个过程就是类加载的生命周期,类加载的生命周期总共分为7个阶段:
加载、验证、准备、解析、初始化、使用和卸载。其中验证、准备、解析三个步骤又可统称为连接。类加载机制包括了前五个阶段,即加载、验证、准备、解析、初始化。
1)加载(Loading)
从字面意思就可以理解,该步的主要目的就是将字节码从不同的数据源(class文件、jar包、war包,甚至网络、其它文件生成[比如将JSP文件转换成对应的Class类])转化为二进制文件加载到内存中,并生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的入口。
2)验证(Verification)
这一阶段的主要目的就是对二进制文件进行校验,校验是否符合Jvm字节码规范,校验是否会危害Jvm安全,简单来说,就是做各种检查,主要包括:
确保二进制字节流格式是否规范;
是否所有方法都遵守访问控制关键字的限定;
方法调用的参数个数和类型是否正确;
确保变量在使用之前是否被正确初始化;
检查变量是否被赋予恰当类型的值。
3)准备(Preparation)
JVM会在这个阶段对类变量(静态变量,即static修饰)分配内存并设置类变量的初始值(对应数据类型的默认初始值,如0、0L、null、false等),即在方法区中分配这些变量所使用的内存空间。注意这里所说的初始值概念,比如一个类变量定义为:
public int aa = 10;
public static int bb = 66;
public static final int cc = 88;
aa不会被分配内存,而bb会分配内存,但bb的初始值是0而不是66,需要注意的是,static修饰的量是类变量,也叫做静态变量,而static final修饰的被称作为常量,所以cc的初始值是88。
4)解析(Resolution)
该阶段将常量池中的符号引用转化为直接引用。
符号引用:就是class文件中的CONSTANT_Class_info、CONSTANT_Field_info、CONSTANT_Method_info等类型的常量。
在编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如我们有一个com.test类引用了阿里巴巴的com.aliyun类,编译时Test类并不知道阿里巴巴类的实际内存地址,因此只能使用符号com.aliyun。
直接引用:通过对符号引用进行解析,找到引用的实际内存地址。
5)初始化(Initialization)
初始化阶段是执行类构造器方法的过程,即真正执行类中定义的Java程序代码。
比如:String xiaoming = new String("need a Girlfriend");
使用new实例化一个String对象,此时就会调用String类的构造方法对xiaoming进行实例化。
说完类加载过程,就不得不说类加载器,什么是类加载器?
还记得使用JdbcTemplate连接数据库过程吗?我们把数据库连接封装为工具类DruidUtils方法,该方法就用到了类加载器:
import com.alibaba.druid.pool.DruidDataSourceFactory;
import javax.sql.DataSource;
import java.io.InputStream;
import java.sql.Connection;
import java.sql.SQLException;
import java.util.Properties;
/**
* 提供连接
*/
public class DruidUtils {
private static DataSource dataSource = null;
static { // 必须优先执行 只执行一次
try {
// 需要一个文件流
InputStream is = DruidUtils.class.getClassLoader().getResourceAsStream("db.properties");
// 创建配置文件对象
Properties props = new Properties();
props.load(is);
// 核心类
dataSource = DruidDataSourceFactory.createDataSource(props);
} catch (Exception e) {
e.printStackTrace();
}
}
/**
* 返回数据源方法
*
* @return
*/
public static DataSource getDataSource() {
return dataSource;
}
/**
* 提供连接的方法
*
* @return
*/
public static Connection getConnection() throws SQLException {
return dataSource.getConnection();
}
}
其中,class.getClassLoader()就是类加载器的标志了。Jvm设计之初就把加载动作放到外部实现,以便让应用程序决定如何获取所需的类,所以提供了3种类加载器:
1)启动类加载器(Bootstrap ClassLoader):加载jre/lib包下面的jar文件,比如说常见的rt.jar。
(java.time.*、java.util.*、java.nio.*、java.lang.*、java.text.*、java.sql.*、java.math.*等等都在rt.jar包下)
import java.net.URL;
/**
* 该程序可以获得根类加载器所加载的核心类库,
* 并会看到本机安装的Java环境变量指定的jdk中提供的核心jar包路径
*
*/
public class ClassLoaderTest {
public static void main(String[] args) {
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
for (URL url : urls) {
System.out.println(url.toExternalForm());
}
}
}
2)扩展类加载器(Extension or Ext ClassLoader):加载jre/lib/ext包下面的jar文件。由Java语言实现,父类加载器为null。
3)应用类加载器(Application or App ClasLoader):根据程序的类路径(classpath)来加载Java类。由Java语言实现,父类加载器为ExtClassLoader。
类加载器加载Class大致要经过如下8个步骤:
1、检测此Class是否曾载入过(缓冲区中是否有此Class),如果有直接进入第8步,否则进入第2步。
2、如果没有父类加载器,则要么Parent是根类加载器,要么本身就是根类加载器,则跳到第4步,如果父类加载器存在,则进入第3步。
3、请求使用父类加载器去载入目标类,如果载入成功则跳至第8步,否则接着执行第5步。
4、请求使用根类加载器去载入目标类,如果载入成功则跳至第8步,否则跳至第7步。
5、当前类加载器尝试寻找Class文件,如果找到则执行第6步,如果找不到则执行第7步。
6、从文件中载入Class,成功后跳至第8步。
7、抛出ClassNotFountException异常。
8、返回对应的java.lang.Class对象。
如果以上3种类加载器不能满足要求,我们还可以自定义类加载器(继承 java.lang.ClassLoader
类),它们的层级关系如图:
这种层次关系就叫作双亲委派模型:如果一个类加载器收到了类加载请求,他首先不会尝试自己去加载这个类,而是把这个请求委派给父类去完成,父类再委派父类,一直到最顶层的类加载器,因此所有的加载请求都应该传送到启动类加载器。只有当父类加载器反馈自己无法完成这个请求的时候(在它的加载路径下没有找到所需加载的Class),子类加载器才会尝试自己去加载。
采用双亲委派的好处是Java类随着它的类加载器一起具备了一种带有优先级的层次关系,保证Java程序的稳定性。比如加载位于rt.jar包中的类java.lang.Object,不管是哪个加载器加载这个类,最终都是委托给顶层的启动类加载器进行加载,这样就保证了使用不同的类加载器最终得到的都是同样一个Object 对象。
所以,如果两个类的加载器不同,即使两个类来源于同一个字节码文件,那这两个类就必定不相等——双亲委派模型能够保证同一个类最终会被特定的类加载器加载。