Java编程的逻辑 前言

为什么要写这本书

写一本关于编程的书,是我大概15年前就有的一个想法,当时,我体会到了编程中数据结构的美妙和神奇,有一种收获的喜悦和分享的冲动。这种收获是我反复阅读教程十几遍,花大量时间上机练习调试得到的,这是一个比较痛苦的过程。我想,如果把我学到的知识更为清晰易懂地表达出来,其他人不就可以掌握编程容易一些,并体会到那种喜悦了吗?不过,当时感觉自己学识太浅,要学习的东西太多,想一想也就算了。

触发我开始写作是在2016年年初,可汗学院的事迹震撼了我。可汗学院的创始人是萨尔曼·可汗,他自己录制了3000多个短视频,主要教中小学生基础课。他为每门课程建立了知识地图,地图由知识点组成,知识点之间有依赖关系。每个知识点都有一个视频,每个视频10分钟左右,他的讲解清晰透彻,极受欢迎。比尔·盖茨声称可汗是他最欣赏的老师,邀请其在TED发表演讲,同时投资可汗成立了非营利机构可汗学院,可汗也受到了来自谷歌等公司的投资。可以说,可汗以一己之力推动了全世界的教育。

我就想,我可不可以学习可汗,为计算机编程教育做一点事情?也就是说,为编程的核心知识建立知识地图,从最基础的概念开始,分解为知识点,一个知识点一个知识点地讲解,每一个知识点都力争清晰透彻,阐述知识点是什么、怎么用、有什么用途、实现原理是什么、思维逻辑是什么、与其他知识点有什么关系等。可汗的形式是视频,但我想先从文字总结开始。我希望表达的是编程的通用知识,但编程总要用一个具体语言,我想就用我最熟悉的Java吧。

过去十几年,Java一直是软件开发领域最主流的语言之一,在可以预见的未来,Java还将是最主流的语言之一。但关于Java编程的书比比皆是,也不乏经典之作,市场还需要一本关于Java编程的书吗?甚至,还需要编程的书吗?如果需要,需要什么样的书呢?

关于编程的需求,我想答案是肯定的。过去几十年,IT革命深刻地改变了人们的生活,但这次革命还远远没有停止,在可以预见的未来,人工智能等前沿技术必将进一步改变世界,而要掌握人工智能技术,必须先掌握基本编程技术。人工智能在我国已经上升为国家战略。2017年7月,国务院印发了《新一代人工智能发展规划》,其中提到“实施全民智能教育项目,在中小学阶段设置人工智能相关课程,逐步推广编程教育”,未来,可能大部分人都需要学习编程。

关于编程的书是很多,但对于非计算机专业学生而言,掌握编程依然是一件困难的事情。绝大部分教程以及培训班过于追求应用,读者学完之后虽然能照着例子写一些程序,但却懵懵懂懂,知其然而不知其所以然,无法灵活应用,当希望进一步深入学习时,发现大部分专业书籍晦涩难懂,难以找到通俗易懂的与学过的应用相结合的进阶原理类书籍。

即使计算机专业的学生,学习编程也不容易。学校开设了很多理论课程,但学习理论的时候往往感觉比较枯燥,比如二进制、编码、数据结构和算法、设计模式、操作系统中的线程和文件系统知识等。而学习具体编程语言的时候,又侧重学习的是语法和API。学习计算机理论的重要目的是为了更好地编程,但学生却难以在理论和编程之间建立密切的联系。

这样,我的想法基本就确定了,用Java语言写一本帮助理解编程到底是怎么回事的书,尽量用通俗易懂的方式循序渐进地介绍编程中的主要概念、语法和类库,不仅介绍用法和应用,还剖析背后的实现原理,以与基础理论相结合,同时包含一些实用经验和教训,并解释一些更为高层的框架和库的基本原理,以与实践应用相结合,在此过程中,融合编程的一些通用思维逻辑。

我有能力写好吗?我并不是编程大师,但我想,可汗也不是每个领域的大师,但他讲授了很多领域的知识,的确帮助了很多人。过去十几年我一直从事编程方面的工作,也在不断学习和思考,我想,只要用心写,至少会给一些人带来一点帮助吧。

于是,我在2016年3月创建了微信公众号“老马说编程”,开始发布系列文章“计算机程序的思维逻辑”。每一篇文章对我都是一个挑战,每一个知识点我都花大量时间用心思考,反复琢磨,力求表达清晰透彻,做到最好。写作是一个痛苦和快乐交织的过程,最痛苦的就是满脑子都是相关的内容,但就是不知道该怎么表达的时候,而最快乐的就是写完一篇文章的时候。令人欣慰的是,这些文章受到了大量读者的极高评价,他们的溢美之词、自发分享和红包赞赏进一步增强了我写作的信心和动力。到2017年7月底,共写了95篇文章,关于Java编程的基本内容也就写完了。

在写作过程中,很多读者反馈希望文章可以尽快整理成书,以便阅读。2016年9月,机械工业出版社的高婧雅女士联系到了我,商讨出版的可能,在她的鼎力帮助和出版社的大力支持下,就有了大家看到的这本书。

本书特色

本书致力于帮助读者真正理解Java编程。对于每个语言特性和API,不仅介绍其概念和用法,还分析了为什么要有这个概念,实现原理是什么,背后的思维逻辑是什么;对于类库,分析了大量源码,使读者不仅知其然,还知其所以然,以透彻理解相关知识点。

本书虽然是Java语言描述,但以更为通用的编程逻辑为主,融入了很多通用的编程相关知识,如二进制、编码、数据结构和算法、设计模式、操作系统、编程思维等,使读者不仅能够学习Java语言,还可以提升整体的编程和计算机水平。

本书不仅注重实现原理,而且重视实用性。本书介绍了很多实践中常用的技术,包含不少实际开发中积累的经验和教训,使读者可以少走一些弯路。在实际开发中,我们经常使用一些高层的系统程序、框架和库,以提升开发效率,本书也介绍了如何利用基本API开发一些系统程序和框架,比如键值数据库、消息队列、序列化框架、DI(依赖注入)容器、AOP(面向切面编程)框架、热部署、模板引擎等,讲解这些内容的目的不是为了“重新发明轮子”,而是为了帮助读者更好地理解和应用高层的系统程序与框架。

本书高度注重表述,尽力站在读者的角度,循序渐进、简洁透彻,从最基本的概念开始,一步步推导出更为高级的概念,在介绍每个知识点时,都会尽力先介绍用法、示例和应用,再分析实现原理和思维逻辑,并与其他知识点建立联系,以便读者能够容易地、全面透彻地理解相关知识。

本书侧重于Java编程的主要概念,绝大部分内容适用于Java 5以上的版本,但也包含了最近几年Java的主要更新,包括Java 8引入的重要更新——Lambda表达式和函数化编程。

读者对象

本书面向所有希望进一步理解编程的主要概念、实现原理和思维逻辑的读者,具体来说有以下几种。

初中级Java开发者:本书采用Java语言,侧重于剖析编程概念背后的实现原理和内在逻辑,同时包含很多实际编程中的经验教训,所以,对于Java编程经历不多,对计算机原理不太了解、对Java的很多概念一知半解的开发人员,阅读本书的收获可能最大,通过本书可以快速提升Java编程水平。而零基础Java开发者,可跳过原理性内容阅读。

非Java语言的开发者:本书不假设读者有任何Java编程基础,系统、全面、细致地讲述了Java的语法和类库,给出了很多示例。另外,本书介绍了很多编程的通用概念、知识、数据结构、设计模式、算法、实现原理和思维逻辑。同时,全书的讨论都尽量站在一个通用的编程语言角度,而非Java语言特定的角度。通过阅读本书,读者可以快速学习和掌握Java,建立与其他语言之间的联系,提升整体编程思维和水平。

中高级Java开发者:经验丰富的Java开发者阅读本书的收获也会很大,可以通过本书对编程有更为系统、更为深刻的认识。

如何阅读本书

本书分为六大部分,共26章内容。

第一部分(第1~2章)介绍编程基础与二进制。第1章介绍编程的基础知识,包括数据类型、变量、赋值、基本运算、条件执行、循环和函数。第2章帮助读者理解数据背后的二进制,包括整数的二进制表示与位运算、小数计算为什么会出错、字符的编码与乱码。

第二部分(第3~7章)介绍面向对象。第3章介绍类的基础知识,包括类的基本概念、类的组合以及代码的基本组织机制。第4章介绍类的继承,包括继承的基本概念、细节、实现原理,分析为什么说继承是把双刃剑。第5章介绍类的一些扩展概念,包括接口、抽象类、内部类和枚举。第6章介绍异常。第7章剖析一些常用基础类,包括包装类、String、StringBuilder、Arrays、日期和时间、随机。

第三部分(第8~12章)介绍泛型与容器及其背后的数据结构和算法。第8章介绍泛型,包括其基本概念和原理、通配符,以及一些细节和局限性。第9章介绍列表和队列,剖析ArrayList、LinkedList以及ArrayDeque。第10章介绍各种Map和Set,剖析HashMap、HashSet、排序二叉树、TreeMap、TreeSet、LinkedHashMap、LinkedHashSet、EnumMap和EnumSet。第11章介绍堆与优先级队列,包括堆的概念和算法及其应用。第12章介绍一些抽象容器类,分析通用工具类Collections,最后对整个容器类体系从多个角度进行系统总结。

第四部分(第13~14章)介绍文件。第13章主要介绍文件的基本技术,包括文件的一些基本概念和常识、Java中处理文件的基本结构、二进制文件和字节流、文本文件和字符流,以及文件和目录操作。第14章介绍文件处理的一些高级技术,包括一些常见文件类型的处理、随机读写文件、内存映射文件、标准序列化机制,以及Jackson序列化。

第五部分(第15~20章)介绍并发。第15章介绍并发的传统基础知识,包括线程的基本概念、线程同步的基本机制synchronized、线程协作的基本机制wait/notify,以及线程的中断。第16章介绍并发包的基石,包括原子变量和CAS、显式锁与显式条件。第17章介绍并发容器,包括写时复制的List和Set、ConcurrentHashMap、基于跳表的Map和Set,以及各种并发队列。第18章介绍异步任务执行服务,包括基本概念和实现原理、主要的实现机制线程池,以及定时任务。第19章介绍一些专门的同步和协作工具类,包括读写锁、信号量、倒计时门栓、循环栅栏,以及ThreadLocal。第20章对整个并发部分从多个角度进行系统总结。

第六部分(第21~26章)介绍动态与函数式编程。第21章介绍反射,包括反射的用法和应用。第22章介绍注解,包括注解的使用、创建,以及两个应用:定制序列化和DI容器。第23章介绍动态代理的用法和原理,包括Java SDK动态代理和cglib动态代理以及一个应用:AOP。第24章介绍类加载机制,包括类加载的基本机制和过程,ClassLoader的用法和自定义,以及它们的应用:可配置的策略与热部署。第25章介绍正则表达式,包括语法、Java API、一个简单的应用(模板引擎),最后剖析一些常见表达式。第26章介绍Java 8引入的函数式编程,包括Lambda表达式、函数式数据处理、组合式异步编程,以及Java 8的日期和时间API。

