Java 类加载机制

咕咚 于 2015-10-20 发布


作为一个 Java 程序员,我们写过很多 Java 类,那他们具体是怎么运行起来的呢? 一开始,我也没有去关心过这个问题,或者说是自己没有在这方面思考过,但是后来觉得,既然每天都在做这些事,为什么不深入了解下具体的原理逻辑呢。知其然还应该知其所以然,作为一个有追求程序员,我们应该对自己有更高的要求。博文是自己对 Java 类加载的一些认识,如有错误欠缺,欢迎指正补充。

这里,我们在开始主题之前,先梳理一下上面提出的这个问题。

首先举个简单的例子,来引出我们的问题

比如,现在需要我们编写一个 Java 类 Person,他有一个包名比如说 com.gudong.demo, 然后这个类会有一个修饰符 public,具体代码就应该是下面的样子,

package com.gudong.demo
public class Person{

}

接着类里面我们会定义一些成员变量,比方说,定义一个 int 型的 age 来表示 Person 的年龄

private int age;

也有可能定义一个类属性,比如说人的最大年龄

public static final int MAX_AGR = 122;//from wiki

接着我们还会定义一些方法,比如 main 方法

public static void main(String[]args){
     System.out.println("hello world!");
}

还有可能定义一些内部类

static class Area{
    private String name;
}

在一个类里,我们还可以定义除上面所说的很多种数据。

当我们写完一个类,我们可能做的就是运行了,点击运行按钮,我们的程序就可以按照我们预期的方式执行了,这里一定要明白一点,因为我们大多数时候用的是 IDE,所以这里省去了自己编译的步骤,如果你使用命令行进行编写代码,那么你一定知道, 在执行 Java 代码前,一定要先执行 javac 命令,也就是编译命令。 javac 全称应该叫做 java compile 如果没猜错的话。

所以我们写完代码,首先要进行应该是 编译,那么编译会做什么事呢?

编译会把我们用 java 写的代码转化成我们经常听说的字节码,字节码是什么鬼?

说到这里,是时候搬出 Java 的一大特性了 - 跨平台。想必很多人都知道这个特性,你会发现工作中,你同事用的是 Mac 在写 Java 代码,而你用着 windows 在开发,最终你们写完的代码, 不论在 MAC 上打包还是 windows 上打包,最终打包出来的 apk,运行起来都是一个样式,根本没有任何差异,其原因就在于不论是 MAC 上的源码还是 Window 上的源码, 在运行之前都要经过 Java 虚拟机进行编译,不同的开发平台,Oracle 公司都有一个对应的 Java 虚拟机实现,但是经过虚拟机编译后的源码都会变成遵循同样规范的一个文件, 这就是我上面说的字节码。

字节码是一个和平台无关的文件,不论任何平台,任何 Java 虚拟机,编译 Java 源码后生成的字节码文件,都应该是一致的, 字节码文件具体就是经常在 build 目录下看到的以.class 结尾的文件

所以说到这里,如果你够牛 B, 你也可以按照 Java 虚拟机规范(这个规范是公布于众的,市面也有相关的中文书籍),实现自己的 Java 虚拟机,然后自己公司的代码, 用自己的编译器进行编译,这个听上去,确实刁刁的,不过要能做到 ,呵呵,我就不遐想了,目前的层次还差好多,而且也没什么意义,至少目前是,国内据说腾讯自己有搞,不知道具体如何。

到这里,关于字节码,应该有点概念了,我们写完的代码,经过编译,会变成已.class 结尾的 java 字节码文件。

具体的字节码,是一组以 8 位字节为基础的二进制流,它包含以下几个部分,具体字节码的数据存储格式都是按照 Java 虚拟机的规范来的, 任何 Java 虚拟机必须严格安装规范来,以确保不同的,Java 虚拟机编译出的字节码是格式、内容相同的文件。 关于字节码文件的结构,如下所述,下面这部分来自摘抄

魔数和class文件版本:类文件开头的四个字节被定义为CAFEBABE,只有开头为CAFEBABE的文件才可以被虚拟机接受,接下来四个字节为class文件的版本号,高版本JDK可以兼容以前版本的class文件,但不能运行以后版本的class文件。

常量池:可以理解为class文件中的资源仓库,它包含两大类常量:字面量和符号引用,字面量包含文本字符串,声明为final的常量值等,符号引用包含类和接口的全限定名,字段的名称和描述符,方法的名称和描述符。

访问标志:常量池结束后,紧接着两个字节表示访问标志,用于识别一些类或接口层次的访问信息,例如是否是public,是否是static等。

