本文整理自:《JRockit权威指南深刻理解JVM》java
做者:Marcus Hirt , Marcus Lagergrengit
出版时间:2018-12-10程序员
在JRockit JVM中,主要有3类命令行选项,分别是系统属性、标准选项(以-X开头)和非标准选项(以-XX开头)。github
一、系统属性算法
设置JVM启动参数的方式有多种。以-D开头的参数会做为系统属性使用,这些属性能够为Java类库(如RMI等)提供相关的配置信息。例如,在启动的时候,若是设置了-Dcom.Rockin.mc.debug=true参数,则JRockit Mission Control会打印出调试信息。不过,R28以后的JRockit JVM版本废弃了不少以前使用过的系统属性,转而采用非标准选项和相似 HotSpot中虚拟机标志(VM flag)的方式设置相关选项。数据库
二、标准选项编程
以-X开头的选项是大部分JVM厂商都支持的通用设置。例如,用于设置堆大小最大值的选项-Xmx在包括 JRockit在内的大部分JVM中都是相同的。固然,也存在例外,如JRockit中的选项-Xverbose会打印出可选的子模块日志信息,而在 HotSpot中,相似的(但实际上有更多的限制)选项是-verbose。数组
三、非标准选项缓存
以-XX开头的命令行选项是各个JVM厂商本身定制的。这些选项可能会在未来的某个版本中被废弃或修改。若是JVM的参数配置中包含了以-XX开头的命令行选项,则在将Java应用程序从一种JVM迁移到另外一种时,应该在启动M以前去除这些非标准选项肯定了新的VM选项后才能够启动Java应用程序。安全
Opcodes for the Java Virtual Machine
程序,包含数据和代码两部分,其中数据做为操做数使用。对于字节码程序来讲,若是操做数很是小或者很经常使用(如常量0),则这些操做数是直接内嵌在字节码指令中的。
较大块的数据,例如常量字符串或比较大的数字,是存储在class文件开始部分的常量池(constant pool)中的。当使用这类数据做为操做数时,使用的是常量池中数据的索引位置,而不是实际数据自己。
此外,Java程序中的方法、属性和类的元数据等也做为clas文件的组成部分,存储在常量池中。
在汇编代码中,方法调用是经过call指令完成的。不一样平台上call指令的具体形式不尽相同,不一样类型的call指令,其具体格式也不尽相同。
在面向对象的语言中,虚拟方法分派一般被编译为对分派表(dispatch table)中地址的间接调用(indirect call,即须要从内存中读取真正的调用地址)。这是由于,根据不一样的类继承结构分派虚拟调用时可能会有多个接收者。每一个类中都有一个分派表,其中包含了其虚拟调用的接收者信息。静态方法和确知只有一个接收者的虚拟方法能够被编译为对固定调用地址的直接调用(direct call)。通常来讲,这能够大大加快执行速度。
假设应用程序是使用C++开发的,对代码生成器来讲,在编译时已经能够获取到程序的全部结构性信息。例如,因为在程序运行过程当中,代码不会发生变化,因此在编译时就能够从代码中判断出,某个虚拟方法是否只有一种实现。正因如此,编译器不只不须要由于废弃代码而记录额外的信息,还能够将那些只有一种实现的虚拟方法转化为静态调用。
假如应用程序是使用Java开发的,起初某个虚拟方法可能只有一种实现,但Java容许在程序运行过程当中修改方法实现。当JIT编译器须要编译某个虚拟方法时,更喜欢的是那些永远只存在一种实现的,这样编译器就能够像前面提到的C++编译器同样作不少优化,例如将虚拟调用转化为直接调用。可是,因为Java容许在程序运行期间修改代码,若是某个方法没有声明final修饰符,那它就有可能在运行期间被修改,即便它看起来几乎不可能有其余实现,编译器也不能将之优化为直接调用。
在Java世界中,有一些场景如今看起来一切正常,编译器能够大力优化代码,可是若是某天程序发生了改变的话,就须要将相关的优化所有撤销。对于Java来讲,为了可以媲美C++程序的执行速度,就须要一些特殊的优化措施。
JVM使用的策略就是“赌”。JVM代码生成策略的假设条件是,正在运行的代码永远不变。事实上,大部分时间里确实如此。但若是正在运行的代码发生了变化,违反了代码优化的假设条件,就会触发其簿记系统(bookkeeping system)的回调功能。此时,基于原先假设条件生成的代码就须要被废弃掉,从新生成,例如为已经转化为直接调用的虚拟调用从新生成相关代码。所以,“赌输”的代价是很大的,但若是“赌赢”的几率很是高,则从中得到的性能提高就会很是大,值得一试。
通常来讲,JVM和JIT编译器所作的典型假设包括如下几点:
有些时候,对Java源代码作优化会拔苗助长。绝大部分写出可读性不好的代码的人都声称是为了优化性能,其实就是照着一些基准测试报告的结论写代码,而这些性能测试每每只涉及了字节码解释执行,没有通过JIT编译器优化,因此并不能表明应用程序在运行时的真实表现。例如,某个服务器端应用程序中包含了大量对数组元素的迭代访问操做,程序员参考了那些报告中的结论,没有设置循环条件,而是写一个无限for循环,置于try语句块中,并在catch语句块中捕获ArrayIndexOutOfBoundsException异常。这种糟糕的写法不只使代码可读性极差,并且一旦运行时对之优化编译的话,其执行效率反而比普通循环方式低得多。缘由在于,JVM的基本假设之一就是“异常是不多发生的”。基于这种假设,JVM会作一些相关优化,因此当真的发生异常时,处理成本就很高。
在生成优化代码时,如何分配寄存器很是重要。编译器教材上都将寄存器分配问题做为图的着色问题处理,这是由于同时用到的两个变量不能共享同一个寄存器,从这点上讲,与着色问题相同。同时使用的多个变量能够用图中相链接的节点来表示,这样,寄存器分配问题就能够被抽象为“如何为图中的节点着色,才能使相连节点有不一样的颜色”。这里可用颜色的数量等于指定平台上可用寄存器的数量。不过,遗憾的是,从计算复杂性上讲,着色问题是NP-hard的,也就是说如今尚未一个高效的算法(指能够在多项式时间内完成计算)能解决这个问题。可是,着色问题能够在线性对数时间内给出近似解,所以大多数编译器都使用着色算法的某个变种来处理寄存器分配问题。
通常来讲,为对象分配内存时,并不会直接在堆上划份内存,而是先在线程局部缓冲(thread local buffer)或其余相似的结构中找地方放置对象,而后随着应用程序的运行、新对象的不断分配,垃圾回收逐次执行,这些对象可能最终会被提高到堆中保存,也有可能会看成垃圾被释放掉。
为了可以在堆中给新建立的对象找一个合适的位置,内存管理系统必须知道堆中有哪些地方是空闲的,即尚未存活对象占用。内存管理系统使用空闲列表(free list)—串联起内存中可用内存块的链表,来管理内存中可用的空闲区域,并按照某个维度的优先级排序。
在空闲列表中搜索足够存储新对象的空闲块时,能够选择大小最适合的空闲块,也能够选择第一个放得下的空闲块。这其中会用到几种不一样的算法去实现,各有优劣,后文会详细讨论。
在后文中,根集合(root set)专指上述搜索算法的初始输入集合,即开始执行引用跟踪时的存活对象集合。通常状况下,根集合中包括了由于执行垃圾回收而暂停的应用程序的当前栈帧中全部的对象,包含了能够从当前线程上下文的用户栈和寄存器中能获得的全部信息。此外,根集合中还包含全局数据,例如类的静态属性。简单来讲就是,根集合中包含了全部无须跟踪引用就能够获得的对象。
Java使用的是准确式垃圾回收器(exact garbage collector),能够将对象指针类型数据和其余类型的数据区分开,只须要将元数据信息告知垃圾回收器便可,这些元数据信息,通常能够从Java方法的代码中获得。
近些年,使用信号来暂停线程的方式受到颇多争议。实践发现,在某些操做系统上,尤以Linux为例,应用程序对信号的使用和测试很不到位,还有一些第三方的本地库不遵照信号约定,致使信号冲突等事件的发生。所以,与信号相关的外部依赖已经再也不可靠。
事实上,将堆划分为两个或多个称为代(generation)的空间,并分别存放具备不一样长度生命周期的对象,能够提高垃圾回收的执行效率。在JRockit中,新建立(young)的对象存放在称为新生代(nursery)的空间中,通常来讲,它的大小会比老年代(old collections)小不少,随着垃圾回收的重复执行,生命周期较长的对象会被提高(promote)到老年代中。所以,新生代垃圾回收和老年代垃圾回收两种不一样的垃圾回收方式应运而生,分别用于对各自空间中的对象执行垃圾回收。
新生代垃圾回收的速度比老年代快几个数量级,即便新生代垃圾回收的频率更高,执行效率也仍然比老年代垃圾回收强,这是由于大多数对象的生命周期都很短,根本无须提高到老年代。理想状况下,新生代垃圾回收能够大大提高系统的吞吐量,并消除潜在的内存碎片。
在实现分代式垃圾回收时,大部分JVM都是用名为写屏障(write barrier)的技术来记录执行垃圾回收时须要遍历堆的哪些部分。当对象A指向对象B时,即对象B成为对象A的属性的值时,就会触发写屏障,在完成属性域赋值后执行一些辅助操做。
写屏障的传统实现方式是将堆划分红多个小的连续空间(例如每块512字节),每块空间称为卡片(card),因而,堆被映射为一个粗粒度的卡表(card table)。当Java应用程序将某个对象赋值给对象引用时,会经过写屏障设置脏标志位(dirty bit),将该对象所在的卡片标记为脏。
这样,遍历从老年代指向新生代的引用时间得以缩短,垃圾回收器在作新生代垃圾回收时只须要检查老年代中被标记为脏的卡片所对应的内存区域便可。
JRockit不只将卡表应用于分代式垃圾回收,还用在并发标记阶段结束时的清理工做,避免搜索整个存活对象图。这是由于JRockit须要找出在执行并发标记操做时,应用程序又建立了哪些对象。修改引用关系时经过写屏障能够更新卡表,存活对象图中的每一个区域使用卡表中的一个卡片表示,卡片的状态能够是干净或者脏,有新对象建立或者对象引用关系修改了的卡片会被标记为脏。在并发标记阶段结束时,垃圾回收器只须要检查那些标记为脏的卡片所对应的堆中区域便可,这样就能够找到在并发标记期间新建立的和被更新过引用关系的对象。
在JRockit中,使用了名为线程局部分配(thread local allocation)的技术来大幅加速对象的分配过程。正常状况下,在线程内的缓冲区中为对象分配内存要比直接在须要同步操做的堆上分配内存快得多。垃圾回收器在堆上直接分配内存时是须要对整个堆加锁的,对于多线程竞争激烈的应用程序来讲,这将会是一场灾难。所以,若是每一个Java线程可以有一块局部对象缓冲区那么绝大部分的对象分配操做只须要移动一下指针便可完成,在大多数硬件平台上,只须要一条汇编指令就好了。这块转为分配对象而保留的区域,就称为线程局部缓冲区(thread local area,TLA)。
为了更好地利用缓存,达到更高的性能,通常状况下,TLA的大小介于16KB到128KB之间,固然,也能够经过命令行参数显式指定。当TLA被填满时,垃圾回收器会将TLA中的内容提高到堆中。所以,能够将TLA看做是线程中的新生代内存空间。
当Java源代码中有new操做符,而且JIT编译器对内存分配执行高级优化以后,内存分配的伪代码以下所示:
object allocateNewobject(Class objectclass){
Thread current getcurrentThread():
int objectSize=alignedSize(objectclass)
if(current.nextTLAOffset+objectSize> TLA_SIZE){
current.promoteTLAToHeap();//慢,并且是同步操做
current.nextTLAOffset=0;
}
Object ptr= current.TLAStart+current.nextTLAOffset:
current.nextTLAOffset + objectSize;
return ptr:
}
复制代码
为了说明内存分配问題,在上面的伪代码中省略了不少其余关联操做。例如若是待分配的对象很是大,超过了某个阈值,或对象太大致使没法存放在TLA中,则会直接在堆中为对象分配内存。
NUMA(non-uniform memory access,非统一内存访问模型)架构的出现为垃圾回收带来了更多挑战。在NUMA架构下,不一样的处理器核心一般访问各自的内存地址空间,这是为了不因多个CPU核心访问同一内存地址形成的总线延迟。每一个CPU核心都配有专用的内存和总线,所以CPU核心在访问其专有内存时速度很快,而要访问相邻CPU核心的内存时就会相对慢些,CPU核心相距越远,访问速度越慢(也依赖于具体配置)传统上,多核CPU是按照UMA(uniform memory access,统一内存访问模型)架构运行的,全部的CPU核心按照统一的模式无差异地访问全部内存。
为了更好地利用NUMA架构,垃圾回收器线程的组织结构应该作相应的调整。若是某个CPU核心正在运行标记线程,那么该线程所要访问的那部分堆内存最好可以放置在该CPU的专有内存中,这样才能发挥NUMA架构的最大威力。在最坏状况下,若是标记线程所要访问的对象位于其余NUMA节点的专有内存中,这时垃圾回收器一般须要一个启发式对象移动算法。这是为了保证使用时间上相近的对象在存储位置上也能相近,若是这个算法可以正确工做,仍是能够带来不小的性能提高的。这里所面临的主要问题是如何避免对象在不一样NUMA节点的专有内存中重复移动。理论上,自适应运行时系统应该能够很好地处理这个问题。
内存分配是经过操做系统及其所使用的页表完成的。操做系统将物理内存划分红多个页来管理,从操做系统层面讲,页是实际分配内存的最小单位。传统上,页的大小是以4KB为基本单位划分的,页操做对进程来讲是透明的,进程所使用的是虚拟地址空间,并不是真正的物理地址。为了便于将虚拟页面转换为实际的物理内存地址,可以使用名为旁路转换缓冲(translation lookaside buffer,TLB)的缓存来加速地址的转换操做。从实现上看,若是页面的容量很是小的话,会致使频繁出现旁路转换缓冲丢失的状况。
修复这个问题的一种方法就是将页面的容量调大几个数量级,例如以MB为基本单位。现代操做系统广泛倾向于支持这种大内存页机制。
很明显,当多个进程分别在各自的寻址空间中分配内存,而页面的容量又比较大时,随着使用的页面数量愈来愈多,碎片化的问题就愈发严重,像进程要分配的内存比页面容量稍微大一点的状况,就会浪费不少存储空间。对于在进程内本身管理内存分配回收、并有大量内存空间可用的运行时来讲,这不算什么问题,由于运行时能够经过抽象出不一样大小的虚拟页面来解决。
一般状况下,对于那些内存分配和回收频繁的应用程序来讲,使用大内存页能够使系统的总体性能至少提高10%。 JRockit对大内存页有很好的支持。
低延迟的代价是垃圾回收总体时间的延长。相比于并行垃圾回收,在程序运行的同时并发垃圾回收的难度更大,而频繁中断垃圾回收则可能带来更多的麻烦。事实上,这并不是什么大问题,由于大多数使用JRockit Real Time的用户更关心系统的可预测性,而不是减小垃圾回收的整体时间。大多数用户认为暂停时间的忽然增加比垃圾回收整体时间的延长更具危害性。
软实时是JRockit Real Time的核心机制。但非肯定性系统如何提供指定程度的肯定性,例如像垃圾回收器这样的系统如何保证应用程序的暂停时间不会超过某个阈值?严格来讲,没法提供这样的保证,但因为这样的极端案例不多,因此也就可有可无了。
固然,没有什么万全之策,确实存在没法保证暂停时间的场景。但实践证实,对于那些堆中存活对象约占30%-50%的应用程序来讲, JRockit Real Time的表现能够知足服务须要,并且随着JRockit Real Time各个版本的发行,30%-50%这个阈值在不断提高,可支持的暂停时间阈值则不断下降。
事实上,实现低延迟的关键还是尽量多让Java应用程序运行,保持堆的使用率和碎片化程度在一个较低的水平。在这一点上, JRockit Real Time使用的是贪心策略,即尽量推迟STW式的垃圾回收操做,但愿问题可以由应用程序自身解决,或者可以减小不得不执行STW式操做的状况,最好在具体执行的时候须要处理的对象也尽量少一些。
JRockit Real Time中,垃圾回收器的工做被划分为几个子任务。若是在执行其中某个子任务时(例如整理堆中的某一部份内存),应用程序的暂停时间超过了阈值,那么就放弃该子任务恢复应用程序的执行。用户根据业务须要指定可用于完成垃圾回收的整体时间,有些时候,某些子任务已经完成,但没有足够的时间完成整个垃圾回收工做,这时为了保证应用程序的运行,不得不废弃还未完成的子任务,待到下次垃圾回收的时候再从新执行,指定的响应时间越短,则废弃的子任务可能越多。
前面介绍过的标记阶段的工做比较容易调整,能够与应用程序并发执行。但清理和整理阶段则须要暂停应用程序线程(STW)。幸运的是,标记阶段会占到垃圾回收整体时间的90%。若是暂停应用程序的时间过长,则不得不终止当前垃圾回收任务,从新并发执行,指望问题能够自动解决。之因此将垃圾回收划分为几个子任务就是为了便于这一目标的实现。
Java中的析构函数的设计就是一个失误,应避免使用。
这不只仅是咱们的意见,也是Java社区的一致意见。
对于JVM来讲,必定谨记,编程语言只能提醒垃圾回收器工做。就Java而言,在设计上它自己并不能精确控制内存系统。例如,假设两个ⅣM厂商所实现软引用在缓存中具备相同的存活时间,这本就是不切实际的。
另一个问题就是大量用户对System.gc()方法的错误使用。System.gc()方法仅仅是提醒运行时“如今能够作垃圾回收了”。在某些JVM实现中,频繁调用该方法致使了频繁的垃圾回收操做,而在某些JVM实现中,大部分时间忽略了该调用。
我过去任职为性能顾问期间,屡次看到该方法被滥用。不少时候,只是去掉对 System.gc方法的几回调用就能够大幅提高性能,这也是 JRock中会有命令行参数-xx:AllowSystemGC=False来禁用System,gc方法的缘由。
部分开发人员在写代码时,有时会写一些“通过优化的”的代码,指望能够帮助完成垃圾回收的工做,但实际上,这只是他们的错觉。记住,过早优化是万恶之源。就Java来讲,很难在语言层面控制垃圾回收的行为。这里的主要问题时,开发人员误觉得垃圾回收器有固定的运行模式,并妄图去控制它。
除了垃圾回收外,对象池(object poll)也是Java中常见的伪优化(false optimization)。有人认为,保留一个存活对象池来从新使用已建立的对象能够提高垃圾回收的性能,但实际上,对象池不只增长了应用程序的复杂度,还很容易出错。对于现代垃圾收集器来讲,使用java.lang.ref.Reference系列类实现缓存,或者直接将无用对象的引用置为null就行了,不用多操心。
事实上,基于现代VM,若是可以合理利用书本上的技巧,例如正确使用java.lang.ref.Reference系列类,注意Java的动态特性,彻底能够写出运行良好的应用程序。若是应用程序真的有实时性要求,那么一开始就不应用Java编写,而应该使用那些由程序员手动控制内存的静态编程语言来实现应用程序。
须要注意的是,花大力气鼓捣JVM参数并不必定会使应用程序性能有多么大的提高,并且反而可能会干扰JVM的正常运行。
每一个对象都持有与同步操做相关的信息,例如当前对象是否做为锁使用,以及锁的具体实现等。通常状况下,为了便于快速访问,这些信息被保存在每一个对象的对象头的锁字(lock word)中。JRockit使用锁字中的一些位来存储垃圾回收状态信息,虽然其中包含了垃圾回收信息,可是本书仍是称之为锁字。
对象头还包含了指向类型信息的指针,在 JRockit中,这称为类块(class block)下图是 JRockit中Java对象在不一样的CPU平台上的内存布局。为了节省内存,并加速解引用操做,对象头中全部字的长度是32位。类块是一个32位的指针,指向另外一个外部结构,该结构包含了当前对象的类型信息和虚分派表(virtual dispatch table)等信息。
原子操做(atomic operation)是指所有执行或所有不执行的本地指令。当原子指令所有执行时,其操做结果须要对全部潜在访问者可见。
原子操做用于读写锁字,具备排他性,这是实现JVM中同步块的基础。
死锁是指两个线程都在等待对方释放本身所需的资源,结果致使两个线程都进入休眠状态。很明显,它们再也醒不过来了。活锁的概念与死锁相似,区别在于线程在竟争时会采起主动操做,但没法获取锁。这就像两我的面对面前进,在一个很窄的走廊相遇,为了能继续前进,他们都向侧面移动,但因为移动的方向相反致使仍是没法前进。
在Java中,关键字synchronized用于定义一个临界区,既能够是一段代码块,也能够是个完整的方法,以下所示:
public synchronized void setGadget(Gadget g){
this.gadget = g;
}
复制代码
上面的方法定义中包含synchronized关键字,所以每次只能有一个线程修改给定对象的gadget域。
在同步方法中,监视器对象是隐式的,即当前对象this,而对静态同步方法来讲,监视器对象是当前对象的类对象。上面的示例代码与下面的代码是等效的:
public void setGadget(Gadget g){
synchronized(this){
this.gadget = g;
}
}
复制代码
Java中的线程也有优先级概念,可是否真的起做用取决于JVM的具体实现。setPriority方法用于设置线程的优先级,提示JVM该线程更加剧要或不怎么重要。固然,对于大多数JVM来讲,显式地修改线程优先级没什么大帮助。当运行时“有更好的方案”时, JRockit JVM甚至会忽略Java线程的优先级。
正在运行的线程能够经过调用yield方法主动放弃剩余的时间片,以便其余线程运行,自身休眠(调用wait方法)或等待其余线程结束再运行(调用join方法)。
在多线程环境下,对某个属性域或内存地址进行写操做后,其余正在运行的线程未必能当即看到这个结果。在某些场景中,要求全部线程在执行时须要得知某个属性最新的值,为此,Java提供了关键字volatile来解决此问题。
使用volatile修饰属性后,能够保证对该属性域的写操做会直接做用到内存中。本来,数据操做仅仅将数据写到CPU缓存中,过一会再写到内存中,正因如此,在同一个属性域上,不一样的线程可能看到不一样的值。目前,JVM在实现volatile关键字时,是经过在写属性操做后插入内存屏障代码来实现的,只不过这种方法有一点性能损耗。
人们经常难以理解“为何不一样的线程会在同一个属性域上看到不一样的值”。通常来讲,目前的机器的内存模型已经足够强,或者应用程序的自己结构就不容易使非volatile属性出现这个问题。可是,考虑到JIT优化编译器可能会对程序作较大改动,若是开发人员不留心的话,仍是会出现问题的。下面的示例代码解释了在Java程序中,为何内存语义如此重要,尤为是当问题还没表现出来的时候。
public class My Thread extends Thread{
private volatile boolean finished;
public void run(){
while(!finished){
//
}
}
public void signalDone(){
this.finished = true
}
}
复制代码
若是定义变量finished时没有加上volatile关键字,那么在理论上,JIT编译器在优化时,可能会将之修改成只在循环开始前加载一次finished的值,但这就改变了代码本来的含义若是finished的值是false,那么程序就会陷入无限循环,即便其余线程调用了signalDone方法也没用。Java语言规范指明,若是编译器认为合适的话,能够为非 volatile变量在线程内建立副本以便后续使用。
因为通常会使用内存屏障来实现volatile关键字的语义,会致使CPU缓存失效,下降应用程序总体性能,使用的时候要谨慎。
如今CPU架构中,广泛使用了数据缓存机制以大幅提高CPU对数据的读写速度,减轻处理器总线的竞争程度。正如全部的缓存系统同样,这里也存在一致性问题,对于多处理器系统来讲尤为重要,由于多个处理器有可能同时访问内存中同一位置的数据内存模型定义了不一样的CPU,在同时访问内存中同一位置时,是否会看到相同的值的状况。
强内存模型(例如x86平台)是指,当某个CPU修改了某个内存位置的值后,其余的CPU几乎自动就能够看到这个刚刚保存的值。在这种内存模型之下,内存写操做的执行顺序与代码中的排列顺序相同。弱内存模型(例如IA-64平台)是指,当某个CPU修改了某个内存位置的值后其余的CPU不必定能够看到这个刚刚保存的值(除非CPU在执行写操做时附有特殊的内存屏障类指令),更广泛的说,全部由Java程序引发的内存访问都应该对其余全部CPU可见,但事实上却不能保证当即可见。
从计算机最底层CPU结构来讲,同步是使用原子指令实现的,各个平台的具体实现可能有所不一样。以x86平台为例,它使用了专门的锁前缀(lock prefix)来实现多处理器环境中指令的原子性。
在大多数CPU架构中,标准指令(例如加法和减法指令)均可以实现为原子指令。
在微架构( micro- architecture)层面,原子指令的执行方式在各个平台上不尽相同。通常状况下,它会暂停CPU流水线的指令分派,直到全部已有的指令都完成执行,并将操做结果刷入到内存中。此外,该CPU还会阻止其余CPU对相关缓存行的访问,直到该原子指令结束执行。在现代x86硬件平台上,若是屏障指令(fence instruction)中断了比较复杂的指令执行,则该原子指令可能须要等上不少个时钟周期才能完成执行。所以,不只是过多的临界区会影响系统性能锁的具体实现也会影响性能,当频繁对较小的临界区执行加锁、解锁操做时,性能损耗更是巨大。
Java字节码中有两条用于实现同步的指令,分别是monitorenter和monitorexit,它们都会从执行栈中弹出一个对象做为其操做数。使用javac编译源代码时,若遇到显式使用监视器对象的同步代码,则为之生成相应的monitorenter指令和monitorexit指令。
默认状况下, JRockit使用一个小的自旋锁来实现刚膨胀的胖锁,只持续很短的时间。乍看之下,这不太符合常理,但这么作确实是颇有益处的。若是锁的竟争确实很是激烈,而致使线程长时间自旋的话,能够使用命令行参数-XX:UseFatSpin=false禁用此方式。做为胖锁的一部分,自旋锁也能够利用自适应运行时获取到的反馈信息,这部分功能默认是禁用的,能够使用命令行参数-XX:UseAdaptiveFatSpin=true来开启。
如何分析不少线程局部的解锁,以及从新加锁的操做只会下降程序执行效率?这是不是程序运行的常态?运行时是否能够假设每一个单独的解锁操做实际上都是没必要要的?
若是某个锁每次被释放后又马上都被同一个线程获取,则运行时能够作上述假设。但只要有另外某个线程试图获取这个看起来像是未被加锁的监视器对象(这种状况是符合语义的),这种假设就再也不成立了。这时为了使这个监视器对象看起来像是一切正常,本来持有该监视器对象的线程须要强行释放该锁。这种实现方式称为延迟解锁,在某些描述中也称为偏向锁(biased locking)。
即便某个锁彻底没有竞争,执行加锁和解锁操做的开销仍旧比什么都不作要大。而使用原子指令会使该指令周围的Java代码都产生额外的执行开销。
从以上能够看出,假设大部分锁都只在线程局部起做用而不会出现竞争状况,是有道理的。在这种状况下,使用延迟解锁的优化方式能够提高系统性能。固然,天下没有免费的午饭,若是某个线程试图获取某个已经延迟解锁优化的监视器对象,这时的执行开销会被直接获取普通监视器对象大得多,由于这个看似未加锁的监视器对象必需要先被强行释放掉所以,不能一直假设解锁操做是没必要要的,须要对不一样的运行时行为作针对性的优化。
1.实现
实现延迟解锁的语义其实很简单。
实现 monitorenter指令。
实现monitorexit指令:若是是延迟加锁的对象,则什么也不作,保留其已加锁状态,即执行延迟解锁。
为了能解除线程对锁的持有状态,必需要先暂停该线程的执行,这个操做有不小的开销。在释放锁以后,锁的实际状态会经过检查线程栈中的锁符号来肯定。延迟解锁使用本身的锁符号,以表示“该对象是被延迟锁定的”。
若是延迟锁定的对象历来也没有被撤销过,即全部的锁都只在线程局部内发挥做用,那么使用延迟锁定就能够大幅提高系统性能。但在实际应用中,若是咱们的假设不成立,运行时就不得不一遍又一遍地释放已经被延迟加锁的对象,这种性能消耗实在承受不起。所以,运行时须要记录下监视器对象被不一样线程获取到的次数,这部分信息存储在监视器对象的锁字中,称为转移位(transfer bit)。
若是监视器对象在不一样的线程之间转移的次数过多,那么该对象、其类对象或者其类的全部实例均可能会被禁用延迟加锁,只会使用标准的胖锁和瘦锁来处理加锁或解锁操做。
正如以前介绍过的,对象首先是未加锁状态的,而后线程T1执行monitorenter指令,使之进入延迟加锁状态。但若是线程T1在该对象上执行了monitorexit指令,这时系统会伪装已经解锁了,但实际上还是锁定状态,锁对象的锁字中仍记录着线程T1的线程ID。在此以后线程T1若是再执行加锁操做,就不用再执行相关操做了。
若是另外一个线程T2试图获取同一个锁,则以前所作“该锁绝大部分被线T1程使用”的假设再也不成立,会受到性能惩罚,将锁字中的线程ID由线程T1的ID替换为线程T2的。若是这状况常常出现,那么可能会禁用该对象做为延迟锁,并将该对象做为普通的瘦锁使用。
永远不要使用Thread.stop方法、Thread.resume方法或Thread.suspend方法并当心处理使用这些方法的历史遗留代码。
广泛建议使用wait方法、notify方法或volatile变量来作线程间的同步处理。
若是对内存模型和CPU架构缺少理解的话,即便使用平遇到问题。如下面的代码为例,其目的是实现单例模式。
public class Gadget Holder{
private Gadget theGadget;
public synchronized Gadget cetGadget(){
if (this.theGadget == null){
this.theGadget = new Gadget();
}
return this.theGadget;
}
}
复制代码
上面的代码是线程安全的,由于getGadget方法是同步但当Gadget类的构造函数已经执行过一次以后,再执行同优化性能,将之改造为下面的代码。
public Gadget getGadget(){
if (this.theGadget == null){
synchronized(this){
if(this.theGadget == null)){
this.theGadget = new Gadget();
}
}
}
return this.theGadget;
}
复制代码
上面的代码使用了一个看起来很“聪明”的技巧,若是行同步操做,而是直接返回已有的对象;若是对象还未建立值。这样能够保证“线程安全”。
上述代码就是所谓的双检查锁(double checked locking),下面分析一下这段代码的问题。假设某个线程通过内层的空值检查,开始初始化theGadget字段的值,该线程须要为新对象分配内存,并对theGadget字段赋值。但是,这一系列操做并非原子的,且执行顺序没法保证。若是在此时正好发生线程上下文切换,则另外一个线程看到的theGadget字段的值多是未经完整初始化的,有可能会致使外层的控制检查失效,并返回这个未经完整初始化的对象。不只仅是建立对象可能会出问题,处理其余类型数据时也要当心。例如,在32位平台上,写入一个long型数据一般须要执行两次32位数据的写操做,而写入int数据则无此顾虑。
上述问题能够经过将 theGadget字段声明为 volatile来解决(注意,只在新版本的内存模型下才有效),增长的执行开销尽管比使用synchronized方法的小,但仍是有的。若是不肯定当前版本的内存模型是否实现正确,不要使用双检查锁。网上有不少文章介绍了为何不该该使用双检查锁,不只限于Java,其余语言也是。
双检查锁的危险之处在于,在强内存模型下,它不多会使程序崩溃。Intel IA-64平台就是个典型示例,其弱内存模型臭名远扬,本来好好运行的Java应用程序却出现故障。若是某个应用程序在x86平台运行良好,在x64平台却出问题,人们很容易怀疑是JVM的bug,却忽视了有多是Java应用程序自身的问题。
使用静态属性来实现单例模式能够实现一样的语义,而无须使用双检查锁,以下所示:
public class GadgetMaker{
public static Gadget theGadget= new Gadget();
}
复制代码
Java语言保证类的初始化是原子操做, GadgetMaker类中没有其余的域,所以,在首次主动使用该类时会自动建立 Gadget类的实例。并赋值给theGadget字段。这种方法在新旧两种内存模型下都可正常工做。
总之,使用Java作并行程序开发有不少须要当心的地方,若是可以正确理解Java内存模型那么是能够避开这些陷阱的。开发人员每每不太关心当前的硬件架构,但若是不能理解Java内存模型,早晚会搬起石头砸本身的脚。
Java是一门强大的通用编程语言,因其友好的语义和内建的内的开发进度,但Java不是万能的,这里来谈谈不宜使用Java解决的场景:
除了上面的示例外,还有其余不少场景不适宜使用Java。经过JvM对底层操做系统的抽象Java实现了“一次编写,处处运行”,也所以受到了普遍关注。但夸大一点说,ANSI C也能作到这一点,只不过在编写源代码时,要花不少精力来应对可移植性问题。所以要结合实际场景选择合适的工具。Java是好用,但也不要滥用。
我的微信公众号:
我的github:
我的博客: