【34】玩转垃圾回收机制

(1)一个人只要自己不放弃自己,整个世界也不会放弃你.
(2)天生我才必有大用
(3)不能忍受学习之苦就一定要忍受生活之苦,这是多么痛苦而深刻的领悟.
(4)做难事必有所得
(5)精神乃真正的刀锋
(6)战胜对手有两次,第一次在内心中.
(7)好好活就是做有意义的事情.
(8)亡羊补牢,为时未晚
(9)科技领域,没有捷径与投机取巧。
(10)有实力,一年365天都是应聘的旺季,没实力,天天都是应聘的淡季。
(11)基础不牢,地动天摇
(12)写博客初心:成长自己,辅助他人。当某一天离开人世,希望博客中的思想还能帮人指引方向.
(13)编写实属不易,若喜欢或者对你有帮助记得点赞+关注或者收藏哦~

玩转垃圾回收机制

1.垃圾回收算法与垃圾收集器

1.1分代收集理论

在这里插入图片描述

(1)当前商业虚拟机的垃圾收集器,大多遵循“分代收集”的理论来进行设计,这个理论大体上是这么描述的:

  • 绝大部分的对象都是朝生夕死。区域:新生代
  • 熬过多次垃圾回收的对象就越难回收。区域:老年代

(2)根据以上两个理论,朝生夕死的对象放一个区域,难回收的对象放另外一个区域,这个就构成了新生代和老年代。

(3)新生代中分为3个区域,老年代中分为1个区

(4)在JVM里面新生代占堆空间的1/3,老年代占堆空间的2/3.

(5)垃圾回收器本身是一个功能,一般是使用线程来运行。垃圾回收器如果对新生代进行回收,叫做Minor GC或Young GC.在分代收集理论中,因为划分了不同的区域,所以就采用分代收集。

(6)如果新生代划分的空间不够了, 垃圾回收器就会介入,介入之后,发生了GC,就是垃圾回收。这个动作称之为Minor GC,或者是Young GC。

(7)如果老年代也满了,发生的GC称之为Major GC或者为Old GC。

(8)在JVM中间提供了很多的垃圾回收器,CMS负责收集老年代垃圾,它会去执行一次Full GC。FullGC不仅回收新生代、老年代,还要回收方法区的垃圾。

1.2GC种类

市面上发生垃圾回收的叫法很多,我大体整理了一下:

(1)新生代回收(Minor GC/Young GC):指只是进行新生代的回收。

(2)老年代回收(Major GC/Old GC):指只是进行老年代的回收。目前只有CMS垃圾回收器会有这个单独的收集老年代的行为。(Major GC定义是比较混乱,有说指是老年代,有的说是做整个堆的收集,这个需要你根据别人的场景来定,没有固定的说法)

(3)整堆收集(Full GC):收集整个Java堆和方法区(注意包含方法区)

1.2.1垃圾回收示例
/**
 * @author XiongJie
 * @version appVer
 * @Package com.gdc.javabase.jvm.ex3
 * @file
 * @Description:垃圾回收示例
 * (1)VM参数: -XX:+PrintGCDetails
 * 打印垃圾回收详情
 *
 * (2)-XX:+UseConcMarkSweepGC -XX:-UseParNewGC
 * @date 2021-4-28 09:51
 * @since appVer
 */

public class StopWorld {

    /*不停往list中填充数据*/
    //就使用不断的填充 堆 -- 触发GC
    public static class FillListThread extends Thread{

        List<byte[]> list = new LinkedList<>();

        @Override
        public void run() {
            try {
                while(true){
                    if(list.size()*512/1024/1024>=990){
                        list.clear();
                        System.out.println("list is clear");
                    }
                    byte[] bl;
                    for(int i=0;i<100;i++){
                        bl = new byte[512];
                        list.add(bl);
                    }
                    Thread.sleep(1);
                }

            } catch (Exception e) {
            }
        }
    }

    /*每100ms定时打印*/
    public static class TimerThread extends Thread{
        public final static long startTime = System.currentTimeMillis();
        @Override
        public void run() {
            try {
                while(true){
                    long t =  System.currentTimeMillis()-startTime;
                    System.out.println(t/1000+"."+t%1000);
                    Thread.sleep(100); //0.1s
                }

            } catch (Exception e) {
            }
        }
    }

