java大厂技术面试第六课 JVM相关及优化

本文详细介绍了JVM的内存布局,包括堆、方法区、程序计数器、虚拟机栈和本地方法栈,以及垃圾回收的理论和实践。讲解了HotSpot虚拟机中的几种垃圾收集器,如Serial、ParNew、ParallelScavenge、SerialOld、ParallelOld、CMS和G1,以及如何使用JDK自带的工具如jps、jstat、jinfo、jmap、jhat和jstack进行问题排查和优化。同时,讨论了垃圾回收算法,如引用计数、可达性分析、标记-清除、标记-复制和标记-整理。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

第23讲:说一下 JVM 的内存布局和运行原理?

JVM(Java Virtual Machine,Java 虚拟机)顾名思义就是用来执行 Java 程序的“虚拟主机”,实际的工作是将编译的 class 代码(字节码)翻译成底层操作系统可以运行的机器码并且进行调用执行,这也是 Java 程序能够“一次编写,到处运行”的原因(因为它会根据特定的操作系统生成对应的操作指令)。JVM 的功能很强大,像 Java 对象的创建、使用和销毁,还有垃圾回收以及某些高级的性能优化,例如,热点代码检测等功能都是在 JVM 中进行的。因为 JVM 是 Java 程序能够运行的根本,因此掌握 JVM 也已经成了一个合格 Java 程序员必备的技能。

我们本课时的面试题是,说一下 JVM 的内存布局和运行原理?

典型回答

JVM 的种类有很多,比如 HotSpot 虚拟机,它是 Sun/OracleJDK 和 OpenJDK 中的默认 JVM,也是目前使用范围最广的 JVM。我们常说的 JVM 其实泛指的是 HotSpot 虚拟机,还有曾经与 HotSpot 齐名为“三大商业 JVM”的 JRockit 和 IBM J9 虚拟机。但无论是什么类型的虚拟机都必须遵守 Oracle 官方发布的《Java虚拟机规范》,它是 Java 领域最权威最重要的著作之一,用于规范 JVM 的一些具体“行为”。

同样对于 JVM 的内存布局也一样,根据《Java虚拟机规范》的规定,JVM 的内存布局分为以下几个部分:

image (8).png

以上 5 个内存区域的主要用途如下。

1. 堆

堆(Java Heap) 也叫 Java 堆或者是 GC 堆,它是一个线程共享的内存区域,也是 JVM 中占用内存最大的一块区域,Java 中所有的对象都存储在这里。

《Java虚拟机规范》对 Java 堆的描述是:“所有的对象实例以及数组都应当在堆上分配”。但这在技术日益发展的今天已经有点不那么“准确”了,比如 JIT(Just In Time Compilation,即时编译 )优化中的逃逸分析,使得变量可以直接在栈上被分配。

当对象或者是变量在方法中被创建之后,其指针可能被线程所引用,而这个对象就被称作指针逃逸或者是引用逃逸

比如以下代码中的 sb 对象的逃逸:

public static StringBuffer createString() {
    StringBuffer sb = new StringBuffer();
    sb.append("Java");
    return sb;
}

sb 虽然是一个局部变量,但上述代码可以看出,它被直接 return 出去了,因此可能被赋值给了其他变量,并且被完全修改,于是此 sb 就逃逸到了方法外部。
想要 sb 变量不逃逸也很简单,可以改为如下代码:

public static String createString() {
    StringBuffer sb = new StringBuffer();
    sb.append("Java");
    return sb.toString();
}

小贴士:通过逃逸分析可以让变量或者是对象直接在栈上分配,从而极大地降低了垃圾回收的次数,以及堆分配对象的压力,进而提高了程序的整体运行效率。

回到主题,堆大小的值可通过 -Xms 和 -Xmx 来设置(设置最小值和最大值),当堆超过最大值时就会抛出 OOM(OutOfMemoryError)异常。

2. 方法区

方法区(Method Area) 也被称为非堆区,用于和“Java 堆”的概念进行区分,它也是线程共享的内存区域,用于存储已经被 JVM 加载的类型信息、常量、静态变量、代码缓存等数据。

说到方法区有人可能会联想到“永久代”,但对于《Java虚拟机规范》来说并没有规定这样一个区域,同样它也只是 HotSpot 中特有的一个概念。这是因为 HotSpot 技术团队把垃圾收集器的分代设计扩展到方法区之后才有的一个概念,可以理解为 HotSpot 技术团队只是用永久代来实现方法区而已,但这会导致一个致命的问题,这样设计更容易造成内存溢出。因为永久代有 -XX:MaxPermSize(方法区分配的最大内存)的上限,即使不设置也会有默认的大小。例如,32 位操作系统中的 4GB 内存限制等,并且这样设计导致了部分的方法在不同类型的 Java 虚拟机下的表现也不同,比如 String::intern() 方法。所以在 JDK 1.7 时 HotSpot 虚拟机已经把原本放在永久代的字符串常量池和静态变量等移出了方法区,并且在 JDK 1.8 中完全废弃了永久代的概念。

3. 程序计数器

程序计数器(Program Counter Register) 线程独有一块很小的内存区域,保存当前线程所执行字节码的位置,包括正在执行的指令、跳转、分支、循环、异常处理等。

4. 虚拟机栈

虚拟机栈也叫 Java 虚拟机栈(Java Virtual Machine Stack),和程序计数器相同它也是线程独享的,用来描述 Java 方法的执行,在每个方法被执行时就会同步创建一个栈帧,用来存储局部变量表、操作栈、动态链接、方法出口等信息。当调用方法时执行入栈,而方法返回时执行出栈。

5. 本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈类似,它是线程独享的,并且作用也和虚拟机栈类似。只不过虚拟机栈是为虚拟机中执行的 Java 方法服务的,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。

小贴士:需要注意的是《Java虚拟机规范》只规定了有这么几个区域,但没有规定 JVM 的具体实现细节,因此对于不同的 JVM 来说,实现也是不同的。例如,“永久代”是 HotSpot 中的一个概念,而对于 JRockit 来说就没有这个概念。所以很多人说的 JDK 1.8 把永久代转移到了元空间,这其实只是 HotSpot 的实现,而非《Java虚拟机规范》的规定。

