Dunwu Blog

大道至简,知易行难

深入理解 Java 异常

img

异常框架

Throwable

Throwable 是 Java 语言中所有错误(Error)和异常(Exception)的超类。在 Java 中只有 Throwable 类型的实例才可以被抛出(throw)或者捕获(catch),它是异常处理机制的基本组成类型。

Throwable 包含了其线程创建时线程执行堆栈的快照,它提供了 printStackTrace() 等接口用于获取堆栈跟踪数据等信息。

主要方法:

  • fillInStackTrace - 用当前的调用栈层次填充 Throwable 对象栈层次,添加到栈层次任何先前信息中。
  • getMessage - 返回关于发生的异常的详细信息。这个消息在 Throwable 类的构造函数中初始化了。
  • getCause - 返回一个 Throwable 对象代表异常原因。
  • getStackTrace - 返回一个包含堆栈层次的数组。下标为 0 的元素代表栈顶,最后一个元素代表方法调用堆栈的栈底。
  • printStackTrace - 打印 toString() 结果和栈层次到 System.err,即错误输出流。
  • toString - 使用 getMessage 的结果返回代表 Throwable 对象的字符串。

Error

ErrorThrowable 的一个子类。**Error 表示正常情况下,不大可能出现的严重问题编译器不会检查 Error**。绝大部分的 Error 都会导致程序(比如 JVM 自身)处于非正常的、不可恢复状态。既然是非正常情况,所以不便于也不需要捕获,常见的比如 OutOfMemoryError 之类,都是 Error 的子类。

常见 Error

  • AssertionError - 断言错误。
  • VirtualMachineError - 虚拟机错误。
  • UnsupportedClassVersionError - Java 类版本错误。
  • StackOverflowError - 栈溢出错误。
  • OutOfMemoryError - 内存溢出错误。

Exception

ExceptionThrowable 的一个子类。**Exception 表示合理的应用程序可能想要捕获的条件。**Exception 是程序正常运行中,可以预料的意外情况,可能并且应该被捕获,进行相应处理。

Exception 又分为可检查(checked)异常和不检查(unchecked)异常,可检查异常在源代码里必须显式地进行捕获处理,这是编译期检查的一部分。

编译器会检查 Exception 异常。此类异常,要么通过 throws 进行声明抛出,要么通过 try catch 进行捕获处理,否则不能通过编译。

常见 Exception

  • ClassNotFoundException - 应用程序试图加载类时,找不到相应的类,抛出该异常。
  • CloneNotSupportedException - 当调用 Object 类中的 clone 方法克隆对象,但该对象的类无法实现 Cloneable 接口时,抛出该异常。
  • IllegalAccessException - 拒绝访问一个类的时候,抛出该异常。
  • InstantiationException - 当试图使用 Class 类中的 newInstance 方法创建一个类的实例,而指定的类对象因为是一个接口或是一个抽象类而无法实例化时,抛出该异常。
  • InterruptedException - 一个线程被另一个线程中断,抛出该异常。
  • NoSuchFieldException - 请求的变量不存在。
  • NoSuchMethodException - 请求的方法不存在。

【示例】Exception 示例

1
2
3
4
5
public class ExceptionDemo {
public static void main(String[] args) {
Method method = String.class.getMethod("toString", int.class);
}
};

试图编译运行时会报错:

1
Error:(7, 47) java: 未报告的异常错误java.lang.NoSuchMethodException; 必须对其进行捕获或声明以便抛出

RuntimeException

RuntimeExceptionException 的一个子类。RuntimeException 是那些可能在 Java 虚拟机正常运行期间抛出的异常的超类。

编译器不会检查 RuntimeException 异常。当程序中可能出现这类异常时,倘若既没有通过 throws 声明抛出它,也没有用 try catch 语句捕获它,程序还是会编译通过。

【示例】RuntimeException 示例

1
2
3
4
5
6
7
8
public class RuntimeExceptionDemo {
public static void main(String[] args) {
// 此处产生了异常
int result = 10 / 0;
System.out.println("两个数字相除的结果:" + result);
System.out.println("----------------------------");
}
};

运行时输出:

1
2
Exception in thread "main" java.lang.ArithmeticException: / by zero
at io.github.dunwu.javacore.exception.RumtimeExceptionDemo01.main(RumtimeExceptionDemo01.java:6)

常见 RuntimeException

  • ArrayIndexOutOfBoundsException - 用非法索引访问数组时抛出的异常。如果索引为负或大于等于数组大小,则该索引为非法索引。
  • ArrayStoreException - 试图将错误类型的对象存储到一个对象数组时抛出的异常。
  • ClassCastException - 当试图将对象强制转换为不是实例的子类时,抛出该异常。
  • IllegalArgumentException - 抛出的异常表明向方法传递了一个不合法或不正确的参数。
  • IllegalMonitorStateException - 抛出的异常表明某一线程已经试图等待对象的监视器,或者试图通知其他正在等待对象的监视器而本身没有指定监视器的线程。
  • IllegalStateException - 在非法或不适当的时间调用方法时产生的信号。换句话说,即 Java 环境或 Java 应用程序没有处于请求操作所要求的适当状态下。
  • IllegalThreadStateException - 线程没有处于请求操作所要求的适当状态时抛出的异常。
  • IndexOutOfBoundsException - 指示某排序索引(例如对数组、字符串或向量的排序)超出范围时抛出。
  • NegativeArraySizeException - 如果应用程序试图创建大小为负的数组,则抛出该异常。
  • NullPointerException - 当应用程序试图在需要对象的地方使用 null 时,抛出该异常
  • NumberFormatException - 当应用程序试图将字符串转换成一种数值类型,但该字符串不能转换为适当格式时,抛出该异常。
  • SecurityException - 由安全管理器抛出的异常,指示存在安全侵犯。
  • StringIndexOutOfBoundsException - 此异常由 String 方法抛出,指示索引或者为负,或者超出字符串的大小。
  • UnsupportedOperationException - 当不支持请求的操作时,抛出该异常。

自定义异常

img

自定义一个异常类,只需要继承 ExceptionRuntimeException 即可。

【示例】自定义异常示例

1
2
3
4
5
6
7
8
9
10
11
public class MyExceptionDemo {
public static void main(String[] args) {
throw new MyException("自定义异常");
}

static class MyException extends RuntimeException {
public MyException(String message) {
super(message);
}
}
}

输出:

1
2
Exception in thread "main" io.github.dunwu.javacore.exception.MyExceptionDemo$MyException: 自定义异常
at io.github.dunwu.javacore.exception.MyExceptionDemo.main(MyExceptionDemo.java:9)

抛出异常

如果想在程序中明确地抛出异常,需要用到 throwthrows

如果一个方法没有捕获一个检查性异常,那么该方法必须使用 throws 关键字来声明。throws 关键字放在方法签名的尾部。

【示例】throw 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ThrowDemo {
public static void f() {
try {
throw new RuntimeException("抛出一个异常");
} catch (Exception e) {
System.out.println(e);
}
}

public static void main(String[] args) {
f();
}
};

输出:

1
java.lang.RuntimeException: 抛出一个异常

也可以使用 throw 关键字抛出一个异常,无论它是新实例化的还是刚捕获到的。

【示例】throws 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ThrowsDemo {
public static void f1() throws NoSuchMethodException, NoSuchFieldException {
Field field = Integer.class.getDeclaredField("digits");
if (field != null) {
System.out.println("反射获取 digits 方法成功");
}
Method method = String.class.getMethod("toString", int.class);
if (method != null) {
System.out.println("反射获取 toString 方法成功");
}
}

public static void f2() {
try {
// 调用 f1 处,如果不用 try catch ,编译时会报错
f1();
} catch (NoSuchMethodException e) {
e.printStackTrace();
} catch (NoSuchFieldException e) {
e.printStackTrace();
}
}

public static void main(String[] args) {
f2();
}
};

输出:

1
2
3
4
5
6
// 反射获取 digits 方法成功
java.lang.NoSuchMethodException: java.lang.String.toString(int)
at java.lang.Class.getMethod(Class.java:1786)
at io.github.dunwu.javacore.exception.ThrowsDemo.f1(ThrowsDemo.java:12)
at io.github.dunwu.javacore.exception.ThrowsDemo.f2(ThrowsDemo.java:21)
at io.github.dunwu.javacore.exception.ThrowsDemo.main(ThrowsDemo.java:30)

throwthrows 的区别:

  • throws 使用在函数上,throw 使用在函数内。
  • throws 后面跟异常类,可以跟多个,用逗号区别;throw 后面跟的是异常对象。

捕获异常

使用 try 和 catch 关键字可以捕获异常try catch 代码块放在异常可能发生的地方。

它的语法形式如下:

1
2
3
4
5
6
7
8
9
try {
// 可能会发生异常的代码块
} catch (Exception e1) {
// 捕获并处理try抛出的异常类型Exception
} catch (Exception2 e2) {
// 捕获并处理try抛出的异常类型Exception2
} finally {
// 无论是否发生异常,都将执行的代码块
}

此外,JDK7 以后,catch 多种异常时,也可以像下面这样简化代码:

1
2
3
4
5
6
7
try {
// 可能会发生异常的代码块
} catch (Exception | Exception2 e) {
// 捕获并处理try抛出的异常类型
} finally {
// 无论是否发生异常,都将执行的代码块
}

trycatchfinally 使用要点如下:

  • try - try 语句用于监听。将要被监听的代码(可能抛出异常的代码)放在 try 语句块之内,当 try 语句块内发生异常时,异常就被抛出。

  • catch - catch 语句包含要捕获异常类型的声明。当保护代码块中发生一个异常时,try 后面的 catch 块就会被检查。

  • finally - finally 语句块总是会被执行,无论是否出现异常。try catch 语句后不一定非要 finally 语句。finally 常用于这样的场景:由于 finally 语句块总是会被执行,所以那些在 try 代码块中打开的,并且必须回收的物理资源(如数据库连接、网络连接和文件),一般会放在 finally 语句块中释放资源。

  • trycatchfinally 三个代码块中的局部变量不可共享使用

  • catch 块尝试捕获异常时,是按照 catch 块的声明顺序依次寻找的,一旦匹配,就不会再向下执行。因此,如果同一个 try 块下的多个 catch 异常类型有父子关系,应该将子类异常放在前面,父类异常放在后面。

【示例】trycatchfinally 使用示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class TryCatchFinallyDemo {
public static void main(String[] args) {
try {
// 此处产生了异常
int temp = 10 / 0;
System.out.println("两个数字相除的结果:" + temp);
System.out.println("----------------------------");
} catch (ArithmeticException e) {
System.out.println("出现异常了:" + e);
} finally {
System.out.println("不管是否出现异常,都执行此代码");
}
}
};

运行时输出:

1
2
// 出现异常了:java.lang.ArithmeticException: / by zero
// 不管是否出现异常,都执行此代码

异常链

异常链是以一个异常对象为参数构造新的异常对象,新的异常对象将包含先前异常的信息。

通过使用异常链,我们可以提高代码的可理解性、系统的可维护性和友好性。

我们有两种方式处理异常,一是 throws 抛出交给上级处理,二是 try…catch 做具体处理。try…catchcatch 块我们可以不需要做任何处理,仅仅只用 throw 这个关键字将我们封装异常信息主动抛出来。然后在通过关键字 throws 继续抛出该方法异常。它的上层也可以做这样的处理,以此类推就会产生一条由异常构成的异常链。

【示例】异常链示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class ExceptionChainDemo {
static class MyException1 extends Exception {
public MyException1(String message) {
super(message);
}
}

static class MyException2 extends Exception {
public MyException2(String message, Throwable cause) {
super(message, cause);
}
}

public static void f1() throws MyException1 {
throw new MyException1("出现 MyException1");
}

public static void f2() throws MyException2 {
try {
f1();
} catch (MyException1 e) {
throw new MyException2("出现 MyException2", e);
}
}

public static void main(String[] args) throws MyException2 {
f2();
}
}

输出:

1
2
3
4
5
6
7
Exception in thread "main" io.github.dunwu.javacore.exception.ExceptionChainDemo$MyException2: 出现 MyException2
at io.github.dunwu.javacore.exception.ExceptionChainDemo.f2(ExceptionChainDemo.java:29)
at io.github.dunwu.javacore.exception.ExceptionChainDemo.main(ExceptionChainDemo.java:34)
Caused by: io.github.dunwu.javacore.exception.ExceptionChainDemo$MyException1: 出现 MyException1
at io.github.dunwu.javacore.exception.ExceptionChainDemo.f1(ExceptionChainDemo.java:22)
at io.github.dunwu.javacore.exception.ExceptionChainDemo.f2(ExceptionChainDemo.java:27)
... 1 more

扩展阅读:https://juejin.im/post/5b6d61e55188251b38129f9a#heading-10

这篇文章中对于异常链讲解比较详细。

异常注意事项

finally 覆盖异常

Java 异常处理中 finally 中的 return 会覆盖 catch 代码块中的 return 语句和 throw 语句,所以不建议在 finally 中使用 return 语句

此外 finally 中的 throw 语句也会覆盖 catch 代码块中的 return 语句和 throw 语句。

【示例】finally 覆盖示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FinallyOverrideExceptionDemo {
static void f() throws Exception {
try {
throw new Exception("A");
} catch (Exception e) {
throw new Exception("B");
} finally {
throw new Exception("C");
}
}

public static void main(String[] args) {
try {
f();
} catch (Exception e) {
System.out.println(e.getMessage());
}
}
}
// 输出:C

覆盖抛出异常的方法

当子类重写父类带有 throws 声明的函数时,其 throws 声明的异常必须在父类异常的可控范围内;用于处理父类的 throws 方法的异常处理器,必须也适用于子类的这个带 throws 方法——这是为了支持多态。

【示例】覆盖抛出异常示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ExceptionOverrideDemo {
static class Father {
public void start() throws IOException {
throw new IOException();
}
}

static class Son extends Father {
@Override
public void start() throws SQLException {
throw new SQLException();
}
}

public static void main(String[] args) {
Father obj1 = new Father();
Father obj2 = new Son();
try {
obj1.start();
obj2.start();
} catch (IOException e) {
e.printStackTrace();
}
}
}

上面的示例编译时会报错,原因在于:

因为 Son 类抛出异常的实质是 SQLException,而 IOException 无法处理它。那么这里的 try catch 就不能处理 Son 中的异常了。多态就不能实现了。

异常和线程

如果 Java 程序只有一个线程,那么没有被任何代码处理的异常会导致程序终止。如果 Java 程序是多线程的,那么没有被任何代码处理的异常仅仅会导致异常所在的线程结束。

最佳实践

  • 对可恢复的情况使用检查性异常 Exception;对编程错误使用运行时异常RuntimeException
  • 优先使用 Java 标准的异常。
  • 抛出与抽象相对应的异常。
  • 在细节消息中包含能捕获失败的信息。
  • 尽可能减少 try 代码块的大小。
  • 尽量缩小异常范围。例如,如果明知尝试捕获的是一个 ArithmeticException,就应该 catch ArithmeticException,而不是 catch 范围较大的 RuntimeException,甚至是 Exception
  • 尽量不要在 finally 块抛出异常或者返回值。
  • 不要忽略异常,一旦捕获异常,就应该处理,而非丢弃。
  • 异常处理效率很低,所以不要用异常进行业务逻辑处理。
  • 各类异常必须要有单独的日志记录,将异常分级,分类管理,因为有的时候仅仅想给第三方运维看到逻辑异常,而不是更细节的信息。如何对异常进行分类:
    • 逻辑异常 - 这类异常用于描述业务无法按照预期的情况处理下去,属于用户制造的意外。
    • 代码错误 - 这类异常用于描述开发的代码错误,例如 NPE,ILLARG,都属于程序员制造的 BUG。
    • 专有异常 - 多用于特定业务场景,用于描述指定作业出现意外情况无法预先处理。

扩展阅读:

参考资料

深入理解 Java 数组

简介

数组的特性

数组对于每一门编程语言来说都是重要的数据结构之一,当然不同语言对数组的实现及处理也不尽相同。几乎所有程序设计语言都支持数组。

数组代表一系列对象或者基本数据类型,所有相同的类型都封装到一起,采用一个统一的标识符名称。

数组的定义和使用需要通过方括号 []

Java 中,数组是一种引用类型。

Java 中,数组是用来存储固定大小的同类型元素。

数组和容器

Java 中,既然有了强大的容器,是不是就不需要数组了?

答案是不。

诚然,大多数情况下,应该选择容器存储数据。

但是,数组也不是毫无是处:

  • Java 中,数组是一种效率最高的存储和随机访问对象引用序列的方式。数组的效率要高于容器(如 ArrayList)。
  • 数组可以持有值类型,而容器则不能(这时,就必须用到包装类)。

Java 数组的本质是对象

Java 数组的本质是对象。它具有 Java 中其他对象的一些基本特点:封装了一些数据,可以访问属性,也可以调用方法。所以,数组是对象。

如果有两个类 A 和 B,如果 B 继承(extends)了 A,那么 A[] 类型的引用就可以指向 B[] 类型的对象。

扩展阅读:Java 中数组的特性

如果想要论证 Java 数组本质是对象,不妨一读这篇文章。

Java 数组和内存

Java 数组在内存中的存储是这样的:

数组对象(这里可以看成一个指针)存储在栈中。

数组元素存储在堆中。

如下图所示:只有当 JVM 执行 new String[] 时,才会在堆中开辟相应的内存区域。数组对象 array 可以视为一个指针,指向这块内存的存储地址。

img

声明数组

声明数组变量的语法如下:

1
2
int[] arr1; // 推荐风格
int arr2[]; // 效果相同

创建数组

Java 语言使用 new 操作符来创建数组。有两种创建数组方式:

  • 指定数组维度
    • 为数组开辟指定大小的数组维度。
    • 如果数组元素是基础数据类型,会将每个元素设为默认值;如果是引用类型,元素值为 null
  • 不指定数组维度
    • 用花括号中的实际元素初始化数组,数组大小与元素数相同。

示例 1:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ArrayDemo {
public static void main(String[] args) {
int[] array1 = new int[2]; // 指定数组维度
int[] array2 = new int[] { 1, 2 }; // 不指定数组维度

System.out.println("array1 size is " + array1.length);
for (int item : array1) {
System.out.println(item);
}

System.out.println("array2 size is " + array1.length);
for (int item : array2) {
System.out.println(item);
}
}
}
// Output:
// array1 size is 2
// 0
// 0
// array2 size is 2
// 1
// 2

💡 说明
请注意数组 array1 中的元素虽然没有初始化,但是 length 和指定的数组维度是一样的。这表明指定数组维度后,无论后面是否初始化数组中的元素,数组都已经开辟了相应的内存