    public static void main(String[] args) {
        //填充对象线程和打印线程同时启动
        FillListThread myThread = new FillListThread(); //造成GC,造成STW
        TimerThread timerThread = new TimerThread(); //时间打印线程
        myThread.start();
        timerThread.start();
    }
}
1.2.2运行结果

在这里插入图片描述

1.3垃圾回收算法

(1)采用分代收集理论划分为新生代与老年代之后,还要清楚垃圾回收算法。
(2)每个区域采用的垃圾回收算法是不同的,在新生代里面都是采用复制算法,在老年代里面一般采用两种,标记清除算法,标记整理算法。

1.3.1复制算法(copying)

在这里插入图片描述

(1)将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。

(2)当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。

(3)这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半。

(4)注意:内存移动是必须实打实的移动(复制),不能使用指针玩。

(5)复制回收算法适合于新生代,因为大部分对象朝生夕死,那么复制过去的对象比较少,效率自然就高,另外一半的一次性清理是很快的。

1.3.1.1Appel式回收

(1)一种更加优化的复制回收分代策略:具体做法是分配一块较大的Eden区和两块较小的Survivor空间(你可以叫做From或者To,也可以叫做Survivor1和Survivor2)

(2)专门研究表明,新生代中的对象98%是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor[1]。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。

(3)HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存会被“浪费”。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)

1.3.2标记-清除算法(Mark-Sweep)

在这里插入图片描述

(1)算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。

(2)回收效率不稳定,如果大部分对象是朝生夕死,那么回收效率降低,因为需要大量标记对象和回收对象,对比复制回收效率很低。

(3)它的主要不足空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。

(4)回收的时候如果需要回收的对象越多,需要做的标记和清除的工作越多,所以标记清除算法适用于老年代。复制回收算法适用于新生代。

1.3.3 标记-整理算法(Mark-Compact)

在这里插入图片描述

(1)首先标记出所有需要回收的对象,在标记完成后,后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。标记整理算法虽然没有内存碎片,但是效率偏低。

(2)我们看到标记整理与标记清除算法的区别主要在于对象的移动。对象移动不单单会加重系统负担,同时需要全程暂停用户线程才能进行,同时所有引用对象的地方都需要更新。

(3)所以看到,老年代采用的标记整理算法与标记清除算法,各有优点,各有缺点。

1.4JVM中常见的垃圾收集器

在这里插入图片描述

1.4.1分代收集的思想

(1)在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。

(2)而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记—清理”或者“标记—整理”算法来进行回收。

(3)请记住下图的垃圾收集器和之间的连线关系。
具体看官网JVM参数:https://docs.oracle.com/javase/8/docs/technotes/tools/unix/java.html

(4)并行:垃圾收集的多线程的同时进行。
(5)并发:垃圾收集的多线程和应用(业务)的多线程同时进行。

(6)注:吞吐量=运行用户代码时间/(运行用户代码时间+ 垃圾收集时间)
(7)垃圾收集时间= 垃圾回收频率 * 单次垃圾回收时间

1.5简单的垃圾回收器工作示意图

在这里插入图片描述

1.5.1Serial/Serial Old

最古老的,单线程,独占式,成熟,适合单CPU 服务器
-XX:+UseSerialGC 新生代和老年代都用串行收集器
-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old
-XX:+UseParallelGC 新生代使用ParallerGC,老年代使用Serial Old

1.5.2ParNew

和Serial基本没区别,唯一的区别:多线程,多CPU的,停顿时间比Serial少
-XX:+UseParNewGC 新生代使用ParNew,老年代使用Serial Old

1.5.3Parallel Scavenge(ParallerGC)/Parallel Old

(1)关注吞吐量的垃圾收集器,高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

(2)所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

1.6CMS垃圾回收器工作示意图

(1)Concurrent Mark Sweep (CMS)

在这里插入图片描述

(1)收集器是一种以获取最短回收停顿时间为目标的收集器。

(2)目前很大一部分的Java应用集中在互联网站或者B/S系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求。