JVM 的执行流程是,首先先把 Java 代码(.java)转化成字节码(.class),然后通过类加载器将字节码加载到内存中,所谓的内存也就是我们上面介绍的运行时数据区,但字节码并不是可以直接交给操作系统执行的机器码,而是一套 JVM 的指令集。这个时候需要使用特定的命令解析器也就是我们俗称的**执行引擎(Execution Engine)**将字节码翻译成可以被底层操作系统执行的指令再去执行,这样就实现了整个 Java 程序的运行,这也是 JVM 的整体执行流程。

考点分析

JVM 的内存布局是一道必考的 Java 面试题,一般会作为 JVM 方面的第一道面试题出现,它也是中高级工程师必须掌握的一个知识点。和此知识点相关的面试题还有这些:类的加载分为几个阶段?每个阶段代表什么含义?加载了什么内容?

知识扩展——类加载

类的生命周期会经历以下 7 个阶段:

  1. 加载阶段(Loading)
  2. 验证阶段(Verification)
  3. 准备阶段(Preparation)
  4. 解析阶段(Resolution)
  5. 初始化阶段(Initialization)
  6. 使用阶段(Using)
  7. 卸载阶段(Unloading)

其中验证、准备、解析 3 个阶段统称为连接(Linking),如下图所示:

image (9).png

我们平常所说的 JVM 类加载通常指的就是前五个阶段:加载、验证、准备、解析、初始化等,接下来我们分别来看看。

1. 加载阶段

此阶段用于查到相应的类(通过类名进行查找)并将此类的字节流转换为方法区运行时的数据结构,然后再在内存中生成一个能代表此类的 java.lang.Class 对象,作为其他数据访问的入口。

小贴士:需要注意的是加载阶段和连接阶段的部分动作有可能是交叉执行的,比如一部分字节码文件格式的验证,在加载阶段还未完成时就已经开始验证了。

2. 验证阶段

此步骤主要是为了验证字节码的安全性,如果不做安全校验的话可能会载入非安全或有错误的字节码,从而导致系统崩溃,它是 JVM 自我保护的一项重要举措。

验证的主要动作大概有以下几个:

  • 文件格式包括常量池中的常量类型、Class 文件的各个部分是否被删除或被追加了其他信息等;
  • 元数据包括父类正确性校验(检查父类是否有被 final 修饰)、抽象类校验等;
  • 字节码,此步骤最为关键和复杂,主要用于校验程序中的语义是否合法且符合逻辑;
  • 符号引用,对类自身以外比如常量池中的各种符号引用的信息进行匹配性校验。
3. 准备阶段

此阶段是用来初始化并为类中定义的静态变量分配内存的,这些静态变量会被分配到方法区上。

HotSpot 虚拟机在 JDK 1.7 之前都在方法区,而 JDK 1.8 之后此变量会随着类对象一起存放到 Java 堆中。

4. 解析阶段

此阶段主要是用来解析类、接口、字段及方法的,解析时会把符号引用替换成直接引用。

所谓的符号引用是指以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可;而直接引用是可以直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

符号引用和直接引用有一个重要的区别:使用符号引用时被引用的目标不一定已经加载到内存中;而使用直接引用时,引用的目标必定已经存在虚拟机的内存中了。

5. 初始化

初始化阶段 JVM 就正式开始执行类中编写的 Java 业务代码了。到这一步骤之后,类的加载过程就算正式完成了。

小结

本课时讲了 JVM 的内存布局主要分为:堆、方法区、程序计数器、虚拟机栈和本地方法栈,并讲了 JVM 的执行流程,先把 Java 代码编译成字节码,再把字节码加载到运行时数据区;然后交给 JVM 引擎把字节码翻译为操作系统可以执行的指令进行执行;最后还讲了类加载的 5 个阶段:加载、验证、准备、解析和初始化。


第24讲:垃圾回收算法有哪些?

说到 Java 虚拟机不得不提的一个词就是 “垃圾回收”(GC,Garbage Collection),而垃圾回收的执行速度则影响着整个程序的执行效率,所以我们需要知道更多关于垃圾回收的具体执行细节,以便为我们选择合适的垃圾回收器提供理论支持。

我们本课时的面试题是,如何判断一个对象是否“死亡”?垃圾回收的算法有哪些?

典型回答

垃圾回收器首先要做的就是,判断一个对象是存活状态还是死亡状态,死亡的对象将会被标识为垃圾数据并等待收集器进行清除。

判断一个对象是否为死亡状态的常用算法有两个:引用计数器算法和可达性分析算法。

引用计数算法(Reference Counting) 属于垃圾收集器最早的实现算法了,它是指在创建对象时关联一个与之相对应的计数器,当此对象被使用时加 1,相反销毁时 -1。当此计数器为 0 时,则表示此对象未使用,可以被垃圾收集器回收。

引用计数算法的优缺点很明显,其优点是垃圾回收比较及时,实时性比较高,只要对象计数器为 0,则可以直接进行回收操作;而缺点是无法解决循环引用的问题,比如以下代码:

class CustomOne {
    private CustomTwo two;
    public CustomTwo getCustomTwo() {
        return two;
    }
    public void setCustomTwo(CustomTwo two) {
        this.two = two;
    }
}
class CustomTwo {
    private CustomOne one;
    public CustomOne getCustomOne() {
        return one;
    }
    public void setCustomOne(CustomOne one) {
        this.one = one;
    }
}
public class RefCountingTest {
    public static void main(String[] args) {
        CustomOne one = new CustomOne();
        CustomTwo two = new CustomTwo();
        one.setCustomTwo(two);
        two.setCustomOne(one);
        one = null;
        two = null;
    }
}

即使 one 和 two 都为 null,但因为循环引用的问题,两个对象都不能被垃圾收集器所回收。

