Java class被提前加载之深度历险记

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 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() {
                @Override
                public ConsumeConcurrentlyStatus doWithCircuitBreaker() {
                    // normal consumer logic
                    return consumeInner(messageExt);
                }
            },
            new CircuitBreakerFallback() {
                @Override
                public ConsumeConcurrentlyStatus fallBack() {
                    // fallBack logic 
                }
            });

    }
}

这里补充一下CircuitBreakerSupport用到的两个接口的定义

public interface CircuitBreakerCallback {
    T doWithCircuitBreaker();
}

public interface CircuitBreakerFallback {
    T fallBack();
}

此时,我们对Sentinel的依赖是provided级别。

        
        
            com.alibaba.csp
            sentinel-common
            provided
         

所以上面的代码可以正常编译,但是运行期正常情况下会根据用户空间有没有scope=compile级别的该依赖来走不同的逻辑。

我们做完这个兼容判断后给自己的评价就是:完美。然后我们本地做了自测,测试了有Sentinel compile的依赖以及没有该依赖的场景,都没什么问题,完全在我们的意料之中。

完美

但是,当我们把这个插件放开后真实地在开发环境跑的时候直接启动失败,抛出了一个java.lang.NoClassDefFoundError异常。

image-20211228193633335
7

2. 初步分析

看到上面那个错误,我们初步分析如下:

  1. 用户应该是没有Sentinel的依赖的,不然不会找不到类
  2. 这个错误的原因不是肯定不是运行了BaseConcurrentMessageListener的consumeMessage方法导致的。因为如果是运行时发生的话,应该因为有了判断Sentinel是否存在的逻辑,所以不会走到CircuitBreakerSupport的syncExecute方法。而且,我们根本就没有发送消息,也就不会出发消费逻辑。

然后我们继续看异常栈,发现是这一行导致的异常:

image-20211228195122260

我们找到那一行代码,如下:

public class DefaultRMQConsumer extends AbstractClientConfig {
    private DefaultMQPushConsumer createConsumer(...) throws MQClientException {
        //...
        
        // 就是这一行导致的错误
        baseConcurrentMessageListener = new NormalConcurrentMessageListener(nameServerAlias, subscribeTable);
        
        //...
    }
}

我们发现,这一行代码与我们代码发送唯一关联的就是NormalConcurrentMessageListenerBaseConcurrentMessageListener的子类。根据周志明大大总结的类加载的知识

image-20211228195731369

new一个NormalConcurrentMessageListener确实会导致加载其父类BaseConcurrentMessageListener。但问题是:CircuitBreakerFallback只是BaseConcurrentMessageListener 类的一个方法中使用的类。按照周志明大大总结的类加载的知识,不应该是主动使用CircuitBreakerFallback的时候才会加载该类的吗?在没有主动使用的时候是不应该被加载的。

所以总结起来,按照我掌握的常规知识与现象来解释的话是自相矛盾的:

  1. 这个异常应该是主动使用该类的时候才会抛出,也就是实际运行BaseConcurrentMessageListener的consumeMessage方法才会抛出。
  2. 如果我们承认上面一个结论是正确的话,那么又会导致实际不会执行到CircuitBreakerFallback的方法,也就不会触发上面的异常。

好吧,我要崩溃了。。。

再用我简单的小脑袋瓜总结一下,现在我们有两个问题难以理解:

  1. 为什么本地没有出现这个异常,到了开发环境就有了这个异常?
  2. 为什么方法中用到的类被提前加载了?

3. 我的瞎想

根据上面 的两个问题,我自然第一步就联想到了可能的原因:是不是JVM的锅?

难道是JVM在Linux平台上的实现有bug,在windows(我本机是windows)和mac(其他同事用的mac也是一样的问题)上的实现没有bug?这个bug就是:某些情况下会导致类的提前加载。

然后我就去JDK官方issue管理渠道(JBS - JDK Bug System)搜索了ClassLoader相关的issue。

