Java虚拟机原理剖析

文章目录

一、JVM基础

在这里插入图片描述

1.1 Java虚拟机简介

1、是什么:

  • 是一种抽象化的计算机,有自己完善的硬体架构,如处理器、堆栈、寄存器等,还具有相应的指令系统
  • 是一种规范,可用不同的方式加以实现,对应不同的虚拟机

2、内容:一个指令集、堆栈、 “垃圾堆”和一个方法区

3、Java语言跨平台

  • Sun以及其提供商提供了许多可以运行在各种不同平台上的虚拟机,这些虚拟机都可以载入和执行同一种平台无关的字节码(.class文件)

  • .Java文件可以被被编译成.class

  • 一旦一个JVM在给定的平台上运行,此.java文件都能在这个平台上运行,如图2所示
    在这里插入图片描述

  • 特点:JVM仅与字节码.class文件有关,与任何语言无关

4、Java和Jvm的关系:

  • Java发布之初,就考虑不止让Java也要让其他语言运行在JVM上(时至今日,可以在JVM上执行的常见语言有:Python、Groovy、JS、PHP、Lua)
  • java规范分为java语言规范和JVM规范。

5、常见的Jvm实现

  • JRockit:BEA公司的虚拟机,全部代码都靠即时编译器编译执行由于在GC方面的优异表现,被Oracle收购

  • HotSpot: 由于LongView技术公司设计,后来因为其在JIT编译上的优异表现被Sun收购,06年ORacle收购了Sun

    现在我们使用的HotSpot = 原HotSpot(取JIT) + JRockit(取GC)

  • Zing:Azul公司的虚拟机,是真正的高性能虚拟机。一个VM实例可以管理至少数十个CPU+几百G内存资源,实现可控的GC时间

1.2 指令

1、定义

指令 = 操作码 + 操作数

​ getstatic #2 :表示去.class文件常量池中[02]处,获取静态字段的值

1.3 执行引擎

混合模式:解释器和JIT即时编译器的共存,使得HotSpot执行引擎能够平衡启动速度和运行效率

1、解释器解释执行

特点:逐条解释字节码指令进行执行

2、JIT及时编译器编译执行

特点:一整段执行

3、HotSpot执行引擎执行过程

1)每当执行完一条指令(操作码 + 操作数)后,PC寄存器会更新下一条需要被执行的指定的地址

2)解释执行阶段

  • 当Java程序启动时,HotSpot执行引擎首先采用解释器,逐条解释执行字节码指令

    即读取字节码指令,将其翻译成对应的机器码,并直接由硬件执行

  • 优势

    可以快速启动程序(不需要等待编译器编译整个程序)、节省内存(只需要加载和执行当前需要的代码)

3)编译执行阶段

  • 热点代码探测:在执行过程中,HotSpot执行引擎会收集代码的执行情况,特别是那些执行频率高的代码块(称为“热点代码”)。热点代码探测通常基于计数器(如方法调用计数器)来实现
  • 即时编译
    • 一旦热点代码被探测到,HotSpot执行引擎就会将这些代码提交给JIT即时编译器进行编译
    • JIT编译器会将热点代码编译成与本地平台相关的机器码,并进行优化以提高执行效率
    • 编译后的代码会被缓存起来,以便后续再次执行时直接使用。这样,热点代码的执行速度就会大大提高

1.4 Jvm基于栈的指令集架构

源于: Java语言的跨平台特性

优点

  • 可移植性好:基于栈的指令集架构不依赖于特定的硬件寄存器,因此可以更容易地实现跨平台的操作
  • 实现简单:基于栈的指令集架构在设计和实现上相对简单

缺点

  • 执行性能:与基于寄存器的指令集架构相比,执行相同操作时需要更多的指令数量,影响执行性能

二 、class文件

2.1 字节码简介

1、是什么:二进制01文件

2、如何生成: .class文件即字节码,是.java文件被编译后(javac)生成的文件,如图3所示
在这里插入图片描述

3、使用IDEA查看:

  • IDEA安装jclassLib Bytecode插件
  • IDEA View栏下使用Show Bytecode with jclassLib ,如图5所示
    在这里插入图片描述

2.2 class文件内容

大纲不展示问题

2.2.1 一般信息

