java class 类加载过程,为什么要双亲委派机制(下)

本文详细介绍了Tomcat的类加载机制,特别是如何打破双亲委派模型以实现Web应用程序的隔离。通过自定义类加载器,展示了如何加载相同包名的不同类,并分析了Tomcat的Catalina、Shared和WebappClassLoader如何协同工作。此外,还讨论了热部署的概念以及Tomcat如何处理类的更新。最后,探讨了Tomcat为何不遵循双亲委派机制的原因,确保各Web应用的类库独立且安全。
摘要由CSDN通过智能技术生成

前提知识:java class 类加载过程,为什么要双亲委派机制(上)

如果相同的class文件要如何加载,违背双亲委派机制?

相同包名的两个类在同一个加载器如何加载?按照优先级原则只能加载自己项目的,第三包加载不到

情况如下: 有一个com.alibaba.druid.filter.config.CongfigTools同包名两个类,怎么同时加载。只能分开两种不同的加载器。
在这里插入图片描述
在这里插入图片描述

//继承ClassLoader的自定义加载器的父类加载器是AppClassLoader,记得修改
protected ClassLoader() {
    this(checkCreateClassLoader(), getSystemClassLoader());
}

//自定义加载器
public class DefineClassLoader extends ClassLoader {
    //.class文件详细地址,相当于自己的加载器也有jar管理地址
    private String dst;

    public void setDst(String dst) {
        this.dst = dst;
    }

    public DefineClassLoader(String dst) {
        //要设置父加载器是ExtClassLoader,不然默认的父加载器是appClassLoader,由于双亲委派机制待会还是失效的,
        //因为AppClassLoader有那个包
        super(Launcher.getLauncher().getClassLoader().getParent());
        this.dst = dst;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        Class clazz = null;
        //获取class文件的字节流
        byte[] bytes = getClassData(dst);
        if (bytes != null) {
            //name是类名字 com.alibaba.druid.filter.config.CongfigTools
            clazz = defineClass(name, bytes, 0, bytes.length);
        }
        return clazz;
    }

    public byte[] getClassData(String dst) {
        File file = new File(dst);
        if (file.exists()) {
            FileInputStream in = null;
            ByteArrayOutputStream out = null;
            try {
                in = new FileInputStream(file);
                out = new ByteArrayOutputStream();
                byte[] bytes = new byte[1024];
                int size = 0;
                while ((size = in.read(bytes)) != -1) {
                    out.write(bytes, 0, size);
                }
            } catch (Exception e) {
                e.printStackTrace();
            } finally {
                try {
                    in.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
            return out.toByteArray();
        }
        return null;
    }
}

自定义类加载器其他同包名同类名的类

public class ConfigTools {
    private Integer a = Integer.parseInt("1");

    public Integer getA() {
        return a;
    }

    public void setA(Integer a) {
        this.a = a;
    }

    public static void main(String[] args) {
        System.out.println("aaaa");
    }
}


public static void main(String[] args) throws Exception{
    ConfigTools filter = new ConfigTools();
    System.out.println(filter.getA());
    DefineClassLoader defineClassLoader = new DefineClassLoader("/Users/hilbert/.m2/repository/com/alibaba/druid/1.1.22/druid-1.1.22/com/alibaba/druid/filter/config/ConfigTools.class");
    Class<?> aClass = defineClassLoader.loadClass("com.alibaba.druid.filter.config.ConfigTools");
    ConfigTools instance = (ConfigTools)aClass.newInstance();
    System.out.println(JSONObject.toJSONString(instance));
}

运行结果:转化类型失败,说明成功
在这里插入图片描述

遇到的坑:

  1. 重写loadClass 方法和findClass方法
@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
    return findClass(name);
}

@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
    Class clazz = null;
    byte[] bytes = getClassData(dst);
    if (bytes != null) {
        clazz = defineClass(name, bytes, 0, bytes.length);
    }
    return clazz;
}

重写loadClass 破坏了双亲委派机制,导致了自定义的类加载器去加载java.lang.object,但被Java安全机制禁止了所以会报错。defineClass调用preDefineClass,preDefineClass 会检查包名,如果以java开头,就会抛出异常,因为让用户自定义的类加载器来加载Java自带的类库会引起混乱。

