Java 类加载器解析及常见类加载问题
原文 https://www.toutiao.com/article/6812564562244534787
java.lang.ClassLoader
每个类加载器本身也是个对象——一个继承 java.lang.ClassLoader 的实例。每个类被其中一个实例加载。我们下面来看看 java.lang.ClassLoader 中的 API, 不太相关的部分已忽略。
package java.lang;
public abstract class ClassLoader {
public Class loadClass(String name);
protected Class defineClass(byte[] b);
public URL getResource(String name);
public Enumeration getResources(String name);
public ClassLoader getParent()
}
loadClass: 目前 java.lang.ClassLoader 中最重要的方法是 loadClass 方法,它获取要加载的类的全限定名返回 Class 对象。
defineClass: defineClass 方法用于具体化 JVM 的类。byte 数组参数是加载自磁盘或其他位置的类字节码。
getResource 和 getResources: 返回资源路径。loadClass 大致相当于 defineClass(getResource(name).getBytes())。
getParent: 返回父加载器。
Java 的懒惰特性影响了类加载器的工作方式——所有事情都应该在最后一刻完成。类只有在以某种方式被引用时才会被加载-通过调用构造函数、静态方法或字段。看个例子:
类 A 实例化类 B:
public class A {
public void doSomething() {
B b = new B();
b.doSomethingElse();
}
}
语句 B b = new B() 在语义上等同于 B b = A.class. getClassLoader().loadClass(“B”).newInstance() 。如我们所见,Java 中的每个对象都与其类 (A.class) 相关联,并且每个类都与用于加载类的类加载器 (A.class.getClassLoader()) 相关联。
当我们实例化类加载器时,我们可以将父类加载器指定为构造函数参数。如果未显式指定父类加载器,则会将虚拟机的系统类加载器指定为默认父类。
类加载器层次结构
每当启动新的 JVM 时,引导类加载器(bootstrap classloader)负责首先将关键 Java 类(来自 Java.lang 包)和其他运行时类加载到内存中。引导类加载器是所有其他类加载器的父类。因此,它是唯一没有父类的。
接下来是扩展类加载器(extension classloader)。引导类加载器(bootstrap classloader)作为父类,负责从 java.ext.dirs 路径中保存的所有 .jar 文件加载类。
从开发人员的角度来看,第三个也是最重要的类加载器是系统类路径类加载器(system classpath classloader),它是扩展类加载器(extension classloader)的直接子类。它从由 CLASSPATH 环境变量 java.class.pat h系统属性或 -classpath 命令行选项指定的目录和 jar 文件加载类。
请注意,类加载器层次结构不是继承层次结构,而是委托层次结构。大多数类加载器在搜索自己的类路径之前将查找类和资源委托给其父类。如果父类加载器找不到类或资源,则类加载器只能尝试在本地找到它们。实际上,类加载器只负责加载父级不可用的类;层次结构中较高的类加载器加载的类不能引用层次结构中较低的可用类。类加载器委托行为的动机是避免多次加载同一个类。
在 Java EE 中,查找的顺序通常是相反的:类加载器可能在转到父类之前尝试在本地查找类。
Java EE 委托模型
下面是应用程序容器的类加载器层次结构的典型视图:容器本身有一个类加载器,每个 EAR 模块都有自己的类加载器,每个 WAR 都有自己的类加载器。Java Servlet 规范建议 web 模块的类加载器在委托给其父类之前先在本地类加载器中查找——父类加载器只要求提供模块中找不到的资源和类。
在某些应用程序容器中,遵循此建议,但在其他应用程序容器中,web 模块的类加载器配置为遵循与其他类加载器相同的委托模型,因此建议参考您使用的应用程序容器的文档。
颠倒本地查找和委托查找之间的顺序的原因是,应用程序容器附带了许多具有自己的发布周期的库,这些库可能不适用于应用程序开发人员。典型的例子是 log4j 库——它的一个版本通常随容器一起提供,不同的版本与应用程序捆绑在一起。
现在,让我们来看看我们可能遇到的几个常见的类加载问题,并提供可能的解决方案。
常见类加载问题
Java EE 委托模型会导致类加载的一些有趣的问题。NoClassDefFoundError、LinkageError、ClassNotFoundException、NoSuchMethodError、ClassCasteException等是开发 Java EE 应用程序时遇到的非常常见的异常。我们可以对这些问题的根本原因做出各种假设,但重要的是要验证它们。
NoClassDefFoundError
NoClassDefFoundError 是开发 Java EE Java 应用程序时最常见的问题之一。
根本原因分析和解决过程的复杂性主要取决于 Java EE 中间件环境的大小;特别是考虑到各种 Java EE 应用程序中存在大量的类加载器。
正如 Javadoc 条目所说,如果 Java 虚拟机或类加载器实例试图在类的定义中加载,而找不到类的定义,则抛出 NoClassDefFoundError。这意味着,在编译当前执行的类时,搜索到的类定义存在,但在运行时找不到该定义。
这就是为什么你不能总是依赖你的 IDE 告诉你一切正常,代码编译应该正常工作。相反,这是一个运行时问题,IDE 在这里无法提供帮助。
让我们看看下面的例子:
public class HelloServlet extends HttpServlet {
protected void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
PrintWriter out = response.getWriter();
out.print(new Util().sayHello());
}
servlet HelloServlet 实例化了 Util 类的一个实例,该实例提供了要打印的消息。遗憾的是,当请求执行时,我们可能会看到以下内容:
java.lang.NoClassdefFoundError: Util
HelloServlet:doGet(HelloServlet.java:17)
javax.servlet.http.HttpServlet.service(HttpServlet.java:617)
javax.servlet.http.HttpServlet.service(HttpServlet.java:717)
我们如何解决这个问题?好吧,您可能要做的最明显的操作是检查丢失的 Util 类是否已实际包含在包中。
我们在这里可以使用的技巧之一是让容器类加载器承认它从何处加载资源。为此,我们可以尝试将 HelloServlet 的类加载器转换为 URLClassLoader 并请求其类路径。
public class HelloServlet extends HttpServlet {
protected void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
PrintWriter out = response.getWriter();
out.print(Arrays.toString(
((URLClassLoader)HelloServlet.class.getClassLoader()).getURLs()));
}
结果很可能是这样:
file:/Users/myuser/eclipse/workspace/.metadata/.plugins/org.eclipse.wst.server.core/tmp0/demo/WEB-INF/classes,
file:/Users/myuser/eclipse/workspace/.metadata/.plugins/org.eclipse.wst.server.core/tmp0/demo/WEB-INF/lib/demo-lib.jar
资源的路径( file:/Users/myuser/eclipse/workspace/.metadata/)实际上显示容器是从 Eclipse 启动的,这是 IDE 解压归档文件来进行部署的地方。现在我们可以检查丢失的 Util 是否真的包含在 demo-lib.jar 中,或者它是否存在于扩展存档的 WEB-INF/classes 目录中。
因此,对于我们的特定示例,可能是这样的情况:Util 类应该打包到 demo-lib.jar 中,但是我们没有重新启动构建过程,并且该类没有包含在以前存在的包中,因此出现了错误。
URLClassLoader 技巧可能不适用于所有应用服务器。另一种方法是使用jconsole 实用程序附加到容器JVM进程,以检查类路径。例如,屏幕截图(如下)演示了连接到 JBoss application server 进程的 jconsole 窗口,我们可以从运行时属性中看到 ClassPath 属性值。
NoSuchMethodError
在另一个具有相同示例的场景中,我们可能会遇到以下异常:
java.lang.NoSuchMethodError: Util.sayHello()Ljava/lang/String;
HelloServlet:doGet(HelloServlet.java:17)
javax.servlet.http.HttpServlet.service(HttpServlet.java:617)
javax.servlet.http.HttpServlet.service(HttpServlet.java:717)
NoSuchMethodError 代表另一个问题。在本例中,我们所引用的类存在,但加载的类版本不正确,因此找不到所需的方法。
要解决这个问题,我们首先必须了解类是从何处加载的。最简单的方法是向 JVM 添加 '-verbose:class' 命令行参数,但是如果您可以快速更改代码,那么您可以使用 getResource 搜索与 loadClass 相同的类路径。
public class HelloServlet extends HttpServlet {
protected void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
PrintWriter out = response.getWriter();
out.print(HelloServlet.class.getClassLoader().getResource(
Util.class.getName.replace(‘.’, ‘/’) + “.class”));
}
假设,上述示例的请求执行结果如下.
file:/Users/myuser/eclipse/workspace/.metadata/.plugins/org.eclipse.wst.server.core/tmp0/demo/WEB-INF/lib/demo-lib.jar!/Util.class
现在我们需要验证关于类的错误版本的假设。我们可以使用javap实用程序来反编译类,然后我们可以看到所需的方法是否实际存在。
$ javap -private Util
Compiled from “Util.java”
public class Util extends java.lang.Object {
public Util();
}
如您所见,Util 类的反编译版本中没有sayHello方法。可能,我们在 demo-lib.jar 中打包了 Util 类的初始版本,但是在添加了新的 sayHello 方法之后,我们没有重新构建这个包。
在处理 Java EE 应用程序时,错误类问题 NoClassDefFoundError 和 NoSuchMethodError 的变体是非常典型的,这是 Java 开发人员理解这些错误的本质以有效解决问题所必需的技能。
这些问题有很多变体:AbstractMethodError、ClassCastException、IllegalAccessError——基本上,当我们认为应用程序使用类的一个版本,但实际上它使用了其他版本,或者类的加载方式与需要的不同时,这些问题都会遇到。
ClassCastException
这里我们只演示 ClassCastException 例子。我们将以使用工厂修改初始示例,以便提供提供问候消息的类的实现。这看起来很做作,但这是很常见的模式。
public class HelloServlet extends HttpServlet {
protected void doGet(HttpServletRequest request,
HttpServletResponse response)
throws ServletException, IOException {
PrintWriter out = response.getWriter();
out.print(((Util)Factory.getUtil()).sayHello());
}
class Factory {
public static Object getUtil() {
return new Util();
}
}
请求的可能结果是:
java.lang.ClassCastException: Util cannot be cast to Util
HelloServlet:doGet(HelloServlet.java:18)
javax.servlet.http.HttpServlet.service(HttpServlet.java:617)
javax.servlet.http.HttpServlet.service(HttpServlet.java:717)
这意味着 HelloServlet 和 Factory 类在不同的上下文中操作。我们必须弄清楚这些类是如何加载的。让我们使用 -verbose:class 并找出如何加载与HelloServlet 和 Factory 类相关的 Util 类。
[Loaded Util from file:/Users/ekabanov/Applications/ apache-tomcat-6.0.20/lib/cl-shared-jar.jar]
[Loaded Util from file:/Users/ekabanov/Documents/workspace-javazone/.metadata/.plugins/org.eclipse.wst. server.core/tmp0/wtpwebapps/cl-demo/WEB-INF/lib/cl-demo- jar.jar]
因此,Util类由不同的类加载器从两个不同的位置加载。一个在web应用程序类加载器中,另一个在应用程序容器类加载器中。它们是不兼容的,不能相互转换。
但它们为什么不相容呢?原来Java中的每个类都是由其完全限定名唯一标识的。但在1997年发表的一篇论文揭露了由此引起的一个广泛的安全问题,即沙盒应用程序(例如:applet)可以定义任何类,包括 java.lang.String,并在沙盒外注入自己的代码。
解决方案是通过完全限定名和类加载器的组合来标识类!这意味着从类加载器 A 加载的 Util 类和从类加载器 B 加载的 Util 类在 JVM 中是不同的类,不能将一个类转换为另一个类!
这个问题的根源是 web 类加载器的反向行为。如果 web 类加载器的行为与其他类加载器相同,那么 Util 类将从应用程序容器类加载器加载一次,并且不会抛出类 CastException。
LinkageError
让我们从前面的示例中稍微修改一下 Factory 类,这样 getUtil 方法现在返回的是 Util 类型而不是 Object:
class Factory {
public static Util getUtil() {
return new Util();
}
}
现在,执行的结果是 LinkageError:
ClassCastException: java.lang.LinkageError: loader constraint violation: when resolving method Factory.getUtil()LUtil;
<…> HelloServlet:doGet(HelloServlet.java:18)
javax.servlet.http.HttpServlet.service(HttpServlet.java:617) javax.servlet.http.HttpServlet.service(HttpServlet.java:717)
根本问题与 ClassCastException 相同——唯一的区别是我们不强制转换对象,而是加载程序约束导致Linkage错误。
在处理类加载器时,一个非常重要的原则是认识到类加载器的行为常常会破坏您的直观理解,因此验证您的假设非常重要。例如,在 LinkageError 的情况下,查看代码或构建过程将阻碍而不是帮助您。关键是查看类的确切加载位置,它们是如何到达那里的,以及如何防止将来发生这种情况。
多个类加载器中存在相同类的一个常见原因是,同一个库的不同版本捆绑在不同的位置,例如应用服务器和 web 应用程序。这通常发生在像 log4j 或 hibernate 这样的实际标准库中。在这种情况下,解决方案要么是将库与 web 应用程序分开,要么是非常小心地避免使用父类加载器中的类。
IllegalAccessError
其实,不仅类由其全限定名和类加载器标识,而且该规则也适用于包。为了演示这一点,我们将 Factory.getUtil 方法的访问修饰符更改为默认值:
class Factory {
static Object getUtil() {
return new Util();
}
}
假设 HelloServlet 和 Factory 都位于同一个(默认)包中,因此 getUtil 在 HelloServlet 类中可见。不幸的是,如果我们试图在运行时访问它,我们将看到 IllegalAccessError 异常。
java.lang.IllegalAccessError: tried to access method Factory.getUtil()Ljava/lang/Object;
HelloServlet:doGet(HelloServlet.java:18)
javax.servlet.http.HttpServlet.service(HttpServlet.java:617)
javax.servlet.http.HttpServlet.service(HttpServlet.java:717)
尽管访问修饰符对于应用程序的编译是正确的,但是在运行时,这些类是从不同的类加载器加载的,应用程序无法运行。这是由于与类一样,包也由它们的完全限定名和类加载器来标识,出于同样的安全原因。
ClassCastException、LinkageError 和 IllegalAccessError 根据实现有点不同,但根本原因是相同的类被不同的类加载器加载。
Java 类加载器备忘单
No class found
Variants
ClassNotFoundException NoClassDefFoundError
Helpful
IDE class lookup (Ctrl+Shift+T in Eclipse) find *.jar -exec jar -tf '{}'; | grep MyClass URLClassLoader.getUrls() Container specific logs
Wrong class found
Variants
IncompatibleClassChangeError AbstractMethodError NoSuch(Method|Field)Error ClassCastException, IllegalAccessError
Helpful
-verbose:class ClassLoader.getResource() javap -private MyClass
More than one class found
LinkageError (class loading constraints violated) ClassCastException, IllegalAccessError
Helpful
-verbose:class ClassLoader.getResource()
推荐阅读
你好,我是程序猿DD,10年开发老司机、阿里云MVP、腾讯云TVP、出过书创过业、国企4年互联网6年。从普通开发到架构师、再到合伙人。一路过来,给我最深的感受就是一定要不断学习并关注前沿。只要你能坚持下来,多思考、少抱怨、勤动手,就很容易实现弯道超车!所以,不要问我现在干什么是否来得及。如果你看好一个事情,一定是坚持了才能看到希望,而不是看到希望才去坚持。相信我,只要坚持下来,你一定比现在更好!如果你还没什么方向,可以先关注我,这里会经常分享一些前沿资讯,帮你积累弯道超车的资本。