对于有一定经验的读者,可以挑选感兴趣的章节直接阅读。而对于初学者,建议从头阅读,但对于一些比较深入的原理性内容,以及一些比较高级的内容,如果理解比较困难可以跳过,有一定实践经验后再回头阅读。任何读者都可以将本书作为一本案头参考书,以备随时查阅不确定的概念、用法和原理。

勘误和支持

由于笔者的水平有限,编写时间仓促,书中难免会出现一些错误或者不准确的地方,恳请读者批评指正。如果读者有更多的宝贵意见,欢迎关注我的微信公众号“老马说编程”,可在后台留言,在“关于”部分也有最新的微信和QQ群信息,欢迎加入讨论,我会尽量提供满意的解答。同时,读者也可以通过邮箱swiftma@sina.com联系到我。期待得到你们的真挚反馈,在技术之路上互勉共进。

致谢

感谢我的微信公众号“老马说编程”、掘金、开发者头条和博客园技术社区的广大读者,他们的极高评价、自发分享和红包赞赏让我备受鼓舞,更重要的是,他们指出了很多文章中的错误,使我可以及时修正。

感谢掘金和开发者头条技术社区,他们经常推荐我的文章,使更多人可以看到。

感谢我在北京理工大学学习时的老师和同学们,在老师的教导和同学们的探讨中,我掌握了比较扎实的计算机基础,特别是我的已故恩师古志民教授,古教授指导我完成了本科到博士的学业,他严谨认真的学术态度深深地影响了我。

感谢我工作以来的领导和同事们,由于他们的言传身教,我得以不断提高自己的技术水平。

感谢机械工业出版社的编辑高婧雅,在一年多的时间中始终支持我的写作,她的帮助和建议引导我顺利完成全部书稿。

特别致谢

特别感谢我的爱人吴特和儿子久久,我为写作这本书,牺牲了很多陪伴他们的时间,但也正因为有了他们的付出与支持,我才能坚持写下去。

特别感谢我岳父母,特别是我的岳母,不遗余力地帮助我们照顾儿子,有了他们的帮助和支持,我才有时间和精力去完成写作工作。

特别感谢我的父母,他们在困难的生活条件下,付出了巨大的汗水与心血,将我养育成人,使我能够完成博士学业,他们一生勤劳朴素的品质深深地影响了我。

特别感谢我的兄长马俊杰,他一直是我成长路上的指明灯,也是从他的耐心讲解中我第一次了解到了计算机的基本工作机制。

谨以此书献给我最亲爱的家人,以及众多热爱编程技术的朋友们!

马俊昌

1.4 条件执行

流程控制中最基本的就是条件执行,也就是说,一些操作只能在某些条件满足的情况下才执行,在一些条件下执行某种操作,在另外一些条件下执行另外的操作。这与交通控制中的红灯停、绿灯行条件执行是类似的。我们先来看Java中表达条件执行的语法,然后介绍其实现原理。

1.4.1 语法和陷阱

Java中表达条件执行的基本语法是if语句,它的语法是:

1
2
3
if(条件语句){
代码块
}

1
if(条件语句) 代码;

表达的含义也非常简单,只在条件语句为真的情况下,才执行后面的代码,为假就不执行了。具体来说,条件语句必须为布尔值,可以是一个直接的布尔变量,也可以是变量运算后的结果。我们在1. 3节介绍过,比较运算和逻辑运算的结果都是布尔值,所以可作为条件语句。条件语句为true,则执行括号{}中的代码,如果后面没有括号,则执行后面第一个分号(;)前的代码。

比如,只在变量为偶数的情况下输出:

1
2
3
4
int a=10;
if(a%2==0){
System.out.println("偶数");
}

或者:

1
2
int a=10;
if(a%2==0) System.out.println("偶数");

if的陷阱:初学者有时会忘记在if后面的代码块中加括号,有时希望执行多条语句而没有加括号,结果只会执行第一条语句,建议所有if后面都加括号。

if实现的是条件满足的时候做什么操作,如果需要根据条件做分支,即满足的时候执行某种逻辑,而不满足的时候执行另一种逻辑,则可以用if/else,语法是:

1
2
3
4
5
if(判断条件){
代码块1
}else{
代码块2
}

if/else也非常简单,判断条件是一个布尔值,为true的时候执行代码块1,为假的时候执行代码块2。

1.3节介绍了各种基本运算,这里介绍一个条件运算,和if/else很像,叫三元运算符,语法为:

1
判断条件 ? 表达式 1 : 表达式2

三元运算符会得到一个结果,判断条件为真的时候就返回表达式1的值,否则就返回表达式2的值。三元运算符经常用于对某个变量赋值,例如求两个数的最大值:

1
int max = x > y ? x : y;

三元运算符完全可以用if/else代替,但三元运算符的书写方式更简洁。

如果有多个判断条件,而且需要根据这些判断条件的组合执行某些操作,则可以使用if/else if/else,语法是:

1
2
3
4
5
6
7
8
9
10
if(条件1){
代码块1
}else if(条件2){
代码块2
} …
else if(条件n){
代码块n
}else{
代码块n+1
}

if/else if/else也比较简单,但可以表达复杂的条件执行逻辑,它逐个检查条件,条件1满足则执行代码块1,不满足则检查条件2, ……,最后如果没有条件满足,且有else语句,则执行else里面的代码。最后的else语句不是必需的,没有就什么都不执行。

if/else if/else陷阱:需要注意的是,在if/else if/else中,判断的顺序是很重要的,后面的判断只有在前面的条件为false的时候才会执行。

初学者有时会搞错这个顺序,如下面的代码:

1
2
3
4
5
6
7
if(score>60){
return "及格";
}else if(score>80){
return "良好";
}else{
return "优秀"
}

看出问题了吧?如果score是90,可能期望返回“优秀”,但实际只会返回“及格”。

在if/else if/else中,如果判断的条件基于的是同一个变量,只是根据变量值的不同而有不同的分支,如果值比较多,比如根据星期几进行判断,有7种可能性,或者根据英文字母进行判断,有26种可能性,使用if/else if/else比较烦琐,这种情况可以使用switch,语法是:

1
2
3
4
5
6
7
8
9
10
switch(表达式){
case1
代码1; break;
case2:
代码2; break;

case值n:
代码n; break;
default: 代码n+1
}

switch也比较简单,根据表达式的值执行不同的分支,具体来说,根据表达式的值找匹配的case,找到后执行后面的代码,碰到break时结束,如果没有找到匹配的值则执行default后的语句。表达式值的数据类型只能是byte、short、int、char、枚举和String(Java 7以后)。枚举和String我们在后续章节介绍。

switch会简化一些代码的编写,但break和case语法会给初学者造成一些困惑。

break是指跳出switch语句,执行switch后面的语句。每条case语句后面都应该跟break语句,否则会继续执行后面case中的代码直到碰到break语句或switch结束。比如,下面的代码会输出所有数字而不只是1。

1
2
3
4
5
6
7
8
9
int a = 1;
switch(a){
case 1:
System.out.println("1");
case 2:
System.out.println("2");
default:
System.out.println("3");
}

case语句后面可以没有要执行的代码,如下所示:

1
2
3
4
5
6
7
8
9
char c = 'A'; //某字符
switch(c){
case 'A':
case 'B':
case 'C':
System.out.println("A-Z"); break;
case 'D':

}

case ‘A’/‘B’后都没有紧跟要执行的代码,它们实际会执行第一块碰到的代码,即case ‘C’匹配的代码。

简单总结下,条件执行总体上是比较简单的:单一条件满足时,执行某操作使用if;根据一个条件是否满足执行不同分支使用if/else;表达复杂的条件使用if/elseif/else;条件赋值使用三元运算符,根据某一个表达式的值不同执行不同的分支使用switch。

从逻辑上讲,if/else、if/else if/else、三元运算符、switch都可以只用if代替,但使用不同的语法表达更简洁,在条件比较多的时候,switch从性能上看也更高(稍后解释原因)。

1.4.2 实现原理

条件执行具体是怎么实现的呢?程序最终都是一条条的指令,CPU有一个指令指示器,指向下一条要执行的指令,CPU根据指示器的指示加载指令并且执行。指令大部分是具体的操作和运算,在执行这些操作时,执行完一个操作后,指令指示器会自动指向挨着的下一条指令。

但有一些特殊的指令,称为跳转指令,这些指令会修改指令指示器的值,让CPU跳到一个指定的地方执行。跳转有两种:一种是条件跳转;另一种是无条件跳转。条件跳转检查某个条件,满足则进行跳转,无条件跳转则是直接进行跳转。

if/else实际上会转换为这些跳转指令,比如下面的代码:

1
2
3
4
5
6
1 int a=10;
2 if(a%2==0)
3 {
4 System.out.println("偶数");
5 }
6 //其他代码

转换到的转移指令可能是:

1
2
3
4
5
6
7
1 int a=10;
2 条件跳转:如果a%2==0,跳转到第4
3 无条件跳转:跳转到第7
4 {
5 System.out.println("偶数");
6 }
7 //其他代码

你可能会奇怪第3行的无条件跳转指令,没有它不行吗?不行,没有这条指令,它会顺序执行接下来的指令,导致不管什么条件,括号中的代码都会执行。不过,对应的跳转指令也可能是:

1
2
3
4
5
6
1 int a=10;
2 条件跳转: 如果a%2! =0,跳转到第6
3 {
4 System.out.println("偶数");
5 }
6 //其他代码

这里就没有无条件跳转指令,具体怎么对应和编译器实现有关。在单一if的情况下可能不用无条件跳转指令,但稍微复杂一些的情况都需要。if、if/else、if/elseif/else、三元运算符都会转换为条件跳转和无条件跳转,但switch不太一样。

switch的转换和具体系统实现有关。如果分支比较少,可能会转换为跳转指令。如果分支比较多,使用条件跳转会进行很多次的比较运算,效率比较低,可能会使用一种更为高效的方式,叫跳转表。跳转表是一个映射表,存储了可能的值以及要跳转到的地址,如表1-5所示。

表1-5 跳转表

epub_923038_10
跳转表为什么会更为高效呢?因为其中的值必须为整数,且按大小顺序排序。按大小排序的整数可以使用高效的二分查找,即先与中间的值比,如果小于中间的值,则在开始和中间值之间找,否则在中间值和末尾值之间找,每找一次缩小一半查找范围。如果值是连续的,则跳转表还会进行特殊优化,优化为一个数组,连找都不用找了,值就是数组的下标索引,直接根据值就可以找到跳转的地址。即使值不是连续的,但数字比较密集,差的不多,编译器也可能会优化为一个数组型的跳转表,没有的值指向default分支。

