java学习笔记之基础:面向对象、包、模块、Maven

面向对象编程

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

  • class 是一种对象模版,它定义了如何创建实例,一个 class 可以包含多个字段和方法,字段用来描述类的一个特征。class 本身就是一种数据类型。
  • instance 是对象实例,instance 是根据 class 创建的实例,可以创建多个 instance,每个 instance 类型相同,但各自属性可能不相同。

一个 Java 源文件可以包含多个类的定义,但只能定义一个 public 类,且 public 类名必须与文件名一致。如果要定义多个 public 类,必须拆到多个 Java 源文件中。public 用来修饰字段时表示这个字段可以被外部访问。

class Person {
    public String name;
    public int age;
}

Person ming = new Person();
ming.name = "Xiao Ming"; 
ming.age = 12; 
System.out.println(ming.name);

方法

一个 class 可以包含多个字段,但是直接把字段用 public 暴露给外部可能会破坏封装性。为了避免外部代码直接去访问字段,我们可以用 private 修饰字段,拒绝外部访问。一个类通过定义方法,就可以给外部代码暴露一些操作的接口,同时内部自己保证逻辑一致性。

class Person {
    private String name;
    private int age;

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

    public void setName(String name) {
        if (name == null || name.isBlank()) {
            throw new IllegalArgumentException("invalid name");
        }
        this.name = name.strip(); // 去掉首尾空格
    }

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

    public void setAge(int age) {
        if (age < 0 || age > 100) {
            throw new IllegalArgumentException("invalid age value");
        }
        this.age = age;
    }
}

虽然外部代码不能直接修改 private 字段,但是可以调用方法 setName()setAge() 来间接修改 private 字段。在方法内部,我们就有机会检查参数对不对。比如setAge() 就会检查传入的参数,参数超出了范围直接报错。这样外部代码就没有任何机会把 age 设置成不合理的值。

this 变量

在方法内部,可以使用一个隐含的变量 this,它始终指向当前实例。因此通过 this.field 就可以访问当前实例的字段。如果没有命名冲突可以省略 this。但是如果有局部变量和字段重名,那么局部变量优先级更高,字段前就必须加上 this 。

方法参数

方法可以包含 0 个或任意个参数。方法参数用于接收传递给方法的变量值。调用方法时必须严格按照参数的定义一一传递。

可变参数

可变参数用 类型... 定义,可变参数相当于数组类型。可变参数可以保证无法传入 null,因为传入 0 个参数时,接收到的实际值是一个空数组而不是 null。

class Group {
    private String[] names;

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

Group g = new Group();
g.setNames("Xiao Ming", "Xiao Hong", "Xiao Jun"); // 传入3个String
g.setNames("Xiao Ming"); // 传入1个String
g.setNames(); // 传入0个String
参数绑定

调用方把参数传递给实例方法时,传递的值会按参数位置一一绑定。基本类型参数的传递是调用方值的复制。双方各自的后续修改互不影响。引用类型参数的传递,调用方的变量和接收方的参数变量指向的是同一个对象。双方任意一方对这个对象的修改,都会影响对方。

构造方法

class Person {
    private String name;
    private int age;

    // 默认构造方法:如果一个类没有定义构造方法,编译器会自动为我们生成一个默认构造方法,它没有参数也没有执行语句。
    // 如果我们自定义了一个构造方法,那么编译器就不再自动创建默认构造方法
    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;
    }
}

Person p = new Person("Xiao Ming", 15);
System.out.println(p.getName());

由于构造方法是如此特殊,构造方法的名称和类名一致。和普通方法相比,构造方法没有返回值,也没有 void。调用构造方法,必须用 new 操作符。可以定义多个构造方法,在通过 new 操作符调用的时候,编译器通过构造方法的参数数量、位置和类型自动区分。一个构造方法可以调用其他构造方法,这样做的目的是便于代码复用。调用其他构造方法的语法是 this(…)

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)
    }
}

方法重载

