一文读懂JVM虚拟机原理

本文详细介绍了JVM虚拟机的工作原理,包括JVM、JRE和JDK的区别,平台无关性的概念,以及JVM如何通过类加载机制、内存区域划分和垃圾回收算法实现跨平台运行。讲解了类加载过程中的主动引用和被动引用,以及虚拟机内存的程序计数器、虚拟机栈、本地方法栈、方法区、堆内存等区域的功能。此外,还探讨了各种垃圾回收算法,如复制算法、标记清除算法、标记整理算法和分代收集算法,以及不同垃圾回收器的特点,如Serial、Parallel、CMS和G1。
部署运行你感兴趣的模型镜像

JVM虚拟机原理详解

本文旨在系统性的回顾JVM虚拟机原理,掌握JVM常用知识点及常见的面试考点,希望通过通俗易懂的思想进行学习,欢迎指正。


前言

JVM(Java Virtual Machine)是一种虚拟机规范,不同的厂商有自己的实现,使用最广的就是Oracle的HotSpot虚拟机,本文具体围绕HotSpot进行展开讲解,欢迎指正!

第一章:概述

分析为何需要使用虚拟机?虚拟机带来的好处是什么?

1. 什么是JVM、JRE、JDK?

①. JVM(Java Virtual Machine:JAVA虚拟机)是指负责将字节码解释成为特定的机器码进行执行“转换器(.class -> 机器码)”。Java源程序需要通过编译器编译为.class文件,才能被虚拟机加载、执行。(JVM = “字节码翻译执行器”:从软件层面屏蔽了不同操作系统在底层硬件与指令上的区别,达到了跨平台的特性。)
JVM作用

②. JRE是指Java运行时环境,也就是我们的写好的程序必须在拥有JRE的机器上才能运行。(JRE = JVM + JAVA核心类库-Lib + JAVA命令 + 基础构件)

③. JDK 是指Java开发人员所用的开发环境,包括常用的开发包、目的就是用来编译和调试Java程序的。(JDK = JRE + 编译器 + 其他类库-rt.jar)


2. 什么是平台无关性?(为什么要有虚拟机?)

①. 平台有关性:没有虚拟机,则所有的源代码需编译成可在本机操作系统运行的机器码(不同操作系统可执行的机器码不同),此时,该编程语言是平台有关性的!
②. 平台无关性:有了虚拟机,不同操作系统只用安装自己操作系统版本的JRE运行环境即可,通过编译器将java源程序(.java)编译成class字节码文件(class文件与操作系统和机器指令集无关),再由当前操作系统的JVM虚拟机读取、“翻译”成可在本地运行的机器码在不同的硬件上运行,此时,该编程语言是平台无关性的!
- 字节码和不同系统的 JVM 实现是 Java 语言“一次编译,随处运行”的关键所在!


3. 再来回顾:什么是JVM虚拟机?

JVM虚拟机通过软件的方式去模拟一个 具有完整硬件系统功能能够运行在一个完全隔离环境 中的完整计算机系统,是一个虚构出来的计算机,是一种规范,是物理计算机的软件实现

Java 虚拟机( JVM)是运行 Java 字节码的虚拟机,JVM 针对不同系统的有其特定的实现版本( Windows, Linux, macOS),目的是输入相同源代码编译后的字节码,不同的操作系统都会给出相同的执行结果,从而实现Java语言的跨平台特性


4. 小结

JVM、JRE、JDK
虚拟机与操作系统进行交互(java -> class -> 机器码),再由操作系统与硬件交互(机器码 -> 控制硬件、资源,实现程序指令,完成功能)。从此以后,同一个功能,终于不用分别在不同的操作系统上实现其对应的版本了!



第二章:类加载机制

上章节提到,java源代码编译成class文件后,可由不同的JVM进行“翻译执行”,这说明,class文件中的信息,最终都要加载到虚拟机中才能运行和使用!

JVM将class字节文件加载到内存中,并进行校验、转换等,最终形成可被虚拟机直接使用的java类型的过程称为类加载! 即,将class文件加载到内存,并在方法区中开辟空间作为该类的Class对象,其中保存类种对应的运行时数据结构(类信息 + 常量池 + 静态变量 + 方法 …)!

1. 何时需加载? - 主动引用

(新生、反射、父亲、main)

①. 使用 new / getstatic / putstatic / invokestatic (实例化对象,读取设置类的静态字段,调用类的静态方法)时若该类未加载,则需加载相关的类!
②. 使用反射使用一个类时若未加载该类,则需加载相关的类!
③. 初始化一个类时,若其父类未初始化,需先初始化其父类!
④. 虚拟机启动时,需找到main()方法的类进行初始化!


