在Springboot可执行jar包中使用自定义字符集失败问题

最近遇到一个诡异的问题:程序中需要用到一个自定义的字符集包my-charset.jar,里面有一个X-GB18030-2022字符集。程序功能要求把UTF-8编码的汉字转换成X-GB18030-2022编码的汉字。整个程序使用springboot开发(基于java8),整个程序需要打包成一个可执行jar包(即fatjar)部署到服务器上。

功能实现很简单:

java 复制代码
String data = "䐀";		// UTF-8编码: E49080。这里直接写成了字面值
Charset gb18030 = Charset.forName("X-GB18030-2022");
byte[] gbdata = data.getBytes(gb18030);	// GB18030编码:82338E35

在IDEA上开发测试一切正常,可以把UTF-8编码的汉字转成GB18030编码。然后使用spring-boot-maven-plugin把整个程序打包成一个可执行jar包my-springboot-app.jar后,部署到服务器上,再测试,却发现程序报错:UnsupportedCharsetException: X-GB18030-2022

根据错误信息,可以定位是Charset.forName("X-GB18030-2022")这个语句报错。明明在IDEA中调试没有问题,为什么部署到服务器上就报错了?

要解决这个问题,需要以下知识:

  • 类加载器和类加载的双亲委托模型
  • Charset.forName()的SPI机制原理,以及SPI对双亲委派模型的突破
  • Springboot的可执行jar包(fatjar)的启动原理

掌握了这些知识,再解决这个问题,就很容易了。

1. 类加载器和类加载的双亲委托模型

Java程序中我们用到的所有类,例如我们常用的String、Integer以及我们自己写的类,都是由类加载器加载到java虚拟机中的。类加载器分为三类:

  • 启动类加载器(bootstrap class loader):是最顶层的类加载器,加载rt.jar、tools.jar等基础包,包括Object.class、String.class、 Charset.class等。
  • 扩展类加载器(extension class loader):加载扩展包。其父加载器是启动类加载器。
  • 应用程序类加载器(application class loader):加载由-classpath参数指定的jar包。其父加载器是扩展类加载器。

它们的关系如图所示:

tex 复制代码
+----------------------------+                     
|                            |                     
|   Bootstrap class loader   |                     
|                            |                     
+--------------^-------------+                     
               |                                   
               |                                   
               |                                   
+----------------------------+                     
|                            |                     
|      Ext class loader      |                     
|                            |                     
+--------------^-------------+                     
               |                                   
               |                                   
               |                                   
+----------------------------+                     
|                            |                     
|      App class loader      |                     
|                            |                     
+----------------------------+ 

类加载器加载类的规则为:

  • 双亲委派模型(或者翻译为父亲委模型制更贴切):一个类加载器加载某个类时,先交给它的父亲执行类加载,父亲无法加载时,再有子类加载。
  • 类A使用到类B时,会由类A的类加载器加载类B
  • 子类加载器加载的类,可以看到父亲加载器加载的类。但是反过来则不行,即父亲加载器加载的类,无法看到子类加载器加载的类。

这些规则比较抽象,我们暂且有个印象,后面会结合SPI具体讲解。

2. SPI机制

SPI是Service Provider Interface的缩写,其作用就是java在标准库中提供一个接口(Interface),用户使用时直接操作这个接口,而不用关心接口的具体实现。接口的具体功能由供应商(Service Provider)实现,通过某种机制(即SPI),把这个实现和接口关联到一起,用户操作的接口可以直接调用供应商实现的功能。

最典型的SPI就是数据库驱动。Java标准库中仅提供了数据库驱动的接口,而具体的驱动由各个数据库供应商实现,例如Oracle驱动或者MySQL驱动。我们使用不同供应商的驱动时,就会把驱动的实现和驱动的接口关联到一起。我们写的程序不用改变,只要更换了驱动的供应商,就可以连接不同的数据库。

