JVM内存结构、Java内存模型、Java对象模型

概述

这是三个截然不同的概念,容易弄混,它们的区别如下:

  • JVM内存结构:和Java虚拟机的运行时区域有关
  • Java内存模型:和Java的并发编程有关
  • Java对象模型:和Java对象在虚拟机中的表现形式有关

JVM内存结构

在这里插入图片描述

程序计数器(Program Counter Register)

程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码(.class 文件)的行号指示器。

由于 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各线程之间计数器互不影响,独立存储,我们称这类内存区域为 “线程私有” 的内存。

如果线程正在执行的是一个 Java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是 Native 方法,这个计数器值则为空(Underfind)。此内存区域是唯一一个在 Java 虚拟机规范中没有规定任何 OutOfMemoryError 情况的区域。

(Native 方法:该方法的实现由非Java语言实现,比如用C或C++实现。)

总结:保存当前执行指令的地址,一旦指令执行,程序计数器将更新到下一条指令

Java 虚拟机栈(Java Virtual Machine Stacks)

在这里插入图片描述
Java 虚拟机栈也是线程私有的,它的生命周期与线程相同 (每个线程运行时所需要的内存空间,称为虚拟机栈)

每个栈由多个栈帧(Frame)组成,一个栈帧就对应着一次方法调用时所占用的内存

每个线程只能有一个活动栈帧(栈顶的栈帧),对应着当前正在执行的那个方法,图解:在这里插入图片描述

栈帧组成:

  • 局部变量表:一个局部变量可以保存类型为boolean、byte、char、short、float、reference和returnAddress的数据,两个局部变量可以保存一个类型为long和double的数据。局部变量使用索引来进行定位访问,第一个局部变量的索引值为零;局部变量表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

  • 操作数栈:也称为操作栈,它是一个后进先出的栈,当一个方法刚刚开始执行时,其操作数栈是空的。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作,一个完整的方法执行期间往往包含多个这样出栈/入栈的过程,简单理解, 操作数栈是线程实际的操作台(更直白一点,操作数栈是进行数据运算的地方);

  • 动态链接:简单的理解为指向运行时常量池的引用,在class文件里面,描述一个方法调用了其他方法,或者访问其成员变量是通过符号引用来表示的,动态链接的作用就是将这些符号引用所表示的方法转换为实际方法的直接引用:
    在这里插入图片描述
    原文:https://www.cnblogs.com/mzzcy/p/7223405.html

  • 方法返回地址:方法法调用的返回,包括正常返回(有返回值)和异常返回(没有返回值),不同的返回类型有不同的指令,无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮他恢复它的上层方法执行状态

虚拟机栈描述的是 Java 方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法返回地址等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

在 Java 虚拟机规范中,对这个区域规定了两种异常状况:

  1. 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError 异常;
  2. 如果虚拟机栈可以动态扩展,但扩展时无法申请到足够的内存,将会抛出 OutOfMemoryError 异常;

本地方法栈(Native Method Stack)

本地方法栈与虚拟机栈所发挥的作用是非常相似的,它们的区别就是:虚拟机栈为虚拟机执行 Java 方法(也就是字节码服务),而本地方法栈则为虚拟机使用的是 Native 方法服务;

