编程就像魔法。最近遇到一个诡异的问题:添加一段看似无害的简单代码后,系统原有功能不可用了。
复现演示
jdk 8 可使用如下演示代码复现这个问题。 TaskCenter
是一个任务框架,可添加多个任务,随后框架将执行这些任务。 First
任务是新增代码,看起来简单无害,且看不出对原有任务 Count
有何影响,但添加 First
任务后,其自身执行正常,原本正常的 Count
任务却不可用了。
public class TaskCenter {
static class Task {
final Function<Object[], Object> action;
final Object[] data;
Task(Function<Object[], Object> action, Object... data) {
this.action = action;
this.data = data;
}
}
static Function<Object[], Object> First = (Object[] data) -> {
for (Object item : data) {
if (item != null) {
return item;
}
}
return null;
};
static Function<Object[], Object> Count = (Object[] data) -> {
final Predicate<Object> nonNull = new Predicate<Object>() {
public boolean test(Object item) {
return item != null;
}
};
return Arrays.stream(data).filter(nonNull).count();
};
public static void main(String[] args) throws Exception {
final ArrayList<Task> taskList = new ArrayList<>();
taskList.add(new Task(First, null, First, "hello")); // <- 新增代码
taskList.add(new Task(Count, "hello", null, "world"));
taskList.add(new Task(Count, "try", "again"));
for (Task task : taskList) {
final Object result = task.action.apply(task.data);
postProcess(result);
}
System.out.println("Success");
}
// 框架层面的附加检查处理
static void postProcess(final Object result) throws Exception {
if (result != null && result.getClass().getClassLoader() != null) {
ClassLoader parent = result.getClass().getClassLoader().getParent();
if (parent != null) {
final Method addURL = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
addURL.setAccessible(true);
addURL.invoke(parent, result.getClass().getProtectionDomain().getCodeSource().getLocation());
}
}
}
}
使用 jdk 8 执行如上代码,可看到 Count
任务在创建内部类 nonNull
对象时报错如下:
Exception in thread "main" java.lang.IllegalAccessError: tried to access class TaskCenter$1 from class TaskCenter
at TaskCenter.lambda$static$1(TaskCenter.java:33)
at TaskCenter.main(TaskCenter.java:49)
原因分析
从报错信息看,是 TaskCenter
不能访问 Count
任务方法的内部类 TaskCenter$1
。
在加载此内部类的地方打断点,仔细比较 TaskCenter
和 TaskCenter$1
各项属性的变化,发现正常情况下两个类都是由系统类加载器加载的,而报错时 TaskCenter$1
变成了由系统类加载器的父加载器加载,导致 IllegalAccessError
。
新增代码 First
任务找不到任何可疑点,问题在哪里呢?在框架执行任务时产生的副作用上,演示代码中体现的非常直接。真实案例中,框架维护了多个 ClassLoader ,偶然情况下可能将本来由一个 ClassLoader 加载的程序库意外提升到其父 ClassLoader 上。
以演示代码为例,创建任务对象时,任务类由系统类加载器加载,但任务方法用到的内部类,会延迟到任务方法执行时才会加载,此时程序库已被提升到父加载器,导致内部类被父加载器加载,出现不一致。
为什么使用 jdk 8 演示?原问题与 jdk 版本无关,但 jdk 8 内置的 ClassLoader 更方便修改,更方便使用简单演示复现问题。
顺带提一下,演示代码如果将 Count
任务下的内部类改成 lambda 表达式,即
final Predicate<Object> nonNull = Objects::nonNull;
就不会出现这个问题,这是因为 lambda 表达式进行了特殊优化,与传统的内部类加载不一样。
问题解决
查到问题为:延迟加载内部类时类加载器不一致。如使解决呢?
办法一,修改创建 Count
任务对象的代码,如同时创建 nonNull
对象避免延迟加载等。但这显然是治标不治本,不可取。谁知道哪里还有这种运行时触发的延迟加载呢,如何保证往后的新增代码不引入类似的延迟加载呢?这是 Java 的常规用法,没有可靠的办法来避免,也不应该这样避免。
办法二,分析框架为什么会有这样的副作用,改动框架避免这样的副作用。看起来是要治根,但框架本身涉及面太大,可能没法改,既使改了反而可能会引起更多不可预料的问题。从工作量和后果来看都不太现实。
办法三,将新增代码移动到一个新的程序库,以尽量避免新增代码影响现有代码,拆分细化风险面。类似办法一,也是个治标不治本的办法,只能说风险更可控一些。目前暂采取了此办法,未来还得从长计议。
办法四,引入更合理的新框架,新增代码转而使用新框架。