面向对象基础

整理BY:Misayaas

内容来自廖雪峰的官方网站: https://www.liaoxuefeng.com/

方法

一个class可以包含多个field。但是,直接把fieldpublic暴露给外部可能会破坏封装性。为了避免外部代码直接去访问field,我们可以用private修饰field,拒绝外部访问

  • 所以我们需要使用方法(method)来让外部代码可以间接修改

所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。

定义方法

1
2
3
4
修饰符 方法返回类型 方法名(方法参数列表) {
若干方法语句;
return 方法返回值;
}
  • private方法
    定义private方法的理由是内部方法是可以调用private方法的

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

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

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

参数绑定

  • 基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响
  • 引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。

构造方法

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

  • 构造方法的名称就是类名
  • 和普通方法相比,构造方法没有返回值(也没有void),调用构造方法,必须用new操作符。
  • 任何class都有构造方法
  • 如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样:
    1
    2
    3
    4
    class Person {
    public Person() {
    }
    }
  • 如果既要能使用带参数的构造方法,又想保留不带参数的构造方法,那么只能把两个构造方法都定义出来

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

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

在Java中,创建对象实例的时候,按照如下顺序进行初始化:

1.先初始化字段
2.执行构造方法的代码进行初始化

一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是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)
}
}

方法重载(Overload)

方法名相同,但各自的参数不同

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

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

继承

子类自动获得了父类的所有字段,严禁定义与父类重名的字段!

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

1
2
3
4
5
6
7
8
9
10
11
12
13
┌───────────┐
│ Object │
└───────────┘


┌───────────┐
│ Person │
└───────────┘


┌───────────┐
│ Student │
└───────────┘

Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。

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

  • 为了让子类可以访问父类的字段,我们需要把private改为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字段。

在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();

  • 这里的super()是无参数构造方法
  • 如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法

子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的

阻止继承

从Java 15开始,允许使用sealed修饰class,并通过permits明确写出能够从该class继承的子类名称。

1
2
3
public sealed class Shape permits Rect, Circle, Triangle {
...
}

  • 上述Shape类就是一个sealed类,它只允许指定的3个类继承它。

向上转型

如果Student是从Person继承下来的,那,一个引用类型为Person的变量,指向Student类型的实例

1
Person p = new Student()

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

向上转型实际上是把一个子类型安全地变为更加抽象的父类型:

1
2
3
4
Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok

向下转型

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

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

  • 因此,向下转型很可能会失败。失败的时候,Java虚拟机会报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

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

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

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

多态

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

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

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

  • 多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法
  • 多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。

重写Object方法

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

  • toString():把instance输出为String
  • equals():判断两个instance是否逻辑相等;
  • hashCode():计算一个instance的哈希值。
    在有必要的情况下,我们可以重写这些方法

调用super

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

final

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

  • 如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final

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

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

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

抽象类

如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去重写它,那么,可以把父类的方法声明为抽象方法

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

  • 因为这个抽象方法本身是无法执行的,所以,Person类也无法被实例化
  • 必须把Person类本身也声明为abstract,才能正确编译它

使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类:

1
Person p = new Person(); // 编译错误

抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错

这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型:

1
2
3
// 不关心Person变量的具体子类型:
s.run();
t.run();

同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:
1
2
3
// 同样不关心新的子类是如何实现run()方法的:
Person e = new Employee();
e.run();

这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程

面向抽象编程的本质就是:

  • 上层代码只定义规范(例如:abstract class Person);

  • 不需要子类就可以实现业务逻辑(正常编译);

  • 具体的业务逻辑由不同的子类实现,调用者并不关心

接口

如果一个抽象类没有字段,所有方法全部都是抽象方法,就可以把该抽象类改写为接口:interface

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

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

所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)

一个类可以实现多个interface(区别于继承)

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

注意区分术语:

  • Java的接口特指interface的定义,表示一个接口类型和一组方法签名
  • 编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
abstract class interface
继承 只能extends一个class 可以implements多个interface
字段 可以定义实例字段 不能定义实例字段
抽象方法 可以定义抽象方法 可以定义抽象方法
非抽象方法 可以定义非抽象方法 可以定义default方法

接口继承

一个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();
}

继承关系

一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度

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

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方法无法访问字段,而抽象类的普通方法可以访问实例字段。

静态字段和静态方法

静态字段

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

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

  • 实例可以访问静态字段,但它们指向的都是一个共享的静态字段
  • 在java程序中,实例对象并没有静态字段
    • 实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象
    • 推荐用类名来访问静态字段
    • 可以把静态字段理解为描述class本身的字段
      1
      2
      Person.number = 99;
      System.out.println(Person.number);

静态方法

调用静态方法则不需要实例变量,通过类名就可以调用

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

  • 通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告

静态方法经常用于工具类。例如:

  • Arrays.sort()

  • Math.random()

静态方法也经常用于辅助方法

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

接口的静态字段

interface是可以有静态字段的,并且静态字段必须为final类型

  • 实际上,因为interface的字段只能public static final类型,所以我们可以把这些修饰符都去掉
    1
    2
    3
    4
    5
    public interface Person {
    // 编译器会自动加上public statc final:
    int MALE = 1;
    int FEMALE = 2;
    }

Java定义了一种名字空间,称之为package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名

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

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

  • 包可以是多层结构,用.隔开。例如:java.util

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

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

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

包作用域

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

  • 不用publicprotectedprivate修饰的字段和方法就是包作用域

import

小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,他有三种写法

  1. 直接写出完整类名
    1
    2
    3
    4
    5
    6
    7
    8
    // Person.java
    package ming;

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

2.是用import语句,导入小军的Arrays,然后写简单类名(最常用)

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

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

public class Person {
public void run() {
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类属于哪个包

3.import static的语法,它可以导入可以导入一个类的静态字段和静态方法

1
2
3
4
5
6
7
8
9
package main;
// 导入System类的所有静态字段和静态方法:
import static java.lang.System.*;
public class Main {
public static void main(String[] args) {
// 相当于调用System.out.println(…)
out.println("Hello, world!");
}
}

Java编译器最终编译出的.class文件只使用完整类名

在代码中,当编译器遇到一个class名称时:

  • 如果是完整类名,就直接根据完整类名查找这个class

  • 如果是简单类名,按下面的顺序依次查找:

    • 查找当前package是否存在这个class

    • 查找import的包是否包含这个class

    • 查找java.lang包是否包含这个class

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

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

  • 默认自动import当前package的其他class

  • 默认自动import java.lang.*

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

如果有两个class名称相同,只能import其中一个,另一个必须写完整类名

最佳实践

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

  • 推荐的做法是使用倒置的域名来确保唯一性
  • 子包就可以根据功能自行命名

要注意不要和java.lang包的类重名
要注意也不要和JDK常用类重名

编译与运行

假设我们创建了如下的目录结构:

1
2
3
4
5
6
7
8
9
10
work
├── bin
└── src
└── com
└── itranswarp
├── sample
│ └── Main.java
└── world
└── Person.java

其中,bin目录用于存放编译后的class文件,src目录按包结构存放Java源码

首先,确保当前目录是work目录,即存放srcbin的父目录:

1
2
$ ls
bin src

然后,编译src目录下的所有Java文件:
1
$ javac -d ./bin src/**/*.java

  • 命令行-d指定输出的class文件存放bin目录,后面的参数src/**/*.java表示src目录下的所有.java文件,包括任意深度的子目录。
  • 注意:Windows不支持**这种搜索全部子目录的做法,所以在Windows下编译必须依次列出所有.java文件:
    1
    C:\work> javac -d bin src\com\itranswarp\sample\Main.java src\com\itranswarp\world\Persion.java

如果编译无误,则javac命令没有任何输出。可以在bin目录下看到如下class文件:

1
2
3
4
5
6
7
bin
└── com
└── itranswarp
├── sample
│ └── Main.class
└── world
└── Person.class

现在,我们就可以直接运行class文件了。根据当前目录的位置确定classpath,例如,当前目录仍为work,则classpath为bin或者./bin
1
2
$ java -cp bin com.itranswarp.sample.Main 
Hello, world!

作用域

public

定义为publicclassinterface可以被其他任何类访问

定义为publicfieldmethod可以被其他类访问,前提是首先有访问class权限

private

定义为privatefieldmethod无法被其他类访问、

  • private访问权限被限定在class内部,而且与方法声明顺序无关
  • 由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限
    • 定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类

protected

protected作用于继承关系

定义为protected的字段和方法可以被子类访问,以及子类的子类

package

包作用域是指一个类允许访问同一个package的没有publicprivate修饰的class,以及没有publicprotectedprivate修饰的字段和方法

  • 只要在同一个包,就可以访问package权限的classfieldmethod
  • 注意,包名必须完全一致,包没有父子关系,com.apachecom.apache.abc是不同的包

内部类

Inner class

有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。

  • Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    public class Main {
    public static void main(String[] args) {
    Outer outer = new Outer("Nested"); // 实例化一个Outer
    Outer.Inner inner = outer.new Inner(); // 实例化一个Inner
    inner.hello();
    }
    }

    class Outer {
    private String name;

    Outer(String name) {
    this.name = name;
    }

    class Inner {
    void hello() {
    System.out.println("Hello, " + Outer.this.name);
    }
    }
    }

    Anonymous Class

    还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义
    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 Main {
    public static void main(String[] args) {
    Outer outer = new Outer("Nested");
    outer.asyncHello();
    }
    }

    class Outer {
    private String name;

    Outer(String name) {
    this.name = name;
    }

    void asyncHello() {
    Runnable r = new Runnable() {
    @Override
    public void run() {
    System.out.println("Hello, " + Outer.this.name);
    }
    };
    new Thread(r).start();
    }
    }
  • 观察asyncHello()方法,我们在方法内部实例化了一个RunnableRunnable本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable接口的匿名类,并且通过new实例化该匿名类,然后转型为Runnable。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
    1
    2
    3
    Runnable r = new Runnable() {
    // 实现必要的抽象方法...
    };
  • 匿名类和Inner Class一样,可以访问Outer Class的private字段和方法
  • 如果有多个匿名类,Java编译器会将每个匿名类依次命名为Outer$1Outer$2Outer$3

除了接口外,匿名类也完全可以继承自普通类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.HashMap;

public class Main {
public static void main(String[] args) {
HashMap<String, String> map1 = new HashMap<>();
HashMap<String, String> map2 = new HashMap<>() {}; // 匿名类!
HashMap<String, String> map3 = new HashMap<>() {
{
put("A", "1");
put("B", "2");
}
};
System.out.println(map3.get("A"));
}
}

Static Nested Class

一种内部类和Inner Class类似,但是使用static修饰,称为静态内部类(Static Nested Class)

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) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}

