最近遇到一个诡异的问题:程序中需要用到一个自定义的字符集包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加载的。根据前面类加载器的规则,父类加载器加载的类无法看到子类加载器加载的类,也就是说Charset
和ServiceLoader
无法看到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中的类了。