java - JVM知识总结

JVM ( Java Virtual Machine):Java虚拟机
 

目录

一、java内存区域与内存溢出异常

1、运行时数据区域

1.1 程序计数器(线程私有)

1.2 java虚拟机栈(线程私有)

1.3 本地方法栈(线程私有)

1.4 Java堆(线程共享)

1.5 方法区(线程共享)

1.6 运行时常量池(线程共享)

2、Java堆溢出

二、垃圾回收与内存分配策略

1、垃圾回收

1.1 何判断对象已死

1.2  finalize( )方法:

1.3  引用类型

1.4   回收方法区

1.5  垃圾回收算法

1.6  垃圾回收器

2、内存分配策略

2.1 对象优先在Eden上分配

2.2   大对象直接进入老年代

2.3  长期存活对象进入老年代

2.4   动态对象年龄判定

2.5  空间分配担保

三、java内存模型

1、主内存与工作内存

四、volatile变量的特殊规则

1、保证此变量对所有线程可见性

2、使用volatile变量的语义是禁止指令重排


 


一、java内存区域与内存溢出异常

1、运行时数据区域

JVM会在会在java程序运行的过程中,将它所管理的内存划分为若干个不同的数据区域。这些数据区域各有各的用途,各有各的创建于销毁时间,有的区域随着JVM的创建与启动而存在,有的区域依赖用户线程的启动与结束而创建和销毁。一般来说,JVM锁管理的区域包含以下几个运行时数据区域:

线程私有:程序计数器、java虚拟机栈、本地方法栈

线程共享:java堆、方法区、运行时常量池,直接内存

1.1 程序计数器(线程私有)

程序计数器是一块较小的内存空间;

如果当前线程正在执行一个java方法,这个计数器记录的是正在执行的虚拟机字节码指令地址;

如果正在执行的是一个Native方法,这个计数器值为空。

所谓的线程私有,就是说各个线程的计数器独立存储,互不影响。

1.2 java虚拟机栈(线程私有)

虚拟机栈描述的是java方法执行的内存模型:每一个java方法执行的同时,都会创建一个栈帧用于存放局部变量表,操作数栈,动态链接,方发出口等信息。每一个方法从调用到执行完成的过程,就对应一个栈帧在虚拟机栈中入栈和出栈的过程。生命周期与线程相同。

之前一直说的栈区域实际上就是此处的虚拟机栈,再详细一点,就是虚拟机栈中的局部变量表。

局部变量表:存放了编译器所知的各种基本数据类型(8中基本数据类型),对象引用。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这些方法需要在栈帧中分配多大的局部变量空间是完全确定的,在执行期间不会改变局部变量表大小。

 

在这个区域会产生以下两种异常:

  1. 如果线程请求栈的深度大于虚拟机所允许的最大深度,会抛出StackOverflowError异常(这里可以通过-Xss这个虚拟机参数来指定栈容量)
  2. 虚拟机在动态扩展时无法申请到足够的内存,或抛出OOM(OutOfMemoryError)异常

1.3 本地方法栈(线程私有)

本地方法栈与java虚拟机栈的作用是一样的,两者的区别是本地方法栈为虚拟机使用Native方法服务,而虚拟机栈为虚拟机使用java方法服务。

本地方法(Native方法):使用一些其他语言(C, C++ 或 汇编语言等)编写的,并且被编译为基于本机硬件和操作系统的程序,对待这些方法需要特别处理。

在Hotspot虚拟机中,Java虚拟机栈和本地方法栈是同一块内存区域。

1.4 Java堆(线程共享)

Java堆是JVM所管理的最大区域,Java堆是所有线程共享的一块区域,在JVM启动时创建。此内存存放的都是对象实例。JVM规范中说到:所有的对象实例以及数组都要在堆上分配。

java堆是垃圾回收器管理的主要区域,因此又叫“GC 堆”。根据JVM规范规定的内容:Java堆可以处于物理上内存不连续的内存空间中。Java堆在主流的虚拟机中都是可扩展的(-Xmx设置最大值, -Xms设置最小值)。