image-20211229101426032

然后我就一个个翻阅了相关的issue。果然jdk还是靠谱的。

4. 我的猜想

在经过上面一轮瞎想之后,我开始反思这个过程可能的原因。然后我又去翻阅了周志明大大关于类加载方面的所有知识。果然,被我翻到了一点蛛丝马迹:

image-20211229101942228

从这段话中,我们可以读出两点:

  1. 类加载的时机是不确定的,但是类初始化的时机是由JVM规范固定的那5种情况
  2. 类加载和类的初始化大部分情况下是同时发生的,但是少数情况还是有可能只发生类的加载,不发生类的初始化的

结合到我们这个场景下,实际上就是提前加载了类,但是估计没有初始化。

那到底什么情况下会提前(这里的提前是指没有主动使用类)加载类,但是不发生类的初始化呢?

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 supplySyncExecute(String resourceName, int resourceType, ...) {
       // 存在Sentinel相关类的时候就直接使用Sentinel来限流消费
        return CircuitBreakerSupport.syncExecute(resourceName, resourceType, origin,
            new CircuitBreakerCallback() {
                @Override
                public ConsumeConcurrentlyStatus doWithCircuitBreaker() {
                    // normal consumer logic
                    return consumeInner(messageExt);
                }
            },
            new CircuitBreakerFallback() {
                @Override
                public ConsumeConcurrentlyStatus fallBack() {
                    // fallBack logic 
                }
            });
    }
    
    private ConsumeConcurrentlyStatus consumeInne(...){
        //...消费逻辑
    }
}

消费监听器更改如下:

public abstract class BaseConcurrentMessageListener implements MessageListenerConcurrently {
    // ....
    @Override
    public ConsumeConcurrentlyStatus consumeMessage(final List msgs, final ConsumeConcurrentlyContext context) {
        final MessageExt messageExt = msgs.get(0);

        // 不存在Sentinel相关依赖的时候就直接消费
        if (SentinelUtil.sentinelDisabled()) {
            return consumeInner(messageExt);
        }
        // 限流逻辑调用SentinelUtil的方法
        return SentinelUtil.supplySyncExecute(...);

    }
}

然后,下面就是见证奇迹的时刻了。我们在开发环境测试竟然没有那个申请的异常了...

所以,隐藏是有用的。我只要退后一步,JVM就不需要看到我了!!!

6. 意外之喜

虽然,我们也不知道为啥就解决了上面的那个问题。但是心总是悬着的。因为在本地无法复现,只能在开发环境验证。那就是说,随时都有可能在本地无法复现,在其他环境有可能复现。那这种风险实际是挺大的。尤其如果没有经过开发和测试环境的验证就直接上生产环境的话,就可能直接嗝屁了。

所以,一直在搜索,却一直没有任何大佬给出相关的解释。

然而,验证了一句话,叫做:再NB的难题,也抵不住傻×似的坚持。

终于在某乎上搜索到了我想要的答案:

关于Java class被提前加载的问题记录

大家有兴趣可以看一下大佬的解答。这个博客不仅有实验代码,还有JVM规范内容。可以说是牛逼大发了,正是我想要的。

这篇文章总结起来,就以下几点:

  1. 在一个类中存在这种涉及类型cast,即使是隐式的子类cast成父类的行为,就可能导致父类和子类被提前加载。

  2. 这种提前加载的行为是发生在校验字节码阶段

7. 验证结论

我们按照上面博客的内容,自己做了对应的实验,确实如博客中所说的一样,在有类型转换的时候,会导致这种提前加载类的行为。

那既然这种行为发生在字节码校验阶段,那是不是说我只要不校验字节码,这种提前加载的行为就不会发生呢?

正好,JVM提供了相关的参数可以用来控制是否验证字节码

-Xverify:none  
// 或者 
-noverify

