实现JVM级别共享静态变量

1,695 阅读10分钟

前言

在为 EaseLint 适配 AGP 7.x 时,从源码里了解到 JvmWideVariable 的使用。它解决了因不同类加载器隔离带来的静态变量无法共享的问题,保证在 Android studio 运行 AGP 时,多 classLoader 场景下 lint 核心入口类 Mian.class 的一致性。

我也在后续扫描文件集合共享时,利用了这一特性如法炮制,非常简洁高效。

在Android的热修复、Dex 动态加载、通过不同类加载器隔离三方库、做 APP 页面路由、多模块通信等场景中,JvmWideVariable 为我提供了新的解决思路。

借助本篇我打算深入学,一探究竟,如果你也有兴趣,建议不要略过我精心翻译的注释。

功能特点归纳:

  • JvmWideVariable 用于将静态变量从类访问范围转换为 JVM 范围,支持跨 ClassLoader 共享静态变量

  • JvmWideVariable 需要被声明为静态变量(依赖 class 的生命周期,如果是声明为成员,铁定内存泄漏)

  • 变量的类型必须由单个类加载器加载一次(实际是必须由 Bootstrap Class Loader 加载,下文会解释)

  • 支持线程安全

  • 因为转换后的变量生命周期长于类,所以需要手动释放(这块可使用软引用简化释放步骤)

先来仔细看下 JvmWideVariable 的注释(本文均使用调教过参数的 GPT3.5 进行翻译):

/**
 * 这是一个代理对象,用于访问 JVM 范围内的变量。JVM 范围内的变量可以从 JVM 中的任何位置访问,即使这些位置由不同的类加载器加载,也没有问题。
 *
 * <p>这个类解决了静态变量作用域的问题:静态变量通常仅在定义它们的类加载器的范围内可见。如果同一个静态变量被不同类加载器加载的类使用,就无法访问它。
 *
 * <p>相比之下,JVM 范围内的变量允许不同类加载器加载的类仍然引用相同的变量。这意味着从一个类加载器加载的类对 JVM 范围内的变量所做的更改可以在从另一个类加载器加载的同一类中看到。
 *
 * <p>通常情况下,应该将 {@link JvmWideVariable} 实例分配给某个类的静态字段,而不是作为实例字段或局部变量,因为实际的 JVM 范围内变量不会在不再使用时自动进行垃圾回收,这与实例字段或局部变量的行为不同。
 *
 * <p>使用这个类的方式如下。假设以前使用了一个静态变量:
 *
 * <pre>{@code
 * public final class Counter {
 *   public static final AtomicInteger COUNT = new AtomicInteger(0);
 * }
 * }</pre>
 *
 * <p>现在,我们可以将这个静态变量转换为 JVM 范围内的变量:
 *
 * <pre>{@code
 * public final class Counter {
 *    public static final JvmWideVariable<AtomicInteger> COUNT =
 *      new JvmWideVariable<>(
 *        my.package.Counter.class, "COUNT", AtomicInteger.class, new AtomicInteger(0));
 * }
 * }</pre>
 *
 * <p>需要注意的是,在上面的示例中,{@code Counter.COUNT} 仍然是 {@code Counter} 类的静态变量,具有先前讨论的限制(不仅 {@code Counter} 类,甚至 {@code JvmWideVariable} 类本身也可能由不同类加载器加载多次)。改变的是,现在 {@code Counter.COUNT} 可以访问 JVM 范围内的类型为 {@code AtomicInteger} 的变量。 (在转换之后,变量的类型与转换之前的静态变量的类型相同。)
 *
 * <p>当上下文清晰时,通常会称这些 {@code JvmWideVariable} 类型的变量为 JVM 范围内的变量,虽然严格来说它们不是,但通过它们我们可以访问 JVM 范围内的变量。
 *
 * <p>另外,请注意,JVM 范围内变量的类型必须由单个类加载器加载一次。如果 JVM 范围内变量的类型由不同类加载器加载多次,可能会导致运行时类型转换异常,因为它们本质上是不同的类型。
 *
 * <p>由于 JVM 范围内变量是共享的,所以在使用变量的值时,用户需要适时提供适当的同步,例如使用线程安全类型(如 {@link AtomicInteger} 和 {@link ConcurrentMap})来存储其值,在需要跨类加载器工作的地方使用(隐式或显式)锁定,或者使用此类提供的 {@link #executeCallableSynchronously(Callable)} 等方法。
 *
 * <p>例如,假设我们有一个非线程安全类型(如 {@link Integer})的静态变量,并且在修改变量时使用了同步块:
 *
 * <pre>{@code
 * public final class Counter {
 *   public static Integer COUNT = 0;
 *   public static synchronized void increaseCounter() {
 *     COUNT++;
 *   }
 * }
 * }</pre>
 *
 * <p>然后,可以将转换后的 JVM 范围内实现如下:
 *
 * <pre>{@code
 * public final class Counter {
 *   public static final JvmWideVariable<Integer> COUNT =
 *       new JvmWideVariable<>(my.package.Counter.class, "COUNT", Integer.class, 0);
 *     public static void increaseCounter() {
 *       COUNT.executeRunnableSynchronously(() -> {
 *         COUNT.set(COUNT.get() + 1);
 *       });
 *     }
 * }
 * }</pre>
 *
 * <p>JVM 范围内变量可以在整个 JVM 生命周期内保持活动,也可以在某个时刻释放。释放 JVM 范围内变量需要两个步骤:(1)使用 {@link #unregister()} 方法从 JVM 注销变量,以及(2)取消所有引用到访问它的 {@code JvmWideVariable} 实例。因此,通常需要在步骤(2)中完全释放变量的使用者还需要执行此方法来释放资源。
 *
 * <p>这个类是线程安全的。
 *
 * @param <T> JVM 范围内变量的类型。必须由单个类加载器加载一次。
 */