private ProtectionDomain preDefineClass(String name, ProtectionDomain pd) {
    if (!checkName(name))
        throw new NoClassDefFoundError("IllegalName: " + name);

    if ((name != null) && name.startsWith("java.")) {
        throw new SecurityException
            ("Prohibited package name: " + name.substring(0, name.lastIndexOf('.')));
    }
    if (pd == null) {
        pd = defaultDomain;
    }

    if (name != null) checkCerts(name, pd.getCodeSource());

    return pd;
}

在这里插入图片描述

热部署的实现(拓展知识点)

热部署就是在不重启应用的情况下,当类的定义即字节码文件修改后,能够替换该Class创建的对象。 一般情况下,类的加载都是由系统自带的类加载器完成,且对于同一个全限定名的java类,只能被加载一次,而且无法被卸载。

  • 一个监听器:监听class文件或者jar 包的变化
  • 利用自定义的 ClassLoader 替换系统的加载器,创建一个新的 ClassLoader
  • ClassLoader加载Class文件,得到的 Class 对象就是新的(因为不是同一个类加载器),再用该 Class
    对象创建一个实例,从而实现动态更新。

如:修改 JSP 文件即生效,就是利用自定义的 ClassLoader 实现的。

Tomcat 违背双亲委派机制?

双亲委派机制的核心是什么:ClassLoader 的 loadClass 方法是加载Class 类的核心
破坏双亲委派机制:重写ClassLoader的loadClass 方法(就是不按照那个链路顺序,不往上找就是了)

Tomcat 的类加载器分两部分:

  • 遵循双亲委派机制的类加载器:CatalinaLoader SharedLoader CommonLoader
  • 违背双亲委派机制的类加载器:WebappClassLoader JasperLoader

在这里插入图片描述

Common 类加载器: 负责加载 ${catalina.home}/lib, ${catalina.base}/lib目录的类库,这儿存放的类库可被 tomcat以及所有的应用使用。(Tomcat本身的依赖和Web应用还需要共享,那么还有类加载器(CommonClassLoader)来装载进而达到共享)

Catalina 类加载器:负责加载 /server 目录的类库,只能被 tomcat使用(为了隔绝Web应用程序与Tomcat本身的类,用来装载Tomcat本身的依赖)
Shared 类加载器: 负载加载 /shared 目录的类库,可被所有的 web 应用使用,但 tomcat 不可使用。

这三个都是URLClassLoader,CommonClassLoader的父类是AppClassLoader 遵循双亲委派机制的,没有重写loadClass方法

ClassLoader commonLoader = null;
ClassLoader catalinaLoader = null;
ClassLoader sharedLoader = null;
private void initClassLoaders() {
    try {
        commonLoader = createClassLoader("common", null);
        if (commonLoader == null) {
            commonLoader = this.getClass().getClassLoader();
        }
        //CommonClassLoader 作为catalinaLoader 和 sharedLoader的父类加载器
        catalinaLoader = createClassLoader("server", commonLoader);
        sharedLoader = createClassLoader("shared", commonLoader);
    } catch (Throwable t) {
        handleThrowable(t);
        log.error("Class loader creation threw exception", t);
        System.exit(1);
    }
}

private ClassLoader createClassLoader(String name, ClassLoader parent)
    throws Exception {

    //从这个properties 决定自己是加载哪个范围的jar
    String value = CatalinaProperties.getProperty(name + ".loader");
    if ((value == null) || (value.equals("")))
        return parent;

    value = replace(value);

    List<Repository> repositories = new ArrayList<Repository>();

    StringTokenizer tokenizer = new StringTokenizer(value, ",");
    while (tokenizer.hasMoreElements()) {
        String repository = tokenizer.nextToken().trim();
        if (repository.length() == 0) {
            continue;
        }

        // Check for a JAR URL repository
        try {
            @SuppressWarnings("unused")
            URL url = new URL(repository);
            repositories.add(new Repository(repository, RepositoryType.URL));
            continue;
        } catch (MalformedURLException e) {
            // Ignore
        }

        // Local repository
        if (repository.endsWith("*.jar")) {
            repository = repository.substring
                (0, repository.length() - "*.jar".length());
            repositories.add(new Repository(repository, RepositoryType.GLOB));
        } else if (repository.endsWith(".jar")) {
            repositories.add(new Repository(repository, RepositoryType.JAR));
        } else {
            repositories.add(new Repository(repository, RepositoryType.DIR));
        }
    }

    return ClassLoaderFactory.createClassLoader(repositories, parent);
}

