深入理解java虚拟机2——垃圾回收

3.1 判断对象是否可回收

  • 引用计数算法
  • 可达性分析算法

3.2 四种引用

  • 强引用
  • 软引用
  • 弱引用
  • 虚引用

3.3 finalize()方法

3.4 方法区的垃圾回收

3.5 垃圾回收

  • 三个假说(朝生夕灭,越熬越久,跨代引用少)
  • 安全区域的概念
  • GC分类
  • 垃圾回收算法(标记清除,标记整理,复制)
  • jvm实际回收算法
  • 记忆集和卡表
  • 并发问题下如何标记GC ROOT(增量更新,原始快照)

3.6 垃圾回收器

  • Serial
  • SerialOld
  • ParNew
  • Parallel Scavenge
  • Parallel Old
  • CMS(运行流程,优缺点)
  • G1(概述及运行流程,特点,其他细节,优缺点)

3.1 判断对象是否可回收

  • 引用计数算法
    • 在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一
    • 优点:原理简单,判定效率很高
    • 缺点:两个对象相互引用对方,但都不再被访问,则无法回收
  • 可达性分析算法
    • 这个算法的基本思路就是通过 一系列称为"GC Roots"的根对象作为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过 程所走过的路径称为“引用链”(Reference Chain),如果某个对象到GC Roots间没有任何引用链相连, 或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
    • 可作为GC Roots的对象:
      • 活跃线程栈帧中使用到的参数、局部变量、临时变量
      • 在方法区中静态变量引用的对象(因为静态变量是类级别的)
      • 在方法区中常量引用的对象,譬如字符串常量池(String Table)里的引用
      • 在本地方法栈中JNI(即通常所说的Native方法)引用的对象
      • Java虚拟机内部的引用,如基本数据类型对应的Class对象,一些常驻的异常对象(比如 NullPointExcepiton, OutOfMemoryError)等,还有系统类加载器
      • 所有被同步锁(synchronized关键字)持有的对象
      • 系统类:由Bootstrap类加载器加载的类

3.2 强软弱虚引用

强引用类似“Object obj=new Object()”这种引用关系。永远不会被回收
软引用SoftReference<Object> softReference = new SoftReference<Object>(obj);在垃圾回收后,内存仍不足时会再次触发第二次垃圾回收,回收软引用对象
弱引用weakReference<Object> weakReference = new weakReference<Object>(obj);

被弱引用关联的对象只能生存到下一次垃圾收集发生为止。

当垃圾收集器开始工作,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。

不过,由于垃圾回收器是一个优先级很低的线程,因此不一定很快发现那些只具有弱引用的对象。

虚引用

如果一个对象仅持有虚引用,那么它就和没有任何引用一样。

在任何时候都可能被垃圾回收器回收。

虚引用必须配合引用队列使用,主要配合 ByteBuffer 使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存

强引用:

//只要 obj 指向 Object 对象,那它就永远都不会被 JVM 回收
Object obj = new Object();
//将 obj 置为 null,可以切断引用链,这样 obj 就会被 JVM 回收
obj = null;

软引用

String str = new String("abc");
SoftReference<String> softReference = new SoftReference<String>(str);

但是,上述例子中gc仅仅回收了str对象,softReference对象仍然存在,为了方便清除软引用对象本身,需关联引用队列。

如果软引用所引用对象被垃圾回收JAVA虚拟机就会把这个软引用加入到与之关联的引用队列中。

//对象
String str = new String("abc");

//构建引用队列
ReferenceQueue<String> queue = new ReferenceQueue<>();

//创建SoftReference对象时关联queue和该对象
SoftReference<String> softreference = new SoftReference<>(str, queue);

虚引用:和软引用用法相同


3.3 finalize()方法

  • finalize()方法是Java中Object类的一个方法,它被设计为在对象被垃圾回收器回收之前给予一个最后的机会来执行清理工作,比如释放资源或者关闭文件
  • 即使在可达性分析算法中判定为不可达的对象,也不是“非死不可”的,这时候它们暂时还处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:
    • 第一次标记判断是否该回收:没有GC Root的引用链——>应该要被回收
    • 第二次标记判断是否要执行finalize()方法:回收:没有重写过finalize()方法或者finalize()方法已经被执行过;否则执行
    • 低优先级的Finalizer线程会去执行所有需要运行的finalize()方法

