Java生产环境下性能监控与调优详解(二)基于JDK命令行工具的监控

本文详细介绍了JVM参数的分类,如标准参数、X参数和XX参数,以及它们在内存管理和调优中的作用。通过jps、jinfo、jstat等工具展示了如何查看JVM运行时参数和统计信息。针对内存溢出,通过jmap导出内存映像文件,并借助MAT工具进行分析定位。同时,文章还深入探讨了如何使用jstack分析线程状态,包括死循环和死锁的识别与解决。这些实战技巧对于理解和优化JVM性能至关重要。
摘要由CSDN通过智能技术生成

主要内容:

  • JVM参数类型
  • 查看运行时JVM参数
  • 查看JVM统计信息
  • jmap + MAT 实战内存溢出
  • jstack实战死循环与死锁

1.JVM参数类型

1.1 参数分类:

  • 标准参数
  • X参数
  • XX参数

1.2 标准参数

常用标准参数如下:

  • -help
  • server -clien
  • -version -showversion
  • -cp -classpath

以上参数在JVM各版本中属于相对比较稳定的,很少变动。

1.3 X参数

X参数,也就是非标准化参数。主要有以下几种:

  • -Xint: 解释执行
  • -Xcomp: 第一次使用就编译成本地代码
  • -Xmixed: 混合模式,JVM自己来决定是否编译成本地代码

1.4 XX参数

(1)说明:该中参数也属于非标准化参数,且相对不稳定

(2)作用:主要用于JVM调优和Debug。

(3)XX参数分类

  • Boolean类型

     格式: -XX:[+-]<name>表示启用或者禁用name属性

     比如:-XX:+UseConcMarkSweepGC

                -XX:+UseG1GC  (开启G1垃圾收集器)

  • 非Boolean类型 

     格式:-XX:<name>=<value>表示name属性的值是value

     比如:-XX:MaxGCPauseMillis=500  (表示GC的最大停顿时间为500ms)

                XX:GCTimeRatio=19

(4)关于 -Xmx -Xms 参数

-Xmx -Xms,不是X参数,而是XX参数。

  • -Xms等价于 -XX:InitialHeapSize ,表示初始化堆的大小
  • -Xmx等价于 -XX:MaxHeapSize  ,表示最大的对大小

2.查看JVM运行时参数

(1)相关参数

  • -XX:+PrintFlagsInitial  (查看初始值)
  • -XX:+PrintFlagsFinal   (查看最终的值,初始值是可以被改变的)
  • -XX:+uNLOCKeXPERIMENTALvmoPTIONS 解锁实验参数
    • JVM中的参数并不都是可以直接赋值的,部分需要先执行解锁操作后才能使用
  • -XX:+UnlockDiagnosticVMOptions解锁诊断参数
  • -XX:+PrintCommandLineFlag打印命令行参数

(2)参数是否被赋值的说明:

  • = 表示默认值
  • := 被用户或者JVM修改后的值

(3)演示——打印版本号 -version

java -client -XX:+PrintFlagsFinal Benchmark

打印出来的参数结果:

从上图可知,其UseG1GC是没有启用过的。

上图中的每一行包括五列,来表示一个XX参数。第一列表示参数的数据类型,第二列是名称,第四列为值,第五列是参数的类别。第三列”=”表示第四列是参数的默认值,而”:=” 表明了参数被用户或者JVM赋值了。

(4)官方文档地址

(5)jps

功能: 显示当前所有java进程pid的命令。

常用指令:

  • jps:显示当前用户的所有java进程的PID
  • jps -v 3331:显示虚拟机参数
  • jps -m 3331:显示传递给main()函数的参数
  • jps -l 3331:显示主类的全路径

案例:

(6)jinfo

功能:查看运行中的JVM内的参数值

使用方式:

-flag <name>

用于打印虚拟机标记参数的值,name表示虚拟机标记参数的名称。

案例:通过 jinfo 来查看对应进程的参数信息

