J a v a 学 习 笔 记 Java学习笔记 Java学习笔记
层数 | 称号 |
---|---|
10 | 初出茅庐 |
20 | 小试牛刀 |
30 | 渐露头脚 |
40 | 奥妙精进 |
50 | 游刃有余 |
60 | 独当一面 |
70 | 技冠群雄 |
80 | 高深莫测 |
90 | 唯我独尊 |
100 | 返璞归真 |
常见异常抛出类型:
异常 | 异常解释 |
---|---|
ArrayIndexOutOfBoundsException | 数组角标越界异常的错误。 |
java.lang.NullPointerException | 空指针异常 |
The constructor Person(int) is not visible | 构造方法Person(int)不可见 |
点击跳转
编写Java程序代码必须先声明一个类,然后在类中编写实现需求的业务代码。类需要使用class关键字定义,在class前面可以有一些修饰符,其语法格式如下:
[修饰符] class 类名 { 程序代码 }
在编写程序时,为了使代码易于阅读,通常会在实现功能的同时为代码添加一些注释。注释是对程序的某个功能或者某行代码的解释说明,它能够让开发者在后期阅读和使用代码时能更容易理解代码的作用。
注释只在Java源文件中有效,在编译程序时编译器会忽略这些注释信息,不会将其编译到class字节码文件中。
Java中的注释有三种类型,具体如下:
1.单行注释
单行注释通常用于对程序中的某一行代码进行解释,用符号“//”表示,“//”后面为被注释的内容,具体示例如下:
int c = 10; // 定义一个整型变量c
2.多行注释
多行注释顾名思义就是可以同时为多行内容进行统一注释,它以符号“/”开头,并以符号“/”结尾,具体示例如下:
/* 定义一个整形变量x 将5赋值给变量x */ int x; x = 5;
3.文档注释
文档注释通常是对程序中某个类或类中的方法进行的系统性的解释说明,开发人员可以使用JDK提供的javadoc工具将文档注释提取出来生成一份API帮助文档。文档注释以符号“/”开头,并以符号“/”结尾,具体示例如下:
/** * Title:HelloWorld类 * @author srx * @version 1.0 */ public class HelloWorld { /** * 这是一个main()方法入口 * @param args 参数名 */ public static void main(String[] args){ System.out.println("这是第一个Java程序!"); } }
脚下留心:注释嵌套
在Java中,有的注释可以嵌套使用,有的则不可以,下面列举两种具体的情况。
1.多行注释“/…/”中可以嵌套使用单行注释“//”,具体示例如下:
/* int c = 10; // 定义一个整型的c int x = 5; */
2.多行注释“/…/”中不能嵌套使用多行注释“/…/”,具体示例如下:
/* /*int c = 10;*/ int x=5; */
上面第二种情况的代码就无法通过编译,原因在于第一个 “/”会和第一个“/”进行配对,而第二个“*/”则找不到匹配,就会编译失败。
针对在使用嵌套注释时可能出现编译异常这一问题,通常在实际开发中都会避免对代码注释进行嵌套使用,只有在特殊情况下才会在多行注释中嵌套使用单行注释。
在程序运行期间,随时可能产生一些临时数据,应用程序会将这些数据保存在一些内存单元中,每个内存单元都用一个标识符来标识。这些内存单元我们称之为变量,定义的标识符就是变量名,内存单元中存储的数据就是变量的值。
定义变量的语法非常简单,只需要指定变量的类型和变量名即可,其语法格式如下:
变量类型 变量名 [= 初始值];
上述定义变量的语法中,变量类型决定了变量的数据性质、范围、存储在内存中所占的字节数以及可以进行的合法操作,变量名必须是一个合法的标识符,而[]中的内容是可选项,即在定义变量的同时,可以对该变量进行初始化赋值。
接下来,通过具体的代码来学习变量的定义:
int x = 0,y; y = x+3;
上述代码中,第一行代码的作用是定义了两个int类型的变量x和y,也就相当于分配了两块内存单元,在定义变量的同时为变量x分配了一个初始值0,而变量y没有分配初始值,变量x和y在内存中的状态如图1所示。
图1 x、y变量在内存中的状态
第二行代码的作用是为变量y赋值,在执行第二行代码时,程序首先从内存中取出变量x的值,然后与3相加后,最后将结果赋值给变量y,此时变量x和y在内存中的状态发生了变化,如图2所示。
图2 x、y变量在内存中的状态
常量就是在程序中固定不变的值,是不能改变的数据。例如数字1、字符’a’、浮点数3.2等。在Java中,常量包括整型常量、浮点数常量、布尔常量、字符常量等。接下来就对这些常量进行详细讲解。
1.整型常量
整型常量是整数类型的数据,有二进制、八进制、十进制和十六进制4种表示形式,具体说明如下:
● 二进制:由数字0和1 组成的数字序列。在JDK 7以后,允许使用二进制字面值来表示整数,此时二进制数值前面要以0b或0B开头,目的是为了和十进制进行区分,如:0b01101100、0B10110101。
● 八进制:以0开头,并且其后由0 ~7范围(包括0和7)内的整数组成的数字序列,如:0342。
● 十进制:由数字0~9范围(包括0和9)内的整数组成的数字序列。如:198。
● 十六进制:以0x或者0X开头,并且其后由09、AF(包括0和9、A和F)组成的字符序列,如:0x25AF。
需要注意的是,在程序中为了标明不同的进制,数据都有特定的标识,八进制必须以0开头,如:0711、0123;十六进制必须以0x或0X开头,如:0xaf3、0Xff;整数以十进制表示时,第一位不能是0,0本身除外。例如十进制的127,用二进制表示为01111111,用八进制表示为0177,用十六进制表示为0x7F或者0X7F。
2.浮点数常量
浮点数常量就是在数学中用到的小数,分为float单精度浮点数和double双精度浮点数两种类型。其中,单精度浮点数后面以F或f结尾,而双精度浮点数则以D或d结尾。当然,在使用浮点数时也可以在结尾处不加任何后缀,此时Java虚拟机会将浮点数默认识别为double双精度浮点数。浮点数常量还可以通过指数形式来表示。具体示例如下:
2e3f 3.6d 0f 3.84d 5.022e+23f
3.字符常量
字符常量用于表示一个字符,一个字符常量要用一对英文半角格式的单引号(’ ')引起来,它可以是英文字母、数字、标点符号以及由转义序列来表示的特殊字符。具体示例如下:
'a' '1' '&' '\r'
4.字符串常量
字符串常量用于表示一串连续的字符,一个字符串常量要用一对英文半角格式的双引号(" ")引起来,具体示例如下:
"HelloWorld" "123" "Welcome \n XXX" ""
一个字符串常量可以包含一个字符或多个字符,也可以不包含任何字符,即长度为零。
5.布尔常量
布尔常量即布尔类型的两个值true和false,该常量用于区分一个条件的真假。
6.null常量
null常量只有一个值null,表示对象的引用为空。
而Java中的常量,其实就是特殊的变量,也是固定不变的量,有且只能进行一次赋值。Java中定义常量的语法也非常简单,只需要在定义变量的语法基础上加上一个final关键字修饰即可,其语法格式如下:
final 常量类型 常量名 [= 初始值];
上述定义Java常量的语法格式与定义Java变量的语法格式基本相同,同样,定义Java常量需要声明定义常量类型、常量名,并可以对该常量进行初始化赋值,也可以后续赋值,唯一不同的是,定义Java常量时必须用final关键字修饰,来声明是一个不可改变的量。其具体使用示例如下:
final int a=0, b; // 定义一个int类型的常量a和b,并为常量a初始化赋值0 b= 1; // 后续为常量b赋值
多学一招:转义符——反斜杠(\)
在字符常量中,反斜杠(\)是一个特殊的字符,被称为转义符,它的作用是用来转义紧随其后的一个字符。转义后的字符通常用于表示一个不可见的字符或具有特殊含义的字符,例如“\n”表示换行。下面列出一些常见的转义字符:
● \r 表示回车,将光标定位到当前行的开头,不会跳到下一行。
● \n 表示换行,换到下一行的开头。
● \t 表示制表符,将光标移到下一个制表符的位置,就像在文档中用Tab键一样。
● \b 表示退格符号,就像键盘上的Backspace。
以下的字符都有特殊意义,无法直接表示,所以用斜杠加上另外一个字符来表示:
● ’ 表示单引号字符,Java代码中单引号表示字符的开始和结束,如果直接写单引号字符(’),程序会认为前两个是一对,会报错,因此需要使用转义(’)。
● " 表示双引号字符,Java代码中双引号表示字符串的开始和结束,包含在字符串中的双引号需要转义,比如"he says,“thank you”."。
●\ 表示反斜杠转义字符,由于在Java代码中的斜杠(\)是转义符,因此需要表示字面意义上的\,就需要使用双斜杠\。
多学一招:整型常量进制间的转换
通过前面的介绍可以知道,整型常量可以分别用二进制、八进制、十进制和十六进制表示,不同的进制并不影响数据本身的大小,同一个整型常量可以在不同进制之间转换,具体转换方式如下:
1.十进制和二进制之间的转换
(1)十进制转二进制*
十进制转换成二进制就是一个除以2取余数的过程。把要转换的数,除以2,得到商和余数,将商继续除以2,直到商为0。最后将所有余数倒序排列,得到数就是转换结果。
以十进制的6转换为二进制为例进行说明,如图1所示。
图1 十进制转二进制
三次除以2计算得到余数依次是:0、1、1,将所有余数倒序排列是:110。所以十进制的6转换成二进制,结果是110。
(2)二进制转十进制
二进制转换成十进制要从右到左用二进制位上的每个数去乘以2的相应次方,例如,将最右边第一位的数乘以2的0次方,第二位的数乘以2的1次方,第n位的数乘以2的n-1次方,然后把所有乘得的结果相加,得到的结果就是转换后的十进制。
以二进制数01100100转换为十进制为例进行说明,转换方式如下:
0 * 20 + 0 * 21 + 1 * 22 + 0 * 23 + 0 * 24 + 1 * 25 + 1 * 26+ 0 * 27 = 100
由于0乘以多少都是0,所以上述表达式也可以简写为:
1 * 22 + 1 * 25 + 1 * 26 = 100
得到的结果100就是二进制数01100100转换后的十进制表现形式。
2.二进制和八进制、十六进制之间的转换
编程中之所以要用八进制和十六进制,是因为它们与二进制之间的互相转换很方便,而且它们比一串长的二进制数方便书写和记忆。接下来,就为大家详细介绍如何将二进制转换为八进制和十六进制。*
(1)二进制转八进制
二进制转八进制时,首先需要将二进制数自右向左每三位分成一段(不足三位的,在左侧补0),然后将二进制数每段的三位数转为八进制的一位,转换过程中数值的对应关系如表1所示。
表1 二进制和八进制数值对应表
二进制 | 八进制 |
---|---|
000 | 0 |
001 | 1 |
010 | 2 |
011 | 3 |
100 | 4 |
101 | 5 |
110 | 6 |
111 | 7 |
了解了二进制转八进制的规则,接下来详细讲解如何将一个二进制数00101010转为八进制,具体步骤如下:
1) 每三位分成一段,结果为:000 101 010
2)将每段的数值分别查表替换,结果如下:
000→ 0
101→ 5
010→ 2
3) 将替换的结果进行组合,转换的结果为:052(注意八进制必须以0开头)
(2)二进制转十六进制
二进制转十六进制,与转八进制类似,不同的是要将二进制数每四位分成一段(不足四位的,在左侧补0),然后通过查表将二进制数每段的四位数转换为八进制的每一位即可。二进制转十六进制过程中数值的对应关系如表2所示。
表2 二进制和十六进制数值对应表
二进制 | 十六进制 | 二进制 | 十六进制 | |
---|---|---|---|---|
0000 | 0 | 1000 | 8 | |
0001 | 1 | 1001 | 9 | |
0010 | 2 | 1010 | A | |
0011 | 3 | 1011 | B | |
0100 | 4 | 1100 | C | |
0101 | 5 | 1101 | D | |
0110 | 6 | 1110 | E | |
0111 | 7 | 1111 | F |
了解了二进制转十六进制的规则,接下来通过一个例子来学习一下。假设要将一个二进制数10100101转为十六进制,具体步骤如下:
1) 每四位分成一段,结果为:1010 0101
2) 将每段的数值分别查表替换,结果如下:
1010 → A
0101 → 5
3) 将替换的结果进行组合,转换的结果为:0xA5或0XA5(注意十六进制必须以0x或者0X开头)。
Java是一门强类型的编程语言,它对变量的数据类型有严格的限定。在定义变量时必须先声明变量的数据类型,在为变量赋值时必须赋予和变量同一种类型的值,否则程序在编译期间就会出现类型匹配错误的问题。
在Java中变量的数据类型分为两种:基本数据类型和引用数据类型。Java中所有的数据类型如图1所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-eMyoaWIG-1624149047165)(https://book.itheima.net/uploads/course/java/images/2.2.2/image-20200525153451748.png)]
图1 数据类型
其中,8种基本数据类型是Java语言内嵌的,在任何操作系统中都具有相同大小和属性,而引用数据类型是在Java程序中由编程人员自己定义的数据类型。本章此处重点介绍的是Java中的基本数据类型,引用数据类型会在以后的章节中进行详细地讲解。
1.整数类型变量
整数类型变量用来存储整数数值,即没有小数部分的值。在Java中,为了给不同大小范围内的整数合理地分配存储空间,整数类型分为4种不同的类型:字节型(byte)、短整型(short)、整型(int)和长整型(long),四种类型所占存储空间的大小以及取值范围如表1所示。
表1 整数类型
类型名 | 占用空间 | 取值范围 |
---|---|---|
byte | 8位(1个字节) | -27 ~ 27-1 |
short | 16位(2个字节) | -215 ~ 215-1 |
int | 32位(4个字节) | -231 ~ 231-1 |
long | 64位(8个字节) | -263 ~ 263-1 |
表1中,列出了4种整数类型变量所占的空间大小和取值范围。其中,占用空间指的是不同类型的变量分别占用的内存大小,如一个int类型的变量会占用4个字节大小的内存空间。取值范围是变量存储的值不能超出的范围,如一个byte类型的变量存储的值必须是-27 ~ 27-1之间的整数。
在为一个long类型的变量赋值时需要注意一点,所赋值的后面要加上一个字母“L”(或小写“l”),说明赋值为long类型。如果赋的值未超出int型的取值范围,则可以省略字母“L”(或小写“l”)。具体示例如下:
long num = 2200000000L; // 所赋的值超出了int型的取值范围,后面必须加上字母L long num = 198L; // 所赋的值未超出int型的取值范围,后面可以加上字母L long num = 198; // 所赋的值未超出int型的取值范围,后面可以省略字母L
2.浮点数类型变量
浮点数类型变量用来存储小数数值。在Java中,浮点数类型分为两种:单精度浮点数(float)和双精度浮点数(double)。double型所表示的浮点数比float型更精确,两种浮点数所占存储空间的大小以及取值范围如表2所示。
表2 浮点类型
类型名 | 占用空间 | 取值范围 |
---|---|---|
float | 32位(4个字节) | 1.4E-45 ~ 3.4E+38,-1.4E-45 ~ -3.4E+38 |
double | 64位(8个字节) | 4.9E-324 ~ 1.7E+308,-4.9E-324 ~ -1.7E+308 |
表2中,列出了两种浮点数类型变量所占的空间大小和取值范围,在取值范围中,E表示以10为底的指数,E后面的“+”号和“-”号代表正指数和负指数,例如1.4E-45表示1.4*10-45。
在Java中,一个小数会被默认为double类型的值,因此在为一个float类型的变量赋值时,所赋值的后面一定要加上字母“F”(或者小写“f”),而为double类型的变量赋值时,可以在所赋值的后面加上字符“D”(或小写“d”),也可以不加。具体示例如下:
float f = 123.4f; // 为一个float类型的变量赋值,后面必须加上字母f或F double d1 = 199.3d; // 为一个double类型的变量赋值,后面可以加上字母d或D double d2 = 100.1; // 为一个double类型的变量赋值,后面可以省略字母d或D
在程序中也可以为一个浮点数类型变量赋予一个整数数值,例如下面的写法也是可以的。
float f = 100; // 声明一个float类型的变量并赋整数值 double d = 100; // 声明一个double类型的变量并赋整数值
3.字符类型变量
字符类型变量用于存储一个单一字符,在Java中用char表示。Java中每个char类型的字符变量都会占用2个字节。在给char类型的变量赋值时,需要用一对英文半角格式的单引号(’ ‘)把字符括起来,如’a’,也可以将char类型的变量赋值为0~65535范围内的整数,计算机会自动将这些整数转化为所对应的字符,如数值97对应的字符为’a’。下面的两行代码可以实现同样的效果。
char c = 'a'; // 为一个char类型的变量赋值字符'a' char ch = 97; // 为一个char类型的变量赋值整数97,相当于赋值字符'a'
4.布尔类型变量
布尔类型变量用来存储布尔值,在Java中用boolean表示,该类型的变量只有两个值,即true和false。具体示例如下:
boolean flag = false; // 声明一个boolean类型的变量,初始值为false flag = true; // 改变flag变量的值为true
5.变量的类型转换
在程序中,当把一种数据类型的值赋给另一种数据类型的变量时,需要进行数据类型转换。根据转换方式的不同,数据类型转换可分为两种:自动类型转换和强制类型转换。
1.自动类型转换
自动类型转换也叫隐式类型转换,指的是两种数据类型在转换的过程中不需要显式地进行声明。当把一个类型取值范围小的数值直接赋给另一个取值范围大的数据类型变量时,系统就会进行自动类型转换,否则需要进行强制类型转换。
Java中的自动类型转换就好比将小瓶水倒入到大瓶的换装过程。我们将小瓶水倒入到大瓶中时,由于小瓶的容量比大瓶的容量小,所以倒入的水永远不可能溢出大瓶。同样,在Java中,将取值范围小的数据类型的变量值赋值给取值范围大的数据类型的变量时,程序也不会出现任何问题。
Java中支持的不同数据类型之间的自动转换如图1所示。
图1 自动类型转换图
从图1可以看出,Java中取值范围小的byte、short、char等类型数据都可以自动转换为取值范围大的数据类型(如int类型),并最终都可以自动转换为双精度浮点数类型。例如:
byte b = 3; int x = b; // 程序把byte类型的变量b转换成了int类型,无需特殊声明 double y = x; // 将int类型的变量x转换成double类型,无需特殊声明
上面的语句中,首先将byte类型的变量b的值赋给了int类型的变量x,然后将int类型的变量x赋值给了double类型的y。由于int(double)类型的取值范围大于byte(int)类型的取值范围,编译器在赋值过程中不会造成数据丢失,所以编译器能够自动完成这种转换,在编译时不报告任何错误。
2.强制类型转换
强制类型转换也叫显式类型转换,指的是两种数据类型之间的转换需要进行显式地声明。当两种类型彼此不兼容,或者目标类型取值范围小于源类型时,自动类型转换无法进行,这时就需要进行强制类型转换。
Java中的强制类型转换就好比将大瓶水倒入到小瓶中一样,如果大瓶中的水的容量小于小瓶的大小,那么水是可以完全倒入的;如果大瓶水过多,其容量超过了小瓶的大小,那么多出来的水就会溢出,从而造成损失。同理,将取值范围大的数据类型的变量值赋值给取值范围小的数据类型的变量时,就可能造成数据的丢失,所以系统默认不支持这种行为,只能由开发者自己决定是否进行强制类型转换。
接下来先演示一个错误类型转换的例子,如文件1所示。
文件1 Example01.java
1 public class Example01 { 2 public static void main(String[] args) { 3 int num = 4; 4 byte b = num; 5 System.out.println(b); 6 } 7 }
程序编译时报错,结果如图2所示。
图2 运行结果
在进行Java代码编写时,Eclipse开发工具会自动对已编写的代码进行检测,如果发现问题,它会以红色波浪线和红叉的形式进行提醒。将鼠标悬停在出现红色波浪线错误的位置时,会出现一个悬浮框,悬浮框内将提示错误信息以及快速解决方案。
从图2可以看出,程序编译过程中出现了类型转换异常,提示“cannot convert from int to byte(无法将int类型转换为byte类型)”。出现这样错误的原因是将一个int型的值赋给byte类型的变量b时,int类型的取值范围大于byte类型的取值范围,这样的赋值可能会导致数值溢出,也就是说一个字节的变量无法存储四个字节的整数值。
在这种情况下,就需要进行强制类型转换,其语法格式如下:
目标类型 变量名 = (目标类型)值;
将文件1中的第4行代码修改为下面的代码:
byte b = (byte) num;
修改后保存源文件,Eclipse中的程序将不再报错。程序的运行结果如图3所示。
图3 运行结果
需要注意的是,在对变量进行强制类型转换时,会发生取值范围较大的数据类型向取值范围较小的数据类型的转换情况,如将一个int类型的数转为byte类型,这样做极容易造成数据精度的丢失。接下来通过一个案例来说明,如文件2所示。
文件2 Example02.java
1 public class Example02 { 2 public static void main(String[] args) { 3 byte a; // 定义byte类型的变量a 4 int b = 298; // 定义int类型的变量b,其表现形式是十六进制 5 a = (byte) b; 6 System.out.println("b=" + b); 7 System.out.println("a=" + a); 8 } 9 }
运行结果如图4所示。
>图4 运行结果
文件2中,第5行代码进行了强制类型转换,将一个int类型的变量b强制转换成byte类型,然后再将强转后的结果赋值给byte类型的变量a。从图2-7可以看出,变量b本身的值为“298”,然而在赋值给变量a后,变量a的值却为42,这说明在强制转换过程中丢失了精度。出现这种现象的原因是,变量b为int类型,在内存中占用4个字节,而byte类型的数据在内存中占用1个字节,当将变量b的类型强转为byte类型后,前面3个高位字节的数据已经丢失,这样数值就发生了改变。int类型转byte类型的过程如图5所示。
图5 int类型变量强制转换为byte类型
多学一招:表达式类型自动提升
所谓表达式是指由变量和运算符组成的一个算式。变量在表达式中进行运算时,也有可能发生自动类型转换,这就是表达式数据类型的自动提升,如byte、short和char类型的变量在运算期间类型会自动提升为int,然后再进行运算。下面通过一个具体的案例来演示,如文件3所示。*
文件3 Example03.java
1 public class Example03 { 2 public static void main(String[] args) { 3 byte b =3; 4 short s =4; 5 char c =5; 6 //将byte、short、char类型数值相加,再赋值给byte类型 7 byte b2 = b+s+c; 8 System.out.println("b2=" + b2); 9 } 10 }
程序编译报错,如图6所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-dhd5jQRf-1624149047171)(https://book.itheima.net/uploads/course/java/images/2.2.3/clip_image002-1590392532835.jpg)]
图6 程序编译报错
图6中,出现了和图5相同的类型转换错误,这是因为在表达式b+s+c运算期间,byte类型的b、short类型的s和char类型的c都被自动提升为int型,表达式的运算结果也就成了int型,这时如果将该结果赋给byte型的变量就会报错,此时就需要进行强制类型转换。
要解决文件3中的错误,必须要将文件3中第5行的代码修改为:
byte b2 = (byte) (b+s+c);
再次编译后,程序不会报错,运行结果如图7所示。
图7 运行结果
运算符 | 运算 | 范例 | 结果 |
---|---|---|---|
+ | 正号 | +3 | 3 |
- | 负号 | b=4;-b; | -4 |
+ | 加 | 5+5 | 10 |
- | 减 | 6-4 | 2 |
* | 乘 | 3*4 | 12 |
/ | 除(即算数中整除的结果) | 7/5 | 1 |
% | 取模(即算术中的求余数) | 7%5 | 2 |
++ | 自增(前) | a=2;b=++a; | a=3;b=3; |
++ | 自增(后) | a=2;b=a++; | a=3;b=2; |
– | 自减(前) | a=2;b=–a; | a=1;b=1; |
– | 自减(后) | a=2;b=a–; | a=1;b=2; |
注:在进行取模(%)运算时,运算结果的正负取决于被模数(%左边的数)的符号,与模数(%右边的数)的符号无关。例如:(-5)%3的结果为-2,而5%(-3)的结果为2。
运算符 | 运算 | 范例 | 结果 |
---|---|---|---|
= | 赋值 | a=3;b=2; | a=3;b=2; |
+= | 加等于 | a=3;b=2;a+=b; | a=5;b=2; |
-= | 减等于 | a=3;b=2;a-=b; | a=1;b=2; |
*= | 乘等于 | a=3;b=2;a*=b; | a=6;b=2; |
/= | 除等于 | a=3;b=2;a/=b; | a=1;b=2; |
%= | 模等于 | a=3;b=2;a%=b; | a=1;b=2; |
在赋值过程中,运算顺序从右往左,将右边表达式的结果赋值给左边的变量。在赋值运算符的使用中,需要注意以下几个问题。
1.在Java中可以通过一条赋值语句对多个变量进行赋值,具体示例如下:
int x, y, z; x = y = z = 5; // 为三个变量同时赋值
在上述代码中,一条赋值语句将变量x,y,z的值同时赋值为5。需要特别注意的是,下面的这种写法在Java中是不可以的:
int x = y = z = 5; // 这样写是错误的,因为没有预先声明变量y、z的数据类型
比较运算符用于对两个常量或变量的大小进行比较,其结果是一个布尔值,即true或false。
运算符 | 运算 | 范例 | 结果 |
---|---|---|---|
== | 相等于 | 4 == 3 | false |
!= | 不等于 | 4 != 3 | true |
< | 小于 | 4 < 3 | false |
> | 大于 | 4 > 3 | true |
<= | 小于等于 | 4 <= 3 | false |
>= | 大于等于 | 4 >= 3 | true |
在使用比较运算符时,需要注意一个问题,不能将比较运算符“==”误写成赋值运算符“=”。
逻辑运算符用于对布尔类型的值或表达式进行操作,其结果仍是一个布尔值。接下来通过一张表来展示Java中的逻辑运算符及其用法。
运算符 | 运算 | 范例 | 结果 |
---|---|---|---|
& | 与 | true & true | true |
& | 与 | true & false | false |
& | 与 | false & false | false |
& | 与 | false &true | false |
| | 或 | true | true | true |
| | 或 | true | false | true |
| | 或 | false| false | false |
| | 或 | false| true | true |
^ | 异或 | true ^ true | false |
^ | 异或 | true ^ false | true |
^ | 异或 | false ^ false | false |
^ | 异或 | false ^ true | true |
! | 非 | !true | false |
! | 非 | !false | true |
&& | 短路与 | true && true | true |
&& | 短路与 | true && false | false |
&& | 短路与 | false && false | false |
&& | 短路与 | false && true | false |
|| | 短路或 | true || true | true |
|| | 短路或 | true || false | true |
|| | 短路或 | false|| false | false |
|| | 短路或 | false|| true | true |
在使用逻辑运算符的过程中,需要注意以下几个细节:
1.逻辑运算符可以针对结果为布尔值的表达式进行逻辑运算。如:5 > 3 && 1 != 0的结果为true。
2.运算符“&”和“&&”都表示与操作,当且仅当运算符两边的操作数都为true时,其结果才为true,否则结果为false。当运算符“&”和“&&”的右边为表达式时,两者在使用上还有一定的区别。在使用“&”进行运算时,不论左边为true或者false,右边的表达式都会进行运算。如果使用“&&”进行运算,当左边为false时,右边的表达式不会进行运算,因此“&&”被称作短路与。
接下来通过一个案例来深入了解一下两者的区别,如文件1所示。
文件1 Example06.java
1 public class Example06 { 2 public static void main(String[] args) { 3 int x = 0; // 定义变量x,初始值为0 4 int y = 0; // 定义变量y,初始值为0 5 int z = 0; // 定义变量z,初始值为0 6 boolean a, b; // 定义boolean变量a和b 7 a = x > 0 & y++ > 1; // 逻辑运算符&对表达式进行运算,然后将结果赋值给a 8 System.out.println("a = " + a); 9 System.out.println("y = " + y); 10 b = x > 0 && z++ > 1; // 逻辑运算符&&对表达式进行运算,然后将结果赋值给b 11 System.out.println("b = " + b); 12 System.out.println("z = " + z); 13 } 14 }
运行结果如图1所示。
图1 运行结果
在文件 1中,定义了三个整型变量x、y、z,初始值都为0,同时定义了两个布尔类型的变量a和b。文件中的第7行代码使用“&”运算符对两个表达式进行运算,左边表达式x>0的结果为false,这时无论右边表达式y++>0的比较结果是什么,整个表达式x > 0 & y++ > 1的结果都会是false。由于使用的是单个的运算符“&”,运算符两边的表达式都会进行运算,因此变量y会进行自增,整个表达式运算结束时y的值为1。文件中的第10行代码是短路与运算,运算结果和第7行代码一样为false,区别在于使用了短路与“&&”运算符,当左边为false时,右边的表达式不再进行运算,因此变量z的值仍为0。
3.运算符“|”和“||”都表示或操作,当运算符任何一边的操作数或表达式为true时,其结果为true,当两边的值都为false时,其结果才为false。同与操作类似,“||”表示短路或,当运算符“||”的左边为true时,右边的表达式不会进行运算,具体示例如下:
int x = 0; int y = 0; boolean b = x==0 || y++>0
上面的代码块执行完毕后,b的值为true,y的值仍为0。出现这样结果的原因是,运算符“||”的左边x==0结果为true,那么右边表达式将不会进行运算,y的值不发生任何变化。
4.运算符“^”表示异或操作,当运算符两边的布尔值相同时(都为true或都为false),其结果为false。当两边布尔值不相同时,其结果为true。
位运算符是针对二进制数的每一位进行运算的符号,它是专门针对数字0和1进行操作的。接下来通过一张表来展示Java中的位运算符及其用法。
运算符 | 运算 | 范例 | 结果 |
---|---|---|---|
& | 按位与 | 0 & 0 | 0 |
& | 按位与 | 0 & 1 | 0 |
& | 按位与 | 1 & 1 | 1 |
& | 按位与 | 1 & 0 | 0 |
| | 按位或 | 0 | 0 | 0 |
| | 按位或 | 0 | 1 | 1 |
| | 按位或 | 1 | 1 | 1 |
| | 按位或 | 1 | 0 | 1 |
~ | 取反 | ~0 | 1 |
~ | 取反 | ~1 | 0 |
^ | 按位异或 | 0 ^ 0 | 0 |
^ | 按位异或 | 0 ^ 1 | 1 |
^ | 按位异或 | 1 ^ 1 | 0 |
^ | 按位异或 | 1 ^ 0 | 1 |
<< | 左移 | 00000010<<2 | 00001000 |
<< | 左移 | 10010011<<2 | 01001100 |
>> | 右移 | 01100010>>2 | 00011000 |
>> | 右移 | 11100010>>2 | 11111000 |
>>> | 无符号右移 | 01100010>>>2 | 00011000 |
>>> | 无符号右移 | 11100010>>>2 | 00111000 |
可以看出,Java中的位运算符本质都是针对二进制数0和1进行运算的,所以,在使用位运算符时,都会先将操作数转换成二进制数的形式进行位运算,然后将得到的结果再转换成想要的进制数。
接下来通过一些具体示例对表2-10中描述的位运算符进行详细讲解。为了方面描述,下面的运算都是针对一个byte类型的数,也就是一个字节大小的数的运算,具体如下:
1.位运算符“&”是将参与运算的两个操作数以二进制数的形式进行“按位与”运算,如果相同位上的两个二进制位都为1,则该位的运算结果为1,否则为0。
例如将6和 11进行按位与运算,一个byte类型的数字6对应的二进制数为00000110,数字11对应的二进制数为00001011,具体演算过程如下所示:
00000110 & 00001011 ————————— 00000010
运算结果为00000010,对应十进制数值2。
2.位运算符“|”是将参与运算的两个操作数以二进制数的形式进行“按位或”运算,如果相同位上的两个二进制位有一个值为1,则该位的运行结果为1,否则为0。
例如将6与11进行或运算,具体演算过程如下:
00000110 | 00001011 ————————— 00001111
运算结果为00001111,对应十进制数值15。
3.位运算符“~”只针对一个操作数进行操作,它会对二进制形式的操作数每位进行取反操作。如果二进制位是0,则取反值为1;如果是1,则取反值为0。
例如将6进行取反运算,具体演算过程如下:
~ 00000110 ————————— 11111001
运算结果为11111001,对应十进制数值-7。
4.位运算符“^”是将参与运算的两个操作数以二进制数的形式进行“异或”运算,如果相同位上的两个二进制数值相同,则值为0,否则为1。
例如将6与11进行异或运算,具体演算过程如下:
00000110 ^ 00001011 ————————— 00001101
运算结果为00001101,对应十进制数值13。
5.位运算符“<<”就是将操作数二进制形式的所有二进制位向左移动指定位数。运算时,左边移走的部分舍去,右边的空位补0。
例如一个byte类型的数字11用二进制表示为00001011,将它左移一位,具体演算过程如下:
00001011 <<1 ————————— 00010110
运算结果为00010110,对应十进制数值22。条件运算符
条件运算符,也称作三元运算符(或三目运算符),由符号“? :”组合构成,其语法格式如下:
(boolean_expr) ? true_statement : false_statement;
上述条件运算符的运算规则是:先对布尔类型的表达式boolean_expr求值,如果结果为true,就执行冒号“:”前面的表达式true_statement,否者执行后面的表达式false_statement。
条件运算符的基本使用示例如下:
int store = 5; System.out.println(store <=0 ? "没有库存啦!" : "库存量为:"+store); // 输出结果为 “库存量为:5”
条件运算符“? :”基本等价于选择结构语句中的“if…else”条件语句,属于是精简写法。关于 “if…else”条件语句,将会在后面小节详细讲解,这里不必深究其具体用法。
6.位运算符“>>“就是将操作数二进制形式的所有进制位向右移动指定位数。运算时,左边的空位根据原数的符号位补0或者1(原来是负数就全部补1,是正数就全部补0),右边移走的部分舍去。
例如一个byte类型的数字11用二进制表示为00001011,将它右移一位,具体演算过程如下。
00001011 >>1 ————————— 00000101
运算结果为00000101,对应十进制数值5。
7.位运算符“>>>”属于无符号右移,就是将操作数二进制形式的所有二进制位不考虑正负数向右移动指定位数。运算时,左边的空位全部补0(不考虑原数正负),右边移走的部分舍去。
例如一个byte类型的数字11用二进制表示为00001011,将它无符号右移一位,具体演算过程如下:
00001011 >>>1 ————————— 00000101
运算结果为00000101,对应十进制数值5。
条件运算符,也称作三元运算符(或三目运算符),由符号“? :”组合构成,其语法格式如下:
(boolean_expr) ? true_statement : false_statement;
上述条件运算符的运算规则是:先对布尔类型的表达式boolean_expr求值,如果结果为true,就执行冒号“:”前面的表达式true_statement,否者执行后面的表达式false_statement。
条件运算符的基本使用示例如下:
int store = 5; System.out.println(store <=0 ? "没有库存啦!" : "库存量为:"+store); // 输出结果为 “库存量为:5”
条件运算符“? :”基本等价于选择结构语句中的“if…else”条件语句,属于是精简写法。关于 “if…else”条件语句,将会在后面小节详细讲解,这里不必深究其具体用法。
在对一些比较复杂的表达式进行运算时,要明确表达式中所有运算符参与运算的先后顺序,通常把这种顺序称作运算符的优先级。接下来通过一张表来展示Java中运算符的优先级,其中数字越小表示优先级越高。
优先级 | 运算符 |
---|---|
1 | . [] () |
2 | ++ – ~ ! |
3 | * / % |
4 | + - |
5 | << >> >>> |
6 | < > <= >= |
7 | == != |
8 | & |
9 | ^ |
10 | | |
11 | && |
12 | || |
13 | ?: |
14 | = *= /= %= += -= <<= >>= >>>= &= ^= |= |
int a =2; int b = a + 3*a; System.out.println(b);
运行结果为8,由于运算符“”的优先级高于运算符“+”和“=”,因此先运算3a,得到的结果是6,再将6与a相加,最后通过运算符“=”赋值给变量b,得到最后的结果8。
int a =2; int b = (a+3) * a; System.out.println(b);
运行结果为10,由于运算符“()”的优先级最高,因此先运算括号内的a+3,得到的结果是5,再将5与a相乘,最后通过运算符“=”赋值给变量b,得到最后的结果10。
其实没有必要去刻意记忆运算符的优先级。编写程序时,尽量使用括号“()”来实现想要的运算顺序,以免产生歧义。
if条件语句分为三种语法格式:
1.if语句
if语句是指如果满足某种条件,就进行某种处理。例如,如果一个人的年龄不满18周岁,那么就可以判定这个人是未成年人。这句话可以通过下面的一段伪代码来描述:
if (年龄不满18岁) { 此人是未成年人! }
上面的例子就描述了if语句的用法,在Java中,if语句的具体语法格式如下:
if (判断条件){ 执行语句 }
上述语法格式中,判断条件是一个布尔值,当判断条件为true时,就会执行{}中的执行语句。if语句的执行流程如图1所示。
接下来就使用if条件语句对上面的情景描述进行案例演示,如文件1所示。
文件1 Example07.java
1 public class Example07 { 2 public static void main(String[] args) { 3 int age = 17; //假设年龄为17岁 4 if (age < 18) { // 判断年龄是否满18岁 5 System.out.println("此人是未成年人!"); 6 } 7 } 8 }
运行结果:
分析:
文件1中,定义了一个int类型的变量age(年龄),并假设属性值为17。在if语句的判断条件中判断age的值是否小于18,很明显条件成立,将会选择执行if后面{}中的执行语句,输出“此人是未成年人!”的信息。
2.if…else语句
if…else语句是指如果满足某种条件,就进行某种处理,否则就进行另一种处理。例如,要判断一个正整数的奇偶,如果该数字能被2整除则是一个偶数,否则该数字就是一个奇数。
if…else语句的语法格式如下:
if (判断条件){ 执行语句1 ... }else{ 执行语句2 ... }
上述语法格式中,判断条件是一个布尔值。当判断条件为true时,会执行if后面{}中的执行语句1,否则会执行else后面{}中的执行语句2。if…else语句的执行流程如图3所示。
图3 if…else语句流程图
接下来使用if…else语句来实现一个判断奇偶数的程序,如文件2所示。
文件2 Example08.java
1 public class Example08 { 2 public static void main(String[] args) { 3 int num = 19; 4 if (num % 2 == 0) { 5 // 判断条件成立,num能被2整除 6 System.out.println(num+"是一个偶数"); 7 } else { 8 // 判断条件不成立,num不能被2整除 9 System.out.println(num+"是一个奇数"); 10 } 11 } 12 }
运行结果如图4所示。
图4 运行结果
文件2中,变量num的值为19,19%2的结果为1,不等于0,判断条件不成立,因此会执行else后面{}中的语句,输出“19是一个奇数”的信息。
小提示:
上述案例中的if…else语句还可以通过前面所学习的三元运算来替换,替换后的输出语句如下:
System.out.println(num % 2 == 0 ? num+"是一个偶数" :num+"是一个奇数" );
3.if…else if…else语句
if…else if…else语句用于对多个条件进行分支判断,从而进行多种不同的处理。例如,对一个学生的考试成绩进行等级划分:如果分数大于80分,那么等级为优;否则,如果分数大于70分,那么等级为良;否则,如果分数大于60分,那么等级为中;否则,等级为差。
if…else if…else语句具体语法格式如下。
if (判断条件1) { 执行语句1 } else if (判断条件2) { 执行语句2 } ... else if (判断条件n) { 执行语句n } else { 执行语句n+1 }
上述语法格式中,判断条件都是布尔值。当判断条件1为true时,if后面{}中的执行语句1会执行;当判断条件1为false时,会继续执行判断条件2,如果为true,则执行语句2,以此类推。如果所有的判断条件都为false,则意味着所有条件均未满足,就会执行else后面{}中的执行语句n+1。
if…else if…else语句的执行流程如图5所示。
图5 if…else if…else语句的流程图
接下来使用if…else if…else语句来实现对学生考试成绩进行等级划分的程序,如文件3所示。
文件3 Example09.java
1 public class Example09 { 2 public static void main(String[] args) { 3 int grade = 75; // 定义学生成绩 4 if (grade > 80) { 5 // 满足条件 grade > 80 6 System.out.println("该成绩的等级为优"); 7 } else if (grade > 70) { 8 // 不满足条件 grade > 80 ,但满足条件 grade > 70 9 System.out.println("该成绩的等级为良"); 10 } else if (grade > 60) { 11 // 不满足条件 grade > 70 ,但满足条件 grade > 60 12 System.out.println("该成绩的等级为中"); 13 } else { 14 // 不满足条件 grade > 60 15 System.out.println("该成绩的等级为差"); 16 } 17 } 18 }
运行结果如图:
分析:
文件3中,定义了学生成绩grade为75,由于它不满足第一个判断条件grade>80,因此会继续向下执行第二个判断条件;由于满足了grade>70的条件,因此会输出“该成绩的等级为良”的信息。
switch 条件语句也是一种很常用的选择结构语句,它由一个switch控制表达式和多个case关键字组成。与if条件语句不同的是,switch 条件语句的控制表达式结果类型只能是byte、short、char、int、enum枚举以及String类型,而不能是boolean类型。switch条件语句的基本语法格式如下:
switch (控制表达式){ case 目标值1: 执行语句1 break; case 目标值2: 执行语句2 break; ... case 目标值n: 执行语句n break; default: 执行语句n+1 break; }
在上面的语法格式中,switch语句将控制表达式的值与每个case中的目标值进行匹配,如果找到了匹配的值,会执行对应case后的执行语句,并通过break关键字跳出该switch语句;如果搜索完所有case中的目标值仍没找到任何匹配的值,就会执行default后的语句。switch语句中的break关键字将在后面的小节中做具体介绍,此处,读者只需要知道break的作用是跳出switch语句即可。
接下来通过一个根据月份来判断当前所属季节的案例来演示switch条件语句的使用,如文件1所示。
文件1 Example10.java
1 public class Example10{ 2 public static void main(String[] args) { 3 int month = 5; //假设当前为5月份 4 switch (month) { 5 case 12: 6 case 1: 7 case 2: 8 System.out.println("当前为时间为冬季"); 9 break; 10 case 3: 11 case 4: 12 case 5: 13 System.out.println("当前时间为春季"); 14 break; 15 case 6: 16 case 7: 17 case 8: 18 System.out.println("当前时间为夏季"); 19 break; 20 case 9: 21 case 10: 22 case 11: 23 System.out.println("当前时间为冬季"); 24 break; 25 default: 26 System.out.println("输入的月份不正确!!!"); 27 break; 28 } 29 } 30 }
运行结果如图1所示。
图1 运行结果
从文件1可以看出,当有多个case目标值会执行同样的执行语句时,可以将这多个case标签并列书写,并只编写一次执行语句即可。
文件1中,由于变量 month的值为5(即当前月份为5月),整个switch语句判断的结果满足第12行代码的条件,因此输出“当前时间为春季”。文件中的default语句用于处理和前面的case目标值都不匹配的情况,如果将第3行代码替换为“int month= 13;”,那么再次运行程序后,输出的结果如图2所示。
图2 运行结果
注意:
在使用switch条件语句时,需要特别注意两点:第一,switch条件语句后{}中的多个case以及default标签顺序可以随机,并不影响程序的执行结果;第二,在case和default标签中的执行语句后务必都加上break关键字,用于执行完语句后跳出当前的switch条件语句,否则,程序会接着执行后续的执行语句,直到遇到break关键字或switch语句末尾为止。
数组类型[] 数组名 = new 数组类型[数组长度]; 数组类型[] 数组名 = new 数组类型[]{数组元素0,数组元素1,...}; 数组类型[] 数组名 = {数组元素0,数组元素1,...};
以上三种定义数组语法格式的实际使用示例如下:
int[] ids = new int[100]; String[] names = new String[]{"张三","tom",...}; Object[] object = {"张三","tom",...};
第一种方式定义了一个int[]数组类型的变量ids,使用new int[100]指定了该数组的长度(也就是元素的个数)为100,但并没有为数组中的元素赋值;第二种和第三种方式分别定义了一个String[]类型和Object[]类型的数组变量names和object,并都通过“=”右边的大括号“{}”为数组完成了初始化赋值。
关于数组在内存中的存储方式,这里以上述定义数组的第一种方式为例,来进行具体说明。第一种数组定义的方式,相当于在内存中定义了100个int类型的变量,第一个变量的名称为ids[0],第二个变量的名称为ids[1],以此类推,第100个变量的名称为ids[99],这些变量的初始值都是0。为了更好地理解数组的这种定义方式,可以将上面的一句代码分成两句来写,具体如下:
int[] ids; // 声明一个int[]类型的变量 ids = new int[100]; // 创建一个长度为100的数组,并将数组地址赋值给数组类型的变量x
接下来通过两张内存图来详细地说明数组在创建过程中内存的分配情况。
第一行代码 int[] ids; 声明了一个变量ids,该变量的类型为int[],即一个int类型的数组。变量ids会占用一块内存单元,它没有被分配初始值。此时ids在内存中的状态如图 内存状态图所示。
第二行代码 ids = new int[100]; 创建了一个数组,并将数组的内存地址赋值给变量ids。在程序运行期间可以使用变量ids来引用数组,这时内存中的状态会发生变化,如图内存状态图所示。
图中描述了变量ids引用数组的情况。该数组中有100个元素,初始值都为0。数组中的每个元素都有一个索引(也可称为角标),要想访问数组中的元素可以通过“数组名[角标]”的形式,即“ids[0]、…、ids[99]”。需要注意的是,数组中最小的索引是0,最大的索引是“数组的长度-1”。在Java中,为了方便获得数组的长度,提供了一个length属性,在程序中可以通过“数组名.length”的方式来获得数组的长度,即数组元素的个数。
1 public class Example22 { 2 public static void main(String[] args) { 3 int[] arr = { 1, 2, 3, 4, 5 }; // 定义数组 4 // 使用for循环遍历数组的元素 5 for (int i = 0; i < arr.length; i++) { 6 System.out.println(arr[i]+"、"); // 通过索引访问元素 7 } 8 } 9 }
定义了一个临时变量max,用于记住数组的最大值。首先假设数组中第一个元素arr[0]为最大值,然后使用for循环对数组进行遍历,在遍历的过程中只要遇到比max值还大的元素,就将该元素赋值给max,这样一来,变量max就能够在循环结束时记住数组中的最大值。
1 public class Example23 { 2 public static void main(String[] args) { 3 // 1、定义一个int[]数组 4 int[] arr = { 4, 1, 6, 3, 9, 8 }; 5 // 2、定义变量max用于记住最大数,首先假设第一个元素为最大值 6 int max = arr[0]; 7 // 3、遍历数组,查找最大值 8 for (int i = 1; i < arr.length; i++) { 9 // 比较 arr[i]的值是否大于max 10 if (arr[i] > max) { 11 // 条件成立,将arr[i]的值赋给max 12 max = arr[i]; 13 } 14 } 15 System.out.println("数组arr中的最大值为:" + max); // 打印最大值 16 } 17 }
冒泡排序
定义
原理
第1步,从第一个元素开始,将相邻的两个元素依次进行比较,直到最后两个元素完成比较。如果前一个元素比后一个元素大,则交换它们的位置。整个过程完成后,数组中最后一个元素自然就是最大值,这样也就完成了第一轮比较。
第2步,除了最后一个元素,将剩余的元素继续进行两两比较,过程与第一步相似,这样就可以将数组中第二大的数放在了倒数第二个位置。
第3步,以此类推,持续对越来越少的元素重复上面的步骤,直到没有任何一对元素需要比较为止。
图——冒泡排序
代码
1 public class Example24 { 2 public static void main(String[] args) { 3 int[] arr = { 9, 8, 3, 5, 2 }; 4 // 1、冒泡排序前,先循环打印数组元素 5 for (int i = 0; i < arr.length; i++) { 6 System.out.print(arr[i] + " "); 7 } 8 System.out.println(); // 用于换行 9 // 2、进行冒泡排序 10 // 2.1、外层循环定义需要比较的轮数(两数对比,要比较n-1轮) 11 for (int i= 1; i < arr.length; i++) { 12 // 2.2、内层循环定义第i轮需要比较的两个数 13 for (int j = 0; j < arr.length -i; j++) { 14 if (arr[j] > arr[j + 1]) { // 比较相邻元素 15 // 下面的三行代码用于相邻两个元素交换 16 int temp = arr[j]; 17 arr[j] = arr[j + 1]; 18 arr[j + 1] = temp; 19 } 20 } 21 } 22 // 3、完成冒泡排序后,再次循环打印数组元素 23 for (int i = 0; i < arr.length; i++) { 24 System.out.print(arr[i] + " "); 25 } 26 } 27 }
二维数组可以被看作是特殊的一维数组,其定义有很多方式,接下来针对几种常见的方式进行详细地讲解,具体如下:
int[][] arr = new int[3][4];
上面的代码相当于定义了一个二维数组,这个二维数组的长度为3,我们可以将它看成3个int[]类型的一维数组,每个一维数组中的元素又是一个长度为4的一维数组。接下来通过一个图来表示这种情况,如图所示。
第二种方式,只指定二维数组的长度,不确定每个数组的元素个数,其基本语法格式如下:
int[][] arr = new int[3][];
第二种方式和第一种类似,只是数组中每个元素的长度不确定,接下来通过一个图来表示这种情况,如图所示。
第三种方式,直接使用嵌套大括号“{}”,对二维数组初始化赋值,其基本语法格式如下:
int[][] arr = {{1,2},{3,4,5,6},{7,8,9}};
上面的二维数组中定义了三个元素,这三个元素都是数组,分别为{1,2}、{3,4,5,6}、{7,8,9},接下来通过一个图来表示这种情况,如图所示。
对二维数组中元素的操作也是通过角标的方式来完成的,具体示例代码如下:
arr[0][1]; // 获取二维数组中第一个元素组的第二个元素 arr[0][1]=1; // 对二维数组中第一个元素组的第二个元素赋值或修改
接下来通过一个统计公司三个销售小组中每个小组的总销售额,以及整个公司销售额的案例来熟悉二维数组的使用,如文件1所示。
文件1 Example25.java
1 public class Example25 { 2 public static void main(String[] args) { 3 int[][] arr = new int[3][]; // 定义一个长度为3的二维数组 4 arr[0] = new int[] { 11, 12 }; // 为数组的元素赋值 5 arr[1] = new int[] { 21, 22, 23 }; 6 arr[2] = new int[] { 31, 32, 33, 34 }; 7 int sum = 0; // 定义变量记录总销售额 8 for (int i = 0; i < arr.length; i++) { // 遍历数组元素 9 int groupSum = 0; // 定义变量记录小组销售总额 10 for (int j = 0; j < arr[i].length; j++) { 11 groupSum += arr[i][j]; 12 } 13 sum +=groupSum; // 累加小组销售额 14 System.out.println("第" + (i + 1) + "小组销售额为:" 15 + groupSum + " 万元。"); 16 } 17 System.out.println("总销售额为: " + sum + " 万元。"); 18 } 19 }
运行结果如图所示。
文件1中,第3行代码定义了一个长度为3的二维数组,并在4~6行代码中为数组的每个元素赋值。文件中还定义了两个变量sum和groupSum,其中sum用来记录公司的总销售额,groupSum用来记录每个销售小组的销售额。当通过嵌套for循环统计销售额时,外层循环对三个销售小组进行遍历,内层循环对每个小组员工的销售额进行遍历,内层循环每循环一次就相当于将一个小组员工的销售总额统计完毕,赋值给groupSum,然后把groupSum的值与sum的值相加赋值给sum。当外层循环结束时,三个销售小组的销售总额groupSum都累加到sum中,即统计出了整个公司的销售总额。
面向对象是一种符合人类思维习惯的编程思想。现实生活中存在各种形态不同的事物,这些事物之间存在着各种各样的联系。在程序中使用对象来映射现实中的事物,使用对象的关系来描述事物之间的联系,这种思想就是面向对象。
面向过程就是分析出解决问题所需要的步骤,然后用函数把这些步骤一一实现,使用的时候依次调用就可以了。面向对象则是把构成问题的事务按照一定规则划分为多个独立的对象,然后通过调用对象的方法来解决问题。
2、面向对象三大特性
2、面向对象三大特性
封装是面向对象的核心思想,将对象的属性和行为封装起来,不需要让外界知道具体实现细节,这就是封装思想。例如,用户使用电脑,只需要使用手指敲键盘就可以了,无需知道电脑内部是如何工作的,即使用户可能碰巧知道电脑的工作原理,但在使用时,并不完全依赖电脑工作原理这些细节。
继承主要描述的就是类与类之间的关系,通过继承,可以在无需重新编写原有类的情况下,对原有类的功能进行扩展。例如,有一个汽车的类,该类中描述了汽车的普通属性和功能,而轿车的类中不仅应该包含汽车的属性和功能,还应该增加轿车特有的属性和功能,这时,可以让轿车类继承汽车类,在轿车类中单独添加轿车特有的属性和功能就可以了。继承不仅增强了代码的复用性、提高开发效率,还为程序的维护补充提供了便利。
多态指的是在一个类中定义的属性和功能被其他类继承后,当把子类对象直接赋值给父类引用变量时,相同引用类型的变量调用同一个方法所呈现出的多种不同行为特性。例如,当听到“Cut” 这个单词时,理发师的行为表现是剪发,演员的行为表现是停止表演等。不同的对象,所表现的行为是不一样的。
面向对象的思想只凭上面的介绍是无法让初学者真正理解的,初学者只有通过大量的实践练习和思考,才能真正领悟面向对象思想。
Java中的类是通过class关键字来定义的,其语法格式如下:
[修饰符] class 类名 [extends 父类名] [implements 接口名]{ // 类体,包括类的成员变量和成员方法 }
在上述语法格式中,class前面的修饰符可以是public,也可以不写(默认);class之后是定义的类名,类名首字母要大写,并且其命名要符合标识符的命名规则;extends和implements是可选项,均为Java中的关键字,其中extends用于说明所定义的类继承于哪个父类,implements关键字用于说明当前类实现了哪些接口(这两个关键字将在下一章详细讲解,这里作为了解即可)。后面大括号{}中的内容是类体,即需要在类中编写的内容,它主要包括类的成员变量和成员方法。
类的成员变量也被称作类的属性,它主要用于描述对象的特征。例如,一个人的基本属性特征有姓名、年龄、职业、住址等信息,在类中要使用姓名、年龄等信息时,就需要先将它们声明(定义)为成员变量。
声明(定义)成员变量的语法格式如下: [修饰符] 数据类型 变量名 [ = 值];
在上述语法格式中,修饰符为可选项,用于指定变量的访问权限,其值可以是public、private等;数据类型可以为Java中的任意类型;变量名是变量的名称,必须符合标识符的命名规则,它可以赋予初始值,也可以不赋值。通常情况下,将未赋值(没有被初始化)的变量称之为声明变量,而赋值(初始化)的变量称之为定义变量。
例如,姓名和年龄属性在类中的声明和定义方式如下:
private String name; // 声明一个String类型的name;
private int age = 20; // 定义一个int类型的age,并赋值为20;
成员方法也被称为方法,类似于C语言中的函数,它主要用于描述对象的行为。一个人的基本行为特征有吃饭、睡觉、运动等,这些行为在Java类中,就可以定义成方法。
定义一个方法的语法格式如下:
[修饰符] [返回值类型] 方法名([参数类型 参数名1,参数类型 参数名2,...]){ //方法体 ... return 返回值; //当方法的返回值类型为void时,return及其返回值可以省略 }
上面语法格式中,[]中的内容表示可选,各部分的具体说明如下:
● 修饰符:方法的修饰符比较多,有对访问权限进行限定的(如public、protected、private),有静态修饰符static,还有最终修饰符final等,这些修饰符在后面的学习过程中会逐步讲解。
● 返回值类型:用于限定方法返回值的数据类型,如果不需要返回值,可以使用void关键字。
● 参数类型:用于限定调用方法时传入参数的数据类型。
● 参数名:是一个变量,用于接收调用方法时传入的数据。
● return关键字:用于结束方法以及返回方法指定类型的值,当方法的返回值类型为void时,return及其返回值可以省略。
● 返回值:被return语句返回的值,该值会返回给调用者。
在上述语法中,{}之前的内容被称之为方法签名(或方法头),而{}中的执行语句被称为方法体。需要注意的是,方法签名中的“[参数类型 参数名1,参数类型 参数名2,…]”被称作参数列表,它用于描述方法在被调用时需要接收的参数,如果方法不需要接收任何参数,则参数列表为空,即()内不写任何内容。关于上述语法结构中的修饰符内容,将在后面进行逐一的讲解,这里读者只需了解如何定义类、成员变量和成员方法即可。
了解了类及其成员的定义方式后,接下来通过一个具体的案例来演示一下类的定义,如文件1所示。
文件1 Person.java
1 public class Person { 2 int age; // 声明int类型的变量age 3 // 定义 speak() 方法 4 void speak() { 5 System.out.println("我今年" + age + "岁了!"); 6 } 7 }
文件1中定义了一个Person类,并在类中定义了类的成员变量和成员方法。其中,Person是类名,age是类的成员变量,speak()是类的成员方法。在成员方法speak()中可以直接访问成员变量age。
脚下留心
在Java中,定义在类中的变量被称为成员变量,定义在方法中的变量被称为局部变量。如果在某一个方法中定义的局部变量与成员变量同名,这种情况是允许的,此时方法中通过变量名访问到的是局部变量,而并非成员变量,请阅读下面的示例代码:
public class Person { int age = 10; // 类中定义的变量被称作成员变量 void speak() { int age = 30; // 方法内部定义的变量被称作局部变量 System.out.println("我今年" + age + "岁了!"); } }
上面的代码中,speak()方法中的打印语句所访问的变量age,就是局部变量,也就是说,当有另外一个程序来调用speak()方法时,输出的值为30,而不是10。
应用程序想要完成具体的功能,仅有类是远远不够的,还需要根据类创建实例对象。在Java程序中,可以使用new关键字来创建对象,具体语法格式如下:
类名 对象名称 = new 类名();
例如,创建Person类的实例对象代码如下:
Person p = new Person();
上面的代码中,“new Person()”用于创建Person类的一个实例对象,“Person p”则是声明了一个Person类型的变量p,中间的等号用于将Person对象在内存中的地址赋值给变量p,这样变量p便持有了对象的引用。为了便于描述,本书接下来的章节,通常会将变量p引用的对象简称为p对象。在内存中变量p和对象之间的引用关系如图内存分析所示。
从图1可以看出,在创建Person对象时,程序会占用两块内存区域,分别是栈内存和堆内存。其中Person类型的变量p被存放在栈内存中,它是一个引用,会指向真正的对象;通过new Person()创建的对象则放在堆内存中,这才是真正的对象。
小提示:
Java将内存分为两种,即栈内存和堆内存。其中栈内存用于存放基本类型的变量和对象的引用变量(如Person p),堆内存用于存放由new创建的对象和数组。
在创建Person对象后,可以通过对象的引用来访问对象所有的成员,具体格式如下:
对象引用.对象成员
接下来通过一个案例来学习如何访问对象的成员,如文件1所示。
文件1 Example02.java
1 public class Example02 { 2 public static void main(String[] args) { 3 Person p1 = new Person(); // 创建第一个Person类对象 4 Person p2 = new Person(); // 创建第二个Person类对象 5 p1.age = 18; // 为age属性赋值 6 p1.speak(); // 调用对象的方法 7 p2.speak(); 8 } 9 }
运行结果如图2所示。
图2 运行结果
文件1中,p1、p2分别引用了Person类的两个实例对象。从图2可以看出,p1和p2对象在调用speak()方法时,打印的age值不同。这是因为p1对象和p2对象是两个完全独立的个体,它们分别拥有各自的age属性,对p1对象的age属性进行赋值并不会影响到p2对象age属性的值。程序运行期间p1、p2引用的对象在内存中的状态如图所示。
小提示:
在实际情况下,除了可以使用文件3-2中介绍的对象引用来访问对象成员外,还可以直接使用创建的对象本身来引用对象成员,具体格式如下:
new 类名().对象成员
这种方式是在通过new关键字创建实例对象的同时就访问了对象的某个成员,并且在创建后只能访问其中某一个成员,而不能像对象引用那样可以访问多个对象成员。同时,由于没有对象引用的存在,在完成某一个对象成员的访问后,该对象就会变成垃圾对象。所以,在实际开发中,创建实例对象时多数会使用对象引用。
在文件1中,通过“p1.age=18”将p1对象的age属性赋值为18,但并没有对p2对象的age属性进行赋值,按理说p2对象的age属性应该是没有值的。但从图2可以看出,p2对象的age属性也是有值的,其值为0。这是因为在实例化对象时,Java虚拟机会自动为成员变量进行初始化,针对不同类型的成员变量赋予不同的初始值,如表1所示。
表1 成员变量的初始化值
成员变量类型 | 初始值 |
---|---|
byte | 0 |
short | 0 |
int | 0 |
long | 0 |
float | 0.0 |
double | 0.0 |
char | 空字符,’\u0000’ |
boolean | false |
引用数据类型 | null |
当对象被实例化后,在程序中可以通过对象的引用变量来访问该对象的成员。需要注意的是,当没有任何变量引用这个对象时,它将成为垃圾对象,不能再被使用。接下来通过两段程序代码来分析对象是如何成为垃圾的。
第一段程序代码:
{ Person p1 = new Person(); ...... }
上面的代码中,使用变量p1引用了一个Person类型的对象。当这段代码运行完毕时,变量p1就会超出其作用域而被销毁,这时Person类型的对象将因为没有被任何变量所引用而变成垃圾。
第二段程序代码:
{ Person p2 = new Person(); ...... p2 = null; ...... }
上面的代码中,使用变量p2引用了一个Person类型的对象,接着将变量p2的值置为null,则表示该变量不指向任何一个对象,被p2所引用的Person对象就会失去引用,成为垃圾对象,过程如图所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-AYBL2QHS-1624149047185)(https://book.itheima.net/uploads/course/images/java/1.3/image-20200612143700978.png)]
在Java中,针对类、成员方法和属性提供了四种访问级别,分别是private、default、protected和public。接下来通过一个图将这四种控制级别由小到大依次列出,如图所示。
图中展示了Java中的四种访问控制级别,具体介绍如下:
● private(当前类访问级别):如果类的成员被private访问控制符来修饰,则这个成员只能被该类的其他成员访问,其他类无法直接访问。类的良好封装就是通过private关键字来实现的。
● default(包访问级别):如果一个类或者类的成员不使用任何访问控制符修饰,则称它为默认访问控制级别,这个类或者类的成员只能被本包中的其他类访问。
● protected(子类访问级别):如果一个类的成员被protected访问控制符修饰,那么这个成员既能被同一包下的其他类访问,也能被不同包下该类的子类访问。
● public(公共访问级别):这是一个最宽松的访问控制级别,如果一个类或者类的成员被public访问控制符修饰,那么这个类或者类的成员能被所有的类访问,不管访问类与被访问类是否在同一个包中。
接下来通过一个表将这四种访问级别更加直观的表示出来,如表访问控制级别所示。
访问范围 | private | default | protected | public |
---|---|---|---|---|
同一类中 | √ | √ | √ | √ |
同一包中 | √ | √ | √ | |
子类中 | √ | √ | ||
全局范围 | √ |
小提示:
如果一个Java源文件中定义的所有类都没有使用public修饰,那么这个Java源文件的文件名可以是一切合法的文件名;如果一个源文件中定义了一个public修饰的类,那么这个源文件的文件名必须与public修饰的类的类名相同。
对成员变量的访问作出一些限定,不允许外界随意访问,这就需要实现类的封装。
类的封装,是指将对象的状态信息隐藏在对象内部,不允许外部程序直接访问对象的内部信息,而是通过该类所提供的方法来实现对内部信息的操作访问。
具体的实现过程是,在定义一个类时,将类中的属性私有化,即使用private关键字来修饰,私有属性只能在它所在类中被访问,如果外界想要访问私有属性,需要提供一些使用public修饰的公有方法,其中包括用于获取属性值的getXxx()方法和设置属性值的setXxx()方法。
1 class Person{ 2 private String name; 3 private int age; 4 public String getName() { 5 return name; 6 } 7 public void setName(String name) { 8 this.name = name; 9 } 10 public int getAge() { 11 return age; 12 } 13 public void setAge(int age) { 14 if(age <= 0){ 15 System.out.println("您输入的年龄不正确!"); 16 } else { 17 this.age = age; 18 } 19 } 20 public void speak(){ 21 System.out.println("我叫"+name+",今年"+age+"岁了"); 22 } 23 } 24 public class Example04 { 25 public static void main(String[] args) { 26 Person p = new Person(); 27 p.setName("张三"); 28 p.setAge(-18); 29 p.speak(); 30 } 31 }
运行结果如图所示。
代码中,使用private关键字将属性name和age声明为私有变量,并对外界提供公有的访问方法,其中getName()方法用于获取name属性的值,setName()方法用于设置name属性的值,依次类推。
在main()方法中创建了Person类对象,并调用了setAge()方法传入一个负数-18,在setAge()方法中会对参数age的值进行检查,由于当前传入的值小于0,因此会打印“您输入的年龄不正确!”的信息。由于此时的age属性没有被赋值,所以仍为初始值0。
Java允许在一个程序中定义多个名称相同,但是参数的类型或个数不同的方法,这就是方法的重载。
1 public class Example06 { 2 // 1、实现两个整数相加 3 public static int add(int x, int y) { 4 return x + y; 5 } 6 // 2、实现三个整数相加 7 public static int add(int x, int y, int z) { 8 return x + y + z; 9 } 10 // 3、实现两个小数相加 11 public static double add(double x, double y) { 12 return x + y; 13 } 14 public static void main(String[] args) { 15 // 针对求和方法的调用 16 int sum1 = add(1, 2); 17 int sum2 = add(3, 4, 7); 18 double sum3 = add(0.2, 5.3); 19 // 打印求和的结果 20 System.out.println("sum1=" + sum1); 21 System.out.println("sum2=" + sum2); 22 System.out.println("sum3=" + sum3); 23 } 24 }
文件中定义了三个同名的add()方法,但它们的参数个数或参数类型不同,从而实现了方法的重载。在main()方法中调用add()方法时,通过传入不同的参数便可以确定调用哪个重载的方法,如add(1,2)调用的是两个整数求和的方法add(int x, int y)。
需要注意的是,方法的重载与返回值类型无关,它只需要满足两个条件:
构造方法的语法格式与定义成员方法的语法格式相似,其语法格式如下:
[修饰符] 方法名 ([参数列表]){ // 方法体 }
上述语法格式所定义的构造方法需同时满足以下三个条件:
① 方法名与类名相同。
② 在方法名的前面没有返回值类型的声明。
③ 在方法中不能使用return语句返回一个值,但是可以单独写return语句来作为方法的结束。
了解了构造方法的定义语法后,接下来通过一个案例来演示如何在类中定义构造方法。
1 class Person { 2 // 类的构造方法 3 public Person() { 4 System.out.println("调用了无参的构造方法"); 5 } 6 } 7 public class Example08 { 8 public static void main(String[] args) { 9 Person p = new Person(); // 实例化Person 对象 10 } 11 }
运行结果如图所示。
文件中,Person类中定义了一个无参的构造方法Person()。从图中可以看出,Person类中无参的构造方法被调用了。这是因为第9行代码在通过“new Person()”实例化Person对象时会自动调用该类的构造方法。
在一个类中除了可以定义无参的构造方法外,还可以定义有参的构造方法,通过有参的构造方法就可以实现对属性的赋值。接下来进行改写,改写后的代码如以下文件所示。
1 class Person { 2 // 声明int类型的变量age 3 int age; 4 // 定义有参构造方法 5 public Person(int a) { 6 age = a; //为age属性赋值 7 } 8 // 定义 speak() 方法 9 public void speak() { 10 System.out.println("我今年" + age + "岁了!"); 11 } 12 } 13 public class Example09 { 14 public static void main(String[] args) { 15 Person p = new Person(18); // 实例化Person对象 16 p.speak(); 17 } 18 }
运行结果如图所示。
文件中,Person类中定义了一个有参的构造方法Person(int a),第15行代码中的“new Person(18);”会调用有参的构造方法来实例化一个Person对象,并传入参数18,对age属性进行赋值。从图2可以看出,Person对象在调用speak()方法时,其age属性已经被赋值为18。
与普通方法一样,构造方法也可以重载,在一个类中可以定义多个构造方法,只要每个构造方法的参数类型或参数个数不同即可。在创建对象时,可以通过调用不同的构造方法来为不同的属性进行赋值。接下来通过一个案例来学习构造方法的重载,如下所示。
1 class Person { 2 // 声明String类型的变量name 3 String name; 4 // 声明int类型的变量age 5 int age; 6 // 定义有参构造方法 7 public Person(int a) { 8 age = a; // 为age属性赋值 9 } 10 public Person(String n,int a){ 11 name = n; // 为name属性赋值 12 age = a; // 为age属性赋值 13 } 14 // 定义 speak() 方法 15 public void speak() { 16 System.out.println("我今年" + age + "岁了!"); 17 } 18 // 定义say()方法 19 public void say(){ 20 System.out.println("我叫"+name+",今年" + age + "岁了!"); 21 } 22 } 23 public class Example10 { 24 public static void main(String[] args) { 25 Person p1 = new Person(18); 26 Person p2 = new Person("张三",32); 27 p1.speak(); 28 p2.say(); 29 } 30 }
运行结果如图所示。
文件1中,Person类中定义了两个构造方法,它们实现了方法的重载。在创建p1对象和p2对象时,根据传入参数个数的不同,分别调用了不同的构造方法。从图1可以看出,两个构造方法对属性赋值的情况是不一样的,其中p1对象只对age属性进行赋值,在调用speak()方法后,输出年龄信息;而p2对象对name和age属性进行赋值,在调用say()方法后,会输出姓名和年龄信息。
脚下留心
①在Java中的每个类都至少有一个构造方法,如果在一个类中没有显示地定义构造方法,系统会自动为这个类创建一个默认的构造方法,这个默认的构造方法没有参数,在其方法体中没有任何代码,即什么也不做。
下面程序中Person类的两种写法效果是完全一样的。
第一种写法:
class Person { }
第二种写法:
class Person { public Person() { } }
对于第一种写法,类中虽然没有显示地声明构造方法,但仍然可以用new Person()语句来创建Person类的实例对象。由于系统提供的无参构造方法往往不能满足需求,因此,可以自己在类中定义构造方法,一旦为该类定义了构造方法,系统将不再提供默认的无参构造方法。
如果将文件1中实例化p1对象时传入的参数18删除,会发现Eclipse中出现了构造器没有定义的错误提示,如图所示。
从图中可以看出,程序提示“The constructor Person() is undefined”,即构造器Person()未定义。其原因是调用new Person()创建Person类的实例对象时,需要调用无参的构造方法,而Person类中已经定义了两个有参的构造方法,此时系统将不再提供无参的构造方法,所以Person类中没有无参的构造方法。为了避免出现上面的错误,在一个类中如果定义了有参的构造方法,最好再定义一个无参的构造方法。
②思考一下,声明构造方法时,可以使用private访问修饰符吗?下面就来测试一下,看看会出现什么结果。
将文件中定义的第一个有参构造的修饰符public修改为private后,会发现Eclipse中又出现了一个错误,如图所示。
从图中可以看出,Eclipse中的错误提示为“The constructor Person(int) is not visible”,即构造方法Person(int)不可见。出现此错误的原因是被private访问控制符修饰的构造方法Person(int)只能在当前Person类中被访问无法在类的外部被访问,也就无法通过该私有构造方法来创建对象。因此,为了方便实例化对象,构造方法通常会使用public来修饰。
1.通过this关键字调用成员变量,解决与局部变量名称冲突问题。具体示例代码如下:
class Person { int age; // 成员变量age public Person(int age) { // 局部变量age this.age = age; // 将局部变量age的值赋给成员变量age } }
在上面的代码中,构造方法的参数被定义为age,它是一个局部变量,在类中还定义了一个成员变量,名称也是age。在构造方法中如果使用“age”,则是访问局部变量,但如果使用“this.age”则是访问成员变量。
2.通过this关键字调用成员方法,具体示例代码如下:
class Person { public void openMouth() { ... } public void speak() { this.openMouth(); } }
在上面的speak()方法中,使用this关键字调用了openMouth()方法。需要注意的是,此处的this关键字可以省略不写,也就是说上面的代码中,写成“this.openMouth()”和“openMouth()”效果是完全一样的。
3.通过this关键字调用构造方法。构造方法是在实例化对象时被Java虚拟机自动调用的,在程序中不能像调用其他方法一样去调用构造方法,但可以在一个构造方法中使用“this([参数1,参数2…])”的形式来调用其他的构造方法。
接下来通过一个案例来演示这种形式构造方法的调用。
1 class Person { 2 public Person() { 3 System.out.println("无参的构造方法被调用了..."); 4 } 5 public Person(int age) { 6 this(); // 调用无参的构造方法 7 System.out.println("有参的构造方法被调用了..."); 8 } 9 } 10 public class Example11 { 11 public static void main(String[] args) { 12 Person p = new Person(18); // 实例化 Person 对象 13 } 14 }
运行结果如图所示。
第12行代码在实例化Person对象时,调用了有参的构造方法,在有参构造方法中又通过this()调用了无参的构造方法,因此运行结果中显示两个构造方法都被调用了。
在使用this调用类的构造方法时,应注意以下几点:
①只能在构造方法中使用this调用其他的构造方法,不能在成员方法中使用。
②在构造方法中,使用this调用构造方法的语句必须是该方法的第一条执行语句,且只能出现一次。下面的写法是错误的:
public Person(int age) { System.out.println("有参的构造方法被调用了..."); this(); // 调用无参的构造方法。 }
在上述代码中,由于调用语句this()不是构造方法的第一条执行语句,所以Eclipse在编译时会报出“Constructor call must be the first statement in a constructor(调用构造函数必须是构造函数中的第一条语句)”的错误提示信息。
③不能在一个类的两个构造方法中使用this互相调用。下面的写法是错误的:
class Person { public Person() { this(18); // 调用有参的构造方法 System.out.println("无参的构造方法被调用了..."); } public Person(int age) { this(); // 调用无参的构造方法 System.out.println("有参的构造方法被调用了...") } }
在上述代码中,无参构造方法和有参构造方法分别使用了this关键字对方法进行了相互调用,此时在编译时,将会报出“Recursive constructor invocation Person()(递归调用构造函数Person())”的错误提示信息。
在定义一个类时,只是在描述某类事物的特征和行为,并没有产生具体的数据。只有通过new关键字创建该类的实例对象后,系统才会为每个对象分配内存空间,存储各自的数据。有时候,开发人员会希望某些特定的数据在内存中只有一份,而且能够被一个类的所有实例对象所共享。例如某个学校所有学生共享同一个学校名称,此时完全不必在每个学生对象所占用的内存空间中都声明一个变量来表示学校名称,而可以在对象以外的空间声明一个表示学校名称的变量,让所有对象来共享。具体内存中的分配情况如图所示。
静态变量可以使用如下语法来访问:
类名.变量名
文件1 Example12.java
1 class Student { 2 static String schoolName; // 声明静态变量schoolName 3 } 4 public class Example12 { 5 public static void main(String[] args) { 6 Student stu1 = new Student(); // 创建第1个学生对象 7 Student stu2 = new Student(); // 创建第2个学生对象 8 Student.schoolName = "清华大学"; // 为静态变量赋值 9 // 分别输出两个学生对象的信息 10 System.out.println("我是" + stu1.schoolName+"的学生"); 11 System.out.println("我是" + stu2.schoolName+"的学生"); 12 } 13 }
运行结果如图所示。
文件中,Student类中定义了一个静态变量schoolName,用于表示学生所在的学校,它被所有的实例对象所共享。由于schoolName是静态变量,因此可以直接使用Student.schoolName的方式进行调用,也可以通过Student的实例对象进行调用,如stu2.schoolName。在第8行代码将变量schoolName赋值为“清华大学”后,学生对象stu1和stu2的schoolName属性值均为“清华大学”。
脚下留心:static关键字注意事项
static关键字只能用于修饰成员变量,不能用于修饰局部变量,否则编译会报错
如果想要使用类中的成员方法,就需要先将这个类实例化,而在实际开发时,开发人员有时会希望在不创建对象的情况下就可以调用某个方法,这种情况就可以使用静态方法。
静态方法的定义十分简单,只需要在类中定义的方法前加上static关键字即可。在使用时,静态方法可以通过如下两种方式来访问:
类名.方法
或
实例对象名.方法
1 class Person { 2 public static void say() { // 定义静态方法 3 System.out.println("Hello!"); 4 } 5 } 6 public class Example13 { 7 public static void main(String[] args) { 8 // “类名.方法”的方式调用静态方法 9 Person.say(); 10 // 实例化对象 11 Person person = new Person(); 12 // “实例对象名.方法”的方式来调用静态方法 13 person.say(); 14 } 15 }
运行结果如图所示。
首先在Person类中定义了静态方法say(),然后在main()方法中分别使用了两种方式来调用静态方法。在第9行代码处通过“Person.say()”的形式调用了静态方法,由此可见静态方法不需要创建对象就可以直接通过类名调用。在第13行代码处通过实例化对象的方式来调用静态方法,这说明通过实例化的对象的方式,同样可以调用静态方法。
注意:
在一个静态方法中只能访问用static修饰的成员,原因在于没有被static修饰的成员需要先创建对象才能访问,而静态方法在被调用时可以不创建任何对象。
在Java类中,使用一对大括号包围起来的若干行代码被称为一个代码块,用static关键字修饰的代码块称为静态代码块。静态代码块的语法如下:
static { ... }
当类被加载时,静态代码块会执行,由于类只加载一次,因此静态代码块也只执行一次。在程序中,通常会使用静态代码块来对类的成员变量进行初始化。接下来通过一个案例来了解静态代码块的使用。
1 class Person{ 1 static { 2 System.out.println("执行了Person类中的静态代码块"); 3 } 4 } 5 public class Example14{ 6 static{ 7 System.out.println("执行了测试类中的静态代码块"); 8 } 9 public static void main(String[] args){ 10 // 实例化2个Person对象 11 Person p1 = new Person(); 12 Person p2 = new Person(); 13 } 14 }
运行结果如图1所示。
可以看出,程序中的两段静态代码块都执行了。运行文件后,Java虚拟机首先会加载类Example14,在加载类的同时就会执行该类的静态代码块,紧接着会调用main()方法。在main()方法中创建了两个Person对象,但在两次实例化对象的过程中,静态代码块中的内容只输出了一次,这就说明静态代码块在类第一次使用时才会被加载,并且只会加载一次。
想声明一个类继承另一个类,需要使用extends关键字,其基本语法格式如下:
[修饰符] class 子类名 extends 父类名 { // 程序核心代码 }
在上述语法格式中,类的修饰符是可选的,用来指定类的访问权限,可以使用public或者省略不写;子类名和父类名都是必选的,并且子类与父类之间要使用extends关键字实现继承关系。
1 // 定义Animal类 2 class Animal { 3 String name; // 声明name属性 4 // 定义动物叫的方法 5 void shout() { 6 System.out.println("动物发出叫声"); 7 } 8 } 9 // 定义Dog类继承Animal类 10 class Dog extends Animal { 11 // 定义一个打印name的方法 12 public void printName() { 13 System.out.println("name=" + name); 14 } 15 } 16 // 定义测试类 17 public class Example01 { 18 public static void main(String[] args) { 19 Dog dog = new Dog(); // 创建一个Dog类的实例对象 20 dog.name = "沙皮狗"; // 为dog对象的name属性进行赋值 21 dog.printName(); // 调用dog对象的printName()方法 22 dog.shout(); // 调用Dog类继承来的shout()方法 23 } 24 }
运行结果如图所示。
Dog类通过extends关键字继承了Animal类,这样Dog类便是Animal类的子类。可以看出,子类虽然没有声明name属性和shout()方法,但是却能访问这两个成员。这就说明,子类在继承父类的时候,会自动拥有父类所有公共的成员。
在实现类的继承中,需要注意一些问题,具体如下:
(1)在Java中,类只支持单继承,不允许多重继承,也就是说一个类只能有一个直接父类,例如下面这种情况是不合法的。
class A{} class B{} class C extends A,B{} // C类不可以同时继承A类和B类
(2)多个类可以继承同一个父类,例如下面这种情况是允许的。
class A{} class B extends A{} class C extends A{} // 类B和类C都可以继承类A
(3)在Java中,多层继承是可以的,即一个类的父类可以再去继承另外的父类,例如下面这种情况是允许的。
class A{} class B extends A{} // 类B继承类A,类B是类A的子类 class C extends B{} // 类C继承类B,类C是类B的子类,同时也是类A的子类
(4)在Java中,子类和父类是一种相对概念,也就是说,一个类是某个类父类的同时,也可以是另一个类的子类。例如上面的第3个示例中,B类是A类的子类,同时又是C类的父类。
子类中重写的方法需要和父类被重写的方法具有相同的方法名、参数列表以及返回值类型。注意:子类重写父类方法时,不能使用比父类中被重写的方法更严格的访问权限。如:父类中的方法访问权限是public,子类重写父类该方法的访问权限就不能是private
问题:当子类重写父类的方法后,子类对象将无法直接访问父类被重写的方法。
为了解决这个问题,在Java中专门提供了一个super关键字来访问父类的成员,例如访问父类的成员变量、成员方法和构造方法。下面分两种情况来学习一下super关键字的具体用法。
(1)使用super关键字调用父类的成员变量和成员方法,具体格式如下:
super.成员变量 super.成员方法([参数1,参数2...])
接下来通过一个案例来学习如何使用super关键字调用父类的成员变量和成员方法。
1 // 定义Animal类 2 class Animal { 3 String name = "动物"; 4 // 定义动物叫的方法 5 void shout() { 6 System.out.println("动物发出叫声"); 7 } 8 } 9 // 定义Dog类继承动物类 10 class Dog extends Animal { 11 String name = "犬类"; 12 // 重写父类的shout()方法 13 void shout() { 14 super.shout(); // 访问父类的成员方法 15 } 16 // 定义打印name的方法 17 void printName() { 18 System.out.println("name=" + super.name);// 访问父类的成员变量 19 } 20 } 21 // 定义测试类 22 public class Example03{ 23 public static void main(String[] args) { 24 Dog dog = new Dog(); // 创建一个dog对象 25 dog.shout(); // 调用dog对象重写的shout()方法 26 dog.printName(); // 调用dog对象的的printName()方法 27 } 28 }
运行结果如图所示。
定义了一个Dog类继承Animal类,重写了Animal类的shout()方法并重新定义了子类的name属性。在子类Dog的shout()方法中使用“super.shout()”调用了父类被重写的方法,在printName()方法中使用“super.name”访问父类的成员变量。从运行结果可以看出,子类通过super关键字成功地访问了父类成员变量和成员方法。
(2)使用super关键字调用父类的构造方法,具体格式如下:
super([参数1,参数2...])
接下来就通过一个案例来学习,如何使用super关键字来调用父类的构造方法。
1 // 定义Animal类 2 class Animal { 3 // 定义Animal类有参的构造方法 4 public Animal(String name) { 5 System.out.println("我是一只" + name); 6 } 7 } 8 // 定义Dog类继承Animal类 9 class Dog extends Animal { 10 public Dog() { 11 super("沙皮狗"); // 调用父类有参的构造方法 12 } 13 } 14 // 定义测试类 15 public class Example04 { 16 public static void main(String[] args) { 17 Dog dog = new Dog(); // 创建Dog类的实例对象 18 } 19 }
运行结果如图所示。
根据前面所学的知识,在创建Dog类对象时一定会调用Dog类的构造方法,从运行结果可以看出,Dog类的构造方法被调用时,执行了内部的super(“沙皮狗”)方法,从而调用了父类的有参构造方法。需要注意的是,通过super调用父类构造方法的代码必须位于子类构造方法的第一行,并且只能出现一次,否则程序在编译期间就会报错。
将文件1第11行代码进行注释,程序就会出现编译错误,如图所示。
可以看出,程序编译出现错误,显示“Implicit super constructor Animal() is undefined. Must explicitly invoke another constructor(未定义隐式无参构造方法,必须显示的调用另一个构造方法)”的错误。出错的原因是,在子类的构造方法中一定会调用父类的某个构造方法。这时可以在子类的构造方法中通过super关键字指定调用父类的哪个构造方法,如果没有指定,在实例化子类对象时,会默认调用父类无参的构造方法,而在文件2中,父类Animal中只定义了有参构造方法,未定义无参构造方法,所以在子类默认调用父类无参构造方法时就会出错。
为了解决上述程序的编译错误,可以在子类中显示地调用父类中已有的构造方法,或者在父类中定义无参的构造方法。现将Animal类进行修改,在父类中添加无参构造方法来解决上述编译错误。
1 // 定义Animal类 2 class Animal { 3 // 定义Animal无参的构造方法 4 public Animal() { 5 System.out.println("我是一只动物"); 6 } 7 // 定义Animal有参的构造方法 8 public Animal(String name) { 9 System.out.println("我是一只" + name); 10 } 11 } 12 // 定义Dog类,继承自Animal类 13 class Dog extends Animal { 14 // 定义Dog类无参的构造方法 15 public Dog() { 16 } 17 } 18 // 定义测试类 19 public class Example05 { 20 public static void main(String[] args) { 21 Dog dog = new Dog(); // 创建Dog类的实例对象 22 } 23 }
运行结果如图所示。
可以看出,子类在实例化时默认调用了父类无参的构造方法。通过这个案例还可以得出一个结论:在定义一个类时,如果没有特殊需求,当定义了有参构造方法后,尽量在类中再显示地定义一个无参构造方法,这样可以避免该类被继承时出现错误。
在Java中提供了一个Object类,它是所有类的父类,即每个类都直接或间接继承自该类,因此,Object类通常被称之为超类、基类或根类。当定义一个类时,如果没有使用extends关键字为这个类显示地指定父类,那么该类会默认继承Object类。
Object类中自定义了一些方法,其中常用的方法如表1所示。
表1 Object类的常用方法
方法声明 | 功能描述 |
---|---|
boolean equals(Object) | 判断某个对象与此对象是否相等 |
final Class<?> getClass() | 返回此Object的运行时类 |
int hashCode() | 返回该对象的哈希码值 |
String toString() | 返回该对象的字符串表示 |
void finalize() | 垃圾回收器调用此方法来清理没有被任何引用变量所引用对象的资源 |
了解了Object类中的常用方法后,下面以toString()方法为例来演示Object类中方法的使用。
1 // 定义Animal类 2 class Animal { 3 // 定义动物叫的方法 4 void shout() { 5 System.out.println("动物叫!"); 6 } 7 } 8 // 定义测试类 9 public class Example06 { 10 public static void main(String[] args) { 11 Animal animal = new Animal(); // 创建Animal类对象 12 System.out.println(animal.toString()); // 调用toString()方法并打印 13 } 14 }
运行结果如图1所示。
图1 运行结果
文件1中,第12行代码调用了Animal对象的toString()方法,虽然Animal类中并没有定义这个方法,但程序并没有报错。这是因为Animal默认继承了Object类,在Object类中定义了toString()方法,在该方法中输出了对象的基本信息。
Object类的toString()方法中输出信息具体格式如下:
getClass().getName() + “@” + Integer.toHexString(hashCode());
为了方便初学者理解上面的代码,接下来分别对其中用到的方法进行解释,具体如下:
● getClass().getName():代表返回对象所属类的类名,即包名+类名的全限定名称。
● hashCode():代表返回该对象的哈希值。
● Integer.toHexString(hashCode()):代表将对象的哈希值用16进制表示。其中,
hashCode()是Object类中定义的一个方法,这个方法将对象的内存地址进行哈希
运算,返回一个int类型的哈希值。
在实际开发中,通常希望toString()方法返回的不仅仅是对象的基本信息,而是一些特有的信息,为此可以通过重写Object的toString()方法来实现,如文件2所示。
文件2 Example07.java
1 // 定义Animal类 2 class Animal { 3 // 重写Object类的toString()方法 4 public String toString() { 5 return "这是一只动物"; 6 } 7 } 8 // 定义测试类 9 public class Example07 { 10 public static void main(String[] args) { 11 Animal animal = new Animal(); // 创建Animal对象 12 System.out.println(animal.toString()); // 打印animal的toString() 13 } 14 }
运行结果如图2所示。
图2 运行结果
文件2中,Animal类重写了Object类的toString()方法,当在main()方法中调用toString()方法时,就输出了Animal类的描述信息“这是一只动物”。
Java中的类被final关键字修饰后,该类将不可以被继承,也就是不能够派生子类。接下来通过一个案例来进行验证,如文件1所示。
文件1 Example08.java
1 // 使用final关键字修饰Animal类 2 final class Animal { 3 } 4 // Dog类继承Animal类 5 class Dog extends Animal { 6 } 7 // 定义测试类 8 public class Example08 { 9 public static void main(String[] args) { 10 Dog dog = new Dog(); // 创建Dog类的实例对象 11 } 12 }
程序编译报错,如图1所示。
图1 运行结果
文件1中,由于Animal类被final关键字所修饰,因此,当Dog类继承Animal类时,Eclipse的编辑器内出现了“The type Dog cannot subclass the final class Animal(无法从final类Animal进行继承)”的错误。由此可见,被final关键字修饰的类为最终类,不能被其他类继承。
当一个类的方法被final关键字修饰后,这个类的子类将不能重写该方法。接下来通过一个案例来进行验证,如文件1所示。
文件1 Example09.java
1 // 定义Animal类 2 class Animal { 3 // 使用final关键字修饰shout()方法 4 public final void shout() { 5 } 6 } 7 // 定义Dog类继承Animal类 8 class Dog extends Animal { 9 // 重写Animal类的shout()方法 10 public void shout() { 11 } 12 } 13 // 定义测试类 14 public class Example09 { 15 public static void main(String[] args) { 16 Dog dog=new Dog(); // 创建Dog类的实例对象 17 } 18 }
程序编译报错,如图1所示。
图1 运行结果
文件1中,Dog类重写父类Animal中的shout()方法后,程序出现了“Cannot override the final method from Animal(无法重写父类final方法)”的错误。由此可见,被final关键字修饰的方法为最终方法,子类不能对该方法进行重写。正是由于final的这种特性,当在父类中定义某个方法时,如果不希望被子类重写,就可以使用final关键字修饰该方法。
Java中被final修饰的变量被称之为常量,它只能被赋值一次,也就是说final修饰的变量一旦被赋值,其值不能改变。如果再次对该变量进行赋值,则程序会在编译时报错。接下来通过一个案例来进行验证,如文件1所示。
文件1 Example10.java
1 public class Example10 { 2 public static void main(String[] args) { 3 final int num = 2; // 第一次可以赋值 4 num = 4; // 再次赋值会报错 5 } 6 }
程序编译报错,如图1所示。
图1 运行结果
文件1中,当在第4行再次对num赋值时,程序出现了“The final local variable num cannot be assigned. It must be blank and not using a compound assignment(final变量num无法被重新赋值)”的错误。由此可见,被final修饰的变量为常量,它只能被赋值一次,其值不可改变。
文件1中,演示的是局部变量被final修饰的情况,当局部变量使用final关键字进行修饰时,可以在声明变量的同时对变量进行赋值,也可以先声明变量然后再进行有且只有一次的赋值。而当成员变量被final修饰时,在声明变量的同时必须进行初始化赋值,否则程序编译报错,接下来通过一个案例来演示final修饰成员变量和局部变量的情况,如文件2所示。
文件2 Example11.java
1 public class Example11 { 2 // final修饰的成员变量,必须在声明的同时进行赋值,否则编译错误 3 final int m; 4 public static void main(String[] args) { 5 // final修饰的局部变量,可以先声明,再进行一次赋值 6 final int n; 7 n = 4; 8 } 9 }
程序编译报错,如图2所示。
图2 运行结果
从图2可以看出,程序出现了编译错误,并提示“The blank final field m may not have been initialized(final修饰的变量m没有初始化)”。这是因为使用final关键字修饰成员变量时,Java虚拟机不会对其进行初始化。因此使用final修饰成员变量时,需要在定义变量的同时赋予一个初始值,下面将第3行代码修改为:
final int m = 0; // 为final修饰的成员变量m初始化赋值
保存后,Eclipse的编辑器将不会提示错误。
当定义一个类时,常常需要定义一些方法来描述该类的行为特征,但有时这些方法的实现方式是无法确定的。例如前面在定义Animal类时,shout()方法用于表示动物的叫声,但是不同的动物,叫声也是不同的,因此在shout()方法中无法准确描述动物的叫声。如何能使Animal类中既包含shout()方法,又无需提供其方法的实现呢?
针对上述这种情况,Java提供了抽象方法来满足这种需求。抽象方法必须使用abstract关键字来修饰,并且在定义方法时不需要实现方法体。当一个类中包含了抽象方法,那么该类也必须使用abstract关键字来修饰,这种使用abstract关键字修饰的类就是抽象类。
抽象类及抽象方法定义的基本语法格式如下:
// 定义抽象类 [修饰符] abstract class 类名 { // 定义抽象方法 [修饰符] abstract 方法返回值类型 方法名([参数列表]); // 其他方法或属性 }
需要注意的是,包含抽象方法的类必须定义为抽象类,但抽象类中可以不包含任何抽象方法。另外,抽象类是不可以被实例化的,因为抽象类中有可能包含抽象方法,抽象方法是没有方法体的,不可以被调用。如果想调用抽象类中定义的抽象方法,需要创建一个子类,在子类中实现抽象类中的抽象方法。
小提示:
定义抽象方法只需要在普通方法上增加abstract关键字,并把普通方法的方法体(花括号以及花括号中的部分)全部去掉,然后在方法名称后增加英文分号即可,例如public abstract void shout();。
接下来通过一个案例来学习如何实现抽象类中的方法,如文件1所示。
文件1 Example12.java
1 // 定义抽象类Animal 2 abstract class Animal { 3 // 定义抽象方法shout() 4 public abstract void shout(); 5 } 6 // 定义Dog类继承抽象类Animal 7 class Dog extends Animal { 8 // 实现抽象方法shout(),编写方法体 9 public void shout() { 10 System.out.println("汪汪……"); 11 } 12 } 13 // 定义测试类 14 public class Example12 { 15 public static void main(String[] args) { 16 Dog dog = new Dog(); // 创建Dog类的实例对象 17 dog.shout(); // 调用dog对象的shout()方法 18 } 19 }
运行结果如图1所示。
图1 运行结果
从图1可以看出,子类实现了父类的抽象方法后,已经可以正常进行实例化操作,通过实例化的对象即可调用实现的方法。
如果一个抽象类中的所有方法都是抽象的,则可以将这个类定义为Java中的另一种形式——接口。接口是一种特殊的抽象类,它不能包含普通方法,其内部的所有方法都是抽象方法,它将抽象进行的更为彻底。
在JDK 8中,对接口进行了重新定义,接口中除了抽象方法外,还可以有默认方法和静态方法(也叫类方法),默认方法使用default修饰,静态方法使用static修改,并且这两种方法都允许有方法体。
与定义类不同的是,在定义接口时,不再使用class关键字,而是使用interface关键字来声明。接口定义的基本语法格式如下:
[修饰符] interface 接口名 [extends 父接口1,父接口2,...] { [public] [static] [final] 常量类型 常量名 = 常量值; [public] [abstract] 方法返回值类型 方法名([参数列表]); [public] default 方法返回值类型 方法名([参数列表]){ // 默认方法的方法体 } [public] static 方法返回值类型 方法名([参数列表]){ // 类方法的方法体 } }
在上述语法格式中,“[]”中的内容都是可选的,修饰符可以使用public或直接省略(省略时默认采用包权限访问控制符);“extends 父接口1,父接口2,…”表示定义一个接口时,可以同时继承多个父接口,这也是为了解决类的单继承的限制;在接口内部可以定义多个常量和抽象方法,定义常量时必须进行初始化赋值,定义默认方法和静态方法时,可以有方法体。
小提示:
在接口中定义常量时,可以省略“public static final”修饰符,此时,接口会默认为常量添加“public static final”修饰符。与此类似,在接口中定义抽象方法时,也可以省略“public abstract”修饰符,定义default默认方法和static静态方法时,可以省略“public”修饰符,这些修饰符系统都会默认进行添加。
从接口定义的语法格式可以看出,接口中可以包含三类方法,抽象方法、默认方法、静态方法,其中静态方法可以通过“接口名.方法名”的形式来调用,而抽象方法和默认方法只能通过接口实现类的实例对象来调用,因此,需要定义一个接口的实现类,该类通过implements关键字实现当前接口,并实现接口中的所有抽象方法。需要注意的是,一个类可以在继承另一个类的同时实现多个接口,并且多个接口之间需要使用英文逗号(,)分隔。
定义接口的实现类语法格式如下:
[修饰符] class 类名 [extends 父类名] [implements 接口1,接口2,...] { ... }
了解了接口及其方法的定义方式后,接下来通过一个案例来学习接口的实现与方法调用,如文件1所示。
文件1 Example13.java
1 // 定义了Animal接口 2 interface Animal { 3 int ID = 1; // 定义全局常量 4 void breathe(); // 定义抽象方法breathe() 5 // 定义一个默认方法 6 default void getType(String type){ 7 System.out.println("该动物属于:"+type); 8 } 9 // 定义一个静态方法 10 static int getID(){ 11 return Animal.ID; 12 } 13 } 14 // Dog类实现了Animal接口 15 class Dog implements Animal { 16 // 实现breathe()方法 17 public void breathe() { 18 System.out.println("狗在呼吸"); 19 } 20 } 21 // 定义测试类 22 public class Example13 { 23 public static void main(String args[]) { 24 System.out.println(Animal.getID()); // 通过接口名调用类方法 25 Dog dog = new Dog(); // 创建Dog类的实例对象 26 System.out.println(dog.ID); // 在实现类中获取接口全局常量 27 dog.breathe(); // 调用dog对象的breathe()方法 28 dog.getType("犬科"); // 通过接口实现类Dog的实例化对象,调用接口默认方法 29 } 30 }
运行结果如图1所示。
图1 运行结果
文件1中,Dog类通过implements关键字实现了Animal接口,并实现了接口中的抽象方法breathe()。从图4-15可以看出,通过接口实现类Dog的实例化对象可以访问接口中的常量、接口实现方法以及默认方法,而接口中的静态方法则可以直接使用接口名调用。需要注意的是,接口的实现类,必须实现接口中的所有抽象方法,否则程序编译报错。
文件1中,演示的是类与接口之间的实现关系,其实,接口与接口之间还可以是继承关系,接口中的继承同样使用extends关键字来实现,接下来对文件1稍加修改,演示接口之间的继承关系,修改后的代码如文件2所示。
文件2 Example14.java
1 // 定义了Animal接口 2 interface Animal { 3 int ID = 1; // 定义全局常量 4 void breathe(); // 定义抽象方法breathe() 5 // 定义一个默认方法 6 default void getType(String type){ 7 System.out.println("该动物属于:"+type); 8 } 9 // 定义一个静态方法 10 static int getID(){ 11 return Animal.ID; 12 } 13 } 14 // 定义了LandAnimal接口,并继承了Animal接口 15 interface LandAnimal extends Animal { 16 void run(); // 定义抽象方法run() 17 } 18 // Dog类实现了LandAnimal接口 19 class Dog implements LandAnimal { 20 // 实现breathe()方法 21 public void breathe() { 22 System.out.println("狗在呼吸"); 23 } 24 // 实现run()方法 25 public void run() { 26 System.out.println("狗在陆地上跑"); 27 } 28 } 29 // 定义测试类 30 public class Example14 { 31 public static void main(String args[]) { 32 System.out.println(Animal.getID()); // 通过接口名调用类方法 33 Dog dog = new Dog(); // 创建Dog类的实例对象 34 System.out.println(dog.ID); // 在实现类中获取接口全局常量 35 dog.breathe(); // 调用dog对象的breathe()方法 36 dog.getType("犬科"); // 通过dog对象,调用接口默认方法 37 dog.run(); // 调用dog对象的run()方法 38 } 39 }
运行结果如图2所示。
图2 运行结果
文件2中,定义了两个接口,其中LandAnimal接口继承了Animal接口,因此LandAnimal接口包含了2个抽象方法。当Dog类实现LandAnimal接口时,就需要实现这2个抽象方法。从图2可以看出,接口实现类Dog的实例化对象可以调用接口中的成员。
为了加深初学者对接口的认识,接下来对接口的特点进行归纳,具体如下:
(1)在JDK 8之前,接口中的方法都必须是抽象的,并且方法不能包含方法体。在调用抽象方法时,必须通过接口的实现类的对象才能调用实现方法;从JDK 8开始,接口中的方法除了包含抽象方法外,还包含默认方法和静态方法,默认方法和静态方法都可以有方法体,并且静态方法可以直接通过“接口.方法名”来调用。
(2)当一个类实现接口时,如果这个类是抽象类,只需实现接口中的部分抽象方法即可,否则需要实现接口中的所有抽象方法。
(3)一个类可以通过implements关键字同时实现多个接口,被实现的多个接口之间要用英文逗号(,)隔开。
(4)接口之间可以通过extends关键字实现继承,并且一个接口可以同时继承多个接口,接口之间用英文逗号(,)隔开。
(5)一个类在继承一个类的同时还可以实现接口,此时,extends关键字必须位于implements关键字之前。具体示例如下:
class A extends B implements C { // 先继承,再实现 ... }
在Java中,多态是指不同类的对象在调用同一个方法时所呈现出的多种不同行为。通常来说,在一个类中定义的属性和方法被其他类继承或重写后,当把子类对象直接赋值给父类引用变量时,相同引用类型的变量调用同一个方法所呈现出的多种不同形态。通过多态,消除了类之间的耦合关系,大大提高了程序的可扩展性和可维护性。
Java的多态性是由类的继承、方法重写以及父类引用指向子类对象体现的。由于一个父类可以有多个子类,多个子类都可以重写父类方法,并且多个不同的子类对象也可以指向同一个父类。这样,程序只有在运行时程序才能知道具体代表的是哪个子类对象,这就体现了多态性。
在解了Java多态性的概念后,接下来通过一个案例来进行演示说明,如文件1所示。
文件1 Example15.java
1 // 定义抽象类Animal 2 abstract class Animal { 3 abstract void shout(); // 定义抽象shout()方法 4 } 5 // 定义Cat类继承Animal抽象类 6 class Cat extends Animal { 7 // 实现shout()方法 8 public void shout() { 9 System.out.println("喵喵……"); 10 } 11 } 12 // 定义Dog类继承Animal抽象类 13 class Dog extends Animal { 14 // 实现shout()方法 15 public void shout() { 16 System.out.println("汪汪……"); 17 } 18 } 19 // 定义测试类 20 public class Example15 { 21 public static void main(String[] args) { 22 Animal an1 = new Cat(); 23 Animal an2 = new Dog(); 24 an1.shout(); 25 an2.shout(); 26 } 27 }
运行结果如图1所示。
图1 运行结果
文件1中,首先定义了一个抽象类Animal和抽象方法,接着定义了两个类Cat和Dog继承了Animal,在第22~25行代码中,分别创建了Cat和Dog两个类对象同时指向一个父类对象,并调用shout()方法,程序在编译时自动识别具体的子类对象,从而选择性的调用对应的方法,这就是Java中多态性的体现。由此可见,多态不仅解决了方法同名的问题,而且还使程序变的更加灵活,从而有效的提高程序的可扩展性和可维护性。
在多态的学习中,涉及到将子类对象当做父类类型使用的情况,此种情况在Java的语言环境中称为“向上转型”,例如下面两行代码:
Animal an1 = new Cat(); // 将Cat类对象当做Animal类型来使用 Animal an2 = new Dog(); // 将Dog类对象当做Animal类型来使用
将子类对象当做父类使用时不需要任何显式地声明,需要注意的是,此时不能通过父类变量去调用子类特有的方法。
接下来通过一个案例来演示对象的类型转换情况,如文件1所示。
文件1 Example16.java
1 // 定义接口Animal 2 interface Animal { 3 void shout(); // 定义抽象shout()方法 4 } 5 // 定义Cat类实现Animal接口 6 class Cat implements Animal { 7 // 实现接口shout()方法 8 public void shout() { 9 System.out.println("喵喵……"); 10 } 11 // 定义Cat类特有的抓老鼠catchMouse()方法 12 public void catchMouse() { 13 System.out.println("小猫抓老鼠……"); 14 } 15 } 16 // 定义测试类 17 public class Example16 { 18 public static void main(String[] args) { 19 Animal an1 = new Cat(); 20 an1.shout(); 21 an1.catchMouse(); 22 } 23 }
程序编译报错,如图1所示。
图1 运行结果
从图1可以看出,程序编译出现了“The method catchMouse() is undefined for the type Anima(在父类Animal中未定义catchMouse()方法)”的错误。原因在于,创建Cat对象时指向了Animal父类类型,这样新创建的Cat对象会自动向上转型为Animal类,然后通过父类对象an1分别调用了shout()方法和子类Cat特有的catchMouse()方法,而catchMouse()方法是Cat类特有的,所以通过父类对象调用时,在编译期间就会报错。
文件1中,由于通过“new Cat();”创建的对象本质就是Cat类型,所以通过Cat类型的对象调用catchMouse()方法是可行的,因此要解决上面的问题,可以将父类类型的对象an1强转为Cat类型。接下来对文件1中的main()方法进行修改,具体代码如下:
// 定义测试类 public class Example16 { public static void main(String[] args) { Animal an1 = new Cat(); Cat cat = (Cat) an1; cat.shout(); cat.catchMouse(); } }
修改后再次编译,程序没有报错,运行结果如图2所示。
图2 运行结果
从图2可以看出,将本质为Cat类型的an1对象由Animal类型向下转型为Cat类型后,程序可以成功运行。需要注意的是,在进行对象向下类型转换时,必须转换为本质类型,否则转换时会出现错误,假如文件4-16中Animal类型引用指向的是一个Dog类型对象,这时进行强制类型转换为Cat类时就会出现出错,如文件2所示。
文件2 Example17.java
1 // 定义接口Animal 2 interface Animal { 3 void shout(); // 定义抽象shout()方法 4 } 5 // 定义Cat类实现Animal接口 6 class Cat implements Animal { 7 // 实现接口shout()方法 8 public void shout() { 9 System.out.println("喵喵……"); 10 } 11 // 定义Cat类特有的抓老鼠catchMouse()方法 12 public void catchMouse() { 13 System.out.println("小猫抓老鼠……"); 14 } 15 } 16 // 定义Dog类实现Animal接口 17 class Dog implements Animal { 18 // 实现接口shout()方法 19 public void shout() { 20 System.out.println("汪汪……"); 21 } 22 } 23 // 定义测试类 24 public class Example17 { 25 public static void main(String[] args) { 26 Animal an1 = new Dog(); 27 Cat cat = (Cat) an1; 28 cat.shout(); 29 cat.catchMouse(); 30 } 31 }
运行结果如图3所示。
图3 运行结果
文件2编译正常,但在运行时就会报错,提示Dog类型不能转换成Cat类型。出错的原因是,创建的Animal对象本质是一个Dog对象,在强制类型转换时,Dog类型的对象显然无法强转为Cat类型。
为了避免上述这种异常情况的发生,Java提供了一个关键字instanceof,它可以判断一个对象是否为某个类(或接口)的实例或者子类实例,语法格式如下:
对象(或者对象引用变量) instanceof 类(或接口)
接下来对文件2的测试类Example17进行修改,具体代码如下:
// 定义测试类 public class Example17 { public static void main(String[] args) { Animal an1 = new Dog(); if(an1 instanceof Cat){ // 判断an1本质类型 Cat cat = (Cat) an1; cat.shout(); cat.catchMouse(); }else{ System.out.println("该类型的对象不是Cat类型!"); } } }
再次运行程序,结果如图4所示。
图4 运行结果
在对文件2修改的代码中,使用instanceof关键字判断对象an1本质是否为Cat类型,如果是Cat类型就强制转换为Cat类型,否则就打印“该类型的对象不是Cat类型!”。由于判断的对象an1本质为Dog类型并非Cat类型,因此出现图4的运行结果。
在一个类中除了可以定义成员变量、成员方法,还可以定义类,这样的类被称作成员内部类。在成员内部类中,可以访问外部类的所有成员,包括成员变量和成员方法;在外部类中,同样可以访问成员内部类的变量和方法。
接下来通过一个案例来学习成员内部类的定义和使用,如文件1所示。
文件1 Example18.java
1 // 定义外部类Outer 2 class Outer { 3 int m = 0; // 定义外部类的成员变量 4 // 定义外部类成员方法 5 void test1() { 6 System.out.println("外部类成员方法"); 7 } 8 // 定义成员内部类Inner 9 class Inner { 10 int n = 1; 11 // 1、定义内部类方法,访问外部类成员变量和方法 12 void show1() { 13 System.out.println("外部类成员变量m="+m); 14 test1(); 15 } 16 void show2(){ 17 System.out.println("内部类成员方法"); 18 } 19 } 20 // 2、定义外部类方法,访问内部类变量和方法 21 void test2() { 22 Inner inner = new Inner(); 23 System.out.println("内部类成员变量n="+inner.n); 24 inner.show2(); 25 } 26 } 27 // 定义测试类 28 public class Example18 { 29 public static void main(String[] args) { 30 Outer outer = new Outer(); // 创建外部类对象 31 Outer.Inner inner = outer.new Inner(); // 创建内部类对象 32 inner.show1(); // 测试在成员内部类中访问外部类成员变量和方法 33 outer.test2(); // 测试在外部类中访问内部类成员变量和方法 34 } 35 }
运行结果如图1所示。
图1 运行结果
文件1中,定义了一个外部类Outer,并在该类中定义了成员变量、成员方法和成员内部类Inner。在成员内部类Inner中,编写了show1()方法来测试内部类对外部类成员变量和方法的调用;同时在外部类Outer中,编写了test2()方法来测试外部类对内部类变量和方法的调用。从图1可以看出,成员内部类可以访问外部类所有成员,同时外部类也可以访问成员内部类的所有成员。
需要注意的是,在文件1中第31行代码是通过外部类对象创建的内部类对象,这样就可以操作内部类中的成员。创建内部类对象的具体语法格式如下:
外部类名.内部类名 变量名 = new 外部类名().new 内部类名();
局部内部类,也叫做方法内部类,就是定义在某个局部范围中的类,它和局部变量一样,都是在方法中定义的,其有效范围只限于方法内部。
在局部内部类中,局部内部类可以访问外部类的所有成员变量和方法,而局部内部类中的变量和方法却只能在创建该局部内部类的方法中进行访问。接下来通过一个案例来学习局部内部类的定义和使用,如文件1所示。
文件1 Example19.java
1 // 定义外部类Outer 2 class Outer { 3 int m = 0; 4 void test1(){ 5 System.out.println("外部类成员方法"); 6 } 7 void test2() { 8 // 1、定义局部内部类Inner,在局部内部类中访问外部类变量和方法 9 class Inner { 10 int n = 1; 11 void show() { 12 System.out.println("外部类变量m="+m); 13 test1(); 14 } 15 } 16 // 2、在创建局部内部类的方法中,调用局部内部类变量和方法 17 Inner inner = new Inner(); 18 System.out.println("局部内部类变量n="+inner.n); 19 inner.show(); 20 } 21 } 22 // 定义测试类 23 public class Example19 { 24 public static void main(String[] args) { 25 Outer outer= new Outer(); 26 outer.test2(); // 通过外部类对象调用创建了局部内部类的方法 27 } 28 }
运行结果如图1所示。
图1 运行结果
文件1中,定义了一个外部类Outer,并在该内中定义了成员变量、成员方法,接着在外部类的成员方法test2()中定义了一个局部内部类Inner;然后在局部内部类Inner中,编写了show()方法来测试对外部类变量和方法的调用;同时在创建该局部内部类的test2()方法中创建了局部内部类对象来测试对局部内部类变量和方法的调用。
从图1可以看出,局部内部类可以访问外部类所有成员,而只有在包含局部内部类的方法中才可以访问内部类的所有成员。
所谓静态内部类,就是使用static关键字修饰的成员内部类。与成员内部类相比,在形式上,静态内部类只是在内部类前增加了static关键字,但在功能上,静态内部类中只能访问外部类的静态成员,同时通过外部类访问静态内部类成员时,可以跳过外部类从而直接通过内部类访问静态内部类成员。
创建静态内部类对象的基本语法格式如下:
外部类名.静态内部类名 变量名 = new 外部类名.静态内部类名();
接下来通过一个案例来学习静态内部类的定义和使用,如文件1所示。
文件1 Example20.java
1 // 定义外部类Outer 2 class Outer { 3 static int m = 0; // 定义外部类静态变量m 4 static class Inner { 5 void show() { 6 // 静态内部类访问外部类静态成员 7 System.out.println("外部类静态变量m="+m); 8 } 9 } 10 } 11 // 定义测试类 12 public class Example20 { 13 public static void main(String[] args) { 14 // 静态内部类可以直接通过外部类创建 15 Outer.Inner inner = new Outer.Inner(); 16 inner.show(); 17 } 18 }
运行结果如图1所示。
图1 运行结果
文件1中,定义了一个外部类Outer,并在该内中定义了静态成员变量和静态内部类Inner。然后在静态内部类Inner中,编写了show()方法来测试对外部类静态变量的调用。
在Java中调用某个方法时,如果该方法的参数是一个接口类型,除了可以传入一个参数接口实现类,还可以使用匿名内部类实现接口来作为该方法的参数。匿名内部类其实就是没有名称的内部类,在调用包含有接口类型参数的方法时,通常为了简化代码,不会创建一个接口的实现类作为方法参数传入,而是直接通过匿名内部类的形式传入一个接口类型参数,在匿名内部类中直接完成方法的实现。
创建匿名内部类的基本语法格式如下:
new 父接口(){ // 匿名内部类实现部分 }
接下来通过一个案例来学习匿名内部类的定义和使用,如文件1所示。
文件1 Example21.java
1 // 定义动物类接口 2 interface Animal { 3 void shout(); 4 } 5 public class Example21{ 6 public static void main(String[] args) { 7 String name = "小花"; 8 // 定义匿名内部类作为参数传递给animalShout()方法 9 animalShout(new Animal() { 10 // 实现shout()方法 11 public void shout() { 12 // JDK 8开始,局部内部类、匿名内部类可以访问非final的局部变量 13 System.out.println(name+"喵喵..."); 14 } 15 }); 16 } 17 //定义静态方法animalShout(),接收接口类型参数 18 public static void animalShout(Animal an) { 19 an.shout(); // 调用传入对象an的shout()方法 20 } 21 }
运行结果如图1所示。
图1 运行结果
文件1中,调用animalShout(Animal an)方法时需要一个Animal接口类型的参数,在第9~15行代码就使用了匿名内部类方式实现Animal接口并作为参数传入。
需要注意的是,在文件1中的匿名内部中类访问了局部变量name,而局部变量name并没有使用final修饰符修饰,程序也没有报错,这是JDK 8开始的新特性,允许在局部内部类、匿名内部类中访问非final修饰的局部变量,而在JDK 8之前,局部变量前必须加final修饰符,否则程序编译报错。
对于初学者而言,可能会觉得匿名内部类的写法比较难理解,接下来分两步来介绍匿名内部类的编写,具体如下:
1.在调用animalShout()方法时,在方法的参数位置写上new Animal(){},这相当于创建了一个实例对象,并将对象作为参数传给animalShout()方法。在new Animal()后面有一对大括号,表示创建的对象为Animal的子类实例,该子类是匿名的。具体代码如下所示:
animalShout(new Animal(){});
2.在大括号中编写匿名子类的实现代码,具体如下所示:
animalShout(new Animal() { public void shout() { System.out.println(name+"喵喵..."); } });
至此便完成了匿名内部类的编写。对于初学者而言不要求完全掌握这种写法,只需尽量理解语法就可以了。
匿名内部类存在的一个问题是,如果匿名内部类的实现非常简单,例如只包含一个抽象方法的接口,那么匿名内部类的语法仍然显得比较冗余。为此,JDK 8中新增了一个特性Lambda表达式,这种表达式只针对有一个抽象方法的接口实现,以简洁的表达式形式实现接口功能来作为方法参数。
一个Lambda表达式由三个部分组成,分别为参数列表、“->”和表达式主体,其语法格式如下:
([数据类型 参数名,数据类型 参数名,...]) -> {表达式主体}
从上述语法格式上看,Lambda表达式的书写非常简单,下面针对Lambda表达式的组成部分进行简单介绍,具体如下:
(1)([数据类型 参数名,数据类型 参数名,…]):用来向表达式主体传递接口方法需要的参数,多个参数名中间必须用英文逗号“,”进行分隔;在编写Lambda表达式时,可以省略参数的数据类型,后面的表达式主体会自动进行校对和匹配;同时,如果只有一个参数,则可以省略括号“()”。
(2)->:表示Lambda表达式箭牌,用来指定参数数据指向,不能省略,且必须用英文横线和大于号书写。
(3){表达式主体}:由单个表达式或语句块组成的主体,本质就是接口中抽象方法的具体实现,如果表达式主体只有一条语句,那么可以省略包含主体的大括号;另外,在Lambda表达式主体中允许有返回值,当只有一条return语句时,也可以省略return关键字。
学习了Lambda表达式的语法后,接下来对文件4-21进行进一步修改,来讲解Lambda表达式的基本使用,如文件1所示。
文件1 Example22.java
1 // 定义动物类接口 2 interface Animal { 3 void shout(); // 定义方法shout() 4 } 5 public class Example22 { 6 public static void main(String[] args) { 7 String name = "小花"; 8 // 1、匿名内部类作为参数传递给animalShout()方法 9 animalShout(new Animal() { 10 public void shout() { 11 System.out.println("匿名内部类输出:"+name+"喵喵..."); 12 } 13 }); 14 // 2、使用Lambda表达式作为参数传递给animalShout()方法 15 animalShout(()-> System.out.println("Lambda表达式输出:" 16 +name+"喵喵...")); 17 } 18 // 创建一个animalShout()静态方法,接收接口类型的参数 19 public static void animalShout(Animal an) { 20 an.shout(); 21 } 22 }
运行结果如图1所示。
图1 运行结果
文件1中,先定义了只有一个抽象方法的接口Animal,然后分别使用匿名内部类和Lambda表达式的方式实现了接口方法。从图1可以看出,使用匿名内部类和Lambda表达式都能实现接口中方法,但很显然使用Lambda表达式更加简洁和清晰。
虽然Lambda表达式可以实现匿名内部类的功能,但在使用时却有一个局限,即接口中有且只有一个抽象方法时才能使用Lamdba表达式代替匿名内部类。这是因为Lamdba表达式是基于函数式接口实现的,所谓函数式接口是指有且仅有一个抽象方法的接口,Lambda表达式就是Java中函数式编程的体现,只有确保接口中有且仅有一个抽象方法,Lambda表达式才能顺利地推导出所实现的这个接口中的方法。
在JDK 8中,专门为函数式接口引入了一个@FunctionalInterface注解,该注解只是显示的标注了接口是一个函数式接口,并强制编辑器进行更严格的检查,确保该接口是函数式接口,如果不是函数式接口,那么编译器就会报错,而对程序运行并没有实质上的影响,
接下来通过一个案例来演示函数式接口的定义与使用,如文件1所示。
文件1 Example23.java
1 // 定义无参、无返回值的函数式接口 2 @FunctionalInterface 3 interface Animal { 4 void shout(); 5 } 6 // 定义有参、有返回值的函数式接口 7 interface Calculate { 8 int sum(int a, int b); 9 } 10 public class Example23 { 11 public static void main(String[] args) { 12 // 分别两个函数式接口进行测试 13 animalShout(() -> System.out.println("无参、无返回值的函数式接口调用")); 14 showSum(10, 20, (x, y) -> x + y); 15 } 16 // 创建一个动物叫的方法,并传入接口对象Animal作为参数 17 private static void animalShout(Animal animal) { 18 animal.shout(); 19 } 20 // 创建一个求和的方法,并传入两个int类型以及接口Calculate类型的参数 21 private static void showSum(int x, int y, Calculate calculate) { 22 System.out.println(x + "+" + y + "的和为:" + calculate.sum(x, y)); 23 } 24 }
运行结果如图1所示。
图1 运行结果
文件1中,先定义了两个函数式接口Animal和Calculate,然后在测试类中分别编写了两个静态方法,并将这两个函数式接口以参数的形式传入,最后在main()方法中分别调用这两个静态方法,并将所需要的函数式接口参数以Lambda表达式的形式传入。从图1可以看出,程序中函数式接口的定义和使用完全正确。
Lambda表达式的主体只有一条语句时,程序不仅可以省略包含主体的花括号,还可以通过英文双冒号“::”的语法格式来引用方法和构造器(即构造方法),这两种形式可以进一步简化Lambda表达式的书写,其本质都是对Lambda表达式的主体部分已存在的方法进行直接引用,主要区别就是对普通方法与构造方法的引用而已。
在JDK 8中,Lambda表达式支持的引用类型主要有以下几种,如表1所示。
表1 Lambda表达式对普通方法和构造方法的引用形式
种类 | Lambda表达式示例 | 对应的引用示例 |
---|---|---|
类名引用普通方法 | (x,y,…)-> 对象名x.类普通方法名(y,…) | 类名::类普通方法名 |
类名引用静态方法 | (x,y,…) -> 类名.类静态方法名(x,y,…) | 类名::类静态方法名 |
对象名引用方法 | (x,y,…) -> 对象名.实例方法名(x,y,…) | 对象名::实例方法名 |
构造器引用 | (x,y,…) -> new 类名 (x,y,…) | 类名::new |
在了解了Lambda表达式支持的引用类型后,接下来分别通过案例对这几种引用的使用进行演示,由于这里类名引用普通方法的形式较为复杂,因此将会最后讲解。
1.类名引用静态方法
类名引用静态方法也就是通过类名对静态方法的引用,该类可以是Java自带的特殊类,也可以是自定义的普通类。接下来通过一个求绝对值的案例来演示类名(Math特殊类)引用静态方法的使用,如文件1所示。
文件1 Example24.java
1 //定义一个函数式接口 2 @FunctionalInterface 3 interface Calcable { 4 int calc(int num); 5 } 6 // 定义一个类,并在类中定义一个静态方法 7 class Math { 8 // 定义一个求绝对值方法 9 public static int abs(int num) { 10 if (num < 0) { 11 return -num; 12 } else { 13 return num; 14 } 15 } 16 } 17 // 定义测试类 18 public class Example24 { 19 private static void printAbs(int num, Calcable calcable) { 20 System.out.println(calcable.calc(num)); 21 } 22 public static void main(String[] args) { 23 // 使用Lambda表达式方式 24 printAbs(-10, n -> Math.abs(n)); 25 // 使用方法引用的方式 26 printAbs(-10, Math::abs); 27 } 28 }
运行结果如图1所示。
图1 运行结果
文件1中,先定义了一个函数式接口Calcable,以及一个包含静态方法的Math类,然后在测试类中编写了一个静态方法printAbs()和一个main()方法,最后在main()方法中分别使用了Lambda表达式和方法引用的方式作为静态方法printAbs()的参数进行调用。
从图1可以看出,通过Lambda表达式和类名引用静态方法的方式都可以实现程序功能,并且类名引用静态方法的实现方式显得更为简洁。
2.对象名引用方法
对象名引用方法指的是通过实例化对象的名称来对其方法进行的引用。接下来通过一个返回字符串所有字母大写的案例来演示对象名引用方法的使用,如文件2所示。
文件2 Example25.java
1 // 定义一个函数式接口 2 @FunctionalInterface 3 interface Printable{ 4 void print(String str); 5 } 6 class StringUtils { 7 public void printUpperCase(String str) { 8 System.out.println(str.toUpperCase()); 9 } 10 } 11 // 定义测试类 12 public class Example25 { 13 private static void printUpper(String text, Printable pt) { 14 pt.print(text); 15 } 16 public static void main(String[] args) { 17 StringUtils stu = new StringUtils(); 18 // 使用Lambda表达式方式 19 printUpper("Hello", t -> stu.printUpperCase(t)); 20 // 使用方法引用的方式 21 printUpper("Hello", stu::printUpperCase); 22 } 23 }
运行结果如图2所示。
图2 运行结果
文件2中,先定义了一个函数式接口Printable,以及一个包含非静态方法的StringUtils类,该类用于实现字母大写转换。然后在测试类中编写了一个静态方法printUpper()和一个main()方法,最后在main()方法中分别使用了Lambda表达式和方法引用的方式作为静态方法printUpper()的参数进行调用。
从图2可以看出,通过Lambda表达式和对象名名引用方法的方式都可以实现程序功能,并且对象名引用方法的实现方式显得更为简洁。
3.构造器引用方法
构造器引用指的是对类自带的构造器的引用。接下来通过一个构造方法获取属性的案例来演示构造器引用方法的使用,如文件3所示。
文件3 Example26.java
1 // 定义一个函数式接口 2 @FunctionalInterface 3 interface PersonBuilder { 4 Person buildPerson(String name); 5 } 6 // 定义一个Person类,并添加有参构造方法 7 class Person { 8 private String name; 9 public Person(String name) { 10 this.name = name; 11 } 12 public String getName() { 13 return name; 14 } 15 } 16 // 定义测试类 17 public class Example26 { 18 public static void printName(String name, PersonBuilder builder) { 19 System.out.println(builder.buildPerson(name).getName()); 20 } 21 public static void main(String[] args) { 22 // 使用Lambda表达式方式 23 printName("赵丽颖", name -> new Person(name)); 24 // 使用构造器引用的方式 25 printName("赵丽颖", Person::new); 26 } 27 }
运行结果如图3所示。
图3 运行结果
文件3中,先定义了一个函数式接口PersonBuilder以及一个包含有构造方法的类Person,然后在测试类中编写了一个静态方法printName()和一个main()方法,最后在main()方法中分别使用了Lambda表达式和构造器引用的方式作为静态方法printName()的参数进行调用。
从图3可以看出,通过Lambda表达式和构造器引用方法的方式都可以实现程序功能,并且构造器引用方法的实现方式显得更为简洁。
4.类名引用普通方法
类名引用普通方法指的是通过一个普通类的类名来对其普通方法进行的引用。接下来仍然通过一个返回字符串所有字母大写的案例来演示类名引用普通方法的使用,如文件4所示。
文件4 Example27.java
1 // 定义一个函数式接口 2 @FunctionalInterface 3 interface Printable{ 4 void print(StringUtils su, String str); 5 } 6 class StringUtils { 7 public void printUpperCase(String str) { 8 System.out.println(str.toUpperCase()); 9 } 10 } 11 // 定义测试类 12 public class Example27 { 13 private static void printUpper(StringUtils su, String text, 14 Printable pt) { 15 pt.print(su, text); 16 } 17 public static void main(String[] args) { 18 // 使用Lambda表达式方式 19 printUpper(new StringUtils(), "Hello", 20 (object, t) -> object.printUpperCase(t)); 21 // 使用方法引用的方式 22 printUpper(new StringUtils(), "Hello", 23 StringUtils::printUpperCase); 24 } 25 }
运行结果如图4所示。
图4 运行结果
文件4中,先定义了一个函数式接口Printable以及一个包含普通方法的类StringUtils,然后在测试类中编写了一个静态方法printUpper()和一个main()方法,最后在main()方法中分别使用了Lambda表达式和方法引用的方式作为静态方法printUpper()的参数进行调用。
从图4可以看出,通过Lambda表达式和类名引用普通方法的方式都可以实现程序功能,并且类名引用普通方法的实现方式显得更为简洁。
尽管人人都希望身体健康、事事顺利,但在实际生活中总会遇到各种状况,例如感冒发烧,工作时电脑蓝屏、死机等。同样在程序运行的过程中,也会发生这种非正常状况,例如程序运行时磁盘空间不足、网络连接中断、被加载的类不存在等。针对这些非正常情况,Java语言中引入了异常,以异常类的形式对这些非正常情况进行封装,并通过异常处理机制对程序运行时发生的各种问题进行处理。
接下来通过一个案例来认识一下什么是异常,如文件1所示。
文件1 Example28.java
1 public class Example28 { 2 // 下面的方法实现了两个整数相除 3 public static int divide(int x, int y) { 4 int result = x / y; // 定义一个变量result记录两个数相除的结果 5 return result; // 将结果返回 6 } 7 public static void main(String[] args) { 8 int result = divide(4, 0); // 调用divide()方法 9 System.out.println(result); 10 } 11 }
运行结果如图1所示。
图1 运行结果
从图1可以看出,程序发生了异常,显示“java.lang.ArithmeticException: / by zero(被0除的算术运算异常)”的错误信息。异常提示信息已经非常明确,指出程序中出现了除以0的算术异常。由于程序中的第8行代码调用divide()方法时传入了参数0,所以在文件中的第4行代码的运算中出现了被0除的问题。在这个异常发生后,程序会立即结束,无法继续向下执行。
在文件1中,产生了一个ArithmeticException异常,ArithmeticException异常只是Java异常类中的一种,在Java中还提供了大量的异常类,这些类都继承自java.lang.Throwable类。
接下来通过一张图来展示Throwable类的继承体系,如图2所示。
图2 Throwable异常体系结构图
从图2可以看出,Throwable有两个直接子类Error和Exception,其中Error代表程序中产生的错误,Exception代表程序中产生的异常。接下来就对这两个直接子类进行解释说明。
● Error类称为错误类,它表示Java运行时产生的系统内部错误或资源耗尽的错误,是比较严重的,仅靠修改程序本身是不能恢复执行的,例如系统崩溃,虚拟机错误等。
● Exception类称为异常类,它表示程序本身可以处理的错误。在Java程序开发中进行的异常处理,都是针对Excption类及其子类的。在Exception类的众多子类中有一个特殊的RuntimeException类,该类及其子类用于表示运行时异常。除了此类,Exception类下所有其他的子类都用于表示编译时异常。
通过前面的学习,读者已经了解了Throwable类及其子类,为了方便后面的学习,接下来将Throwable类中的常用方法罗列出来,如表1所示。
表1 Throwable常用方法
方法声明 | 功能描述 |
---|---|
String getMessage() | 返回此throwable的详细消息字符串 |
void printStackTrace() | 将此throwable及其追踪输出至标准错误流 |
void printStackTrace(PrintStream s) | 将此throwable及其追踪输出到指定的输出流 |
表1中,这些方法都用于获取异常信息,由于Error和Exception继承自Throwable类,所以它们都拥有这些方法,读者在后面的异常学习中会逐渐接触到这些方法的使用。
在实际开发中,经常会在程序编译时期产生一些异常,而这些异常必须要进行处理,这种异常被称为编译时期异常,也称为checked异常。另外还有一种异常是在程序运行时期产生的,这种异常即使不编写异常处理代码,依然可以通过编译,因此我们称之为运行时异常,也称为unchecked异常。接下来分别对这两种异常进行详细地讲解。
1.编译时异常
在Exception的子类中,除了RuntimeException类及其子类外,其他子类都是编译时异常。编译时异常的特点是在程序编写过程中,Java编译器就会对编写的代码进行检查,如果出现比较明显的异常就必须对异常进行处理,否则程序无法通过编译。
处理编译时异常有两种方式,具体如下:
● 使用try…catch语句对异常进行捕获处理;
● 使用throws关键字声明抛出异常,让调用者对其处理。
2.运行时异常
RuntimeException类及其子类都是运行时异常。运行时异常是在程序运行时由Java虚拟机自动进行捕获处理的,即使没有使用try…catch语句捕获或使用throws关键字声明抛出,程序也能编译通过,只是在运行过程中可能报错。
在Java中,常见的运行时异常有多种,如表1所示。
表1 常见运行时异常
异常类名称 | 异常类说明 |
---|---|
ArithmeticException, | 算术异常 |
IndexOutOfBoundsException | 角标越界异常 |
ClassCastException | 类型转换异常 |
NullPointerException | 空指针异常 |
NumberFormatException | 数字格式化异常 |
运行时异常一般是由于程序中的逻辑错误引起的,在程序运行时无法恢复。例如通过数组的角标访问数组的元素时,如果超过了数组的最大角标,就会发生运行时异常,代码示例如下:
int [] arr=new int[5]; System.out.println(arr[5]);
上面代码中,由于数组arr的长度为5,最大角标应为4,当使用arr[5]访问数组中的元素就会发生数组角标越界的异常。
当程序发生异常时,会立即终止,无法继续向下执行。为了保证程序能够有效的执行,Java中提供了一种对异常进行处理的方式——异常捕获。
异常捕获通常使用try…catch语句,其具体语法格式如下:
try { // 可能发生异常的语句 } catch(Exception类或其子类 e){ // 对捕获的异常进行相应处理 }
上述代码中,try{}代码块中包含的是可能发生异常的语句,catch(){}代码块中编写针对捕获的异常进行处理的代码。当try{}代码块中的程序发生了异常,系统会将这个异常的信息封装成一个异常对象,并将这个对象传递给catch(){}代码块。catch(){}代码块需要一个形参指明它所能够接收的异常类型,这个参数的类型必须是Exception类或其子类。
接下来使用try…catch语句对文件4-28中出现的异常进行捕获和处理,如文件1所示。
文件1 Example29.java
1 public class Example29 { 2 // 下面的方法实现了两个整数相除 3 public static int divide(int x, int y) { 4 try { 5 int result = x / y; // 定义一个变量result记录两个数相除的结果 6 return result; // 将结果返回 7 } catch (Exception e) { // 对异常进行捕获处理 8 System.out.println("捕获的异常信息为:" + e.getMessage()); 9 } 10 // 定义当程序发生异常直接返回-1 11 return -1; 12 } 13 public static void main(String[] args) { 14 int result = divide(4, 0); // 调用divide()方法 15 if(result == -1){ // 对调用方法返回结果进行判断 16 System.out.println("程序发生异常!"); 17 }else{ 18 System.out.println(result); 19 } 20 } 21 }
运行结果如图1所示。
图1 运行结果
文件1中,在定义的整数除法运算方法divide()中对可能发生异常的代码用try…catch语句进行了捕获处理。在try{}代码块中发生被0除异常,程序会转而执行catch(){}中的代码,通过调用Exception对象的getMessage()方法,即可返回异常信息“/ by zero”。catch(){}代码块对异常处理完毕后,程序仍会向下执行,而不会因为异常而终止运行。
需要注意的是,在try{}代码块中,发生异常语句后面的代码是不会被执行的,如文件4-29中第6行代码的return语句就没有执行。
在程序中,有时候会希望有些语句无论程序是否发生异常都要执行,这时就可以在try…catch语句后,加一个finally{}代码块。接下来对文件4-29进行修改,演示一下finally{}代码块的用法,如文件2所示。
文件2 Example30.java
1 public class Example30 { 2 // 下面的方法实现了两个整数相除 3 public static int divide(int x, int y) { 4 try { 5 int result = x / y; // 定义一个变量result记录两个数相除的结果 6 return result; // 将结果返回 7 } catch (Exception e) { // 对异常进行捕获处理 8 System.out.println("捕获的异常信息为:" + e.getMessage()); 9 } finally { 10 System.out.println("执行finally代码块,无论程序是否异常,都会执行"); 11 } 12 // 定义当程序发生异常直接返回-1 13 return -1; 14 } 15 public static void main(String[] args) { 16 int result = divide(4, 0); // 调用divide()方法 17 if(result == -1){ // 对调用方法返回结果进行判断 18 System.out.println("程序发生异常!"); 19 }else{ 20 System.out.println(result); 21 } 22 } 23 }
运行结果如图2所示。
图2 运行结果
文件2中,divide()方法中增加了一个finally{}代码块,用于处理无论程序是否发生异常都要执行的语句,该代码块并不受return语句和程序异常的影响。正是由于这种特殊性,在程序设计时,经常会在try…catch后使用finally{}代码块来完成必须做的事情,例如释放系统资源、关闭线程池等。
需要注意的是,finally中的代码在一种情况下是不会执行的,那就是在try…catch中执行了System.exit(0)语句。System.exit(0)表示退出当前的Java虚拟机,Java虚拟机停止了,任何代码都不能再执行了。
在前面章节案例中,定义除法运算时,开发者通常会意识到可能出现的异常,可以直接通过try…catch对异常进行捕获处理,但有些时候,方法中代码是否会出现异常,开发者并不明确或者并不急于处理,为此,Java允许将这种异常从当前方法中抛出,然后让后续的调用者在使用时再进行异常处理。
在Java中,将异常抛出需要使用throws关键字来实现,该关键字用在会抛出异常的方法名称后,同时支持一次性抛出多种类型的异常,基本语法格式如下:
[修饰符] 返回值类型 方法名([参数类型 参数名1...]) throws 异常类1,异常类2,... { // 方法体... }
从上述语法格式中可以看出,throws关键字需要写在方法声明的后面,throws后面需要声明方法中发生异常的类型,通常将这种做法称为方法声明抛出一个异常。接下来对上一节案例进行修改,在devide()方法上声明抛出异常,如文件1所示。
文件1 Example31.java
1 public class Example31 { 2 // 下面的方法实现了两个整数相除,并使用throws关键字声明抛出异常 3 public static int divide(int x, int y) throws Exception { 4 int result = x / y; // 定义变量result记录两个数相除的结果 5 return result; // 将结果返回 6 } 7 public static void main(String[] args) { 8 int result = divide(4, 0); // 调用divide()方法 9 System.out.println(result); 10 } 11 }
编译程序报错,结果如图1所示。
图1 运行结果
文件1中,在调用divide()方法时,由于该方法时声明抛出了异常,所以调用者在调用divide()方法时就必须进行处理,否则就会发生编译错误。从图1可以看出,Eclipse在程序编译时发生了“Unhandled exception type Exception(未处理的异常类型)”的问题,并且给出了两种快速解决的方案。其中“Add throws declaration”表示在方法上继续使用throws关键字抛出异常,而“Surround with try/catch”表示在出现异常的代码处使用try…catch代码块进行捕获处理。
下面对文件1进行修改,在调用divide()方法时对其进行try…catch捕获处理,如文件2所示。
文件2 Example32.java
1 public class Example32 { 2 // 下面的方法实现了两个整数相除,并使用throws关键字声明抛出异常 3 public static int divide(int x, int y) throws Exception { 4 int result = x / y; //定义一个变量result记录两个数相除的结果 5 return result; //将结果返回 6 } 7 public static void main(String[] args) { 8 try { 9 int result = divide(4, 0); //调用divide()方法 10 System.out.println(result); 11 } catch (Exception e) { //对捕获到的异常进行处理 12 System.out.println("捕获的异常信息为:" + e.getMessage()); 13 } 14 } 15 }
运行结果如图2所示。
图2 运行结果
文件2中,在调用divide()方法时,并没有对异常进行处理而是继续使用throws关键字将异常抛出。从运行结果可以看出,程序虽然可以通过编译,但在运行时由于没有对“/by zero”的异常进行处理,最终导致程序终止运行。
除了可以通过throws关键字抛出异常外,还可以使用throw关键字抛出异常。与throws有所不同的是,throw用于方法体内,并且抛出的是一个异常类对象,而throws关键字用在方法声明中,用来指明方法可能抛出的多个异常。
通过throw关键字抛出异常后,还需要使用throws关键字或try…catch对异常进行处理。需要注意的是,如果throw抛出的是Error、RuntimeException或它们的子类异常对象,则无需使用throws关键字或try…catch对异常进行处理。
使用throw关键字抛出异常的语法格式如下:
[修饰符] 返回值类型 方法名([参数类型 参数名,...]) throw 抛出的异常类 { // 方法体... throw new Exception类或其子类构造方法; }
接下来通过一个案例来演示throw关键字的使用,如文件1所示。
文件1 Example34.java
1 public class Example34 { 2 // 定义printAge()输出年龄 3 public static void printAge(int age) throws Exception { 4 if(age <= 0){ 5 // 对业务逻辑进行判断,当输入年龄为负数时抛出异常 6 throw new Exception("输入的年龄有误,必须是正整数!"); 7 }else { 8 System.out.println("此人年龄为:"+age); 9 } 10 } 11 public static void main(String[] args) { 12 // 下面的代码定义了一个try…catch语句用于捕获异常 13 int age = -1; 14 try { 15 printAge(age); 16 } catch (Exception e) { // 对捕获到的异常进行处理 17 System.out.println("捕获的异常信息为:" + e.getMessage()); 18 } 19 } 20 }
运行结果如图1所示。
图1 运行结果
文件1中,printAge()方法对输入的年龄进行了逻辑判断,虽然输入负数在语法上能够通过编译,并且程序能够正常运行,但这显然与现实情况不符,因此需要在方法中对输入的内容进行判断,当数值小于0时,使用throw关键字抛出异常,并指定异常提示信息,同时在方法后继续用throws关键字处理抛出的异常。
从图1可以看出,对于代码中的业务逻辑异常,使用throw关键字抛出异常后,同样可以正确捕获异常,从而保证程序的正常运行。当然,throw关键字除了可以抛出代码的逻辑性异常外,也可以抛出Java能够自动识别的异常。
Java中定义了大量的异常类,虽然这些异常类可以描述编程时出现的大部分异常情况,但是在程序开发中有时可能需要描述程序中特有的异常情况,例如在设计divide()方法时不允许被除数为负数。为了解决这样的问题,Java允许用户自定义异常,但自定义的异常类必须继承自Exception或其子类。
接下来通过一个案例来学习自定义异常的创建,如文件1所示。
文件1 DivideByMinusException.java
1 // 下面的代码是自定义一个异常类继承自Exception 2 public class DivideByMinusException extends Exception{ 3 public DivideByMinusException (){ 4 super(); // 调用Exception无参的构造方法 5 } 6 public DivideByMinusException (String message){ 7 super(message); // 调用Exception有参的构造方法 8 } 9 }
在实际开发中,如果没有特殊的要求,自定义的异常类只需继承Exception类,在构造方法中使用super()语句调用Exception的构造方法即可。
既然自定义了异常,那么该如何使用呢?这时就需要用到前面小节讲解的throw关键字,在程序指定位置通过throw关键字抛出自定义的异常对象,然后对抛出的异常进行异常处理。
接下来重新对divide()方法进行改写,在divide()方法中判断被除数是否为负数,如果为负数,就是用throw关键字向调用者抛出自定义的DivideByMinusException异常对象,如文件2所示。
文件2 Example36.java
1 public class Example36 { 2 // 下面的方法实现了两个整数相除, 3 public static int divide(int x,int y) throws DivideByMinusException { 4 if (y == 0) { 5 // 使用throw关键字声明异常对象 6 throw new DivideByMinusException("除数是0"); 7 } 8 int result = x / y; // 定义一个变量result记录两个数相除的结果 9 return result; // 将结果返回 10 } 11 public static void main(String[] args) { 12 try { 13 int result = divide(4, 0); 14 System.out.println(result); 15 } catch (DivideByMinusException e) { 16 System.out.println("捕获的异常信息为:" + e.getMessage()); 17 } 18 } 19 }
运行结果如图1所示。
图1 运行结果
在文件2中,divide()方法通过逻辑判断对除法运算的除数是否为0进行了判断,如果除数为0就使用throw关键字抛出自定义的DivideByMinusException异常对象,然后通过throws关键字抛出异常,并在最后通过try…catch语句捕获异常。从图2可以看出,程序执行后判断出除数为0,抛出了指定的异常信息。
在Java中,当一个对象成为垃圾后仍会占用内存空间,时间一长,就会导致内存空间的不足。针对这种情况,Java中引入了垃圾回收机制(Java GC)。有了这种机制,程序员不需要过多关心垃圾对象回收的问题,Java虚拟机会自动回收垃圾对象所占用的内存空间。
当一个对象在堆内存中运行时,根据它被引用变量所引用的状态,可以把它所处的状态分成如下三种:
● 可用状态:当一个对象被创建后,如果有一个以上的引用变量引用它,那么这个对象在程序中将处于可用状态,程序可以通过引用变量来调用该对象的实例变量和方法。
● 可恢复状态:如果程序中某个对象不再有任何引用变量引用它,它就进入了可恢复状态。在这种状态下,系统的垃圾回收机制准备回收该对象所占用的内存,在回收该对象之前,系统会调用所有可恢复状态对象的finalize()方法进行资源清理。如果系统在调用finalize()方法前重新使一个引用变量引用该对象,则这个对象会再次变为可用状态;否则该对象将进入不可用状态。
● 不可用状态:当对象失去了所有引用变量的关联,且系统已经调用所有对象的finalize()方法后依然没有使该对象变成可用状态,那么这个对象将永久的失去引用,变成不可用状态。只有当一个对象处于不可用状态时,系统才会真正的回收该对象所占用的内存空间。
上述三种状态的转换示意图如1所示。
图1 对象的状态转换
一个对象在彻底失去引用成为垃圾后会暂时地保留在内存中,当这样的垃圾堆积到一定程度时,Java虚拟机就会启动垃圾回收器将这些垃圾对象从内存中释放,从而使程序获得更多可用的内存空间。虽然通过程序可以控制一个对象何时不再被任何引用变量所引用,但是却无法精确的控制Java垃圾回收的时机。除了等待Java虚拟机进行自动垃圾回收外,还可以通过如下两种方式强制系统进行垃圾回收。
● 调用System类的gc()静态方法:System.gc()。
● 调用Runtime对象的gc()实例方法:Runtime.getRuntime().gc()。
实际上,调用System.gc()方法时,所执行的也是Runtime.getRuntime().gc()方法。需要说明的是,调用这两种方式可以强制启动垃圾回收器进行垃圾回收,但系统是否立即进行垃圾回收依然具有不确定性。大多数情况下,强制系统垃圾回收后总是有一定的效果。
当一个对象在内存中被释放时,它的finalize()方法会被自动调用,finalize()方法是定义在Object类中的实例方法,其方法原型如下:
protected void finalize() throws Throwable { }
任何Java类都可以重写Object类的finalize()方法,在该方法中清理该对象占用的资源。如果程序终止之前仍然没有进行垃圾回收,则不会调用失去引用对象的finalize()方法来清理资源。
需要注意的是,只有当程序认为需要更多的额外内存时,垃圾回收器才会自动进行垃圾回收,而在一些情况下,对象的finalize()方法并不一定会被调用,例如某个失去了引用的对象只占用了少量的内存,而系统并没有严重的内存需求,此时垃圾回收机制就可能不会回收该对象所占用的资源,所以该对象的finalize()方法也就不会被调用。
接下来通过一个案例来演示Java虚拟机进行垃圾回收的过程,如文件1所示。
文件1 Example37.java
1 class Person { 2 // 下面定义的finalize()方法会在垃圾回收前被调用 3 public void finalize() { 4 System.out.println("对象将被作为垃圾回收..."); 5 } 6 } 7 public class Example37 { 8 // 1、演示一个不通知强制垃圾回收的方法 9 public static void recyclegWaste1(){ 10 Person p1 = new Person(); 11 p1 = null; 12 int i = 1; 13 while (i < 10) { 14 System.out.println("方法1循环中..........."); 15 i++; 16 } 17 } 18 // 2、演示一个通知强制垃圾回收的方法 19 public static void recyclegWaste2(){ 20 Person p2 = new Person(); 21 p2 = null; 22 // 通知垃圾回收器进行强制垃圾回收 23 System.gc(); 24 // Runtime.getRuntime().gc(); 25 int i = 1; 26 while (i < 10) { 27 System.out.println("方法2循环中..........."); 28 i++; 29 } 30 } 31 public static void main(String[] args) { 32 // 分别调用两个模拟演示垃圾回收的方法 33 recyclegWaste1(); 34 System.out.println("================"); 35 recyclegWaste2(); 36 } 37 }
运行结果如图2所示。
图2 运行结果
文件1中,Person类重写了finalize()方法,并在方法中编写了一条输出语句来查看垃圾回收时的执行效果。在测试类中先分别创建了两个演示垃圾回收的方法recyclegWaste1()和recyclegWaste2(),在recyclegWaste1()方法中将一个新创建的对象引用去除后并没有强制调用垃圾回收器,而在recyclegWaste2()方法中将一个新创建的对象引用去除后就立即强制调用垃圾回收器进行垃圾回收,并在两个方法中都添加了一个while循环来模拟程序中其他执行过程。最后,在main()方法中按照顺序先后调用了recyclegWaste1()和recyclegWaste2()方法。
从图2可以看出,在recyclegWaste1()方法中,一个对象失去了引用不会立即作为垃圾被回收,即使整个方法结束也一样;而在recyclegWaste2()方法中,一个对象失去了引用后,就会立即启动垃圾回收器进行垃圾回收,但这个过程也不是瞬间进行回收垃圾的,而是在程序继续执行过程中陆续将所有垃圾进行回收,包括当前recyclegWaste2()方法以及recyclegWaste1()方法产生的垃圾。
如果在文件1中的main()方法中,将调用的两个演示垃圾回收的方法recyclegWaste1()和recyclegWaste2()方法执行顺序互换,再次运行程序,结果如图3所示。
图3 运行结果
从图3可以看出,整个执行过程中只有一条“对象将被作为垃圾回收”的输出信息,说明该过程中只立即执行了一次强制垃圾回收,也就是强制回收了recyclegWaste2()方法中失去引用的垃圾对象p2。其原因是,在main()方法中先调用了recyclegWaste2()方法,在该方法中通知垃圾回收器进行强制垃圾回收时,只发现了垃圾对象p2,所以会在某个时间进行垃圾回收,而在执行了recyclegWaste1()方法时,又产生了一个垃圾对象p1,此时却没有再次通知垃圾回收器,所以不会立即进行垃圾回收。
在操作String类之前,首先需要对String类进行初始化。在Java中,可以通过以下两种方式对String类进行初始化,具体如下:
1.使用字符串常量直接初始化一个String对象,其语法格式如下:
String 变量名= 字符串;
在初始化字符串对象时,既可以将字符串对象的初始化值设为空,也可以初始化为一个具体的字符串,其示例如下:
String str1 = null; // 初始化为空 String str2 = ""; // 初始化为空字符串 String str3 = "abc"; // 初始化为abc,其中abc为字符串常量
2.使用String的构造方法初始化字符串对象,其语法格式如下:
String 变量名 = new String(字符串);
在上述语法中,字符串同样可以为空或是一个具体的字符串。当为具体字符串时,会使用String类的不同参数类型的构造方法来初始化字符串对象。
String类中包含多个构造方法,常用的构造方法如表1所示。
表1 String类的常用构造方法
方法声明 | 功能描述 |
---|---|
String() | 创建一个内容为空的字符串 |
String(String value) | 根据指定的字符串内容创建对象 |
String(char[] value) | 根据指定的字符数组创建对象 |
表1中,列出了String类的三种构造方法,通过调用不同参数的构造方法便可完成String类的初始化。接下来通过一个案例来学习String类是如何通过构造方法来初始化字符串对象的,如文件1所示。
文件1 Example01.java
1 public class Example01 { 2 public static void main(String[] args) { 3 // 创建一个空的字符串 4 String str1 = new String(); 5 // 创建一个内容为abc的字符串 6 String str2 = new String("abc"); 7 // 创建一个内容为字符数组的字符串 8 char[] charArray = new char[] { 'A', 'B', 'C' }; 9 String str3 = new String(charArray); 10 // 输出结果 11 System.out.println("a" + str1 + "b"); 12 System.out.println(str2); 13 System.out.println(str3); 14 } 15 }
运行结果如图1所示。
图1 运行结果
文件1中,分别使用表1中的3个构造方法创建了字符串对象。其中第4行代码使用无参构造方法创建的是一个空字符串,所以第一个输出语句中的str1为空(””),当使用连字符(+)连接a和b后,输出的结果为ab。第6行代码使用参数类型为String的构造方法创建了一个内容为abc的字符串,第8~9行代码使用参数类型为字符数组的构造方法创建了一个内容为字符数组的字符串。从图1可以看出,它们最后的输出结果就是存储在字符串对象中的内容。
小提示:
连接字符串可以通过运算符“+”来实现,例如上面案例代码(“a” + str1 + “b”)中的“+”的作用就是将两个字符串合并到一起并生成新的字符串。在Java中,如果“+”的两边操作数有一个为String类型,那么“+”就表示字符串连接运算符。
String类在实际开发中的应用非常广泛,因此灵活地使用String类是非常重要的,接下来讲解一下String类常用的一些方法,如表1所示。
表1 String类的常用方法
方法声明 | 功能描述 |
---|---|
int indexOf(int ch) | 返回指定字符在此字符串中第一次出现处的索引 |
int lastIndexOf(int ch) | 返回指定字符在此字符串中最后一次出现处的索引 |
int indexOf(String str) | 返回指定子字符串在此字符串中第一次出现处的索引 |
int lastIndexOf(String str) | 返回指定子字符串在此字符串中最后一次出现处的索引 |
char charAt(int index) | 返回字符串中index位置上的字符,其中index的取值范围是:0~(字符串长度-1) |
boolean endsWith(String suffix) | 判断此字符串是否以指定的字符串结尾 |
int length() | 返回此字符串的长度 |
boolean equals(Object anObject) | 将此字符串与指定的字符串比较 |
boolean isEmpty() | 当且仅当字符串长度为0时返回true |
boolean startsWith(String prefix) | 判断此字符串是否以指定的字符串开始 |
boolean contains(CharSequence cs) | 判断此字符串中是否包含指定的字符序列 |
String toLowerCase() | 使用默认语言环境的规则将String中的所有字符都转换为小写 |
String toUpperCase() | 使用默认语言环境的规则将String中的所有字符都转换为大写 |
static String valueOf(int i) | 返回 int 参数的字符串表示形式 |
char[] toCharArray() | 将此字符串转换为一个字符数组 |
String replace(CharSequence oldstr, CharSequence newstr) | 返回一个新的字符串,它是通过用newstr替换此字符串中出现的所有oldstr得到的 |
String[] split(String regex) | 根据参数regex(regex是一个正则表达式,用来限定分隔规则)将字符串分割为若干个子字符串 |
String substring(int beginIndex) | 返回一个新字符串,它包含从指定的beginIndex起始角标处开始,直到此字符串末尾的所有字符 |
String substring(int beginIndex, int endIndex) | 返回一个新字符串,它包含从指定的beginIndex起始角标处开始,直到索引endIndex-1角标处的所有字符 |
String trim() | 返回一个新字符串,它去除了原字符串首尾的空格 |
在表1中,列出了String类常用的方法,为了让读者更加熟悉这些方法的作用,接下来通过几个案例来具体学习一下String类中常用方法的使用。
1.字符串的基本操作
在程序中,需要对字符串进行一些基本操作,如获得字符串长度、获得指定位置的字符等。String类针对每一个操作都提供了对应的方法,接下来通过一个案例来学习这些方法的使用,如文件1所示。
文件1 Example02.java
1 public class Example02 { 2 public static void main(String[] args) { 3 String s = "abcabcbacdba"; // 初始化字符串 4 System.out.println("字符串的长度为:" + s.length()); 5 System.out.println("字符串中第一个字符:" + s.charAt(0)); 6 System.out.println("字符c第一次出现的位置:" + s.indexOf('c')); 7 System.out.println("字符c最后一次出现的位置:" + s.lastIndexOf('c')); 8 System.out.println("子字符串第一次出现的位置:" + s.indexOf("ab")); 9 System.out.println("子字符串最后一次出现的位置:" 10 + s.lastIndexOf("ab")); 11 } 12 }
运行结果如图1所示。
图1 运行结果
从图1可以看出,String类提供的方法可以很方便地获取字符串的长度,获取指定位置的字符以及指定字符和字符串的位置。
2.字符串的转换操作
程序开发中,经常需要对字符串进行转换操作,例如将字符串转换成字符数组、将字符串中的字符进行大小写转换等。接下来通过一个案例来演示字符串的转换操作,如文件2所示。
文件2 Example03.java
1 public class Example03 { 2 public static void main(String[] args) { 3 String str = "java"; 4 char[] charArray = str.toCharArray(); // 字符串转换为字符数组 5 System.out.print("将字符串转为字符数组后的遍历结果:"); 6 for (int i = 0; i < charArray.length; i++) { 7 if (i != charArray.length - 1) { 8 // 如果不是数组的最后一个元素,在元素后面加逗号 9 System.out.print(charArray[i] + ","); 10 } else { 11 // 数组的最后一个元素后面不加逗号 12 System.out.println(charArray[i]); 13 } 14 } 15 System.out.println("将int值转换为String类型之后的结果:" 16 + String.valueOf(12)); 17 System.out.println("将字符串转换成大写之后的结果:" 18 + str.toUpperCase()); 19 } 20 }
运行结果如图2所示。
图2 运行结果
在文件2中,使用String类的toCharArray()方法将一个字符串转为一个字符数组,静态方法valueOf()将一个int类型的整数转为字符串,toUpperCase()方法将字符串中的字符都转为大写。其中valueOf()方法有很多重载的形式,float、double、char等其他基本类型的数据都可以通过该方法转为String字符串类型。
3.字符串的替换和去除空格操作
在开发程序的过程中,需要考虑到用户输入数据时会有一些错误和空格的情况,这时可以使用String类的replace()和trim()方法,进行字符串的替换和去除空格操作。接下来通过一个案例来学习这两个方法的使用,如文件3所示。
文件3 Example04.java
1 public class Example04 { 2 public static void main(String[] args) { 3 String s = " http :// localhost : 8080 "; 4 // 字符串去除空格操作 5 System.out.println("去除字符串两端空格后的结果:" + s.trim()); 6 // 字符串替换操作 7 System.out.println("去除字符串中所有空格后的结果:" 8 + s.replace(" ", "")); 9 } 10 }
运行结果如图3所示。
图3 运行结果
在文件3中,调用了String类的两个方法,其中trim()方法用于去除字符串中首尾的空格,replace()方法用于将字符串中所有与指定字符串匹配的子串替换成另一个字符串。
需要注意的是,trim()方法只能去除两端的空格,不能去除中间的空格。若想去除字符串中间的空格,则可以通过String类的replace()方法来实现。
4.字符串的判断操作
操作字符串时,经常需要对字符串进行一些判断,如判断字符串是否以指定的字符串开始、结束,是否包含指定的字符串,字符串是否为空等。在String类中针对字符串的判断操作提供了很多方法,接下来通过一个案例来学习这些判断方法的使用,如文件4所示。
文件4 Example05.java
1 public class Example05 { 2 public static void main(String[] args) { 3 String s1 = " Starter"; // 声明一个字符串 4 String s2 = "St"; 5 System.out.println("判断是否以字符串St开头:" + s1.startsWith("St")); 6 System.out.println("判断是否以字符串er结尾:" + s1.endsWith("er")); 7 System.out.println("判断是否包含字符串ar:" + s1.contains("ar")); 8 System.out.println("判断字符串是否为空:" + s1.isEmpty()); 9 System.out.println("判断两个字符串是否相等" + s1.equals(s2)); 10 } 11 }
运行结果如图4所示。
图4 运行结果
在文件4中涉及到的方法都是用于判断字符串的,并且返回值均为boolean类型。在所使用的方法中,equals()方法是比较重要的,在String类中重写了父类Object中的equals()方法。
在程序中可以通过==
和equals()两种方式对字符串进行比较,但这两种方式有显著的区别。equals()方法用于比较两个字符串中的字符值是否相等,==
方式用于比较两个字符串对象的内存地址是否相同。对于两个字符串对象,当它们的字符值完全相同时,使用equals判断结果会为true,但使用==
判断时,结果一定为false。为了便于理解,下面给出示例代码:
String str1 = new String("abc"); String str2 = new String("abc"); // 结果为false,因为str1和str2是两个对象 System.out.println(str1 == str2); // 结果为true,因为str1和str2字符内容相同 System.out.println(str1.equals(str2));
5.字符串的截取和分割
在String类中针对字符串的截取和分割操作提供了两个方法,其中,substring()方法用于截取字符串的一部分,split()方法可以将字符串按照某个字符进行分割。接下来通过一个案例来学习这两个方法的使用,如文件5所示。
文件5 Example06.java
1 public class Example06 { 2 public static void main(String[] args) { 3 String str = "2018-01-24"; 4 // 下面是字符串截取操作 5 System.out.println("从第6个字符截取到末尾的结果:" 6 + str.substring(5)); 7 System.out.println("从第6个字符截取到第7个字符的结果:" 8 +str.substring(5, 7)); 9 // 下面是字符串分割操作 10 System.out.print("分割后的字符串数组中的元素依次为:"); 11 // 通过横线连接符“—”将字符串转换为字符串数组 12 String[] strArray = str.split("-"); 13 // 循环输出数组中的元素 14 for (int i = 0; i < strArray.length; i++) { 15 if (i != strArray.length - 1) { 16 // 如果不是数组的最后一个元素,在元素后面加顿号 17 System.out.print(strArray[i] + "、"); 18 } else { 19 // 数组的最后一个元素后面不加顿号 20 System.out.println(strArray[i]); 21 } 22 } 23 } 24 }
运行结果如图5所示。
图5 运行结果
在文件5中,调用了String类中重载的两个substring()方法,在第6行代码调用substring(5)方法时,因为字符串中的字符索引是从0开始的,所以会截取字符串中第6个字符以及之后的所有字符;第8行代码调用substring(5,7)方法时,会截取第6个和第7个字符。文件中的第12~22行代码演示了split()方法的用法,该方法会根据指定的符号“-”将字符串分割成了三部分,并存放到一个String类型的数组当中。使用for循环遍历数组即可按照要求输出所需内容,这里将各个日期之间使用顿号分隔。
脚下留心:
String字符串在获取某个字符时,会用到字符的索引,当访问字符串中的字符时,如果字符的索引不存在,则会发生StringIndexOutOfBoundsException(字符串角标越界异常)。
接下来通过一个案例来演示这种异常,如文件6所示。
文件6 Example07.java
1 public class Example07 { 2 public static void main(String[] args) { 3 String s = "abcde"; 4 System.out.println(s.charAt(10)); 5 } 6 }
运行结果如图6所示。
图6 运行结果
从图6可以看出,访问字符串中的字符时,不能超出字符的索引范围,否则会出现异常,这与数组中的角标越界异常相似。
在Java中,由于String类是final类型的,所以使用String定义的字符串是一个常量,因此它一旦创建,其内容和长度是不可改变的。如果需要对一个字符串进行修改,则只能创建新的字符串。为了便于对字符串进行修改,在JDK中提供了一个StringBuffer类(也称字符串缓冲区)来操作字符串。StringBuffer类和String类最大的区别在于它的内容和长度都是可以改变的。StringBuffer类似一个字符容器,当在其中添加或删除字符时,所操作的都是这个字符容器,因此并不会产生新的StringBuffer对象。
针对添加和删除字符的操作,StringBuffer类提供了一系列的方法,如表1所示。
表1 StringBuffer类常用方法
方法声明 | 功能描述 |
---|---|
StringBuffer append(char c) | 添加字符到StringBuffer对象中末尾 |
StringBuffer insert(int offset,String str) | 在StringBuffer对象中的offset位置插入字符串str |
StringBuffer deleteCharAt(int index) | 移除StringBuffer对象中指定位置的字符 |
StringBuffer delete(int start,int end) | 删除StringBuffer对象中指定范围的字符或字符串 |
StringBuffer replace(int start,int end,String s) | 将StringBuffer对象中指定范围的字符或字符串用新的字符串s进行替换 |
void setCharAt(int index, char ch) | 修改指定位置index处的字符 |
String toString() | 返回StringBuffer缓冲区中的字符串对象 |
StringBuffer reverse() | 将此StringBuffer对象用其反转形式取代 |
在表1中,列出了StringBuffer类的一系列常用方法,对于初学者来说比较难以理解。接下来通过一个案例来学习一下表中方法的具体使用,如文件1所示。
文件1 Example08.java
1 public class Example08 { 2 public static void main(String[] args) { 3 System.out.println("1、添加------------------------"); 4 add(); 5 System.out.println("2、修改------------------------"); 6 update(); 7 System.out.println("3、删除------------------------"); 8 delete(); 9 } 10 // 添加 11 public static void add() { 12 StringBuffer sb = new StringBuffer(); // 定义一个字符串缓冲区 13 sb.append("ABC"); // 添加字符串 14 System.out.println("append添加结果:" + sb); 15 sb.insert(3, "DE"); // 在指定位置插入字符串 16 System.out.println("insert添加结果:" + sb); 17 } 18 // 修改 19 public static void update() { 20 StringBuffer sb = new StringBuffer("ABAAA"); 21 sb.setCharAt(2, 'C'); // 修改指定位置字符 22 System.out.println("修改指定位置字符结果:" + sb); 23 sb.replace(3, 5, "DE"); // 替换指定位置字符串或字符 24 System.out.println("替换指定位置字符(串)结果:" + sb); 25 System.out.println("字符串翻转结果:" + sb.reverse()); 26 } 27 // 删除 28 public static void delete() { 29 StringBuffer sb = new StringBuffer("ABCDEFG"); 30 sb.delete(3, 7); // 指定范围删除 31 System.out.println("删除指定位置结果:" + sb); 32 sb.deleteCharAt(2); // 指定位置删除 33 System.out.println("删除指定位置结果:" + sb); 34 sb.delete(0, sb.length()); // 清空缓冲区 35 System.out.println("清空缓冲区结果:" + sb); 36 } 37 }
运行结果如图1所示。
图1 运行结果
在文件1中涉及到StringBuffer类的很多方法,其中append()和insert()方法是最常用的,并且这两个方法有很多重载形式,它们都用于添加字符。不同的是,append()方法始终将这些字符添加到缓冲区的末尾,而insert()方法则可以在指定的位置添加字符。另外, StringBuffer对象的delete()方法用于删除指定位置的字符,包含起始索引,不包含结束索引,setCharAt()和replace()方法用于替换指定位置的字符。
StringBuffer类和String类有很多相似之处,初学者在使用时很容易混淆。接下来针对这两个类进行对比,简单归纳一下两者的不同,具体如下:
①String类定义的字符串是常量,一旦创建后,内容和长度都是无法改变的。StringBuffer表示字符容器,其内容和长度可以随时修改。在操作字符串时,如果该字符串仅用于表示数据类型,则使用String类即可,但是如果需要对字符串中的字符进行增删操作,则使用StringBuffer类。
②String类重写了Object类的equals()方法,而StringBuffer类没有重写Object类的equals()方法。具体示例如下:
String s1 = new String("abc"); String s2 = new String("abc"); System.out.println(s1.equals(s2)); // 打印结果为true StringBuffer sb1 = new StringBuffer("abc"); StringBuffer sb2 = new StringBuffer("abc"); System.out.println(sb1.equals(sb2)); // 打印结果为false
③String类对象可以用操作符“+”进行连接,而StringBuffer对象之间不能,具体示例如下:
String s1 = "a"; String s2 = "b"; String s3 = s1+s2; // 合法 System.out.println(s3); // 打印输出 ab StringBuffer sb1 = new StringBuffer("a"); StringBuffer sb2 = new StringBuffer("b"); StringBuffer sb3 = sb1 + sb2; // 编译出错
多学一招:StringBuilder的使用
除了使用StringBuffer外,JDK 1.5之后提供了一个StringBuilder类同样可以操作字符串。StringBuilder与StringBuffer的功能相似,且两个类中所提供的方法也基本相同。二者所不同的是StringBuffer是线程安全的,而StringBuilder没有实现线程安全功能,所以性能略高。通常情况下,如果创建一个内容可变的字符串对象,应该优先考虑StringBuilder类。
StringBuilder类同样提供了一系列的追加(append)、插入(insert)、替换(replace)和删除(delete)的方法。如果将文件1中的StringBuffer换成StringBuilder,程序同样可以正确执行。
System类对于读者来说并不陌生,因为在之前所学知识中,需要打印结果时,使用的都是“System.out.println();”语句,这句代码中就使用了System类。System类定义了一些与系统相关的属性和方法,它所提供的属性和方法都是静态的,因此,想要引用这些属性和方法,直接使用System类调用即可。
System类的常用方法,如表1所示。
表1 System类的常用方法
方法声明 | 功能描述 |
---|---|
static void exit(int status) | 该方法用于终止当前正在运行的Java虚拟机,其中参数status表示状态码,若状态码非0 ,则表示异常终止 |
static void gc() | 运行垃圾回收器,并对垃圾进行回收 |
static native long currentTimeMillis() | 返回以毫秒为单位的当前时间 |
static void arraycopy(Object src,int srcPos,Object dest,int destPos,int length) | 从src引用的指定源数组复制到dest引用的数组,复制从指定的位置开始,到目标数组的指定位置结束 |
static Properties getProperties() | 取得当前的系统属性 |
static String getProperty(String key) | 获取指定键描述的系统属性 |
在表1中,列出了System类的常用方法,接下来通过一些案例针对表中的方法进行逐一讲解。
1.getProperties()方法
System类的getProperties()方法用于获取当前系统的全部属性,该方法会返回一个Properties对象,其中封装了系统的所有属性,这些属性是以键值对形式存在的。接下来通过一个案例来演示getProperties()方法的使用,如文件1所示。
文件1 Example09.java
1 import java.util.*; 2 public class Example09 { 3 public static void main(String[] args) { 4 // 获取当前系统属性 5 Properties properties = System.getProperties(); 6 System.out.println(properties); 7 // 获取所有系统属性的key(属性名),返回Set对象 8 Set<String> propertyNames = properties.stringPropertyNames(); 9 for (String key : propertyNames ) { 10 //获取当前键key(属性名)所对应的值(属性值) 11 String value = System.getProperty(key); 12 System.out.println(key +"--->"+ value); 13 } 14 } 15 }
运行结果如图1所示。
图1 运行结果
文件1实现了获取当前系统属性的功能。首先通过System的getProperties()方法获取封装了系统属性的Properties集合,然后对Properties集合进行迭代,将所有系统属性的键以及对应的值打印出来。关于集合将在下一章中进行讲解,在这里读者只需知道通过System.getProperties()方法可以获得系统属性即可。从图1中可以看出,这些系统属性包括虚拟机版本号、用户的国家、操作系统的版本等。
2.currentTimeMillis()
currentTimeMillis()方法返回一个long类型的值,该值表示当前时间与1970年1月1日0点0分0秒之间的时间差,单位是毫秒,通常也将该值称作时间戳。为了便于读者理解该方法的使用,接下来通过一个案例来计算程序在进行求和操作时所消耗的时间,如文件2所示。
文件2 Example10.java
1 public class Example10 { 2 public static void main(String[] args) { 3 long startTime = System.currentTimeMillis();// 循环开始时的当前时间 4 int sum = 0; 5 for (int i = 0; i < 100000000; i++) { 6 sum += i; 7 } 8 long endTime = System.currentTimeMillis();// 循环结束后的当前时间 9 System.out.println("程序运行时间为:"+(endTime - startTime)+ "毫秒"); 10 } 11 }
运行结果如图2所示。
图2 运行结果
文件2中,演示了数字的求和操作,程序在求和开始和结束时,分别调用了currentTimeMillis()方法获得了两个时间戳,两个时间戳之间的差值便是求和操作耗费的时间。需要注意的是,由于处理器性能等原因,程序运行的时间也会有所不同。
3.arraycopy(Object src,int srcPos,Object dest,int destPos,int length)
arraycopy()方法用于将一个数组中的元素快速拷贝到另一个数组,其中参数列表中的参数具体说明如下:
● src:表示源数组;
● dest:表示目标数组;
● srcPos:表示源数组中拷贝元素的起始位置;
● destPos:表示拷贝到目标数组的起始位置;
● length:表示拷贝元素的个数。
在进行数组复制时,目标数组必须有足够的空间来存放拷贝的元素,否则会发生角标越界异常。接下来通过一个案例来演示数组元素的拷贝,如文件3所示。
文件3 Example11.java
1 public class Example11 { 2 public static void main(String[] args) { 3 int[] srcArray = { 101, 102, 103, 104, 105, 106 }; // 源数组 4 int[] destArray = { 201, 202, 203, 204, 205}; // 目标数组 5 System.arraycopy(srcArray, 2, destArray, 0, 4); // 拷贝数组元素 6 // 打印目标数组中的元素 7 for (int i = 0; i < destArray.length; i++) { 8 System.out.println(i + ": " + destArray[i]); 9 } 10 } 11 }
运行结果如图3所示。
图3 运行结果
文件3中,创建了两个数组srcArray和destArray,分别代表源数组和目标数组,当调用arraycopy()方法进行元素拷贝时,由于指定了从源数组中索引为2的元素开始拷贝,并且拷贝4个元素存放在目标数组中索引为0的位置,因此,在打印目标数组的元素时,程序首先打印输出的是从源数组srcArray中拷贝的的后四个元素,然后打印输出的destArray中的最后一个元素。
需要注意的是,使用arraycopy()方法进行数组元素拷贝时,一定要保证源数组元素类型和目标数组元素类型一样,否则程序运行会出现ArrayStoreException异常。另外,使用arraycopy()方法时,最后一个复制的元素长度参数length既不能超过截取的源数组从指定位置srcPos开始剩余元素个数,也不能超过目标数组从指定位置destPos开始可容纳的元素个数,否则程序运行会出现ArrayIndexOutOfBoundsException异常。
小提示:
除了以上案例涉及到的方法外,System类还有两个常见的方法,分别是gc()和exit(int status)方法。其中gc()方法用来启动Java的垃圾回收器,并且对内存中的垃圾对象进行回收。exit(int status)方法用来终止当前正在运行的Java虚拟机,其中的参数status用于表示当前发生的异常状态,通常指定为0,表示正常退出,否则表示异常终止。
Runtime类用于表示Java虚拟机运行时的状态,它用于封装Java虚拟机进程。每次使用“java”命令启动Java虚拟机时都会对应一个Runtime实例,并且只有一个实例,应用程序会通过该实例与其运行时的环境相连。应用程序不能创建自己的Runtime实例,若想在程序中获得一个Runtime实例,可以通过getRuntime()方法获取与之相关的Runtime对象,具体方式如下:
Runtime run = Runtime.getRuntime();
由于Runtime类封装了Java虚拟机进程,因此,可以通过该类的实例对象来获取当前虚拟机的相关信息。接下来通过一个案例来演示Runtime类的使用,如文件1所示。
文件1 Example12.java
1 public class Example12 { 2 public static void main(String[] args) { 3 Runtime rt = Runtime.getRuntime(); // 获取Java程序关联的运行时对象 4 System.out.println("处理器的个数: " 5 + rt.availableProcessors() + "个"); 6 System.out.println("空闲内存大小: " 7 + rt.freeMemory() / 1024 / 1024 + "M"); 8 System.out.println("最大可用内存大小: " 9 + rt.maxMemory() / 1024 / 1024 + "M"); 10 } 11 }
运行结果如图1所示。
图1 运行结果
文件1中,通过“Runtime.getRuntime();”方法创建了一个Runtime的实例对象,并分别调用该对象的availableProcessors()方法、freeMemory()方法和maxMemory()方法,将当前虚拟机的处理器个数、空闲内存大小和可用最大内存大小的信息打印出来。
需要注意的是,由于每台计算机的配置和性能不同,该文件的打印结果也会有所不同。另外,空闲内存大小和可用最大内存大小都是以字节为单位计算的,文件1中程序的运行结果已经换算成了以兆(M)为单位的值。
Runtime类中提供了一个exec()方法,该方法用于执行一个DOS命令,从而实现和在命令行窗口中输入DOS命令同样的效果。例如,可以通过运行“notepad.exe”命令打开一个Windows自带的记事本,程序代码如文件2所示。
文件2 Example13.java
1 import java.io.IOException; 2 public class Example13 { 3 public static void main(String[] args) throws IOException { 4 Runtime rt = Runtime.getRuntime(); // 创建Runtime实例对象 5 rt.exec("notepad.exe"); // 调用exec()方法 6 } 7 }
文件2中,调用了Runtime对象的exec()方法,并将系统命令“notepad.exe”作为参数传递给方法。运行程序后会在桌面上打开一个记事本,如图2所示。
图2 记事本
此时,会在Windows系统中产生一个新的进程notepad.exe,可以通过任务管理器进行观察,如图3所示。
图3 任务管理器
Runtime类的exec()方法会返回一个Process对象,该对象表示操作系统的一个进程,此处为notepad.exe进程,通过Process对象可以对产生的新进程进行管理,如关闭此进程只需调用destroy()方法即可。
接下来通过一个案例来实现打开的记事本并在3秒后自动关闭的功能,如文件3所示。
文件3 Example14.java
1 public class Example14 { 1 public static void main(String[] args) throws Exception { 2 // 创建一个Runtime实例对象 3 Runtime rt = Runtime.getRuntime(); 4 // 得到表示进程的Process对象 5 Process process = rt.exec("notepad.exe"); 6 // 程序休眠3秒 7 Thread.sleep(3000); 8 // 关闭进程 9 process.destroy(); 10 } 11 }
在文件3中,通过调用Process对象的destroy()方法,将打开的记事本关闭了。为了突出演示的效果,使用了Thread类的静态方法sleep(long millis)使程序休眠了3秒,因此,程序运行后,会看到打开的记事本在3秒后自动关闭。
Math类是一个工具类,主要用于完成复杂的数学运算,如求绝对值、三角函数、指数运算等。由于其构造方法被定义成private,因此无法创建Math类的对象。Math类中的所有方法都是静态方法,可以直接通过类名来调用它们。除静态方法外,Math类中还有两个静态常量PI和E,分别代表数学中的π和e。
由于Math类比较简单,因此初学者可以通过查看API文档来学习Math类的具体用法。接下来通过一个案例对Math类中比较常用的方法进行演示,如文件1所示。
文件1 Example15.java
1 public class Example15 { 2 public static void main(String[] args) { 3 System.out.println("计算绝对值的结果: " + Math.abs(-1)); 4 System.out.println("计算正弦的结果: " + Math.sin(1.57)); 5 System.out.println("计算余弦的结果: " + Math.cos(2.0)); 6 System.out.println("计算正切的结果: " + Math.tan(0.8)); 7 System.out.println("计算平方根的结果: " + Math.sqrt(4)); 8 System.out.println("计算立方根的结果: " + Math.cbrt(9)); 9 System.out.println("计算乘方的结果: " + Math.pow(2,2)); 10 System.out.println("求大于参数的最小整数: " + Math.ceil(4.6)); 11 System.out.println("求小于参数的最大整数: " + Math.floor(-5.2)); 12 System.out.println("对小数进行四舍五入后的结果: " + Math.round(-8.6)); 13 System.out.println("求两个数的较大值: " + Math.max(5.1, 5.5)); 14 System.out.println("求两个数的较小值: " + Math.min(5.1, 5.5)); 15 System.out.println("生成一个大于等于0小于1的随机值: "+ 16 Math.random()); 17 } 18 }
运行结果如图1所示。
图1 运行结果
在文件1中,对Math类的常用方法进行了演示。从图1的运行结果中可以看出每个方法的作用。
需要注意的是,round()方法用于对某个小数进行四舍五入,此方法会将小数点后面的数字全部忽略,返回一个int类型的数,而ceil()方法和floor()方法返回的都是double类型的数,这个数在数值上等于一个整数。
在JDK的java.util包中,有一个Random类,它可以在指定的取值范围内随机产生数字。在Random类中提供了两个构造方法,如表1所示。
表1 Random的构造方法
方法声明 | 功能描述 |
---|---|
Random() | 构造方法,用于创建一个随机数生成器,每次实例化Random对象会生成不同的随机数 |
Random(long seed) | 构造方法,使用一个long型的seed(种子)创建伪随机数生成器,当seed相同时,每次实例化Random对象会生成相同的随机数 |
表1中列举了Random类的两个构造方法,其中第一个构造方法是无参的,通过它创建的Random实例对象每次使用的种子是随机的,因此每个对象所产生的随机数不同。如果希望创建的多个Random实例对象产生相同序列的随机数,则可以在创建对象时调用第二个构造方法,传入相同的种子即可。
相对于Math的random()方法而言,Random类提供了更多的方法来生成各种伪随机数。它不仅可以生成整数类型的随机数,还可以生成浮点类型的随机数,Random类中的常用方法,如表2所示。
表2 Random类的常用方法
方法声明 | 功能描述 |
---|---|
boolean nextBoolean() | 随机生成boolean类型的随机数 |
double nextDouble() | 随机生成double类型的随机数 |
float nextFloat() | 随机生成float类型的随机数 |
int nextInt() | 随机生成int类型的随机数 |
int nextInt(int n) | 随机生成[0,n)之间int类型的随机数 |
long nextLong() | 随机生成long类型的随机数 |
表2中,列出了Random类常用的方法,其中,Random类的nextDouble()方法返回的是0.0和1.0之间double类型的值,nextFloat()方法返回的是0.0和1.0之间float类型的值,nextInt(int n)返回的是0(包括)和指定值n(不包括)之间的值。接下来通过一个案例来学习这些方法的使用,如文件1所示。
文件1 Example16.java
1 import java.util.Random; 2 public class Example16 { 3 public static void main(String[] args) { 4 Random r = new Random(); 5 System.out.println("生成boolean类型的随机数:" 6 + r.nextBoolean()); 7 System.out.println("生成double类型的随机数:" 8 + r.nextDouble()); 9 System.out.println("生成float类型的随机数:" 10 + r.nextFloat()); 11 System.out.println("生成int类型的随机数:" 12 + r.nextInt()); 13 System.out.println("生成0到10之间int类型的随机数:" 14 + r.nextInt(10)); 15 System.out.println("生成long类型的随机数:" 16 + r.nextLong()); 17 } 18 }
运行结果如图1所示。
图1 运行结果
从图1可以看出,通过调用Random类不同的方法分别产生了不同类型的随机数。
虽然Java是面向对象的编程语言,但它所包含的8种基本数据类型却不支持面向对象的编程机制(没有属性和方法)。Java之所以提供这8种基本数据类型,是为了方便常规数据的处理。在Java中,很多类的方法都需要接收引用类型的对象,此时就无法将一个基本数据类型的值传入。为了解决这样的问题,JDK中提供了一系列的包装类,通过这些包装类可以将基本数据类型的值包装为引用数据类型的对象。
在Java中,每种基本类型都有对应的包装类,具体如表1所示。
表1 基本类型对应的包装类
基本数据类型 | 对应的包装类 |
---|---|
byte | Byte |
char | Character |
int | Integer |
short | Short |
long | Long |
float | Float |
double | Double |
boolean | Boolean |
在表1中,列举了8种基本数据类型及其对应的包装类。除了Integer和Character类外,其他对应的包装类的名称都与其基本数据类型一样,只不过首字母需要大写。
包装类和基本数据类型在进行转换时,引入了自动装箱(Autoboxing)和自动拆箱(AutoUnboxing)的概念,其中自动装箱是指将基本数据类型的变量赋给对应的包装类变量;反之,拆箱是指将包装类对象类型直接赋给一个对应的基本数据类型变量。
接下来以int基本类型与对应的包装类Integer为例,学习一下装箱拆箱的过程,如文件1所示。
文件1 Example17.java
1 public class Example17 { 2 public static void main(String args[]) { 3 // 定义一个基本类型的变量a,并赋值为20 4 int a = 20; 5 // 自动装箱:将基本类型的变量a赋给Integer类型的变量b 6 Integer b = a; 7 System.out.println(b); 8 // 自动拆箱:将Integer类型的变量b赋给基本类型的变量a 9 int c = b; 10 System.out.println(c); 11 } 12 }
运行结果如图1所示。
图1 运行结果
文件1中,通过包装类和自动拆箱、装箱功能,开发人员可以把基本数据类型的变量转换成对象来使用,也可以把包装类的实例转换成基本类型的变量来使用。
Java中,除了支持基本数据类型与对应包装类之间进行转换外,还提供了其他方法来支持基本数据类型、基本数据包装类以及字符串之间的相互转换。具体如下:
(1)通过引用数据类型字符串String类的valueOf()方法可以将8种基本数据类型转换为对应的字符串类型;
(2)通过8种包装类的静态方法valueOf()既可以将对应的基本数据类型转换为包装类,也可以将变量内容匹配的字符串转换为对应的包装类(Character包装类除外);
(3)通过8种包装类的有参构造方法同样既可以将对应的基本数据类型转换为包装类,也可以将变量内容匹配的字符串转换为对应的包装类(Character包装类除外);
(4)通过8种包装类的静态方法parseXxx()可以将变量内容匹配的字符串转换为对应的基本数据类型;
(5)包装类都重写了Object类中的toString()方法,以字符串的形式返回被包装的基本数据类型的值。
下面通过上面的方法来实现基本数据类型、包装类以及字符串之间的相互转换,如文件2所示。
文件2 Example18
1 public class Example18 { 2 public static void main(String args[]) { 3 int num = 123; 4 // 1、通过String.valueOf()方法将基本类型转换为字符串 5 String string = String.valueOf(num); 6 System.out.println("将int变量转换为字符串的结果:"+string); 7 // 2、通过包装类的valueOf()静态方法将基本类型和字符串转换为包装类 8 String str = "998"; 9 Integer integer = Integer.valueOf(num); 10 Integer integer2 = Integer.valueOf(str); 11 System.out.println("将int变量转换为包装类的结果:"+integer); 12 System.out.println("将字符串变量转换为包装类的结果:"+integer2); 13 // 3、通过包装类的有参构造方法将基本类型和字符串转换为包装类 14 Integer integer3 = new Integer(num); 15 Integer integer4 = new Integer(str); 16 System.out.println("通过构造器将int变量转换为包装类的结果:" 17 +integer3); 18 System.out.println("通过构造器将字符串变量转换为包装类的结果:" 19 +integer4); 20 // 4、通过包装类的parseXxx()静态方法将字符串转换为基本数据类型 21 int parseInt = Integer.parseInt(str); 22 System.out.println("将字符串转换为基本类型的结果:"+parseInt); 23 // 5、通过包装类的toString()方法将包装类转换为字符串 24 String string2 = integer.toString(); 25 System.out.println("将包装类转换为字符串的结果:"+string2); 26 } 27 }
运行结果如图2所示。
图2 运行结果
从图2可以看出,介绍的几种方法可以实现基本数据类型、包装类以及字符串之间的相互转换,但在使用valueOf(String s)和parseXxx(String s)方法时,还需要注意以下几点:
①除了Character外,包装类都有valueOf(String s)方法,可以根据String类型的参数创建包装类对象,但参数字符串s不能为null,而且字符串必须是可以解析为相应基本类型的数据,否则虽然编译通过,但运行时会报错。具体示例如下:
Integer i = Integer.valueOf("123"); // 合法 Integer i = Integer.valueOf("12a"); // 不合法
②除了Character外,包装类都有parseXxx(String s)的静态方法,将字符串转换为对应的基本类型的数据。参数s不能为null,而且同样必须是可以解析为相应基本类型的数据,否则虽然编译通过,但运行时会报错。具体示例如下:
int i = Integer.parseInt("123"); // 合法 Integer in = Integer.parseInt("itcast");// 不合法
在JDK的java.util包中,提供了一个Date类用于表示日期和时间,该类在JDK 1.0时就已经开始使用。随着JDK版本的不断升级和发展,Date类中大部分的构造方法和普通方法都已经不再推荐使用。目前JDK 8中,Date类只有两个构造方法是可以使用的,具体如下:
● Date():用来创建当前日期时间的Date对象。
● Date(long date):用于创建指定时间的Date对象,其中date参数表示1970年1月1日0时0分0(称为历元)以来的毫秒数,即时间戳。
接下来通过一个案例来说明如何使用这两个构造函数创建Date对象,如文件1所示。
文件1 Example19.java
1 import java.util.*; 2 public class Example19 { 3 public static void main(String[] args) { 4 // 创建表示当前时间的Date对象 5 Date date1 = new Date(); 6 // 获取当前时间后1秒的时间 7 Date date2 = new Date(System.currentTimeMillis() + 1000); 8 System.out.println(date1); 9 System.out.println(date2); 10 } 11 }
运行结果如图1所示。
图1 运行结果
从图1可以看出,第一条输出语句输出的是当前计算机的日期和时间,第二条输出语句输出的是当前计算机的日期和时间加1秒后的时间。
对于Date类,只需要了解如何通过创建对象封装时间值即可。由于Date类在设计之初,没有考虑国际化的问题,因此从 JDK 1.1 开始,Date类相应的功能也被Calendar类中的方法取代。
Calendar类用于完成日期和时间字段的操作,它可以通过特定的方法设置和读取日期的特定部分,比如年、月、日、时、分和秒等。Calendar类是一个抽象类,不可以被实例化,在程序中需要调用其静态方法getInstance()来得到一个Calendar对象,然后才能调用其相应的方法,具体示例如下:
Calendar calendar = Calendar.getInstance();
Calendar类为操作日期和时间提供了大量的方法,下面列举一些常用的方法,如表1所示。
表1 Calendar的常用方法
方法声明 | 功能描述 |
---|---|
int get(int field) | 返回指定日历字段的值 |
void add(int field,int amount) | 根据日历规则,为指定的日历字段增加或减去指定的时间量 |
void set(int field,int value) | 为指定日历字段设置指定值 |
void set(int year,int month,int date) | 设置Calendar对象的年、月、日三个字段的值 |
void set(int year.int month,int date,int hourOfDay,int minute,int second) | 设置Calendar对象的年、月、日、时、分、秒六个字段的值 |
表1中,大多数方法都用到了int类型的参数field,该参数需要接收Calendar类中定义的常量值,这些常量值分别表示不同的字段,如Calendar.YEAR用于表示年份,Calendar.MONTH用于表示月份,Calendar.SECOND用于表示秒等。尤其要注意的是,在使用Calendar.MONTH字段时,月份的起始值是从0开始的,而不是从1开始,因此要获取当前的月份需要在Calendar.MONTH的基础上加1。
接下来通过一个案例来学习下Calender类如何获取当前计算机的日期和时间,如文件1所示。
文件1 Example20.java
1 import java.util.*; 2 public class Example20 { 3 public static void main(String[] args) { 4 // 获取表示当前时间的Calendar对象 5 Calendar calendar = Calendar.getInstance(); 6 int year = calendar.get(Calendar.YEAR); // 获取当前年份 7 int month = calendar.get(Calendar.MONTH) + 1; // 获取当前月份 8 int date = calendar.get(Calendar.DATE); // 获取当前日 9 int hour = calendar.get(Calendar.HOUR); // 获取时 10 int minute = calendar.get(Calendar.MINUTE); // 获取分 11 int second = calendar.get(Calendar.SECOND); // 获取秒 12 System.out.println("当前时间为:" + year + "年 " + month + "月 " 13 + date + "日 "+ hour + "时 " + minute + "分 " + second + "秒"); 14 } 15 }
运行结果如图1所示。
图1 运行结果
在文件1中,调用Calendar的getInstance()方法创建了一个代表默认时区内当前时间的Calendar对象,然后调用该对象的get(int field)方法,通过传入不同的常量字段值来分别得到日期、时间各个字段的值。
在程序中除了要获得当前计算机的时间外,还会经常设置或修改某个时间,比如一项工程的开始时间为2018年的1月1日,假设要100天后竣工,此时要想知道竣工日期是哪天就需要先将日期设定在开始的那天,然后对日期的天数进行增加,如果没有按照预期完成,可能还需要对日期进行修改。其中添加和修改时间的功能就可以通过Calendar类中的add()和set()方法来实现。
接下来就通过案例来实现上述例子,如文件2所示。
文件2 Example21.java
1 import java.util.*; 2 public class Example21 { 3 public static void main(String[] args) { 4 // 获取表示当前时间的Calendar对象 5 Calendar calendar = Calendar.getInstance(); 6 // 设置指定日期 7 calendar.set(2018, 1, 1); 8 // 为指定日期增加时间 9 calendar.add(Calendar.DATE, 100); 10 // 返回指定日期的年 11 int year = calendar.get(Calendar.YEAR); 12 // 返回指定日期的月 13 int month = calendar.get(Calendar.MONTH) + 1; 14 // 返回指定日期的日 15 int date = calendar.get(Calendar.DATE); 16 System.out.println("计划竣工日期为:" + year + "年" 17 + month + "月" + date + "日"); 18 } 19 }
运行结果如图2所示。
图2 运行结果
文件2的代码中调用Calendar的set()方法将日期设置为2018年1月1号,然后调用add()方法在Calendar.Date字段上增加100,从图5-21可以看出,增加100天的日期为2018年5月12日。
需要注意的是,Calendar.Date表示的是天数,当天数累加到当月的最大值时,如果继续再累加一次,就会从1开始计数,同时月份值会加1,这和算术运算中的进位有点类似。
多学一招:Calendar日历容错模式与非容错模式
Calendar有两种解释日历字段的模式——lenient模式(容错模式)和non-lenient模式(非容错模式)。当Calendar处于lenient模式时,它的字段可以接收超过允许范围的值,当调用get(int field)方法获取某个字段值时,Calendar会重新计算所有字段的值,将字段的值标准化。换句话说,就是在lenient模式下,允许出现一些数值上的错误,例如月份只有12个月,取值为0到11,但在这种模式下,月份值指定为13也是可以的。当Calendar处于non-lenient模式时,如果某个字段的值超出了它允许的范围,程序将会抛出异常。接下来通过一个案例来演示这种异常情况,如文件3所示。
文件3 Example22.java
1 import java.util.*; 2 public class Example22 { 3 public static void main(String[] args) { 4 // 获取表示当前时间的Calendar对象 5 Calendar calendar = Calendar.getInstance(); 6 // 设置指定日期,将MONTH设为13 7 calendar.set(Calendar.MONTH, 13); 8 System.out.println(calendar.getTime()); 9 // 开启non-lenient模式 10 calendar.setLenient(false); 11 calendar.set(Calendar.MONTH, 13); 12 System.out.println(calendar.getTime()); 13 } 14 }
运行结果如图3所示。
图3 运行结果
从图3可以看出,文件3中的第一个输出语句可以正常地输出时间值,而第二个输出语句在输出时间值时报错。出现这样现象的原因在于,Calendar类默认使用lenient模式,当调用Calendar的set()方法将MONTH字段设置为13时,会发生进位,YEAR字段加1,然后MONTH字段变为1,第一个输出语句打印出的结果是“Feb 27”(2月27日)。当代码调用Calendar的setLenient(false)方法开启non-lenient模式后,同样地设置MONTH字段为13,会因为超出了MONTH字段0~11的范围而抛出异常。
本例中用到了Calendar的getTime()方法,getTime()方法会返回一个表示Calendar时间值的Date对象,同时Calendar有一个setTime(Date date)方法,setTime()方法接收一个Date对象,将Date对象表示的时间值设置给Calendar对象,通过这两个方法就可以完成Date和Calendar对象之间的转换。
为了满足更多的需求,JDK 8中新增了一个java.time包,在该包下包含了更多的日期和时间操作类,其常用类如表1所示。
表1 JDK 8新增日期、时间常用类
类名 | 功能描述 |
---|---|
Clock | 用于获取指定时区的当前日期、时间。 |
DayOfWeek | 枚举类,定义了一周七天周一到周日的枚举值 |
Duration | 表示持续时间。该类提供的ofXxx()方法用于获取指定的时间的小时、分钟、秒数等。 |
Instant | 表示一个具体时刻,可以精确到纳秒。该类提供了静态的now()方法来获取当前时刻,提供了静态的now(Clock clock)方法来获取clock对应的时刻。同时还提供了一系列的plusXxx()方法来获取当前时刻基础上加上一段时间,以及一系列的minusXxx()方法在当前时刻基础上减去一段时间。 |
LocalDate | 表示不带时区的日期,如2018-01-27。该类提供了静态的now()方法来获取当前日期,提供了静态的now(Clock clock)方法来获取clock对应的日期。同时还提供了一系列的plusXxx()方法在当前年份基础上加上几年、几月、几日等,以及一系列的minusXxx()方法在当前年份基础上减去几年、几月、几日等。 |
LocalTime | 表示不带时区的时间,如14:49:20。该类提供了静态的now()方法来获取当前时间,提供了静态的now(Clock clock)方法来获取clock对应的时间。同时还提供了一系列的plusXxx()方法在当前年份基础上加上几小时、几分、几秒等,以及一系列的minusXxx()方法在当前年份基础上减去几小时、几分、几秒等。 |
LocalDateTime | 表示不带时区的日期、时间。该类提供了静态的now()方法来获取当前日期、时间,提供了静态的now(Clock clock)方法来获取clock对应的日期、时间。同时还提供了一系列的plusXxx()方法在当前年份基础上加上几年、几月、几日、几小时、几分、几秒等,以及一系列的minusXxx()方法在当前年份基础上减去几年、几月、几日、几小时、几分、几秒等。 |
Month | 枚举类,定义了一月到十二月的枚举值 |
MonthDay | 表示月日,如–01-27。该类提供了静态的now()方法来获取当前月日,提供了静态的now(Clock clock)方法来获取clock对应的月日。 |
Year | 表示年,如2018。该类提供了静态的now()方法来获取当前年份,提供了静态的now(Clock clock)方法来获取clock对应的年份。同时还提供了plusYears()方法在当前年份基础上增加几年,以及minusYears()方法在当前年份基础上减去几年。 |
YearMonth | 表示年月,如2018-01。该类提供了静态的now()方法来获取当前年月,提供了静态的now(Clock clock)方法来获取clock对应的年月。同时还提供了plusXxx()方法在当前年月基础上增加几年、几月,以及minusXxx()方法在当前年月基础上减去几年、几月。 |
ZoneId | 表示一个时区 |
ZonedDateTime | 表示一个时区化的日期、时间 |
了解了上述各个类的作用后,接下来通过一个具体的案例来演示这些类的用法,如文件1所示。
文件1 Example23
1 import java.time.*; 2 public class Example23 { 3 public static void main(String[] args) { 4 // 1、Clock的使用 5 Clock clock = Clock.systemUTC(); 6 System.out.println("获取UTC时区转换的当前时间:" + clock.instant()); 7 System.out.println("获取UTC时区转换的的毫秒数:" + clock.millis()); 8 // 2、Duration的使用 9 Duration d = Duration.ofDays(1); 10 System.out.println("一天等于" + d.toHours() +"小时"); 11 System.out.println("一天等于" + d.toMinutes() +"分钟"); 12 System.out.println("一天等于" + d.toMillis() +"秒"); 13 // 3、Instant的使用 14 Instant instant = Instant.now(); 15 System.out.println("获取UTC时区的当前时间为:" + instant); 16 System.out.println("当前时间一小时后的时间为:" 17 + instant.plusSeconds(3600)); 18 System.out.println("当前时间一小时前的时间为:" 19 + instant.minusSeconds(3600)); 20 // 4、LocalDate的使用 21 LocalDate localDate = LocalDate.now(); 22 System.out.println("从默认时区的系统时钟获得当前日期:" + localDate); 23 // 5、LocalTime的使用 24 LocalTime localTime = LocalTime.now(); 25 System.out.println("从默认时区的系统时钟获取当前时间:" + localTime); 26 // 6、LocalDateTime的使用 27 LocalDateTime localDateTime = LocalDateTime.now(); 28 System.out.println("从默认时区的系统时钟获取日期、时间:" 29 + localDateTime); 30 LocalDateTime times = localDateTime.plusDays(1) 31 .plusHours(3).plusMinutes(30); 32 System.out.println("当前的日期、时间加上1天3小时30分之后:" + times); 33 // 7、Year、YearMonth、MonthDay的使用 34 Year year = Year.now(); 35 System.out.println("当前年份为:" + year); 36 YearMonth yearMonth = YearMonth.now(); 37 System.out.println("当前年月为:" + yearMonth); 38 MonthDay monthDay = MonthDay.now(); 39 System.out.println("当前月日为:" + monthDay); 40 // 8、 获取系统默认时区 41 ZoneId zoneId = ZoneId.systemDefault(); 42 System.out.println("当前系统默认时区为:" + zoneId); 43 } 44 }
运行结果如图1所示。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ZIHxMfZ5-1624149047235)(https://book.itheima.net/uploads/course/images/java/1.5/image-20200615100948301.png)]
图1 运行结果
文件1中,演示了JDK 8新增日期时间、包中类的一些用法。需要注意的是,从图5-23可以看出,通过clock.instant()和Instant.now()获取的当前时间与本地系统显示时间有8个小时的时差,这是因为Instant默认使用的是UTC(Universal Time Coordinated)世界协调时间,又称世界标准时间,UTC 提供了一种与时区无关的时间,与CST(China Standard Time)中国标准时间(北京时间)有8个小时的时差。
使用Date类时,在程序中打印Date对象所输出的当前时间都是以默认的英文格式输出的,如果要将Date对象表示的日期以指定的格式输出,例如输出中文格式的时间,就需要用到DateFormat类。
DateFormat类专门用于将日期格式化为字符串或者将用特定格式显示的日期字符串转换成一个Date对象。DateFormat是一个抽象类,不能被直接实例化,但它提供了一系列的静态方法来获取DateFormat类的实例对象,并能调用其他相应的方法进行操作。
DateFormat类中提供的常用方法,如表1所示。
表1 DateFormat的常用方法
方法声明 | 功能描述 |
---|---|
static DateFormat getDateInstance() | 用于创建默认语言环境和格式化风格的日期格式器 |
static DateFormat getDateInstance(int style) | 用于创建默认语言环境和指定格式化风格的日期格式器 |
static DateFormat getDateTimeInstance() | 用于创建默认语言环境和格式化风格的日期/时间格式器 |
static DateFormat getDateTimeInstance( int dateStyle,int timeStyle) | 用于创建默认语言环境和指定格式化风格的日期/时间格式器 |
String format(Date date) | 将一个 Date 格式化为日期/时间字符串。 |
Date parse(String source) | 将给定字符串解析成一个日期 |
表1中,列出了DateFormat类的四个静态方法,这四个静态方法能用于获得DateFormat类的实例对象,每种方法返回的对象都具有不同的作用,它们可以分别对日期或者时间部分进行格式化。
在DateFormat类中还定义了许多常量,其中有四个常量值是用于作为参数传递给方法的,包括FULL、LONG、MEDIUM和SHORT。FULL常量用于表示完整格式,LONG常量用于表示长格式,MEDIUM常量用于表示普通格式,SHORT常量用于表示短格式。
接下来通过一个案例针对DateFormat类的使用进行演示,如文件1所示。
文件1 Example24.java
1 import java.text.*; 2 import java.util.*; 3 public class Example24 { 4 public static void main(String[] args) { 5 // 创建Date对象 6 Date date = new Date(); 7 // Full格式的日期格式器对象 8 DateFormat fullFormat = 9 DateFormat.getDateInstance(DateFormat.FULL); 10 // Long格式的日期格式器对象 11 DateFormat longFormat = 12 DateFormat.getDateInstance(DateFormat.LONG); 13 // MEDIUM格式的日期/时间 格式器对象 14 DateFormat mediumFormat = DateFormat.getDateTimeInstance( 15 DateFormat.MEDIUM, DateFormat.MEDIUM); 16 // SHORT格式的日期/时间格式器对象 17 DateFormat shortFormat = DateFormat.getDateTimeInstance( 18 DateFormat.SHORT, DateFormat.SHORT); 19 // 下面打印格式化后的日期或者日期/时间 20 System.out.println("当前日期的完整格式为:" 21 + fullFormat.format(date)); 22 System.out.println("当前日期的长格式为:" 23 + longFormat.format(date)); 24 System.out.println("当前日期的普通格式为:" 25 + mediumFormat.format(date)); 26 System.out.println("当前日期的短格式为:" 27 + shortFormat.format(date)); 28 } 29 }
运行结果如图1所示。
图1 运行结果
文件1中,演示了四种格式下时间和日期格式化输出的效果,其中调用getDateInstance()方法获得的实例对象用于对日期部分进行格式化,getDateTimeInstance()方法获得的实例对象可以对日期和时间部分进行格式化。
DateFormat中还提供了一个parse(String source)方法,能够将一个字符串解析成Date对象,但是它要求字符串必须符合日期/时间的格式要求,否则会抛出异常。
接下来通过一个案例来演示parse()方法的使用,如文件2所示。
文件2 Example25.java
1 import java.text.*; 2 public class Example25 { 3 public static void main(String[] args) throws ParseException { 4 // 创建DateFormat对象 5 DateFormat dt1 = DateFormat.getDateInstance(); 6 // 创建Long格式的DateFormat对象 7 DateFormat dt2 = DateFormat.getDateInstance(DateFormat.LONG); 8 // 定义两个日期格式的字符串 9 String str1 = "2018-01-27"; 10 String str2 = "2018年01月27日"; 11 // 输出对应格式的字符串解析成Date对象后的结果 12 System.out.println(dt1.parse(str1)); 13 System.out.println(dt2.parse(str2)); 14 } 15 }
运行结果如2所示。
图2 运行结果
文件2中,首先创建了一个普通的DateFormat对象,以及LONG格式的DateFormat对象,然后定义了两种日期格式的字符串,最后在输出语句中调用parse()方法将对应格式的字符串解析成Date对象。
在使用DateFormat对象的parse()方法将字符串解析为日期时,需要输入固定格式的字符串,这显然不够灵活。为了能够更好地格式化日期、解析字符串,Java中提供了一个SimpleDateFormat类。
SimpleDateFormat类是DateFormat类的子类,它可以使用new关键字创建实例对象。在创建实例对象时,它的构造方法需要接收一个表示日期格式模板的字符串参数。
接下来通过一个案例演示,如何使用SimpleDateFormat类将日期对象以特定的格式转为字符串形式,如文件1所示。
文件1 Example26.java
1 import java.text.*; 2 import java.util.*; 3 public class Example26 { 4 public static void main(String[] args) throws Exception { 5 // 创建一个SimpleDateFormat对象 6 SimpleDateFormat sdf = new SimpleDateFormat( 7 "Gyyyy年MM月dd日:今天是yyyy年的第D天,E"); 8 // 按SimpleDateFormat对象的日期模板格式化Date对象 9 System.out.println(sdf.format(new Date())); 10 } 11 }
运行结果如图1所示。
图1 运行结果
在文件1中,在创建SimpleDateFormat对象时传入日期格式模板“Gyyyy年MM月dd日:今天是yyyy年的第D天,E”,在调用SimpleDateFormat的format()方法时,会将Date对象格式化成如模板格式的时间形式,即“公元2018年01月27日:今天是2018年的第27天,星期六”。
上面通过SimpleDateFormat类将一个Date时间对象转换为指定格式字符串形式,接下来通过一个案例来演示,如何使用SimpleDateFormat类将一个指定日期格式的字符串解析为Date对象,如文件2所示。
文件2 Example27.java
1 import java.text.*; 2 public class Example27 { 3 public static void main(String[] args) throws ParseException{ 4 // 创建一个SimpleDateFormat对象,并指定日期格式 5 SimpleDateFormat sdf = new SimpleDateFormat("yyyy/MM/dd"); 6 // 定义一个日期格式的字符串 7 String str = "2018/01/27"; 8 // 将字符串解析成Date对象 9 System.out.println(sdf.parse(str)); 10 } 11 }
运行结果如图2所示。
图2 运行结果
在文件2中,首先在创建SimpleDateFormat对象时传入日期格式模板“yyyy/MM/dd”,然后定义了一个指定日期格式的字符串“2018/01/27”,最后调用SimpleDateFormat的parse()方法将符合日期模板格式的字符串解析成Date对象。
SimpleDateFormat的功能非常强大,在创建SimpleDateFormat对象时,只要传入合适的格式字符串参数,就能解析各种形式的日期字符串或者将Date日期格式化成任何形式的字符串。其中,格式字符串参数是一个使用日期/时间字段占位符的日期模板。
除了DataFormat和SimpleDateFormat类外,JDK 8在java.time.format包下还提供了一个DateTimeFormatter类,该类也是一个格式化类,其功能相当于DataFormat和SimpleDateFormat的合体,它不仅可以将日期、时间对象格式化成字符串,还能将特定格式的字符串解析成日期、时间对象。
要使用DateTimeFormatter进行格式化或者解析,就必须先获得DateTimeFormatter对象。获取DateTimeFormatter对象有三种方式,具体如下:
● 使用静态常量创建DateTimeFormatter格式器。在DateTimeFormatter类中包含大量的静态常量,如BASIC_ISO_DATE、ISO_LOCAL_DATE、ISO_LOCAL_TIME等,通过这些静态常量都可以获取DateTimeFormatter实例。
● 使用不同风格的枚举值来创建DateTimeFormatter格式器。在FormatStyle类中定义了FULL、LONG、MEDIUM和SHORT四个枚举值,它们表示日期和时间的不同风格。
● 根据模式字符串创建DateTimeFormatter格式器。
了解了DateTimeFormatter的作用及其对象获取方式后,下面分别讲解下如何使用DateTimeFormatter来格式化和解析日期、时间。
1.完成日期、时间格式化
使用DateTimeFormatter将日期、时间格式化为字符串,可以通过以下两种方式:
● 调用DateTimeFormatter的format(TemporalAccessor temporal)方法执行格式化,其中参数temporal是一个TemporalAccessor类型接口,其主要实现类有LocalDate、LocalDateTime。
● 调用LocalDate、LocalDateTime等日期、时间对象的format(DateTimeFormatter formatter)方法执行格式化。
接下来通过一个案例来演示,如何使用DateTimeFormatter来格式化日期、时间,如文件1所示。
文件1 Example28.java
1 import java.time.*; 2 import java.time.format.*; 3 public class Example28 { 4 public static void main(String[] args) { 5 LocalDateTime date = LocalDateTime.now(); 6 // 1、使用常量创建DateTimeFormatter 7 System.out.print("使用常量创建DateTimeFormatter:"); 8 System.out.println(DateTimeFormatter 9 .ISO_LOCAL_DATE.format(date)); 10 // 2、使用Long类型风格的DateTimeFormatter 11 System.out.print("使用Long类型风格的DateTimeFormatter:"); 12 DateTimeFormatter dtf = DateTimeFormatter 13 .ofLocalizedDateTime(FormatStyle.LONG); 14 System.out.println(dtf.format(date)); 15 // 3、根据模式字符串来创建DateTimeFormatter格式器 16 System.out.print("根据模式字符串来创建DateTimeFormatter:"); 17 DateTimeFormatter formatter = DateTimeFormatter 18 .ofPattern("yyyy MM dd HH:mm:ss"); 19 // 使用LocalDateTime的format()方法格式化 20 String text = date.format(formatter); 21 // 使用格式化程序解析文本,返回日期时间 22 LocalDateTime parsedDate = LocalDateTime.parse(text, formatter); 23 System.out.println(parsedDate); 24 } 25 }
运行结果如图1所示。
图1 运行结果
文件1中,分别使用三种方式创建了DateTimeFormatter格式器,并使用不同方式创建的格式器对LocalDateTime进行格式化。
2.解析字符串
要使用DateTimeFormatter将指定格式的字符串解析成日期、时间对象,可以通过日期时间对象所提供的parse(CharSequence text, DateTimeFormatter formatter)方法来实现。
下面通过一个具体的案例来演示,如何使用DateTimeFormatter解析日期、时间,如文件2所示。
文件2 Example29.java
1 import java.time.*; 2 import java.time.format.*; 3 public class Example29 { 4 public static void main(String[] args) { 5 // 定义两种日期格式的字符串 6 String str1 = "2018-01-27 12:38:36"; 7 String str2 = "2018年01月29日 15时01分20秒"; 8 // 定义解析所用的格式器 9 DateTimeFormatter formatter1 = DateTimeFormatter 10 .ofPattern("yyyy-MM-dd HH:mm:ss"); 11 DateTimeFormatter formatter2 = DateTimeFormatter 12 .ofPattern("yyyy年MM月dd日 HH时mm分ss秒"); 13 // 使用LocalDateTime的parse()方法执行解析 14 LocalDateTime localDateTime1 = LocalDateTime 15 .parse(str1, formatter1); 16 LocalDateTime localDateTime2 = LocalDateTime 17 .parse(str2, formatter2); 18 // 输出结果 19 System.out.println(localDateTime1); 20 System.out.println(localDateTime2); 21 } 22 }
运行结果如图2所示。
图2 运行结果
在文件2中,首先定义了两种日期格式的字符串,然后通过DateTimeFormatter对象定义了解析不同格式字符串的格式器,接下来通过LocalDateTime的parse()方法对字符串进行解析,最后使用输出语句输出解析后的结果。
Java中的集合就像一个容器,专门用来存储Java对象(实际上是对象的引用,但习惯上称为对象),这些对象可以是任意的数据类型,并且长度可变。其中,这些集合类都位于java.util包中,在使用时一定要注意导包的问题,否则会出现异常。
集合按照其存储结构可以分为两大类,即单列集合Collection和双列集合Map,这两种集合的特点具体如下:
● Collection:单列集合的根接口,用于存储一系列符合某种规则的元素。
Collection集合有两个重要的子接口,分别是List和Set。其中,List集合的特点是元素有序、可重复;Set集合的特点是元素无序并且不可重复。List接口的主要实现类有ArrayList和LinkedList,Set接口的主要实现类有HashSet和TreeSet。
● Map:双列集合的根接口,用于存储具有键(Key)、值(Value)映射关系的元素。
Map集合中每个元素都包含一对键值,并且Key是唯一的,在使用Map集合时可以通过指定的Key找到对应的Value。例如,根据一个学生的学号就可以找到对应的学生。Map接口的主要实现类有HashMap和TreeMap。
从上面的描述可以看出Java中提供了丰富的集合类库,为了便于初学者进行系统地学习,接下来通过一张图来描述整个集合的核心继承体系,如图1所示。
图1 集合体系核心架构图
图1中列出了Java开发中常用的一些集合类,其中,虚线框里填写的都是接口类型,而实线框里填写的都是具体的实现类。
Collection是所有单列集合的根接口,因此在Collection中定义了单列集合(如List和Set)的一些通用方法,这些方法可用于操作所有的单列集合,如表1所示。
表1 Collection接口的主要方法
方法声明 | 功能描述 |
---|---|
boolean add(Object o) | 向集合中添加一个元素 |
boolean addAll(Collection c) | 将指定集合c中的所有元素添加到该集合中 |
void clear() | 删除该集合中的所有元素 |
boolean remove(Object o) | 删除该集合中指定的元素 |
boolean removeAll(Collection c) | 删除该集合中包含指定集合c中的所有元素 |
boolean isEmpty() | 判断该集合是否为空 |
boolean contains(Object o) | 判断该集合中是否包含某个元素 |
boolean containsAll(Collection c) | 判断该集合中是否包含指定集合c中的所有元素 |
Iterator iterator() | 返回在该集合的元素上进行迭代的迭代器(Iterator),用于遍历该集合所有元素 |
int size() | 获取该集合元素个数 |
Stream stream() | 将集合源转换为有序元素的流对象(JDK 8新方法) |
表1中列举了单列集合根接口Collcetion中的一些主要方法,其中stream()方法是JDK 8新增的,用于对集合元素进行聚合操作,针对该方法,在后续小节将会详细讲解。
另外表1中列举的Collcetion集合的主要方法都来自Java API文档,初学者可以通过查询API文档来学习更多有关Collcetion集合方法的具体用法,此处列出这些方法,只是为了方便后面的学习。
List接口继承自Collection接口,是单列集合的一个重要分支,习惯性的会将实现了List接口的对象称为List集合。在List集合中允许出现重复的元素,所有的元素是以一种线性方式进行存储的,在程序中可以通过索引(类似于数组中的元素角标)来访问集合中的指定元素。另外,List集合还有一个特点就是元素有序,即元素的存入顺序和取出顺序一致。
List作为Collection集合的子接口,不但继承了Collection接口中的全部方法,而且还增加了一些操作集合的特有方法,如表1所示。
表1 List集合常用方法
方法声明 | 功能描述 |
---|---|
void add(int index,Object element) | 将元素element插入在List集合的指定索引位置 |
boolean addAll(int index,Collection c) | 将集合c包含的所有元素插入到List集合的指定索引位置 |
Object get(int index) | 返回集合索引index处的元素 |
Object remove(int index) | 删除index索引处的元素 |
Object set(int index, Object element) | 将索引index处元素替换成element元素,并将替换后的元素返回 |
int indexOf(Object o) | 返回对象o在List集合中首次出现的位置索引 |
int lastIndexOf(Object o) | 返回对象o在List集合中最后一次出现的位置索引 |
List subList(int fromIndex, int toIndex) | 返回从索引fromIndex(包括)到 toIndex(不包括)处所有元素集合组成的子集合 |
Object[] toArray() | 将集合元素转换为数组 |
default void sort(Comparator<? super E> c) | 根据指定的比较器规则对集合元素排序(JDK 8新方法) |
表1中列举了List集合中的常用方法,所有的List实现类都可以调用这些方法来对集合元素进行操作。其中sort(Comparator<? super E> c)方法是JDK 8新增的,用于对集合元素进行排序操作,该方法的参数是一个接口类型的比较器Comparator,可以通过前面讲解的Lambda表达式传入一个函数式接口作为参数,来指定集合元素的排序规则。
ArrayList是List接口的一个实现类,它是程序中最常见的一种集合。在ArrayList内部封装了一个长度可变的数组对象,当存入的元素超过数组长度时,ArrayList会在内存中分配一个更大的数组来存储这些元素,因此可以将ArrayList集合看作一个长度可变的数组。
正是由于ArrayList内部的数据存储结构是数组形式,在增加或删除指定位置的元素时,会创建新的数组,效率比较低,因此不适合做大量的增删操作。但是,这种数组结构允许程序通过索引的方式来访问元素,因此使用ArrayList集合在遍历和查找元素时显得非常高效。
ArrayList集合中大部分方法都是从接口Collection和List继承过来的,接下来通过一个案例来学习如何使用ArrayList集合的方法来存取元素,如文件1所示。
文件1 Example01.java
1 import java.util.ArrayList; 2 public class Example01 { 3 public static void main(String[] args) { 4 // 创建ArrayList集合 5 ArrayList list = new ArrayList(); 6 // 向集合中添加元素 7 list.add("stu1"); 8 list.add("stu2"); 9 list.add("stu3"); 10 list.add("stu4"); 11 System.out.println("集合的长度:" + list.size()); 12 System.out.println("第2个元素是:" + list.get(1)); 13 } 14 }
运行结果如图1所示。
图1 运行结果
文件1中,首先通过“new ArrayList()”语句创建了一个空的ArrayList集合,接着调用add(Object o)方法向ArrayList集合中添加了4个元素,然后调用size()方法获取集合中元素个数,最后通过调用ArrayList的get(int index)方法取出指定索引位置的元素。
从图1可以看出,索引位置为1的元素是集合中的第二个元素,这说明集合和数组一样,索引的取值是从0开始的,最后一个索引是size-1,在访问元素时一定要注意索引不可超出此范围,否则会抛出角标越界异常IndexOutOfBoundsException。
注意:
1.在编译文件1时,会得到如图1所示的警告,意思是说在使用ArrayList集合时并没有显示的指定集合中存储什么类型的元素,会产生安全隐患,这涉及到泛型安全机制的问题。与泛型相关的知识将在后面的章节详细讲解,现在无需考虑。
图2 运行结果
2.在编写程序时,不要忘记使用类似于“import java.util.ArrayList;”语句导包,否则程序将会编译失败,显示类找不到,如图6-4所示。要解决此问题,只需单击图6-4所示错误小窗口中的第一行“Import’ArrayList’(java.util)”链接即可,这样Eclipse就会自动导入ArrayList的包。另外在后面的案例中可能会大量的用到集合类,除了可以使用上述方式导入指定集合类所在的包外,为了方便,程序中还可以统一使用import java.util.;来进行导包,其中为通配符,整个语句的意思是将java.util包中的内容都导入进来。
图3 编译错误
ArrayList集合在查询元素时速度很快,但在增删元素时效率较低,为了克服这种局限性,可以使用List接口的另一个实现类LinkedList。LinkedList集合内部包含有两个Node类型的first和last属性维护一个双向循环链表,在链表中的每一个元素都使用引用的方式来记住它的前一个元素和后一个元素,从而可以将所有的元素彼此连接起来。当插入一个新元素时,只需要修改元素之间的这种引用关系即可,删除一个节点也是如此。正因为这样的存储结构,所以LinkedList集合对于元素的增删操作表现出很高的效率,LinkedList集合添加元素和删除元素的过程如图1所示。
图1 双向循环链表结构图
在图1中,通过两张图描述了LinkedList集合新增元素和删除元素的过程。其中,左图为新增一个元素,图中的元素1和元素2在集合中彼此为前后关系,在它们之间新增一个元素时,只需要让元素1记住它后面的元素是新元素,让元素2记住它前面的元素为新元素就可以了。右图为删除元素,要想删除元素1和元素2之间的元素3,只需要让元素1与元素2变成前后关系就可以了。
LinkedList集合除了从接口Collection和List中继承并实现了集合操作方法外,还专门针对元素的增删操作定义了一些特有的方法,如表1所示。
表1 LinkedList中的特有方法
方法声明 | 功能描述 |
---|---|
void add(int index, E element) | 在此列表中指定的位置插入指定的元素。 |
void addFirst(Object o) | 将指定元素插入集合的开头 |
void addLast(Object o) | 将指定元素添加到集合的结尾 |
Object getFirst() | 返回集合的第一个元素 |
Object getLast() | 返回集合的最后一个元素 |
Object removeFirst() | 移除并返回集合的第一个元素 |
Object removeLast() | 移除并返回集合的最后一个元素 |
boolean offer(Object o) | 将指定元素添加到集合的结尾 |
boolean offerFirst(Object o) | 将指定元素添加到集合的开头 |
boolean offerLast(Object o) | 将指定元素添加到集合的结尾 |
Object peek() | 获取集合的第一个元素 |
Object peekFirst() | 获取集合的第一个元素 |
Object peekLast() | 获取集合的最后一个元素 |
Object poll() | 移除并返回集合的第一个元素 |
Object pollFirst() | 移除并返回集合的第一个元素 |
Object pollLast() | 移除并返回集合的最后一个元素 |
void push(Object o) | 将指定元素添加到集合的开头 |
Object pop() | 移除并返回集合的第一个元素 |
表1中,列出的方法主要针对集合中的元素进行增加、删除和获取操作,接下来通过一个案例来学习LinkedList中常用方法的使用,如文件1所示。
文件1 Example02.java
1 import java.util.LinkedList; 2 public class Example02 { 3 public static void main(String[] args) { 4 // 创建LinkedList集合 5 LinkedList link = new LinkedList(); 6 // 1、添加元素 7 link.add("stu1"); 8 link.add("stu2"); 9 System.out.println(link); // 输出集合中的元素 10 link.offer("offer"); // 向集合尾部追加元素 11 link.push("push"); // 向集合头部添加元素 12 System.out.println(link); // 输出集合中的元素 13 // 2、获取元素 14 Object object = link.peek();//获取集合第一个元素 15 System.out.println(object); // 输出集合中的元素 16 // 3、删除元素 17 link.removeFirst(); // 删除集合第一个元素 18 link.pollLast(); // 删除集合最后一个元素 19 System.out.println(link); 20 } 21 }
运行结果如图2所示。
图2 运行结果
文件1中,首先创建了一个LinkedList集合,接着分别使用add()、offer()、push()方法向集合中插入元素,然后使用peek()方法获取了集合的第一个元素,最后分别使用removeFirst()、pollLast()方法将集合中指定位置的元素移除,这样就完成了元素的增、查、删操作。由此可见,使用LinkedList对元素进行增删操作是非常便捷的。
Iterator接口是Java集合框架中的一员,但它与Collection、Map接口有所不同,Collection接口与Map接口主要用于存储元素,而Iterator主要用于迭代访问(即遍历)Collection中的元素,因此Iterator对象也被称为迭代器。
接下来通过一个案例来学习如何使用Iterator迭代集合中的元素,如文件1所示。
文件1 Example03.java
1 import java.util.ArrayList; 2 import java.util.Iterator; 3 public class Example03 { 4 public static void main(String[] args) { 5 // 创建ArrayList集合 6 ArrayList list = new ArrayList(); 7 // 向该集合中添加字符串 8 list.add("data_1"); 9 list.add("data_2"); 10 list.add("data_3"); 11 // 获取Iterator对象 12 Iterator iterator = list.iterator(); 13 // 判断集合中是否存在下一个元素 14 while (iterator.hasNext()) { 15 Object obj = iterator.next(); // 取出ArrayList集合中的元素 16 System.out.println(obj); 17 } 18 } 19 }
运行结果如图1所示。
图1 运行结果
文件1演示的是Iterator遍历集合的整个过程。当遍历元素时,首先通过调用ArrayList集合的iterator()方法获得迭代器对象,然后使用hashNext()方法判断集合中是否存在下一个元素,如果存在,则调用next()方法将元素取出,否则说明已到达了集合末尾,停止遍历元素。需要注意的是,在通过next()方法获取元素时,必须保证要获取的元素存在,否则,会抛出NoSuchElementException异常。
Iterator迭代器对象在遍历集合时,内部采用指针的方式来跟踪集合中的元素,为了让初学者能更好地理解迭代器的工作原理,接下来通过一个图例来演示Iterator对象迭代元素的过程,如图2所示。
图2 遍历元素过程图
在图2中,在调用Iterator的next()方法之前,迭代器的索引位于第一个元素之前,不指向任何元素,当第一次调用迭代器的next()方法后,迭代器的索引会向后移动一位,指向第一个元素并将该元素返回,当再次调用next()方法时,迭代器的索引会指向第二个元素并将该元素返回,以此类推,直到hasNext()方法返回false,表示到达了集合的末尾终止对元素的遍历。
虽然Iterator可以用来遍历集合中的元素,但写法上比较繁琐,为了简化书写,从JDK 5开始,提供了foreach循环。foreach循环是一种更加简洁的for循环,也称增强for循环。foreach循环用于遍历数组或集合中的元素,其具体语法格式如下:
for(容器中元素类型 临时变量 :容器变量) { // 执行语句 }
从上面的格式可以看出,与for循环相比,foreach循环不需要获得容器的长度,也不需要根据索引访问容器中的元素,但它会自动遍历容器中的每个元素。接下来通过一个案例对foreach循环进行详细讲解,如文件1所示。
文件1 Example04.Java
1 import java.util.ArrayList; 2 public class Example04 { 3 public static void main(String[] args) { 4 ArrayList list = new ArrayList(); 5 list.add("data_1"); 6 list.add("data_2"); 7 list.add("data_3"); 8 // 使用foreach循环遍历集合 9 for (Object obj : list) { 10 System.out.println(obj); // 取出并打印集合中的元素 11 } 12 } 13 }
运行结果如图1所示。
图1 运行结果
通过文件1可以看出,foreach循环遍历集合的语法非常简洁,没有循环条件,也没有迭代语句,所有这些工作都交给JVM去执行了。foreach循环的次数是由容器中元素的个数决定的,每次循环时,foreach中都通过变量将当前循环的元素记住,从而将集合中的元素分别打印出来。
脚下留心:
1.foreach循环虽然书写起来很简洁,但在使用时也存在一定的局限性。当使用foreach循环遍历集合和数组时,只能访问集合中的元素,不能对其中的元素进行修改,接下来以一个String类型的数组为例来进行演示,如文件2所示。
文件2 Example05.java
1 public class Example05 { 2 static String[] strs = { "aaa", "bbb", "ccc" }; 3 public static void main(String[] args) { 4 // 1、foreach循环遍历数组 5 for (String str : strs) { 6 str = "ddd"; 7 } 8 System.out.println("foreach循环修改后的数组:" + strs[0] + "," 9 + strs[1] + "," + strs[2]); 10 // 2、for循环遍历数组 11 for (int i = 0; i < strs.length; i++) { 12 strs[i] = "ddd"; 13 } 14 System.out.println("普通for循环修改后的数组:" + strs[0] + "," 15 + strs[1] + "," + strs[2]); 16 } 17 }
运行结果如图2所示。
图2 运行结果
在文件2中,分别使用foreach循环和普通for循环去修改数组中的元素。从运行结果可以看出foreach循环并不能修改数组中元素的值。其原因是第6行代码中的str = "ddd"只是将临时变量str指向了一个新的字符串,这和数组中的元素没有一点关系。而在普通for循环中,是可以通过索引的方式来引用数组中的元素并将其值进行修改的。
2.在使用Iterator迭代器对集合中的元素进行迭代时,如果调用了集合对象的remove()方法去删除元素,会出现异常。接下来通过一个案例来演示这种异常,如文件3所示。
文件3 Example06.java
1 import java.util.ArrayList; 2 import java.util.Iterator; 3 public class Example06 { 4 public static void main(String[] args) { 5 ArrayList list = new ArrayList(); 6 list.add("Jack"); 7 list.add("Annie"); 8 list.add("Rose"); 9 list.add("Tom"); 10 Iterator it = list.iterator(); // 获得Iterator对象 11 while (it.hasNext()) { // 判断该集合是否有下一个元素 12 Object obj = it.next(); // 获取该集合中的元素 13 if ("Annie".equals(obj)) { // 判断该集合中的元素是否为Annie 14 list.remove(obj); // 删除该集合中的元素 15 } 16 } 17 System.out.println(list); 18 } 19 }
运行结果如图3所示。
图3 运行结果
文件3在运行时出现了并发修改异常ConcurrentModificationException。这个异常是迭代器对象抛出的,出现异常的原因是集合中删除了元素会导致迭代器预期的迭代次数发生改变,导致迭代器的结果不准确。
为了解决上述问题,可以采用两种方式,具体如下:
第一种方式:从业务逻辑上讲只想将元素Annie删除,至于后面还有多少元素我们并不关心,所以只需找到该元素后跳出循环不再迭代即可,也就是在第14行代码下面增加一个break语句,代码如下:
if ("Annie".equals(obj)) { list.remove(obj); break; }
在使用break语句跳出循环以后,由于没有继续使用迭代器对集合中的元素进行迭代,因此,集合中删除元素对程序没有任何影响,不会出现异常。
第二种方式:如果需要在集合的迭代期间对集合中的元素进行删除,可以使用迭代器本身的删除方法,将文件3中第14行代码替换成it.remove()即可解决这个问题,代码如下:
if ("Annie".equals(obj)) { it.remove(); }
替换代码后再次运行程序,运行结果如图4所示。
图4 运行结果
从图4可以看出,元素Annie确实被删除了,并且没有出现异常。因此可以得出结论,调用迭代器对象的remove()方法删除元素所导致的迭代次数变化,对于迭代器对象本身来讲是可预知的。
在JDK 8中,根据Lambda表达式特性还新增了一个forEach(Consumer action)方法来遍历集合,该方法所需要的参数是一个函数式接口。接下来通过一个案例来演示如何使用forEach(Consumer action)遍历集合对象,如文件1所示。
文件1 Example07.java
1 import java.util.ArrayList; 2 public class Example07 { 3 public static void main(String[] args) { 4 ArrayList list = new ArrayList(); 5 list.add("data_1"); 6 list.add("data_2"); 7 list.add("data_3"); 8 System.out.println(list); 9 // 使用JDK 8新增的forEach(Consumer action)方法遍历集合 10 list.forEach(obj -> System.out.println("迭代集合元素:"+obj)); 11 } 12 }
运行结果如图1所示。
图1 运行结果
文件1中,首先创建了一个集合对象并调用add()方法向集合添加三个元素,接着打印该集合信息,然后使用forEach(Consumer action)方法对集合中的元素进行遍历,该方法传递的是一个Lambda表达式形式书写的函数式接口。forEach(Consumer action)方法在执行时,会自动遍历集合元素并将元素逐个传递给Lambda表达式的形参。
JDK 8中除了针对所有集合类型对象新增了一个forEach(Consumer action)方法来遍历集合外,还针对Iterator迭代器对象提供了一个forEachRemaining(Consumer action)方法来进行遍历,该方法同样需要一个函数式接口。接下来通过一个案例来演示如何使用forEachRemaining(Consumer action)方法遍历Iterator接口元素,如文件2所示。
文件2 Example08.java
1 import java.util.ArrayList; 2 import java.util.Iterator; 3 public class Example08 { 4 public static void main(String[] args) { 5 ArrayList list = new ArrayList(); 6 list.add("data_1"); 7 list.add("data_2"); 8 list.add("data_3"); 9 System.out.println(list); 10 // 将集合转换为Iterator迭代器对象 11 Iterator it = list.iterator(); 12 // 使用JDK 8新增的forEachRemaining(Consumer action)来遍历迭代器对象 13 it.forEachRemaining(obj -> System.out.println("迭代集合元素:"+obj)); 14 } 15 }
运行结果如图2所示。
图2 运行结果
同文件1对比可以看出,文件2中只是将集合对象转换为Iterator迭代器对象,然后使用forEachRemaining(Consumer action)方法对集合中的元素进行遍历,而从程序运行结果看出,两种集合遍历方式得到的结果完全相同,都能够正确遍历出集合元素。
小提示:
在本小节List集合遍历中,讲解了List集合遍历的三种主要方法,这些方法都可以用来对单例集合Collection进行遍历,包括List集合以及后面将要讲解的Set集合。
Set接口和List接口一样,同样继承自Collection接口,它与Collection接口中的方法基本一致,并没有对Collection接口进行功能上的扩充,只是比Collection接口更加严格。与List接口不同的是,Set接口中的元素无序,并且都会以某种规则保证存入的元素不出现重复。
Set接口主要有两个实现类,分别是HashSet和TreeSet。其中,HashSet是根据对象的哈希值来确定元素在集合中的存储的位置,因此具有良好的存取和查找性能;TreeSet则是以二叉树的方式来存储元素,它可以实现对集合中的元素进行排序。接下来,将对Set集合的这两个实现类进行详细地讲解。
HashSet是Set接口的一个实现类,它所存储的元素是不可重复的,并且元素都是无序的。当向HashSet集合中添加一个元素时,首先会调用该元素的hashCode()方法来确定元素的存储位置,然后再调用元素对象的equals()方法来确保该位置没有重复元素。Set集合与List集合存取元素的方式都一样,在此不再进行赘述,接下来通过一个案例来演示HashSet集合的用法,如文件1所示。
文件1 Example09.java
1 import java.util.HashSet; 2 public class Example09 { 3 public static void main(String[] args) { 4 HashSet set = new HashSet(); 5 set.add("Jack"); 6 set.add("Eve"); 7 set.add("Rose"); 8 set.add("Rose"); // 向该Set集合中添加重复元素 9 // 遍历输出Set集合中的元素 10 set.forEach(o -> System.out.println(o)); 11 } 12 }
运行结果如图1所示。
图1 运行结果
文件1中,首先通过add()方法向HashSet集合依次添加了四个字符串元素,然后通过forEach(Consumer action)方法遍历所有的元素并输出打印。从打印结果可以看出取出元素的顺序与添加元素的顺序并不一致,并且重复存入的字符串元素“Rose”被去除了,只添加了一次。
HashSet集合之所以能确保不出现重复的元素,是因为它在存入元素时做了很多工作。当调用HashSet集合的add()方法存入元素时,首先调用当前存入元素的hashCode()方法获得对象的哈希值,然后根据对象的哈希值计算出一个存储位置。如果该位置上没有元素,则直接将元素存入,如果该位置上有元素存在,则会调用equals()方法让当前存入的元素依次和该位置上的元素进行比较,如果返回的结果为false就将该元素存入集合,返回的结果为true则说明有重复元素,就将该元素舍弃。整个存储的流程如图2所示。
图2 HashSet对象存储过程
根据前面的分析不难看出,当向集合中存入元素时,为了保证HasheSet正常工作,要求在存入对象时,需要重写Object类中的hashCode()和equals()方法。文件6-9中将字符串存入HashSet时,String类已经默认重写了hashCode()和equals()方法。但是如果将开发者自定义的类型对象存入HashSet,结果又如何呢?接下来通过一个案例来进行演示将Student类型对象存入HashSet,如文件2所示。
文件2 Example10.java
1 import java.util.*; 2 class Student { 3 String id; 4 String name; 5 public Student(String id,String name) { 6 this.id=id; 7 this.name = name; 8 } 9 public String toString() { 10 return id+":"+name; 11 } 12 } 13 public class Example10 { 14 public static void main(String[] args) { 15 HashSet hs = new HashSet(); 16 Student stu1 = new Student("1", "Jack"); 17 Student stu2 = new Student("2", "Rose"); 18 Student stu3 = new Student("2", "Rose"); 19 hs.add(stu1); 20 hs.add(stu2); 21 hs.add(stu3); 22 System.out.println(hs); 23 } 24 }
运行结果如图3所示。
图3 运行结果
在文件2中,向HashSet集合存入三个Student对象,然后打印该Set集合进行查看。图6-17所示的运行结果中出现了两个相同的学生信息“2:Rose”,这样的学生信息应该被视为重复元素,不允许同时出现在HashSet集合中。之所以没有去掉这样的重复元素是因为在定义Student类时没有重写hashCode()和equals()方法,因此创建的这两个学生对象sut2和sut3所引用的对象地址不同,所以HashSet集合会认为这是两个不同的对象。接下来针对文件2中的Student类进行改写,增加重写的hashCode()和equals()方法,假设id相同的学生就是同一个学生,修改后的代码示例如下:
class Student { private String id; private String name; public Student(String id, String name) { this.id = id; this.name = name; } // 重写toString()方法 public String toString() { return id + ":" + name; } // 重写hashCode()方法 public int hashCode() { return id.hashCode(); // 返回id属性的哈希值 } // 重写equals()方法 public boolean equals(Object obj) { if (this == obj) { // 判断是否是同一个对象 return true; // 如果是,直接返回true } if (!(obj instanceof Student)) { // 判断对象是为Student类型 return false; // 如果对象不是Student类型,返回false } Student stu = (Student) obj; // 将对象强转为Student类型 boolean b = this.id.equals(stu.id); // 判断id值是否相同 return b; // 返回判断结果 }
再次运行文件2,结果如图4所示。
图4 运行结果
在修改后的文件2中,Student类重写了Object类的hashCode()和equals()方法。在hashCode()方法中返回id属性的哈希值,在equals()方法中比较对象的id是否相等,并返回结果。当调用HashSet集合的add()方法添加stu3对象时,发现它的哈希值与stu2对象相同,而且stu2.equals(stu3)返回true,HashSet集合认为两个对象相同,因此重复的Student对象被舍弃了。
TreeSet是Set接口的另一个实现类,它内部采用平衡二叉树来存储元素,这样的结构可以保证TreeSet集合中没有重复的元素,并且可以对元素进行排序。所谓二叉树就是说每个节点最多有两个子节点的有序树,每个节点及其子节点组成的树称为子树,通常左侧的子节点称为“左子树”,右侧的节点称为“右子树”,其中左子树上的元素小于它的根结点,而右子树上的元素大于它的根结点。二叉树中元素的存储结构如图1所示。
图1 二叉树的存储结构
在图1所示的二叉树中,同一层的元素,左边的元素总是小于右边的元素。为了使初学者更好的理解TreeSet集合中二叉树存放元素的原理,接下来就分析一下二叉树中元素的存储过程。当二叉树中存入新元素时,新元素首先会与第1个元素(最顶层元素)进行比较,如果小于第1个元素就执行左边的分支,继续和该分支的子元素进行比较;如果大于第1个元素就执行右边的分支,继续和该分支的子元素进行比较。如此往复,直到与最后一个元素进行比较时,如果新元素小于最后一个元素就将其放在最后一个元素的左子树上,如果大于最后一个元素就将其放在最后一个元素的右子树上。
上面通过文字描述的方式对二叉树的存储原理进行了讲解,接下来通过一个具体的图例来演示二叉树的存储过程。假设向集合中存入8个元素,依次为13、8、17、17、1、11、15、25,如果以二叉树的方式来存储,在集合中的存储结构会形成一个树状结构,如图2所示。
图2 二叉树
从图2可以看出,在向TreeSet集合依次存入元素时,首先将第1个存入的元素放在二叉树的最顶端,之后存入的元素与第一个元素比较,如果小于第一个元素就将该元素放左子树上,如果大于第1个元素,就将该元素放在右子树上,依次类推,按照左子树元素小于右子树元素的顺序进行排序。当二叉树中已经存入一个17的元素时,再向集合中存入一个为17的元素时,TreeSet会将重复的元素去掉。
针对TreeSet集合存储元素的特殊性,TreeSet在继承Set接口的基础上实现了一些特有的方法,如表1所示。
表1 TreeSet集合的特有方法
方法声明 | 功能描述 |
---|---|
Object first() | 返回TreeSet集合的首个元素 |
Object last() | 返回TreeSet集合的最后一个元素 |
Object lower(Object o) | 返回TreeSet集合中小于给定元素的最大元素,如果没有返回null |
Object floor(Object o) | 返回TreeSet集合中小于或等于给定元素的最大元素,如果没有返回null |
Object higher(Object o) | 返回TreeSet集合中大于给定元素的最小元素,如果没有返回null |
Object ceiling(Object o) | 返回TreeSet集合中大于或等于给定元素的最小元素,如果没有返回null |
Object pollFirst() | 移除并返回集合的第一个元素 |
Object pollLast() | 移除并返回集合的最后一个元素 |
了解了TreeSet集合存储元素的原理和一些常用元素操作方法后,接下来通过一个案例来演示TreeSet集合中常用方法的使用,如文件1所示。
文件1 Example11.java
1 import java.util.TreeSet; 2 public class Example11 { 3 public static void main(String[] args) { 4 // 创建TreeSet集合 5 TreeSet ts = new TreeSet(); 6 // 1、向TreeSet集合中添加元素 7 ts.add(3); 8 ts.add(9); 9 ts.add(1); 10 ts.add(21); 11 System.out.println("创建的TreeSet集合为:"+ts); 12 // 2、获取首尾元素 13 System.out.println("TreeSet集合首元素为:"+ts.first()); 14 System.out.println("TreeSet集合尾部元素为:"+ts.last()); 15 // 3、比较并获取元素 16 System.out.println("集合中小于或等于9的最大的一个元素为:" 17 +ts.floor(9)); 18 System.out.println("集合中大于10的最小的一个元素为:"+ts.higher(10)); 19 System.out.println("集合中大于100的最小的一个元素为:" 20 +ts.higher(100)); 21 // 4、删除元素 22 Object first = ts.pollFirst(); 23 System.out.println("删除的第一个元素是:"+first); 24 System.out.println("删除第一个元素后TreeSet集合变为:"+ts); 25 } 26 }
运行结果如图3所示。
图3 运行结果
从图3可以看出,使用TreeSet集合的方法正确完成了集合元素的操作。另外从输出结果也可以看出,向TreeSet集合添加元素时,不论元素的添加顺序如何,这些元素都能够按照一定的顺序进行排列,其原因是每次向TreeSet集合中存入一个元素时,就会将该元素与其他元素进行比较,最后将它插入到有序的对象序列中。集合中的元素在进行比较时,都会调用compareTo()方法,该方法是Comparable接口中定义的,因此要想对集合中的元素进行排序,就必须实现Comparable接口。Java中大部分的类都实现了Comparable接口,并默认实现了接口中的CompareTo()方法,如Integer、Double和String等。
在实际开发中,除了会向TreeSet集合中存储一些Java中默认的类型数据外,还会存储一些用户自定义的类型数据,如Student类型数据、Teacher类型数据等。由于这些自定义类型的数据没有实现Comparable接口,因此也就无法直接在TreeSet集合中进行排序操作。为了解决这个问题,Java提供了两种TreeSet的排序规则,分别为:自然排序和定制排序。在默认情况下,TreeSet集合都是采用自然排序,接下来将对这两种排序规则进行详细讲解。
1.自然排序
自然排序要求向TreeSet集合中存储的元素所在类必须实现Comparable接口,并重写compareTo()方法,然后TreeSet集合就会对该类型元素使用compareTo()方法进行比较,并默认进行升序排序。
接下来,就以自定义的Teacher类为例,来演示TreeSet集合中自然排序的使用,如文件2所示。
文件2 Example12.java
1 import java.util.TreeSet; 2 // 定义Teacher类实现Comparable接口 3 class Teacher implements Comparable { 4 String name; 5 int age; 6 public Teacher(String name, int age) { 7 this.name = name; 8 this.age = age; 9 } 10 public String toString() { 11 return name + ":" + age; 12 } 13 //重写Comparable接口的compareTo()方法 14 public int compareTo(Object obj){ 15 Teacher s = (Teacher) obj; 16 // 定义比较方式,先比较年龄age,再比较名称name 17 if(this.age -s.age > 0) { 18 return 1; 19 } 20 if(this.age -s.age == 0) { 21 return this.name.compareTo(s.name); 22 } 23 return -1; 24 } 25 } 26 public class Example12 { 27 public static void main(String[] args) { 28 TreeSet ts = new TreeSet(); 29 ts.add(new Teacher("Jack",19)); 30 ts.add(new Teacher("Rose",18)); 31 ts.add(new Teacher("Tom", 19)); 32 ts.add(new Teacher("Rose",18)); 33 System.out.println(ts); 34 } 35 }
运行结果如图4所示。
图4 运行结果
文件2中,Teacher类实现了Comparable接口,并重写了compareTo()方法。在compareTo()方法中,首先先针对age值进行比较,根据比较结果返回-1和1,当age相同时,再对name进行比较。因此,从运行结果可以看出,教师Teacher对象首先按照年龄升序排序,年龄相同时会按照姓名进行升序排序,并且TreeSet集合会将重复的元素去掉。
2.定制排序
有时候,用户自定义的类型数据所在的类没有实现Comparable接口或者对于实现了Comparable接口的类而不想按照定义的compareTo()方法进行排序。例如,希望存储在TreeSet集合中的字符串可以按照长度而不是英文字母的顺序来进行排序,这时,可以通过在创建TreeSet集合时就自定义一个比较器来对元素进行定制排序。接下来通过一个案例来实现TreeSet集合中字符串按照长度进行定制排序,如文件3所示。
文件3 Example13.java
1 import java.util.Comparator; 2 import java.util.TreeSet; 3 // 定义比较器实现Comparator接口 4 class MyComparator implements Comparator { 5 public int compare(Object obj1, Object obj2) { // 定制排序方式 6 String s1 = (String) obj1; 7 String s2 = (String) obj2; 8 int temp = s1.length() - s2.length(); 9 return temp; 10 } 11 } 12 public class Example13 { 13 public static void main(String[] args) { 14 // 1、创建集合时,传入Comparator接口实现定制排序规则 15 TreeSet ts = new TreeSet(new MyComparator()); 16 ts.add("Jack"); 17 ts.add("Helena"); 18 ts.add("Eve"); 19 System.out.println(ts); 20 // 2、创建集合时,使用Lambda表达式定制排序规则 21 TreeSet ts2 = new TreeSet((obj1, obj2) -> { 22 String s1 = (String) obj1; 23 String s2 = (String) obj2; 24 return s1.length() - s2.length(); 25 }); 26 ts2.add("Jack"); 27 ts2.add("Helena"); 28 ts2.add("Eve"); 29 System.out.println(ts2); 30 } 31 }
运行结果如图5所示。
图5 运行结果
文件3中,使用了TreeSet集合的public TreeSet(Comparator<? super E> comparator)有参构造方法,分别传入Comparable接口实现类MyComparator以及Lambda表达式两种参数方式创建了定制序规则的TreeSet集合,当向集合中添加元素时,TreeSet集合就会按照定制的排序规则进行比较,从而使存入TreeSet集合中的字符串按照长度进行排序。
注意:
在使用TreeSet集合存储数据时,TreeSet集合会对存入元素进行比较排序,所以为了保证程序的正常运行,一定要保证存入TreeSet集合中的元素是同一种数据类型。
在现实生活中,每个人都有唯一的身份证号,通过身份证号可以查询到这个人的信息,这两者是一对一的关系。在应用程序中,如果想存储这种具有对应关系的数据,则需要使用Java中提供的Map接口。
Map接口是一种双列集合,它的每个元素都包含一个键对象Key和值对象Value,键和值对象之间存在一种对应关系,称为映射。Map中的映射关系是一对一的,一个键对象Key对应唯一一个值对象Value,其中键对象Key和值对象Value可以是任意数据类型,并且键对象Key不允许重复,这样在访问Map集合中的元素时,只要指定了Key,就能找到对应的Value。为了便于Map接口的学习,接下来首先了解一下Map接口中定义的一些常用方法,如表1所示。
表1 Map集合常用方法
方法声明 | 功能描述 |
---|---|
void put(Object key, Object value) | 向Map集合中添加指定键值映射的元素 |
int size() | 返回Map集合键值对映射的个数 |
Object get(Object key) | 返回指定键所映射的值,如果此映射不包含该键的映射关系,则返回null |
boolean containsKey(Object key) | 查看Map集合中是否存在指定的键对象key |
boolean containsValue(Object value) | 查看Map集合中是否存在指定的值对象value |
Object remove(Object key) | 删除并返回Map集合中指定键对象Key的键值映射元素 |
void clear() | 清空整个Map集合中的键值映射元素 |
Set keySet() | 以Set集合的形式返回Map集合中所有的键对象Key |
Collection values() | 以Collection集合的形式返回Map集合中所有的值对象Value |
Set<Map.Entry<Key,Value>> entrySet() | 将Map集合转换为存储元素类型为Map的Set集合 |
Object getOrDefault(Object key, Object defaultValue) | 返回Map集合指定键所映射的值,如果不存在则返回默认值defaultValue(JDK 8新方法) |
void forEach(BiConsumer action) | 通过传入一个函数式接口对Map集合元素进行遍历(JDK 8新方法) |
Object putIfAbsent(Object key, Object value) | 向Map集合中添加指定键值映射的元素,如果集合中已存在该键值映射元素,则不再添加而是返回已存在的值对象Value(JDK 8新方法) |
boolean remove(Object key, Object value) | 删除Map集合中键值映射同时匹配的元素(JDK 8新方法) |
boolean replace(Object key, Object value) | 将Map集合中指定键对象Key所映射的值修改为value(JDK 8新方法) |
在表1中,列举了双列集合根接口Map中的一些增、删、改、查的主要方法,另外JDK 8版本在原有方法的基础上新增了许多新方法针对Map集合进行操作,在后续小节将会对这些方法进行详细讲解。
HashMap集合是Map接口的一个实现类,它用于存储键值映射关系,该集合的键和值允许为空,但键不能重复,且集合中的元素是无序的。HashMap底层是由哈希表结构组成的,其实就是“数组+链表”的组合体,数组是HashMap的主体结构,链表则主要是为了解决哈希值冲突而存在的分支结构。正因为这样特殊的存储结构,HashMap集合对于元素的增、删、改、查操作表现出的效率都比较高。接下来,通过一张图来展示HashMap集合底层实现,如图1所示。
图1 HashMap集合内部结构及存储原理图
在图1所示结构中,水平方向以数组结构为主体并在竖直方向以链表结构进行结合的就是HashMap中的哈希表结构。在哈希表结构中,水平方向数组的长度称为HashMap集合的容量(capacity),竖直方向每个元素位置对应的链表结构称为一个桶(bucket),每个桶的位置在集合中都有对应的桶值,用于快速定位集合元素添加、查找时的位置。
图1中,在展示HashMap集合内部哈希表结构的基础上,也展示了存储元素的原理。当向HashMap集合添加元素时,首先会调用键对象k的hash(k)方法,快速定位并寻址到该元素在集合中要存储的位置。在定位到存储元素键对象k的哈希值所对应桶位置后,会出现两种情况:第一种情况,键对象k的hash值所在桶位置为空,则可以直接向该桶位置插入元素对象;第二种情况,键对象k的hash值所在桶位置不为空,则还需要继续通过键对象k的equals(k)方法比较新插入的元素键对象k和已存在的元素键对象k是否相同,如果键对象k相同,就会对原有元素的值对象v进行替换并返回原来的旧值,否则会在该桶的链表结构头部新增一个节点来插入新的元素对象。
接下来就通过一个案例来学习HashMap的基本用法,如文件1所示。
文件1 Example14.java
1 import java.util.HashMap; 2 import java.util.Map; 3 public class Example14 { 4 public static void main(String[] args) { 5 // 创建HashMap对象 6 Map map = new HashMap(); 7 // 1、向Map存储键值对元素 8 map.put("1", "Jack"); 9 map.put("2", "Rose"); 10 map.put("3", "Lucy"); 11 map.put("4", "Lucy"); 12 map.put("1", "Tom"); 13 System.out.println(map); 14 // 2、查看键对象是否存在 15 System.out.println(map.containsKey("1")); 16 // 3、获取指定键对象映射的值 17 System.out.println(map.get("1")); 18 // 4、获取集合中的键对象和值对象集合 19 System.out.println(map.keySet()); 20 System.out.println(map.values()); 21 // 5、替换指定键对象映射的值 22 map.replace("1", "Tom2"); 23 System.out.println(map); 24 // 6、删除指定键对象映射的键值对元素 25 map.remove("1"); 26 System.out.println(map); 27 } 28 }
运行结果如图2所示。
图2 运行结果
文件1中,首先使用Map的put(Object key,Object value)方法向集合中加入5个元素,然后通过HashMap的相关方法对集合中元素进行查询、修改、删除操作。从图2的结果可以看出,Map集合中的键具有唯一性,当向集合中添加已存在的键值元素时,会覆盖之前已存在的键值元素,如果需要可以接收返回来的旧值。
小提示:
在使用HashMap集合时,还需要考虑一个问题,如果通过键对象k定位到的桶位置不含链表结构,那么对于查找、添加等操作很快,仅需一次定位寻址即可;如果定位到的桶位置包含链表结构,对于添加操作,其时间复杂度依然不大,因为最新的元素会插入链表头部,只需要简单改变引用链即可;而对于查找操作来讲,此时就需要遍历链表,然后通过键对象k的equals(k)方法逐一查找比对,所以,从性能方面考虑,HashMap中的链表出现越少,性能才会越好,这就要求HashMap集合中的桶越多越好。
我们知道,HashMap的桶数目,就是集合中主体数组结构的长度,由于数组是内存中连续的存储单元,它占用的空间代价是很大的,但是它的随机存取速度是Java集合中最快的。通过增大桶的数量,而减少Entry<k,v>链表的长度,来提高从HashMap中读取数据的速度,这是典型的拿空间换时间的策略。但是我们不能刚开始就给HashMap分配过多的桶,这是因为数组是连续的内存空间,它的创建代价很大,况且我们也不能确定给HashMap分配多大的空间才够合理,为了解决这一个问题,HashMap内部采用了根据实际情况,动态地分配桶数量的策略。
HashMap这种动态分配桶的数量,是通过new HashMap()方法创建HashMap时,会默认集合容量capacity大小为16,加载因子loadFactor为0.75(HashMap桶多少权衡策略的经验值),此时该集合桶的阀值就为12(容量capacity与加载因子loadFactor的乘积),如果向HashMap集合中不断添加完全不同的键值对<k,v>,当超过12个存储元素时,HashMap集合就会默认新增加一倍桶的数量(也就是集合的容量),此时集合容量就变为32。当然,如果开发者对存取效率要求的不是太高,想节省点空间的话,可以使用new HashMap(int initialCapacity, float loadFactor)构造方法,在创建HashMap集合时指定集合容量和加载因子,并将这个加载因子设置得大一些。
在程序开发中,经常需要取出Map中所有的键和值,那么如何遍历Map中所有的键值对呢?Map集合遍历的方式和单列集合Collection集合遍历的方式基本相同,主要有两种方式可以实现:第一种方式,可以使用Iterator迭代器遍历集合;第二种方式就是使用JDK 8提供的forEach(Consumer action)方法遍历集合。接下来,就以前面学习的HashMap集合为例来分别对这两种集合遍历方式进行详细讲解。
1.Iterator迭代器遍历Map集合
使用Iterator迭代器遍历Map集合,需要先将Map集合转换为Iterator接口对象,然后进行遍历,由于Map集合中元素是由键值对组成的,所以使用Iterator接口遍历Map集合时,会有两种将Map集合转换为Iterator接口对象再进行遍历的方法:keySet()方法和entrySet()方法。
其中,keySet()方法,需要先将Map集合中所有键对象转换为Set单列集合,接着将包含键对象的Set集合转换为Iterator接口对象,然后遍历Map集合中所有的键,再根据键获取相应的值。接下来就通过一个案例来演示先遍历Map集合中所有的键,再根据键获取相应的值的方式,如文件1所示。
文件1 Example15.java
1 import java.util.*; 2 public class Example15 { 3 public static void main(String[] args) { 4 Map map = new HashMap(); // 创建Map集合 5 map.put("1", "Jack"); // 存储元素 6 map.put("2", "Rose"); 7 map.put("3", "Lucy"); 8 System.out.println(map); 9 Set keySet = map.keySet(); // 获取键的集合 10 Iterator it = keySet.iterator(); // 迭代键的集合 11 while (it.hasNext()) { 12 Object key = it.next(); 13 Object value = map.get(key); // 获取每个键所对应的值 14 System.out.println(key + ":" + value); 15 } 16 } 17 }
运行结果如图1所示。
图1 运行结果
文件1中,首先调用Map对象的KeySet()方法,获得存储Map集合中所有键的Set集合,然后通过Iterator迭代Set集合的每一个键元素,最后通过get(Objectkey)方法,根据键获取对应的值。
Map集合中的另外一种通过Iterator迭代器遍历集合的方式是使用entrySet()方法,该方法将原有Map集合中的键值对作为一个整体返回为Set集合,接着将包含键值对对象的Set集合转换为Iterator接口对象,然后获取集合中的所有的键值对映射关系,再从映射关系中取出键和值。接下来,将文件1中的第9~15行代码进行修改,通过使用entrySet()方法来进行Map集合遍历,修改后的代码示例如下所示:
import java.util.*; public class Example15 { public static void main(String[] args) { Map map = new HashMap(); map.put("1", "Jack"); map.put("2", "Rose"); map.put("3", "Lucy"); System.out.println(map); Set entrySet = map.entrySet(); Iterator it = entrySet.iterator(); // 获取Iterator对象 while (it.hasNext()) { // 获取集合中键值对映射关系 Map.Entry entry = (Map.Entry) (it.next()); Object key = entry.getKey(); // 获取Entry中的键 Object value = entry.getValue(); // 获取Entry中的值 System.out.println(key + ":" + value); } } }
再次运行文件1,运行结果如图2所示。
图2 运行结果
在上述修改的代码示例中,首先调用Map对象的entrySet()方法获得存储Map中所有键值映射的Set集合,这个集合中存放了Map.Entry类型的元素(Entry是Map接口内部类),每个Map.Entry对象代表Map中的一个键值对,然后迭代Set集合,获得每一个映射对象,并分别调用映射对象的getKey()和getValue()方法获取键和值。
2.JDK 8新方法遍历集合Map集合
与Collection集合遍历类似,在JDK 8中也根据Lambda表达式特性新增了一个forEach(BiConsumer action)方法来遍历Map集合,该方法所需要的参数也是一个函数式接口,因此可以使用Lambda表达式的书写形式来进行集合遍历。接下来通过一个案例来演示如何使用forEach(BiConsumer action)方法遍历Map集合,如文件2所示。
文件2 Example16.java
1 import java.util.HashMap; 2 import java.util.Map; 3 public class Example16 { 4 public static void main(String[] args) { 5 Map map = new HashMap(); 6 map.put("1", "Jack"); 7 map.put("2", "Rose"); 8 map.put("3", "Lucy"); 9 System.out.println(map); 10 // 使用JDK 8新增的forEach(BiConsumer action)方法遍历集合 11 map.forEach((key,value) -> System.out.println(key + ":" + value)); 12 } 13 }
运行结果如图3所示。
外链图片转存中…(img-4PEuVySJ-1624149947653)
图3 运行结果
文件2中,使用了JDK 8中Map集合新增的forEach(BiConsumer action)方法对集合中的元素进行遍历,该方法传递的是一个Lambda表达式书写的函数式接口BiConsumer。forEach(BiConsumer action)方法在执行时,会自动遍历集合元素的键和值并将结果逐个传递给Lambda表达式的形参。
在Map集合中,除了以上两种主要的遍历方式外,还提供了一个values()方法,通过这个方法可以直接获取Map中存储所有值的Collection集合,接下来通过一个案例来演示,如文件3所示。
文件3 Example17.java
1 import java.util.*; 2 public class Example17 { 3 public static void main(String[] args) { 4 Map map = new HashMap(); 5 map.put("1", "Jack"); 6 map.put("2", "Rose"); 7 map.put("3", "Lucy"); 8 System.out.println(map); 9 Collection values = map.values(); // 获取Map集合中value值集合对象 10 // 遍历Map集合所有值对象V 11 values.forEach(v -> System.out.println(v)); 12 } 13 }
运行结果如图4所示。
图4 运行结果
在文件3中,通过调用Map的values()方法获取包含Map中所有值的Collection集合,然后迭代出集合中的每一个值。
多学一招:使用LinkedHashMap集合保证元素添加顺序
在前面小节介绍HashMap集合时,已经说明HashMap集合并不保证集合元素存入和取出的顺序。如果想让这两个顺序一致,可以使用Java中提供的LinkedHashMap类,它是HashMap的子类,和LinkedList一样也使用双向链表来维护内部元素的关系,使LinkedHashMap元素迭代的顺序与存入的顺序一致,接下来通过一个案例来学习一下LinkedHashMap的用法,如文件4所示。
文件4 Example18.java
1 import java.util.*; 2 public class Example18 { 3 public static void main(String[] args) { 4 Map map1 = new HashMap(); // 创建HashMap集合 5 map1.put(2, "Rose"); 6 map1.put(1, "Jack"); 7 map1.put(3, "Lucy"); 8 map1.forEach((key,value) -> System.out.println(key + ":" + value)); 9 System.out.println("====================="); 10 Map map2 = new LinkedHashMap(); // 创建LinkedHashMap集合 11 map2.put(2, "Rose"); 12 map2.put(1, "Jack"); 13 map2.put(3, "Lucy"); 14 map2.forEach((key,value) -> System.out.println(key + ":" + value)); 15 } 16 }
运行结果如图5所示。
[外链图片转存中…(imgaOCMNAetnhYEnc-162415087285)]](https://www.www.zyiz.net/i/ll/?i=img_convert/3a27fb4ffa2bb1ba80ca1bb1ce83dee7.png)
图5 运行结果
在文件4中,分别创建了HashMap和LinkedHashMap两个集合,并按相同的顺序插入了相同的元素,然后使用forEach(BiConsumer action)方法将元素遍历取出。从运行结果可以看出,使用HashMap集合取出的元素并不能保证与存入元素顺序一致,而LinkedHashMap集合却可以保证存入和取出的一致性。
一般情况下,我们用的最多的是HashMap,在Map中插入、删除和定位元素,HashMap 是最好的选择。但如果需要输出的顺序和输入的相同,那么用LinkedHashMap可以实现,它还可以按读取顺序来排列。
在Java中,Map接口还有一个常用的实现类TreeMap,它也是用来存储键值映射关系的,并且不允许出现重复的键。在TreeMap内部是通过二叉树的原理来保证键的唯一性,这与TreeSet集合存储的原理一样,因此TreeMap中所有的键是按照某种顺序排列的。接下来通过一个案例来了解TreeMap的具体用法,如文件1所示。
文件1 Example19.java
1 import java.util.Map; 2 import java.util.TreeMap; 3 public class Example19 { 4 public static void main(String[] args) { 5 Map map = new TreeMap(); 6 map.put("2", "Rose"); 7 map.put("1", "Jack"); 8 map.put("3", "Lucy"); 9 System.out.println(map); 10 } 11 }
运行结果如图1所示。
图1 运行结果
在文件1中,首先创建了一个TreeMap集合,并使用put()方法按顺序向集合中添加了3个元素然后打印出集合信息。从运行结果可以看出,取出的元素按照键对象的自然顺序进行了排序,这是因为添加的元素中键对象是String类型,String类实现了Comparable接口,因此默认会按照自然顺序对元素进行排序。
同TreeSet集合一样,在使用TreeMap集合时,也可以通过自定义比较器Comparator的方式对所有的键进行定制排序。接下来对文件6-19进行修改,将集合中的元素按照键对象由大到小进行排序,如文件2所示。
文件2 Example20.java
1 import java.util.*; 2 // 自定义比较器 3 class CustomComparator implements Comparator { 4 public int compare(Object obj1, Object obj2) { 5 String key1 = (String) obj1; 6 String key2 = (String) obj2; 7 return key2.compareTo(key1); // 将比较之后的值返回 8 } 9 } 10 public class Example20 { 11 public static void main(String[] args) { 12 Map map = new TreeMap(new CustomComparator()); 13 map.put("2", "Rose"); 14 map.put("1", "Jack"); 15 map.put("3", "Lucy"); 16 System.out.println(map); 17 } 18 }
运行结果如图2所示。
图2 运行结果
文件2中定义了比较器CustomComparator针对String类型的键对象k进行比较,在实现compare()方法时,调用了String对象的compareTo()方法。由于方法中返回的是“key2.compareTo(key1)”,因此最终输出结果中的元素按照键对象从大到小的顺序进行了排序。
Map接口还有一个实现类Hashtable,它和HashMap十分相似,其中一个主要区别在于Hashtable是线程安全的。另外在使用方面,Hashtable的效率也不及HashMap,所以,目前基本上被HashMap类所取代,但Hashtable类有一个子类Properties在实际应用中非常重要。Properties主要用来存储字符串类型的键和值,在实际开发中,经常使用Properties集合类来存取应用的配置项。
假设有一个文本编辑工具,要求默认背景色是红色,字体大小为14px,语言为中文,这些要求就可以使用Properties集合类对应的properties文件进行配置,效果如文件1所示(假设配置文件的文件名为test.properties)。
文件1 test.properties
1 Backgroup-color = red 2 Font-size = 14px 3 language = chinese
接下来,就通过一个案例来学习Properties集合类如何对properties配置文件进行读取和写入操作(假设test.Properties配置文件在项目的根目录下),如文件2所示。
文件2 Example22.java
1 import java.io.FileInputStream; 2 import java.io.FileOutputStream; 3 import java.util.Properties; 4 public class Example22 { 5 public static void main(String[] args) throws Exception { 6 // 1、通过Properties进行属性文件读取操作 7 Properties pps = new Properties(); 8 // 加载要读取的文件test.properties 9 pps.load(new FileInputStream("test.properties")); 10 // 遍历test.properties键值对元素信息 11 pps.forEach((k, v) -> System.out.println(k + "=" + v)); 12 // 2、通过Properties进行属性文件写入操作 13 // 指定要写入操作的文件名称和位置 14 FileOutputStream out = new FileOutputStream("test.properties"); 15 // 向Properties类文件进行写入键值对信息 16 pps.setProperty("charset", "UTF-8"); 17 // 将此 Properties集合中新增键值对信息写入配置文件 18 pps.store(out, "新增charset编码"); 19 } 20 }
运行结果如图1所示。
图1 运行结果
执行完文件1程序后,刷新并查看原有的test.properties配置文件中的内容,如图2所示。
图2 test.properties配置文件内容
在文件1中,首先创建了Properties集合对象,然后通过IO流的形式读取了test.properties配置文件中的内容,并进行遍历,完成了Properties集合读取properties配置文件的操作。接着,同样通过IO流的形式指定了要进行写入操作的文件地址和名称,使用Properties的setProperty()方法新增了一个键值对元素,并使用store()方法将新增信息写入到properties配置文件中。在该文件中涉及到的配置文件test.properties是通过IO流的形式进行操作的,关于IO流的有关内容将会在下一章进行详细讲解,此处不必深究。
通过前面的学习,了解到集合中可以存储任意类型的对象元素,但是当把一个对象存入集合后,集合会“忘记”这个对象的类型,将该对象从集合中取出时,这个对象的编译类型就统一变成了Object类型。换句话说,在程序中无法确定一个集合中的元素到底是什么类型,那么在取出元素时,如果进行强制类型转换就很容易出错。接下来通过一个案例来演示这种情况,如文件1所示。
文件1 Example23.java
1 import java.util.ArrayList; 2 public class Example23 { 3 public static void main(String[] args) { 4 ArrayList list = new ArrayList(); 5 list.add("String"); // 添加字符串对象 6 list.add("Collection"); 7 list.add(1); // 添加Integer对象 8 for (Object obj : list) { 9 String str = (String) obj; // 强制转换成String类型 10 System.out.println(str); 11 } 12 } 13 }
运行结果如图1所示。
图1 运行结果
文件1中,向List集合存入3个元素,分别是两个字符串和一个整数。在取出这些元素时,都将它们强转为String类型,由于Integer对象无法转换为String类型,因此在程序运行时会出现如图6-35所示“ClassCastException(类型转换异常)”的错误。为了解决这个问题,在Java中引入了“参数化类型(parameterized type)”这个概念,即泛型。泛型可以限定操作的数据类型,在定义集合类时,可以使用“<参数化类型>”的方式指定该集合中存储的数据类型,具体格式如下:
ArrayList<参数化类型> list = new ArrayList<参数化类型>();
接下类对文件1中的第4行代码进行进行修改,使用泛型来限定ArrayList集合中只能存储String类型的数据,如下所示:
ArrayList<String> list = new ArrayList<String>();
代码修改之后,会发现程序在编译时期就会出现错误提示,如图2所示。
图2 编译错误
在图2中,程序编译报错的原因是修改后的代码限定了集合元素的数据类型,ArrayList这样的集合只能存储String类型的元素,程序在编译时,编译器检查出Integer类型的元素与List集合的规定的类型不匹配,编译不通过,这样就可以在编译时期解决错误,避免程序在运行时期发生错误。
接下来使用泛型再次对文件1进行改写,如文件2所示。
文件2 Example24.java
1 import java.util.ArrayList; 2 public class Example24 { 3 public static void main(String[] args) { 4 // 创建ArrayList集合,使用泛型 5 ArrayList<String> list = new ArrayList<String>(); 6 list.add("String"); // 添加字符串对象 7 list.add("Collection"); 8 for (String str : list) { // 遍历集合,可以直接指定元素类型 9 System.out.println(str); 10 } 11 } 12 }
运行结果如图3所示。
图3 运行结果
文件2中,使用泛型规定了ArrayList集合只能存入String类型元素,之后向集合中存入了两个String类型元素,并对这个集合进行遍历,从图3可以看出,该文件可以正常运行。需要注意的是,在使用泛型后每次遍历集合元素时,可以指定元素类型为String,而不是Object,这样就避免了在程序中进行强制类型转换。
在Java中,针对集合的操作非常频繁,例如对集合中的元素排序、从集合中查找某个元素等。针对这些常见操作,Java提供了一个工具类专门用来操作集合,这个类就是Collections,它位于java.util包中。Collections类中提供了大量的静态方法用于对集合中元素进行排序、查找和修改等操作,接下来对这些常用的方法进行讲解。
1.添加、排序操作
Collections类中提供了一系列方法用于对List集合进行添加和排序操作,如表1所示。
表1 Collections常用添加和排序方法
方法声明 | 功能描述 |
---|---|
static boolean addAll(Collection<? super T> c, T… elements) | 将所有指定元素添加到指定集合c中 |
static void reverse(List list) | 反转指定List集合中元素的顺序 |
static void shuffle(List list) | 对List集合中的元素进行随机排序 |
static void sort(List list) | 根据元素的自然顺序对List集合中的元素进行排序 |
static void swap(List list,int i,int j) | 将指定List集合中角标i处元素和j处元素进行交换 |
接下来通过一个案例针对表中的方法进行学习,如文件1所示。
文件1 Example25.java
1 import java.util.ArrayList; 2 import java.util.Collections; 3 public class Example25 { 4 public static void main(String[] args) { 5 ArrayList<String> list = new ArrayList<>(); 6 Collections.addAll(list, "C","Z","B","K"); // 添加元素 7 System.out.println("排序前: " + list); 8 Collections.reverse(list); // 反转集合 9 System.out.println("反转后: " + list); 10 Collections.sort(list); // 按自然顺序排列 11 System.out.println("按自然顺序排序后: " + list); 12 Collections.shuffle(list); // 随机打乱集合元素 13 System.out.println("按随机顺序排序后: " + list); 14 Collections.swap(list, 0, list.size()-1); // 将集合首尾元素交换 15 System.out.println("集合首尾元素交换后: " + list); 16 } 17 }
运行结果如图1所示。
图1 运行结果
2.查找、替换操作
Collections类还提供了一些常用方法用于查找和替换集合中的元素,如表2所示。
表2 Collections常用查找和替换方法
方法声明 | 功能描述 |
---|---|
static int binarySearch(List list,Object key) | 使用二分法搜索指定对象在List集合中的索引,查找的List集合中的元素必须是有序的 |
static Object max(Collection col) | 根据元素的自然顺序,返回给定集合中最大的元素 |
static Object min(Collection col) | 根据元素的自然顺序,返回给定集合中最小的元素 |
static boolean replaceAll(List list,Object oldVal,Object newVal) | 用一个新值newVal替换List集合中所有的旧值oldVal |
接下来对表中的方法通过一个案例来演示如何查找、替换集合中的元素,如文件2所示。
文件2 Example26.java
1 import java.util.ArrayList; 2 import java.util.Collections; 3 public class Example26 { 4 public static void main(String[] args) { 5 ArrayList<Integer> list = new ArrayList<>(); 6 Collections.addAll(list, -3,2,9,5,8);// 向集合中添加所有指定元素 7 System.out.println("集合中的元素: " + list); 8 System.out.println("集合中的最大元素: " + Collections.max(list)); 9 System.out.println("集合中的最小元素: " + Collections.min(list)); 10 Collections.replaceAll(list, 8, 0); // 将集合中的8用0替换掉 11 System.out.println("替换后的集合: " + list); 12 Collections.sort(list); //使用二分查找前,必须保证元素有序 13 System.out.println("集合排序后为: "+list); 14 int index = Collections.binarySearch(list, 9); 15 System.out.println("集合通过二分查找方法查找元素9所在角标为:"+index); 16 } 17 }
运行结果如图2所示。
图2 运行结果
Collections工具类中还有一些其他方法,有兴趣的读者,可以根据需要自学API帮助文档,这里就不再介绍了。
在java.util包中,除了针对集合操作提供了一个集合工具类Collections,还针对数组操作提供了一个数组工具类——Arrays。Arrays工具类提供了大量针对数组操作的静态方法,接下来就对其中一些常用方法进行讲解。
1.使用sort()方法排序
在前面学习数组时,要想对数组进行排序就需要自定义一个排序方法,其实也可以使用Arrays工具类中的静态方法sort()来实现这个功能,接下来通过一个案例来学习sort()方法的使用,如文件1所示。
文件1 Example27.java
1 import java.util.Arrays; 2 public class Example27 { 3 public static void main(String[] args) { 4 int[] arr = { 9, 8, 3, 5, 2 }; // 初始化一个数组 5 System.out.print("排序前:"); 6 printArray(arr); // 打印原数组 7 Arrays.sort(arr); // 调用Arrays的sort()方法排序 8 System.out.print("排序后:"); 9 printArray(arr); // 打印排序后数组 10 } 11 // 定义打印数组元素方法 12 public static void printArray(int[] arr) { 13 System.out.print("["); 14 for (int x = 0; x < arr.length; x++) { 15 if (x != arr.length - 1) { 16 System.out.print(arr[x] + ", "); 17 } else { 18 System.out.println(arr[x] + "]"); 19 } 20 } 21 } 22 }
运行结果如图1所示。
图1 运行结果
从图1可以看出,使用Arrays的sort()方法时将会按照自然顺序对数组元素进行排序,使用非常方便。针对数组排序,数组工具类Arrays还提供了其他多个重载的sort()方法,既可以按照自然顺序进行排序,也可以传入比较器参数按照定制规则排序,同时还支持选择排序的元素范围。
2.使用binarySearch(Object[] a, Object key)方法查找元素
程序开发中,经常会在数组中查找某些特定的元素,如果数组中元素较多时查找某个元素就会非常繁琐,为此,Arrays工具类中还提供了一个binarySearch(Object[] a, Object key)方法用于查找元素,接下来通过一个案例来学习该方法的使用,如文件2所示。
文件2 Example28.java
1 import java.util.Arrays; 2 public class Example28 { 3 public static void main(String[] args) { 4 int[] arr = { 9, 8, 3, 5, 2 }; 5 Arrays.sort(arr); // 对数组进行排序 6 int index = Arrays.binarySearch(arr, 3); // 查找指定元素3 7 System.out.println("元素3的索引是:" + index); 8 } 9 }
运行结果如图2所示。
图2 运行结果
从图2可以看出,使用Arrays的bianrySearch(Object[] a, Object key)方法查找出了3在数组中的索引为1(排序后的数组索引)。需要注意的是,bianrySearch()方法只能针对排序后的数组进行元素查找,因为该方法采用的是二分法查找。所谓二分法查找就是每次将指定元素和数组中间位置的元素进行比较,从而排除掉其中的一半元素,这样的查找是非常高效的。接下来通过一个图例来演示二分法查找元素的过程,如图3所示。
图3 二分查找法
图3中的start、end和mid(mid=(start+end)/2)分别代表在数组中查找区间的开始索引、结束索引和中间索引,假设查找的元素为key,接下来分步骤讲解元素的查找过程。
第1步,判断开始索引start和结束索引end,如果start<=end,则key和arr[mid]进行比较,如果两者相等,说明找到了该元素;如果不相等,则需要进入第2步继续比较二者的大小。
第2步,将key和arr[mid]继续进行比较,如果key<arr[mid],表示查找的值处于索引start和mid之间,这时执行第3步;否则表示要查找的值处于索引mid和end之间,这时执行第4步。
第3步,将查找区间的结束索引end置为mid-1,开始索引不变,中间索引mid重新置为(start+end)/2,继续查找,直到start>end,表示查找的数组不存在,这时执行第5步。
第4步,将查找区间的开始索引start置为mid+1,结束索引不变,中间索引mid重新置为(start+end)/2,继续查找,直到start>end,表示查找的数组不存在,这时执行第5步。
第5步,返回“(插入点)-(start+1)”。这个“插入点”指的是大于key值的第一个元素在数组中的位置,如果数组中所有的元素值都小于要查找的对象,“插入点”就等于“-array.length”。
3.使用copyOfRange(int[] original, int from, int to)方法拷贝元素
在程序开发中,经常需要在不破坏原数组的情况下使用数组中的部分元素,这时可以使用Arrays工具类的copyOfRange(int[] original,int from,int to)方法将数组中指定范围的元素复制到一个新的数组中,该方法中参数original表示被复制的数组,from表示被复制元素的初始索引(包括),to表示被复制元素的最后索引(不包括),接下来通过一个案例来学习如何拷贝数组,如文件3所示。
文件3 Example29.java
1 import java.util.Arrays; 2 public class Example29 { 3 public static void main(String[] args) { 4 int[] arr = { 9, 8, 3, 5, 2 }; 5 // 复制一个指定范围的数组 6 int[] copied = Arrays.copyOfRange(arr, 1, 7); 7 for (int i = 0; i < copied.length; i++) { 8 System.out.print(copied[i] + " "); 9 } 10 } 11 }
运行结果如图4所示。
图4 运行结果
文件3中,使用Arrays的copyOfRange(arr, 1, 7)方法将数组{ 9, 8, 3, 5, 2 }中从arr[1](包括开始索引对应的元素)到arr[7](不包括结束索引对应的元素)这6个元素复制到新数组copied中,由于原数组arr 的最大索引为4,因此只有arr[1]到arr[4]这四个元素“8,3,5,2”复制到了新数组copied中,另外两个元素放入了int类型数组的默认值0。
4.使用fill(Object[] a, Object val)方法替换元素
程序开发中,可能会需要将一个数组中的所有元素替换成同一个元素,此时可以使用Arrays工具类的fill(Object[] a, Object val)方法,该方法可以将指定的值赋给数组中的每一个元素,接下来通过一个案例来演示如何替换元素,如文件4所示。
文件4 Example30.java
1 import java.util.Arrays; 2 public class Example30 { 3 public static void main(String[] args) { 4 int[] arr = { 1, 2, 3, 4 }; 5 Arrays.fill(arr, 8); // 用8替换数组中的每个元素 6 for (int i = 0; i < arr.length; i++) { 7 System.out.println(i + ": " + arr[i]); 8 } 9 } 10 }
运行结果如图5所示。
图5 运行结果
从图5可以看出,在调用了Arrays工具类的fill(arr,8)方法后,数组arr中的元素全部被替换为8。
Arrays工具类中还有一些其他方法,有兴趣的读者,同样可以根据需要自学API帮助文档,这里就不再介绍了。
在开发中,多数情况下会涉及到对集合、数组中元素的操作,在JDK 8之前都是通过普通的循环遍历出每一个元素,然后还会穿插一些if条件语句选择性的对元素进行查找、过滤、修改等操作,这种原始的操作方法虽然可行,但是代码量较大并且执行效率较低。
为此,JDK 8中新增了一个Stream接口,该接口可以将集合、数组的中的元素转换为Stream流的形式,并结合Lambda表达式的优势来进一步简化集合、数组中元素的查找、过滤、转换等操作,这一新功能就是JDK 8中的聚合操作。
在程序中,使用聚合操作没有绝对的语法规范,根据实际操作流程,主要可以分为以下3个步骤:
(1)将原始集合或者数组对象转换为Stream流对象;
(2)对Stream流对象中的元素进行一系列的过滤、查找等中间操作(Intermediate Operations),然后仍然返回一个Stream流对象;
(3)对Stream流进行遍历、统计、收集等终结操作(Terminal Operation),获取想要的结果。
接下来,就根据上面聚合操作的3个步骤,通过一个案例来演示聚合操作的基本用法,如文件1所示。
文件1 Example31.java
1 import java.util.*; 2 import java.util.stream.Stream; 3 public class Example31 { 4 public static void main(String[] args) { 5 // 创建一个List集合对象 6 List<String> list = new ArrayList<>(); 7 list.add("张三"); 8 list.add("李四"); 9 list.add("张小明"); 10 list.add("张阳"); 11 // 1、创建一个Stream流对象 12 Stream<String> stream = list.stream(); 13 // 2、对Stream流中的元素分别进行过滤、截取操作 14 Stream<String> stream2 = stream.filter(i -> i.startsWith("张")); 15 Stream<String> stream3 = stream2.limit(2); 16 // 3、对Stream流中的元素进行终结操作,进行遍历输出 17 stream3.forEach(j -> System.out.println(j)); 18 System.out.println("======="); 19 // 通过链式表达式的形式完成聚合操作 20 list.stream().filter(i -> i.startsWith("张")) 21 .limit(2) 22 .forEach(j -> System.out.println(j)); 23 } 24 }
运行结果如图1所示。
图1 运行结果
文件1中,先创建了一个List集合,然后根据聚合操作的3个步骤实现了集合对象的聚合操作,对集合中的元素使用Stream流的形式进行过滤(filter)、截取(limit),并进行遍历输出。其中第1217行代码分步骤详细展示了聚合操作,而第2022行代码是使用了链式表达式(调用有返回值的方法时不获取返回值而是直接再调用另一个方法)实现了聚合操作,该表达式的语法格式更简洁、高效,这种链式调用也被称为操作管道流。
小提示:
在JDK 8中使用聚合操作时,通常会涉及到两个新的名词Intermediate Operations(中间操作)和Terminal Operation(终结操作),Java官方并没有对中间操作和终结操作的概念进行详细说明,只是为了便于区分和管理聚合操作步骤中的方法,将这些方法根据返回类型进行了区分:将执行某个方法后返回类型仍为Stream流对象的方法归类为中间操作,如过滤、截取、排序等方法。而将执行方法后返回类型不再是Stream流对象的方法归类为终结操作,如遍历、统计、收集等方法。
需要注意的是,在进行聚合操作时,只是改变了Stream流对象中的数据,并不会改变原始集合或数组中的源数据。
在上一小节中,介绍了聚合操作的主要使用步骤,其中首要解决的问题就是创建Stream流对象。聚合操作针对的就是可迭代数据进行的操作,如集合、数组等,所以创建Stream流对象其实就是将集合、数组等通过一些方法转换为Stream流对象。
在Java中,集合对象有对应的集合类,可以通过集合类提供的静态方法创建Stream流对象,而数组数据却没有对应的数组类,所以必须通过其他方法创建Stream流对象。针对不同的源数据,Java提供了多种创建Stream流对象的方式,分别如下:
● 所有的Collections集合都可以使用stream()静态方法获取Stream流对象;
● Stream接口的of()静态方法可以获取基本类型包装类数组、引用类型数组和单个元素的Stream流对象;
● Arrays数组工具类的stream()静态方法也可以获取数组元素的Stream流对象。
接下来,通过一个案例来学习聚合操作中如何创建Stream流对象,如文件1所示。
文件1 Example32.java
1 import java.util.*; 2 import java.util.stream.Stream; 3 public class Example32 { 4 public static void main(String[] args) { 5 // 创建一个数组 6 Integer[] array = { 9, 8, 3, 5, 2 }; 7 // 将数组转换为List集合 8 List<Integer> list = Arrays.asList(array); 9 // 1、使用集合对象的stream()静态方法创建Stream流对象 10 Stream<Integer> stream = list.stream(); 11 stream.forEach(i -> System.out.print(i+" ")); 12 System.out.println(); 13 // 2、使用Stream接口的of()静态方法创建Stream流对象 14 Stream<Integer> stream2 = Stream.of(array); 15 stream2.forEach(i -> System.out.print(i+" ")); 16 System.out.println(); 17 // 3、使用Arrays数组工具类的stream()静态方法创建Stream流对象 18 Stream<Integer> stream3 = Arrays.stream(array); 19 stream3.forEach(i -> System.out.print(i+" ")); 20 } 21 }
运行结果如图1所示。
图1 运行结果
文件1中,先创建了一个数组和一个集合,然后通过三种方式实现了Stream流对象的创建,并通过Stream流对象的forEach()方法结合Lambda表达式完成了集合和数组中元素的遍历。
小提示:
在JDK 8中,只针对单列集合Collections接口对象提供了stream()静态方法获取Stream流对象,并未对Map集合提供相关方法获取Stream流对象,所以想要用Map集合创建Stream流对象必须先通过Map集合的keySet()、values()、entrySet()等方法将Map集合转换为单列Set集合,然后再使用单列集合的stream()静态方法获取对应键、值集合的Stream流对象。
JDK 8为聚合操作中的Stream流对象提供了非常丰富的操作方法,这些方法被划分为中间操作和终结操作两种类型。这两种类型操作方法的根本区别就是方法的返回值,只要返回值类型不是Stream类型的就是终结操作,将会终结当前流模型,而其他的操作都属于中间操作。
接下来,通过一张表来展示Stream流对象的常用方法,如表1所示。
表1 Stream流的常用方法
方法声明 | 功能描述 |
---|---|
Stream filter(Predicate<? super T> predicate) | 将指定流对象中的元素进行过滤,并返回一个子流对象 |
Stream map(Function<? super T, ? extends R> mapper) | 将流中的元素按规则映射到另一个流中 |
Stream distinct() | 删除流中重复的元素 |
Stream sorted() | 将流中的元素按自然顺序排序 |
Stream limit(long maxSize) | 截取流中元素的长度 |
Stream skip(long n) | 丢弃流中前n个元素 |
static Stream concat(Stream<? extends T> a, Stream<? extends T> b) | 将两个流对象合并为一个流 |
long count() | 统计流中元素的个数 |
R collect(Collector<? super T, A, R> collector) | 将流中的元素收集到一个容器中(如集合) |
Object[] toArray() | 将流中的元素收集到一个数组中 |
void forEach(Consumer<? super T> action) | 将流中的元素进行遍历 |
表6-8中,只是列出了Stream流对象的常用方法,其中一些方法还有多个重载方法。下面将选取表1中的常用方法进行演示说明。
1.遍历
遍历forEach()方法名称虽然与for循环中的“foreach()”相同,但是该方法是JDK 8新增的Stream接口中用于遍历流元素,并且该方法不保证元素的遍历过程在流中是被有序执行的。其方法声明如下:
void forEach(Consumer<? super T> action);
上述方法接收一个Consumer函数式接口参数(可以是一个Lambda或方法引用)作为遍历动作。接下来,通过一个案例对forEach()方法进行详细讲解,如文件1所示。
文件1 Example33.java
1 import java.util.stream.Stream; 2 public class Example33 { 3 public static void main(String[] args) { 4 // 通过字符串源数据创建了一个Stream流对象 5 Stream<String> stream = Stream.of("张三","李四","张小明","张阳"); 6 // 通过forEach方法遍历Stream流对象中的元素 7 stream.forEach(i -> System.out.println(i)); 8 } 9 }
运行结果如图1所示。
图1 运行结果
文件1中,先通过一组字符串源数据创建了一个Stream流对象,然后直接使用终结操作forEach()方法遍历了Stream流对象中的元素。其中第7行代码也可以使用第4章中讲解的方法引用来打印遍历出的流元素,第7行代码的修改方式如下:
stream.forEach(System.out::println);
2.过滤
使用过滤filter()方法可以将一个Stream流中的元素进行筛选转换成另一个子集流。该方法声明如下:
Stream<T> filter(Predicate<? super T> predicate);
上述方法接收一个Predicate函数式接口作为参数作为筛选条件。接下来,通过一个案例对filter()方法进行详细讲解,如文件2所示。
文件2 Example34.java
1 import java.util.stream.Stream; 2 public class Example34 { 3 public static void main(String[] args) { 4 // 通过字符串源数据创建了一个Stream流对象 5 Stream<String> stream = Stream.of("张三","李四","张小明","张阳"); 6 stream.filter(i -> i.startsWith("张"))//筛选以“张”开头的元素 7 .filter(i -> i.length()>2) //筛选长度大于2个元素 8 .forEach(System.out::println); // 对流元素进行遍历输出 9 } 10 }
运行结果如图2所示。
图2 运行结果
文件2中,先通过一组字符串源数据创建了一个Stream流对象,然后以链式表达式的方式分别调用了filter()方法和forEach()方法对Stream流对象进行过滤和遍历操作。在过滤操作中,执行了两次filter()方法分别对字符串中以“张”开头和字符串长度大于2的元素进行了筛选,其中第6~7行代码中两个filter()方法中的两个筛选条件可以在一个filter()方法中使用逻辑运算符“&&”进行过滤操作,具体可以修改为如下方式:
stream.filter(i -> i.startsWith("张") && i.length() >2)
3.映射
Stream流对象的map()方法可以将流对象中的元素通过特定的规则进行修改然后映射为另一个流对象。该方法声明如下:
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
上述方法接收一个Function函数式接口参数作为映射条件。接下来,通过一个案例对map()方法进行详细讲解,如文件35所示。
文件3 Example35.java
1 import java.util.stream.Stream; 2 public class Example35 { 3 public static void main(String[] args) { 4 // 通过字符串源数据创建了一个Stream流对象 5 Stream<String> stream = Stream.of("a1", "a2", "b1", "c2", "c1"); 6 stream.filter(s -> s.startsWith("c")) // 筛选出流中以“c”开头的元素 7 .map(String::toUpperCase) // 对流元素进行映射,将全部字符改为大写 8 .sorted() // 对流元素进行排序 9 .forEach(System.out::println); // 对流元素进行遍历输出 10 } 11 }
运行结果如图3所示。
图3 运行结果
文件3中,先通过一组字符串源数据创建了一个Stream流对象,然后以链式表达式的方式分别调用了filter()方法、map()方法、sorted()方法和forEach()方法对Stream流对象进行聚合操作。在文件3中,map(String::toUpperCase)用于流对象映射,通过传入方法引入形式的参数“String::toUpperCase”将流中所有元素字母转为大写,而sorted()没有参数,用于对流元素进行自然排序。
4.截取
Stream流对象的limit()方法用于对流对象中的元素进行截取操作,该方法只需要一个参数,并且截取的是流中的前n个元素。在多数情况下,limit()方法会与skip()方法(跳过方法)组合使用,用于截取流对象中指定位置的多个元素。接下来,通过一个案例对limit()方法进行详细讲解,如文件4所示。
文件4 Example36.java
1 import java.util.stream.Stream; 2 public class Example36 { 3 public static void main(String[] args) { 4 // 通过字符串源数据创建了一个Stream流对象 5 Stream<String> stream = Stream.of("张三","李四","张小明","张阳"); 6 stream.skip(1) // 跳过流中的前1个元素 7 .limit(2) // 截取流中的前2个元素 8 .forEach(System.out::println); // 对流元素进行遍历输出 9 } 10 }
运行结果如图4所示。
图4 运行结果
文件4中,先通过一组字符串源数据创建了一个Stream流对象,然后以链式表达式的方式分别调用了skip()、limit()和forEach()方法对流对象数据进行聚合操作。在原始流Stream对象中有4个元素,先通过skip(1)方法跳过了第1个元素,接着在剩余元素中使用limit(2)方法截取了前2个元素,最后使用forEach()方法遍历打印出截取的两个元素“李四”和“张小明”。
5.收集
在前面几个案例中,对Stream流元素进行中间操作后都是使用forEach()终结操作方法将元素遍历输出,显示的查看聚合操作后元素信息。像forEach()这种终结操作,在某些场景下并不可行,因为它无法将进行中间操作后的流元素作为我们熟悉的对象或者数据类型进行保存,为此,JDK 8中还为操作流对象增加了一个重要的终结操作——collect。
collect(收集)是一种是十分有用的终结操作,它可以把Stream中的元素保存为另外一种形式,比如集合、字符串等。该方法声明如下:
<R, A> R collect(Collector<? super T, A, R> collector);
collect()方法使用Collector作为参数,Collector包含四种不同的操作:supplier(初始构造器)、accumulator(累加器)、combiner(组合器)、finisher(终结者)。这些操作听起来很复杂,但是有一个好消息是JDK 8通过java.util.stream包下的Collectors类内置了各种复杂的收集操作,因此对于大部分操作来说,不需要开发者自己去实现Collectors类中的操作方法。接下来,通过一个案例对collect()方法进行详细讲解,如文件5所示。
文件5 Example37.java
1 import java.util.List; 2 import java.util.stream.Collectors; 3 import java.util.stream.Stream; 4 public class Example37 { 5 public static void main(String[] args) { 6 // 通过字符串源数据创建了一个Stream流对象 7 Stream<String> stream = Stream.of("张三","李四","张小明","张阳"); 8 // 通过filter()方法筛选出字符串中以“张”开头的元素, 9 // 最后通过collect()方法进行终结操作,将流元素收集到一个List集合中 10 List<String> list = stream.filter(i -> i.startsWith("张")) 11 .collect(Collectors.toList()); 12 System.out.println(list); 13 Stream<String> stream2 = Stream.of("张三","李四","张小明","张阳"); 14 // 通过filter()方法筛选出字符串中以“张”开头的元素, 15 //通过collect()方法进行终结操作,将流元素使用" and "连接收集到一个字符串中 16 String string = stream2.filter(i -> i.startsWith("张")) 17 .collect(Collectors.joining(" and ")); 18 System.out.println(string); 19 } 20 }
运行结果如图5所示。
图5 运行结果
文件5中,通过同一组字符串源数据创建了两个Stream流对象,然后以链式表达式的方式分别调用了filter()方法和collect()方法对流对象数据进行聚合操作。在collect()方法中,分别通过Collectors.toList()方法和Collectors.joining(" and ")方法将过滤后的流对象中的元素收集了到一个List集合和一个字符串中。
文件5只是演示了通过Stream接口的collect()方法将流对象元素收集为集合、字符串的用法,而Stream接口的另一个toArray()方法则支持将流对象元素收集为数组,这里就不再进行演示了。
注意:
一个Stream流对象可以连续进行多次中间操作,仍会返回一个流对象,但一个流对象只能进行一次终结操作,并且一旦进行终结操作后,该流对象就不复存在了。
在前面小节中,介绍的创建Stream流对象的3种方式都是创建的串行流(Serial Stream),所谓串行流就是将源数据转换为一个流对象,然后在单线程下执行聚合操作的流(也就是单一管道流)。而JDK 8中针对大批量的数据处理还提供了一个并行流(Parallel Stream),并行流就是将源数据分为多个子流对象进行多线程操作(也就是多个管道流),然后将处理的结果再汇总为一个流对象。
接下来,就通过一个简单的示意图来展示Stream串行流与并行流的主要区别,如图1所示。
图1 Stream串行流与并行流示意图
Stream并行流底层会将源数据拆解为多个流对象在多个线程中并行执行,这依赖于JDK 7中新增的fork/join框架,该框架解决了应用程序并行计算的能力,但是单独使用这个框架,必须指定源数据如何进行详细拆分,而JDK 8中的聚合操作,在fork/join框架的基础上进行组合解决了这一麻烦。
使用Stream并行流在一定程度上可以提升程序的执行效率,但是在多线程执行就会出现线程安全这个大问题,所以为了能够在聚合操作中使用Stream并行流,前提是要执行操作的源数据在并行执行过程中不会被修改。
在创建Stream流对象时,除非有特别声明,否则默认创建的都是串行流。JDK 8中提供了两种方式来创建Stream并行流:第一种方式,通过Collection集合接口的parallelStream()方法直接将集合类型的源数据转变为Stream并行流;第二种方式,通过BaseStream接口的parallel()方法将Stream串行流转变为Stream并行流。另外,在BaseStream接口中还提供了一个isParallel()方法,用于判断当前Stream流对象是否为并行流,方法返回值为boolean类型。
接下来,通过一个案例来学习聚合操作中Stream并行流的创建和基本使用,如文件1所示。
文件1 Example38.java
1 import java.util.*; 2 import java.util.stream.Stream; 3 public class Example38 { 4 public static void main(String[] args) { 5 // 创建一个List集合数据源 6 List<String> list = Arrays.asList("张三","李四","张小明","张阳"); 7 // 1、直接使用Collection接口的parallelStream()创建并行流 8 Stream<String> parallelStream = list.parallelStream(); 9 System.out.println(parallelStream.isParallel()); 10 // 创建一个Stream串行流 11 Stream<String> stream = Stream.of("张三","李四","张小明","张阳"); 12 // 2、使用BaseStream接口的parallel()方法将串行流转变为并行流 13 Stream<String> parallel = stream.parallel(); 14 System.out.println(parallel.isParallel()); 15 } 16 }
运行结果如图2所示。
图2 运行结果
文件1中,分别使用两种方式创建了Stream并行流,并通过isParallel()方法验证了创建后的流对象是否为并行流。另外,不论是Stream并行流还是串行流,都属于Stream流对象,所以它们都拥有相同的流操作方法,这里就不再进行演示说明了。
后续见下篇
本文转载自IT黑马