2. 何时不加载? - 被动引用

(final、数组、static)

①. 若引用某个类中的final属性,则不需加载
②. 通过数组定义类的引用,不需初始化。eg:A[] a = new A[2];
③. 通过子类访问父类的static属性时,子类不需初始化!


3. 类加载过程

加-验-准-解-初

①. 加载
a. 将class字节码文件加载到内存中;
b. 在方法区产生一个该类的Class运行时数据信息(包含该类的所有信息,static变量、方法、代码块,以及常量,类的代码);
c. 在堆中生成一个代表这个类的Class对象,作为程序访问该类的入口!

②. 验证:检查class文件中的字节流是否符合虚拟机规范,且不会危害虚拟机安全!

③. 准备:为类中static静态变量分配内存,赋初始值(通常为对应数据类型的默认值);实例变量的初始值在实例化时跟随对象分配在java堆中,对象初始化时赋值;常量(final)则赋值为具体值!

④. 解析:将常量池中的符号引用(字面量,比如 Arraylist)替换为直接引用(对象地址)、相对偏移量,符号引用的目标一定在内存中已存在!

⑤. 初始化:开始真正执行类中定义的 Java 程序代码。初始化是执行类构造器 Client<> 方法的过程。 Client<>方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。
虚拟机会保证子Client<>方法执行之前,父类的Client<>方法已经执行完毕, 如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成Client<>方法。

⑥. 使用:Java Running

⑦. 卸载:GC,将无用对象从内存中清除!


4. 类加载器 - 用来实现类加载机制的工具!

类加载器
①. 类加载顺序:
a. BootStrap ClassLoader:rt.jar
b. Extention ClassLoader: 加载扩展的jar包
c. App ClassLoader:指定的classpath下面的jar包
d. Custom ClassLoader:自定义的类加载器

注意:一个类的唯一性,由该类本身 + 加载该类的类加载器共同决定!(如果一个类由不同的类加载器加载入内存,JVM认为这是两个不同的类!)

②. 双亲委派机制:(找爸爸)
当一个类收到了加载请求时,它是不会先自己去尝试加载的,而是委派给父类去完成,比如我现在要new一个Person,这个Person是我们自定义的类,如果我们要加载它,就会先委派App ClassLoader,然后委托Extension ClassLoader,最后委托给BootStrap ClassLoader,只有当父类加载器都反馈自己无法完成这个请求(也就是父类加载器都没有找到加载所需的Class)时,子类加载器才会自行尝试加载!

双亲委派机制的好处是:
1. 加载位于rt.jar包中的类时不管是哪个加载器加载,最终都会委托到BootStrap ClassLoader进行加载,这样保证了使用不同的类加载器得到的都是同一个结果。
2. 保护JDK中核心代码库的安全,不被monkey更改!比如我现在要来一个

	public class String(){
	    属性 ....
	    方法 ....
	}

这种时候,我们的代码肯定会报错,因为在加载的时候,通过双亲委派,最终其实是找到了rt.jar中的String.class核心类,而不是自定义的String类!



第三章:JVM内存区域

JVM在运行Java程序期间,会把JVM内存划分为若干个不同的数据区域(运行时数据区域),各区域有各自的作用,以及创建、销毁的时间。运行时数据区域包括:堆内存、栈内存(虚拟机栈)、本地方法栈、方法区、常量池、程序计数器等。
JVM 内存区域主要分为:
1. 线程私有区域【程序计数器、虚拟机栈、本地方法栈】
2. 线程共享区域【JAVA 堆、方法区】
线程私有区域生命周期与线程相同, 依赖用户线程的启动/结束 而 创建/销毁。
线程共享区域随虚拟机的启动/关闭而创建/销毁。

1. JVM内存区域(JMM)

JVM内存模型

①. 线程计数器(线程私有):当前线程所执行字节码的行号指示器!在多线程切换CPU时,需要准确的告诉CPU执行线程的哪条指令!
1. 如果正在执行 java 方法的话,计数器记录的是虚拟机字节码指令的地址(当前指令的地址) 。
2. 如果是 Native 方法,则为空。
程序计数器内存区域是唯一一个在虚拟机中没有规定任何 OOM 情况的区域。

