JVM系列[2]-Java类加载机制

上一篇博文Java类的生命周期概要性地介绍了一个类从“出生”到“凋亡”的各个生命阶段。今天,笔者就跟你们聊聊其中很是重要的一个环节:类的加载过程。Java虚拟机中类加载的过程具体包含加载验证准备解析初始化这5个阶段,各阶段涉及的细节较多,在上一篇博文中都有简短介绍,本文将主要介绍加载阶段,其中包括加载方式、加载时机、类加载器原理、实例分析等内容。java

前言

在具体介绍类加载机制以前,咱们先来看下网上关于理解类加载机制的经典题目:spring

public class Singleton {
    private static Singleton singleton = new Singleton();
    public static int counter1;
    public static int counter2 = 0;
    
    private Singleton() {
        counter1++;
        counter2++;
    }
    
    public static Singleton getSingleton() {
        return singleton;
    }
}
// 打印静态属性的值
public class TestSingleton{
    public static void main(String[] args) {
        Singleton singleton = Singleton.getSingleton();
        System.out.println("counter1=" + singleton.counter1);
        System.out.println("counter2=" + singleton.counter2);
    }
}
// 输出结果:
>>> counter1=1
>>> counter2=0

关于为何counter2=0,这里就不具体解释了,只是想说下它考核了那几个点:编程

  1. 类加载过程的5个阶段前后顺序:准备阶段在初始化以前
  2. 准备阶段和初始化阶段各自作的事情
  3. 静态初始化的细节:前后顺序

什么是类的加载

言归正传,咱们先从类加载的定义提及,一句话概述,缓存

虚拟机将class文件中的二进制数据流加载到JVM运行时数据区的方法区内,并进行验证准备解析初始化等动做后,在内存中建立java.lang.class对象,做为对方法区中该类数据结构的访问入口。tomcat

这里有几点要解释下,class文件是指符合class文件格式的二进制数据流,也就是咱们常说的字节码文件,它是咱们与JVM约定的格式协议,只要是符合class文件格式的二进制流,均可被JVM加载,这也是JVM跨平台的基础;另外,java.lang.class对象只是说在内存建立,并无明确规定是否在Java堆中,对于Hotspot虚拟机,是存放在方法区的。安全

加载方式

类的加载方式分为两种:隐式加载和显式加载。服务器

隐式加载

实际就是不用咱们代码主动声明,而是JVM在适当的时机自动加载类。好比主动引用某个类时,会自动触发类加载和初始化阶段。数据结构

显式加载

则一般是指经过代码的方式显式加载指定类,常见如下几种:架构

经过Class.forName()加载指定类。对于forName(String className)方法,默认会执行静态初始化,但若是使用另外一个重载函数forName(String name, boolean initialize, ClassLoader loader),其实是能够经过initialize来控制是否执行静态初始化并发

经过ClassLoader.loadClass()加载指定类,这种方式仅仅是将.class加载到JVM,并不会执行静态初始化块,这个等后面谈到类加载器的职责时会再强调这一点

关于Class.forName()是否执行静态初始化,经过源码就能一目了然:

public static Class<?> forName(String className)	// 执行初始化,由于initialize为true
            throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}
...
public static Class<?> forName(String name, boolean initialize,
                               ClassLoader loader)  // 可控的,经过initialize来指定初始化与否
    throws ClassNotFoundException
{
    ...
    return forName0(name, initialize, loader, caller);
}

加载时机

类加载的第一个阶段——加载阶段具体何时开始,虚拟机规范并未指明,由具体的虚拟机实现决定,可分为预加载和运行时加载两种时机:

  1. 预加载:对于JDK中的经常使用基础库——JAVA_HOME/lib下的rt.jar,它包含了咱们最经常使用的class,如java.lang.*java.util.*等,在虚拟机启动时会提早加载,这样用到时就省去了加载耗时,能加快访问速度。
  2. 运行时加载:大多数类好比用户代码,都是在类第一次被使用时才加载的,也就是常说的惰性加载,这么作的比较直观的缘由大概是节省内存吧。

加载原理

loadClass源码

先上代码(JDK1.7源码):

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            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
                }

                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
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