如图6所示
在这里插入图片描述

  • 主版本号:52-即Java8
  • 访问标志

类是public的、final等修饰的

  • 本类索引:#9即在常量池[09]中:全类名
  • 字段计数:成员变量个数
  • 方法计数:方法的个数

2.2.2 常量池

1、是什么:class文件的资源库

2、内容:14种不同结构的表数据

如:符号引用和字面量等

2.2.2.1 符号引用

class_info

类的全限定名:com/mjp/类名

Fieldref_info

  • 字段所述的类|接口:字段所在类信息
  • 字段名(name)和字段描述符(java/lang/String)

methodRef_info
在这里插入图片描述

名字和描述符:无参构造方法,在常量池[33]处有描述符

  • 名字<<init>>:即构造方法
  • 描述符()V:方法入参为空()、方法返回值为V即void

2.2.2.2 字面量

string_info

private String name = "mjp";

字符串字面量:cp_info #29 <mjp>

Integer_info

private final int a = 10;

Integer:10

2.2.2.3 字面量和符号引用

1、字面量

1)类型:string_info、Integer_info等

2)分类

  • 文本字符串:String name = “mjp”; cp_info # 4
  • 常量:final int a = 1;

二者均存在于常量池中,一个是[13]CONSTANT_Integer_info处、一个是[29]CONSTANT_utf8_info字符串常量池中

2、符号引用

1)类型:class_info、Fieldref_info、methodRef_info等

2)分类

  • class_info:类、接口全限定名,cp_info # 1
  • Fieldref_info:字段名称 和 字段描述符,cp_info # 2
  • methodRef_info:方法名、方法入参、方法返回值类型,cp_info # 3

3、符号引用的作用

  • 符号引用,保存了方法(所属类、描述符)的信息
  • 在类加载过程时,可以通过符号引用,动态链接到直接内存地址引用

这样使得Xxx.class文件更小


2.2.3 字段

private String name;
  • 名称:name
  • 描述符:String
  • 访问标志:public

2.2.4 方法

1、默认的无参构造方法

  • 名称:<<init>>
  • 描述符:<()V>表示方法的返回值类型是void、方法的入参为空
  • 访问标志:public

2、自定义方法

public void func(Integer i, String s) {
   
   
     this.map.put(s, i);
}
  • 名称:<func>
  • 描述符:(String;Integer;)V表示方法的返回值类型是void、方法的入参为String 和 Integer类型
  • 访问标志:public
3、方法调用字节码指令invoke
public class Demo{
   
   
	private void func(Animal animal, Map<String, Integer> map) {
   
   
        // invokevirtual,调用的多态方法
        animal.eat();
        
    	// invokeinterface,调用的接口方法
        map.put("mjp", 18);
        
        // invokestatic,调用的类方法
        f1();
        
        // invokespecial,调用的对象私有方法
        f2();
        
        // invokevirtual,调用的实力方法
        f3();
    }

    // invokestatic
    public static void f1() {
   
   

    }
    
	// invokespecial
    private void f2() {
   
   

    }
    
	// invokevirtual
    public void f3() {
   
   
    }
}
  • invokevirutal: 当你调用一个对象的非静态、非私有方法,并且这个方法在编译时不能确定具体调用哪个类的方法时(即存在继承和多态的情况) 或者 实力方法(非静态、非私有)

    上述animal.eat()即多态在编译时期无法确定调用是父类还是子类的eat方法、对象本身的实力方法-f3()

  • invokespecial:调用对象的private私有方法、构造方法、父类继承方法-f2

  • invokeinterface:调用接口方法,上述map.put即调用的Map接口的put方法

  • invokestatic:调用的类方法-f1()

  • invkoedynamic:使用了lambda表达式

三、类加载

3.1 类加载过程

分为三个阶段:加载、链接、初始化

1、加载(Loading)

  • 通过类的全限定名,获取此类的二进制字节流class文件
  • 将class文件存储的静态结构,转换为方法区的运行时数据结构
  • 并在堆中生成一个代表此类的Class对象,作为这个类在方法区中各种字段、方法的访问入口

2、链接

1)验证(Verification)

确保Class文件的字节流中包含的信息符合JVM规范,保证这些信息被当作代码运行后不会危害虚拟机自身的安全

