JVM类加载

类加载过程

类的生命周期

类从被加载到虚拟机内存中开始到卸载出内存为止,整个生命周期可以简单概括为 7 个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading),其中,验证、准备和解析这三个阶段统称为连接(Linking)

类加载过程

Class文件需要加载到虚拟机中之后才能运行和使用

系统加载Class类型的文件主要三步:加载->连接->初始化,连接过程又可分为三步:验证->准备->解析

加载

类加载过程的第一步,主要完成下面 3 件事情:

1.通过全类名获取定义此类的二进制字节流

2.将字节流所代表的静态存储结构转换为方法区的运行时数据结构

3.在内存中生成一个代表该类的Class对象,作为方法区这些数据的访问入口

虚拟机规范上面这3点并不具体,因此是非常灵活的,例如通过全类名获取定义此类的二进制字节流并没有指明具体从哪里获取(ZIP、JAR、EAR、WAR、网络、动态代理技术运行时动态生成、其他文件生成如JSP...)、怎样获取

加载这一步主要是通过类加载器完成的,类加载器有很多种,加载一个类时,具体是哪个类加载器加载由双亲委派模型决定(不过也能打破由双亲委派模型)

每个Java类都有一个引用指向加载它的ClassLoader,不过数组类不是通过ClassLoader创建的,而是JVM在需要的时候自动创建的,数组类通过getClassLoader()方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的

一个非数组类的加载阶段(加载阶段获取类的二进制字节流的动作)是可控性最强的阶段,这一步可以自定义类加载器去控制字节流的获取方式(重写类加载器的loadClass()方法)

