浅谈Java类加载

二叶草 2020年2月14日09:48:06函数代码评论阅读模式

一切都是对象。 -《Java编程思想》

对象与类

在Java的全球里,一切都是目标。那麼是啥决策了某一类目标的外型与个人行为呢?换句话,是啥明确了目标的种类呢?参考答案就是说Class。实际上,在Java中,存有二种目标:案例目标和Class目标。每一类在运行时,其类型信息是通过java.lang.Class对象来描述的,而实例对象就通过java.lang.Class对象来创建的。
那么Java是如何让我们在运行时识别对象和类的信息的?答案是RTTI(运行时类型识别)。

RTTI

RTTI为(Run-Time Type Identification)的缩写,即运行时类型识别。RTTI,能够在JVM在运行时识别一个对象的类型和类的信息。 主要有两种方式,一种是“传统”的RTTI,它假定在编译时已经知道了所有的类型;另一种是“反射”机制,它允许JVM在运行时发现和使用类的信息。

类加载过程

每当我们新写并编译了一个新类,就会被JVM生成一个Class对象加载到JVM中。无论你创建(new)了多少这个类的实例对象,都只会有一个Class对象。
JVM在运行的过程中,每遇到一个新类(rt.jar中的类,JVM会预先加载),都会先去方法区去查找Class对象,若找到,就直接拿来用;若未找到,则会进行类加载,根据类名查找.class文件,并将其加载到方法区,进行初始化(这里表示类加载是懒加载)。一旦这个类的Class对象被载入到内存,就表示完成了类的初始化,就可以用来创建这个类的实例对象。

类的生命周期包括以下七个阶段:

加载 -> 连接(验证 -> 准备 -> 解析) -> 初始化 -> 使用 -> 卸载

每个阶段所需要完成的工作如下:

  1. 加载(Loading):通过类的完全限定名,从数据源获取二进制数据流,将其代表的静态存储结构解析为方法区的运行时数据结构,最后创建一个表示该类的Class对象。其中数据源由类加载器决定的,可以是本地文件(包括Class文件、Jar文件)、远程网络、代理$Proxy等。
  2. 连接(Linking):连接分为三个阶段;
  • 验证(Verification):验证Class文件的字节流的信息是否符合当前JVM的要求,并且不会危害JVM自身的安全;
  • 准备(Preparation):在方法区中,为类成员变量分配内存,并设置类成员变量的默认初始值(通常为数据类型的零值);
  • 解析(Resolution):将常量池中的符号引用替换为直接引用;
  1. 初始化(Initializing):执行类构造器<clinit>()方法的过程,它执行是类成员变量的显示初始化和静态代码块。
  2. 使用(Using):对类生成实例对象,通常表现为new Object();
  3. 卸载(Unloading):通常为JVM进行一系列的内存回收操作;

其中类加载包括了加载、验证、准备、解析、初始化五个阶段。其中加载、验证、准备、初始化这四个阶段的顺序是确定的。但是解析阶段则不一定,它在某些场景下可以在初始化阶段之后再进行。

类数据初始化顺序

上面介绍了类加载的整个流程,那类数据(包括类成员变量、静态代码块、非静态变量、非静态代码块)的初始化顺序是怎么样的呢?

  1. 先初始化父类再初始化子类;
  2. 先加载静态变量、静态代码块,再加载非静态变量、非静态代码块;其中每种类型的数据,都是按照声明顺序加载并初始化的。其中静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以正常赋值,但不能访问。
  3. 加载静态数据:
  • 把.class文件中的所有静态数据加载到方法区下的静态区域内;(加载阶段)
  • 静态数据加载完成之后,对所有的静态变量进行默认初始化;(准备阶段)
  • 静态变量默认初始化完成之后,再进行显式初始化;(初始化阶段)
  • 当静态区域下的所有静态变量显式初始化完后,执行静态代码块;(初始化阶段)
  • 加载非静态数据:把.class文件中的所有非静态变量、非静态代码块加载到方法区下的非静态区域内。(只加载,并未初始化执行)

执行完之后,整个类的数据初始化就完成了。以上所有步骤,只会加载并初始化一次,无论调用多少次Class.forName加载类。
其中静态数据是在类加载时自动执行的,不加载类不执行该类的静态数据;
非静态数据是在创建对象时自动执行的,不创建对象不执行该类的非静态数据。
下面来看下一个例子,就明白类的初始化顺序了。