程序源代码中的case值排列不要求是排序的,编译器会自动排序。之前说switch值的类型可以是byte、short、int、char、枚举和String。其中byte/short/int本来就是整数,char本质上也是整数(2.4节介绍),而枚举类型也有对应的整数(5.4节介绍), String用于switch时也会转换为整数。不可以使用long,为什么呢?跳转表值的存储空间一般为32位,容纳不下long。简单说明下String, String是通过hashCode方法(7.2节介绍)转换为整数的,但不同String的hashCode可能相同,跳转后会再次根据String的内容进行比较判断。

简单总结下,条件执行的语法是比较自然和容易理解的,需要注意的是其中的一些语法细节和陷阱。它执行的本质依赖于条件跳转、无条件跳转和跳转表。条件执行中的跳转只会跳转到跳转语句以后的指令,能不能跳转到之前的指令呢?可以,那样就会形成循环。

1.3 基本运算

有了初始值之后,可以对数据进行运算。运算有不同的类型,不同的数据类型支持的运算也不一样,本节介绍Java中基本类型数据的主要运算。

  • 算术运算:主要是日常的加减乘除。
  • 比较运算:主要是日常的大小比较。
  • 逻辑运算:针对布尔值进行运算。

1.3.1 算术运算

算术运算符有加、减、乘、除,符号分别是+-*/,另外还有取模运算符%,以及自增(++)和自减(--)运算符。取模运算适用于整数和字符类型,其他算术运算适用于所有数值类型和字符类型。大部分运算都符合我们的数学常识,但字符怎么也可以进行算术运算?我们到2.4节再解释。

减号(-)通常用于两个数相减,但也可以放在一个数前面,例如-a,这表示改变a的符号,原来的正数会变为负数,原来的负数会变为正数,这也是符合我们常识的。

取模(%)就是数学中的求余数,例如,5%3是2,10%5是0。

自增(++)和自减(--),是一种快捷方式,是对自己进行加1或减1操作。

加、减、乘、除大部分情况和数学运算是一样的,都很容易理解,但有一些需要注意的地方,而自增、自减稍微复杂一些,下面我们解释下。

1.加、减、乘、除注意事项

运算时要注意结果的范围,使用恰当的数据类型。两个正数都可以用int表示,但相乘的结果可能就会超出,超出后结果会令人困惑,例如:

1
int a = 2147483647*2; //2147483647是int能表示的最大值

a的结果是-2。为什么是-2我们暂不解释,要避免这种情况,我们的结果类型应使用long,但只改为long也是不够的,因为运算还是默认按照int类型进行,需要将至少一个数据表示为long形式,即在后面加L或l,下面这样才会出现期望的结果:

1
long a = 2147483647*2L;

另外,需要注意的是,整数相除不是四舍五入,而是直接舍去小数位,例如:

1
double d = 10/4;

结果是2而不是2.5,如果要按小数进行运算,需要将至少一个数表示为小数形式,或者使用强制类型转化,即在数字前面加(double),表示将数字看作double类型,如下所示任意一种形式都可以:

1
2
a) double d = 10/4.0;
b) double d = 10/(double)4;

2.小数计算结果不精确

无论是使用float还是double,进行运算时都会出现一些非常令人困惑的现象,比如:

1
2
float f = 0.1f0.1f;
System.out.println(f);

这个结果看上去应该是0.01,但实际上,屏幕输出却是0.010000001,后面多了个1。换用double看看:

1
2
double d = 0.10.1;
System.out.println(d);

屏幕输出0.010000000000000002,一连串的0之后多了个2,结果也不精确。

这是怎么回事?看上去这么简单的运算,计算机计算的结果怎么不精确呢?但事实就是这样,究其原因,我们需要理解float和double的二进制表示,我们到2.2节再进行分析。

3.自增(++)/自减(–)

自增/自减是对自己做加1或减1操作,但每个都有两种形式,一种是放在变量后,例如a++、a–,另一种是放在变量前,例如++a、–a。

如果只是对自己操作,这两种形式也没什么差别,区别在于还有其他操作的时候。放在变量后(a++)是先用原来的值进行其他操作,然后再对自己做修改,而放在变量前(++a)是先对自己做修改,再用修改后的值进行其他操作。例如,快捷运算和其等同的运算如表1-4所示。

表1-4 快捷运算和其等同的运算

epub_923038_9
自增/自减是“快捷”操作,是让程序员少写代码的,但遗憾的是,由于比较奇怪的语法和诡异的行为,给初学者带来了一些困惑。

1.3.2 比较运算

比较运算就是计算两个值之间的关系,结果是一个布尔类型(boolean)的值。比较运算适用于所有数值类型和字符类型。数值类型容易理解,但字符怎么比呢?我们到2.4节再解释。

比较操作符有大于(>)、大于等于(>=)、小于(<)、小于等于(<=)、等于(==)、不等于(! =)。

大部分也都是比较直观的,需要注意的是等于。首先,它使用两个等号==,而不是一个等号=。为什么不用一个等号呢?因为一个等号=已经被占了,表示赋值操作。另外,对于数组,==判断的是两个变量指向的是不是同一个数组,而不是两个数组的元素内容是否一样,即使两个数组的内容是一样的,但如果是两个不同的数组,==依然会返回false,比如:

1
2
3
int[] a = new int[] {1,2,3};
int[] b = new int[] {1,2,3};
//a==b的结果是false

如果需要比较数组的内容是否一样,需要逐个比较里面存储的每个元素。

1.3.3 逻辑运算

逻辑运算根据数据的逻辑关系,生成一个布尔值true或者false。逻辑运算只可应用于boolean类型的数据,但比较运算的结果是布尔值,所以其他类型数据的比较结果可进行逻辑运算。

逻辑运算符具体有以下这些。

  • 与(&):两个都为true才是true,只要有一个是false就是false;
  • 或(|):只要有一个为true就是true,都是false才是false;
  • 非(!):针对一个变量,true会变成false, false会变成true;
  • 异或(^):两个相同为false,两个不相同为true;
  • 短路与(&&):和&类似,不同之处稍后解释;
  • 短路或(||):与|类似,不同之处稍后解释。

逻辑运算的大部分都是比较直观的,需要注意的是&和&&,以及|和||的区别。如果只是进行逻辑运算,它们也都是相同的,区别在于同时有其他操作的情况下,例如:

1
2
3
boolean a = true;
int b = 0;
boolean flag = a | b++>0;

因为a为true,所以flag也为true,但b的结果为1,因为|后面的式子也会进行运算,即使只看a已经知道flag的结果,还是会进行后面的运算。而||则不同,如果最后一句的代码是:

1
boolean flag = a || b++>0;

则b的值还是0,因为||会“短路”,即在看到||前面部分就可以判定结果的情况下,忽略||后面的运算。

1.3.4 小结

本节介绍了Java中基本类型数据的主要运算,包括算术运算、比较运算和逻辑运算。

一个稍微复杂的运算可能会涉及多个变量和多种运算,那哪个先算,哪个后算呢?程序语言规定了不同运算符的优先级,有的会先算,有的会后算,大部分情况下,这个优先级与我们的常识理解是相符的。但在一些复杂情况下,我们可能会搞不明白其运算顺序。但这个我们不用太操心,可以使用括号()来表达我们想要的顺序,括号里的会先进行运算。简单来说,不确定顺序的时候,就使用括号。

本节遗留了一些问题,比如:

  • 正整数相乘的结果居然出现了负数;
  • 非常基本的小数运算结果居然不精确;
  • 字符类型也可以进行算术运算和比较。

关于这些问题,我们到第2章再进行解释。为了编写有更多实用功能的程序,只进行基本操作是远远不够的,我们至少需要对操作的过程进行流程控制。流程控制主要有两种:一种是条件执行,另外一种是循环执行,接下来的两节对它们进行详细介绍。

1.2 赋值

声明变量之后,就在内存分配了一块位置,但这个位置的内容是未知的,赋值就是把这块位置的内容设为一个确定的值。Java中基本类型、数组、对象的赋值有明显不同,本节介绍基本类型和数组的赋值,对象的赋值第3章再介绍。

1.2.1 基本类型

(1)整数类型

整数类型有byte、short、int和long,分别占1、2、4、8个字节,取值范围如表1-1所示。

表1-1 整数类型和取值范围

epub_923038_6
我们用^表示指数,2^7即2的7次方。这个范围我们不需要记得那么清楚,有个大概范围认识就可以了。第2章会从二进制的角度进一步分析表示范围为什么会是这样的。

赋值形式很简单,直接把熟悉的数字常量形式赋值给变量即可,对应的内存空间的值就从未知变成了确定的常量。但常量不能超过对应类型的表示范围。例如:

1
2
3
4
byte b = 23;
short s = 3333;
int i = 9999;
long l = 32323;

但是,在给long类型赋值时,如果常量超过了int的表示范围,需要在常量后面加大写或小写字母L,即L或l,例如:

1
long a = 3232343433L;

之所以需要加L或l,是因为数字常量默认为是int类型。

(2)小数类型

小数类型有float和double,占用的内存空间分别是4和8字节,有不同的取值范围和精度,double表示的范围更大,精度更高,具体如表1-2所示。

表1-2 小数类型和取值范围

epub_923038_7
取值范围看上去很奇怪,一般也不需要记住,有个大概印象就可以了。E表示以10为底的指数,E后面的+号和-号代表正指数和负指数,例如:1.4E-45表示1.4乘以10的-45次方。第2章会进一步分析小数的二进制表示。

对于double,直接把熟悉的小数表示赋值给变量即可,例如:

1
double d = 333.33;

但对于float,需要在数字后面加大写字母F或小写字母f,例如:

1
float f = 333.33f;

这是由于小数常量默认是double类型。

除了小数,也可以把整数直接赋值给float或double,例如:

1
2
float f = 33;
double d = 3333333333333L;

(3)真假类型

真假(boolean)类型很简单,直接使用true或false赋值,分别表示真和假,例如:

1
2
boolean b = true;
b = false;

(4)字符类型

字符类型char用于表示一个字符,这个字符可以是中文字符,也可以是英文字符,char占用的内存空间是两个字节。赋值时把常量字符用单引号括起来,不要使用双引号,例如:

1
2
char c = 'A';
char z = '马';

大部分的常用字符用一个char就可以表示,但有的特殊字符用一个char表示不了。此外,关于char还有一些其他细节,我们在2.4节再进一步解释。

前面介绍的赋值都是直接给变量设置一个常量值,但也可以把变量赋给变量,例如:

1
2
int a = 100;
int b = a;

变量可以进行各种运算(1.3节介绍),也可以将变量的运算结果赋给变量,例如:

1
2
3
int a = 1;
int b = 2;
int c = 2*a+b; //2乘以a的值再加上b的值赋给c

