JVM中对象晋升老年代的几种方式(附实例验证)

前言:

JVM的内存中,从JDK1.8开始,内存被划分为四块区域,分别是堆区,栈区,程序计数器,本地方法栈。

其中堆区是所有线程共有,其余三块是线程独占。

堆区中,又被划分为三块区域,新生代,老年代,元空间。元空间中,存放class类等数据,替代原本方法区的功能。

翻阅网上的各种文档,往往是单纯的复制粘贴,缺乏实战,所以本文结合各种实例,来讲解下对象晋升为老年代的种种场景。

垃圾回收策略有四种,主要分为两类:其中UseParallelGC和UseParallelOldGC属于一类,UseParNewGC和UseSerialGC属于一类。两类的效果是不一样的,所以我们后面会区分开来讲。

一.主动GC

主动GC的时候,会把新生代中的对象挪到老年代。

为了方便看出效果,我们通过虚拟机参数进行一些配置:

  1. 配置参数-XX:+PrintGCDetails,方面我们查看虚拟机参数;

  2. 配置参数-Xmx128m,把JVM虚拟机内存大小设置为128M;

  3. 配置参数-XX:SurvivorRatio=3,把eden区和suivivor区的大小比设置为3比1;

  4. 配置参数-XX:MaxTenuringThreshold=5,把晋升老年代的年龄设置为5次;

  5. 配置参数 -XX:+UseParallelGC,使用UseParallelGC的回收策略。

然后开始实验,代码如下:

public void testgc() {
    System.gc();
    byte[] byte1 = new byte[2 * 1024 * 1024];
    byte[] byte2 = new byte[2 * 1024 * 1024];
    System.gc();
}
  1. 主动触发一次GC,避免老的对象影响。

  2. 创建两个变量byte1和byte2,分别申请2M的空间。

  3. 再次出主动触发一次GC

我们看一下执行结果:

[GC (System.gc()) [PSYoungGen: 522K->64K(34816K)] 1250K->792K(122368K), 0.0005943 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 64K->0K(34816K)] [ParOldGen: 728K->728K(87552K)] 792K->728K(122368K), [Metaspace: 3584K->3584K(1056768K)], 0.0051335 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
//解释1
[GC (System.gc()) [PSYoungGen: 4618K->4096K(34816K)] 5346K->4824K(122368K), 0.0030396 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 
//解释2
[Full GC (System.gc()) [PSYoungGen: 4096K->0K(34816K)] [ParOldGen: 728K->4824K(87552K)] 4824K->4824K(122368K), [Metaspace: 3584K->3584K(1056768K)], 0.0057893 secs] [Times: user=0.01 sys=0.01, real=0.00 secs] 

首先触发的是minorGC,新生代空间:4618K->4096K,这里把survivor区的内存被挪到了老年代。

然后触发了fullGC,新生代空间:4096K->0K,这里把eden区的内存挪到了老年代。

二.年龄判断

我们先来解释下年龄判断:suivivor区的对象,每经历过一次GC,其年龄就会+1。当这个年龄大于等于我们的设置值时,在下一次GC的时候,就会把其挪到老年代中。

为了方便看出效果,我们通过虚拟机参数进行一些配置:

  1. 配置参数-XX:+PrintGCDetails,方面我们查看虚拟机参数;

  2. 配置参数-Xmx128m,把JVM虚拟机内存大小设置为128M;

  3. 配置参数-XX:SurvivorRatio=3,把eden区和suivivor区的大小比设置为3比1;

  4. 配置参数-XX:MaxTenuringThreshold=5,把晋升老年代的年龄设置为5次;

  5. 配置参数 -XX:+UseParallelGC,使用UseParallelGC的回收策略。

通过如上的配置,内存大小如下表所示:

区域

大小(单位KB)

eden

26112

survivor

8704

oldGen

87552

备注:四种垃圾回收策略都支持年龄判断这一项,我们这里仅验证UseSerialGC的类型。

接下来,我们做一个实验,相关代码如下:

public void testAge() {
    System.gc();
    byte[] byte1 = new byte[1];
    byte[] byte2 = new byte[1];
    byte1 = new byte[3 * 1024 * 1024];
    int num = 5;
    int index = 0;
    while (index < num) {
        for (int i = 0; i < (index == 0 ? 22 : 25); i++) {
            byte2 = new byte[1 * 1024 * 1024];//1
            byte2 = null;
        }
        index++;
        sleep(1000);
    }
}

相关代码解释如下:

  1. 首先触发一次full GC,让新生代的相关老对象直接进入老年代,避免影响实验结果。

  2. 变量byte1持有3M内存,一直不释放。为什么是3M,因为3M不到survivor区的一半,不会有动态年龄判断的影响。

  3. 第一次循环,变量byte2累积申请22M内存,使eden充满,触发一次minorGC。

  4. 第二到第五次循环,每次累积申请25M内存,同样也是为了触发minorGC。

因为除了我们申请的变量byte1和byte2之外,系统也在运行时会使用到用一些内存,所以这里累积申请25M的内存,就可以使eden充满,从而触发minorGC。

接下来,我们看一下实验结果

//第一次GC
[Full GC (System.gc()) [Tenured: 0K->415K(87424K), 0.0041167 secs] 2113K->415K(122368K), [Metaspace: 3193K->3193K(1056768K)], 0.0042887 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
//第二次GC
[GC (Allocation Failure) [DefNew: 25625K->3072K(34944K), 0.0013869 secs] 26041K->3487K(122368K), 0.0014169 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 28688K->3072K(34944K), 0.0013359 secs] 29104K->3488K(122368K), 0.0013602 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 28455K->3407K(34944K), 0.0018386 secs] 28870K->3823K(122368K), 0.0020103 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 29506K->3407K(34944K), 0.0021437 secs] 29921K->3822K(122368K), 0.0022199 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 29511K->3407K(34944K), 0.0019472 secs] 29926K->3822K(122368K), 0.0019993 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 34944K, used 16465K [0x00000007b8000000, 0x00000007baaa0000, 0x00000007baaa0000)
  eden space 26240K,  49% used [0x00000007b8000000, 0x00000007b8cc0a38, 0x00000007b99a0000)
  from space 8704K,  39% used [0x00000007ba220000, 0x00000007ba573d10, 0x00000007baaa0000)
  to   space 8704K,   0% used [0x00000007b99a0000, 0x00000007b99a0000, 0x00000007ba220000)
 tenured generation   total 87424K, used 415K [0x00000007baaa0000, 0x00000007c0000000, 0x00000007c0000000)
   the space 87424K,   0% used [0x00000007baaa0000, 0x00000007bab07df8, 0x00000007bab07e00, 0x00000007c0000000)
 Metaspace       used 3715K, capacity 4540K, committed 4864K, reserved 1056768K
  class space    used 412K, capacity 428K, committed 512K, reserved 1048576K

