1. Java 简介
Java 是一门面向对象的编程语言,不仅吸收了 C++ 语言的各种优点,还摒弃了 C++ 里难以理解的多继承、指针等概念,因此 Java 语言具有功能强大和简单易用两个特征。Java 语言作为静态面向对象编程语言的代表,极好地实现了面向对象理论,允许程序员以优雅的思维方式进行复杂的编程。
Java具有简单性、面向对象、分布式、健壮性、安全性、平台独立与可移植性、多线程、动态性等特点。Java 可以编写桌面应用程序、Web 应用程序、分布式系统和嵌入式系统应用程序等。如今,Java 在 Web 应用程序和分布式系统中有着广泛的应用。
1.1 JRE / JDK / JVM
想要运行由 Java 语言编写的程序,就要有 Java 运行环境(Java Runtime Environment,JRE);而想要编写 Java 程序,就需要有 Java 开发工具包(Java Development Kit,JDK)。
一个 JRE 通常包括 Java 虚拟机(JVM),Java 类库,Java 命令和其他的一些基础构件;而一个 JDK 完全包含一个 JRE,除了上述内容之外,JDK 还包含有 javac 编译器和其他工具(如 javadoc
和 jdb
),JDK 能够创建和编译程序,是每个 Java 开发者必备的基础工具。
每一个 Java 运行环境都有一个 Java 虚拟机(Java Virtual Machine,JVM),所有的 Java 应用程序都在 JVM 上运行。换句话说,任何一个操作系统平台上只要存在 Java 虚拟机,那么我们编写的应用程序就能在该系统上运行,这也是 Java "Write Once, Run Anywhere" 口号的来源。
Java 的优势
直到今天,依然有很多人觉得跨平台是 Java 语言最大的优势。实际上,跨平台已经不是 Java 最大的卖点了,现在有很多更好用更简单的新语言不断地涌现出来。
在笔者看来,Java 强大的生态才是 Java 最强大的地方。
1.1.1 JDK 版本
JDK 版本现已来到 17(本文写于 2022-04-30),其中的长期支持版(Long Term Support,LTS)有 8、11、17。建议使用这三个版本的 JDK 进行开发,LTS 版本的 JDK 受官方支持的时间更长,同时很多第三方库也都是基于 JDK 的长期支持版更新的,许多公司使用的都是 8 和 11 的 JDK 版本。
OpenJDK 与 OracleJDK
OpenJDK 和 OracleJDK 有一段历史渊源,笔者这里就不详细展开了,直接说结论:
- OpenJDK 是完全开源的,可以被看作是 JDK 的一个参考模型
- OracleJDK 是由 Oracle 官方实现的 JDK,但是它并不是完全开源的
OracleJDK 通常比 OpenJDK 更稳定。OpenJDK 和 OracleJDK 的代码几乎相同,但 OracleJDK 有更多的类和一些错误修复。因此,如果想开发企业/商业软件,建议选择 OracleJDK,因为它经过了彻底的测试和稳定。
当然,由一些知名机构编译的 OpenJDK 也是值得信赖的,比如微软推出的 Microsoft OpenJDK,以及 Adopt OpenJDK。
1.1.2 配置 JDK
在常见的操作系统上,都可以通过下载对应操作系统版本的 JDK 压缩包,解压放在一个目录上,然后配置环境变量的 JAVA_HOME
值为 JDK 根目录的路径,即可完成安装。
以 Linux 操作系统为例,下载压缩包,使用 tar -zxvf
命令解压,然后移动到一个位置,比如:
$> tar -zvxf jdk-11.tar.gz
$> mv jdk-11 /usr/local/jdk
然后在系统环境变量中配置一下即可:
$> vim /etc/profile
export JAVA_HOME=/usr/local/jdk
export PATH=$JAVA_HOME/bin:$PATH
$> source /etc/profile
最后,在命令行输入:
$> java --version
$> javac --version
得到的结果应该类似于:
1.2 .java 文件 和 .class 文件
使用 Java 语言编写的代码源文件的后缀是 .java
,就像 C 语言的源文件后缀是 .c
那样。
JDK 中提供了编译器 javac
程序,它可以把 .java
文件翻译成 .class
文件,也叫做类文件;类文件可以被装载到 JVM 上。
如果一个 .class
文件含有一个 main
函数,那么它就可以被 JDK 提供的 java
程序加载到 JVM 上运行,该文件所其他需要的 .class
文件会被自动加载进去。
2. 第一个 Java 程序
新建一个文本文档,复制下列代码到其中:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello From Java!");
}
}
保存为 HelloWorld.java
文件,使用 javac HelloWorld.java
编译为 .class
文件,然后使用 java HelloWorld
运行,结果应该为:
$> javac HelloWorld.java
$> java HelloWorld
Hello From Java!
提示
在 JDK 11 及以上版本中,对于只包含单个源文件的 Java 程序只需要使用
java xxx.java
即可直接编译运行。
2.1 使用 jShell
jShell 是一个“读取-计算-打印循环”(Read-Evaluate-Print Loop,REPL),类似于 Python 命令行的交互式环境:键入一个 Java 语言表达式,jShell 会帮你计算出来并立即显示在屏幕上。
类似于常见的 CLI,还可以使用 tab 键进行提示补全。
有兴趣的读者可以做深入了解,这里就不再进行深入介绍了。
2.2 Java 的输入输出
2.2.1 输出
Java 最基本的命令行输出为 System.out.println
、System.out.print
以及 System.out.printf
。
其本质是利用 Java 的输出流来输出到屏幕上,存在一个输出流绑定到了标准输出屏幕,而这个输出流又被绑定在 System.out
变量中。
print
与 println
都是输出一个字符串,不同之处在于 println
会额外输出一个换行符。
printf
用于格式化输出,本质上是调用 String
的 format
方法,在之后的章节中会详细介绍。
2.2.2 输入
Java 使用 Scanner
类来从标准输入中读取输入。有关类的介绍见下一章。
要使用它,首先需要构造一个 Scanner
对象:
Scanner in = new Scanner(System.in);
然后读取输入:
int a = in.nextInt();
如果要读入其他类型,则是 in.nextXXX()
,比如 in.nextFloat()
。
可以使用 in.next()
和 in.nextLine
读取字符串,不同的是 next()
碰到空格等空白字符就会立即停止读取,而 nextLine
会一直读取直到读取到一个换行符。
还可以使用 in.hasNext()
或 in.hasNextLine()
判断是否还有输入。
3. Java 程序基本结构
Java 程序都是由一个一个的类 class
组成的,每一个类又由字段 field
(或叫做属性 property)以及方法 method(或叫做函数 function)组成的。
更为复杂的类中,可能还包含其他的类,我们之后再讲解这个问题。
其中,有 public static void main(String[] args)
的方法的类是整个 Java 程序的入口,你的程序将从这里开始执行。
注意识别作为程序入口 main
方法,真正的作为程序入口的 main
方法的名字必须按上面所说的,除了 args
这个地方可以变动。比如:如果你看到 public void main(String[] args)
的 main
方法,那么它不是程序的入口。
下面还有一些其他的注意事项:
- 一个 Java 源文件里可以有多个类,但是只能有一个公共类,也就是
public class
。 - Java 源文件的名称必须和公共类的名称相同。如果一个源文件没有公共类,那么它的名字可以是任意的。
- 如果一个 Java 源文件里包含多个类,则每一个类都会被
javac
编译成单独的.class
文件,也就是一个类对应一个.class
文件。 - JDK / JRE 中的名为
java
的程序其实就是 JVM,JVM 能加载格式正确的.class
文件,但是程序的执行入口只能是包含正确书写main
方法的类。
Java 程序的基本单位是一条一条的语句(statement),一条完整的语句由 ;
结尾,一行可以有多条语句,但是不推荐这么做。语句的类型多种多样,作用也天差地别,我们之后会一一介绍。
多条语句可以包含在一个块(block)中,任何块都需要以 {
开头,}
结尾,块内可以包含 0 条或任意条语句,也可以包含其他的块。
3.1 标识符
我们可以给类命名、给字段命名、给方法命名,这个名字就是标识符。
关于 Java 标识符,有以下几点需要注意:
-
所有的标识符都应该以字母(A-Z 或者 a-z)、美元符(
$
)、或者下划线(_
)开始。但是极不推荐使用
$
开头,甚至不推荐包含这个字符,我们之后会讲解其中的原因。 -
首字符之后可以是字母(A-Z 或者 a-z)、美元符(
$
)、下划线(_
)或数字的任何字符组合。 -
关键字不能用作标识符。
关键字是具有特殊含义的词,比如
public
、class
、void
之类的词,我们之后都会介绍。 -
标识符是大小写敏感的。
合法标识符举例:age、$salary、_value、__1_value。
不合法标识符举例:123abc、-salary。
标识符命名应该遵守一定规范,Java 最常用的就是驼峰命名法(Camel-Case):
- 类名由一个或多个单词组成,每个单词的首字母都大写。
- 变量(字段)名由一个或多个单词组成,如果是由多个单词组成,则第一个单词的首字母无需大写,之后的每一个单词的首字母都需要大写。
- 方法名的命名规则和变量(字段)名一致。
- 常量名应该全大写,如果有多个单词,单词之间应该以
_
分隔。
这样的变量名看上去就像骆驼峰一样此起彼伏,因此被称为驼峰命名法,
3.2 关键字
Java 内置许多关键字,这些关键字都不能作为标识符使用,它们都有独特的含义。
类别 | 关键字 | 说明 |
---|---|---|
访问控制 | private | 私有的 |
protected | 受保护的 | |
public | 公共的 | |
default | 默认 | |
类、方法和变量修饰符 | abstract | 声明抽象 |
class | 类 | |
extends | 扩充,继承 | |
final | 最终值,不可改变的 | |
implements | 实现(接口) | |
interface | 接口 | |
native | 本地,原生方法(非 Java 实现) | |
new | 新,创建 | |
static | 静态 | |
strictfp | 严格,精准 | |
synchronized | 线程,同步 | |
transient | 短暂 | |
volatile | 易失 | |
程序控制语句 | break | 跳出循环 |
case | 定义一个值以供 switch 选择 | |
continue | 继续 | |
default | 默认 | |
do | 运行 | |
else | 否则 | |
for | 循环 | |
if | 如果 | |
instanceof | 实例 | |
return | 返回 | |
switch | 根据值选择执行 | |
while | 循环 | |
错误处理 | assert | 断言表达式是否为真 |
catch | 捕捉异常 | |
finally | 有没有异常都执行 | |
throw | 抛出一个异常对象 | |
throws | 声明一个异常可能被抛出 | |
try | 捕获异常 | |
包相关 | import | 引入 |
package | 包 | |
基本类型 | boolean | 布尔型 |
byte | 字节型 | |
char | 字符型 | |
double | 双精度浮点 | |
float | 单精度浮点 | |
int | 整型 | |
long | 长整型 | |
short | 短整型 | |
变量引用 | super | 父类,超类 |
this | 本类 | |
void | 无返回值 | |
保留关键字 | goto | 是关键字,但不能使用 |
const | 是关键字,但不能使用 |
除此之外,Java 还包含一些 null
、true
、false
这样的字面值,这些不属于关键字,但是它们也不能被作为标识符使用。
3.3 注释
注释是不会被执行的代码,通常用于说明代码的含义等。
在 Java 中,分为三种注释:
-
单行注释,以
//
开头,//
之后的代码全部被视为注释,换行后失效。 -
多行注释,以
/*
开头,*/
结尾,之中的所有代码都被视为注释。 注意,多行注释中不能再包含多行注释,即/* /* */ */
是非法的。非法指的是编写的程序中不符合编程语言的语法,编译器不能识别这种代码,并且报一个错误。
-
文档注释,之后会详细介绍,它以
/**
开头,*/
结尾,可以看作是多行注释的一种,但是 Java 有特殊的方式处理它。
3.4 空白
除了必须的空白,Java 程序中任意空白都会被忽略掉。
必须的空白指的是某些关键字之间、关键字和标识符之间的空白,比如:
public class A {
}
// 等价于
public class A{}
适当的加一些空白会使得程序更好看,可读性更强。
3.5 变量与常量
变量就像数学里的变量一样,它的值是可以变的。
我们想要操作一个变量,就得首先声明它,给它起一个名字,形如 type var = xxx
这样的语句叫做赋值语句,用于定义一个变量 var
,并把 xxx
值给它,其中 type
表示该变量的类型,=
是赋值运算符。
Java 是一种强类型语言,一个变量一旦被声明为一个类型,那么到它的生命结束时都是这个类型。
定义变量时,可以不立即进行赋值,但是并不能不赋值就参与运算,比如:
int i = 1; // 定义并立即赋值
int j; // 先定义
j = 2; // 再赋值
int l;
i = l + j; // l 未赋值,错误
提示
在 JDK 10 之后,可以使用
var
关键字定义一个变量,这个变量的类型会根据后面所赋的值自动推断。这时就必须在定义完变量后立即赋值。
在声明变量时,如果在变量类型前加上一个 final
关键字,则该变量就变成了常量。这时就必须立马赋值,因为之后这个变量的值不可改变了。
3.5.1 数据类型
每个变量都有一个数据类型,Java 中数据类型分为基本数据类型和类数据类型。我们之前介绍的 class
就是用于定义一个类类型的关键字。
基本数据类型有 8 种,它们是复杂类类型的基石,根据表示的内容的不同,可以分为四大类:
-
整数类型
byte
,代表 8 位有符号整数。short
,代表 16 位有符号整数。int
,代表 32 位有符号整数。long
,代表 64 位有符号整数。
-
浮点类型
float
,代表 32 位 IEEE 浮点数。double
,代表 64 位 IEEE 浮点数。
浮点数存在精度损失问题,一般不直接使用
==
或!=
进行比较,而是作差后和一个非常小的数字进行比较,比如x - y < eps = 10 ^ -8
,则认为x == y
。浮点数有三个特殊值:NaN、无穷大以及无穷小,在溢出或非法运算时会使用到它们。
-
布尔类型
-
boolean
,取值只有true
和false
两种,表真假。 其所占位数和 JDK 实现有关,建议实现是 1 位,但是由于字节对齐的原因,可能实际上会是 8 位。布尔类型表示一个条件的结果,比如 1 > 2,结果显然为
false
。提示
JVM 会在编译时期将 boolean 类型的数据转换为 int,使用 1 来表示 true,0 表示 false。JVM 支持 boolean 数组,但是是通过读写 byte 数组来实现的。
-
-
字符类型
-
char
,代表一个字符。字符的存储方法
字符在计算机中是通过编码的方式(即一个字符对应一个数字)表示的,这意味着字符本质上是一个个的整数。
在 Java 中,一个
char
占 16 位,而 Java 的默认字符集Unicode
的字符总数超出了 16 位所能表示的最大数并且还在持续增加。那么这就无法形成一一对应的关系,导致多个char
才能表示一个完整的字符。所以,如果不是必要,建议不使用
char
类型而使用String
(字符串),有关字符串的相关知识我们之后会详细介绍。
-
类类型我们已经介绍过了,但是类类型不仅仅只有 class
一种,但是所有类类型的本质都是 class
,我们之后再谈这个问题。
3.5.1.1 基本数据类型之间的转换
基本原则:所有低位数值类型转换到高位数值类型都是允许的,且不会出现任何问题,这样的转换叫做无信息丢失的转换。
根据这一基本原则,我们可以得到下图,其中实线表示无信息丢失的转换,虚线则是有信息丢失的转换:
有丢失信息的转换是不被允许的,需要在前面额外加上 ()
,()
内部填写转换后的数据类型,这叫做强制类型转换,如:
int i = 1;
double j = 3.14;
i = j; // 编译失败
i = (double) j; // 编译通过
有时还会存在隐式转换,也就是自动转换,有如下几种:
-
无信息丢失的转换都可以自动转换。
比如:
int i = 1; double j = 3.14; j = 1; // 自动转换
-
基本数据类型和其包装类型之间的转换都可以自动转换。
有关这个问题,我们以后再详细介绍。
3.5.2 字面量
在 Java 中,字面值也属于常量,也叫字面量,比如 1
、'a'
、2.36
、false
等。
整数类的字面量默认属于 int
类型,浮点数类的字面量默认属于 double
型,布尔类的字面量就属于 boolean
类型,字符类的字面量就属于 char
类型。
如果在默认的字面量加上一些后缀,则该字面量就属于其他类型:
- 在整数后加上一个
L
或l
,则变为长整型。 - 在浮点数后加上一个
F
或f
,则变为float
型。
需要注意的是,以 ''
包裹的单个字符(多个字符是非法的)的字面量属于字符字面量(char
),而以 ""
包裹的单个及多个字符的字面量属于字符串字面量(String
)。
特殊字符
有一些特殊的字符(比如换行,制表符)是无法直接写出来的,于是就利用
\
+ 字符的方式表示它,这叫做转义字符。比如'\n'
表示一个换行符。详细的转义字符表如下:
转义字符 意义 ASCII 码值(十进制) \0 空字符 0 \b 退格,将当前光标位置移到前一列 8 \t 制表 9 \n 换行,将当前光标位置移到下页开头 10 \f 换页,将当前光标位置移到下一页开头 12 \r 回车,将当前光标位置移到本行开头 13 \" 双引号 34 \' 单引号 39 \\ 一个反斜线 \
92 \xxx 1 ~ 3 位八进制数所代表的字符 范围 '\000' ~ '\377'
\uxxxx Unicode 转义字符,xxxx 是十六进制数字 范围 '\u0000' ~ '\uffff'
3.5.2.1 整数字面量的其他表示方法
在 Java 中,整数除了常见的十进制表示方法,还有二进制、八进制、十六进制的表示方法。
- 以
0b
或0B
开头的整数是二进制整数。 - 以
0
开头的整数是八进制整数。 - 以
0x
或0X
开头的整数是十六进制整数。
还可以为数字添加下划线,这只是为了更方便阅读,编译后 _
就不再存在了,如:
int a = 1_000_000;
3.5.3 运算符
Java 支持许多种运算符,除了常见的数学运算符之外,还有一些特殊的运算符。
我们知道,数学运算符之间是有优先级的,比如乘除先于加减运算,事实上,Java 中所有的运算符都有一个优先级,如下表所示:
运算符 | 结合性 |
---|---|
[] , () (方法调用) |
左 |
! , ~ , ++ , -- , + (正号), - (负号), () (强制类型转换) |
右 |
* , / , % |
左 |
+ , - |
左 |
<< , >> , >>> |
左 |
< , <= , > , >= , instanceof |
左 |
== , != |
左 |
& |
左 |
^ |
左 |
` | ` |
&& |
左 |
` | |
(boolean condition) ? a : b |
右 |
= , += , -= , *= , /= , %= , &= ,` |
=, ^=`, `~=`, `<<=`, `>>=`, `>>>=` |
可以使用 ()
括起一个表达式,使它最优先进行运算。
运算符的结合性
若有三个运算数和两个运算符:
a O b O c
,其中O
为运算符,则左结合性等价于:(a O b) O c
,右结合性等价于:a O (b O c)
。
3.5.4 数组
数组是用来存储固定数量的同类型的元素。我们可以声明一个数组变量,如 numbers[100] 来代替直接声明 100 个独立变量 number0,number1,....,number99。
请注意,这和我们常见的计数不一样,我们常见的计数通常是从 1 开始,而计算机中的计数通常是从 0 开始,也就是说,我们认知中的第一个元素的位置其实是 0。
数组基本定义语法为:
type[] arrName = new type[arrSize];
之后就可以通过 arrName[i]
的语法访问数组中的第 i
个位置,[]
运算符的作用就是访问数组下标。
数组也具有字面量,即:{ele1, ele2, ele3}
,它可以被赋给一个数组变量,比如:
int[] arr = {1, 2, 3};
数组初始化之后,是可以直接访问的,它内部的每一个值都是该类型的零值,有关零值,我们之后再详细解释,现在你需要知道的是:
- 对于数值类型,零值是 0;
- 对于布尔类型,零值是
false
; - 对于字符串类型,零值是
""
(空串); - 对于类类型,零值是
null
。
数组具有一个 length
属性,表示其长度,我们可以通过 数组变量.length
的方式获取到。
3.5.4.1 多维数组
多维数组是数组的数组,即数组里保存的元素类型是数组:
type[][] arrName = new type[row][column];
多维数组也有字面量,即:
int[][] arr = {
{1, 2, 3},
{4, 5, 6}
};
可以只访问多维数组的某一行(即一个数组),这意味着在初始化时可以只写 row
而省略 column
,之后再单独对每一行进行初始化。
数组的本质
数组的本质是对象。对象的存储方式是:真实存储对象内容的空间分配在堆上,对象变量分配在栈上,对象变量和对象实际存储空间使用指针对应。 所以对象变量的本质是一个个地址,因此把一个数组变量赋值给另一个数组变量会导致两个数组变量指向同一个数组。
正因为数组是对象,因此我们可以通过数组变量 +
.
运算符的方式访问其长度length
属性。
3.6 类与对象
类由关键字 class
定义,后跟一个类标识符表示类的名称,类名称后必须跟一个类的代码块。
比如:
class A {
}
class
关键字之前还可以添加一个 public
修饰符,表示公共类,公共类和普通类的注意事项我们之前已经说过了。
在类中,我们可以定义方法和字段,甚至可以定义其他类,这些我们称之为类的成员。
类是一种类型,类类型的变量有另一个名字,叫做对象,也叫做这个类的实例,我们可以通过 .
运算符来访问对象(或类)中的成员。
对象要怎么进行赋值呢?我们可以使用 new
操作符 + 类类型名称()
的方式进行对象的初始化:
class A {
}
class Main {
public static void main(String[] args) {
A a = new A();
}
}
其中:
-
类类型名称()
是一个特殊的方法,叫做构造方法(也叫构造器、构造函数),它用于做一个对象的初始化工作。 -
new
操作符用于给对象在内存中分配空间,然后调用构造器完成类的初始化,之后返回一个地址给类变量。
如果没有使用这样的方式初始化对象,那么 Java 将会报一个错误:NullPointerException
(空指针,NPE),如果没有对这个错误做任何处理的话,默认情况下程序将会立即停止,这是一个非常严重的错误,因此在使用对象之前一定要详细的检查该对象变量是否已经有一个实际的对象引用。
一个类的实例还可以指向名为 null
的特殊值,表示该类变量还未经过任何初始化或不引用任何对象,当然,这样的对象参与运算会直接触发 NPE。
有关类和对象的更详细的内容我们会在接下来的章节中深入讲解。
多个源文件的使用
迄今为止,我们编写的程序可能都写在一个源文件中。当类越来越多时,这样会导致一个文件变得很大,难以管理和查看,而且,之后我们会了解到由于访问权限的存在,一个源文件有多个类甚至会存在问题。
最佳实践是:每一个类尽量写在单独的源文件中,Java 在编译时会自动寻找主类所引用的类的源文件进行编译。更重要的是,如果某个源文件发生了变化,Java 还会自动重新编译该源文件,这就好像 Linux 上的 make 工具,你可以理解为 Java 自带一个 make 这样的工具。
3.6.1 字段
字段是定义在类内部的变量,也叫成员变量,指的是对象运行所必须的数据。
字段又分为类字段和普通字段,类字段属于类,而普通字段属于每一个对象。
普通变量和成员变量的区别
普通变量定义在方法内部,字段定义在类内部、方法的外部。
普通变量不赋值不能参与运算,字段不赋值会有默认值(也叫零值)。
各个类型的零值如下:
- 数值/字符类型:0
- 布尔类型:
false
- 字符串类型:
""
(空串)- 类类型:
null
3.6.1.1 普通字段
普通字段就是类中的一个变量,只不过可能会多出访问修饰符。
比如:
class A {
int a;
private double b;
public char c;
protected String d;
}
我们曾经在类上也看到过 public
这样的访问修饰符,有关这个问题我们之后再详细说明。
3.6.1.1 类字段
类字段相比于普通字段来说,多了一个静态修饰符 static
。
比如:
class A {
static int a;
int b;
}
类字段是属于类的,多个对象会共享一个类字段:
class A {
static int a;
int b;
}
class Main {
public static void main(String[] args) {
A o1 = new A();
o1.b = 3;
A o2 = new A();
o2.b = 4;
o1.a = 1;
System.out.println(o1.b); // 3
System.out.println(o1.a); // 1
System.out.println(o2.a); // 1
System.out.println(o2.b); // 4
}
}
正由于类字段是所有对象共享的,所以类字段不需要通过对象来访问,直接通过类就可以访问:
class A {
static int a;
int b;
}
class Main {
public static void main(String[] args) {
A.a = 1;
System.out.println(A.a); // 1
}
}
3.6.2 方法
Java 方法(也叫函数)是语句的集合,它们在一起执行一个功能:
- 方法是解决一类问题的步骤的有序组合
- 方法包含于类或对象中
- 方法在类中声明,在其他地方被引用
方法的优点:
- 使程序变得更简短而清晰。
- 有利于程序维护。
- 可以提高程序开发的效率。
- 提高了代码的重用性。
一般情况下,定义一个方法包含以下语法:
修饰符 返回值类型 方法名(参数类型 参数名) {
方法体
return 返回值;
}
我们的 main
方法就是完全符合的,读者可以尝试对比一下这个格式。
方法之所以也叫函数,是因为它就像数学中的函数一样,只要给它一些参数,它就给你返回一个你想要的结果。
方法包含一个方法头和一个方法体。下面是一个方法的所有部分:
-
修饰符:修饰符,这是可选的,告诉编译器如何调用该方法。定义了该方法的访问类型。
-
返回值类型:方法通常都会返回一个值。
当然,有些方法只是执行所需的操作,但没有返回值,在这种情况下,返回值类型需要使用关键字
void
,表示无返回值。 -
方法名:是方法的实际名称。
-
参数类型:参数像是一个占位符。当方法被调用时,传递值给参数。这个值被称为实参或变量。参数列表是指方法的参数类型、顺序和参数的个数。参数是可选的,方法可以不包含任何参数。
方法名和参数表共同构成方法签名。
-
方法体:方法体包含具体的语句,定义该方法的功能。
比如:
public static int max(int num1, int num2) {
int result;
if (num1 > num2) {
result = num1;
} else {
result = num2;
}
return result;
}
这个方法就是用来求两个数中的最大值,而且,这个方法修饰符包含了一个 static
,意味着它是类方法,属于类,这个 static
的含义和类字段是一样的。
从对象(或类)访问一个方法也是通过 .
运算符,如果有参数就需要在 ()
内填写参数,比如:
class MathUtil {
public static int max(int num1, int num2) {
int result;
if (num1 > num2) {
result = num1;
} else {
result = num2;
}
return result;
}
}
class Main {
public static void main(String[] args) {
MathUtil.max(1, 2);
}
}
访问方法也叫做调用方法。
3.6.2.1 可变参数
方法可以有可变参数,即可以接受任意数量的参数。语法为 返回类型 方法名称(参数类型 ...参数名称)
。
本质上,args
为 type
类型的数组,那么这种语法和直接使用 type[] args
这样一个数组有什么不同呢?
答案是:在调用时,可变参数既可以直接传递多个参数,又可以通过数组传递多个参数;而直接使用数组意味着只能使用数组传递多个参数。比如:
public int add1(int ...nums) {
int result = 0;
for (int num : nums) {
result += num;
}
return result;
}
// 等价于
public int add2(int[] nums) {
int result = 0;
for (int num : nums) {
result += num;
}
return result;
}
// 调用时不同
add1(1, 2, 3); // 可变参数
add2(new int[] {1, 2, 3}); // 需要构造数组
3.6.2.2 隐式参数
看下面的例子:
class Car {
int speed;
int speedUp(int speed) {
speed += speed;
return speed;
}
}
在这个例子中,speedUp
方法的参数名称也叫 speed
,这会和成员变量 speed
冲突吗?答案是会。
事实上,当方法中参数与成员变量重名时,方法参数优先级更高。
为此,需要使用隐式参数 this
。
class Car {
int speed;
int speedUp(int speed) {
this.speed += speed;
return this.speed;
}
}
this
代表当前对象,它就像一个普通的类变量一样,可以通过 .
操作符访问成员变量以及方法。
提示
可以通过在每个方法的第一个参数中声明一个本类变量,变量名为
this
的方式显式接收this
参数。比如:class Car { int speed; int speedUp(Car this, int speed) { this.speed += speed; return this.speed; } }
3.6.2.3 Java 中参数的传递方式
Java 的参数是以值传递的形式传入方法中,而不是引用传递。
以下代码中 Dog dog 的 dog 是一个指针,存储的是对象的地址。在将一个参数传入一个方法时,本质上是将对象的地址以值的方式传递到形参中。
public class Dog {
String name;
Dog(String name) {
this.name = name;
}
String getName() {
return this.name;
}
void setName(String name) {
this.name = name;
}
String getObjectAddress() {
return super.toString();
}
}
在方法中改变对象的字段值会改变原对象该字段值,因为引用的是同一个对象。
class PassByValueExample {
public static void main(String[] args) {
Dog dog = new Dog("A");
func(dog);
System.out.println(dog.getName()); // B
}
private static void func(Dog dog) {
dog.setName("B");
}
}
但是在方法中将指针引用了其它对象,那么此时方法里和方法外的两个指针指向了不同的对象,在一个指针改变其所指向对象的内容对另一个指针所指向的对象没有影响。
public class PassByValueExample {
public static void main(String[] args) {
Dog dog = new Dog("A");
System.out.println(dog.getObjectAddress()); // Dog@4554617c
func(dog);
System.out.println(dog.getObjectAddress()); // Dog@4554617c
System.out.println(dog.getName()); // A
}
private static void func(Dog dog) {
System.out.println(dog.getObjectAddress()); // Dog@4554617c
dog = new Dog("B");
System.out.println(dog.getObjectAddress()); // Dog@74a14482
System.out.println(dog.getName()); // B
}
}
3.6.3 访问权限
如果类中的某些方法、字段并不想暴露出去给别人使用,那么就可以使用访问权限修饰符。
Java 有四种访问权限,如下表所示:
访问权限 | 本类 | 本包的类 | 子类 | 非子类且非本包类 |
---|---|---|---|---|
public | 是 | 是 | 是 | 是 |
protected | 是 | 是 | 是 | 否 |
default | 是 | 是 | 否 | 否 |
private | 是 | 否 | 否 | 否 |
其中,default
并不是访问权限修饰符,它意味着默认,也就是说不加任何访问权限修饰符时就属于这种情况。
关于包和子类,我们将在接下来的章节中详细讲解,现在需要知道的是,迄今为止所写的所有类都在同一个包下。
3.6.4 getter 和 setter
习惯上,我们把类的成员变量都设置为 private
,而通过 setter 和 getter 方法对它进行修改、访问。
比如:
class Person {
private int age;
public void setAge(int age) {
this.age = age;
}
public int getAge() {
return age;
}
}
可能有人要问,getter 和 setter 使得代码量多了许多,为什么要这么做呢?而且,使用 getter 和 setter 不是和直接使用 .
操作符访问成员变量是等价的吗?有关这个问题,我们之后再详细解释。
3.6.5 方法重载
我们在介绍方法时,提到过方法签名的概念:方法名和参数表共同构成方法签名。
签名实际上用在方法重载,所谓重载,指的是一个方法可以具有多个相同的名称,但是签名不同的方法。
既然重载要求方法名称相同,但是签名又要求不同,则重载的区别就在参数上。
示例:
class Car {
private int speed;
public void speedUp() {
speed += 1;
}
public void speedUp(int speed) {
this.speed += speed;
}
}
注意
返回值不在函数签名内,因此返回值不同,其他地方相同的函数不能算作函数重载。
同理,修饰符
static
、final
等也不算在函数签名中,因此也不能算作重载。
3.6.5 构造方法
构造方法(constructor),也叫构造器、构造函数,之前已经了解到了,它在类初始化时被调用。
构造方法有如下特点:
- 方法名和类名相同。
- 通过
new
调用。 - 无返回值类型,也没有返回值。
- 可以有参数。
- 可以重载。
在没有显式声明一个构造函数时,Java 编译器会自动为该类生成一个无参数的构造函数,对其成员变量进行零值初始化。
但是如果有显式的带参数的构造器了,而调用时不提供参数,那么就会报错,提示找不到该函数,因为只要具有一个显式的构造器,Java 编译器就不会生成默认的构造器。
如果一个 final
的常量(但是非 static
)未在声明时被初始化,那么它必须在构造函数中被初始化,且之后不能再被赋值。
提示
假设某个类已有一个构造器,初始化了部分成员变量,现在要编写一个新的构造器初始化全部成员变量,这时就不用重新写这些代码,而是直接调用这个构造器。
在构造器中调用构造器使用
this(params)
的形式调用,其中 params 为参数。
3.6.6 初始化块
初始化块是特殊的代码块,一个类可以包含多个的初始化块,用于初始化类。
如:
class Car {
private int speed;
{
speed = 2;
}
public Car() {
speed += 3;
}
}
当该类初始化完毕后,这个对象的 speed 成员的取值为 5。
初始化块是先于构造方法执行的。
3.6.7 静态初始化块
初始化块还可以添加 static
关键字,这样的块叫做静态初始化块。
如:
class DBDemo {
static {
Class.forName("com.mysql.cj.jdbc.Driver");
}
// ...
}
静态初始化块比初始化块要常见,它只会在类初次被加载到内存时执行一次,一般用于复杂的静态初始化。
静态的特殊性
我们直接接触到了很多次
static
,比如static
的成员变量、static
的函数,这里又涉及到了static
的初始化块,之后我们还会见识到static
的类。那么
static
究竟是什么呢?为什么它这么特殊?首先,
static
从语义上表示静态,它属于类,意味着类被加载到 JVM 后,静态的东西就随着类一起被加载了。这也是为什么通过类名就能调用和访问静态成员。其次,由于类可以生产多个对象,并且类在整个程序的生命周期中只会被加载一次,因此所有对象共享静态成员。那么我们就可以理解为什么静态初始化块只会执行一次了。
想要更多的了解
static
,可以阅读我的 JVM 系列文章。
3.7 流程控制语句
我们之前已经接触到了很多语句:
- 赋值语句:
[type] var = val
- 访问成员语句:
obj.member
- 方法调用语句:
obj.method([param])
这些虽然已经可以帮助我们写一个完整的程序了,但是还不够,如果要写更复杂的程序,接下来要介绍的两种语句必不可少。
3.7.1 条件语句
条件语句包含关键字 if
、else
以及 else if
。
if
语句的语法如下:
if (condition1) {
} else if (condition2) {
} else {
}
condition
一定要是一个能够得到布尔值的表达式。
如果 condition1
的值为 true
,则执行 if
语句中的代码块;否则判断 condition2
,如果 condition2
的值为 true
,则执行 else if
语句块后面的代码,否则执行 else
的代码。
其中,else if
可以有多个,也可以省略 else if
和 else
,也就是只有 if
也是允许的。
3.7.2 循环控制
有时我们希望一条语句能够执行多次,这时就需要用到循环控制。
循环有三大要点:
- 循环结构,循环结构就是要重复执行的代码(段)
- 循环变量,用于控制循环条件
- 循环条件,和循环变量一起控制循环。当循环遍历满足循环条件时,继续循环;否则退出
基本的循环有三种:
for
型:for (循环变量定义; 循环条件; 更新循环变量) {循环体}
do...while
型:do {循环体} while (循环条件)
while
型:while (循环条件) {循环体}
下面的程序使用了这三种循环,作用都是输出 1-10 之间的所有数字:
for (int i = 1; i <= 10; ++i) {
System.out.println(i);
}
// -------------------------------
int i = 1;
do {
System.out.println(i);
i++;
} while (i <= 10)
// -------------------------------
int i = 1;
while (i <= 10) {
System.out.println(i);
i++;
}
3.7.2.1 for-each 循环
对数组来说,如果只是想访问数组中的元素而不想访问其下标,可以使用 for-each 循环的语法。
for-each 循环的语法是:for (type elem : type[] array)
。
比如:
int[][] arr = {
{1, 2, 3},
{4, 5, 6}
};
int sum = 0;
for (int[] row : arr) {
for (int num : row) {
sum += num;
}
}
System.out.println(sum);
3.7.3 中断流程控制
如果在循环(或接下来要介绍的多重选择控制语句)中,想要提前结束流程,则可以使用 break
或 continue
语句。
break
用于直接中断,而 continue
则是跳过本次。
比如上面的例子,当到 8 时,停止输出:
for (int i = 1; i <= 10; ++i) {
if (i == 8) {
break;
}
System.out.println(i);
}
当到 8 时,跳过 8 直接输出之后的数:
for (int i = 1; i <= 10; ++i) {
if (i == 8) {
continue;
}
System.out.println(i);
}
break-label 语句
可以通过 break-label 的语法指定跳到 label 所在的那层循环,比如:
outer: for (int i = 1; i <= 10; ++i) { inner: for (int j = 1; j <= 10; ++j) { if (j == 3) { break outer; } } }
事实上,break-label 的语法可以用于任何语句而不仅仅是循环,但是这样的做法是不提倡的。
continue
也是支持 continue-label 的。
3.7.4 多重选择控制
使用 switch
多重选择控制语句可以代替含有多个 else-if
的语句:
switch (varName) {
case value1:
// xxx
break;
case value2:
// yyy
break;
default:
// zzz
break;
}
注意,如果某个 case
没有 break
,则会一直执行下去,直到碰到一个 break
或执行完毕。
switch
语句中的变量的类型可以是:
char
、byte
、short
、int
的字面值- 枚举常量(枚举会单独介绍)
- 字符串字面值(JDK 7 开始支持)
switch
不支持 long
,是因为 switch
的设计初衷是对那些只有少数几个值的类型进行等值判断,如果值过于复杂,那么还是用 if
比较合适。
4. 管理 Java 代码
4.1 包
Java 使用包将类组织在一个集合中,便于管理和组织代码。
使用包的主要目的是确保类名的唯一性,在 Java 中就有这样的例子,比如 java.sql.Date
和 java.util.Date
。
包名一般是公司的域名的逆序,一般是全小写字母,而类名遵循驼峰命名法,这就可以将类名和包名区分开来,比如 com.baidu.util.Date
。
包中还可以有其他包,比如:com.baidu.util
包还可以包括一个 com.baidu.util.provider
包。事实上这两个包是没有任何关系的,每一个包都是独立的类的集合。
包体现在文件系统上,就是文件夹的区别,比如:
package com.baidu.util;
public class Date {
}
package com.baidu;
import com.baidu.util.Date;
public class Application {
public static void main(String[] args) {
Date date = new Date();
}
}
这里的 package
关键字就是用于声明该类属于哪个包。
体现在文件夹上,就是:
编译时需要注意,要回到项目根目录(这里是和 com 同级别的目录),使用类的路径名进行编译,而使用完全限定名(包名 + 类名)运行:
$> javac com/baidu/Application.java
$> java com.baidu.Application
4.1.1 导入
要使用其他包中的类,除了使用类的完全限定名外,还可以使用导入的方式。
导入使用 import
关键字,用于导入一个类或多个类。
比如 import java.util.List
表示只导入了该包下的 List
这个类;而 import java.util.*
则表示引入了 java.util
包下的所有类。
注意,导入包的所有类不会导入其子包的类。
假设你导入了 java.sql.*
和 java.util.*
,此时你想使用 java.util
的 Date
类,有两种方法可以解决:
-
增加一个单独的导入语句:
import java.sql.*; import java.util.*; import java.util.Date;
-
使用完全限定的类名。
4.1.2 静态导入
在 import
后增加一个 static
关键字可以导入某个类的单个或全部静态成员。比如:
import static java.lang.System.out;
public class Application {
public static void main(String[] args) {
out.println("123");
}
}
4.2 类路径
类路径是所有包含类文件的路径的集合。
类路径可以通过设置操作系统环境变量 CLASSPATH
来配置,也可以通过命令加上 -classpath(或 -cp)
参数来指定。
下面是一个例子:
$> java -classpath c:\classdir;.;c:\archives\xxx.jar MyApplication
c:\classdir;.;c:\archives\xxx.jar
这样的就是一个类路径,其中,.
代表当前目录,;
代表目录之间的分隔符。分隔符由操作系统决定,Linux 上是 :
。
CLASSPATH
通过设置 CLASSPATH 的方式配置类路径的方式已经过时。在早期版本的 JDK 中需要配置 CLASSPATH 以找到 JDK 类库,8 以后的版本已经不需要此配置。
4.2.1 类路径与 java、javac 命令
javac 命令用于编译指定的 Java 源文件,可以是当前目录下的源文件,也可以是子目录下的源文件,如果是子目录下的源文件,就需要使用操作系统中的路径分隔符来编译,这种情况一般发生在包中。
编译完成之后生成的是 .class
文件,这是介于源码和机器码之间的一种 JVM 字节码文件,是有规律的。它可以被 java 命令装载到 JVM 中,如果有 main 函数,就可以执行。
javac 和 java 命令总是会在你的类路径下搜索 Java 源文件以及类文件。唯一不同的是,javac 命令总是会搜索当前目录,而 java 命令只有在类路径中设置有当前目录(用一个 .
表示)时才会查看当前目录。
类路径的作用就是帮助 javac 和 java 命令找到需要的类。
4.3 Jar 包
在包的学习中我们已经了解到,通过包可以很好的组织我们的类。事实上,如果类不在它对应包所对应的文件夹中,那么类将无法工作。平常我们自己使用 IDE 开发,IDE 会管理我们自己编写的类,出现这种错误的情况很少。
当我们使用第三方库时,如果这个库的规模很小,我们大可以把类文件复制到我们的工程路径中,但是库的规模很大的话(比如著名的 Spring),该如何组织呢?
事实上,Java 允许使用格式为 .jar
的压缩文件(本质上是 zip 文件)把类装载进去,在需要时使用即可。这里的使用方式就是把 jar 文件(也叫 jar 包)放置到你的类路径下。
4.3.1 打包
JDK 提供了 jar
命令进行打包,它很类似 Linux 上的 tar
命令,下面是一些选项说明:
选项 | 说明 |
---|---|
c | 创建一个新的 jar 文件并把指定的文件放入其中,如果是目录,会递归的处理。 |
v | 生成详细的输出结果 |
f | 档案文件名。省略时, 基于操作使用 stdin 或 stdout |
i | 建立索引文件加快查找(适用于大型 jar 包) |
m | 将一个清单文件加入到 jar 包中 |
e | 在清单文件中创建一个入口点 |
x | 解压 jar 文件 |
u | 更新 jar 文件 |
4.3.2 清单文件
jar 包含有一个清单文件 MANIFEST.MF
,它被放在 jar 包中的 META-INF 目录下(该目录在打包时自动生成)。
清单文件用于描述 jar 包的一些信息,最小的清单文件可以只有一行:‘
Manifest-Version: 1.0
含义是当前版本是 1.0 版本。清单文件可以包含更多的条目,这些条目可以被组织为多个节,节与节之间用空行隔开。由于一些工具会自动为我们编辑清单文件,因此我们并不关注它的具体内容。
可执行 jar 包
在清单文件中添加一行:
Main-Class: 含有 main 方法的类
那么这个 jar 包就可以被运行,需要使用 java 命令的
-jar
选项。
4.4 文档注释
JDK 提供了一个很好用的工具 javadoc,用于抽取代码中的文档注释,生成 HTML 文件。
文档注释的格式是:
/**
* 这是Java的文档注释
*
* @author wzy
*/
javadoc 会从以下几个地方抽取文档:
- 模块
- 包
- public 的类与接口
- public 和 protected 的字段
- public 和 protected 的构造器以及方法
由于文档注释最后会转换成 HTML,因此可以使用 HTML 的标签,也存在一些特殊语法。
文档注释可以包含标记,标记以 @
开头(或以 {@
开头,}
结尾),比如 @author
可以放在类注释上,说明该类的作者,@since
则是说明从何时开始有该类等等。
javadoc 可以识别的标记如下:
标签 | 描述 |
---|---|
@author | 标识一个类的作者,一般用于类注释 |
{@code ...} | 在省略号处可以键入代码 |
@deprecated | 指名一个过期的类或成员,表明该类或方法不建议使用 |
{@docRoot} | 指明当前文档根目录的路径 |
@exception | 可能抛出异常的说明,一般用于方法注释 |
{@inheritDoc} | 从直接父类继承的注释 |
{@link} | 插入一个到另一个主题的链接 |
{@linkplain} | 插入一个到另一个主题的链接,但是该链接显示纯文本字体 |
@param | 说明一个方法的参数,一般用于方法注释 |
@return | 说明返回值类型,一般用于方法注释,不能出现再构造方法中 |
@see | 指定一个到另一个主题的链接,需要使用 # 分割类名和方法名 |
@serial | 说明一个序列化属性 |
@serialData | 说明通过 writeObject() 和 writeExternal() 方法写的数据 |
@serialField | 说明一个 ObjectStreamField 组件 |
@since | 说明从哪个版本起开始有了这个函数 |
@throws | 和 @exception 标签一样. |
{@value} | 显示常量的值,该常量必须是 static 属性。 |
@version | 指定类的版本,一般用于类注释 |
对两种标签格式的说明:
- @tag 格式的标签(不被
{ }
包围的标签)为块标签,只能在主要描述(类注释中对该类的详细说明为主要描述)后面的标签部分(如果块标签放在主要描述的前面,则生成 API 帮助文档时会检测不到主要描述)。 - {@tag} 格式的标签(由
{ }
包围的标签)为内联标签,可以放在主要描述中的任何位置或块标签的注释中。
比如:
/**
* 测试类
*
* @author wzy
* @version 1.0.0
*/
public class Test{
}
文档注释可以包含大部分的 HTML 标签,甚至是 <img\>
图像标签。使用图像时,需要把图像等外部文件放在 doc-files
文件夹里,javadoc 工具会自动提取这些文件。
下面介绍一些常用的文档注释:
- 类注释。必须放在
import
语句后,类定义之前。 - 方法注释。必须放在方法之前。
- 字段注释。必须放在字段之前,一般来说,只需要对 public 和 protected 的字段说明注释即可,但是不排除需要查看源代码的情况,此时加上注释也是很好的。
- 包注释。包注释需要在每个包中添加一个单独的文件,名为
package-info.java
,它除了能包含 javadoc 之外,不能包含任何其他东西。
使用 javadoc 命令行工具就可以抽取并生成文档了,一般会添加 -d
选项。如:javadoc -d doc com.example.test
,这样就为 com.example.test
生成 javadoc 的 HTML 文件,放在 doc 目录中。