对Java虚拟机的学习总结

本文深入探讨Java的四种引用类型、内存空间划分、对象访问方式、GC算法及收集器,解析Java对象生命周期与内存回收策略,帮助理解JVM内存模型。

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

Java的四种引用

  • 强引用:例如Object obj = new Object();,强引用就是创建一个对象存放在堆内存,然后用一个引用指向它。如果一个对象有强引用,那垃圾回收器绝不会回收它。
  • 软引用:如果一个对象只具有软引用,则内存空间足够时,垃圾回收器就不会回收它;如果内存空间不足了,就会回收这些对象的内存。
  • 弱引用:与软引用相比,只具有弱引用的对象拥有更短暂的生命周期。每次执行GC的时候,一旦发现了只具有弱引用的对象,不管当前内存空间足够与否,都会回收它的内存。
  • 虚引用:虚引用即形同虚设,虚引用并不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收器回收,主要用来跟踪对象被垃圾回收器回收的活动。

Java的内存管理

Java程序在运行时,需要在内存中的分配空间。为了提高运算效率,就对数据进行了不同空间的划分,因为每一片区域都有特定的处理数据方式和内存管理方式。
在这里插入图片描述

Java内存空间可以划分为5个部分
  • 程序计数器(线程私有):每个线程拥有一个程序计数器,在线程创建时创建,指向下一条指令的地址,执行本地方法时,其值为undefined,作用为线程切换后能够恢复到正确的执行位置。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
  • 虚拟机栈(线程私有):每个方法被调用的时候都会创建一个栈帧,用于存储局部变量表(保存函数内部的变量)、操作数栈(执行引擎计算时需要)、动态链接、方法出口等信息。 每个方法被调用直到执行完成的过程,就对应着一个栈帧在虚拟机中从入栈到出栈的过程。
  • 本地方法栈(线程私有):为虚拟机执使用到的Native方法服务
  • 堆内存(线程共享):存放对象实例,几乎所有的对象实例都在堆内存分配。
  • 方法区(线程共享):也被称作永久代,存储被虚拟机加载的类信息、常量、静态常量、静态方法等(运行时常量池是方法区的一部分)
什么是局部变量表

局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference 类型,它不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和returnAddress 类型(指向了一条字节码指令的地址)。其中64 位长度的long 和double 类型的数据会占用2 个局部变量空间(Slot),其余的数据类型只占用1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

什么是运行时常量池

运行时常量池是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述等信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。

对象访问方式

由于reference 类型在Java 虚拟机规范里面只规定了一个指向对象的引用,并没有定义这个引用应该通过哪种方式去定位,以及访问到Java 堆中的对象的具体位置,因此不同虚拟机实现的对象访问方式会有所不同,主流的访问方式有两种:使用句柄和直接指针。

  1. 句柄访问方式:Java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例数据和类型数据各自的具体地址信息

优势:reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改

在这里插入图片描述
2. 直接指针访问:reference 中直接存储的就是对象地址

优势:速度更快,它节省了一次指针定位的时间开销,由于对象的访问在Java 中非常频繁,因此这类开销积少成多后也是一项非常可观的执行成本
在这里插入图片描述

总结

对主要虚拟机Sun HotSpot 而言,它是使用第二种方式进行对象访问的,但从整个软件开发的范围来看,各种语言和框架使用句柄来访问的情况也十分常见。

GC对内存的回收
  • 程序计数器、虚拟机栈、本地方法栈这3个区域随着线程而生,线程而灭;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈的操作,每个栈帧中分配多少内存基本是在类结构确定下来时就已知的。在这几个区域不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟着回收了。
  • Java堆和方法区则不同,一个接口中的多个实现类需要的内存可能不同,一个方法中的多个分支需要的内存也可能不一样,我们只有在程序处于运行期间时才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,GC关注的也是这部分内存

Java对象在内存中的三种状态

  • 可达的/可触及的:Java对象被创建后,如果被一个或多个变量引用,那就是可达的。即从根节点可以触及到这个对象。其实就是从根节点扫描,只要这个对象在引用链中,那就是可触及的。
  • 可恢复的:Java对象不再被任何变量引用就进入了可恢复状态。在回收该对象之前,该对象的finalize()方法进行资源清理。如果在finalize()方法中重新让变量引用该对象,则该对象再次变为可达状态,否则该对象进入不可达状态
  • 不可达的:Java对象不被任何变量引用,且系统在调用对象的finalize()方法后依然没有使该对象变成可达状态(该对象依然没有被变量引用),那么该对象将变成不可达状态。当Java对象处于不可达状态时,系统才会真正回收该对象所占有的资源。
对象的内存布局

对象在内存中储存的布局可以分为3块区域:对象头、实例数据和对齐填充,其中对象头包括两部分

  • 储存对象自身的运行时数据,如哈希码、GC分带年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳
  • 指类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例

