Loading

Java-类的生命周期浅析

简述:Java虚拟机为Java程序提供运行时环境,其中一项重要的任务就是管理类和对象的生命周期。类的生命周期。类的生命周期从类被加载、连接和初始化开始,到类被卸载结束。当类处于生命周期中时,它的二级制数据位于方法区内,在堆区中还会有一个相应的描述这个类的Class对象(当Java程序使用任何一个类时,系统都会为之创建一个java.lang.Class对象)。只有当类处于生命周期中时,Java程序才能使用它,比如调用类的静态成员或者创建类的实例。

 

一、Java虚拟机及程序的生命周期

当通过java命令运行一个Java程序时,就启动了一个Java虚拟机进程。Java虚拟机进程从启动到终止的过程,称为Java虚拟机的生命周期。在以下情况中,Java虚拟机将结束生命周期。

01.程序正常执行结束;

02.程序在执行中因为出现异常或错误而异常终止;

03.执行了System.exit()或者Runtime.getRuntime().exit();

04.由于操作系用出现错误而导致Java虚拟机进程终止;

当Java虚拟机处于生命周期中时,它的总任务就是运行Java程序。Java程序从开始运行带终止的过程称为程序的生命周期,它和Java虚拟机的生命周期的一致的。

当Java程序运行结束时,JVM进程结束,在进程在内存中的状态将全部丢失。下面通过一个小例子来说明。

public class A {
    public static int a = 5;
}
public class ATest1 {

    public static void main(String[] args) {
        A.a++;
        System.out.println(A.a);//6
    }

}
public class ATest2 {

    public static void main(String[] args) {
        System.out.println(A.a);//5
    }

}

观察输出结果,原因就是ATest1和ATest2是两次运行JVM进程,第一次运行结束后它对A类所做的修改将全部丢失;第二次运行JVM将再次初始化A类。

一些初学者会认为A类中的a是静态成员变量,同一个类中所有实例的静态变量共享同一块内存区,错误的认为第二次运行会输出被第一次改变后的结果。但实际上两次运行Java程序处于两个不同的JVM进程中,故两个JVM之间不会共享数据。

二、类的加载、连接和初始化

当Java程序需要使用某个类时,Java虚拟机会确保这个类已经被加载、连接和初始化。其中连接过程又包括验证、准备和解析这三个子步骤。这些步骤必须严格按照以下顺序执行。

01.加载:查找并加载类的二进制数据;

加载具体是指把类的.class文件中的二进制数据读入到内存中,把它存放在运行时数据区的方法区内,然后再堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。

 

Java虚拟机能够通过不同的类加载器从不同来源加载类的二进制数据,包括以下几种:

001.从本地文件系统中加载class文件,也是最常见的方式;

002.从JAR、ZIP或者其他类型的归档文件中提取class文件(例如使用JDBC编程时用到的数据库驱动类就是放在JAR文件中,JVM可以从JAR文件中直接加载所需的class文件);

003.通过网络加载class文件;

004.把一个Java源文件动态的编译为class文件,并执行加载;

 

类加载的最终产品是位于运行时数据区的堆区的Class对象。Class对象封装了类在方法区内的数据结构,并且向Java程序提供了访问类在方法区内的数据结构对的接口。

 

 

类的加载是由类加载器完成的,类加载器通常由JVM提供,这些类加载器也是所有程序运行的基础,JVM所提供的这些类加载器通常称之为系统类加载器。除此之外,开发者可以通过继承ClassLoader基类来创建自定义的类加载器。

类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它。如果在预先加载过程中遇到class文件缺失或者存在错误,类加载器必须等到首次使用该类时才报错(抛出一个LinkageError错误),如果这个类一直没有被程序主动使用,那么该类加载器不会报错。

 

02.连接:包括验证、准备和解析类的二进制数据;

001.验证:确保被加载类的正确性

当类被加载后,就进入验证阶段。连接就是把已经读入到内存中的类的二进制数据合并到JVM运行时环境中去。连接的第一步是类的验证,其目的是保证被加载的类由正确的内部结构,并且与其他类协调一致。如果JVM检查到错误,那么就会抛出相应的Error对象。

疑问:由Java编译器生成的Java类的二进制数据肯定是正确的,为什么还要进行类的验证呢?

因为JVM不知道某个特定的class文件到底是如何创建的、从哪来的,这个class文件可能是由正常的Java编译器生成的,也有可能是恶意创建的(通过恶意class文件破坏JVM运行时环境),类的验证能提高程序的健壮性,确保程序被安全的执行。

类的验证主要包括以下内容:

类文件的结构检查:确保类文件遵从Java类文件的固定格式。

语义检查:确保类本身符合Java语言的语法规定(例如final类型的类没有子类,final类型的方法没有被覆写)。