在一个类中我们可以定义多个方法。如果有一系列方法,它们的名字和功能都是类似的,只有参数有所不同,那么可以把这一组方法名做成同名方法。这种方法名相同但各自的参数不同,称为方法重载(Overload)。方法重载的返回值类型通常都是相同的。方法重载的目的是:功能类似的方法使用同一名字更容易记住,因此调用起来更简单。

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 + "!");
        }
    }
}

继承

继承是面向对象编程中非常强大的一种机制,它可以复用代码。通过继承,子类只需要编写额外的功能,不再需要编写重复代码。子类自动获得了父类的所有字段,因此严禁定义与父类重名的字段!

继承树

在 Java 中,没有明确写 extends 的类,编译器会自动加上 extends Object。所以除了 Object 的任何其它类都会继承自某个类。Java 只允许一个 class 继承自一个类,因此一个类有且仅有一个父类。只有 Object 特殊,它没有父类。Java 使用 extends 关键字来实现继承。

protected 关键字

子类无法访问父类的 private 字段或者 private 方法。protected 关键字可以把字段和方法的访问权限控制在继承树内部,使用 protected 关键字修饰的字段和方法可以被其子类,以及子类的子类所访问。

super 关键字

super 关键字表示父类、超类。子类引用父类的字段时,可以用 super.fieldName 。 在 Java 中任何 class 的构造方法,第一行语句必须是调用父类的构造方法。如果没有明确地调用父类的构造方法,编译器会帮我们自动加一句 super(); 。如果父类没有默认的构造方法,子类就必须显式调用 super() 并给出参数以便让编译器定位到父类的一个合适的构造方法。子类不会继承任何父类的构造方法。子类默认的构造方法是编译器自动生成的,不是继承的。

阻止继承

正常情况下,只要某个 class 没有 final 修饰符,那么任何类都可以从该 class 继承。从 Java 15 开始,允许使用 sealed 修饰 class,并通过 permits 明确写出能够从该 class 继承的子类名称。如果一个类不希望任何其他类继承自它,那么可以把这个类本身标记为 final。用 final 修饰的类不能被继承。

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

public final class Rect extends Shape {...}

// Compile error: class is not allowed to extend sealed class: Shape
// public final class Ellipse extends Shape {...}

final class Person {
    protected String name;
}

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

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

class Person {
    public final String name = "Unamed";
}
Person p = new Person();
// p.name = "New Name"; // compile error!

在构造方法中初始化 final 字段的方法更为常用,因为可以保证实例一旦创建,其 final 字段就不可修改。

class Person {
    public final String name;
    public Person(String name) {
        this.name = name;
    }
}
向上转型

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

Student s = new Student();
Person p = s; // upcasting, ok
Object o1 = p; // upcasting, ok
Object o2 = s; // upcasting, ok
向下转型

和向上转型相反,如果把一个父类类型强制转型为子类类型,就是向下转型。因为子类功能比父类多,多的功能无法凭空变出来,因此向下转型很可能会失败。失败的时候,JVM 会报 ClassCastException。为了避免向下转型出错,Java 提供了 instanceof 操作符,可以先判断一个实例究竟是不是某种类型。

Person p1 = new Student(); // upcasting, ok
Person p2 = new Person();
Student s1 = (Student) p1; // ok
// Student s2 = (Student) p2; // runtime error! ClassCastException!

Person p = new Student();
if (p instanceof Student) {
    // 只有判断成功才会向下转型:
    Student s = (Student) p; // 一定会成功
}

多态

在继承关系中,子类如果定义了一个与父类方法签名完全相同的方法,被称为覆写(Override)。Override 和 Overload 不同的是,如果方法签名不同,就是 Overload,Overload 方法是一个新方法;如果方法签名相同并且返回值也相同就是 Override。方法名相同,方法参数相同,但方法返回值不同,也是不同的方法。在 Java 程序中,出现这种情况,编译器会报错。

class Student extends Person {
    @Override
    public void run() {
        System.out.println("Student.run");
    }
}

引用变量的声明类型可能与其实际类型不符,Java 的实例方法调用是基于运行时的实际类型的动态调用,而非变量的声明类型。这个非常重要的特性在面向对象编程中称之为多态 Polymorphic。

