还在学JVM?我都帮你总结好了(附脑图)
本文脑图
运行时数据区模型
在java虚拟机中把内存分为若干个不同的数据区域。这些区域有各自的用途,有些区域随着虚拟机进程启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。在JVM中主要分为以下几个区域:
程序计数器
方法区
虚拟机栈
本地方法栈
java堆
程序计数器
程序计数器是内存中较小的一部分区域,是当前线程执行的字节码的行号指示器。在字节码解释器工作时通过计数器的值来选取下一条指令。
为什么需要计数器?
多线程情况下,一条线程中有多个指令,为了使线程切换可以恢复到正确执行位置,每个线程都具有各自独立的程序计数器,所以该区域是非线程共享的内存区域。
如果执行的是Java方法,计数器记录的是正在执行的字节码指令的地址;若执行的是Native方法,计数器存储为空。这块内存区域是虚拟机规范中唯一没有OutOfMemoryError的区域。
我们可以得出程序计数器主要有两个作用:
字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制,如:顺序执行、选择、循环、异常处理。
在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了。
方法区
方法区也称"永久代
",它用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,是各个线程共享的内存区域。
在JDK8之前的HotSpot JVM,存放这些”永久的”的区域叫做“永久代(permanent generation)
”。永久代是一片连续的堆空间,在JVM启动之前通过在命令行设置参数-XX:MaxPermSize
来设定永久代最大可分配的内存空间,默认大小是64M(64位JVM默认是85M)。
方法区或永生代相关设置
-XX:PermSize=64MB
最小尺寸,初始分配-XX:MaxPermSize=256MB
最大允许分配尺寸,按需分配
XX:+CMSClassUnloadingEnabled
-XX:+CMSPermGenSweepingEnabled
设置垃圾不回收-server
选项下默认MaxPermSize为64m
,-client
选项下默认MaxPermSize为32m
java虚拟机规范堆方法区限制非常的宽松,可以不选择垃圾回收,以及不需要连续的内存和可扩展的大小。这个区域主要是针对于常量池的回收以及对类型的卸载,当方法区无法分配到足够的内存的时候也会抛出OOM。
虚拟机栈
虚拟机栈是每个java方法的内存模型:每个方法被执行的时候都会创建一个"栈帧",用于存储局部变量表(包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
平时说的栈一般指局部变量表部分。栈帧对应的结构图,如下图所示:
局部变量表所需要的空间在编译期完成分配,当执行一个方法时,该方法需要在栈帧中分配多大的局部变量表的空间完全是可以确定的,因此在方法运行的期间不会改变局部变量表的大小。
初级程序员可能笼统的将Java内存分为堆内存和栈内存时,该区域就是常说的栈内存,该区域的局部变量表存放基本类型、对象的引用类型,在对象的引用类型中存储的是指向对象的地址。
该区域会出现两种异常
当线程请求的栈深度大于虚拟机所允许的深度,就会抛出
StackOverflowError
异常。一般虚拟机的内存都是动态扩展的,但是有可能动态的扩展还是配不到足够的内存,就会抛出OOM异常。
本地方法栈
本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务。
本地方法栈为虚拟机使用到的native方法服务,可能底层调用的c或者c++,我们打开jdk安装目录可以看到也有很多用c编写的文件,可能就是native方法所调用的c代码。
Java堆
Java 堆(Java Heap
)是Java 虚拟机所管理的内存中最大的一块。Java 堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。
此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。堆内存是所有线程共有的,可以分为两个部分:年轻代和老年代。
注意:它是所有线程共享的,它的目的是存放对象实例。同时它也是GC所管理的主要区域,因此常被称为GC堆。根据虚拟机规范,Java堆可以存在物理上不连续的内存空间,就像磁盘空间只要逻辑是连续的即可。它的内存大小可以设为固定大小,也可以扩展。当前主流的虚拟机如HotPot都能按扩展实现(通过设置
-Xmx
和-Xms
),如果堆中没有内存内存完成实例分配,而且堆无法扩展将报OOM错误(OutOfMemoryError)
新生代又分为:Eden
空间、From Survivor
、To Survivor
空间。进一步划分的目的是更好地回收内存,或者更快地分配内存。
分代回收的原因:对象的生命周期不同,所以针对不同生命周期的对象可以采取不同的回收方式,以便提高回收效率。从内存分配的角度来看,线程共享的java堆中可能会划分出多个线程私有的分配缓冲区。
垃圾回收算法
标记-清除
标记-清除是最基本的回收算法,后面的算法的设计的思想都是基于此算法进行设计。标记-清除算法一共分为两个阶段标记和清除阶段,标记阶段是将不可达的对象(即为不存活的对象)进行标记,接着清除阶段将这些标记的对象进行清除。
标记-清除算法主要有两个问题:一个是效率的问题,标记和清除两个过程效率都不高;另一个问题就是该算法会产生很多的内存碎片,大量的不连续内存空间,当程序需要申请较大的内存空间存储大对象的时候,有可能无法申请到足够的内存空间而不得不再一次触发一次垃圾回收动作。
复制算法
复制算法将内存划分为两个区间,在任意时间点,所有动态分配的对象都只能分配在其中一个区间(称为活动区间),而另外一个区间(称为空闲区间)则是空闲的。
当有效内存空间耗尽时,JVM将暂停程序运行,开启复制算法GC线程。接下来GC线程会将活动区间内的存活对象,全部复制到空闲区间,且严格按照内存地址依次排列,与此同时,GC线程将更新存活对象的内存引用地址指向新的内存地址。
此时,空闲区间已经与活动区间交换,而垃圾对象现在已经全部留在了原来的活动区间,也就是现在的空闲区间。事实上,在活动区间转换为空间区间的同时,垃圾对象已经被一次性全部回收。
新生代中因为对象都是"朝生夕死的",深入理解JVM虚拟机上说98%的对象存活率很低,适用于复制算法,复制算法比较适合用于存活率低的内存区域。它优化了标记/清除算法的效率和内存碎片问题。
JVM不是平分内存,新生代中由于存活率低,不需要复制保留那么大的区域造成空间上的浪费,因此不需要按1:1划分内存区域,而是将内存分为一块Eden空间和From Survivor、To Survivor【保留空间】。
(1)新生代:大多数对象在新生代中被创建,其中很多对象的生命周期很短。每次新生代的垃圾回收(又称Minor GC)后只有少量对象存活,所以选用复制算法,只需要少量的复制成本就可以完成回收。
在新生代内存中每次只是用Eden和其中的一个S区。当Eden区满时,还存活的对象将被复制到两个
Survivor区
中的一个。当这个Survivor区满时,此区的存活且不满足“晋升”条件的对象将被复制到另外一个Survivor区。对象每经历一次
Minor GC
,年龄加1,达到“晋升年龄阈值”后,被放到老年代,这个过程也称为“晋升”。显然,“晋升年龄阈值”的大小直接影响着对象在新生代中的停留时间,在Serial
和ParNew GC
两种回收器中,“晋升年龄阈值”通过参数MaxTenuringThreshold
设定,默认值为15。
(2)老年代:在新生代中经历了N次垃圾回收后仍然存活的对象,就会被放到年老代,该区域中对象存活率高。老年代的垃圾回收(又称Major GC)通常使用标记-清理或标记-整理算法。整堆包括新生代和老年代的垃圾回收称为Full GC。
(3)永久代:主要存放元数据,例如Class
、Method
的元信息,与垃圾回收要回收的Java对象关系不大。相对于新生代和年老代来说,该区域的划分对垃圾回收影响比较小。在 JDK 1.8中移除整个永久代,取而代之的是一个叫元空间的区域。
默认的Eden : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即:Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。
在新生代中,并不是每次存活的对象都少于10%,有时候若是存活的对象大于10%,就会向老年代进行空间分配担保。
标记-整理
标记-整理算法也分为两步,首先标记不可达的对象,然后存活的对象往一端移动,然后直接清理掉端边界以外的内存。
老年代中存活率比较高,要是使用复制算法,会大量浪费时间在复制对象上,因此复制算法不适合用在存活率比较高的场景。
标记的存活对象将会被整理,按照内存地址依次排列,而未被标记的内存会被清理掉。如此一来,当我们需要给新对象分配内存时,JVM只需要持有一个内存的起始地址即可,这比维护一个空闲列表显然少了许多开销。
不难看出,标记/整理算法不仅可以弥补标记/清除算法当中,内存区域分散的缺点,也消除了复制算法当中,内存减半的高额代价。
不过任何算法都会有其缺点,标记/整理算法唯一的缺点就是效率也不高,不仅要标记所有存活对象,还要整理所有存活对象的引用地址。从效率上来说,标记/整理算法要低于复制算法。
关于JVM深入研究以及JVM调优,后面的文章会继续深入,这篇文章先介绍JVM的内存模型,以及回收算法。
[往期精彩回顾]