Java内存区域与垃圾收集算法

25 篇文章 0 订阅
25 篇文章 1 订阅

Java内存区域与垃圾收集算法

前言

好几天不更新了,之前说的设计模式相关文章暂时搁置了,因为我发现了一本好书,是由小傅哥编写的《重学Java设计模式》,推荐想学习设计模式的可以购买一本,里面对常见设计模式讲解的非常透彻,提供了设计模式的应用场景以及正反面例子,以及DDD领域驱动设计相关概念。目前我也在学习中,所以设计模式就暂时鸽了,让我先学习学习再说。

前些天也没闲着,主要在看Java虚拟机相关的知识,接下来几篇文章主要系统的整理一下这些天接触到的关于Java虚拟机相关的知识点(来源:《深入理解Java虚拟机》、《Java虚拟机规范》、网络博主以及自己的理解),也算是重新巩固。其中涉及虚拟机主要为HotSpot(文中涉及相关虚拟机不做详细讲解,可从网上了解),以及延申出的Java内存区域、垃圾收集器、内存分配等相关问题。

此文内容多涉及概念知识,比较枯燥乏味希望小伙伴们能细心且耐心看下去。

了解Java虚拟机对Java研发人员有着重要的意义,借用《深入理解Java虚拟机》中的引言:“如果开发人员不了解虚拟机的诸多技术特性的运行原理,就无法写出最适合虚拟机运行和自优化的代码。”

Java内存区域与相关内存溢出异常

Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的高墙,墙外面的人想进去,墙里面的人却想出来。

——《深入理解Java虚拟机》

运行时数据区域

如果小伙伴们之前有对内存区域做过了解或学习那么可以跳过这段,如果没有的话建议仔细阅读此节,类似于游戏的新手教程?

在此强调Java内存区域和内存模型是不一样的东西!有很多“不称职面试官”在面试时会问Java的内存模型(不是我说的哦,我的一个朋友说的,一个朋友哦…),其实想问的是Java的内存区域,有可能就会导致小伙伴们的误解,在此提一下概念:

  • 内存区域是指Jvm运行时将数据分区域存储,强调对内存空间的划分。
  • 内存模型(Java Memory Model,简称 JMM )是定义了线程和主内存之间的抽象关系,即 JMM 定义了 JVM 在计算机内存(RAM)中的工作方式,如果我们要想深入了解Java并发编程,就要先理解好Java内存模型。

话不多说,Java的内存区域(运行时数据区域)如下图所示:

img

图片来自网络

JVM的内存区域分为线程私有区域、线程共享区域和直接内存;

线程私有区域的生命周期与线程相同,随线程的启动而创建,随线程的结束而销毁;

线程共享区域随虚拟机的启动而创建,随虚拟机的关闭而销毁;

直接内存也叫堆外内存它并不是JVM运行时数据区的一部分,但在并发编程中被频繁的使用(JDK的NIO模块提供的基于Channel与Buffer的I/O操作方式就是基于堆外内存实现的,这里不做展开讨论);

程序计数器

这么多区域为什么要先说程序计数器呢?当然是因为它特别啊!程序计数器时唯一一个没有规定任何OutOfMemoryError情况的区域。

程序计数器(Program Counter Register)是很小的一块内存空间,可以简单理解为当前线程执行字节码的指示器,程序分支、循环、跳转、异常处理、线程恢复等基础功能都需要计数器来完成。

在Java虚拟机中,多线程是通过线程轮流切换、分配处理器执行时间来实现的,也就是说无论在任何时间,一个处理器都只会执行一条线程中的指令(多核处理器指一个内核)。因此,为了线程切换后能恢复正确的执行位置,每条线程都需要一个独立的程序计数器,互不影响、独立存储,为线程私有内存。

虚拟机栈

虚拟机栈(Java Virtual Machine Stack)与程序计数器一个样也是线程私有的,虚拟机栈表示Java方法执行的内存模型:每一个方法被执行时,Java虚拟机都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每个方法被调用到执行完毕的过程,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常; 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutOfMemo-ryError异常。

——《Java虚拟机规范》

img

栈帧的概念结构

