《疯狂Java讲义》(第5版) 李刚

第1章 Java语言概述与开发环境

1.1 Java语言的发展简史

JDK1.0 :

Sun在1996年年初发布了JDK 1.0,该版本包括两部分:运行环境(即JRE)和开发环境(即JDK)。运行环境包括核心API、集成API、用户界面API、发布技术、Java虚拟机(JVM)5个部分;开发环境包括编译Java程序的编译器(即javac命令)。

JDK1.1 :

Sun在1997年2月18日发布了JDK 1.1,该版本增加了JIT(即时编译)编译器。

JDK 1.2 :

1998年12月Sun发布了JDK 1.2,伴随JDK 1.2一同发布的还有JSP/Servlet、EJB等规范,并将Java分成了J2EE、J2SE和J2ME三个版本。JDK 1.2还把它的API分成了三大类:核心API、可选API、特殊API。

JDK1.4 :

2002年2月,Sun发布了JDK 1.4。

JDK1.5 :

2004年10月,Sun发布了JDK 1.5,将JDK 1.5改名为Java SE 5.0,J2EE、J2ME也相应地改名为Java EE和Java ME。JDK 1.5增加了诸如泛型增强的for语句可变数量的形参注释(Annotations)、自动拆箱和装箱等功能;推出了EJB 3.0规范、推出了自己的MVC框架规范:JSF。

JDK 1.6 :

2006年12月,Sun公司发布了JDK 1.6(也被称为Java SE 6)。2009年4月20日,Oracle收购Sun.

JDK 1.7 :

2011年7月28日,Oracle发布Java SE 7,该版本引入了二进制整数支持字符串的switch语句菱形语法多异常捕捉自动关闭资源的try语句等新特性。

JDK 1.8:

2014年3月18日,Oracle公司发布了Java SE 8,该版本带来了全新的Lambda表达式流式编程等大量新特性

JDK1.9 :

2017年9月22日,Oracle公司发布了Java SE 9,这次版本升级强化了Java的模块化系统,而且采用了更高效、更智能的G1垃圾回收器,并在核心类库上进行了大量更新。

JDK1.10:

2018年3月如约发布Java 10

JDK1.11 :

2018年9月如约发布Java 11。

1.2 Java程序运行机制

Java语言具有解释型语言、编译型语言的特征,因为Java程序要经过先编译、后解释两个步骤。

1.2.1 高级语言的运行机制

计算机高级语言按程序的执行方式可以分为编译型解释型两种。

1.2.2 Java程序的运行机制和JVM

Java程序的执行过程必须经过先编译、后解释两个步骤,如图1.1所示。

《疯狂Java讲义》(第5版) 李刚_第1张图片

Java语言里负责解释执行字节码文件的是Java虚拟机,即JVM(Java Virtual Machine)。JVM是Java程序跨平台的关键部分,只要为不同平台实现了相应的虚拟机,编译后的Java字节码就可以在该平台上运行。JVM是一个抽象的计算机,和实际的计算机一样,它具有指令集并使用不同的存储区域。它负责执行指令,还要管理数据、内存和寄存器。

Oracle公司制定的Java虚拟机规范在技术上规定了JVM的统一标准,具体定义了JVM的如下细节: 指令集、寄存器、 类文件的格式、 栈、 垃圾回收堆、存储区,制定这些规范的目的是为了提供统一的标准,最终实现Java程序的平台无关性。

1.3 开发Java的准备

下载和安装Java 11的JDK、设置PATH环境变量

1.4 第一个Java程序

1.5 Java程序的基本规则

Java程序必须以类(class)的形式存在,类(class)是Java程序的最小程序单位。

Java程序源文件的扩展名必须是.java,不能是其他文件扩展名。

在通常情况下,Java程序源文件的主文件名可以是任意的。如果Java程序源代码里定义了一个public类,则该源文件的主文件名必须与该public类(也就是该类定义使用了public关键字修饰)的类名相同。

通常有如下建议:一个Java源文件通常只定义一个类,不同的类使用不同的源文件定义。 让Java源文件的主文件名与该源文件中定义的public类同名。

1.6 交互式工具:jshell

1.7 Java 11改进的垃圾回收器

第2章 理解面向对象

Java完全支持面向对象的三种基本特征:继承、封装和多态。Java语言完全以对象为中心,Java程序的最小程序单位是类,整个Java程序由一个一个的类组成。

面向对象的方式由OOA(面向对象分析)、OOD(面向对象设计)和OOP(面向对象编程)组成,其中,OOA和OOD的结构需要使用一种方式来描述并记录,目前业界统一采用UML(统一建模语言)来描述并记录OOA和OOD的结果。

2.1 面向对象

程序的三种基本结构:顺序结构、选择结构、循环结构。

面向对象程序设计方法的基本思想是使用类、对象、继承、封装、消息等基本概念进行程序设计。

成员变量(状态数据)+方法(行为)= 类定义

2.1.4 面向对象的基本特征

面向对象方法具有三个基本特征:封装(Encapsulation)、继承(Inheritance)和多态(Polymorphism)。

封装:指将对象的实现细节隐藏起来,然后通过一些公用方法来暴露该对象的功能;

继承:是面向对象实现软件复用的重要手段,当子类继承父类后,子类作为特殊的父类,将直接获得父类的属性和方法;

多态:指子类对象可以直接赋给父类变量,但运行时依然表现出子类的行为特征,这意味着同一个类型的对象在执行同一个方法时,可能表现出多种行为特征。

除此之外,抽象也是面向对象的重要部分,抽象就是忽略一个主题中与当前目标无关的那些方面,以便更充分地注意与当前目标有关的方面。

2.2 UML(统一建模语言)介绍

UML图大致上可分为静态图和动态图两种,UML 2.0的组成如图2.9所示:

《疯狂Java讲义》(第5版) 李刚_第2张图片

2.3 Java的面向对象特征

2.3.1 一切都是对象

在Java语言中,除8个基本数据类型值之外,一切都是对象,而对象就是面向对象程序设计的中心。Java通过为对象定义成员变量来描述对象的状态;Java通过为对象定义方法来描述对象的行为。

2.3.2 类和对象

具有相同或相似性质的一组对象的抽象就是类,类是对一类事物的描述,是抽象的、概念上的定义;对象是实际存在的该类事物的个体,也称为实例(instance)。对象的抽象化是类,类的具体化就是对象,也可以说类的实例是对象。

类通常有如下两种主要的结构关系:

一般→特殊关系:这种关系就是典型的继承关系,Java语言使用extends关键字来表示这种继承关系,Java的子类是一种特殊的父类。因此,这种一般→特殊的关系其实是一种“is a”关系。

整体→部分结构关系:也被称为组装结构,这是典型的组合关系,Java语言通过在一个类里保存另一个对象的引用来实现这种组合关系。因此,这种整体→部分结构关系其实是一种“has a”关系。

第3章 数据类型和运算符

Java语言是一门强类型语言,强类型的含义:① 所有的变量必须先声明、后使用;② 指定类型的变量只能接受类型与之匹配的值。

3.1 注释

