写自己的JVM(0x6)- 方法调用与参数传递

本文详细介绍了Java虚拟机中几种方法调用指令的实现原理,包括invokestatic、invokespecial、invokevirtual、invokeinterface和invokedynamic,以及它们在静态分派和动态分派中的作用。同时讨论了非虚方法和虚方法的概念以及vtable的运用。

​原始博客的地址:

https://lyldalek.notion.site/JVM-0x6-fa280975b6f947e3bb8077d3e1685b5b

该项目的地址:

https://github.com/aprz512/write-your-own-jvm

前面我们基本完成了方法区的实现,现在我们需要搞定方法的调用。

在Java虚拟机支持以下5条方法调用字节码指令,分别是:

  • invokestatic:用于调用静态方法。

  • invokespecial:用于调用实例构造器()方法、私有方法和父类中的方法。

  • invokevirtual:用于调用所有的虚方法。

  • invokeinterface:用于调用接口方法,会在运行时再确定一个实现该接口的对象。

  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。这个比较麻烦,我们后面单独开一篇来说。

只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。

invokestatic

假定解析符号引用后得到方法M。M必须是静态方法,否则抛出Incompatible-ClassChangeError异常。M不能是类初始化方法。类初始化方法只能由Java虚拟机调用,不能使用invokestatic指令调用。这一规则由class文件验证器保证,这里不做检查。如果声明M的类还没有被初始化,则要先初始化该类。

代码实现:

  @Override
    public void execute(StackFrame frame) {
        ConstantPool constantPool = frame.getMyMethod().getMyClass().getConstantPool();
        ConstantPool.Constant constant = constantPool.getConstant(operand);
        MethodRef methodRef = (MethodRef) constant.value;
        MyMethod resolvedMethod = methodRef.getResolvedMethod();
        if (!resolvedMethod.isStatic()) {
            throw new MyJvmException("java.lang.IncompatibleClassChangeError");
        }
        MyClass resolvedClass = methodRef.getResolvedClass();
        if (!resolvedClass.isInitStarted()) {
            frame.revertPc();
            ClassInit.initMyClass(resolvedClass, frame.getThread());
            return;
        }

        InvokeMethod.invokeMethod(frame, resolvedMethod);
    }

这里我们封装了一些方法调用的逻辑 InvokeMethod ,它主要是处理栈帧与参数的传递。

参数传递

当调用一个方法的时候,所以我们根据方法的 descriptor 算出参数的个数,由于解析比较麻烦,具体可看 MethodDescriptorParser里面的逻辑,简单来说就是分割字符串。举个例子,对于下面的方法:

public static void func(int a, String b, double c, float[] d) {

}

