Android 虚拟机与类加载机制

193 篇文章 9 订阅
107 篇文章 0 订阅

JVM与Dalvik

Android应用程序运行在Dalvik/ART虚拟机,并且每一个应用程序对应有一个单独的Dalvik虚拟机实例。Dalvik虚拟机实则也算是一个Java虚拟机,只不过它执行的不是class文件,而是dex文件。

Dalvik虚拟机与Java虚拟机共享有差不多的特性,差别在于两者执行的指令集是不一样的,前者的指令集是基本寄存器的,而后者的指令集是基于栈的。

在这里插入图片描述

基于栈的虚拟机(Stack-Based)

对于基于栈的虚拟机来说,每一个运行时的线程,都有一个独立的栈。栈中记录了方法调用的历史,每有一次方法调用,栈中便会多一个栈桢。最顶部的栈桢称作当前栈桢,其代表着当前执行的方法。基于栈的虚拟机通过栈帧中的操作数栈进行所有操作。
在这里插入图片描述
字节码指令:
在这里插入图片描述
ICONST_1 : 将int类型常量1压入操作数栈;
ISTORE 0 : 将栈顶int类型值存入局部变量0;
IADD : 执行int类型的加法 ;

执行过程:
在这里插入图片描述

基于寄存器的虚拟机(Register-Based)

什么是寄存器

寄存器是CPU的组成部分。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和位址。
在这里插入图片描述

基于寄存器的虚拟机

基于寄存器的虚拟机中没有操作数栈,但是有很多虚拟寄存器(用虚拟寄存器模拟CPU中的真实寄存器)。其实和操作数栈相同,这些寄存器也存放在虚拟机栈的栈帧中,本质上就是一个数组。与JVM相似,在Dalvik VM中每个线程都有自己的PC和虚拟机栈,方法调用的活动记录以栈帧为单位保存在虚拟机栈上(栈帧中没有了操作数栈和局部变量表,而是被虚拟寄存器替代)。

同样的test方法在基于寄存器的虚拟机中的执行过程:
在这里插入图片描述
与JVM版相比,可以发现Dalvik版程序的指令数明显减少了,数据移动次数也明显减少了。

Dalvik和ART虚拟机都是基于寄存器的虚拟机。

基于栈的虚拟机与基于寄存器的虚拟机的区别

1.指令条数:基于栈的虚拟机需要更多的指令,因为要在操作数栈和局部变量表中加载和操作数据,基于寄存器的虚拟机只在寄存器上操作数据。
2.可移植性:基于栈的虚拟机可移植性较强,对于不同的平台,例如 ARM,x86,x64 等,栈的概念是相同的,但是寄存器在不同的平台上,有着不同的实现。

ART 和 Dalvik

DVM也是实现了JVM规范的一个虚拟器,默认使用CMS垃圾回收器,但是与JVM运行 Class 字节码不同,DVM执行 Dex(Dalvik Executable Format) ——专为 Dalvik 设计的一种压缩格式。Dex 文件是很多 .class 文件处理后的产物,最终可以在 Android 运行时环境执行。

而ART(Android Runtime) 是在 Android 4.4 中引入的一个开发者选项,也是 Android 5.0 及更高版本的默认Android 运行时。ART 和 Dalvik 都是运行 Dex 字节码的兼容运行时,因此针对 Dalvik 开发的应用也能在 ART 环境中运作。

dexopt与dexaot

dexopt

在Dalvik中虚拟机在加载一个dex文件时,会用dexopt工具对 dex 文件进行验证和优化,其对 dex 文件的优化结果变成了 odex(Optimized dex) 文件,这个odex文件和 dex 文件很像,只是使用了一些优化操作码。

dex2oat (dalvik excutable file to optimized art file)

而Art下将应用的dex字节码翻译成本地机器码的最恰当AOT时机也就发生在应用安装的时候。ART 引入了预先编译机制(AOT,Ahead Of Time),在安装时,ART 使用设备自带的 dex2oat 工具对 dex 文件执行AOT提前编译操作,dex中的字节码将被编译成本地机器码。