上图中案例分析:jinfo -flag MaxHeapSize 22684

  • jinfo —— 查看命令
  • -flag —— 查看方式
  • MaxHeapSize —— 查看的参数
  • 22684 —— 被查看参数的那个进程(JVM的Bootstrap)

3.jstat查看JVM统计信息

(1)定义:Jstat是JDK自带的一个轻量级小工具。全称“Java Virtual Machine statistics monitoring tool”,它位于java的bin目录下,主要利用JVM内建的指令对Java应用程序的资源和性能进行实时的命令行的监控,包括了对Heap size和垃圾回收状况的监控。

(2)jstat 可查看的JVM统计信息有如下几种:

  • 类装载
  • 垃圾收集
  • JIT编译

(3)jstat 用法

  • option: 参数选项
  • -t: 可以在打印的列加上Timestamp列,用于显示系统运行的时间
  • -h: 可以在周期性数据数据的时候,可以在指定输出多少行以后输出一次表头
  • vmid: Virtual Machine ID( 进程的 pid)
  • interval: 执行每次的间隔时间,单位为毫秒
  • count: 用于指定输出多少次记录,缺省则会一直打印

option 可以从下面参数中选择

  • -class 显示ClassLoad的相关信息;
  • -compiler 显示JIT编译的相关信息;
  • -gc 显示和gc相关的堆信息;
  • -gccapacity    显示各个代的容量以及使用情况;
  • -gcmetacapacity 显示metaspace的大小
  • -gcnew 显示新生代信息;
  • -gcnewcapacity 显示新生代大小和使用情况;
  • -gcold 显示老年代和永久代的信息;
  • -gcoldcapacity 显示老年代的大小;
  • -gcutil   显示垃圾收集信息;
  • -gccause 显示垃圾回收的相关信息(通-gcutil),同时显示最后一次或当前正在发生的垃圾回收的诱因;
  • -printcompilation 输出JIT编译的方法信息;

(4)查看类装载的信息  :  -class

显示加载class的数量,及所占空间等信息。

jstat -class <pid>

案例:

jstat -class 3176 1000 10
  • 3176: 为PID
  • 1000:表示每个1s输出
  • 10: 表示输出次数

结果参数说明:

  • Loaded : 已经装载的类的数量
  • Bytes : 装载类所占用的字节数
  • Unloaded:已经卸载类的数量
  • Bytes:卸载类的字节数
  • Time:装载和卸载类所花费的时间

(5)查看垃圾回收信息:-gc

显示gc相关的堆信息,查看gc的次数,及时间。

jstat –gc <pid>

案例:

参数值说明:

  • S0C:年轻代中第一个survivor(幸存区)的总容量 (字节)
  • S1C:年轻代中第二个survivor(幸存区)的总容量 (字节)
  • S0U :年轻代中第一个survivor(幸存区)目前已使用空间 (字节)
  • S1U :年轻代中第二个survivor(幸存区)目前已使用空间 (字节)
  • EC :年轻代中Eden(伊甸园)的总容量 (字节)
  • EU :年轻代中Eden(伊甸园)目前已使用空间 (字节)
  • OC :Old代的总容量 (字节)
  • OU :Old代目前已使用空间 (字节)
  • MC:metaspace(元空间)的总容量 (字节)
  • MU:metaspace(元空间)目前已使用空间 (字节)
  • YGC :从应用程序启动到采样时年轻代中gc次数
  • YGCT :从应用程序启动到采样时年轻代中gc所用时间(s)
  • FGC :从应用程序启动到采样时old代(全gc)gc次数
  • FGCT :从应用程序启动到采样时old代(全gc)gc所用时间(s)
  • GCT:从应用程序启动到采样时gc用的总时间(s)

(6)查看JIT编译信息 :-compiler

显示VM实时编译(JIT)的数量等信息。

jstat -compiler <pid>

案例:

参数说明:

  • Compiled:编译任务执行数量
  • Failed:编译任务执行失败数量
  • Invalid :编译任务执行失效数量
  • Time :编译任务消耗时间
  • FailedType:最后一个编译失败任务的类型
  • FailedMethod:最后一个编译失败任务所在的类及方法

