Bug 背景
产品部门突然提了个需求,APP 中所有的对话框选择框啊只要是弹出来的框风格全部要统一成一种样式,所以为了应对各种各样的 Dialog ,我就写了个 BaseDialog 基类,只封装了最基础的样式。对话框至少要包含:普通提示、警告提示、信息确认框、普通文本选择器、时间选择器、日期选择器、城市选择器等等。所以不同种类的对话框也需要定义基类,这样一来继承关系就越来越复杂,问题也就来了。
我日期时间选择器 C 继承的是日期选择器 B,日期选择器 B 继承的是具有选择框的基类 A,A 继承了 BaseDialog 大概是 C ->B->A->BaseDialog 这样子。
现象
我在日期时间选择器 C 类中定义了两个List:
private List<String> mHourList = new ArrayList();
private List<String> mMinuteList = new ArrayList();
然后再实例化完成后向其中添加数据:
protected void initData(){
for(int i = 0; i < 24; i++){
mHourList.add( i + "");
}
for(int i = 0; i < 60; i++){
mMinuteList.add( i + "");
}
这么看起来也没什么问题吧?结果一运行突然就报了空指针异常,我有点懵,变量定义时我就初始化了怎么是空了呢,我以为是在某个地方被释放掉了,没仔细看就直接添加了空指针判断再试一下:
protected void initData(){
if(mHourList== null){
mHourList = new ArrayList<>();
}
for(int i = 0; i < 24; i++){
mHourList.add( i + "");
}
if(mMinuteList == null){
mMinuteList = new ArrayList<>();
}
for(int i = 0; i < 60; i++){
mMinuteList.add( i + "");
}
这下是没空指针,结果调用 show() 显示对话框时又发现数据都没了???
这下子就很诡异了,肯定和刚刚那个空指针是一个问题,我把代码仔细检查了一下,没有发现哪个地方释放过这俩 List,结合刚刚添加完数据又变成空的问题,答案显而易见,添加数据发生在变量初始化之前,添加完成之后才开始初始化,所以数据都没了。我以为是指令重排,就加了个 volatile 关键字修饰,然而也没什么用。
真相
排除掉上面说的原因,那么真相只有一个!initData 方法发生在构造器之前!!!
然后我去父类看了下,果不其然,父类也有个 initData 方法,而且是在构造器里调用的,所以当我在实例化 C 类时会先调用 B 类的构造器,B 类的构造器又调用了 initData 方法,但是 initData 方法被子类重写了,于是在 A 还没有实例化时就调用了 initData 方法,这才导致了空指针异常。
重现
那么我再来尝试简化并重现这个问题,首先定义 A 类(此处的 A 类与上面说的 A 类无关):
public class A{
static{
System.out.println("A->static {}");
}
{
System.out.println("A->{}");
}
public A(){
System.out.println("A->A()");
init();
}
protected void init(){
System.out.println("A->init()");
}
}
A 类会在类加载以及实例化时打印出几条日志,然后定义 B 类继承 A(此处 B 类与上面说的 B 无关):
public class B extends A{
static{
System.out.println("B->static{}");
}
{
System.out.println("B->{}");
}
private String mTestStr = "123test";
public B(){
super();
System.out.println("B->B():" + mTestStr);
}
public void init(){
System.out.println("B->init():" + mTestStr);
}
public static void main(String[] args){
B b = new B();
}
}
运行一下:
可以看到,A 在初始化之后就直接调用了 B 的 init 方法,此时的 B 还未实例化,所以 mTestStr 变量的值为 null,然后继续实例化 B ,在调用 init 方法时才有值。
类加载机制
当时机成熟时 JVM 会把一个类加载到内存中,加载过程为:验证、准备、解析、初始化等等。
初始化时会根据计划初始化类变量和其他资源,JVM 会创建一个名为 <cinit>() 的方法来初始化类,这个方法类似于构造器,是编译期自动收集类中的静态变量赋值、静态语句块合并产生的。并且父类的 <cinit>() 方法肯定在子类的 <cinit>() 之前执行。
与此对应的是 <init>() 方法,也就是实例初始化方法,其中会先执行父类的 <init>() 方法,然后执行包括实例对象的初始化动作以及构造器中的代码,这也就解释了上面的那个现象。