美文网首页
记录一个Java类加载相关的问题

记录一个Java类加载相关的问题

作者: SoldierWIN | 来源:发表于2021-04-17 15:25 被阅读0次

    一、问题始末

    临近下班在改一段别人的代码,遇到了一个特别奇怪(当时以为)的问题,大致上是一个抽象的父类,在构造方法中调用了内部的抽象方法,然后抽象方法的具体实现也就是子类中修改了子类的一个成员变量a(默认值-1)的值为100,并接着在方法中开启了一个异步操作,在异步逻辑执行完成之后又执行了一个子类私有的方法,这个私有方法读取了成员变量a的值,这时候发现竟然是默认值-1

    本来是段Android代码,但是为了方便演示,故模拟代码入下:

    #Father.java
    public abstract class Father {
    
        public Father() {
            System.out.println("Father init");
            login();
        }
    
        public abstract void login();
    
    }
    
    #Son.java
    public class Son extends Father {
    
        private int index = -1;
        public Son() {
            super();
            System.out.println("Son init");
    //        index = 10;//问题解决
        }
    
        @Override
        public void login() {
             System.out.println("login(Son):Father read index = " + index);
            index = 100;
            System.out.println("login(Son):reset the index " + index);
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                       Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    callback();
                }
            }).start();
    
            try {
                Thread.sleep(100);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    //私有的成员方法
        private void callback() {
            System.out.println("callback(Son): index = " + index);
        }
    }
    
    

    开始觉得好奇怪的问题,但是看起来再奇怪的问题总有其原因,现在想想还是因为对类加载流程印象不深刻才导致没发现原因,后来下班地铁路上回想了整段代码逻辑,才意识到是因为修改成员变量值是在父类的构造方法中执行,此时子类的构造方法还未执行,接着异步回调子类私有方法读取成员变量值,此时已经执行过了子类的构造方法,自然值会被修改为定义时的默认值-1
    上边代码执行结果:

    Son son = new Son();
    #log打印:
    Father init
    login(Son):Father read index = 0  //login方法中打印index的初始值为0
    login(Son):reset the index 100
    Son init
    callback(Son): index = -1
    

    原因分析:通过new子类会先调用父类的构造方法,然后调用抽象方法的实现,对子类属性赋值100接着开启一个异步,此时子类的构造方法已经执行完成,所以子类的属性又被赋值为定义的默认值-1,当异步方法执行完回调子类的私有方法时成员变量的值就变成了定义的默认值-1;
    验证:(在login方法中加入睡眠,确保异步结束早于子类构造方法的调用)

    public class Son extends Father {
    
        private int index = -1;
    
        public Son() {
            super();
            System.out.println("Son init . " + index);
    //        index = 10;
        }
    
        @Override
        public void login() {
    
            System.out.println("login(Son):Father read index = " + index);
    
            index = 100;
    
            System.out.println("login(Son):reset the index " + index);
            new Thread(new Runnable() {
                @Override
                public void run() {
                    try {
                        Thread.sleep(100);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    callback();
                }
            }).start();
    
            try {
                Thread.sleep(500);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
    
        private void callback() {
            System.out.println("callback(Son): index = " + index);
        }
    }
    

    代码执行结果:

    Father init
    login(Son):Father read index = 0
    login(Son):reset the index 100
    callback(Son): index = 100
    Son init -1  //证实了这个问题的原因
    

    二、类加载流程(基于HotSpot)

    上边问题原因很明显了,也是因为对类加载的流程理解不够细致才没第一时间意识到问题原因,接下来就梳理一下整个流程。
    引用一段《深入理解Java虚拟机》中一句话来描述类加载:虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。
    可见要使用一个类需要经历 加载、验证、准备、解析、初始化、使用和卸载几个步骤,其中验证、准备、解析又统称为链接。其中类从文件加载到JVM方法区为Class对象(与实例化对象不是一个概念)主要经历前五个步骤,类加载需要用到类加载器(ClassLoader),不同路径或目录下的类用到的类加载器也不一样,这里还会涉及到双亲委派的原理,另外数组类不通过类加载器创建,而是由虚拟机创建,但这些都不是重点。
    下边分别梳理一下每次步骤中做了什么工作。

    1.加载

    1)通过类全限定名获取定义类的二进制流到内存(定义类二进制流不一定是.class文件);
    2)将字节流代表静态结构转化为方法区的运行时数据结构;
    3)在方法区生成一个java.lang.Class对象,代表这个类的入口;

    2.验证

    主要是验证字节流是否符合JVM规范,是否是一个有效的字节码文件。
    文件格式验证:验证是否符合Class文件格式规范;
    元数据验证:对字节码描述信息语义分析,保证其符合Java语言规范;
    字节码验证:通过数据流|控制流确定程序语义是合法的、符合逻辑的;
    符号引用验证:对类自身以外(常量池中各种符号引用)的信息进行匹配性校验;

    3.准备

    正式为类变量分配内存并设置初始值的阶段,这些变量所使用的内存都在方法区分配。注意类变量指static修饰的变量,而实例对象将会由实例化分配到堆中。注意上边初始值通常是指数据类型的0值,比如public static int value = 100;在准备阶段后的初始值为0而不是100,而把value赋值为100的指令是程序被编译后,存放与类构造器<clinit>方法之中,所以赋值动作是在初始化阶段进行的。但是被final修饰的属性public final static int value = 100;会在准备阶段就将其值赋为100。

    4.解析

    虚拟机将常量池内符号引用替换为直接引用的过程。
    类或接口解析:这里可能触发其他类的加载动作,例如加载这个类的父类或实现的接口;
    字段解析:也就是对字段表中索引的符号引用解析;
    类方法解析:解析接口放发表的class_index项中索引方法所属类或接口符号引用;
    接口方法解析:解析接口放发表的class_index项中索引方法所属类或接口符号引用;

    5.初始化

    类加载的最后一步,是执行类构造器<clinit>方法过程,<clinit>方法由编译器自动收集类中所有类变量(static)的赋值动作和静态代码块中语句合并生成,收集顺序由编写代码顺序决定,静态代码块可以访问定义在前边的类变量,但是定义在其后边的类变量只能对其赋值而不能访问。

        static {
            System.out.println("son static block");
            System.out.println("son static member property = " + staticFrontVar);
    //        System.out.println("son static member property = " + staticVar);//不能引用
            staticBackVar =100;//可以赋值
        }
        private static int staticBackVar = 10;
    

    值得注意的是类构造器<clinit>与实例构造器<init>不同,它不需要显示调用父类的类构造器,虚拟机保证子类的构造器执行之前会执行完父类的类构造器,因此第一个被执行的<clinit>方法的类是Object。<clinit>方法对于类或者接口不是必须的,如果一个类中没有静态成员则不必生成类构造方法。虚拟机会保证一个类的<clinit>方法在多线程中被正确加锁、同步,如果多线程执行一个类的<clinit>方法那么只会有一个线程去执行,这里也是单例模式的饿汉模式的线程安全原因。

    三、小结

    1.各种成员的初始化及赋值时机

    1.常量final修饰在编译阶段会存入调用类的常量池,本质并没有直接引用定义常量的类;
    2.类属性static修饰在准备阶段分配空间并赋初始值,在初始化阶段赋为定义时默认值
    3.静态常量static final修饰在准备阶段分配空间并赋为定义时的默认值
    4.实例成员变量在实例化对象时分配内存并在构造方法之前赋默认值
    5.成员变量代码块执行顺序却决于代码的先后顺序;

    #注意这种情况
    public class Son extends Father {
        private int propertyVar = -1;
        {
            System.out.println("Son method block,>>> " + propertyVar);
            propertyVar = 10;
            index = 10;
        }
        private int index = -1;
        public Son() {
            super();
            System.out.println("Son init . index>> " + index);
            System.out.println("Son init . propertyVar>> " + propertyVar);
        }
    }
    #执行结果
    Son method block,>>> -1
    Son init . index>> -1
    Son init . propertyVar>> 10
    对于propertyVar变量在代码块中对其赋值,按顺序执行就ok;
    对于index变量,定义在代码块之后但在代码块种赋值,则会被定义的默认值覆盖;
    
    2.类初始化时机

    Java虚拟机并没有强制约束类加载的时机,而是在以下几种情况下对类进行初始化:
    1.遇到new、getstatic、putstatic或invokestatic这四个字节码指令时;
    2.使用反射时;
    3.初始化子类时会先初始化其父类;
    4.虚拟机要执行的主类,包含main方法的那个;
    5.jdk1.7中如果java.lang.invoke.MethodHandle实例的最后解析结果REF_getStatic、REF_putStatic、REF_invokeStatic方法句柄,并且这个方法句柄对应类么有进行初始化,则会出发其初始化;

    这五种称之为主动引用,除此之外所有引用都不会进行初始化,比如被动引用

    示例一:
    public abstract class Father {
        public static int superVar = 100;
    }
    
    public class Son extends Father {
    }
    
    public class Tool {
        public static void main(String[] args) {
            System.out.println(Son.superVar);//只会加载父类
        }
    }
    
    示例二:
    public class ConstClass { //不会被加载
         static {
             System.out.print("clinit invoke");
          }
        public static final String HELLO = "hello";
    }
    
    public class NoInitialization {
        public static void mian(String[] args) {
          System.out.print(ConstClass.HELLO );
          }
    }
    
    

    相关文章

      网友评论

          本文标题:记录一个Java类加载相关的问题

          本文链接:https://www.haomeiwen.com/subject/dguulltx.html