class Parent {
public static Map classObjectOne = new HashMap() {{
        System.out.println("Parent 类成员变量一");
    }};

public Map instanceObjectOne = new HashMap() {{
        System.out.println("Parent 实例成员变量一");
    }};

public static Map classObjectTwo = new HashMap(){{
        System.out.println("Parent 类成员变量二");
    }};

public Map instanceObjectTwo = new HashMap() {{
        System.out.println("Parent 实例成员变量二");
    }};

    {
        System.out.println("Parent 非静态代码块一");
    }

static {
        System.out.println("Parent 静态代码块一");
    }

    {
        System.out.println("Parent 非静态代码块一");
    }

static {
        System.out.println("Parent 静态代码块二");
    }

public Parent() {
        System.out.println("Parent 无参构造器");
    }

public Parent(String arg) {
        System.out.println("Parent 有参构造器 " + arg);
    }
}

class Child extends Parent {

public Map instanceObjectOne = new HashMap(){{
        System.out.println("Child 实例成员变量一");
    }};

public Map instanceObjectTwo = new HashMap(){{
        System.out.println("Child 实例成员变量二");
    }};

public static Map classObjectOne = new HashMap(){{
        System.out.println("Child 类成员变量一");
    }};

public static Map classObjectTwo = new HashMap(){{
        System.out.println("Child 类成员变量二");
    }};

    {
        System.out.println("Child 非静态代码块一");
    }
    {
        System.out.println("Child 非静态代码块二");
    }

static {
        System.out.println("Child 静态代码块一");
    }
static {
        System.out.println("Child 静态代码块二");
    }

public Child() {
        System.out.println("Child 无参构造器");
    }

public Child(String arg) {
super(arg);
        System.out.println("Child 有参构造器 " + arg);
    }
}
public class LoadClassTest {
@Test
public void testLoadClass() throws ClassNotFoundException {
        Class.forName("com.example.demo.reflect.Child");
        Class.forName("com.example.demo.reflect.Child");
        Class.forName("com.example.demo.reflect.Parent");
        Class.forName("com.example.demo.reflect.Parent");
    }
}

输出结果为:

Parent 类成员变量一
Parent 类成员变量二
Parent 静态代码块一
Parent 静态代码块二
Child 类成员变量一
Child 类成员变量二
Child 静态代码块一
Child 静态代码块二

从上面的结果可以看出来类的初始化顺序:

  1. 先加载父类,再加载子类;
  2. 按声明顺序加载类成员变量;
  3. 按声明顺序执行静态代码块;
  4. 无论执行多少类加载,都只会执行初始化一次;

对象创建顺序

上面介绍了类的数据初始化过程,那对象创建的过程是怎么样的呢?即new Object()这一个操作发生了什么?下面一起来看下创建对象的整体流程。

  1. 若是第一次创建对象,先执行上面的类加载、初始化流程;
  2. 在堆内存中为其开辟一块内存空间,并为其分配地址;
  3. 把对象的所有非静态成员加载到所开辟的空间下;
  4. 所有的非静态成员变量加载完成之后,对所有非静态成员变量进行默认初始化;
  5. 完成非静态成员变量默认初始化后,再调用构造器,构造函数的执行分为四个部分。
  • 执行super(),即调用父类默认构造函数(若指定了非默认有参构造函数,则执行);
  • 显示初始化,对所有非静态成员变量进行初始化;
  • 执行非静态代码块;
  • 执行构造函数中的代码;
  1. 在整个构造函数执行完并弹栈后,把空间分配的地址赋给引用对象,这样整个对象创建过程就完成了。

同样,我们通过下面的例子,看下整体执行流程。

    @Test
public void testCreateInstance() {
        Child childOne = new Child();
        System.out.println("-----------------------");
        Child childTwo = new Child("我是一个参数");
    }

得到的输出结果为:

Parent 类成员变量一
Parent 类成员变量二
Parent 静态代码块一
Parent 静态代码块二
Child 类成员变量一
Child 类成员变量二
Child 静态代码块一
Child 静态代码块二
Parent 实例成员变量一
Parent 实例成员变量二
Parent 非静态代码块一
Parent 非静态代码块一
Parent 默认构造器
Child 实例成员变量一
Child 实例成员变量二
Child 非静态代码块一
Child 非静态代码块二
Child 默认构造器
-----------------------
Parent 实例成员变量一
Parent 实例成员变量二
Parent 非静态代码块一
Parent 非静态代码块一
Parent 默认构造器
Child 实例成员变量一
Child 实例成员变量二
Child 非静态代码块一
Child 非静态代码块二
Child 有参构造器 我是一个参数

