《深入理解Java虚拟机》第2版笔记(完整)

第1章 走近Java

1.2 Java技术体系

  1. Java程序设计语言、Java虚拟机、Java API类库这三部分统称为JDK(Java Development Kit),JDK是用于支持Java程序开发的最小环境。

1.4 Java虚拟机发展史

1.4.1 Sun Classic / Exact VM

  1. Exact VM因它使用准确式内存管理而得名,即虚拟机可以知道内存中某个位置的数据具体是什么类型。譬如内存中有一个32位的整数123456,它到底是一个reference类型指向123456的内存地址还是一个数值为123456的整数,虚拟机将有能力分辨出来,这样才能在GC的时候准确判断堆上的数据是否还可能被使用。由于使用了准确式内存管理,Exact VM可以抛弃以前Classic VM基于handler的对象查找方式,这样每次定位对象都烧了一次间接查找的开销,提升执行性能。

1.4.2 Sun HotSpot VM

  1. HotSpot VM的热点代码探测能力可以通过执行计数器找出最具有编译价值的代码,然后通知JIT编译器以方法为单位进行编译。如果一个方法被频繁调用,或方法中有效循环次数很多,将会分别触发标准编译和OSR(栈上替换)编译动作。

1.4.6 Apache Harmony / Google Android Dalvik VM

  1. Dalvik VM并不是一个Java虚拟机,它没有遵循Java虚拟机规范,不能直接执行Java的Class文件,使用的是寄存器架构而不是JVM中常见的栈架构。但是它与Java又有着千丝万缕的联系,它执行的dex(Dalvik Executable)文件可以通过Class文件转化而来,使用Java语法编写应用程序,可以直接使用大部分的Java API等。

1.5 展望Java技术的未来

  1. 函数式编程的一个重要优点就是这样的程序天然地适合并行运行。

第2章 Java内存区域与内存溢出异常

2.2 运行时数据区域

2.2.4 Java堆

  1. 此内存区域(Java堆)的唯一目的就是存放对象实例,几乎所有的对象实例都在这里分配内存。

2.2.5 方法区

  1. 方法区(Method Area)与Java堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

2.3 HotSpot虚拟机对象探秘

2.3.1 对象的创建

  1. 选择哪种分配方式由Java堆是否规整决定,而Java堆是否规整又由所采用的垃圾收集器是否带有压缩整理功能决定。因此,在使用Serial、ParNew等带Compact过程的收集器时,系统采用的分配算法是指针碰撞,而使用CMS这种基于Mark-Sweep算法的收集器时,通常采用空闲列表。
  2. 另一种是把内存分配的动作按照线程划分在不同的空间之中进行,即每个线程在Java堆中预先分配一小块内存,称为本地线程分配缓冲(Thread Local Allocation Buffer, TLAB)。哪个线程要分配内存,就在哪个线程的TLAB上分配,只有TLAB用完并分配新的TLAB时,才需要同步锁定。

2.3.2 对象的内存布局

  1. HotSpot虚拟机的对象头包括两部分信息,第一部分用于存储对象自身的运行时数据,如哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据的长度在32位和64位的虚拟机(未开启压缩指针)中分别为32bit和64bit,官方称它为”Mark Word”。
  2. 对象头的另一部分是类型指针,即对象指向它的类元数据的指针,虚拟机通过这个指针来确定这个对象是哪个类的实例。

2.3.3 对象的访问定位

  1. 使用句柄来访问的最大好处就是reference中存储的是稳定的句柄地址,在对象被移动(垃圾收集时移动对象是非常普遍的行为)时只会改变句柄中的实例数据指针,而reference本身不需要修改。

2.4 实战:OutOfMemoryError异常

2.4.1 Java堆溢出

  1. 保证GC Roots到对象之间有可达路径来避免垃圾回收机制清除这些对象。
  2. 重点是确认内存中的对象是否是必要的,也就是要先分清楚到底是出现了内存泄漏(Memory Leak)还是内存溢出(Memory Overflow)。

2.4.2 虚拟机栈和本地方法栈溢出

  1. 如果使用虚拟机默认参数,栈深度在大多数情况下达到1000~2000完全没有问题,对于正常的方法调用(包括递归),这个深度应该完全够用了。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程。

2.4.3 方法区和运行时常量池溢出

  1. 在JDK 1.6中,intern()方法会把首次遇到的字符串实例复制到永久代中,返回的也是永久代中这个字符串实例的引用,而由StringBuilder创建的字符串实例在Java堆上,所以必然不是同一个引用,将返回false。而JDK 1.7(以及部分其他虚拟机,例如JRockit)的intern()实现不会再复制实例,只是在常量池中记录首次出现的实例引用,因此intern()返回的引用和由StringBuilder创建的那个字符串实例是同一个。

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

3.1 概述

  1. 程序计数器、虚拟机栈、本地方法栈3个区域随线程而生,随线程而灭。
  2. 每一个栈帧中分配多少内存基本上是在类结构确定下来时就已知的,因此这几个区域的内存分配和回收都具备确定性,在这几个区域内就不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

3.2 对象已死吗

3.2.2 可达性分析算法

  1. 在Java语言中,可作为GC Roots的对象包括下面几种:

    虚拟机栈(栈帧中的本地变量表)中引用的对象。

    方法区中类静态属性引用的对象。

    方法区中常量引用的对象。

    本地方法栈中JNI(即一般说的Native方法)引用的对象。

