Android-JVM

JVM内存管理与垃圾收集

2017-11-11  本文已影响27人  conndots

1 运行在Linux上的JVM

本文想讲Java的内存管理,首先花一点篇幅在OS的内存上。这里以大部分服务器运行的基础Linux为例。

Linux内存

学习过OS的都知道,一个CPU指令想要访问或者写内存的某一个区域,就会有一个寻址的操作。OS的内存模型里,内存的每一个区域都有一个地址。而32位CPU的地址的位数限制在32位,意即CPU只能在最大4GB(232)的空间内完成寻址。而64位CPU可以完成更大范围内的寻址操作(264,自己算算呢)。当前,我相信大部分的CPU与OS都已经进化到了64位。 虽然CPU可以在这么宽广的范围内寻址,但是实际机器的内存可能并没有那么大。所以,大部分现代的OS支持虚拟内存。比如我的机器有4GB RAM内存,我可以假装有8GB内存,其余4GB是虚拟内存,存储在磁盘的交换区上(对于linux存储在特殊的分区swap上,相信自己装过ubuntu的都知道)。当当前任务使用的内存超出物理内存时,OS会根据一些算法把一部分最近不常使用的区域换出内存到交换区,腾出来的空间给当前急用。对于Linux和大部分现代OS,内存交换的单位都是页。

还需要提一下OS的进程模型。一个进程拥有自己神圣不可侵犯的进程空间。而一个进程的空间被分为很多的页,有可能一部分页被OS调度到交换区。显而易见,这是这个进程不想看到的事情。
下图可以看到两个JVM进程,都拥有自己的寻址空间。在JVM服务中,可以通过-Xmx,-Xms来限制JVM进程的最大内存在物理内存内,并预留足够空间给OS还有其他服务进程。

image

如果内存耗尽

地址空间耗尽一般发生在32位的机器上。内存泄漏或过度使用本机内存会迫使OS使用swap。访问经过交换的内存地址比读取驻留在物理内存中的地址慢得多,因为必须从磁盘拉取数据。可能会分配大量内存来用完所有物理内存和所有交换内存(页面空间),在 Linux 上,这将触发内核内存不足(OOM)结束程序,强制结束最消耗内存的进程。
从JVM外简单梳理了一下内存管理后,我们可以来看看JVM内部的内存管理。

2 JVM内存布局

JVM内部的内存布局可以看下图。

image
大致可以分为3部分:

对于HotSpotVM,虚拟机栈和本地方法栈放在了一起。

2.1 方法区

方法区域Java堆一样,是各个线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等数据。

许多人称这部分区域为永久代(Permanent Generation)。但是二者并不等价。HotSpot团队把GC分代收集扩展到方法区,使用永久代来实现方法区的回收和管理,避免专门为方法区编写内存管理代码。在其它的虚拟机实现并不存在永久代的概念。而HotSpot这样的设计并不是一个很好的设计,更容易遇到内存溢出的问题。可以看下图,只要方法区到达了XX:MaxPermSize上限就溢出了,但是,其它虚拟机,如J9、JRockit只要没有触碰出可用内存上限,就不会出现问题。在JDK 7中,已经把字符串常量从永久代中移出,JDK 8中正式移除了永久代这个概念(JDK features),原先永生代中大部分内容比如类的元信息会被放入本地内存(Metaspace)。

JVM规范对方法区的限制很宽松,甚至可以选择不实现垃圾收集。而垃圾收集在这个区域很少见。主要内存回收的目标是针对常量池和对类型的卸载。对类型的卸载非常苛刻。

运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中,除了有类的版本、字段、方法、接口等描述信息外,还有一项信息室常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用。这部分内容在类被加载后进入方法区的运行时常量池中存放。
而运行时常量池相对于Class文件中的常量池的一个重要特征是具有动态性。运行期间也可以把新的常量放入池中。比如:String的intern方法。