从上面的结果可以看出来对象的创建顺序:

  1. 若是第一次创建,则先加载父类的Class,再加载子类的Class;
  2. 先初始化父实例对象,再初始化子实例对象;
  3. 按声明顺序加载实例成员变量;
  4. 按声明顺序执行非静态代码块;
  5. 执行构造函数的代码;
  6. 每创建一次对象,都只会执行步骤2-5;

以上内容参考文章 Java类的加载和对象创建流程的详细分析[1]。

类加载机制

Class文件是由JVM的类加载器子系统加载到内存的,可以看到类加载器子系统在JVM外部。这种设计让Java更加灵活,可以用不同的类加载器来加载不同来源Class文件。浅谈Java类加载

JVM定义了三种类加载器:

启动类加载器(Bootstrap Class Loader)
启动类加载器是用来加载JVM正常工作所需要的基本系统类,这些系统类在目录{JRE_HOME}/lib下。我们常用的rt.jar的类,在JVM启动时,由启动类加载器将其加载JVM的方法区。由于启动类加载器是由C++底层代码实现的,因此无法通过Java代码引用。但是可以查询某个类是否被其加载过。

扩展类加载器(Extension Class Loader)
扩展类加载器是用来加载Java的扩展类,扩展类一般会放在目录{JRE_HOME}/lib/ext 下,由sun.misc.Launcher$ExtClassLoader实现。

应用类加载器(Applicatoin Class Loader)
应用类加载器是用于加载用户类路径ClassPath所指定的类库,由sun.misc.Launcher$AppClassLoader实现。

当然用户也可以自定义类加载器,叫做自定义类加载器(User Class Loader),自定义类加载器必须继承java.lang.ClassLoader类。

双亲委派模型

上面提到的四种类加载器,它们的层次关系如下所示。这种层次关系,也称为类加载器的双亲委派模型(Parents Delegation Model)。

浅谈Java类加载

双亲委派模型要求要求除了顶层的启动类加载器外,其余的类加载都应该有自己父类加载器。但是这种父子关系一般不是以继承的方式来实现,而是以组合的方式来复用父类加载器的代码。
下面通过代码来看下其关系:

public class ClassLoaderTest {
@Test
public void testClassLoaderStructure() {
// 获取当前的类加载器
        ClassLoader classLoader = this.getClass().getClassLoader();
// 当前类加载器为应用类加载器
        Assert.assertEquals("sun.misc.Launcher$AppClassLoader", classLoader.getClass().getName());
// 应用类加载器的父类加载器为扩展类加载器
        Assert.assertEquals("sun.misc.Launcher$ExtClassLoader", classLoader.getParent().getClass().getName());
// 由于启动类加载器在Java代码中无法访问到,故扩展类加载器的父类加载器为null
        Assert.assertNull(classLoader.getParent().getParent());
// 系统类是由启动类加载器加载的,故其类加载器也为null
        Assert.assertNull(Integer.class.getClassLoader());
    }
}

从上可以看到,应用类加载器(AppClassLoader)的父类加载器为扩展类加载器(ExtClassLoader)。而由于启动类加载器无法通过Java代码访问到,故扩展类加载器的父加载器为null,那么扩展类加载器就是我们可以接触到的最顶级的类加载器了。而像Integer.class这样的系统类是由启动类加载器加载的,故其类加载器也为null。

从《深入理解Java虚拟机》一书中了解到,双亲委派模型的工作过程:一个类加载器收到类加载的请求后,它首先不会自己去加载这个类,而是把这个请求委派给父类加载器去加载,每一层的类加载器都是如此。因此所有的加载请求最终都会传递到顶层的启动类加载器(无父类加载器)中,只有当父类加载器反馈说自己无法完成这个加载请求的时候(它的搜索范围内,没有找到需要加载的类),子类加载器才会尝试自己去加载。

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。比如类java.lang.Object,它是一个系统类,存放在rt.jar中,无论哪个一个类加载器要加载这个类,最终都是委派给启动类加载器进行加载,因此Object在程序的各种类加载器环境下都是同一个类,且只会加载一次。这样就保证了程序运行的安全性和稳定性。