前面介绍的赋值都是在声明变量的时候就进行了赋值,但这不是必需的,可以先声明变量,随后再进行赋值。

1.2.2 数组类型

基本类型的数组有3种赋值形式,如下所示:

1
2
3
4
1. int[] arr = {1,2,3};
2. int[] arr = new int[]{1,2,3};
3. int[] arr = new int[3];
arr[0]=1; arr[1]=2; arr[2]=3;

第1种和第2种都是预先知道数组的内容,而第3种是先分配长度,然后再给每个元素赋值。第3种形式中,即使没有给每个元素赋值,每个元素也都有一个默认值,这个默认值跟数组类型有关,数值类型的值为0, boolean为false, char为空字符。

数组长度可以动态确定,如下所示:

1
2
int length = ... ; //根据一些条件动态计算
int arr = new int[length];

数组长度虽然可以动态确定,但定了之后就不可以变。数组有一个length属性,但只能读,不能改。还有一个小细节,不能在给定初始值的同时给定长度,即如下格式是不允许的:

1
int[] arr = new int[3]{1,2,3}

可以这么理解,因为初始值已经决定了长度,再给个长度,如果还不一致,计算机将无所适从。

数组类型和基本类型是有明显不同的,一个基本类型变量,内存中只会有一块对应的内存空间。但数组有两块:一块用于存储数组内容本身,另一块用于存储内容的位置。用一个例子来说明,有一个int变量a,以及一个int数组变量arr,其代码、变量对应的内存地址和内存内容如表1-3所示。

表1-3 变量对应的内存地址和内容

epub_923038_8
基本类型a的内存地址是1000,这个位置存储的就是它的值100。数组类型arr的内存地址是2000,这个位置存储的值是一个位置3000,3000开始的位置存储的才是实际的数据“1, 2, 3”。

为什么数组要用两块空间?不能只用一块空间吗?我们来看下面这段代码:

1
2
3
int[] arrA = {1,2,3};
int[] arrB = {4,5,6,7};
arrA = arrB;

这段代码中,arrA初始的长度是3, arrB的长度是4,后来将arrB的值赋给了arrA。如果arrA对应的内存空间是直接存储的数组内容,那么它将没有足够的空间去容纳arrB的所有元素。

用两块空间存储就简单得多,arrA存储的值就变成了和arrB的一样,存储的都是数组内容{4,5,6,7}的地址,此后访问arrA就和arrB是一样的了,而arrA {1,2,3}的内存空间由于不再被引用会进行垃圾回收,如下所示:

1
2
3
4
arrA          {1,2,3}
\
\
arrB -> {4,5,6,7}

由上也可以看出,给数组变量赋值和给数组中元素赋值是两回事,给数组中元素赋值是改变数组内容,而给数组变量赋值则会让变量指向一个不同的位置。

上面我们说数组的长度是不可以变的,不可变指的是数组的内容空间,一经分配,长度就不能再变了,但可以改变数组变量的值,让它指向一个长度不同的空间,就像上例中arrA后来指向了arrB一样。

给变量赋值就是将变量对应的内存空间设置为一个明确的值,有了值之后,变量可以被加载到CPU, CPU可以对这些值进行各种运算,运算后的结果又可以被赋值给变量,保存到内存中。数据可以进行哪些运算?如何进行运算呢?我们下节介绍。

第1章 编程基础

我们先来简单介绍何谓编程,以及编出来的程序大概是什么样子。

计算机是个机器,这个机器主要由CPU、内存、硬盘和输入/输出设备组成。计算机上跑着操作系统,如Windows或Linux,操作系统上运行着各种应用程序,如Word、QQ等。

操作系统将时间分成很多细小的时间片,一个时间片给一个程序用,另一个时间片给另一个程序用,并频繁地在程序间切换。不过,在应用程序看来,整个机器资源好像都归它使用,操作系统给它制造了这种假象。对程序员而言,编写程序时基本不用考虑其他应用程序,做好自己的事就可以了。

应用程序看上去能做很多事情,能读写文档、能播放音乐、能聊天、能玩游戏、能下围棋等,但本质上,计算机只会执行预先写好的指令而已,这些指令也只是操作数据或者设备。所谓程序,基本上就是告诉计算机要操作的数据和执行的指令序列,即对什么数据做什么操作,比如:

1)读文档,就是将数据从磁盘加载到内存,然后输出到显示器上;
2)写文档,就是将数据从内存写回磁盘;
3)播放音乐,就是将音乐的数据加载到内存,然后写到声卡上;
4)聊天,就是从键盘接收聊天数据,放到内存,然后传给网卡,通过网络传给另一个人的网卡,再从网卡传到内存,显示在显示器上。

基本上,所有数据都需要放到内存进行处理,程序的很大一部分工作就是操作在内存中的数据。那具体如何表示和操作数据呢?本章介绍一些基础知识,具体分为7个小节。

数据在计算机内部都是二进制表示的,不方便操作,为了方便操作数据,高级语言引入了数据类型变量的概念,这两个概念我们在1.1节介绍。

表示了数据后,1.2节介绍能对数据进行的第一个操作:赋值。

数据有了初始值之后,1.3节介绍可以对数据进行的一些基本运算,计算机之所以称为“计算”机,是因为最初发明它的主要目的也是运算。

为了编写有实用功能的程序,只进行基本运算是远远不够的,至少需要对操作的过程进行流程控制。流程控制有两种:一种是条件执行;另外一种是循环。我们分别在1.4节和1.5节介绍。

为了减少重复代码和分解复杂操作,计算机程序引入了函数和子程序的概念,我们分别在1.6节和1.7节介绍函数的用法和函数调用的基本原理。

1.1 数据类型和变量

数据类型用于对数据归类,以便于理解和操作。对Java语言而言,有如下基本数据类型。

  • 整数类型:有4种整型byte/short/int/long,分别有不同的取值范围;
  • 小数类型:有两种类型float/double,有不同的取值范围和精度;
  • 字符类型:char,表示单个字符;
  • 真假类型:boolean,表示真假。

基本数据类型都有对应的数组类型,数组表示固定长度的同种数据类型的多条记录,这些数据在内存中连续存放。比如,一个自然数可以用一个整数类型数据表示,100个连续的自然数可以用一个长度为100的整数数组表示。一个字符可以用一个char类型数据表示,一段文字可以用一个char数组表示。

Java是面向对象的语言,除了基本数据类型,其他都是对象类型。对象到底是什么呢?简单地说,对象是由基本数据类型、数组和其他对象组合而成的一个东西,以方便对其整体进行操作。比如,一个学生对象,可以由如下信息组成。

  • 姓名:一个字符数组;
  • 年龄:一个整数;
  • 性别:一个字符;
  • 入学分数:一个小数。

日期在Java中也是一个对象,内部表示为整型long。

世界万物都是由元素周期表中的基本元素组成的,基本数据类型就相当于化学中的基本元素,而对象就相当于世界万物。

为了操作数据,需要把数据存放到内存中。所谓内存在程序看来就是一块有地址编号的连续的空间,数据放到内存中的某个位置后,为了方便地找到和操作这个数据,需要给这个位置起一个名字。编程语言通过变量这个概念来表示这个过程。

声明一个变量,比如int a,其实就是在内存中分配了一块空间,这块空间存放int数据类型,a指向这块内存空间所在的位置,通过对a操作即可操作a指向的内存空间,比如a=5这个操作即可将a指向的内存空间的值改为5。

之所以叫“”量,是因为它表示的是内存中的位置,这个位置存放的值是可以变化的。

虽然变量的值是可以变化的,但变量的名字是不变的,这个名字应该代表程序员心目中这块内存空间的意义,这个意义应该是不变的。比如,变量int second表示时钟秒数,在不同时间可以被赋予不同的值,但它表示的始终是时钟秒数。之所以说应该,是因为这不是必需的,如果一定要为一个名为age的变量赋予身高的值,计算机也拿你没办法。

重要的话再说一遍!变量就是给数据起名字,方便找不同的数据,它的值可以变,但含义不应变。再比如说一个合同,可以有4个变量:

  • first_party:含义是甲方;
  • second_party:含义是乙方;
  • contract_body:含义是合同内容;
  • contract_sign_date:含义是合同签署日期。

这些变量表示的含义是确定的,但对不同的合同,它们的值是不同的。初学编程的人经常使用像a、b、c、hehe、haha这种无意义的名字。在此建议为变量起一个有意义的名字吧!通过声明变量,每个变量赋予一个数据类型和一个有意义的名字,我们就告诉了计算机要操作的数据。

有了数据,如何对数据进行操作呢?我们先来看对数据能做的第一个操作:赋值。

附录A 在Windows系统下编译OpenJDK 6

这是本书第1版中介绍如何在Windows下编译OpenJDK 6的例子,里面的部分内容现在已经过时了 (例如安装Plug部分),但对在Windows上构建安装环境和进行较老版本的OpenJDK编译还是有一定参考意义的,所以笔者并没有把它删除,而是挪到附录之中。

A.1 获取JDK源码

