12.2 类加载
对于任何尝试优化程序启动或优化新代码在动态系统中的部署(比如向 Java EE 应用服务器中部署一个新应用,或者是在浏览器中加载一个 Applet)的人而言,类加载的性能都让人头疼。
原因是多方面的。最主要的一点是,类数据(也就是 Java 字节码)通常无法快速访问到。它必须从磁盘或者网络上加载过来,必须能在 classpath 下的某个 JAR 文件中找到,还必须能在某个类加载器中找到。对此有一些改进方案,比如,Java WebStart 会将从网络读取的类数据写入一个隐藏目录,这样当下次启动同一应用时,就可以从本地磁盘读取数据,而不再需要从网络读取,速度得以提升。在打包应用时,减少所生成的 JAR 文件数,也能提升类加载的性能。
在复杂环境中,提升速度的明显方式之一就是将类加载并行化。以一个典型的应用服务器为例:在启动时,它可能需要初始化多个应用,其中每个应用都使用了自己的类加载器。假设有多个 CPU 可供大部分应用服务器使用,并行化应该有明显优势。
不过有两个因素会影响其可伸缩性。第一,类数据很可能保存在同一个磁盘上,因此如果有两个类加载器并发运行,它们会向同一设备发出读请求。尽管操作系统擅于处理这种情况:它们可以随着磁盘旋转,分割读操作,并抓取字节数据,但是此时磁盘仍然有很大机会成为瓶颈。
在 Java 7 之前,ClassLoader 类本身的设计一直存在一个比较大的问题。如图 12-1 所示,Java 的类加载器存在于一个层次结构中,这是某个 Java EE 容器中类加载器的理想情况。当运行在第一个 Servlet 应用中的类加载器需要某个类时,请求会流向第一个 Web 应用类加载器(App Classloader),但是这个类加载器会将请求委派给其父类加载器:系统类加载器(System Classloader)。它是与 classpath 关联的类加载器,负责加载 Java EE 相关的类(比如 Java Server Faces,即 JSF 接口)以及这些类在该容器中的实现。系统类加载器也会将一些加载工作委派给其父类加载器,即启动类加载器(Bootstrap Classloader),它负责加载核心 JDK 类。