数组 array1 中的元素都被设为默认值。

示例 2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class ArrayDemo2 {
static class User {}

public static void main(String[] args) {
User[] array1 = new User[2]; // 指定数组维度
User[] array2 = new User[] {new User(), new User()}; // 不指定数组维度

System.out.println("array1: ");
for (User item : array1) {
System.out.println(item);
}

System.out.println("array2: ");
for (User item : array2) {
System.out.println(item);
}
}
}
// Output:
// array1:
// null
// null
// array2:
// io.github.dunwu.javacore.array.ArrayDemo2$User@4141d797
// io.github.dunwu.javacore.array.ArrayDemo2$User@68f7aae2

💡 说明

请将本例与示例 1 比较,可以发现:如果使用指定数组维度方式创建数组,且数组元素为引用类型,则数组中的元素元素值为 null

数组维度的形式

创建数组时,指定的数组维度可以有多种形式:

  • 数组维度可以是整数、字符。
  • 数组维度可以是整数型、字符型变量。
  • 数组维度可以是计算结果为整数或字符的表达式。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ArrayDemo3 {
public static void main(String[] args) {
int length = 3;
// 放开被注掉的代码,编译器会报错
// int[] array = new int[4.0];
// int[] array2 = new int["test"];
int[] array3 = new int['a'];
int[] array4 = new int[length];
int[] array5 = new int[length + 2];
int[] array6 = new int['a' + 2];
// int[] array7 = new int[length + 2.1];
System.out.println("array3.length = [" + array3.length + "]");
System.out.println("array4.length = [" + array4.length + "]");
System.out.println("array5.length = [" + array5.length + "]");
System.out.println("array6.length = [" + array6.length + "]");
}
}
// Output:
// array3.length = [97]
// array4.length = [3]
// array5.length = [5]
// array6.length = [99]

💡 说明

当指定的数组维度是字符时,Java 会将其转为整数。如字符 a 的 ASCII 码是 97。

综上,Java 数组的数组维度可以是常量、变量、表达式,只要转换为整数即可

请留意,有些编程语言则不支持这点,如 C/C++ 语言,只允许数组维度是常量。

数组维度的大小

数组维度并非没有上限的,如果数值过大,编译时会报错。

1
int[] array = new int[6553612431]; // 数组维度过大,编译报错

此外,数组过大,可能会导致栈溢出

访问数组

Java 中,可以通过在 [] 中指定下标,访问数组元素,下标位置从 0 开始。

1
2
3
4
5
6
7
8
9
10
11
12
13
public class ArrayDemo4 {
public static void main(String[] args) {
int[] array = {1, 2, 3};
for (int i = 0; i < array.length; i++) {
array[i]++;
System.out.println(String.format("array[%d] = %d", i, array[i]));
}
}
}
// Output:
// array[0] = 2
// array[1] = 3
// array[2] = 4

💡 说明

上面的示例中,从 0 开始,使用下标遍历数组 array 的所有元素,为每个元素值加 1 。

数组的引用

Java 中,数组类型是一种引用类型

因此,它可以作为引用,被 Java 函数作为函数入参或返回值

数组作为函数入参的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class ArrayRefDemo {
private static void fun(int[] array) {
for (int i : array) {
System.out.print(i + "\t");
}
}

public static void main(String[] args) {
int[] array = new int[] {1, 3, 5};
fun(array);
}
}
// Output:
// 1 3 5

数组作为函数返回值的示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class ArrayRefDemo2 {
/**
* 返回一个数组
*/
private static int[] fun() {
return new int[] {1, 3, 5};
}

public static void main(String[] args) {
int[] array = fun();
System.out.println(Arrays.toString(array));
}
}
// Output:
// [1, 3, 5]

泛型和数组

通常,数组和泛型不能很好地结合。你不能实例化具有参数化类型的数组。

1
Peel<Banana>[] peels = new Pell<Banana>[10]; // 这行代码非法

Java 中不允许直接创建泛型数组。但是,可以通过创建一个类型擦除的数组,然后转型的方式来创建泛型数组。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class GenericArrayDemo<T> {

static class GenericArray<T> {
private T[] array;

public GenericArray(int num) {
array = (T[]) new Object[num];
}

public void put(int index, T item) {
array[index] = item;
}

public T get(int index) { return array[index]; }

public T[] array() { return array; }
}



public static void main(String[] args) {
GenericArray<Integer> genericArray = new GenericArray<Integer>(4);
genericArray.put(0, 0);
genericArray.put(1, 1);
Object[] array = genericArray.array();
System.out.println(Arrays.deepToString(array));
}
}
// Output:
// [0, 1, null, null]

扩展阅读:https://www.cnblogs.com/jiangzhaowei/p/7399522.html

我认为,对于泛型数组的理解,点到为止即可。实际上,真的需要存储泛型,还是使用容器更合适。

多维数组

多维数组可以看成是数组的数组,比如二维数组就是一个特殊的一维数组,其每一个元素都是一个一维数组。

Java 可以支持二维数组、三维数组、四维数组、五维数组。。。

但是,以正常人的理解能力,一般也就最多能理解三维数组。所以,请不要做反人类的事,去定义过多维度的数组。

多维数组使用示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
public class MultiArrayDemo {
public static void main(String[] args) {
Integer[][] a1 = { // 自动装箱
{1, 2, 3,},
{4, 5, 6,},
};
Double[][][] a2 = { // 自动装箱
{ {1.1, 2.2}, {3.3, 4.4} },
{ {5.5, 6.6}, {7.7, 8.8} },
{ {9.9, 1.2}, {2.3, 3.4} },
};
String[][] a3 = {
{"The", "Quick", "Sly", "Fox"},
{"Jumped", "Over"},
{"The", "Lazy", "Brown", "Dog", "and", "friend"},
};
System.out.println("a1: " + Arrays.deepToString(a1));
System.out.println("a2: " + Arrays.deepToString(a2));
System.out.println("a3: " + Arrays.deepToString(a3));
}
}
// Output:
// a1: [[1, 2, 3], [4, 5, 6]]
// a2: [[[1.1, 2.2], [3.3, 4.4]], [[5.5, 6.6], [7.7, 8.8]], [[9.9, 1.2], [2.3, 3.4]]]
// a3: [[The, Quick, Sly, Fox], [Jumped, Over], [The, Lazy, Brown, Dog, and, friend]]

Arrays 类

Java 中,提供了一个很有用的数组工具类:Arrays。

它提供的主要操作有:

  • sort - 排序
  • binarySearch - 查找
  • equals - 比较
  • fill - 填充
  • asList - 转列表
  • hash - 哈希
  • toString - 转字符串

扩展阅读:https://juejin.im/post/5a6ade5c518825733e60acb8

小结

img

参考资料

深入理解 Java 方法

方法(有的人喜欢叫函数)是一段可重用的代码段。

方法的使用

方法定义

方法定义语法格式:

1
2
3
4
5
6
[修饰符] 返回值类型 方法名([参数类型 参数名]){
...
方法体
...
return 返回值;
}

示例:

1
2
3
public static void main(String[] args) {
System.out.println("Hello World");
}

方法包含一个方法头和一个方法体。下面是一个方法的所有部分:

  • 修饰符 - 修饰符是可选的,它告诉编译器如何调用该方法。定义了该方法的访问类型。
  • 返回值类型 - 返回值类型表示方法执行结束后,返回结果的数据类型。如果没有返回值,应设为 void。
  • 方法名 - 是方法的实际名称。方法名和参数表共同构成方法签名。
  • 参数类型 - 参数像是一个占位符。当方法被调用时,传递值给参数。参数列表是指方法的参数类型、顺序和参数的个数。参数是可选的,方法可以不包含任何参数。
  • 方法体 - 方法体包含具体的语句,定义该方法的功能。
  • return - 必须返回声明方法时返回值类型相同的数据类型。在 void 方法中,return 语句可有可无,如果要写 return,则只能是 return; 这种形式。

方法的调用

当程序调用一个方法时,程序的控制权交给了被调用的方法。当被调用方法的返回语句执行或者到达方法体闭括号时候交还控制权给程序。

Java 支持两种调用方法的方式,根据方法是否有返回值来选择。

  • 有返回值方法 - 有返回值方法通常被用来给一个变量赋值或代入到运算表达式中进行计算。
1
int larger = max(30, 40);
  • 无返回值方法 - 无返回值方法只能是一条语句。
1
System.out.println("Hello World");

递归调用

Java 支持方法的递归调用(即方法调用自身)。

🔔 注意:

  • 递归方法必须有明确的结束条件。
  • 尽量避免使用递归调用。因为递归调用如果处理不当,可能导致栈溢出。

斐波那契数列(一个典型的递归算法)示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class RecursionMethodDemo {
public static int fib(int num) {
if (num == 1 || num == 2) {
return 1;
} else {
return fib(num - 2) + fib(num - 1);
}
}

public static void main(String[] args) {
for (int i = 1; i < 10; i++) {
System.out.print(fib(i) + "\t");
}
}
}

方法参数

在 C/C++ 等编程语言中,方法的参数传递一般有两种形式:

  • 值传递 - 值传递的参数被称为形参。值传递时,传入的参数,在方法中的修改,不会在方法外部生效。
  • 引用传递 - 引用传递的参数被称为实参。引用传递时,传入的参数,在方法中的修改,会在方法外部生效。

那么,Java 中是怎样的呢?

Java 中只有值传递。

示例一:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class MethodParamDemo {
public static void method(int value) {
value = value + 1;
}
public static void main(String[] args) {
int num = 0;
method(num);
System.out.println("num = [" + num + "]");
method(num);
System.out.println("num = [" + num + "]");
}
}
// Output:
// num = [0]
// num = [0]

示例二:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public class MethodParamDemo2 {
public static void method(StringBuilder sb) {
sb = new StringBuilder("B");
}

public static void main(String[] args) {
StringBuilder sb = new StringBuilder("A");
System.out.println("sb = [" + sb.toString() + "]");
method(sb);
System.out.println("sb = [" + sb.toString() + "]");
sb = new StringBuilder("C");
System.out.println("sb = [" + sb.toString() + "]");
}
}
// Output:
// sb = [A]
// sb = [A]
// sb = [C]

说明:

以上两个示例,无论向方法中传入的是基础数据类型,还是引用类型,在方法中修改的值,在外部都未生效。

Java 对于基本数据类型,会直接拷贝值传递到方法中;对于引用数据类型,拷贝当前对象的引用地址,然后把该地址传递过去,所以也是值传递。

扩展阅读:

图解 Java 中的参数传递

方法修饰符

前面提到了,Java 方法的修饰符是可选的,它告诉编译器如何调用该方法。定义了该方法的访问类型。

Java 方法有好几个修饰符,让我们一一来认识一下:

访问控制修饰符

访问权限控制的等级,从最大权限到最小权限依次为:

1
public > protected > 包访问权限(没有任何关键字)> private
  • public - 表示任何类都可以访问;
  • 包访问权限 - 包访问权限,没有任何关键字。它表示当前包中的所有其他类都可以访问,但是其它包的类无法访问。
  • protected - 表示子类可以访问,此外,同一个包内的其他类也可以访问,即使这些类不是子类。
  • private - 表示其它任何类都无法访问。

static

static 修饰的方法被称为静态方法。

静态方法相比于普通的实例方法,主要有以下区别:

  • 在外部调用静态方法时,可以使用 类名.方法名 的方式,也可以使用 对象名.方法名 的方式。而实例方法只有后面这种方式。也就是说,调用静态方法可以无需创建对象

  • 静态方法在访问本类的成员时,只允许访问静态成员(即静态成员变量和静态方法),而不允许访问实例成员变量和实例方法;实例方法则无此限制。

静态方法常被用于各种工具类、工厂方法类。

final

final 修饰的方法不能被子类覆写(Override)。

final 方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class FinalMethodDemo {
static class Father {
protected final void print() {
System.out.println("call Father print()");
};
}

static class Son extends Father {
@Override
protected void print() {
System.out.println("call print()");
}
}

public static void main(String[] args) {
Father demo = new Son();
demo.print();
}
}
// 编译时会报错

说明:

上面示例中,父类 Father 中定义了一个 final 方法 print(),则其子类不能 Override 这个 final 方法,否则会编译报错。

default

JDK8 开始,支持在接口 Interface 中定义 default 方法。**default 方法只能出现在接口 Interface 中**。

接口中被 default 修饰的方法被称为默认方法,实现此接口的类如果没 Override 此方法,则直接继承这个方法,不再强制必须实现此方法。

default 方法语法的出现,是为了既有的成千上万的 Java 类库的类增加新的功能, 且不必对这些类重新进行设计。 举例来说,JDK8 中 Collection 类中有一个非常方便的 stream() 方法,就是被修饰为 default,Collection 的一大堆 List、Set 子类就直接继承了这个方法 I,不必再为每个子类都注意添加这个方法。

default 方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class DefaultMethodDemo {
interface MyInterface {
default void print() {
System.out.println("Hello World");
}
}


static class MyClass implements MyInterface {}

public static void main(String[] args) {
MyInterface obj = new MyClass();
obj.print();
}
}
// Output:
// Hello World

abstract

abstract 修饰的方法被称为抽象方法,方法不能有实体。抽象方法只能出现抽象类中。

抽象方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class AbstractMethodDemo {
static abstract class AbstractClass {
abstract void print();
}

static class ConcreteClass extends AbstractClass {
@Override
void print() {
System.out.println("call print()");
}
}

public static void main(String[] args) {
AbstractClass demo = new ConcreteClass();
demo.print();
}

}
// Outpu:
// call print()

synchronized

synchronized 用于并发编程。synchronized 修饰的方法在一个时刻,只允许一个线程执行。

在 Java 的同步容器(Vector、Stack、HashTable)中,你会见到大量的 synchronized 方法。不过,请记住:在 Java 并发编程中,synchronized 方法并不是一个好的选择,大多数情况下,我们会选择更加轻量级的锁 。

特殊方法

Java 中,有一些较为特殊的方法,分别使用于特殊的场景。

main 方法

Java 中的 main 方法是一种特殊的静态方法,因为所有的 Java 程序都是由 public static void main(String[] args) 方法开始执行。

有很多新手虽然一直用 main 方法,却不知道 main 方法中的 args 有什么用。实际上,这是用来接收接收命令行输入参数的。

示例:

1
2
3
4
5
6
7
public class MainMethodDemo {
public static void main(String[] args) {
for (String arg : args) {
System.out.println("arg = [" + arg + "]");
}
}
}

依次执行

1
2
javac MainMethodDemo.java
java MainMethodDemo A B C

控制台会打印输出参数:

1
2
3
arg = [A]
arg = [B]
arg = [C]

构造方法

任何类都有构造方法,构造方法的作用就是在初始化类实例时,设置实例的状态。

每个类都有构造方法。如果没有显式地为类定义任何构造方法,Java 编译器将会为该类提供一个默认构造方法。