ART将dex文件编译为OAT格式的可执行文件(OAT格式是ELF可执行文件格式的变种),OAT可执行文件里面是dex文件中的字节码编译后得到的本地机器码。

在这里插入图片描述

ART 和 Dalvik的区别

当apk在手机上运行时,JAVA虚拟机需要将dex文件转换成机器能识别的机器码(微处理器指令),如果每次执行一段代码都要执行这个过程(将dex转换成机器能识别的机器码的过程),然后交给系统处理,这样的效率不是很高。

为了解决这个问题,Google在2.2版本添加了JIT编译器,JIT是"Just In Time Compiler"的缩写,就是"即时编译技术",Dalvik虚拟机使用到JIT技术。当App运行时,JIT会对频繁执行的dex代码进行编译和优化,并将得到的机器码缓存在内存中,这样在下次执行到相同代码的时候,直接调用缓存中的机器码,速度就会更快。当然,如果你的“这段代码”被重复执行的次数非常少,那么JIT的效果会不太明显。

有一点需要注意,那就是JIT将dex字节码翻译成本地机器码是发生在应用程序的运行过程中的,并且应用程序每一次重新运行的时候,都要做重做这个翻译工作,所以这个工作并不是一劳永逸,每次重新打开App,运行代码的时候,都需要JIT编译(即JIT编译后得到的本地机器码是缓存在内存中的,不是磁盘中)。

JIT是在2.2版本提出的,目的是为了提高Android的运行速度,一直存活到4.4版本,因为在4.4之后两种运行时环境共存(JIT 和 ART),可以相互切换,但是在5.0+,Dalvik虚拟机则被彻底的丢弃,全部采用ART.

ART有个特性是AOT,AOT是"Ahead Of Time"的缩写,即在APK安装的时候就会做预编译,编译好的文件是OAT文件。

推出AOT的其中原因之一是JIT即时编译的缺陷。前面介绍过,JIT是运行时编译,这样可以对执行次数频繁的dex代码进行编译和优化,减少以后使用时的翻译时间,虽然可以加快Dalvik运行速度,但是还是有弊病,那就是将dex翻译为本地机器码也要占用时间,所以Google在4.4之后推出了ART,用来替换Dalvik。

ART的策略与Dalvik不同,在ART 环境中,应用在第一次安装的时候,字节码就会预先编译成机器码,使其成为真正的本地应用。之后打开App的时候,不需要额外的翻译工作,直接使用本地机器码运行,因此运行速度提高。

当然ART与Dalvik相比,还是有缺点的:
(1)机器码占用的存储空间更大,ART需要应用程序在安装时,就把字节码预编译为机器码,所以这会消耗掉更多的存储空间,但消耗掉空间的增幅通常不会超过应用代码包大小的20%
(3)由于有了一个转码的过程,所以应用的安装时间会变长,

总结:

  1. Dalvik使用JIT技术,在Dalvik下,应用每次启动运行时都要通过JIT将字节码编译为机器码;ART使用AOT技术,在安装应用程序时,就把字节码预编译为机器码。
  2. ART在安装应用程序时,就把字节码预编译为机器码,这会消耗掉更多的存储空间,应用安装时间也会延长。
  3. ART在运行时执行的是本地机器码,因此运行速度提高。

ART与Dalvik更多介绍可以参照官网介绍:https://source.android.google.cn/devices/tech/dalvik/

Android N(Android 7.x)的运作方式

ART 使用预先 (AOT) 编译,并且 Android N引入了一种包含AOT预先编译,解释和JIT即时编译的混合运行时,以便在安装时间、内存占用、电池消耗和性能之间获得最好的折衷。
1、最初安装应用时不进行任何 AOT 编译(安装又快了),运行过程中解释执行,对经常执行的方法进行JIT,经过 JIT 编译的方法将会记录到Profile配置文件中(Profile配置文件中记录的不是代码,而是一些描述信息)。
2、当设备闲置和充电时,编译守护进程会运行,根据Profile文件对常用代码进行 AOT 编译。待下次运行时直接使用。

在这里插入图片描述

https://source.android.google.cn/devices/tech/dalvik/configure#how_art_works