String.intern
public String intern()
Returns a canonical representation for the string object.
A pool of strings, initially empty, is maintained privately by the class String.
When the intern method is invoked, if the pool already contains a string equal to this String object as determined by the equals(Object) method, then the string from the pool is returned. Otherwise, this String object is added to the pool and a reference to this String object is returned.
It follows that for any two strings s and t, s.intern() == t.intern() is true if and only if s.equals(t) is true.
All literal strings and string-valued constant expressions are interned. String literals are defined in section 3.10.5 of the The Java Language Specification.
Returns:
a string that has the same contents as this string, but is guaranteed to be from a pool of unique strings.

String.intern在java7之前,过度使用可能会导致方法区(永久代)触发OOM。Java7及之后将运行时常量池被放到堆中管理,避免了这种情况。

2.2 方法栈

方法栈有两种,JVM栈与本地方法栈(Native Method Stacks)。它是线程私有的,生命周期与线程相同。每个方法在执行时都会创建一个栈帧(Stack Frame)存储局部变量表、操作数栈、动态链接、方法出口等。局部变量表存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型(指向一条字节码指令的地址)。

局部变量表所需的内存空可以在编译期间完全确定,runtime它的大小不会被改变。当线程请求的栈深度大于设定阈值,JVM会抛出StackOverflowError;如果无法申请到足够内存完成栈的动态扩展,将会抛出OutOfMemoryError异常。

本地方法栈为VM使用的本地方法服务。虚拟机规范并未对其使用语言、方式、数据结构有强制规定,而许多VM实现,包括HotSpot虚拟机,把VM栈和本地方法栈合二为一,一样也可能抛出StackOverflowError或者OOM。

2.3 程序计数器

程序计数器是每个线程私有的用于记录当前线程所执行字节码的行号指示器。在VM的概念模型中,字节码解释器工作时就是通过改变这个计数器来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等都依赖这个计数器完成。当线程执行java方法时,它存储正在执行的字节码的地址;如果是本地方法,它的值为undefined。此内存区域是JVM规范中没有规定任何OOM情况的区域。

2.4 堆

堆应该是内存管理中最大的一块,被所有线程共享。所有的对象实例都在堆上分配。但是,随着JIT的发展与逃逸分析技术的成熟,栈上分配、标量替换优化技术会导致一些微妙变化发生,所有对象在堆上分配便不那么绝对了。

现代的垃圾收集器基本采用分代收集算法,可以参考下图:

image

分为新生代和老年代,还可以细分为:Eden,From Survivor,To Survivor,等。线程共享的java堆中,可能会划分出多个线程私有的分配缓冲区(Thread Local Allocation buffer,TLAB)。进一步划分的目的是为了更好地回收内存,或更快地分配内存。
而Java堆可以处于物理上不连续的空间中。

2.5 其它内存占用

还有一些其它的内存占用值得注意。直接内存(Direct Memory)不是VM运行时数据区的一部分,也不是VM规范定义的内存区域。但是它也会被频繁使用,可能导致OOM。
JDK 1.4中加入的NIO(Nonblocking IO)引入了基于通道Channel与缓冲区(Buffer)的IO方式。它使用native函数库直接分配堆外内存,通过存在Java堆中的DirectByteBuffer对象作为这块内存的引用来操作。在许多场景中,这样的设计因避免在Java堆与native对中来回复制而提高了性能。但是它的分配不会受到Java堆大小的限制,但还是会收到本机内存的限制。如果在配置JVM参数时候忽略了直接内存,使得各个内存区域总和大于物理内存限制,会导致动态扩展时出现OOM。

3 自动内存管理

这里我们以官方的HotSpot虚拟机为例,看JVM如何自动管理内存。

3.1 对象的创建

对象的创建可以分为如下的过程:

  1. 检查并类加载如果需要
  2. 分配内存空间
  3. 初始化零值(可能会在TLAB分配新空间时做)
  4. 对象头设置元信息

JVM在遇到一条new指令时,首先检查这个指令的参数是否能在常量池中定位到一个类的符号引用,这个类是否被加载、解析和初始化过。如果没有,这执行类加载过程。
加载类完毕后,接下来VM将会为新对象分配内存。对象所需要的内存大小在类加载完成后便可完全确定。
分配对象有两种情况:

  1. 如果Java堆内存绝对规整 使用一个指针指向已经分配的内存区域和未分配内存区域的地址,新的对象创建只需要将这个指针向空闲区域方向移动给定空间。这种分配方式被称为『指针碰撞』。
  2. 如果Java内存并不规整 『指针碰撞』无法适用。VM需要维护一个列表,记录哪些内存块是可用的,在列表中找到足够大的空间分配给对象。这种方法叫『空闲列表』。