在创建一个对象的时候,至少要调用一个构造方法。构造方法的名称必须与类同名,一个类可以有多个构造方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class ConstructorMethodDemo {

static class Person {
private String name;

public Person(String name) {
this.name = name;
}

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

public static void main(String[] args) {
Person person = new Person("jack");
System.out.println("person name is " + person.getName());
}
}

注意,构造方法除了使用 public,也可以使用 private 修饰,这种情况下,类无法调用此构造方法去实例化对象,这常常用于设计模式中的单例模式。

变参方法

JDK5 开始,Java 支持传递同类型的可变参数给一个方法。在方法声明中,在指定参数类型后加一个省略号 ...。一个方法中只能指定一个可变参数,它必须是方法的最后一个参数。任何普通的参数必须在它之前声明。

变参方法示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class VarargsDemo {
public static void method(String... params) {
System.out.println("params.length = " + params.length);
for (String param : params) {
System.out.println("params = [" + param + "]");
}
}

public static void main(String[] args) {
method("red");
method("red", "yellow");
method("red", "yellow", "blue");
}
}
// Output:
// params.length = 1
// params = [red]
// params.length = 2
// params = [red]
// params = [yellow]
// params.length = 3
// params = [red]
// params = [yellow]
// params = [blue]

finalize() 方法

finalize 在对象被垃圾收集器析构(回收)之前调用,用来清除回收对象。

finalize 是在 java.lang.Object 里定义的,也就是说每一个对象都有这么个方法。这个方法在 GC 启动,该对象被回收的时候被调用。

finalizer() 通常是不可预测的,也是很危险的,一般情况下是不必要的。使用终结方法会导致行为不稳定、降低性能,以及可移植性问题。

请记住:应该尽量避免使用 finalizer()。千万不要把它当成是 C/C++ 中的析构函数来用。原因是:Finalizer 线程会和我们的主线程进行竞争,不过由于它的优先级较低,获取到的 CPU 时间较少,因此它永远也赶不上主线程的步伐。所以最后可能会发生 OutOfMemoryError 异常。

扩展阅读:

下面两篇文章比较详细的讲述了 finalizer() 可能会造成的问题及原因。

覆写和重载

覆写(Override)是指子类定义了与父类中同名的方法,但是在方法覆写时必须考虑到访问权限,子类覆写的方法不能拥有比父类更加严格的访问权限。

子类要覆写的方法如果要访问父类的方法,可以使用 super 关键字。

覆写示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MethodOverrideDemo {
static class Animal {
public void move() {
System.out.println("会动");
}
}
static class Dog extends Animal {
@Override
public void move() {
super.move();
System.out.println("会跑");
}
}

public static void main(String[] args) {
Animal dog = new Dog();
dog.move();
}
}
// Output:
// 会动
// 会跑

方法的重载(Overload)是指方法名称相同,但参数的类型或参数的个数不同。通过传递参数的个数及类型的不同可以完成不同功能的方法调用。

🔔 注意:

重载一定是方法的参数不完全相同。如果方法的参数完全相同,仅仅是返回值不同,Java 是无法编译通过的。

重载示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class MethodOverloadDemo {
public static void add(int x, int y) {
System.out.println("x + y = " + (x + y));
}

public static void add(double x, double y) {
System.out.println("x + y = " + (x + y));
}

public static void main(String[] args) {
add(10, 20);
add(1.0, 2.0);
}
}
// Output:
// x + y = 30
// x + y = 3.0

小结

img

参考资料

深入理解 Java 枚举

简介

enum 的全称为 enumeration, 是 JDK5 中引入的特性。

在 Java 中,被 enum 关键字修饰的类型就是枚举类型。形式如下:

1
enum ColorEn { RED, GREEN, BLUE }

枚举的好处:可以将常量组织起来,统一进行管理。

枚举的典型应用场景:错误码、状态机等。

枚举的本质

java.lang.Enum类声明

1
2
public abstract class Enum<E extends Enum<E>>
implements Comparable<E>, Serializable { ... }

新建一个 ColorEn.java 文件,内容如下:

1
2
3
4
5
package io.github.dunwu.javacore.enumeration;

public enum ColorEn {
RED,YELLOW,BLUE
}

执行 javac ColorEn.java 命令,生成 ColorEn.class 文件。

然后执行 javap ColorEn.class 命令,输出如下内容:

1
2
3
4
5
6
7
8
9
Compiled from "ColorEn.java"
public final class io.github.dunwu.javacore.enumeration.ColorEn extends java.lang.Enum<io.github.dunwu.javacore.enumeration.ColorEn> {
public static final io.github.dunwu.javacore.enumeration.ColorEn RED;
public static final io.github.dunwu.javacore.enumeration.ColorEn YELLOW;
public static final io.github.dunwu.javacore.enumeration.ColorEn BLUE;
public static io.github.dunwu.javacore.enumeration.ColorEn[] values();
public static io.github.dunwu.javacore.enumeration.ColorEn valueOf(java.lang.String);
static {};
}

💡 说明:

从上面的例子可以看出:

枚举的本质是 java.lang.Enum 的子类。

尽管 enum 看起来像是一种新的数据类型,事实上,enum 是一种受限制的类,并且具有自己的方法。枚举这种特殊的类因为被修饰为 final,所以不能继承其他类。

定义的枚举值,会被默认修饰为 public static final ,从修饰关键字,即可看出枚举值本质上是静态常量。

枚举的方法

在 enum 中,提供了一些基本方法:

  • values():返回 enum 实例的数组,而且该数组中的元素严格保持在 enum 中声明时的顺序。
  • name():返回实例名。
  • ordinal():返回实例声明时的次序,从 0 开始。
  • getDeclaringClass():返回实例所属的 enum 类型。
  • equals() :判断是否为同一个对象。

可以使用 == 来比较enum实例。

此外,java.lang.Enum实现了ComparableSerializable 接口,所以也提供 compareTo() 方法。

例:展示 enum 的基本方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class EnumMethodDemo {
enum Color {RED, GREEN, BLUE;}
enum Size {BIG, MIDDLE, SMALL;}
public static void main(String args[]) {
System.out.println("=========== Print all Color ===========");
for (Color c : Color.values()) {
System.out.println(c + " ordinal: " + c.ordinal());
}
System.out.println("=========== Print all Size ===========");
for (Size s : Size.values()) {
System.out.println(s + " ordinal: " + s.ordinal());
}

Color green = Color.GREEN;
System.out.println("green name(): " + green.name());
System.out.println("green getDeclaringClass(): " + green.getDeclaringClass());
System.out.println("green hashCode(): " + green.hashCode());
System.out.println("green compareTo Color.GREEN: " + green.compareTo(Color.GREEN));
System.out.println("green equals Color.GREEN: " + green.equals(Color.GREEN));
System.out.println("green equals Size.MIDDLE: " + green.equals(Size.MIDDLE));
System.out.println("green equals 1: " + green.equals(1));
System.out.format("green == Color.BLUE: %b\n", green == Color.BLUE);
}
}

输出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
=========== Print all Color ===========
RED ordinal: 0
GREEN ordinal: 1
BLUE ordinal: 2
=========== Print all Size ===========
BIG ordinal: 0
MIDDLE ordinal: 1
SMALL ordinal: 2
green name(): GREEN
green getDeclaringClass(): class org.zp.javase.enumeration.EnumDemo$Color
green hashCode(): 460141958
green compareTo Color.GREEN: 0
green equals Color.GREEN: true
green equals Size.MIDDLE: false
green equals 1: false
green == Color.BLUE: false

枚举的特性

枚举的特性,归结起来就是一句话:

除了不能继承,基本上可以将 enum 看做一个常规的类

但是这句话需要拆分去理解,让我们细细道来。

基本特性

如果枚举中没有定义方法,也可以在最后一个实例后面加逗号、分号或什么都不加。

如果枚举中没有定义方法,枚举值默认为从 0 开始的有序数值。以 Color 枚举类型举例,它的枚举常量依次为 RED:0,GREEN:1,BLUE:2

枚举可以添加方法

在概念章节提到了,枚举值默认为从 0 开始的有序数值 。那么问题来了:如何为枚举显式的赋值。

(1)Java 不允许使用 = 为枚举常量赋值

如果你接触过 C/C++,你肯定会很自然的想到赋值符号 = 。在 C/C++语言中的 enum,可以用赋值符号=显式的为枚举常量赋值;但是 ,很遗憾,Java 语法中却不允许使用赋值符号 = 为枚举常量赋值

例:C/C++ 语言中的枚举声明

1
2
3
4
5
6
typedef enum {
ONE = 1,
TWO,
THREE = 3,
TEN = 10
} Number;

(2)枚举可以添加普通方法、静态方法、抽象方法、构造方法

Java 虽然不能直接为实例赋值,但是它有更优秀的解决方案:为 enum 添加方法来间接实现显式赋值

创建 enum 时,可以为其添加多种方法,甚至可以为其添加构造方法。

注意一个细节:如果要为 enum 定义方法,那么必须在 enum 的最后一个实例尾部添加一个分号。此外,在 enum 中,必须先定义实例,不能将字段或方法定义在实例前面。否则,编译器会报错。

例:全面展示如何在枚举中定义普通方法、静态方法、抽象方法、构造方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public enum ErrorCodeEn {
OK(0) {
@Override
public String getDescription() {
return "成功";
}
},
ERROR_A(100) {
@Override
public String getDescription() {
return "错误A";
}
},
ERROR_B(200) {
@Override
public String getDescription() {
return "错误B";
}
};

private int code;

// 构造方法:enum的构造方法只能被声明为private权限或不声明权限
private ErrorCodeEn(int number) { // 构造方法
this.code = number;
}

public int getCode() { // 普通方法
return code;
} // 普通方法

public abstract String getDescription(); // 抽象方法

public static void main(String args[]) { // 静态方法
for (ErrorCodeEn s : ErrorCodeEn.values()) {
System.out.println("code: " + s.getCode() + ", description: " + s.getDescription());
}
}
}
// Output:
// code: 0, description: 成功
// code: 100, description: 错误A
// code: 200, description: 错误B

注:上面的例子并不可取,仅仅是为了展示枚举支持定义各种方法。正确的例子情况错误码示例

枚举可以实现接口

enum 可以像一般类一样实现接口。

同样是实现上一节中的错误码枚举类,通过实现接口,可以约束它的方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface INumberEnum {
int getCode();
String getDescription();
}

public enum ErrorCodeEn2 implements INumberEnum {
OK(0, "成功"),
ERROR_A(100, "错误A"),
ERROR_B(200, "错误B");

ErrorCodeEn2(int number, String description) {
this.code = number;
this.description = description;
}

private int code;
private String description;

@Override
public int getCode() {
return code;
}

@Override
public String getDescription() {
return description;
}
}

枚举不可以继承

enum 不可以继承另外一个类,当然,也不能继承另一个 enum 。

因为 enum 实际上都继承自 java.lang.Enum 类,而 Java 不支持多重继承,所以 enum 不能再继承其他类,当然也不能继承另一个 enum

枚举的应用

组织常量

在 JDK5 之前,在 Java 中定义常量都是public static final TYPE a; 这样的形式。有了枚举,你可以将有关联关系的常量组织起来,使代码更加易读、安全,并且还可以使用枚举提供的方法。

下面三种声明方式是等价的:

1
2
3
enum Color { RED, GREEN, BLUE }
enum Color { RED, GREEN, BLUE, }
enum Color { RED, GREEN, BLUE; }

switch 状态机

我们经常使用 switch 语句来写状态机。JDK7 以后,switch 已经支持 intcharStringenum 类型的参数。这几种类型的参数比较起来,使用枚举的 switch 代码更具有可读性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class StateMachineDemo {
public enum Signal {
GREEN, YELLOW, RED
}

public static String getTrafficInstruct(Signal signal) {
String instruct = "信号灯故障";
switch (signal) {
case RED:
instruct = "红灯停";
break;
case YELLOW:
instruct = "黄灯请注意";
break;
case GREEN:
instruct = "绿灯行";
break;
default:
break;
}
return instruct;
}

public static void main(String[] args) {
System.out.println(getTrafficInstruct(Signal.RED));
}
}
// Output:
// 红灯停

错误码

枚举常被用于定义程序错误码。下面是一个简单示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class ErrorCodeEnumDemo {
enum ErrorCodeEn {
OK(0, "成功"),
ERROR_A(100, "错误A"),
ERROR_B(200, "错误B");

ErrorCodeEn(int number, String msg) {
this.code = number;
this.msg = msg;
}

private int code;
private String msg;

public int getCode() {
return code;
}

public String getMsg() {
return msg;
}

@Override
public String toString() {
return "ErrorCodeEn{" + "code=" + code + ", msg='" + msg + '\'' + '}';
}

public static String toStringAll() {
StringBuilder sb = new StringBuilder();
sb.append("ErrorCodeEn All Elements: [");
for (ErrorCodeEn code : ErrorCodeEn.values()) {
sb.append(code.getCode()).append(", ");
}
sb.append("]");
return sb.toString();
}
}

public static void main(String[] args) {
System.out.println(ErrorCodeEn.toStringAll());
for (ErrorCodeEn s : ErrorCodeEn.values()) {
System.out.println(s);
}
}
}
// Output:
// ErrorCodeEn All Elements: [0, 100, 200, ]
// ErrorCodeEn{code=0, msg='成功'}
// ErrorCodeEn{code=100, msg='错误A'}
// ErrorCodeEn{code=200, msg='错误B'}

组织枚举

可以将类型相近的枚举通过接口或类组织起来,但是一般用接口方式进行组织。

原因是:Java 接口在编译时会自动为 enum 类型加上public static修饰符;Java 类在编译时会自动为 enum 类型加上 static 修饰符。看出差异了吗?没错,就是说,在类中组织 enum,如果你不给它修饰为 public,那么只能在本包中进行访问。

例:在接口中组织 enum

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
public class EnumInInterfaceDemo {
public interface INumberEnum {
int getCode();
String getDescription();
}


public interface Plant {
enum Vegetable implements INumberEnum {
POTATO(0, "土豆"),
TOMATO(0, "西红柿");

Vegetable(int number, String description) {
this.code = number;
this.description = description;
}

private int code;
private String description;

@Override
public int getCode() {
return this.code;
}

@Override
public String getDescription() {
return this.description;
}
}


enum Fruit implements INumberEnum {
APPLE(0, "苹果"),
ORANGE(0, "桔子"),
BANANA(0, "香蕉");

Fruit(int number, String description) {
this.code = number;
this.description = description;
}

private int code;
private String description;

@Override
public int getCode() {
return this.code;
}

@Override
public String getDescription() {
return this.description;
}
}
}

public static void main(String[] args) {
for (Plant.Fruit f : Plant.Fruit.values()) {
System.out.println(f.getDescription());
}
}
}
// Output:
// 苹果
// 桔子
// 香蕉

例:在类中组织 enum

本例和上例效果相同。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
public class EnumInClassDemo {
public interface INumberEnum {
int getCode();
String getDescription();
}

public static class Plant2 {
enum Vegetable implements INumberEnum {
// 略,与上面完全相同
}
enum Fruit implements INumberEnum {
// 略,与上面完全相同
}
}

// 略
}
// Output:
// 土豆
// 西红柿

策略枚举

Effective Java 中展示了一种策略枚举。这种枚举通过枚举嵌套枚举的方式,将枚举常量分类处理。

这种做法虽然没有 switch 语句简洁,但是更加安全、灵活。

例:EffectvieJava 中的策略枚举范例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
enum PayrollDay {
MONDAY(PayType.WEEKDAY), TUESDAY(PayType.WEEKDAY), WEDNESDAY(
PayType.WEEKDAY), THURSDAY(PayType.WEEKDAY), FRIDAY(PayType.WEEKDAY), SATURDAY(
PayType.WEEKEND), SUNDAY(PayType.WEEKEND);

private final PayType payType;

PayrollDay(PayType payType) {
this.payType = payType;
}

double pay(double hoursWorked, double payRate) {
return payType.pay(hoursWorked, payRate);
}

// 策略枚举
private enum PayType {
WEEKDAY {
double overtimePay(double hours, double payRate) {
return hours <= HOURS_PER_SHIFT ? 0 : (hours - HOURS_PER_SHIFT)
* payRate / 2;
}
},
WEEKEND {
double overtimePay(double hours, double payRate) {
return hours * payRate / 2;
}
};
private static final int HOURS_PER_SHIFT = 8;

abstract double overtimePay(double hrs, double payRate);

double pay(double hoursWorked, double payRate) {
double basePay = hoursWorked * payRate;
return basePay + overtimePay(hoursWorked, payRate);
}
}
}

测试

1
2
System.out.println("时薪100的人在周五工作8小时的收入:" + PayrollDay.FRIDAY.pay(8.0, 100));
System.out.println("时薪100的人在周六工作8小时的收入:" + PayrollDay.SATURDAY.pay(8.0, 100));

枚举实现单例模式

单例模式是最常用的设计模式。

单例模式在并发环境下存在线程安全问题。

为了线程安全问题,传统做法有以下几种:

  • 饿汉式加载
  • 懒汉式 synchronize 和双重检查
  • 利用 java 的静态加载机制

相比上述的方法,使用枚举也可以实现单例,而且还更加简单:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class SingleEnumDemo {
public enum SingleEn {

INSTANCE;

private String name;

public String getName() {
return name;
}

public void setName(String name) {
this.name = name;
}
}

public static void main(String[] args) {
SingleEn.INSTANCE.setName("zp");
System.out.println(SingleEn.INSTANCE.getName());
}
}

扩展阅读:深入理解 Java 枚举类型(enum)

这篇文章对于 Java 枚举的特性讲解很仔细,其中对于枚举实现单例和传统单例实现方式说的尤为细致。

枚举工具类

Java 中提供了两个方便操作 enum 的工具类——EnumSetEnumMap

EnumSet

EnumSet 是枚举类型的高性能 Set 实现。它要求放入它的枚举常量必须属于同一枚举类型。

主要接口:

  • noneOf - 创建一个具有指定元素类型的空 EnumSet
  • allOf - 创建一个指定元素类型并包含所有枚举值的 EnumSet
  • range - 创建一个包括枚举值中指定范围元素的 EnumSet
  • complementOf - 初始集合包括指定集合的补集
  • of - 创建一个包括参数中所有元素的 EnumSet
  • copyOf - 创建一个包含参数容器中的所有元素的 EnumSet

示例:

1
2
3
4
5
6
7
8
9
public class EnumSetDemo {
public static void main(String[] args) {
System.out.println("EnumSet展示");
EnumSet<ErrorCodeEn> errSet = EnumSet.allOf(ErrorCodeEn.class);
for (ErrorCodeEn e : errSet) {
System.out.println(e.name() + " : " + e.ordinal());
}
}
}

EnumMap

EnumMap 是专门为枚举类型量身定做的 Map 实现。虽然使用其它的 Map 实现(如 HashMap)也能完成枚举类型实例到值得映射,但是使用 EnumMap 会更加高效:它只能接收同一枚举类型的实例作为键值,并且由于枚举类型实例的数量相对固定并且有限,所以 EnumMap 使用数组来存放与枚举类型对应的值。这使得 EnumMap 的效率非常高。

主要接口:

  • size - 返回键值对数
  • containsValue - 是否存在指定的 value
  • containsKey - 是否存在指定的 key
  • get - 根据指定 key 获取 value
  • put - 取出指定的键值对
  • remove - 删除指定 key
  • putAll - 批量取出键值对
  • clear - 清除数据
  • keySet - 获取 key 集合
  • values - 返回所有

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class EnumMapDemo {
public enum Signal {
GREEN, YELLOW, RED
}

public static void main(String[] args) {
System.out.println("EnumMap展示");
EnumMap<Signal, String> errMap = new EnumMap(Signal.class);
errMap.put(Signal.RED, "红灯");
errMap.put(Signal.YELLOW, "黄灯");
errMap.put(Signal.GREEN, "绿灯");
for (Iterator<Map.Entry<Signal, String>> iter = errMap.entrySet().iterator(); iter.hasNext();) {
Map.Entry<Signal, String> entry = iter.next();
System.out.println(entry.getKey().name() + " : " + entry.getValue());
}
}
}

扩展阅读:深入理解 Java 枚举类型(enum)

这篇文章中对 EnumSet 和 EnumMap 原理做了较为详细的介绍。

小结

img

参考资料

深入理解 Java 注解

本文内容基于 JDK8。注解是 JDK5 引入的,后续 JDK 版本扩展了一些内容,本文中没有明确指明版本的注解都是 JDK5 就已经支持的注解。

简介

注解的形式

Java 中,注解是以 @ 字符开始的修饰符。如下:

1
2
@Override
void mySuperMethod() { ... }

注解可以包含命名或未命名的属性,并且这些属性有值。

1
2
3
4
5
@Author(
name = "Benjamin Franklin",
date = "3/27/2003"
)
class MyClass() { ... }

如果只有一个名为 value 的属性,那么名称可以省略,如:

1
2
@SuppressWarnings("unchecked")
void myMethod() { ... }

如果注解没有属性,则称为标记注解。如:@Override

什么是注解

从本质上来说,注解是一种标签,其实质上可以视为一种特殊的注释,如果没有解析它的代码,它并不比普通注释强。

解析一个注解往往有两种形式:

  • 编译期直接的扫描 - 编译器的扫描指的是编译器在对 java 代码编译字节码的过程中会检测到某个类或者方法被一些注解修饰,这时它就会对于这些注解进行某些处理。这种情况只适用于 JDK 内置的注解类。
  • 运行期的反射 - 如果要自定义注解,Java 编译器无法识别并处理这个注解,它只能根据该注解的作用范围来选择是否编译进字节码文件。如果要处理注解,必须利用反射技术,识别该注解以及它所携带的信息,然后做相应的处理。

注解的作用

注解有许多用途:

  • 编译器信息 - 编译器可以使用注解来检测错误或抑制警告。
  • 编译时和部署时的处理 - 程序可以处理注解信息以生成代码,XML 文件等。
  • 运行时处理 - 可以在运行时检查某些注解并处理。

作为 Java 程序员,多多少少都曾经历过被各种配置文件(xml、properties)支配的恐惧。过多的配置文件会使得项目难以维护。个人认为,使用注解以减少配置文件或代码,是注解最大的用处。

注解的代价

凡事有得必有失,注解技术同样如此。使用注解也有一定的代价:

  • 显然,它是一种侵入式编程,那么,自然就存在着增加程序耦合度的问题。
  • 自定义注解的处理需要在运行时,通过反射技术来获取属性。如果注解所修饰的元素是类的非 public 成员,也可以通过反射获取。这就违背了面向对象的封装性。
  • 注解所产生的问题,相对而言,更难以 debug 或定位。

但是,正所谓瑕不掩瑜,注解所付出的代价,相较于它提供的功能而言,还是可以接受的。

注解的应用范围

注解可以应用于类、字段、方法和其他程序元素的声明。

JDK8 开始,注解的应用范围进一步扩大,以下是新的应用范围:

类实例初始化表达式:

1
new @Interned MyObject();

类型转换:

1
myString = (@NonNull String) str;

实现接口的声明:

1
2
class UnmodifiableList<T> implements
@Readonly List<@Readonly T> {}

抛出异常声明:

1
2
void monitorTemperature()
throws @Critical TemperatureException {}

内置注解

JDK 中内置了以下注解:

  • @Override
  • @Deprecated
  • @SuppressWarnnings
  • @SafeVarargs(JDK7 引入)
  • @FunctionalInterface(JDK8 引入)

@Override

@Override 用于表明被修饰方法覆写了父类的方法。

如果试图使用 @Override 标记一个实际上并没有覆写父类的方法时,java 编译器会告警。

@Override 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class OverrideAnnotationDemo {

static class Person {
public String getName() {
return "getName";
}
}


static class Man extends Person {
@Override
public String getName() {
return "override getName";
}

/**
* 放开下面的注释,编译时会告警
*/
/*
@Override
public String getName2() {
return "override getName2";
}
*/
}

public static void main(String[] args) {
Person per = new Man();
System.out.println(per.getName());
}
}

@Deprecated

@Deprecated 用于标明被修饰的类或类成员、类方法已经废弃、过时,不建议使用。

@Deprecated 有一定的延续性:如果我们在代码中通过继承或者覆盖的方式使用了过时的类或类成员,即使子类或子方法没有标记为 @Deprecated,但编译器仍然会告警。

🔔 注意: @Deprecated 这个注解类型和 javadoc 中的 @deprecated 这个 tag 是有区别的:前者是 java 编译器识别的;而后者是被 javadoc 工具所识别用来生成文档(包含程序成员为什么已经过时、它应当如何被禁止或者替代的描述)。

@Deprecated 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
public class DeprecatedAnnotationDemo {
static class DeprecatedField {
@Deprecated
public static final String DEPRECATED_FIELD = "DeprecatedField";
}


static class DeprecatedMethod {
@Deprecated
public String print() {
return "DeprecatedMethod";
}
}


@Deprecated
static class DeprecatedClass {
public String print() {
return "DeprecatedClass";
}
}

public static void main(String[] args) {
System.out.println(DeprecatedField.DEPRECATED_FIELD);

DeprecatedMethod dm = new DeprecatedMethod();
System.out.println(dm.print());


DeprecatedClass dc = new DeprecatedClass();
System.out.println(dc.print());
}
}
//Output:
//DeprecatedField
//DeprecatedMethod
//DeprecatedClass

@SuppressWarnnings

@SuppressWarnings 用于关闭对类、方法、成员编译时产生的特定警告。

@SuppressWarning 不是一个标记注解。它有一个类型为 String[] 的数组成员,这个数组中存储的是要关闭的告警类型。对于 javac 编译器来讲,对 -Xlint 选项有效的警告名也同样对 @SuppressWarings 有效,同时编译器会忽略掉无法识别的警告名。

@SuppressWarning 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
@SuppressWarnings({"rawtypes", "unchecked"})
public class SuppressWarningsAnnotationDemo {
static class SuppressDemo<T> {
private T value;

public T getValue() {
return this.value;
}

public void setValue(T var) {
this.value = var;
}
}

@SuppressWarnings({"deprecation"})
public static void main(String[] args) {
SuppressDemo d = new SuppressDemo();
d.setValue("南京");
System.out.println("地名:" + d.getValue());
}
}

@SuppressWarnings 注解的常见参数值的简单说明:

  • deprecation - 使用了不赞成使用的类或方法时的警告;
  • unchecked - 执行了未检查的转换时的警告,例如当使用集合时没有用泛型 (Generics) 来指定集合保存的类型;
  • fallthrough - 当 Switch 程序块直接通往下一种情况而没有 Break 时的警告;
  • path - 在类路径、源文件路径等中有不存在的路径时的警告;
  • serial - 当在可序列化的类上缺少 serialVersionUID 定义时的警告;
  • finally - 任何 finally 子句不能正常完成时的警告;
  • all - 所有的警告。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@SuppressWarnings({"uncheck", "deprecation"})
public class InternalAnnotationDemo {

/**
* @SuppressWarnings 标记消除当前类的告警信息
*/
@SuppressWarnings({"deprecation"})
static class A {
public void method1() {
System.out.println("call method1");
}

/**
* @Deprecated 标记当前方法为废弃方法,不建议使用
*/
@Deprecated
public void method2() {
System.out.println("call method2");
}
}

/**
* @Deprecated 标记当前类为废弃类,不建议使用
*/
@Deprecated
static class B extends A {
/**
* @Override 标记显示指明当前方法覆写了父类或接口的方法
*/
@Override
public void method1() { }
}

public static void main(String[] args) {
A obj = new B();
obj.method1();
obj.method2();
}
}

@SafeVarargs

@SafeVarargs 在 JDK7 中引入。

@SafeVarargs 的作用是:告诉编译器,在可变长参数中的泛型是类型安全的。可变长参数是使用数组存储的,而数组和泛型不能很好的混合使用。

简单的说,数组元素的数据类型在编译和运行时都是确定的,而泛型的数据类型只有在运行时才能确定下来。因此,当把一个泛型存储到数组中时,编译器在编译阶段无法确认数据类型是否匹配,因此会给出警告信息;即如果泛型的真实数据类型无法和参数数组的类型匹配,会导致 ClassCastException 异常。

@SafeVarargs 注解使用范围:

  • @SafeVarargs 注解可以用于构造方法。
  • @SafeVarargs 注解可以用于 staticfinal 方法。

@SafeVarargs 示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class SafeVarargsAnnotationDemo {
/**
* 此方法实际上并不安全,不使用此注解,编译时会告警
*/
@SafeVarargs
static void wrongMethod(List<String>... stringLists) {
Object[] array = stringLists;
List<Integer> tmpList = Arrays.asList(42);
array[0] = tmpList; // 语法错误,但是编译不告警
String s = stringLists[0].get(0); // 运行时报 ClassCastException
}

public static void main(String[] args) {
List<String> list = new ArrayList<>();
list.add("A");
list.add("B");

List<String> list2 = new ArrayList<>();
list.add("1");
list.add("2");

wrongMethod(list, list2);
}
}

以上代码,如果不使用 @SafeVarargs ,编译时会告警

1
2
[WARNING] /D:/Codes/ZP/Java/javacore/codes/basics/src/main/java/io/github/dunwu/javacore/annotation/SafeVarargsAnnotationDemo.java: 某些输入文件使用了未经检查或不安全的操作。
[WARNING] /D:/Codes/ZP/Java/javacore/codes/basics/src/main/java/io/github/dunwu/javacore/annotation/SafeVarargsAnnotationDemo.java: 有关详细信息, 请使用 -Xlint:unchecked 重新编译。

@FunctionalInterface

@FunctionalInterface 在 JDK8 引入。

@FunctionalInterface 用于指示被修饰的接口是函数式接口。

需要注意的是,如果一个接口符合”函数式接口”定义,不加 @FunctionalInterface 也没关系;但如果编写的不是函数式接口,却使用 @FunctionInterface,那么编译器会报错。

什么是函数式接口?

函数式接口(Functional Interface)就是一个有且仅有一个抽象方法,但是可以有多个非抽象方法的接口。函数式接口可以被隐式转换为 lambda 表达式。

函数式接口的特点:

  • 接口有且只能有个一个抽象方法(抽象方法只有方法定义,没有方法体)。
  • 不能在接口中覆写 Object 类中的 public 方法(写了编译器也会报错)。
  • 允许有 default 实现方法。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class FunctionalInterfaceAnnotationDemo {

@FunctionalInterface
public interface Func1<T> {
void printMessage(T message);
}

/**
* @FunctionalInterface 修饰的接口中定义两个抽象方法,编译时会报错
* @param <T>
*/
/*@FunctionalInterface
public interface Func2<T> {
void printMessage(T message);
void printMessage2(T message);
}*/

public static void main(String[] args) {
Func1 func1 = message -> System.out.println(message);
func1.printMessage("Hello");
func1.printMessage(100);
}
}

元注解

JDK 中虽然内置了几个注解,但这远远不能满足开发过程中遇到的千变万化的需求。所以我们需要自定义注解,而这就需要用到元注解。

元注解的作用就是用于定义其它的注解

Java 中提供了以下元注解类型:

  • @Retention
  • @Target
  • @Documented
  • @Inherited(JDK8 引入)
  • @Repeatable(JDK8 引入)

这些类型和它们所支持的类在 java.lang.annotation 包中可以找到。下面我们看一下每个元注解的作用和相应分参数的使用说明。

@Retention

@Retention 指明了注解的保留级别。

@Retention 源码:

1
2
3
4
5
6
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Retention {
RetentionPolicy value();
}

RetentionPolicy 是一个枚举类型,它定义了被 @Retention 修饰的注解所支持的保留级别:

  • RetentionPolicy.SOURCE - 标记的注解仅在源文件中有效,编译器会忽略。
  • RetentionPolicy.CLASS - 标记的注解在 class 文件中有效,JVM 会忽略。
  • RetentionPolicy.RUNTIME - 标记的注解在运行时有效。

@Retention 示例:

1
2
3
4
5
6
7
8
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Column {
public String name() default "fieldName";
public String setFuncName() default "setField";
public String getFuncName() default "getField";
public boolean defaultDBValue() default false;
}

@Documented

@Documented 表示无论何时使用指定的注解,都应使用 Javadoc(默认情况下,注释不包含在 Javadoc 中)。更多内容可以参考:Javadoc tools page

@Documented 示例:

1
2
3
4
5
6
7
8
9
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Column {
public String name() default "fieldName";
public String setFuncName() default "setField";
public String getFuncName() default "getField";
public boolean defaultDBValue() default false;
}

@Target

@Target 指定注解可以修饰的元素类型。

@Target 源码:

1
2
3
4
5
6
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.ANNOTATION_TYPE)
public @interface Target {
ElementType[] value();
}

