专栏首页Throwable's Blog深入分析Java反射(五)-类实例化和类加载

深入分析Java反射(五)-类实例化和类加载

前提

其实在前面写过的《深入分析Java反射(一)-核心类库和方法》已经介绍过通过类名或者java.lang.Class实例去实例化一个对象,在《浅析Java中的资源加载》中也比较详细地介绍过类加载过程中的双亲委派模型,这篇文章主要是加深一些对类实例化和类加载的认识。

类实例化

在反射类库中,用于实例化对象只有两个方法:

  • T java.lang.Class#newInstance():这个方法只需要提供java.lang.Class<T>的实例就可以实例化对象,如果提供的是无限定类型Class<?>则得到的是Object类型的返回值,可以进行强转。这个方法不支持任何入参,底层实际上也是依赖无参数的构造器Constructor进行实例化。
  • T java.lang.reflect.Constructor#newInstance(Object ... initargs):这个方法需要提供java.lang.reflect.Constructor<T>实例和一个可变参数数组进行对象的实例化,上面提到的T java.lang.Class#newInstance()底层也是依赖此方法。这个方法除了可以传入构造参数之外,还有一个好处就是可以通过``抑制修饰符访问权限检查,也就是私有的构造器也可以用于实例化对象。

在编写反射类库的时候,优先选择T java.lang.reflect.Constructor#newInstance(Object ... initargs)进行对象实例化,目前参考很多优秀的框架(例如Spring)都是用这个方法进行对象实例化。

类加载

类加载实际上由类加载器(ClassLoader)完成,protected Class<?> java.lang.ClassLoader#loadClass(String name, boolean resolve)方法提现了类加载过程中遵循了双亲委派模型,实际上,我们可以覆写此方法完全不遵循双亲委派模型,实现同一个类(这里指的是全类名完全相同)重新加载。JDK中提供类加载相关的特性有两个方法:

  • protected Class<?> java.lang.ClassLoader#loadClass(String name, boolean resolve):通过类加载器实例去加载类,一般应用类路径下的类是由jdk.internal.loader.ClassLoaders$AppClassLoader加载,也可以自行继承java.lang.ClassLoader实现自己的类加载器。
  • public static Class<?> forName(String name, boolean initialize, ClassLoader loader):通过全类名进行类加载,可以通过参数控制类初始化行为。

ClassLoader中的类加载

类加载过程其实是一个很复杂的过程,主要包括下面的步骤:

  • 1、加载过程:使用(自定义)类加载器去获取类文件字节码字节类的过程,Class实例在这一步生成,作为方法区的各种数据类型的访问入口。
  • 2、验证过程:JVM验证字节码的合法性。
  • 3、准备过程:为类变量分配内存并且设置初始值。
  • 4、解析过程:JVM把常量池中的符号替换为直接引用。
  • 5、初始化过程:执行类构造器<cinit>()方法,<cinit>()方法是编译器自动收集所有类变量的赋值动作和静态代码块中的语句合并生成,收集顺序由语句在源文件中出现的顺序决定,JVM保证在子类<cinit>()方法调用前父类的<cinit>()方法已经执行完毕。

ClassLoader#loadClass()方法就是用于控制类加载过程的第一步-加载过程,也就是控制字节码字节数组和类名生成Class实例的过程。ClassLoader中还有一个protected final Class<?> defineClass(String name, byte[] b, int off, int len)方法用于指定全类名和字节码字节数组去定义一个类,我们再次看下loadClass()的源码:

    protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException{
        synchronized (getClassLoadingLock(name)) {
            // 检查类是否已经加载过,如果已经加载过,则直接返回
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                // 委派父类加载器去加载类
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }
                // 委派父类加载器如果加载失败则调用findClass方法进行加载动作
                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }
    // 扩展点-1
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
    } 
    // 扩展点-2
    protected final void resolveClass(Class<?> c) {
        if (c == null) {
            throw new NullPointerException();
        }
    }       

实际上,loadClass()方法留下了两个扩展点用于改变类加载的行为,而findClass()方法就是用于扩展父类加载器加载失败的情况下,子类加载器的行为。当然,实际上Class<?> loadClass(String name, boolean resolve)方法是非final的方法,可以整个方法覆写掉,这样子就有办法完全打破双亲委派机制。但是注意一点,即使打破双亲委派机制,子类加载器也不可能重新加载一些由Bootstrap类加载器加载的类库如java.lang.String,这些是由JVM验证和保证的。自定义类加载器的使用在下一节的"类重新加载"中详细展开。