验证内容

2)准备(Preparation)

  • 在方法区中为静态变量分配内存、并设置默认值
public static int value = 1;

在准备阶段过后,value的值为0,而不是1。赋值1的动作在初始化阶段进行

3)解析(Resolution)

动态链接:将常量池中的符号引用转化为直接引用的过程。

3、初始化(Initialization):<clinit>()

此时Java程序代码才开始真正执行

  • 为类的静态变量赋予初始值

    • 正常情况下:在编译时就完成了赋值
    public static final int a = 1;
    
    • 特殊情况:对于复杂表达式,即使是static final修饰,也不在编译时完成赋值,而是在此处初始化阶段
    public static final int a = new Random().next(10);
    
  • 执行 static {} ,仅在此执行一次

  • 执行其父类的<clinit>()

4、使用

除了上述1-3类的加载过程外,类的生命周期还包括4-5

  • new 类

5、卸载

当堆中代表类的Class对象不再被引用,即不可达时,表示类的生命周期结束

  • GC会回收这部分堆内存
  • 同时卸载类在方法区的数据(方法、字段)

JVM自带的类加载器所加载的类(String类等核心类),在虚拟机的生命周期中通常不会被卸载


3.2 类加载时机

JVM规定了五种情况,需立即对类进行初始化(自然的,加载和链接则需要在此之前开始)

1、如果类没有进行过初始化,此时遇到newgetstaticputstaticinvokestatic这四条字节码指令时,则触发此类的初始化

2、首次访问(读取或者设置)这个类的静态变量(被final修饰、已在编译期把结果放入常量池的静态字段除外)和调用静态方法的时候。

3、如果类型没有进行过初始化,使用了反射获取此类的class对象,则触发此类的初始化

A.class;
Class.forName(xxx.xxx.A);

4、当类进行初始化的时,如果其父类还未初始化过,则对其父类进行初始化

5、当虚拟机启动时,会先初始化main方法所在的主类


3.3 类加载器的层次结构

Java的类加载器采用了双亲委派模型(Parents Delegation Model)。这个模型要求除了顶层的启动类加载器(Bootstrap ClassLoader)外,其余的类加载器都应当有自己的父类加载器。

1、启动类加载器(Bootstrap ClassLoader)

  • 根类加载器负责加载Java核心库,如rt.jar(javax、lang、util、io等)
  • 由JVM使用C++实现的

2、扩展类加载器(Extension ClassLoader)

  • 负责加载JRE的扩展目录(lib/ext)中的jar包和类库
  • Java实现,由根类加载器完成加载

3、系统类加载器(System ClassLoader):应用类加载器(Application ClassLoader),加载类路径CLASSPATH下的类。一般来说,用户编写的类都是由它来完成加载的

4、自定义类加载器

1)自定义

  • 如果不打破双亲委派机制-参考:ApClassLoader(本质继承ClassLoader,重写findClass方法)

  • 如果打破双亲委派机制-参考:WebappClassLoader

2)场景

  • 用于加载非标准路径下的类,如网络上的类文件
  • 隔离记载类,尤其是不同版本的相同类

5、上下文类加载器

1)定义

  • 是Thread类的一个属性
  • 每个线程都可以有自己的上下文类加载器,在运行时动态加载与当前线程上下文相关的类
  • 在JDBC -SPI机制中,使用当前线程的上下文类加载器来加载JDBC驱动-con.mysql.driver.Driver

2)显示设置

// 1.显示设置-自定义类加载器为当前线程的上下文加载器
MyClassLoader myClassLoader = new MyClassLoader();
Thread.currentThread().setContextClassLoader(myClassLoader);

// 2.显示设置-系统类加载器为当前线程的上下文加载器
Thread.currentThread().setContextClassLoader(ClassLoader.getSystemClassLoader());

3)默认为系统类加载器关系

  • 如果没有显式地为当前线程设置上下文类加载器,则默认继承自其父线程-通常是main线程
  • 如果main线程也未显式设置上下文类加载器,则使用系统类加载器作为上下文类加载器

3.4 类型安全机制