编译后的 descriptor 是 (ILjava/lang/String;D[F)V 。将这个字符串分割后,就可以拿到方法的参数类型与个数,也可以拿到返回值类型。

参数的传递过程,其实就是将调用者栈帧的操作数栈中 pop 出数据,放入到被调用者栈帧的局部变量表中:

计算的时候,注意区分以下静态方法与实例方法,因为实例方法是有隐藏的 this 参数的,所以它的参数个数需要加1。

将参数放入被调用方法的局部变量表中,被调用方法就能正确使用吗?回想一下我们实现解释器的时候,有一些指令都是从局部变量表中取出值来使用,所以参数会被正确使用,当然这里面有一半的功劳要给编译器。

代码实现:

  public static void invokeMethod(StackFrame invokerFrame, MyMethod method) {
        MyThread thread = invokerFrame.getThread();
        StackFrame invokedFrame = thread.newStackFrame(method);
        thread.pushStackFrame(invokedFrame);
        int argSlotCount = method.getArgsSlotCount();
        if (argSlotCount > 0) {
            // 操作数栈上的参数:
            // arg3
            // arg2
            // arg1
            // 传递给调用的方法需要按照 index 对应,第一个参数设置到 index 为 0 的位置
            // 实例方法,通常 index 为 0 的位置是 this
            for (int i = argSlotCount - 1; i >= 0; i--) {
                Slot slot = invokerFrame.getOperandStack().popSlot();
                invokedFrame.getLocalVariableTable().setSlot(i, slot);
            }
        }
    }

里面除了参数传递之外,额外做了 一件事情,就是创建被调用方法的栈帧

invokespecial

invokespecial 与 invokestatic 类似,它调用的方法是在解析的时候就确定的,不需要等到运行时再确定。所以,其实现逻辑与invokestatic 也差不多。

public class InvokeSpecial extends Operand1Instruction {

    public InvokeSpecial(CodeReader reader) {
        super(reader);
    }

    @Override
    public int getOpCode() {
        return 0xb7;
    }

    @Override
    public void execute(StackFrame frame) {
        ConstantPool constantPool = frame.getMyMethod().getMyClass().getConstantPool();
        ConstantPool.Constant constant = constantPool.getConstant(operand);
        MethodRef methodRef = (MethodRef) constant.value;
        MyMethod resolvedMethod = methodRef.getResolvedMethod();
        MyClass resolvedClass = methodRef.getResolvedClass();
        MyClass currentClass = frame.getMyMethod().getMyClass();

        // ..., objectref, [arg1, [arg2 ...]] →
        OperandStack operandStack = frame.getOperandStack();
        MyObject ref = operandStack.getRefFromTop(resolvedMethod.getArgsSlotCount() - 1);
        if (ref == null) {
            throw new MyJvmException("java.lang.NullPointerException");
        }

        // 确保protected方法只能被声明该方法的类或子类调用
        if (resolvedMethod.isProtected()
                && resolvedClass.isSuperClassOf(currentClass)
                && !resolvedClass.getPackageName().equals(currentClass.getPackageName())
                && ref.getMyClass() != currentClass
                && !ref.getMyClass().isSubClassOf(currentClass)) {
            throw new MyJvmException("java.lang.IllegalAccessError");
        }

        MyMethod lookupMethod = resolvedMethod;
        if (currentClass.isSuper() && resolvedClass.isSuperClassOf(currentClass)
                && !resolvedMethod.getName().equals("<init>")) {
            lookupMethod = MethodRef.lookupMethodInClass(currentClass.getSuperClass(), resolvedMethod.getName(), resolvedMethod.getDescriptor());
        }
        if (lookupMethod == null || lookupMethod.isAbstract()) {
            throw new MyJvmException("java.lang.AbstractMethodError");
        }
        InvokeMethod.invokeMethod(frame, lookupMethod);
    }

    @Override
    public String getReadableName() {
        return "invokespecial";
    }

    @Override
    protected int readOperand(CodeReader reader) {
        return reader.readUnsignedShort();
    }
}

相比invokestatic 来说,多了一些校验代码,做了一些条件检查,为啥要做这些条件检查呢?我们可以看看官方文档的说明,截取一个片段:

invokevirtual

这个指令就比较麻烦了,涉及到一个叫做“分派”的概念。分派又分为两种,一种是静态分派,一种是动态分派。

静态分派

看一个例子:

public class StaticDispatch {

    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }

    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sr = new StaticDispatch();
        sr.sayHello(man);
        sr.sayHello(woman);
    }

}

直接看编译后的字节码:

可以看到mian方法中调用 sayHello 时,编译器选择的是 Human 作为参数的函数。为啥会这样呢?

我们把上面代码中的“Human”称为变量的“静态类型”(Static Type),把后面的“Man”则被称为变量的“实际类型”(Actual Type)。虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。

重载方法还有优先级一说,比如一个参数是 int,一个参数是short,这个时候该选择哪个函数就需要一些规则来判断。具体的内容可以自行查询。

动态分派

动态分派与Java语言多态性的另外一个重要特性——重写(Override)有着很密切的关联。看例子:

public class DynamicDispatch {

    static abstract class Human {
        protected abstract void sayHello();
    }

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("man say hello");
        }
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("woman say hello");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }

}

看编译后的字节码:

发现三个 sayHello 方法的调用都是 Human 方法的。但是我们知道,这肯定是不对的。sayHello 的输出是与实际的调用对象有关的。

静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时产生了不同的行为。这是如何做到的呢?我们需要从 invokevirtual 指令入手:

根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步:

  1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

  2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果 通过则返回这个方法的直接引用,查找过程结束;不通过则返回java.lang.IllegalAccessError异常。

  3. 否则,按照继承关系从下往上依次对C的各个父类进行第二步的搜索和验证过程。

  4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

再看上面的字节码图,第16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,也就是 C。由于C重写了 sayHello 方法,所以我们就可以将重写后的方法作为目标方法进行调用,这样就调用到了不同对象上的方法。

因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。