如果堆中没有足够的内存完成实例分配并且对也无法在扩展时,会抛出OOM(OutOfMemoryError)异常。

1.5 方法区(线程共享)

方法区用于存放已被加载过的类信息,常量,静态变量,即时编译器编译(它把字节码转换为可执行的机器码)后的代码等数据。

和堆一样不需要连续的内存,可以动态扩展,当方法区无法满足动态内存需求时(扩展失败),一样会抛出OutOfMemoryError异常

对这块区域进行垃圾回收的主要目标是对常量池的回收和对类的卸载,但是一般比较难以实现。

在JDK8之前的HotSpoot虚拟机中,方法区也被称为“永久代”(永久代并不是意味着进入方法区之后就永久存在),但是永久代的大小很难确定,因为它收很多因素的影响,并且每次FullGC后永久代的大小都会改变,所以经常会抛出OutOfMemoryError异常,为了更方便管理方法区,JDK8移除永久代,并把方法区移至元空间,它位于本地方法中,而不是虚拟机内存中。

1.6 运行时常量池(线程共享)

运行时常量池是方法区的一部分,存放了 字面量 符号引用

字面量:字符串(JDK1.7之后移到堆中)、final常量、基本数据类型的值

符号引用:类和结构的完全限定名,字段的名称和描述符、方法的名称和描述符。

2、Java堆溢出

在上面说过了Java堆是用来存放对象实例的,只要我们不停创建对象,并且保证GC Roots到对象之间有可达路径来避免GC清除这些对象,那么在对象数量达到最大对容量后就会产生内存溢出异常。

Java堆内存的OOM异常是实际应用中最常见的内存溢出情况。当出现Java堆内存溢出时,异常堆栈信息java.lang.OutOfMemoryError会进一步提示Java heap space。当出现Java heap space 时,表示OOM发生在堆上。

内存溢出和内存泄漏:

  • 内存泄漏:泄漏对象无法被GC(对象不能被回收)
  • 内存溢出:内存对象确实还应该存活。此时要根据JVM堆参数与物理内存相比较检查是否还应该吧JVM堆内存调大;或者检查对象生命周期是否过长。(内存不够了,放不下)

二、垃圾回收与内存分配策略

1、垃圾回收

关于在这之前的Java运行时内存各个区域的分析:程序计数器、虚拟机栈、本地方法栈这三个部分的生命周期与线程相关,随线程而生,随线程而灭。并且这三个区域的内存分配与回收具有确定性,因为当方法结束或者线程结束时,内存自然就跟着线程回收了。因此下面说的内存分配和回收主要是针对Java堆区和方法区这两个区域。

1.1 如何判断对象已死

  • 引用计数算法:

为对象添加一个 引用计数器,当对象增加一个引用时计数器加一;当对象减少一个引用时计数器减一;引用对象为0时对象可以被回收。

在两个对象出现循环引用的情况下,此时引用计数器永远不为0,导致无法对他们进行回收。正是由于这种循环引用的存在,Java虚拟机中不采用这种算法。

    /**
    *  循环引用举例
    * */
    public class Test
    {
        public Object instance = null;
        public static void main (String[] args)
        {
            Test a = new Test();
            Test b = new Test();
            a.instance = b;
            b.instance = a;
        }
    }

 

  • 可达性分析算法:

在Java虚拟机中,采用的是可达性分析算法来判断对象该不该被回收。

此算法的核心思想是:通过一系列称为“GC Roots”的对象作为起始点向下搜索,搜索走过的路径称为“引用链”,当一个对象到GCRoots没有任何引用链时(不可达),表名此对象不可用。

在java语言中,虚拟机使用可达性分析法判断对象是否可以被回收,GC Roots一般包含以下内容:

  •    虚拟机栈中局部变量表中引用的对象

  •    本地方法栈中JNI(Native方法)引用的对象

  •    方法区中类静态属性引用的对象

  •    方法区中常量引用的对象

上面之所以说的是可能会被回收,主要是因为还有一个finalize方法:

1.2  finalize( )方法:

即使一个对象在可达性分析算法中不可达,这个对象也并非 “非死不可” ,这时候它只是出于暂缓 “行刑” 的一个阶段要宣告一个对象真正死亡,至少还要经过两次的标记过程:

  1. 如果一个对象现在和GC Roots不可达,那么它将会被第一次标记并进行一次筛选筛选条件是此对象是否有必要执行finalize方法。
  2. 当此对象没有覆盖finalize方法或者此方法已经被JVM调用过了,虚拟机将这两种情况视为 “没有必要执行finalize方法”,此时这个对象才真正的死亡。
  3. 如果这个对象被判断有必要执行finalize方法,那么这个对象将被放进一个叫F-Queue的队列中,并在稍后由虚拟机自动建立一个低优先级的Finalize线程去执行它(也就是虚拟机会除法finalize方法)。finalize方法是一个对象脱离死亡的最后一次机会,稍后GC将对F-Queue中的对象进行二次标记,如果对象在finalize方法中自救成功(和GC Roots可达),那么在第二次标记时它将会被移出 “即将回收”的集合;如果对象这个时候还是没有逃离,那么它将被回收

注意:finalize方法只会执行一次,当一个对象快死了,它有可能直接死亡或者被finalize救活,当它被拯救后再一次快死亡的时候就直接死亡,不会再调用finalize方法。

1.3  引用类型

在JDK1.2之前,java中的引用定义很传统:如果引用类型的数据中存储的数值代表的是另一块内存的起始地址,就称这块内存代表着一个引用。(这种定义很狭隘,一个对象在这种定义下只有引用和被引用两种状态)

在JDK1.2之后,对引用的概念进行了扩充,将引用分为强引用, 软引用, 弱引用和虚引用 四种,这四种引用强度依次递减。

  • 强引用:强引用指的是在程序代码之中普遍存在的,类似于 “Object obj = new Object( ) ;”这类引用,只要强引用还存在,垃圾回收器永远不会回收掉被引用的对象(被强引用关联的对象不会被回收)
  • 软引用:软引用是用来描述一些还有用但是不是必须的对象。对于软引用关联的对象,在系统将要发生内存溢出之前,会把这些对象列入回收范围之中进行二次回收,如果这次回收还是没有足够的内存,才会抛出内存溢出异常。JDK1.2之后,提供了SoftReference类来实现软引用。(被软引用关联的对象只有在内存不够的情况下才会被回收)
        Object obj = new Onject();
        SoftReference<Object> sf = new SoftReference<Object>(obj);
        sf = null;//使对象只被软引用关联
  • 弱引用:弱引用也是用来描述非必须对象的。弱引用一定会被回收,也就是说它只能存活到下一次垃圾回收之前。在JDK1.2之后,提供WeakReference来实现弱引用。
        Object obj = new Onject();
        WeakReference<Object> sf = new WeakReference<Object>(obj);
        sf = null;//使对象只被弱引用关联
  • 虚引用:虚引用是最弱的一种引用关系,又被称为幽灵引用或者幻影引用。一个对象是否有虚引用的存在,完全不会对其生存时间造成任何影响,也无法通过虚引用来获取一个对象实例。为一个对象设置虚引用的唯一目的只是在这个对象被收集器回收时,收到一个系统通知。在JDK1.2后,提供了PhantomReference类来实现虚引用。
        Object obj = new Onject();
        PhantomReference<Object> sf = new PhantomReference<Object>(obj);
        sf = null;//使对象只被虚引用关联

1.4   回收方法区

方法区的垃圾回收主要收集两部分内容:废弃常量无用的类

回收废弃常量 : 和回收java堆中的对象十分类似:以常量池中的直接字面量为例:假如现在一个字符串“abc”已经进入了常量池,但是当前系统中没有一个String类对象引用常量池中的“abc”常量,也没有在其他地方引用这个字面量,如果此时发生GC且有必要的话,这个“abc”常量就会被清理出常量池。常量池中的其他类(接口)、方法、字段的符号引用也与此类似。