加载阶段与连接阶段的部分动作(例如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未结束,连接阶段可能已经开始了

验证

验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

验证阶段这一步在整个类加载过程中耗费的资源还是相对较多的,但很有必要,可以有效防止恶意代码的执行,任何时候,程序安全都是第一位

不过验证阶段也不是必须要执行的阶段,若程序运行的全部代码(包括自己编写的、第三方包中的、从外部加载的、动态生成的等所有代码)都已经被反复使用和验证过,在生产环境的实施阶段可以考虑使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

验证阶段主要由四个检验阶段组成:

1.文件格式验证(Class 文件格式检查)

2.元数据验证(字节码语义检查)

3.字节码验证(程序语义检查)

4.符号引用验证(类的正确性检查)

文件格式验证这一阶段是基于该类的二进制字节流进行的,主要目的是保证输入的字节流能正确地解析并存储于方法区之内,格式上符合描述一个Java类型信息的要求,除了这一阶段之外,其余三个验证阶段都是基于方法区的存储结构上进行的,不会再直接读取、操作字节流了

方法区属于是JVM运行时数据区域的一块逻辑区域,是各个线程共享的内存区域,当虚拟机要使用一个类时,它需要读取并解析Class文件获取相关信息,再将信息存入到方法区,方法区会存储已被虚拟机加载的类信息、字段信息、方法信息、常量、静态变量、即时编译器编译后的代码缓存等数据

符号引用验证发生在类加载过程中的解析阶段,具体说就是JVM将符号引用转化为直接引用的时候(解析阶段会介绍符号引用和直接引用)

符号引用验证的主要目的是确保解析阶段能正常执行,若无法通过符号引用验证,JVM会抛出异常,例如:

java.lang.IllegalAccessError:当类试图访问或修改它没有权限访问的字段,或调用它没有权限访问的方法时,抛出该异常

java.lang.NoSuchFieldError:当类试图访问或修改一个指定的对象字段,而该对象不再包含该字段时,抛出该异常

java.lang.NoSuchMethodError:当类试图访问一个指定的方法,而该方法不存在时,抛出该异常

准备

准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些内存都将在方法区中分配,对于该阶段有以下几点需要注意:

1.这时候进行内存分配的仅包括类变量(Class Variables ,即静态变量,被static关键字修饰的变量,只与类相关,因此被称为类变量),而不包括实例变量,实例变量会在对象实例化时随着对象一块分配在Java堆中

2.从概念上讲,类变量所使用的内存都应当在方法区中进行分配,不过有一点需要注意的是:JDK7之前,HotSpot使用永久代来实现方法区的时候,实现是完全符合这种逻辑概念的,而在JDK7及之后,HotSpot已经把原本放在永久代的字符串常量池、静态变量等移动到堆中,这个时候类变量则会随着Class对象一起存放在Java堆中

3.这里所设置的初始值通常情况下是数据类型默认的零值(例如0、0L、null、false等),例如定义public static int value = 111,那么value变量在准备阶段的初始值就是0而不是111(初始化阶段才会赋值),特殊情况:例如给value变量加上了final关键字public static final int value = 111,那么准备阶段value的值就被赋值为111

基本数据类型的零值:

解析

解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用进行

《深入理解 Java 虚拟机》对符号引用和直接引用的解释如下:

举个例子:在程序执行方法时,系统需要明确知道这个方法所在的位置,Java虚拟机为每个类都准备了一张方法表来存放类中所有的方法,当需要调用一个类的方法的时候,只要知道这个方法在方法表中的偏移量就可以直接调用该方法了,通过解析操作符号引用可以直接转变为目标方法在类中方法表的位置,从而使得方法可以被调用

综上,解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,即得到类或者字段、方法在内存中的指针或者偏移量

初始化

初始化阶段是执行初始化方法<clinit> ()方法的过程,是类加载的最后一步,这一步JVM才开始真正执行类中定义的Java程序代码(字节码)

说明:<clinit> ()方法是编译之后自动生成的

对于<clinit> ()方法的调用,虚拟机会自己确保其在多线程环境中的安全性,因为<clinit> ()方法是带锁线程安全,所以在多线程环境下进行类初始化的话可能会引起多个线程阻塞,并且这种阻塞很难被发现

对于初始化阶段,虚拟机严格规范了有且只有6种情况下,必须对类进行初始化(只有主动去使用类才会初始化类):

1.当遇到new、getstatic、putstatic、或invokestatic这4条字节码指令时,例如new一个类,读取一个静态字段(未被final修饰)、或调用一个类的静态方法时
当JVM执行new指令时会初始化类,即当程序创建一个类的实例对象
当JVM执行getstatic指令时会初始化类,即程序访问类的静态变量(不是静态常量,常量会被加载到运行时常量池)
当JVM执行putstatic指令时会初始化类,即程序给类的静态变量赋值
当JVM执行invokestatic指令时会初始化类,即程序调用类的静态方法

2.使用java.lang.reflect包的方法对类进行反射调用时,如Class.forname("...")、newInstance等,若类没初始化,需要触发其初始化

3.初始化一个类,如果其父类还未初始化,则先触发该父类的初始化。

4.当虚拟机启动时,用户需要定义一个要执行的主类(包含main方法的那个类),虚拟机会先初始化这个类

5.MethodHandle和VarHandle可以看作是轻量级的反射调用机制,要想使用这2个调用,就必须先使用findStaticVarHandle来初始化要调用的类

6.当一个接口中定义了JDK8新加入的默认方法(被default关键字修饰的接口方法)时,若有该接口的实现类发生了初始化,那该接口要在其之前被初始化

类卸载

卸载类即该类的Class对象被GC

卸载类需要满足 3 个要求:

1.该类的所有的实例对象都已被GC,也就是说堆不存在该类的实例对象

2.该类没有在其他任何地方被引用

3.该类的类加载器的实例已被GC

所以,在JVM生命周期内,由JVM自带的类加载器加载的类是不会被卸载的,但是自定义的类加载器加载的类是可能被卸载的

只要想通一点就好了,JDK自带的BootstrapClassLoader、ExtClassLoader、AppClassLoader负责加载JDK提供的类,所以它们(类加载器的实例)肯定不会被回收,而自定义的类加载器的实例是可以被回收的,所以使用自定义加载器加载的类是可以被卸载掉的

类加载器

类加载器介绍

类加载器从JDK1.0就出现了,最初只是为了满足Java Applet(已被淘汰)的需要,后来慢慢成为Java程序中的一个重要组成部分,赋予了Java类可以被动态加载到JVM中并执行的能力

类加载器是一个负责加载类的对象,ClassLoader是一个抽象类,给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据,典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的类文件
每个Java类都有一个引用指向加载它的ClassLoader,不过数组类不是通过ClassLoader创建的,而是JVM在需要的时候自动创建的,数组类通过getClassLoader()方法获取ClassLoader的时候和该数组的元素类型的ClassLoader是一致的

类加载器是一个负责加载类的对象,用于实现类加载过程中的加载这一步
每个Java类都有一个引用指向加载它的ClassLoader
数组类不是通过ClassLoader创建的(数组类没有对应的二进制字节流),是由JVM直接生成的

类加载器的主要作用是加载Java类的字节码(.class文件)到JVM中(在内存中生成一个代表该类的Class对象),字节码可以是Java源程序(.java文件)经过javac编译得来,也可以是通过工具动态生成或者通过网络下载得来

其实除了加载类之外,类加载器还可以加载Java应用所需的资源,例如文本、图像、配置文件、视频等文件资源

类加载器加载规则

JVM启动的时候,并不会一次性加载所有的类,而是根据需要去动态加载,即大部分类在具体用到的时候才会去加载,这样对内存更加友好

对于已经加载的类会被放在ClassLoader中,在类加载的时候,系统会首先判断当前类是否被加载过,已经被加载的类会直接返回,否则才会尝试加载,即对于一个类加载器来说,相同二进制名称的类只会被加载一次

public abstract class ClassLoader {
    ...
    private final ClassLoader parent;
    // 由这个类加载器加载的类
    private final Vector<Class<?>> classes = new Vector<>();
    // 由VM调用, 用此类加载器记录每个已加载类
    void addClass(Class<?> c) {
        classes.addElement(c);
    }
    ...
}

类加载器总结

JVM中内置了三个重要的ClassLoader:

1.BootstrapClassLoader(启动类加载器):最顶层的加载类,由C++实现,通常表示为null,并且没有父级,主要用来加载JDK内部的核心类库(%JAVA_HOME%/lib目录下的rt.jar、resources.jar、charsets.jar等jar包和类)以及被-Xbootclasspath参数指定的路径下的所有类

2.ExtensionClassLoader(扩展类加载器):主要负责加载%JRE_HOME%/lib/ext目录下的jar包和类以及被java.ext.dirs系统变量所指定的路径下的所有类

3.AppClassLoader(应用程序类加载器):面向用户的加载器,负责加载当前应用classpath下的所有jar包和类

拓展:

rt.jar:rt代表RunTime,rt.jar是Java基础类库,包含Java doc里面看到的所有的类的类文件,也就是说,常用内置库java.xxx.*都在里面,例如java.util.*、java.io.*、java.nio.*、java.lang.*、java.sql.*、java.math.*
Java9引入模块系统,并且略微更改了上述的类加载器,扩展类加载器被改名为平台类加载器(platform class loader),Java SE中除了少数几个关键模块,例如java.base是由启动类加载器加载之外,其他的模块均由平台类加载器加载

除了这三种类加载器之外,用户还可以加入自定义的类加载器来进行拓展,以满足自定义的特殊需求,例如可以对Java类的字节码(.class文件)进行加密,加载时再利用自定义的类加载器对其解密

除了BootstrapClassLoader是JVM自身的一部分之外,其他所有的类加载器都是在JVM外部实现的,并且全都继承自ClassLoader抽象类,这样做的好处是用户可以自定义类加载器,以便让应用程序自己决定如何去获取所需的类

每个ClassLoader可以通过getParent()获取其父ClassLoader,若获取到ClassLoader为null,则该类是通过BootstrapClassLoader加载的

public abstract class ClassLoader {
    ...
    // 父加载器
    private final ClassLoader parent;
    @CallerSensitive
    public final ClassLoader getParent() {
        ...
    }
    ...
}

获取到ClassLoader为null就是BootstrapClassLoader加载的:因为BootstrapClassLoader由C++实现,由于这个C++实现的类加载器在Java中是没有与之对应的类的,所以拿到的结果是null

例子:

public class PrintClassLoaderTree {
    public static void main(String[] args) {
        ClassLoader classLoader = PrintClassLoaderTree.class.getClassLoader();

        StringBuilder split = new StringBuilder("|--");
        boolean needContinue = true;
        while (needContinue) {
            System.out.println(split.toString() + classLoader);
            if (classLoader == null) {
                needContinue = false;
            } else {
                classLoader = classLoader.getParent();
                split.insert(0, "\t");
            }
        }
    }

}

输出:

|--sun.misc.Launcher$AppClassLoader@18b4aac2
    |--sun.misc.Launcher$ExtClassLoader@53bd815b
        |--null

从输出结果可以看出:

编写的Java类PrintClassLoaderTree的ClassLoader是AppClassLoader
AppClassLoader的父ClassLoader是ExtClassLoader
ExtClassLoader的父ClassLoader是BootstrapClassLoader,因此输出结果为null

自定义类加载器

除了BootstrapClassLoader其他类加载器均由Java实现且全部继承自java.lang.ClassLoader,若要自定义类加载器,需要继承ClassLoader抽象类

ClassLoader类有两个关键的方法:

1.protected Class loadClass(String name, boolean resolve):加载指定二进制名称的类,实现双亲委派机制,name是类的二进制名称,resolve若为true,在加载时调用resolveClass(Class<?> c)方法解析该类

2.protected Class findClass(String name):根据类的二进制名称来查找类,默认实现是空方法

官方API文档:建议ClassLoader的子类重写findClass(String name)方法而不是loadClass(String name, boolean resolve)方法

若不想打破双亲委派模型,就重写ClassLoader类中的findClass(String name)方法即可,无法被父类加载器加载的类最终会通过这个方法被加载,但若想打破双亲委派模型则需要重写loadClass(String name, boolean resolve)方法

双亲委派模型

双亲委派模型介绍

类加载器有很多种,当加载一个类时,具体是哪个类加载器加载呢?这就需要提到双亲委派模型

ClassLoader类使用委托模型来搜索类和资源,每个ClassLoader实例都有一个相关的父类加载器,需要查找类或资源时,ClassLoader实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器
虚拟机中被称为BootstrapClassLoader的内置类加载器本身没有父类加载器,但是可以作为ClassLoader实例的父类加载器

ClassLoader类使用委托模型来搜索类和资源
双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应有自己的父类加载器
ClassLoader实例会在试图亲自查找类或资源之前,将搜索类或资源的任务委托给其父类加载器

下图展示的各种类加载器之间的层次关系被称为类加载器的双亲委派模型

双亲委派模型并不是一种强制性的约束,只是JDK官方推荐的一种方式,若因为某些特殊需求想要打破双亲委派模型,也是可以的,后文会介绍具体的方法

其实这个双亲翻译得容易让别人误解,一般理解的双亲都是父母,这里的双亲更多地表达的是“父母这一辈”的人而已,并不是说真的有一个MotherClassLoader和一个FatherClassLoader,个人觉得翻译成单亲委派模型更好一些,不过,国内既然翻译成了双亲委派模型并流传了,按照这个来也没问题,不要被误解了就好

另外,类加载器之间的父子关系一般不是以继承的关系来实现的,而是通常使用组合关系来复用父加载器的代码

public abstract class ClassLoader {
    ...
    // 组合
    private final ClassLoader parent;
    protected ClassLoader(ClassLoader parent) {
        this(checkCreateClassLoader(), parent);
    }
    ...
}

在面向对象编程中,有一条非常经典的设计原则:组合优于继承,多用组合少用继承

双亲委派模型的执行流程

双亲委派模型的实现代码非常简单,逻辑非常清晰,都集中在java.lang.ClassLoader的loadClass()

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 首先, 检查该类是否已经加载过
        Class c = findLoadedClass(name);
        if (c == null) {
            // 若c为null, 则说明该类没有被加载过
            long t0 = System.nanoTime();
            try {
                if (parent != null) {
                    // 当父类的加载器不为空, 则通过父类的loadClass来加载该类
                    c = parent.loadClass(name, false);
                } else {
                    // 当父类的加载器为空, 则调用启动类加载器来加载该类
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 非空父类的类加载器无法找到相应的类,则抛出异常
            }

            if (c == null) {
                // 当父类加载器无法加载时, 则调用findClass方法来加载该类
                // 用户可通过覆写该方法, 来自定义类加载器
                long t1 = System.nanoTime();
                c = findClass(name);

                // 用于统计类加载器相关的信息
                sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                sun.misc.PerfCounter.getFindClasses().increment();
            }
        }
        if (resolve) {
            // 对类进行link操作
            resolveClass(c);
        }
        return c;
    }
}