loadClass是类加载机制中最核心的代码,这段代码基本阐述了如下最核心的两点:

缓存机制

findLoadedClass(name),首先第一步就检查这个name表示的类是否已被某个类加载器实例加载过,若已加载,则直接返回已加载的c,不然才继续下面的委派等逻辑。即JVM层会对已加载的类进行缓存,那具体是怎么缓存的的呢?在JVM实现中,有个相似于HashTable的数据结构叫作SystemDictionary,对已加载的类信息进行缓存,缓存的key是类加载器实例+类的全限定名,value则是指向表示类信息的klass数据结构。这也是为何咱们常说,JVM中加载类的类加载器实例加上类的全限定名,这二者一块儿才能惟一肯定一个类。每一个类加载器实例就至关因而一个独立的类命名空间,对于两个不一样类加载器实例加载的类,即使名称相同,也是两个彻底不一样的类。

双亲委派

对于新加载的类,缓存没命中后走双亲委派逻辑——当parent存在时,会先委派给parent进行loadClass,而后parent.loadClass内部又会进行一样的向上委派,直至parentnull,委派给根加载器。也就是说委派请求会一直向上传递,直到顶层的引导类加载器,而后再统一用ClassNotFoundException异常的方式逐层向下回传,直到某一层classLoader在其搜索范围内找到并加载该类;当parent不存在时,即没有父类加载器,此时直接委派给顶层加载器——BootstrapClassLoader

从这里能够看到双亲委派结构中,类加载器之间的父子层级关系并非经过继承来实现,而是经过组合的方式即子类加载器持有parent代理以指向父类加载器来实现的。

要点

  1. 因为委派是单向的,处于子类加载器层级的类,能够访问父类加载器层级的类,反过来不行
  2. 各执其责,各层级的类加载器只负责加载本层级下的类。实现方式:各层级类加载器有本身的加载路径,路径隔离,互不可见。URLClassLoaderucp属性了解下~
  3. ClassNotFoundException——父子类加载器之间的协议。只有当父类加载器抛出此异常时,加载请求才会向下层传递,其余异常不认!
  4. 上下层的这种优先级进一步保证了Java程序的稳定性,对于JDK库中核心的类不会由于用户误定义的同名类而致使被覆盖。

类加载器

仍是给个定义吧:

经过一个类的全限定名来获取描述此类的二进制字节流的代码模块

经典的三层加载器结构:

three_level_classloaders

一、启动类加载器(或称为引导类加载器):只负责加载<JAVA_HOME>/lib目录中的,或是启动参数-Xbootclasspath所指定路径中的特定名称类库。该加载器由C++实现,对Java程序不可见,对于自定义加载器,如果未指定parent,则会委派该加载器进行加载。

二、扩展类加载器:负责加载<JAVA_HOME>/lib/ext目录中的,或是java.ext.dirs系统变量所指定的路径下全部类库。该加载器由sum.misc.Launcher$ExtClassLoader实现,可直接使用。

三、应用程序类加载器(或称为系统类加载器):负责加载用户类路径ClassPath中全部类库。该加载器由sum.misc.Launcher$AppClassLoader实现,可由ClassLoader.getSystemClassLoader()方法得到。

要点

ExtClassLoaderAppClassLoader都是继承自URLClassLoader,各自负责的加载路径都是保存在ucp属性中,这个看源码就能得知。

三次“破坏”

双亲委派并非一个强制性约束模型,毕竟它自身也有局限性。不管是历史代码层面、SPI设计问题、仍是新的热部署需求,都不可避免地会违背该原则,累计有三次“破坏”。

可覆盖的loadClass方法

经过ClassLoader的源码可知,双亲委派的实现细节都在loadClass方法中,而该方法是一个protected的,意味着子类能够覆盖该方法,从而可绕过双亲委派逻辑。双亲委派模型是在JDK1.2以后才被引入,在此以前的JDK1.0,已有部分用户经过继承ClassLoader重写了loadClass逻辑,这使得后面引入的双亲委派逻辑在这些用户程序中不起做用。

为了向前兼容,ClassLoader新增了findClass方法,提倡用户将本身的类加载逻辑放入findClass中,而不要再去覆盖loadClass方法。