可达性分析算法(Reachability Analysis) 是目前商业系统中所采用的判断对象死亡的常用算法,它是指从对象的起点(GC Roots)开始向下搜索,如果对象到 GC Roots 没有任何引用链相连时,也就是说此对象到 GC Roots 不可达时,则表示此对象可以被垃圾回收器所回收,如下图所示:

image (4).png

当确定了对象的状态之后(存活还是死亡)接下来就是进行垃圾回收了,垃圾回收的常见算法有以下几个:

  • 标记-清除算法;

  • 标记-复制算法;

  • 标记-整理算法。

标记-清除(Mark-Sweep)算法属于最早的垃圾回收算法,它是由标记阶段和清除阶段构成的。标记阶段会给所有的存活对象做上标记,而清除阶段会把没有被标记的死亡对象进行回收。而标记的判断方法就是前面讲的引用计数算法和可达性分析算法。

标记-清除算法的执行流程如下图所示:

image (5).png

从上图可以看出,标记-清除算法有一个最大的问题就是会产生内存空间的碎片化问题,也就是说标记-清除算法执行完成之后会产生大量的不连续内存,这样当程序需要分配一个大对象时,因为没有足够的连续内存而导致需要提前触发一次垃圾回收动作。

标记-复制算法是标记-清除算法的一个升级,使用它可以有效地解决内存碎片化的问题。它是指将内存分为大小相同的两块区域,每次只使用其中的一块区域,这样在进行垃圾回收时就可以直接将存活的东西复制到新的内存上,然后再把另一块内存全部清理掉。这样就不会产生内存碎片的问题了,其执行流程如下图所示:

image (6).png

标记-复制的算法虽然可以解决内存碎片的问题,但同时也带来了新的问题。因为需要将内存分为大小相同的两块内存,那么内存的实际可用量其实只有原来的一半,这样此算法导致了内存的可用率大幅降低了。

标记-整理算法的诞生晚于标记-清除算法和标记-复制算法,它也是由两个阶段组成的:标记阶段和整理阶段。其中标记阶段和标记-清除算法的标记阶段一样,不同的是后面的一个阶段,标记-整理算法的后一个阶段不是直接对内存进行清除,而是把所有存活的对象移动到内存的一端,然后把另一端的所有死亡对象全部清除,执行流程图如下图所示:

image (7).png

考点分析

本题目考察的是关于垃圾收集的一些理论算法问题,都属于概念性的问题,只要深入理解之后还是挺容易记忆的。和此知识点相关的面试题还有这些:

  • Java 中可作为 GC Roots 的对象有哪些?

  • 说一下死亡对象的判断细节?

知识扩展

GC Roots

在 Java 中可以作为 GC Roots 的对象,主要包含以下几个:

  • 所有被同步锁持有的对象,比如被 synchronize 持有的对象;

  • 字符串常量池里的引用(String Table);

  • 类型为引用类型的静态变量;

  • 虚拟机栈中引用对象;

  • 本地方法栈中的引用对象。

死亡对象判断

当使用可达性分析判断一个对象不可达时,并不会直接标识这个对象为死亡状态,而是先将它标记为“待死亡”状态再进行一次校验。校验的内容就是此对象是否重写了 finalize() 方法,如果该对象重写了 finalize() 方法,那么这个对象将会被存入到 F-Queue 队列中,等待 JVM 的 Finalizer 线程去执行重写的 finalize() 方法,在这个方法中如果此对象将自己赋值给某个类变量时,则表示此对象已经被引用了。因此不能被标识为死亡状态,其他情况则会被标识为死亡状态。

以上流程对应的示例代码如下:

public class FinalizeTest {
    // 需要状态判断的对象
    public static FinalizeTest Hook = null;
    @Override
    protected void finalize() throws Throwable {
        super.finalize();
        System.out.println("执行了 finalize 方法");
        FinalizeTest.Hook = this;
    }
    public static void main(String[] args) throws InterruptedException {
        Hook = new FinalizeTest();
        // 卸载对象,第一次执行 finalize()
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待 finalize() 执行
        if (Hook != null) {
            System.out.println("存活状态");
        } else {
            System.out.println("死亡状态");
        }
        // 卸载对象,与上一次代码完全相同
        Hook = null;
        System.gc();
        Thread.sleep(500); // 等待 finalize() 执行
        if (Hook != null) {
            System.out.println("存活状态");
        } else {
            System.out.println("死亡状态");
        }
    }
}

上述代码的执行结果为:

执行了 finalize 方法
存活状态
死亡状态

从结果可以看出,卸载了两次对象,第一次执行了 finalize() 方法,成功地把自己从待死亡状态拉了回来;而第二次同样的代码却没有执行 finalize() 方法,从而被确认为了死亡状态,这是因为任何对象的 finalize() 方法都只会被系统调用一次

虽然可以从 finalize() 方法中把自己从死亡状态“拯救”出来,但是不建议这样做,因为所有对象的 finalize() 方法只会执行一次。因此同样的代码可能产生的结果是不同的,这样就给程序的执行带来了很大的不确定性。

小结

本课时讲了对象状态判断的两种算法:引用计数算法和可达性分析算法。其中引用计数算法无法解决循环引用的问题,因此对于绝大多数的商业系统来说使用的都是可达性分析算法;同时还讲了垃圾回收的三种算法:标记-清除算法、标记-复制算法、标记-整理算法,其中,标记-清除算法会带来内存碎片的问题,而标记-复制算法会降低内存的利用率。所以,标记-整理算法算是一个不错的方案。


第25讲:你用过哪些垃圾回收器?它们有什么区别?

上一课时我们讲了垃圾回收的理论知识,而本课时将介绍这些理论知识的具体实践。垃圾回收器也叫垃圾收集器,不同的厂商对垃圾收集器的实现也是不同的,这里主要介绍目前使用最广泛的 OracleJDK 中自带的 HotSpot 虚拟机中的几个垃圾收集器。

我们本课时的面试题是,你用过哪些垃圾回收器?它们有什么区别?

典型回答

《Java 虚拟机规范》并没有对垃圾收集器的具体实现做任何的规定,因此每家垃圾收集器的实现方式都不同,但比较常用的垃圾回收器是 OracleJDK 中自带的 HotSpot 虚拟机。HotSpot 中使用的垃圾收集器主要包括 7 个:Serial、ParNew、Parallel Scavenge、Serial Old、Parallel Old、CMS 和 G1(Garbage First)收集器。

