【摘要】
前面系统讨论过java类型加载(loading)的问题,在这篇文章中简要分析一下java类型卸载(unloading)的问题,并简要分析一下如何解决如何运行时加载newly compiled version的问题。
【相关规范摘要】
首先看一下,关于java虚拟机规范中时如何阐述类型卸载(unloading)的:
A class or interface may be unloaded if and only if its
class loader is unreachable. The bootstrap class loader is always
reachable; as a result, system classes may never be unloaded.
Java虚拟机规范中关于类型卸载的内容就这么简单两句话,大致意思就是:
只有当加载该类型的类加载器实例(非类加载器类型)为unreachable状态时,当前被加载的类型才被卸载.启动类加载器实例永远为reachable状态,由启动类加载器加载的类型可能永远不会被卸载.
我们再看一下Java语言规范提供的关于类型卸载的更详细的信息(部分摘录):
//摘自JLS 12.7 Unloading of Classes and Interfaces
1、An implementation of the Java programming language may unload classes.
2、Class unloading is an optimization that helps reduce memory use.
Obviously,the semantics of a program should not depend on whether and
how a system chooses to implement an optimization such as class
unloading.
3、Consequently,whether a class or interface has been unloaded or not should be transparent to a program
通过以上我们可以得出结论: 类型卸载(unloading)仅仅是作为一种减少内存使用的性能优化措施存在的,具体和虚拟机实现有关,对开发者来说是透明的.
纵观java语言规范及其相关的API规范,找不到显示类型卸载(unloading)的接口, 换句话说:
1、一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的
2、一个被特定类加载器实例加载的类型运行时可以认为是无法被更新的
类型卸载进一步分析】
前面提到过,如果想卸载某类型,必须保证加载该类型的类加载器处于unreachable状态,现在我们再看看有 关unreachable状态的解释:
1、A reachable object is any object that can be accessed in any potential continuing computation from any live thread.
2、finalizer-reachable: A finalizer-reachable object can be reached
from some finalizable object through some chain of references, but not
from any live thread. An unreachable object cannot be reached by either
means.
某种程度上讲,在一个稍微复杂的java应用中,我们很难准确判断出一个实例是否处于unreachable状态,所 以为了更加准确的逼近这个所谓的unreachable状态,我们下面的测试代码尽量简单一点.
【测试场景一】使用自定义类加载器加载, 然后测试将其设置为unreachable的状态
说明:
1、自定义类加载器(为了简单起见, 这里就假设加载当前工程以外D盘某文件夹的class)
2、假设目前有一个简单自定义类型MyClass对应的字节码存在于D:/classes目录下
public class MyURLClassLoader extends URLClassLoader { public MyURLClassLoader() { super(getMyURLs()); } private static URL[] getMyURLs() { try { return new URL[]{new File ("D:/classes/").toURL()}; } catch (Exception e) { e.printStackTrace(); return null; } } }
public class Main { 2 public static void main(String[] args) { 3 try { 4 MyURLClassLoader classLoader = new MyURLClassLoader(); 5 Class classLoaded = classLoader.loadClass("MyClass"); 6 System.out.println(classLoaded.getName()); 7 8 classLoaded = null; 9 classLoader = null; 10 11 System.out.println("开始GC"); 12 System.gc(); 13 System.out.println("GC完成"); 14 } catch (Exception e) { 15 e.printStackTrace(); 16 } 17 } 18 }
我们增加虚拟机参数-verbose:gc来观察垃圾收集的情况,对应输出如下:
MyClass 开始GC [Full GC[Unloading class MyClass] 207K->131K(1984K), 0.0126452 secs] GC完成
测试场景二】使用系统类加载器加载,但是无法将其设置为unreachable的状态
说明:将场景一中的MyClass类型字节码文件放置到工程的输出目录下,以便系统类加载器可以加载
1 public class Main { 2 public static void main(String[] args) { 3 try { 4 Class classLoaded = ClassLoader.getSystemClassLoader().loadClass( 5 "MyClass"); 6 7 8 System.out.printl(sun.misc.Launcher.getLauncher().getClassLoader()); 9 System.out.println(classLoaded.getClassLoader()); 10 System.out.println(Main.class.getClassLoader()); 11 12 classLoaded = null; 13 14 System.out.println("开始GC"); 15 System.gc(); 16 System.out.println("GC完成"); 17 18 //判断当前系统类加载器是否有被引用(是否是unreachable状态) 19 System.out.println(Main.class.getClassLoader()); 20 } catch (Exception e) { 21 e.printStackTrace(); 22 } 23 } 24 }
我们增加虚拟机参数-verbose:gc来观察垃圾收集的情况, 对应输出如下:
sun.misc.Launcher$AppClassLoader@197d257 sun.misc.Launcher$AppClassLoader@197d257 sun.misc.Launcher$AppClassLoader@197d257 开始GC [Full GC 196K->131K(1984K), 0.0130748 secs] GC完成 sun.misc.Launcher$AppClassLoader@197d257
由于系统ClassLoader实例(AppClassLoader@197d257">sun.misc.Launcher$AppClassLoader@197d257)加载了很多类型,而且又没有明确的接口将其设置为null,
所以我们无法将加载MyClass类型的系统类加载器实例设置为unreachable状态,所以通过测试结果我们可以看出,
MyClass类型并没有被卸载.(说明: 像类加载器实例这种较为特殊的对象一般在很多地方被引用, 会在虚拟机中呆比较长的时间)
【测试场景三】使用扩展类加载器加载, 但是无法将其设置为unreachable的状态
说明:将测试场景二中的MyClass类型字节码文件打包成jar放置到JRE扩展目录下,以便扩展类加载器可以加载的到。
由于标志扩展ClassLoader实例(ExtClassLoader@7259da">sun.misc.Launcher$ExtClassLoader@7259da)加载了很多类型,而且又没有明确的接口将其设置为null,
所以我们无法将加载MyClass类型的系统类加载器实例设置为unreachable状态,所以通过测试结果我们可以看出,MyClass类型并没有被卸载.
1 public class Main { 2 public static void main(String[] args) { 3 try { 4 Class classLoaded = ClassLoader.getSystemClassLoader().getParent() 5 .loadClass("MyClass"); 6 7 System.out.println(classLoaded.getClassLoader()); 8 9 classLoaded = null; 10 11 System.out.println("开始GC"); 12 System.gc(); 13 System.out.println("GC完成"); 14 //判断当前标准扩展类加载器是否有被引用(是否是unreachable状态) 15 System.out.println(Main.class.getClassLoader().getParent()); 16 } catch (Exception e) { 17 e.printStackTrace(); 18 } 19 } 20 }
我们增加虚拟机参数-verbose:gc来观察垃圾收集的情况,对应输出如下:
sun.misc.Launcher$ExtClassLoader@7259da 开始GC [Full GC 199K->133K(1984K), 0.0139811 secs] GC完成 sun.misc.Launcher$ExtClassLoader@7259da
关于启动类加载器我们就不需再做相关的测试了,jvm规范和JLS中已经有明确的说明了.
类型卸载总结】
通过以上的相关测试(虽然测试的场景较为简单)我们可以大致这样概括:
1、有启动类加载器加载的类型在整个运行期间是不可能被卸载的(jvm和jls规范).
2、被系统类加载器和标准扩展类加载器加载的类型在运行期间不太可能被卸载,因为系统类加载器实例或者标准扩展类的实例基本上在整个运行期间总能直接或者间接的访问的到,其达到unreachable的可能性极小.(当然,在虚拟机快退出的时候可以,因为不管ClassLoader实例或者Class(java.lang.Class)实例也都是在堆中存在,同样遵循垃圾收集的规则).
3、被开发者自定义的类加载器实例加载的类型只有在很简单的上下文环境中才能被卸载,而且一般还要借助于强制调用虚拟机的垃圾收集功能才可以做到.可以预想,稍微复杂点的应用场景中(尤其很多时候,用户在开发自定义类加载器实例的时候采用缓存的策略以提高系统性能),被加载的类型在运行期间也是几乎不太可能被卸载的(至少卸载的时间是不确定的).
综合以上三点,我们可以默认前面的结论1, 一个已经加载的类型被卸载的几率很小至少被卸载的时间是不确定的.同时,我们可以看的出来,开发者在开发代码时候,不应该对虚拟机的类型卸载做任何假设的前提下来实现系统中的特定功能.
类型更新进一步分析】
前面已经明确说过,被一个特定类加载器实例加载的特定类型在运行时是无法被更新的.注意这里说的
是一个特定的类加载器实例,而非一个特定的类加载器类型.
【测试场景四】
说明:现在要删除前面已经放在工程输出目录下和扩展目录下的对应的MyClass类型对应的字节码
1 public class Main { 2 public static void main(String[] args) { 3 try { 4 MyURLClassLoader classLoader = new MyURLClassLoader(); 5 Class classLoaded1 = classLoader.loadClass("MyClass"); 6 Class classLoaded2 = classLoader.loadClass("MyClass"); 7 //判断两次加载classloader实例是否相同 8 System.out.println(classLoaded1.getClassLoader() == classLoaded2.getClassLoader()); 9 10 //判断两个Class实例是否相同 11 System.out.println(classLoaded1 == classLoaded2); 12 } catch (Exception e) { 13 e.printStackTrace(); 14 } 15 } 16 }
输出如下:
true
true
通过结果我们可以看出来,两次加载获取到的两个Class类型实例是相同的.那是不是确实是我们的自定义
类加载器真正意义上加载了两次呢(即从获取class字节码到定义class类型…整个过程呢)?
通过对java.lang.ClassLoader的loadClass(String name,boolean resolve)方法进行调试,我们可以看出来,第二
次 加载并不是真正意义上的加载,而是直接返回了上次加载的结果.
说明:为了调试方便, 在Class classLoaded2 =
classLoader.loadClass("MyClass");行设置断点,然后单步跳入,
可以看到第二次加载请求返回的结果直接是上次加载的Class实例. 调试过程中的截图 最好能自己调试一下).
测试场景五】同一个类加载器实例重复加载同一类型
说明:首先要对已有的用户自定义类加载器做一定的修改,要覆盖已有的类加载逻辑, MyURLClassLoader.java类简要修改如下:重新运行测试场景四中的测试代码
1 public class MyURLClassLoader extends URLClassLoader { 2 //省略部分的代码和前面相同,只是新增如下覆盖方法 3 /* 4 * 覆盖默认的加载逻辑,如果是D:/classes/下的类型每次强制重新完整加载 5 * 6 * @see java.lang.ClassLoader#loadClass(java.lang.String) 7 */ 8 @Override 9 public Class<?> loadClass(String name) throws ClassNotFoundException { 10 try { 11 //首先调用系统类加载器加载 12 Class c = ClassLoader.getSystemClassLoader().loadClass(name); 13 return c; 14 } catch (ClassNotFoundException e) { 15 // 如果系统类加载器及其父类加载器加载不上,则调用自身逻辑来加载D:/classes/下的类型 16 return this.findClass(name); 17 } 18 } 19 }
说明: this.findClass(name)会进一步调用父类URLClassLoader中的对应方法,其中涉及到了defineClass(String name)的调用,
所以说现在类加载器MyURLClassLoader会针对D:/classes/目录下的类型进行真正意义上的强制加载并定义对应的类型信息.
测试输出如下:
Exception in thread "main" java.lang.LinkageError: duplicate class definition: MyClass
at java.lang.ClassLoader.defineClass1(Native Method)
at java.lang.ClassLoader.defineClass(ClassLoader.java:620)
at java.security.SecureClassLoader.defineClass(SecureClassLoader.java:124)
at java.net.URLClassLoader.defineClass(URLClassLoader.java:260)
at java.net.URLClassLoader.access$100(URLClassLoader.java:56)
at java.net.URLClassLoader$1.run(URLClassLoader.java:195)
at java.security.AccessController.doPrivileged(Native Method)
at java.net.URLClassLoader.findClass(URLClassLoader.java:188)
at MyURLClassLoader.loadClass(MyURLClassLoader.java:51)
at Main.main(Main.java:27)
结论:如果同一个类加载器实例重复强制加载(含有定义类型defineClass动作)相同类型,会引起java.lang.LinkageError: duplicate class definition.
【测试场景六】同一个加载器类型的不同实例重复加载同一类型
1 public class Main { 2 public static void main(String[] args) { 3 try { 4 MyURLClassLoader classLoader1 = new MyURLClassLoader(); 5 Class classLoaded1 = classLoader1.loadClass("MyClass"); 6 MyURLClassLoader classLoader2 = new MyURLClassLoader(); 7 Class classLoaded2 = classLoader2.loadClass("MyClass"); 8 9 //判断两个Class实例是否相同 10 System.out.println(classLoaded1 == classLoaded2); 11 } catch (Exception e) { 12 e.printStackTrace(); 13 } 14 } 15 }
测试对应的输出如下:
false
【类型更新总结】
由不同类加载器实例重复强制加载(含有定义类型defineClass动作)同一类型不会引起java.lang.LinkageError错误,
但是加载结果对应的Class类型实例是不同的,即实际上是不同的类型(虽然包名+类名相同).
如果强制转化使用,会引起ClassCastException.(说明:
头一段时间那篇文章中解释过,为什么不同类加载器加载同名类型实际得到的结果其实是不同类型,
在JVM中一个类用其全名和一个加载类ClassLoader的实例作为唯一标识,不同类加载器加载的类将被置于不同的命名空间).
应用场景:我们在开发的时候可能会遇到这样的需求,就是要动态加载某指定类型class文件的不同版本,以便能动态更新对应功能.
建议:
1. 不要寄希望于等待指定类型的以前版本被卸载,卸载行为对java开发人员透明的.
2. 比较可靠的做法是,每次创建特定类加载器的新实例来加载指定类型的不同版本,这种使用场景下,一般就要牺牲缓存特定类型的类加载器实例以带来性能优化的策略了.对于指定类型已经被加载的版本,
会在适当时机达到unreachable状态,被unload并垃圾回收.每次使用完类加载器特定实例后(确定不需要再使用时),
将其显示赋为null, 这样可能会比较快的达到jvm 规范中所说的类加载器实例unreachable状态,
增大已经不再使用的类型版本被尽快卸载的机会.
3. 不得不提的是,每次用新的类加载器实例去加载指定类型的指定版本,确实会带来一定的内存消耗,一般类加载器实例会在内存中保留比较长的时间.
在bea开发者网站上找到一篇相关的文章(有专门分析ClassLoader的部分):http://dev2dev.bea.com/pub/a/2005/06/memory_leaks.html
写的过程中参考了jvm规范和jls, 并参考了sun公司官方网站上的一些bug的分析文档。