Java类加载基础

我们知道,我们从写java代码开始,到代码执行的时候,中间一共经历四个阶段:

  1. 新建.java文件 并写代码,这称为编辑期
  2. 将.java文件编译为.class文件,这称为编译期
  3. 将.class文件加载到内存 并 生成.class类,这称为加载期
  4. 通过.class类去创建对象、执行代码,这称为运行期

其中,除了第一个阶段我们能直接干预,剩余三个阶段,都是jvm自己执行的(当然也有黑科技可以人工干预)。

1、类加载时机

引起类加载的场景

  1. 使用new创建对象时
  2. 读取或设置类的静态变量时(编译期常量除外)
  3. 使用java.lang.reflect包中方法对类进行反射调用时
  4. 初始化一个类时,会先初始化其父类,接口例外
  5. 虚拟机启动的主类,也就是定义main()方法的那个类,会在虚拟机启动就初始化

不会引起类加载的场景

1.1 对于静态字段,只有直接定义这个静态字段的类才会被初始化,通过子类引用不会导致子类被初始化

比如下面代码:

public static void main(String[] args) {
    System.out.println(Child.NAME);
}

public static class Base {
    public static String NAME = "NAME";

    static {
        System.out.println("Base init");
    }
}

public static class Child extends Base {
    static {
        System.out.println("Child init");
    }
}

打印结果为:

Base init
NAME

1.2 通过使用数组定义来引用类,不会触发类的初始化

比如:

public static void main(String[] args) {
    Child[] children = new Child[250];
}

public static class Child extends Base {
    static {
        System.out.println("Child init");
    }
}

结果什么都没打印

1.3引用类的编译期常量不会触发类的初始化

先来解释什么叫类的编译期常量:

  1. 类的编译期常量必须用static修饰,因为非static的在编译期都不能访问,必须要new出来对象才行,而new对象就出发了类的初始化,所以static对应了“编译期常量”中“编译期”这三个字
  2. 编译期常量必须用final修饰,这对应了“编译期常量”中“常量”这两个字

那么以static final 修饰的就一定是编译期常量吗?错!比如:

public static final long time = 74110; //这是个编译期常量
public static final long time = System.currentTimeMillis(); //不是编译期常量,因为系统时间只有在运行时才知道,编译期知道个毛啊

我们用代码来验证:

public static void main(String[] args) {
    System.out.println(Init.time);
}

public static class Init {
    public static final long time = 74110;

    static {
        System.out.println("Init被初始化!");
    }
}

运行结果:

74110

可以看到,并没有引起类的初始化! 这是正常的,因为编译期常量在编译期就被放入常量池,后面访问这个变量都会在常量池找,跟类半毛钱关系都没有,所以不会引起初始化。
接着来看第二个例子:

public static void main(String[] args) {
    System.out.println(Init.time);
}

public static class Init {
    public static final long time = System.currentTimeMillis();

    static {
        System.out.println("Init被初始化!");
    }
}

运行结果:

Init被初始化!
1596355938901

可以看到,类会先被初始化!
所以编译期常量的第三个要素变量的值需要在编译期就知道,那么,可以总结一下编译期常量的定义:static final 同时修饰的并且编译期就知道的才是编译期常量。

2、类加载机制

java类加载分为5个步骤: 加载、连接(验证、准备、解析)、初始化、使用、卸载,接下来我们来详细讲解加载、连接和初始化,至于使用,卸载就不废话了

2.1 加载

加载阶段完成的事情:

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

可以简单理解为:通过一个类的全限定名在方法区生成一个java.lang.Class对象

2.2 连接(连接阶段拆分为3个阶段)

  • 验证: 验证加载阶段Class文件是否合法,比如是否以魔数开头,版本号是否在当前虚拟机的处理范围之内等。
  • 准备: 为类变量分配内存并设置初始值,注意是“类变量”,也就是static变量,所以都在方法区分配,这些初始值一般都是“零值”,比如对象的零值是null,int的零值是0,boolean的零值是false等,但是如果是“编译期常量”,则直接就是定义的初始值。
  • 解析: 将符号引用转化为直接引用的过程,会确定部分方法的版本

2.3 初始化: 执行《clinit》()方法的过程。

《clinit》方法是由jvm收集类中所有类变量的“赋值语句”和“static块”得到的,也就是说,如果没有类变量的赋值语句和static块,就不会有《clinit》块,看例子:

public class Hello {
public static final int a = 100;
}

然后用javap -verbose Hello.class查看字节码:

Classfile /Users/lloydfinch/venn/workspace/java/test/Hello.class //路径
  Last modified 202082; size 229 bytes //修改时间和大小
  MD5 checksum 415f32c281d3178ff83100e89e1d092d //校验码
  Compiled from "Hello.java" //源文件
public class Hello 
  minor version: 0 //支持的最低版本号,45对应jdk1.0,之后每次版本号升高就加1
  major version: 55 //支持的最高版本号,55-45 = 10,所以对应jdk 11
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #2                          // Hello
  super_class: #3                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 1, attributes: 1
Constant pool:
   #1 = Methodref          #3.#14         // java/lang/Object."<init>":()V
   #2 = Class              #15            // Hello
   #3 = Class              #16            // java/lang/Object
   #4 = Utf8               a
   #5 = Utf8               I
   #6 = Utf8               ConstantValue
   #7 = Integer            10
   #8 = Utf8               <init>  //实例构造器
   #9 = Utf8               ()V
  #10 = Utf8               Code
  #11 = Utf8               LineNumberTable
  #12 = Utf8               SourceFile
  #13 = Utf8               Hello.java
  #14 = NameAndType        #8:#9          // "<init>":()V
  #15 = Utf8               Hello
  #16 = Utf8               java/lang/Object
{
  public static final int a;
    descriptor: I
    flags: (0x0019) ACC_PUBLIC, ACC_STATIC, ACC_FINAL
    ConstantValue: int 10

  public Hello();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0
}
SourceFile: "Hello.java"