public final class JvmWideVariable<T> {
    // ... 省略其他代码
}

使用:

先在一个类中声明一个 JvmWideVariable,并将类型设置为 ArrayList,命名为 targetFiles

//声明变量,这是里否使用弱引用影响并不大,因为Lint task运行结束整个进程也就结束了。
companion object {
    private val targetFiles: JvmWideVariable<ArrayList<String>> =
        JvmWideVariable(LintHookHelper.lintRequestClass,
            "targetFiles",
            object : TypeToken<ArrayList<String>>() {}) { ArrayList() }
}
// 这里的 LintHookHelper.lintRequestClass 是被Hook的 com.android.tools.lint.client.api.LintRequest

//更新变量
fun setValue() {
    targetFiles.executeCallableSynchronously {
        targetFiles.set(ArrayList<String>().apply {
           add(xxFile.absolutePath)
        })
    }
}

然后在需要访问此 JVM 范围变量的类声明一个一模一样的 JvmWideVariable,然后读取值即可,非常简洁,支持线程安全:

targetFiles.executeCallableSynchronously {
    targetFiles.get().forEach { path ->
        val file = File(path)
        println(file)
    }
}

源码细节学习

初始化

//声明 JVM 范围变量
private val cachedClassloader: JvmWideVariable<MutableMap<String, SoftReference<URLClassLoader>>> =
    JvmWideVariable(
        AndroidLintWorkAction::class.java,
        "cachedClassloader",
        object : TypeToken<MutableMap<String, SoftReference<URLClassLoader>>>() {}
    ) { HashMap() }
    
//构造器
/**
 * 创建一个 {@code JvmWideVariable} 实例,用于访问 JVM 范围内的变量。如果 JVM 范围内的变量尚不存在,
 * 此构造函数将创建该变量并使用初始值进行初始化。
 *
 * <p>JVM 范围内的变量由其组、名称和标签唯一定义。通常,JVM 范围内的变量应分配给类的静态字段。在这种情况下,
 * 变量的组通常是静态字段定义类的完全限定名称。变量的标签用于区分具有相同组和名称但不应共享的变量(例如,
 * 如果 JVM 加载代码的不同版本,并且变量的类型在这些版本之间发生了更改)。
 *
 * <p>JVM 范围内的变量具有类型和初始值。JVM 范围内变量的类型 {@code T} 必须由单个类加载器加载,
 * 以避免运行时强制转换异常。目前,此构造函数要求该单个类加载器为引导类加载器。
 *
 * <p>如果此类的用户为已经存在的变量提供不同的类型,也将导致运行时强制转换异常。但是,如果他们为已经存在的变量提供不同的初始值,
 * 此构造函数将简单地忽略该值。
 *
 * <p>用户需要通过 {@link TypeToken} 或 {@link Class} 实例显式传递类型 {@code T}。此构造函数使用 {@code TypeToken},
 * 因为它更通用(可以捕获复杂类型,如 {@code Map<K, V>})。如果类型是简单的(可以完全由 {@code Class} 实例表示),
 * 用户可以使用其他构造函数。
 *
 * @param group 变量的组
 * @param name 变量的名称
 * @param tag 变量的标签
 * @param typeToken 变量的类型,必须由引导类加载器加载
 * @param initialValueSupplier 用于生成变量初始值的供应商。仅在首次创建变量时调用。提供的值可以为 null。
 */
public JvmWideVariable(
        @NonNull String group,
        @NonNull String name,
        @NonNull String tag,
        @NonNull TypeToken<T> typeToken,
        @NonNull Supplier<T> initialValueSupplier) {
    String fullName = getFullName(group, name, tag);
    verifyBootstrapLoadedType(typeToken.getType(), fullName);

    this.fullName = fullName;
    this.unregistered = false;

    variableTable.computeIfAbsent(
            fullName, (any) -> new AtomicReference<>(initialValueSupplier.get()));
}

类型限制

值得注意的是这里校验了声明的类型是否由 Bootstrap Class Loader 加载:

/**
 * 收集定义给定类型所涉及的所有类,并检查它们是否全部由引导类装入器装入。
 */
