Java入门

Java基础

为什么Java应用最广泛?

从互联网到企业平台,Java是应用最广泛的编程语言,原因在于:

  • Java是基于JVM虚拟机的跨平台语言,一次编写,到处运行;
  • Java程序易于编写,而且有内置垃圾收集,不必考虑内存管理;
  • Java虚拟机拥有工业级的稳定性和高度优化的性能,且经过了长时期的考验;
  • Java拥有最广泛的开源社区支持,各种高质量组件随时可用。

Java语言常年霸占着三大市场:

  • 互联网和企业应用,这是Java EE的长期优势和市场地位;
  • 大数据平台,主要有Hadoop、Spark、Flink等,他们都是Java或Scala(一种运行于JVM的编程语言)开发的;
  • Android移动平台。

Java简介

Java介于编译型语言解释型语言之间。编译型语言如C、C++,代码是直接编译成机器码执行,但是不同的平台(x86、ARM等)CPU的指令集不同,因此,需要编译出每一种平台的对应机器码。

解释型语言如Python、Ruby没有这个问题,可以由解释器直接加载源码然后运行,代价是运行效率太低。

而Java是将代码编译成一种“字节码”,它类似于抽象的CPU指令,然后,针对不同平台编写虚拟机,不同平台的虚拟机负责加载字节码并执行,这样就实现了“一次编写,到处运行”的效果。

随着Java的发展,SUN给Java又分出了三个不同版本:

https://www.liaoxuefeng.com/wiki/1252599548343744/1255876875896416

面向对象编程(OOP)

Java是一种面向对象的编程语言。面向对象编程,英文是Object-Oriented Programming,简称OOP。

  • 面向对象编程 - 是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
  • 面向过程编程 - 是把模型分解成一步一步的过程。
    s

定义方法

定义方法的语法是:

修饰符 方法返回类型 方法名(方法参数列表) {
    若干方法语句;
    return 方法返回值;
}

方法返回值通过return语句实现,如果没有返回值,返回类型设置为void,可以省略return。

public

public 是用来修饰字段的,它表示这个字段可以被外部访问。

1
2
3
4
class Person {
public String name;
public int age;
}

private

private方法不允许外部调用, 内部方法是可以调用private方法的。

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 Main {
public static void main(String[] args) {
Person ming = new Person();
ming.setBirth(2008);
System.out.println(ming.getAge());
}
}

class Person {
private String name;
private int birth;

public void setBirth(int birth) {
this.birth = birth;
}

public int getAge() {
return calcAge(2019); // 调用private方法
}

// private方法:
private int calcAge(int currentYear) {
return currentYear - this.birth;
}
}

观察上述代码,calcAge()是一个private方法,外部代码无法调用,但是,内部方法getAge()可以调用它。

protected

this

在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。

因此,通过this.field就可以访问当前实例的字段。

如果没有命名冲突,可以省略this。例如:

1
2
3
4
5
6
7
class Person {
private String name;

public String getName() {
return name; // 相当于this.name
}
}

但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this:

1
2
3
4
5
6
7
class Person {
private String name;

public void setName(String name) {
this.name = name; // 前面的this不可少,少了就变成局部变量name了
}
}

方法参数

1
2
3
4
5
6
class Person {
...
public void setNameAndAge(String name, int age) {
...
}
}

可变参数

可变参数用类型...定义,可变参数相当于数组类型:

1
2
3
4
5
6
7
class Group {
private String[] names;

public void setNames(String... names) {
this.names = names;
}
}

参数绑定 - 传值 和 引用

调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。

基本类型参数绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) {
Person p = new Person();
int n = 15; // n的值为15
p.setAge(n); // 传入n的值
System.out.println(p.getAge()); // 15
n = 20; // n的值改为20
System.out.println(p.getAge()); // 15还是20? - 15
}
}

class Person {
private int age;

public int getAge() {
return this.age;
}

public void setAge(int age) {
this.age = age;
}
}

结论:基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。

引用类型参数绑定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) {
Person p = new Person();
String[] fullname = new String[] { "Homer", "Simpson" };
p.setName(fullname); // 传入fullname数组
System.out.println(p.getName()); // "Homer Simpson"
fullname[0] = "Bart"; // fullname数组的第一个元素修改为"Bart"
System.out.println(p.getName()); // "Homer Simpson"还是"Bart Simpson"? - Bart Simpson
}
}

class Person {
private String[] name;

public String getName() {
return this.name[0] + " " + this.name[1];
}

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

结论:引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。

构造方法

创建实例的时候,实际上是通过构造方法来初始化实例的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
private String name;
private int age;

/**
* 构造方法
*/
public Person(String name, int age) {
this.name = name;
this.age = age;
}

public String getName() {
return this.name;
}

public int getAge() {
return this.age;
}
}

构造方法的名称就是类名。构造方法的参数没有限制,在方法内部,也可以编写任意语句。

但是,和普通方法相比,构造方法没有返回值(也没有void),调用构造方法,必须用new操作符。

默认构造方法

是不是任何class都有构造方法?是的。

如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样:

1
2
3
4
class Person {
public Person() {
}
}

要特别注意的是,如果我们自定义了一个构造方法,那么,编译器就不再自动创建默认构造方法

如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来:

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 Main {
public static void main(String[] args) {
Person p1 = new Person("Xiao Ming", 15); // 既可以调用带参数的构造方法
Person p2 = new Person(); // 也可以调用无参数构造方法
}
}

