学习JVM需要有的学习脑图
JVM是一种用于计算设备的规范,它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。
详细介绍就不说了,咱直接开始:
学习一个知识,为了更好的理解并记住,我们最好把它划分为几个部分,部分再划分,直到延伸至细节,这样,我们学习过程中不仅学的快,理解的也越深。
JVM体系总体可分为四个部分去学习:类的加载机制,内存结构,GC算法,GC分析及调优
jvm是可运行java代码的机器,有一套字节码指令集,一组寄存器,一个栈,一个垃圾回收。堆,一个存储方法域,并且是运行在操作系统上的。
总体脑图
一、Java代码执行过程
一般流程就是 Java源文件经过编译器编译成字节码文件,然后这个字节码文件通过jvm转成机器码。
意思就是jvm执行的是编译后的class文件。
我们常说的跨平台是为什么呢? 其实很简单理解,虽然我们大多数语言的解释器不同,但是虚拟机是相同的,所以我们虚拟机会执行相应的文件,多个平台都有相同的虚拟机,这样就跨平台了。但是多个虚拟机实例之间是不能共享数据的!
看一下总体的一个流程图,看看class文件从哪到哪,分别经过了哪里,然后具体分析各个组成部分是什么,能做什么:
图中分了一下线程是否共享。这里的线程是一个线程实体。
我们的jvm在栈,程序计数器,对象等等准备好后,就会创建一个原生线程,执行run方法,原生线程执行完后就会是释放线程的所有资源,也就是被回收了。线程是操作系统调度的喔。
关于线程:
Hotspot JVM系统线程:
1. 虚拟机线程: 这个线程会等待jvm到达安全点操作出现,这些操作必须要在独立的线程里面执行,因为堆修改不能进行时,线程都要位于安全点。操作类型有:线程栈dump、线程暂停、线程偏向锁解除、stop-the-world垃圾回收
2. GC线程: gc线程支持jvm的垃圾回收活动,是一个守护线程,没有服务的对象就随时会挂了。
3. 周期性任务线程: 负责定时器的事件,调度周期性操作的执行,就是中断线程的概念。
4. 编译器线程: 负责在运行时把字节码编译成机器码,一般了解就行。
5. 信号分发线程: 它会接收发送到JVM的信号并调用适当的jvm方法处理。
对于这么些的线程肯定记不住全部,但是要知道jvm中很多操作是由线程调度的,重要的虚拟机线程,GC线程,周期性任务线程要有印象。
既然线程几乎占满了我们的jvm的调度,那么我们就按照线程的角度去具体分析jvm的内存结构呗!
我们把jvm内存分为线程私有、线程共享、直接内存来学习。
二、jvm内存
- 线程私有:程序计数器 、虚拟机栈 、本地方法栈
- 线程共享: 方法区(永久代) 、 堆(类实例区)
- 直接内存(不受GC管理)
线程私有和共享有什么区别呢?
- 线程私有的数据区域的生命周期是与线程相同的,那就依赖用户线程的启动和结束,而相应的创建和销毁。
因为每个线程和操作系统的本地线程直接映射,相当于私有的线程是依赖于用户线程的,所以“生死对应”了,细品。 - 线程共享区域是跟随jvm的启动关闭去创建销毁的,共享的就是跟着他爸JVM“生死相随”的。
- 直接内存不是JVM运行时数据区的部分,但是了解过NIO的零拷贝特性就知道直接缓冲区的内存分配不发生在堆的,而是通过本地方法调用分配内存,免去中间交换的内存拷贝,可以驻留在垃圾回收扫描的堆区之外的,所以不受GC管理的。NIO提供基于 Channel 与 Buffer 的 IO 方式, 它可以使用 Native 函数库直接分配堆外内存, 然后使用 直接缓冲区 对象作为这块内存的引用进行操作, 这样就避免了在 Java 堆和Native堆中来回复制数据, 因此在一些场景中可以显著提高性能。
接下来我们对内存结构单独分析分析:
- 虚拟机栈 :描述java方法的执行的内存模型,每个方法在执行时会对应创建一个栈帧。
栈帧用来存储数据和部分过程的数据结构,局部变量表,操作数栈,动态链接,方法出口等等信息。
每个方法从调用直到完成的过程,就对应一个栈帧会在虚拟机栈中入栈到出栈的过程。
那么虚拟机栈我们通俗来说就是我们代码中执行的方法就对应一个创建的栈帧,方法执行完后,栈帧相应出栈销毁。 - 本地方法栈:作用和虚拟机栈差不多,区别就是,本地方法栈是用来为Native本地方法服务的,虚拟栈是为Java的方法服务的。 如果一个虚拟机用C-linkage模型支持native的调用,那么这个栈就是c栈,jvm会把本地方法栈和虚拟机栈合并。
- 程序计数器:是当前线程执行的字节码行号指示器,每个线程都会由单独的程序计数器,它就是在Java方法执行时,计数器记录的就还是虚拟机字节码指令的地址,但如果是native的方法,那就为空了。这个内存区域是唯一一个没有规定任何超出内存的错误(OOM)的区域。
以上是 线程私有的内存区域的分析,接下来是线程共享的内存区域分析
-
堆 :也就是运行时的数据区,是被线程共享的一块内存区域,一般我们创建的对象和数组都是保存在堆中的,也是垃圾收集器回收垃圾的最重要的内存区域。对于我们常听到的新生代和老年代,其实就是JVM用分代收集算法,按GC的角度再分的两个概念。
-
方法区(永久代):用来存储被JVM加载的类信息,常量,静态变量,即时编译器编译后的代码(用jlt编译器,编译热点代码,提高效率)等等。JVM把GC分代收集扩展至方法区,也就是用堆的永久代实现方法区,所以有些人说方法区在堆里也是从这个角度来看的。既然扩展到方法区,那GC也可以像管理堆那样来管理方法区的内存了,这样就不用单独为方法区开发内存管理器,因为我们对方法区的内存回收也只是针对常量池和类型的回收。
-
运行时常量池:属于方法区的一部分。它用来存放编译期生成的各种字面量的符号引用,比如String常量池。
我们再从堆的GC角度上可以分为新生代(占1/3堆空间)和老年代(占2/3堆空间):
- 新生代:用来存放新创建的对象,它分三个区Eden ,ServivorFrom ,ServivorTo。如果我们频繁的去创建对象,新生代则会频繁的触发MinorGC进行垃圾回收。
1.1. Eden区:Java新创建的对象的出生地。如果新创建的对象占用内存很大的话,就直接分配给老年代。如果Eden内存不够就会触发MinorGC对新生代进行垃圾回收。
1.2. ServivorFrom:是上次GC回收后的幸存者,作为这次GC的被扫描者。
1.3. ServivorTo: 保留了一次GC过程中的幸存者。
提到这么多MinorGC,它是什么呢?
MinorGC是一个触发机制,利用复制算法对触发区域做一次垃圾回收。
步骤:
- 把Eden,ServivorFrom中存活 的对象复制到ServivorTo区域,其中如果有对象的年龄达到老年代的标准,就会赋值到老年代,如果ServivorTo的位置不够了就会把对象的年龄加1,然后加到老年代。一般对象躲过一次GC年龄加1,到达15就会被移到老年代中。如果对象的大小大于Eden的二分之一会直接分配在老年代。MinorGC后survivor仍然放不下,就放到老年代。
- 复制完,然后清空Eden,ServivorFrom里面的 对象。
- 最后,把ServivorTo和ServivorFrom互换,原来的ServivorTo成为下一次GC时的ServivorFrom。
- 老年代:用来存放应用程序中生命周期长的内存对象。老年代的对象比较稳定,MajorGC不会频繁执行。一般MajorGC前都会进行一次MinorGC,使新生代的“有资格”的对象到老年代,如果空间不够时就会触发MajorGC。
那么这个MajorGC是采用的标记整理的算法:
1. 先扫描一次所有老年代,标记出存活的对象,然后回收没有被标记的对象,因为要扫描一遍再回收,所以比较耗时。MajorGC会产生内存碎片,一般为了减少内存的损耗,我们一般需要进行合并或者标记出来方便下次直接分配。如果老年代内存也满了就会抛出out of memory的错误。这时就造成内存溢出的情况
永久代:之前说堆的永久代扩展到方法区,这个永久代指的就是内存的永久保存区域,主要存放class和元数据(meta)的信息,class在被加载的时候放到永久区域,和存放实例不同,GC不会再主程序运行期对永久区域进行清理,所以就导致了永久代的区域会随着加载的class的增多 最终内存不够抛出out of memory 错误。
在java8中,永久代已经被移除,被元空间取代,元空间就是存放”元数据“的空间。元空间不在虚拟机中,而是使用本地内存。类的元数据放入native memory,字符串池和类的静态变量放入Java堆中,这样加载多少类的元数据就不会再由MaxPermsize控制,而是由系统的实际可用空间控制。
string pool 在 元空间 中是Table结构,用来记录用双引号引起来的string的引用,本质上是一张哈希表,具体的string实例是存放在heap中(java8)。
划重点!!!
JDK1.6和JDK1.7和JDK1.8的区别:
JDK1.6: 有方法区,上文就是针对JDK1.6的内存结构。那些运行时常量池、常量、静态变量就是存放在方法区中。(问题就是方法区是堆的永久代扩展实现的,很容易因为class加载过多造成out of memory)。
JDK1.7:也有方法区,但是运行时常量池等等在堆中存放,问题跟1.6差不多。
JDK1.8:方法区被抛弃了,由元空间替代,而元空间不在这个运行时数据区内,而是在虚拟机外部,用本地内存保存的,这样内存问题就由你计算机的内存决定了,很大改善了上面的问题,而运行时常量池等等信息就存放在堆中了。
分清楚内存区域,我们就到了GC这个常常提到但又很抽象的地方。
那么我们要考虑的就是那些内存要回收? 什么时候回收? 怎么回收?
所以首先,我们先确定哪些是垃圾。
方法:
- 引用计数法
在Java中,引用和对象是有关联的。如果要操作对象则必须用引用进行。所以,很简单的方法就是通过引用计数区判断一个对象是否可以回收。通俗上说,引用计数法就是去判断一个对象有没有与之关联的引用,如果没有那么引用计数就为0,就说明对象不太可能被用到,就是垃圾。与其他算法相比,引用计数法在于GC的调用时机,GC标记-清除算法是在没有可用分块再进行垃圾回收,是无侵入性的GC,这样就是,如果有垃圾也不会立即回收,而会在没有分块的时候再回收。引用计数法是一种侵入式的算法,最大暂停时间表现均匀。在引用计数法中,对象的状态监控是实时的。
- 可达性分析
可达性分析是为了解决引用计数法中的循环引用的问题。通过一系列的”GC roots“对象为起点去搜索。如果在”GC roots“和一个对象之间没有可达路径,则称这个对象是不可达的。重点在于。不可达对象不能等价于可回收对象,不可达对象变为回收对象要最少经过两次标记过程。两次标记后仍然是可回收对象,那就会回收。
确定了垃圾,就要用相应的算法去回收垃圾。
三、垃圾回收算法及收集器
- 标记清除算法(Mark-Sweep)
这是最基础的垃圾回收的算法,也很好理解,一个标记,一个清除。流程也很简单,就是标记好要被回收的对象,然后清除。
但是这样造成的问题就是内存的碎片化很严重,那么以后加入需要大内存的对象就很难找到空间去存放。
- 复制算法(copying)
为了解决标记清除带来的碎片化严重的问题,使用复制算法可以大大改善。
复制算法就是按照内存容量把内存划分为等大的两块,每次只用其中一块,当这一块内存满后把存活的对象复制到另一块,使用过的就清掉。其实就是一半一半,每次用一半的空间,当被用的那一半满了,就把那一半里面还存活的对象复制到另一块,老的一半就清掉。 很好的解决了内存碎片化的问题,但也有一些问题,就是内存被压缩到原来一半,如果存活对象增多的话,这个复制算法的效率也会大大降低。
- 标记整理(压缩)算法(Mark-Compact)
这个算法就是结合了以上两个算法,它的标记和Mark-sweep相同,标记后不是直接清理对象,而是把存活对象移到内存的一端(移到边界外,相当于丢垃圾到垃圾场),然后清除边界外的对象。达到回收垃圾且优化算法的作用。
解释:以上三种GC收集算法是目前JVM常用三种,那么我们这三种其实就是按照分代收集算法整理的。而我们知道在JVM中是可分代回收和分区回收的。
- 分代收集算法
目前大部分JVM采用的是分代收集算法。分代其实就是把GC堆划分成新生代和老年代。根据对象存活的不同的生命周期将内存划分为不同的区域。一般来说,新生代是每次垃圾回收都有大量垃圾要回收,老年代每次垃圾回收只有少量垃圾要回收。那么我们就可以根据不同的区域去选择不同的算法。
- 分区收集算法
分区算法是将整个堆空间划分为连续多个不同的小区间,然后独立使用,独立回收。这样可以使GC减少停顿。
那么算法也有了,那么就要知道具体的垃圾收集器GC了,看看它们用的是那些算法去收集的。
对于垃圾收集器,又有很多种类型供我们使用,使得系统性能达到最佳。
- Serial (单线程,复制算法)
Serial是最基本的垃圾收集器,使用了复制算法,jdk1.3之前是新生代唯一的垃圾收集器。
它是一个单线程的收集器,所以只使用一个线程区完成垃圾收集的工作,而且在它进行垃圾收集的同时,其他线程都要暂停等待垃圾收集完成。优点是简单高效,对于单个cpu的环境来说,效率最高。Serial仍然是jvm在Client模式下默认的新生代垃圾收集器。
- ParNew (Serial + 多线程+复制算法)
ParNew是Serial的多线程的版本,使用的也是复制算法,它在垃圾收集时也要暂停所有其他的线程。
可以通过 -XX:ParallelGCThreads 限制多线程个数。ParNew是jvm在Server模式下默认的新生代垃圾收集器。
- Parallel Scavenge (多线程复制算法)
Parallel Scavenge也是一个新生代垃圾收集器,它的吞吐量很高(吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间),也就是用户代码运行时间/CPU总消耗的时间)。
- Serial Old (单线程标记整理算法)
是Serial的老年代版本,同样是单线程的。用的是标记整理算法。主要运行在Client的默认老年代垃圾收集器。
- Parallel Old (多线程标记整理算法)
是Parallel Scavenge的老年代版本,jdk1.6开始提供。使用这个和新生代的Parallel Scavenge可以提高整体高吞吐量。
- CMS (多线程标记清除算法)
老年代垃圾收集器,可以获取最短垃圾回收停顿时间,交互性比较高,提高用户体验。
有4个阶段:
1. 初始标记:标记GC Roots能直接关联的对象,速度很快,不过要暂停所有工作线程。
2. 并发标记:对GC Roots 的跟踪的过程,和用户线程一起工作,不用暂停工作流程。
3. 重新标记:并发标记期间,用户程序运行,导致标记产生变动的一部分对象的标记,要暂停所有工作线程重新标记。
4. 并发清除:清除GC Roots 不可达对象,和用户线程一起工作,不用暂停工作线程。
也就是CMS种内存回收和用户线程其实是并发执行的。
- G1(分区,标记整理算法,优先级列表)
基于标记整理算法,不产生内存碎片。
可以精确的控制停顿时间,在不牺牲吞吐量前提下,低停顿的垃圾回收。
会把堆内存划分大小固定的独立区域,并跟踪这些区域的垃圾回收进度,同时在后台维护一个优先级列表,每次根据收集时间,优先回收垃圾最多的区域。G1能在有限时间获得最高的垃圾收集效率。
JVM使用的是准确式GC。JVM使用一组 oopMap 的数据结构,来存储所有的对象引用(空间换时间) ,不会把所有的指令都生成oopMap ,只会在安全点上生成oopMap ,在安全域上开始GC。 oopMap可以使HotSpot快速且准确的完成GC Roots的枚举(可达性分析)。GC Roots是垃圾收集器要回收的对象。
四、GC日志分析
PSYoungGen、ParOldGen 、PSPermGen 属于Parallel收集器。
PSYoungGen表示gc回收前后新生代的内存变化
ParOldGen表示gc回收前后老年代的内存变化
PSPermGen表示gc回收前后方法区的内存变化
young gc主要是针对新生代进行内存回收比较频繁,耗时短;
full gc 会对整个堆内存进行回收,耗时长,所以一般尽量减少full gc的次数,full GC频繁可能原因是老年代空间满了。
五、GC调优
Sun JDK监控和故障处理命令有 :
jps 显示指定系统内所有的HotSpot虚拟机进程。
jstat 用于监视虚拟机运行时状态信息的命令,可以显示出虚拟机进程中的类装载,内存,垃圾收集,JLT即时编译等运行数据
jmap 用于生成heap dump 文件
jhat 与jmap搭配,分析jmap生成的dump,jhat内置了一个微型的HTTP/HTML服务器,生成dump的分析结果后,可以在浏览器查看
jstack 用于生成jvm当前时刻的线程快照
jinfo 实时查看和调整虚拟机运行参数
我们可以使用jdk自带的监控工具:jconsole和jvisualvm 第三方有:MAT,GCHISTO
六、JVM类加载机制
首先什么是类的加载:是将类的class文件中的二进制数据读入到内存中,然后把它放在运行时数据区的方法区内,在堆中创建一个class对象,用来封装类在方法区内的数据结构。最终产品是堆中的对象,这个对象封装了类在方法区内的数据结构。
类加载机制分为5个部分:加载、验证、准备、解析、初始化。
分别解释:
- 加载:这个阶段会在内存中生成一个代表这个类的class对象,作为方法区这个类的各种数据的入口。加载不一定要从class文件中获取,也可以从jar或war包读取,也可以在动态代理是计算生成。
- 验证: 为了确保class文件的字节流中包含的信息是否符合当前虚拟机的要求。
- 准备: 这个阶段是正式为类变量(静态变量)分配内存并设置类变量的初始值的。就是在方法区分配这些变量使用的内存空间。 比如一个静态变量定义为:public static int a = 10; 那么在准备阶段,变量a 初始值是0 ,10是在程序被编译后才给a赋值10的。这个赋值指令是存放在类构造器client方法中的。但是如果声明为final 类型的,那么就会在准备阶段给a赋值10。
- 解析:指虚拟机将常量池中的符号引用替换为直接引用的过程。
符号引用:引用的目标并不一定要已经加载到内存中。是字面形式明确定义在JVM规范的class中。
直接引用: 可以是指向目标的指针,相对偏移量或是一个间接定位到目标的句柄,只要有了直接引用,那么引用的目标一定已经在内存中存在的。 - 初始化:是最后一个阶段,除了在加载阶段可以自定义类加载器,其他操作都是由JVM做的,初始化阶段,才会开始执行类中定义的Java程序代码。初始化阶段是执行类构造器client方法的过程。这个方法是编译器自动收集类中的类变量的赋值以及静态块中的语句合并而成的。如果一个类中没有静态变量和静态语句块,那么编译器可以不为这个类生成client方法。
几种情况不会执行类初始化:
- 通过子类引用父类的静态字段,只会触发父类的初始化,子类则不会。
- 定义对象数组,不会触发该类的初始化。
- 通过类名获取class对象,不会触发类的初始化。
- 通过Class.forName加载指定类时,如果指定参数initialize为false时,不会触发,因为这个方法就是告诉虚拟机要不要触发初始化。
- 通过类加载器默认的loadclass方法,也不会触发初始化。
接下来是加载阶段的类加载器:
加载是在JVM外部实现的,可以让应用程序自己决定获取的需要的类
JVM提供了3种类加载器:
- 启动类加载器: 负责加载JAVA_HOME\lib目录中的,或者通过 -X bootclasspath参数指定路径中的,并且被JVM认可的类,如 aa.jar。
- 扩展类加载器: 负责加载 JAVA_HOME\lib\ext 目录中的,或者通过java.ext.dirs系统变量指定路径中的类库。
- 应用程序类加载器: 负责加载用户路径(classpath)上的类库。
- 我们可以继承java.lang.ClassLoader去自定义类加载器。
双亲委派模型
jvm用此模型完成对类的加载。
双亲委派就是:一个类收到类加载请求,他不会自己去尝试加载这个类,而是把这个请求委派给父类去完成,父类又给它的父类,所以所有的类加载请求都会传到启动类加载中,而只有当父类加载器说它不能完成这个请求时,子类加载器才会自己去尝试加载。
使用优点是:比如加载位于aa.jar 包中的类 Java.lang.Object ,不管哪个类加载器去加载这个类,最终都是委托给启动类加载器去加载,这样保证了使用不同的类加载器最终得到的 都是同一个Object对象。也可以防止内存中出现多份同样的字节码,比较安全。
值得注意的是:
-
类加载器成功加载某个类后,会把得到的Java.lang.class类实例缓存下来,下次请求加载该类会使用缓存的实例,不会尝试再次加载。
-
实际开发中,可以通过自定义ClassLoader,并重写父类的loadClass去打破这一机制,SPI(服务提供发现)就是打破了这一机制的。
记录几个问题
一、如何在堆中给对象分配内存?
两种方式:指针碰撞和空闲列表。
- 指针碰撞:如果Java堆中内存是绝对规整的,所有用过的内存放一边,空闲的内存放一边,中间放一个指针作为分界点的指示器,分配内存就只是把指针向空闲空间那边挪一段和对象相等的空间,这就是指针碰撞。
- 空闲列表:有一个列表,记录哪些内存块有用,分配内存时从列表找到足够大的空间划分给对象实例,然后更新列表的记录,这就是空闲列表。
二、内存泄露(OOM)和内存溢出的解决方案
- 首先内存泄露的原因是:对象是可达的(一直被引用) ,但是对象不会被使用。或者死循环让堆内存空间用满。
常见的就是new一个对象放到Set集合里,但是把它设为空了。这时我们用不上这个对象,但是一直被Set引用。
解决方案:
把set也设置为null,一步一步排查就可以了。 - 内存溢出:
内存泄露导致堆栈内存不断增大,从而内存溢出。
大量的jar,class文件加载,装载类的空间不够,造成溢出。
操作大量的对象导致堆内存空间已经用满了,造成溢出。
nio直接操作内存,内存过大导致溢出。
解决:
查看是否有内存泄露的问题,
设置参数加大空间,
检查代码是否形成死循环或循环是否产生过多重复的对象实体,
查看是否用了nio直接操作内存。
三、内存泄露排查
内存泄露:堆空间爆满,因为大量对象被放到堆中,
JVM参数中我们可以使用 -Xmx10m 设置堆内存极限值为10m,
那么我们可以通过heap dump文件分析,这个dump文件就是某个时刻的Java进程的内存快照(就是把Java进程的内存以某种格式持久化到磁盘上),
可以通过参数 -XX:+HeapDumpOnOutOfMemoryError 去生成一个以.hprof 后缀的文件,
然后我们可以通过使用 IBM heapAnalyzer的dump分析工具去分析泄漏点在哪。也就是 ha456.jar 包。
四、类的实例化顺序:
- 父类静态成员和静态块 2. 子类静态成员和静态块 3. 父类实例成员和实例块 4. 父类构造方法
5.子类实例成员和实例块 6. 子类构造方法
五、永久代替换成元空间的问题
jdk1.7以前的永久代,在jdk1.8时替换成元空间。
永久代存储的数据:符号引用转移到了本地方法的堆;字面量转到了Java 堆;类的静态变量转移到了Java 堆。
元空间本质上和永久代类似,都是jvm规范的方法区的实现,他们最大的区别在于:元空间并不是在虚拟机中,而是使用本地内存。
替换的好处: 字符串存在永久代中,容易出现性能问题和内存溢出 。 永久代会给GC带来不必要的复杂度,回收效率偏低。
六、OSGI (动态模型系统)
面向Java的动态模型系统,是Java动态化模块化系统的一系列规范。
- 动态改变构造:OSGI服务平台提供在多种网络设备上无需重启的动态改变构造的功能。为了最小化耦合度和促使这些耦合度可管理,OSGI提供一种面向服务的架构,使这些组件动态的 发现对方。
- 模块化编程与热插拔
小结
- jdk包含jre和一些其他工具组件,jre是java运行环境,jre包含Java HotSpot VM (jvm)。
- 我们常说的JVM调优一般是一些JVM的内存参数配置的调整,比如新生代和老年代的内存空间的大小等等。我们一般会查看堆空间,线程资源,内存热点,内存泄露和溢出的检查及解决。通过一系列手段获得信息并加以调整。
- 垃圾回收的瓶颈在于,我们使用传统分代收集方式,把吞吐量提高了一个极限,但是解决不了的一个问题就是Full GC带来的应用暂停。 那么我们开发中对于一些实时性要求很高的环境下,GC的暂停是很难接受的。所以对于这种情况,使用增量收集的方式可以解决这种问题,增量收集其实就是分区收集,我们使用的G1其实就可以解决这个问题,它吸收了CMS的特点,可以做到高吞吐,实时性强等优点。
最后
希望各位看官看完文章能多回顾回顾,学完以后做出总结。觉得写的还不错的请点点关注,如果文章有错误或者不足请不吝指出。关于一些优质面试题,我也会不定期分享。