JVM笔记

主流虚拟机

  • HotSpot:Oracle/Sun JDK和OpenJDK都使用HotSPot VM的相同核心

  • J9

  • JRockit

  • Zing

  • Dalvik

JAVA程序的加载过程

在这里插入图片描述

查看字节码文件

# 进入要查看的class文件目录
javap -v [文件名]
  • java虚拟机采用基于栈的架构,其指令由操作码和操作数组成。这些字节码指令叫opcode。
  • jvm就是靠解析这些opcode和操作数来完成程序执行的。Java命令运行.class时,就相当于启动了一个jvm进程。
  • jvm翻译字节码的两种执行方式:
    • 解释执行:将opcode + 操作数翻译成机器代码
    • JIT:即时编译,它会在一定条件下将字节码编译成机器码之后再执行

JVM整体架构

  • 根据JVM规范,jdk7中,JVM内存共分为虚拟机栈、堆、方法区、程序计数器、本地方法栈五个部分。在这里插入图片描述
名称特征作用配置参数异常
虚拟机栈线程私有,生命周期与线程相同,使用连续的内存空间JAVA方法执行的内存模型,存储局部变量表、操作数栈、动态链接、方法返回地址等信息-XssStackOverflowError / OutOfMemoryError
线程共享,生命周期与虚拟机周期相同,可以不使用连续的内存地址保存对象实例,所有对象实例(包括数组)都要在堆上分配-Xms -Xmx -XmnOutOfMemoryError
方法区线程共享,生命周期与虚拟机相同,可以不使用连续的内存地址存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据-XX:PermSize:16M -XX:MaxPermSize64M / -XX:MetaspaceSize=16M -XX:MaxMetaspaceSize=64MOutOfMemoryError
程序计数器占用内存小,线程私有,生命周期与线程相同字节码行号指示器
本地方法栈线程私有为虚拟机使用到的native方法服务StackOverflowError / OutOfMemoryError
  • JVM 五大模块:类装载器子系统、运行时数据区、执行引擎、本地方法接口、垃圾收集模块在这里插入图片描述

  • 类加载器子系统:加载、链接、初始化

    • 类加载器:引导加载器(BootStrap class loader)(由c / c++实现)、扩展类加载器(Extension class loader)、应用类加载器(Application class loader),不同的加载器加载不同的类解码文件
    • 链接:校验(verify)、准备(prepare)、解析(resolve)
    • 初始化
  • 运行时数据区:程序计数器、栈区、堆区、方法区、本地方法栈

  • 执行引擎:解释器、JIT编译器、垃圾回收器

JVM运行时内存

  • jdk7的内存结构在这里插入图片描述

  • jdk8的内存结构在这里插入图片描述
    在这里插入图片描述

  • 堆区包含新生代、老年代

    • 新生代包含:Eden、S0、S1
  • 元空间:常量池、方法元信息、类元信息

  • 在java8中,元空间(metaspace)出现,方法区存在于元空间。同时,元空间不再与堆连续,而且是存在于本地内存(native memory)。

  • 方法区JDK8的变化

    • 移除了永久代(PernGen),替换为了元空间(metaspace)。
    • 永久代中的类元信息(class metadata)转移到了本地内存(native memory),而不是虚拟机。
    • 永久代中的字符串常量池(interned Strings)和类静态变量(class static variables)转到到了Java Heap。
    • 永久代参数(PermSize MaxPermSize)-> 元空间参数(MetaspaceSize MaxMetaspaceSize)
  • Java8为什么要将永久代替换成Metaspace?

    • 字符串存在永久代中,容易出现性能问题和内存溢出。
    • 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
    • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
    • Oracle 可能会将HotSpot 与 JRockit 合二为一,JRockit没有所谓的永久代。

程序计数器

  • idea 查看字节码插件:jclasslib bytecode viewer
  • 也叫PC寄存器

虚拟机栈

  • Java虚拟机栈(Java Virtual Machine Stacks)线程私有,即生命周期和线程相同。Java虚拟机栈和线程同时创建,用于存储栈帧。每个方法在执行时都会创建一个栈帧(Stack Frame)。
  • 栈帧:(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用至执行完成的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。
  • 设置虚拟机栈大小:-Xss,为jvm启动的每个线程分配的内存大小,默认jdk1.4是256K,jdk1.5+是1M,一般不需要设置
-Xss1m
  • 局部变量表

    • 一组变量的存储空间,用于存放方法参数和方法内定义的局部变量,包括8种基本数据类型、对象引用(reference类型)和returnAddress类型(指向一条字节码指令的地址)。
    • 64位long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。
  • 操作数栈:Operated Stack,也称操作栈,是一个后入先出栈(LIFO)。随着方法执行和字节码指令的执行,会从局部变量表或对象实例的字段中复制常量或变量写入到操作数栈,再随着计算的进行将栈中元素出栈到局部变量表或者返回给方法调用者,也就是出栈/入栈操作。在这里插入图片描述

  • 动态链接:java虚拟机栈中,每个栈帧都包含一个指向运行时常量池中该栈所需方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态链接(Dymatic Linking)

    • 作用:将符号引用转换成直接引用。
  • 方法返回地址:存放调用该方法的PC寄存器的值。一个方法的结束,有两种方式:正常地执行完成、出现未处理的异常非正常的退出。无论通过哪种方式退出,在方法退出后都返回到该方法被调用的位置。方法正常退出时,调用者的PC计数器的值作为返回地址,即调用该方法的指令的下一条指令的地址。而通过异常退出的,返回地址是要通过异常表来确定,栈帧中一般不会保存这部分信息。无论方法是否正常完成,都需要返回到方法被调用的位置,程序才能继续进行。

本地方法栈

  • 本地方法栈(Native Method Stacks) 与虚拟机栈所发挥的作用是非常相似的, 其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码) 服务, 而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。
    • 在Java虚拟机规范中,对本地方法栈这块区域,与Java虚拟机栈一样,规定了两种类型的异常:
    • StackOverFlowError:线程请求的栈深度 > 所允许的深度。(递归调用)
    • OutOfMemoryError:本地方法栈扩展时无法申请到足够的内存。

  • 特点
    1. 是java虚拟机所管理的内存中最大的一块
    2. 是jvm中所有线程共享的。
      堆中也包含私有的线程缓冲区 Thread Local Allocation Buffer (TLAB)
    3. 在虚拟机启动的时候创建
    4. 唯一的目的就是存放对象实例,几乎所有的对象以及数组都要在这里分配内存
    5. java堆是垃圾收集器管理的主要区域,因此很多时候java堆也被称为“GC堆”(Garbage Collected Heap)
    6. 从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆还可以细分为:新生代和老年代;新生代又可以分为:Eden 空间、From Survivor空间、To Survivor空间
    7. java堆是计算机物理上不连续的,逻辑上是连续的,也是大小可调节的(通过-Xms和-Xmx控制)
    8. 方法结束后,堆中对象不会马上移除,仅仅在垃圾回收的时候才移除
    9. 如果在堆中没有内存完成实例的分配,并且堆也无法再扩展时,将会抛出OutOfMemmoryError异常
  • 设置堆空间大小