其中 Serial 收集器属于最早期的垃圾收集器,也是 JDK 1.3 版本之前唯一的垃圾收集器。它是单线程运行的垃圾收集器,其单线程是指在进行垃圾回收时所有的工作线程必须暂停,直到垃圾回收结束为止,如下图所示:

01.png

Serial 收集器的特点是简单和高效,并且本身的运行对内存要求不高,因此它在客户端模式下使用的比较多。

ParNew 收集器实际上是 Serial 收集器的多线程并行版本,运行示意图如下图所示:

02.png

Parallel Scavenge 收集器和 ParNew 收集器类似,它也是一个并行运行的垃圾回收器;不同的是,该收集器关注的侧重点是实现一个可以控制的吞吐量。而这个吞吐量计算的也很奇怪,它的计算公式是:用户运行代码的时间 / (用户运行代码的时间 + 垃圾回收执行的时间)。比如用户运行的时间是 8 分钟,垃圾回收运行的时间是 2 分钟,那么吞吐量就是 80%。Parallel Scavenge 收集器追求的目标就是将这个吞吐量的值,控制在一定的范围内。

Parallel Scavenge 收集器有两个重要的参数:

  • -XX:MaxGCPauseMillis 参数:它是用来控制垃圾回收的最大停顿时间;

  • -XX:GCTimeRatio 参数:它是用来直接设置吞吐量的值的。

Serial Old 收集器为 Serial 收集器的老年代版本,而 Parallel Old 收集器是 Parallel Scavenge 收集器的老年代版本。

CMS(Concurrent Mark Sweep)收集器与以吞吐量为目标的 Parallel Scavenge 收集器不同,它强调的是提供最短的停顿时间,因此可能会牺牲一定的吞吐量。它主要应用在 Java Web 项目中,它满足了系统需要短时间停顿的要求,以此来提高用户的交互体验。

Garbage First(简称 G1)收集器是历史发展的产物,也是一款更先进的垃圾收集器,主要面向服务端应用的垃圾收集器。它将内存划分为多个 Region 分区,回收时则以分区为单位进行回收,这样它就可以用相对较少的时间优先回收包含垃圾最多区块。从 JDK 9 之后也成了官方默认的垃圾收集器,官方也推荐使用 G1 来代替选择 CMS 收集器。

考点分析

JVM 内存布局和垃圾回收算法是面试中常考的题目,也是我们理解并优化 Java 程序的理论基础,而对于垃圾收集器来说除了目前主流版本(JDK 8)常用的 CMS 之外,其他的垃圾收集器都属于面试中的加分项。对于 G1 和 JDK 11 中的 ZGC 的理解代表了你对技术的热爱和新技术的敏感程度,也属于面试中的重要加分项。

和此知识点相关的面试题还有以下这些:

  • 讲一下分代收集理论?

  • CMS 收集器的具体执行流程是什么?

  • 讲一下 JDK 11 中的 ZGC 收集器?

知识扩展

1. 分代收集

说到垃圾收集器不得不提的一个理论就是“分代收集”,因为目前商用虚拟机的垃圾收集器都是基于分代收集的理论进行设计的,它是指将不同“年龄”的数据分配到不同的内存区域中进行存储,所谓的“年龄”指的是经历过垃圾收集的次数。这样我们就可以把那些朝生暮死的对象集中分配到一起,把不容易消亡的对象分配到一起,对于不容易死亡的对象我们就可以设置较短的垃圾收集频率,这样就能消耗更少的资源来实现更理想的功能了。

通常情况下分代收集算法会分为两个区域:新生代(Young Generation)和老年代(OldGeneration),其中新生代用于存储刚刚创建的对象,这个区域内的对象存活率不高,而对于经过了一定次数的 GC 之后还存活下来的对象,就可以成功晋级到老生代了。

对于上面介绍的 7 个垃圾收集器来说,新生代垃圾收集器有:Serial、ParNew、Parallel Scavenge,老生代的垃圾收集器有:Serial Old、Parallel Old、CMS,而 G1 属于混合型的垃圾收集器,如下图所示:

image (2).png

2. CMS 收集器的具体执行流程

CMS 收集器是基于标记-清除算法实现的,我们之前有讲过关于标记-清除的算法,这里简单地回顾一下。标记-清除的算法是由标记阶段和清除阶段构成的,标记阶段会给所有的存活对象做上标记;而清除阶段会把被标记为死亡的对象进行回收,而死亡对象的判断是通过引用计数法或者是目前主流的可达性分析算法实现的。但是 CMS 的实现稍微复杂一些,它的整个过程可以分为四个阶段:

  • 初始标记(CMS initial mark)

  • 并发标记(CMS concurrent mark)

  • 重新标记(CMS remark)

  • 并发清除(CMS concurrent sweep)

首先,初始标记阶段的执行时间很短,它只是标记一下 GC Roots 的关联对象;并发阶段是从 GC Roots 关联的对象进行遍历判断并标识死亡对象,这个过程比较慢,但不需要停止用户线程,用户的线程可以和垃圾收集线程并发执行;而重新标记阶段则是为了判断并标记,刚刚并发阶段用户继续运行的那一部分对象,所以此阶段的执行时间也比较短;最后是并发清除阶段,也就是清除上面标记的死亡对象,由于 CMS 使用的是标记-清除算法,而非标记-整理算法,因此无须移动存活的对象,这个阶段垃圾收集线程也可以和用户线程并发执行。

CMS 的整个执行过程中只有执行时间很短的初始标记和重新标记需要 Stop The World(全局停顿)的,执行过程如下图所示:

image (3).png

因为 CMS 是一款基于标记清除算法实现的垃圾收集器,因此会在收集时产生大量的空间碎片,为了解决这个问题,CMS 收集器提供了一个 -XX:+UseCMS-CompactAtFullCollection 的参数(默认是开启的,此参数从 JDK9 开始废弃),用于在 CMS 收集器进行 Full GC 时开启内存碎片的合并和整理。