ElementType 是一个枚举类型,它定义了被 @Target 修饰的注解可以应用的范围:

  • ElementType.ANNOTATION_TYPE - 标记的注解可以应用于注解类型。
  • ElementType.CONSTRUCTOR - 标记的注解可以应用于构造函数。
  • ElementType.FIELD - 标记的注解可以应用于字段或属性。
  • ElementType.LOCAL_VARIABLE - 标记的注解可以应用于局部变量。
  • ElementType.METHOD - 标记的注解可以应用于方法。
  • ElementType.PACKAGE - 标记的注解可以应用于包声明。
  • ElementType.PARAMETER - 标记的注解可以应用于方法的参数。
  • ElementType.TYPE - 标记的注解可以应用于类的任何元素。

@Target 示例:

1
2
3
4
5
6
7
8
9
10
11
@Target(ElementType.TYPE)
public @interface Table {
/**
* 数据表名称注解,默认值为类名称
* @return
*/
public String tableName() default "className";
}

@Target(ElementType.FIELD)
public @interface NoDBColumn {}

@Inherited

@Inherited 表示注解类型可以被继承(默认情况下不是这样)

表示自动继承注解类型。 如果注解类型声明中存在 @Inherited 元注解,则注解所修饰类的所有子类都将会继承此注解。

🔔 注意:@Inherited 注解类型是被标注过的类的子类所继承。类并不从它所实现的接口继承注解,方法并不从它所覆写的方法继承注解。

此外,当 @Inherited 类型标注的注解的 @RetentionRetentionPolicy.RUNTIME,则反射 API 增强了这种继承性。如果我们使用 java.lang.reflect 去查询一个 @Inherited 类型的注解时,反射代码检查将展开工作:检查类和其父类,直到发现指定的注解类型被发现,或者到达类继承结构的顶层。

1
2
3
4
5
6
@Inherited
public @interface Greeting {
public enum FontColor{ BULE,RED,GREEN};
String name();
FontColor fontColor() default FontColor.GREEN;
}

@Repeatable

@Repeatable 表示注解可以重复使用。

以 Spring @Scheduled 为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Schedules {
Scheduled[] value();
}

@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Repeatable(Schedules.class)
public @interface Scheduled {
// ...
}

应用示例:

1
2
3
4
5
6
public class TaskRunner {

@Scheduled("0 0/15 * * * ?")
@Scheduled("0 0 12 * ?")
public void task1() {}
}

自定义注解

使用 @interface 自定义注解时,自动继承了 java.lang.annotation.Annotation 接口,由编译程序自动完成其他细节。在定义注解时,不能继承其他的注解或接口。@interface 用来声明一个注解,其中的每一个方法实际上是声明了一个配置参数。方法的名称就是参数的名称,返回值类型就是参数的类型(返回值类型只能是基本类型、Class、String、enum)。可以通过 default 来声明参数的默认值。

这里,我会通过实现一个名为 RegexValid 的正则校验注解工具来展示自定义注解的全步骤。

注解的定义

注解的语法格式如下:

1
public @interface 注解名 {定义体}

我们来定义一个注解:

1
2
3
4
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface RegexValid {}

说明:

通过上一节对于元注解 @Target@Retention@Documented 的说明,这里就很容易理解了。

  • 上面的代码中定义了一个名为 @RegexValid 的注解。
  • @Documented 表示 @RegexValid 应该使用 javadoc。
  • @Target({ElementType.FIELD, ElementType.PARAMETER}) 表示 @RegexValid 可以在类成员或方法参数上修饰。
  • @Retention(RetentionPolicy.RUNTIME) 表示 @RegexValid 在运行时有效。

此时,我们已经定义了一个没有任何属性的注解,如果到此为止,它仅仅是一个标记注解。作为正则工具,没有属性可什么也做不了。接下来,我们将为它添加注解属性。

注解属性

注解属性的语法形式如下:

1
[访问级别修饰符] [数据类型] 名称() default 默认值;

例如,我们要定义在注解中定义一个名为 value 的字符串属性,其默认值为空字符串,访问级别为默认级别,那么应该定义如下:

1
String value() default "";

🔔 注意:**在注解中,我们定义属性时,属性名后面需要加 ()**。

定义注解属性有以下要点:

  • 注解属性只能使用 public 或默认访问级别(即不指定访问级别修饰符)修饰

  • 注解属性的数据类型有限制要求。支持的数据类型如下:

    • 所有基本数据类型(byte、char、short、int、long、float、double、boolean)
    • String 类型
    • Class 类
    • enum 类型
    • Annotation 类型
    • 以上所有类型的数组
  • 注解属性必须有确定的值,建议指定默认值。注解属性只能通过指定默认值或使用注解时指定属性值,相较之下,指定默认值的方式更为可靠。注解属性如果是引用类型,不可以为 null。这个约束使得注解处理器很难判断注解属性是默认值,或是使用注解时所指定的属性值。为此,我们设置默认值时,一般会定义一些特殊的值,例如空字符串或者负数。

  • 如果注解中只有一个属性值,最好将其命名为 value。因为,指定属性名为 value,在使用注解时,指定 value 的值可以不指定属性名称。

1
2
3
// 这两种方式效果相同
@RegexValid("^((\\+)?86\\s*)?((13[0-9])|(15([0-3]|[5-9]))|(18[0,2,5-9]))\\d{8}$")
@RegexValid(value = "^((\\+)?86\\s*)?((13[0-9])|(15([0-3]|[5-9]))|(18[0,2,5-9]))\\d{8}$")

示例:

了解了注解属性的定义要点,让我们来为 @RegexValid 注解定义几个属性。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
@Documented
@Target({ElementType.FIELD, ElementType.PARAMETER})
@Retention(RetentionPolicy.RUNTIME)
public @interface RegexValid {
enum Policy {
// @formatter:off
EMPTY(null),
DATE("^(?:(?!0000)[0-9]{4}([-/.]?)(?:(?:0?[1-9]|1[0-2])\\1(?:0?[1-9]|1[0-9]|2[0-8])|(?:0?[13-9]|1[0-2])\\1"
+ "(?:29|30)|(?:0?[13578]|1[02])\\1(?:31))|(?:[0-9]{2}(?:0[48]|[2468][048]|[13579][26])|"
+ "(?:0[48]|[2468][048]|[13579][26])00)([-/.]?)0?2\\2(?:29))$"),
MAIL("^[A-Za-z0-9](([_\\.\\-]?[a-zA-Z0-9]+)*)@([A-Za-z0-9]+)(([\\.\\-]?[a-zA-Z0-9]+)*)\\.([A-Za-z]{2,})$");
// @formatter:on

private String policy;

Policy(String policy) {
this.policy = policy;
}

public String getPolicy() {
return policy;
}
}

String value() default "";
Policy policy() default Policy.EMPTY;
}

说明:

在上面的示例代码中,我们定义了两个注解属性:String 类型的 value 属性和 Policy 枚举类型的 policy 属性。Policy 枚举中定义了几个默认的正则表达式,这是为了直接使用这几个常用表达式去正则校验。考虑到,我们可能需要自己传入一些自定义正则表达式去校验其他场景,所以定义了 value 属性,允许使用者传入正则表达式。

至此,@RegexValid 的声明已经结束。但是,程序仍不知道如何处理 @RegexValid 这个注解。我们还需要定义注解处理器。

注解处理器

如果没有用来读取注解的方法和工作,那么注解也就不会比注释更有用处了。使用注解的过程中,很重要的一部分就是创建于使用注解处理器。JDK5 扩展了反射机制的 API,以帮助程序员快速的构造自定义注解处理器。

java.lang.annotation.Annotation 是一个接口,程序可以通过反射来获取指定程序元素的注解对象,然后通过注解对象来获取注解里面的元数据

Annotation 接口源码如下:

1
2
3
4
5
6
7
8
9
public interface Annotation {
boolean equals(Object obj);

int hashCode();

String toString();

Class<? extends Annotation> annotationType();
}

除此之外,Java 中支持注解处理器接口 java.lang.reflect.AnnotatedElement ,该接口代表程序中可以接受注解的程序元素,该接口主要有如下几个实现类:

  • Class - 类定义
  • Constructor - 构造器定义
  • Field - 累的成员变量定义
  • Method - 类的方法定义
  • Package - 类的包定义