在虚拟机规范中对本地方法栈中方法使用的语言,使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(例如 Sun HotSpot 虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法区域也会抛出StackOverflowError 和 OutOfMemoryError异常。

Java 堆(Java Heap)

Java 堆是 Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存

Java 堆是垃圾收集器管理的主要区域,因此很多时候也被称为 “GC堆” (Garbage Collection Heap)。

根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。

堆内存溢出:错误提示:java.lang.OutOfMemoryError: Java heap space
错误原因:

  1. 内存真不够,通过调整堆内存大小解决;
  2. 存在死循环,通过修改代码解决

堆内存诊断:

  • jps:查看当前系统中有哪些Java进程;

    2784 Launcher
    4128 RemoteMavenServer36
    6544
    20200 Demo1_4
    15548
    18188 Jps
    
  • jmap -head [pid]:查看堆内存占用情况(某一个时刻);

  • jconsole:图形界面的,内置 Java 性能分析器,多功能的监测工具,可以连续监测

方法区(Method Area)

方法区也是被所有线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

知乎参考,挺有道理的:
在这里插入图片描述

对于习惯在 HotSpot 虚拟机上开发、部署程序的开发者来说,很多人都更愿意把方法区称为 “永久代”(Permannet Generation),本质上两者并不等价,仅仅是因为 HotSpot 虚拟机的设计团队选择把 GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样 HotSpot 的垃圾收集器可以像管理 Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其它虚拟机(如 BEA JRockit、IBM J9等)来说是不存在永久代的概念的。

Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样 “永久”存在了,这区域的内存回收目标主要是对常量池的回收和对类型的卸载,一般来说,这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的。

根据 Java 虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。

运行时常量池(Runtime Constant Pool)

常量池,就是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息

运行时常量池,常量池是 *.class 文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址。
例如:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

javap -v HelloWorld.class
Classfile /F:/BaiduNetdiskDownload/资料 解密JVM/代码/jvm/out/production/jvm/cn/itcast/jvm/t5/HelloWorld.class
  Last modified 2021-1-5; size 567 bytes
  MD5 checksum 8efebdac91aa496515fa1c161184e354
  Compiled from "HelloWorld.java"
public class cn.itcast.jvm.t5.HelloWorld
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // hello world
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // cn/itcast/jvm/t5/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lcn/itcast/jvm/t5/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               hello world
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               cn/itcast/jvm/t5/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
{
  public cn.itcast.jvm.t5.HelloWorld();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 4: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lcn/itcast/jvm/t5/HelloWorld;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 6: 0
        line 7: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}
SourceFile: "HelloWorld.java"

运行时常量池是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可以将新的常量放入池中。

运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError异常。

直接内存(Direct Memory)

直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。但是这部分内存也被频繁使用,而且也可能导致 OutOfMemoryError异常出现。直接内存属于系统内存,因此不受 JVM 内存回收管理。

拷贝一个800M的视频例子:

static final int _1Mb = 1024 * 1024;

//使用直接内存,用时0.5秒左右
ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb);
//不使用,用时1.5秒左右。
byte[] buf = new byte[_1Mb];

为什么使用了直接内存,大文件的读写效率会非常高?

不使用直接内存:
在这里插入图片描述
使用了直接内存:
在这里插入图片描述
系统缓存区,java代码是不能直接运行的,所以java会在堆内存中分配一块java的缓存区(对应java代码new byte[]),数据就需要从系统缓存区读入到java缓存区。问题所在:有两个缓存区,数据需要读入两次,造成不必要的数据复制。

系统划分的直接内存java代码可以直接访问,换句话说,直接内存系统可以用,java代码也可以用。这样就少了一次缓存区的复制操作,效率就随之提升。

直接内存的释放

使用 Unsafe 对象完成直接内存的分配回收,并且回收需要主动调用 freeMemory 方法;

ByteBuffer 的实现类内部,使用了 Cleaner (虚引用)来监测 ByteBuffer 对象,一旦ByteBuffer 对象被垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory 来释放直接内存;

Java内存模型(Java Memory Model)

JMM是一组规范,各类JVM的实现厂商需要遵守JMM规范,以便于开发者可以利用这些规范,更方便地开发多线程程序;

JMM主要包括以下三点:

  • 重排序
  • 可见性
  • 原子性

重排序

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,指令重排序使得代码在多线程执行时会出现一些问题;
在这里插入图片描述

可见性

可见性是指一个线程对共享变量进行修改,另一个线程立即得到修改后的最新值;

在Java中,不同线程拥有各自的私有工作内存,当线程需要读取或修改某个变量时,不能直接去操作主内存中的变量,而是需要将这个变量读取到线程的工作内存的变量副本中,当该线程修改其变量副本的值后,其它线程并不能立刻读取到新值,需要将修改后的值刷新到主内存中,其它线程才能从主内存读取到修改后的值
在这里插入图片描述
示例:一个线程改变boolen变量的值,另一个线程并不会停止循环

/**
 * @author yangdong
 * @date 2021-05-19
 * 一个线程对共享变量的修改,另一个线程不能立即得到最新值
 */
