Java基础:类加载机制

  之前的《java基础:内存模型》当中,我们大体了解了在java当中,不同类型的信息,都存放于java当中哪个部位当中,那么有了对于堆、栈、方法区、的基本理解以后,今天我们来好好剖析一下,java当中的类加载机制(其实就是在美团的二面的时候,被面试官问的懵逼了,特地来总结一下,免得下次再那么丢人 T-T)。

  我们都知道,在java语言当中,猴子们写的程序,都会首先被编译器编译成为.class文件(又称字节码文件),而这个.class文件(字节码文件)中描述了类的各种信息,字节码文件格式主要分为两部分:常量池和方法字节码。那么java的编译器生成了这些.class文件之后,又是怎么将它们加载到虚拟机当中的呢?接下来我们就好好讨论一下这个事情。

  参考连接:http://www.cnblogs.com/xrq730/p/4844915.html  (感觉这个博主写的很适合greenHand看,所以就参考着自己总结了一份)

类的生命周期:

  首先我们来看看,在java当中一个类的完整的生命周期,主要包括了以下七个部分:1.加载、2.验证、3.准备、4.解析、5.初始化、6.使用、7.卸载。在这7个阶段当中,前5个阶段加起来,就是类加载的全

Java基础:类加载机制

过程,如图所示。而验证、准备、解析,三个阶段又可以被称为连接阶段。除此之外,类加载过程当中的五个阶段,除了解析阶段,其他都是顺序开始的,但不是顺序执行的,也就是说在过程当中是可以并行的,比如在验证开始后,还未结束,可能就会开始准备阶段。而解析阶段不一定在这个顺序当中的原因是因为,它在某些情况下可以初始化阶段之后在开始,这是为了支持Java语言的运行时绑定(也称为动态绑定)。

注意:这里出现了一个新概念,叫绑定,简单解释了一下什么叫绑定吧,在java当中的绑定定义为:指的是把一个方法的调用与方法所在的类(方法主体)关联起来,主要分为了静态绑定和动态绑定。

静态绑定:即在程序执行方法之前就已经被绑定,简单来说再编译期就进行绑定,在java当中被final、static、private修饰的方法,以及构造方法都是属于静态绑定,即编译期绑定。

动态绑定:又称运行时绑定,在运行时根据具体对象的类型进行绑定,在java当中,几乎除了满足静态绑定的方法之外,所有方法都是动态绑定的(java当中运行时多态的重要实现根据)

1.加载

  在java的类加载的过程当中的加载,一般分为两种:第一种,预加载,指的是虚拟机启动的时候,加载JDK路径下的   lib/rt.jar   下的.class文件,在这个jar包当中包含着基础的java.lang.*、java,util.*等基础包,他们随着虚拟机一起被加载。第二种,运行时加载,指虚拟机在需要用到某一个类的时候,会先去内存当中查看有没有这个类对应的.class文件,如果没有会按照类的全限定名来加载这个类。而在我们的文章当中,主要讨论第二种运行时加载。

注意:这里提到了一个全限定名,指的是包含着这个类所在的包的名称,即比如   bjtu.wellhold.test.testclass 这样的名称,有包含绝对路径的含义。

其实在加载阶段,主要做了三件事情:

1.获取.class文件的二进制字节流。

2.将类信息,静态变量,方法字节码,常量等这些.class文件中的内容放入到方法区当中(在《java基础:内存模型》当中已经讲解过)

3.在堆当中生成一个代表这个.class文件的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。(HotSpot虚拟机比较特殊,这个Class类放在了方法区当中)

而程序员来说,最可控的地方就在于第一件事,由于虚拟机并没有规定二进制字节流要从哪而来,所以再这个部分,二进制字节流的来源可以来自以下源:

1)从jar、war格式等来,

2)从网络当中来,如Applet

3)运行时计算得到,如动态代理技术。

4)由其他文件生成,如JSP。

2.验证

  由于在加载阶段的过程当中,并没有严格规定二进制字节流需要通过Java源码编译而来,所以验证阶段的主要目的是在加载阶段获取得到的二进制字节流中包含的信息符合当前虚拟机的要求,并且不会导致虚拟机收到危害。主要分为了以下几种形式的验证:

1.文件格式验证:提一点,也许在安装某个开源中间件的时候,需要JDK多少版本以上,这是因为在文件格式验证的过程当中,有一个部分就是对.class文件的版本号,高版本的JDK可以向下兼容以前版本的.class文件,但是低版本的JDK则不能运行高版本的.class文件,即使文件格式为发生任何变化,虚拟机也会拒绝执行。

2.元数据验证。

3.字节码验证。

4.符号引用验证。

注意:验证阶段是非常重要的,但不是必须的,它对程序运行期没有影响,如果所引用的类经过反复验证,那么可以考虑采用-Xverifynone参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间。

3.准备

  这个阶段当中,会正式的为类当中那些被 static修饰的变量分配内存,并且设置其初始值,而这些变量都会存在方法区当中。

注意:

1)这个时候分配内存的,都是静态变量,即被Static修饰的变量,而非实例变量。

2)这个阶段赋初始值的变量指的是那些不被final修饰的static变量,比如"public static int value = 123;",value在准备阶段过后是0而不是123,给value赋值为123的动作将在初始化阶段才进行;比如"public static final int value = 123;"就不一样了,在准备阶段,虚拟机就会给value赋值为123。

4.解析

  解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。主要针对   类或接口的全限定名、字段的名称和描述符、方法的名称和描述符       的符号引用进行。

注意:这里提到了一个符号引用和直接引用的概念,简单解释一下。

1.符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language来表示Language类的地址。

2.直接引用:1)直接指向目标的指针,比如Class对象、static变量,static方法,都是指向方法区的指针。2)相对偏移量(从对象的映像开始算起到这个实例变量位置的偏移量。实例方法的直接引用可能是方法表的偏移量)。3)一个能间接定位到目标的句柄

5.初始化

  初始化是类加载过程当中的最后一步,在这个过程当中才真正执行类中定义的java程序代码(或者说字节码),其实简单来说,初始化阶段做的事就是给static变量赋予用户指定的值以及执行静态代码块。它是一个执行类构造器<clinit>()方法的过程。

这里简单说明下<clinit>()方法的执行规则:

 1、<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句中可以赋值,但是不能访问。
2、<clinit>()方法与实例构造器<init>()方法(类的构造函数)不同,它不需要显式地调用父类构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此,在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。
3、<clinit>()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。
4、接口中不能使用静态语句块,但仍然有类变量(final static)初始化的赋值操作,因此接口与类一样会生成<clinit>()方法。但是接口鱼类不同的是:执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,只有当父接口中定义的变量被使用时,父接口才会被初始化。另外,接口的实现类在初始化时也一样不会执行接口的<clinit>()方法。
5、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确地加锁和同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,那就可能造成多个线程阻塞,在实际应用中这种阻塞往往是很隐蔽的。
 

Java虚拟机规范严格规定了有且只有5种场景必须立即对类进行初始化,以下介绍4种场景,也称为对一个类进行主动引用(有一种由于参考的帖子没有写出,自己也就并没有得知)

1、使用new关键字实例化对象、读取或者设置一个类的静态字段(被final修饰的静态字段除外)、调用一个类的静态方法

2、使用java.lang.reflect包中的方法对类进行反射调用的时候

3、初始化一个类,发现其父类还没有初始化过的时候

4、虚拟机启动的时候,虚拟机会先初始化用户指定的包含main()方法的那个类

注意:通过数组定义引用类,不会触发此类的初始化

class A
{
public static int a=1;
static
{
a=2;
System.out.println("father class:"+a);
}
}
class B extends A
{
static{
System.out.println("this is the B");
}
public static int b=a;
} public class ClassLoad
{
static
{
System.out.println("this is the main");
}
public static void main(String[] args)
{
System.out.println("B class :"+B.b);
}
}

结合这个例子,我们来分析一下上述当中所说的主动引用和<clinit>()方法的规则,在这个例子当中,JVM首先会初始化ClassLoad这一个类,因为该类包含了main函数(主动引用第四种场景),之后在main函数当中,执行B.b这一行代码的时候,由于涉及到了对static变量的赋值,所以B类也会被初始化,但是并不是立即初始化,而是查看B所继承的父类,即A类是否被初始化(<clinit>规则2),这时候发现A类并没有被初始化,则首先初始化A类,所以可以看到上述初始化的顺序为:ClassLoad-》A-》B,运行结果如下:

this is the main
father class:2
this is the B
B class :2

 

自定义类加载器

  要自己实现类加载器之前,我们首先看看在java jdk1.8当中的ClassLoad是怎么实现loadClass方法的:

protected synchronized Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
//首先,先查找当前全限定名的类是否已经被加载。
Class c = findLoadedClass(name); //如果没有被加载
if (c == null)
{
try
{
//当前加载器还有父类加载器(如果父加载器不是null,不是Bootstrap
//ClassLoader),则通过委托父类去加载
if (parent != null)
{
c = parent.loadClass(name, false);
}
//一直递归找到最上级的父类加载器,再先通过父类去加载
else {
c = findBootstrapClass0(name);
}
}
//如果父类加载不到,则再通过自己来加载
catch (ClassNotFoundException e) {
c = findClass(name);
}
}
//根据需求解析。
if (resolve) {
resolveClass(c);
}
return c;
}

整个JDK的loadclass的源码和源码解读都在注释中体现了。那么我们要自己实现一个类加载器,可以有以下两种方式:

1、如果不想打破双亲委派模型,那么只需要重写findClass方法即可

2、如果想打破双亲委派模型,那么就重写整个loadClass方法

当然,我们自定义的ClassLoader不想打破双亲委派模型,所以自定义的ClassLoader继承自java.lang.ClassLoader并且只重写findClass方法。

首先我们做一个实体类,叫Person:

public class Person
{
public String toString()
{
return "I am a person, my name is " + name;
}
}

将这个实体类编译出来的.class文件放到D盘根目录下(eclipse当中的工程bin目录下,可以找到这个类的.class文件)

然后在手动编写一个自定义类加载器MyClassLoad,它继承了ClassLoad:

package wellhold.bjtu.classload;

import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.nio.ByteBuffer;
import java.nio.channels.Channels;
import java.nio.channels.FileChannel;
import java.nio.channels.WritableByteChannel; public class MyClassLoader extends ClassLoader
{
public MyClassLoader() {
// TODO Auto-generated constructor stub
}
public MyClassLoader(ClassLoader parent)
{
super(parent);
} @Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
File file = getClassFile(name);
try
{
byte[] bytes = getClassBytes(file);
Class<?> c = this.defineClass(name, bytes, 0, bytes.length);
return c;
}
catch (Exception e)
{
e.printStackTrace();
}
return super.findClass(name);
} private File getClassFile(String name)
{
File file=new File("d:/Person.class");
return file;
} private byte[] getClassBytes(File file) throws Exception
{
FileInputStream fis = new FileInputStream(file);
FileChannel fc = fis.getChannel();
ByteArrayOutputStream baos = new ByteArrayOutputStream();
WritableByteChannel wbc = Channels.newChannel(baos);
ByteBuffer by = ByteBuffer.allocate(1024); while (true)
{
int i = fc.read(by);
if (i == 0 || i == -1)
break;
by.flip();
wbc.write(by);
by.clear();
} fis.close(); return baos.toByteArray();
} }

主要包括了从磁盘当中读取.class文件,然后重写了findClass方法,方法当中通过defineclass方法,将io读取到的Byte流转换成Class对象。之后再看看我们的测试方法:

public class TestMyClassLoader {

    public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException {
// TODO Auto-generated method stub
MyClassLoader mcl = new MyClassLoader();
Class<?> c1 = Class.forName("wellhold.bjtu.classload.Person", true, mcl);
Object obj = c1.newInstance();
System.out.println(obj);
System.out.println(obj.getClass().getClassLoader());
} }

在测试方法当中,通过反射当中的forName方法,指定类的全限定名和类加载器,并且将初始化设定为TRUE,加载到类后,打印:

this is the Person
wellhold.bjtu.classload.MyClassLoader@6d06d69c

说明我们的类被成功的加载进来了。自定义类加载器完成了任务。

上一篇:java基础之反射机制


下一篇:java基础-String不可变的好处