阅读《深入理解Java虚拟机-JVM高级特性与最佳实践》.周志明 笔记
上一篇文章记录了自己学习JVM运行时数据区,对内存几个区域的划分有了了解,以及会遇到的一些OOM的问题。使用1.7和1.8的JDK环境跑了几个程序,结果有些不一样。为什么有些不一样,带着问题进行第三章的阅读:垃圾收集器与内存分配策略。
目前内存动态分配和内存回收技术已经相当成熟,一切看起来都进入了“自动化”时代,那么为什么我们还要去了解GC和内存分配呢?答案很简单:当需要排查各种内存溢出、内存泄露问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
-----引自 《深入理解Java虚拟机-JVM高级特性与最佳实践》第3章
JVM运行时内存划分,程序计数器、虚拟机栈、本地方法栈三个区域随线程而生,随线程而死;栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。每一个栈帧分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆和方法区就不一样,一个接口的多个实现需要的内存可能不一样,一个方法的多个分支需要的内存也可能不一样,只有在程序运行期间才能知道会创建哪些对象,这部分内存的分配和回收都是动态的,垃圾收集器所关注的是这部分内存。
其实按照我个人理解,根据每个区域存储的东西来看更直观,特别是堆(Java Heap)用来存储对象实例,那么Java程序中Java对象实例占用的存储本来就很大,而且这些对象实例是后续是否会被用到(或者说后续程序不会用到),那么可能就没有再存储在内存空间了,所以去主动的释放这一部分内存空间,才会使得GC的工作有价值。
欲回收,必先知何可收
在Java Heap里面存放着Java世界几乎所有的对象实例,垃圾收集器在对垃圾进行回收前,第一件事情就是要确定这些对象之间哪些还“活着”、哪些已经“死去”(即不可能再被任何途径使用的对象)。为了做好第一件事情,可以后很多算法,如:
引用计数算法:
给对象添加一个引用计数器,每当有一个地方引用它时,计数器就加1;当引用失效时,计数器就减1;任何计数器为0的对象就是不可能再被使用的。客观讲,引用计数法实现简单,判定效率高。但是目前主流的JVM并没有使用这个算法。主要原因是它很难解决对象之间相互循环引用的问题。
可达性分析算法:
可达性分析算法基本思想:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连时,则证明此对象是 不可用的。那么在Java语言中,可作为GC Roots的对象包括以下几种:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
关于引用:
从JDK1.2之后,Java将引用分为四种:
强引用:类似"Obejct obi = new Object();" 这类引用,只要强引用还存在,;垃圾收集器永远不会回收掉被引用的对象。
软引用:用来描述一些还有用但并非必须的对象。对于软引用关联着的对象,在系统中将要发生OOM之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收还没有足够的内存,将会抛出OOM异常,JDK1.2之后提供了SoftReference类来实现软引用。
弱引用:用来描述非必须对象的,但是它的强度要软引用更弱一些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当 垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在JDK1.2之后,提供了WeakReference类来实现弱引用。示例代码:
/**
* @date 2019/3/18 0018 下午 3:56
* @Description 弱引用使用方式
*/
public class WeakReferenceDemo {
public static void main(String[] args){
Car car = new Car();
WeakReference<Car> weakCar = new WeakReference<Car>(car);
// 当要获得weak reference引用的Obejct时
// 首先需要判断它是否已经被回收
// 如果get返回为null,则其指向的对象已经被回收
if(weakCar.get()!=null){
//TODO
//do somthing
}
}
}
虚引用:也被称为幽灵引用或者幻影引用。一个对象是是否存在虚引用,完全不会对其生存时间构成影响,也无法通过虚引用来获取一个实例。为一个对象设置虚引用关联的唯一目的就是能在这个对象被垃圾收集器回收时收到一个系统通知。在JDK1.2之后,提供了PhantomReference类来实现虚引用。
回收方法区:
首先方法区的垃圾回收效率是很低的。
方法区的垃圾回收,主要是永久代的垃圾回收,永久代的垃圾回收分为两部分内容:“废弃常量”和“无用的类”。
如字符串“abc”没有被String对象引用了,那么就会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也是类似。
需要注意的是一个类是否“无用的类”的条件则相对苛刻,需要满足以下三个条件(满女条件可以回收,不是必然回收,可设参控制):
- 该类所有的实例都已经被回收,也就是说Java堆中不存在该类的任何实例;
- 加载该类的ClassLoader已经被回收;
- 该类对应的java.lang.Class对象没有被任何地方引用,无法再任何地方通过反射访问该类的方法。
**************************************************************分割线****************************************************************************************
前面的部分是介绍对象存活判定算法。接下来需要解锁一下垃圾收集的姿势(知识)。
书上讲的很清楚,各个平台的虚拟机操作内存的方法各不相同,所以无法去一一介绍具体程序的代码实现。只了了解到垃圾收集算法即可。
垃圾收集算法
"标记-清除"算法:顾名思义,两个阶段,首先标记处多有需要回收的对象(前文中提到,可达性分析算法),标记完成之后统一回收多有被标记的对象。缺点:一个是效率问题,标记和清除两个过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能导致以后再程序运行过程中需要分配较大对象时,无法找到足够的连续内训而得不到提前触发另一次垃圾收集动作。
"复制"算法:复制算法是为了解决“标记-清除”算法效率低的问题,它将可用内存按容量划分为大小相等的两块,每次只使用其中一块。当一块内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉!这样每次都可以对整个搬去进行内存回收,也不用考虑到内存碎片等复杂情况。但是付出的代价就是可用内存缩小为原来的一半,代价有些高!现在很多商业虚拟机都采用这种回收算法来回收新生代,因为新生代的对象98%是“朝生夕死”,所以使用区域和备用区域也不是按照1:1划分,而是将内存华为为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地复制到另外一块Survivor。最后清理掉Eden和刚才使用过的Survivor空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8:1。当然这样的比例划分是经过研究的。但是针对有些项目工程来说可能就是会超过10%的对象存货下来,那么当Survivor空间不够使用时,就需要依赖其他内存(此处指老年代)进行分担分配担保。所以,如果另外一块Survivor空间没有足够空间存放上一次新生代收集下来的存货对象时,这些对象将直接通过分配担保机制进入老年代。
“标记-整理”算法:复制收集算法在对象存活率较高时就要进行较多的复制操作,效率会跟着下降。另外,如果不想浪费50%的空间,就需要额外的空间进行分配担保,以应对被使用内存中所有对象100%都存活下来的极端情况,所以在老年代一般不能直接使用这种算法。所以针对老年代的特点,“标记-整理”算法就出来了。在“标记-清除”算法基础之上,在后续的步骤中,不是直接对可回收的对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
“分代收集”算法:其实前两种算法当中已经提到了新生代和老年代,当前商业虚拟机的垃圾收集都采用了“分代收集”算法,根据对象存活周期的不同,将内存划分为几块。一般是把Java堆划分为新生代和老年代。那么新生代就是用“复制”算法,而老年代就使用“标记-清除”或者“标记-整理”算法。
**************************************************************分割线****************************************************************************************
简单学习一下HotSpot的算法实现
为什么要了解HotSpot呢?看上图!
枚举根节点
第一部分提到现在用的主流虚机在判断对象是否存活的时候,使用可达性分析算法。即,从GC Roots节点找引用链,可以作为GC Roots的节点主要在全局性的引用(例如方法区中的常量或类静态属性(在1.8中,永久代不存在,取而代之的是元空间,不与堆相连的一段内存))。
在可达性分析期间,分析工作必须在一个能确保一致性的快照中进行——一致性是指在真个分析期间整个执行系统看起来就像被冻结在某个时间点上,不能再出现对象引用关系发生变化的情况。所以这点要求导致GC进行时必须停顿所有的Java执行线程。
插入一个概念“准确式GC”:虚拟机知道内存中某个位置的数据具体是什么类型!由一组称为OopMap的数据计算出来。
在OopMap的协助下,枚举根节点可以很快完成。
安全点:能让Java工作线程停顿下来,可以进行GC的位置。一般在指令序列复用的地方,如方法调用、循环跳转、异常跳转等,在这些功能的指令下才会产生安全点(Safepoint)。
安全区:指在一段代码片段之中,引用关系不会发生变化。所以在这个区域中任意的地方开始GC都是安全的。
**************************************************************分割线****************************************************************************************
垃圾收集器
内存回收是如何进行的,是由虚拟机所采用的GC收集器决定的,而通常虚拟机中往往不止有一种GC收集器。所以接下来还是要了解一下垃圾收集器的。
前面的收集算法是内存回收的方法论,垃圾收集器就是内存回收的实现。
Java虚拟机规范没有对垃圾收集器做任何要求,由不同厂商去做实现。
Serial收集器:该收集器属于单线程的收集器,不仅意味着它只会使用一个CPU或者一条收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集时,必须暂停其他所有工作线程,知道它收集结束。优点:简单高效,单CPU的环境来说,Serial收集器由于没有线程交互的开销,分配给虚拟机管理的内存一般来说不会很大,专心做垃圾收集自然获得最高的单线程收集效率。两百兆内新生代的收集,停顿时间再百毫秒之内。所以说,收集不频繁的情况下,是可以接受的。(JVM Client模式下,默认的新生代收集器)
ParNew收集器:该收集器其实就是Serial收集器的多线程版本(线程数默认为CPU的数量,可以通过参数-XX:ParallelGCThreads来限制)。除了使用多条线程进行垃圾收集之外,其余行为包括所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都和Serial收集器完全一样。(需要注意的是,只在新生代使用),永久代没有。(许多运行在Server模式下的虚机中首选的新生代收集器),除了性能原因之外,就是目前除了Serial之外只有ParNew能与CMS收集器配合使用。
预备知识:提到垃圾收集器,会有并行和并发两种。并行是指:多条垃圾收集线程并行工作,但是此时用户线程仍然处于等待状态;并发是指:用户线程和垃圾收集线程同时执行(不一定是并行,有可能是交替进行),用户线程在运行,而垃圾收集程序运行在另一个CPU上。
Parallel Scavenge收集器:首先,这种收集器也是用于新生代的并行收集器,采用复制算法。是一种“吞吐量优先”收集器,也就是说它的关注点是在“吞吐量”(参数可设置,设置最大停顿时间或者吞吐量大小的设置两个参数)。另外这种收集器有个好处是:虚拟机会根据当前系统的运行情况收集性能监控信息,动态调用这些参数以提供最合适的停顿时间或者最大吞吐量,这种调节方式成为GC自适应的调节策略(-XX:+UseAdaptiveSizePolicy)。
补充知识:关于上边提到“吞吐量”的问题。所谓吞吐量就是CPU用于运行用户代码的时间和CPU总耗时的比值,即:吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)。那么适合什么样的场景呢?高吞度量可以高效地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。而停顿时间约旦就越适合需要与用户交互的程序,良好的响应速度能提升用户体验。
Serial Old收集器:该版本是Serial收集器的老年代版本,它同样是一个单线程收集器,使用“标记-整理”算法。这个收集器的主要意义在于给Client模式下的虚拟机使用。若果在Server模式下,那么它还有两大用途:一种是在JDK1.5之前的版本中与Parallel Scavenge收集器搭配使用,另外一种用途是作为CMS收集器的后备预案,在并发收集器发生Concurrent Mode Failure时使用。
Parallel Old收集器:Parallel Old是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。
CMS收集器:该收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用集中在互联网站或者B/S系统的服务器上,这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS基于“标记-清除”算法实现。该收集器的收集过程分为四步:
- 初始标记(CMS initial mark),只标记一下GC Roots能直接关联到的对象,速度快。
- 并发标记(CMS concurrent mark),进行GC Roots Tracing的过程
- 重新标记(CMS remark),为了修正并发标记期间因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。这段时间会比初始标记时间稍长,远比并发标记时间短。
- 并发清除(CMS concurrent sweep )
这四个步骤中初始标记和重新标记过程是需要Stop The World的。而并发标记和并发清除耗时较长,但是可以与用户线程一起进行。
CMS缺点:(1)对CPU资源非常敏感(因为并发标记和清除都需要占用CPU);(2)无法处理浮动垃圾(一次标记之后到下次标记之前,用户线程产生的垃圾),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生;(3)由“标记-清除”算法引起的,容易产生比较多的空间碎片。可能有人会问为什不使用“标记-整理”算法呢?整理是需要时间的呀!
G1收集器:当今收集器技术发展的前沿成果之一。面向服务端应用的垃圾收集器。G1具备的特征:
- 并发与并行,充分利用多CPU(CPU或CPU核),停顿时间缩短,甚至可以 将收集和用户线程并发执行;
- 分代收集,分代概念在G1中保留。但是G1可以不与其他收集器配合。而且对熬过多次GC的旧对象有更好的回收效果;
- 空间整合,整体基于“标记-整理”,局部采用“复制算法,所以不会产生空间碎片,分配大对象时,不会因为无法找到连续内存空前而提前触发下一次GC;
- 可预测的停顿,同CMS一样关注降低停顿时间,而且还能建立可预测的停顿时间模型(将堆划分成多个Region进行监控),能让使用者明确指定一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。接近实时Java的垃圾收集器特征了。
G1收集器运作大致分为几个步骤(不考虑一些数据维护工作):
- 初始标记
- 并发标记
- 最终标记
- 筛选回收
关于G1的应用场景,毕竟G1追求低停顿,所以如果应用追求低停顿的话,可以尝试G1。但是如果追求高吞吐就没有什么优势了。
关于垃圾收集器的总结(有线相连,可以搭配使用):
收集器名称 | 工作方式 | 关注点 | 收集算法 | 适用内存区域 |
Serial | 单线程 | 短停顿时间 | 复制 | 新生代 |
ParNew | 并行 | 短停顿时间 | 复制 | 新生代 |
Parallel Scavenge | 并行 | 高吞吐量 | 复制 | 新生代 |
CMS | 并发 | 短停顿时间 | 标记-清除 | 老年代 |
Serial Old | 单线程 | 短停顿时间 | 标记-整理 | 老年代 |
Parallel Old | 并行 | 吞吐量 | 标记-整理 | 老年代 |
G1 | 并发 | 短停顿时间 | 整体“标记-整理”,局部复制 | 新生代和老年代 |
**************************************************************分割线****************************************************************************************
内存分配与回收策略
Java技术体系所提倡的自动内存管理最终可以归结为自动化解决两个问题:给对象分配内存以及回收分配给对象的内存。而前边的大篇幅内容讲的都是关于回收的内容。那么下边还是需要了解一下分配内存的事情。
对象的内存分配,大方向讲,就是在堆上分配(但也可能经过JIT变异后被拆散为标量类型并间接地栈上分配),对象主要分配在新生代的Eden上,如果启动了而本地线程分配缓冲,将按线程优先在TLAB上分配。少数情况下可以能直接分配在老年代(与分配策略有关,后文会提到),分配规则并不是固定不变的,细节决定于当前使用的垃圾收集器组合以及虚拟机中和内存相关的参数设置。几种内存分配策略如下:
1.对象优先在Eden分配
大多数情况下,对象在新生代Eden区中分配。当Eden区没有足够空间进行分配时,虚拟机将发起一次Minor GC。
补充知识:
GC类型(按照范围) | 收集范围 | 触发条件 |
Minor GC | 新生代 | Eden区满 |
Major GC | 老年代 | 一般由Minjor GC触发 |
Full GC | 整个堆(新生代和永久代) | (1)调用System.gc,系统建议执行Full GC,但是不必然执行; (2)老年代空间不足; (3)方法区空间不足; (4)通过Minor GC后进入老年代的对象大小大于老年代的可用内存; (5)永久代满时,并且导致Class、Method元信息的卸载(只针对还存在永久代的虚机) |
备注:关于Major GC和Full GC没有什么能说的很明白的资料,不做详细了解先 |
2.大对象直接进入老年代
所谓的大对象是指:需要大量连续内存空间的Java对象。最典型的对象就是很长的字符串以及数组。经常出现大对象容易导致内存还有不少空间时就提前触发垃圾收集以获取足够的连续空间来“安置”它们。虚拟机提供-XX:PretenureSizeThreshold参数设置。
3.长期存活的对象将直接进入老年代
虚拟机给对象定义了一个对象年龄计数器。对于产生于Eden区的对象,经过一次Minor GC并且移动到了To Survivor Space,那么年龄就增1。虚拟机提供参数-XX:MaxTenuringThreshold(默认15)。
但是不一定到这个阈值。还有一种情况:如果在From Survivor Space空间中相同年龄的对象大小之和大于这个空间的一半,那么年龄在这个值以及之上的对象都会被移动到老年代。
4.空间分配担保
为了说明为什么需要空间担保。来描述一个场景:“在Minor GC过程中,出现了极端的情况新生代中的所有对象都存活下来的,所以在进行复制的时候,就需要有一部分对象进入到老年代了,但是老年代可能无法容纳下这些对象。”所以老年代的空间就需要有一个保障。
在JDK 6 update 24之前的处理方式
(1)检查老年代最大可用的连续空间是否大于新生所有对象总空间,如果成立,直接到(4);
(2)如果不成立,虚拟机查看HandlePromotionFailure设置值是否允许担保失败,如果允许进入(3),否则进行(5)
(3)检查老年代最大可用连续空间值是哦福大于历次晋升到老年代对象的平均大小,如果大于,则进行(4),否则(5)
(4)Minor GC
(5)Full GC
之后的处理方式(福利之音):
只判断老年代连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则就Full GC。