(3)从名字(包含“Mark Sweep”)上就可以看出,CMS收集器是基于“标记—清除”算法实现的,它的运作过程相对于前面几种收集器来说更复杂一些,整个过程分为4个步骤,包括:

  • 初始标记-短暂,仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。

  • 并发标记-和用户的应用程序同时进行,进行GC Roots追踪的过程,标记从GCRoots开始关联的所有对象开始遍历整个可达分析路径的对象。这个时间比较长,所以采用并发处理(垃圾回收器线程和用户线程同时工作)

  • 重新标记-短暂,为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。

  • 并发清除

由于整个过程中耗时最长的并发标记和并发清除过程收集器线程都可以与用户线程一起工作,所以,从总体上来说,CMS收集器的内存回收过程是与用户线程一起并发执行的。

-XX:+UseConcMarkSweepGC ,表示新生代使用ParNew,老年代的用CMS。

(4)CPU敏感:CMS对处理器资源敏感,毕竟采用了并发的收集、当处理核心数不足4个时,CMS对用户的影响较大。

(5)浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就称为“浮动垃圾”。

(6)由于浮动垃圾的存在,因此需要预留出一部分内存,意味着 CMS 收集不能像其它收集器那样等待老年代快满的时候再回收。

(7)在1.6的版本中老年代空间使用率阈值(92%)

(8)如果预留的内存不够存放浮动垃圾,就会出现 Concurrent Mode Failure,这时虚拟机将临时启用 Serial Old 来替代 CMS。

(9)会产生空间碎片:标记 - 清除算法会导致产生不连续的空间碎片

(10)总体来说,CMS是JVM推出了第一款并发垃圾收集器,所以还是非常有代表性。

(11)但是最大的问题是CMS采用了标记清除算法,所以会有内存碎片,当碎片较多时,给大对象的分配带来很大的麻烦,为了解决这个问题,CMS提供一个参数:
-XX:+UseCMSCompactAtFullCollection,一般是开启的,如果分配不了大对象,就进行内存碎片的整理过程。

(12)这个地方一般会使用Serial Old ,因为Serial Old是一个单线程,所以如果内存空间很大、且对象较多时,CMS发生这样情况会很卡。

(13)Android 、NDK用的就是CMS垃圾回收器

2.StopTheWorld详解

在这里插入图片描述

(1)任何的GC收集器都会进行业务线程的暂停,这个就是STW,Stop The World,所以我们GC调优的目标就是尽可能的减少STW的时间和次数。

2.1G1

在这里插入图片描述

(1)-XX:+UseG1GC

(2)内存布局

在G1之前的其他收集器进行收集的范围都是整个新生代或者老年代,而G1不再是这样。使用G1收集器时,Java堆的内存布局就与其他收集器有很大差别,它将整个Java堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region(不需要连续)的集合。

(3)每一个区域可以通过参数-XX:G1HeapRegionSize=size 来设置。

(4)Region中还有一块特殊区域Humongous区域,专门用于存储大对象,一般只要认为一个对象超过了Region容量的一般可认为是大对象,如果对象超级大,那么使用连续的N个Humongous区域来存储。

(5)并行与并发:G1能充分利用多CPU、多核环境下的硬件优势,使用多个CPU(CPU或者CPU核心)来缩短Stop-The-World停顿的时间,部分其他收集器原本需要停顿Java线程执行的GC动作,G1收集器仍然可以通过并发的方式让Java程序继续执行。

(6)分代收集:与其他收集器一样,分代概念在G1中依然得以保留。虽然G1可以不需要其他收集器配合就能独立管理整个GC堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次GC的旧对象以获取更好的收集效果。

(7)空间整合:与CMS的“标记—清理”算法不同,G1从整体来看是基于“标记—整理”算法实现的收集器,从局部(两个Region之间)上来看是基于“复制”算法实现的,但无论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次GC。

(8)追求停顿时间:

-XX:MaxGCPauseMillis 指定目标的最大停顿时间,G1尝试调整新生代和老年代的比例,堆大小,晋升年龄来达到这个目标时间。

-XX:ParallerGCThreads:设置GC的工作线程数量。

(9)一般在G1和CMS中间选择的话平衡点在6~8G,只有内存比较大G1才能发挥优势。

3.常量池与String

在这里插入图片描述

(1)静态常量池

主要存放:

  • 字面量:int i = 13; 13是字面量
  • 符号引用:String 这个类,java.lang.String
  • 类与类方法相关的信息。

(2)运行时常量池
类加载–运行时数据区—方法区(逻辑区域)

