ClassLoder总结

1. 关于javac、java

我们先从熟知的jdk命令javac、java说起。

首先,Android Studio(以下简称AS)创建一个工程,并在默认包名下新建一个HelloWorld的类。

 

然后打开AS自带的Terminal, 路径切换到HelloWrold.java所在的目录,依次执行javac和java命令:

javac命令执行正常,但java命令却报错:

错误: 找不到或无法加载主类 HelloWorld

被搞得一头雾水,原因出在哪?我们分析一下。

网上很多人提到了JAVA的环境变量配置可能不对,主要就是CLASSPATH不要忘了写当前路径 ".;",而且jdk1.6以上的版本也不再需要配置CLASSPATH,所以如果环境变量没有问题,还是出现了上面的错误,就接着往下分析解决吧。

其实这主要是对javac和java命令的理解不深导致的。参考《Java核心技术》的第一卷的4.7.3章节(将类放入包中)和4.8章节(类路径), 我们看下javac和java命令的区别。

我们都知道通过javac 源文件 来编译生成.class文件,它会调用javac编译器在特定的目录下查找源文件并编译生成.class。这个特定的目录就是CLASSPATH环境变量指定的: ".;%JAVA_HOME%\lib;%JAVA_HOME%\lib\tools.jar;",查找当前目录,如果没有找到类文件,则查找第二个路径,以此类推,直到找到返回,否则报编译报错。

java 类名 是java解释器解释执行.class文件,也会在特定的目录下查找类路径。这里说的是类路径,而不是.class的路径,这是有区别的。 这里的特定目录同上。先在当前目录上查找对应的包路径,然后再找对应的类,找不到就继续查找。显然HelloWorld的类路径一定是包含"\com\milanac007\classloaderdemo\"的。而当前路径是

D:\Android\AndroidProjects\ClassLoaderDemo\app\src\main\java\com\milanac007\classloaderdemo

显然这个路径下,再去查找一个"\com\milanac007\classloaderdemo\HelloWorld"的类,肯定找不到。这个文件在哪里?显然在这个路径下:

D:\Android\AndroidProjects\ClassLoaderDemo\app\src\main\java

我们要做的就是切换到该目录,再执行对应命令:

成功!

同时可知,如果源文件没有包名,并且没有在包名路径下,就没这么多麻烦了。感兴趣的可以自己尝试修改一下。

2. ClassLoader简介

言归正传,下面开始我们的主题类加载器ClassLoader。顾名思义,ClassLoader是加载类Class的。编译生成的.class是字节码文件,根据需要类加载器ClassLoader动态的将字节码转成对应的Class对象,加载JVM虚拟机中运行。

JDK默认提供了三个类加载器:

引导类加载器BootStrap ClassLoader

扩展类加载器Extension ClassLoader

应用类加载器Application ClassLoader

其中Extension ClassLoader和Application ClassLoader被定义在sun.misc.Launcher类中,它是一个java虚拟机的入口应用。而BootStrap ClassLoader在jdk中无法直接找到,是用c++实现的。

2.1 BootStrap ClassLoader

主要加载JRE\lib下的核心jar比如charset.jar、resources.jar、rt.jar等, 和java基本类, 比如int.class、String.class等。可以通过

System.getProperty("sun.boot.class.path")获取具体的jar:

public class HelloWorld {
    public static void main(String[] args){
//        System.out.println("Hello World!");
        
        String bootPath = System.getProperty("sun.boot.class.path");
//文件路径分隔符,可用File.pathSeparator替代。On UNIX systems, this character is ':';on Microsoft Windows systems it is ';'
        StringTokenizer stringTokenizer = new StringTokenizer(bootPath, ";");
        for(int i=0, size = stringTokenizer.countTokens(); i<size; i++) {
            System.out.println(stringTokenizer.nextToken());
        }
    }
}

 

2.2  Extension ClassLoader

主要加载JRE\lib\ext路径下的jar, 可以通过System.getProperty("java.ext.dirs")获取具体的jar:

public class HelloWorld {
    public static void main(String[] args){
//        System.out.println("Hello World!");

//        String path = System.getProperty("sun.boot.class.path");
        String path = System.getProperty("java.ext.dirs");
        StringTokenizer stringTokenizer = new StringTokenizer(path, File.pathSeparator);
        for(int i=0, size = stringTokenizer.countTokens(); i<size; i++) {
            System.out.println(stringTokenizer.nextToken());
        }
    }
}

得到:

2.3  AppClassLoader

主要加载当前应用程序路径下的jar和class, 可以通过System.getProperty("java.class.path")获取。

public class HelloWorld {
    public static void main(String[] args){
//        System.out.println("Hello World!");

        String path = System.getProperty("sun.boot.class.path");
        path = System.getProperty("java.ext.dirs");
        path = System.getProperty("java.class.path");
        StringTokenizer stringTokenizer = new StringTokenizer(path, File.pathSeparator);
        for(int i=0, size = stringTokenizer.countTokens(); i<size; i++) {
            System.out.println(stringTokenizer.nextToken());
        }
    }
}

得到:

 

3. 双亲委派机制

理论上,每个类加载器都有一个父类加载器,通过该类加载器的getParent()方法获取。AppClassLoader的父类加载器是extClassLoader, 自定义的ClassLoader如果没有指定parent, 那么它的parent默认就是AppClassLoader, 这样就能够保证它能访问系统内置ClassLoader已经加载成功的class。对于extClassLoader,它的parent为null。在java中,如果某个classLoader本身的parent为空,bootStrap ClassLoader被认为是它的parent,故extClassLoader的parent理解为bootStrap ClassLoader, 所以这么机制被称为"双亲"。

在某个类加载器A加载某个类过程中,A首先检查自己的缓存中是否有该类,如果有说明之前已经加载过了,从A的缓存中获取并返回。如果没有,则调用classLoader的parent B去加载,这是一个递归的过程。B也是先从自己的缓存中查找,找到则返回,没有则继续向上请求B的parent C,如此反复查找。当位于最顶层的bootStrap ClassLoader的缓存中没有查到时,会从它的jar的系统路径System.getProperty("sun.boot.class.path")中查找,找到则返回,否则会在它子类extClassLoader的jar的系统路径中查找,如此递归。最后要么找到返回,要么找不到抛出异常。

可以看到,查到的过程是一个自下而上的委派过程,而加载的过程则是一个自上而下的委派过程。所以称为"双亲委派"。

为什么采用双亲委派机制来加载类?

出于安全考虑。通过双亲委派机制,可以有效的防止系统类被篡改,比如自己写一个java.lang.String类是无法替换掉真正的String类的。保证了各个类加载器之间的隔离。最常见的就是tomcat等web容器可以通过定义不同的类加载器实现各个web应用的完全隔离。

4. Launcher源码

这里附上Launcher源码,可见上面代码内容的出处。

package sun.misc;

public class Launcher {
	private static Launcher launcher = new Launcher();
    private static String bootClassPath = System.getProperty("sun.boot.class.path");
    private ClassLoader loader;

    public static Launcher getLauncher() {
        return launcher;
    }
	
	public Launcher() {
        Launcher.ExtClassLoader var1;
        try {
            var1 = Launcher.ExtClassLoader.getExtClassLoader();
        } catch (IOException var10) {
            throw new InternalError("Could not create extension class loader", var10);
        }

        try {
            this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
        } catch (IOException var9) {
            throw new InternalError("Could not create application class loader", var9);
        }

        Thread.currentThread().setContextClassLoader(this.loader);
		
       ......
    }
	
	public ClassLoader getClassLoader() {
        return this.loader;
    }
	
	static class ExtClassLoader extends URLClassLoader {
		private static File[] getExtDirs() {
            String var0 = System.getProperty("java.ext.dirs");
            File[] var1;
            if(var0 != null) {
                StringTokenizer var2 = new StringTokenizer(var0, File.pathSeparator);
                int var3 = var2.countTokens();
                var1 = new File[var3];

                for(int var4 = 0; var4 < var3; ++var4) {
                    var1[var4] = new File(var2.nextToken());
                }
            } else {
                var1 = new File[0];
            }

            return var1;
        }
		
