56 KiB
- 深入理解java虚拟机
深入理解java虚拟机
Java内存区域和内存溢出异常
运行时数据区
java程序在虚拟机运行时会将其所管理的内存区域划分为若干个不同的数据区。java运行时数据区的结构如下:
程序计数器
程序计数器是一块较小的内存空间,其可以被看做是当前线程所执行字节码的行号指示器。在虚拟机的概念模型中,字节码解释器在工作时会修改程序计数器的值,从而选取下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都依赖计数器来完成。
在java虚拟机中,多线程是通过线程轮流切换,并分配处理器的执行时间片来实现的。故而,在任一给定时刻,一个处理器只会执行一个线程中的指令(即给定时刻一个处理器只和一个线程进行绑定)。为了线程切换后,仍然能恢复到线程上次执行的位置,每个线程都会有一个独立的程序计数器。各个线程之间的程序计数器互不干扰,独立存储,该类内存为线程私有的内存。
由于JVM支持通过
本地方法来调用c++/c等其他语言的native方法,故而:
- 如果线程正在执行的是java方法,计数器记录的是正在执行的虚拟字节码指令地址
- 如果线程正在执行的是native方法,计数器的值为空
Java虚拟机栈(Java Virutal Machine Stack)
和程序计数器一样,java虚拟机栈也是线程私有的,其声明周期和线程相同。
虚拟机栈描述了java方法执行时的内存模型:
- 每个方法在执行时都会创建一个栈帧,用于存储局部变量、操作数栈、动态链接、方法出口等信息
- 每个方法从调用直至执行完成的过程,对应一个栈帧在虚拟机栈中入栈、出栈的过程
局部变量
局部变量中存放了编译器可知的各种数据类型,如下:
- 基本数据类型(primitives)
- 对象引用
- returnAddress类型(指向一条字节码指令的地址)
其中,64位长度的long和double数据类型会占用2个局部变量空间,其余数据类型只占用一个。
局部变量表所需的空间大小在编译时就已经确定,在运行时进入一个方法时,该方法需要的局部变量表大小就已经确定,并且
方法执行过程中不会改变局部变量表的大小
在java虚拟机规范中,针对该区域定义了两种异常状况:
StackOverflowError:线程所请求的栈深度大于虚拟机所允许的栈深度,将抛出StackOverflowErrorOutOfMemoryError:如果虚拟机栈可以动态拓展,且拓展时无法申请到足够的内存,将会抛出OutOfMemoryError
本地方法栈
本地方法栈和虚拟机栈的作用类似,区别如下:
- 虚拟机栈为虚拟机执行Java方法服务
- 本地方法栈为虚拟机用到的Native方法服务
和虚拟机栈相同,本地方法栈也会抛出OutOfMemoryError和StackOverflowError
Java堆
Java堆是java虚拟机所管理内存中最大的部分,java堆为所有线程所共享,在虚拟机创建时启动。java堆内存用于存放对象实例,几乎所有的对象实例堆在该处分配内存。
Java堆是垃圾收集器管理的主要区域,在很多时候也被称为GC堆。从垃圾收集的角度看,现代收集器基本都采用分代收集的算法,故而java堆内存还可以进一步细分为新生代和老年代。
虽然Java堆内存为线程所共享的区域,但从内存分配的角度看,Java堆中可能划分出多个线程私有的分配缓冲区(Thead Local Allocation Buffer,TLAB)。
但是,不论Java堆内存如何划分,其存放的内容都为对象实例。Java堆内存可以处于物理上不连续的内存空间中,只需在逻辑上连续即可。通常,java堆内存都是可拓展的(通过-Xms和-Xmx控制),如果在为实例分配内存时,没有足够的内存空间进行分配,并且堆也无法再进行拓展时,将会抛出OutOfMemoryError。
方法区
方法区和java堆一样,是各个线程共享的区域,其用于存储已被虚拟机加载的类信息、常量、静态变量、JIT编译后的代码等数据。
jvm规范对方法区的限制比较宽松,方法区的内存并不需要在物理上连续,并可选择固定大小或可拓展。除此之外,方法区还可以选择不实现垃圾回收。
通常,发生在方法区的垃圾回收比较罕见,对方法区的垃圾回收主要是针对常量池的回收和对类型的卸载。类型卸载触发的条件比较苛刻。
当方法区无法满足内存分配的需求时,将抛出OutOfMemoryError。
运行时常量池
运行时常量池是方法区的一部分。在class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放在编译期生成的各种字面量和符号引用。
该部分内存将会在类加载后,进入方法区的运行时常量池进行存放。
在java程序运行时,也能将新的常量放入池中,例如
String.intern()方法。
直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是java虚拟机规范中定义的内存区域,但该部分内存也会被频繁使用,并且可能导致OutOfMemoryError。
在JDK 1.4中,引入了NIO,其可以使用native函数库直接分配堆外内存,然后通过一个存储在Java堆内的DirectByteBuffer对象操作堆外内存。这样能够在部分场景中显著提高性能,避免在java堆和native堆中来回复制数据。
并且,直接内存并不会受java堆大小的限制,但仍然会受物理机物理内存大小的限制。
Hotspot虚拟机对象探秘
对象创建
类加载
虚拟机在遇到new指令时,首先会去方法区常量池中进行匹配,检查是否有对应的符号引用,并检查该符号引用对应的类是否已经被加载、解析、初始化。如果尚未被加载,则先执行该类的加载过程。
在加载该类后,对象所需的内存大小便可完全确定,会为该对象的创建分配内存空间。
内存分配方式
在java堆内存中的内存分配存在如下几种方式:
- 指针碰撞: 如果Java堆内存中内存是绝对规整的,所有用过的指针都放在一边,空闲内存放在另一边,那么可以通过一个中间的指针来划分使用的空间和空闲空间
- 当分配内存空间时,仅需将指针向空闲空间的那个方向移动即可
- FreeList:如果Java中的堆内存并不规整,已使用的内存空间和空闲内存空间相互交错,此时并无法使用指针碰撞的方式来分配内存。虚拟机会维护一个列表,列表中维护空闲的内存块。
- 在内存分配时,会从列表中找到一块足够大的空间划分给对象,并更新列表上的空闲内存记录
选择采用哪种内存分配方式取决于java堆内存是否规整,而java堆是否规整则取决于采用的垃圾收集器是否带有压缩整理功能。
故而,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞;而使用CMS和Mark-Sweep算法的收集器时,通常采用FreeList
TLAB
对象创建是非常频繁的行为,多线程场景下容易出现并发安全的问题。即使使用指针碰撞的方式来分配内存空间,也会出现争用问题。解决内存分配下的并发问题存在两种方案:
- 对分配内存空间的操作进行同步,可以采用CAS等方案保证分配内存动作的原子性
- 将内存分配的动作按照线程的不同划分在不同区域中,即每个线程先在java堆内存中预先分配一小块内存,称为TLAB,每个线程都在自身的TLAB中分配内存空间
- 只有当线程的TLAB使用完并需要分配新的TLAB时,才需要同步锁定操作,这样能减少同步锁定这一耗时操作的次数
虚拟机是否采用TLAB,可以通过-XX:+/-UseTLAB参数来设定。
内存分配完成后,虚拟机需要将分配到的内存空间都初始化为0,若使用TLAB,则该操作可提前到分配TLAB时。该操作可以保证对象的实例字段在不赋值初始值时就能使用,程序能够访问这些字段数据类型所对应的零值。
初始化
在为对象分配完空间后,会执行<init>方法进行初始化。
对象布局
在Hotspot虚拟机中,对象在内存中的布局可以分为3块区域:
- 对象头(Header)
- 实例数据(Instance Data)
- 对齐填充(Padding)
对象头
对象头中包含两部分信息:
- 对象的运行时数据,如hash code、gc分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等
- 该部分在32bit和64bit的虚拟机中,长度分别为32bit和64bit,被官方称为
MarkWord
- 该部分在32bit和64bit的虚拟机中,长度分别为32bit和64bit,被官方称为
- 类型指针:该部分信息指向对象对应
类元数据的指针,虚拟机会通过该指针来确定对象是哪个类的实例。 - 数组长度:如果对象是一个java数组,那么对象头中还需要记录数组的长度
实例数据
实例数据为对象真正存储的有效信息,即程序代码中定义的各种类型字段内容,无论是从父类继承的还是子类中定义的。
实例数据中的存储顺序会收到虚拟机分配策略参数和字段在java源码中定义顺序的影响。Hotspot虚拟机默认的分配策略为longs/doubles, ints, shorts/chars, bytes/booleans、oops(Ordinary Object Pointers),相同宽度的字段会被分配到一起。
对齐填充
对齐填充并不是自然存在的,仅起占位符的作用。hotsopt虚拟机自动内存管理系统要求对象起始地址必须是8字节的整数倍,即对象大小必须为8字节的整数倍。
对象头刚好是8字节整数倍,故而当对象的实例数据没有对对齐,需要通过对象填充来对齐。
对象的访问定位
java程序需要通过虚拟机栈上的reference来操作java堆中具体的对象。目前,访问堆上对象有两种主流的方式:
- 句柄访问:java堆中会划分一块区域来作为句柄池,references存储的地址即为对象句柄的地址,句柄中则包含了对象实例数据地址和对象类型数据地址
- 使用句柄访问时,reference中存储的是句柄地址,即使对象被移动(在垃圾回收时非常普遍),也只需要修改句柄中的实例数据指针,不需要修改虚拟机栈中的reference
- 直接指针访问:采用直接指针访问时,堆内存中无需维护句柄池,而java的堆对象中则包含类型数据的地址。reference中直接会存储java堆对象的地址,而可以通过堆对象中存储的类型数据地址再次访问类型数据
- 使用直接指针访问时,访问速度更改,在访问实例数据时能够节省一次内存查找的开销。
在Hotspot实现中,采用的是直接指针访问方案。
垃圾收集器和内存分配策略
在上一章节中提到的java运行时数据区域中,java虚拟机栈、本地方法栈、程序计数器三个区域是线程私有的,生命周期和线程一致,无需考虑垃圾回收问题,在方法结束/线程终止时内存会随之回收。
但是,方法区和java堆内存这两个区域则不同,内存的分配和回收都是动态的,垃圾收集器主要关注这部分内存。
对象是否应当被回收
堆中几乎存放着所有的java对象,垃圾收集器在对对内存进行回收前,需要确定哪些对象可以被回收,哪些对象不能被回收。
引用计数算法
该算法会为对象添加一个引用计数器,每当有一个地方引用该对象时,计数器加一;当引用失效时,计数器则减一;技术器为0代表该对象不再被引用。
但是,java虚拟机并没有采用引用计数算法来管理内存,主要原因是其难以解决对象之间循环引用的问题。
可达性分析算法
在主流的商用程序语言(JAVA/C#等)的主流实现中,都通过可达性分析来判定对象是否可以被回收的。该算法核心是根据一系列被称为GC Roots的对象来作为起点,从这些节点开始向下搜索,搜索时所走过的路径被称为引用链,当一个对象GC Roots没有任何的引用链相连接时,即从GC Roots到该对象不可达时,证明该对象是不可用的,可以被回收。
在Java语言中,GC Roots包含如下内容:
- 虚拟机栈中所引用的对象
- 方法区中静态属性应用的对象
- 方法区中常量所引用的对象
- 本地方法栈中JNI所引用的对象
引用类型
在JDK 1.2之后,Java对引用的概念进行了扩充,引入了如下的引用类型概念:
- 强引用(Strong Reference)
- 软引用(Soft Reference)
- 弱引用(Weak Reference)
- 虚引用(Phantom Reference)
上述四种引用类型,引用强度逐级递减:
强引用:对于强引用类型,只要强引用还存在,垃圾收集器永远不会回收掉被引用对象软引用:软引用用于描述一些有用但非必须的对象。对于软引用关联着的对象,在系统即将要发生内存溢出的异常之前,会将这些引用列进回收范围之内进行二次回收。如果此次回收之后还没有足够的内存,才会抛出内存溢出异常。- 即在抛出OOM之前,SoftReference会被清空,如果清空后内存仍然不够,就会实际抛出OOM
弱引用:弱引用用于描述非必须对象,但其强度比软引用更弱一些。被弱引用关联的对象只能生存到下一次垃圾收集发生之前。- 在垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象
虚引用:虚引用是最弱的引用关系,一个对象是有虚引用存在,完全不会对其生存时间造成影响,也无法通过虚引用获得一个对象实例。- 为对象设置虚引用关联的唯一目的是能在该对象被收集器回收时收到一个系统通知
垃圾回收细节
即使在可达性分析算法中被判定为不可达,对象也并非一定会被回收。要真正宣告对象的死亡,必须经历两次标记过程:
- 如果在可达性分析后发现GC Roots到该对象不可达,其将会被第一次标记并进行一次筛选,筛选条件是对该对象是否有必要执行finalize方法:
- 如果当前对象没有覆盖finalize方法,或是finalize方法已经被虚拟机调用过,则虚拟机将其视为没有必要执行
- 如果当前对象被判定为需要执行finalize方法,那么该对象将会被放置到F-QUEUE的队列中,并且稍后由一个
由虚拟机自动建立的、优先级较低的Finalizer线程去执行。该执行只是触发该方法,并不承诺会等待其执行结束。- finalize方法是对象逃脱死亡命运的最后一次机会,稍后GC将会对F-QUEUE中的对象进行第二次小规模的标记,如果对象需要在finalize中避免自己被回收,只需将自身和引用链上任一对象建立关联即可(例如将自身赋值给引用链上任一对象的成员变量或类变量),那么在第二次标记时其将被移除即使回收的集合;如果此时对象仍未逃脱,其会被真正回收
即对象被GC第一次标记为不可达后,只能通过finalize方法来避免自己被回收,并且finalize方法只会被调用一次。需要覆盖finalize逻辑,在finalize方法中重新令自身变得可达,此时才能避免被垃圾回收。
如果一个对象已经调用过一次finalize,并且成功变为可达。那么其再次变为不可达后,将不会有再次调用finalize的机会,这次对象将会被回收。
通常,不推荐使用finalize方法。
回收方法区
Java虚拟机规范并不强制要求对方法区进行垃圾回收,并且在方法区进行垃圾回收的性价比较低:
- 在堆中,尤其在新生代中,常规应用进行一次垃圾收集一般可以回收70%~95%的空间,但方法区垃圾回收的效率远低于此
方法区的垃圾回收主要回收两部分内容:
- 废弃常量
- 无用类
例如,在回收字面量时,如果一个字符串“abc"已进入常量池,但是当前系统没有任何一个String对象引用常量池中“abc”常量,也没有其他地方引用该字面量,如果此时发生垃圾回收,该常量将会被系统清理出常量池。常量池中其他方法、接口、字段、类的符号引用也与此类似。
判断类是否可回收
判断类是否可回收的条件比较苛刻,必须同时满足如下条件:
- 该类所有实例已经被回收,java堆中不存在任何该类的实例
- 加载该类的classloader已经被回收
- 该类对应的java.lang.Class对象没有在其他任何地方被引用,无法在其他任何地方通过反射访问该类的方法
在大量使用反射、动态代理、Cglib、动态生成jsp、动态生成osgi等频繁自定义ClassLoader的场景都需要虚拟机具备类卸载功能,已保证永久代不会溢出。
垃圾收集算法
标记-清除算法
最基础的垃圾收集算法是标记-清除算法(Mark-Sweep),算法分为标记和清除两个阶段:
- 首先,算法会标记出需要回收的对象
- 对象在被认定为需要回收前,需要经历两次标记,具体细节在前文有描述
- 在标记完成后,会对所有被标记对象进行统一回收
标记清除算法存在如下不足之处:
效率问题:标记和清除这两个过程的效率都不高空间问题: 标记和清除之后会产生大量不连续的内存碎片,大量碎片可能导致后续需要分配大对象时无法找到足够的连续内存而不得不触发另一次垃圾收集动作
复制算法
为了解决效率问题,提出了一种复制算法。该算法将可用内存划分为大小相等的两块,每次只使用其中的一块,当本块内存用尽后,其会将存活对象复制到另一块上,并将已使用的内存空间一次性清理掉。这样,每次回收都是针对半区的,并且在分配内存时也不用考虑内存碎片的问题。
复制算法存在如下弊端:
- 复制算法将可用内存划分为相同的两部分,每次只使用一半,该代价比较高昂
基于复制算法的虚拟机新生代内存划分
当前,商用虚拟机都采用该种方法来回收新生代。在新生代中,98%的对象生命周期都很短暂,完全不需要根据1:1来划分内存空间。对于新生代内存空间,将其划分为了一块较大的Eden空间和两块较小的Survivor空间,每次只会使用Eden和其中一块Survivor。
当发生垃圾回收时,会将Eden区和Survivor区中还存活的对象一次性复制到另一块Survivor。Hotspot虚拟机默认Eden区和两块Survivor区的大小比例为8:1:1,即每次新生代内存区域中可用内存为90%。当然,并不能确保每次发生垃圾回收时,另一块Survivor区能够容纳Eden+Survivor中所有的存活对象,故而,需要老年代来作为担保。
标记-整理算法
在采用复制算法时,如果存活对象较多,那么将会需要大量复制操作,这样会带来较高的性能开销,并且,如果不想带来50%的内存空间浪费,就需要额外的内存空间来进行担保,故而,在老年代采用复制算法来进行垃圾回收是不适当的。
基于老年代的特点,提出了标记-整理算法(Mark-Compact):
- 标记过程仍然和
标记-清除一样 - 标记过程完成后,并不是直接对可回收对象进行清理,而是将存活对象都向一端移动,然后直接清理掉边界以外的内存
标记-整理算法相对于标记-清除算法,其能够避免产生内存碎片。
分代收集算法
目前,商业虚拟机的垃圾收集都采用分代收集的算法,这种算法将对象根据存活周期的不同,将内存划分为好几块。
一般,会将java堆内存划分为新生代和老年代,针对新生代对象的特点和老年代对象的特点采用不同且适当的垃圾回收算法:
- 在新生代中,对象生命周期短暂,每次垃圾收集都有大量对象需要被回收,只有少部分对象才能存活,应采用复制算法
- 在新生代,存活对象较少,复制的目标区的内存占比也可以划分的更少,并且较少存活对象带来的复制成本也较低
- 在老年代,对象存活率更高,并且没有额外的内存空间来对齐进行担保,故而不应使用复制算法,而是应当采用
标记-清除算法或标记-整理算法来进行回收
三色标记算法
为了减少垃圾回收过程对于用户线程的阻塞,引入了三色标记算法。三色标记算法在标记垃圾的过程中将对象分为了三种不同的状态:
- 黑:黑色代表该对象及其所有引用已经被完全扫描,被标记为黑色代表该对象不会再次被扫描
- 白:白色代表该对象从未被扫描,如果标记过程结束后,仍然处于白色状态的对象将会被视为垃圾
- 灰:灰色代表该对象已经扫描过但是没有完全扫描,未来还会对该对象进行扫描
三色标记算法在并发标记时的缺陷
如果在采用三色标记算法的同时并发的执行用户线程,用户线程在可达性分析过程中对对象进行标记时,仍然能改变对象之间的引用关系,那么可能会出现如下问题:
- 浮动垃圾(多标):假如一个对象已经被标记为黑色/灰色、可达,但是用户线程后续断开了其他对象到该对象的引用,该对象实际应当被回收,但是本次仍然会存活,得等待到下一阶段再次回收
- 对象消失(漏标):除此之外,还会出现对象消失的问题,假设用户线程新增了黑色对象到白色对象的引用,那么黑色对象不会再被重新扫描,此时白色对象在本次垃圾回收之后将会被判定为应回收,即使其仍然被黑色对象所引用,这将造成对象消失的问题
CMS对该问题的解决方法
CMS为了解决该问题,其采用了如下方案:
- 当为黑色状态的对象创建新引用时,对象将会重新变为灰色(通过写屏障实现)
写屏障是垃圾回收器在应用线程修改对象引用时,插入的一段逻辑hook,其类似于拦截器,分为pre-write barrier和post-write barrier
Hotspot算法实现
枚举根节点
在可达性分析中,需要通过GC Roots来查找引用链。可以作为GC Roots的节点有:
- 全局性的引用
- 常量
- 类静态属性
- 执行上下文
- 栈帧中的本地变量表
目前,很多应用仅方法区就包含数百兆,如果需要逐个检查这方面引用,需要消耗较多时间。
可达性分析对执行时间的敏感还体现在GC停顿上,在执行可达性分析时,需要停顿所有的Java执行线程(STW),避免在进行可达性分析时对象之间的引用关系还在不断的变化。
在Hotspot实现中,采用OopMap的数据结构来实现该目的,在类加载完成时,Hotspot会计算出对象内什么位置是什么类型的数据,在JIT编译过程中也会在特定位置记录栈和寄存器中哪些位置是引用。
OopMap
OopMap用于存储java stack中object referrence的位置。其主用于在java stack中查找GC roots,并且在堆中对象发生移动时,更新references。
OopMaps的种类示例如下:
OopMaps for interpreted methods: 这类OopMap是惰性计算的,仅当GC发生时才通过分析字节码流生成OopMaps for JIT-compiled methods:这类OopMap在JIT编译时产生,并且和编译后的代码一起保存
安全点
在OopMap的帮助下,Hotspot可以快速完成GC Roots的枚举。但是,可能会导致OopMap内容变化的指令非常多,如果为每条指令都生成OopMap,将耗费大量额外空间。
故而,Hotspot引入了安全点的概念,只有到达安全点时,才能暂停并进行GC。在选定Safepoint时,选定既不能太少以让GC等待时间太长,也不能过于频繁。在选定安全点时,一般会选中令程序长时间执行的的指令,例如方法调用、循环跳转、异常跳转等。
对于Safepoint,需要考虑如何在GC发生时让所有线程都跑到最近的安全点上再停顿下来。这里有两种方案:
抢先式中断:在GC发生时,首先把所有线程全部中断,如果有线程中断的地方不在安全点上,就恢复线程,让其跑到安全点上主动式中断:需要中断线程时,不直接对线程进行操作,只是会设置一个全局标志,线程在执行时主动轮询该标志,发现中断标志为true时自己中断挂起。- 轮询的时机和安全点是重合的
VM Thread会控制JVM的STW过程,其会设置全局safepoint标志,所有线程都会轮询该标志,并中断执行。当所有线程都中断后,才会实际发起GC操作。
安全区域
safepoint解决了线程执行时如何进入GC的问题,但是如果此时线程处于Sleep或Blocked状态,此时线程无法响应JVM的中断请求,jvm也不可能等待线程重新被分配cpu时间。
在上述描述的场景下,需要使用安全区域(Safe Region)。安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意位置开始GC都是安全的。可以把Safe Region看作是Safe Point的拓展版本。
当线程执行到SafeRegion时,首先标识自身已经进入Safe Region。此时,当这段时间里JVM要发起GC时,对于状态已经被标识为Safe Region的线程,不用进行处理。当线程要离开安全区域时,需要检查系统是否已经完成了根节点枚举(或整个GC过程),如果完成了,线程继续执行,否则其必须等待,直到收到可以安全离开SafeRegion的信号。
Safe Region可以用于解决线程在GC时陷入sleep或blocked状态的问题。
垃圾收集器
Serial收集器
Serial是最基本、历史最悠久的收集器,负责新生代对象的回收。该收集器是单线程的,并且在serial收集器进行回收时,必须暂停其他所有的工作线程,直到serial收集器回收结束。
ParNew
ParNew即是Serial的多线程版本,除了使用多线程进行垃圾收集外,其他行为都和Serial完全一样。
Parallel Scavenge
Parallel Scavenge是一个新生代的垃圾收集器,也采用了多线程并行的垃圾收集方式。
Parallel Scavenge收集器的特点是其关注点和其他收集器不同,CMS等收集器会尽可能缩短收集时STW的时间,而Parallel Scavenge的目标则是达到一个可控制的吞吐量。
此处,吞吐量指的是
运行用户代码时间/(运行用户代码时间 + 垃圾收集时间),例如,虚拟机总共运行100分钟,垃圾收集花掉1分钟,那么吞吐量就为99%
- 对于吞吐量较高的应用,则可以更高效的利用cpu时间,更快完成运算任务,主要适合在后台运行且不需要太多交互的任务
- 而对于和用户交互的应用,STW时间越短则相应时间越短,用户交互体验越好
MaxGCPauseMillis
-XX:MaxGCPauseMillis用于控制最大的GC停顿时间,该值是一个大于0的毫秒数,垃圾回收器尽量确保内存回收花费的时间不超过该设定值。
GCTimeRatio
该值应该是一个大于0且小于100的整数,如果该值为n,代表允许的GC时间占总时间的最大比例为1/(1+n)。
Serial Old
Serial Old是Serial的老年代版本,同样是一个单线程的收集器,采用标记-整理算法对老年代对象进行垃圾收集。
Parallel Old
Parallel Old是Parallel Scavenge的老年代版本,使用多线程和标记-整理算法。
Parallel Old主要是和Parallel Scavenge搭配使用,否则Parallel Scavenge只能搭配Serial Old使用。
在Parallel Scavenge/Parallel Old搭配使用时,发生GC时用户线程也都处于暂停状态。
CMS收集器
CMS(Concurrent Mark Sweep)是一种以获取最短回收停顿时间的收集器。在重视服务响应时间的应用中,适合使用CMS收集器进行老年代的垃圾回收。
从名字上可以看出,CMS收集器基于标记-清除算法实现,其运作过程相对于前面集中收集器来说更加复杂,其分为如下步骤:
- 初始标记(STW):
- 初始标记仅仅会标记GC Roots能够直接关联到的对象,速度很快
- 并发标记:
- 并发标记阶段会执行GC Roots Tracing
- 重新标记(STW)
- 重新标记期间会修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录
- 该阶段的耗时比初始标记长,但是远比并发标记短
- 并发清除
在CMS的整个收集过程中,初始标记和重新标记阶段是存在STW的,但是并发标记和并发清除时收集器线程可以和用户线程同时运行。
CMS缺陷
CMS虽然在垃圾收集时能够做到停顿时间较短,但是其仍然存在如下缺陷:
- CMS对CPU资源比较敏感。
CMS默认启动的回收线程数是(CPU数量 + 3)/4个,当cpu物理核心数较少时,垃圾收集线程将会抢占大量cpu资源 - CMS无法处理浮动垃圾,由于CMS在并发清理阶段并不会引入STW,故而此时用户进程可以运行并伴随新的垃圾产生。该部分垃圾cms无法进行处理,只能等待下一次GC来清掉。
- 由于在并发清除阶段同时还有用户线程运行,必须为用户线程预留内存,
故而CMS无法在老年代空间几乎被填满后才进行垃圾收集,需预留一部分空间以供并发收集时用户线程使用 - 在JDK 1.6中,CMS收集器的启动阈值提升到了92%,要是预留的内存没有办法满足用户线程的运行需要,
会出现Concurrent Mode Failure,此时虚拟机将执行后备方案临时启用Serial Old收集器重新进行老年代的垃圾收集 - 故而
-XX:CMSInitiatingOccupancyFraction参数设置过大时,容易产生大量Concurrent Mode Failure,性能反而降低
- 由于在并发清除阶段同时还有用户线程运行,必须为用户线程预留内存,
- CMS是一款基于标记-清除算法的老年代垃圾收集器,故而在收集结束时会有大量的内存碎片产生
- 为了解决内存碎片问题,CMS提供了
-XX:+UseCMSCompactAtFullCollection开关参数(默认是开启的),用于在CMS进行full gc时开启内存碎片的合并过程 - 但是,每次full gc都进行内存碎片合并会导致等待时间过长,故而引入了另一个参数
-XX:CMSFullGCsBeforeCompaction,该参数用于限制执行多少次不压缩的full gc后,再执行一次带压缩的(默认为0,代表每次进入fullgc后都进行碎片整理)
- 为了解决内存碎片问题,CMS提供了
G1垃圾收集器
G1是一项面对服务端应用的垃圾收集器,和其他垃圾收集器相比,G1具备如下特点:
并行和并发:- G1能够充分利用多cpu、多核环境下的硬件优势,利用多个cpu/cpu核心来缩短STW停顿时间
- 在部分GC动作中,其他垃圾收集器都需要暂停用户线程的执行,而G1垃圾收集器则是在这些GC动作中仍然允许用户线程的同时执行
分代垃圾收集:和其他垃圾收集器一样,G1中也保留了分代的概念。但是,G1并不需要其他垃圾收集器的配合就能够独立管理整个GC堆空间整合:和CMS的标记-清理算法不同,G1在整体上采用了标记-整理算法实现垃圾收集;而从局部(两个Region)上看,G1采用了复制算法。不管从整体还是局部上看,G1都不会产生内存碎片可预测的停顿:G1相比于CMS的一大优势是G1造成的停顿可预测。G1和CMS都关注在垃圾回收中降低停顿,但G1除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者指明在一个时间为M的时间片段内,消耗在垃圾收集上的时间不得超过N毫秒。
G1垃圾收集器内存布局
在G1之前,其他的垃圾收集器在进行收集时都是只关注新生代或老年代对象,但G1则并非这样。在使用G1垃圾收集器时,java堆的内存布局和使用其他垃圾收集器有较大区别。
在使用G1垃圾收集器时,其将整个java堆划分为多个大小相等的独立区域(Region),虽然仍然保留有新生代和老年代的概念,但新生代和老年代并非在物理上是隔离的,它们都是一部分(并不需要连续)Region的集合。
G1之所以能够建立可预测的停顿模型,是因为其可以避免在整个java堆中进行全区域的垃圾收集。G1会跟踪各个Region中垃圾堆积的价值大小(回收所获取的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的垃圾回收时间,优先回收价值最大的Region。(这即是G1垃圾收集器的名称由来,即Garbage-First)。
上述将内存空间划分为Region,以及有优先级的区域回收方式,能够保证G1垃圾收集器在有限的时间内以尽可能高的效率进行垃圾回收。
Remembered Set
即使将内存区域划分为多个Region,但是Region中的对象仍然会跨Region相互引用。故而,在进行可达性分析时,如果不做特殊处理,在分析某个Region中的对象存活与否时,仍然需要扫描整个堆内存空间。
在使用其他垃圾收集器时,即使不对内存进行Region划分,在新生代和老年代之间也会存在相互引用。如果不做特殊处理,在分析新生代对象时,也必须扫描老年代。这将会极大的影响GC效率
G1收集器中Region之间的对象引用以及其他收集器中新生代和老年代之间的对象引用,虚拟机都是通过Remembered Set来避免整个堆的扫描的。G1中,每个Region都有一个与之对应的Remembered Set,虚拟机发现程序在对reference类型的数据进行写操作时,会产生一个Write Barrier暂时中断写操作,检查Reference引用的对象是否处于不同的Region中,如果是,则将相关引用信息记录到被引用对象所属Region的Remembered Set中。在进行内存回收时,在GC根节点枚举范围中加入Remembered Set即可保证不对全堆扫描也不会有遗漏。
若不计算维护Remembered Set的操作,G1垃圾收集器的运作可以分为如下步骤:
- 初始标记:
- 初始标记会记录GC Roots能直接关联到的对象
- 其会修改TAMS,令下一阶段用户线程并发运行时,能在Region中创建新对象
- 初始标记阶段需要STW,暂停用户线程
- 并发标记
- 对堆中对象进行可达性分析,找出存活对象
- 该阶段耗时较长,但是可以和用户线程并发执行
- 最终标记
- 修正在并发标记期间因用户线程运行而导致的标记变动
- 该阶段需要STW,暂停用户线程,但是可以并行执行
- 筛选回收
- 在筛选回收期间,会对各个Region的回收价值和成本进行排序,根据用户期望的GC停顿时间来制定回收计划
- 在筛选回收阶段,也是会暂停用户线程的
虚拟机内存监控和故障处理工具
jps
该命令会列举出正在运行的java虚拟机进程,并现实如下内容:
- 进程ID
- 虚拟机执行MainClass名称
jstat
jstat命令用于监视虚拟机各种运行状态信息。其可以显示本地、远程虚拟机进程中类装载、内存、垃圾收集、JIT编译等运行数据。
jstat [ option vmid [interval[s|ms] [count]]]
在上述格式中,interval代表的是间隔,count代表的是次数,如果上述两个参数省略,代表只查询一次。假设需要每250ms查询一次进程2764的垃圾收集情况,一共查询20次,那么具体命令如下:
jstat -gc 2764 250 20
其中,option代表用户希望查询的虚拟机信息,主要分为3类:
- 类装载
- 垃圾收集
- 运行期编译情况
| 选项 | 作用 |
|---|---|
| -class | 监视类装载、装载数量、总空间、类装载耗费时间 |
| -gc | 监视java堆内存情况,包括eden区、s0/s1区、老年代、永久代等容量、已用空间、gc时间合计等信息 |
| -gccapacity | 内容和-gc基本相同,输出主要关注java堆各个区域使用到的最大、最小空间 |
| -gcutil | 内容和-gc基本相同,输出主要关注已使用空间占总空间的百分比 |
jinfo
jinfo用于实时查看和调整虚拟机各项参数,使用jps -v命令可以查看jvm启动时显式指定的参数列表。
但是想要了解未显式指定参数的默认值,可以通过
jinfo -flag
示例如下
jinfo -flag MaxHeapSize 5338
除此之外,还可以通过
jinfo -flags 5338
上述命令会输出所有参数。
info命令还支持打印出进程的System.getProperties()内容
jinfo -sysprops 5338
jstack
jstack命令用于生成虚拟机当前时刻的线程快照,即当前虚拟机每一个线程正在执行的方法堆栈集合。该命令主要用于定位线程长时间出现停顿的原因,例如线程间死锁、死循环、长时间等待等。
类文件结构
class是以字节为基础单位的二进制流,各个数据项排列紧凑,中间没有分隔符。当数据项实际占用的空间超过一个字节时,多个字节之间按照大端序来进行排序,即高位字节在前。
class文件采用一种伪结构来存储数据,该伪结构类似C语言中的结构体,伪结构中只有两种数据类型:
- 无符号数: 无符号数属于基本数据类型,u1/u2/u4/u8分别代表长度为1/2/4/8个字节的无符号数,无符号数可以用于描述数字、索引引用、数量值、utf-8编码的字符串值
- 表:表是由多个无符号数或其他表组成的复合数据类型,所有表都习惯性以
_info结尾。
class文件本质上就是一张表,其组成结构如下所示:
| 类型 | 名称 | 数量 |
|---|---|---|
| u4 | magic | 1 |
| u2 | minor_version | 1 |
| u2 | major_version | 1 |
| u2 | constant_pool_count | 1 |
| cp_info | constant_pool | constant_pool_count -1 |
| u2 | access_flags | 1 |
| u2 | this_class | 1 |
| u2 | super_class | 1 |
| u2 | interfaces_count | 1 |
| u2 | interfaces | interfaces_count |
| u2 | fields_count | 1 |
| field_info | fields | fields_count |
| u2 | methods_count | 1 |
| method_info | methods | methods_count |
| u2 | attributes_count | 1 |
| attribute_info | attributes | attributes_count |
魔数
每个class文件的头4个字节被称为魔数,其作用是确定该文件是否是一个能被虚拟机接受的class文件。class文件的魔数为0XCAFEBABE。
class文件版本号
魔数之后的4个字节记录的是class文件的版本号:
- 第5、6个字节记录的是minor version(次版本号)
- 第7、8个字节记录的是major version(主版本号)
jdk的主版本号从45开始,jdk 1.0/1.1使用了45的版本号,而每个jdk大版本发布主版本号都会+1。并且,jdk都能兼容以前版本的class文件。
常量池
在class主版本/次版本号之后,是常量池入口。
常量池中主要存放字面量和符号引用。
类索引、父类索引、接口索引集合
类索引(this_class)和父类索引(super_class)都是一个u2类型的数据,接口索引集合(interfaces)则是一组u2类型的数据集合。
class文件通过上述3个字段来确定类的继承关系。
字段集合
字段表用于描述接口或类中声明的变量。字段(field)包括类级变量和实例级变量。
方法集合
方法集合中同样包含了静态方法和非静态方法。
属性表集合
属性表集合用于记录类的一些属性信息,如final/SourceFile等。
虚拟机加载机制
类加载时机
类从被加载到内存中开始,到类被卸载,其整个生命周期包含如下环节:
- 加载(Loading)
- 验证(Verification)
- 准备(Preparation)
- 解析(Resolution)
- 初始化(Initialization)
- 使用(Using)
- 卸载(Unloading)
其中,验证、准备、解析三个阶段统称为链接
加载
加载是类加载过程中的一个阶段,在加载阶段虚拟机主要执行如下步骤:
- 通过类的全限定类名来获取此类的二进制字节流
- 将该字节流所代表的静态存储结构转化为方法区的运行时数据结构
- 在内存中生成一个代表该类的
java.lang.Class对象,作为方法区中这个类数据的访问入口
验证
验证是连接阶段的第一步,这一阶段目的是为了确保class文件中字节流包含信息符合当前虚拟机的要求。
验证大致会完成下面四个阶段的检验动作:
- 文件格式验证:验证阶段的主要目的是保证输入的字节流能被正确解析并存储在方法区之内
- 是否以魔数
0XCAFEBABE开头 - 主、次版本号是否在当前虚拟机的处理范围之内
- 常量池中常量是否有不被支持的常量类型
- class中各个部分及文件本身是否有被删除或附加的其他信息
- 是否以魔数
- 元数据验证: 对字节码描述的信息进行语义分析,确保其描述的信息符合java的语言规范要求
- 验证该类是否存在父类
- 验证该类是否继承了不允许被继承的类(final类)
- 如果这个类不是抽象类,校验其是否实现了父类或接口中所有要求实现的方法
- 类中的字段、方法是否和父类产生矛盾
- 字节码验证:通过数据流和控制分析,确保程序的语义是合法的、符合逻辑的
- 符号引用验证:确保解析动作能正常执行
准备
准备阶段会正式的为类变量(static)分配内存,并设置类变量的初始值,这些变量所使用的内存都会在方法区中进行分配。在准备阶段,并不会为实例变量分配内存空间,实例变量将会在对象实例化时随着对象一起分配在java堆中。
例如,存在一个类变量
public static int value = 123;
那么,value变量在准备阶段过后值为0,而将value赋值为123的putstatic指令是在编译后存放于类构造器<clinit>()方法中的。
故而,将value赋值为123的动作在初始化阶段才会执行。
<clinit>方法是类构造器方法,而<init>方法是对象构造器方法,二者并不相同
但是,如果类变量按照如下方式声明
public static final int value = 123
那么,准备阶段value就会被直接赋值为123
解析
解析阶段是虚拟机将常量池内符号引用替换为直接引用的过程。
符号引用
符号引用采用一组符号来描述所引用的目标,符号可以是任何形式的字面量。符号引用和虚拟机实现的内存布局无关,引用的目标并不一定已经加载到内存中。
各种虚拟机实现的内存布局可以不相同,但是它们能接受的符号引用必须都是一致的。
直接引用
直接引用是可以直接指向目标的指针、相对偏移量或一个能间接定位到目标的句柄。
初始化
类初始化是类加载过程中的最后一步。在准备阶段,变量已经赋值过一次初始值,而在初始化阶段,则会执行类构造器<clinit>()方法。
锁优化
自旋锁和自适应自旋
在许多场景下,共享数据的锁定时间只会维持很短一段时间,为此挂起和恢复线程并不值得。对此,可以令后请求锁的线程等待但并不放弃处理器的执行时间,看看持有锁的线程是否会很快释放锁。为此,让线程执行忙自旋即可。
自旋锁并不能代替阻塞,自旋等待虽然避免了线程切换的开销,但是却要占用处理器时间:
- 如果锁被占用的时间很短,那么自旋等待的效果就较好
- 如果锁被占用的时间很长,那么自旋线程只会白白浪费处理器资源
故而,自旋等待必须要有一定的限度,如果自旋超过限定次数仍然没有获取锁,就应当使用传统方式去挂起线程。自旋的默认次数为10次。
在jdk 1.6中,引入了自适应的自旋锁,自适应代表自旋时间不再固定了,而是由前一次在同一锁上的自旋时间以及锁拥有者的状态来决定:
- 如果在同一锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机会认为这次自旋也很有可能再次成功,其会允许自旋等待持续相对更长的时间,例如100个循环
- 如果对于某个锁,自旋很少成功获得过,那么在以后获得这个锁时可能会省略自旋过程,以避免浪费处理器资源
拥有自适应自旋,随着程序的运行和性能监控信息的不断完善,虚拟机对程序锁状况的预测会越来越准确
锁消除
在即时编译器运行时,对于代码上要求同步,但经检测后不可能存在共享数据竞争的锁进行消除。锁消除的主要判断依据为逃逸分析。如果判断在一段代码中,堆上所有的数据都不可能逃逸出去被其他线程访问到,那么可以将其看作是栈上数据,认为它们是线程私有的,自然无需进行同步加锁。
锁粗化
原则上,在编写代码时,会倾向将同步块的作用范围限制的尽可能小,只在共享数据的实际作用域中才进行同步。这样,可以令需要同步的操作数量尽可能变少,如存在锁竞争,能够尽快拿到锁。
但是,如果存在一系列的连续操作都对同一个对象反复加锁/释放锁,甚至加锁操作出现在循环中,那么即使没有线程竞争,频繁的对锁进行获取和释放也会带来不必要的性能损耗。
故而,如果虚拟机检测到有这样一串零碎操作都对同一对象加锁,会将加锁的同步范围拓展(粗化)到整个操作序列外部。
轻量级锁
在jdk 1.6中,加入了轻量级锁这一新型锁机制。轻量级是相对传统基于操作系统互斥量实现的锁来说的。
轻量级锁并非用于替代重量级锁,其用途是在没有多线程竞争的前提下,减少传统重量级锁使用操作系统互斥量带来的性能消耗。
Hotspot对象头内存布局
在Hotspot虚拟机对象头中包含两部分信息:
- 第一部分存储对象自身的运行时数据(Mark Word),如:
- hash码
- gc分代年龄等
- 存储指向方法区对象类型数据的指针
如果是数组类型,对象头中还会由额外部分用于存储数组长度
对象头信息是和对象自身定义数据无关的额外存储成本,为了考虑虚拟机空间效率,Mark Word被设计为了非固定的数据结构,以在尽量少的空间内存储更多信息。
Mark Word会根据对象状态复用自身的存储空间,例如在32位Hotspot虚拟机中:
- 如果对象处于未锁定状态下,Mark Word的32bit空间内容如下:
- 25bit用于存储对象的hash码
- 4bit用于存储对象的分代年龄
- 2bit用于存储锁标志位
- 1bit固定为0
在其他状态下,Mark Word的存储内容如下所示:
| 存储内容 | 标志位 | 状态 |
|---|---|---|
| 对象hash码和对象分代年龄 | 01 | 未锁定 |
| 指向锁记录的指针 | 00 | 轻量级锁定 |
| 指向重量级锁的指针 | 10 | 膨胀(重量级锁定) |
| 空,不需要记录信息 | 11 | GC标记 |
| 偏向线程ID、偏向时间戳、对象分代年龄 | 01 | 可偏向 |
轻量级锁加锁过程
在代码进入同步块时,如果此时同步对象没有被锁定,
- 虚拟机将首先
在当前线程的栈帧中建立一个名为锁记录的空间,用于存储对象目前的Mark Record拷贝。 - 然后,虚拟机将会尝试使用CAS将对象的Mark Word更新为指向该锁记录的指针,
若更新成功,则线程则持有了该对象的锁。- 之后,会将对象Mark Word的标志位改为00,代表对象处于轻量级锁定状态
- 如果CAS更新Mark Word失败,虚拟机会检查Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了该锁,直接执行同步代码块
- 如果Mark Word指向的不是当前线程的栈帧,那么代表该锁已经被其他线程抢先持有,此时,轻量级锁不再有效,需要膨胀为重量级锁,锁标记位会变为10,此时Mark Word中需要存储指向重量级锁(互斥量)的指针,后面等待的线程需要进入阻塞状态
轻量级锁的解锁过程
轻量级锁的解锁过程也是通过CAS来操作的
- 如果Mark Word仍然指向线程的锁记录,那么会使用CAS将线程中复制的Mark Word替换回来,若替换成功,则整个解锁过程成功
- 如果替换失败,说明有其他线程尝试获取过锁,那么需要在释放锁的同时唤醒被挂起的线程
轻量级锁仅在整个同步周期内都不存在竞争这一场景下能够提高性能。如果没有竞争,轻量级锁使用CAS规避了互斥量带来的开销。
但是,如果存在锁竞争,那么除了互斥量外,还额外发生了CAS操作,故而在存在锁竞争的场景下轻量级锁比重量级锁更慢。
偏向锁
偏向锁也是jdk 1.6中引入的优化,其目的是进一步优化在无竞争情况下的程序性能。轻量级锁是在无竞争条件下用CAS操作去减少系统互斥量带来的开销;而偏向锁则是在无竞争条件下消除所有同步开销。
偏向锁代表其会偏向第一个获得其的线程,如果在后续执行的过程中,该所没有被其他线程获取,那么持有偏向锁的线程将永远不需要再同步。
如果虚拟机使用了偏向锁,那么在锁对象第一次被线程获取时,会将对象头中标志位设置为01,即偏向模式。同时,使用CAS操作将获取该锁的线程ID记录到对象的Mark Word中。若CAS成功,那么持有偏向锁的线程后续每次进入到该锁相关的同步代码中时,虚拟机都可以不再执行任何同步操作。
当有另一个线程尝试去获取该锁时,偏向模式将结束:
- 若当前对象处于未锁定状态,那么撤销偏向后,恢复到未锁定状态
- 若当前对象处于已锁定状态,那么撤销偏向后,恢复到轻量级锁定状态