private static void verifyBootstrapLoadedType(@NonNull Type type, @NonNull String variable) {
    for (Class<?> clazz : collectComponentClasses(type)) {
        Verify.verify(
                clazz.getClassLoader() == null,
                "Type %s used to define JVM-wide variable %s must be loaded"
                        + " by the bootstrap class loader but is loaded by %s",
                clazz,
                variable,
                clazz.getClassLoader());
    }
}

不过并不需要担心,通常只会用到 Java.lang,Java.util 包下的类,而这些都会被 Bootstrap Class Loader 默认加载,比如上面的 HashMap 就在其中。

Bootstrap Class Loader 作为 JVM 内置的加载器,在启动时会默认加载 <JAVA_HOME>/lib 下的 jar,种类很多。常用的有下面这些:

rt.jar - 这是包含 Java 核心类的主要 JAR 文件,其中包括 java.lang、java.util 等核心包。
charsets.jar - 包含了字符编码和解码支持的类,例如 java.nio.charset 包。
jce.jar - Java 加密扩展 (Java Cryptography Extension) 包含了加密和解密的类,例如 javax.crypto 包。
jsse.jar - Java 安全套接字扩展 (Java Secure Socket Extension) 提供了安全通信的支持,例如 javax.net.ssl 包。
dnsns.jar - 包含用于域名系统 (DNS) 解析的类。

如何支持 JVM 级别的存储与读取?

/**
 * JVM 全局变量表,它是从变量的完整名称映射到实际 JVM 全局变量(一个包含变量值的 {@link AtomicReference})的映射。
 */
@NonNull
private static final ConcurrentMap<String, AtomicReference<Object>> variableTable =
        createVariableTableIfNotExists();

@NonNull
private static ConcurrentMap<String, AtomicReference<Object>> createVariableTableIfNotExists() {
    // 下面的 MBeanServer 是一个 JVM 范围内的单例对象(即使从不同的类加载器访问它也是同一个实例)。
    // 我们将使用它来存储 JVM 范围内的变量表。
    MBeanServer server = ManagementFactory.getPlatformMBeanServer();

    // 类似于一个 JVM 范围内的变量,变量表具有名称、类型和标签。
    // 我们使用标签以便在此类的实现发生更改并导致变量表不再与先前版本兼容时(例如,如果其类型发生更改),
    // 我们可以更新唯一的标签以避免跨版本的冲突。
    // 目前,我们使用变量表的类型作为其标签。
    String tag = ...
    ObjectName objectName = new ObjectName(getFullName("JvmWideVariable", "variableTable", tag));
    ...
    // 通过同步块确保线程安全,因为我们将要在 MBeanServer 上执行注册操作。
    //noinspection SynchronizationOnLocalVariableOrMethodParameter
    synchronized (server) {
        if (!server.isRegistered(objectName)) {
...
                server.registerMBean(valueWrapper, objectName);
...
        }
    }
...
        variableTable = server.getAttribute(objectName,"VALUE_PROPERTY");               
...

}

使用线程安全的 ConcurrentMap 存储共享变量,key 由 className、自定义名称、由类信息生成的 tag 组成,value 则是 AtomicReference 类型,保持多线程场景读写可见性。

而支持 JVM 范围读写的重点则是 MBeanServer ,将 table 存入到 MBeanServer 内,进而实现 JVM 范围内的读写,那自然也就跨过了不同的 ClassLoader 了。

好家伙,看到这里迷雾终于散去,也比我预想的操作要简洁很多,这得益于 MBeanServer 是 java 内置的,高度封装的。

那就继续看看 MBeanServer 是什么吧。

相关文档与教学:

结合以上介绍大致明白,JMX(Java Management Extensions)的设计是用于监控 Java 程序运行状态,提供 JVM 级别的存储,并通过标准 API 让不同程序可以运行时更新或读取存储在 MBeanServer 中的信息。比如数据库读写次数,网络连接总数,服务器网络配置等等。

那么回到 Android 场景,这里的重点是什么?提供 JVM 级别的存储,支持不同程序更新或读取存储在 MBeanServer 中的信息。

妥妥的是一个随时可用,足够轻量,跨包跨 ClassLoader 的,进程级别的静态变量通信框架。当然用 JvmWideVariable 就很好了,不想导包完全可以 copy 后直接用。

使用非常简单:

//注册
MBeanServer server = ManagementFactory.getPlatformMBeanServer();
server.registerMBean(Object object, ObjectName name)
//读取
server.getAttribute(ObjectName name, String attribute)
//添加变量变化监听,可选
server.addNotificationListener(ObjectName name,
                                    NotificationListener listener,
                                    NotificationFilter filter,
                                    Object handback)

「不要畏惧知识盲区,专注当下的每一小步,每一行代码,每一行注释。JVM 并不可怕,可怕的是你总觉得它很复杂,总觉得还用不到它」——橘子树


滑到底部可点赞评论

浏览橘子树其他文章:橘子树的飞书写作记录