CatalinaProperties.loadProperties() 决定了CommonClassLoader , SharedClassLoader ,CatalinaClassLoader 加载的jar 范围

Tomcat的catalina.properties文件位于%CATALINA_HOME%/conf/目录下面,该文件主要配置tomcat的安全设置、类加载设置、不需要扫描的类设置、字符缓存设置四大块。
loadPropertiese(){
    ....
    if (is == null) {
        try {
            File home = new File(getCatalinaBase());
            File conf = new File(home, "conf");
            File propsFile = new File(conf, "catalina.properties");
            is = new FileInputStream(propsFile);
        } catch (Throwable t) {
            handleThrowable(t);
        }
    }
    .....
}
//catalina.properties文件如下
common.loader=${catalina.base}/lib,${catalina.base}/lib/*.jar,${catalina.home}/lib,${catalina.home}/lib/*.jar
server.loader=
shared.loader=

server.loader 和 shared.loader

在common.loader 加载完后,tomcat启动程序会检查catalina.properties文件中配置的server.loader和shared.loader是否设置。
如果设置,读取 tomcat下对应的server和shared这两个目录的类库。server和shared是对应tomcat目录下的两个目录

在Tomcat7中默认这两个目录是没有的。设置方法如下:

server.loader= c a t a l i n a . b a s e / s e r v e r / c l a s s e s , {catalina.base}/server/classes, catalina.base/server/classes,{catalina.base}/server/lib/*.jar

设置CatalinaLoader 和 SharedLoader

public void init() throws Exception {

    //设置项目文件路径
    setCatalinaHome();
    setCatalinaBase();

    initClassLoaders();

    //加载tomcat本身的类(隔离Web应用跟Tomcat 本身的类)
    Thread.currentThread().setContextClassLoader(catalinaLoader);

    SecurityClassLoad.securityClassLoad(catalinaLoader);

   
    if (log.isDebugEnabled())
        log.debug("Loading startup class");
    Class<?> startupClass =
        catalinaLoader.loadClass
        ("org.apache.catalina.startup.Catalina");
    Object startupInstance = startupClass.newInstance();

    if (log.isDebugEnabled())
        log.debug("Setting startup class properties");
    String methodName = "setParentClassLoader";
    Class<?> paramTypes[] = new Class[1];
    paramTypes[0] = Class.forName("java.lang.ClassLoader");
    Object paramValues[] = new Object[1];
    paramValues[0] = sharedLoader;
    Method method =
        startupInstance.getClass().getMethod(methodName, paramTypes);
    //给Catalina 设置父类类加载器为SharedLoader
    method.invoke(startupInstance, paramValues);

    catalinaDaemon = startupInstance;
}

//Catalina 是Server 的一个属性。 Server 下面的Service 都是 用这个父类加载器,找不到就用SystemClassLoader:AppClassLoader
/**
 * @return the parent class loader for this component. If not set, return
 * {@link #getCatalina()} {@link Catalina#getParentClassLoader()}. If
 * catalina has not been set, return the system class loader.
 */
public ClassLoader getParentClassLoader() {
    if (parentClassLoader != null)
        return parentClassLoader;
    if (catalina != null) {
        return catalina.getParentClassLoader();
    }
    return ClassLoader.getSystemClassLoader();
}

SharedLoader 如何做到Web应用程序共享的?

因为他是WebAppClassLoader的父类 。WebAppClassLoader 是在context 级别的。context以上的host Service Server 用的父类加载器都是,因为这些都是可以不同context 共享的。

Context 是Server.xml 最小配置单位了,所以Context 是 最细的web 应用
在这里插入图片描述

###Server.xml 配置文件
<?xml version='1.0' encoding='utf-8'?>

