说说 Java 的类加载机制?究竟什么是双亲委派模型?
首先引入一个概念,什么是 Java 类加载器?
一句话总结:类加载器(class loader)用来加载 Java 类到 Java 虚拟机中。
官方总结:Java 类加载器(英语:Java Classloader)是 Java 运行时环境(Java Runtime Environment)的一部分,负责动态加载 Java 类到 Java 虚拟机的内存空间中。类通常是按需加载,即第一次使用该类时才加载。由于有了类加载器,Java 运行时系统不需要知道文件与文件系统。
类与类加载器
先来看一下 JVM 中默认的类加载器
分类
实现通过类的全限定名获取该类的二进制字节流的代码块叫做类加载器。
类加载器虽然只用于实现类的加载动作,但它在 Java 程序中起到的作用却远超类加载阶段
对于任意一个类,都必须由加载它的类加载器和这个类本身一起共同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。这句话可以表达得更通俗一些:比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个 Java 虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等
为什么要有三个类加载器?一方面是分工,各自负责各自的区块,就如 Application Class Loader 主要负责加载用户之间开发的代码,另一方面为了实现委托模型。
启动类加载器
引导类加载器属于 JVM 的一部分,由 C++代码实现。
引导类加载器负责加载<JAVA_HOME>\jre\lib 路径下的核心类库,由于安全考虑只加载 包名 java、javax、sun 开头的类。
拓展类加载器
全类名:sum.misc.Launch$ExtClassLoader,Java 语言实现。
扩展类加载器的父加载器是 Bootstrap 启动类加载器 (注:不是继承关系)
扩展类加载器负责加载<JAVA_HOME>\jre\lib\ext 目录下的类库。
应用程序类加载器
全类名: sun.misc.Launcher$AppClassLoader
系统类加载器的父加载器是 ExtClassLoader 扩展类加载器(注: 不是继承关系)。
系统类加载器负责加载 classpath 环境变量所指定的类库,是用户自定义类的默认类加载器。
类的加载方式
类加载有三种方式:
命令行启动应用时候由 JVM 初始化加载
通过 Class.forName()方法动态加载
通过 ClassLoader.loadClass()方法动态加载
Class.forName()和 ClassLoader.loadClass()的区别:
Class.forName(): 将类的.class 文件加载到 jvm 中之外,还会对类进行解释,执行类中的 static 块;
ClassLoader.loadClass(): 只干一件事情,就是将.class 文件加载到 jvm 中,不会执行 static 中的内容,只有在 newInstance 才会去执行 static 块。
Class.forName(name, initialize, loader)带参函数也可控制是否加载 static 块。并且只有调用了 newInstance()方法采用调用构造函数,创建类的对象 。
JVM 类加载机制
全盘负责:当一个类加载器负责加载某个 Class 时,该 Class 所依赖的和引用的其他 Class 也将由该类加载器负责载入,除非显示使用另外一个类加载器来载入
缓存机制:缓存机制将会保证所有加载过的 Class 都会被缓存,当程序中需要使用某个 Class 时,类加载器先从缓存区寻找该 Class,只有缓存区不存在,系统才会读取该类对应的二进制数据,并将其转换成 Class 对象,存入缓存区。这就是为什么修改了 Class 后,必须重启 JVM,程序的修改才会生效
双亲委派机制:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把请求委托给父加载器去完成,依次向上,因此,所有的类加载请求最终都应该被传递到顶层的启动类加载器中,只有当父加载器在它的搜索范围中没有找到所需的类时,即无法完成该加载,子加载器才会尝试自己去加载该类。
一个应用程序总是由 n 多个类组成,Java 程序启动时,并不是一次把所有的类全部加载后再运行,它总是先把保证程序运行的基础类一次性加载到 JVM 中,其它类等到 JVM 用到的时候再加载。
双亲委派模型
一个类加载器收到一个类的加载请求时,它首先不会自己尝试去加载它,而是把这个请求委派给父类加载器去完成,这样层层委派,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求时,子加载器才会尝试自己去加载。
双亲委派模型的具体实现代码在 java.lang.ClassLoader 中,此类的 loadClass() 方法运行过程如下:先检查类是否已经加载过,如果没有则让父类加载器去加载。当父类加载器加载失败时抛出 ClassNotFoundException ,此时尝试自己去加载。
例如:
执行流程为:
sun.misc.Launcher$AppClassLoader //1 处, 开始查看已加载的类,结果没有
sun.misc.Launcher$AppClassLoader // 2 处,委派上级
sun.misc.Launcher$ExtClassLoader // 1 处,查看已加载的类,结果没有
sun.misc.Launcher$ExtClassLoader // 3 处,没有上级了,则委派 BootstrapClassLoader 查找
BootstrapClassLoader 是在 JAVA_HOME/jre/lib 下找 F 这个类,显然没有,就会捕获异常,但是不处理
sun.misc.Launcher$ExtClassLoader // 4 处,调用自己的 findClass 方法,是在 JAVA_HOME/jre/lib/ext 下找 F 这个类,显然没有,回到 sun.misc.Launcher $AppClassLoader 的 // 2 处
继续执行到 sun.misc.Launcher$AppClassLoader // 4 处,调用它自己的 findClass 方法,在 classpath 下查找,找到了
双亲委派模型目的
可以防止内存中出现多份同样的字节码。如果没有双亲委派模型而是由各个类加载器自行加载的话,如果用户编写了一个 java.lang.Object 的同名类并放在 ClassPath 中,多个类加载器都去加载这个类到内存中,系统中将会出现多个不同的 Object 类,那么类之间的比较结果及类的唯一性将无法保证。
依赖传递原则
假设类 C 是由加载器 L1 定义加载的,那么类 C 中所依赖的其他类将会通过 L1 进行加载。
如下:假设 Demo2 是由 L1 定义加载的,那么类 Demo2 中所依赖的类 F 和接口 IDemo 将会通过 L1 进行加载。甚至还用到了 String 类,String 也会由 L1 加载,但由于双亲委派模型,String 类最终会由 Bootstrap ClassLoader 进行加载。(前提是这些依赖的类尚未加载)
打破双亲委派模型
什么时候需要打破双亲委派模型?比如类 A 已经有一个 classA,恰好类 B 也有一个 clasA 但是两者内容不一致,如果不打破双亲委派模型,那么类 A 只会加载一次
只要在加载类的时候,不按照 UserCLASSlOADER->Application ClassLoader->Extension ClassLoader->Bootstrap ClassLoader 的顺序来加载就算打破打破双亲委派模型了。比如自定义个 ClassLoader,重写 loadClass 方法(不依照往上开始寻找类加载器),那就算是打破双亲委派机制了。
打破双亲委派模型有两种方式:
自定义一个类加载器的类,并覆盖抽象类 java.lang.ClassL oader 中 loadClass..)方法,不再优先委派“父”加载器进行类加载。(比如 Tomcat)
主动违背类加载器的依赖传递原则
例如在一个 BootstrapClassLoader 加载的类中,又通过 APPClassLoader 来加载所依赖的其它类,这就打破了“双亲委派模型”中的层次结构,逆转了类之间的可见性。
典型的是 Java SPI 机制,它在类 ServiceLoader 中,会使用线程上下文类加载器来逆向加载 classpath 中的第三方厂商提供的 Service Provider 类。(比如 JDBC)
Tomcat
在 Tomcat 部署项目时,是把 war 包放到 tomcat 的 webapp 下,这就意味着一个 tomcat 可以运行多个 Web 应用程序。
假设现在有两个 Web 应用程序,它们都有一个类,叫 User,并且它们的类全限定名都一样,比如都是 com.yyy.User,但是他们的具体实现是不一样的。那么 Tomcat 如何保证它们不会冲突呢?
Tomcat 给每个 Web 应用创建一个类加载器实例(WebAppClassLoader),该加载器重写了 loadClass 方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找,这样就做到了 Web 应用层级的隔离。
但是并不是 Web 应用程序的所有依赖都需要隔离的,比如要用到 Redis 的话,Redis 就可以再 Web 应用程序之间贡献,没必要每个 Web 应用程序每个都独自加载一份。因此 Tomcat 就在 WebAppClassLoader 上加个父加载器 ShareClassLoader,如果 WebAppClassLoader 没有加载到这个类,就委托给 ShareClassLoader 去加载。(意思就类似于将需要共享的类放到一个共享目录下)
Web 应用程序有类,但是 Tomcat 本身也有自己的类,为了隔绝这两个类,就用 CatalinaClassLoader 类加载器进行隔离,CatalinaClassLoader 加载 Tomcat 本身的类
Tomcat 与 Web 应用程序还有类需要共享,那就再用 CommonClassLoader 作为 CatalinaClassLoader 和 ShareClassLoader 的父类加载器,来加载他们之间的共享类
Tomcat 加载结构图如下:
JDBC
实际上 JDBC 定义了接口,具体的实现类是由各个厂商进行实现的(比如 MySQL)
类加载有个规则:如果一个类由类加载器 A 加载,那么这个类的依赖类也是由「相同的类加载器」加载。
而在用 JDBC 的时候,是使用 DriverManager 获取 Connection 的,DriverManager 是在 java.sql 包下的,显然是由 BootStrap 类加载器进行装载的。当使用 DriverManager.getConnection ()时,需要得到的一定是对应厂商(如 Mysql)实现的类。这里在去获取 Connection 的时候,是使用「线程上下文加载器」去加载 Connection 的,线程上下文加载器会直接指定对应的加载器去加载。也就是说,在 BootStrap 类加载器利用「线程上下文加载器」指定了对应的类的加载器去加载
线程上下文加载器
Java 提供了很多服务提供者接口(Service Provider Interface,SPI),允许第三方为这些接口提供实现。常见的 SPI 有 JDBC 。
这些 SPI 的接口由 Java 核心库来提供,而这些 SPI 的实现代码则是作为 Java 应用所依赖的 jar 包被包含进类路径(CLASSPATH)里。SPI 接口中的代码经常需要加载具体的实现类。那么问题来了,SPI 的接口是 Java 核心库的一部分,是由启动类加载器来加载的;SPI 的实现类是由系统类加载器来加载的。启动类加载器是无法找到 SPI 的实现类的,因为它只加载 Java 的核心库。它也不能委派给系统类加载器,因为它是系统类加载器的祖先类加载器。
线程上下文类加载器正好解决了这个问题。如果不做任何的设置,Java 应用的线程的上下文类加载器默认就是系统上下文类加载器。在 SPI 接口的代码中使用线程上下文类加载器,就可以成功的加载到 SPI 实现的类。线程上下文类加载器在很多 SPI 的实现中都会用到。
线程上下文加载器的一般使用模式(获取 - 使用 - 还原)
源码实现
这就是普通的连接数据库的代码,可以直接获取数据库连接进行操作,这段代码没有了加载驱动的代码,那要怎么去确定使用哪个数据库连接的驱动呢?这里就涉及到使用 Java 的 SPI 扩展机制来查找相关驱动的东西了,关于驱动的查找其实都在 DriverManager 中,DriverManager 是 Java 中的实现,用来获取数据库连接,在 DriverManager 中有一个静态代码块如下:
可以看到是加载实例化驱动的,接着看 loadInitialDrivers 方法:
上面的代码主要步骤是:
从系统变量中获取有关驱动的定义。
使用 SPI 来获取驱动的实现。
遍历使用 SPI 获取到的具体实现,实例化各个实现类。
根据第一步获取到的驱动列表来实例化具体实现类。
主要关注 2,3 步,这两步是 SPI 的用法,首先看第二步,使用 SPI 来获取驱动的实现,对应的代码是:
这里没有去 META-INF/services 目录下查找配置文件,也没有加载具体实现类,做的事情就是封装了我们的接口类型和类加载器,并初始化了一个迭代器。
接着看第三步,遍历使用 SPI 获取到的具体实现,实例化各个实现类,对应的代码如下:
在遍历的时候,首先调用 driversIterator.hasNext()方法,这里会搜索 classpath 下以及 jar 包中所有的 META-INF/services 目录下的 java.sql.Driver 文件,并找到文件中的实现类的名字,此时并没有实例化具体的实现类(ServiceLoader 具体的源码实现在下面)。
然后是调用 driversIterator.next();方法,此时就会根据驱动名字具体实例化各个实现类了。现在驱动就被找到并实例化了。
可以看下截图,我在测试项目中添加了两个 jar 包,mysql-connector-java-6.0.6.jar 和 postgresql-42.0.0.0.jar,跟踪到 DriverManager 中之后:
可以看到此时迭代器中有两个驱动,mysql 和 postgresql 的都被加载了。
自定义类加载器加载 java.lang.String
很多人都有个误区:双亲委派机制不能被打破,不能使用自定义类加载器加载 java.lang.String
但是事实上并不是,只要重写 ClassLoader 的 loadClass()方法,就能打破了。
加载同一个类 MyClassLoader,使用的类加载器不同,说明这里是打破了双亲委派机制的,但是尝试加载 String 类的时候报错了
看代码是 ClassLoader 类里面的限制,只要加载 java 开头的包就会报错。所以真正原因是 JVM 安全机制,并不是因为双亲委派。
那么既然是 ClassLoader 里面的代码做的限制,那把 ClassLoader.class 修改了不就好了吗。
写了个 java.lang.ClassLoader,把 preDefineClass()方法里那段 if 直接删掉,再用编译后的 class 替换 rt.jar 里面的,直接通过命令 jar uvf rt.jar java/lang/ClassLoader/class 即可。
不过事与愿违,修改之后还是报错:
仔细看报错和之前的不一样了,这次是 native 方法报错了。这就比较难整了,看来要自己重新编译个 JVM 才行了。理论上来说,编译 JVM 的时候把校验的代码去掉就行了。
结论:自定义类加载器加载 java.lang.String,必须修改 jdk 的源码,自己重新编译个 JVM 才行。
总结
1.、JDK 中有三个默认类加载器:AppClassLoader、ExtClassLoader、BootStrapClassLoader。AppClassLoader 的父加载器为 Ext ClassLoader、Ext ClassLoader 的父加载器为 BootStrap ClassLoader。
2、什么是双亲委派机制:加载器在加载过程中,先把类交由父加载器进行加载,父加载器没找到才由自身加载。
3、双亲委派机制目的:为了防止内存中存在多份同样的字节码(安全)
4、依赖传递原则:如果一个类由类加载器 A 加载,那么这个类的依赖类也是由「相同的类加载器」加载。
5、什么时候需要打破双亲委派模型?比如类 A 已经有一个 classA,恰好类 B 也有一个 clasA 但是两者内容不一致,如果不打破双亲委派模型,那么类 A 只会加载一次
6、如何打破双亲委派机制:
自定义 ClassLoader,重写 loadClass 方法(只要不依次往上交给父加载器进行加载,就算是打破双亲委派机制)(Tomcat)
主动违背类加载器的依赖传递原则(JDBC)
7、打破双亲委派机制案例:Tomcat
为了 Web 应用程序类之间隔离,为每个应用程序创建 WebAppClassLoader 类加载器
为了 Web 应用程序类之间共享,把 ShareClassLoader 作为 WebAppClassLoader 的父类加载器,如果 WebAppClassLoader 加载器找不到,则尝试用 ShareClassLoader 进行加载
为了 Tomcat 本身与 Web 应用程序类隔离,用 CatalinaClassLoader 类加载器进行隔离,CatalinaClassLoader 加载 Tomcat 本身的类
为了 Tomcat 与 Web 应用程序类共享,用 CommonClassLoader 作为 CatalinaClassLoader 和 ShareClassLoader 的父类加载器
8、线程上下文加载器:由于类加载的规则,很可能导致父加载器加载时依赖子加载器的类,导致无法加载成功(BootStrap ClassLoader 无法加载第三方库的类),所以存在「线程上下文加载器」来进行加载。
9、打破双亲委派机制案例:JDBC
JDBC 的接口的具体的实现类是由各个厂商进行实现的(比如 MySQL),因此在用 JDBC 的时候,需要得到厂商实现的类。这里就使用「线程上下文加载器」,线程上下文加载器直接指定对应的加载器去加载对应厂商的具体的实现类。
10、自定义类加载器加载 java.lang.String,必须修改 jdk 的源码,自己重新编译个 JVM 才行。
文章转载自:seven97_top
评论