多态

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

class Animal {
    void makeSound() {
        System.out.println("动物发出声音");
    }
}

class Dog extends Animal {
    @Override
    void makeSound() {
        System.out.println("汪汪汪");
    }
}

class Cat extends Animal {
    @Override
    void makeSound() {
        System.out.println("喵喵喵");
    }
}

public class Main {
    public static void main(String[] args) {
        Animal myAnimal = new Animal();  // 父类对象
        Animal myDog = new Dog();        // 子类对象(向上转型)
        Animal myCat = new Cat();        // 子类对象(向上转型)

        myAnimal.makeSound();  // 输出: 动物发出声音
        myDog.makeSound();     // 输出: 汪汪汪(多态体现)
        myCat.makeSound();     // 输出: 喵喵喵(多态体现)
    }
}
覆写 Object 方法

所有的 class 最终都继承自 Object,Object 定义了几个重要的方法:toString()equals()hashCode()

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 来调用。

class Person {
    protected String name;
    public String hello() {
        return "Hello, " + name;
    }
}

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

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

class Person {
    protected String name;
    public final String hello() {
        return "Hello, " + name;
    }
}

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

抽象类

由于多态的存在,每个子类都可以覆写父类的方法。如果父类的方法本身不需要实现任何功能,仅仅是为了定义方法签名,目的是让子类去覆写它,那么可以把父类的方法声明为抽象方法。把一个方法声明为 abstract,表示它是一个抽象方法,本身没有实现任何方法语句。因为这个抽象方法本身是无法执行的,所以 Person 类也无法被实例化。必须把 Person 类本身也声明为 abstract,才能正确编译它。

使用 abstract 修饰的类是抽象类。我们无法实例化一个抽象类,因为抽象类本身被设计成只能用于被继承,因此抽象类可以强迫子类实现其定义的抽象方法,否则编译会报错。因此抽象方法实际上相当于定义了“规范”。

abstract class Person {
    public abstract void run();
}
面向抽象编程
Person s = new Student();
Person t = new Teacher();

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

// 同样不关心新的子类是如何实现 run() 方法的:
Person e = new Employee();
e.run();

面向抽象编程的本质就是:上层代码只定义规范,具体的业务逻辑由不同的子类实现,调用者并不关心。

接口

在抽象类中,抽象方法本质上是定义接口规范:即规定高层类的接口,从而保证所有子类都有相同的接口实现。如果一个抽象类没有字段,所有方法全部都是抽象方法,就可以把该抽象类改写为接口:interface。接口定义的所有方法默认都是 public abstract 的,所以这两个修饰符不需要写出来。

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

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

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。

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

抽象类和接口的区别:

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

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

interface Hello {
    void hello();
}

interface Person extends Hello {
    void run();
    String getName();
}
继承关系

合理设计 interface 和 abstract class 的继承关系可以充分复用代码。一般来说公共逻辑适合放在 abstract class 中,具体逻辑放到各个子类,而接口层次代表抽象程度。在使用的时候,实例化的对象永远只能是某个具体的子类,但总是通过接口去引用它。

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

在接口中可以定义 default 方法。实现类可以不必覆写 default 方法。当我们需要给接口新增一个方法时,会涉及到修改全部子类。如果新增的是 default 方法,那么子类就不必全部修改,只需要在需要覆写的地方去覆写新增方法。default 方法和抽象类的普通方法是有所不同的。因为 interface 没有字段,default 方法无法访问字段,而抽象类的普通方法可以访问实例字段。

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

静态字段和静态方法

静态字段

在一个 class 中定义的字段,我们称之为实例字段。实例字段的特点是:每个实例都有独立的字段,各个实例的同名字段互不影响。还有一种字段是用 static 修饰的字段,称为静态字段。实例字段在每个实例中都有自己的一个独立“空间”,但是静态字段只有一个共享“空间”,所有实例都会共享该字段。无论修改哪个实例的静态字段,所有实例的静态字段都被修改了 。

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);
静态方法