<Server port="8005" shutdown="SHUTDOWN">
  <Listener className="org.apache.catalina.startup.VersionLoggerListener" />
  
  <Listener className="org.apache.catalina.core.AprLifecycleListener" SSLEngine="on" />
  
  <Listener className="org.apache.catalina.mbeans.GlobalResourcesLifecycleListener" />
  <Listener className="org.apache.catalina.core.ThreadLocalLeakPreventionListener" />

  
  <GlobalNamingResources>
    <Resource name="UserDatabase" auth="Container"
              type="org.apache.catalina.UserDatabase"
              description="User database that can be updated and saved"
              factory="org.apache.catalina.users.MemoryUserDatabaseFactory"
              pathname="conf/tomcat-users.xml" />
  </GlobalNamingResources>
   
    <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" />

    //监听的端口
    <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />


    <Engine name="Catalina" defaultHost="localhost">

      <Realm className="org.apache.catalina.realm.LockOutRealm">
        <Realm className="org.apache.catalina.realm.UserDatabaseRealm"
               resourceName="UserDatabase"/>
      </Realm>

      <Host name="localhost"  appBase="webapps" unpackWARs="true" autoDeploy="true">
         
         //最小单位是context localhost:8080/server/aaaaa.jsp      
         <Context path="" docBase="client" reloadable="true" crossContext="true" /> 
         <Context path="/server" docBase="server" reloadable="true" crossContext="true" /> 

      </Host>

       <Host name="www.a.com"  appBase="webapps2" unpackWARs="true" autoDeploy="true">
       </Host>
    </Engine>
  </Service>
   <!--开始第二个Service配置-->
  <Service name="bbs">

    <Connector port="8090" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" />

    <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />

    <Engine name="bbs" defaultHost="localhost">

      <Realm className="org.apache.catalina.realm.LockOutRealm">
        <Realm className="org.apache.catalina.realm.UserDatabaseRealm"
               resourceName="UserDatabase"/>
      </Realm>

      <Host name="localhost"  appBase="" unpackWARs="true" autoDeploy="true">
           
        <Context debug="0" docBase="/srv/www/bbs" path="" privileged="true" reloadable="true"/>

        <Valve className="org.apache.catalina.valves.AccessLogValve" directory="logs"
               prefix="bbs_access_log" suffix=".txt"
               pattern="%h %l %u %t &quot;%r&quot; %s %b" />
      </Host>
    </Engine>
  </Service>
</Server>

所以context是相对独立的,每个context都有自己独立的包目录。会存在相同的包 com.xxx.a ,但是不同的实现,所以需要各自加载各自的,隔离开来。所以需要一个新的类加载器实例(WebAppClassLoader),该加载器重写了loadClass方法,优先加载当前应用目录下的类,如果当前找不到了,才一层一层往上找。这样就做到Web应用层级的隔离。

Context 的标准实现 StandardContext 类的 startInternal 方法 会开启一个web应用时会创建类加载器

 if (getLoader() == null) {
    WebappLoader webappLoader = new WebappLoader(getParentClassLoader());//添加父类加载器
    webappLoader.setDelegate(getDelegate());
    setLoader(webappLoader);
}

//前面已经说了getParentClassLoader 得到的是SharedClassLoader
//WebappLoader 也有一个 startInternal 方法 所以webAppLoader 的classLoader 是WebappClassLoader
classLoader = createClassLoader();

private String loaderClass = "org.apache.catalina.loader.WebappClassLoader";

private WebappClassLoaderBase createClassLoader()
    throws Exception {

    Class<?> clazz = Class.forName(loaderClass);
    WebappClassLoaderBase classLoader = null;

    if (parentClassLoader == null) {
        parentClassLoader = container.getParentClassLoader();
    } else {
        container.setParentClassLoader(parentClassLoader);
    }
    Class<?>[] argTypes = { ClassLoader.class };
    Object[] args = { parentClassLoader };
    Constructor<?> constr = clazz.getConstructor(argTypes);
    classLoader = (WebappClassLoaderBase) constr.newInstance(args);
    return classLoader;
}

同时会启动类加载器

Loader loader = getLoader();
 if (loader instanceof Lifecycle) {
      ((Lifecycle) loader).start();
 }