class Person {
private String name; // 默认初始化为null
private int age; // 默认初始化为0

public Person() {
}

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

public String getName() {
return this.name;
}

public int getAge() {
return this.age;
}
}

没有在构造方法中初始化字段时,引用类型的字段默认是null,数值类型的字段用默认值,int类型默认值是0,布尔类型默认值是false。

也可以对字段直接进行初始化:

1
2
3
4
class Person {
private String name = "Unamed";
private int age = 10;
}

多构造方法

可以定义多个构造方法,在通过new操作符调用的时候,编译器通过构造方法的参数数量、位置和类型自动区分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
private String name;
private int age;

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

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

public Person() {
}
}

一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是this(…):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Person {
private String name;
private int age;

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

public Person(String name) {
this(name, 18); // 调用另一个构造方法Person(String, int)
}

public Person() {
this("Unnamed"); // 调用另一个构造方法Person(String)
}
}

方法重载

如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。

例如,在Hello类中,定义多个hello()方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Hello {
public void hello() {
System.out.println("Hello, world!");
}

public void hello(String name) {
System.out.println("Hello, " + name + "!");
}

public void hello(String name, int age) {
if (age < 18) {
System.out.println("Hi, " + name + "!");
} else {
System.out.println("Hello, " + name + "!");
}
}
}

这种方法名相同,但各自的参数不同,称为方法重载(Overload)。

注意:方法重载的返回值类型通常都是相同的。

方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。

举个例子,String类提供了多个重载方法indexOf(),可以查找子串:

  • int indexOf(int ch):根据字符的Unicode码查找;
  • int indexOf(String str):根据字符串查找;
  • int indexOf(int ch, int fromIndex):根据字符查找,但指定起始位置;
  • int indexOf(String str, int fromIndex)根据字符串查找,但指定起始位置。

继承

继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。

当我们让Student从Person继承时,Student就获得了Person的所有功能,我们只需要为Student编写新增的功能。

Java使用extends关键字来实现继承:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class Person {
private String name;
private int age;

public String getName() {...}
public void setName(String name) {...}
public int getAge() {...}
public void setAge(int age) {...}
}
/**
* 继承
*/
class Student extends Person {
// 不要重复name和age字段/方法,
// 只需要定义新增score字段/方法:
private int score;

public int getScore() { … }
public void setScore(int score) { … }
}

在OOP的术语中,我们把Person称为超类(super class),父类(parent class),基类(base class),把Student称为子类(subclass),扩展类(extended class)。

继承树

注意到我们在定义Person的时候,没有写extends。在Java中,没有明确写extends的类,编译器会自动加上extends Object

所以,任何类,除了Object,都会继承自某个类。

下图是Person、Student的继承树:

┌───────────┐
│  Object   │
└───────────┘
    ▲
    │
┌───────────┐
│  Person   │
└───────────┘
    ▲
    │
┌───────────┐
│  Student  │
└───────────┘

Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类

只有Object特殊,它没有父类。

protected

继承有个特点,就是子类无法访问父类的private字段或者private方法

为了让子类可以访问父类的字段,我们需要把private改为protected

protected修饰的字段可以被子类访问:

1
2
3
4
5
6
7
8
9
10
class Person {
protected String name;
protected int age;
}

class Student extends Person {
public String hello() {
return "Hello, " + name; // OK!
}
}

因此,protected关键字可以把字段和方法的访问权限控制在继承树内部,一个protected字段和方法可以被其子类,以及子类的子类所访问。

super

super关键字表示父类(超类)。

子类引用父类的字段时,可以用super.fieldName。例如:

1
2
3
4
5
class Student extends Person {
public String hello() {
return "Hello, " + super.name;
}
}

实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。

编译器会自动定位到父类的name字段。

但是,在某些时候,就必须使用super。

在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。

如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();,

所以,Student类的构造方法实际上是这样:

1
2
3
4
5
6
7
8
class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
super(); // 自动调用父类的构造方法
this.score = score;
}
}

但是,Person类并没有无参数的构造方法,因此,编译失败。

解决方法是调用Person类存在的某个构造方法。例如:

1
2
3
4
5
6
7
8
class Student extends Person {
protected int score;

public Student(String name, int age, int score) {
super(name, age); // 调用父类的构造方法Person(String, int)
this.score = score;
}
}

因此我们得出结论:如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法。

这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。

向上转型

如果一个引用变量的类型是Student,那么它可以指向一个Student类型的实例:

Student s = new Student();

如果一个引用类型的变量是Person,那么它可以指向一个Person类型的实例:

Person p = new Person();

现在问题来了:如果Student是从Person继承下来的,那么,一个引用类型为Person的变量,能否指向Student类型的实例?

Person p = new Student(); // ???

测试一下就可以发现,这种指向是允许的!

这是因为Student继承自Person,因此,它拥有Person的全部功能。Person类型的变量,如果指向Student类型的实例,对它进行操作,是没有问题的!

这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)。

向下转型

和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。

例如:

1
2
3
4
5
Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
// 因为p2的实际类型是Person,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。
Student s2 = (Student) p2; // runtime error! ClassCastException!

为了避免向下转型出错,Java提供了instanceof操作符,可以先判断一个实例究竟是不是某种类型:

1
2
3
4
5
6
7
8
9
10
Person p = new Person();
System.out.println(p instanceof Person); // true
System.out.println(p instanceof Student); // false

Student s = new Student();
System.out.println(s instanceof Person); // true
System.out.println(s instanceof Student); // true

Student n = null;
System.out.println(n instanceof Student); // false

instanceof实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。

区分继承和组合