# 当前java最小可用内存5m,最大可用内存20m
-Xms5m -Xmx20m -XX:+PrintGCDetails
  • 堆的分类
    1. jdk7:

      • 新生代Young Generation
      • 老年代Old Generation
      • 永久代Permanent Generation在这里插入图片描述
    2. jdk8后,由于方法区的内存不在分配在Java堆上,而是存储于本地内存元空间Metaspace中,所以永久代就不存在了。在这里插入图片描述

    3. 新生代(Young Gen):主要存放新创建的对象,内存大小相对会比较小,垃圾回收会比较频繁。分成1个Eden Space和2个Suvivor Space(from 和to)。

    4. 老年代(Tenured Gen):主要存放JVM认为生命周期比较长的对象(经过几次的Young Gen的垃圾回收后仍然存在),内存大小相对会比较大,垃圾回收也相对没有那么频繁。

    5. 配置新生代和老年代堆结构占比:

      • 默认 -XX:NewRatio=2,标识新生代占1,老年代占2,新生代占整个堆的1/3
      • 修改占比 -XX:NewRatio=4,标识新生代占1,老年代占4,新生代占整个堆的1/5
      • Eden空间和另外两个Survivor空间占比分别为8:1:1,可以通过操作选项 -XX:SurvivorRatio 调整这个空间比例。 比如 -XX:SurvivorRatio=8,即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。因此,新生代实际可用的内存空间为 9/10 ( 即90% )的新生代空间
      • 几乎所有的java对象都在Eden区创建, 但80%的对象生命周期都很短,创建出来就会被销毁
# jdk1.8:设置最大可用内存,最小可用内存, 设置老年代和年轻代比例:4:1,设置Eden:from:to比例=8:1:1
-Xmx600m -Xms600m -XX:NewRatio=4 -XX:SurvivorRatio=8
# 通过jvisualvm 查看
jvisualvm
# 安装visual GC插件
  • 对象分配过程

    1. new的对象先放到Eden区,该区域有大小限制
    2. 当Eden区填满时,程序需要创建对象,JVM的垃圾回收器将对Eden预期进行垃圾回收(Minor GC),将Eden区域中不再被其他对象引用的对象进行销毁,再加载新的对象放到Eden区
    3. 然后将Eden区中剩余的对象移动到S0区
    4. 如果再次触发垃圾回收,此时上次幸存下来的都在S0区的,如果没有被回收,就会放到S1区
    5. 如果再次经历垃圾回收,此时未被回收的对象会重新返回S0区, 再回收再去S1区
    6. 如果累计次数到达默认的15次,就会进入老年代,可以通过设置参数调整阈值:-XX:MaxTenuringThreshold=15
    7. 老年代内存不足时,会再次发生GC(Major GC)进行老年代内存清理
    8. 如果老年代执行了Major GC后,仍然没有办法进行对象的保存,就会报OOM异常在这里插入图片描述
  • 堆GC

    • GC回收垃圾的主要区域。分两种:一种是部分收集器(Partial GC),另一种是整堆回收器(Full GC)。
      • 部分回收期又分为:
        • 新生代收集(Minor GC / Young GC)
        • 老年代收集(Major GC / Old GC),(CMS GC单独回收老年代)
        • 混合收集(Mixed GC),收集整个新生代和老年代的垃圾收集,(G1 GC会混合回收,region区域回收)
      • 整堆收集(Full GC),收集整个java堆和方法区的垃圾收集器
    • 新生代GC的触发条件:
      • 新生代空间(Eden区满)不足,触发Minor GC,(Survivor区不满不会触发GC)
      • Minor GC会引发STW(stop the world),暂停其他用户的线程,等待垃圾回收接收,用户的线程才能恢复
    • 老年代的触发条件:
      • 老年代空间不足时,会尝试触发Minor GC,如果空间还是不足,则触发Major GC
      • 如果Major GC,内存仍然不足,则报错OOM
      • Major GC的速度比Minor GC慢10倍以上
    • Full GC
      • 调用System.gc(),系统会执行Full GC,但不是立即执行
      • 老年代空间不足
      • 方法区空间不足
      • 通过Minor GC进入老年代平均大小大于老年代可用内存
        注意:调优过程尽量减少Full GC执行
  • 元空间:jdk1.7之前,把方法区当成永久代进行垃圾回收,从jdk1.8开始,移除永久代,并把方法区移至元空间,它位于本地内存中,而不是虚拟机内存。

    • 存储位置不同:永久代在物理上是堆的一部分,和新生代、老年代的地址是连续的,而元空间属于本地内存

    • 存储内容不同:在原来的永久代划分中,永久代用来存放类的元数据信息、静态变量以及常量池等。现在类的元信息存储在元空间中,静态变量和常量池等并入堆中
      在这里插入图片描述

    • Metaspace相关参数:

      • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
      • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。如果没有使用该参数来设置类的元数据的大小,其最大可利用空间是整个系统内存的可用空间。JVM也可以增加本地内存空间来满足类元数据信息的存储。但是如果没有设置最大值,则可能导致Metaspace的空间在不停的扩展,会导致机器的内存不足;进而可能出现swap内存被耗尽;最终导致进程直接被系统直接kill掉。如果设置了该参数,当Metaspace剩余空间不足,会抛出:java.lang.OutOfMemoryError: Metaspace space
      • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
      • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