代码实现

  @Override
    public void execute(StackFrame frame) {
        ConstantPool constantPool = frame.getMyMethod().getMyClass().getConstantPool();
        ConstantPool.Constant constant = constantPool.getConstant(operand);
        MethodRef methodRef = (MethodRef) constant.value;
        MyMethod resolvedMethod = methodRef.getResolvedMethod();
        MyClass resolvedClass = methodRef.getResolvedClass();
        MyClass currentClass = frame.getMyMethod().getMyClass();

        if (resolvedMethod.isStatic()) {
            throw new MyJvmException("java.lang.IncompatibleClassChangeError");
        }

        OperandStack operandStack = frame.getOperandStack();
        MyObject ref = operandStack.getRefFromTop(resolvedMethod.getArgsSlotCount() - 1);
        if (ref == null) {
            // System.out.println hack
            if ("println".equals(methodRef.getName())) {
                print(methodRef, operandStack);
                operandStack.popRef();
                return;
            }
            throw new MyJvmException("java.lang.NullPointerException");
        }

        // 确保protected方法只能被声明该方法的类或子类调用
        if (resolvedMethod.isProtected()
                && resolvedClass.isSuperClassOf(currentClass)
                && !resolvedClass.getPackageName().equals(currentClass.getPackageName())
                && ref.getMyClass() != currentClass
                && !ref.getMyClass().isSubClassOf(currentClass)) {
            throw new MyJvmException("java.lang.IllegalAccessError");
        }

        MyMethod lookupMethod = MethodRef.lookupMethodInClass(ref.getMyClass(), resolvedMethod.getName(), resolvedMethod.getDescriptor());
        if (lookupMethod == null || lookupMethod.isAbstract()) {
            throw new MyJvmException("java.lang.AbstractMethodError");
        }
        InvokeMethod.invokeMethod(frame, lookupMethod);

    }

其核心逻辑就在 MethodRef.lookupMethodInClass 里面,它会从下往上的从类的继承结构里面找匹配的方法,找到就返回。这里就又不得不提到另外一个知识点,就是 vtable。我们会发现,每次执行一个invokevirtual 指令的时候,都需要从下往上的寻找匹配方法,这岂不是显得很傻逼,所以呢我们可以做一个方法表。在我们的实现里面,方法表其实就可以看成一个缓存来实现,或者参考虚拟机规范来实现。但是项目里面并没有实现这个vtable逻辑,有兴趣的可以pr。

invokeinterface

和其他三条方法调用指令略有不同,在字节码中,invokeinterface指令的操作码后面跟着4字节而非2字节。前两字节的含义和其他指令相同,是个uint16运行时常量池索引。第3字节的值是给方法传递参数需要的slot数,其含义和给Method结构体定义的argSlotCount字段相同。这个数是可以根据方法描述符计算出来的,它的存在仅仅是因为历史原因。第4字节是留给Oracle的某些Java虚拟机实现用的,它的值必须是0。该字节的存在是为了保证Java虚拟机可以向后兼容。

  public InvokeInterface(CodeReader codeReader) {
        index = codeReader.readUnsignedShort();
        // must not be zero
        count = codeReader.readUnsignedByte();
        // must always be zero
        zero = codeReader.readByte();
    }

具体的核心执行逻辑其实是与 invokevirtual 一样的,只不过校验的东西不一样:

  @Override
    public void execute(StackFrame frame) {
        ConstantPool constantPool = frame.getMyMethod().getMyClass().getConstantPool();
        ConstantPool.Constant constant = constantPool.getConstant(index);
        InterfaceMethodRef methodRef = (InterfaceMethodRef) constant.value;
        MyMethod resolvedMethod = methodRef.getResolvedInterfaceMethod();
        MyClass resolvedClass = methodRef.getResolvedClass();

        if (resolvedMethod.isStatic() || resolvedMethod.isPrivate()) {
            throw new MyJvmException("java.lang.IncompatibleClassChangeError");
        }

        OperandStack operandStack = frame.getOperandStack();
        MyObject ref = operandStack.getRefFromTop(resolvedMethod.getArgsSlotCount() - 1);
        if (ref == null) {
            throw new MyJvmException("java.lang.NullPointerException");
        }

        if (!ref.getMyClass().isImplement(resolvedClass)) {
            throw new MyJvmException("java.lang.IncompatibleClassChangeError");
        }

        MyMethod lookupMethod = MethodRef.lookupMethodInClass(ref.getMyClass(), methodRef.getName(), methodRef.getDescriptor());
        if (lookupMethod == null || lookupMethod.isAbstract()) {
            throw new MyJvmException("java.lang.AbstractMethodError");
        }

        if (!lookupMethod.isPublic()) {
            throw new MyJvmException("java.lang.IllegalAccessError");
        }
        InvokeMethod.invokeMethod(frame, lookupMethod);
    }

