JVM(Java虚拟机)详解(JVM 内存模型、堆、GC、直接内存、性能调优)

文章详细介绍了JVM内存模型的结构,包括新生代、老年代、永久代(在JDK8中被元空间替代)以及直接内存。讨论了垃圾收集的不同类型,如MinorGC、MajorGC和FullGC,以及它们之间的区别。此外,还提到了堆内存分区的原因、STW事件、内存调优参数和垃圾回收器的选择。文章强调了理解和优化JVM内存管理对于提升应用性能的重要性。
摘要由CSDN通过智能技术生成

写作前面
前些天发现了一个不错的人工智能学习网站,通俗易懂,风趣幽默,有兴趣的可以看一下。传送门

JVM(Java虚拟机)

JVM 内存模型 结构图

jdk1.8 结构图(极简)

在这里插入图片描述


jdk1.8 结构图(简单)

JVM(Java虚拟机):

  • 是一个抽象的计算模型。
  • 如同一台真实的机器,它有自己的指令集和执行引擎,可以在运行时操控内存区域。
  • 目的是为构建在其上运行的应用程序提供一个运行环境,能够运行 java 字节码。
  • JVM 可以解读指令代码并与底层进行交互:包括操作系统平台和执行指令并管理资源的硬件体系结构。

在这里插入图片描述


jdk1.7 结构图(详细)

在这里插入图片描述


JVM 内存模型 组成元素

Java 内存模型主要包含线程私有程序计数器java虚拟机栈本地方法栈线程共享堆空间元数据区直接内存

  • Java运行时数据区域

    Java 虚拟机在执行过程中会将所管理的内存划分为不同的区域,有的随着线程产生和消失,有的随着 Java 进程产生和消失。

    根据 JVM 规范,JVM 运行时区域大致分为程序计数器、虚拟机栈、本地方法栈、堆、方法区(jkd1.8废弃)五个部分。

  • 程序计数器(PC 寄存器、计数器)

    程序计数器就是当前线程所执行的字节码的行号指示器,通过改变计数器的值,来选取下一行指令,通过它主要实现跳转、循环、恢复线程等功能。

    在任何时刻,一个处理器内核只能运行一个线程,多线程是通过抢占 CPU,分配时间完成的。这时就需要有个标记,来标明线程执行到哪里,程序计数器便拥有这样的功能,所以,每个线程都已自己的程序计数器。

    可以理解为一个指针,指向方法区中的方法字节码(用来存储指向下一个指令的地址,也即将要执行的指令代码),由执行引擎读取下一条指令,是一个非常小的内存空间,几乎可以忽略不计。

    倘若执行的是 native 方法,则程序计数器中为空

  • Java 虚拟机栈(JVM Stacks)

    虚拟机栈也就是平常所称的栈内存,每个线程对应一个私有的栈,随着线程的创建而创建。栈里面存着的是一种叫“栈帧”的东西,每个方法在执行的同时都会创建一个栈帧,方法被执行时入栈,执行完后出栈。

    不存在垃圾回收问题,只要线程一结束该栈就释放,生命周期和线程一致。

    每个栈帧主要包含的内容如下:

    • 局部变量表

      存储着 java 基本数据类型(byte/boolean/char/int/long/double/float/short)以及对象的引用

      注意:这里的基本数据类型指的是方法内的局部变量

      局部变量表随着栈帧的创建而创建,它的大小在编译时确定,创建时只需分配事先规定的大小即可。在方法运行过程中,局部变量表的大小不会发生改变。

    • 操作数栈

    • 动态连接

    • 方法返回地址

    虚拟机栈可能会抛出两种异常:
    • 栈溢出(StackOverFlowError):

      若 Java 虚拟机栈的大小不允许动态扩展,那么当线程请求栈的深度超过当前 Java 虚拟机栈的最大深度时,抛出 StackOverFlowError 异常

    • 内存溢出(OutOfMemoryError):

      若虚拟机栈的容量允许动态扩展,那么当线程请求栈时内存用完了,无法再动态扩展时,抛出 OOM 异常

  • 本地方法栈(Native Method Stacks)

    本地方法栈是为 JVM 运行 Native 方法准备的空间,由于很多 Native 方法都是用 C 语言实现的,所以它通常又叫 C 栈。

    本地方法栈与虚拟机栈的作用是相似的,都是线程私有的,只不过本地方法栈是描述本地方法运行过程的内存模型。

    本地方法被执行时,在本地方法栈也会创建一块栈帧,用于存放该方法的局部变量表、操作数栈、动态链接、方法出口信息等。方法执行结束后,相应的栈帧也会出栈,并释放内存空间。也会抛出 StackOverFlowError 和 OutOfMemoryError 异常。

    虚拟机栈和本地方法栈的主要区别:

    • 虚拟机栈执行的是 java 方法
    • 本地方法栈执行的是 native 方法
  • Java 堆(Java Heap)

    Java 堆中是 JVM 管理的最大一块内存空间。主要存放对象实例。

    Java 堆是所有线程共享的一块内存,在虚拟机启动时创建,几乎所有的对象实例都存放在这里,是垃圾收集器管理的主要区域。

    Java 堆的分区:

    • 在 jdk1.8 之前,分为新生代、老年代、永久代

    • 在 jdk1.8 及之后,只分为新生代、老年代

      永久代在 jdk1.8 已经被移除,被一个称为 “元数据区”(元空间)的区域所取代

    Java 堆内存大小:

    • 堆内存大小 = 新生代 + 老年代(新生代占堆空间的1/3、老年代占堆空间2/3)
    • 既可以是固定大小的,也可以是可扩展的(通过参数 -Xmx 和 -Xms 设定)
    • 如果堆无法扩展或者无法分配内存时报 OOM

    主要存储的内容是:

    • 对象实例

    • 类初始化生成的对象

    • 基本数据类型的数组也是对象实例

    • 字符串常量池

      字符串常量池原本存放在方法区,jdk8 开始放置于堆中

      字符串常量池存储的是 string 对象的直接引用,而不是直接存放的对象,是一张 string table

    • 静态变量

      • static 修饰的静态变量,jdk8 时从方法区迁移至堆中

      • 线程分配缓冲区(Thread Local Allocation Buffer)

        线程私有,但是不影响 java 堆的共性

        增加线程分配缓冲区是为了提升对象分配时的效率

    堆和栈的区别:

    • 管理方式,堆需要GC,栈自动释放
    • 大小不同,堆比栈大
    • 碎片相关:栈产生的碎片远小于堆,因为GC不是实时的
    • 分配方式:栈支持静态分配内存和动态分配,堆只支持动态分配
    • 效率:栈的效率比堆高
  • 方法区(逻辑上)

    方法区是 JVM 的一个规范,所有虚拟机必须要遵守的。常见的 JVM 虚拟机有 Hotspot 、 JRockit(Oracle)、J9(IBM)

    方法区逻辑上属于堆的一部分,但是为了与堆区分,通常又叫非堆区

    各个线程共享,主要用于存储已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等。

    方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误。关闭 JVM 就会释放这个区域的内存。

    • Java8 以前是放在 JVM 内存中的,由堆空间中的永久代实现,受 JVM 内存大小参数限制
    • Java8 移除了永久代和方法区,引入了元空间

    拓展:

    JDK版本方法区的实现运行时常量池所在的位置
    JDK6PermGen space(永久代)PermGen space(永久代)
    JDK7PermGen space(永久代)Heap(堆)
    JDK8Metaspace(元空间)Heap(堆)
  • 元空间(元数据区、Metaspace)

    元空间是 JDK1.8 及之后,HotSpot 虚拟机对方法区的新实现。

    元空间不在虚拟机中,而是直接用物理(本地)内存实现,不再受 JVM 内存大小参数限制,JVM 不会再出现方法区的内存溢出问题,但如果物理内存被占满了,元空间也会报 OOM

    元空间和方法区不同的地方在于编译期间和类加载完成后的内容有少许不同,不过总的来说分为这两部分:

    • 类元信息(Class)

      类元信息在类编译期间放入元空间,里面放置了类的基本信息:版本、字段、方法、接口以及常量池表

      常量池表:主要存放了类编译期间生成的字面量、符号引用,这些信息在类加载完后会被解析到运行时常量池中

    • 运行时常量池(Runtime Constant Pool)

      运行时常量池主要存放在类加载后被解析的字面量与符号引用,但不止这些

      运行时常量池具备动态性,可以添加数据,比较多的使用就是 String 类的 intern() 方法

  • 直接内存(Direct Memory)

    直接内存不是虚拟机运行时数据区的一部分,而是在 Java 堆外,直接向系统申请的内存区域。

    常见于 NIO 操作时,用于数据缓冲区(比如 ByteBuffer 使用的就是直接内存)。

    分配、回收成本较高,但读写性能高。

    直接内存不受 JVM 内存回收管理(直接内存的分配和释放是 Java 会通过 UnSafe 对象来管理的),但是系统内存是有限的,物理内存不足时会报OOM。


