前言
在上一篇文章中,我们了解到了JVM的运行时数据区,基本被划分了5个区域。
- 程序计数器(pc寄存器)
- java虚拟机栈
- 本地方法栈
- java堆
- 方法区(永久代,元空间)。
而上一篇文章中,我们详细的讲解了关于线程私有的3个区域,程序计数器,java虚拟机栈,本地方法栈。而本文则讲解的是线程共享的两个区域,java堆和方法区(元数据)。
java堆
Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
由于Java堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”(Garbage Collected Heap)
对于Java应用程序来说,Java堆(Java Heap)是虚拟机所管理的内存中最大的一块。此内存区域的唯一目的就是存放对象实例,Java 世界里“几乎”所有的对象实例都在这里分配内存。
由于现代垃圾收集器大部分都是基于 分代收集理论 设计的。
什么是分代收集理论? 当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)[1]的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。 |
显而易见,如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间。
如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域,这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。
那么为了更好的利用垃圾收集器来管理我们的java堆,也因此,大多数虚拟机中,java堆也是基于分代收集理论划分出了各个区域,主要如下:
- 新生代
- Eden 区
- Survivor 区
- ①From Survivor 区
- ②To Survivor 区
- 老年代
从这个可以看出,整个java堆内存区域,被划分为了新生代和老年代。而新生代,则又进行划分,分为了Eden区和Survivor区,而Survivor区也被划分为From Survivor区域,To Survivor区域。
一般来说,也就是说,虚拟机默认的,java堆中的内存,新生代占1/3,老年代占了2/3。
而在新生代中,Eden区占了新生代总的8/10,From Survivor区占了新生代总内存的1/10,To Survivor区占了新生代总内存的1/10。
java堆大小设置自然是用初始值(-Xms N设置)和最大值(-Xmx N设置)。堆的默认大小,为我们物理机内存大小的1/64,而堆内存的默认最大值是我们物理机内存的1/4。
官方建议初始和最大Java堆大小命令行选项-Xms和-Xmx的值应设置为旧空间的实时数据大小的三到四倍。
Eden区
对于大部分虚拟机而言,对象都是出生在Eden区。
我之所以是说是对于大部分虚拟机而言,是因为随着垃圾回收算法的不断进步,有着一些垃圾回收器,它们并没有严格的遵循分代收集算法来区分java堆,比如说,G1收集器,它虽然也遵循着分代收集理论,但是它并没有坚持固定大小以及固定数量的分代区域划分,而是把连续的java堆划分为多个大小相等的独立区域,小对象由小对象区域存储,大对象由大对象存储,如果一定要说这些区域都是所谓的Eden区,那肯定就是有问题的。 而且也由一部分的大对象,当它的大小超过了我们虚拟机设置的大小的时候,也可能不在Eden区域创建。 还有两种特殊的情况:
什么是逃逸分析? 逃逸分析是指分析指针动态范围的方法,它同编译器优化原理的指针分析和外形分析相关联。当变量(或者对象)在方法中分配后,其指针有可能被返回或者被全局引用,这样就会被其他过程或者线程所引用,这种现象称作指针(或者引用)的逃逸(Escape)。 |
Eden区域是新生代的一部分,占了新生代内存大小的8/10,这个大小是由虚拟机默认的,只是默认值,可以通过参数–XX:SurvivorRatio来设置。
Eden区域的对象,基本上都是朝生暮死的,也就是说,因为一段代码刚开始跑,会在Eden区域生成了很多对象,但是等到这段程序跑完,基本上刚刚生成的对象,大部分都会通不过可达性分析,变成了可回收的对象。
正是因为这个原因,Eden区域里面的垃圾收集算法,基本上都是标记-复制算法,使用的gc,也就是我们的Minor GC。(请注意的是,我这里所说是Minor GC,是因为我没有确定当前虚拟机使用的是哪一个收集器,不同的收集器,可能在Eden区域有着不同的gc,因此我这里指的只是普遍情况。)
什么是标记-复制算法? 标记-复制算法常被简称为复制算法。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。 如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有 空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。 这也就是为什么Eden区域使用的回收算法是标记-复制算法的原因。 |
当我们的Eden区域满了的时候,会触发Minor GC,Minor GC会通过标记-复制算法,将那些处于可回收状态的对象清除掉。
什么是Minor GC? Minor GC 是发生在新生代中的垃圾收集动作,所采用的是标记-复制算法。它只发生在我们的新生代,是隶属于新生代的一个垃圾收集动作。 |
但是要注意的是,不是所有的对象都会被gc清除掉,总有一些对象,并不会被清除掉,这些对象通过了可达性分析,顽强的生存下来,挺过了Minor GC的一次又一次的清除。
那么如果我们不处理这些对象,总有一天,这些对象会将我们的Eden区域占据满,也就是说,Minor GC的触发频率会变得非常高,要知道,GC的开销是非常大的,如果这样持续下去,那么会导致我们的程序的性能,受到了持续触发的GC的影响。
所以,当Eden区的对象挺过了一次Minor GC之后,会被直接复制到To Survivor区,然后Minor GC会将整个Eden区清空。
Survivor区
我们知道,当Eden区域中的对象,挺过了一次Minor GC的回收后,会被复制到To Survivor区。
但是要知道的是,在新生代中,To Survivor区和From Survivor区域不会一直不变。
当有一次Minor GC之后,不仅仅是将Eden区域存活的对象复制到To Survivor区,而且也会将会在From Survivor区域中也挺过了Minor GC的对象,也复制到To Survivor区,然后,将整个From Survivor区清空,这个时候From Survivor区域就变成了To Survivor区。
而之前的To Survivor区域则保存了由Eden区复制过来的对象和由之前的From Survivor区域复制过来的对象,并且改名为了From Survivor区域。
要注意的是,当Survivor区域满了之后,不会触发Minor GC,只有当Eden区满的时候,才会触发Minor GC。
也就是说,From Survivor区域和To Survivor区域都是使用标记-复制算法和Minor GC的。
那么问题来了,当这样循环下去后,当有一天,如果整个Survivor区域被对象占满了怎么办呢?
JVM规定了,当From Survivor中的一个对象,经历了一次Minor GC之后,就称这个对象长大了一岁,当年龄达到一定值(年龄阈值,默认是 15 岁,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到To Survivor区域。
还有一种例外的情况,当一次Minor GC过后,发现我们的From Survivor的有一批年龄相同的对象,加起来占据了这个From Survivor大小的50%,那么会直接将这批对象放到我们的老年代中去。
老年代
老年代的内存占了整个java堆的2/3,是堆里面最大的一个内存空间。
老年代的对象,都是从Survivor区域复制过来的,都是存活了很长时间的对象,所以这部分内存区域才会被称之为老年代。
但是在一些特殊的情况下,一些新建的对象,也有可能在老年代直接创建,情况如下:
- 如果是大对象,如果超过了一定大小范围(通过启动参数-XX:PretenureSizeThreshold=N设置)的大对象,会直接在老年代创建。这种情况在年轻代采用的收集器是Parallel Scavenge GC时无效,因为其会根据运行情况自己决定什么对象直接在老年代上分配内存。
- 如果大的数组对象,且数组对象中无引用外部对象,那么也会直接在老年代创建。
这样做的目的是,就是要避免新生代里出现那种大对象,然后屡次躲过GC,还得把他在两个Survivor区域来回复制多次之后才能进入老年代,那么大的对象在内存里来回复制,是很耗时且耗费性能的过程。
当在新生代的Minor GC要触发之前,JVM会做一个判断,判断当前老年代的剩余内存大小,是否大于或者等于当前的From Survivor区域大小。
如果不成立,那么就会触发属于老年代的GC,等老年代的GC执行完了,才会继续触发属于新生代的Minor GC。
如果成立,那么会继续触发Minor GC,但是当 Minor GC过后,剩余的存活对象的大小,是大于Survivor区域的大小,此时就会直接进入老年代,但是这个时候如果老年代的内存也不够,于是也会触发一次老年代的GC。
如果老年代经过一次GC后,还是内存不足,就会出现所谓的OOM(内存溢出)了。
在这里要强调一下,因为当收集器我们选择的是CMS收集器的时候,那么我们的老年代触发的GC是Major GC,但是如果是其他的收集器,那么触发的就是Full GC。
如果使用的Full GC,那么使用的垃圾回收算法就是标记-整理算法。如果使用的是Major GC,那么使用的垃圾回收算法是标记-清除算法。
什么是标记-清除算法? 如它的名字一样,标记-清除算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程。 主要缺点有两个:
什么是标记-整理算法? 标记-整理的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。 主要缺点: 移动存活对象并更新所有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才能进行,这就更加让使用者不得不小心翼翼地权衡其弊端了,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。 |
方法区(永久代,元空间)
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
但是我们知道,有时候,我们会将方法区称之为永久代。但是我们知道,方法区不等价于永久代,只是因为Hotspot的开发者们将分代收集中的永久代扩展到了方法区,将方法区和永久代合二为一了。
怎么说呢?
要理解的是,永久代这个东西,只存在于HotSpot虚拟机上面,也就是说,HotSpot用永久代实现了java虚拟机规范中的方法区,但是在java虚拟机规范中,没有永久代这个说法,只有方法区这个说法,于是能理解了,那就是方法区是一个java虚拟机规范规定的一个标准,而永久代则是HotSpot虚拟机对方法区的一种实现方式。
也就是说,方法区是java虚拟机规范的一个标准,而永久代,则是HotSpot实现方法区的一个实现方式。
但是随着时间的发展,在jdk7的时候,HotSpot虚拟机开始将永久代慢慢移除,它首先永久代的一部分数据转移到其他地方了。比如,符号引用(Symbols)转移到了本地内存(Native Memory),字符串常量池(interned strings)和类的静态变量转移到了Java堆。
到了jdk8的时候,HotSpot取消了永久代了,它改变了使用永久代来实现方法区的思路,而是使用了一个叫元空间的东西来替代永久代去实现我们的方法区。
很明显,所谓的元空间,则是HotSpot在jdk8中对方法区的另一个实现方式。
那么元空间和我们的永久代有什么不同呢?
- 首先,元空间发源于JRockit虚拟机,不是发源于HotSpot本身的研发团队的。而永久代就是HotSpot最根正苗红的后裔。
- 永久代也是使用了虚拟机的内存的,实际上和java堆,在物理上是连接在一起的一片内存。也就是说,永久代也可能导致内存溢出的情况出现。而元空间就不一样,它的存储空间没有在虚拟机上面,而是存储在本地内存中,理论上来讲,物理机的内存有多大,那么元空间就有多大。
- 元空间中,只存储了类的元数据信息。而之前的永久代存储的字符串池和类的静态变量,则是放入Java堆中了。
当然,JVM中也有参数来设置元空间的大小。
看到这里,我们可能会有疑问,为什么要这么做呢?主要原因如下:
- 字符串存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- Oracle 可能会将HotSpot 与 JRockit 合二为一。
元空间参数设置:
|
直接内存
我们都知道,运行时数据区是在JVM虚拟机中的,但是我们要知道的是,JVM除了使用了虚拟机自带的内存外,在虚拟机运行的物理机上,还有很多剩余的物理机内存没有被使用到,虽然说,JVM自带的内存,其实基本上已经满足了JVM运行和java程序运行的需要,但是总有那么一些情况,会突然之间产生巨大的数据流,导致JVM的内存不足。
但是之所以称之为数据流,原因就在于,这部分巨大的数据,像是潮流一样,只是偶尔会出现。如果JVM在设计的时候,将这部分数据流需要的内存也纳入到设计模型中,那么我可以保证的是,JVM的内存模型所需要的内存将是无比庞大的,虽然现在我们的内存的价格在逐步降低,但是这不代表JVM能忍受自己的设计是如此的臃肿和浪费内存。
于是,在JVM的内存模型之外,出现了叫直接内存的事物。
什么是直接内存?
直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。
在JDK1.4 中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。
这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
直接内存(Direct Memory)的容量大小可通过-XX:MaxDirectMemorySize参数来指定,如果不去指定,则默认与Java堆最大值(由-Xmx指定)一致。
直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
总结
我们对运行时数据区的分析就到此为止了,从我本文和上一篇文章《从头开始学习JVM(七):运行时数据区(上)》中可以看出来,运行时数据区,包含了对数据和对象的存储,以及对数据和对象的操作过程,而包含了这些,也就包含了整个运行程序了。
接下来,我们会对JVM的其他方面做出研究和学习。