4.jmap+MAT实战内存溢出

重点:如何定位内存溢出的位置?

4.1 JVM的内存结构

JVM 的内存分为两大块:堆区、非堆区。

堆区又分为两大块:Young、Old.

Young区分为两大块:Eden、S0+S1

  • S0和S1大小一样,同一时间只使用其中一个,另一个是空的。

4.2 代码案例 -- 演示堆内存溢出

(1)创建SpringBoot工程,并定义接口 “/heap” ,访问接口无限写入数据,知道堆内存占满。

        // 定义内存对象,在该对象循环存入User实例数据知道堆内存占满
        private List<User>  userList = new ArrayList<User>();

	/**
	 * -Xmx32M -Xms32M
	 * */
	@GetMapping("/heap")
	public String heap() {
		int i=0;
		while(true) {
			userList.add(new User(i++, UUID.randomUUID().toString()));
		}
	}

(2)关于User对象,提供 id、name 两个参数以及set/get方法即可。

(3)结果分析:

访问 localhost:8080/heap 路径后开始写入数据到堆中。

  • 为提高效率看到堆内存溢出结果。这里将堆内存大小进行修改为 “-Xmx32M -Xms32M” 。

启动程序,并访问接口后结果如下:

如上图,当死循环写入的数据超过堆内存容量后,就发生堆内存异常的情况。

4.3 代码案例 -- 演示非堆内存溢出

(1)定义 Metaspace.java 类用于创建 class 类对象

package com.imooc.monitor_tuning.chapter2;//package com.imooc.monitor_tuning.chapter2;
import java.util.ArrayList;
import java.util.List;

import org.objectweb.asm.ClassWriter;
import org.objectweb.asm.MethodVisitor;
import org.objectweb.asm.Opcodes;

/*
 * https://blog.csdn.net/bolg_hero/article/details/78189621
 * 继承ClassLoader是为了方便调用defineClass方法,因为该方法的定义为protected
 * */
public class Metaspace extends ClassLoader {

    public static List<Class<?>> createClasses() {
        // 类持有
        List<Class<?>> classes = new ArrayList<Class<?>>();
        // 循环1000w次生成1000w个不同的类。
        for (int i = 0; i < 10000000; ++i) {
            ClassWriter cw = new ClassWriter(0);
            // 定义一个类名称为Class{i},它的访问域为public,父类为java.lang.Object,不实现任何接口
            cw.visit(Opcodes.V1_1, Opcodes.ACC_PUBLIC, "Class" + i, null,
                    "java/lang/Object", null);
            // 定义构造函数<init>方法
            MethodVisitor mw = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>",
                    "()V", null, null);
            // 第一个指令为加载this
            mw.visitVarInsn(Opcodes.ALOAD, 0);
            // 第二个指令为调用父类Object的构造函数
            mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object",
                    "<init>", "()V");
            // 第三条指令为return
            mw.visitInsn(Opcodes.RETURN);
            mw.visitMaxs(1, 1);
            mw.visitEnd();
            Metaspace test = new Metaspace();
            byte[] code = cw.toByteArray();
            // 定义类
            Class<?> exampleClass = test.defineClass("Class" + i, code, 0, code.length);
            classes.add(exampleClass);
        }
        return classes;
    }
}

上述代码中的类,主要用于动态生产并返回多个class类对象。

(2)存储class对象避免被回收

    private List<Class<?>>  classList = new ArrayList<Class<?>>();

    /**    
      * -XX:MetaspaceSize=32M -XX:MaxMetaspaceSize=32M
      * */
    @GetMapping("/nonheap")
    public String nonheap() {
	while(true) {
		classList.addAll(Metaspace.createClasses());
	}
    }

在上述代码的 nonheap() 接口中,由于将生产的class都存入到了 classList 中,会导致该 class 对象一直是可用的,同时其不断添加 class 对象进入,导致其在非堆区所占领的内存越来越大,知道溢出。

说明:每个类结构(如运行时常数池、字段和方法数据)以及方法和构造方法 的代码都在非堆内存中

