医生我觉得我可以再抢救一下 發表於 2025-10-21 20:10:00

深入认识ClassLoader - 一次投产失败的复盘

<h4 id="问题背景">问题背景</h4>
<p>投产日,同事负责的项目新版本发布,版本包是<code>SpringBoot v2.7.18</code>的一个<code>FatJar</code>,<code>java -jar</code>启动报错停止了,输出的异常日志如下:</p>
<pre><code class="language-shell">Caused by: org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'dataSource' defined in class path resource : Invocation of init method failed; nested exception is org.springframework.boot.autoconfigure.jdbc.DataSourceProperties$DataSourceBeanCreationException: Failed to determine a suitable driver class

      ...省略
      
Caused by: org.springframework.boot.autoconfigure.jdbc.DataSourceProperties$DataSourceBeanCreationException: Failed to determine a suitable driver class
      at org.springframework.boot.autoconfigure.jdbc.DataSourceProperties.determineDriverClassName(DataSourceProperties.java:186)
      at org.springframework.boot.autoconfigure.jdbc.DataSourceProperties.determineUsername(DataSourceProperties.java:280)
      at com.alibaba.druid.spring.boot.autoconfigure.DruidDataSourceWrapper.afterPropertiesSet(DruidDataSourceWrapper.java:40)
      
       ...省略
</code></pre>
<p>版本回退,正好我也在旁边,记录下一起排查解决的过程。</p>
<h4 id="定位与解决问题">定位与解决问题</h4>
<h5 id="分析错误日志">分析错误日志</h5>
<p>拉了版本分支代码,从下往上看输出的错误日志,发现是<code>DruidDataSourceWrapper</code>这个类中40行出错,看下这个类以及出错的位置:</p>
<pre><code class="language-java">@ConfigurationProperties("spring.datasource.druid")
class DruidDataSourceWrapper extends DruidDataSource implements InitializingBean {
    @Autowired
    private DataSourceProperties basicProperties;

