附录B 展望Java技术的未来(2013年版)

本书第1版和2版中的“展望Java技术的未来”分别成文于2011年和2013年,近十年时间已经过去, 当时畅想的Java新发展新变化全部如约而至,这部分内容已不再有“展望”的价值。笔者在更新第3版时重写了全部相关内容,并把第2版的“展望”的原文挪到附录之中。假若Java的未来依旧灿烂精彩,假若下一个十年本书还会有第4、第5版,那希望届时能在附录中回首今日,去回溯哪些预测成为现实,哪些改进中途夭折。

在2005年,Java语言诞生10周年的SunOne技术大会上,Java语言之父James Gosling做过题为《Java 技术下一个十年》的演讲。笔者不具备James Gosling博士那样高屋建瓴的视角,这里仅从Java平台中几个新生的但已经开始展现出蓬勃之势的技术发展点来看一下后续一至两个JDK版本内的一些很有希望的技术重点。

B.1 模块化

模块化是解决应用系统与技术平台越来越复杂、越来越庞大的一个重要途径。无论是开发人员还是产品最终用户,都不希望为了系统中一小块的功能而不得不下载、安装、部署及维护整套庞大的系统。站在整个软件工业化的高度来看,模块化是建立各种功能标准件的前提。最近几年OSGi技术的迅速发展、各个厂商在JCP中对模块化规范的激烈斗争^1,都能充分说明模块化技术的迫切和重要。

在未来的Java平台中,很可能会对模块化提出语法层面的支持。早在2007年,Sun公司就提出过JSR-277:Java模块系统(Java Module System),试图建立Java平台的模块化标准,但受挫于以IBM为主导的提交的JSR-291:Java SE动态组件支持(Dynamic Component Support for Java SE,实际就是OSGi R4.1)。由于模块化规范主导权的重要性,Sun不能接受一个无法由它控制的规范,在整个Java SE 6期间都拒绝把任何模块化技术内置到JDK之中。在Java SE 7发展初期,Sun公司再次提交了一个新的规范请求文档JSR-294:Java编程语言中的改进模块性支持(Improved Modularity Support in the Java Programming Language),尽管这个JSR仍然没有通过,但是Sun已经独立于JCP专家组在OpenJDK里建立了一个名为Jigsaw(拼图)的子项目来将这个规范在Java平台中转变为具体的实现。Java的模块化之争目前还没有结束,OSGi已经发布到R5.0版本,而Jigsaw从Java 7延迟至Java 8,在2012年7月又不得不宣布推迟到Java 9中发布,从这一点看来,Sun在这场战争中处于劣势,但无论胜利者是哪一方,Java 模块化已经成为一股无法阻挡的变革潮流。

B.2 混合语言

当单一的Java开发已经无法满足当前软件复杂的需求时,越来越多基于Java虚拟机的开发语言被应用到软件项目中,Java平台上的多语言混合编程正成为主流,每种语言都可以针对自己擅长的方面更好地解决问题。试想一下:在一个项目之中,并行处理用Clojure语言编写,展示层使用JRuby/Rails, 中间层则是Java,每个应用层都使用不同的编程语言来完成,而且,接口对每一层的开发者都是透明的,各种语言之间的交互不存在任何困难,就像使用自己语言的原生API一样方便[^2],因为他们最终都运行在一个虚拟机之上。

在最近的几年里,Clojure、JRuby、Groovy等新生语言的使用人数如同滚动的雪球一般增长,而运行在Java虚拟机之上的语言数量也在迅速膨胀,图B-1中列举了其中的一部分。这两点证明混合编程在我们身边已经有所应用并被广泛认可。通过特定领域的语言去解决特定领域的问题是当前软件开发应对日趋复杂的项目需求的一个方向。

除了催生大量的新语言外,许多已经有很长历史的程序语言也出现了基于Java虚拟机实现的版本,这样混合编程对许多以前使用其他语言的“老”程序员也具备相当大的吸引力,软件企业投入了大量资本的现有代码资产也能被很好地保护起来。表B-1中列举了常见语言的Java虚拟机实现版本。