public class Test01Visibility {
    // 多个线程都会访问的数据,我们称为线程的共享数据
    private static boolean run = true;
    public static void main(String[] args) throws InterruptedException {
        Thread t1 = new Thread(() -> {
            while (run) {
            }
        });
        t1.start();
        Thread.sleep(1000);
        Thread t2 = new Thread(() -> {
            run = false;
            System.out.println("线程2设置为false");
        });
        t2.start();
    }
}

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

  1. 子线程t从主内存读取到数据放入其对应的工作内存;
  2. 将flag的值更改为true,但是这个时候flag的值还没有写回主内存;
  3. 此时main方法读取到了flag的值为false;
  4. 当子线程t将flag的值写回去后,但是main函数里面的while(true)调用的是系统比较底层的代码,速度快,快到没有时间再去读取主存中的值;

原子性

在一次或多次操作中,要么所有的操作都执行并且不会受其他因素干扰而中断,要么所有的操作都不执行;

/**
 * @author yangdong
 * @date 2021-05-19
 * 案例演示:5个线程各执行1000次 i++;
 */
public class Test02Atomicity {
    private static int number = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable increment = () -> {
            for (int i = 0; i < 1000; i++) {
                number++;
            }
        };
        ArrayList<Thread> ts = new ArrayList<>();
        for (int i = 0; i < 5; i++) {
            Thread t = new Thread(increment);
            t.start();
            ts.add(t);
        }
        for (Thread t : ts) {
            t.join();
        }
        System.out.println("number = " + number);
    }
}

使用javap反汇编class文件,其中,对于 number++ 而言(number 为静态变量),实际会产生如下的 JVM 字节码指令:

 9: getstatic     #18                 // Field number:I
12: iconst_1
13: iadd
14: putstatic     #18                 // Field number:I

由此可见number++是由多条语句组成,以上多条指令在一个线程的情况下是不会出问题的,但是在多线程情况下就可能会出现问题。比如一个线程在执行13: iadd时,另一个线程又执行9: getstatic。会导致两次number++,实际上只加了1。

主内存与工作内存之间的交互

在这里插入图片描述
Java虚拟机内存模型中定义了8种关于主内存和工作内存的交互协议操作:

  • lock:把一个变量标识为一条线程独占状态;
  • unlock:把一个处于锁定状态的变量释放出来,释放后的变量可以被其他线程锁定;
  • read:把一个变量的值从主内存传输到线程的工作内存中,以便随后的load操作使用;
  • load:把read操作传输过来的变量值放入工作内存的变量拷贝中;
  • use:把工作内存中一个变量的值传递给java虚拟机执行引擎;
  • assign:把一个从执行引擎接收到的变量的值赋值给工作变量;
  • store:把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用;
  • write:把store操作从工作内存中得到的变量值放入主内存的变量中;

主内存与工作内存之间的数据交互过程:

lock -> read -> load -> use -> assign -> store -> write -> unlock

其中lock、unlock、read、write作用于主内存的变量,其它操作则作用于工作内存的变量;

如果对一个变量执行lock操作,将会清空工作内存中此变量的值,对一个变量执行unlock操作之前,必须先把此变量同步到主内存中

happens-before

如果一个操作 happens-before 于另一个操作,那么我们说第一个操作对于第二个操作是可见的;

示例:A happens-before B(定义为hb(A, B)),则B保证能看见A;

happens-before具有如下规则:

  1. 单线程规则:单线程内的操作 ,后面的操作一定能看到前面操作的结果;
  2. 锁操作:对于同一个锁来说,一个线程对这个锁解锁之后,另一个线程获取了这个锁后都能看到前一个线程的操作结果;
  3. volatile变量:如果一个线程先去写一个volatile变量,然后一个线程去读这个变量,那么这个写操作的结果一定对读的这个线程可见;
  4. 线程启动:主线程A执行过程中,启动子线程B,那么线程A在启动子线程B之前对共享变量的修改结果对线程B可见;
  5. 线程join:主线程一定能看到join线程执行后的结果;
  6. 传递性:如果hb(A,B)而且hb(B,C),那么可以推出hb(A,C);
  7. 中断: 一个线程被其它线程interrupt,那么检测中断(isInterrupted)或者抛出InterruptedException一定能被看到;

as-if-serial

as-if-serial语义的意思是:不管编译器和CPU如何重排序,必须保证在单线程情况下程序的结果是正确的;