在WebappClassLoader的父类WebappClassLoaderBase中实现了start方法

	@Override
    public void start() throws LifecycleException {
 
        state = LifecycleState.STARTING_PREP;
		//加载web应用的所有class文件
        WebResource classes = resources.getResource("/WEB-INF/classes");
        if (classes.isDirectory() && classes.canRead()) {
            localRepositories.add(classes.getURL());
        }
		//加载web应用lib目录下在jar文件
        WebResource[] jars = resources.listResources("/WEB-INF/lib");
        for (WebResource jar : jars) {
            if (jar.getName().endsWith(".jar") && jar.isFile() && jar.canRead()) {
                localRepositories.add(jar.getURL());
                jarModificationTimes.put(
                        jar.getName(), Long.valueOf(jar.getLastModified()));
            }
        }
 
        state = LifecycleState.STARTED;
    }
在WebappClassLoaderBase中重写了ClassLoader的loadClass方法。通过设置web应用可以遵循类加载的双亲委派机制或者不遵循双亲委派机制了。
  • 从ClassLoader 的本地缓存中加载类
  • 调用ClassLoader的findLoadedClass方法查看jvm是否已经加载过此类
  • 通过系统的类加载器加载此类,这里防止应用写的类覆盖了J2SE的类(java 核心类,加载的是AppClassLoader那种。不是SharedLoader那种)
public WebappClassLoaderBase() {
    ClassLoader p = getParent();
    if (p == null) {
        p = getSystemClassLoader();
    }
    this.parent = p;
}

public final ClassLoader getParent() {
    if (parent == null)
        return null;
    SecurityManager sm = System.getSecurityManager();
    if (sm != null) {
        checkClassLoaderPermission(parent, Reflection.getCallerClass());
    }
    return parent;
}
  • 是否交给父类去加载。filter方法中根据包名来判断是否需要进行委托加载,默认情况下会返回false.因此delegatedLoad为false
  • 因为delegatedLoad为false,那么此时不会委托父加载器SharedLoader去加载,这里其实是没有遵循parent-first的加载机制
  • 自身加载
  • 交给父类去加载
@Override
    public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
         //锁住正在加载的类
        synchronized (getClassLoadingLock(name)) {
            if (log.isDebugEnabled())
                log.debug("loadClass(" + name + ", " + resolve + ")");
            Class<?> clazz = null;
            checkStateForClassLoading(name);
 
		//从当前ClassLoader的本地缓存中加载类,如果找到则返回
            clazz = findLoadedClass0(name);
            if (clazz != null) {
                if (log.isDebugEnabled())
                    log.debug("  Returning class from cache");
                if (resolve)
                    resolveClass(clazz);
                return (clazz);
            }
 
          
		// 本地缓存没有的情况下,调用ClassLoader的findLoadedClass方法查看jvm是否已经加载过此类,如果已经加载则直接返回。
            clazz = findLoadedClass(name);
            if (clazz != null) {
                if (log.isDebugEnabled())
                    log.debug("  Returning class from cache");
                if (resolve)
                    resolveClass(clazz);
                return (clazz);
            }
 
		//通过系统的类加载器加载此类,这里防止应用写的类覆盖了J2SE的类,这句代码非常关键,如果不写的话,
		//就会造成你自己写的类有可能会把J2SE的类给替换调,另外假如你写了一个javax.servlet.Servlet类,放在当前应用的WEB-INF/class中,
		//如果没有此句代码的保证,那么你自己写的类就会替换到Tomcat容器Lib中包含的类。
            String resourceName = binaryNameToPath(name, false);
 
            ClassLoader javaseLoader = getJavaseClassLoader();
            boolean tryLoadingFromJavaseLoader;
            try {
                tryLoadingFromJavaseLoader = (javaseLoader.getResource(resourceName) != null);
            } catch (ClassCircularityError cce) {
                tryLoadingFromJavaseLoader = true;
            }
 
            if (tryLoadingFromJavaseLoader) {
                try {
                    clazz = javaseLoader.loadClass(name);
                    if (clazz != null) {
                        if (resolve)
                            resolveClass(clazz);
                        return (clazz);
                    }
                } catch (ClassNotFoundException e) {
                    // Ignore
                }
            }
            
		//判断是否需要委托给父类加载器进行加载,delegate属性默认为false,那么delegatedLoad的值就取决于filter的返回值了,filter中是优先加载tomcat的lib下的class文件
		//filter方法中根据包名来判断是否需要进行委托加载,默认情况下会返回false.因此delegatedLoad为false
            boolean delegateLoad = delegate || filter(name, true);
 
            
		//因为delegatedLoad为false,那么此时不会委托父加载器去加载,这里其实是没有遵循parent-first的加载机制。
            if (delegateLoad) {
                if (log.isDebugEnabled())
                    log.debug("  Delegating to parent classloader1 " + parent);
                try {
                    clazz = Class.forName(name, false, parent);
                    if (clazz != null) {
                        if (log.isDebugEnabled())
                            log.debug("  Loading class from parent");
                        if (resolve)
                            resolveClass(clazz);
                        return (clazz);
                    }
                } catch (ClassNotFoundException e) {
                    // Ignore
                }
            }
 
           
		//调用findClass方法在webapp级别进行加载
            if (log.isDebugEnabled())
                log.debug("  Searching local repositories");
            try {
                clazz = findClass(name);
                if (clazz != null) {
                    if (log.isDebugEnabled())
                        log.debug("  Loading class from local repository");
                    if (resolve)
                        resolveClass(clazz);
                    return (clazz);
                }
            } catch (ClassNotFoundException e) {
                // Ignore
            }
 
          
		//如果还是没有加载到类,并且不采用委托机制的话,则通过父类加载器去加载。
            if (!delegateLoad) {
                if (log.isDebugEnabled())
                    log.debug("  Delegating to parent classloader at end: " + parent);
                try {
                    clazz = Class.forName(name, false, parent);
                    if (clazz != null) {
                        if (log.isDebugEnabled())
                            log.debug("  Loading class from parent");
                        if (resolve)
                            resolveClass(clazz);
                        return (clazz);
                    }
                } catch (ClassNotFoundException e) {
                    // Ignore
                }
            }
        }
 
        throw new ClassNotFoundException(name);
    }