Java语言的注释共有3种类型:单行注释(使用 //)、多行注释(使用 /*  …… */)、 文档注释(使用 /**  …… */)。

3.2 标识符和关键字

Java语言里的分隔符: 分号(;)、花括号({})、方括号([])、圆括号(())、空格、圆点(.)。

标识符就是用于给程序中变量、类、方法命名的符号。Java语言的标识符必须以字母、下画线(_)、美元符($)开头,后面可以跟任意数目的字母、数字、下画线(_)和美元符($)。Java语言是区分大小写的,从Java 9开始,不允许使用单独的下画线(_)作为标识符。

使用标识符时,需要注意如下规则:

➢ 标识符可以由字母、数字、下画线(_)和美元符($)组成,其中数字不能打头。

➢ 标识符不能是Java关键字和保留字,但可以包含关键字和保留字。

➢ 标识符不能包含空格。

➢ 标识符只能包含美元符($),不能包含@、#等其他特殊字符。

Java的所有关键字都是小写的,TRUE、FALSE和NULL都不是Java关键字。

《疯狂Java讲义》(第5版) 李刚_第3张图片

不仅如此,Java还提供了三个特殊的直接量(literal):true、false和null;Java语言的标识符也不能使用这三个特殊的直接量。从Java 10开始引入的var并不是关键字,它相当于一个可变的类型名(后面会详述),因此var依然可作为标识符。

3.3 数据类型分类

声明变量的语法:type varName [=初始值];

Java语言支持的类型分为两类:基本类型(Primitive Type)和引用类型(Reference Type)。

基本类型:包括boolean类型数值类型。数值类型有整数类型和浮点类型。整数类型包括byte、short、int、long、char,浮点类型包括float和double。

引用类型:包括类、接口和数组类型,还有一种特殊的null类型。

3.4 基本数据类型

《疯狂Java讲义》(第5版) 李刚_第4张图片

Java只包含这8种基本数据类型,字符串不是基本数据类型,字符串是一个类,也就是一个引用数据类型。

Java中整数值有4种表示方式十进制二进制(Java7 开始支持)、八进制十六进制,其中二进制的整数以0b或0B开头;八进制的整数以0开头;十六进制的整数以0x或者0X开头,其中10~15分别以a~f(此处的a~f不区分大小写)来表示。

所有数字在计算机底层都是以二进制形式存在的,原码是直接将一个数值换算成二进制数。但计算机以补码的形式保存所有的整数。补码的计算规则:正数的补码和原码完全相同,负数的补码是其反码加1;反码是对原码按位取反,只是最高位(符号位)保持不变。

字符型值有三种表示形式:

➢ 直接通过单个字符来指定字符型值,例如'A'、'9'和'0'等。

➢ 通过转义字符表示特殊字符型值,例如'\n'、'\t'等。

➢ 直接使用Unicode值来表示字符型值,格式是'\uXXXX',其中XXXX代表一个十六进制的整数。

Java语言中常用的转义字符如表3.2所示。

《疯狂Java讲义》(第5版) 李刚_第5张图片

char类型的值也可直接作为整型值来使用。

Java语言中的单引号、双引号和反斜线都有特殊的用途,如果一个字符串中包含了这些特殊字符,则应该使用转义字符的表示形式。例如,在Java程序中表示一个绝对路径:"c:\codes",但这种写法得不到期望的结果,因为Java会把反斜线当成转义字符,所以应该写成这种形式:"c:\\codes",只有同时写两个反斜线,Java才会把第一个反斜线当成转义字符,和后一个反斜线组成真正的反斜线。

Java的浮点数遵循IEEE 754标准,采用二进制数据的科学计数法来表示浮点数,对于float型数值,第1位是符号位,接下来8位表示指数,再接下来的23位表示尾数;对于double类型数值,第1位也是符号位,接下来的11位表示指数,再接下来的52位表示尾数。

Java 7引入了一个新功能:程序员可以在数值中使用下画线,不管是整型数值,还是浮点型数值,都可以自由地使用下画线。

boolean类型的值或变量主要用做旗标来进行流程控制,主要有: if条件控制语句、 while循环控制语句、do while循环控制语句、for循环控制语句、三目运算符(?:)。

Java 10开始支持使用var定义局部变量:var相当于一个动态类型,使用var定义的局部变量的类型由编译器自动推断—定义变量时分配了什么类型的初始值,那该变量就是什么类型。因此,使用var定义局部变量时,必须在定义局部变量的同时指定初始值,否则编译器无法推断该变量的类型。

3.5 基本类型的类型转换

Java中类型转换分为:自动类型转换、强制类型转换。

《疯狂Java讲义》(第5版) 李刚_第6张图片

强制类型转换的语法格式是:(targetType)value,强制类型转换的运算符是圆括号(())。

当一个算术表达式中包含多个基本类型的值时,整个算术表达式的数据类型将发生自动提升。Java定义了如下的自动提升规则。➢ 所有的byte类型、short类型和char类型将被提升到int类型。➢ 整个算术表达式的数据类型自动提升到与表达式中最高等级操作数同样的类型。操作数的等级排列如图3.10所示,位于箭头右边类型的等级高于位于箭头左边类型的等级。

3.6 直接量

直接量(literal value,也被直译为字面值)是指在程序中通过源代码直接给出的值,例如在int a=5;这行代码中,为变量a所分配的初始值5就是一个直接量。

并不是所有的数据类型都可以指定直接量,能指定直接量的通常只有三种类型:基本类型、字符串类型和null类型。对于null类型的直接量的值只有null一个。

关于字符串直接量有一点需要指出,当程序第一次使用某个字符串直接量时,Java会使用常量池(constantpool)来缓存该字符串直接量,如果程序后面的部分需要用到该字符串直接量时,Java会直接使用常量池中的字符串直接量。

常量池指的是在编译期被确定,并被保存在已编译的.class文件中的一些数据。它包括关于类、方法、接口中的常量,也包括字符串直接量。

3.7 运算符

运算符是一种特殊的符号,用以表示数据的运算、赋值和比较等。

Java语言中的运算符可分为如下几种:

➢ 算术运算符(+   -    *    /     %)

➢ 赋值运算符( = )

➢ 比较运算符(>  >=  <  <=  !=   ==)

➢ 逻辑运算符(&&  &  ||  |  !  ^)

➢ 位运算符( |   ~    ^    <<   >>    >>>)

➢ 类型相关运算符

赋值运算符可与算术运算符、位移运算符结合,扩展成功能更加强大的运算符。扩展后的赋值运算符如下。+=    -=    *=    /=     %=     &=     |=   ^=    <<=    >>=     >>>= 

三目运算符只有一个  ?:

《疯狂Java讲义》(第5版) 李刚_第7张图片

第4章 流程控制与数组

4.1 顺序结构

4.2 分支结构

Java提供了两种常见的分支控制结构:if语句switch语句

switch语句由一个控制表达式和多个case标签组成,和if语句不同的是,switch语句后面的控制表达式的数据类型只能是byte、short、char、int四种整数类型,枚举类型和java.lang.String类型(从Java 7才允许),不能是boolean类型。

switch语句的语法格式:

《疯狂Java讲义》(第5版) 李刚_第8张图片

Java 11编译器做了一些改进,如果开发者忘记了case块后面的break语句,Java 11编译器会生成警告:“[fallthrough]可能无法实现case”。这个警告以前需要为javac指定-X:fallthrough选项才能显示出来。

4.3 循环结构

while循环的语法格式如下:

《疯狂Java讲义》(第5版) 李刚_第9张图片

do while循环的语法格式如下:

《疯狂Java讲义》(第5版) 李刚_第10张图片

for循环的基本语法格式如下:

4.4 控制循环结构

Java提供了continue和break来控制循环结构。除此之外,return可以结束整个方法,当然也就结束了一次循环。

break用于完全结束一个循环,跳出循环体。不管是哪种循环,一旦在循环体中遇到break,系统将完全结束该循环,开始执行循环之后的代码。break语句不仅可以结束其所在的循环,还可以直接结束其外层循环。此时需要在break后紧跟一个标签,这个标签用于标识一个外层循环。Java中的标签就是一个紧跟着英文冒号(:)的标识符。与其他语言不同的是,Java中的标签只有放在循环语句之前才有作用。

continue只是忽略本次循环剩下语句,接着开始下一次循环,并不会终止循环;而break则是完全终止循环本身。与break类似的是,continue后也可以紧跟一个标签,用于直接跳过标签所标识循环的当次循环的剩下语句,重新开始下一次循环。

return关键字并不是专门用于结束循环的,return的功能是结束一个方法。

4.5 数组类型

一个数组里只能存储一种数据类型的数据,而不能存储多种数据类型的数据。

数组也是一种数据类型,它本身是一种引用类型。

Java语言支持两种语法格式来定义数组

Java语言中数组必须先初始化,然后才可以使用。所谓初始化,就是为数组的数组元素分配内存空间,并为每个数组元素赋初始值。

数组的初始化有如下两种方式。

➢ 静态初始化:初始化时由程序员显式指定每个数组元素的初始值,由系统决定数组长度。

➢ 动态初始化:初始化时程序员只指定数组长度,由系统为数组元素分配初始值。

从Java 5之后,Java提供了一种更简单的循环:foreach循环,这种循环遍历数组和集合。foreach循环的语法格式如下:

Java提供的Arrays类里包含的一些static修饰的方法可以直接操作数组

第5章 面向对象(上)

5.1 类和对象

类可以当成一种自定义类型,可以使用类来定义变量,这种类型的变量统称为引用变量

是某一批对象的抽象,可以把类理解成某种概念;对象才是一个具体存在的实体。Java语言是面向对象的程序设计语言,类和对象是面向对象的核心。Java语言提供了对创建类和创建对象简单的语法支持。

Java语言里定义类的简单语法如下:

《疯狂Java讲义》(第5版) 李刚_第11张图片

在上面的语法格式中,修饰符可以是public、final、abstract,或者完全省略这三个修饰符,类名只要是一个合法的标识符即可,建议Java类名必须是由一个或多个有意义的单词连缀而成的,每个单词首字母大写,其他字母全部小写,单词与单词之间不要使用任何分隔符。

对一个类定义而言,可以包含三种最常见的成员:构造器、成员变量和方法,static修饰的成员不能访问没有static修饰的成员。成员变量用于定义该类或该类的实例所包含的状态数据,方法则用于定义该类或该类的实例的行为特征或者功能实现。构造器用于构造该类的实例,Java语言通过new关键字来调用构造器,从而返回该类的实例。构造器是一个类创建对象的根本途径,如果一个类没有构造器,这个类通常无法创建实例。因此,Java语言提供了一个功能:如果程序员没有为一个类编写构造器,则系统会为该类提供一个默认的构造器。一旦程序员为一个类提供了构造器,系统将不再为该类提供构造器。

定义成员变量的语法格式如下:

➢ 修饰符:修饰符可以省略,也可以是public、protected、private、static、final,其中public、protected、private三个最多只能出现其中之一,可以与static、final组合起来修饰成员变量。

➢ 类型:类型可以是Java语言允许的任何数据类型,包括基本类型和现在介绍的引用类型。

➢ 成员变量名:成员变量名只要是一个合法的标识符即可,建议成员变量名应该由一个或多个有意义的单词连缀而成,第一个单词首字母小写,后面每个单词首字母大写,其他字母全部小写,单词与单词之间不要使用任何分隔符。

➢ 默认值:定义成员变量还可以指定一个可选的默认值。

定义方法的语法格式如下:

➢ 修饰符:修饰符可以省略,也可以是public、protected、private、static、final、abstract,其中public、protected、private三个最多只能出现其中之一;abstract和final最多只能出现其中之一,它们可以与static组合起来修饰方法。

➢ 方法返回值类型:返回值类型可以是Java语言允许的任何数据类型,包括基本类型和引用类型;如果声明了方法返回值类型,则方法体内必须有一个有效的return语句,该语句返回一个变量或一个表达式,这个变量或者表达式的类型必须与此处声明的类型匹配。除此之外,如果一个方法没有返回值,则必须使用void来声明没有返回值。

➢ 方法名:方法名的命名规则与成员变量的命名规则基本相同,但由于方法用于描述该类或该类的实例的行为特征或功能实现,因此通常建议方法名以英文动词开头。

➢ 形参列表:形参列表用于定义该方法可以接受的参数,形参列表由零组到多组“参数类型 形参名”组合而成,多组参数之间以英文逗号(,)隔开,形参类型和形参名之间以英文空格隔开。一旦在定义方法时指定了形参列表,则调用该方法时必须传入对应的参数值——谁调用方法,谁负责为形参赋值。

定义构造器的语法格式如下:

➢ 修饰符:修饰符可以省略,也可以是public、protected、private其中之一。

➢ 构造器名:构造器名必须和类名相同。

➢ 形参列表:和定义方法形参列表的格式完全相同

创建对象的根本途径是构造器,通过new关键字来调用某个类的构造器即可创建这个类的实例。

创建对象之后,接下来即可使用该对象了,Java的对象大致有如下作用:

➢ 访问对象的实例变量

➢ 调用对象的方法。

有这样一行代码:Person p=new Person();  这行代码创建了一个Person实例,也被称为Person对象,这个Person对象被赋给p变量。在这行代码中实际产生了两个东西:一个是p变量,一个是Person对象。

Java提供了一个this关键字,this关键字总是指向调用该方法的对象。

根据this出现位置的不同,this作为对象的默认引用有两种情形:

➢ 构造器中引用该构造器正在初始化的对象。

➢ 在方法中引用调用该方法的对象。

this关键字最大的作用就是让类中一个方法,访问该类里的另一个方法或实例变量。

5.2 方法详解

Java语言里,方法不能独立存在,方法必须属于类或对象。一旦将一个方法定义在某个类的类体内,如果这个方法使用了static修饰,则这个方法属于这个类,否则这个方法属于这个类的实例。

Java里方法的参数传递方式只有一种:值传递。所谓值传递,就是将实际参数值的副本(复制品)传入方法内,而参数本身不会受到任何影响。

从JDK 1.5之后,Java允许定义形参个数可变的参数,从而允许为方法指定数量不确定的形参。如果在定义方法时,在最后一个形参的类型后增加三点(...),则表明该形参可以接受多个参数值,多个参数值被当成数组传入

注意:个数可变的形参只能处于形参列表的最后。一个方法中最多只能包含一个个数可变的形参。个数可变的形参本质就是一个数组类型的形参,因此调用包含个数可变形参的方法时,该个数可变的形参既可以传入多个参数,也可以传入一个数组。

一个方法体内调用它自身,被称为方法递归。只要一个方法的方法体实现中再次调用了方法本身,就是递归方法。递归一定要向已知方向递归。

如果同一个类中包含了两个或两个以上方法的方法名相同,但形参列表不同,则被称为方法重载。方法重载的要求就是两同一不同:同一个类中方法名相同,参数列表不同。至于方法的其他部分,如方法返回值类型、修饰符等,与方法重载没有任何关系。

5.3 成员变量和局部变量

成员变量指的是在类里定义的变量,也就是前面所介绍的field;局部变量指的是在方法里定义的变量。变量名称建议第一个单词首字母小写,后面每个单词首字母大写。Java程序中的变量划分如图5.9所示。

《疯狂Java讲义》(第5版) 李刚_第12张图片

成员变量无须显式初始化,只要为一个类定义了类变量或实例变量,系统就会在这个类的准备阶段或创建该类的实例时进行默认初始化。

局部变量除形参之外,都必须显式初始化。也就是说,必须先给方法局部变量和代码块局部变量指定初始值,否则不可以访问它们。

当系统加载类或创建该类的实例时,系统自动为成员变量分配内存空间,并在分配内存空间后,自动为成员变量指定初始值。

局部变量定义后,必须经过显式初始化后才能使用,系统不会为局部变量执行初始化。这意味着定义局部变量后,系统并未为这个变量分配内存空间,直到等到程序为这个变量赋初始值时,系统才会为局部变量分配内存,并将初始值保存到这块内存中。与成员变量不同,局部变量不属于任何类或实例,因此它总是保存在其所在方法的栈内存中。如果局部变量是基本类型的变量,则直接把这个变量的值保存在该变量对应的内存中;如果局部变量是一个引用类型的变量,则这个变量里存放的是地址,通过该地址引用到该变量实际引用的对象或数组。栈内存中的变量无须系统垃圾回收,往往随方法或代码块的运行结束而结束。

5.4 隐藏和封装

封装实际上有两个方面的含义:把该隐藏的隐藏起来,把该暴露的暴露出来。这两个方面都需要通过使用Java提供的访问控制符来实现。

Java提供了3个访问控制符:private、protected和public,分别代表了3个访问控制级别,另外还有一个不加任何访问控制符的访问控制级别,提供了4个访问控制级别。Java的访问控制级别由小到大如图5.14所示:

《疯狂Java讲义》(第5版) 李刚_第13张图片

《疯狂Java讲义》(第5版) 李刚_第14张图片

关于访问控制符的使用,存在如下几条基本原则。

➢ 类里的绝大部分成员变量都应该使用private修饰,只有一些static修饰的、类似全局变量的成员变量,才可能考虑使用public修饰。除此之外,有些方法只用于辅助实现该类的其他方法,这些方法被称为工具方法,工具方法也应该使用private修饰。

➢ 如果某个类主要用做其他类的父类,该类里包含的大部分方法可能仅希望被其子类重写,而不想被外界直接调用,则应该使用protected修饰这些方法。

➢ 希望暴露出来给其他类自由调用的方法应该使用public修饰。因此,类的构造器通过使用public修饰,从而允许在其他地方创建该类的实例。因为外部类通常都希望被其他类自由使用,所以大部分外部类都使用public修饰。

package语句必须作为源文件的第一条非注释性语句,一个源文件只能指定一个包,即只能包含一条package语句,该源文件中可以定义多个类,则这些类将全部位于该包下。如果没有显式指定package语句,则处于默认包下。

import可以向某个Java文件中导入指定包层次下某个类或全部类,import语句应该出现在package语句(如果有的话)之后、类定义之前。

JDK 1.5以后更是增加了一种静态导入的语法,它用于导入指定类的某个静态成员变量、方法或全部的静态成员变量、方法。静态导入使用import static语句,静态导入也有两种语法,分别用于导入指定类的单个静态成员变量、方法和全部静态成员变量、方法

Java的核心类都放在java包以及其子包下,Java扩展的许多类都放在javax包以及其子包下。

下面几个包是Java语言中的常用包:

➢ java.lang:这个包下包含了Java语言的核心类,如String、Math、System和Thread类等,使用这个包下的类无须使用import语句导入,系统会自动导入这个包下的所有类。

➢ java.util:这个包下包含了Java的大量工具类/接口和集合框架类/接口,例如Arrays和List、Set等。

➢ java.net:这个包下包含了一些Java网络编程相关的类/接口。

➢ java.io:这个包下包含了一些Java输入/输出编程相关的类/接口。

➢ java.text:这个包下包含了一些Java格式化相关的类。

➢ java.sql:这个包下包含了Java进行JDBC数据库编程的相关类/接口。

➢ java.awt:这个包下包含了抽象窗口工具集的相关类/接口,这些类主要用于构建图形用户界面(GUI)程序。

➢ java.swing:这个包下包含了Swing图形用户界面编程的相关类/接口,这些类可用于构建平台无关的GUI程序。

5.5 深入构造器

构造器最大的用处就是在创建对象时执行初始化。

同一个类里具有多个构造器,多个构造器的形参列表不同,即被称为构造器重载

使用this调用另一个重载的构造器只能在构造器中使用,而且必须作为构造器执行体的第一条语句。使用this调用重载的构造器时,系统会根据this后括号里的实参来调用形参列表与之对应的构造器。

5.6 类的继承

Java的继承通过extends关键字来实现,实现继承的类被称为子类,被继承的类被称为父类,有的也称其为基类、超类。父类和子类的关系,是一种一般和特殊的关系。

注意:子类只能从被扩展的父类获得成员变量、方法和内部类(包括内部接口、枚举),不能获得构造器和初始化块。

如果定义一个Java类时并未显式指定这个类的直接父类,则这个类默认扩展java.lang.Object类。因此,java.lang.Object类是所有类的父类,要么是其直接父类,要么是其间接父类。因此所有的Java对象都可调用java.lang.Object类所定义的实例方法。

子类包含与父类同名方法的现象被称为方法重写(Override),也被称为方法覆盖。可以说子类重写了父类的方法,也可以说子类覆盖了父类的方法。

方法的重写要遵循“两同两小一大”规则,“两同”即方法名相同、形参列表相同;“两小”指的是子类方法返回值类型应比父类方法返回值类型更小或相等,子类方法声明抛出的异常类应比父类方法声明抛出的异常类更小或相等;“一大”指的是子类方法的访问权限应比父类方法的访问权限更大或相等。尤其需要指出的是,覆盖方法和被覆盖方法要么都是类方法,要么都是实例方法,不能一个是类方法,一个是实例方法。

当子类覆盖了父类方法后,子类的对象将无法访问父类中被覆盖的方法,但可以在子类方法中调用父类中被覆盖的方法。如果需要在子类方法中调用父类中被覆盖的方法,则可以使用super(被覆盖的是实例方法)或者父类类名(被覆盖的是类方法)作为调用者来调用父类中被覆盖的方法。

如果父类方法具有private访问权限,则该方法对其子类是隐藏的,因此其子类无法访问该方法,也就是无法重写该方法。如果子类中定义了一个与父类private方法具有相同的方法名、相同的形参列表、相同的返回值类型的方法,依然不是重写,只是在子类中重新定义了一个新方法。

重载主要发生在同一个类的多个同名方法之间,而重写发生在子类和父类的同名方法之间。

super是Java提供的一个关键字,super用于限定该对象调用它从父类继承得到的实例变量或方法。

5.7 多态

Java引用变量有两个类型:一个是编译时类型,一个是运行时类型编译时类型由声明该变量时使用的类型决定,运行时类型由实际赋给该变量的对象决定。如果编译时类型和运行时类型不一致,就可能出现所谓的多态(Polymorphism)。

相同类型的变量、调用同一个方法时呈现出多种不同的行为特征,这就是多态。与方法不同的是,对象的实例变量则不具备多态性。

instanceof运算符的前一个操作数通常是一个引用类型变量,后一个操作数通常是一个类(也可以是接口,可以把接口理解成一种特殊的类),它用于判断前面的对象是否是后面的类,或者其子类、实现类的实例。如果是,则返回true,否则返回false。

在使用instanceof运算符时需要注意:instanceof运算符前面操作数的编译时类型要么与后面的类相同,要么与后面的类具有父子继承关系,否则会引起编译错误。

5.8 继承与组合

继承是实现类复用的重要手段,但继承带来了一个最大的坏处:破坏封装。相比之下,组合也是实现类复用的重要方式,而采用组合方式来实现类复用则能提供更好的封装性。

子类扩展父类时,子类可以从父类继承得到成员变量和方法,如果访问权限允许,子类可以直接访问父类的成员变量和方法,相当于子类可以直接复用父类的成员变量和方法。

为了保证父类有良好的封装性,不会被子类随意改变,设计父类通常应该遵循如下规则。

➢ 尽量隐藏父类的内部数据。尽量把父类的所有成员变量都设置成private访问类型,不要让子类直接访问父类的成员变量。

➢ 不要让子类可以随意访问、修改父类的方法。父类中那些仅为辅助其他的工具方法,应该使用private访问控制符修饰,让子类无法访问该方法;如果父类中的方法需要被外部类调用,则必须以public修饰,但又不希望子类重写该方法,可以使用final修饰符(该修饰符后面会有更详细的介绍)来修饰该方法;如果希望父类的某个方法被子类重写,但不希望被其他类自由访问,则可以使用protected来修饰该方法。

➢ 尽量不要在父类构造器中调用将要被子类重写的方法。

如果想把某些类设置成最终类,即不能被当成父类,则可以使用final修饰这个类,例如JDK提供的java.lang.String类和java.lang.System类。除此之外,使用private修饰这个类的所有构造器,从而保证子类无法调用该类的构造器,也就无法继承该类。对于把所有的构造器都使用private修饰的父类而言,可另外提供一个静态方法,用于创建该类的实例。

如果需要复用一个类,除把这个类当成基类来继承之外,还可以把该类当成另一个类的组合成分,从而允许新类直接复用该类的public方法。

5.9 初始化块

一个类里可以有多个初始化块,相同类型的初始化块之间有顺序:前面定义的初始化块先执行,后面定义的初始化块后执行。

在 Java 语言中,当实例化对象时,对象所在类的所有成员变量首先要进行初始化,只有当所有类成员完成初始化后,才会调用对象所在类的构造函数创建象。

初始化一般遵循3个原则:

  • 静态对象(变量)优先于非静态对象(变量)初始化,静态对象(变量)只初始化一次,而非静态对象(变量)可能会初始化多次;
  • 父类优先于子类进行初始化;
  • 按照成员变量的定义顺序进行初始化。 即使变量定义散布于方法定义之中,它们依然在任何方法(包括构造函数)被调用之前先初始化;

加载顺序

  • 父类(静态变量、静态语句块)
  • 子类(静态变量、静态语句块)
  • 父类(实例变量、普通语句块)
  • 父类(构造函数)
  • 子类(实例变量、普通语句块)
  • 子类(构造函数)

第6章 面向对象(下)

6.1 包装类

《疯狂Java讲义》(第5版) 李刚_第15张图片

JDK 1.5提供了自动装箱(Autoboxing)和自动拆箱(AutoUnboxing)功能。

包装类还可实现基本类型变量和字符串之间的转换。

把字符串类型的值转换为基本类型的值有两种方式。

➢ 利用包装类提供的parseXxx(String s)静态方法(除Character之外的所有包装类都提供了该方法。

➢ 利用包装类提供的valueOf(String s)静态方法。

String类也提供了多个重载valueOf()方法,用于将基本类型变量转换成字符串。

Java 7增强了包装类的功能,Java 7为所有的包装类都提供了一个静态的compare(xxx val1,xxx val2)方法,这样开发者就可以通过包装类提供的compare(xxx val1,xxx val2)方法来比较两个基本类型值的大小,包括比较两个boolean类型值,两个boolean类型值进行比较时,true>false。

6.2 处理对象

Object类提供的toString()方法总是返回该对象实现类的“类名+@+hashCode”值,这个返回值并不能真正实现“自我描述”的功能,因此如果用户需要自定义类能实现“自我描述”的功能,就必须重写Object类的toString()方法。

Java程序中测试两个变量是否相等有两种方式:一种是利用==运算符,另一种是利用equals()方法。当使用==来判断两个变量是否相等时,如果两个变量是基本类型变量,且都是数值类型(不一定要求数据类型严格相同),则只要两个变量的值相等,就将返回true。但对于两个引用类型变量,只有它们指向同一个对象时,==判断才会返回true。==不可用于比较类型上没有父子关系的两个对象。

6.3 类成员

在Java类里只能包含成员变量、方法、构造器、初始化块、内部类(包括接口、枚举)5种成员。

6.4 final修饰符

对于final修饰的成员变量而言,一旦有了初始值,就不能被重新赋值,如果既没有在定义成员变量时指定初始值,也没有在初始化块、构造器中为成员变量指定初始值,那么这些成员变量的值将一直是系统默认分配的0、'\u0000'、false或null,这些成员变量也就完全失去了存在的意义。因此Java语法规定:final修饰的成员变量必须由程序员显式地指定初始值

归纳起来,final修饰的类变量、实例变量能指定初始值的地方如下:

➢ 类变量:必须在静态初始化块中指定初始值或声明该类变量时指定初始值,而且只能在两个地方的其中之一指定。

➢ 实例变量:必须在非静态初始化块、声明该实例变量或构造器中指定初始值,而且只能在三个地方的其中之一指定。

系统不会对局部变量进行初始化,局部变量必须由程序员显式初始化。因此使用final修饰局部变量时,既可以在定义时指定默认值,也可以不指定默认值。如果final修饰的局部变量在定义时没有指定默认值,则可以在后面代码中对该final变量赋初始值,但只能一次,不能重复赋值;如果final修饰的局部变量在定义时已经指定默认值,则后面代码中不能再对该变量赋值。final修饰的形参因为形参在调用该方法时,由系统根据传入的参数来完成初始化,因此使用final修饰的形参不能被赋值。

当使用final修饰基本类型变量时,不能对基本类型变量重新赋值,因此基本类型变量不能被改变。但对于引用类型变量而言,它保存的仅仅是一个引用,final只保证这个引用类型变量所引用的地址不会改变,即一直引用同一个对象,但这个对象完全可以发生改变。

对一个final变量来说,不管它是类变量、实例变量,还是局部变量,只要该变量满足三个条件,这个final变量就不再是一个变量,而是相当于一个直接量。➢ 使用final修饰符修饰。➢ 在定义该final变量时指定了初始值。➢ 该初始值可以在编译时就被确定下来。

注意:final修饰符的一个重要用途就是定义“宏变量”。当定义final变量时就为该变量指定了初始值,而且该初始值可以在编译时就确定下来,那么这个final变量本质上就是一个“宏变量”,编译器会把程序中所有用到该变量的地方直接替换成该变量的值。

final修饰的方法不可被重写。

final修饰的类不可以有子类,例如java.lang.Math类就是一个final类,它不可以有子类。

不可变(immutable)类的意思是创建该类的实例后,该实例的实例变量不可改变的。Java提供的8个包装类和java.lang.String类都是不可变类,当创建它们的实例后,其实例的实例变量不可改变。

如果需要创建自定义的不可变类,可遵守如下规则。

➢ 使用private和final修饰符来修饰该类的成员变量。

➢ 提供带参数的构造器(或返回该实例的类方法),用于根据传入参数来初始化类里的成员变量。

➢ 仅为该类成员变量提供getter方法,不要为该类的成员变量提供setter方法,因为普通方法无法修改final修饰的成员变量。

➢ 如果有必要,重写Object类的hashCode()和equals()方法(关于重写hashCode()的步骤可参考8.3.1节)。equals()方法根据关键成员变量来作为两个对象是否相等的标准,除此之外,还应该保证两个用equals()方法判断为相等的对象的hashCode()也相等。

6.5 抽象类

抽象方法和抽象类必须使用abstract修饰符来定义,有抽象方法的类只能被定义成抽象类,抽象类里可以没有抽象方法。

抽象方法和抽象类的规则如下:

➢ 抽象类必须使用abstract修饰符来修饰,抽象方法也必须使用abstract修饰符来修饰,抽象方法不能有方法体。

➢ 抽象类不能被实例化,无法使用new关键字来调用抽象类的构造器创建抽象类的实例。即使抽象类里不包含抽象方法,这个抽象类也不能创建实例。

➢ 抽象类可以包含成员变量、方法(普通方法和抽象方法都可以)、构造器、初始化块、内部类(接口、枚举)5种成分。抽象类的构造器不能用于创建实例,主要是用于被其子类调用。

➢ 含有抽象方法的类(包括直接定义了一个抽象方法;或继承了一个抽象父类,但没有完全实现父类包含的抽象方法;或实现了一个接口,但没有完全实现接口包含的抽象方法三种情况)只能被定义成抽象类。

定义抽象方法只需在普通方法上增加abstract修饰符,并把普通方法的方法体(也就是方法后花括号括起来的部分)全部去掉,并在方法后增加分号即可。

定义抽象类只需在普通类上增加abstract修饰符即可。甚至一个普通类(没有包含抽象方法的类)增加abstract修饰符后也将变成抽象类。

当使用abstract修饰类时,表明这个类只能被继承;当使用abstract修饰方法时,表明这个方法必须由子类提供实现(即重写)。而final修饰的类不能被继承,final修饰的方法不能被重写。因此final和abstract永远不能同时使用。

注意:abstract不能用于修饰成员变量,不能用于修饰局部变量,即没有抽象变量、没有抽象成员变量等说法;abstract也不能用于修饰构造器,没有抽象构造器,抽象类里定义的构造器只能是普通构造器。

6.6 Java 9改进的接口

抽象类是从多个类中抽象出来的模板,如果将这种抽象进行得更彻底,则可以提炼出一种更加特殊的“抽象类”—接口(interface)。

Java 8对接口进行了改进,允许在接口中定义默认方法和类方法,默认方法和类方法都可以提供方法实现,Java 9为接口增加了一种私有方法,私有方法也可提供方法实现。

接口定义使用interface关键字,接口定义的基本语法如下:

《疯狂Java讲义》(第5版) 李刚_第16张图片

➢ 修饰符可以是public或者省略,如果省略了public访问控制符,则默认采用包权限访问控制符。

➢ 接口名应与类名采用相同的命名规则

➢ 一个接口可以有多个直接父接口,但接口只能继承接口,不能继承类。

由于接口定义的是一种规范,因此接口里不能包含构造器和初始化块定义。接口里可以包含成员变量(只能是静态常量)、方法(只能是抽象实例方法、类方法、默认方法或私有方法)、内部类(包括内部接口、枚举)定义。

接口的继承和类继承不一样,接口完全支持多继承,即一个接口可以有多个直接父接口。和类继承相似,子接口扩展某个父接口,将会获得父接口里定义的所有抽象方法、常量。一个接口继承多个父接口时,多个父接口排在extends关键字之后,多个父接口之间以英文逗号(,)隔开。

接口主要有如下用途:➢ 定义变量,也可用于进行强制类型转换。➢ 调用接口中定义的常量。➢ 被其他类实现。

一个类可以实现一个或多个接口,继承使用extends关键字,实现则使用implements关键字。因为一个类可以实现多个接口,这也是Java为单继承灵活性不足所做的补充。类实现接口的语法格式如下:

接口与抽象类异同点:

接口和抽象类很像,它们都具有如下特征(同):

➢ 接口和抽象类都不能被实例化,它们都位于继承树的顶端,用于被其他类实现和继承。

➢ 接口和抽象类都可以包含抽象方法,实现接口或继承抽象类的普通子类都必须实现这些抽象方法。

接口和抽象类在用法上也存在如下差别:

➢ 接口里只能包含抽象方法、静态方法、默认方法和私有方法,不能为普通方法提供方法实现;抽象类则可以包含普通方法。

➢ 接口里只能定义静态常量,不能定义普通成员变量;抽象类里则既可以定义普通成员变量,也可以定义静态常量。

➢ 接口里不包含构造器;抽象类里可以包含构造器,抽象类里的构造器并不是用于创建对象,而是让其子类调用这些构造器来完成属于抽象类的初始化操作。

➢ 接口里不能包含初始化块;但抽象类则完全可以包含初始化块。

➢ 一个类最多只能有一个直接父类,包括抽象类;但一个类可以直接实现多个接口。

➢ 接口是一种规范,抽象类是模板模式。

6.7 内部类

Java从JDK 1.1开始引入内部类,内部类主要有如下作用:

➢ 内部类提供了更好的封装,可以把内部类隐藏在外部类之内,不允许同一个包中的其他类访问该类。

➢ 内部类成员可以直接访问外部类的私有数据,因为内部类被当成其外部类成员,同一个类的成员之间可以互相访问。但外部类不能访问内部类的实现细节,例如内部类的成员变量。

➢ 匿名内部类适合用于创建那些仅需要一次使用的类。

从语法角度来看,定义内部类与定义外部类的语法大致相同,内部类除需要定义在其他类里面之外,还存在如下两点区别:

➢ 内部类比外部类可以多使用三个修饰符:private、protected、static—外部类不可以使用这三个修饰符。

➢ 非静态内部类不能拥有静态成员。

6.7.1 非静态内部类

内部类都被作为成员内部类定义,而不是作为局部内部类。成员内部类是一种与成员变量、方法、构造器和初始化块相似的类成员;局部内部类和匿名内部类则不是类成员。

成员内部类分为两种:静态内部类和非静态内部类,使用static修饰的成员内部类是静态内部类,没有使用static修饰的成员内部类是非静态内部类。

成员内部类(包括静态内部类、非静态内部类)的class文件总是这种形式:OuterClass$InnerClass.class。

当在非静态内部类的方法内访问某个变量时,系统优先在该方法内查找是否存在该名字的局部变量,如果存在就使用该变量;如果不存在,则到该方法所在的内部类中查找是否存在该名字的成员变量,如果存在则使用该成员变量;如果不存在,则到该内部类所在的外部类中查找是否存在该名字的成员变量,如果存在则使用该成员变量;如果依然不存在,系统将出现编译错误:提示找不到该变量。因此,如果外部类成员变量、内部类成员变量与内部类里方法的局部变量同名,则可通过使用this、外部类类名.this作为限定来区分。

非静态内部类的成员可以访问外部类的实例成员,但反过来就不成立了。如果外部类需要访问非静态内部类的实例成员,则必须显式创建非静态内部类对象来调用访问其实例成员。

根据静态成员不能访问非静态成员的规则,外部类的静态方法、静态代码块不能访问非静态内部类,包括不能使用非静态内部类定义变量、创建实例等。总之,不允许在外部类的静态成员中直接使用非静态内部类。

Java不允许在非静态内部类里定义静态成员,非静态内部类里不能有静态方法、静态成员变量、静态初始化块。

6.7.2 静态内部类

静态内部类可以包含静态成员,也可以包含非静态成员。根据静态成员不能访问非静态成员的规则,静态内部类不能访问外部类的实例成员,只能访问外部类的类成员。即使是静态内部类的实例方法也不能访问外部类的实例成员,只能访问外部类的静态成员。

6.7.3 使用内部类

1.在外部类内部使用内部类

从前面程序中可以看出,在外部类内部使用内部类时,与平常使用普通类没有太大的区别。一样可以直接通过内部类类名来定义变量,通过new调用内部类构造器来创建实例。唯一存在的一个区别是:不要在外部类的静态成员(包括静态方法和静态初始化块)中使用非静态内部类,因为静态成员不能访问非静态成员。在外部类内部定义内部类的子类与平常定义子类也没有太大的区别。

2.在外部类以外使用非静态内部类

如果希望在外部类以外的地方访问内部类(包括静态和非静态两种),则内部类不能使用private访问控制权限,private修饰的内部类只能在外部类内部使用。

对于使用其他访问控制符修饰的内部类,则能在访问控制符对应的访问权限内使用:

➢ 省略访问控制符的内部类,只能被与外部类处于同一个包中的其他类所访问。

➢ 使用protected修饰的内部类,可被与外部类处于同一个包中的其他类和外部类的子类所访问。

➢ 使用public修饰的内部类,可以在任何地方被访问。

在外部类以外的地方定义内部类(包括静态和非静态两种)变量的语法格式如下:

由于非静态内部类的对象必须寄生在外部类的对象里,因此创建非静态内部类对象之前,必须先创建其外部类对象。在外部类以外的地方创建非静态内部类实例的语法如下:

3.在外部类以外使用静态内部类

因为静态内部类是外部类类相关的,因此创建静态内部类对象时无须创建外部类对象。在外部类以外的地方创建静态内部类实例的语法如下:

6.7.4 局部内部类

如果把一个内部类放在方法里定义,则这个内部类就是一个局部内部类,局部内部类仅在该方法里有效。由于局部内部类不能在外部类的方法以外的地方使用,因此局部内部类也不能使用访问控制符和static修饰符修饰。

6.7.5 匿名内部类

匿名内部类适合创建那种只需要一次使用的类,定义匿名内部类的格式如下:

关于匿名内部类还有如下两条规则:

➢ 匿名内部类不能是抽象类,因为系统在创建匿名内部类时,会立即创建匿名内部类的对象。因此不允许将匿名内部类定义成抽象类。

➢ 匿名内部类不能定义构造器。由于匿名内部类没有类名,所以无法定义构造器,但匿名内部类可以定义初始化块,可以通过实例初始化块来完成构造器需要完成的事情。

6.8 Java 11增强的Lambda表达式

当使用Lambda表达式代替匿名内部类创建对象时,Lambda表达式的代码块将会代替实现抽象方法的方法体,Lambda表达式就相当一个匿名方法。

从上面语法格式可以看出,Lambda表达式的主要作用就是代替匿名内部类的烦琐语法,它由三部分组成:

➢ 形参列表。形参列表允许省略形参类型。如果形参列表中只有一个参数,甚至连形参列表的圆括号也可以省略。

➢ 箭头(->)。必须通过英文中画线和大于符号组成。

➢ 代码块。如果代码块只包含一条语句,Lambda表达式允许省略代码块的花括号,那么这条语句就不要用花括号表示语句结束。Lambda代码块只有一条return语句,甚至可以省略return关键字。Lambda表达式需要返回值,而它的代码块中仅有一条省略了return的语句,Lambda表达式会自动返回这条语句的值。

Lambda表达式的类型,也被称为“目标类型(target type)”,Lambda表达式的目标类型必须是“函数式接口(functional interface)”。函数式接口代表只包含一个抽象方法的接口。函数式接口可以包含多个默认方法、类方法,但只能声明一个抽象方法。

前面已经介绍过,如果Lambda表达式的代码块只有一条代码,程序就可以省略Lambda表达式中代码块的花括号。不仅如此,如果Lambda表达式的代码块只有一条代码,还可以在代码块中使用方法引用和构造器引用。方法引用和构造器引用可以让Lambda表达式的代码块更加简洁。方法引用构造器引用都需要使用两个英文冒号。Lambda表达式支持如表6.2所示的几种引用方式:

《疯狂Java讲义》(第5版) 李刚_第17张图片

Lambda表达式与匿名内部类的联系和区别:

Lambda表达式是匿名内部类的一种简化,因此它可以部分取代匿名内部类的作用。

Lambda表达式与匿名内部类存在如下相同点。

➢ Lambda表达式与匿名内部类一样,都可以直接访问“effectively final”的局部变量,以及外部类的成员变量(包括实例变量和类变量)。

➢ Lambda表达式创建的对象与匿名内部类生成的对象一样,都可以直接调用从接口中继承的默认方法。

Lambda表达式与匿名内部类主要存在如下区别。

➢ 匿名内部类可以为任意接口创建实例—不管接口包含多少个抽象方法,只要匿名内部类实现所有的抽象方法即可;但Lambda表达式只能为函数式接口创建实例。

➢ 匿名内部类可以为抽象类甚至普通类创建实例;但Lambda表达式只能为函数式接口创建实例。

➢ 匿名内部类实现的抽象方法的方法体允许调用接口中定义的默认方法;但Lambda表达式的代码块不允许调用接口中定义的默认方法。

6.9 枚举类

Java 5新增了一个enum关键字(它与class、interface关键字的地位相同),用以定义枚举类。正如前面看到的,枚举类是一种特殊的类,它一样可以有自己的成员变量、方法,可以实现一个或者多个接口,也可以定义自己的构造器。一个Java源文件中最多只能定义一个public访问权限的枚举类,且该Java源文件也必须和该枚举类的类名相同。

但枚举类终究不是普通类,它与普通类有如下简单区别:

➢ 枚举类可以实现一个或多个接口,使用enum定义的枚举类默认继承了java.lang.Enum类,而不是默认继承Object类,因此枚举类不能显式继承其他父类。其中java.lang.Enum类实现了java.lang.Serializable和java.lang.Comparable两个接口。

➢ 使用enum定义、非抽象的枚举类默认会使用final修饰。

➢ 枚举类的构造器只能使用private访问控制符,如果省略了构造器的访问控制符,则默认使用private修饰;如果强制指定访问控制符,则只能指定private修饰符。由于枚举类的所有构造器都是private的,而子类构造器总要调用父类构造器一次,因此枚举类不能派生子类。

➢ 枚举类的所有实例必须在枚举类的第一行显式列出,否则这个枚举类永远都不能产生实例。列出这些实例时,系统会自动添加public static final修饰,无须程序员显式添加。枚举类默认提供了一个values()方法,该方法可以很方便地遍历所有的枚举值。

6.10 对象与垃圾回收

6.10.1 对象在内存中的状态

当一个对象在堆内存中运行时,根据它被引用变量所引用的状态,可以把它所处的状态分成如下三种:

➢ 可达状态:当一个对象被创建后,若有一个以上的引用变量引用它,则这个对象在程序中处于可达状态,程序可通过引用变量来调用该对象的实例变量和方法。

➢ 可恢复状态:如果程序中某个对象不再有任何引用变量引用它,它就进入了可恢复状态。在这种状态下,系统的垃圾回收机制准备回收该对象所占用的内存,在回收该对象之前,系统会调用所有可恢复状态对象的finalize()方法进行资源清理。如果系统在调用finalize()方法时重新让一个引用变量引用该对象,则这个对象会再次变为可达状态;否则该对象将进入不可达状态。

➢ 不可达状态:当对象与所有引用变量的关联都被切断,且系统已经调用所有对象的finalize()方法后依然没有使该对象变成可达状态,那么这个对象将永久性地失去引用,最后变成不可达状态。只有当一个对象处于不可达状态时,系统才会真正回收该对象所占有的资源。

6.10.2 强制垃圾回收

当一个对象失去引用后,系统何时调用它的finalize()方法对它进行资源清理,何时它会变成不可达状态,系统何时回收它所占有的内存,对于程序完全透明。程序只能控制一个对象何时不再被任何引用变量引用,绝不能控制它何时被回收。程序无法精确控制Java垃圾回收的时机,但依然可以强制系统进行垃圾回收—这种强制只是通知系统进行垃圾回收,但系统是否进行垃圾回收依然不确定。大部分时候,程序强制系统垃圾回收后总会有一些效果。

强制系统垃圾回收有如下两种方式:

➢ 调用System类的gc()静态方法:System.gc()。

➢ 调用Runtime对象的gc()实例方法:Runtime.getRuntime().gc()。

这种强制只是建议系统立即进行垃圾回收,系统完全有可能并不立即进行垃圾回收,垃圾回收机制也不会对程序的建议完全置之不理:垃圾回收机制会在收到通知后,尽快进行垃圾回收。

6.10.3 finalize方法

finalize()方法具有如下4个特点。

➢ 永远不要主动调用某个对象的finalize()方法,该方法应交给垃圾回收机制调用。

➢ finalize()方法何时被调用,是否被调用具有不确定性,不要把finalize()方法当成一定会被执行的方法。

➢ 当JVM执行可恢复对象的finalize()方法时,可能使该对象或系统中其他对象重新变成可达状态。

➢ 当JVM执行finalize()方法时出现异常时,垃圾回收机制不会报告异常,程序继续执行。

6.10.4 对象的软、弱和虚引用

Java语言对对象的引用有如下4种方式:

1.强引用(StrongReference)这是Java程序中最常见的引用方式。程序创建一个对象,并把这个对象赋给一个引用变量,程序通过该引用变量来操作实际的对象,前面介绍的对象和数组都采用了这种强引用的方式。当一个对象被一个或一个以上的引用变量所引用时,它处于可达状态,不可能被系统垃圾回收机制回收。

2.软引用(SoftReference)软引用需要通过SoftReference类来实现,当一个对象只有软引用时,它有可能被垃圾回收机制回收。对于只有软引用的对象而言,当系统内存空间足够时,它不会被系统回收,程序也可使用该对象;当系统内存空间不足时,系统可能会回收它。软引用通常用于对内存敏感的程序中。

3.弱引用(WeakReference)弱引用通过WeakReference类实现,弱引用和软引用很像,但弱引用的引用级别更低。对于只有弱引用的对象而言,当系统垃圾回收机制运行时,不管系统内存是否足够,总会回收该对象所占用的内存。当然,并不是说当一个对象只有弱引用时,它就会立即被回收—正如那些失去引用的对象一样,必须等到系统垃圾回收机制运行时才会被回收。

4.虚引用(PhantomReference)虚引用通过PhantomReference类实现,虚引用完全类似于没有引用。虚引用对对象本身没有太大影响,对象甚至感觉不到虚引用的存在。如果一个对象只有一个虚引用时,那么它和没有引用的效果大致相同。虚引用主要用于跟踪对象被垃圾回收的状态,虚引用不能单独使用,虚引用必须和引用队列(ReferenceQueue)联合使用。

第7章 Java基础类库

7.1 与用户互动

Scanner主要提供了两个方法来扫描输入。

➢ hasNextXxx():是否还有下一个输入项,其中Xxx可以是Int、Long等代表基本数据类型的字符串。如果只是判断是否包含下一个字符串,则直接使用hasNext()。

➢ nextXxx():获取下一个输入项。Xxx的含义与前一个方法中的Xxx相同。

7.2 系统相关

Java程序在不同操作系统上运行时,可能需要取得平台相关的属性,或者调用平台命令来完成特定功能。Java提供了System类Runtime类来与程序的运行平台进行交互。

7.3 常用类

本节将介绍Java提供的一些常用类,如String、Math、BigDecimal等的用法。

Object类的clone()方法虽然简单、易用,但它只是一种“浅克隆”——它只克隆该对象的所有成员变量值,不会对引用类型的成员变量值所引用的对象进行克隆。如果开发者需要对对象进行“深克隆”,则需要开发者自己进行“递归”克隆,保证所有引用类型的成员变量值所引用的对象都被复制了。

Java 7新增了一个Objects工具类,它提供了一些工具方法来操作对象,这些工具方法大多是“空指针”安全的。

String类是不可变类,即一旦一个String对象被创建以后,包含在这个对象中的字符序列是不可改变的,直至这个对象被销毁。StringBuffer对象则代表一个字符序列可变的字符串,当一个StringBuffer被创建以后,通过StringBuffer提供的append()、insert()、reverse()、setCharAt()、setLength()等方法可以改变这个字符串对象的字符序列。一旦通过StringBuffer生成了最终想要的字符串,就可以调用它的toString()方法将其转换为一个String对象。StringBuilder类是JDK 1.5新增的类,它也代表可变字符串对象。实际上,StringBuilder和StringBuffer基本相似,两个类的构造器和方法也基本相同。不同的是,StringBuffer是线程安全的,而StringBuilder则没有实现线程安全功能,所以性能略高。因此在通常情况下,如果需要创建一个内容可变的字符串对象,则应该优先考虑使用StringBuilder类。

Java 9改进了字符串(包括String、StringBuffer、StringBuilder)的实现。在Java 9以前字符串采用char[]数组来保存字符,因此字符串的每个字符占2字节;而Java 9及更新版本的JDK的字符串采用byte[]数组再加一个encoding-flag字段来保存字符,因此字符串的每个字符只占1字节。所以Java 9及更新版本的JDK的字符串更加节省空间,但字符串的功能方法没有受到任何影响。

Random类专门用于生成一个伪随机数,它有两个构造器:一个构造器使用默认的种子(以当前时间作为种子),另一个构造器需要程序员显式传入一个long型整数的种子。ThreadLocalRandom类是Java 7新增的一个类,它是Random的增强版。在并发访问的环境下,使用ThreadLocalRandom来代替Random可以减少多线程资源竞争,最终保证系统具有更好的线程安全性。

为了能精确表示、计算浮点数,Java提供了BigDecimal类,该类提供了大量的构造器用于创建BigDecimal对象,包括把所有的基本数值型变量转换成一个BigDecimal对象,也包括利用数字字符串、数字字符数组来创建BigDecimal对象。

7.4 Java 8的日期、时间类

Date、Calendar

7.5 正则表达式

正则表达式是一个强大的字符串处理工具,可以对字符串进行查找、提取、分割、替换等操作。

String类里也提供了如下几个特殊的方法:

➢ boolean matches(String regex):判断该字符串是否匹配指定的正则表达式。

➢ String replaceAll(String regex, String replacement):将该字符串中所有匹配regex的子串替换成replacement。

➢ String replaceFirst(String regex, String replacement):将该字符串中第一个匹配regex的子串替换成replacement。

➢ String[] split(String regex):以regex作为分隔符,把该字符串分割成多个子串。

Java还提供了Pattern和Matcher两个类专门用于提供正则表达式支持。Pattern对象是正则表达式编译后在内存中的表示形式,因此,正则表达式字符串必须先被编译为Pattern对象,然后再利用该Pattern对象创建对应的Matcher对象。执行匹配所涉及的状态保留在Matcher对象中,多个Matcher对象可共享同一个Pattern对象。

《疯狂Java讲义》(第5版) 李刚_第18张图片

上面定义的Pattern对象可以多次重复使用。如果某个正则表达式仅需一次使用,则可直接使用Pattern类的静态matches()方法,此方法自动把指定字符串编译成匿名的Pattern对象,并执行匹配,如下所示。

Pattern是不可变类,可供多个并发线程安全使用。

Matcher类提供了如下几个常用方法:

➢ find():返回目标字符串中是否包含与Pattern匹配的子串。

➢ group():返回上一次与Pattern匹配的子串。

➢ start():返回上一次与Pattern匹配的子串在目标字符串中的开始位置。

➢ end():返回上一次与Pattern匹配的子串在目标字符串中的结束位置加1。

➢ lookingAt():返回目标字符串前面部分与Pattern是否匹配。

➢ matches():返回整个目标字符串与Pattern是否匹配。

➢ reset():将现有的Matcher对象应用于一个新的字符序列。

7.6 变量处理和方法处理

Java 9引入了一个新的VarHandle类,并增强了原有的MethodHandle类。通过这两个类,允许Java像动态语言一样引用变量、引用方法,并调用它们。

7.6.1 Java 9增强的MethodHandle

MethodHandle为Java增加了方法引用的功能,方法引用的概念有点类似于C的“函数指针”。这种方法引用是一种轻量级的引用方式,它不会检查方法的访问权限,也不管方法所属的类、实例方法或静态方法,MethodHandle就是简单代表特定的方法,并可通过MethodHandle来调用方法。

为了使用MethodHandle,还涉及如下几个类:

➢ MethodHandles:MethodHandle的工厂类,它提供了一系列静态方法用于获取MethodHandle。

➢ MethodHandles.Lookup:Lookup静态内部类也是MethodHandle、VarHandle的工厂类,专门用于获取MethodHandle和VarHandle。

➢ MethodType:代表一个方法类型。MethodType根据方法的形参、返回值类型来确定方法类型。

下面程序示范了MethodHandle的用法。

《疯狂Java讲义》(第5版) 李刚_第19张图片

7.6.2 Java 9增加的VarHandle

VarHandle主要用于动态操作组的元素或对象的成员变量。VarHandle与MethodHandle非常相似,它也需要通过MethodHandles来获取实例,接下来调用VarHandle的方法即可动态操作指定数组的元素或指定对象的成员变量。

下面程序示范了VarHandle的用法。

《疯狂Java讲义》(第5版) 李刚_第20张图片

《疯狂Java讲义》(第5版) 李刚_第21张图片

7.7 Java 11改进的国际化与格式化

Java程序的国际化主要通过如下三个类完成。

➢ java.util.ResourceBundle:用于加载国家、语言资源包。

➢ java.util.Locale:用于封装特定的国家/区域、语言环境。

➢ java.text.MessageFormat:用于格式化带占位符的字符串。

为了实现程序的国际化,必须先提供程序所需要的资源文件。资源文件的内容是很多key-value对,其中key是程序使用的部分,而value则是程序界面的显示字符串。

资源文件的命名可以有如下三种形式。

➢ baseName_language_country.properties

➢ baseName_language.properties

➢ baseName.properties

其中baseName是资源文件的基本名,用户可随意指定;而language和country都不可随意变化,必须是Java所支持的语言和国家。

Locale类的getAvailableLocales()方法,该方法返回一个Locale数组,该数组里包含了Java所支持的国家和语言。

MessageFormat是抽象类Format的子类,Format抽象类还有两个子类:NumberFormat和DateFormat,它们分别用以实现数值、日期的格式化。NumberFormat、DateFormat可以将数值、日期转换成字符串,也可以将字符串转换成数值、日期。SimpleDateFormat是DateFormat的子类,正如它的名字所暗示的,它是“简单”的日期格式器。

《疯狂Java讲义》(第5版) 李刚_第22张图片

7.8 Java 8新增的日期、时间格式器

第8章 Java集合

8.1 Java集合概述

集合类主要负责保存、盛装其他数据,因此集合类也称为容器类。所有的集合类都位于java.util包下,后来为了处理多线程环境下的并发安全问题,Java 5还在java.util.concurrent包下提供了一些多线程支持的集合类。

Java的集合类主要由两个接口派生而出:CollectionMap,Collection和Map是Java集合框架的根接口,这两个接口又包含了一些子接口或实现类。

《疯狂Java讲义》(第5版) 李刚_第23张图片

《疯狂Java讲义》(第5版) 李刚_第24张图片

8.2 Java 11增强的Collection和Iterator接口

Collection接口里定义了如下操作集合元素的方法。

➢ boolean add(Object o):该方法用于向集合里添加一个元素。

➢ boolean addAll(Collection c):该方法把集合c里的所有元素添加到指定集合里。

➢ void clear():清除集合里的所有元素,将集合长度变为0。

➢ boolean contains(Object o):返回集合里是否包含指定元素。

➢ boolean containsAll(Collection c):返回集合里是否包含集合c里的所有元素。

➢ boolean isEmpty():返回集合是否为空。当集合长度为0时返回true,否则返回false。

➢ Iterator iterator():返回一个Iterator对象,用于遍历集合里的元素。

➢ boolean remove(Object o):删除集合中的指定元素o,当集合中包含了一个或多个元素o时,该方法只删除第一个符合条件的元素,该方法将返回true。

➢ boolean removeAll(Collection c):从集合中删除集合c里包含的所有元素(相当于用调用该方法的集合减集合c),如果删除了一个或一个以上的元素,则该方法返回true。

➢ boolean retainAll(Collection c):从集合中删除集合c里不包含的元素(相当于把调用该方法的集合变成该集合和集合c的交集),如果该操作改变了调用该方法的集合,则该方法返回true。

➢ int size():该方法返回集合里元素的个数

Java 11为Collection新增了一个toArray(IntFunction)方法,使用该方法的主要目的就是利用泛型。对于传统的toArray()方法而言,不管Collection本身是否使用泛型,toArray()的返回值总是Object[];但新增的toArray(IntFunction)方法不同,当Collection使用泛型时,toArray(IntFunction)可以返回特定类型的数组。

8.2.1 使用Lambda表达式遍历集合

Java 8为Iterable接口新增了一个forEach(Consumer action)默认方法,该方法所需参数的类型是一个函数式接口。正因为Consumer是函数式接口,因此可以使用Lambda表达式来遍历集合元素。

8.2.2 使用Iterator遍历集合元素

Iterator则主要用于遍历(即迭代访问)Collection集合中的元素,Iterator对象也被称为迭代器

Iterator接口里定义了如下4个方法。

➢ boolean hasNext():如果被迭代的集合元素还没有被遍历完,则返回true。

➢ Object next():返回集合里的下一个元素。

➢ void remove():删除集合里上一次next方法返回的元素。

➢ void forEachRemaining(Consumer action),这是Java 8为Iterator新增的默认方法,该方法可使用Lambda表达式来遍历集合元素。

Iterator迭代器采用的是快速失败(fail-fast)机制,一旦在迭代过程中检测到该集合已经被修改(通常是程序中的其他线程修改),程序立即引发ConcurrentModificationException异常,而不是显示修改后的结果,这样可以避免共享资源而引发的潜在问题。

8.2.3 使用Lambda表达式遍历Iterator

Java 8为Iterator新增了一个forEachRemaining(Consumer action)方法,该方法所需的Consumer参数同样也是函数式接口。当程序调用Iterator的forEachRemaining(Consumeraction)遍历集合元素时,程序会依次将集合元素传给Consumer的accept(T t)方法(该接口中唯一的抽象方法)。

8.2.4 使用foreach循环遍历集合元素

Java 5提供的foreach循环迭代访问集合元素更加便捷。

8.2.5 使用Predicate操作集合

Java 8为Collection集合新增了一个removeIf(Predicate filter)方法,该方法将会批量删除符合filter条件的所有元素。该方法需要一个Predicate(谓词)对象作为参数,Predicate也是函数式接口,因此可使用Lambda表达式作为参数。

8.2.6 使用Stream操作集合

Java 8还新增了Stream、IntStream、LongStream、DoubleStream等流式API,这些API代表多个支持串行和并行聚集操作的元素。

Java 8还为上面每个流式API提供了对应的Builder,例如Stream.Builder、IntStream.Builder、LongStream.Builder、DoubleStream.Builder,开发者可以通过这些Builder来创建对应的流。

独立使用Stream的步骤如下:

①使用Stream或XxxStream的builder()类方法创建该Stream对应的Builder。

②重复调用Builder的add()方法向该流中添加多个元素。

③调用Builder的build()方法获取对应的Stream。

④调用Stream的聚集方法。

在上面4个步骤中,第4步可以根据具体需求来调用不同的方法,Stream提供了大量的聚集方法供用户调用,具体可参考Stream或XxxStream的API文档。对于大部分聚集方法而言,每个Stream只能执行一次。

Stream提供了大量的方法进行聚集操作,这些方法既可以是“中间的”(intermediate),也可以是“末端的”(terminal)。

➢ 中间方法:中间操作允许流保持打开状态,并允许直接调用后续方法。

➢ 末端方法:末端方法是对流的最终操作。

除此之外,关于流的方法还有如下两个特征。

➢ 有状态的方法:这种方法会给流增加一些新的属性,比如元素的唯一性、元素的最大数量、保证元素以排序的方式被处理等。有状态的方法往往需要更大的性能开销。

➢ 短路方法:短路方法可以尽早结束对流的操作,不必检查所有的元素。

下面简单介绍一下Stream常用的中间方法

➢ filter(Predicate predicate):过滤Stream中所有不符合predicate的元素。

➢ mapToXxx(ToXxxFunction mapper):使用ToXxxFunction对流中的元素执行一对一的转换,该方法返回的新流中包含了ToXxxFunction转换生成的所有元素。

➢ peek(Consumer action):依次对每个元素执行一些操作,该方法返回的流与原有流包含相同的元素。该方法用于调试。

➢ distinct():该方法用于排序流中所有重复元素(判断元素重复的标准是使用equals()比较返回true),这是一个有状态的方法。

➢ sorted():该方法用于保证流中的元素在后续的访问中处于有序状态。这是一个有状态的方法。

➢ limit(long maxSize):该方法用于保证对该流的后续访问中最大允许访问的元素个数。这是一个有状态的、短路方法。

下面简单介绍一下Stream常用的末端方法。

➢ forEach(Consumer action):遍历流中所有元素,对每个元素执行action。

➢ toArray():将流中所有元素转换为一个数组

➢ reduce():该方法有三个重载的版本,都用于通过某种操作来合并流中的元素。

➢ min():返回流中所有元素的最小值。

➢ max():返回流中所有元素的最大值。

➢ count():返回流中所有元素的数量。

➢ anyMatch(Predicate predicate):判断流中是否至少包含一个元素符合Predicate条件。

8.3 Set集合

Set集合不允许包含相同的元素,如果试图把两个相同的元素加入同一个Set集合中,则添加操作失败,add()方法返回false,且新元素不会被加入。

8.3.1 HashSet类

HashSet是Set接口的典型实现,大多数时候使用Set集合时就是使用这个实现类。HashSet按Hash算法来存储集合中的元素,因此具有很好的存取和查找性能。

HashSet具有以下特点。

➢ 不能保证元素的排列顺序,顺序可能与添加顺序不同,顺序也有可能发生变化。

➢ HashSet不是同步的,如果多个线程同时访问一个HashSet,假设有两个或者两个以上线程同时修改了HashSet集合时,则必须通过代码来保证其同步。

➢ 集合元素值可以是null。

HashSet集合判断两个元素相等的标准是两个对象通过equals()方法比较相等,且两个对象的hashCode()值也相等。

8.3.2 LinkedHashSet类

HashSet还有一个子类LinkedHashSet,LinkedHashSet集合也是根据元素的hashCode值来决定元素的存储位置,但它同时使用链表维护元素的次序,这样使得元素看起来是以插入的顺序保存的。也就是说,当遍历LinkedHashSet集合里的元素时,LinkedHashSet将会按元素的添加顺序来访问集合里的元素。LinkedHashSet需要维护元素的插入顺序,因此性能略低于HashSet的性能,但在迭代访问Set里的全部元素时将有很好的性能,因为它以链表来维护内部顺序。

8.3.3 TreeSet类

TreeSet是SortedSet接口的实现类,正如SortedSet名字所暗示的,TreeSet可以确保集合元素处于排序状态。

与HashSet集合相比,TreeSet还提供了如下几个额外的方法。

➢ Comparator comparator():如果TreeSet采用了定制排序,则该方法返回定制排序所使用的Comparator;如果TreeSet采用了自然排序,则返回null。

➢ Object first():返回集合中的第一个元素。

➢ Object last():返回集合中的最后一个元素。

➢ Object lower(Object e):返回集合中位于指定元素之前的元素(即小于指定元素的最大元素,参考元素不需要是TreeSet集合里的元素)。

➢ Object higher(Object e):返回集合中位于指定元素之后的元素(即大于指定元素的最小元素,参考元素不需要是TreeSet集合里的元素)。

➢ SortedSet subSet(Object fromElement, Object toElement):返回此Set的子集合,范围从fromElement(包含)到toElement(不包含)。

➢ SortedSet headSet(Object toElement):返回此Set的子集,由小于toElement的元素组成。

➢ SortedSet tailSet(Object fromElement):返回此Set的子集,由大于或等于fromElement的元素组成。

与HashSet集合采用hash算法来决定元素的存储位置不同,TreeSet采用红黑树的数据结构来存储集合元素。TreeSet支持两种排序方法:自然排序和定制排序。在默认情况下,TreeSet采用自然排序。

8.3.4 EnumSet类

EnumSet是一个专为枚举类设计的集合类,EnumSet中的所有元素都必须是指定枚举类型的枚举值,该枚举类型在创建EnumSet时显式或隐式地指定。EnumSet的集合元素也是有序的,EnumSet以枚举值在Enum类内的定义顺序来决定集合元素的顺序。

EnumSet在内部以位向量的形式存储,这种存储形式非常紧凑、高效,因此EnumSet对象占用内存很小,而且运行效率很好。尤其是进行批量操作(如调用containsAll() 和retainAll()方法)时,如果其参数也是EnumSet集合,则该批量操作的执行速度也非常快。

EnumSet集合不允许加入null元素,如果试图插入null元素,EnumSet将抛出NullPointerException异常。如果只是想判断EnumSet是否包含null元素或试图删除null元素都不会抛出异常,只是删除操作将返回false,因为没有任何null元素被删除。

EnumSet类没有暴露任何构造器来创建该类的实例,程序应该通过它提供的类方法来创建EnumSet对象。

EnumSet类它提供了如下常用的类方法来创建EnumSet对象。

➢ EnumSet allOf(Class elementType):创建一个包含指定枚举类里所有枚举值的EnumSet集合。

➢ EnumSet complementOf(EnumSet s):创建一个其元素类型与指定EnumSet里元素类型相同的EnumSet集合,新EnumSet集合包含原EnumSet集合所不包含的、此枚举类剩下的枚举值(即新EnumSet集合和原EnumSet集合的集合元素加起来就是该枚举类的所有枚举值)。

➢ EnumSet copyOf(Collection c):使用一个普通集合来创建EnumSet集合。

➢ EnumSet copyOf(EnumSet s):创建一个与指定EnumSet具有相同元素类型、相同集合元素的EnumSet集合。

➢ EnumSet noneOf(Class elementType):创建一个元素类型为指定枚举类型的空EnumSet。

➢ EnumSet of(E first, E...rest):创建一个包含一个或多个枚举值的EnumSet集合,传入的多个枚举值必须属于同一个枚举类。

➢ EnumSet range(E from, E to):创建一个包含从from枚举值到to枚举值范围内所有枚举值的EnumSet集合。

8.3.5 各Set实现类的性能分析

HashSet的性能总是比TreeSet好(特别是最常用的添加、查询元素等操作),因为TreeSet需要额外的红黑树算法来维护集合元素的次序。只有当需要一个保持排序的Set时,才应该使用TreeSet,否则都应该使用HashSet。

HashSet还有一个子类:LinkedHashSet,对于普通的插入、删除操作,LinkedHashSet比HashSet要略微慢一点,这是由维护链表所带来的额外开销造成的,但由于有了链表,遍历LinkedHashSet会更快。

EnumSet是所有Set实现类中性能最好的,但它只能保存同一个枚举类的枚举值作为集合元素。

必须指出的是,Set的三个实现类HashSet、TreeSet和EnumSet都是线程不安全的。

如果有多个线程同时访问一个Set集合,并且有超过一个线程修改了该Set集合,则必须手动保证该Set集合的同步性。通常可以通过Collections工具类的synchronizedSortedSet方法来“包装”该Set集合。

8.4 List集合

List集合代表一个元素有序、可重复的集合,集合中每个元素都有其对应的顺序索引。List集合允许使用重复元素,可以通过索引来访问指定位置的集合元素。

8.4.1 改进的List接口和ListIterator接口

List作为Collection接口的子接口,当然可以使用Collection接口里的全部方法。而且由于List是有序集合,因此List集合里增加了一些根据索引来操作集合元素的方法。

➢ void add(int index, Object element):将元素element插入到List集合的index处。

➢ boolean addAll(int index, Collection c):将集合c所包含的所有元素都插入到List集合的index处。

➢ Object get(int index):返回集合index索引处的元素。

➢ int indexOf(Object o):返回对象o在List集合中第一次出现的位置索引。

➢ int lastIndexOf(Object o):返回对象o在List集合中最后一次出现的位置索引。

➢ Object remove(int index):删除并返回index索引处的元素。

➢ Object set(int index, Object element):将index索引处的元素替换成element对象,返回被替换的旧元素。

➢ List subList(int fromIndex, int toIndex):返回从索引fromIndex(包含)到索引toIndex(不包含)处所有集合元素组成的子集合。所有的List实现类都可以调用这些方法来操作集合元素。与Set集合相比,List增加了根据索引来插入、替换和删除集合元素的方法。除此之外,Java 8还为List接口添加了如下两个默认方法。

➢ void replaceAll(UnaryOperator operator):根据operator指定的计算规则重新设置List集合的所有元素。➢ void sort(Comparator c):根据Comparator参数对List集合的元素排序。

Java 8为List集合增加了sort()和replaceAll()两个常用的默认方法,其中sort()方法需要一个Comparator对象来控制元素排序,程序可使用Lambda表达式来作为参数;而replaceAll()方法则需要一个UnaryOperator来替换所有集合元素,UnaryOperator也是一个函数式接口,因此程序也可使用Lambda表达式作为参数。

与Set只提供了一个iterator()方法不同,List还额外提供了一个listIterator()方法,该方法返回一个ListIterator对象,ListIterator接口继承了Iterator接口,提供了专门操作List的方法。

ListIterator接口在Iterator接口基础上增加了如下方法。

➢ boolean hasPrevious():返回该迭代器关联的集合是否还有上一个元素。

➢ Object previous():返回该迭代器的上一个元素。

➢ void add(Object o):在指定位置插入一个元素。

8.4.2 ArrayList和Vector实现类

ArrayList和Vector作为List类的两个典型实现,完全支持前面介绍的List接口的全部功能。

ArrayList和Vector类都是基于数组实现的List类,所以ArrayList和Vector类封装了一个动态的、允许再分配的Object[]数组。ArrayList或Vector对象使用initialCapacity参数来设置该数组的长度,当向ArrayList或Vector中添加元素超出了该数组的长度时,它们的initialCapacity会自动增加。对于通常的编程场景,程序员无须关心ArrayList或Vector的initialCapacity。但如果向ArrayList或Vector集合中添加大量元素时,可使用ensureCapacity(int minCapacity)方法一次性地增加initialCapacity。这可以减少重分配的次数,从而提高性能。如果开始就知道ArrayList或Vector集合需要保存多少个元素,则可以在创建它们时就指定initialCapacity大小。如果创建空的ArrayList或Vector集合时不指定initialCapacity参数,则Object[]数组的长度默认为10

除此之外,ArrayList和Vector还提供了如下两个方法来重新分配Object[]数组。

➢ void ensureCapacity(int minCapacity):将ArrayList或Vector集合的Object[]数组长度增加大于或等于minCapacity值。

➢ void trimToSize():调整ArrayList或Vector集合的Object[]数组长度为当前元素的个数。调用该方法可减少ArrayList或Vector集合对象占用的存储空间。

ArrayList和Vector在用法上几乎完全相同,ArrayList和Vector的显著区别是:

ArrayList是线程不安全的,当多个线程访问同一个ArrayList集合时,如果有超过一个线程修改了ArrayList集合,则程序必须手动保证该集合的同步性;但Vector集合则是线程安全的,无须程序保证该集合的同步性。因为Vector是线程安全的,所以Vector的性能比ArrayList的性能要低。实际上,即使需要保证List集合线程安全,也同样不推荐使用Vector实现类。后面会介绍一个Collections工具类,它可以将一个ArrayList变成线程安全的。

Vector还提供了一个Stack子类,它用于模拟“栈”这种数据结构,“栈”通常是指“后进先出”(LIFO)的容器。最后“push”进栈的元素,将最先被“pop”出栈。

Stack类里提供了如下几个方法。

➢ Object peek():返回“栈”的第一个元素,但并不将该元素“pop”出栈。

➢ Object pop():返回“栈”的第一个元素,并将该元素“pop”出栈。

➢ void push(Object item):将一个元素“push”进栈,最后一个进“栈”的元素总是位于“栈”顶。需要指出的是,由于Stack继承了Vector,因此它也是一个非常古老的Java集合类,它同样是线程安全的、性能较差的,因此应该尽量少用Stack类。如果程序需要使用“栈”这种数据结构,建议使用后面将要介绍的ArrayDeque代替它。

8.4.3 固定长度的List

前面讲数组时介绍了一个操作数组的工具类Arrays,该工具类里提供了asList(Object...a)方法,该方法可以把一个数组或指定个数的对象转换成一个List集合,这个List集合既不是ArrayList实现类的实例,也不是Vector实现类的实例,而是Arrays的内部类ArrayList的实例。Arrays.ArrayList是一个固定长度的List集合,程序只能遍历访问该集合里的元素,不可增加、删除该集合里的元素。

8.5 Queue集合

Queue用于模拟队列这种数据结构,队列通常是指“先进先出”(FIFO)的容器。

队列的头部保存在队列中存放时间最长的元素,队列的尾部保存在队列中存放时间最短的元素。新元素插入(offer)到队列的尾部,访问元素(poll)操作会返回队列头部的元素。通常,队列不允许随机访问队列中的元素。

Queue接口中定义了如下几个方法。

➢ void add(Object e):将指定元素加入此队列的尾部。

➢ Object element():获取队列头部的元素,但是不删除该元素。

➢ boolean offer(Object e):将指定元素加入此队列的尾部。当使用有容量限制的队列时,此方法通常比add(Object e)方法更好。

➢ Object peek():获取队列头部的元素,但是不删除该元素。如果此队列为空,则返回null。

➢ Object poll():获取队列头部的元素,并删除该元素。如果此队列为空,则返回null。

➢ Object remove():获取队列头部的元素,并删除该元素。

Queue接口有一个PriorityQueue实现类。除此之外,Queue还有一个Deque接口,Deque代表一个“双端队列”,双端队列可以同时从两端来添加、删除元素,因此Deque的实现类既可当成队列使用,也可当成栈使用。Java为Deque提供了ArrayDequeLinkedList两个实现类。

8.5.1 PriorityQueue实现类

PriorityQueue是一个比较标准的队列实现类。PriorityQueue保存队列元素的顺序并不是按加入队列的顺序,而是按队列元素的大小进行重新排序。因此当调用peek()方法或者poll()方法取出队列中的元素时,并不是取出最先进入队列的元素,而是取出队列中最小的元素。从这个意义上来看,PriorityQueue已经违反了队列的最基本规则:先进先出(FIFO)。

PriorityQueue不允许插入null元素,它还需要对队列元素进行排序,PriorityQueue的元素有两种排序方式。

➢ 自然排序:采用自然顺序的PriorityQueue集合中的元素必须实现了Comparable接口,而且应该是同一个类的多个实例,否则可能导致ClassCastException异常。

➢ 定制排序:创建PriorityQueue队列时,传入一个Comparator对象,该对象负责对队列中的所有元素进行排序。采用定制排序时不要求队列元素实现Comparable接口。PriorityQueue队列对元素的要求与TreeSet对元素的要求基本一致。

8.5.2 Deque接口与ArrayDeque实现类

Deque接口是Queue接口的子接口,它代表一个双端队列,Deque接口里定义了一些双端队列的方法,这些方法允许从两端来操作队列的元素。

➢ void addFirst(Object e):将指定元素插入该双端队列的开头。

➢ void addLast(Object e):将指定元素插入该双端队列的末尾。

➢ Iterator descendingIterator():返回该双端队列对应的迭代器,该迭代器将以逆向顺序来迭代队列中的元素。

➢ Object getFirst():获取但不删除双端队列的第一个元素。

➢ Object getLast():获取但不删除双端队列的最后一个元素。

➢ boolean offerFirst(Object e):将指定元素插入该双端队列的开头。

➢ boolean offerLast(Object e):将指定元素插入该双端队列的末尾。

➢ Object peekFirst():获取但不删除该双端队列的第一个元素;如果此双端队列为空,则返回null。

➢ Object peekLast():获取但不删除该双端队列的最后一个元素;如果此双端队列为空,则返回null。

➢ Object pollFirst():获取并删除该双端队列的第一个元素;如果此双端队列为空,则返回null。

➢ Object pollLast():获取并删除该双端队列的最后一个元素;如果此双端队列为空,则返回null。

➢ Object pop()(栈方法):pop出该双端队列所表示的栈的栈顶元素。相当于removeFirst()。

➢ void push(Object e)(栈方法):将一个元素push进该双端队列所表示的栈的栈顶。相当于addFirst(e)。

➢ Object removeFirst():获取并删除该双端队列的第一个元素。

➢ Object removeFirstOccurrence(Object o):删除该双端队列的第一次出现的元素o。

➢ Object removeLast():获取并删除该双端队列的最后一个元素。

➢ boolean removeLastOccurrence(Object o):删除该双端队列的最后一次出现的元素o。从上面方法中可以看出,Deque不仅可以当成双端队列使用,而且可以被当成栈来使用,因为该类里还包含了pop(出栈)、push(入栈)两个方法。

从上面方法中可以看出,Deque不仅可以当成双端队列使用,而且可以被当成栈来使用,因为该类里还包含了pop(出栈)、push(入栈)两个方法。

Deque的方法与Queue的方法对照表如表8.2所示。

《疯狂Java讲义》(第5版) 李刚_第25张图片

Deque的方法与Stack的方法对照表如表8.3所示。

Deque接口提供了一个典型的实现类:ArrayDeque,从该名称就可以看出,它是一个基于数组实现的双端队列,创建Deque时同样可指定一个numElements参数,该参数用于指定Object[]数组的长度;如果不指定numElements参数,Deque底层数组的长度为16。

8.5.3 LinkedList实现类

LinkedList类是List接口的实现类——这意味着它是一个List集合,可以根据索引来随机访问集合中的元素。除此之外,LinkedList还实现了Deque接口,可以被当成双端队列来使用,因此既可以被当成“栈”来使用,也可以当成队列使用。

ArrayList、ArrayDeque内部以数组的形式来保存集合中的元素,因此随机访问集合元素时有较好的性能;而LinkedList内部以链表的形式来保存集合中的元素,因此随机访问集合元素时性能较差,但在插入、删除元素时性能比较出色(只需改变指针所指的地址即可)。需要指出的是,虽然Vector也是以数组的形式来存储集合元素的,但因为它实现了线程同步功能(而且实现机制也不好),所以各方面性能都比较差。

8.5.4 各种线性表的性能分析

Java提供的List就是一个线性表接口,而ArrayList、LinkedList又是线性表的两种典型实现:基于数组的线性表和基于链的线性表。

Queue代表了队列,Deque代表了双端队列(既可作为队列使用,也可作为栈使用),接下来对各种实现类的性能进行分析。初学者可以无须理会ArrayList和LinkedList之间的性能差异,只需要知道LinkedList集合不仅提供了List的功能,还提供了双端队列、栈的功能就行。一般来说,由于数组以一块连续内存区来保存所有的数组元素,所以数组在随机访问时性能最好,所有的内部以数组作为底层实现的集合在随机访问时性能都比较好;而内部以链表作为底层实现的集合在执行插入、删除操作时有较好的性能。但总体来说,ArrayList的性能比LinkedList的性能要好,因此大部分时候都应该考虑使用ArrayList。

关于使用List集合有如下建议。

➢ 如果需要遍历List集合元素,对于ArrayList、Vector集合,应该使用随机访问方法(get)来遍历集合元素,这样性能更好;对于LinkedList集合,则应该采用迭代器(Iterator)来遍历集合元素。

➢ 如果需要经常执行插入、删除操作来改变包含大量数据的List集合的大小,可考虑使用LinkedList集合。使用ArrayList、Vector集合可能需要经常重新分配内部数组的大小,效果可能较差。

➢ 如果有多个线程需要同时访问List集合中的元素,开发者可考虑使用Collections将集合包装成线程安全的集合。

8.6 增强的Map集合

Map用于保存具有映射关系的数据,因此Map集合里保存着两组值,一组值用于保存Map里的key,另外一组值用于保存Map里的value,key和value都可以是任何引用类型的数据。Map的key不允许重复,即同一个Map对象的任何两个key通过equals方法比较总是返回false。key和value之间存在单向一对一关系,即通过指定的key,总能找到唯一的、确定的value。从Map中取出数据时,只要给出指定的key,就可以取出对应的value。

8.6.1 Java 8为Map新增的方法

Java 8除为Map增加了remove(Object key,Object value)默认方法之外,还增加了如下方法。

 Object compute(Object key, BiFunction remappingFunction)、 Object computeIfAbsent(Object key, Function mappingFunction)、 Object computeIfPresent(Object key, BiFunction remappingFunction)、➢ void forEach(BiConsumer action)、➢ Object getOrDefault(Object key, V defaultValue)等。

8.6.2 改进的HashMap和Hashtable实现类

Java 8改进了HashMap的实现,使用HashMap存在key冲突时依然具有较好的性能。

此外,Hashtable和HashMap存在两点典型区别。

➢ Hashtable是一个线程安全的Map实现,但HashMap是线程不安全的实现,所以HashMap比Hashtable的性能高一点;但如果有多个线程访问同一个Map对象时,使用Hashtable实现类会更好。

➢ Hashtable不允许使用null作为key和value,如果试图把null值放进Hashtable中,将会引发NullPointerException异常;但HashMap可以使用null作为key或value。由于HashMap里的key不能重复,所以HashMap里最多只有一个key-value对的key为null,但可以有无数多个key-value对的value为null。下面程序示范了用null值作为HashMap的key和value的情形。

8.6.3 LinkedHashMap实现类

HashSet有一个LinkedHashSet子类,HashMap也有一个LinkedHashMap子类;LinkedHashMap也使用双向链表来维护key-value对的顺序(其实只需要考虑key的顺序),该链表负责维护Map的迭代顺序,迭代顺序与key-value对的插入顺序保持一致。LinkedHashMap可以避免对HashMap、Hashtable里的key-value对进行排序(只要插入key-value对时保持顺序即可),同时又可避免使用TreeMap所增加的成本。LinkedHashMap需要维护元素的插入顺序,因此性能略低于HashMap的性能;但因为它以链表来维护内部顺序,所以在迭代访问Map里的全部元素时将有较好的性能。

8.6.4 使用Properties读写属性文件

Properties类是Hashtable类的子类,正如它的名字所暗示的,该对象在处理属性文件时特别方便(Windows操作平台上的ini文件就是一种属性文件)。

Properties类可以把Map对象和属性文件关联起来,从而可以把Map对象中的key-value对写入属性文件中,也可以把属性文件中的“属性名=属性值”加载到Map对象中。由于属性文件里的属性名、属性值只能是字符串类型,所以Properties里的key、value都是字符串类型。该类提供了如下三个方法来修改Properties里的key、value值。

➢ String getProperty(String key):获取Properties中指定属性名对应的属性值,类似于Map的get(Object key)方法。

➢ String getProperty(String key, String defaultValue):该方法与前一个方法基本相似。该方法多一个功能,如果Properties中不存在指定的key时,则该方法指定默认值。

➢ Object setProperty(String key, String value):设置属性值,类似于Hashtable的put()方法。

除此之外,它还提供了两个读写属性文件的方法。

➢ void load(InputStream inStream):从属性文件(以输入流表示)中加载key-value对,把加载到的key-value对追加到Properties里(Properties是Hashtable的子类,它不保证key-value对之间的次序)。

➢ void store(OutputStream out, String comments):将Properties中的key-value对输出到指定的属性文件(以输出流表示)中。

8.6.5 SortedMap接口和TreeMap实现类

正如Set接口派生出SortedSet子接口,SortedSet接口有一个TreeSet实现类一样,Map接口也派生出一个SortedMap子接口,SortedMap接口也有一个TreeMap实现类。TreeMap就是一个红黑树数据结构,每个key-value对即作为红黑树的一个节点。TreeMap存储key-value对(节点)时,需要根据key对节点进行排序。TreeMap可以保证所有的key-value对处于有序状态。

TreeMap也有两种排序方式。

➢ 自然排序:TreeMap的所有key必须实现Comparable接口,而且所有的key应该是同一个类的对象,否则将会抛出ClassCastException异常。

➢ 定制排序:创建TreeMap时,传入一个Comparator对象,该对象负责对TreeMap中的所有key进行排序。采用定制排序时不要求Map的key实现Comparable接口。类似于TreeSet中判断两个元素相等的标准,TreeMap中判断两个key相等的标准是:两个key通过compareTo()方法返回0,TreeMap即认为这两个key是相等的。

如果使用自定义类作为TreeMap的key,且想让TreeMap良好地工作,则重写该类的equals()方法和compareTo()方法时应保持一致的返回结果:两个key通过equals()方法比较返回true时,它们通过compareTo()方法比较应该返回0。如果equals()方法与compareTo()方法的返回结果不一致,TreeMap与Map接口的规则就会冲突。

8.6.6 WeakHashMap实现类

WeakHashMap与HashMap的用法基本相似。与HashMap的区别在于,HashMap的key保留了对实际对象的强引用,这意味着只要该HashMap对象不被销毁,该HashMap的所有key所引用的对象就不会被垃圾回收,HashMap也不会自动删除这些key所对应的key-value对;但WeakHashMap的key只保留了对实际对象的弱引用,这意味着如果WeakHashMap对象的key所引用的对象没有被其他强引用变量所引用,则这些key所引用的对象可能被垃圾回收,WeakHashMap也可能自动删除这些key所对应的key-value对。WeakHashMap中的每个key对象只持有对实际对象的弱引用,因此,当垃圾回收了该key所对应的实际对象之后,WeakHashMap会自动删除该key对应的key-value对。

8.6.7 IdentityHashMap实现类

在IdentityHashMap中,当且仅当两个key严格相等(key1==key2)时,IdentityHashMap才认为两个key相等;对于普通的HashMap而言,只要key1和key2通过equals()方法比较返回true,且它们的hashCode值相等即可。

IdentityHashMap提供了与HashMap基本相似的方法,也允许使用null作为key和value。与HashMap相似:IdentityHashMap也不保证key-value对之间的顺序,更不能保证它们的顺序随时间的推移保持不变。

8.6.8 EnumMap实现类

EnumMap是一个与枚举类一起使用的Map实现,EnumMap中的所有key都必须是单个枚举类的枚举值。创建EnumMap时必须显式或隐式指定它对应的枚举类。

EnumMap具有如下特征。

➢ EnumMap在内部以数组形式保存,所以这种实现形式非常紧凑、高效。

➢ EnumMap根据key的自然顺序(即枚举值在枚举类中的定义顺序)来维护key-value对的顺序。当程序通过keySet()、entrySet()、values()等方法遍历EnumMap时可以看到这种顺序。

➢ EnumMap不允许使用null作为key,但允许使用null作为value。如果试图使用null作为key时将抛出NullPointerException异常。如果只是查询是否包含值为null的key,或只是删除值为null的key,都不会抛出异常。与创建普通的Map有所区别的是,创建EnumMap时必须指定一个枚举类,从而将该EnumMap和指定枚举类关联起来。

8.6.9 各Map实现类的性能分析

对于Map的常用实现类而言,虽然HashMap和Hashtable的实现机制几乎一样,但由于Hashtable是一个古老的、线程安全的集合,因此HashMap通常比Hashtable要快。

TreeMap通常比HashMap、Hashtable要慢(尤其在插入、删除key-value对时更慢),因为TreeMap底层采用红黑树来管理key-value对(红黑树的每个节点就是一个key-value对)。使用TreeMap有一个好处:TreeMap中的key-value对总是处于有序状态,无须专门进行排序操作。当TreeMap被填充之后,就可以调用keySet(),取得由key组成的Set,然后使用toArray()方法生成key的数组,接下来使用Arrays的binarySearch()方法在已排序的数组中快速地查询对象。对于一般的应用场景,程序应该多考虑使用HashMap,因为HashMap正是为快速查询设计的(HashMap底层其实也是采用数组来存储key-value对)。但如果程序需要一个总是排好序的Map时,则可以考虑使用TreeMap。

LinkedHashMap比HashMap慢一点,因为它需要维护链表来保持Map中key-value时的添加顺序。

IdentityHashMap性能没有特别出色之处,因为它采用与HashMap基本相似的实现,只是它使用==而不是equals()方法来判断元素相等。

EnumMap的性能最好,但它只能使用同一个枚举类的枚举值作为key。

8.7 HashSet和HashMap的性能选项

对于HashSet及其子类而言,它们采用hash算法来决定集合中元素的存储位置,并通过hash算法来控制集合的大小;对于HashMap、Hashtable及其子类而言,它们采用hash算法来决定Map中key的存储,并通过hash算法来增加key集合的大小。

hash表里可以存储元素的位置被称为“桶(bucket)”,在通常情况下,单个“桶”里存储一个元素,此时有最好的性能:hash算法可以根据hashCode值计算出“桶”的存储位置,接着从“桶”中取出元素。但hash表的状态是open的:在发生“hash冲突”的情况下,单个桶会存储多个元素,这些元素以链表形式存储,必须按顺序搜索。如图8.8所示是hash表保存各元素,且发生“hash冲突”的示意图。

《疯狂Java讲义》(第5版) 李刚_第26张图片

因为HashSet和HashMap、Hashtable都使用hash算法来决定其元素(HashMap则只考虑key)的存储,因此HashSet、HashMap的hash表包含如下属性。

➢ 容量(capacity):hash表中桶的数量。

➢ 初始化容量(initial capacity):创建hash表时桶的数量。HashMap和HashSet都允许在构造器中指定初始化容量。

➢ 尺寸(size):当前hash表中记录的数量。

➢ 负载因子(load factor):负载因子等于“size/capacity”。负载因子为0,表示空的hash表,0.5表示半满的hash表,依此类推。轻负载的hash表具有冲突少、适宜插入与查询的特点(但是使用Iterator迭代元素时比较慢)。除此之外,hash表里还有一个“负载极限”,“负载极限”是一个0~1的数值,“负载极限”决定了hash表的最大填满程度。当hash表中的负载因子达到指定的“负载极限”时,hash表会自动成倍地增加容量(桶的数量),并将原有的对象重新分配,放入新的桶内,这称为rehashing。

HashSet和HashMap、Hashtable的构造器允许指定一个负载极限,HashSet和HashMap、Hashtable默认的“负载极限”为0.75,这表明当该hash表的3/4已经被填满时,hash表会发生rehashing。

“负载极限”的默认值(0.75)是时间和空间成本上的一种折中:较高的“负载极限”可以降低hash表所占用的内存空间,但会增加查询数据的时间开销,而查询是最频繁的操作(HashMap的get()与put()方法都要用到查询);较低的“负载极限”会提高查询数据的性能,但会增加hash表所占用的内存开销。

程序员可以根据实际情况来调整HashSet和HashMap的“负载极限”值。如果开始就知道HashSet和HashMap、Hashtable会保存很多记录,则可以在创建时就使用较大的初始化容量,如果初始化容量始终大于HashSet和HashMap、Hashtable所包含的最大记录数除以“负载极限”,就不会发生rehashing。使用足够大的初始化容量创建HashSet和HashMap、Hashtable时,可以更高效地增加记录,但将初始化容量设置太高可能会浪费空间,因此通常不要将初始化容量设置得过高。

8.8 操作集合的工具类:Collections

Java提供了一个操作Set、List和Map等集合的工具类:Collections,该工具类里提供了大量方法对集合元素进行排序、查询和修改等操作,还提供了将集合对象设置为不可变、对集合对象实现同步控制等方法。

8.8.1 排序操作Collections

提供了如下常用的类方法用于对List集合元素进行排序。

➢ void reverse(List list):反转指定List集合中元素的顺序。

➢ void shuffle(List list):对List集合元素进行随机排序(shuffle方法模拟了“洗牌”动作)。

➢ void sort(List list):根据元素的自然顺序对指定List集合的元素按升序进行排序。

➢ void sort(List list, Comparator c):根据指定Comparator产生的顺序对List集合元素进行排序。

➢ void swap(List list, int i, int j):将指定List集合中的i处元素和j处元素进行交换。

➢ void rotate(List list, int distance):当distance为正数时,将list集合的后distance个元素“整体”移到前面;当distance为负数时,将list集合的前distance个元素“整体”移到后面。该方法不会改变集合的长度。

8.8.2 查找、替换操作

Collections还提供了如下常用的用于查找、替换集合元素的类方法。

➢ int binarySearch(List list, Object key):使用二分搜索法搜索指定的List集合,以获得指定对象在List集合中的索引。如果要使该方法可以正常工作,则必须保证List中的元素已经处于有序状态。

➢ Object max(Collection coll):根据元素的自然顺序,返回给定集合中的最大元素。

➢ Object max(Collection coll, Comparator comp):根据Comparator指定的顺序,返回给定集合中的最大元素。

➢ Object min(Collection coll):根据元素的自然顺序,返回给定集合中的最小元素。

➢ Object min(Collection coll, Comparator comp):根据Comparator指定的顺序,返回给定集合中的最小元素。

➢ void fill(List list, Object obj):使用指定元素obj替换指定List集合中的所有元素。

➢ int frequency(Collection c, Object o):返回指定集合中指定元素的出现次数。

➢ int indexOfSubList(List source, List target):返回子List对象在父List对象中第一次出现的位置索引;如果父List中没有出现这样的子List,则返回-1。

➢ int lastIndexOfSubList(List source, List target):返回子List对象在父List对象中最后一次出现的位置索引;如果父List中没有出现这样的子List,则返回-1。

➢ boolean replaceAll(List list, Object oldVal, Object newVal):使用一个新值newVal替换List对象的所有旧值oldVal。

8.8.3 同步控制

Collections类中提供了多个synchronizedXxx()方法,该方法可以将指定集合包装成线程同步的集合,从而可以解决多线程并发访问集合时的线程安全问题。Java中常用的集合框架中的实现类HashSet、TreeSet、ArrayList、ArrayDeque、LinkedList、HashMap和TreeMap都是线程不安全的。如果有多个线程访问它们,而且有超过一个的线程试图修改它们,则存在线程安全的问题。Collections提供了多个类方法可以把它们包装成线程同步的集合。

8.8.4 设置不可变集合

Collections提供了如下三类方法来返回一个不可变的集合。

➢ emptyXxx():返回一个空的、不可变的集合对象,此处的集合既可以是List,也可以是SortedSet、Set,还可以是Map、SortedMap等。

➢ singletonXxx():返回一个只包含指定对象(只有一个或一项元素)的、不可变的集合对象,此处的集合既可以是List,还可以是Map。

➢ unmodifiableXxx():返回指定集合对象的不可变视图,此处的集合既可以是List,也可以是Set、SortedSet,还可以是Map、SorteMap等。上面三类方法的参数是原有的集合对象,返回值是该集合的“只读”版本。通过Collections提供的三类方法,可以生成“只读”的Collection或Map。

8.8.5 Java 9新增的不可变集合

Java 9终于增加这个功能了——以前假如要创建一个包含6个元素的Set集合,程序需要先创建Set集合,然后调用6次add()方法向Set集合中添加元素。Java 9对此进行了简化,程序直接调用Set、List、Map的of()方法即可创建包含N个元素的不可变集合,这样一行代码就可创建包含N个元素的集合。不可变意味着程序不能向集合中添加元素,也不能从集合中删除元素。

8.9 烦琐的接口:Enumeration

Enumeration接口是Iterator迭代器的“古老版本”,从JDK 1.0开始,Enumeration接口就已经存在了(Iterator从JDK 1.2才出现)。

Enumeration接口只有两个名字很长的方法。

➢ boolean hasMoreElements():如果此迭代器还有剩下的元素,则返回true。

➢ Object nextElement():返回该迭代器的下一个元素,如果还有的话(否则抛出异常)。通过这两个方法不难发现,Enumeration接口中的方法名称冗长,难以记忆,而且没有提供Iterator的remove()方法。

如果现在编写Java程序,应该尽量采用Iterator迭代器,而不是用Enumeration迭代器。

第9章 泛型

9.1 泛型入门

从Java 5以后,Java引入了“参数化类型(parameterized type)”的概念,允许程序在创建集合时指定集合元素的类型,如List,这表明该List只能保存字符串类型的对象。Java的参数化类型被称为泛型(Generic)

在Java 7以前,如果使用带泛型的接口、类定义变量,那么调用构造器创建对象时构造器的后面也必须带泛型了。例如如下两条语句:

从Java 7开始,Java允许在构造器后不需要带完整的泛型信息,只要给出一对尖括号(<>)即可,Java可以推断尖括号里应该是什么泛型信息。即上面两条语句可以改写为如下形式:

把两个尖括号并排放在一起非常像一个菱形,这种语法也就被称为“菱形”语法

需要说明的是,当使用var声明变量时,编译器无法推断泛型的类型。因此,若使用var声明变量,程序无法使用“菱形”语法。

Java 9再次增强了“菱形”语法,它甚至允许在创建匿名内部类时使用菱形语法,Java可根据上下文来推断匿名内部类中泛型的类型。

9.2 深入泛型

所谓泛型,就是允许在定义类、接口、方法时使用类型形参,这个类型形参(或叫泛型)将在声明变量、创建对象、调用方法时动态地指定(即传入实际的类型参数,也可称为类型实参)。

Java 5改写了集合框架中的全部接口和类,为这些接口、类增加了泛型支持,从而可以在声明集合变量、创建集合对象时传入类型实参,这就是在前面程序中看到的List和ArrayList两种类型。

9.2.1 定义泛型接口、类

《疯狂Java讲义》(第5版) 李刚_第27张图片

注意:当创建带泛型声明的自定义类,为该类定义构造器时,构造器名还是原来的类名,不要增加泛型声明。例如,为Apple类定义构造器,其构造器名依然是Apple,而不是Apple!调用该构造器时却可以使用Apple的形式,当然应该为T形参传入实际的类型参数。Java 7提供了“菱形”语法,允许省略<>中的类型实参。

9.2.2 从泛型类派生子类

当创建了带泛型声明的接口、父类之后,可以为该接口创建实现类,或从该父类派生子类,需要指出的是,当使用这些接口、父类时不能再包含泛型形参。例如,下面代码就是错误的:

如果想从Apple类派生一个子类,则可以改为如下代码:

像这种使用Apple类时省略泛型的形式被称为原始类型(raw type)。

9.2.3 并不存在泛型类

看下面代码的打印结果是什么?

《疯狂Java讲义》(第5版) 李刚_第28张图片

运行上面的代码片段,可能有读者认为应该输出false,但实际输出true。因为不管泛型的实际类型参数是什么,它们在运行时总有同样的类(class)。

不管为泛型形参传入哪一种类型实参,对于Java来说,它们依然被当成同一个类处理,在内存中也只占用一块内存空间,因此在静态方法、静态初始化块或者静态变量(它们都是类相关的)的声明和初始化中不允许使用泛型形参。

由于系统中并不会真正生成泛型类,所以instanceof运算符后不能使用泛型类。例如,下面代码是错误的。

9.3 类型通配符

问号(?)被称为通配符,它的元素类型可以匹配任何类型。

《疯狂Java讲义》(第5版) 李刚_第29张图片

现在使用任何类型的List来调用它,程序依然可以访问集合c中的元素,其类型是Object,这永远是安全的,因为不管List的真实类型是什么,它包含的都是Object。

但这种带通配符的List仅表示它是各种泛型List的父类,并不能把元素加入到其中。因为程序无法确定c集合中元素的类型,所以不能向其中添加对象。例如,如下代码将会引起编译错误。

9.3.2 设定类型通配符的上限

当直接使用List这种形式时,即表明这个List集合可以是任何泛型List的父类。但还有一种特殊的情形,程序不希望这个List是任何泛型List的父类,只希望它代表某一类泛型List的父类。

为了表示List集合的所有元素是Shape的子类,Java泛型提供了被限制的泛型通配符被限制的泛型通配符表示如下

List是受限制通配符的例子,此处的问号(?)代表一个未知的类型,就像前面看到的通配符一样。但是此处的这个未知类型一定是Shape的子类型(也可以是Shape本身),因此可以把Shape称为这个通配符的上限(upper bound)。

使用普通通配符相似的是,shapes.add()的第二个参数类型是?extends Shape,它表示Shape未知的子类,程序无法确定这个类型是什么,所以无法将任何对象添加到这种集合中。简而言之,这种指定通配符上限的集合,只能从集合中取元素(取出的元素总是上限的类型或其子类),不能向集合中添加元素(因为编译器没法确定集合元素实际是哪种子类型)。

对于更广泛的泛型类来说,指定通配符上限就是为了支持类型型变。比如Foo是Bar的子类,这样A就相当于A的子类,可以将A赋值给A类型的变量,这种型变方式被称为协变

对于协变的泛型而言,它只能调用泛型类型作为返回值类型的方法(编译器会将该方法返回值当成通配符上限的类型);而不能调用泛型类型作为参数的方法。口诀是:协变只出不进!

9.3.3 设定类型通配符的下限

除可以指定通配符的上限之外,Java也允许指定通配符的下限通配符的下限用的方式来指定,通配符下限的作用与通配符上限的作用恰好相反。指定通配符的下限就是为了支持类型型变。

比如Foo是Bar的子类,当程序需要一个A变量时,程序可以将A、A赋值给A类型的变量,这种型变方式被称为逆变。

对于逆变的泛型集合来说,编译器只知道集合元素是下限的父类型,但具体是哪种父类型则不确定。因此,这种逆变的泛型集合能向其中添加元素(因为实际赋值的集合元素总是逆变声明的父类),从集合中取元素时只能被当成Object类型处理(编译器无法确定取出的到底是哪个父类的对象)。对于逆变的泛型而言,它只能调用泛型类型作为参数的方法;而不能调用泛型类型作为返回值类型的方法。口诀是:逆变只进不出!

假设自己实现一个工具方法:实现将src集合中的元素复制到dest集合的功能,因为dest集合可以保存src集合中的所有元素,所以dest集合元素的类型应该是src集合元素类型的父类。

9.3.4 设定泛型形参的上限

Java泛型不仅允许在使用通配符形参时设定上限,而且可以在定义泛型形参时设定上限,用于表示传给该泛型形参的实际类型要么是该上限类型,要么是该上限类型的子类。

《疯狂Java讲义》(第5版) 李刚_第30张图片

在一种更极端的情况下,程序需要为泛型形参设定多个上限(至多有一个父类上限,可以有多个接口上限),表明该泛型形参必须是其父类的子类(是父类本身也行),并且实现多个上限接口。如下代码所示。

与类同时继承父类、实现接口类似的是,为泛型形参指定多个上限时,所有的接口上限必须位于类上限之后。也就是说,如果需要为泛型形参指定类上限,类上限必须位于第一位。

9.4 泛型方法

前面介绍了在定义类、接口时可以使用泛型形参,在该类的方法定义和成员变量定义、接口的方法定义中,这些泛型形参可被当成普通类型来用。在另外一些情况下,定义类、接口时没有使用泛型形参,但定义方法时想自己定义泛型形参,这也是可以的,Java 5还提供了对泛型方法的支持。

9.4.1 定义泛型方法

泛型方法,就是在声明方法时定义一个或多个泛型形参。泛型方法的语法格式如下:

《疯狂Java讲义》(第5版) 李刚_第31张图片

9.4.2 泛型方法和类型通配符的区别

泛型方法允许泛型形参被用来表示方法的一个或多个参数之间的类型依赖关系,或者方法返回值与参数之间的类型依赖关系。如果没有这样的类型依赖关系,就不应该使用泛型方法。

类型通配符与泛型方法(在方法签名中显式声明泛型形参)还有一个显著的区别:类型通配符既可以在方法签名中定义形参的类型,也可以用于定义变量的类型;但泛型方法中的泛型形参必须在对应方法中显式声明。

9.4.3 “菱形”语法与泛型构造器

正如泛型方法允许在方法签名中声明泛型形参一样,Java也允许在构造器签名中声明泛型形参,这样就产生了所谓的泛型构造器。

一旦定义了泛型构造器,接下来在调用构造器时,就不仅可以让Java根据数据参数的类型来“推断”泛型形参的类型,而且程序员也可以显式地为构造器中的泛型形参指定实际的类型。

《疯狂Java讲义》(第5版) 李刚_第32张图片

前面介绍过Java 7新增的“菱形”语法,它允许调用构造器时在构造器后使用一对尖括号来代表泛型信息。但如果程序显式指定了泛型构造器中声明的泛型形参的实际类型,则不可以使用“菱形”语法。如下程序所示。

《疯狂Java讲义》(第5版) 李刚_第33张图片

9.4.4 泛型方法与方法重载

9.4.5 类型推断

Java 8改进了泛型方法的类型推断能力,类型推断主要有如下两方面。➢ 可通过调用方法的上下文来推断泛型的目标类型。➢ 可在方法调用链中,将推断得到的泛型传递到最后一个方法。

9.5 擦除和转换

在严格的泛型代码里,带泛型声明的类总应该带着类型参数。但为了与老的Java代码保持一致,也允许在使用带泛型声明的类时不指定实际的类型。如果没有为这个泛型类指定实际的类型,此时被称作raw type(原始类型),默认是声明该泛型形参时指定的第一个上限类型。

当把一个具有泛型信息的对象赋给另一个没有泛型信息的变量时,所有在尖括号之间的类型信息都将被扔掉。比如一个List类型被转换为List,则该List对集合元素的类型检查变成了泛型参数的上限(即Object)。

9.6 泛型与数组

Java泛型有一个很重要的设计原则—如果一段代码在编译时没有提出“[unchecked] 未经检查的转换”警告,则程序在运行时不会引发ClassCastException异常。正是基于这个原因,所以数组元素的类型不能包含泛型变量或泛型形参,除非是无上限的类型通配符。但可以声明元素类型包含泛型变量或泛型形参的数组。也就是说,只能声明List[]形式的数组,但不能创建ArrayList[10]这样的数组对象。

第10章 异常处理

Java的异常机制主要依赖于try、catch、finally、throw和throws五个关键字,其中try关键字后紧跟一个花括号扩起来的代码块(花括号不可省略),简称try块,它里面放置可能引发异常的代码。catch后对应异常类型和一个代码块,用于表明该catch块用于处理这种类型的代码块。多个catch块后还可以跟一个finally块,finally块用于回收在try块里打开的物理资源,异常机制会保证finally块总被执行。throws关键字主要在方法签名中使用,用于声明该方法可能抛出的异常;而throw用于抛出一个实际的异常,throw可以单独作为语句使用,抛出一个具体的异常对象。

Java 7进一步增强了异常处理机制的功能,包括带资源的try语句、捕获多异常的catch两个新功能,这两个功能可以极好地简化异常处理。

Java将异常分为两种,Checked异常Runtime异常,Java认为Checked异常都是可以在编译阶段被处理的异常,所以它强制程序处理所有的Checked异常;而Runtime异常则无须处理。

10.1 异常概述

10.2 异常处理机制

10.2.1 使用try...catch捕获异常

Java异常处理机制的语法结构:

《疯狂Java讲义》(第5版) 李刚_第34张图片

如果执行try块里的业务逻辑代码时出现异常,系统自动生成一个异常对象,该异常对象被提交给Java运行时环境,这个过程被称为抛出(throw)异常。

当Java运行时环境收到异常对象时,会寻找能处理该异常对象的catch块,如果找到合适的catch块,则把该异常对象交给该catch块处理,这个过程被称为捕获(catch)异常;如果Java运行时环境找不到捕获异常的catch块,则运行时环境终止,Java程序也将退出。

10.2.2 异常类的继承体系

Java提供了丰富的异常类,这些异常类之间有严格的继承关系,图10.2显示了Java常见的异常类之间的继承关系,Java把所有的非正常情况分成两种:异常(Exception)和错误(Error),它们都继承Throwable父类。

《疯狂Java讲义》(第5版) 李刚_第35张图片

Error错误,一般是指与虚拟机相关的问题,如系统崩溃、虚拟机错误、动态链接失败等,这种错误无法恢复或不可能捕获,将导致应用程序中断。通常应用程序无法处理这些错误,因此应用程序不应该试图使用catch块来捕获Error对象。在定义该方法时,也无须在其throws子句中声明该方法可能抛出Error及其任何子类。

注意:捕获异常时,一定要记住先捕获小异常,再捕获大异常。

10.2.3 多异常捕获

Java 7开始,一个catch块可以捕获多种类型的异常。使用一个catch块捕获多种类型的异常时需要注意如下两个地方。

➢ 捕获多种类型的异常时,多种异常类型之间用竖线(|)隔开。

➢ 捕获多种类型的异常时,异常变量有隐式的final修饰,因此程序不能对异常变量重新赋值。

10.2.4 访问异常信息

如果程序需要在catch块中访问异常对象的相关信息,则可以通过访问catch块的后异常形参来获得。

所有的异常对象都包含了如下几个常用方法。

➢ getMessage():返回该异常的详细描述字符串。

➢ printStackTrace():将该异常的跟踪栈信息输出到标准错误输出。

➢ printStackTrace(PrintStream s):将该异常的跟踪栈信息输出到指定输出流。

➢ getStackTrace():返回该异常的跟踪栈信息。

10.2.5 使用finally回收资源

有些时候,程序在try块里打开了一些物理资源(例如数据库连接、网络连接和磁盘文件等),这些物理资源都必须显式回收。

提示:Java的垃圾回收机制不会回收任何物理资源,垃圾回收机制只能回收堆内存中对象所占用的内存。

为了保证一定能回收try块中打开的物理资源,异常处理机制提供了finally块。不管try块中的代码是否出现异常,也不管哪一个catch块被执行,甚至在try块或catch块中执行了return语句,finally块总会被执行。完整的Java异常处理语法结构如下:

《疯狂Java讲义》(第5版) 李刚_第36张图片

注意:除非在try块、catch块中调用了退出虚拟机的方法,否则不管在try块、catch块中执行怎样的代码,出现怎样的情况,异常处理的finally块总会被执行。

在通常情况下,不要在finally块中使用如return或throw等导致方法终止的语句,(throw语句将在后面介绍),一旦在finally块中使用了return或throw语句,将会导致try块、catch块中的return、throw语句失效。

 10.2.6 异常处理的嵌套

10.2.7 Java 9增强的自动关闭资源的try语句

Java 7增强了try语句的功能——它允许在try关键字后紧跟一对圆括号,圆括号可以声明、初始化一个或多个资源,此处的资源指的是那些必须在程序结束时显式关闭的资源(比如数据库连接、网络连接等),try语句在该语句结束时自动关闭这些资源。

需要指出的是,为了保证try语句可以正常关闭资源,这些资源实现类必须实现AutoCloseable或Closeable接口,实现这两个接口就必须实现close()方法。

提示:Java 7几乎把所有的“资源类”(包括文件IO的各种类、JDBC编程的Connection、Statement等接口)进行了改写,改写后资源类都实现了AutoCloseable或Closeable接口。

Java 9再次增强了这种try语句,Java 9不要求在try后的圆括号内声明并创建资源,只需要自动关闭的资源有final修饰或者是有效的final(effectively final),Java 9允许将资源变量放在try后的圆括号内。

《疯狂Java讲义》(第5版) 李刚_第37张图片

10.3 Checked异常和Runtime异常体系

Java的异常被分为两大类:Checked异常和Runtime异常(运行时异常)。所有的RuntimeException类及其子类的实例被称为Runtime异常;不是RuntimeException类及其子类的异常实例则被称为Checked异常。

Java认为Checked异常都是可以被处理(修复)的异常,所以Java程序必须显式处理Checked异常。

对于Checked异常的处理方式有如下两种。

➢ 当前方法明确知道如何处理该异常,程序应该使用try...catch块来捕获该异常,然后在对应的catch块中修复该异常

➢ 当前方法不知道如何处理这种异常,应该在定义该方法时声明抛出该异常。

Runtime异常则更加灵活,Runtime异常无须显式声明抛出,如果程序需要捕获Runtime异常,也可以使用try...catch块来实现。

10.3.1 使用throws声明抛出异常

throws声明抛出只能在方法签名中使用,throws可以声明抛出多个异常类,多个异常类之间以逗号隔开。throws声明抛出的语法格式如下:

10.3.2 方法重写时声明抛出异常的限制

使用throws声明抛出异常时有一个限制,就是方法重写时“两小”中的一条规则:子类方法声明抛出的异常类型应该是父类方法声明抛出的异常类型的子类或相同,子类方法声明抛出的异常不允许比父类方法声明抛出的异常多。

10.4 使用throw抛出异常

当程序出现错误时,系统会自动抛出异常;除此之外,Java也允许程序自行抛出异常,自行抛出异常使用throw语句来完成。

10.4.1 抛出异常

如果需要在程序中自行抛出异常,则应使用throw语句,throw语句可以单独使用,throw语句抛出的不是异常类,而是一个异常实例,而且每次只能抛出一个异常实例。throw语句的语法格式如下:

10.4.2 自定义异常类

用户自定义异常都应该继承Exception基类,如果希望自定义Runtime异常,则应该继承RuntimeException基类。定义异常类时通常需要提供两个构造器:一个是无参数的构造器;另一个是带一个字符串参数的构造器,这个字符串将作为该异常对象的描述信息(也就是异常对象的getMessage()方法的返回值)。

10.4.3 catch和throw同时使用

10.4.4 使用throw语句抛出异常

10.4.5 异常链

10.5 Java的异常跟踪栈

异常对象的printStackTrace()方法用于打印异常的跟踪栈信息,根据printStackTrace()方法的输出结果,开发者可以找到异常的源头,并跟踪到异常一路触发的过程。

10.6 异常处理规则

不要过度使用异常;  不要使用过于庞大的try块;  避免使用Catch All语句;  不要忽略捕获到的异常

第11章 AWT编程

第12章 Swing编程

第13章 MySQL数据库与JDBC编程

13.1 JDBC基础

JDBC的全称是Java Database Connectivity,即Java数据库连接,它是一种可以执行SQL语句的Java API。

程序可通过JDBC API连接到关系数据库,并使用结构化查询语言(SQL,数据库标准的查询语言)来完成对数据库的查询、更新。与其他数据库编程环境相比,JDBC为数据库开发提供了标准的API,所以使用JDBC开发的数据库应用可以跨平台运行,而且可以跨数据库(如果全部使用标准的SQL)。

13.1.1 JDBC简介

为了使JDBC程序可以跨平台,则需要不同的数据库厂商提供相应的驱动程序。图13.1显示了JDBC驱动示意图。

《疯狂Java讲义》(第5版) 李刚_第38张图片

正是通过JDBC驱动的转换,才使得使用相同JDBC API编写的程序,在不同的数据库系统上运行良好。Sun提供的JDBC可以完成以下三个基本工作。

➢ 建立与数据库的连接。

➢ 执行SQL语句。

➢ 获得SQL语句的执行结果。

通过JDBC的这三个功能,应用程序即可访问、操作数据库系统。

13.1.2 JDBC驱动程序

数据库驱动程序是JDBC程序和数据库之间的转换层,数据库驱动程序负责将JDBC调用映射成特定的数据库调用。图13.2显示了JDBC示意图。

《疯狂Java讲义》(第5版) 李刚_第39张图片

JDBC驱动通常有如下4种类型。

➢ 第1种JDBC驱动:称为JDBC-ODBC桥,这种驱动是最早实现的JDBC驱动程序,主要目的是为了快速推广JDBC。这种驱动将JDBC API映射到ODBC API。这种方式在Java 8中已经被删除了。

➢ 第2种JDBC驱动:直接将JDBC API映射成数据库特定的客户端API。这种驱动包含特定数据库的本地代码,用于访问特定数据库的客户端。

➢ 第3种JDBC驱动:支持三层结构的JDBC访问方式,主要用于Applet阶段,通过Applet访问数据库。

➢ 第4种JDBC驱动:是纯Java的,直接与数据库实例交互。这种驱动是智能的,它知道数据库使用的底层协议。这种驱动是目前最流行的JDBC驱动。

13.2 SQL语法

SQL语句是对所有关系数据库都通用的命令语句,而JDBC API只是执行SQL语句的工具,JDBC允许对不同的平台、不同的数据库采用相同的编程接口来执行SQL语句。

13.2.1 安装数据库

13.2.2 关系数据库基本概念和MySQL基本命令

数据库(Database)仅仅是存放用户数据的地方。当用户访问、操作数据库中的数据时,就需要数据库管理系统的帮助。数据库管理系统的全称是Database Management System,简称DBMS。习惯上常常把数据库和数据库管理系统笼统地称为数据库,通常所说的数据库既包括存储用户数据的部分,也包括管理数据库的管理系统。DBMS是所有数据的知识库,它负责管理数据的存储、安全、一致性、并发、恢复和访问等操作。DBMS有一个数据字典(有时也被称为系统表),用于存储它拥有的每个事务的相关信息,例如名字、结构、位置和类型,这种关于数据的数据也被称为元数据(metadata)。在数据库发展历史中,按时间顺序主要出现了如下几种类型的数据库系统。➢ 网状型数据库 层次型数据库关系数据库 面向对象数据库

查看数据库: 

创建数据库:

删除数据库:

进如指定数据库:

查看数据库中数据表:

查看数据表结构:

启动MySQL命令行客户端:

13.2.3 SQL语句基础

SQL的全称是Structured Query Language,也就是结构化查询语言。SQL是操作和检索关系数据库的标准语言,标准的SQL语句可用于操作任何关系数据库。

标准的SQL语句通常可分为如下几种类型。

➢ 查询语句:主要由select关键字完成,查询语句是SQL语句中最复杂、功能最丰富的语句。

➢ DML(Data Manipulation Language,数据操作语言)语句:主要由insert、update和delete三个关键字完成。

➢ DDL(Data Definition Language,数据定义语言)语句:主要由create、alter、drop和truncate四个关键字完成。

➢ DCL(Data Control Language,数据控制语言)语句:主要由grant和revoke两个关键字完成。

➢ 事务控制语句:主要由commit、rollback和savepoint三个关键字完成。

SQL语句的关键字不区分大小写,也就是说,create和CREATE的作用完全一样。

在SQL命令中也可能需要使用标识符,标识符可用于定义表名、列名,也可用于定义变量等。这些标识符的命名规则如下。➢ 标识符通常必须以字母开头。➢ 标识符包括字母、数字和三个特殊字符(#_$)。➢ 不要使用当前数据库系统的关键字、保留字,通常建议使用多个单词连缀而成,单词之间以_分隔。➢ 同一个模式下的对象不应该同名,这里的模式指的是外模式。

13.2.4 DDL语句

DDL语句是操作数据库对象的语句,包括创建(create)、删除(drop)和修改(alter)数据库对象。

《疯狂Java讲义》(第5版) 李刚_第40张图片

1.创建表的语法

《疯狂Java讲义》(第5版) 李刚_第41张图片

《疯狂Java讲义》(第5版) 李刚_第42张图片

2.修改表结构的语法

修改表结构包括增加列定义修改列定义删除列重命名列等操作。

增加列定义的语法:

《疯狂Java讲义》(第5版) 李刚_第43张图片

修改列定义的语法:

删除列的语法:

重命名数据表名的语法:

改变数据列名称:

3.删除表的语法

删除表的语法格式如下:

删除数据表的效果如下:

➢ 表结构被删除,表对象不再存在。

➢ 表里的所有数据也被删除。

➢ 该表所有相关的索引、约束也被删除。

4.truncate表

truncate被称为“截断”某个表—它的作用是删除该表里的全部数据,但保留表结构。相对于DML里的delete命令而言,truncate的速度要快得多,而且truncate不像delete可以删除指定的记录,truncate只能一次性删除整个表的全部记录。

truncate命令的语法如下:

MySQL对truncate的处理比较特殊—如果使用非InnoDB存储机制,truncate比delete速度要快;如果使用InnoDB存储机制,在MySQL 5.0.3之前,truncate和delete完全一样,在5.0.3之后,truncate table比delete效率高,但如果该表被外键约束所参照,truncate又变为delete操作。在5.0.13之后,快速truncate总是可用,即比delete性能要好。

13.2.5 数据库约束

约束是在表上强制执行的数据校验规则,约束主要用于保证数据库里数据的完整性。

大部分数据库支持下面5种完整性约束:

➢ NOT NULL:非空约束,指定某列不能为空。

➢ UNIQUE:唯一约束,指定某列或者几列组合不能重复。

➢ PRIMARY KEY:主键,指定该列的值可以唯一地标识该条记录。

➢ FOREIGN KEY:外键,指定该行记录从属于主表中的一条记录,主要用于保证参照完整性。

➢ CHECK:检查,指定一个布尔表达式,用于指定对应列的值必须满足该表达式。(MySql不支持,使用了也没效果)

虽然约束的作用只是用于保证数据表里数据的完整性,但约束也是数据库对象,并被存储在系统表中,也拥有自己的名字。根据约束对数据列的限制,约束分为如下两类。➢ 单列约束:每个约束只约束一列。➢ 多列约束:每个约束可以约束多个数据列。

1.NOT NULL约束

非空约束用于确保指定列不允许为空,它只能作为列级约束使用,只能使用列级约束语法定义。

       《疯狂Java讲义》(第5版) 李刚_第44张图片

2.UNIQUE约束

唯一约束用于保证指定列或指定列组合不允许出现重复值。当为某列创建唯一约束时,MySQL会为该列相应地创建唯一索引。如果不给唯一约束起名,该唯一约束默认与列名相同。

使用列级约束语法建立唯一约束非常简单,只要简单地在列定义后增加unique关键字即可。SQL语句如下:

如果需要为多列组合建立唯一约束,或者想自行指定约束名,则需要使用表级约束语法。表级约束语法格式如下:

3.PRIMARY KEY约束

主键约束相当于非空约束和唯一约束,如果对多列组合建立主键约束,则多列里包含的每一列都不能为空,但只要求这些列组合不能重复。主键列的值可用于唯一地标识表中的一条记录。每一个表中最多允许有一个主键,但这个主键约束可由多个数据列组合而成,主键是表中能唯一确定一行记录的字段或字段组合。

建立主键约束时既可使用列级约束语法,也可使用表级约束语法。如果需要对多个字段建立组合主键约束,则只能使用表级约束语法。使用表级约束语法来建立约束时,可以为该约束指定约束名。但不管用户是否为该主键约束指定约束名,MySQL总是将所有的主键约束命名为PRIMARY。

建表时创建主键约束,使用列级约束语法:

《疯狂Java讲义》(第5版) 李刚_第45张图片

建表时创建主键约束,使用表级约束语法:

《疯狂Java讲义》(第5版) 李刚_第46张图片

如果需要删除指定表的主键约束,则在alter table语句后使用drop primary key子句即可。SQL语句如下:

如果需要为指定表增加主键约束,既可通过modify修改列定义来增加主键约束,这将采用列级约束语法来增加主键约束;也可通过add来增加主键约束,这将采用表级约束语法来增加主键约束。SQL语句如下:

如果只是为单独的数据列增加主键约束,则可使用modify修改列定义来实现。SQL语句如下:

4.FOREIGN KEY约束

外键约束主要用于保证一个或两个数据表之间的参照完整性,外键是构建于一个表的两个字段或者两个表的两个字段之间的参照关系。外键确保了相关的两个字段的参照关系:子(从)表外键列的值必须在主表被参照列的值范围之内,或者为空(也可以通过非空约束来约束外键列不允许为空)。

采用列级约束语法建立外键约束直接使用references关键字,references指定该列参照哪个主表,以及参照主表的哪一列。SQL语句如下:

《疯狂Java讲义》(第5版) 李刚_第47张图片

如果要使MySQL中的外键约束生效,则应使用表级约束语法。

《疯狂Java讲义》(第5版) 李刚_第48张图片

5.CHECK约束

当前版本的MySQL支持建表时指定CHECK约束,但这个CHECK约束不会有任何作用。建立CHECK约束的语法很简单,只要在建表的列定义后增加check(逻辑表达式)即可。

13.2.6 索引

创建索引的唯一作用就是加速对表的查询,索引通过使用快速路径访问方法来快速定位数据,从而减少了磁盘的I/O。

创建索引有两种方式。

➢ 自动:当在表上定义主键约束唯一约束外键约束时,系统会为该数据列自动创建对应的索引。

➢ 手动:用户可以通过create index...语句来创建索引。

删除索引也有两种方式。

➢ 自动:数据表被删除时,该表上的索引自动被删除。

➢ 手动:用户可以通过drop index...语句来删除指定数据表上的指定索引。

创建索引的语法格式如下:

MySQL中删除索引需要指定表,采用如下语法格式:

索引的好处是可以加速查询,但索引也有如下两个坏处

➢ 当数据表中的记录被添加、删除、修改时,数据库系统需要维护索引,因此有一定的系统开销。

➢ 存储索引信息需要一定的磁盘空间。

13.2.7 视图

视图看上去非常像一个数据表,但它不是数据表,因为它并不能存储数据。

视图只是一个或多个数据表中数据的逻辑显示

使用视图有如下几个好处:

➢ 可以限制对数据的访问。

➢ 可以使复杂的查询变得简单。

➢ 提供了数据的独立性。

➢ 提供了对相同数据的不同显示。

创建视图的语法如下:

为了强制不允许改变视图的数据,MySQL允许在创建视图时使用with check option子句,使用该子句创建的视图不允许修改。

删除视图使用如下语句:

13.2.8 DML语句语法

与DDL操作数据库对象不同,DML主要操作数据表里的数据,使用DML可以完成如下三个任务。

➢ 插入新数据。➢ 修改已有数据。➢ 删除不需要的数据。

DML语句由insert into、update和delete from三个命令组成。

1.insert into语句

2.update语句

3.delete from语句

13.2.9 单表查询

单表查询的select语句的语法格式如下:

《疯狂Java讲义》(第5版) 李刚_第49张图片

上面语法格式中的数据源可以是表、视图等。

13.2.10 数据库函数

根据函数对多行数据的处理方式,函数被分为单行函数和多行函数,单行函数对每行输入值单独计算,每行得到一个计算结果返回给用户;多行函数对多行输入值整体计算,最后只会得到一个结果。

13.2.11 分组和组函数

组函数也就是前面提到的多行函数,组函数将一组记录作为整体计算,每组记录返回一个结果,而不是每条记录返回一个结果。常用的组函数有如下5个:

➢ avg([distinct|all]expr):计算多行expr的平均值,其中,expr可以是变量、常量或数据列,但其数据类型必须是数值型。还可以在变量、列前使用distinct或all关键字,如果使用distinct,则表明不计算重复值;all用和不用的效果完全一样,表明需要计算重复值。

➢ count({*|[distinct|all]expr}):计算多行expr的总条数,其中expr可以是变量、常量或数据列,其数据类型可以是任意类型;用星号(*)表示统计该表内的记录行数;distinct表示不计算重复值。

➢ max(expr):计算多行expr的最大值,其中expr可以是变量、常量或数据列,其数据类型可以是任意类型。

➢ min(expr):计算多行expr的最小值,其中expr可以是变量、常量或数据列,其数据类型可以是任意类型。

➢ sum([distinct|all]expr):计算多行expr的总和,其中expr可以是变量、常量或数据列,但其数据类型必须是数值型;distinct表示不计算重复值。

13.2.12 多表连接查询

13.3 JDBC的典型用法

13.3.1 JDBC 4.2常用接口和类简介

Java支持JDBC 4.2标准,JDBC 4.2在原有JDBC标准上增加了一些新特性。下面介绍这些JDBC API时会提到Java 8新增的功能。

➢ DriverManager:用于管理JDBC驱动的服务类。程序中使用该类的主要功能是获取Connection对象,该类包含如下方法。

• public static synchronized Connection getConnection(String url, String user,String pass) throws SQLException:该方法获得url对应数据库的连接。

➢ Connection:代表数据库连接对象,每个Connection代表一个物理连接会话。要想访问数据库,必须先获得数据库连接。该接口的常用方法如下。

• Statement createStatement() throws SQLExcetpion:该方法返回一个Statement对象。

• PreparedStatement prepareStatement(String sql) throws SQLExcetpion:该方法返回预编译的Statement对象,即将SQL语句提交到数据库进行预编译。

• CallableStatement prepareCall(String sql) throws SQLExcetpion:该方法返回CallableStatement对象,该对象用于调用存储过程。

Connection还有如下几个用于控制事务的方法:

➢ Savepoint setSavepoint():创建一个保存点。

➢ Savepoint setSavepoint(String name):以指定名字来创建一个保存点。

➢ void setTransactionIsolation(int level):设置事务的隔离级别。

➢ void rollback():回滚事务。

➢ void rollback(Savepoint savepoint):将事务回滚到指定的保存点。

➢ void setAutoCommit(boolean autoCommit):关闭自动提交,打开事务。

➢ void commit():提交事务。

Java 7为Connection新增了setSchema(String schema)、getSchema()两个方法,这两个方法用于控制该Connection访问的数据库Schema。Java 7还为Connection新增了setNetworkTimeout(Executor executor, int milliseconds)、getNetworkTimeout()两个方法来控制数据库连接的超时行为。

➢ Statement:用于执行SQL语句的工具接口。该对象既可用于执行DDL、DCL语句,也可用于执行DML语句,还可用于执行SQL查询。当执行SQL查询时,返回查询到的结果集。它的常用方法如下:

• ResultSet executeQuery(String sql) throws SQLException:该方法用于执行查询语句,并返回查询结果对应的ResultSet对象。该方法只能用于执行查询语句。

• int executeUpdate(String sql) throws SQLExcetion:该方法用于执行DML语句,并返回受影响的行数;该方法也可用于执行DDL语句,执行DDL语句将返回0。

• boolean execute(String sql) throws SQLException:该方法可执行任何SQL语句。如果执行后第一个结果为ResultSet对象,则返回true;如果执行后第一个结果为受影响的行数或没有任何结果,则返回false。

Java 7为Statement新增了closeOnCompletion()方法,如果Statement执行了该方法,则当所有依赖于该Statement的ResultSet关闭时,该Statement会自动关闭。Java 7还为Statement提供了一个isCloseOnCompletion()方法,该方法用于判断该Statement是否打开了“closeOnCompletion”。

Java 8为Statement新增了多个重载的executeLargeUpdate()方法,这些方法相当于增强版的executeUpdate()方法,返回值类型为long—也就是说,当DML语句影响的记录条数超过Integer.MAX_VALUE时,就应该使用executeLargeUpdate()方法。

➢ PreparedStatement:预编译的Statement对象。PreparedStatement是Statement的子接口,它允许数据库预编译SQL语句(这些SQL语句通常带有参数),以后每次只改变SQL命令的参数,避免数据库每次都需要编译SQL语句,因此性能更好。相对于Statement而言,使用PreparedStatement执行SQL语句时,无须再传入SQL语句,只要为预编译的SQL语句传入参数值即可。所以它比Statement多了如下方法。

• void setXxx(int parameterIndex,Xxx value):该方法根据传入参数值的类型不同,需要使用不同的方法。传入的值根据索引传给SQL语句中指定位置的参数。

➢ ResultSet:结果集对象。该对象包含访问查询结果的方法,ResultSet可以通过列索引或列名获得列数据。它包含了如下常用方法来移动记录指针。• void close():释放ResultSet对象。• boolean absolute(int row):将结果集的记录指针移动到第row行,如果row是负数,则移动到倒数第row行。如果移动后的记录指针指向一条有效记录,则该方法返回true。

• void beforeFirst():将ResultSet的记录指针定位到首行之前,这是ResultSet结果集记录指针的初始状态—记录指针的起始位置位于第一行之前。

• boolean first():将ResultSet的记录指针定位到首行。如果移动后的记录指针指向一条有效记录,则该方法返回true。

• boolean previous():将ResultSet的记录指针定位到上一行。如果移动后的记录指针指向一条有效记录,则该方法返回true。

• boolean next():将ResultSet的记录指针定位到下一行,如果移动后的记录指针指向一条有效记录,则该方法返回true。

• boolean last():将ResultSet的记录指针定位到最后一行,如果移动后的记录指针指向一条有效记录,则该方法返回true。

• void afterLast():将ResultSet的记录指针定位到最后一行之后。

13.3.2 JDBC编程步骤

JDBC编程大致按如下步骤进行:

① 加载数据库驱动。通常使用Class类的forName()静态方法来加载驱动。例如如下代码:

② 通过DriverManager获取数据库连接。DriverManager提供了如下方法:

③ 通过Connection对象创建Statement对象。

Connection创建Statement的方法有如下三个。

➢ createStatement():创建基本的Statement对象。

➢ prepareStatement(String sql):根据传入的SQL语句创建预编译的Statement对象。

➢ prepareCall(String sql):根据传入的SQL语句创建CallableStatement对象。

④ 使用Statement执行SQL语句。

所有的Statement都有如下三个方法来执行SQL语句。

➢ execute():可以执行任何SQL语句,但比较麻烦。

➢ executeUpdate():主要用于执行DML和DDL语句。执行DML语句返回受SQL语句影响的行数,执行DDL语句返回0。

➢ executeQuery():只能执行查询语句,执行后返回代表查询结果的ResultSet对象。

⑤ 操作结果集。

如果执行的SQL语句是查询语句,则执行结果将返回一个ResultSet对象,该对象里保存了SQL语句查询的结果。程序可以通过操作该ResultSet对象来取出查询结果。ResultSet对象主要提供了如下两类方法。

➢ next()、previous()、first()、last()、beforeFirst()、afterLast()、absolute()等移动记录指针的方法。

➢ getXxx()方法获取记录指针指向行、特定列的值。该方法既可使用列索引作为参数,也可使用列名作为参数。

⑥ 回收数据库资源,包括关闭ResultSet、Statement和Connection等资源。

13.4 执行SQL语句的方式

第14章 注解(Annotation)

从JDK 5开始,Java增加了对元数据(MetaData)的支持,也就是Annotation(即注解,偶尔也被翻译为注释)。

注意:注解是一个接口,程序可以通过反射来获取指定程序元素的java.lang.annotation.Annotation对象,然后通过java.lang.annotation.Annotation对象来取得注解里的元数据。

14.1 基本注解

注解必须使用工具来处理,工具负责提取注解里包含的元数据,工具还会根据这些元数据增加额外的功能。

Java提供的5个基本注解的用法——使用注解时要在其前面增加@符号,并把该注解当成一个修饰符使用,用于修饰它支持的程序元素。5个基本的注解如下:

➢ @Override➢ @Deprecated➢ @SuppressWarnings➢ @SafeVarargs➢ @FunctionalInterface

上面5个基本注解中的@SafeVarargs是Java 7新增的、@FunctionalInterface是Java 8新增的。这5个基本的注解都定义在java.lang包下。

14.1.1 限定重写父类方法:@Override

@Override就是用来指定方法覆载的,它可以强制一个子类必须覆盖父类的方法。

@Override的作用是告诉编译器检查这个方法,保证父类要包含一个被该方法重写的方法,否则就会编译出错。

14.1.2 Java 9增强的@Deprecated

@Deprecated用于表示某个程序元素(类、方法等)已过时,当其他程序使用已过时的类、方法时,编译器将会给出警告。

Java 9为@Deprecated注解增加了如下两个属性。

➢ forRemoval:该boolean类型的属性指定该API在将来是否会被删除。

➢ since:该String类型的属性指定该API从哪个版本被标记为过时。

14.1.3 抑制编译器警告:@SuppressWarnings

@SuppressWarnings指示被该注解修饰的程序元素(以及该程序元素中的所有子元素)取消显示指定的编译器警告。

14.1.4 “堆污染”警告与Java 9增强的@SafeVarargs

《疯狂Java讲义》(第5版) 李刚_第50张图片

Java把引发这种错误的原因称为“堆污染”(Heap pollution),当把一个不带泛型的对象赋给一个带泛型的变量时,往往就会发生这种“堆污染”,如上①号粗体字代码所示。

对于形参个数可变的方法,该形参的类型又是泛型,这将更容易导致“堆污染”。

但在有些时候,开发者不希望看到这个警告,则可以使用如下三种方式来“抑制”这个警告。

➢ 使用@SafeVarargs修饰引发该警告的方法或构造器。Java 9增强了该注解,允许使用该注解修饰私有实例方法。

➢ 使用@SuppressWarnings("unchecked")修饰。

➢ 编译时使用-Xlint:varargs选项。

14.1.5 函数式接口与@FunctionalInterface

从Java 8开始:如果接口中只有一个抽象方法(可以包含多个默认方法或多个static方法),该接口就是函数式接口。@FunctionalInterface就是用来指定某个接口必须是函数式接口。

14.2 JDK的元注解

JDK除在java.lang下提供了5个基本的注解之外,还在java.lang.annotation包下提供了6个Meta注解(元注解),其中有5个元注解都用于修饰其他的注解定义。

14.2.1 使用@Retention

@Retention只能用于修饰注解定义,用于指定被修饰的注解可以保留多长时间。

@Retention包含一个RetentionPolicy类型的value成员变量,所以使用@Retention时必须为该value成员变量指定值。value成员变量的值只能是如下三个。

➢ RetentionPolicy.CLASS:编译器将把注解记录在class文件中。当运行Java程序时,JVM不可获取注解信息。这是默认值。

➢ RetentionPolicy.RUNTIME:编译器将把注解记录在class文件中。当运行Java程序时,JVM也可获取注解信息,程序可以通过反射获取该注解信息。

➢ RetentionPolicy.SOURCE:注解只保留在源代码中,编译器直接丢弃这种注解。

14.2.2 使用@Target

@Target也只能修饰注解定义,它用于指定被修饰的注解能用于修饰哪些程序单元。

@Target元注解也包含一个名为value的成员变量,该成员变量的值只能是如下几个。

➢ ElementType.ANNOTATION_TYPE:指定该策略的注解只能修饰注解。

➢ ElementType.CONSTRUCTOR:指定该策略的注解只能修饰构造器。

➢ ElementType.FIELD:指定该策略的注解只能修饰成员变量。

➢ ElementType.LOCAL_VARIABLE:指定该策略的注解只能修饰局部变量。

➢ ElementType.METHOD:指定该策略的注解只能修饰方法定义。

➢ ElementType.PACKAGE:指定该策略的注解只能修饰包定义。

➢ ElementType.PARAMETER:指定该策略的注解可以修饰参数。

➢ ElementType.TYPE:指定该策略的注解可以修饰类、接口(包括注解类型)或枚举定义。

14.2.3 使用@Documented

@Documented用于指定被该元注解修饰的注解类将被javadoc工具提取成文档,如果定义注解类时使用了@Documented修饰,则所有使用该注解修饰的程序元素的API文档中将会包含该注解说明。

14.2.4 使用@Inherited

@Inherited元注解指定被它修饰的注解将具有继承性——如果某个类使用了@Xxx注解(定义该注解时使用了@Inherited修饰)修饰,则其子类将自动被@Xxx修饰。

14.3 自定义注解

14.3.1 定义注解

定义新的注解类型使用@interface关键字(在原有的interface关键字前增加@符号)定义一个新的注解类型与定义一个接口非常像,如下代码可定义一个简单的注解类型。

注解不仅可以是这种简单的注解,还可以带成员变量,成员变量在注解定义中以无形参的方法形式来声明,其方法名和返回值定义了该成员变量的名字和类型。如下代码可以定义一个有成员变量的注解。

《疯狂Java讲义》(第5版) 李刚_第51张图片

《疯狂Java讲义》(第5版) 李刚_第52张图片

根据注解是否可以包含成员变量,可以把注解分为如下两类。

➢ 标记注解:没有定义成员变量的注解类型被称为标记。这种注解仅利用自身的存在与否来提供信息,如前面介绍的@Override、@Test等注解。

➢ 元数据注解:包含成员变量的注解,因为它们可以接受更多的元数据,所以也被称为元数据注解。

14.3.2 提取注解信息

使用注解修饰了类、方法、成员变量等成员之后,这些注解不会自己生效,必须由开发者提供相应的工具来提取并处理注解信息。

Java使用java.lang.annotation.Annotation接口来代表程序元素前面的注解,该接口是所有注解的父接口。Java 5在java.lang.reflect包下新增了AnnotatedElement接口,该接口代表程序中可以接受注解的程序元素。该接口主要有如下几个实现类。

➢ Class:类定义。

➢ Constructor:构造器定义。

➢ Field:类的成员变量定义。

➢ Method:类的方法定义。

➢ Package:类的包定义。

java.lang.reflect包下主要包含一些实现反射功能的工具类,从Java 5开始,java.lang.reflect包所提供的反射API增加了读取运行时注解的能力。只有当定义注解时使用了@Retention(RetentionPolicy.RUNTIME)修饰,该注解才会在运行时可见,JVM才会在装载*.class文件时读取保存在class文件中的注解信息。

AnnotatedElement接口是所有程序元素(如Class、Method、Constructor等)的父接口,所以程序通过反射获取了某个类的AnnotatedElement对象(如Class、Method、Constructor等)之后,程序就可以调用该对象的如下几个方法来访问注解信息。

A getAnnotation(Class annotationClass):返回该程序元素上存在的、指定类型的注解,如果该类型的注解不存在,则返回null。

A getDeclaredAnnotation(Class annotationClass):这是Java 8新增的方法,该方法尝试获取直接修饰该程序元素、指定类型的注解。如果该类型的注解不存在,则返回null。

➢ Annotation[] getAnnotations():返回该程序元素上存在的所有注解。

➢ Annotation[] getDeclaredAnnotations():返回直接修饰该程序元素的所有注解。

➢ boolean isAnnotationPresent(Class< ?extends Annotation> annotationClass):判断该程序元素上是否存在指定类型的注解,如果存在则返回true,否则返回false。

A[] getAnnotationsByType(Class annotationClass):该方法的功能与前面介绍的getAnnotation()方法基本相似。但由于Java 8增加了重复注解功能,因此需要使用该方法获取修饰该程序元素、指定类型的多个注解。

A[] getDeclaredAnnotationsByType(ClassannotationClass):该方法的功能与前面介绍的getDeclaredAnnotations()方法基本相似。但由于Java 8增加了重复注解功能,因此需要使用该方法获取直接修饰该程序元素、指定类型的多个注解。

14.3.3 使用注解的示例

14.3.4 重复注解

Java 8允许使用多个相同类型的注解来修饰同一个类。

14.3.5 类型注解

Java 8为ElementType枚举增加了TYPE_PARAMETER、TYPE_USE两个枚举值,这样就允许定义注解时使用@Target(ElementType.TYPE_USE)修饰,这种注解被称为类型注解(TypeAnnotation),类型注解可用于修饰在任何地方出现的类型。

在Java 8以前,只能在定义各种程序元素(定义类、定义接口、定义方法、定义成员变量……)时使用注解。从Java 8开始,类型注解可以修饰在任何地方出现的类型。比如,允许在如下位置使用类型注解。➢ 创建对象(用new关键字创建)。➢ 类型转换。➢ 使用implements实现接口。➢ 使用throws声明抛出异常。上面这些情形都会用到类型,因此都可以使用类型注解来修饰。

14.4 编译时处理注解

APT(Annotation Processing Tool)是一种注解处理工具,它对源代码文件进行检测,并找出源文件所包含的注解信息,然后针对注解信息进行额外的处理。

第15章 输入/输出

15.1 File类

File类是java.io包下代表与平台无关的文件和目录,值得指出的是,不管是文件还是目录都是使用File来操作的,File能新建、删除、重命名文件和目录,File不能访问文件内容本身。如果需要访问文件内容本身,则需要使用输入/输出流。

15.1.1 访问文件和目录

File类提供了很多方法来操作文件和目录:

1.访问文件名相关方法

➢ String getName():返回此File对象所表示的文件名或路径名(如果是路径,则返回最后一级子路径名)。

➢ String getPath():返回此File对象所对应的路径名。

➢ File getAbsoluteFile():返回此File对象的绝对路径。

➢ String getAbsolutePath():返回此File对象所对应的绝对路径名。

➢ String getParent():返回此File对象所对应目录(最后一级子目录)的父目录名。

➢ boolean renameTo(File newName):重命名此File对象所对应的文件或目录,如果重命名成功,则返回true;否则返回false。

2.文件检测相关方法

➢ boolean exists():判断File对象所对应的文件或目录是否存在。

➢ boolean canWrite():判断File对象所对应的文件和目录是否可写。

➢ boolean canRead():判断File对象所对应的文件和目录是否可读。

➢ boolean isFile():判断File对象所对应的是否是文件,而不是目录。

➢ boolean isDirectory():判断File对象所对应的是否是目录,而不是文件。

➢ boolean isAbsolute():判断File对象所对应的文件或目录是否是绝对路径。

3.获取常规文件信息

➢ long lastModified():返回文件的最后修改时间。

➢ long length():返回文件内容的长度。

4.文件操作相关方法

➢ boolean createNewFile():当此File对象所对应的文件不存在时,该方法将新建一个该File对象所指定的新文件,如果创建成功则返回true;否则返回false。

➢ boolean delete():删除File对象所对应的文件或路径。

➢ static File createTempFile(String prefix, String suffix):在默认的临时文件目录中创建一个临时的空文件,使用给定前缀、系统生成的随机数和给定后缀作为文件名

➢ static File createTempFile(String prefix, String suffix, File directory):在directory所指定的目录中创建一个临时的空文件,使用给定前缀、系统生成的随机数和给定后缀作为文件名。

➢ void deleteOnExit():注册一个删除钩子,指定当Java虚拟机退出时,删除File对象所对应的文件和目录。

5.目录操作相关方法

➢ boolean mkdir():试图创建一个File对象所对应的目录,如果创建成功,则返回true;否则返回false。

➢ String[] list():列出File对象的所有子文件名和路径名,返回String数组。

➢ File[] listFiles():列出File对象的所有子文件和路径,返回File数组。

➢ static File[] listRoots():列出系统所有的根路径。这是一个静态方法,可以直接通过File类来调用。

15.1.2 文件过滤器

在File类的list()方法中可以接收一个FilenameFilter参数,通过该参数可以只列出符合条件的文件。

FilenameFilter接口里包含了一个accept(File dir, String name)方法,该方法将依次对指定File的所有子目录或者文件进行迭代,如果该方法返回true,则list()方法会列出该子目录或者文件。

15.2 理解Java的IO流

15.2.1 流的分类

按照不同的分类方式:

1.输入流和输出流按照流的流向来分,可以分为输入流和输出流。

➢ 输入流:只能从中读取数据,而不能向其写入数据。

➢ 输出流:只能向其写入数据,而不能从中读取数据。

数据从内存到硬盘,通常称为输出流——也就是说,这里的输入、输出都是从程序运行所在内存的角度来划分的。

Java的输入流主要由InputStream和Reader作为基类,而输出流则主要由OutputStream和Writer作为基类。它们都是一些抽象基类,无法直接创建实例。

2.字节流和字符流

3.节点流和处理流按照流的角色来分,可以分为节点流和处理流。

15.2.2 流的概念模型

Java的IO流的40多个类都是从如下4个抽象基类派生的。

InputStream/Reader所有输入流的基类,前者是字节输入流,后者是字符输入流。

OutputStream/Writer:所有输出流的基类,前者是字节输出流,后者是字符输出流。

15.3 字节流和字符流

15.3.1 InputStream和Reader

InputStream和Reader是所有输入流的抽象基类:

在InputStream里包含如下三个方法。

➢ int read():从输入流中读取单个字节,返回所读取的字节数据(字节数据可直接转换为int类型)。

➢ int read(byte[] b):从输入流中最多读取b.length个字节的数据,并将其存储在字节数组b中,返回实际读取的字节数。

➢ int read(byte[] b, int off, int len):从输入流中最多读取len个字节的数据,并将其存储在数组b中,放入数组b中时,并不是从数组起点开始,而是从off位置开始,返回实际读取的字节数。

在Reader里包含如下三个方法。

➢ int read():从输入流中读取单个字符,返回所读取的字符数据(字符数据可直接转换为int类型)。

➢ int read(char[] cbuf):从输入流中最多读取cbuf.length个字符数据,并将其存在字符数组cbuf中,返回实际读取的字符数。

➢ int read(char[] cbuf, int off, int len):从输入流中最多读取len个字符的数据,并将其存储在字符数组cbuf中,放入数组cbuf中时,并不是从数组起点开始,而是从off位置开始,返回实际读取的字符数。

InputStream和Reader还支持如下几个方法来移动记录指针。

➢ void mark(int readAheadLimit):在记录指针当前位置记录一个标记(mark)。

➢ boolean markSupported():判断此输入流是否支持mark()操作,即是否支持记录标记。

➢ void reset():将此流的记录指针重新定位到上一次记录标记(mark)的位置。

➢ long skip(long n):记录指针向前移动n个字节/字符。

15.3.2 OutputStream和Writer

OutputStream和Writer都提供了如下三个方法。

➢ void write(int c):将指定的字节/字符输出到输出流中,其中c既可以代表字节,也可以代表字符。

➢ void write(byte[]/char[] buf):将字节数组/字符数组中的数据输出到指定输出流中。

➢ void write(byte[]/char[] buf, int off, int len):将字节数组/字符数组中从off位置开始,长度为len的字节/字符输出到输出流中。

因为字符流直接以字符作为操作单位,Writer里还包含如下两个方法。

➢ void write(String str):将str字符串里包含的字符输出到指定输出流中。

➢ void write(String str, int off, int len):将str字符串里从off位置开始,长度为len的字符输出到指定输出流中。

15.4 输入/输出流体系

15.4.1 处理流的用法

处理流的功能,它可以隐藏底层设备上节点流的差异,并对外提供更加方便的输入/输出方法。使用处理流时的典型思路是,使用处理流来包装节点流,程序通过处理流来执行输入/输出功能,让节点流与底层的I/O设备、文件交互。实际识别处理流非常简单,只要流的构造器参数不是一个物理节点,而是已经存在的流,那么这种流就一定是处理流;而所有节点流都是直接以物理IO节点作为构造器参数的。

15.4.2 输入/输出流体系

Java的输入/输出流体系提供了近40个类,表15.1显示了Java输入/输出流体系中常用的流分类。

《疯狂Java讲义》(第5版) 李刚_第53张图片

注:表15.1中的粗体字标出的类代表节点流,必须直接与指定的物理节点关联;斜体字标出的类代表抽象基类,无法直接创建实例。

15.4.3 转换流

输入/输出流体系中还提供了两个转换流,这两个转换流用于实现将字节流转换成字符流,其中InputStreamReader将字节输入流转换成字符输入流,OutputStreamWriter将字节输出流转换成字符输出流。

15.4.4 推回输入流

在输入/输出流体系中,有两个特殊的流与众不同,就是PushbackInputStream和PushbackReader,它们都提供了如下三个方法。

➢ void unread(byte[]/char[] buf):将一个字节/字符数组内容推回到推回缓冲区里,从而允许重复读取刚刚读取的内容。

➢ void unread(byte[]/char[] b, int off, int len):将一个字节/字符数组里从off开始,长度为len字节/字符的内容推回到推回缓冲区里,从而允许重复读取刚刚读取的内容。

➢ void unread(int b):将一个字节/字符推回到推回缓冲区里,从而允许重复读取刚刚读取的内容。

细心的读者可能已经发现了这三个方法与InputStream和Reader中的三个read()方法一一对应,这两个推回输入流都带有一个推回缓冲区,当程序调用这两个推回输入流的unread()方法时,系统将会把指定数组的内容推回到该缓冲区里,而推回输入流每次调用read()方法时总是先从推回缓冲区读取,只有完全读取了推回缓冲区的内容后,但还没有装满read()所需的数组时才会从原输入流中读取。

当程序创建一个PushbackInputStream和PushbackReader时需要指定推回缓冲区的大小,默认的推回缓冲区的长度为1。如果程序中推回到推回缓冲区的内容超出了推回缓冲区的大小,将会引发Pushback buffer overflow的IOException异常。

15.5 重定向标准输入/输出

在System类里提供了如下三个重定向标准输入/输出的方法。

➢ static void setErr(PrintStream err):重定向 “标准”错误输出流。

➢ static void setIn(InputStream in):重定向“标准”输入流。

➢ static void setOut(PrintStream out):重定向 “标准”输出流。

15.6 Java虚拟机读写其他进程的数据

使用Runtime对象的exec()方法可以运行平台上的其他程序,该方法产生一个Process对象,Process对象代表由该Java程序启动的子进程。

Process类提供了如下三个方法,用于让程序和其子进程进行通信。

➢ InputStream getErrorStream():获取子进程的错误流。

➢ InputStream getInputStream():获取子进程的输入流。

➢ OutputStream getOutputStream():获取子进程的输出流。

15.7 RandomAccessFile

RandomAccessFile是Java输入/输出流体系中功能最丰富的文件内容访问类,支持“随机访问”的方式,程序可以直接跳转到文件的任意地方来读写数据。

RandomAccessFile的方法虽然多,但它有一个最大的局限,就是只能读写文件,不能读写其他IO节点。

RandomAccessFile对象也包含了一个记录指针,用以标识当前读写处的位置,当程序新创建一个RandomAccessFile对象时,该对象的文件记录指针位于文件头(也就是0处),当读/写了n个字节后,文件记录指针将会向后移动n个字节。除此之外,RandomAccessFile可以自由移动该记录指针,既可以向前移动,也可以向后移动。

RandomAccessFile包含了如下两个方法来操作文件记录指针。

➢ long getFilePointer():返回文件记录指针的当前位置。

➢ void seek(long pos):将文件记录指针定位到pos位置。

RandomAccessFile既可以读文件,也可以写,所以它既包含了完全类似于InputStream的三个read()方法,其用法和InputStream的三个read()方法完全一样;也包含了完全类似于OutputStream的三个write()方法,其用法和OutputStream的三个write()方法完全一样。

除此之外,RandomAccessFile还包含了一系列的readXxx()和writeXxx()方法来完成输入、输出。

RandomAccessFile类有两个构造器,其实这两个构造器基本相同,只是指定文件的形式不同而已—一个使用String参数来指定文件名,一个使用File参数来指定文件本身。

除此之外,创建RandomAccessFile对象时还需要指定一个mode参数,该参数指定RandomAccessFile的访问模式,该参数有如下4个值。

➢ "r":以只读方式打开指定文件。如果试图对该RandomAccessFile执行写入方法,都将抛出IOException异常。

➢ "rw":以读、写方式打开指定文件。如果该文件尚不存在,则尝试创建该文件。

➢ "rws":以读、写方式打开指定文件。相对于"rw"模式,还要求对文件的内容或元数据的每个更新都同步写入到底层存储设备。

➢ "rwd":以读、写方式打开指定文件。相对于"rw"模式,还要求对文件内容的每个更新都同步写入到底层存储设备。

15.8 Java 9改进的对象序列化

对象序列化的目标是将对象保存到磁盘中,或允许在网络中直接传输对象。对象序列化机制允许把内存中的Java对象转换成平台无关的二进制流,从而允许把这种二进制流持久地保存在磁盘上,通过网络将这种二进制流传输到另一个网络节点。其他程序一旦获得了这种二进制流(无论是从磁盘中获取的,还是通过网络获取的),都可以将这种二进制流恢复成原来的Java对象。

15.8.1 序列化的含义和意义

序列化机制允许将实现序列化的Java对象转换成字节序列,这些字节序列可以保存在磁盘上,或通过网络传输,以备以后重新恢复成原来的对象。

序列化机制使得对象可以脱离程序的运行而独立存在。

对象的序列化(Serialize)指将一个Java对象写入IO流中,与此对应的是,对象的反序列化(Deserialize)则指从IO流中恢复该Java对象。

Java 9增强了对象序列化机制,它允许对读入的序列化数据进行过滤,这种过滤可在反序列化之前对数据执行校验,从而提高安全性和健壮性。

为了让某个类是可序列化的,该类必须实现如下两个接口之一。

➢ Serializable

➢ ExternalizableJava的很多类已经实现了Serializable,该接口是一个标记接口,实现该接口无须实现任何方法,它只是表明该类的实例是可序列化的。

通常建议:程序创建的每个JavaBean类都实现Serializable。

15.8.2 使用对象流实现序列化

使用Serializable来实现序列化非常简单,主要让目标类实现Serializable标记接口即可,无须实现任何方法。一旦某个类实现了Serializable接口,该类的对象就是可序列化的,程序可以通过如下两个步骤来序列化该对象。

① 创建一个ObjectOutputStream,这个输出流是一个处理流,所以必须建立在其他节点流的基础之上。代码如下:

② 调用ObjectOutputStream对象的writeObject()方法输出可序列化对象。代码如下:

如果希望从二进制流中恢复Java对象,则需要使用反序列化。反序列化的步骤如下。

① 创建一个ObjectInputStream输入流,这个输入流是一个处理流,所以必须建立在其他节点流的基础之上。代码如下:

② 调用ObjectInputStream对象的readObject()方法读取流中的对象,该方法返回一个Object类型的Java对象,如果程序知道该Java对象的类型,则可以将该对象强制类型转换成其真实的类型。代码如下:

15.8.3 对象引用的序列化

如果某个类的成员变量的类型不是基本类型或String类型,而是另一个引用类型,那么这个引用类必须是可序列化的,否则拥有该类型成员变量的类也是不可序列化的。

Java序列化机制采用了一种特殊的序列化算法,其算法内容如下。

➢ 所有保存到磁盘中的对象都有一个序列化编号。

➢ 当程序试图序列化一个对象时,程序将先检查该对象是否已经被序列化过,只有该对象从未(在本次虚拟机中)被序列化过,系统才会将该对象转换成字节序列并输出。

➢ 如果某个对象已经序列化过,程序将只是直接输出一个序列化编号,而不是再次重新序列化该对象。

15.8.4 Java 9增加的过滤功能

Java 9为ObjectInputStream增加了setObjectInputFilter()、getObjectInputFilter()两个方法,其中第一个方法用于为对象输入流设置过滤器。当程序通过ObjectInputStream反序列化对象时,过滤器的checkInput()方法会被自动激发,用于检查序列化数据是否有效。

使用checkInput()方法检查序列化数据时有3种返回值。

➢ Status.REJECTED:拒绝恢复。

➢ Status.ALLOWED:允许恢复。

➢ Status.UNDECIDED:未决定状态,程序继续执行检查。

ObjectInputStream将会根据ObjectInputFilter的检查结果来决定是否执行反序列化,如果checkInput()方法返回Status.REJECTED,反序列化将会被阻止;如果checkInput()方法返回Status.ALLOWED,程序将可执行反序列化。

15.8.5 自定义序列化

在一些特殊的场景下,如果一个类里包含的某些实例变量是敏感信息,例如银行账户信息等,这时不希望系统将该实例变量值进行序列化;或者某个实例变量的类型是不可序列化的,因此不希望对该实例变量进行递归序列化,以避免引发java.io.NotSerializableException异常。

提示:当对某个对象进行序列化时,系统会自动把该对象的所有实例变量依次进行序列化,如果某个实例变量引用到另一个对象,则被引用的对象也会被序列化;如果被引用的对象的实例变量也引用了其他对象,则被引用的对象也会被序列化,这种情况被称为递归序列化。

通过在实例变量前面使用transient(只能修饰实例变量)关键字修饰,可以指定Java序列化时无须理会该实例变量。

在序列化和反序列化过程中需要特殊处理的类应该提供如下特殊签名的方法,这些特殊的方法用以实现自定义序列化。

➢ private void writeObject(java.io.ObjectOutputStream out) throws IOException

➢ private void readObject(java.io.ObjectInputStream in) throwsIOException,ClassNotFoundException;

➢ private void readObjectNoData() throws ObjectStreamException;

15.8.6 另一种自定义序列化机制

Java还提供了另一种序列化机制,这种序列化方式完全由程序员决定存储和恢复对象数据。要实现该目标,Java类必须实现Externalizable接口,该接口里定义了如下两个方法。

➢ void readExternal(ObjectInput in):需要序列化的类实现readExternal()方法来实现反序列化。该方法调用DataInput(它是ObjectInput的父接口)的方法来恢复基本类型的实例变量值,调用ObjectInput的readObject()方法来恢复引用类型的实例变量值。

➢ void writeExternal(ObjectOutput out):需要序列化的类实现writeExternal()方法来保存对象的状态。该方法调用DataOutput(它是ObjectOutput的父接口)的方法来保存基本类型的实例变量值,调用ObjectOutput的writeObject()方法来保存引用类型的实例变量值。

实际上,采用实现Externalizable接口方式的序列化与前面介绍的自定义序列化非常相似,只是Externalizable接口强制自定义序列化。

关于对象序列化,还有如下几点需要注意。

➢ 对象的类名、实例变量(包括基本类型、数组、对其他对象的引用)都会被序列化;方法、类变量(即static修饰的成员变量)、transient实例变量(也被称为瞬态实例变量)都不会被序列化。

➢ 实现Serializable接口的类如果需要让某个实例变量不被序列化,则可在该实例变量前加transient修饰符,而不是加static关键字。虽然static关键字也可达到这个效果,但static关键字不能这样用。

➢ 保证序列化对象的实例变量类型也是可序列化的,否则需要使用transient关键字来修饰该实例变量,要不然,该类是不可序列化的。

➢ 反序列化对象时必须有序列化对象的class文件。

➢ 当通过文件、网络来读取序列化后的对象时,必须按实际写入的顺序读取。

15.8.7 版本

Java序列化机制允许为序列化类提供一个private static final的serialVersionUID值,该类变量的值用于标识该Java类的序列化版本,也就是说,如果一个类升级后,只要它的serialVersionUID类变量值保持不变,序列化机制也会把它们当成同一个序列化版本。

可以通过JDK安装路径的bin目录下的serialver.exe工具来获得该类的serialVersionUID类变量的值。命令如下:

不显式指定serialVersionUID类变量的值的另一个坏处是,不利于程序在不同的JVM之间移植。因为不同的编译器对该类变量的计算策略可能不同,从而造成虽然类完全没有改变,但是因为JVM不同,也会出现序列化版本不兼容而无法正确反序列化的现象。如果类的修改确实会导致该类反序列化失败,则应该为该类的serialVersionUID类变量重新分配值。那么对类的哪些修改可能导致该类实例的反序列化失败呢?下面分三种情况来具体讨论。

➢ 如果修改类时仅仅修改了方法,则反序列化不受任何影响,类定义无须修改serialVersionUID类变量的值。

➢ 如果修改类时仅仅修改了静态变量或瞬态实例变量,则反序列化不受任何影响,类定义无须修改serialVersionUID类变量的值。

➢ 如果修改类时修改了非瞬态的实例变量,则可能导致序列化版本不兼容

如果对象流中的对象和新类中包含同名的实例变量,而实例变量类型不同,则反序列化失败,类定义应该更新serialVersionUID类变量的值。如果对象流中的对象比新类中包含更多的实例变量,则多出的实例变量值被忽略,序列化版本可以兼容,类定义可以不更新serialVersionUID类变量的值;如果新类比对象流中的对象包含更多的实例变量,则序列化版本也可以兼容,类定义可以不更新serialVersionUID类变量的值;但反序列化得到的新对象中多出的实例变量值都是null(引用类型实例变量)或0(基本类型实例变量)。

15.9 NIO

15.9.1 Java新IO概述

新IO采用内存映射文件的方式来处理输入/输出,新IO将文件或文件的一段区域映射到内存中,这样就可以像访问内存一样来访问文件了(这种方式模拟了操作系统上的虚拟内存的概念),通过这种方式来进行输入/输出比传统的输入/输出要快得多。

Java中与新IO相关的包如下。

➢ java.nio包:主要包含各种与Buffer相关的类。

➢ java.nio.channels包:主要包含与Channel和Selector相关的类。

➢ java.nio.charset包:主要包含与字符集相关的类。

➢ java.nio.channels.spi包:主要包含与Channel相关的服务提供者编程接口。

➢ java.nio.charset.spi包:包含与字符集相关的服务提供者编程接口。

Channel(通道)和Buffer(缓冲)是新IO中的两个核心对象,Channel是对传统的输入/输出系统的模拟,在新IO系统中所有的数据都需要通过通道传输;Channel与传统的InputStream、OutputStream最大的区别在于它提供了一个map()方法,通过该map()方法可以直接将“一块数据”映射到内存中。如果说传统的输入/输出系统是面向流的处理,则新IO则是面向块的处理。

Buffer可以被理解成一个容器,它的本质是一个数组,发送到Channel中的所有对象都必须首先放到Buffer中,而从Channel中读取的数据也必须先放到Buffer中。

除Channel和Buffer之外,新IO还提供了用于将Unicode字符串映射成字节序列以及逆映射操作的Charset类,也提供了用于支持非阻塞式输入/输出的Selector类。

15.9.2 使用Buffer

从内部结构上来看,Buffer就像一个数组,它可以保存多个类型相同的数据。Buffer是一个抽象类,其最常用的子类是ByteBuffer,它可以在底层字节数组上进行get/set操作。

除ByteBuffer之外,对应于其他基本数据类型(boolean除外)都有相应的Buffer类:CharBuffer、ShortBuffer、IntBuffer、LongBuffer、FloatBuffer、DoubleBuffer。

这些Buffer类都没有提供构造器,通过使用如下方法来得到一个Buffer对象:

➢ static XxxBuffer allocate(int capacity):创建一个容量为capacity的XxxBuffer对象。

但实际使用较多的是ByteBuffer和CharBuffer,其他Buffer子类则较少用到。其中ByteBuffer类还有一个子类:MappedByteBuffer,它用于表示Channel将磁盘文件的部分或全部内容映射到内存中后得到的结果,通常MappedByteBuffer对象由Channel的map()方法返回。

在Buffer中有三个重要的概念:容量(capacity)、界限(limit)和位置(position)。

➢ 容量(capacity):缓冲区的容量(capacity)表示该Buffer的最大数据容量,即最多可以存储多少数据。

➢ 界限(limit):第一个不应该被读出或者写入的缓冲区位置索引。也就是说,位于limit后的数据既不可被读,也不可被写。

➢ 位置(position):用于指明下一个可以被读出的或者写入的缓冲区位置索引(类似于IO流中的记录指针)。

当使用Buffer从Channel中读取数据时,position的值恰好等于已经读到了多少数据。当刚刚新建一个Buffer对象时,其position为0;如果从Channel中读取了2个数据到该Buffer中,则position为2,指向Buffer中第3个(第1个位置的索引为0)位置。除此之外,Buffer里还支持一个可选的标记(mark,类似于传统IO流中的mark),Buffer允许直接将position定位到该mark处。这些值满足如下关系:

Buffer的主要作用就是装入数据,然后输出数据(其作用类似于前面介绍的取水的“竹筒”),开始时Buffer的position为0,limit为capacity,程序可通过put()方法向Buffer中放入一些数据(或者从Channel中获取一些数据),每放入一些数据,Buffer的position相应地向后移动一些位置。当Buffer装入数据结束后,调用Buffer的flip()方法,该方法将limit设置为position所在位置,并将position设为0,这就使得Buffer的读写指针又移到了开始位置。也就是说,Buffer调用flip()方法之后,Buffer为输出数据做好准备;当Buffer输出数据结束后,Buffer调用clear()方法,clear()方法不是清空Buffer的数据,它仅仅将position置为0,将limit置为capacity,这样为再次向Buffer中装入数据做好准备。

15.9.3 使用Channel

Channel类似于传统的流对象,但与传统的流对象有两个主要区别。

➢ Channel可以直接将指定文件的部分或全部直接映射成Buffer。

➢ 程序不能直接访问Channel中的数据,包括读取、写入都不行,Channel只能与Buffer进行交互。也就是说,如果要从Channel中取得数据,必须先用Buffer从Channel中取出一些数据,然后让程序从Buffer中取出这些数据;如果要将程序中的数据写入Channel,一样先让程序将数据放入Buffer中,程序再将Buffer里的数据写入Channel中。

Java为Channel接口提供了DatagramChannel、FileChannel、Pipe.SinkChannel、Pipe.SourceChannel、SelectableChannel、ServerSocketChannel、SocketChannel等实现类,本节主要介绍FileChannel的用法。根据这些Channel的名字不难发现,新IO里的Channel是按功能来划分的,例如Pipe.SinkChannel、Pipe.SourceChannel是用于支持线程之间通信的管道Channel;ServerSocketChannel、SocketChannel是用于支持TCP网络通信的Channel;而DatagramChannel则是用于支持UDP网络通信的Channel。

所有的Channel都不应该通过构造器来直接创建,而是通过传统的节点InputStream、OutputStream的getChannel()方法来返回对应的Channel,不同的节点流获得的Channel不一样。

Channel中最常用的三类方法是map()、read()和write(),其中map()方法用于将Channel对应的部分或全部数据映射成ByteBuffer;而read()或write()方法都有一系列重载形式,这些方法用于从Buffer中读取数据或向Buffer中写入数据。

15.9.4 字符集和Charset

15.9.5 文件锁

文件锁在操作系统中是很平常的事情,如果多个运行的程序需要并发修改同一个文件时,程序之间需要某种机制来进行通信,使用文件锁可以有效地阻止多个进程并发修改同一个文件,所以现在的大部分操作系统都提供了文件锁的功能。

从JDK 1.4的NIO开始,Java开始提供文件锁的支持。

在NIO中,Java提供了FileLock来支持文件锁定功能,在FileChannel中提供的lock()/tryLock()方法可以获得文件锁FileLock对象,从而锁定文件。lock()和tryLock()方法存在区别:当lock()试图锁定某个文件时,如果无法得到文件锁,程序将一直阻塞;而tryLock()是尝试锁定文件,它将直接返回而不是阻塞,如果获得了文件锁,该方法则返回该文件锁,否则将返回null。

如果FileChannel只想锁定文件的部分内容,而不是锁定全部内容,则可以使用如下的lock()或tryLock()方法。

➢ lock(long position, long size, boolean shared):对文件从position开始,长度为size的内容加锁,该方法是阻塞式的。

➢ tryLock(long position, long size, boolean shared):非阻塞式的加锁方法。参数的作用与上一个方法类似。当参数shared为true时,表明该锁是一个共享锁,它将允许多个进程来读取该文件,但阻止其他进程获得对该文件的排他锁。当shared为false时,表明该锁是一个排他锁,它将锁住对该文件的读写。程序可以通过调用FileLock的isShared来判断它获得的锁是否为共享锁。

15.10 NIO.2的功能和用法

Java 7对原有的NIO进行了重大改进,改进主要包括如下两方面的内容。

➢ 提供了全面的文件IO和文件系统访问支持。

➢ 基于异步Channel的IO。

第一个改进表现为Java 7新增的java.nio.file包及各个子包;第二个改进表现为Java 7在java.nio.channels包下增加了多个以Asynchronous开头的Channel接口和类。Java 7把这种改进称为NIO.2,本章先详细介绍NIO的第二个改进。

15.10.1 Path、Paths和Files核心API

NIO.2引入了一个Path接口,Path接口代表一个平台无关的平台路径。

除此之外,NIO.2还提供了Files、Paths两个工具类,其中Files包含了大量静态的工具方法来操作文件;Paths则包含了两个返回Path的静态工厂方法。

15.10.2 使用FileVisitor遍历文件和目录

在以前的Java版本中,如果程序要遍历指定目录下的所有文件和子目录,则只能使用递归进行遍历,但这种方式不仅复杂,而且灵活性也不高。

有了Files工具类的帮助,现在可以用更优雅的方式来遍历文件和子目录。Files类提供了如下两个方法来遍历文件和子目录。

➢ walkFileTree(Path start, FileVisitorvisitor):遍历start路径下的所有文件和子目录。

➢ walkFileTree(Path start, Set options, int maxDepth, FileVisitorvisitor):与上一个方法的功能类似。该方法最多遍历maxDepth深度的文件。

上面两个方法都需要FileVisitor参数,FileVisitor代表一个文件访问器,walkFileTree()方法会自动遍历start路径下的所有文件和子目录,遍历文件和子目录都会“触发”FileVisitor中相应的方法。

FileVisitor中定义了如下4个方法。

➢ FileVisitResult postVisitDirectory(T dir, IOException exc):访问子目录之后触发该方法。

➢ FileVisitResult preVisitDirectory(T dir, BasicFileAttributes attrs):访问子目录之前触发该方法。

➢ FileVisitResult visitFile(T file, BasicFileAttributes attrs):访问file文件时触发该方法。

➢ FileVisitResult visitFileFailed(T file, IOException exc):访问file文件失败时触发该方法。

上面4个方法都返回一个FileVisitResult对象,它是一个枚举类,代表了访问之后的后续行为。FileVisitResult定义了如下几种后续行为。

➢ CONTINUE:代表“继续访问”的后续行为。

➢ SKIP_SIBLINGS:代表“继续访问”的后续行为,但不访问该文件或目录的兄弟文件或目录。

➢ SKIP_SUBTREE:代表“继续访问”的后续行为,但不访问该文件或目录的子目录树。

➢ TERMINATE:代表“中止访问”的后续行为。

实际编程时没必要为FileVisitor的4个方法都提供实现,可以通过继承SimpleFileVisitor(FileVisitor的实现类)来实现自己的“文件访问器”,这样就根据需要、选择性地重写指定方法了。

15.10.3 使用WatchService监控文件变化

NIO.2的Path类提供了如下一个方法来监听文件系统的变化。

➢ register(WatchService watcher, WatchEvent.Kind...events):用watcher监听该path代表的目录下的文件变化。events参数指定要监听哪些类型的事件。

在这个方法中WatchService代表一个文件系统监听服务,它负责监听path代表的目录下的文件变化。一旦使用register()方法完成注册之后,接下来就可调用WatchService的如下三个方法来获取被监听目录的文件变化事件。

➢ WatchKey poll():获取下一个WatchKey,如果没有WatchKey发生就立即返回null。

➢ WatchKey poll(long timeout, TimeUnit unit):尝试等待timeout时间去获取下一个WatchKey。

➢ WatchKey take():获取下一个WatchKey,如果没有WatchKey发生就一直等待。如果程序需要一直监控,则应该选择使用take()方法;如果程序只需要监控指定时间,则可考虑使用poll()方法。

15.10.4 访问文件属性

Java 7的NIO.2在java.nio.file.attribute包下提供了大量的工具类,通过这些工具类,开发者可以非常简单地读取、修改文件属性。这些工具类主要分为如下两类。

➢ XxxAttributeView:代表某种文件属性的“视图”。

➢ XxxAttributes:代表某种文件属性的“集合”,程序一般通过XxxAttributeView对象来获取XxxAttributes。

在这些工具类中,FileAttributeView是其他XxxAttributeView的父接口,下面简单介绍一下这些XxxAttributeView。

AclFileAttributeView:通过AclFileAttributeView,开发者可以为特定文件设置ACL(Access Control List)及文件所有者属性。它的getAcl()方法返回List对象,该返回值代表了该文件的权限集。通过setAcl(List)方法可以修改该文件的ACL。

BasicFileAttributeView:它可以获取或修改文件的基本属性,包括文件的最后修改时间、最后访问时间、创建时间、大小、是否为目录、是否为符号链接等。它的readAttributes()方法返回一个BasicFileAttributes对象,对文件夹基本属性的修改是通过BasicFileAttributes对象完成的。

DosFileAttributeView:它主要用于获取或修改文件DOS相关属性,比如文件是否只读、是否隐藏、是否为系统文件、是否是存档文件等。它的readAttributes()方法返回一个DosFileAttributes对象,对这些属性的修改其实是由DosFileAttributes对象来完成的。

FileOwnerAttributeView:它主要用于获取或修改文件的所有者。它的getOwner()方法返回一个UserPrincipal对象来代表文件所有者;也可调用setOwner(UserPrincipal owner)方法来改变文件的所有者。

PosixFileAttributeView:它主要用于获取或修改POSIX(Portable Operating SystemInterface of INIX)属性,它的readAttributes()方法返回一个PosixFileAttributes对象,该对象可用于获取或修改文件的所有者、组所有者、访问权限信息(就是UNIX的chmod命令负责干的事情)。这个View只在UNIX、Linux等系统上有用。

UserDefinedFileAttributeView:它可以让开发者为文件设置一些自定义属性。

第16章 多线程

16.1 线程概述

一个任务通常就是一个程序,每个运行中的程序就是一个进程。当一个程序运行时,内部可能包含了多个顺序执行流,每个顺序执行流就是一个线程。

16.1.1 线程和进程

进程包含如下三个特征。

➢ 独立性:进程是系统中独立存在的实体,它可以拥有自己独立的资源,每一个进程都拥有自己私有的地址空间。在没有经过进程本身允许的情况下,一个用户进程不可以直接访问其他进程的地址空间。

➢ 动态性:进程与程序的区别在于,程序只是一个静态的指令集合,而进程是一个正在系统中活动的指令集合。在进程中加入了时间的概念。进程具有自己的生命周期和各种不同的状态,这些概念在程序中都是不具备的。

➢ 并发性:多个进程可以在单个处理器上并发执行,多个进程之间不会互相影响。

并行指在同一时刻,有多条指令在多个处理器上同时执行;并发指在同一时刻只能有一条指令执行,但多个进程指令被快速轮换执行,使得在宏观上具有多个进程同时执行的效果。

线程(Thread)也被称作轻量级进程(Lightweight Process),线程是进程的执行单元。

16.1.2 多线程的优势

使用多线程编程具有如下几个优点。

➢ 进程之间不能共享内存,但线程之间共享内存非常容易。

➢ 系统创建进程时需要为该进程重新分配系统资源,但创建线程则代价小得多,因此使用多线程来实现多任务并发比多进程的效率高。

➢ Java语言内置了多线程功能支持,而不是单纯地作为底层操作系统的调度方式,从而简化了Java的多线程编程。

16.2 线程的创建和启动

16.2.1 继承Thread类创建线程类

通过继承Thread类来创建并启动多线程的步骤如下:

① 定义Thread类的子类,并重写该类的run()方法,该run()方法的方法体就代表了线程需要完成的任务。因此把run()方法称为线程执行体。

② 创建Thread子类的实例,即创建了线程对象。

③ 调用线程对象的start()方法来启动该线程。

 

➢ Thread.currentThread():currentThread()是Thread类的静态方法,该方法总是返回当前正在执行的线程对象。

➢ getName():该方法是Thread类的实例方法,该方法返回调用该方法的线程名字。

注意:使用继承Thread类的方法来创建线程类时,多个线程之间无法共享线程类的实例变量。

16.2.2 实现Runnable接口创建线程类

实现Runnable接口来创建并启动多线程的步骤如下:

① 定义Runnable接口的实现类,并重写该接口的run()方法,该run()方法的方法体同样是该线程的线程执行体。

② 创建Runnable实现类的实例,并以此实例作为Thread的target来创建Thread对象,该Thread对象才是真正的线程对象。

③ 调用线程对象的start()方法来启动该线程。

注意:采用Runnable接口的方式创建的多个线程可以共享线程类的实例变量。这是因为在这种方式下,程序所创建的Runnable对象只是线程的target,而多个线程可以共享同一个target,所以多个线程可以共享同一个线程类(实际上应该是线程的target类)的实例变量。

16.2.3 使用Callable和Future创建线程

从Java 5开始,Java提供了Callable接口,该接口怎么看都像是Runnable接口的增强版,Callable接口提供了一个call()方法可以作为线程执行体,但call()方法比run()方法功能更强大。

➢ call()方法可以有返回值。

➢ call()方法可以声明抛出异常。

Java 5提供了Future接口来代表Callable接口里call()方法的返回值,并为Future接口提供了一个FutureTask实现类,该实现类实现了Future接口,并实现了Runnable接口——可以作为Thread类的target。

在Future接口里定义了如下几个公共方法来控制它关联的Callable任务。

➢ boolean cancel(boolean mayInterruptIfRunning):试图取消该Future里关联的Callable任务。

➢ V get():返回Callable任务里call()方法的返回值。调用该方法将导致程序阻塞,必须等到子线程结束后才会得到返回值。

➢ V get(long timeout, TimeUnit unit):返回Callable任务里call()方法的返回值。该方法让程序最多阻塞timeout和unit指定的时间,如果经过指定时间后Callable任务依然没有返回值,将会抛出TimeoutException异常。

➢ boolean isCancelled():如果在Callable任务正常完成前被取消,则返回true。

➢ boolean isDone():如果Callable任务已完成,则返回true。

创建并启动有返回值的线程的步骤如下:

① 创建Callable接口的实现类,并实现call()方法,该call()方法将作为线程执行体,且该call()方法有返回值,再创建Callable实现类的实例。

② 使用FutureTask类来包装Callable对象,该FutureTask对象封装了该Callable对象的call()方法的返回值。

③ 使用FutureTask对象作为Thread对象的target创建并启动新线程。

④ 调用FutureTask对象的get()方法来获得子线程执行结束后的返回值。

16.2.4 创建线程的三种方式对比

通过继承Thread类或实现Runnable、Callable接口都可以实现多线程,不过实现Runnable接口与实现Callable接口的方式基本相同,只是Callable接口里定义的方法有返回值,可以声明抛出异常而已。因

采用实现Runnable、Callable接口的方式创建多线程的优缺点:

➢ 线程类只是实现了Runnable接口或Callable接口,还可以继承其他类。

➢ 在这种方式下,多个线程可以共享同一个target对象,所以非常适合多个相同线程来处理同一份资源的情况,从而可以将CPU、代码和数据分开,形成清晰的模型,较好地体现了面向对象的思想。

➢ 劣势是,编程稍稍复杂,如果需要访问当前线程,则必须使用Thread.currentThread()方法。

采用继承Thread类的方式创建多线程的优缺点:

➢ 劣势是,因为线程类已经继承了Thread类,所以不能再继承其他父类。

➢ 优势是,编写简单,如果需要访问当前线程,则无须使用Thread.currentThread()方法,直接使用this即可获得当前线程。

16.3 线程的生命周期

在线程的生命周期中,要经过新建(New)、就绪(Ready)、运行(Running)、阻塞(Blocked)和死亡(Dead)5种状态。尤其是当线程启动以后,它不可能一直“霸占”着CPU独自运行,所以CPU需要在多条线程之间切换,于是线程状态也会多次在运行、就绪之间切换。

16.3.1 新建和就绪状态

当程序使用new关键字创建了一个线程之后,该线程就处于新建状态,此时它和其他的Java对象一样,仅仅由Java虚拟机为其分配内存,并初始化其成员变量的值。

当线程对象调用了start()方法之后,该线程处于就绪状态,Java虚拟机会为其创建方法调用栈和程序计数器,处于这个状态中的线程并没有开始运行,只是表示该线程可以运行了,至于该线程何时开始运行,取决于JVM里线程调度器的调度。

16.3.2 运行和阻塞状态

如果处于就绪状态的线程获得了CPU,开始执行run()方法的线程执行体,则该线程处于运行状态。

当发生如下情况时,线程将会进入阻塞状态。

➢ 线程调用sleep()方法主动放弃所占用的处理器资源。

➢ 线程调用了一个阻塞式IO方法,在该方法返回之前,该线程被阻塞。

➢ 线程试图获得一个同步监视器,但该同步监视器正被其他线程所持有。

➢ 线程在等待某个通知(notify)。

➢ 程序调用了线程的suspend()方法将该线程挂起。但这个方法容易导致死锁,所以应该尽量避免使用该方法。

针对上面几种情况,当发生如下特定的情况时可以解除上面的阻塞,让该线程重新进入就绪状态。

➢ 调用sleep()方法的线程经过了指定时间。

➢ 线程调用的阻塞式IO方法已经返回。

➢ 线程成功地获得了试图取得的同步监视器。

➢ 线程正在等待某个通知时,其他线程发出了一个通知。

➢ 处于挂起状态的线程被调用了resume()恢复方法。

《疯狂Java讲义》(第5版) 李刚_第54张图片

16.3.3 线程死亡

线程会以如下三种方式结束,结束后就处于死亡状态。

➢ run()或call()方法执行完成,线程正常结束。

➢ 线程抛出一个未捕获的Exception或Error。

➢ 直接调用该线程的stop()方法来结束该线程——该方法容易导致死锁,通常不推荐使用。

为了测试某个线程是否已经死亡,可以调用线程对象的isAlive()方法,当线程处于就绪、运行、阻塞三种状态时,该方法将返回true;当线程处于新建、死亡两种状态时,该方法将返回false。

16.4 控制线程

16.4.1 join线程

Thread提供了让一个线程等待另一个线程完成的方法——join()方法。当在某个程序执行流中调用其他线程的join()方法时,调用线程将被阻塞,直到被join()方法加入的join线程执行完为止。

16.4.2 后台线程

有一种线程,它是在后台运行的,它的任务是为其他的线程提供服务,这种线程被称为“后台线程(Daemon Thread)”,又称为“守护线程”或“精灵线程”。JVM的垃圾回收线程就是典型的后台线程。

后台线程有个特征:如果所有的前台线程都死亡,后台线程会自动死亡。

调用Thread对象的setDaemon(true)方法可将指定线程设置成后台线程。

Thread类还提供了一个isDaemon()方法,用于判断指定线程是否为后台线程。

注意:前台线程死亡后,JVM会通知后台线程死亡,但从它接收指令到做出响应,需要一定时间。而且要将某个线程设置为后台线程,必须在该线程启动之前设置,也就是说,setDaemon(true)必须在start()方法之前调用,否则会引发IllegalThreadStateException异常。

16.4.3 线程睡眠:sleep

如果需要让当前正在执行的线程暂停一段时间,并进入阻塞状态,则可以通过调用Thread类的静态sleep()方法来实现。

关于sleep()方法和yield()方法的区别如下。

➢ sleep()方法暂停当前线程后,会给其他线程执行机会,不会理会其他线程的优先级;但yield()方法只会给优先级相同,或优先级更高的线程执行机会。

➢ sleep()方法会将线程转入阻塞状态,直到经过阻塞时间才会转入就绪状态;而yield()不会将线程转入阻塞状态,它只是强制当前线程进入就绪状态。因此完全有可能某个线程被yield()方法暂停之后,立即再次获得处理器资源被执行。

➢ sleep()方法声明抛出了InterruptedException异常,所以调用sleep()方法时要么捕捉该异常,要么显式声明抛出该异常;而yield()方法则没有声明抛出任何异常。

➢ sleep()方法比yield()方法有更好的可移植性,通常不建议使用yield()方法来控制并发线程的执行。

16.4.4 改变线程优先级

每个线程执行时都具有一定的优先级,优先级高的线程获得较多的执行机会,而优先级低的线程则获得较少的执行机会。

每个线程默认的优先级都与创建它的父线程的优先级相同,在默认情况下,main线程具有普通优先级,由main线程创建的子线程也具有普通优先级。

Thread类提供了setPriority(int newPriority)、getPriority()方法来设置和返回指定线程的优先级,其中setPriority()方法的参数可以是一个整数,范围是1~10之间,也可以使用Thread类的如下三个静态常量。

➢ MAX_PRIORITY:其值是10。

➢ MIN_PRIORITY:其值是1。

➢ NORM_PRIORITY:其值是5。

16.5 线程同步

16.5.1 线程安全问题

关于线程安全问题,有一个经典的问题——银行取钱的问题。

16.5.2 同步代码块

同步代码块的语法格式如下:

《疯狂Java讲义》(第5版) 李刚_第55张图片

16.5.3 同步方法

同步方法就是使用synchronized关键字来修饰某个方法,则该方法称为同步方法。

对于synchronized修饰的实例方法(非static方法)而言,无须显式指定同步监视器,同步方法的同步监视器是this,也就是调用该方法的对象。

16.5.4 释放同步监视器的锁定

任何线程进入同步代码块、同步方法之前,必须先获得对同步监视器的锁定,那么何时会释放对同步监视器的锁定呢?

程序无法显式释放对同步监视器的锁定,线程会在如下几种情况下释放对同步监视器的锁定

➢ 当前线程的同步方法、同步代码块执行结束,当前线程即释放同步监视器。

➢ 当前线程在同步代码块、同步方法中遇到break、return终止该代码块、该方法的继续执行,当前线程会释放同步监视器。

➢ 当前线程在同步代码块、同步方法中出现了未处理的Error或Exception,导致了该代码块、该方法异常结束时,当前线程将会释放同步监视器。

➢ 当前线程执行同步代码块或同步方法时,程序执行了同步监视器对象的wait()方法,则当前线程暂停,并释放同步监视器。

在如下所示的情况下,线程不会释放同步监视器。

➢ 线程执行同步代码块或同步方法时,程序调用Thread.sleep()、Thread.yield()方法来暂停当前线程的执行,当前线程不会释放同步监视器。

➢ 线程执行同步代码块时,其他线程调用了该线程的suspend()方法将该线程挂起,该线程不会释放同步监视器。

16.5.5 同步锁(Lock)

从Java 5开始,Java提供了一种功能更强大的线程同步机制——通过显式定义同步锁对象来实现同步,在这种机制下,同步锁由Lock对象充当,并且支持多个相关的Condition对象。

Lock、ReadWriteLock是Java 5提供的两个根接口,并为Lock提供了ReentrantLock(可重入锁)实现类,为ReadWriteLock提供了ReentrantReadWriteLock实现类。

Java 8新增StampedLock类,在大多数场景中它可以替代传统的ReentrantReadWriteLock。ReentrantReadWriteLock为读写操作提供了三种锁模式:Writing、ReadingOptimistic、Reading。

16.5.6 死锁及常用处理策略

可以通过下面几种常见方式来解决死锁问题。

➢ 避免多次锁定:尽量避免同一个线程对多个同步监视器进行锁定。

➢ 具有相同的加锁顺序:如果多个线程需要对多个同步监视器进行锁定,则应该保证它们以相同的顺序请求加锁。

➢ 使用定时锁:程序调用Lock对象的tryLock()方法加锁时可指定time和unit参数,当超过指定时间后会自动释放对Lock的锁定,这样就可以解开死锁了。

➢ 死锁检测:这是一种依靠算法来实现的死锁预防机制,它主要针对那些不可能实现按序加锁,也不能使用定时锁的场景。

注意:由于Thread类的suspend()方法也很容易导致死锁,所以Java不再推荐使用该方法来暂停线程的运行。

16.6 线程通信

16.6.1 传统的线程通信

借助于Object类提供的wait()、notify()和notifyAll()三个方法,这三个方法并不属于Thread类,而是属于Object类。但这三个方法必须由同步监视器对象来调用,这可分成以下两种情况。

➢ 对于使用synchronized修饰的同步方法,因为该类的默认实例(this)就是同步监视器,所以可以在同步方法中直接调用这三个方法。

➢ 对于使用synchronized修饰的同步代码块,同步监视器是synchronized后括号里的对象,所以必须使用该对象调用这三个方法。

16.6.2 使用Condition控制线程通信

当使用Lock对象来保证同步时,Java提供了一个Condition类来保持协调,使用Condition可以让那些已经得到Lock对象却无法继续执行的线程释放Lock对象,Condition对象也可以唤醒其他处于等待的线程。

Condition实例被绑定在一个Lock对象上。要获得特定Lock实例的Condition实例,调用Lock对象的newCondition()方法即可。

Condition类提供了如下三个方法。

➢ await():类似于隐式同步监视器上的wait()方法,导致当前线程等待,直到其他线程调用该Condition的signal()方法或signalAll()方法来唤醒该线程。

➢ signal():唤醒在此Lock对象上等待的单个线程。如果所有线程都在该Lock对象上等待,则会选择唤醒其中一个线程。选择是任意性的。只有当前线程放弃对该Lock对象的锁定后(使用await()方法),才可以执行被唤醒的线程。

➢ signalAll():唤醒在此Lock对象上等待的所有线程。只有当前线程放弃对该Lock对象的锁定后,才可以执行被唤醒的线程。

16.6.3 使用阻塞队列(BlockingQueue)控制线程通信

Java 5提供了一个BlockingQueue接口,虽然BlockingQueue也是Queue的子接口,但它的主要用途并不是作为容器,而是作为线程同步的工具

BlockingQueue具有一个特征:当生产者线程试图向BlockingQueue中放入元素时,如果该队列已满,则该线程被阻塞;当消费者线程试图从BlockingQueue中取出元素时,如果该队列已空,则该线程被阻塞。

《疯狂Java讲义》(第5版) 李刚_第56张图片

16.7 线程组和未处理的异常

Java使用ThreadGroup来表示线程组,它可以对一批线程进行分类管理,Java允许程序直接对线程组进行控制。对线程组的控制相当于同时控制这批线程。用户创建的所有线程都属于指定线程组,如果程序没有显式指定线程属于哪个线程组,则该线程属于默认线程组。

Thread类提供了如下几个构造器来设置新创建的线程属于哪个线程组。

➢ Thread(ThreadGroup group, Runnable target):以target的run()方法作为线程执行体创建新线程,属于group线程组。

➢ Thread(ThreadGroup group, Runnable target, String name):以target的run()方法作为线程执行体创建新线程,该线程属于group线程组,且线程名为name。

➢ Thread(ThreadGroup group, String name):创建新线程,新线程名为name,属于group线程组。

因为中途不可改变线程所属的线程组,所以Thread类没有提供setThreadGroup()方法来改变线程所属的线程组,但提供了一个getThreadGroup()方法来返回该线程所属的线程组,getThreadGroup()方法的返回值是ThreadGroup对象,表示一个线程组。ThreadGroup类提供了如下两个简单的构造器来创建实例。

➢ ThreadGroup(String name):以指定的线程组名字来创建新的线程组。

➢ ThreadGroup(ThreadGroup parent, String name):以指定的名字、指定的父线程组创建一个新线程组。上面两个构造器在创建线程组实例时都必须为其指定一个名字,也就是说,线程组总会具有一个字符串类型的名字,该名字可通过调用ThreadGroup的getName()方法来获取,但不允许改变线程组的名字。ThreadGroup类提供了如下几个常用的方法来操作整个线程组里的所有线程。

➢ int activeCount():返回此线程组中活动线程的数目。

➢ interrupt():中断此线程组中的所有线程。

➢ isDaemon():判断该线程组是否是后台线程组。

➢ setDaemon(boolean daemon):把该线程组设置成后台线程组。后台线程组具有一个特征——当后台线程组的最后一个线程执行结束或最后一个线程被销毁后,后台线程组将自动销毁。

➢ setMaxPriority(int pri):设置线程组的最高优先级。

ThreadGroup内还定义了一个很有用的方法:void uncaughtException(Thread t,Throwable e),该方法可以处理该线程组内的任意线程所抛出的未处理异常。

从Java 5开始,Java加强了线程的异常处理,如果线程执行过程中抛出了一个未处理异常,JVM在结束该线程之前会自动查找是否有对应的Thread.UncaughtExceptionHandler对象,如果找到该处理器对象,则会调用该对象的uncaughtException(Thread t,Throwable e)方法来处理该异常。Thread.UncaughtExceptionHandler是Thread类的一个静态内部接口,该接口内只有一个方法:void uncaughtException(Thread t,Throwable e),该方法中的t代表出现异常的线程,而e代表该线程抛出的异常。

Thread类提供了如下两个方法来设置异常处理器。

➢ static setDefaultUncaughtExceptionHandler(Thread.UncaughtExceptionHandlereh):为该线程类的所有线程实例设置默认的异常处理器。

➢ setUncaughtExceptionHandler(Thread.UncaughtExceptionHandler eh):为指定的线程实例设置异常处理器。

ThreadGroup类实现了Thread.UncaughtExceptionHandler接口,所以每个线程所属的线程组将会作为默认的异常处理器。当一个线程抛出未处理异常时,JVM会首先查找该异常对应的异常处理器(setUncaughtExceptionHandler()方法设置的异常处理器),如果找到该异常处理器,则将调用该异常处理器处理该异常;否则,JVM将会调用该线程所属的线程组对象的uncaughtException()方法来处理该异常。

线程组处理异常的默认流程如下。

① 如果该线程组有父线程组,则调用父线程组的uncaughtException()方法来处理该异常。

② 如果该线程实例所属的线程类有默认的异常处理器(由setDefaultUncaughtExceptionHandler()方法设置的异常处理器),那么就调用该异常处理器来处理该异常。

③ 如果该异常对象是ThreadDeath的对象,则不做任何处理;否则,将异常跟踪栈的信息打印到System.err错误输出流,并结束该线程。

16.8 线程池

16.8.1 使用线程池管理线程

Java5新增了一个Executors工厂类来产生线程池,该工厂类包含如下几个静态工厂方法来创建线程池。

➢ newCachedThreadPool():创建一个具有缓存功能的线程池,系统根据需要创建线程,这些线程将会被缓存在线程池中。

➢ newFixedThreadPool(int nThreads):创建一个可重用的、具有固定线程数的线程池。

➢ newSingleThreadExecutor():创建一个只有单线程的线程池,相当于调用newFixedThread Pool()方法时传入参数为1。

➢ newScheduledThreadPool(int corePoolSize):创建具有指定线程数的线程池,它可以在指定延迟后执行线程任务。corePoolSize指池中所保存的线程数,即使线程是空闲的也被保存在线程池内。

➢ newSingleThreadScheduledExecutor():创建只有一个线程的线程池,它可以在指定延迟后执行线程任务。

➢ ExecutorService newWorkStealingPool(int parallelism):创建持有足够的线程的线程池来支持给定的并行级别,该方法还会使用多个队列来减少竞争。

➢ ExecutorService newWorkStealingPool():该方法是前一个方法的简化版本。如果当前机器有4个CPU,则目标并行级别被设置为4,也就是相当于为前一个方法传入4作为参数。

前三个方法返回一个ExecutorService对象,该对象代表一个线程池,它可以执行Runnable对象或Callable对象所代表的线程;而中间两个方法返回一个ScheduledExecutorService线程池,它是ExecutorService的子类,它可以在指定延迟后执行线程任务;最后两个方法则是Java 8新增的,这两个方法可充分利用多CPU并行的能力。

ExecutorService里提供了如下三个方法。

➢ Future submit(Runnable task):将一个Runnable对象提交给指定的线程池,线程池将在有空闲线程时执行Runnable对象代表的任务。其中Future对象代表Runnable任务的返回值——但run()方法没有返回值,所以Future对象将在run()方法执行结束后返回null。但可以调用Future的isDone()、isCancelled()方法来获得Runnable对象的执行状态。

Futuresubmit(Runnable task, T result):将一个Runnable对象提交给指定的线程池,线程池将在有空闲线程时执行Runnable对象代表的任务。其中result显式指定线程执行结束后的返回值,所以Future对象将在run()方法执行结束后返回result。

Futuresubmit(Callabletask):将一个Callable对象提交给指定的线程池,线程池将在有空闲线程时执行Callable对象代表的任务。其中Future代表Callable对象里call()方法的返回值。

ScheduledExecutorService代表可在指定延迟后或周期性地执行线程任务的线程池,它提供了如下4个方法。

➢ ScheduledFutureschedule(Callablecallable, long delay, TimeUnit unit):指定callable任务将在delay延迟后执行。

➢ ScheduledFutureschedule(Runnable command, long delay, TimeUnit unit):指定command任务将在delay延迟后执行。

➢ ScheduledFuture scheduleAtFixedRate(Runnable command, long initialDelay,long period, TimeUnit unit):指定command任务将在delay延迟后执行,而且以设定频率重复执行。也就是说,在initialDelay后开始执行,依次在initialDelay+period、initialDelay+2*period…处重复执行,依此类推。

➢ ScheduledFuture scheduleWithFixedDelay(Runnable command, longinitialDelay, long delay, TimeUnit unit):创建并执行一个在给定初始延迟后首次启用的定期操作,随后在每一次执行终止和下一次执行开始之间都存在给定的延迟。

如果任务在任一次执行时遇到异常,就会取消后续执行;否则,只能通过程序来显式取消或终止该任务。用完一个线程池后,应该调用该线程池的shutdown()方法,该方法将启动线程池的关闭序列,调用shutdown()方法后的线程池不再接收新任务,但会将以前所有已提交任务执行完成。

使用线程池来执行线程任务的步骤如下:

① 调用Executors类的静态工厂方法创建一个ExecutorService对象,该对象代表一个线程池。

② 创建Runnable实现类或Callable实现类的实例,作为线程执行任务。

③ 调用ExecutorService对象的submit()方法来提交Runnable实例或Callable实例。

④ 当不想提交任何任务时,调用ExecutorService对象的shutdown()方法来关闭线程池。

16.8.2 使用ForkJoinPool利用多CPU

Java 7提供了ForkJoinPool来支持将一个任务拆分成多个“小任务”并行计算,再把多个“小任务”的结果合并成总的计算结果。ForkJoinPool是ExecutorService的实现类,因此是一种特殊的线程池。

ForkJoinPool提供了如下两个常用的构造器。

➢ ForkJoinPool(int parallelism):创建一个包含parallelism个并行线程的ForkJoinPool。

➢ ForkJoinPool():以Runtime.availableProcessors()方法的返回值作为parallelism参数来创建Fork JoinPool。

Java 8进一步扩展了ForkJoinPool的功能,Java 8为ForkJoinPool增加了通用池功能。ForkJoinPool类通过如下两个静态方法提供通用池功能。

➢ ForkJoinPool commonPool():该方法返回一个通用池,通用池的运行状态不会受shutdown()或shutdownNow()方法的影响。当然,如果程序直接执行System.exit(0);来终止虚拟机,通用池以及通用池中正在执行的任务都会被自动终止。

➢ int getCommonPoolParallelism():该方法返回通用池的并行级别。

创建了ForkJoinPool实例之后,就可调用ForkJoinPool的submit(ForkJoinTask task)或invoke(ForkJoinTask task)方法来执行指定任务了。其中ForkJoinTask代表一个可以并行、合并的任务。ForkJoinTask是一个抽象类,它还有两个抽象子类:RecursiveAction和RecursiveTask。其中RecursiveTask代表有返回值的任务,而RecursiveAction代表没有返回值的任务。

《疯狂Java讲义》(第5版) 李刚_第57张图片

16.9 线程相关类

16.9.1 ThreadLocal类

程局部变量(ThreadLocal)的功用其实非常简单,就是为每一个使用该变量的线程都提供一个变量值的副本,使每一个线程都可以独立地改变自己的副本,而不会和其他线程的副本冲突。从线程的角度看,就好像每一个线程都完全拥有该变量一样。

ThreadLocal类的用法非常简单,它只提供了如下三个public方法。

➢ T get():返回此线程局部变量中当前线程副本中的值。

➢ void remove():删除此线程局部变量中当前线程的值。

➢ void set(T value):设置此线程局部变量中当前线程副本中的值。

通常建议:如果多个线程之间需要共享资源,以达到线程之间的通信功能,就使用同步机制;如果仅仅需要隔离多个线程之间的共享冲突,则可以使用ThreadLocal。

16.9.2 包装线程不安全的集合

Collections提供了如下几个静态方法。

Collection synchronizedCollection(Collection c):返回指定collection对应的线程安全的collection。

➢ static List synchronizedList(List list):返回指定List对象对应的线程安全的List对象。

➢ static Map synchronizedMap(Map m):返回指定Map对象对应的线程安全的Map对象。

➢ static Set synchronizedSet(Set s):返回指定Set对象对应的线程安全的Set对象。

➢ static SortedMap synchronizedSortedMap(SortedMap m):返回指定SortedMap对象对应的线程安全的SortedMap对象。

➢ static SortedSet synchronizedSortedSet(SortedSet s):返回指定SortedSet对象对应的线程安全的SortedSet对象。

16.9.3 线程安全的集合类

java.util.concurrent包下提供了大量支持高效并发访问的集合接口和实现类。

《疯狂Java讲义》(第5版) 李刚_第58张图片

16.9.4 Java 9新增的发布-订阅框架

Java 9新增了一个发布-订阅框架,该框架是基于异步响应流的。这个发布-订阅框架可以非常方便地处理异步线程之间的流数据交换(比如两个线程之间需要交换数据)。而且这个发布-订阅框架不需要使用数据中心来缓冲数据,同时具有非常高效的性能。

这个发布-订阅框架使用Flow类的4个静态内部接口作为核心API。

➢ Flow.Publisher:代表数据发布者、生产者。

➢ Flow.Subscriber:代表数据订阅者、消费者。

➢ Flow.Subscription:代表发布者和订阅者之间的链接纽带。订阅者既可通过调用该对象的request()方法来获取数据项,也可通过调用对象的cancel()方法来取消订阅。

➢ Flow.Processor:数据处理器,它可同时作为发布者和订阅者使用。

Flow.Publisher发布者作为生产者,负责发布数据项,并注册订阅者。Flow.Publisher接口定义了如下方法来注册订阅者。

➢ void subscribe(Flow.Subscribersubscriber):程序调用此方法注册订阅者时,会触发订阅者的onSubscribe()方法,而Flow.Subscription对象作为参数传给该方法;如果注册失败,将会触发订阅者的onError() 方法。

Flow.Subscriber接口定义了如下方法。

➢ void onSubscribe(Flow.Subscription subscription):订阅者注册时自动触发该方法。

➢ void onComplete():当订阅结束时触发该方法。

➢ void onError(Throwable throwable):当订阅失败时触发该方法。

➢ void onNext(T item):订阅者从发布者处获取数据项时触发该方法,订阅者可通过该方法获取数据项。

为了处理一些通用发布者的场景,Java 9为Flow.Publisher提供了一个SubmissionPublisher实现类,它可向当前订阅者异步提交非空的数据项,直到它被关闭。每个订阅者都能以相同的顺序接收到新提交的数据项。程序创建SubmissionPublisher对象时,需要传入一个线程池作为底层支撑;该类也提供了一个无参数的构造器,该构造器使用ForkJoinPool.commonPool()方法来提交发布者,以此实现发布者向订阅者提供数据项的异步特性。

第17章 网络编程

17.1 网络编程的基础知识

17.1.1 网络基础知识

计算机网络,就是把分布在不同地理区域的计算机与专门的外部设备用通信线路互连成一个规模大、功能强的网络系统,从而使众多的计算机可以方便地互相传递信息,共享硬件、软件、数据信息等资源。

《疯狂Java讲义》(第5版) 李刚_第59张图片

17.1.2 IP地址和端口号

IP地址被分成了A、B、C、D、E五类,每个类别的网络标识和主机标识各有规则。

➢ A类:10.0.0.0~10.255.255.255

➢ B类:172.16.0.0~172.31.255.255

➢ C类:192.168.0.0~192.168.255.255

IP地址用于唯一地标识网络上的一个通信实体,但一个通信实体可以有多个通信程序同时提供网络服务,此时还需要使用端口。端口是一个16位的整数,用于表示数据交给哪个通信程序处理。

端口号可以从0到65535,通常将它分为如下三类。

➢ 公认端口(Well Known Ports):从0到1023,它们紧密绑定(Binding)一些特定的服务。

➢ 注册端口(Registered Ports):从1024到49151,它们松散地绑定一些服务。应用程序通常应该使用这个范围内的端口。

➢ 动态和/或私有端口(Dynamic and/or Private Ports):从49152到65535,这些端口是应用程序使用的动态端口,应用程序一般不会主动使用这些端口。

17.2 Java的基本网络支持

Java为网络支持提供了java.net包,该包下的URL和URLConnection等类提供了以编程方式访问Web服务的功能,而URLDecoder和URLEncoder则提供了普通字符串和application/x-www-form-urlencoded MIME字符串相互转换的静态方法。

17.2.1 使用InetAddress

Java提供了InetAddress类来代表IP地址,InetAddress下还有两个子类:Inet4Address、Inet6Address。

InetAddress类没有提供构造器,而是提供了如下两个静态方法来获取InetAddress实例。

➢ getByName(String host):根据主机获取对应的InetAddress对象。

➢ getByAddress(byte[] addr):根据原始IP地址来获取对应的InetAddress对象。

InetAddress还提供了如下三个方法来获取InetAddress实例对应的IP地址和主机名。

➢ String getCanonicalHostName():获取此IP地址的全限定域名。

➢ String getHostAddress():返回该InetAddress实例对应的IP地址字符串(以字符串形式)。

➢ String getHostName():获取此IP地址的主机名。

除此之外,InetAddress类还提供了一个getLocalHost()方法来获取本机IP地址对应的InetAddress实例。InetAddress类还提供了一个isReachable()方法,用于测试是否可以到达该地址。

17.2.2 使用URLDecoder和URLEncoder

URLDecoder和URLEncoder用于完成普通字符串和application/x-www-form-urlencodedMIME字符串之间的相互转换。

➢ URLDecoder类包含一个decode(String s, String enc)静态方法,它可以将看上去是乱码的特殊字符串转换成普通字符串。

➢ URLEncoder类包含一个encode(String s, String enc)静态方法,它可以将普通字符串转换成application/x-www-form-urlencoded MIME字符串。

17.2.3 URL、URLConnection和URLPermission

URL可以由协议名、主机、端口和资源组成,即满足如下格式:

URL类提供了多个构造器用于创建URL对象,一旦获得了URL对象之后,就可以调用如下方法来访问该URL对应的资源。

➢ String getFile():获取该URL的资源名。

➢ String getHost():获取该URL的主机名。

➢ String getPath():获取该URL的路径部分。

➢ int getPort():获取该URL的端口号。

➢ String getProtocol():获取该URL的协议名称。

➢ String getQuery():获取该URL的查询字符串部分。

➢ URLConnection openConnection():返回一个URLConnection对象,它代表了与URL所引用的远程对象的连接。

➢ InputStream openStream():打开与此URL的连接,并返回一个用于读取该URL资源的InputStream。

Java 8新增了一个URLPermission工具类,用于管理HttpURLConnection的权限问题,如果在HttpURLConnection安装了安全管理器,通过该对象打开连接时就需要先获得权限。

17.3 基于TCP协议的网络编程

17.3.1 TCP协议基础

17.3.2 使用ServerSocket创建TCP服务器端

Java中能接收其他通信实体连接请求的类是ServerSocket,ServerSocket对象用于监听来自客户端的Socket连接,如果没有连接,它将一直处于等待状态。

ServerSocket包含一个监听来自客户端连接请求的方法。

➢ Socket accept():如果接收到一个客户端Socket的连接请求,该方法将返回一个与客户端Socket对应的Socket;否则该方法将一直处于等待状态,线程也被阻塞。

为了创建ServerSocket对象,ServerSocket类提供了如下几个构造器。

➢ ServerSocket(int port):用指定的端口port来创建一个ServerSocket。该端口应该有一个有效的端口整数值,即0~65535。

➢ ServerSocket(int port, int backlog):增加一个用来改变连接队列长度的参数backlog。

➢ ServerSocket(int port, int backlog, InetAddress localAddr):在机器存在多个IP地址的情况下,允许通过localAddr参数来指定将ServerSocket绑定到指定的IP地址。

当ServerSocket使用完毕后,应使用ServerSocket的close()方法来关闭该ServerSocket。在通常情况下,服务器不应该只接收一个客户端请求,而应该不断地接收来自客户端的所有请求,所以Java程序通常会通过循环不断地调用ServerSocket的accept()方法。代码片段如下:

《疯狂Java讲义》(第5版) 李刚_第60张图片

17.3.3 使用Socket进行通信

客户端通常可以使用Socket的构造器来连接到指定服务器,Socket通常可以使用如下两个构造器。

➢ Socket(InetAddress/String remoteAddress, int port):创建连接到指定远程主机、远程端口的Socket,该构造器没有指定本地地址、本地端口,默认使用本地主机的默认IP地址,默认使用系统动态分配的端口。

➢ Socket(InetAddress/String remoteAddress, int port, InetAddress localAddr, intlocalPort):创建连接到指定远程主机、远程端口的Socket,并指定本地IP地址和本地端口,适用于本地主机有多个IP地址的情形。

当客户端、服务器端产生了对应的Socket之后,程序无须再区分服务器端、客户端,而是通过各自的Socket进行通信。Socket提供了如下两个方法来获取输入流和输出流。

➢ InputStream getInputStream():返回该Socket对象对应的输入流,让程序通过该输入流从Socket中取出数据。

➢ OutputStream getOutputStream():返回该Socket对象对应的输出流,让程序通过该输出流向Socket中输出数据。

17.4 基于UDP协议的网络编程

17.4.1 UDP协议基础

UDP协议和TCP协议简单对比如下。

➢ TCP协议:可靠,传输大小无限制,但是需要连接建立时间,差错控制开销大。

➢ UDP协议:不可靠,差错控制开销较小,传输大小限制在64KB以下,不需要建立连接。

17.4.2 使用DatagramSocket发送、接收数据

Java使用DatagramSocket代表UDP协议的Socket,DatagramSocket本身只是码头,不维护状态,不能产生IO流,它的唯一作用就是接收和发送数据报,Java使用DatagramPacket来代表数据报,DatagramSocket接收和发送的数据都是通过DatagramPacket对象完成的。

先看一下DatagramSocket的构造器。

➢ DatagramSocket():创建一个DatagramSocket实例,并将该对象绑定到本机默认IP地址、本机所有可用端口中随机选择的某个端口。

➢ DatagramSocket(int prot):创建一个DatagramSocket实例,并将该对象绑定到本机默认IP地址、指定端口。

➢ DatagramSocket(int port, InetAddress laddr):创建一个DatagramSocket实例,并将该对象绑定到指定IP地址、指定端口。

一旦得到了DatagramSocket实例之后,就可以通过如下两个方法来接收和发送数据。

➢ receive(DatagramPacket p):从该DatagramSocket中接收数据报。

➢ send(DatagramPacket p):以该DatagramSocket对象向外发送数据报。

从上面两个方法可以看出,使用DatagramSocket发送数据报时,DatagramSocket并不知道将该数据报发送到哪里,而是由DatagramPacket自身决定数据报的目的地。就像码头并不知道每个集装箱的目的地,码头只是将这些集装箱发送出去,而集装箱本身包含了该集装箱的目的地。

下面看一下DatagramPacket的构造器。

➢ DatagramPacket(byte[] buf, int length):以一个空数组来创建DatagramPacket对象,该对象的作用是接收DatagramSocket中的数据

。➢ DatagramPacket(byte[] buf, int length, InetAddress addr, int port):以一个包含数据的数组来创建DatagramPacket对象,创建该DatagramPacket对象时还指定了IP地址和端口—这就决定了该数据报的目的地。

➢ DatagramPacket(byte[] buf, int offset, int length):以一个空数组来创建DatagramPacket对象,并指定接收到的数据放入buf数组中时从offset开始,最多放length个字节。

➢ DatagramPacket(byte[] buf, int offset, int length, InetAddress address, int port):创建一个用于发送的DatagramPacket对象,指定发送buf数组中从offset开始,总共length个字节。

17.4.3 使用MulticastSocket实现多点广播

事实上MulticastSocket是DatagramSocket的一个子类,也就是说,MulticastSocket是特殊的DatagramSocket。当要发送一个数据报时,可以使用随机端口创建MulticastSocket,也可以在指定端口创建MulticastSocket。

MulticastSocket提供了如下三个构造器。

➢ public MulticastSocket():使用本机默认地址、随机端口来创建MulticastSocket对象。

➢ public MulticastSocket(int portNumber):使用本机默认地址、指定端口来创建MulticastSocket对象。

➢ public MulticastSocket(SocketAddress bindaddr):使用本机指定IP地址、指定端口来创建MulticastSocket对象。

创建MulticastSocket对象后,还需要将该MulticastSocket加入到指定的多点广播地址,MulticastSocket使用joinGroup()方法加入指定组;使用leaveGroup()方法脱离一个组。

➢ joinGroup(InetAddress multicastAddr):将该MulticastSocket加入指定的多点广播地址。

➢ leaveGroup(InetAddress multicastAddr):让该MulticastSocket离开指定的多点广播地址。

第18章 类加载机制与反射

18.1 类的加载、连接和初始化

18.1.1 JVM和类

当调用java命令运行某个Java程序时,该命令将会启动一个Java虚拟机进程,不管该Java程序有多么复杂,该程序启动了多少个线程,它们都处于该Java虚拟机进程里。正如前面介绍的,同一个JVM的所有线程、所有变量都处于同一个进程里,它们都使用该JVM进程的内存区。

当系统出现以下几种情况时,JVM进程将被终止。

➢ 程序运行到最后正常结束。

➢ 程序运行到使用System.exit()或Runtime.getRuntime().exit()代码处结束程序。

➢ 程序执行过程中遇到未捕获的异常或错误而结束。

➢ 程序所在平台强制结束了JVM进程。

18.1.2 类的加载

当程序主动使用某个类时,如果该类还未被加载到内存中,则系统会通过加载连接初始化三个步骤来对该类进行初始化。如果没有意外,JVM将会连续完成这三个步骤,所以有时也把这三个步骤统称为类加载或类初始化。

类加载指的是将类的class文件读入内存,并为之创建一个java.lang.Class对象,也就是说,当程序中使用任何类时,系统都会为之建立一个java.lang.Class对象。

类的加载由类加载器完成,类加载器通常由JVM提供,这些类加载器也是前面所有程序运行的基础,JVM提供的这些类加载器通常被称为系统类加载器。除此之外,开发者可以通过继承ClassLoader基类来创建自己的类加载器。

通过使用不同的类加载器,可以从不同来源加载类的二进制数据,通常有如下几种来源。

➢ 从本地文件系统加载class文件,这是前面绝大部分示例程序的类加载方式。

➢ 从JAR包加载class文件,这种方式也是很常见的,前面介绍JDBC编程时用到的数据库驱动类就放在JAR文件中,JVM可以从JAR文件中直接加载该class文件。

➢ 通过网络加载class文件。

➢ 把一个Java源文件动态编译,并执行加载。

类加载器通常无须等到“首次使用”该类时才加载该类,Java虚拟机规范允许系统预先加载某些类。

18.1.3 类的连接

当类被加载之后,系统为之生成一个对应的Class对象,接着将会进入连接阶段,连接阶段负责把类的二进制数据合并到JRE中。

类连接又可分为如下三个阶段。

(1)验证:验证阶段用于检验被加载的类是否有正确的内部结构,并和其他类协调一致。

(2)准备:类准备阶段则负责为类的类变量分配内存,并设置默认初始值。

(3)解析:将类的二进制数据中的符号引用替换成直接引用。

18.1.4 类的初始化

类的初始化阶段,虚拟机负责对类进行初始化,主要就是对类变量进行初始化。在Java类中对类变量指定初始值有两种方式:① 声明类变量时指定初始值;② 使用静态初始化块为类变量指定初始值。

JVM初始化一个类包含如下几个步骤:

① 假如这个类还没有被加载和连接,则程序先加载并连接该类。

② 假如该类的直接父类还没有被初始化,则先初始化其直接父类。

③ 假如类中有初始化语句,则系统依次执行这些初始化语句。

18.1.5 类初始化的时机

当Java程序首次通过下面6种方式来使用某个类或接口时,系统就会初始化该类或接口。

➢ 创建类的实例。为某个类创建实例的方式包括:使用new操作符来创建实例,通过反射来创建实例,通过反序列化的方式来创建实例。

➢ 调用某个类的类方法(静态方法)。

➢ 访问某个类或接口的类变量,或为该类变量赋值。

➢ 使用反射方式来强制创建某个类或接口对应的java.lang.Class对象。例如代码:Class.forName("Person"),如果系统还未初始化Person类,则这行代码将会导致该Person类被初始化,并返回Person类对应的java.lang.Class对象。

➢ 初始化某个类的子类。当初始化某个类的子类时,该子类的所有父类都会被初始化。

➢ 直接使用java.exe命令来运行某个主类。当运行某个主类时,程序会先初始化该主类。

除此之外,下面的几种情形需要特别指出。

对于一个final型的类变量,如果该类变量的值在编译时就可以确定下来,那么这个类变量相当于“宏变量”。Java编译器会在编译时直接把这个类变量出现的地方替换成它的值,因此即使程序使用该静态类变量,也不会导致该类的初始化。

反之,如果final修饰的类变量的值不能在编译时确定下来,则必须等到运行时才可以确定该类变量的值,如果通过该类来访问它的类变量,则会导致该类被初始化。

18.2 类加载器

18.2.1 类加载机制

类加载器负责将.class文件(可能在磁盘上,也可能在网络上)加载到内存中,并为之生成对应的java.lang.Class对象。

在Java中,一个类用其全限定类名(包括包名和类名)作为标识;但在JVM中,一个类用其全限定类名和其类加载器作为唯一标识。

当JVM启动时,会形成由三个类加载器组成的初始类加载器层次结构。

➢ Bootstrap ClassLoader:根类加载器。

➢ Extension ClassLoader:扩展类加载器。

➢ System ClassLoader:系统类加载器。

JVM的类加载机制主要有如下三种:

➢ 全盘负责。所谓全盘负责,就是当一个类加载器负责加载某个Class时,该Class所依赖的和引用的其他Class也将由该类加载器负责载入,除非显式使用另外一个类加载器来载入。

➢ 父类委托。所谓父类委托,则是先让parent(父)类加载器试图加载该Class,只有在父类加载器无法加载该类时才尝试从自己的类路径中加载该类。

➢ 缓存机制。缓存机制将会保证所有加载过的Class都会被缓存,当程序中需要使用某个Class时,类加载器先从缓存区中搜寻该Class,只有当缓存区中不存在该Class对象时,系统才会读取该类对应的二进制数据,并将其转换成Class对象,存入缓存区中。这就是为什么修改了Class后,必须重新启动JVM,程序所做的修改才会生效的原因。

注意:类加载器之间的父子关系并不是类继承上的父子关系,这里的父子关系是类加载器实例之间的关系。

《疯狂Java讲义》(第5版) 李刚_第61张图片

类加载器加载Class大致要经过如下8个步骤:

① 检测此Class是否载入过(即在缓存区中是否有此Class),如果有则直接进入第8步,否则接着执行第2步。

② 如果父类加载器不存在(如果没有父类加载器,则要么parent一定是根类加载器,要么本身就是根类加载器),则跳到第4步执行;如果父类加载器存在,则接着执行第3步。

③ 请求使用父类加载器去载入目标类,如果成功载入则跳到第8步,否则接着执行第5步。

④ 请求使用根类加载器来载入目标类,如果成功载入则跳到第8步,否则跳到第7步。

⑤ 当前类加载器尝试寻找Class文件(从与此ClassLoader相关的类路径中寻找),如果找到则执行第6步,如果找不到则跳到第7步。⑥ 从文件中载入Class,成功载入后跳到第8步。

⑦ 抛出ClassNotFoundException异常。

⑧ 返回对应的java.lang.Class对象。

其中,第5、6步允许重写ClassLoader的findClass()方法来实现自己的载入策略,甚至重写loadClass()方法来实现自己的载入过程。

18.2.2 创建并使用自定义的类加载器

ClassLoader中包含了大量的protected方法——这些方法都可被子类重写。

ClassLoader类有如下两个关键方法。

➢ loadClass(String name, boolean resolve):该方法为ClassLoader的入口点,根据指定名称来加载类,系统就是调用ClassLoader的该方法来获取指定类对应的Class对象。

➢ findClass(String name):根据指定名称来查找类。

如果需要实现自定义的ClassLoader,则可以通过重写以上两个方法来实现,通常推荐重写findClass()方法,而不是重写loadClass()方法。

loadClass()方法的执行步骤如下。

① 用findLoadedClass(String) 来检查是否已经加载类,如果已经加载则直接返回。

② 在父类加载器上调用loadClass()方法。如果父类加载器为null,则使用根类加载器来加载。

③ 调用findClass(String)方法查找类。

18.2.3 URLClassLoader类

Java为ClassLoader提供了一个URLClassLoader实现类,该类也是系统类加载器和扩展类加载器的父类(此处的父类,就是指类与类之间的继承关系)。URLClassLoader功能比较强大,它既可以从本地文件系统获取二进制文件来加载类,也可以从远程主机获取二进制文件来加载类。

在应用程序中可以直接使用URLClassLoader加载类,URLClassLoader类提供了如下两个构造器。

➢ URLClassLoader(URL[] urls):使用默认的父类加载器创建一个ClassLoader对象,该对象将从urls所指定的系列路径来查询并加载类。

➢ URLClassLoader(URL[] urls, ClassLoader parent):使用指定的父类加载器创建一个ClassLoader对象,其他功能与前一个构造器相同。

一旦得到了URLClassLoader对象之后,就可以调用该对象的loadClass()方法来加载指定类。

18.3 通过反射查看类信息

Java程序中的许多对象在运行时都会出现两种类型:编译时类型和运行时类型。例如代码:Person p=new Student();,这行代码将会生成一个p变量,该变量的编译时类型为Person,运行时类型为Student。

18.3.1 获得Class对象

每个类被加载之后,系统就会为该类生成一个对应的Class对象,通过该Class对象就可以访问到JVM中的这个类。

在Java程序中获得Class对象通常有如下三种方式:

➢ 使用Class类的forName(String clazzName)静态方法。该方法需要传入字符串参数,该字符串参数的值是某个类的全限定类名(必须添加完整包名)。

➢ 调用某个类的class属性来获取该类对应的Class对象。例如,Person.class将会返回Person类对应的Class对象。

➢ 调用某个对象的getClass()方法。该方法是java.lang.Object类中的一个方法,所以所有的Java对象都可以调用该方法,该方法将会返回该对象所属类对应的Class对象。

18.3.2 从Class中获取信息

通过Class对象可以得到大量的Method、Constructor、Field等对象,这些对象分别代表该类所包括的方法、构造器和成员变量等,程序还可以通过这些对象来执行实际的功能,例如调用方法、创建实例。

18.3.3 方法参数反射

Java 8在java.lang.reflect包下新增了一个Executable抽象基类,该对象代表可执行的类成员,该类派生了Constructor、Method两个子类。

Executable基类提供了大量方法来获取修饰该方法或构造器的注解信息;还提供了isVarArgs()方法用于判断该方法或构造器是否包含数量可变的形参,以及通过getModifiers()方法来获取该方法或构造器的修饰符。

除此之外,Executable提供了如下两个方法来获取该方法或参数的形参个数及形参名。

➢ int getParameterCount():获取该构造器或方法的形参个数。

➢ Parameter[] getParameters():获取该构造器或方法的所有形参。上面第二个方法返回了一个Parameter[]数组,Parameter也是Java 8新增的API,每个Parameter对象代表方法或构造器的一个参数。

Parameter也提供了大量方法来获取声明该参数的泛型信息,还提供了如下常用方法来获取参数信息。

➢ getModifiers():获取修饰该形参的修饰符。

➢ String getName():获取形参名。

➢ Type getParameterizedType():获取带泛型的形参类型。

➢ Class getType():获取形参类型。

➢ boolean isNamePresent():该方法返回该类的class文件中是否包含了方法的形参名信息。

➢ boolean isVarArgs():该方法用于判断该参数是否为个数可变的形参。

需要指出的是,使用javac命令编译Java源文件时,默认生成的class文件并不包含方法的形参名信息,因此调用isNamePresent()方法将会返回false,调用getName()方法也不能得到该参数的形参名。如果希望javac命令编译Java源文件时可以保留形参信息,则需要为该命令指定-parameters选项。

18.4 使用反射生成并操作对象

Class对象可以获得该类里的方法(由Method对象表示)、构造器(由Constructor对象表示)、成员变量(由Field对象表示),这三个类都位于java.lang.reflect包下,并实现了java.lang.reflect.Member接口。程序可以通过Method对象来执行对应的方法,通过Constructor对象来调用对应的构造器创建实例,能通过Field对象直接访问并修改对象的成员变量值。

18.4.1 创建对象

通过反射来生成对象需要先使用Class对象获取指定的Constructor对象,再调用Constructor对象的newInstance()方法来创建该Class对象对应类的实例。

18.4.2 调用方法

当获得某个类对应的Class对象后,就可以通过该Class对象的getMethods()方法或者getMethod()方法来获取全部方法或指定方法——这两个方法的返回值是Method数组,或者Method对象。

每个Method对象对应一个方法,获得Method对象后,程序就可通过该Method来调用它对应的方法。在Method里包含一个invoke()方法,该方法的签名如下。

➢ Object invoke(Object obj, Object...args):该方法中的obj是执行该方法的主调,后面的args是执行该方法时传入该方法的实参。

18.4.3 访问成员变量值

通过Class对象的getFields()或getField()方法可以获取该类所包括的全部成员变量或指定成员变量。

Field提供了如下两组方法来读取或设置成员变量值。

➢ getXxx(Object obj):获取obj对象的该成员变量的值。此处的Xxx对应8种基本类型,如果该成员变量的类型是引用类型,则取消get后面的Xxx。

➢ setXxx(Object obj, Xxx val):将obj对象的该成员变量设置成val值。此处的Xxx对应8种基本类型,如果该成员变量的类型是引用类型,则取消set后面的Xxx。

使用这两个方法可以随意地访问指定对象的所有成员变量,包括private修饰的成员变量。

18.4.4 操作数组

在java.lang.reflect包下还提供了一个Array类,Array对象可以代表所有的数组。程序可以通过使用Array来动态地创建数组,操作数组元素等。

Array提供了如下几类方法。

➢ static Object newInstance(ClasscomponentType, int...length):创建一个具有指定的元素类型、指定维度的新数组。

➢ static xxx getXxx(Object array, int index):返回array数组中第index个元素。其中xxx是各种基本数据类型,如果数组元素是引用类型,则该方法变为get(Object array,intindex)。

➢ static void setXxx(Object array, int index, xxx val):将array数组中第index个元素的值设为val。其中xxx是各种基本数据类型,如果数组元素是引用类型,则该方法变成set(Object array,int index,Object val)。

18.4.5 Java 11新增的嵌套访问权限

18.5 使用反射生成JDK动态代理

18.5.1 使用Proxy和InvocationHandler创建动态代理

Proxy提供了用于创建动态代理类和代理对象的静态方法,它也是所有动态代理类的父类。如果在程序中为一个或多个接口动态地生成实现类,就可以使用Proxy来创建动态代理类;如果需要为一个或多个接口动态地创建实例,也可以使用Proxy来创建动态代理实例。

Proxy提供了如下两个方法来创建动态代理类和动态代理实例。

➢ static Class getProxyClass(ClassLoader loader, Class...interfaces):创建一个动态代理类所对应的Class对象,该代理类将实现interfaces所指定的多个接口。第一个ClassLoader参数指定生成动态代理类的类加载器。

➢ static Object newProxyInstance(ClassLoader loader, Class[] interfaces,InvocationHandler h):直接创建一个动态代理对象,该代理对象的实现类实现了interfaces指定的系列接口,执行代理对象的每个方法时都会被替换执行InvocationHandler对象的invoke方法。

实际上,即使采用第一个方法生成动态代理类之后,如果程序需要通过该代理类来创建对象,依然需要传入一个InvocationHandler对象。也就是说,系统生成的每个代理对象都有一个与之关联的InvocationHandler对象。

18.6 反射和泛型

从JDK 5以后,Java的Class类增加了泛型功能,从而允许使用泛型来限制Class类,例如,String.class的类型实际上是Class。如果Class对应的类暂时未知,则使用Class。通过在反射中使用泛型,可以避免使用反射生成的对象需要强制类型转换。

18.6.1 泛型和Class类

使用Class泛型可以避免强制类型转换。

18.6.2 使用反射来获取泛型信息

然后将Type对象强制类型转换为ParameterizedType对象,ParameterizedType代表被参数化的类型,也就是增加了泛型限制的类型。ParameterizedType类提供了如下两个方法。

➢ getRawType():返回没有泛型信息的原始类型。

➢ getActualTypeArguments():返回泛型参数的类型。


《完》

你可能感兴趣的:(书籍阅读笔记,java)