本篇为《深入理解Java虚拟机 第3版》读书笔记
文中,《Java虚拟机规范》简称《规范》
字节码解释器工作时,通过改变这个计数器的值,来选取下一条需要执行的字节码指令
程序计数器特点
Java
方法,计数器记录正在执行的虚拟机字节码指令的地址;执行Native
方法,计数器的值为空Java
方法被执行的时候,JVM
会创建一个栈帧,一个Java
方法的被调用到执行完毕,对应着一个栈帧在虚拟机栈中入栈到出栈的过程。通常,Java
讲的堆栈中的栈就是指「虚拟机栈」,或者指「虚拟机栈」中的局部变量表
栈帧存储:局部变量表、操作数栈、动态连接、方法出口等信息
局部变量表存储:
reference
returnAddress
方法参数(包括实例方法中的隐藏参数“this
”)、显式异常处理程序的参数(Exception Handler Parameter,就是try-catch
语句中catch
块中所定义的异常)、方法体中定义的局部变量都需要依赖局部变量表来存放
局部变量表的存储空间以局部变量槽来表示,对于byte、char、float、int、short、boolean和returnAddress
等长度不超过32位的数据类型,每个局部变量占用一个变量槽,而double和long
这两种64位的数据类型则需要两个变量槽来存放
关于「this」:在任何实例方法里面,都可以通过this
关键字访问到此方法所属的对象。这个访问机制对Java
程序的编写很重要,而它的实现非常简单,仅仅是通过在Javac
编译器编译的时候把对this
关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数而已。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个变量槽位来存放对象实例的引用,注意这个处理只对实例方法有效.
实例的构造方法中,也可以使用this,神奇!
虚拟机栈特点
HotSpot虚拟机栈的容量是不可以动态扩展的
关于上面的两类异常,可以分为两种情况去思考它
一、栈容量允许动态扩展的情况,在这种情况下,如果栈容量无法容纳新的栈帧,这时候栈会申请更多的内存,如果申请不到,就会抛出OOM异常,这种情况下不会抛出StackOverFlow异常
二、栈容量不支持动态扩展的情况,在这种情况下,如果栈容量无法容纳新的栈帧,因为无法进行扩展,所以这时候会抛出StackOverFlow异常,而不会抛出OOM异常
补充:在创建新的线程时没有足够的内存去创建对应的Java虚拟机栈,也会抛出OOM异常。
与虚拟机栈的作用类似,区别是虚拟机栈为执行Java
方法服务,本地方法栈为执行Native
方法服务
本地方法栈特点
HotSpot虚拟机把虚拟机栈和本地方法栈合二为一,在HotSpot中,不区分虚拟机栈和本地方法栈,其栈容量都实现为不可动态扩展的
关于上面的两类异常,可以分为两种情况去思考它
一、栈容量允许动态扩展的情况,在这种情况下,如果栈容量无法容纳新的栈帧,这时候栈会申请更多的内存,如果申请不到,就会抛出OOM异常,这种情况下不会抛出StackOverFlow异常
二、栈容量不支持动态扩展的情况,在这种情况下,如果栈容量无法容纳新的栈帧,因为无法进行扩展,所以这时候会抛出StackOverFlow异常,而不会抛出OOM异常
补充:在创建新的线程时没有足够的内存去创建对应的本地方法栈,也会抛出OOM异常。
JVM
所管理的内存中最大的一块,虚拟机启动时创建,用于存放对象实例。Java
堆可以划分出多个线程私有的分配缓冲区,以提升对象分配时的效率
Java
堆特点
Java
堆可以实现为固定大小、也可以是可扩展的,主流JVM
按照可扩展实现JVM
抛出OutOfMemoryErrorIdea的虚拟机启动参数中,可以将对容量设置为不可扩展(将堆的最小值-Xms参数与最大值-Xmx参数设置为一样即可避免堆自动扩展) --书本P55
别名Non-Heap,方法区存储JVM
加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
类型信息:对象的类型、父类、实现的接口、方法描述等
HotSpot方法区发展历史
方法区特点
运行时常量池是方法区的一部分,Class文件的常量池表在类加载后存放到方法区的运行时常量池中
PS:Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量与符号引用
所在区域
Class文件的常量池表与运行时常量池的区别与联系
JVM
对于Class文件每一部分包括常量池表的格式有严格规定,但对于运行时常量池,《规范》没有做任何细节的要求String
的intern
方法运行时常量池特点
永久代:PermGen「Permanent Generation」
元空间:Metaspace
HotSpot虚拟机,在JDK8以前,方法区是通过永久代来实现的,从JDK8开始,废弃了永久代的概念,方法区采用元空间实现。方法区是JVM的规范,永久代和元空间是JVM规范的一种实现,并且只有HotSpot虚拟机,才有永久代的概念。在方法区中,存储有类的相关信息,而永久代有着大小的限制,通过参数-XX:MaxPermSize设置,即使不设置也有默认大小,那么动态生成大量的类的情况下,容易出现永久代内存溢出的情况。
JDK7中,字符串常量池从永久代转移到了Java Heap中,类的静态变量也从永久代转移到了Java Heap中,符号引用则从永久代转移到了Native Heap
在《规范》所定义的概念模型中,所有Class相关的信息都应该存放在方法区之中,但方法区应该如何实现,《规范》并未做出规定,这就成了一件允许不同虚拟机自己灵活把握的事情。JDK7及其以后版本的HotSpot虚拟机选择把「静态变量」与「类型在Java语言一端的映射的Class对象」存放在一起,存储于Java堆之中,也就是类的静态变量从永久代转移到了Java Heap中 --参考书P156
JDK8中,完全废弃了永久代的概念,采用元空间实现方法区,元空间并不在虚拟机中,它使用的是本地内存,默认情况下,元空间的大小仅受本地内存大小的限制
可以采用参数-XX:MaxMetaspaceSize来指定元空间的最大空间,默认是没有限制的
将字符串常量池从PermGen剥离到Java Heap中,将类型信息从PermGen转移到MetaSpace的好处
应用程序的String::intern()的调用是不可预测和不可控的,类及方法的信息比较难确定其大小,所以永久代的大小比较难确定,太小容易出现永久代溢出,如果设置的太大,那么其它区域的可用空间就减小了
字符串常量池若在PermGen中实现,容易有内存溢出的问题,而在Java Heap中不容易出现这个问题
将字符串常量池从PermGen分离出来,与类元数据分开,提升类元数据的独立性
将元数据从PermGen剥离出来到Metaspace,可以提升对元数据的管理同时提升GC效率
在PermGen中元数据可能会随着每一次Full GC发生而进行移动。HotSpot虚拟机的每种类型的垃圾回收器都需要特殊处理PermGen中的元数据,分离出来以后可以简化Full GC
PermGen是HotSpot的实现特有的,JRockit并没有PermGen一说
类变量在方法区中,但要注意方法区本身是一个逻辑上的区域,在JDK 7及之前,HotSpot使用永久代来实现方法区时,实现是完全符合这种逻辑概念的;而在JDK 8及之后,类变量则会随着Class对象一起存放在Java堆中,这时候“类变量在方法区”就完全是一种对逻辑概念的表述了
书P272
参考
该区域不属于运行时数据区的一部分,由于频繁使用,也放到这里讲解
直接内存特点
Java
堆大小的限制,但会受到本机总内存和处理器寻址空间的限制这里探讨HotSpot虚拟机的Java堆中,对象分配、布局和访问的全过程
在Java中,使用关键字new,即可创建一个对象,在虚拟机中,该对象的创建需要经历下面的过程
选择哪种分配方式由Java堆是否规整决定,Java堆是否规整由所采用的垃圾收集器是否带有空间压缩整理的能力决定
在为对象分配内存的时候,要考虑并发情况,使得内存可以正常分配,有两种可选方案
对象在堆内存的存储布局可以划分为三个部分:对象头、实例数据、对齐填充
对象头
对象头包含两类信息
官方称这部分数据为“Mark Word”,Mark Word采取动态定义的数据结构,以便在极小的空间存储尽量多的数据,根据对象的状态复用自己的存储空间
并不是所有的虚拟机都实现为在对象数据上保留类型指针,也就是说,查找对象的元数据信息并不一定要经过对象本身
普通Java对象的元数据信息可以确定Java对象的大小
实例数据
存储我们在程序代码里面所定义的各种类型的字段内容
对齐填充
HotSpot虚拟机的自动内存管理系统要求对象的起始地址必须是8字节的整数倍,也就是任何对象的大小都必须是8字节的整数倍,对齐填充用于填充数据,起占位符的作用
Java程序通过栈上的references数据来操作堆上的具体对象,关于引用如何访问到对象,有两种方式
HotSpot虚拟机采用「直接指针」的方式,进行对象访问
程序计数器、虚拟机栈和本地方法栈随线程而生,随线程而灭。这三个区域的内存分配和回收具备确定性,首先是栈帧,每一个栈帧分配多少内存基本是在类结构确定下来时就已知的,栈中的栈帧随着方法的进入和退出,执行着入栈和出栈的操作;当方法结束或者线程结束时,内存就自然被回收了。
Java堆和方法区则有着显著的不确定性,这里讨论的内存分配与回收主要指这一部分内存。
垃圾回收之前,要先判断哪些对象是存活的,哪些对象是死去的
思路:每个对象添加一个引用计数器,每当有一个地方引用它时,计数器值就加一;当引用失效时,计数器值就减一;计数器的值为零就表示这个对象不可能再被使用了
优点:原理简单、判定高效
缺点:该算法需要考虑很多例外情况,需配合大量额外处理才能保证正确工作,例如单纯的引用计数无法解决对象循环引用的问题
主流的Java虚拟机没有选用引用计数算法来管理内存
思路:通过一系列GC Roots的根对象为起始节点集,从这些节点开始,根据引用关系向下搜索,搜索过程所走过的路径称为“引用链”,如果某个对象到GC Roots间没有任何引用链相连,或者用图论的话来说就是从GC Roots到这个对象不可达时,则证明此对象是不可能再被使用的。
固定可以作为GC Roots的集合
当前主流的商用程序语言的内存管理子系统,都是通过可达性分析算法来判定对象是否存活的
书P74
垃圾收集算法分为两大类
主流Java虚拟机未涉及「引用式计数器垃圾收集」算法,这里讨论「追踪式垃圾收集」算法,包含"标记"阶段是所有「追踪式垃圾收集」算法的共同特征
后面提到的「标记-复制算法」「标记-清除算法」「标记-整理算法」,都始于分代收集理论
分代收集理论建立在三条假说之上
根据前两条假说,垃圾收集器有下面的设计原则:收集器将堆划分为不同的区域,回收对象依据其年龄分配到不同的区域中存储。如果一个区域中大多数对象都是朝生夕灭,每次回收时只关注如何保留少量存活的对象,而不是去标记那些大量要被回收的对象,就能以较低代价回收到大量的空间;如果一个区域都是难以消亡的对象,虚拟机可以使用较低的频率回收这个区域。
分代收集理论具体放到现在的商用Java虚拟机里,设计者一般至少会把Java堆划分为新生代和老年代两个区域 。顾名思义,在新生代中,每次垃圾收集时都发现有大批对象死去,而每次回收后存活的少量对象,将会逐步晋升到老年代中存放。
第三条假说与跨代引用有关,书P76
方法:算法分为「标记」和「清除」两个阶段,首先标记出所有需要回收的对象,在标记完成后,统一回收掉所有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。标记过程就是对象是否属于垃圾的判定过程,这在前一节讲述垃圾对象标记判定算法时其实已经介绍过了。
缺点:
执行效率不稳定,如果Java堆中包含大量需要被回收的对象,这时必须进行大量标记和清除的动作,也就是说,标记和清除两个过程的执行效率都随对象数量增长而降低
内存空间的碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致当以后在程序运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作
这是最基础的收集算法,后续的收集算法大多都是以「标记-清除」算法为基础,对其缺点进行改进而得到的
算法示意图:
「标记-复制」算法主要用于新生代的垃圾回收,老年代的回收一般不直接选用该算法
方法:将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着
的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。如果内存中多数对象都是存活的,这种算法将会产生大量的内存间复制的开销,但对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象
优点
缺点
算法示意图
算法改进:分代收集理论中,提到大多对象是朝生夕灭,其实新生代中有98%对象熬不过第一轮收集,因此不需要按照1∶1的比例来划分新生代的内存空间。一种策略是将新生代分为一块较大的Eden空间和两块较小的Survivor空间,每次分配内存只使用Eden和其中一块Survivor。发生垃圾收集时,将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上,然后直接清理掉Eden和已用过的那块Survivor空间。
在HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,这样的话,每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的,由于没有保证每次回收都只有不多于10%的对象存活,因此有一个「逃生门」的安全设计,需要依赖其它内存区域进行内存的分配担保。
当执行一次Minor GC(新生代收集)时,Eden空间的存活对象会被复制到To Survivor空间,并且之前经过一次Minor GC并在From Survivor空间存活的仍年轻的对象也会复制到To Survivor空间。有两种情况Eden空间和From Survivor空间存活的对象不会复制到To Survivor空间,而是晋升到老年代。一种是存活的对象的分代年龄超过-XX:MaxTenuringThreshold(用于控制对象经历多少次Minor GC才晋升到老年代)所指定的阈值。另一种是To Survivor空间容量达到阈值。当所有存活的对象被复制到To Survivor空间,或者晋升到老年代,也就意味着Eden空间和From Survivor空间剩下的都是可回收对象,这个时候GC执行Minor GC,Eden空间和From Survivor空间都会被清空,新生代中存活的对象都存放在To Survivor空间。接下来将From Survivor空间和To Survivor空间互换位置,也就是此前的From Survivor空间成为了现在的To Survivor空间,每次Survivor空间互换都要保证To Survivor空间是空的,这就是"标记-复制算法"在新生代中的应用。
老年代一般采取「标记-整理算法」.
「标记-复制算法」不适合老年代的原因
「标记-整理算法」的步骤
「标记-整理算法」是移动式的,「标记-清除算法」是非移动式的,即它们区别在于是否是移动式的,移动式的利与弊如下
「非移动式」的「利与弊」与上面的相反。由于内存访问是程序最频繁的操作,假如在这个环节上增加额外的负担,势必会直接影响应用程序的吞吐量,从这一个角度来看,移动式更胜一筹。
HotSpot虚拟机里面关注吞吐量的Parallel Old收集器是基于「标记-整理算法」的,而关注延迟的CMS收集器则是基于「标记-清除算法」。实际上,CMS收集器中,是结合了「标记-整理算法」和「标记-清除算法」的.
一种“和稀泥式”解决方案可以不在内存分配和访问上增加太大额外负担,做法是让虚拟机平时多数时间都采用「标记-清除算法」,暂时容忍内存碎片的存在,直到内存空间的碎片化程度已经大到影响对象分配时,再采用「标记-整理算法」收集一次,以获得规整的内存空间。前面提到的基于「标记-清除算法」的CMS收集器面临空间碎片过多时采用的就是这种处理办法
算法示意图
这一部分是看书后自己总结的,不一定准确~~
HotSpot虚拟机内存回收主要有下面的几个步骤
这三种垃圾收集算法都是「追踪式垃圾收集」算法,都包含"标记"阶段,而"标记"阶段是在第2步完成的
垃圾收集器的职责不仅仅是垃圾收集,描述它的职责的一个更贴切的名词是「自动内存管理子系统」,自动内存管理最根本的目标是自动化地解决两个问题:自动给对象分配内存以及自动回收分配给对象的内存。
一个垃圾收集器除了垃圾收集的本职工作外,还要负责堆的管理与布局、对象的分配、与解释器的协作、与编译器的协作、与监控子系统协作等职责。其中至少堆的管理和对象的分配这部分功能是Java虚拟机能够正常运作的必要支持。
参考书P121和P129
《深入理解Java虚拟机 第3版》