java 类加载和初始化

java的字节代码表现形式为字节数组,java类在jvm中的标识形式是java.lang.Class类的对象,一个java类从字节码到能够 在jvm中使用,需要经过加载、链接和初始化这三个步骤。这三个步骤中对我们可见的是java类的加载,通过java类加载器(classloader) 可以在运行期加载一个java类;而链接和初始化则是在使用java类之前发生的的动作。

 

java类的加载

java 类的加载时有类加载器完成的,类加载器分为启动类加载器(bootstrap)、扩展类加载器(externd)、系统类加载器和用户自定义的类加载器, 区别在于启动类加载器是有jvm使用本地代码实现的,用户自定义的类加载器需要继承java.lang.ClassLoader类进行定义。

类 加载器需要完成的工作是最终定义一个java类,把java字节码转化为jvm中的java.lang.Class类的实例,java的类加载器的特征是 层次组织结构和全盘父委托模式层次结构组织是指每一个类加载器都会有一个父加载器,公国getParent方法可以获取到,类加载器通过这种父亲--后代 的方式组织到一起组织为一棵树形结构,全盘父委托模式是指如果一个类需要被加载则首先询问他的父类加载器是否能够加载,如果能加载则又父类加载器进行加载,如果不能加载则继续向上进行传递询问,最终到达启动类加载器,如果都不能加载则抛出ClassNotRefException,(这样jvm可以把 java最核心的类库使用启动类加载器进行加载,这样就可以避免恶意程序替换java的核心类库保证了系统的安全)代理模式是指一个类加载器,既可以自己完成java类的定义工作,也可以给其他类加载器来完成,由于代理模式的存在,启动一个类的加载过程的类加载器和最终定义这个类的类加载器可能不是同一个类加载器,启动一个类的加载过程的类加载器称为初始类加载器,最终定义一个类的类加载器称为定义类加载器,两者的联系是一个java类的定义类加载器是该类所导入的其他java类的初始类加载器。一般的类加载器在尝试自己去加载某个类之前总会首先代理给其父类加载器,当父类加载器无法完成加载时才会自己进行加载,这个逻辑被封装在java.lang.ClassLoader#loadClass()中。但是在web环境下却采用相反的加载策略,这也是java web servlet推荐的做法,比如tomcat为每一个web应用提供独立的类加载器。使用的就是自己优先的类加载策略。

类加载器的一个重要用途是在jvm中为相同名称的java类创建隔离空间。Jvm中判断2个类是否相同不仅仅要根据该类的二进制名称,还要根据2个类的定义类加载器如果2者相同才认为2个类时相同的,因此即便是2个一样的java字节码,却被2个不同的类加载器定义后,所得到的java类也是不同的,如果试图在2个类的对象之间进行赋值,就会抛出java.lang.ClassCastException,这个特性为2同样名称的java类在jvm中共存创造了条件,通过类加载器就可以满足这种需求,在osgi中得到广泛的使用。

 

Java类的链接

Java类的链接是指java类的二进制代码合并到jvm的运行状态的过程。在链接之前,这个类必须被成功加载。类的链接包括验证、准备和解析过程。验证是用来保证java类的二进制标识在结构上完全正确,如果验证过程出现错误,会抛出java.lang.VerifyError错误,准备过程则是创建java类中的静态域,并将他们的值赋给默认值,准备过程并不会执行代码,在一个java类中会包含对其他类或接口的引用,包括他的父类、所实现的接口、方法的形式参数和返回值的java类等,解析过程就是确保这些被引用的类能被正确找到,解析的过程可能导致其他的java类被加载。

不同的jvm可能选择不同的解析策略,一种做法是在链接的时候就递归的把所有依赖的形式引用都进行解析为直接引用,而另一种做法是只是在形式引用被真正用到时才去解析他们,也就是说如果一个java类只是被引用了而没有被真正用到,那么那个类是不会被解析了。

 

Java类的初始化

当一个java类第一次被真正用到的使用,jvm会进行该类的初始化操作,初始化主要的作用是执行静态代码块和初始化静态域,在一个类被初始化之前,她的直接父类将会被初始化,但是一个接口的初始化不会引起父接口的初始化,在初始化的时候会按照代码的顺序从上到下执行静态代码块和初始化静态域。考虑下面代码