图 12-1:多个类加载器的理想结构
最后的结果是,当请求加载一个类时,启动类加载器是尝试去找这个类的第一段代码 1;之后是系统类加载器(classpath),再找不到则诉诸应用类加载器。从功能角度看,这是说得通的:java.lang.String 类的字节码必须由启动类加载器加载,而不能是由层次结构中的其他某个类加载器有意或无意加载的别的实现。
1Java 的类加载器使用了双亲委派模型,一个类加载器在收到加载类的请求后,首先会把这个请求委派给父类加载器去完成,每个层次的类加载器都如此处理,一直到启动类加载器;如果启动类加载器无法完成加载请求,才会沿这个路径返回,找到合适的类加载器。——译者注
在 Java 7 之前,类加载器存在的问题是,用于加载类的方法是同步的:在某个时刻,只有一个类加载器可以将任务委派给系统类加载器。这对使用多个类加载器实现并行化有着极大限制,因为每个类加载器都要等待,只有轮到它时,才能访问系统类加载器和启动类加载器。Java 7 利用一组基于类名的锁解决了这种状况。 现在,如果有两个类加载器在寻找同一个类,它们仍然会争用某个锁,但是类层次结构中寻找其他类的类加载器可以并行执行。
如果使用了诸如 URLClassLoader 等 Java 提供的类加载器,也能体会到 Java 7 带来的这一好处。在 Java 6 中,如果其他的类加载器以 URLClassLoader 为父类加载器,它会成为并行操作的同步瓶颈;而在 Java 7 中,类加载器可以并行使用。Java 7 所提供的类加载器是可以并行的(parallel-capable)。
自定义的类加载器默认是不支持并行的。如果希望自己的类加载器也能并行使用,必须采取一些措施。措施总共分为两步。
首先,确保类加载器的层次结构中没有任何回环。回环并不多见。如果存在回环,代码会很难维护,因为在某一时刻,某个类加载器必须直接满足请求,而不能将请求传给其父类加载器(否则委派就成了无限循环)。因此,对于一组存在回环的类加载器而言,尽管在技术上支持并行是可能的,但是过程会非常复杂(要在本已非常复杂的代码上实现)。因为编写高性能 Java 代码的一个规则是采用惯用的方法,以及编写便于编译器优化的简单代码,所以我们不推荐使用存在回环的类加载器层次结构。
第二,在定义加载器类时,在静态初始化部分将其注册为可以并行的:
public class MyCustomClassLoader extends SecureClassLoader {static {registerAsParallelCapable();}....}
这个调用必须放在每个具体的类加载器实现之内。SecureClassLoader 本身是可以并行的,但是其子类并不会自动具备这种能力。如果在我们的代码内还有一个类继承了 MyCustomClassLoader,那个类也必须自己注册为支持并行的。
对于大部分类加载器而言,只需要这两步。在编写类加载器时,建议重写 findClass() 方法。如果自定义的类加载器重写的是 loadClass() 方法,而非 findClass() 方法,则一定要确保在每个类加载器实例内,对于每个类名,defineClass() 方法只调用一次。
在涉及围绕锁的可伸缩性时,和与此有关的所有性能问题一样,该优化的性能净收益还与代码被锁住多久有关。举一个简单的例子,考虑如下代码:
URL url = new File(args[0]).toURL();URLClassLoader ucl = new URLClassLoader(url);for (String className : classNames) {ucl.loadClass(className);}
在命令行中,一个 JAR 文件的名字被作为 args 的第一个元素传入了,这里自定义的类加载器会在这个 JAR 文件中查找。它会遍历由类名组成的数组(在其他地方定义),并从这个 JAR 文件中加载每个类。
其父类加载器就是系统类加载器(查找 classpath)。当有两个或多个线程并发执行这个循环时,因为它们会把类的查找委派给系统类加载器,所以两个线程会彼此等待。表 12-2 列出了当系统的 classpath 为空时该循环的性能。
表12-2:并发加载类的时间(classpath为空)
| 线程数 | 在JDK 7中用的时间(秒) | 在JDK 6中用的时间(秒) |
|---|---|---|
| 1 | 30.353 | 27.696 |
| 2 | 34.811 | 31.409 |
| 4 | 48.106 | 72.208 |
| 8 | 117.34 | 184.45 |
这是有 1500 个类的类名列表循环 100 次所用的时间。这里可以得出几个有趣的结论。首先,JDK 7 中的代码更为复杂(以支持并行加载),因此会在最简单的情况下引入一点点性能损失——这是越简单的代码跑得越快这一原则的一个例证。即使在两个线程的情况下,JDK 7 的新模型还是要稍微慢点,因为代码在父类加载器中几乎没花时间:被锁在父类加载器上所花的时间,远远不及在程序其他地方所花的时间多。
当有 4 个线程时,情况就不一样了。首先,在有 4 个 CPU 的机器上,这 4 个线程会与其他进程(尤其像正在显示用到了 Flash 的页面的浏览器,它会占用一个 CPU 的 40%)争用 CPU 周期。因此即使在 JDK 7 中,伸缩也不是线性的。但至少可伸缩性有了提高:在 JDK 6 中,围绕父类加载器的竞争非常严重。
产生这种竞争有两个原因。首先,争用 CPU 实际上会增加类加载器锁的持有时间;其次,争用锁的线程数也成了原来的 2 倍。
增加系统 classpath 的长度也会极大增加父类加载器锁的持有时间。表 12-3 重复了这个实验,classpath 下有 266 个条目(GlassFish 发行版中的 JAR 文件数)。(GlassFish 不会简单地把这些文件都放到一个类加载器中;之所以选择它,只是方便举例而已。)
表12-3:并发加载类的时间(classpath较长的情况)
| 线程数 | 在JDK 7中用的时间(秒) | 在JDK 6中用的时间(秒) |
|---|---|---|
| 1 | 98.146 | 92.129 |
| 2 | 111.16 | 316.01 |
| 4 | 150.98 | 708.24 |
| 8 | 287.97 | 1461.5 |
现在,即便只有两个线程,竞争也非常严重:没有支持并行的类加载器,加载这些类的时间是原来的 3 倍。如果是在压力已经很大的系统中,可伸缩性就更糟糕了。最后,性能会慢 7 倍。
这里有一个有趣的取舍:是采用更复杂的代码,稍微牺牲一下单线程情况下的性能,还是针对其他情况做优化——特别是像上面例子中的情况,两种选择的性能差距非常大。这种性能取舍时常会遇到,在这种情况下,JDK 团队将第 2 种选择当作了默认情况。作为一个平台,同时提供这两种选择是个不错的主意(即使默认的只能有一个)。因此,在 JDK 7 中,要想获得 JDK 6 的这种行为,可以使用 -XX:+AlwaysLockClassLoader 标志(它默认为 false)开启。如果启动周期较长,而且没有并发的线程会从不同的类加载中加载类,这种情况使用该标志可能稍微有好处。
快速小结
1. 在存在多个类加载器的复杂应用(特别是应用服务器)中,让这些类加载器支持并行,可以解决系统类加载器或者启动类加载器上的瓶颈问题。
2. 如果应用是在单线程内,则通过一个类加载器加载很多类,关掉 Java 7 支持并行的特性可能会有好处。