3.2.4 生存还是死亡

  1. 这里所谓的“执行”是指虚拟机会触发这个方法,但并不承诺会等待它运行结束,这样做的原因是,如果一个对象在finalize()方法中执行缓慢,或者发生了死循环(更极端的情况),将很可能会导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次小规模的标记,如果对象要在finalize()中成功拯救自己——只要重新与引用链上的任何一个对象建立关联即可,譬如把自己(this关键字)赋值给某个类变量或者对象的成员变量,那在第二次标记时它将被移除出“即将回收”的集合;如果对象这时候还没有逃脱,那基本上它就真的被回收了。

3.4 HotSpot算法实现

3.4.2 安全点

  1. 所以,安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的——因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这个原因而过长时间运行,“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生Safepoint。
  2. 现在几乎没有虚拟机实现采用抢先式中断来暂停线程从而响应GC事件。

3.4.3 安全区域

  1. 所谓的程序不执行就是没有分配CPU时间,典型的例子就是线程处于Sleep状态或者Blocked状态,这时候线程无法响应JVM的中断请求,“走”到安全的地方去中断挂起,JVM也显然不太可能等待线程重新被分配CPU时间。
  2. 安全区域是指在一段代码片段中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的。我们也可以把Safe Region看做是被扩展了的Safepoint。

3.5 垃圾收集器

  1. Java虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的收集器。

3.5.1 Serial收集器

  1. Serial收集器依然是虚拟机运行在Client模式下的默认新生代收集器。

3.5.3 Parallel Scavenge收集器

  1. Parallel Scavenge收集器的特点是它的关注点与其他收集器不同,CMS等收集器的关注点是尽可能地缩短垃圾收集时用户线程的停顿时间,而Parallel Scavenge收集器的目标则是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间),虚拟机总共运行了100分钟,其中垃圾收集花掉1分钟,那吞吐量就是99%。

3.5.6 CMS收集器

  1. 其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间按一般会比初始标记阶段稍长一些,但远比并发标记的时间短。
  2. 实践证明,增量时的CMS收集器效果很一般,在目前版本中,i-CMS已经被声明为“deprecated”,即不再提倡用户使用。
  3. 要是CMS运行期间预留的内存无法满足程序需要,就会出现一次”Concurrent Mode Failure“失败,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,这样停顿时间就很长了。

3.5.7 G1收集器

  1. G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的来由)。

3.6 内存分配与回收策略

3.6.2 大对象直接进入老年代

  1. 最典型的大对象就是那种很长的字符串以及数组(笔者列出的例子中的byte[]数组就是典型的大对象)。

3.6.5 空间分配担保

  1. JDK 6 Update 24之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

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

4.2 JDK的命令行工具

4.2.4 jmap: Java内存映像工具

  1. 又或者在Linux系统下通过Kill -3命令发送进程退出信号”吓唬“一下虚拟机,也能拿到dump文件。

4.3 JDK的可视化工具

4.3.1 JConsole: Java监视与管理控制台

  1. readBytes方法检查到流没有更新时会立刻归还执行令牌,这种等待只消耗很小的CPU资源。
  2. 没有归还线程执行令牌的动作,会在空循环上用尽全部执行时间直到线程切换,这种等待会消耗较多CPU资源。
  3. 造成死锁的原因是Integer.valueOf()方法基于减少对象创建次数和节省内存的考虑,[-128, 127]之间的数字会被缓存,当valueOf()方法传入参数在这个范围之内,将直接返回缓存中的对象。也就是说,代码中调用了200次Integer.valueOf()方法一共就只返回了两个不同的对象。

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

5.2 案例分析

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

  1. 我们仅仅需要保障集群具备亲合性,也就是均衡器按一定的规则算法(一般根据SessionID分配)将一个固定的用户请求永远分配到固定的一个集群节点进行处理即可,这样程序开发阶段就基本不用为集群环境做什么特别的考虑了。

5.2.3 堆外内存导致的溢出错误

  1. 大家知道操作系统对每个进程能管理的内存是有限制的,这台服务器使用的32位Windows平台的限制是2GB,其中划了1.6GB给Java堆,而Direct Memory内存并不算入1.6GB的堆之内,因此它最大也只能在剩余的0.4GB空间中分出一部分。在此应用中导致溢出的关键是:垃圾收集进行时,虚拟机虽然会对Direct Memory进行回收,但是Direct Memory却不能像新生代、老年代那样,发现空间不足了就通知收集器进行垃圾回收,它只能等待老年代满了后Full GC,然后“顺便地”帮它清理掉内存的废弃对象。

5.2.4 外部命令导致系统缓慢

  1. 执行这个shell脚本是通过Java的Runtime.getRuntime().exec()方法来调用的。这种调用方式可以达到目的,但是它在Java虚拟机中是非常消耗资源的操作,即使外部命令本身能很快执行完毕,频繁调用时创建进程的开销也非常可观。Java虚拟机执行这个命令的过程时:首先克隆一个和当前虚拟机拥有一样环境变量的进城,再用这个新的进城去执行外部命令,最后再退出这个进城。如果频繁执行这个操作,系统的消耗会很大,不仅是CPU,内存负担也很重。