        public static Launcher.ExtClassLoader getExtClassLoader() throws IOException {
            final File[] var0 = getExtDirs();

            try {
                return (Launcher.ExtClassLoader)AccessController.doPrivileged(new PrivilegedExceptionAction() {
                    public Launcher.ExtClassLoader run() throws IOException {
                        int var1 = var0.length;

                        for(int var2 = 0; var2 < var1; ++var2) {
                            MetaIndex.registerDirectory(var0[var2]);
                        }

                        return new Launcher.ExtClassLoader(var0);
                    }
                });
            } catch (PrivilegedActionException var2) {
                throw (IOException)var2.getException();
            }
        }

   
        public ExtClassLoader(File[] var1) throws IOException {
            super(getExtURLs(var1), (ClassLoader)null, Launcher.factory);
            ......
        }

        

        private static URL[] getExtURLs(File[] var0) throws IOException {
			//遍历File[],将元素File的路径encode,并放入新生成URL中,再将URL放入URL[];
        }
        ......
    }
	
	static class AppClassLoader extends URLClassLoader {
  
        public static ClassLoader getAppClassLoader(final ClassLoader var0) throws IOException {
            final String var1 = System.getProperty("java.class.path");
            final File[] var2 = var1 == null?new File[0]:Launcher.getClassPath(var1);
            return (ClassLoader)AccessController.doPrivileged(new PrivilegedAction() {
                public Launcher.AppClassLoader run() {
                    URL[] var1x = var1 == null?new URL[0]:Launcher.pathToURLs(var2);
                    return new Launcher.AppClassLoader(var1x, var0);
                }
            });
        }

        AppClassLoader(URL[] var1, ClassLoader var2) {
            super(var1, var2, Launcher.factory);
			......
        }
       
    }
	
	public static URLClassPath getBootstrapClassPath() {
        return Launcher.BootClassPathHolder.bcp;
    }
	
	
	private static class BootClassPathHolder {
        static final URLClassPath bcp;

        private BootClassPathHolder() {
        }

        static {
            URL[] var0;
            if(Launcher.bootClassPath != null) {
                var0 = (URL[])AccessController.doPrivileged(new PrivilegedAction() {
                    public URL[] run() {
                        File[] var1 = Launcher.getClassPath(Launcher.bootClassPath);
                        ......

                        return Launcher.pathToURLs(var1);
                    }
                });
            } else {
                var0 = new URL[0];
            }

            bcp = new URLClassPath(var0, Launcher.factory);
            ......
        }
    }
	
	private static URL[] pathToURLs(File[] var0) {
        URL[] var1 = new URL[var0.length];

        for(int var2 = 0; var2 < var0.length; ++var2) {
            var1[var2] = getFileURL(var0[var2]);
        }

        return var1;
    }
	
	
	static URL getFileURL(File var0) {
		//将File var0的路径encode,并放入新生成URL中,返回URL
    }
	
   /*
	* 根据File.pathSeparator将var0拆分成路径path的集合,再new File(path), 放到File[]里输出
	*/
	private static File[] getClassPath(String var0) {
        File[] var1;
        if(var0 != null) {
            int var2 = 0;
            int var3 = 1;
            boolean var4 = false;

            int var5;
            int var7;
            for(var5 = 0; (var7 = var0.indexOf(File.pathSeparator, var5)) != -1; var5 = var7 + 1) {
                ++var3;
            }

            var1 = new File[var3];
            var4 = false;

            for(var5 = 0; (var7 = var0.indexOf(File.pathSeparator, var5)) != -1; var5 = var7 + 1) {
                if(var7 - var5 > 0) {
                    var1[var2++] = new File(var0.substring(var5, var7));
                } else {
                    var1[var2++] = new File(".");
                }
            }

            if(var5 < var0.length()) {
                var1[var2++] = new File(var0.substring(var5));
            } else {
                var1[var2++] = new File(".");
            }

            if(var2 != var3) {
                File[] var6 = new File[var2];
                System.arraycopy(var1, 0, var6, 0, var2);
                var1 = var6;
            }
        } else {
            var1 = new File[0];
        }

        return var1;
    }

}

5. ClassLoader的其他需要了解的