那么为什么要单独定义invokeinterface指令呢?统一使用invokevirtual指令不行吗?答案是,可以,但是可能会影响效率。这两条指令的区别在于:当Java虚拟机通过invokevirtual调用方法时,this引用指向某个类(或其子类)的实例。因为类的继承层次是固定的,所以虚拟机可以使用一种叫作vtable的技术加速方法查找。但是当通过invokeinterface指令调用接口方法时,因为this引用可以指向任何实现了该接口的类的实例,所以无法使用vtable技术。

测试

public class Test07 implements Runnable {
    public static void main(String[] args) {
        new Test07().test();
    }

    public static void staticMethod() {
//        System.out.println("staticMethod");
    }

    public void test() {
        Test07.staticMethod(); // invokestatic
        Test07 demo = new Test07(); // invokespecial
        demo.instanceMethod(); // invokespecial
        super.equals(null); // invokespecial
        this.run(); // invokevirtual
        ((Runnable) demo).run(); // invokeinterface
    }

    private void instanceMethod() {
//        System.out.println("instanceMethod");
    }

    @Override
    public void run() {
//        System.out.println("run");
    }

}

程序编译无异常。再测试另外一个程序:

public class Test072 {
    public static void main(String[] args) {
        long x = fibonacci(30);
        System.out.println(x);
    }

    private static long fibonacci(long n) {
        if (n <= 1) {
            return n;
        }
        return fibonacci(n - 1) + fibonacci(n - 2);
    }

    // 0 1 1 2 3 5 8 13

}

输出:

没有问题!!!