首先确定要使用的JDK版本,OpenJDK 6和OpenJDK 7都是开源的,源码都可以在它们的主页 (http://openjdk.java.net/)上找到。OpenJDK 6的源码其实是从OpenJDK 7的某个基线中引出的,然后剥离JDK 1.7相关的代码,从而得到一份可以通过TCK 6的JDK 1.6实现,因此直接编译OpenJDK 7会更加“原汁原味”一些,其实这两个版本的编译过程差异并不大。

获取源码有两种方式:一是通过Mercurial代码版本管理工具从Repository中直接取得源码 (Repository地址:http://hg.openjdk.java.net/jdk7/jdk7),这是最直接的方式,从版本管理中看变更轨迹比看什么Release Note都来得实在,不过太麻烦了一些,尤其是Mercurial远不如SVN、ClearCase或CVS之类的版本控制工具那样普及;另外一种就是直接下载官方打包好的源码包了,可以从Source Releases页面(地址:http://download.java.net/openjdk/jdk7/)取得打包好的源码,一般来说大概一个月左右会更新一次,虽然不够及时,但的确方便了许多。笔者下载的是OpenJDK 7 Early Access Source Build b121版,2010年12月9日发布的,大概81.7MB,解压出来约308MB。

A.2 系统需求

如果可能,笔者建议尽量在Linux或Solaris上构建OpenJDK,这要比在Windows平台上轻松许多, 而且网上能找到的资料绝大部分都是在Linux上编译的。如果一定要在Windows平台上编译,建议读者认真阅读一下源码中的README-builds.html文档(无论在OpenJDK网站上还是在下载的源码包里面都有这份文档),因为编译过程中需要注意的细节非常多。虽然不至于像文档上所描述的“Building the source code for the JDK requires a high level of technical expertise.Sun provides the source code primarily for technical experts who want to conduct research(编译JDK需要很高的专业技术,Sun提供JDK源码是为了供技术专家进行研究之用)”那么夸张,但是如果读者是第一次编译,那在上面耗费一整天乃至更多的时间都很正常。

笔者在本次实战中演示的是在32位Windows 7平台下编译x86版的OpenJDK(也就是32位的JDK),如果需要编译x64版,那毫无疑问也需要一个64位的操作系统。另外编译涉及的所有文件都必须存放在NTFS格式的文件系统中,因为FAT32格式无法支持大小写敏感的文件名。官方文档上写道: 编译至少需要512MB的内存和600MB的磁盘空间。如果读者耐心很好的话,512MB的内存也可以凑合使用,不过600MB的磁盘空间仅仅是指存放OpenJDK源码和相关依赖项的空间,要完成编译,600MB 肯定是无论如何都不够的。这次实战中所下载的工具、依赖项、源码,全部安装、解压完成最少(“最少”是指只下载C++编译器,不下载VS的IDE)需要1GB的空间。

对系统的最后一点要求就是所有的文件,包括源码和依赖项目,都不要放在包含中文或空格的目录里面,这样做不是一定不可以,只是这样会为后续建立CYGWIN环境带来很多额外的工作。这是由于Linux和Windows的磁盘路径差别所导致的,我们也没有必要自己给自己找麻烦。

A.3 构建编译环境

准备编译环境的第一步是安装一个CYGWIN^1。这是一个在Windows平台下模拟Linux运行环境的软件,提供了一系列的Linux命令支持。需要CYGWIN的原因是,在编译中要使用GNU Make来执行Makefile文件(C/C++程序员肯定很熟悉,如果只使用Java,那把这个东西当成C++版本的ANT看待就可以了)。安装CYGWIN时不能直接默认安装,因为表A-1中所示的工具都不会进行默认安装,但又是编译过程中需要的,因此要在图A-1所示的安装界面中进行手工选择。

CYGWIN安装时的定制包选择界面如图A-1所示。

表A-1 需要手工选择安装的CYGWIN工具

image-20211127130828423

image-20211127130853102

图A-1 CYGWIN安装界面

建立编译环境的第二步是安装编译器。JDK中最核心的代码(Java虚拟机及JDK中Native方法的实现等)是使用C++语言及少量的C语言编写的,官方文档中说它们的内部开发环境是在Microsoft Visual Studio C++2003(VS2003)中进行编译的,同时也是在Microsoft Visual Studio C++2010(VS2010)中测试过的,所以最好只选择这两个编译器之一进行编译。如果选择VS2010,那么要求在编译器之中已经包含了Windows SDK v 7.0a,否则可能还要自己去下载这个SDK,并且更新PlatformSDK目录。由于笔者没有购买Visual Studio 2010的IDE,所以仅下载了VS2010 Express中提取出来的C++编译器,这部分是免费的,但单独安装好编译器比较麻烦。建议读者选择使用整套Visual Studio C++2010或Visual Studio C++2010 Express版进行编译。

需要特别注意的一点是:CYGWIN和VS2010安装之后都会在操作系统的PATH环境变量中写入自己的bin目录路径,必须检查并保证VS2010的bin目录在CYGWIN的bin目录之前,因为这两个软件的bin 目录之中各自都有一个连接器“link.exe”,但是只有VS2010中的连接器可以完成OpenJDK的编译。

准备JDK编译环境的第三步就是下载一个已经编译好的JDK。这听起来也许有点滑稽——要用鸡蛋孵小鸡还真得必须先养一只母鸡呀?但仔细想想,其实这个步骤很合理:因为JDK包含的各个部分(Hotspot、JDK API、JAXWS、JAXP……)有的是使用C++编写的,而更多的代码则是使用Java自身实现的,因此编译这些Java代码需要用到一个可用的JDK,官方称这个JDK为Bootstrap JDK。而编译OpenJDK 7的话,Bootstrap JDK必须使用JDK6 Update 14或之后的版本,笔者选用的是JDK6 Update 21。

最后一个步骤是下载一个Apache ANT,JDK中Java代码部分都是使用ANT脚本进行编译的,ANT 版本要求在1.6.5以上,这部分是Java的基础知识,对本书的读者来说应该没有难度,笔者不再详述。

A.4 准备依赖项

前面说过,OpenJDK中开放的源码并没有达到100%,还有极少量的无法开源的产权代码存在。 OpenJDK承诺日后将逐步使用开源实现来替换掉这部分产权代码,但至少在今天,编译JDK还需要这部分闭源包,官方称之为“JDK Plug”[^2],它们从前面的Source Releases页面就可以下载到。Windows平台的JDK Plug是以Jar包的形式提供的,通过下面这条命令可以安装它:

1
java –jar jdk-7-ea-plug-b121-windows-i586-09_dec_2010.jar

运行后将会显示图A-2所示的协议,点击“ACCEPT”接受协议,然后把Plug安装到指定目录即可。 安装完毕后建立一个环境变量ALT_BINARY_PLUGS_PATH,变量值为此JDK Plug的安装路径,后面编译程序时需要用到它。

image-20211127131106318

图A-2 JDK Plug安装协议

除了要用到JDK Plug外,编译时还需要引用JDK的运行时包,这是编译JDK中用Java代码编写的那部分所需要的,如果仅仅是想编译一个HotSpot虚拟机则可以不用。官方文档把这部分称为Optional Import JDK,可以直接使用前面Bootstrap JDK的运行时包。我们需要建立一个名为ALT_JDK_IMPORT_PATH的环境变量指向JDK的安装目录。

然后,安装一个大于2.3版的FreeType[^3],这是一个免费的字体渲染库,JDK的Swing部分和JConsole这类工具会用到它。安装好后建立两个环境变量ALT_FREETYPE_LIB_PATH和ALT_FREETYPE_HEADERS_PATH,分别指向FreeType安装目录下的bin目录和include目录。另外还有一点是官方文档没有提到但必须要做的事情,那就是把FreeType的bin目录加入PATH环境变量中。

接着,下载Microsoft DirectX 9.0 SDK(Summer 2004),安装后大约有298MB,在微软官方网站上搜索一下就可以找到下载地址,它是免费的。安装后建立环境变量ALT_DXSDK_PATH指向DirectX 9.0 SDK的安装目录。

最后,寻找一个名为MSVCR100.DLL的动态链接库,如果读者在前面安装了全套的Visual Studio 2010,那这个文件在本机就能找到,否则上网搜索一下也能找到单独的下载地址,大概有744KB。建立环境变量ALT_MSVCRNN_DLL_PATH指向这个文件所在的目录。如果读者选择的是VS2003,这个文件名应当为MSVCR73.DLL,应该在很多软件中都包含有这个文件,如果找不到的话,前面下载的Bootstrap JDK的bin目录中应该也有一个,直接拿来用吧。

A.5 进行编译

现在需要下载的编译环境和依赖项目都准备齐全了,最后我们还需要对系统做一些设置以便编译能够顺利通过。

首先执行VS2010中的VCVARS32.BAT,这个批处理文件的目的主要是设置INCLUDE、LIB和PATH这几个环境变量,如果和笔者一样只是下载了编译器,则需要手工设置它们。各个环境变量的设置值可以参考下面给出的代码清单A-1中的内容。批处理运行完之后建立ALT_COMPILER_PATH环境变量,让Makefile知道在哪里可以找到编译器。

再建立ALT_BOOTDIR和ALT_JDK_IMPORT_PATH两个环境变量指向前面提到的JDK 1.6的安装目录。建立ANT_HOME指向Apache ANT的安装目录。建立的环境变量很多,为了避免遗漏,笔者写了一个批处理文件以供读者参考,如代码清单A-1所示。

代码清单A-1 环境变量设置
1
2
3
4
5
6
7
8
9
10
11
12
13
SET ALT_BOOTDIR=D:/_DevSpace/JDK 1.6.0_21 
SET ALT_BINARY_PLUGS_PATH=D:/jdkBuild/jdk7plug/openjdk-binary-plugs
SET ALT_JDK_IMPORT_PATH=D:/_DevSpace/JDK 1.6.0_21
SET ANT_HOME=D:/jdkBuild/apache-ant-1.7.0
SET ALT_MSVCRNN_DLL_PATH=D:/jdkBuild/msvcr100
SET ALT_DXSDK_PATH=D:/jdkBuild/msdxsdk
SET ALT_COMPILER_PATH=D:/jdkBuild/vcpp2010.x86/bin
SET ALT_FREETYPE_HEADERS_PATH=D:/jdkBuild/freetype-2.3.5-1-bin/include
SET ALT_FREETYPE_LIB_PATH=D:/jdkBuild/freetype-2.3.5-1-bin/bin
SET INCLUDE=D:/jdkBuild/vcpp2010.x86/include;D:/jdkBuild/vcpp2010.x86/sdk/Include;%INCLUDE%
SET LIB=D:/jdkBuild/vcpp2010.x86/lib;D:/jdkBuild/vcpp2010.x86/sdk/Lib;%LIB%
SET LIBPATH=D:/jdkBuild/vcpp2010.x86/lib;%LIB%
SET PATH=D:/jdkBuild/vcpp2010.x86/bin;D:/jdkBuild/vcpp2010.x86/dll/x86;D:/Software/OpenSource/cygwin/bin;%ALT_FREETYPE_LIB_PATH%;%PATH%

最后还需要进行两项调整,官方文档没有说明这两项,但是必须要做完才能保证编译过程的顺利完成:一是取消环境变量JAVA_HOME,这点很简单;另外一项是尽量在英文的操作系统上编译。估计大部分读者会感到比较为难吧?如果不能在英文的系统上编译就把系统的文字格式调整为“英语(美国)”,在控制面板-区域和语言选项的第一个页签中可以设置。如果这个设置还不能更改就建立一个BUILD_CORBA环境变量,将其值设置为false,取消编译CORBA部分。否则Java IDL(idlj.exe)为 *.idl文件生成CORBA适配器代码的时候会产生中文注释,而这些中文注释会因为字符集的问题而导致编译失败。

完成了上述烦琐的准备工作之后,我们终于可以开始编译了。进入控制台(Cmd.exe)后运行刚才准备好的设置环境变量的批处理文件,然后输入bash进入Bourne Again Shell环境(习惯sh或ksh的读者请自便)。如果JDK的安装源码中存在jdk_generic_profile.sh这个Shell脚本,先执行它,笔者下载的OpenJDK 7 B121版没有这个文件了,所以直接输入make sanity来检查我们前面所做的设置是否全部正确。如果一切顺利,几秒钟之后会有类似代码清单A-2所示的输出。

代码清单A-2 make sanity检查
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
D:\jdkBuild\openjdk7>bash

bash-3.2$ make sanity
cygwin warning:
MS-DOS style path detected: C:/Windows/system32/wscript.exe
Preferred POSIX equivalent is: /cygdrive/c/Windows/system32/wscript.exe
CYGWIN environment variable option "nodosfilewarning" turns off this warning.
Consult the user's guide for more details about POSIX paths:
http://cygwin.com/cygwin-ug-net/using.html#using-pathnames
( cd ./jdk/make && \
……因篇幅关系,中间省略了大量的输出内容……
OpenJDK-specific settings:
FREETYPE_HEADERS_PATH = D:/jdkBuild/freetype-2.3.5-1-bin/include
ALT_FREETYPE_HEADERS_PATH = D:/jdkBuild/freetype-2.3.5-1-bin/include
FREETYPE_LIB_PATH = D:/jdkBuild/freetype-2.3.5-1-bin/bin
ALT_FREETYPE_LIB_PATH = D:/jdkBuild/freetype-2.3.5-1-bin/bin

OPENJDK Import Binary Plug Settings:
IMPORT_BINARY_PLUGS = true
BINARY_PLUGS_JARFILE = D:/jdkBuild/jdk7plug/openjdk-binary-plugs/jre/lib/rt-closed.jar
ALT_BINARY_PLUGS_JARFILE =
BINARY_PLUGS_PATH = D:/jdkBuild/jdk7plug/openjdk-binary-plugs
ALT_BINARY_PLUGS_PATH = D:/jdkBuild/jdk7plug/openjdk-binary-plugs
BUILD_BINARY_PLUGS_PATH = J:/re/jdk/1.7.0/promoted/latest/openjdk/binaryplugs
ALT_BUILD_BINARY_PLUGS_PATH =
PLUG_LIBRARY_NAMES =

Previous JDK Settings:
PREVIOUS_RELEASE_PATH = USING-PREVIOUS_RELEASE_IMAGE
ALT_PREVIOUS_RELEASE_PATH =
PREVIOUS_JDK_VERSION = 1.6.0
ALT_PREVIOUS_JDK_VERSION =
PREVIOUS_JDK_FILE =
ALT_PREVIOUS_JDK_FILE =
PREVIOUS_JRE_FILE =
ALT_PREVIOUS_JRE_FILE =
PREVIOUS_RELEASE_IMAGE = D:/_DevSpace/JDK 1.6.0_21
ALT_PREVIOUS_RELEASE_IMAGE =
Sanity check passed.

Makefile的Sanity检查过程输出了编译所需的所有环境变量,如果看到“Sanity check passed.”则说明检查过程通过了,可以输入“make”执行整个Makefile,然后就去喝个下午茶再回来了。笔者Core i5/4GB RAM的机器编译整个JDK大概需要半个小时的时间。如果失败则需要根据系统输出的失败原因,回头再检查一下对应的设置。最好在下一次编译之前先执行“make clean”来清理掉上次编译遗留的文件。

编译完成之后,打开OpenJDK源码下的build目录,看看是不是已经有一个编译好的JDK在那里等着了?执行一下“java-version”,看到以自己机器命名的JDK了吧?很有成就感吧?

[^2]: 在2011年,JDK Plug已经不再需要了,但在笔者写本次实战使用的2010年12月9日发布的OpenJDK b121版时还是需要这些JDK Plug的。
[^3]: FreeType主页:http://www.freetype.org/。

9.2.4 Backport工具:Java的时光机器

一般来说,以“做项目”为主的软件公司比较容易更新技术,在下一个项目中换一个技术框架、升级到最时髦的JDK版本,甚至把Java换成C#、Golang来开发都是有可能的。但是当公司发展壮大,技术有所积累,逐渐成为以“做产品”为主的软件公司后,自主选择技术的权利就会逐渐丧失,因为之前积累的代码和技术都是用真金白银砸出来的,一个稳健的团队也不会随意地改变底层的技术。然而在飞速发展的程序设计领域,新技术总是日新月异层出不穷,偏偏这些新技术又如鲜花之于蜜蜂一样, 对程序员们散发着天然的吸引力。

在Java世界里,每一次JDK大版本的发布,都会伴随着规模不等或大或小的技术革新,而对Java程序编写习惯改变最大的,肯定是那些对Java语法做出重大改变的版本,譬如JDK 5时加入的自动装箱、 泛型、动态注解、枚举、变长参数、遍历循环(foreach循环);譬如JDK 8时加入的Lambda表达式、 Stream API、接口默认方法等。事实上在没有这些语法特性的年代,Java程序也照样能写,但是现在回头看来,上述每一种语法的改进几乎都是“必不可少”的,如同用惯了32寸液晶、4K分辨率显示器的程序员,就很难再在19寸显示器、1080P分辨率的显示器上编写代码了。但假如公司“不幸”因为要保护现有投资、维持程序结构稳定等,必须使用JDK 5或者JDK 8以前的版本呢?幸好,我们没有办法把19寸显示器变成32寸的,但却可以跨越JDK版本之间的沟壑,把高版本JDK中编写的代码放到低版本JDK 环境中去部署使用。为了解决这个问题,一种名为“Java逆向移植”的工具(Java Backporting Tools)应运而生,Retrotranslator^1和Retrolambda是这类工具中的杰出代表。

Retrotranslator的作用是将JDK 5编译出来的Class文件转变为可以在JDK 1.4或1.3上部署的版本, 它能很好地支持自动装箱、泛型、动态注解、枚举、变长参数、遍历循环、静态导入这些语法特性, 甚至还可以支持JDK 5中新增的集合改进、并发包及对泛型、注解等的反射操作。Retrolambda^2的作用与Retrotranslator是类似的,目标是将JDK 8的Lambda表达式和try-resources语法转变为可以在JDK 5、JDK 6、JDK 7中使用的形式,同时也对接口默认方法提供了有限度的支持。

了解了Retrotranslator和Retrolambda这种逆向移植工具的作用以后,相信读者更关心的是它是怎样做到的?要想知道Backporting工具如何在旧版本JDK中模拟新版本JDK的功能,首先要搞清楚JDK升级中会提供哪些新的功能。JDK的每次升级新增的功能大致可以分为以下五类:

  • 1)对Java类库API的代码增强。譬如JDK 1.2时代引入的java.util.Collections等一系列集合类,在 JDK 5时代引入的java.util.concurrent并发包、在JDK 7时引入的java.lang.invoke包,等等。
  • 2)在前端编译器层面做的改进。这种改进被称作语法糖,如自动装箱拆箱,实际上就是Javac编 译器在程序中使用到包装对象的地方自动插入了很多Integer.valueOf()、Float.valueOf()之类的代码;变 长参数在编译之后就被自动转化成了一个数组来完成参数传递;泛型的信息则在编译阶段就已经被擦 除掉了(但是在元数据中还保留着),相应的地方被编译器自动插入了类型转换代码^3
  • 3)需要在字节码中进行支持的改动。如JDK 7里面新加入的语法特性——动态语言支持,就需要 在虚拟机中新增一条invokedynamic字节码指令来实现相关的调用功能。不过字节码指令集一直处于相 对稳定的状态,这种要在字节码层面直接进行的改动是比较少见的。
  • 4)需要在JDK整体结构层面进行支持的改进,典型的如JDK 9时引入的Java模块化系统,它就涉 及了JDK结构、Java语法、类加载和连接过程、Java虚拟机等多个层面。
  • 5)集中在虚拟机内部的改进。如JDK 5中实现的JSR-133[^4]规范重新定义的Java内存模型(Java Memory Model,JMM),以及在JDK 7、JDK 11、JDK 12中新增的G1、ZGC和Shenandoah收集器之 类的改动,这种改动对于程序员编写代码基本是透明的,只会在程序运行时产生影响。