java.lang.reflect 包下主要包含一些实现反射功能的工具类。实际上,java.lang.reflect 包所有提供的反射 API 扩充了读取运行时注解信息的能力。当一个注解类型被定义为运行时的注解后,该注解才能是运行时可见,当 class 文件被装载时被保存在 class 文件中的注解才会被虚拟机读取。
AnnotatedElement 接口是所有程序元素(Class、Method 和 Constructor)的父接口,所以程序通过反射获取了某个类的AnnotatedElement 对象之后,程序就可以调用该对象的如下四个个方法来访问注解信息:

  • getAnnotation - 返回该程序元素上存在的、指定类型的注解,如果该类型注解不存在,则返回 null。
  • getAnnotations - 返回该程序元素上存在的所有注解。
  • isAnnotationPresent - 判断该程序元素上是否包含指定类型的注解,存在则返回 true,否则返回 false。
  • getDeclaredAnnotations - 返回直接存在于此元素上的所有注释。与此接口中的其他方法不同,该方法将忽略继承的注释。(如果没有注释直接存在于此元素上,则返回长度为零的一个数组。)该方法的调用者可以随意修改返回的数组;这不会对其他调用者返回的数组产生任何影响。

了解了以上内容,让我们来实现 @RegexValid 的注解处理器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
import java.lang.reflect.Field;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

public class RegexValidUtil {
public static boolean check(Object obj) throws Exception {
boolean result = true;
StringBuilder sb = new StringBuilder();
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
// 判断成员是否被 @RegexValid 注解所修饰
if (field.isAnnotationPresent(RegexValid.class)) {
RegexValid valid = field.getAnnotation(RegexValid.class);

// 如果 value 为空字符串,说明没有注入自定义正则表达式,改用 policy 属性
String value = valid.value();
if ("".equals(value)) {
RegexValid.Policy policy = valid.policy();
value = policy.getPolicy();
}

// 通过设置 setAccessible(true) 来访问私有成员
field.setAccessible(true);
Object fieldObj = null;
try {
fieldObj = field.get(obj);
} catch (IllegalAccessException e) {
e.printStackTrace();
}
if (fieldObj == null) {
sb.append("\n")
.append(String.format("%s 类中的 %s 字段不能为空!", obj.getClass().getName(), field.getName()));
result = false;
} else {
if (fieldObj instanceof String) {
String text = (String) fieldObj;
Pattern p = Pattern.compile(value);
Matcher m = p.matcher(text);
result = m.matches();
if (!result) {
sb.append("\n").append(String.format("%s 不是合法的 %s !", text, field.getName()));
}
} else {
sb.append("\n").append(
String.format("%s 类中的 %s 字段不是字符串类型,不能使用此注解校验!", obj.getClass().getName(), field.getName()));
result = false;
}
}
}
}

if (sb.length() > 0) {
throw new Exception(sb.toString());
}
return result;
}
}

说明:

以上示例中的注解处理器,执行步骤如下:

  1. 通过 getDeclaredFields 反射方法获取传入对象的所有成员。
  2. 遍历成员,使用 isAnnotationPresent 判断成员是否被指定注解所修饰,如果不是,直接跳过。
  3. 如果成员被注解所修饰,通过 RegexValid valid = field.getAnnotation(RegexValid.class); 这样的形式获取,注解实例化对象,然后,就可以使用 valid.value()valid.policy() 这样的形式获取注解中设定的属性值。
  4. 根据属性值,进行逻辑处理。

使用注解

完成了以上工作,我们就可以使用自定义注解了,示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
public class RegexValidDemo {
static class User {
private String name;
@RegexValid(policy = RegexValid.Policy.DATE)
private String date;
@RegexValid(policy = RegexValid.Policy.MAIL)
private String mail;
@RegexValid("^((\\+)?86\\s*)?((13[0-9])|(15([0-3]|[5-9]))|(18[0,2,5-9]))\\d{8}$")
private String phone;

public User(String name, String date, String mail, String phone) {
this.name = name;
this.date = date;
this.mail = mail;
this.phone = phone;
}

@Override
public String toString() {
return "User{" + "name='" + name + '\'' + ", date='" + date + '\'' + ", mail='" + mail + '\'' + ", phone='"
+ phone + '\'' + '}';
}
}

static void printDate(@RegexValid(policy = RegexValid.Policy.DATE) String date){
System.out.println(date);
}

public static void main(String[] args) throws Exception {
User user = new User("Tom", "1990-01-31", "xxx@163.com", "18612341234");
User user2 = new User("Jack", "2019-02-29", "sadhgs", "183xxxxxxxx");
if (RegexValidUtil.check(user)) {
System.out.println(user + "正则校验通过");
}
if (RegexValidUtil.check(user2)) {
System.out.println(user2 + "正则校验通过");
}
}
}

小结

img

img

img

img

参考资料

JDK8 入门指南

JDK8 升级常见问题章节是我个人的经验整理。其他内容基本翻译自 java8-tutorial

📦 本文以及示例源码已归档在 javacore

关键词:StreamlambdaOptional@FunctionalInterface

Default Methods for Interfaces(接口的默认方法)

Java 8 使我们能够通过使用 default 关键字将非抽象方法实现添加到接口。这个功能也被称为虚拟扩展方法。

这是我们的第一个例子:

1
2
3
4
5
6
7
interface Formula {
double calculate(int a);

default double sqrt(int a) {
return Math.sqrt(a);
}
}

除了抽象方法 calculate ,接口 Formula 还定义了默认方法 sqrt。具体类只需要执行抽象方法计算。默认的方法 sqrt 可以用于开箱即用。

1
2
3
4
5
6
7
8
9
Formula formula = new Formula() {
@Override
public double calculate(int a) {
return sqrt(a * 100);
}
};

formula.calculate(100); // 100.0
formula.sqrt(16); // 4.0

Formula 被实现为一个匿名对象。代码非常冗长:用于 sqrt(a * 100) 这样简单的计算的 6 行代码。正如我们将在下一节中看到的,在 Java 8 中实现单个方法对象有更好的方法。

Lambda expressions(Lambda 表达式)

让我们从一个简单的例子来说明如何在以前版本的 Java 中对字符串列表进行排序:

1
2
3
4
5
6
7
8
List<String> names = Arrays.asList("peter", "anna", "mike", "xenia");

Collections.sort(names, new Comparator<String>() {
@Override
public int compare(String a, String b) {
return b.compareTo(a);
}
});

静态工具方法 Collections.sort 为了对指定的列表进行排序,接受一个列表和一个比较器。您会发现自己经常需要创建匿名比较器并将其传递给排序方法。

Java 8 使用更简短的 lambda 表达式来避免常常创建匿名对象的问题:

1
2
3
Collections.sort(names, (String a, String b) -> {
return b.compareTo(a);
});

如您所见,这段代码比上段代码简洁很多。但是,还可以更加简洁:

1
Collections.sort(names, (String a, String b) -> b.compareTo(a));

这行代码中,你省去了花括号 {} 和 return 关键字。但是,这还不算完,它还可以再进一步简洁:

1
names.sort((a, b) -> b.compareTo(a));

列表现在有一个 sort 方法。此外,java 编译器知道参数类型,所以你可以不指定入参的数据类型。让我们深入探讨如何使用 lambda 表达式。

Functional Interfaces(函数接口)

lambda 表达式如何适应 Java 的类型系统?每个 lambda 对应一个由接口指定的类型。一个所谓的函数接口必须包含一个抽象方法声明。该类型的每个 lambda 表达式都将与此抽象方法匹配。由于默认方法不是抽象的,所以你可以自由地添加默认方法到你的函数接口。

只要保证接口仅包含一个抽象方法,就可以使用任意的接口作为 lambda 表达式。为确保您的接口符合要求,您应该添加 @FunctionalInterface 注解。编译器注意到这个注解后,一旦您尝试在接口中添加第二个抽象方法声明,编译器就会抛出编译器错误。

示例:

1
2
3
4
@FunctionalInterface
interface Converter<F, T> {
T convert(F from);
}
1
2
3
Converter<String, Integer> converter = (from) -> Integer.valueOf(from);
Integer converted = converter.convert("123");
System.out.println(converted); // 123

请记住,如果 @FunctionalInterface 注解被省略,代码也是有效的。

Method and Constructor References(方法和构造器引用)

上面的示例代码可以通过使用静态方法引用进一步简化:

1
2
3
Converter<String, Integer> converter = Integer::valueOf;
Integer converted = converter.convert("123");
System.out.println(converted); // 123

Java 8 允许您通过 :: 关键字传递方法或构造函数的引用。上面的例子展示了如何引用一个静态方法。但是我们也可以引用对象方法:

1
2
3
4
5
class Something {
String startsWith(String s) {
return String.valueOf(s.charAt(0));
}
}
1
2
3
4
Something something = new Something();
Converter<String, String> converter = something::startsWith;
String converted = converter.convert("Java");
System.out.println(converted); // "J"

我们来观察一下 :: 关键字是如何作用于构造器的。首先,我们定义一个有多个构造器的示例类。

1
2
3
4
5
6
7
8
9
10
11
class Person {
String firstName;
String lastName;

Person() {}

Person(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
}

接着,我们指定一个用于创建 Person 对象的 PersonFactory 接口。

1
2
3
interface PersonFactory<P extends Person> {
P create(String firstName, String lastName);
}

我们不是手动实现工厂,而是通过构造引用将所有东西粘合在一起:

1
2
PersonFactory<Person> personFactory = Person::new;
Person person = personFactory.create("Peter", "Parker");

我们通过 Person::new 来创建一个 Person 构造器的引用。Java 编译器会根据PersonFactory.create 的签名自动匹配正确的构造器。

Lambda Scopes(Lambda 作用域)

从 lambda 表达式访问外部作用域变量与匿名对象非常相似。您可以访问本地外部作用域的常量以及实例的成员变量和静态变量。

Accessing local variables(访问本地变量)

我们可以访问 lambda 表达式作用域外部的常量:

1
2
3
4
5
final int num = 1;
Converter<Integer, String> stringConverter =
(from) -> String.valueOf(from + num);

stringConverter.convert(2); // 3

不同于匿名对象的是:这个变量 num 不是一定要被 final 修饰。下面的代码一样合法:

1
2
3
4
5
int num = 1;
Converter<Integer, String> stringConverter =
(from) -> String.valueOf(from + num);

stringConverter.convert(2); // 3

但是,num 必须是隐式常量的。下面的代码不能编译通过:

1
2
3
4
int num = 1;
Converter<Integer, String> stringConverter =
(from) -> String.valueOf(from + num);
num = 3;

此外,在 lambda 表达式中对 num 做写操作也是被禁止的。

Accessing fields and static variables(访问成员变量和静态变量)

与局部变量相比,我们既可以在 lambda 表达式中读写实例的成员变量,也可以读写实例的静态变量。这种行为在匿名对象中是众所周知的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Lambda4 {
static int outerStaticNum;
int outerNum;

void testScopes() {
Converter<Integer, String> stringConverter1 = (from) -> {
outerNum = 23;
return String.valueOf(from);
};

Converter<Integer, String> stringConverter2 = (from) -> {
outerStaticNum = 72;
return String.valueOf(from);
};
}
}

Accessing Default Interface Methods(访问默认的接口方法)

还记得第一节的 formula 例子吗? Formula 接口定义了一个默认方法 sqrt,它可以被每个 formula 实例(包括匿名对象)访问。这个特性不适用于 lambda 表达式。

默认方法不能被 lambda 表达式访问。下面的代码不能编译通过:

1
Formula formula = (a) -> sqrt(a * 100);

Built-in Functional Interfaces(内置函数接口)

JDK 1.8 API 包含许多内置的功能接口。它们中的一些在较早的 Java 版本(比如 ComparatorRunnable)中是众所周知的。这些现有的接口通过 @FunctionalInterfaceannotation 注解被扩展为支持 Lambda。

但是,Java 8 API 也提供了不少新的函数接口。其中一些新接口在 Google Guava 库中是众所周知的。即使您熟悉这个库,也应该密切关注如何通过一些有用的方法扩展来扩展这些接口。

Predicates

Predicate 是只有一个参数的布尔值函数。该接口包含各种默认方法,用于将谓词组合成复杂的逻辑术语(与、或、非)

1
2
3
4
5
6
7
8
9
10
Predicate<String> predicate = (s) -> s.length() > 0;

predicate.test("foo"); // true
predicate.negate().test("foo"); // false

Predicate<Boolean> nonNull = Objects::nonNull;
Predicate<Boolean> isNull = Objects::isNull;

Predicate<String> isEmpty = String::isEmpty;
Predicate<String> isNotEmpty = isEmpty.negate();

Functions

Function 接受一个参数并产生一个结果。可以使用默认方法将多个函数链接在一起(compose、andThen)。

1
2
3
4
Function<String, Integer> toInteger = Integer::valueOf;
Function<String, String> backToString = toInteger.andThen(String::valueOf);

backToString.apply("123"); // "123"

Suppliers

Supplier 产生一个泛型结果。与 Function 不同,Supplier 不接受参数。

1
2
Supplier<Person> personSupplier = Person::new;
personSupplier.get(); // new Person

Consumers

Consumer 表示要在一个输入参数上执行的操作。

1
2
Consumer<Person> greeter = (p) -> System.out.println("Hello, " + p.firstName);
greeter.accept(new Person("Luke", "Skywalker"));

Comparators

比较器在老版本的 Java 中是众所周知的。 Java 8 为接口添加了各种默认方法。

1
2
3
4
5
6
7
Comparator<Person> comparator = (p1, p2) -> p1.firstName.compareTo(p2.firstName);

Person p1 = new Person("John", "Doe");
Person p2 = new Person("Alice", "Wonderland");

comparator.compare(p1, p2); // > 0
comparator.reversed().compare(p1, p2); // < 0

Optionals

Optional 不是功能性接口,而是防止 NullPointerException 的好工具。这是下一节的一个重要概念,所以让我们快速看看 Optional 是如何工作的。

Optional 是一个简单的容器,其值可以是 null 或非 null。想想一个可能返回一个非空结果的方法,但有时候什么都不返回。不是返回 null,而是返回 Java 8 中的 Optional

1
2
3
4
5
6
7
Optional<String> optional = Optional.of("bam");

optional.isPresent(); // true
optional.get(); // "bam"
optional.orElse("fallback"); // "bam"

optional.ifPresent((s) -> System.out.println(s.charAt(0))); // "b"

Streams

java.util.Stream 表示可以在其上执行一个或多个操作的元素序列。流操作是中间或终端。当终端操作返回一个特定类型的结果时,中间操作返回流本身,所以你可以链接多个方法调用。流在源上创建,例如一个 java.util.Collection 像列表或集合(不支持映射)。流操作既可以按顺序执行,也可以并行执行。

流是非常强大的,所以,我写了一个独立的 Java8 Streams 教程您还应该查看 Sequent,将其作为 Web 的类似库。

我们先来看看顺序流如何工作。首先,我们以字符串列表的形式创建一个示例源代码:

1
2
3
4
5
6
7
8
9
List<String> stringCollection = new ArrayList<>();
stringCollection.add("ddd2");
stringCollection.add("aaa2");
stringCollection.add("bbb1");
stringCollection.add("aaa1");
stringCollection.add("bbb3");
stringCollection.add("ccc");
stringCollection.add("bbb2");
stringCollection.add("ddd1");

Java 8 中的集合已被扩展,因此您可以通过调用 Collection.stream()Collection.parallelStream() 来简单地创建流。以下各节介绍最常见的流操作。

Filter

过滤器接受一个谓词来过滤流的所有元素。这个操作是中间的,使我们能够调用另一个流操作(forEach)的结果。 ForEach 接受一个消费者被执行的过滤流中的每个元素。 ForEach 是一个终端操作。它是无效的,所以我们不能调用另一个流操作。

1
2
3
4
5
6
stringCollection
.stream()
.filter((s) -> s.startsWith("a"))
.forEach(System.out::println);

// "aaa2", "aaa1"

Sorted

排序是一个中间操作,返回流的排序视图。元素按自然顺序排序,除非您传递自定义比较器。

1
2
3
4
5
6
7
stringCollection
.stream()
.sorted()
.filter((s) -> s.startsWith("a"))
.forEach(System.out::println);

// "aaa1", "aaa2"

请记住,排序只会创建流的排序视图,而不会操纵支持的集合的排序。 stringCollection 的排序是不变的:

1
2
System.out.println(stringCollection);
// ddd2, aaa2, bbb1, aaa1, bbb3, ccc, bbb2, ddd1

Map

中间操作映射通过给定函数将每个元素转换为另一个对象。以下示例将每个字符串转换为大写字母字符串。但是您也可以使用 map 将每个对象转换为另一种类型。结果流的泛型类型取决于您传递给 map 的函数的泛型类型。

1
2
3
4
5
6
7
stringCollection
.stream()
.map(String::toUpperCase)
.sorted((a, b) -> b.compareTo(a))
.forEach(System.out::println);

// "DDD2", "DDD1", "CCC", "BBB3", "BBB2", "AAA2", "AAA1"

Match

可以使用各种匹配操作来检查某个谓词是否与流匹配。所有这些操作都是终端并返回布尔结果。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
boolean anyStartsWithA =
stringCollection
.stream()
.anyMatch((s) -> s.startsWith("a"));

System.out.println(anyStartsWithA); // true

boolean allStartsWithA =
stringCollection
.stream()
.allMatch((s) -> s.startsWith("a"));

System.out.println(allStartsWithA); // false

boolean noneStartsWithZ =
stringCollection
.stream()
.noneMatch((s) -> s.startsWith("z"));

System.out.println(noneStartsWithZ); // true

Count

Count 是一个终端操作,返回流中元素的个数。

1
2
3
4
5
6
7
long startsWithB =
stringCollection
.stream()
.filter((s) -> s.startsWith("b"))
.count();

System.out.println(startsWithB); // 3

Reduce

该终端操作使用给定的功能对流的元素进行缩减。结果是一个 Optional 持有缩小后的值。

1
2
3
4
5
6
7
8
Optional<String> reduced =
stringCollection
.stream()
.sorted()
.reduce((s1, s2) -> s1 + "#" + s2);

reduced.ifPresent(System.out::println);
// "aaa1##aaa2##bbb1##bbb2##bbb3##ccc##ddd1##ddd2"

Parallel Streams

如上所述,流可以是顺序的也可以是并行的。顺序流上的操作在单个线程上执行,而并行流上的操作在多个线程上同时执行。

以下示例演示了通过使用并行流提高性能是多么容易。

首先,我们创建一个较大的独特元素的列表:

1
2
3
4
5
6
int max = 1000000;
List<String> values = new ArrayList<>(max);
for (int i = 0; i < max; i++) {
UUID uuid = UUID.randomUUID();
values.add(uuid.toString());
}

现在我们测量对这个集合进行排序所花费的时间。

Sequential Sort

1
2
3
4
5
6
7
8
9
10
11
long t0 = System.nanoTime();

long count = values.stream().sorted().count();
System.out.println(count);

long t1 = System.nanoTime();

long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0);
System.out.println(String.format("sequential sort took: %d ms", millis));