每当一个类加载器接收到加载请求时,它会先将请求转发给父类加载器,在父类加载器没有找到所请求的类的情况下,该类加载器才会尝试去加载

结合上面的源码,简单总结一下双亲委派模型的执行流程:

1.在类加载的时候,系统会首先判断当前类是否被加载过,已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)

2.类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器loadClass()方法来加载类),这样的话,所有的请求最终都会传送到顶层的启动类加载器BootstrapClassLoader中

3.只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的findClass()方法来加载类)

4.若子类加载器也无法加载这个类,那么它会抛出一个ClassNotFoundException异常

拓展:

JVM判定两个Java类是否相同的具体规则:JVM不仅要看类的全名是否相同,还要看加载此类的类加载器是否一样,只有两者都相同的情况,才认为两个类是相同的,即使两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相同

双亲委派模型的好处

双亲委派模型保证了Java程序的稳定运行,可以避免类的重复加载(JVM区分不同类的方式不仅仅根据类名,相同的类文件被不同的类加载器加载产生的是两个不同的类),也保证了Java的核心API不被篡改

若没有使用双亲委派模型,而是每个类加载器加载自己的话会出现一些问题,例如编写一个称为java.lang.Object的类,程序运行的时候,系统就会出现两个不同的Object类,双亲委派模型可以保证加载的是JRE里的那个Object类,而不是自定义的Object类,这是因为AppClassLoader在加载自定义的Object类时,会委托给ExtClassLoader去加载,而ExtClassLoader会委托给BootstrapClassLoader,BootstrapClassLoader发现自己已经加载过Object类,会直接返回,不会去加载自定义的Object类

