[TOC]
Java虚拟机的发展史(略)
- SunClassic/Exact VM
只能用纯解释方式来执行Java代码,如果使用JIT编译器,就必须使用外挂。但是如果外挂了JIT编译器,JIT编译器完全接管了虚拟机的执行系统,解释器便不再工作了,即解释器和编译器不能配合工作。编译器和解释其的区别 - HotSpot VM
JDK1.3后,HotSpot VM就成为默认的虚拟机,其中HotSpot是指热点探测技术,它通过计数器找出最具有价值的代码,然后通知JIT编译器以方法为单位进行编译。HotSpot虚拟机有两个及时编译器,分别是C1和C2 - 嵌入式的 VM和Meta-Circular VM(元循环VM)
- JRockit和IBM J9 VM
JRockit专门为服务器硬件和服务器端应用场景高度优化的虚拟机,因此内部不包含解析器的实现。J9会一款高性能的虚拟机
自动内存管理机制
运行时数据区域
程序计数器
运行时的数据区可以分为线程之间共享的数据区和线程隔离的数据区,其中程序计数器是线程隔离的数据区,每个线程通过程序计数器来记录当前执行的指令,或者说行号。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。需要注意的是执行native方法时,计数器值为空(Undefined)。
补充:程序计数器是一块较小的空间,可以看做是当前线程所执行的字节码的行号指示器。任何确定的时刻,一个处理器都只会执行一条线程中的指令,因此,每个线程有一个独立的程序计数器
Java虚拟机栈
通常把虚拟机分为堆内存和栈内存,这里的虚拟机栈就是指栈内存。虚拟机栈也是线程私有的,它的生命周期与线程相同,它描述的是Java方法执行的内存模型;每个方法在运行的时候都会创建一个栈帧,它是一种数据结构,每一个方法的从调用直至执行完成,就对应着一个栈帧在虚拟机栈中的入栈和出栈的过程。如果线程请求的栈的深度大于所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈在动态扩展时无法申请足够的内存,将会抛出OOM异常。
补充:每个方法被执行的时候,Java虚拟机都会创建一个栈帧用于存储局部变量表、操作数栈、动态连接、方法出口等信息
局部变量表存放了编译器可知的各种Java虚拟机基本数据类型、对象引用和returnAddress类型,存储的单位是槽,long和doule占用两个槽
局部变量表所需的内存空间在编译期完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量表空间是完全确定的
本地方法栈
本地方法栈和虚拟机栈类似,只不过它是为本地方法服务的。HotSpot直接把本地方法栈和虚拟机栈合二为一,也会抛出OOM和StackOverFlowError
Java堆
Java堆是迅疾所管理的内存中最大的一块,它能够被所有的线程共享。此内存区域的唯一目的就是存放对象的实例,几乎所有的对象实例都在这里分配内存(栈上分配,标量替换等优化手段可能不会在堆上分配)。Java堆是垃圾收集器管理的主要区域,也称为“GC堆”。
Java堆可以分为:新生代、老生代、永久代,再细致点可以分为Eden空间、From Survivor空间、To Survivor空间。Java堆无法扩展时会抛出OOM异常。虚拟机发展到今天分代设计并不是固定的
java堆一般用-Xmx和-Xms参数设定最大和小值
方法区
方法区也是各个线程共享的内存区域,它用于存储已被虚拟机就加载的类信息、常量、静态变量、即时编译器编译后的代码等数据:
修饰符、常量池、字段描述、方法描述
虽然Java虚拟机把它描述为堆的一个逻辑部分,但是它却有一个别名叫Non-Heap,目的是为了和Java堆区分开来。HotSpot虚拟机使用“永久代”来实现方法区,因此也称为“永久代”,但是其他的一些虚拟机不存在永久代的概念。当方法区无法满足内存分配的需求时,将抛出OOM异常
JDK7,HotSpot已经把放在永久代的字符串常量池、静态变量等移出,到了JDK8,完全废弃了永久代的概念,改用在本地内存中实现的元空间来代替
常量池
运行时常量池是方法区的一部分
Class文件中除了有类的版本,字段,方法,接口等描述信息外,还有一项信息是常量池表,用于存放编译期生成的各种字面量和符号引用。
运行时常量池是方法区的一部分,它是编译期生成的各种字面量和符号引用,在类加载后进入常量池。同时运行时期间也能够将新的常量放入常量池,比如调用String.intern()方法。由于受方法区的限制,因此也能抛出OOM异常
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,因此也可能会抛出OOM异常。比如NIO会直接使用native方法分配堆外内存(DirectByteBuffer)。
HotSpot虚拟机对象探秘
对象的创建过程
- 当虚拟机遇到一条new指令时,会先去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用是否被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 类加载后虚拟机将为新生对象分配内存,对象所需大小在类加载完成后便可确定。堆空间有两种分配方式,一种是“指针碰撞”(注:其实翻译为指针跳跃更恰当):也就是堆的内存分配是规整的,用过的内存放一边,空闲的内存放一边,分配的时候只需要移动中间的分界点指示器即可(对应Serial、ParNew收集器,它们能够压缩整理内存)。还有一个分配方式称为”空闲列表“,也就是虚拟机内部维护一张表,记录那些内存是使用的,哪些是空闲的。
- 为了保证并发分配内存的内存空间的安全性,虚拟机采用CAS加失败重试的方法保证更新操作的原子性。另一种方式是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,成为本地线程分配缓冲(TLAB),只有TLAB用完,才需要同步锁定。
- 内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值。这步操作保证了对象的实例字段在Java代码中不赋初始值就可以直接使用,使程序能够访问到这些字段的数据类型所对应的零值
- 接下来,虚拟机要对对象进行必要的设置(设置对象头),例如这个对象是哪个类的实例,如何才能找到类的元数据信心、对象的哈希码、对象的GC分代年龄信息,这些信息存放在对象的对象头之中。
- 上面的工作完成后,从虚拟机的角度来开,一个新的对象已经产生了,当从Java程序员的视角看,对象创建才刚刚开始,因为还要执行init方法来执行初始化的动作。
对象的内存布局
对象在内存中存储的布局可以分为三块区域:对象头、实例数据和对齐填充
对象头包括两个部分:第一个部分存储对象自身运行时数据:哈希码、GC分代年龄、锁状态标志、偏向线程ID、偏向时间戳等,也称为”Mark Word“。Mark Word被设计成一个非固定的数据结构以便在极小的空间存储更多的信息。
对象头的另一个部分是类型指针,即对象指向它的类元数据的指针,可以通过这个指针来确定是哪个类的实例。如果对象是一个数组,那么对象头中还必须有一块用于记录数组长度的数据。接下来是对象真正存储的实例数据部分,这部分的存储顺序受虚拟机分配策略参数和字段在Java源码中的定义顺序有关。HotSpot默认分配策略为longs/doubles,ints,shorts/chars,bytes/booleawns,oops(普通对象指针),也就是相同字宽的字段总是放在一起。在满足这个前提下,父类中定义的变量会出现在子类之前。
- 对齐填充并不是必然存在的,它的目的是保证对象的大小必须是8字节的整数倍。
对象的访问定位
栈上是通过引用来操作堆上的具体对象。引用类型在Java虚拟机规范没有指定具体实现,目前有两种方式通过引用访问对象:句柄和直接引用
句柄方式:堆中会划分出一部分内存作为句柄池,引用实际是对象的句柄地址,而句柄中包含了对象实例数据与类型数据(指向方法区)各自的具体地址信息。
直接引用方式:直接引用就是能够直接访问对象,但是必须也能同时访问对象类型数据(类型数据在方法区)。
这两种方法各有优势,使用句柄的好处就是存储的是稳定的句柄,在对象被移动时只会改变句柄中的实例数据指针。使用直接引用的好处就是速度更快,它节省了一次指针定位的时间开销。对于HotSpot而言,它也是使用第二种方式进行对象访问的
实战:OOM异常
堆溢出
只要在代码中不断地创建对象,并保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆的容量限制后就会产生OOM异常。
可以通过-Xms和-Xmx参数设置最小最大堆和最大堆的数值,通过-XX:+HeapDumpOnOutOfMemoryError参数可以在OOM异常时Dump出内存快照。
抛出OOM异常后,会打印是否为堆异常,在出现堆OOM异常时要区分是下面的那种情况:
- 内存泄漏:可以分析内存快照中的泄漏对象的GC Roots的引用链判断
- 内存太小:这时候就需要调整前面提到的最小堆和最大堆的参数
虚拟机栈和本地方法栈溢出
HotSpot不区分虚拟机栈和本地方法栈,因此相关的参数设置命令(-Xoss)无效,只能用-Xss设置栈容量
构造虚拟机栈OOM的方法:
- -Xss 减少栈内存容量
- 定义了大量的本地变量,增大栈帧中本地变量表的长度
HotSpot在单线程情况下,上面两种一般只会抛出StackOverFlow异常,因为内存太小和栈空间无法分配本质上是一个概念。如果在允许栈动态扩展的虚拟机上,会抛出OOM
HotSpot在多线程不断创建线程的情况,会出现OOM异常,而且如果每个线程分配到的栈内存越大, 可以建立的线程数量自
然就越少, 建立线程时就越容易把剩下的内存耗尽
方法区和运行时常量池溢出
在JDK6及以前的HotSpot虚拟机中,常量池都是分配在永久代中,可以通过下面的参数设置
可以通过-XX:PerSize和-XX:MaxPermSize来限制方法区大小,从而间接限制其中的常量池的容量
可以调用intern方法不断将字符串加入常量池,可以用来构造JDK1.6下的OOM
JDK7以后相关的方法区和元空间大小设置:
JDK7: -XX:MaxPermSize
JDK8: -XX:MaxMetaspaceSize
对于HotSpot虚拟机和使用JDK1.6来说,常量池OOM会显示PermGen space OOM,因为常量池属于方法区的一部分,而方法区又是用永久代实现的。
但是JDK1.7开始逐步“去永久代”,常量池会在堆上进行创建,因此使用JDK试验会得出不同的结果,不出显现OOM。这同时引出了一个更有意思的案例:
1 | //对于1.6会返回false(str是在堆里面,虚拟机会拷贝放进常量池),对于jdk1.7返回true(常量池创建一个引用指向str) |
JDK1.6 intern方法会吧首次遇到的字符串实例复制到永久代的字符串常量池中存储,返回的也是永久代里面这个字符串实例的引用,而StringBuilder创建的字符串在Java堆上,所以不是一个引用
JDK1.7的intern不会在复制实例,而只是在常量池中记录首次出现的实例引用,因此intern返回的引用和由StringBuilder创建的那个字符串实例是同一个,但是由于“java”这个常量已经由其他类加载到了常量池中,所以返回的false。
测试方法区OOM可以使用CGLib不断的创建增强类,因为这类字节码技术需要足够容量的方法区来保证动态生成的Class可以加载到内存中,其他的场景还有JSP的动态生成。
JDK8引入了元空间,并提供了一些参数限制元空间的大小:
-XX:MaxMetaspaceSize:设置元空间大小
-XX:MetaspaceSize:指定元空间的初始空间大小
-XX:MinMetaspaceFreeRatio:在垃圾收集之后控制最小的元空间剩余容量的百分比
本机直接内存溢出
直接内存(Direct Memory)溢出常常和NIO的使用有关,因为它会占用Java堆以外的内存。直接内存如果不指定默认和Java堆的最大值一样,可以通过使用Unsafe类进行直接内存的分配来验证OOM异常。可以通过一下参数指定:
-XX:MaxDirectMemeorySize
垃圾收集器与内存分配策略
概述
GC需要完成3件事情:
哪些内存需要回收
什么时候回收
如何回收
了解GC是为了能够排查各种内存溢出、内存泄漏问题,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
程序计数器、虚拟机栈、本地方法栈这个三个区域随线程而生,随线程而灭。栈中的栈帧随着方法的进入和退出进行出栈和入栈,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此GC主要是指Java堆和方法区的垃圾回收。
对象已死吗
引用计数法
引用计数法就是给对象中的引用添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1,当引用失效时,计数器值就减1,当引用计数为0就表示对象可以回收 。
引用计数法的效率很高,但是它不能解决对象之间循环依赖的问题。
Java没有采用引用计数法(两个对象互相持有对方的引用,但实际上两个对象都不会被访问)
可达性分析方法
可达性分析是主流的GC方法,基本思想就是通过一系列成为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明对象是不可用的。在Java中,可作为GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象,比如字符串常量池里的引用
本地方法栈中JNI引用的对象
虚拟机内部的引用,比如基本类型的class对象,一些常驻的异常
所有被同步锁持有的对象
反映java虚拟机内部情况的JMXBean等
目前的虚拟机基本都具备了局部回收的特征,局部回收要考虑到当前区域的对象是否被区域外的对象引用好·
在JDK1.2之后,Java对引用的概念进行了扩充:
- 强引用:通过new出来的引用,只要强引用还存在,垃圾收集器永远不会回收掉引用的对象
- 软引用:描述一些还有用但是非必须的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出异常
- 弱引用:一次GC就会回收
- 虚引用:幽灵引用,它的目的就是能在这个对象被回收时收到一个系统通知。
生存还是死亡
即使不可达的对象,也并不是非死不可的,要宣告一个对象死亡,至少经历两次标记过程:
- 第一次是可达性分析标记的对象,标记后还要进行筛选,筛选的条件是此对象有必要执行finalize方法(重写了该方法并没有被虚拟机调用过)
- 有必要执行finalize的对象将会被放入F-Queue中,虚拟机会有线程去执行finalize方法,但是不保证能够执行完,GC稍后会对该队列中的对象进行第二次标记,如果仍未可达,对象将会被回收,如果重新和强引用挂上钩,则复活
需要注意的是:
- finalize方法不会被承诺执行并等待其结束,因为该方法可能执行比较缓慢,并且可能会出现死循环。
- 任何一个对象的finalize方法都只会被系统自动调用一次,如果对象第一次标记后在finalize中逃脱了,下一次回收时,它的finalize方法不会被执行 。
- 尽量不要依赖finalize方法,因为它的不确定性大,且无法保证各个对象的调用顺序
回收方法区
方法区垃圾回收的性价比很低,在堆中,尤其是新生代中,一次垃圾回收一般可以回收70%-95%,而永久代的垃圾回收率远低于此。
永久带的垃圾回收主要回收两部分:废弃常量和不再使用的类型。废弃常量的判断比较简单,就是没有指向该常量的引用,对于无用的类来说,需要满足三个条件:
- 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实现
- 加载该类的ClassLoader已经被回收
- 该类对应的Class对象没有任何地方被引用,无法在任何地方通过反射方位该类的方法
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然回收。HotSpot对是否进行回收提供了参数进行控制。
垃圾收集算法
垃圾回收算法可以分为引用计数和追踪式收集,下面讲的都是追踪式收集
分代收集理论
当前商业虚拟机的垃圾收器,大多遵循分代收集的理论进行设计,分代收集名为理论,实际是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:
- 弱分代假说:绝大多数对象都是朝生夕灭的
- 强分代假说:熬过越多次垃圾收集过程的对象就越难以消亡
因此虚拟机一般分为新生代和老年代,分代的问题会引入问题:需要关注分代之间的引用产生的影响
- 另外一条额外的经验法则:跨代引用相对于同代引用来说仅占少数,因此不必为少量的跨代引用而去扫描老年代
基于这条假说,我们不应再为了少量的跨代引用去扫描整个老年代,反过来也是。只需要在新生代上建立一个全局的数据结构(Remembered Set),这个结构把老年代划分成若干个小块,标识出老年代的哪一块内存会存在跨代引用,后面进行MinorGC的时候只需要把存在跨代引用的小块里面的对象加入到GC Roots进行扫描
相关名词解释:
- Partial GC: 部分收集,指目标不是完整收集整个Java堆的垃圾数据,又分为
- 新生代收集:Minor GC/Young GC:指目标只是新生代的垃圾收集
- 老年代收集:Major GC/Old GC:指目标只是老年代的垃圾收集,目前只有CMS会有老年代垃圾回收。Major GC视语义而定
- 混合收集:Mixed GC:指目标是收集整个新生代以及部分老年代的垃圾收集,目前只有G1会有
- 整堆收集:Full GC:收集整个Java堆和方法区的垃圾收集
标记-清除算法
首先标记需要回收的对象,然后进行回收,它的缺点:
- 标记和清除的效率都不高(要扫描所有对象)
- 清除后会产生大量不连续的内存碎片,空间碎片太多可能导致之后再分配较大对象时,无法找到最后的连续内存而不得不提前出发另一次垃圾回收动作
复制算法
复制算法会分配两个内存块,当GC后,仍存活的对象复制到另一个内存块,然后把已用过的内存块清理掉,这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等情况。
现在大部分虚拟机新生代都是采用这种收集算法,由于新生代的对象大部分是要被GC的,因此不需要1:1的比例划分两个内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survior(每次分配内存只使用Eden和其中一块Survivor。 发生垃圾搜集时, 将Eden和Survivor中仍然存活的对象一次性复制到另外一块Survivor空间上, 然后直接清理掉Eden和已用过的那块Survivor空间),HotSpot(Serial,ParNew新生代收集都用了这种思想)的两者的内存容量之比为8:1。
需要注意的是:
如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活的对象时,这些对象将通过分配担保机制进入老年代。
标记-整理算法
标记复制算法在对象存活率较高的时候需要进行较多的复制操作,效率会降低,而且还需要额外的空间进行担保。因此老年代一般不能直选用这种算法
根据老年代的特点,有人提出了另一种标记-整理算法那,标记过程和上面的一样,但后续步骤不是直接对可回收对象进行整理,而是让存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
标记-整理需要移动对象,因此存在Stop The World现象,标记清除算法也有不过时间很短(用来标记对象)
标记整理吞吐量更高(Parallel Scavenge收集器采用了该算法):吞吐量的实质是赋值器(Mutator, 可以理解为使用垃圾收集的用户程序, 本书为便于理解, 多数地方用“用户程序”或“用户线程”代替) 与收集器的效率总和
标记清除延迟更低(CMS使用了该算法,但是在碎片过多的时候还是会进行整理)
HotSpot的算法实现
枚举根节点
可作为GC Root的对象主要在全局性的引用和执行上下文中。可达性分析或者说枚举根节点是对时间敏感的,主要体现在下面两个方面:
- 现在应用近方法区就有几百兆内存,因此要逐个检查这里面的引用会消耗很多时间
- 可达性分析或者说枚举根节点时,需要确保快照是一致性的,也就是在整个分析期间整个执行系统看起来是被冻结起来的,不可以出现分析过程中对象引用关系还在不断的变化情况。这导致GC时必须停顿所有的Java执行线程,即Stop The World。迄今为止,所有收集器在根节点枚举这一步骤都是必须暂停用户线程的
在HotSpot虚拟机的解决方案中,当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局引用位置,HotSpot是通过一个叫OopMap的数据结构实现的,它保存了引用的位置(一旦类加载完成,HotSpot就会把对象内的什么偏移量上是什么类型的数据计算出来,在JIT过程中,也会把栈上代表引用的位置全部记录下来,从而实现准确式GC)。
安全点
在OopMap的帮助下,虚拟机可以快速且准确的完成GC Roots枚举,但一个很现实的问题:如果为每一个指令(比如某个方法)都生成对应的OopMap,那将会需要大量的额外空间。实际上HotSpot并没有为每条指令都生成OopMap,只是在特定的位置记录了这些信息,这些位置成为安全点。即程序执行时并非在所有的地方都停顿开始GC,只有在达到安全点时才能暂停。安全点的选定不能太少(GC等待时间长),也不能太多(增大运行时负荷)。
对于安全点,另一个需要考虑的问题是如何在GC发生时让所有线程都跑到最近的安全点上,有两种方案可以选:
- 抢先式中断:它不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
- 主动式中断:线程在安全点轮询,发现当前中断标志位为真时就进行中断挂起。
安全区域
在实际情况,线程可能会Sleep或者Bolcked,这时候线程就无法响应JVM的中断请求,这种情况就需要安全区域来解决 :
安全区域是指在一段代码中,引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的。我们也可以把安全区域看成是安全点的扩展。
当线程执行到安全区域时,首先标识自己进入了安全区域。那样,在当前这段时间发生GC,就不用管标识自己为安全区域状态的线程了。
在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举,如果完成了,那线程就继续执行,否则它就必须等待知道收到可以安全离开安全区域的信号为止。
记忆集和卡表
为了解决对象跨代引用所带来的的问题,垃圾收集器在新生代中建立了名为记忆集的数据结构,用以避免把整个老年代加入GC Roots扫描范围,同样老年代也会被新生代引用,以及所有涉及部分收集行为的垃圾收集器都会面临同样的问题。
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象收据结构,但是没有必要维护所有的对象,维护的精度有:
字长精度:一个字包含跨代指针
对象精度:该对象包含跨代指针
卡精度:每个记录精确到一块内存区域,该区域内对象含有跨代指针。目前最常用的一种记忆集实现形式
卡精度使用卡表来实现的,卡表(实现为一个字节数组)的每一个元素都对应这其标识的内存区域中一块特定大小的内存块,称为卡页,HotSpot卡页是512字节。一个卡页的内存通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在跨代指针,就标记为1(卡表是“我指向谁” )
在垃圾收集发生时, 只要筛选出卡表中变脏的元素, 就能轻易得出哪些卡页内存块中包含跨代指针, 把它们加入GC Roots中一并扫描
HotSpot虚拟机是通过写屏障(和内存屏障不同)技术维护卡表状态的,写屏障可以看做AOP切面,分为写前屏障和写后屏障。
并发的可达性分析
可达性分析算法理论上要求全过程都基于一个能保障一致性的快照中才能够进行分析,这意味着必须冻结用户线程的执行。枚举GC Roots上面通过引入OopMap进行了优化,然而顺着GC Roots往下遍历对象图,这部分时间消耗和堆内存大小是相关的。
如果用户线程和收集器并发工作,会存在对象错误删除的情况,这里引入三色标记来推导为什么需要一次性快照,三色是指
白色:未被垃圾回收器访问过
黑色:已经被垃圾回收器访问过,且这个对象的所有引用都已经扫描过
灰色:标识被垃圾回收器访问过,但这个对象至少存在一个引用还没有被扫描过
当且仅当以下两个条件同时满足时,会产生“对象消失”的问题:
赋值器插入了一条或多条从黑色对象到白色对象的新引用 (即该白色对象应该是活的)
赋值器删除了全部从灰色对象到该白色对象的直接或者间接引用(但是灰色对象这里删除连接,判断白色对象要回收,从而使对象消失)
以上无论是对引用关系记录的插入还是删除, 虚拟机的记录操作都是通过写屏障实现的
解决办法:
增量更新:破坏第一个条件,并发扫描结束后再重新扫描
原始快照:破坏第二个条件,先按照原始快照进行扫描(快照的重要性),并发扫描后再以灰色对象为根扫描
垃圾收集器
查看本机使用的垃圾收集器(以下都是server模式):
可以看到JDK11用的是G1
JDK8用的是ParallelGC
1 | ➜ ~ java -XX:+PrintCommandLineFlags -version |
收集算法是内存回收的方法论,而垃圾收集器是内存回收的具体实现。虚拟机包含的所有收集器如图所示:
新生代:Serial ParNew Parallel Scavenge
————————————————G1————
老年代:CMS Serial Old Parallel Old
上图注意的是:
Serial-CMS和ParNew-Serial Old在jdk9已经去掉
Serial收集器
重点:
- 历史悠久的收集器,采用复制算法的新生代收集器,Serial Old采用标记-整理算法
- 完全单线程,收集时会停止到其他的线程(“Stop The World”)
- 注意:之后发展的收集器也不能完全消除暂停线程,只能不断缩短暂停的时间
- 它是虚拟机在运行在Client模式下的默认新生代收集器
ParNew 收集器
重点:
- Serial收集器的多线程版本,也是新生代的垃圾收集齐,用的也是复制算法,一般用在服务端模式下
- 除Serial外,只有他能够CMS收集器配合(不幸的是,JDK1.5提出的CMS作为老年代的收集器,却无法与JDK1.4中已经存在的Parallel Scavenge配合工作,从此以后, ParNew合并入CMS, 成为它专门处理新生代的组成部分 )
- 在单核环境下,性能不会超过Serial收集器
- 默认开启的收集线程和CPU的数量一样多,也可以通过参数限制线程数
Parallel Scavenge收集器
重点:
新生代收集器,也采用复制算法以及能够并行收集,JDK1.4中已经存在
它的目标是达到一个可控制的吞吐量,所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值
停顿时间短—>适合需要与用户交互的程序,响应快;高吞吐量–>可以高效率的利用CPU,适合后台运算而不需要太多交互的任务
相关控制参数:
-XX:MaxGCPauseMillis:控制停顿时间,注意GC停顿时间越短,吞吐量越小,新生代的空间越需要的越多
-XX:GCTimeRatio:控制垃圾收集时间占总时间的比率(比如该值为19则,GC时间占比1/20),相当于(约等)吞吐量的倒数
-XX:UseAdaptiveSizePolicy:打开后不用手动同时指定上面两个参数(可以指定单个),收集器会自适应改变上面两个参数
Serial Old
重点:
是Serial收集器的老年版,使用标记-整理算法,主要用在客户端下,支持多线程并发
在Server模式下,它主要有两个作用:
在JDK1.5以及之前的版本与Parallel Scavenge收集器搭配使用
作为CMS收集器的后备预案
Parallel Old收集器
重点:
- Parallel Old是Parallel Scavenge收集器的老年代版本,采用多线程和复制整理算法,JDK1.6中才开始提供的
- 它出来之前,除了Serial Old外,PS收集器别无其他可以合作的老年代收集器
CMS收集器
重点:
以获取最短回收停顿时间为目标的收集器,看重服务的响应速度,采用标记-清除算法,收集的过程分为4个过程:
初始标记:仅标记GC Roots能直接关联的对象
并发标记:并发进行GC Roots Tracing
重新标记:修正并发标记期间因程序的继续运行产生的变动
并发清除:
初始标记、重新标记仍需要“Stop The World”;并发标记、并发清除时间耗时最长
缺点:
- CMS收集器对CPU资源非常敏感,CPU个数越少,CMS对用户程序的影响就可能变得很大
- CMS收集器无法处理浮动垃圾:并发标记时新产生的垃圾只能在下一次清理,因此,CMS收集器不能像其他老年代收集器在老年代几乎填满了在进行收集,可以通过参数来设置触发比。如果CMS期间内存不够用,将会临时启用Serial Old收集器重新收集
- 采用标记-清除算法,因此会有空间碎片产生,如果无法找到足够大的的连续空间来分配对象,会提前触发Full GC。提供了一个参数来打开在Full GC之前进行空间整理
G1收集器
重点:
- 当今发展最前沿的成果之一,JDK1.7提供,它是面向服务端应用的垃圾收集器
- G1能充分利用多CPU,缩短StopTheWorld的时间
- 以面向堆内存任何部分来组成回收集(Collection Set, 一般简称CSet) 进行回收, 衡量标准不再是它属于哪个分代 。G1开创了基于Region的堆内存布局
- G1也是能分代收集的,虽然它能管理整个堆。它能够采用不同的方式处理新生代和老年代对象
- G1收集器去跟踪各个Region里面的垃圾堆积的“价值”大小, 价值即回收所获得的空间大小以及回收所需时间的经验值, 然后在后台维护一个优先级列表, 每次根据用户设定允许的收集停顿时间(使用参数-XX: MaxGCPauseMillis指定, 默认值是200毫秒) , 优先处理回收价值收益最大的那些Region
- G1从整理上看是标记整理算法,从局部上看是复制算法
- 能够预测停顿
- G1将内存划分为多个Region,新生代和老生代不在是物理隔离,按照Region回收价值最大的先回收策略
- 需要处理的问题:
多个Region会互相关联的引用,怎么来避免全部扫描堆内存:采用Remembered Set来避免
在并发标记阶段如何保证收集线程与用户线程互不干扰地运行 :使用原始快照
怎样建立起可靠的停顿预测模型 :以衰减均值(Decaying Average) 为理论基础来实现的
G1收集器的运作过程大致可划分为以下四个步骤 :
初始标记、并发标记、最终标记、筛选回收
虚拟机参数
理解GC日志
注意点:
- 查看GC基本信息, 在JDK 9之前使用-XX: +PrintGC, JDK 9后使用-Xlog: gc
- 查看GC详细信息, 在JDK 9之前使用-XX: +PrintGCDetails, 在JDK 9之后使用-X-log: gc*
- 查看GC前后的堆、 方法区可用容量变化, 在JDK 9之前使用-XX: +PrintHeapAtGC, JDK 9之
后使用-Xlog: gc+heap=debug:- 查看GC过程中用户线程并发时间以及停顿的时间, 在JDK 9之前使用-XX: +PrintGCApplicationConcurrentTime以及-XX: +PrintGCApplicationStoppedTime, JDK 9之后使用-Xlog:safepoint
- 会显示Full GC(会StopTheWorld)还是Minor GC
- 会显示GC发生的区域、时间、GC前和后的内存
内存分配与回收策略
对象的内存分配往大方向讲,就是在堆上分配(JIT编译后可能在栈上)。新生对象通常分配在新生代上,少数情况,比如超过设定的阈值,会直接进入老年代。对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓存,则优先在tlab上分配,少数情况下也可能直接分配在老年代中。新生代的垃圾回收采用复制算法
对象优先在Eden分配
- 大多数情况,对象的新生代在Eden区中分配
- 当Eden区不足时,会发起Minor GC
- 当Survivor不足时会分配到老年代中(分配担保机制)
- -XX:+PrintGCDetails打印日志信息
1 | private static final int _1MB = 1024 * 1024; |
大对象直接进入老年代
- 大对象就是指需要大量连续内存空间的Java对象
- 大且短命的大对象对虚拟机的内存分配来说就是一个坏消息
- -XX:PretenureSizeThreshold参数可以令对象大小大于该值的对象直接分配在老年代中。该参数只对Serial和ParNew生效
1 | private static final int _1MB = 1024 * 1024; |
长期存活的对象将进入老年代
- 虚拟机给每个对象定义了一个对象年龄计数器,存储在对象头,对象在Survivor中每熬过一次Minor GC,年龄都会增加一岁
- 年龄增加到一定的程度,就会晋升到老年代中,对象晋升老年代的年龄阈值, 可以通过参数-XX:
MaxTenuringThreshold设置
1 | private static final int _1MB = 1024 * 1024; |
对象动态年龄的判断
- 虚拟机并不是永远要求对象的年龄必须达到某个程度才会晋升老年代,如果在Survior空间中相同的年龄所有对象的大小总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就直接进入老年代,无需等到某个岁数
空间担保分配
- 在Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的
- 如果不成立,则虚拟机会查看是否允许担保失败(参数-XX:HandlePromotionFailure)
- 如果允许,虚拟机会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于会尝试着进行一次Minor GC
- 如果小于或者不允许冒险,那么这时也要改为进行一次Full GC
- 担保失败也会触发Full GC
- Jdk1.6 Update24之后HandlePromotionFailure不会再影响虚拟机的空间分配担保策略,规则变成只要老年代的连续空间大于新生代对象总大小或者历次平均总大小,就会进行MinorGC,否则进行Full GC
虚拟机性能监控与故障处理工具
JDK命令行工具
大部分工具都是基于或者要用到JMX(包括下一节的可视化工具) ,工具主要有:
- jps:虚拟机进程状况工具
- jstat:虚拟机统计信息工具:类装载、内存、垃圾收集、JIT编译等数据,没有图形界面,监控选项有很多比如-gc
- jstat -gc 2764 250 20 需要每250毫秒查询一次进程2764垃圾收集状况, 一共查询20次
- jinfo:Java配置信息工具:实时地查看和调整虚拟机各项参数
- jinfo [ option ] pid
- jmap:Java内存映射工具:用于生成堆转储快照
- jmap( Memory Map for Java) 命令用于生成堆转储快照( 一般称为heapdump或dump文件) 如果不使用jmap命令, 要想获取Java堆转储快照也还有一些比较“暴力”的手段: 譬如在第2章中用过的-XX: +HeapDumpOnOutOfMemoryError参数
- jmap的作用并不仅仅是为了获取堆转储快照, 它还可以查询finalize执行队列、 Java堆和方法区的
详细信息, 如空间使用率、 当前用的是哪种收集器- jhat:虚拟机堆转储快照分析工具
- jstack:Java堆栈跟踪工具:生成线程快照
- HSDIS:JIT生成代码反汇编
- JMC和JFR:商业的
- 对于JDK6后,JMX是默认开启的
JDK的可视化工具
JConsole、JHSDB、VisualVM、JMC
Integer.valueOf会缓存[-128,127]的整数
调优案例分析与实战
案列分析
高性能硬件上的程序部署策略
问题:高性能硬件上的超大堆内存,Full GC能有十几秒,会造成服务停顿。
如果是通过64位JDK使用大内存的缺点:
大内存GC停顿时间长,64位JDK没有32位快,如果仍溢出,dump出的堆转储快照很大无法分析,64JDK消耗较大(指针膨胀,数据类型对齐等造成)
解决办法:使用若干个32位虚拟机建立逻辑集群来利用硬件资源(无Session复制的亲合式集群),但可能会遇到的问题:
- 尽量避免节点竞争全局的资源
- 很难最高效地利用某些资源池
- 各个节点仍面临32位的内存的限制
- 大量使用本地缓存,比如HashMap缓存导致较大的内存浪费
集群间同步导致的内存溢出
问题:一个BS系统,采用集群部署,需要各个节点共享数据,不定期出现内存泄漏
原因:使用JBossCache构建全局缓存,会向所有节点同步操作时间,导致网络交互繁忙,从而会导致消息重发,大量的重发消息会在内存缓存,从而导致OOM
堆外内存导致的溢出错误
问题:使用NIO导致直接内存溢出
引申出类似的非常见非堆内存过大问题:
- Directr Memory:可以通过参数控制大小
- 线程堆栈:可以通过参数控制大小
- Socket缓冲区:每个Socket连接都有接收和发送缓存,可能会导致溢出
- JNI代码:本地内存也不再堆中,可能会溢出
- 虚拟机和GC:虚拟机和GC的代码也要消耗一定的内存,因此需要预留一定的空间
外部命令导致系统缓慢
问题:java调动shell命令,会克隆线程导致大量占用CPU资源
解决:使用Java API实现
服务器JVM进程崩溃
问题:出现集群虚拟机自动关闭的情况
原因:异步任务返回时间过长导致Socket连接越来越多,最终是JVM崩溃
不恰当的数据结构导致内存占用过大
问题:在内存中加载大数据会造成GC长时间停顿
解决:考虑将Survivor空间去掉,大数据直接进入老年代
Windows虚拟内存导致的常见停顿
问题:准备开始GC到开始GC之间消耗了大部分时间
原因:GUI程序在最小化的时候,工作内存被自动交换到磁盘的页面文件之中了,发生GC时就有可能因为恢复页面文件的操作导致不正常的GC停顿
类文件结构
各种不同平台的Java虚拟机, 以及所有平台都统一支持的程序存储格式——字节码(Byte Code) 是构成平台无关性的基石
Class类文件的结构
- 任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
- Class文件是一组8位字节为基础单位的二进制流,它只包含两种类型:无符号数和表,无符号数u1,u2,u3,u4分表表示1,2,3,4个字节。无符号数可以用来描述数字、 索引引用、 数量值或者按照UTF-8编码构成字符串值。表是由多个无符号数或者其他表作为数据项构成的复合数据类型, 为了便于区分, 所有表的命名都习惯性地以“_info”结尾
- 无论是无符号数还是表, 当需要描述同一类型但数量不定的多个数据时, 经常会使用一个前置的容量计数器加若干个连续的数据项的形式, 这时候称这一系列连续的某一类型的数据为某一类型的“集合”
魔数与Class文件的版本
每个Class文件的头4个字节成为魔数,确定该Class文件是否能够被虚拟机接受
魔数后面的4个字节是Class文件的版本号,虚拟机会校验是否是JDK支持的版本。第5和第6个字节是次版本号(Minor Version) , 第7和第8个字节是主版本号(Major Version)
4个字节魔数->4个字节版本号->
常量池
常量池是Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目
开头是两个字节是常量池数量(编号从1开始到容量-1)。常量池主要存放两个类常量:字面量(文本字符串、声明为final的常量值等)和符号引用。符号引用则属于编译方面的概念,包括:
被模块导出或者开放的包
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
方法句柄和方法类型
动态调用点和动态常量
Java代码在进行Javac编译的时候, 并不像C和C++那样有“连接”这一步骤, 而是在虚拟机加载Class 文件的时候进行动态连接
常量池的每一项常量都是一个表,常量之间可以互相引用,也就是常量表之间可以关联。
每个表开始的第一位是一个u1类型的标志位,表示是17张常量表中的哪一个
CONSTANT_Utf8_info类型的常量一般存储类的限定名,因此很多常量都是引用该类型的常量
4个字节魔数->4个字节版本号->连续出现的常量表
Class文件中方法、 字段等都需要引用CONSTANT_Utf8_info型常量来描述名 称, 所以CONSTANT_Utf8_info型常量的最大长度也就是Java中方法、 字段名的最大长度。 而这里的 最大长度就是length的最大值, 既u2类型能表达的最大值65535
访问标志
在常量池之后,紧接着的两个字节代表访问标志,识别类或接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否为abstract类型;是否为final等
4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志
类索引、父类索引、接口索引集合
类索引和父类索引都是一个u2类型的数据,而接口是一组u2类型的数据的集合
4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志->类索引->父类索引->接口索引
字段表集合
字段表用于描述接口或者类中声明的变量,字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量
字段表由access_flags、name_index、descriptor_index、attributes_count、attributes组成
字段表不会列出从超类或者父接口中继承而来的字段,编译器可能会自定添加字段,比如在内部类中为了保持对外部类的访问性,最自动添加指向外部类的实例的字段
4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志->类索引->父类索引->接口索引->字段表集合(字段数量->字段访问标志->字段名称->ConstantValue属性,存储静态常量)
方法表集合
和字段表类似,不同的是方法中的代码,经过编译器编译成字节码指令后,存放在方法属性表集合中的一个名为“Code”的属性里面
与字段表集合相对应的,如果父类方法在子类中没有重写,方法表集合中就不会出现来自父类的方法信息。同样的,有可能也会出现编译器自动添加的方法,最常见的便是类构造 器\
()方法和实例构造器\ ()”方法 4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志->类索引->父类索引->接口索引->字段表集合(字段数量->字段访问标志->字段名称->ConstantValue属性,存储静态常量)->方法表集合(访问标志->名称索引->描述符索引->属性集合)
属性表集合
- 在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。属性值长度不一,数据自定义结构,只要指出占用多少字节就可以了
- Java虚拟机执行字节码是基于栈的体系结构
Code属性:
Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内,注意接口或者抽象类的方法不存在Code属性
该属性结构如下:
attribute_name_index: 固定为Code
max_stack代表了操作数栈(Operand Stack) 深度的最大值;max_locals代表了局部变量表所需的存储空间,max_locals的单位是变量槽(Slot) , 变量槽是虚拟机为局部变量分配内存所使用的最小单位
在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变 量, 局部变量表中也会预留出第一个变量槽位来存放对象实例的引用, 所以实例方法参数值从1开始计 算
code_length和code用来存储Java源程序编译后生成的字节码指令
字节码之后的是这个方法的显示异常处理表,异常表对于Code属性来说并不是必须存在的
编译器使用的异常表而不是简单的跳转命令来实现Java异常及finally处理机制,编译器会自动在每段可能的分支路径之后都将finally语句块的内容冗余生成一遍实现finally语义
Exceptions属性:
与Code属性平级的一项属性,Exceptions属性的作用是列举出方法中可能抛出的受检查异常,也就是方法描述时在throws关键字后面列举的异常LineNumberTable属性
用于描述Java源码行号与字节码行号之间的对应关系,并不是运行时必须的属性,但默认会生成到Class文件之中, 可以在Javac中使用-g: none或-g: lines 选项来取消或要求生成这项信息LocalVariableTable属性
用于描述栈帧中局部变量表中的变量与Java源码中定义的变量之间的关系。如果没有生成这项属性, 最大的影响就是当其他人引用这个方法时, 所 有的参数名称都将会丢失SourceFile属性
用于记录生成这个Class文件的源码文件名称ConstantValue属性
该属性的作用是通知虚拟机自动为静态变量赋值,只有被static关键字修饰的变量才可以使用这项属性。对于非static类型的变量的赋值的赋值只在实例构造器中进行的,对于类变量(static)来说,如果使用了final或者数据类型为基本类型或者String的话,就生成ConstantValue属性来进行初始化,如果没有final,或者并非基本类型及字符串,会在类构造器中进行初始化InnerClass属性
用于记录内部类与宿主类之间的关联。如果一个类中定义了内部类,那编译器将会为它以及它所包含的内部类生成该属性。Deprecated以及Synthetic属性
这两个属性都属于标志类型的布尔属性,只存在有和没有。前者表示某个类、字段、或者方法已经被程序作者定位不再推荐使用;后者表示此字段或者方法并不是由Java源码直接产生,而是由编译器自动产生的,最常见的是Bridge Method,但是除init和clinit方法之外StackMapTable属性
位于Code属性的属性表中,这个属性会在虚拟机类加载的字节码验证阶段被新类型检查验证器使用,目的是在于代替比较消耗性能的基于数据流的类推导验证器Signature属性
该属性会记录类、接口、初始化方法或者成员的泛型签名信息BootstrapMethods属性
是一个复杂的变长属性,用于保存invokedynamic指令引用的引导方法限定符
4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志->类索引->父类索引->接口索引->字段表集合->方法表集合->属性表集合
虚拟机类加载机制
- 虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制
- 在Java语言里面, 类型的加载、 连接和初始化过程都是在程序运行期间完成的,Java应用提供了极高的扩展性和灵活性, Java天生可以动态扩展的语言特性就是依赖运行期动 态加载和动态连接这个特点实现的
类加载的时机
类的声明周期:加载-{(连接)验证-准备-解析}-初始化-使用-卸载
解析在某些时候可能会出现在初始化解读之后,比如运行时绑定
虚拟机规范严格规定了有且只有6种情况必须立即对类进行初始化(如果类还没有初始化):
遇到new、getstatic、putstatic或invokestatic者4条字节码指令时,如果类没有初始化则进行初始化
使用new关键字实例化对象的时候
读取或设置一个类型的静态字段(被 final 修饰、已在编译期把结果放人常量池的静态字段除外)
调用一个类的静态方法的时候
使用reflect包的方法对类进行反射调用的时候
当初始化一个类的时候,其父类还没有进行过初始化,则需要先触发其父类的初始化,注意的是,一个接口在初始化时,并不要求其父类接口全部都完成了初始化
当虚拟机启动时,用户需要制定一个执行的主类,虚拟机会先初始化这个主类
当使用JDK1.7的动态语言支持时,如果一个MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化时,则需要先进行初始化
当一个接口中定义了JDK8新加入的默认方法( 被default关键字修饰的接口方法)时, 如果有这个接口的实现类发生了初始化, 那该接口要在其之前被初始化
上面的6种成为对类的一个主动引用,除此之外, 所有引用类型的方式都不会触发初始化, 称为被动引用:
通过子类引用父类的静态字段,不会导致子类初始化
通过数组定义来引用类,不会触发此类的初始化,数组在虚拟机中其实是虚拟机自己创造的一个类(它是一个由虚拟机自动生成的、 直接继承于java.lang.Object的子类, 创建动作由字节码指令newarray触发)
常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
接口也有初始化过程,接口中不能使 用“static{}”语句块, 但编译器仍然会为接口生成“\
()”类构造器,用于初始化接口中所定义的成员变量
类加载的过程
加载
加载需要完成三件事:
- 通过一个类的全限定名获取此类的二进制字节流
- 将这个字节流代表的静态存储结构转化为方法区的运行时数据结构
- 在内存生成该类的Class对象,作为方法区这个类的各种数据的访问入口
相对于其他阶段,一个非数组类的加载阶段是开发人员可控性最强的,我们可以自己定义类加载器来完成加载行为
对于数组类,它的加载过程:
- 如果数组的组件类型是引用类型,那就递归采用上面提到的加载过程加载这个组件类型
- 如果不是引用类型,会把数组标记为与引导类加载器关联
- 数组类的可见性与它的组件类型的可见性一致。如果组件类型不是引用类型, 它的数组类的可访问性将默认为public, 可被所有的类和接口访问到
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区中,方法区中的数据存储格式虚拟机自行定义,然后在内存中实例化一个Class类的对象
验证
验证的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求
验证包括:
文件格式验证: 验证字节流是否符合Class文件格式的规范
元数据验证: 第二阶段是对字节码描述的信息进行语义分析, 以保证其描述的信息符合《Java语言规范》 的要求 ,比如继承的正确性
字节码验证: 主要目的是通过数据流分析和控制流分析, 确定程序语义是合法的、 符合逻辑的
符号引用验证: 发生在虚拟机将符号引用转化为直接引用的时候, 这个转化动作将在连接的第三阶段—解析阶段中发生, 比如是否可访问
准备
准备阶段说是正式为类变量(即静态变量, 被static修饰的变量)分配内存并设置变量初始值阶段,这些变量所使用的内存概念上都将在方法区中进行分配,注意这时候分配的是类变量不是实例变量,实例变量会分配在java堆中,另外这里所说的初始值是指对应类型的零值
从概念上讲, 这些变量所使用的内存都应当在方法区中进行分配, 但必须注意到方法区 本身是一个逻辑上的区域, 在JDK 7及之前, HotSpot使用永久代来实现方法区时, 实现是完全符合这种逻辑概念的; 而在JDK8及之后, 类变量则会随着Class对象一起存放在Java堆中
如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值
解析
- 解析阶段是Java虚拟机将常量池内的符号引用替换为直接引用的过程, 这个符号引用指的是在Class文件中以CONSTANT_Class_info、 CONSTANT_Fieldref_info、 CONSTANT_Methodref_info等类型的常量出现
- 符号引用是以一组符号来描述所引用的目标,符号可以是任何 形式的字面量, 只要使用时能无歧义地定位到目标即可
- 直接引用是可以直接指向目标的指针、 相对偏移量或者是一个能间接定位到目标的句柄
- 除invokedynamic指令以外, 虚拟机实现可以对第一次解析的结果进行缓存, 譬如在运行时直接引用常量池中的记录, 并把常量标识为已解析状 态, 从而避免解析动作重复进行。 无论是否真正执行了多次解析动作, Java虚拟机都需要保证的是在 同一个实体中, 如果一个符号引用之前已经被成功解析过, 那么后续的引用解析请求就应当一直能够 成功; 同样地, 如果第一次解析失败了, 其他指令对这个符号的解析请求也应该收到相同的异常
- 不过对于invokedynamic指令, 上面的规则就不成立了。 当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时, 并不意味着这个解析结果对于其他invokedynamic指令也同样生效。 因为invokedynamic指令的目的本来就是用于动态语言支持。这里“动态”的含义是指必须等到程序实际运行到这条指 令时, 解析动作才能进行
- 解析包括:类或接口的解析,字段解析,类方法解析,接口方法解析
初始化
- 初始化是加载过程的最后一步,初始化是执行clinit方法的过程,clinit方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序和类中出现的顺序一致,因此静态语句块中只能访问到定义在静态语句块之前的变量
- 虚拟机保证在子类的clinit方法执行之前,父类的clinit方法已经执行完毕,因此父类中定义的静态语句块要优先于子类的变量赋值操作
- clinit方法不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不生成该方法
- 接口中不能使用静态语句块, 但仍然有变量初始化的赋值操作, 因此接口与类一样都会生成 \
()方法。但接口与类不同的是, 执行接口的\ ()方法不需要先执行父接口的\ ()方法, 因为只有当父接口中定义的变量被使用时, 父接口才会被初始化。 此外, 接口的实现类在初始化时也 一样不会执行接口的\ ()方法 - 虚拟机会保证一个类的clinit方法在多线程环境中被正确的加锁、同步。如果多个线程同时去初始化一个类, 那么只会有其中一个线程去执行这个类的\
()方法
类加载器
“通过一个类的全限定名称来获取描述此类的二进制字节流”这个动作成为类加载
类与类加载器
一个类是由它的类加载器和这个类本身一起确立其在Java虚拟机中的唯一性。每一个类加载器, 都拥有一个独立的类名称空间。两个相同限定名的类,经过不同的类加载器加载也是代表两个不同的类,而且Class的equals,isAssignableFrom,isInstance方法返回的结果也会不一致
双亲委派模型
从虚拟机的角度来说,有两种不同的类加载器:一种是启动类加载器(Bootstrap Classloader),是C++实现的,它是虚拟机的一部分;另一个部分就是所有其他的类加载器,是由Java实现的,且用户可以自定义
从开发人员的角度可以分为三种类加载器:
启动类加载器:负责加载JAVA_HOME/lib目录中的被虚拟机识别的类,无法被Java直接引用,用户在编写自定义的类加载器的时候,如果需要把类加载请求委托给引导类加载器,直接给加载器赋值为null就行
扩展列加载器:负责加载JAVA_HOME/lib/ext目录中的类
应用程序加载器:由AppClassLoader实现,一般称为系统类加载器,负载加用户的ClassPath上说指定的类,开发者可以直接使用这个类加载器,也是默认使用的类加载器
优先级:启动类加载器->扩展类加载器->应用程序类加载器->自定义类加载器
类加载器的双亲委派模型:
要求除了顶层启动类加载器外,其余的类加载器都应当有自己的父类加载器。不过这里类加载器之间的父子关系一般不是以继承(Inheritance) 的关系来实现的, 而是通常使用 组合(Composition) 关系来复用父加载器的代码
过程: 如果一个类加载器收到了类加载的请求,它首先不会尝试加载这个类,而是把请求往上传递,只有当父加载器反馈无法加载的时候,子加载器才会尝试加载
好处:加载器有优先级关系,对于那些公用的类来说,都可以委托优先级高的类统一加载
破坏双亲委派模型:
第一次: 由于JDK1.2之后才引入的双亲委派模式,因此为了前向兼容,允许用户自定义loadClass的代码,从而可以使用自定的加载类加载代码。JDK1.2之后,建议通过findClass来定义自己的类加载器
第二次:JNDI,JDBC等需要调用独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI)的代码,因此需要委托子加载器加载代码,可以通过Thread类的setContextClassLoader()来设置加载器,默认是应用程序类加载器
第三次:像OSGi的热代码替换技术重新构建了自己的类加载逻辑,没有采用双亲委派模式,而是引入了Bundle的概念,Bundle类似于模块的概念,当更换一个Bundle的时候,就把Bundle连通类加载器一起更换
Java模块化系统
在JDK 9中引入的Java模块化系统(Java Platform Module System, JPMS) 是对Java技术的一次重要升级, 为了能够实现模块化的关键目标——可配置的封装隔离机制
在JDK 9以后, 如果启用了模块化进行封装, 模块就可以声明对其他模块 的显式依赖, 这样Java虚拟机就能够在启动时验证应用程序开发阶段设定好的依赖关系在运行期是否 完备, 如有缺失那就直接启动失败, 从而避免了很大一部分由于类型依赖而引发的运行时异常
为了使可配置的封装隔离机制能够兼容传统的类路径查找机制, JDK 9提出了与“类路 径”(ClassPath) 相对应的“模块路径”(ModulePath) 的概念
虚拟机字节码执行引擎
概述
执行引擎是Java虚拟机的最核心组成部分之一,在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行和编译执行
运行时栈帧结构
栈帧是 用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机的栈元素,每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
栈帧中的组成:
局部变量表、操作数栈、动态链接、返回地址等信息
在编译的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并写入了方法表的Code属性中,因此一个栈帧需要分配多少内存,是编译时确定的
在活动的线程中,只有位于栈顶的栈帧才是有效的,其被称为“当前栈帧” ,这个栈帧关联的方法称为当前方法。运行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表
局部表量表用来存储方法参数和方法内定义的局部变量,在编译时,Code属性中的max_local就定义了其最大容量。
局部变量表的基本存储单位是Slot,Slot的长度和虚拟机相关,但是要满足存储一些基本的数据类型:
boolean,byte,char,short,int,float,reference,returnAddress
其中reference标识一个对象实例的引用
returnAddress目前很少见,它是为字节码jsr等服务的,用来实现异常处理,现在虚拟机一般通过异常表来代替
对于Java虚拟机来说,一个slot可以存放一个32位以内的数据类型,对于64位的基本数据类型,虚拟机会以高位对齐的方式分配两个连续地Slot空间(long,double)
虚拟机通过索引定位的方式使用局部变量表,索引值得范围从0开始至局部变量表最大的Slot数量。如果访问的是64位变量,会同时使用第N和N+1两个变量槽
在方法执行的时候,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的是实例方法,局部变量表的第0位索引默认是this变量
局部变量表的Slot是可以重用的,当超出变量作用域,且后面又有新的变量出现就会重用之前变量的Slot。如果变量除了作用域,其占用的slot还没有被复用,及时主动调用gc,该变量占用的内存也不会被回收。修复的办法如下,但是不推荐这样写,因为无效的代码会被JIT优化掉,而且优化后的代码也不需要手动置为null
需要注意的是,局部变量不像之前介绍的类变量一样存在准备阶段,类变量会经过两次初始化过程,一次是在准备阶段,赋予系统初始值,另一次是在初始化阶段,赋予程序定义的初始值。但是局部变量没有这些,没有赋值的局部变量是无法引用的
操作数栈
- 操作数栈的最大深度也是编译时确定好的,存于Code属性表中
- 操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位为2
- 当一个方法开始执行的时候,操作数栈是空的,在方法执行的过程中,不断的会有入栈和出栈的操作。譬如在做算术运算的时候是通过 将运算涉及的操作数栈压入栈顶后调用运算指令来进行的, 又譬如在调用其他方法的时候是通过操作数栈来进行方法参数的传递
- 操作数栈的数据类型必须和当前要执行的指令类型严格匹配,不然会报错
- 栈帧中,为了减少额外的参数赋值传递,为让不同栈帧(来自不同方法)的局部变量表共享区域和操作数栈共享区域重叠,如下图所示。这样做不仅节约了一些空间, 更重要的是在进行方法调 用时就可以直接共用一部分数据, 无须进行额外的参数复制传递了
- Java虚拟机是基于栈的执行引擎,其中栈就是指操作数栈
动态链接
- 每个栈帧都持有一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接
- 符号引用一部分会在类加载阶段直接化为直接引用,这称为静态解析,另一部分会在运行时进行动态解析
方法返回地址
有两种方式退出方法:
第一种称为正常调用完成:遇到了返回的字节码指令,正常退出时,调用者的PC计数器的值可以作为返回地址
第二种称为异常调用完成
遇到了异常且异常表中没有匹配的异常处理器,就会导致方法退出。异常退出时,返回地址是要通过异常处理器表来确定
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分完全取决于具体的虚拟机实现
方法调用
方法的调用只是确定调用方法的版本,不涉及方法内部的运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。因此Java需要在类加载阶段甚至是运行时才能决定所调用目标方法的直接引用
解析
调用目标在程序代码写好、编译器进行编译的那一刻就已经确定下来,这类的方法调用称为解析调用,它是一个静态的过程(另一种调用方式是分派)
符合“编译期可知, 运行期不可变”这个要求的方法, 主要有静态方法和私有方法两大类
如果方法在真正运行之前就可以确定调用的版本,并且在运行时是不可变的,则在类加载的解析阶段就会转换为直接引用,采用这种方式的方法一般是静态方法和私有方法,因为没法重写和改变
虚拟机调用方法有5中指令:
invokestatic:调用静态方法
inivokespecial:调用实例构造器方法,私有方法和父类方法
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法
invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后在执行该方法
前面的4种指令的方法分派逻辑是固化在虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的
只要能被invokestatic和invokespecial指令调用的方法, 都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、 私有方法、 实例构造器、 父类方法4种, 再加上被final 修饰的方法(尽管它使用invokevirtual指令调用) ,这些方法也称为非虚方法,除此之外的方法都是虚方法。
解析调用一定是个静态的过程, 在编译期间就完全确定, 在类加载的解析阶段就会把涉及的符号 引用全部转变为明确的直接引用, 不必延迟到运行期再去完成
另一种主要的方法调用形式: 分派 (Dispatch) 调用则要复杂许多, 它可能是静态的也可能是动态的, 按照分派依据的宗量数可分为单分派和多分派 ,分派又可以静态和动态
分派
Java面向对象的三个基本特特征:继承、封装和多态
静态分派:所有依赖静态类型来决定方法执行版本的分派动作, 都称为静态分派。实际上就是方法的重载,此时方法是依赖静态类型来判断和执行方法的。虚拟机(或者准确地说是编译器) 在重载时是通过参数的静态类型而不是实际类型作为 判定依据的。静态分派也调用invokevirtual指令。静态分派发生在编译阶段, 因此确定静态分派的动作实际上不是由虚拟机来执行的, 这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因
动态分派:我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派,动态分派实际指的就是多态性,也就是方法的重写 ,动态分派会调用invokevirtual指令,其解析过程如下:
找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C
如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回直接引用,不如不通过则返回异常
如果没找到,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
如果没有找到合适的方法则抛出异常
单分派和多分派
方法接收者和方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划为单分派和多分派。
Java语言是一门静态多分派,动态单分派的语言
虚拟机动态分派的实现:
动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索,常用的优化方法有:虚方法表(如果子类重写了某个方法,子类方法表中的地址将会替换为指向子类实现版本入口地址)。
动态语言的支持
JDK 7以前的字节码指令集中, 4条方法调用指令( invokevirtual、 invokespecial、 invokestatic、invokeinterface) 的第一个参数都是被调用的方法的符号引用( CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量)。前面已经提到过, 方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。
JDK1.7新增了invokedynamic指令和invoke包来支撑动态语言,这个包的主要目的是在之前 单纯依靠符号引用来确定调用的目标方法这条路之外, 提供一种新的动态确定目标方法的机制, 称 为“方法句柄”(Method Handle)。动态语言的一个特征是:变量无类型而变量值才有类型。
invoke包的使用案例:
1 | public class MethodHandleTest { |
MethodHandle和Reflection的区别:
反射是在java代码层次模拟方法的调用,而MethodHandle是在字节码层面模拟方法的调用
反射是重量级的,而MethodHandle是轻量级的
MethodHandle可以享有调用类似字节码指令时的虚拟机优化,同时它可以不进针对java语言
invokedynamic指令:
含有invokedynamic指令的位置都被称作“动态调用点(Dynamically-Computed Call Site) ”, 这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量, 而是变为JDK 7 时新加入的CONSTANT_InvokeDynamic_info常量
其分派逻辑不是由虚拟机决定的,而是由程序决定
引入了CONSTANT_InvokeDynamic_info常量,它包含引导方法,MethodType和名称信息
使用invoke包实现子类调用组类的方法:
1 | public class TestInvoke { |
基于栈的字节码解释执行引擎
- Java编译器输出的指令流,基本上是一种基于栈的指令集架构,与此相对的是基于寄存器的指令结构
- 基于栈的指令结构是可有移植,因为寄存器是和硬件强相关的
- 基于栈架构指令的主要缺点是执行速度相对较慢一点
类加载及执行子系统的案例与实战
概述
对于JVM的编译运行,用户能通过程序操作的主要是字节码生成与类加载器
案例分析
Tomcat:正统的类加载架构
tomcat的要求:
- 不同的web应用程序使用的类库可以实现相互隔离
- 不同的web应用程序使用的类库也可以互相共享
- 服务器要尽可能保证自身的安全不受部署的web应用程序影响
- 可能要支持HotSwap功能
因此,一个classpath在web服务器是满足不了要求的。Tomcat自定义了以下的类加载器:
Common类加载器,继承自应用程序类加载器:它包含两个子类加载器:
Catalina类加载器
Shared类加载器–>WebApp类加载器–>Jsp类加载器
Tomcat使用了正统的双亲委派模式
OSGI:灵活的类加载架构
OSGI的每个模块成为Bundle,与普通的类库区别不大。但是Bundle类加载器之间只有规则,没有固定的委派关系
OSGi之所以能有上述诱人的特点, 必须要归功于它灵活的类加载器架构。 OSGi的Bundle类加载器之间只有规则, 没有固定的委派关系。 例如, 某个Bundle声明了一个它依赖的Package, 如果有其他 Bundle声明了发布这个Package后, 那么所有对这个Package的类加载动作都会委派给发布它的Bundle类 加载器去完成。
早期编译期优化
Javac编译器
Javac编译器是由Java编写的,编译过程大致可以分为3个过程:
解析和填充符号表:解析又包括语法、词法分析
插入式注解处理器的注解处理过程
分析与字节码生成过程:分析又包括标注检查、解语法糖
语法糖的味道
泛型与类型擦除
Java的泛型其实一种伪泛型,主要是在编译器起作用,生成的字节码会进行泛型擦除,所以如果两个方法仅仅是参数的泛型参数化的类型不同,是构不成重载的,因为参数化类型擦除后,参数类型都是一个,但是如果返回值也不同是可以构成重载的,因为Java允许返回值其他签名相同的方法共存
自动装箱、拆箱和遍历循环
包装类在不遇到算术运算的情况下不会自动拆箱
条件编译
Java的条件编译实际就是靠If语句来实现的(编译优化)
晚期编译器优化
概述
- java最初只有解释器,后台增加了即时编译器(JIT),能够对运行特别频繁的热点代码进行编译和优化
- 即时编译对于Java虚拟机规范来说不是必须的
HotSpot使用的JIT
解释器与编译器
- 对于HotSpot(以下简称H)等使用了解释器和编译器的虚拟机:首先通过解释器启动程序,在程序运行后,编译器逐渐发挥作用。通过把代码编译成本地代码,获取更高的执行效率
- 解释器可以作为编译器激进优化的逃生门,当激进优化假设不成立的时候可以通过逆优化退回到解释执行
- H虚拟机内置了两个即时编译器:Client 编译器和Server编译器,简称C1和C2编译器,使用哪个编译器决定于JVM是运行client还是server模式,但是无论使用C1还是C2,都是在解释器和编译器都有的混合模式下运行,可以通过相关参数来强制让JVM运行在解释模式或优先使用编译器的模式
- 解释器可以为编译器收集性能监控信息
- H采用分层编译的策略,层数越高,编译程度越高
- C1编译速度更快,C2编译质量越高
编译器对象和触发条件
- 热点代码有两类:1. 多次调用的方法;2.被多次执行的循环体
- 目前热点探测判定方式有两种:基于采样的热点探测;基于计数器的热点探测。采用不准确,但是高效。H采用的是计数器形式,会设置一个阈值,当计数器超过该阈值就会触发编译,对于第一类热点代码采用的是普通的JIT编译,对于循环体采用的是OSR编译
- 判断是否是热点代码是计算调用计数器和回边计数器值(统计循环次数)之和是否超过阈值来判定的
- 如果不做任何设置,方法调用统计器统计的并不是方法调用的绝对次数,而是一个相对的执行频率,也就是超过一段时间,该数量会衰减
- 调用计数器和回边计数器都有阈值,回边计数器阈值是通过计算公式计算出来的
- 对于循环体的场景,如果计数器的和超过阈值后,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果
- 回边计数器没有热点衰减过程,因此统计的是绝对次数
编译过程
在默认设置下,无论是方法调用产生的即时编译,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释的方式进行,编译在后台进行。对于Client Compiler,它采用三段式编译,主要的关注点在于局部性的优化,而放弃了许多耗时较长的全局优化手段。对于Server Compiler,它会执行所有的经典优化动作
编译优化技术
常见的优化技术有:
- 空循环可能会被优化掉,不会执行
- 方法内联:非虚方法直接内联
- 冗余代码消除:代码简化
- 复写传播:使用某些变量代替其他的变量是的变量访问的次数减少
- 无用代码消除
- 公共子表达式消除:相同结果的表达式化为同一个变量
- 数组边界检查消除
- 判空消除
- 逃逸分析:栈上分配,同步消除,标量替换
Java内存模型与线程
硬件的效率一致性
- 由于CPU和内存速度的差距,现代计算机系统都会使用高速缓存,这就会导致缓存不一致的问题,因此多个处理器都涉及同一块主内存时,读写时要根据相关的协议操作
- 除了增加高速缓存之外, 为了使处理器内部的运算单元能尽量被充分利用, 处理器可能会对输入代码进行乱序执行( Out-Of-Order Execution) 优化
Java内存模型
- Java试图定义一种内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台下都能达到一致的内存访问效果
主内存和工作内存
- Java内存模型规定了所有的变量(线程共享的变量)都存在主内存中,每个线程还有自己的工作内存。线程的工作内存中保存了该线程使用到的变量的主内存的拷贝,线程对变量的所有操作都是在工作内存中进行的,不同的线程之间不会互访工作内存。工作内存和主内存通过save和load指令交互
- 主内存直接对应于物理硬件的内存, 而为了获取更好的运行速度, 虚拟机(或者是硬件、 操作系统本身的优化措施) 可能会让工作内存优先存储于寄存器和高速缓存中, 因为程序运行时主要访问的是工作内存
内存交互操作
Java内存模型定义了8中操作,虚拟机必须保证每一种操作都是原子的:
lock:作用于主内存
unlock:作用于主内存
read:从主内存读
load:把read的数据放入工作内存
use:作用于工作内存,用于向执行引擎传递数据
assign:作用于工作内存,从执行引擎接收数据赋值给变量
store:作用于工作内存,把变量的值传送到主内存中
write:作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存变量中
Java针对这8中操作定义了一些基本的规则,这样基本规则实际等效于happen-before原则
volatile的变量的特殊规则
- volatile是虚拟机提供的最轻量级的同步机制
- volatile第一个语义保证了变量对所有线程的可见性
- 但是保证可见性并不代表是线程安全的,在不符合下面两条规则的场景仍需要加锁:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一线程修改变量的值
- 变量不需要与其他的状态变量共同参与不变约束
- volatile变量的第二个语义是禁止指令重排序优化,它是通过以下方式实现的:
- 会在操作指令中加入一条空操作,这条空操作带有lock指令,使其后面的指令不能重排到前面去,lock会使CPU的cache写入内存,因此会使其他的cache无效化,通过这样一个空操作,可让前面的volatile变量的修改对其他CPU立即可见
- 该操作把修改同步到主内存,意味着所有之前的操作都已经执行完毕,这样便形成了”指令重排序无法越过内存屏障“的效果
- double和long的操作可以是非原子的,尽管目前大部分虚拟机都是实现为原子的
原子性、可见性、有序性
Java内存模型是围绕着并发过程中如何处理原子性、可见性和有序性的3个特征来建立的
原子性:底层靠的是lock和unlock指令,更高层次发展为monitorenter和monitorexit,然后是synchronized关键字
可见性:volatile关键字,synchronized和final关键字也支持
而final关键字的可见性是指: 被final修饰的字段在构造器中一旦被初始化完成, 并且构造器没有把“this”的引用传递出去( this引用逃逸是一件很危险的事情, 其他线程有可能通过这个引用访问到“初始化了一半”的对象) , 那么在其他线程中就能看见final字段的值
有序性:如果本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有的操作都是无序的
volatile和synchronized两个关键字来保证线程之间操作的有序性
线性并发原则
也就是happens-before原则,它是判断数据是否竞争、线程是否安全的主要依据。它定义了Java内存模型中定义的两项操作之间的偏序关系,这是无需任何同步手段就能成立的
- 程序次序规则(Program Order Rule) : 在一个线程内, 按照控制流顺序, 书写在前面的操作先行发生于书写在后面的操作。 注意, 这里说的是控制流顺序而不是程序代码顺序, 因为要考虑分支、 循环等结构。
- 管程锁定规则(Monitor Lock Rule) : 一个unlock操作先行发生于后面对同一个锁的lock操作。 这里必须强调的是“同一个锁”, 而“后面”是指时间上的先后
- volatile变量规则(Volatile Variable Rule) : 对一个volatile变量的写操作先行发生于后面对这个变量的读操作, 这里的“后面”同样是指时间上的先后。
- 线程启动规则(Thread Start Rule) : Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则(Thread Termination Rule) : 线程中的所有操作都先行发生于对此线程的终止检测, 我们可以通过Thread::join()方法是否结束、 Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
- 线程中断规则(Thread Interruption Rule) : 对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生, 可以通过Thread::interrupted()方法检测到是否有中断发生。
- 对象终结规则(Finalizer Rule) : 一个对象的初始化完成(构造函数执行结束) 先行发生于它的finalize()方法的开始。
- 传递性(Transitivity) : 如果操作A先行发生于操作B, 操作B先行发生于操作C, 那就可以得出操作A先行发生于操作C的结论
先行发生和时间上的先后没有关系:
操作A先行发生于操作B, 其实就是说在发生操作B之前, 操作A产生的影响能被操作B观察到
Java与线程
线程的实现
实现线程主要有3中方式:使用内核实现、使用有用户线程实现和使用用户线程加轻量级机进程混合实现
使用内核:有内核来完成线程切换和线程调度,程序一般不会直接去使用内核线程,而是去使用内核线程的高级接口——轻量级进程
使用用户线程:效率很低
混合实现
Java线程如何实现并不受Java虚拟机规范的约束, 这是一个与具体虚拟机相关的话题
以HotSpot为例, 它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的, 而且中间没有额外的间接结构, 所以HotSpot自己是不会去干涉线程调度的
Java线程调度
系统调度主要有两种方式:协同式线程调度和抢占式线程调度,Java采用后者
状态转换
Java定义了5中线程状态
线程安全与锁优化
线程安全
Java语言中的线程安全
- 不可变:一定是线程安全的
- 绝对线程安全:不需要任何同步就能达到线程安全
- 相对线程安全:需要额外的保障
- 线程兼容:不安全但使用得当也没问题
- 线程队里:肯定会死锁
线程安全的实现方法:
- 互斥同步:加锁
- 非阻塞同步:CAS
- 无同步方案:有一些代码天生是线程安全的
锁优化
自旋锁和自适应锁
- 互斥同步对性能最大的影响是阻塞的实现, 挂起线程和恢复线程的操作都需要转入内核态中完成
- 可以让后面请求锁的那个线程“稍等一会”, 但不放弃处理器的执行时间, 看看持有锁的线程是否很
快就会释放锁 - 自旋锁在JDK 1.4.2中就已经引入, 只不过默认是关闭的, 可以使用-XX: +UseSpinning参数来开
启, 在JDK 6中就已经改为默认开启了 - 在JDK 6中对自旋锁的优化, 引入了自适应的自旋
锁消除
锁粗化
轻量级锁
- 在代码即将进入同步块的时候, 如果此同步对象没有被锁定(锁标志位为“01”状态) , 虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record) 的空间, 用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀, 即Displaced Mark Word)
- 然后, 虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。 如果这个更新动作成功了, 即代表该线程拥有了这个对象的锁, 并且对象Mark Word的锁标志位(Mark Word的最后两个比特) 将转变为“00”, 表示此对象处于轻量级锁定状态
- 如果这个更新操作失败了, 那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。 虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧, 如果是, 说明当前线程已经拥有了这个对象的锁, 那直接进入同步块继续执行就可以了, 否则就说明这个锁对象已经被其他线程抢占了。 如果出现两条以上的线程争用同一个锁的情况, 那轻量级锁就不再有效, 必须要膨胀为重量级锁, 锁标志的状态值变为“10”, 此时Mark Word中存储的就是指向重量级锁(互斥量) 的指针
- 解锁过程也同样是通过CAS操作来进行的, 如果对象的Mark Word仍然指向线程的锁记录, 那就用CAS操作把对象当前的Mark Word和线程中复制的DisplacedMark Word替换回来。 假如能够成功替换, 那整个同步过程就顺利完成了; 如果替换失败, 则说明有其他线程尝试过获取该锁, 就要在释放锁的同时, 唤醒被挂起的线程
偏向锁
- 当锁对象第一次被线程获取的时候, 虚拟机将会把对象头中的标志位设置为“01”、 把偏向模式设置为“1”, 表示进入偏向模式。 同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。 如果CAS操作成功, 持有偏向锁的线程以后每次进入这个锁相关的同步块时, 虚拟机都可以不再进行任何同步操作
- 一旦出现另外一个线程去尝试获取这个锁的情况, 偏向模式就马上宣告结束。 根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”) , 撤销后标志位恢复到未锁定(标志位为“01”) 或轻量级锁定(标志位为“00”) 的状态, 后续的同步操作就按照上面介绍的轻量级锁那样去执行
- 当一个对象已经计算过一致性哈希码后, 它就再也无法进入偏向锁状态了; 而当一个对象当前正处于偏向锁状态, 又收到需要计算其一致性哈希码请求时, 它的偏向状态会被立即撤销, 并且锁会膨胀为重量级锁。 在重量级锁的实现中, 对象头指向了重量级锁的位置, 代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”) 下的Mark Word, 其中自然可以存储原来的哈希码