1. 先说问题
我司搭建了一个类似于Skywalking的字节码插件平台。基本原理参考谈谈Java Intrumentation和相关应用 。 所以我们就编写了各种神奇的插件。其中就有一个使用Sentinel限流MQ的插件。其核心逻辑就是,当用户空间有Sentinel相关类的时候,就使用Sentinel来做限流。
下面这个SentinelUtil
类是用来判断是否有相关Sentinel
import com.alibaba.csp.sentinel.common.support.CircuitBreakerSupport;
public class SentinelUtil {
private static boolean sentinelDisabled = true;
static {
try {
//检测相关类和对应的方法是否存在
final Class<?> circuitBreakerSupportClass = Class.forName("com.alibaba.csp.sentinel.common.support.CircuitBreakerSupport", false, SentinelUtil.class.getClassLoader());
sentinelDisabled = false;
} catch (Throwable throwable) {
}
}
private SentinelUtil() {
}
public static boolean sentinelDisabled() {
return sentinelDisabled;
}
}
下面是MQ消费的逻辑:不存在Sentinel相关依赖就直接消费,存在的时候使用Sentinel限流消费
public abstract class BaseConcurrentMessageListener implements MessageListenerConcurrently {
// ....
@Override
public ConsumeConcurrentlyStatus consumeMessage(final List<MessageExt> msgs, final ConsumeConcurrentlyContext context) {
final MessageExt messageExt = msgs.get(0);
// 不存在Sentinel相关依赖的时候就直接消费
if (SentinelUtil.sentinelDisabled()) {
return consumeInner(messageExt);
}
// 存在Sentinel相关类的时候就直接使用Sentinel来限流消费
return CircuitBreakerSupport.syncExecute(resourceName, resourceType, origin,
new CircuitBreakerCallback<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus doWithCircuitBreaker() {
// normal consumer logic
return consumeInner(messageExt);
}
},
new CircuitBreakerFallback<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus fallBack() {
// fallBack logic
}
});
}
}
这里补充一下CircuitBreakerSupport用到的两个接口的定义
public interface CircuitBreakerCallback<T> {
T doWithCircuitBreaker();
}
public interface CircuitBreakerFallback<T> {
T fallBack();
}
此时,我们对Sentinel的依赖是provided级别。
<!-- 此依赖是我司对Sentinel的简单封装的jar包,用来简化Sentinel的使用 -->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-common</artifactId>
<scope>provided</scope>
</dependency>
所以上面的代码可以正常编译,但是运行期正常情况下会根据用户空间有没有scope=compile级别的该依赖来走不同的逻辑。
我们做完这个兼容判断后给自己的评价就是:完美。然后我们本地做了自测,测试了有Sentinel compile的依赖以及没有该依赖的场景,都没什么问题,完全在我们的意料之中。
但是,当我们把这个插件放开后真实地在开发环境跑的时候直接启动失败,抛出了一个java.lang.NoClassDefFoundError
异常。
2. 初步分析
看到上面那个错误,我们初步分析如下:
- 用户应该是没有Sentinel的依赖的,不然不会找不到类
- 这个错误的原因不是肯定不是运行了
BaseConcurrentMessageListener
的consumeMessage方法导致的。因为如果是运行时发生的话,应该因为有了判断Sentinel是否存在的逻辑,所以不会走到CircuitBreakerSupport
的syncExecute方法。而且,我们根本就没有发送消息,也就不会出发消费逻辑。
然后我们继续看异常栈,发现是这一行导致的异常:
我们找到那一行代码,如下:
public class DefaultRMQConsumer extends AbstractClientConfig {
private DefaultMQPushConsumer createConsumer(...) throws MQClientException {
//...
// 就是这一行导致的错误
baseConcurrentMessageListener = new NormalConcurrentMessageListener(nameServerAlias, subscribeTable);
//...
}
}
我们发现,这一行代码与我们代码发送唯一关联的就是NormalConcurrentMessageListener
是BaseConcurrentMessageListener
的子类。根据周志明大大总结的类加载的知识
new一个NormalConcurrentMessageListener
确实会导致加载其父类BaseConcurrentMessageListener
。但问题是:CircuitBreakerFallback
只是BaseConcurrentMessageListener
类的一个方法中使用的类。按照周志明大大总结的类加载的知识,不应该是主动使用CircuitBreakerFallback
的时候才会加载该类的吗?在没有主动使用的时候是不应该被加载的。
所以总结起来,按照我掌握的常规知识与现象来解释的话是自相矛盾的:
- 这个异常应该是主动使用该类的时候才会抛出,也就是实际运行
BaseConcurrentMessageListener
的consumeMessage方法才会抛出。 - 如果我们承认上面一个结论是正确的话,那么又会导致实际不会执行到
CircuitBreakerFallback
的方法,也就不会触发上面的异常。
好吧,我要崩溃了。。。
再用我简单的小脑袋瓜总结一下,现在我们有两个问题难以理解:
- 为什么本地没有出现这个异常,到了开发环境就有了这个异常?
- 为什么方法中用到的类被提前加载了?
3. 我的瞎想
根据上面 的两个问题,我自然第一步就联想到了可能的原因:是不是JVM的锅?
难道是JVM在Linux平台上的实现有bug,在windows(我本机是windows)和mac(其他同事用的mac也是一样的问题)上的实现没有bug?这个bug就是:某些情况下会导致类的提前加载。
然后我就去JDK官方issue管理渠道(JBS - JDK Bug System)搜索了ClassLoader相关的issue。
然后我就一个个翻阅了相关的issue。果然jdk还是靠谱的。
4. 我的猜想
在经过上面一轮瞎想之后,我开始反思这个过程可能的原因。然后我又去翻阅了周志明大大关于类加载方面的所有知识。果然,被我翻到了一点蛛丝马迹:
从这段话中,我们可以读出两点:
- 类加载的时机是不确定的,但是类初始化的时机是由JVM规范固定的那5种情况
- 类加载和类的初始化大部分情况下是同时发生的,但是少数情况还是有可能只发生类的加载,不发生类的初始化的
结合到我们这个场景下,实际上就是提前加载了类,但是估计没有初始化。
那到底什么情况下会提前(这里的提前是指没有主动使用类)加载类,但是不发生类的初始化呢?
5. 歪打正着
那既然遇到这个问题了,而且我们还不知道是啥原因的情况下,我们又该怎么解决呢?
我们再来分析下,其实像我们这种处理方式,在很多其他的框架中应该都有类似的方式。
就判断有没有这个类,有的话就使用这个类提供的方法等。没有的话走兜底逻辑。这种兼容逻辑在开源框架中应该都有类似的解决方案。那为什么开源框架没有出现这种问题呢?
肯定有某些条件限制住了该异常的发生。那到底是什么条件呢?
然后,我们就开始了尝试。既然找不到类,那我把找不到的那个类隐藏到另外一个类中是不是就可以了呢?
大体方案就是把限流逻辑隐藏到SentinelUtil
中,然后调用SentinelUtil
的限流方法来做
public class SentinelUtil {
private static boolean sentinelDisabled = true;
static {
try {
//检测相关类和对应的方法是否存在
final Class<?> circuitBreakerSupportClass = Class.forName("com.alibaba.csp.sentinel.common.support.CircuitBreakerSupport", false, SentinelUtil.class.getClassLoader());
sentinelDisabled = false;
} catch (Throwable throwable) {
}
}
private SentinelUtil() {
}
public static boolean sentinelDisabled() {
return sentinelDisabled;
}
/**
* 把限流逻辑移到该方法中
*/
public static <T> T supplySyncExecute(String resourceName, int resourceType, ...) {
// 存在Sentinel相关类的时候就直接使用Sentinel来限流消费
return CircuitBreakerSupport.syncExecute(resourceName, resourceType, origin,
new CircuitBreakerCallback<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus doWithCircuitBreaker() {
// normal consumer logic
return consumeInner(messageExt);
}
},
new CircuitBreakerFallback<ConsumeConcurrentlyStatus>() {
@Override
public ConsumeConcurrentlyStatus fallBack() {
// fallBack logic
}
});
}
private ConsumeConcurrentlyStatus consumeInne(...){
//...消费逻辑
}
}
消费监听器更改如下:
public abstract class BaseConcurrentMessageListener implements MessageListenerConcurrently {
// ....
@Override
public ConsumeConcurrentlyStatus consumeMessage(final List<MessageExt> msgs, final ConsumeConcurrentlyContext context) {
final MessageExt messageExt = msgs.get(0);
// 不存在Sentinel相关依赖的时候就直接消费
if (SentinelUtil.sentinelDisabled()) {
return consumeInner(messageExt);
}
// 限流逻辑调用SentinelUtil的方法
return SentinelUtil.supplySyncExecute(...);
}
}
然后,下面就是见证奇迹的时刻了。我们在开发环境测试竟然没有那个申请的异常了...
所以,隐藏是有用的。我只要退后一步,JVM就不需要看到我了!!!
6. 意外之喜
虽然,我们也不知道为啥就解决了上面的那个问题。但是心总是悬着的。因为在本地无法复现,只能在开发环境验证。那就是说,随时都有可能在本地无法复现,在其他环境有可能复现。那这种风险实际是挺大的。尤其如果没有经过开发和测试环境的验证就直接上生产环境的话,就可能直接嗝屁了。
所以,一直在搜索,却一直没有任何大佬给出相关的解释。
然而,验证了一句话,叫做:再NB的难题,也抵不住傻×似的坚持。
终于在某乎上搜索到了我想要的答案:
大家有兴趣可以看一下大佬的解答。这个博客不仅有实验代码,还有JVM规范内容。可以说是牛逼大发了,正是我想要的。
这篇文章总结起来,就以下几点:
在一个类中存在这种涉及类型cast,即使是隐式的子类cast成父类的行为,就可能导致父类和子类被提前加载。
这种提前加载的行为是发生在校验字节码阶段
7. 验证结论
我们按照上面博客的内容,自己做了对应的实验,确实如博客中所说的一样,在有类型转换的时候,会导致这种提前加载类的行为。
那既然这种行为发生在字节码校验阶段,那是不是说我只要不校验字节码,这种提前加载的行为就不会发生呢?
正好,JVM提供了相关的参数可以用来控制是否验证字节码
-Xverify:none
// 或者
-noverify
然后,我们就在开发环境中先使用我们第一版的代码(出现java.lang.NoClassDefFoundError
异常的代码)跑了一下确实还是会抛出java.lang.NoClassDefFoundError
异常。
然后,我们给JVM加上-noverify参数(或者-Xverify:none )。神奇的事情发生了,没有异常了。意不意外,惊不惊喜。
8. 峰回路转
再回首一下我们之前的两个问题:
- 为什么本地没有出现这个异常,到了开发环境就有了这个异常?
- 为什么方法中用到的类被提前加载了?
现在第二个问题,其实我们已经有答案了:因为在调用CircuitBreakerSupport
的syncExecute方法的时候需要接受一个CircuitBreakerCallback
以及CircuitBreakerFallback
接口类型的参数。又因为实际传入的是一这两个接口类型的匿名内部类,所以在加载``BaseConcurrentMessageListener类的时候需要校验这种存在类型转换的情况,需要需要提前加载接口
CircuitBreakerCallback以及
CircuitBreakerFallback。所以发生了
java.lang.NoClassDefFoundError`异常。
并且,这种情况实际上在JVM规范中是有提到的:
链接如下:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.4.1
那既然如此,到底是为什么我们在本地没有出现这个异常呢?
允许你们停顿一下,思考研究个几分钟。
好吧,不买关子了,直接说出我当时的想法吧。
既然这个问题只要我们加上-noverify参数(或者-Xverify:none )就不会出现该问题,那我们本地开发的时候是不是ide开发工具自动帮我们加上了这个参数了呢?
然后,一启动,一看,世界都亮了。。。。
9. 一探到底
我自己又没有加上这个参数,那究竟是为啥ide要为我加上这样一个神奇的参数呢?然后我就百度了下,真被我找到原因了,竟然是因为这个:
好了,一切真相大白了。
对于SpringBoot项目,【Enbale launch optimization】选项默认是勾选上的。这个选项会给JVM加上两个参数(其中一个就是-noverify参数)。然后我们的异常只会出现在字节码的验证阶段。由于-noverify参数关掉了字节码校验,所以本地是不会出现该异常的。
10. 如何解决
上面,我们讨论了提前加载的原因(可能是一部分原因)。那我们编码的时候如果规避掉提前加载的问题呢?
- 退后一步:将需要校验的类放到另外一个类中(我们之前的解决方案就是这种方案)
- 尽量使用lamda表达式
对于第一种解决方案其实比较好理解,那第二种解决方案究竟是什么意思呢?
我们来看一下具体的代码:https://github.com/wuyupengwoaini/class-load-demo.git
下面就是最核心的测试代码:
package com.demo.load.lambda;
import com.alibaba.fastjson.serializer.JSONSerializer;
import com.alibaba.fastjson.serializer.ObjectSerializer;
import java.io.IOException;
import java.lang.reflect.Type;
public class InterfacesTest {
public static void sayHello() {
System.out.println("hello");
}
public static void testInterfaces(){
InterfacesHolder holder = new InterfacesHolder();
// 不会抛出异常
// 原因:lambda表达式在编译期只会生成方法名类似于lambda$0的静态私有方法,不会生成对应接口实现类的class,对应class是在运行期生成
// 所以在校验本类的字节码的时候是不需要校验类型的
// 关于lambda表达式的实现原理参考:https://www.cnblogs.com/WJ5888/p/4667086.html
//holder.invokeInterfaces((serializer, object, fieldName, fieldType, features) -> {
// do nothing
//});
// 会抛出异常
// 原因:匿名内部类在编译期就生成了对应接口的实现类,所以在校验本类字节码的时候会校验类型
holder.invokeInterfaces(new ObjectSerializer() {
@Override
public void write(JSONSerializer serializer, Object object, Object fieldName, Type fieldType, int features) throws IOException {
// do nothing
}
});
}
public static void main(String[] args) {
InterfacesTest.sayHello();
}
}
你会发现,使用lamda时不会抛出异常的,但是使用匿名内部类是会抛出异常的。
是不是已经智商不够用了呢?
下面我们简单分析下(太深入分析可能需要了解比较多的lamda表达式的实现原理):
对于匿名内部类类,在编译期会生成一个对应的子类:
简单反编译
那实际上这个场景跟我们一开始遇到的场景是一样的。所以还是会抛异常。
那为什么使用lamda表达式就不会抛出异常呢?
首先,使用lamda表达式是不会在编译期生成对应接口的实现类或者父类的子类的:
其次,实际上lamda表达式也会生成实现类,但是是在运行期动态生成的。具体可以参考这篇文章
所以,这样就比较好理解了,因为lamda表达式是在运行期生产的子类,所以在校验字节码的时候根本无法校验。但是匿名内部类在编译期就生产了子类,所以在字节码校验的时候就可以校验对应的子类了。
例子中,还有其他的几种情况会导致类的提前加载,这里简单总结一下:
- 存在类型转换的情况
- catch块中使用异常的情况(这种情况我没有在JVM规范中找到对应的说明)
11. 总结一下
- 类的加载和类的初始化,大部分情况下是同时触发的,少数情况下只有类的加载,没有类的初始化
- 如果存在类型转换,可能会导致会导致提前加载接口或者父类。如果catch块中显示使用异常的情况,那么就会导致提前加载异常类。
- 在使用SpringBoot测试的时候,对于开发字节码植入逻辑的同学来说,一定要关掉【Enbale launch optimization】选项来测试