java9模块化JPMS的坑

11 min read,created at 2024-07-21
javaJPMS模块化

模块概念

java9引入了模块系统(Java Platform Module System, JPMS),旨在增强 Java 的封装性、可维护性和可扩展性,模块是packagepackage,作用主要是声明哪些是公开的包,哪些是自己模块内部用的,防止引入不必要的依赖,这样可以便于大型应用的管理,减少不同模块之间的干扰。

模块使用

模块的管理是通过module-info.java文件来管理的,这个文件是模块的入口,里面声明了模块的名称,依赖的模块,以及暴露的包,下面项目使用java11。

正常情况下,我们创建一个maven项目,然后添加两个maven module,分别是mod1mod2,其中mod1引入mod2,此时能够使用mod2中的UserInnerUtils类,这就是没有JPMS的时候,

img

InnerUtilsmod2中的内部类,所以mod1中不应该直接使用。添加module-info.java文件,到mod2java文件夹下。

img

mod2包的根目录下创建的module-info.java如下,这里演示了最主要的三个部分,module是模块名不需要是包名,可以任意命名,但最好还是和包名类似的规范,requires表示依赖的模块,exports表示暴露的包。

module my.mod2 {
    requires java.base; // java.base是默认都引入的,可以不写
    exports org.example.mod2;
}

然后在mod1中同样声明模块,引入my.mod2

module my.mod1 {
    requires my.mod2;
}

引入没有exports org.example.mod2.utils这个包,所以此时报错。

img

除了基本的requires exports引入和导出,其他语法如下。

  • opens 允许反射访问(私有属性方法)
  • requires transitive 同时也会引入依赖的依赖
  • requires static 仅在编译阶段需要引入的依赖
  • usesprovidesspi机制类似,uses表示使用服务,provides表示提供服务

uses与provides

先说SPI,Service Provider Interface机制,这是一个很早的机制,只需要在META-INF/services目录下创建一个文件,文件名是接口的全限定名,文件内容是实现类的全限定名,就可以使用ServiceLoader.load加载该实例。

例如在mysql-connector-java中,META-INF/services/java.sql.Driver文件内容是com.mysql.cj.jdbc.Driver,这样就可以使用ServiceLoader.load加载该实例,这也是jdbc driver的加载机制,利用spi的机制,在classpath目录下扫描所有的META-INF/services/java.sql.Driver文件,找到里面的声明的类名,然后用AppClassLoader加载该类,并实例化。

img

如果采用JPMS组织模块,则ServiceLoader.load方法,不再能扫描真个classpath,而是只能扫描自己uses的接口。我们以Runnable接口为例。

// mod2的module-info.java
import org.example.mod2.MyRunnable;
module my.mod2 {
    provides Runnable with MyRunnable;
}

// mod1的module-info.java
module my.mod1 {
    uses Runnable;
    requires my.mod2;
}

此时在mod1中可以使用ServiceLoader加载mod2中的MyRunnable类。

ServiceLoader<Runnable> loader = ServiceLoader.load(Runnable.class);
for (Runnable runnable : loader) {
    runnable.run();
}

这使得mod2中的MyRunnable类,可以被mod1中的ServiceLoader加载,但mod1中不能直接使用MyRunnable类,因为mod2中没有exports

opens与反射

在上面的例子中,只提供了provides没有提供exports会导致,类无法访问,例如,改为反射调用会报错。

ServiceLoader<Runnable> loader = ServiceLoader.load(Runnable.class);

for (Runnable runnable : loader) {
    Method m = runnable.getClass().getDeclaredMethod("run");
    m.invoke(runnable);
}
// class org.example.mod1.Main (in module my.mod1) cannot access class org.example.mod2.MyRunnable (in module my.mod2) because module my.mod2 does not export org.example.mod2 to module my.mod1

此时只需要修改mod2,即可正常反射调用。

import org.example.mod2.MyRunnable;
module my.mod2 {
    provides Runnable with MyRunnable;
+   exports org.example.mod2;
}

opens指的一般是private的属性方法,例如MyRunnable中有个private void inner方法。

// mod1中直接反射调用mod2的private方法
Method inner = MyRunnable.class.getDeclaredMethod("inner");
inner.setAccessible(true);// 这一行会报错如下:

// Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make private void org.example.mod2.MyRunnable.inner() accessible: module my.mod2 does not "opens org.example.mod2" to module my.mod1

上面报错是因为没有给mod1中开放反射权限,需要修改mod2,添加opens