// sequential sort took: 899 ms

Parallel Sort

1
2
3
4
5
6
7
8
9
10
11
long t0 = System.nanoTime();

long count = values.parallelStream().sorted().count();
System.out.println(count);

long t1 = System.nanoTime();

long millis = TimeUnit.NANOSECONDS.toMillis(t1 - t0);
System.out.println(String.format("parallel sort took: %d ms", millis));

// parallel sort took: 472 ms

如你所见,两个代码段差不多,但是并行排序快了近 50%。你所需做的仅仅是将 stream() 改为 parallelStream()

Maps

如前所述,map 不直接支持流。Map 接口本身没有可用的 stream() 方法,但是你可以通过 map.keySet().stream()map.values().stream()map.entrySet().stream() 创建指定的流。

此外,map 支持各种新的、有用的方法来处理常见任务。

1
2
3
4
5
6
7
Map<Integer, String> map = new HashMap<>();

for (int i = 0; i < 10; i++) {
map.putIfAbsent(i, "val" + i);
}

map.forEach((id, val) -> System.out.println(val));

上面的代码应该是自我解释的:putIfAbsent 阻止我们写入额外的空值检查;forEach 接受消费者为 map 的每个值实现操作。

这个例子展示了如何利用函数来计算 map 上的代码:

1
2
3
4
5
6
7
8
9
10
11
map.computeIfPresent(3, (num, val) -> val + num);
map.get(3); // val33

map.computeIfPresent(9, (num, val) -> null);
map.containsKey(9); // false

map.computeIfAbsent(23, num -> "val" + num);
map.containsKey(23); // true

map.computeIfAbsent(3, num -> "bam");
map.get(3); // val33

接下来,我们学习如何删除给定键的条目,只有当前键映射到给定值时:

1
2
3
4
5
map.remove(3, "val3");
map.get(3); // val33

map.remove(3, "val33");
map.get(3); // null

另一个有用方法:

1
map.getOrDefault(42, "not found");  // not found

合并一个 map 的 entry 很简单:

1
2
3
4
5
map.merge(9, "val9", (value, newValue) -> value.concat(newValue));
map.get(9); // val9

map.merge(9, "concat", (value, newValue) -> value.concat(newValue));
map.get(9); // val9concat

如果不存在该键的条目,合并或者将键/值放入 map 中;否则将调用合并函数来更改现有值。

Date API

Java 8 在 java.time 包下新增了一个全新的日期和时间 API。新的日期 API 与 Joda-Time 库相似,但不一样。以下示例涵盖了此新 API 的最重要部分。

Clock

Clock 提供对当前日期和时间的访问。Clock 知道一个时区,可以使用它来代替 System.currentTimeMillis() ,获取从 Unix EPOCH 开始的以毫秒为单位的当前时间。时间线上的某一时刻也由类 Instant 表示。 Instants 可以用来创建遗留的 java.util.Date 对象。

1
2
3
4
5
Clock clock = Clock.systemDefaultZone();
long millis = clock.millis();

Instant instant = clock.instant();
Date legacyDate = Date.from(instant); // legacy java.util.Date

Timezones

时区由 ZoneId 表示。他们可以很容易地通过静态工厂方法访问。时区定义了某一时刻和当地日期、时间之间转换的重要偏移量。

1
2
3
4
5
6
7
8
9
10
System.out.println(ZoneId.getAvailableZoneIds());
// prints all available timezone ids

ZoneId zone1 = ZoneId.of("Europe/Berlin");
ZoneId zone2 = ZoneId.of("Brazil/East");
System.out.println(zone1.getRules());
System.out.println(zone2.getRules());

// ZoneRules[currentStandardOffset=+01:00]
// ZoneRules[currentStandardOffset=-03:00]

LocalTime

LocalTime 代表没有时区的时间,例如晚上 10 点或 17:30:15。以下示例为上面定义的时区创建两个本地时间。然后我们比较两次,并计算两次之间的小时和分钟的差异。

1
2
3
4
5
6
7
8
9
10
LocalTime now1 = LocalTime.now(zone1);
LocalTime now2 = LocalTime.now(zone2);

System.out.println(now1.isBefore(now2)); // false

long hoursBetween = ChronoUnit.HOURS.between(now1, now2);
long minutesBetween = ChronoUnit.MINUTES.between(now1, now2);

System.out.println(hoursBetween); // -3
System.out.println(minutesBetween); // -239

LocalTime 带有各种工厂方法,以简化新实例的创建,包括解析时间字符串。

1
2
3
4
5
6
7
8
9
10
LocalTime late = LocalTime.of(23, 59, 59);
System.out.println(late); // 23:59:59

DateTimeFormatter germanFormatter =
DateTimeFormatter
.ofLocalizedTime(FormatStyle.SHORT)
.withLocale(Locale.GERMAN);

LocalTime leetTime = LocalTime.parse("13:37", germanFormatter);
System.out.println(leetTime); // 13:37

LocalDate

LocalDate 表示不同的日期,例如:2014 年 3 月 11 日。它是不可变的,并且与 LocalTime 完全类似。该示例演示如何通过加减日、月或年来计算新日期。请记住,每个操作都会返回一个新的实例。

1
2
3
4
5
6
7
LocalDate today = LocalDate.now();
LocalDate tomorrow = today.plus(1, ChronoUnit.DAYS);
LocalDate yesterday = tomorrow.minusDays(2);

LocalDate independenceDay = LocalDate.of(2014, Month.JULY, 4);
DayOfWeek dayOfWeek = independenceDay.getDayOfWeek();
System.out.println(dayOfWeek); // FRIDAY

从一个字符串中解析出 LocalDate 对象,和解析 LocalTime 一样的简单:

1
2
3
4
5
6
7
DateTimeFormatter germanFormatter =
DateTimeFormatter
.ofLocalizedDate(FormatStyle.MEDIUM)
.withLocale(Locale.GERMAN);

LocalDate xmas = LocalDate.parse("24.12.2014", germanFormatter);
System.out.println(xmas); // 2014-12-24

LocalDateTime

LocalDateTime 表示日期时间。它将日期和时间组合成一个实例。 LocalDateTime 是不可变的,其作用类似于 LocalTimeLocalDate。我们可以利用方法去获取日期时间中某个单位的值。

1
2
3
4
5
6
7
8
9
10
LocalDateTime sylvester = LocalDateTime.of(2014, Month.DECEMBER, 31, 23, 59, 59);

DayOfWeek dayOfWeek = sylvester.getDayOfWeek();
System.out.println(dayOfWeek); // WEDNESDAY

Month month = sylvester.getMonth();
System.out.println(month); // DECEMBER

long minuteOfDay = sylvester.getLong(ChronoField.MINUTE_OF_DAY);
System.out.println(minuteOfDay); // 1439

通过一个时区的附加信息可以转为一个实例。这个实例很容易转为java.util.Date 类型。

1
2
3
4
5
6
Instant instant = sylvester
.atZone(ZoneId.systemDefault())
.toInstant();

Date legacyDate = Date.from(instant);
System.out.println(legacyDate); // Wed Dec 31 23:59:59 CET 2014

日期时间的格式化类似于 Date 或 Time。我们可以使用自定义模式创建格式化程序,而不是使用预定义的格式。

1
2
3
4
5
6
7
DateTimeFormatter formatter =
DateTimeFormatter
.ofPattern("MMM dd, yyyy - HH:mm");

LocalDateTime parsed = LocalDateTime.parse("Nov 03, 2014 - 07:13", formatter);
String string = formatter.format(parsed);
System.out.println(string); // Nov 03, 2014 - 07:13

不同于 java.text.NumberFormatDateTimeFormatter 是不可变且线程安全的

更多关于日期格式化的内容可以参考这里.

Annotations

Java 8 中的注释是可重复的。让我们直接看一个例子来解决这个问题。

首先,我们定义一个包含实际注释数组的外层注释:

1
2
3
4
5
6
7
8
@interface Hints {
Hint[] value();
}

@Repeatable(Hints.class)
@interface Hint {
String value();
}

Java8 允许我们通过使用 @Repeatable 注解来引入多个同类型的注解。

Variant 1: 使用容器注解 (老套路)

1
2
@Hints({@Hint("hint1"), @Hint("hint2")})
class Person {}

Variant 2: 使用 repeatable 注解 (新套路)

1
2
3
@Hint("hint1")
@Hint("hint2")
class Person {}

使用场景 2,Java 编译器隐式地设置了 @Hints 注解。

这对于通过反射来读取注解信息很重要。

1
2
3
4
5
6
7
8
Hint hint = Person.class.getAnnotation(Hint.class);
System.out.println(hint); // null

Hints hints1 = Person.class.getAnnotation(Hints.class);
System.out.println(hints1.value().length); // 2

Hint[] hints2 = Person.class.getAnnotationsByType(Hint.class);
System.out.println(hints2.length); // 2

尽管,我们从没有在 Person 类上声明 @Hints 注解,但是仍可以通过getAnnotation(Hints.class) 读取它。然而,更便利的方式是 getAnnotationsByType ,它可以直接访问所有 @Hint 注解。

此外,Java 8 中的注释使用扩展了两个新的目标:

1
2
@Target({ElementType.TYPE_PARAMETER, ElementType.TYPE_USE})
@interface MyAnnotation {}

水平线以上为 java8-tutorial 翻译内容。


JDK8 升级常见问题

JDK8 发布很久了,它提供了许多吸引人的新特性,能够提高编程效率。

如果是新的项目,使用 JDK8 当然是最好的选择。但是,对于一些老的项目,升级到 JDK8 则存在一些兼容性问题,是否升级需要酌情考虑。

近期,我在工作中遇到一个任务,将部门所有项目的 JDK 版本升级到 1.8 (老版本大多是 1.6)。在这个过程中,遇到一些问题点,并结合在网上看到的坑,在这里总结一下。

Intellij 中的 JDK 环境设置

Settings

点击 File > Settings > Java Compiler

Project bytecode version 选择 1.8

点击 File > Settings > Build Tools > Maven > Importing

选择 JDK for importer 为 1.8

Projcet Settings

Project SDK 选择 1.8

Application

如果 web 应用的启动方式为 Application ,需要修改 JRE

点击 Run/Debug Configurations > Configuration

选择 JRE 为 1.8

Linux 环境修改

修改环境变量

修改 /etc/profile 中的 JAVA_HOME,设置 为 jdk8 所在路径。

修改后,执行 source /etc/profile 生效。

编译、发布脚本中如果有 export JAVA_HOME ,需要注意,需要使用 jdk8 的路径。

修改 maven

settings.xml 中 profile 的激活条件如果是 jdk,需要修改一下 jdk 版本

1
2
3
<activation>
<jdk>1.8</jdk> <!-- 修改为 1.8 -->
</activation>

修改 server

修改 server 中的 javac 版本,以 resin 为例:

修改 resin 配置文件中的 javac 参数。

1
<javac compiler="internal" args="-source 1.8"/>

sun.* 包缺失问题

JDK8 不再提供 sun.* 包供开发者使用,因为这些接口不是公共接口,不能保证在所有 Java 兼容的平台上工作。

使用了这些 API 的程序如果要升级到 JDK 1.8 需要寻求替代方案。

虽然,也可以自己导入包含 sun.* 接口 jar 包到 classpath 目录,但这不是一个好的做法。

需要详细了解为什么不要使用 sun.* ,可以参考官方文档:Why Developers Should Not Write Programs That Call ‘sun’ Packages

默认安全策略修改

升级后估计有些小伙伴在使用不安全算法时可能会发生错误,so,支持不安全算法还是有必要的

找到$JAVA_HOME 下 jre/lib/security/java.security ,将禁用的算法设置为空:jdk.certpath.disabledAlgorithms=

JVM 参数调整

在 jdk8 中,PermSize 相关的参数已经不被使用:

1
2
3
4
5
6
7
-XX:MaxPermSize=size

Sets the maximum permanent generation space size (in bytes). This option was deprecated in JDK 8, and superseded by the -XX:MaxMetaspaceSize option.

-XX:PermSize=size

Sets the space (in bytes) allocated to the permanent generation that triggers a garbage collection if it is exceeded. This option was deprecated un JDK 8, and superseded by the -XX:MetaspaceSize option.

JDK8 中再也没有 PermGen 了。其中的某些部分,如被 intern 的字符串,在 JDK7 中已经移到了普通堆里。其余结构在 JDK8 中会被移到称作“Metaspace”的本机内存区中,该区域在默认情况下会自动生长,也会被垃圾回收。它有两个标记:MetaspaceSize 和 MaxMetaspaceSize。

-XX:MetaspaceSize=size

Sets the size of the allocated class metadata space that will trigger a garbage collection the first time it is exceeded. This threshold for a garbage collection is increased or decreased depending on the amount of metadata used. The default size depends on the platform.

-XX:MaxMetaspaceSize=size

Sets the maximum amount of native memory that can be allocated for class metadata. By default, the size is not limited. The amount of metadata for an application depends on the application itself, other running applications, and the amount of memory available on the system.

以下示例显示如何将类类元数据的上限设置为 256 MB:

XX:MaxMetaspaceSize=256m

字节码问题

ASM 5.0 beta 开始支持 JDK8

字节码错误

1
2
Caused by: java.io.IOException: invalid constant type: 15
at javassist.bytecode.ConstPool.readOne(ConstPool.java:1113)
  • 查找组件用到了 mvel,mvel 为了提高效率进行了字节码优化,正好碰上 JDK8 死穴,所以需要升级。
1
2
3
4
5
<dependency>
<groupId>org.mvel</groupId>
<artifactId>mvel2</artifactId>
<version>2.2.7.Final</version>
</dependency>
  • javassist
1
2
3
4
5
<dependency>
<groupId>org.javassist</groupId>
<artifactId>javassist</artifactId>
<version>3.18.1-GA</version>
</dependency>

注意

有些部署工具不会删除旧版本 jar 包,所以可以尝试手动删除老版本 jar 包。

http://asm.ow2.org/history.html

Java 连接 redis 启动报错 Error redis clients jedis HostAndPort cant resolve localhost address

错误环境:
本地 window 开发环境没有问题。上到 Linux 环境,启动出现问题。
错误信息:
Error redis clients jedis HostAndPort cant resolve localhost address

解决办法:

(1)查看 Linux 系统的主机名

1
2
# hostname
template

(2)查看/etc/hosts 文件中是否有 127.0.0.1 对应主机名,如果没有则添加

Resin 容器指定 JDK 1.8

如果 resin 容器原来版本低于 JDK1.8,运行 JDK 1.8 编译的 web app 时,可能会提示错误:

1
java.lang.UnsupportedClassVersionError: PR/Sort : Unsupported major.minor version 52.0

解决方法就是,使用 JDK 1.8 要重新编译一下。然后,我在部署时出现过编译后仍报错的情况,重启一下服务器后,问题解决,不知是什么原因。

1
2
./configure --prefix=/usr/local/resin  --with-java=/usr/local/jdk1.8.0_121
make & make install

参考资料

代码工程规范

软件项目开发规范。

项目结构

以下为项目根目录下的文件和目录的组织结构:

目录

codes - 代码目录。
configurations - 配置目录。一般存放项目相关的配置文件。如 maven 的 settings.xml,nginx 的 nginx.conf 等。
demos - 示例目录。
docs - 文档目录。
libs - 第三方库文件。
scripts - 脚本目录。一般存放用于启动、构建项目的可执行脚本文件。
packages - 打包文件目录。Java 项目中可能是 jar、war 等;前端项目中可能是 zip、rar 等;电子书项目中可能是 pdf 等。

文件

.gitignore - git 忽略规则。
.gitattributes - git 属性规则。
.editorconfig - 编辑器书写规则。
README.md - 项目说明文件。
LICENSE - 开源协议。如果项目是开源文件,需要添加。

命名规则

目录名

目录名必须使用半角字符,不得使用全角字符。这也意味着,中文不能用于文件名。

目录名建议只使用小写字母,不使用大写字母。

1
2
不佳: Test
正确: test

目录名可以使用数字,但不应该是首字符。

1
2
不佳: 1-demo
正确: demo1

目录名包含多个单词时,单词之间建议使用半角的连词线(-)分隔。

1
2
不佳: common_demo
正确: common-demo

文件名

文档的文件名不得含有空格。

文件名必须使用半角字符,不得使用全角字符。这也意味着,中文不能用于文件名。

1
2
错误: 名词解释.md
正确: glossary.md

文件名建议只使用小写字母,不使用大写字母。

1
2
错误:TroubleShooting.md
正确:troubleshooting.md

为了醒目,某些说明文件的文件名,可以使用大写字母,比如READMELICENSE

一些约定俗成的习惯可以保持传统写法,如:Java 的文件名一般使用驼峰命名法,且首字母大写;配置文件 applicationContext.xml ;React 中的 JSX 组件文件名一般使用驼峰命名法,且首字母大写等。

文件名包含多个单词时,单词之间建议使用半角的连词线(-)分隔。

1
2
不佳:advanced_usage.md
正确:advanced-usage.md

Java 日志规范

这里基于阿里巴巴 Java 开发手册日志规约章节,结合自己的开发经验做了一些增删和调整。

  1. 【强制】应用中不可直接使用日志系统(Log4j、Logback)中的 API,而应依赖使用日志框架 SLF4J 中的 API,使用门面模式的日志框架,有利于维护和各个类的日志处理方式统一。
1
2
3
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
private static final Logger logger = LoggerFactory.getLogger(Abc.class);
  1. 【强制】日志文件推荐至少保存 30 天,因为有些异常具备以“周”为频次发生的特点。

  2. 【强制】应用中的扩展日志(如打点、临时监控、访问日志等)命名方式:appName_logType_logName.log。logType:日志类型,推荐分类有 stats/desc/monitor/visit 等;logName:日志描述。这种命名的好处:通过文件名就可知道日志文件属于什么应用,什么类型,什么目的,也有利于归类查找。

正例:mppserver 应用中单独监控时区转换异常,如:mppserver_monitor_timeZoneConvert.log

说明:推荐对日志进行分类,如将错误日志和业务日志分开存放,便于开发人员查看,也便于通过日志对系统进行及时监控。

  1. 【强制】对 trace/debug/info 级别的日志输出,必须使用条件输出形式或者使用占位符的方式。

说明:logger.debug("Processing trade with id: " + id + " and symbol: " + symbol); 如果日志级别是 warn,上述日志不会打印,但是会执行字符串拼接操作,如果 symbol 是对象,会执行 toString()方法,浪费了系统资源,执行了上述操作,最终日志却没有打印。