public class StaticTest {

public static int X = 10;

public static void main(String[] args) {

System.out.println(Y); //输出60

}

static {

X = 30;

}

public static int Y = X * 2;

}

初始化的时候,静态域的初始化和静态代码块的执行会自上到下进行。。

Java类和接口的初始化只在特定的时机才会发生:

1 创建一个java类的实例,包括使用new 方式创建或者通过Class.newInstance()x方法进行创建;

2 调用一个java类的静态方法时

3 给一个类或接口的静态域成员赋值

4 访问java类或接口声明的静态域并且该域不是常量变量

5 在顶层java类中执行assert语句

6 通过java反射api对类或接口进行初始化,当访问一个java类或接口中的静态域的时候,只有真正声明了这个域的类或接口才会被初始化。

class B {

static int value = 100;

static {

System.out.println("Class B is initialized."); //输出

}

}

class A extends B {

static {

System.out.println("Class A is initialized."); //不会输出

}

}

public class InitTest {

public static void main(String[] args) {

System.out.println(A.value); //输出100

}

}

 

创建自己的类加载器

在java应用开发中会需要创建自己的类加载器。一种场景就是实现特定的java字节码查找方式、对字节码进行加密和解密和实现同名java类隔离等。

defineClass()方法:完成从java字节码的字节数组到java.lang.Class的转化过程,一般不需要重写。

findLoadedClass()根据名称查找已经加载过的java类、一个类加载器不会重复加载同一个名称的类。

findClass()用来根据名称查找并加载java类

loadClass()根据名称加载java类

resolveClass()链接一个java类

这里比较 容易混淆的是findClass()方法和loadClass()方法的作用。前面提到过,在

Java 类的链接过程中,会需要对Java 类进行解析,而解析可能会导致当前Java类所

引用的其它Java 类被加载。在这个时候,JVM 就是通过调用当前类的定义类加载器

的loadClass()方法来加载其它类的。findClass()方法则是应用创建的类加载器的扩展

点。应用自己的类加载器应该覆写findClass()方法来添加自定义的类加载逻辑。

loadClass()方法的默认实现会负责调用findClass()方法。

前面提到,类加载器的代理模式默认使用的是父类优先的策略。这个策略的实现是

封装在loadClass()方法中的。如果希望修改此策略,就需要覆写loadClass()方法。

下面的代码给出了自定义的类加载的常见实现模式:

public class MyClassLoader extends ClassLoader {

protected Class<?> findClass(String name) throws

ClassNotFoundException {

byte[] b = null; //查找或生成Java类的字节代码

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

}

}

 

 

bootstrap classloader -引导(也称为原始)类加载器,它负责加载Java的核心类。在Sun的JVM中,在执行java的命令中使用-Xbootclasspath选项或使用- D选项指定sun.boot.class.path系统属性值可以指定附加的类。这个加载器的是非常特殊的,它实际上不是 java.lang.ClassLoader的子类,而是由JVM自身实现的。大家可以通过执行以下代码来获得bootstrap classloader加载了那些核心类库:

URL[] urls=sun.misc.Launcher.getBootstrapClassPath().getURLs();

for (int i = 0; i < urls.length; i++) {

System.out.println(urls.toExternalForm());

}

在我的计算机上的结果为:

file:/C:/j2sdk1.4.1_01/jre/lib/endorsed/dom.jar

file:/C:/j2sdk1.4.1_01/jre/lib/endorsed/sax.jar

file:/C:/j2sdk1.4.1_01/jre/lib/endorsed/xalan-2.3.1.jar

file:/C:/j2sdk1.4.1_01/jre/lib/endorsed/xercesImpl-2.0.0.jar

file:/C:/j2sdk1.4.1_01/jre/lib/endorsed/xml-apis.jar

file:/C:/j2sdk1.4.1_01/jre/lib/endorsed/xsltc.jar

file:/C:/j2sdk1.4.1_01/jre/lib/rt.jar

file:/C:/j2sdk1.4.1_01/jre/lib/i18n.jar

file:/C:/j2sdk1.4.1_01/jre/lib/sunrsasign.jar

file:/C:/j2sdk1.4.1_01/jre/lib/jsse.jar

file:/C:/j2sdk1.4.1_01/jre/lib/jce.jar

file:/C:/j2sdk1.4.1_01/jre/lib/charsets.jar