字符集也是同样的道理。Charset.forName()函数就使用了SPI机制,来加载不同的字符集。Java标准库中的提供了字符集的接口java.nio.charset.spi.CharsetProvider(它实际是一个虚拟类,为了简便,我们把它称为接口),各个供应商实现了这个接口,把自己实现的字符集贡献出来。前面出问题的my-charset.jar就是供应商实现的一个字符集,它实现的接口类是com.x.GB18030_2022_CharsetProvider

供应商实现了字符集之后,在my-charset.jar包的META-INF/services/目录下创建一个java.nio.charset.spi.CharsetProvider文件(注意它是一个文件名),文件中的内容就是供应商实现的接口类,例如com.x.GB18030_2022_CharsetProvider,它的作用就是提供"X-GB18030-2022"字符集。

Charset.forName()函数会执行ServiceLoader.load()函数,在所有jar包中搜索"META-INF/services/java.nio.charset.spi.CharsetProvider"文件,执行文件中指定的类,这样就获取到了供应商提供的字符集。在这里,我们就获取到了"X-GB18030-2022"字符集。

这里就会有一个问题,Charset、ServiceLoader都是由Bootstrap-class-loader加载的,而供应商提供的my-charset.jar一般会放到-classpath指定的路径上,即my-charset.jar是由App-class-loader加载的,进而可知com.x.GB18030_2022_CharsetProvider是由App-class-loader加载的。根据前面类加载器的规则,父类加载器加载的类无法看到子类加载器加载的类,也就是说CharsetServiceLoader无法看到GB18030_2022_CharsetProvider,这怎么办?

这就需要突破双亲委托模型的限制:ServiceLoader.load()函数有一个参数就是ClassLoader,即这个函数可以指定类加载器。查看Charset.forName()的源码:

java 复制代码
ClassLoader cl = ClassLoader.getSystemClassLoader();	// 获取App-class-loader
ServiceLoader<CharsetProvider> sl = ServiceLoader.load(CharsetProvider.class, cl);

可以看到这里使用了App-class-loader作为类加载器,来加载CharsetProvider的所有实现类。my-charset.jar中就有一个此接口的实现类。

在IDEA中启动应用时,会自动添加-classpath参数,指向my-charset.jar。根据第2节的内容可知,App-class-loader会加载-classpath指向的jar包,这些jar包不会被Bootstrap-class-loader加载的类(例如String.class, Charset.class)看到。但是由于Charset.forName()函数中显示的指定了使用App-class-loader来加载类,所有可以加载到my-charset.jar中的类。

我们可以做个假设:如果Charset.forName()函数中使用的类加载器cl不是App-class-loader,而是Ext-class-loader,那可以获取到my-charset.jar中的类吗?答案是不能。因为Ext-class-loader只能看到<JAVA_HOME>\lib\ext路径中的jar包和类以及Bootstrap-class-loader加载的jar包和类,而无法看到-classpath指定的jar包和类,所以它无法加载my-charset.jar中的类。

到这里,我们开头的问题就有了一个可能的答案:是不是打包成的fatjar使用的类加载器有问题呢?我们接着往下看。

3. Fatjar启动原理

我们使用spring-boot-maven-plugin把springboot项目打包成一个可执行jar包,其目录结构如下:

text 复制代码
my-springboot-app.jar/
│
├── META-INF/
│   ├── MANIFEST.MF               # 清单文件,包含主类信息等
│   └── maven/                    # Maven元数据
│
├── BOOT-INF/
│   ├── classes/                  # 应用程序编译后的class文件
│   └── lib/                      # 依赖的第三方JAR包
│       ├── spring-boot-2.7.0.jar
│       ├── spring-boot-autoconfigure-2.7.0.jar
│       ├── spring-web-5.3.21.jar
│       ├── my-charset.jar        # 供应商的字符集JAR包
│       └── ... (其他依赖JAR)
│
└── org/
    └── springframework/
        └── boot/
            └── loader/           # Spring Boot启动器相关类
                ├── JarLauncher.class
                ├── LaunchedURLClassLoader.class
                └── ... (启动器其他类)