ClassLoader是一个抽象类。通过二进制类名称,类加载器尝试找到或生成定义该类的数据。每个Class类都包含一个定义该Class的ClassLoader的引用。

 Array类并不是由classLoader创建的,它是在需要的时候由Java runtime自动创建。

对于一个Array类,通过Class.getClassLoader()返回的类加载器 与 数组的数据类型的类加载器一致。
而如果数据类型是基本类型,那么这个Array 没有类加载器(更确切的说应该为bootstrap loader)。

应用程序通过实现ClassLoader的子类,来扩展JVM动态加载类的能力。

ClassLoader类使用委托模型来寻找类和资源。每个ClassLoader的实例都有一个相关的父类加载器。当需要找一个类或资源时,这个ClassLoader实例先会委托它的父类加载器进行加载,找不到再尝试自己寻找。Java虚拟机内建的类加载器,"bootstrap class loader",没有父类加载器,但可以担任一个ClassLoader实例的父亲。

在类初始化时,通过调用ClassLoader.registerAsParallelCapable方法,ClassLoader可以被设置为并发加载。
需注意的是,虽然ClassLoader默认被设置为并发加载,但它的子类依然需要将自己设置为并发加载模式。

在某些情况下,委托模型并不是严格遵守继承关系的,类加载器需要并发加载,否则类加载可能导致死锁,因为在类加载过程中会持有加载器的锁,具体见loadClass方法。

通常,java虚拟机从本地的文件系统加载类,比如对于UNIX系统,类加载器是从环境变量CLASSPATH定义的路径上加载类。无论如何,有些类并不是来源于类文件,它们也可能来源于其他来源,比如网络、数据库或被一个应用构造。
defineClass(String, byte[], int, int)会将字节数组转化为一个类Class的实例 cls。通过反射cls.newInstance(),可以得到一个实例化的类对象。

一个类加载器创造的类的方法和构造函数也许引用了其他类,JVM通过调用loadClass()来引用其他类。

举例,一个应用可以创建一个网络类加载器来从服务器加载类文件。示例代码如下:

ClassLoader loader = new NetworkClassLoader(host, port);
Object main = loader.loadClass("Main", true).newInstance();


 该网络类加载器的子类必须定义findClass和loadClassData方法来从从网络加载类。它应该使用defineClass来创建一个类实例。
示例代码如下:
 

class NetworkClassLoader extends ClassLoader {
    String host;
    int port;

    public Class findClass(String name) {
        byte[] b = loadClassData(name);
        return defineClass(name, b, 0, b.length);
    }

    private byte[] loadClassData(String name) {
        // load the class data from the connection
    }
}

任何提供给ClassLoader方法的字符串类名参数必须是须符合Java语言规范的二进制名称。有效类名的示例包括:

"java.lang.String"
"javax.swing.JSpinner$DefaultEditor"
"java.security.KeyStore$Builder$FileBuilder$1"
"java.net.URLClassLoader$3$1"

 6. ClassLoader中的主要函数

6.1  loadClass

通过二进制类名name,JVM执行loadClass(String name)来加载并解析类。

public Class<?> loadClass(String className) throws ClassNotFoundException {
        return loadClass(className, false);
}

 可以看到,里面调用了Class<?> loadClass(String name, boolean resolve)。
 该方法默认遵守以下顺序查找所需的类:
 1. 执行findLoadedClass(String)来检查这个类是否已经被加载过;
 2. 执行父类加载器的loadClass方法。如果父类加载器为空,则用JVM内建的类加载器,即bootstrap classloader代替;
 3. 执行findClass(String)来查找类;
 
 如果通过以上步骤找到了类,且参数resolve为true,那么这个方法将会调用resolveClass(Class)来完成类的解析并返回该Class。
 
另外,ClassLoader的子类应当复写findClass(String),而不是该loadClass方法。
loadClass除非被复写,否则在整个类加载过程中,该方法会同步(synchronizes) getClassLoadingLock方法执行的结果。
loadClass(String name, boolean resolve) 源码如下:
 

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
                    ......
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

    getClassLoadingLock返回类加载过程的锁对象。为了向下兼容,该方法默认实现为:
    如果这个ClassLoader对象是并发加载模式,那么该方法返回一个专门的与该类名相关的锁对象;
    否则,该方法返回该ClassLoader本身。
   