类索引,父类索引,和接口索引集合:类索引用来确定这个类的全限定名,父类为父类的全限定名,接口索引集合为接口的全限定名。

字段表集合:用于描述接口或者类中声明的变量,但不包含方法中的变量。

方法表集合:用于表述接口或者类中的方法。

属性表集合:class文件,字段表,方法表中的属性都源自这里。

经过编译,一个.java 文件已经变成了一个.class 字节码文件,下面要真真使用这个.class 时,虚拟机对这个文件又会做什么,这就是接下来的类加载。

类加载

首先从一个简单的实例开始。比如说,现在我们已经写好了自己 Person 类,并且编译成功,现在是 Person 类以 Person.class 的形式存在于计算机的一个地方(必然是自己的项目编译目录下)。

现在自己写一个 Test 类,通过使用 Person 来实例化一个对象,从而分析 Person 类的加载过程,如下

public class Test{
    //主方法
    public static void main(String[]args){

         Person p1 = new Person();
         p1.name = 大虾;

          System.out.println(p1 name is +p1.name);

         Person p2 = new Person();
         p2.name = 咕咚;

         System.out.println(p2 name is +p2.name);

         System.out.println(p1 max age +Person.MAX_AGE);
    }
}

可以看到,上面我们定义了两个 Person 对象 p1 和 p2。

对于 Test 类,他经过编译,然后运行,在第一次运行时,虚拟机也会先加载 Test.class 文件到内存,然后执行方法,暂且不细究 Test 的加载。

当执行 Main 方法,执行到第一条代码语句时,他看到这里需要 new 一个 Person,此时他会查看内存中是不是已经存在 Person.class 类对象,如果没有,就会根据 Person 的包路径,也就是具体的本地存储路径,然后找到 Person.class 的存储位置,然后虚拟机就会把这个字节码的二进制流加载到内存,具体类加载分为以下几个过程

加载

加载是类加载的第一个阶段,虚拟机要完成以下三个过程:

验证

目的是确保 class 文件字节流信息符合虚拟机的要求。

准备

为 static 修饰的变量赋初值,例如 int 型默认为 0,boolean 默认为 false。

解析

虚拟机将常量池内的符号引用替换成直接引用。

初始化

初始化是类加载的最后一个阶段,将执行类构造器 () 方法,注意这里的方法不是构造方法。 该方法将会显式调用父类构造器,接下来按照 java 语句顺序为类变量和静态语句块赋值。

在执行完成类加载的第一步初时,会在内存中 (具体是在堆内存) 中生存一个代表该类的 Class 对象,针对 Person 类,此时虚拟机会在堆内存空间,开辟一块空间 用于存储 Person.class 这个对象,如下图所示

usage

接着如果这个类有类属性,如我们定义在 Person 中的 MAX_AGE,那么他在类准备阶段,虚拟机也会在在类对象所在的内存块,给她分配一段空间,最终在类初始化完成时,类对象的状态如下。

usage

此时类加载完毕,现在需要去实例化 Person p1 , 此时虚拟机会利用已经加载到内存中的 Person.class, 来生成一个 p1 的实例,此时 p1 这边变量处于栈空间,具体的对象位于堆空间,

继续向下执行,需要实例化 p2 了,因为虚拟机检查到 Person.class 已存在于内从,此时直接执行实例化过程,最终的内存分配情况如下图所示

usage

到这里就可以看到为什么有类加载这个过程了。

通过上面分析会发现,如果为一个类设置了类属性,也就是用 static 去修饰的成员变量,他会在类加载完成后,就一直存在于内存,当然如果发生 GC,他有可能被回收,一般情况下,可以直接 通过类名去访问这块空间,因为他是在类对象的空间内,

相比类属性,对成员属性来说,如 Person 的 age、name 属性,都是在具体实例化类时才会去单独分配内从。

关于类加载,这是只说了一部分,具体成员变量的内存分配,以及临时变量的内存分配还有方法的执行过程,还有很多内存操作相关的知识点,自己可以去搜索更多的博客去学习,这里只是 把自己的理解说了出来,如有错误,欢迎指正。

最后,了解类加载还有内存分配有什么意义?

其实我们平时的开发过程中,好像用不到这些知识,但是这些都是最根本的基础,不了解好像对日常的开发也没什么大碍,但是如果你了解了这些细节的东西, 那么你在编写每一个类,定义每一个变量时,你就可以更加清楚的知道,它在内存中的状态。有利于对代码有更深刻的认识和了解。

这些如果你细究,都是有很多的学问,这里只是说出了一点点的东西,做个记录!

参考文档