Java堆是否规整由采用的垃圾收集器是否带有压缩整理功能决定。CMS默认使用空闲列表,而Serial、parNew等带compat过程的收集器就采用指针碰撞。JVM启动时的参数XX+UseCMSCompactAtFullCollection可以在CMS垃圾收集器中开启Full GC时整理、压缩堆内存。

对象创建是VM中非常频繁的行为,并发情况下如何保证一致性,通常有两种解决方案:

  1. 加锁同步 通过VM使用CAS(java.util.concurrent包中借助CAS实现了区别于synchronize同步锁的一种乐观锁)配上失败重试来保证内存分配的原子性。
  2. 分配TLAB划分不同线程的内存空间 将不同线程的内存分配隔离在不同的内存区域。每个线程拥有一个内存分配缓冲(TLAB,Thread Local Allocation Buffer)。 只有在TLAB用尽分配新的空间给TLAB时候才需要同步锁定。可以通过-XX:/-UseTLAB参数设定是否使用TLAB。

内存分配完毕后,VM会将所有空间都初始化为零值(不包括对象头)。然后VM会对对象头做初始化。对于VM来说,一个新的对象已经诞生了;但从Java的角度,对象创建才刚刚开始——开始执行<init>方法,按照程序员的意愿初始化数据。

而对象的回收才是进入到JVM的垃圾回收器的工作原理。

3.2 回收算法概述

基本的垃圾收集算法由4种:

3.3 堆分代收集

如下图所示,JVM主要会被分为两个部分:年轻代老年代
[图片上传失败...(image-e5966-1510413201979)]
把对象按照不同的年龄分离开,使用不同的策略清理,能够提高垃圾收集器的效率。

JVM的开发者发现,一个程序中的大部分对象都会在年轻的时候死亡。IBM专门的研究表明,年轻代98%的对象都是朝生夕死的。不需要按照1:1比例划分内存空间。
年轻代又分为Eden和两个大小相等的Survivor区域:from和to。每次使用Eden和一块Survivor。新的对象在Eden里分配。当Eden空间不足时,会发生一次Minor GC。
Minor GC将在使用的Survivor和Eden存活的对象一次性复制到另一个Survivor中,然后清理Eden和之前的Survivor。HotSpot虚拟机默认Eden:Survivor大小比例是8:1。这样,年轻代自浪费了10%的空间。当然,当Survivor空间不够用时,需要依赖老年代(Tenured)进行分配担保(Handle Promotion)
一个对象的年龄即它经历的Minor GC次数。在年龄到达一定次数时,对象会被移动到老年代(Promotion)。发生分配担保时也会有年轻代对象被移到老年代。

而由于老年代的对象存活率较高,没有额外空间进行分配担保,必须使用『标记-清除』或者『标记-整理』算法来回收。

3.4 如何标记

HotSpot实现上述的算法时,必须要保证高效的执行效率。

如何标记存活实例呢?目前主要有两种方法:

  1. 引用计数法 VM维护每个对象被引用的次数,引用次数为0的对象被标记清除。这个算法足够简单,但是无法回收循环依赖的对象。Java并没有采用这样的算法。
  2. 标记存活对象 从一系列根节点出发,根据引用关系遍历对象,遍历的对象标记为存活对象。剩余对象为需要回收的对象。正是因为大部分对象都是朝生暮死,所以找活着的比找死去的容易一些。

那么,对于JVM来说,哪些是根节点对象呢?

Java会从这些根节点对象开始扫描存活对象。实际上,HotSpot为了更加高效地实现标记算法,在编译期间与运行期间都使用了很多手段降低标记运行时间。可达性分析对执行时间非常敏感,尤其是GC停顿的时候。可达性分析需要在一个能够确保一致性的堆内存快照中进行,在分析过程中不会出现对象引用关系仍然在不断变化的情况。此时便会停顿所有执行线程(Stop the World)。在几乎不停顿的CMS收集器中仍然会在枚举根节点的时候停顿。
同时,JVM会保证任何停顿都发生在SafePoint或者SafeRegion。