5.2.6 不恰当数据结构导致内存占用过大

  1. HashMap<Long,Long> 结构中,只有Key和Value所存放的两个长整型数据是有效数据,共16B( 2×8B )。这两个长整型数据包装成java.lang.Long对象之后,就分别具有8B的Mark Word、8B的Klass指针,在加8B存储数据的long值。在这两个Long对象组成Map.Entry之后,又多了16B的对象头,然后一个8B的next字段和4B的int型的hash字段,为了对齐,还必须添加4B的空白填充,最后还有HashMap中对这个Entry的8B的引用,这样增加两个长整型数字,实际耗费的内存为 (Long(24B)×2)+Entry(32B)+HashMapRef(8B)=88B ,空间效率为 16B/88B=18% ,实在太低了。看完HashMap的源码之后,就全都明白了

5.3 实战:Eclipse运行速度调优

5.3.3 编译时间和类加载时间的优化

  1. 编译时间是指虚拟机的JIT编译器编译热点代码的耗时。我们知道Java语言为了实现跨平台的特性,Java代码编译出来后形成的Class文件中存储的是字节码(ByteCode),虚拟机通过解释方式执行字节码命令,比起C/C++编译成本地二进制代码来说,速度要慢不少。

5.3.4 调整内存设置控制垃圾收集频率

  1. 由于我们做的测试是在测程序的启动时间,所以类加载和编译时间在这项测试中的影响力被大幅度放大了。
  2. 严格来说,不包括正在执行native代码的用户线程,因为native代码一般不会改变Java对象的引用关系,所以没有必要挂起它们来等待垃圾回收。
  3. Eclipse启动时,Full GC大多数是由于老年代容量扩展而导致的,由永久代空间扩展而导致的也有一部分。

第6章 类文件结构

6.2 无关性的基石

  1. Java虚拟机不和包括Java在内的任何语言绑定,它只与“Class文件”这种特定的二进制文件格式所关联,Class文件中包含了Java虚拟机指令集和符号表以及若干其他辅助信息。

6.3 Class文件的结构

  1. 当遇到需要占用8位字节以上空间的数据项时,则会按照高位在前的方式分割成若干个8位字节进行存储。
  2. 无论是无符号数还是表,当需要描述同一类型但数量不定的多个数据时,经常会使用一个前置的容量计数器加若干个连续的数据项的形式,这时称这一系列连续的某一类型的数据为某一类型的集合。

6.3.1 魔数与Class文件的版本

  1. 很多文件存储标准中都使用魔数来进行身份识别,譬如图片格式,如gif或者jpeg等在文件头中都存有魔数。
  2. Class文件的魔数的获得很有“浪漫气息”,值为:0xCAFEBABE(咖啡宝贝?),这个魔数值在Java还称做“Oak”语言的时候(大约是1991年前后)就已经确定下来了。

6.3.2 常量池

  1. 在Class文件格式规范制定之时,设计者将第0项常量空出来是有特殊考虑的,这样做的目的在于满足后面某些指向常量池的索引值的数据在特定情况下需要表达“不引用任何一个常量池项目”的含义,这种情况就可以把索引值置为0来表示。

6.3.6 方法表集合

  1. 方法里的Java代码,经过编译器编译成字节码指令后,存放在方法属性表集合中的一个名为“Code”的属性里面,属性表作为Class文件格式中最具扩展性的一种数据项目。
  2. 特征签名就是一个方法中各个参数在常量池中的字段符号引用的集合,也就是因为返回值不会包含在特征签名中,因此Java语言里面是无法仅仅依靠返回值的不同来对一个已有方法进行重载的。

6.3.7 属性表集合

  1. 方法参数(包括实例方法中的隐藏参数“this”)、显式异常处理器的参数(Exception Handler Parameter,就是try-catch语句中catch块所定义的异常)、方法体中定义的局部变量都需要使用局部变量表来存放。
  2. Javac编译器会根据变量的作用域来分配Slot给各个变量使用。
  3. 通过Javac编译器编译的时候把对this关键字的访问转变为对一个普通方法参数的访问,然后在虚拟机调用实例方法时自动传入此参数。因此在实例方法的局部变量表中至少会存在一个指向当前对象实例的局部变量,局部变量表中也会预留出第一个Slot位来存放对象实例的引用,方法参数值从1开始计算。
  4. 此时x的值复制一份副本到最后一个本地变量表的Slot中。
  5. 不要与前面刚刚讲解完的异常表产生混淆。Exceptions属性的作用是列举出方法中可能抛出的受查异常(Checked Exceptions),也就是方法描述时在throws关键字后面列举的异常。
  6. start_pc和length属性分别代表了这个局部变量的生命周期开始的字节码偏移量及其作用范围覆盖的长度,两者结合起来就是这个局部变量在字节码之中的作用域范围。
  7. 目前Sun Javac编译器的选择是:如果同时使用final和static来修饰一个变量(按照习惯,这里称“常量”更贴切),并且这个变量的数据类型是基本类型或者java.lang.String的话,就生成ConstantValue属性来进行初始化,如果这个变量没有被final修饰,或者并非基本类型及字符串,则将会选择在 <clinit> 方法(类构造器,class init)中进行初始化。
  8. 所有由非用户代码产生的类、方法及字段都应当至少设置Synthetic属性和ACC_SYNTHETIC标志位中的一项,唯一的例外是实例构造器 <init> 方法和类构造器 <clinit> 方法。
  9. 省略了在运行期通过数据流分析去确认字节码的行为逻辑合法性的步骤,而是在编译阶段将一系列的验证类型(Verification Types)直接记录在Class文件之中,通过检查这些验证类型代替了类型推导过程。
  10. 运行期无法像C#等有真泛型支持的语言那样,将泛型类型与用户定义的普通类型同等对待,例如运行期做反射时无法获得泛型信息。Signature属性就是为了弥补这个缺陷而增设的,现在Java的API能够获取泛型类型,最终的数据来源也就是这个属性。

