深入理解java虚拟机

字数 18875阅读 115

第二部分 自动内存管理机制

第二章 java内存异常与内存溢出异常

运行数据区域

程序计数器:当前线程所执行的字节码的行号指示器。字节码解释器通过改变这个计数器的值来选择下一条需要执行额字节码指令。每个线程有一个独立的计数器(线程私有的),记录当前程序执行的位置,线程切换以此来恢复正确执行位置。线程执行java方法,那么计数器记录指令地址,native方法,则计数器为空;

java虚拟机栈:即我们所说的栈内存,是属于线程私有的。通过栈帧存储java方法执行的内存模型:局部变量表、操作数帧、动态链接、方法出口等信息。每一个方法从调用到执行完成,对应着一个栈帧从虚拟机栈入栈出栈的过程。局部变量表存放各种基本数据类型(int、byte、short、long等)、对象引用、returnAddress类型;

本地方法栈:为虚拟机提供native方法服务。

java堆:线程共享的一块内存区域,几乎所有对象实例都要在此分配内存。此区域也是垃圾收集器管理的主要区域,俗称GC堆。java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可;

方法区:线程共享的内存区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据;

运行时常量池:方法区的一部分,保存class文件中描述的符号引用,直接引用和各种字面量;

直接内存:非虚拟机运行数据区的一部分,但是NIO类会用到。它使用native函数库直接分配堆外内存,通过java堆中的DirectByteBuffer对象对此内存的引用进行操作,提高性能,避免java堆和native堆来回复制数据;

HotSpot 虚拟机对象探秘

对象的创建:虚拟机遇到new指令,首先检查指令参数能否定位到常量池中的一个类的符号引用并检查该符号引用所代表的类是否已被加载、解析和初始化过。如果没有,则执行类加载过程。类加载完后,为新生对象分配内存,其所需内存大小在类加载完后已完全确定。分配方式有两种,a:内存规整,采用指针碰撞方式,即已分配和未分配内存是完整的两部分,此时两者中间使用一个指针作为分界点,分配时只需将指针向未分配内存移动一段空间即可。b:内存使用不连续,使用内存和空闲内存相互交错,采用空闲列表方式。内存是否规整取决于虚拟机采用的垃圾回收策略,serial、parnew垃圾收集时,系统采用指针碰撞,mark-sweep系统采用空闲列表。并发情况下内存分配是非线程安全的,解决办法,a:虚拟机采用CAS配上失败重试的方式保证更新操作的原子性。b:把内存按照线程划分到不同空间进行,称为本地线程分配缓冲(TLAB)。内存分配是在TLAB上进行的,TLAB用完并分配新的时才需要同步锁定。内存分配完,虚拟机初始化内存空间为零。接下来,虚拟机设置对象头信息。最后执行类的方法,将对象进行初始化;

对象的内存布局:对象内存布局分为3个区域:对象头、实例数据、对其填充。对象头分为两部分,一部分用于记录自身运行时数据,另一部分用于记录类型指针,虚拟机通过它来确定对象属于哪个类实例;实例数据主要是存储对象的有效信息,包括从父类继承的;对其填充不是必须的,仅仅是占位符的作用

对象的访问定位:目前主流的访问方式有使用句柄直接指针。使用句柄java堆需要划分一块内存作为句柄池,java栈中的reference存储的是对象的句柄地址,而句柄中包含了对象实例数据和类型数据的具体地址信息,此种方法优点在对象移动时只需改变句柄中实例数据指针,reference本身不需要修改。使用直接指针访问,reference存储的是对象地址,优点节省一次指针定位的时间开销(sun hotspot虚拟机使用此方式)。

java堆溢出:堆的最小值和最大值设为一样可避免堆自动扩展。堆溢出解决方案:通过内存映像分析工具对Dump出的堆转储快照进行分析,确认是内存泄漏还是内存溢出。如果是内存泄漏,需要查看泄漏对象到GC Roots的引用链,进而找到泄漏对象为啥无法让gc自动回收。如果不存在泄漏,即对象都还活着,那么需要检查虚拟机的堆参数(-Xms和-Xmx)是否还可以调大,从代码上检查是否某些对象的生命周期过长,持有状态过长的情况。

java虚拟机栈和本地方法栈溢出:hotspot不区分虚拟机栈和本地方法栈。栈容量对与hotSpot来说只由-Xss参数设定,如果线程请求的栈深度大于虚拟机允许的最大深度则抛出stackOverflowError异常,虚拟机在扩展栈容量是无法申请到内存空间抛出OutOfMemoryError异常。虚拟机默认参数配置,栈深度在1000~2000.可以通过减少堆容量和栈容量来换取更多线程。

方法区和运行时常量池溢出:常量池是方法区的一部分,可以通过-XX:PermSize和-XX:MaxPermSize限制方法区大小。溢出则抛出OutOfMemoryError: PermGen space异常。大量jsp或动态产生jsp文件的应用,基于OSGi的应用,使用CGLib字节码增强和动态语言都容易产生此异常

第三章 垃圾收集器与内存分配策略

对象已死吗?

引用计数算法:给对象添加一个引用计数器,每当有一个地方引用它,计数器加1,引用失效,计数器减一;任何时刻计数器为0的对象就是不可能在被使用的。难以解决对象间互相循环引用的问题。