但又因为碎片整理的过程必须移动存活的对象,所以它和用户线程是无法并发执行的,为了解决这个问题 CMS 收集器又提供了另外一个参数 -XX:CMSFullGCsBefore-Compaction,用于规定多少次(根据此参数的值决定)之后再进行一次碎片整理。

3. ZGC

ZGC 收集器是 JDK 11 中新增的垃圾收集器,它是由 Oracle 官方开发的,并且支持 TB 级别的堆内存管理,而且 ZGC 收集器也非常高效,可以做到 10ms 以内完成垃圾收集。

在 ZGC 收集器中没有新生代和老生代的概念,它只有一代。ZGC 收集器采用的着色指针技术,利用指针中多余的信息位来实现着色标记,并且 ZGC 使用了读屏障来解决 GC 线程和应用线程可能存在的并发(修改对象状态的)问题,从而避免了Stop The World(全局停顿),因此使得 GC 的性能大幅提升。

ZGC 的执行流程和 CMS 比较相似,首先是进行 GC Roots 标记,然后再通过指针进行并发着色标记,之后便是对标记为死亡的对象进行回收(被标记为橘色的对象),最后是重定位,将 GC 之后存活的对象进行移动,以解决内存碎片的问题。

小结

本课时我们介绍了 JDK 11 之前的 7 种垃圾收集器:Serial、Serial Old、ParNew、Parallel Scavenge、Parallel Old、CMS、G1,其中 CMS 收集器是 JDK 8 之前的主流收集器,而 JDK 9 之后的默认收集器为 G1,并且在文章的最后,介绍了性能更加强悍、综合表现更好的 ZGC 收集器,希望本课时的内容可以切实的帮助到你。

OK,这节课就讲到这里啦,下一课时我将分享“生产环境如何排查和优化 JVM?”,记得按时来听课哈。


第26讲:生产环境如何排除和优化 JVM?

通过前面几个课时的学习,相信你对 JVM 的理论及实践等相关知识有了一个大体的印象。而本课时将重点讲解 JVM 的排查与优化,这样就会对 JVM 的知识点有一个完整的认识,从而可以更好地应用于实际工作或者面试了。

我们本课时的面试题是,生产环境如何排查问题?

典型回答

如果是在生产环境中直接排查 JVM 的话,最简单的做法就是使用 JDK 自带的 6 个非常实用的命令行工具来排查。它们分别是:jps、jstat、jinfo、jmap、jhat 和 jstack,它们都位于 JDK 的 bin 目录下,可以使用命令行工具直接运行,其目录如下图所示:

Drawing 0.png

接下来我们来看看这些工具的具体使用。

1. jps(虚拟机进程状况工具)

jps(JVM Process Status tool,虚拟机进程状况工具)它的功能和 Linux 中的 ps 命令比较类似,用于列出正在运行的 JVM 的 LVMID(Local Virtual Machine IDentifier,本地虚拟机唯一 ID),以及 JVM 的执行主类、JVM 启动参数等信息。语法如下:

jps [options] [hostid]

常用的 options 选项:

  • -l:用于输出运行主类的全名,如果是 jar 包,则输出 jar 包的路径;

  • -q:用于输出 LVMID(Local Virtual Machine Identifier,虚拟机唯一 ID);

  • -m:用于输出虚拟机启动时传递给主类 main() 方法的参数;

  • -v:用于输出启动时的 JVM 参数。

使用实例:

➜  jps -l
68848
40085 org.jetbrains.jps.cmdline.Launcher
40086 com.example.optimize.NativeOptimize
40109 jdk.jcmd/sun.tools.jps.Jps
68879 org.jetbrains.idea.maven.server.RemoteMavenServer36
➜  jps -q
40368
68848
40085
40086
68879
➜  jps -m
40400 Jps -m
68848
40085 Launcher /Applications/IntelliJ IDEA2.app/Contents/lib/idea_rt.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/oro-2.0.8.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/resources_en.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/maven-model-3.6.1.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/qdox-2.0-M10.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/plexus-component-annotations-1.7.1.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/httpcore-4.4.13.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/maven-resolver-api-1.3.3.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/netty-common-4.1.47.Final.jar:/Applications/IntelliJ IDEA2.app/Contents/plugins/java/lib/maven-resolver-connector-basic-1.3.3.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/maven-artifact-3.6.1.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/plexus-utils-3.2.0.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/netty-resolver-4.1.47.Final.jar:/Applications/IntelliJ IDEA2.app/Contents/lib/guava-28.2-
40086 NativeOptimize
68879 RemoteMavenServer36
➜  jps -v
68848  -Xms128m -Xmx2048m -XX:ReservedCodeCacheSize=240m -XX:+UseCompressedOops -Dfile.encoding=UTF-8 -XX:+UseConcMarkSweepGC -XX:SoftRefLRUPolicyMSPerMB=50 -ea -XX:CICompilerCount=2 -Dsun.io.useCanonPrefixCache=false -Djava.net.preferIPv4Stack=true -Djdk.http.auth.tunneling.disabledSchemes="" -XX:+HeapDumpOnOutOfMemoryError -XX:-OmitStackTraceInFastThrow -Djdk.attach.allowAttachSelf -Dkotlinx.coroutines.debug=off -Djdk.module.illegalAccess.silent=true -Xverify:none -XX:ErrorFile=/Users/admin/java_error_in_idea_%p.log -XX:HeapDumpPath=/Users/admin/java_error_in_idea.hprof -javaagent:/Users/admin/.jetbrains/jetbrains-agent-v3.2.0.de72.619 -Djb.vmOptionsFile=/Users/admin/Library/Application Support/JetBrains/IntelliJIdea2020.1/idea.vmoptions -Didea.paths.selector=IntelliJIdea2020.1 -Didea.executable=idea -Didea.home.path=/Applications/IntelliJ IDEA2.app/Contents -Didea.vendor.name=JetBrains
40085 Launcher -Xmx700m -Djava.awt.headless=true -Djava.endorsed.dirs="" -Djdt.compiler.useSingleThread=true -Dpreload.project.path=/Users/admin/github/blog-example/blog-example -Dpreload.config.path=/Users/admin/Library/Application Support/JetBrains/IntelliJIdea2020.1/options -Dcompile.parallel=false -Drebuild.on.dependency.change=true -Djava.net.preferIPv4Stack=true -Dio.netty.initialSeedUniquifier=1366842080359982660 -Dfile.encoding=UTF-8 -Duser.language=zh -Duser.country=CN -Didea.paths.selector=IntelliJIdea2020.1 -Didea.home.path=/Applications/IntelliJ IDEA2.app/Contents -Didea.config.path=/Users/admin/Library/Application Support/JetBrains/IntelliJIdea2020.1 -Didea.plugins.path=/Users/admin/Library/Application Support/JetBrains/IntelliJIdea2020.1/plugins -Djps.log.dir=/Users/admin/Library/Logs/JetBrains/IntelliJIdea2020.1/build-log -Djps.fallback.jdk.home=/Applications/IntelliJ IDEA2.app/Contents/jbr/Contents/Home -Djps.fallback.jdk.version=11.0.6 -Dio.netty.noUnsafe=true -Djava.io.tmpdir=/Users/admin/Library/Caches/Je
40086 NativeOptimize -Dfile.encoding=UTF-8
40425 Jps -Dapplication.home=/Users/admin/Library/Java/JavaVirtualMachines/openjdk-14/Contents/Home -Xms8m -Djdk.module.main=jdk.jcmd
68879 RemoteMavenServer36 -Djava.awt.headless=true -Dmaven.defaultProjectBuilder.disableGlobalModelCache=true -Xmx768m -Didea.maven.embedder.version=3.6.1 -Dmaven.ext.class.path=/Applications/IntelliJ IDEA2.app/Contents/plugins/maven/lib/maven-event-listener.jar -Dfile.encoding=UTF-8
2. jstat(虚拟机统计信息监视工具)

