分享者:锐哥
1、运行时数据区域
JVM在执行java程序的过程中会把它所管理的内存划分成若干个不同的数据区域。
(1)程序计数器
程序计数器(Program Counter Register)是一块比较小的内存区域,它可以看作是当前线程所执行的字节码指令的行号计数器。在虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条要执行的字节码指令。
由于java虚拟机的多线程是通过线程的轮流切换并分配CPU时间片来实现的,在任何一个确定的时刻,一个核只会执行一条线程中的指令,为了线程切换后能恢复到正确的执行位置,每一条线程都需要有一个独立的程序计数器,因此,程序计数器是线程私有的内存。
(2)虚拟机栈
虚拟机栈(Java Virtual Machine Stack)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是java方法执行的内存模型:每个方法在执行的同时都会创建一个称为栈帧(Stack Frame)的东西,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从调用开始直至执行完成,都对应着一个栈帧在虚拟机栈中入栈和出栈的过程。
如果线程请求的栈深度超过了虚拟机的最大深度,那么就会抛出StackOverFlowError异常;如果虚拟机可以动态拓展并且在拓展时无法申请到足够的内存,将抛出OutOfMemoryError异常。
(3)本地方法栈
本地方法栈(Native Method Stack)和虚拟机栈一样,都是线程私有的,只不过虚拟机栈为虚拟机执行java方法服务,而本地方法栈则为虚拟机执行native方法服务。
(4)Java堆
对大多数应用程序来说,java堆(Java Heap)都是java虚拟机所管理的内存区域中最大的一块。java堆是被所有线程所共享的一块内存区域,在虚拟机启动时创建。此内存区域存在的唯一目的就是存放对象实例,几乎所有的对象都在此内存区域上进行分配。
根据java虚拟机规范的规定,java堆可以处于物理上不连续的内存空间,只要逻辑上是连续的即可。在实现时,可以实现成固定大小的,也可以实现成可拓展的。当拓展时,如果无法申请到足够的内存,将抛出OutOfMemoryError异常。
(5)方法区
方法区(Method Area)也是被各个线程所共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、JIT编译器编译后的代码等数据。
对于习惯在HotSpot虚拟机上开发、部署程序的的开发者来说,更习惯于把方法区称为”永久代“,但本质上两者并不等价。
(6)运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载以后进入方法区的运行时常量池中存放。
符号引用用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能够无歧义地定位到目标即可。符号引用与虚拟机的内存布局无关,引用的目标不一定加载到内存中。例如org.simple.People类引用了org.simple.Language类,在编译时,People类并不知道Language类的实际内存地址,因此只能使用符号来代替,这就是符号引用。
2、对象的内存布局
HotSpot虚拟机中,对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。
实例数据部分是对象真正存储的有效信息。这部分的存储顺序会受到虚拟机分配策略参数和字段在Java源码中定义顺序的影响。
3、对象的访问定位
使用句柄访问方式的话,java堆中将会划分出一块内存来作为句柄池,reference中存储的就是对象的句柄地址,而句柄中包含了对象实例和类型数据各自具体的地址信息。
使用句柄访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动时只会改变句柄中的实例数据指针,而reference本身不需要修改。使用直接指针访问的最大好处是速度更快,节省了一次指针定位的时间开销。HotSpot虚拟机采用的是直接指针方式。
4、垃圾收集器
对于第一个问题,哪些内存需要回收,就是哪些对象是不可用的。第二个问题,什么时候回收,一句话概述就是内存不够用的时候进行回收。第三个问题,就涉及到回收的具体实现上了。
4.1 对象的存活判定
(1)引用计数算法
(2)可达性分析
在Java中,可以作为GC Roots的对象包括下面几种:
a. 虚拟机栈(栈帧中的本地变量表)中引用的对象
b. 方法区中类静态变量所引用的对象
d. 本地方法栈中JNI(即Native方法)引用的对象
主流的商用程序语言(java、c#等)都是采用的可达性分析算法来判定对象是否存活。
4.2 垃圾收集算法
(1)标记-清除算法
这种算法存在两个缺点:
a. 效率问题。”标记”和”清除”两个阶段的效率都不高
(2)复制算法
这种算法的缺点是内存利用率只有50%
(3)标记-整理算法
标记-整理算法(Mark-Compact)是针对老年代的特点(对象存活率较高、没有额外空间进行分配担保)而提出的。这种算法分为标记和整理两个阶段,标记阶段和标记-清除算法的标记阶段一致,但是整理阶段不是直接对标记对象进行回收,而是让还存活的对象向一端移动,然后一次性清理掉端边界以外的内存。
(4)分代收集
新生代中,对象的存活率较低,就采用复制算法,只需要付出少量存活对象的复制成本就可以完成收集;老年代中因为对象存活率高,也没有额外空间进行分配担保,所以采用标记-清除算法或者标记-整理算法。
4.3 垃圾收集器
如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。
(1)Serial收集器
它是虚拟机运行在client模式下的新生代默认的收集器:因为它简单而高效,对于限定单个CPU的环境来说,Serial收集器因为没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。
(2)ParNew收集器
ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,一个很重要但是与性能无关的原因是:除了Serial收集器外,只有它能与CMS收集器配合工作。
(3)Parallel Scavenge收集器
停顿时间越短,就越适合需要与用户交互的程序,良好的响应速度能够提升用户体验,而高吞吐量则可以高效率地利用CPU时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。
除上述两个参数之外,Parallel Scavenge还提供一个参数 -XX:UseAdaptiveSizePolicy。如果开启了这个参数,就不需要手动指定新生代的大小(-Xmn)、Eden与Survivor的比率(-XX:SurvivorRatio)、晋升老年代对象的年龄(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为 GC自适应调节策略。
(4)Serial Old收集器
Serial Old收集器是Serial收集器的老年代版本,使用”标记-整理”算法。这个收集器的主要意义也是给client模式下的虚拟机使用。如果在Server模式下,它还有另外两个作用:一种用途是在JDK 1.5及之前的版本中与Parallel Scavenge收集器搭配使用,另一种用途就是作为CMS收集器的后备预案,在CMS收集器发生Concurrent Mode Failer时使用。
(5)Parallel Old收集器
(6)CMS收集器
CMS收集器(Concurrent Mark Sweep,并发标记清除)是一种以获取最短停顿时间为目标的收集器。基于“标记-清除”算法实现,它的运作过程大致分为4个步骤:
a. 初始标记
c. 重新标记
其中,初始标记和重新标记过程仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快。重新标记阶段就是为了修正并发标记阶段因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录。
这个收集器存在3个明显的缺点:
b. CMS无法处理浮动垃圾(Floating Garbage),可能出现“Concurrent Mode Failer”失败而导致另一次Full GC的产生。并发清理阶段,用户程序还在运行着,伴随着就会产生新的垃圾,这一部分的垃圾出现在标记之后,无法在当次收集中处理掉,只好等待下一次收集时处理,这一部分垃圾称之为“浮动垃圾”。也是因为并发清除阶段,用户程序还在运行,那也就必须要预留一部分内存空间给并发收集时用户程序使用。在JDK1.5的默认设置下,CMS收集器在当老年代使用了68%的空间后就会被激活,在JDK1.6种,这个阈值被提升到了92%。要是CMS运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failer”失败,这时虚拟机将启动后背预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集。
(7)G1收集器
G1(Garbage First)收集器是一款面向服务端应用的垃圾收集器,JDK 1.7中才出现。相比其他的垃圾收集器,G1具有更加明显的优势:
a. G1中虽然还保留分代的概念,但是G1能独立管理整个Java堆,不再需要像以前那样要多个收集器配合。因为G1将整个堆划分成多个大小相等的独立区域(Region),新生代和老年代的概念虽然保留着,但是不再是物理隔离的了,他们都是一部分Region(不需要连续)的集合。
b. 空间整合。G1从整体上来看是基于标记-整理算法实现,从局部(两个Region之间)来看是基于复制算法实现,避免了内存碎片的问题。
c. 可预测的停顿。这是G1相对于CMS的另一大优势。G1除了追求低停顿以外,还能建立可预测的停顿时间模型,能让使用者指明在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
G1之所以能建立可预测的停顿时间模型,是因为它可以有计划的避免在整个Java堆中进行全区域的垃圾收集。G1跟踪每个Region里面的垃圾堆积的价值大小(回收这块Region所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也是Garbage First名称的由来)。
5、内存分配与回收策略
Java技术体系中所讲的自动内存管理最终可以归结为自动化的解决了两个问题:给对象分配内存以及回收分配给对象的内存。内存的回收上面已经说过原理,下面看一下内存分配。
(1)对象优先在Eden区上分配
对象在新生代Eden区中分配,当Eden区中没有足够的内存进行分配时,虚拟机将发起一次Minor GC。
(2)大对象直接进入老年代
虚拟机提供了一个参数 -XX:PretenureSizeThreshold ,大于这个参数设置值的对象将不会在Eden区域上进行分配,而是直接在老年代进行分配。这样做的目的是为了避免在Eden区以及两个Survivor区之间发生大量的内存复制(当大对象的存活率比较高时)。
(3)长期存活的对象将进入老年代
前面在说对象的内存布局时提到对象头中有一部分存储对象自身运行时所需要的数据,例如哈希码、GC分代年龄,这里面的GC分代年龄指的就是对象在新生代中熬过的GC次数,每熬过一次Minor GC,对象的年龄就增加一岁,当它的年龄增加到一定程度(默认是15岁)就会被晋升到老年代。这个晋升老年代的阈值可以通过参数 -XX:MaxTenuringThreshold 来设置。
(4)动态对象年龄判定
对象的年龄不一定非要达到 MaxTenuringThreshold 设置的值才能晋升老年代。如果在Survivor中相同年龄的对象的大小之和超过了Survivor空间大小的一半,那么年龄大于或等于该年龄的对象将直接进入老年代,无需等到 MaxTenuringThreshold 要求的年龄。
(5)分配担保
在新生代进行Minor GC之前,虚拟机会做如下的事情:
(1)先检查老年代最大可用的连续内存空间是否大于新生代所有对象总空间,如果大于,那么Minor GC就是安全的,因为不会有对象进入老年代。否则进行步骤(2)
(2)查看 HandlePromotionFailure设置值是否允许分配担保失败,如果不允许,那么也要进行一次Full GC。否则进行步骤(3)
(3)检查老年代最大可用连续空间是否大于历次晋升老年代的平均大小,如果大于,会尝试进行一次Minor GC,但是可能会有风险。否则进行步骤(4)
(4)也要进行一次Full GC。
步骤(3)中提到会尝试进行一次Minor GC,但这次GC会存在风险,这句话是什么意思呢?前面在讲复制算法时提到,新生代在进行GC时,如果Eden空间和From Survivor空间中存活的对象大小之和超过了To Survivor空间的大小,那么这些存活对象将通过分配担保机制进入老年代。因为还没有进行Minor GC,所以虚拟机并不知道本次GC中存活对象的大小,所以只好采用历次晋升老年代对象的平均值来作为经验值,如果老年代可用的最大连续内存空间大于经验值,那么很有可能说明老年代可用的最大连续内存空间也大于本次Minor GC存活的对象大小之和,所以会尝试进行Minor GC。但是如果在进行Minor GC后发现存活对象大小之和大于老年代最大可用连续内存空间,那么这时老年代将无法存放这部分存活对象,这就是风险所在,此时老年代就要来一次Full GC以腾出空间。