6.4 字节码指令简介

  1. 由于Java虚拟机采用面向操作数栈而不是寄存器的架构,所以大多数的指令都不包含操作数,只有一个操作码。

6.4.10 同步指令

  1. 方法级的同步是隐式的,即无须通过字节码指令来控制,它实现在方法调用和返回操作之中。
  2. 当方法调用时,调用指令就会检查方法的ACC_SYNCHRONIZED访问标志是否被设置,如果设置了,执行线程就要求先成功持有管程,然后才能执行方法,最后当方法完成(无论是正常完成还是非正常完成)时释放管程(Monitor)。
  3. 同步一段指令集序列通常是由Java语言中的synchronized语句块来表示的,Java虚拟机的指令集中有monitorenter和monitorexit两条指令来支持synchronized关键字的语义,正确实现synchronized关键字需要Javac编译器与Java虚拟机两者共同协作支持。

6.5 公有设计和私有实现

  1. 将输入的Java虚拟机代码在加载或执行时翻译成宿主机CPU的本地指令集(即JIT代码生成技术)。

第7章 虚拟机类加载机制

7.2 类加载的时机

  1. 实际上NotInitialization的Class文件之中并没有ConstClass类的符号引用入口,这两个类在编译成Class之后就不存在任何联系了。

7.3 类加载的过程

7.3.1 加载

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

7.3.2 验证

  1. 这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流。

7.3.3 准备

  1. 这个阶段中有两个容易产生混淆的概念需要强调一下,首先,这时候进行内存分配的仅包括类变量(被static修饰的变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一起分配在Java堆中。

7.4 类加载器

  1. 虚拟机设计团队把类加载阶段中的”通过一个类的全限定名来获取描述此类的二进制字节流“这个动作放到Java虚拟机外部去实现,以便让应用程序自己决定如何去获取所需要的类。

7.4.1 类与类加载器

  1. 对于任意一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。
  2. 两行输出结果中,从第一句可以看出,这个对象确实是类org.fenixsoft.classloading.ClassLoaderTest实例化出来的对象,但从第二句可以发现,这个对象与类org.fenixsoft.classloading.ClassLoaderTest做所属类型检查的时候却返回了false,这是因为虚拟机中存在了两个ClassLoaderTest类,一个是由系统应用程序类加载器加载的,另外一个是由我们自定义的类加载器加载的,虽然都来自同一个Class文件,但依然是两个独立的类,做对象所属类型检查时结果自然为false。

7.4.2 双亲委派模型

  1. 扩展类加载器(Extension ClassLoader):这个加载器由sun.misc.Launcher$ExtClassLoader实现,它负责加载 <JAVA_HOME>\lib\ext 目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器。
  2. 双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。
  3. Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类。相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为java.lang.Object的类,并放在程序的ClassPath中,那系统中将出现多个不同的Object类,Java类型体系中最基础的行为也就无法保证,应用程序也将会变得一片混乱。如果读者有兴趣的话,可以尝试去编写一个与rt.jar类库中已有类重名的Java类,将会发现可以正常编译,但永远无法被加载运行。
  4. 双亲委派模型对于保证Java程序的稳定运作很重要,但它的实现却非常简单,实现双亲委派的代码都集中在java.lang.ClassLoader的loadClass()方法之中,逻辑清晰易懂:先检查是否已被加载过,若没有加载则调用父加载器的loadClass()方法,若父加载器为空则默认使用启动类加载器作为父加载器。如果父类加载失败,抛出ClassNotFoundException异常后,再调用自己的findClass()方法进行加载。
protected synchronized Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    Class c = findLoadedClass(name);
    if (c==null) {
        try {
            if (parent!=null) 
                c = parent.loadClass(name, false);
            else
                c = findBootstrapClassOrNull(name);
        } catch (ClassNotFoundException e)
        {}
        if (c==null)
            findClass(name);
    }
    if (resolve)
        resolveClass(c);
    return c;
}

7.4.3 破坏双亲委派模型

  1. 为了向前兼容,JDK 1.2之后的java.lang.ClassLoader添加了一个新的protected方法findClass(),在此之前,用户去继承java.lang.ClassLoader的唯一目的就是为了重写loadClass()方法,因为虚拟机在进行类加载的时候会调用加载器的私有方法loadClassInternal(),而这个方法的唯一逻辑就是去调用自己的loadClass()。
  2. JDK 1.2之后已不再提倡用户再去覆盖loadClass()方法,而应当把自己的类加载器逻辑写到findClass()方法中,在loadClass()方法的逻辑里如果父类加载失败,则会调用自己的findClass()方法来完成加载,这样就可以保证新写出来的类加载器是符合双亲委派规则的。
  3. Java中所有涉及SPI的加载动作基本上都采用这种方式,例如JNDI、JDBC、JCE、JAXE和JBI等。
  4. OSGi实现模块化热部署的关键则是它自定义的类加载器机制的实现。每一个程序模块(OSGi中成为Bundle)都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。
  5. OSGi中对类加载器的使用是很值得学习的,弄懂了OSGi的实现,就可以算是掌握了类加载器的精髓。

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

8.2 运行时栈帧结构

