Java内存模型
Java内存模型在Java Virtual Machine Specification, in the chapters “2.5 Runtime Data Areas” and “2.6 Frames”中有详细的说明。对象和类的数据存储在3个不同的内存区域:堆(heap space)、方法区(method area)、本地区(native area)。
堆内存存放对象以及数组的数据,方法区存放类的信息(包括类名、方法、字段)、静态变量、编译器编译后的代码,本地区包含线程栈、本地方法栈等存放线程。
被移除的永久代
对于方法区,在HotSpot虚拟机中我们可以认为等同于永久代(PermGen),在Java 6及之前的版本,永久代存放了以下一些内容:
- 虚拟机加载的类信息
- 常量池
- 静态变量
- 即时编译后的代码
Java 7之后,常量池已经不在永久代之中进行分配了,而是移到了堆中,即常量池和对象共享堆内存。
Java 8之后,永久代已经被永久移除,取而代之的是Metaspace。Metaspace是方法区在HotSpot中的实现,它与永久代最大的区别在于:Metaspace并不使用虚拟机内存而是使用本地内存。
HotSpot团队选择移除永久代,有内因和外因两部分,从外因来说,JEP 122的Motivation部分:
This is part of the JRockit and Hotspot convergence effort. JRockit customers do not need to configure the permanent generation (since JRockit does not have a permanent generation) and are accustomed to not configuring the permanent generation.
大致就是说移除永久代也是为了和JRockit进行融合而做的努力。JRockit客户不需要配置永久代(因为JRockit没有永久代)。
从内因来说,永久代大小受到-XX:PermSize
和-XX:MaxPermSize
两个参数的限制,而这两个参数又受到JVM设定的内存大小限制,这就导致在使用中可能会出现永久代内存溢出的问题,因此在Java 8及之后的版本中彻底移除了永久代而使用Metaspace来进行替代。
Java内存结构
虽然实现的细节千差万别,但所有的垃圾收集器都遵循了同一个方式,即根据情况将堆划分成不同的代(Generation)。
这些代被称为“老年代”(Old Generation)和“新生代”(Young Generation)。
新生代又被进一步地划分为不同的区段,分别称为Eden空间和Survivor空间。
- Young Generation
- Eden
- Survivor
- Old Generation
JVM类型
Client 和Server 类虚拟机
Java的自动优化前提是机器被分为Client
和Server
。
这两个术语直接与特定平台上的默认JVM 编译器相关,它们也设定了默认的调优标志。例如,机器类别决定了平台默认的垃圾收集器。
Microsoft Windows上运行的任何32位JVM(无论机器上CPU 的个数是多少),以及单CPU机器(不论是什么操作系统)上运行的任何32 位JVM,都是Client 类机器。
所有其他机器(包括所有64 位JVM)都被认为是Server类。
垃圾收集
垃圾收集概述
简单来说,垃圾收集由两步构成:查找不再使用的对象,以及释放这些对象所管理的内存。
JVM 从查找不再使用的对象(垃圾对象)入手。有时,这也被称为查找不再有任何对象引用的对象(暗指采用“引用计数”的方式统计对象引用)。
然而,这种靠引用计数的方式不太靠谱:
假设有一个对象链接列表,列表中的每一个对象(除了头节点)都指向列表中的另一个对象,但是,如果没有任何一个引用指向列表头,这个列表就没人使用,可以被垃圾回收器回收。如果这是一个循环列表(即列表的尾元素反过来又指向了头元素),那么列表中的每一个元素都包含一个引用,即使这个列表内没有任何一个对象实际被使用,因为没有任何一个对象指向这个列表。
所以引用是无法通过计数的方式动态跟踪的,JVM必须定期地扫描堆来查找不再使用的对象。一旦发现垃圾对象,JVM会回收这些对象所持有的内存,把它们分配给需要内存的其他对象。然而,简单地记录空闲内存也无法保证将来有足够的可用内存,有些时候,我们还必须进行内存整理来防止内存碎片。
假设以下场景,一个程序需要分配大小为1000字节的数组,紧接着又分配一个大小为24字节的数组,并在一个循环中持续进行这样的分配。最终程序会耗尽整个堆,结果如图中的第一行所示:堆空间被占满,分配的数组间隔地分布于整个堆内。
堆内存用尽会触发JVM回收不再使用的数组空间。假设所有大小为24字节的数组都不再被使用,而大小为1000字节的数组还继续使用,这就形成了图中第二行的场景。虽然堆内部有足够的空闲空间,却找不到任何一个大于24字节的连续空间,除非JVM 移动所有大小为1000字节的数组,让它们连续存储,把空闲的空间整合成一块更大的连续空间,供其他的内存分配使用。
通常Java程序都启动了大量的线程,垃圾收集器自身往往也是多线程的。接下来的讨论中,我们从逻辑上将线程分成了两组,分别是应用程序线程和处理垃圾收集的线程。
垃圾收集器回收对象,或者在内存中移动对象时,必须确保应用程序线程不再继续使用这些对象。这一点在收集器移动对象时尤其重要:在操作过程中,对象的内存地址会发生变化,因此这个过程中任何应用线程都不应再访问该对象。所有应用线程都停止运行所产生的停顿被称为stop-the-world(时空停顿)。通常这些停顿对应用的性能影响最大,调优垃圾收集时,尽量减少这种停顿是最为关键的考量因素。
垃圾收集的性能就是由这些基本操作所决定的:找到不再使用的对象、回收它们使用的内存、对堆的内存布局进行压缩整理。完成这些操作时不同的收集器采用了不同的方法,这也是不同垃圾收集器表现出不同性能特征的原因。
分代垃圾收集器
采用分代机制的原因是很多对象的生存时间非常短。通常Java代码在循环中常会出现创建新的对象的操作,并且只是在这一个循环里使用。这些对象在循环的下一个周期开始时会被丢弃。所以垃圾收集器设计时就特别考虑要处理大量(有时候是大多数)的临时对象。这也是分代设计的初衷之一。
新生代是堆的一部分,对象首先在新生代中分配。新生代填满时,垃圾收集器会暂停所有的应用线程,回收新生代空间。不再使用的对象会被回收,仍然在使用的对象会被移动到其他地方。这种操作被称为Minor GC。
采用这种设计有两个性能上的优势。
其一,由于新生代仅是堆的一部分,与处理整个堆相比,处理新生代的速度更快。而这意味着应用线程停顿的时间会更短。这也意味着应用程序线程会更频繁地发生停顿,因为JVM不再等到整个堆都填满才进行垃圾收集;本章后续部分会针对其利弊进行深入的讨论。然而,就目前而言,更短的停顿显然能带来更多的优势,即使发生的频率更高。
第二个优势源于新生代中对象分配的方式。对象分配于Eden空间,垃圾收集时,新生代空间被清空,Eden空间中的对象要么被移走,要么被回收;所有的存活对象要么被移动到另一个Survivor空间,要么被移动到老年代。由于所有的对象都被移走,相当于新生代空间在垃圾收集时自动地进行了一次压缩整理。
所有的垃圾收集算法在对新生代进行垃圾回收时都存在“时空停顿”现象。
对象不断地被移动到老年代,最终老年代也会被填满,JVM 需要找出老年代中不再使用的对象,并对它们进行回收。而这便是垃圾收集算法差异最大的地方。简单的垃圾收集算法直接停掉所有的应用线程,找出不再使用的对象,对其进行回收,接着对堆空间进行整理。这个过程被称为Full GC,通常导致应用程序线程长时间的停顿。
另一方面,通过更复杂的计算,我们还有可能在应用线程运行的同时找出不再使用的对象;CMS和G1收集器就是通过这种方式进行垃圾收集的。由于它们不需要停止应用线程就能找出不再用的对象,CMS和G1收集器被称为Concurrent垃圾收集器。同时,由于它们将停止应用程序的可能降到了最小,也被称为低停顿(Low-Pause)收集器。Concurrent 收集器也使用各种不同的方法对老年代空间进行压缩。
使用CMS 或G1 收集器时,应用程序经历的停顿会更少(也更短)。其代价是应用程序会消耗更多的CPU。CMS和G1 收集也可能遭遇长时间的Full GC 停顿(尽量避免发生那样的停顿是这些调优算法要考虑的重要方面)。
GC算法
JVM提供了以下4种不同的垃圾收集算法。
Serial垃圾收集器
Serial垃圾收集器是四种垃圾收集器中最简单的一种。如果应用运行在Client型虚拟机上,这也是默认的垃圾收集器。
Serial收集器使用单线程清理堆空间,无论是进行Minor GC还是Full GC,清理堆空间时,所有的应用线程都会被暂停。进行Full GC时,它还会对老年代空间的对象进行压缩整理。
通过-XX:+UseSerialGC
标志可以启用Serial收集器(大多数情况下,如果可以使用这个标志,默认就会开启)。
注意,跟大多数的JVM标志不同,关闭Serial收集器不能简单地将加号符变成减号符。在Serial收集器作为默认收集器的系统上,如果需要关闭Serial 收集器,可以通过指定另一种垃圾收集器来实现。
Throughput垃圾收集器
Throughput收集器是Server级虚拟机的默认收集器。
Throughput收集器使用多线程回收堆空间,这已经是JDK 7u4 及之后的版本的默认行为,对于之前老版本的JDK 7 虚拟机,通过-XX:+UseParallelOldGC
标志可以开启这个功能。
由于Throughput收集器使用多线程,Throughput收集器也常常被称为Parallel收集器。
Throughput收集器在Minor GC 和Full GC 时会暂停所有的应用线程,同时在Full GC 过程中会对老年代空间进行压缩整理。
由于在大多数适用的场景,它已经是默认的收集器,所以你基本上不需要显式地启用它。如果需要,可以使用-XX:+UseParallelGC
、-XX:+UseParallelOldGC
标志启用Throughput收集器。
CMS收集器
CMS收集器设计的初衷是为了消除Throughput收集器和Serial收集器Full GC周期中的长时间停顿。
CMS 收集器在Minor GC时会暂停所有的应用线程,并以多线程的方式进行垃圾回收。CMS不再使用Throughput的收集算法(-XX:+UseParallelGC
),改用新的算法来收集新生代对象(使用-XX:+UseParNewGC
标志)。
CMS 收集器在Full GC 时不再暂停应用线程,而是使用若干个后台线程定期地对老年代空间进行扫描,及时回收其中不再使用的对象。这种算法帮助CMS成为一个低延迟的收集器:应用线程只在Minor GC 以及后台线程扫描老年代时发生极其短暂的停顿。应用程序线程停顿的总时长与使用Throughput收集器比起来短得多。
这里额外付出的代价是更高的CPU 使用:必须有足够的CPU资源用于运行后台的垃圾收集线程,在应用程序线程运行的同时扫描堆的使用情况。
除此之外,后台线程不再进行任何压缩整理的工作,这意味着堆会逐渐变得碎片化。如果CMS 的后台线程无法获得完成他们任务所需的CPU资源,或者如果堆变得过度碎片化以至于无法找到连续空间分配对
象,CMS 就蜕化到Serial收集器的行为:暂停所有应用线程,使用单线程回收、整理老年代空间。这之后又恢复到并发运行,再次启动后台线程(直到下一次堆变得过度碎片化)。
通过-XX:+UseConcMarkSweepGC
、-XX:+UseParNewGC
标志(默认情况下,这两个标志都是禁用的)可以启用CMS垃圾收集器。
G1垃圾收集器
G1 垃圾收集器(或者垃圾优先收集器)的设计初衷是为了尽量缩短处理超大堆(大于4GB)时产生的停顿。
G1 收集算法将堆划分为若干个区域(Region),不过它依旧属于分代收集器。这些区域中的一部分包含新生代,新生代的垃圾收集仍然采用暂停所有应用线程的方式,将存活对象移动到老年代或者Survivor空间。同其他的收集算法一样,这些操作也利用多线程的方式完成。
G1 收集器属于Concurrent收集器:老年代的垃圾收集工作由后台线程完成,大多数的工作不需要暂停应用线程。由于老年代被划分到不同的区域,G1收集器通过将对象从一个区域复制到另一个区域,完成对象的清理工作,这也意味着在正常的处理过程中,G1 收集器实现了堆的压缩整理(至少是部分的整理)。因此,使用G1收集器的堆不大容易发生碎片化——虽然这种问题无法避免。
同CMS收集器一样,避免Full GC 的代价是消耗额外的CPU 周期:负责垃圾收集的多个后台线程必须能在应用线程运行的同时获得足够的CPU 运行周期。通过标志-XX:+UseG1GC
(默认值是关闭的)可以启动G1垃圾收集器。
触发及禁用显式的垃圾收集
Java提供了一种机制让应用程序强制进行GC:这就是System.gc()
方法。通常情况下,试图通过调用这个方法显式触发GC都不是个好主意。
调用这个方法会触发Full GC(即使JVM使用CMS或者G1垃圾收集器),应用程序线程会因此而停顿相当长的一段时间。同时,调用这个方法也不会让应用程序更高效,它会让GC更早地开始,但那实际只是将性能的影响往后推迟而已。
另外需要注意的是,从规范层面讲Java的标准库API里System.gc()
的规定说它只是一个提示,不保证有什么作用或者何时起作用。
从实现层面讲,HotSpot VM和很多其它JVM一样,其实默认是会在用户调用System.gc()
的时候马上执行GC,并且等到GC完成才返回的。只有使用CMS或G1时,配置-XX:+ExplicitGCInvokesConcurrent
,才会在触发了并发GC后就返回。
可以通过JVM参数-XX:+DisableExplicitGC
显式地禁止这种类型的GC;默认情况下该标志是关闭的。
GC调优基础
调整堆空间
如果分配的堆过于小,程序的大部分时间可能都消耗在GC 上,没有足够的时间去运行应用程序的逻辑。但是,简单粗暴地设置一个特别大的堆也不是解决问题的方法。GC停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长。这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序的整体性能变慢。
调整堆大小时首要的原则就是永远不要将堆的容量设置得比机器的物理内存还大,另外,如果同一台机器上运行着多个JVM 实例,这个原则适用于所有堆的总和。除此之外,你还需要为JVM自身以及机器上其他的应用程序预留一部分的内存空间:通常情况下,对于普通的操作系统,应该预留至少1 G 的内存空间。
堆的大小由2个参数值(初始值、最大值)控制:
- -Xms
- -Xmx
堆的大小具有初始值和最大值的这种设计让JVM 能够根据实际的负荷情况更灵活地调整JVM 的行为。
如果JVM 发现使用初始的堆大小,频繁地发生GC,它就会尝试增大堆的空间,直到JVM 的GC 的频率回归到正常的范围,或者直到堆大小增大到它的上限值。
默认堆的大小
操作系统及JVM类型 | Xms | Xmx |
---|---|---|
Linux 32位 Client | 16MB | 256MB |
Linux 32位 Server | 64MB | 取1GB和物理内存大小1/4二者中的最小值 |
Linux 64位 Server | 取512MB和物理内存大小1/64二者中的最小值 | 取32GB和物理内存大小1/4二者中的最小值 |
MacOS 64位 Server | 64MB | 取1GB和物理内存大小1/4二者中的最小值 |
Window 32位 Client | 16MB | 256MB |
Window 64位 Server | 64MB | 取1GB和物理内存大小1/4二者中的最小值 |
如果机器的物理内存少于192 MB,最大堆的大小会是物理内存的一半(大约96 MB,或者更少)。
堆的大小具有初始值和最大值的这种设计让JVM 能够根据实际的负荷情况更灵活地调整JVM 的行为。如果JVM 发现使用初始的堆大小,频繁地发生GC,它就会尝试增大堆的空间,直到JVM 的GC 的频率回归到正常的范围,或者直到堆大小增大到它的上限值。
如果你确切地了解应用程序需要多大的堆,那么你可以将堆的初始值和最大值直接设置成对应的数值(譬如:-Xms4096m -Xmx4096m)。这种设置能稍微提高GC的运行效率,因为它不再需要估算堆是否需要调整大小了。一个经验法则是完成Full GC 后,应该释放出70% 的空间(30% 的空间仍然占用)。
调整代空间
如果新生代分配得比较大,垃圾收集发生的频率就比较低,从新生代晋升到老年代的对象也更少。任何事物都有两面性,采用这种分配方法,老年代就相对比较小,比较容易被填满,会更频繁地触发Full GC。这里找到一个恰当的平衡点是解决问题的关键。
所有用于调整代空间的命令行标志调整的都是新生代空间;新生代空间剩下的所有空间都被老年代占用。
# 设置新生代与老年代的空间占用比率。
-XX:NewRatio=N
# 设置新生代空间的初始大小。
-XX:NewSize=N
# 设置新生代空间的最大大小。
-XX:MaxNewSize=N
# 将NewSize和MaxNewSize设定为同一个值的快捷方法。
-XmnN
NewRatio的默认值为2。影响堆空间大小的参数通常以比率的方式指定;这个值被用于一个计算空间分配的公式之中。
下面是使用NewRatio计算空间的公式:Initial Young Gen Size = Initial Heap Size / (1 + NewRatio)
代入堆的初始大小和NewRatio 的值就能得到新生代的设置值。那么我们很容易得出,默认情况下,新生代空间的大小是初始堆大小的33%。
使用NewSize 标志设定的新生代大小,其优先级要高于通过NewRatio 计算出来的新生代大小。
调整元空间或永久代
JVM载入类的时候,它需要记录这些类的元数据。这部分数据被保存在一个单独的堆空间中。在Java 7里,这部分空间被称为永久代(Permgen 或Permanent Generation),在Java 8中,它们被称为元空间(Metaspace)。
不过永久代和元空间并不完全一样。Java 7中,永久代还保存了一些与类数据无关的杂项对象(miscellaneous object);这些对象在Java 8中被挪到了普通的堆空间内。注意永久代或者元空间内并没有保存类实例的具体信息(即类对象),也没有反射对象(譬如方法对象);这些内容都保存在常规的堆空间内。永久代和元空间内保存的信息只对编译器或者JVM的运行时有用,这部分信息被称为“类的元数据”。
到目前为止都没有一个能提前计算出程序的永久代/元空间需要多大空间的好算法。永久代或者元空间的大小与程序使用的类的数量成比率相关,应用程序越复杂,使用的对象越多,永久代或者元空间就越大。使用元空间替换掉永久代的优势之一是我们不再需要对其进行调整因为元空间默认使用尽可能多的空间。
永久代/元空间的默认大小
JVM类型 | 默认的初始大小 | 默认永久代大小的最大值 | 默认元空间大小的最大值 |
---|---|---|---|
32位 Client | 12MB | 64MB | 没有限制 |
32位 Server | 16MB | 64MB | 没有限制 |
64位 Server | 20.75MB | 82MB | 没有限制 |
这些内存区域的行为就像是分隔开的普通堆空间。它们会根据初始的大小动态地调整,需要的时候会增大到最大的堆空间。
设置永久代
-XX:PermSize=N
-XX:MaxPermSize=N
设置元空间
-XX:MetaspaceSize=N
-XX:MaxMetaspaceSize=N
由于元空间默认的大小是没有作限制的,因此Java 8(尤其是32 位系统)的应用可能由于元空间被填满而耗尽内存。如果元空间增长得过大,通过设置MaxMetaspaceSize你可以调整元空间的上限,将其限制为一个更小的值,不过这又会导致应用程序最后由于元空间耗尽,发生OutOfMemoryError 异常。解决这类问题的终极方法还是定位出为什么类的元空间会变得如此巨大。
此外可以设置-XX:+PrintAdaptiveSizePolicy
标志。开启该标志后,一旦发生垃圾回收,GC 的日志中会包含垃圾回收时不同的代进行空间调整的细节信息。
结尾
调优的前提是我们知道JMM的划分以及堆的分代结构,可以发现JVM众多参数中,许多都是范围,依赖于JVM的动态调节。这其中又和JVM的类型、物理机器的配置息息相关。可见各种参数是不存在“最优”的配置参数,实际的参数设置依赖于我们对相关知识的了解程度以及实际项目的代码实现。
另一方面,在对内存结构清晰的认识后,也有利于我们分析异常、定位原因或是调整参数设置。
在JVM中98%的时间是用于GC且可用的堆空间仍不足2%的时候将抛出此异常信息,常发生于一次加载大量数据时。
对象无法被分配至堆中
java.lang.OutOfMemoryError: Java heap space
常见于一次加载过多类的时候。
# 类或者方法不能被加载到永久代
java.lang.OutOfMemoryError: PermGen space
# 类或者方法不能被加载到元空间
java.lang.OutOfMemoryError: Metaspace
…
引用参考
《Java性能权威指南》
http://ifeve.com/under-the-hood-runtime-data-areas-javas-memory-model/
https://www.cnblogs.com/xrq730/p/8688203.html
http://rednaxelafx.iteye.com/blog/1042471
https://docs.oracle.com/javase/7/docs/webnotes/tsg/TSG-VM/html/memleaks.html