Java的类型安全机制依赖于

  • 类的完全限定名(包括包名和类名)和类加载器
  • 两个类如果由不同的类加载器加载,即使它们的完全限定名相同,它们也被视为不同的类型
  • 这意味着,如果一个接口是由一个类加载器加载的,而其实现类是由另一个类加载器加载的,那么这些类之间就无法正确地实现多态和类型转换,因为JVM会认为它们是两种完全不同的类型
  • 所以,通常情况下接口和实现类应由相同的类加载器加载

3.5 双亲委派机制

3.5.1 流程

当需要加载一个类时

  • 首先委托给父类加载器去加载
  • 如果父类加载器能够加载该类,就成功返回
  • 如果父类加载器加载不到,子类加载器才会尝试自己去加载

3.5.2 api

  • Class<?> loadClass(String name) :加载name类,返回此类的class对象

  • Class<?> findLoadedClass(String name):查找name类是否被加载,若为null则说明未被加载。如果已被加载则返回class对象

  • Class<?> findClass(String name):调用子类加载器去加载name类

    • 若子类加载器加载范围包括name类,则可以加载,并返回class对象
    • 否则返回null
    • 具体实现,可以参考ApClassLoader,底层调用defineClass
      • 根据类的全路径名称,找到.class文件
      • 将.class文件转换为运行时方法区的动态数据结构
      • 返回此类在堆中的class对象,作为此类在方法区字段、方法的访问入口
  • Class<?> defineClass(String name, byte[] b, int off, int len):将.class(二进制字节数组) --> .java(class对象)

  • ClassLoader getClassLoader():使用class对象调用此方法,返回加载此类的类加载器

  • getSystemClassLoader():获取系统类加载器


3.5.3 双亲委派机制源码-loadClass

protected Class<?> loadClass(String name, boolean resolve){
   
   
    synchronized (getClassLoadingLock(name)) {
   
   
    // 1.校验下这个类是否已经被加载过了
    Class<?> c = findLoadedClass(name);
    // 2.没有被加载过
    if (c == null) {
   
   
        // 2.向上委派
        // 2.1如果当前类加载器不是启动类根类加载器,则向上委派给其父类加载器去加载
		if (parent != null) {
   
   
            // 递归-委派
            c = parent.loadClass(name, false);
         } else {
   
   
            // 2.2如果当前类加载器已经是根类加载器了,则让其尝试去加载
            c = findBootstrapClassOrNull(name);
         }
		
        // 3.向下尝试
        // 如果向上委派直到根类加载器都没能加载,则不断执行此句,尝试子类加载器加载
        if (c == null) {
   
   
            // 自定义类加载器需要重写此方法,当父类加载器无法完成加载时,需自定义加载逻辑去自己加载
            c = findClass(name);
        }
    }
    return c;
}

3.5.4 双亲委派机制的作用

1)安全性

防止用户自定义的类(可能含有恶意代码)覆盖掉Java核心库中的类

2)唯一性

loadClass时,一旦发现c != null,则直接返回class对象,保证了一个类在Jvm中只会被加载一次

3)可扩展性

支持自定义类加载器,通过继承ClassLoader,重写findClass方法,实现自定义加载逻辑


3.5.5 打破双亲委派机制

场景一:SPI

1、缺陷:无法直接加载第三方类

2、缺陷描述

  • JDBC规范中的Driver接口,是在java.sql下的即rt.jar中的sql包下的,故通过启动类加载器去加载
  • 而JDBC驱动的实现类如MySQL-com.mysql.jdbc包下的具体实现Driver类
<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.6</version>
</dependency>
public class Driver  implements java.sql.Driver{
   
   

}

鉴于类型安全机制,正常情况下实现类应该和接口一样由启动类加载器去加载,但是实现类在类路径下,无法被根类加载器直接加载

  • 导致了在JDBC 4.0之前,需要通过Class.forName("com.mysql.jdbc.Driver")方法显式的使用系统类加载器加载驱动

3、解决-SPI机制

JDBC 4.0后,支持SPI方式来注册数据库驱动,无需再通过Class.forName("com.mysql.jdbc.Driver")方法显式加载驱动

1)代码实现

String url = "jdbc:mysql://localhost:3306/day21?useUnicode=true&characterEncoding=UTF-8&useSSL=false";  