判断一个类是否是无用类:需要同时满足一下三个条件

  1. 该类所有实例都已经被回收(java堆中不存在该类的任何实例)
  2. 加载该类的ClassLoader已经被回收
  3. 该类对应的Class对象没有在任何其他地方被引用,无法在任何地方通过反射访问该类的方法

满足以上三个方法也仅仅是"可以",而不是"必然"

1.5  垃圾回收算法

   标记清除算法

标记清除算法:就是先遍历一遍标记出所有需要回收的对象,在遍历一遍回收所有标记的对象。

两个不足之处:

        ◆  效率问题:遍历两次,效率低

        ◆  标记清除后会产生大量碎片空间,导致程序需要分配一个大对象时没有足够的连续空间而不得不提前触发一次垃圾回收

复制算法(新生代回收算法)

复制算法:将内存按容量大小分为大小相等的两块,每次只使用其中的一块。当这一块需要垃圾回收时,会将此区域上的存活对象复制到另一块上面,然后把已经使用过的内存清理掉。这样做的好处是每次只对整个半区进行垃圾回收,但同时每次只能使用一半的内存。

现在的商业虚拟机都是采用的这种算法来收集新生代的,但并不是将内存划分为两块一样的空间,而是一块较大的Eden和两块较小的Survivor空间。每次只是用Eden和其中的一块Survivor,垃圾回收时将Eden和使用的Survivor中存活的对象复制到另一块Survivor上,在清除Eden和刚刚使用的Survivor的空间。

当Survivor空间不够时,需要依赖其他内存(老年代)进行分配担保。

Hotspot默认Eden和Survivor的大小比例是 Eden :Survivor :Survivor = 8 :1 :1

复制算法在存活率高的时候会进行较多的复制,效率低下,因此在老年代一般不用此方法,老年代使用标记-整理算法

 

标记整理算法(老年代回收算法)

标记-整理算法:标记过程与标记-清除一致,但后续步骤不是直接清理,而是将存活的对象都向一端移动,然后直接清理掉端边界以外的部分。

 

分代收集算法

分代收集算法:当前JVM都采用的是分代收集算法,这个算法并没有什么新思想,而是根据对象的存活周期将内存分为不同的几个块。

一般情况下Java堆分为新生代老年代

  • 在新生代中,每次垃圾回收都有大批对象死去,只有少量存活,因此采用复制算法
  • 在老年代中,对象存活率高,没有额外空间对它进行分配担保,因此必须采用“标记-清理”或者“标记-整理”算法

常见问题: 请问了解Minor GC和Full GC吗?

  1.  Minor GC 又称为新生代GC,指的是发生在新生代的垃圾收集。因为java对象大多数都是 “朝生夕死” 的对象,因此Minor GC非常频繁,一般回收速度也比较快;
  2. Full GC 又称为老年代GC或者Major GC,指的是发生在老年代的垃圾收集,出现Major GC,经常会伴随至少一次的Minor GC(并非绝对:在Parallel Scavenge收集器中就有直接进行Full GC 的策略选择过程)。Major GC至少比Minor GC慢10倍以上。

1.6  垃圾回收器

Hotspot虚拟机中的7个垃圾收集器如下图(连线表示各拉机器可以配合使用):

 

串行:垃圾收集器与用户线程交替执行

并行:垃圾收集器与用户线程同时执行

单线程:垃圾收集器只使用一个线程

多线程:垃圾收集器使用多个线程

吞吐量:CPU运行用户线程的时间与CPU总消耗时间的比值

  • Serial :译为串行,它以串行方式执行,是一个单线程收集器

引用场景:Serial收集器是虚拟机运行在Client模式下的默认新生代收集器

优点是:简单高效,在单个CPU下没有线程交互的开销,因此拥有最高的单线程收集效率

注意:它的单线程并不意味着他只会使用一个CPU或一条收集线程去完成垃圾收集工作,而更重要的是它在进行垃圾收集时,必须                           暂停其他所有工作线程,直到它收集结束。

  • ParNew:多线程收集器,它是Serial的多线程版本

