前言
在上一章节我们了解到了JVM 的内存划分以及类的加载过程,那么这一章节我们通过以下三个面试问题了解下JVM 如何回收内存以及多线程情况下 Java 内存模型如何保证可见性、原子性以及有序性。
1. JVM 有哪些垃圾回收算法,以及对应的收集器有哪些?
2. Java 的内存模型( JMM )如何保证线程之间的通信、同步?
3. 如何针对 GC 问题进行 JVM 的参数调优?
1. 垃圾回收算法以及垃圾收集器
这个可以说是 jvm 里面我觉得较为复杂的内容,由于垃圾收集器各有各的优缺点以及对应的应用场景,因此选择什么样的垃圾收集器也成了 jvm 优化的一大难题,不过本章节不过多讨论垃圾收集器,需要了解这方面的读者可以参考垃圾收集器介绍。
为了让读者能快速了解 GC 的内容,这章节主要以简单例子为主介绍垃圾回收算法以及对应的垃圾回收器。
首先我们要知道哪些对象是属于需要被回收的,那么这个时候有两种办法:
- 引用计数法:通过对象被引用的次数是否为0判断对象存活,但由于存在相互引用导致无法判断,被废弃。
- 可达性分析法:被栈中引用的对象、类变量引用的对象以及常量引用的对象都会作为 GCRoot,而只要其他对象与 GCRoot 有关联,则该对象无需回收,反之,需要清理。
可达性分析法中,涉及的对象引用为强引用,如果是软引用、弱引用或者虚引用,则会通过另外的回收方式进行判断以及回收。
GCRoot可以理解为是指向栈中引用的对象、类变量引用的对象以及类常量引用的对象的一组引用,如果一个对象不属于GCRoot指向的对象或者没有与这些对象进行关联,则该对象可以被判断为死亡
通过上面两种方法将对象进行生死分类后,就可以利用相应的回收算法进行回收了。在回收之前,我们来看看GC是如何看待堆内存的。
从上图可知,GC 将堆分成了两大部分,分别是新生代和年老代,针对这两个部分的垃圾回收,分别叫 Minor GC 和 Major GC,两个 GC 都执行则叫Full GC。这种处理方式叫做分代收集算法;
而新生代又分成三个部分,分别是 eden 区和两个 s 区,这里涉及到的算法则是复制算法。
其中 s0 和 s1 可以理解为是两块一样的内存,每次进行 Minor GC 的时候,会将eden 区存活的对象复制并存放到其中一个 s 区,暂时叫区域 s0,然后删除其余新生代空间,在下次 Minor GC 的时候,则将 eden 和 s0 存活的对象复制并存放到另一个 s 区,这个区域我们叫 s1,那么,新生代 survivor 区域里面存活的对象便会在 s0 和 s1 之间“左右横跳”,当横跳的次数到达15次(jvm默认),便会存放到年老代中,这种垃圾回收算法称为复制算法。至于哪个叫 s0 哪个叫 s1 是无所谓的,只是为了清理的过程好理解,本质两块是一样的
而针对年轻代使用了复制算法的垃圾收集器有 Serial New 以及 Parallel New。而由于新生代对象存在太多的朝夕生死,因此使用复制算法可以减少内存碎片的产生,每次将对象复制到一片连续的内存,然后将其他内存清空,保证了内存块的连续性。但是如果有一个大对象,内存占用大于年轻代的剩余连续空间,那么该对象有可能直接进入到年老代。
针对年老代,由于年老代的对象不经常被判断死亡,因此垃圾收集器会将死亡的对象标记起来,等待下次GC的时候针对被标记的对象进行清理,这个过程或者算法我们叫做标记-清除算法。
标记整理算法有个坏处,便是容易产生内存碎片,如果有新的、占用较大内存的对象进入年老代,这个时候就有可能出现OOM问题。
因此,为了优化这个算法,垃圾收集器会先标记要回收的对象,并将存活对象移至一端,最后清理端边界以外的内存,在 Major GC 的时候只要删除一侧就行,并且保证了连续内存的空间,这个算法我们称之为标记-整理算法。
其中,针对年老代使用了标记-清除算法的垃圾收集器有CMS收集器,而使用了标记-整理算法的垃圾收集器有Serial Old 以及 Parallel Old。
我做了一张图总结了上面的知识点,大家可以根据知识点进行一个大概的印象记忆:
顺带说一句,此章节只描述堆内存的回收,而jdk1.8后的元空间里面的直接内存回收稍微比较复杂,只要知道直接内存也存有相应的垃圾回收即可;除了分代收集算法外,新版本的jdk提出了分区收集算法,而实现了这个算法的垃圾收集器则是G1,G1也大体使用了标记整理算法,在某些具体环境下也会使用复制算法,以上两点更多详细的内容请读者去相关博客查看,这里不再阐述。
2. Java的内存模型(JMM)如何保障多线程同步
首先我们要知道,为什么多线程会出现数据的不一致,导致这个问题的最主要原因目前大多数CPU都是多核,并且每个核心有自己的寄存器或高速缓存,而这些寄存器/高速缓存便是导致资源不同步的根本。
JVM 基于共享变量模型提出了 JMM,其将共享变量存储在主内存中,而每条线程将主内存的变量拷贝一份存储在工作内存(寄存器\高速缓存)当中,线程不能直接操作主内存中的变量,而不同线程间无法直接访问对方的工作内存变量,从而导致最终各自线程将拷贝的变量写回到主内存的时候出现数据的不一致问题。除此之外,JVM会在编译期间进行一些优化,如代码执行顺序的重排序,如果不对重排序进行一些规范化的操作,也会容易出现多线程的同步问题。我们通过下图简单了解下JMM的共享内存模型:
上图很明显的展示了 java 线程修改共享变量的过程:
Ⅰ. 将共享变量拷贝到高速缓存
Ⅱ. 对拷贝的变量进行读/写
Ⅲ. 将拷贝变量写回主内存
为什么现代的 CPU 需要高速缓存?举个例子,初期有个加工厂,负责某样产品的开发,但是前期订单需求小(CPU处理速度慢),车间里面只需要人员手工加工即可(车间的人工工作理解为主内存的读写),后期随着时代发展,订单猛增(CPU快速发展),导致纯人工的生产速度跟不上,工厂的整体生产速度到了瓶颈,因此工厂的老板便通过购买相应的机器来处理部分订单(高速缓存来了),但是机器产生的产品最终还是得员工进行打包整理(高速缓存和主内存对接),为什么不用大量机器代替更多人工,这里涉及到成本问题(高速缓存很昂贵),因此,通过机器的引进,整个工厂的生产速度又跟上步伐了。但是由于机器和机器之间是不会沟通的(高速缓存与高速缓存之间是隔离),如果我们要整理目前的生产情况,最终还是得由车间员工进行整理再统一分配任务到机器上(高速缓存的数据最终还是得写回主内存才能进行进一步协作)。
所以高速缓存主要是用来作为一个缓冲区,目的是为了解决低速的主存读写与高速的CPU处理的不匹配的问题,但是相应的会带来数据可见性的问题。
因此,为了解决共享内存模型以及编译器优化带来的问题,JMM 通过定义happens-before与 as-if-serial 规则来解决高速缓存以及代码重排序带来的多线程同步问题,而其底层则是通过内存屏障指令来完成 happens-before 的实现。
其中,两个操作满足了happens-before规则,则说明其中一个操作对另一个操作可见,所谓的可见就是一个操作更改了共享变量A,另一个操作可以立即得知共享变量A被修改,从而保证数据的可见性问题;而as-if-serial则规定在单线程环境下,程序的执行结果不会因为重排序导致改变。具体的 happens-before 规则可以参考happens-before。
本文章主要介绍happens-before是如何保证并发情况下的原子性、可见性以及有序性:
操作 | |
---|---|
原子性 | synchroized关键字、lock对象 |
可见性 | synchroized关键字、volatile关键字 、final关键字 、lock对象 |
有序性 | synchroized关键字、volatile关键字 、lock对象 |
最后总结一下,JMM 并没有完全限制执行引擎的优化,因此默认情况下还是会出现高并发的同步问题,但是他为开发人员提供了 happens-before 原则,让开发人员自己选择在合时、何处需要保证多线程的同步,简单来说就是给开发人员提供了优化后的多线程同步工具,让开发人员在高性能以及同步之间根据具体需求进行折中处理。
3. 如何针对GC进行JVM参数设置调优?
首先,一般是业务层面已经无法进一步优化,才会考虑进行GC调优;
GC调优的主要目的是减少转移到年老代的对象数目,减少GC执行的耗时以及次数。
有关gc调优的参数有以下几种:
示例参数 | 描述 |
---|---|
-Xmn1800m | 新生代最大可用值1800M |
-XX:MaxTenuringThreshold | 对象进入年老代的最低年龄值,默认是15,及对象经过15次GC就进入年老代,并且该值的范围只能是0~15 |
-XX:PrintGC | 触发GC时日志打印 |
-XX:PrintGCDetails | 触发GC时日志打印更详细 |
-XX:PretenureSizeThreshold | 直接进入年老代的对象大小门槛 |
- 大部分时候,分析GC日志比直接调整参数可能更加实际,那么我们需要设置-XX:PrintGCDetails为开启状态,方便以后查看相关的 GC 日志信息,具体的日志分析读者可以自行搜索或者后期我会单独出相关的文章进行解析。
- 项目中经常生成新的大对象,小对象相对较少,可以考虑将新生代最大值设置大一点,减少大对象直接跨过 eden 区进入年老代,导致频繁major GC,同时新生代设置大一点也会有利于秒杀系统的运作;
- 项目中只有少数经常用的大对象,而小对象相对较多,则可以将新生代最大值设置小一点,减少大对象占用大量的新生代导致不少小对象直接进入年老代。
- 项目中多数大对象占用的空间大于等于某个大小,那么可以直接通过-XX:PretenureSizeThreshold参数来设置一个门槛值,大于这个值的直接进入年老代,前提是这些对象必须是相对稳定且活跃的对象。
在这里提一下内存泄漏以及内存溢出。
前者是由于逻辑代码出现重大BUG,导致多个对象永远处于强引用状态,内存无法释放,例如无限new一个数组,然后存一个全局map,整体看来这个数组好像是没有引用,但是实际上他还被一个全局map引用;
而后者则是由于当前堆内存不足以存放下一个对象而导致,内存泄漏严重便会导致内存溢出。
有关垃圾收集器的选择,这里面涉及的因素太多,一般来说使用默认的垃圾回收器即可,下面我会列出一些垃圾收集器的组合使用方案,读者感兴趣可以自行进行更深度的学习。
新生代 | 年老代 | JVM参数 |
---|---|---|
Serial | Serial | -XX:+UseSerialGC |
Paraller scavenge | Paraller Old | -XX:+UseParallerGC; -XX:+UseParallerOldGC; |
Serial \ Paraller scavenge | CMS | -XX:+UseParallerNewGC; -XX:+UseConcMarkSweepGC |
G1 | G1 | -XX:+UseG1GC |
总结
本章节主要介绍了JVM的GC算法的运作流程,以及各个GC算法对应的收集器有哪些,如果读者想要进一步了解GC收集器的特性,可以自行通过博客等途径进行学习,网上相关信息非常多;与此同时,我们还了解了JMM的前因后果以及其制定了哪些规则和语义进行多线程的同步,在最后,我们简单了解了一些有关GC方面的调优场景和技巧。
接下来,我们回到上面的三个问题
1. JVM有哪些垃圾回收算法,以及对应的收集器有哪些?
2. Java的内存模型(JMM)如何保证线程之间的通信、同步?
3. 如何针对GC问题进行JVM的参数调优?
如果你们可以很流畅的回答这些问题,那么恭喜你,该章节的内容已经全部掌握,如果不行,希望可以回到对应问题讲解的地方,或者对某个不了解的点进行额外的知识搜索,尽量用自己组织的语言回答这些问题。