ClassLoader介绍

在这里插入图片描述

任何一个 Java 程序都是由一个或多个 class 文件组成,在程序运行时,需要将 class 文件加载到 JVM 中才可以使用,负责加载这些 class 文件的就是 Java 的类加载机制。ClassLoader 的作用简单来说就是加载 class 文件,提供给程序运行时使用。每个 Class 对象的内部都有一个 classLoader 字段来标识自己是由哪个 ClassLoader 加载的。

class Class<T> { 
	... 
	private transient ClassLoader classLoader; 
	... 
}

ClassLoader是一个抽象类,而它的具体实现类主要有:

  • BootClassLoader
    用于加载Android Framework层class文件。
  • PathClassLoader
    用于Android应用程序类加载器。可以加载指定的dex,以及jar、zip、apk中的classes.dex
  • DexClassLoader
    用于加载指定的dex,以及jar、zip、apk中的classes.dex

很多博客里说PathClassLoader只能加载已安装的apk的dex,其实这说的应该是在dalvik虚拟机上。但现在一般不用关心dalvik了。

Log.e(TAG, "Activity.class 由:" + Activity.class.getClassLoader() +" 加载"); 
Log.e(TAG, "MainActivity.class 由:" + getClassLoader() +" 加载"); 

//输出: 
Activity.class 由:java.lang.BootClassLoader@d3052a9 加载 
MainActivity.class 由:dalvik.system.PathClassLoader[DexPathList[[zip file "/data/app/com.enjoy.enjoyfix-1/base.apk"],nativeLibraryDirectories= [/data/app/com.enjoy.enjoyfix-1/lib/x86, /system/lib, /vendor/lib]]] 加载

它们之间的关系如下:
在这里插入图片描述
PathClassLoader 与 DexClassLoader 的共同父类是 BaseDexClassLoader 。

public class DexClassLoader extends BaseDexClassLoader {

    public DexClassLoader(String dexPath, String optimizedDirectory,
            String libraryPath, ClassLoader parent) {
        super(dexPath, new File(optimizedDirectory), libraryPath, parent);
    }
}

public class PathClassLoader extends BaseDexClassLoader {
 
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

 
    public PathClassLoader(String dexPath, String libraryPath,
            ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
}

可以看到两者唯一的区别在于:创建 DexClassLoader 需要传递一个 optimizedDirectory 参数,并且会将其创建为 File 对象传给 super ,而 PathClassLoader 则直接给到null。因此两者都可以加载指定的dex,以及jar、zip、apk中的classes.dex

PathClassLoader pathClassLoader = new PathClassLoader("/sdcard/xx.dex", getClassLoader()); 
File dexOutputDir = context.getCodeCacheDir(); 
DexClassLoader dexClassLoader = new DexClassLoader("/sdcard/xx.dex",dexOutputDir.getAbsolutePath(), null,getClassLoader());

其实, optimizedDirectory 参数就是dexopt的产出目录(odex)。那 PathClassLoader 创建时,这个目录为null,就意味着不进行dexopt?并不是, optimizedDirectory 为null时的默认路径为:/data/dalvik-cache。

在API 26源码中,将DexClassLoader的optimizedDirectory标记为了 deprecated 弃用,实现也变为了:

public DexClassLoader(String dexPath, String optimizedDirectory, 
						String librarySearchPath, ClassLoader parent) { 
	super(dexPath, null, librarySearchPath, parent); 
}

和PathClassLoader一模一样了!

PathClassLoader 什么时候创建

在Zygote进程初始化的时候创建:

//ZygoteInit.java


    /**
     * Finish remaining work for the newly forked system server process.
     */
    private static Runnable handleSystemServerProcess(ZygoteConnection.Arguments parsedArgs) {
       
       ...
       
        } else {
            ClassLoader cl = null;
            if (systemServerClasspath != null) {
                cl = createPathClassLoader(systemServerClasspath, parsedArgs.targetSdkVersion);

                Thread.currentThread().setContextClassLoader(cl);
            }

            /*
             * Pass the remaining arguments to SystemServer.
             */
            return ZygoteInit.zygoteInit(parsedArgs.targetSdkVersion, parsedArgs.remainingArgs, cl);
        }

        /* should never reach here */
    }


    /**
     * Creates a PathClassLoader for the given class path that is associated with a shared
     * namespace, i.e., this classloader can access platform-private native libraries. The
     * classloader will use java.library.path as the native library path.
     */
    static ClassLoader createPathClassLoader(String classPath, int targetSdkVersion) {
        String libraryPath = System.getProperty("java.library.path");

        return ClassLoaderFactory.createClassLoader(classPath, libraryPath, libraryPath,
                ClassLoader.getSystemClassLoader(), targetSdkVersion, true /* isNamespaceShared */,
                null /* classLoaderName */);
    }
//ClassLoaderFactory.java


/**
 * Creates class loaders.
 *
 * @hide
 */
public class ClassLoaderFactory {
    // Unconstructable
    private ClassLoaderFactory() {}

    private static final String PATH_CLASS_LOADER_NAME = PathClassLoader.class.getName();
    private static final String DEX_CLASS_LOADER_NAME = DexClassLoader.class.getName();
    private static final String DELEGATE_LAST_CLASS_LOADER_NAME =
            DelegateLastClassLoader.class.getName();

    /**
     * Returns true if {@code name} is a supported classloader. {@code name} must be a
     * binary name of a class, as defined by {@code Class.getName}.
     */
    public static boolean isValidClassLoaderName(String name) {
        // This method is used to parse package data and does not accept null names.
        return name != null && (isPathClassLoaderName(name) || isDelegateLastClassLoaderName(name));
    }

    /**
     * Returns true if {@code name} is the encoding for either PathClassLoader or DexClassLoader.
     * The two class loaders are grouped together because they have the same behaviour.
     */
    public static boolean isPathClassLoaderName(String name) {
        // For null values we default to PathClassLoader. This cover the case when packages
        // don't specify any value for their class loaders.
        return name == null || PATH_CLASS_LOADER_NAME.equals(name) ||
                DEX_CLASS_LOADER_NAME.equals(name);
    }

    /**
     * Returns true if {@code name} is the encoding for the DelegateLastClassLoader.
     */
    public static boolean isDelegateLastClassLoaderName(String name) {
        return DELEGATE_LAST_CLASS_LOADER_NAME.equals(name);
    }

    /**
     * Same as {@code createClassLoader} below, except that no associated namespace
     * is created.
     */
    public static ClassLoader createClassLoader(String dexPath,
            String librarySearchPath, ClassLoader parent, String classloaderName) {
        if (isPathClassLoaderName(classloaderName)) {
            return new PathClassLoader(dexPath, librarySearchPath, parent);
        } else if (isDelegateLastClassLoaderName(classloaderName)) {
            return new DelegateLastClassLoader(dexPath, librarySearchPath, parent);
        }

        throw new AssertionError("Invalid classLoaderName: " + classloaderName);
    }

    /**
     * Create a ClassLoader and initialize a linker-namespace for it.
     */
    public static ClassLoader createClassLoader(String dexPath,
            String librarySearchPath, String libraryPermittedPath, ClassLoader parent,
            int targetSdkVersion, boolean isNamespaceShared, String classloaderName) {

        final ClassLoader classLoader = createClassLoader(dexPath, librarySearchPath, parent,
                classloaderName);

        boolean isForVendor = false;
        for (String path : dexPath.split(":")) {
            if (path.startsWith("/vendor/")) {
                isForVendor = true;
                break;
            }
        }
        Trace.traceBegin(Trace.TRACE_TAG_ACTIVITY_MANAGER, "createClassloaderNamespace");
        String errorMessage = createClassloaderNamespace(classLoader,
                                                         targetSdkVersion,
                                                         librarySearchPath,
                                                         libraryPermittedPath,
                                                         isNamespaceShared,
                                                         isForVendor);
        Trace.traceEnd(Trace.TRACE_TAG_ACTIVITY_MANAGER);

        if (errorMessage != null) {
            throw new UnsatisfiedLinkError("Unable to create namespace for the classloader " +
                                           classLoader + ": " + errorMessage);
        }

        return classLoader;
    }

