
第1章 Java概要
Java是一门面向对象的编程语言,它选择性地吸取了C++语言的优点,并在其基础上丰富了自己的体系。Java在健壮性、可移植性、安全性等多个方面均有所突破。同时Java的单一继承性和引用的概念(无指针)也使语言更易理解。本章通过讲解Java中常用的能力使读者能够快速地了解和使用Java。如果您已经对Java有非常深的理解,那么此章也可以作为Java核心内容的提炼笔记,需要时可以随手翻阅。
1.1 Java环境搭建
开始学习Java,往往需要配置Java的基础环境,环境配置好后,就可以编写Java代码并编译运行。可以选择用记事本编写代码、用JDK编译运行,但是本书推荐直接采用Eclipse[1]作为Java的IDE工具编译并运行代码。
1.1.1 Java基础环境搭建
Java基础环境搭建简单来说就是下载对应的JDK,安装后配置对应的环境变量。下面是基础环境搭建的详细流程。
1)首先通过官网下载对应的JDK版本,官网地址为http://www.oracle.com/technetwork/ java/javase/downloads/index.html,本书采用统一的JDK版本1.8。下载时注意选择JDK的版本和对应的操作系统。
2)下载完成后进行“傻瓜式”安装,安装完成后进行环境变量的配置。在计算机中找到环境变量设置的地方,添加对应的变量名和值,见表1-1。
表1-1 Java环境变量配置
3)JDK基础环境安装完毕,下面通过命令行检测是否正确安装。在命令行输入java-version,若输出结果类似图1-1,则说明基础环境搭建正确。
图1-1 命令行输出结果
1.1.2 Eclipse的安装
IDE(提供程序开发环境)是每个从事编程工作的人必须接触的工具,一个好的IDE能够大大地提高研发效率,Eclipse就是这样一款开源的工具。本节介绍Eclipse工具的安装,安装完成后使用Eclipse编写简单的代码,通过控制台输出这段代码的运行结果“Hello World!”。
Eclipse的安装非常简单,通过如下几步即可完成:
1)通过搜索找到下载地址,或者直接去Eclipse的官网https://www.eclipse.org/downloads/进行下载。下载时选择eclipse-packages,然后选择Eclipse IDE for Java EE Developers的正确系统版本即可。
2)下载后的文件为eclipse-jee-oxygen-2-win32-x86_64.zip,解压此文件到你想安装的目录下,在解压后的文件中找到eclipse.exe可执行文件并运行。
3)运行后程序会让你选择工作空间,设定好工作空间文件夹后即可进入程序。
1.1.3第一个Java程序
Java基础环境和编译运行环境已经准备妥当,下面运行第一个程序“Hello World!”。
1)在Eclipse的菜单中选择“File->New->Project->Java Project”。Project name设置为HelloWorld,可以设置工程存放路径或者默认,然后单击finish按钮。
2)此时会生成一个HelloWorld的工程,鼠标右键单击此工程,在快捷菜单中选择“New->Class”,添加Name为HelloWorld,可以设置Package为自定义名字(一般为域名的反转)或者直接使用默认名称,然后单击finish按钮。
3)现在已经创建了第一个Java的类HelloWorld。在此类中填写如下内容:
4)在此文件上用鼠标右键单击,选择“Run AS->Java Application”,可在Console窗格中看到输出为“Hello World!”。
以上是一个最简单的Java程序。如果在编写代码时经常犯拼写错误,可以设置Eclipse代码提示来解决这个问题。把“Window->Preferences->Java->Editor->Content Assist”中的Auto activation triggers for Java设置改为abcdefghijklmnopqrstuvwxyz.即可。这也是直接使用IDE的好处之一。
1.2 基本类型与运算
Java是一种面向对象的编程语言,并且Java的单根继承结构导致所有的对象都是由Object派生而来,但Java中也存在一些特例,即Java的基本类型。
1.2.1 基本类型概述
Java的基本类型包含表示真假的boolean,表示字符的char,表示数值的byte、short、int、long、float、double,表示空的void。Java基本类型的取值范围及默认值见表1-2。
表1-2 Java基本类型
下面针对各个类型,通过程序验证其具体情况。创建一个JavaBasicTypes类,编写如下代码:
㊀静态方法basicTypesRange可以直接被main方法调用,具体原理后面会有讲述,本章如非必要不再展示main方法的代码。
运行结果如下:
以上代码使用它们的包装类取出对应的值,此方法是获取基本类型边界值的最快方法。程序的输出结果和预期的一样,7个基本类型都正确输出了它们的范围值和默认值。这段代码提前使用了静态类和静态成员变量,具体讲解在后面章节会有涉及。另外本例仅用于演示,具体工作中应尽量保证所有值都已经被正确初始化。
1.2.2 操作符
1.2.1节介绍了Java基本类型的概念,那么基本类型如何使用呢?基本类型的使用与操作符(运算符)是分不开的。操作符用于进行变量或者对象之间的计算或者关系判断,没有操作符就无法做任何运算、比较或者赋值。操作符主要分为以下几类,分别是算术操作符、赋值操作符、关系操作符、逻辑操作符、位操作符和其他操作符。
(1)算术操作符:包括加号(+)、减号(-)、乘号(*)、除号(/)以及取模操作符(%,除法的余数)、自增和自减运算符(++和--)。二元[2]算术操作符与等号连接使用可以达到简化书写的目的,例如a+=b[3]表示a=a+b。代码如下:
运行结果如下:
由输出结果可见,运用算术操作符,可以进行对应的数学运算。请注意除法对于int类型来讲是直接去掉小数点后面数字的,而不是四舍五入;自增自减操作符写在不同的位置得到的结果是不同的;简化的运算符赋值写法会改变左侧变量的值。
(2)赋值操作符:从前面的例子可以看到一个常用的符号“=”,它的目的就是把右边的值赋值给左边。有些书把“+=”操作符也归入赋值操作符,但是作者认为这仅仅是算术操作符与赋值操作符的一种简化合并写法,列入算术操作符或赋值操作符均可,这里就不再过多介绍。
(3)关系操作符:主要包含6种操作符,具体含义见表1-3。
表1-3 关系操作符
下面通过代码演示关系操作符的使用方法及判断结果。
运行结果如下:
value==10 is true
value!=10 is false
value!=11 is true
value>9 is true
value<9 is false
value>=10 is true
value<=8 is false
可以把关系操作符用于变量之间的比较,本例为了直观直接使用数值进行比较。
(4)逻辑操作符:包含逻辑与操作符“&&”,逻辑或操作符“||”,逻辑非操作符“!”。逻辑与操作符当两侧都为真时为真,逻辑或操作符当两侧有一个为真时为真,逻辑非操作符表示取反。
下面所示代码演示了逻辑操作符的使用方法。逻辑或操作符稍有特殊:当第一个表达式为真时,不再执行第二个表达式,这种情况称为短路。
运行结果如下:
(5)位操作符:用来操作整数基本数据类型中的二进制位。这种用法在实际使用中比较少用。下面以整数类型int为例,讲解位操作符的用法。代码如下:
运行结果如下:
上面例子中使用的Integer.toBinaryString()方法,是转化整型数为二进制数的展示。代码中先打印了两个整数的二进制的展示形式,然后通过位操作符对数字进行操作,获取结果。位操作符的含义见表1-4。
表1-4 位操作符
表中难以理解的地方就是负数的位操作,但是这种操作使用较少,待使用时再详细了解 即可。
(6)三元操作符:此操作符较为特殊,因为它有3个操作数。简单来讲,此操作符通过第一个操作数的判断条件是否为真,在后面两个操作数中选择一个。
Condition?value0:value1;
如果Condition为真,选择value0,否则选择value1。代码如下:
运行结果如下:
condition operator trueCondition=conditionTrue
condition operator falseCondition=conditionFalse
(7)字符串操作符:前面的例子中已经大量使用此操作符,字符串的连接通过操作符“+”或者“+=”实现,其他类型和字符串进行“+”操作时会转化为字符串。代码如下:
运行结果如下:
string value 12
string value 3
3string value
string value add other string
可见,字符串连续与整型进行“+”操作时,由于操作符的结合性,是自左向右连接了数字,而不是进行了数字的加法操作。当用()把数字括起来后,数字才可以正确相加,这又涉及了操作符的优先级,见表1-5。
表1-5 操作符的优先级和结合性[4]
1.2.3 类型转换与越界
表示数值的基本类型之间是可以进行相互转换的,当取值范围比较小的类型向较大类型转换时可以获得更高的精度或者更大的存储空间;当取值范围比较大的类型向较小的类型转换时,往往意味着丢失或者其他问题。boolean是不能和其他类型进行转换的。下面用几个例子来说明这些问题。
(1)类型转换:以int为初始数据类型,赋整数的最大值,然后向其他类型转换。代码如下:
运行结果如下:
max int=2147483647
max int to short=-1
max int to long=2147483647
max int to float=2.14748365E9
max int to double+2.147483647E9
可见,当int转为short的时候,存在风险;当int转为long时一切正常;当int转为float时数据会丢失一部分;当int转为double时一切正常。
(2)越界:观察几种类型已经为可表示的最大值时,再进行加运算会发生什么;或者几种类型已经为可表示的最小值时,再进行减运算会发生什么。代码如下:
㊀感兴趣的读者可以了解一下为什么使用-Double.MAX_VALUE而不使用Double.MIN_VALUE。
运行结果如下:
max int=2147483647
max int+1=-2147483648
min int=-2147483648
min int-1=2147483647
max double=1.7976931348623157E308
max double+max double=Infinity
-max double=-1.7976931348623157E308
-max double-max double=-Infinity
越界的运算往往出现意想不到的结果,虽然实际使用这些类型时,只要正确地分配了对应的类型(例如不要给手机号分配byte类型),一般都不会出现这种情况。但是如果代码编写中出现了错误,还是会遇到越界的情况。
(3)boolean的使用:boolean不可以通过其他类型转换而来(与C++不同),boolean的值只能是true或者false。代码如下:
运行结果如下:
boolean value=true
在上述代码中,注释掉的部分是错误的使用方法,一定要注意。
(4)运算中的转换与赋值:当不同类型同时参与一组运算时,往往伴随着类型转换,而类型的转换都是向上(更大)转换。代码如下:
运行结果如下:
int i/j=1
double i/j=1.0
double(double)i/j=1.2
double i*1.0/j=1.2
■当int相除的结果赋给int类型时,会去掉小数点后面的数。
■当int相除的结果赋给double类型时,其实是先得出int的整数值,然后用这个得出的整数值赋给double类型,所以还是会丢失数据,但是精度提高了。
■当int相除时进行显式的类型转换,则结果为double类型。
■当以int先乘以一个double类型的值,此int值已经升级为double类型,计算结果赋给double类型可以得到正确的值。
1.3 流程控制
程序在执行时会出现各种情况,例如上一节通过关系操作符和逻辑操作符得出的结果,会走向不同的程序分支,如何实现分支的选择就属于流程控制。另外程序还会出现不停执行某语句,直到执行条件不成立为止的情况,这也属于流程控制。Java处理流程控制的关键字和语句包含if-else、while、do-while、for、return、break、continue、switch。本节讲解以上主要流程控制语句的使用方法。
1.3.1 If-else
if-else语句主要是根据if语句的判断结果,选择不同的分支路径。此语句有几种不同的写法:if后面可以没有else语句;if-else语句一起使用;或者else后面可以再连接一个if的判断语句,继续进行条件判断。代码如下:
运行结果如下:
num=51
num<100
num>=50&&num<100
在上面的例子中,传入的参数为51,可见第一个if条件判断不成功,所以对应的代码段没有执行;第二个if语句判断成功,所以显示了num<100;最后,在else后面的if语句判断成功,所以显示num>=50&&num<100。
1.3.2 Switch
当使用if-else语句时,如果需要判断的条件过多,那么会出现很多个if-else语句,这样的代码可读性是很差的,当出现这种情况时推荐使用switch语句。switch语句列出了所有待选条件,当符合条件判断时则执行相应的代码。例如:
㊀枚举类型可以先理解为对几个同类常量值的封装。
当传入参数为Color.RED时,输出为:
color is RED
swtich语句的主要写法如上所示,用case列举各种情况进行匹配,当匹配成功时执行相应的代码段,代码段的后面用break结束执行。break的主要作用就是结束当前的选择语句或者循环语句。如果去掉case后面对应的break语句,那么代码将继续执行下一个case的内容。连续执行的特性在实际工作中会有用处,但是在没有彻底搞清楚之前不建议使用。
1.3.3 For
for循环其实是依靠三个字段来达成循环的目的,三个字段分别是初始值、结束条件、游标移动。设置一个游标的初始值,每次循环移动游标,达到结束条件时结束循环。例如:
运行结果如下:
0 1 2 3 4 5 6 7 8 9
上例中使用了两种for循环的用法,第一种是基本的for循环使用方法,用for循环实现了数组的赋值。第二种方法是对已有的数据进行遍历,是for循环的简单写法。
1.3.4 While
while也是一种循环控制的方法,while后面跟随一个判断条件,当条件成立时则执行后面程序段的语句。do-while方法则是先执行语句,再进行条件判断。例如:
运行结果如下:
0 1 2 3 4 5 6 7 8 9
for和while都是Java进行循环操作的方法,但是写循环时一定要谨慎,除了有目的的无穷循环[5]以外一定要确定循环可以退出,即有结束条件并且可以结束。还有就是循环的嵌套,例如for语句中又嵌套了一层for语句,一定要确定这种写法不会对程序的执行造成很大的影响,嵌套循环的时间复杂度是两个循环执行次数相乘,应尽量优化这种嵌套写法,例如使用便于查找的容器来替代其中的一层循环等。除非确实必要[6],尽量不要写3层以上嵌套的循环,这种循环会让程序完全失控。
1.3.5 break与continue
break与continue在循环中起着重要的作用。break可以直接退出整个循环,当循环嵌套时,退出break所属的循环;continue可以结束本次循环,进行下次循环。例如:
运行结果如下:
0 1 2 4 5
上面的代码对前面的for循环的例子进行修改,在打印时设置了条件判断,当j为3时,直接进行下次循环,所以3没有打印出来;当j为6时,直接退出整个循环,所以6以后的数字没有打印。
1.3.6 Return
return语句可以退出当前的方法,并且可以带出返回值;如果一个void返回值的方法没有写return,那么在方法的结尾有一个隐式的return。return语句后面的代码段都不会执行,但是有一个例外——finally。例如:
在main方法中传不同参数的输出分别为:
0:
testReturn start*******
testReturn end*******
1:
testReturn start*******
2:
testReturn start*******
testReturn try*******
testReturn finally*******
return语句的执行就代表一个方法的结束。return语句后面可以跟随一个变量用于返回一个值,例如return 0;至于finally这个特例会在1.8节讲述,这里仅作为演示。
1.4 对象
Java是一种面向对象的语言,什么是面向对象以及如何使用对象是本节要介绍的内容。
1.4.1 什么是对象
什么是对象?试想身边常用的任何物品,拿正在使用的手机举例。把手机比喻成对象,那么手机的硬件例如CPU、显示屏、电池就是对象里的字段;打电话、使用app、上网等就是对象里的方法。面向对象的核心其实就是把任何事物抽象为类,这个事物具备的能力就是抽象出来的方法,这个事物具备的各个实际物品就是抽象出来的字段。下面以学生为例,编写一个学生类并创建它的实例[7]。
观察上面的代码,这个类名叫Student(Java的public的类名必须和文件名相同)。这个类从学生这个群体中抽象出来两个字段,一个是age(年龄),一个是name(名字)。可以通过get或者set方法对字段进行获取和设置操作,例如getAge()方法得到学生的年龄。下面根据这个抽象出来的类,创建第一个实体(实例)。
通过new关键字,可以创建某个类的实例。这样就完成了Java面向对象最基本的抽象和实例创建的过程。其中类是抽象,new是创建此类型单个实例个体。
1.4.2 方法
前面代码中已经大量使用了方法,读者对方法的使用应该也有一个初步的了解。方法主要包含4个内容,按照顺序分别是:返回值、方法名、参数、方法体。也可以用其他关键字来修饰一个方法,以达到其他能力,例如方法的可见范围和静态[8]。
普通方法的调用格式是Object.fun(arg);。下面编写代码对上一节创建的实例进行方法的 调用。
运行结果如下:
student age=12
student name=xiaoming
在代码中已经演示了创建对象以及方法的调用,为了揭示对象更多的特性,需要再创建一个类School。具体代码如下:
上面的代码中用到了import关键字,它的作用是引用其他类,本例中它引用了List容器 类[9]。以后在代码中使用其他的类时,也需要用此关键字引入。
代码中的School类是对学校的抽象,包含的字段有地址、名字以及学生列表。可以实现新的方法,用于把学生添加到学校的学生列表中。具体代码如下:
以上两个方法负责把学生添加到学生列表,下面使用这两个方法向学校中添加学生。
通过上面的代码可见,两个相同名字的方法都可以用于把学生放入学生列表,这就是Java的方法重载机制。方法重载就是同名方法,但是方法的参数数量或者类型不同[10]。
在代码中会发现一个问题,每次创建一个学生对象的时候,总是要调用两次set方法用于设置学生的字段属性,有没有什么办法能够方便地创建对象呢?
1.4.3 初始化
对象的初始化是通过构造器实现的,构造器就是与类名相同并且没有返回值的那个方法。如果一个类没有明确地编写构造器,那么编译器会默认生成一个构造器。构造器可以有多个,每个构造器的参数列表不同。下面针对Student类编写它的构造器。
如果只写上面带参数的构造器,那么之前编写的代码无法正确编译,因为默认的构造器没有自动生成,而之前的代码都是通过默认构造器创建的对象,所以必须添加无参数的构造器。添加Student的构造器后,可以修改School类的addStudent方法为:
这种调用构造器创建对象的方法会使代码更简洁,同时也保证程序的健壮,否则对象中的字段没有正确初始化,会在不可知的地方发生问题。同时也注意到,之前的代码用/*、*/和//框起来了,/*和*/可以使其中间的代码失效,//可以使它后面的代码失效。通常用它们注释无用代码或者添加说明性文字。
除了构造器的方式,也有其他的方法初始化字段的值,例如直接在声明字段的时候赋值,或者通过初始化代码块来进行赋值。下面演示这两种写法。
㊀本书在代码段中可能会包含“…”这样的内容,这是表示在书中省略了部分已介绍代码或者get、set方法,如果要查看“…”表示的实际内容,可以查看本书附带的工程源码。
运行结果如下:
student age=18
student name=todo
以上截取了部分Student类修改后的写法,由输出可见字段被设置了初始值。下面添加初始化块,再执行main方法观察字段值最后的输出结果。
运行结果如下:
student age=20
student name=Construct
最后的输出结果是初始化块中赋值的结果,这里涉及了一个初始化顺序的问题,最后赋值的结果才会被打印出来,所以可知初始化块的执行时间晚于变量声明时赋值的时间。
在目前已经介绍的内容中,初始化顺序先是变量声明时的赋值,然后是初始化块,最后是构造器。对于一个普通的类,这个顺序是一定的,但是当引入静态和继承之后,会在这个顺序的基础上插入其他步骤。
1.4.4 This与Static
在上面的例子中,使用set方法设置实例某个字段的值,方法里的语句是this.name=name;。这里this的作用就是指代调用这个方法的实例,这句话的意思就是把调用的实例的name字段的值设为传入的参数的值。普通成员方法都是默认有this的,其意义就是指代调用的实例。
static修饰的方法称为静态方法。静态方法与普通成员方法不同,静态方法里没有this,所以它不能指代调用的实例,或者说静态方法不关心是哪个实例调用它,它只对所属的类负责。下面对Student类进行改造,统计创建的学生的人数。
运行结果如下:
student count=10
这里仅展示和静态相关的内容,添加了一个静态的变量count和静态方法getCount()。
每次调用构造器时把计数变量自增。这样在main方法中创建了10个对象后,计数器会正确记录创建实例的个数。注意静态方法的调用方式,是通过类名调用的,而不是通过实例调 用的。
对于静态和非静态的区别,只要记住,静态变量是属于类的,一个类仅有一份[11];非静态变量是属于实例的,每个实例一份;静态方法是没有this的,所以不能像普通方法那样调用,静态方法不能直接调用类里的非静态变量和方法,因为非静态的变量和方法需要this。
静态的引入会对类的构造顺序造成影响,当第一次使用某个类时,会先初始化类的静态变量然后执行静态初始化块,之后才会按照上面所讲的类的构造顺序进行构造。当创建此类的第二个实例时则不会执行静态的构造,因为静态的数据只构造一次。
1.4.5 访问权限
Java的访问权限分为4种,分别是公开访问权限、保护访问权限、包访问权限、私有权限。这四种权限的写法和使用范围见表1-6。
表1-6 访问权限
关于权限的使用不再举例,前面的例子中已经大量使用了权限设置。一般来讲注意以下几点即可:不要把字段设为public权限,要分配正确的get和set方法;不要把所有方法都设为public,要适当地对外暴露方法;除非明确包访问权限的用意,否则一定要合理分配一个显式的权限设置;protected权限需要明确类里的继承关系时再使用,否则这个权限等同于private。
1.4.6 垃圾回收
前面用大量的篇幅讲解了如何创建一个实例以及如何使用这个实例。但是这些实例使用完之后去了哪里呢?这个问题对于C++语言来说是必须解决的,如果用C++语言创建完对象后置之不理,整个程序肯定会崩溃。但是这个问题对于Java来讲就没有那么重要了,Java有一套自动回收的机制用于处理创建出来的实例,而本书所涉及的内容基本都不需要手动清理垃圾,实际使用中真正需要手动清理的地方也很少,所以就不再赘述,有兴趣的读者可以查阅关于Java清理的相关资料。
1.5 继承和多态
继承是指派生类继承基类的属性和某些行为,多态是指派生类在基类的基础上进行重写从而表现出来的不同性状。本节从基础的Object和类似的组合讲起,一点点了解继承和多态的原理和用法。
1.5.1 Object
前面编写了好多代码用来创建类对象以及调用相应的方法,但是类不仅仅是这些内容。观察下面代码的输出,了解类和对象的其他特性。
运行结果如下:
com.javadevmap.Person@7852e922
这里使用了public的字段,是为了使代码看起来简单一些,实际项目中不这样使用。在这个例子中,创建了一个person对象,然后调用了toString()方法,但是类里并没有这个方法,这个方法是从哪里来的呢?
这就涉及Java的单根继承结构。在Java中,所有的类都继承自Object类。也就是说除了基本类型,其他类都是一种Object。而toString方法就是Object里的方法,通过类的继承而来。这种单根继承结构也为Java的内存回收提供了很大的便利。
继承听起来很费解,举个例子。例如常用的手机是一种物质,看不见的原子也是一种物质,那么把这些东西的通用性全部抽离出来,用物质这个统称来代替它们是可以的。对于Java语言,这个统称就是Object,所有物质包含的属性,例如大小,重量就相当于Object里的字段或方法。而继承就是在这个统称之上再进行细分,从而凸显自己的特性。
再回到代码中,toString方法其实是把类的内容转化为String进行输出,但好像并没有得到期望输出的内容[12]。是否可以通过某种办法输出期望的数据?代码如下:
运行结果如下:
id=1 name=xiaoming
可以在Person类中重写这个方法,用于替换Object的默认toString实现,从而达到正确输出的目的。
Object还有一个equals()方法,用于对象的比较。通过下面的代码演示这个方法的使用。首先不重写此方法,观察输出的结果。
运行结果如下:
person 1==person 2=false
person 1 equals person 2=false
虽然代码中给创建的两个对象赋的值是相同的,但是无论用“==”比较还是用equals比较,比较的结果都是不同的。下面重写equals方法,再执行程序观察输出的结果。
运行结果如下:
person 1==person 2=false
person 1 equals person 2=true
通过重写equals方法,对对象里的字段值进行比较,字段值相同即两个对象相同,最后两个对象比较的结果是true。那么为什么“==”比较的结果还是false呢?其实“==”比较的是对象的地址,两个对象地址不同所以不同,equals默认的方法比较的也是对象的地址,需要覆盖equals的默认实现才能正确进行比较。
1.5.2 组合
在了解继承之前,先弄清楚什么是组合。正如前面章节所讲,手机相对于物质来讲,属于继承关系,它继承了物质这个大概念下的一些属性;手机相对于触摸屏来讲,属于包含关系,这个包含叫作组合。在之前的Person类中写一个内部类[13]Eyes,添加一个Eyes的实例为person的字段。
在这个例子中,用Person包含了一个静态内部类的实例,当然Person也可以包含非内部类的实例,这里这样写是为了展示方便。Person是对一个事物的抽象,但是这个事物是由很多部分组成的,每个部分也可以抽象出来,最后在Person中组合在一起。
在实际项目中组合的应用是非常多的,当组装业务逻辑的时候,常常会把数据库的操作类实例、对外服务调用的操作类实例等和业务相关的类实例组合在一起以实现业务逻辑。
1.5.3 继承
如前面所讲,继承是在同一种共性基础上的细分和丰富。在基类中定义此类事物的共性,在派生类中对基类中定义的共性进行具体的实现或者修改,并且添加自己的特性。下面通过动物的例子来说明这个问题。
代码中先定义了动物的基类Animal,在基类中定义了动物共有的属性weight和方法move()。然后在派生类Tiger中通过extends关键字声明此类继承了Animal基类,添加Tiger自己的特性roar,并且重写[14]了基类的move()方法。在派生类的构造器中,通过super传递需要基类构造的属性。
运行结果如下:
tiger can run!
tiger weight=500 tiger roar=ao!
animal can move!
animal weight=1000
上面代码中创建了Tiger类的实际对象,其不仅包含了新定义的字段,还包含了基类的字段,并且move()方法具有不同的表现。
引入了继承后,一个对象的构造顺序又变得更加复杂了,当创建一个派生类对象的时候,原则是先构造此派生类的基类部分,再构造派生类新定义的部分。那么设想一个问题,当派生类和基类中都包含静态字段时,整个的构造顺序是什么样的呢?希望读者自己动手,设计一个方法,验证这种情况的构造顺序。毕竟编程不是背概念,自己动手才能丰衣足食。
1.5.4 多态
说到多态就不得不说动态绑定,动态绑定是指在执行时判断所作用对象的实际类型。多态的实现基于动态绑定,是指用基类的引用指向派生类的实例,当调用方法时再确定是应该调用基类的方法还是派生类的方法。基于上面的例子,再添加一个派生类Fish来说明这个问题。
派生类Fish继承自基类Animal,并且重写了move()方法。下面分别创建Animal、Tiger、Fish这3个类的实例,然后把它们加入同一个数组,最后用Animal的类型分别引用这3个实例进行操作并观察其结果。
运行结果如下:
animal weight=1000
animal can move!
animal weight=500
tiger can run!
animal weight=10
fish can swim!
在这段代码的for循环中,都是用Animal类型的引用指代数组中的实例,但是在调用move()方法时却有不同的表现,这就是多态。多态是用基类指代派生类,在实际调用时调用派生类的实现。通过基类的引用可以调用在基类中定义的字段,例如weight,但是不能使用在派生类中添加的字段。
1.5.5 接口
设想一种情况,当把一组Tiger类的实例放入一个容器(List)中,希望按照每个实例的重量从小到大进行排序,应该怎么办?一个标准的容器是有sort()排序方法的,排序需要基于大小的比较,这个比较方法需要通过Tiger类来提供。但是容器都是通用的,在比较的时候容器需要一个通用的引用来指代具体的对象,而这种引用又不适合于使用Object,因为如果那样的话,Object将会非常庞大并且需要对非常多的这种需求提供基础方法。因此需要有一种机制对能力进行说明,并且实现对象通用能力的引用,这就是接口[15]。
由于Java的单根继承结构,所以没法像C++一样通过多重继承来引入更多的能力;而一些能力又不能全部封装进Object基类;一些通用的容器却又需要一种通用的引用来指代不同的类;在这种情况下,通过接口来封装一些通用的能力,具体的类继承接口并且实现这种能力,通用的容器就可以用这个接口引用具体的类实例进行比较了。下面提前透露容器的内容,来看看容器的排序是怎么实现的。
运行结果如下:
sort before[the tiger weight is 498,the tiger weight is 430,the tiger weight is 500,the tiger weight is 488,
the tiger weight is 590]
sort after[the tiger weight is 430,the tiger weight is 488,the tiger weight is 498,the tiger weight is 500,
the tiger weight is 590]
Tiger类实现了接口Comparable中的compareTo()方法,此方法会允许继承此接口的类定义实例间的比较方式,这样当把一组实例放入容器后,通过Collections.sort(list)即可快速排序。
1.5.6 抽象类
抽象类简单来讲,就是不可创建实例的基类。在之前的例子中,如果在Animal类的class前面添加一个abstract关键字,会发现之前创建Animal实例的地方都报错了。抽象类的定义取决于程序的设计,设计者希望Animal类作为基类可以指代派生出来的所有动物,同时不希望创建一个毫无意义的仅仅叫作动物的实例(而不知道具体的类型),这种情况下,会把基类定义为抽象类。
在抽象类中,可以定义抽象方法,就是在方法的前面添加abstract,并且没有方法的实现。抽象方法要求派生类必须实现此方法。例如修改之前的Animal类,观察它的派生类的 变化。
在基类中添加抽象方法eat(),如果不修改它的派生类,派生类会报错,要求实现此方法。是否使用抽象类还需要在实际项目中根据具体情况进行选择。
1.6 容器
容器是存放对象的地方,当大量的对象需要在内存中存在,并且单个对象分别使用很不方便的时候,就是容器应用的场景了。Java存放数据的方式有很多种,例如固定大小的数据以及可以自动调整大小的容器类。而容器类经过Java多个版本的迭代,继承关系较为复杂,有些容器已经建议废弃,目前比较常用的就是List、Set、Map,本节将一一使用它们并且了解它们的基础用法。在介绍容器类之前先看看数组。
1.6.1 数组
数组相对于容器类,效率更高[16],但缺点也很明显,在生命周期内不可改变数组大小。数组有length字段,用于访问数组的大小。“[]”语法可以访问组数成员。数组的创建也有多种方式,例如用new创建或者直接填写数组元素。数组还有多维的能力,可以创建二维以上的数组。下面分别演示数组的这些用法。
(1)一维数组
运行结果如下:
array length=3
[lilei,hanmeimei,lucy]
array length=5
[A,B,C,D,null]
代码中使用了两个方法,每个方法使用不同的方式创建一维数组。在第一种方法中,采用直接赋值的方式初始化数组,并且在打印时使用Arrays.asList()方法将数组转化为List进行打印。如果不使用此方法,可以采用直接打印的方式打印数组,看看结果是否如期望的那样,并考虑一下为什么。在第二种方法中采用new来创建数组空间,并且逐个赋值。方法中创建的数组空间是5个,而实际只赋值了4个,但是打印时还是打印出了第五个空元素。
(2)二维数组
运行结果如下:
[one,two,three]
[four,five,six]
[seven,eight,nine]
[up,down]
[east,south,west,north]
代码中使用两种方式创建二维数组,二维数组和一维数组的使用没有太大分别,只是多加了一个维度;嵌套的数组大小可以保持统一或者自定义不同的大小。
数组的使用和功能简单,虽然有效率高的优点,但是一般的业务逻辑很难体现其优势,通常情况下一般使用容器类来代替数组的使用。
1.6.2 List
容器List其实就是一个列表,但是Java对列表的实现分为两种,一种是类似数组的实现ArrayList,一种是链表的实现LinkedList。这两种List都可以通过List类进行引用并且调用方法,只是由于内部实现的不同存在性能上的差异,ArrayList在插入方面不如LinkedList,LinkedList在获取列表中的值方面性能不如ArrayList。可以设计实验方法来检验这两种List的性能差别,这里就不过多介绍了,仅介绍List的基本用法。
(1)ArrayList
运行结果如下:
[one,two,three,four]
four
[one,two,three]
list contains one is true
[one,two,three,four]
list index of two is 1
sub list is[two,three]
array length is 4
在上面的例子中,使用了常用的List方法,添加、删除、包含判断、设置值、查询索引、生成子List、转为数组等。List的使用还有很多其他方法,大家可以查看类文档进行了解。List的遍历可以用foreach的形式,也可以用迭代器的形式,下面代码演示LinkedList和迭代器如何配合使用。
(2)LinkedList
运行结果如下:
[one,two,four,five,seven,eight,ten]
这个例子运用迭代器对List进行遍历,在遍历的过程中根据业务需要对数据进行处理,删除List中不需要的内容,很好地利用了LinkedList方便增删数据的特性。
1.6.3 Set
Set是一个集合,它不保证存取的顺序[17],它的主要特性就是存储值的唯一性,重复的添加操作对Set无用,集合中只会存储一份数据。要判断存储的对象是否相等,可使用equals和hashCode方法。本节主要介绍两种Set,分别是HashSet和TreeSet。
(1)HashSet
运行结果如下:
[id=1 name=lilei,id=2 name=hanmeimei,id=3 name=lucy]
[id=1 name=lilei,id=3 name=lucy]
it is a person and id=1 name=lilei
it is a person and id=3 name=lucy
对之前实现的Person类添加hashCode方法。使用HashSet作为集合容器,向集合中添加Person的实例。HashSet通过Hash算法保证对象的快速读取,通过hashCode和equals方法保证对象不会重复,所以当重复添加对象的时候Set中并没有出现重复的内容。Set包含的方法很多,例如contains()就是一个常用方法,用于判断一个集合是否包含某个对象。读者可以在编写代码时了解Set其他方法的具体用法。
这个例子中使用Set的写法和之前的List写法并不相同,Set后面的<>符号中没有添加具体的类型,这种情况下当遍历集合中的对象时,并不知道集合中的具体对象类型,所以需要使用instanceof动态判断对象的类型(当然也可以使用强制转换类型);而如果使用在<>中添加类型的写法则不用进行这种判断,这种写法称为泛型,后面的章节会有介绍。
(2)TreeSet
运行结果如下:
[id=29 name=xiaoming,id=32 name=xiaoming,id=37 name=xiaoming,id=40 name=xiaoming,
id=41 name=xiaoming,id=50 name=xiaoming]
TreeSet是一种可排序的Set,代码中没有采用之前的让对象实现接口从而排序的方法,而直接采用对Set设置比较方法来进行排序,这两种方法都可以实现排序的能力。在这里用Person作为泛型的类型,所以在内部类中可以直接进行对象的比较而不用进行类型转换。方法最后输出了一个有序的集合数据。
1.6.4 Map
Map是通过键值对存储的,可以通过键来获取值。HashMap是最常用的Map,本节以它为例讲解Map的原理和实现。HashMap通过散列的形式,以达到快速存取和空间控制的目的。以手机号为例,用手机号对10000取余,那么所有手机号就散列了10000个分组,分别是从0到9999,这种散列的基础就是hashCode方法。散列后手机号映射到的分组值会重复,要把这些散列后重复的数据保存到某一分组中就用到了链表,在链表中要正确地取值就需要equals方法作为对象的比较依据。这就是作为HashMap的Key值的类为什么必须实现hashCode和equals这两个方法的原因,见表1-7。
表1-7 散列情况
代码如下:
运行结果如下:
{id=1 name=xiaoming=Musician,id=2 name=daming=Scientist,id=3 name=xiaobai=Astronaut}
key=id=1 name=xiaoming value=Musician
key=id=2 name=daming value=Scientist
key=id=3 name=xiaobai value=Astronaut
id=1 name=xiaoming
id=2 name=daming
id=3 name=xiaobai
Musician
Scientist
Astronaut
key=id=1 name=xiaoming value=Musician
key=id=2 name=daming value=Scientist
key=id=3 name=xiaobai value=Astronaut
在上面的例子中,构建了一个HashMap,key值是之前经常使用的Person类对象,当然可以构建key值是基本类型包装器类的对象,具体使用什么作为Key需要在实际项目中进行判断,这里仅作为演示。代码中提供了几种遍历HashMap的方法,包含全量遍历HashMap、只遍历Key和只遍历Value。一些其他方法这里就不过多介绍,读者可查看相关文档进行了解。
本节已经演示了常用容器的使用方法并介绍了容器的不同特性,在实际的项目中需要根据业务的要求和容器的特性选择合适的容器。容器性能问题一般不会对业务造成太多困扰,除非特殊的业务逻辑,一般都不会遇到容器性能瓶颈。
1.7 泛型
正如前面例子所写,其实泛型最常见的使用场景就是在容器内,容器提供了存储对象的通用能力,其他所有类型的对象都可以放入容器之内,声明容器时,用具体的类型标明容器中使用的类型即可,这就是泛型的基本使用。
1.7.1 泛型的基本使用
泛型的基本使用前面已经有所涉及,在下面的例子中将创建一个继承结构,然后用基类的类型来声明容器,看看容器是否表现正常。并且创建一个泛型方法,观察其对类型的处理。
运行结果如下:
It is a Fruit!
It is an Apple!
It is an Orange!
It is an Fruit!
It is an Apple!
It is an Orange!
使用泛型的容器,很好地保存了对象,并且取出对象后仍具备多态性,可见泛型容器的使用非常简单。代码中使用了一个静态方法print,这个方法也是通过泛型定义的,在方法内不知道具体的数据类型[18],所以通过动态类型检查来确定类型。
1.7.2 通配符
Fruit是Apple类的基类,那么List<Fruit>和List<Apple>之间是什么关系呢?看看下面的例子。
通过代码发现,List<Fruit>和List<Apple>之间并没有关系,尤其需要注意的是,不要以为它们是继承关系。如果想用另一个容器指代List<Apple>,就用到了通配符,可以用List<? extends Fruit>来指代List<Apple>。此用法大多作为方法的参数判断,例如某方法参数需要一个Fruit子类的容器。另外还需注意,无法通过List<?extends Fruit>向容器中添加数据,只能获取数据。原因就是这个容器指代了一切继承自Fruit的类的容器,所以无法确定是否正确地向容器中添加了适当的类型。例如当?Extend Fruit指代一个Apple容器时,如果向容器中添加Orange对象就会出现错误。
List<?super Apple>指代了Fruit至Apple及派生类继承关系链的对象,可以向其添加对象,因为添加的对象类型更加明确,可以通过类型转换成基类Fruit来获取和使用对象。
1.7.3 泛型接口
Java的泛型区别于C++的泛型实现主要在类型擦除。类型擦除就是说Java泛型只在编译期间进行静态类型检查,编译器生成的代码会擦除相应的类型信息,这样到了运行期间实际上JVM根本就不知道泛型所代表的具体类型。也就是说在运行期间无法识别List<String>和List<Integer>,因为它们都是List<Object>。但是也不是毫无办法,可以定义一个泛型接口,然后让泛型类声明传进的具体类型必须实现此接口,这样还能保留部分接口能力。
以上是声明的泛型接口,这个接口希望保留打印的能力。其实最常用的泛型接口是Comparable<T>,它保留了对象比较的能力。
1.7.4 自定义泛型
声明了泛型接口,下面的代码将演示泛型接口在泛型类中是如何使用的。
运行结果如下:
It is Tomato!
创建了一个继承自泛型接口的Tomato类,并且重写了接口方法用来打印数据。泛型类CustomTemplete通过<T extends PrintInterface<T>>表示,传入的类型必须继承此泛型接口。所以可以在main方法中调用泛型类的打印,如果不继承此接口,那么data对象在泛型内部并不知道自己具备什么能力(仅具备Object对象能力)。
以上介绍了泛型的常用方法和一些与其他语言不同的地方,同时还在Java泛型擦除的现实下保留了部分接口能力。
1.8 异常
对于使用Java编写的程序,编译器在编译的时候会进行语法检查等工作。但是有一些程序中存在的问题是编译阶段无法识别的,例如用Java实现了一个计算器,当用户输入除数为0的情况下,怎么办?这就是异常处理存在的原因。这些错误会导致程序无法继续进行,而异常处理就是处理这些错误的。
1.8.1 运行时异常
运行时异常是程序在执行过程中出现错误的调用而抛出的异常,这种异常都可以在编写时避免,而编译器也不要求对可能抛出运行时异常的代码段强制加上try语句。下面看几种常见的运行时异常。
运行结果如下:
用一个整数除以0在算术上是明显错误的,但是这个问题编译器目前是不会报错的,只会在执行时抛出一个异常,异常包含错误的类型和代码的位置,可以很容易地找到出问题的地方并且优化。下面用异常捕获来处理这段代码。
㊀异常发生后,后面的语句不会执行。
运行结果如下:
divisor can not be 0
例子中用try/catch进行了代码运行和异常捕获,try语句块的意思是执行代码,catch语句块的意思是对try中的代码异常进行处理。当然这里只作为演示,实际项目中一般还是先用if语句判断除数是否为0,为0则直接进行提示或者其他的处理,而不用异常捕获来进行处理,这样就避免了运行时异常。
下面代码演示空引用异常:
运行结果如下:
这种空异常的避免办法一般也是在调用前对不确定是否已经初始化的对象进行非空判断,从而避免这种异常。
下面代码演示常见的List异常。
运行结果如下:
这种情况,在for循环遍历过程中移除List元素是非常危险的,如何避免请参看1.6节。Java的这种运行时异常有很多种,例如数组越界异常、类型转换异常等。异常的处理不是背出来的,在实际的代码中去解决异常才是最快的学习方法,本书附带的代码中包含了其他异常的几种情况,读者可以尝试模拟、处理和避免运行时异常。
1.8.2 检查性异常
运行时异常基本都可以避免,只要代码足够严谨就不会出现运行时异常。所以真正的代码中要处理的是检查性异常。这就涉及异常的抛出和捕获,在抛出异常的地方使用throw关键字抛出;在抛出异常的方法后面添加“throws异常类名”。异常捕获的地方使用try-catch-finally。下面看一个读取文件的例子。
运行结果如下:
read file ok!
1234567890
23456789
end
在这个例子中,使用文件读写类FileReader读取一个文件,在创建这个类的时候,编译器强制要求程序员必须把这个方法放入一段try语句中,或者使整个方法向外抛出异常(添加throws语句)由外层进行处理,否则编译不通过;这是区别于运行时异常的地方,运行时异常允许编译通过。
如果在文件的路径下没有找到对应的文件,则会抛出一个文件不存在的异常,这个异常会被catch捕获,并由e.printStackTrace()方法打印到控制台。finally语句是一定要执行的,它根据读取文件过程中判断是否发生异常来识别文件是否读取成功,如果发生异常则会把返回的字符串置为fail,用于标明失败。在这个例子中连接字符串使用了StringBuilder类,如果读者感兴趣可以研究一下它的特性。
1.8.3 自定义异常
在实际编程中,例如数据库中的数据出现了业务逻辑上的错误等情况,希望通过抛出一个异常把问题暴露出来,而已有的异常类型不能说明问题的原意,所以需要自定义一个异常。自定义异常非常简单,只要继承相关的异常类就可以了。代码如下。
运行结果如下:
catch CustomException
在上面的例子中,自定义了两个异常,但是两个异常的继承关系不一样。一个继承了RuntimeException,另一个继承了Exception,这两种继承关系会导致在实际使用中存在区别。继承自RuntimeException的异常,当用throw抛出的时候,包含它的方法不需要用throws声明要抛出异常,继承自Exception的异常则需要在方法上明确声明抛出异常类型。用catch异常捕获时,是按照顺序从第一个匹配的异常类型进行捕获的,一般都会把异常的基类Exception放到顺序的最后,防止它拦截了其他的捕获。
异常还包含其他一些方法可供使用,但是对于简单情况,只需要继承一个异常,并且用类的名字来区分异常类型就可以了。
1.9 I/O
使用之前讲解的Java内容,已经可以实现在程序内的很多业务处理能力了,但是在实际的业务中存在大量的交互和通信的需求,这就需要对I/O有相应的了解。I/O使Java程序可以和控制台、文件、其他Java服务、数据库、缓存等各个组件进行信息的互通,有了I/O才能把程序组成一个庞大的系统,否则只能是一个个程序计算的孤岛。虽然很多组件都封装了简单易用的I/O操作,但是了解一些Java的基本I/O还是对研发者的工作有好处的。
Java的I/O主要包含两种流,分别是字节流和字符流。字节流分为读入(InputStream)和输出(OutputStream);字符流分为读入(Reader)和输出(Writer)。两者选择的根据是针对Unicode[19]字符处理能力的,如果不需要Unicode基本都可以选择字节流。
1.9.1 控制台I/O
在IDE中负责控制台输入输出的就是Console窗口,下面通过此窗口输入数据,然后通过两种不同的I/O流分别读取此数据,观察两种不同I/O的读取结果。代码如下:
㊁Java 5之后,可以使用Scanner来读取输入。
第一种方法直接获取系统的字节输入流,读取流数据分别显示到控制台;第二种方法用字符流封装了系统输入流,然后读取数据显示至控制台;使用这两种方法,当读取全英文时没有分别,当读取汉字时,使用字符流的方式能够准确输出汉字,而字节流则不能。这就是字节流和字符流最明显的区别。所以当读取二进制文件时,例如音频、图片等使用字节流是合适的方式,当读取汉字时使用字符流是合适的方式。
1.9.2 查看文件列表
File类是Java对文件和目录进行操作的类,可以用它对文件进行创建、改名、删除等操作。File类的使用相对简单,而且对应的API也很健全,只要正确使用即可。下面以遍历目录下的文件列表为例,简单介绍File类的使用。代码如下:
运行结果如下:
[JavaBasicTypes.java,JavaOperator.java,JavaProcessControl.java,JavaArray.java,JavaList.java,
JavaMap.java,JavaSet.java,Animal.java,Fish.java,Tiger.java,CustomException.java,CustomException
Demo.java,CustomRuntimeException.java,FileExceptionDemo.java,JavaRuntimeException.java,Console
IO.java,FileListDemo.java,Person.java,School.java,Student.java]
main方法中传进一个路径到getFileList方法,getFileList方法会识别这个路径是文件夹还是文件,或者根本不存在。如果是文件夹,则进入getFileListByDir递归方法,搜索出路径下所有文件夹下的文件,加入到List中,递归完成后,得到一个文件清单。
1.9.3 文件I/O
文件的读取在1.8节已经涉及,所以这里换一种文件的读取方法来演示文件的读写。Java的I/O类比较有意思的地方是你可以通过流之间的包装,在最外层类对象中使用较为方便的功能。
运行结果如下:
你好,世界!
hello world!
在write方法中,通过File创建了FileOutputStream流,但是FileOutputStream无法简单地写入字符串,所以用OutputStreamWriter进行了包装,这样就可以把String类型写入文件。在Read方法中,使用InputStreamReader来包装FileInputStream,从而实现文件内容的读取。
1.9.4序列化
当把一个Java的对象存入文件或者进行网络通信时,需要把一个对象转为一串数据,并可以再反转回一个对象,这就是序列化的需求。Java序列化有几种方式,例如对象的类继承Serializable接口;或者对象的类继承Externalizable接口,实现接口的两个方法;或者转换为其他的通用数据交换格式,例如Json。
(1)Serializable方法实现序列化
采用此种Java序列化方式较为简单,只要使需要序列化的对象类继承此接口,并且保证对象内的字段也是可序列化的,如果存在不可序列化或者无需序列化的字段,可以用transient关键字在字段前标注。代码如下:
运行结果如下:
address name=beijing longitude=116.23 latitude=39.54 person=null
在此例中,Address类继承了Serializable接口,并且对person字段标注了transient,表示无需序列化。创建Address类实例后,用write方法把序列化结果存入一个文件,然后用read方法从文件中读取数据并且反序列化回一个Address实例。
(2)Externalizable方法实现序列化
此接口包含两个方法,分别是readExternal和writeExternal,可以通过这两个方法完成序列化的定制。重写Adddress类,继承自Externalizable接口。代码如下:
通过重写此Address,最后达成的效果和上一个例子是相同的,但是此种写法给了编写者更大的灵活度,可以在两个方法中修改字段数据或者做其他的事情。在实际业务中具体采用哪种方法还需要根据实际需求来定。
(3)Json
Json是一种轻量级的数据交换格式,可以把对象序列化为Json格式,序列化后会生成一个Json格式的字符串。上例中的数据序列化后变为:
这种格式非常简单易懂,而且方便研发人员直接阅读序列化后的数据,具体如何转换为Json格式会在后续章节讲解。
1.9.5 网络I/O
Java服务之间可以通过网络进行通信,从而可以实现程序间数据的互通,网络I/O是Java服务进行微服务化[20]的基础。网络通信一般较为复杂,但本书所涉及的内容一般不用考虑过多的网络部分,网络问题一般都由使用的服务框架解决。所以这里仅作为演示,了解Java基本的通信方式。在下面的例子中,用Socket套接字使用TCP[21]协议进行通信,创建两个Java程序,分别是客户端程序和服务端程序。
(1)服务端程序
在这段代码中,提前使用了线程,线程的使用在1.10节中讲解。在main方法中创建了一个Server监听线程,通过ServerSocket监听某端口号,当有链接请求时通过accept方法返回和客户端的连接。通过Socket得到客户端传过来的数据,并且延迟5s回复客户端一条数据。这个逻辑是写在无限循环中的,会一直监听连接的数据情况并且回复。
(2)客户端程序
客户端的逻辑是请求与服务端的连接,连接成功后向服务端发送一条数据;发送完第一条数据后,会一直监听服务器的应答,并且延迟5s回复给服务端。所以在这个例子中,客户端和服务端之间会一直通信下去,直到手动结束它们。它们的输出情况为:
服务端输出:
socket server receive:client say nihao
socket server receive:client say nihao0
socket server receive:client say nihao1
socket server receive:client say nihao2
…
客户端输出:
socket client receive:server say nihao0
socket client receive:server say nihao1
socket client receive:server say nihao2
socket client receive:server say nihao3
…
以上仅演示了基本的服务间通信,具体项目中的通信情况会更加复杂,好在使用框架可以解决大部分网络问题,让研发人员能够专心完成业务逻辑。如果读者对网络通信很感兴趣,可以研究网络通信的NIO框架Netty,相信会有不少收获。
1.10 并发
一个Java程序运行在一个进程[22]中,但是如上面例子所演示,有时希望一个程序可以同时做好多事情,例如监听端口、接收数据、逻辑计算等等,那么只有一个运算单元就明显不够了,所以这时需要启动好多个运算单元,这就是多线程。多个线程的执行其实是抢占CPU的时间,但是在感觉上好像在同时进行一样。本节介绍多线程的写法和一些重点。多线程其实是一个比较困难的知识点,尤其对于初次接触的新人来讲有些时候较为费解,对于多线程的学习一定要在实际问题中多多摸索才能真正理解。
1.10.1 多线程的实现
(1)Runnable任务
运行结果如下:
thread is main start=100 end=1000 sum=495550
thread is Thread-0 start=200 end=2000 sum=1981100
ThreadRunnable是一个继承自Runnable的类,如果在主线程中创建这个类,并且调用run方法,其实它并没有什么特殊,只是正常执行求和的逻辑。Runnable对多线程的作用就是可以把它传入一个Thread中,作为新建线程的执行任务,这样就实现了多线程。
(2)自定义Thread
运行结果如下:
Thread[Thread-1,5,main]
Thread[Thread-0,1,main]
可以让一个类继承自Thread类,重写run方法来实现线程的任务单元,这样就可以不用把任务单元传给Thread,只要创建此线程并且调用start即可实现多线程。在这个例子中用到了线程的几个方法,sleep方法用来使线程休眠,setPriority方法用来设置线程的优先级,setDaemon方法用来设置后台线程[23]。
(3)线程池
以上两种方法都需要手动创建线程、启动线程,如果使用线程池进行托管,那么就省去了直接操作线程的麻烦,并且线程池中的线程还可以复用,也省去了重复创建线程的开销。代码如下:
以上是两种创建线程池的写法,只要把任务单元传入线程池即可执行多线程运算,而不用手动创建线程。这两种方法的区别就是newFixedThreadPool会规定最大线程数。
1.10.2 线程冲突
在上面的几个例子中,都会为每个线程创建独立的任务单元,目前看来执行的情况良好。设想一种情况,如果传入多个线程中的任务单元是相同的,并且使用了同一份数据,那么会发生什么?代码如下:
运行结果如下:
main thread sum=499500
pool-1-thread-1 sum=499500
pool-1-thread-2 sum=499500
pool-1-thread-3 sum=499500
pool-1-thread-1 sum=499500
pool-1-thread-4 sum=499500
pool-1-thread-2 sum=499500
pool-1-thread-6 sum=499500
pool-1-thread-7 sum=499500
pool-1-thread-5 sum=327769
pool-1-thread-8 sum=743833
代码中创建了一个ThreadConflict的对象,这个对象包含一个sum字段和一个求和的方法。把这个对象的执行任务传入线程池进行计算,结果有的线程执行结果是错误的。
Java的多线程执行是抢占式的,当多个线程同时抢占同一资源进行运算时,有可能线程A运算到一半时线程B抢占了线程A重新开始计算,线程B计算完毕线程A抢占回资源继续运算,这时就会发生了错误,因为线程A抢占回的资源数据已经不是它离开时的数据了。在这种情况下,可以使用锁解决并发导致的资源抢占问题。
1.10.3 锁
在现实世界中锁住某些东西表示独占或者使用中,程序中的锁也是同样的意义。对于多个线程同时访问的单一资源,当前获得执行权限的线程可以把这个资源锁住,执行完毕再把锁打开。下面介绍几种简单的程序加锁方式。
(1)Synchronized关键字
对上面的任务单元进行修改,改为如下内容,则程序运算可以输出正确的答案。
Synchronized关键字把这个方法设置为同步方法,当有多个线程希望使用此方法时,此关键字只允许一个线程占用此方法,其他线程处于等待状态,先入线程执行完毕其他线程再分别单独抢占此方法。
(2)Lock
可以在这个对象中创建一个ReentrantLock的实例,对需要加锁的代码段的前面调用lock方法上锁,执行完毕调用unlock方法解锁。这样也可以避免其他线程抢占此公共资源。此实例还包含其他几种加锁的方式,例如使用tryLock方法。lock方法和tryLock方法的区别是tryLock可以设置立刻返回或者等待一段时间再返回。
常用的锁还有读写锁ReentrantReadWriteLock,这里不再介绍,希望大家自己完成读写锁的学习。对于同步代码块或者锁的使用一定要精简,在确定会发生异步问题的地方才加入同步的逻辑,否则乱上锁会造成很大的性能问题,多线程的优势也得不到发挥。另外,被锁住的代码也要精简,不要把冗余的可以异步执行的代码放到同步代码块中。本节对于并发的意义以及并发会导致的问题通过几个例子都已经讲到了,希望读者能够很好地理解并且使用多线程。
1.11 反射与注解
对于一种语言来讲,前面的内容好像够全面了。那么Java的反射和注解为Java做出了什么贡献呢?
Java的反射机制是指在运行状态中,对于任意一个类,都能够知道这个类的所有属性和方法;对于任意一个对象,都能够调用它的任意方法和属性。这种动态获取信息以及动态调用对象方法的功能称为Java语言的反射机制。
Java反射机制的意义在于与框架结合,各个框架正是应用了Java的反射机制才能对业务代码进行加载和整合[24]。那么注解的意义是什么?可以把注解理解为Java对类、字段或方法的补充说明,Java通过反射读到注解,通过注解的说明对被注解内容进行相应的操作,例如生成数据库表、字段、执行测试等。
1.11.1 反射
虽然平时使用反射较少,但是理解反射却非常重要,反射就像粘合剂一样,把Java框架和程序粘合在一起,下面通过代码了解反射的基本能力。
运行结果如下:
从代码中可以看出,反射有两种获取Class对象的方式,但是常用的还是第二种方式。这两种方法生成的Class对象是同一个,每个类都有一个Class对象,并且唯一。可以通过反射获取字段、方法、构造器[25]。并且可以通过反射直接获取方法,调用带参数或者不带参数的方法,甚至可以调用私有方法。
如果没有特殊需求,一般的业务逻辑中不会带有反射,了解反射的用处和能力就好,如果有更高的要求,那么反射还是要仔细研究的。
1.11.2 注解
注解是丰富代码信息的一种方法,通过注解可以更加了解代码,并且通过注解解析和使用,能够方便管理代码和让编程更加简单。
(1)Java内置了三种注解,分别是:
■@Override:表示当前方法覆盖基类的方法,如果基类不存在此方法,则编译器会报错(如果不使用注解,则编译器不会检查到)。
■@Deprecated:表示弃用的方法,如果其他类使用了这个注解标注的方法,编译器会生成警告。
■@SuppressWarnings:关闭警告的注解,使用此注解后编译器会关闭相应类型的警告。
(2)元注解
注解的定义是基于元注解的,元注解可以理解为注解的注解。通过元注解来修饰自定义注解,例如圈定使用范围或应用阶段等,见表1-8。
表1-8 元注解
(3)自定义注解
自定义注解的写法和接口很像,只是在名字前面加上@即可。注解中用元注解修饰自定义注解的应用范围和应用级别;注解可以用的数据类型包含基本类型、String、Class、enum、Annotation以及这些类型的数组。注解中的方法不能有参数,但是可以有默认值。
下面定义一个自定义注解,这个注解是用来辅助说明方法是做什么的,以及它对外暴露的接口是什么。
自定义注解通过@Target设定,此注解用于方法;通过@Retention设定注解有效期至运行期。注解的字段包含方法的描述和对外提供的URL路径。
(4)注解解析
如上例自定义注解,这个注解写完了有什么用呢?用于给方法做补充说明,难道仅仅在文档上有用吗?下面给一个类加上自定义注解,然后再通过一个解析的方法把注解解析出来,看看它的用处有多大。
上面代码建立了一个类,这个类有两个方法,对每个方法都写了注解,标明了这个方法是做什么的,以及一个路径。下面的代码可以获取JavaAnnotation类的注解情况并输出。
运行结果如下:
getName function ID is 1 and url is localhost:8080/JavaAn/getName for获取名字
setName function ID is 2 and url is localhost:8080/JavaAn/setName for设置名字
main方法通过解析,输出了带自定义注解的两个方法的名字,还有一个URL地址,并且说明了用途。某些Web框架其实就是用这种方法把类中方法和访问地址关联起来,这样来自网络的调用就可以找到对应的方法,从而实现程序对外提供的HTTP服务。
注解的应用场景很多,例如下一节要讲的JUnit和本书后面的很多框架都用到了注解的内容。本节讲的是基本的注解使用原理,了解原理之后对以后章节的理解会更加轻松。
1.12 JUnit
当编写完代码,需要对自己写的功能进行测试时,可以直接写一个main方法来测试自己的代码,还有一种方式测试业务代码,即本节介绍的JUnit框架。JUnit是一个针对Java语言的单元测试框架,通过使用JUnit可以保证程序的稳定性,并且减少花费在排错上的时间。
1.12.1 JUnit的集成
JUnit的基础环境简单来说就是下载对应的JUnit包,解压到本机,配置到项目的Build Path中。下面介绍两种集成JUnit的方法。
(1)如果当前项目为非Maven管理,可以使用下面两种方式集成JUnit。
■从https://junit.org/junit5/下载JUnit最新版本的压缩文件,解压后,将需要的以Junit开头的jar包放到Eclipse项目里面的libs[26]文件夹中,然后在引入的jar包上执行“鼠标右键单击->Build->Add Build Path”即可。
■在项目上执行“鼠标右键单击->Build->Add Library”,在弹出来的界面中选中JUnit,点击next,选中JUnit的版本,一般选用4.0以上的版本,点击Finish。
(2)当前项目为maven管理的项目。
在项目的pom文件中的dependencies元素下面添加如下代码:
添加完后,在项目的pom.xml文件上执行“鼠标右键单击->Run as->Maven install”即可 使用。
1.12.2 JUnit的基本使用
下面演示JUnit的基本使用。编写一个业务类,里面有两个方法如下:
对上面的两个业务方法进行测试。编写一个JUnit的测试类步骤如下:
(1)创建一个名为TestJunit的测试类。
(2)向测试类中添加名为testPrintMethods()的方法。
(3)在方法上添加注解@Test。
(4)执行JUnit的assertEquals方法来检查测试是否通过。
具体代码如下:
在testPrintMethods上执行“鼠标右键单击->run as->Junit test”,运行完后,会出现一个JUnit的测试结果窗口,如图1-2所示。
图1-2 测试结果
其中的状态栏显示测试用例通过和未通过的比例,绿色表示通过,红色表示未通过,点击下边的未通过的测试方法,还可以看到未通过的原因信息。
测试类中注解的含义见表1-9。
表1-9 JUnit的常用注解
这样,使用JUnit就能编写一个测试用例来检验自己的代码。