因为Student是Person的一种,它们是is关系,而Student并不是Book。实际上Student和Book的关系是has关系。

具有has关系不应该使用继承,而是使用组合,即Student可以持有一个Book实例:

1
2
3
4
class Student extends Person {
protected Book book;
protected int score;
}

因此,继承是is关系,组合是has关系。

小结

继承是面向对象编程的一种强大的代码复用方式;

Java只允许单继承,所有类最终的根类是Object;

protected允许子类访问父类的字段和方法;

子类的构造方法可以通过super()调用父类的构造方法;

可以安全地向上转型为更抽象的类型;

可以强制向下转型,最好借助instanceof判断;

子类和父类的关系是is,has关系不能用继承。

多态

在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。

例如,在Person类中,我们定义了run()方法:

1
2
3
4
5
class Person {
public void run() {
System.out.println("Person.run");
}
}

在子类Student中,覆写这个run()方法:

1
2
3
4
5
6
7
8
9
10
class Student extends Person {
/**
* 加上`@Override`可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错。
* 但是@Override不是必需的。
*/
@Override
public void run() {
System.out.println("Student.run");
}
}

Override和Overload不同的是,如果方法签名如果不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override。

注意:方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错。

1
2
3
4
5
6
7
8
9
10
class Person {
public void run() { … }
}

class Student extends Person {
// 不是Override,因为参数不同:
public void run(String s) { … }
// 不是Override,因为返回值不同:
public int run() { … }
}

那么,一个实际类型为Student,引用类型为Person的变量,调用其run()方法,调用的是Person还是Student的run()方法?

Person p = new Student();

实际上调用的方法是Student的run()方法。因此可得出结论:

Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。

这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic。

多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。

例如:

1
2
Person p = new Student();
p.run(); // 无法确定运行时究竟调用哪个run()方法

有童鞋会问,从上面的代码一看就明白,肯定调用的是Student的run()方法啊。

但是,假设我们编写这样一个方法:

1
2
3
4
public void runTwice(Person p) {
p.run();
p.run();
}

它传入的参数类型是Person,我们是无法知道传入的参数实际类型究竟是Person,还是Student,还是Person的其他子类,因此,也无法确定调用的是不是Person类定义的run()方法。

所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。

这种不确定性的方法调用,究竟有什么作用?

假设我们定义一种收入,需要给它报税,那么先定义一个Income类;

对于工资收入,可以减去一个基数,那么我们可以从Income派生出SalaryIncome,并覆写getTax();

如果你享受国务院特殊津贴,那么按照规定,可以全部免税(StateCouncilSpecialAllowance);

现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税,可以这么写:

1
2
3
4
5
6
7
public double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}

来试一下:

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
public class Main {
public static void main(String[] args) {
// 给一个有普通收入、工资收入和享受国务院特殊津贴的小伙伴算税:
Income[] incomes = new Income[] {
new Income(3000),
new Salary(7500),
new StateCouncilSpecialAllowance(15000)
};
System.out.println(totalTax(incomes));
}

public static double totalTax(Income... incomes) {
double total = 0;
for (Income income: incomes) {
total = total + income.getTax();
}
return total;
}
}

class Income {
protected double income;

public Income(double income) {
this.income = income;
}

public double getTax() {
return income * 0.1; // 税率10%
}
}

class Salary extends Income {
public Salary(double income) {
super(income);
}

@Override
public double getTax() {
if (income <= 5000) {
return 0;
}
return (income - 5000) * 0.2;
}
}

class StateCouncilSpecialAllowance extends Income {
public StateCouncilSpecialAllowance(double income) {
super(income);
}

@Override
public double getTax() {
return 0;
}
}

观察totalTax()方法:利用多态,totalTax()方法只需要和Income打交道,它完全不需要知道Salary和StateCouncilSpecialAllowance的存在,就可以正确计算出总的税。

如果我们要新增一种稿费收入,只需要从Income派生,然后正确覆写getTax()方法就可以。

把新的类型传入totalTax(),不需要修改任何代码。

可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。

覆写Object方法

因为所有的class最终都继承自Object,而Object定义了几个重要的方法:

  • toString():把instance输出为String;
  • equals():判断两个instance是否逻辑相等;
  • hashCode():计算一个instance的哈希值。

在必要的情况下,我们可以覆写Object的这几个方法。例如:

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
class Person {
...
// 显示更有意义的字符串:
@Override
public String toString() {
return "Person:name=" + name;
}

// 比较是否相等:
@Override
public boolean equals(Object o) {
// 当且仅当o为Person类型:
if (o instanceof Person) {
Person p = (Person) o;
// 并且name字段相同时,返回true:
return this.name.equals(p.name);
}
return false;
}

// 计算hash:
@Override
public int hashCode() {
return this.name.hashCode();
}
}

调用super

在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super来调用。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Person {
protected String name;
public String hello() {
return "Hello, " + name;
}
}

Student extends Person {
@Override
public String hello() {
// 调用父类的hello()方法:
return super.hello() + "!";
}
}

final

继承可以允许子类覆写父类的方法。

如果一个父类不允许子类对它的某个方法s进行覆写,可以把该方法标记为final。

用final修饰的方法不能被Override:

1
2
3
4
5
6
7
8
9
10
11
12
13
class Person {
protected String name;
public final String hello() {
return "Hello, " + name;
}
}

Student extends Person {
// compile error: 不允许覆写
@Override
public String hello() {
}
}

如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final。用final修饰的类不能被继承:

1
2
3
4
5
6
7
final class Person {
protected String name;
}