    @Override
    public void afterPropertiesSet() throws Exception {
      //if not found prefix 'spring.datasource.druid' jdbc properties ,'spring.datasource' prefix jdbc properties will be used.
      if (super.getUsername() == null) {
            // 关键行:这一行出错,basicProperties.determineUsername()这个方法会出现异常
            super.setUsername(basicProperties.determineUsername());
      }
      if (super.getPassword() == null) {
            super.setPassword(basicProperties.determinePassword());
      }
      if (super.getUrl() == null) {
            super.setUrl(basicProperties.determineUrl());
      }
      if (super.getDriverClassName() == null) {
            super.setDriverClassName(basicProperties.getDriverClassName());
      }
    }
    ...
</code></pre>
<p><code>DruidDataSourceWrapper</code>归属于<code>druid-spring-boot-starter</code>这个依赖,是 alibaba druid 数据库连接池的一个 starter。</p>
<p>结合错误日志看下<code>basicProperties.determineUsername()</code>这个方法里面出错的位置:</p>
<pre><code class="language-java">public String determineUsername() {
    if (StringUtils.hasText(this.username)) {
      return this.username;
    }
    // 关键行:调用determineDriverClassName()这个方法出错
    if (EmbeddedDatabaseConnection.isEmbedded(determineDriverClassName(), determineUrl())) {
      return "sa";
    }
    return null;
}
</code></pre>
<p>再次结合错误日志看下<code>determineDriverClassName()</code>这个方法里面出错的位置:</p>
<pre><code class="language-java">public String determineDriverClassName() {
    if (StringUtils.hasText(this.driverClassName)) {
      Assert.state(driverClassIsLoadable(), () -&gt; "Cannot load driver class: " + this.driverClassName);
      return this.driverClassName;
    }
    String driverClassName = null;
    if (StringUtils.hasText(this.url)) {
      driverClassName = DatabaseDriver.fromJdbcUrl(this.url).getDriverClassName();
    }
    if (!StringUtils.hasText(driverClassName)) {
      driverClassName = this.embeddedDatabaseConnection.getDriverClassName();
    }
    if (!StringUtils.hasText(driverClassName)) {
      // 关键行:在这边抛出的异常
      throw new DataSourceBeanCreationException("Failed to determine a suitable driver class", this,
                this.embeddedDatabaseConnection);
    }
    return driverClassName;
}
</code></pre>
<p>定位到了出错的位置,分析这块代码抛出异常的原因,意思就是如果<code>spring.datasource.druid.username</code>这个配置的值为空,那么读取<code>spring.datasource.username</code>这个配置,如果还是空,尝试从<code>spring.datasource.url</code>配置信息中解析<code>jdbc</code>驱动类,解析不出来就抛出<code>DataSourceBeanCreationException</code>异常。</p>
<h5 id="版本变动点">版本变动点</h5>
<p>是配置信息有问题?</p>
<p>问了下这个项目的配置原本是放在配置文件中的,公共配置放在了<code>application.yml</code>中,不同环境的配置采用<code>application-{profile}.yml</code>放置,如下:</p>
<pre><code class="language-yaml">application.yml
application-dev.yml
...
application-pro.yml
</code></pre>
<p>在<code>application.yml</code>中使用占位符借助 maven 打包时添加<code>-P</code>参数设置激活的<code>profile</code>:</p>
<pre><code class="language-yaml">spring:
profiles:
    # env
    active: @env@
</code></pre>
<p>项目 pom 文件中多个 profile 配置如下(这是本次版本的一个变动点):</p>
<pre><code class="language-xml">&lt;profiles&gt;
    &lt;!-- DEV 开发环境--&gt;
    &lt;profile&gt;
      &lt;id&gt;dev&lt;/id&gt;
      &lt;properties&gt;
            &lt;env&gt;DEV&lt;/env&gt;
            ...
      &lt;/properties&gt;
    &lt;/profile&gt;
    ...
    &lt;!-- PRO 生产环境--&gt;
    &lt;profile&gt;
      &lt;id&gt;pro&lt;/id&gt;
      &lt;properties&gt;
            &lt;env&gt;PRO&lt;/env&gt;
            ...
      &lt;/properties&gt;
    &lt;/profile&gt;
&lt;/profiles&gt;

</code></pre>
<p>maven 打生产包,<code>spring.profiles.active</code>的值被设置成了<code>PRO</code>,也就是生产环境将使用<code>application-PRO.yml</code>这个配置文件。</p>
<p>这个版本的另一个变动点是接入了 apollo 配置中心,但是没有删除不同环境的配置文件,配置文件<code>application.yml</code>中增加了 apollo 相关的配置:</p>
<pre><code class="language-yaml">app:
id: app-xxx-web
apollo:
bootstrap:
    namespaces: application
    enabled: true
eagerLoad:
    enabled: true
</code></pre>
<h5 id="分析-springboot-的配置加载流程">分析 SpringBoot 的配置加载流程</h5>
<h6 id="触发时机">触发时机</h6>
<p>SpringBoot 应用启动时在 SpringApplication <code>prepareEnvironment</code>方法中发布<code>ApplicationEnvironmentPreparedEvent</code>事件,EnvironmentPostProcessorApplicationListener 中监听了这个事件触发配置信息读取,不同来源的配置信息有专门实现了<code>EnvironmentPostProcessor</code>接口的类进行处理,这些类实现<code>postProcessEnvironment</code>方法,<code>apollo-client</code>使用的是<code>v1.9.0</code>版本,其包含一个META-INF/spring.factories:</p>
<pre><code class="language-plain">org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
com.ctrip.framework.apollo.spring.boot.ApolloAutoConfiguration
org.springframework.context.ApplicationContextInitializer=\
com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer
org.springframework.boot.env.EnvironmentPostProcessor=\
com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer
</code></pre>
<p><code>com.ctrip.framework.apollo.spring.boot.ApolloApplicationContextInitializer</code> 会被扫描到,然后执行其<code>postProcessEnvironment</code>方法,多个<code>EnvironmentPostProcessor</code>的执行顺序由其内部的<code>order</code>属性决定,越小的越靠前,<code>ApolloApplicationContextInitializer</code>的<code>order</code>为0,属于是靠后的:</p>
<p><img src="https://cdn.nlark.com/yuque/0/2025/png/403972/1758249078347-2f8033c4-063f-4e3b-bd69-3f8496fd5135.png"></p>
<p>SpringBoot 中,后加载的属性源可以覆盖先加载的属性源定义的值,参考:属性源的优先级顺序,因此 apollo 中的配置会覆盖配置文件中的配置。</p>
<p>难道是 apollo 中的配置写错了?</p>
<p>看了下 apollo 中没有<code>spring.datasource.url</code>这个配置,数据库的连接信息是写在<code>spring.datasource.druid</code>这个前缀开头下面的,apollo 中有两个名为<code>application</code>的命名空间,一个格式是<code>properties</code>,另一个格式是<code>yml</code>,这些配置是写在<code>yml</code>格式命名空间下的,<code>properties</code>格式命名空间下的配置为空。</p>
<pre><code class="language-properties">spring:
# druid pool
datasource:
    druid:
      url: jdbc:mysql://...:3306/...?useUnicode=true&amp;characterEncoding=UTF-8&amp;useSSL=false&amp;...
      username: ...
      password: ...
      driver-class-name: com.mysql.cj.jdbc.Driver
    type: com.alibaba.druid.pool.DruidDataSource
    ...
</code></pre>
<p>idea 启动参数指定 apollo 配置,启动项目,本地 apollo 的缓存文件夹<code>config-cache</code>下是有配置文件存在的,不过只有一个文件<code>app-xx-web+default+application.properties</code>,里面是空的。</p>
<p><code>yml</code>格式命名空间下的配置呢?</p>
<p>看了下 apollo 的文档,原来<code>yml</code>格式命名空间下的配置在客户端使用需要填写带后缀的完整名字。</p>
<blockquote>
<p>注1:yaml/yml格式的namespace从1.3.0版本开始支持和Spring整合,注入时需要填写带后缀的完整名字,比如application.yml</p>
<p>注2:非properties、非yaml/yml格式(如xml,json等)的namespace暂不支持和Spring整合。</p>
</blockquote>
<p>配置文件<code>application.yml</code>中修改<code>apollo</code>的配置,将<code>namespaces</code>从<code>application</code>修改为<code>application.yml</code>:</p>
<pre><code class="language-yaml">app:
id: app-xxx-web
apollo:
bootstrap:
    namespaces: application.yml
    enabled: true
eagerLoad:
    enabled: true
</code></pre>
<p>本地调试启动ok,apollo 中的配置可以正常拉取,项目启动成功。</p>
<p>生产环境 apollo 中的配置没有生效的话,可<code>application-{profile}.yml</code>文件还在,应该还是能读取配置文件中的配置完成启动的吧?</p>
<p>额,不对, maven 打生产包,<code>spring.profiles.active</code>的值被设置成了<code>PRO</code>,但<code>classpath</code>下生产环境配置文件名称为 <code>application-pro.yml</code>,大小写不一致,能正常加载吗?</p>
<p>将<code>application.yml</code>配置文件中的<code>app.apollo.bootstrap.namespaces</code>配置还原,在 maven 的 Profiles 中勾选 dev ,<code>spring.profiles.active</code>的值被设置成了<code>DEV</code>,idea 中正常启动项目,说明 <code>application-dev.yml</code>这个配置文件被读取了。</p>
<p>拿生产包在本地<code>java -jar</code>启动,apollo 的配置服务器指定为<code>dev</code>环境,和生产环境报一样的错误:</p>
<pre><code class="language-plain">java -Dapp.id=app-xxx-web -Dapollo.meta=http://10.100.x.x:8072 -jar app-xxx-web.jar
</code></pre>
<p>难道是 CICD 打包的问题?</p>
<h6 id="没有加载的配置文件">没有加载的配置文件</h6>
<p>本地打了一个包,启动也是报一样的错误,奇怪了,idea 里面启动和打成 <code>FatJar</code> 之后启动的行为还不一样。</p>
<p>idea 里面启动,<code>spring.profiles.active</code> 的值是大写的 <code>DEV</code>,<code>application-dev.yml</code>中的配置是能正常读取的,打成<code>FatJar</code>之后,<code>spring.profiles.active</code>的值是大写的 <code>PRO</code>,<code>application-pro.yml</code>中的配置却不能正常读取。</p>
<p>apollo 的 <code>app.id</code> 这个配置是放在<code>application.yml</code>中的,启动后本地 apollo 的配置缓存文件夹 <code>config-cache</code> 下是有配置的,说明 <code>application.yml</code> 是生效的,只是不同环境 <code>application-{profile}.yml</code> 文件中的配置没有生效。</p>
<p>得着重看看 SpringBoot 中读取配置文件的逻辑了。</p>
<h6 id="配置文件的加载流程">配置文件的加载流程</h6>
<p>上面分析到,EnvironmentPostProcessorApplicationListener 中监听了<code>ApplicationEnvironmentPreparedEvent</code>事件做配置信息读取动作,不同来源的配置信息有专门实现了<code>EnvironmentPostProcessor</code>接口的类进行处理,配置文件的处理类是哪一个?</p>
<p>debug 看了下,是 <code>ConfigDataEnvironmentPostProcessor</code>,其 postProcessEnvironment 方法里面进行处理,然后调用了<code>ConfigDataEnvironment</code>类中的 processAndApply 方法,其内部会调用<code>processWithProfiles</code>方法:</p>
<pre><code class="language-java">private ConfigDataEnvironmentContributors processWithProfiles(ConfigDataEnvironmentContributors contributors,
      ConfigDataImporter importer, ConfigDataActivationContext activationContext) {
    this.logger.trace("Processing config data environment contributors with profile activation context");
    // 在这~~~
    contributors = contributors.withProcessedImports(importer, activationContext);
    registerBootstrapBinder(contributors, activationContext, ALLOW_INACTIVE_BINDING);
    return contributors;
}
</code></pre>
<p>此时的<code>contributors</code>是<code>ConfigDataEnvironmentContributors</code>,继续跟踪 withProcessedImports 方法,里面会调用是<code>ConfigDataImporter</code>的 resolveAndLoad 方法:</p>
<pre><code class="language-java">/**
* Resolve and load the given list of locations, filtering any that have been
* previously loaded.
* @param activationContext the activation context
* @param locationResolverContext the location resolver context
* @param loaderContext the loader context
* @param locations the locations to resolve
* @return a map of the loaded locations and data
*/
Map&lt;ConfigDataResolutionResult, ConfigData&gt; resolveAndLoad(ConfigDataActivationContext activationContext,
      ConfigDataLocationResolverContext locationResolverContext, ConfigDataLoaderContext loaderContext,
      List&lt;ConfigDataLocation&gt; locations) {
    try {
      // 关键行:定位出使用的环境profile
      Profiles profiles = (activationContext != null) ? activationContext.getProfiles() : null;
      // 关键行:根据profile列出需要查找的配置文件列表
      List&lt;ConfigDataResolutionResult&gt; resolved = resolve(locationResolverContext, profiles, locations);
      return load(loaderContext, resolved);
    }
    catch (IOException ex) {
      throw new IllegalStateException("IO error on loading imports from " + locations, ex);
    }
}
</code></pre>
<p>因为我本地 debug 的时候 <code>profile</code> 指定的 <code>dev</code>,所以<code>spring.profiles.active</code>的值被设置成了<code>DEV</code>:</p>
<p><img src="https://cdn.nlark.com/yuque/0/2025/png/403972/1758249132546-95311156-c726-41ed-b913-a336bd5e9aaf.png"></p>
<p>继续断点,跟踪到了<code>StandardConfigDataLocationResolver</code>类,其 getProfileSpecificReferences 方法中根据 <code>profile</code> 列出需要读取的配置文件路径列表:</p>
<p><img src="https://cdn.nlark.com/yuque/0/2025/png/403972/1758249141954-85a7a7c7-3628-4594-97a0-225fe7d3fe8b.png"></p>
<p><img src="https://cdn.nlark.com/yuque/0/2025/png/403972/1758249154925-89d41673-1169-48cc-a0cd-3d167d74bd63.png"></p>
<p>继续断点到了获取配置文件资源的位置,是 resolveNonPattern 方法:</p>
<pre><code class="language-java">private List&lt;StandardConfigDataResource&gt; resolveNonPattern(StandardConfigDataReference reference) {
    // 关键行:通过统一的 ResourceLoader 接口获取资源
    Resource resource = this.resourceLoader.getResource(reference.getResourceLocation());
    // 关键行:调用 Resource.exists() 方法,如果文件存在,则继续在后面读取,否则忽略
    if (!resource.exists() &amp;&amp; reference.isSkippable()) {
      logSkippingResource(reference);
      return Collections.emptyList();
    }
    return Collections.singletonList(createConfigResourceLocation(reference, resource));
}
</code></pre>
<p>因为 <code>application-DEV.yml</code> 是放在<code>classpath</code>下,在这加一个条件断点,只关注<code>application-DEV.yml</code>:</p>
<pre><code class="language-java">reference.getResourceLocation().equals("classpath:/application-DEV.yml");
</code></pre>
<p>判断文件是否存在这个语句执行的结果是存在的:</p>
<p><img src="https://cdn.nlark.com/yuque/0/2025/png/403972/1758249169492-03dfa1b8-1d1e-430e-9d41-e68474b952d2.png"></p>
<p>可见<code>spring.profiles.active</code>的值被设置成了<code>DEV</code>,本地在 idea 中 debug 项目代码也能正常加载 <code>application-dev.yml</code> ,会不会是打成 jar 包之后就不行呢?</p>
<h5 id="远程调试生产包">远程调试生产包</h5>
<p>idea 支持** Remote JVM Debug** ,我想要观测下生产版本 jar 包启动的时候,<code>spring.profiles.active</code>的值被设置成了<code>PRO</code>,这块代码判断<code>classpath:/application-PRO.yml</code> 文件是否存在的结果。</p>
<p>在生产 jar 包目录下打开命令行窗口,执行以下命令,其中 <code>suspend</code> 需要设置成 <code>y</code>,代表回车执行命令需要等到 idea 连接到这个 <code>5005</code> 调试端口之后才继续执行程序:</p>
<pre><code class="language-plain">java -agentlib:jdwp=transport=dt_socket,server=y,suspend=y,address=5005 -Dapollo.meta=http://10.100.x.xx:8072 -jar app-xxx-web.jar --logging.level.root=TRACE
</code></pre>
<p>因为是生产包,所以需要改下条件断点为:</p>
<pre><code class="language-java">reference.getResourceLocation().equals("classpath:/application-PRO.yml");
</code></pre>
<p><strong>Remote JVM Debug</strong> 启动后 , 判断<code>classpath:/application-PRO.yml</code> 文件是否存在的结果为<code>false</code>,和 debug 项目代码不一样了:</p>
<p><img src="https://cdn.nlark.com/yuque/0/2025/png/403972/1758249183014-22d485ef-41c8-4016-8faf-a06f8575b684.png"></p>
<p>改成 <code>classpath:/application-pro.yml</code> 呢?在断点处执行以下命令,判断结果为 <code>true</code>了:</p>
<p><img src="https://cdn.nlark.com/yuque/0/2025/png/403972/1758249187557-6ce5f954-7ae0-418e-b7e7-df3b1edff6fc.png"></p>
<h5 id="解决方案">解决方案</h5>
<p>分析到这,问题点和解决方案已经出来了:</p>
<ol>
<li>项目 pom 文件中 profile 设置的 env 参数值本该小写但是用了大写</li>
<li>全面使用 apollo,去掉不同环境的配置文件,修正 apollo 命名空间配置为: apollo.bootstrap.namespaces = application.yml</li>
</ol>
<p>可为什么测试环境没有出现这个问题呢,原来测试环境的启动脚本中指定了<code>spring.profiles.active</code>的值且是小写,生产环境启动脚本却没有指定。</p>
<p>🙂</p>
<h4 id="深入认识-classloader">深入认识 ClassLoader</h4>
<h5 id="不一样的-classloader">不一样的 ClassLoader</h5>
<p>版本能正常投产了,但是同一份代码不同的启动方式却有不同的表现,这着实让我费解,想着空余花点时间来弄明白这其中的原理。</p>
<p>resolveNonPattern 这个方法里面调用了<code>resource.exists()</code>方法判断配置文件是否存在,resource 是 <code>spring-core</code> 提供的 org.springframework.core.io.ClassPathResource 类:</p>
<pre><code class="language-java">/**
* {@link Resource} implementation for class path resources. Uses either a
* given {@link ClassLoader} or a given {@link Class} for loading resources.
*
* &lt;p&gt;Supports resolution as {@code java.io.File} if the class path
* resource resides in the file system, but not for resources in a JAR.
* Always supports resolution as {@code java.net.URL}.
*
* ...
*/
public class ClassPathResource extends AbstractFileResolvingResource {