方法区

  • 元空间、永久代是方法区具体的落地实现,方法区看作是一块独立于Java堆的内存空间,主要用来存储所加载的类信息在这里插入图片描述

  • 特点:

    • 方法区与堆一样是各个线程共享的内存区域
    • 方法区在JVM启动的时候就会被创建并且它实例的物理内存空间和Java堆一样都可以不连续
    • 方法区的大小跟堆空间一样,可以选择固定大小或者动态变化
    • 方法区的对象决定了系统可以保存多少个类,如果系统定义了太多的类导致方法区溢出虚拟机同样会跑出(OOM)异常(Java7之前是 PermGen Space (永久代) Java 8之后 是MetaSpace(元空间) )
    • 关闭JVM就会释放这个区域的内存
  • 方法区内部结构在这里插入图片描述

    • 类型信息:对每个加载的类型(类class、接口interface、枚举enum、注解annotation),jvm必须在方法区存储以下信息:
      1. 这个类型的完整有效名称(全名 = 包名.类名)
      2. 这个类型直接父类的有效名称(interface或java.lang.Object,没有父类)
      3. 这个类型的修饰符(public、abstract、final的某个子集)
      4. 这个类型直接接口的一个有序列表
    • 域信息:类的属性,成员变量
      1. jvm必须在方法区中保存所有的成员变量相关信息及声明顺序
      2. 域的相关信息包括:域名称、域类型、域修饰符(pυblic、private、protected、static、final、volatile、transient的某个子集)
    • 方法信息,保存下列信息及声明顺序
      1. 方法名称方法的返回类型(或void)
      2. 方法参数的数量和类型(按顺序)
      3. 方法的修饰符public、private、protected、static、final、synchronized、native,、abstract的一个子集
      4. 方法的字节码bytecodes、操作数栈、局部变量表及大小( abstract和native方法除外)
      5. 异常表( abstract和 native方法除外)。每个异常处理的开始位置、结束位置、代码处理在程序计数器中的偏移地址、被捕获的异常类的常量池索引
    • 方法区设置,大小不必是固定的,jvm可以根据应用的需要动态调整
      • jdk7及以前
        1. 通过-XX:PermSize来设置永久代初始分配空间。默认值是20.75M
        2. -XX:MaxPermSize来设定永久代最大可分配空间。32位机器默认是64M,64位机器模式是82M
        3. 当JVM加载的类信息容量超过了这个值,会报异常OutofMemoryError:PermGen space。
      • jdk8及以后
        1. 元数据区大小可以使用参数 -XX:MetaspaceSize 和 -XX:MaxMetaspaceSize指定
        2. 默认值依赖于平台。windows下,-XX:MetaspaceSize是21M,-XX:MaxMetaspaceSize的值是-1,即没有限制。与永久代不同,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存。如果元数据区发生溢出,虚拟机一样会抛出异常OutOfMemoryError:Metaspace
        3. -XX:MetaspaceSize:设置初始的元空间大小。对于一个64位的服务器端JVM来说,其默认的-XX:MetaspaceSize值为21MB。这就是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类(即这些类对应的类加载器不再存活)然后这个高水位线将会重置。新的高水位线的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放空间过多,则适当降低该值
        4. 如果初始化的高水位线设置过低,上述高水位线调整情况会发生很多次。通过垃圾回收器的日志可以观察到Full GC多次调用。为了避免频繁地GC,建议将-XX:MetaspaceSize设置为一个相对较高的值。
# 查看JDK7 PermSpace区域默认大小
jps
jinfo -flag PermSize [进程号] # 查看进程的PermSize初始化空间大小
jinfo -flag MaxPermSize [进程号] # 查看进程的PermSize的最大空间

# 查看JDK8 MetaspaceSize区域默认大小
jps
jinfo -flag MetaspaceSize [进程号] # 查看进程的MetaspaceSize初始化空间大小
jinfo -flag MaxMetaspaceSize [进程号] # 查看进程的MetaspaceSize的最大空间
# 设置元空间大小
-XX:MetaspaceSize=100m -XX:MaxMetaspaceSize=500m
# 通过上面的命令验证

运行时常量池

  • 字节码文件中,内部包含了常量池(存放编译期间生成的字面常量与符号引用)
  • 方法区中,内部包含了运行时常量池(常量池在运行时的表现形式)
  • 常量池,可以看做是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面常量等类型

直接内存

  • 不是运行时数据区的一部分
  • jdk1.4中加入了NIO(New Input / Output)类,引入了一种基于通道(Channel) 与缓冲区 (Buwer) 的I/O方式, 它可以使用Native函数库直接分配堆外内存, 然后通过一个存储在Java堆里面的 DirectByteBuffer(分配在物理内存上)对象作为这块内存的引用进行操作。 这样能在一些场景中显著提高性能, 因为避免了在Java堆和Native堆中来回复制数据。
  • 好处:
    1. 改善堆过大时的垃圾回收效率,减少停顿,Full GC时会扫描堆内存,回收效率和堆大小成正比。native的内存由OS负责管理和回收
    2. 减少内存在native和jvm堆的拷贝过程,避免拷贝损耗,降低内存使用
    3. 可突破jvm内存大小限制
# 指定直接内存的容量大小,如果不指定,默认与java堆最大值(-Xmx)一致
-XX:MaxDirectMemorySize=10m -Xmx=20m