Java 程序内存 = JVM 内存 + 本地内存

  • JVM 内存(JVM 虚拟机数据区)

    Java 虚拟机在执行的时候会把管理的内存分配到不同的区域,这些区域称为虚拟机(JVM)内存。

    JVM 内存受虚拟机内存大小的参数控制,当大小超过参数设置的大小时会报 OOM

  • 本地内存(元空间 + 直接内存)

    对于虚拟机没有直接管理的物理内存,也会有一定的利用,这些被利用但不在虚拟机内存的地方称为本地内存。

    本地内存不受虚拟机内存参数的限制,只受物理内存容量的限制。

    虽然不受参数的限制,如果所占内存超过物理内存,仍然会报 OOM


堆外内存

  • 直接内存

    直接内存不是虚拟机运行时数据区的一部分,而是在 Java 堆外,直接向系统申请的内存区域。

    可通过 -XX:MaxDirectMemorySize 调整大小,默认和 Java 堆最大值一样

    内存不足时抛出OutOf-MemoryError或 者OutOfMemoryError:Direct buffer memory;

  • 线程堆栈

    可通过 -Xss 调整大小

    内存不足时抛出

    • StackOverflowError(如果线程请求的栈深度大于虚拟机所允许的深度)
    • OutOfMemoryError(如果 Java 虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存)
  • Socket 缓存区

    每个 Socket 连接都 Receive 和 Send 两个缓存区,分别占大约 37KB 和 25KB 内存,连接多的话这块内存占用也比较可观。

    如果无法分配,可能会抛出 IOException:Too many open files异常

  • JNI 代码

    如果代码中使用了 JNI 调用本地库,那本地库使用的内存也不在堆中,而是占用 Java 虚拟机的本地方法栈和本地内存

  • 虚拟机和垃圾收集器

    虚拟机、垃圾收集器的工作也是要消耗一定数量的内存


JVM 堆及各种 GC 详解

参考:Java 中的新生代、老年代、永久代和各种 GC

结构图(新生代、老年代、永久代)

JVM 中的堆,一般分为三大部分:新生代、老年代、永久代( Java8 中已经被移除)

在这里插入图片描述


新生代、MinorGC(Young GC)

新生代

  • 主要是用来存放新生的对象。一般占据堆的 1/3 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。

  • 新生代又分为 Eden、S0、S1(SurvivorFrom、SurvivorTo)三个区

    • Eden 区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。

      当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。

    • SurvivorFrom 区:上一次 GC 的幸存者,作为这一次 GC 的被扫描者。

    • SurvivorTo 区:保留了一次 MinorGC 过程中的幸存者。

    Eden 和 S0,S1 区的比例为 8 : 1 : 1

    幸存者 S0,S1 区:复制之后发生交换,谁是空的,谁就是 SurvivorTo 区

    JVM 每次只会使用 eden 和其中一块 survivor 来为对象服务,所以无论什么时候,都会有一块 survivor 是空的,因此新生代实际可用空间只有 90%

  • 当 JVM 无法为新建对象分配内存空间的时候 (Eden 满了),Minor GC 被触发。因此新生代空间占用率越高,Minor GC 越频繁。


MinorGC

  • MinorGC 的过程(采用复制算法)

    1. 首先,把 Eden 和 ServivorFrom 区域中存活的对象复制到 ServicorTo 区域(如果有对象的年龄以及达到了老年的标准,一般是 15,则赋值到老年代区)
    2. 同时把这些对象的年龄 + 1(如果 ServicorTo 不够位置了就放到老年区)
    3. 然后,清空 Eden 和 ServicorFrom 中的对象;
    4. 最后,ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom 区。
  • Minor GC 触发机制:

    当年轻代满(指的是 Eden 满,Survivor 满不会引发 GC)时就会触发 Minor GC(通过复制算法回收垃圾)

  • 对象年龄(Age)计数器

    虚拟机给每个对象定义了一个对象年龄(Age)计数器。

    如果对象在 Eden 出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间中,并将对象年龄设为 1。

    对象在 Survivor 区中每熬过一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁)时,就会被晋升到老年代中。

    对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold (阈值) 来设置。


老年代、MajorGC(Old GC)

老年代

  • 老年代的对象比较稳定,所以 MajorGC 不会频繁执行。

  • 在进行 MajorGC 前一般都先进行了一次 MinorGC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。

    当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 MajorGC 进行垃圾回收腾出空间。

  • MajorGC 采用标记-清除算法

    1. 首先扫描一次所有老年代,标记出存活的对象
    2. 然后回收没有标记的对象。

    MajorGC 的耗时比较长(速度一般会比 Minor GC 慢10倍以上,STW 的时间更长),因为要扫描再回收。

    MajorGC 会产生内存碎片,为了减少内存损耗,一般需要进行合并或者标记出来方便下次直接分配。

  • 当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。


永久代、元数据区(元空间)、常量池

永久代(PermGen)

  • 是 JDK7 及之前, HotSpot 虚拟机基于 JVM 规范对方法区的一个落地实现,其他虚拟机如 JRockit(Oracle)、J9(IBM) 有方法区 ,但是没有永久代。

    在 JDK1.8 已经被移除,取而代之的是元数据区(元空间)

  • 内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域。

    和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。

    所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。

元数据区(元空间、Metaspace)

  • 元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。

  • 元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。

    默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:

    • -XX:MetaspaceSize (初始空间大小):达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整

      如果释放了大量的空间,就适当降低该值;

      如果释放了很少的空间,那么在不超过 MaxMetaspaceSize时,适当提高该值。

    • -XX:MaxMetaspaceSize(最大空间)默认是没有限制的。

    除了上面两个指定大小的选项以外,还有两个与 GC 相关的属性:

    • -XX:MinMetaspaceFreeRatio :在 GC 之后,最小的 Metaspace 剩余空间容量的百分比,减少为分配空间所导致的垃圾收集;
    • -XX:MaxMetaspaceFreeRatio :在GC之后,最大的 Metaspace 剩余空间容量的百分比,减少为释放空间所导致的垃圾收集;

    类的元数据放入本地内存中,字符串池和类的静态变量放入 java 堆中,这样可以加载多少类的元数据就不再由虚拟机的 MaxPermSize 控制,而由系统的实际可用空间来控制。


元空间替换永久代的原因分析:

  • 字符串存在永久代中,容易出现性能问题和内存溢出。

  • 通常会使用 PermSize 和 MaxPermSize 设置永久代的大小就决定了永久代的上限,但是类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。

    当使用元空间时,可以加载多少类的元数据就不再由 MaxPermSize 控制,而由系统的实际可用空间来控制。

  • 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。

  • Oracle 可能会将HotSpot 与 JRockit 合二为一。


类常量池、运行时常量池、字符串常量池

  • 类常量池

    在类编译过程中,会把类元信息存放到元空间(方法区),类元信息其中一部分便是类常量池

    主要存放字面量(字面量一部分便是文本字符)和符号引用

  • 运行时常量池

    在类加载时,会将字面量和符号引用解析为直接引用存储在运行时常量池

    (文本字符会在解析时查找字符串常量池,查出这个文本字符对应的字符串对象的直接引用,将直接引用存储在运行时常量池)

    • 在 JDK6,运行时常量池 存在于 方法区
    • 在 JDK7,运行时常量池 存在于 Java 堆
  • 字符串常量池

    存储的是字符串对象的引用,而不是字符串本身

    字符串常量池在 jdk7 时就已经从方法区迁移到了 java 堆中(JDK8 时,方法区就是元空间)

拓展

  • 字面量

    java 代码在编译过程中是无法构建引用的,字面量就是在编译时对于数据的一种表示:

    int a=1; // 这个1便是字面量
    String b="iloveu"; // iloveu便是字面量
    
  • 符号引用

    由于在编译过程中并不知道每个类的地址,因为可能这个类还未加载,所以如果在一个类中引用了另一个类,被引用的类的全限定类名会作为符号引用,在类加载完后用这个符号引用去获取它的内存地址。

    比如:com.javabc.Solution 类中引用了 com.javabc.Quest,那么 com.javabc.Quest 作为符号引用就会存到类常量池,等类加载完后,就可以拿着这个引用去元空间找此类的内存地址


Full GC 、Major GC(Old GC)