file:/C:/j2sdk1.4.1_01/jre/classes

这时大家知道了为什么我们不需要在系统属性CLASSPATH中指定这些类库了吧,因为JVM在启动的时候就自动加载它们了。

 

extension classloader -扩展类加载器,它负责加载JRE的扩展目录(JAVA_HOME/jre/lib/ext或者由java.ext.dirs系统属性指定的)中JAR的类包。这为引入除Java核心类以外的新功能提供了一个标准机制。因为默认的扩展目录对所有从同一个JRE中启动的JVM都是通用的,所以放入这个目录的JAR类包对所有的JVM和systemclassloader都是可见的。在这个实例上调用方法getParent()总是返回空值null,因为引导加载器bootstrap classloader不是一个真正的ClassLoader实例。所以当大家执行以下代码时:

System.out.println(System.getProperty("java.ext.dirs"));

ClassLoaderextensionClassloader=ClassLoader.getSystemClassLoader().getParent();

System.out.println("the parent of extension classloader :"+extensionClassloader.getParent());

结果为:

C:/j2sdk1.4.1_01/jre/lib/ext

the parent of extension classloader : null

extension classloader是system classloader的parent,而bootstrap classloader是extension classloader的parent,但它不是一个实际的classloader,所以为null。

 

system classloader -系统(也称为应用)类加载器,它负责在JVM被启动时,加载来自在命令java中的-classpath或者java.class.path系统属性或者 CLASSPATH操作系统属性所指定的JAR类包和类路径。总能通过静态方法ClassLoader.getSystemClassLoader()找到该类加载器。如果没有特别指定,则用户自定义的任何类加载器都将该类加载器作为它的父加载器。执行以下代码即可获得:

System.out.println(System.getProperty("java.class.path"));

输出结果则为用户在系统属性里面设置的CLASSPATH。

classloader 加载类用的是全盘负责委托机制。所谓全盘负责,即是当一个classloader加载一个Class的时候,这个Class所依赖的和引用的所有 Class也由这个classloader负责载入,除非是显式的使用另外一个classloader载入;委托机制则是先让parent(父)类加载器 (而不是super,它与parentclassloader类不是继承关系)寻找,只有在parent找不到的时候才从自己的类路径中去寻找。此外类加载还采用了cache机制,也就是如果 cache中保存了这个Class就直接返回它,如果没有才从文件中读取和转换成Class,并存入cache,这就是为什么我们修改了Class但是必须重新启动JVM才能生效的原因。

 

 

每个ClassLoader加载Class的过程是:

1.检测此Class是否载入过(即在cache中是否有此Class),如果有到8,如果没有到2

2.如果parent classloader不存在(没有parent,那parent一定是bootstrap classloader了),到4

3.请求parent classloader载入,如果成功到8,不成功到5

4.请求jvm从bootstrap classloader中载入,如果成功到8

5.寻找Class文件(从与此classloader相关的类路径中寻找)。如果找不到则到7.

6.从文件中载入Class,到8.

7.抛出ClassNotFoundException.

8.返回Class.

 

其中5.6步我们可以通过覆盖ClassLoader的findClass方法来实现自己的载入策略。甚至覆盖loadClass方法来实现自己的载入过程。

 

类加载器的顺序是:

先是bootstrap classloader,然后是extensionclassloader,最后才是system classloader。大家会发现加载的Class越是重要的越在靠前面。这样做的原因是出于安全性的考虑,试想如果systemclassloader“亲自”加载了一个具有破坏性的“java.lang.System”类的后果吧。这种委托机制保证了用户即使具有一个这样的类,也把它加入到了类路径中,但是它永远不会被载入,因为这个类总是由bootstrap classloader来加载的。大家可以执行一下以下的代码:

System.out.println(System.class.getClassLoader());

将会看到结果是null,这就表明java.lang.System是由bootstrap classloader加载的,因为bootstrapclassloader不是一个真正的ClassLoader实例,而是由JVM实现的,正如前面已经说过的。

 

下面就让我们来看看JVM是如何来为我们来建立类加载器的结构的:

sun.misc.Launcher,顾名思义,当你执行java命令的时候,JVM会先使用bootstrap classloader载入并初始化一个Launcher,执行下来代码:

System.out.println("the Launcher's classloader is"+sun.misc.Launcher.getLauncher().getClass().getClassLoader());

结果为:

the Launcher's classloader is null (因为是用bootstrapclassloader加载,所以class loader为null)

Launcher 会根据系统和命令设定初始化好class loader结构,JVM就用它来获得extension classloader和system classloader,并载入所有的需要载入的Class,最后执行java命令指定的带有静态的main方法的Class。extension classloader实际上是sun.misc.Launcher$ExtClassLoader类的一个实例,systemclassloader实际上是sun.misc.Launcher$AppClassLoader类的一个实例。并且都是java.net.URLClassLoader的子类。

 

让我们来看看Launcher初试化的过程的部分代码。

 

Launcher的部分代码:

public class Launcher {

public Launcher() {

ExtClassLoader extclassloader;

try {

//初始化extension classloader

extclassloader = ExtClassLoader.getExtClassLoader();

} catch(IOException ioexception) {

throw new InternalError("Could not create extension classloader");

}

try {

//初始化system classloader,parent是extension classloader

loader = AppClassLoader.getAppClassLoader(extclassloader);

} catch(IOException ioexception1) {

throw new InternalError("Could not create applicationclass loader");

}

//将system classloader设置成当前线程的contextclassloader(将在后面加以介绍)

Thread.currentThread().setContextClassLoader(loader);

......

}

public ClassLoader getClassLoader() {

//返回system classloader

return loader;

}

}

 

extension classloader的部分代码:

static class Launcher$ExtClassLoader extends URLClassLoader {

 

public static Launcher$ExtClassLoader getExtClassLoader()

throws IOException

{

File afile[] = getExtDirs();

return(Launcher$ExtClassLoader)AccessController.doPrivileged(new Launcher$1(afile));

}

private static File[] getExtDirs() {

//获得系统属性“java.ext.dirs”

String s = System.getProperty("java.ext.dirs");

File afile[];

if(s != null) {

StringTokenizer stringtokenizer = new StringTokenizer(s,File.pathSeparator);

int i = stringtokenizer.countTokens();

afile = new File;

for(int j = 0; j < i; j++)

afile[j] = new File(stringtokenizer.nextToken());

 

} else {

afile = new File[0];

}

return afile;

}

}

 

system classloader的部分代码:

static class Launcher$AppClassLoader extends URLClassLoader

{

 

public static ClassLoader getAppClassLoader(ClassLoaderclassloader)

throws IOException

{

//获得系统属性“java.class.path”

String s = System.getProperty("java.class.path");

File afile[] = s != null ? Launcher.access$200(s) : newFile[0];

return(Launcher$AppClassLoader)AccessController.doPrivileged(new Launcher$2(s, afile,classloader));

}

}

 

看了源代码大家就清楚了吧,extension classloader是使用系统属性“java.ext.dirs”设置类搜索路径的,并且没有parent。system classloader是使用系统属性“java.class.path”设置类搜索路径的,并且有一个parentclassloader。Launcher初始化extensionclassloader,system classloader,并将system classloader设置成为context classloader,但是仅仅返回system classloader给JVM。

 

  这里怎么又出来一个context classloader呢?它有什么用呢?我们在建立一个线程Thread的时候,可以为这个线程通过setContextClassLoader方法来指定一个合适的classloader作为这个线程的context classloader,当此线程运行的时候,我们可以通过getContextClassLoader方法来获得此context classloader,就可以用它来载入我们所需要的Class。默认的是system classloader。利用这个特性,我们可以“打破”classloader委托机制了,父classloader可以获得当前线程的context classloader,而这个context classloader可以是它的子classloader或者其他的classloader,那么父classloader就可以从其获得所需的Class,这就打破了只能向父classloader请求的限制了。这个机制可以满足当我们的classpath是在运行时才确定,并由定制的 classloader加载的时候,由system classloader(即在jvm classpath中)加载的class可以通过context classloader获得定制的classloader并加载入特定的class(通常是抽象类和接口,定制的classloader中是其实现),例如web应用中的servlet就是用这种机制加载的.

 

 

