JAVA-面向对象编程-基础
Java是一种面向对象的编程语言。面向对象编程,英文是Object-Oriented Programming,简称OOP。
那什么是面向对象编程?
和面向对象编程不同的,是面向过程编程。面向过程编程,是把模型分解成一步一步的过程。比如,老板告诉你,要编写一个TODO任务,必须按照以下步骤一步一步来:
- 读取文件;
- 编写TODO;
- 保存文件。
而面向对象编程,顾名思义,你得首先有个对象:
有了对象后,就可以和对象进行互动:
1 | GirlFriend gf = new GirlFriend(); |
因此,面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
在本章中,我们将讨论:
面向对象的基本概念,包括:
- 类
- 实例
- 方法
面向对象的实现方式,包括:
- 继承
- 多态
Java语言本身提供的机制,包括:
- package
- classpath
- jar
以及Java标准库提供的核心类,包括:
- 字符串
- 包装类型
- JavaBean
- 枚举
- 常用工具类
通过本章的学习,完全可以理解并掌握面向对象的基本思想,但不保证能找到对象。
面向对象编程,是一种通过对象的方式,把现实世界映射到计算机模型的一种编程方法。
现实世界中,我们定义了“人”这种抽象概念,而具体的人则是“小明”、“小红”、“小军”等一个个具体的人。所以,“人”可以定义为一个类(class),而具体的人则是实例(instance):
| 现实世界 | 计算机模型 | Java代码 |
|---|---|---|
| 人 | 类 / class | class Person { } |
| 小明 | 实例 / ming | Person ming = new Person() |
| 小红 | 实例 / hong | Person hong = new Person() |
| 小军 | 实例 / jun | Person jun = new Person() |
同样的,“书”也是一种抽象的概念,所以它是类,而《Java核心技术》、《Java编程思想》、《Java学习笔记》则是实例:
| 现实世界 | 计算机模型 | Java代码 |
|---|---|---|
| 书 | 类 / class | class Book { } |
| Java核心技术 | 实例 / book1 | Book book1 = new Book() |
| Java编程思想 | 实例 / book2 | Book book2 = new Book() |
| Java学习笔记 | 实例 / book3 | Book book3 = new Book() |
class和instance
所以,只要理解了class和instance的概念,基本上就明白了什么是面向对象编程。
class是一种对象模版,它定义了如何创建实例,因此,class本身就是一种数据类型:

而instance是对象实例,instance是根据class创建的实例,可以创建多个instance,每个instance类型相同,但各自属性可能不相同:

定义class
在Java中,创建一个类,例如,给这个类命名为Person,就是定义一个class:
1 | class Person { |
一个class可以包含多个字段(field),字段用来描述一个类的特征。上面的Person类,我们定义了两个字段,一个是String类型的字段,命名为name,一个是int类型的字段,命名为age。因此,通过class,把一组数据汇集到一个对象上,实现了数据封装。
public是用来修饰字段的,它表示这个字段可以被外部访问。
我们再看另一个Book类的定义:
1 | class Book { |
请指出Book类的各个字段。
创建实例
定义了class,只是定义了对象模版,而要根据对象模版创建出真正的对象实例,必须用new操作符。
new操作符可以创建一个实例,然后,我们需要定义一个引用类型的变量来指向这个实例:
1 | Person ming = new Person(); |
上述代码创建了一个Person类型的实例,并通过变量ming指向它。
注意区分Person ming是定义Person类型的变量ming,而new Person()是创建Person实例。
有了指向这个实例的变量,我们就可以通过这个变量来操作实例。访问实例变量可以用变量.字段,例如:
1 | ming.name = "Xiao Ming"; // 对字段name赋值 |
上述两个变量分别指向两个不同的实例,它们在内存中的结构如下:
1 | ┌──────────────────┐ |
两个instance拥有class定义的name和age字段,且各自都有一份独立的数据,互不干扰。
注意
一个Java源文件可以包含多个类的定义,但只能定义一个public类,且public类名必须与文件名一致。如果要定义多个public类,必须拆到多个Java源文件中。
练习
请定义一个City类,该class具有如下字段:
- name: 名称,String类型
- latitude: 纬度,double类型
- longitude: 经度,double类型
实例化几个City并赋值,然后打印。
1 | // City |
小结
在OOP中,class和instance是“模版”和“实例”的关系;
定义class就是定义了一种数据类型,对应的instance是这种数据类型的实例;
class定义的field,在每个instance都会拥有各自的field,且互不干扰;
通过new操作符创建新的instance,然后用变量指向它,即可通过变量来引用这个instance;
访问实例字段的方法是变量名.字段名;
指向instance的变量都是引用变量。
一个class可以包含多个field,例如,我们给Person类就定义了两个field:
1 | class Person { |
但是,直接把field用public暴露给外部可能会破坏封装性。比如,代码可以这样写:
1 | Person ming = new Person(); |
显然,直接操作field,容易造成逻辑混乱。为了避免外部代码直接去访问field,我们可以用private修饰field,拒绝外部访问:
1 | class Person { |
试试private修饰的field有什么效果:
1 | // private field |
是不是编译报错?把访问field的赋值语句去了就可以正常编译了。
把field从public改成private,外部代码不能访问这些field,那我们定义这些field有什么用?怎么才能给它赋值?怎么才能读取它的值?
所以我们需要使用方法(method)来让外部代码可以间接修改field:
1 | // private field |
虽然外部代码不能直接修改private字段,但是,外部代码可以调用方法setName()和setAge()来间接修改private字段。在方法内部,我们就有机会检查参数对不对。比如,setAge()就会检查传入的参数,参数超出了范围,直接报错。这样,外部代码就没有任何机会把age设置成不合理的值。
对setName()方法同样可以做检查,例如,不允许传入null和空字符串:
1 | public void setName(String name) { |
同样,外部代码不能直接读取private字段,但可以通过getName()和getAge()间接获取private字段的值。
所以,一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时,内部自己保证逻辑一致性。
调用方法的语法是实例变量.方法名(参数);。一个方法调用就是一个语句,所以不要忘了在末尾加;。例如:ming.setName("Xiao Ming");。
定义方法
从上面的代码可以看出,定义方法的语法是:
1 | 修饰符 方法返回类型 方法名(方法参数列表) { |
方法返回值通过return语句实现,如果没有返回值,返回类型设置为void,可以省略return。
private方法
有public方法,自然就有private方法。和private字段一样,private方法不允许外部调用,那我们定义private方法有什么用?
定义private方法的理由是内部方法是可以调用private方法的。例如:
1 | // private method |
观察上述代码,calcAge()是一个private方法,外部代码无法调用,但是,内部方法getAge()可以调用它。
此外,我们还注意到,这个Person类只定义了birth字段,没有定义age字段,获取age时,通过方法getAge()返回的是一个实时计算的值,并非存储在某个字段的值。这说明方法可以封装一个类的对外接口,调用方不需要知道也不关心Person实例在内部到底有没有age字段。
this变量
在方法内部,可以使用一个隐含的变量this,它始终指向当前实例。因此,通过this.field就可以访问当前实例的字段。
如果没有命名冲突,可以省略this。例如:
1 | class Person { |
但是,如果有局部变量和字段重名,那么局部变量优先级更高,就必须加上this:
1 | class Person { |
方法参数
方法可以包含0个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时,必须严格按照参数的定义一一传递。例如:
1 | class Person { |
调用这个setNameAndAge()方法时,必须有两个参数,且第一个参数必须为String,第二个参数必须为int:
1 | Person ming = new Person(); |
可变参数
可变参数用类型...定义,可变参数相当于数组类型:
1 | class Group { |
上面的setNames()就定义了一个可变参数。调用时,可以这么写:
1 | Group g = new Group(); |
完全可以把可变参数改写为String[]类型:
1 | class Group { |
但是,调用方需要自己先构造String[],比较麻烦。例如:
1 | Group g = new Group(); |
另一个问题是,调用方可以传入null:
1 | Group g = new Group(); |
而可变参数可以保证无法传入null,因为传入0个参数时,接收到的实际值是一个空数组而不是null。
参数绑定
调用方把参数传递给实例方法时,调用时传递的值会按参数位置一一绑定。
那什么是参数绑定?
我们先观察一个基本类型参数的传递:
1 | // 基本类型参数绑定 |
运行代码,从结果可知,修改外部的局部变量n,不影响实例p的age字段,原因是setAge()方法获得的参数,复制了n的值,因此,p.age和局部变量n互不影响。
结论:基本类型参数的传递,是调用方值的复制。双方各自的后续修改,互不影响。
我们再看一个传递引用参数的例子:
1 | // 引用类型参数绑定 |
注意到setName()的参数现在是一个数组。一开始,把fullname数组传进去,然后,修改fullname数组的内容,结果发现,实例p的字段p.name也被修改了!
结论
引用类型参数的传递,调用方的变量,和接收方的参数变量,指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方(因为指向同一个对象嘛)。
有了上面的结论,我们再看一个例子:
1 | // 引用类型参数绑定 |
不要怀疑引用参数绑定的机制,试解释为什么上面的代码两次输出都是"Bob"。
练习
给Person类增加getAge/setAge方法:
1 | public class Main { |
小结
-
方法可以让外部代码安全地访问实例字段;
-
方法是一组执行语句,并且可以执行任意逻辑;
-
方法内部遇到return时返回,void表示不返回任何值(注意和返回null不同);
-
外部代码通过public方法操作实例,内部代码可以调用private方法;
-
理解方法的参数绑定。
方法重载
在一个类中,我们可以定义多个方法。如果有一系列方法,它们的功能都是类似的,只有参数有所不同,那么,可以把这一组方法名做成同名方法。例如,在Hello类中,定义多个hello()方法:
1 | class Hello { |
这种方法名相同,但各自的参数不同,称为方法重载(Overload)。
注意:方法重载的返回值类型通常都是相同的。
方法重载的目的是,功能类似的方法使用同一名字,更容易记住,因此,调用起来更简单。
举个例子,String类提供了多个重载方法indexOf(),可以查找子串:
int indexOf(int ch):根据字符的Unicode码查找;int indexOf(String str):根据字符串查找;int indexOf(int ch, int fromIndex):根据字符查找,但指定起始位置;int indexOf(String str, int fromIndex)根据字符串查找,但指定起始位置。
试一试:
1 | // String.indexOf() |
练习
给Person增加重载方法setName(String, String):
1 | public class Main { |
小结
方法重载是指多个方法的方法名相同,但各自的参数不同;
重载方法应该完成类似的功能,参考String的indexOf();
重载方法返回值类型应该相同。
在前面的章节中,我们已经定义了Person类:
1 | class Person { |
现在,假设需要定义一个Student类,字段如下:
1 | class Student { |
仔细观察,发现Student类包含了Person类已有的字段和方法,只是多出了一个score字段和相应的getScore()、setScore()方法。
能不能在Student中不要写重复的代码?
这个时候,继承就派上用场了。
继承是面向对象编程中非常强大的一种机制,它首先可以复用代码。当我们让Student从Person继承时,Student就获得了Person的所有功能,我们只需要为Student编写新增的功能。
Java使用extends关键字来实现继承:
1 | class Person { |
可见,通过继承,Student只需要编写额外的功能,不再需要重复代码。
注意
子类自动获得了父类的所有字段,严禁定义与父类重名的字段!
在OOP的术语中,我们把Person称为超类(super class),父类(parent class),基类(base class),把Student称为子类(subclass),扩展类(extended class)。
继承树
注意到我们在定义Person的时候,没有写extends。在Java中,没有明确写extends的类,编译器会自动加上extends Object。所以,任何类,除了Object,都会继承自某个类。下图是Person、Student的继承树:
1 | ┌───────────┐ |
Java只允许一个class继承自一个类,因此,一个类有且仅有一个父类。只有Object特殊,它没有父类。
类似的,如果我们定义一个继承自Person的Teacher,它们的继承树关系如下:
1 | ┌───────────┐ |
protected
继承有个特点,就是子类无法访问父类的private字段或者private方法。例如,Student类就无法访问Person类的name和age字段:
1 | class Person { |
这使得继承的作用被削弱了。为了让子类可以访问父类的字段,我们需要把private改为protected。用protected修饰的字段可以被子类访问:
1 | class Person { |
因此,protected关键字可以把字段和方法的访问权限控制在继承树内部,一个protected字段和方法可以被其子类,以及子类的子类所访问,后面我们还会详细讲解。
super
super关键字表示父类(超类)。子类引用父类的字段时,可以用super.fieldName。例如:
1 | class Student extends Person { |
实际上,这里使用super.name,或者this.name,或者name,效果都是一样的。编译器会自动定位到父类的name字段。
但是,在某些时候,就必须使用super。我们来看一个例子:
1 | // super |
运行上面的代码,会得到一个编译错误,大意是在Student的构造方法中,无法调用Person的构造方法。
这是因为在Java中,任何class的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句super();,所以,Student类的构造方法实际上是这样:
1 | class Student extends Person { |
但是,Person类并没有无参数的构造方法,因此,编译失败。
解决方法是调用Person类存在的某个构造方法。例如:
1 | class Student extends Person { |
这样就可以正常编译了!
因此我们得出结论:如果父类没有默认的构造方法,子类就必须显式调用super()并给出参数以便让编译器定位到父类的一个合适的构造方法。
这里还顺带引出了另一个问题:即子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。
阻止继承
正常情况下,只要某个class没有final修饰符,那么任何类都可以从该class继承。
从Java 15开始,允许使用sealed修饰class,并通过permits明确写出能够从该class继承的子类名称。
例如,定义一个Shape类:
1 | public sealed class Shape permits Rect, Circle, Triangle { |
上述Shape类就是一个sealed类,它只允许指定的3个类继承它。如果写:
1 | public final class Rect extends Shape {...} |
是没问题的,因为Rect出现在Shape的permits列表中。但是,如果定义一个Ellipse就会报错:
1 | public final class Ellipse extends Shape {...} |
原因是Ellipse并未出现在Shape的permits列表中。这种sealed类主要用于一些框架,防止继承被滥用。
sealed类在Java 15中目前是预览状态,要启用它,必须使用参数--enable-preview和--source 15。
向上转型
如果一个引用变量的类型是Student,那么它可以指向一个Student类型的实例:
1 | Student s = new Student(); |
如果一个引用类型的变量是Person,那么它可以指向一个Person类型的实例:
1 | Person p = new Person(); |
现在问题来了:如果Student是从Person继承下来的,那么,一个引用类型为Person的变量,能否指向Student类型的实例?
1 | Person p = new Student(); // ??? |
测试一下就可以发现,这种指向是允许的!
这是因为Student继承自Person,因此,它拥有Person的全部功能。Person类型的变量,如果指向Student类型的实例,对它进行操作,是没有问题的!
这种把一个子类类型安全地变为父类类型的赋值,被称为向上转型(upcasting)。
向上转型实际上是把一个子类型安全地变为更加抽象的父类型:
1 | Student s = new Student(); |
注意到继承树是Student > Person > Object,所以,可以把Student类型转型为Person,或者更高层次的Object。
向下转型
和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型(downcasting)。例如:
1 | Person p1 = new Student(); // upcasting, ok |
如果测试上面的代码,可以发现:
Person类型p1实际指向Student实例,Person类型变量p2实际指向Person实例。在向下转型的时候,把p1转型为Student会成功,因为p1确实指向Student实例,把p2转型为Student会失败,因为p2的实际类型是Person,不能把父类变为子类,因为子类功能比父类多,多的功能无法凭空变出来。
因此,向下转型很可能会失败。失败的时候,Java虚拟机会报ClassCastException。
为了避免向下转型出错,Java提供了instanceof操作符,可以先判断一个实例究竟是不是某种类型:
1 | Person p = new Person(); |
instanceof实际上判断一个变量所指向的实例是否是指定类型,或者这个类型的子类。如果一个引用变量为null,那么对任何instanceof的判断都为false。
利用instanceof,在向下转型前可以先判断:
1 | Person p = new Student(); |
从Java 14开始,判断instanceof后,可以直接转型为指定变量,避免再次强制转型。例如,对于以下代码:
1 | Object obj = "hello"; |
可以改写如下:
1 | // instanceof variable: |
这种使用instanceof的写法更加简洁。
区分继承和组合
在使用继承时,我们要注意逻辑一致性。
考察下面的Book类:
1 | class Book { |
这个Book类也有name字段,那么,我们能不能让Student继承自Book呢?
1 | class Student extends Book { |
显然,从逻辑上讲,这是不合理的,Student不应该从Book继承,而应该从Person继承。
究其原因,是因为Student是Person的一种,它们是is关系,而Student并不是Book。实际上Student和Book的关系是has关系。
具有has关系不应该使用继承,而是使用组合,即Student可以持有一个Book实例:
1 | class Student extends Person { |
因此,继承是is关系,组合是has关系。
练习
定义PrimaryStudent,从Student继承,并新增一个grade字段:
1 | public class Main { |
小结
继承是面向对象编程的一种强大的代码复用方式;
Java只允许单继承,所有类最终的根类是Object;
protected允许子类访问父类的字段和方法;
子类的构造方法可以通过super()调用父类的构造方法;
可以安全地向上转型为更抽象的类型;
可以强制向下转型,最好借助instanceof判断;
子类和父类的关系是is,has关系不能用继承。
在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。
例如,在Person类中,我们定义了run()方法:
1 | class Person { |
在子类Student中,覆写这个run()方法:
1 | class Student extends Person { |
Override和Overload不同的是,如果方法签名不同,就是Overload,Overload方法是一个新方法;如果方法签名相同,并且返回值也相同,就是Override。
注意
方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在Java程序中,出现这种情况,编译器会报错。
1 | class Person { |
加上@Override可以让编译器帮助检查是否进行了正确的覆写。希望进行覆写,但是不小心写错了方法签名,编译器会报错。
1 | // override |
但是@Override不是必需的。
在上一节中,我们已经知道,引用变量的声明类型可能与其实际类型不符,例如:
1 | Person p = new Student(); |
现在,我们考虑一种情况,如果子类覆写了父类的方法:
1 | // override |
那么,一个实际类型为Student,引用类型为Person的变量,调用其run()方法,调用的是Person还是Student的run()方法?
运行一下上面的代码就可以知道,实际上调用的方法是Student的run()方法。因此可得出结论:
Java的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。
这个非常重要的特性在面向对象编程中称之为多态。它的英文拼写非常复杂:Polymorphic。
多态
多态是指,针对某个类型的方法调用,其真正执行的方法取决于运行时期实际类型的方法。例如:
1 | Person p = new Student(); |
有同学会说,从上面的代码一看就明白,肯定调用的是Student的run()方法啊。
但是,假设我们编写这样一个方法:
1 | public void runTwice(Person p) { |
它传入的参数类型是Person,我们是无法知道传入的参数实际类型究竟是Person,还是Student,还是Person的其他子类例如Teacher,因此,也无法确定调用的是不是Person类定义的run()方法。
所以,多态的特性就是,运行期才能动态决定调用的子类方法。对某个类型调用某个方法,执行的实际方法可能是某个子类的覆写方法。这种不确定性的方法调用,究竟有什么作用?
我们还是来举例子。
假设我们定义一种收入,需要给它报税,那么先定义一个Income类:
1 | class Income { |
对于工资收入,可以减去一个基数,那么我们可以从Income派生出SalaryIncome,并覆写getTax():
1 | class Salary extends Income { |
如果你享受国务院特殊津贴,那么按照规定,可以全部免税:
1 | class StateCouncilSpecialAllowance extends Income { |
现在,我们要编写一个报税的财务软件,对于一个人的所有收入进行报税,可以这么写:
1 | public double totalTax(Income... incomes) { |
来试一下:
1 | // Polymorphic |
观察totalTax()方法:利用多态,totalTax()方法只需要和Income打交道,它完全不需要知道Salary和StateCouncilSpecialAllowance的存在,就可以正确计算出总的税。如果我们要新增一种稿费收入,只需要从Income派生,然后正确覆写getTax()方法就可以。把新的类型传入totalTax(),不需要修改任何代码。
可见,多态具有一个非常强大的功能,就是允许添加更多类型的子类实现功能扩展,却不需要修改基于父类的代码。
覆写Object方法
因为所有的class最终都继承自Object,而Object定义了几个重要的方法:
toString():把instance输出为String;equals():判断两个instance是否逻辑相等;hashCode():计算一个instance的哈希值。
在必要的情况下,我们可以覆写Object的这几个方法。例如:
1 | class Person { |
调用super
在子类的覆写方法中,如果要调用父类的被覆写的方法,可以通过super来调用。例如:
1 | class Person { |
final
继承可以允许子类覆写父类的方法。如果一个父类不允许子类对它的某个方法进行覆写,可以把该方法标记为final。用final修饰的方法不能被Override:
1 | class Person { |
如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为final。用final修饰的类不能被继承:
1 | final class Person { |
对于一个类的实例字段,同样可以用final修饰。用final修饰的字段在初始化后不能被修改。例如:
1 | class Person { |
对final字段重新赋值会报错:
1 | Person p = new Person(); |
可以在构造方法中初始化final字段:
1 | class Person { |
这种方法更为常用,因为可以保证实例一旦创建,其final字段就不可修改。
练习
给一个有工资收入和稿费收入的小伙伴算税。
小结
子类可以覆写父类的方法(Override),覆写在子类中改变了父类方法的行为;
Java的方法调用总是作用于运行期对象的实际类型,这种行为称为多态;
final修饰符有多种作用:
final修饰的方法可以阻止被覆写;final修饰的class可以阻止被继承;final修饰的field必须在创建对象时初始化,随后不可修改。
由于多态的存在,每个子类都可以覆写父类的方法,例如:
1 | class Person { |
从Person类派生的Student和Teacher都可以覆写run()方法。
如果父类Person的run()方法没有实际意义,能否去掉方法的执行语句?
1 | class Person { |
答案是不行,会导致编译错误,因为定义方法的时候,必须实现方法的语句。
能不能去掉父类的run()方法?
答案还是不行,因为去掉父类的run()方法,就失去了多态的特性。例如,runTwice()就无法编译:
1 | public void runTwice(Person p) { |
如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么,可以把父类的方法声明为抽象方法:
1 | class Person { |
把一个方法声明为abstract,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以,Person类也无法被实例化。编译器会告诉我们,无法编译Person类,因为它包含抽象方法。
必须把Person类本身也声明为abstract,才能正确编译它:
1 | abstract class Person { |
抽象类
如果一个class定义了方法,但没有具体执行代码,这个方法就是抽象方法,抽象方法用abstract修饰。
因为无法执行抽象方法,因此这个类也必须申明为抽象类(abstract class)。
使用abstract修饰的类就是抽象类。我们无法实例化一个抽象类:
1 | Person p = new Person(); // 编译错误 |
无法实例化的抽象类有什么用?
因为抽象类本身被设计成只能用于被继承,因此,抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此,抽象方法实际上相当于定义了“规范”。
例如,Person类定义了抽象方法run(),那么,在实现子类Student的时候,就必须覆写run()方法:
1 | // abstract class |
面向抽象编程
当我们定义了抽象类Person,以及具体的Student、Teacher子类的时候,我们可以通过抽象类Person类型去引用具体的子类的实例:
1 | Person s = new Student(); |
这种引用抽象类的好处在于,我们对其进行方法调用,并不关心Person类型变量的具体子类型:
1 | // 不关心Person变量的具体子类型: |
同样的代码,如果引用的是一个新的子类,我们仍然不关心具体类型:
1 | // 同样不关心新的子类是如何实现run()方法的: |
这种尽量引用高层类型,避免引用实际子类型的方式,称之为面向抽象编程。
面向抽象编程的本质就是:
- 上层代码只定义规范(例如:
abstract class Person); - 不需要子类就可以实现业务逻辑(正常编译);
- 具体的业务逻辑由不同的子类实现,调用者并不关心。
练习
用抽象类给一个有工资收入和稿费收入的小伙伴算税。
小结
通过abstract定义的方法是抽象方法,它只有定义,没有实现。抽象方法定义了子类必须实现的接口规范;
定义了抽象方法的class必须被定义为抽象类,从抽象类继承的子类必须实现抽象方法;
如果不实现抽象方法,则该子类仍是一个抽象类;
面向抽象编程使得调用者只关心抽象方法的定义,不关心子类的具体实现。
在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现,这样,多态就能发挥出威力。
如果一个抽象类没有字段,所有方法全部都是抽象方法:
1 | abstract class Person { |
就可以把该抽象类改写为接口:interface。
在Java中,使用interface可以声明一个接口:
1 | interface Person { |
所谓interface,就是比抽象类还要抽象的纯抽象接口,因为它连字段都不能有。因为接口定义的所有方法默认都是public abstract的,所以这两个修饰符不需要写出来(写不写效果都一样)。
当一个具体的class去实现一个interface时,需要使用implements关键字。举个例子:
1 | class Student implements Person { |
我们知道,在Java中,一个类只能继承自另一个类,不能从多个类继承。但是,一个类可以实现多个interface,例如:
1 | class Student implements Person, Hello { // 实现了两个interface |
术语
注意区分术语:
Java的接口特指interface的定义,表示一个接口类型和一组方法签名,而编程接口泛指接口规范,如方法签名,数据格式,网络协议等。
抽象类和接口的对比如下:
| abstract class | interface | |
|---|---|---|
| 继承 | 只能extends一个class | 可以implements多个interface |
| 字段 | 可以定义实例字段 | 不能定义实例字段 |
| 抽象方法 | 可以定义抽象方法 | 可以定义抽象方法 |
| 非抽象方法 | 可以定义非抽象方法 | 可以定义default方法 |
接口继承
一个interface可以继承自另一个interface。interface继承自interface使用extends,它相当于扩展了接口的方法。例如:
1 | interface Hello { |
此时,Person接口继承自Hello接口,因此,Person接口现在实际上有3个抽象方法签名,其中一个来自继承的Hello接口。
继承关系
合理设计interface和abstract class的继承关系,可以充分复用代码。一般来说,公共逻辑适合放在abstract class中,具体逻辑放到各个子类,而接口层次代表抽象程度。可以参考Java的集合类定义的一组接口、抽象类以及具体子类的继承关系:
1 | ┌───────────────┐ |
在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它,因为接口比抽象类更抽象:
1 | List list = new ArrayList(); // 用List接口引用具体子类的实例 |
default方法
在接口中,可以定义default方法。例如,把Person接口的run()方法改为default方法:
1 | // interface |
实现类可以不必覆写default方法。default方法的目的是,当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是default方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。
default方法和抽象类的普通方法是有所不同的。因为interface没有字段,default方法无法访问字段,而抽象类的普通方法可以访问实例字段。
练习
用接口给一个有工资收入和稿费收入的小伙伴算税。
小结
Java的接口(interface)定义了纯抽象规范,一个类可以实现多个接口;
接口也是数据类型,适用于向上转型和向下转型;
接口的所有方法都是抽象方法,接口不能定义实例字段;
接口可以定义default方法(JDK>=1.8)。
在一个class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。
还有一种字段,是用static修饰的字段,称为静态字段:static field。
实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。举个例子:
1 | class Person { |
我们来看看下面的代码:
1 | // static field |
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例:
1 | ┌──────────────────┐ |
虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。
因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象。
推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段。对于上面的代码,更好的写法是:
1 | Person.number = 99; |
静态方法
有静态字段,就有静态方法。用static修饰的方法称为静态方法。
调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:
1 | // static method |
因为静态方法属于class而不属于实例,因此,静态方法内部,无法访问this变量,也无法访问实例字段,它只能访问静态字段。
通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。
通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。
静态方法经常用于工具类。例如:
- Arrays.sort()
- Math.random()
静态方法也经常用于辅助方法。注意到Java程序的入口main()也是静态方法。
接口的静态字段
因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型:
1 | public interface Person { |
实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为:
1 | public interface Person { |
编译器会自动把该字段变为public static final类型。
练习
给Person类增加一个静态字段count和静态方法getCount(),统计实例创建的个数。
小结
静态字段属于所有实例“共享”的字段,实际上是属于class的字段;
调用静态方法不需要实例,无法访问this,但可以访问静态字段和其他静态方法;
静态方法常用于工具类和辅助方法。
在前面的代码中,我们把类和接口命名为Person、Student、Hello等简单名字。
在现实中,如果小明写了一个Person类,小红也写了一个Person类,现在,小白既想用小明的Person,也想用小红的Person,怎么办?
如果小军写了一个Arrays类,恰好JDK也自带了一个Arrays类,如何解决类名冲突?
在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 | package ming; // 申明包名ming |
小军的Arrays.java文件:
1 | package mr.jun; // 申明包名mr.jun |
在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。
包可以是多层结构,用.隔开。例如:java.util。
特别注意
包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。
没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。
我们还需要按照包结构把上面的Java文件组织起来。假设以package_sample作为根目录,src作为源码目录,那么所有文件结构就是:
1 | package_sample |
即所有Java文件对应的目录层次要和包的层次一致。
编译后的.class文件也需要按照包结构存放。如果使用IDE,把编译后的.class文件放到bin目录下,那么,编译的文件结构就是:
1 | package_sample |
包作用域
位于同一个包的类,可以访问包作用域的字段和方法。不用public、protected、private修饰的字段和方法就是包作用域。例如,Person类定义在hello包下面:
1 | package hello; |
Main类也定义在hello包下面:
1 | package hello; |
import
在一个class中,我们总会引用其他的class。例如,小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,他有三种写法:
第一种,直接写出完整类名,例如:
1 | // Person.java |
很显然,每次写完整类名比较痛苦。
因此,第二种写法是用import语句,导入小军的Arrays,然后写简单类名:
1 | // Person.java |
在写import的时候,可以使用*,表示把这个包下面的所有class都导入进来(但不包括子包的class):
1 | // Person.java |
我们一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays类属于哪个包。
还有一种import static的语法,它可以导入一个类的静态字段和静态方法:
1 | package main; |
import static很少使用。
Java编译器最终编译出的.class文件只使用完整类名,因此,在代码中,当编译器遇到一个class名称时:
- 如果是完整类名,就直接根据完整类名查找这个
class; - 如果是简单类名,按下面的顺序依次查找:
- 查找当前
package是否存在这个class; - 查找
import的包是否包含这个class; - 查找
java.lang包是否包含这个class。
- 查找当前
如果按照上面的规则还无法确定类名,则编译报错。
我们来看一个例子:
1 | // Main.java |
因此,编写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.lang包的类重名,即自己的类不要使用这些名字:
- String
- System
- Runtime
- …
要注意也不要和JDK常用类重名:
- java.util.List
- java.text.Format
- java.math.BigInteger
- …
编译和运行
假设我们创建了如下的目录结构:
1 | work |
其中,bin目录用于存放编译后的class文件,src目录按包结构存放Java源码,我们怎么一次性编译这些Java源码呢?
首先,确保当前目录是work目录,即存放src和bin的父目录:
1 | $ ls |
然后,编译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 |
使用Windows的PowerShell可以利用Get-ChildItem来列出指定目录下的所有.java文件:
1 | PS C:\work> (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName |
因此,编译命令可写为:
1 | PS C:\work> javac -d .\bin (Get-ChildItem -Path .\src -Recurse -Filter *.java).FullName |
如果编译无误,则javac命令没有任何输出。可以在bin目录下看到如下class文件:
1 | bin |
现在,我们就可以直接运行class文件了。根据当前目录的位置确定classpath,例如,当前目录仍为work,则classpath为bin或者./bin:
1 | $ java -cp bin com.itranswarp.sample.Main |
练习
请按如下包结构创建工程项目:
1 | oop-package |
小结
Java内建的package机制是为了避免class命名冲突;
JDK的核心类使用java.lang包,编译器会自动导入;
JDK的其它常用类定义在java.util.*,java.math.*,java.text.*,……;
包名推荐使用倒置的域名,例如org.apache。
在Java中,我们经常看到public、protected、private这些修饰符。在Java中,这些修饰符可以用来限定访问作用域。
public
定义为public的class、interface可以被其他任何类访问:
1 | package abc; |
上面的Hello是public,因此,可以被其他包的类访问:
1 | package xyz; |
定义为public的field、method可以被其他类访问,前提是首先有访问class的权限:
1 | package abc; |
上面的hi()方法是public,可以被其他类调用,前提是首先要能访问Hello类:
1 | package xyz; |
private
定义为private的field、method无法被其他类访问:
1 | package abc; |
实际上,确切地说,private访问权限被限定在class的内部,而且与方法声明顺序无关。推荐把private方法放到后面,因为public方法定义了类对外提供的功能,阅读代码的时候,应该先关注public方法:
1 | package abc; |
由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限:
1 | // private |
定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类。
protected
protected作用于继承关系。定义为protected的字段和方法可以被子类访问,以及子类的子类:
1 | package abc; |
上面的protected方法可以被继承的类访问:
1 | package xyz; |
package
最后,包作用域是指一个类允许访问同一个package的没有public、private修饰的class,以及没有public、protected、private修饰的字段和方法。
1 | package abc; |
只要在同一个包,就可以访问package权限的class、field和method:
1 | package abc; |
注意,包名必须完全一致,包没有父子关系,com.apache和com.apache.abc是不同的包。
局部变量
在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。
1 | package abc; |
我们观察上面的hi()方法代码:
- 方法参数name是局部变量,它的作用域是整个方法,即1 ~ 10;
- 变量s的作用域是定义处到方法结束,即2 ~ 10;
- 变量len的作用域是定义处到方法结束,即3 ~ 10;
- 变量p的作用域是定义处到if块结束,即5 ~ 9;
- 变量i的作用域是for循环,即6 ~ 8。
使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。
final
Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。
用final修饰class可以阻止被继承:
1 | package abc; |
用final修饰method可以阻止被子类覆写:
1 | package abc; |
用final修饰field可以阻止被重新赋值:
1 | package abc; |
用final修饰局部变量可以阻止被重新赋值:
1 | package abc; |
最佳实践
如果不确定是否需要public,就不声明为public,即尽可能少地暴露对外的字段和方法。
把方法定义为package权限有助于测试,因为测试类和被测试类只要位于同一个package,测试代码就可以访问被测试类的package权限方法。
一个.java文件只能包含一个public类,但可以包含多个非public类。如果有public类,文件名必须和public类的名字相同。
小结
Java内建的访问权限包括public、protected、private和package权限;
Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;
final修饰符不是访问权限,它可以修饰class、field和method;
一个.java文件只能包含一个public类,但可以包含多个非public类。
在Java程序中,通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系:
1 | java.lang |
还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。
Inner Class
如果一个类定义在另一个类的内部,这个类就是Inner Class:
1 | class Outer { |
上述定义的Outer是一个普通类,而Inner是一个Inner Class,它与普通类有个最大的不同,就是Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例。示例代码如下:
1 | // inner class |
观察上述代码,要实例化一个Inner,我们必须首先创建一个Outer的实例,然后,调用Outer实例的new来创建Inner实例:
1 | Outer.Inner inner = outer.new Inner(); |
这是因为Inner Class除了有一个this指向它自己,还隐含地持有一个Outer Class实例,可以用Outer.this访问这个实例。所以,实例化一个Inner Class不能脱离Outer实例。
Inner Class和普通Class相比,除了能引用Outer实例外,还有一个额外的“特权”,就是可以修改Outer Class的private字段,因为Inner Class的作用域在Outer Class内部,所以能访问Outer Class的private字段和方法。
观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而Inner类被编译为Outer$Inner.class。
Anonymous Class
还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义。示例代码如下:
1 | // Anonymous Class |
观察asyncHello()方法,我们在方法内部实例化了一个Runnable。Runnable本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable接口的匿名类,并且通过new实例化该匿名类,然后转型为Runnable。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
1 | Runnable r = new Runnable() { |
匿名类和Inner Class一样,可以访问Outer Class的private字段和方法。之所以我们要定义匿名类,是因为在这里我们通常不关心类名,比直接定义Inner Class可以少写很多代码。
观察Java编译器编译后的.class文件可以发现,Outer类被编译为Outer.class,而匿名类被编译为Outer$1.class。如果有多个匿名类,Java编译器会将每个匿名类依次命名为Outer$1、Outer$2、Outer$3……
除了接口外,匿名类也完全可以继承自普通类。观察以下代码:
1 | // Anonymous Class |
map1是一个普通的HashMap实例,但map2是一个匿名类实例,只是该匿名类继承自HashMap。map3也是一个继承自HashMap的匿名类实例,并且添加了static代码块来初始化数据。观察编译输出可发现Main$1.class和Main$2.class两个匿名类文件。
Static Nested Class
最后一种内部类和Inner Class类似,但是使用static修饰,称为静态内部类(Static Nested Class):
1 | // Static Nested Class |
用static修饰的内部类和Inner Class有很大的不同,它不再依附于Outer的实例,而是一个完全独立的类,因此无法引用Outer.this,但它可以访问Outer的private静态字段和静态方法。如果把StaticNested移到Outer之外,就失去了访问private的权限。
小结
Java的内部类可分为Inner Class、Anonymous Class和Static Nested Class三种;
Inner Class和Anonymous Class本质上是相同的,都必须依附于Outer Class的实例,即隐含地持有Outer.this实例,并拥有Outer Class的private访问权限;
Static Nested Class是独立类,但拥有Outer Class的private访问权限。
在Java中,我们经常听到classpath这个东西。网上有很多关于“如何设置classpath”的文章,但大部分设置都不靠谱。
到底什么是classpath?
classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class。
因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件。
所以,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变量,推荐。
我们强烈不推荐在系统环境变量中设置classpath,那样会污染整个系统环境。在启动JVM时设置classpath才是推荐的做法。实际上就是给java命令传入-classpath参数:
1 | java -classpath .;C:\work\project1\bin;C:\shared abc.xyz.Hello |
或者使用-cp的简写:
1 | java -cp .;C:\work\project1\bin;C:\shared abc.xyz.Hello |
没有设置系统环境变量,也没有传入-cp参数,那么JVM默认的classpath为.,即当前目录:
1 | java abc.xyz.Hello |
上述命令告诉JVM只在当前目录搜索Hello.class。
在IDE中运行Java程序,IDE自动传入的-cp参数是当前工程的bin目录和引入的jar包。
通常,我们在自己编写的class中,会引用Java核心库的class,例如,String、ArrayList等。这些class应该上哪去找?
有很多“如何设置classpath”的文章会告诉你把JVM自带的rt.jar放入classpath,但事实上,根本不需要告诉JVM如何去Java核心库查找class,JVM怎么可能笨到连自己的核心库在哪都不知道!
注意
不要把任何Java核心库添加到classpath中!JVM根本不依赖classpath加载核心库!
更好的做法是,不要设置classpath!默认的当前目录.对于绝大多数情况都够用了。
假设我们有一个编译后的Hello.class,它的包名是com.example,当前目录是C:\work,那么,目录结构必须如下:
1 | C:\work |
运行这个Hello.class必须在当前目录下使用如下命令:
1 | C:\work> java -cp . com.example.Hello |
JVM根据classpath设置的.在当前目录下查找com.example.Hello,即实际搜索文件必须位于com/example/Hello.class。如果指定的.class文件不存在,或者目录结构和包名对不上,均会报错。
jar包
如果有很多.class文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。
jar包就是用来干这个事的,它可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。
jar包实际上就是一个zip格式的压缩文件,而jar包相当于目录。如果我们要执行一个jar包的class,就可以把jar包放到classpath中:
1 | java -cp ./hello.jar abc.xyz.Hello |
这样JVM会自动在hello.jar文件里去搜索某个类。
那么问题来了:如何创建jar包?
因为jar包就是zip包,所以,直接在资源管理器中,找到正确的目录,点击右键,在弹出的快捷菜单中选择“发送到”,“压缩(zipped)文件夹”,就制作了一个zip文件。然后,把后缀从.zip改为.jar,一个jar包就创建成功。
假设编译输出的目录结构是这样:
1 | package_sample |
这里需要特别注意的是,jar包里的第一层目录,不能是bin,而应该是hong、ming、mr。如果在Windows的资源管理器中看,应该长这样:

如果长这样:

上面的hello.zip包含有bin目录,说明打包打得有问题,JVM仍然无法从jar包中查找正确的class,原因是hong.Person必须按hong/Person.class存放,而不是bin/hong/Person.class。
jar包还可以包含一个特殊的/META-INF/MANIFEST.MF文件,MANIFEST.MF是纯文本,可以指定Main-Class和其它信息。JVM会自动读取这个MANIFEST.MF文件,如果存在Main-Class,我们就不必在命令行指定启动的类名,而是用更方便的命令:
1 | java -jar hello.jar |
在大型项目中,不可能手动编写MANIFEST.MF文件,再手动创建jar包。Java社区提供了大量的开源构建工具,例如Maven,可以非常方便地创建jar包。
小结
JVM通过环境变量classpath决定搜索class的路径和顺序;
强烈建议不要设置系统环境变量classpath,建议始终通过-cp命令传入;
jar包本质上是zip格式,相当于目录,可以包含很多.class文件,方便下载和使用;
MANIFEST.MF文件可以提供jar包的信息,如Main-Class,这样可以直接运行jar包。
在Java开发中,许多童鞋经常被各种版本的JDK搞得晕头转向,本节我们就来详细讲解Java程序编译后的class文件版本问题。
我们通常说的Java 8,Java 11,Java 17,是指JDK的版本,也就是JVM的版本,更确切地说,就是java.exe这个程序的版本:
1 | $ java -version |
而每个版本的JVM,它能执行的class文件版本也不同。例如,Java 11对应的class文件版本是55,而Java 17对应的class文件版本是61。
如果用Java 11编译一个Java程序,输出的class文件版本默认就是55,这个class既可以在Java 11上运行,也可以在Java 17上运行,因为Java 17支持的class文件版本是61,表示“最多支持到版本61”。
如果用Java 17编译一个Java程序,输出的class文件版本默认就是61,它可以在Java 17、Java 18上运行,但不可能在Java 11上运行,因为Java 11支持的class版本最多到55。如果使用低于Java 17的JVM运行,会得到一个UnsupportedClassVersionError,错误信息类似:
1 | java.lang.UnsupportedClassVersionError: Xxx has been compiled by a more recent version of the Java Runtime... |
只要看到UnsupportedClassVersionError就表示当前要加载的class文件版本超过了JVM的能力,必须使用更高版本的JVM才能运行。
打个比方,用Word 2013保存一个Word文件,这个文件也可以在Word 2016上打开。但反过来,用Word 2016保存一个Word文件,就无法使用Word 2013打开。
但是,且慢,用Word 2016也可以保存一个格式为Word 2013的文件,这样保存的Word文件就可以用低版本的Word 2013打开,但前提是保存时必须明确指定文件格式兼容Word 2013。
类似的,对应到JVM的class文件,我们也可以用Java 17编译一个Java程序,指定输出的class版本要兼容Java 11(即class版本55),这样编译生成的class文件就可以在Java >=11的环境中运行。
指定编译输出有两种方式,一种是在javac命令行中用参数--release设置:
1 | $ 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版本,会有一些潜在的问题。例如,我们用Java 17编译Hello.java,参数设置--source 9和--target 11:
1 | public class Hello { |
用低于Java 11的JVM运行Hello会得到一个LinkageError,因为无法加载Hello.class文件,而用Java 11运行Hello会得到一个NoSuchMethodError,因为String.indent()方法是从Java 12才添加进来的,Java 11的String版本根本没有indent()方法。
注意
如果使用–release 11则会在编译时检查该方法是否在Java 11中存在。
因此,如果运行时的JVM版本是Java 11,则编译时也最好使用Java 11,而不是用高版本的JDK编译输出低版本的class。
如果使用javac编译时不指定任何版本参数,那么相当于使用--release 当前版本编译,即源码版本和输出版本均为当前版本。
在开发阶段,多个版本的JDK可以同时安装,当前使用的JDK版本可由JAVA_HOME环境变量切换。
源码版本
在编写源代码的时候,我们通常会预设一个源码的版本。在编译的时候,如果用--source或--release指定源码版本,则使用指定的源码版本检查语法。
例如,使用了lambda表达式的源码版本至少要为8才能编译,使用了var关键字的源码版本至少要为10才能编译,使用switch表达式的源码版本至少要为12才能编译,且12和13版本需要启用--enable-preview参数。
小结
高版本的JDK可编译输出低版本兼容的class文件,但需注意,低版本的JDK可能不存在高版本JDK添加的类和方法,导致运行时报错。
运行时使用哪个JDK版本,编译时就尽量使用同一版本的JDK编译源码。
从Java 9开始,JDK又引入了模块(Module)。
什么是模块?这要从Java 9之前的版本说起。
我们知道,.class文件是JVM看到的最小可执行文件,而一个大型程序需要编写很多Class,并生成一堆.class文件,很不便于管理,所以,jar文件就是class文件的容器。
在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之间的依赖。
从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项目是完全一样的,以oop-module工程为例,它的目录结构如下:
1 | oop-module |
其中,bin目录存放编译后的class文件,src目录存放源码,按包名的目录结构存放,仅仅在src目录下多了一个module-info.java这个文件,这就是模块的描述文件。在这个模块中,它长这样:
1 | module hello.world { |
其中,module是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;表示这个模块需要引用的其他模块名。除了java.base可以被自动引入外,这里我们引入了一个java.xml的模块。
当我们使用模块声明了依赖关系后,才能使用引入的模块。例如,Main.java代码如下:
1 | package com.itranswarp.sample; |
如果把requires java.xml;从module-info.java中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系。
下面,我们用JDK提供的命令行工具来编译并创建模块。
首先,我们把工作目录切换到oop-module,在当前目录下编译所有的.java文件,并存放到bin目录下,命令如下:
1 | $ javac -d bin src/module-info.java src/com/itranswarp/sample/*.java |
如果编译成功,现在项目结构如下:
1 | oop-module |
注意到src目录下的module-info.java被编译到bin目录下的module-info.class。
下一步,我们需要把bin目录下的所有class文件先打包成jar,在打包的时候,注意传入--main-class参数,让这个jar包能自己定位main方法所在的类:
1 | $ jar --create --file hello.jar --main-class com.itranswarp.sample.Main -C bin . |
现在我们就在当前目录下得到了hello.jar这个jar包,它和普通jar包并无区别,可以直接使用命令java -jar hello.jar来运行它。但是我们的目标是创建模块,所以,继续使用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 | Error occurred during initialization of boot layer |
原因是.jmod不能被放入--module-path中。换成.jar就没问题了:
1 | $ java --module-path hello.jar --module hello.world |
那我们辛辛苦苦创建的hello.jmod有什么用?答案是我们可以用它来打包JRE。
打包JRE
前面讲了,为了支持模块化,Java 9首先带头把自己的一个巨大无比的rt.jar拆成了几十个.jmod模块,原因就是,运行Java程序的时候,实际上我们用到的JDK模块,并没有那么多。不需要的模块,完全可以删除。
过去发布一个Java应用程序,要运行它,必须下载一个完整的JRE,再运行jar包。而完整的JRE块头很大,有100多M。怎么给JRE瘦身呢?
现在,JRE自身的标准库已经分拆成了模块,只需要带上程序用到的模块,其他的模块就可以被裁剪掉。怎么裁剪JRE呢?并不是说把系统安装的JRE给删掉部分模块,而是“复制”一份JRE,但只带上用到的模块。为此,JDK提供了jlink命令来干这件事。命令如下:
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。试试直接运行这个JRE:
1 | $ jre/bin/java --module hello.world |
要分发我们自己的Java应用程序,只需要把这个jre目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。
访问权限
前面我们讲过,Java的class访问权限分为public、protected、private和默认的包访问权限。引入模块后,这些访问权限的规则就要稍微做些调整。
确切地说,class的这些访问权限只在一个模块内有效,模块和模块之间,例如,a模块要访问b模块的某个class,必要条件是b模块明确地导出了可以访问的包。
举个例子:我们编写的模块hello.world用到了模块java.xml的一个类javax.xml.XMLConstants,我们之所以能直接使用这个类,是因为模块java.xml的module-info.java中声明了若干导出:
1 | module java.xml { |
只有它声明的导出的包,外部代码才被允许访问。换句话说,如果外部代码想要访问我们的hello.world模块中的com.itranswarp.sample.Greeting类,我们必须将其导出:
1 | module hello.world { |
因此,模块进一步隔离了代码的访问权限。
练习
请下载并练习如何打包模块和JRE。
小结
Java 9引入的模块目的是为了管理依赖;
使用模块可以按需打包JRE;
使用模块对类的访问权限有了进一步限制。