最近遇到一个诡异的问题:程序中需要用到一个自定义的字符集包my-charset.jar,里面有一个x-gb18030-2022字符集。程序功能要求把utf-8编码的汉字转换成x-gb18030-2022编码的汉字。整个程序使用springboot开发(基于java8),整个程序需要打包成一个可执行jar包(即fatjar)部署到服务器上。
功能实现很简单:
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包。其父加载器是扩展类加载器。
它们的关系如图所示:
+----------------------------+ | | | 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()
的源码:
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包,其目录结构如下:
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包
方法二:修改函数
代码如下:
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中的类了。
以上就是springboot可执行jar包中使用自定义字符集失败问题的原因及解决方案的详细内容,更多关于springboot jar自定义字符集失败的资料请关注代码网其它相关文章!
发表评论