在java中,有java程序、虚拟机、操作系统三个层次,其中java程序与虚拟机交互,而虚拟机与操作系统交互。这也就保证了java的与平台无关性,三者的运行原理是:
1、程序运行前:JVM向操作系统请求一定的内存空间,成为初始内存空间!程序执行过程中所需的内存都是由java虚拟机从这片内存空间中划分的。
2、程序运行中:java程序一直向java虚拟机申请内存,当程序所需要的内存空间超出初始内存空间时,java虚拟机会再次向操作系统申请更多的内存供程序使用!
3、内存溢出:程序接着运行,当java虚拟机已申请的内存达到了规定的最大内存空间,但程序还需要跟多的内存,这时会出现内存溢出的错误!
目录
4.4.5 CMS(Concurrent Mark Sweep) 收集器
1. jvm知识汇总
2. 内存空间
Java虚拟机在运行时,会把内存空间分为若干个区域,根据《Java虚拟机规范(Java SE 7 版)》的规定,Java虚拟机所管理的内存区域分为如下部分:方法区、堆内存、虚拟机栈、本地方法栈、程序计数器。
2.1 方法区
《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。在其他JVM上不存在永久代。
在jdk1.7及其之前,方法区是堆的一个“逻辑部分”(一片连续的堆空间),也有人叫“非堆”,也有人叫“永久代”(HotSpot JVM对方法区的实现方法),方法区主要用于存储虚拟机加载的类信息、常量、静态变量,以及编译器编译后的代码等数据。
jdk1.7的HotSpot中,开始准备“去永久代”的规划,已经把原本放在方法区中的静态变量、字符串常量池等移到堆内存中(常量池除字符串常量池还有class常量池等)。
在jdk1.8中,方法区已经不存在,原方法区中存储的类信息、编译后的代码数据等已经移动到了元空间(MetaSpace)中,元空间并没有处于堆内存上,而是直接占用的本地内存(NativeMemory)。
为什么废弃永久代(PermGen)?
参照JEP122,原文截取:
Motivation
This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation。即:移除永久代是为融合HotSpot JVM与 JRockit VM而做出的努力,因为JRockit没有永久代,不需要配置永久代。
去永久代的原因有:
(1)字符串存在永久代中,容易出现性能问题和内存溢出。
(2)类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
(3)永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
总之,元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。,理论上取决于32位/64位系统可虚拟的内存大小。可见也不是无限制的,需要配置参数。
2.2 堆内存
堆内存主要用于存放对象和数组,它是JVM管理的内存中最大的一块区域,堆内存和方法区都被所有线程共享,在虚拟机启动时创建。在垃圾收集的层面上来看,由于现在收集器基本上都采用分代收集算法,因此堆还可以分为新生代(YoungGeneration)和老年代(OldGeneration),新生代还可以分为Eden、From Survivor、To Survivor。
2.3 程序计数器
程序计数器是一块非常小的内存空间,可以看做是当前线程执行字节码的行号指示器,每个线程都有一个独立的程序计数器,因此程序计数器是线程私有的一块空间,此外,程序计数器是Java虚拟机规定的唯一不会发生内存溢出的区域。
2.4 虚拟机栈
虚拟机栈也是每个线程私有的一块内存空间,它描述的是方法的内存模型,直接看下图所示:
虚拟机会为每个线程分配一个虚拟机栈,每个虚拟机栈中都有若干个栈帧,每个栈帧中存储了局部变量表、操作数栈、动态链接、返回地址等。一个栈帧就对应Java代码中的一个方法,当线程执行到一个方法时,就代表这个方法对应的栈帧已经进入虚拟机栈并且处于栈顶的位置,每一个Java方法从被调用到执行结束,就对应了一个栈帧从入栈到出栈的过程。
2.5 本地方法栈
本地方法栈与虚拟机栈的区别是,虚拟机栈执行的是Java方法,本地方法栈执行的是本地方法(Native Method),其他基本上一致,在HotSpot中直接把本地方法栈和虚拟机栈合二为一,这里暂时不做过多叙述。
2.6 堆与栈
堆 | 堆是一个运行时数据区,类的对象从中分配空间。这些对象通过new建立,它们不需要程序代码来显式的释放。堆是由垃圾回收来负责的,堆的优势是可以动态地分配内存大小,生存期也不必事先告诉编译器,因为它是在运行时动态分配内存的,Java的垃圾收集器会自动收走这些不再使用的数据。但缺点是,由于要在运行时动态分配内存,存取 速度较慢。java中的对象和数组都存放在堆中。 |
栈 | 栈的优势是,存取速度比堆要快,仅次于寄存器,栈数据可以共享。但缺点是,存在栈中的数据大小与生存期必须是确定的,缺乏灵活性。栈中主要存放一些基本类型的变量 (,int, short, long, byte, float, double, boolean, char)和对象引用。 栈有一个很重要的特殊性,就是存在栈中的数据可以共享。 |
假设我们同时定义: int a = 3; int b = 3;
编译器先处理int a = 3;首先它会在栈中创建一个变量为a的引用,然后查找栈中是否有3这个值,如果没找到,就将3存放进来,然后将a指向3。接着处理int b = 3;在创建 完b的引用变量后,因为在栈中已经有3这个值,便将b直接指向3。这样,就出现了a与b同时均指向3的情况。这时,如果再令a=4;那么编译器会重新搜索栈中是否有4值,如 果没有,则将4存放进来,并令a指向4;如果已经有了,则直接将a指向这个地址。因此a值的改变不会影响到b的值。要注意这种数据的共享与两个对象的引用同时指向一个对 象的这种共享是不同的,因为这种情况a的修改并不会影响到b, 它是由编译器完成的,它有利于节省空间。而一个对象引用变量修改了这个对象的内部状态,会影响到另一个对象引用变量。
3. 内存溢出
- 堆内存溢出, java.lang.OutOfMemoryError: Java heap space 的信息。新产生的对象最初分配在新生代,新生代满后会进行一次Minor GC,如果Minor GC后空间不足会把该对象和新生代满足条件的对象放入老年代,老年代空间不足时会进行Full GC,之后如果空间还不足以存放新对象则抛出OutOfMemoryError异常。
- 虚拟机栈/本地方法栈溢出,StackOverflowError:当线程请求的栈的深度大于虚拟机所允许的最大深度,简单理解就是虚拟机栈中的栈帧数量过多(一个线程嵌套调用的方法数量过多)时,就会抛出StackOverflowError异常。最常见的场景就是方法无限递归调用。OutOfMemoryError:如果虚拟机在扩展栈时无法申请到足够的内存空间,虚拟机中可以供栈占用的空间≈可用物理内存 - 最大堆内存 - 最大方法区内存,比如一台机器内存为4G,系统和其他应用占用2G,虚拟机可用的物理内存为2G,最大堆内存为1G,最大方法区内存为512M,那可供栈占有的内存大约就是512M,假如我们设置每个线程栈的大小为1M,那虚拟机中最多可以创建512个线程,超过512个线程再创建就没有空间可以给栈了,就报OutOfMemoryError异常了
- 方法区溢出,java.lang.OutOfMemoryError: PermGen space,由于在jdk1.6之前字符串常量池是存在于方法区中的,可以通过不断产生不一致的字符串(同时要保证和GC Roots之间保证有可达路径)来模拟方法区的OutOfMemoryError异常;但方法区还存储加载的类信息,所以基于jdk1.7的虚拟机,可以通过动态不断创建大量的类来模拟方法区溢出
- 本机直接内存溢出,本机直接内存(DirectMemory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但Java中用到NIO相关操作时(比如ByteBuffer的allocteDirect方法申请的是本机直接内存),也可能会出现内存溢出的异常。
4. 垃圾回收
垃圾回收,就是通过垃圾收集器把内存中没用的对象清理掉。垃圾回收涉及到的内容有:
- 判断对象是否已死;
- 选择垃圾收集算法;
- 选择垃圾收集的时间;
- 选择适当的垃圾收集器清理垃圾(已死的对象)
4.1 判断对象是否已死
引用计数算法
给每一个对象添加一个引用计数器,每当有一个地方引用它时,计数器值加1;每当有一个地方不再引用它时,计数器值减1,这样只要计数器的值不为0,就说明还有地方引用它,它就不是无用的对象。这种方法看起来非常简单,但目前许多主流的虚拟机都没有选用这种算法来管理内存,原因就是当某些对象之间互相引用时,无法判断出这些对象是否已死。
方法区回收
方法区中主要回收的是废弃的常量和无用的类。
判断常量是否废弃可以判断是否有地方引用这个常量,如果没有引用则为废弃的常量。
判断类是否废弃需要同时满足如下条件:
- 该类所有的实例已经被回收(堆中不存在任何该类的实例)
- 加载该类的ClassLoader已经被回收
- 该类对应的java.lang.Class对象在任何地方没有被引用(无法通过反射访问该类的方法)
可达性分析算法
先了解一个概念——GC Roots,垃圾收集的起点,可以作为GC Roots的有:
- 虚拟机栈中本地变量表中引用的对象
- 方法区中静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中JNI(Native方法)引用的对象
当一个对象到GC Roots没有任何引用链相连(GC Roots到这个对象不可达)时,就说明此对象是不可用的,是死对象。但并不是“非死不可”的,要真正宣告一个对象死亡,至少要经历两次标记的过程。如果没有与GC Roots相连接的引用链,它会被第一次标记,并进行筛选,筛选的条件是此对象是否有必要执行finalize()方法。
执行finalize()方法的两个条件:
- 重写了finalize()方法
- finalize()方法之前没被调用过,因为对象的finalize()方法只能被执行一次。
如果满足以上两个条件,这个对象将会放置在F-Queue的队列之中,并在稍后由一个虚拟机自建的、低优先级Finalizer线程来执行它。
finalize()方法是对象脱离死亡命运最后的机会,如果对象覆盖了finalize()方法且还没有被调用,则会执行finalize()方法中的内容,所以在finalize()方法中如果重新与GC Roots引用链上的对象关联就可以拯救自己(比如把自己(this关键字)赋值给某个类变量或对象的成员变量),但是一般不建议这么做。
下面被判了死刑的对象(object5、object6、object7)并不是必死无疑,还有挽救的余地。进行可达性分析后对象和GC Roots之间没有引用链相连时,对象将会被进行一次标记,接着会判断如果对象没有覆盖Object的finalize()方法或者finalize()方法已经被虚拟机调用过,那么它们就会被行刑(清除)。
4.2 常用垃圾回收算法
4.2.1 标记-清除算法
分为标记和清除两个阶段,首先标记出所有需要回收的对象,标记完成后统一回收所有被标记的对象,缺点:标记和清除两个过程效率都不高;标记清除之后会产生大量不连续的内存碎片。
4.2.2 复制算法
把内存分为大小相等的两块,每次存储只用其中一块,当这一块用完了,就把存活的对象全部复制到另一块上,同时把使用过的这块内存空间全部清理掉,往复循环。缺点:实际可使用的内存空间缩小为原来的一半,比较适合。
4.2.3 标记-整理算法
先对可用的对象进行标记,然后所有被标记的对象向一段移动,最后清除可用对象边界以外的内存
4.2.4 分代收集算法
把堆内存分为新生代和老年代,新生代又分为Eden区、From Survivor和To Survivor。一般新生代中的对象基本上都是朝生夕灭的,每次只有少量对象存活,因此采用复制算法,只需要复制那些少量存活的对象就可以完成垃圾收集;老年代中的对象存活率较高,就采用标记-清除和标记-整理算法来进行回收。
大多数情况下,新的对象都分配在Eden区,当Eden区没有空间进行分配时,将进行一次Minor GC,清理Eden区中的无用对象。清理后,Eden和From Survivor中的存活对象如果小于To Survivor的可用空间则进入To Survivor,否则直接进入老年代);Eden和From Survivor中还存活且能够进入To Survivor的对象年龄增加1岁(虚拟机为每个对象定义了一个年龄计数器,每执行一次Minor GC年龄加1),当存活对象的年龄到达一定程度(默认15岁)后进入老年代,可以通过-XX:MaxTenuringThreshold来设置年龄的值。
当进行了Minor GC后,Eden还不足以为新对象分配空间(那这个新对象肯定很大),新对象直接进入老年代。
占To Survivor空间一半以上且年龄相等的对象,大于等于该年龄的对象直接进入老年代,比如Survivor空间是10M,有几个年龄为4的对象占用总空间已经超过5M,则年龄大于等于4的对象都直接进入老年代,不需要等到MaxTenuringThreshold指定的岁数。
在进行Minor GC之前,会判断老年代最大连续可用空间是否大于新生代所有对象总空间,如果大于,说明Minor GC是安全的,否则会判断是否允许担保失败,如果允许,判断老年代最大连续可用空间是否大于历次晋升到老年代的对象的平均大小,如果大于,则执行Minor GC,否则执行Full GC。
当在java代码里直接调用System.gc()时,会建议JVM进行Full GC,但一般情况下都会触发Full GC,一般不建议使用,尽量让虚拟机自己管理GC的策略。
永久代(方法区)中用于存放类信息,jdk1.6及之前的版本永久代中还存储常量、静态变量等,当永久代的空间不足时,也会触发Full GC,如果经过Full GC还无法满足永久代存放新数据的需求,就会抛出永久代的内存溢出异常。
大对象(需要大量连续内存的对象)例如很长的数组,会直接进入老年代,如果老年代没有足够的连续大空间来存放,则会进行Full GC。
4.3 选择垃圾收集的时间
当程序运行时,各种数据、对象、线程、内存等都时刻在发生变化,当下达垃圾收集命令后就立刻进行收集吗?肯定不是。这里来了解两个概念:安全点(safepoint)和安全区(safe region)。
安全点(safepoint)
从线程角度看,安全点可以理解为是在代码执行过程中的一些特殊位置,当线程执行到安全点的时候,说明虚拟机当前的状态是安全的,如果有需要,可以在这里暂停用户线程。当垃圾收集时,如果需要暂停当前的用户线程,但用户线程当时没在安全点上,则应该等待这些线程执行到安全点再暂停。
理论上,解释器的每条字节码的边界上都可以放一个安全点,实际上,安全点基本上以“是否具有让程序长时间执行的特征”为标准进行选定。
比如:妈妈在扫地,儿子在吃西瓜(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈等一下,让我吃完这块再扫。”儿子吃完这块西瓜把瓜皮扔到地上后就是一个安全点,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。
安全区(safe region)
安全点是相对于运行中的线程来说的,对于如sleep或blocked等状态的线程,收集器不会等待这些线程被分配CPU时间,这时候只要线程处于安全区中,就可以算是安全的。安全区就是在一段代码片段中,引用关系不会发生变化,可以看作是被扩展、拉长了的安全点。
比如:妈妈在扫地,儿子在吃西瓜(瓜皮会扔到地上),妈妈扫到儿子跟前时,儿子说:“妈妈你继续扫地吧,我还得吃10分钟呢!”儿子吃瓜的这段时间就是安全区,妈妈可以继续扫地(垃圾收集器可以继续收集垃圾)。
4.4 垃圾收集器
现在常见的垃圾收集器有如下几种:
新生代收集器:Serial、ParNew、Parallel Scavenge
老年代收集器:Serial Old、CMS、Parallel Old
堆内存垃圾收集器:G1
并行和并发
并行(Parallel) :指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行,可能会交替执行),用户程序在继续运行,而垃圾收集器运行在另一个CPU上
java8默认使用的是 Parallel Scavenge(新生代)+ Serial Old(老年代)
查看的命令java -XX:+PrintCommandLineFlags -version
4.4.1 Serial 收集器
Serial是一款用于新生代的单线程收集器,采用复制算法进行垃圾收集。Serial进行垃圾收集时,不仅只用一条线程执行垃圾收集工作,它在收集的同时,所有的用户线程必须暂停(Stop The World)。
适用场景:Client模式(桌面应用);单核服务器。
可以用-XX:+UserSerialGC来指定【选择新生代为Serial 收集器、老年代为Serial Old收集器】,下图是该组合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,Serial收集器以单线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
4.4.2 ParNew 收集器
ParNew就是一个Serial的多线程版本,其它与Serial并无区别。ParNew在单核CPU环境并不会比Serial收集器达到更好的效果,它默认开启的收集线程数和CPU数量一致,可以通过-XX:ParallelGCThreads来设置垃圾收集的线程数。
适用场景:多核服务器;与CMS收集器搭配使用。
当使用-XX:+UserConcMarkSweepGC来指定(选择新生代为ParNew收集器、老年代为CMS收集器,当发生CMS发生Concurrent Mode Failure失败后选择再Serial Old收集器作为后备),也可以用-XX:+UseParNewGC来指定(选择新生代为ParNew收集器、老年代为Serial Old收集器)。
下图是ParNew收集器和Serial Old收集器结合进行垃圾收集的示意图,当用户线程都执行到安全点时,所有线程暂停执行,ParNew收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行。
4.4.3 Parallel Scavenge 收集器
Parallel Scavenge也是一款用于新生代的多线程收集器,与ParNew的不同之处是,ParNew的目标是尽可能缩短垃圾收集时用户线程的停顿时间,Parallel Scavenge的目标是达到一个可控制的吞吐量。吞吐量就是CPU执行用户线程的的时间与CPU执行总时间的比值【吞吐量=运行用户代代码时间/(运行用户代码时间+垃圾收集时间)】。可以通过-XX:MaxGCPauseMillis来设置收集器尽可能在多长时间内完成内存回收,可以通过-XX:GCTimeRatio来精确控制吞吐量。
比如垃圾收集器每100秒收集一次,每次停顿10秒,和垃圾收集器每50秒收集一次,每次停顿时间7秒,虽然后者每次停顿时间变短了,但是总体吞吐量变低了,CPU总体利用率变低了。
适用场景:注重吞吐量,高效利用CPU,需要高效运算且不需要太多交互。
可以使用-XX:+UseParallelGC来指定(选择新生代为Parallel Scavenge收集器、老年代为Serial Old收集器),也可以通过-XX:+UseParallelOldGC来指定(选择新生代为Parallel Scavenge收集器、老年代为Parallel Old收集器)。
如下是Parallel收集器和Parallel Old收集器结合进行垃圾收集的示意图,在新生代,当用户线程都执行到安全点时,所有线程暂停执行,ParNew收集器以多线程,采用复制算法进行垃圾收集工作,收集完之后,用户线程继续开始执行;在老年代,当用户线程都执行到安全点时,所有线程暂停执行,Parallel Old收集器以多线程,采用标记整理算法进行垃圾收集工作。
4.4.4 Serial Old收集器
Serial Old收集器是Serial的老年代版本,同样是一个单线程收集器,采用标记-整理算法。
适用场景:Client模式(桌面应用);单核服务器;与Parallel Scavenge收集器搭配;作为CMS收集器的后备预案。
下图是Serial收集器和Serial Old收集器结合进行垃圾收集的示意图:
4.4.5 CMS(Concurrent Mark Sweep) 收集器
CMS收集器是一种以最短回收停顿时间为目标的收集器,以“最短用户线程停顿时间”著称。
适用场景:重视服务器响应速度,要求系统停顿时间最短。可以使用-XX:+UserConMarkSweepGC来选择CMS作为老年代收集器。整个垃圾收集过程分为4个步骤:
① 初始标记:标记一下GC Roots能直接关联到的对象,速度较快
② 并发标记:进行GC Roots Tracing,标记出全部的垃圾对象,耗时较长
③ 重新标记:修正并发标记阶段引用户程序继续运行而导致变化的对象的标记记录,耗时较短
④ 并发清除:用标记-清除算法清除垃圾对象,耗时较长
整个过程耗时最长的并发标记和并发清除都是和用户线程一起工作,所以从总体上来说,CMS收集器垃圾收集可以看做是和用户线程并发执行的。
CMS收集器也存在一些缺点:
- 对CPU资源敏感:默认分配的垃圾收集线程数为(CPU数+3)/4,随着CPU数量下降,占用CPU资源越多,吞吐量越小
- 无法处理浮动垃圾:在并发清理阶段,由于用户线程还在运行,还会不断产生新的垃圾,CMS收集器无法在当次收集中清除这部分垃圾。同时由于在垃圾收集阶段用户线程也在并发执行,CMS收集器不能像其他收集器那样等老年代被填满时再进行收集,需要预留一部分空间提供用户线程运行使用。当CMS运行时,预留的内存空间无法满足用户线程的需要,就会出现“Concurrent Mode Failure”的错误,这时将会启动后备预案,临时用Serial Old来重新进行老年代的垃圾收集。
- 因为CMS是基于标记-清除算法,所以垃圾回收后会产生空间碎片,可以通过-XX:UserCMSCompactAtFullCollection开启碎片整理(默认开启),在CMS进行Full GC之前,会进行内存碎片的整理。还可以用-XX:CMSFullGCsBeforeCompaction设置执行多少次不压缩(不进行碎片整理)的Full GC之后,跟着来一次带压缩(碎片整理)的Full GC。
4.4.6 Parallel Old 收集器
Parallel Old收集器是Parallel Scavenge的老年代版本,是一个多线程收集器,采用标记-整理算法。可以与Parallel Scavenge收集器搭配,可以充分利用多核CPU的计算能力。
适用场景:与Parallel Scavenge收集器搭配使用;注重吞吐量。使用见Parallel Scavenge 收集器。
4.4.7 G1 收集器
G1 收集器是jdk1.7才正式引用的商用收集器,现在已经成为jdk9默认的收集器。前面几款收集器收集的范围都是新生代或者老年代,G1进行垃圾收集的范围是整个堆内存,它采用“化整为零”的思路,把整个堆内存划分为多个大小相等的独立区域(Region),在G1收集器中还保留着新生代和老年代的概念,它们分别都是一部分Region。
适用场景:要求尽可能可控GC停顿时间;内存占用较大的应用。可以用-XX:+UseG1GC使用G1收集器。如下图:
每一个方块就是一个区域,每个区域可能是Eden、Survivor、老年代,每种区域的数量也不一定。JVM启动时会自动设置每个区域的大小(1M~32M,必须是2的次幂),最多可以设置2048个区域(即支持的最大堆内存为32M*2048=64G),假如设置-Xmx8g -Xms8g,则每个区域大小为8g/2048=4M。
为了在GC Roots Tracing的时候避免扫描全堆,在每个Region中,都有一个Remembered Set来实时记录该区域内的引用类型数据与其他区域数据的引用关系(在前面的几款分代收集中,新生代、老年代中也有一个Remembered Set来实时记录与其他区域的引用关系),在标记时直接参考这些引用关系就可以知道这些对象是否应该被清除,而不用扫描全堆的数据。
G1收集器可以“建立可预测的停顿时间模型”,它维护了一个列表用于记录每个Region回收的价值大小(回收后获得的空间大小以及回收所需时间的经验值),这样可以保证G1收集器在有限的时间内可以获得最大的回收效率。
下图所示,G1收集器收集器收集过程有初始标记、并发标记、最终标记、筛选回收,和CMS收集器前几步的收集过程很相似:
① 初始标记:标记出GC Roots直接关联的对象,这个阶段速度较快,需要停止用户线程,单线程执行
② 并发标记:从GC Root开始对堆中的对象进行可达新分析,找出存活对象,这个阶段耗时较长,但可以和用户线程并发执行
③ 最终标记:修正在并发标记阶段引用户程序执行而产生变动的标记记录
④ 筛选回收:筛选回收阶段会对各个Region的回收价值和成本进行排序,根据用户所期望的GC停顿时间来指定回收计划(用最少的时间来回收包含垃圾最多的区域,这就是Garbage First的由来——第一时间清理垃圾最多的区块),这里为了提高回收效率,并没有采用和用户线程并发执行的方式,而是停顿用户线程。