(3)测试结果并分析:

访问: http://localhost:8080/nonheap 路径触发非堆溢出

分析: 从上图可知,该接口调用后由于 class 不断动态生成并未被及时回收,导致存放class文件的非堆区内存被占满,最终溢出。

5.导出内存映像文件

5.1 文件的作用

用于在内存溢出的情况下,分析到底是哪些类一直占用内存不释放。

5.2 如何导出内存映像文件

  • 内存溢出自动导出

      -XX:+HeapDumpOnOutOfMemoryError

      -XX:HeapDumpPath=./

  • 使用 jmap 命令手动导出文件

5.3 案例一:内存溢出自动导出

在 "VM options" 中添加 “ -XX:+HeapDumpOnOutOfMemoryError   -XX:HeapDumpPath=./ ” 配置,然后重新访问接口触发溢出:

导出来的映像文件:

5.4 案例二:使用 jmap 命令手动导出文件

(1)jmap功能

命令jmap是一个多功能的命令。它可以生成 java 程序的 dump 文件, 也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及 finalizer 队列。

(2)jmap 用法

参数:

  • option: 选项参数。
  • pid: 需要打印配置信息的进程ID。
  • executable: 产生核心dump的Java可执行文件。
  • core: 需要打印配置信息的核心文件。
  • server-id 可选的唯一id,如果相同的远程主机上运行了多台调试服务器,用此选项参数标识服务器。
  • remote server IP or hostname 远程调试服务器的IP地址或主机名。

option

  • no option: 查看进程的内存映像信息,类似 Solaris pmap 命令。
  • heap: 显示Java堆详细信息
  • histo[:live]: 显示堆中对象的统计信息
  • clstats:打印类加载器信息
  • finalizerinfo: 显示在F-Queue队列等待Finalizer线程执行finalizer方法的对象
  • dump:<dump-options>:生成堆转储快照,也就是导出映像文件。
  • F: 当-dump没有响应时,使用-dump或者-histo参数. 在这个模式下,live子参数无效.
  • help:打印帮助信息
  • J<flag>:指定传递给运行jmap的JVM的参数

(3)导出映像文件 --- dump 命令

C:\Users\wushaopei\Desktop>jmap -dump:format=b,file=heap.hprof 20204
Dumping heap to C:\Users\wushaopei\Desktop\heap.hprof ...
Heap dump file created

说明:jmap -dump:format=b,file=heap.hprof 20204

  • jmap 导出命令
  • -dump 导出映像文件命令
  • format=b 导出格式
  • file=heap.hprof 导出到 heap.hprof 文件中

导出结果:

6. MAT分析内存溢出

(1)下载MAT工具 —— MemoryAnalyzer-1.11.0.20201202-win32.win32.x86_64

(2)解压MAT工具后,打开文件夹 mat/ 目录,双击 eclipsec.exe 启动工具

(3)导入映像文件,File - Open File - 选择本地已导出的映像文件。

(4)工具自动解析,并生成分析结果。如下:

上图中,除了灰色部分以外,其他的都是发生内存溢出的可能区域。

上述三个 Problem Suspect1、2、3分别为内存溢出区域的分析结果。

  • Problem Suspect1 —— 应用加载器,也就是我们自己创建的类所占的引用内存
  • Problem Suspect2 —— JVM 启动时class所占用的引用内存,这是正常的
  • Problem Suspect3 —— JVM 启动时String所占用的引用内存,这是正常的

根据以上的分析结果,发生溢出的原因和区域应该是 Problem Suspect1

(5)查看对象的数量

模糊查询过滤

(6)查看对象的引用

查看引用

(7)在上述的(5)、(6)中通过查看分析最终定位到内存溢出的对象、引用。并从而获取到溢出的原因。

7.jstack与线程的状态

7.1 jstack 功能

jstack一般用来查看指定线程(比如CPU较高、内存占用较高)的堆栈、查看死锁的原因。
打印对指定进程的堆栈信息:

jstack 进程号

7.2 用法与参数

/opt/java8/bin/jstack