自身缺陷,没法支持SPI

双亲委派的层次优先级就决定了用户代码和JDK基础类之间的不对等性,即只能用户代码调用基础类,反之不行。对于SPI之类的设计,好比已经成为Java标准服务的JNDI,其接口代码是在基础类中,而具体的实现代码则是在用户Classpath下,在双亲委派的限制下,JNDI没法调用实现层代码。

开个后门——引入线程上下文类加载器(Thread Context ClassLoader),该加载器可经过java.lang.Thread.setContextClassLoader()进行设置,若建立线程时未设置,则从父线程继承;若应用程序的全局范围都未设置过,则默认设置为应用程序类加载器,这个可在Launcher的源码中找到答案。

有了这个,JNDI服务就可以使用该加载器去加载所需的SPI代码。其余相似的SPI设计也是这种方式,如JDBC、JCE、JAXB、JBI等。

程序动态性的需求,即热部署

模块化热部署,在生产环境中显得尤其有吸引力,就像咱们的计算机外设同样,不用重启,可随时更换鼠标、U盘等。 OSGi已经成为业界事实上的Java模块化标准,此时类加载器再也不是双亲委派中的树状层次,而是复杂的网状结构。

类加载器实例分析

Tomcat——双亲委派的最佳实践者

一般Web服务器须要解决几个基本问题:

  1. 同一个服务器上,部署两个及以上的Web应用程序,各自使用的Java类库能够相互隔离。
  2. 多个Web应用程序,共享所使用的部分Java类库。好比都用到了一样版本的spring,共享一份,不管是本地磁盘,仍是Web服务器内存(主要是方法区),都是不错的节省。
  3. 保证服务器自身安全不受部署的Web应用程序影响,这跟前面谈到的双亲委派保证Java程序稳定性是一个道理。
  4. 支持JSP的话,须要支持HotSwap功能。

为了应对以上基本问题,主流的Java Web服务器都会提供多个Classpath存放类库。对于Tomcat,其目录结构划分为如下4组:

  1. /common目录,存放的类库被Tomcat和全部的Web应用程序共享。
  2. /server目录,仅被Tomcat使用,其余Web应用程序不可见。
  3. /shared目录,可被全部Web应用程序共享,但对Tomcat不可见。
  4. /WebApp/WEB-INF目录,仅被所属的Web应用程序使用,对Tomcat和其余Web应用程序不可见。

跟以上目录对应的,是Tomcat经典的双亲委派类加载器架构:

tomcat_classloaders

上图中,CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebAppClassLoader分别负责/common/*/server/*/shared/*/WebApp/WEB-INF/*目录下的Java类库加载,其中WebApp类加载器和Jsp类加载器一般会存在多个实例,每个Web应用程序对应一个WebAppClassLoader,每个JSP文件对应一个Jsp类加载器。

OSGi——勇于突破

OSGi(Open Service Gateway Initiative)是OSGi联盟制定的一个基于Java语言的动态模块化规范,其最著名的应用案例就是Eclipse IDE,它是Eclipse强大插件体系的基础。

OSGi中的每一个模块称为Bundle,一个Bundle能够声明它所依赖的Java Package(经过Import-Package描述),也能够声明它容许导出发布的Java Package(经过Export-Package描述)。Bundle之间的依赖关系为平级依赖,Bundle类加载器之间只有规则,没有固定的委派关系。假设存在BundleA、BundleB和BundleC,

BundleA:声明发布了packageA,依赖了java.*的包 BundleB:声明依赖了packageA和packageC,同时也依赖了java.*的包 BundleC:声明发布了packageC,依赖了packageA

一个简单的OSGi类加载器架构示例以下:

osgi_classloaders

上图的这种网状架构带来了更好的灵活性,但同时也可能产生许多新的隐患。好比Bundle之间的循环依赖,在高并发场景下致使加载死锁。

总结

本文以一个关于类加载的编程题为切入点,阐述了类加载阶段的具体细节,包括加载方式、加载时机、加载原理,以及双亲委派的优劣点。并以具体的类加载器实例Tomcat和OSGi为例,简单分析了类加载器在实践过程当中的多种选择。

同步更新到原文

相关文章
相关标签/搜索