最后还有两点十分重要:

  • 1、对于任意一个类,都需要由加载它的类加载器和这个类本身一起确立其在Java虚拟机中的唯一性,也就是一个类在JVM中的签名是加载它的类加载器和它本身,对于每一个类加载器,都拥有一个独立的类命名空间
  • 2、比较两个类是否"相等",只有这两个类是由同一个类加载器加载的前提下才有意义。即使这两个类的全类名一致、来源于同一个字节码文件、被同一个Java虚拟机加载,但是加载它们的类加载器不同,那么它们必定不相等。这里相等的范畴包括:Class对象的equals()方法、isAssignableForm()方法、isInstance()方法的返回结果以及使用instanceof关键字做对象所属关系时候的判定等情况。

Class中的类加载

java.lang.Class中的类加载主要由public static Class<?> forName(String name, boolean initialize, ClassLoader loader)方法完成,该方法可以指定全类名、是否初始化和类加载器实例。源码如下:

    @CallerSensitive
    public static Class<?> forName(String name, boolean initialize,
                                   ClassLoader loader)
        throws ClassNotFoundException
    {
        Class<?> caller = null;
        SecurityManager sm = System.getSecurityManager();
        if (sm != null) {
            // Reflective call to get caller class is only needed if a security manager
            // is present.  Avoid the overhead of making this call otherwise.
            caller = Reflection.getCallerClass();
            if (loader == null) {
                ClassLoader ccl = ClassLoader.getClassLoader(caller);
                if (ccl != null) {
                    sm.checkPermission(
                        SecurityConstants.GET_CLASSLOADER_PERMISSION);
                }
            }
        }
        return forName0(name, initialize, loader, caller);
    }

    private static native Class<?> forName0(String name, boolean initialize,
                                            ClassLoader loader,
                                            Class<?> caller) throws ClassNotFoundException;

它最终调用的是JVM的本地接口方法,由于暂时没有能力分析JVM的源码,只能通过forName方法的注释理解方法的功能:

返回给定字符串全限定名称、指定类加载器的类或者接口的Class实例,此方法会尝试对类或者接口进行locate、load and link操作,如果loader参数为null,则使用bootstrap类加载器进行加载,如果initialize参数为true同时类或者接口在早期没有被初始化,则会进行初始化操作。

也就是说initialize参数对于已经初始化过的类或者接口来说是没有意义的。这个方法的特性还可以参考Java语言规范的12章中的内容,这里不做展开。

虽然暂时没法分析JVM本地接口方法native Class<?> forName0()的功能,但是它依赖一个类加载器实例入参,可以大胆猜测它也是依赖于类加载器的loadClass()进行类加载的。

类重新加载

先提出一个实验,如果定义一个类如下:

public class Sample {

	public void say() {
		System.out.println("Hello Doge!");
	}
}

如果使用字节码工具修改say()方法的内容为System.out.println("Hello Throwable!");,并且使用自定义的ClassLoader重新加载一个同类名的Sample类,那么通过new关键字实例化出来的Sample对象调用say()到底打印"Hello Doge!“还是"Hello Throwable!”?

先引入字节码工具javassist用于修改类的字节码:

<dependency>
    <groupId>org.javassist</groupId>
    <artifactId>javassist</artifactId>
    <version>3.24.0-GA</version>
</dependency>

下面是测试代码:

// 例子
public class Demo {

	public void say() {
		System.out.println("Hello Doge!");
	}
}

// 一次性使用的自定义类加载器
public class CustomClassLoader extends ClassLoader {

	private final byte[] data;

	public CustomClassLoader(byte[] data) {
		this.data = data;
	}

	@Override
	public Class<?> loadClass(String name) throws ClassNotFoundException {
		if (!Demo.class.getName().equals(name)) {
			return super.loadClass(name);
		}
		return defineClass(name, data, 0, data.length);
	}
}

public class Main {

	public static void main(String[] args) throws Exception {

		String name = Demo.class.getName();
		CtClass ctClass = ClassPool.getDefault().getCtClass(name);
		CtMethod method = ctClass.getMethod("say", "()V");
		method.setBody("{System.out.println(\"Hello Throwable!\");}");
		byte[] bytes = ctClass.toBytecode();
		CustomClassLoader classLoader = new CustomClassLoader(bytes);
		// 新的Demo类,只能反射调用,因为类路径中的Demo类已经被应用类加载器加载
		Class<?> newDemoClass = classLoader.loadClass(name);
        // 类路径中的Demo类
		Demo demo = new Demo();
		demo.say();
		// 新的Demo类
		newDemoClass.getDeclaredMethod("say").invoke(newDemoClass.newInstance());
		// 比较
		System.out.println(newDemoClass.equals(Demo.class));
	}
}