打破双亲委派模型方法

自定义加载器需要继承ClassLoader
若不想打破双亲委派模型,重写ClassLoader类中的findClass()方法即可,无法被父类加载器加载的类最终会通过这个方法被加载;若想打破双亲委派模型,则需要重写loadClass()方法

为什么是重写loadClass()方法打破双亲委派模型呢?双亲委派模型的执行流程已经解释了:类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器loadClass()方法来加载类)

重写loadClass()方法后,就可以改变传统双亲委派模型的执行流程,例如子类加载器可以在委派给父类加载器之前,先自己尝试加载这个类,或者在父类加载器返回之后,再尝试从其他地方加载这个类,具体的规则自定义实现,根据项目需求定制化

比较熟悉的Tomcat服务器为了能够优先加载Web应用目录下的类,然后再加载其他目录下的类,就自定义了类加载器WebAppClassLoader来打破双亲委托机制,这也是Tomcat下Web应用之间的类实现隔离的具体原理

Tomcat的类加载器的层次结构如下:

Tomcat 这四个自定义的类加载器对应的目录如下:
CommonClassLoader对应<Tomcat>/common/*
CatalinaClassLoader对应<Tomcat>/server/*
SharedClassLoader对应<Tomcat>/shared/*
WebAppClassLoader对应<Tomcat>/webapps/<app>/WEB-INF/*

从图中的委派关系中可以看出:

1.CommonClassLoader作为CatalinaClassLoader和SharedClassLoader的父加载CommonClassLoader能加载的类都可以被CatalinaClassLoader和SharedClassLoader使用,因此,CommonClassLoader是为了实现公共类库(可以被所有Web应用和Tomcat内部组件使用的类库)的共享和隔离

2.CatalinaClassLoader和SharedClassLoader能加载的类则与对方相互隔离,CatalinaClassLoader用于加载Tomcat自身的类,为了隔离Tomcat本身的类和Web应用的类,SharedClassLoader作为WebAppClassLoader的父加载器,专门来加载Web应用之间共享的类例如Spring、Mybatis

3.每个Web应用都会创建一个单独的WebAppClassLoader,并在启动Web应用的线程里设置线程线程上下文类加载器为WebAppClassLoader,各个WebAppClassLoader实例之间相互隔离,进而实现Web应用之间的类隔

单纯依靠自定义类加载器没办法满足某些场景的要求,例如,有些情况下,高层的类加载器需要加载低层的加载器才能加载的类

例如SPI中,SPI的接口(如java.sql.Driver)是由Java核心库提供的,由BootstrapClassLoader加载,而SPI的实现(如com.mysql.cj.jdbc.Driver)是由第三方供应商提供的,它们是由应用程序类加载器或者自定义类加载器来加载的,默认情况下,一个类及其依赖类由同一个类加载器加载,所以加载SPI的接口的类加载器(BootstrapClassLoader)也会用来加载SPI的实现,按照双亲委派模型,BootstrapClassLoader是无法找到SPI的实现类的,因为它无法委托给子类加载器去尝试加载

例如,假设项目中有Spring的jar包,由于其是Web应用之间共享的,因此会由SharedClassLoader加载(Web服务器是Tomcat),项目中有一些用到了Spring的业务类,例如实现了Spring提供的接口、用到了Spring提供的注解,所以加载Spring的类加载器(也就是SharedClassLoader)也会用来加载这些业务类,但是业务类在Web应用目录下,不在SharedClassLoader的加载路径下,所以SharedClassLoader无法找到业务类,也就无法加载它们

如何解决这个问题呢? 这个时候需要用到线程上下文类加载器(ThreadContextClassLoader)

拿Spring这个例子来说,当Spring需要加载业务类的时候,它不是用自己的类加载器,而是用当前线程的上下文类加载器,每个Web应用都会创建一个单独的WebAppClassLoader,并在启动Web应用的线程里设置线程线程上下文类加载器为WebAppClassLoader,这样就可以让高层的类加载器(SharedClassLoader)借助子类加载器(WebAppClassLoader)来加载业务类,破坏了Java的类加载委托机制,让应用逆向使用类加载器

线程线程上下文类加载器的原理是将一个类加载器保存在线程私有数据里,跟线程绑定,然后在需要的时候取出来使用,这个类加载器通常是由应用程序或者容器(例如Tomcat)设置

java.lang.Thread中的getContextClassLoader()和setContextClassLoader(ClassLoader c1)分别用来获取和设置线程的上下文类加载器,若没有通过setContextClassLoader(ClassLoader c1)进行设置,线程将继承其父线程的上下文类加载器

Spring获取线程线程上下文类加载器的代码如下:

cl = Thread.currentThread().getContextClassLoader();
  • 20
    点赞
  • 14
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值