//例如,b依赖a的赋值,不能重排序
int a = 1;
int b = a;

编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作就可能被编译器和处理器重排序。

//例如,b与a并没有依赖关系,可能会发生重排序
int a = 1;
int b = 2;

HotSpot虚拟机对象

对象的创建

虚拟机遇到一条 new 指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过,如果没有,那必须先执行相应的类加载过程。

在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便可确定,为对象分配空间的任务等同于把一块确定大小的内存从 Java 堆中划分出来。

  1. 指针碰撞(Bump the Pointer):Java 堆中内存是绝对规整的,所有用过的内存都放在一边,空闲的内存放在另一边,中间放着一个指针作为分界点的指示器,那所分配内存就仅仅是把那个指针向空闲空间那边挪动一段与对象大小相等的距离;
  2. 空闲列表(Free List):Java 堆中的内存并不是规整的,已使用的内存和空闲的内存相互交错,那就没有办法简单的进行指针碰撞了,虚拟机就必须维护一个列表,记录上哪些内存块是可用的,在分配的时候从列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录;

选择哪种分配方式由 Java 堆是否规则决定,而 Java 堆是否规则又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用 Serial、ParNew 等带 Compact 过程的收集器时,系统采用的分配算法是指针碰撞,而使用 CMS 这种基于 Mark-Sweep 算法的收集器时,通常采用空闲列表;

另外一个需要考虑的问题是对象创建在虚拟机中是非常频繁的行为,即使是仅仅修改一个指针所指向的位置,在并发情况下也并不是线程安全的。解决这个问题有两种方案:

  1. 对分配内存空间的动作进行同步处理
  2. 把内存分配的动作按照线程划分在不同的空间之中进行

内存分配完成后,虚拟机需要将分配到的内存空间都初始化为零值,这一步操作保证了对象的实例字段在 Java 代码中可以不赋初始值就可以使用,程序能访问到这些字段的数据类型所对应的零值。

接下来,虚拟机要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的 GC 分代年龄等信息。这些信息存放在对象的对象头之中。根据虚拟机当前的运行状态的不同,如是否启用偏向锁等,对象头会有不同的设置方式。

在上面工作都完成后,从虚拟机的视角来看,一个新的对象已经产生了,但从 Java 程序的视觉来看,对象创建才刚刚开始----init 方法还没有执行,所有的字段都还为零。所以,一般来说,执行 new 指令之后会接着执行 init 方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来;

对象的内存布局

在 HotSpot 虚拟机中,对象在内存中存储的布局可以分为 3 块区域:

  1. 对象头(Header)
  2. 实例数据(Instance Data)
  3. 对齐填充(Padding)

HotSpot 虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机中分别为 32bit 和 64bit。

对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

实例数据部分是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容。

对齐填充并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于 HotSpot VM 的自动内存管理系统要求对象起始地址必须是 8 字节的整倍数,换句话说,就是对象的大小必须是 8 字节的整数倍。而对象头部分正好是 8 字节的倍数(1 倍或者 2 倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全;

对象的访问定位

建立对象是为了使用对象,我们的 Java 程序需要通过栈上的 reference 数据来操作堆上的具体对象。由于 reference 类型在 Java 虚拟机规范中只规定了一个指向对象的引用,并没有定义这个引用应该通过何种方式去定位、访问堆中的对象的具体位置,所以对象访问方式也是取决于虚拟机实现而定的。目前主流的访问方式有使用句柄和直接指针两种:

  1. 句柄访问:Java 堆中将会划分出一块内存来作为句柄池,reference 中存储的就是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息;
    在这里插入图片描述

  2. 直接指针访问:Java 堆对象的布局中就必须考虑如何放置访问类型数据的相关信息,而 reference 中存储的直接就是对象地址;
    在这里插入图片描述

这两种对象访问方式各有优势,使用句柄来访问的最大好处就是 reference 中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而 reference 本身不需要修改;

直接指针访问方式的最大好处就是速度更快,它节省了一次指针定位的时间开销,由于对象的访问在 Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本。

Sun HotSpot 是使用第二种方式进行对象访问的;

以上全是自己学习所做的笔记,以供日后可以复习参考!!!
书籍:深入理解 Java 虚拟机 JVM高级特性与最佳实践
作者:周志明

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值