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

一、问题始末

临近下班在改一段别人的代码,遇到了一个特别奇怪(当时以为)的问题,大致上是一个抽象的父类,在构造方法中调用了内部的抽象方法,然后抽象方法的具体实现也就是子类中修改了子类的一个成员变量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 );
      }
}

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 217,826评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 92,968评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,234评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,562评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,611评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,482评论 1 302
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,271评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,166评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,608评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,814评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,926评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,644评论 5 346
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,249评论 3 329
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,866评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,991评论 1 269
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,063评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,871评论 2 354

推荐阅读更多精彩内容