上述的5类新功能中,逆向移植工具能比较完美地模拟了前两类,从第3类开始就逐步深入地涉及了直接在虚拟机内部实现的改进了,这些功能一般要么是逆向移植工具完全无能为力,要么是不能完整地或者在比较良好的运行效率上完成全部模拟。想想这也挺合理的,如果在语法糖和类库层面可以完美解决的问题,Java虚拟机设计团队也没有必要舍近求远地改动处于JDK底层的虚拟机嘛。

在能够较好模拟的前两类功能中,第一类模拟相对更容易实现一些,如JDK 5引入的java.util.concurrent包,实际是由多线程编程的大师Doug Lea开发的一套并发包,在JDK 5出现之前就已经存在(那时候名字叫作dl.util.concurrent,引入JDK时由作者和JDK开发团队共同进行了一些改进),所以要在旧的JDK中支持这部分功能,以独立类库的方式便可实现。Retrotranslator中就附带了一个名叫“backport-util-concurrent.jar”的类库(由另一个名为“Backport to JSR 166”的项目所提供)来代替JDK 5的并发包。

至于第二类JDK在编译阶段进行处理的那些改进,Retrotranslator则是使用ASM框架直接对字节码进行处理。由于组成Class文件的字节码指令数量并没有改变,所以无论是JDK 1.3、JDK 1.4还是JDK 5,能用字节码表达的语义范围应该是一致的。当然,肯定不会是简单地把Class的文件版本号从49.0改回48.0就能解决问题了,虽然字节码指令的数量没有变化,但是元数据信息和一些语法支持的内容还是要做相应的修改。

以枚举为例,尽管在JDK 5中增加了enum关键字,但是Class文件常量池的CONSTANT_Class_info 类型常量并没有发生任何语义变化,仍然是代表一个类或接口的符号引用,没有加入枚举,也没有增加过“CONSTANT_Enum_info”之类的“枚举符号引用”常量。所以使用enum关键字定义常量,尽管从Java语法上看起来与使用class关键字定义类、使用interface关键字定义接口是同一层次的,但实际上这是由Javac编译器做出来的假象,从字节码的角度来看,枚举仅仅是一个继承于java.lang.Enum、自动生成了values()和valueOf()方法的普通Java类而已。

Retrotranslator对枚举所做的主要处理就是把枚举类的父类从“java.lang.Enum”替换为它运行时类库中包含的“net.sf.retrotranslator.runtime.java.lang.Enum_”,然后再在类和字段的访问标志中抹去ACC_ENUM标志位。当然,这只是处理的总体思路,具体的实现要比上面说的复杂得多。可以想象既然两个父类实现都不一样,values()和valueOf()的方法自然需要重写,常量池需要引入大量新的来自父类的符号引用,这些都是实现细节。图9-3是一个使用JDK 5编译的枚举类与被Retrotranslator转换处理后的字节码的对比图。

image-20211125161344990

image-20211125161520599

图9-3 Retrotranslator处理前后的枚举类字节码对比

用Retrolambda模拟JDK 8的Lambda表达式属于涉及字节码改动的第三类情况,Java为支持Lambda 会用到新的invokedynamic字节码指令,但幸好这并不是必须的,只是基于效率的考量。在JDK 8之前,Lambda表达式就已经被其他运行在Java虚拟机的编程语言(如Scala)广泛使用了,那时候是怎么生成字节码的现在照着做就是,不使用invokedynamic,除了牺牲一点效率外,可行性方面并没有太大的障碍。