用 static 修饰的方法称为静态方法,静态方法通过类名调用。因为静态方法属于 class 而不属于实例,因此静态方法内部无法访问 this 变量,也无法访问实例字段,只能访问静态字段。通过实例变量也可以调用静态方法,但这只是编译器自动帮我们把实例改写成类名而已。

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;
    }
}
接口的静态字段

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

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

内部类

内部类是定义在另一个类的内部的类。与普通类有个最大的不同,就是内部类的实例不能单独存在,必须依附于一个外部类的实例。

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 和普通 class 相比,除了能引用 Outer 实例外,还有一个额外的“特权”,就是可以访问 Outer 的 private 字段和方法,因为 Inner 的作用域在 Outer 内部,所以能访问 Outer 的 private 字段和方法。

在 Java 中,我们使用 package 来解决名字冲突。Java 定义了一种名字空间,称之为包 package。一个类总是属于某个包,类名比如 Person 只是一个简写,真正的完整类名是 包名.类名。在定义 class 的时候,我们需要在第一行声明这个 class 属于哪个包。

package hello; // 声明包名

public class Person {
}

在 JVM 执行的时候,JVM 只看完整类名,因此只要包名不同,类就不同。包可以是多层结构,用 . 隔开。包没有父子关系。java.utiljava.util.zip 是不同的包,两者没有任何继承关系。没有定义包名的 class,它使用的是默认包,非常容易引起名字冲突,因此不推荐不写包名的做法。

import

package hello;

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

public class Person {
    public void run() {
        // 写简单类名: Arrays
        Arrays arrays = new Arrays();
    }
}

编写 class 的时候,编译器会自动帮我们做两个 import 动作:默认自动 import 当前 package 的其他 class;默认自动 import java.lang.*

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

作用域

在 Java 中,我们经常看到 public、protected、private 这些修饰符,这些修饰符可以用来限定访问作用域。

public

定义为 public 的 class、interface 可以被其他任何类访问。定义为 public 的 field、method 可以被其他类访问,前提是首先有访问 class 的权限。

private

定义为 private 的 field、method 无法被其他类访问。确切地说 private 访问权限被限定在 class 的内部。推荐把 private 方法放到后面,因为 public 方法定义了类对外提供的功能,阅读代码的时候,应该先关注 public 方法。由于 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();
        }
    }
}
protected

protected 作用于继承关系。定义为 protected 的字段和方法可以被子类访问,以及子类的子类。

package

位于同一个包的类,可以访问包作用域的字段和方法。包作用域是指一个类允许访问同一个 package 的没有 public、private 修饰的 class,以及没有 public、protected、private 修饰的字段和方法。

package hello;

// package 权限的类:
class Hello {

    // package 权限的方法:
    void hi() {
    }
}

局部变量

在方法内部定义的变量称为局部变量,局部变量作用域从变量声明处开始到对应的块结束。方法参数也是局部变量。使用局部变量时,应该尽可能把局部变量的作用域缩小,尽可能延后声明局部变量。

final

Java 还提供了一个 final 修饰符。final 与访问权限不冲突,它有很多作用:用 final 修饰 class 可以阻止被继承;用 final 修饰 method 可以阻止被子类覆写;用 final 修饰 field 可以阻止被重新赋值;用 final 修饰局部变量可以阻止被重新赋值。

package abc;

public class Hello {
    protected void hi(final int t) {
        t = 1; // error!
    }
}

最佳实践

  • 如果不确定是否需要 public 就不声明为 public,即尽可能少地暴露对外的字段和方法。
  • 把方法定义为 package 权限有助于测试,因为测试类和被测试类只要位于同一个 package,测试代码就可以访问被测试类的 package 权限方法。
  • 一个 .java 文件只能包含一个 public 类,但可以包含多个非 public 类。如果有 public 类,文件名必须和 public 类的名字相同。
  • 为了避免名字冲突,我们需要确定唯一的包名。推荐的做法是使用倒置的域名来确保唯一性。org.apache , org.apache.commons.log

classpath 和 jar