好了,现在我们了解了classloader的结构和工作原理,那么我们如何实现在运行时的动态载入和更新呢?只要我们能够动态改变类搜索路径和清除classloader的cache中已经载入的Class就行了,有两个方案,一是我们继承一个classloader,覆盖 loadclass方法,动态的寻找Class文件并使用defineClass方法来;另一个则非常简单实用,只要重新使用一个新的类搜索路径来new 一个classloader就行了,这样即更新了类搜索路径以便来载入新的Class,也重新生成了一个空白的cache(当然,类搜索路径不一定必须更改)。噢,太好了,我们几乎不用做什么工作,java.netURLClassLoader正是一个符合我们要求的classloader!我们可以直接使用或者继承它就可以了!

 

这是j2se1.4 API的doc中URLClassLoader的两个构造器的描述:

URLClassLoader(URL[] urls)

Constructs a new URLClassLoader for the specified URLs usingthe default delegation parent ClassLoader.

URLClassLoader(URL[] urls, ClassLoader parent)

Constructs a new URLClassLoader for the given URLs.

其中URL[] urls就是我们要设置的类搜索路径,parent就是这个classloader的parent classloader,默认的是system classloader。

 

 

好,现在我们能够动态的载入Class了,这样我们就可以利用newInstance方法来获得一个Object。但我们如何将此Object造型呢?可以将此Object造型成它本身的Class吗?

 

首先让我们来分析一下java源文件的编译,运行吧!javac命令是调用“JAVA_HOME/lib/tools.jar”中的“com.sun.tools.javac.Main”的compile方法来编译:

 

public static int compile(String as[]);

 

public static int compile(String as[], PrintWriterprintwriter);

 

返回0表示编译成功,字符串数组as则是我们用javac命令编译时的参数,以空格划分。例如:

javac -classpath c:/foo/bar.jar;. -d c:/ c:/Some.java

则字符串数组as为{"-classpath","c://foo//bar.jar;.","-d","c://","c://Some.java"},如果带有PrintWriter参数,则会把编译信息出到这个指定的printWriter中。默认的输出是System.err。

 

其中Main是由JVM使用Launcher初始化的system classloader载入的,根据全盘负责原则,编译器在解析这个java源文件时所发现的它所依赖和引用的所有Class也将由system classloader载入,如果system classloader不能载入某个Class时,编译器将抛出一个“cannot resolve symbol”错误。

 

所以首先编译就通不过,也就是编译器无法编译一个引用了不在CLASSPATH中的未知Class的java源文件,而由于拼写错误或者没有把所需类库放到CLASSPATH中,大家一定经常看到这个“cannot resolve symbol”这个编译错误吧!

 

其次,就是我们把这个Class放到编译路径中,成功的进行了编译,然后在运行的时候不把它放入到CLASSPATH中而利用我们自己的 classloader来动态载入这个Class,这时候也会出现“java.lang.NoClassDefFoundError”的违例,为什么呢?

 

我们再来分析一下,首先调用这个造型语句的可执行的Class一定是由JVM使用Launcher初始化的system classloader载入的,根据全盘负责原则,当我们进行造型的时候,JVM也会使用system classloader来尝试载入这个Class来对实例进行造型,自然在system classloader寻找不到这个Class时就会抛出“java.lang.NoClassDefFoundError”的违例。

 

OK,现在让我们来总结一下,java文件的编译和Class的载入执行,都是使用Launcher初始化的system classloader作为类载入器的,我们无法动态的改变system classloader,更无法让JVM使用我们自己的classloader来替换system classloader,根据全盘负责原则,就限制了编译和运行时,我们无法直接显式的使用一个system classloader寻找不到的Class,即我们只能使用Java核心类库,扩展类库和CLASSPATH中的类库中的Class。

 

还不死心!再尝试一下这种情况,我们把这个Class也放入到CLASSPATH中,让system classloader能够识别和载入。然后我们通过自己的classloader来从指定的class文件中载入这个Class(不能够委托parent载入,因为这样会被system classloader从CLASSPATH中将其载入),然后实例化一个Object,并造型成这个Class,这样JVM也识别这个Class(因为 system classloader能够定位和载入这个Class从CLASSPATH中),载入的也不是CLASSPATH中的这个Class,而是从 CLASSPATH外动态载入的,这样总行了吧!十分不幸的是,这时会出现“java.lang.ClassCastException”违例。

 