注:它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,自Java 9开始,已被官方明确声明为不推荐使用(deprecated)的语法。


3.4 方法区的垃圾回收

方法区也会进行垃圾回收,但不像Java堆中的回收那么频繁。

方法区垃圾回收的触发时机:

  • 类的卸载:当一个类在方法区中不再有任何引用,即它的类加载器已经被回收,所有的实例都已经被垃圾回收器回收,且没有任何地方通过反射等机制引用该类的方法和属性时,JVM就可能触发对这个类的定义进行回收
  • 常量池清理:方法区中的常量池可能包含了很多不再使用的常量信息,当发现这些常量不再被引用时,JVM可能会对这部分内存进行回收
  • 系统资源不足:当操作系统的内存非常紧张时,JVM可能会尝试进行更多的垃圾回收,包括方法区的垃圾回收,以释放一些内存资源
  • JVM参数:某些JVM实现可能提供了特定的参数来控制方法区的垃圾回收,例如,可以设置一个阈值来触发GC,或者是通过JVM的参数来显式触发方法区的GC

方法区垃圾回收与堆垃圾回收的关系

  • 两者没有直接关系,但当进行堆垃圾回收时,可能会触发方法区的垃圾回收
  • 这是因为类的卸载通常与堆内存中对象的生命周期相关联。当堆内存中的对象被回收时,相应的类加载器和类定义也可能变得无用,进而触发方法区的垃圾回收。

方法区回收的对象:无用的类。如何判定是无用的类,需要同时满足以下三个条件

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

即使满足上述条件,方法区的垃圾回收效果也通常比较有限,因为类和元数据的生命周期通常很长,且方法区的垃圾回收并不一定能够回收大量空间

如何避免Metaspace区域因内存不足而触发OOM、扩容、垃圾回收等问题

  • JVM参数:-XX:MetaspaceSize和-XX:MaxMetaspaceSize,控制Metaspace的初始空间和最大空间限制

3.5 垃圾回收

(1)垃圾回收理论

  • 绝大多数对象是朝生夕灭的(IBM 公司的专业研究表明,有将近98%的对象是朝生夕死)
  • 熬过越多次垃圾收集过程的对象就越难以消亡:因此划分 新生代(伊甸园+生存区From+生存区To) + 老年代
  • 跨代引用相对同代引用来说占极少数

(2)安全区域的概念

  • 安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化。在这个区域中任 意地方开始垃圾收集都是安全的。

(3)GC分类

  • 部分收集(Partial GC):指不是收集整个堆
    • 新生代收集(Minor GC / Young GC):只收集新生代
    • 老年代收集(Major GC / Old GC):只收集老年代。目前只有CMS会单独收集老年代
    • 混合收集(Mixed GC):新生代+老年代。目前只有G1收集器
  • 整堆收集(Full GC):针对整个堆和方法区的垃圾收集

注:在不同资料中,对Major GC的定义和理解不一样,有时指Full GC,有时指Old GC

(4)垃圾回收算法

算法描述优点缺点
标记清除(Mark-Sweep)首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。1. 速度快

1.容易产生内存碎片:大对象无法分配而导致额外垃圾回收
2. 执行效率不稳定:如果有大量对象且绝大多数需要回收,则需要大量的标记和清除

标记整理(Mark-Compact)多数用于老年代,会引发STW

1. 无碎片

1. 效率低
标记复制多数用于新生代

1.无碎片,只需移动堆顶指针按顺序分配
2.由于大量的对象是朝生夕灭的,复制的对象不需要很多

1. 内存减半,空间浪费。

(5)jvm实际回收算法:分代收集