classpath 是 JVM 用到的一个环境变量,它用来指示 JVM 如何搜索 class。classpath 就是一组目录的集合,它设置的搜索路径与操作系统相关。在 Windows 系统上,用 ; 分隔,带空格的目录用 "" 括起来,可能长这样:C:\work\project1\bin;C:\shared;"D:\My Documents\project1\bin" 。在 Linux 系统上,用 : 分隔,可能长这样:/usr/shared:/usr/local/bin

因为 Java 是编译型语言,源码文件是 .java,而编译后的 .class 文件才是真正可以被 JVM 执行的字节码。因此 JVM 需要知道如果要加载一个 abc.xyz.Hello 的类,应该去哪搜索对应的 Hello.class 文件。当 JVM 在加载 abc.xyz.Hello 这个类时,会依次查找 classpath 目录。如果 JVM 在某个路径下找到了对应的 class 文件,就不再往后继续搜索。如果所有路径下都没有找到,就报错。

classpath 的设定方法

classpath 的设定方法有两种: 在系统环境变量中设置 classpath 环境变量(不推荐);在启动 JVM 时设置 classpath 变量(推荐)。

在启动 JVM 时设置 classpath ,实际上就是给 java 命令传入 -classpath 参数: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 为 . ,即当前目录。在 IDE 中运行 Java 程序,IDE 自动传入的 -cp 参数是当前工程的 bin 目录和引入的 jar 包。

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

jar 包

如果有很多 .class 文件散落在各层目录中,肯定不便于管理。如果能把目录打一个包,变成一个文件,就方便多了。jar 包就是用来把 package 组织的目录层级以及各个目录下的所有文件(包括 .class 文件和其他文件)都打成一个 jar 文件。jar 包实际上就是一个 zip 格式的压缩文件,而 jar 包相当于目录。如果我们要执行一个 jar 包的 class,就可以把 jar 包放到 classpath 中,这样 JVM 会自动在 hello.jar 文件里去搜索某个类。java -cp ./hello.jar abc.xyz.Hello

因为 jar 包就是 zip 包,可以直接在资源管理器中找到正确的目录,点击右键,在弹出的快捷菜单中选择 发送到 - 压缩(zipped)文件夹,就制作了一个 zip 文件。然后把后缀从 .zip 改为 .jar,一个 jar 包就创建成功。

jar 包还可以包含一个特殊的 /META-INF/MANIFEST.MF 文件,MANIFEST.MF 是纯文本,可以指定 Main Class 和其它信息。JVM 会自动读取这个 MANIFEST.MF 文件,如果存在 Main Class,我们就不必在命令行指定启动的类名,而是用更方便的命令:java -jar hello.jar

class 版本

我们通常说的 Java 8,Java 11,Java 17 是指 JDK 的版本,也就是 JVM 的版本,更确切地说就是 java.exe 这个程序的版本。而每个版本的 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。只要看到 UnsupportedClassVersionError 就表示当前要加载的 class 文件版本超过了 JVM 的能力,必须使用更高版本的 JVM 才能运行。

我们也可以用 Java 17 编译一个 Java 程序,指定输出的 class 版本要兼容 Java 11(即 class 版本 55),这样编译生成的 class 文件就可以在 Java >=11 的环境中运行。指定编译输出有两种方式,一种是在 javac 命令行中用参数 --release 设置:参数 --release 11 表示源码兼容 Java 11,编译的 class 输出版本为 Java 11 兼容,即 class 版本 55。javac --release 11 Main.java 。第二种方式是用参数 --source 指定源码版本,用参数 --target 指定输出 class 版本:javac --source 9 --target 11 Main.java 。上述命令如果使用 Java 17 的 JDK 编译,它会把源码视为 Java 9 兼容版本,并输出 class 为 Java 11 兼容版本。注意 --release 参数和 --source --target 参数只能二选一,不能同时设置。

然而指定版本如果低于当前的 JDK 版本,会有一些潜在的问题。因此如果运行时的 JVM 版本是 Java 11,则编译时也最好使用 Java 11,而不是用高版本的 JDK 编译输出低版本的 class。如果使用 javac 编译时不指定任何版本参数,那么相当于使用 --release 当前版本 编译,即源码版本和输出版本均为当前版本。在开发阶段,多个版本的 JDK 可以同时安装,当前使用的 JDK 版本可由 JAVA_HOME 环境变量切换。

