抄录自廖雪峰的java教程
#入门
#Java程序基础
#基本结构
#类和方法
public class Hello {
public static void main(String[] args) {
// 向屏幕输出文本:
System.out.println("Hello, world!");
/* 多行注释开始
注释内容
注释结束 */
}
}
因为Java是面向对象的语言,一个程序的基本单位就是class
,class
是关键字,这里定义的class
名字就是Hello
类名要求:
- 必须以英文字母开头,后接字母,数字和下划线的组合
- 习惯以大写字母开头
public
是访问修饰符,表示该class
是公开的。不写public
,也能正确编译,但是这个类将无法从命令行执行。public
除了可以修饰class
外,也可以修饰方法。
在class
内部,可以定义若干方法。方法定义了一组执行语句,方法内部的代码将会被依次顺序执行。
方法名要求:
- 必须以英文字母开头,后接字母,数字和下划线的组合
- 习惯以小写字母开头
static
是另一个修饰符,它表示静态方法(之后再议)。Java入口程序规定的方法必须是静态方法,方法名必须为main
,括号内的参数必须是String数组。
#注释
Java有3种注释,第一种是单行注释,以双斜线开头,直到这一行的结尾结束:
// 这是注释...
而多行注释以/*
星号开头,以*/
结束,可以有多行:
/*
这是注释
blablabla...
这也是注释
*/
还有一种特殊的多行注释,以/**
开头,以*/
结束,如果有多行,每行通常以星号开头:
/**
* 这种特殊的多行注释需要写在类和方法的定义处,
* 可以用于自动创建文档。
*
*/
public class Hello {
public static void main(String[] args) {
System.out.println("Hello, world!");
}
}
#变量和数据类型
#变量
在Java中,变量必须先定义后使用,在定义变量的时候,可以给它一个初始值。例如:
int x = 1;
#基本数据类型
基本数据类型是CPU可以直接进行运算的类型。Java定义了以下几种基本数据类型:
- 整数类型:byte,short,int,long
- 浮点数类型:float,double
- 字符类型:char
- 布尔类型:boolean
#整型
对于整型类型,Java只定义了带符号的整型,因此,最高位的bit表示符号位(0表示正数,1表示负数)。各种整型能表示的最大范围如下:
- byte:-128 ~ 127
- short: -32768 ~ 32767
- int: -2147483648 ~ 2147483647
- long: -9223372036854775808 ~ 9223372036854775807
不同进制数的表示:
- 16进制:0x
- 8进制:0
- 2进制:0b
例如:
15
=0xf
=017
=0b1111
#浮点型
浮点类型的数就是小数,因为小数用科学计数法表示的时候,小数点是可以“浮动”的,如1234.5可以表示成12.345e10^2^,也可以表示成1.2345e10^3^,所以称为浮点数。
下面是定义浮点数的例子:
float f1 = 3.14f;
float f2 = 3.14e38f;
double d = 1.79e308;
double d2 = -1.79e308;
double d3 = 4.9e-324;
对于float
类型,需要加上f
后缀。否则是double类型。
浮点数可表示的范围非常大,float
类型可最大表示3.4x10^38^,而double
类型可最大表示1.79x10^308^。
#布尔类型
布尔类型boolean
只有true
和false
两个值,布尔类型总是关系运算的计算结果:
boolean b1 = true;
boolean b2 = false;
boolean isGreater = 5 > 3; // 计算结果为true
int age = 12;
boolean isAdult = age >= 18; // 计算结果为false
Java语言对布尔类型的存储并没有做规定,因为理论上存储布尔类型只需要1 bit,但是通常JVM内部会把boolean
表示为4字节整数。
#字符类型
字符类型char
表示一个字符。Java的char
类型除了可表示标准的ASCII外,还可以表示一个Unicode字符:
// 字符类型
public class Main {
public static void main(String[] args) {
char a = 'A';
char zh = '中';
System.out.println(a);
System.out.println(zh);
}
}
注意char
类型使用单引号'
,且仅有一个字符,要和双引号"
的字符串类型区分开。
#引用类型
引用类型最常用的就是String
字符串:
String s = "hello";
引用类型的变量类似于C语言的指针,它内部存储一个“地址”,指向某个对象在内存的位置。
#常量
定义变量的时候,如果加上final
修饰符,这个变量就变成了常量:
final double PI = 3.14; // PI是一个常量
double r = 5.0;
double area = PI * r * r;
PI = 300; // compile error!
常量在定义时进行初始化后就不可再次赋值,再次赋值会导致编译错误。
常量的作用是用有意义的变量名来避免魔术数字(拥有特殊意义的数字),例如,不要在代码中到处写3.14
,而是定义一个常量。
为了和变量区分开来,根据习惯,常量名通常全部大写。
#var关键字
如果想省略变量类型,可以使用var
关键字:
var sb = new StringBuilder();
编译器会根据赋值语句自动推断出变量sb
的类型是StringBuilder
。
#运算
运算方面基本与C完全一致,仅记录一些特殊的点。
#浮点运算溢出
整数运算在除数为0
时会报错,而浮点数运算在除数为0
时,不会报错,但会返回几个特殊值:
NaN
表示Not a NumberInfinity
表示无穷大-Infinity
表示负无穷大
#字符和字符串
仅记录和C的不同之处
#字符串连接
Java的编译器对字符串做了特殊照顾,可以使用+
连接任意字符串和其他数据类型,这样极大地方便了字符串的处理(和py一样)。
如果用+
连接字符串和其他数据类型,会将其他数据类型先自动转型为字符串,再连接。
从Java 13开始,字符串可以用"""..."""
表示多行字符串了(和py一样)。举个例子:
// 多行字符串
public class Main {
public static void main(String[] args) {
String s = """
SELECT * FROM
users
WHERE id > 100
ORDER BY name DESC
""";
System.out.println(s);
}
}
#空值null
引用类型的变量可以指向一个空值null
,它表示不存在,即该变量不指向任何对象。例如:
String s1 = null; // s1是null
String s2 = s1; // s2也是null
String s3 = ""; // s3指向空字符串,不是null
注意要区分空值null
和空字符串""
,空字符串是一个有效的字符串对象,它不等于null
。
#数组
可以使用数组来表示“一组”int
类型。代码如下:
// 数组
public class Main {
public static void main(String[] args) {
// 5位同学的成绩:
int[] ns = new int[] { 68, 79, 91, 85, 62 };
//等同于int[] ns = { 68, 79, 91, 85, 62 };
}
}
定义一个数组类型的变量,使用数组类型“类型[]”,例如,int[]
。和单个基本类型变量不同,数组变量初始化必须使用new int[5]
表示创建一个可容纳5个int
元素的数组。
Java的数组有几个特点:
数组属于引用类型。
数组所有元素初始化为默认值,整型都是
0
,浮点型是0.0
,布尔型是false
;数组一旦创建后,大小就不可改变。
要访问数组中的某一个元素,需要使用索引。数组索引从
0
开始。可以修改数组中的某一个元素,使用赋值语句,例如,
ns[1] = 79;
。可以用
数组变量.length
获取数组大小数组是引用类型,在使用索引访问数组元素时,如果索引超出范围,运行时将报错
也可以在定义数组时直接指定初始化的元素,这样就不必写出数组大小,而是由编译器自动推算数组大小。
#流程控制
#输入和输出
#输出
在前面的代码中,我们总是使用System.out.println()
来向屏幕输出一些内容。
println
是print line的缩写,表示输出并换行。因此,如果输出后不想换行,可以用print()
:
// 输出
public class Main {
public static void main(String[] args) {
System.out.print("A,");
System.out.print("B,");
System.out.print("C.");
System.out.println();
System.out.println("END");
}
}
#格式化输出
省流(?):和C基本一致
如果要把数据显示成我们期望的格式,就需要使用格式化输出的功能。格式化输出使用System.out.printf()
,通过使用占位符%?
,printf()
可以把后面的参数格式化成指定格式:
// 格式化输出
public class Main {
public static void main(String[] args) {
double d = 3.1415926;
System.out.printf("%.2f\n", d); // 显示两位小数3.14
System.out.printf("%.4f\n", d); // 显示4位小数3.1416
}
}
Java的格式化功能提供了多种占位符,可以把各种数据类型“格式化”成指定的字符串:
占位符 | 说明 |
---|---|
%d | 格式化输出整数 |
%x | 格式化输出十六进制整数 |
%f | 格式化输出浮点数 |
%e | 格式化输出科学计数法表示的浮点数 |
%s | 格式化字符串 |
注意,由于%
表示占位符,因此,连续两个%%
表示一个%
字符本身。
#输入
和输出相比,Java的输入就要复杂得多。
我们先看一个从控制台读取一个字符串和一个整数的例子:
import java.util.Scanner;
public class Main {
public static void main(String[] args) {
Scanner scanner = new Scanner(System.in);
// 创建Scanner对象
System.out.print("Input your name: ");
// 打印提示
String name = scanner.nextLine();
// 读取一行输入并获取字符串
System.out.print("Input your age: ");
// 打印提示
int age = scanner.nextInt();
// 读取一行输入并获取整数
System.out.printf("Hi, %s, you are %d\n", name, age);
// 格式化输出
}
}
首先,我们通过import
语句导入java.util.Scanner
。
然后,创建Scanner
对象并传入System.in
。
System.out
代表标准输出流,而System.in
代表标准输入流。直接使用
System.in
读取用户输入虽然是可以的,但需要更复杂的代码,而通过Scanner
就可以简化后续的代码。
有了Scanner
对象后,要读取用户输入的字符串,使用scanner.nextLine()
;要读取用户输入的整数,使用scanner.nextInt()
。Scanner
会自动转换数据类型,因此不必手动转换。
要测试输入,必须从命令行读取用户输入,因此,需要走编译、执行的流程:
$ javac Main.java
执行:
$ java Main
Input your name: Bob ◀── 输入 Bob
Input your age: 12 ◀── 输入 12
Hi, Bob, you are 12 ◀── 输出
根据提示分别输入一个字符串和整数后,我们得到了格式化的输出。
#if条件判断
与C基本一致,仅记录不同之处
#判断引用类型相等
判断引用类型的变量是否相等,==
表示“引用是否相等”,或者说,是否指向同一个对象。例如,下面的两个String类型,它们的内容是相同的,但是,分别指向不同的对象,用==
判断,结果为false
。
要判断引用类型的变量内容是否相等,必须使用equals()
方法:
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1);
System.out.println(s2);
if (s1.equals(s2)) {
System.out.println("s1 equals s2");
} else {
System.out.println("s1 not equals s2");
}
}
}
注意:执行语句s1.equals(s2)
时,如果变量s1
为null
,会报NullPointerException
。要避免NullPointerException
错误,可以利用短路运算符&&
:
// 条件判断
public class Main {
public static void main(String[] args) {
String s1 = null;
if (s1 != null && s1.equals("hello")) {
System.out.println("hello");
}
}
}
#switch多重选择
仅记录Java 12后的新语法与yield。传统语法与C基本一致
#switch表达式
使用switch
时,如果遗漏了break
,就会造成严重的逻辑错误,而且不易在源代码中发现错误。从Java 12开始,switch
语句升级为更简洁的表达式语法,保证只有一种路径会被执行,并且不需要break
语句:
// switch
public class Main {
public static void main(String[] args) {
String fruit = "apple";
switch (fruit) {
case "apple" -> System.out.println("Selected apple");
case "pear" -> System.out.println("Selected pear");
case "mango" -> {
System.out.println("Selected mango");
System.out.println("Good choice!");
}
default -> System.out.println("No fruit selected");
}
}
}
注意新语法使用->
,如果有多条语句,需要用{}
括起来。不要写break
语句,因为新语法只会执行匹配的语句,没有穿透效应。
#yield
如果需要复杂的语句,我们也可以写很多语句,放到{...}
里,然后,用yield
返回一个值作为switch
语句的返回值:
// yield
public class Main {
public static void main(String[] args) {
String fruit = "orange";
int opt = switch (fruit) {
case "apple" -> 1;
case "pear", "mango" -> 2;
default -> {
int code = fruit.hashCode();
yield code; // switch语句返回值
}
};
System.out.println("opt = " + opt);
}
}
#循环语句
仅记录部分和C不同的部分
#for each循环
Java提供了另一种for each
循环,它可以更简单地遍历数组:
// for each
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 4, 9, 16, 25 };
for (int n : ns) {
System.out.println(n);
}
}
}
和for
循环相比,for each
循环的变量n不再是计数器,而是直接对应到数组的每个元素。for each
循环的写法也更简洁。但是,for each
循环无法指定遍历顺序,也无法获取数组的索引。
for each
循环能够遍历所有“可迭代”的数据类型。
#数组操作
#遍历
除了用for循环遍历数组外,Java标准库还提供了Arrays.toString()
,可以快速打印数组内容:
// 遍历数组
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] ns = { 1, 1, 2, 3, 5, 8 };
System.out.println(Arrays.toString(ns));
}
}
#排序
Java的标准库已经内置了排序功能,我们只需要调用JDK提供的Arrays.sort()
就可以排序(默认升序):
// 排序
import java.util.Arrays;
public class Main {
public static void main(String[] args) {
int[] ns = { 28, 12, 89, 73, 65, 18, 96, 50, 8, 36 };
Arrays.sort(ns);
System.out.println(Arrays.toString(ns));
}
}
#命令行参数
Java程序的入口是main
方法,而main
方法可以接受一个命令行参数,它是一个String[]
数组。
这个命令行参数由JVM接收用户输入并传给main
方法:
public class Main {
public static void main(String[] args) {
for (String arg : args) {
System.out.println(arg);
}
}
}
我们可以利用接收到的命令行参数,根据不同的参数执行不同的代码。例如,实现一个-version
参数,打印程序版本号:
public class Main {
public static void main(String[] args) {
for (String arg : args) {
if ("-version".equals(arg)) {
System.out.println("v 1.0");
break;
}
}
}
}
上面这个程序必须在命令行执行,我们先编译它:
$ javac Main.java
然后,执行的时候,给它传递一个-version
参数:
$ java Main -version
v 1.0
这样,程序就可以根据传入的命令行参数,作出不同的响应。
#面向对象
#面向对象基础
#方法
一个class
可以包含多个field
,例如,我们给Person
类就定义了两个field
:
class Person {
public String name;
public int age;
}
显然,直接操作field
,容易造成逻辑混乱。为了避免外部代码直接去访问field
,我们可以用private
修饰field
,拒绝外部访问:
class Person {
private String name;
private int age;
}
我们需要使用方法(method
)来让外部代码可以间接修改field
:
// private field
public class Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setName("Xiao Ming"); // 设置name
ming.setAge(12); // 设置age
System.out.println(ming.getName() + ", " + ming.getAge());
}
}
class Person {
private String name;
private int age;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
public int getAge() {
return this.age;
}
public void setAge(int age) {
if (age < 0 || age > 100) {
throw new IllegalArgumentException("invalid age value");
}
this.age = age;
}
}
虽然外部代码不能直接修改private
字段,但是,外部代码可以调用方法setName()
和setAge()
来间接修改private
字段。在方法内部,我们就有机会检查参数对不对。
一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。
调用方法的语法是实例变量.方法名(参数);
。
#定义方法
定义方法的语法是:
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
#private方法
和private
字段一样,private
方法不允许外部调用,那我们定义private
方法有什么用?
定义private
方法的理由是内部方法是可以调用private
方法的。
#this变量
在方法内部,可以使用一个隐含的变量this
,它始终指向当前实例。因此,通过this.field
就可以访问当前实例的字段。
#可变参数
可变参数用类型...
定义,可变参数相当于数组类型:
class Group {
private String[] names;
public void setNames(String... names) {
this.names = names;
}
}
上面的setNames()
就定义了一个可变参数。调用时,可以这么写:
Group g = new Group();
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
g.setNames("Xiao Ming", "Xiao Hong"); // 传入2个String
g.setNames("Xiao Ming"); // 传入1个String
g.setNames(); // 传入0个String
完全可以把可变参数改写为String[]
类型:
class Group {
private String[] names;
public void setNames(String[] names) {
this.names = names;
}
}
但是,调用方需要自己先构造String[]
,比较麻烦。所以可以直接用...
表达式。
#构造方法
创建实例的时候,实际上是通过构造方法来初始化实例的。我们先来定义一个构造方法,能在创建Person
实例的时候,一次性传入name
和age
,完成初始化:
// 构造方法
public class Main {
public static void main(String[] args) {
Person p = new Person("Xiao Ming", 15);
System.out.println(p.getName());
System.out.println(p.getAge());
}
}
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
}
构造方法有以下特点:
- 构造方法的名称就是类名。
- 构造方法的参数没有限制,在方法内部,也可以编写任意语句。
- 构造方法没有返回值(也没有
void
)。 - 调用构造方法,必须用
new
操作符。创建实例的同时会调用构造方法。
#默认构造方法
果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样:
class Person {
public Person() {
}
}
如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来:
public Person()
{
}
public Person(String name, int age)
{
this.name = name;
this.age = age;
}
没有在构造方法中初始化字段时,引用类型的字段默认是null
,数值类型的字段用默认值,int
类型默认值是0
,布尔类型默认值是false
。
当我们对字段进行初始化,又在构造方法中对字段进行初始化时,字段的值根据构造方法的代码确定。
#多个构造方法
可以定义多个构造方法,在通过new
操作符调用的时候,编译器通过构造方法的参数数量、位置和类型自动区分(就像之前,定义一个没有参数,一个有参数的构造方法)。
#方法重载
在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。例如,在Hello
类中,定义多个hello()
方法:
class Hello {
public void hello() {
System.out.println("Hello, world!");
}
public void hello(String name) {
System.out.println("Hello, " + name + "!");
}
public void hello(String name, int age) {
if (age < 18) {
System.out.println("Hi, " + name + "!");
} else {
System.out.println("Hello, " + name + "!");
}
}
}
这种方法名相同,但各自的参数不同,称为方法重载(Overload)。要注意方法重载的返回值类型通常都是相同的。方法重载的目的是,功能类似的方法使用同一名字,更容易记住,调用起来更简单。
例如,String
类提供了多个重载方法indexOf()
,可以查找子串:
int indexOf(int ch)
:根据字符的Unicode码查找;int indexOf(String str)
:根据字符串查找;int indexOf(int ch, int fromIndex)
:根据字符查找,但指定起始位置;int indexOf(String str, int fromIndex)
根据字符串查找,但指定起始位置。
#继承
继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。
Java使用extends
关键字来实现继承:
class Person {
private String name;
private int age;
public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}
class Student extends Person {
// 不要重复name和age字段/方法,
// 只需要定义新增score字段/方法:
private int score;
public int getScore() { … }
public void setScore(int score) { … }
}
可见,通过继承,Student
只需要编写额外的功能,不再需要重复代码。
子类自动获得了父类的所有字段,严禁定义与父类重名的字段!
我们把被继承的类称为超类,父类,基类,把继承其他类的类称作其子类,扩展类。
#继承树
在Java中,没有明确写extends
的类,编译器会自动加上extends Object
。所以,任何类,除了Object
,都会继承自某个类。

Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object
特殊,它没有父类。
#protected
继承有个特点,就是子类无法访问父类的private
字段或者private
方法,这使得继承的作用被削弱了。
如果我们希望子类可以访问父类的字段,我们需要把private
改为protected
。protected
关键字可以把字段和方法的访问权限控制在继承树内部,一个protected
字段和方法可以被其子类,以及子类的子类所访问。
#super❀
super
关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName
。例如:
class Student extends Person {
public String hello() {
return "Hello, " + super.name;
}
}
实际上,这里使用super.name
,或者this.name
,或者name
,效果都是一样的。编译器会自动定位到父类的name
字段。
但是,在某些时候,就必须使用super
。我们来看一个例子:
// super
public class Main {
public static void main(String[] args) {
Student s = new Student("Xiao Ming", 12, 89);
}
}
class Person {
protected String name;
protected int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
this.score = score;
}
}
运行上面的代码,会得到一个编译错误,大意是在Student
的构造方法中,无法调用Person
的构造方法。
这是因为在Java中,任何class
的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,*编译器会帮我们自动加一句super();
*,所以,Student
类的构造方法实际上是这样:
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
super(); // 自动调用父类的构造方法
this.score = score;
}
}
但是,Person
类并没有无参数的构造方法,因此,编译失败。
解决方法是调用Person
类存在的某个构造方法。例如:
class Student extends Person {
protected int score;
public Student(String name, int age, int score) {
super(name, age); // 调用父类的构造方法Person(String, int)
this.score = score;
}
}
这样就可以正常编译了!
本质地说,即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。如果父类没有默认的构造方法,子类就必须显式调用super()
并给出参数以便让编译器定位到父类的一个合适的构造方法。
比较好的解决方法就是在子类的构造方式里写super
,这样也能明确需要继承的字段,同时不易产生报错。
#阻止继承
正常情况下,只要某个class没有final
修饰符,那么任何类都可以从该class继承。
从Java 15开始,允许使用sealed
修饰class,并通过permits
明确写出能够从该class继承的子类名称。
例如,定义一个Shape
类:
public sealed class Shape permits Rect, Circle, Triangle {
...
}
上述Shape
类就是一个sealed
类,它只允许指定的3个类继承它。
- final:不允许继承该类。
- sealed+permits:仅允许permits的类继承该类。
#向上转型
如果Student
是从Person
继承下来的,那么,一个引用类型为Person
的变量能指向Student
类型的实例。
Person p = new Student();
这是因为Student
继承自Person
,因此,它拥有Person
的全部功能。Person
类型的变量,如果指向Student
类型的实例,对它进行操作,是没有问题的。
这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型。
由此我们可以知道,引用变量的声明类型和实际类型可能是不一样的。
#向下转型
如果把一个父类类型强制转型为子类类型,就是向下转型。例如:
Person p1 = new Student(); // upcasting(向上转型), ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
Student s2 = (Student) p2; // runtime error! ClassCastException!
把p2
转型为Student
会失败,因为p2
的实际类型是Person
,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。因此,向下转型很可能会失败。失败的时候,Java虚拟机会报ClassCastException
。
为了避免向下转型出错,Java提供了instanceof
操作符,可以先判断一个实例究竟是不是某种类型。instanceof
实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null
,那么对任何instanceof
的判断都为false
。
从Java 14开始,判断instanceof
后,可以直接转型为指定变量,避免再次强制转型。例如,对于以下代码:
Object obj = "hello";
if (obj instanceof String) {
String s = (String) obj;
System.out.println(s.toUpperCase());
}
可以改写如下:
// instanceof variable:
public class Main {
public static void main(String[] args) {
Object obj = "hello";
if (obj instanceof String s) {
// 可以直接使用变量s:
System.out.println(s.toUpperCase());
}
}
}
这种使用instanceof
的写法更加简洁。
#多态
#覆写与动态调用
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。
方法声明的两个组件构成了方法签名:方法的名称和参数类型。
例如,这里是一个典型的方法声明:public double calculateAnswer(double wingSpan, int numberOfEngines, double length, double grossTons) { //do the calculation here }
上面方法的签名是:
calculateAnswer(double, int, double, double)
例如,在Person
类中,定义run()
方法:
class Person {
public void run() {
System.out.println("Person.run");
}
}
在子类Student
中,覆写这个run()
方法:
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
如果方法签名不同,就是Overload
。Overload
方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override
。
加上@Override
可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错,但是@Override
不是必需的。
在上一节中,我们已经知道,引用变量的声明类型可能与其实际类型不符,例如:
Person p = new Student();
现在,如果子类覆写了父类的方法:
// override
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run(); // 应该打印Person.run还是Student.run?
}
}
class Person {
public void run() {
System.out.println("Person.run");
}
}
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
那么,一个实际类型为Student
,引用类型为Person
的变量,调用其run()
方法,调用的是Person
还是Student
的run()
方法?
运行一下上面的代码就可以知道,实际上调用的方法是Student
的run()
方法。因此可得出结论:
Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
这个非常重要的特性在面向对象编程中称之为多态(Polymorphic)。
#多态
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。
多态的特性是运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。这种不确定性的方法调用,究竟有什么作用?
假设我们定义一种收入,需要给它报税,那么先定义一个Income
类。对于工资收入,可以减去一个基数,那么我们可以从Income
派生出SalaryIncome
,并覆写getTax()
。如果你享受国务院特殊津贴,那么按照规定,可以全部免税:
现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税。可以这么写:
public class Main {
public static void main(String[] args) {
// 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税:
Income[] incomes = new Income[] {
new Income(3000),
new Salary(7500),
new StateCouncilSpecialAllowance(15000)
};
System.out.println(totalTax(incomes));
}
public static double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
}
class Income {
protected double income;
public Income(double income) {
this.income = income;
}
public double getTax() {
return income * 0.1; // 税率10%
}
}
class Salary extends Income { //仅工资税收时
public Salary(double income) {
super(income);
}
@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}
class StateCouncilSpecialAllowance extends Income { //享受津贴时
public StateCouncilSpecialAllowance(double income) {
super(income);
}
@Override
public double getTax() {
return 0;
}
}
利用多态,totalTax()
方法只需要和Income
打交道,它完全不需要知道Salary
和StateCouncilSpecialAllowance
的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从Income
派生,然后正确覆写getTax()
方法就可以。把新的类型传入totalTax()
,不需要修改任何代码。
可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
#覆写Object方法
因为所有的class
最终都继承自Object
,而Object
定义了几个重要的方法:
toString()
:把instance输出为String
;equals()
:判断两个instance是否逻辑相等;hashCode()
:计算一个instance的哈希值。
在必要的情况下,我们可以覆写Object
的这几个方法。
#调用super
在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super
来调用。例如:
class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}
class Student extends Person {
@Override
public String hello() {
// 调用父类的hello()方法:
return super.hello() + "!";
}
}
#final
继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为final
。用final
修饰的方法不能被Override
:
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}
class Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}
#抽象类
#抽象类与抽象方法
如果一个class
定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用abstract
修饰。
因为无法执行抽象方法,因此这个类也必须声明为抽象类(abstract class)。使用abstract
修饰的类就是抽象类。我们无法实例化一个抽象类。
因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
例如,Person
类定义了抽象方法run()
,那么,在实现子类Student
的时候,就必须覆写run()
方法:
// abstract class
public class Main {
public static void main(String[] args) {
Person p = new Student();
p.run();
}
}
abstract class Person {
public abstract void run();
}
class Student extends Person {
@Override
public void run() {
System.out.println("Student.run");
}
}
#接口
#interface和implements
在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。
如果一个抽象类没有字段,所有方法全部都是抽象方法,就可以把该抽象类改写为接口:interface
。在Java中,使用interface
可以声明一个接口:
interface Person {
void run();
String getName();
}
/*等同于:
abstract class Person {
public abstract void run();
public abstract String getName();
}
*/
所谓interface
,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract
的,所以不需要写这两个修饰符。
当一个具体的class
去实现一个interface
时,需要使用implements
关键字。例如:
class Student implements Person {
private String name;
public Student(String name) {
this.name = name;
}
@Override
public void run() {
System.out.println(this.name + " run");
}
@Override
public String getName() {
return this.name;
}
}
我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface
,例如:
class Student implements Person, Hello { // 实现了两个interface
...
}
#接口继承
一个interface
可以继承自另一个interface
。interface
继承自interface
使用extends
,它相当于扩展了接口的方法。例如:
interface Hello {
void hello();
}
interface Person extends Hello {
void run();
String getName();
}
此时,Person
接口继承自Hello
接口,因此,Person
接口现在实际上有3个抽象方法签名,其中一个来自继承的Hello
接口。
#default方法
在接口中,可以定义default
方法。例如,把Person
接口的run()
方法改为default
方法:
// interface
public class Main {
public static void main(String[] args) {
Person p = new Student("Xiao Ming");
p.run();
}
}
interface Person {
String getName();
default void run() {
System.out.println(getName() + " run");
}
}
class Student implements Person {
private String name;
public Student(String name) {
this.name = name;
}
public String getName() {
return this.name;
}
}
实现类可以不必覆写default
方法。default
方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default
方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
default
方法和抽象类的普通方法是有所不同的。因为interface
没有字段,default
方法无法访问字段,而抽象类的普通方法可以访问实例字段。
#静态字段和静态方法
#静态字段
在一个class
中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。
还有一种字段,是用static
修饰的字段,称为静态字段。虽然实例可以访问静态字段,但是它们指向的其实都是Person class
的静态字段。所以,所有实例共享一个静态字段。如下例:
// static field
public class Main {
public static void main(String[] args) {
Person ming = new Person("Xiao Ming", 12);
Person hong = new Person("Xiao Hong", 15);
ming.number = 88;
System.out.println(hong.number);
hong.number = 99;
System.out.println(ming.number);
}
}
class Person {
public String name;
public int age;
public static int number;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
}
//运行后不能论哪个实例调用number,其值都是99
静态字段并不属于实例。实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段
来访问静态对象。可以把静态字段理解为描述class
本身的字段。
#静态方法
用static
修饰的方法称为静态方法。
调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:
// static method
public class Main {
public static void main(String[] args) {
Person.setNumber(99);
System.out.println(Person.number);
}
}
class Person {
public static int number;
public static void setNumber(int value) {
number = value;
}
}
因为静态方法属于class
而不属于实例,因此,静态方法内部,无法访问this
变量,也无法访问实例字段,它只能访问静态字段。
通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。但通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。
Java程序的入口
main()
也是静态方法。
#接口的静态字段
因为interface
是一个纯抽象类,所以它不能定义实例字段。但是,interface
是可以有静态字段的,并且静态字段必须为final
类型:
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}
实际上,因为interface
的字段只能是public static final
类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:
public interface Person {
// 编译器会自动加上public static final:
int MALE = 1;
int FEMALE = 2;
}
#包
在Java中,我们使用package
来解决名字冲突。Java定义了一种名字空间,称之为包(package)。一个类总是属于某个包,类名(比如Person
)只是一个简写,真正的完整类名是包名.类名
。例如,JDK的Arrays
类存放在包java.util
下面,因此,完整类名是java.util.Arrays
。
在定义class
的时候,我们需要在第一行声明这个class
属于哪个包。比如小明的Person.java
文件:
package ming; // 申明包名ming
public class Person {
}
在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。包可以是多层结构,用.
隔开。例如:java.util
。
要注意包没有父子关系,java.util和java.util.zip是不同的包,两者没有任何继承关系。
没有定义包名的class
,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。
我们还需要按照包结构把上面的Java文件组织起来。假设以package_sample
作为根目录,src
作为源码目录,那么所有文件结构如下图,即所有Java文件对应的目录层次要和包的层次一致。
package_sample
└─ src
├─ hong
│ └─ Person.java
│ ming
│ └─ Person.java
└─ mr
└─ jun
└─ Arrays.java
编译后的.class
文件也需要按照包结构存放。这样的组织是有必要的,为之后导入其他包打下基础,使导入更加方便清晰。
#包作用域
位于同一个包的类,可以访问包作用域的字段和方法。不用public
、protected
、private
修饰的字段和方法就是包作用域。例如,Person
类定义在hello
包下面,Main
类也定义在hello
包下面
package hello;
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}
public class Person {
void hello() {
System.out.println("Hello!");
}
}
#import
在一个class
中,我们总会引用其他的class
。例如,小明的ming.Person
类,如果要引用小军的mr.jun.Arrays
类,有三种写法:
第一种,直接写出完整类名。然而很多类名写起来很长,这显然不方便。
第二种写法是用
import
语句,导入小军的Arrays
,然后写简单类名:
// Person.java
package ming;
// 导入完整类名:
import mr.jun.Arrays;
public class Person {
public void run() {
// 写简单类名: Arrays
Arrays arrays = new Arrays();
}
}
在写import
的时候,可以使用*
,表示把这个包下面的所有class
都导入进来(*但不包括子包的class
*),如下。
// Person.java
package ming;
// 导入mr.jun包的所有class:
import mr.jun.*;
但我们一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays
类属于哪个包。
- 还有一种
import static
的语法,它可以导入一个类的静态字段和静态方法。这个方法很少使用。
package main;
// 导入System类的所有静态字段和静态方法:
import static java.lang.System.*;
Java编译器最终编译出的.class
文件只使用完整类名,因此,在代码中,当编译器遇到一个class
名称时:
- 如果是完整类名,就直接根据完整类名查找这个
class
; - 如果是简单类名,按下面的顺序依次查找:
- 查找当前
package
是否存在这个class
; - 查找
import
的包是否包含这个class
; - 查找
java.lang
包是否包含这个class
。
- 查找当前
在读反编译出来的代码时,这是个不错的策略。
编写class的时候,编译器会自动帮我们做两个import动作:
- 默认自动
import
当前package
的其他class
; - 默认自动
import java.lang.*
。
注意,如果有两个
class
名称相同,例如,mr.jun.Arrays
和java.util.Arrays
,那么只能import
其中一个,另一个必须写完整类名。
#编译与运行
假设我们创建了如下的目录结构:
work
├── bin
└── src
└── com
└── itranswarp
├── sample
│ └── Main.java
└── world
└── Person.java
其中,bin
目录用于存放编译后的class
文件,src
目录按包结构存放Java源码,我们怎么一次性编译这些Java源码呢?
在linux中,编译src
目录下的所有Java文件:
$ javac -d ./bin src/**/*.java
命令行-d
指定输出的class
文件存放bin
目录,后面的参数src/**/*.java
表示src
目录下的所有.java
文件,包括任意深度的子目录。
注意:Windows不支持
**
这种搜索全部子目录的做法,所以在Windows下编译必须依次列出所有.java
文件:C:\work> javac -d bin src\com\itranswarp\sample\Main.java src\com\itranswarp\world\Persion.java
#作用域
#public
定义为public
的class
、interface
可以被其他任何类访问,前提是首先有访问class
的权限(即在同个包作用域内)。
#private
private
访问权限被限定在class
的内部,而且与方法声明顺序无关。推荐把private
方法放到后面,因为public
方法定义了类对外提供的功能,阅读代码的时候,应该先关注public
方法。
由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private
的权限:
// private
public class Main {
public static void main(String[] args) {
Inner i = new Inner();
i.hi();
}
// private方法:
private static void hello() {
System.out.println("private hello!");
}
// 静态内部类:
static class Inner {
public void hi() {
Main.hello();
}
}
}
定义在一个class
内部的class
称为嵌套类(nested class
),Java支持好几种嵌套类。
#protected
protected
作用于继承关系。定义为protected
的字段和方法可以被子类访问,以及子类的子类。
#package
最后,包作用域是指一个类允许访问同一个package
的没有public
、private
修饰的class
,以及没有public
、protected
、private
修饰的字段和方法。
#局部变量
在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。
package abc;
public class Hello {
void hi(String name) { // 1
String s = name.toLowerCase(); // 2
int len = s.length(); // 3
if (len < 10) { // 4
int p = 10 - len; // 5
for (int i=0; i<10; i++) { // 6
System.out.println(); // 7
} // 8
} // 9
} // 10
}
根据以上代码,可知:
- 方法参数name是局部变量,它的作用域是整个方法,即1 ~ 10;
- 变量s的作用域是定义处到方法结束,即2 ~ 10;
- 变量len的作用域是定义处到方法结束,即3 ~ 10;
- 变量p的作用域是定义处到if块结束,即5 ~ 9;
- 变量i的作用域是for循环,即6 ~ 8。
使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。
#final
final
与访问权限不冲突,它有很多作用。
- 用
final
修饰class
可以阻止被继承 - 用
final
修饰method
可以阻止被子类覆写 - 用
final
修饰field(字段)
可以阻止被重新赋值 - 用
final
修饰局部变量可以阻止被重新赋值
#注意事项
一个.java
文件只能包含一个public
类,但可以包含多个非public
类。如果有public
类,文件名必须和public
类的名字相同。
#内部类
在Java程序中,通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系:
java.lang
├── Math
├── Runnable
├── String
└── ...
还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种。
#Inner Class
如果一个类定义在另一个类的内部,这个类就是Inner Class:
class Outer {
class Inner {
// 定义了一个Inner Class
}
}
上述定义的Outer
是一个普通类,而Inner
是一个Inner Class,它与普通类最大的不同就是Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例:
// inner class
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested"); // 实例化一个Outer
Outer.Inner inner = outer.new Inner(); // 实例化一个Inner
inner.hello();
}
}
class Outer {
private String name;
Outer(String name) {
this.name = name;
}
class Inner {
void hello() {
System.out.println("Hello, " + Outer.this.name);
}
}
}
观察上述代码,要实例化一个Inner
,我们必须首先创建一个Outer
的实例,然后,调用Outer
实例的new
来创建Inner
实例:
Outer.Inner inner = outer.new Inner();
这是因为Inner Class除了有一个this
指向它自己,还隐含地持有一个Outer Class实例,可以用Outer.this
访问这个实例。所以,实例化一个Inner Class不能脱离Outer实例。
Inner Class和普通Class相比,除了能引用Outer实例外,还可以修改Outer Class的private
字段,因为Inner Class的作用域在Outer Class内部,所以能访问Outer Class的private
字段和方法。
观察Java编译器编译后的.class
文件可以发现,Outer
类被编译为Outer.class
,而Inner
类被编译为Outer$Inner.class
。
#Anonymous Class
还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义。示例代码如下:
// Anonymous Class
public class Main {
public static void main(String[] args) {
Outer outer = new Outer("Nested");
outer.asyncHello();
}
}
class Outer {
private String name;
Outer(String name) {
this.name = name;
}
void asyncHello() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello, " + Outer.this.name);
}
};
new Thread(r).start();
}
}
匿名类使我们能够在代码中创建一次性的类实例,通常用于实现接口或继承类,而不需要显式定义类。匿名类常用于需要短期实现某个接口、或者处理简单逻辑的场景,这样可以避免为了使用某个接口功能而频繁创建新类。
#Static Nested Class
最后一种内部类和Inner Class类似,但是使用static
修饰,称为静态内部类(Static Nested Class):
// Static Nested Class
public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}
class Outer {
private static String NAME = "OUTER";
private String name;
Outer(String name) {
this.name = name;
}
static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}
用static
修饰的内部类和Inner Class有很大的不同,它不再依附于Outer
的实例,而是一个完全独立的类,因此无法引用Outer.this
,但它可以访问Outer
的private
静态字段和静态方法。
#classpath和jar
#classpath
classpath
是JVM用到的一个环境变量,它用来指示JVM如何搜索class
。
因为Java是编译型语言,源码文件是.java
,而编译后的.class
文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello
的类,应该去哪搜索对应的Hello.class
文件。
所以,classpath
就是一组目录的集合,它设置的搜索路径与操作系统相关。例如,在Windows系统上,用;
分隔,带空格的目录用""
括起来,可能长这样:
C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin"
在Linux系统上,用:
分隔,可能长这样:
/usr/shared:/usr/local/bin:/home/liaoxuefeng/bin
现在我们假设classpath
是.;C:\work\project1\bin;C:\shared
,当JVM在加载abc.xyz.Hello
这个类时,会依次查找:
- .\abc\xyz\Hello.class
- C:\work\project1\bin\abc\xyz\Hello.class
- C:\shared\abc\xyz\Hello.class
classpath
的设定方法有两种:
在系统环境变量中设置
classpath
环境变量,不推荐;在启动JVM时设置
classpath
变量,推荐。
在系统环境变量中设置classpath
会污染整个系统环境。在启动JVM时设置classpath
才是推荐的做法。实际上就是给java
命令传入-classpath(-cp)
参数。
java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello
没有设置系统环境变量,也没有传入-cp
参数,那么JVM默认的classpath
为.(当前目录)
。
在java5中,sun公司改进了JDK设计,JRE会自动搜索当前路径下的jar包,并自动加载dt.jar和tools.jar。所以从Java5开始,就不必再设置CLASSPATH环境变量了。
#jar包
如果有很多.class
文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。
jar包就是用来干这个事的,它可以把package
组织的目录层级,以及各个目录下的所有文件(包括.class
文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。
jar包实际上就是一个zip格式的压缩文件,而jar包相当于目录。如果我们要执行一个jar包的class
,就可以把jar包放到classpath
中:
java -cp ./hello.jar abc.xyz.Hello
这样JVM会自动在hello.jar
文件里去搜索某个类。
创建jar包的方式很简单,将目录压缩成zip,再把后缀改成jar就好了。要注意里面的目录结构和之后运行时保持配对。
也可以使用jar命令行方法打包,下面是jar命令行的使用方法:
jar {c t x u f }[ v m e 0 M i ][-C 目录] 文件名 …
- -c 创建一个jar包
- -t 显示jar中的内容列表
- -x 解压jar包
- -u 添加文件到jar包中
- -f 指定jar包的文件名
- -v 生成详细的报造,并输出至标准设备
- -m 指定MANIFEST.MF文件
- -o 产生jar包时不对其中的内容进行压缩处理
- -M 不产生MANIFEST.MF。这个参数相当于忽略掉-m参数的设置
- -i 为指定的jar文件创建索引文件
- -C 表示转到相应的目录下执行jar命令,相当于cd到那个目录,然后不带-C执行jar命令
摘录自jar命令的用法详解
jar包还可以包含一个特殊的/META-INF/MANIFEST.MF
文件,MANIFEST.MF
是纯文本,可以指定Main-Class
和其它信息。JVM会自动读取这个MANIFEST.MF
文件,如果存在Main-Class
,我们就不必在命令行指定启动的类名,而是用更方便的命令:
java -jar hello.jar
在大型项目中,不可能手动编写
MANIFEST.MF
文件,再手动创建jar包。Java社区提供了大量的开源构建工具,例如Maven,可以非常方便地创建jar包。
#模块
#什么是模块
从Java 9开始,JDK又引入了模块(Module)。主要是为了解决“依赖”这个问题。如果a.jar
必须依赖另一个b.jar
才能运行,那我们应该给a.jar
加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar
,这种自带“依赖关系”的class容器就是模块。
从Java 9开始,原有的Java标准库已经由一个单一巨大的rt.jar
分拆成了几十个模块,这些模块以.jmod
扩展名标识,可以在$JAVA_HOME/jmods
目录下找到它们:
- java.base.jmod
- java.compiler.jmod
- java.datatransfer.jmod
- java.desktop.jmod
- …
这些.jmod
文件每一个都是一个模块,模块名就是文件名。模块之间的依赖关系已经被写入到模块内的module-info.class
文件了。所有的模块都直接或间接地依赖java.base
模块,只有java.base
模块不依赖任何模块,它可以被看作是“根模块”。
把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本。
#编写模块
首先,创建模块和原有的创建Java项目是完全一样的,以oop-module
工程为例,它的目录结构如下:
oop-module
├── bin
├── build.sh
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java
其中,bin
目录存放编译后的class文件,src
目录存放源码,按包名的目录结构存放,仅仅在src
目录下多了一个module-info.java
这个文件,这就是模块的描述文件。在这个模块中,它长这样:
module hello.world {
requires java.base; // 可不写,任何模块都会自动引入java.base
requires java.xml;
}
其中,module
是关键字,后面的hello.world
是模块的名称,它的命名规范与包一致。花括号的requires xxx;
表示这个模块需要引用的其他模块名。除了java.base
可以被自动引入外,这里我们引入了一个java.xml
的模块。
当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java
代码如下:
package com.itranswarp.sample;
// 必须引入java.xml模块后才能使用其中的类:
import javax.xml.XMLConstants;
public class Main {
public static void main(String[] args) {
Greeting g = new Greeting();
System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
}
}
接下来我们用JDK提供的命令行工具来编译并创建模块。
首先,我们把工作目录切换到oop-module
,在当前目录下编译所有的.java
文件,并存放到bin
目录下,命令如下:
$ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java
如果编译成功,现在项目结构如下:
oop-module
├── bin
│ ├── com
│ │ └── itranswarp
│ │ └── sample
│ │ ├── Greeting.class
│ │ └── Main.class
│ └── module-info.class
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java
注意到src
目录下的module-info.java
被编译到bin
目录下的module-info.class
。
下一步,我们需要把bin目录下的所有class文件先打包成jar,在打包的时候,注意传入--main-class
参数,让这个jar包能自己定位main
方法所在的类:
$ jar -cf hello.jar --main-class com.itranswarp.sample.Main -C bin .
--main-class
指定main类
-C bin .
表示将bin
目录下的所有文件和子目录都包含在 JAR 文件中。
现在我们就在当前目录下得到了hello.jar
这个jar包,可以直接使用命令java -jar hello.jar
来运行它。但是我们的目标是创建模块,所以,继续使用JDK自带的jmod
命令把一个jar包转换成模块:
$ jmod create -cp hello.jar hello.jmod
于是,在当前目录下我们又得到了hello.jmod
这个模块文件。
#运行模块
要运行一个jar,我们使用java -jar
命令。要运行一个模块,我们只需要指定模块名。
$ java --module-path hello.jar --module hello.world
Hello, xml!
注意指定module-path时要指定的是jar位置而非jmod。生成的jmod主要是用来打包jre的
#打包JRE
前面讲了,为了支持模块化,Java 9首先带头把自己的一个巨大无比的rt.jar
拆成了几十个.jmod
模块,原因就是,运行Java程序的时候,实际上我们用到的JDK模块,并没有那么多。不需要的模块,完全可以删除。过去发布一个Java应用程序,要运行它,必须下载一个完整的JRE,再运行jar包。非常麻烦,并且JRE占用存储不小。
现在,JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。怎么裁剪JRE呢?并不是说把系统安装的JRE给删掉部分模块,而是“复制”一部分JRE,只带上用到的模块。为此,JDK提供了jlink
命令来干这件事。命令如下:
$ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/
我们在--module-path
参数指定了我们自己的模块hello.jmod
,然后,在--add-modules
参数中指定了我们用到的3个模块java.base
、java.xml
和hello.world
,用,
分隔。最后,在--output
参数指定输出目录。
现在,在当前目录下,我们可以找到jre
目录,这是一个完整的并且带有我们自己hello.jmod
模块的JRE。试试直接运行这个JRE:
$ jre/bin/java --module hello.world
Hello, xml!
//相当于这是个新的JRE,可以在其他未部署java的环境下运行!
要分发我们自己的Java应用程序,只需要把这个jre
目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。
#访问权限
class
的访问权限(public等)只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class
,必要条件是b模块明确地导出了可以访问的包。
举个例子:我们编写的模块hello.world
用到了模块java.xml
的一个类javax.xml.XMLConstants
,我们之所以能直接使用这个类,是因为模块java.xml
的module-info.java
中声明了若干导出:
module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world
模块中的com.itranswarp.sample.Greeting
类,我们必须将其导出:
module hello.world {
exports com.itranswarp.sample;
requires java.base;
requires java.xml;
}
因此,模块进一步隔离了代码的访问权限。
#Java核心类
#字符串和编码
#String
在Java中,String
是一个引用类型,它本身也是一个class
。但是,Java编译器对String
有特殊处理,即可以直接用"..."
来表示一个字符串:
String s1 = "Hello!";
实际上字符串在String
内部是通过一个char[]
数组表示的,因此,按下面的写法也是可以的:
String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});
Java字符串的一个重要特点就是字符串不可变。这种不可变性是通过内部的private final char[]
字段,以及没有任何修改char[]
的方法实现的。
#字符串比较
当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用equals()
方法而不能用==
。
如下例:
// String
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "hello";
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}
从表面上看,两个字符串用==
和equals()
比较都为true
,但实际上那只是Java编译器在编译期,会自动把所有相同的字符串当作一个对象放入常量池,自然s1
和s2
的引用就是相同的。
所以,这种==
比较返回true
纯属巧合。换一种写法,==
比较就会失败:
// String
public class Main {
public static void main(String[] args) {
String s1 = "hello";
String s2 = "HELLO".toLowerCase();
System.out.println(s1 == s2);
System.out.println(s1.equals(s2));
}
}
两个字符串比较,必须总是使用equals()
方法。
要忽略大小写比较,使用
equalsIgnoreCase()
方法。
String
类还提供了多种方法来操作字符串。
- 判断字串:
string.contains("ll"); // 判断字符串中是否存在指定字符串
注意到
contains()
方法的参数是CharSequence
而不是String
,CharSequence
是String
实现的一个接口。
- 搜索子串的方法:
string.indexOf("l"); // 查找首个指定字符的索引
string.lastIndexOf("l"); // 查找末个指定字符的索引
string.startsWith("He"); // 判断是否以某字符串开头
string.endsWith("lo"); // 判断是否以某字符串结尾
- 提取子串:
string.substring(2, 4); //截取索引范围内的字符
- 去除空白字符:
string.trim(); //去除字符串首尾空白字符,包括\t,\r,\n
string.strip(); // 去除字符串首尾空白字符,包括\t,\r,\n,\u3000
string.stripLeading(); // 去除字符串首空白字符
string.stripTrailing(); // 去除字符串尾空白字符
- 判断空字符或空白字符:
string.isEmpty(); // 判断字符串长度是否为0
string.isBlank(); // 判断是否只包含空白字符
- 替换字串:
string.replace(str1, str2); // 将字符串中指定字串(str1)替换为另指定字符(str2)
string.replaceAll(re_str, str); // 根据正则表达式替换字符
- 分割字符:
string.split(re_str); // 根据正则表达式分割字符串为数组
- 拼接字符串:
String.join(str, arr); // 以指定字符串(str)连接字符串数组(arr)中所有字符串
// 该方法为静态方法
- 格式化字符串
String.format("Hi %s, your score is %.2f!", "Bob", 59.5)
// 格式化字符串,占位符与C中无异
// 该方法为静态方法
- 类型转换
String.valueOf(arg1); // 将arg1转换为字符串类,该方法为静态方法
Integer.parseInt(str, arg2); // 将str以arg2的进制转换为十进制int
要特别注意,Integer
有个getInteger(String)
方法,它不是将字符串转换为int
,而是把该字符串对应的系统变量转换为Integer
:
Integer.getInteger("java.version"); // 版本号int
不记录StringJoiner和StringBuilder
#包装类型
我们已经知道,Java的数据类型分两种:
- 基本类型:
byte
,short
,int
,long
,boolean
,float
,double
,char
; - 引用类型:所有
class
和interface
类型。
引用类型可以赋值为null
,表示空,但基本类型不能赋值为null
那么,如何把一个基本类型视为对象(引用类型)?比如,想要把int
基本类型变成一个引用类型,我们可以定义一个Integer
类,它只包含一个实例字段int
,这样,Integer
类就可以视为int
的包装类(Wrapper Class):
public class Main {
public static void main(String[] args) {
Integer n = null;
Integer n2 = new Integer(99);
int n3 = n2.intValue();
}
}
class Integer { // 定义int的包装类型integer
private int value;
public Integer(int value) {
this.value = value;
}
public int intValue() {
return this.value;
}
}
实际上,因为包装类型非常有用,Java核心库为每种基本类型都提供了对应的包装类型:
基本类型 | 对应的引用类型 |
---|---|
boolean | java.lang.Boolean |
byte | java.lang.Byte |
short | java.lang.Short |
int | java.lang.Integer |
long | java.lang.Long |
float | java.lang.Float |
double | java.lang.Double |
char | java.lang.Character |
#Auto Boxing
因为int
和Integer
可以互相转换,所以,Java编译器可以帮助我们自动在int
和Integer
之间转型:
Integer n = 100; // 编译器自动使用Integer.valueOf(int)
int x = n; // 编译器自动使用Integer.intValue()
这种直接把int
变为Integer
的赋值写法,称为自动装箱(Auto Boxing),反过来,把Integer
变为int
的赋值写法,称为自动拆箱(Auto Unboxing)。
自动装箱和自动拆箱只发生在编译阶段,目的是为了少写代码。
装箱和拆箱会影响代码的执行效率,因为编译后的class
代码是严格区分基本类型和引用类型的。并且,自动拆箱执行时可能会报NullPointerException
(基本类型被赋为引用类型时)。
#不变类
所有的包装类型都是不变类。我们查看Integer
的源码可知,它的核心代码如下。因此,一旦创建了Integer
对象,该对象就是不变的。
public final class Integer {
private final int value;
}
由于包装类型是引用类型,比较时要用equals()
函数。
我们把能创建“新”对象的静态方法称为静态工厂方法。Integer.valueOf()
就是静态工厂方法,它尽可能地返回缓存的实例以节省内存。因此创建新对象时,优先选用静态工厂方法而不是new操作符。
#JavaBean
在Java中,有很多class
的定义都符合这样的规范:
- 若干
private
实例字段; - 通过
public
方法来读写实例字段。 - 存在get…与set…的读写方法(
boolean
字段比较特殊,它的读方法一般命名为isXyz()
)
那么这种class
被称为JavaBean
,它是一种JAVA语言写成的可重用组件。,例如:
public class Person {
private String name;
private int age;
public String getName() { return this.name; }
public void setName(String name) { this.name = name; }
public int getAge() { return this.age; }
public void setAge(int age) { this.age = age; }
}
我们通常把一组对应的读方法(getter
)和写方法(setter
)称为属性(property
)。例如,name
属性:
- 对应的读方法是
String getName()
- 对应的写方法是
setName(String)
只有getter
的属性称为只读属性(read-only),例如,定义一个age只读属性:
- 对应的读方法是
int getAge()
- 无对应的写方法
setAge(int)
类似的,只有setter
的属性称为只写属性(write-only)。
#JavaBean的作用
JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。
另外还有事件类JavaBean,这里就不摘录了