解释1:首先,我们看一下第一次GC后的结果。这一次是fullGC,是我们主动通过System.gc()触发的,这时候我们可以看到,老年代有415K的空间,新生代空间减少为0KB。

解释2:第二次GC,新生代总空间:25625K->3072K(34944K),其中包含byte1持有的3M空间。

第三到第五次GC,结果都类似,说明5次时,变量byte1并没有晋升老年代。

接下来,我们进行一个修改,把上面代码中的num从5改成6,如下:

public void testAge() {
    ...
    int num = 6;
    ...
}

接下来运行,结果如下:

//第一次GC
[Full GC (System.gc()) [Tenured: 0K->380K(87424K), 0.0037785 secs] 2113K->380K(122368K), [Metaspace: 3058K->3058K(1056768K)], 0.0038223 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
//第二次GC
[GC (Allocation Failure) [DefNew: 25625K->3097K(34944K), 0.0033583 secs] 26006K->3478K(122368K), 0.0033883 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 29279K->3433K(34944K), 0.0056187 secs] 29660K->3814K(122368K), 0.0058359 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
[GC (Allocation Failure) [DefNew: 29527K->3433K(34944K), 0.0032739 secs] 29907K->3813K(122368K), 0.0033299 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 29534K->3433K(34944K), 0.0025676 secs] 29914K->3813K(122368K), 0.0025963 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [DefNew: 29539K->3433K(34944K), 0.0018001 secs] 29919K->3813K(122368K), 0.0018320 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
//第六次GC
[GC (Allocation Failure) [DefNew: 29542K->335K(34944K), 0.0037965 secs] 29923K->3813K(122368K), 0.0038633 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 def new generation   total 34944K, used 14683K [0x00000007b8000000, 0x00000007baaa0000, 0x00000007baaa0000)
  eden space 26240K,  54% used [0x00000007b8000000, 0x00000007b8e03090, 0x00000007b99a0000)
  from space 8704K,   3% used [0x00000007b99a0000, 0x00000007b99f3d00, 0x00000007ba220000)
  to   space 8704K,   0% used [0x00000007ba220000, 0x00000007ba220000, 0x00000007baaa0000)
 tenured generation   total 87424K, used 3478K [0x00000007baaa0000, 0x00000007c0000000, 0x00000007c0000000)
   the space 87424K,   3% used [0x00000007baaa0000, 0x00000007bae05a88, 0x00000007bae05c00, 0x00000007c0000000)
 Metaspace       used 3588K, capacity 4540K, committed 4864K, reserved 1056768K
  class space    used 398K, capacity 428K, committed 512K, reserved 1048576K

第一到五次的GC,结果和上面是一样的。

但是第六次GC之后,我们发现DefNew的内存变成了335K,说明byte1所持有的3M空间从新生代被移除了。移到了哪了呢?我们看结束时的GC日志,此时老年代的空间3478K,恰好对应byte1所持有的3M空间。从而说明,经过了6次GC,byte1对应的内存空间晋升到了老年代。

三.大内存对象

介绍下大内存对象,指的是新申请的对象,如果大于我们的设定值时,会直接进入老年代,而不经过eden和survivor区。

我们同样对虚拟机做一些配置,如下

  1. 配置参数-Xmx128m,把JVM虚拟机内存大小设置为128M,

  2. 配置参数-XX:SurvivorRatio=3,把eden区和suivivor区的大小比设置为3比1,

  3. 配置参数 -XX:+UseSerialGC,使用UseSerialGC的回收策略。

  4. 配置参数-XX:PretenureSizeThreshold=5m,把晋升老年代的大对象限制为5m。

通过如上的配置,内存大小如下表所示:

区域

大小(单位KB)

eden

26112

survivor

8704

oldGen

87552

备注:这里只能使用UseParNewGC或者UseSerialGC的回收策略,另外两种策略不支持此种类型。

接下来,我们开始做实验,相关测试代码如下:

public void testBigObject() {
    byte[] byte1 = new byte[1 * 1024 * 1024];
    byte[] byte2 = new byte[6 * 1024 * 1024];
    byte[] byte3 = new byte[1 * 1024 * 1024];
}

代码解释如下:

  1. 变量byte1申请1M空间;

  2. 变量byte2申请6M空间,6M要大于之前设定的5M限定值;

  3. 变量byte3申请1M空间。

接下来,我们看一下实验结果

Heap
 def new generation   total 34944K, used 5736K [0x00000007b8000000, 0x00000007baaa0000, 0x00000007baaa0000)
  eden space 26240K,  21% used [0x00000007b8000000, 0x00000007b859a020, 0x00000007b99a0000)
  from space 8704K,   0% used [0x00000007b99a0000, 0x00000007b99a0000, 0x00000007ba220000)
  to   space 8704K,   0% used [0x00000007ba220000, 0x00000007ba220000, 0x00000007baaa0000)
 tenured generation   total 87424K, used 6144K [0x00000007baaa0000, 0x00000007c0000000, 0x00000007c0000000)
   the space 87424K,   7% used [0x00000007baaa0000, 0x00000007bb0a0010, 0x00000007bb0a0200, 0x00000007c0000000)
 Metaspace       used 3083K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 388K, committed 512K, reserved 1048576K

通过实验我们发现,最终老年代的空间大小为6M,恰好对应我们的设置的变量byte2的大小。

其实实际上,如果我们把byte设置为5M,其也会进入到老年代,所以准确的结果是大约等于设置值的对象,都会直接进入老年代。

接下来,我们去掉-XX:+UseSerialGC的配置,再试一下,这时候我们惊讶的发现,老年代还是0KB,所以这就说明了大对象只对 Serial 和ParNew两种收集器有效。

Heap
 PSYoungGen      total 34816K, used 11862K [0x00000007bd580000, 0x00000007c0000000, 0x00000007c0000000)
  eden space 26112K, 45% used [0x00000007bd580000,0x00000007be115870,0x00000007bef00000)
  from space 8704K, 0% used [0x00000007bf780000,0x00000007bf780000,0x00000007c0000000)
  to   space 8704K, 0% used [0x00000007bef00000,0x00000007bef00000,0x00000007bf780000)
 ParOldGen       total 87552K, used 0K [0x00000007b8000000, 0x00000007bd580000, 0x00000007bd580000)
  object space 87552K, 0% used [0x00000007b8000000,0x00000007b8000000,0x00000007bd580000)
 Metaspace       used 3083K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 388K, committed 512K, reserved 1048576K

最后,我们要说明一下,其实一般是不推荐去设置大对象直接进入老年代的,就像上面的例子中一样。其实byte2属于朝生夕灭的对象,但是却进入了老年代,从而可能导致提前触发fullGC。

当然,如果申请的对象足够大,以至于大于eden区空间时,还是会直接进入老年代的。

四.动态年龄判定

介绍下动态年龄判断,指的是当survivor区内存占比超过设定值时,会把对象按照年龄从大到小,依次的挪到老年代,直到survivor区的内存占比降低到设置值以下。

我们同样对虚拟机做一些配置,如下

  1. 配置参数-Xmx128m,把JVM虚拟机内存大小设置为128M,

  2. 配置参数-XX:SurvivorRatio=3,把eden区和suivivor区的大小比设置为3比1,

  3. 配置参数 -XX:+UseSerialGC,使用UseSerialGC的回收策略。

  4. 配置参数-XX:TargetSurvivorRatio=50,动态年龄判断的比例设置为超过survivor区的50%。

区域

大小(单位KB)

eden

26112

survivor

8704

oldGen

87552

备注:这里只能使用UseParNewGC或者UseSerialGC的回收策略,另外两种策略不支持此种类型。

接下来我们看一下测试代码:

/**
 * 动态年龄判断
 * -XX:SurvivorRatio
 */
public void testDynamicAge() {
    byte[] temp = new byte[8 * _1MB];
    temp = new byte[8 * _1MB];
    byte[] local1 = new byte[3 * _1MB];
    byte[] local2 = new byte[3 * _1MB];
    temp = null;

    //第一次GC
    temp = new byte[8 * _1MB];
    temp = new byte[8 * _1MB];
    temp = null;
    local1 = null;
    //第二次GC
    temp = new byte[11 * _1MB];
}

简单的介绍下上面的代码:

  1. 首先申请2个8M和2个3M的空间,其中2个3M的空间不会释放;

  2. 然后申请8M空间触发第一次minorGC;

  3. 再申请2个8M的空间,然后把这两个8M的空间和local1的引用链断掉;

  4. 最后再申请11M空间从而触发第二次minorGC。

接着我们看一下实验结果

[GC (Allocation Failure) [ParNew: 24641K->6595K(34944K), 0.0058893 secs] 24641K->6595K(122368K), 0.0060441 secs] [Times: user=0.02 sys=0.00, real=0.00 secs] 
[GC (Allocation Failure) [ParNew: 23493K->0K(34944K), 0.0012427 secs] 23493K->3517K(122368K), 0.0012697 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 
Heap
 par new generation   total 34944K, used 11789K [0x00000007b8000000, 0x00000007baaa0000, 0x00000007baaa0000)
  eden space 26240K,  44% used [0x00000007b8000000, 0x00000007b8b834b8, 0x00000007b99a0000)
  from space 8704K,   0% used [0x00000007b99a0000, 0x00000007b99a0000, 0x00000007ba220000)
  to   space 8704K,   0% used [0x00000007ba220000, 0x00000007ba220000, 0x00000007baaa0000)
 tenured generation   total 87424K, used 3517K [0x00000007baaa0000, 0x00000007c0000000, 0x00000007c0000000)
   the space 87424K,   4% used [0x00000007baaa0000, 0x00000007bae0f4a0, 0x00000007bae0f600, 0x00000007c0000000)
 Metaspace       used 3079K, capacity 4500K, committed 4864K, reserved 1056768K
  class space    used 339K, capacity 388K, committed 512K, reserved 1048576K

整个流程可以总结成下图:

  1. 第一次GC前,因为eden区已经被占用了22M区间,而新的变量需要8M空间,eden区的空间已经不够了,所以触发了第一次GC。我们看一下GC的日志:[ParNew: 24641K->6595K(34944K), 0.0058893 secs] 24641K->6595K(122368K), 0.0060441 secs]。内存从24M多下降到6M多,释放了大约17M的空间,其中就包含了我们申请的那两个8M,因为这两块内存并未被持有。而被保留的6M内存中,就包含了local1和local2,并且这6M的区域分布在survivor区,我们可以通过执行到第一次GC完成来验证这个结论。

  2. 第二次GC前,eden区又被填充了两块8M的空间,这时又新申请了11M空间,eden区空间不够了,所以触发第二次GC。

  3. 第二次GC执行时,首先会计算survivor的内存占用比:6583K/8704K=75%。此时已经符合了动态年龄判断的条件。我们同样看一下GC日志:[ParNew: 23493K->0K(34944K), 0.0012427 secs] 23493K->3517K(122368K), 0.0012697 secs]。通过日志我们可以发现,新生代内存空间从23M->0,总的内存空间从23M->3M,说明这次GC后,只有local2这块内存还保留着,其余的已经释放。

      其实GC之后,local2被释放,survivor区内存占比3517/8704=40%,已经不到50%的标准了,但是还是触发了动态年龄判断,所以这个判断标准是看GC之前的占比是否超过了目标值,而不是看GC后的。

  4. 最后,我们来看下最终的内存状态:

     

    par new generation total 34944K, used 11789K [0x00000007b8000000, 0x00000007baaa0000, 0x00000007baaa0000) eden space 26240K, 44% used [0x00000007b8000000, 0x00000007b8b834b8, 0x00000007b99a0000) from space 8704K, 0% used [0x00000007b99a0000, 0x00000007b99a0000, 0x00000007ba220000) to space 8704K, 0% used [0x00000007ba220000, 0x00000007ba220000, 0x00000007baaa0000) tenured generation total 87424K, used 3517K [0x00000007baaa0000, 0x00000007c0000000, 0x00000007c0000000) the space 87424K, 4% used [0x00000007baaa0000, 0x00000007bae0f4a0, 0x00000007bae0f600, 0x00000007c0000000)

eden区占用11M,对应我们新申请的区间。老年代3M,对应local2未被释放的空间。

五.空间分配担保

首先仍然介绍下什么是空间分配担保,当新申请的对象>survivor区所剩空间,并且恰好也充满eden区的时候。如果此时新对象内存<老年代连续可用空间时,触发担保机制,由老年代进行担保。虚拟机此时会触发一次minGC,如果minGC后survivor无法容纳所有的对象,说明担保失败,则survivor容纳不下的对象,会直接进入老年代。

我们先尝试下UseSerialGC的策略,配置如下:

  1. 配置参数-Xmx128m,把JVM虚拟机内存大小设置为128M,

  2. 配置参数-XX:SurvivorRatio=3,把eden区和suivivor区的大小比设置为3比1,

  3. 配置参数 -XX:+UseSerialGC,使用UseSerialGC的回收策略。

  4. 配置参数-XX:TargetSurvivorRatio=50,把晋升老年代的大对象限制为5m。

区域

大小(单位KB)

eden

26112

survivor

8704

oldGen

87552

接下来我们首先做一个担保成功的验证,相关代码如下:

 

//担保成功 public void testGuarantee() { byte[] byte1 = new byte[7 * _1MB]; byte[] byte2 = new byte[7 * _1MB]; byte[] byte3 = new byte[7 * _1MB]; byte1 = null; byte2 = null; byte[] byte4 = new byte[7 * _1MB]; }

实验结果如下:

 

[GC (Allocation Failure) [PSYoungGen: 23607K->7776K(34816K)] 23607K->7784K(122368K), 0.0064972 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] Heap PSYoungGen total 34816K, used 15205K [0x00000007bd580000, 0x00000007c0000000, 0x00000007c0000000) eden space 26112K, 28% used [0x00000007bd580000,0x00000007bdcc1600,0x00000007bef00000) from space 8704K, 89% used [0x00000007bef00000,0x00000007bf698010,0x00000007bf780000) to space 8704K, 0% used [0x00000007bf780000,0x00000007bf780000,0x00000007c0000000) ParOldGen total 87552K, used 8K [0x00000007b8000000, 0x00000007bd580000, 0x00000007bd580000) object space 87552K, 0% used [0x00000007b8000000,0x00000007b8002000,0x00000007bd580000) Metaspace used 3080K, capacity 4500K, committed 4864K, reserved 1056768K class space used 339K, capacity 388K, committed 512K, reserved 1048576K

我们发现只发生了一次minorGC,GC结束后,byte3被放入survivor区,byte1和byte2被释放,内存下降到7MB。然后新申请的byte4就可以放入eden区了。

我们再来看一个担保失败的例子:

 

public void testGuarantee() { //担保失败 byte[] byte1 = new byte[7 * _1MB]; byte[] byte2 = new byte[7 * _1MB]; byte[] byte3 = new byte[7 * _1MB]; byte[] byte4 = new byte[7 * _1MB]; }

实验结果如下:

 

[GC (Allocation Failure) [PSYoungGen: 23607K->7792K(34816K)] 23607K->22136K(122368K), 0.0100913 secs] [Times: user=0.03 sys=0.01, real=0.01 secs] Heap PSYoungGen total 34816K, used 15221K [0x00000007bd580000, 0x00000007c0000000, 0x00000007c0000000) eden space 26112K, 28% used [0x00000007bd580000,0x00000007bdcc1600,0x00000007bef00000) from space 8704K, 89% used [0x00000007bef00000,0x00000007bf69c020,0x00000007bf780000) to space 8704K, 0% used [0x00000007bf780000,0x00000007bf780000,0x00000007c0000000) ParOldGen total 87552K, used 14344K [0x00000007b8000000, 0x00000007bd580000, 0x00000007bd580000) object space 87552K, 16% used [0x00000007b8000000,0x00000007b8e02020,0x00000007bd580000) Metaspace used 3080K, capacity 4500K, committed 4864K, reserved 1056768K class space used 339K, capacity 388K, committed 512K, reserved 1048576K

eden区有3个7M的内存对象。新申请一个7M的空间时,发现eden区内存不够,触发GC。这时候老年代的空间>新生代已使用空间,所以是完全可以担保的。所以触发一次minorGC,minorGC后发现survivor无法容纳所有的对象,担保失败,则把1个7M的对象留在survivor区,放不下的另外2个7M的空间则挪到老年代。然后新申请的对象放入eden区。

上面介绍的是新对象内存<老年代连续可用空间的场景,那么如果小于会怎样呢?

说到这里,我们不禁产生一个疑问,如果老年代的空间<新生代已使用空间 又会怎么样呢?

这时候就要看配置的回收策略了,我们使用仍然使用上面配置的虚拟机参数,其中回收策略使用UseSerialGC来进行实验,相关代码如下:

 

public void testGuarantee() { byte[][] list = new byte[100][]; for (int i = 0; i < 23; i++) { list[i] = new byte[4 * _1MB]; } }

实验结果如下:

 

[GC (Allocation Failure) [DefNew: 22593K->8598K(34944K), 0.0099432 secs] 22593K->20886K(122368K), 0.0101040 secs] [Times: user=0.01 sys=0.01, real=0.01 secs] [GC (Allocation Failure) [DefNew: 33687K->8192K(34944K), 0.0105610 secs] 45975K->45451K(122368K), 0.0106012 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [DefNew: 33251K->8192K(34944K), 0.0080976 secs] 70510K->70027K(122368K), 0.0081292 secs] [Times: user=0.01 sys=0.01, real=0.01 secs] Heap def new generation total 34944K, used 33787K [0x00000007b8000000, 0x00000007baaa0000, 0x00000007baaa0000) eden space 26240K, 97% used [0x00000007b8000000, 0x00000007b98fef98, 0x00000007b99a0000) from space 8704K, 94% used [0x00000007ba220000, 0x00000007baa20020, 0x00000007baaa0000) to space 8704K, 0% used [0x00000007b99a0000, 0x00000007b99a0000, 0x00000007ba220000) tenured generation total 87424K, used 61835K [0x00000007baaa0000, 0x00000007c0000000, 0x00000007c0000000) the space 87424K, 70% used [0x00000007baaa0000, 0x00000007be702c58, 0x00000007be702e00, 0x00000007c0000000) Metaspace used 3080K, capacity 4500K, committed 4864K, reserved 1056768K class space used 339K, capacity 388K, committed 512K, reserved 1048576K

我们可以看到,此时eden区的内存占用比已经达到了97%,survivor区也达到了94%,新生代总空间大小为33M。经历过3次GC,老年代的空间占比也达到了61M,剩余26M,平均每次晋升老年代的大小为20M

这时候我们再申请一个3M的空间

 

public void testGuarantee() { byte[][] list = new byte[100][]; for (int i = 0; i < 23; i++) { list[i] = new byte[4 * _1MB]; } list[24] = new byte[3 * _1MB]; }

结果如下:

 

[GC (Allocation Failure) [DefNew: 22593K->8577K(34944K), 0.0126735 secs] 22593K->20865K(122368K), 0.0127272 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] [GC (Allocation Failure) [DefNew: 33928K->8193K(34944K), 0.0126238 secs] 46216K->45426K(122368K), 0.0127751 secs] [Times: user=0.00 sys=0.00, real=0.02 secs] [GC (Allocation Failure) [DefNew: 33423K->8192K(34944K), 0.0104128 secs] 70657K->70002K(122368K), 0.0104478 secs] [Times: user=0.00 sys=0.01, real=0.01 secs] [GC (Allocation Failure) [DefNew: 33372K->33372K(34944K), 0.0000172 secs][Tenured: 61810K->86386K(87424K), 0.0150326 secs] 95183K->94578K(122368K), [Metaspace: 3069K->3069K(1056768K)], 0.0151686 secs] [Times: user=0.01 sys=0.00, real=0.02 secs] Heap def new generation total 34944K, used 12124K [0x00000007b8000000, 0x00000007baaa0000, 0x00000007baaa0000) eden space 26240K, 46% used [0x00000007b8000000, 0x00000007b8bd7200, 0x00000007b99a0000) from space 8704K, 0% used [0x00000007ba220000, 0x00000007ba220000, 0x00000007baaa0000) to space 8704K, 0% used [0x00000007b99a0000, 0x00000007b99a0000, 0x00000007ba220000) tenured generation total 87424K, used 86386K [0x00000007baaa0000, 0x00000007c0000000, 0x00000007c0000000) the space 87424K, 98% used [0x00000007baaa0000, 0x00000007bfefcbb0, 0x00000007bfefcc00, 0x00000007c0000000) Metaspace used 3081K, capacity 4500K, committed 4864K, reserved 1056768K class space used 339K, capacity 388K, committed 512K, reserved 1048576K

我们发现,按照网上的理论,新对象内存<老年代连续可用空间时,会去判断老年代剩余空间和历次晋升老年代的平均大小,如果老年代剩余空间>历次晋升老年代的平均大小时,只会触发一次minorGC。

但是实际上,我们可以看到,触发了一次fullGC,survivor区被清空,老年代被接近填满,实在无法容纳的对象,被留在了eden区。

所以,我们可以得到这样一个结论,UseSerialGC策略下,新对象内存<老年代连续可用空间时,会直接触发一次fullGc。

接下来,我们把回收策略改成UseParallelGC试一下,相关测试代码如下:

 

public void testGuarantee() { byte[][] list = new byte[100][]; for (int i = 0; i < 17; i++) { list[i] = new byte[4 * _1MB]; } }

结果如下:

 

[GC (Allocation Failure) [PSYoungGen: 22583K->4736K(34816K)] 22583K->21128K(122368K), 0.0086973 secs] [Times: user=0.01 sys=0.01, real=0.01 secs] [GC (Allocation Failure) [PSYoungGen: 29822K->8688K(34816K)] 46214K->45688K(122368K), 0.0050424 secs] [Times: user=0.00 sys=0.01, real=0.00 secs] Heap PSYoungGen total 34816K, used 34268K [0x00000007bd580000, 0x00000007c0000000, 0x00000007c0000000) eden space 26112K, 97% used [0x00000007bd580000,0x00000007bee7b118,0x00000007bef00000) from space 8704K, 99% used [0x00000007bf780000,0x00000007bfffc030,0x00000007c0000000) to space 8704K, 0% used [0x00000007bef00000,0x00000007bef00000,0x00000007bf780000) ParOldGen total 87552K, used 37000K [0x00000007b8000000, 0x00000007bd580000, 0x00000007bd580000) object space 87552K, 42% used [0x00000007b8000000,0x00000007ba422090,0x00000007bd580000) Metaspace used 3080K, capacity 4500K, committed 4864K, reserved 1056768K class space used 339K, capacity 388K, committed 512K, reserved 1048576K

历次GC后内存空间大小如下:

第几次

eden

survivor

老年代

老年代增加

1

0

4*1=4M

4*4=16M

16

2

0

4*2=8M

4*9=36M

20

接下来,我们把上面的17改成18试一下,

 

public void testGuarantee() { byte[][] list = new byte[100][]; for (int i = 0; i < 18; i++) { list[i] = new byte[4 * _1MB]; } }

结果如下:

 

[GC (Allocation Failure) [PSYoungGen: 22583K->4704K(34816K)] 22583K->21096K(122368K), 0.0086213 secs] [Times: user=0.02 sys=0.01, real=0.00 secs] [GC (Allocation Failure) [PSYoungGen: 29790K->8672K(34816K)] 46182K->45608K(122368K), 0.0051320 secs] [Times: user=0.01 sys=0.02, real=0.00 secs] [GC (Allocation Failure) [PSYoungGen: 34251K->8672K(34816K)] 71188K->70184K(122368K), 0.0055399 secs] [Times: user=0.01 sys=0.01, real=0.00 secs] [Full GC (Ergonomics) [PSYoungGen: 8672K->0K(34816K)] [ParOldGen: 61512K->70025K(87552K)] 70184K->70025K(122368K), [Metaspace: 3074K->3074K(1056768K)], 0.0236453 secs] [Times: user=0.04 sys=0.01, real=0.03 secs] Heap PSYoungGen total 34816K, used 4357K [0x00000007bd580000, 0x00000007c0000000, 0x00000007c0000000) eden space 26112K, 16% used [0x00000007bd580000,0x00000007bd9c1600,0x00000007bef00000) from space 8704K, 0% used [0x00000007bef00000,0x00000007bef00000,0x00000007bf780000) to space 8704K, 0% used [0x00000007bf780000,0x00000007bf780000,0x00000007c0000000) ParOldGen total 87552K, used 70025K [0x00000007b8000000, 0x00000007bd580000, 0x00000007bd580000) object space 87552K, 79% used [0x00000007b8000000,0x00000007bc462798,0x00000007bd580000) Metaspace used 3080K, capacity 4500K, committed 4864K, reserved 1056768K class space used 339K, capacity 388K, committed 512K, reserved 1048576K

历次GC后内存空间大小如下:

第几次

eden

survivor

老年代

老年代增加

1

0

4*1=4

4*4=16

16

2

0

4*2=8

4*9=36

20

3

0

4*2=8

4*15=60

24

4

0

0

60+8=68

8

第三次GC前,老年代大小为50M,是明显大于eden和survivor之和34M的,但是在触发了一次minorGC后,仍然触发了fullGC。

这一点,我也没有找到原因,希望知道的读者能够帮忙告知一下。

六.一次OOM的实例分析

最后,我们再来讲一个实例,通过这个实例我们来详细的了解下java的内存回收策略。

首先仍然是进行配置,配置如下:

  1. 配置参数-Xmx128m,把JVM虚拟机内存大小设置为128M,

  2. 配置参数-XX:SurvivorRatio=3,把eden区和suivivor区的大小比设置为3比1,

  3. 配置参数 -XX:+UseParallelGC,使用UseSerialGC的回收策略。

区域

大小(单位KB)

eden

26112

survivor

8704

oldGen

87552

相关配置和第二章中保持一致,使用默认的UseParallelGC回收策略。

所以虚拟机各块内存空间大小如下:

区域

大小(单位KB)

eden

26112

survivor

8704

oldGen

87552

测试相关代码如下:

 

public void testGc() { new Thread(new Runnable() { @Override public void run() { int i = 0; while (i++ < 3) { try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); } } int sum = 120; i = 0; List<byte[]> list = new ArrayList<>(); while (i++ < sum) { byte[] byte1 = new byte[1024 * 1024]; byte[] byte2 = new byte[665 * 1024]; list.add(byte1); System.out.println("times:" + list.size()); try { Thread.sleep(100); } catch (InterruptedException e) { e.printStackTrace(); } } } }).start(); }