Usage:
    jstack [-l] <pid>
        (to connect to running process) 连接活动线程
    jstack -F [-m] [-l] <pid>
        (to connect to a hung process) 连接阻塞线程
    jstack [-m] [-l] <executable> <core>
        (to connect to a core file) 连接dump的文件
    jstack [-m] [-l] [server_id@]<remote server IP or hostname>
        (to connect to a remote debug server) 连接远程服务器

Options:
    -F  to force a thread dump. Use when jstack <pid> does not respond (process is hung)
    -m  to print both java and native frames (mixed mode)
    -l  long listing. Prints additional information about locks
    -h or -help to print this help message

7.3 案例-jstack查看输出

/opt/java8/bin/jstack -l 28367

2021-04-28 15:04:46
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.77-b03 mixed mode):

"Attach Listener" #453 daemon prio=9 os_prio=0 tid=0x00007f9f94001000 nid=0xf30 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE

   Locked ownable synchronizers:
        - None

"grpc-default-executor-263" #452 daemon prio=5 os_prio=0 tid=0x00007f9f4c01f800 nid=0x9aa waiting on condition [0x00007f9f398bd000]
   java.lang.Thread.State: TIMED_WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x00000007400243f0> (a java.util.concurrent.SynchronousQueue$TransferStack)
        at java.util.concurrent.locks.LockSupport.parkNanos(LockSupport.java:215)
        at java.util.concurrent.SynchronousQueue$TransferStack.awaitFulfill(SynchronousQueue.java:460)
        at java.util.concurrent.SynchronousQueue$TransferStack.transfer(SynchronousQueue.java:362)
        at java.util.concurrent.SynchronousQueue.poll(SynchronousQueue.java:941)
        at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1066)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
        at java.lang.Thread.run(Thread.java:745)

   Locked ownable synchronizers:
        - None

"http-bio-8080-exec-10" #235 daemon prio=5 os_prio=0 tid=0x0000000001bcc800 nid=0x3c13 waiting on condition [0x00007f9f384a9000]
   java.lang.Thread.State: WAITING (parking)
        at sun.misc.Unsafe.park(Native Method)
        - parking to wait for  <0x0000000743d26638> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
        at java.util.concurrent.locks.LockSupport.park(LockSupport.java:175)
        at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(AbstractQueuedSynchronizer.java:2039)
        at java.util.concurrent.LinkedBlockingQueue.take(LinkedBlockingQueue.java:442)
        at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:104)
        at org.apache.tomcat.util.threads.TaskQueue.take(TaskQueue.java:32)
        at java.util.concurrent.ThreadPoolExecutor.getTask(ThreadPoolExecutor.java:1067)
        at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1127)
        at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
        at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
        at java.lang.Thread.run(Thread.java:745)

   Locked ownable synchronizers:
        - None

堆栈信息说明:

  • http-apr-xxxx-exec-** : 工作线程,没有请求进入时为 WAITING 状态
  • C1 CompilerThread2 : JIT编译使用的线程
  • JDWP ** : 调试对象用的
  • main : main 主函数线程
  • G1 Concurrent Refinement Thread#*: GC 的线程
  • java.lang.Thread.State : 当前线程的状态

8.jstack实战死循环与死锁

8.1 死循环导致的CPU飙升

该案例引用自: 若鱼1919 - 多么痛的领悟-代码优化导致的BUG

(1)案例代码:

public static List<Long> getPartneridsFromJson(String data){
    //{\"data\":[{\"partnerid\":982,\"count\":\"10000\",\"cityid\":\"11\"},{\"partnerid\":983,\"count\":\"10000\",\"cityid\":\"11\"},{\"partnerid\":984,\"count\":\"10000\",\"cityid\":\"11\"}]}
    //上面是正常的数据
    List<Long> list = new ArrayList<Long>(2);
    if(data == null || data.length() <= 0){
        return list;
    }    
    int datapos = data.indexOf("data");
    if(datapos < 0){
        return list;
    }
    int leftBracket = data.indexOf("[",datapos);
    int rightBracket= data.indexOf("]",datapos);
    if(leftBracket < 0 || rightBracket < 0){
        return list;
    }
    String partners = data.substring(leftBracket+1,rightBracket);
    if(partners == null || partners.length() <= 0){
        return list;
    }
    while(partners!=null && partners.length() > 0){
        int idpos = partners.indexOf("partnerid");
        if(idpos < 0){
            break;
        }
        int colonpos = partners.indexOf(":",idpos);
        int commapos = partners.indexOf(",",idpos);
        if(colonpos < 0 || commapos < 0){
            //partners = partners.substring(idpos+"partnerid".length());//1
            continue;
        }
        String pid = partners.substring(colonpos+1,commapos);
        if(pid == null || pid.length() <= 0){
            //partners = partners.substring(idpos+"partnerid".length());//2
            continue;
        }
        try{
            list.add(Long.parseLong(pid));
        }catch(Exception e){
            //do nothing
        }
        partners = partners.substring(commapos);
    }
    return list;
}

(2)打包部署到服务器

打包:

mvn clean package -Dmaven.test.skip  #打包

上传至服务器,后台启动 jar包:

nohup java -jar monitor_tuning-0.0.1-SNAPSHOT.jar &

(3)访问接口,触发已经配置好的脏数据写入逻辑层造成死循环

        /**
	 * 死循环
	 * */
	@RequestMapping("/loop")
	public List<Long> loop(){
		String data = "{\"data\":[{\"partnerid\":]";
		return getPartneridsFromJson(data);
	}

访问地址: localhost:12345/loop

(4)top 命令查看 CPU 负载

上图中划线部分为当前服务器的CPU负载值。

(5)top -p 7930 -H 打印按CPU占用率排序

(6)导出死循环导致的堆栈信息文件,并分析:

这里只列出其中一个执行 getPartneridsFromJson(CpuController.java:71) 任务的线程,在映像文件中一共发现了五个这样的线程,这正好对应了前面 (5)中CPU占用率最高的五个线程。

定位问题:结合 (5)(6)中的分析,我们可以得出结论,造成当前服务器CPU飙升的原因是因为 getPartneridsFromJson(CpuController.java:71) 方法。

8.2 死锁导致的CPU飙升

(1)案例代码

        private Object lock1 = new Object();
	private Object lock2 = new Object();

	/**
	 * 死锁
	 * */
	@RequestMapping("/deadlock")
	public String deadlock(){
		new Thread(()->{
			synchronized(lock1) {
				try {Thread.sleep(1000);}catch(Exception e) {}
				synchronized(lock2) {
					System.out.println("Thread1 over");
				}
			}
		}) .start();
		new Thread(()->{
			synchronized(lock2) {
				try {Thread.sleep(1000);}catch(Exception e) {}
				synchronized(lock1) {
					System.out.println("Thread2 over");
				}
			}
		}) .start();
		return "deadlock";
	}

上述方法为死锁程序,lock1 、lock2竞争对方的锁。求而不得,最终将导致线程卡死、CPU飙升。

(2)部署至服务器后启动jar包

流程参照死循环的打包和启动步骤。

(3)访问接口,触发死锁

访问路径:localhost:12345/deadlock

(4)访问结果

访问接口,触发死锁的时候,直接就返回了最后一行的 “deadlock” 结果。但这不代表着死锁失败了。而是因为造成死锁所在主线程执行比较快。在主线程内部的两个子线程依旧是陷入在死锁的情境中。

(5)根据进程id(PID)用 jstack 命令导出堆栈信息文件

jstack 23674 > 23674.txt

(6)定位问题——死锁

查看堆栈文件,根据 deadlock() 最后会返回的 “deadlock” 可以定位到我们定义的死锁的位置。

从上图中,我们可以知道,在主线程 “deadlock” 之前就是死锁的线程问题所在,经过分析:

  • Thread-5\Thread-6都处于 WAITING 状态
  • Thread-5\Thread-6要解锁需要获取对方的 locked
  • 导致这两个线程死锁的类是 CpuController.java,分别对应下图中 lock1\lock2.
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值