image-20211127132505768

图B-1 可以运行在JVM之上的语言[^3]
表B-1 常见语言的JVM实现版本

image-20211127132541842

image-20211127132550959

对这些运行于Java虚拟机之上、Java之外的语言,来自系统级的、底层的支持正在迅速增强,以JSR-292为核心的一系列项目和功能改进(如Da Vinci Machine项目、Nashorn引擎、InovkeDynamic指令、java.lang.invoke包等),推动Java虚拟机从“Java语言的虚拟机”向“多语言虚拟机”的方向发展。

B.3 多核并行

如今,CPU硬件的发展方向已经从高频率转变为多核心,随着多核时代的来临,软件开发越来越关注并行编程的领域。早在JDK 1.5之中就已经引入java.util.concurrent包,实现了一个粗粒度的并发框架。而JDK 1.7中加入的java.util.concurrent.forkjoin包则是对这个框架的一次重要扩充。Fork/Join模式是处理并行编程的一个经典方法,如图B-2所示。虽然不能解决所有的问题,但是在它的适用范围之内, 能够轻松地利用多个CPU核心提供的计算资源来协作完成一个复杂的计算任务。通过利用Fork/Join模式,我们能够更加顺畅地过渡到多核时代。

image-20211127132623789

图B-2 Fork/Join模式示意图[^4]

在Java 8中,将会提供Lambda支持,将会极大改善目前Java语言不适合函数式编程的现状(目前Java语言使用函数式编程并不是不可以,只是会显得很臃肿),函数式编程的一个重要优点就是这样的程序天然地适合并行运行,这样对Java语言在多核时代继续保持主流语言的地位有很大帮助。

另外并行计算中必须提及的还有OpenJDK的子项目Sumatra[^5],目前显卡的算术运算能力、并行能力已经远远超过了CPU,在图形领域以外发掘显卡的潜力是近几年计算机发展的方向之一,例如C 语言的CUDA。Sumatra项目就是为Java提供使用GPU(Graphics Processing Unit)和APU(Accelerated Processing Unit)运算能力的工具,以后它将会直接提供Java语言层面的API,或者为Lambda和其他JVM语言提供底层的并行运算支持。

在JDK外围,也出现了专为实现并行计算需求的计算框架,如Apache的Hadoop Map/Reduce,这是一个简单易懂的并行框架,能够运行在由上千个商用机器组成的大型集群上,并能以一种可靠的容错方式并行处理TB级别的数据集。另外,还出现了诸如Scala、Clojure及Erlang等天生就具备并行计算能力的语言。

B.4 进一步丰富语法

Java 5曾经对Java语法进行了一次扩充,这次扩充加入了自动装箱、泛型、动态注解、枚举、可变 长参数、遍历循环等语法,使得Java语言的精确性和易用性有了很大的进步。在Java 7(由于进度压 力,许多改进已被推迟至Java 8)中,对Java语法进行了另一次大规模的扩充。Sun(Oracle)专门为改 进Java语法在OpenJDK中建立了Coin子项目[^6]来统一处理Java语法的细节修改,如对二进制数的原生 支持、在switch语句中支持字符串、“<>”操作符、异常处理的改进、简化变长参数方法调用、面向资 源的try-catch-finally语句等都是在Coin项目之中提交的内容。

除了Coin项目之外,JSR-335(Lambda Expressions for the JavaTM Programming Language)中定义的Lambda表达式[^7],也将对Java的语法和语言习惯产生很大的影响,面向函数方式的编程可能会成为主流。

B.5 64位虚拟机

几年之前,主流的CPU就开始支持64位架构。Java虚拟机也在很早之前就推出了支持64位系统的版本。但Java程序运行在64位虚拟机上需要付出比较大的额外代价:首先是内存问题,由于指针膨胀和各种数据类型对齐补白的原因,运行于64位系统上的Java应用需要消耗更多的内存,通常要比32位系统额外增加10%~30%的内存消耗;其次是多个机构的测试结果显示,64位虚拟机的运行速度在各个测试项上几乎都全面落后于32位虚拟机,两者大约有15%的性能差距。