8.2.1 局部变量表

  1. Java虚拟机规范中没有明确规定reference类型的长度,它的长度与实际使用32还是64位虚拟机有关,如果是64位虚拟机,还与是否开启某些对象指针压缩的优化有关,这里暂且只取32位虚拟机的reference长度。
  2. 由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot是否为原子操作,都不会引起数据安全问题。
  3. 第一次修改中,代码虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。
  4. 在虚拟机使用解释器执行时,通常与概念模型还比较接近,但经过JIT编译器后,才是虚拟机执行代码的主要方式,赋null值的操作在经过JIT编译优化后就会被消除掉,这时候将变量设置为null就是没有意义的。字节码被编译为本地代码后,对GC Roots的枚举也与解释执行时期有巨大差别,以前面例子来看,代码清单8-2在经过JIT编译后,System.get()执行时就可以正确地回收掉内存,无须写成代码清单8-3的样子。

8.2.2 操作数栈

  1. 让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。

8.3 方法调用

8.3.1 解析

  1. 在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
  2. 只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。

8.3.2 分派

  1. 我们把上面代码中的“Human”称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
  2. 代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。
  3. 所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。
  4. 这种模糊的结论在由0和1构成的计算机世界中算是比较“稀罕”的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。
  5. 但不会匹配到byte和short类型的重载,因为char到byte或short的转型是不安全的。
  6. 7个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符’a’被当做了一个数组元素。
  7. 找哪个重载方法是静态分派,重载方法嘛,当然是同一个类里的方法啦!找哪个重写方法是动态分派,重写方法嘛,当然是子类方法重写父类方法啦! 静态方法既考虑方法的接收者,也考虑方法的参数;动态分派只考虑方法的接收者。接收者嘛,就是调用者啦,也就是方法的所有者啦!
  8. 由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。
  9. 虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

8.3.3 动态类型语言支持

  1. 否则,哪怕obj属于一个确实有用println(String)方法,但与PrintStream接口没有继承关系,代码依然不可能运行——因为类型检查不合法。
  2. 而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特定也是动态类型语言的一个重要特征。
  3. 而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个“引用”。
  4. 从本质上讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。
  5. Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息多。

8.4 基于栈的字节码解释执行引擎

8.4.1 解释执行

  1. Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java程序的编译就是半独立的实现。

8.4.2 基于栈的指令集与基于寄存器的指令集

  1. 虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。

第9章 类加载及执行子系统的案例与实战

9.2 案例分析

9.2.1 Tomcat: 正统的类加载器架构

  1. 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。
  2. 被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常,每一个目录都会有一个相应的自定义类加载器去加载放置在厘米那的Java类库。
  3. 其中WebApp类加载器和Jsp类加载器通常会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个Jsp类加载器。
  4. 而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class,它出现的目的就是为了被丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的Jsp类加载器来实现JSP文件的HotSwap功能。

9.2.2 OSGi: 灵活的类加载器架构

  1. java.lang.ClassLoader.loadClass()是一个synchronized方法。
  2. 对于单个虚拟机下的应用,从开发初期就建立在OSGi上是一个很不错的选择,这样便于约束依赖。

9.2.4 Retrotranslator: 跨越JDK版本

  1. 如自动装箱拆箱,实际上就是编译器在程序中使用到包装对象的地方自动插入了很多Integer.valueOf()、Float.valueOf()之类的代码;变长参数在编译之后就自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经擦除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码。
  2. 如JDK 1.5引入的java.util.concurrent包,实际是由多线程大师Doug Lea开发的一套并发包,在JDK 1.5出现之前就已经存在(那时候名字叫做dl.util.concurrent,引入JDK时由作者和JDK开发团队共同做了一些改进),所以要在旧的JDK中支持这部分功能,以独立类库的方式便可实现。

9.3 实战:自己动手实现远程执行功能

9.3.2 思路

  1. 另外一种思路是直接在客户端编译好,把字节码而不是Java代码传到服务器,这听起来好像有点投机取巧,一般来说确实不应该假定客户端一定具有编译代码的能力,但是既然程序员会写Java代码去给服务端排查问题,那么很难想象他的机器上会连编译Java程序的环境都没有。

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

10.1 概述

  1. 相当多新生的Java语法特性,都是靠编译器的“语法糖”来实现,而不是依赖虚拟机的底层改进来支持,可以说,Java中即时编译器在运行期的优化过程对于程序运行来说更重要,而前段编译器在编译期的优化过程对于程序编码来说关系更加密切。

10.2 Javac编译器