import org.example.mod2.MyRunnable;
module my.mod2 {
    provides Runnable with MyRunnable;
    exports org.example.mod2;
    opens org.example.mod2;
    // opens org.example.mod2 to my.mod1; 这是只暴露给mod1的语法
}

反射坑

java9-15中,即使非模块化项目,也就是即使没有module-info,使用反射调用private方法属性的时候,也会有警告信息如下。

img

--illegal-access=permit这是默认的配置,即非法反射会有警告信息打印,其实还好,只是会打印警告信息,不会报错。但是如果使用--illegal-access=deny,则会报错。而java16+中就是改成了deny,我们改成java17再次运行。

img

这里报错信息也很清楚,就是没有把java.lang包open给我们的匿名模块,这里解释下java.base模块,这个是包含了java.lang/java.io等等一众基础的jdk的包的模块名,他没有open给我们的模块,意味着我们是不能直接反射调用的。并且第一行表示java17--illegal-access=warn; support was removed in 17.0,没法修改这个标志位了,只能挂。解决方法是设置另一个java运行标志,如下,java.base模块开放给我们的UNNAMED模块,这样就可以正常反射调用了。

java --add-opens java.base/java.lang=ALL-UNNAMED \
     --add-opens java.base/java.util=ALL-UNNAMED \
     --add-opens java.base/java.io=ALL-UNNAMED \
     --add-opens java.base/java.nio=ALL-UNNAMED \
     --add-opens java.base/java.security=ALL-UNNAMED \
     --add-opens java.base/java.net=ALL-UNNAMED \
     --add-opens java.base/java.time=ALL-UNNAMED \
     -jar your-app.jar

小结一下:

  • 模块化的项目,本来就无法反射java.base中的私有方法来调用
  • 非模块化的项目,如果--illegal-access=deny,也会报错
  • java9-15中,只是警告,不会报错,但是16+deny

不修改jvm参数调用defineClass

JPMS对老的java8-的项目升级带来了一些障碍,对于新的项目来说是没问题的,按照JPMS的规范,可以直接使用module-info.java,但是对于老的项目,如果直接升级,可能就有问题。首先老的项目所有的包都是UNNAME模块下的,本身不受requiresexports的约束,所以问题主要集中在opens反射。而java9-15只是警告,不报错也还好,但是如果要升级java17可能很多项目都会遇到上述问题。

例如Mockito框架就会报错

@RunWith(MockitoJUnitRunner.class)
public class MainTest {
    @Mock
    Object anything;

    @Test
    public void test() {
        System.out.println("test");
    }
}

img

还有一些cglibjavassist等库也会报错,与上面报错类似,基本都是因为ClassLoader#define这个方法是protected,而非public反射无法访问导致的,我自己刚好也写了一个字节码工具,也需要自己根据byte[]加载成Class,这个defineClass方法是jvm类加载的方法。

这里介绍我是如何绕开反射机制来运行defineClass方法的,借助defineClass方法是protected,所以继承ClassLoader,就可以在自己的ClassLoader中访问defineClass方法了,但是最终效果与直接用指定加载器加载类不同,是通过一个子加载器加载的类,只不过这个类运行时,他用到的其他类,都会用其父类加载器。

public static class MyClassLoader extends ClassLoader {
    final byte[] bytes;
    final String className;
    public MyClassLoader(ClassLoader parent, byte[] data, String className) {
        super(parent);
        this.bytes = data;
        this.className = className;
    }

    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        // 不是指定的类名,用父类加载器加载
        if (!className.equals(name)) {
            return super.loadClass(name);
        }
        // 指定类名,用当前类加载器加载指定的字节码
        return defineClass(name, bytes, 0, bytes.length);
    }
}

loadClass findClassdefineClass的区别:

  • loadClass:加载类,public,如果类已经加载过,则直接返回,没有加载过,则递归调用parent.loadClass,父加载器没有返回结果或抛出异常,则最后调用findClass,注意父加载器中也会挨着调用findClass
  • findClass:查找类,procted。默认实现是抛出ClassNotFoundException,子类可以重写这个方法,一般是在findClass中去找到文件,读取字节码,调用defineClass
  • defineClass:定义类,涉及native方法,最底层的类加载的步骤,一般在findClass中调用。

我们上面的例子是直接在loadClass中调用defineClass,而不是在findClass中调用,这里结果上应该是一致的,但放到loadClass中可以直接跳过到parent中递归load的过程,直接就用当前的ClassLoader加载了,可以避免万一在parent中确实加载过同名的class