对代码进行一下介绍:

首先,循环三次分别sleep500毫秒,这一操作知识为了避免虚拟机刚启动时一些延迟操作的影响。

然后开始循环,每次循环时,分别申请1024KB和665KB空间,其中1024的保留,665的会被释放掉。

由于每次都有1M的空间未被释放掉,所以最终会触发OOM。

然后我们进行实验,结果如下:

 

times:1 ... times:16 /** * 解释1 */ [GC (Allocation Failure) [PSYoungGen: 33016K->5008K(38400K)] 33016K->17304K(125952K), 0.0562495 secs] [Times: user=0.02 sys=0.02, real=0.06 secs] times:17 ... times:35 /** * 解释2 */ [GC (Allocation Failure) [PSYoungGen: 37704K->5024K(38400K)] 50001K->36784K(125952K), 0.0296807 secs] [Times: user=0.02 sys=0.03, real=0.03 secs] times:36 ... times:53 /** * 解释3 */ [GC (Allocation Failure) [PSYoungGen: 37733K->4944K(38400K)] 69494K->56168K(125952K), 0.0198389 secs] [Times: user=0.01 sys=0.02, real=0.02 secs] times:54 ... times:72 /** * 解释4 */ [GC (Allocation Failure) [PSYoungGen: 37660K->5072K(38400K)] 88885K->75761K(125952K), 0.0177455 secs] [Times: user=0.01 sys=0.02, real=0.01 secs] [Full GC (Ergonomics) [PSYoungGen: 5072K->0K(38400K)] [ParOldGen: 70689K->75481K(87552K)] 75761K->75481K(125952K), [Metaspace: 3590K->3590K(1056768K)], 0.0559110 secs] [Times: user=0.24 sys=0.04, real=0.06 secs] times:73 ... times:91 /** * 解释5 */ [Full GC (Ergonomics) [PSYoungGen: 32730K->8192K(38400K)] [ParOldGen: 75481K->86745K(87552K)] 108212K->94938K(125952K), [Metaspace: 3591K->3591K(1056768K)], 0.0182895 secs] [Times: user=0.02 sys=0.01, real=0.02 secs] times:92 /** * 解释6 */ times:106 [Full GC (Ergonomics) [PSYoungGen: 33147K->22528K(38400K)] [ParOldGen: 86745K->86745K(87552K)] 119893K->109274K(125952K), [Metaspace: 3591K->3591K(1056768K)], 0.0091037 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] times:107 ... times:111 /** * eden区内存满了,越来越频繁的GC */ [Full GC (Ergonomics) [PSYoungGen: 32919K->28673K(38400K)] [ParOldGen: 86745K->86745K(87552K)] 119665K->115418K(125952K), [Metaspace: 3592K->3592K(1056768K)], 0.0165881 secs] [Times: user=0.02 sys=0.01, real=0.02 secs] times:112 times:113 times:114 [Full GC (Ergonomics) [PSYoungGen: 33161K->30721K(38400K)] [ParOldGen: 86745K->86745K(87552K)] 119907K->117466K(125952K), [Metaspace: 3597K->3597K(1056768K)], 0.0106328 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] times:115 [Full GC (Ergonomics) [PSYoungGen: 32733K->31745K(38400K)] [ParOldGen: 86745K->86693K(87552K)] 119478K->118438K(125952K), [Metaspace: 3597K->3597K(1056768K)], 0.0134628 secs] [Times: user=0.05 sys=0.01, real=0.01 secs] [Full GC (Ergonomics) [PSYoungGen: 32769K->32769K(38400K)] [ParOldGen: 86693K->86693K(87552K)] 119462K->119462K(125952K), [Metaspace: 3597K->3597K(1056768K)], 0.0041897 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] times:116 /** * 解释7 */ [Full GC (Ergonomics) [PSYoungGen: 32986K->32769K(38400K)] [ParOldGen: 87358K->86693K(87552K)] 120344K->119462K(125952K), [Metaspace: 3597K->3597K(1056768K)], 0.0100986 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] [Full GC (Allocation Failure) [PSYoungGen: 32769K->32769K(38400K)] [ParOldGen: 86693K->86693K(87552K)] 119462K->119462K(125952K), [Metaspace: 3597K->3597K(1056768K)], 0.0074046 secs] [Times: user=0.01 sys=0.00, real=0.01 secs] Heap PSYoungGen total 38400K, used 33247K [0x00000007bd580000, 0x00000007c0000000, 0x00000007c0000000) eden space 33280K, 99% used [0x00000007bd580000,0x00000007bf5f7fd0,0x00000007bf600000) from space 5120K, 0% used [0x00000007bfb00000,0x00000007bfb00000,0x00000007c0000000) to space 5120K, 0% used [0x00000007bf600000,0x00000007bf600000,0x00000007bfb00000) ParOldGen total 87552K, used 86693K [0x00000007b8000000, 0x00000007bd580000, 0x00000007bd580000) object space 87552K, 99% used [0x00000007b8000000,0x00000007bd4a95f0,0x00000007bd580000) Metaspace used 3628K, capacity 4540K, committed 4864K, reserved 1056768K class space used 401K, capacity 428K, committed 512K, reserved 1048576K Exception in thread "Thread-0" java.lang.OutOfMemoryError: Java heap space at com.check.Main$1.run(Main.java:91) at java.lang.Thread.run(Thread.java:748) Process finished with exit code 0