10.2.4 语义分析与字节码生成

  1. 局部变量与字段(实例变量、类变量)是有区别的,它在常量池中没有CONSTANT_Fieldref_info的符号引用,自然就没有访问标志(Access_Flags)的信息,甚至可能连名称都不会保留下来(取决于编译时的选项),自然在Class文件中不可能知道一个局部变量是不是声明为final了。因此,将局部变量声明为final,对运行期是没有影响的,变量的不变性仅仅由编译器在编译期间保障。
  2. Java中最常用的语法糖主要是前面提到过的泛型(泛型并不一定都是语法糖实现的,如C#的泛型就是直接由CLR支持的)、变长参数、自动装箱/拆箱等,虚拟机运行时不支持这些语法,它们在编译阶段还原回简单的基础语法结构。
  3. 例如,前面章节中多次提到的实例构造器 <init>() 方法和类构造器 <clinit>() 方法就是在这个阶段添加到语法树之中的(注意,这里的实例构造器并不是指默认构造函数,如果用户代码中没有提供任何构造函数,那编译器将会添加一个没有参数的、访问行(public、protected或private)与当前类一致的默认构造函数,这个工作在填充符号表阶段就已经完成),这两个构造器的产生过程实际上是一个代码收敛的过程,编译器会把语句块(对于实例构造器而言是“{ }”块,对于类构造器而言是“static{ }”块)、变脸初始化(实例变量和类变量)、调用父类的实例构造器(仅仅是实例构造器, <clinit>() 方法中无须调用父类的 <clinit>() 方法,虚拟机会自动保证父类构造器的执行,但在 <clinit>() 方法中经常会生成调用java.lang.Object的 <init>() 方法的代码)等操作收敛到 <init>() <clinit>() 方法之中,并且保证一定是按先执行父类的实例构造器,然后初始化变量,最后执行语句块的顺序进行,上面所述的动作由Gen.normalizeDefs()方法来实现。

10.3 Java语法糖的味道

10.3.1 泛型与类型擦除

  1. 在Java代码中的方法特征签名只包括了方法名称、参数顺序及参数类型,而在字节码中的特征签名还包括方法返回值及受检查异常表,本书中如果指的是字节码层面的方法签名,笔者会加入限定语进行说明。
  2. 从Signature属性的出现我们还可以得出结论,擦除法所谓的擦除,仅仅是对方法的Code属性中的字节码进行擦除,实际上元数据中还是保留了泛型信息,这也是我们能通过发射手段取得参数化类型的根本依据。

自动装箱、拆箱与遍历循环

  1. 遍历循环把代码还原成了迭代器的实现,这也是为何遍历循环需要被遍历的类实现Iterable接口的原因。

10.4 实战:插入式注解处理器

10.4.2 代码实现

  1. 每一个注解处理器在运行的时候都是单例的,如果不需要改变或生成语法树的内容,process()方法就可以返回一个值为false的布尔值,通知编译器这个Round中的代码未发生变化,无须构造新的JavaCompiler实例,在这次实战的注解处理器中只对程序命名进行检查,不需要改变语法树的内容,因此process()方法的返回值都是false。

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

11.2 HotSpot虚拟机内的即时编译器

11.2.1 解释器与编译器

  1. 作为三大商用虚拟机之一的JRockit是个例外,它内部没有解释器,因此会存在本书中所说的“启动响应时间长”之类的缺点,但它主要是面向服务端的应用,这类应用一般不会重点关注启动时间。

11.2.2 编译对象与触发条件

  1. 基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易受到线程阻塞或别的外界因素的影响而扰乱热点探测。
  2. 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。
  3. 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 XX:UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。
  4. 回边计数器,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge)。(空循环实际上就可以视为自己跳转到自己的过程,因此并不算作控制流向后跳转,也不会被回边计数器统计。)
  5. 当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有,它将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。当超过阈值的时候,将会提交一个OSR编译请求,并且把回边计数器的值降低一些,以便继续在解释器中执行循环,等待编译器输出编译结果。
  6. 当计数器溢出的时候,它还会把方法计数器的值也调整到溢出状态,这样下次再进入该方法的时候就会执行标准编译过程。

11.2.3 编译过程

  1. 在默认设置下,无论是方法调用产生的即时编译请求,还是OSR编译请求,虚拟机在代码编译器还未完成之前,都仍然将按照解释方式继续执行,而编译动作则在后台的编译线程中进行。
  2. 在第二个阶段,一个平台相关的后端从HIR中产生低级中间代码表示(Low-Level Intermediate Representation, LIR),而在此之前会在HIR上完成另外一些优化,如空值检查消除、范围检查消除等,以便让HIR达到更高效的代码表示形式。

11.2.4 查看及分析即时编译结果

  1. 方法doubleValue()被内联编译到calcSum()中,而calcSum()又被内联编译到方法main()中,所以虚拟机再次执行main()方法的时候(举例而已,main()方法并不会运行两次),calcSum()和doubleValue()方法都不会再被调用,它们的代码逻辑都被直接内联到main()方法中了。
  2. 其中doubleValue()方法出现了两次,这是由于该方法的编译结果存在标准编译和OSR编译两个版本。
  3. 到今天还有许多程序设计的入门教程把空循环当作程序延时的手段来介绍,在Java中这样的做法真的能起到延时的作用吗?
  4. 每一个方块就代表了一个程序的基本块(Basic Block),基本块的特点是只有唯一的一个入口和唯一的一个出口,只要基本块中第一条指令执行了,那么基本块内所有执行都会按照顺序仅执行一次。
  5. doubleValue()方法虽然只有简单的两行字,但是按基本块划分后,形成的图形结构要比想象中复杂得多,这一方面是要满足Java语言所定义的安全需要(如类型安全、空指针检查)和Java虚拟机的运作需要(如Safepoint轮询),另一方面是由于有些程序代码中一行语句就可能形成好几个基本块(例如循环)。
  6. 到了最后的“Final Code”阶段,不仅空循环的开销消除了,许多语言安全和Safepoint轮询的操作也一起消除了,因为编译器判断即使不做这些安全保障,虚拟机也不会受到威胁。

11.3 编译优化技术

  1. 在JDK 1.3之后,Javac就去除了 -O选项,不会生成任何字节码级别的优化代码了。