②. 虚拟机栈(线程私有):描述java方法执行的内存模型,每个方法在执行的同时都会创建一个栈帧(Stack Frame,虚拟机栈中的元素)用于存储该方法的局部变量、操作数栈、动态链接、方法出口等信息。
虚拟机栈解决了程序如何运行的问题,每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。栈帧随着方法调用而创建,随着方法结束而销毁,无论方法是正常完成还是异常完成(抛出了在方法内未被捕获的异常)都算作方法结束。

虚拟机栈遇到深递归、局部变量过多、无出口情况时,会发生StackOverFlow 栈溢出错误。

③. 本地方法栈(线程私有):本地方法区和 Java Stack(虚拟机栈) 作用类似, 区别是虚拟机栈为执行 Java 方法服务, 而本地方法栈则为Native 方法服务。

④. 方法区(线程共享,JDK1.8转为元空间,使用物理内存):我们常说的永久代(Permanent Generation),用于存储被 JVM 加载的类信息、 常量、 静态变量、 方法、即时编译器编译后的代码等数据。
当内存过小、加载类过多,方法区也会出现OOM错误,JDK1.8后将方法区放入了物理内存中,理论上计算机有多大内存,方法区就能有多大,一定程度上避免了OOM的出现。

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

⑥. 堆内存(线程共享):被线程共享的一块内存区域, 堆内存解决了如何存储数据的问题,程序中创建的对象和数组都保存在 Java 堆内存中,该区域也是垃圾收集器进行垃圾收集的最重要的内存区域。
从 GC 的角度,堆内存还可以细分为: 新生代( Eden 区 、 From Survivor 区 和 To Survivor 区) 和 老年代。
堆内存作为GC回收的重要区域,又称GC堆,细节见下章。

小结

1. 运行一个java程序 = 一个java进程(可有多个线程,一个线程对应一个栈) = 一个JVM实例 = 一个内存空间(多线程共享)
2. 实例对象放堆中,存储对象真内容;对象引用放栈中,存储对象堆引用(内存地址);类的信息方法区,存储各类的信息(方法、类名、static变量等)



第四章:垃圾回收算法(器)

Java语言的一个特点就是无需开发人员手动的去显示管理内存空间,防止内存泄漏,是由虚拟机自动执行,靠的就是GC-垃圾回收线程,它是低优先级的,在正常情况下是不会执行的,只有在虚拟机空闲或者当前堆内存不足时,才会触发执行,扫描那些没有任何引用的对象,进行“垃圾”回收 。

1. 如何确定垃圾?

①. 引用计数法
在 Java 中,引用和对象是有关联的。如果要操作对象则必须用引用进行。因此,很显然一个简单的办法是通过引用计数来判断一个对象是否可以回收。
简单说,即一个对象如果没有任何与之关联的引用, 即他们的引用计数都为 0, 则说明对象不太可能再被用到,那么这个对象就是可回收对象。
(一个对象分配一个计数器,有引用+1,失效-1,为0则无引用即可GC)

引用计数法的缺点:
- 多个对象发生循环引用时,无法释放无用对象,造成内存泄漏(无用对象占用内存又无法被GC回收)

引用计数法原理

②. 可达性分析
可达性分析算法是从离散数学中的图论引入的,程序把所有的引用关系看作一张图,通过一系列的名为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的,如下图所示:

可达性分析原理
缺点:需要大量资源及时间,需停止所有进程!
优点:解决了引用计数法存在的循环引用带来的内存泄漏问题!

③. 一次标记即死亡? - 看谁关系硬!
a.首次在可达性分析后发现不可达,则会被第一次标记且进行一次筛选,条件是此对象是否有必要执行finalize()方法,如果对象没有覆盖该方法,或该方法已经被虚拟机调用过,这两种情况都视为“没必要执行”,即直接死亡;如果该对象被判定为“有必要执行”,则将该对象放入一个F-Queue的队列中,由一个虚拟机自动建立的线程去执行(触发该方法),该方法finalize()是对象逃脱死亡的最后一次机会。
b. GC对F-Queue队列中的对象进行二次标记,如果对象在该方法中重新与引用链上任何一个对象建立关联(变为可达对象!),则在第二次标记时将被移出即将回收的集合中;如过在该方法中还没有挂链,则真的被回收。
c. Finalize()方法:是Object类的一个方法一个对象的finalize方法只会被系统调用一次,经过该方法逃脱死亡的对象不会再被调用第二次!如过下次面临回收,则不会再被执行!


2. 垃圾回收算法