由于流程比较复杂,所以我相关的解释我写到了结果当中了。通过上面的例子我们可以发现下面几个结论:

解释1:

[GC (Allocation Failure) [PSYoungGen: 33016K->5008K(38400K)] 33016K->17304K(125952K), 0.0562495 secs] [Times: user=0.02 sys=0.02, real=0.06 secs]

我们可以发现此时eden空间+survivor空间<老年代剩余空间(33016+5120<87552),则可以进行空间分配担保。担保时触发minorGC,优先进行survivor区复制,然后把eden空间挪到survivor区,放不下的部分,则直接晋升老年代。

新生代内存:33016K->5008K,这5008K空间在survivor区。 老年代内存:0K->12296K。 堆区总内存:12296K+5008K=17304K。

解释2:

[GC (Allocation Failure) [PSYoungGen: 37704K->5024K(38400K)] 50001K->36784K(125952K), 0.0296807 secs] [Times: user=0.02 sys=0.03, real=0.03 secs]

这个流程和解释1一样,进行空间分配担保,担保失败的部分则直接进入老年代。

解释3:

[GC (Allocation Failure) [PSYoungGen: 37733K->4944K(38400K)] 69494K->56168K(125952K), 0.0198389 secs] [Times: user=0.01 sys=0.02, real=0.02 secs]