11.3.3 数组边界检查消除

  1. 更加常见的情况是数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间 [0,foo.length) 之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作。
  2. 虚拟机会注册一个Segment Fault信号的异常处理器(伪代码中的uncommon_trap()),这样当foo不为空的时候,对value的访问是不会额外消耗一次对foo判空的开销的。代价就是当foo真为空时,必须转入到异常处理器中恢复并抛出NullPointException异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。
if (foo!=null) {
    return foo.value;
} else {
    throw new NullPointException();
}

try {
    return foo.value;
} catch(segment_fault) {
    uncommon_trap();
}

11.3.4 方法内联

  1. 它最重要的意义是为其他优化手段建立良好的基础。
  2. 实际上Java虚拟机中的内联过程远远没有那么简单,因为如果不是即时编译器做了一些特别的努力,按照经典编译原理的优化理论,大多数的Java方法都无法进行内联。
  3. 无法内联的原因其实在第8章中讲解Java方法解析和分派调用的时候就已经介绍过。只有使用invokespecial指令调用的私有方法、实例构造器、父类方法以及使用invokestatic指令调用的静态方法才是在编译期进行解析的,除了上述4种方法之外,其他的Java方法调用都需要在运行时进行方法接收者的多态选择,并且都有可能存在多于一个版本的方法接收者(最多再除去被final修饰的方法这种特殊情况,尽管它使用invokevirtual指令调用,但也是非虚方法,Java语言规范中明确说明了这点),简而言之,Java语言中默认的实例方法是虚方法。对于一个虚方法,编译器做内联的时候根本无法确定应该使用哪个方法版本。
  4. 如果向CHA查询出来的结果是有多个版本的目标方法可供选择,则编译器还将会进行最后一次努力,使用内联缓存(Inline Cache)来完成方法内联,这是一个建立在目标方法正常入口之前的缓存,它的工作原理大致是:在未发生方法调用之前,内联缓存为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派。

11.3.5 逃逸分析

  1. 栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储。

11.4 Java与C/C++的编译器对比

  1. Java语言中对象的内存分配都是堆上进行的,只有方法中的局部变量才能在栈上分配。(Java中非逃逸对象的标量替换可以看作是一种高度优化后的栈上分配,但它相当于把对象拆散成局部变量再进行栈上分配,而不是C/C++那种程序代码可控的栈上分配方式。)而C/C++的对象则有多种分配方式,既可能在堆上分配,又可能在栈上分配,如果可以在栈上分配线程私有的对象,将减轻内存回收的压力。

第12章 Java内存模型与线程

12.2 硬件的效率与一致性

  1. 在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。
  2. “内存模型”一词,可以理解为在特定的操作协议下,对特定的内存或高速缓存进行读写访问的过程抽象。

12.3 Java内存模型

12.3.1 主内存与工作内存

  1. 不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。(volatile变量依然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)