①. 复制算法:将可用内存划分为大小相等的两块,每次只使用其中的一块,当进行垃圾回收时,把使用块中的存活对象全部复制到另外一块中,然后把已使用的内存空间一次清空掉。
优缺点:不容易产生内存碎片;可用内存空间缩小一半、存活对象多的情况下,效率低!

②. 标记清除算法:先标记出所有需要回收的对象,清除就是直接清除被标记对象的空间。(实质上为将已死亡对象标记为空闲内存,且记录在空闲列表中,需要new一个对象时,从空闲列表中找空闲的内存进行分配。)
优缺点:实现简单;容易产生内存碎片而造成后续无法给大对象分配空间,直接进入老年代,可能提前引发GC。

③. 标记整理算法:先标记存活对象,然后把存活对象向内存的一边移动,然后清理掉端边界以外的内存(一个坑位一个萝卜,按序排放,最后一个萝卜以后的空间直接清除)。
优缺点:不容易产生内存碎片;内存利用率高;存活对象多并且分散的时候,移动次数多,效率低下

垃圾收集算法原理


④. 分代收集算法:(目前大部分JVM的垃圾收集器所采用的算法,具体问题具体分析,新生代 - 复制算法,老年代 - 标记整理 / 标记清除算法)
a. 分代收集的“代”如何分?
根据对象存活周期的不同将内存划分为几块,一般是把Java堆分为新生代和老年代,这样就可以根据各个年代的特点采用最适当的收集算法。
1.新生代,用于存储刚刚创建的对象,这个区域内的对象存活率不高,每次GC时都发现有大批对象死去,只有少量存活,因此选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。
2. 老年代,用于存储一些大数组、大对象、高龄对象,该区域内的对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或者“标记-整理”算法来进行回收。
分代垃圾回收原理
b. 分代收集的内存分配与回收策略原理:
1.JAVA 虚拟机提到过的处于方法区的永生代(Permanet Generation), 它用来存储 class 类,常量,方法描述等。对永生代的回收主要包括废弃常量和无用的类。
2.新生对象:新生对象一般直接分配在eden区,eden区内存满后引发 Minor_GC,将eden和Survivor from存活对象复制到Survivor to,然后清理eden、from空间,如果Survivor to空间不够则担保到老年代(新生代和Survivor from中的存活对象无法全部放入Survivor to区域中时,分配到老年代)
3. 大对象:需要大量连续内存空间的java对象直接进入老年代(eg:很长的字符串、数组等),目的是为了避免在Eden和两个Survivor区之间发生大量的内存复制!(可设置阈值,大于多少为大对象)
4. 长期存活对象:一个对象分配一个年龄计数器,超过x(可设置,默认15)则放入老年代。 对象熬过一次Minor GC过程(在Eden出生并经过第一次Minor GC后仍存活,且能被Survivor容纳,则移动到Survivor中),在from和to中转移一次,年龄+1(第一次进入s区中年龄为0)。
5. 空间分配担保:minorGC 前看老年代的空间是否够放新生代的所有对象,如果可以则minor GC安全,如果不安全,则看是否允许失败?允许失败则看老年代最大连续空间是否大于以前GC时晋升老年代对象总大小的均值?如果大于则尝试Minor GC可能会出错,如果小于或者进行Full GC(老年代GC,majorGC)


⑤. 分区收集算法(最新的G1收集器采用分区收集算法):分区算法则将整个堆空间划分为连续的不同小区间, 每个小区间独立使用, 独立回收. 这样做的好处是可以控制一次回收多少个小区间 , 根据目标停顿时间, 每次合理地回收若干个小区间(而不是整个堆), 从而减少一次 GC 所产生的停顿。


3. 垃圾回收器

①. Serial(Young 单线程新生代回收器 / Old 单线程老年代回收器):早期计算机内存很小的时候,使用的垃圾回收器!Serial 只会使用一个 CPU 或一条线程去完成垃圾收集工作,并且在进行垃圾收集的同时,必须暂停其他所有的工作线程,直到垃圾收集结束。
1.Serial Young 收集器是单线程、新生代垃圾回收器,采用复制算法,效率高。
2.Serial Old 收集器是单线程、老年代垃圾回收器,采用标记整理算法,避免空间碎片。
Serial 虽然在GC过程中需要暂停所有其他的工作线程(STW),但它简单高效,对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率,因此 Serial垃圾收集器依然是 java 虚拟机运行在 Client 模式下默认的新生代垃圾收集器。
后来计算机内存越来越大了…且老年代本就空间大,每次GC太久了(STW)!用户体验太差!玩玩多线程呗!