这个流程和解释1一样,进行空间分配担保,担保失败的部分则直接进入老年代。

survivor区空间:4944K 老生代空间:56168K-4944K=51224K(总空间-新生代空间)

解释4:

[GC (Allocation Failure) [PSYoungGen: 37660K->5072K(38400K)] 88885K->75761K(125952K), 0.0177455 secs] [Times: user=0.01 sys=0.02, real=0.01 secs] [Full GC (Ergonomics) [PSYoungGen: 5072K->0K(38400K)] [ParOldGen: 70689K->75481K(87552K)] 75761K->75481K(125952K), [Metaspace: 3590K->3590K(1056768K)], 0.0559110 secs] [Times: user=0.24 sys=0.04, real=0.06 secs]

这时候eden区内存满了,触发GC。仍然进行空间分配担保,eden+survivor区空间(37660K)>老年代剩余空间(87552K-51224K=36328K),则不能担保,需要full GC。由于survivor区有占用,所以先进行minorGC,内存总大小从88885K->75761K,其中新生代内存:37660K->24537K,包含survivor=5072K,eden区未处理内存:24537K-5072K=19465K。然后进行fullGC,发现没有更多的可释放空间,所以内存空间并没有减小。这时候先把eden区内存放入老年代,老年代空间:51224K+19465K=70689K,然后发现老年代还有空间,则把survivor区也加入老年代,老年代空间:70689K->75481K。