protected Object getClassLoadingLock(String className) {
        Object lock = this;
        if (parallelLockMap != null) {
            Object newLock = new Object();
            lock = parallelLockMap.putIfAbsent(className, newLock);
            if (lock == null) {
                lock = newLock;
            }
        }
        return lock;
    }
    // Maps class name to the corresponding lock object when the current
    // class loader is parallel capable.
    // Note: VM also uses this field to decide if the current class loader
    // is parallel capable and the appropriate lock object for class loading.
    private final ConcurrentHashMap<String, Object> parallelLockMap;

6.2  findClass
再来看findClass方法,通过制定的二进制类名查找类。对于类加载遵守委托模型的ClassLoader的子类而言,该方法应该被复写,在父ClassLoader查找指定类且没有找到后,该方法会被loadClass调用。可以看到,默认实现只是抛出一个异常。

 protected Class<?> findClass(String name) throws ClassNotFoundException {
        throw new ClassNotFoundException(name);
 }

6.3  resolveClass
 resolveClass方法,链接指定的类。 该(误导性命名)方法用于类加载器链接类。 如果类已经链接,然后这个方法只简单返回。 否则,类加载器使用符合Java语言规范的类名链接类。
该方法调用了本地方法resolveClass0,我们看不到源码,但可以参考defineClass(String, byte[], int, int)。

protected final void resolveClass(Class<?> c) {
        resolveClass0(c);
}

private native void resolveClass0(Class<?> c);

 6.4 defineClass(String name, byte[] b, int off, int len)
 将字节数组转化为类Class的实例。在Class被使用之前,它必须先被解析(resolved)。
 该方法指定一个默认域ProtectionDomain到新定义的类。默认域是在第一次调用defineClass(String,byte [],int,int)时创建的,并在后续重复使用。

protected final Class<?> defineClass(String name, byte[] b, int off, int len)
        throws ClassFormatError{
        return defineClass(name, b, off, len, null);
}

 name: 该类的二进制的名称
 b:指定格式的类文件,有效数据从偏移量off到off+len-1。
 注:如果试图添加一个未签名的类到包含被不同的证书签名的其他类的包中,或者name以"java."开头,那么将会抛出SecurityException。

注:这个方法将class二进制内容转换成Class对象,如果不符合要求, 抛出各种异常。所以此方法在编写自定义ClassLoader时非常重要。

里面调用了另外一个defineClass,源码如下:

6.5 defineClass(String name, byte[] b, int off, int len, ProtectionDomain protectionDomain)

 protected final Class<?> defineClass(String name, byte[] b, int off, int len,
                                         ProtectionDomain protectionDomain)
        throws ClassFormatError
    {
        protectionDomain = preDefineClass(name, protectionDomain);
        String source = defineClassSourceLocation(protectionDomain);
        Class<?> c = defineClass1(name, b, off, len, protectionDomain, source);
        postDefineClass(c, protectionDomain);
        return c;
    }

使用一个可选的域protectionDomain,将字节数组转化为一个类Class的实例。如果域为null,那么将默认域分配到该类,这个默认域在defineClass(String,byte [],int,int)中被指定。

在一个包中,定义的第一个类确定了确切的证书集合,这个集合是这个包中定义的任何类都必须包含的,它可以从这个类的ProtectionDomain的CodeSource中获取。被添加到这个包中的任意类都必须包含相同的证书集,否则SecurityException将会抛出。

参数name不能以"java."开头,因为在"java."的包中的所有类只能被bootstrap classloader定义。

   
7. Context ClassLoader 线程上下文类加载器

contextClassLoader是Thread的一个成员变量,通过setContextClassLoader()方法设置,getContextClassLoader()获取。

public class Thread implements Runnable {
 ......
/* The context ClassLoader for this thread */
   private ClassLoader contextClassLoader;

   public void setContextClassLoader(ClassLoader cl) {
       SecurityManager sm = System.getSecurityManager();
       if (sm != null) {
           sm.checkPermission(new RuntimePermission("setContextClassLoader"));
       }
       contextClassLoader = cl;
   }