可达性分析算法:通过一系列的称为GC Roots的对象作为起始点,从该节点往下搜索所经过的路径称为引用链,当一个对象到GC Roots没有任何引用链相连时,则证明该对象是可回收的。可作为GC Roots的对象以下:虚拟机栈(栈帧中本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(native方法)引用的对象

再谈引用:强引用、软引用、弱引用、虚引用,强度越来越弱。new Object()强引用GC永远不会回收;软引用指有用但非必须。会在抛出内存溢出异常之前回收。弱引用非必须对象,会在下一次垃圾回收时回收。虚引用存在的唯一目的是能收到GC收集的系统通知。

生存还是死亡:对象判为死刑需要两次标记:1、没有与GC Roots相连的引用链。2、首先判断虚拟机是否需要执行finalize()方法,如果对象没用覆盖finalize方法或者finalize被虚拟机调用过,那么虚拟机都没必要执行finalize方法。否则对象被放置在一个叫做F-Queue的队列中,稍后虚拟机建立一个低优先级的Finalizer线程去执行finalize方法。如果在finalize方法里对象重新与GC Roots关联上,那么对象便复活。可以说finalize方法是对象逃脱死亡命运的最后一次机会,这种机会只有一次,因为同一个对象的finalize最多只会被虚拟机自动调用一次。

回收方法区:主要回收废弃常量和无用的类。废弃常量即没有任何地方引用这个常量,没有任何地方引用这个字面量。无用类条件:1、该类的所有实例都已被回收。2、加载该类的classLoader已被回收。3、对应的class对象没有任何地方引用,没法通过反射访问该类。满足以上条件,类可以被回收。

垃圾收集算法

标记-清除算法:标记所有需要回收的对象,统一回收。不足:1、效率问题。2、标记清除后产生大量的不连续内存碎片。

复制算法:将内存按容量划分为大小相等的两块,每次使用其中的一块,当一块用完后,将存活的对象复制到另一块上,然后将使用过的一次性清除,不需要考虑内存碎片等复杂情况。实现简单,效率高。代价内存缩小为了原来的一半,并且当对象存活率很高时也会进行回收导致效率变低。

标记-整理算法:标记所有需要回收的对象,让存活的对象向一端移动,然后清理掉另一端内存。

分代收集算法:将java堆分为新生代和老年代。新生代死亡对象很多,采用复制算法,每次只需复制少量对象即可完成收集。老年代存活对象较多,采用标记-清除或标记-整理算法来回收。

HotSpot的算法实现

枚举根节点:采用可达性分析可得到哪些对象需要回收,但是java堆和方法区太大,逐个检查耗时太久。另外,可达性分析还体现在“一致性(整个分析像是冻结在某个冻结点上)”上,因为不可以在分析过程中对象的引用关系还在不停地变化,这就需要暂停所有执行线程。由此,jvm采用了叫OopMap 的数据结构来存储引用的位置,可通过此直接或许GC所需信息。

安全点:由于引用关系不断发生变化,jvm不可能每次都生成对应的OopMap,为此,jvm考虑两个因素。第一个因素采用只在“特定的位置”生成OopMap,这些位置称为安全点。安全点的选择以程序“是否具有让程序长时间执行的特征”来选定,例如:方法调用、循环跳转、异常跳转等;另一个因素:如果在GC发生时让所有线程在安全点上暂停。方案:抢占式中断和主动式中断。普遍jvm采用抢断式。GC发生时,让所有线程中断,如果有线程中断的位置不在安全点,则恢复线程,待其执行到安全点中断。

安全区域:在此区域的任意地方开始GC都是安全的。

线程执行到安全点时,开始执行GC,阻塞所有线程,扫描OopMap找出需要回收的对象信息,执行回收操作。

垃圾收集器

serial收集器:是一个单线程新生代收集器,在垃圾收集的时候必须停止所有的工作线程,直到收集结束。

ParNew收集器:serial收集器的多线程版本 。除Serial收集器外,目前只能它和CMS老年代收集器配合工作,在多线程环境,收集器建议使用此,可通过-XX:+ParallelGCThreads参数来限制垃圾收集的线程数。并行(Parallel):指多条垃圾收集线程并行工作。并发(Concurrent):指多个线程同时交替执行。

Parallel Scavenge 收集器:新生代收集器,多线程,采用复制算法收集。最大特点控制系统吞吐量。吞吐量就是CPU运行用户代码时间与(运行用户代码时间+垃圾手机时间)的比值。其他收集器是尽可能的缩短用户线程的停顿时间,适合需要与用户交互的程序,而此收集器目的是提高吞吐量,适用于后台运算而不需要太多的交互。可通过-XX:MaxGCPauseMillis设置停顿时间、-XX:GCTimeRatio设置吞吐量大小,如果此参数为19,那么允许GC时间为5%(1/(1+19))。默认值99。可通过-XX:+UseAdaptiveSizePolicy设置为真,那么系统会自适应调节新生代和老年代比例等细节参数。此特点也是此收集器和ParNew收集器的一个重要区别。

Serial Old 收集器:Serial收集器的老年代版本,使用标记-整理算法。两大用途:1、在JDK1.5及之前版本和Parallel Scavenge配合使用。2、作为CMS收集器的后备预案。

Parallel Scavenge Old收集器:Parallel Scavenge 收集器的老年代版本。注重系统提高吞吐量。使用多线程和标记-整理算法。在注重吞吐量以及CPU资源敏感的场合,优先考虑Parallel Scavenge +Parallel Scavenge Old收集器。

CMS(Concurrent Mark Sweep)收集器:一种以获取最短回收停顿时间为目标的收集器。回收过程分为4个步骤:初始标记、并发标记、重新标记、并发清除。初始标记记录GC Roots关联了哪些对象,并发标记进行GC Roots Tracing过程,重新标记则是修正并发期间程序运行导致标记变动的记录,这三个步骤均需要停顿,不过并发标记时间最长。重新标记次之,初始最短。CMS看似完美,但是还有三个缺点:1、CMS对CPU资源非常敏感,因为要保证回收线程和用户线程并发执行,所以收集器会占用一部分CPU资源可能会导致用户程序变慢,吞吐量下降。CMS默认启动的线程数:(CPU 数量+3)/4,由此看出,CMS所占用的CPU资源会随着CPU的数量增加而下降。为此,虚拟机提供了一种“增量式并发收集器”,让并发线程和用户线程交替执行,这样使得整个垃圾收集时间变长,但是堆用户程序影响变小,但效果并不好。2、CMS无法处理浮动垃圾(并发清理时用户线程产生的垃圾),并且为了保证收集时用户线程正常运行,所以CMS不能等到老年代内存用尽后在进行收集,需要预留一部分空间提供并发收集时用户线程使用,这就牵扯到回收时的触发比例,可通过-XX:CMSInitiatingOccupancyFraction的值来设置此比例,此值太高容易导致大量“Concurrent Mode Failure”失败,性能反而下降。如果CMS回收期间预留的内存空间不够,虚拟机会出现“Concurrent Mode Failure”,临时启用Serial Old收集器来回收老年代。3、CMS采用标记-清除算法实现收集器,导致内存中出现大量碎片,碎片过多会导致给对象分配内存时无法找到连续空间,于是不得不触发一次Full GC(会进行碎片整理)。为此,CMS收集器可通过-XX:+UseCMSCompactAtFullCollection 在内存顶不住要进行FullGC时进行内存整理,但是会增加停顿时间。另外还有一个参数-XX:CMSFullGCsBeforeCompaction可以设置执行多次不压缩的Full GC后跟着一次带压缩的(默认是0,代表每次Full GC都会进行碎片整理)

G1(garbage first) 收集器:G1与其他收集器相比,具有以下特点:1、并行与并发。2、分代收集。3、空间整合,G1从整体来看是采用标记-整理算法实现,从局部(两个Region之间)来看是基于“复制”算法实现。意味着不会产生碎片。4、可预测停顿。G1不会再有明显的物理级别的新生代和老年代概念,他将整个java堆划分为多个大小相等的独立区域(region),老年代和新生代都是多个region集合,非连续的。G1在后台维护一个优先列表,里面存储着各个Region里面垃圾堆积的价值大小,收集发生时,优先收集价值大的。由于不同Region可能存储对象引用,为了避免虚拟机进行全堆扫描,G1为每个Region增加了一个Remembered Set,用于存储不同Region间互相引用问题。这样在进行GC标记时,将Remembered Set 加入GC Roots根节点便可避免全堆扫描。G1的回收步骤:1、初始标记。2、并发标记。3、最终标记。4、筛选回收。前三个步骤和CMS差不多,但是在最终标记时虚拟机需要修改并发标记一些对象的引用发生变化并将其记录在线程的Remembered Set Logs里,然后合并到Remembered Set中。

垃圾收集器参数总结:

内存分配与回收策略

新生代GC(Minor GC):指发生在新生代的垃圾收集动作。

老年代GC(Major GC/Full GC):指发生在老年代的GC。

对象优先在Eden分配:如果Eden内存不够会触发一次Minor GC。1个Eden区和2个Survivor区(分别叫from和to)。默认比例为8:1。一般情况下,新创建的对象都会被分配到Eden区(一些大对象特殊处理),这些对象经过第一次Minor GC后,如果仍然存活,将会被移到Survivor区。对象在Survivor区中每熬过一次Minor GC,年龄就会增加1岁,当它的年龄增加到一定程度时,就会被移动到年老代中。在GC开始的时候,对象只会存在于Eden区和名为“From”的Survivor区,Survivor区“To”是空的。紧接着进行GC,Eden区中所有存活的对象都会被复制到“To”,而在“From”区中,仍存活的对象会根据他们的年龄值来决定去向。年龄达到一定值(年龄阈值,可以通过-XX:MaxTenuringThreshold来设置)的对象会被移动到年老代中,没有达到阈值的对象会被复制到“To”区域。经过这次GC后,Eden区和From区已经被清空。这个时候,“From”和“To”会交换他们的角色,也就是新的“To”就是上次GC前的“From”,新的“From”就是上次GC前的“To”。不管怎样,都会保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到“To”区被填满,“To”区被填满之后,会将所有对象移动到年老代中。新生代内存大小通过-Xmn设置。

大对象直接进入老年代:所谓大对象就是需要占用连续内存空间的java对象,典型的大对象就是那种很长的字符串以及数组。应该尽量避免这种情况。可通过-XX:PretenureSizeThreshold参数设置晋升老年代门槛,大于此值得对象直接进入老年代,此参数只对Serial和ParNew有效。

长期存活的对象将直接进入老年代:新生代对象每经过一次Minor GC都会增加1岁,可通过-XX:MaxTenuringThreshold设置晋升老年代的门槛。

动态对象年龄判定:为了更好的适应不同程序的内存状况,虚拟机并不是永远等到对象年龄到达指定阈值才会进入老年代,而是如果在survivor空间中所有对象相同年龄大小总和大约空间一半,那么大于或等于这个年龄的对象都将进入老年代。

空间分配担保:每次Minor GC之前,虚拟机会首先检查老年代最大可用连续空间大小是否大于新生代所有对象总大小,如果是,那么Minor GC是安全的。否则,虚拟机会检查HandlerPromotionFailure的值是否是允许担保,如果允许担保,那么虚拟机会检查老年代最大连续可用空间大小是否大于新生代中历次晋升老年代对象的平均大小,如果大于,那么会尝试进行一次Minor GC;如果小于或者不允许担保,那么虚拟机会进行一次Full GC。jdk6之后,HandlePromotionFailure参数已不再用

第四章 虚拟机性能监控与故障处理工具

JDK的命令行工具

jps 虚拟机进程状况工具:功能和Unix命令的ps类似,列出正在运行的虚拟机进程,并显示虚拟机执行主类以及进程的唯一ID(LVMID)。

jstat 虚拟机统计信息监视工具:监视虚拟机各种运行状态信息。

jinfo java配置信息工具:实时地查看和调整虚拟机各项参数。

jmap java内存映像工具:生成堆转储快照

jhat 虚拟机堆转储快照分析工具:一般不用,一般使用IBM Heap Analyzer,EclipseMemory Analyzer。

jstack java堆栈跟踪工具:生成虚拟机当前时刻的线程快照。目的定位线程出现长时间停顿,如线程死锁,死循环等

第五章 调优案例分析与实战

案例分析

高性能硬件上的程序部署策略

用户A部署一个web服务器,分配堆内存-Xms和-Xmx均为12G,但是效果并不理想,经常出现卡顿。原因虚拟机运行在server模式,使用吞吐量优先的GC收集器,一个Full GC高达12s。并且由于用户程序原因,很多大对象均跑到老年代,导致Minor GC根本不会清理,那么老年代内存消耗殆尽,一次Full GC很慢。

在高性能硬件上部署一般两种方式:1、通过64位JDK使用大内存。2、使用若干个32为虚拟机建立逻辑集群来利用硬件资源。

如果是第一种方式,那么应该尽可能的把Full GC 的频率控制的很低,要实现这个目标,那么可以通过减少应用程序中生存时间较长的大对象的数量。存在以下问题:

1、内存回收导致长时间停顿。

2、现阶段64位性能普遍低于32位

3、需要保证程序稳定,防止出现堆溢出。

4、相同程序64位内存消耗严重,因为由于指针膨胀和数据类型对齐补白等导致。

并不推荐使用。

如果是第二种方式,做法是在一台物理机上启动多个应用服务器进程,每个服务器进程分配不同的端口号,然后前端搭建一个负载均衡器,以反向代理的方式来分配请求。均衡器算法可以根据sessionId分配。但是也有以下缺点:

1、避免节点竞争全局资源,容易出现并发问题。

2、难以高效的利用某些资源池,譬如连接池。一般每个节点建立自己的连接池,导致内内存开销,可以利用JNDI,但是会带来额外的性能开销

3、每个节点都会受到32位的内存限制。

4、大量使用本地缓存的应用,在逻辑集群造成较大内存浪费,可以考虑把本地缓存改为集中式缓存。

堆外内存导致的溢出错误

java NIO操作会使用到Direct Memory内存。这部分内存是不属于虚拟机运行区域的,只是会在虚拟机发生Full GC的时候顺便清理这部分内存。但是Nio操作过多会导致这部分内存溢出。除此之外,还有一些区域也会占用较多的内存。如下:

1、Direct Memory:可通过-XX:MaxDirectMemorySize调整大小,内存不足时抛出OutOfMemeoryError异常

2、线程堆栈:可通过-Xss调整,内存不足抛出StackOverflowError(无法分配新的栈帧)或者OutOfMemoryError:unable create new native thread异常。

3、Socket 缓存区:每个Socket连接连接都有receive和send两个缓存区,连接多的话这部分内存占用量也很大,如果无法分配内存,会抛出IOExeception:too many open files异常。

4、JNI代码:如果代码中使用JNI调用本地库,那么本地库使用的内存也不再堆中。

5、虚拟机和GC也会占用部分内存。

外部命令调用导致系统缓慢

应用程序通过java的Runtime.getRuntime().exec()调用外部shell脚本,虽然能达到目的,但是非常消耗cpu和内存资源。Java虚拟机执行这个命令的过程是:首先克隆一个和当前虚拟机拥有一样环境变量的进程,再用这个新的进程去执行外部命令,最后退出这个进程,频繁执行,系统消耗很大

第三部分 虚拟机执行子系统

第六章 类文件结构

概述

class 文件的前4个字节为魔数,确定这个文件是否为一个能被虚拟机接受的Class文件。后面的4个字节分别是Class的主版本号和次版本号

常量池:主要存放两大类常量,字面量和符号引用。字面量如文本字符串、声明为final的常量等。符号引用指的是类和接口的全限定名、字段的名称和描述符、方法的名称和描述符。

重载:在java中要重载一个方法,除了需要一个与原方法相同的简单名称之外,还必须拥有一个与原方法不同的特征签名,特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合。依靠返回值是不可以的,因为他不在特征签名里

字段表集合:用于描述接口或者类中声明的变量。包括类级变量和实例级变量,但不包括方法内部声明的局部变量。

Code属性:java程序方法体中的代码经过Javac编译器编译后,最终变成字节码指令存储在Code属性内。max_stack代表操作数栈;max_locals代表局部变量表所需的存储空间;code_length和code用来存储Java源程序编译后生成的字节码指令。

LocalVariableTable属性:描述栈帧中局部变量和java源码中定义的变量之间的关系。非程序运行必须的,但是如果没有,会影响用户对局部变量的引用导致找不到对应变量。

ConstantValue属性:通知虚拟机自动为静态变量赋值。对于非static类型的变量的实例化是在实例构造器中进行。对于static类型变量,如果该变量同时还被final修饰,并且是基本数据类型或者是String类型,就会生成ConstantValue属性来初始化,如果没有被final修饰或者非基本类型或字符串,那么会在方法中初始化。

字节码指令简介

java虚拟机指令由一个字节长度的、代表着某种特定操作含义的数字(称为操作码,Opcode)以及跟随其后的零至多个代表此操作所需参数(称为操作数,Operands)构成。大多数指令均不包含操作数。

同步指令:java虚拟机支持方法级的同步和方法内部一段指令序列的同步。这两种同步结构都是使用管程来支持的。其中方法级的同步是隐式的,无需通过字节码指令来控制,他实现在方法调用和返回操作之中。虚拟机可以从方法常量池的方法表结构中的ACC_SYNCHRONIZED访问标识得知一个方法是否声明为同步方法。当方法被调用时,调用指令将会检查方法的访问标识ACC_SYNCHRONIZED访问标识是否被设置,如果设置,执行线程会持有管程,然后执行方法,不论正常完成还是非正常完成,都会释放管程。同步一段指令集序列通常是由synchronized语句块来表示。java虚拟机的指令集中使用monitorenter和monitorexit来支持synchronized关键字,两者必须匹配出现,。为了保证在方法出现异常时该指令依然能成对出现,编译器自动产生一个异常处理器在monitorexit后面用于保证该指令会释放管程。

第七章 虚拟机类加载机制

类加载的时机

Class文件中描述的各种信息,最终都需要加载到虚拟机中才能运行和使用。虚拟机将描述类的数据从Class文件中加载到内存,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。

类的生命周期包括:加载、验证、准备、解析、初始化、使用、卸载七个阶段。

加载、验证、准备、初始化、卸载这几个步骤必须按部就班的进行,但是解析可能在初始化前也可能在初始化之后,取决于java运行时动态绑定还是晚期绑定。对于类加载虚拟机并没有进行强制约束,但是对于初始化阶段,虚拟机严格规定了有且只有以下五种情况必须进行初始化

1、遇到new、getstatic、putstatic、或invokestatic这四条字节码指令时,生成这4条指令最常见场景:使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译器把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。

2、使用java.lang.reflect包的方法对类进行反射调用的时候。

3、当初始化一个子类的时候。

4、执行一个类的主方法(main(String[] args))的时候。

5、当使用jdk1.7的动态语言支持,如果一个java.long.invoke.MethodHandle实例最后解析结果是REF_getStatic、REF_pubStatic、REF_invokeStatic的方法句柄时。

以下情况不会导致类被初始化:

1、通过子类名引用父类的静态字段,不会导致子类初始化,只有直接定义这个字段的类才被初始化。

2、通过数组定义来引用类,不会触发此类初始化。例如:Test[] t = new Test[2];

3、常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会导致定义常量的类的初始化。

例如:A{static final String hello = “sf”}

B{public static void main(String args[]){System.out.print(A.hello)}}

上面的代码并不会导致A被加载,在编译期间,hello的值已经被存储到了B的常量池中,两个类已经没有任何联系。

类加载的过程

加载:加载时类加载过程的一个阶段。加载阶段虚拟机需要完成以下三件事情:

1、通过一个类的全限定名来获取定义此类的二进制字节流。

2、将这个字节流所代表的静态存储结构转换为方法区的运行时数据结构。

3、在内存中生成一个代表此类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

验证:主要是确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。整体上来看主要完成以下4个阶段的检验动作:文件格式验证、元数据验证、字节码验证、符号引用验证。验证阶段不一定是非必须的。可通过-Xverify:none参数关闭验证,缩短虚拟机类加载时间。

准备:准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这里的内存都将在方法区中分配,所分配的仅包括类变量(被static修饰的变量)不包括实例变量,实例变量将在对象实例化的时分配在java堆中。这里所说的初始值“通常情况”下是数据类型的零值。例如:public static int value = 123;变量value在准备阶段过后的初始值是0而不是123.因为此时尚未进行任何java方法,而把value赋值为123是通过putstatic指令,该指令是在程序编译后存放在类构造方法中,指令将在初始化阶段才会执行。当然还有一些“特殊情况”,如果类字段的字段属性表中存在ConstantValue属性,那么准备阶段变量value就会被初始化为ConstantValue属性所指定的值,public static final int value = 123;

解析:解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。对于符号引用和直接引用关系如下:

1、符号引用:以一组符号来描述所引用的目标,符号可以是以任何形式的字面量,只要使用时能无歧义的定位到目标即可。符号引用与虚拟机实现的内存布局无关,但是虚拟机所能接受的符号引用都是一致的。引用的目标并不一定都加载到内存中。通过符号引用是无法调用的目标的方法的。符号引用存放在常量池中。

2、直接引用:直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机和虚拟机的内存布局有关,直接引用存在,那么引用的目标一定存在在内存中。

解析阶段发生的时间虚拟机并没有给出具体时间,但是在执行anewarry、checkcase、getfield、getstatic、instanceof、invokedynamic、invokeinterface、invokespecial、invokestatic、invokevirual、ldc、ldc_w、multianewarry、new、putfield和put static这16个指令之前需要解析符号引用。对同一个符号引用进行多次解析很常见,但是除了invokedynamic之外,虚拟机会对第一次解析结果进行缓存,缓存结果存放在常量池中。引用的解析过程部分如下:类或接口的解析、字段解析、类方法解析、接口方法解析

初始化:初始化阶段是执行类构造器方法的过程。对于方法的生成有如下解释:

1、方法由编译器自动收集类中所有的类变量的赋值动作和静态语句块(static{})中的语句合并产生,编译器收集的顺序由语句在源代码中出现的顺序决定。静态语句块只能访问定义在静态语句之前的变量,之后的变量访问不到。

2、方法和类的构造方法不同,他不需要显示的调用父类构造方法,虚拟机会保证在子类的方法执行前,父类的方法已经执行完毕,因此虚拟机中的第一个被执行的方法的类一定是java.lang.Object。

3、由于父类的方法先执行,所有父类中定义的静态语句块有优先于子类执行。

4、方法对于类和接口来说并不是必需的,如果一个类中没有静态代码块,也没有静态变量赋值操作,那么编译器可以不为这个类生成方法。

5、接口中不允许使用静态语句块,但是可以存在静态变量的赋值操作,因为也会生成方法,但是执行接口的方法不会执行父类的方法,只有当调用父接口的变量时,父接口才会被初始化。另外,接口的实现类初始化时也不会调用接口的方法。

6、虚拟机会保持一个类的方法在多线程环境中被正确地加锁、同步,如果多个线程同时去初始化一个类,那么只有一个线程执行方法,其他线程都阻塞等待,直到方法执行完毕。其他线程虽然阻塞,但是当方法执行完毕后,其他线程被唤醒之后不会在去执行方法,同一个类加载器下,一个类只会被初始化一次。

类加载器

类与类加载器:实现类的加载动作,类在java虚拟机中的唯一性由类加载器和类本身确定。比较两个类是否相等,只有在这里两个类在由同一个类加载器加载的前提下才有意义,否则,即使两个类来源于同一个class文件,只要加载他们的类加载器不同,那么必定不相等。

双亲委派模型:类加载器可分为以下三类:

1、启动类加载器(Bootstrap ClassLoader):此类加载器负责将存放在\lib目录中的、或者被-Xbootclasspath参数指定的路径中的并且被虚拟机识别(仅按照文件名识别)的类库加载到虚拟机内存中。

2、扩展类加载器(Extension ClassLoader):这个加载器负责加载\lib\ext目录下或者被java.ext.dirs所指定的路径中的所有类库。

3、应用程序加载器(Application ClassLoader)这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称为系统类加载器,他负责加载用户类路径(ClassPath)上所指定的类库,如果应用程序没有指定类加载器,那么这个就是程序默认类加载器。

双亲委派模型工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都会传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成此加载请求时,子加载器才会尝试自己去加载。

使用此模型好处:java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如:java.lang.Object类存放于rt.jar中,无论哪一个类加载器要加载这个类,都要委派给模型最顶端启动类加载器进行加载,因此Object类在程序的各种加载器中都是同一个类。相反,如果没有使用双亲委派模型,由各个类自行去加载的话,如果用户自己编写了一个java.lang.Object类,并放在ClassPath下,那系统中会出现多个不同的Object类,导致应用程序会变得混乱。

第八章 虚拟机字节码执行引擎

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态链接和方法返回值等信息。每一个方法从开始执行到执行结束的过程,都对应着一个栈帧在虚拟机里面从入栈到出栈的过程。

栈帧所需内存大小在程序编译期都已被确定并且写入到方法表的Code属性的max_locals之中。

一个线程中的方法链可能会很长,可能很多方法都同时处于执行状态,但是对于执行引擎,只有位于栈顶的栈帧才是有效的,称为当前栈帧。

局部变量表(Loval Variable Table)  是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。局部变量表的容量以变量槽Slot为最小单位。对于32位虚拟机,32位类型的数据,虚拟机中一个Slot就能存放,但是对于64位类型数据,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。对于64位虚拟机,那么存放32位类型数据,虚拟机会以对齐和补白的手段让其看起来和32位虚拟机一致。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的Slot数量。如果是32位类型数据,那么索引n就代表使用第n个Slot,但是如果64位,那么索引n代表使用第n和n+1两个Slot。

方法执行时,如果执行的方法是实例方法(非static方法),那么局部变量表中的索引第0位默认为当前对象实例引用,即this关键字,其余的变量索引从1开始。

为了尽可能的节省栈帧空间,局部变量表中的Slot是可以重用的,因为方法体中定义的变量,作用域不一定覆盖整个方法体,当然,也存在副作用,影响垃圾回收。

操作数栈也叫操作栈,是一个后进先出的栈,操作数栈的最大深度在编译时就以确定并写到方法表的Code属性的max_stacks数据项里。操作数栈中每一个元素可以是任意java数据类型,32位数据类型所占的栈容量为1,64位栈容量所占的栈容量为2.方法执行时,任何时候操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

方法刚开始,操作栈是空的,方法执行中,会有各种字节码指令往操作栈里写入和读取内容,也就是入栈和出栈操作。例如:整数加法的字节码指令iadd在运行的时候操作栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行iadd指令时,会将这两个int值出栈并相加,然后将结果入栈。

在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。当时大多数虚拟机在实现里做了优化,让让两个栈帧出现一部分重叠,这样方法调用时避免额外的参数复制传递。

java虚拟机的解释执行引擎称为“基于栈的执行引擎”.

动态链接,每一个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,Class文件的常量池中存放着大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用是无法调用到方法的,一部分符号引用会在类加载阶段或者第一次使用的时候就转换为直接引用,这种称为静态解析。另外一部分会在每一次运行期间转换为直接引用,称为动态链接。

方法返回地址,当一个方法执行后,只有两种方式让此方法退出,第一种是执行引擎遇到任意一个方法返回的字节码指令,称为正常完成出口。另一种是方法遇到异常,并且方法没有在方法体内得到处理,称为异常完成出口。方法退出的过程可能执行的操作:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调用PC计数器的值指向方法调用指令后面的一条指令等。

方法调用

解析,所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将一部分符号引用转换为直接引用。在java语言中符号“编译器克制,运行期不变”的方法,主要包括静态方法和私有方法两大类,这两大类在类加载阶段进行解析。

其中,java虚拟机提供了5条方法调用的字节码指令,

invoke static :调用静态方法,invoke special:调用实例构造器方法,私有方法和父类方法,invoke virtual:调用所有的虚方法,invoke interface:调用接口方法,invoke dynamic:先在运行时动态解析出调用点限定符所引用的方法,然后在执行该方法。

只能被invoke static 和invoke special 指令调用的方法都可以在解析阶段确定唯一版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,在类加载时便将符号引用转换为直接引用,称为非虚方法。另外final修饰的方法也为非虚方法,但是使用invokevirtual指令调用。

静态分派,例如:Fruit f = new Apple()/父类Fruit称为变量静态类型,或者外观类型,apple称为变量的实际类型。静态类型是不会变化的,在编译期间就以确定,但是实际类型却是会变化的,只有在运行时才可确定。所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。典型应用为方法重载。静态分派发生在编译期间,所以静态分派的动作实际上不是有虚拟机执行的。

有种观点认为:重载是静态的,重写是动态的,所有只有重写才是多态性的体现。

动态分派即在运行期间根据对象的实际类型确定方法的执行版本的分派过程,一般指的是方法重写。具体步骤如下:

1、找到操作数栈中第一个元素所指向的对象的实际类型,记作C。

2、类型C在类加载的阶段已经被加载到内存中,如果在C中找到与常量池中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则转换为直接引用,查找过程结束;不通过,则报异常。

3、如果未找到匹配的方法,则按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。

4、如果始终没有找到方法,则抛出异常。

由于动态分派是非常频繁的动作,虚拟机基于性能考虑,在累的方法区中建立一个虚方法表。方法表一般在类加载的连接段进行初始化。

基于栈的解释器执行过程

第四部分 程序编译与代码优化

第10章 早期(编译器)优化

javac编译器

javac编译器有java语言编写,编译过程大致分为:解析与填充符号表过程、插入式注解处理器过程、语义分析与字节码生成过程。

解析过程中,词法分析会将源代码的字符流转变为标记(Token)集合,进而语法分析会根据Token序列构造出抽象语法树。接下来是填充符号表的过程,符号表是由一组符号地址和符号信息构成的表格。

语义分析过程会检查抽象语法树的结构是否符合逻辑。其中包括标注检查、数据及控制流分析,接下来解语法糖,最典型的就是泛型语法,自动装箱拆箱。最后是字节码生成,会将前面的语法树、符号表转化成字节码写到磁盘并在其中添加少量代码和转换。例如,添加实例构造器方法和类构造器。此步骤会有代码收敛,编译器会将语法块、变量初始化、调用父类的实例构造器收敛到方法和方法中。另外,对于字符串的+操作替换为StringBuilder的append等。

第11章 晚期(运行期)优化

解释器与编译器

JIT即时编译器:目前普遍的商用虚拟机,想hotspot使用的架构都是解释器与编译器JIT并存的架构。两个各有优势:当程序需要迅速启动和执行时,那么解释器会优先发挥作用,省去了编译的时候,立即执行,但是随着时间的推移,由于程序中的部分代码经常被调用,此时编译器会发挥作用,将经常被调用的代码编译为本地代码,可以提高运行效率。虚拟机使用混合的模式。是因为如果只使用编译器,由于编译时间会比较长,导致程序启动会变慢。但是编译完后,那么运行效率会比解释器高。

会被编译器编译的热点代码有:被多次调用的方法体和被多次调用的循环体。

逃逸分析:基本行为分析 对象的动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还会被外部线程访问到,称为线程逃逸。

如果一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到该对象,则可能为这个变量进行一些高效的优化。

栈上分配:如果在一个方法之内定义一个对象,该对象不会逃逸到方法之外,那让这个对象在栈上分配将会是一个很不错的主意,对象所占用的空间就可以随着栈帧出栈而销毁。

同步消除:如果确定一个变量不会逃逸到线程之外,那么同步就可以消除。

变量替换:标量是指一个数据已经无法再分解成更小的数据来表示了。java虚拟机中原始数据类型(int,long等以及reference)都不能再进一步分解,称为标量。相反如果一个数据可以继续分解,那么称为聚合量,java对象是典型的聚合量。如果一个对象经逃逸分析确定不会被外部访问并且可以拆散,那么该对象会被成员变量所替代,将其存储在栈上。

第五部分 高效并发

第12章 java内存模型与线程

java 内存模型的主要目标是定义程序中各个变量的访问规则,此处的变量只包括实力字段、静态字段和够成数组对象的元素,但是不包括局部变量和方法参数,因为这俩都是线程私有的,不会被共享,所以不会出现资源竞争问题。

java规定了所有的变量都存在主内存,每个线程都有自己的工作内存,其中保存了该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,不得直接读写主内存的变量。不同的线程间无法直接访问对方工作内存的变量,线程间变量值的传递均需要通过主内存来完成。

对于主内存、工作内存和java内存区域的堆、栈、方法区的关系勉强来说可以是主内存主要对应于java堆中的实例对象数据部分,而工作内存主要对应于虚拟机栈中的部分区域。更低层次来说,主内存直接对应于物理硬件的内存,而为了更好的获得速度,工作内存优先存储于寄存器和高速缓存中,因为程序运行主要读写的是工作内存。

内存间交互操作

关于主内存与工作内存间是如何交互的,java内存模型定义了8中原子操作:

如果要将一个变量从主内存复制到工作内存,那就要顺序的执行read和load操作,如果要把一个变量从工作内存同步回主内存,同样需要顺序的执行store和write操作。java虚拟机要求这两个操作必须是顺序的,但是中间可以插入其他操作。例如:对主内存变量a,b进行访问,可能出现顺序read a,read b, load b,load a。

另外java内存模型规定执行8中操作必须遵守如下规则:

volatile 型变量的特殊规则

一个变量被定义为volatile,具备两种特性,第一是保持次变量对所有线程的可见性,这里的可见性是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。具体为啥,后面可知。对于普通变量无法做到这一点,普通变量的值在线程间传递均需要通过主内存来完成。例如:线程A修改了一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成后再从主内存进行读写操作,新变量值才会被线程B可见。

由于volatile变量只能保证可见性,如果场景符合以下两种情况,我们可以使用此来定义变量,其他的我们还是通过加锁来保证原子性。

1、运算结果并不依赖变量的当前值或者能够确保只有单一线程对变量修改

2、变量不需要与其他的状态变量共同参与不变约束。

使用volatile变量的第二个语义是禁止指令重排序优化。普通的变量只会保证该该方法执行完后能得到正确地结果,不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。而volatile变量则会在指令序列中增加许多内存屏障,使用指令无法进行重排序。对于重排序,例如指令1把地址A中的值+10,指令2把地址A中的值乘以2,指令3把地址B中的值-3,重排时,由于指令1和指令2是相互依赖的,不会发生重排,但是指令3是独立的,可以在指令1和2间执行或者前面执行,其结果都不会受影响。

java内存模型对volatile变量定义的特殊规则。假定T表示一个线程,V和W分别表示两个volatile型变量,那么在进行read、load、use、assign、store、write操作时需满足如下规则:

对于long和double型变量的特殊规则

java内存模型对于8个操作都具有原子性,但是对于64位数据类型允许没有被定义为volatile修饰的数据的读写操作划分为两次32位操作进行,这就有可能导致如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对他们进行读取和修改操作,那么某些线程可能读取到一个既非原值,也不是其他线程修改的中间值。

不过这种情况非常罕见,因为在目前的商用虚拟机中,jvm选择吧这些操作实现为原子操作,所以一般不需要把double和long变量专门定义为volatile。

原子性、可见性和有序性

原子性:java内存模型直接保证的原子性变量操作包括read、load、use、assign、store和write,可以认为基本数据类型的访问读写具有原子性的。

如果需要更大范围的原子性保证,那么java内存模型定义了lock和unlock操作来满足需求,但是jvm并未开放给用户使用,而是使用monitorenter和monitorexit两个字节码指令来保证原子性,对应的java代码关键字—synchronized。

可见性:指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存的方式来实现可见性。volatile变量的特殊性在于:新值能立即同步到主内存,以及每次使用都会立即从主内存刷新,立即性是因为read、load、use和assign、store、write的一体性连续性。而普通变量由于存在主内存和工作内存同步延迟性。

除了volatile,java提供了synchronized和final也能实现变量的可见性。同步块 的可见性是由于“对一个变量执行解锁(unlock)之前,必须将对变量的修改同步回主内存中(执行store、write操作)”。而final的可见性是指被final修饰的变量一旦被初始化完成,并且构造器没有发生this引用逃逸,那么其他线程就能看到变量的值。

有序性:java程序中的有序性可以总结为:如果在本线程中观察,那么所有的操作都是有序的,如果从一个线程去观察另外一个线程,那么所有的操作都是无序的。有序是指线程内表现为串行的语义,无序是指指令重排序和工作内存与主内存同步延迟现象。

先行发生原则

可以利用先行发生原则判断数据是否发生竞争、线程是否安全。

先行发生是Java内存模型中定义的两项操作间的偏序关系,如果操作A先行发生于操作B,,那么操作A产生的影响能被操作B观察到,“影响”包括修改了内存中共享变量的值、发送了消息、方法调用等。

java内存模型定义了一些天然的先行发生的关系,这些关系无需任何同步器协助就已经存在。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,那么就没有顺序保证,虚拟机就可以对他们进行指令重排。

一个操作时间上的先行发生不代表这个操作会是先行发生,相反,一个操作先行发生也不一定是时间上的先行发生,例如,指令重排。同一个线程 int i = 1;int j= 2;

第13章 线程安全与锁

Brian Goetz给出的线程安全的定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确地结果,那么就是线程安全

java语言中按照线程操作共享数据分为不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。

不可变:被final修饰的对象一定是线程安全的。如果修饰的是基本数据类型,那么值一定是不可变的。如果修饰的是对象,那么对象的行为是不会影响其状态。其中String、Number的部分子类

绝对线程安全:绝对线程安全完全满足Brian Goetz给出的线程安全的定义:当多个线程访问一个对象时,如果不用考虑这些线程在运行环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方法进行任何其他的协调操作,调用这个对象的行为都可以获得正确地结果,那么就是线程安全。

相对线程安全:我们通常意义上所说的线程安全,他需要保证对这个对象单独的操作时是线程安全的,我们在调用时不需要进行额外的保障措施。

线程兼容:指对象本身不是线程安全的,但是可以通过调用端正确的使用同步手段来保证对象在并发环境下可以安全的使用。我们平常说的线程不安全,绝大多数说的就是这种情况。

线程对立:无论调用端是否采用了同步措施,都无法在多线程环境中并发使用的代码。

线程安全的实现方法

互斥同步:同步是指多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个(或者是一些,使用信号量的时候)线程使用。而互斥的实现方式有临界区、互斥量和信号量。其中,互斥是方法,同步是目的。

最基本的互斥同步手法就是synchronized关键字,经过编译后,会在代码块的前后分别形成monitorenter和monitorexit两个字节码指令,这两个指令都需要一个reference类型的参数来锁定。如果程序中指定了锁定的对象参数,那这个对象就是reference,如果没有,那么synchronized所修饰的实例方法所在的类对象就是多对象。

虚拟机规范要求,在执行monitorenter指令时,首先尝试获取对象的锁,若获取到,那么该对象锁的计数器+1,相应的,在执行monitorexit时,相应的对象锁计数器-1,当计数器为0时,才表明锁被释放,其他线程才可以获取该锁。对于这两个指令,synchronized同步块对于同一个线程来说是可以重入的,不会自己把自己锁死。

另外,我们还可以使用reentrantLock来同步,相比之下,ReentrantLock增加了几项高级功能:等待可中断、可实现公平锁、锁可以绑定多个条件。

等待可中断是指当前线程长时间持有锁时,其他线程可以在等待时间到后放弃等待转而去做的事情。

公平锁是指多个线程等待同一个锁时,必须按照申请获取锁的时间顺序来获得锁。而synchronized实现的锁是非公平的。

锁绑定多个条件是指一个ReentrantLock对象可以同时绑定多个Condition对象,而Synchronized中,锁对象的wait和notify()或notifyAll()只能实现一个隐含的条件。

但是基于性能考虑,目前优先提倡synchronized

非阻塞同步:随着硬件指令集的发展,有些需要使用互斥同步来保证原子性的操作现在已经不再需要。硬件保证一个从语义上看起来需要多次操作的行为只通过一条处理器指令就能完成。常用的有:测试并设置(test-and-set)、获取并增加(Fetch-and-Increase)、

交换(swap)、比较并交换(compare-and-swap,简称CAS)、加载链接/条件存储(load-linked/store-conditional,简称LL/SC)

jdk1.5之后,java可以使用CAS操作,该操作有sun.misc.Unsafe类里面的compareAndSwapInt()和compareAndSwapLong()等几个方法包装而成,虚拟机在内部队这些方法做了特殊处理,即使编译出来就是一条平台相关的处理器CAS指令,没有方法调用的过程。这种被处理器特殊处理的方法称为固有函数,例如:Math.sin()等

锁优化

jdk1.6之后,HotSpot实现了大量锁优化技术。例如:自旋锁、适应性自旋锁、锁清除、锁粗化、轻量级锁、偏向锁。

锁是怎么发生的,当程序进入同步代码块后,如果检查同步对象的锁标志位是否为01状态表明未被锁定,虚拟机首先在当前栈帧中建立一个名为锁记录(lock Record)的空间来存储对象目前的Mark Word的拷贝,然后虚拟机尝试CAS操作将对象的Mark Word更新为指向Lock Record的指针,如果成功,那么线程就拥有了该对象的锁,并且对象的Mark Word的锁标志位变为00,表示进入轻量级锁定状态,如果更新操作失败,虚拟机会检查是否当前对象的Mark Word指针已经指向当前线程栈帧,如果是,则表明已经拥有了该对象锁,直接进入方法运行,否则,表明锁已经被抢占。如果有两个以上线程争用同一个锁,那么轻量级锁会膨胀为重量级,锁的标志位变为10,Mark Word存储的是指向重量级锁的指针。

自旋锁:互斥同步对性能最大的影响就是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,于是给系统带来很大压力。由于在许多应用上,共享数据的锁定状态只会持续很短的时间,为此而去挂起和恢复线程并不值得。何况目前多数处理器都是多核,何不让多个线程并行执行,让后面的线程稍等一会操作共享数据,但是不放弃处理器的执行时间,看看持有锁的线程是否很快就释放锁,为了让线程稍等一会,只需让线程执行一个忙循环(自旋),这就是自旋锁。自旋锁默认自旋次数10次,可通过-XX:PreBlockSpin改变次数。jdk1.6之后默认是开启的,可通过-XX:+UseSpinning参数控制。

适应性自旋:意味着自旋的时间不在固定,而是根据前一次在同一个锁上的自旋时间及锁的拥有者状态来决定。

锁清除:指的是即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除。主要判断依据来源于逃逸分析的数据支持。

锁粗化:如果频繁对同一个对象进行加锁和释放锁,那么虚拟机会进行扩大锁的范围。

轻量级锁:在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量的锁的机制。轻量级锁利用的是对象的头信息里的Mark Word,里面存储着指向锁记录的指针(轻量级)和指向重量级锁的指针。提升性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”

偏向锁:目的消除数据在无竞争情况下的同步,提高性能。利用对象头的Mark Word的偏向线程ID标记,如果某个线程被标记了。那么该线程下次访问数据将不需要任何同步操作。然而如果另外一个线程访问,那么偏向模式结束。

推荐阅读更多精彩内容