Minor GC、Major GC、Full GC 的区别

  • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC ):只是老年代的垃圾收集
  • 整堆收集(Full GC):收集整个 java 堆(young gen + old gen)和方法区的垃圾收集

Full GC 触发机制:

  • 调用 System.gc 时,系统建议执行 Full GC,但是不必然执行
  • 老年代空间不足
  • 方法区空间不足
  • 通过 Minor GC 后进入老年代的平均大小大于老年代的可用内存
  • 由 Eden 区、survivor space1(From Space)区向 survivor space2(To Space)区复制时,对象大小大于 To Space 可用内存,则把该对象转存到老年代,且老年代的可用内存小于该对象大小
  • 当永久代满时也会引发 Full GC,会导致 Class、Method 元信息的卸载

堆空间分成不同区的原因

  • 堆空间分为新生代和老年代的原因

    根据对象存活的时间,有的对象寿命长,有的对象寿命短。应该将寿命长的对象放在一个区,寿命短的对象放在一个区。不同的区采用不同的垃圾收集算法。寿命短的区清理频次高一点,寿命长的区清理频次低一点。

  • 新生代分为了 eden、Survivor 区的原因

    为了更好的管理堆内存中的对象,方便GC算法(复制算法)来进行垃圾回收。

    如果没有 Survivor 区,那么 Eden 每次满了清理垃圾,存活的对象被迁移到老年区,老年区满了,就会触发 Full GC,而 Full GC 是非常耗时的。

    将 Eden 区满了的对象,添加到 Survivor 区,等对象反复清理几遍之后都没清理掉,再放到老年区,这样老年区的压力就会小很多。即 Survivor 相当于一个筛子,筛掉生命周期短的,将生命周期长的放到老年代区,减少老年代被清理的次数。

  • 新生代的 Survivor 区又分为 s0 和 s1 区的原因:

    分两个区的好处就是解决内存碎片化。

    为什么一个 Survivor 区不行?

    假设现在只有一个survivor区,模拟一下流程:

    新建的对象在 Eden 中,一旦 Eden 满了,触发一次 Minor GC,Eden 中的存活对象就会被移动到 Survivor 区。这样继续循环下去,下一次 Eden 满了的时候,问题来了,此时进行 Minor GC,Eden和 Survivor 各有一些存活对象,如果此时把 Eden 区的存活对象硬放到 Survivor 区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。

  • GC 优化的本质,也是为什么分代的原因:减少GC次数和GC时间,避免全区扫描。


堆不是对象存储的唯一选择(逃逸分析)

如果经过逃逸分析后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样无需在堆上分配内存。也无须进行垃圾回收了。

逃逸分析概述: 一种可以有效减少 Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部引用,则认为没有发生逃逸
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。

GC(垃圾回收)

System.gc()

  • GC(Garbage Collection)垃圾回收。

    System.gc() 是用 Java,C#和许多其他流行的高级编程语言提供的API。

    当它被调用时,它将尽最大努力从内存中清除垃圾(即未被引用的对象)。

  • 在默认情况下,通过 System.gc() 或者Runtime.getRuntime().gc() 的调用,会显式触发 Full GC(完整的 GC 事件),对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

  • 在 GC 完成之前,整个 JVM 将冻结(即正在运行的所有服务将被暂停),通常完整的 GC 需要很长时间才能完成。

    因此在不合适的时间运行 GC,将导致不良的用户体验,甚至是崩溃。

    JVM 具有复杂的算法,该算法始终在后台运行,进行所有计算以及有关何时触发 GC 的计算。当显式调用 System.gc() 调用时,所有这些计算都将被抛掉。

  • system.gc() 调用附带一个免责声明,无法保证对垃圾收集器的调用**(不能确保立即生效)**

  • System.gc() 可以从应用程序堆栈的各个部分调用:

    • 开发的应用程序可以显式的调用 System.gc() 方法
    • System.gc() 也可以由第三方库,框架触发
    • 可以由外部工具(如 VisualVM)通过使用 JMX 触发
    • 如果应用程序使用了RMI,RMI会定期调用 System.gc()
  • GC 操作应该由 JVM 自行控制,在绝大部分的场景都不建议程序员手动写代码显式进行 System.gc() 操作。

    但是也不排除其中个别例外:

    在开发多个微服务时,每个服务都有多个备份节点。在非业务高峰时段,可以从微服务-负载均衡的节点池中取出其中一个 JVM 实例。然后通过该 JVM 上的 JMX 显式触发 System.gc() 调用,一旦 GC 事件完成并且从内存中清除了垃圾,将该 JVM 放回到微服务-负载均衡的节点池中。

    当然这个过程需要很好的微服务管理及服务发布机制配合,这样既能保证 JVM 垃圾内存的有效清理,又不影响业务的正常运行。


如何检测应用程序正在进行 System.gc()?

  • System.gc() 可以从多个渠道进行的调用,而不仅仅是从应用程序源代码进行的调用。因此,搜索应用程序代码System.gc() 字符串,不足以知道 GC 是否正在被调用。

  • 通过 GC 日志可以检测应用程序是否正在进行垃圾回收

    // java 8 启动 GC 日志:-XX:+PrintGCDetails -Xloggc:<gc-log-file-path>
    -XX:+PrintGCDetails -Xloggc:/opt/tmp/myapp-gc.log
    
    // java 9 启动 GC 日志:-Xlog:gc*:file=<gc-log-file-path>
    -Xlog:gc*:file=/opt/tmp/myapp-gc.log
    
  • 建议始终在所有生产服务器中始终启用 GC 日志,因为它有助于排除故障并优化应用程序性能。

    启用GC日志只会增加微不足道的开销。

    还可以将 GC 日志上传到垃圾收集日志分析器工具,例如GCeasy,HP JMeter等。这些工具将生成丰富的垃圾收集分析报告。