使用命令java -jar my-springboot-app.jar启动此应用。应用启动时会使用LaunchedURLClassLoader作为类加载器,加载BOOT-INF/中class目录中的类和lib目录中的依赖JAR包,我们的my-charset.jar就被放到了这里。

由此可知,fatjar启动时不是使用App-class-loader来加载依赖JAR包的,而是使用了自定义的一个类加载器LaunchedURLClassLoader,它的父亲是App-class-loader,也满足双亲委托模型。

注意fatjar是无法使用-classpath来指定依赖包路径的,这些依赖包在fatjar的内部。例如my-charset.jar就被封装在了BOOT-INF/lib里,外部是无法看到的,因此也就无法用-classpath来指定,所以也无法用App-class-loader直接加载。同时,java -jar命令还会忽略-classpath参数,即使把my-charset.jar拿出来用-classpath指定,也不会被加载。

就是这个springboot自定义的类加载器导致了开头的问题:my-charset.jar包是被LaunchedURLClassLoader加载的,但是Charset.forName()函数中使用App-class-loader来加载CharsetProvider的所有实现类,App-class-loader看不到LaunchedURLClassLoader加载的类,所以就会找不到X-GB18030-2022字符集,导致报错。

4. 解决方案

知道了问题,那解决方案也就随之而出:

  • 让my-charset.jar被App-class-loader、Ext-class-loader或者Bootstrap-class-loader加载
  • 自己实现Charset.forName()函数,使用LaunchedURLClassLoader获取my-charset.jar中的类

方法一:指定类加载器

  • java8的jvm启动时,可以使用-Xbootclasspath/a:<path-to-jar>指定my-charset.jar的路径,让Bootstrap-class-loader加载这个jar包
  • 可以使用-Djava.ext.dir=<path-to-dir>指定一个目录,让扩展类加载器加载这个目录中的所有jar包

方法二:修改函数

代码如下:

java 复制代码
try {
    Charset target = Charset.forName("X-GB18030-2022");
} catch (Exception ex) {
    // 关键代码
    ClassLoader loader = Thread.currentThread().getContextClassLoader();
    ServiceLoader<CharsetProvider> providers = ServiceLoader.load(CharsetProvider.class, loader);
    
    Iterator<CharsetProvider> iterator = providers.iterator();
    while (iteartor.hasNext()) {
        CharsetProvider provider = iterator.next();
        try {
            target = provider.charsetForName("X-GB18030-2022");
        	if (target != null) {
            	return target;
        	}
        } catch (Exception ignored) {}
    }
    throw ex;
}

Thread.currentThread().getContextClassLoader()这条语句会获取到当前线程的类加载器,对于我们自己写的应用来说,这就是springboot的LaunchedURLClassLoader类加载器。然后使用这个类加载器执行ServiceLoader.load(CharsetProvider.class, loader)函数,可以加载到my-charset.jar中的类了。

参考资料

相关推荐
雾林小妖2 分钟前
springboot集成deepseek
java·spring boot·后端
知识浅谈40 分钟前
基于Dify构建本地化知识库智能体:从0到1的实践指南
后端
网络安全打工人1 小时前
CentOS7 安装 rust 1.82.0
开发语言·后端·rust
梦兮林夕1 小时前
04 gRPC 元数据(Metadata)深入解析
后端·go·grpc
pe7er1 小时前
RESTful API 的规范性和接口安全性如何取舍
前端·后端
山风呼呼2 小时前
golang--通道和锁
开发语言·后端·golang
Ice__Cai3 小时前
Django + Celery 详细解析:构建高效的异步任务队列
分布式·后端·python·django
阿华的代码王国3 小时前
【Android】卡片式布局 && 滚动容器ScrollView
android·xml·java·前端·后端·卡片布局·滚动容器
云边散步4 小时前
《校园生活平台从 0 到 1 的搭建》第四篇:微信授权登录前端
前端·javascript·后端
架构师沉默4 小时前
让我们一起用 DDD,构建更美好的软件世界!
java·后端·架构