深入JVM底层类加载&对象布局&逃逸分析

1.JVM底层类加载

Java的每个类,在JVM中,都有一个对应的Klass类实例与之对应,存储类的元信息如:常量池、属性信息、方法信息……
klass模型类的继承结构
在这里插入图片描述
普通的Java类在JVM中对应的是instanceKlass类的实例,再来说下它的三个子类

  1. InstanceMirrorKlass:用于表示java.lang.Class,Java代码中获取到的Class对象,实际上就是这个C++类的实例,存储在堆区,学名镜像类,反射时用到
  2. InstanceRefKlass:用于表示java/lang/ref/Reference类的子类
  3. InstanceClassLoaderKlass:用于遍历某个加载器加载的类
    Java中的数组不是静态数据类型,是动态数据类型,即是运行期生成的,Java数组的元信息用ArrayKlass的子类来表示:
  4. TypeArrayKlass:用于表示基本类型的数组
  5. ObjArrayKlass:用于表示引用类型的数组
    类加载的过程
    类加载由7个步骤完成,看图
    类的生命周期是由7个阶段组成,但是类的加载说的是前5个阶段
    在这里插入图片描述
    加载
1、通过类的全限定名获取存储该类的class文件(没有指明必须从哪获取)
2、解析成运行时数据,即instanceKlass实例,存放在方法区
3、在堆区生成该类的Class对象,即instanceMirrorKlass实例
何时加载
主动使用时
1new、getstatic、putstatic、invokestatic
2、反射
3、初始化一个类的子类会去加载其父类
4、启动类(main函数所在类)
5、当使用jdk1.7动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic,REF_putstatic,REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化,则需要先出触发其初始化

预加载:包装类、String、Thread
因为没有指明必须从哪获取class文件,脑洞大开的工程师们开发了这些
1、从压缩包中读取,如jar、war
2、从网络中获取,如Web Applet
3、动态生成,如动态代理、CGLIB
4、由其他文件生成,如JSP
5、从数据库读取
6、从加密文件中读取

验证

1、文件格式验证
2、元数据验证
3、字节码验证
4、符号引用验证

准备

为静态变量分配内存、赋初值
实例变量是在创建对象的时候完成赋值的,没有赋初值一说
如果被final修饰,在编译的时候会给属性添加ConstantValue属性,准备阶段直接完成赋值,即没有赋初值这一步

解析

将常量池中的符号引用转为直接引用
解析后的信息存储在ConstantPoolCache类实例中
1、类或接口的解析
2、字段解析
3、方法解析
4、接口方法解析
何时解析
思路:
1、加载阶段解析常量池时
2、用的时候
openjdk是第二种思路,在执行特定的字节码指令之前进行解析:
anewarray、checkcast、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirtual、ldc、ldc_w、ldc2_w、multianewarray、new、putfield

初始化

执行静态代码块,完成静态变量的赋值
静态字段、静态代码段,字节码层面会生成clinit方法
方法中语句的先后顺序与代码的编写顺序相关

类加载器与SPI

类加载器

JVM中有两种类型的类加载器,由C++编写的及由Java编写的。除了启动类加载器(Bootstrap Class Loader)是由C++编写的,其他都是由Java编写的。由Java编写的类加载器都继承自类java.lang.ClassLoader。
JVM还支持自定义类加载器。后面会将。
各种类加载器之间存在着逻辑上的父子关系,但不是真正意义上的父子关系,因为它们直接没有从属关系。

在这里插入图片描述
启动类加载器

因为启动类加载器是由C++编写的,通过Java程序去查看显示的是null
因此,启动类加载器无法被Java程序调用
启动类加载器不像其他类加载器有实体,它是没有实体的,JVM将C++处理类加载的一套逻辑定义为启动类加载器

查看启动类加载器的加载路径
也可以通过-Xbootclasspath指定

URL[] urLs = Launcher.getBootstrapClassPath().getURLs();
        for (URL urL : urLs) {
            System.out.println(urL);
        }

查看扩展类加载器
可以通过java.ext.dirs指定