        private final String path;

        @Nullable
        private ClassLoader classLoader;

        @Nullable
        private Class&lt;?&gt; clazz;

    /**
   * This implementation checks for the resolution of a resource URL.
   * @see ClassLoader#getResource(String)
   * @see Class#getResource(String)
   */
    @Override
    public boolean exists() {
            return (resolveURL() != null);
    }

    /**
   * Resolves a URL for the underlying class path resource.
   * @return the resolved URL, or {@code null} if not resolvable
   */
    @Nullable
    protected URL resolveURL() {
            try {
                    if (this.clazz != null) {
                            return this.clazz.getResource(this.path);
                    }
                    else if (this.classLoader != null) {
                // 关键行:委托给具体的 ClassLoader
                            return this.classLoader.getResource(this.path);
                    }
                    else {
                            return ClassLoader.getSystemResource(this.path);
                    }
            }
            catch (IllegalArgumentException ex) {
                    // Should not happen according to the JDK's contract:
                    // see https://github.com/openjdk/jdk/pull/2662
                    return null;
            }
    }

    ...
}
</code></pre>
<p>2.7.18 版本的 SpringBoot 使用的 <code>spring-core</code> 版本为 5.3.31,exists() 方法会调用本类中的 resolveURL() 方法,debug 看了下,不管是 idea 启动还是打成 jar 之后启动,<code>resolveURL</code> 方法中都是通过<code>return this.classLoader.getResource(this.path)</code>返回配置文件的 URL 的,区别在于:</p>
<ul>
<li>idea 中启动时,<code>classLoader</code>为<code>Launcher$AppClassLoader</code>,<code>exists</code>方法中<code>return (resolveURL() != null)</code>的返回值为 true</li>
<li>打成 jar 之后启动,<code>classLoader</code>为<code>LaunchedURLClassLoader</code>,<code>exists</code>方法中<code>return (resolveURL() != null)</code>的返回值为 false</li>
</ul>
<h5 id="launcherappclassloader-与-launchedurlclassloader-的差异"><font style="color: rgba(15, 17, 21, 1)">Launcher$AppClassLoader 与 LaunchedURLClassLoader 的差异</font></h5>
<h6 id="类加载器的架构差异"><font style="color: rgba(15, 17, 21, 1)">类加载器的架构差异</font></h6>
<p><strong><font style="color: rgba(15, 17, 21, 1)">Launcher$AppClassLoader</font></strong><font style="color: rgba(15, 17, 21, 1)"> 是 JDK 标准的三层类加载器架构中的系统类加载器:</font></p>
<pre><code class="language-plain">// JDK 类加载器层次结构
Bootstrap ClassLoader (C++实现,加载JRE核心类)
    ↓
Extension ClassLoader (加载JRE扩展包)
    ↓
AppClassLoader (系统类加载器,加载-classpath指定路径)
</code></pre>
<p>在 idea 环境中运行时,应用类路径由 idea 动态构建,通常包含:</p>
<p>项目编译输出目录(如<code>target/classes</code>)<br>
所有依赖的 JAR 文件<br>
idea 特定的资源目录</p>
<p>此时的资源查找基于文件系统,<code>ClassLoader.getResource()</code>方法会遍历类路径中的每个条目,在文件系统上直接查找对应的资源文件。</p>
<p><strong>LaunchedURLClassLoader</strong> 是 SpringBoot 为 FatJar 设计的特殊类加载器:</p>
<pre><code class="language-plain">// SpringBoot FatJar 类加载架构
LaunchedURLClassLoader
    ↓
URLClassLoader
    ↓
ClassLoader (父类加载器)
</code></pre>
<p>其特殊之处在于能够处理"嵌套的JAR"(nested JARs)——即 FatJar 中内嵌的其他 JAR 文件。</p>
<h6 id="资源解析机制的对比">资源解析机制的对比</h6>
<p><strong>AppClassLoader 的资源解析流程:</strong></p>
<pre><code class="language-java">// 简化版的资源查找逻辑
public URL getResource(String name) {
    URL url;
    // 首先委托父加载器查找
    if (parent != null) {
      url = parent.getResource(name);
    } else {
      url = getBootstrapResource(name);
    }
    if (url == null) {
      // 在自身的类路径中查找
      url = findResource(name);
    }
    return url;
}
// 在文件系统中的查找
URL findResource(String name) {
    for (File classpathEntry : classpath) {
      File resourceFile = new File(classpathEntry, name);
      if (resourceFile.exists()) {
            return resourceFile.toURI().toURL();
      }
    }
    return null;
}
</code></pre>
<p><strong>关键特性:</strong></p>
<p>基于文件系统路径直接查找<br>
受操作系统文件系统大小写规则影响(Windows 不敏感,Linux 敏感)<br>
支持通配符和模式匹配</p>
<p><strong>LaunchedURLClassLoader 的资源解析流程:</strong></p>
<pre><code class="language-java">// SpringBoot 自定义的资源查找
public URL findResource(String name) {
    // 1. 首先尝试从已索引的资源中查找
    URL url = findResourceFromIndex(name);
    if (url != null) {
      return url;
    }
    // 2. 在嵌套的JAR文件中查找
    for (JarFile jar : nestedJars) {
      JarEntry entry = jar.getJarEntry(name);
      if (entry != null) {
            try {
                // 创建特殊的URL指向JAR内的资源
                return createJarUrl(jar, entry);
            } catch (IOException e) {
                // 处理异常
            }
      }
    }
    // 3. 回退到标准的URLClassLoader查找
    return super.findResource(name);
}
</code></pre>
<p><strong>关键特性:</strong></p>
<p>基于 JAR 文件条目的精确匹配<br>
严格的大小写敏感性(ZIP/JAR实现的实际要求)<br>
需要预先构建资源索引以提高性能</p>
<h6 id="fatjar-中的资源定位机制">FatJar 中的资源定位机制</h6>
<p>SpringBoot FatJar 的特殊结构:</p>
<pre><code class="language-plain">app.jar
├── META-INF/
├── BOOT-INF/
│   ├── classes/          # 应用类文件
│   │   └── application.yml
│   └── lib/            # 依赖库
│       ├── spring-core.jar
│       └── druid.jar
└── org/springframework/boot/loader/
    ├── Launcher.class
    └── LaunchedURLClassLoader.class
</code></pre>
<p><strong>资源解析的核心挑战:</strong></p>
<ol>
<li><strong>JAR 规范的限制</strong>:
<ul>
<li>JAR 文件本质上是 ZIP 文件</li>
<li>严格的大小写敏感性(ZIP/JAR实现的实际要求),<code>application-PRO.yml</code> ≠ <code>application-pro.yml</code></li>
</ul>
</li>
</ol>
<p><strong>SpringBoot 的优化策略</strong>:</p>
<pre><code class="language-java">// SpringBoot 在构建时创建资源索引
private Map&lt;String, List&lt;String&gt;&gt; createResourceIndex() {
    Map&lt;String, List&lt;String&gt;&gt; index = new HashMap&lt;&gt;();
    for (JarFile jar : getAllJars()) {
      Enumeration&lt;JarEntry&gt; entries = jar.entries();
      while (entries.hasMoreElements()) {
            JarEntry entry = entries.nextElement();
            if (!entry.isDirectory()) {
                String path = entry.getName();
                // 将路径转换为标准形式
                String normalized = normalizePath(path);
                index.computeIfAbsent(normalized, k -&gt; new ArrayList&lt;&gt;())
                .add(path);
            }
      }
    }
    return index;
}
</code></pre>
<ol start="2">
<li><strong>类加载器的初始化差异</strong>:
<ul>
<li>idea 环境:类路径包含具体的目录和文件</li>
<li>FatJar 环境:类路径指向 JAR 文件内部的嵌套结构</li>
</ul>
</li>
</ol>
<h6 id="操作系统的影响">操作系统的影响</h6>
<p><strong>开发环境(Windows):</strong></p>
<pre><code class="language-java">// 文件系统级别的大小写处理
File file = new File("application-PRO.yml");
System.out.println(file.exists());
// Windows: 如果存在application-pro.yml,可能返回true(不敏感)
// Linux: 严格返回false(敏感)
</code></pre>
<p><strong>生产环境(Linux):</strong></p>
<pre><code class="language-java">// JAR文件内部的资源查找
JarFile jar = new JarFile("app.jar");
JarEntry entry1 = jar.getJarEntry("application-PRO.yml"); // null
JarEntry entry2 = jar.getJarEntry("application-pro.yml"); // 找到条目
</code></pre>
<h6 id="springboot-配置加载的完整链条">SpringBoot 配置加载的完整链条</h6>
<p>理解整个配置加载过程中 ClassLoader 的作用:</p>
<pre><code class="language-plain">// 配置解析的完整调用链
ConfigDataEnvironmentPostProcessor.postProcessEnvironment()
→ ConfigDataEnvironment.processAndApply()
    → ConfigDataImporter.resolveAndLoad()
      → StandardConfigDataLocationResolver.resolve()
      → ClassPathResource.exists()
          → LaunchedURLClassLoader.getResource()
            → JarFile.getJarEntry() // 严格大小写匹配
</code></pre>
<p><strong>关键发现:</strong></p>
<ul>
<li>在 FatJar 中,资源查找最终委托给<code>java.util.jar.JarFile</code></li>
<li><code>JarFile.getJarEntry()</code>方法基于哈希表实现,要求精确的键匹配</li>
<li>哈希键的计算基于原始字节,不进行大小写转换</li>
</ul>
<h6 id="问题复现的技术根源">问题复现的技术根源</h6>
<p>通过源码分析,可以精确重现问题:</p>
<pre><code class="language-java">// 问题重现的伪代码
public class ProblemReproduction {
    public static void main(String[] args) {
      // 开发环境(IDE)
      ClassLoader devLoader = Launcher.AppClassLoader;
      URL devResource = devLoader.getResource("application-PRO.yml");
      System.out.println("DEV Found: " + (devResource != null)); // true

      // 生产环境(FatJar)
      ClassLoader prodLoader = new LaunchedURLClassLoader();
      URL prodResource = prodLoader.getResource("application-PRO.yml");
      System.out.println("PROD Found: " + (prodResource != null)); // false

      // 实际存在的文件
      URL actualResource = prodLoader.getResource("application-pro.yml");
      System.out.println("Actual Found: " + (actualResource != null)); // true
    }
}
</code></pre>
<h6 id="设计启示与最佳实践">设计启示与最佳实践</h6>
<p><strong>架构层面的启示:</strong></p>
<ol>
<li><strong>环境一致性</strong>:开发、测试、生产环境的运行时行为应该尽可能一致</li>
<li><strong>早期验证</strong>:在构建阶段就应该检测配置文件和类路径的一致性</li>
<li><strong>防御性编程</strong>:对资源加载进行适当的容错处理</li>
</ol>
<p><strong>技术实践建议:</strong></p>
<pre><code class="language-java">// 防御性的资源配置加载
public class SafeConfigLoader {
    public static Resource loadConfig(ClassLoader loader, String baseName,String profile) {
      // 尝试规范化的命名
      String[] possibleNames = {
            baseName + "-" + profile.toLowerCase() + ".yml",
            baseName + "-" + profile.toUpperCase() + ".yml",
            baseName + "-" + profile + ".yml"
      };

      for (String name : possibleNames) {
            Resource resource = loader.getResource(name);
            if (resource != null &amp;&amp; resource.exists()) {
                return resource;
            }
      }
      return null;
    }
}
</code></pre>
<p><strong>构建期检查:</strong></p>
<pre><code class="language-xml">&lt;!-- Maven 构建期资源验证 --&gt;
&lt;plugin&gt;
&lt;groupId&gt;org.apache.maven.plugins&lt;/groupId&gt;
&lt;artifactId&gt;maven-enforcer-plugin&lt;/artifactId&gt;
&lt;configuration&gt;
    &lt;rules&gt;
      &lt;requireFilesExist&gt;
      &lt;files&gt;
          &lt;!-- 验证配置文件命名一致性 --&gt;
          &lt;file&gt;src/main/resources/application-${env}.yml&lt;/file&gt;
      &lt;/files&gt;
      &lt;/requireFilesExist&gt;
    &lt;/rules&gt;
&lt;/configuration&gt;
&lt;/plugin&gt;
</code></pre>


</div>
<div id="MySignature" role="contentinfo">
    <p>本文来自博客园,作者:杜劲松,转载请注明原文链接:https://www.cnblogs.com/imadc/p/19156192</p><br><br>
来源:https://www.cnblogs.com/imadc/p/19156192
頁: [1]
查看完整版本: 深入认识ClassLoader - 一次投产失败的复盘