另外,双亲委派模型中的“双亲”不是指子加载器有两个父类加载器。除了启动类加载器无父类加载器之外,其它类加载器有且只有一个父类加载器(注意ExtClassLoader的父类加载器为null)。只是说除了启动类加载器外,所有的类加载器都有一个能力,能够判断一个类是否被启动类加载器加载过(不是委托启动类加载器加载)。若加载过,可以直接返回对应类的Class对象;若未加载,则返回null。

例如在应用类加载器AppClassLoader加载类的过程中,有两个角色:

  1. 父类加载器(Parent ClassLoader,这里为ExtClassLoader):子类加载器可以委派父加载器尝试加载类;
  • 如父类加载器加载成功,则子类加载器直接返回父类加载器的结果;
  • 如父类加载器加载失败,则子类加载器尝试自己加载;
  1. 启动类加载器(Bootstrap ClassLoader): 子类加载器只能判断某个类是否被引导类加载器加载的系统类(通过native方法进行查找),而不能委托它加载类。

下面我们来分析ClassLoader的类加载的源码:

    protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
    {
// 加锁,保证线程安全
synchronized (getClassLoadingLock(name)) {
// 先检查类是否被当前类加载器加载过,若加载过,返回对应的Class对象
            Class<?> c = findLoadedClass(name);
// 若未加载过
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
// 若有父类加载器
// 尝试先让父类加载器加载
                        c = parent.loadClass(name, false);
                    } else {
// 没有父加载器,扩展类加载器就是走的这一步
// 检查是否被启动类加载器加载过,若加载过,则返回对应的Class对象
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
                }
// 以上都没有加载过
if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
// 尝试自己加载,实现自定义类加载器,主要是重写这个方法
                    c = findClass(name);

// this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
// 是否需要解析类
if (resolve) {
                resolveClass(c);
            }
return c;
        }
    }
protected final Class<?> findLoadedClass(String name) {
if (!checkName(name))
return null;
return findLoadedClass0(name);
    }

private Class<?> findBootstrapClassOrNull(String name) {
if (!checkName(name)) return null;
return findBootstrapClass(name);
    }
protected Class<?> findClass(String name) throws ClassNotFoundException {
throw new ClassNotFoundException(name);
    }

以上就是ClassLoader默认的加载类的逻辑,整理其执行流程图如下:

浅谈Java类加载

如果我们想自定义一个类加载器,需要继承ClassLoader,这样能保证新写出来的类加载器是符合双亲委派规则。并重写findClass()方法,这样就可以从指定数据源,获取二进制字节流;然后通过defineClass()将二进制字节流转换为Class实例。

本文来源于:浅谈Java类加载-变化吧门户
特别声明:以上文章内容仅代表作者本人观点,不代表变化吧门户观点或立场。如有关于作品内容、版权或其它问题请于作品发表后的30日内与变化吧联系。

  • 赞助本站
  • 微信扫一扫
  • weinxin
  • 加入Q群
  • QQ扫一扫
  • weinxin
二叶草
Go语言中的常量 函数代码

Go语言中的常量

1 概述 常量,一经定义不可更改的量。功能角度看,当出现不需要被更改的数据时,应该使用常量进行存储,例如圆周率。从语法的角度看,使用常量可以保证数据,在整个运行期间内,不会被更改。例如当前处理器的架构...
Go语言的接口 函数代码

Go语言的接口

Go语言-接口 在Go语言中,一个接口类型总是代表着某一种类型(即所有实现它的类型)的行为。一个接口类型的声明通常会包含关键字type、类型名称、关键字interface以及由花括号包裹的若干方法声明...
Go语言支持的正则语法 函数代码

Go语言支持的正则语法

1 字符 语法 说明 . 任意字符,在单行模式(s标志)下,也可以匹配换行 字符类 否定字符类 d Perl 字符类 D 否定 Perl 字符类 ASCII 字符类 否定 ASCII 字符类 pN U...
Go语言的包管理 函数代码

Go语言的包管理

1 概述 Go 语言的源码复用建立在包(package)基础之上。包通过 package, import, GOPATH 操作完成。 2 main包 Go 语言的入口 main() 函数所在的包(pa...

发表评论