// compile error: 不允许继承自Person
Student extends Person {
}

对于一个类的实例字段,同样可以用final修饰。用final修饰的字段在初始化后不能被修改。例如:

1
2
3
4
5
6
7
class Person {
public final String name = "Unamed";
}

// 对final字段重新赋值会报错:
Person p = new Person();
p.name = "New Name"; // compile error!

可以在构造方法中初始化final字段:

1
2
3
4
5
6
class Person {
public final String name;
public Person(String name) {
this.name = name;
}
}

这种方法更为常用,因为可以保证实例一旦创建,其final字段就不可修改。

抽象类 abstract

接口 interface

在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。

如果一个抽象类没有字段,所有方法全部都是抽象方法:

1
2
3
4
abstract class Person {
public abstract void run();
public abstract String getName();
}

就可以把该抽象类改写为接口:interface。

在Java中,使用interface可以声明一个接口:

1
2
3
4
interface Person {
void run();
String getName();
}

所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有

因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)。

当一个具体的class去实现一个interface时,需要使用implements关键字。举个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Student implements Person {
private String name;

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

@Override
public void run() {
System.out.println(this.name + " run");
}

@Override
public String getName() {
return this.name;
}
}

我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。

但是,一个类可以实现多个interface,例如:

1
2
3
class Student implements Person, Hello { // 实现了两个interface
...
}

抽象类和接口的对比如下:

abstract class interface
继承 只能extends一个class 可以implements多个interface
字段 可以定义实例字段 不能定义实例字段
抽象方法 可以定义抽象方法 可以定义抽象方法
非抽象方法 可以定义非抽象方法 可以定义default方法

Java的接口特指interface的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。

接口继承

一个interface可以继承自另一个interface。

interface继承自interface使用extends,它相当于扩展了接口的方法。

例如:

1
2
3
4
5
6
7
8
interface Hello {
void hello();
}

interface Person extends Hello {
void run();
String getName();
}

此时,Person接口继承自Hello接口,因此,Person接口现在实际上有3个抽象方法签名,其中一个来自继承的Hello接口。

继承关系

合理设计interface和abstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。

可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:

┌───────────────┐
│   Iterable    │
└───────────────┘
        ▲                ┌───────────────────┐
        │                │      Object       │
┌───────────────┐        └───────────────────┘
│  Collection   │                  ▲
└───────────────┘                  │
        ▲     ▲          ┌───────────────────┐
        │     └──────────│AbstractCollection │
┌───────────────┐        └───────────────────┘
│     List      │                  ▲
└───────────────┘                  │
            ▲          ┌───────────────────┐
            └──────────│   AbstractList    │
                       └───────────────────┘
                                ▲     ▲
                                │     │
                                │     │
                    ┌────────────┐ ┌────────────┐
                    │ ArrayList  │ │ LinkedList │
                    └────────────┘ └────────────┘

在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:

1
2
3
List list = new ArrayList(); // 用List接口引用具体子类的实例
Collection coll = list; // 向上转型为Collection接口
Iterable it = coll; // 向上转型为Iterable接口

default方法

在接口中,可以定义default方法。

例如,把Person接口的run()方法改为default方法:

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 Main {
public static void main(String[] args) {
Person p = new Student("Xiao Ming");
p.run();
}
}

interface Person {
String getName();
default void run() {
System.out.println(getName() + " run");
}
}

class Student implements Person {
private String name;

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

public String getName() {
return this.name;
}
}

实现类可以不必覆写default方法。default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。

default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。

静态字段和静态方法

在一个class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。

静态字段

还有一种字段,是用static修饰的字段,称为静态字段:static field。

实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”所有实例都会共享该字段

举个例子:

1
2
3
4
5
6
class Person {
public String name;
public int age;
// 定义静态字段number:
public static int number;
}

我们来看看下面的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class Main {
public static void main(String[] args) {
Person ming = new Person("Xiao Ming", 12);
Person hong = new Person("Xiao Hong", 15);
ming.number = 88; // 更好的写法 Person.number
System.out.println(hong.number);
hong.number = 99; // 更好的写法 Person.number
System.out.println(ming.number);
}
}

class Person {
public String name;
public int age;

public static int number;

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

对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,

原因是静态字段并不属于实例:

        ┌──────────────────┐
ming ──>│Person instance   │
        ├──────────────────┤
        │name = "Xiao Ming"│
        │age = 12          │
        │number ───────────┼──┐    ┌─────────────┐
        └──────────────────┘  │    │Person class │
                              │    ├─────────────┤
                              ├───>│number = 99  │
        ┌──────────────────┐  │    └─────────────┘
hong ──>│Person instance   │  │
        ├──────────────────┤  │
        │name = "Xiao Hong"│  │
        │age = 15          │  │
        │number ───────────┼──┘
        └──────────────────┘

虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。

因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。

在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象。

推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段(非实例字段)。对于上面的代码,更好的写法是:

1
2
Person.number = 99;
System.out.println(Person.number);

静态方法

有静态字段,就有静态方法。用static修饰的方法称为静态方法。

调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。

静态方法类似其它编程语言的函数。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Main {
public static void main(String[] args) {
Person.setNumber(99);
System.out.println(Person.number);
}
}

class Person {
public static int number;

public static void setNumber(int value) {
number = value;
}
}

因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段。

通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。
通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。

注意到Java程序的入口main()也是静态方法。

接口的静态字段

因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型:

1
2
3
4
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}

实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:

1
2
3
4
5
public interface Person {
// 编译器会自动加上public statc final:
int MALE = 1;
int FEMALE = 2;
}

