深入解析Java类加载的案例与实战教程
本篇文章主要介绍Tomcat类加载器架构,以及基于类加载和字节码相关知识,去分析动态代理的原理。
一、Tomcat类加载器架构
Tomcat有自己定义的类加载器,因为一个功能健全的Web服务器,都要解决 如下的这些问题:
- 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的 需求。两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求每个类库在一个服务 器中只能有一份。
- 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。例如用户可能有10个使用Spring组织的应用程序部署在同一台服务器 上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒 不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟 机的方法区就会很容易出现过度膨胀的风险。
- 服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。基于安 全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
- 支持JSP应用的Web服务器,十有八九都需要支持HotSwap功能。我们知道JSP文件最终要被编译 成Java的Class文件才能被虚拟机执行,所谓的hotswap,就是使用新的代码替换掉已经加载的这个Class中的内容。
由于存在上述问题,在部署Web应用时,单独的一个ClassPath就不能满足需求了,所以各种Web服 务器都不约而同地提供了好几个有着不同含义的ClassPath路径供用户存放第三方类库
,这些路径一般 会以“lib”或“classes”命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常每一 个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库
。
在Tomcat目录结构中,把Java类库放置在这4组目录中,每一组都有独立的含义,分别是:
- 放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用。
- 放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见。放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
- 放置在/WebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应用程序都不可见。
为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器, 这些类加载器按照经典的双亲委派模型来实现,关系如下图所示。
灰色背景的3个类加载器是默认提供的类加载器,而JDKCommon类加载器、Catalina类加载器(也称为Server类 加载器)、Shared类加载器和Webapp类加载器则是Tomcat自己定义的类加载器。
它们分别加 载/common/、/server/、/shared/*和/WebApp/WEB-INF/*中的Java类库。
其中WebApp类加载器和JSP类加载器通常还会存在多个实例,每一个Web应用程序对应一个WebApp类加载器
,每一个JSP文件对应 一个JasperLoader类加载器
。
由上图得知:
- Common类加载器能加载的类都可以被Catalina类加载器和Shared 类加载器使用
- 而Catalina类加载器和Shared类加载器自己能加载的类则与对方相互隔离
- WebApp类 加载器可以使用Shared类加载器加载到的类,但各个WebApp类加载器实例之间相互隔离。
- JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class文件,它存在的目的就是为了被 丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新 的JSP类加载器来实现JSP文件的HotSwap功能。
本例中的类加载结构在Tomcat 6以前是它默认的类加载器结构
,在Tomcat 6及之后的版本简化了默 认的目录结构,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loader项后才会 真正建立Catalina类加载器和Shared类加载器的实例,否则会用到这两个类加载器的地方都会用 Common类加载器的实例代替。
Tomcat 6之后也 顺理成章地把/common、/server和/shared这3个目录默认合并到一起变成1个/lib目录
,这个目录里的类库 相当于以前/common目录中类库的作用。
那么笔者不妨再提一个问题让各位读者思考一下:前 面曾经提到过一个场景,如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把Spring 放到Common或Shared目录下让这些程序共享
。Spring要对用户程序的类进行管理,自然要能访问到用 户程序的类
,而用户的程序显然是放在/WebApp/WEB-INF目录中的。那么被Common类加载器或 Shared类加载器加载的Spring如何访问并不在其加载范围内的用户程序呢?
答案:如果按主流的双亲委派机制,显然无法做到让父类加载器加载的类去访问子类加载器加载的类,但使用线程上下文加载器,可以让父类加载器请求子类加载器去完成类加载的动作。spring加载类所用的Classloader是通过Thread.currentThread().getContextClassLoader()来获取的,而当线程创建时会默认setContextClassLoader(AppClassLoader),即线程上下文类加载器被设置为AppClassLoader,spring中始终可以获取到这个AppClassLoader(在Tomcat里就是WebAppClassLoader)子类加载器来加载的bean,以后任何一个线程都可以通过getContextClassLoader()获取到WebAppClassLoader来getbean了。
二、动态代理的原理
“字节码生成”并不是什么高深的技术,因为JDK里面的Javac命令就是字节码生成技术的“老祖 宗”,并且Javac也是一个由Java语言写成的程序。
在Java世界里面除了Javac和字 节码类库外,使用到字节码生成的例子比比皆是,如Web服务器中的JSP编译器
,编译时织入的AOP框 架
,还有很常用的动态代理技术
,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提 高执行速度
。我们选择其中相对简单的动态代理技术来讲解字节码生成技术是如何影响程序运作的。
什么是动态代理?
动态代理中所说的“动态”,是指实 现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系 后,就可以很灵活地重用于不同的应用场景之中。
下面代码演示了一个最简单的动态代理的用法,原始的代码逻辑是打印一句“hello world”,代 理类的逻辑是在原始类方法执行前打印一句“welcome”。我们先看一下代码,然后再分析JDK是如何做 到的。
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("hello world");
}
}
static class DynamicProxy implements InvocationHandler {
Object originalObj;
Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
}
}
运行结果如下:
在上述代码里,唯一的“黑匣子”就是Proxy::newProxyInstance()方法,除此之外再没有任何特殊之 处。这个方法返回一个实现了IHello的接口,并且代理了new Hello()实例行为的对象。
newProxyInstance一共传进去三个参数:
- loader第一个参数,代表的是被代理类的类加载器
- interfaces代理类要实现的被代理类接口
- InvocationHandler代表的是将方法调用分派给的调用处理程序
跟踪这个方法的 源码,可以看到程序进行过验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤 并不是我们关注的重点,这里只分析它最后调用sun.misc.ProxyGenerator::generateProxyClass()方法来完 成生成字节码的动作
。
这个方法会在运行时产生一个描述代理类的字节码byte[]数组。如果想看一看这 个在运行时产生的代理类中写了些什么,可以在main()方法中加入下面这句:
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");
执行完 可以用idea在debug状态下直接双击shift搜索$Proxy即可找到java文件,如下:
import com.gzl.cn.DynamicProxyTest.IHello;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;
final class $Proxy0 extends Proxy implements IHello {
private static Method m1;
private static Method m3;
private static Method m2;
private static Method m0;
public $Proxy0(InvocationHandler var1) throws {
super(var1);
}
// 此处由于版面原因,省略equals()、hashCode()、toString()3个方法的代码
public final void sayHello() throws {
try {
super.h.invoke(this, m3, (Object[])null);
} catch (RuntimeException | Error var2) {
throw var2;
} catch (Throwable var3) {
throw new UndeclaredThrowableException(var3);
}
}
static {
try {
m1 = Class.forName("java.lang.Object").getMethod("equals", Class.forName("java.lang.Object"));
m3 = Class.forName("com.gzl.cn.DynamicProxyTest$IHello").getMethod("sayHello");
m2 = Class.forName("java.lang.Object").getMethod("toString");
m0 = Class.forName("java.lang.Object").getMethod("hashCode");
} catch (NoSuchMethodException var2) {
throw new NoSuchMethodError(var2.getMessage());
} catch (ClassNotFoundException var3) {
throw new NoClassDefFoundError(var3.getMessage());
}
}
}
动态代理的原理:
- 通过ProxyGenerator::generateProxyClass()生成一个代理类
- 这个代理类的实现代码也很简单,它为传入接口中的每一个方法,以及从java.lang.Object中继承来 的equals()、hashCode()、toString()方法都生成了对应的实现,并且统一调用了InvocationHandler对象的 invoke()方法来实现这些方法的 内容。
- 代码中的“super.h”就是父类Proxy中保存的InvocationHandler实例变量,而实例变量就是刚刚传入的new Hello()。
- 所以无论调用动态代理的哪一 个方法,实际上都是在执行InvocationHandler::invoke()中的代理逻辑。
这个例子中并没有讲到generateProxyClass()方法具体是如何产生代理类“$Proxy0.class”的字节码 的,大致的生成过程其实就是根据Class文件的格式规范去拼装字节码
,但是在实际开发中,以字节为 单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。
对于用 户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。如果 读者对动态代理的字节码拼装过程确实很感兴趣,可以在OpenJDK的 java.base\share\classes\java\lang\reflect目录下找到sun.misc.ProxyGenerator的源码。
三、Java语法糖的改变
在Java世界里,每一次JDK大版本的发布,对Java程 序编写习惯改变最大的,肯定是那些对Java语法做出重大改变的版本。
- 譬如JDK 5时加入的自动装箱、 泛型、动态注解、枚举、变长参数、遍历循环(foreach循环);譬如JDK 8时加入的Lambda表达式、 Stream API、接口默认方法等。
- 事实上在没有这些语法特性的年代,Java程序也照样能写。 现在问题来了,如何把高版本JDK中编写的代码放到低版本JDK 环境中去部署使用?
为了解决这个问题,一种名为“Java逆向移植”的工具(Java Backporting Tools)应 运而生,Retrotranslator和Retrolambda是这类工具中的杰出代表。
Retrotranslator的作用是将JDK 5编译出来的Class文件转变为可以在JDK 1.4或1.3上部署的版本
, 它能很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性, 甚至还可以支持JDK 5中新增的集合改进、并发包及对泛型、注解等的反射操作。
Retrolambda
的作 用与Retrotranslator是类似的,目标是将JDK 8
的Lambda表达式和try-resources语法转变为可以在JDK 5、JDK 6、JDK 7中使用的形式
,同时也对接口默认方法提供了有限度的支持。
什么是语法糖?
在前端编译器层面做的改进。这种改进被称作语法糖。也就是这些语法糖主要是帮助我们这些开发人员减少代码量,但是并没有省略掉,只是交给了javac编译器,来替我们做了转换。
- 如自动装箱拆箱,实际上就是Javac编 译器在程序中使用到包装对象的地方自动插入了很多Integer.valueOf()、Float.valueOf()之类的代码
- 使用enum关键字定义常量,尽管从 Java语法上看起来与使用class关键字定义类、使用interface关键字定义接口是同一层次的,但实际上这 是由Javac编译器做出来的假象,从字节码的角度来看,枚举仅仅是一个继承于java.lang.Enum、自动生 成了values()和valueOf()方法的普通Java类而已。
到此这篇关于深入解析Java类加载的案例与实战的文章就介绍到这了,更多相关Java类加载内容请搜索编程网以前的文章或继续浏览下面的相关文章希望大家以后多多支持编程网!
免责声明:
① 本站未注明“稿件来源”的信息均来自网络整理。其文字、图片和音视频稿件的所属权归原作者所有。本站收集整理出于非商业性的教育和科研之目的,并不意味着本站赞同其观点或证实其内容的真实性。仅作为临时的测试数据,供内部测试之用。本站并未授权任何人以任何方式主动获取本站任何信息。
② 本站未注明“稿件来源”的临时测试数据将在测试完成后最终做删除处理。有问题或投稿请发送至: 邮箱/279061341@qq.com QQ/279061341