执行后输出:

Hello Doge!
Hello Throwable!
false

这里得出的结论是:

  • new关键字只能使用在当前类路径下的类的实例化,而这些类都是由应用类加载器加载,如果上面的例子中newDemoClass.newInstance()强制转换为Demo类型会报错。
  • 通过自定义类加载器加载的和当前类路径相同名全类名的类只能通过反射去使用,而且即使全类名相同,由于类加载器隔离,它们其实是不相同的类。

如何避免类重新加载导致内存溢出

实际上,JDK没有提供方法去卸载一个已经加载的类,也就是类的生命周期是由JVM管理的,因此要解决类重新加载导致内存溢出的问题归根结底就是解决重新加载的类被回收的问题。由于创建出来是的java.lang.Class对象,如果需要回收它,则要考虑下面几点:

  • 1、java.lang.Class对象反射创建的实例需要被回收。
  • 2、java.lang.Class对象不能被任何地方强引用。
  • 3、加载java.lang.Class对象的ClassLoder已经被回收。

基于这几点考虑可以做个试验验证一下:

public class Demo {
    // 这里故意建立一个数组占用大量内存
    private int[] array = new int[1000];

    public void say() {
        System.out.println("Hello Doge!");
    }
}

public class Main {

	private static final Map<ClassLoader, List<Class<?>>> CACHE = new HashMap<>();

	public static void main(String[] args) throws Exception {
		String name = Demo.class.getName();
		CtClass ctClass = ClassPool.getDefault().getCtClass(name);
		CtMethod method = ctClass.getMethod("say", "()V");
		method.setBody("{System.out.println(\"Hello Throwable!\");}");
		for (int i = 0; i < 100000; i++) {
			byte[] bytes = ctClass.toBytecode();
			CustomClassLoader classLoader = new CustomClassLoader(bytes);
			// 新的Demo类,只能反射调用,因为类路径中的Demo类已经被应用类加载器加载
			Class<?> newDemoClass = classLoader.loadClass(name);
			add(classLoader, newDemoClass);
		}
		// 清理类加载器和它加载过的类
		clear();
		System.gc();
		Thread.sleep(Integer.MAX_VALUE);
	}

	private static void add(ClassLoader classLoader, Class<?> clazz) {
		if (CACHE.containsKey(classLoader)) {
			CACHE.get(classLoader).add(clazz);
		} else {
			List<Class<?>> classes = new ArrayList<>();
			CACHE.put(classLoader, classes);
			classes.add(clazz);
		}
	}

	private static void clear() {
		CACHE.clear();
	}
}

使用VM参数-XX:+PrintGC -XX:+PrintGCDetails执行上面的方法,JDK11默认使用G1收集器,由于Z收集器还在实验阶段,不是很建议使用,执行main方法后输出:

[11.374s][info   ][gc,task       ] GC(17) Using 8 workers of 8 for full compaction
[11.374s][info   ][gc,start      ] GC(17) Pause Full (System.gc())
[11.374s][info   ][gc,phases,start] GC(17) Phase 1: Mark live objects
[11.429s][info   ][gc,stringtable ] GC(17) Cleaned string and symbol table, strings: 5637 processed, 0 removed, symbols: 135915 processed, 0 removed
[11.429s][info   ][gc,phases      ] GC(17) Phase 1: Mark live objects 54.378ms
[11.429s][info   ][gc,phases,start] GC(17) Phase 2: Prepare for compaction
[11.429s][info   ][gc,phases      ] GC(17) Phase 2: Prepare for compaction 0.422ms
[11.429s][info   ][gc,phases,start] GC(17) Phase 3: Adjust pointers
[11.430s][info   ][gc,phases      ] GC(17) Phase 3: Adjust pointers 0.598ms
[11.430s][info   ][gc,phases,start] GC(17) Phase 4: Compact heap
[11.430s][info   ][gc,phases      ] GC(17) Phase 4: Compact heap 0.362ms
[11.648s][info   ][gc,heap        ] GC(17) Eden regions: 44->0(9)
[11.648s][info   ][gc,heap        ] GC(17) Survivor regions: 12->0(12)
[11.648s][info   ][gc,heap        ] GC(17) Old regions: 146->7
[11.648s][info   ][gc,heap        ] GC(17) Humongous regions: 3->2
[11.648s][info   ][gc,metaspace   ] GC(17) Metaspace: 141897K->9084K(1062912K)
[11.648s][info   ][gc             ] GC(17) Pause Full (System.gc()) 205M->3M(30M) 273.440ms
[11.648s][info   ][gc,cpu         ] GC(17) User=0.31s Sys=0.08s Real=0.27s

