身为Java程序员你不知道Java虚拟机,你好意思么!知道Java虚拟机连它运行时的数据区域都不知道,你敢说你知道Java虚拟机!!
-----------------来自小马哥的故事
运行时数据区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而建立和销毁。根据《Java虚拟机规范(JavaSE 7版)》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域,如图所示
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里(仅是概念模型,各种虚拟机可能会通过一些更高效的方式去实现),字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
由于Java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器(对于多核处理器来说是一个内核)都只会执行一条线程中的指令。因此,为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。因为CPU通过时间轮转来为线程服务,为了线程切换后能够恢复的正确的位置,在每一个线程都保存一个程序计数器
如果线程正在执行的是一个Java方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。
Java虚拟机栈
与程序计数器一样,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame[1])用于存储局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
经常有人把Java内存区分为堆内存(Heap)和栈内存(Stack),这种分法比较粗糙,Java内存区域的划分实际上远比这复杂。这种划分方式的流行只能说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域是这两块。其中所指的“堆”笔者在后面会专门讲述,而所指的“栈”就是现在讲的虚拟机栈,或者说是虚拟机栈中局部变量表部分。
局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。
其中64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余的数据类型只占用1个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
在Java虚拟机规范中,对这个区域规定了两种异常状况:如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。使用-Xss来设置栈大小。栈帧是方法运行时的基础数据结构,
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。
在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。
甚至有的虚拟机(譬如Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
Java堆
对于大多数应用来说,Java堆(Java Heap)是Java虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。
这一点在Java虚拟机规范中的描述是:所有的对象实例以及数组都要在堆上分配[1],但是随着JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换[2]优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么“绝对”了。
Java堆是垃圾收集器管理的主要区域,因此很多时候也被称做“GC堆”(GarbageCollected Heap,幸好国内没翻译成“垃圾堆”)。从内存回收的角度来看,由于现在收集器基本都采用分代收集算法,所以Java堆中还可以细分为:新生代和老年代;再细致一点的有Eden空间、From Survivor空间、To Survivor空间等。从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。不过无论如何划分,都与存放内容无关,无论哪个区域,存储的都仍然是对象实例,进一步划分的目的是为了更好地回收内存,或者更快地分配内存。 根据Java虚拟机规范的规定,Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可,就像我们的磁盘空间一样。在实现时,既可以实现成固定大小的,也可以是可扩展的,不过当前主流的虚拟机都是按照可扩展来实现的(通过-Xmx和-Xms控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。
[1]Java虚拟机规范中的原文:The heap is the runtime data area from which memory for all class instances and arrays is allocated
方法区
方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开来。
对于习惯在HotSpot虚拟机上开发、部署程序的开发者来说,很多人都更愿意把方法区称为“永久代”(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已,这样HotSpot的垃圾收集器可以像管理Java堆一样管理这部分内存,能够省去专门为方法区编写内存管理代码的工作。对于其他虚拟机(如BEA JRockit、IBM J9等)来说是不存在永久代的概念的。原则上,如何实现方法区属于虚拟机实现细节,不受虚拟机规范约束,但使用永久代来实现方法区,现在看来并不是一个好主意,因为这样更容易遇到内存溢出问题(永久代有-XX:MaxPermSize的上限,J9和JRockit只要没有触碰到进程可用内存的上限,例如32位系统中的4GB,就不会出现问题),而且有极少数方法(例如String.intern())会因这个原因导致不同虚拟机下有不同的表现。因此,对于HotSpot虚拟机,根据官方发布的路线图信息,现在也有放弃永久代并逐步改为采用Native Memory来实现方法区的规划了[1],在目前已经发布的JDK 1.7的HotSpot中,已经把原本放在永久代的字符串常量池移出。
Java虚拟机规范对方法区的限制非常宽松,除了和Java堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。相对而言,垃圾收集行为在这个区域是比较少出现的,但并非数据进入了方法区就如永久代的名字一样“永久”存在了。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收“成绩”比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但是这部分区域的回收确实是必要的。在Sun公司的BUG列表中,曾出现过的若干个严重的BUG就是由于低版本的HotSpot虚拟机对此区域未完全回收而导致内存泄漏。根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。使用-XX:PermSize和-XX:MaxPermSize设置方法区的大小。
[1]JEP 122-Remove the Permanent Generation:http://openjdk.java.net/jeps/122。
运行时常量池
运行时常量池(Runtime Constant Pool)是方法区的一部分。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
Java虚拟机对Class文件每一部分(自然也包括常量池)的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java虚拟机规范没有做任何细节的要求,不同的提供商实现的虚拟机可以按照自己的需要来实现这个内存区域。
不过,一般来说,除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。运行时常量池相对于Class文件常量池的另外一个重要特征是具备动态性,Java语言并不要求常量一定只有编译期才能产生,也就是并非预置入Class文件中常量池的内容才能进入方法区运行时常量池,运行期间也可能将新的常量放入池中,这种特性被开发人员利用得比较多的便是String类的intern()方法。既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutOfMemoryError异常。
直接内存
直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。
在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存(包括RAM以及SWAP区或者分页文件)大小以及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置-Xmx等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。
垃圾收集器与内存分配策略
如何判断对象已死
引用计数算法
在对象中保存一个引用计数器,如果该对象在一个地方被引用,则引用计数器值加1,如果有一个地方的引用失效则计数器减1。在任意时刻计数器的值为0则表示对象已死。优点是简单,速度快。缺点是:循环引用问题。
根搜索算法
当一个对象到GC Roots没有任何引用链,则表示该对象已死
哪些对象可以当做GC Roots
-
Java虚拟机栈中的引用的对象。
-
本地方法栈中的引用的对象。
-
方法区中常量引用的对象。
-
方法区中静态变量引用的对象。
Java中的四种引用
-
强引用:例如Object obj = new Object();只要强引用还在,则对象一定不会被回收
-
软引用:当将要发生内存溢出时,GC则将这些对象列入垃圾回收的范围。如果回收后仍然内存空间不足,则抛出OutOfMemoryError异常。
-
弱引用:弱引用关联的对象只能活到下一次垃圾回收之前。
-
虚引用:虚引用完全不影响对象的生存周期,只是在垃圾回收时收到一个系统通知。
对象的二次标记
当对象到GC Roots不可达时,并不一定被回收。还回经历两次标记的过程。
当对象到GC Roots不可达时,它会被第一次标记,并被筛选。筛选的条件是是否有必要执行finalize(), 如果对象没有覆finalize()或者已经被JVM执行了finalize(),则认为没有必要执行(直接被回收)。如果认为有必要执行,则将对象存放到一个F-Queue队列中,JVM会自动创建一个低级线程Finalizer用来执行finalize()。这里执行只是触发该方法,并不会等待该方法执行完成。执行finalize()方法是对象逃脱别回收的最后一次机会。GC 会对F-Queue中的对象进行二次标记,如果在期间被GC Roots引用链上的对象重新连接,则不会被回收。
方法区的回收
方法区主要回收常量池和无用的类。
如何判断一个类是无用的类
要满足下面三个条件:
(1) 该类的所有对象都已经被回收,Java堆中没有该类的任何实例。
(2) 该类的类加载器已经被回收。
(3) 该类的java.lang.Class对象没有在任何地方被使用。没有在任何地方通过反射访问该类的方法。
垃圾回收算法
(1) 标记清除算法:第一步将所有要回收的对象进行标记,第二步回收掉所有被标记的对象。优点:简单;缺点:标记和清除效率都较低,并且会使得内存中出现很对碎片。
(2) 复制算法:将内存区域分成一个较大的Eden区域,两个较小的Survivor区域。分配空间时,每次使用Eden区域和其中一块Survivor区域。在垃圾回收时,将Eden区域和Survivor区域存活的对象复制到另一块Survivor中。
(3) 标记-整理算法:第一步对所有要回收对对象进行标记,第二步将存活的对象移到一端,将边界以外的所有对象回收。
(4) 分代收集算法。按照对象的生存周期将内存分成几个区域。在每个区域使用不同的算法。一般把Java堆分成新生代和老年代,对新生代使用复制算法,对老年代使用标记清除或者标记整理算法。
垃圾收集器
(1) Serial收集器:是单线程的,使用的是复制算法。使用一条线程去垃圾回收时,必须要停止其他工作线程。
(2) ParNew收集器:是Serial的多线程版本。
(3) Parallel Scavenge:目标主要是用来控制CPU的吞吐量。使用的是复制算法
(4) Serial Old收集器:Serial的老年版本。使用的是标记整理算法。
(5) Parallel Old收集器:Parallel Scavenge的老年版本。使用的是标记整理算法。
(6) CMS收集器:以获取最短停顿时间为目标的。使用的是标记清除算法。在标记和清除阶段使用的是并发操作。
(7) G1收集器:将Java堆分成若干个大小固定的区域,使用的是标记清除算法。跟踪没一个区域的垃圾堆积程度,并维持一个优先级队列,根据允许的收集时间,选择垃圾堆积最多的区域进行回收。
内存分配与回收策略
- 对象优先在Eden区域分配
对象优先在Eden区域分配,如果Eden区域没有足够的空间分配,则虚拟机发起一次Minor GC。-XX:SurvivorRatio用来设置Eden区域和Survivor区域的大小比值。
Eden区域空间不足,发起一次Minor GC,将Eden区域和Survivor中存活的对象复制到另一个Survivor中,如果Survivor无法容纳所有存活的对象,则根据分配担保机制,将其转移到老年代。
- 大对象直接进入老年代
大对象指需要大量连续内存空间的对象,例如大数组。使用 -XX:PretenureSizeThreshold来设置阈值,如果对象答案与这个阈值则直接进入老年代。这样做的目的避免对象在Eden区域以及两个Survivor区域发生大量拷贝。
- 长期存活的对象进入老年代
Java虚拟机给每个对象定义一个Age计数器,如果对象在Eden区域出生,经过一次GC仍然存活,将其复制到Survivor区域,如果能被容纳则将Age加1。当Age的值大于等于MaxTenuringThreshold时进入老年代。阈值设置使用-XX:MaxTenuringThreshold。
- 动态对象年龄判定
对应并不总是等到年龄大于maxTenuringThreshold才进入老年代。如果Survivor中相同年龄的对象的总大小大于等于Survivor空间的一半,则将所有大于等于该年龄的对象移入到老年代。
- 空间分配担保
在发生minor GC之前,Java虚拟机会检测之前每次进入老年代的平均大小是否大于老年代的剩余大小。如果大于老年代的大小,则将进行一次Full GC。如果不大于,则查看HandlePromotionFailure是否设置为true, 若果是则进行一次minor GC. 如果为False则进行一次Full GC。
个人心得总结
常量池用于存放编译期期间生成的各种字面量和符号,在类加载后进入方法区的运行时常量池。
Java语言并不要求常量一定在编译期才能产生。并非一定是在class编译期中预置的才能进去方法区中的运行时常量池,在运行期间也可以将常量放入。运用的最多的就是String类的intern()方法。运行时常量池是方法区的一部分,当无法申请的内存是会抛出OOS异常。
对象的创建,当遇到一个new指令时,会先去检查这个指令的参数在常量池中是否能定位到一个类的符号引用,在检查这个符号引用代表的类是否被加载,解析和初始化够。在类加载通过后,对象所需要的大小可以完全确定。在Java堆中,拥有一个指针座位分界点的指示器,当需要分配内存时,会指向未被分配内存的区域,将其挪动一段与对象大小相等的距离,称为指针碰撞。如果内存不是规整的,会有一个列表,上面记录哪些区域是可用的,当需要是划分列表中一块足够大的空间分配给对象,然后更新列表上的记录,称为空闲列表。Java堆是否规整由GC收集器是否带有压缩整理功能决定。Serial等带有Compact过程的采用指针碰撞,CMS这种基于Mark-Sweep算法的收集器,采用空闲列表。
对象在内存中存储的布局可以分为3部分:对象头,实例数据,对齐填充。
对象头包含两部分,第一部分是自己运行时的数据,包裹哈希吗,GC分代年龄,锁状态的标志,线程自带的锁,偏向线程ID,偏向时间戳等。另一部分是类型指针,指向它的类元数据。虚拟机可以通过这个指针来确定它是哪个类的实例。如果是Java数组的话,在对象头中还必须有一块用于用于记录数组长度的数据,虚拟机可以通过普通java对象的元数据确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
对齐填充并不是必然存在的,起到一个占位符的作用,HotSpot VM的自动内存管理系统要求对象的起始地址必须是8字节的倍数,当对象的实例数据没有对齐的时候,就需要对齐填充来补全。
实例数据是对象真正存储的有效信息,也是在程序中所定义的各种类型的字段内容。存储顺序受到虚拟机分配策略参数和字段在java源码中定义顺序的影响。相同宽度的字段总是被分配到一起。满足这个条件下,父类中定义的方法会出现在子类之前。如果CompactFields为true,子类中较窄的变量也可能会插入到父类变量的空隙之中。
在jdk1.6之前,StringBuilder会在java堆中创建一个实例,调用String.intern()会把这个实例复制在方法区,所以他们不是一个相同的引用,在jdk1.7后,不会再实现复制,而是在方法区中记录首次出现的实例引用。
本文由 小马哥 创作,采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
最后编辑时间为:
2021/12/19 14:55