模块

模块的重要作用是声明依赖关系。

在 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.example.sample.Main。JVM 自带的标准库 rt.jar 不要写到 classpath 中,写了反而会干扰 JVM 的正常运行。如果漏写了某个运行时需要用到的 jar,那么在运行期极有可能抛出 ClassNotFoundException。所以 jar 只是用于存放 class 的容器,它并不关心 class 之间的依赖。

从 Java 9 开始引入的模块,主要是为了解决“依赖”这个问题。如果 a.jar 必须依赖另一个 b.jar 才能运行,那我们应该给 a.jar 加点说明,让程序在编译和运行的时候能自动定位到 b.jar,这种自带“依赖关系”的 class 容器就是模块。从 Java 9 开始,原有的 Java 标准库由一个单一巨大的 rt.jar 分拆成了几十个模块,这些模块以 .jmod 扩展名标识,可以在 $JAVA_HOME/jmods 目录下找到它们。这些 .jmod 文件每一个都是一个模块,模块名就是文件名。例如模块 java.base 对应的文件就是 java.base.jmod。模块之间的依赖关系已经被写入到模块内的 module-info.class 文件了。所有的模块都直接或间接地依赖 java.base 模块,只有 java.base 模块不依赖任何模块,它可以被看作是“根模块”。

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

编写模块

创建模块和原有的创建 Java 项目是完全一样的:bin 目录存放编译后的 class 文件,src 目录存放源码,按包名的目录结构存放,仅仅在 src 目录下多了一个 module-info.java 这个文件,这就是模块的描述文件。它长这样:

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

module 是关键字,后面的 hello.world 是模块的名称,它的命名规范与包一致。

