java 父子类同名属性踩坑

问题背景

最近排查生产环境线程夯死的问题,遇到一个 npe (NullPointException) 异常,隐藏很深,费了半天劲,才抓到该 bug,特此记之。

剥离业务逻辑,简化后的代码如下:

public abstract class AbstractParent {
    private String name;

    public void setName(String name) {
        this.name = name;
    }

    public void hello() {
        System.out.println("i am " + this.name);
    }
}

public class Child extends AbstractParent {
    private String name;

    @Override
    public void setName(String name) {
        this.name = name;
    }
}

public class Son extends AbstractParent {
}

@Test
public void testParentChild() {
    AbstractParent child = new Child();
    child.setName("child");
    child.hello();

    AbstractParent son = new Son();
    son.setName("son");
    son.hello();
}

运行测试方法 testParentChild(),结果如下:

i am null
i am son

问题来了,同样都是继承,为什么当子类Child覆盖父类同名属性nameset方法后,对应的值输出为 null,而子类Son却输出了符合预期的正确值?

答疑解惑

在这段代码中,Child类中并没有重写父类 AbstractParenthello()方法。因此,当调用child.hello()时,实际上是调用了父类AbstractParenthello方法,即使你在子类Child中设置了name值,父类AbstractParent也不知道这个变化,因为它只能访问自己的私有变量,最终输出的是父类AbstractParent的私有属性name的值。

对于Son类,它既没有重写setName()方法,也没有重写hello()方法,所以当调用son.setName("son")son.hello()时,它们的行为与父类完全相同:setName()设置的是父类的私有属性name,而hello()打印的也是父类的私有属性name

java 继承中子类和父类同名的属性和方法的使用、重载: https://blog.csdn.net/javahelpyou/article/details/125926617 ,这篇文章提到了动态绑定技术,很值得学习,聪明的你一定要看一下。

解决方案

好了,知道了原因,我们来解决该问题。

方案一

在子类覆写同名属性的同时,设置父类属性。

public class Child extends AbstractParent {
    private String name;
    @Override
    public void setName(String name) {
        // 优化方案一
        super.setName(name);
        this.name = name;
    }
}

方案二

无须花里胡哨,直接将同名的属性和set方法全部删掉。

public class Child extends AbstractParent {
}

其实这是最佳方案,同名属性,完全没有必要子类自己搞一个,去增加理解和维护成本。

拓展衍生

这个问题抽象成上述模型,似乎很容易定位,但是工作中结合了Spring框架、抽象类、自动注入等技术,再加上代码的臃肿,问题就变得很隐蔽。我们不妨来看下,这个 bug 是怎么写出来的:

public abstract class AbstractParentHandler implements InitializingBean {
    private MyService myService;

    public void setMyService(MyService myService) {
        this.myService = myService;
    }

    public void handle() {
        try {
            beforeHandle();
            process();
        } catch(Throwable t) {
            LOGGER.error(t);
            myService.lambdaUpdate().update();
        } finally {
            THREAD_LOCAL.remove();
        }
    }
}

@Service
public class ChildHandler extends AbstractParentHandler {
    private MyService myService;
    @Override
    public void setMyService(MyService myService) {
        this.MyService = myService;
    }
}

很明显,这位同学使用了模板方法的设计模式,抽象出了父类AbstractParentHandler,然后让不同子类去继承父类的handle()方法,以达到复用同一业务逻辑的目的。

初衷很好,设计模式结合Spring框架,简洁而优雅。但是只要你注意到子类setMyService()上出现了@Override注解,稍微停顿思考一下,同样一个无状态的Service类,其实完全没有必要在子类中再去单独注入一次。

反思改进

从开发的角度思考

  • 日志不规范。我注意到这个Service被调用时,除了自己在catch块中,它所在的方法外层包裹了一个try...finally块,并没有用catch进行处理,哪怕打个日志也能很快发现问题。
  • 自测不到位(占导致该问题的 80%)。这块代码Service是在一个catch块中被使用,说明当时只是跑通了主流程,而忽视了异常分支catch块的验证。开发完毕,自测乃至进入到测试环境,如果进入了异常分支,数据库记录没有更新,是极有可能发现问题的。但是我们都没有发现,让它上了生产环境。这要求我们开发同学,一定要去自测,保证各分支能够走到。
  • 心理上不重视。为什么自测不到位?主观上没有重视,忽视了特殊场景。该业务逻辑只是用于清理磁盘上很久不用的文件,点点滴滴的不关注,最后文件堆积,严重了就会对服务器上的其他进程造成影响。我们要意识到这属于做好了没有盈收,做坏了致命的业务,做不好很可能会丢失我们的口碑,所以一定要敬畏线上。
  • 线上复核不到位。一个 2021 年 11 月投产的代码,直到今年 2023 年 12 月才被发现,两年的时间,如果说这个 bug 的触发需要条件,我想中途一定有某一次出现过问题,需要研发同学去排查。等到排查完毕再投产,以为解决了问题,实际没有解决问题,后面没有继续跟进了。

从测试的角度思考

  • 对问题的回归验证不仔细,心理上不重视。边缘业务,清理文件,测过了就过了,不会对主业务造成影响。改进方案同开发。

总结

这次问题排查,从一个线程夯死,定位到 java 父子类同名属性的坑。提醒我们一定要敬畏线上。因为一旦口碑丢失,客户不信任我们了,那基本上公司离死亡也就不远了。

posted @ 2023-12-10 01:32  ZERO_FAITH  阅读(262)  评论(0编辑  收藏  举报