正例:(条件)

1
2
3
if (logger.isDebugEnabled()) {
logger.debug("Processing trade with id: " + id + " and symbol: " + symbol);
}

正例:(占位符)

1
logger.debug("Processing trade with id: {} and symbol : {} ", id, symbol);
  1. 【强制】避免重复打印日志,浪费磁盘空间。务必在 log4j.xmllogback.xml 中设置 additivity=false

正例

1
<logger name="com.taobao.dubbo.config" additivity="false">
  1. 【强制】异常信息应该包括两类信息:案发现场信息和异常堆栈信息。如果不处理,那么通过关键字 throws 往上抛出。

正例:logger.error(各类参数或者对象 toString + “_“ + e.getMessage(), e);

  1. 【强制】日志格式遵循如下格式:

打印出的日志信息如:

1
2
3
4
5
2018-03-29 15:06:57.277 [javalib] [main] [TRACE] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 trace 日志记录
2018-03-29 15:06:57.282 [javalib] [main] [DEBUG] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 debug 日志记录
2018-03-29 15:06:57.282 [javalib] [main] [INFO] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 info 日志记录
2018-03-29 15:06:57.282 [javalib] [main] [WARN] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 warn 日志记录
2018-03-29 15:06:57.282 [javalib] [main] [ERROR] i.g.dunwu.javalib.log.LogbackDemo#main - 这是一条 error 日志记录
  1. 【参考】slf4j 支持的日志级别,按照级别从低到高,分别为:trace < debug < info < warn < error

建议只使用 debug < info < warn < error 四个级别。

  • error 日志级别只记录系统逻辑出错、异常等重要的错误信息。如非必要,请不要在此场景打出 error 级别。
  • warn 日志级别记录用户输入参数错误的情况,避免用户投诉时,无所适从。
  • info 日志级别记录业务逻辑中一些重要步骤信息。
  • debug 日志级别记录一些用于调试的信息。
  1. 【参考】有一些第三方框架或库的日志对于排查问题具有一定的帮助,如 Spring、Dubbo、Mybatis 等。这些框架所使用的日志库未必和本项目一样,为了避免出现日志无法输出的问题,请引入对应的桥接 jar 包。

参考资料

如何优雅的玩转 Git

Git 简介

Git 是什么

Git 是一个开源的分布式版本控制系统。

Git 和其它版本控制系统(包括 Subversion 和近似工具)的主要差别在于 Git 对待数据的方式。 从概念上来说,其它大部分系统以文件变更列表的方式存储信息,而 Git 是把数据看作是对小型文件系统的一系列快照。

什么是版本控制

版本控制是一种记录一个或若干文件内容变化,以便将来查阅特定版本修订情况的系统。

集中化的版本控制系统

介绍分布式版本控制系统前,有必要先了解一下传统的集中式版本控制系统。

集中化的版本控制系统,诸如 CVS,Subversion 等,都有一个单一的集中管理的服务器,保存所有文件的修订版本,而协同工作的人们都通过客户端连到这台服务器,取出最新的文件或者提交更新。

这么做最显而易见的缺点是中央服务器的单点故障。如果宕机一小时,那么在这一小时内,谁都无法提交更新,也就无法协同工作。要是中央服务器的磁盘发生故障,碰巧没做备份,或者备份不够及时,就会有丢失数据的风险。最坏的情况是彻底丢失整个项目的所有历史更改记录。

img

分布式版本控制系统

分布式版本控制系统的客户端并不只提取最新版本的文件快照,而是把代码仓库完整地镜像下来。这么一来,任何一处协同工作用的服务器发生故障,事后都可以用任何一个镜像出来的本地仓库恢复。因为每一次的提取操作,实际上都是一次对代码仓库的完整备份。

img

为什么使用 Git

Git 是分布式的。这是 Git 和其它非分布式的版本控制系统(例如 svn,cvs 等),最核心的区别。分布式带来以下好处:

  • 工作时不需要联网 - 首先,分布式版本控制系统根本没有“中央服务器”,每个人的电脑上都是一个完整的版本库,这样,你工作的时候,就不需要联网了,因为版本库就在你自己的电脑上。既然每个人电脑上都有一个完整的版本库,那多个人如何协作呢?比方说你在自己电脑上改了文件 A,你的同事也在他的电脑上改了文件 A,这时,你们俩之间只需把各自的修改推送给对方,就可以互相看到对方的修改了。
  • 更加安全
    • 集中式版本控制系统,一旦中央服务器出了问题,所有人都无法工作。
    • 分布式版本控制系统,每个人电脑中都有完整的版本库,所以某人的机器挂了,并不影响其它人。

Git 的工作原理

个人认为,对于 Git 这个版本工具,再不了解原理的情况下,直接去学习命令行,可能会一头雾水。所以,本文特意将原理放在命令使用章节之前讲解。

版本库

当你一个项目到本地或创建一个 git 项目,项目目录下会有一个隐藏的 .git 子目录。这个目录是 git 用来跟踪管理版本库的,如果不熟悉其工作机制,千万不要手动修改。

img

  • hooks 目录:包含客户端或服务端的钩子脚本(hook scripts)
  • info 目录:包含一个全局性排除(global exclude)文件, 用以放置那些不希望被记录在 .gitignore 文件中的忽略模式(ignored patterns)。
  • objects 目录:存储所有数据内容。
  • refs 目录:存储指向数据(分支、远程仓库和标签等)的提交对象的指针
  • HEAD 文件:指向目前被检出的分支。
  • index 文件保存暂存区信息。
  • config 文件:包含项目特有的配置选项。
  • description 文件:description 文件仅供 GitWeb 程序使用,我们无需关心。

哈希值

Git 中所有数据在存储前都计算校验和,然后以校验和来引用。 这意味着不可能在 Git 不知情时更改任何文件内容或目录内容。 这个功能构筑在 Git 底层,是 Git 的关键组件。 若你在传送过程中丢失信息或损坏文件,Git 就能发现。

Git 计算校验和的使用 SHA-1 哈希算法。 这是一个由 40 个十六进制字符(0-9 和 a-f)组成字符串,基于 Git 中文件的内容或目录结构计算出来。 SHA-1 哈希值看起来是这样:

1
24b9da6552252987aa493b52f8696cd6d3b00373

Git 中使用这种哈希值的情况很多,你将经常看到这种哈希值。 实际上,Git 数据库中保存的信息都是以文件内容的哈希值来索引,而不是文件名。

文件状态

在 GIt 中,你的文件可能会处于三种状态之一:

  • 已修改(modified) - 已修改表示修改了文件,但还没保存到数据库中。
  • 已暂存(staged) - 已暂存表示对一个已修改文件的当前版本做了标记,使之包含在下次提交的快照中。
  • 已提交(committed) - 已提交表示数据已经安全的保存在本地数据库中。

工作区域