如图所示,每一个栈帧都包括了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译Java程序源码时,栈帧中需要多大的局部变量表,多深的操作数栈就已经被分析计算出来,换句话说,一个栈帧需要分配多少内存并不会受到运行时变量数据的影响,仅仅取决于程序源码和具体的虚拟机实现栈内存布局形式。

局部变量表

局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。

我们经常提起对象是放在堆中,局部变量则是放在栈中。局部变量表中的最小单位是变量槽,在《Java虚拟机规范》中由提到,每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference、returnAddress类型的数据。这8种数据类型,又可以使用32位或更小的物理内存来存储,前6种类型应该都了解,reference类型表示一个对象实例的引用,returnAddress很少见,为指向一条字节码的地址。而long和double为64位数据类型,这两种会匹配两个连续的变量槽空间。

操作数栈

操作数栈是一个后入先出的栈,当一个方法开始执行时,方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。

动态链接

每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。   ——《深入理解Java虚拟机》

方法返回地址

方法执行时有两种退出情况:

  1. 正常退出,即正常执行到任何方法的返回字节码指令,如 RETURN、IRETURN、ARETURN 等;
  2. 异常退出。

无论何种退出情况,都将返回至方法当前被调用的位置。方法退出的过程相当于弹出当前栈帧,退出可能有三种方式:

  1. 返回值压入上层调用栈帧。
  2. 异常信息抛给能够处理的栈帧。
  3. PC计数器指向方法调用后的下一条指令。
本地方法栈

本地方法栈与虚拟机栈所发挥的作用非常相似,区别在于虚拟机栈为虚拟机执行Java方法,本地方法栈为虚拟机执行本地方法。

HotSpot虚拟机中本地方法栈、虚拟机栈合二为一,与虚拟机栈一样在栈深度溢出、栈扩展失败时会分别抛出StackOverflowError、OutOfMemoryError异常。

Java堆

因为Java堆是垃圾收集器管理的内存区域,提到Java堆就会引出很多概念:新生代、老年代、永久代、Eden、Survivor…这些先不做讨论,后面会在GC与垃圾收集器的时候统一整理,这里仅描述Java堆的概念。

对于Java应用程序来说,Java堆是虚拟机管理的内存中最大的一块,Java堆是被所有线程共享的内存区域,在Java中几乎所有对象实例都可以在这里分配内存。

Java堆可以处于物理上不连续的内存空间,但在逻辑上它应该被视为连续的。                             ——《Java虚拟机规范》

Java堆既可以实现成固定大小、也可扩展,目前主流的Java虚拟机都是可扩展实现的,相信大家也都了解过(同通-Xmx、-Xms设定)。如果Java堆中没有内存完成实力分配,并且堆无法扩展,将会抛出OutOfMemoryError异常。

方法区

方法区与Java堆一样、属于线程共享的内存区域,用来存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等等数据。

如果是老程序员的话一定听说过“永久代”这个概念(仅限于HotSpot虚拟机,其他虚拟机并无“永久代”概念),特别是在JDK8以前,许多人更愿意称呼方法去为“永久代”。为什么称为“永久代”呢?在《Java虚拟机规范》中对于方法区的约束非常宽松,除了和Java堆一样不需要连续的内存、可以选择固定大小或可扩展外,甚至还可以选择不实现垃圾收集。这也就导致了垃圾收集行为在这个区域的确是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了,这个区域的内存回收主要针对常量池的回收和对类型的卸载,回收效果可想而知很难令人满意,尤其类型卸载,条件十分苛刻,其中出现了很多严重的Bug,就是由于低版本的HotSpot虚拟机对此区域未完全回收导致的内存泄漏。

这里还需要了解的是,JDK7时已经把原本放在永久代的字符串常量池、静态变量灯光移到了Java堆中;JDK8时完全废弃了永久代的概念,改用了本地内存实现的元空间来代替,将JDK7中永久代还剩余的内容全部移动到元空间中。

为什么要使用元空间取代永久代的实现?

  1. 字符串存在永久代中,容易出现性能问题和内存溢出。
  2. 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
  3. 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
  4. 将 HotSpot 与 JRockit 合二为一。

《Java虚拟机规范》中规定,如果方法区无法满足新的内存分配需求,将会抛出OutOfMemoryError异常。

