合成字段?一个线上问题带你回顾类加载子系统。全程高能
场景复现
代码小考查
- 在不考虑代码规范及写法的前提下,判断以下代码是否可以正常运行结束?
- 其实代码很简单,很容易看出来的。可以在弹幕上写出来。
- 代码主要有几部分:静态代码块,main方法,Student类,Property注解。
- 答案是能够能够正常执行,代码没有任何问题,仅是写法上不够优雅,以及可能有风险,但是跑还是可以跑得,不信我给你跑一个。
-
结果居然报错了,这不是怕怕打脸么。刚刚说可以正常运行结束的都给自己两耳光!
-
但这不是违背常理了吗,高斯林(Java之父)来了也得说可以正常运行并结束。
-
别急,各位客户请听我慢慢道来。
场景描述
- 这是我当时遇到的一个线上问题,但是代码并不是我写的哈,而是我当时带的实习生写的。
- 代码也很简单,由于涉及到公司项目,我就把场景还原了。
- 大致逻辑就是一个类有一个静态代码块,在静态代码块中读取所有字段的
Property
注解中的value值,作为map的key,而当前字段作为map的value添加到map中。也就是构建了一个<String, Field>Map。 - 当时代码在本地跑的非常顺畅,简直就是丝滑的不能再丝滑。
- 然后一上线就报错了。
排查思路
-
由于当时场景比较特殊,拿不到报错的那些异常信息,这个原因我会在后面介绍(很硬的哟,干货满满)。只知道大概是当前这个类的问题。
-
其实这里的关键代码也就是static静态代码块了。逻辑就是拿
Student
类的所有字段,然后获取Property
注解的value。虽然这里的写法是一段高危写法(字段如果没有Property
注解就直接获取value就会空指针),但是抵不住我的Student
两个字段都有Property
注解啊,也都有value属性。那这就不可能报错啊。
-
关键是本地还是正常的,代码看破天也没问题,还拿不到正确的异常信息(后面会解释),但是线上还是有问题。
-
只能祭出终极大法了,try+catch+日志。合理怀疑stati静态代码块的问题,给整个static代码块都try起来了,捕获异常并打印日志。
-
重新打包发布,终于看到异常信息了,但是也就一个空指针,没有任何额外的信息。但看瞎了也没可能空指针啊。
-
我突然想起眼见不一定为实,如果空指针,那就把最有可能空指针的地方找出来。毫无疑问是
field.getDeclaredAnnotation(Property.class).value()
。如果字段没有Property
注解就会空指针。那就把Student
类的所有字段都打印出来,看看他到底有哪些字段。 -
线上环境居然多了一个字段,
$jacocoData
这个字段是什么鬼?为什么线上环境会比测试环境多出一个字段?这个字段到底是干啥的呢?又为什么会空指针呢?
揭秘时刻
-
为什么会多出一个
$jacocoData
字段?而本地环境没有。-
首先,jacoco是一个统计代码覆盖率的工具。
-
其实就是因为线上环境使用了jacoco,将jacoco作为javaagent运行。增强了我们的代码,用于统计我们的代码覆盖率。
-
我这里为了模拟这种场景,使用了覆盖运行,它也会使用jacoco,也就能够模拟线上环境使用jacoco的javaagent了。
-
-
为什么会空指针?
- 因为jacoco要统计代码覆盖率,就需要增强我们的代码,他就往类中加了一个字段,
$jacocoData
,用于辅助统计代码覆盖率。 - 由于这个字段是jacoco合成了,那当然也就没有
Property
注解了,所以我们去获取他的value也就会空指针。
- 因为jacoco要统计代码覆盖率,就需要增强我们的代码,他就往类中加了一个字段,
如何解决
-
这个问题解决其实很简单,代码本身写的就不优雅,并且属于高危代码。
- 首先是可以直接对
field.getDeclaredAnnotation(Property.class)
直接判空,不为空在范围value属性。 - 或者是使用
field.isAnnotationPresent(Property.class)
判断字段是否存在Property
注解。 - 直接判断字段是否为合成字段
field.isSynthetic()
,如果是合成字段则过滤掉。 - 我的建议是不论字段是否是合成字段,获取注解的相关属性是,都应该先确定字段存在当前注解。当然value也有可能为空哈,我这里是有默认值,就直接获取了(错误示范)。
- 首先是可以直接对
面试
- 把这个问题搞明白了,加在面试里面,平时开发中遇到过什么问题,那不是爽歪歪啊,涉及知识面也广。
- 在之前的面试中回答过,但是还有一些东西没有弄清楚。
- 感兴趣的可以去看看 https://www.bilibili.com/video/BV1ro4y1K7uv 1:00–4:30
真的正确吗?
-
如果不使用jacoco,那么就没有这个合成字段,那这个代码就能够完美运行了吗?大家思考一下,可以发送弹幕记录一下。
-
不要在乱说了哈,不然高斯林的棺材板压不住了,不对,他还没有到那一步,还没有棺材板。
-
答案是不,在某些情况下他依旧不能够正常运行。
-
这是因为内部类,lambda表达式这些,也会生成合成字段,标识这个表达式属于哪个类。当前内部类属于哪个外部类。
-
为何拿不到异常信息【类加载子系统】
-
我们的业务系统是springboot的web项目,然后当前功能是通过接口去触发的,在第一次调用接口才会触发完整的异常信息,后面调用接口都是其他异常。
-
这是因为在第一次调用接口时,由于内存中还没有这个类,jvm就会尝试去加载这个类,根据异常堆栈可以看到,首先是空指针异常,访问了不存在的实例。由于空指针发生在static代码块中;在编译阶段,编译器会收集所有的静态字段和static静态代码块,构建
<clinit>
方法。 -
clinit方法中发送空指针异常,clinit方法也就执行失败。然后jvm就抛出了
ExceptionInInitializerError
异常,表示静态初始化失败。 -
那我们的clinit执行在类加载的那个阶段呢,先说结论,初始化就是执行
<clinit>
的过程,注意,初始化方法不是构造器哈。- 类加载整体分为三个阶段:加载,链接,初始化。
- 加载:将class文件从磁盘加载到jvm内存(使用双亲委派模型)。
- 链接:链接又分为三个阶段。
- 验证:文件格式校验(开头字节是否为0X CAFEBABE),版本校验…
- 准备:静态变量赋默认值
- 解析:将常量池中的符号引号转换为直接引用的过程
- 初始化:执行
<clinit>
方法。给静态变量赋程序中的真实值,执行静态代码块。
-
现在也就是加载,链接阶段都成功了,但是在初始化阶段失败了。那么jvm就会认定当前类加载失败。并且不会重新加载当前类。为什么?
- 确定性:Java虚拟机保证在程序的生命周期内,一个类在初始化时只会执行一次
<clinit>()
方法。如果重新加载,就会违反这一保证。 - 性能:重新加载类是一个昂贵的操作,如果一个类初始化失败,重新加载它并不会解决根本问题,反而可能浪费资源。 因此,一旦类加载过程中的
<clinit>()
方法执行失败,Java虚拟机就会将该类标记为错误状态,并且不会再次尝试加载这个类。开发者需要根据错误信息来修正类定义中的问题,然后重新编译和部署,以确保类能够正确加载和执行。
- 确定性:Java虚拟机保证在程序的生命周期内,一个类在初始化时只会执行一次
-
所以,我们在去访问这个类时,jvm就不会再去加载这个类,但是方法区中又没有当前类,要怎么办呢,那不如抛出一个异常吧,也就是
NoClassDefFoundError
(未找到类定义错误)异常。不同于ClassNotFoundException
(类未找到异常)。NoClassDefFoundError
它是能够找到这个类,但是没有相应的类定义。 -
同理,在web程序中,第一次请求接口时,去尝试加载类,加载失败之后,后续不会重新加载。而是在再次访问类是抛出
NoClassDefFoundError
.
总结
- 使用
Jacoco
会生成合成字段,获取字段注解时应该注意判空。 - 内部类,lambda也有可能生成合成字段。
- 判断是否为合成字段:
field.isSynthetic()
- 类加载整体分为三个阶段:加载,链接,初始化。执行静态代码块在初始化阶段,也就是执行
<clinit>
方法。 <clinit>
方法执行失败会抛出ExceptionInInitializerError
异常初始化类错误,并且不会重新加载类,后续使用类时,会抛出NoClassDefFoundError
(无类定义错误)。
结语
- 记得3连多多支持一下,也可以点个关注。
- 后续也会更新更多干货