与文件状态对应的,不同状态的文件在 Git 中处于不同的工作区域。

  • 工作区(working) - 当你 git clone 一个项目到本地,相当于在本地克隆了项目的一个副本。工作区是对项目的某个版本独立提取出来的内容。 这些从 Git 仓库的压缩数据库中提取出来的文件,放在磁盘上供你使用或修改。
  • 暂存区(staging) - 暂存区是一个文件,保存了下次将提交的文件列表信息,一般在 Git 仓库目录中。 有时候也被称作`‘索引’’,不过一般说法还是叫暂存区。
  • 本地仓库(local) - 提交更新,找到暂存区域的文件,将快照永久性存储到 Git 本地仓库。
  • 远程仓库(remote) - 以上几个工作区都是在本地。为了让别人可以看到你的修改,你需要将你的更新推送到远程仓库。同理,如果你想同步别人的修改,你需要从远程仓库拉取更新。

img

分支管理

Git Flow

Git Flow 应该是目前流传最广的 Git 分支管理策略。Git Flow 围绕的核心点是版本发布(release),它适用于迭代版本较长的项目。

img

详细内容,可以参考这篇文章:Git 在团队中的最佳实践–如何正确使用 Git Flow

Git Flow 常用分支:

  • master - 主线分支
  • develop - 开发分支
  • feature - 特性分支
  • release - 发布分支
  • hotfix - 问题修复分支

Git Flow 工作流程

2.1. 主干分支

img

主干分支有两个,它们是伴随着项目生命周期长期存在的分支。

  • master - 这个分支对应发布到生产环境的代码。这个分支只允许从其他分支合入代码,不能在这个分支直接修改。所有在 master 分支上的 Commit 都应该打 Tag。
  • develop - 这个分支包含所有要发布到下一个 release 的代码,这个分支主要是从其他分支合入代码,比如 feature 分支。

2.2. feature 分支

这个分支主要是用来开发一个新的功能,一旦开发完成,我们合并回 develop 分支进入下一个 release。feature 分支开发结束后,必须合并回 develop 分支, 合并完分支后一般会删点这个 feature 分支,但是我们也可以保留。

img

2.3. release 分支

release 分支基于 develop 分支创建,创建后,我们可以在这个 release 分支上进行测试,修复 Bug 等工作。同时,其它开发人员可以基于它开发新的 feature (记住:一旦创建了 release 分支之后不要从 develop 分支上合并新的改动到 release 分支)。

发布 release 分支时,合并 release 到 master 和 develop, 同时在 master 分支上打个 Tag 记住 release 版本号,然后可以删除 release 分支了。

2.4. hotfix 分支

当出现线上 bug 时,也意味着 master 存在 Bug。这时,我们需要基于 master 创建一个 hotfix 分支,在此分支上完成 bug 修复。修复后,我们应该将此分支合并回 master 和 develop 分支,同时在 master 上打一个 tag。所以,hotfix 的改动会进入下一个 release。

img

2.5. 如何应用 Git Flow

在实际开发中,如何具体落地 Git Flow 流程呢?

git 提供了 git flow 命令来手动管理,但是比较麻烦,所以还是建议使用 Git Flow 的 GUI 工具。比如:SourceTreeVScode 的 GitFlow 插件、Intellij 的 GitFlow 插件等。

想了解更详细的 Git Flow 介绍,可以参考:

A Successful Git Branching Model

Git 在团队中的最佳实践–如何正确使用 Git Flow

Github Flow

对于简单且迭代频繁的项目来说,Git Flow 可能有些太复杂了。这时,可以考虑 Github Flow。

在 Github Flow 策略中,所有分支都是基于 master 创建。在 Feature 或 Bugfix 分支中完成工作后,将其合入 master,然后继续迭代。

img

想了解更详细的 Github Flow 介绍,可以参考:GitHub Flow

Git Commit 规范

Git 每次提交代码,都要写 Commit message(提交说明),否则就不允许提交。

好的 Commit message 可以让人一眼就明白提交者修改了什么内容,有什么影响;而不好的 Commit message 写了和没写一样,甚至还可能误导别人。

先来看下图中不好的 Commit message 范例,从提交信息完全看不出来修改了什么。

img

再来一张较好的 Commit message 范例,每次提交的是什么内容,做了什么一目了然。

img

Commit message 的作用

从前面,我们不难看出,完善的 Commit message 非常有利于项目维护。即时是个人维护的项目,时间久了,可能也会忘记当初自己改了什么。

Commit message 的作用还不仅仅是理解历史信息,它的主要作用如下:

(1)提供易于理解的历史信息,方便检索

(2)可以过滤某些 commit(比如文档改动),便于快速查找信息。

(3)可以直接从 commit 生成 Change log。

Commit message 的规范

开源社区有很多 Commit message 的规范,个人推荐使用 Angular Git Commit 规范,这是目前使用最广的写法,比较合理和系统化,并且有配套的工具。

它主要有以下组成部分:

  • 标题行:必填, 描述主要修改类型和内容
  • 主题内容:描述为什么修改, 做了什么样的修改, 以及开发的思路等等
  • 页脚注释:放 Breaking Changes 或 Closed Issues

常用的修改项

  • type:commit 的类型
  • feat:新特性
  • fix:修改问题
  • refactor:代码重构
  • docs:文档修改
  • style:代码格式修改, 注意不是 css 修改
  • test:测试用例修改
  • chore:其他修改, 比如构建流程, 依赖管理.
  • scope:commit 影响的范围, 比如:route, component, utils, build…
  • subject:commit 的概述
  • body:commit 具体修改内容, 可以分为多行
  • footer:一些备注, 通常是 BREAKING CHANGE 或修复的 bug 的链接

Git Commit Template

Intellij 中有集成 Angular Git Commit 规范 的插件,可以帮助我们快速创建符合 Angular Git Commit 规范 的 Git Commit Message。

其使用步骤如下:

第一步,安装插件

img

第二步,提交代码时,按照模板填写 commit message

img

生成 Change log

如果你的所有 Commit 都符合 Angular Git Commit 规范,那么发布新版本时,就可以用脚本自动生成 Change log。

生成的文档包括以下三个部分。

  • New features
  • Bug fixes
  • Breaking changes.

每个部分都会罗列相关的 commit ,并且有指向这些 commit 的链接。当然,生成的文档允许手动修改,所以发布前,你还可以添加其他内容。

conventional-changelog 就是生成 Change log 的工具,运行下面的命令即可。

1
2
3
$ npm install -g conventional-changelog
$ cd my-project
$ conventional-changelog -p angular -i CHANGELOG.md -w

上面命令不会覆盖以前的 Change log,只会在CHANGELOG.md的头部加上自从上次发布以来的变动。

如果你想生成所有发布的 Change log,要改为运行下面的命令。

1
$ conventional-changelog -p angular -i CHANGELOG.md -w -r 0

为了方便使用,可以将其写入package.jsonscripts字段。

1
2
3
4
5
{
"scripts": {
"changelog": "conventional-changelog -p angular -i CHANGELOG.md -w -r 0"
}
}

以后,直接运行下面的命令即可。

1
$ npm run changelog

Git 奇技淫巧

生成 SSH 公钥

许多 Git 服务器都使用 SSH 公钥进行认证。 为了向 Git 服务器提供 SSH 公钥,如果某系统用户尚未拥有密钥,必须事先为其生成一份。 这个过程在所有操作系统上都是相似的。 首先,你需要确认自己是否已经拥有密钥。 默认情况下,用户的 SSH 密钥存储在其 ~/.ssh 目录下。 进入该目录并列出其中内容,你便可以快速确认自己是否已拥有密钥:

1
2
3
4
$ cd ~/.ssh
$ ls
authorized_keys2 id_dsa known_hosts
config id_dsa.pub

我们需要寻找一对以 id_dsaid_rsa 命名的文件,其中一个带有 .pub 扩展名。 .pub 文件是你的公钥,另一个则是私钥。 如果找不到这样的文件(或者根本没有 .ssh 目录),你可以通过运行 ssh-keygen 程序来创建它们。在 Linux/Mac 系统中,ssh-keygen 随 SSH 软件包提供;在 Windows 上,该程序包含于 MSysGit 软件包中。

1
2
3
4
5
6
7
8
9
10
$ ssh-keygen
Generating public/private rsa key pair.
Enter file in which to save the key (/home/schacon/.ssh/id_rsa):
Created directory '/home/schacon/.ssh'.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in /home/schacon/.ssh/id_rsa.
Your public key has been saved in /home/schacon/.ssh/id_rsa.pub.
The key fingerprint is:
d0:82:24:8e:d7:f1:bb:9b:33:53:96:93:49:da:9b:e3 schacon@mylaptop.local

首先 ssh-keygen 会确认密钥的存储位置(默认是 .ssh/id_rsa),然后它会要求你输入两次密钥口令。如果你不想在使用密钥时输入口令,将其留空即可。

现在,进行了上述操作的用户需要将各自的公钥发送给任意一个 Git 服务器管理员(假设服务器正在使用基于公钥的 SSH 验证设置)。 他们所要做的就是复制各自的 .pub 文件内容,并将其通过邮件发送。 公钥看起来是这样的:

1
2
3
4
5
6
7
$ cat ~/.ssh/id_rsa.pub
ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAklOUpkDHrfHY17SbrmTIpNLTGK9Tjom/BWDSU
GPl+nafzlHDTYW7hdI4yZ5ew18JH4JW9jbhUFrviQzM7xlELEVf4h9lFX5QVkbPppSwg0cda3
Pbv7kOdJ/MTyBlWXFCR+HAo3FXRitBqxiX1nKhXpHAZsMciLq8V6RjsNAQwdsdMFvSlVK/7XA
t3FaoJoAsncM1Q9x5+3V0Ww68/eIFmb1zuUFljQJKprrX88XypNDvjYNby6vw/Pb0rwert/En
mZ+AW4OZPnTPI89ZPmVMLuayrD2cE86Z/il8b+gw3r3+1nKatmIkjn2so1d01QraTlMqVSsbx
NrRFi9wrf+M7Q== schacon@mylaptop.local

在你的 Github 账户中,依次点击 Settings > SSH and GPG keys > New SSH key

然后,将上面生成的公钥内容粘贴到 Key 编辑框并保存。至此大功告成。

后面,你在克隆你的 Github 项目时使用 SSH 方式即可。

如果觉得我的讲解还不够细致,可以参考:adding-a-new-ssh-key-to-your-github-account

使用 .gitignore 忽略不必提交内容

.gitignore 文件可能从字面含义也不难猜出:这个文件里配置的文件或目录,会自动被 git 所忽略,不纳入版本控制。

在日常开发中,我们的项目经常会产生一些临时文件,如编译 Java 产生的 *.class 文件,又或是 IDE 自动生成的隐藏目录(Intellij 的 .idea 目录、Eclipse 的 .settings 目录等)等等。这些文件或目录实在没必要纳入版本管理。在这种场景下,你就需要用到 .gitignore 配置来过滤这些文件或目录。

.gitignore 配置的规则很简单,也没什么可说的,看几个例子,自然就明白了。

【示例】一份 Java 的 .gitignore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
# Compiled class file
*.class

# Log file
*.log

# BlueJ files
*.ctxt

# Mobile Tools for Java (J2ME)
.mtj.tmp/

# Package Files #
*.jar
*.war
*.nar
*.ear
*.zip
*.tar.gz
*.rar

# virtual machine crash logs, see http://www.java.com/en/download/help/error_hotspot.xml
hs_err_pid*

【推荐】这里推荐一个 Github 的开源项目:gitignore,在这里,你可以找到很多常用的 .gitignore 模板,如:Java、Nodejs、C++ 的 .gitignore 模板等等。

使用 .gitattributes 解决 LF 和 CRLF 问题

你有没有在和多人协同开发时遇到过以下烦恼?

开发者们分别使用不同的操作系统进行开发,有的人用 Windows,有的人用 Linux/MacOS。众所周知,不同操作系统默认的文件结尾行是不同的:在 Windows 上默认的是回车换行(Carriage Return Line Feed, CRLF),然而,在 Linux/MacOS 上则是换行(Line Feed, LF)。这就可能导致这种情况:明明文件内容一模一样,但是版本比对时仍然存在版本差异。

那么如何解决这个问题呢?Git 提供了 .gitattributes 配置文件,它允许使用者指定由 git 使用的文件和路径的属性。

在 Git 库中,一个普通文本文件的行尾默认是 LF。对于工作目录,除了 text 属性之外,还可以设置 eol 属性或 core.eol 配置变量。

.gitattributes 文件中,可以用 text 属性指定某类文件或目录下的文件,控制它的行结束标准化。当一个文本文件被标准化时,它的行尾将在存储库中转换为 LF。要控制工作目录中使用的行结束风格,请使用单个文件的eol属性和所有文本文件的 core.eol 配置变量。

【示例】一份 .gitattributes 示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
* text=auto eol=lf

*.txt text
*.java text
*.scala text
*.groovy text
*.gradle text
*.properties text

# unix style
*.sh text eol=lf

# win style
*.bat text eol=crlf

# binary
*.jar binary
*.war binary
*.zip binary
*.tar binary
*.tar.gz binary
*.gz binary
*.apk binary
*.bin binary
*.exe binary

【推荐】这里推荐一个 Github 的开源项目:gitignore,在这里,你可以找到很多常用的 .gitignore 模板,如:Java、Nodejs、C++ 的 .gitignore 模板等等。

同时提交代码到不同的远程仓库

如果,你在不同的 Git 远程仓库中维护同一个项目,你可能会有这样的需求:能不能一次提交,同时 push 到多个远程仓库中呢?

这个可以有,解决方案如下:

比如,我有一个 blog 项目,同时维护在 Github 和 Gitee 上。

(1)首先,在 Github 和 Gitee 上配置本地的 ssh 公钥(如果是 Gitlab,也同样如此),这样中央仓库就能识别本地。

生成 SSH 公钥的方法,请参考上文的 “生成 SSH 公钥” 章节。

(2)进入 git 项目的隐藏目录 .git,打开 config 文件,参考下面配置进行编辑:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
[core]
repositoryformatversion = 0
filemode = false
bare = false
logallrefupdates = true
symlinks = false
ignorecase = true
[remote "origin"]
url = git@github.com:dunwu/blog.git
url = git@gitee.com:turnon/blog.git
fetch = +refs/heads/*:refs/remotes/origin/*
[branch "master"]
remote = origin
merge = refs/heads/master
[user]
name = dunwu
email = forbreak@163.com

重点在于 remote "origin",同时配置了两个 url。配置后,一旦触发 push 远程仓库的动作,就会同时推送提交记录到配置的远程仓库。

Github Issue 和 Gitlab Issue

开发软件,Bug 再所难免,出现问题不可怕,可怕的是放任不管;所以,优秀的软件项目,都应该管理好问题追踪。软件的使用者,在使用中,可能会遇到形形色色的问题,难以解决,需要向维护者寻求帮助。

问题追踪如此重要,所以各种代码托管平台都会提供 Issue 维护机制,如 Github Issue 和 Gitlab Issue。

如果一个项目的开源社区很活跃,在没有任何约束的前提下,提问肯定是五花八门的,让维护者难以招架。

其实,提问也是一门艺术,如果提问者的问题长篇大幅,言不达意,让别人难以理解,就很难得到有效帮助。关于如何高效的提问,推荐参考 提问的智慧 这篇文章,作者整理的非常好。

作为开发者,你不能期望所有提问者都是训练有素的提问者。所以,使用规范化的 Issue 模板来引导提问者提问,可以大大减轻开发者的负担。

Github Issue 模板

如何在 Github Issue 平台上创建 Issue 模板呢?方法如下:

(1)在仓库根目录创建新目录 .github

(2)在 .github 目录中添加 ISSUE_TEMPLATE 目录,在其中添加的 md 文件都会被 Github 自动识,并将其作为 issue 的默认模板。

示例,下面是携程 apollo 的一个 Issue 模板,要求提问者填充 bug 描述、复现步骤、期望、截图、日志等细节。

img

更多模板:Github issue_templates 模板

Gitlab Issue 模板

如何在 Gitlab Issue 平台上创建 Issue 模板呢?方法如下:

(1)在仓库根目录创建新目录 .gitlab

(2)在 .gitlab 目录中添加 issue_templates 目录,在其中添加的 md 文件都会被 Gitlab 自动识,并将其作为 issue 的默认模板。

img

更多模板:Gitlab 官方 issue_templates 模板

Git Hook

在执行提交代码(git commit),推送代码(git push)等行为时,我们可能希望做一些代码检查性工作,例如:代码 lint 检查、代码格式化等。当检查发现代码存在问题时,就拒绝代码提交,从而保证项目质量。

Git 提供了 Git Hook 机制,允许使用者在特定的重要动作发生时触发自定义脚本。有两类钩子:客户端钩子和服务器端钩子。客户端钩子由诸如提交和合并等操作所触发调用,而服务器端钩子作用于诸如接收被推送的提交这样的联网操作。钩子都被存储在 Git 项目目录下的 .git/hooks 子目录中。Git 在这个目录下放置了一些示例,这些示例的名字都是以 .sample 结尾,如果想启用它们,得先移除这个后缀。

常用的客户端钩子:

  • pre-commit 钩子:在提交信息前运行。 它用于检查即将提交的快照,例如,检查是否有所遗漏,确保测试运行,以及核查代码。 如果该钩子以非零值退出,Git 将放弃此次提交,不过你可以用 git commit --no-verify 来绕过这个环节。 你可以利用该钩子,来检查代码风格是否一致(运行类似 lint 的程序)、尾随空白字符是否存在(自带的钩子就是这么做的),或新方法的文档是否适当。
  • prepare-commit-msg 钩子:在启动提交信息编辑器之前,默认信息被创建之后运行。 它允许你编辑提交者所看到的默认信息。 该钩子接收一些选项:存有当前提交信息的文件的路径、提交类型和修补提交的提交的 SHA-1 校验。 它对一般的提交来说并没有什么用;然而对那些会自动产生默认信息的提交,如提交信息模板、合并提交、压缩提交和修订提交等非常实用。 你可以结合提交模板来使用它,动态地插入信息。
  • commit-msg 钩子:接收一个参数,此参数即上文提到的,存有当前提交信息的临时文件的路径。 如果该钩子脚本以非零值退出,Git 将放弃提交,因此,可以用来在提交通过前验证项目状态或提交信息。 在本章的最后一节,我们将展示如何使用该钩子来核对提交信息是否遵循指定的模板。
  • post-commit 钩子:在整个提交过程完成后运行。它不接收任何参数,但你可以很容易地通过运行 git log -1 HEAD 来获得最后一次的提交信息。 该钩子一般用于通知之类的事情。
  • pre-push 钩子:会在 git push 运行期间, 更新了远程引用但尚未传送对象时被调用。 它接受远程分支的名字和位置作为参数,同时从标准输入中读取一系列待更新的引用。 你可以在推送开始之前,用它验证对引用的更新操作(一个非零的退出码将终止推送过程)。

Javascript 应用 Git Hook

想在 JavaScript 应用中使用 Git Hook,推荐使用 husky ,可以很方便的编写钩子处理命令。

使用方法很简单,先安装 husky

1
npm i -D husky

然后,在 package.json 中添加配置:

1
2
3
4
5
6
7
8
9
10
11
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"src/**/*.{js,vue}": [
"eslint --fix",
"git add"
]
},

以上配置的作用是,当提交代码前( pre-commit ),先执行 lint-staged

lint-staged 中执行的动作是,对 src 目录的所有 js、vue 文件进行 eslint 检查,并尝试修复。如果修复后没有问题,就 git add 添加修改后的文件;如果修复失败,则拒绝提交代码。

参考资料

UML 快速入门

UML 简介

UML 图类型

UML 图类型如下图所示:

结构式建模图

结构式建模图(Structure diagrams)强调的是系统式的建模。结构图定义了一个模型的静态架构。它们通常被用来对那些构成模型的‘要素’建模,诸如:类,对象,接口和物理组件。另外,它们也被用来对元素间关联和依赖关系进行建模。

行为式建模图

行为式建模图(Behavior diagrams)强调系统模型中触发的事。行为图用来记录在一个模型内部,随时间的变化,模型执行的交互变化和瞬间的状态;并跟踪系统在真实环境下如何表现,以及观察系统对一个操作或事件的反应,以及它的结果。

UML 概念

UML 从来源中使用相当多的概念。我们将之定义于统一建模语言术语汇表。下面仅列代表性的概念。

  • 对于结构而言 - 执行者,属性,类,元件,接口,对象,包。
  • 对于行为而言 - 活动(UML),事件(UML),消息(UML),方法(UML),操作(UML),状态(UML),用例(UML)。
  • 对于关系而言 - 聚合,关联,组合,相依,广义化(or 继承)。
  • 其他概念
    • 构造型—这规范符号应用到的模型
    • 多重性—多重性标记法与资料库建模基数对应,例如:1, 0..1, 1..*

UML 工具

UML 工具非常多,到底哪种工具好,真的是仁者见仁智者见智。这里列举一些我接触过的 UML 工具:

亿图

国内开发的、收费的绘图工具。图形模板、素材非常全面,样式也很精美,可以导出为 word、pdf、图片。

亿图官网

Visio

Office 的绘图工具,特点是简单、清晰。

Visio 官网

StarUML

样式精美,功能全面的 UML 工具。

StarUML 官网

Astah

样式不错,功能全面的绘图工具。

Astah 官网

ArgoUML

UML 工具。

ArgoUML 官网

ProcessOn

在线绘图工具,特点是简洁、清晰。

ProcessOn 官网

drawio

开源的在线绘图工具,特点是简洁、清晰。

drawio 官网

参考资料

计算机网络之传输层

网络层只把分组发送到目的主机,但是真正通信的并不是主机而是主机中的进程。传输层提供了进程间的逻辑通信,传输层向高层用户屏蔽了下面网络层的核心细节,使应用程序看起来像是在两个传输层实体之间有一条端到端的逻辑通信信道。

UDP 和 TCP 的特点

  • 用户数据报协议 UDP(User Datagram Protocol)是无连接的,尽最大可能交付,没有拥塞控制,面向报文(对于应用程序传下来的报文不合并也不拆分,只是添加 UDP 首部),支持一对一、一对多、多对一和多对多的交互通信。

  • 传输控制协议 TCP(Transmission Control Protocol)是面向连接的,提供可靠交付,有流量控制,拥塞控制,提供全双工通信,面向字节流(把应用层传下来的报文看成字节流,把字节流组织成大小不等的数据块),每一条 TCP 连接只能是点对点的(一对一)。

UDP 首部格式

img

首部字段只有 8 个字节,包括源端口、目的端口、长度、检验和。12 字节的伪首部是为了计算检验和临时添加的。

TCP 首部格式

img

  • 序号 :用于对字节流进行编号,例如序号为 301,表示第一个字节的编号为 301,如果携带的数据长度为 100 字节,那么下一个报文段的序号应为 401。

  • 确认号 :期望收到的下一个报文段的序号。例如 B 正确收到 A 发送来的一个报文段,序号为 501,携带的数据长度为 200 字节,因此 B 期望下一个报文段的序号为 701,B 发送给 A 的确认报文段中确认号就为 701。

  • 数据偏移 :指的是数据部分距离报文段起始处的偏移量,实际上指的是首部的长度。

  • 确认 ACK :当 ACK=1 时确认号字段有效,否则无效。TCP 规定,在连接建立后所有传送的报文段都必须把 ACK 置 1。

  • 同步 SYN :在连接建立时用来同步序号。当 SYN=1,ACK=0 时表示这是一个连接请求报文段。若对方同意建立连接,则响应报文中 SYN=1,ACK=1。

  • 终止 FIN :用来释放一个连接,当 FIN=1 时,表示此报文段的发送方的数据已发送完毕,并要求释放连接。

  • 窗口 :窗口值作为接收方让发送方设置其发送窗口的依据。之所以要有这个限制,是因为接收方的数据缓存空间是有限的。

TCP 的三次握手

img

假设 A 为客户端,B 为服务器端。

  • 首先 B 处于 LISTEN(监听)状态,等待客户的连接请求。

  • A 向 B 发送连接请求报文,SYN=1,ACK=0,选择一个初始的序号 x。

  • B 收到连接请求报文,如果同意建立连接,则向 A 发送连接确认报文,SYN=1,ACK=1,确认号为 x+1,同时也选择一个初始的序号 y。

  • A 收到 B 的连接确认报文后,还要向 B 发出确认,确认号为 y+1,序号为 x+1。

  • B 收到 A 的确认后,连接建立。

三次握手的原因

第三次握手是为了防止失效的连接请求到达服务器,让服务器错误打开连接。

客户端发送的连接请求如果在网络中滞留,那么就会隔很长一段时间才能收到服务器端发回的连接确认。客户端等待一个超时重传时间之后,就会重新请求连接。但是这个滞留的连接请求最后还是会到达服务器,如果不进行三次握手,那么服务器就会打开两个连接。如果有第三次握手,客户端会忽略服务器之后发送的对滞留连接请求的连接确认,不进行第三次握手,因此就不会再次打开连接。

TCP 的四次挥手

img

以下描述不讨论序号和确认号,因为序号和确认号的规则比较简单。并且不讨论 ACK,因为 ACK 在连接建立之后都为 1。

  • A 发送连接释放报文,FIN=1。

  • B 收到之后发出确认,此时 TCP 属于半关闭状态,B 能向 A 发送数据但是 A 不能向 B 发送数据。

  • 当 B 不再需要连接时,发送连接释放报文,FIN=1。

  • A 收到后发出确认,进入 TIME-WAIT 状态,等待 2 MSL(最大报文存活时间)后释放连接。

  • B 收到 A 的确认后释放连接。

四次挥手的原因

客户端发送了 FIN 连接释放报文之后,服务器收到了这个报文,就进入了 CLOSE-WAIT 状态。这个状态是为了让服务器端发送还未传送完毕的数据,传送完毕之后,服务器会发送 FIN 连接释放报文。

TIME_WAIT

客户端接收到服务器端的 FIN 报文后进入此状态,此时并不是直接进入 CLOSED 状态,还需要等待一个时间计时器设置的时间 2MSL。这么做有两个理由:

  • 确保最后一个确认报文能够到达。如果 B 没收到 A 发送来的确认报文,那么就会重新发送连接释放请求报文,A 等待一段时间就是为了处理这种情况的发生。

  • 等待一段时间是为了让本连接持续时间内所产生的所有报文都从网络中消失,使得下一个新的连接不会出现旧的连接请求报文。

TCP 可靠传输

TCP 使用超时重传来实现可靠传输:如果一个已经发送的报文段在超时时间内没有收到确认,那么就重传这个报文段。

一个报文段从发送再到接收到确认所经过的时间称为往返时间 RTT,加权平均往返时间 RTTs 计算如下:

img

其中,0 ≤ a < 1,RTTs 随着 a 的增加更容易受到 RTT 的影响。

超时时间 RTO 应该略大于 RTTs,TCP 使用的超时时间计算如下:

img

其中 RTTd 为偏差的加权平均值。

TCP 滑动窗口

img

窗口是缓存的一部分,用来暂时存放字节流。发送方和接收方各有一个窗口,接收方通过 TCP 报文段中的窗口字段告诉发送方自己的窗口大小,发送方根据这个值和其它信息设置自己的窗口大小。

发送窗口内的字节都允许被发送,接收窗口内的字节都允许被接收。如果发送窗口左部的字节已经发送并且收到了确认,那么就将发送窗口向右滑动一定距离,直到左部第一个字节不是已发送并且已确认的状态;接收窗口的滑动类似,接收窗口左部字节已经发送确认并交付主机,就向右滑动接收窗口。

接收窗口只会对窗口内最后一个按序到达的字节进行确认,例如接收窗口已经收到的字节为 {31, 34, 35},其中 {31} 按序到达,而 {34, 35} 就不是,因此只对字节 31 进行确认。发送方得到一个字节的确认之后,就知道这个字节之前的所有字节都已经被接收。

TCP 流量控制

流量控制是为了控制发送方发送速率,保证接收方来得及接收。

接收方发送的确认报文中的窗口字段可以用来控制发送方窗口大小,从而影响发送方的发送速率。将窗口字段设置为 0,则发送方不能发送数据。

TCP 拥塞控制

如果网络出现拥塞,分组将会丢失,此时发送方会继续重传,从而导致网络拥塞程度更高。因此当出现拥塞时,应当控制发送方的速率。这一点和流量控制很像,但是出发点不同。流量控制是为了让接收方能来得及接收,而拥塞控制是为了降低整个网络的拥塞程度。

img

TCP 主要通过四个算法来进行拥塞控制:慢开始、拥塞避免、快重传、快恢复。

发送方需要维护一个叫做拥塞窗口(cwnd)的状态变量,注意拥塞窗口与发送方窗口的区别:拥塞窗口只是一个状态变量,实际决定发送方能发送多少数据的是发送方窗口。

为了便于讨论,做如下假设:

  • 接收方有足够大的接收缓存,因此不会发生流量控制;
  • 虽然 TCP 的窗口基于字节,但是这里设窗口的大小单位为报文段。

img

慢开始与拥塞避免

发送的最初执行慢开始,令 cwnd = 1,发送方只能发送 1 个报文段;当收到确认后,将 cwnd 加倍,因此之后发送方能够发送的报文段数量为:2、4、8 …

注意到慢开始每个轮次都将 cwnd 加倍,这样会让 cwnd 增长速度非常快,从而使得发送方发送的速度增长速度过快,网络拥塞的可能性也就更高。设置一个慢开始门限 ssthresh,当 cwnd >= ssthresh 时,进入拥塞避免,每个轮次只将 cwnd 加 1。

如果出现了超时,则令 ssthresh = cwnd / 2,然后重新执行慢开始。

快重传与快恢复

在接收方,要求每次接收到报文段都应该对最后一个已收到的有序报文段进行确认。例如已经接收到 M1 和 M2,此时收到 M4,应当发送对 M2 的确认。

在发送方,如果收到三个重复确认,那么可以知道下一个报文段丢失,此时执行快重传,立即重传下一个报文段。例如收到三个 M2,则 M3 丢失,立即重传 M3

在这种情况下,只是丢失个别报文段,而不是网络拥塞。因此执行快恢复,令 ssthresh = cwnd / 2 ,cwnd = ssthresh,注意到此时直接进入拥塞避免。

慢开始和快恢复的快慢指的是 cwnd 的设定值,而不是 cwnd 的增长速率。慢开始 cwnd 设定为 1,而快恢复 cwnd 设定为 ssthresh。

img