解释5:

[Full GC (Ergonomics) [PSYoungGen: 32730K->8192K(38400K)] [ParOldGen: 75481K->86745K(87552K)] 108212K->94938K(125952K), [Metaspace: 3591K->3591K(1056768K)], 0.0182895 secs] [Times: user=0.02 sys=0.01, real=0.02 secs]

eden区内存满了,触发GC。进行空间分配担保,eden区空间(32730K)>老年代剩余空间(87552K-75481K=36328K),则不能担保。因为此时的survivor是空的,所以不会触发minor GC,而是直接full GC。full GC后,释放了32730K-8192K-(86745K-75481K)=13274K空间。老年空间不足以存放所有的eden区,但是老年代还有空间,所以把eden区内存对象尽可能挪到老年代,是在挪不掉的就留在eden区。 所以此时eden区:8192K,老年代:86745K

解释6:

[Full GC (Ergonomics) [PSYoungGen: 33147K->22528K(38400K)] [ParOldGen: 86745K->86745K(87552K)] 119893K->109274K(125952K), [Metaspace: 3591K->3591K(1056768K)], 0.0091037 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

eden区内存满了,触发GC。这个流程和解释5一样。只不过full GC释放了33147K-22528K=10619K的空间,比上次的要少,并且老年代此时已经满了,不能存放新的对象。所以后面的GC会越来越频繁

解释7:

[Full GC (Ergonomics) [PSYoungGen: 32986K->32769K(38400K)] [ParOldGen: 87358K->86693K(87552K)] 120344K->119462K(125952K), [Metaspace: 3597K->3597K(1056768K)], 0.0100986 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] [Full GC (Allocation Failure) [PSYoungGen: 32769K->32769K(38400K)] [ParOldGen: 86693K->86693K(87552K)] 119462K->119462K(125952K), [Metaspace: 3597K->3597K(1056768K)], 0.0074046 secs] [Times: user=0.01 sys=0.00, real=0.01 secs]

eden区内存满了,触发GC。本次full GC释放了部分空间后,eden区剩余的内存仍然无法容纳新的对象,即使这时两个survivor是空的。最终eden区还可以存放38400K-32769K-5120K=511K的空间,无法容纳新的对象,所以发生了OOM。

七.一些内存相关的问题

问题1:eden区8M,两个survivor区各1M,老年代20M。这时候有特别多的对象不能释放。总大小为21M时,会不会发生OOM?

答:不会。正如第五章中空间分配担保中的例子一样,老年代存放不下时,会触发一次fullGC,fullGC后如果老年代仍然存放不下eden区所有的空间,则会尽量多的把eden区中的相关对象挪到老年代,使老年代充满。但是由于eden区中内存已经挪出去了一部分,所以eden区还能存放新的对象,所以此时并不会OOM。

问题2:eden区8M,两个survivor区各1M,老年代20M。这时候有特别多的对象不能释放。总大小为28.5M时,会不会发生OOM?

答:这时候要区分具体的情况。如果两个survivor区都是空的,则会触发OOM,虽然此时survivor还有2M的空间没有使用,但是因为survivor区为空并且老年代的剩余空间不够担保,所以不会触发minorGC而是直接触发fullGC。fullGC后老年代空间20M,eden区8M,还有0.5M的对象无法容纳,所以则会OOM。

如果survivor区不为空。我们举个例子,eden区8M,survivor区1M,老年代19M,新申请的对象0.5M。发现eden不够时,首先触发一次minorGC,发现不能释放。则触发fullGC,因为老年代还有19M,所以会把eden区1M的空间挪到老年代,eden区变为7M从而还可以容纳这个0.5M的新对象。

  • 3
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
JVMJava Virtual Machine)调优是为了优化Java应用程序的性能和资源利用率。常见的JVM调优方式有以下几种: 1. **内存管理**: - **设置初始堆大小(-Xms)和最大堆大小(-Xmx)**: 根据应用需求合理配置,避免频繁的垃圾回收。 - **使用新生代年代的分代策略**: 如Eden、Survivor和Old Generation之间的大小调整,以及新生代晋升策略。 - **调整堆的分代比例**: 控制年轻代与年代的比例,以平衡GC频率和吞吐量。 2. **垃圾收集器的选择和调优**: - 选择适合的应用场景:如Serial、Parallel、CMS(Concurrent Mark Sweep)、G1(Garbage-First)等。 - 了解不同GC算法的特性,如CMS的低停顿时间和G1的分区垃圾回收。 - 使用参数如`-XX:+UseParallelGC`或`-XX:+UseG1GC`来指定GC器。 3. **线程池优化**: - 设置合理的线程数:过多或过少都可能导致性能下降。 - 调整`-XX:ParallelThreadCount`和`-XX:ThreadStackSize`。 - 使用`Fork/JoinPool`或`CompletableFuture`等并发工具。 4. **CPU缓存优化**: - 避免大对象直接进入堆,尽量使对象小于CPU缓存大小。 - 使用`-XX:+UseCompressedOops`减少对象引用的开销。 5. **JVM选项调整**: - `-XX:+UseStringDeduplication`启用字符串共享。 - `-XX:+UnlockDiagnosticVMOptions`打开诊断日志,用于调试性能问题。 - `-XX:+HeapDumpOnOutOfMemoryError`在发生内存溢出时自动生成堆转储文件。 6. **监控和诊断**: - 使用JMX(Java Management Extensions)或JConsole等工具监控JVM的性能指标。 - 使用VisualVM或JProfiler进行详细的性能分析。

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

打赏作者

失落夏天

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

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

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

打赏作者

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

抵扣说明:

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

余额充值