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
覆盖父类同名属性name
的 set
方法后,对应的值输出为 null
,而子类Son
却输出了符合预期的正确值?
答疑解惑
在这段代码中,Child
类中并没有重写父类 AbstractParent
的hello()
方法。因此,当调用child.hello()
时,实际上是调用了父类AbstractParent
的hello
方法,即使你在子类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 父子类同名属性的坑。提醒我们一定要敬畏线上。因为一旦口碑丢失,客户不信任我们了,那基本上公司离死亡也就不远了。