编译器会自动把该字段变为public static final类型。

包 package

在Java中,我们使用package来解决名字冲突。

Java定义了一种名字空间,称之为包:package。

一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名

例如:

  • 小明的Person类存放在包ming下面,因此,完整类名是ming.Person;
  • 小红的Person类存放在包hong下面,因此,完整类名是hong.Person;
  • 小军的Arrays类存放在包mr.jun下面,因此,完整类名是mr.jun.Arrays;
  • JDK的Arrays类存放在包java.util下面,因此,完整类名是java.util.Arrays。

在定义class的时候,我们需要在第一行声明这个class属于哪个包。

小明的Person.java文件:

1
2
3
4
package ming; // 申明包名ming

public class Person {
}

小军的Arrays.java文件:

1
2
3
4
package mr.jun; // 申明包名mr.jun

public class Arrays {
}

在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。

要特别注意:包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。

没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。

我们还需要按照包结构把上面的Java文件组织起来。

假设以package_sample作为根目录,src作为源码目录,那么所有文件结构就是:

package_sample
└─ src
    ├─ hong
    │   └─ Person.java
    │  ming
    │   └─ Person.java
    └─ mr
       └─ jun
           └─ Arrays.java

即所有Java文件对应的目录层次要和包的层次一致。

编译后的.class文件也需要按照包结构存放。

如果使用IDE,把编译后的.class文件放到bin目录下,那么,编译的文件结构就是:

package_sample
    └─ bin
        ├─ hong
        │   └─ Person.class
        │  ming
        │   └─ Person.class
        └─ mr
            └─ jun
                └─ Arrays.class

编译的命令相对比较复杂,我们需要在src目录下执行javac命令:

javac -d ../bin ming/Person.java hong/Person.java mr/jun/Arrays.java

在IDE中,会自动根据包结构编译所有Java源码,所以不必担心使用命令行编译的复杂命令。

包作用域

位于同一个包的类,可以访问包作用域的字段和方法。

不用public、protected、private修饰的字段和方法就是包作用域。

例如,Person类定义在hello包下面:

1
2
3
4
5
6
7
8
package hello;

public class Person {
// 包作用域:
void hello() {
System.out.println("Hello!");
}
}

Main类也定义在hello包下面:

1
2
3
4
5
6
7
8
package hello;

public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}

import

在一个class中,我们总会引用其他的class。

例如,小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,

他有三种写法:

  • 第一种,直接写出完整类名,例如:
1
2
3
4
5
6
7
8
9
// Person.java
package ming;

public class Person {
public void run() {
// 直接写出完整类名
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}

很显然,每次写完整类名比较痛苦。

  • 第二种写法是用import语句,导入小军的Arrays,然后写简单类名:
1
2
3
4
5
6
7
8
9
10
11
12
// Person.java
package ming;

// 导入完整类名:
import mr.jun.Arrays;

public class Person {
public void run() {
// 用import语句,导入小军的Arrays
Arrays arrays = new Arrays();
}
}

在写import的时候,可以使用*,表示把这个包下面的所有class都导入进来(但不包括子包的class):

1
2
3
4
5
6
7
8
9
10
11
// Person.java
package ming;

// 导入mr.jun包的所有class:
import mr.jun.*;

public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}

一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays类属于哪个包。

  • 还有一种import static的语法,它可以导入可以导入一个类的静态字段和静态方法:
1
2
3
4
5
6
7
8
9
10
11
package main;

// 导入System类的所有静态字段和静态方法:
import static java.lang.System.*;

public class Main {
public static void main(String[] args) {
// 相当于调用System.out.println(…)
out.println("Hello, world!");
}
}

import static很少使用。

Java编译器最终编译出的.class文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时:

  • 如果是完整类名,就直接根据完整类名查找这个class;
  • 如果是简单类名,按下面的顺序依次查找:
    • 查找当前package是否存在这个class;
    • 查找import的包是否包含这个class;
    • 查找java.lang包是否包含这个class。

如果按照上面的规则还无法确定类名,则编译报错。

我们来看一个例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Main.java
package test;

import java.text.Format;

public class Main {
public static void main(String[] args) {
java.util.List list; // ok,使用完整类名 -> java.util.List
Format format = null; // ok,使用import的类 -> java.text.Format
String s = "hi"; // ok,使用java.lang包的String -> java.lang.String
System.out.println(s); // ok,使用java.lang包的System -> java.lang.System
MessageFormat mf = null; // 编译错误:无法找到MessageFormat: MessageFormat cannot be resolved to a type
}
}

因此,编写class的时候,编译器会自动帮我们做两个import动作:

  • 默认自动import当前package的其他class;
  • 默认自动import java.lang.*。

注意:自动导入的是java.lang包,但类似java.lang.reflect这些包仍需要手动导入。

如果有两个class名称相同,例如,mr.jun.Arrays和java.util.Arrays,那么只能import其中一个,另一个必须写完整类名。

最佳实践

为了避免名字冲突,我们需要确定唯一的包名。

推荐的做法是使用倒置的域名来确保唯一性。

例如:

  • org.apache
  • org.apache.commons.log
  • com.liaoxuefeng.sample

子包就可以根据功能自行命名。

作用域

在Java中,这些修饰符可以用来限定访问作用域。

classpath 和 jar

classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class。

因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。

因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件。

所以,classpath就是一组目录的集合,它设置的搜索路径与操作系统相关。

设定方法