②. Parallel(Scavenge 多线程新生代回收器 / Old 多线程老年代回收器):后来计算机内存大了,单线程效率太低,衍生出了多线程并行进行清理。
1.Parallel Scavenge 收集器是一个新生代垃圾收集器,使用复制算法,是一个多线程的垃圾收集器, 它重点关注的是程序达到一个可控制的吞吐量(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)),高吞吐量可以最高效率地利用 CPU 时间,尽快地完成程序的运算任务,主要适用于在后台运算而不需要太多交互的任务。
2.Parallel Old 收集器是Parallel Scavenge的老年代版本,使用多线程的标记整理算法,在 JDK1.6才开始提供。Parallel Old 同样为了在年老代提供吞吐量优先的垃圾收集器, 如果系统对吞吐量要求比较高,可以优先考虑新生代 Parallel Scavenge和年老代 Parallel Old 收集器的搭配策略。
Parallel 在GC过程中也需要暂停其他线程(STW),虽然使用多线程进行清理,但在内存更大、垃圾更多的时候,依然体验不好。于是衍生除了CMS、G1等回收器。


③. CMS(Concurrent mark sweep 老年代垃圾回收器,一般配合Parallel 回收器使用):CMS收集器是一种老年代垃圾收集器,其最主要目标是获取最短垃圾回收停顿时间,这是因为CMS收集器工作时,GC工作线程与用户线程可以并发执行,以此来达到降低收集停顿时间的目的,最短的垃圾收集停顿时间可以为交互比较高的程序提高用户体验,和其他年老代使用标记-整理算法不同,它使用多线程的标记-清除算法。
CMS回收过程:
1.初始标记(STW):只是标记一下 GC Roots 能直接关联的对象,速度很快,仍然需要暂停所有的工作线程。
2.并发标记(无STW):进行 GC Roots 跟踪的过程,和用户线程一起工作,不需要暂停工作线程。此处有大量问题:
a. 错误标记:一边产生一边回收中,可能有一个对象被标为垃圾还没来得及回收时,运行过程中下一刻该对象又获得一个引用,则产生错标!
3.重新标记(STW):为了修正在并发标记期间,因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有的工作线程。
4.并发清除(无STW):清除 GC Roots 不可达对象,和用户线程一起工作,不需要暂停工作线程。由于耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户现在一起并发工作, 所以总体上来看CMS 收集器的内存回收和用户线程是一起并发地执行。
CMS特点:
1.并发收集,低停顿:CMS以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需STW才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。
2.CMS收集器对CPU资源非常敏感。
3.CMS收集器无法处理浮动垃圾(Floating Garbage)。
4.CMS收集器是基于标记清除算法,该算法的缺点都有。


④. G1(Garbage first 垃圾回收器):Garbage first 垃圾收集器是目前垃圾收集器理论发展的最前沿成果,G1重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成。即G1提供了接近实时的收集特性。
G1回收过程:
1.初始标记(Initial Marking)- STW:仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短。
2.并发标记(Concurrent Marking)- No STW:是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
3.最终标记(Final Marking)- STW:是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,最终标记阶段需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,但是可并行执行。
4.筛选回收(Live Data Counting and Evacuation)- STW:首先对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。
G1特点:
1.并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU来缩短Stop-the-world停顿的时间,部分其他收集器原来需要停顿Java线程执行的GC操作,G1收集器仍然可以通过并发的方式让Java程序继续运行。
2.分代收集
3.空间整合(整体上是标记整理,局部上是复制算法):与CMS的标记-清除算法不同,G1从整体来看是基于标记-整理算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。
4.可预测的停顿:这是G1相对于CMS的一个优势,降低停顿时间是G1和CMS共同的关注点。

相比与 CMS 收集器, G1 收集器两个最突出的改进是:
①. 基于标记-整理算法,不产生内存碎片。
②. 可以非常精确控制停顿时间,在不牺牲吞吐量前提下,实现低停顿垃圾回收。



总结

JVM是Java语言的基础,理解了虚拟机的执行原理,内存空间分配,GC过程… 才能更好的在开发过程中理解程序执行、优化的整体过程,从而预测规避一些可预见的问题,或发生问题时可快速定位并解决问题。

您可能感兴趣的与本文相关的镜像

Python3.10

Python3.10

Conda
Python

Python 是一种高级、解释型、通用的编程语言,以其简洁易读的语法而闻名,适用于广泛的应用,包括Web开发、数据分析、人工智能和自动化脚本

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符  | 博主筛选后可见
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值