如何禁止GC显式调用或调整调用GC的频率?

如果就是想避免程序员显式调用GC,避免不成熟的程序员在不合适时间调用GC,避免人为造成的GC崩溃,可以通过如下方法:

  • 搜索和替换

    在代码库中搜索 System.gc() 和 Runtime.getRuntime().gc()

    如果看到匹配项,则将其删除。但是这种方法无法避免第三方库、框架或通过外部源进行调用。

  • 通过JVM参数强制禁止

    通过传递 JVM 参数 -XX:+DisableExplicitGC 来强制禁止显式调用。

    这种方式强制、有效,应用程序内的任何 GC 显式代码调用 System.gc() 都将被禁止生效。

    JVM 自身的 GC 策略不受此参数影响,只禁止人为的触发 GC。

  • RMI

    如果应用程序正在使用 RMI,则可以控制 GC 调用的频率 。启动应用程序时,可以使用以下JVM参数配置该频率:

    • -Dsun.rmi.dgc.server.gcInterval=n
    • -Dsun.rmi.dgc.client.gcInterval=n

    这些属性的默认值在

    • JDK 1.4.2 和 5.0 是 60000毫秒(即60秒)
    • JDK 6 和更高版本是 3600000毫秒(即60分钟)

    如果应用主机内存资源非常富余,可以将这些属性设置为很高的值,以便可以将GC带来的对应用程序的影响最小化。这也是应用程序性能优化的一种方式之一。


STW(Stop The World)事件

stop-the-world,简称 STW,指的是 GC 事件发生过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应,有点像卡死的感觉,这个停顿称为 STW。

可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿。

  • 分析工作必须在一个能确保一致性的快照中进行
  • 一致性指整个分析期间整个执行系统看起来像被冻结在某个时间点上
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证

被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以需要减少 STW 的发生。

STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件。哪怕是 G1 也不能完全避免 Stop-the-world 情况发生,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

开发中除非特殊情况,不要用 system.gc() 进行手动 GC,会导致 stop-the-world 的发生。


GC 常用算法

  • 分代收集算法(现在的虚拟机垃圾收集大多采用这种方式)

    它根据对象的生存周期,将堆分为新生代(Young)和老年代(Tenure)。

    新生代中,由于对象生存期短,每次回收都会有大量对象死去,所以使用的是复制算法。

    老年代里的对象存活率较高,没有额外的空间进行分配担保,所以使用的是标记-整理 或者 标记-清除。

  • 标记-清除算法

    每个对象都会存储一个标记位,记录对象的状态(活着或是死亡)。

    标记-清除算法分为两个阶段,一个是标记阶段,这个阶段内,为每个对象更新标记位,检查对象是否死亡;第二个阶段是清除阶段,该阶段对死亡的对象进行清除,执行 GC 操作。

    优点是可以避免内存碎片。

  • 标记-压缩(标记-整理)算法

    标记-压缩法是标记-清除法的一个改进版,和标记清除算法基本相同。

    不同的就是,在清除完成之后,会把存活的对象向内存的一边进行压缩(整理),然后把剩下的所有对象全部清除,这样就可以解决内存碎片问题。

  • 复制算法

    复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的。

    当有效内存空间耗尽时,JVM 将暂停程序运行,开启复制算法 GC 线程。接下来 GC 线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC 线程将更新存活对象的内存引用地址指向新的内存地址

    此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。

    复制算法不会产生内存碎片。

    在这里插入图片描述


直接内存(Direct Memory)详解

参考:JVM 直接内存

文件的读写过程

  • 传统 io 方式

    Java 本身不具备磁盘的读写能力,要想实现磁盘读写,必须调用操作系统提供的函数(即本地方法)。在这里 CPU 的状态改变从用户态(Java)切换到内核态(system)【调用系统提供的函数后】。

    内存这边也会有一些相关的操作,当切换到内核态以后,就可以由 CPU 的函数,去真正读取磁盘文件的内容,在内核状态时,读取内容后,会在操作系统内存中划出一块儿缓冲区,其称之为系统缓冲区,磁盘的内容先读入到系统缓冲区中(分次进行读取);系统的缓冲区是不能被 Java 代码直接操作的,所以 Java 会先在堆内存中分配一块儿 Java 的缓冲区,即代码中的 new byte[大小],Java 的代码要能访问到刚才读取的那个流中的数据,必须先从系统缓冲区的数据间接读入到 Java 缓冲区,然后 CPU 的状态又切换到用户态了,然后再去调用 Java 的那个输出流的写入操作,就这样反复进行读写读写,把整个文件复制到目标位置。

    可以发现,由于有两块儿内存,两块儿缓冲区,即系统内存和 Java 堆内存都有缓冲区,那读取的时候必然涉及到这数据存两份,第一次先读到系统缓冲区还不行,因为 Java 代码不能直接访问系统缓冲区,所以需要先把系统缓冲区数据读入到 Java 缓冲区中,这样就造成了一种不必要的数据的复制,效率因而不是很高。

  • directBuffer(直接缓存区)方式

    当 ByteBuffer 调用 allocateDirect 方法后,操作系统这边划出一块缓冲区,即 direct memory(直接内存),这段区域与之前不一样的地方在于这个操作系统划出来的内存可以被 Java 代码直接访问,即系统可以访问它,Java 代码也可以访问它,它是 java 代码和系统共享的一段内存区域,这就是直接内存。

    磁盘文件读到直接内存后,Java 代码直接访问直接内存,比传统 io 方式少了一次缓冲区里的复制操作,所以速度得到了成倍的提高。

    这也是直接内存带来的好处,适合做较大文件拷贝的这种 io 操作。

演示案例(运行并比较时间后可以发现,尤其是读写大文件时使用 ByteBuffer 的读写性能非常高):

