java类加载过程浅分析

272 阅读15分钟

微信公众号:51码农网(www.51manong.com)
欢迎关注 如果觉得对你有帮助的话。没有帮助也没关系。

写在前面

通过这篇文章,我们需要解决以下几个问题:

1. 类的加载过程?

2. 类加载时,在准备阶段,类变量的初始值是多少?"

1public static int value=123
2public static final value =123;
3上面两种写法,在类加载过程的准备阶段,初始值分别是多少?

3. 加载、验证、准备、解析、初始化、使用、卸载、解析阶段一定是在初始化之前开始?

类的加载过程

加载

“加载”是“类加载”过程的一个阶段,在加载过程中,虚拟机会完成以下3件事情

  1. 通过一个类的全限定名来获取定义此类的的二进制字节流
  2. 将这个字节流所代表的的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据访问入口

"补充方法区的知识点:"

  1. 方法区和java堆一样,是各个线程共享的内存区域,它用来存储已被虚拟机加载的类信息、常量、静态常量、即时编译器编译后的代码等数据。在JDK1.6,运行时常量池就是在方法区中。但是JDK1.7之后的版本将运行时常量池从方法区中移动了出来,放在了java堆中。JDK1.8的版本将方法区改为了原空间,并且放在了直接内存中。

"看下面的代码,想想在JDK1.6和JDK1.7会输出什么结果:"

 1public class Test {
2    public static void main(String[] args)  {
3            String s = new String("1");
4            s.intern();
5            String s2 = "1";
6            System.out.println(s == s2);
7
8            String s3 = new String("1") + new String("1");
9            s3.intern();
10            String s4 = "11";
11            System.out.println(s3 == s4);
12
13  }
14}

先说答案:(想一想是为什么?)
JDK1.6 false false
JDK1.7 false true

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的安全。这点也好理解,就好比我们乘坐地铁,要先进行安检一样。包含了以下4点验证

  1. 文件格式验证
  2. 元数据验证
  3. 字节码验证
  4. 符号引用验证

"补充点:"
验证阶段对于虚拟机的类加载机制来说,是一个非常重要,但不是一定必要(因为对程序运行期没有影响)的阶段,如果所运行的全部代码,都已经反复使用和验证过的话,那么在实施阶段就可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

准备

准备阶段是正式为类变量分配内存并设置类变量初始值得阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段有2个容易混淆的概念,首先,这时候进行内存分配仅包含类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化的时候随对象一起分配在java堆中。其次,这里说的初始值"通常情况下"是数据类型的零值,假如一个类变量的定义为:

1public static int value =123;

那么变量在准备阶段过后的初始值为0而不是123,因为这个时候,还没有开始执行Java方法,而把value赋值为123的putstatic指令是程序被编译后,存放于类构造器<clinit>()方法之中,所以把value赋值为123的动作将在初始化阶段才会执行

基本数据类型的零值

数据类型 零值 数据类型 零值
int 0 boolean false
long oL float 0.0f
short (short)0 double 0.0d
char '\u0000' reference null
byte (byte)0

上面提到在"通常情况"下初始值是0值,那相对的会有一些"特殊情况":如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值,如果上面的类变量定义变成:

1public static final  int value =123;

编译时候,javac将会为value生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将value赋值为123。

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在Class文件中,以CONSTANT_class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、等类型的常量出现。

符号引用

 符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义的定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标并不一定加载到内存中。在Java中,一个java类将会编译成一个class文件。在编译时,java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。比如org.simple.People类引用了org.simple.Language类,在编译时People类并不知道Language类的实际内存地址,因此只能使用符号org.simple.Language(假设是这个,当然实际中是由类似于CONSTANT_Class_info的常量来表示的)来表示Language类的地址。各种虚拟机实现的内存布局可能有所不同,但是它们能接受的符号引用都是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。

直接引用

直接引用可以是
(1)直接指向目标的指针(比如,指向“类型”【Class对象】、类变量、类方法的直接引用可能是指向方法区的指针)
(2)相对偏移量(比如,指向实例变量、实例方法的直接引用都是偏移量)
(3)一个能间接定位到目标的句柄
直接引用是和虚拟机的布局相关的,同一个符号引用在不同的虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经被加载入内存中了。

加载、验证、准备、初始化、卸载这5个阶段的顺序是确定的。类的加载过程必须按照这种顺序按部就班的开始,而解析阶段则不一定:它在某种情况下可以在初始化阶段之后再开始,这是为了支持java语言的运行时绑定(也称为动态绑定或者晚期绑定)。这里的按部就班地“开始”,而不是按部就班地“进行”或“完成”,强调这点是因为这些阶段通常都是互相交叉地混合式进行。通常会在一个阶段执行过程中调用、激活另外一个阶段,并不是说,需要等待上一步执行完毕,才可以开始下一阶段的执行。

初始化

类初始化阶段是类加载过程的最后一步,前面的类加载过程中,除了在加载阶段用户应用程序可以通过自定义类加载器参与之外,其余动作完全由虚拟机主导。到了初始化阶段。才真正开始执行类中定义的Java程序代码。 在准备阶段,变量已经赋过一次系统要求的初始值,而在初始化阶段,则根据程序员通过程序制定的主观计划去初始化类变量和其他资源,初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法