既然 Tomcat 不遵循双亲委派机制,那么如果我自己web项目定义一个恶意的HashMap 还有org.apache.catalina.loader.WebappLoader,会不会有风险呢?

在这里插入图片描述
不会的,他先从自己的本地缓存和jvm 缓存查找,如果没有找到
第一个调用的是SystemClassLoader,能够加载java 核心类,所以HashMap 是不会被加载到的。
第二个WebAppClassLoader 的 findClass 方法 会有一个check 包权限方法。会检验这个类名的包是不是合法能够加载的。package.definition 这个参数设置哪些包是不能通过WebAppClassLoadr加载的。

#catalina.properties 配置文件里面
package.definition=sun.,java.,org.apache.catalina.,org.apache.coyote.,\
org.apache.jasper.,org.apache.naming.,org.apache.tomcat.
securityManager.checkPackageDefinition(name.substring(0,i))


public void checkPackageDefinition(String pkg) {
    if (pkg == null) {
        throw new NullPointerException("package name can't be null");
    }

    String[] pkgs;
    synchronized (packageDefinitionLock) {
        if (!packageDefinitionValid) {
            String tmpPropertyStr =
                AccessController.doPrivileged(
                    new PrivilegedAction<String>() {
                        public String run() {
                            return java.security.Security.getProperty(
                                "package.definition");
                        }
                    }
                );
            packageDefinition = getPackages(tmpPropertyStr);
            packageDefinitionValid = true;
        }
        pkgs = packageDefinition;
    }

    for (int i = 0; i < pkgs.length; i++) {
        if (pkg.startsWith(pkgs[i]) || pkgs[i].equals(pkg + ".")) {
            checkPermission(
                new RuntimePermission("defineClassInPackage."+pkg));
            break; // No need to continue; only need to check this once
        }
    }
}

Tomcat 为什么 要打破双亲委派机制?

(1)对于各个 webapp中的 class和 lib,需要相互隔离,不能出现一个应用中加载的类库会影响另一个应用的情况(同包同民情况,需要隔离类加载器,在不影响上层包的情况下,先找自身,在找父类的),而对于许多应用,需要有共享的lib以便不浪费资源。

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值