#编译
javac -d bin src/module-info.java src/com/example/sample/*.java
#打包成jar
jar --create --file hello.jar --main-class com.example.sample.Main -C bin .
#打包成模块
jmod create --class-path hello.jar hello.jmod
#运行
java --module-path hello.jar --module hello.world

打包 JRE

使用模块可以按需打包 JRE: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:jre/bin/java --module hello.world。分发我们自己的 Java 应用程序,只需要把这个 jre 目录打个包给对方发过去,对方直接运行上述命令即可,既不用下载安装 JDK,也不用知道如何配置我们自己的模块,极大地方便了分发和部署。

访问权限

Java 的 class 访问权限分为 public、protected、private 和默认的包访问权限。引入模块后,class 的这些访问权限只在一个模块内有效。模块和模块之间,例如 a 模块要访问 b 模块的某个 class,必要条件是 b 模块明确地导出了可以访问的包。如果外部代码想要访问我们的 hello.world 模块中的 com.example.sample.Greeting 类,我们必须将其导出。因此模块进一步隔离了代码的访问权限。

module hello.world {
    exports com.example.sample;

    requires java.base;
	requires java.xml;
}

Maven 基础

Maven 是一个 Java 项目管理和构建工具,它可以定义项目结构、项目依赖,并使用统一的方式进行自动化构建,是 Java 项目不可缺少的工具。

Maven 是是专门为 Java 项目打造的管理和构建工具,它的主要功能有:提供了一套标准化的项目结构;提供了一套标准化的构建流程(编译,测试,打包,发布……);提供了一套依赖管理机制。

Maven 项目结构

使用 Maven 管理的 Java 项目的目录结构默认如下:

maven-project # 项目名
├── pom.xml # 项目描述文件
├── src
│ ├── main
│ │ ├── java # 存放 Java 源码
│ │ └── resources # 存放资源文件
│ └── test
│ ├── java # 存放测试源码
│ └── resources # 存放测试资源
└── target # 所有编译、打包生成的文件

项目描述文件 pom.xml的内容长得像下面:

<project ...>
	<modelVersion>4.0.0</modelVersion>
	<groupId>com.example.demo</groupId>
	<artifactId>hello</artifactId>
	<version>1.0</version>
	<packaging>jar</packaging>
	<properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
		<maven.compiler.release>17</maven.compiler.release>
	</properties>
	<dependencies>
        <dependency>
            <groupId>org.slf4j</groupId>
            <artifactId>slf4j-simple</artifactId>
            <version>2.0.16</version>
        </dependency>
	</dependencies>
</project>

<groupId> 类似于 Java 的包名,通常是公司或组织名称,<artifactId> 类似于 Java 的类名,通常是项目名称。一个 Maven 工程就是由 <groupId><artifactId><version> 作为唯一标识。引用其他第三方库也是通过这 3 个变量确定。通过上述 3 个变量,即可唯一确定某个 jar 包。Maven 通过对 jar 包进行 PGP 签名确保任何一个 jar 包一经发布就无法修改。修改已发布 jar 包的唯一方法是发布一个新版本。因此某个 jar 包一旦被 Maven 下载过,即可永久地安全缓存在本地。以 -SNAPSHOT 结尾的版本号会被 Maven 视为开发版本,开发版本每次都会重复下载,只能用于内部私有的 Maven repo,公开发布的版本不允许出现 SNAPSHOT。

使用 <dependency> 声明一个依赖后,Maven 就会自动下载这个依赖包并把它放到 classpath 中。

<properties> 定义了常用的属性:

  • project.build.sourceEncoding:表示项目源码的字符编码,通常应设定为 UTF-8;
  • maven.compiler.release:表示使用的 JDK 版本,例如 21;
  • maven.compiler.source:表示 Java 编译器读取的源码版本;
  • maven.compiler.target:表示 Java 编译器编译的 Class 版本。

从 Java 9 开始,推荐使用 maven.compiler.release 属性,保证编译时输入的源码和编译输出版本一致。如果源码和输出版本不同,则应该分别设置 maven.compiler.source 和 maven.compiler.target。通过 <properties> 定义的属性,就可以固定 JDK 版本,防止同一个项目的不同的开发者各自使用不同版本的 JDK。

安装 Maven

要安装 Maven,可以从 Maven 官网下载安装包,然后在本地解压,设置环境变量:M2_HOME=/path/to/maven-3.9.x 。Windows 可以把 %M2_HOME%\bin 添加到系统 Path 变量中。

打开命令行窗口,输入 mvn -version,应该看到 Maven 的版本信息。

搜索第三方组件可以通过 search.maven.org 搜索关键字,找到对应的组件后,直接复制组件的 <groupId><artifactId><version>

依赖管理

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
    <version>1.4.2.RELEASE</version>
</dependency>

当我们声明一个依赖时,Maven 会自动下载依赖包及其当前依赖包需要的其它依赖包,并将其放到 classpath 中。手动管理这些依赖非常费时费力,而且出错的概率很大。

依赖范围

Maven 定义了几种依赖范围,分别是 compile、test、runtime 和 provided。默认的 compile 是最常用的,Maven 会把这种类型的依赖直接放入 classpath。

scope 说明 示例
compile 编译时需要用到该 jar 包(默认) commons-logging
test 编译 Test 时需要用到该 jar 包 junit
runtime 编译时不需要,但运行时需要用到 mysql
provided 编译时需要用到,但运行时由 JDK 或某个服务器提供 servlet-api

test 依赖表示仅在测试时使用,正常运行时并不需要。最常用的 test 依赖就是 JUnit:

<dependency>
    <groupId>org.junit.jupiter</groupId>
    <artifactId>junit-jupiter-api</artifactId>
    <version>5.3.2</version>
    <scope>test</scope>
</dependency>

runtime 依赖表示编译时不需要,但运行时需要。最典型的 runtime 依赖是 JDBC 驱动,例如 MySQL 驱动:

<dependency>
    <groupId>mysql</groupId>
    <artifactId>mysql-connector-java</artifactId>
    <version>5.1.48</version>
    <scope>runtime</scope>
</dependency>

provided 依赖表示编译时需要,但运行时不需要。最典型的 provided 依赖是 Servlet API,编译的时候需要,但是运行时,Servlet 服务器内置了相关的 jar,所以运行期不需要:

<dependency>
    <groupId>jakarta.servlet</groupId>
    <artifactId>jakarta.servlet-api</artifactId>
    <version>4.0.0</version>
    <scope>provided</scope>
</dependency>

Maven 维护了一个中央仓库 repo1.maven.org ,所有第三方库将自身的 jar 以及相关信息上传至中央仓库,Maven 会从中央仓库把所需依赖下载到本地。Maven 并不会每次都从中央仓库下载 jar 包。一个 jar 包一旦被下载过,就会被 Maven 自动缓存在本地目录,即用户主目录的 .m2 目录,所以除了第一次编译时因为下载需要时间会比较慢,后续过程因为有本地缓存,并不会重复下载相同的 jar 包。

Maven 镜像

除了可以从 Maven 的中央仓库下载外,还可以从 Maven 的镜像仓库下载。Maven 镜像仓库定期从中央仓库同步。中国区用户可以使用阿里云提供的 Maven 镜像仓库。使用 Maven 镜像仓库需要一个配置,在用户主目录下进入 .m2 目录,创建一个 settings.xml 配置文件,内容如下:

<settings>
    <mirrors>
        <mirror>
            <id>aliyun</id>
            <name>aliyun</name>
            <mirrorOf>central</mirrorOf>
            <!-- 国内推荐阿里云的Maven镜像 -->
            <url>https://maven.aliyun.com/repository/central</url>
        </mirror>
    </mirrors>
</settings>

生命周期

Maven 的生命周期就是为了对所有的 maven 项目构建过程进行抽象和统一。

Maven 中有 3 套相互独立的生命周期:

  • clean:清理工作。可以分为 pre-clean、clean、post-clean 阶段。
  • default:核心工作,如编译、测试、打包、安装、部署等。可以分为 validate、initialize、generate-sources、proces5-sources、generate-resources、process-resources、compile、process-classes、generate-test-sources、generate-test-resources、process-test-sources、process-test-resources、test-compile、process-test-classes、test、package、prepare-package、verify、install、deploy阶段。
  • site:生成报告、发布站点等。可以分为 pre-site、site、post-site、site-deploy 阶段。

每套生命周期包含一些阶段 phase ,阶段是有顺序的,后面的阶段依赖于前面的阶段。

常用的生命周期阶段

  • clean:移除上一次构建生成的文件
  • compile:编译项目源代码
  • test:使用合适的单元测试框架运行测试 junit
  • package:将编译后的文件打包,如:jar、war等
  • install:安装项目到本地仓库

在同一套生命周期中,当运行后面的阶段时,前面的阶段都会运行。

常用命令

项目创建命令

  • mvn archetype:generate - 创建新项目 mvn archetype:generate -DgroupId=com.example -DartifactId=my-project -DarchetypeArtifactId=maven-archetype-quickstart

基础构建命令

  • mvn clean - 清理项目编译生成的文件(target目录)
  • mvn compile - 编译主代码(src/main/java)
  • mvn test-compile - 编译测试代码(src/test/java)
  • mvn package - 编译+测试+打包(生成JAR/WAR)
  • mvn install - 打包并安装到本地仓库
  • mvn deploy - 发布到远程仓库

测试相关命令

  • mvn test - 运行所有单元测试
  • mvn test -Dtest=TestClass - 运行指定测试类
  • mvn test -DskipTests - 跳过测试阶段

依赖管理命令

  • mvn dependency:tree - 显示依赖树
  • mvn dependency:analyze - 分析未声明/未使用的依赖
  • mvn versions:display-dependency-updates - 检查依赖更新

调试与优化命令

  • mvn -X - 开启调试日志
  • mvn -U - 强制更新快照依赖
  • mvn --offline - 离线模式构建

组合命令

  • mvn clean package - 先清理再打包
  • mvn clean install -DskipTests - 跳过测试的快速安装
  • mvn clean deploy -U - 强制更新依赖后部署
posted @ 2022-11-19 09:23  carol2014  阅读(392)  评论(0)    收藏  举报