可见FullGC之后,元空间(Metaspace)回收了(141897-9084)KB,一共回收了202M的内存空间,初步可以认为元空间的内存被回收了,接下来注释掉main方法中调用的clear()方法,再调用一次main方法:

....
[4.083s][info   ][gc,heap        ] GC(17) Humongous regions: 3->2
[4.083s][info   ][gc,metaspace   ] GC(17) Metaspace: 141884K->141884K(1458176K)
[4.083s][info   ][gc             ] GC(17) Pause Full (System.gc()) 201M->166M(564M) 115.504ms
[4.083s][info   ][gc,cpu         ] GC(17) User=0.84s Sys=0.00s Real=0.12s

可见元空间在FullGC执行没有进行回收,而堆内存的回收率也比较低,由此可以得出一个经验性的结论:只需要通过ClassLoader对象做映射关系保存使用它加载出来的新的类,只需要确保这些类没有没强引用、类实例都已经销毁,那么只需要移除ClassLoader对象的引用,那么在JVM进行GC的时候会把ClassLoader对象以及使用它加载的类回收,这样做就可以避免元空间的内存泄漏。

小结

通过一些资料和实验,深化了类加载过程的一些认识。

参考资料:

  • 《深入理解Java虚拟机-第二版》
  • JDK11部分源码

(本文完 e-2018129 c-2-d r-20181212)

本文是Throwable的原创文章,转载请提前告知作者并且标明出处。 博客内容遵循 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 协议 本文永久链接是:https://www.throwable.club/2018/12/09/java-reflection-class-load/

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 通过源码浅析Java中的资源加载

    最近在做一个基础组件项目刚好需要用到JDK中的资源加载,这里说到的资源包括类文件和其他静态资源,刚好需要重新补充一下类加载器和资源加载的相关知识,整理成一篇文章...

    Throwable
  • 线程上下文类加载器ContextClassLoader内存泄漏隐患

    今天(2020-01-18)在编写Netty相关代码的时候,从Netty源码中的ThreadDeathWatcher和GlobalEventExecutor追溯...

    Throwable
  • 深入理解JDK中的Reference原理和源码实现

    这篇文章主要基于JDK11的源码和最近翻看的《深入理解Java虚拟机-2nd》一书的部分内容,对JDK11中的Reference(引用)做一些总结。值得注意的是...

    Throwable
  • 图片预加载和懒加载

    对于前端性能来说,图片是一个过不去的坎,又想能页面美观,又想页面响应速度快,那么这时候就有了两个技术,图片懒加载和预加载。在这边我只介绍一些方法和原理,不具体把...

    wade
  • Springboot Application 集成 OSGI 框架开发

    是 Java 类加载层次中最顶层的类加载器,负责加载 JDK 中的核心类库,如:rt.jar、resources.jar、charsets.jar 等

    一个会写诗的程序员
  • 浅入Java ClassLoader

    ClassLoader是用来加载Class 文件的。它负责将 Class 的字节码形式转换成内存形式的 Class 对象。字节码可以来自于磁盘文件 .class...

    日薪月亿
  • 《前端那些事》聊聊前端的按需加载

    前沿:按需加载是性能优化其中的一个环节,可以是图片的按需加载,也就是lazyload来实现按需加载的场景,也可以是组件库的引入,只需部分组件的使用而无需全局引入...

    树酱
  • JVM 类加载机制详解

    jvm将class文读取到内存中,经过对class文件的校验、转换解析、初始化最终在jvm的heap和方法区分配内存形成可以被jvm直接使用的类型的过程。

    用户1637228
  • 如何加载一张超大高清图

    "大图片加载容易做,可是这个需求要保证在不OOM的情况下能放大查看,还要能清晰展示,这得怎么呢?",愁眉苦脸的小呼说到。

    PhoenixZheng
  • Java 类机制(2)---- 类加载过程

    大家好,在该专栏的上一篇文章中我们介绍了一下关于 Java 中类的相关知识点。那么这篇文章我们来看一下一个 Java 类是怎么被虚拟机加载并使用的,本文内容参考...

    指点

扫码关注云+社区

领取腾讯云代金券