判断对象死亡

  • 引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加1;当引用失效时,计数器值就减1;任何时刻计数器为0的对象就是不可能再被使用的。但它很难解决对象之间相互循环引用的问题。
  • 根搜索算法:这也是当前Java虚拟机采用的方法。设立若干种根对象,当任何一个根对象(GC
    Root)到某一个对象均不可达时,则认为这个对象是可以被回收的。
哪些对象可作为GC Roots
  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中类静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象
可达性分析

从根(GC Roots)的对象作为起始点,开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连(用图论的概念来讲,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。

方法区的GC

永久代的垃圾收集主要回收两部分内容:废弃常量和无用的类

废弃常量
假如一个字符串abc已经进入了常量池中,如果当前系统没有任何一个String对象abc,也就是没有任何Stirng对象引用常量池的abc常量,也没有其他地方引用的这个字面量,这个时候发生内存回收这个常量就会被清理出常量池

无用的类

  1. 该类所有的实例都已经被回收,就是Java堆中不存在该类的任何实例
  2. 加载该类的ClassLoader已经被回收
  3. 该类对用的java.lang.Class对象没有在任何地方被引用,无法再任何地方通过反射访问该类的方法

垃圾回收算法

标记-清除算法

主要用于老年代,先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象,然后清除所有未被标记的对象。但是标记和清除的过程效率不高,而且标记清除后会产生大量不连续的碎片。

复制算法

主要用于新生代,将原有的内存空间分为两块,每次只使用其中一块,在垃圾回收时,将正在使用的内存中的存活对象复制到未使用的内存块中,然后清除正在使用的内存块中的所有对象。优点是不必考虑内存碎片,而且只要移动堆顶指针,按顺序分配内存即可,实现简单,运行效率高,但会造成空间的浪费。

现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%都是“朝生夕死”的,所以并不需要按照1:1的比例来划分内存空间,而是将内存分为一块比较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1,也就是说,每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的空间会被浪费。

当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖于老年代进行分配担保,所以大对象直接进入老年代。

标记-整理算法

主要用于老年代,先通过根节点,标记所有从根节点开始的可达对象。因此,未被标记的对象就是未被引用的垃圾对象,然后将将所有的存活对象压缩到内存的一端;之后,清理边界外所有的空间。它不会产生内存碎片,但在标记的基础之上还需要进行对象的移动,成本相对较高,效率也不高。

分代收集算法

当前商业虚拟机的GC均采用这种算法,据对象的存活周期的不同将Java堆分为新生代和老年代

  • 新生代:存活率低:少量对象存活,适合复制算法。每次GC时都发现有大批对象死去,只有少量存活(新生代中98%的对象都是“朝生夕死”),那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成GC。
  • 老年代:存活率高,大量对象存活,没有额外空间对他进行分配担保,适合用标记-清理/标记-整理。

垃圾收集器

Serial收集器

这个收集器是一个单线程的收集器,但它的单线程的意义并不仅仅说明它只会使用一个CPU或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程(Stop-The-World:将用户正常工作的线程全部暂停掉),直到它收集结束。

当它进行GC工作的时候,虽然会造成Stop-The-World,但它存在有存在的原因:正是因为它的简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,没有线程交互的开销,专心做GC,自然可以获得最高的单线程工作效率。所以Serial收集器对于运行在client模式下是一个很好的选择(它依然是虚拟机运行在client模式下的默认新生代收集器)。

ParNew收集器

这是Serial收集器的多线程版本

它是运行在server模式下的首选新生代收集器,除了Serial收集器外,目前只有它能与CMS收集器配合工作。

ParNew Scanvenge收集器

与ParNew收集器大体上类似,但更加关注吞吐量。

注意,停顿时间和吞吐量不可能同时调优。我们一方面希望停顿时间少,另外一方面希望吞吐量高,其实这是矛盾的。因为:在GC的时候,垃圾回收的工作总量是不变的,如果将停顿时间减少,那频率就会提高;既然频率提高了,说明就会频繁的进行GC,那吞吐量就会减少,性能就会降低。

吞吐量

即为CPU用于用户代码的时间/CPU总消耗时间的比值,即=运行用户代码的时间/(运行用户代码时间+垃圾收集时间)。

Serial Old 收集器

Serial收集器的老年代版本,是一个单线程收集器,使用标记整理算法

Parallel Old 收集器

Parallel Old是Paraller Seavenge收集器的老年代版本,使用多线程和标记整理算法

CMS收集器(Concurrent Mark Sweep 并发标记清除)

这个收集器是一个老年代收集器,是一种以获取最短回收停顿时间为目标的收集器。适合应用在互联网站或者B/S系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短。
在这里插入图片描述
初始标记和重新标记时,需要stop the world。整个过程中耗时最长的是并发标记和并发清除,这两个过程都可以和用户线程一起工作。

优点

并发收集,低停顿

缺点
  • 用户的执行速度降低
  • 无法处理浮动垃圾。因为它采用的是标记-清除算法。有可能有些垃圾在标记之后,需要等到下一次GC才会被回收。如果CMS运行期间无法满足程序需要,那么就会临时启用Serial Old收集器来重新进行老年代的收集
  • 由于采用的是标记-清除算法,那么就会产生大量的碎片。往往会出现老年代还有很大的空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前触发一次full GC
CMS收集器使用标记-清除算法的原因

CMS收集器更加关注停顿,它在做GC的时候是和用户线程一起工作的(并发执行),如果使用标记整理算法的话,那么在清理的时候就会去移动可用对象的内存空间,那么应用程序的线程就很有可能找不到应用对象在哪里。

G1收集器
优点
  • 进行了空间整合,不会产生大量的碎片,也降低了进行gc的频率
  • 可以让使用者明确指定指定停顿时间。(可以指定一个最小时间,超过这个时间,就不会进行回收了)

G1收集器有了这么高效率的原因之一就是:对垃圾回收进行了划分优先级的操作,这种有优先级的区域回收方式保证了它的高效率。

如果你的应用追求停顿,那G1现在已经可以作为一个可尝试的选择;如果你的应用追求吞吐量,那G1并不会为你带来什么特别的好处。

两种GC

  • Minor GC:Minor GC是发生在新生代中的垃圾收集动作,采用的是复制算法。对象在Eden和From区出生后,在经过一次Minor GC后,如果对象还存活,并且能够被to区所容纳,那么在使用复制算法时这些存活对象就会被复制到to区域,然后清理掉Eden区和from区,并将这些对象的年龄设置为1,以后对象在Survivor区每熬过一次Minor GC,就将对象的年龄+1,当对象的年龄达到某个值时(默认是15岁,可以通过参数 --XX:MaxTenuringThreshold设置),这些对象就会成为老年代。但这也是不一定的,对于一些较大的对象(即需要分配一块较大的连续内存空间)则是直接进入老年代
  • Full GC:Full GC是发生在老年代的垃圾收集动作,采用的是标记-清除/整理算法。老年代里的对象几乎都是在Survivor区熬过来的,不会那么容易死掉。因此Full GC发生的次数不会有Minor GC那么频繁,并且做一次Full GC要比做一次Minor GC的时间要长。另外,如果采用的是标记-清除算法的话会产生许多碎片,此后如果需要为较大的对象分配内存空间时,若无法找到足够的连续的内存空间,就会提前触发一次GC

内存分配与回收策略

  1. 对象优先在Eden分配: 大多数情况对象在新生代Eden区分配,当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC
  2. 大对象直接进入老年代:所谓大对象就是指需要大量连续内存空间的Java对象,最典型的大对象就是那种很长的字符串以及数组。这样做的目的是避免Eden区及两个Servivor之间发生大量的内存复制
  3. 长期存活的对象将进入老年代:如果对象在Eden区出生并且经历过一次Minor GC后仍然存活,并且能够被Servivor容纳,将被移动到Servivor空间中,并且把对象年龄设置成为1.对象在Servivor区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就将会被晋级到老年代中
  4. 动态对象年龄判定:为了更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋级到老年代,如果在Servivor空间中相同年龄所有对象的大小总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入到老年代,无须等到MaxTenuringThreshold中要求的年龄
  5. 空间分配担保:在发生Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor DC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许那么会继续检查老年代最大可用的连续空间是否大于晋级到老年代对象的平均大小,如果大于,将尝试进行一次Minor GC,尽管这次MinorGC 是有风险的:如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC

Java堆内存

  • 新生代:比如我们在方法中去new一个对象,那这方法调用完毕后,对象就会被回收,这就是一个典型的新生代对象。
  • 老年代:在新生代中经历了N次垃圾回收后仍然存活的对象就会被放到老年代中。而且大对象直接进入老年代。
  • 永久代:方法区。

类加载机制

虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

类从被加载到虚拟机内存开始,到卸载出内存为止,整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载七个阶段。

其中加载、验证、准备、初始化、和卸载这5个阶段的顺序是确定的。而解析阶段不一定:它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java的运行时绑定。

一 加载

类加载指的是将类的class文件读入内存,并为之创建一个java.lang.Class对象,作为方法区这个类的数据访问的入口。也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象。

  1. 通过类的全名产生对应类的二进制数据流。(根据early load原理,如果没找到对应的类文件,只有在类实际使用时才会抛出错误)
  2. 分析并将这些二进制数据流转换为方法区特定的数据结构
  3. 创建对应类的java.lang.Class对象,作为方法区的入口(有了对应的Class对象,并不意味着这个类已经完成了加载链接)
二 链接

链接指的是将Java类的二进制文件合并到jvm的运行状态之中的过程。在链接之前,这个类必须被成功加载。

类的链接包括验证、准备、解析这三步。

  1. 验证:验证是用来确保Java类的二进制表示在结构上是否完全正确(如文件格式、语法语义等)。
  2. 准备:准备过程则是创建Java类中的静态域(static修饰的内容),并将这些域的值设置为默认值,同时在方法区中分配内存空间。准备过程并不会执行代码。注意这里是做默认初始化,不是做显式初始化。例如:public static int value = 12;上面的代码中,在准备阶段,会给value的值设置为0(默认初始化)。在后面的初始化阶段才会给value的值设置为12(显式初始化)。
  3. 解析:解析的过程就是确保这些被引用的类能被正确的找到(将符号引用替换为直接引用)。解析的过程可能会导致其它的Java类被加载。
三 初始化

真正执行类中定义的Java程序代码(或者说是字节码)。

JVM规范明确规定,有且只有5种情况必须执行对类的初始化
  • 遇到new、getstatic、putstatic、invokestatic,如果类没有初始化,则必须初始化,这几条指令分别是指:创建新对象、读取静态变量、设置静态变量,调用静态函数
  • 使用java.lang.reflect包的方法对类进行反射调用时,如果类没初始化,则需要初始化
  • 当初始化一个类时,如果发现父类没有初始化,则需要先触发父类初始化
  • 当虚拟机启动时,用户需要制定一个执行的主类(包含main函数的类),虚拟机会先初始化这个类
  • 使用JDK1.7的动态语言支持时,如果一个MethodHandle实例最后解析的结果是REF_getStatic、REF_putStatic、Ref_invokeStatic的方法句柄时,并且这个方法句柄所对应的类没有进行初始化,则要先触发其初始化
注意:
  • 通过子类来引用父类的静态字段,不会导致子类初始化

  • 通过数组定义来引用类,不会触发此类的初始化

  • 常量会在编译阶段存入调用者的常量池,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类初始化

类的初始化过程

执行代码Student s = new Student();,会发生什么事情呢

  1. 加载Student.class文件进内存
  2. 在栈内存为s开辟空间
  3. 在堆内存为学生对象开辟空间
  4. 对学生对象的成员变量进行默认初始化
  5. 对学生对象的成员变量进行显示初始化
  6. 通过构造方法对学生对象的成员变量赋值
  7. 学生对象初始化完毕,把对象地址赋值给s变量

双亲委派模型

只存在两种不同的类加载器:启动类加载器(Bootstrap ClassLoader),使用C++实现,是虚拟机自身的一部分。另一种是所有其他的类加载器,使用JAVA实现,独立于JVM,并且全部继承自抽象类java.lang.ClassLoader

下面介绍三种类加载器
  1. 启动类加载器(Bootstrap ClassLoader),负责将存放在<JAVA+HOME>\lib目录中的,或者被-Xbootclasspath参数所制定的路径中的,并且是JVM识别的(仅按照文件名识别,如rt.jar,如果名字不符合,即使放在lib目录中也不会被加载),加载到虚拟机内存中,启动类加载器无法被JAVA程序直接引用。
  2. 扩展类加载器,由sun.misc.Launcher$ExtClassLoader实现,负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  3. 应用程序类加载器(Application ClassLoader),由sun.misc.Launcher$AppClassLoader来实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般称它为系统类加载器。负责加载用户类路径(ClassPath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

双亲委派模型要求除了顶层的启动加载类外,其余的类加载器都应当有自己的父类加载器,这里类加载器之间的父子关系一般不会以继承的关系来实现,而是使用组合关系来复用父类加载器的代码。

工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都是应该传送到顶层的启动类加载器中,只有当父类加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

优势

Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.object的类,并放在程序的ClassPath中,那系统中将会出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱

就是保证某个范围的类一定是被某个类加载器所加载的,这就保证在程序中同 一个类不会被不同的类加载器加载。这样做的一个主要的考量,就是从安全层面上,杜绝通过使用和JRE相同的类名冒充现有JRE的类达到替换的攻击方式

volatile型变量的特殊规则

当一个变量定义为volatile之后,它将具备两种特性

  1. 保证此变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量的值在线程间传递需要通过主内存来完成
  2. 禁止指令重排序,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中执行顺序一致,这个就是所谓的线程内表现为串行的语义

参考:Java虚拟机详解----JVM常见问题总结
JVM理解其实并不难!
深入理解JVM—JVM内存模型
《深入理解java虚拟机》 精华总结(面试)

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值