Java 如何实现动态脚本?( 三 )


从上文中,我们知道 JVM 对于一个类的唯一标识是(Classloader,类全名),因此只要能保证我们自定义的 Classloader 是不同的对象,也能够避免类重名的问题 。
Class 生命周期问题
Java 脚本动态化必须考虑垃圾回收的问题,否则随着 Class 被加载的越来越多,系统的内存很快就不够用了 。我们知道在 JVM 中,对象实例在没有被引用后会被 GC (Garbage Collection 垃圾回收),Class 作为 JVM 中一个特殊的对象,也会被 GC(清空方法区中 Class 的信息和堆区中的 java.lang.Class 对象 。这时 Class 的生命周期就结束了) 。
Class 要被回收,需要满足以下三个条件:

  • NoInstance:该类所有的实例都已经被 GC 。
  • NoClassLoader:加载该类的 ClassLoader 实例已经被 GC 。
  • NoReference:该类的 java.lang.Class 没有被引用 (XXX.class,使用了静态变量/方法) 。
从上面三个条件可以推出,JVM 自带的类加载器(Bootstrap 类加载器、Extension 类加载器)所加载的类,在 JVM 的生命周期中始终不会被 GC 。自定义的类加载器所加载的 Class 是可以被 GC 的,因此在编码时,自定义的 Classloader 一定做成局部变量,让其自然被回收 。
为了验证 Class 的 GC 情况,我们写一个简单的循环来观察,模块 advance-discuss 下的 AdvanceDiscuss.java 文件中:
???????for (int i = 0; i < 1000000; i++) { //编译加载并且执行 compileAndRun(i); //10000个回收一下 if (i % 10000 == 0) { System.gc(); }}//强制进行回收System.gc();System.out.println("休息10s");Thread.currentThread().sleep(10 * 1000);打开 Java 自带的 jvisualvm 程序(位于 JAVA_HOME/bin/jvisualvm),可以可视化的观看到 JVM 的情况 。
Java 如何实现动态脚本?

文章插图
 
在上图中可以看到加载类的变化图以及堆大小呈锯齿状,说明动态加载类能够被有效的被回收 。
安全问题
让用户写脚本,并且在服务器上运行,光是想想就知道是一件非常危险的事情,因此如何保证脚本的安全,是必须严肃对待的一个问题 。
类的白名单及黑名单机制
在用户写的 Java 代码中,我们需要规定用户允许使用的类范围,试想用户调用 File 来操作服务器上的文件,这是非常不安全的 。javassist 库可以对 Class 二进制文件进行分析,借助该库我们可以很容易地得到 Class 所依赖的类 。代码位于模块 advance-discuss 下的 JavassistUtil.java 文件中,以下是核心代码:
???????public static Set<String> getDependencies(InputStream is) throws Exception { ClassFile cf = new ClassFile(new DataInputStream(is)); ConstPool constPool = cf.getConstPool(); HashSet<String> set = new HashSet<>(); for (int ix = 1, size = constPool.getSize(); ix < size; ix++) { int descriptorIndex; if (constPool.getTag(ix) == ConstPool.CONST_Class) { set.add(constPool.getClassInfo(ix)); } else if (constPool.getTag(ix) == ConstPool.CONST_NameAndType) { descriptorIndex = constPool.getNameAndTypeDescriptor(ix); String desc = constPool.getUtf8Info(descriptorIndex); for (int p = 0; p < desc.length(); p++) { if (desc.charAt(p) == 'L') { set.add(desc.substring(++p, p = desc.indexOf(';', p)).replace('/', '.')); } } } } return set;}拿到依赖后,就可以首先使用白名单来过滤,以下这些包或类只涉及简单的数据操作和处理,是被允许的:
??java.lang,java.util,com.alibaba.fastjson,java.text,[Ljava.lang (java.lang下的数组,例如 `String[]`)[D (double[])[F (float[])[I (int[])[J (long[])[C (char[])[B (byte[])[Z (boolean[])但是有个别的包下的类也比较危险,需要过滤掉,这时候就需要用黑名单再做一次筛选,这些包或类是不被允许的:
??????java.lang.Threadjava.lang.reflect线程隔离
有可能用户的代码中包含死循环,或者执行时间特别长,对于这种有问题的逻辑在编译时是无法感知的,因此还需要使用单独的线程来执行用户的代码,当出现超时或者内存占用过大的情况就直接 kill 。
缓存问题
上面讨论的都是从编译到执行的完整过程,但是有时候用户的代码没有变更,我们去执行时就没有必要再次去编译了,因此可以设计一个缓存策略,当用户代码没有发生变更时,就使用懒加载策略,当用户的代码发生了变更就释放之前加载好的 Class,重新加载新的代码 。
及时加载问题
当系统重启时,相当于所有的类都被释放了需要重新加载,对于一些比较重要的脚本,可能短暂的懒加载时间也是难以接受的,对于这种就需要单独搜集,在系统启动的时候根据系统一起加载进内存,这样就可以当健康检查通过时,保证类已经加载好了,从而有效缩短响应时间 。


推荐阅读