<think>嗯,用户现在想了解如何在JNI中调用DataCallback的onData方法。首先,我需要回忆一下JNI的基本知识。JNI是Java本地接口,允许Java代码和本地代码(如C/C++)互相调用。用户提到的DataCallback应该是一个Java接口,而onData是其中的方法。 首先,我需要确定用户的具体需求。他们可能正在开发一个Android应用,需要从本地代码(比如C++)触发Java层的回调方法。这种情况下,正确使用JNI来调用Java方法就很重要了。 接下来,我需要考虑调用Java方法的基本步骤。通常,这包括获取类引用、方法ID,然后使用JNI函数进行调用。对于非静态方法,需要有一个对象实例。如果是接口方法,可能还需要确认该对象是否已经正确实现接口。 用户提供的例子可能涉及到回调,所以可能是在本地代码中持有Java对象的引用,然后在适当的时候调用其onData方法。这里需要注意的是JNI环境的正确获取,尤其是在多线程情况下,可能需要附加到JVM线程。 另外,要考虑异常处理。JNI调用可能会抛出异常,必须在本地代码中检查并处理,否则可能导致崩溃。例如,在调用方法后使用ExceptionCheck来查看是否有异常发生。 还有内存管理的问题。本地代码中创建的局部引用需要及时删除,避免内存泄漏。特别是当在循环中创建大量局部引用时,应该使用DeleteLocalRef或者确保在合适的时机释放。 用户可能还需要传递参数给onData方法。假设onData方法有一个byte数组作为参数,那么在本地代码中需要构造一个jbyteArray对象,并将其传递方法。这涉及到如何将本地数据转换为Java数组,并正确管理其内存。 另外,考虑性能优化。频繁的JNI调用可能影响性能,特别是在处理大量数据时。可能需要批量处理数据,减少JNI调用的次数,或者使用直接缓冲区等方法提高效率。 还有,回调接口的生命周期管理。确保在Java对象被回收后,本地代码不再持有其引用,避免野指针或无效引用导致的崩溃。可能需要使用GlobalRef来维持长期引用,并在不再需要时释放。 用户可能遇到的问题包括方法签名错误、未能正确获取方法ID、对象引用错误等。需要指导他们如何正确获取方法签名,例如使用javap工具获取准确的方法签名,避免手动编出错。 此外,跨线程调用时需要注意JNIEnv的正确使用,因为每个线程的JNIEnv是不同的。需要使用AttachCurrentThread获取当前线程的JNIEnv,并在完成后Detach,特别是在多线程环境中。 最后,可能需要提供一个完整的示例代码,展示如何从JNI调用onData方法,包括获取类、方法ID、调用方法以及异常处理和资源释放的步骤。这有助于用户更直观地理解和应用。</think>--- 在 JNI(Java Native Interface)中调用 Java 的 `DataCallback` 接口的 `onData` 方法,需要 **获取方法 ID、构造参数并触发回调**。以下是详细步骤及注意事项: --- ### **1. 定义 Java 回调接口** 假设 `DataCallback` 是 Java 层的接口,定义如下: ```java public interface DataCallback { void onData(byte[] data, int length); // 示例方法参数 } ``` --- ### **2. JNI 调用步骤** #### **步骤 1:获取回调类和方法 ID** 在本地代码(C/C++)中,首先获取 `DataCallback` 类和 `onData` 方法 ID: ```cpp // 全局缓存(避免重复查找) jclass g_dataCallbackClass = nullptr; jmethodID g_onDataMethodID = nullptr; JNIEXPORT void JNICALL Java_com_example_NativeLib_init(JNIEnv* env, jobject thiz, jobject callback) { // 获取 DataCallback 类引用 jclass localClass = env->GetObjectClass(callback); g_dataCallbackClass = (jclass)env->NewGlobalRef(localClass); // 提升为全局引用 env->DeleteLocalRef(localClass); // 释放局部引用 // 获取 onData 方法 ID(参数类型需 Java 方法签名匹配) const char* methodName = "onData"; const char* methodSignature = "([BI)V"; // void(byte[], int) g_onDataMethodID = env->GetMethodID(g_dataCallbackClass, methodName, methodSignature); // 检查方法是否存在 if (g_onDataMethodID == nullptr) { // 处理错误:方法未找到 } } ``` #### **步骤 2:触发回调** 在需要回调时调用 `onData` 方法: ```cpp void triggerCallback(JNIEnv* env, jobject callback, jbyteArray data, jint length) { // 确保回调对象和方法 ID 有效 if (callback == nullptr || g_onDataMethodID == nullptr) { return; } // 调用 onData 方法 env->CallVoidMethod(callback, g_onDataMethodID, data, length); // 检查是否发生异常 if (env->ExceptionCheck()) { env->ExceptionDescribe(); // 打印异常信息(调试用) env->ExceptionClear(); // 清除异常(避免崩溃) } } ``` --- ### **3. 参数构造示例** 若需传递 `byte[]` 数据到 Java 层: ```cpp // 示例:将 C/C++ 数据转换为 jbyteArray jbyteArray createJavaByteArray(JNIEnv* env, const uint8_t* data, int length) { jbyteArray javaArray = env->NewByteArray(length); env->SetByteArrayRegion(javaArray, 0, length, (const jbyte*)data); return javaArray; } // 使用示例 uint8_t rawData[] = {0x01, 0x02, 0x03}; jbyteArray javaData = createJavaByteArray(env, rawData, sizeof(rawData)); triggerCallback(env, callback, javaData, sizeof(rawData)); env->DeleteLocalRef(javaData); // 释放局部引用 ``` --- ### **4. 关键注意事项** 1. **线程安全**: - JNIEnv 是线程相关的,**不可跨线程直接使用**。若在子线程中调用,需通过 `AttachCurrentThread` 获取当前线程的 JNIEnv: ```cpp JavaVM* g_jvm; // 需在 JNI_OnLoad 中初始化 JNIEnv* env; g_jvm->AttachCurrentThread(&env, nullptr); triggerCallback(env, callback, data, length); g_jvm->DetachCurrentThread(); // 可选,根据场景决定 ``` 2. **引用管理**: - **局部引用**(如 `jbyteArray`)需手动释放(`env->DeleteLocalRef`)。 - **全局引用**(如 `g_dataCallbackClass`)在不再使用时需释放: ```cpp env->DeleteGlobalRef(g_dataCallbackClass); ``` 3. **方法签名**: - 使用 `javap -s com.example.DataCallback` 获取准确方法签名: ``` Compiled from "DataCallback.java" public interface com.example.DataCallback { public abstract void onData(byte[], int); descriptor: ([BI)V } ``` 4. **异常处理**: -调用 `CallVoidMethod` 后必须检查异常,否则可能导致崩溃。 --- ### **5. 完整流程示意图** ``` Java 层初始化 Native 模块 → 传递 DataCallback 对象到 JNI ↓ JNI 缓存 DataCallback 类和方法 ID ↓ 本地代码生成数据 → 构造 jbyteArray → 调用 onData 方法 ↓ Java 层接收数据并处理 ``` --- ### **常见问题** - **崩溃问题**:未正确附加线程(如子线程未调用 `AttachCurrentThread`)。 - **回调不触发**:方法签名错误或全局引用未正确缓存。 - **内存泄漏**:未释放全局引用或局部引用。 --- 通过以上步骤,可安全地在 JNI 中调用 Java 回调接口方法
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

二手的程序员

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值