面向对象基础
整理BY:Misayaas
内容来自廖雪峰的官方网站: https://www.liaoxuefeng.com/
方法
一个class
可以包含多个field
。但是,直接把field
用public
暴露给外部可能会破坏封装性。为了避免外部代码直接去访问field
,我们可以用private
修饰field
,拒绝外部访问
- 所以我们需要使用方法(
method
)来让外部代码可以间接修改
所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。
定义方法
1 | 修饰符 方法返回类型 方法名(方法参数列表) { |
private方法
定义private
方法的理由是内部方法是可以调用private
方法的this变量
在方法内部,可以使用一个隐含的变量this
,它始终指向当前实例。1
2
3
4
5
6
7class Person {
private String name;
public void setName(String name) {
this.name = name; // 前面的this不可少,少了就变成局部变量name了
}
}
参数绑定
- 基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响
- 引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。
构造方法
创建实例的时候,实际上是通过构造方法来初始化实例的。
- 构造方法的名称就是类名
- 和普通方法相比,构造方法没有返回值(也没有
void
),调用构造方法,必须用new
操作符。 - 任何
class
都有构造方法 - 如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数,也没有执行语句,类似这样:
1
2
3
4class 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
17class 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
5class 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
3public sealed class Shape permits Rect, Circle, Triangle {
...
}
- 上述
Shape
类就是一个sealed
类,它只允许指定的3个类继承它。
向上转型
如果Student
是从Person
继承下来的,那,一个引用类型为Person
的变量,能指向Student
类型的实例1
Person p = new Student()
- 这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)
向上转型实际上是把一个子类型安全地变为更加抽象的父类型:
1 | Student s = new Student(); |
向下转型
如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)1
2
3
4Person 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
10Person 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
4class 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
6class Person {
public final String name;
public Person(String name) {
this.name = name;
}
}
- 这种方法更为常用,因为可以保证实例一旦创建,其
final
字段就不可修改
抽象类
如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去重写它,那么,可以把父类的方法声明为抽象方法1
2
3class 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
4interface Person {
void run();
String getName();
}
所谓interface
,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract
的,所以这两个修饰符不需要写出来(写不写效果都一样)
一个类可以实现多个interface
(区别于继承)1
2
3class 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
8interface 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
25public 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
2Person.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
5public 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
属于哪个包
包作用域
位于同一个包的类,可以访问包作用域的字段和方法
- 不用
public
、protected
、private
修饰的字段和方法就是包作用域
import
小明的ming.Person
类,如果要引用小军的mr.jun.Arrays
类,他有三种写法
直接写出完整类名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
9package 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
10work
├── bin
└── src
└── com
└── itranswarp
├── sample
│ └── Main.java
└── world
└── Person.java
其中,bin
目录用于存放编译后的class
文件,src
目录按包结构存放Java源码
首先,确保当前目录是work
目录,即存放src
和bin
的父目录: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
7bin
└── 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
定义为public
的class
、interface
可以被其他任何类访问
定义为public
的field
、method
可以被其他类访问,前提是首先有访问class
的权限
private
定义为private
的field
、method
无法被其他类访问、
private
访问权限被限定在class
的内部,而且与方法声明顺序无关- 由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问
private
的权限- 定义在一个
class
内部的class
称为嵌套类(nested class
),Java支持好几种嵌套类
- 定义在一个
protected
protected
作用于继承关系。
定义为protected
的字段和方法可以被子类访问,以及子类的子类
package
包作用域是指一个类允许访问同一个package
的没有public
、private
修饰的class
,以及没有public
、protected
、private
修饰的字段和方法
- 只要在同一个包,就可以访问
package
权限的class
、field
和method
- 注意,包名必须完全一致,包没有父子关系,
com.apache
和com.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
21public 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
24public 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() {
public void run() {
System.out.println("Hello, " + Outer.this.name);
}
};
new Thread(r).start();
}
} - 观察
asyncHello()
方法,我们在方法内部实例化了一个Runnable
。Runnable
本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable
接口的匿名类,并且通过new
实例化该匿名类,然后转型为Runnable
。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:1
2
3Runnable r = new Runnable() {
// 实现必要的抽象方法...
}; - 匿名类和Inner Class一样,可以访问Outer Class的
private
字段和方法 - 如果有多个匿名类,Java编译器会将每个匿名类依次命名为
Outer$1
、Outer$2
、Outer$3
除了接口外,匿名类也完全可以继承自普通类1
2
3
4
5
6
7
8
9
10
11
12
13
14
15import 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
22public class Main {
public static void main(String[] args) {
Outer.StaticNested sn = new Outer.StaticNested();
sn.hello();
}
}
class Outer {
private static String NAME = "OUTER";
private String name;
Outer(String name) {
this.name = name;
}
static class StaticNested {
void hello() {
System.out.println("Hello, " + Outer.NAME);
}
}
}
用static
修饰的内部类和Inner Class有很大的不同,它不再依附于Outer
的实例,而是一个完全独立的类,因此无法引用Outer.this
,但它可以访问Outer
的private
静态字段和静态方法。
classpath和jar
classpath
classpath
是JVM用到的一个环境变量,它用来指示JVM如何搜索class
- 编译后的
.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
- Windows中应该是这样
https://www.liaoxuefeng.com/files/attachments/1261393208671488/l - 有问题的长这样
https://www.liaoxuefeng.com/files/attachments/1261391527906784/l
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
10oop-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
4module 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
11package 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 | oop-module |
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
2Error 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.base
、java.xml
和hello.world
,用,
分隔。最后,在--output
参数指定输出目录
在当前目录下,我们可以找到jre
目录,这是一个完整的并且带有我们自己hello.jmod
模块的JRE
要分发我们自己的Java应用程序,只需要把这个jre
目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署
访问权限
class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包
举个例子:我们编写的模块hello.world
用到了模块java.xml
的一个类javax.xml.XMLConstants
,我们之所以能直接使用这个类,是因为模块java.xml
的module-info.java
中声明了若干导出:1
2
3
4
5
6module java.xml {
exports java.xml;
exports javax.xml.catalog;
exports javax.xml.datatype;
...
}
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world
模块中的com.itranswarp.sample.Greeting
类,我们必须将其导出:1
2
3
4
5
6module hello.world {
exports com.itranswarp.sample;
requires java.base;
requires java.xml;
}
因此,模块进一步隔离了代码的访问权限