classpath的设定方法有两种:

  • 在系统环境变量中设置classpath环境变量,不推荐;
  • 在启动JVM时设置classpath变量,推荐。

我们强烈不推荐在系统环境变量中设置classpath,那样会污染整个系统环境。在启动JVM时设置classpath才是推荐的做法。

实际上就是给java命令传入-classpath或-cp参数:

java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello

或者使用-cp的简写:

java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello

没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath为.,即当前目录:

java abc.xyz.Hello

在IDE中运行Java程序,IDE自动传入的-cp参数是当前工程的bin目录引入的jar包

通常,我们在自己编写的class中,会引用Java核心库的class,例如,String、ArrayList等。

有很多“如何设置classpath”的文章会告诉你把JVM自带的rt.jar放入classpath,但事实上,根本不需要告诉JVM如何去Java核心库查找class,JVM怎么可能笨到连自己的核心库在哪都不知道?

不要把任何Java核心库添加到classpath中!JVM根本不依赖classpath加载核心库!

更好的做法是,不要设置classpath!默认的当前目录.对于绝大多数情况都够用了。

jar包

如果有很多.class文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。

jar包就是用来干这个事的,它可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。

jar包实际上就是一个zip格式的压缩文件,而jar包相当于目录。

如果我们要执行一个jar包的class,就可以把jar包放到classpath中:

java -cp ./hello.jar abc.xyz.Hello

这样JVM会自动在hello.jar文件里去搜索某个类。

jar包还可以包含一个特殊的/META-INF/MANIFEST.MF文件,

MANIFEST.MF是纯文本,可以指定Main-Class和其它信息。

JVM会自动读取这个MANIFEST.MF文件,如果存在Main-Class,我们就不必在命令行指定启动的类名,而是用更方便的命令:

java -jar hello.jar

jar包还可以包含其它jar包,这个时候,就需要在MANIFEST.MF文件里配置classpath了。

在大型项目中,不可能手动编写MANIFEST.MF文件,再手动创建zip包。

Java社区提供了大量的开源构建工具,例如Maven,可以非常方便地创建jar包。

模块 Module

我们知道,.class文件是JVM看到的最小可执行文件,而一个大型程序需要编写很多Class,并生成一堆.class文件,很不便于管理,所以,jar文件就是class文件的容器。

在Java 9之前,一个大型Java程序会生成自己的jar文件,同时引用依赖的第三方jar文件,而JVM自带的Java标准库,实际上也是以jar文件形式存放的,这个文件叫rt.jar,一共有60多M。

如果是自己开发的程序,除了一个自己的app.jar以外,还需要一堆第三方的jar包,运行一个Java程序,一般来说,命令行写这样:

java -cp app.jar:a.jar:b.jar:c.jar com.liaoxuefeng.sample.Main

如果漏写了某个运行时需要用到的jar,那么在运行期极有可能抛出ClassNotFoundException。

所以,jar只是用于存放class的容器,它并不关心class之间的依赖。

从Java 9开始引入的模块,主要是为了解决“依赖”这个问题。

如果a.jar必须依赖另一个b.jar才能运行,那我们应该给a.jar加点说明啥的,让程序在编译和运行的时候能自动定位到b.jar,这种自带“依赖关系”的class容器就是模块。

为了表明Java模块化的决心,从Java 9开始,原有的Java标准库已经由一个单一巨大的rt.jar分拆成了几十个模块,这些模块以.jmod扩展名标识,可以在$JAVA_HOME/jmods目录下找到它们:

  • java.base.jmod
  • java.compiler.jmod
  • java.datatransfer.jmod
  • java.desktop.jmod

这些.jmod文件每一个都是一个模块,模块名就是文件名。

例如:模块java.base对应的文件就是java.base.jmod。

模块之间的依赖关系已经被写入到模块内的module-info.class文件了。所有的模块都直接或间接地依赖java.base模块,只有java.base模块不依赖任何模块,它可以被看作是“根模块”,好比所有的类都是从Object直接或间接继承而来。

把一堆class封装为jar仅仅是一个打包的过程,而把一堆class封装为模块则不但需要打包,还需要写入依赖关系,并且还可以包含二进制代码(通常是JNI扩展)。此外,模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本。

Java核心类

Java的核心类,包括:

  • 字符串
  • StringBuilder
  • StringJoiner
  • 包装类型
  • JavaBean
  • 枚举
  • 常用工具类

String

在Java中,String是一个引用类型,它本身也是一个class。

但是,Java编译器对String有特殊处理,即可以直接用”…”来表示一个字符串:

String s1 = "Hello!";

实际上字符串在String内部是通过一个char[]数组表示的,因此,按下面的写法也是可以的:

String s2 = new String(new char[] {'H', 'e', 'l', 'l', 'o', '!'});

字符串比较

当我们想要比较两个字符串是否相同时,要特别注意,我们实际上是想比较字符串的内容是否相同。必须使用equals()方法而不能用==

小结

Java字符串String是不可变对象;

字符串操作不改变原字符串内容,而是返回新字符串;

常用的字符串操作:提取子串、查找、替换、大小写转换等;

Java使用Unicode编码表示String和char;

转换编码就是将String和byte[]转换,需要指定编码;

转换为byte[]时,始终优先考虑UTF-8编码。

StringBuilder

Java编译器对String做了特殊处理,使得我们可以直接用+拼接字符串。

考察下面的循环代码:

String s = "";
for (int i = 0; i < 1000; i++) {
    s = s + "," + i;
}