ClassLoader classLoader = ClassLoader.getSystemClassLoader().getParent();

        URLClassLoader urlClassLoader = (URLClassLoader) classLoader;

        URL[] urls = urlClassLoader.getURLs();
        for (URL url : urls) {
            System.out.println(url);
        }

应用类加载器
可以通过java.class.path指定

String[] urls = System.getProperty("java.class.path").split(":");

        for (String url : urls) {
            System.out.println(url);
        }

        System.out.println("================================");

        URLClassLoader classLoader = (URLClassLoader) ClassLoader.getSystemClassLoader();

        URL[] urls1 = classLoader.getURLs();
        for (URL url : urls1) {
            System.out.println(url);
        }

自定义类加载器
继承类java.lang.ClassLoader

SPI

是一种服务发现机制基于sevlet3.0。它通过在ClassPath路径下的META-INF/services文件夹查找文件,自动加载文件里所定义的类。这一机制为很多框架扩展提供了可能,比如在Dubbo、JDBC中都使用到了SPI机制

jvm
在这里插入图片描述
JVM底层是如何做的

1、去字符串常量池中去查,
  有,直接返回对应的String对象
2、如果没有,就会创建String对象、char数组对象
3、将这个String对象对应的InstanceOopDesc封装成HashtableEntry,作为StringTable的value进行存储
4new String又在堆区创建一个对象,char数组直接指向

栈到底放什么

1、如果是基本数据类型,栈中存储的就是值本身
  int a = 10;

2、如果引用类型,栈中存储的就是引用(内存地址)
  Object obj = new Object();

  oop的内存地址放入栈中

三种运行模式

JIT为什么能提升性能呢?原因是运行期的热点代码编译与缓存
JVM中有两种即时编译器,就诞生了三种运行模式
-Xint:纯字节码解释器模式
-Xcomp:纯模板解释器模式
模板解释器
    Java字节码->硬编码
    看一个C程序:模拟的就是模板解释器的底层实现
    1、申请一块内存:可读可写可执行
      JIT在Mac是无法运行的
      Mac无法申请可执行的内存块
      Unix
      mmap
    2、将处理new字节码的硬编码拿过来(硬编码怎么拿到?) lldb   解析可执行文件
    3、将处理new字节码的硬编码写入申请的内存
    4、申请一个函数指针,用这个函数指针执行这块内存
      函数指针
        执行函数的指针
    5、调用的时候,直接通过这个函数指针调用就可以了
-Xmixed:字节码解释器+模板解释器模式(默认)

两种即时编译器
jdk6以前是没有混合编译的,后来根据两种编译器的使用场景组合起来使用进一步提升性能
1、C1编译器

-client模式启动,默认启动的是C1编译器。有哪些特点呢?
1、需要收集的数据较少,即达到触发即时编译的条件较宽松
2、自带的编译优化优化的点较少
3、编译时较C2,没那么耗CPU,带来的结果是编译后生成的代码执行效率较C2低

2、C2编译器

-server模式启动。有哪些特点呢?
1、需要收集的数据较多
2、编译时很耗CPU
3、编译优化的点较多
4、编译生成的代码执行效率高

3、混合编译

目前的-server模式启动,已经不是纯粹只使用C2。程序运行初期因为产生的数据较少,这时候执行C1编译,程序执行一段时间后,收集到足够的数据,执行C2编译器

编译优化技术
HotSpot的官方的编译优化技术列表见这里。下面列举一些最有代表性的优化技术是如何运用的。

公共子表达式消除
数组边界检查消除
例如在循环体内访问数组,如果能够通过数据流分析就可以判断循环变量的取值范围永远在[0,array.length],那在循环体中就可以消除数组的上下界检查。

2方法内联
逃逸分析

逃逸分析的基本行为就是分析对象的动态作用域。当一个对象在方法中被定义后,它可能被外部方法所引用(例如作为形参传递到其它方法中去),称为方法逃逸。如果是被外部线程访问到,称为线程逃逸。如果能够证明一个对象不会逃逸到方法或者线程之外,则可能对这个对象进行一些高效的优化:

栈上分配

 -XX:+/-DoEscapeAnalysis