然后,我们就在开发环境中先使用我们第一版的代码(出现java.lang.NoClassDefFoundError异常的代码)跑了一下确实还是会抛出java.lang.NoClassDefFoundError异常。

然后,我们给JVM加上-noverify参数(或者-Xverify:none )。神奇的事情发生了,没有异常了。意不意外,惊不惊喜。

8. 峰回路转

再回首一下我们之前的两个问题:

  1. 为什么本地没有出现这个异常,到了开发环境就有了这个异常?
  2. 为什么方法中用到的类被提前加载了?

现在第二个问题,其实我们已经有答案了:因为在调用CircuitBreakerSupport的syncExecute方法的时候需要接受一个CircuitBreakerCallback以及CircuitBreakerFallback接口类型的参数。又因为实际传入的是一这两个接口类型的匿名内部类,所以在加载``BaseConcurrentMessageListener类的时候需要校验这种存在类型转换的情况,需要需要提前加载接口CircuitBreakerCallback以及CircuitBreakerFallback。所以发生了java.lang.NoClassDefFoundError`异常。

并且,这种情况实际上在JVM规范中是有提到的:

image-20211229134446738

链接如下:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-5.html#jvms-5.4.1

那既然如此,到底是为什么我们在本地没有出现这个异常呢?

允许你们停顿一下,思考研究个几分钟。


好吧,不买关子了,直接说出我当时的想法吧。

既然这个问题只要我们加上-noverify参数(或者-Xverify:none )就不会出现该问题,那我们本地开发的时候是不是ide开发工具自动帮我们加上了这个参数了呢?

然后,一启动,一看,世界都亮了。。。。

1

9. 一探到底

我自己又没有加上这个参数,那究竟是为啥ide要为我加上这样一个神奇的参数呢?然后我就百度了下,真被我找到原因了,竟然是因为这个:

image-20211229135949477

好了,一切真相大白了。

对于SpringBoot项目,【Enbale launch optimization】选项默认是勾选上的。这个选项会给JVM加上两个参数(其中一个就是-noverify参数)。然后我们的异常只会出现在字节码的验证阶段。由于-noverify参数关掉了字节码校验,所以本地是不会出现该异常的。

10. 如何解决

上面,我们讨论了提前加载的原因(可能是一部分原因)。那我们编码的时候如果规避掉提前加载的问题呢?

  1. 退后一步:将需要校验的类放到另外一个类中(我们之前的解决方案就是这种方案)
  2. 尽量使用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表达式的实现原理):

对于匿名内部类类,在编译期会生成一个对应的子类:

image-20211229143441547

简单反编译

image-20211229143505296

那实际上这个场景跟我们一开始遇到的场景是一样的。所以还是会抛异常。

那为什么使用lamda表达式就不会抛出异常呢?

首先,使用lamda表达式是不会在编译期生成对应接口的实现类或者父类的子类的:

image-20211229144312504

其次,实际上lamda表达式也会生成实现类,但是是在运行期动态生成的。具体可以参考这篇文章

Java 8 Lambda实现原理分析

所以,这样就比较好理解了,因为lamda表达式是在运行期生产的子类,所以在校验字节码的时候根本无法校验。但是匿名内部类在编译期就生产了子类,所以在字节码校验的时候就可以校验对应的子类了。

例子中,还有其他的几种情况会导致类的提前加载,这里简单总结一下:

  1. 存在类型转换的情况
  2. catch块中使用异常的情况(这种情况我没有在JVM规范中找到对应的说明)

11. 总结一下

  1. 类的加载和类的初始化,大部分情况下是同时触发的,少数情况下只有类的加载,没有类的初始化
  2. 如果存在类型转换,可能会导致会导致提前加载接口或者父类。如果catch块中显示使用异常的情况,那么就会导致提前加载异常类。
  3. 在使用SpringBoot测试的时候,对于开发字节码植入逻辑的同学来说,一定要关掉【Enbale launch optimization】选项来测试

你可能感兴趣的:(Java class被提前加载之深度历险记)