jstat(JVM Statistics Monitoring Tool,虚拟机统计信息监视工具)用于监控虚拟机的运行状态信息。

例如,我们用它来查询某个 Java 进程的垃圾收集情况,示例如下:

➜  jstat -gc 43704
 S0C    S1C    S0U    S1U      EC       EU        OC         OU       MC     MU    CCSC   CCSU   YGC     YGCT    FGC    FGCT    CGC    CGCT     GCT
10752.0 10752.0  0.0    0.0   65536.0   5243.4   175104.0     0.0     4480.0 774.0  384.0   75.8       0    0.000   0      0.000   -          -    0.000

参数说明如下表所示:

参数说明
S0C年轻代中第一个存活区的大小
S1C年轻代中第二个存活区的大小
S0U年轻代中第一个存活区已使用的空间(字节)
S1U年轻代中第二个存活区已使用的空间(字节)
ECEden 区大小
EU年轻代中 Eden 区已使用的空间(字节)
OC老年代大小
OU老年代已使用的空间(字节)
YGC从应用程序启动到采样时 young gc 的次数
YGCT从应用程序启动到采样时 young gc 的所用的时间(s)
FGC从应用程序启动到采样时 full gc 的次数
FGCT从应用程序启动到采样时 full gc 的所用的时间
GCT从应用程序启动到采样时整个 gc 所用的时间

注意:年轻代的 Eden 区满了会触发 young gc,老年代满了会触发 old gc。full gc 指的是清除整个堆,包括 young 区 和 old 区。

jstat 常用的查询参数有:

  • -class,查询类加载器信息;

  • -compiler,JIT 相关信息;

  • -gc,GC 堆状态;

  • -gcnew,新生代统计信息;

  • -gcutil,GC 堆统计汇总信息。

3. jinfo(查询虚拟机参数配置工具)

jinfo(Configuration Info for Java)用于查看和调整虚拟机各项参数。语法如下:

jinfo <option> <pid>

查看 JVM 参数示例如下:

➜  jinfo -flags 45129
VM Flags:
-XX:CICompilerCount=3 -XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296 -XX:MaxNewSize=1431306240 -XX:MinHeapDeltaBytes=524288 -XX:NewSize=89128960 -XX:OldSize=179306496 -XX:+UseCompressedClassPointers -XX:+UseCompressedOops -XX:+UseFastUnorderedTimeStamps -XX:+UseParallelGC

其中 45129 是使用 jps 查询的 LVMID。
我们可以通过 jinfo -flag [+/-]name 来修改虚拟机的参数值,比如下面的示例:

➜  jinfo -flag PrintGC 45129 # 查询是否开启 GC 打印
-XX:-PrintGC
➜  jinfo -flag +PrintGC 45129 # 开启 GC 打印
➜  jinfo -flag PrintGC 45129 # 查询是否开启 GC 打印
-XX:+PrintGC
➜  jinfo -flag -PrintGC 45129 # 关闭 GC 打印
➜  jinfo -flag PrintGC 45129 # 查询是否开启 GC 打印
-XX:-PrintGC
4. jmap(堆快照生成工具)

jmap(Memory Map for Java)用于查询堆的快照信息。

查询堆信息示例如下:

➜  jmap -heap 45129
Attaching to process ID 45129, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.101-b13
using thread-local object allocation.
Parallel GC with 6 thread(s)
Heap Configuration:
   MinHeapFreeRatio         = 0
   MaxHeapFreeRatio         = 100
   MaxHeapSize              = 4294967296 (4096.0MB)
   NewSize                  = 89128960 (85.0MB)
   MaxNewSize               = 1431306240 (1365.0MB)
   OldSize                  = 179306496 (171.0MB)
   NewRatio                 = 2
   SurvivorRatio            = 8
   MetaspaceSize            = 21807104 (20.796875MB)
   CompressedClassSpaceSize = 1073741824 (1024.0MB)
   MaxMetaspaceSize         = 17592186044415 MB
   G1HeapRegionSize         = 0 (0.0MB)