//一、获取mysql的数据库连接对象
Connection conn = DriverManager.getConnection(url,"root", "941123");
        
//二、获取SQL语句执行对象
Statement statement = conn.createStatement();
        
//三、执行SQL语句
ResultSet set = statement.executeQuery("select  * from tb_user");

2)SPI实现机制原理

SPI

3)打破双亲委派机制

  • 在加载Driver驱动时,使用的是SPI服务发现机制
private static void loadInitialDrivers() {
   
   
        // 一、load
		ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
        Iterator<Driver> driversIterator = loadedDrivers.iterator();
        // 二、hasNext
         while(driversIterator.hasNext()) {
   
   
             // 三、next
             driversIterator.next();
         }
}
  • load:使用上下文类加载器去加载驱动Driver类
public static <S> ServiceLoader<S> load(Class<S> service) {
   
   
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return ServiceLoader.load(service, cl);
}
  • 因为上下文类加载器没有显示指定,所以默认为系统类加载器,所以本质就是使用系统类加载器去加载的类路径下的Driver实现类
Class<?> c = Class.forName("com.mysql.driver.Driver", false, loader);

场景二:Tomcat

1、背景

  • 在容器化环境,如Docker中,每个容器运行自己的Web服务和Jvm实例

  • 传统的应用服务器,如Tomcat中,多个Web服务,是共享一个Jvm实例的

    • 则两个不同Web服务(如web1和web2)中分别调用 ClassLoader.getSystemClassLoader() 时,返回的是相同的系统类加载器实例
    • 因为系统类加载器是JVM级别的,而非应用程序级别的

2、Tomcat中可能存在的缺陷

如图8,Web1和Web2共享一个Jvm实例时
在这里插入图片描述

  • web1服务使用系统类加载器去加载Spring4中的某个类A,并获取aClass对象
  • 当web2服务使用到类A时,发现全限定名称 和 类加载器(系统类加载器)都相同,则不会再去加载Spring5中的类A,而是直接返回aClass对象

3、 解决-自定义类加载器

Tomcat为每个Web应用程序创建了一个独立的WebAppClassLoader类加载器 ,并打破双亲委派机制

如图9所示
在这里插入图片描述

1)打破双亲委派机制

  • 当要加载一个类时,tomcat不会按照双亲委派机制,优先让父类加载器去加载,而是优先使用自定义WebAppClassLoader类加载器去优先加载类路径下的全限定类名的类
  • 当自定义加载器无法加载到时,才会走双亲委派

具体代码为:WebappClassLoader --继承> WebappClassLoaderBase --继承> URLClassLoader#findClass

2)自定义类加载器加载过程

  • web1服务使用自定义类加载器WebApp1加载器去加载Spring4中的某个类A,并获取aClass对象
  • 当web2服务使用到类A时,使用自定义类加载器WebApp2加载器去加载Spring5中的类A

3.6 Launcher

1、内容

  • static class ExtClassLoader:扩展类加载器
  • static class AppClassLoader:系统类加载器

2、构造器

public Launcher() {
   
   
	// 1.创建扩展类加载器
    ExtClassLoader var1 = Launcher.ExtClassLoader.getExtClassLoader();
	// 2.创建系统类加载器
    this.loader = Launcher.AppClassLoader.getAppClassLoader(var1);
	// 3.设置上下文类加载器为:系统类加载器
    Thread.currentThread().setContextClassLoader(this.loader); 
}

四、运行时数据区-栈

4.1 PC寄存器

1、特点

一个线程对应一个栈,当然也对应一个PC寄存器。Java中唯一一块无OOM的内存区域

2、内容

下一条该执行的指令地址

3、作用

当CPU在多个线程之间切换时,PC寄存器能够确保每个线程都能从上次中断的位置继续执行

4.2 本地方法栈

1、作用

管理Java中native方法的调用

  • JNI:是Java调用本地方法的一种机制
  • 通过JNI,Java程序可以加载并执行用其他语言(如C或C++)编写的库
  • 本地方法栈保存JNI调用过程中的状态信息,确保JNI调用的正确性和稳定性

2、特点

  • 可能OOM
  • 也可以StackOverFlow

4.3 Java虚拟机栈

4.3.1 Java栈