12.3.3 对于volatile型变量的特殊规则

  1. 在各个线程的工作内存中,volatile变量也可以存在不一致的情况,但由于每次使用之前都要先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题。
  2. 由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized或java.util.concurrent中的原子类)来保证原子性。

    运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
    变量不需要与其他的状态变量共同参与不变约束。

  3. 如果定义initialized变量时没有使用volatile修饰,就可能会由于指令重排序的优化,导致位于线程A中最后一句的代码“initialized=true”被提前执行(这里虽然使用Java作为伪代码,但所指的重排序优化是机器级的优化操作,提前执行是指这句话对应的汇编代码被提前执行),这样在线程B中使用配置信息的代码就可能出现错误,而volatile关键字则可以避免此类情况的发生。

  4. 有volatile修饰的变量,赋值后(前面 mov %eax,0x150 (%esi) 这句便是赋值操作)多执行了一个” lock addl $0x0, (%esp) “操作,这个操作相当于一个内存屏障(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个CPU访问内存时,并不需要内存屏障。关键在于lock前缀,查询IA32手册,它的作用是使得本CPU的Cache写入了内存,该写入动作也会引起别的CPU或者别的内核无效化(Invalidate)其Cache,这种操作相当于对Cache中的变量做了一次前面介绍Java内存模式中所说的”store和write“操作。
  5. lock addl $0x0, (%esp)指令把修改同步到内存时,意味着所有之前的操作都已经执行完成,这便形成了”指令重排序无法越过内存屏障“的效果。
  6. volatile变量读操作的性能消耗与普通变量几乎没有什么差别,但是写操作则可能会慢一些,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。
  7. 这条规则要求在工作内存中,每次使用V前都必须先从主内存刷新最新的值,用于保证能看见其他线程对变量V所做的修改后的值。
  8. DCL单例模式
public class Singleton {
    private volatile static Singleton instance;
    public static Singleton getInstance() {
        if (instance==null) {
            synchronized(Singleton.class) {
                if(instance==null) {
                    instance = new Singleton();
                }
            }
        return instance;
    }
}

12.3.5 原子性、可见性与有序性

  1. 对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)。

12.4 Java与线程

12.4.1 线程的实现

  1. 我们注意到Thread类与大部分的Java API有显著的差别,它的所有关键方法都是声明为Native的。在Java API中,一个Native方法往往意味着这个方法没有使用或无法使用平台无关的手段来实现(当然也可能是为了执行效率而使用Native方法,不过,通常最高效率的手段也就是平台相关的手段)。
  2. 程序一般不会直接去使用内核线程,而是去使用内核线程的一种高级接口——轻量级进程(Light Weight Process, LWP),轻量级进程就是我们通常意义上所讲的线程。
  3. 由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。(使用内核线程实现)
  4. 由于操作系统只把处理器资源分配到进程,那诸如”阻塞如何处理“、”多处理器系统中如何将线程映射到其他处理器上“这类问题解决起来将会异常困难,甚至不可能完成。因而使用用户线程实现的程序一般都比较复杂,除了以前在不支持多线程的操作系统中(如DOS)的多线程程序与少数有特殊需求的程序外,现在使用用户线程的程序越来越少, ,Java、Ruby等语言都曾经使用过用户线程,最终又都放弃使用它。(使用用户线程实现)
  5. 操作系统支持怎样的线程模型,在很大程度上决定了Java虚拟机的线程是怎样映射的。

12.4.2 Java线程调度

  1. 例如,在Windows系统中存在一个称为”优先级推进器“(Priority Boosting,当然它可以被关闭掉)的功能,它的大致作用就是当系统发现一个线程执行得特别”勤奋努力“的话,可能会越过线程优先级去为它分配执行时间。

12.4.3 状态转换

  1. Runnable包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着CPU为它分配执行时间。
  2. ”阻塞状态“在等待着获取到一个排他锁,这个事件将在另外一个线程放弃这个锁的时候发生;而”等待状态“则是在等待一段时间,或者唤醒动作的发生。

第13章 线程安全与锁优化

13.2 线程安全

13.2.1 Java语言中的线程安全

  1. 不妨想一想java.lang.String类的对象,它是一个典型的不可变对象,我们调用它的substring()、replace()和concat()这些方法都不会影响它原来的值,只会返回一个新构造的字符串对象。
  2. Thread类的suspend()和resume()方法已经被JDK声明废弃(@Deprecated)了。常见的线程对立的操作还有System.setIn()、System.setOut()和System.runFinalizersOnExit()等。

13.2.2 线程安全的实现方法

  1. 在Java中,最基本的互斥手段就是synchronized关键字,synchronized关键字经过编译之后,会在同步块的前后分别形成monitorenter和monitorexit这两个字节码指令,这两个字节码都需要一个reference类型的参数来指明要锁定和解锁的对象。如果Java程序中的synchronized明确指定了对象参数,那就是这个对象的reference;如果没有明确指定,那就根据synchronzied修饰的是实例方法还是类方法,去取对应的对象实例或Class对象来作为锁对象。 所以单例里面的getInstance和instance都要声明为static!!!因为要将Class对象作为锁对象!!!
  2. Java的线程是映射到操作系统的原生线程之上的,如果要阻塞或唤醒一个线程,都需要操作系统来帮忙完成,这就需要从用户态转到核心态中,因此状态转换需要耗费很多的处理器时间。对于代码简单的同步块(如被synchronized修饰的getter()或setter()方法),状态转换消耗的时间有可能比用户代码执行的时间还要长。所以synchronized是Java语言中一个重量级(Heavyweight)的操作,有经验的程序员都会在确实必要的情况下才使用这种操作。
  3. JDK 1.6发布之后,人们就发现synchronized与ReentrantLock的性能基本上是完全持平了。虚拟机在未来的性能改进中肯定也会更加偏向于原生的synchronized,所以还是提倡在synchronized能实现需求的情况下,优先考虑使用synchronized来进行同步。
  4. incrementAndGet()方法在一个无线循环中,不断尝试将一个比当前值大1的新值赋给自己。如果失败了,那说明在执行“获取-设置”操作的时候已经有了修改,于是再次循环进行下一次操作,直到设置成功为止。
  5. 可重入代码有一些共同的特征,例如不依赖存储在堆上的数据和共用的系统资源、用到的状态量都由参数中传入、不调用非可重入的方法等。
  6. incrementAndGet()方法的JDK源码
public final int incrementAndGet() {
    for(;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

13.3 锁优化

13.3.1 自旋锁与自适应自旋

  1. 前面我们讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给系统的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。为了让线程等待,我们只需让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。
  2. 如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而它将允许自旋等待持续相对更长的时间,比如100次循环。另外,如果对于某个锁,自旋很少成功获得过,那在以后要获取这个锁时将可能省略掉自旋过程,以避免浪费处理器资源。

13.3.3 锁粗化

  1. 如果虚拟机探测到有这样一串零碎的操作都对同一个对象加锁,将会把加锁同步的范围扩展(粗化)到整个操作序列的外部,以代码清单13-7为例,就是扩展到第一个append()操作之前直至最后一个append()操作之后,这样只需要加锁一次就可以了。

13.3.4 轻量级锁

  1. 考虑到虚拟机的空间效率,Mark Word被设计成一个非固定的数据结构以便在极小的空间内存储尽量多的信息,它会根据对象的状态复用自己的存储空间。
  2. 如果这个更新操作失败了,虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那就可以直接进入同步块继续执行,否则说明这个锁对象已经被其他线程抢占了。如果有两条以上的线程争用同一个锁,那轻量级锁就不再有效,要膨胀为重量级锁,锁标志的状态值变为“10”,Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也要进入阻塞状态。
  3. 如果替换失败,说明有其他线程尝试过获取该锁,那就要在释放锁的同时,唤醒被挂起的线程。
  4. 轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”,这是一个经验数据。
    原文作者:java虚拟机
    原文地址: https://blog.csdn.net/youngsend/article/details/47833363
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