运行时常量池

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

一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。

运行时常量池相对于 Class 文件常量池的另外一个重要特征是具备动态性,Java 语言并不要求常量一定只有编译期才能产生,也就是并非预置入 Class 文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是 String 类的 intern() 方法。

既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。   ——《深入理解Java虚拟机》

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是 Java 虚拟机规范中定义的内存区域。

在 JDK 1.4 中新加入了 NIO,引入了一种基于通道(Channel)与缓冲区(Buffer)的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在 Java 堆和 Native 堆中来回复制数据。

显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括 RAM 以及 SWAP 区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现 OutOfMemoryError 异常。   ——《深入理解Java虚拟机》

创建一个对象都发生了什么?

众所周知,Java是一门面向对象的语言,通常创建对象是只需要一个new关键字就可以了,那么对象创建究竟是一个怎么样的过程呢?

  1. 当Java虚拟机遇到一条字节码new指令时,首先会检查这个指令的参数是否能在常量池中定位到一个类的引用,并检查符号引用代表的类是否已被加载、解析和初始化。若没有则执行相应的类加载过程;

  2. 类加载的检查通过后,虚拟机将为新生对象分配内存。为对象分配空间的任务实际上相当于将一块确定大小的内存块从Java堆中划分出来。可分为两种分配方式,“指针碰撞”(Bump The Pointer)、“空闲列表”(Free List):

    • “指针碰撞”: 此处假设Java堆中的内存绝对规整,已使用与空闲的内存分别放在两边,中间放着一个指针作为分界点,那么所分配内存就仅仅是把那个指针向空闲空间方向挪动一段与对象大小相等的距离,这种分配方式称为“指针碰撞”
    • “空闲列表”: 此处假设Java堆中的内存并不规整,已经使用的内存和空闲内存互相交错,这种情况下无法进行简单的指针碰撞处理,虚拟机则需要维护一个列表,用来记录那些内存块可用,在进行分配时找到足够使用的空间划分给对象实例,并更新列表记录,这种分配方式称为“空闲列表”

    选择哪一种分配方式由Java堆是否规整决定,而Java堆的规整与否由所采用的垃圾收集器是否携带压缩整理来决定。因此,当使用Serial、ParNew等带压缩整理的收集器,系统采用分配算法为指针碰撞,简单高效;而当使用CMS这种基于清除算法的收集器时,理论上就只能采用空闲列表来分配内存。

    此处还要考虑一个问题:对象创建在虚拟机中时非常频繁的行为,即使仅修改指针指向位置,在并发情况下也并不是线程安全的,存在正在给对象A分配内存,指针未来得及修改,对象B又使用了原来的指针分配内存。解决此问题有两种方案:一种虚拟机采用CAS配上失败重试的方法保证原子性;另外一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲区(TLAB),哪个线程要分配内存,就在哪个线程的本地缓冲区中分配,只有本地缓冲区用完了,分配新的缓存区时才需要同步锁定。虚拟机是否启用TLAB可以通过 -XX:+/UseTLAB 参数配置。

  3. 内存分配完成后,虚拟机必须将分配到内存空间(不包括对象头)都初始化为零值,如果使用了TLAB的话,这一项工作也可以提前至TLAB分配时顺便进行

  4. 接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据、对象的哈希码、对象的GC分代年龄等信息。这些信息存放在对象头(Mark Word)之中。

  5. 在上面步骤完成后,从虚拟机的视角来看一个新的对象已经产生了。但是从Java程序的视角,对象的创建才刚刚开始,构造函数(Class文件中的< init >())方法还没有执行,所有的字段都为默认零值。在new指令执行后会接着执行< init >()方法,进行初始化对象,这样对象才算完全构造成功。

垃圾收集算法

垃圾收集器(Garbage Collection,简称GC),为什么要存在GC呢?这里采用我之前在Boss的回答:

img

写代码的路还长,不论代码写的多么完美,总有可能出现内存溢出、内存泄露问题,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就必须对“自动化的GC”进行监控与调节,了解GC是必经之路。

可达性分析算法