为什么呢?我们也来分析一下,不错,我们虽然从CLASSPATH外使用我们自己的classloader动态载入了这个Class,但将它的实例造型的时候是JVM会使用system classloader来再次载入这个Class,并尝试将使用我们的自己的classloader载入的Class的一个实例造型为system classloader载入的这个Class(另外的一个)。大家发现什么问题了吗?也就是我们尝试将从一个classloader载入的Class的一个实例造型为另外一个classloader载入的Class,虽然这两个Class的名字一样,甚至是从同一个class文件中载入。但不幸的是JVM 却认为这个两个Class是不同的,即JVM认为不同的classloader载入的相同的名字的Class(即使是从同一个class文件中载入的)是不同的!这样做的原因我想大概也是主要出于安全性考虑,这样就保证所有的核心Java类都是system classloader载入的,我们无法用自己的classloader载入的相同名字的Class的实例来替换它们的实例。

 

看到这里,聪明的读者一定想到了该如何动态载入我们的Class,实例化,造型并调用了吧!

 

那就是利用面向对象的基本特性之一的多形性。我们把我们动态载入的Class的实例造型成它的一个system classloader所能识别的父类就行了!这是为什么呢?我们还是要再来分析一次。当我们用我们自己的classloader来动态载入这我们只要把这个Class的时候,发现它有一个父类Class,在载入它之前JVM先会载入这个父类Class,这个父类Class是systemclassloader所能识别的,根据委托机制,它将由system classloader载入,然后我们的classloader再载入这个Class,创建一个实例,造型为这个父类Class,注意了,造型成这个父类 Class的时候(也就是上溯)是面向对象的java语言所允许的并且JVM也支持的,JVM就使用system classloader再次载入这个父类Class,然后将此实例造型为这个父类Class。大家可以从这个过程发现这个父类Class都是由 system classloader载入的,也就是同一个classloader载入的同一个Class,所以造型的时候不会出现任何异常。而根据多形性,调用这个父类的方法时,真正执行的是这个Class(非父类 Class)的覆盖了父类方法的方法。这些方法中也可以引用system classloader不能识别的Class,因为根据全盘负责原则,只要载入这个Class的classloader即我们自己定义的 classloader能够定位和载入这些Class就行了。

 

这样我们就可以事先定义好一组接口或者基类并放入CLASSPATH中,然后在执行的时候动态的载入实现或者继承了这些接口或基类的子类。还不明白吗?让我们来想一想Servlet吧,web application server能够载入任何继承了Servlet的Class并正确的执行它们,不管它实际的Class是什么,就是都把它们实例化成为一个Servlet Class,然后执行Servlet的init,doPost,doGet和destroy等方法的,而不管这个Servlet是从web- inf/lib和web-inf/classes下由system classloader的子classloader(即定制的classloader)动态载入。说了这么多希望大家都明白了。在applet,ejb等容器中,都是采用了这种机制.

 

classloader虽然称为类加载器,但并不意味着只能用来加载Class,我们还可以利用它也获得图片,音频文件等资源的 URL,当然,这些资源必须在CLASSPATH中的jar类库中或目录下。我们来看API的doc中关于ClassLoader的两个寻找资源和 Class的方法描述吧: public URL getResource(String name) 用指定的名字来查找资源,一个资源是一些能够被class代码访问的在某种程度上依赖于代码位置的数据(图片,音频,文本等等)。

一个资源的名字是以'/'号分隔确定资源的路径名的。

这个方法将先请求parent classloader搜索资源,如果没有parent,则会在内置在虚拟机中的classloader(即bootstrap classloader)的路径中搜索。如果失败,这个方法将调用findResource(String)来寻找资源。

public static URL getSystemResource(String name)

从用来载入类的搜索路径中查找一个指定名字的资源。这个方法使用system class loader来定位资源。即相当于ClassLoader.getSystemClassLoader().getResource(name)。

 

例如:

System.out.println(ClassLoader.getSystemResource("java/lang/String.class"));

的结果为:

jar:file:/C:/j2sdk1.4.1_01/jre/lib/rt.jar!/java/lang/String.class

表明String.class文件在rt.jar的java/lang目录中。

因此可以将图片等资源随同Class一同打包到jar类库中(当然,也可单独打包这些资源)并添加它们到class loader的搜索路径中,我们就可以无需关心这些资源的具体位置,让classloader来帮我们寻找了!

 


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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值