class Outer {
private static String NAME = "OUTER";

private String name;

Outer(String name) {
this.name = name;
}

static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}

static修饰的内部类和Inner Class有很大的不同,它不再依附于Outer的实例,而是一个完全独立的类,因此无法引用Outer.this,但它可以访问Outerprivate静态字段和静态方法。

classpath和jar

classpath

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

  • 编译后.class文件才是真正可以被JVM执行的字节码

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

  • 在Windows系统上,用;分隔,带空格的目录用""括起来
    1
    C:\work\project1\bin;C:\shared;"D:\My 	Documents\project1\bin"
  • 在Linux系统上,用:分隔
    1
    /usr/shared:/usr/local/bin:/home/liaoxuefeng/bin

现在我们假设classpath.;C:\work\project1\bin;C:\shared,当JVM在加载abc.xyz.Hello这个类时,会依次查找

  • <当前目录>\abc\xyz\Hello.class

  • C:\work\project1\bin\abc\xyz\Hello.class

  • C:\shared\abc\xyz\Hello.class
    注意到.代表当前目录

如果JVM在某个路径下找到了对应的class文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错。

classpath的设定方法有两种:

  • 在系统环境变量中设置classpath环境变量

    • 污染整个系统环境
  • 启动JVM时设置classpath变量

    • java命令传入-classpath-cp参数
      1
      java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello
      1
      java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello
    • 没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath.,即当前目录
      1
      java abc.xyz.Hello

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

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

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

如果指定的.class文件不存在,或者目录结构和包名对不上,均会报错

jar包

jar包可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件

jar包里的第一层目录,不能是bin

jar包还可以包含一个特殊的/META-INF/MANIFEST.MF文件,MANIFEST.MF是纯文本,可以指定Main-Class和其它信息

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

1
java -jar hello.jar

class版本

我们通常说的Java 8,Java 11,Java 17,是指JDK的版本,也就是JVM的版本,更确切地说,就是java.exe这个程序的版本

  • 而每个版本的JVM,它能执行的class文件版本也不同
  • 只要看到UnsupportedClassVersionError就表示当前要加载的class文件版本超过了JVM的能力,必须使用更高版本的JVM才能运行

指定编译输出有两种方式,一种是在javac命令行中用参数--release设置:

1
2
$ javac --release 11 Main.java

  • 参数--release 11表示源码兼容Java 11,编译的class输出版本为Java 11兼容,即class版本55。

第二种方式是用参数--source指定源码版本,用参数--target指定输出class版本

1
$ javac --source 9 --target 11 Main.java

  • 上述命令如果使用Java 17的JDK编译,它会把源码视为Java 9兼容版本,并输出class为Java 11兼容版本

注意--release参数和--source --target参数只能二选一,不能同时设置
指定版本如果低于当前的JDK版本,会有一些潜在的问题

==如果使用—release 11则会在编译时检查该方法是否在Java 11中存在==

如果运行时的JVM版本是Java 11,则编译时也最好使用Java 11,而不是用高版本的JDK编译输出低版本的class

如果使用javac编译时不指定任何版本参数,那么相当于使用--release 当前版本编译,即源码版本和输出版本均为当前版本