具体JVM针对枚举根节点过程的优化可以参考《深入理解JVM虚拟机》3.4节。

3.5 垃圾回收器

下图包含了HotSpot虚拟机的全部虚拟机,还有不同回收器之间是否可以配合使用(用线连接)。而不同的收集器在不同的区域使用。
[图片上传失败...(image-41cc80-1510413201979)]
CMS和G1是两个比较复杂的收集器,G1更是在JDK 7 Update14后在移除了实验的标签。没有完美的收集器,而是针对具体应用最适合的收集器。

新生代回收器

新生代收集器包含3种:Serial,ParNew,Parrallel Scavenge。

Serial

[图片上传失败...(image-7edf57-1510413201979)]
一图省千言。单GC线程,Stop the World,但是Serial收集器仍然是Java client模式下默认的年轻代垃圾收集器,简单而高效。

ParNew

ParNew其实就是Serial的多线程版本,除了多GC线程外,还可以通过JVM启动参数调整,如:-XX:SurvivorRatio, -XX:PretenureSizeThreshold, -XX:HandlePromotionFailure等。
ParNew是用户使用-XX:+UseConcMarkSweepGC来使用CMS作为老年代的收集器时,ParNew是默认年轻代的收集器。

Parrallel Scavenge

它也是复制算法的收集器,同时是并行的。与ParNew相比的特别之处在于,它的目标是要达到一个可控制的吞吐量,而其他收集器如CMS关注于尽可能缩短垃圾收集时用户线程停顿的时间
所谓的吞吐量,是CPU用于运行用户代码的时间与CPU总消耗时间的比值。

Parrallel Scavenge提供了比较丰富的可控制的参数,并且自己有一套自适应的调节策略。

老年代回收器

老年代包含:Serial Old收集器,Parrallel Old收集器,和CMS。CMS比较复杂,应用比较广泛,单独来说。

Serial Old是单线程采用标记-整理算法的收集器,会Stop the World后单GC线程标记-整理。它也是Client模式下的默认老年代收集器。在JDK 5以及之前与Parrallel Scavenge搭配使用。另一个作用是作为CMS的后备方案,在发生Concurrent Mode Failure的时候使用。

Parrallel Old是Parrallel Scavenge的老年代版本,使用多线程和标记-整理算法。从JDK6开始提供。Parrallel Old配合Parrallel Scavenge成为了『吞吐量优先』收集器的应用组合,用于注重吞吐量和CPU资源敏感的场合

CMS

CMS全称为Concurrent Mark-Sweep,是一种以获取最短回收停顿时间为目标的收集器。被广泛用于互联网的后端服务等注重服务响应速度的场合。
CMS是基于标记-清除算法实现的,过程比较复杂,整个过程可以分为4步,其中初始标记与重新标记步骤需要Stop the World,二者的耗时得到了比较好的控制,总体上来说CMS是HotSpot虚拟机第一款真正意义上的并发收集器,第一次实现了GC线程与用户线程同时工作,是具有划时代意义的。

下面按照4个步骤分解一下收集器的收集过程。

CMS初始标记 initial mark

初始标记比较简单,标记一下GC根节点能够关联到的对象,速度很快,但是需要Stop the World。

CMS并发标记 concurrent mark

并发标记,JVM会开启多线程与用户线程一起工作。进行GC根节点的tracing过程。在trace过程中,堆中对象的引用关系可能在不断变化。

CMS重新标记 remark

remark需要再次Stop the World,为了修正并发标记过程中因用户程序继续运作导致标记产生变动的那一部分对象的标记记录。耗时会比初始标记长,但远比并发标记的时间短。

CMS并发清除 concurrent sweep

并发清除就是与用户线程一起同时对标记完后未被标记的对象做清理。此时,因为不会做整理,所以会产生不少的碎片。

整个过程可以看下图。
[图片上传失败...(image-53e113-1510413201979)]