   public ClassLoader getContextClassLoader() {
       if (contextClassLoader == null)
           return null;
       SecurityManager sm = System.getSecurityManager();
       if (sm != null) {
           ClassLoader.checkClassLoaderPermission(contextClassLoader,
                                                  Reflection.getCallerClass());
       }
       return contextClassLoader;
   }
   ......
}

 

每个Thread都有一个相关联的ClassLoader,默认是AppClassLoader。并且子线程默认使用父线程的ClassLoader。

子线程可以设置自己的ClassLoader。设置方法:

Thread.currentThread().setContextClassLoader(cl);

好了,ClassLoader说的差不多了,下面我们亲自实践一下自定义ClassLoader的写法。

自定义ClassLoader在创建时如果没有指定父ClassLoader,默认就是AppClassLoader, 这样就能够保证它能访问系统内置加载器加载成功的class文件。

 

首先新建一个Utils的无Activity的工程,编写一个Utils工具类,里面简单实现一个返回版本号的静态方法:

package org.milanac007.library;

public class Utils {
    public static void main(String[] args){
        System.out.println("====Utils====");
        System.out.println(getVersion());
    }
    public static String getVersion(){
        return "1.0.0.1";
    }
}

命令行生成.class:

javac org\tendyron\library\Utils.java

然后将Utils.class放到另一个工程ClassLoaderDemo的Assets目录;

并在此工程中新建一个CustomClassLoader的类,功能为从指定目录下加载指定名称的.class文件,主要的逻辑在findClass里实现:

public class CustomClassLoader extends ClassLoader {
    private String mPath;
    public CustomClassLoader(String  path){
        mPath = path;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String fileName = findFileName(name);
        File file = new File(mPath, fileName);
        try {
            FileInputStream in = new FileInputStream(file);
            ByteArrayOutputStream out = new ByteArrayOutputStream();
            int len = -1;
            while ((len = in.read())!= -1){
                out.write(len);
            }
            
            byte[] bytes = out.toByteArray();
            in.close();
            out.close();

            return defineClass(name, bytes, 0, bytes.length);

        } catch (FileNotFoundException e) {
            e.printStackTrace();
        }catch (IOException e1){
            e1.printStackTrace();
        }catch (Exception e2){
            e2.printStackTrace();
        }

        return super.findClass(name);
    }

    private String findFileName(String name){
        int index  = name.lastIndexOf(".");
        if(index == -1){
            return name +  ".class";
        }
        return name.substring(index+1) + ".class";
    }
}

 在main中添加测试代码, new 一个CustomClassLoader对象,参数指定为asset的决定路径:

public class HelloWorld {
    public static void main(String[] args){
//        System.out.println("Hello World!");
        String path = System.getProperty("sun.boot.class.path");
        path = System.getProperty("java.ext.dirs");
        path = System.getProperty("java.class.path");
        StringTokenizer stringTokenizer = new StringTokenizer(path, ";");
        for(int i=0, size = stringTokenizer.countTokens(); i<size; i++) {
            System.out.println(stringTokenizer.nextToken());
        }

        try {
            CustomClassLoader customClassLoader = new CustomClassLoader("D:\\Android\\AndroidProjects\\ClassLoaderDemo\\app\\src\\main\\assets");
            Class aClass =customClassLoader.loadClass("org.tendyron.library.Utils");
            Method method = aClass.getDeclaredMethod("getVersion", null);
            method.setAccessible(true);
            System.out.println("library version: " + method.invoke(null, null));
        } catch (ClassNotFoundException e) {
            e.printStackTrace();
        } catch (NoSuchMethodException e3){
            e3.printStackTrace();
        }catch (Exception e5){
            e5.printStackTrace();
        }
    }
}

运行main方法,结果如下图:

可见,自定义classLaoder成功的读取了特定目录下的.class。

之后我们将讨论如何在Android程序中引用其他Android程序生成的jar,来扩展原程序。希望大家关注。

版权声明:本文为博主原创文章,未经博主允许不得转载;来自https://blog.csdn.net/milanac007/article/details/82758678

 


    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
    
   


 

 

 

 

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值