但是在Java EE方面,企业级应用经常需要使用超过4GB的内存,对于64位虚拟机的需求是非常迫切的,但由于上述的原因,许多企业应用都仍然选择使用虚拟集群等方式继续在32位虚拟机中进行部署。Sun也注意到了这些问题,并做出了一些改善,在JDK 1.6 Update 14之后,提供了普通对象指针压缩功能(-XX:+UseCompressedOops,这个参数不建议显式设置,建议维持默认由虚拟机的Ergonomics机制自动开启),在执行代码时,动态植入压缩指令以节省内存消耗。但是开启压缩指针会增加执行代码数量,因为所有在Java堆里的、指向Java堆内对象的指针都会被压缩,这些指针的访问就需要更多的代码才可以实现,而且并不仅只是读写字段才受影响,在实例方法调用、子类型检查等操作中也受影响,因为对象实例指向对象类型的引用也被压缩了。随着硬件的进一步发展,计算机终究会完全过渡到64位的时代,这是一件毫无疑问的事情,主流的虚拟机应用也终究会从32位发展至64 位,而虚拟机对64位的支持也将会进一步完善。

[^2]: 在同一个虚拟机上跑的其他语言与Java之间的交互一般都比较容易,但非Java语言之间的交互一般 都比较烦琐。dynalang项目(http://dynalang.sourceforge.net/)就是为了解决这个问题而出现的。
[^3]: 图片来源:http://wikis.sun.com/download/attachments/16418319/OOW- 2009+Towards+A+Universal+VM.pdf。
[^4]: 图片来源:http://www.ibm.com/developerworks/cn/java/j-lo-forkjoin/。
[^5]: Sumatra项目:http://openjdk.java.net/projects/sumatra/。
[^6]: Coin项目主页:https://openjdk.java.net/projects/coin。
[^7]: Lambda项目主页:http://openjdk.java.net/projects/lambda/。

附录D 对象查询语言(OQL)简介

[^1]

D.1 SELECT子句

SELECT子句用于确定查询语句需要从堆转储快照中选择什么内容。如果需要显示堆转储快照中的对象,并且浏览这些对象的引用关系,可以使用“*”,这与传统SQL语句中的习惯是一致的,如:

1
SELECT * FROM java.lang.String

1.选择特定的显示列

查询也可以选择特定的需要显示的字段,如:

1
SELECT toString(s), s.count, s.value FROM java.lang.String s

查询可以通过“@”符号来使用Java对象的内存属性访问器。MAT提供了一系列的内置函数来获取与分析相关的信息,如:

1
SELECT toString(s), s.@usedHeapSize, s.@retainedHeapSize FROM java.lang.String s

关于对象属性访问器的具体内容,可以参见下文的“属性访问器”。

2.使用列别名

可以使用AS关键字来对选择的列进行命名,如:

1
2
3
4
SELECT toString(s) AS Value, 
s.@usedHeapSize AS “Shallow Size”,
s.@retainedHeapSize AS “Retained Size”
FROM java.lang.String s

可以使用AS RETAINED SET关键字来获得与选择对象相关联的对象集合,如:

1
SELECT AS RETAINED SET * FROM java.lang.String

3.拼合成为一个对象列表选择项目

可以使用OBJECTS关键字把SELECT子句中查找出来的数据项目转换为对象,如:

1
SELECT OBJECTS dominators(s) FROM java.lang.String s

上面例子中,dominators()函数将会返回一个对象数组,所以如果没有OBJECTS关键字,上面的查询将返回一组二维的对象数组的列表。通过使用关键字OBJECTS,迫使OQL把查询结果缩减为一维的对象列表。

4.排除重复对象

使用DISTINCT关键字可以排除结果集中的重复对象,如:

1
SELECT DISTINCT classof(s) FROM java.lang.String s

上面的例子中,classof()函数的作用是返回对象所属的Java类,当然,所有字符串对象的所属类都是java.lang.String,所以如果上面的查询中没有加入DISTINCT关键字,查询结果就会返回与快照中的字符串数量一样多的行记录,并且每行记录的内容都是java.lang.String类型。

D.2 FROM子句

1.FROM子句指定需要查询的类

OQL查询需要在FROM子句定义的查询范围内进行操作。FROM子句可以接受的查询范围有下列几种描述方式:

(1)通过类名进行查询,如:

1
SELECT * FROM java.lang.String

(2)通过正则表达式匹配一组类名进行查询,如:

1
SELECT * FROM “java\.lang\..*”

(3)通过类对象在堆转储快照中的地址进行查询,如:

1
SELECT * FROM 0xe14a100

(4)通过对象在堆转储快照中的ID进行查询,如:

1
SELECT * FROM 3022

(5)在子查询中的结果集中进行查询,如:

1
SELECT * FROM (SELECT * FROM java.lang.Class c WHERE c implements org.eclipse.mat.snapshot.model.IClass) 

上面的查询返回堆转储快照中所有实现了org.eclipse.mat.snapshot.model.IClass接口的类。下面的这 句查询语句使用属性访问器达到了同样的效果,它直接调用了ISnapshot对象的方法:

1
SELECT * FROM $snapshot.getClasses()

2.包含子类

使用INSTANCEOF关键字把指定类的子类列入查询结果集之中,如:

1
SELECT * FROM INSTANCEOF java.lang.ref.Reference

这个查询的结果集中将会包含WeakReference、SoftReference和PhantomReference类型的对象,因为它们都继承自java.lang.ref.Reference。下面这句查询语句也有相同的结果:

1
SELECT * FROM $snapshot.getClassesByName(“java.lang.ref.Reference”, true)

3.禁止查询类实例

在FROM子句中使用OBJECTS关键字可以禁止OQL把查询的范围解释为对象实例,如:

1
SELECT * FROM OBJECTS java.lang.String

这个查询的结果不是返回快照中所有的字符串,而是只有一个对象,也就是与java.lang.String类对应的Class对象。

D.3 WHERE子句

1.>=,<=,>,<,[NOT]LIKE,[NOT]IN(关系操作)

WHERE子句用于指定搜索的条件,即从查询结果中删除不需要的数据,如:

1
2
3
SELECT * FROM java.lang.String s WHERE s.count >= 100 
SELECT * FROM java.lang.String s WHERE toString(s) LIKE “.*day
SELECT * FROM java.lang.String s WHERE s.value NOT IN dominators(s)

2.=,!=(等于操作)

1
SELECT * FROM java.lang.String s WHERE toString(s) = “monday”

3.AND(条件与操作)

1
SELECT * FROM java.lang.String s WHERE s.count > 100 AND s.@retainedHeapSize > s.@usedHeapSize

4.OR(条件或操作)

“条件或操作”可以应用于表达式、常量文本和子查询之中,如:

1
SELECT * FROM java.lang.String s WHERE s.count > 1000 OR s.value.@length > 1000

5.文字表达式

文字表达式包括了布尔值、字符串、整型、长整型和null,如:

1
2
3
4
5
6
SELECT * FROM java.lang.String s 
WHERE ( s.count > 1000 ) = true
WHERE toString(s) = “monday”
WHERE dominators(s).size() = 0
WHERE s.@retainedHeapSize > 1024L
WHERE s.@GCRootInfo != null

D.4 属性访问器

1.访问堆转储快照中对象的字段

对象的内存属性可以通过传统的“点表示法”进行访问,格式为:

1
[<alias>.] <field>.<field>.<field>...

2.访问Java Bean属性

格式为:

1
[<alias>.] @<attribute> ...

使用@符号,OQL可以访问底层Java对象的内存属性。下表列出了一些常用的Java属性。

image-20211127203831071

3.调用OQLJava方法

格式为:

1
[<alias>.]@<方法>([<表达式>,<表达式>])...

加“()”会将MAT解释为一个OQLJava方法调用。这个方法的调用是通过反射执行的。常见的OQL Java方法如下:

image-20211127203928619

4.OQL的内建函数

格式为:

1
<function>(<parameter>)

image-20211127204007219

D.5 OQL语言的BNF范式

image-20211127204029605

image-20211127204041963

[^1]: 本文翻译自Eclipse Memory Analyzer Tool(MAT,Eclipse出品的内存分析工具)的OQL帮助文档。

附录E JDK历史版本轨迹

本附录列出了从Java诞生开始直到2019年9月发布的JDK 13为止的Java全部更新版本、发布日期和关键的更新内容。其中提及的绝大部分的JDK历史版本(JDK 1.1.6之后的版本),以及JDK所附带的各种工具的历史版本,都可以从Oracle公司的归档网站^1上下载到。

image-20211127140500093

image-20211127140510375

image-20211127140525403

image-20211127140547720

image-20211127140559752

image-20211127140611637

image-20211127140622621

image-20211127140639386

image-20211127140651247

image-20211127140701869

13.4 本章小结

本章介绍了线程安全所涉及的概念和分类、同步实现的方式及虚拟机的底层运作原理,并且介绍了虚拟机为实现高效并发所做的一系列锁优化措施。

能够写出高性能、高伸缩性的并发程序是一门艺术,而了解并发在系统底层是如何实现的,则是掌握这门艺术的前提条件,也是成长为高级程序员的必备知识之一。

13.3.5 偏向锁

偏向锁也是JDK 6中引入的一项锁优化措施,它的目的是消除数据在无竞争情况下的同步原语, 进一步提高程序的运行性能。如果说轻量级锁是在无竞争的情况下使用CAS操作去消除同步使用的互斥量,那偏向锁就是在无竞争的情况下把整个同步都消除掉,连CAS操作都不去做了。

偏向锁中的“偏”,就是偏心的“偏”、偏袒的“偏”。它的意思是这个锁会偏向于第一个获得它的线程,如果在接下来的执行过程中,该锁一直没有被其他的线程获取,则持有偏向锁的线程将永远不需要再进行同步。

如果读者理解了前面轻量级锁中关于对象头Mark Word与线程之间的操作过程,那偏向锁的原理就会很容易理解。假设当前虚拟机启用了偏向锁(启用参数-XX:+UseBiased Locking,这是自JDK 6 起HotSpot虚拟机的默认值),那么当锁对象第一次被线程获取的时候,虚拟机将会把对象头中的标志位设置为“01”、把偏向模式设置为“1”,表示进入偏向模式。同时使用CAS操作把获取到这个锁的线程的ID记录在对象的Mark Word之中。如果CAS操作成功,持有偏向锁的线程以后每次进入这个锁相关的同步块时,虚拟机都可以不再进行任何同步操作(例如加锁、解锁及对Mark Word的更新操作等)。

一旦出现另外一个线程去尝试获取这个锁的情况,偏向模式就马上宣告结束。根据锁对象目前是否处于被锁定的状态决定是否撤销偏向(偏向模式设置为“0”),撤销后标志位恢复到未锁定(标志位为“01”)或轻量级锁定(标志位为“00”)的状态,后续的同步操作就按照上面介绍的轻量级锁那样去执行。偏向锁、轻量级锁的状态转化及对象Mark Word的关系如图13-5所示。

image-20211127125341034

图13-5 偏向锁、轻量级锁的状态转化及对象Mark Word的关系

细心的读者看到这里可能会发现一个问题:当对象进入偏向状态的时候,Mark Word大部分的空间(23个比特)都用于存储持有锁的线程ID了,这部分空间占用了原有存储对象哈希码的位置,那原来对象的哈希码怎么办呢?

在Java语言里面一个对象如果计算过哈希码,就应该一直保持该值不变(强烈推荐但不强制,因为用户可以重载hashCode()方法按自己的意愿返回哈希码),否则很多依赖对象哈希码的API都可能存在出错风险。而作为绝大多数对象哈希码来源的Object::hashCode()方法,返回的是对象的一致性哈希码(Identity Hash Code),这个值是能强制保证不变的,它通过在对象头中存储计算结果来保证第一次计算之后,再次调用该方法取到的哈希码值永远不会再发生改变。因此,当一个对象已经计算过一致性哈希码后,它就再也无法进入偏向锁状态了;而当一个对象当前正处于偏向锁状态,又收到需要计算其一致性哈希码请求[^1]时,它的偏向状态会被立即撤销,并且锁会膨胀为重量级锁。在重量级锁的实现中,对象头指向了重量级锁的位置,代表重量级锁的ObjectMonitor类里有字段可以记录非加锁状态(标志位为“01”)下的Mark Word,其中自然可以存储原来的哈希码。

偏向锁可以提高带有同步但无竞争的程序性能,但它同样是一个带有效益权衡(Trade Off)性质的优化,也就是说它并非总是对程序运行有利。如果程序中大多数的锁都总是被多个不同的线程访问,那偏向模式就是多余的。在具体问题具体分析的前提下,有时候使用参数-XX:- UseBiasedLocking来禁止偏向锁优化反而可以提升性能。

[^1]: 注意,这里说的计算请求应来自于对Object::hashCode()或者System::identityHashCode(Object)方法的 调用,如果重写了对象的hashCode()方法,计算哈希码时并不会产生这里所说的请求。

13.3.4 轻量级锁

轻量级锁是JDK 6时加入的新型锁机制,它名字中的“轻量级”是相对于使用操作系统互斥量来实现的传统锁而言的,因此传统的锁机制就被称为“重量级”锁。不过,需要强调一点,轻量级锁并不是用来代替重量级锁的,它设计的初衷是在没有多线程竞争的前提下,减少传统的重量级锁使用操作系统互斥量产生的性能消耗。

要理解轻量级锁,以及后面会讲到的偏向锁的原理和运作过程,必须要对HotSpot虚拟机对象的内存布局(尤其是对象头部分)有所了解。HotSpot虚拟机的对象头(Object Header)分为两部分,第一部分用于存储对象自身的运行时数据,如哈希码(HashCode)、GC分代年龄(Generational GC Age) 等。这部分数据的长度在32位和64位的Java虚拟机中分别会占用32个或64个比特,官方称它为“Mark Word”。这部分是实现轻量级锁和偏向锁的关键。另外一部分用于存储指向方法区对象类型数据的指针,如果是数组对象,还会有一个额外的部分用于存储数组长度。这些对象内存布局的详细内容,我们已经在第2章中学习过,在此不再赘述,只针对锁的角度做进一步细化。

由于对象头信息是与对象自身定义的数据无关的额外存储成本,考虑到Java虚拟机的空间使用效率,Mark Word被设计成一个非固定的动态数据结构,以便在极小的空间内存储尽量多的信息。它会根据对象的状态复用自己的存储空间。例如在32位的HotSpot虚拟机中,对象未被锁定的状态下, Mark Word的32个比特空间里的25个比特将用于存储对象哈希码,4个比特用于存储对象分代年龄,2 个比特用于存储锁标志位,还有1个比特固定为0(这表示未进入偏向模式)。对象除了未被锁定的正常状态外,还有轻量级锁定、重量级锁定、GC标记、可偏向等几种不同状态,这些状态下对象头的存储内容如表13-1所示。

表13-1 HotSpot虚拟机对象头Mark Word

image-20211127125038765

我们简单回顾了对象的内存布局后,接下来就可以介绍轻量级锁的工作过程了:在代码即将进入同步块的时候,如果此同步对象没有被锁定(锁标志位为“01”状态),虚拟机首先将在当前线程的栈帧中建立一个名为锁记录(Lock Record)的空间,用于存储锁对象目前的Mark Word的拷贝(官方为这份拷贝加了一个Displaced前缀,即Displaced Mark Word),这时候线程堆栈与对象头的状态如图13- 3所示。

image-20211127125108449

图13-3 轻量级锁CAS操作之前堆栈与对象的状态[^1]

然后,虚拟机将使用CAS操作尝试把对象的Mark Word更新为指向Lock Record的指针。如果这个更新动作成功了,即代表该线程拥有了这个对象的锁,并且对象Mark Word的锁标志位(Mark Word的最后两个比特)将转变为“00”,表示此对象处于轻量级锁定状态。这时候线程堆栈与对象头的状态如图13-4所示。

如果这个更新操作失败了,那就意味着至少存在一条线程与当前线程竞争获取该对象的锁。虚拟机首先会检查对象的Mark Word是否指向当前线程的栈帧,如果是,说明当前线程已经拥有了这个对象的锁,那直接进入同步块继续执行就可以了,否则就说明这个锁对象已经被其他线程抢占了。如果出现两条以上的线程争用同一个锁的情况,那轻量级锁就不再有效,必须要膨胀为重量级锁,锁标志的状态值变为“10”,此时Mark Word中存储的就是指向重量级锁(互斥量)的指针,后面等待锁的线程也必须进入阻塞状态。

image-20211127125149448

图13-4 轻量级锁CAS操作之后堆栈与对象的状态

上面描述的是轻量级锁的加锁过程,它的解锁过程也同样是通过CAS操作来进行的,如果对象的Mark Word仍然指向线程的锁记录,那就用CAS操作把对象当前的Mark Word和线程中复制的Displaced Mark Word替换回来。假如能够成功替换,那整个同步过程就顺利完成了;如果替换失败,则说明有其他线程尝试过获取该锁,就要在释放锁的同时,唤醒被挂起的线程。

轻量级锁能提升程序同步性能的依据是“对于绝大部分的锁,在整个同步周期内都是不存在竞争的”这一经验法则。如果没有竞争,轻量级锁便通过CAS操作成功避免了使用互斥量的开销;但如果确实存在锁竞争,除了互斥量的本身开销外,还额外发生了CAS操作的开销。因此在有竞争的情况下, 轻量级锁反而会比传统的重量级锁更慢。

[^1]: 图13-3和图13-4来源于HotSpot虚拟机的高级工程师Paul Hohensee所写的演示文档《The Hotspot Java Virtual Machine》。

13.3.3 锁粗化

原则上,我们在编写代码的时候,总是推荐将同步块的作用范围限制得尽量小——只在共享数据的实际作用域中才进行同步,这样是为了使得需要同步的操作数量尽可能变少,即使存在锁竞争,等待锁的线程也能尽可能快地拿到锁。

大多数情况下,上面的原则都是正确的,但是如果一系列的连续操作都对同一个对象反复加锁和解锁,甚至加锁操作是出现在循环体之中的,那即使没有线程竞争,频繁地进行互斥同步操作也会导致不必要的性能损耗。

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

13.3.2 锁消除

锁消除是指虚拟机即时编译器在运行时,对一些代码要求同步,但是对被检测到不可能存在共享数据竞争的锁进行消除。锁消除的主要判定依据来源于逃逸分析的数据支持(第11章已经讲解过逃逸分析技术),如果判断到一段代码中,在堆上的所有数据都不会逃逸出去被其他线程访问到,那就可以把它们当作栈上数据对待,认为它们是线程私有的,同步加锁自然就无须再进行。

也许读者会有疑问,变量是否逃逸,对于虚拟机来说是需要使用复杂的过程间分析才能确定的, 但是程序员自己应该是很清楚的,怎么会在明知道不存在数据争用的情况下还要求同步呢?这个问题的答案是:有许多同步措施并不是程序员自己加入的,同步的代码在Java程序中出现的频繁程度也许超过了大部分读者的想象。我们来看看如代码清单13-6所示的例子,这段非常简单的代码仅仅是输出三个字符串相加的结果,无论是源代码字面上,还是程序语义上都没有进行同步。

代码清单13-6 一段看起来没有同步的代码
1
2
3
public String concatString(String s1, String s2, String s3) {
return s1 + s2 + s3;
}

我们也知道,由于String是一个不可变的类,对字符串的连接操作总是通过生成新的String对象来进行的,因此Javac编译器会对String连接做自动优化。在JDK 5之前,字符串加法会转化为StringBuffer 对象的连续append()操作,在JDK 5及以后的版本中,会转化为StringBuilder对象的连续append()操作。 即代码清单13-6所示的代码可能会变成代码清单13-7所示的样子[^1]。

代码清单13-7 Javac转化后的字符串连接操作
1
2
3
4
5
6
7
public String concatString(String s1, String s2, String s3) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
sb.append(s3);
return sb.toString();
}