实际使用中,java采用分代收集,即由于不同对象生命周期不一样,可以将不同生命周期的对象分代,不同的代采取不同算法

  • 分代收集
    • 融合了上述三种算法思想,堆内存被划分为新生代和老年代。新生代进一步划分为Eden区和两个Survivor区(通常称为From和To,或者S0和S1)
    • 新生代:采用 复制 算法,因为对象大多数朝生夕灭,大多对象被清除,只需要付出少量的复制成本
    • 老年代:对象存活率高,采用 标记清除 或者 标记整理
  • Java堆的结构比例
    • 在HotSpot JVM中,默认的新生代与老年代的比例约为1:2。新生代中,Eden区与Survivor区的默认比例通常是8:1:1,即Eden区占新生代的80%,每个Survivor区占10%。这意味着如果新生代总大小为10个单位,那么Eden区为8个单位,每个Survivor区为1个单位
  • 分代收集算法
    • 1. 新对象的分配
      • 普通对象:对象首先被分配在伊甸园区
      • 大对象:如果该对象占用内存非常大,则直接分配到老年代区。原因是避免朝生夕灭的“短命大对象”,高额内存复制开销
    • 2. 触发Minor GC的条件:Eden空间不够时
      • 当伊甸区没有足够的空间分配时,虚拟机会发起一次Minor GC,Minor GC 相比 Major GC 更频繁,回收速度也更快
      • minor gc时,Eden和From区存活的对象使用复制算法复制到To中,存活对象年龄+1,交换From和To
      • minor gc时会引发stop the world(stw),暂停其他用户线程,等垃圾回收结束,用户线程才恢复运行
    • 3. 触发Full GC的场景
      • ①Minor GC晋升失败:如果在Minor GC过程中,存活对象需要晋升到老年代,但老年代空间不足以容纳这些对象,会直接触发Full GC
      • ②老年代空间不足:当老年代中没有足够的空间分配给新对象时,JVM会尝试进行Full GC来清理空间
      • ③永久代或元空间不足:如果元空间到达阈值,会触发Full GC
      • ④JVM内部调用:JVM内部可能会基于某些启发式算法(如老年代的占用比例、对象分配率等)决定执行Full GC
      • ⑤System.gc()显示调用
      • 每次Full GC 都会触发“Stop-The-World”。 内存越大,STW 的时间也越长
      • Reference:Major GC和Full GC的区别是什么?触发条件呢? - 知乎
    • 4. 几种情况对象直接进老年代
      • 大对象,见第1点
      • 长期存活对象,当对象寿命超过阈值时,会晋升老年代,最大寿命是15。(通过-XX: MaxTenuringThreshold可设置)
    • 5. 动态对象年龄判断
      • 如果在Survivor空间中相同年龄所有对象大小的总和大于 Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

(6)记忆集和卡表

为了减少GC Root的扫描范围(如在老年代垃圾回收时,存在跨代指针,需要再扫描年轻代的GC ROOT),引入了记忆集。

  • 记忆集:是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,是一种来解决对象跨代引用所带来的问题的概念(非实现方法)
  • 卡表:记忆集的一种实现方式(HashMap和Map的关系),是一个字节数组
    • CARD_TABLE [this address >> 9] = 0;
    • 数组中的每个元素分别对应了一块地址范围(卡页card page)。HotSpot卡页大小是512字节,那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了 地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。
    • 如果某个元素对应的卡页(范围地址中存在跨代指针),那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
  • 维护卡表的方式:写屏障。每次对“引用类型字段赋值”,都会像AOP切面一样加入写前和写后屏障更新卡表。

(7)并发问题下如何标记GC ROOT

用户线程在标记进行时并发修改了引用关系,会影响之前的扫描结果

  • 增量更新(Incremental Update):CMS
  • 原始快照(Snapshot At The Beginning, SATB):G1

3.6 垃圾回收器

注:由于维护和兼容性测试的成本,在JDK 8时将Serial+CMS、 ParNew+Serial Old这两个组合声明为废弃(JEP 173),并在JDK 9中完全取消了这些组合的支持

3.5.1 Serial收集器:新生代,复制算法,简单高效,适合单CPU

  • 最古老的,最稳定,效率高的收集器
  • 收集垃圾的过程中需要暂停其他的工作线程,可能会产生较长的停顿
  • 对于单CPU环境来说,没有线程交互的开销(内存消耗小),简单高效。,
  • Java虚拟机运行在Client模式下默认的新生代垃圾回收器

上图展示了Serial+Serial Old收集器的运行过程

3.5.2 Serial Old:老年代,标记整理算法,简单高效

  • 开启串行收集器的JVM参数是-XX:+UseSerialGC
  • 开启后会使用:Serial(Young区)+ Serial Old(Old区)的收集器组合。
  • 在Server模式下:作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用