字节码验证:确保字节码流可以被JVM安全的执行。字节码流代表Java方法(包括静态方法和实例方法),它是由被称作操作码的单元字节指令组成的序列,每一个操作码后都跟着一个或多个操作数。字节码验证步骤会检查每个操作码是否合法,即是否有着合法的操作数。

二进制兼容的检查:确保相互引用的类之间协调一致。例如在A类的的a方法中调用B类的b方法。JVM在验证A类时,会检查在方法区是否存在B类的b方法,如果不存在(当A类和B类的版本不兼容,就会出现这种问题),就会抛出NoSuchMethodError错误。

 

 

002.准备:为类的静态变量分配内存,并将其初始化为默认值

在准备阶段,JVM为类的静态变量分配内存,并设置默认的初始值。例如如下情况:

 

public class Demo {
    public static int a = 1;
    public static long b;
    
    static {
        b = 2;
    }
}

 

在准备阶段,将为int类型的静态变量a分配4个字节的内存空间并赋予默认值为0;为long类型的静态变量b分配8个字节的内存空间并赋予默认值0。

 

 

 

003.解析:把类中的符号引用转换成直接引用

在解析阶段,JVM会把二进制数据中的 符号引用替换为直接引用。例如在A类的a方法中调用B

类的b方法。

 

public class A {
    
    B b = new B();
    public void a() {
        b.b();//这行代码在A类的二进制数据中表示为符号引用
    }
    
}

 

在A类的二进制数据中,包含了一个对B类b()方法的符号引用,它由b()方法的全名和相关描述组成。在解析阶段,JVM将这个符号引用替换成为一个指针,该指针指向B类b()方法在方法区内的内存位置,这个指针就是直接引用。

 

 

03.初始化:给类的静态变量赋予正确的初始值;

在初始化阶段,JVM执行类初始化语句,为类的静态变量赋予初始值。在程序中,静态变量的初始化有两种途径:一是在静态变量的声明处进行初始化;二是在静态代码块中进行初始化。

如下代码,a和b都被显式的初始化,而c没有没显式的初始化,它将报纸默认值0。

 

public class A {
    
    private static int a = 1;//在变量声明处初始化
    public static long b;
    public static long c;
    
    static {
        b = 1;//在静态代码块中初始化
    }
    
}

在本文中,如果未加特别说明,类的静态变量都是指不能作为 编译时常量的静态变量。Java编译器和虚拟机对 编译时常量有特殊的处理方式,具体可参参考下文中 类的初始化时机。

 

静态变量的声明语句,以及静态代码块都被看作类初始化语句,JVM会按照初始化语句在类文件中的的书写顺序依次执行它们。

 

Java虚拟机初始化一个类包含以下步骤:

1.假如这个类还没有被加载和连接,那么先进行加载和连接。

2.假如类中存在直接父类,并且这个父类还没有被初始化,那么就先初始化直接父类。

3.假如类中存在初始化语句,那么就依次执行。

当初始化一个类的直接父类时,也需要重复以上步骤,这会确保当程序主动使用一个类时,这个类以及它的所有父类(包括直接父类和间接父类)都已经被初始化。程序中第一个被初始化的类是Object类。

 

 

在类或接口被加载的时机上,Java虚拟机规范给实现提供的一定的灵活性,但是又严格定义了初始化的时机,所有的Java虚拟机实现必须在每个类或接口被Java程序“首次主动使用”时才初始化它们。Java程序对类的使用可分为两种:主动使用和被动使用,在下面的类的初始化时机进行详细阐述。

 

三、类的初始化时机

JVM只有在程序首次主动使用一个类或接口时才会初始化它。只有以下6种方式被看作程序对类或接口的主动使用。

01.创建类的实例。包括new关键字来创建,或者通过反射、克隆及反序列化方式来创建实例。

02.调用类的静态方法。

03.访问某个类或接口的静态变量,或者对该静态变量赋值。

04.使用反射机制来创建某个类或接口对应的java.lang.Class对象。例如Class.forName("Test")操作,如果系统还未初始化Test类,这波操作会导致该Test类被初始化,并返回Test类对应的java.lang.Class对象。

05.初始化一个类的子类,该子类所有的父类都会被初始化。

06.JVM启动时被标明为启动类的类(直接使用java.exe命令运行某个主类)。例如对于“java Test”命令,Test类就是启动类(主类),JVM会先初始化这个主类。

 

除了以上6种情况,其他方式都被看作成是 被动使用,不会导致类的初始化。下面通过接个例子来验证:

public class A {
    
    public static final int a = 2*3;//a为编译时常量
    public static final String str = "haha";//str为编译时常量
    public static final int b = (int)(Math.random()*5);//b不是编译时常量
    
    static {
        System.out.println("init A");
    }
    
}

 

“宏变量”:

1.对于final类型的静态变量,如果在编译时就能计算出变量的取值,那么这种变量看作 编译时常量。Java程序中对类的编译时常量的使用,被看作是对类的被动使用,不会导致类的初始化。

上面例子的因为编译时能计算出a为6,所以程序访问A.a时,是对A类的被动使用,不会导致A类初始化。

public class Test {
    public static void main(String[] args) {
        System.out.println(A.a); 
    }
}

在Test测试类中运行程序:控制台只打印出6,并没有打印静态代码块中的 init A。

当Java编译器生成A类的class文件时,他不会在main()方法的字节码流中保存一个表示“A.a”的符号引用,而是直接在字节码流中嵌入常量值6。因此当程序访问A.a时,客观上无须初始化A类。(当JVM加载并连接A类时,不会在方法区内为它的编译时常量a分配内存。)

 

2.对于final类型的静态变量,如果在编译时不能计算出变量的取值,那么程序对类的这种变量的使用,被看作是对类的主动使用,会导致类的初始化

 

public class Test {
    public static void main(String[] args) {
        System.out.println(A.b);
    }
}

访问A类中不是编译时常量的b,控制台会打印出 init A 4。

这波操作JVM会初始化A类,使得变量b在方法区内拥有特定的内存和初始值。

 

3.当JVM初始化一个类时,要求它的所有父类都已经初始化完毕,但是这条规则并不适用于接口。

01.在初始化一个类时,并不会先初始化它所实现的接口。

02.在初始化一个接口时,并不会先初始化它的父接口。

因此,一个父接口不会因为它的子接口或者实现类被初始化而初始化,只有当程序首次使用特定接口的静态变量时,才导致该接口的初始化。

 

4.只有当程序访问的静态变量或静态方法的确在当前类或接口中定义时,才可看作是对类或接口的主动使用。观察下面例子:

class Father{//父类
    static int a = 1;
    static {
        System.out.println("init father");
    }
    static void method() {
        System.out.println("father method");
    }
}
class Son extends Father {
    static {
        System.out.println("init son");
    }
}
public class Demo {
    public static void main(String[] args) {
        System.out.println(Son.a); //仅仅初始化父类Father
        Son.method();
    }
}

控制台结果:

init father
1
father method

 

5.调用ClassLoader类的loadClass()方法加载一个类,该方法只是加载该类,并不是对类的主动使用,不导致类的初始化。使用Class.forName()静态方法才会导致强制初始化该类。

package cn.lifecycle;
class A {
    static {
        System.out.println("init A");
    }
}
public class B {
    public static void main(String[] args) throws Exception  {
        ClassLoader loader = ClassLoader.getSystemClassLoader();//获取系统类加载器
        Class objClass = loader.loadClass("cn.lifecycle.A");//加载A
        System.out.println("after load A");
        System.out.println("before init A");
        objClass = Class.forName("cn.lifecycle.A");//初始化A
    }
}

控制台结果:

after load A
before init A
init A

 

四、类加载器

类加载器简介:

类加载器负责将class文件(可能在磁盘上,也可能在网络上)加载到内存中,并为之生成对应的java.lang.Class对象。

类加载器负责加载所有的类,系统为所有被载入内存中的类生成一个java.lang.Class实例。一旦一个类被载入到JVM中,同一个类就不会再次被载入了。那么怎么样才算是”同一个类“?

正如一个对象有一个唯一的表示一样,一个载入JVM的类也有一个唯一的标识。在Java中,一个类用其完整限定名作为标识;但在JVM中,一个类用其完整限定名和其类加载器作为唯一标识。例如,在yzx包下的Student类,被类加载器ClassLoader的实例k1负责加载,则该Student类对应的Class对象在JVM中表示为(Student、yzx、k1)。这就意味着两个类加载器加载的同名类:(Student、yzx、k1)和(Student、yzx、k2)是不同的,它们所加载的类也是完全不同,互不兼容的。

 

JVM自带了以下几种加载器:

当JVM启动时,会形成由三个类加载器组成的初始类加载器层次结构。

01.Bootstrap ClassLoader:根类加载器

02.Extension ClassLoader:扩展类加载器

03.System ClassLoader:系统类加载器

 

类加载器用来把类的class文件加载到Java虚拟机中(内存中)。从jdk1.2开始,类的加载过程采用父亲委托机制,这种机制能更好地保证Java平台的安全。在此委托机制中,除了JVM自带的根加载器以外,其余的类加载器有且只有一个父加载器。当Java程序请求加载器loader1加载A类时,loader1首先委托自己的父加载器去加载A类,若父加载器能加载,则由父加载器完成加载任务,否则才由加载器loader1本身加载A类。

 

未完...

posted @ 2018-02-21 16:57  yanbubao  阅读(15037)  评论(0编辑  收藏  举报