8、静态字段和静态方法
1)静态字段
在一个class中定义的字段,我们称之为实例字段。实例字段的特点是,每个实例都有独立的字段,各个实例的同名字段互不影响。
还有一种字段,是用static修饰的字段,称为静态字段:static field。
实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。举个例子:
class Person {
public String name;
public int age;
// 定义静态字段number:
public static int number;
}
我们来看看下面的代码:
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;
System.out.println(hong.number);
hong.number = 99;
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;
}
}
对于静态字段,无论修改哪个实例的静态字段,效果都是一样的:所有实例的静态字段都被修改了,原因是静态字段并不属于实例:
虽然实例可以访问静态字段,但是它们指向的其实都是Person class的静态字段。所以,所有实例共享一个静态字段。
因此,不推荐用实例变量.静态字段去访问静态字段,因为在Java程序中,实例对象并没有静态字段。在代码中,实例对象能访问静态字段只是因为编译器可以根据实例类型自动转换为类名.静态字段来访问静态对象。
推荐用类名来访问静态字段。可以把静态字段理解为描述class本身的字段(非实例字段)。对于上面的代码,更好的写法是:
Person.number = 99;
System.out.println(Person.number);
2)静态方法
有静态字段,就有静态方法。用static修饰的方法称为静态方法。
调用实例方法必须通过一个实例变量,而调用静态方法则不需要实例变量,通过类名就可以调用。静态方法类似其它编程语言的函数。例如:
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变量,也无法访问实例字段,它只能访问静态字段。
通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。
通常情况下,通过实例变量访问静态字段和静态方法,会得到一个编译警告。
静态方法经常用于工具类。例如:
-
Arrays.sort()
-
Math.random()
静态方法也经常用于辅助方法。注意到Java程序的入口main()也是静态方法。
3)接口的静态字段
因为interface是一个纯抽象类,所以它不能定义实例字段。但是,interface是可以有静态字段的,并且静态字段必须为final类型:
public interface Person {
public static final int MALE = 1;
public static final int FEMALE = 2;
}
实际上,因为interface的字段只能是public static final类型,所以我们可以把这些修饰符都去掉,上述代码可以简写为
public interface Person {
// 编译器会自动加上public statc final:
int MALE = 1;
int FEMALE = 2;
}
编译器会自动把该字段变为public static final类型。
4)小结
-
静态字段属于所有实例“共享”的字段,实际上是属于class的字段;
-
调用静态方法不需要实例,无法访问this,但可以访问静态字段和其他静态方法;
-
静态方法常用于工具类和辅助方法。
9、包
1)包
在前面的代码中,我们把类和接口命名为Person、Student、Hello等简单名字。
在现实中,如果小明写了一个Person类,小红也写了一个Person类,现在,小白既想用小明的Person,也想用小红的Person,怎么办?
如果小军写了一个Arrays类,恰好JDK也自带了一个Arrays类,如何解决类名冲突?
在Java中,我们使用package来解决名字冲突。
Java定义了一种名字空间,称之为包:package。一个类总是属于某个包,类名(比如Person)只是一个简写,真正的完整类名是包名.类名。
在定义class的时候,我们需要在第一行声明这个class属于哪个包。
在Java虚拟机执行的时候,JVM只看完整类名,因此,只要包名不同,类就不同。
包可以是多层结构,用.隔开。例如:java.util。
要特别注意!!!包没有父子关系。java.util和java.util.zip是不同的包,两者没有任何继承关系。
包没有父子关系
没有定义包名的class,它使用的是默认包,非常容易引起名字冲突,因此,不推荐不写包名的做法。
我们还需要按照包结构把上面的Java文件组织起来。假设以package_sample作为根目录,src作为源码目录,那么所有文件结构就是:
即所有Java文件对应的目录层次要和包的层次一致。
编译后的.class文件也需要按照包结构存放。如果使用IDE,把编译后的.class文件放到bin目录下,那么,编译的文件结构就是:
编译的命令相对比较复杂,我们需要在src目录下执行javac命令:
javac -d ../bin ming/Person.java hong/Person.java mr/jun/Arrays.java
在IDE中,会自动根据包结构编译所有Java源码,所以不必担心使用命令行编译的复杂命令。
2)包作用域
位于同一个包的类,可以访问包作用域的字段和方法。不用public、protected、private修饰的字段和方法就是包作用域。例如,Person类定义在hello包下面:
package hello;
public class Person {
// 包作用域:
void hello() {
System.out.println("Hello!");
}
}
Main类也定义在hello包下面:
package hello;
public class Main {
public static void main(String[] args) {
Person p = new Person();
p.hello(); // 可以调用,因为Main和Person在同一个包
}
}
3)import
在一个class中,我们总会引用其他的class。例如,小明的ming.Person类,如果要引用小军的mr.jun.Arrays类,他有三种写法:
第一种,直接写出完整类名,例如:
// Person.java
package ming;
public class Person {
public void run() {
mr.jun.Arrays arrays = new mr.jun.Arrays();
}
}
很显然,每次写完整类名比较痛苦。
因此,第二种写法是用import语句,导入小军的Arrays,然后写简单类名:
// Person.java
package ming;
// 导入完整类名:
import mr.jun.Arrays;
public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}
在写import的时候,可以使用*,表示把这个包下面的所有class都导入进来(但不包括子包的class):
// Person.java
package ming;
// 导入mr.jun包的所有class:
import mr.jun.*;
public class Person {
public void run() {
Arrays arrays = new Arrays();
}
}
我们一般不推荐这种写法,因为在导入了多个包后,很难看出Arrays类属于哪个包。
还有一种import static的语法,它可以导入可以导入一个类的静态字段和静态方法:
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。
如果按照上面的规则还无法确定类名,则编译报错。
我们来看一个例子:
// 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其中一个,另一个必须写完整类名。
4)最佳实践
为了避免名字冲突,我们需要确定唯一的包名。推荐的做法是使用倒置的域名来确保唯一性。例如:
org.apache
org.apache.commons.log
com.liaoxuefeng.sample
子包就可以根据功能自行命名。
要注意不要和java.lang包的类重名,即自己的类不要使用这些名字:
String
System
Runtime
...
要注意也不要和JDK常用类重名:
java.util.List
java.text.Format
java.math.BigInteger
...
5)小结
-
Java内建的package机制是为了避免class命名冲突;
-
JDK的核心类使用java.lang包,编译器会自动导入;
-
JDK的其它常用类定义在java.util. * ,java.math. * ,java.text.*,……;
-
包名推荐使用倒置的域名,例如org.apache。
10、作用域
在Java中,我们经常看到public、protected、private这些修饰符。在Java中,这些修饰符可以用来限定访问作用域。
1)public
定义为public的class、interface可以被其他任何类访问:
package abc;
public class Hello {
public void hi() {
}
}
上面的Hello是public,因此,可以被其他包的类访问:
package xyz;
class Main {
void foo() {
// Main可以访问Hello
Hello h = new Hello();
}
}
定义为public的field、method可以被其他类访问,前提是首先有访问class的权限:
package abc;
public class Hello {
public void hi() {
}
}
上面的hi()方法是public,可以被其他类调用,前提是首先要能访问Hello类:
package xyz;
class Main {
void foo() {
Hello h = new Hello();
h.hi();
}
}
2)private
定义为private的field、method无法被其他类访问:
package abc;
public class Hello {
// 不能被其他类调用:
private void hi() {
}
public void hello() {
this.hi();
}
}
实际上,确切地说,private访问权限被限定在class的内部,而且与方法声明顺序无关。推荐把private方法放到后面,因为public方法定义了类对外提供的功能,阅读代码的时候,应该先关注public方法:
package abc;
public class Hello {
public void hello() {
this.hi();
}
private void hi() {
}
}
由于Java支持嵌套类,如果一个类内部还定义了嵌套类,那么,嵌套类拥有访问private的权限:
public class Main {
public static void main(String[] args) {
Inner i = new Inner();
i.hi();
}
// private方法:
private static void hello() {
System.out.println("private hello!");
}
// 静态内部类:
static class Inner {
public void hi() {
Main.hello();
}
}
}
定义在一个class内部的class称为嵌套类(nested class),Java支持好几种嵌套类。
3)protected
protected作用于继承关系。定义为protected的字段和方法可以被子类访问,以及子类的子类:
package abc;
public class Hello {
// protected方法:
protected void hi() {
}
}
上面的protected方法可以被继承的类访问:
package xyz;
class Main extends Hello {
void foo() {
// 可以访问protected方法:
hi();
}
}
4)package
最后,包作用域是指一个类允许访问同一个package的没有public、private修饰的class,以及没有public、protected、private修饰的字段和方法。
package abc;
// package权限的类:
class Hello {
// package权限的方法:
void hi() {
}
}
只要在同一个包,就可以访问package权限的class、field和method:
package abc;
class Main {
void foo() {
// 可以访问package权限的类:
Hello h = new Hello();
// 可以调用package权限的方法:
h.hi();
}
}
注意,包名必须完全一致,包没有父子关系,com.apache和com.apache.abc是不同的包。
5)局部变量
在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。
package abc;
public class Hello {
void hi(String name) { // ①
String s = name.toLowerCase(); // ②
int len = s.length(); // ③
if (len < 10) { // ④
int p = 10 - len; // ⑤
for (int i=0; i<10; i++) { // ⑥
System.out.println(); // ⑦
} // ⑧
} // ⑨
} // ⑩
}
使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。
6)final
Java还提供了一个final修饰符。final与访问权限不冲突,它有很多作用。
用final修饰class可以阻止被继承:
package abc;
// 无法被继承:
public final class Hello {
private int n = 0;
protected void hi(int t) {
long i = t;
}
}
用final修饰method可以阻止被子类覆写:
package abc;
public class Hello {
// 无法被覆写:
protected final void hi() {
}
}
用final修饰field可以阻止被重新赋值:
package abc;
public class Hello {
private final int n = 0;
protected void hi() {
this.n = 1; // error!
}
}
用final修饰局部变量可以阻止被重新赋值:
package abc;
public class Hello {
protected void hi(final int t) {
t = 1; // error!
}
}
7)最佳实践
如果不确定是否需要public,就不声明为public,即尽可能少地暴露对外的字段和方法。
把方法定义为package权限有助于测试,因为测试类和被测试类只要位于同一个package,测试代码就可以访问被测试类的package权限方法。
一个.java文件只能包含一个public类,但可以包含多个非public类。如果有public类,文件名必须和public类的名字相同。
8)小结
-
Java内建的访问权限包括public、protected、private和package权限;
-
Java在方法内部定义的变量是局部变量,局部变量的作用域从变量声明开始,到一个块结束;
-
final修饰符不是访问权限,它可以修饰class、field和method;
-
一个.java文件只能包含一个public类,但可以包含多个非public类。
11、内部类
在Java程序中,通常情况下,我们把不同的类组织在不同的包下面,对于一个包下面的类来说,它们是在同一层次,没有父子关系:
还有一种类,它被定义在另一个类的内部,所以称为内部类(Nested Class)。Java的内部类分为好几种,通常情况用得不多,但也需要了解它们是如何使用的。
1)Inner Class
如果一个类定义在另一个类的内部,这个类就是Inner Class:
class Outer {
class Inner {
// 定义了一个Inner Class
}
}
上述定义的Outer是一个普通类,而Inner是一个Inner Class,它与普通类有个最大的不同,就是Inner Class的实例不能单独存在,必须依附于一个Outer Class的实例。示例代码如下:
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);
}
}
}
观察上述代码,要实例化一个Inner,我们必须首先创建一个Outer的实例,然后,调用Outer实例的new来创建Inner实例:
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。
2)Anonymous Class
还有一种定义Inner Class的方法,它不需要在Outer Class中明确地定义这个Class,而是在方法内部,通过匿名类(Anonymous Class)来定义。示例代码如下:
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()方法,我们在方法内部实例化了一个Runnable。Runnable本身是接口,接口是不能实例化的,所以这里实际上是定义了一个实现了Runnable接口的匿名类,并且通过new实例化该匿名类,然后转型为Runnable。在定义匿名类的时候就必须实例化它,定义匿名类的写法如下:
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……
除了接口外,匿名类也完全可以继承自普通类。观察以下代码:
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"));
}
}
map1是一个普通的HashMap实例,但map2是一个匿名类实例,只是该匿名类继承自HashMap。map3也是一个继承自HashMap的匿名类实例,并且添加了static代码块来初始化数据。观察编译输出可发现Main$1.class和Main$2.class两个匿名类文件。
3)Static Nested Class
最后一种内部类和Inner Class类似,但是使用static修饰,称为静态内部类(Static Nested Class):
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,但它可以访问Outer的private静态字段和静态方法。如果把StaticNested移到Outer之外,就失去了访问private的权限。
4)小结
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访问权限。
12、classpath和jar
1)classpath
在Java中,我们经常听到classpath这个东西。网上有很多关于“如何设置classpath”的文章,但大部分设置都不靠谱。
到底什么是classpath?
classpath是JVM用到的一个环境变量,它用来指示JVM如何搜索class。
因为Java是编译型语言,源码文件是.java,而编译后的.class文件才是真正可以被JVM执行的字节码。因此,JVM需要知道,如果要加载一个abc.xyz.Hello的类,应该去哪搜索对应的Hello.class文件。
所以,classpath就是一组目录的集合,它设置的搜索路径与操作系统相关。例如,在Windows系统上,用;分隔,带空格的目录用""括起来,可能长这样:
C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin"
在Linux系统上,用:分隔,可能长这样:
/usr/shared:/usr/local/bin:/home/liaoxuefeng/bin
现在我们假设classpath是.;C:\work\project1\bin;C:\shared,当JVM在加载abc.xyz.Hello这个类时,会依次查找:
注意到.代表当前目录。如果JVM在某个路径下找到了对应的class文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错。
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
上述命令告诉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,那么,目录结构必须如下:
JVM根据classpath设置的.在当前目录下查找com.example.Hello,即实际搜索文件必须位于com/example/Hello.class。如果指定的.class文件不存在,或者目录结构和包名对不上,均会报错。
2)jar
如果有很多.class文件,散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。
jar包就是用来干这个事的,它可以把package组织的目录层级,以及各个目录下的所有文件(包括.class文件和其他文件)都打成一个jar文件,这样一来,无论是备份,还是发给客户,就简单多了。
jar包实际上就是一个zip格式的压缩文件,而jar包相当于目录。如果我们要执行一个jar包的class,就可以把jar包放到classpath中:
java -cp ./hello.jar abc.xyz.Hello
这样JVM会自动在hello.jar文件里去搜索某个类。
那么问题来了:如何创建jar包?
因为jar包就是zip包,所以,直接在资源管理器中,找到正确的目录,点击右键,在弹出的快捷菜单中选择“发送到”,“压缩(zipped)文件夹”,就制作了一个zip文件。然后,把后缀从.zip改为.jar,一个jar包就创建成功。
假设编译输出的目录结构是这样:
这里需要特别注意的是,jar包里的第一层目录,不能是bin,而应该是hong、ming、mr。如果在Windows的资源管理器中看,应该长这样:
说明打包打得有问题,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,我们就不必在命令行指定启动的类名,而是用更方便的命令:
java -jar hello.jar
jar包还可以包含其它jar包,这个时候,就需要在MANIFEST.MF文件里配置classpath了。
在大型项目中,不可能手动编写MANIFEST.MF文件,再手动创建zip包。Java社区提供了大量的开源构建工具,例如Maven,可以非常方便地创建jar包。
3)小结
-
JVM通过环境变量classpath决定搜索class的路径和顺序;
-
不推荐设置系统环境变量classpath,始终建议通过-cp命令传入;
-
jar包相当于目录,可以包含很多.class文件,方便下载和使用;
-
MANIFEST.MF文件可以提供jar包的信息,如Main-Class,这样可以直接运行jar包。
classpath:查找class的路径
MANIFEST.MF文件可以提供jar包的信息,如Main-Class,这样可以直接运行jar包
13、class版本
在Java开发中,许多童鞋经常被各种版本的JDK搞得晕头转向,本节我们就来详细讲解Java程序编译后的class文件版本问题。
我们通常说的Java 8,Java 11,Java 17,是指JDK的版本,也就是JVM的版本,更确切地说,就是java.exe这个程序的版本:
$ java -version
java version "17" 2021-09-14 LTS
而每个版本的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,错误信息类似:
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设置:
然而,指定版本如果低于当前的JDK版本,会有一些潜在的问题。例如,我们用Java 17编译Hello.java,参数设置–source 9和–target 11:
注:如果使用–release 11则会在编译时检查该方法是否在Java 11中存在。
源码版本
在编写源代码的时候,我们通常会预设一个源码的版本。在编译的时候,如果用–source或–release指定源码版本,则使用指定的源码版本检查语法。
例如,使用了lambda表达式的源码版本至少要为8才能编译,使用了var关键字的源码版本至少要为10才能编译,使用switch表达式的源码版本至少要为12才能编译,且12和13版本需要启用–enable-preview参数。
小结
-
高版本的JDK可编译输出低版本兼容的class文件,但需注意,低版本的JDK可能不存在高版本JDK添加的类和方法,导致运行时报错。
-
运行时使用哪个JDK版本,编译时就尽量使用同一版本编译源码。
14、模块
从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程序,一般来说,命令行写这样:
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容器就是模块。
模块关心jar之间的依赖关系。
为了表明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工程为例,它的目录结构如下:
其中,**bin目录存放编译后的class文件,src目录存放源码,按包名的目录结构存放,仅仅在src目录下多了一个module-info.java这个文件,这就是模块的描述文件。**在这个模块中,它长这样:
module hello.world {
requires java.base; // 可不写,任何模块都会自动引入java.base
requires java.xml;
}
其中,module是关键字,后面的hello.world是模块的名称,它的命名规范与包一致。花括号的requires xxx;表示这个模块需要引用的其他模块名。除了java.base可以被自动引入外,这里我们引入了一个java.xml的模块。
**当我们使用模块声明了依赖关系后,才能使用引入的模块。**例如,Main.java代码如下:
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中去掉,编译将报错。可见,模块的重要作用就是声明依赖关系。
运行模块
可以用它来打包JRE
打包JRE
$ jre/bin/java --module hello.world
Hello, xml!
要分发我们自己的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中声明了若干导出:
模块进一步隔离了代码的访问权限。
小结
Java 9引入的模块目的是为了管理依赖;
使用模块可以按需打包JRE;
使用模块对类的访问权限有了进一步限制。