只要一个对象被其他变量所引用,就让该对象的计数+1,如果引用了两次,计数就+2,如果某一个变量不再引用他了,计数-1,当该对象引用计数为0时,表示该对象未被引用,就可以当作垃圾回收。
存在弊端:
A对象引用B对象,B对象的引用计数为1,B对象反过来也引用A对象,A对象的引用计数也为1,造成循环依赖,两者一直相互引用,内存无法得到释放,从而导致内存泄漏。
Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象。
哪些对象可以作为 GC Root ?
上图四个分类为:
系统类:由启动类加载器加载的类,核心的类,在运行期间肯定会用到的类(Object,HashMap…)
本地方法栈:Java虚拟机在执行方法时,必须调用操作系统方法,操作系统方法所引用的Java方法
活动线程:运行线程的栈帧中所引用的对象
正在加锁的对象:synchronized关键字对一个对象加了锁,被加锁的对象不能被回收
在Java计数体系里,可以作为GC Roots对象的可以分为以下几种:
强引用(FinalReference)>软引用(SoftReference)>弱引用(WeakReference)>虚引用(PhantomReference)
Object obj = new Object();
无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。
-Xmx20m -XX:+PrintGCDetails -verbose:gc 设置堆内存最大值为20m,打印GC详细信息
强引用时,会造成堆内存溢出
public static void main(String[] args) throws IOException {List list = new ArrayList<>();for (int i = 0; i < 5; i++) {list.add(new byte[_4MB]);}System.in.read();}
list先引用软引用对象,再间接引用byte[]
public static void main(String[] args) throws IOException {// list --> SoftReference --> byte[]List> list = new ArrayList<>();for (int i = 0; i < 5; i++) {SoftReference ref = new SoftReference<>(new byte[_4MB]); System.out.println(ref.get());list.add(ref);System.out.println(list.size());} System.out.println("循环结束:" + list.size());for (SoftReference ref : list) {System.out.println(ref.get());}}
由GC信息可知,在第5次添加的时候,内存已经不够,在一次完全的垃圾回收后,内存空间任然不够,又触发了一次新的内存回收,将软引用的内存回收
配合引用队列,将软引用对象清理掉
public class Demo2_4 {private static final int _4MB = 4 * 1024 * 1024;public static void main(String[] args) {List> list = new ArrayList<>();// 配合引用队列,将软引用清理ReferenceQueue queue = new ReferenceQueue<>();for (int i = 0; i < 5; i++) {// 关联了引用队列, 当软引用所关联的 byte[]被回收时,软引用自己会加入到 queue 中去SoftReference ref = new SoftReference<>(new byte[_4MB], queue);System.out.println(ref.get());list.add(ref);System.out.println(list.size());}// 从队列中获取无用的 软引用对象,并移除Reference extends byte[]> poll = queue.poll();while( poll != null) {list.remove(poll);poll = queue.poll();}System.out.println("===========================");for (SoftReference reference : list) {System.out.println(reference.get());}}
}
弱引用示例:
-Xmx20m -XX:+PrintGCDetails -verbose:gc
public class Demo2_5 {private static final int _4MB = 4 * 1024 * 1024;public static void main(String[] args) {// list --> WeakReference --> byte[]List> list = new ArrayList<>();for (int i = 0; i < 10; i++) {WeakReference ref = new WeakReference<>(new byte[_4MB]);list.add(ref);for (WeakReference w : list) {System.out.print(w.get()+" ");}System.out.println();}System.out.println("循环结束:" + list.size());}
}
在第5次添加时,内存不够了,回收掉第4个,才能添加到第5个,…第10次是因为弱引用本身也占内存,放不下时,进行了Fll GC,将弱引用全部清空。
标记-清除算法分为“标记”和“清除”两个阶段,首先通过可达性分析,标记出所有需要回收的对象,然后统一回收所有被标记的对象。
标记-整理算法的“标记”过程与“标记-清除算法”的标记过程一致,但标记之后不会直接清理。而是将所有存活对象都移动到内存的一端。移动结束后直接清理掉剩余部分。
将内存分成两块,每次申请内存时都使用其中的一块,当内存不够时,将这一块内存中所有存活的复制到另一块上。然后将然后再把已使用的内存整个清理掉。
长时间使用的对象放在老年代中,用完可以丢弃的对象放在新生代中。老年代的垃圾回收很久发生一次,新生代的垃圾回收发生的比较频繁。
含义 | 参数 |
---|---|
堆初始大小 | -Xms |
堆最大大小 | -Xmx或 -XX:MaxHeapSize=size |
新生代大小 | -Xmn或 -XX:NewSize=size + -XX:MaxNewSize=size(初始最大同时指定) |
幸存区比例(动态) | -XX:InitialSurvivorRatio=ratio和-XX:+UserAdaptiveSizePolicy(开启) |
幸存区比例 | -XX:SurvivorRatio=ratio(默认8,如果新生代为10,伊甸园为8,to和from各自2) |
晋升阈值 | -XX:MaxTenuringThreshold=threshold() |
晋升详情 | -XX:+PrintTenuringDistribution打印晋升详情 |
GC详情 | -XX:+PrintGCDetails -verbose:gc打印GC详情 |
FullGC前MinorGC | -XX:+ScavengeBeforeFullGC 默认打开 |
GC案例分析
设置参数:
-Xms20M -Xmx20M -Xmn10M -XX:+UseSerialGC -XX:+PrintGCDetails -verbose:gc
public class Demo2_1 {private static final int _512KB = 512 * 1024;private static final int _1MB = 1024 * 1024;private static final int _6MB = 6 * 1024 * 1024;private static final int _7MB = 7 * 1024 * 1024;private static final int _8MB = 8 * 1024 * 1024;public static void main(String[] args) throws InterruptedException {}
}
public class Demo2_1 {private static final int _512KB = 512 * 1024;private static final int _1MB = 1024 * 1024;private static final int _6MB = 6 * 1024 * 1024;private static final int _7MB = 7 * 1024 * 1024;private static final int _8MB = 8 * 1024 * 1024;public static void main(String[] args) throws InterruptedException {ArrayList list = new ArrayList<>();list.add(new byte[_7MB]);list.add(new byte[_512KB]);}
}
public class Demo2_1 {private static final int _512KB = 512 * 1024;private static final int _1MB = 1024 * 1024;private static final int _6MB = 6 * 1024 * 1024;private static final int _7MB = 7 * 1024 * 1024;private static final int _8MB = 8 * 1024 * 1024;public static void main(String[] args) throws InterruptedException {ArrayList list = new ArrayList<>();list.add(new byte[_8MB]);}
}
public class Demo2_1 {private static final int _512KB = 512 * 1024;private static final int _1MB = 1024 * 1024;private static final int _6MB = 6 * 1024 * 1024;private static final int _7MB = 7 * 1024 * 1024;private static final int _8MB = 8 * 1024 * 1024;public static void main(String[] args) throws InterruptedException {ArrayList list = new ArrayList<>();list.add(new byte[_8MB]);list.add(new byte[_8MB]);}
}
public class Demo2_1 {private static final int _512KB = 512 * 1024;private static final int _1MB = 1024 * 1024;private static final int _6MB = 6 * 1024 * 1024;private static final int _7MB = 7 * 1024 * 1024;private static final int _8MB = 8 * 1024 * 1024;public static void main(String[] args) throws InterruptedException {new Thread(() -> {ArrayList list = new ArrayList<>();list.add(new byte[_8MB]);list.add(new byte[_8MB]);}).start();System.out.println("sleep....");Thread.sleep(1000L);}
}
一个线程内的OutOfMemory,不会导致Java进程结束
单线程的收集器,说明它只会使用一个CPU或一条收集线程区完成垃圾收集工作,并且在它进行垃圾收集时,必须暂停其他所有所有的工作线程,直到它收集结束。
优势:简单而高效(与其他收集器的单线程比),对于限定单个CPU的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集,自然可以获得最高的单线程收集效率。
场景:适合堆内存较小,个人电脑
-XX:+UserSerialGC =Serial +SerialOld 指定年轻代和老年代都使用串行收集器
等价于新生代用Serial GC(复制算法),老年代用Serial Old GC(标记+整理算法)
用户工作的线程,在安全点停下来
多线程的收集器,主要让单位时间内,STW(垃圾收集器最大停顿时间) 的时间最短(0.2+0.2 = 0.4),可以高效地利用CPU时间,尽快完成程序的运算任务(垃圾回收时间占比最低,这样就称吞吐量高)。
场景:堆内存较大,多核 cpu来支持(单核,也是多个线程轮流争抢单核CPU的时间片,效率更低),适合在后台运算而不需要太多交互的任务。parallel并行,指多个垃圾回收器可以并行的运行,占用不同的cpu。但是在此期间,用户线程是被暂停的,只有垃圾回收线程在运行。
-XX:+UseParallelGC 手动指定年轻代使用Parallel并行收集器执行内存回收任务(复制算法)
-XX:+UseParallelOldGC 手动指定老年代使用并行回收收集器(标记+整理算法)
jdk8默认是开启的.上面两个参数,默认开启一个,另一个也会被开启(互相激活)
-XX:+UseAdaptiveSizePolicy:自适应调整新生代大小(新生代占比和晋升阈值大小)
-XX:ParallelGCThreads:设置年轻代并行收集器的线程数,
最好与CPU数量相等,以避免过多的线程数影响垃圾收集性能,在默认情况下,CPU数量小于8, ParallelGCThreads的值等于CPU数量,当CPU数量大于8,ParallelGCThreads的值等于3+(5*CPU_COUNT/8)
-XX:GCTimeRatio:垃圾收集时间占总时间的比例(=1/(N+1)),用于衡量吞吐量的大小
取值范围(0,100),默认99,也就是垃圾回收时间不超过1%,很难达到,一般设置为19,即100分钟只允许5分钟垃圾回收
与-XX:MaxGCPauseMillis参数有一定矛盾性,暂停时间越长,Radio参数就越容易超过设定的比例
-XX:MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即STW的时间)单位是毫秒(该参数使用需谨慎)
为了尽可能地把停顿时间控制在MaxGCPauseMillis以内,收集器在工作时会调整Java堆大小或者其他一些参数
对于用户来讲,停顿时间越短体验越好,但是在服务器端,我们注重高并发,整体的吞吐量,所以服务器端适合Parallel,进行控制
多线程
场景:堆内存较大,多核 cpu
尽可能让单次 STW 的时间最短 0.1+0.1+0.1+0.1+0.1 = 0.5
-XX:+UseConcMarkSweepGC(老年代,标记清除算法) ~ -XX:+UseParNewGC ~ SerialOld (新生代,复制算法)
concurrent 并发(垃圾回收器进行垃圾回收时,其他用户线程也可以并发进行,与垃圾回收线程抢占cpu)mark标记,sweep清除()
-XX:ParallelGCThreads=n 并行的垃圾回收线程数,一般跟cpu数目相等
-XX:ConcGCTreads=threads 并发的垃圾回收线程数目,
一般是ParallelGCThreads的 1/4,即一个cpu做垃圾回收,剩下3个cpu留给人家用户线程。
-XX:CMSInitiatingOccupancyFraction=percent,开始执行CMS垃圾回收时的内存占比,
早期默认65,即只要老年代内存占用率达到65%的时候就要开始清理,留下35%的空间给新产生的浮动垃圾。
-XX:+CMSScavengeBeforeRemark 在重新标记之前,对新生代做一次垃圾回收
CMS回收器发生并行失败时,CMS回收器会退化成SerialOld的单线程的基于标记整理的垃圾回收器。
CMS老年代回收过程
整个工作阶段只会在初始标记和重新标记的时候STW,其他阶段并发执行,响应时间特别短。
CMS垃圾回收器
定义:Garbage First
2004 论文发布
2009 JDK 6u14 体验
2012 JDK 7u4 官方支持
2017 JDK 9 默认,取代CMS垃圾回收器
适用场景:
-XX:+UseG1GC 显示启动G1
-XX:G1HeapRegionSize=size 设置区域大小
-XX:MaxGCPauseMillis=time 设置暂停目标
新生代垃圾收集:同样叫Minor GC(Young GC)发生时机就是Eden区满的时候
新生代垃圾收集+并发标记:当老年代内存超过阈值时,在新生代垃圾回收的同时,并发标记
混合收集:不只清理年轻代,还会将老年代的一部分区域进行清理
Young GC主要是对Eden区进行GC,它在Eden空间耗尽时会被触发。在这种情况下,Eden空间的数据移动到Survivor空间中,如果Survivor空间不够,Eden空间的部分数据会直接晋升到年老代空间。Survivor区的数据移动到新的Survivor区中,也有部分数据晋升到老年代空间中。最终Eden空间的数据为空,GC停止工作,应用线程继续执行。
新生代垃圾回收时,先找到GC Root对象,进行可达性分析算法,找到存活对象,存活对象复制到幸存区。
那如何找到所有的根对象呢? 根对象有一部分来自于老年代,老年代存活的对象特别多,如果遍历一遍老年代去寻找根对象,那这样扫描下来会耗费大量的时间。G1引进了RSet的概念。它的全称是Remembered Set,作用是跟踪指向某个heap区内的对象引用。
上图中region2的RSet记录了两个引用到本region内对象的关系
每个region都有自身对应的一个记忆集RSet
每次引用类型数据写操作的时候,都会产生一个写屏障(post-write barrier)暂时的中断操作
检查将要写入的引用指向的对象是否和该引用类型数据在不同的region(其他收集器将会检查老年代对象是否引用了新生代对象,是,标记为dirty card)
如果不同,通过CardTable把相关引用信息记录到引用指向对象所在的region对象的RSet中
老年代维护采用card table技术,将老年代区域再细分为card(上图右侧橙色区域),每个card大约为512k,如果老年代对象引用了新生代,对应的card标记为dirty card(粉色区域),在做GC Root遍历时,不需要找整个老年代,只需关注dirty card区域,减少扫描范围,提高搜索效率。
当堆新生代进行回收时,通过Remembered Set记录找到对应的dirty card,然后在dirty card区域遍历Region的GC Root
SATB全称是Snapshot-At-The-Beginning,由字面理解,是GC开始时活着的对象的一个快照。它是通过Root Tracing得到的,作用是维持并发GC的正确性。那么它是怎么维持并发GC的正确性的呢?根据三色标记算法,我们知道对象存在三种状态:白:对象没有被标记到,标记阶段结束后,会被当做垃圾回收掉。灰:对象被标记了,但是它的field还没有被标记或标记完。黑:对象被标记了,且它的所有field也被标记完了。
SATB 利用 write barrier(写屏障) 将所有即将被删除的引用关系的旧引用记录下来(加入到队列中),标记为灰色,最后以这些旧引用为根 Stop The World 地重新扫描一遍即可避免漏标问题。
因此G1 Remark阶段 Stop The World 与 CMS了的remark有一个本质上的区别,那就是这个暂停只需要扫描有 write barrier 所追中对象为根的对象, 而 CMS 的remark 需要重新扫描整个根集合,因而CMS remark有可能会非常慢。
混合式垃圾回收,每次- 收集既可能只收集年轻代分区(年轻代收集),也可能在收集年轻代的同时,包含部分老年代分区(混合- 收集),这样即使堆内存很大时,也可以限制收集范围,从而降低停顿。
G1有一个参数:“-XX:InitiatingHeapOccupancyPercent”,默认值是45%,当老年代的大小占据了堆内存的45%的Region时,此时就会触发一个新生代和老年代的混合回收阶段,对E S 0 H进行全面回收。
该阶段一旦触发会导致系统进入STW,同时进行最后一个标记:
此时老年代也是根据标记-复制算法来进行回收的,会将标记存活的对象拷贝到新的Region中作为老年代区域:
SerialGC
新生代内存不足发生的垃圾收集 - minor gc
老年代内存不足发生的垃圾收集 - full gc
ParallelGC
新生代内存不足发生的垃圾收集 - minor gc
老年代内存不足发生的垃圾收集 - full gc
CMS
新生代内存不足发生的垃圾收集 - minor gc
老年代内存不足时,垃圾回收速度低于产生速度时候,并发失败,退化为单线程SerialGC串行执行,为full GC,否则不是。
G1
新生代内存不足发生的垃圾收集 - minor gc
老年代内存不足:超过阈值时先并发标记再混合收集,当回收速度高于新的用户线程产生垃圾的速度,处于并发垃圾收集。当垃圾回收速度低于新产生的垃圾速度,退化为full GC,响应时间较长。
-XX:+UseStringDeduplication 开启字符串去重功能,默认打开
会将所有新分配的字符串放入一个队列,当新生代回收时,G1并发检查是否有字符串重复,如果它们值一样,让它们引用同一个 char[]。s1和s2引用的是堆中的两个不同的对象,只不过那两个对象都指向同一个字符串而已,所以s1!= s2。
优点:节省大量内存
缺点:略微多占用了 cpu 时间,新生代回收时间略微增加
注意: 与 String.intern() 不一样
String.intern() 关注的是字符串对象,而字符串去重关注的是 char[],在 JVM 内部,使用了不同的字符串表。
之前版本jdk的类,一般是不卸载的,类加载之后,会一直占用内存。
在所有对象都经过并发标记后,就能知道哪些类不再被使用,当一个类加载器的所有类都不再使用,则卸
载它所加载的所有类。
-XX:+ClassUnloadingWithConcurrentMark 默认启用
卸载条件:
类的实例都被回收掉
类所在的类加载器其中的所有类都不再使用了
当一个对象大于 region 的一半时,称之为巨型对象。
G1 不会对巨型对象进行拷贝,回收时被优先考虑。
G1 会跟踪老年代所有 incoming 引用,这样老年代 incoming 引用为0 的巨型对象就可以在新生代垃圾回收时处理掉。
为了减少Full GC,可以提前让并发标记,混合收集提前开始。
JDK 9 之前需要使用 -XX:InitiatingHeapOccupancyPercent 老年代在整个堆内存的占比的阈值,超过时,并发垃圾回收开始,默认45%
JDK 9 可以动态调整 -XX:InitiatingHeapOccupancyPercent 用来设置初始值,在垃圾回收过程中,进行数据采样并动态调整阈值,会添加一个安全的空档空间,减少Full GC产生机率。
参考:GC如何调优
查看虚拟机运行参数:java -XX:+PrintFlagsFinal -version | findstr “GC”(查看本地虚拟机与GC相关的参数)
PS D:\java\idea\IdeaProject2\jvm\out> java -XX:+PrintFlagsFinal -version | findstr "GC"
java version "1.8.0_66"
Java(TM) SE Runtime Environment (build 1.8.0_66-b17)
Java HotSpot(TM) 64-Bit Server VM (build 25.66-b17, mixed mode)uintx AdaptiveSizeMajorGCDecayTimeScale = 10 {product}uintx AutoGCSelectPauseMillis = 5000 {product}bool BindGCTaskThreadsToCPUs = false {product}uintx CMSFullGCsBeforeCompaction = 0 {product}uintx ConcGCThreads = 0 CMS并发线程数,默认0 {product}bool DisableExplicitGC = false {product}bool ExplicitGCInvokesConcurrent = false {product}bool ExplicitGCInvokesConcurrentAndUnloadsClasses = false {product}uintx G1MixedGCCountTarget = 8 {product}uintx GCDrainStackTargetSize = 64 {product}uintx GCHeapFreeLimit = 2 {product}uintx GCLockerEdenExpansionPercent = 5 {product}bool GCLockerInvokesConcurrent = false {product}uintx GCLogFileSize = 8192 {product}uintx GCPauseIntervalMillis = 0 {product}uintx GCTaskTimeStampEntries = 200 {product}uintx GCTimeLimit = 98 {product}uintx GCTimeRatio = 99 GC时间占比 {product}bool HeapDumpAfterFullGC = false {manageable}bool HeapDumpBeforeFullGC = false {manageable}uintx HeapSizePerGCThread = 87241520 {product}uintx MaxGCMinorPauseMillis = 4294967295 {product}uintx MaxGCPauseMillis 最大GC停止时间目标 = 4294967295 {product}uintx NumberOfGCLogFiles = 0 {product}intx ParGCArrayScanChunk = 50 {product}uintx ParGCDesiredObjsFromOverflowList = 20 {product}bool ParGCTrimOverflow = true {product}bool ParGCUseLocalOverflow = false {product}uintx ParallelGCBufferWastePct = 10 {product}uintx ParallelGCThreads = 13 {product}bool ParallelGCVerbose = false {product}bool PrintClassHistogramAfterFullGC = false {manageable}bool PrintClassHistogramBeforeFullGC = false {manageable}bool PrintGC = false {manageable}bool PrintGCApplicationConcurrentTime = false {product}bool PrintGCApplicationStoppedTime = false {product}bool PrintGCCause = true {product}bool PrintGCDateStamps = false {manageable}bool PrintGCDetails = false {manageable}bool PrintGCID = false {manageable}bool PrintGCTaskTimeStamps = false {product}bool PrintGCTimeStamps = false {manageable}bool PrintHeapAtGC = false {product rw}bool PrintHeapAtGCExtended = false {product rw}bool PrintJNIGCStalls = false {product}bool PrintParallelOldGCPhaseTimes = false {product}bool UseGCOverheadLimit = true {product}bool UseGCTaskAffinity = false {product}bool UseMaximumCompactionOnSystemGC = true {product}bool UseParNewGC = false {product}bool UseParallelGC := true {product}bool UseParallelOldGC = true {product}bool UseSerialGC = false {product}
掌握相关工具:jmap,jconsole,jstat查看GC相关状态
调优不仅仅从内存GC,还应该考虑线程堆锁的竞争,CPU的占用,以及IO的调用,网络延迟,软硬件的考虑。
对于 GC 调优来说,首先就需要清楚调优的目标是什么?要清楚自己的应用程序是做什么的,如果是做科学运算,就要关注高吞吐量,如果是互联网项目,就要追求低延迟,提高用户体验。
GC调优从性能的角度看,通常关注三个方面,内存占用(footprint)、延时(latency)和吞吐量(throughput)。大多数情况下调优会侧重于其中一个或者两个方面的目标,很少有情况可以兼顾三个不同的角度。也可能需要考虑其他 GC 相关的场景,例如,OOM 也可能与不合理的 GC 相关参数有关;或者,应用启动速度方面的需求,GC 也会是个考虑的方面。
查看 FullGC 前后的内存占用,考虑下面几个问题
新生代的特点:
新生代内存越大越好吗?
-Xmn
Sets the initial and maximum size (in bytes) of the heap for the young generation (nursery). GC is
performed in this region more often than in other regions. If the size for the young generation is
too small, then a lot of minor garbage collections are performed. If the size is too large, then only
full garbage collections are performed, which can take a long time to complete. Oracle
recommends that you keep the size for the young generation greater than 25% and less than
50% of the overall heap size.
【设置新生代的初始大小和最大大小(以字节为单位)。GC是在该区域比在其他区域更频繁地执行。如果年轻一代太小,则会执行触发多次minor GC。如果尺寸太大,仅执行新生代的垃圾收集,可能需要很长时间。建议新生代保持占堆的25%~50%。】
新生区大小建议:
-XX:MaxTenuringThreshold=threshold 调整最大晋升阈值
-XX:+PrintTenuringDistribution 打印晋升区的存活对象
Desired survivor size 48286924 bytes, new threshold 10 (max 10)
- age 1: 28992024 bytes, 28992024 total
- age 2: 1366864 bytes, 30358888 total
- age 3: 1425912 bytes, 31784800 total
...
以 CMS 为例
-XX:CMSInitiatingOccupancyFraction=percent
案例1:Full GC 和 Minor GC频繁
业务高峰来了,创建大量对象将新生代空间塞满,幸存区的晋升阈值就会降低,导致很多生存周期很短的对象,也会被晋升到老年代,进一步触发老年代Full GC的发生。
先试着增大新生代内存大小,内存充裕了,垃圾回收就不会那么频繁,同时增大了幸存区和幸存区阈值,让生命周期较短的对象,尽可能的留在新生代,进一步减少触发老年代的GC。
案例2:请求高峰期发生 Full GC,单次暂停时间特别长 (CMS)
查看GC日志,判断CMS的哪一阶段耗费时间较长,(一般重新标记耗时较长)。
所以需要在重新标记之前,先回收新生代(-XX:+CMSScavengeBeforeRemark参数设置),就不会存在新生代引用老年代,然后去查找老年代了。新生代的垃圾回收(通过-XX:+UseParNewGC)之后,新生代对象少了,重新标记的压力就轻了。
案例3:老年代充裕情况下,发生 Full GC (CMS jdk1.7)
1.8采用元空间作为方法区的实现,1.7采用永久代作为方法区的实现。
上一篇:python第六天练习
下一篇:springboot复习(黑马)