    private static native String createClassloaderNamespace(ClassLoader classLoader,
                                                            int targetSdkVersion,
                                                            String librarySearchPath,
                                                            String libraryPermittedPath,
                                                            boolean isNamespaceShared,
                                                            boolean isForVendor);
}

双亲委托机制

可以看到创建 ClassLoader 需要接收一个 ClassLoader parent 参数。这个 parent 的目的就在于实现类加载的双亲委托。即:某个类加载器在接到加载类的请求时,首先将加载任务委托给父类加载器,依次递归,如果父类加载器可以完成类加载任务,就成功返回;只有父类加载器无法完成此加载任务时,才自己去加载。

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
            // First, check if the class has already been loaded
            //先是判断ClassLoader自身是否加载过该class文件
            Class c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                    	//如果父加载器parent不为null,则调用parent的loadClass进行加载
                        c = parent.loadClass(name, false);
                    } else {
                   		//如果parent为null,则调用findBootstrapClassOrNull()方法进行加载
                        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
                }
            }
            return c;
    }

可以看到,先是判断ClassLoader自身是否加载过该class文件,如果没有再判断父ClassLoader是否加载过,如果都没有加载过再自己去加载。这和我们上述的双亲委派模型思想完全一致。

因此我们自己创建的ClassLoader: new PathClassLoader("/sdcard/xx.dex", getClassLoader()); 并不仅仅只能加载 xx.dex中的class。

上述代码中值得注意的是: c = findBootstrapClassOrNull(name); 这句代码,按照方法名理解,应该是当parent为null时候,也能够加载 BootClassLoader 加载的类。
那么new PathClassLoader("/sdcard/xx.dex", null) ,能否加载Activity.class?
但是实际上,Android当中的实现为:

    /**
     * Returns a class loaded by the bootstrap class loader;
     * or return null if not found.
     */
    private Class<?> findBootstrapClassOrNull(String name)
    {
        return null;
    }

,与Java中的实现不同。因此,当parent为null时候,并不能够加载 BootClassLoader 加载的类,也就是并不能加载到Activity.class。

双亲委托机制的好处

1、避免重复加载,当父加载器已经加载了该类的时候,就没有必要子ClassLoader再加载一次。

2、安全性考虑,防止核心API库被随意篡改。比如自定义了一个jang/lang/String类,并不能被类加载器加载到,类加载器只能加载到系统的jang/lang/String类。

findClass

可以看到在所有父ClassLoader无法加载Class时,则会调用自己的 findClass 方法。 findClass 在ClassLoader中的定义为:

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

其实任何ClassLoader子类,都可以重写 loadClass 与 findClass 。一般如果你不想使用双亲委托,则重写loadClass 修改其实现。而重写 findClass 则表示在双亲委托下,父ClassLoader都找不到Class的情况下,定义自己如何去查找一个Class。而我们的 PathClassLoader 会自己负责加载 MainActivity 这样的程序中自己编写的类,利用双亲委托父ClassLoader加载Framework中的 Activity 。说明 PathClassLoader 并没有重写loadClass ,因此我们可以来看看PathClassLoader中的 findClass 是如何实现的。

public class PathClassLoader extends BaseDexClassLoader {
 
    public PathClassLoader(String dexPath, ClassLoader parent) {
        super(dexPath, null, null, parent);
    }

 
    public PathClassLoader(String dexPath, String libraryPath,
            ClassLoader parent) {
        super(dexPath, null, libraryPath, parent);
    }
}

它的源码也只是有两个构造方法,我们来看第二个构造方法,可以看出,它与DexClassLoader的构造方法的区别就是少了一个要把dex文件copy到的目录路径。正是因为缺少这个路径,我们的PathClassLoader只能用来加载安装过的apk中的dex文件。
这两个ClassLoader的真正核心方法都在BaseDexClassLoader中,我们现在来看下源码。

public class BaseDexClassLoader extends ClassLoader {
    private final DexPathList pathList;

