JVM原理学习
JDK组成部分
JDK基本组件
(1)Javac:Java代码编译器,用于将Java源代码编译成相应的Class文件。
(2) Jar:Java代码的打包组件,通过该工具,可以将多个Java源码文件编译后生成以.jar结尾的库文件。
(3) JavadocAPl应用程序接口文档生成组件,可以将Java源代码中的Javadoc注释提取后,转换为用户较为方便查着的html格式的文档.
(4)Jdb:Java调试器(debugger)组件,功能代码调试及查错的工具组件。
(5)Java:Java程序解释器,直接根据Class字节码执行Java程序。
(6) Appletviewer:在B/S架构模式兴起之前的一种可执行的浏览器模式的应用程序(Java Applet)。
(7) Javah:主要用于调用Java的本地方法,其本质是生成相关C语言的头文件(.h后缀名),通过它可以调用底层的C语言代码。
(8)Javap:Java反汇编工具,反编译Class字节码文件,可展示方法和类结构信息。
9)Jconsole:基于JVMTI的技术,是可以针对其他JVM进程进行性能和执行参数分析的工具。
JDK主要物理组成
jre包:主要包含VM运行环境的所有所需运行文件和资源库,分别存储在bin包和lib包中。
lib包:其直接从属于JDK内部,存放JavaSE核心标准库及扩展库文件等。
bin包:其存储VM运行环境的所有所需运行文件,除此之外还包含了JRE的bin包中没有的Java、Javac等相关的开发Java程序的工具组件.
JVM核心体系
主要包括
1、类加载子系统、
2、运行堆内存区域(Runtime Heap Area)、
3、方法区(Method Area)、
4、虚拟机栈(包含PC计数器):'虚拟机栈随着线程的建立而建立,在线程销毁时也被销毁。虚拟机栈由栈帧组成,以栈帧为单元执行任务并存储工作状态。虚拟机中对栈帧的操作方式主要为进栈与出栈。当前线程执行栈帧作为栈顶的栈帧。一般在调用方法时进行入栈,当抛出异常或者返回(retumn)时进行出栈。虚拟机栈会存在StackOverflow和ErrorOutOfMemoryError。'、
5、本地方法栈(Native Stack)、
6、内存管理子系统(Garbage Colector,垃圾回收器)和
7、执行引擎子系统。
这7个子系统相互协作,实现了JVM的核心功能。
代码基本遵循二八定律,即80%的热代码不会耗费虚拟机过多的计算资源,而剩下20%的热点代码要耗费虚拟机80%的计算资源。如果编译完所有的机
器码并保存在硬盘/内存上,则会占据相当大的空间,因此大部分代码根本不需要消耗很多资源。
对象的初始化是在创建对象时由JVM完成的,创建对象的方式其中常用方式有以下6种。
(1)使用new关键字。
(2)Class对象的newlnstance()方法。
(3)构造函数对象的newlnstance()方法。
(4)对象反序列化。
(5)Object象的clone()方法。
(6)使用Unsafe类创建对象。
JVM的OMM异常
1、运行时数据堆溢出的主要原因是堆的内存不足,无法进行扩展或者分配更多的内存空间给Java对象。这种场景较为常见,其报错信息为:
java.lang.OutOfMemoryError:Java heap space.
2、虚拟机栈和方法栈溢出若线程中请求的栈的最大深度已经超过了虚拟机可容忍的最大栈深度,则线程就会立刻抛出StackOverflowEror异常;如果虚拟机在扩展栈时无法申请到足够的内存空间,则抛出OutOfMemory-Error异常。
3、永久代及元空间内存溢出所产生的报错信息分别如 下: java.lang.OutofMemoryEror: PermGen space和java.lang.OutofMemoryError:Metaspace.
JVM的内存结构主要包括3部分:堆内存(Heap)、方法区(Method Area)和栈(Stack)。
类加载系统
整个加载过程,包括加载、验证、准备、解析、初始化。
1、加载阶段
(1)通过一个Class类的全限定名获取定义该类的二进制字节流。
(2)把这个由二进制字节流代表的静态内存结构,转换成虚拟机中需要的格式并保存在方法区中。
(3)在堆内存中生成一个代表该类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。
2、验证
验证阶段是链接阶段的第一个子阶段,该阶段用来确定Class文件的二进制字节流格式是否符合JVM的规范条件,并且能够确保虚拟机的安全性和完整
性。此外,加载阶段与验证阶段可能在时间层面上存在交叉执行的情况。
验证阶段分以下四个部分:文件格式验证、元数据验证、字节码验证、符号引用验证。
3、准备
分配阶段正式给每个静态变量分配内存存储空间,同时给静态变量设置默认初始值,这些所分配的内存都将在一个方法区中进行。
4、解析
在解析阶段的主要任务是从一个Class类的静态符号常量池中,将相应的静态符号引用(类、接口、字段和方法)转化成直接符号引用。这里主要涉及
Class字节码的格式,后续会详细介绍Class字节码的格式及元素属性。
5、初始化阶段
初始化阶段是运行Java应用程序的开端,该阶段是应用程序开始主导,应按编码逻辑开始初始化变量和其他资源。通俗地说,初始化过程实际上是执行
类构造方法的过程,但该类构造方法并非自行创建的实例构造器,而是由javac编译器自行产生的。
Class字节码
不同平台的虚拟机都有统一使用程序的存储格式,字节码(ByteCode)是构成平台无关性的基石,也是语言无关性的基础。JVM并不与包括Java源码有内的任何语言进行直接绑定,其只与Class文件这种特定的二进制文件格式关联。Class文件中包含VM指令集和符号表及若干其他辅助信息。同时,Class文件结构也是IVM加载Class类及实例化对象进行方法调用的重要依据。Class文件是一组以8位字节为基础单位的二进制字节流,所有16位、32位和64位长度的数据将被构造成2个、4个和8个字节单位来描述。
典型的Class文件结构主要分为魔数头、版本号、常量池、访问标志、类元数据、接口元数据、字段元数据、方法元数据和属性元数据。
虚拟世界核心
堆(Heap)内存空间是一种区别于虚拟机栈存储空间、方法区存储内存及虚拟机堆外内存空间的另一种虚拟机存储使用区域。堆内存允许应用程序在任何运行时刻部可以动态申请特定容量大小的数据内存空间。堆内存的运行情况是左右应用程序性能的根本因素之一。堆内存的溢出问题是Java应用程序中非常普遍的问题,在开始处理溢出问题以前,应该先熟悉虚拟机堆内存是如何运作的。
随着Java程序的运行,新的对象会不断被创建,这些对象会存储在JVM内存中,如果没有一套机制来管理这些内存,那么被占用的内存会越来越多,可用内存会越来越少,直至内存被消耗完。JVM体系中最核心的组成部分之一是垃圾回收系统,其中大部分核心内存的分配和回收都是动态的,当JVM线程执行结束后,它会负责回收那些不再使用的内存空间。
Hotspot虚拟机系统中的的9类典型垃圾回收器: Serial、 ParNew、Parallel Scavenge、 Se-rial Old、 Parallel Old、CMS (Concurrent ManSweep)、G1(Garbage-First)、ZGC(Z Garbage Collector)、Shenandoah GC。
1、Serial
Serial是JVM中功能最基础、发展历程最漫长的新生代垃圾回收器。
工作特点:单线程执行,避免上下文切换(与其他回收器相比)对于单个CPU环境来说,Serial回收器由于没有线程交互的开销,单纯做垃圾回收处理(使用一个CPU或一条线程完成垃圾回收工作),因此可以获得非常高的单线程回收效率。
回收算法:利用标记-复制算法,“快刀斩乱麻”。在早期,机器大多是单核的,也比较实用,但在垃圾回收过程中总会STW(Stop The World),而且当其完成垃圾回收之后,就必须停止所有的工作线程,直到Serial回收操作完毕为止。
2、ParNew
ParNew垃圾回收器也是一种新生代的回收器,其实际上就是新生代Serial回收器的多线程版。
工作特点:多线程可以同时执行,并能够充分发挥计算机中对于多处理器或多逻辑内核的计算处理能力,还可以同时自动进行多线程的对象垃圾回收处理工作,因此执行效率会大幅度增强。ParNew垃圾回收器中默认自动开启的垃圾回收线程数量和CPU的数量一致,开发者也可以直接通过”XX:ParallelGCThreads” 参数设置垃圾回收的线程数量。
回收算法:同新生代Serial回收器一样,也采用标记-复制算法
3、Parallel Scavenge
Parallel Scavenge垃圾回收器与吞吐量(Throughput)之间关系密切,故也可以称为以吐量为中心的垃圾回收器。
工作原理:它是一种采用并行复制模式算法的垃圾回收器,也是采用并行技术的多线程垃圾回收器,和ParNew等回收器非常相似。Parallel Scavenge的主要设计目的是实现一个可以近乎完全控制的系统吞吐量方案。我们首先分析一下系统服务的吞吐量计算公式:系统吞吐量三系统程序运行时间/(系统程序运行时间+系统垃圾回收时间)x100%。例如,程序运行了99s,垃圾回收消耗的实际时间是1s,那么吞吐量为99/(99+1)x100%=99%
4、Serial Old
Serial Old回收器是Serial回收器的老年代版本,它同样是一个单线程收集器。
工作特点:存在STW的停顿问题,采用标记-整理算法,它与Serial回收器一样是单线程回收器。
与新生代Serial回收器不同,Serial Old回收器采用标记-整理算法实现垃圾对象的回收处理,这主要是因为老年代的对象通常比较多,并且占用的空间也会更大,如果采用复制算法,留出50%的空间用于复制,会相当不划算,而且因为对象多,从其中一个区复制到另一个区消耗的时间也会更长,所以老年代的回收通常会采用标记-整理算法。
5、Parallel Old
Parallel Old垃圾回收器是Parallel Scavenge并行回收器的老年代进化版,也可以说它是Serial Old的多线程版本。
工作特点:多线程机制进行垃圾回收工作,采用标记-整理算法。
使用方式:选择"-XX:+UseParalelOldGC”参数,开启该回收器之后,会自动采用Paralel Scavenge+Parallel Old的组合体进行回收操作。如果不需要用新生代的Parallel Scavenge回收器,可以采用VM参数"-XX:-UseParallelGC”将其关闭。
6、CMS (Concurrent ManSweep)
CMS回收器是一种以获取“最短回收停顿时间”为目标的运行在老年代的垃圾回收器。它是JDK1.5版本后的第一个真正意义上的并发回收器,同时也第一个实现了让垃圾回收线程和应用线程同时并发工作。CMS也会产生STW问题,但是其产生的STW问题的时间相较上面的垃圾回收器会相应地减少。
工作特点:采用标记-清除算法且多线程运行,采用并发回收方式,从而实现低停顿。
应用场景:主要应用于注重响应速度和交互时间的服务,并且希望系统停顿时间最短,能为使用者提供良好的服务体验,如Web应用程序、B/S架构等应用服务
7、G1(Garbage-First)
G1回收器是专门为大型服务端应用定制的垃圾回收器之一,于2012年在JDK1.7-u4中推出,而且Oracle官方已于JDK 9中将G1作为默认垃圾回收器并取
代了CMS回收器。尽管空间分代的概念在G1中仍然得以保留,但其已经完全打破了之前内存分割的回收器空间模型,并把整个Java堆空间分割成为多个规模相同的空间区域(Region)。虽然仍保留了新生代和老年代这两个概念,但整个新生代和整个老年代不再看成是两个物理空间相隔离的区域了,而变成了许多个地址不一定连续的空间集合体。
8、ZGC(Z Garbage Collector)
ZGC是JDK 11版本中由Oracle开发的一个全新且可伸缩的低延迟垃圾回收器,其可以并发地执行所有需要执行的线程,包括应用程序线程和GC线程,而
几乎不需要出现STW。
ZGC主要应用于那些要求极低延时(例如少于10ms的暂停时间)或需要使用非常大的数据堆(TB数量级)的应用程序,用户可以通过"-XX:
+UseZGC” 参数进行启用。
9、Shenandoah GC
Shenandoah GC回收器是JDK12版本中新增的一个为更接近“低停顿”时间目标而设计的垃圾回收器。此外,它被称为停顿时间与堆空间大小无关的垃圾回收器,这就意味着无论堆空间是500MB、5GB还是500GB,其垃圾回收的停顿时间都是一样的。Shenandoah GC的垃圾回收阶段通常由两个STW阶段及两个并发阶段所构成,在初始化标记阶段,首先会扫猫GC Roots对象,并在此时进行STW.后续在并发标记阶段,Shenandoah GC与Java应用线程同时进行,此时无须进行STW阶段。最后标记阶段,再次执行STW,进行并发Evacuation阶段。
在整个Java内存区域中存在不同的内存块区域,并且它们都拥有不同的GC回收器及垃圾回收算法,其中垃圾回收方式根据不同的区域可以划分为Mino GC(新生代GC)、Major GC(老年代GC)和Mixed GC等。
1、Mino GC(新生代GC)
Minor GC指处于新生代的垃圾回收动作,由于大多数的Java对象都具有朝生夕灭的特点,使得新生代的Minor GC出现得相当频繁,但通常它的垃圾收速度亦相当的快。
JNM为每个新创建的对象定义了一个对象年龄属性(age),初始值是零。对象在Eden区中每经历过一个Minor GC后不能被处理掉,但能被Survivord所接纳,就会被移回到Survivor空间中,从而将对象年龄加1。
当其年龄增加到一定程度(默认为15)时,就会被晋升到老年代中。对象晋升老年代的年龄阈值可以通过参数".XX:MaxTenuringThreshold”进行设
置。
Eden和Survivor区都已经实现了标记和复制的整理操作,其完全取代了标记+整理和标记+清理操作。Eden和Survivor区不会产生内存碎片,写入的针始终驻留于可用内存池的顶部。执行MinorGC操作时,不会对方法区造成影响。从方法区到新生代的引用被直接认为是GC Roots,从新生代到方法区自引用则会在标记阶段中直接忽略。
Minor GC会触发STW,停止应用程序的线程。但对于大部分应用程序来说,停顿导致的延迟都是可以忽略不计的。
大部分在Eden区中的对象都可以被看作“垃圾对象”,并且永远也不会被复制到Survivor区或老年代:但如果Eden区中的大部分新生对象都不满足被收的要求,那么在执行MinorGC中所消耗的的时间也会比STW时间长,因为对象复制的成本特别高。
Minor GC触发时机:当Eden区的空间不足以存放对象时就会触发Minor GC,而Survivor区不足时不会引发Minor GC。
2、Major GC(老年代GC)
Major GC指发生在老年代的垃圾回收机制,其消耗的时间比Minor GC多很多。此外,人们还经常提Full GC,这里简单介绍这两种GC方式。Major GC:主要目的是方便清理存在老年代的对象,Major GC的执行时间通常比Minor GC的时间长10倍以上。其实,许多Major GC是由Minor GC引起的,所以在许多情形下把这两种垃圾回收机制分离是不太可能的。
发生在老年代的Major GC,通常会同时伴随着至少一次Minor GC。例如,使用Parallel Scav-enge回收器的收集策略里就有直接执行Major GC的策
略。
FullGC:针对整个新生代、老年代、方法区的全局范围的垃圾回收操作。由于Minor GC的时间较短,因此一般将Full GC和Major GC等同起来。
3、MixedGC
Mixed GC是G1回收器及之后新版本回收器所特有的一种回收处理方法,其和之前Major GC/Full GC的不同之处在于,Mixed GC只能回收部分老年代Region。
Old Regjion可以自动放到CSet (Collection Set)里,有很多参数可以控制。例如,G1Heap-WastePercent这个参数,当发生Minor GC后,允许内存中存在垃圾对象的百分比,如果达到这个值就会自动触发一次MixedGC。
G1MixedGCLiveThresholdPercent参数(JDK8及以后默认值为85%),它控制老年代Region中的存活对象百分比,Region中存活的对象低于该阈值时才会被回收,达到或超过该阈值的Region会被放入CSet。
通常在Mixed GC之前会进行一次Minor GC,而这么做的主要目的就是提高执行效率,因为Mixed GC将复用Minor GC后的GC Roots集合的扫描结果,因此这个"StopTheWorld”的过程还是必要的,但总体上来讲减少了暂停应用线程的时间。
Miked GC的回收过程通常可以被理解为发生在Minor GC后附加的全局并发标记,全局并发标记主要用于老年代Region(包含H区)中所有的对象。
方法区(Method Area)是除了Java Heap堆内存外的另一个线程之间共享的内存区域,其主要包含运行时常量池(Runtime Constant Pool)、常量池(String Constant Pool)、字符串常量池等相关数据和信息。随着VM技术的不断发展,字符串常量池和静态变量等数据也已经逐步转移到了堆中。
假如将JVM对等于计算机系统,那么对计算机系统而言,执行引擎就是直接构建于处理器(CPU)、寄存器(Register)、指令集及操作系统层面之上组件,所以执行引擎子系统是JVM中最接近底层且最为神秘的内核组件部分。但是JVM的执行引擎是由其自身实现的,它自己制定了指令集与执行引擎相关的架构体系,主要作用是将编译后的Class字节码翻译成为机器码,最后执行机器指令并输出结果。
不管哪一种处理方法,如Minor GC、Major GC(Full GC)、Mixed GC等,在实施回收处理之前,垃圾回收器的首要任务就是辨别出哪些对象仍然保
持存活状态,以及哪些对象需要被回收。下面介绍两种基础的回收算法:引用计数器算法和可达性分析算法。
引用计数器算法思路:为Java对象绑定一个引用计数器,如果还有另外一个Java对象对其进行引用,那它所对应的计数器就会加一;对其引用一旦被清除,计数器又会减一。因此,只要一个对象绑定的计数器为零时,该对象就处于被回收的状态。该算法在很多情况下都是一种不错的选择,但是在我们常用的JVM中并没有采用。
引用计数器算法的优点:实现简单,判断效率高。
引用计数器算法的缺点:很难解决对象之间循环引用的问题。
可达性分析算法思路:将通过一系列称为GCRoots的对象作为起始点,向下搜索,搜索走过的路径称为引用链,当一个对象到GCRoots没有使用任何引
用链时,则说明该对象是不可用的。主流商用程序语言都通过可达性分析算法判定对象是否存活。
可达性分析算法优点:计算对象间相互引用的存活条件会比较严谨和准确,同时能够解决循环数据结构之间相互引用的问题。
可达性分析算法缺点:实施比较复杂且常常要求线程分析大量引用数据,耗费大量计算时间;分析的过程中必须发生GC停顿(引用之间的关系不会发生变化),即暂停所有正在应用程序中运行的线程。
Java体系中的对象引用类型总体分为4种,分别是强引用(Strong Reference)、软引用(Soft Reference)、弱引用(Weak Reference)
虚引用(PhantomReference),根据引|用能力的强度依次减弱。
GC垃圾回收算法
标记-复制算法的基础是将整个堆内存空间划分为两个相等的区域,同一时刻只可以使用其中一个区域。在执行垃圾回收时,会遍历当前正在使用的内存区域(50%),把正在使用的对象复制到另外一个内存区域中。此算法每次只处理正在使用的对象,因此复制成本比较小,同时复制过去以后还能进行内存压缩和整理,所以不会出现内存碎片问题。但是,标记-复制算法有一个较为严重的缺点,即需要两倍内存空间,它会浪费50%的内存资源,属于用空间来换时间的方案。
1、标记-复制算法将正在使用的内存区域划分为3种类型:未使用内存块、可回收内存块和已使用内存块(不可回收)。首先通过可达性分析算法分析哪些内存块可以被回收,哪些内存块不可被回收。然后把已使用(不可回收)的对象复制到另外一块同样大小的内存块中,再把可回收的内存块进行回收,这样做的好处是不用考虑内存碎片;坏处就是内存变成两块之后,每次只能用50%的内存,此外如果存活的对象过多,复制的成本也会过高。
在开发实践中,新生代中的对象98%以上都是“朝生夕死”的,并不需要按照1比1来划分内存空间,一般是将内存空间划分为一个较大的Eden区和两个较小的Survivor区,每次只使用Eden区和其中一个Survivor区。当进行内存回收时,将Eden区和其中一块Survivor存活的对象复制到另一块Survivor区中,最后清理完Eden区和刚才使用的Survivor区。
2、标记-清除算法是所有JVM中最基本的回收算法,其大致包括两个阶段:首先进行标记,然后进行清除。标记-清除算法的弊端也相当明显,它会产生大量的内存碎片。
首先标记所有存活对象,在标记完成后统一回收不再存活的对象,此时可能会形成不连续的内存碎片。一旦程序中内存碎片过多,将会造成在接下来的程序运行时无法分配到一个相对较大的连续内存空间,从而不得不触发一个GC。
第一阶段:从引用GC Roots根节点开始标记所有被引用的对象,并且将内存块分为可回收状态、已使用状态和未使用状态。
第二阶段:遍历整个堆内存空间,GC回收器可能会将末标记的对象全部进行清理。此时该算法不仅需要暂停应用线程,同时还将生成大量的内存碎片。
3、标记-整理算法结合了标记-清除和标记-复制两个算法的优点,其也被划分成两个阶段。
第一阶段:从GCRoots根节点开始遍历并且标记所有被引用对象,此部分操作与标记-清除算法相同。
第二阶段:遍历整个堆内存空间,并清理未标记的堆对象,同时对所有已存活对象进行“压缩”(把存活对象都向另外一端进行移动,按照顺序排放用一个指针作为分隔点),之后会彻底清理该指针另外一侧的所有垃圾对象。本算法既有效避免了标记-清除算法的大量碎片存储问题,同时也有效避免了标记-复制算法产生的内存空间浪费问题。
4、分代回收算法,应用程序创建的大部分对象生成之后很快就会变成“垃圾对象”,只有较少一部分对象能够存活很长时间,根据对象能够存活的时间周期作为对象分配在新生代或老年代区的依据和条件,在不同的区域代中采用不同的回收算法,从而提高内存空间及时间层面的利用率。分代回收算法是对对象生命周期进行分析后得出的垃圾回收算法,把运行时数据区划分成新生代、老年代、方法区,对应不同生命周期的对象可以通过不同的分析算法进行处理。现在很多主流的垃圾回收器都会直接或者间接地采用此回收算法。
永远线程安全的区域
虚拟机栈
虚拟机栈负责方法的调用,可支持多个线程同时执行任务,但底层实现依赖于每个虚拟机线程栈内的栈帧(Stack Trame)和虚拟机线程的PC寄存器虚拟机栈与线程一一对应。
钱帧是用来存储程序执行过程中产生的临时数据和结果的数据模型,从逻辑角度来看,其是虚拟机栈的基本组成元素,就如同人类由无数个细胞构成一样。栈帧主要管理整个运作流程,就像运动轨迹上的每个动作节点,但有时又被用于直接管理动态链接(DynamicLinking)、方法返回值和异常分派(Dispatch Exception)。一个由局部变量表(Local Variable Table)、操作数栈(Operand Stack)、动态链接(Dynamic Linking)、返回地址(RetunAddress)等组成。每个堆栈方法在开始被调用到运行完成的整个过程中,都需要对应一个虚拟机栈的栈帧,从栈帧的入栈作为开始点并以出栈作为结束点。
1、局部变量表是一组带有值的调用变量的局部存储空间,用来存储调用方法的入参参数和方法内部的局部变量。当编译器成功完成编译后,生成对应的Class字节码,Class字节码中的code属性内部的max Iocals数据项就决定了该方法需要分配的局部变量的最大数据空间。局部变量表内部以变量槽(Variable Slot) 为基本单位,每个slot可以存放以下8种基本数据类型: boolean、byte、char、short、int、float,referencere、turnAddress。
2、操作数栈又称参数读写操作栈,属于后入先出的LIFO栈。操作数栈与局部变量表相同,但是每个操作数栈的最大操作深度可以是编译执行阶段的每个Code表的属性中的maX_stacks数据项,且可以为任意一种数据类型。32bi的数据类型占用栈容量1,而64bit的数据类型占用栈容量2,但是在执行方法的任
何时刻内,都不能超过max_stacks数据项所设置的阈值。操作数栈中的栈帧在刚被创建的时候是空的。虚拟机提供了一些字节码指令用于从局部变量表或者对象实例的字段中复制常量或变量值到操作数栈中;也提供了一些指令,用于从操作数栈取走数据、操作数据和把操作结果重新入栈。在方法调用时,操作数栈也用来准备调用方法的参数及接收方法返回结果。
3、动态链接,针对所有的栈帧均有一组与之对应且指向运行时常量池的动态引用,使用它与调用的方法之间建立一个关联关系,我们称为动态链接。在Clas文件里,描述某个方法调用了其他方法或者访问其他成员变量的值,我们称为符号引用(Symbolic Reference),而这种动态链接的主要功能就是将符号引用所表示的值转换为实际调用方法的直接引用地址。类在加载过程中将把未被解析的符号引用进行解析处理,从而把对引用变量的访问方式转换为访问这些引用变量所在的内存地址的偏移量,这就属于链接解析。
4、返回地址(Retum Address),当方法执行结束后并进行原路返回的过程中,有两种方式返回并退出的方法,一种方式是使用JVM中的执行引擎,当遇到基于方法原路返回的字节码指令,此时方法可能会存在有返回值或无返回值的形式(会根据不同的退出方法对应的返回指令决定的)并返回到调用它的方法处,这种叫作方法正常调用完成,此时返回地址值是PC计数器中的指令地址值;另外一种是遇到athrow字节码指令抛出异常且无法被捕获的场景,并且返回地址不会存储数据,因为依靠的是异常处理器表且不会有返回值。
PC(Program Counter)寄存器
JVM需要能够支持多个线程同时运行,因此任何虚拟机线程均必须拥有一个PC(Program Counter)寄存器。在任意时间内虚拟机线程只会执行一个方法的指令,这个正在被执行的方法称为该线程所对应的当前运行方法(Curent Method)。如果这个方法不是Native的,那PC寄存器就需要保存JVM中正在执行的字节码指令的地址,若此方法本身就是Native的,那么对于PC寄存器的值就是undefined。PC寄存器的容量至少能够存储一条returnAddress类型的数据或者一个与平台相关的指针地址的值。
Java语言有很多保留字及符号定义规范,在此不做赘述。JVM本身也有多种规范要求,但在JVM执行的初始化时,大家会发现一种特殊情况,针对类构
造器和实例构造器而言,执行的方法名称很特殊,分别是
式实现。(init)实例构造器通过执行invokespecial指令来调用,其只有在实例正在构造时,实例初始化方法才可以被调用访问。
JVM的异常主要由Throwable及其子类Exception和Eror机制构成,抛出异常的本质实际上就是对程序控制权的实时性、全局性的转移,使程序能够从
错误异常抛出的执行点直接跳转至执行处理过的错误异常区继续执行。
在虚拟机视角范围内的异常场景主要有两种:同步异常和异步异常。
1、虚拟机执行被侦测的程序,如出现非正常的程序运行状况,这时异常可能会紧接着在该字节码执行指令后直接抛出。
例如:类在加载或者链接时出现错误。字节码指令违反Java语言语义,会出现如数组越界或者流资源关闭状态后读取等。使用某些资源时产生资源限制,如使用了太多内存或者权限不够。
2、athrow字节码指令被执行,可以理解为手动抛出异常或者系统运行抛出异常。
3、导致异步异常的出现,有以下原因。调用Thread的stop或suspend方法。JVM实现的内部程序错误,此种情况对开发人员来说几乎不可控。
虚拟机的内部异常也被认为是一种异步异常,抛出属于VirtualMachineError 的子类的异常对象实例,有以下4类。
1、IntemnalError:由虚拟机自身实现的软件程序出错或硬件程序出错,两者均可能造成各种InternalEror异常的直接发生,它们几乎可以直接发生在应用程序运行中的任意一个地方。
2、OutOfMemoryError:如果一个VM消耗光了所有被分配的内存资源和物理资源,并且内存回收器子系统也可能无法自动回收足够的物理内存则虚拟机将主动抛出OutOfMemoryError异常。
3、StackOverflowError:当虚拟机程序耗尽了线程内部全部的栈空间,多数是因程序无限制地递归调用,导致超过创建栈帧的阈值,那么虚拟机将会抛出StackOverflowError异常。
4、UnknownError:在某个异常或者错误已经发生,而JVM无法准确判断其具体是哪种异常或错误原因的情况下,将会抛出UnknownError异常。
方法调用不代表执行,主要负责确定执行的方法及版本,暂时不涉及执行内部的具体内容。方法调用主要是通过静态链接解析(类加载阶段确定)和动
态链接解析(运行时阶段确定),进行解析计算最终的调用地址(可能在类加载阶段确定或者是运行时阶段确定),目前有5种调用字节码指令。
(1) invokevirtual指令:主要用于分析调用对象的实例方法,并按照调用对象的实际类型进行分派(虚方法分派)。其最常用的分派方式就是实例分派
方式(相当于C++中“虚方法”)。
(2) invokeinterface指令:主要用于调用接口中定义的方法,会在指令执行时自动查找某个已经实现过该接口的对象,并在其中找到合适的实现方法后进行方法调用。
(3)invokespecial指令:用于调用某些需要特殊处理的实例方法,包括对象的实例初始化方法(init)、私有型的方法及父类方法。
(4)invokestatic指令:用于调用静态方法(类方法)及static修饰的方法。
(5) invokedynamic指令:为了实现动态类型语言(Dynamicaly Typed Language)支持而进行的改进之一,解决了上面4条invoke指令方法分派规则固化在虚拟机之中的问题,把查找目标方法的决定权从虚拟机转移到具体用户代码之中,让用户(包含其他语言的设计者)有更高的自由度,Java 8的ambda及MethodHandle都是受益于此。
对方法的调用工作大致包括两部分:方法解析与方法分派(Dispatcher)。
(1)方法解析:由于在虚拟机中调用目标方法的Class字节码常量池中只是符号引用(Symbolic References),其在虚拟机中无法实际执行,因此必须分解为直接引用地址。这便是人们熟悉的虚拟机中的“链接阶段-符号解析”基本过程。
(2)方法分派:与方法解析不同,方法分派主要侧重于定位执行的方法,但其与解析有同样的目的。方法分派的好处在于可以将方法定位延迟到运行阶段,而并非只是固定的编译或者解析等这些静态阶段。方法分派分为静态分派和动态分派。
虚拟机字节码指令集
指令包括两方面的内容:操作码和操作数,其中操作码决定所要完成的运算,而操作数指进行计算的数据及所在的单元地址。虚拟机的字节码指令集也采用这种模式,其中Class文件相当于JVM的机器语言,既是源代码信息的完整表述形式,也是物质载体。方法内的代码被存储在code属性中,字节码指令序列便是对方法的调用过程。
字节码指令集
加载和存储指令,运算指令,类型转换指令,对象创建和操作指令,操作数栈管理指令,方法调用指令,返回指令,抛出异常指令
主要数据类型
整数类型byte,char,int,short,long
浮点数类型 float,double
逻辑类型 boolean
引用类型 returnAddress,reference
1.局部变量加载到操作数栈的指令:
iload、iload_
2.数值从操作数栈中存储的变量到局部变量表的指令。
istore、istore_
3.常量加载到操作数栈的指令
bipush、sipush:加载取值范围为-128127的int整数参数采用bipush指令;加载取值范围为-3276832767的int整数参数采用sipush指令,需
要注意的,这里是从常量池中获取值。
4.扩充局部变量表的访问索引的指令
wide:宽索引字节码的指令通常是单字节的,对局部变量来说,最多容纳256个局部变量,wide命令通常用来扩展局部变量数量,如将8位索引再次扩展
8位,即16位。
运算指令类型
加法指令 iadd
减法指令 isub
乘法指令 imul
除法指令 idiv
求余指令 irem
取反指令 ineg
移位指令 ishl
按位或指令 ior
按位异或指令 ixor
局部变量自增指令 1inc
比较指令 dcmpg
转换指令
i21 int类型转换到long、float或者double类型
12f long类型转换到float、double类型
f2d float类型转换到double类型
转换指令
i2b、i2c、i2s int类型转换到boolean类型、int类型转换到char类型、int类型转换到short类型
12i、f2i、d2f long类型转换到int类型、float类型转换到int类型、double类型转换到float类型
f21、 d2i、 d21 float类型转换到long类型、double类型转换到int类型、double类型转换到long类型
创建对象指令
new 创建类实例
newarray 数据类型为基本数据类型的新数组
anewarray 数据类型为引用类型的新数组
multianewarray 创建新的多维数组
转换指令
Getfield、 putfield 获取和设置实例变量属性
Getstatic, putstatic 获取和设置类变量属性
Baload, caload, saload, iaload, faload, daload,aaload 把一个数组的一个元素加载到操作数栈,不同的前缀对应不同的类型
Bastore, castore, iastore, sastore, fastore, dastore,aastore 把一个操作数栈的数组元素值作为参数存储在数组中,不同的前缀对应不同的类型
arraylength 取数组长度
Instanceof、 checkcast 检查类实例类型
操作数栈管理指令
pop、pop2 将操作数栈、栈顶一个或两个元素出栈
dup、 dup2, dup_x1,dup2_x1,dup_x2,dup2_x2 复制栈顶的一个或两个数值并将复制值或双份的复制值重新压入栈顶
swap 交换栈顶端的两个数值
跳转指令
ifeq 当栈顶int类型元素,等于0时跳转
ifne 当栈顶int类型元素,不等于0时跳转
iflto00 当栈顶int类型元素,小于0时跳转
ifle000 当栈顶int类型元素,小于等于0时跳转
ifgt 当栈顶int类型元素,大于0时跳转
ifge 当栈顶int类型元素,大于等于0时跳转
跳转指令
if_icmpeq 比较栈顶两个int类型数值的大小当前者等于后者时跳转
ificmpneooo 比较栈顶两个int类型数值的大小当前者不等于后者时跳转
if_icmplto0000 比较栈顶两个int类型数值的大小当前者小于后者时跳转
if icmple00o 00 比较栈顶两个int类型数值的大小当前者小于等于后者时跳转
if_icmpgeoo0 比较栈顶两个int类型数值的大小当前者大于等于后者时跳转
ificmpgtoo0 比较栈顶两个int类型数值的大小当前者大于后者时跳转
if_acmpeq 比较栈顶两个引用类型数值的大小当前者等于后者时跳转
if_acmpne 比较栈顶两个引用类型数值的大小当前者不等于后者时跳转
跳转指令
tableswitch switch指令条件跳转,case值属于分布的场景
lookupswitchoo switch指令条件跳转,case值属于分布不连续的场景
goto0000 无条件跳转
goto wo 宽索引形式无条件跳转
jsr下一条指令地址压入栈顶
jSr W宽索引形式下一条指令地址压入栈顶
ret 返回由指定局部变量所给出指令地址
方法调用指令主要有如下5条。
(1)invokevirtual指令:用于调用对象的实例方法,根据对象的实际类型进行分派(虚方法分派),这也是Java语言中最常见的方法分派方式。
(2)invokeinterface指令:主要用于调用接口方法,它会在执行时寻找某个实现这种接口的对象,在找到合适的实现方法后进行调用。
(3)invokespecial指令:通常用于表示调用某些需特别处理的实例对象方法,包括实例初始化对象方法、私有方法、父类实例方法。
(4)invokestatic:用于调用静态方法(类方法实现)。
5) invokedynamic:用于调用动态链接的方法,它在运行时才会进行方法派遣和方法调用,如Lambda表达式、MethodHandle句柄运行机制。
方法返回指令也按照返回值的种类划分,主要包括以下几种。
(1)ireturn:只有返回值属于short、char、byte、boolean或者int类型时才被使用。
(2)lreturn:当返回值是long类型时使用。
(3)freturn:当返回值是float类型时使用。
(4)dreturn:当返回值是double类型时使用。
(5)areturn:当返回值是数组或者引用对象时使用。
此外,方法返回指令还有一个return指令,供声明返回结果为void类型的方法、实例构造器、类和接口的构造器使用
抛出异常指令
Java应用程序中抛出异常的形式主要有两种,一种为手动显式抛出异常,主要由athrow指令实现;此外还有一种,当VM在执行命令时侦测到程序中的
异常状态时,虚拟机会自动抛出异常。
JVM同样也支持同步方法和同步代码块,这两种同步指令结构都可以通过管程(Monitor)进行支持。
静态方法执行案例,在案例中,调用了String类的valueOf仿法及对应反编译后的结果。
public Class Example {
//获取相关数据值
public String getValue (int a, int b){//将int类型转换为String类型的方法
return String.valueOf (add (a,b)) ;
}//main方法执行操作
public static void main (String[] args) {
Example t = new Example ();
}//调用相关静态方法 getValue
String str = t.getValue (1,2);
//相加相关的整数对象
private int add(int a, int b){
return a + b;
}
}
编译后指令集
public java.lang.String getValue (int, int);
Code:
0: aload 0 //加载下标为0的操作数,这里指的是Example对象
1: iload 1 //加载索引下标为1的int类型的操作数
2: iload 2 //加载索引下标为2类型的操作数
3: invokespecial #2 // 调用 add 私有方法机制,属于 invokespecial方法,#2指向常量池索引值为2的常量数据
6: invokestatic #3 //调用String.valueOf()方法,#3指向常量池索引值为3的常量数据
9: areturn //返回字符串对象
实例方法执行案例
public Class Example2{
//add方法操作
private int add(int a, int b) {
return a + b;
}
//将int类型转换为String类型的方法
public String getValue (int a, int b){
return String.valueOf (add (a,b));
}
//main方法执行操作
public static void main (String[] args){
Example2 t = new Example2 ();
String str = t.getValue (1,2);
}
}
反编译结果
public static void main (java.lang.String[l);
Code:
0:new #7//创建对象操作
3:dup //加载相关常量数据信息
4:invokespecial #8//调用构造器及父类构造器4:
7:astore1//加载1到局部变量表7:
8:aload1//加载1到操作数栈
9:astore2//加载2到局部变量表9:
10:aload1//加载2到操作数栈
11:iconst1//加载常量值1到操作数栈顶11:
12:iconst2//加载常量值2到 操作数栈顶12:
13:invokevirtual #9 // 调用 getValue 方法13:
16:return
接口方法执行案例
//接口方法
public interface ByteCodeInvoke{
//调用接口方法
void invoke ();
}
//example方法类实现
public Class Example3 implements ByteCodeInvoke
{
@Override
public void invoke () {
System.out.println ("123");
}
public static void main (String[] args)
{
Example3 t = new Example3 ();
Action a = t;
a.invoke ();
}
}
反编译结果
public static void main (java.lang.String[]);
Code:
0:new #7
3:dup
4: invokespecial #8 // 调用父类构造器
7: astore 1 //加载局部参数t
8: aload 1 //加载到操作数栈 Action a
9:invokeinterface #12, 1//调用接口方法
10: return
动态方法执行案例
public Class Example4 {
public static void main (String[] args)
{
Example4 t = new Example4 ();
t.createThread();
}
public void createThread (){
Runnable r = () -> System.out.println ("123");
}
}
反编译结果
0: invokedynamic #13, 0
5: astore 1
6: return
JVM运作原理深入分析
内存分配及回收得依据
JVM的GC回收算法中使用的是可达性分析算法,而可达性分析算法的最核心阶段就是定位根对象并且遍历标记存活对象,因此根对象是垃圾回收算法中确定一个对象是否可以回收的基本依据。
可达性分析算法:可以把整个堆空间理解为一个图,那么以一组根路径的对象作为遍历分析的起点,由这些对象从上向下进行遍历搜索,遍历对象所经过的搜索路径就可以称为引用链路(Ref erence Chain),如果搜索的对象与对应的根对象之间没有引用链路时,则说明该对象不再被引用了,也表示该对象不会被继续使用了,那么在垃圾回收阶段时就会被回收。反之则说明对象还存在着引用,属于存活的对象。而对应的根对象,我们称为GCRoots。
Java程序运行时并非在所有位置都能暂停下来执行GC的。从JVM规范中可知,只有在特定的位置才可以执行GC,而这些位置称为Safe Point。
之所以会需要Safe Point,主要是因为查找过程中需要暂停用户线程,而Java程序不可能运行每条指令时都执行一次GC。这样就可以让程序尽可能一直
安全地跑下去,不会让太多垃圾对象占据JVM内存。
针对主动式中断,Safe Point无法处理因线程末到达Safe Point而陷入休眠(Sleep方法等)或等待状态(加锁等)的情形,主要因为它没有在运行,所以就无法去轮询GC Safe Point检查点对应的标识位,为了解决这个问题,JVM引|入Safe Region(安全检查区)。
Safe Region是程序中所对应的一块代码区域或者是线程的某种执行状态(如Wait、TimeWait等),并且在Safe Region中,线程执行与否并不影响对象引用的状态。在线程执行到Safe Region中的代码时,首先会标识自己已经加入了Safe Region中,并通知JVM可以执行GC操作的状态,在线程准备离开Safe Region前会检查JVM是否已经完成了GC,如果完成了就继续执行,否则就要等待GC结束之后才可以离开Safe Region。反过来想一下就是,假如线程本身就不再执行,那何必去中断或暂停它呢?因为它本身就不会使对象的引用发生变化
GC清除对象的过滤机制,在可达性算法中不可达的对象,并不是一定立刻要被回收,还需要再进行一次选择过滤机制。
要真正宣告对象“死亡”,需经过如下两个过程。
(1)经可达性分析算法分析之后,过滤出没有发现引用链的对象,放到待回收的对象池中,等待GC线程回收即可。但是在此之前,还需要进行第二阶段的过滤。
(2)在回收对象之前,GC回收器会先检查对象是否写了finalize方法,一旦有对象重写并在方法里成功建立了自我引用关系,则马上从回收队列中移除该GC对象,以免被回收。注意,一个类的fnalize方法只能执行一次,所以可能会发生同样的代码首次“自救”完成,但第二次自救失败的情形(第二次压根不会执行)。如果类重写了finalize方法并且还没有调用过,那么就把该对象放在一个称为F-Queue的队列里,等待finalize线程执行,但是finalize并不一定会执行,主要是因为如果里面存在死循环的话,可能会导致F-Queue队列处于等待状态,更严重时会造成内存崩溃,这点千万要注意。
应用程序锁创建的对象分配到JVM内存中的规则,主要面向分代回收算法中存储的对象。此外还有一些特殊场景的介绍和分析,如大对象
直接迁入老年代、对象年龄达到了相关阈值后直接迁入老年代、新生代的担保分配机制、动态年龄分配机制等。
对象可以优先分配在新生代的Eden区内。一般情况下,当一个对象先在Eden区内进行分配,但是没有足够的空间进行分配内存的时候,JVM可能会先进行一次Minor GC(Young GC),来尝试释放一些内存空间。
长期存活的对象进入老年代。虚拟机为每个对象定义了一个年龄计数器,如果对象经过了1次Minor GC,那么对象会进入Survivor区,之后每经过一次
MinorGC,对象的年龄加1,直到达到阈值后,对象进入老年代区。此外,针对对象的分配,简言之就是在堆上分配(但也可能经过IT编译后被拆分为标量类型数据间接地在栈上分配)。最开始对象主要会分配在新生代的Eden区中,如果启动了本地线程分配缓冲区(Thread local allocation bufer),简称为TLAB,将会优先分配在TLAB中,少数情况也可能直接分配在老年代,分配的规则并不是固定的模式,其策略取决于当前使用的是哪种垃圾回收器及VM中相关的内存配置参数。因此考虑优化内存分配机制,在堆内存之外还可以再加入一个TLAB分配机制(栈上分配机制)
大对象是指需要大量连续内存空间的Java对象,最典型的大对象就是很长的字符串及数组。虚拟机提供了一个JVM参数:-XX:PretenureSizeThreshold,其可以设置大对象的大小。如果对象超过设置大小,则不会进入年轻代,而是直接进入老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制。该参数只在Serial 和ParNew两个收集器中有效。例如,-XX:PretenureSizeThreshold=1000000(单位是字节),设置该参数的目的是避免为大对象分配内存时因进行复制操作而降低效率。
Java对象当达到一定年龄后会直接进入老年代,这个年龄的分界阈值可以道通过参数-XX:Max-TenuringThreshold进行设置。既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这一点,JVM给每个对象创建一个年龄(Age)计数器。
如果对象在Eden出生并经过第一次Minor GC后仍然能够存活,并且能被Survivor容纳,将被移动到Survivor空间中,并将对象年龄设为1岁。对象在Survivor中每经过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁)时,就会被晋升到老年代中。对于需要长时间使用的对象,并且使用的频率很高,经过分析后可以使用”下面的配置将对象复制的次数减少,直接提前进入老年代。JVM参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:+PrintGCDetails-XX:MaxTenuringThreshold=5.
新生代使用的是复制回收算法,但为了内存利用率,只会使用其中一个Survivor的空间来作为轮换备份,因此当出现大量对象在执行Minor GC后仍活的情况时(最极端就是内存回收后新生代中所有对象部存活),就需要老年代进行分配担保,让Survivor无法容纳的对象直接进入老年代。与生活中款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下去,在实际完成内存回收之前是无法知道的,所以只好取之前每次回收晋升到老年代对象容量的平均值作为参考,与老年代的剩余空间进行对比,决定是否进行Full GC来让老年代腾出更间,这就是所谓的担保分配机制。
为了能更好地适配不同场景下的内存分配问题,JVM也不会总是规定对象的年龄超过Max-TenuringThreshold值之后才可以晋升的老年代,如果Survivor区中同一年龄阶段的所有对象大小的总和超过了Survivor空间的50% (-X: TargertSurvivorRatio可以指定),那么年龄超过或者等于此年龄阶段的对象就可能会直接进入老年代。
举个例子,针对Survivor区中的一批年龄为n的对象,当它们的总数达到了整个Survivor区域的50%,那么此时就会将所有年龄为n或以上的对象直接放入老年代。这个规则其实就是希望那些可能长期存活的对象,尽快步入老年代。对象的动态年龄判断机制通常是在执行Minor GC之后才触发的。
JVM分析工具大全
JDK及其自带的各种性能监测和常用故障修复管理工具有jps、jstat、jinfo、jmap、jhat、jstack等。
jps
ips (JVM Process Status Tool) 是JVM进程状态监控工具,其主要目的是帮助开发者查看当前机器运行的Java进程信息,可追踪到本地虚拟机唯一的进程ID (Local Virtual Machine Identifier, LVMID)、虚拟机启动执行主类名、文件路径等.
指令格式:jps[-q][-mlvV][
主要参数说明如下。
(1)[q]:仅会打印虚拟机标识,并且会省略每个主类的初始名称。
(2)[m]:打印在虚拟机进程启动时所传递给主类main方法的所有启动环境参数。
(3)[l}:打印启动时正在执行主类的全限定名,如果正在运行的是一个jar包服务,则进程输出的为jar包的地址路径。
(4)[v]:打印虚拟机启动时的JVM参数。
次要参数说明如下。
[
jstat
jstat是基于JVM的信息监视管理工具(JVM Statistics Monitoring Tool),主要用于收集JVM运行中各个方面的统计数据,包括自身或者目标JVM中的Class类加载情况、各个进程内存存储区域中的GC概况和信息数据统计、JIT编计圣器相关数据信息统计及程序运行时的数据。指令格式: jstat [option vmid [interval[s|ms] [count]] ].
主要参数说明如下。
(1)[interval]:可选参数,表示间隔时间,即多久输出一次信息,默认单位为ms。
省略这两个参数,说明只查询一次。
(2)[count]:可选参数,表示查询次数,即总共输出多少次信息。如果
(3)[vmid]:标识想要查询监控的虚拟机进程ID。
(4)option:可选参数选择加多。
jstack
jstack(Java Stack Trace Tool) 通过统计一个JVM栈中的线程快照信息,从而直接产生一个JVM中当前的线程快照数据,该数据文件通常称为threaddump或者java core。
Java Stack(ava栈数据)是对JVM内各种应用线程中正在进行的方法堆栈的汇总。生成堆栈快照的首要目的就在于定位线程中发生长期停滞的主要因素,如死锁、活锁、深度递归、死循环、大量请求外部资源而引起的长期等待等。指令格式:jstack [option]
[option]参数说明如下。
(1)-F:当正常输出的请求不被响应时,强制输出线程堆栈。
(2)-m:如果调用本地方法(Native),则可以显示C/C++堆栈信息。
(3)-I:除了显示堆栈之外,还可以显示对象锁的所有附加条件信息。
jmap
jmap命令用于生成堆转储快照(称为heapdump)。
jmap不仅可以快速获取堆转储的内存快照,而且还可以查看finalize对应的执行队列、Java堆和方法区等更多详尽的数据,如能够查看当前内存空间中的每个对象占据的数量及当前内存空间是否被占用、当前使用的是哪种垃圾回收器等。此外如果不使用jmap命令,还可以直接利用-X+HeapDumpOnOutOfMemoryError参数实现当JVM产生内存溢出时直接创建一个内存快照。命令格式:jmap [option]
[option]参数说明如下。
(1)-dump:表示当前操作是导出内存dump文件。
(2) -finalizerinfo:主要会显示GC过程中F-Queue队列中所有将要被Finalizer进行回调finalize方法的对象。
(3)-heap:监控Java堆详细信息,如回收器配置、虚拟机参数配置、分代内存存储数据。
(4)-histo:显示堆中对象数据信息,包括类信息、实例数量、统计容量等。
(5)-permstat:显示永久代内存状态,JDK1.7及之前版本拥有永久代。
(6)-F:JVM中的进程系统对-dump选项不进行响应式操作,直接强制生成一个快照。
jinfo
jinfo (Configuration Info for Java)是具有JVM中的配置参数信息采集功能的监控管理工具,主要用来检测并管理虚拟机的各种配置参数信息。
命令格式:jinfo[option]
[option]参数说明如下。
(1)-flag
(2)-flag[+/-]
(3)-flag
(4)-flags:输出JVM进程非默认的配置项及用户启动时设置的虚拟机参数。
(5)-sysprops:输出当前VM内部的系统运行参数。
jhat
jhat (JVM Heap Analysis Too) 是JVM在堆转储中的快照文件分析常用工具,其可以和jmap搭配使用,解析由jmap dump转储产生的快照文件。因为在某些功能上相对简陋,而且有些操作执行起来比较耗时,所以一般不太建议使用。笔者推荐大家使用MAT或者Visual VM这种更加优秀的工具。命令格式:jhat<dump.file>。
dump.file:分析jmap生成的快照文件。
常用JVM图形化分析工具概述
Jconsole
Jconsole (Java Monitoring and Management Console,JVM监视和管理控制台),它是JDK自带的一款内置应用程序监视与内存性能跟踪分析管理工具,在%JAVA HOME%/bin目录下可快速发现Jconsole的可执行文件,并能够轻松连接远程应用程序与本地应用程序,也可以同时监控几个JVM实例。Jconsole可以对运行的Java应用程序的资源耗费情况与性能表现进行监测,还可统计计算相关图形报表,并提供可视化界面。其本身所占用服务器内存也非常少,可以说基本不消耗。它可以结合jstat,通过JTop插件更有效监测java内存的变化状况,以及产生变化的原因。当项目追踪内存泄漏问题时,非常实用。
启动命令: jconsole [ -interval=n ][ -notile ][ -pluginpath
核心参数如下。
(1)interval:将数据刷新的周期间隔窗口设定为is(它的默认值为4s)。
(2)notile:初始不平铺窗口(对于两个或多个连接)。
(3)pluginpath:指定Jconsole查找插件的路径。
(4)version:输出程序版本。
(5) connection = pid || host:port || JMX URL (service:jmx:<协议>://...).
1pid:目标进程的进程ID。
2host:远程主机名或IP地址。
3port:远程连接的端口号。
VisualVM
VisualVM (Al-in-One Java Troubleshooting Tool) 是功能最强大的运行监视和故障处理程序。与Jconsole相似,VisualVM不仅能监控本地的应用程序,还可以监控远程服务器上的应用。远程监控一般不会用于生产环境。
VisualVM来监控JVM的使用情况,这样有助于了解VM的实时运行状态从而进行优化和调整,通过收集程序的初始运行配置和线程堆栈dump、堆内存dump等相关信息,输出相关应用程序快照的分析统计结果。
MAT
MAT(Memory Analyzer Tool,内存分析工具)是基于Eclipse的Java堆应用内存系统分析工具,功能十分强劲,能够有效协助开发人员分析系统内有泄漏的问题以及优化应用系统的内存。它可以针对数量众多的对象进行内存分析,迅速地统计出M内存中每个对象所占用内存的大小,可以分析出究竟是哪些对象在阻碍垃圾回收器的回收工作,并且能够使用报表的方式输出导致内存问题的对象。
JVM在线性能分析服务
FastThread
除了前面讲述的本地化VM分析工具外,目前也有很多在线SAAS服务可以支持VM内存分析,目前主要分为线程堆栈分析工具(istack)、Heap堆的分析工具(jmap)和JVM参数调优工具这三大类。
线程堆栈相关的分析服务FastThread,图形化界面效果很不错。其主要解析压缩格式(zip、gz、xz等)的文件。
GCEasy
一个针对分析垃圾回收和内存使用相关的SAAS服务(Gceasy)。类似jmap服务,上传解析的文件也是压缩格式的。
PerfMa
针对JVM调优和性能分析的SAAS服务(PerfMa),它是目前最专业的JVM调优工具。堆栈的分析也可以上传到https://console.perfma.com
查看分析结果,需要注意的是dump文件中的线程号是以16进制表示的,所以我们定位线程的时候,也要把Linux系统线程号(10进制)转化为16进制格式。它的设计主旨就是帮助大家更加清晰地认识VM内的每个参数,并且还能对目前正在运行的NM参数提供优化建议,针对不同环境或者不同发行版本的JNVM参数也有着良好的适配能力,并且可以促进大家互相学习,交流相关的调优经验,使NVM参数变得不再那么高深莫测。
Arthas分析JVM问题定位
使用Arthas诊断工具在线排查问题时,无须再次重启,可以实现实时调试Java代码和动态监控当前JVM的运行状态。Arthas可以支持JDK6+,也可以支持Linux/MacWindows,其使用命令行交互处理模式,并且提供了完善的Tab自动判断+补全处理功能,从而可以更加快捷和方便地让用户对问题进行准确定位与分析判断。
Arthas可以帮助我们解决如下问题。
(1)查询和分析类存在于哪些jar包。
(2)Java代码报出Exception异常或Error错误原因。
(3)代码未按照预期执行,如何通过jad反编译查看源码。
(4)无法在生产环境上远程debug代码或无法查看实时的运行日志,可通过热加载重新调整代码。
(5)解决线上遇到某应用数据处理逻辑问题。
(6)通过Dashboard统计全局视图,监控整个系统的运行状况。
(7)监控JVM实时运行状态,包括线程和内存的运行状态。
(8)快速定位应用程序热点代码,生成热力图,便于分析调用频率和热度。
(9)直接通过JVM查找某个类实例及相关方法说明等。
dashboard指令会实时显示当前JVM中应用服务的多线程状态、各内存区域的GC情况等信息.
参数
ID Java中的ThreadID,整个内核中的线程ID是1:1的关系
NAME 线程名
GROUP 线程组名
PRIORITY 线程优先级,取值范围为1~10,值越大表示优先级越高
STATE 线程的状态
%CPU 线程的CPU使用率。例如,采样间隔为1000ms,某个线程的增量CPU时间为100ms,则CPU使用率=100/1000x100%=10%
DELTA TIME 上次采样后线程运行增量CPU时间,数据格式为秒
TIME 线程运行总CPU时间,数据格式为分:秒
INTERRUPTED 线程当前的中断位状态
DAEMON 是否是daemon线程
JVM内部线程包括如下几种。
(1) JIT编译线程: C1 CompilerThread0、C2 CompilerThread0.
(2) GC线程: GC Thread0, G1 Young RemSet Sampling.
(3) 其他内部线程: VM Periodic Task Thread, VM Thread、 Service Thread.
thread指令,查看当前线程信息,查看线程的堆栈。它具有很多相关的操作指令,
参数
id 线程ID
-n 指定最忙的前n个线程
-b 阻塞状态的线程
-i 指定CPU的频率,默认为200ms
-all 所有相关的线程
JVM指令,查看JVM相关的性能数据,包含运行时的数据和内存相关的参数信息、线程和操作系统等配置信息.
jad工具对字节码进行反编译,针对某些代码是否生效或者是否变更成功,都可以通过jad工具非常清晰地观察到。jad指令针对Map的源码反编译后的效
果。
sc搜索指令查找在JVM内已经被加载的类,是Search-Class的英文缩写。通过该查询命令,可以快速查询和输出所有已经被添加到JVM中的Class类信息。
参数
Class-pattern 类名表达式匹配
method-pattern 方法名表达式匹配
-d 输出当前Class类对象的详细信息,包含当前类被加载的原始文件来源、类版本的声明、加载类的ClassLoader等
-E 标识采用正则表达式进行字符匹配
-f 输出当前类的成员变量信息(需要配合参数-d一起使用)
-X 输出静态变量对属性的最大遍历深度值,大多数情况下是0,直接采用toString方法输出数据
-c 指定Class的ClassLoader的hashcode
-n 具有详细信息的匹配类的最大数量(默认为100)
-ClassLoaderClass 指定执行表达式的 ClassLoader的Class name
sc指令与sm同理,它是“Search-Method”的简写,通过这个命令可以查询出任何加载在JVM中的Class所对应的Method方法。
mc指令是"Memory Compiler”的英文缩写,它主要负责编译java源文件生成Class字节码。例如:mc /test/TestSamplejava.
编译生成Class文件之后,可以结合retransform命令实现热更新代码。
retransform指令主要负责加载外部的Class文件,可以实现热加载Class类到JVM内。
指令名称
help 查看帮助信息。可以查看当前arthas版本支持的指令,也可以查看具体指令的使用说明
cls 清空当前屏幕区域
session 查看当前会话信息,显示当前绑定的pid及会话ID
reset 重设强化类,把被Arthas强化过的类型全部恢复。当Arthas服务器进行stop时,会重置所有强化过的类
history 输出命令历史
quit 等同于exit、logout、q这3个指令。
stop 关闭服务。一旦关闭后,则所有连接的客户端也会自动退出
keymap 输出当前的快捷键映射表
Http API是使用Arthas对外提供服务且采用RESTfu接口协议的功能服务组件,请求与服务应答中的内容都应该是采用JSON协议格式的接口结构体。Http API相比TelnetWebConsole的数据输出类型来说是更加非结构化的数据格式,能够为开发者提供更加复杂的结构化数据类型,并支持更加复杂的交互
处理功能,如特定应用场景的一系列诊断操作。
OOM主要发生在堆和方法区中,是JVM内存中发生的最普遍问题。当FGC(Full Garbage Col-lection) 无法回收内存时,就会出现OOM,如果不分析和排查引起OOM的具体原因,JVM可能很快又会发生0OM,并且也很可能频繁FGC,而FGC的STW会比较久,所以在极端的情况下可能引起微服务调用链
路上出现超时、熔断或者阻塞现象。
当Heap区被塞满对象且JVM已无法再次创建新的对象的时候,OOM就会产生。
故此我们写一个无限循环,让它不停去创建新对象并让其被引用,使其不会被GC回收,这样我们就可以看到对象从Eden区到Survivor区再到Old/Tenure区的过程了, 最后导致 "java.lang.Out-OfMemoryError:Java heap space”
在执行循环的过程中(还未发生OOM之前),我们可以使用Arthas服务的heapdump指令(等同JDK原生的jmap组件的heap/dump命令的功能),将堆内存数据dump下来,使用工具分析。这个命令也会暂停程序(STW),可以内测使用,推荐在开发环境或者测试环境、准生产环境等使用,不推荐在生产环境使用。
使用heapdump指令将生成当前JVM内存快照数据,并且dump到指定文件中。
基于Arthas工具的heapdump指令导出dump文件,将文件通过MAT进行解析。通过Dominator Tree这个视图可以详细分析系统中大对象的分布状况,单击工具栏上方的按钮图标,可以直接启动一个Dominator Tree(支配树)视图,可以展示出每个对象(Object Instance)或与其他引用关系对象形成的树状结构,此外还包括这些对象所占用的系统内存大小和空间百分比.
通过Shallow Heap及Retained Heap的百分比指标进行排序,可以非常直观地了解系统大对象的数据趋势和比重.
可以从Dominator Tree或者Histogram视图中找出疑似内存泄漏的对象或者类,使用Retained Heap进行排序,并且可以在ClassName中输入正则表达
式的关键词(显示指定的类名),然后右键选择Path To GC Roots (Histogram中没有此项)或Merge Shortest Paths to GC Roots,选择不同的引用类型
进行过滤。
作为一个Java开发者,相信你对FullGC一定不会陌生,一般而言我们会采用横切FullGC的方式分析FullGC,采用前置拦截(一XX:+HeapDumpBeforeFullGC)和后置拦截(-XX:+HeapDumpAfter-FullGC)的方式导出FullGC发生前后的heap dump文件,以便进行FullGC问题的分析和定位。
观测GC回收次数及时间,除Java原生的jstat指令外,还可通过dashboard看板中的GC子面板(整体部分的右下角)部分。
通过分析及观察发现,当FullGC的频率过高时,可以有针对性地获取FullGC前后n(3~5)组的heap dump文件。解析每次FullGC前后的对象数量和存数据,分析指标较为靠前的对象,从而找出FullGC频繁原因,侧重分析以下5个要点。
(1)分析其创建的数量为什么过多,以及占用内存剧增的原因。
(2)分析其创建的对象生命周期为什么过长,导致会直接迁移到老年代。
(3)存在担保分配机制导致直接在老年代分配,即对象大小是否过大,导致动态年龄跃升。
(4)进入老年代的阈值过低(XX:PretentureSizeThreshold 的值过低),导致跃升。
(5)内存分配不符合业务场景,对于老年代及新生代的内存值不合理。
当JVM出现FullGC且耗时超过1s时,即可认为FullGC时间过长,对该问题的分析如下。
(1)新生代内存空间分配过小:如果新生代过小,对象会过早地晋升至Old区,而Old区的垃圾回收工作一股较新生代耗费更多的时间,因此可增大毅生区空间来有效地减少GC的停顿时间。
(2)最优的GC回收器:GC回收器是影响GC停止时间的一个十分关键的原因。笔者个人建议选择G1收集器,由于G1回收器是自动调优的,因此只需设
定一个停止时间的目标即可,如-XX:MaxGCPauseMillis=200。
(3)当发生热交换内存(Swap)时,如系统内存不足,那么操作系统就会将应用中的内存交换出去。内存的热交换机制相当耗时,而且由于必须访问硬盘,因此相对于直接访问物理内存而言速度要慢得多。
(4)磁盘的IO负载重:如果硬盘存在大量的文件读写操作,则会造成GC停顿时间变长。
(5)堆中的内存过大:如果堆内存过大,则整个堆内存空间中就会积累过多的内存垃圾。一旦发生FullGC时,需要回收并且处理所有内存垃圾时,将需要耗费更多的时间。(当运行JVM的堆内存总数为12G时,可以考虑将其分成3个4GB的小型JVM实例,这样会大大地降低GC的停顿时间。)
(6)发生特殊情况:如CMS中出现concurrent mode failure后,此时GC回收器降级成串行回收器进行GC处理,因此导致GC回收时间整体变得更长。
(7)显式调用System.gc:当调用System.gc或者Runtime.getRuntime0.gc之后,就会发生FullGC,从而引起GC时间过长,可能会存在以下场景。
1应用程序定制化/业务化调用了System.gc方法。
2引用的第三方库或者框架,甚至是应用服务器都调用了System.gc方法。
3外部使用JMX/JVMTI技术进行触发,如jmap指令或者JVisualVM工具。
死锁问题非常常见,为了排查这类问题,Arthas提供了相关命令,协助开发人员快速定位。使用thread命令输出线程统计信息,其中BLOCKED表示目前
阻塞的线程数,是需要重点观察的保护对象。执行“thread-b”命令相当于"stack-l
行的死锁线程。
在日常开发过程中,可能会发生CPU负载过高的情况,此时一般会考虑是线程引起的,可以采用thread命令查看当前线程信息及线程的堆栈。CPU使用率是衡量系统繁忙程度的重要指标,一般情况下单纯的CPU使用率高并没有问题,它代表系统正在不断地处理任务;但是,如果CPU使用率过高,导致任务处理不完,从而引起负载过高,需要特别关注。CPU使用率的安全值没有一个标准值,取决于系统是计算密集型还是IO密集型,一般计算密集型应用CPU使用率偏高、load偏低,IO密集型则相反。
如果需要定位CPU负载过高的问题,那么首先需要知道的是哪些线程存在着高负载问题,比如GC线程或者应用程序线程等,这时最简单的方法就是通过dashboard看板查询整个进程中所有线程、内存、GC的运作情况。
其中查看CPU使用率的效果与Linux的命令top-H-p
再次采样,获取所有线程的CPU时间。对比两次采样数据,计算每个线程的增量CPU运行时间。
线程负载的CPU使用率= 线程增量CPU运行时间/采样线程间隔时间x100%
程序的编译和代码优化
Java源码必须通过编译器编译成为字节码后才能在JVM中运行,其中编译技术会涉及非常多的知识点,如编译原理、语言规范、虚拟机规范、本地机器
码优化等。
Java系统中主要有3个编译器,分别为前端编译器、即时编译器(JIT编译器)和静态预先编译器(AOT编译器)
(1)前端编译器是Java编译体系中的“先头兵”,负责把Java源码文件(java)编译成Class字节码文件(Class),读者可以理解为它就是把人类可以理解识别的Java语言代码转化为八M可以识别的字节码指令。此外针对前端编译器的优化范畴属于Java源码层面的,例如,JDK8以后的新特性(语法糖、泛
型、内部类、钻石语法等),这些都是依靠前端编译器实现的,与JVM无关。在开发过程中常用的前端编译器主要有Oracle/Sun javac、Eclipse JDT中的增量式编译器(ECJEclipse Compile for Java)、IDEA的相关编译器等。由前端编译器编译的Clas字节码可直接由JVM执行引擎进行解释执行,这样可以节省编译时间,并提高启动速率。但是对代码的运行效率和性能几乎没有任何优化,因此解释执行的效率较低,一般都要结合JIT编译器。
有前端编译器自然也有后端编译器,目前后端编译器主要是由JVM的JIT编译器(ust In Time Compiler,即时编译器)和AOT编译器(Ahead of Time
Compiler,静态预先编译器)组成,它们的目的都是在程序运行时将Class字节码编译成本地机器码。通过在任务执行时获取监控数据,将热点代码(Hot Spot Code)编译成和本地平台相应的机器码,并且加以不同级别的优化,能够极大地提高任务实施效果。同时,收集监控信息不会影响程序运行。不过后端编译器也会出现某些缺陷,如在编译过程中会浪费大量程序执行时间(使启动速度变慢),以及
为了生成编译机器码而耗费大量内存空间。
(2)JIT编译器包括HotSpot虚拟机的C1、C2编译器等。由于JIT编译器的速率和编译结果的好坏是评价JVM性能的关键指标,因此对程序执行时性能优化主要集中在这一阶段,即可以对该阶段进行JVM调优。
(3)AOT编译器,在程序运行之前,AOT编译器会直接将Class字节码编译成本地机器码。AOT编译器的特性包括:编译器不占程序时间,可做某些更
耗时的优化,能加速程序启动;能够直接将编译器的本地机器码存入硬盘而不会浪费大量内存空间,并且可反复利用。AOT编译器的缺点主要在于Java语言的动态性(如反射)带来了额外的复杂性,影响了静态编译代码的质量。常用的AOT编译器有JAOTC、GC、Excelsior JET、ART(Android Runtime)等。总体来说,AOT编译器比JIT编译器的编译质量稍差,因此这种编译方式使用得较少。据笔者了解,目前整个Java技术生态中,绝大部分使用的是前端编译器+JIT编译器的运作方式,如在HotSpot虚拟机中使用的就是这种方式。
Java程序最初是使用解释器对代码进行解释执行的,当在JVM中检测到某些代码块执行的频率很高时,便会将这种代码认定为热点代码。为了提升热点代码的执行效果和性能,当任务执行时,JVM会将所有热点代码编译成本地机器码,而且还会对语义和语法等各个层面进行优化,而完成这种任务的编译器就称为JIT编译器。
JIT编译器是一个能够提升应用程序运行速度的工具。一般来说,Java程序主要有两种实现方案:静态编译与动态编译。静态编译方案是程序在开始运行之前会被全部编译成为机器码;动态编译方案需要先不断解释执行,然后动态编译为机器码执行。
在Java编程语言和技术领域中,JIT编译器指的是一个可以把对应的Class字节码转换成能够直接由系统处理器执行的机器指令的工具组件。从狭义上来讲,JIT编译器能够让用户真正执行程序代码的时候进行实时编译,它又称为“即时编译”。其实川编译本来是动态编译的特例,在后面的时间里被高度泛化,一般情况下可以理解为与动态编译等价。
自适应动态编译(Adaptive Dynamic Compilation)是一种动态编译技术,但其执行的时间比JIT编译器更晚,必须先使程序进行预热,在获取一定信息以后才进行动态编译处理,这样的编译过程可以更加灵活和强大。
JIT编译器在默认运行状况下一直保持自动启用状态,只有当调用方法达到其要求标准时才被激活。JIT编译器将该方法对应的每一行字节码编译为可执行
的机器码,当完成对该方法的编译之后,JVM就可以直接调用该可执行的机器码,无须再进行解释执行。从理论层面来说,假设编译器几乎不占用CPU的处理时间且使用极少的内存空间,通过快速编译各种程序代码就能够让Java应用程序的运行速度无限接近本机底层程序的运行速度。
JVM中一般内置有两种I编译器,分别为Client Compiler (客户端编译器)和Server Compiler(服务器端编译器),或者简称为C1编译器和C2编译
器。
C1编译器(Client Compiler)是利用参数-client启动所需要使用的编译器,也称为“面向客户端服务的编译器”。C1编译器主要是为开发客户端级别的应用程序而设计的,因此大多数基于客户端类型的Java应用程序不会消耗太多系统资源,而且相对于服务端类型的应用程序启动时间特别短,C1编译器采用性能计数器对其执行的代码进行性能分析,以实现简单、相对无干扰的优化。
C2编译器主要面向长时间运行的应用程序(如服务器端企业级Java应用程序)。由于C1编译器可能无法支持这种程度的编译及优化,因此可以考虑采用C2这种基于服务端级的编译器。在JVM的启动项中添加-server的JVM参数,可以启用C2编译器。因为大多数服务端应用程序运行时间比较久,所以启用C2编译器意味着能够比运行时间较短的客户端应用程序收集到更多与系统性能相关的分析数据,这样就可以继续使用更先进的代码处理技术和优化算法。
JIT编译过程中需要重点关注CPU的负载率与内存使用率,当应用程序刚刚启动时,会自动调用数千种编译方法。尽管应用程序已经达到了相当好的运行
性能,但进行编译的这些应用方法仍将严重影响程序启动时间。
在Java 7以后,虚拟机就不再采用“解释器和编译器”相互结合的工作方式,而直接使用分层编译模式,在分层编译模式下实现了C1编译器和C2编译器
为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机会逐渐启动分层编译策略。根据编译器编译、优化的规模与耗时,可以划分:
多个编译层次,具体如下。
第0层:应用程序以解释方式执行,但解释器不会开启性能监控功能(Profiling)。
第1层:又称C1编译模式,把每个字节码编译为可执行机器码,以此实现最为简单、安全且可靠的性能优化。若有必要,将加入性能监控的逻辑,
第2层:又称C2编译模式,同C1编译器一样,也是将字节码编译为可执行机器码,但会启用耗时较长的优化手段,甚至会基于某些性能监控信息而做某些不可靠、不安全的编译优化。
实施分层编译后,C1编译器和C2编译器将一起进行工作,使用C1编译器可以获得较高的代码编译速度
当选择一种方法进行编译时,JVM会将其字节码提供给JIT编译器。JIT编译器必须先了解字节码的语义和语法,然后才能正确编译该方法。为了帮助JIT编译器分析该方法,首先将其字节码重新格式化为AST(Abstarct Syunta Tree,抽象语法树),与字节码相比,其更类似于机器代码;然后对方法的树进行分析和优化;最后,将树转换为本地代码。JIT编译器也可以通过多个编译线程执行JIT编译任务,使用多个线程可以潜在地帮助Java应用程序更快启动。
编译线程的默认数量由JVM标识,并且取决于系统自动化配置。如果生成的线程数不是最佳的,则可以使用-XcompilationThreads启动参数设置并译的线程数。编译对象即为会被编译优化的热点代码,这种编译机制也属于JVM中标准的JIT编译方式,目前编译的方式主要有下面两种。
(1)被多次调用的方法:方法被多次调用,会被判定为热点代码。
(2)被多次运行的循环体:以循环体执行频次为标准,但仍以整个方法为对象。如果编译发生在方法执行过程中,则可以直接进行VM的栈上(On Stack Replacement,OSR),即方法栈帧还在栈上,而对应方法已被替换。
综上所述,对于以上这两种情况,编译器都以整个方法作为编译对象,这种编译也是虚拟机中标准的编译方式。要知道一段代码或方法是不是热片码,以及是否需要触发即时编译,需要进行Hotspot Detection(热点探测)目前热点探测方式主要有以下两种。
(1)基于采样的热点探测。采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那么这段方法代码就是热点代码。
(2)基于计数器的热点探测。采用这种方法的虚拟机会为每个方法,甚至是代码块建立计数器,统计方法的执行次数,如果执行频率超过阈值,就认为
它是热点方法。
编译优化技术主要流程如下:
(1)内联将较小方法合并到调用的方法中,这样可以加速频繁执行的方法调用。
(2)局部分析优化一般只是分析和调优少部分代码,而大多数代码的优化还是要依靠静态编译器来实现,毕竟静态编译器拥有久经实战的大量经验。
(3)控制流优化分析方法(或方法的特定部分)内部的控制流,或因重新排列代码路径以增加系统的性能。
(4)全局优化可一次对整个方法起作用。它们更加“昂贵”,需要大量的编译时间,但可以大大提高性能。
(5)本机平台代码的操作生成,其过程因平台架构而异。通常,在编译的此阶段,将方法树转换为机器代码指令;针对架构特征进行了一些小优化。编译过程是在后台线程(daemon)中完成的,可以通过参数“-XX:-BackgroundCompilation”禁止后台编译,但此时执行线程就会同步等待编译完成才会执行程序,使用参数"-XX:+Print Compilation”会让虚拟机在JIT时把方法名称输出。
Java程序自身的一些动态执行特征也造成了额外的程序计算量和复杂度,影响了Java应用程序静态编译代码的工作。因为AOT编译器在整个程序运行之前就开始编译,所以无法获得运行时候的性能数据,因此可能会导致一些复杂问题的产生,这里不再详细举例。总而言之,从编译质量上来说,A0T编译器肯定不及JIT编译器。AOT编译器存在的主要目的就是减少JIT编译器执行时的性能损耗或内存耗费,或减少解释程序的早期性能开销。从执行速度上来讲,AOT编译器编译的机器码要比JIT编译器编译的机器码执行速度要慢,但是比解释器的解释执行速度要快。
AOT编译器的设计思路:在程序实现之前先自动产生包含Java方法的本地执行代码,从而在程序开始执行时直接使用本地执行代码。但由于Java语言自身的一些动态特征,导致产生了额外的程序计算量和复杂度,因此影响了Java程序使用静态编译的代码质量。在编译时间上,AOT编译器的速度也是较为稳定的,所以AOT编译器的存在只是JVM牺牲高质量来换取高性能的一个重要策略。
静态编译的程序在执行前全部被翻译为机器码,通常将这种操作称为AOT。它把高级语言源程序作为输入,进行翻译转换,产生出机器语言的目标程序,然后让计算机执行该目标程序,得到计算结果。
AOT编译器在程序运行前就把代码编译好了,不过这也算是它的缺点之一,因为这会导致很多无用的代码也给编译了,同时也牺牲了平台无关性,因为它们不能利用程序的动态化能力,也不会有相关类或类层次结构的信息了。
但是它也带来了一些好处,如避免IT编译器在运行时的性能消耗,同时相比而言也提升了解释执行在早期的运行性能,极大地缩短了程序的启动时间。
最常见的前端编译器是javac编译器,其将Java源代码编译为Java字节码文件;最常见的JIT编译器是HotSpot虚拟机中的Client Compiler和ServerCompiler,其将Java字节码编译为本地机器代码;AOT编译器则能将源代码直接编译为本地机器码。这3种编译器的编译速度和编译质量如下:
(1)编译速度上,前端编译器>AOT编译器>JIT编译器。
(2)编译质量上,JIT编译器>AOT编译器>前端编译器。
编译器优化的常见技术原则和技术方案,如消除公共子表达式、消除数组边界检查、方法内联机制、消除对象空值校验、常量传播机制等技
术,通过认识和分析这些技术要点,读者可以对动态编译技术有进一步的学习和认识。
公共子表达式消除方法是一种广泛被运用在各种代码编译器中的代码优化技术。假设一个程序中的表达式A已经计算过,而且从先前的计算结果一直执行到现在,表达式A中每个变量的值都不改变,则表达式A就称为公共子表达式。对于公共子表达式,没有必要对它进行二次计算,只需要直接用前面计算过的表达式结果代替A即可。如果这种优化仅限于程序的基本块内,则称为局部公共子表达式消除(Local Common Subexpression Elimination);如果这种优化范围涵盖多个基本块,则称为全局公共子表达式消除(Global Common
Subexpression Elimination)
消除数组边界检查是JIT编译器中的一项在语言及语义层面上的优化技术。因为Java是一门动态化且安全机制很强的编程语言,当程序指令对于数组对象进行读写访问操作时,JVM会对数组对象上下边界范围进行检查。如果初始化一个数组,当Java程序访问数组元素时,VM将会校验访问数据是否在可访问的边界范围内,当索引下标小于0或者超过数组最大长度代表的下标范围时,将抛出运行时异常Arrayln-dexOutfBoundsExcepion(数组索引越界异常)
如果代码中访问数组下标值是一个常量,如array[2],只要在编译阶段根据数据流分析确定aray的长度和数组边界范围,并判断下标"2”没有越界,在真正运行阶段就无须再做数组边界检查,这就属于消除数组边界检查。
再者,如果对数组访问发生在循环之中,并且还使用循环变量访问数组。编译器通过数据流分析判定循环变量的取值范围在区间[0,aray.length)范内,那么在循环中就可以把整个数组的上下界检查消除掉,这样可以节省很多次的条件判断操作。
方法内联机制也是JIT编译器在方法及方法块级层面上的优化技术,主要出现在方法调用点处,是一种非常强大的优化技术。
下面分析Java程序中方法之间的调用流程。
(1)为当前调用方法的线程建立一个对应的虚拟机执行栈,建立栈顺模型,存储方法的局部变量、返回地址、操作数栈、动态链接、其他栈帧属性等。
(2)当调用另一个方法时,一个新的栈帧会被Push到栈顶,进行分配的局部变量和参数会存储在这个栈帧中。
(3)通过PC计数器跳转到目标方法指令执行。
(4)执行完成后,方法返回,栈顶数据被清除且栈帧被移除。
(5)借助返回地址和PC计数器跳转到上一个栈帧的指令地址进行执行。
方法内联是把被调用方程序代码复制到调用点的方法内部并且进行组合整理,从而减少因方法调用所带来的性能开销和资源开销。JIT编译器可以内联final方法,但其也会根据运行时统计信息内联一些非final修饰的方法。
消除对象空值检查机制与消除数组边界检查技术大同小异,同时也是考虑安全检查对象本身内存访问的安全性,主要针对空指针异常(
NullPointException)的访问问题。举个例子,Java程序中访问一个对象(假设对象叫object)的某个属性value,那么在访问object. value的过程中,从安全角度出发,便会存在隐式的空值检查机制,但是当编译器中检测到对象已经完成了初始化和实例化后,那么会酌情去除相关的空值检测,从而提升一些执行效率,因案例比较简单且较为容易理解,所以此处不进行相关的代码解释。
基本块重排序是一种使用非常广泛的编译优化技术,它通过重新组织基本块在存储映像中的排列顺序,使得基本块按照最经常执行的控制流序列排列
从而减少转移指令的开销和指令Cache的失效损失。
基本块重排序优化通常在编译器中实现,不需要对硬件进行修改,具有适用性广、实现代价小的特点。
循环表达式(不会变化的属性变量)外提(在英文中又被称为hoisting或scalar promotion),在计算机编程中是指将循环不变的语句或表达式移到循
环体之外,而不改变程序的语义。循环不变代码外提,是编译器中常见的优化方法。
循环不变量是指在循环开始和循环中每一次迭代时永远不会变化的数值,这意味着在循环中和循环结束时循环不变量和循环终止条件必须同时成立。
计算机程序建立相关指令集合形成流水线后,就需要一种高效的调度机制来保证硬件层面并发或者并行的效果。其最佳情况是每条流水线里的十几个指令都是正确的,这样完全不浪费时钟周期,而分支预测(Branch Prediction)就负责指令的执行预测。分支预测的方法有静态预测和动态预测两类。
(1)静态预测:预测永远不转移、预测永远转移(jmp)、预测后向转移等。其并不根据执行时的条件和历史信息进行预测,因此预测的准确性不高。
(2)动态预测:根据同一条转移指令过去的转移情况来预测未来的转移情况。
无论是静态预测还是动态预测都是需要依靠分支预测器来进行条件判定,而分支预测器会判定条件表达式所对应的多个分支中哪条支路最可能发生,然后执行这条路径的指令,从而避免流水线停顿所造成的时间浪费。但是,如果发现分支预测出现错误,那么流水线中执行的那些中间结果将全部丢弃,并重新执行正确分支的指令,这可能会带来十几个时钟周期的延迟,这个时候CPU完全就是在浪费时间。
常量传播是指将程序计算的结果值直接替换为常量的方法,它是JIT编译器的核心优化技术之一,同时也是众多编译器中非常广泛的优化方法之一,它通常应用于中间表示(IR,Intermediate,Representation),解决了在运行时表达式所得出的结果总是同一个常量的问题,如果在调用过程中知道哪些变量将具有常量值,以及这些值是什么,则编译器可以在编译时期简化常数。
常量传播在优化中的几种用途如下。
(1)在编译时求值的表达式不需要在执行时才求值。如果这样的表达式在循环内,则只在编译时进行一次求值,从而提高执行效率。
(2)用常量值替换常量变量来修改源程序,这样可以识别并消除程序的无效代码部分,如始终为假的表达式所关联的无效代码,从而提高程序的整体效
率。
(3)执行过程的部分参数是常量,减少相关的变量取值范围可以避免变量数量过于膨胀。对于控制状态,只需要存储非常量的值。常量值不需要存储,可以始终通过查看控制状态来检索。
(4)对从未到达的执行路径的检测,减少/简化程序的执行流程,可以帮助将程序转换为适合向量化处理的形式或并行处理的形式。
创建的Java对象默认都分配到堆上,在虚拟机栈中只保存了对象的引用句柄(引用指针)。一旦对象不再使用,就需要依靠GC遍历所有GCRoots建立引用树并进行内存回收。但如果堆中对象数量过多,就会消耗大量时间及性能,这会给垃圾回收机制带来很大的压力。所以,如何优化堆栈的开销是一个非常
重要的课题,而逃逸分析(Escape Anagly)技术就是解决该问题的方法之一。
在计算机语言的编译程序优化理论中,逃逸分析是分析指针动态范围的方法,并且还可以分析在程序的哪些地方可以访问到指针,与前面介绍的编译器优化技术相关联和协同。当变量或者对象在方法中分配后,其对象引用指针有可能被返回或者被全局引用,这样就会被其他方法或者线程所引用,这种现象称为指针或者引用的逃逸(Escape)。通俗地说,如果一个Java对象的指针被多个方法或者线程引用,那么就称该对象的引用指针或对象逃逸(Escape)通过逃逸分析,Hotspot编译器能够分析出一个Java对象的引用的使用范围,从而决定是否要将该对象分配到堆上。逃逸分析是目前JVM中比较前沿的优化技术。
常见的逃逸场景有全局变量赋值、方法返回值、实例引用传递等。
方法逃逸:在一个方法体内定义一个局部变量,而它可能被外部方法引用,如作为调用参数传递给方法或者作为对象直接返回,可以理解成对象的生命
周期延长,跳出了方法的作用域。
线程逃逸:这个对象被其他线程访问到,如赋值给了共享的实例变量,这样该对象就逃出了当前线程。逃逸分析由VM启动参数进行配置。
逃逸分析带来的第一个好处是标量替换。Java语言中的原始数据类型(int、long等类型及refer-ence类型等)都不能再分解成更小的数据类型元,我们称为标量;相对地,如果数据类型能够再进一步的分解,则称为聚合量,我们定义的对象就是典型的聚合量。逃逸分析发生的场景:当逃逸分析检测到一个Java对象不会被外部线程或者方法所访问且这个对象属于聚合量时,那么Java程序在真正执行时很可能不会创建该对象,而直接转换为该对象的若干个标量属性并进行替换。这些标量数据可以被单独分析与优化,甚至可以分别在栈帧或寄存器上分配空间,以上所有步骤就称为标量替换。
逃逸分析带来的第二个好处是同步消除机制,其适用于类的方法上有定义同步锁的时候,方法在实际执行的过程中,同一个时刻只会允许一个线程进行资源访问或锁定,这样会大大降低多线程场景下的性能。而如果是逃逸分析后进行优化后的机器码,则会去掉同步锁的运行机制,大大提高程序运行的性能和吞吐量。接下来分析什么时候可以实现同步消除机制。
如果一个同步方法中不存在被多个线程之间共享的对象,那么JVM可以消除该共享对象的同步锁。这主要是因为线程同步本身比较耗费资源和时间,所以如果确定一个对象不会逃逸出线程,无法被其他线程访问到,那么该对象的读写就不会存在竞争,则可以消除该对象的同步锁。
栈上分配就是在栈上分配对象。一般情况下,该机制可以减少内存使用,因因为不用生成对象头;另外,程序内存回收效率高,GC频率降低。逃逸分析优化,栈上分配,找到未逃逸的变量,将该变量的内存直接在栈上分配(无须进入堆),分配完成后,继续在调用栈内执行,最后线程结束栈空间被回收,局部变量对象被回收。对比可以看出,主要区别是将栈空间直排接作为临时对象的存储介质,从而减少了临时对象在堆内的分配次数。但目前LTS版的Hotspot虚拟机在此方面的技术实现还不成熟,所以所谓的“栈上分配”主要还是以“标量替换”为主。
JAVA内存模型和线程运作原理
Java内存模型描绘了一个程序的所有可能行为,而JVM的实现能够很自由地产生想要的代码,因此程序最终执行所产生的结果都可以使用内存模型进行分析。这给大量的代码转换工作带来了足够的自由度,包括执行指令之间的重排序和非必要的同步消除机制。
JMM遵循不同内存模型设计标准,并屏蔽在不同内存模型和不同操作系统之间的数据访问实现差异,以确保在不同的软件平台上对数据访问均正常运行。它实现了共享内存环境下的并发处理机制,线程之间主要通过读、写共享变量来完成隐式数据共享或通信。
Java内存模型通过监控和管理线程内部的通信与数据共享,判断某个Java线程对于共享变量的写入时机,以及对于另外一些线程的可见时机。Java内存模型使用各种执行动作来定义,包含对变量的读、写动作,对监视器的加锁和解锁动作,以及对线程的启动和合并动作。Java内存模型对程序的内部操作定义了偏序关系和偏序规范。例如,现在有操作A和操作B,如果想保证操作B能够发现并使用操作A的所有执行结果(无论操作A和操作B是否从属于同一个执行线程内),那么只需要符合Java内存模型的规定即可。
而以上的这种规范就是我们众所周知的“Happen-Before”原则,此外还有其他方面,如处理器重排序机制、As-f-Serial语义、volatile关键字实现和MESI协议的介绍等。
不同线程间无法直接存取对方工作时内存空间中的本地数据变量,因此线程间的数据通信一般需要通过两种方法进行实现,一种就是通过发送消息进行通信,另一种则是通过共享内存。Java线程之间的数据通信最常使用的方式就是线程间共享内存的方式,线程、主内存与线程工作内存之间的相互关系.
Java内存模型的首要任务就是明确定义一个程序中对所有内存变量的自动存取操作规则,如从一个NM上的内存变量(线程之间共享的变量)写入内存或者从内存中读取等操作,Java内存模型保证了其访问数据的原子性、可见性和有序性。
Java内存模型中明确规定了将全部线程变量直接存放在主工作内存中,而任务线程的运算操作部只能在当前所在的工作内存中完成,并不能直接读取存在主内存中的所有数据变量。这里的本地工作内存只是Java内存模型的一种基本抽象概念,又可以称为局部内存,它只是保存该线程的所有可读句写的共享存储变量的一个副本。如同每个内核处理器上的内核都必须拥有一个私有高速缓存,在Java内存模型中的线程也都必须拥有一个私有本地内存lava多线程之间通常也需要采用一个共享内存进行数据通信,但这样一来在整个通信过程中必然会产生许多复杂的问题,如可见性、原子性、顺序性问题等。Java共享内存模型正是一种围绕着多线程之间通信所产生的标准规范。Java内存模型体系定义了若干个语法集,这些语法集直接映射到Java语言中的volatile、synchronized等关键字。
Java内存模型的共享内存模式规定了线程间通信必须经过主内存。假如有两个线程进行数据通信或者交换,那至少要通过如下两个阶段的运作流程。
第一阶段:由线程1将存储在自身工作内存中已经更改过的共享变量X刷新到公共主内存中。
第二阶段:由线程2主动从公共主内存中读取线程1之前就已经更改了的共享变量X。
将主内存、线程执行(CPU执行引擎层级)及线程本地内存三者之间的数据传输及通信总结归纳。
指令
lock 作用于主内存变量,把一个变量标识为一条线程独占状态
unlock 作用于主内存变量,把一个处于锁定状态的变量释放出来,释放后的变量可以被其他线程锁定
read 作用于主内存变量,把一个变量值从主内存传输到线程的工作内存中,以便随后的1oad动作使用
load 作用于工作内存变量,把read操作从主内存中得到的变量值放入工作内存的变量副本中
use 作用每个工作区和内存上的变量,将每个工作区内存中的每个变量值都自动传送到执行引擎。当虚拟机中出现了一条使用这个变量的字节码指令,就会进行这种操作
assign 作用每个工作内存上的每个变量,即将执行引擎中接受的变量数值赋值到每个工作管理内存的变量。当虚拟机中遇到变量赋值的字节码指令时会自动进行这种操作
store 作用于工作内存变量,将工作内存中的某个变量的值传递至主内存中,以方便随后的write运算
write 作用于主存储器变量,将store操作的工作内存的变量赋值给主内存变量
根据Java内存模型的标准(共享内存模型),在多处理器体系中,虽然各个处理器均拥有自身的高速缓存,但其也共用一个主存储器,正因如此虽然通过与高速缓存之间的存储交互,非常好地解决了处理器和内存之间的速度问题,但其也引入了新的问题:缓存一致性。
缓存一致性问题主要集中于多个处理器的运算任务运行在同一个主内存区,那么就会存在各自缓存数据不一致的场景,但要真的出现了此问题,那在返回主内存时最终以哪个的缓存数据为准呢?如果要解决缓存一致性的问题,就必须让所有处理器在访问主内存区时均遵守一个标准协议,并且使用时也必须按照该标准完成运算,这类协议主要有MSl、MESl、MOSl、Synapse、Firefly和Dragon Protocol等,而我们主要学习MESI协议.
MESI (Modifed Exclusive Shared or Invalid)协议又称作美国伊利诺伊协议,因为此协议前身是美国伊利诺伊州立大学于1994年提出的一个支持缓存写回策略的数据缓存一致性协议,此后该协议被广泛应用于Intel奔腾系列的CPU当中。,MESI协议是作用在主存、缓存之间的桥梁。
MESl协议架构中的有效状态数据类型有序分布,主要由M (Modifed,被修改状态)、E(Ex-clusive,独享状态)、S(Shared,共享状态)、I
(Invalid,无效/失效状态)组成,这4种有效状态分别对应存在CPU每个缓存行中的4种有效状态(可以使用额外的两位表示)。
M 修改 该内存缓存行只被缓存在该CPU的缓存中,它本身未被任何操作修改过(Clean)与它在主存中存储的数据值相同。任何时刻当有其他CPU读取该内存对应的数值时,都将会变成Shared状态。类似地,当在CPU中修改该缓存行中的内容时,该协议状态也同样可以被改变为Modified
E 独享 该缓存行只被缓存在该CPU的缓存中,并且是被修改过的(Dirty),即与主存中的数据不一致。该缓存行中的内存需要在未来的某个时间点(其他CPU读取主存中相应数据)写回(Write Back)主存。当被写回主存之后,该缓存行的状态会变成Exclusive
S 共享 该状态意味着该缓存行可能被多个CPU缓存,并且各个缓存中的数据与主存数据一致(Clean),当其中一个CPU修改该缓存的数据后,其他CPU中的缓存行可以被作废(变成Invalid状态)
I 无效/失效 该缓存是无效的(可能有其他CPU修改了该缓存行)
(1)MESI协议的各状态之间的简单流转过程。
(2)CPU-A从缓存中读取缓存行A,其他CPU都没有读,这时缓存行的状态为E。
(3)CPU-B从缓存中读取缓存行A,这时该缓存行的状态为S。
CPU-A修改缓存行A,并回写到自身内部缓存中,这时缓存行的状态为M,然后会回写到主存中。
(4)每个CPU读取完缓存行之后都在内存中监听已读缓存行的状态,这时CPU-B就会监听到缓存行A已被修改,那么CPU-B就会把它设置为。处于I状
态的数据会被丢弃,如果想继续操作,还需要到主存中重新获取。
(5)缓存行A在CPU-A中的状态又会改为E。
除MESI协议中规范状态流转的基本机制以外,Java内存模型中还详细规定了在读写操作之前的8种内存控制指令,操作时需要满足如下基本规则。
(1)变量从主内存中复制到本地工作内存,read和load操作必须按顺序执行,但不需要一定是连续执行。
(2)变量从工作内存同步回主内存,store和write操作必须按顺序执行,但不需要一定是连续执行。
(3)不允许read和load两者任意之一单独操作或者不允许store和write两者任意之一单独操作。
(4)不允许线程忽略或丢弃assign操作,即变量在工作内存中发生了修改,那么必须同步到主内存中。
(5)不允许线程没有执行assign操作就把数据从工作内存同步回主内存中。
(6)变量实施use和store操作之前,必须先执行assign和load操作。
(7)变量在同一时刻只允许一条线程对其进行1ock操作,但Iock操作可以被同一条线程重复执行多次。多次执行lock后,只有执行相同次数的unlock作,变量才会被解锁。
(8)变量执行lock操作,将会清空工作内存中此变量的值,在执行引擎使用该变量前需要重新执行load及assign操作以初始化变量的值。
(9)变量事先没有被lock操作锁定,则不允许对其执行unlock操作,同时也不允许直接unlock被其他线程锁定的变量。
(10)变量执行unlock操作之前,必须把此变量同步到主内存(执行store和write操作)
volatile是由JVM提供的最轻量级的动态同步转换机制。将一个同步变量定义成volatile变量之后,它将同时具有以下两种同步特征。
(1)保持此变量对所有线程间的可见性,但由于普通变量无法实现这一点,因此普通变量的数值在线程间传输时必须通过主内存。
(2)禁止指令重排序优化,因为普通变量仅仅会确保在某方法的运行过程中,任何依赖于赋值结果的地方均能获取得到合理的结果,但却无法确保对变量赋值时操作的次序和在程序代码中的执行次序保持一致。
用volatile来修饰一个共享的变量,那么对该共享变量的底层读或写操作会进行特殊化处理,因为volatile实现了一种动态化轻量级锁的同步机制。
对于采用volatile修饰的变量的读或者写操作,以及对于普通变量的读或者写执行操作,通过一个“动态锁”来实现同步,
两者之间的运行结果一致。所以volatile保证了一般情况下操作的原子性和可见性,但针对上面的复合操作volatile++,则无法保证原子性。
此外,有序性的基本实现原理是利用内存屏障,内存屏障类型分为如下四类。
(1) LoadLoadBarriers.
指令示例:LoadA-Loadload-LoadB。
此屏障可用于确保LoadB及其之后的指令均能读到LoadA指令所加载的数据,即在指令操作中的LoadA肯定较LoadB先执行。
(2) StoreStoreBarriers.
指令示例:StoreA-StoreStore-StoreB。
此屏障可用于确保StoreB指令及后续写指令均可以操作StoreA指令执行后的数据结果,即写操作StoreA一定会比StoreB先执行。
(3) LoadStoreBarriers.
指令示例:LoadA-LoadStore-StoreB.
此屏障能够有效确保它的StoreB及在它后面的指令都能读到LoadA操作所读取到的数据,即读取的操作LoadA肯定比后续的写操作StoreB先执行。
(4) StoreLoadBarriers.
指令示例:StoreA-StoreLoad-LoadB。
此屏障能够确保LoadB及其后续读指令都能读到StoreA操作后的数据,即写操作StoreA必然比读操作的LoadB先执行。
如果变量被volatile修饰,那么编译的时候会将在这些变量前面或后面插入以上描述的4种内存屏障以防止指令重排,包括:
1在volatile写操作的前面插入Store StoreBarriers,保证volatile写操作之前的读写操作执行完后再执行该 volatile变量的写操作.
2将volatile写操作的后面插入StoreLoadBarriers,保证volatile写操作之后的读写操作同步到主内存,并可以确保后面的volatile操作均可以读到最新的
数据(存在主内存)。
3将volatile读操作的后面分别插入LoadLoadBarriers和LoadStoreBarriers,保证volatile 读写操作之后的读写操作均会先把线程本地的变量置为无效,再把主内存的共享变量更新到本地内存,并且使用最终的本地内存变量。
综上,volatile的特性如下。
(1)可见性:对于volatile变量,任何线程都可以看到该变量最终的结果。
(2)原子性:对单个volatile变量的读或写都具有原子性,如赋值操作volatile=1。
(3)有序性:内存屏障,阻止或者禁用相关的指令优化进行重排序。
Happen-Before原则是Java内存模型体系中重要的概念之一,可以通过Happen-Before关系模型保证跨线程之间的数据内存可见性。如果线程A的写操作与线程B的读操作之间存在Happen-Before关联,那么虽然线程A的写操作和线程B的读操作在不同线程中独立进行,但Java内存管理模型仍然能够确保它们之间的写操作对读操作完全可见。
由于JVM拥有的重排序机制可能会造成线程安全问题,一个非常典型的解决案例就是DCL重排序。相对于Java编译器的重排序,还有处理器重排序(造成线程安全问题),通过将内存屏障命令插入程序的指令序列中,防止对一些特定的处理器重新排序规则。
(1)假设一个动作发生先行于另一个动作,则前一个动作操作的执行结果将对第二个动作可见,并且将第一个动作的执行顺序列于第二个动作前面。
(2)两种操作之间都具有Happen- Before关系,但并不代表Java平台的具体实现就一定要根据Happen-Before关系确定的顺序来运行。只要经过重序后的执行结果和按照Happen-Before关系锁产生的结果相同,则该重排序就不非法(Java内存模型允许这种重排序)。
以下是Java内存模型定义的8种Happen-Before原则。
(1)应用程序顺序规则:线程中每个操作,Happen-Before该线程任意后续操作。
(2)对象监视器锁规则:对锁的解锁,Happen-Before随后对这个锁的加锁。
(3) volatile变量规则:对volatile域的写,Happen-Before任意后续对这个volatile域的读/写。对于一个volatile变量的单次写,处理Happen-Before对此变量的任意操作:
(4) Happen-Before的的传递性, 如果A Happen-Before B, 且B Happen-Before C, 那么A Hap-pen-Before C.
(5)线程执行操作Thread.start()(启动线程),则该线程start方法操作一定Happen-Before于该线程中的其他任意操作。
(6)如果线程A执行操作线程B的join()并且成功返回,那么线程B中的任意操作一定会Hap-pen-Before(先于)线程A调用线程B
(7)程序的中断规则:对线程调用interrupted方法进行中断的操作一定会Happen-Before中断状态被检测。
(8)对象finalize规则:一个对象的初始化完成(构造函数执行结束)先行于发生它的final-ize()方法的开始。
在执行Java程序时,为了提升程序性能,编译器或者处理器往往会对每个指令行在执行时重新排序。一般的重排序可以划分成3个阶段,
(1)编译器优化:有些编译器在不需要修改单线程执行语义的情况下,可能会对程序代码进行重排序。
(2)指令级并行优化:一些现代化的处理器通过利用指令级并行技术,可使多条指令重叠交叉执行。如果它们之间不产生对数据结果的依赖性,则处理器可能会修改指令语句中对应机器指令的运行次序。
(3)内存系统级优化:处理器通常需要使用读/写数据缓存,这样会导致内存加载数据出现不一致的场景及对应的读写操作处于乱序状态。
而As-If-Serial语义主要是指:无论编译器还是处理器为了提升并行度所实现的重排序优化,在单线程运行场景下该程序的实际运行结果都不会发生改
变。
为确保程序执行结果的一致性,按照As-If-Seria语义原则,编译器与处理器之间不能直接就具有数据依赖关系的操作进行重新排序,因为这种重排序会改变运行结果,但是,如果操作之间并不具有数据依赖关系,那么就可以让编译器与处理器之间重新排序。
Linux中最开始没有线程概念,而单纯依靠多个进程进行上下文切换,其效率是非常低的,于是出现了Linux Threads。它定义线程和进程之间的映射关系为一比一
Linux Threads最初的考虑是认为多个线程间的上下文切换速度极快,所以每个内核线程都可以处理多个相关的用户级线程。这样,就产生了"1V1"的线程模式,一个用户线程部对应着一个轻量级进程(LWPLlight Weight Process),一个轻量级进程就对应着一个特定的内核线程,但仍有明显缺点,所以有了改进版本也就是NPTL(Native POSIX Thread Library,本地POSIX线程库)NPTL属于POSIX的标准线程库,属于Linux线程的一个新实现,它克服了Linux Threads的缺点,同时也遵循POSX的标准要求。与Linux Threads相比,NPTL在性能和稳定性方面都有了重大的改进。与Linux Threads一样,NPTL也实现了一对一模型,即一个用户线程对应一个轻量级进程,而一个轻量级进程对应一个特定的内核线程。
多线程内核技术有3个非常关键的线程:系统态内核线程、轻量级用户线程和用户线程。
(1)系统态内核线程:一个内核线程通常是一个内核分身,一个内核分身通常能够同时处理一个特定的任务。内核线程同样可以被内核管理,就像普通内核进程那样被内核调度。内核线程的实际使用通常来说是很廉价的,唯一可以利用的资源通常是在内核栈和每个上下文的切换时保存的寄存器存储空间。
支持多线程的内核称为多线程内核(Multi-Threads Kernel)
(2)轻量级用户线程:一个由内核提供支持的户线程,它是对所有内核线程的一种高度抽象。每个轻量级用户线程都和特定的内核线程密切相关。所每个LWP都必须是一个单独的线程数据调度控制单元。一个LWP在整个系统进程调用中发生了阻塞,不会直接影响整个系统进程的正常运行。轻量级用户程由clone系统调用创建,参数是CLONE_VM。
(3)用户线程:是完全建立在用户空间的线程库,用户线程的建立、同步、销毁、调度完全在用户空间完成,不需要内核的帮助。因此,这种线程的操作极其快速且消耗低。
调用一个线程,实际上调用的是用户空间的线程库,线程库中的每个线程(用户线程)对应一个轻量级进程,而一个轻量级进程对应一个内核线程,所有的内核线程经内核线程调度器调度后,再交由CPU完成相应操作。在使用Java线程时,JVM内部是转而调用当前操作系统的内核线程来完成当前任务。内核线程是由操作系统内核支持的线程,操作系统内核通过操作调度器对线程执行调度,并将线程的任务映射到各个处理器上。每个内核线程可以视为内核的一个分身,这也是操作系统可以同时处理多任务的原因。
线程的生命周期及状态之间的切换是并发编程技术中一个非常重要的理论。Java中的线程状态总体分为6种,定义在java.lang.Thread.State枚举类中,
可以调用线程Thread中的getState方法获取当前线程的状态。
(1) NEW:实现Runnable接口或继承Thread可以直接得到一个线程类,使用new关键字创建Thread (线程)的实例对象,但没有调用Thread的start方法,此时Thread便处于该状态。
(2)RUNNABLE:在NEW状态下,调用Thread的start方法后,线程将进入就绪状态,并将等待操作系统为其分配CPU资源,需要注意的是进入此妆态说明该线程有资格运行,但如果操作系统没有可分配的资源,那么也就只能一直停留在将执行还未执行的状态。这种状态称为Ready子状态。
(3)RUNNING:此状态属于RUNNABLE状态的一种,不属于Java定义的线程状态,当操作系统通过线程调度器在一个可执行池里选定某个线程运行
时,该线程才会真正执行,而这也是线程进入运行状态的唯一方法。这种状态称为RUNNING(运行中)子状态。
(4)BLOCKED:此状态是指线程进入synchronized关键字修饰的方法或代码块且不能及时恢复到正常运行/等待运行的状态。
(5)WAITING:当线程没有被分配到CPU时间片时,就要等待被显式地唤醒,否则会处于无限期等待的状态,进入该状态的线程需要等待其他线程做出一些特定动作(通知或中断)才会进入RUNNABLE状态。
(6)TIMED.WAITING:当处在等待状态下的线程没有被分配到CPU时间片,该状态不同于WAITING,它无须经过无限期的等待,当超过线程设置的等待时限后,会自行唤醒。
(7)TERMINATED:当线程已经停止运行或结束,如线程的run方法执行完成或主线程的main方法已经结束,便可认定该线程已经结束了,线程一旦结束了,就无法进行其他任何状态的转换了。
原子性:Java有两种高级字节码指令monitorenter与monitorexit,其对应的关键字是synchronized,通过该关键字可以实现方法及代码块内操作的原子性。即保证在操作过程中,CPU不能够让该操作中断或者切换时间片去做其他事情,故此保证了执行的原子性。
可见性:Java的volatile关键字所提供了一项功能,当线程内部变量在被修改之后能够立刻同步到主内存中,每次读取对应变量的操作也都会使用主内存中最新的值。所以,一般都会使用volatile关键字来实现在多线程情况下变量的可见性。
有序性:在很多场景下程序执行的顺序与代码的顺序未必一致,因为在多线程中为了提高性能,编译器和处理器常常会对指令进行重排(编译器优化重排、指令并行重排、内存系统重排)。使用volatile关键字禁止指令重排,使用synchronized关键字对程序加锁,都可以实现有序性。
最常见的实现线程安全的方案便是互斥同步(Mutex And Synchronized)机制,对应的就是Java中的synchronized锁。
synchronized重量级同步锁:当synchronized经过编译以后,系统将在synchronized 的同步代码块前后添加两个Mutex指令,分别是“monitorenter”和"monitorexit"。这两个字节码都需要一个ref-erence类型的参数来声明锁对象,如果是实例方法或者静态方法,则分别是对象的实例(this)或者类的Class对象。
根据规范要求,在执行"monitorenter"指令时,首先尝试去获取对象的锁,如果这个对象没有被锁定或者当前线程已经获取到锁,则锁的计数器+1,执行“monitorexit”指令,锁计数器-1,锁释放了。
规范描述有两点:
(1) synchronized同步块对同一个线程来说是可重入的,因此不会出现被自己锁死的异常情况,但是该同步块在每个线程任务执行结束前,都会阻塞其他线程的进入。
(2)synchronized关键字是一项重量级锁的线程操作,由于一个Java线程是直接映射在一个操作系统的原生线程上的,所以无论是阻塞还是唤醒线程,均需要操作系统的协助才能完成,如果要将线程的用户态直接转换成内核状态,转换状态的过程中需要耗费不少的资源和时间,甚至可能比用户代码所需要执行的时间还长。JVM对此作了优化,比如,采用自旋锁从而避免频繁切换到核心态。
Reentrantlock重入锁机制:Reentrantlock 和 Synchronized类似,分别属于APl层面上的互斥(lock和unlock方法)和原生语法层面上的互斥。
Reentrantlock比 Synchronized增加了一些高级功能。
(1)等待中断:持有锁的线程长期不释放锁(执行时间长的同步块)的时候,正在等待的线程可以选择放弃等待,做其他事情
(2)实现公平锁:ReentrantLock默认模式是非公平的,但是可以通过一个参数来设置成为公平锁,相对应的synchronized是非公平锁。
此外还有一种常用的无锁方案:线程隔离机制ThreadLocal(本地变量)。
原子性:Java有两种高级字节码指令monitorenter与monitorexit,其对应的关键字是synchronized,通过该关键字可以实现方法及代码块内操作的原子性。即保证在操作过程中,CPU不能够让该操作中断或者切换时间片去做其他事情,故此保证了执行的原子性。
可见性:Java的volatile关键字所提供了一项功能,当线程内部变量在被修改之后能够立刻同步到主内存中,每次读取对应变量的操作也都会使用主内存中最新的值。所以,一般都会使用volatile关键字来实现在多线程情况下变量的可见性。
有序性:在很多场景下程序执行的顺序与代码的顺序未必一致,因为在多线程中为了提高性能,编译器和处理器常常会对指令进行重排(编译器优化重排、指令并行重排、内存系统重排)。使用volatile关键字禁止指令重排,使用synchronized关键字对程序加锁,都可以实现有序性。
最常见的实现线程安全的方案便是互斥同步(Mutex And Synchronized)机制,对应的就是Java中的synchronized锁。
synchronized重量级同步锁:当synchronized经过编译以后,系统将在synchronized 的同步代码块前后添加两个Mutex指令,分别是“monitorenter”和"monitorexit"。这两个字节码都需要一个ref-erence类型的参数来声明锁对象,如果是实例方法或者静态方法,则分别是对象的实例(this)或者类的Class对象。
根据规范要求,在执行"monitorenter"指令时,首先尝试去获取对象的锁,如果这个对象没有被锁定或者当前线程已经获取到锁,则锁的计数器+1,执行“monitorexit”指令,锁计数器-1,锁释放了。
规范描述有两点:
(1) synchronized同步块对同一个线程来说是可重入的,因此不会出现被自己锁死的异常情况,但是该同步块在每个线程任务执行结束前,都会阻塞其他线程的进入。
(2)synchronized关键字是一项重量级锁的线程操作,由于一个Java线程是直接映射在一个操作系统的原生线程上的,所以无论是阻塞还是唤醒线程,均需要操作系统的协助才能完成,如果要将线程的用户态直接转换成内核状态,转换状态的过程中需要耗费不少的资源和时间,甚至可能比用户代码所需要执行的时间还长。JVM对此作了优化,比如,采用自旋锁从而避免频繁切换到核心态。
Reentrantlock重入锁机制:Reentrantlock 和 Synchronized类似,分别属于APl层面上的互斥(lock和unlock方法)和原生语法层面上的互斥。
Reentrantlock比 Synchronized增加了一些高级功能。
(1)等待中断:持有锁的线程长期不释放锁(执行时间长的同步块)的时候,正在等待的线程可以选择放弃等待,做其他事情
(2)实现公平锁:ReentrantLock默认模式是非公平的,但是可以通过一个参数来设置成为公平锁,相对应的synchronized是非公平锁。
此外还有一种常用的无锁方案:线程隔离机制ThreadLocal(本地变量)。
自旋锁(SpinLock)是指当一个线程将获取资源锁时,却被其他线程抢先一步获取到,那么该线程将进行循环等待,并且不断地检测锁是否可以被获取,直至获取到锁后才会退出循环。在没有获取锁的过程中,该线程一直处于活跃状态,但它并不能进行一个有效的任务,因为使用这种锁时会产生“busy-waiting”
自旋锁是Linux内核中使用最多的锁,其他很多锁均依赖自旋锁实现。自旋锁在概念上很筒单,本身就是一种互斥模式的加解锁机制,我们常常采用一个布尔类型或者整数类型的数值来表示“上锁”和“解锁”这两种状态。当成功加锁后,那么这个“上锁”参数值将会设置到状态位,之后临界区的代码还会继续执行;反之,这个锁(状态位)已被别的线程所成功修改,则该程序会进入连续的加锁循环内,并且不断重新检查这个状态值,直至其他线程将其变为
可用(解锁)状态。而这个循环过程通常被称为“自旋”选择自旋锁的要求就是自旋等待的代价要小于操作系统对线程调度的代价。所以使用自旋锁的一个重要规则就是它是尽可能短时间的持有资源。这个很好理解,因为持有的时间越长,其他线程就不得不长时间自旋等待。同时持有资源的线程不能被抢占或睡眠,如果出现这种场景,那其他等待的线程就浪费了。
锁消除是根据加锁的对象与实际执行情况是否一致来进行甄别的。如果两者不一致,那么对该对象就没有必要进行加锁或解锁。例如,开发者采用的StringBuffer的append方法,因为append本身就会判别该对象有没有被其他线程所占用,此场景不具有对象锁的竞争条件,那么这部分的性能消耗是无意义的。于是,Java虚拟机在即时编译的时候便会将上面的同步代码进行剔除,这就是锁消除,如逃逸分析技术。
为了提高多线程间的有效并发能力,最直接的方案就是让线程持有锁的时间尽量减少,但在某些场景下,如果一个程序中对于同一个锁不间断日高频地请求、同步和释放,那么会消耗掉较多的时间和资源,而由于锁的请求、同步和释放本身就会造成系统性能损失,所以如此高频的锁请求也就不利于整个系统性能的优化了,尽管单次同步操作的持续时间是非常短暂的。而锁粗化的本意也是告诉人们万事都有一个度,在某些场景下计算机反而希望将多个锁的请求合成一个请求,以减少锁请求、同步和释放所造成的性能损失。
自旋锁的主要目的是减少单线程切换成本。如果锁的争夺太激烈,那么就不得不依赖于重量级锁,让竞争失败的线程阻塞;如果完全不存在实际的锁竟争,那么中请重量级锁通常是很浪费的。轻量级锁的主要目的在于减少无实际竞争状况下的重量级锁产生的系统性能损耗,以及系统调用造成的内核状态和用户态转换、线程阻塞导致的多个线程之间的切换等。
轻量级锁是相对于重量级锁而言的,使用轻量级锁时,不需要申请互斥量,仅仅将Mark Word中的部分字节CAS更新指向线程栈中的锁记录,如果执行更新成功,则轻量级锁获取成功,记录锁状态为轻量级锁;否则,说明已经有线程获得了轻量级锁,目前发生了锁竞争(不适合继续使用轻量级锁),接下来膨胀为重量级锁。
当然,因为轻量级锁在锁争抢并不激烈的场景下会失效,甚至更加浪费和影响性能资源,所以倘若存在锁争抢但不激烈,也可采用自旋锁进行优化,只有等自旋失败后才会膨胀为重量级锁。
在没有实际激烈竞争的情形下,还可以针对部分应用场景继续进行优化。若仅对于锁而言没有任何竞争或自始至终使用锁的线程只有一个,那么即使是建立轻量级锁也是很浪费资源的,此时引入了偏向锁。它会大大减少在几乎只有单线程占用资源的情况下,频繁使用轻量级锁而产生的性能损耗。轻量级锁在每次锁的申请、释放锁时都最少需要一次CAS计算,而偏向锁则只会在初始化的时候进行一次CAS计算。偏向锁中偏向的目标是第一个占用该资源的线程,该线程在首次访问资源时在对象头的Mark Word中以CAS方式记录(本质上也是更新,但初始值为空)owner(代表线程),如果记录成功,则偏向锁获取成功,记录锁状态为偏向锁,以后当前线程等于owner就可以零成本地直接获得锁;否则,说明有其他线程竞争,膨胀为轻量级锁。
偏向锁通常无法通过自旋锁进行优化,因为如果由其他线程直接申请自旋锁,则会完全打破线程偏向自旋锁的基本假定。

浙公网安备 33010602011771号