// 演示ByteBuffer作用
public class Demo {
	static final String FORM = "D:\\asd\\asd.mp4"; // 选比较大的文件,比如200多兆
	static final String TO = "D:\\asd.mp4";
	static final int _1Mb = 1024 * 1024;

	public static void main(String[] args) {
        // io 用时:3187.41008(大概用了3秒),多跑几遍,多比较,跑一次不算。
		io();
        // directBuffer 用时:951.114625(不到1秒)
		derectBuffer();
	}

	private static void deirectBuffer() {
		long start = System.nanoTime();
		try (FileChannel from = new FileInputStream(FROM).getChannel();
			FileChannel to = new FileOutputStream(TO).getChannel();
		) {
			ByteBuffer bb = ByteBuffer.allocateDirect(_1Mb); // 读写的缓冲区(分配一块儿直接内存)
			while (true) {
				int len = from.read(bb);
				if (len == -1) {
					break;
				}
				bb.flip();
				to.write(bb);
				bb.clear();
			}

		}catch (IOException e) {
			e.printStackTrace();
		}
		long end = System.nanoTime();
		print("directBuffer用时:" + (end - start) / 1000_000.0);
	}

	// 用传统的io方式做文件的读写
	private static void io() {
		long start = System.nanoTime();
		try ( // 网友1:写到try()括号里就不用手动close了
			FileInputStream from = new FileInputStream(FROM);
			FileOutPutStream to = new FileOutputStream(TO);
		) {
			byte[] buf = new byte[_1Mb];// byte数组缓冲区(与上面的读写缓冲区设置大小一致,比较时公平)
			while (true) {
				int len = from.read(buf);// 用输入流读
				if (len == -1) {
					break;
				}
				to.write(buf, 0, len);// 用输出流写
			}
		}catch(IOException e) {
			e.printStackTrace();
		}
		long end = System.nanoTime();
		print("io用时:" + (end - start) / 1000_000.0);
	}
}

直接内存的分配和回收

直接内存的分配和释放是 Java 通过 UnSafe 对象来管理的,并且回收需要主动调用 freeMemory() 方法,不直接受 JVM 内存回收管理。

ByteBuffer 底层分配和释放直接内存的大概情况

  • ByteBuffer 对象被创建时,调用 Unsafe 对象的 allocateMemory(_1Gb) 方法分配直接内存,返回 long base,即内存地址

  • ByteBuffer 对象被销毁时,调用 unsafe 对象的 freeMemory(base) 方法释放直接内存。

    ByteBuffer 的实现类内部,使用了 Cleaner(虚引用)来检测 ByteBuffer 对象,一旦 ByteBuffer 对象被(Java)垃圾回收,那么就会由 ReferenceHandler 线程通过 Cleaner 的 clean 方法调用 freeMemory() 方法来释放直接内存。

演示案例(演示直接内存溢出)

  • 运行后,输出 36

    即循环 36 次(一次 100 兆,循环 36 次也算 3 个 G 多了)后,爆出直接内存溢出异常:

    Exception in thread “main” java.lang.OutOfMemoryError: Direct buffer memory

// 演示直接内存溢出
public class Demo {
	static int _100Mb = 1024 * 1024 * 100;
	
	public static void main(String[] args) {
		List<ByteBuffer> list = new ArrayList<>();
		int i = 0; 
		try {
			while (true) {
				ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_100Mb);// 每次分配100兆内存
				list.add(byteBuffer);// 把这玩意放到List中,一直循环
				i++;
			}
		}finally {
			print(i);
		}
	}
}

使用 System.gc() 间接进行直接内存的回收可能存在的问题

  • 代码案例

    public class Demo {
    	static int _1Gb = 1024 * 1024 * 1024;
    	public static void main(String[] args) throws IOException {
    		ByteBuffer byteBuffer = ByteBuffer.allocateDirect(_1Gb);
    		print("分配完毕");
    		print("开始释放");
    		byteBuffer = null;
    		System.gc(); // 显式的垃圾回收
    	}
    }
    
  • System.gc() 触发的是一次 Full GC,是比较影响性能的垃圾回收 ,不光要回收新生代,还要回收老年代,所以它造成的程序暂停时间比较长。

  • 为了防止一些程序员不小心在代码里经常写 System.gc() 以触发显式的垃圾回收,做一些 JVM 调优时经常会加上 JVM 虚拟机参数 -XX:+DisableExplicitGC,禁用这种显式的垃圾回收,也就是让 System.gc() 代码无效。但是加上这个虚拟机参数后,可能会间接影响到直接内存的回收机制。

    • 没加虚拟机参数的话,由于 byteBuffer 被 null 了,显式触发 Java 垃圾回收,byteBuffer 的堆内存被回收时,会调用 unsafe 对象的 freeMemory(base) 方法释放直接内存,所以也导致了直接内存也被释放掉。

    • 加虚拟机参数之后,System.gc() 代码失效,虽然 byteBuffer 被 null 了,但如果内存比较充足,那么它还会暂时存活着,其创建的直接内存(ByteBuffer.allocateDirect(-1Gb))也会在 byteBuffer 的堆内存被 JVM 自动进行垃圾回收前一直存在着。

    所以禁用 System.gc() 之后,会发现别的代码不受太大影响,但直接内存会受到影响,因为不能用显式的方法回收掉Bytebuffer,所以 ByteBuffer 只能等到 JVM 自动进行垃圾回收时,才会被清理,从而它所对应的那块儿直接内存在此之前也会一直不会被释放掉,这就会造成直接内存可能占用较大,长时间得不到释放这样一个现象。

    所以使用直接内存的情况比较多,由程序员直接手动的管理直接内存时,推荐用 Unsafe 的相关方法,直接调用 Unsafe 对象的 freeMemory() 方法来释放直接内存。


JVM 的性能调优

调优参数