Heap Usage:
PS Young Generation
Eden Space:
   capacity = 67108864 (64.0MB)
   used     = 5369232 (5.1204986572265625MB)
   free     = 61739632 (58.87950134277344MB)
   8.000779151916504% used
From Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
To Space:
   capacity = 11010048 (10.5MB)
   used     = 0 (0.0MB)
   free     = 11010048 (10.5MB)
   0.0% used
PS Old Generation
   capacity = 179306496 (171.0MB)
   used     = 0 (0.0MB)
   free     = 179306496 (171.0MB)
   0.0% used

2158 interned Strings occupying 152472 bytes.

我们也可以直接生成堆快照文件,示例如下:

➜  jmap -dump:format=b,file=/Users/admin/Documents/2020.dump 47380
Dumping heap to /Users/admin/Documents/2020.dump ...
Heap dump file created
5. jhat(堆快照分析功能)

jhat(JVM Heap Analysis Tool,堆快照分析工具)和 jmap 搭配使用,用于启动一个 web 站点来分析 jmap 生成的快照文件。

执行示例如下:

jhat /Users/admin/Documents/2020.dump
Reading from /Users/admin/Documents/2020.dump...
Dump file created Tue May 26 16:12:41 CST 2020
Snapshot read, resolving...
Resolving 17797 objects...
Chasing references, expect 3 dots...
Eliminating duplicate references...
Snapshot resolved.
Started HTTP server on port 7000
Server is ready.

上述信息表示 jhat 启动了一个 http 的服务器端口为 7000 的站点来展示信息,此时我们在浏览器中输入:http://localhost:7000/,会看到如下图所示的信息:

Drawing 1.png

6. jstack(查询虚拟机当前的线程快照信息)

jstack(Stack Trace for Java)用于查看当前虚拟机的线程快照,用它可以排查线程的执行状况,例如排查死锁、死循环等问题。

比如,我们先写一段死锁的代码:

public class NativeOptimize {
    private static Object obj1 = new Object();
    private static Object obj2 = new Object();
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                synchronized (obj2) {
                    System.out.println(Thread.currentThread().getName() + "锁住 obj2");
                    try {
                        Thread.sleep(1000);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    synchronized (obj1) {
                        // 执行不到这里
                        System.out.println("1秒钟后," + Thread.currentThread().getName()
                                + "锁住 obj1");
                    }
                }
            }
        }).start();
        synchronized (obj1) {
            System.out.println(Thread.currentThread().getName() + "锁住 obj1");
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            synchronized (obj2) {
                // 执行不到这里
                System.out.println("1秒钟后," + Thread.currentThread().getName()
                        + "锁住 obj2");
            }
        }
    }
}

以上程序的执行结果如下:

main:锁住 obj1
Thread-0:锁住 obj2

此时我们使用 jstack 工具打印一下当前线程的快照信息,结果如下:

➜  bin jstack -l 50016
2020-05-26 18:01:41
Full thread dump Java HotSpot(TM) 64-Bit Server VM (25.101-b13 mixed mode):
"Attach Listener" #10 daemon prio=9 os_prio=31 tid=0x00007f8c00840800 nid=0x3c03 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE
   Locked ownable synchronizers:
	- None
"Thread-0" #9 prio=5 os_prio=31 tid=0x00007f8c00840000 nid=0x3e03 waiting for monitor entry [0x00007000100c8000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at com.example.optimize.NativeOptimize$1.run(NativeOptimize.java:25)
	- waiting to lock <0x000000076abb62d0> (a java.lang.Object)
	- locked <0x000000076abb62e0> (a java.lang.Object)
	at java.lang.Thread.run(Thread.java:745)
   Locked ownable synchronizers:
	- None
"Service Thread" #8 daemon prio=9 os_prio=31 tid=0x00007f8c01814800 nid=0x4103 runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE
   Locked ownable synchronizers:
	- None
"C1 CompilerThread2" #7 daemon prio=9 os_prio=31 tid=0x00007f8c0283c800 nid=0x4303 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE
   Locked ownable synchronizers:
	- None
"C2 CompilerThread1" #6 daemon prio=9 os_prio=31 tid=0x00007f8c0300a800 nid=0x4403 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE
   Locked ownable synchronizers:
	- None
"C2 CompilerThread0" #5 daemon prio=9 os_prio=31 tid=0x00007f8c0283c000 nid=0x3603 waiting on condition [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE
   Locked ownable synchronizers:
	- None
"Signal Dispatcher" #4 daemon prio=9 os_prio=31 tid=0x00007f8c0283b000 nid=0x4603 runnable [0x0000000000000000]
   java.lang.Thread.State: RUNNABLE
   Locked ownable synchronizers:
	- None
"Finalizer" #3 daemon prio=8 os_prio=31 tid=0x00007f8c03001000 nid=0x5003 in Object.wait() [0x000070000f8ad000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x000000076ab08ee0> (a java.lang.ref.ReferenceQueue$Lock)
	at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:143)
	- locked <0x000000076ab08ee0> (a java.lang.ref.ReferenceQueue$Lock)
	at java.lang.ref.ReferenceQueue.remove(ReferenceQueue.java:164)
	at java.lang.ref.Finalizer$FinalizerThread.run(Finalizer.java:209)
   Locked ownable synchronizers:
	- None
"Reference Handler" #2 daemon prio=10 os_prio=31 tid=0x00007f8c03000000 nid=0x2f03 in Object.wait() [0x000070000f7aa000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait(Native Method)
	- waiting on <0x000000076ab06b50> (a java.lang.ref.Reference$Lock)
	at java.lang.Object.wait(Object.java:502)
	at java.lang.ref.Reference.tryHandlePending(Reference.java:191)
	- locked <0x000000076ab06b50> (a java.lang.ref.Reference$Lock)
	at java.lang.ref.Reference$ReferenceHandler.run(Reference.java:153)
   Locked ownable synchronizers:
	- None
"main" #1 prio=5 os_prio=31 tid=0x00007f8c00802800 nid=0x1003 waiting for monitor entry [0x000070000ef92000]
   java.lang.Thread.State: BLOCKED (on object monitor)
	at com.example.optimize.NativeOptimize.main(NativeOptimize.java:41)
	- waiting to lock <0x000000076abb62e0> (a java.lang.Object)
	- locked <0x000000076abb62d0> (a java.lang.Object)
   Locked ownable synchronizers:
	- None
