JVM
JVM基础知识
主力机型
HotSpot VM
HotSpot虚拟机时OpenJDK和OracleJDK中默认的Java虚拟机。它最初并非由Sun公司所开发,而是由一家名为“Longview Technologies”的小公司设计。Sun公司注意到这款虚拟机在即时编译等多个方面有着优秀的理念和实际成果,在1997年收购了Longview Technologies公司,从而获得了HotSpot虚拟机
让HotSpot虚拟机与总不同的就是它的热点探测技术。HotSpot虚拟机通过执行计数器找出最具有编译价值的代码。然后通知即时编译器以方法为单位进行编译。通过编译器与解释器恰当地协同工作,可以在最优化的程序响应时间与最佳执行性能中取得平衡。这样有助于引入更复杂的代码优化技术,输出质量更高的本地代码。
Oracle收购Sun以后,建立了HotRockit项目来把原来BEA JRockit中的优秀特性融合到HotSpot之中。到了2014年的JDK 8时期,里面的HotSpot就已是两者融合的结果,HotSpot在这个过程里移除掉永久代,吸收了JRockit的Java Mission Control监控工具等功能。
IBM J9 VM
IBM J9虚拟机的职责分离与模块化做得比HotSpot更优秀,由J9虚拟机中抽象封装出来的核心组件库(包括垃圾收集器、即时编译器、诊断监控子系统等)就单独构成了IBM OMR项目
在追求高性能情景,或者嵌入式的Java开发中,更推荐使用IBM J9 VM(前者是因为优秀的垃圾回收算法,后者是IBM J9 VM的模块化比HotSpot更优秀)
体系结构
- 每个JVM都有一个类加载子系统,它根据给定的全限定名来载入类。
- 每个JVM都有一个执行引擎,它负责执行那些包含在被载入类的方法中的指令。
- 当JVM运行一个程序时,它需要内存来存储许多东西,例如:字节码、从已载入的class文件中得到的
其他信息、程序创建的对象、传递给方法的参数、返回值、局部变量,以及运算的中间结果等等。
JVM把这些东西都组织到几个“运行时数据区”中,以便于管理。
运行时数据区
程序计数器
程序计数器是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。
为了线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存。
栈
我们通常将Java内存区笼统的划分为 堆和栈,这种划分方式直接继承自传统的C、C++程序的内存布局结构,在Java语言里就显得有些粗糙了,实际的内存区域划分要比这更复杂。
Java中的栈大致可以分为虚拟机栈和本地方法栈。他们的发挥的作用是非常相似的,其区别只是
虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,
而本地方法栈则是为虚拟机使用到的本地(Native)方法服务。
虚拟机栈
虚拟机栈也是线程私有的,它的生命周期与线程相同。
虚拟机栈描述的是Java方法执行的线程内存模型:每个方法被执行的时候,
Java虚拟机都会同步创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息。每一个方法被调用直至执行完毕的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
堆
堆是虚拟机所管理的内存中最大的一块。Java堆是被所有线程共享的一块内存区域,在虚拟机启动时创建。此内存区域的唯一目的就是存放对象实例,Java世界里“几乎”所有的对象实例都在这里分配内存。堆是垃圾收集器管理的内存区域,因此一些资料中它也被称作“GC堆”。
堆既可以被实现成固定大小的,也可以是可扩展的,不过当前主流的Java虚拟机都是按照可扩展来实
现的(通过参数-Xmx和-Xms设定)。如果在Java堆中没有内存完成实例分配,并且堆也无法再扩展时,
JVM将会抛出OutOfMemoryError异常。
方法区
方法区与堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码缓存等数据。
在《Java虚拟机规范》中方法去也叫做非堆,目的是与堆区分开来。
对象如何存放
对象的创建过程
检查
检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并检查这个符号引用所代表的类是
否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
分配
在类加载检查通过后,接下来虚拟机将为新生对象分配内存。对象所需内存的大小在类加载完成后便
可完全确定,为对象分配空间的任务实际上便等同于把一块确定大小的内存块从堆中划分出来。
初始化
内存分配完成之后,虚拟机必须将分配到的内存空间(不包括对象头)都初始化为零值。这步操作保
证了对象的实例字段在Java代码中可以不赋初始值就直接使用,使程序能访问到这些字段的零值。
设置
接下来,Java虚拟机还要对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的
元数据信息、对象的哈希码、对象的GC分代年龄等信息,这些信息存放在对象的对象头之中。
构造
到目前为止,构造函数,即Class文件中的()方法还没有执行,所有的字段都为默认的零值,对象
需要的其他资源和状态信息也还没有按照预定的意图构造好。所以,new指令之后会接着执行()
方法,按照程序员的意愿对对象进行初始化,这样一个真正可用的对象才算完全被构造出来。
对象的内存布局
对象头
第一类是用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、
偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机中分别为32个比特和64个比特,官
方称它为“Mark Word”。考虑到虚拟机的空间效率,Mark Word被设计成一个有着动态定义的数据结构,
以便在极小的空间内存储尽量多的数据,根据对象的状态复用自己的存储空间。
第二类是类型指针,即对象指向它的类型元数据的指针,Java虚拟机通过这个指针来确定该对象是哪
个类的实例。并不是所有的虚拟机实现都必须在对象数据上保留类型指针,换句话说,查找对象的元数据
信息并不一定要经过对象本身。
此外,如果对象是一个Java数组,那在对象头中还必须有一块用于记录数组长度的数据,因为虚拟机
可以通过普通Java对象的元数据信息确定Java对象的大小,但是如果数组的长度是不确定的,将无法通过
元数据中的信息推断出数组的大小。
实例数据
实例数据部分是对象真正存储的有效信息,即我们在程序代码里面所定义的各种类型的字段内容,
无论是从父类继承下来的,还是在子类中定义的字段都必须记录起来。
对齐填充
这并不是必然存在的,也没有特别的含义,它仅仅起着占位符的作用。由于HotSpot虚拟机的自动
内存管理系统要求对象起始地址必须是8字节的整数倍,换句话说就是任何对象的大小都必须是8字节的整
数倍。对象头部分已经被精心设计成正好是8字节的倍数,而如果对象实例数据部分没有对齐的话,就需
要通过对齐填充来补全
对象的访问定位
主流的访问方式主要有使用句柄和直接指针两种。就HotSpot虚拟机而言,它主要使用第二种方式进
行对象访问,但从整个软件开发的范围来看,在各种语言、框架中使用句柄来访问的情况也十分常见
使用句柄
如果使用句柄访问的话,堆中将可能会划分出一块内存来作为句柄池,reference中存储的就是对象
的句柄地址,而句柄中包含了对象实例数据与类型数据各自具体的地址信息。
使用句柄来访问的最大好处就是reference中存储的是稳定句柄地址,在对象被移动(垃圾收集时移
动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要被修改
直接指针
使用直接指针来访问最大的好处就是速度更快,它节省了一次指针定位的时间开销,由于对象访问
在Java中非常频繁,因此这类开销积少成多也是一项极为可观的执行成本。
垃圾回收机制
在JVM的数据区中,程序计数器和栈都是生命周期和线程保持一致。而堆和方法区的数据是所有线程共享的,垃圾收集器关注的重点也就在堆和方法区的内存该如何管理。
如何判定对象已死
引用计数法
在对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加一,当引用失效时,计数器值就减一,任何时刻计数器为零的对象就是不可能再被使用的。
在Java领域,没有使用他的原因是无法解决相互引用的问题,a只引用了b,而b也只引用了a。a和b之间相互引用。但是却没引用其他对象。这就会造成资源浪费。
可达性分析算法
通过一系列的根对象作为引用链的起点,从这些节点开始向下搜索。如果某个对象和引用链没有关联。那么就证明了这个对象是不可使用的。
在Java技术体系里面,固定可作为GC Roots的对象包括以下几种:
- JVM内部的引用,如基本数据类型对应的Class对象,常驻的异常对象(如NullPointExcepiton),以及系
统类加载器; - 在虚拟机栈中引用的对象,譬如各个线程被调用的方法堆栈中使用到的参数、局部变量、临时变量等;
- 在方法区中类静态属性引用的对象,譬如Java类的引用类型静态变量;
- 在方法区中常量引用的对象,譬如字符串常量池里的引用;
- 在本地方法栈中引用的对象;
- 所有被同步锁(synchronized关键字)持有的对象;
- 反映Java虚拟机内部情况的JMXBean、JVMTI中注册的回调、本地代码缓存等
对象的引用级别
为强引用、软引用、弱引用、虚引用4种,这4种引用强度依次逐渐减弱
对象的死亡过程
- 第一次标记:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次
标记,随后进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。假如对象没有覆盖
finalize()方法,或者finalize()方法已经被虚拟机调用过,那么虚拟机将这两种情况都视为“没有必要执
行”。反之,该对象将会被放置在一个名为F-Queue的队列之中,并在稍后由一条由虚拟机自动建立的、
低调度优先级的Finalizer线程去执行它们的finalize()方法。 - 第二次标记:稍后,收集器将对F-Queue中的对象进行第二次小规模的标记。如果对象要在finalize()中
成功拯救自己,只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this)赋值给某个类
变量或者对象的成员变量,那在第二次标记时它将被移出“即将回收”的集合。如果对象这时候还没有
逃脱,那基本上它就真的要被回收了
回收方法区
- 回收废弃的常量,假如一个字符串“java”曾经进入常量池中,但是当前系统又没有任何一个字符串对象的值是“java”,换句话说,已经没有任何字符串对象引用常量池中的“java”常量,且虚拟机中也没有其他地方引用这个字面量。如果在这时发生内存回收,而且垃圾收集器判断确有必要的话,这个“java”常量就将会被系统清理出常量池。常量池中其他类(接口)、方法、字段的符号引用也与此类似。
- 回收不使用的类,要判定一个类型是否属于“不再被使用的类”,需要同时满足下面三个条件:
- 该类所有的实例都已经被回收,也就是Java堆中不存在该类及其任何派生子类的实例。
- 加载该类的类加载器已经被回收,这个条件除非是经过精心设计,一般是很难达成的。
- 该类对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问该类的方法。
Java虚拟机被允许对满足上述三个条件的无用类进行回收,这里说的仅仅是“被允许”,而并不是和对象一样,没有引用了就必然会回收。关于是否要对类型进行回收,HotSpot虚拟机提供了-Xnoclassgc参数进行控制
垃圾回收算法
分代收集假说
1.弱分代假说:绝大多数对象都是朝生夕灭的。
2.强分代假说:熬过越多次垃圾收集过程的对象越难以消亡。
3.跨代引用假说:跨代引用相对于同代引用来说只占极少数
标记清除算法
算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后,统一回收掉所
有被标记的对象,也可以反过来,标记存活的对象,统一回收所有未被标记的对象。它的主要缺点有两个:
- 第一个是执行效率不稳定,如果Java堆中包含大量对象,而且其中大部分是需要被回收的,这时必须
进行大量标记和清除的动作,导致标记和清除两个过程的执行效率都随对象数量增长而降低。 - 第二个是内存空间碎片化问题,标记、清除之后会产生大量不连续的内存碎片,空间碎片太多可能会
导致当程序在运行过程中需要分配较大对象时无法找到足够的连续的内存而不得不提前触发另一次垃
圾收集。
标记复制算法
将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。对于多数对象都是可回收的情况,算法需要复制的就是占少数的存活对象,而且每次都是针对整个半区进行内存回收,分配内存时也就不用考虑有空间碎片的复杂情况,只要移动堆顶指针,按顺序分配即可。
这种复制回收算法的代价是将可用内存缩小为了原来的一半,空间浪费未免太多了一点。
为了解决空间浪费的问题,IBM公司做了一项专门研究,发现98%的新生代都熬不过第一次垃圾回收。于是在1998年Andrew Appel针对具备“朝生夕灭”特点的对象,提出了一种更优化的半区复制分代策略,现在称为“Appel式回收”。
现在HotSpot虚拟机的的Serial、ParNew等新生代收集器均采用了这种策略来设计新生代的内存布局。
HotSpot虚拟机默认Eden和Survivor的大小比例是8:1:1,也即每次新生代中可用内存空间为整个新生代容量的90%(Eden的80%加上一个Survivor的10%),只有一个Survivor空间,即10%的新生代是会被“浪费”的。(80%的空间用来处理新生代,新生代垃圾回收完毕后转移到10%的空间)
标记整理算法
标记-复制算法在对象存活率较高时就要进行较多的复制操作,效率将会降低。更关键的是,如果不
想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极
端情况,所以在老年代一般不能直接选用这种算法。
针对老年代对象的存亡特征,1974年Edward Lueders提出了另外一种有针对性的“标记-整理”算法,
其中的标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有
存活的对象都向内存空间一端移动,然后直接清理掉边界以外的内存。
如果移动存活对象,尤其是在老年代这种每次回收都有大量对象存活区域,移动存活对象并更新所
有引用这些对象的地方将会是一种极为负重的操作,而且这种对象移动操作必须全程暂停用户应用程序才
能进行,像这样的停顿被最初的虚拟机设计者形象地描述为“Stop The World”。
垃圾收集器
Serial
Serial收集器是最基础、历史最悠久的收集器,曾经是HotSpot虚拟机新生代收集器的唯一选择。这个
收集器是一个单线程工作的收集器,但它的“单线程”的意义并不仅仅是说明它只会使用一个处理器或一条
收集线程去完成垃圾收集工作,更重要的是强调在它进行垃圾收集时,必须暂停其他所有工作线程,直到
它收集结束。也就是说它在进行垃圾收集时,会发生“Stop The World”。
看起来Serial收集器已经是老而无用了,但事实上它依然是HotSpot虚拟机运行在客户端模式下的默认
新生代收集器。对于内存资源受限的环境,它是所有收集器里额外内存消耗最小的。对于单核处理器或处
理器核心数较少的环境来说,Serial收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高
的单线程收集效率。
Serial Old
Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法,这个收集
器的主要意义也是供客户端模式下的HotSpot虚拟机使用。
CMS
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器,从名字上就
可以看出CMS收集器是基于标记-清除算法实现的,它的运作过程相对于前面几种收集器来说要更复杂一些,
整个过程分为四个步骤,包括:初始标记、并发标记、重新标记、并发清除。其中初始标记、重新标记这
两个步骤仍然需要“Stop The World”
CMS是一款优秀的收集器,它最主要的优点在名字上已经体现出来:并发收集、低停顿,一些官方
公开文档里面也称之为“并发低停顿收集器”。CMS收集器是HotSpot虚拟机追求低停顿的第一次成功尝试,
但是它还远达不到完美的程度,至少有以下三个明显的缺点:
- 并发阶段,虽然不会导致用户线程停顿,却因为占用一部分线程而导致应用程序变慢,降低总吞吐量。
- 它无法处理“浮动垃圾”,有可能会出现“并发失败”进而导致另一次Full GC的发生。
- 它是一款基于标记清除算法实现的收集器,这意味着收集结束时会有大量空间碎片产生
“浮动垃圾”指的是,CMS在标记的过程中,线程是不间断的,会持续产生垃圾,而这些垃圾只能等到下次GC时才会清除掉。
G1
arbage First(简称G1)收集器是垃圾收集器技术发展历史上的里程碑式的成果,它开创了收集器
面向局部收集的设计思路和基于Region的内存布局形式。到了JDK 8 Update 40的时候,G1提供并发的类
卸载的支持,补全了其计划功能的最后一块拼图。这个版本以后的G1收集器才被Oracle官方称为“全功能
的垃圾收集器”。
G1是一款主要面向服务端应用的垃圾收集器,HotSpot开发团队最初赋予它的期望是未来可以替换掉
JDK 5中发布的CMS收集器。直到JDK 9发布之日,G1宣告取代Parallel Scavenge加Parallel Old组合,成为服
务端模式下的默认垃圾收集器,而CMS则沦落至被声明为不推荐使用的收集器。
在G1收集器出现之前的所有其他收集器,包括CMS在内,垃圾收集的目标范围要么是整个新生代
(Minor GC),要么就是整个老年代(Major GC),再要么就是整个Java堆(Full GC)。而G1跳出了这
个限制,它可以面向堆内存任何部分来组成回收集进行回收,**衡量标准不再是它属于哪个分代,而是哪
块内存中存放的垃圾数量最多,回收收益最大,**这就是G1收集器的Mixed GC模式
G1和CMS的对比
G1从整体来看是基于标记整理算法实现的收集器,但从局部上看又是基于标记复制算法实现。无
论如何,这两种算法都意味着G1运作期间不会产生内存空间碎片,垃圾收集完成之后能提供规整的可
用内存。
比起CMS,G1的弱项也可以列举出不少。例如在用户程序运行过程中,G1无论是为了垃圾收集产
生的内存占用还是程序运行时的额外执行负载都要比CMS要高
G1不会产生空间碎片,但是G1的性能消耗比CMS要高很多。
G1与CMS的选择:
目前在小内存应用上CMS的表现大概率仍然要会优于G1,而在大内存应用上G1则大多能发挥其优
势,这个优劣势的Java堆容量平衡点通常在6GB至8GB之间。以上这些也仅是经验之谈,随着HotSpot的
开发者对G1的不断优化,也会让对比结果继续向G1倾斜。
大应用:G1
小应用:CMS
类的加载机制
类加载的过程
个类型从被加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期将会经历加载、验
证、准备、解析、初始化、使用、卸载七个阶段,其中验证、准备、解析三个部分统称为连接
关于在什么情况下需要开始类加载过程的第一个阶段“加载”,《Java虚拟机规范》中并没有进行强制
约束,这点可以交给虚拟机的具体实现来自由把握。但是对于初始化阶段,《Java虚拟机规范》则是严格
规定了有且只有六种情况必须立即对类进行“初始化”:
- 使用new实例化对象、读写类的静态字段、调用类的静态方法时。
- 使用java.lang.reflect包的方法对类型进行反射调用时。
- 当初始化类时,若发现其父类还没有进行过初始化,则先初始化这个父类。
- 虚拟机启动时,需要指定一个要执行的主类,虚拟机会先初始化这个主类。
- 当使用JDK 7新加入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为
REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种类型的方法句柄,并且这个
方法句柄对应的类没有进行过初始化,则需要先触发其初始化。 - 当一个接口中定义了JDK 8新加入的默认方法(被default关键字修饰的接口方法)时,如果有这个接口
的实现类发生了初始化,那该接口要在其之前被初始化
加载
在加载阶段,Java虚拟机需要完成以下三件事情:
- 通过一个类的全限定名来获取定义此类的二进制字节流。
- 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
- 在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
验证
验证是连接阶段的第一步,这一阶段的目的是确保Class文件的字节流中包含的信息符合《Java虚拟机
规范》的全部约束要求,保证这些信息被当作代码运行后不会危害虚拟机自身的安全。
准备
**准备阶段是正式为类中定义的变量(静态变量)分配内存并设置类变量初始值的阶段,**这些变量所
使用的内存都应当在方法区中进行分配,但必须注意到方法区本身是一个逻辑上的区域。
关于准备阶段,还有两个容易产生混淆的概念需要强调。首先是这时候进行内存分配的仅包括类变
量,而不包括实例变量。其次是这里所说的初始值“通常情况”下是数据类型的零值。
解析
解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程
初始化
类的初始化阶段是类加载过程的最后一个步骤,直到初始化阶段,Java虚拟机才真正开始执行类中编
写的Java程序代码,将主导权移交给应用程序。本质上,初始化阶段就是执行类构造器()的过程。
()并不是程序员在Java代码中直接编写的方法,它是Javac编译器的自动生成物。
双亲委派模型
对于JDK 8及其之前版本的Java应用,都会使用到以下3个系统提供的类加载器来进行加载:
- 启动类加载器:这个类加载器负责加载存放在<JAVA_HOME>\lib目录,或者被-Xbootclasspath参数所
指定的路径中存放的,而且是Java虚拟机能够识别的类库加载到虚拟机的内存中。注意,Java虚拟机会
按照文件名识别类库,例如rt.jar、tools.jar,对于名字不符合的类库即使放在lib目录中也不会被加载。
启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给
启动类加载器去处理,那直接使用null代替即可,即让getClassLoader()返回null。 - 扩展类加载器:这个类加载器是在sun.misc.Launcher$ExtClassLoader中以Java代码形式实现的。它负责
加载<JAVA_HOME>\lib\ext目录中,或者被java.ext.dirs系统变量所指定的路径中所有的类库。由于扩展
类加载器是由Java代码实现的,开发者可以直接在程序中使用扩展类加载器来加载Class文件。 - 应用程序类加载器:这个类加载器由sun.misc.Launcher$AppClassLoader来实现。它负责加载用户类路
径(classpath)上所有的类库,开发者同样可以直接在代码中使用这个类加载器。
工作过程
如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此。
好处:是Java中的类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都能够保证是同一个类。不然系统会出现多个Object类。会非常混乱。
参考牛客网JVM教程