3.5.3 ParNew:新生代,复制算法,Serial的多线程版

  • 许多运行在Server模式下的虚拟机中首选的新生代收集器。原因是,除了Serial收集器外,目前只有它能和CMS收集器(Concurrent Mark Sweep)配合工作。
  • ParNew 收集器在单CPU的环境不如Serial收集器,甚至由于存在线程交互的开销,两个CPU的环境中都不能百分之百地保证可以超越。
  • 多CPU环境下有优势,随着CPU的数量增加,它对于GC时系统资源的有效利用是很有好处的。它默认开启的收集线程数与CPU的数量相同,可使用-XX:ParallerGCThreads参数设置。

3.5.4 Parallel Scavenge收集器:新生代,多线程,复制算法,吞吐量优先

  • 吞吐量:虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%
  • CMS等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间(用户体验),而Parallel Scavenge收集器的目标是达到一个可控制的吞吐量,尽快完成程序的运算任务
  • 主要适合在后台运算而不需要太多交互的任务
  • 与ParNew的区别:还提供了一个参数-XX:+UseAdaptiveSizePolicy,开启后虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种方式称为GC自适应的调节策略(GC Ergonomics)

3.5.5 Parallel Old:老年代,标记整理,多线程,吞吐量优先

  • Parallel Old收集器是Parallel Scavenge收集器的老年代版本
  • 注重吞吐量以及CPU资源敏感的场合适用

3.5.6 CMS:老年代Old GC,标记清除,最短回收停顿时间,注重用户体验

CMS(Concurrent Mark Sweep)收集器工作的整个流程分为以下4个步骤:

  • 初始标记(CMS initial mark):仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,需要“Stop The World”。
  • 并发标记(CMS concurrent mark):进行GC Roots Tracing的过程,在整个过程中耗时最长。且由于1/4线程用来标记,会影响吞吐量。
  • 重新标记(CMS remark):为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短。此阶段也需要“Stop The World”。
  • 并发清除(CMS concurrent sweep):清理删除掉标记阶段判断的已经死亡的对象。会产生浮动垃圾

建议:阈值设置70%到80%,如果不够,CMS运行期间预留的内存无法满足程序分配新对象的需要,会冻结用户线程退化成SerialOld

优点

  • STW时间少,用户体验好

缺点:

  • CPU资源敏感:CMS默认启动的回收线程数是(CPU数量+3)/4,也就是当CPU在4个以上时,并发回收时垃圾收集线程不少于25%的CPU资源,并且随着CPU数量的增加而下降。但是当CPU不足4个时(比如2个),CMS对用户程序的影响就可能变得很大,如果本来CPU负载就比较大,还要分出一半的运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然降低了50%,其实也让人无法接受。
  • 无法处理浮动垃圾:由于CMS并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生。这一部分垃圾出现在标记过程之后,CMS无法再当次收集中处理掉它们,只好留待下一次GC时再清理掉。这一部分垃圾就被称为“浮动垃圾”。因此CMS收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供并发收集时的程序运作使用。
  • 标记-清除算法导致的空间碎片:空间碎片过多时,将会给大对象分配带来很大麻烦,往往出现老年代空间剩余,但无法找到足够大连续空间来分配当前对象。提前Full GC。

3.5.7 G1收集器:整体基于标记整理;局部基于复制算法;运行期间不会产生碎片;可预测的停顿

(1)概述

  • 上述的 GC 收集器将连续的内存空间划分为新生代、老生代和永久代,这种划分的特点是各代的存储地址(逻辑地址)是连续的。G1 (Garbage First) 的各代存储地址是不连续的,每一代都使用了 n 个不连续的大小相同的 region, 每个 region 占有一块连续的虚拟内存地址。每一个Region都可以 根据需要,扮演新生代的Eden空间、Survivor空间,或者老年代空间。Region中还有一类特殊的Humongous区域,专门用来存储大对象。(G1认为只要大小超过了一个 Region容量一半的对象即可判定为大对象。)