ParNew收集器是许多运行在Server模式下的虚拟机中首选新生代收集器

  • Parallel Scavenge:多线程收集器,其他收集器的目标是尽量缩短用户线程的等待时间,而它则是达到一个可控制的吞吐量。

Parallel Scavenge收集器是新生代收集器,并行GC,使用的是复制算法

  • Serial Old:是Serial的老年代版本,单线程收集器,使用标记-整理算法

Parallel Old收集器:老年代收集器,并行GC,是Parallel Scavenge的老年版本,多线程,使用标记 - 整理算法

应用场景:在注重吞吐量以及CPU资源敏感的场合,可以考虑使用 Parallel Scavenge加Parallel Old收集器

  • CMS收集器:老年代收集器,并发GC,是一种以获取 最短回收停顿时间为目标的收集器

优点:并发收集,低停顿

缺点:CMS收集器对CPU资源非常敏感,面向并发设计的程序都对CPU资源比较敏感

            CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC产生。

  • G1收集器:(唯一 一款全区域垃圾回收器)

G1收集器它是一款面向服务端的垃圾回收器,在多CPU和大内存的场景下有很好的性能。

堆被分为新生代和老年代,其他收集器在进行垃圾收集时收集范围都是整个新生代或者整个老年代,而G1可以直接对新生代和老年代一起回收。

G1把堆分为多个大小相等的独立区域(Region),新生代和老年代不再物理隔离。

通过region的概念的引入,从而将原来一整块内存空间划分为多个小空间,使得每个小空间可以单独进行垃圾回收,这种划分方法带来的很大的灵活性,使得可预测的停顿时间模型变成可能。通过记录每个region垃圾回收时间以及回收所得的空间,并维护一个优先列表,每次根据允许的收集时间优先回收价值最大的region。

G1具备如下特点:

  1. 空间整合:从整体上来看是基于“标记 - 整理算法”实现的收集器,从局部来看,是基于复制算法实现的,这意味着运行过程中不会产生内存空间碎片。
  2. 可预测的停顿:能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不超过N毫秒。

2、内存分配策略

2.1 对象优先在Eden上分配

大多数情况下,对象在新生代Eden中分配。当Eden中没有足够的空间进行分配时,虚拟机将发生一次Minor GC。

2.2   大对象直接进入老年代

大对象:指的是需要大量连续空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。大对象对虚拟机的内存分配是一个坏消息,经常出现大对象容易导致内存还有不少空间时就提前触发GC以获取足够的连续空间来放置大对象。

虚拟机提供了一个-XX:pretenureSizeThreshold参数,令大于这个设置的值的对象直接在老年代分配。这样做的目的是在于避免Eden区以及两个Survivor区之间发生大量的内存复制(新生代采用复制算法收集内存)

2.3  长期存活对象进入老年代

虚拟机给每个对象定义了一个对象年龄计数器(Age)。如果对象在Eden出生斌经过一次Minor GC后仍然存活,并且能被Survivor容纳,将被移动到Survivor空间中,并且把对象年龄设为1,对象在Survivor中每熬过一次Minor GC,年龄就会增加一岁。当他的年龄增加到一定程度,将晋升到老年代中,对象晋升到老年代中的年龄阈值,可以通过-XX:MaxTenuringThreshold设置。

2.4   动态对象年龄判定

为了更好的适应不同程序的内存状况,JVM并不是永远要求对象的年龄达到MaxTenuringThreshold才能景升到老年代。如果在Survivor中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代。无需等到MaxTenuringThreshold的年龄要求。

2.5  空间分配担保

在发生Minor GC之前,虚拟机会检查老年代最大连续的可用空间是否大于新生代所有对象的总空间,如果大于,则此次MinorGC是安全的;如果小于,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果HandlePromotionFailure=true,,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。如果大于,则尝试进行一次Minor GC,但这次Minor GC是有风险的;如果小于或者HandlePromotionFailure = false,则改为进行一次Full GC。

三、java内存模型

