5.4 自定义类加载器示例
同时我们可以自行实现类加载器来加载其他格式的类,对加载方式、加载数据的格式进行自定义处理,只要能通过 classloader 返回一个 Class 实例即可。这就大大增强了加载器灵活性。
比如我们试着实现一个可以用来处理简单加密的字节码的类加载器,用来保护我们的 class 字节码文件不被使用者直接拿来破解。
我们先来看看我们希望加载的一个 Hello 类:
package jvm;
public class Hello {
static {
System.out.println("Hello Class Initialized!");
}
}
这个 Hello 类非常简单,就是在自己被初始化的时候,打印出来一句“Hello Class Initialized!”。假设这个类的内容非常重要,我们不想把编译到得到的 Hello.class 给别人,但是我们还是想别人可以调用或执行这个类,应该怎么办呢?
一个简单的思路是,我们把这个类的 class 文件二进制作为字节流先加密一下,然后尝试通过自定义的类加载器来加载加密后的数据。为了演示简单,我们使用 jdk 自带的 Base64 算法,把字节码加密成一个文本。
在下面这个例子里,我们实现一个 HelloClassLoader,它继承自 ClassLoader 类,但是我们希望它通过我们提供的一段 Base64 字符串,来还原出来,并执行我们的 Hello 类里的打印一串字符串的逻辑。
package jvm;
import java.util.Base64;
public class HelloClassLoader extends ClassLoader {
public static void main(String[] args) {
try {
new HelloClassLoader().findClass("jvm.Hello").newInstance();
} catch (ClassNotFoundException e) {
e.printStackTrace();
} catch (IllegalAccessException e) {
e.printStackTrace();
} catch (InstantiationException e) {
e.printStackTrace();
}
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
String helloBase64 = "yv66vgAAADQAHwoABgARCQASABMIABQKABUAFgcAFwcAGAEABjxpbml0PgEAAygpVgEABENvZGUBAA9MaW5lTnVtYmVyVGFibGUBABJMb2N" +
"hbFZhcmlhYmxlVGFibGUBAAR0aGlzAQALTGp2bS9IZWxsbzsBAAg8Y2xpbml0PgEAClNvdXJjZUZpbGUBAApIZWxsby5qYXZhDAAHAAgHABkMABoAGwEAGEhlb" +
"GxvIENsYXNzIEluaXRpYWxpemVkIQcAHAwAHQAeAQAJanZtL0hlbGxvAQAQamF2YS9sYW5nL09iamVjdAEAEGphdmEvbGFuZy9TeXN0ZW0BAANvdXQBABVMamF2" +
"YS9pby9QcmludFN0cmVhbTsBABNqYXZhL2lvL1ByaW50U3RyZWFtAQAHcHJpbnRsbgEAFShMamF2YS9sYW5nL1N0cmluZzspVgAhAAUABgAAAAAAAgABAAcACA" +
"ABAAkAAAAvAAEAAQAAAAUqtwABsQAAAAIACgAAAAYAAQAAAAMACwAAAAwAAQAAAAUADAANAAAACAAOAAgAAQAJAAAAJQACAAAAAAAJsgACEgO2AASxAAAAAQAK" +
"AAAACgACAAAABgAIAAcAAQAPAAAAAgAQ";
byte[] bytes = decode(helloBase64);
return defineClass(name,bytes,0,bytes.length);
}
public byte[] decode(String base64){
return Base64.getDecoder().decode(base64);
}
}
直接执行这个类:
$ java jvm.HelloClassLoaderHello Class Initialized!
可以看到达到了我们的目的,成功执行了 Hello 类的代码,但是完全不需要有 Hello 这个类的 class 文件。
此外,需要说明的是两个没有关系的自定义类加载器之间加载的类是不共享的(只共享父类加载器,兄弟之间不共享),这样就可以实现不同的类型沙箱的隔离性,我们可以用多个类加载器,各自加载同一个类的不同版本,大家可以相互之间不影响彼此,从而在这个基础上可以实现类的动态加载卸载,热插拔的插件机制等,具体信息大家可以参考 OSGi 等模块化技术。
5.5 一些实用技巧
1)如何排查找不到 Jar 包的问题?
有时候我们会面临明明已经把某个 jar 加入到了环境里,可以运行的时候还是找不到。那么我们有没有一种方法,可以直接看到各个类加载器加载了哪些 jar,以及把哪些路径加到了 classpath 里?答案是肯定的,代码如下:
package jvm;
import java.lang.reflect.Field;
import java.net.URL;
import java.net.URLClassLoader;
import java.util.ArrayList;
public class JvmClassLoaderPrintPath {
public static void main(String[] args) {
URL[] urls = sun.misc.Launcher.getBootstrapClassPath().getURLs();
System.out.println("启动类加载器");
for(URL url : urls) {
System.out.println(" ==> " +url.toExternalForm());
}
printClassLoader("扩展类加载器", JvmClassLoaderPrintPath.class.getClassLoader().getParent());
printClassLoader("应用类加载器", JvmClassLoaderPrintPath.class.getClassLoader());
}
public static void printClassLoader(String name, ClassLoader CL){
if(CL != null) {
System.out.println(name + " ClassLoader -> " + CL.toString());
printURLForClassLoader(CL);
}else{
System.out.println(name + " ClassLoader -> null");
}
}
public static void printURLForClassLoader(ClassLoader CL){
Object ucp = insightField(CL,"ucp");
Object path = insightField(ucp,"path");
ArrayList ps = (ArrayList) path;
for (Object p : ps){
System.out.println(" ==> " + p.toString());
}
}
private static Object insightField(Object obj, String fName) {
try {
Field f = null;
if(obj instanceof URLClassLoader){
f = URLClassLoader.class.getDeclaredField(fName);
}else{
f = obj.getClass().getDeclaredField(fName);
}
f.setAccessible(true);
return f.get(obj);
} catch (Exception e) {
e.printStackTrace();
return null;
}
}
}
代码执行结果如下:
启动类加载器
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/resources.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/rt.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/sunrsasign.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/jsse.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/jce.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/charsets.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/jfr.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/classes
扩展类加载器 ClassLoader -> sun.misc.Launcher$ExtClassLoader@15db9742
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/access-bridge-64.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/cldrdata.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/dnsns.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/jaccess.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/jfxrt.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/localedata.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/nashorn.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/sunec.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/sunjce_provider.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/sunmscapi.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/sunpkcs11.jar
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/ext/zipfs.jar
应用类加载器 ClassLoader -> sun.misc.Launcher$AppClassLoader@73d16e93
==> file:/D:/git/studyjava/build/classes/java/main/
==> file:/D:/git/studyjava/build/resources/main
从打印结果,我们可以看到三种类加载器各自默认加载了哪些 jar 包和包含了哪些 classpath 的路径。
2)如何排查类的方法不一致的问题?
假如我们确定一个 jar 或者 class 已经在 classpath 里了,但是却总是提示java.lang.NoSuchMethodError,这是怎么回事呢?
很可能是加载了错误的或者重复加载了不同版本的 jar 包。这时候,用前面的方法就可以先排查一下,加载了具体什么 jar,然后是不是不同路径下有重复的 class 文件,但是版本不一样。
3)怎么看到加载了哪些类,以及加载顺序?
还是针对上一个问题,假如有两个地方有 Hello.class,一个是新版本,一个是旧的,怎么才能直观地看到他们的加载顺序呢?
也没有问题,我们可以直接打印加载的类清单和加载顺序。只需要在类的启动命令行参数加上-XX:+TraceClassLoading 或者 -verbose 即可,注意需要加载 java 命令之后,要执行的类名之前,不然不起作用。例如:
$ java -XX:+TraceClassLoading jvm.HelloClassLoader
[Opened D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.Object from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.io.Serializable from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.Comparable from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.CharSequence from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.String from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.reflect.AnnotatedElement from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.reflect.GenericDeclaration from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.reflect.Type from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.Class from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.Cloneable from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.ClassLoader from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.System from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded jvm.Hello from __JVM_DefineClass__]
[Loaded java.util.concurrent.ConcurrentHashMap$ForwardingNode from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
Hello Class Initialized!
[Loaded java.lang.Shutdown from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
[Loaded java.lang.Shutdown$Lock from D:\Program Files\Java\jre1.8.0_231\lib\rt.jar]
上面的信息,可以很清楚的看到类的加载先后顺序,以及是从哪个 jar 里加载的,这样排查类加载的问题非常方便。
4)怎么调整或修改 ext 和本地加载路径?
从前面的例子我们可以看到,假如什么都不设置,直接执行 java 命令,默认也会加载非常多的 jar 包,怎么可以自定义加载哪些 jar 包呢?比如我的代码很简单,只加载 rt.jar 行不行?答案是肯定的。
$ java -Dsun.boot.class.path="D:\Program Files\Java\jre1.8.0_231\lib\rt.jar" -Djava.ext.dirs= jvm.JvmClassLoaderPrintPath
启动类加载器
==> file:/D:/Program%20Files/Java/jdk1.8.0_231/jre/lib/rt.jar
扩展类加载器 ClassLoader -> sun.misc.Launcher$ExtClassLoader@15db9742
应用类加载器 ClassLoader -> sun.misc.Launcher$AppClassLoader@73d16e93
==> file:/D:/git/studyjava/build/classes/java/main/
==> file:/D:/git/studyjava/build/resources/main
我们看到启动类加载器只加载了 rt.jar,而扩展类加载器什么都没加载,这就达到了我们的目的。其中命令行参数-Dsun.boot.class.path表示我们要指定启动类加载器加载什么,最基础的东西都在 rt.jar 这个包了里,所以一般配置它就够了。
需要注意的是因为在 windows 系统默认 JDK 安装路径有个空格,所以需要把整个路径用双引号括起来,如果路径没有空格,或是 Linux/Mac 系统,就不需要双引号了。
参数-Djava.ext.dirs表示扩展类加载器要加载什么,一般情况下不需要的话可以直接配置为空即可。
5)怎么运行期加载额外的 jar 包或者 class 呢?
有时候我们在程序已经运行了以后,还是想要再额外的去加载一些 jar 或类,需要怎么做呢?简单说就是不使用命令行参数的情况下,怎么用代码来运行时改变加载类的路径和方式。
假如说,在d:/app/jvm路径下,有我们刚才使用过的 Hello.class 文件,怎么在代码里能加载这个 Hello 类呢?
两个办法,一个是前面提到的自定义 ClassLoader 的方式,还有一个就是直接在当前的应用类加载器里,使用 URLClassLoader 类的方法 addURL,不过这个方法是 protected 的,需要反射处理一下,然后又因为程序在启动时并没有显示加载 Hello 类,所以在添加完了 classpath 以后,没法直接显式初始化,需要使用 Class.forName 的方式来拿到已经加载的 Hello 类(Class.forName("jvm.Hello") 默认会初始化并执行静态代码块)。代码如下:
package jvm;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.net.MalformedURLException;
import java.net.URL;
import java.net.URLClassLoader;
public class JvmAppClassLoaderAddURL {
public static void main(String[] args) {
String appPath = "file:/d:/app/";
URLClassLoader urlClassLoader = (URLClassLoader) JvmAppClassLoaderAddURL.class.getClassLoader();
try {
Method addURL = URLClassLoader.class.getDeclaredMethod("addURL", URL.class);
addURL.setAccessible(true);
URL url = new URL(appPath);
addURL.invoke(urlClassLoader, url);
Class.forName("jvm.Hello");
} catch (Exception e) {
e.printStackTrace();
}
}
}
执行以下,结果如下:
$ java JvmAppClassLoaderAddURLHello Class Initialized!
结果显示 Hello 类被加载,成功的初始化并执行了其中的代码逻辑。
参考链接