Retrolambda的Backport过程实质上就是生成一组匿名内部类来代替Lambda,里面会做一些优化措施,譬如采用单例来保证无状态的Lambda表达式不会重复创建匿名类的对象。有一些Java IDE工具, 如IntelliJ IDEA和Eclipse里会包含将此过程反过来使用的功能特性,在低版本Java里把匿名内部类显示成Lambda语法的样子,实际存在磁盘上的源码还是匿名内部类形式的,只是在IDE里可以把它显示为Lambda表达式的语法,让人阅读起来比较简洁而已。

[^4]: JSR-133:Java Memory Model and Thread Specification Revision(Java内存模型和线程规范修订)。

9.2.3 字节码生成技术与动态代理的实现

“字节码生成”并不是什么高深的技术,读者在看到“字节码生成”这个标题时也先不必去想诸如 Javassist、CGLib、ASM之类的字节码类库,因为JDK里面的Javac命令就是字节码生成技术的“老祖 宗”,并且Javac也是一个由Java语言写成的程序,它的代码存放在OpenJDK的 jdk.compiler\share\classes\com\sun\tools\javac目录中^1。要深入从Java源码到字节码编译过程,阅读Javac 的源码是个很好的途径,不过Javac对于我们这个例子来说太过庞大了。在Java世界里面除了Javac和字 节码类库外,使用到字节码生成的例子比比皆是,如Web服务器中的JSP编译器,编译时织入的AOP框 架,还有很常用的动态代理技术,甚至在使用反射的时候虚拟机都有可能会在运行时生成字节码来提 高执行速度。我们选择其中相对简单的动态代理技术来讲解字节码生成技术是如何影响程序运作的。

相信许多Java开发人员都使用过动态代理,即使没有直接使用过java.lang.reflect.Proxy或实现过java.lang.reflect.InvocationHandler接口,应该也用过Spring来做过Bean的组织管理。如果使用过Spring, 那大多数情况应该已经不知不觉地用到动态代理了,因为如果Bean是面向接口编程,那么在Spring内部都是通过动态代理的方式来对Bean进行增强的。动态代理中所说的“动态”,是针对使用Java代码实际编写了代理类的“静态”代理而言的,它的优势不在于省去了编写代理类那一点编码工作量,而是实现了可以在原始类和接口还未知的时候,就确定代理类的代理行为,当代理类与原始类脱离直接联系后,就可以很灵活地重用于不同的应用场景之中。

代码清单9-1演示了一个最简单的动态代理的用法,原始的代码逻辑是打印一句“hello world”,代理类的逻辑是在原始类方法执行前打印一句“welcome”。我们先看一下代码,然后再分析JDK是如何做到的。

代码清单9-1 动态代理的简单示例
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class DynamicProxyTest {
interface IHello {
void sayHello();
}
static class Hello implements IHello {
@Override
public void sayHello() {
System.out.println("hello world");
}
}
static class DynamicProxy implements InvocationHandler {
Object originalObj;
Object bind(Object originalObj) {
this.originalObj = originalObj;
return Proxy.newProxyInstance(originalObj.getClass().getClassLoader(), originalObj.getClass().getInterfaces(), this);
}
@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
System.out.println("welcome");
return method.invoke(originalObj, args);
}
}
public static void main(String[] args) {
IHello hello = (IHello) new DynamicProxy().bind(new Hello());
hello.sayHello();
}
}

运行结果如下:

1
2
welcome 
hello world

在上述代码里,唯一的“黑匣子”就是Proxy::newProxyInstance()方法,除此之外再没有任何特殊之处。这个方法返回一个实现了IHello的接口,并且代理了new Hello()实例行为的对象。跟踪这个方法的源码,可以看到程序进行过验证、优化、缓存、同步、生成字节码、显式类加载等操作,前面的步骤并不是我们关注的重点,这里只分析它最后调用sun.misc.ProxyGenerator::generateProxyClass()方法来完成生成字节码的动作,这个方法会在运行时产生一个描述代理类的字节码byte[]数组。如果想看一看这个在运行时产生的代理类中写了些什么,可以在main()方法中加入下面这句:

1
System.getProperties().put("sun.misc.ProxyGenerator.saveGeneratedFiles", "true");

加入这句代码后再次运行程序,磁盘中将会产生一个名为“$Proxy0.class”的代理类Class文件,反编译后可以看见如代码清单9-2所示的内容:

代码清单9-2 反编译的动态代理类的代码
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
package org.fenixsoft.bytecode;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
import java.lang.reflect.UndeclaredThrowableException;

public final class $Proxy0 extends Proxy implements DynamicProxyTest.IHello {
private static Method m3;
private static Method m1;
private static Method m0;
private static Method m2;
public $Proxy0(InvocationHandler paramInvocationHandler) throws {
super(paramInvocationHandler);
}
public final void sayHello() throws {
try {
this.h.invoke(this, m3, null);
return;
}
catch (RuntimeException localRuntimeException) {
throw localRuntimeException;
}

catch (Throwable localThrowable) {
throw new UndeclaredThrowableException(localThrowable);
}
}
// 此处由于版面原因,省略equals()、hashCode()、toString()3个方法的代码
// 这3个方法的内容与sayHello()非常相似。
static {
try {
m3 = Class.forName("org.fenixsoft.bytecode.DynamicProxyTest$IHello").getMethod("sayHello", new Class[^0]);
m1 = Class.forName("java.lang.Object").getMethod("equals", new Class[] {Class.forName("java.lang.Object") });
m0 = Class.forName("java.lang.Object").getMethod("hashCode", new Class[^0]);
m2 = Class.forName("java.lang.Object").getMethod("toString", new Class[^0]);
return;
}
catch (NoSuchMethodException localNoSuchMethodException) {
throw new NoSuchMethodError(localNoSuchMethodException.getMessage());
}
catch (ClassNotFoundException localClassNotFoundException) {
throw new NoClassDefFoundError(localClassNotFoundException.getMessage());
}
}
}

这个代理类的实现代码也很简单,它为传入接口中的每一个方法,以及从java.lang.Object中继承来的equals()、hashCode()、toString()方法都生成了对应的实现,并且统一调用了InvocationHandler对象的invoke()方法(代码中的“this.h”就是父类Proxy中保存的InvocationHandler实例变量)来实现这些方法的内容,各个方法的区别不过是传入的参数和Method对象有所不同而已,所以无论调用动态代理的哪一个方法,实际上都是在执行InvocationHandler::invoke()中的代理逻辑。

这个例子中并没有讲到generateProxyClass()方法具体是如何产生代理类“$Proxy0.class”的字节码的,大致的生成过程其实就是根据Class文件的格式规范去拼装字节码,但是在实际开发中,以字节为单位直接拼装出字节码的应用场合很少见,这种生成方式也只能产生一些高度模板化的代码。对于用户的程序代码来说,如果有要大量操作字节码的需求,还是使用封装好的字节码类库比较合适。如果读者对动态代理的字节码拼装过程确实很感兴趣,可以在OpenJDK的java.base\share\classes\java\lang\reflect目录下找到sun.misc.ProxyGenerator的源码。

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

曾经在Java程序社区中流传着这么一个观点:“学习Java EE规范,推荐去看JBoss源码;学习类加载器的知识,就推荐去看OSGi源码。”尽管“Java EE规范”和“类加载器的知识”并不是一个对等的概念,不过,既然这个观点能在部分程序员群体中流传开来,也从侧面说明了OSGi对类加载器的运用确实有其独到之处。

OSGi^1(Open Service Gateway Initiative)是OSGi联盟(OSGi Alliance)制订的一个基于Java语言 的动态模块化规范(在JDK 9引入的JPMS是静态的模块系统),这个规范最初由IBM、爱立信等公司 联合发起,在早期连Sun公司都有参与。目的是使服务提供商通过住宅网关为各种家用智能设备提供服 务,后来这个规范在Java的其他技术领域也有相当不错的发展,现在已经成为Java世界中“事实上”的动 态模块化标准,并且已经有了Equinox、Felix等成熟的实现。根据OSGi联盟主页上的宣传资料,OSGi 现在的重点应用在智慧城市、智慧农业、工业4.0这些地方,而在传统Java程序员中最知名的应用案例 可能就数Eclipse IDE了,另外,还有许多大型的软件平台和中间件服务器都基于或声明将会基于OSGi 规范来实现,如IBM Jazz平台、GlassFish服务器、JBoss OSGi等。

OSGi中的每个模块(称为Bundle)与普通的Java类库区别并不太大,两者一般都以JAR格式进行封装[^2],并且内部存储的都是Java的Package和Class。但是一个Bundle可以声明它所依赖的Package(通过Import-Package描述),也可以声明它允许导出发布的Package(通过Export-Package描述)。在OSGi 里面,Bundle之间的依赖关系从传统的上层模块依赖底层模块转变为平级模块之间的依赖,而且类库的可见性能得到非常精确的控制,一个模块里只有被Export过的Package才可能被外界访问,其他的Package和Class将会被隐藏起来。

以上这些静态的模块化特性原本也是OSGi的核心需求之一,不过它和后来出现的Java的模块化系统互相重叠了,所以OSGi现在着重向动态模块化系统的方向发展。在今天,通常引入OSGi的主要理由是基于OSGi架构的程序很可能(只是很可能,并不是一定会,需要考虑热插拔后的内存管理、上下文状态维护问题等复杂因素)会实现模块级的热插拔功能,当程序升级更新或调试除错时,可以只停用、重新安装然后启用程序的其中一部分,这对大型软件、企业级程序开发来说是一个非常有诱惑力的特性,譬如Eclipse中安装、卸载、更新插件而不需要重启动,就使用到了这种特性。

OSGi之所以能有上述诱人的特点,必须要归功于它灵活的类加载器架构。OSGi的Bundle类加载器之间只有规则,没有固定的委派关系。例如,某个Bundle声明了一个它依赖的Package,如果有其他Bundle声明了发布这个Package后,那么所有对这个Package的类加载动作都会委派给发布它的Bundle类加载器去完成。不涉及某个具体的Package时,各个Bundle加载器都是平级的关系,只有具体使用到某个Package和Class的时候,才会根据Package导入导出定义来构造Bundle间的委派和依赖。

另外,一个Bundle类加载器为其他Bundle提供服务时,会根据Export-Package列表严格控制访问范围。如果一个类存在于Bundle的类库中但是没有被Export,那么这个Bundle的类加载器能找到这个类, 但不会提供给其他Bundle使用,而且OSGi框架也不会把其他Bundle的类加载请求分配给这个Bundle来处理。

我们可以举一个更具体些的简单例子来解释上面的规则,假设存在Bundle A、Bundle B、BundleC3个模块,并且这3个Bundle定义的依赖关系如下所示。

  • Bundle A:声明发布了packageA,依赖了java.*的包;
  • Bundle B:声明依赖了packageA和packageC,同时也依赖了java.*的包;
  • Bundle C:声明发布了packageC,依赖了packageA。