3.1String 对象是如何实现的?

(1)了解了 String 对象的实现后,你有没有发现在实现代码中 String 类被 final 关键字修饰了,而且变量 char 数组也被 final 修饰了。

(2)我们知道类被 final 修饰代表该类不可继承,而 char[]被 final+private 修饰,代表了 String 对象不可被更改。

(3)Java 实现的这个特性叫作 String 对象的不可变性,即 String 对象一旦创建成功,就不能再对它进行改变。

(4)在 Java 中,通常有两种创建字符串对象的方式

  • 一种是通过字符串常量的方式创建,如 String str=“abc”;

这种方式首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。这种方式可以减少同一个值的字符串对象的重复创建,节约内存。

  • 另一种是字符串变量通过 new 形式的创建,如 String str = new String(“abc”)。

这种方式,首先在编译类文件时,"abc"常量字符串将会放入到常量结构中,在类加载时,“abc"将会在常量池中创建;

其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc” 字符串,在堆内存中创建一个 String 对象;最后,str 将引用 String 对象。

如果调用 intern 方法,会去查看字符串常量池中是否有等于该对象的字符串的引用,如果没有会把首次遇到的字符串的引用添加到常量池中;如果有,就返回常量池中的字符串引用。(这个版本都是基于JDK1.7及以后版本)

public class Location {

    //这个地方不会在常量池中创建
    private String city;
    private String region;

    public static void main(String[] args) {
        //JVM首先会检查该对象是否在字符串常量池中,如果在,就返回该对象引用,否则新的字符串将在常量池中被创建。
        //这种方式可以减少同一个值的字符串对象的重复创建,节约内存。
        String str ="abc";



        //首先在编译类文件时,"abc"常量字符串将会放入到常量结构中,在类加载时,“abc"将会在常量池中创建;
        //其次,在调用 new 时,JVM 命令将会调用 String 的构造函数,同时引用常量池中的"abc” 字符串,
        // 在堆内存中创建一个 String 对象;最后,str1 将引用 String 对象。
        String str1 =new String("abc");


        //这里就跟第一步类似。
        Location location = new Location();
        location.setCity("深圳");
        location.setRegion("南山");

        //首先会生成 ab 对象,再生成 abcd 对象,最后生成 abcdef 对象
        String str2= "ab" + "cd" + "ef";


        //new Sting() 会在堆内存中创建一个a的String对象,
        // “king"将会在常量池中创建
        // 在调用intern方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
        String a =new String("king").intern();
        //调用 new Sting() 会在堆内存中创建一个b的String 对象,。
        //在调用 intern 方法之后,会去常量池中查找是否有等于该字符串对象的引用,有就返回引用。
        String b = new String("king").intern();
        //所以 a 和 b 引用的是同一个对象。
        if(a==b) {
            System.out.print("a==b");
        }else{
            System.out.print("a!=b");
        }
    }

    public String getCity() {
        return city;
    }

    public void setCity(String city) {
        this.city = city;
    }

    public String getRegion() {
        return region;
    }

    public void setRegion(String region) {
        this.region = region;
    }

}

4.面试常见问题分析

4.1JVM内存结构说一下

(1)开放式题目,具体可见章节 运行时数据区域

(2)一般从两个维度出发:线程私有和线程共享。到每一个内存区域的细节点。

(3)Java 虚拟机栈是基于线程的。

哪怕你只有一个 main() 方法,也是以线程的方式运行的。在线程的生命周期中,参与计算的数据会频繁地入栈和出栈,栈的生命周期是和线程一样的。

(4)栈里的每条数据,就是栈帧。

在每个 Java 方法被调用的时候,都会创建一个栈帧,并入栈。一旦完成相应的调用,则出栈。所有的栈帧都出栈后,线程也就结束了。

(5)每个栈帧,都包含四个区域:

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

(6)本地方法栈是和虚拟机栈非常相似的一个区域,它服务的对象是 native 方法。

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

(8)堆是 JVM 上最大的内存区域,我们申请的几乎所有的对象,都是在这里存储的。我们常说的垃圾回收,操作的对象就是堆。

(9)方法区,这个区域存储的内容,包括:类的信息、常量池、方法数据、方法代码就可以了。

4.2什么情况下内存栈溢出?

(1)java.lang.StackOverflowError 如果出现了可能会是无限递归。