JVM定义了一种java的内存模型(JMM),用来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让java程序在各个平台下都能达到一致的内存访问效果。(C/C++直接使用物理硬件和操作系统的内存模型,因此,会由于不同平台下的内存模型的差异,可能导致程序在不同平台上运行出现并发访问错误)

1、主内存与工作内存

java内存模型的主要目标是定义程序中各个变量的访问规则,即在JVM中将变量存入内存和从内存中取出变量这样的底层细节。此处的变量包括实例字段,静态字段和构成数组对象的元素,但不包括局部变量和方法参数,因为或两者线程是私有的,不会被线程共享。

java内存模型规定了所有的变量都存储在主内存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存拷贝副本,线程对变量的所有操作(读取,赋值等等)都必须在工作内存中进行,而不能直接读写主内存中的变量。不同的线程之间也无法访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。线程、主内存和工作内存之间的关系如下:

主内存与工作内存之间的具体交互协议,即一个变量如何从主内存中拷贝到工作内存、如何从工作内存中同步会主内存之类的实现细节,java内存模型中定义了如下八种操作来完成。JVM实现时必须保证下面体积的每一种操作的原子性(不可再分)

  • lock(锁定):作用于主内存,他把一个变量标示为一条线程独占的状态。
  • unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才能被其他线程锁定。
  • read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
  • load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
  • use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎。
  • assign(赋值):作用于工作内存的变量,它把一个从工作引擎接收到的值赋给工作内存中的变量。
  • store(存储):作用于工作内存的变量它把工作内存中一个变量的值传送到主内存中,以便后续的write操作。
  • write(写入):作用于主内存的变量,它把store操作从工作内存得到的值放入主内存的变量中。

Java内存模型的三大特性:

  1. 原子性:由java内存模型来直接保证原子性变量操作,包括read,load,assign,use,store和write。大致可以认为,基本数据类型的访问读写是具备原子性的。若需要更大范围的原子性,需要synchronized关键字约束。(即一个操作或者多个操作要么全部执行并且执行过程不会被其它任何因素打断,要么就都不执行)
  2. 可见性:可见性是指当一个线程修改了共享变量的值,其他线程就能够立即得知这个修改,volatile,synchronized,final三个关键字可以实现可见性
  3. 有序性:如果在本线程内观察,所有的操作都是有序的;如果在线程中观察另一个线程,所有的操作都是无序的。前半句表示线程内表现为串行,后半句指“指令重排”和“工作内存与主内存同步延迟”的现象。

四、volatile变量的特殊规则

1、保证此变量对所有线程可见性

volatile定义的变量,保证此变量对所有线程的可见性,这里的可见性指的是:当一条线程修改了这个变量的值,新值对于其他线程来说是立即得知的。(普通变量做不到这一点:普通变量的值在线程间传递均需要通过主内存来完成,例如 线程A修改一个普通变量的值,然后像主内存中进行回写,另一条线程B在线程A将新值写回主内存之后再从主内存中进行读取操作,新值才会对线程B可见)

volatile变量在各个线程中是一致的,但volatile变量的运算在并发下一样是不安全的,因为在java中并非原子操作。

volatile变量只保证可见性,在不符合以下两条规则的运算场景中,我们仍需要通过加锁(synchronized或lock)来保证原子性

  • 运算结果并不依赖变量的当前值,或者能够保证只有单一的线程修改变量的值
  • 变量不需要与其他状态变量共同参与不变约束

2、使用volatile变量的语义是禁止指令重排

volatile关键字的禁止指令重排序有一下意思:

  • 当程序执行到volatile变量的读操作或者写操作时,在其前面的操作肯定全部已经执行,且结果已经对后面的可见,在其后面的肯定还没有执行。
  • 在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行
//flag 为 volatile 变量

x = 2;            // 语句1
y = 3;            // 语句2
flag = true;      // 语句3
x = 4;            // 语句4
y = 5;            // 语句5
// 此时不能将语句3放到语句1,2前面;也不能将语句3放到4,5后面;
// 但是此时语句1,2的顺序、语句4,5的顺序不会做任何保证

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值