虽然可以直接拼接字符串,但是,在循环中,每次循环都会创建新的字符串对象,然后扔掉旧的字符串。这样,绝大部分字符串都是临时对象,不但浪费内存,还会影响GC效率。

为了能高效拼接字符串,Java标准库提供了StringBuilder,它是一个可变对象,可以预分配缓冲区,这样,往StringBuilder中新增字符时,不会创建新的临时对象:

StringBuilder sb = new StringBuilder(1024);
for (int i = 0; i < 1000; i++) {
    sb.append(',');
    sb.append(i);
}
String s = sb.toString();

注意:对于普通的字符串+操作,并不需要我们将其改写为StringBuilder,因为Java编译器在编译时就自动把多个连续的+操作编码为StringConcatFactory的操作。在运行期,StringConcatFactory会自动把字符串连接操作优化为数组复制或者StringBuilder操作。

StringJoiner

包装类型

Java的数据类型分两种:

  • 基本类型:byte,short,int,long,boolean,float,double,char

  • 引用类型:所有class和interface类型

实际上,因为包装类型非常有用,Java核心库为每种基本类型都提供了对应的包装类型:

基本类型 对应的引用类型
boolean java.lang.Boolean
byte java.lang.Byte
short java.lang.Short
int java.lang.Integer
long java.lang.Long
float java.lang.Float
double java.lang.Double
char java.lang.Character

小结

Java核心库提供的包装类型可以把基本类型包装为class;

自动装箱和自动拆箱都是在编译期完成的(JDK>=1.5);

装箱和拆箱会影响执行效率,且拆箱时可能发生NullPointerException;

包装类型的比较必须使用equals();

整数和浮点数的包装类型都继承自Number;

包装类型提供了大量实用方法。

JavaBean

JavaBean主要用来传递数据,即把一组数据组合成一个JavaBean便于传输。此外,JavaBean可以方便地被IDE工具分析,生成读写属性的代码,主要用在图形界面的可视化设计中。

小结

JavaBean是一种符合命名规范的class,它通过getter和setter来定义属性;

属性是一种通用的叫法,并非Java语法规定;

可以利用IDE快速生成getter和setter;

使用Introspector.getBeanInfo()可以获取属性列表。

枚举类

BigInteger

在Java中,由CPU原生提供的整型最大范围是64位long型整数。使用long型整数可以直接通过CPU指令进行计算,速度非常快。

如果我们使用的整数范围超过了long型怎么办?这个时候,就只能用软件来模拟一个大整数。

java.math.BigInteger就是用来表示任意大小的整数。

BigInteger内部用一个int[]数组来模拟一个非常大的整数:

BigInteger bi = new BigInteger("1234567890");
System.out.println(bi.pow(5)); // 2867971860299718107233761438093672048294900000

和long型整数运算比,BigInteger不会有范围限制,但缺点是速度比较慢。

BigDecimal

和BigInteger类似,BigDecimal可以表示一个任意大小且精度完全准确的浮点数。

对BigDecimal做加、减、乘时,精度不会丢失,但是做除法时,存在无法除尽的情况,这时,就必须指定精度以及如何进行截断:

BigDecimal d1 = new BigDecimal("123.456");
BigDecimal d2 = new BigDecimal("23.456789");
BigDecimal d3 = d1.divide(d2, 10, RoundingMode.HALF_UP); // 保留10位小数并四舍五入

小结

BigDecimal用于表示精确的小数,常用于财务计算;

比较BigDecimal的值是否相等,必须使用compareTo()而不能使用equals()。

常用工具类

Java的核心库提供了大量的现成的类供我们使用。本节我们介绍几个常用的工具类。

Math

顾名思义,Math类就是用来进行数学计算的,它提供了大量的静态方法来便于我们实现数学计算

SecureRandom的安全性是通过操作系统提供的安全的随机种子来生成随机数。这个种子是通过CPU的热噪声、读写磁盘的字节、网络流量等各种随机事件产生的“熵”。

在密码学中,安全的随机数非常重要。如果使用不安全的伪随机数,所有加密体系都将被攻破。因此,时刻牢记必须使用SecureRandom来产生安全的随机数。

异常处理

Java的异常

ava内置了一套异常处理机制,总是使用异常来表示错误。

异常是一种class,因此它本身带有类型信息。异常可以在任何地方抛出,但只需要在上层捕获,这样就和方法调用分离了:

try {
    String s = processFile(“C:\\test.txt”);
    // ok:
} catch (FileNotFoundException e) {
    // file not found:
} catch (SecurityException e) {
    // no read permission:
} catch (IOException e) {
    // io error:
} catch (Exception e) {
    // other error:
}

反射

反射就是Reflection,Java的反射是指程序在运行期可以拿到一个对象的所有信息。

反射是为了解决在运行期,对某个实例一无所知的情况下,如何调用其方法。

加密与安全

应对潜在的安全威胁,需要做到三防:

  • 防窃听
  • 防篡改
  • 防伪造

计算机加密技术就是为了实现上述目标,而现代计算机密码学理论是建立在严格的数学理论基础上的,密码学已经逐渐发展成一门科学。

对于绝大多数开发者来说,设计一个安全的加密算法非常困难,验证一个加密算法是否安全更加困难,当前被认为安全的加密算法仅仅是迄今为止尚未被攻破。

多线程

多线程是Java最基本的一种并发模型,本章我们将详细介绍Java多线程编程。

现代操作系统(Windows,macOS,Linux)都可以执行多任务。多任务就是同时运行多个任务.

CPU执行代码都是一条一条顺序执行的,但是,即使是单核cpu,也可以同时运行多个任务。