<clinit>()方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块(static(){})中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序所决定的,静态语句块中只能访问到定义在静态语句块之前的变量。编译器会报错。

 1public class Test {
2
3      static{
4          i=0;
5        //Cannot reference a field before it is defined
6        //提示"非法向前引用"
7          System.out.println(i);
8      }
9      static int i=1
10}

<clinit>()方法与类的构造函数(或者说实例构造器<init>()方法不同,它不需要显示地调用父类的构造器,虚拟机会保证在子类的<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法的类肯定是java.lang.Object。

由于父类的<clinit>()方法先执行,也就意味着父类中定义的静态语句块要优先于子类的变量赋值操作。查看以下代码

1public class Parent {
2    public static   int  A =1;
3    static{
4        A=2;
5        System.out.println("Parent中的static执行了");
6    }
7
8}
1public class Children extends Parent {
2    public static int B=A;
3    static{
4        System.out.println("child中的static执行了");
5    }
6
7}
1public class Test {
2    public static void main(String[] args) {
3        Children sub = new Children();
4        System.out.println(sub.B);
5    }
6
7}
1执行结果:
2Parent中的static执行了
3child中的static执行了
42

在父类和子类上添加无参构造方法:

 1public class Parent {
2
3    public Parent({
4        super();
5        System.out.println("Parent的无参构造执行了");
6    }
7    public static   int  A =1;
8    static{
9        A=2;
10        System.out.println("Parent中的static执行了");
11    }
12
13}
 1public class Children extends Parent {
2
3    public Children() {
4        super();
5      System.out.println("Children的无参构造执行了");
6    }
7    public static int B=A;
8    static{
9        System.out.println("child中的static执行了");
10    }
11
12}
1public class Test {
2    public static void main(String[] args) {
3        Children sub = new Children();
4        System.out.println(sub.B);
5    }
6
7}
1执行结果:
2Parent中的static执行了
3child中的static执行了
4Parent的无参构造执行了
5Children的无参构造执行了
62

<clinit>()方法对于类和接口来说并不是必需的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法

接口中不能使用静态语句块,但仍然有变量初始化的赋值操作,因此接口与类一样都会生成<clinit>()方法。但接口与类不同的是,执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法。只有当父接口中定义了变量的使用时,父接口才会初始化,另外接口的实现类在初始化的时候也一样不会执行接口的<clinit>()方法。

接口中不能使用静态语句块

1public interface MyInterface {
2
3    //编译报错 接口MyInterface无法定义初始值设定项
4    static{
5        System.out.println("MyInterface的static执行了");
6    }
7
8}

接口中仍然可以变量初始化赋值的操作

1public interface MyInterface {
2    //编译通过 正常
3   static int value = 123;
4}

虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。如果在一个类的<clinit>()方法中有耗时很长的操作,就可能造成多个进程阻塞。(需要注意的是,其他线程虽然会被阻塞,但是如果执行<clinit>()方法的那条线程退出<clinit>()方法后,其他线程唤醒以后不会再进行<clinit>()方法,同一个类加载器下,一个类型只会初始化一次。)

 1public class DeadLoopClass {
2    static{
3        if(true){
4            System.out.println(Thread.currentThread().getName()+",init DeadLoopClass");
5            try {
6                Thread.sleep(2000);
7            } catch (InterruptedException e) {
8                e.printStackTrace();
9            }   
10        }
11    }
12    public static void main(String[] args{
13
14        Runnable runAble = new Runnable() {
15            @Override
16            public void run(
{
17              System.out.println(Thread.currentThread().getName()+",start");
18              DeadLoopClass deadLoopClass = new DeadLoopClass();
19              System.out.println(Thread.currentThread().getName()+",end");
20            }
21        };
22        Thread thread1 = new Thread(runAble);
23        Thread thread2 = new Thread(runAble);
24        thread1.start();
25        thread2.start();    
26    }
27
28}
1结果:
2main,init DeadLoopClass
3Thread-0,start
4Thread-0,end
5Thread-1,start
6Thread-1,end

分析:
DeadLoopClass的初始化,并不是thread1和thread2执行了<clinit>()方法,而是main方法的线程去执行的了<clinit>(),当虚拟机启动时,需要指定一个要执行的主类,(包含main方法的那个类),虚拟机会先初始化这主类,这个时候,会有3个线程。main thread0 thread1 ,main去执行的这个类构造器<clinit>()方法,其他2个阻塞了,当main线程执行完毕,其他线程唤醒以后不会再进行<clinit>()方法,同一个类加载器下,一个类型只会初始化一次。

 1public class DeadLoopClass {
2    static{
3        if(true){
4            System.out.println(Thread.currentThread().getName()+",init DeadLoopClass");
5            while(true){
6
7            }
8        }
9    }
10    public static void main(String[] args{
11        Runnable runAble = new Runnable() {
12            @Override
13            public void run(
{
14              System.out.println(Thread.currentThread().getName()+",start");
15              DeadLoopClass deadLoopClass = new DeadLoopClass();
16              System.out.println(Thread.currentThread().getName()+",end");
17            }
18        };
19        Thread thread1 = new Thread(runAble);
20        Thread thread2 = new Thread(runAble);
21        thread1.start();
22        thread2.start();    
23    }
24
25}

结果:
main,init DeadLoopClass
其他两个线程被一直阻塞了下去。

结尾

我是结尾
微信公众号:51码农网(www.51manong.com)
欢迎关注我,一起学习,一起进步!