OutOfMemoryError 常见原因

  • 内存中加载的数据过多,如一次从数据库中读取过多数据
  • 集合对象引用过多且使用完后没有清空
  • 代码中存在死循环或循环产生过多重复的对象
  • 堆内存分配不合理

虚拟机栈和本地方法栈溢出

  • 给每个线程的栈分配的内存越大,越容易产生内存溢出

JVM加载机制

  • 类使用的7个阶段:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)在这里插入图片描述

  • 加载:有两种时机会触发加载

    • 预加载:加载JAVA_HOME/lib/下rt.jar中的.class文件
    • 运行时加载:虚拟机在用到一个class文件的时候,会先到内存中查看是否加载过这个class文件,如果没有就会按照类的全限定名来加载这个类
# 获取类加载信息
-XX:+TraceClassLoading
  • 链接

    • 验证:确保.class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全
      • 文件格式验证
      • 元数据验证
      • 字节码验证
      • 符号引用验证
    • 准备:为类变量分配内存并设置初始值,这些变量所使用的内存都在方法区中分配。
      • 被static修饰的类变量
      • 不被final修饰的static变量,如:public static int value = 123; 准备阶段初始值为0,123要到初始化阶段才赋值。
      • public static final int value = 123; 在准备阶段就会赋值123
    • 解析:虚拟机将常量池内的符号引用替换为直接引用的过程,解析阶段负责把整个类激活,串成一个可以找到彼此的网
      • 类或接口的解析
      • 类方法解析
      • 接口方法解析
      • 字段解析
    • 初始化:执行类构造器cinit()方法的过程。cinit()是由编译器自动收集类中所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问 到定义在静态语句块之前的变量, 定义在它之后的变量, 在前面的静态语句块可以赋值, 但是不能访问
    • 符号引用包括三类常量:(符号引用的目标未必已经加载到内存中
      • 类和接口的全限定名称
      • 字段的名称和描述符
      • 方法的名称和描述符
    • 直接引用:可以是指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。(如果有了直接引用,那引用的目标必定已经加载到内存中
  • 类加载器

    • jvm在程序第一次主动使用类的时候,才会加载该类到内存中,而且只加载一次
  • 双亲委派:如果一个类加载器收到类加载请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器完成,只有当父类加载器在自己的搜索范围内找不到指定的类时(ClassNotFoundException),子加载器才会尝试自己去加载

  • 自定义函数调用过程在这里插入图片描述

  • 自定义类加载器实现:应该继承ClassLoader类,有两种做法:

    1. 重写loadClass()方法(是实现双亲委派逻辑的地方,修改它会破坏双亲委派机制,不推荐)
    2. 重写findClass()方法(推荐)

垃圾回收机制

  • 判断对象是否存活
    • 引用计数算法:给每个创建的对象添加一个引用计数器,被引用 +1 ,引用失效 -1 ,当计数值为0,表示对象不能被使用。虽然简单直接,但是对java来说,不是一个好的选择,因为它很难解决对象直接相互循环引用问题。
      • 优点:实现简单,执行效率高
      • 缺点:无法检测出循环引用
    • 可达性分析算法:通过一系列的“GC Roots”的对象作为起始点,从起始点开始向下搜索到对象的路径。搜索所经过的路径称为引用链(Reference Chain),当一个对象到任何GC Roots都没有引用链时,则表明对象“不可达”,即该对象是不可用的。
      • 在Java语言中,可作为GC Roots的对象包括下面几种:
        1. 栈帧中的局部变量表中的reference引用所引用的对象
        2. 方法区中static静态引用的对象
        3. 方法区中final常量引用的对象
        4. 本地方法栈中JNI(Native方法)引用的对象
        5. Java虚拟机内部的引用, 如基本数据类型对应的Class对象, 一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError) 等, 还有系统类加载器。
        6. 所有被同步锁(synchronized关键字) 持有的对象
        7. 反映Java虚拟机内部情况的JMXBean、 JVMTI中注册的回调、 本地代码缓存等
      • jvm通过fianalize()方法最终判断对象是否存活:需要进行两次标记,最终被finalize()判定回收
    • 引用从强到弱:
      1. 强引用(Strong Refrence):即时内存空间不足,jvm宁可OOM,也不会回收, A a = new A();
      2. 软引用(Soft Reference):内存足够时,就不会回收,内存不足,会回收
      3. 弱引用(Weak Reference):只能存活到下一次垃圾回收发生,不管内存是否足够,都会被回收
      4. 虚引用(Phantom Reference):在任何时候都有可能被回收,主要用来跟踪对象被垃圾回收器回收的活动。虚引用必须和引用队列(Reference Queue)联合使用
    • 垃圾收集算法
      • 分代收集理论
      • 标记 - 清除算法,首先标记需要回收的对象,标记完成后,统一回收掉,两个不足之处:
        1. 执行效率不稳定, 如果Java堆中包含大量对 象, 而且其中大部分是需要被回收的, 这时必须进行大量标记和清除的动作, 导致标记和清除两个过程的执行效率都随对象数量增长而降低
        2. 内存空间的碎片化问题, 标记、 清除之后会产生大 量不连续的内存碎片, 空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
      • 标记 - 复制算法:将内存分配为相等大小的两块,每次只使用其中的一块,当内存用完时,就把存活的对象复制到另外一块去,然后再把使用过的那块一次清理掉。缺点:
        1. 需要预留内存空间给存活对象,导致GC更频繁
        2. 如果出现存活的对象比回收的多的情况,成本上升,效率降低
        3. 如果99%的对象都是存活的(老年代),那么就无法使用这种算法
      • 标记 - 整理算法:标记要清除的对象,然后让存活的对象都向内存空间的一端移动,最后清理掉边界以外的内存

垃圾收集器

  • 垃圾回收器分类
    • 串行垃圾回收(Serial):为单线程环境设计,只使用一个线程垃圾回收,会暂停所有用户线程,不适合交互性强的服务器环境
    • 并行垃圾回收(Parallel):多个垃圾回收线程并行工作,同样会暂停用户线程,适用于科学计算、大数据处理等多交互场景
    • 并发垃圾回收(CMS):用户线程和垃圾回收线程同时执行,不一定是并行,可能是交替执行,不需要停顿用户线程,互联网应用程序中经常使用,适用于对响应时间有要求的场景
    • G1垃圾回收:将堆内存分割成不同的区域,并发地对其进行垃圾回收
  • 分代思想,7种主流垃圾回收器:
    • 新生代:

      • Serial
        • 优点:简单、高效
        • 使用方式:-XX:+UseSerialGC:这个参数说明新生代使用了Serial GC,老年代使用了Serial Old GC
        • -XX:+PrintCommandLineFlags:查看程序使用默认jvm参数
      • ParNew,单CPU服务器上垃圾收集效率比不上Serial收集器,多CPU服务器上效果会比Serial好
        • 使用方式:-XX:+UseParNewGC(收集新生代) -XX:+UseConcMarkSweepGC(CMS,并行垃圾回收器,收集老年代),组合使用
        • 设置线程数:-XX:ParallelGCThreads,默认设置为CPU的核数
      • Parallel Scavenge,又称吞吐量优先级收集器
        • 适用场景:后台运算、交互不多的任务:批处理、订单处理、科学计算
        • 使用方式:-XX:+UseParellelGC
        • 最大垃圾收集停顿时间:-XX:MaxGCPauseMillis,大于0的毫秒数
        • 吞吐量大小:-XX:GCTimeRatio,大于0小于100的整数,默认是99
        • 设置新生代线程数:-XX:ParallelGCThreads:(当cpu核数小于等于8,默认cpu核数相同;当cpu核数超过8,ParallelGCThreads设置为 3 + (5 * CPU_COUNT) / 8)
        • -XX:+UseAdaptiveSizePolicy(有了这个参数之后,就不要手工指定年轻代、Eden、Suvisor区的比例,晋升老年代的对象年龄等,因为虚拟机会根据系统运行情况进行自适应调节)
    • 老年代:

      • Serial Old,使用标记 - 整理算法,主要用于Client模式
        • 使用方式:-XX:+UseSerialGC
      • Parallel Old,使用标记 - 整理算法,jdk6后,代替Serial Old,特别是在Server模式,多CPU情况下
        • 使用方式:-XX:+UseParallelOldGC
      • CMS,是以获取最短垃圾收集停顿时间为目标的垃圾收集器,使用标记 - 清除算法,CMS默认启动的回收线程数是(处理器核心数量 + 3) / 4
        • 回收的4个步骤:初始标记(Initial-Mark)(STW)、并发标记(Concurrent-Mark)、重新标记(Remark)(STW)、并发清除(Concurrent-Sweep)
        • 缺点:
          1. 对CPU资源非常敏感
          2. 无法处理浮动垃圾,可能出现"Concurrent Mode Failure"失败而导致另一次Full GC的产生,CMS在jdk5默认老年代使用了68%的空间被激活,偏保守,可以通过-XX:CMSInitiatingOccupancyFraction适当调高,到jdk6,启动阈值默认提升到92%,这样可能会面临CMS预留的内存无法满足程序分配新对象的需要,造成“并发失败”,冻结用户线程执行,临时启动Serial Old来重新进行老年代的垃圾收集,这样停顿时间就很长了
          3. CMS基于标记 - 清除算法,会产生空间碎片,jdk9之前采用-XX:+UseCMS-CompactAtFullCollection这个参数解决(默认开启,jdk9开始废弃),在不得不进行Full GC的时候开始内存碎片的合并整理,但由于无法并发执行,所以停顿时间变长,还有-XX:CMSFullGCsBeforeCompaction(jdk9开始废弃),要求CMS在执行若干次不整理空间的Full GC之后,下一次Full GC之前会先整理碎片(默认值是0,表示每次进入Full GC时都会进行碎片整理)
    • 整理回收器:G1:面向服务端应用,主要针对多CPU、大内存的服务器,满足GC停顿时间的同时,兼具高吞吐量的性能

      • 特点:
        • 把内存划分成多个独立区域(Region:G1不再坚持固定大小以及固定数量的分代区域划分(将整个堆划分成2048个大小相同的独立块,2的N次幂,如1MB,2MB,4MB,最小1MB,最大32MB), 而是把连续的Java堆划分为多个独立区域(Region) , 每一个Region都可以根据需要, 扮演新生代的Eden空间、 Survivor空间, 或者老年代空间)
        • 保留分代思想,保留了新生代和老年代,但不再是物理隔离,而是一部分Region的集合
      • 充分利用多CPU、多核的硬件优势,尽量缩短STW
      • 整体采用标记 - 整理算法,局部采用标记 - 复制算法,不会产生内存碎片
      • 停顿可预测,能够明确指定在一个时间段内,消耗在垃圾收集上的时间不超过设置时间
      • 根据各个Region里垃圾回收的大小,维护一个优先列表,每次根据允许的时间来回收价值最大的区域,从而保证有限的时间内高效的垃圾回收
      • 增加了一种新的内存区域:Humongous,主要用于存储大对象,如果超过1.5个region就放到humongous,一般视为老年代在这里插入图片描述
    • G1的两种模式:Young GC 和 Mixed GC,都是STW的

      • Young GC:选定所有新生代里的Region。通过控制新生代的region个数,即新生代内存大小,来控制young GC的时间开销
      • Mixed GC:选定所有新生代里的Region,外加根据global concurrent marking统计得出收集收益高的若干老年代Region。在用户指定的开销目标范围内尽可能选择收益高的老年代Region
    • G1垃圾回收的4个阶段:

      • 初始标记(STW):和CMS一样只标记GC Roots直接关联的对象
      • 并发标记:进行GC Roots Traceing过程
      • 最终标记(STW):使用开始快照(SATB)算法(比CMS使用的算法快得多),修正并发标记期间,因程序运行导致发生变化的那一部分对象
      • 筛选回收(STW):根据时间来进行价值最大化收集
    • G1常用参数

参数 / 默认值含义
-XX:+UseG1GC使用 G1 垃圾收集器
-XX:MaxGCPauseMillis=200设置期望达到的最大GC停顿时间指标(JVM会尽力实现,但不保证达到)
-XX:InitiatingHeapOccupancyPercent=45mixed gc中也有一个阈值参数 ,当老年代大小占整个堆大小百分比达到该阈值时,会触发一次mixed gc. 默认值为 45
-XX:NewRatio=2新生代与老年代(new/old generation)的大小比例(Ratio). 默认值为2
-XX:SurvivorRatio=8eden/survivor 空间大小的比例(Ratio). 默认值为 8
-XX:MaxTenuringThreshold=15提升年老代的最大临界值(tenuring threshold). 默认值为 15
-XX:ParallelGCThreads=n设置垃圾收集器在并行阶段使用的线程数,默认值随JVM运行的平台不同而不同
-XX:ConcGCThreads=n并发垃圾收集器使用的线程数量. 默认值随JVM运行的平台不同而不同
-XX:G1ReservePercent=10设置堆内存保留为假天花板的总量,以降低提升失败的可能性. 默认值是 10
-XX:G1HeapRegionSize=n使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size 算出最优解. 最小值为 1Mb, 最大值为 32Mb
  • 垃圾回收器的组合:

    • jdk8默认Parallel Scavenge GC、Parallel Old GC
    • jdk9默认G1 GC
    • jdk14弃用了Parellel Scavenge GC、Parellel Old GC,移除了CMS GC在这里插入图片描述
  • GC性能指标

    • 吞吐量:即CPU用于运行用户代码的时间与CPU总消耗时间的比值(吞吐量 = 运行用户代码时间 / ( 运行用户代码时间 + 垃圾收集时间 ))。例如:虚拟机共运行100分钟,垃圾收集器花掉1分钟,那么吞吐量就是99%
    • 暂停时间:执行垃圾回收时,程序的工作线程被暂停的时间
    • 内存占用:java堆所占内存的大小
    • 收集频率:垃圾收集的频次

常用指令

  • jps
# -l:显示进程id,显示主类全名或jar路径
# -q:显示进程id
# -m:显示进程id,显示JVM启动时传递给main()的参数
# -v:显示进程id,显示JVM启动时显示指定的JVM参数
jps [option] [hostid]

# 输出jar包路径,类全名
jps -l

# 输出main参数
jps -m 

# 输出JVM参数
jps -v 
  • jinfo:查看jvm参数和动态修改部分jvm参数
# no options 输出所有的系统属性和参数
# -flag <name>  打印指定名称的参数
# -flag [+|-] <name>  打开或关闭参数
# -flag <name>=<value> 设置参数
# -flags 打印所有参数
# -sysprops 打印系统配置
jinfo [option] <pid>

# 查看jvm参数和系统配置
jinfo 1234
jinfo -flags 1234
jinfo -sysprops 1234

# 查看打印GC日志参数
jinfo -flag PrintGC 1234
jinfo -flag PrintGCDetails 1234

# 打开GC日志参数
jinfo -flag +PrintGC 1234
jinfo -flag +PrintGCDetails 1234

# 关闭GC日志参数
jinfo -flag -PrintGC 1234
jinfo -flag -PrintGCDetails 1234
  • 下面的参数可以使用jinfo来管理:

常用jvm参数:
-Xms:初始堆大小,默认为物理内存的1/64(<1GB);默认(MinHeapFreeRatio参数可以调整)空余堆内存小于40%时,JVM就会增大堆直到-Xmx的最大限制
-Xmx:最大堆大小,默认(MaxHeapFreeRatio参数可以调整)空余堆内存大于70%时,JVM会减少堆直到 -Xms的最小限制
-Xmn:新生代的内存空间大小,注意:此处的大小是(eden+ 2 survivor space)。与jmap -heap中显示的New gen是不同的。整个堆大小=新生代大小 + 老生代大小 + 永久代大小。
在保证堆大小不变的情况下,增大新生代后,将会减小老生代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
-XX:SurvivorRatio:新生代中Eden区域与Survivor区域的容量比值,默认值为8。两个Survivor区与一个Eden区的比值为2:8,一个Survivor区占整个年轻代的1/10。
-Xss:每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。应根据应用的线程所需内存大小进行适当调整。在相同物理内存下,
减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。一般小的应用, 如果栈不是很深, 应该是128k够用的,
大的应用建议使用256k。这个选项对性能影响比较大,需要严格的测试。和threadstacksize选项解释很类似,官方文档似乎没有解释,
在论坛中有这样一句话:"-Xss ``is translated `` in a VM flag named ThreadStackSize”一般设置这个值就可以了。
-XX:PermSize:设置永久代(perm gen)初始值。默认值为物理内存的1/64。
-XX:MaxPermSize:设置持久代最大值。物理内存的1/4。

  • jstat:查看jvm运行时的状态信息,包括内存状态、垃圾回收等
# -class class loader的行为统计
# -compiler HotSpt JIT编译器行为统计
# -gc 垃圾回收堆的行为统计
# -gccapacity 各个垃圾回收代容量(young,old,perm)和他们相应的空间统计
# -gcutil 垃圾回收统计概述
# -gccause 垃圾收集统计概述(同-gcutil),附加最近两次垃圾回收事件的原因
# -gcnew 新生代行为统计
# -gcnewcapacity 新生代与其相应的内存空间的统计
# -gcold 年老代和永生代行为统计
# -gcoldcapacity 年老代行为统计
# -printcompilation HotSpot编译方法统计
# interval:打印间隔时间(毫秒),count:打印次数,默认一直打印
jstat [option] pid [interval] [count]

# 例子,输入百分比
jstat -gcutil 1234 1000 3
# 字段解释
# S0 survivor0使用百分比
# S1 survivor1使用百分比
# E Eden区使用百分比
# O 老年代使用百分比
# M 元数据区使用百分比
# CCS 压缩使用百分比
# YGC 年轻代垃圾回收次数
# YGCT 年轻代垃圾回收消耗时间
# FGC Full GC垃圾回收次数
# FGCT Full GC垃圾回收消耗时间
# GCT 垃圾回收消耗总时间

# -gc 与 -gcutil 不同的是,输出的是实际值
jstat -gc 1234 1000 3
  • jstack:查看jvm线程快照,是当前jvm线程正在执行的方法堆栈的集合。使用jstack可以定位线程出现长时间卡顿的原因,如死锁、死循环等,还可以查看程序崩溃时生成的core文件中的stack信息
# -F 当使用jstack 无响应时,强制输出线程堆栈。
# -m 同时输出java堆栈和c/c++堆栈信息(混合模式)
# -l 除了输出堆栈信息外,还显示关于锁的附加信息
jstack [option]
  • 排查cpu占用高问题
    1. 使用Process Explorer工具找到cpu占用率较高的线程
    2. 在thread卡中找到cpu占用高的线程id
    3. 线程id转换成16进制
    4. 使用jstack -l 查看进程的线程快照
    5. 线程快照中找到指定线程,并分析代码
  • jmap:可以生成 java 程序的 dump 文件,也可以查看堆内对象示例的统计信息、查看 ClassLoader 的信息以及finalizer 队列
# 如果使用不带选项参数的jmap打印共享对象映射,将会打印目标虚拟机中加载的每个共享对象的起始 地址、映射大小以及共享对象文件的路径全称。
# -heap 打印java heap摘要
# -histo[:live] 打印堆中的java对象统计信息
# -clstats 打印类加载器统计信息
# -finalizerinfo 打印在f-queue中等待执行finalizer方法的对象
# -dump: 生成java堆的dump文件
#     dump-options:
#     live 只转储存活的对象,如果没有指定则转储所有对象
#     format=b 二进制格式
#     file= 转储文件到
jmap [option] pid(正在执行的进程id)

# 示例,把java堆中的存活对象信息转储到dump.bin文件
jmap -dump:live,format=b,file=dump.bin 1234

# 当前没有在等待执行finalizer的对象
jmap -finalizerinfo 1234

# 输出堆的详细信息
jmap -heap 1234

# 输出存活对象统计信息
jmap -histo:live 1234 | more
  • jhat:分析jmap生成dump文件,jhat内置了应用服务器,可以通过网页查看dump文件分析结果,一般用在离线分析上
# -stack false:关闭对象分配调用堆栈的跟踪
# -refs false:关闭对象引用的跟踪
# -port:HTTP服务器端口,默认是7000
# -debug:debug级别
# -version:分析报告版本
jhat [option] [dumofile]

jhat dump.bin

JVM常用工具

  • jconsole监控管理工具:jdk中自带的java监控、管理控制台,用于对JVM中内存,线程和类等的监控,是一个基于JMX(java management extensions)的GUI性能监测工具。jconsole使用jvm的扩展机制获取并展示虚拟机中运行的应用程序的性能和资源消耗等信息
    • 内存监控
    • 线程监控
  • VisualVM可视化优化工具:提供了一个可视界面,用于查看 JVM上运行的Java 应用程序的详细信息。VisualVM 对JDK工具所检索的,JVM 软件相关数据进行组织,并通过一种快速查看有关多个Java 应用程序的数据的方式提供该信息。查看本地应用程序以及远程主机上运行的应用程序的相关数据。此外,还可以捕获有关JVM软件实例的数据,并将该数据保存到本地系统,以供后期查看或与其他用户共享

GC日志分析

  • GC日志参数
参数说明
-XX:+PrintGC打印简单GC日志,类似:-verbose:gc
-XX:+PrintGCDetails打印GC详细信息
-XX:+PrintGCTimeStamps输出GC的时间戳(以基准时间的形式)
-XX:+PrintGCDateStamps输出GC的时间戳(以日期的形式)
-XX:+PrintHeapAtGC在进行GC的前后,打印出GC的信息
-Xloggc:…/logs/gc.log指定输出路径收集日志到日志文件
-XX:+UseGCLogFileRotation开启滚动生成日志
  • 常用垃圾收集器参数
参数说明
-XX:+UseSerialGC虚拟机在运行在 Client 模式下的默认值,打开此开关后,使用Serial+Serial Old 收集器组合进行内存回收
-XX:+UseParNewGC使用 ParNew + Serial Old 收集器组合进行内存回收
-XX:+UseConcMarkSweepGC使用 ParNew + CMS + Serial Old 的收集器组合尽心内存回收,当 CMS 出现Concurrent Mode Failure 失败后会使用 Serial Old 作为备用收集器
-XX:+UseParallelOldGC使用 Parallel Scavenge + Parallel Old 的收集器组合
-XX:+UseParallelGC使用 Parallel Scavenge + Serial Old (PS MarkSweep)的收集器组合
-XX:SurvivorRatio新生代中 Eden 和任何一个 Survivor 区域的容量比值,默认为 8
-XX:PretenureSizeThreshold直接晋升到老年代对象的大小,单位是Byte
-XX:+UseAdaptiveSizePolicy动态调整 Java 堆中各区域的大小以及进入老年代的年龄
-XX:ParallelGCThreads设置并行 GC 时进行内存回收的线程数
-XX:GCTimeRatioGC 时间占总时间的比率,默认值为99,只在 Parallel Scavenge 收集器的时候生效
-XX:MaxGCPauseMillis设置 GC 最大的停顿时间,只在 Parallel Scavenge 收集器的时候生效
-XX:CMSInitiatingOccupancyFraction=80设置 CMS 收集器在老年代空间被使用多少后触发垃圾收集,默认是68%,仅在 CMS 收集器上生效
-XX:CMSFullGCsBeforeCompaction设置 CMS 收集器在进行多少次垃圾回收之后启动一次内存碎片整理
-XX:+UseG1GC使用 G1 (Garbage First) 垃圾收集器
-XX:MaxGCPauseMillis设置最大GC停顿时间(GC pause time)指标(target). 这是一个软性指标(sox goal), JVM 会尽量去达成这个目标
-XX:G1HeapRegionSize使用G1时Java堆会被分为大小统一的的区(region)。此参数可以指定每个heap区的大小. 默认值将根据 heap size 算出最优解. 最小值为 1Mb, 最大值为 32Mb
# 日志含义
[GC (Allocation Failure) [PSYoungGen: 6146K->904K(9216K)] 6146K->5008K(19456K), 0.0038730 secs] [Times: user=0.08 sys=0.00, real=0.00 secs]
将上面 GC 日志抽象为各个部分,然后我们再分别说明各个部分的含义
[a(b)[c:d->e(f), g secs] h->i(j), k secs] [Times: user:l sys=m, real=n secs]

a: GC 或者是 Full GC
b: 用来说明发生这次 GC 的原因
c: 表示发生GC的区域,这里表示是新生代发生了GC,上面那个例子是因为在新生代中内存不够给新对象分配了,然后触发了 GC
d: GC 之前该区域已使用的容量
e: GC 之后该区域已使用的容量
f: 该内存区域的总容量
g: 表示该区域这次 GC 使用的时间
h: 表示 GC 前整个堆的已使用容量
i: 表示 GC 后整个堆的已使用容量
j: 表示 Java 堆的总容量
k: 表示 Java堆 这次 GC 使用的时间
l: 代表用户态消耗的 CPU 时间
m: 代表内核态消耗的 CPU 时间
n: 整个 GC 事件从开始到结束的墙钟时间(Wall Clock Time)

日志分析工具

  • GCeasy:一款在线的GC日志分析器,可以通过GC日志分析进行内存泄露检测、GC暂停原因分析、JVM配置建议优化等功能
  • GCViewer:一款实用的GC日志分析软件,免费开源使用,你需要安装jdk或者java环境才可以使用。软件为GC日志分析人员提供了强大的功能支持,有利于大大提高分析效率
    • 下载
    • 使用方式:java -jar gcviewer-1.37-SNAPSHOT.jar

JVM调优

  • tomcat调优,两方面:tomcat自身配置;tomcat所运行的jvm的调优;
# 添加用户
vim conf/tomcat-user.xml,增加:
<role rolename="manager"/> 
<role rolename="manager-gui"/> 
<role rolename="admin"/>
<role rolename="admin-gui"/>
<user username="tomcat" password="tomcat" roles="admin-gui,admin,manager-gui,manager"/>

# 配置可以访问Server Status(tomcat8,需要配置,tomcat9不需要配置)
vim webapps/manager/META-INF/context.xml
<Context antiResourceLocking="false" privileged="true" >
    <CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor" sameSiteCookies="strict" /> 
    <!-- <Valve className="org.apache.catalina.valves.RemoteAddrValve" allow="127\.\d+\.\d+\.\d+|::1|0:0:0:0:0:0:0:1" /> -->
    <Manager sessionAttributeValueClassNameFilter="java\.lang\. 
        (?:Boolean|Integer|Long|Number|String)|org\.apache\.catalina\.filters\.CsrfPreventionFilter\$Lru Cache(?:\$1)?|java\.util\.(?:Linked)?HashMap"/> 
</Context>

# 禁用AJP服务:AJP(Apache JServer Protocol)是定向包协议 。WEB服务器和Servlet容器通过TCP连接来交互;为了节省SOCKET创建的昂贵代价,WEB服务器会尝试维护一个永久TCP连接到servlet容器,并且在多个请求和响应周期过程会重用连接,Nginx + Tomcat的架构,用不到AJP协议
vim conf/server.xml
注释  <Connector port="8009" protocol="AJP/1.3" redirectPort="8443" />

# 设置线程池:频繁创建线程会造成性能浪费,使用线程池优化
vim conf/server.xml
# 打开注释
# maxThreads:最大并发数,默认设置 200,一般建议在 500 ~ 1000,根据硬件设施和业务来判断
# minSpareThreads:Tomcat 初 始 化 时 创 建 的 线 程 数 , 默 认 设 置 25
# prestartminSpareThreads: 在 Tomcat 初始化的时候就初始化 minSpareThreads 的参数值,如果不等于 true,minSpareThreads 的值就没啥效果了
# maxQueueSize,最大的等待队列数,超过则拒绝请求
<Executor name="tomcatThreadPool" namePrefix="catalina‐exec‐" maxThreads="500" minSpareThreads="50" prestartminSpareThreads="true" maxQueueSize="100"/>
<Connector port="8080" executor="tomcatThreadPool" protocol="HTTP/1.1" connectionTimeout="20000" redirectPort="8443" />

# 设置nio2的运行模式:tomcat有3种运行模式,bio(默认,效率低,没有任何优化处理)、nio(非阻塞i/o,比bio有更好的并发运行性能)、apr(从操作系统级别解决异步i/o问题,大幅度提高性能),tomcat8之前推荐nio,tomcat8中有nio2,速度更快
vim conf/server.xml
<Connector executor="tomcatThreadPool" port="8080" protocol="org.apache.coyote.http11.Http11Nio2Protocol" connectionTimeout="20000" redirectPort="8443" />

# 设置jvm参数优化
vim bin/catalina.sh,优化参数需要根据实际情况去调试
JAVA_OPTS="-XX:+UseG1GC -XX:MaxGCPauseMillis=100 -Xms512m -Xmx512m -XX:+PrintGCDetails 
    -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:../logs/gc.log"
# 可以用jmeter配合GCeasy根据GC日志进行分析,调整参数
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值