那么,这3个Bundle之间的类加载器及父类加载器之间的关系如图9-2所示。

image-20211125160302086

图9-2 OSGi的类加载器架构

由于没有涉及具体的OSGi实现,图9-2中的类加载器都没有指明具体的加载器实现,它只是一个体现了加载器之间关系的概念模型,并且只是体现了OSGi中最简单的加载器委派关系。一般来说,在OSGi里,加载一个类可能发生的查找行为和委派关系会远远比图9-2中显示的复杂,类加载时可能进行的查找规则如下:

  • 以java.*开头的类,委派给父类加载器加载。
  • 否则,委派列表名单内的类,委派给父类加载器加载。
  • 否则,Import列表中的类,委派给Export这个类的Bundle的类加载器加载。
  • 否则,查找当前Bundle的Classpath,使用自己的类加载器加载。
  • 否则,查找是否在自己的Fragment Bundle中,如果是则委派给Fragment Bundle的类加载器加载。
  • 否则,查找Dynamic Import列表的Bundle,委派给对应Bundle的类加载器加载。
  • 否则,类查找失败。

从图9-2中还可以看出,在OSGi中,加载器之间的关系不再是双亲委派模型的树形结构,而是已经进一步发展成一种更为复杂的、运行时才能确定的网状结构。这种网状的类加载器架构在带来更优秀的灵活性的同时,也可能会产生许多新的隐患。笔者曾经参与过将一个非OSGi的大型系统向Equinox OSGi平台迁移的项目,由于项目规模和历史原因,代码模块之间的依赖关系错综复杂,勉强分离出各个模块的Bundle后,发现在高并发环境下经常出现死锁。我们很容易就找到了死锁的原因:如果出现了Bundle A依赖Bundle B的Package B,而Bundle B又依赖了Bundle A的Package A,这两个Bundle进行类加载时就有很高的概率发生死锁。具体情况是当Bundle A加载Package B的类时,首先需要锁定当前类加载器的实例对象(java.lang.ClassLoader.loadClass()是一个同步方法),然后把请求委派给Bundle B的加载器处理,但如果这时Bundle B也正好想加载Package A的类,它会先锁定自己的加载器再去请求Bundle A的加载器处理,这样两个加载器都在等待对方处理自己的请求,而对方处理完之前自己又一直处于同步锁定的状态,因此它们就互相死锁,永远无法完成加载请求了。Equinox的Bug List中有不少关于这类问题的Bug[^3],也提供了一个以牺牲性能为代价的解决方案——用户可以启用osgi.classloader.singleThreadLoads参数来按单线程串行化的方式强制进行类加载动作。在JDK 7时才终于出现了JDK层面的解决方案,类加载器架构进行了一次专门的升级,在ClassLoader中增加了registerAsParallelCapable方法对可并行的类加载进行注册声明,把锁的级别从ClassLoader对象本身,降低为要加载的类名这个级别,目的是从底层避免以上这类死锁出现的可能。

总体来说,OSGi描绘了一个很美好的模块化开发的目标,而且定义了实现这个目标所需的各种服务,同时也有成熟框架对其提供实现支持。对于单个虚拟机下的应用,从开发初期就建立在OSGi上是一个很不错的选择,这样便于约束依赖。但并非所有的应用都适合采用OSGi作为基础架构,OSGi在提供强大功能的同时,也引入了额外而且非常高的复杂度,带来了额外的风险。

[^2]: OSGi R7开始支持JDK 9的JPMS,但只是兼容意义上的支持,并未将两者重合的特性互相融合。譬 如在R7中Bundle仍然是一个标准的JAR包,未封装成Module(即以Unnamed Module的形式存在)。
[^3]: Bug-121737:https://bugs.eclipse.org/bugs/show_bug.cgi?id=121737。

9.2 案例分析

在案例分析部分,笔者准备了4个例子,关于类加载器和字节码的案例各有两个。并且这两个领域的案例中又各有一个案例是大多数Java开发人员都使用过的工具或技术,另外一个案例虽然不一定每个人都使用过,但却能特别精彩地演绎出这个领域中的技术特性。希望后面的案例能引起读者的思考,并给读者的日常工作带来灵感。

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

主流的Java Web服务器,如Tomcat、Jetty、WebLogic、WebSphere或其他笔者没有列举的服务器, 都实现了自己定义的类加载器,而且一般还都不止一个。因为一个功能健全的Web服务器,都要解决如下的这些问题:

  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以实现相互隔离。这是最基本的 需求,两个不同的应用程序可能会依赖同一个第三方类库的不同版本,不能要求每个类库在一个服务 器中只能有一份,服务器应当能够保证两个独立应用程序的类库可以互相独立使用。
  • 部署在同一个服务器上的两个Web应用程序所使用的Java类库可以互相共享。这个需求与前面一 点正好相反,但是也很常见,例如用户可能有10个使用Spring组织的应用程序部署在同一台服务器 上,如果把10份Spring分别存放在各个应用程序的隔离目录中,将会是很大的资源浪费——这主要倒 不是浪费磁盘空间的问题,而是指类库在使用时都要被加载到服务器内存,如果类库不能共享,虚拟 机的方法区就会很容易出现过度膨胀的风险。
  • 服务器需要尽可能地保证自身的安全不受部署的Web应用程序影响。目前,有许多主流的Java Web服务器自身也是使用Java语言来实现的。因此服务器本身也有类库依赖的问题,一般来说,基于安 全考虑,服务器所使用的类库应该与应用程序的类库互相独立。
  • 支持JSP应用的Web服务器,十有八九都需要支持HotSwap功能。我们知道JSP文件最终要被编译 成Java的Class文件才能被虚拟机执行,但JSP文件由于其纯文本存储的特性,被运行时修改的概率远大 于第三方类库或程序自己的Class文件。而且ASP、PHP和JSP这些网页应用也把修改后无须重启作为一 个很大的“优势”来看待,因此“主流”的Web服务器都会支持JSP生成类的热替换,当然也有“非主 流”的,如运行在生产模式(Production Mode)下的WebLogic服务器默认就不会处理JSP文件的变化。

由于存在上述问题,在部署Web应用时,单独的一个ClassPath就不能满足需求了,所以各种Web服务器都不约而同地提供了好几个有着不同含义的ClassPath路径供用户存放第三方类库,这些路径一般会以“lib”或“classes”命名。被放置到不同路径中的类库,具备不同的访问范围和服务对象,通常每一个目录都会有一个相应的自定义类加载器去加载放置在里面的Java类库。现在笔者就以Tomcat服务器[^1]为例,与读者一同分析Tomcat具体是如何规划用户类库结构和类加载器的。

在Tomcat目录结构中,可以设置3组目录(/common/*、/server/*和/shared/*,但默认不一定是开放的,可能只有/lib/*目录存在)用于存放Java类库,另外还应该加上Web应用程序自身的“/WEB- INF/*”目录,一共4组。把Java类库放置在这4组目录中,每一组都有独立的含义,分别是:

  • 放置在/common目录中。类库可被Tomcat和所有的Web应用程序共同使用。
  • 放置在/server目录中。类库可被Tomcat使用,对所有的Web应用程序都不可见。
  • 放置在/shared目录中。类库可被所有的Web应用程序共同使用,但对Tomcat自己不可见。
  • 放置在/WebApp/WEB-INF目录中。类库仅仅可以被该Web应用程序使用,对Tomcat和其他Web应
  • 用程序都不可见。

为了支持这套目录结构,并对目录里面的类库进行加载和隔离,Tomcat自定义了多个类加载器, 这些类加载器按照经典的双亲委派模型来实现,其关系如图9-1所示。

image-20211125160018667

图9-1 Tomcat服务器的类加载架构

灰色背景的3个类加载器是JDK(以JDK 9之前经典的三层类加载器为例)默认提供的类加载器, 这3个加载器的作用在第7章中已经介绍过了。而Common类加载器、Catalina类加载器(也称为Server类加载器)、Shared类加载器和Webapp类加载器则是Tomcat自己定义的类加载器,它们分别加载/common/*、/server/*、/shared/*和/WebApp/WEB-INF/*中的Java类库。其中WebApp类加载器和JSP类加载器通常还会存在多个实例,每一个Web应用程序对应一个WebApp类加载器,每一个JSP文件对应一个JasperLoader类加载器。

从图9-1的委派关系中可以看出,Common类加载器能加载的类都可以被Catalina类加载器和Shared 类加载器使用,而Catalina类加载器和Shared类加载器自己能加载的类则与对方相互隔离。WebApp类加载器可以使用Shared类加载器加载到的类,但各个WebApp类加载器实例之间相互隔离。而JasperLoader的加载范围仅仅是这个JSP文件所编译出来的那一个Class文件,它存在的目的就是为了被丢弃:当服务器检测到JSP文件被修改时,会替换掉目前的JasperLoader的实例,并通过再建立一个新的JSP类加载器来实现JSP文件的HotSwap功能。

本例中的类加载结构在Tomcat 6以前是它默认的类加载器结构,在Tomcat 6及之后的版本简化了默认的目录结构,只有指定了tomcat/conf/catalina.properties配置文件的server.loader和share.loader项后才会真正建立Catalina类加载器和Shared类加载器的实例,否则会用到这两个类加载器的地方都会用Common类加载器的实例代替,而默认的配置文件中并没有设置这两个loader项,所以Tomcat 6之后也顺理成章地把/common、/server和/shared这3个目录默认合并到一起变成1个/lib目录,这个目录里的类库相当于以前/common目录中类库的作用,是Tomcat的开发团队为了简化大多数的部署场景所做的一项易用性改进。如果默认设置不能满足需要,用户可以通过修改配置文件指定server.loader和share.loader 的方式重新启用原来完整的加载器架构。

Tomcat加载器的实现清晰易懂,并且采用了官方推荐的“正统”的使用类加载器的方式。如果读者阅读完上面的案例后,毫不费力就能完全理解Tomcat设计团队这样布置加载器架构的用意,这就说明你已经大致掌握了类加载器“主流”的使用方式,那么笔者不妨再提一个问题让各位读者思考一下:前面曾经提到过一个场景,如果有10个Web应用程序都是用Spring来进行组织和管理的话,可以把Spring 放到Common或Shared目录下让这些程序共享。Spring要对用户程序的类进行管理,自然要能访问到用户程序的类,而用户的程序显然是放在/WebApp/WEB-INF目录中的。那么被Common类加载器或Shared类加载器加载的Spring如何访问并不在其加载范围内的用户程序呢?如果你读懂了本书第7章的相关内容,相信回答这个问题一定会毫不费力。

[^1]: Tomcat是Apache基金会旗下一款开源的Java Web服务器,主页地址为:http://tomcat.apache.org。