    public BaseDexClassLoader(String dexPath, File optimizedDirectory,
            String librarySearchPath, ClassLoader parent) {
        super(parent);
        this.pathList = new DexPathList(this, dexPath, librarySearchPath, optimizedDirectory);
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        List<Throwable> suppressedExceptions = new ArrayList<Throwable>();
        Class c = pathList.findClass(name, suppressedExceptions);
        if (c == null) {
            ClassNotFoundException cnfe = new ClassNotFoundException("Didn't find class \"" + name + "\" on path: " + pathList);
           for (Throwable t : suppressedExceptions) {
                cnfe.addSuppressed(t);
            }
            throw cnfe;
        }
        return c;
    }
	
	...

}

可以看到,这个类的实现也是比较简单,有一个成员变量DexPathList,

   private final DexPathList pathList;

BaseDexClassLoader的findClass方法就是通过成员变量pathList的findClass()方法来查找类的的。看DexPathList的源码。

public DexPathList(ClassLoader definingContext, String dexPath, String librarySearchPath, File optimizedDirectory) { 
	
	//......... 
	
	// splitDexPath 实现为返回 List<File>.add(dexPath) 
	// makeDexElements 会去 List<File>.add(dexPath) 中使用DexFile加载dex文件返回 Element数组 
	this.dexElements = makeDexElements(splitDexPath(dexPath), optimizedDirectory, suppressedExceptions, definingContext); 
	
	//......... 

}


public Class findClass(String name, List<Throwable> suppressed) {
        for (Element element : dexElements) {
            DexFile dex = element.dexFile;

            if (dex != null) {
                Class clazz = dex.loadClassBinaryName(name, definingContext, suppressed);
                if (clazz != null) {
                    return clazz;
                }
            }
        }
        if (dexElementsSuppressedExceptions != null) {
            suppressed.addAll(Arrays.asList(dexElementsSuppressedExceptions));
        }
        return null;
}


它就是通过遍历elements数组,拿到里面的每一个dex文件,通过DexFile的loadClassBinaryName()方法找到class字节码。通过查看DexFile源码,可以得知loadClassBinaryName()方法最终是调用的底层C++方法来load class。

在这里插入图片描述

热修复

PathClassLoader 中存在一个Element数组,Element类中存在一个dexFile成员表示dex文件,即:APK中有X个dex,则Element数组就有X个元素。
在这里插入图片描述
在 PathClassLoader 中的Element数组为:[patch.dex , classes.dex , classes2.dex]。如果存在Key.class位于patch.dex与classes2.dex中都存在一份,当进行类查找时,循环获得 dexElements 中的DexFile,查找到了Key.class则立即返回,不会再管后续的element中的DexFile是否能加载到Key.class了。

因此实际上,一种热修复实现可以将出现Bug的class单独的制作一份fix.dex文件(补丁包),然后在程序启动时,从服务器下载fix.dex保存到某个路径,再通过fix.dex的文件路径,用其创建 Element 对象,然后将这个 Element 对象插入到我们程序的类加载器 PathClassLoader 的 pathList 中的 dexElements 数组头部。这样在加载那个出现Bug的class时会优先加载fix.dex中的修复类,从而解决Bug。

热修复流程:
1.获取当前应用的类加载器PathClassLoader
2.反射获取PathClassLoader的DexPathList属性对象pathList
3.反射修改pathList的dexElements
1)把补丁包patch.dex转为Element[]数组(patch)
2)反射获取pathList的dexElements属性(old)
3)合并old+patch=result,将结果反射赋值给pathList的dexElements。

【Android热修复与插件化 三】ClassLoader详解
Android N混合使用AOT编译,解释和JIT三种运行时
JAVA虚拟机、Dalvik虚拟机和ART虚拟机简要对照
Android动态加载之ClassLoader详解

基于栈的虚拟机 VS 基于寄存器的虚拟机

https://www.androidos.net.cn/android/9.0.0_r8/xref/frameworks/base/core/java/com/android/internal/os/ZygoteInit.java
https://www.androidos.net.cn/android/8.0.0_r4/xref/frameworks/base/core/java/com/android/internal/os/PathClassLoaderFactory.java

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值