在开发阶段,多个版本的JDK可以同时安装,当前使用的JDK版本可由JAVA_HOME环境变量切换

在编译的时候,如果用--source--release指定源码版本,则使用指定的源码版本检查语法

模块

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

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

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

==注意:JVM自带的标准库rt.jar不要写到classpath中,写了反而会干扰JVM的正常运行==

  • 如果漏写了某个运行时需要用到的jar,那么在运行期极有可能抛出ClassNotFoundException
  • jar只是用于存放class的容器,它并不关心class之间的依赖

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

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

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

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

  • 模块java.base对应的文件就是java.base.jmod
  • 模块之间的依赖关系已经被写入到模块内的module-info.class文件了

所有的模块直接或间接地依赖java.base模块,只有java.base模块不依赖任何模块,它可以被看作是“根模块”,好比所有的类都是从Object直接或间接继承而来

模块支持多版本,即在同一个模块中可以为不同的JVM提供不同的版本

编写模块

oop-module工程为例,它的目录结构如下:

1
2
3
4
5
6
7
8
9
10
oop-module
├── bin
├── build.sh
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java

其中,bin目录存放编译后的class文件,src目录存放源码,按包名的目录结构存放,仅仅在src目录下多了一个module-info.java这个文件,这就是模块的描述文件

  • 在这个模块中,它长这样:

    1
    2
    3
    4
    module hello.world {
    requires java.base; // 可不写,任何模块都会自动引入java.base
    requires java.xml;
    }

    其中,module是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;表示这个模块需要引用的其他模块名。除了java.base可以被自动引入外,这里我们引入了一个java.xml的模块

  • 当我们使用模块声明了依赖关系后,才能使用引入的模块

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    package com.itranswarp.sample;

    // 必须引入java.xml模块后才能使用其中的类:
    import javax.xml.XMLConstants;

    public class Main {
    public static void main(String[] args) {
    Greeting g = new Greeting();
    System.out.println(g.hello(XMLConstants.XML_NS_PREFIX));
    }
    }

    如果把requires java.xml;module-info.java中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系

创建模块

1.我们把工作目录切换到oop-module,在当前目录下编译所有的.java文件,并存放到bin目录下,命令如下:

1
$ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java

如果编译成功,现在项目结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
oop-module
├── bin
│ ├── com
│ │ └── itranswarp
│ │ └── sample
│ │ ├── Greeting.class
│ │ └── Main.class
│ └── module-info.class
└── src
├── com
│ └── itranswarp
│ └── sample
│ ├── Greeting.java
│ └── Main.java
└── module-info.java

2.我们需要把bin目录下的所有class文件先打包成jar,在打包的时候,注意传入--main-class参数,让这个jar包能自己定位main方法所在的类:

1
$ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin .

3.继续使用JDK自带的jmod命令把一个jar包转换成模块:

1
$ jmod create --class-path hello.jar hello.jmod

于是,在当前目录下我们又得到了hello.jmod这个模块文件,这就是最后打包出来的传说中的模块

运行模块

要运行一个jar,我们使用java -jar xxx.jar命令。要运行一个模块,我们只需要指定模块名。试试:

1
$ java --module-path hello.jmod --module hello.world

结果是一个错误:
1
2
Error occurred during initialization of boot layer
java.lang.module.FindException: JMOD format not supported at execution time: hello.jmod

原因是.jmod不能被放入--module-path。换成.jar就没问题了:
1
2
$ java --module-path hello.jar --module hello.world
Hello, xml!

那我们辛辛苦苦创建的hello.jmod有什么用?答案是我们可以用它来打包JRE

打包JRE

JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉

  • 并不是说把系统安装的JRE给删掉部分模块,而是复制一份JRE,但只带上用到的模块
    1
    $ jlink --module-path hello.jmod --add-modules java.base,java.xml,hello.world --output jre/
  • 我们在--module-path参数指定了我们自己的模块hello.jmod,然后,在--add-modules参数中指定了我们用到的3个模块java.basejava.xmlhello.world,用,分隔。最后,在--output参数指定输出目录

在当前目录下,我们可以找到jre目录,这是一个完整的并且带有我们自己hello.jmod模块的JRE

要分发我们自己的Java应用程序,只需要把这个jre目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署

访问权限

class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包

举个例子:我们编写的模块hello.world用到了模块java.xml的一个类javax.xml.XMLConstants,我们之所以能直接使用这个类,是因为模块java.xmlmodule-info.java中声明了若干导出:

1
2
3
4
5
6
module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}

只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world模块中的com.itranswarp.sample.Greeting类,我们必须将其导出:
1
2
3
4
5
6
module hello.world {
exports com.itranswarp.sample;

requires java.base;
requires java.xml;
}

因此,模块进一步隔离了代码的访问权限