1. Java简介
1.1 SUN给Java又分出了三个不同版本:
- Java SE:Standard Edition
- Java EE:Enterprise Edition
- Java ME:Micro Edition
这三者之间有啥关系呢?
┌───────────────────────────┐
│Java EE │
│ ┌────────────────────┐ │
│ │Java SE │ │
│ │ ┌─────────────┐ │ │
│ │ │ Java ME │ │ │
│ │ └─────────────┘ │ │
│ └────────────────────┘ │
└───────────────────────────┘
简单来说,Java SE就是标准版,包含标准的JVM和标准库,而Java EE是企业版,它只是在Java SE的基础上加上了大量的API和库,以便方便开发Web应用、数据库、消息服务等,Java EE的应用使用的虚拟机和Java SE完全相同。
Java ME就和Java SE不同,它是一个针对嵌入式设备的“瘦身版”,Java SE的标准库无法在Java ME上使用,Java ME的虚拟机也是“瘦身版”。
毫无疑问,Java SE是整个Java平台的核心,而Java EE是进一步学习Web应用所必须的。我们熟悉的Spring等框架都是Java EE开源生态系统的一部分。没有特殊需求,不建议学习Java ME。
推荐的Java学习路线图如下:
- 首先要学习
Java SE,掌握Java语言本身、Java核心开发技术以及Java标准库的使用; - 如果继续学习Java EE,那么Spring框架、数据库开发、分布式架构就是需要学习的;
- 如果要学习大数据开发,那么Hadoop、Spark、Flink这些大数据平台就是需要学习的,他们都基于Java或Scala开发;
- 如果想要学习移动开发,那么就深入Android平台,掌握Android App开发。
1.2 名词解释
1.21 JDK、JRE:
- JDK:Java Development Kit
- JRE:Java Runtime Environment
简单地说,JRE就是运行Java字节码的虚拟机。但是,如果只有Java源码,要编译成Java字节码,就需要JDK,因为JDK除了包含JRE,还提供了编译器、调试器等开发工具。
二者关系如下:
┌─ ┌──────────────────────────────────┐
│ │ Compiler, debugger, etc. │
│ └──────────────────────────────────┘
JDK ┌─ ┌──────────────────────────────────┐
│ │ │ │
│ JRE │ JVM + Runtime Library │
│ │ │ │
└─ └─ └──────────────────────────────────┘
┌───────┐┌───────┐┌───────┐┌───────┐
│Windows││ Linux ││ macOS ││others │
└───────┘└───────┘└───────┘└───────┘
1.22 那JSR、JCP……又是啥?
- JSR规范:Java Specification Request
- JCP组织:Java Community Process
为了保证Java语言的规范性,SUN公司搞了一个JSR规范,凡是想给Java平台加一个功能,比如说访问数据库的功能,大家要先创建一个JSR规范,定义好接口,这样,各个数据库厂商都按照规范写出Java驱动程序,开发者就不用担心自己写的数据库代码在MySQL上能跑,却不能跑在PostgreSQL上。
所以JSR是一系列的规范,从JVM的内存模型到Web程序接口,全部都标准化了。而负责审核JSR的组织就是JCP。
2. Java程序基础
2.1 Java程序的基础知识:
- Java程序基本结构
- 变量和数据类型
- 整数运算
- 浮点数运算
- 布尔运算
- 字符和字符串
- 数组类型
2.2 Java程序基本结构
/**
* 可以用来自动创建文档的注释
*/
public class Hello {
public static void main(String[] args) {
// 向屏幕输出文本:
System.out.println("Hello, world!");
/* 多行注释开始
注释内容
注释结束 */
}
} // class定义结束
2.21 一个程序的基本单位就是class,class是关键字
类名要求:
类名必须以英文字母开头,后接字母,数字和下划线的组合- 习惯以
大写字母开头
public是访问修饰符,表示该class是公开的。
不写public,也能正确编译,但是这个类将无法从命令行执行。
2.22 在class内部,可以定义若干方法(method):
public class Hello {
public static void main(String[] args) { // 方法名是main
// 方法代码...
} // 方法定义结束
}
方法定义了一组执行语句,方法内部的代码将会被依次顺序执行。
这里的方法名是main,返回值是void,表示没有任何返回值。
public除了可以修饰class外,也可以修饰方法。而关键字static是另一个修饰符,它表示静态方法,目前,Java入口程序规定的方法必须是静态方法,方法名必须为main,括号内的参数必须是String数组。
方法名也有命名规则,命名和class一样,但是首字母小写:
类名要求:
类名必须以英文字母开头,后接字母,数字和下划线的组合- 习惯以
小写字母开头
2.3 变量和数据类型
2.31 基本数据类型
基本数据类型是CPU可以直接进行运算的类型。Java定义了以下几种基本数据类型:
- 整数类型:
byte,short,int,long - 浮点数类型:
float,double - 字符类型:
char - 布尔类型:
boolean
不同的数据类型占用的字节数不一样。Java基本数据类型占用的字节数:
┌───┐
byte │ │
└───┘
┌───┬───┐
short │ │ │
└───┴───┘
┌───┬───┬───┬───┐
int │ │ │ │ │
└───┴───┴───┴───┘
┌───┬───┬───┬───┬───┬───┬───┬───┐
long │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
┌───┬───┬───┬───┐
float │ │ │ │ │
└───┴───┴───┴───┘
┌───┬───┬───┬───┬───┬───┬───┬───┐
double │ │ │ │ │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
┌───┬───┐
char │ │ │
└───┴───┘
byte恰好就是一个字节,而long和double需要8个字节。
2.32 引用类型
引用类型最常用的就是String字符串:
引用类型的变量类似于C语言的指针,它内部存储一个“地址”,指向某个对象在内存的位置.
2.321 String字符串:
String s = "hello";
2.322 常量
定义变量的时候,如果加上final修饰符,这个变量就变成了常量
final double PI = 3.14; // PI是一个常量
- 常量在定义时进行初始化后就不可再次赋值,再次赋值会导致编译错误。
- 根据习惯,
常量名通常全部大写。 - 常量的作用是用有意义的变量名来避免魔术数字(Magic number)
2.323 var关键字
如果想省略变量类型,可以使用var关键字:
var sb = new StringBuilder();
编译器会根据赋值语句自动推断出变量sb的类型是StringBuilder。
使用var定义变量,仅仅是少写了变量类型而已
2.324 变量的作用范围
在Java中,多行语句用{ }括起来。很多控制语句,例如条件判断和循环,都以{ }作为它们自身的范围
语句块中定义的变量,它有一个作用域,就是从定义处开始,到语句块结束。超出了作用域引用这些变量,编译器会报错。
2.4 整数运算
Java的整数运算遵循四则运算规则,可以使用任意嵌套的小括号。四则运算规则和初等数学一致。
2.41 移位运算
左移实际上就是不断地×2,右移实际上就是不断地÷2。
2.42 位运算
位运算是按位进行与、或、非和异或的运算。
&与运算的规则是,必须两个数同时为1,结果才为1|或运算的规则是,只要任意一个为1,结果就为1~非运算的规则是,0和1互换^异或运算的规则是,如果两个数不同,结果为1,否则为0
n = 0 & 0; // 0
n = 0 & 1; // 0
n = 0 | 0; // 0
n = 0 | 1; // 1
n = ~0; // 1
n = ~1; // 0
n = 0 ^ 0; // 0
n = 0 ^ 1; // 1
2.43 运算优先级
在Java的计算表达式中,运算优先级从高到低依次是:
()!~++--*/%+-<<>>>>>&|+=-=*=/=
记不住也没关系,只需要加括号就可以保证运算的优先级正确。
2.5 浮点数运算
2.51 类型提升
如果参与运算的两个数其中一个是整型,那么整型可以自动提升到浮点型:
public class Main {
public static void main(String[] args) {
int n = 5;
double d = 1.2 + 24.0 / n; // 6.0
System.out.println(d);
}
}
2.6 布尔运算
2.61 对于布尔类型boolean,永远只有true和false两个值。
布尔运算是一种关系运算,包括以下几类:
- 比较运算符:
>,>=,<,<=,==,!= - 与运算
&& - 或运算
|| - 非运算
!
2.62 短路运算
布尔运算的一个重要特点是短路运算。如果一个布尔运算的表达式能提前确定结果,则后续的计算不再执行,直接返回结果。
2.7 字符和字符串
在Java中,字符和字符串是两个不同的类型。
2.71 字符类型
字符类型char是基本数据类型,它是character的缩写。一个char保存一个Unicode字符
2.72 字符串类型
和char类型不同,字符串类型String是引用类型,我们用双引号"..."表示字符串。一个字符串可以存储0个到任意个字符
常见的转义字符包括:
"表示字符"'表示字符'\表示字符``\n表示换行符\r表示回车符\t表示Tab\u####表示一个Unicode编码的字符
例如:
String s = "ABC\n\u4e2d\u6587"; // 包含6个字符: A, B, C, 换行符, 中, 文
- 如果用
+连接字符串和其他数据类型,会将其他数据类型先自动转型为字符串 多行字符串:从Java 13开始,字符串可以用"""..."""表示多行字符串(Text Blocks)了。不可变特性:Java的字符串除了是一个引用类型外,还有个重要特点,就是字符串不可变。
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);
}
}
2.8 数组类型
Java的数组有几个特点:
- 数组所有元素初始化为默认值,整型都是
0,浮点型是0.0,布尔型是false; - 数组一旦创建后,大小就不可改变。
3. 流程控制
3.1 输入和输出
3.11 输出
使用System.out.println()来向屏幕输出一些内容。
println是print line的缩写,表示输出并换行。因此,如果输出后不想换行,可以用print()
3.111 格式化输出
Java还提供了格式化输出的功能
格式化输出使用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 | 格式化字符串 |
3.112 输入
从控制台读取一个字符串和一个整数的例子:
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,import是导入某个类的语句,必须放到Java源代码的开头。
然后,创建Scanner对象并传入System.in。System.out代表标准输出流,而System.in代表标准输入流。直接使用System.in读取用户输入虽然是可以的,但需要更复杂的代码,而通过Scanner就可以简化后续的代码。
有了Scanner对象后,要读取用户输入的字符串,使用scanner.nextLine(),要读取用户输入的整数,使用scanner.nextInt()。Scanner会自动转换数据类型,因此不必手动转换。
要测试输入,我们不能在线运行它,因为输入必须从命令行读取,因此,需要走编译、执行的流程:
$ javac Main.java
这个程序编译时如果有警告,可以暂时忽略它,在后面学习IO的时候再详细解释。编译成功后,执行:
$ java Main
Input your name: Bob
Input your age: 12
Hi, Bob, you are 12
根据提示分别输入一个字符串和整数后,我们得到了格式化的输出。
3.2 break和continue
3.21 break
在循环过程中,可以使用break语句跳出当前循环。
3.22 continue
break会跳出当前循环,也就是整个循环都不会执行了。而continue则是提前结束本次循环,直接继续执行下次循环。
4. 数组操作
- 通过
for循环就可以遍历数组。 - 使用
for each循环,直接迭代数组的每个元素
int[] ns = {68, 79};
for (int i=0; i<ns.length; i++) {
int nI = ns[i];
System.out.println(nI);
}
for(int nI: ns) {
System.out.println(nI);
}
5. 面向对象编程
5.1 面向对象基础
面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
5.11 class和instance
- class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型
- instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同
一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中。
5.12 方法
5.121 一个class可以包含多个field,例如,我们给Person类就定义了两个field:
class Person {
public String name;
public int age;
}
5.122 把field从public改成private,外部代码不能访问这些field,那我们定义这些field有什么用?怎么才能给它赋值?怎么才能读取它的值?
所以我们需要使用方法(method)来让外部代码可以间接修改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;
}
}
定义方法的语法是:
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
方法返回值通过return语句实现,如果没有返回值,返回类型设置为void,可以省略return。
5.123 private方法
有public方法,自然就有private方法。和private字段一样,private方法不允许外部调用。
定义private方法的理由是内部方法是可以调用private方法的。
5.124 this变量
在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。因此,通过this.field就可以访问当前实例的字段。
如果没有命名冲突,可以省略this。例如:
class Person {
private String name;
public String getName() {
return name; // 相当于this.name
}
}
但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this:
class Person {
private String name;
public void setName(String name) {
this.name = name; // 前面的this不可少,少了就变成局部变量name了
}
}
5.125 方法参数
方法可以包含0个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时,必须严格按照参数的定义一一传递。
5.126 可变参数
可变参数用类型...定义,可变参数相当于数组类型
5.13 构造方法
5.131 多构造方法
可以定义多个构造方法,在通过new操作符调用的时候,编译器通过构造方法的参数数量、位置和类型自动区分:
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person(String name) {
this.name = name;
this.age = 12;
}
public Person() {
}
}
如果调用new Person("Xiao Ming", 20);,会自动匹配到构造方法public Person(String, int)。
如果调用new Person("Xiao Ming");,会自动匹配到构造方法public Person(String)。
如果调用new Person();,会自动匹配到构造方法public Person()。
5.132 一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是this(…):
class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public Person(String name) {
this(name, 18); // 调用另一个构造方法Person(String, int)
}
public Person() {
this("Unnamed"); // 调用另一个构造方法Person(String)
}
}
5.14 继承
5.141 Java使用extends关键字来实现继承
注意:子类自动获得了父类的所有字段,严禁定义与父类重名的字段!
- 继承有个特点,就是
子类无法访问父类的private字段或者private方法。 - 为了让子类可以访问父类的字段,我们需要把private改为protected。
protected关键字可以把字段和方法的访问权限控制在继承树内部,一个protected字段和方法可以被其子类,以及子类的子类所访问,
5.142 super
super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName。
5.143 向上转型
如果一个引用变量的类型是Student,那么它可以指向一个Student类型的实例:
Student s = new Student();
5.144 向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。例如:
Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
Student s2 = (Student) p2; // runtime error! ClassCastException!
5.145 覆写Object方法
因为所有的class最终都继承自Object,而Object定义了几个重要的方法:
toString():把instance输出为String;equals():判断两个instance是否逻辑相等;hashCode():计算一个instance的哈希值。
在必要的情况下,我们可以覆写Object的这几个方法。例如:
class Person {
...
// 显示更有意义的字符串:
@Override
public String toString() {
return "Person:name=" + name;
}
// 比较是否相等:
@Override
public boolean equals(Object o) {
// 当且仅当o为Person类型:
if (o instanceof Person) {
Person p = (Person) o;
// 并且name字段相同时,返回true:
return this.name.equals(p.name);
}
return false;
}
// 计算hash:
@Override
public int hashCode() {
return this.name.hashCode();
}
}
5.15 抽象类
5.151 如果一个class定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用abstract修饰。
因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。
使用abstract修饰的类就是抽象类。
abstract class Person {
public abstract void run();
}
抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。
5.152 面向抽象编程:
当我们定义了抽象类Person,以及具体的Student、Teacher子类的时候,我们可以通过抽象类Person类型去引用具体的子类的实例:
Person s = new Student();
Person t = new Teacher();
这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型:
// 不关心Person变量的具体子类型:
s.run();
t.run();
面向抽象编程的本质就是:
- 上层代码只定义规范(例如:
abstract class Person); - 不需要子类就可以实现业务逻辑(正常编译);
- 具体的业务逻辑由不同的子类实现,调用者并不关心。
5.16 接口
5.161 在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。
如果一个抽象类没有字段,所有方法全部都是抽象方法:
abstract class Person {
public abstract void run();
public abstract String getName();
}
就可以把该抽象类改写为接口:interface。
在Java中,使用interface可以声明一个接口:
interface Person {
void run();
String getName();
}
所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)。
5.162 抽象类和接口的对比如下:
| abstract class | interface | |
|---|---|---|
| 继承 | 只能extends一个class | 可以implements多个interface |
| 字段 | 可以定义实例字段 | 不能定义实例字段 |
| 抽象方法 | 可以定义抽象方法 | 可以定义抽象方法 |
| 非抽象方法 | 可以定义非抽象方法 | 可以定义default方法 |
5.163 接口继承
一个interface可以继承自另一个interface。interface继承自interface使用extends,它相当于扩展了接口的方法。例如:
interface Hello {
void hello();
}
interface Person extends Hello {
void run();
String getName();
}
5.164 继承关系
合理设计interface和abstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:
┌───────────────┐
│ Iterable │
└───────────────┘
▲ ┌───────────────────┐
│ │ Object │
┌───────────────┐ └───────────────────┘
│ Collection │ ▲
└───────────────┘ │
▲ ▲ ┌───────────────────┐
│ └──────────│AbstractCollection │
┌───────────────┐ └───────────────────┘
│ List │ ▲
└───────────────┘ │
▲ ┌───────────────────┐
└──────────│ AbstractList │
└───────────────────┘
▲ ▲
│ │
│ │
┌────────────┐ ┌────────────┐
│ ArrayList │ │ LinkedList │
└────────────┘ └────────────┘
在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:
List list = new ArrayList(); // 用List接口引用具体子类的实例
Collection coll = list; // 向上转型为Collection接口
Iterable it = coll; // 向上转型为Iterable接口
5.17 静态字段和静态方法
5.171 用static修饰的字段,称为静态字段:static field。
实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。
class Person {
public static int number;
}
5.172 静态方法
用static修饰的方法称为静态方法。
调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:
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变量,也无法访问实例字段,它只能访问静态字段。
静态方法经常用于工具类。例如:
- Arrays.sort()
- Math.random()
静态方法也经常用于辅助方法。注意到Java程序的入口main()也是静态方法。
5.173 接口的静态字段
因为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 statc final:
int MALE = 1;
int FEMALE = 2;
}
5.18 包名
Java内建的package机制是为了避免class命名冲突;
JDK的核心类使用java.lang包,编译器会自动导入;
JDK的其它常用类定义在java.util.*,java.math.*,java.text.*,……;
包名推荐使用倒置的域名,例如org.apache。
5.19 作用域
5.191 public
- 定义为
public的class、interface可以被其他任何类访问 - 定义为
public的field、method可以被其他类访问,前提是首先有访问class的权限:
package abc;
public class Hello {
public void hi() {
}
}
上面的hi()方法是public,可以被其他类调用,前提是首先要能访问Hello类:
package xyz;
class Main {
void foo() {
Hello h = new Hello();
h.hi();
}
}
5.192 private
定义为private的field、method无法被其他类访问
5.193 protected
protected作用于继承关系。定义为protected的字段和方法可以被子类访问,以及子类的子类:
package abc;
public class Hello {
// protected方法:
protected void hi() {
}
}
上面的protected方法可以被继承的类访问:
package xyz;
class Main extends Hello {
void foo() {
// 可以访问protected方法:
hi();
}
}
5.194 package
包作用域是指一个类允许访问同一个package的没有public、private修饰的class,以及没有public、protected、private修饰的字段和方法。
package abc;
// package权限的类:
class Hello {
// package权限的方法:
void hi() {
}
}
只要在同一个包,就可以访问package权限的class、field和method:
package abc;
class Main {
void foo() {
// 可以访问package权限的类:
Hello h = new Hello();
// 可以调用package权限的方法:
h.hi();
}
}
注意,包名必须完全一致,包没有父子关系,com.apache和com.apache.abc是不同的包。
5.195 final
Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。
用final修饰class可以阻止被继承:
package abc;
// 无法被继承:
public final class Hello {
private int n = 0;
protected void hi(int t) {
long i = t;
}
}
用final修饰method可以阻止被子类覆写:
package abc;
public class Hello {
// 无法被覆写:
protected final void hi() {
}
}
用final修饰field可以阻止被重新赋值:
package abc;
public class Hello {
private final int n = 0;
protected void hi() {
this.n = 1; // error!
}
}
用final修饰局部变量可以阻止被重新赋值:
package abc;
public class Hello {
protected void hi(final int t) {
t = 1; // error!
}
}
6. Java核心类
Java的核心类,包括:
- 字符串
- StringBuilder
- StringJoiner
- 包装类型
- JavaBean
- 枚举
- 常用工具类
6.1 字符串和编码
6.11 String
在Java中,String是一个引用类型,它本身也是一个class。但是,Java编译器对String有特殊处理,即可以直接用"..."来表示一个字符串:
String s1 = "Hello!";
6.12 String比较
两个字符串比较,必须总是使用equals()方法。
要忽略大小写比较,使用equalsIgnoreCase()方法。
String类还提供了多种方法来搜索子串、提取子串。常用的方法有:
// 是否包含子串:
"Hello".contains("ll"); // true
注意到contains()方法的参数是CharSequence而不是String,因为CharSequence是String实现的一个接口。
搜索子串的更多的例子:
"Hello".indexOf("l"); // 2
"Hello".lastIndexOf("l"); // 3
"Hello".startsWith("He"); // true
"Hello".endsWith("lo"); // true
提取子串的例子:
"Hello".substring(2); // "llo"
"Hello".substring(2, 4); "ll"
注意索引号是从0开始的。
6.13 去除首尾空白字符
使用trim()方法可以移除字符串首尾空白字符。空白字符包括空格,\t,\r,\n:
" \tHello\r\n ".trim(); // "Hello"
注意:trim()并没有改变字符串的内容,而是返回了一个新字符串。
另一个strip()方法也可以移除字符串首尾空白字符。它和trim()不同的是,类似中文的空格字符\u3000也会被移除:
"\u3000Hello\u3000".strip(); // "Hello"
" Hello ".stripLeading(); // "Hello "
" Hello ".stripTrailing(); // " Hello"
String还提供了isEmpty()和isBlank()来判断字符串是否为空和空白字符串:
"".isEmpty(); // true,因为字符串长度为0
" ".isEmpty(); // false,因为字符串长度不为0
" \n".isBlank(); // true,因为只包含空白字符
" Hello ".isBlank(); // false,因为包含非空白字符
6.14 替换子串
要在字符串中替换子串,有两种方法。一种是根据字符或字符串替换:
String s = "hello";
s.replace('l', 'w'); // "hewwo",所有字符'l'被替换为'w'
s.replace("ll", "~~"); // "he~~o",所有子串"ll"被替换为"~~"
另一种是通过正则表达式替换:
String s = "A,,B;C ,D";
s.replaceAll("[\,\;\s]+", ","); // "A,B,C,D"
上面的代码通过正则表达式,把匹配的子串统一替换为","。关于正则表达式的用法我们会在后面详细讲解。
6.15 分割字符串
要分割字符串,使用split()方法,并且传入的也是正则表达式:
String s = "A,B,C,D";
String[] ss = s.split("\,"); // {"A", "B", "C", "D"}
6.16 拼接字符串
拼接字符串使用静态方法join(),它用指定的字符串连接字符串数组:
String[] arr = {"A", "B", "C"};
String s = String.join("***", arr); // "A***B***C"
6.17 格式化字符串
字符串提供了formatted()方法和format()静态方法,可以传入其他参数,替换占位符,然后生成新的字符串:
public class Main {
public static void main(String[] args) {
String s = "Hi %s, your score is %d!";
System.out.println(s.formatted("Alice", 80));
System.out.println(String.format("Hi %s, your score is %.2f!", "Bob", 59.5));
}
}
常用的占位符有:
%s:显示字符串;%d:显示整数;%x:显示十六进制整数;%f:显示浮点数。
占位符还可以带格式,例如%.2f表示显示两位小数。如果你不确定用啥占位符,那就始终用%s,因为%s可以显示任何数据类型。要查看完整的格式化语法,请参考JDK文档。
6.18 类型转换
- 要把任意基本类型或引用类型转换为字符串,可以使用静态方法
valueOf()。
String.valueOf(123); // "123"
String.valueOf(45.67); // "45.67"
String.valueOf(true); // "true"
String.valueOf(new Object()); // 类似java.lang.Object@636be97c
- 要把字符串转换为其他类型,就需要根据情况。例如,把字符串转换为
int类型:
int n1 = Integer.parseInt("123"); // 123
int n2 = Integer.parseInt("ff", 16); // 按十六进制转换,255
- 把字符串转换为
boolean类型:
boolean b1 = Boolean.parseBoolean("true"); // true
boolean b2 = Boolean.parseBoolean("FALSE"); // false
要特别注意,Integer有个getInteger(String)方法,它不是将字符串转换为int,而是把该字符串对应的系统变量转换为Integer:
Integer.getInteger("java.version"); // 版本号,11
6.19 转换为char[]
String和char[]类型可以互相转换,方法是:
char[] cs = "Hello".toCharArray(); // String -> char[]
String s = new String(cs); // char[] -> String
6.20 String小结:
- Java字符串
String是不可变对象; - 字符串操作不改变原字符串内容,而是
返回新字符串; - 常用的字符串操作:
提取子串、查找、替换、大小写转换等; - Java使用Unicode编码表示
String和char; - 转换编码就是将
String和byte[]转换,需要指定编码; - 转换为
byte[]时,始终优先考虑UTF-8编码。
6.3 包装类型
6.31 Java的数据类型分两种:
- 基本类型:
byte,short,int,long,boolean,float,double,char - 引用类型:所有
class和interface类型
引用类型可以赋值为null,表示空,但基本类型不能赋值为null
6.32 包装类(Wrapper Class)
想要把int基本类型变成一个引用类型,我们可以定义一个Integer类,它只包含一个实例字段int,这样,Integer类就可以视为int的包装类(Wrapper Class):
public class Integer {
private int value;
public Integer(int value) {
this.value = value;
}
public int intValue() {
return this.value;
}
}
定义好了Integer类,我们就可以把int和Integer互相转换:
Integer n = null;
Integer n2 = new Integer(99);
int n3 = n2.intValue();
6.33 实际上,因为包装类型非常有用,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 |
可以直接使用包装类型,并不需要自己去定义
public class Main {
public static void main(String[] args) {
int i = 100;
// 通过new操作符创建Integer实例(不推荐使用,会有编译警告):
Integer n1 = new Integer(i);
// 通过静态方法valueOf(int)创建Integer实例:
Integer n2 = Integer.valueOf(i);
// 通过静态方法valueOf(String)创建Integer实例:
Integer n3 = Integer.valueOf("100");
System.out.println(n3.intValue());
}
}
6.34 不变类
所有的包装类型都是不变类。
对两个Integer实例进行比较要特别注意:绝对不能用==比较,因为Integer是引用类型,必须使用equals()比较
6.4 JavaBean
6.41 如果读写方法符合以下这种命名规范:
// 读方法:
public Type getXyz()
// 写方法:
public void setXyz(Type value)
那么这种class被称为JavaBean:
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; }
public boolean isChild() {
return age <= 6;
}
}
getter和setter也是一种数据封装的方法
6.42 JavaBean的作用
JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中。
通过IDE,可以快速生成getter和setter。例如,在Eclipse中,先输入以下代码:
public class Person {
private String name;
private int age;
}
然后,点击右键,在弹出的菜单中选择“Source”,“Generate Getters and Setters”,在弹出的对话框中选中需要生成getter和setter方法的字段,点击确定即可由IDE自动完成所有方法代码。
6.5 枚举类enum
在Java中,我们可以通过static final来定义常量。例如,我们希望定义周一到周日这7个常量,可以用7个不同的int表示:
public class Weekday {
public static final int SUN = 0;
public static final int MON = 1;
public static final int TUE = 2;
public static final int WED = 3;
public static final int THU = 4;
public static final int FRI = 5;
public static final int SAT = 6;
}
6.51 为了让编译器能自动检查某个值在枚举的集合内,并且,不同用途的枚举需要不同的类型来标记,不能混用,我们可以使用enum来定义枚举类:
public class Main {
public static void main(String[] args) {
Weekday day = Weekday.SUN;
if (day == Weekday.SAT || day == Weekday.SUN) {
System.out.println("Work at home!");
} else {
System.out.println("Work at office!");
}
}
}
enum Weekday {
SUN, MON, TUE, WED, THU, FRI, SAT;
}
使用enum定义枚举有如下好处:
enum常量本身带有类型信息,即Weekday.SUN类型是Weekday,编译器会自动检查出类型错误。- 不可能引用到非枚举的值,因为无法通过编译。
- 同类型的枚举不能互相比较或者赋值,因为类型不符。
6.52 enum类型
通过enum定义的枚举类,和其他的class有什么区别?
答案是没有任何区别。enum定义的类型就是class,只不过它有以下几个特点:
- 定义的
enum类型总是继承自java.lang.Enum,且无法被继承; - 只能定义出
enum的实例,而无法通过new操作符创建enum的实例; - 定义的每个实例都是引用类型的唯一实例;
- 可以将
enum类型用于switch语句。
6.6 记录类
从Java 14开始,引入了新的Record类。我们定义Record类时,使用关键字record。把上述Point类改写为Record类,代码如下:
public class Main {
public static void main(String[] args) {
Point p = new Point(123, 456);
System.out.println(p.x());
System.out.println(p.y());
System.out.println(p);
}
}
public record Point(int x, int y) {}
6.7 BigInteger /
6.71 BigInteger
public class Main {
public static void main(String[] args) {
BigInteger n = new BigInteger("999999").pow(99);
float f = n.floatValue();
System.out.println(f);
}
}
BigInteger用于表示任意大小的整数;
BigInteger是不变类,并且继承自Number;
将BigInteger转换成基本类型时可使用longValueExact()等方法保证结果准确。
6.72 BigDecimal
和BigInteger类似,BigDecimal可以表示一个任意大小且精度完全准确的浮点数。
BigDecimal bd = new BigDecimal("123.4567");
System.out.println(bd.multiply(bd)); // 15241.55677489
BigDecimal用scale()表示小数位数,例如:
BigDecimal d1 = new BigDecimal("123.45");
BigDecimal d2 = new BigDecimal("123.4500");
BigDecimal d3 = new BigDecimal("1234500");
System.out.println(d1.scale()); // 2,两位小数
System.out.println(d2.scale()); // 4
System.out.println(d3.scale()); // 0
通过BigDecimal的stripTrailingZeros()方法,可以将一个BigDecimal格式化为一个相等的,但去掉了末尾0的BigDecimal:
BigDecimal d1 = new BigDecimal("123.4500");
BigDecimal d2 = d1.stripTrailingZeros();
System.out.println(d1.scale()); // 4
System.out.println(d2.scale()); // 2,因为去掉了00
BigDecimal d3 = new BigDecimal("1234500");
BigDecimal d4 = d3.stripTrailingZeros();
System.out.println(d3.scale()); // 0
System.out.println(d4.scale()); // -2
小结:
BigDecimal用于表示精确的小数,常用于财务计算;
比较BigDecimal的值是否相等,必须使用compareTo()而不能使用equals()。
6.8 常用工具类
6.81 Math
顾名思义,Math类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算:
求绝对值:
Math.abs(-100); // 100
Math.abs(-7.8); // 7.8
取最大或最小值:
Math.max(100, 99); // 100
Math.min(1.2, 2.3); // 1.2
计算xy次方:
Math.pow(2, 10); // 2的10次方=1024
计算√x:
Math.sqrt(2); // 1.414...
计算ex次方:
Math.exp(2); // 7.389...
计算以e为底的对数:
Math.log(4); // 1.386...
计算以10为底的对数:
Math.log10(100); // 2
三角函数:
Math.sin(3.14); // 0.00159...
Math.cos(3.14); // -0.9999...
Math.tan(3.14); // -0.0015...
Math.asin(1.0); // 1.57079...
Math.acos(1.0); // 0.0
Math还提供了几个数学常量:
double pi = Math.PI; // 3.14159...
double e = Math.E; // 2.7182818...
Math.sin(Math.PI / 6); // sin(π/6) = 0.5
生成一个随机数x,x的范围是0 <= x < 1:
Math.random(); // 0.53907... 每次都不一样
6.82 HexFormat
要将byte[]数组转换为十六进制字符串,可以用formatHex()方法:
import java.util.HexFormat;
public class Main {
public static void main(String[] args) throws InterruptedException {
byte[] data = "Hello".getBytes();
HexFormat hf = HexFormat.of();
String hexData = hf.formatHex(data); // 48656c6c6f
}
}
如果要定制转换格式,则使用定制的HexFormat实例:
// 分隔符为空格,添加前缀0x,大写字母:
HexFormat hf = HexFormat.ofDelimiter(" ").withPrefix("0x").withUpperCase();
hf.formatHex("Hello".getBytes())); // 0x48 0x65 0x6C 0x6C 0x6F
从十六进制字符串到byte[]数组转换,使用parseHex()方法:
byte[] bs = HexFormat.of().parseHex("48656c6c6f");
6.83 Random
Random用来创建伪随机数。所谓伪随机数,是指只要给定一个初始的种子,产生的随机数序列是完全一样的。
要生成一个随机数,可以使用nextInt()、nextLong()、nextFloat()、nextDouble():
Random r = new Random();
r.nextInt(); // 2071575453,每次都不一样
r.nextInt(10); // 5,生成一个[0,10)之间的int
r.nextLong(); // 8811649292570369305,每次都不一样
r.nextFloat(); // 0.54335...生成一个[0,1)之间的float
r.nextDouble(); // 0.3716...生成一个[0,1)之间的double
6.84 SecureRandom
有伪随机数,就有真随机数。实际上真正的真随机数只能通过量子力学原理来获取,而我们想要的是一个不可预测的安全的随机数,SecureRandom就是用来创建安全的随机数的:
SecureRandom sr = new SecureRandom();
System.out.println(sr.nextInt(100));
SecureRandom无法指定种子,它使用RNG(random number generator)算法。JDK的SecureRandom实际上有多种不同的底层实现,有的使用安全随机种子加上伪随机数算法来产生安全的随机数,有的使用真正的随机数生成器。实际使用的时候,可以优先获取高强度的安全随机数生成器,如果没有提供,再使用普通等级的安全随机数生成器:
import java.util.Arrays;
import java.security.SecureRandom;
import java.security.NoSuchAlgorithmException;
public class Main {
public static void main(String[] args) {
SecureRandom sr = null;
try {
sr = SecureRandom.getInstanceStrong(); // 获取高强度安全随机数生成器
} catch (NoSuchAlgorithmException e) {
sr = new SecureRandom(); // 获取普通的安全随机数生成器
}
byte[] buffer = new byte[16];
sr.nextBytes(buffer); // 用安全随机数填充buffer
System.out.println(Arrays.toString(buffer));
}
}
SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。
在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。
6.85 Java提供的常用工具类有:
Math:数学计算Random:生成伪随机数SecureRandom:生成安全的随机数
7. 异常处理
7.1 Java的异常
调用方如何获知调用失败的信息?有两种方法:
方法一:约定返回错误码。
例如,处理一个文件,如果返回0,表示成功,返回其他整数,表示约定的错误码:
int code = processFile("C:\test.txt");
if (code == 0) {
// ok:
} else {
// error:
switch (code) {
case 1:
// file not found:
case 2:
// no read permission:
default:
// unknown error:
}
}
因为使用int类型的错误码,想要处理就非常麻烦。这种方式常见于底层C函数。
方法二:在语言层面上提供一个异常处理机制。
Java内置了一套异常处理机制,总是使用异常来表示错误。
异常是一种class,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获,这样就和方法调用分离了:
try {
String s = processFile(“C:\test.txt”);
// ok:
} catch (FileNotFoundException e) {
// file not found:
} catch (SecurityException e) {
// no read permission:
} catch (IOException e) {
// io error:
} catch (Exception e) {
// other error:
}
因为Java的异常是class,它的继承关系如下:
┌───────────┐
│ Object │
└───────────┘
▲
│
┌───────────┐
│ Throwable │
└───────────┘
▲
┌─────────┴─────────┐
│ │
┌───────────┐ ┌───────────┐
│ Error │ │ Exception │
└───────────┘ └───────────┘
▲ ▲
┌───────┘ ┌────┴──────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐┌───────────┐
│OutOfMemoryError │... │RuntimeException ││IOException│...
└─────────────────┘ └─────────────────┘└───────────┘
▲
┌───────────┴─────────────┐
│ │
┌─────────────────────┐ ┌─────────────────────────┐
│NullPointerException │ │IllegalArgumentException │...
└─────────────────────┘ └─────────────────────────┘
从继承关系可知:Throwable是异常体系的根,它继承自Object。Throwable有两个体系:Error和Exception,Error表示严重的错误,程序对此一般无能为力,例如:
OutOfMemoryError:内存耗尽NoClassDefFoundError:无法加载某个ClassStackOverflowError:栈溢出
而Exception则是运行时的错误,它可以被捕获并处理。
某些异常是应用程序逻辑处理的一部分,应该捕获并处理。例如:
NumberFormatException:数值类型的格式错误FileNotFoundException:未找到文件SocketException:读取网络失败
还有一些异常是程序逻辑编写不对造成的,应该修复程序本身。例如:
NullPointerException:对某个null的对象调用方法或字段IndexOutOfBoundsException:数组索引越界
Exception又分为两大类:
RuntimeException以及它的子类;- 非
RuntimeException(包括IOException、ReflectiveOperationException等等)
Java规定:
- 必须捕获的异常,包括
Exception及其子类,但不包括RuntimeException及其子类,这种类型的异常称为Checked Exception。 - 不需要捕获的异常,包括
Error及其子类,RuntimeException及其子类。
7.11 捕获异常
捕获异常使用try...catch语句,把可能发生异常的代码放到try {...}中,然后使用catch捕获对应的Exception及其子类
7.12 小结
Java使用异常来表示错误,并通过try ... catch捕获异常;
Java的异常是class,并且从Throwable继承;
Error是无需捕获的严重错误,Exception是应该捕获的可处理的错误;
RuntimeException无需强制捕获,非RuntimeException(Checked Exception)需强制捕获,或者用throws声明;
7.2 使用断言
7.21 断言(Assertion)是一种调试程序的方式。在Java中,使用assert关键字来实现断言。
public static void main(String[] args) {
double x = Math.abs(-123.45);
assert x >= 0;
System.out.println(x);
}
语句assert x >= 0;即为断言,断言条件x >= 0预期为true。如果计算结果为false,则断言失败,抛出AssertionError。
使用assert语句时,还可以添加一个可选的断言消息:
assert x >= 0 : "x must >= 0";
这样,断言失败的时候,AssertionError会带上消息x must >= 0,更加便于调试。
7.22 小结
断言是一种调试方式,断言失败会抛出AssertionError,只能在开发和测试阶段启用断言;
对可恢复的错误不能使用断言,而应该抛出异常;
断言很少被使用,更好的方法是编写单元测试。
7.3 使用JDK Logging
输出日志,而不是用System.out.println(),有以下几个好处:
- 可以设置输出样式,避免自己每次都写
"ERROR: " + var; - 可以设置输出级别,禁止某些级别输出。例如,只输出错误日志;
- 可以被重定向到文件,这样可以在程序运行结束后查看日志;
- 可以按包名控制日志级别,只输出某些包打的日志;
- 可以……
对比可见,使用日志最大的好处是,它自动打印了时间、调用类、调用方法等很多有用的信息。
再仔细观察发现,4条日志,只打印了3条,logger.fine()没有打印。这是因为,日志的输出可以设定级别。JDK的Logging定义了7个日志级别,从严重到普通:
- SEVERE
- WARNING
- INFO
- CONFIG
- FINE
- FINER
- FINEST
因为默认级别是INFO,因此,INFO级别以下的日志,不会被打印出来。使用日志级别的好处在于,调整级别,就可以屏蔽掉很多调试相关的日志输出。
使用Java标准库内置的Logging有以下局限:
Logging系统在JVM启动时读取配置文件并完成初始化,一旦开始运行main()方法,就无法修改配置;
配置不太方便,需要在JVM启动时传递参数-Djava.util.logging.config.file=<config-file-name>。
因此,Java标准库内置的Logging使用并不是非常广泛。更方便的日志系统我们稍后介绍。
7.4 使用Commons Logging
7.41 使用Commons Logging只需要和两个类打交道,并且只有两步:
- 第一步,通过
LogFactory获取Log类的实例; - 第二步,使用
Log实例的方法打日志。
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
public class CommonsLogging {
public static void main(String[] args) {
Log log = LogFactory.getLog(Main.class);
log.info("start...");
log.warn("end.");
}
}
运行上述代码,肯定会得到编译错误,类似error: package org.apache.commons.logging does not exist(找不到org.apache.commons.logging这个包)。因为Commons Logging是一个第三方提供的库,所以,必须先把它下载下来。下载后,解压,找到commons-logging-1.2.jar这个文件,再把Java源码Main.java放到一个目录下,例如work目录:
work
│
├─ commons-logging-1.2.jar
│
└─ Main.java
然后用javac编译Main.java,编译的时候要指定classpath,不然编译器找不到我们引用的org.apache.commons.logging包。编译命令如下:
javac -cp commons-logging-1.2.jar Main.java
如果编译成功,那么当前目录下就会多出一个Main.class文件:
work
│
├─ commons-logging-1.2.jar
│
├─ Main.java
│
└─ Main.class
现在可以执行这个Main.class,使用java命令,也必须指定classpath,命令如下:
java -cp .;commons-logging-1.2.jar Main
注意到传入的classpath有两部分:一个是.,一个是commons-logging-1.2.jar,用;分割。.表示当前目录,如果没有这个.,JVM不会在当前目录搜索Main.class,就会报错。
如果在Linux或macOS下运行,注意classpath的分隔符不是;,而是::
java -cp .:commons-logging-1.2.jar Main
运行结果如下:
Mar 02, 2019 7:15:31 PM Main main
INFO: start...
Mar 02, 2019 7:15:31 PM Main main
WARNING: end.
7.42 Commons Logging定义了6个日志级别:
- FATAL
- ERROR
- WARNING
- INFO
- DEBUG
- TRACE
默认级别是INFO。
7.43 使用Commons Logging时,如果在静态方法中引用Log,通常直接定义一个静态类型变量:
// 在静态方法中引用Log:
public class Main {
static final Log log = LogFactory.getLog(Main.class);
static void foo() {
log.info("foo");
}
}
在实例方法中引用Log,通常定义一个实例变量:
// 在实例方法中引用Log:
public class Person {
protected final Log log = LogFactory.getLog(getClass());
void foo() {
log.info("foo");
}
}
注意到实例变量log的获取方式是LogFactory.getLog(getClass()),虽然也可以用LogFactory.getLog(Person.class),但是前一种方式有个非常大的好处,就是子类可以直接使用该log实例。例如:
// 在子类中使用父类实例化的log:
public class Student extends Person {
void bar() {
log.info("bar");
}
}
由于Java类的动态特性,子类获取的log字段实际上相当于LogFactory.getLog(Student.class),但却是从父类继承而来,并且无需改动代码。
此外,Commons Logging的日志方法,例如info(),除了标准的info(String)外,还提供了一个非常有用的重载方法:info(String, Throwable),这使得记录异常更加简单:
try {
...
} catch (Exception e) {
log.error("got exception!", e);
}
7.5 使用Log4j
Log4j是一种非常流行的日志框架,最新版本是2.x。
Log4j是一个组件化设计的日志系统,它的架构大致如下:
log.info("User signed in.");
│
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──>│ Appender │───>│ Filter │───>│ Layout │───>│ Console │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
├──>│ Appender │───>│ Filter │───>│ Layout │───>│ File │
│ └──────────┘ └──────────┘ └──────────┘ └──────────┘
│
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐
└──>│ Appender │───>│ Filter │───>│ Layout │───>│ Socket │
└──────────┘ └──────────┘ └──────────┘ └──────────┘
当我们使用Log4j输出一条日志时,Log4j自动通过不同的Appender把同一条日志输出到不同的目的地。例如:
- console:输出到屏幕;
- file:输出到文件;
- socket:通过网络输出到远程计算机;
- jdbc:输出到数据库
在输出日志的过程中,通过Filter来过滤哪些log需要被输出,哪些log不需要被输出。例如,仅输出ERROR级别的日志。
最后,通过Layout来格式化日志信息,例如,自动添加日期、时间、方法名称等信息。
上述结构虽然复杂,但我们在实际使用的时候,并不需要关心Log4j的API,而是通过配置文件来配置它。
以XML配置为例,使用Log4j的时候,我们把一个log4j2.xml的文件放到classpath下就可以让Log4j读取配置文件并按照我们的配置来输出日志。下面是一个配置文件的例子:
<?xml version="1.0" encoding="UTF-8"?>
<Configuration>
<Properties>
<!-- 定义日志格式 -->
<Property name="log.pattern">%d{MM-dd HH:mm:ss.SSS} [%t] %-5level %logger{36}%n%msg%n%n</Property>
<!-- 定义文件名变量 -->
<Property name="file.err.filename">log/err.log</Property>
<Property name="file.err.pattern">log/err.%i.log.gz</Property>
</Properties>
<!-- 定义Appender,即目的地 -->
<Appenders>
<!-- 定义输出到屏幕 -->
<Console name="console" target="SYSTEM_OUT">
<!-- 日志格式引用上面定义的log.pattern -->
<PatternLayout pattern="${log.pattern}" />
</Console>
<!-- 定义输出到文件,文件名引用上面定义的file.err.filename -->
<RollingFile name="err" bufferedIO="true" fileName="${file.err.filename}" filePattern="${file.err.pattern}">
<PatternLayout pattern="${log.pattern}" />
<Policies>
<!-- 根据文件大小自动切割日志 -->
<SizeBasedTriggeringPolicy size="1 MB" />
</Policies>
<!-- 保留最近10份 -->
<DefaultRolloverStrategy max="10" />
</RollingFile>
</Appenders>
<Loggers>
<Root level="info">
<!-- 对info级别的日志,输出到console -->
<AppenderRef ref="console" level="info" />
<!-- 对error级别的日志,输出到err,即上面定义的RollingFile -->
<AppenderRef ref="err" level="error" />
</Root>
</Loggers>
</Configuration>
虽然配置Log4j比较繁琐,但一旦配置完成,使用起来就非常方便。对上面的配置文件,凡是INFO级别的日志,会自动输出到屏幕,而ERROR级别的日志,不但会输出到屏幕,还会同时输出到文件。并且,一旦日志文件达到指定大小(1MB),Log4j就会自动切割新的日志文件,并最多保留10份。
有了配置文件还不够,因为Log4j也是一个第三方库,我们需要从这里下载Log4j,解压后,把以下3个jar包放到classpath中:
- log4j-api-2.x.jar
- log4j-core-2.x.jar
- log4j-jcl-2.x.jar
因为Commons Logging会自动发现并使用Log4j,所以,把上一节下载的commons-logging-1.2.jar也放到classpath中。
要打印日志,只需要按Commons Logging的写法写,不需要改动任何代码,就可以得到Log4j的日志输出
7.51 最佳实践
在开发阶段,始终使用Commons Logging接口来写入日志,并且开发阶段无需引入Log4j。如果需要把日志写入文件, 只需要把正确的配置文件和Log4j相关的jar包放入classpath,就可以自动把日志切换成使用Log4j写入,无需修改任何代码。