现在大家还认为这段代码没有涉及同步吗?每个StringBuffer.append()方法中都有一个同步块,锁就是sb对象。虚拟机观察变量sb,经过逃逸分析后会发现它的动态作用域被限制在concatString()方法内部。也就是sb的所有引用都永远不会逃逸到concatString()方法之外,其他线程无法访问到它,所以这里虽然有锁,但是可以被安全地消除掉。在解释执行时这里仍然会加锁,但在经过服务端编译器的即时编译之后,这段代码就会忽略所有的同步措施而直接执行。

[^1]: 客观地说,既然谈到锁消除与逃逸分析,那虚拟机就不可能是JDK 5之前的版本,所以实际上会转 化为非线程安全的StringBuilder来完成字符串拼接,并不会加锁。但是这也不影响笔者用这个例子证明 Java对象中同步的普遍性。

13.3.1 自旋锁与自适应自旋

前面我们讨论互斥同步的时候,提到了互斥同步对性能最大的影响是阻塞的实现,挂起线程和恢复线程的操作都需要转入内核态中完成,这些操作给Java虚拟机的并发性能带来了很大的压力。同时,虚拟机的开发团队也注意到在许多应用上,共享数据的锁定状态只会持续很短的一段时间,为了这段时间去挂起和恢复线程并不值得。现在绝大多数的个人电脑和服务器都是多路(核)处理器系统,如果物理机器有一个以上的处理器或者处理器核心,能让两个或以上的线程同时并行执行,我们就可以让后面请求锁的那个线程“稍等一会”,但不放弃处理器的执行时间,看看持有锁的线程是否很快就会释放锁。为了让线程等待,我们只须让线程执行一个忙循环(自旋),这项技术就是所谓的自旋锁。