(2)特点

  • 主要面向服务端应用:JDK9后,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服务端模式下的默认垃圾收集器,而CMS未来则会被废弃
  • 分代收集:哪块内存中存放的垃圾数量最多,回收收益最大 来衡量:在后台维护一 个优先级列表,每次根据用户设定允许的收集停顿时间,优先处理回收价值收益最大的那些Region。这也就是“Garbage First”名字的由来。
  • 空间整合(整体基于标记整理;局部基于复制算法
  • 可预测的停顿(能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒)

(3)几个细节

  • 跨Region引用问题:如何避免全堆扫描
    • 多个 Region 之前的对象可能会有引用关系,在做可达性分析时需要扫描整个堆才能保证准确性,这显然降低了 GC 效率。为避免全堆扫描,虚拟机为 G1 中每个 Region 维护了一个与之对应的 Remembered Set。虚拟机发现程序在对Reference类型的数据进行写操作时,会产生一个Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是,便通过 CardTable 把相关引用信息记录到被引用对象所属的Region的 Remembered Set 之中。当进行内存回收时,在GC根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。
    • G1的记忆集在存储结构的本质上是一 种哈希表,是双向的卡表结构(卡表是“我指向谁”,这种结构还记录了“谁指向我”)
  • 多线程下并发标记阶段如何保证收集线程和用户线程互不干扰
    • 原始快照(SATB)算法:G1为每一个Region设计了两个名为TAMS(Top at Mark Start)的指针,把Region中的一部分空间划分出来用于并发回收过程中的新对象分配,并发回收时新分配的对象地址都必须要在这两个指针位置以上。G1收集器默认在这个地址以上的对象是被隐式标记过的,即默认它们是存活的,不纳入回收范围。
  • 怎样建立起可靠的停顿预测模型
    • 衰减均值(Decaying Average)为理论基础:在垃圾收集过程中,G1收集器会记 录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得 出平均值、标准偏差、置信度等统计信息。Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由 哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。

(4)G1的运作步骤

  • 初始标记(Initial Marking) :STW耗时短
    • 仅仅只是标记一下GC Roots 能直接关联到的对象,并且修改TAMS(Nest Top Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可以的Region中创建对象,此阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking) :并发耗时长
    • 从GC Root 开始对堆中对象进行可达性分析递归扫描整个堆 里的对象图,找出要回收的对象找到存活对象,此阶段耗时较长,但可与用户程序并发执行。当对象图扫描完成以 后,还要重新处理原始快照(SATB)记录下的在并发时有引用变动的对象。
  • 最终标记(Final Marking):STW耗时短
    • 对用户线程做另一个短暂的暂停,用于处理并发阶段结束后仍遗留下来的最后那少量的SATB记录。
  • 筛选回收(Live Data Counting and Evacuation):STW耗时短
    •  首先对各个Region中的回收价值和成本进行排序,根据用户所期望的GC 停顿是时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。

(5)G1小结

  • 理念变化:从G1开始,最先进的垃圾收集器的设计导向都不约而同地变为追求能够应付应用的内存分配速率 (Allocation Rate),而不追求一次把整个Java堆全部清理干净。
  • 对比CMS
    • 取代:在未来,G1收集器最终还是要取代CMS 的
    • 优点:可以指定最大停顿时间;按收益动态回收;不会有空间碎片
    • 缺点:如在用户程序运行过程中,G1无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载 (Overload)都要比CMS要高。
  • 内存占用:虽然G1和CMS都使用卡表来处理跨代指针,但G1的卡表实现更为复杂,而且堆中每个Region,无论扮演的是新生代还是老年代角色,都必须有一份卡表,这导致G1的记忆集(和 其他内存消耗)可能会占整个堆容量的20%乃至更多的内存空间;相比起来CMS的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝 生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的

3.7 垃圾回收器总结

常见组合模式:

  • Serial + Serial Old(几乎不用)
  • Parallel Scavenge + Parallel Old(吞吐量优先)
  • ParNew + CMS(互联网业界常用)
    • 为何互联网常用
      • 低延迟:暂停时间少,体验无感知
      • 特别适用于多核服务器环境
      • 可配置性强:CMS有丰富的参数用于调优,可根据需求进行调整
  • G1(未来可期,常用)
收集器串行/并行/并发新生代/老年代算法目标适用场景
Serial串行新生代复制响应速度优先单CPU下的Client
Serial Old串行老年代标记整理响应速度优先

单CPU下的Client

CMS的退化

ParNew并行新生代复制响应速度优先

多CPU下的Server

配合CMS

Parallel Scavenge并行新生代复制吞吐量优先在后台运算而不需要太多交互的任务
Parallel Old并行老年代标记整理吞吐量优先在后台运算而不需要太多交互的任务
CMS并发老年代标记清除响应速度优先集中在互联网站或B/S系统服务端上的JAVA应用
G1并发都有局部复制,整体标记整理响应速度优先面向服务端应用,将来替换CMS

Reference:

https://crowhawk.github.io/2017/08/15/jvm_3/

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值