如果能够确定一个对象不会逃逸到方法之外,可以在栈上分配对象的内存,这样对象占用的内存* 空间可以随着栈帧出栈而销毁,减少gc的压力;

同步消除

如果逃逸分析得出对象不会逃逸到线程之外,那么对象的同步措施可以消除。

标量替换

如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆解,那么程序执行的时候可能不创建这个对象,改为在栈上分配这个方法所用到的对象的成员变量。

JIT相关的JVM参数

-XX:CompileThreshold,方法调用计数器触发JIT编译的阀值
-XX:BackEdgeThreshold,回边计数器触发OSR编译的阀值
-XX:-BackgroundCompilation,禁止JIT后台编译

热点代码缓存区

热点代码缓存是保存在方法区的,这块也是调优需要调的地方
server 编译器模式下代码缓存大小则起始于 2496KB
client 编译器模式下代码缓存大小起始于 160KB
java -XX:+PrintFlagsFinal -version | grep InitialCodeCacheSize

oop模型
在这里插入图片描述
对象的内存布局
在这里插入图片描述
在这里插入图片描述
计算对象大小

用jol-core包或者HSDB都可以看,区别是HSDB只能看基于普通类生成对象的大小,java中的数组因为是运行时生成的,故它的大小只有运行时才能知晓。

1、空对象(没有实例属性的对象)

public class CountEmptyObjectSize {

    public static void main(String[] args) {
        CountEmptyObjectSize obj = new CountEmptyObjectSize();

        System.out.println(ClassLayout.parseInstance(obj).toPrintable());
    }
}

2、普通对象

public class CountObjectSize {

    int a = 10;
    int b = 20;

    public static void main(String[] args) {
        CountObjectSize object = new CountObjectSize();

        System.out.println(ClassLayout.parseInstance(object).toPrintable());
    }
}

3、数组对象

public class CountSimpleObjectSize {

    static int[] arr = {0, 1, 2};

    public static void main(String[] args) {
        CountSimpleObjectSize test1 = new CountSimpleObjectSize();

        System.out.printf(ClassLayout.parseInstance(arr).toPrintable());
    }
}

实战:亿级流量系统调优

