深入理解Java虚拟机读书笔记
Java虚拟机的发展史(略)
SunClassic/Exact VM
只能用纯解释方式来执行Java代码,如果使用JIT编译器,就必须使用外挂。但是如果外挂了JIT编译器,JIT编译器完全接管了虚拟机的执行系统,解释器便不再工作了,即解释器和编译器不能配合工作。编译器和解释其的区别
HotSpot VM
JDK1.3后,HotSpot VM就成为默认的虚拟机,其中HotSpot是指热点探测技术,它通过计数器找出最具有价值的代码,然后通知JIT编译器以方法为单位进行编译
嵌入式的 VM和Meta-Circular VM(元循环VM)
JRockit和IBM J9 VM
JRockit专门为服务器硬件和服务器端应用场景高度优化的虚拟机,因此内部不包含解析器的实现。J9会一款高性能的虚拟机
自动内存管理机制
运行时数据区域
程序计数器
运行时的数据区可以分为线程之间共享的数据区和线程隔离的数据区,其中程序计数器是线程隔离的数据区,每个线程通过程序计数器来记录当前执行的指令,或者说行号。字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令。需要注意的是执行native方法时,计数器值为空(Undefined)。
Java虚拟机栈
通常把虚拟机分为堆内存和栈内存,这里的虚拟机栈就是指栈内存。虚拟机栈也是线程私有的,它的生命周期与线程相同,它描述的是Java方法执行的内存模型;每个方法在运行的时候都会创建一个栈帧,它是一种数据结构,每一个方法的从调用直至执行完成,就对应着一个栈帧在虚拟机栈中的入栈和出栈的过程。如果线程请求的栈的深度大于所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈在动态扩展时无法申请足够的内存,将会抛出OOM异常。
本地方法栈
本地方法栈和虚拟机栈类似,只不过它是为本地方法服务的。
Java堆
Java堆是迅疾所管理的内存中最大的一块,它能够被所有的线程共享。此内存区域的唯一目的就是存放对象的实例,几乎所有的对象实例都在这里分配内存。Java堆是垃圾收集器管理的主要区域,也称为“GC堆”。Java堆可以分为:新生代和老生代,再细致点可以分为Eden空间、From Survivor空间、To Survivor空间。Java堆无法扩展时会抛出OOM异常。
方法区
方法区也是各个线程共享的内存区域,它用于存储已被虚拟机就加载的类信息、常量、静态变量、即时编译器编译后的代码等数据(修饰符、常量池、字段描述、方法描述),虽然Java虚拟机把它描述为堆的一个逻辑部分,但是它却有一个别名叫Non-Heap,目的是为了和Java堆区分开来。很多虚拟机使用“永久代”来实现方法区,因此也称为“永久代”。当方法区无法满足内存分配的需求时,将抛出OOM异常
常量池
运行时常量池是方法区的一部分,它是编译期生成的各种字面量和符号引用,在类加载后进入常量池。同时运行时期间也能够将新的常量放入常量池,比如调用String.intern()方法。由于受方法区的限制,因此也能抛出OOM异常
直接内存
直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但是这部分内存也被频繁的使用,因此也可能会抛出OOM异常。比如NIO会直接使用native方法分配对外内存。
HotSpot虚拟机对象探秘
对象的创建过程
- 当虚拟机遇到一条new指令时,会先去检查这个指令的参数是否能在常量池中定位到这个类的符号引用,并且检查这个符号引用是否被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
- 类加载后虚拟机将为新生对象分配内存,对象所需大小在类加载完成后便可确定。堆空间有两种分配方式,一种是“指针碰撞”(注:其实翻译为指针跳跃更恰当):也就是堆的内存分配是规整的,用过的内存放一边,空闲的内存放一边,分配的时候只需要移动中间的分界点指示器即可。还有一个分配方式称为”空闲列表“,也就是虚拟机内部维护一张表,记录那些内存是使用的,哪些是空闲的。
- 为了保证并发分配内存的内存空间的安全性,虚拟机采用CAS加失败重试的方法保证更新操作的原子性。另一种方式是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,成为本地线程分配缓冲(TLAB),只有TLAB用完,才需要同步锁定。
- 内存分配完后,虚拟机需要将分配到的内存空间都初始化为零值。
- 接下来,虚拟机要对对象进行必要的设置(设置对象头),例如这个对象是哪个类的实例,如何才能找到类的元数据信心、对象的哈希码、对象的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设置栈容量
在单线程情况下,一般只会抛出StackOverFlow异常,因为内存太小和栈空间无法分配本质上是一个概念,在抛出该异常后会打印栈深度。在多线程不断创建线程的情况,会出现OOM异常,而且为每个线程分配的内存越大,越容易出现该异常。
方法区和运行时常量池溢出
可以通过-XX:PerSize和-XX:MaxPermSize来限制方法区大小,从而间接限制其中的常量池的容量
可以调用intern方法不断将字符串加入常量池
对于HotSpot虚拟机和使用JDK1.6来说,常量池OOM会显示PermGen space OOM,因为常量池属于方法区的一部分,而方法区又是用永久代实现的。
但是JDK1.7开始逐步“去永久代”,因此使用JDK试验会得出不同的结果。这同时引出了一个更有意思的案例:
//对于1.6会返回false,对于jdk1.7返回true
String str = new StringBuilder("计算机").append("软件").toString();
System.out.println(str.intern() == str);
//对于jdk1.6会返回false,对于jdk1.7返回fasle
String str3 = new StringBuilder("ja").append("va").toString();
System.out.println(str2.intern() == str3);
JDK1.7的intern不会在复制实例,而只是在常量池中记录首次出现的实例引用,因此intern返回的引用和由StringBuilder创建的那个字符串实例是同一个,但是由于“java”这个常量已经由其他类加载到了常量池中,所以返回的false。
测试方法区OOM可以使用CGLib不断的创建增强类,因为这类字节码技术需要足够容量的方法区来保证动态生成的Class可以加载到内存中。
本机直接内存溢出
直接内存溢出常常和NIO的使用有关,因为它会占用Java堆以外的内存。直接内存如果不指定默认和Java堆的最大值一样,可以通过使用Unsafe类进行直接内存的分配来验证OOM异常。
垃圾收集器与内存分配策略
概述
GC需要完成3件事情:
哪些内存需要回收
什么时候回收
如何回收
了解GC是为了能够排查各种内存溢出、内存泄漏问题,当垃圾收集成为系统达到更高并发量的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。
程序计数器、虚拟机栈、本地方法栈这个三个区域随线程而生,随线程而灭。栈中的栈帧随着方法的进入和退出进行出栈和入栈,每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的。因此GC主要是指Java堆和方法区的垃圾回收。
对象已死吗
引用计数法
引用计数法就是给对象中的引用添加一个引用计数器,每当有一个地方引用它时,计数器的值就加1,当引用失效时,计数器值就减1,当引用计数为0就表示对象可以回收 。
引用计数法的效率很高,但是它不能解决对象之间循环依赖的问题。
可达性分析方法
可达性分析是主流的GC方法,基本思想就是通过一系列成为“GC Roots”的对象作为起点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明对象是不可用的。在Java中,可作为GC Roots的对象包括下面几种:
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中JNI引用的对象
在JDK1.2之后,Java对引用的概念进行了扩充:
- 强引用:通过new出来的引用,只要强引用还存在,垃圾收集器永远不会回收掉引用的对象
- 软引用:描述一些还有用但是非必须的对象,在系统将要发生内存溢出异常之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收还没有足够的内存,才会抛出异常
- 弱引用:一次GC就会回收
- 虚引用:幽灵引用,它的目的就是能在这个对象被回收时收到一个系统通知。
生存还是死亡
即使不可达的对象,也并不是非死不可的,要宣告一个对象死亡,至少经历两次标记过程:
- 第一次是可达性分析标记的对象,标记后还要进行筛选,筛选的条件是此对象有必要执行finalize方法(重写了该方法并没有被虚拟机调用过)
- 有必要执行finalize的对象将会被放入F-Queue中,GC稍后会对该队列中的对象进行第二次标记,如果仍未可达,对象将会被回收
需要注意的是:
- finalize方法不会被承诺执行并等待其结束,因为该方法可能执行比较缓慢,并且可能会出现死循环。
- 任何一个对象的finalize方法都只会被系统自动调用一次,如果对象第一次标记后在finalize中逃脱了,下一次回收时,它的finalize方法不会被执行。
- 尽量不要依赖finalize方法,因为它的不确定性大,且无法保证各个对象的调用顺序
回收方法区
方法区垃圾回收的性价比很低,在堆中,尤其是新生代中,一次垃圾回收一般可以回收70%-95%,而永久代的垃圾回收率远低于此。
永久带的垃圾回收主要回收两部分:废弃常量和无用的类。废弃常量的判断比较简单,就是没有指向该常量的引用,对于无用的类来说,需要满足三个条件:
- 该类的所有实例都已经被回收,也就是Java堆中不存在该类的任何实现
- 加载该类的ClassLoader已经被回收
- 该类对应的Class对象没有任何地方被引用,无法在任何地方通过反射方位该类的方法
虚拟机可以对满足上述3个条件的无用类进行回收,这里说的仅仅是“可以”,而并不是和对象一样,不使用了就必然回收。HotSpot对是否进行回收提供了参数进行控制。
垃圾收集算法
标记-清除算法
首先标记需要回收的对象,然后进行回收,它的缺点:
- 标记和清除的效率都不高
- 清除后会产生大量不连续的内存碎片,空间碎片太多可能导致之后再分配较大对象时,无法找到最后的连续内存而不得不提前出发另一次垃圾回收动作
复制算法
复制算法会分配两个内存块,当GC后,仍存活的对象复制到另一个内存块,然后把已用过的内存块清理掉,这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等情况。同时由于新生代的对象大部分是要被GC的,因此不需要1:1的比例划分两个内存空间,而是将内存划分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survior,且HotSpot的两者的内存容量之比为8:1。
需要注意的是:
如果另外一块Survivor空间没有足够的空间存放上一次新生代收集下来的存活的对象时,这些对象将通过分配担保机制进入老年代。
标记-整理算法
根据老年代的特点,有人提出了另一种标记-整理算法那,标记过程和上面的一样,但后续步骤不是直接对可回收u对象进行整理,而是让存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
HotSpot的算法实现
枚举根节点
可作为GC Root的对象主要在全局性的引用和执行上下文中。可达性分析或者说枚举根节点是对时间敏感的,主要体现在下面两个方面:
- 现在应用近方法区就有几百兆内存,因此要逐个检查这里面的引用会消耗很多时间
- 可达性分析或者说枚举根节点时,需要确保快照是一致性的,也就是在整个分析期间整个执行系统看起来是被冻结起来的,不可以出现分析过程中对象引用关系还在不断的变化情况。这导致GC时必须停顿所有的Java执行线程。
当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局引用位置,这是通过一个叫OopMap的数据结构实现的,它保存了引用的位置(把栈上代表引用的位置全部记录下来,从而实现准确式GC)。
安全点
在OopMap的帮助下,虚拟机可以快速且准确的完成GC Roots枚举,但一个很现实的问题:如果未每一个指令都生成对应的OopMap,那将会需要大量的额外空间。实际上HotSpot并没有为每条指令都生成OopMap,只是在特定的位置记录了这些信息,这些位置成为安全点。即程序执行时并非在所有的地方都停顿开始GC,即程序执行时并非在所有地方都能停顿下来开始GC,只有在达到安全点时才能暂停。安全点的选定不能太少(GC等待时间长),也不能太多(增大运行时负荷)。
对于安全点,另一个需要考虑的问题是如何在GC发生时让所有线程都跑到最近的安全点上,有两种方案可以选:
- 抢先式中断:它不需要线程的执行代码主动去配合,在GC发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。
- 主动式中断:线程在安全点轮询,发现当前中断标志位为真时就进行中断挂起。
安全区域
在实际情况,线程可能会Sleep或者Bolcked,这时候线程就无法响应JVM的中断请求,这种情况就需要安全区域来解决 :
安全区域是指在一段代码中,引用关系不会发生变化,在这个区域中的任意地方开始GC都是安全的。我们也可以把安全区域看成是安全点的扩展。
当线程执行到安全区域时,首先标识自己进入了安全区域。那样,在当前这段时间发生GC,就不用管标识自己为安全区域状态的线程了。
在线程要离开安全区域时,它要检查系统是否已经完成了根节点枚举,如果完成了,那线程就继续执行,否则它就必须等待知道收到可以安全离开安全区域的信号为止。
垃圾收集器
收集算法是内存回收的方法论,而垃圾收集器是内存回收的具体实现。虚拟机包含的所有收集器如图所示:
新生代:Serial ParNew Parallel Scavenge
————————————————G1————
老年代:CMS Serial Old Parallel Old
Serial收集器
重点:
- 历史悠久的收集器,采用复制算法的新生代收集器
- 完全单线程,收集时会停止到其他的线程(“Stop The World”)
- 注意:之后发展的收集器也不能完全消除暂停线程,只能不断缩短暂停的时间
- 它是虚拟机在运行在Client模式下的默认新生代收集器
ParNew 收集器
重点:
- Serial收集器的多线程版本
- 除Serial外,只有他能够CMS收集器配合(不幸的是,JDK1.5提出的CMS作为老年代的收集器,却无法与JDK1.4中已经存在的Parallel Scavenge配合工作)
- 在单核环境下,性能不会超过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的时间
G1也是能分代收集的,虽然它能管理整个堆。它能够采用不同的方式处理新生代和老年代对象
G1从整理上看是标记整理算法,从局部上看是复制算法
能够预测停顿
G1将内存划分为多个Region,新生代和老生代不在是物理隔离,按照Region回收价值最大的先回收策略
需要处理的问题:
多个Region会互相关联的引用,怎么来避免全部扫描堆内存:采用Remembered Set来避免
理解GC日志
注意点:
- 会显示Full GC(会StopTheWorld)还是Minor GC
- 会显示GC发生的区域、时间、GC前和后的内存
内存分配与回收策略
对象的内存分配往大方向讲,就是在堆上分配。对象主要分配在新生代的Eden区上,如果启动了本地线程分配缓存,则优先在tlab上分配,少数情况下也可能直接分配在老年代中。
对象优先在Eden分配
- 大多数情况,对象的新生代在Eden区中分配
- 当Eden区不足时,会发起Minor GC
- 当Survivor不足时会分配到老年代中(分配担保机制)
大对象直接进入老年代
- 大且短命的大对象对虚拟机的内存分配来说就是一个坏消息
- -XX:PretenureSizeThreshold参数可以令对象大小大于该值的对象直接分配在老年代中
长期存活的对象将进入老年代
- 虚拟机给每个对象定义了一个对象年龄计数器,对象在Survivor中每熬过一次Minor GC,年龄都会增加一岁
- 年龄增加到一定的程度,就会晋升到老年代中,这个程度也可以通过参数设置
对象动态年龄的判断
- 虚拟机并不是永远要求对象的年龄必须达到某个程度才会晋升老年代,如果在Survior空间中相同的年龄所有对象的大小总和大于Survivor空间的一半,年龄大于或者等于该年龄的对象就直接进入老年代,无需等到某个岁数
空间担保分配
- 在Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的
- 如果不成立,则虚拟机会查看是否允许担保失败
- 如果允许,虚拟机会检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于会尝试着进行一次Minor GC
- 如果小于或者不允许冒险,那么这时也要改为进行一次Full GC
- 担保失败也会触发Full GC
虚拟机性能监控与故障处理工具
JDK命令行工具
主要有:
- jps:虚拟机进程状况工具
- jstat:虚拟机统计信息工具:类装载、内存、垃圾收集、JIT编译等数据
- jinfo:Java配置信息工具:实时地查看和调整虚拟机各项参数
- jmap:Java内存映射工具:用于生成堆转储快照
- jhat:虚拟机堆转储快照分析工具
- jstack:Java堆栈跟踪工具:生成线程快照
- HSDIS:JIT生成代码反汇编
JDK的可视化工具
JConsole和VisualVM
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停顿
类文件结构
Class类文件的结构
- 任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
- Class文件是一组8位字节为基础单位的二进制流,它只包含两种类型:无符号数和表,无符号数u1,u2,u3,u4分表表示1,2,3,4个字节
魔数与Class文件的版本
每个Class文件的头4个字节成为魔数,确定该Class文件是否能够被虚拟机接受
魔数后面的4个字节是Class文件的版本号,虚拟机会校验是否是JDK支持的版本
4个字节魔数->4个字节版本号->
常量池
常量池是Class文件之中的资源仓库,它是Class文件结构中与其他项目关联最多的数据类型,也是Class文件空间最大的数据项目之一,同时它还是在Class文件中第一个出现的表类型数据项目
开头是两个字节是常量池数量。常量池主要存放两个类常量:字面量(文本字符串、声明为final的常量值等)和符号引用。符号引用则属于编译方面的概念,包括:
类和接口的全限定名
字段的名称和描述符
方法的名称和描述符
常量池的每一项常量都是一个表,常量之间可以互相引用,也就是常量表之间可以关联。
每个表开始的第一位是一个u1类型的标志位,表示是14张常量表中的哪一个
CONSTANT_Utf8_info类型的常量一般存储类的限定名,因此很多常量都是引用该类型的常量
4个字节魔数->4个字节版本号->连续出现的常量表
访问标志
在常量池之后,紧接着的两个字节代表访问标志,识别类或接口层次的访问信息,包括:这个Class是类还是接口;是否定义为public类型;是否为abstract类型;是否为final等
4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志
类索引、父类索引、接口索引集合
类索引和父类索引都是一个u2类型的数据,而接口是一组u2类型的数据的集合
4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志->类索引->父类索引->接口索引
字段的集合
字段表用于描述接口或者类中声明的变量,字段包括类级变量以及实例级变量,但不包括在方法内部声明的局部变量
字段表由access_flags、name_index、descriptor_index、attributes_count、attributes组成
字段表不会列出从超类或者父接口中继承而来的字段,编译器可能会自定添加字段,比如在内部类中为了保持对外部类的访问性,最自动添加指向外部类的实例的字段
4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志->类索引->父类索引->接口索引->字段表集合
方法表集合
和字段表类似,不同的是方法中的代码,经过编译器编译成字节码指令后,存放在方法属性表集合中的一个名为“Code”的属性里面
与字段表集合相对应的,如果父类方法在子类中没有重写,方法表集合中就不会出现来自父类的方法信息。同样的,有可能也会出现编译器自动添加的方法,最典型的便是类构造器和实例构造器方法
4个字节魔数->4个字节版本号->连续出现的常量表->类访问标志->类索引->父类索引->接口索引->字段表集合->方法表集合
属性表集合
在Class文件、字段表、方法表都可以携带自己的属性表集合,以用于描述某些场景专有的信息。属性值长度不一,数据自定义结构,只要指出占用多少字节就可以了
Java虚拟机执行字节码是基于栈的体系结构
Code属性:
Java程序方法体中的代码经过Javac编译器处理后,最终变为字节码指令存储在Code属性内,注意接口或者抽象类的方法不存在Code属性
字节码之后的是这个方法的显示异常处理表,异常表对于Code属性来说并不是必须存在的
编译器使用的异常表而不是简单的跳转命令来实现Java异常及finally处理机制,编译器会自动在每段可能的分支路径之后都将finally语句块的内容冗余生成一遍实现finally语义
Exceptions属性:
与Code属性平级的一项属性,Exceptions属性的作用是列举出方法中可能抛出的受检查异常,也就是方法描述时在throws关键字后面列举的异常
LineNumberTable属性
用于描述Java源码行号与字节码行号之间的对应关系,并不是运行时必须的属性
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天生可以动态扩展
类加载的时机
- 类的声明周期:加载-{(连接)验证-准备-解析}-初始化-使用-卸载
- 解析在某些时候可能会出现在初始化解读之后,比如运行时绑定
- 虚拟机规范严格规定了有且只有5种情况必须立即对类进行初始化(如果类还没有初始化):
- 遇到new getstatic putstatic或invokestatic者4条字节码指令时,如果类没有初始化则进行初始化
- 使用reflect包的方法对类进行反射调用的时候
- 当初始化一个类的时候,其父类还没有进行过初始化,则需要先触发其父类的初始化,注意的是,一个接口在初始化时,并不要求其父类接口全部都完成了初始化
- 当虚拟机启动时,用户需要制定一个执行的主类,虚拟机会先初始化这个主类
- 当使用JDK1.7的动态语言支持时,如果一个MethodHandle实例最后的解析结果是REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行初始化时,则需要先进行初始化
- 上面的5种成为对类的一个主动引用,除此之外,所有引用类的方式都不会触发初始化,也称为被动引用:
- 通过子类引用父类的静态字段,不会导致子类初始化
- 通过数组定义来引用类,不会触发此类的初始化,数组在虚拟机中其实是虚拟机自己创造的一个类
- 常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化
类加载的过程
加载
加载需要完成三件事:
通过一个类的全限定名获取此类的二进制字节流
将这个字节流代表的静态存储结构转化为方法区的运行时数据结构
在内存生成该类的Class对象,作为方法区这个类的各种数据的访问入口
相对于其他阶段,一个非数组类的加载阶段是开发人员可控性最强的,我们可以自己定义类加载器来完成加载行为
对于数组类,它的加载过程:
- 如果数组的组件类型是引用类型,那就递归采用上面提到的加载过程加载这个组件类型
- 如果不是引用类型,会把数组标记为与引导类加载器关联
- 数组类的可见性与它的组件类型的可见性一致
加载阶段完成后,虚拟机外部的二进制字节流就按照虚拟机所需要的格式存储在方法区中,方法区中的数据存储格式虚拟机自行定义,然后在内存中实例化一个Class类的对象
验证
- 验证的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求
- 验证包括:文件格式验证、元数据验证、字节码验证、符号引用验证
准备
- 准备阶段说是正式为类变量分配内存并设置变量初始值阶段,这些变量所使用的内存都将在方法区中进行分配,注意这时候分配的是类变量不是实例变量,实例变量会分配在java堆中,另外这里所说的初始值是指对应类型的零值
- 如果类字段的字段属性表中存在ConstantValue属性,那在准备阶段变量value就会被初始化为ConstantValue属性所指定的值
解析
- 解析阶段是虚拟机将常量池的符号引用替换为直接引用的过程
- 符号引用是以一组符号来描述所引用的目标,直接引用则是直接指向目标的指针
- 解析包括:类或接口的解析,字段 解析,类方法解析,接口方法解析
初始化
- 初始化是加载过程的最后一步,初始化是执行clinit方法的过程,ciinit方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的,编译器收集的顺序和类中出现的顺序一致,因此静态语句块中只能访问到定义在静态语句块之前的变量
- 虚拟机保证在子类的clinit方法执行之前,父类的clinit方法已经执行完毕,因此父类中定义的静态语句块要优先于子类的变量赋值操作
- clinit方法不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不生成该方法
- 虚拟机会保证一个类的clinit方法在多线程环境中被正确的加锁、同步
类加载器
“通过一个类的全限定名称来获取描述此类的二进制字节流”这个动作成为类加载
类与类加载器
一个类是由它的类加载器和这个类本身一起确立其在Java虚拟机中的唯一性。两个相同限定名的类,经过不同的类加载器加载也是代表两个不同的类,而且Class的equals,isAssignableFrom,isInstance方法返回的结果也会不一致
双亲委派模型
从虚拟机的角度来说,有两种不同的类加载器:一种是启动类加载器(Bootstrap Classloader),是C++实现的,它是虚拟机的一部分;另一个部分就是所有其他的类加载器,是由Java实现的,且用户可以自定义
从开发人员的角度可以分为三种类加载器:
启动类加载器:负责加载JAVA_HOME/lib目录中的被虚拟机识别的类,无法被Java直接引用,用户在编写自定义的类加载器的时候,如果需要把类加载请求委托给引导类加载器,直接给加载器赋值为null就行
扩展列加载器:负责加载JAVA_HOME/lib/ext目录中的类
应用程序加载器:由AppClassLoader实现,一般称为系统类加载器,负载加用户的ClassPath上说指定的类,开发者可以直接使用这个类加载器,也是默认使用的类加载器
优先级:启动类加载器->扩展类加载器->应用程序类加载器->自定义类加载器
类加载器的双亲委派模型:
要求除了顶层启动类加载器外,其余的类加载器都应当有自己的父类加载器
过程: 如果一个类加载器收到了类加载的请求,它首先不会尝试加载这个类,而是把请求往上传递,只有当父加载器反馈无法加载的时候,子加载器才会尝试加载
好处:加载器有优先级关系,对于那些公用的类来说,都可以委托优先级高的类统一加载
破坏双亲委派模型:
第一次: 由于JDK1.2之后才引入的双亲委派模式,因此为了前向兼容,允许用户自定义loadClass的代码,从而可以使用自定的加载类加载代码。JDK1.2之后,建议通过findClass来定义自己的类加载器
第二次:JNDI,JDBC等需要调用独立厂商实现并部署在应用程序的ClassPath下的JNDI接口提供者(SPI)的代码,因此需要委托子加载器加载代码,可以通过Thread类的setContextClassLoader()来设置加载器,默认是应用程序类加载器
第三次:像OSGi的热代码替换技术重新构建了自己的类加载逻辑,没有采用双亲委派模式,而是引入了Bundle的概念,Bundle类似于模块的概念,当更换一个Bundle的时候,就把Bundle连通类加载器一起更换
虚拟机字节码执行引擎
概述
执行引擎是Java虚拟机的最核心组成部分之一,在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行和编译执行
运行时栈帧结构
栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机的栈元素,每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。
栈帧中的组成:
局部变量表、操作数栈、动态链接、返回地址等信息
在编译的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并写入了方法表的Code属性中,因此一个栈帧需要分配多少内存,是编译时确定的
在活动的线程中,只有位于栈顶的栈帧才是有效的,称为当前栈,这个栈帧关联的方法称为当前方法。运行引擎运行的所有字节码指令都只针对当前栈帧进行操作。
局部变量表
- 局部表量表用来存储方法参数和方法内定义的局部变量,在编译时,Code属性中的max_local就定义了其最大容量。
- 局部变量表的基本存储单位是Slot,Slot的长度和虚拟机相关,但是要满足存储一些基本的数据类型(像int这种32位的数据类型)。对于64位的基本数据类型,虚拟机会以高位对齐的方式分配两个连续地Slot空间
- 虚拟机通过索引定位的方式使用局部变量表,索引值得范围从0开始至局部表量表最大的Slot数量
- 在方法执行的时候,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程,如果执行的是实例方法,局部变量表的第0位索引默认是this变量
- 局部变量表的Slot是可以重用的,当超出变量作用域,且后面又有新的变量出现就会重用之前变量的Slot
- 需要注意的是,局部变量不像之前介绍的类变量一样存在准备阶段,类变量会经过两次初始化过程,一次是在准备阶段,赋予系统初始值,另一次是在初始化阶段,赋予程序定义的初始值。但是局部变量没有这些,没有赋值的局部变量是无法引用的
操作数栈
- 操作数栈的最大深度也是编译时确定好的,存于Code属性表中
- 操作数栈的每一个元素可以是任意的Java数据类型,包括long和double
- 当一个方法开始执行的时候,操作数栈是空的,在方法执行的过程中,不断的会有入栈和出栈的操作
- 操作数栈的数据类型必须和当前要执行的指令类型严格匹配,不然会报错
- 栈帧中,为了减少额外的参数赋值传递,为让不同栈帧的局部变量表共享区域和操作数栈共享区域重叠
- Java虚拟机是基于栈的执行引擎,其中栈就是指操作数
动态链接
- 每个栈帧都持有一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态链接
- 符号引用一部分会在类加载阶段直接化为直接引用,这称为静态解析,另一部分会在运行时进行动态解析
方法返回地址
有两种方式退出方法:
第一种:遇到了返回的字节码指令,正常退出时,调用者的PC计数器的值可以作为返回地址
第二种:遇到了异常且异常表中没有匹配的异常处理器,就会导致方法退出。异常退出时,返回地址是要通过异常处理器表来确定
附加信息
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分完全取决于具体的虚拟机实现
方法调用
方法的调用只是确定调用方法的版本,不涉及方法内部的运行过程。Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。因此Java需要在类加载阶段甚至是运行时才能决定所调用目标方法的直接引用
解析
如果方法在真正运行之前就可以确定调用的版本,并且在运行时是不可变的,则在类加载的解析阶段就会转转换为直接引用,采用这种方式的方法一般是静态方法和私有方法,因为没法重写和改变
虚拟机调用方法有5中指令:
invokestatic:调用静态方法
inivokespecial:调用实例构造器方法,私有方法和父类方法
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法
invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后在执行该方法
前面的4种指令的方法分派逻辑是固化在虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的
invokestatic和invokespecial都可以在解析阶段确定,除此之外的方法都是虚方法。一个例外是虽然final方法也是通过invokevirtual调用的,但是它也是非虚方法
分派
静态分派:实际上就是方法的重载,此时方法是依赖静态类型来判断和执行方法的
动态分派:动态分派实际指的就是多态性,也就是方法的重写 ,动态分派会调用invokevirtual指令,其解析过程如下:
找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C
如果在类型C中找到与常量中的描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回直接引用,不如不通过则返回异常
如果没找到,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程
如果没有找到合适的方法则抛出异常
单分派和多分派
方法接收者和方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划为单分派和多分派。
Java语言是一门静态多分派,动态单分派的语言
虚拟机动态分派的实现:
动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索,常用的优化方法有:虚方法表(如果子类重写了某个方法,子类方法表中的地址将会替换为指向子类实现版本入口地址)。
动态语言的支持
JDK1.7新增了invokedynamic指令和invoke包来支撑动态语言。动态语言的一个特征是:变量无类型而变量值才有类型。
invoke包的使用案例:
public class MethodHandleTest {
static class ClassA{
public void println(String s) {
System.out.println(s);
}
}
public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis() % 2 == 0 ? System.out : new ClassA();
getPrintlnMH(obj).invokeExact("hi");
}
//MethodHandle可以动态确定方法
private static MethodHandle getPrintlnMH(Object obj) throws NoSuchMethodException, IllegalAccessException {
//MethodType代表方法类型,包括方法的返回值和参数
MethodType methodType = MethodType.methodType(void.class, String.class);
//bindTo用来绑定java方法的第一个隐式this参数
return MethodHandles.lookup().findVirtual(obj.getClass(), "println", methodType).bindTo(obj);
}
}
MethodHandle和Reflection的却别:
反射是在java代码层次模拟方法的调用,而MethodHandle是在字节码层面模拟方法的调用
反射是重量级的,而MethodHandle是轻量级的
MethodHandle可以享有调用类似字节码指令时的虚拟机优化,同时它可以不进针对java语言
invokedynamic指令:
其分派逻辑不是由虚拟机决定的,而是由程序决定
引入了CONSTANT_InvokeDynamic_info常量,它包含引导方法,MethodType和名称信息
使用invoke包实现子类调用组类的方法:
public class TestInvoke {
class GrandFather{
void thinking() {
System.out.println("i am grandfather");
}
}
class Father extends GrandFather {
void thinking() {
System.out.println("i am father");
}
}
class Son extends Father {
@Override
void thinking() {
MethodType mt = MethodType.methodType(void.class);
try {
MethodHandle special = MethodHandles.lookup().findSpecial(GrandFather.class, "thinking", mt, getClass());
special.invoke(this);
} catch (Throwable throwable) {
throwable.printStackTrace();
}
}
}
public static void main(String[] args) {
(new TestInvoke().new Son()).thinking();
}
}
基于栈的字节码解释执行引擎
- Java编译器输出的指令流,基本上是一种基于栈的指令集架构,与此相对的是基于寄存器的指令结构
- 基于栈的指令结构是可有移植,因为寄存器是和硬件强相关的
- 基于栈架构指令的主要缺点是执行速度相对较慢一点
类加载及执行子系统的案例与实战
概述
对于JVM的编译运行,用户能通过程序操作的主要是字节码生成与类加载器
案例分析
Tomcat:正统的类加载架构
tomcat的要求:
- 不同的web应用程序使用的类库可以实现相互隔离
- 不同的web应用程序使用的类库也可以互相共享
- 服务器要尽可能保证自身的安全不受部署的web应用程序影响
- 可能要支持HotSwap功能
因此,一个classpath在web服务器是满足不了要求的。Tomcat自定义了以下的类加载器:
Common类加载器:它包含两个子类加载器:
Catalina类加载器
Shared类加载器–>WebApp类加载器–>Jsp类加载器
Tomcat使用了正统的双亲委派模式
OSGI:灵活的类加载架构
OSGI的每个模块成为Bundle,与普通的类库区别不大。但是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和内存速度的差距,现代计算机系统都会使用高速缓存,这就会导致缓存不一致的问题,因此多个处理器都涉及同一块主内存时,读写时要根据相关的协议操作
Java内存模型
- Java试图定义一种内存模型来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各个平台 下都能达到一致的内存访问效果
主内存和工作内存
- Java内存模型规定了所有的变量(线程共享的变量)都存在主内存中,每个线程还有自己的工作内存。线程的工作内存中保存了该线程使用到的变量的主内存的拷贝,线程对变量的所有操作都是在工作内存中进行的,不同的线程之间不会互访工作内存。工作内存和主内存通过save和load指令交互
- 主内存实际对应着java堆中的数据,而工作内存实际对应着虚拟机栈中的部分区域
内存交互操作
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关键字也支持
有序性:如果本线程内观察,所有的操作都是有序的,如果在一个线程中观察另一个线程,所有的操作都是无序的
线性并发原则
- 也就是happens-before原则,它是判断数据是否竞争、线程是否安全的主要依据。它定义了Java内存模型中定义的两项操作之间的偏序关系,这是无需任何同步手段就能成立的
- 先行发生和时间上的先后没有关系
Java与线程
线程的实现
实现线程主要有3中方式:使用内核实现、使用有用户线程实现和使用用户线程加轻量级机进程混合实现
使用内核:有内核来完成线程切换和线程调度,程序一般不会直接去使用内核线程,而是去使用内核线程的高级接口——轻量级进程
使用用户线程:效率很低
混合实现
Java线程调度
系统调度主要有两种方式:协同式线程调度和抢占式线程调度,Java采用后者
状态转换
Java定义了5中线程状态
线程安全与锁优化
线程安全
Java语言中的线程安全
- 不可变:一定是线程安全的
- 绝对线程安全:不需要任何同步就能达到线程安全
- 相对线程安全:需要额外的保障
- 线程兼容:不安全但使用得当也没问题
- 线程队里:肯定会死锁
线程安全的实现方法:
- 互斥同步:加锁
- 非阻塞同步:CAS
- 无同步方案:有一些代码天生是线程安全的
锁优化
- 自旋锁和自适应锁
- 锁消除
- 锁粗化
- 轻量级锁
- 偏向锁