自旋锁在JDK 1.4.2中就已经引入,只不过默认是关闭的,可以使用-XX:+UseSpinning参数来开启,在JDK 6中就已经改为默认开启了。自旋等待不能代替阻塞,且先不说对处理器数量的要求,自旋等待本身虽然避免了线程切换的开销,但它是要占用处理器时间的,所以如果锁被占用的时间很短,自旋等待的效果就会非常好,反之如果锁被占用的时间很长,那么自旋的线程只会白白消耗处理器资源,而不会做任何有价值的工作,这就会带来性能的浪费。因此自旋等待的时间必须有一定的限度,如果自旋超过了限定的次数仍然没有成功获得锁,就应当使用传统的方式去挂起线程。自旋次数的默认值是十次,用户也可以使用参数-XX:PreBlockSpin来自行更改。

不过无论是默认值还是用户指定的自旋次数,对整个Java虚拟机中所有的锁来说都是相同的。在JDK 6中对自旋锁的优化,引入了自适应的自旋。自适应意味着自旋的时间不再是固定的了,而是由前一次在同一个锁上的自旋时间及锁的拥有者的状态来决定的。如果在同一个锁对象上,自旋等待刚刚成功获得过锁,并且持有锁的线程正在运行中,那么虚拟机就会认为这次自旋也很有可能再次成功,进而允许自旋等待持续相对更长的时间,比如持续100次忙循环。另一方面,如果对于某个锁,自旋很少成功获得过锁,那在以后要获取这个锁时将有可能直接省略掉自旋过程,以避免浪费处理器资源。有了自适应自旋,随着程序运行时间的增长及性能监控信息的不断完善,虚拟机对程序锁的状况预测就会越来越精准,虚拟机就会变得越来越“聪明”了。