因为操作系统执行多任务实际上就是让CPU对多个任务轮流交替执行。

操作系统轮流让多个任务交替执行,例如,让浏览器执行0.001秒,让QQ执行0.001秒,再让音乐播放器执行0.001秒,在人看来,CPU就是在同时执行多个任务。

即使是多核CPU,因为通常任务的数量远远多于CPU的核数,所以任务也是交替执行的。

进程

在计算机中,我们把一个任务称为一个进程,浏览器就是一个进程,视频播放器是另一个进程,类似的,音乐播放器和Word都是进程。

某些进程内部还需要同时执行多个子任务。例如,我们在使用Word时,Word可以让我们一边打字,一边进行拼写检查,同时还可以在后台进行打印,我们把子任务称为线程。

进程和线程的关系就是:一个进程可以包含一个或多个线程,但至少会有一个线程。

                        ┌──────────┐
                        │Process   │
                        │┌────────┐│
            ┌──────────┐││ Thread ││┌──────────┐
            │Process   ││└────────┘││Process   │
            │┌────────┐││┌────────┐││┌────────┐│
┌──────────┐││ Thread ││││ Thread ││││ Thread ││
│Process   ││└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘└──────────┘
┌──────────────────────────────────────────────┐
│               Operating System               │
└──────────────────────────────────────────────┘

操作系统调度的最小任务单位其实不是进程,而是线程。

常用的Windows、Linux等操作系统都采用抢占式多任务,如何调度线程完全由操作系统决定,程序自己不能决定什么时候执行,以及执行多长时间。

因为同一个应用程序,既可以有多个进程,也可以有多个线程,因此,实现多任务的方法,有以下几种:

1、多进程模式(每个进程只有一个线程):

┌──────────┐ ┌──────────┐ ┌──────────┐
│Process   │ │Process   │ │Process   │
│┌────────┐│ │┌────────┐│ │┌────────┐│
││ Thread ││ ││ Thread ││ ││ Thread ││
│└────────┘│ │└────────┘│ │└────────┘│
└──────────┘ └──────────┘ └──────────┘

2、多线程模式(一个进程有多个线程):

┌────────────────────┐
│Process             │
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
│┌────────┐┌────────┐│
││ Thread ││ Thread ││
│└────────┘└────────┘│
└────────────────────┘

3、多进程+多线程模式(复杂度最高):

┌──────────┐┌──────────┐┌──────────┐
│Process   ││Process   ││Process   │
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
│┌────────┐││┌────────┐││┌────────┐│
││ Thread ││││ Thread ││││ Thread ││
│└────────┘││└────────┘││└────────┘│
└──────────┘└──────────┘└──────────┘

进程 vs 线程

进程和线程是包含关系,但是多任务既可以由多进程实现,也可以由单进程内的多线程实现,还可以混合多进程+多线程。

具体采用哪种方式,要考虑到进程和线程的特点。

和多线程相比,多进程的缺点在于:

  • 创建进程比创建线程开销大,尤其是在Windows系统上;
  • 进程间通信比线程间通信要慢,因为线程间通信就是读写同一个变量,速度很快。

而多进程的优点在于:

多进程稳定性比多线程高,因为在多进程的情况下,一个进程崩溃不会影响其他进程,而在多线程的情况下,任何一个线程崩溃会直接导致整个进程崩溃。

多线程

Java语言内置了多线程支持:一个Java程序实际上是一个JVM进程,JVM进程用一个主线程来执行main()方法,在main()方法内部,我们又可以启动多个线程。此外,JVM还有负责垃圾回收的其他工作线程等。

因此,对于大多数Java程序来说,我们说多任务,实际上是说如何使用多线程实现多任务。

和单线程相比,多线程编程的特点在于:多线程经常需要读写共享数据,并且需要同步。例如,播放电影时,就必须由一个线程播放视频,另一个线程播放音频,两个线程需要协调运行,否则画面和声音就不同步。因此,多线程编程的复杂度高,调试更困难。

Java多线程编程的特点又在于:

  • 多线程模型是Java程序最基本的并发模型;
  • 后续读写网络、数据库、Web开发等都依赖Java多线程模型。

因此,必须掌握Java多线程编程才能继续深入学习其他内容。

创建新线程

Java语言内置了多线程支持。当Java程序启动的时候,实际上是启动了一个JVM进程,然后,JVM启动主线程来执行main()方法。在main()方法中,我们又可以启动其他线程。

要创建一个新线程非常容易,我们需要实例化一个Thread实例,然后调用它的start()方法:

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread();
        t.start(); // 启动新线程
    }
}

但是这个线程启动后实际上什么也不做就立刻结束了。

我们希望新线程能执行指定的代码,有以下几种方法:

方法一:从Thread派生一个自定义类,然后覆写run()方法:

public class Main {
    public static void main(String[] args) {
        Thread t = new MyThread();
        t.start(); // 启动新线程
    }
}

class MyThread extends Thread {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

执行上述代码,注意到start()方法会在内部自动调用实例的run()方法。

方法二:创建Thread实例时,传入一个Runnable实例:

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(new MyRunnable());
        t.start(); // 启动新线程
    }
}

class MyRunnable implements Runnable {
    @Override
    public void run() {
        System.out.println("start new thread!");
    }
}

或者用Java8引入的lambda语法进一步简写为:

public class Main {
    public static void main(String[] args) {
        Thread t = new Thread(() -> {
            System.out.println("start new thread!");
        });
        t.start(); // 启动新线程
    }
}