《深入理解Java虚拟机》学习笔记
本文是我自己在秋招复习时的读书笔记,整理的知识点,也是为了防止忘记,尊重劳动成果,转载注明出处哦!如果你也喜欢,那就点个小心心,文末赞赏一杯豆奶吧,嘻嘻。 让我们共同成长吧……
第一部分 走进Java
第1章 走进Java
1.1 概述
Java语言口号是:一次编写,到处运行!
Java的优点:结构严谨、面向对象;摆脱平台的束缚,一次编写到处运行;提供了相对安全的内存管理和访问机制;实现了热点代码检测和运行时编译及优化;一套完善的应用程序接口以及无数的第三方类库
1.2 Java技术体系
Sun定义的Java技术体系包括:Java程序设计语言、各种硬件平台的Java虚拟机、Class文件格式、Java API类库、来自商业机构和开源社区的第三方Java类库。
JDK是支持java开发的最小的环境,包括:Java程序设计语言、Java虚拟机、Java API类库三个部分;JRE是JavaAPI中的Java SE API的子集和Java虚拟机这两部分。
Java技术体系可分为4个平台:Java Card、Java ME、Java SE、Java EE
1.3 Java发展史
Java语言的前身是Oak,起源于1991年4月的James Gosling博士的绿色计划。
1995.5.23 Oak改名为Java,并发布Java 1.0版本,提出口号。
1996.1.23 JDK 1.0发布,提供了JVM的实现,Applet、AWT等;
1997.2.19 Sun发布JDK1.1,提供了JAR、JDBC、JavaBean、RMI,java语法的发展例如,内部类、反射等;
1998.12.4 JDK1.2,将java技术体系划分为3个方向:J2ME、J2SE、J2EE
1999.4.27 HotSpot虚拟机发布,随JDK1.2发布,作为1.3之后的默认虚拟机。
2000.5.8 JDK1.3,
2002.2.13 JDK1.4,正则表达式、异常连、NIO、日志类等
2004.9.30 JDK1.5,自动拆箱装箱、泛型、动态注解、枚举、可变长参数、foreach循环等,虚拟机改进了JMM,提供变法包等
2006.12.11 JDK1.6,提供编译API、微型HTTP服务器API等,虚拟机改进了锁与同步、垃圾收集、类加载等
2012.10.16 Java SE7 Update9 发布,提供G1收集器等
2013.9 JDK1.8发布,提供Lambda表达式。
1.4 Java虚拟机发展史
Sun Classic/Exact VM、Sun HotSpot VM、Sun Mobile-Embedded VM/Meta-Circular VM 、BEA JRockit/IBM J9 VM 、Azul VM/BEA Liquid VM、还有很多……
1.5 展望Java技术的未来
模块化、混合语言、多核并行、进一步丰富语法、64位虚拟机
1.6 实战:自己编译JDK
下载OpenJDK:https://jdk7.java.net/source.html
系统需求:Ubuntu 64位、5GB的磁盘、1G内存;
构建编译环境:需要Bootstrap JDK(JDK6以上)/Ant(1.7.1以上)/GCC。
进行编译:设置环境变量、make sanity检查、make编译、复制到JAVA_HOME、编辑env.sh
1.7 本章小结
本章介绍了Java技术体系的过去、现在以及未来的一些发展趋势,并独立编译一个OpenJDK 7的版本。
第二部分 自动内存管理机制
第2章 Java内存区域与内存溢出异常
2.1 概述
Java与C++之间有一面内存动态分配和垃圾收集技术所围成的墙。对于Java程序员来说,在虚拟机自动内存管理机制下,不需要为new操作去写配对的delete/free代码,不容易出现内存泄漏。但是如果出现内存泄漏问题,如果不了解虚拟机的机制,便难以定位。
2.2 运行时数据区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域,主要包括以下几个运行时数据区域:
1)程序计数器
程序计数器是当前线程所执行字节码的行号指示器。在虚拟机概念模型中,字节码解释器的工作是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。该部分内存区域是线程私有的内存。如果线程正在执行的是Java方法,这个程序计数器记录的是正在执行的虚拟机字节码指定的地址;如果是正在执行Native方法,这个程序计数器值为空。
此内存是唯一一个在java虚拟机规范中没有规定OutOfMemoryError情况的区域。
2)Java虚拟机栈
该区域也是线程私有的,其生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行时都会创建一个栈帧用于存局部变量表、操作数栈、动态链接、方法出口等信息。每个方法的调用到执行完成,就对应着一个栈帧在虚拟机栈中的入栈出栈的过程。
局部变量存放编译器可知的基本类型数据(boolean、byte、char、short、int、float、long、double)、对象引用、returnAddress类型。其中64位的long和double占2个局部变量空间(slot),其余占1个。局部变量表所需的空间在编译期间完成分配。
Java虚拟机规范对该区域规定了StackOverflowError异常和OutOfMemoryError异常。
3)本地方法栈
本地方法栈为虚拟机执行Native方法服务的,是线程私有的内存区域。Java虚拟机规范没有规定本地方法栈使用的语言、使用方式与数据结构。Sun HotSpot直接将本地方法栈和虚拟机栈合二为一。
Java虚拟机规范对该区域规定了StackOverflowError异常和OutOfMemoryError异常。
4)Java堆
Java堆是线程共享的,是java虚拟机管理的最大一块内存,在虚拟机启动时创建。该区域存放所有的对象实例以及数组。Java堆是垃圾回收器管理的主要区域。从内存回收角度,现在收集器采用分分代收集算法,将Java堆分为新生代和老年代;再细致点将新生代分为Eden空间、From Survivor空间和To Survivor空间等。从内存分配角度,线程共享的java堆可能划分多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLBA)。Java虚拟机规范规定,Java堆可以处于物理上不连续的内存空间,只要逻辑上连续即可。可通过-Xmx和-Xms进行控制堆内存大小。
Java虚拟机规范对该区域规定了OutOfMemoryError异常。
5)方法区
该区域是线程共享的,用于存储已被加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。尽管Java虚拟机规范规定该区域是堆的一个逻辑部分,但是它的别名叫Non-Heap(非堆),目的是与Java堆区分开。对于习惯HotSpot虚拟机开发、部署的开发者来说,将方法区成为永久代。不需要连续的内存空间,可以设置内存大小,还可以选择不同的垃圾收集。永久代用-XX:MaxPermSize设置。
Java虚拟机规范对该区域规定了OutOfMemoryError异常。
6)运行时常量池
运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译器生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。除了保存Class文件描述的符号引用外,还把翻译出来的直接引用存在运行时常量池。另外一个重要特征是具备动态性,可以在运行期间将新的常量放入池中,如String的intern方法。
Java虚拟机规范对该区域规定了OutOfMemoryError异常。
7)直接内存
直接内存不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。JDK 1.4的NIO引入了基于通道(Channel)和缓冲区(Buffer)的IO方法,可以使用Native函数库直接分配对外内存,然后通过一个存储在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作以提升性能。
2.3 HotSpot虚拟机对揭秘
为了进一步了解虚拟机内存中数据的其他细节,比如它们是如何创建、如何布局以及如何访问的。下面以虚拟机HotSpot和常用的内存区域Java堆为例,深入探讨HotSpot虚拟机在Java堆中对象分配、布局和访问的全过程。
1. 对象的创建
(1)虚拟机遇到一条new指令时,先检查指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载、解析和初始化过。如果没有,那必须先执行相应的类加载过程。
(2) 在类的加载检查后,接下来虚拟机为对象分配内存空间。对象所需的内存大小在类加载完成后便可确定。虚拟机可以采取“指针碰撞”或者“空闲列表”进行内存分配,选择哪种分配方法取决于Java堆是否规整,而java堆是否规整又由所采取的垃圾回收器是否带压缩整理功能决定。因此,Serial、ParNew、等带Compact过程的收集器采用指针碰撞分配,CMS采用空闲列表。在并发情况下,在对象分配内存时要注意同步。解决该问题有2种方案:一种是对分配内存空间动作进行同步处理——虚拟机采用CAS配上失败重试方式保证原子性;另一种是不内存分配动作按照线程划分为不同的空间进行,即每个线程在Java堆中预先分配一小块内存(TLAB),可通过-XX:+/UseTLAB设定。
(3)内存分配完后,虚拟机将分配到的内存空间都初始化为零值(不包括对象头),如果使用了TLAB,初始化工作是在TLAB分配时进行的。这保证了对象的实例字段在Java代码中可以不赋值就直接使用,程序能访问到这些字段数据类型对应的零值。
(4)接下来虚拟机要对对象进行必要的设置,即设置对象的对象头(Object Header)信息,包括对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象GC分代年龄等信息。
(5) 在上面的工作完成后,从虚拟机角度来看,一个新对象产生了,但是从Java程序角度来看,对象创建才刚刚开始。——<init>方法还没执行,所有字段还是零。接着执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正可用的对象才算完全产生出来;
HotSpot解释器的代码片段:略
2. 对象的内存布局
在HotSpot虚拟机中,对象在内存中的存储布局分为3块区域:对象头(Header)、实例数据(Instance Data)、对其填充(Padding)。
1. 对象头
HotSpot虚拟机的对象头包括2部分信息:
第一部分用于存储对象自身运行时数据(如哈希吗、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等),叫做“Mark Word”。
第二部分是类型指针,即对象指向它的类元数据的指针,虚拟机可以通过这个指针确定对象时哪个类的实例(并不是所有虚拟机都必须在对象数据上保留类型指针)。若是Java的数组,对象头中还要有一块存储数组长度的数据,因为虚拟机可以通过普通对象的元数据信息确定Java对象的大小,但是从数组的元数据中无法确定数组的大小。
2. 实例数据
是对象真正存储的有效信息,就是代码中所定义的各种类型字段的内容。不论是父类继承还是子类中定义的都要记录起来。HotSpot虚拟机默认分配策略:longs/doubles ints shorts/chars bytes/booleans oops(Ordinary Object Pointers),相同的字段总是分配在一起,在满足这个条件的情况下父类中定义的变量会出现在子类前。
3. 对其填充
不是必然存在的,没有特别含义,起到占位符的作用。由于HotSpot VM 自动内存管理系统要求对象的起始地址是8字节的整数倍,即对象的大小是8字节的整数倍。对象头正好是8字节整数倍,但是实例数据没有对齐时,使用该字段对齐。
3. 对象的访问定位
Java程序中需要通过栈中的reference数据操作堆上的具体对象。目前主流的访问方式有使用句柄和直接指针。
1.使用句柄
Java堆中划分出一块内存作为句柄池,reference中存储的是对象的句柄地址,而句柄中包含了对象实例数据与类型数据各自的具体地址信息。
2.直接访问对象
如果使用直接指针访问,那么Java堆对象分布中就必须考虑如何放置访问类型数据的相关信息,reference存储的直接就时对象地址。
这两种对象的访问方式各有优势,使用句柄访问最大的好处就是reference中存储的是稳定的句柄地址,在对象被移动时,只用修改句柄中的实例数据指针,而reference本身不需要修改。而直接访问对象方式的好处就是,减少一次指针定位的时间开销,由于对象的访问是非常频繁的,因此这类开销积少成多也是一项非常的执行成本。HotSpot使用的是第二种方式。
2.4 OutOfMemoryError异常
在Java虚拟机规范中,除了程序计数器之外,其他几个运行时区域都有发生OOM异常的可能。本节目的有二:
通过代码验证Java虚拟机规范中描述各个运行时区域存储的内容;
在实际遇到内存溢出异常时,能根据异常的信息快速判断是哪个区域内存溢出以及怎么处理。
1. Java堆溢出
Java堆用于存储对象实例,只要不断创建对象,并且保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象,那么在对象数量达到最大堆的容量限制之后就会产生内存溢出异常。
解决思路:先通过内存映像分析工具对dump出来的堆转储快照进行分析,先分清楚是内存泄漏还是内存溢出;如果是内存泄漏,进一步查看泄漏对象到GC Roots的引用链,从而确认为什么无法回收;如果是内存溢出,则应当检查虚拟机堆参数(-Xmx与-Xmx)或检查是否存在对象生命周期过长、持有状态时间过长的情况。
2. 虚拟机栈和本地方法栈溢出
HotSpot不区分虚拟机栈和本地方法栈。栈的容量只由-Xss参数设置。关于虚拟机栈和本地方法栈,在Java虚拟机规范中描述了两种异常:StackOverflowError和OutOfMemoryError。
虚拟机栈溢出
虚拟机的默认参数对于通常的方法调用(1000~2000层)完全够用,通常根据异常的堆栈日志就可以很容易定位问题。
3. 方法区和运行时常量池溢出
对于这个区域的测试,基本思路是运行时产生大量的类去填满方法区(比如使用反射和动态代理),这里我们借助CGLib直接操作字节码运行时产生大量的动态类(很对主流框架如Spring、Hibernate都会采用类似的字节码技术)。在这里需要特别注意垃圾回收的状况。
借助CGLib使方法区出现内存溢出异常1
借助CGLib使方法区出现内存溢出异常2
4. 本机直接内存溢出
本机直接内存溢出1
本机直接内存溢出2
DirectMemory导致的内存溢出,在Heap Dump里不会看见明显的异常。如果发现OouOfMemory之后Dump文件很小,程序又使用了NIO,那就可以检查下是否这方面的原因。
2.5 本章小结
学习了虚拟机的内存是如何划分的,对象是如何创建、布局和访问的,哪部分区域、什么样的代码和操作可能导致内存的溢出异常。
第3章 垃圾收集器与内存分配策略
3.1 概述
Java程序通过reference类型数据操作堆上的具体对象.在JVM层面的引用reference类型是引用类型(Reference Types)的一种;JVM规范规定reference类型来表示对某个对象的引用,可以想象成类似于一个指向对象的指针;对象的操作、传递和检查都通过引用它的reference类型的数据进行操作。在Java语言层面的引用如果reference类型的数据中存储的数值代表的是另外一块内存的起始地址,就称这块内存代表着一个引用。
3.2 对象已死吗
在垃圾收集器进行回收前,第一件事就是确定这些对象哪些还存活,哪些已经死去。
1、引用计数算法
引用计数算法是在对象中添加一个引用计数器,每当有一个地方引用它,计数器就加1;当引用失效,计算器值就减1;任何时刻计数器的值为0时该对象就是不可用的。
该方法实现简单,判定效率也很高。但是,java虚拟机中没有使用该方法,其中最主要的原因是该方法不能解决对象之间相互循环引用的问题。
2.可达性分析算法
该算法基本思想是:通过一系列被称为“GC Roots”的对象作为起始点,从该起始点开始向下搜索,搜索所走过的路径叫做引用链,当一个对象到GC Roots没有任何引用链时,则证明该对象是不可用的。
在Java中,可作为GC Roots对象包括:虚拟机栈(栈帧的本地变量表)中引用的对象、方法区中类静态属性引用的对象、方法区中常量引用的对象、本地方法栈中JNI(一般说的Native方法)引用的对象。
3. 再谈引用
JDK 1.2将Java的引用分为:强引用、软引用、弱引用、虚引用4种,强度依次减弱。
强引用:就是常见的引用,类似 Object o=new Object() 这类引用,只要强引用存在,垃圾收集器永远不会回收被引用的对象。
软引用: 描述一些还有用但非必需的对象。软引用关联的对象,在系统将要发生内存溢出之前,将会把这些对象列进回收范围之中进行第二次回收。如果这次回收没有足够的内存,才会发生内存溢出异常。提供SoftReference类实现软引用。
弱引用: 也用来描述非必需的对象,比软引用还要弱。该引用引用的对象只能生存到下一次垃圾收集器发生之前。当垃圾回收器工作时,不论内存是否足够都会回收被弱引用引用的对象。提供WeakReference类实现弱引用。
虚引用:又称幽灵引用或者幻影引用,最弱的引用关系。一个对象是否有该引用存在,完全不会对其生存时间构成影响,也无法通过该引用获得一个对象实例。唯一目的是:能在这个对象再被收集器回收时收到一个系统通知。提供PhantomReference类实现虚引用。
4. 对象生存还是死亡
要真正宣告一个对象死亡,至少要经历两次标记过程:
第一次标记:如果对象在进行可达性分析后发现到GC Roots没有任何引用链相连时,那么它将被第一次标记并且进行一次筛选,筛选的条件是此对象是否必要执行finalize()方法。当对象没有覆盖finalize()方法或者finalize()方法已经被JVM调用过,虚拟机认为这两种情况都是没必要执行,可以认为对象已死,可以回收。如果对象被判定有必要执行finalize()方法,那么这个对象将会被放入F-Queue队列中,并在稍后由一个由JVM自动建立的、低优先级的Finalizer线程执行它。
第二次标记:finalize()方法是对象逃脱死亡的最后一次机会,GC将对F-Queue队列中的对象进行第二次小规模标记。如果对象在其finalize()方法中重新与引用链上任何一个对象建立关联,第二次标记时会将其移出"即将回收"的集合;如果对象没有逃脱,也可以认为对象已死,可以回收了。
一个对象的finalize()方法只会被系统自动调用一次,经过finalize()方法逃脱死亡的对象,第二次不会再调用。
5. 回收方法区
方法区(HotSpot叫永久代)的垃圾回收主要回收:废弃常量和无用的类。
判定废弃常量比较简单,但是判定无用类比较苛刻。类要同时满足下面3个条件才能算无用的类:
1)该类的所有实例都被回收,就是java堆中不存在该类的任何实例
2)加载该类的ClassLoader已经被回收
3)该类的java.lang.Class对象没有被任何地方引用,无法在任何地方通过反射访问该类的方法
在大量使用反射、动态代理、CGLib等ByteCode框架、动态生成JSP以及OSGI这类频繁自定义ClassLoader的场景都需要虚拟机具备类卸载的功能(HotSpot提供-Xnoclassgc参数控制),以保证永久代不会溢出。
3.3. 垃圾收集算法
1、标记-清除算法
该算法分为“标记”和“清除”两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收掉所有被标记的对象。
它的主要缺点有两个:一个是效率问题,标记和清除过程的效率都不高;另外一个是空间问题,标记清除之后会产生大量不连续的内存碎片,空间碎片太多可能会导致,当程序在以后的运行过程中需要分配较大对象时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。
2、复制算法
为了解决效率问题,一种称为“复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对其中的一块进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。只是这种算法的代价是将内存缩小为原来的一半,未免太高了一点。
现在的商业虚拟机都采用这种收集算法来回收新生代,新生代中的对象98%是朝生夕死的,所以并不需要按照1∶1的比例来划分内存空间,而是将内存分为一块较大的Eden空间和两块较小的Survivor空间,每次使用Eden和其中的一块Survivor。当回收时,将Eden和Survivor中还存活着的对象一次性地拷贝到另外一块Survivor空间上,最后清理掉Eden和刚才用过的Survivor的空间。HotSpot虚拟机默认Eden和Survivor的大小比例是8∶1,也就是每次新生代中可用内存空间为整个新生代容量的90%(80%+10%),只有10%的内存是会被“浪费”的。当然,98%的对象可回收只是一般场景下的数据,我们没有办法保证每次回收都只有不多于10%的对象存活,当Survivor空间不够用时,需要依赖其他内存(这里指老年代)进行分配担保(Handle Promotion)。
3、标记-整理算法
复制收集算法在对象存活率较高时就要执行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况,所以在老年代一般不能直接选用这种算法。根据老年代的特点,有人提出了另外一种“标记-整理”(Mark-Compact)算法,标记过程仍然与“标记-清除”算法一样,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。
4、分代收集算法
当前商业虚拟机的垃圾收集都采用“分代收集”(Generational Collection)算法,一般是把Java堆分为新生代和老年代。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量存活对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清理”或“标记-整理”算法来进行回收。
3.4 HotSpot的算法实现
1.枚举根节点
由于可达性分析的GC Roots节点主要在全局性的引用(例如常量或类静态属性)与执行上下文(例如栈帧中的本地变量表)中 ,如果要逐个检查这里面的引用,那么必然会消耗很多时间。另外,可达性分析对执行时间的敏感还体现在GC停顿上 ,因为这项分析工作必须在一个能确保一致性的快照中进行,从而导致GC进行时必须要停顿所有Java执行线程。
由于目前的主流Java虛拟机使用的都是准确式GC ,所以当执行系统停顿下来后,并不需要一个不漏地检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得知哪些地方存放着对象引用。在HotSpot的实现中,是使用一组称为OopMap的数据结构来达到这个目的的,在类加载完成的时候 ,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定的位置记录下栈和寄存器中哪些位置是引用。这样 ,GC在扫描时就可以直接得知这些信息了。
2.安全点
HotSpot只在特定的位置记录了OopMap,这些位置称为安全点(SafePoint);
即程序执行时并非在所有地方都能停顿下来开始GC,只有到达安全点时才能暂停;
对于安全点基本上是以程序“是否具有让程序长时间执行的特征”(比如方法调用、循环跳转、异常跳转等)为标准进行选定的;
另外还需要考虑如果在GC时让所有线程都跑到最近的安全点上,有两种方案:抢先式中断和主动式中断(主流选择);
3、安全区域
如果程序没有分配CPU时间(如线程处于Sleep或Blocked),此时就需要安全区域(Safe Region),其是指在一段代码片段之中,引用关系不会发生变化。线程执行到安全区域时,首先标识自己已经进入了安全区域,这样JVM在GC时就不管这些线程了;
3.5 垃圾收集器
如果两个收集器之间存在连线,就说明它们可以搭配使用。
1、Serial收集器
特点:最基本、发展历史最悠久,在JDK 1.3之前是新生代收集的唯一选择;是一个单线程(并非指一个收集线程,而是会暂停所有工作线程)的收集器,采用的是复制算法;现在依然是虚拟机运行在Client模式下的默认新生代收集器,主要就是因为它简单而高效(对于限定单个CPU的环境来说,没有线程交互的开销);
2、ParNew收集器
特点:ParNew收集器其实就是Serial收集器的多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括Serial收集器可用的所有控制参数(例如:-XX:SurvivorRatio、 -XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、Stop The World、对象分配规则、回收策略等都与Serial收集器完全一样,实现上这两种收集器也共用了相当多的代码。
ParNew收集器是许多运行在Server模式下的虚拟机中首选的新生代收集器,其中有一个与性能无关但很重要的原因是,除了Serial收集器外,目前只有它能与CMS收集器配合工作。
3、ParallelScavenge收集器
特点:新生代收集器,使用复制算法,并行的多线程收集器;与其他收集器关注于尽可能缩短垃圾收集时用户线程停顿时间不同,它的目标是达到一个可控制的吞吐量;高吞吐量可以高效率利用CPU时间,适合在后台运算而不需要太多交互的任务;Parallel Scavenge收集器提供了两个参数用于精确控制吞吐量,分别是控制最大垃圾收集停顿时间的-XX:MaxGCPauseMillis参数及直接设置吞吐量大小的-XX:GCTimeRatio参数,而停顿时间缩短是以牺牲吞吐量和新生代空间来换取的;另外它还支持GC自适应的调节策略。
4、Serial Old收集器
特点:是Serial收集器的老年代版本,同样是单线程,使用标记-整理算法;主要是给Client模式下的虚拟机使用的;在Server模式下主要是给JDK 1.5及之前配合Parallel Scavenge使用或作为CMS收集器的后备预案;
5、Parallel Old收集器
特点:Parallel Old是ParallelScavenge收集器的老年代版本,使用多线程和“标记-整理”算法。直到Parallel Old收集器出现后,“吞吐量优先”收集器终于有了比较名副其实的应用组合,在注重吞吐量及CPU资源敏感的场合,都可以优先考虑Parallel Scavenge加Parallel Old收集器。
6、CMS收集器
CMS(Concurrent MarkSweep)收集器是一种以获取最短回收停顿时间为目标的收集器。目前很大一部分的Java应用都集中在互联网站或B/S系统的服务端上。从名字(包含“Mark Sweep”)上就可以看出CMS收集器是基于“标记-清除”算法实现的,整个过程分为4个步骤,包括:初始标记(CMS initial mark)、并发标记(CMS concurrent mark)、重新标记(CMS remark)、并发清除(CMS concurrent sweep)
其中初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记则是为了修正并发标记期间,因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录。由于整个过程中耗时最长的并发标记和并发清除过程中,收集器线程都可以与用户线程一起工作,所以总体上来说,CMS收集器的内存回收过程是与用户线程一起并发地执行的。
CMS有以下三个显著的缺点:CMS收集器对CPU资源非常敏感。CMS收集器无法处理浮动垃圾(FloatingGarbage),可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。CMS是一款基于“标记-清除”算法实现的收集器,这意味着收集结束时会产生大量空间碎片。
7 、G1收集器
G1是一款面向服务端应用的垃圾收集器,后续会替换掉CMS垃圾收集器;
特点:并行与并发(充分利用多核多CPU缩短Stop-The-World时间)、分代收集(独立管理整个Java堆,但针对不同年龄的对象采取不同的策略)、空间整合(基于标记-整理)、可预测的停顿(将堆分为大小相等的独立区域,避免全区域的垃圾收集);
关于Region:新生代和老年代不再物理隔离,只是部分Region的集合;G1跟踪各个Region垃圾堆积的价值大小,在后台维护一个优先列表,根据允许的收集时间优先回收价值最大的Region;Region之间的对象引用以及其他收集器中的新生代与老年代之间的对象引用,采用Remembered Set来避免全堆扫描;
如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为以下几个步骤:
初始标记(Initial Marking):并行,仅仅只是标记一下GC Roots能直接关联到的对象,并且修改TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的Region中创建新对象,这阶段需要停顿线程,但耗时很短;
并发标记(Concurrent Marking):并发,从GC Root开始对堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长;
最终标记(Final Marking):并行,为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程Remembered Set Logs里面,需要把Remembered Set Logs的数据合并到Remembered Set中,这阶段需要停顿线程,停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的时间短;
筛选回收(Live Data Counting and Evacuation):并行,首先对各个Region的回收价值和成本进行排序,然后根据用户所期望的GC停顿时间来制定回收计划。这个阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分Region,时间是用户可控制的,而且停顿用户线程将大幅提供收集效率;
8. 垃圾收集器参数总结
3.6 内存分配与回收策略
对象优先在新生代分配;
大对象直接进入老年代;
长期存活的对象将进入老年代;
动态对象年龄判断(如果在Survivor空间中相同年龄所有对象大小总和大于Survivor空间的一半,大于或等于该年龄的对象直接进入老年代);
空间分配担保(发生Minor GC前,虚拟机会先检查老年代最大可用连续空间是否大于新生代所有对象总空间,如果不成立,虚拟机会查看HandlePromotionFailure设置值是否允许担保失败,如果允许继续检查老年代最大可用的连续空间是否大于历次晋升到老年代的平均大小,如果大于会尝试进行一次Minor GC;如果小于或者不允许冒险,会进行一次Full GC)
3.7 本章小结
本章介绍了垃圾回收算法、几款JDK 1.7中提供的垃圾收集器特点以及运作原理。内存回收与垃圾收集器在很多时候都是影响系统性能、并发能力的主要因素之一,然而没有固定收集器和参数组合,也没有最优的调优方法,需要根据实践了解各自的行为、优势和劣势。
第4章 虚拟机性能监控与故障处理工具
4.1 概述
给一个系统 定位问题时,知识和经验是关键基础、数据(运行日志、异常堆栈、GC日志、线程快照、堆转储快照)是依据、工具是运用知识处理数据的手段。
4.2 JDK的命令行工具
Sun JDK监控和故障处理工具:
1、jps: 虚拟机进程状况工具
jps工具主要选项:
2、jstat: 虚拟机统计信息监视工具
jstat工具主要选项:
3、jinfo:Java配置信息工具
4、jmap:Java内存映像工具
jmap工具主要选项:
5、jhat:虚拟机堆转储快照分析工具
6、jstack:Java堆栈跟踪工具
7、HSDIS:JIT生成代码反编译
现代虚拟机的实现慢慢地和虚拟机规范产生差距,如果要分析程序如果执行,最常见的就是通过软件调试工具(GDB、Windbg等)断点调试,但是对于Java来说,很多执行代码是通过JIT动态生成到CodeBuffer中的;
功能:HSDIS是官方推荐的HotSpot虚拟机JIT编译代码的反汇编工具,它包含在HotSpot虚拟机的源码中但没有提供编译后的程序,可以自己下载放到JDK的相关目录里;
4.3 JDK的可视化工具
JConsole:Java监视与管理控制台
是一种基于JMX的可视化监控和管理工具,它管理部分的功能是针对MBean进行管理,由于MBean可以使用代码、中间件服务器或者所有符合JMX规范的软件进行访问,因此这里着重介绍JConsole的监控功能;
通过jconsole命令启动JConsole后,会自动搜索本机所有虚拟机进程。另外还支持远程进程的监控;
进入主界面,支持查看以下标签页:概述、内存、线程、类、VM摘要和MBean;
VisualVM:多合一故障处理工具
目前为止JDK发布的功能最强调的运行监控和故障处理程序,另外还支持性能分析;
VisualVM还有一个很大的优点:不需要被监视的程序基于特殊Agent运行,对应用程序的实际性能影响很小,可直接应用在生成环境中;
VisualVM基于NetBeans平台开发,具备插件扩展功能的特性,基于插件可以做到:显示虚拟机进程以及进程配置、环境信息(jps、jinfo)、监视应用程序的CPU、GC、堆、方法区以及线程的信息(jstat、jstack)、dump以及分析堆转储快照(jmap、jhat)、方法级的程序运行性能分析,找出被调用最多运行时间最长的方法、离线程序快照(收集运行时配置、线程dump、内存dump等信息建立快照)、其他plugins的无限可能。
使用jvisualvm首次启动时需要在线自动安装插件(也可手工安装);
特色功能:生成浏览堆转储快照(摘要、类、实例标签页、OQL控制台)、分析程序性能(Profiler页签可以录制一段时间程序每个方法执行次数和耗时)、BTrace动态日志跟踪(不停止目标程序运行的前提下通过HotSwap技术动态加入调试代码);
4.4 本章小结
本章介绍了随JDK发布的6个命令行工具以及两个可视化的故障处理工具,灵活运行这些工具可以给问题处理带来很多便利。我的总体感觉是可视化工具虽然强大,但是加载速度相比命令行工具慢很多,这个时候专注于某个功能的命令行工具是更优的选择。
第5章 调优案例分析与实战
5.1 概述
除了第四章介绍的知识和工具外,在处理实际问题时,经验同样很重要。
5.2 案例分析
5.3 实战:Eclipse运行速度调优
升级JDK;
设置-XX:MaxPermSize=256M解决Eclipse判断虚拟机版本的bug;
加入参数-Xverfify:none禁止字节码验证;
虚拟机运行在client模式,采用C1轻量级编译器;
把-Xms和-XX:PermSize参数设置为-Xmx和-XX:MaxPermSize一样,这样强制虚拟机启动时把老年代和永久代的容量固定下来,避免运行时自动扩展;
增加参数-XX:DisableExplicitGC屏蔽掉显式GC触发;
采用ParNew+CMS的垃圾收集器组合;
最终从Eclipse启动耗时15秒到7秒左右, eclipse.ini配置如下:
5.4 本章小结
Java虚拟机的内存管理和垃圾收集是虚拟机结构体系最重要的组成部分,对程序的性能和稳定性有非常大的影响。通过案例和实战部分,加深了对前面理论知识和工具的理解。
第三部分 虚拟机执行子系统
第6章 类文件结构
6.1 概述
由于虚拟机以及建立在虚拟机之上的编程语言蓬勃发展,将程序编译成二进制本地机器码已不再是唯一选择,越来越多的程序语言选择了与操作系统和机器指令集无关的、平台中立的格式作为程序编译后的存储格式。
6.2 无关性的基石
各种不同平台的虚拟机与所有平台都统一使用的程序存储格式——字节码(ByteCode)是构成平台无关性的基石;实现语言无关性的基础仍然是虚拟机和字节码存储格式。
6.3 Class类文件结构
任何一个Class文件都对应着唯一一个类或接口的定义信息,但反过来说,类或接口并不一定都得定义在文件里(譬如类或接口也可以通过类加载器直接生成)。
Class文件是一组8位字节为基础的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有添加任何分隔符。当遇到需要占用8位字节以上空间的数据项目时,则会按照高位在前的方式分割成若干个8为字节来存储。
根据Java虚拟机规定,Class文件格式采用一种类似C语言结构体的伪结构来存储数据,这种伪结构只有两种数据类型:无符号数和表。
无符号数属于基本的数据类型,以u1、u2、u4、u8来分别代表1个字节、2个字节、4个字节和8个字节的无符号数、无符号数可以用来描述数字、索引引用、数量值或者按照UTF-8编码构成字符串值。
表是由多个无符号数或者其他表作为数据项构成的复合数据类型,所有表都以“_info”结尾。表用于描述有层次关系的复合结构的数据,整个Class文件本质上就是一张表。
6.3.1 魔数和版本
每个Class文件的头4个字节称为魔数,它的唯一作用是确定这个文件是否为一个能被虚拟机接受的Class文件。
紧接着魔数存储的4个字节存储的是Class文件的版本号:5、6 字节是次版本号,7、8 字节是主版本号。
6.3.2 常量池
常量池可以理解为Class文件之中的资源仓库,由于常量池中的常量数量不固定,因此需要在常量池前放置一项u2类型的数据来表示容量,该值是从1开始的,上图的0x0013为十进制的19,代表常量池中有18项常量,索引值范围为1~18;常量池主要存放两大类常量:字面量(比如文本字符串和final常量等)和符号引用(包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符);
6.3.3 访问标志
紧接在常量池后面的是两个字节的访问标志,用于标识类或接口的访问信息。
6.3.4 类索引、父类索引与接口索引集合
类索引:u2类型的数据,用于确定类的全限定名。
父类索引:u2类型的数据,用于确定父类的全限定名。
接口索引计算器:u2类型的数据,用于表示索引集合的容量。
接口索引集合:一组u2类型的数据的集合,用于确定实现的接口(对于接口来说就是extend的接口)。
6.3.5 字段表集合
用于描述接口或者类中声明的变量,包括类级变量和实例级变量,但不包括方法内部声明的局部变量;它不会列出从父类和超类继承而来的字段;
6.4 字节码指令简介
Java虚拟机的指令由一个字节长度的、代表着特定操作含义的数字(操作码)以及跟随其后的零至多个代表此操作所需参数(称为操作数)而构成。由于Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码。
加载和存储指令:iload/iload等(加载局部变量到操作栈)、istore/istore等(从操作数栈存储到局部变量表)、bipush/sipush/ldc/iconst(加载常量到操作数栈)、wide(扩充局部变量表访问索引);
运算指令:没有直接支持byte、short、char和boolean类型的算术指令而采用int代替;iadd/isub/imul/idiv加减乘除、irem求余、ineg取反、ishl/ishr位移、ior按位或、iand按位与、ixor按位异或、iinc局部变量自增、dcmpg/dcmpl比较;
类型转换指令:i2b/i2c/i2s/l2i/f2i/f2l/d2i/d2l/d2f;
对象创建与访问指令:new创建类实例、newarray/anewarray/multianewarray创建数组、getfield/putfield/getstatic/putstatic访问类字段或实例字段、baload/iaload/aaload把一个数组元素加载到操作数栈、bastore/iastore/aastore将一个操作数栈的值存储到数组元素中、arraylength取数组长度、instanceof/checkcast检查类实例类型;
操作数栈管理指令:pop/pop2一个或两个元素出栈、dup/dup2复制栈顶一个或两个数组并将复制值或双份复制值重新压力栈顶、swap交互栈顶两个数值;
控制转移指令:ifeq/iflt/ifnull条件分支、tableswitch/lookupswitch复合条件分支、goto/jsr/ret无条件分支;
方法调用和返回指令:invokevirtual/invokeinterface/invokespecial/invokestatic/invokedynamic方法调用、ireturn/lreturn/areturn/return方法返回;
异常处理指令:athrow
同步指令:monitorenter/monitorexit
6.5 公有设计和私有实现
Java虚拟机的实现必须能够读取Class文件并精确实现包含在其中的Java虚拟机代码的含义;
但一个优秀的虚拟机实现,通常会在满足虚拟机规范的约束下具体实现做出修改和优化;
虚拟机实现的方式主要有两种:将输入的Java虚拟机代码在加载或执行时翻译成另外一种虚拟机的指令集或宿主主机CPU的本地指令集。
6.6 Class文件结构的发展
Class文件结构一直比较稳定,主要的改进集中向访问标志、属性表这些可扩展的数据结构中添加内容;
Class文件格式所具备的平台中立、紧凑、稳定和可扩展的特点,是Java技术体系实现平台无关、语言无关两项特性的重要支柱;
6.7 本章小结
第7章 虚拟机类加载机制
7.1 概述
虚拟机把描述类的数据从Class文件加载到虚拟机,并对数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制。在Java语言里面,类的加载和连接过程都在程序运行期间完成的。
7.2 类加载时机
类从加载到虚拟机内存中开始,到卸载出内存为止,它的整个生命周期包括了:加载(Loading)、验证(Verification)、准备(Preparation)、解析(Resolution)、初始化(Initialization)、使用(Using)和卸载(Unloading)7个阶段。其中,验证、准备、解析3个部分成为连接(Linking)。
加载:java虚拟机规范没有强制规定加载时机,具体由虚拟机具体实现自有把握。
初始化:虚拟机规范严格控制规定了有且只有四种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在这些之前开始)。
(1)使用new关键字实例化对象、读取或设置一个类的静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)、以及调用一个类的静态方法的时候。
(2)使用java.lang.reflect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化;
(3)当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化;
(4)当虚拟机启动时,用户需要指定一个要执行的主类(包括main方法的那个类),虚拟机会先初始化这个主类。注意,接口的情况并不适合第三种情况。即一个接口在初始化时,并不要求其父接口全部都完成初始化了,只有在真正使用到父接口的时候(如引用接口中定义的常量)才会初始化。
(5)使用1.7的动态语言支持。如果个java.lang.invoke.MethodHandle解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,并且该类对应的类没有初始化,触发该类的初始化
7.3 类加载的过程
加载、连接(验证、准备、解析)、初始化的解析。
1、加载
加载时类加载过程的一个阶段,虚拟机需要完成三件事情:
1)通过一个类的权限定名来获取定义此类的二进制字节流。
2)将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。
3)在Java堆中生成一个代表这个类的java.lang.Class对象,作为方法区这些数据的访问入口,对于数组,不是由类加载器创建,而是由Java虚拟机直接创建。
2、验证
验证是连接阶段的第一步,来确保包含的信息是否符合当前虚拟机的要求和是否会危害虚拟机自身的安全。大致分为四个检验过程:文件格式验证、元数据验证、字节码验证和符号引用验证。
文件格式验证:主要是文件格式规范和版本验证。
元数据验证:主要是对字节码描述的信息进行 语义分析,以确保其描述的信息符合Java语言规范的要求。
字节码验证:主要是数据流和控制流分析,任务是保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。
符号引用验证:发生在虚拟机将符号引用转换为直接引用的时候,可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验。目标是确保解析动作能正常执行。
3、准备
准备阶段正式为类变量分配内存并设置类变量初始化阶段,这些内存将在方法区中进行分配。进行内存分配的仅包括类变量(被static修饰的变量),而不是实例常量,实例常量将会在对象实例化时随着对象一起分配到Java堆中。
4、解析
解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程。解析动作主要针对类或接口、字段、类方法、接口方法四类符号引用进行。
5、初始化
类初始化阶段是类加载过程的最后一步。在准备阶段,类变量已经赋过一次系统要求的初始值,而在初始化阶段,则是根绝程序员通过程序制定的主观计划去初始化类变量和其他资源,或者从另外一个角度来表达:初始化阶段是执行类构造器方法的过程。
7.4 类加载器
1、类和类加载器
类加载器虽然只用于实现类的加载动作,但他在Java程序中起到的作用远远不限于类加载阶段。对于任意一个类,都需要由加载他的类加载器和类本身来确立在Java虚拟机中的唯一性。
2、双亲委派模式
从Java虚拟机的角度讲,只存在两种不同的类加载器:一种是启动类加载器(Bootstrap ClassLoader),这个类加载器使用C++语言实现,是虚拟机自身的一部分;另外一种就是所有的其他类加载器,这些类加载器都是由java语言实现,独立于虚拟机外部,并且全部继承自抽象类java.lang.ClassLoader。
从Java程序员的角度看,绝大部分Java程序都会使用到以下三种系统提供的类加载器:
启动类加载器(Bootstrap ClassLoader):负责将\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。启动类加载器无法被Java程序直接应用。
扩展类加载器(Extension ClassLoader):这个加载器有sun.misc.Launcher$ExtClassLoader实现,他负责加载\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
应用程序类加载器(Application ClassLoader):这个类加载器有sun.misc.Launcher$AppClassLoader实现。负责加载用户类路径(ClassPath)上所指定的类库。
双亲委派模式(Parents Delegation Model):要求除了顶层的类加载器外,其余的类加载器都应有自己的父类加载器。一般这种父子关系不是继承关系,而是组合关系来复用父加载器的代码。
双亲委派模式的工作过程是:如果一个类加载器收到了类加载的请求,他首先不会自己去尝试加载这个类,而且把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的请求最终都应该传送到顶层的启动类的加载器中,只要当父类返回无法完成这个加载请求时(就是查找范围没有查找到这个类),子加载器才会尝试去加载。
使用双亲委派模型来组织加载器之间的关系,一个明显的好处是:Java类随它的类加载器一起具备了一种带优先级的层次关系。
3、破坏双亲委派模式
1)第一次被破坏是在双亲委派模型出现之前(JDK1.2发布之前),JDK1.2之后不在提倡重写loadClass()方法,而是使用findClass方法。
2)第二次被破坏是由于该模型自身的缺陷造成的,如JNDI、JDBC等
3)第三次被破坏由于对动态性追求造成的,例如: OSGi而是网状结构的加载模式。
第8章 虚拟机字节码执行引擎
8.1概述
所有的Java虚拟机的执行引擎:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果。
8.2运行时栈帧结构
栈帧是虚拟机用于方法调用和方法执行的数据结构,是虚拟机运行时数据区的虚拟机栈的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接、方法返回地址等信息。每一个方法从调用开始到执行结束,就是栈帧在虚拟机栈中入栈出栈的过程。在编译期间,栈帧需要多大的局部变量表、多深的操作数栈都已经完全确定,并且写入方法的code 属性中。一个栈帧需要分配多大内存,不会受到程序运行期变量数据影响。在活动线程中,栈顶的栈帧才是有效的,称为当前栈帧,相关联的方法称为当前方法。
8.2.1局部变量表
是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量
8.2.2操作数栈(操作栈)
是一个后入先出栈,在编译时候写入code属性的max_stacks数据项中。
8.2.3动态连接
每个栈帧都有一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接
8.2.4方法返回地址
正常完成出口:PC计数器的值可以作为返回地址,栈帧中很可能保存这个计数值
异常完成出口:通过异常处理器表来确定返回地址,栈帧中一般不会保存这部分信息
方法退出等同于当前栈帧出栈,可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令
8.2.5附加信息
8.3方法调用
方法调用不等同于方法执行,唯一任务就是确定被调用方法的版本,暂不涉及方法内部的具体运行过程。一切方法调用在Class文件里面存储的都只是符号引用,而不是内存布局中的入口地址。
8.3.1解析
调用目标在程序写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析
Java中符合“编译期可知,运行期不变”要求的方法主要包括:静态方法,私有方法两大类。与之相对应的5条方法调用字节码指令
invokestatic:调用静态方法
invokespecial:调用实例构造器init方法、私有方法、父类方法
invokevirtual:调用所有的虚方法
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法
只要能被invokestatic和invokespecial指令调用的方法,都可以再解析阶段中确定唯一的调用版本,符合这个条件的由静态方法、构造方法、私有方法、父类方法四大类,这些方法称为非虚方法(还包含final修饰的方法,无法被覆盖,没有其他版本);与之相反的称为虚方法(final修饰除外)
8.3.2分派
解析调用是一个静态过程,而分派调用可能是静态调用,也可能是动态调用。分派调用过程将揭示多态特征的一些最基本的体现。
1、静态分派
所有依赖静态类型来定位方法执行版本的分派称为静态分派。静态分派的典型应用就是方法重载,动态分派是重写。静态分派发生在编译阶段。
2、动态分派
运行期根据实际类型确定方法版本的分派称为动态分派。 动态分派典型应用就是重写。
3、单分派和多分派
方法的接受者方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择。静态分派属于多分派,动态分派属于单分派。
4、虚拟机动态分派的实现
在方法区中建立一个虚方法表(Virtual Method Table),使用虚方法表索引来代替元数据查找以提高性能;方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始化值后,虚拟机会把该类的方法表也初始化完毕;
8.3.3动态类型语言支持
JDK 1.7发布增加的invokedynamic指令实现了“动态类型语言”支持,也是为JDK 1.8顺利实现Lambda表达式做技术准备。动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译器。Java语言在编译期间就将方法完整的符号引用生成出来,作为方法调用指令的参数存储到Class文件中;这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息。
JDK 1.7实现了JSR-292,新加入的java.lang.invoke包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法外,提供一种新的动态确定目标方法的机制,称为MethodHandle;
从本质上讲,Reflection(反射)和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用,前者是重量级,而后者是轻量级;另外前者只为Java语言服务,后者可服务于所有Java虚拟机之上的语言;
每一处含有invokedynamic指令的位置都称为“动态调用点(Dynamic Call Site)”,这条指令的第一个参数不再是代表符号引用的CONSTANT_Methodref_info常量,而是CONSTANT_InvokeDynamic_info常量(可以得到引导方法、方法类型和名称);
invokedynamic指令与其他invoke指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定的;
8.4基于栈的字节码解释执行引擎
上节主要讲虚拟机是如何调用方法的,这节探讨虚拟机是如何执行方法中的字节码指令的。
8.4.1 解释执行
javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,在遍历语法树生成线性的字节码指令流的过程,一部分在虚拟机之外进行,而解释器是在虚拟机内部,所以Java程序的编译是半独立的实现。
8.4.2基于栈的指令集和基于寄存器的指令集
Java编译器输出的指令集,基本上是一种基于栈的指令集架构,指令流中的指令大部分是零地址指令,它们依赖操作数栈进行工作;
基于栈的指令集主要的优点是可移植性,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束;主要缺点是执行速度相对来说会稍慢一点;
8.4.3基于栈的解释器执行过程
第9章 类加载及执行子系统的案例与实战
9.1 概述
在Class文件格式与执行引擎这部分中,用户的程序能直接影响的内容并不多;能通过程序进行操作的,主要是字节码生成与类加载器这两部分的功能,但仅仅在如何处理这两点上,就已经出现了许多值得欣赏和借鉴的思路;
9.2 案例分析
9.2.1 Tomcat:正统的类加载器架构
Tomcat服务器的类加载架构
Java Web服务器:部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离又要可以互相共享;尽可能保证自身的安全不受部署的Web应用程序影响;要支持JSP生成类的热替换;
上图中,灰色背景的三个类加载器是JDK默认提供的类加载器,而CommonClassLoader、CatalinaClassLoader、SharedClassLoader和WebappClassLoader是Tomcat自己定义的类加载器,分别加载/common/(可被Tomcat和Web应用共用)、/server/(可被Tomcat使用)、/shared/(可被Web应用使用)和/WebApp/WEB-INF/(可被当前Web应用使用)中的Java类库,Tomcat 6.x把前面三个目录默认合并到一起变成一个/lib目录(作用同原先的common目录);
9.2.2 OSGI:灵活的类加载架构
OSGI的每个模块称为Bundle,可以声明它所依赖的Java Package(通过Import-Package描述),也可以声明它允许导出发布的Java Package(通过Export-Package描述);除了更精确的模块划分和可见性控制外,引入OSGI的另外一个重要理由是基于OSGI的程序很可能可以实现模块级的热插拔功能;OSGI的类加载器之间只有规则,没有固定的委派关系;加载器之间的关系更为复杂、运行时才能确定的网状结构,提供灵活性的同时,可能会产生许多的隐患;
9.2.3 字节码生成技术与动态代理的实现
在Java里面除了javac和字节码类库外,使用字节码生成的例子还有Web服务器中的JSP编译器、编译时植入的AOP框架和很常用的动态代理技术等,这里选择其中相对简单的动态代理来看看字节码生成技术是如何影响程序运作的;动态代理的优势在于实现了在原始类和接口还未知的时候就确定类的代理行为,可以很灵活地重用于不同的应用场景之中;以下的例子中生成的代理类“$Proxy0.class”文件可以看到代理为传入接口的每一个方法统一调用了InvocationHandler对象的invoke方法;其生成代理类的字节码大致过程其实就是根据Class文件的格式规范去拼接字节码;
9.2.4 Retrotranslator:跨越JDK版本
Retrotranslator的作用是将JDK 1.5编译出来的Class文件转变为可以在JDK 1.4或JDK 1.3部署的版本,它可以很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性,甚至还可以支持JDK 1.5中新增的集合改进、并发包以及对泛型、注解等的反射操作;JDK升级通常包括四种类型:编译器层面的做的改进、Java API的代码增强、需要再字节码中进行支持的活动以及虚拟机内部的改进,Retrotranslator只能模拟前两类,第二类通过独立类库实现,第一类则通过ASM框架直接对字节码进行处理;
9.3 实战:自己动手实现远程执行功能
第四部分 程序编译与代码优化
第10章 早期(编译期)优化
10.1 概述
前端编译器(或叫编译器前端):把.java文件转变为.class文件的过程,比如Sun的javac、Eclipse JDT中的ECJ;
后端运行编译器(JIT编译器):把字节码转变为机器码的过程,比如HotSpot VM的C1、C2编译器;
静态提前编译器(AOT编译器):直接把*.java文件编译成本地机器代码的过程,比如GNU Compiler for the Java;
本章主要针对第一类,把第二类的编译过程留到下一章讨论;
javac这类编译器对代码运行效率几乎没有任何优化措施,虚拟机设计团队把对性能的优化集中到了后端的即时编译器中,这样那些不是由javac产生的Class文件也同样能享受到编译器优化所带来的好处;
javac做了许多针对Java语言编码过程的优化措施来改善程序员的编码风格和提高编码效率;可以说,Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前端编译器在编译器的优化过程对于程序编码来说关系更加密切;
10.2 javac编译器
javac编译器本身就是一个由Java语言编写的程序,这为纯Java的程序员了解它的编译过程带来了很大的便利。
10.2.1 javac的源码与调试
javac的源码存放在JDK_SRC_HOME/langtools/src/share/classes/com/sun/tools/javac,除了JDK自身的API外,就只引用了JDK_SRC_HOME/langtools/src/share/classes/com/sun/*里面的代码;
导入javac的源码后就可以运行com.sun.tools.javac.Main的main方法来执行编译了;
javac编译过程大概可以分为3个过程:解析与填充符号表过程、插入式注解处理器的注解处理过程、分析与字节码生成过程;
10.2.2 解析与填充符号表
解析步骤由parseFiles方法完成;
词法分析将源代码的字符流转变为标记(Token)集合,由com.sun.tools.javac.parser.Scanner类完成;
语法分析是根据Token序列构造抽象语法树(AST,一种用来描述程序代码语法结构的树形表示方式)的过程,由com.sun.tools.javac.parser.Parser类实现,AST由com.sun.tools.javac.tree.JCTree类表示;
填充符号表:由enterTrees方法完成;符号表是由一组符号地址和符号信息构成的表格,所登记的信息在编译的不同阶段都要用到,在语义分析中用于语义检查,在目标代码生成时用于地址分配;由com.sun.tools.javac.comp.Enter类实现;
10.2.3 注解处理器
在JDK 1.6中实现了JSR-269规范,提供了一组插入式注解处理器的标准API在编译期间对注解进行处理,可以读取、修改、添加抽象语法树中的任意元素;
通过插入式注解处理器实现的插件在功能上有很大的发挥空间,程序员可以使用插入式注解处理器来实现许多原本只能在编码中完成的事情;
javac中,在initProcessAnnotations初始化,在processAnnotations执行,如果有新的注解处理器,通过com.sun.tools.javac.processing.JavacProcessingEnviroment类的doProcessing方法生成一个新的JavaCompiler对象对编译的后续步骤进行处理;
10.2.4 语义分析与字节码生成
语义分析的主要任务是对结构上正确的源程序进行上下文有关性质的审查,主要包括标注检查、数据及控制流分析两个步骤;
解语法糖(Syntactic Sugar,添加的某种对语言功能没有影响但方便程序员使用的语法):Java中最常用的语法糖主要是泛型、变长参数、自动装箱等,他们在编译阶段还原回简单的基础语法结构;在com.sun.tools.javac.comp.TransTypes类和com.sun.tools.javac.comp.Lower类中完成;
字节码生成:javac编译的最后一个阶段,不仅仅是把前面各个步骤所生成的信息转化为字节码写入到磁盘中,编译器还进行了少量的代码添加和转换工作(如实例构造器方法和类构造器方法);由com.sun.tools.javac.jvm.ClassWriter类的writeClass方法输出字节码,生成最终的Class文件;
10.3 Java语法糖的味道
10.3.1 泛型与类型擦除
Java语言的泛型只在程序源码中存在,在编译后的字节码文件中,就已经替换为原来的原生类型了,并且在相应的地方插入了强制转换,这种基于类型擦除的泛型实现是一种伪泛型;
JCP组织引入了Signature属性,它的作用就是存储一个方法在字节码层面的特征签名,这个属性中保存的参数类型并不是原生类型,而是包括了参数化类型的信息,这样我们就可以通过反射手段获取参数化类型;
10.3.2 自动装箱、拆箱与遍历循环
它们的实现比较简单,但却是Java语言里使用最多的语法糖;
10.3.3 条件编译
Java语言之中并没有使用预处理器,因为Java编译器并非一个个地编译Java文件,而是将所有编译单元的语法树顶级节点输入到待处理列表后再进行编译;
Java语言可以使用条件为常量的if语句进行条件编译;编译器将会把分支中不成立的代码块消除掉;
10.4 实战:插入式注解处理器
第十一章 晚期(运行期)优化
11.1 概述
为了提高热点代码的执行效率,在运行时虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化,完成这个任务的编译器称为即时编译器(JIT);JIT不是虚拟机必需的,但是其编译性能的好坏、代码优化程度的高低却是衡量一款商用虚拟机优秀与否的最关键的指标之一,它也是虚拟机中最核心且最能体现虚拟机技术水平的部分;
11.2 HotSpot虚拟机内的即时编译器
11.2.1 解释器与编译器
当程序需要迅速启动和执行的时候,解释器可以先发挥作用,省去编译的时间立即执行;在程序运行后,随着时间的推移,编译器把越来越多的代码编译成本地代码提升执行效率;
HotSpot虚拟机中内置了两个即时编译器,分别为Client Compiler和Server Compiler,或简称为C1编译器和C2编译器;虚拟机会根据自身版本与宿主机器的硬件性能自动选择运行模式,也可以使用“-client”或“-server”参数去强制指定运行模式;
想要编译出优化程度更高的代码,解释器可能还要替编译器收集性能监控信息,为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机还会逐渐启动分层编译的策略:第0层,程序解释运行;第1层,C1编译;第2层,C2编译;
实施分层编译后,Client Compiler和Server Compiler将会同时工作,许多代码都可能会被多次编译,用Client Compiler获取更高的编译速度,用Server Compiler来获取更好的编译质量,在解释执行的时候也无须再承担性能收集监控信息的任务;
11.2.2 编译对象与触发条件
被JIT编译的热点代码有两类:被多次调用的方法、被多次执行的循环体;对于前者编译器会以整个方法作为编译对象,属于标准的JIT编译方式;对于后者尽管编译动作是由循环体所触发的,但编译器依然会以整个方法作为编译对象,这种编译方式称之为栈上替换(OSR编译);
热点探测:基于采样的热点探测和基于计数器的热点探测,在HotSpot虚拟机中使用的是第二种,通过方法计数器和回边计数器进行热点探测。方法调用计数器触发的即时编译交互过程如下图所示:
对于Client Compiler来说,它是一个简单快速的三段式编译器,主要的关注点在于局部性的优化,而放弃了很多耗时较长的全局优化手段;第一阶段一个平台独立的前端将字节码构造成一个高级中间代码表示(HIR),第二阶段一个平台相关的后端从HIR中产生低级中间代码表示(LIR),最后阶段是在平台相关的后端使用线性扫描算法在LIR上分配寄存器,并在LIR上做窥孔优化,然后产生机器代码。其大致过程如下所示:
Server Compiler是专门面向服务端的典型应用并为服务端的性能配置特别调整过的编译器,也是一个充分优化过的高级编译器,几乎能达到GNU C++编译器使用-02参数时的优化强大,它会执行所有经典的优化动作,如无用代码消除、循环展开、循环表达式外提、消除公共子表达式、常量传播、基本块重排序等,还会实现如范围检查消除、空值检查消除等Java语言特性密切相关的优化技术;
11.2.4 查看及分析即时编译结果
本节的运行参数有一部分需要Debug或FastDebug版虚拟机的支持;
要知道某个方法是否被编译过,可以使用参数-XX:+PrintCompilation要求虚拟机在即时编译时将被编译成本地代码的方法名称打印出来;
还可以加上参数-XX:+PrintInlining要求虚拟机输出方法内联信息,输出内容如下:
除了查看那些方法被编译之外,还可以进一步查看即时编译器生成的机器码内容,这个需要结合虚拟机提供的反汇编接口来阅读;
11.3 编译优化技术
11.3.1 优化技术概览
11.3.2 公共子表达式消除
如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式,只需要直接用前面计算过的表达式结果代替E就可以了;
11.3.3 数组边界检查消除
对于虚拟机的执行子系统来说,每次数组元素的读写都带有一次隐含的条件判断,对于拥有大量数组访问的程序代码无疑是一种性能负担;
11.3.4 方法内联
除了消除方法调用的成本外更重要的意义是为其他优化手段建立良好的基础;
为了解决虚方法的内联问题,引入了类型继承关系分析(CHA)技术和内联缓存(Inline Cache)来完成方法内联;
11.3.5 逃逸分析
逃逸分析的基本行为就是分析对象动态作用域,当一个对象在方法中被定义后,它可能被外部方法所引用(方法逃逸),甚至还可能被外部线程所访问到(线程逃逸);如果能证明一个对象不会逃逸到方法或线程之外,则可能为这个变量进行一些高效的优化,比如栈上分配(减轻垃圾收集的压力)、同步消除(读写不会有竞争)、标量替换;
11.4 Java与C/C++的编译器对比
Java虚拟机的即时编译器与C/C++的静态优化编译器相比,可能会由于下列这些原因而导致输出的本地代码有一些劣势:即时编译器运行占用用户程序运行时间、动态类型安全语言导致的频繁检查、运行时对方法接收者进行多态选择的频率大、可以动态扩展导致很多全局的优化难以运行、大部分对象在堆上分配导致垃圾收集机制的效率低;
Java语言的特性换取了开发效率的提升、还有许多优化是静态优化编译器不好做的,比如别名分析、还有一些以运行期性能监控为基础的优化措施如调用频率预测等;
11.5 本章小结
本章我们着重了解了虚拟机的热点探测方法、HotSpot的即时编译器、编译触发条件以及如何从虚拟机外部观察和分析JIT编译的数据和结果,还选择了集中场景的编译期优化技术进行讲解。对Java编译器的深入了解,有助于在工作中分辨哪些代码是编译器可以帮我们处理的,哪些代码需要自己调节以便更适合编译器的优化。
第五部分 高效并发
第12章 Java内存模型与线程
并发处理的广泛应用是使得Amdahl定律代替摩尔定律成为计算机性能发展源动力的根本原因,也是人类“压榨”计算机运算能力的最有力武器。
12.1 概述
多任务处理在现代计算机操作系统中几乎已是一项必备的功能了;除了充分利用计算机处理器的能力外,一个服务端同时对多个客户端提供服务则是另一个更具体的并发应用场景。
12.2 硬件的效率与一致性
基于高速缓存的存储交互很好地解决了处理器与内存的速度矛盾,但是也引入了一个新的问题:缓存一致性。为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI、MOSI、Synapse、Firefly及Dragon Protocol等。
12.3 Java内存模型
Java虚拟机规范中视图定义一种Java内存模型(JMM)来屏蔽掉各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
12.3.1 主内存与工作内存
Java内存模型的主要目标是定义程序中各个变量的访问规则,即在虚拟机中将变量存储到内存和从内存中取出变量这样的底层细节。此处的变量与Java编程中所说的变量有所区别,它包括了实例字段、静态字段和构成数组对象的元素,但不包括局部变量与方法参数,因为后者是线程私有的,不会被共享;
Java内存模型规定了所有的变量都存储在主内存中,每个线程还有自己的工作内存,线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作都必须在工作内存中进行,而不能直接读写主内存中的变量。线程、主内存和工作内存的关系如下所示:
12.3.2 内存间交互操作
主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、从工作内存同步回主内存之类的实现细节,Java内存模型中定义了以下8种操作来完成:
Lock(锁定):作用于主内存的变量,将主内存该变量标记成当前线程私有的,其他线程无法访问它把一个变量标识为一条线程独占的状态。
Unlock(解锁):作用于主内存的变量,把一个处于锁定状态的变量释放出来,才能被其他线程锁定。
Read(读取):作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
Load(加载):作用于工作内存中的变量,把read操作从内存中得到的变量值放入工作内存的变量副本中。
Use(使用):作用于工作内存中的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作。
Assgin(赋值):作用于工作内存中的变量,把一个从执行引擎接收到的值赋值给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
Store(存储):作用于工作内存中的变量,把工作内存中一个变量的值传递到主内存中,以便随后的write操作使用。
Write(写入):作用于主内存中的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中。
12.3.3 对于volatile型变量的特殊规则
关键字volatile可以说是Java虚拟机提供的最轻量级的同步机制。
当一个变量定义为volatile之后,它将具备两种特性:第一是保证此变量对所有线程的可见性,这里的可见性是指当一个线程修改了这个变量的值,新的值对于其他线程来说是可以立即得知的,而普通的变量的值在线程间传递均需要通过主内存来完成;另外一个是禁止指令重排序优化,普通的变量仅仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致;volatile变量不保证原子性。
java内存模型中对volatile变量定义的特殊规则。假定T表示一个线程,V和W分别表示volatile型变量,那么在进行read、load、use、assign、store和write操作时需要满足如下规则:
1)只有当线程T对变量V执行的前一个动作为load时,T才能对V执行use;并且,只有T对V执行的后一个动作为use时,T才能对V执行load。T对V的use,可以认为是和T对V的load。read动作相关联,必须连续一起出现(这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对V修改后的值)。
2)只有当T对V的前一个动作是assign时,T才能对V执行store;并且,只有当T对V执行的后一个动作是store时,T才能对V执行assign。T对V的assign可以认为和T对V的store、write相关联,必须连续一起出现(这条规则要求在工作内存中,每次修改V后都必须立刻同步回主内存中,用于保证其他线程看到自己对V的修改)。
3)假定动作A是T对V实施的use或assign动作,假定动作F是和动作A相关联的load或store动作,假定动作P是和动作F相应的对V的read或write动作;类似的,假定动作B是T对W实施的use或assign动作,假定动作G是和动作B相关联的load或store动作,假定动作Q是和动作G相应的对W的read或write动作。如果A先于B,那么P先于Q(这条规则要求volatile修饰的变量不会被指令的重排序优化,保证代码的执行顺序与程序的顺序相同)。
12.3.4 对long和double型变量的特殊规则
允许虚拟机将没有被volatile修饰的64位数据类型(long和double)的读取操作划分为两次32位的操作来进行,即允许虚拟机实现选择可以不保证64位数据类型的load、store、read和write这4个操作的原子性,就点就是long和double的非原子协定(Nonatomic Treatment of double and long Variables)。
12.3.5 原子性、可见性和有序性
原子性(Atomicity):由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write,我们大致可以认为基本数据类型的访问具备原子性(long和double例外)。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足需求,尽管虚拟机未把lock和unlock操作直接开放给用户,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反应到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。
可见性(Visibility):指当一个线程修改了共享变量的值,其他线程能够立即得知这个修改。除了volatile,Java还有两个关键字能实现可见性,synchronized和final。
有序性(Ordering):Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另外一个线程,所有的操作都是无序的。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性。
12.3.6 先行发生原则
1)程序次序规则(Program Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地来说应该是控制流顺序而不是程序代码顺序,因为要考虑分支/循环结构。
2)管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一锁的lock操作。这里必须强调的是同一锁,而“后面”是指时间上的先后顺序。
3)volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的“后面”是指时间上的先后顺序。
4)线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
5)线程终止规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束/Thread.isAlive()的返回值等手段检测到县城已经终止执行。
6)线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测到是否有中断发生。
7)对象终结规则(Finalizer Rule):一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
8)传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那么操作A先行发生于操作C。
12.4 Java与线程
并发不一定依赖多线程,但是Java里面谈论并发,大多数与线程脱不开关系。
12.4.1 线程的实现
主流操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台对线程的同一处理,每个java.lang.Thread类的实例就代表了一个线程。Thread类与大部分Java API有着显著的差别,它的所有关键方法都被声明为Native。在Java API中一个Native方法可能就意味着这个方法没有使用或无法使用平台无关的手段实现。
实现线程主要三种方式:
1. 使用内核线程实 2. 使用用户线程实现 3. 使用用户线程加轻量级进程混合实现
12.4.2 Java线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度(线程的执行时间由线程本身来控制)和抢占式线程调度(线程由系统来分配执行时间,线程的切换不由线程本身来决定);
Java语言一共设置了10个级别的线程优先级,不过线程优先级并不是太靠谱,原因就是操作系统的线程优先级不见得总是与Java线程的优先级一一对应,另外优先级还可能被系统自行改变;
12.4.3 状态转换
Java语言定义了五种线程状态,在任意一个时间点,一个线程只能有且只有其中一种状态,分别是新建(New)、运行(Runnable)、无限期等待(Waiting)、限期等待(Timed Waiting)、阻塞(Blocled)、结束(Terminated)。它们之间相互的转换关系如下所示:
第13章 线程安全与锁优化
13.1 概述
首先需要保证并发的正确性,然后在此基础上实现高效;
13.2 线程安全
当多个线程访问一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他的协调操作,调用这个对象的行为都可以获得正确的结果,那这个对象是线程安全的。
13.2.1 Java语言中的线程安全
我们可以将Java语言中各个操作共享的数据分为以下五类:不可变、绝对线程安全、相对线程安全、线程兼容和线程对立;
不可变:不可变带来的安全性是最简单和最纯粹的,如final的基本数据类型;如果共享的数据是一个对象,那就需要保证对象的行为不会对其状态产生任何影响才行,比如String类的substring、replace方法;Number类型的大部分子类都符合不可变要求的类型,但是AtomicInteger和AtomicLong则并非不可变的;
线程绝对安全:Java API中标注自己是线程安全的类,大多数都不是绝对的线程安全;比如java.util.Vector,不意味着调用它的是时候永远都不再需要同步手段了;
线程相对安全:是我们通常意义上所讲的线程安全,在Java语言中,大部分的线程安全类都属于这种类型;
线程兼容:指对象本身并不是线程安全的,但是可以通过在调用端正确地使用同步手段来保证对象在并发环境中可以安全地使用;我们说一个类不是线程安全的,绝大多数时候指的是这一种情况;
线程对立:无论调用端是否采取了同步措施,都无法在多线程环境中并发使用的代码,Java语言中很少出现;
13.2.2 线程安全的实现方法
互斥同步:同步是指在多个线程并发访问共享数据时,保证共享数据在同一个时刻只被一个线程使用,而互斥是实现同步的一种手段,临界区、互斥量和信号量都是主要的互斥实现方式;Java中最基本的互斥同步手段就是synchronized关键字,它对同一个线程来说是可重入的且会阻塞后面其他线程的进入;另外还可以使用java.util.concurrent包中的重入锁(ReentrantLock)来实现同步,相比synchronized关键字ReentrantLock增加了一些高级功能:等待可中断、可实现公平锁以及锁可以绑定多个条件;
非阻塞同步:互斥同步最主要的问题就是进行线程阻塞和唤醒带来的性能问题,其属于一种悲观的并发策略;随着硬件指令集的发展,我们有了另外一个选择即基于冲突检测的乐观并发策略,就是先进行操作,如果没有其他线程争用共享数据那就操作成功了,如果有争用产生了冲突,那就再采取其他的补偿措施(最常见的就是不断重试直至成功),这种同步操作称为非阻塞同步;Java并发包的整数原子类,其中的compareAndSet和getAndIncrement等方法都使用了Unsafe类的CAS操作;
无同步方案:要保证线程安全,并不是一定就要进行同步;有一些代码天生就是线程安全的,比如可重入代码和线程本地存储的代码;
13.3 锁优化
锁优化技术:适应性自旋、锁销除、锁粗化、轻量级锁、偏向锁,这些技术都为了在线程之间更加高效地共享数据,以及解决竞争问题,从而提高程序的执行效率。
13.3.1 自旋锁与自适应自旋
互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力;另外在共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得,如果让两个或以上的线程同时并行执行,让后面请求锁的那个线程稍等一下,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁;为了让线程等待,我们只需让线程执行一个忙循环,这些技术就是所谓的自旋锁;
在JDK 1.6已经默认开启自旋锁;如果锁被占用的时间很短自旋等待的效果就会非常好,反之则会白白消耗处理器资源;
在JDK 1.6中引入了自适应的自旋锁,这意味着自旋的时间不再固定,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定;
13.3.2 锁消除
锁消除是指虚拟机即时编译器在运行时,对一些代码上要求同步,但是被检测到不可能存在共享数据竞争的锁进行消除;锁消除的主要判断依据来源于逃逸分析的数据支持。
13.3.3 锁粗化
原则上总是推荐将同步块的作用范围限制得尽量小 – 只有在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变小,如果存在锁竞争,那等待锁的线程也能尽快拿到锁;
但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗;
13.3.4 轻量级锁
要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须从HotSpot虚拟机的对象的内存布局开始介绍;HotSpot虚拟机的对象头分为两部分信息:第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄等,这部分官方称之为Mark Word,是实现轻量级锁和偏向锁的关键;另外一部分用于存储指向方法区对象类型数据的指针:
在代码进入同步块的时候,如果此同步对象没有被锁定,虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储对象目前的Mark Word的拷贝(官方称之为Displaced Mark Word);然后虚拟机将使用CAS操作尝试将对象的Mark Word更新为指向Lock Record的指针,如果更新成功了那么这个线程就拥有了该对象的锁,并且对象Mark Word的锁标志位将转变为“00”,即表示此对象处于轻量级锁定状态;如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了;如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁的指针,后面等待锁的线程也要进行阻塞状态。
13.3.5 偏向锁
偏向锁也是JDK 1.6中引入的一项锁优化,它的目的是消除数据在无竞争情况下的同步原语,进一步提高程序的运行性能;如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不做了;
偏向锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步;
假设当前虚拟机启动了偏向锁,那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设为“01”,即偏向模式;同时使用CAS操作把获取到这个锁的线程ID记录在对象的Mark Word之中;如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作;当有另外一个线程去尝试获取这个锁时,偏向模式就宣告结束,根据锁对象目前是否被锁定的状态,撤销偏向后恢复到未锁定或轻量级锁定的状态,后续的同步操作就如上面介绍的轻量级锁那样执行;偏向锁、轻量级锁的状态转化以及对象Mark Work的关系如下图所示:
完结……