CMS的缺陷与常见问题

  1. CMS对CPU资源非常敏感。CMS虽然会与用户线程并发执行,但是仍然会占用一部分CPU资源,造成吞吐降低。CMS默认启动的回收线程数是(CPU数量+3)/4。尤其是CPU核数较小的时候对用户程序影响比较明显。

  2. CMS无法处理浮动垃圾,可能出现Concurrent Mode Failure进而导致另一次Full GC发生。在CMS并发清除时,用户程序还在运行,仍然会产生垃圾。这部分垃圾并不会在这次GC过程中被回收。它们就叫浮动垃圾。在过程期间,CMS需要预留足够空间给用户的程序使用(这个值可以通过JVM参数-XX:CMSInitiatingoccupancyFraction设定,JDK 6中为92%)。如果预留空间无法满足用户程序需要,就会发生Concurrent Mode Failure。此时,JVM启动预案,即使用Serial Old收集器重新进行老年代的垃圾收集,导致停顿时间非常长。

  3. 当进行Handle Promotion的时候,survivor空间放不下年轻代的存活对象,对象只能放入老年代,但是老年代也放不下的时候,就会出现 担保失败(Promotion Failure)
    担保失败的出现很可能是因为老年代内存碎片太多,而新生代来的对象比较大造成。此时会提前触发stop the world的CMS的Full GC过程,但是CMS默认使用标记-清除算法,并不会整理。可以通过Java启动参数-XX:UseCMSCompactAtFullCollection -XX:CMSFullGCBeforeCompaction=5来开启CMS算法的每5次Full GC进行一次标记整理,从而控制老年代的碎片。
    下图是promotion failure的一条gc日志:

2016-11-18T07:26:09.665+0800: 39287.719: [GC2016-11-18T07:26:09.666+0800: 39287.719: [ParNew (promotion failed)
: 557937K->566208K(566208K), 0.1302900 secs]2016-11-18T07:26:09.796+0800: 39287.850: [CMS: 2310524K->290069K(2516608K), 1.4146810 secs] 2855851K->290069K(3082816K), [CMS Perm : 117537K->116340K(195872K)], 1.5457950 secs] [Times: user=1.67 sys=0.00, real=1.54 secs]

Garbage First (G1)

G1是面向服务端应用,未来的目标是全面替代JDK 5发布的CMS收集器。
相比其他收集器,G1有如下特点:

  1. 并行与并发 充分利用多核缩短Stop the World停顿时间。
  2. 分代收集 继承了分代收集特点,无需其他收集器配合就可以独立管理整个GC堆。
  3. 空间整合 整体看采用标记-整理算法实现。局部上看是通过复制实现。不会产生空间碎片。有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发GC。
  4. 可预测的停顿 可以建立可预测的停顿时间模型,让使用者明确指定一个长度为M毫秒的时间片段内,消耗在GC上的时间不得超过N毫秒。做到这点是因为,G1可以有计划避免在整个Java堆进行全区域GC。

G1的堆的内存布局不再是连续的年轻代与老年代了,而是如下图这样的被分为多个大小相等的独立region。G1会跟踪各个region里垃圾堆积的价值大小,维护优先队列,根据允许的收集时间,优先回收最大的region。

image
其实,实现的复杂度很高。Java堆虽然被分为了多个region,却不可能是互相孤立的,一个region的对象可能与整个java堆的任意region的对象有引用关系。在做可达性判定时还是无法避免扫描整个堆。在各个收集器都有类似的问题,G1尤其突出。
G1使用Recommended Set来避免全堆扫描。具体细节不表,参考《深入理解JVM虚拟机》3.5.7。

G1的处理流程也可以大致分为以下几步:

  1. 初始标记 initial marking
  2. 并发标记 concurrent marking
  3. 最终标记 final marking
  4. 筛选回收 live data counting and evacuation

[图片上传失败...(image-3537c-1510413201979)]
具体的过程我还需要研究一下,暂且留个坑在这里。
//TODO

References

  1. 《深入理解JVM虚拟机》
  2. 胖胖的回答-Java 的垃圾回收机制的主要原理是什么?什么情况下会影响到程序的运行效率?
  3. Java内存详解
上一篇下一篇

猜你喜欢

热点阅读