我们发现上面并没有《clinit》方法,因为a是个编译期常量,所以并没有,然后我们改成:

public class Hello {
    public static int a = 100; //去掉final,那么就等价于赋值语句,因为有final的话,不是赋值语句,而是“初始化语句”
}

对应的字节码指令:

Classfile /Users/lloydfinch/venn/workspace/java/test/Hello.class
  Last modified 202082; size 265 bytes
  MD5 checksum d45f4426aa6b31b54300f59966e46049
  Compiled from "Hello.java"
public class Hello
  minor version: 0
  major version: 55
  flags: (0x0021) ACC_PUBLIC, ACC_SUPER
  this_class: #3                          // Hello
  super_class: #4                         // java/lang/Object
  interfaces: 0, fields: 1, methods: 2, attributes: 1
Constant pool:
   #1 = Methodref          #4.#14         // java/lang/Object."<init>":()V
   #2 = Fieldref           #3.#15         // Hello.a:I
   #3 = Class              #16            // Hello
   #4 = Class              #17            // java/lang/Object
   #5 = Utf8               a
   #6 = Utf8               I
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               <clinit>        //注意这里,多出来了<clinit>方法
  #12 = Utf8               SourceFile
  #13 = Utf8               Hello.java
  #14 = NameAndType        #7:#8          // "<init>":()V
  #15 = NameAndType        #5:#6          // a:I
  #16 = Utf8               Hello
  #17 = Utf8               java/lang/Object
{
  public static int a;
    descriptor: I
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC

  public Hello();
    descriptor: ()V
    flags: (0x0001) ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  static {};
    descriptor: ()V
    flags: (0x0008) ACC_STATIC
    Code:
      stack=1, locals=0, args_size=0
         0: bipush        10
         2: putstatic     #2                  // Field a:I
         5: return
      LineNumberTable:
        line 4: 0
}
SourceFile: "Hello.java"

我们看到,上面多出来了《clinit》()方法,如果代码改成这样:

public class Hello {
    public static final int a;
    static {
        a = 100;
    }
}

结果是一样的,一样有《clinit》()语句

()方法是根据语句在源文件中出现的顺序生成的,静态语句块只能访问定义在它之前的变量,定义在它之后的,只能赋值不能访问!

public static class Init {
    public static int a = 10;

    static {
        a = 20; //对
        System.out.println(a);//对
        b = 10;//对
        System.out.println(b);//错,静态语句块不能访问定义在它之后的变量
    }

    public static int b = 20;
}

jvm会在子类的()执行之前自动调用父类的()方法,这就意味着父类的静态语句优先于子类赋值变量语句执行,所以java.lang.Object的()方法总是第一个被调用

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

public static class Child extends Base {
    public static int b = a;

    static {
        System.out.println("Child Init");
    }
}

public static class Base {
    public static int a = 10;

    static {
        System.out.println("赋值为10");
        a = 20;
    }
}

运行结果:

赋值为10
Child Init
20

可以看到最后结果为20,而不是10

《clinit》()方法对类或接口不是必须的,如果类中没有类变量的赋值语句或静态块,就不会有,接口的《clinit》()方法调用前不会先调用父接口的《clinit》()方法,除非父接口定义的变量使用时,才会初始化

jvm会保证《clinit》()方法在多线程中被正确的加锁、同步,可以使用这个特性来实现单例模式,也就是静态内部类单例。比如:

public class SingleInstance {
    private static SingleInstance instance;

    private SingleInstance() {
    }

    public static SingleInstance getInstance() {
        return Inner.instance;
    }

    private static class Inner {
        //因为这是个静态变量的赋值语句,所以在<clinit>()中,而jvm保护了<clinit>()被正确的加锁、同步,所以是线程安全的
        private static SingleInstance instance = new SingleInstance();
    }
}

2.4 方法调用

在“连接”阶段的“解析阶段”,我们会确定一部分方法的版本,比如重载的版本,来看例子:

public static void main(String[] args) {
    TestClass testClass = new TestClass();
    Base base = new Child();
    testClass.info(base);
}

public static class Child extends Base {
}

public static class Base {
}

public void info(Base base) {
    System.out.println("info base");
}

public void info(Child child) {
    System.out.println("info child");
}

运行结果:

info base

也就是说,函数的重载是在编译期就确定的,在jvm里面叫“静态分派”
看另一个例子:

public static void main(String[] args) {
    Base base = new Child();
    base.info();
}

public static class Child extends Base {
    @Override
    public void info() {
        System.out.println("Child");
    }
}

public static class Base {
    public void info() {
        System.out.println("Base");
    }
}

运行结果:

child

相信所有人都知道这个结果,这就是个多态的体现,也就是重写,这证明:函数的重写在jvm里是“动态分派”

总结

  1. 编译期常量是static final修饰的在编译期就能确定其值的变量,会在jvm指令中ConstantValue标记
  2. 准备阶段就会为类变量分配内存并赋初值,如果是编译期常量,则直接就是指定的值,否则就是零值
  3. 《clinit》()方法会保证父类先执行,并且保证线程安全,可以用来实现静态内部类单例
  4. 方法的重载是静态分配的,方法的重写是动态分配的
  5. 类变量有两个赋值阶段,一次是准备阶段,一次是初始化阶段,编译期常量准备阶段就被正确的赋值,非编辑期常量在初始化阶段才会被正确赋值
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值