这里以亿级流量秒杀电商系统为例:
1、如果每个用户平均访问20个商品详情页,那访客数约等于500w(一亿 / 202、如果按转化率10%来算,那日均订单约等于50w(500w * 10%3、如果40%的订单是在秒杀前两分钟完成的,那么每秒产生1200笔订单(50w * 30% / 120s)
4、订单支付又涉及到发起支付流程、物流、优惠券、推荐、积分等环节,导致产生大量对象,这里我们假设整个支付流程生成的对象约等于20K,那每秒在Eden区生成的对象约等于20M(1200* 20K)
5、在生产环境中,订单模块还涉及到百万商家查询订单、改价、包邮、发货等其他操作,又会产生大量对象,我们放大10倍,即每秒在Eden区生成的对象约等于200M(其实这里就是在大并发时刻可以考虑服务降级的地方,架构其实就是取舍)
这里的假设数据都是大部分电商系统的通用概率,是有一定代表性的。
如果你作为这个系统的架构师,面对这样的场景,你会如何做JVM调优呢?即将运行该系统的JVM堆区设置成多大呢?

在这里插入图片描述
垃圾判断算法
即判断JVM中的所有对象,哪些对象是存活的,哪些对象可回收的算法。
引用计数算法
最简单的垃圾判断算法。
在对象中添加一个属性用于标记对象被引用的次数,每多一个其他对象引用,计数+1,当引用失效时,计数-1,如果计数=0,表示没有其他对象引用,就可以被回收。
这个算法无法解决循环依赖的问题。
在这里插入图片描述
可达性分析算法
通过一系列被称为“GC Roots”的根对象作为起始节点集,从这些节点开始,根据引用关系链向下搜索,如果某个对象无法被搜索到,则说明该对象无引用执行,可回收。相反,则对象处于存活状态,不可回收。
JVM中的实现是找到存活对象,未打标记的就是无用对象,GC时会回收。
在这里插入图片描述
哪些对象可以作为GC Root呢:
• 所有Java线程当前活跃的栈帧里指向GC堆里的对象的引用;换句话说,当前所有正在被调用的方法的引用类型的参数/局部变量/临时值。
• VM的一些静态数据结构里指向GC堆里的对象的引用,例如说HotSpot VM里的Universe里有很多这样的引用。
• JNI handles,包括global handles和local handles
• (看情况)所有当前被加载的Java类
• (看情况)Java类的引用类型静态变量
• (看情况)Java类的运行时常量池里的引用类型常量(String或Class类型)
• (看情况)String常量池(StringTable)里的引用
垃圾收集器

目前JVM中的收集器有九种,了解5个,详解2个。因为并发、分区管理式的收集器才是未来的趋势。
注意:标记阶段标记的是存活对象,回收未被标记的对象。
1、Serial收集器
串行垃圾收集器,即GC线程与用户线程先后运行,即GC时需要STW(暂停所有用户线程),直至GC结束才恢复用户线程的运行
专注于收集年轻代,底层是复制算法
相关参数:-XX:+UseSerialGC
在这里插入图片描述
2、ParNew收集器
Serial收集器的多线程版本。唯一能与CMS收集器搭配使用的新生代收集器。
相关参数:
-XX:+UseConcMarkSweepGC:指定使用CMS后,会默认使用ParNew作为新生代收集器
-XX:+UseParNewGC:强制指定使用ParNew
-XX:ParallelGCThreads:指定垃圾收集的线程数量,ParNew默认开启的收集线程与CPU的数量相同
在这里插入图片描述
3、Parallel收集器
关注吞吐量的收集器
吞吐量 = 运行用户代码时间 / (运行用户代码时间+垃圾收集时间)
相关参数:
• -XX:MaxGCPauseMillis:是一个大于0的毫秒数,收集器将尽力保证内存回收花费的时间不超过设定值。不过大家不要异想天开地认为如果把这个参数的值设置得稍小一点就能使得系统的垃圾收集速度变得更快,GC停顿时间缩短是以牺牲吞吐量和新生代空间来换取的:系统把新生代调小一些,收集300MB新生代肯定比收集500MB快吧,这也直接导致垃圾收集发生得更频繁一些,原来10秒收集一次、每次停顿100毫秒,现在变成5秒收集一次、每次停顿70毫秒。停顿时间的确在下降,但吞吐量也降下来了。
• -XX:GCTimeRatio:一个大于0小于100的整数,也就是垃圾收集时间占总时间的比率。如果把此参数设置为19,那允许的最大GC时间就占总时间的5%(即1 /(1+19)),默认值为99,就是允许最大1%(即1 /(1+99))的垃圾收集时间。
• -XX:+UseAdaptiveSizePolicy:一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或最大的吞吐量,这种调节方式称为GC自适应的调节策略(GC Ergonomics)
在这里插入图片描述
4、Serial Old收集器
Serial收集器的老年代版本。基于标记-整理算法实现,
有两个用途:
1、与Serial收集器、Parallel收集器搭配使用
2、作为CMS收集器的后备方案

5、Parallel Old收集器
Parallel收集器的老年代版本。基于标记-整理算法实现。
6、CMS收集器
在这里插入图片描述
聚焦低延迟。基于标记-清除算法实现。
由于CMS收集器是并发收集器,即在运行阶段用户线程依然在运行,会产生对象,所以CMS收集器不能等到老年代满了才触发,而是要提前触发,这个阈值是92%。这个阈值可以通过参数-XX:CMSInitiatingOccupancyFraction设置
相关参数:
-XX:-CMSParallelRemarkEnabled : 手动配置开启并行标记,节省年轻代标记时间,JDK1.6以前不需要配置,默认开启
-XX:+UseConcMarkSweepGC:手动开启CMS收集器
-XX:+CMSIncrementalMode:设置为增量模式
-XX:CMSFullGCsBeforeCompaction:设定进行多少次CMS垃圾回收后,进行一次内存压缩
-XX:+CMSClassUnloadingEnabled:允许对类元数据进行回收
-XX:UseCMSInitiatingOccupancyOnly:表示只在到达阀值的时候,才进行CMS回收
-XX:CMSInitiatingOccupancyFraction:设置CMS收集器在老年代空间被使用多少后触发
-XX:+UseCMSCompactAtFullCollection:设置CMS收集器在完成垃圾收集后是否要进行一次内存碎片的整理
CMS收集器工作分四个步骤:
1、初始标记
会STW。只标记GC Roots直接关联的对象。
2、并发标记
不会STW。GC线程与用户线程并发运行。
会沿着GC Roots直接关联的对象链遍历整个对象图。可想而知需要的时间较长,但因为是与用户线程并发运行的,除了能感知到CPU飙升,不会出现卡顿现象。
3、重新标记
会STW。
CMS垃圾收集器通过写屏障+增量更新记录了并发标记阶段新建立的引用关系,重新标记就是去遍历这个记录。
4、并发清除
GC线程与用户线程并发运行,清理未被标记到的对象
默认启动的回收线程数 = (处理器核心数 + 3) / 4
显然CMS收集器依然不是完美的,不然后面就不会出现G1、ZGC等。那有哪些缺点呢?
1、运行期间会与用户线程抢夺CPU资源。当然,这是所有并发收集器的缺点
2、无法处理浮动垃圾(标记结束后创建的对象)
3、内存碎片

7、G1收集器
在这里插入图片描述
G1收集器与之前的所有收集器都不一样,它将堆分成了一个一个Region,这些Region用的时候才被赋予角色:Eden、from、to、humongous。一个region只能是一个角色,不存在一个region既是Eden又是from。
每个region的大小可通过参数-XX:G1HeapRegionSize设置,取值范围是2-32M。
一个对象的大小超过region的一半则被认定为大对象,会用N个连续的region来存储。
G1名字的由来
回收某个region的价值大小 = 回收获得的空间大小 + 回收所需时间
G1收集器会维护一个优先级列表,每个region按价值大小排序存放在这个优先级列表中。收集时优先收集价值更大的region,这就是G1名字的由来。
在这里插入图片描述
四个步骤:
1、初始标记
会STW。
做了两件事:
1、修改TAMS的值,TAMS以上的值为新创建的对象,默认标记为存活对象,即多标
2、标记GC Roots能直接关联到的对象
2、并发标记
耗时较长。GC线程与用户线程并发运行。
从GC roots能直接关联到的对象开始遍历整个对象图
3、最终标记
遍历写屏障+SATB记录下的旧的引用对象图
4、筛选回收
更新region的统计数据,对各个region的回收价值进行计算并排序,然后根据用户设置的期望暂停时间的期望值生成回收集。
然后开始执行清除操作。将旧的region中的存活对象移动到新的Region中,清理这个旧的region。这个阶段需要STW。
相关参数:
-XX:G1HeapRegionSize:设置region的大小
-XX:MaxGCPauseMillis:设置GC回收时允许的最大停顿时间(默认200ms)
-XX:+UseG1GC:开启g1
-XX:ConcGCThreads:设置并发标记、并发整理的gc线程数
-XX:ParallelGCThreads:STW期间并行执行的gc线程数
缺点:
1、需要10%-20%的内存来存储G1收集器运行需要的数据,如不cset、rset、卡表等
2、运行期间会与用户线程抢夺CPU资源。当然,这是所有并发收集器的缺点

查看默认收集器
java -XX:+PrintFlagsFinal -version | grep GC

GC日志
相关参数:
-XX:+PrintGC 输出GC日志
-XX:+PrintGCDetails 输出GC的详细日志
-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps 输出GC的时间戳(以日期的形式,如 2013-05-04T21:53:59.234+0800)
-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息
-Xloggc:…/logs/gc.log 日志文件的输出路径
日志内容:
1、gc类型:GC、Full GC
2、gc原因:Metadata GC Threshold、Last ditch collection……
3、gc前内存数据
4、gc后内存数据
5、花费的时间:用户态、内核态、实际用时
比如元空间的gc日志

[GC (Metadata GC Threshold) [PSYoungGen: 6398K->1133K(46592K)] 6398K->1141K(153088K), 0.0371218 secs] [Times: user=0.03 sys=0.01, real=0.04 secs] 
[Full GC (Metadata GC Threshold) [PSYoungGen: 1133K->0K(46592K)] [ParOldGen: 8K->1013K(76800K)] 1141K->1013K(123392K), [Metaspace: 4061K->4061K(1056768K)], 0.0815840 secs] [Times: user=0.13 sys=0.00, real=0.08 secs] 

比如堆区gc日志
在这里插入图片描述

OmitStackTraceInFastThrow
查看线上日志,遇到一个诡异的问题,就是系统大量空指针的异常,但是没有打印堆栈,导致不方便定位问题。
经过一番代码调试,确定并非程序代码问题。没有线索之后,从Google找到了答案:是因为在server模式下运行的时候,有一个默认选项是-XX:+OmitStackTraceInFastThrow,这个玩意的意思就是当大量抛出同样的异常的后,后面的异常输出将不打印堆栈,打印堆栈的时候底层会调用到Throwable.getOurStackTrace()方法,而这个方法是synchronized的,对性能有比较明显对影响。所以这个参数是合理的。正常情况下,如果打印了几万条异常堆栈是很容易发现问题的。但是我们的系统正好赶上访问量高峰,一不留神就错过打印详细堆栈的阶段了。
三、JVM常用配置参数

    1) -Xmn: 新生代内存上限值
    2) -Xms: 整个堆区初始内存分配的大小
    3) -Xmx: 整个堆区内存分配的最大上限,推荐-Xms和-Xmx设置等同大小,避免动态回收消耗资源
    4) -XX:+HeapDumpOnOutOfMemoryError 当出现OOM时,打印堆转储dump文件
    5) -XX:HeapDumpPath= 指定堆转储dump文件存储路径
    6) -XX:MaxTenuringThreshold=5 : 手动设置对象在新生代中存活年龄(存活次数),默认15次
    7) -XX:SurvivorRatio=6 : Eden区与Survivor0、Survivor1区的大小比值,一般设置为6,Eden:S0:S1=6:1:1
    8) -XX:PretenureSizeThreshold 手动指定对象大小,当对象达到指定大小时直接存放到老年代中,由于新生代大多使用复制算法,为了节省复制消耗
     9) -XX:+UseParNewGC : 手动指定新生代使用 ParNew 收集器
     10) -XX:+UseConcMarkSweepGC : 手动指定老年代使用CMS收集器
     11) -XX:PermSize=512M 指定非堆区域(永久代)初始内存分配大小, JDK 1.7 及以下生效
     12) -XX:MaxPermSize= 1024M 指定非堆区域(永久代)内存分配的最大上限,JDK1.7 及以下生效
     13) -XX:+UseCMSInitiatingOccupancyOnly : Hotspot会根据成本计算决定是否需要执行CMS收集器,可手动设置-XX:+UseCMSInitiatingOccupancyOnly关闭计算策略,强制使用CMS 收集器
     14)  -XX:+CMSClassUnloadingEnabled : 手动指定CMS 收集器对非堆区域永久代进行回收,默认永久代不回收
     15) -XX:CMSInitiatingOccupancyFraction=80 : 手动指定当老年代已用空间达到80%时,触发老年代回收(默认92%)
     16) -XX:CMSInitiatingPermOccupancyFraction=80  : 手动指定当永久代已用空间达到80%时,触发永久代回收(默认92%)
     17) -XX:+DisableExplicitGC : 手动配置禁止使用外部调用System.gc 来进行触发垃圾回收
     18) -XX:+UseCMSCompactAtFullCollection : 在进行Full GC时对内存进行压缩,JDK1.6以前不需要配置,默认开启
     19) -XX:CMSFullGCsBeforeCompaction=2 : 与-XX:+UseCMSCompactAtFullCollection 关联使用标识着每经过多少次Full GC 触发对内存进行一次压缩,默认是0次
      20) -XX:-CMSParallelRemarkEnabled : 手动配置开启并行标记,节省年轻代标记时间,JDK1.6以前不需要配置,默认开启
      21) -Xnoclassgc  : 关闭CLASS的垃圾回收功能,默认20分钟这个class未被使用,虚拟机会卸载这个类。再次使用时重新加载
  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值