在 CentOS7 上使用 Go 编译 JVM 可加载的共享库
posts/compile-go-shared-library-for-jvm-on-centos7背景
最新编写的 Java 程序需要调用本地方法库去做一些事情,但是这个 Java 程序可能会跑在非常老的机器上,所以对 Glibc 的版本是有要求的(为什么不用到静态链接后面会讲)。因为在高版本的机器上编译出来的 so 文件是无法在低版本的机器跑的,而且我这台机器是 Arch Linux,Glibc 版本更是达到了极其先进的 2.42,我们必须选择一个上古的 Linux 操作系统 CentOS 7 来进行构建(其实也有其他 Tick 的方法,不过保险起见选择这个传统的方法吧)。
为什么不用静态链接库
最初想了想既然老系统 glibc 太旧,那我用 musl 配合 Go 静态编译一个动态链接库不就好了,但实际上 JVM 根本没法加载这种“静态化”的 .so,一启动 JVM 就炸。
System.loadLibrary() 底层依靠的是 dlopen(),它只能加载真正的动态库。dlopen() 在运行时会去解析 .so 文件里的 ELF 动态段、符号表和重定位信息,把符号绑定进当前进程空间。这要求 .so 里保留完整的动态链接信息,且依赖系统的动态链接器(ld-linux.so)来完成符号解析。
而“静态链接的 .so”在构建时就已经把所有符号绑定死了——没有 .dynamic 段、没有重定位表,也不需要动态链接器参与。这样的文件根本无法被 dlopen() 打开,System.loadLibrary() 调用时会直接报错,因为从 ELF 的角度看,它已经不是一个合格的共享对象。
更麻烦的是,glibc 自身很多功能(比如 DNS、locale、线程局部存储等)内部也依赖 dlopen() 的插件系统。如果你把 glibc 静态编进 .so,这套机制会完全失效,JVM 在加载库时连基础的符号解析都可能出错。
所以 System.loadLibrary() 只能加载真正的动态库。静态链接会让 .so 失去动态库的本质属性,JVM 自然也就无从加载。
所以我们要寻求另一个方式那就构建动态链接库,但是后面的 glibc 要足够旧,这里我们选用 CentOS 7 的 2.17 版本的 glibc。
使用 CentOS7 来构建动态库
为了在干净的旧系统环境下构建,我们用 Docker 来创建一个 CentOS 7 构建环境。Dockerfile 如下:
FROM centos:7 AS env
RUN sed -e 's|^mirrorlist=|#mirrorlist=|g' -e 's|#\?baseurl=http.*/centos|baseurl=https://mirrors.aliyun.com/centos-vault/centos|g' -i.bak /etc/yum.repos.d/CentOS-*.repo
RUN yum -y update && \
yum -y install wget git tar gcc make && \
yum clean all
ENV GO_VERSION=1.22.6
RUN wget https://go.dev/dl/go${GO_VERSION}.linux-amd64.tar.gz && \
tar -C /usr/local -xzf go${GO_VERSION}.linux-amd64.tar.gz && \
rm -f go${GO_VERSION}.linux-amd64.tar.gz
ENV PATH="/usr/local/go/bin:${PATH}"
FROM env AS package
WORKDIR /package
COPY . .
# File download "https://releases.linaro.org/components/toolchain/binaries/latest-7/aarch64-linux-gnu/gcc-linaro-*-x86_64_aarch64-linux-gnu.tar.xz"
# ADD gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu.tar .
RUN tar -xf 'gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu.tar' -C /opt && rm gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu.tar
ENV PATH=/opt/gcc-linaro-7.5.0-2019.12-x86_64_aarch64-linux-gnu/bin:$PATH
RUN CGO_ENABLED=1 go build -ldflags="-s -w" -o output_amd64.so -buildmode=c-shared jni_by_init.go
RUN CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc GOOS=linux GOARCH=arm64 go build -ldflags="-s -w" -o output_aarch64.so -buildmode=c-shared jni_by_init.go
这里用到了 Docker 的多阶段构建来避免重复安装工具链,调试时配合 docker buildx 可以显著减少构建时间。
因为我这边示例里没定义任何 JNI 方法,只是利用了 Go 的 init() 自动执行,所以没指定 Java 头文件路径。如果你的 .so 里需要导出 JNI 接口,可以在 Dockerfile 里加上:
ENV CGO_CFLAGS="-I/usr/lib/jvm/java-1.8.0/include -I/usr/lib/jvm/java-1.8.0/include/linux"
这样就能在大多数 Linux 系统(glibc ≥ 2.17)上直接加载运行了。如果要支持更多架构(比如 armv7、ppc64le),可以按上面模板追加对应的交叉编译配置即可。