1、定义

  • Java栈是线程私有的,它的生命周期与线程相同,在创建线程时,会创建一个Java栈用于存放该线程执行方法的所有栈帧。

2、特点

  • 无GC、但是可能有OOM和StackOverFlow

4.3.2 栈帧

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用。这个引用用于支持当前方法的代码能够实现动态链接

1、定义

  • 是一种数据结构,是Java栈的基本单位

2、作用

  • 一个线程对应一个Java栈,一个线程可能有多个执行方法,每个方法对应一个栈帧

  • 栈帧中存储了方法的

    • 局部变量表
    • 操作数栈
    • 动态链接
    • 方法返回地址

    等信息

  • 栈帧中存储的方法信息,用于支持Jvm对方法的调用和执行

3、局部变量表

1)作用

  • 存储方法的参数
  • 存储方法内部定义的局部变量

2)存储基本单元:slot- 变量槽

  • 存储内容
    • 8中基本类型的值(long、double占用2个slot来存储值)
    • 引用类型的堆内存地址值

3)局部变量名

  • 局部变量名主要是在.java源代码中,用于帮助开发者理解和编写代码的,是编译时概念
  • 在.class文件中,局部变量名会被转换成JVM内部使用的索引index
// 其中d = 1.0、s = "m" 
public void testMethod(double d, String s) {
   
     
    int i = 5;  
    User user = new User();
    // 其他代码  
}  

slot和局部变量的关系如图10所示
在这里插入图片描述

其中index即.java源码中的局部变量名

  • index = 11表示d变量名称
  • slot1和slot2两个槽用于存储double类型的值,即存储的d变量的值
  • double d = 10,即上述的index = 11指向槽,这样就可以通过index访问槽中的值了

一般this的index = 0,即当前对象的堆地址值,在index = 0 指向的slot中

4)局部变量是否线程安全

  • 线程安全
    public String func1() {
   
   
        StringBuilder sb = new StringBuilder();
        sb.append("a");
        String s = sb.toString();
        return s;
    }

sb在方法结束后,栈帧随着方法的结束,生命周期也结束了

  • 线程不安全-逃逸
    public StringBuilder func2() {
   
   
        StringBuilder sb = new StringBuilder();
        sb.append("a");
        return sb;
    }

sb是堆中的对象,非线程安全


4、操作数栈

1)作用

保存计算过程中临时变量

2)特点

FIFO

3)实战

public int add() {
   
   
    int a = 8;
    int b = 15;
    int c = a + b;
    return c;
}

.class文件中的方法-code字节码信息如图11所示
在这里插入图片描述

0- bipush:

  • PC寄存器,去指令地址0,获取字节码指令: bipush
  • 执行字节码指令bipush 8 : 将8压至操作数栈顶

2- istore_1:

  • PC寄存器,去指令地址2,获取字节码指令: istore_1
  • 执行字节码指令istore_1 : 将操作数栈顶的int类型值(8)存储到局部变量表中索引index = 1处
    • 弹出操作数栈栈顶int值8
    • 将int值8,存入局部变量表中的slot槽中(值为8),指向此槽的index = 1(即局部变量名a)
  • 此时局部变量表中内容为,如图12所示
    在这里插入图片描述

3-bipush:同上0,操作数栈,操作值15

5- istore_2:同上2,局部变量表,slot存储值15,index = 2指向此slot

6、7:iload_1和iload_2

  • 获取index = 1对应的slot中的值:8,存入操作数栈
  • 获取index = 2对应的slot中的值:15,存入操作数栈

8-iadd:iadd 由于JIT即时编译器负责执行,从操作数栈中弹出两个int类型的值进行相加,并将结果压回操作数栈

  • 从操作数栈中拿出元素:8
  • 从操作数栈中拿出元素:15
  • 8 + 15 = 23
  • 23压入操作数栈中

9-istore_3:同上2,局部变量表,slot存储值23,index = 3指向此slot

10-iload_3:获取index = 3对应的slot中的值:23,存入操作数栈

11-ireturn:从操作数栈中拿出元素:23

  • 将返回值23压入调用者func的操作数栈
public void func() {
   
   
    // pc = n
	add();
	// other,pc n + 1
}

public int add() {
   
   
    int a = 8;
    int b = 15;
    int c = a + b;
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值