配置方式

  • java [options] MainClass [arguments]
  • options :JVM 启动参数。 配置多个参数的时候,参数之间使用空格分隔。
  • 参数命名: 常见为 -参数名
  • 参数赋值: 常见为 -参数名=参数值 或 -参数名:参数值

内存参数:

  • -Xms(s 为 strating):初始堆大小,JVM启动的时候,给定堆空间大小。

    可以设置与-Xmx 相同,以避免每次垃圾回收完成后 JVM 重新分配内存。

    示例:-Xms3550m :设置 JVM 初始内存为 3550M。

  • -Xmx(x 为 max):最大堆大小,JVM运行过程中,如果初始堆空间不足的时候,最大可以扩展到多少。

    示例:-Xmx3550m :设置 JVM 最大可用内存为 3550M。

  • -Xmn(n 为 new):新生代大小

    整个堆大小 = 新生代大小 + 老年代大小 + 持久代大小(jkd1.8废弃)

    持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。

    此值对系统性能影响较大,Sun官方推荐配置为整个堆的 3/8

    示例:-Xmn2g:设置年轻代大小为2G。

  • -Xss:设置每个线程的 Java 栈大小。

    根据应用的线程所需内存大小进行调整。

    在相同物理内存下,减小这个值能生成更多的线程。

    但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。

    JDK5.0 以后每个线程 Java 栈大小为1M,以前每个线程堆栈大小为 256K。

    示例:-Xss128k :设置每个线程的堆栈大小为128k。

  • -XX:NewSize=n:设置年轻代大小

  • -XX:NewRatio=n:设置年轻代(包括 Eden 和两个 Survivor 区)与年老代的比值。

    示例:设置为 4 :年轻代与年老代所占比值为 1:4,年轻代占整个堆栈的 1/5

  • -XX:SurvivorRatio=n:年轻代中 Eden 区与两个 Survivor 区的比值。

    注意 Survivor 区有两个。

    示例:设置为 3 :表示 Eden:Survivor=3:2,一个 Survivor 区占整个年轻代的 1/5。

  • -XX:MaxPermSize=n:设置永久代大小

    示例:-XX:MaxPermSize=16m:设置持久代大小为16m。

  • -XX:MaxTenuringThreshold=n:设置垃圾最大年龄

    如果设置为 0 的话,则年轻代对象不经过 Survivor 区,直接进入年老代。对于年老代比较多的应用,可以提高效率。

    如果将此值设置为一个较大值,则年轻代对象会在Survivor区进行多次复制,这样可以增加对象再年轻代的存活时间,增加在年轻代即被回收的概率。


垃圾回收器参数

JVM给了三种选择:串行收集器、并行收集器、并发收集器。串行收集器只适用于小数据量的情况。

  • -XX:+UseSerialGC: 设置串行收集器。

  • -XX:+UseParallelGC: 设置并行收集器,表示年轻代使用并行收集器。

  • -XX:+UseParNewGC: 设置年轻代为并行收集。

    可与 CMS 收集同时使用。

    JDK5.0 以上,JVM 会根据系统配置自行设置,所以无需再设置此值。

  • -XX:+UseParallelOldGC: 设置并行年老代收集器

    JDK6.0 支持对年老代并行收集。

  • -XX:+UseConcMarkSweepGC: 设置年老代并发收集器 CMS。

  • -XX:+UseG1GC: 设置G1收集器

  • -XX:ParallelGCThreads=n: 设置并行收集器收集时最大线程数使用的CPU数。并行收集线程数。

  • -XX:MaxGCPauseMillis=n: 设置并行收集最大暂停时间,单位毫秒。

    可以减少STW时间。

  • -XX:GCTimeRatio=n: 设置垃圾回收时间占程序运行时间的百分比。

    公式为 1/(1+n) 并发收集器设置

  • -XX:+CMSIncrementalMode: 设置为增量模式。

    适用于单 CPU 情况。

  • -XX:+UseAdaptiveSizePolicy: 设置此选项后,并行收集器会自动选择年轻代区大小和相应的 Survivor 区比例,以达到目标系统规定的最低相应时间或者收集频率等。

    此值建议使用并行收集器时,一直打开。

  • -XX:CMSFullGCsBeforeCompaction=n: 此值设置运行多少次 GC 以后对内存空间进行压缩、整理。

    因为并发收集器不对内存空间进行压缩、整理,所以运行一段时间以后会产生“碎片”,使得运行效率降低。

  • -XX:+UseCMSCompactAtFullCollection: 打开对年老代的压缩。

    可能会影响性能,但是可以消除碎片。


元空间参数:

  • -XX:MetaspaceSize:初始化的 Metaspace 大小,该值越大触发 Metaspace GC 的时机就越晚。

    随着GC的到来,虚拟机会根据实际情况调控 Metaspace 的大小,而上下浮动主要由 -XX:MaxMetaspaceFreeRatio 和 -XX:MinMetaspaceFreeRatio 两个参数控制。

    在默认情况下,这个值大小根据不同的平台在 12M 到 20M 浮动。

    使用 java -XX:+PrintFlagsInitial 命令查看本机的初始化参数。

  • -XX:MinMetaspaceFreeRatio

当进行过 Metaspace GC 之后,会计算当前 Metaspace 的空闲空间比,如果空闲比小于这个参数,那么虚拟机将增加 MetaspaceSize 的大小(为了避免过早引发一次垃圾回收)。

默认值为40,也就是40%。