"VM Thread" os_prio=31 tid=0x00007f8c01008800 nid=0x2e03 runnable
"GC task thread#0 (ParallelGC)" os_prio=31 tid=0x00007f8c00803000 nid=0x2007 runnable

“GC task thread#1 (ParallelGC)” os_prio=31 tid=0x00007f8c00006800 nid=0x2403 runnable

“GC task thread#2 (ParallelGC)” os_prio=31 tid=0x00007f8c01800800 nid=0x2303 runnable
“GC task thread#3 (ParallelGC)” os_prio=31 tid=0x00007f8c01801800 nid=0x2a03 runnable
“GC task thread#4 (ParallelGC)” os_prio=31 tid=0x00007f8c01802000 nid=0x5403 runnable
“GC task thread#5 (ParallelGC)” os_prio=31 tid=0x00007f8c01006800 nid=0x2d03 runnable
“VM Periodic Task Thread” os_prio=31 tid=0x00007f8c00010800 nid=0x3803 waiting on condition
JNI global references: 6

Found one Java-level deadlock:

“Thread-0”:
  waiting to lock monitor 0x00007f8c000102a8 (object 0x000000076abb62d0, a java.lang.Object),
  which is held by “main”
“main”:
  waiting to lock monitor 0x00007f8c0000ed58 (object 0x000000076abb62e0, a java.lang.Object),
  which is held by “Thread-0”

Java stack information for the threads listed above:

“Thread-0”:
at com.example.optimize.NativeOptimize$1.run(NativeOptimize.java:25)
- waiting to lock <0x000000076abb62d0> (a java.lang.Object)
- locked <0x000000076abb62e0> (a java.lang.Object)
at java.lang.Thread.run(Thread.java:745)
“main”:
at com.example.optimize.NativeOptimize.main(NativeOptimize.java:41)
- waiting to lock <0x000000076abb62e0> (a java.lang.Object)
- locked <0x000000076abb62d0> (a java.lang.Object)

Found 1 deadlock.

从上述信息可以看出使用 jstack ,可以很方便地排查出代码中出现“deadlock”(死锁)的问题。

考点分析

Java 虚拟机的排查工具是一个合格程序员必备的技能,使用它我们可以很方便地定位出问题的所在,尤其在团队合作的今天,每个人各守一摊很容易出现隐藏的 bug(缺陷)。因此使用这些排查功能可以帮我们快速地定位并解决问题,所以它也是面试中常问的问题之一。

和此知识点相关的面试题还有以下这些:

  • 除了比较实用的命令行工具之外,有没有方便一点的排查工具?

  • JVM 常见的调优手段有哪些?

知识扩展

可视化排查工具

JVM 除了上面的 6 个基础命令行工具之外,还有两个重要的视图调试工具,即 JConsole 和 JVisualVM,它们相比于命令行工具使用更方便、操作更简单、结果展现也更直观。

JConsole 和 JVisualVM 都位于 JDK 的 bin 目录下,JConsole(Java Monitoring and Management Console)是最早期的视图调试工具,其启动页面如下图所示:

Drawing 2.png

可以看出我们可以用它来连接远程的服务器,或者是直接调试本机,这样就可以在不消耗生产环境的性能下,从本机启动 JConsole 来连接服务器。选择了调试的进程之后,运行界面如下图所示:

Drawing 3.png

从上图可以看出,使用 JConsole 可以监控线程、CPU、类、堆以及 VM 的相关信息,同样我们可以通过线程这一页的信息,发现之前我们故意写的死锁问题,如下图所示:

Drawing 4.png

可以看到 main(主线程)和 Thread-0 线程处于死锁状态。

JVisualVM 的启动图如下图所示:

Drawing 5.png

由上图可知,JVisualVM 既可以调试本地也可以调试远程服务器,当我们选择了相关的进程之后,运行如下图所示:

Drawing 6.png

可以看出 JVisualVM 除了包含了 JConsole 的信息之外,还有更多的详细信息,并且更加智能。例如,线程死锁检查的这页内容如下图所示:

Drawing 7.png

可以看出 JVisualVM 会直接给你一个死锁的提示,而 JConsole 则需要程序员自己分析。

JVM 调优

JVM 调优主要是根据实际的硬件配置信息重新设置 JVM 参数来进行调优的,例如,硬件的内存配置很高,但 JVM 因为是默认参数,所以最大内存和初始化堆内存很小,这样就不能更好地利用本地的硬件优势了。因此,需要调整这些参数,让 JVM 在固定的配置下发挥最大的价值。

JVM 常见调优参数包含以下这些:

  • -Xmx,设置最大堆内存大小;

  • -Xms,设置初始堆内存大小;

  • -XX:MaxNewSize,设置新生代的最大内存;

  • -XX:MaxTenuringThreshold,设置新生代对象经过一定的次数晋升到老生代;

  • -XX:PretrnureSizeThreshold,设置大对象的值,超过这个值的对象会直接进入老生代;

  • -XX:NewRatio,设置分代垃圾回收器新生代和老生代内存占比;

  • -XX:SurvivorRatio,设置新生代 Eden、Form Survivor、To Survivor 占比。

我们要根据自己的业务场景和硬件配置来设置这些值。例如,当我们的业务场景会有很多大的临时对象产生时,因为这些大对象只有很短的生命周期,因此需要把“-XX:MaxNewSize”的值设置的尽量大一些,否则就会造成大量短生命周期的大对象进入老生代,从而很快消耗掉了老生代的内存,这样就会频繁地触发 full gc,从而影响了业务的正常运行。

小结

本课时我们讲了 JVM 排查的 6 个基本命令行工具:jps、jstat、jinfo、jmap、jhat、jstack,以及 2 个视图排查工具:JConsole 和 JVisualVM;同时还讲了 JVM 的常见调优参数,希望本课时的内容可以切实的帮助到你。


评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值