(2)OutOfMemoryError:不断建立线程,JVM申请栈内存,机器没有足够的内存。

4.3描述new一个对象的流程

在这里插入图片描述

见对象的分配

4.4Java对象会不会分配在栈中?

可以,如果这个对象不满足逃逸分析,那么虚拟机在特定的情况下会走栈上分配。

4.5如何判断一个对象是否被回收,有哪些算法,实际虚拟机使用得最多的是什么?

引用计数法和根可达性分析两种,用得最多是根可达性分析。

4.6GC收集算法有哪些?他们的特点是什么?

(1)复制、标记清除、标记整理。

(2)复制速度快,但是要浪费空间,不会内存碎片。标记清除空间利用率高,但是有内存碎片。标记整理算法没有内存碎片,但是要移动对象,性能较低。三种算法各有所长,各有所短。

4.7JVM中一次完整的GC流程是怎样的?对象如何晋级到老年代?

(1)对象优先在新生代区中分配,若没有足够空间,Minor GC;

(2)大对象(需要大量连续内存空间)直接进入老年态;长期存活的对象进入老年态。

(3)如果对象在新生代出生并经过第一次MGC后仍然存活,年龄+1,若年龄超过一定限制(15),则被晋升到老年态。

4.8Java中的几种引用关系,他们的区别是什么?

4.8.1强引用

一般的Object obj = new Object() ,就属于强引用。在任何情况下,只有有强引用关联(与根可达)还在,垃圾回收器就永远不会回收掉被引用的对象。

4.8.2软引用 SoftReference

一些有用但是并非必需,用软引用关联的对象,系统将要发生内存溢出(OuyOfMemory)之前,这些对象就会被回收(如果这次回收后还是没有足够的空间,才会抛出内存溢出)。

4.8.3弱引用 WeakReference

一些有用(程度比软引用更低)但是并非必需,用弱引用关联的对象,只能生存到下一次垃圾回收之前,GC发生时,不管内存够不够,都会被回收。

4.8.4虚引用 PhantomReference

幽灵引用,最弱(随时会被回收掉)
垃圾回收的时候收到一个通知,就是为了监控垃圾回收器是否正常工作。

4.9final、finally、finalize的区别?

(1)在java中,final可以用来修饰类,方法和变量(成员变量或局部变量)

(2)当用final修饰类的时,表明该类不能被其他类所继承。当我们需要让一个类永远不被继承,此时就可以用final修饰,但要注意:

(3)final类中所有的成员方法都会隐式的定义为final方法。

(4)使用final方法的原因主要有两个:

  • 把方法锁定,以防止继承类对其进行更改。
  • 效率

在早期的java版本中,会将final方法转为内嵌调用。但若方法过于庞大,可能在性能上不会有多大提升。因此在最近版本中,不需要final方法进行这些优化了。

(5)final成员变量表示常量,只能被赋值一次,赋值后其值不再改变。

(6)finally作为异常处理的一部分,它只能用在try/catch语句中,并且附带一个语句块,表示这段语句最终一定会被执行(不管有没有抛出异常),经常被用在需要释放资源的情况下

(7)Object中的Finalize方法

即使通过可达性分析判断不可达的对象,也不是“非死不可”,它还会处于“缓刑”阶段,真正要宣告一个对象死亡,需要经过两次标记过程,一次是没有找到与GCRoots的引用链,它将被第一次标记。随后进行一次筛选(如果对象覆盖了finalize),我们可以在finalize中去拯救。

所以建议大家尽量不要使用finalize,因为这个方法太不可靠。在生产中你很难控制方法的执行或者对象的调用顺序,建议大家忘了finalize方法!因为在finalize方法能做的工作,java中有更好的,比如try-finally或者其他方式可以做得更好。

4.10String s = new String(“xxx”);创建了几个对象?

(1)2个,

(2)在一开始字符串"xxx"会在加载类时,在常量池中创建一个字符串对象。

(3)调用 new时 会在堆内存中创建一个 String 对象,String 对象中的 char 数组将会引用常量池中字符串。

5.打赏鼓励

感谢您的细心阅读,您的鼓励是我写作的不竭动力!!!

5.1微信打赏

在这里插入图片描述

5.2支付宝打赏

在这里插入图片描述

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值