窥探JVM(一):类的加载、连接与初始化

我们都知道将java源文件通过javac命令编译后得到的是.class文件,它是真实存储在磁盘上的,那么Java虚拟机是如何将其读入内存,最终形成虚拟机直接使用的Java类型的呢?这一切都要归功于虚拟机类加载机制。

  1. 虚拟机类加载机制可以分为如下几个阶段

    • 加载
    • 连接:
      • 验证
      • 准备
      • 解析
    • 初始化
    • 使用
    • 卸载

    总的来说,分为7个阶段,其中验证、准备和解析可以统称为连接。

  2. “加载”阶段

    首先要区别“加载阶段”和类加载这两个词的含义,“加载”是整个类加载过程(机制)的一个阶段。在加载阶段,虚拟机需要完成以下3件事情:

    • 通过一个类的全限定名来获取定义此类的二进制字节流
    • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
    • 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。至于这个Class对象存放在虚拟机的哪个运行时内存区域,JVM规范并没有做强制性要求,我们熟悉的HotSpot虚拟机就将其存放在方法区。

    简而言之,加载阶段就是将class文件读入内存,这里的class文件有可能来自于本地文件系统,也有可能来自于专有数据库,亦或是zip、jar等归档文件,还有可能来自于网络。那么自然而然这里面会涉及IO操作,以及上面提到的转化,最终形成的产物

    我们熟悉的Java类加载器就是从这个阶段开始工作。关于类加载器和双亲委托机制我们放到后面的系列文章做专门介绍。

  3. 验证阶段:

    对于读取到内存的class文件,我们并不能确保其合法性,所以就需要对读取到的字节码文件进行校验。这和我们平常开发中是一样的,“Input is Evil”,忘了是哪个国外大佬说的,输入总是邪恶的。所以对于程序输入的内容,我们都需要进行合法性校验,无论是前端还是后台开发者。至于验证的内容包括以下几个方面:

    • 文件格式验证
    • 元数据验证
    • 字节码验证
    • 符号引用验证

    验证的内容很多,我这里就举一个简单的例子:我们都知道Java泛型以及自动装箱/拆箱以及增强for循环,这些特性都是JDK1.5中才引入的,如果我们的字节码文件是基于JDK1.5生成的,假设当前的JVM是基于1.4版本,那么自然而然是有问题的。

    不知道大家有没有这样一个疑问:前面在加载阶段,我们已经提到,加载阶段最终会生成一个Class对象。既然都生成了Class对象,那为什么在验证阶段再做这些校验有什么意义呢?原来:

    加载阶段与连接阶段的部分内容是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。

  4. 准备阶段

    在这个阶段,JVM就会为类的静态变量分配空间,并将其初始化为默认值。这个地方所说的默认值要和我们显式指定的默认值做下区分。例如:

    private static int flag = 25;

    那么在准备阶段,会为flag变量分配内存空间,并且初始化为0,而不是25。被赋值成25是在后面的初始化阶段。这里我们可以通过一段代码进行验证:

    public class MyTest {
        public static void main(String[] args) {
            System.out.println("in main: " + Singleton.count);
        }
    }
    
    class Singleton {
    
        private static final Singleton instance = new Singleton();
        
        public static int count = 0;
        
        private Singleton() {
            count++;
            System.out.println("Singleton constructor: " + count);
        }
        
        public static Singleton getInstance(){
            return instance;
        }
    
    }
    

    我们在这里实现了一个饿汉式单例。这样,我们直接看输出结果:

    Singleton constructor: 1

    in main: 0

    可以发现,我们在构造器中确确实实是将count自增了一次,变成了1.但是在main方法里访问的时候,count的值为0。为了更好、更体系地解释这其中的原理,我们马上介绍初始化阶段。

  5. 解析阶段

    把类中的符号引用转换成直接引用。之所以会有符号引用和直接引用,是因为Java是跨平台语言,在编译期间并没有确定具体引用的内存地址空间。以下摘自《深入理解JVM虚拟机》

    • 符号引用:符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定加载到内存中。各种虚拟机实现的内存布局可以各不相同,但是它们能接受的符号引用必须是一致的,因为符号引用的字面量形式明确定义在Java虚拟机规范的Class文件格式中。
    • 直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局相关的,同一个符号引用在不同虚拟机实例上翻译出来的直接引用一般不会相同。如果有了直接引用,那引用的目标必定已经在内存中存在。
  6. 初始化阶段

    Java程序对类的使用方式可分为两种:

    • 主动使用
    • 被动使用

    所有的Java虚拟机实现必须在每个类或接口被Java程序”首次主动使用“时才初始化它们。有哪些情况会被视为是对类的主动使用呢?

    • 创建类的实例
    • 访问某个类或接口的静态变量,或者对该静态变量赋值
    • 调用类的静态方法
    • 反射,如Class.forName(“xxx…xxx.xx”)
    • 初始化一个类的子类
    • Java虚拟机启动时被标明为启动类的类
    • JDK1.7开始提供动态语言支持,MethodHandle实例解析的结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化
    • 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有
      这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

    前面的6种情况都是我们日常开发中常见的。下面我们先解释之前遗留下来的问题解释清楚,然后在分别举例说明前面6种情况对于类的主动使用。

    public class MyTest {
        public static void main(String[] args) {
            System.out.println("in main: " + Singleton.count);
        }
    }
    
    class Singleton {
        private static final Singleton instance = new Singleton();
    
        public static int count = 0;
    
        private Singleton() {
            count++;
            System.out.println("Singleton constructor: " + count);
        }
    
        public static Singleton getInstance(){
            return instance;
        }
     }
    

    由于我们访问了Singleton的静态变量count,被视为主动使用,并且是首次主动使用,所以会触发类的初始化。而在准备阶段,已经为Singleton的静态变量instance以及count变量分配空间,并且初始化为默认值null以及0。到了初始化阶段,会按照代码中的先后顺序执行初始化(实际上通过反编译可以得知,他们都放置在 <clinit>):

    • 对于instance变量的初始化:创建Singleton类的实例(这也是对类的主动使用,但是不是首次),从而会执行Singleton的构造方法,使得count自增为1,并且打印:Singleton constructor: 1
    • 对于count变量的初始化:将count赋值为0

    这样是不是就已经回答我们之前遗留下来的问题了。在准备阶段,会为类的静态变量分配内存空间,并且初始化为默认值,这个时候的默认值是和我们的开发代码无关的,是已经规定好了的:

    类型默认值
    byte(byte)0
    short(short)0
    int0
    long0L
    float0.0f
    double0.0d
    booleanfalse
    char‘\u0000’
    referencenull

    ok.那么我们来验证上面提到的被视为是对类的主动使用的六种情形:

    • 创建类的实例

      public class MyTest2 {
      
          public static void main(String[] args) {
      
              new Person();
              new Person();
          }
      
      }
      
      class Person {
      
          static {
              System.out.println("person init block.");
          }
      }
      

      输出结果为:

      person init block.

      1. 通过静态代码块来反映是否执行了类的初始化;
      2. 只有对于类的首次主动使用才会触发类的初始化,这里我们new两个Person对象,但是static代码块只执行了一次。
    • 访问某个类或接口的静态变量,或者对它们进行赋值

      public class MyTest3 {
      
          public static void main(String[] args) {
      
              System.out.println(Human.name);
          }
      }
      
      class Human{
      
          public static String name = "David";
      
          static {
              System.out.println("in Human static block.");
          }
      }
      

      输出结果:

      in Human static block.
      David

    • 调用类的静态方法

      public class MyTest4 {
      
          public static void main(String[] args) {
              
              Student.introduce("David");
          }
      }
      
      class Student {
      
          public static void introduce(String name) {
              System.out.println("Hello, I'm " + name);
          }
          
          static {
              System.out.println("in Student's static block.");
          }
      }
      

      输出结果如下:

      in Student’s static block.
      Hello, I’m David

    • 反射,如Class.forName()

      public class MyTest5 {
      
          public static void main(String[] args) throws ClassNotFoundException {
              Class.forName("com.xlh.jvm.classloader2.Student");
          }
      }
      
      
      class Student {
      
          public static void introduce(String name) {
              System.out.println("Hello, I'm " + name);
          }
      
          static {
              System.out.println("in Student's static block.");
          }
      }
      

      输出结果:

      in Student’s static block.

      结合 Class.forName() 的源码也可得知:
      在这里插入图片描述
      在这里插入图片描述

    • 访问某个类的子类

      public class MyTest6 {
      
          public static void main(String[] args) {
              new Dog();
          }
      
      }
      
      class Animal{
      
          static {
              System.out.println("Animal's static block.");
          }
      }
      
      class Dog extends Animal{
      
      }
      

      输出结果为:

      Animal’s static block.

    • 被Java虚拟机标记为启动类的类

      public class MyTest7 {
          public static void main(String[] args) {
      
          }
      
          static {
              System.out.println("Main class static");
          }
      }
      

      输出结果为:

      Main class static

      可以看到,我们通过实例来证明了前面说到的七种情况。

  7. 几种易混淆点:

    • 对于静态常量的处理:

      public class MyTest6 {
      
          public static void main(String[] args) {
              System.out.println(Animal.name);
          }
      
      }
      
      class Animal{
      
          public static final String name = "Animal";
      
          static {
              System.out.println("Animal's static block.");
          }
      }
      
      

      输出结果为:

      Animal

      从输出结果来看,Animal类的静态代码块并没有执行。换句话说,对于Animal类中的常量name的访问,并没有触发Animal类的初始化。我们前面提到:”访问某个类或接口的静态变量,或者对它们进行赋值“,但是加了final关键字后变成常量,编译期对于其处理就发生了改变:常量在编译阶段会被存入到调用这个常量的方法所在类的常量池中。从本质上来说,调用类并没有直接引用到定义常量的类。比如我们这里的Animal.name,编译期间就会把它存放到MyTest6类的常量池中,在这之后,MyTest6和Animal类就没有任何关系了。下面我们来看看反编译的结果:

      在这里插入图片描述

      我们再把final关键字去掉:

      在这里插入图片描述

      很明显,助记符由ldc变成了getstatic。

      ldc:Push item from run-time constant pool

      getstatic: Get static field from class。

      而且,加了final关键字的反编译结果中,看不到Animal类信息。由此,我们可以尝试将Animal.class文件删除:

      在这里插入图片描述

      再次运行MyTest6程序,会发现并没有报错,输出结果也是只有一个”Animal“。由此可见,我们甚至并没有触发 Animal 类的加载,通过虚拟机参数 -XX:+TraceClassLoading 也可得知。

      ok,如果我们现在稍微将name的赋值行为修改一下:

      public class MyTest6 {
      
          public static void main(String[] args) {
              System.out.println(Animal.name);
          }
      
      }
      
      class Animal {
      
          public static final String name = UUID.randomUUID().toString();
      
          static {
              System.out.println("Animal's static block.");
          }
      }
      

      再次运行程序:

      Animal’s static block.
      6946150f-92db-467f-8776-c78449d66f30

      我们会发现,此时Animal类的静态代码块得到了执行,也就是说Animal类执行了初始化。同样是加了final关键字,这里表现出来的行为和我们上面的得出的结论恰恰相反。

      两者的唯一区别在于:同样是常量,前者的值在编译期间就可以确定,称之为编译期常量;后者的值在编译期间是无法确定的,需要在运行期间才得以确定,称之为运行期常量,既然是运行期才可以确定,那么我们自然无法在编译期间就将其放置到调用类的常量池中。

    • 对于静态字段,只有直接定义这个字段的类才会被初始化

      public class MyTest6 {
      
          public static void main(String[] args) {
              System.out.println(Dog.name);
          }
      
      }
      
      class Animal {
      
          public static  String name = "David";
      
          static {
              System.out.println("Animal's static block.");
          }
      }
      
      class Dog extends Animal {
          
          static {
              System.out.println("Dog's static block.");
          }
      }
      

      还是前面的Animal类例子,我们把final关键字去掉,然后通过Dog.name的形式来访问定义在Animal类的静态属性。我们来看输出结果:

      Animal’s static block.

      David

      我们可以发现,Dog类并没有被初始化,反而是Animal类被初始化了。这也就验证了,我们这条的结论:**对于静态字段,只有直接定义这个字段的类才会被初始化。**那么这个 Dog 类有没有被加载呢?实际上我们可以添加虚拟机参数:
      在这里插入图片描述
      在这里插入图片描述
      从输出可以看出,Dog 类被加载了,但是没有初始化。

    • 对数组类型的处理

      public class MyTest8 {
      
          public static void main(String[] args) {
      
              Item[] items = new Item[5];
              
          }
      }
      
      class Item {
          static {
              System.out.println("in Item's static block.");
          }
      }
      

      如果运行这段程序,输出结果是怎样的呢?答案是控制台没有任何输出信息。

      对于数组实例而言,其类型是由JVM在运行期生成的,数组类本身不通过类加载器创建对于某个类的数组类型的主动使用,并不会导致该元素类的初始化。这里我们用的是一维数组举例的换成二维数组也是一样。但是对于引用数据类型而言,尽管其对应的数组类本身是不同类加载器创建,但是该引用数据类型还是需要通过类加载器创建。例如此处再添加一行:

      items[0] = new Item();

      那么肯定会触发Item类的初始化。
      而对于原生数据类型而言,其对应的数组类会被标记为与引导类加载器关联。

  8. 总结:

    • 虚拟机类加载机制的几个阶段:加载、连接(验证、准备、解析)、初始化、使用以及卸载。

    • 对于类的首次主动使用,才会触发类的初始化,具体可被视为主动使用的情形有:

      • 创建类的实例(对应的助记符为new)
      • 访问某个类或者接口的静态变量,或者对该静态变量赋值(常量除外)(助记符:getstatic、putstatic)
      • 调用某个类的静态方法(invokestatic)
      • 使用java.lang.reflect包对某个类型进行反射调用,如Class.forName(“xxx.xxx.xxx”);
      • 初始化一个类的子类
      • 被标记为启动类的类
      • JDK1.7开始提供动态语言支持,MethodHandle实例解析的结果REF_getStatic、REF_putStatic、REF_invokeStatic句柄对应的类没有初始化
      • 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有
        这个接口的实现类发生了初始化,那该接口要在其之前被初始化。

      有且只有这些情形被视为类的主动使用

    • 对几种易混淆点的处理

      • 编译期常量与运行期常量所表现出来的不同行为
      • 对于类的静态变量,只有直接定义该静态变量的类才会被初始化
      • 对于数组类型的处理,数组类型本身是由JVM在运行期间动态产生的,对于某个类的数组类型的主动使用,并不会触发该类的初始化。

参考资源:《深入理解Java虚拟机第三版》

  • 2
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值