设置该参数可以控制 Metaspace 的增长的速度,太小的值会导致 Metaspace 增长的缓慢,Metaspace的使用逐渐趋于饱和,可能会影响之后类的加载。而太大的值会导致 Metaspace 增长的过快,浪费内存。

  • -XX:MaxMetaspaceFreeRatio:当进行过 Metaspace GC 之后, 会计算当前Metaspace的空闲空间比,如果空闲比大于这个参数,那么虚拟机会减小 MetaspaceSize 的大小。

    默认值为70,也就是70%。

  • -XX:MaxMetaspaceExpansion :Metaspace 增长时的最大幅度。默认值大约为5MB。

  • -XX:MinMetaspaceExpansion :Metaspace 增长时的最小幅度。默认值大约330KB。

  • -XX:MaxMetaspaceSize:最大空间。默认是没有限制的。

    指定该值可以防止因为某些情况导致Metaspace无限的使用本地内存,影响到其他程序。


辅助参数

JVM提供了大量命令行参数,打印信息,供调试使用。商业项目上线的时候,不允许使用。一定使用 loggc。主要有以下一些:

  • -XX:+PrintGC

    输出形式:

    [GC 118250K->113543K(130112K), 0.0094143 secs]

    [Full GC 121376K->10414K(130112K), 0.0650971 secs]

  • -XX:+PrintGCDetails

    输出形式:

    [GC [DefNew: 8614K->781K(9088K), 0.0123035 secs] 118250K->113543K(130112K), 0.0124633 secs]

    [GC [DefNew: 8614K->8614K(9088K), 0.0000665 secs][Tenured: 112761K->10414K(121024K), 0.0433488 secs] 121376K->10414K(130112K), 0.0436268 secs]

  • -XX:+PrintGCTimeStamps -XX:+PrintGC:可与上面两个混合使用

    输出形式:11.851: [GC 98328K->93620K(130112K), 0.0082960 secs]

  • -XX:+PrintGCApplicationConcurrentTime :打印每次垃圾回收前,程序未中断的执行时间。

    可与上面混合使用

    输出形式:Application time: 0.5291524 seconds

  • -XX:+PrintGCApplicationStoppedTime: 打印垃圾回收期间程序暂停的时间。

    可与上面混合使用

    输出形式:Total time for which application threads were stopped: 0.0468229 seconds

  • -XX:PrintHeapAtGC :打印GC前后的详细堆栈信息

  • -Xloggc:filename :与上面几个配合使用,把相关日志信息记录到文件以便分析。


调优建议

  • 年轻代大小选择

    • 响应时间优先的应用:

      尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选择)。

      在此种情况下,年轻代收集发生的频率也是最小的。同时,减少到达老年代的对象。

    • 吞吐量优先的应用:

      尽可能的设置大,可能到达 Gbit 的程度。

      因为对响应时间没有要求,垃圾收集可以并行进行,一般适合 8 CPU 以上的应用。

  • 老年代大小选择

    • 响应时间优先的应用:

      老年代使用并发收集器,所以其大小需要小心设置,一般要考虑并发会话率和会话持续时间等一些参数。

      如果堆设置小了,可以会造成内存碎片、高回收频率以及应用暂停而使用传统的标记清除方式;

      如果堆大了,则需要较长的收集时间。

      最优化的方案,一般需要参考以下数据获得:

      • 并发垃圾收集信息
      • 持久代并发收集次数
      • 传统GC信息
      • 花在年轻代和年老代回收上的时间比例
      • 减少年轻代和老年代花费的时间,一般会提高应用的效率
    • 吞吐量优先的应用:

      一般吞吐量优先的应用都有一个很大的年轻代和一个较小的老年代。

      原因是,这样可以尽可能回收掉大部分短期对象,减少中期的对象,而老年代尽存放长期存活对象。

  • 较小堆引起的碎片问题

    因为老年代的并发收集器使用标记-清除算法,所以不会对堆进行压缩。

    当收集器回收时,它会把相邻的空间进行合并,这样可以分配给较大的对象。但是,当堆空间较小时,运行一段时间以后,就会出现“碎片”,如果并发收集器找不到足够的空间,那么并发收集器将会停止,然后使用传统的标记-清除方式进行回收。

    如果出现“碎片”,可能需要进行如下配置:

    • -XX:+UseCMSCompactAtFullCollection:使用并发收集器时,开启对年老代的压缩。
    • -XX:CMSFullGCsBeforeCompaction=0:上面配置开启的情况下,这里设置多少次 Full GC 后,对老年代进行压缩
Java内存模型Java Memory Model,JMM)定义了Java程序在多线程环境下的内存访问规则。它规定了线程如何和主内存、本地内存以及其他线程进行通信。 JVM内存模型是指Java虚拟机Java Virtual Machine,JVM)在执行Java程序时的内存布局和管理方式。JVM内存模型包括了内存、栈内存、方法区、直接内存等。 在Java内存模型中,主要有以下几个概念: 1. 主内存:所有线程共享的内存区域,包含了实例字段、静态字段以及数组元素。 2. 工作内存:每个线程独立的内存区域,包含了该线程使用的变量副本或者缓存。 3. 内存间的交互操作:线程之间通过读写主内存来进行通信。 4. 原子性、可见性和有序性:JMM保证了原子性(对基本类型的读写操作具有原子性)、可见性(一个线程对主内存的修改对其他线程是可见的)和有序性(在一个线程中,按照程序顺序执行)。 JVM内存模型主要包括以下几个部分: 1. 内存:用于存储对象实例,由垃圾回收器进行管理。 2. 栈内存:用于存储方法的局部变量和方法调用的信息。每个线程都有自己的栈内存。 3. 方法区:用于存储类的信息、常量、静态变量等。 4. 直接内存:在外分配内存,不受JVM管理,由操作系统进行管理。 需要注意的是,JVM内存模型是具体实现的一种规范,可以根据不同的JVM厂商进行优化和调整。而Java内存模型Java语言规范中定义的多线程内存访问规则,对于不同的JVM实现都是一样的。
评论 128
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

墨鸦_Cormorant

大家喜欢的话可以点个关注投币哟

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

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

打赏作者

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

抵扣说明:

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

余额充值