当前主流的商用程序语言(Java、C#等)内存管理子系统,均通过可达性分析算法来判定对象是否存活:

img

利用可达性分析算法判断对象是否可回收

可达性分析算法的基本思路如上图,通过一系列成为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程中所走过的路径成为“引用链”,如果某个对象到GC Roots间没有任何引用链相连接,则证明此对象是不可能再被使用的。

强引用、软引用、弱引用、虚引用?

既然可达性分析是根据GC Roots到对象是否可达,判断回收与否,那么就要提到“引用”这个概念:

JDK1.2前,引用表示:如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称该reference数据是代表某块内存、某个对象的引用。

JDK1.2之后,Java对引用的概念进行了扩充,分为强引用、软引用、弱引用、虚引用,强度由强到弱:

  • 强引用(StrongReference):强引用是使用最普遍的引用。如果一个对象具有强引用,那垃圾回收器绝不会回收它。如:Object sr = new Object();当内存空间不足时,Java虚拟机宁愿抛出OutOfMemoryError错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足的问题。
  • 软引用(SoftReference):如果一个对象只具有软引用,则内存空间充足时,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。只要垃圾回收器没有回收它,该对象就可以被程序使用,可通过SoftReference类实现。
  • 弱引用(WeakReference):弱引用与软引用的区别在于:只具有弱引用的对象拥有更短暂的生命周期。在垃圾回收器线程扫描它所管辖的内存区域的过程中,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。不过,由于垃圾回收器是一个优先级很低的线程,因此不一定会很快发现那些只具有弱引用的对象,通过WeakReference类实现。
  • 虚引用(PhantomReference):虚引用顾名思义,就是形同虚设。与其他几种引用都不同,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,通过PhantomReference类实现。

分代收集理论

大多数的垃圾收集器都遵循了“分代收集理论”(Generational Collection)进行设计,其建立在三个假说之上:

  1. 弱分代假说:绝大多数对象都是朝生熄灭的。
  2. 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡。
  3. 跨代引用假说:跨代引用相对于同代引用来说仅占极少数。

假说1、2奠定了多款垃圾收集器的一致设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄分配到不同的区域之中储存。在Java堆划分出不同区域后,垃圾收集器才可以每次只回收一个或一些区域(Minor GC、Major GC、Full GC),以及对应衍生出的“标记-复制算法”、“标记-清除算法”、“标记-整理算法”。

  • 新生代收集(Minor GC/Young GC):表示目标只是新生代的垃圾收集。
  • 老年代收集(Major GC/Old GC):表示目标只是老年代的垃圾收集。
  • 混合收集(Mixed GC):表示目标是收集整个新生代以及部分老年代的垃圾收集。
  • 整堆收集(Full GC):收集整个Java堆和方法区的垃圾收集

此时我们思考这样一个问题,假设现在要进行Minor GC,但新生代的对象被老年代所引用该怎么解决?为了找出该区域存活的对象,我们势必在GC Roots之外,再额外遍历整个老年代中所有对象来确保可达性分析的结果是正确的,反之亦然,这样无疑会为内存回收带来很大的性能负担,为了决绝这个问题我们引出了假说3,如果某个和新生代对象存在跨代引用,由于老年代难以消亡,该引用会使得新生代同样得以存活,进而在年龄增长后晋升为老年代,这样跨代引用也就消除了。因此新生代出现了全新的数据结构“记忆集”(Rememvered Set),此结构将老年代划分为若干小块,标识出老年代的哪一块内存存在跨代引用,此后发生Minor GC时,仅将存在跨代引用的部分加入GC Roots扫描范围。

标记-清除算法

标记-清除算法是最早出现的垃圾收集算法,见名知意,算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活对象后回收所有未标记对象。

img

标记-清除算法示意图

其主要缺点有两个:

  • 执行效率不稳定,若Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须进行大量标记和清除的动作,导致标记和清除的两个过程的执行效率都随对象数量增长而降低。
  • 内存空间碎片化,标记、清除之后会产生大量的不连续的内存碎片,空间碎片太多可能导致当以后程序运行过程中需要分配较大的对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作

标记-复制算法

为了解决标记-清除算法面对大量可回收对象时执行效率低的问题提出了标记-复制算法,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块,当这一块用完了就将还存活的对象复制到另外一块上,然后再把已使用过的内存空间一次清理掉。

img

标记-复制算法示意图

而这种方法的缺点也显而易见,直接将可用内存缩小为原来的一半,空间浪费很大,并且如果内存中多数对象都是存活的,那么这种算法会产生大量的空间复制开销。然而,现在商用的Java虚拟机大多数都采用了这种收集算法回收新生代,这是为什么呢?

我们可以回顾一下分代收集理论的第一条假说,绝大多数对象都是朝生熄灭的,再新生代更是如此,IBM公司曾专门研究此问题并得出结论——新生代中的对象有98%都熬不过第一轮收集,因此并不需要按照1:1的比例来划分新生代的内存空间。在1989年,Andrew Appel针对“朝生熄灭”的特点提出了一种更为优化的半区复制分代策略,简称“Appel式回收”,在HotSpot虚拟机的Serial、ParNew等新生代收集器均采用此策略来设计新生代的内存布局。

Appel式收集的具体做法是把新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将EdenSurvivor中仍然存活的对象一次性复制到另外的Survivor空间上,然后清理掉Eden和已经使用的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比为8:1,也就是每次新生代中可用内存空间为整个新生代的90%,只有一个Survivor是会被浪费的,因为98%的对象可以被回收仅仅是“普通场景”下测试的数据,所有Appel还有一个“逃生门”的设定,当Survivor空间不足以容纳一次Minor GC之后存活的对象时,就需要依赖其他内存区域(大多数为老年代)进行分配担保。

标记-整理算法

基于标记-复制算法在对象存活率较高的情况下需要进行多复制操作,效率降低问题,以及不想浪费50%的空间,或额外担保空间的情况,所以一般不会在老年代直接选用这种算法,针对老年代的存亡特征,1974年Edward Lueders提出了一种针对老年代的算法,即标记-整理算法。

其标记过程与“标记-清除算法”一致,但后续的步骤并不是对对象的清理,而是让所有存活的对象都向内存空间的一端移动,然后清除半边界以外内存。

img

标记-整理算法示意图

移动存活对象,尤其是在老年代这种有大量对象存活的区域,移动存活对象并更新所有引用这些对象的地方会是一种及为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这种暂停常被描述为“Stop The World”。

但是,如果像标记-清除算法一样不考虑移动和整理存活对象,散乱的堆中存活对象导致空间碎片化问题就只能依赖更为复杂的内存分配器和内存访问器来解决,内存的访问是用户程序最频繁的操作没有之一,假如在这个环节出现问题,势必会直接影响应用程序的吞吐量。

这就导致是否移动对象都会存在弊端,移动时内存回收更复杂,不移动内存分配更复杂。

总结

相信读完大家已经基本了解Java中的内存区域与垃圾收集算法了,本篇知识讲解了要学习垃圾收集器的一些基础知识,后续会推出常见的垃圾收集器的讲解。其实此部分知识对普通的程序编写、CRUD并无太大影响,但是我觉得原理部分的知识是必须要了解的!

尼采说过:“越是向往阳光,越要扎根于黑暗”。

人生如此,程序亦是如此,如过你向往更加前沿的技术,期望成为更高端的Java人才,那么势必要更加深入研究相对底层的东西,即便此处的东西枯燥、乏味。

我是loger,期待与大家一起成长。

最后:

最近我整理了整套**《JAVA核心知识点总结》**,说实话 ,作为一名Java程序员,不论你需不需要面试都应该好好看下这份资料。拿到手总是不亏的~我的不少粉丝也因此拿到腾讯字节快手等公司的Offer

进[Java架构资源交流群] ,找管理员获取哦-!

才,那么势必要更加深入研究相对底层的东西,即便此处的东西枯燥、乏味。

我是loger,期待与大家一起成长。

最后:

最近我整理了整套**《JAVA核心知识点总结》**,说实话 ,作为一名Java程序员,不论你需不需要面试都应该好好看下这份资料。拿到手总是不亏的~我的不少粉丝也因此拿到腾讯字节快手等公司的Offer

进[Java架构资源交流群] ,找管理员获取哦-!

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值