Java17-入门基础知识-一-

135 阅读1小时+

Java17 入门基础知识(一)

原文:Beginning Java 17 Fundamentals

协议:CC BY-NC-SA 4.0

一、编程概念

在本章中,您将学习:

  • 编程的一般概念

  • 编程的不同组成部分

  • 主要编程范例

  • 什么是面向对象(OO)范式,它是如何在 Java 中使用的

什么是编程?

术语“编程”在许多上下文中使用。我们讨论它在人机交互环境中的意义。简而言之,编程就是编写一系列指令来告诉计算机执行特定任务的方式。计算机的指令序列被称为程序。一组定义明确的符号用于编写程序。用来编写程序的一套符号被称为编程语言。写程序的人被称为程序员。程序员使用编程语言编写程序。

一个人如何告诉计算机执行一项任务?人能告诉计算机执行任何任务吗,或者计算机有一套预定义的它能执行的任务吗?在我们看人机交流之前,我们先来看看人与人之间的交流。一个人如何与另一个人交流?你会说人与人之间的交流是通过口语来完成的,例如,英语、德语、印地语等。然而,口语并不是人类之间唯一的交流方式。我们也用书面语言或手势交流,而不用说任何话。有些人甚至可以坐在几英里以外的地方交流,而不用任何语言或手势;他们可以在思想层面交流。

要进行成功的交流,仅仅使用像口语或书面语这样的交流媒介是不够的。双方之间成功沟通的主要要求是双方能够理解对方传达的内容。例如,假设有两个人。一个人知道如何说英语,另一个人知道如何说德语。他们能互相交流吗?答案是否定的,因为他们听不懂对方的语言。如果我们在它们之间加一个英德翻译器会怎么样?我们同意,他们将能够在翻译的帮助下交流,即使他们不能直接相互理解。

计算机只能理解二进制格式的指令,二进制格式是 0 和 1 的序列。所有计算机都理解的 0 和 1 的序列被称为机器语言或机器代码。计算机有一套它能理解的固定的基本指令。每台计算机都有自己的一套指令。例如,一台计算机可能使用 0010 作为将两个数字相加的指令,而另一台计算机可能出于相同的目的使用 0101。因此,用机器语言编写的程序是机器相关的。有时机器代码被称为本机代码,因为它是为其编写的机器的本机代码。用机器语言编写的程序很难写、读、理解和修改。假设你想写一个程序,把 15 和 12 这两个数字相加。用机器语言将两个数字相加的程序看起来类似于这里显示的程序。您不需要理解本节中编写的示例代码。这仅仅是为了讨论和说明的目的:

0010010010  10010100000100110
0001000100  01010010001001010

这些指令是把两个数相加。用机器语言写一个程序来执行一个复杂的任务会有多难?基于这段代码,你现在可能意识到用机器语言编写、阅读和理解一个程序是非常困难的。但是计算机不是应该使我们的工作更容易,而不是更困难吗?我们需要用一些更容易书写、阅读和理解的符号来表示计算机的指令,所以计算机科学家们想出了另一种语言,叫做汇编语言。汇编语言提供了不同的符号来编写指令。它比它的前身机器语言更容易写、读和理解。汇编语言使用助记符来表示指令,与机器语言中使用的二进制(0 和 1)相反。用汇编语言编写的将两个数相加的程序如下所示:

li $t1, 15
add $t0, $??, 12

如果比较用两种不同语言编写的执行相同任务的两个程序,你会发现汇编语言比机器代码更容易编写、阅读和理解。对于给定的计算机体系结构,机器语言的指令和汇编语言的指令之间是一一对应的。回想一下,计算机只能理解机器语言的指令。用汇编语言编写的指令必须先翻译成机器语言,计算机才能执行。把汇编语言编写的指令翻译成机器语言的程序叫做汇编程序。图 1-1 显示了汇编代码、汇编程序和机器码之间的关系。

img/323069_3_En_1_Fig1_HTML.png

图 1-1

汇编代码、汇编程序和机器代码之间的关系

机器语言和汇编语言也被称为低级语言,因为程序员必须理解计算机的低级细节才能用这些语言编写程序。例如,如果你在用这些语言编写程序,你需要知道你在哪个内存位置写或者读,哪个寄存器用来存储一个特定的值,等等。很快,程序员们意识到需要一种更高级的编程语言,可以对他们隐藏计算机的底层细节。这种需求导致了诸如 COBOL、Pascal、FORTRAN、C、C++、Java、C#等高级编程语言的发展。高级编程语言使用类似英语的单词、数学符号和标点符号来编写程序。用高级编程语言编写的程序也叫源代码 *。*它们更接近人类熟悉的书面语言。用高级编程语言(例如 Java)编写的将两个数字相加的指令类似于以下内容:

int x = 15 + 12;

你可能会注意到,用高级语言编写的程序比用机器和汇编语言编写的程序更容易、更直观地编写、阅读、理解和修改。你可能已经意识到计算机不能理解用高级语言编写的程序,因为它们只能理解 0 和 1 的序列。因此,需要一种方法将高级语言编写的程序翻译成机器语言。翻译由编译器、解释器或两者的组合来完成。编译器是把用高级编程语言编写的程序翻译成机器语言的程序。编译程序是一个超载的短语。通常,这意味着将高级语言编写的程序翻译成机器语言。有时它被用来指把用高级编程语言编写的程序翻译成低级编程语言,而低级编程语言不一定是机器语言。由编译器生成的代码称为编译代码 *。*编译后的程序由计算机执行。

执行用高级编程语言编写的程序的另一种方法是使用解释器。解释器不会立刻把整个程序翻译成机器语言。相反,它一次读取一条用高级编程语言编写的指令,将其翻译成机器语言,并执行它。你可以把一个解释器看作一个模拟器。有时,编译器和解释器的组合可以用来编译和运行用高级语言编写的程序。例如,用 Java 编写的程序被编译成一种叫做字节码的中间语言。一个解释器,具体称为 Java 平台的 Java 虚拟机(JVM ),用于解释字节码并执行它。解释程序比编译程序运行得慢。今天,大多数 JVM 使用实时(JIT)编译器,根据需要将整个 Java 程序编译成机器语言。有时,另一种称为超前(AOT)编译器的编译器被用来将中间语言(例如 Java 字节码)的程序编译成机器语言。图 1-2 显示了源代码、编译器和机器码之间的关系。

img/323069_3_En_1_Fig2_HTML.png

图 1-2

源代码、编译器和机器码之间的关系

编程语言也分为第一代、第二代、第三代和第四代语言。这种语言的版本越高,用这种语言编写程序就越接近普通的人类语言。机器语言也被称为第一代编程语言或 1GL。汇编语言也被称为第二代编程语言或 2GL。高级过程编程语言,如 C、C++、Java 和 C#,其中您必须使用语言语法编写算法来解决问题,也称为第三代编程语言或 3GLs。高级非过程化编程语言被称为第四代编程语言或 4gl,在这种语言中,您不需要编写算法来解决问题。结构化查询语言(SQL)是使用最广泛的 4GL,用于与数据库通信。

编程语言的组成部分

编程语言是用来为计算机编写指令的符号系统。它可以用三个部分来描述:

  • 句法

  • 语义学

  • 语用学

语法部分处理使用可用的符号形成有效的编程结构。语义部分处理编程结构的含义。语用学部分处理编程语言在实践中的使用。

像书面语言(例如,英语)一样,编程语言具有词汇和语法。编程语言的词汇表由一组单词、符号和标点符号组成。编程语言的语法定义了如何使用该语言的词汇来形成有效的编程结构的规则。您可以将编程语言中的有效编程构造想象成书面语言中的句子,它是使用该语言的词汇和语法形成的。类似地,使用编程语言的词汇和语法来形成编程构造。词汇表和使用该词汇表形成有效编程结构的规则被称为编程语言的语法

在书面语言中,你可能会形成一个语法正确的句子,但这个句子可能没有任何有效的含义。例如,“石头在笑”是一个语法正确的句子。然而,这没有任何意义。在书面语言中,这种歧义是允许的。编程语言意味着向计算机传达指令,没有任何含糊不清的余地。我们不能用模糊的指令与计算机交流。编程语言还有另一个组成部分,叫做语义,它解释了语法上有效的编程结构的含义。编程语言的语义回答了这样一个问题,“这个程序在计算机上运行时做什么?”请注意,语法上有效的编程结构可能在语义上也无效。一个程序在被计算机执行之前必须在语法和语义上是正确的。

编程语言的语用学描述了它的用途和它对用户的影响。用编程语言编写的程序可能在语法和语义上是正确的。但是,它可能不容易被其他程序员理解。这一方面与编程语言的语用学有关。语用学关注的是编程语言的实用方面。它回答了关于编程语言的一些问题,如实现的容易程度、对特定应用的适用性、效率、可移植性、对编程方法的支持等。

编程范例

在线的《韦氏词典学习词典》对“范式”一词的定义如下:

范式是关于应该如何做、做或思考某事的一种理论或一组想法。

一开始,在编程环境中理解“范式”这个词有点困难。编程就是使用编程语言支持的计算模型为现实世界的问题提供解决方案。这个解决方案叫做程序。在我们以程序的形式提供问题的解决方案之前,我们总是对问题及其解决方案有一个心理上的看法。在我讨论如何使用计算模型解决现实世界的问题之前,让我们举一个现实世界的社会问题的例子,一个与计算机无关的问题。

假设地球上有一个地方食物短缺。那个地方的人们没有足够的食物吃。问题是“食物短缺”让我们请三个人提供解决这个问题的方法。这三个人分别是政治家、慈善家和僧侣。政治家会对问题及其解决方案有政治观点。他们可能认为这是一个为他们的同胞服务的机会,通过制定一些法律为饥饿的人提供食物。一个慈善家会提供一些钱/食物来帮助那些饥饿的人,因为他们同情所有的人类,因此也同情那些饥饿的人。一个僧侣会试图用他们的精神观点来解决这个问题。他们可能会对他们说教,让他们自己去工作,去谋生;他们可能会呼吁富人向饥饿的人捐赠食物;或者他们可能会教他们瑜伽来征服他们的饥饿!你看到三个人对同一个现实,也就是“食物短缺”有不同的看法了吗?他们看待现实的方式就是他们的范式。你可以把范式想象成一种在特定背景下看待现实的思维模式。通常有多种范例,让一个人以不同的观点看待同一现实。例如,一个既是慈善家又是政治家的人将有能力以不同的方式看待“食物短缺”问题及其解决方案,一个人有他们的政治思维,一个人有他们的慈善家思维。三个人遇到了同样的问题。他们都提供了解决问题的方法。然而,他们对问题及其解决方案的看法并不相同。我们可以将术语“范式”定义为一组概念和想法,它们构成了一种看待现实的方式。

无论如何,我们为什么需要为一个范例而烦恼呢?如果一个人使用他们的政治、慈善或精神范式来找到解决方案,这有关系吗?最终我们找到了解决问题的方法。不是吗?

仅仅有解决问题的方法是不够的。解决方案必须切实有效。因为问题的解决方案总是与思考问题和解决方案的方式相关,所以范式变得至关重要。你可以看到,和尚提供的解决方案可能会在饥饿的人们得到任何帮助之前杀死他们。慈善家的解决方案可能是一个不错的短期解决方案。这位政治家的解决方案似乎是一个长期的解决方案,也是最好的方案。使用正确的范例来解决一个问题,以得到一个实用的和最有效的解决方案,这总是很重要的。请注意,一种范式不可能是解决所有问题的正确范式。例如,如果一个人在寻求永恒的幸福,他们需要咨询僧侣,而不是政治家或慈善家。

这是著名的计算机科学家 Robert W. Floyd 对术语“编程范式”的定义。他在 1978 年 ACM 图灵奖题为“编程范例”的演讲中给出了这个定义:

编程范例是一种概念化的方式,它意味着执行计算,以及在计算机上执行的任务应该如何构造和组织。

您可以观察到,在编程环境中,单词“paradigm”的含义与日常生活中使用的含义相似。编程用于使用计算机提供的计算模型来解决现实世界的问题。编程范式是您思考和概念化真实世界问题及其在底层计算模型中的解决方案的方式。在您开始使用编程语言编写程序之前,编程范式就已经出现了。这是在分析阶段,当你使用一个特定的范式,以一种特定的方式分析一个问题及其解决方案。编程语言提供了一种适当实现特定编程范例的方法。一种编程语言可以提供使其适合使用一种编程范例而不适合另一种编程范例的特性。

一个程序有两个组成部分——数据和算法。数据用来表示信息片段。算法是对数据进行操作以得出问题解决方案的一组步骤。不同的编程范例涉及通过以不同的方式组合数据和算法来查看问题的解决方案。编程中使用了许多范例。以下是一些常用的编程范例:

  • 命令范式

  • 程序范式

  • 语句范式

  • 功能范式

  • 逻辑范式

  • 面向对象的范例

命令范式

命令式范式也称为算法范式。在命令式范例中,程序由数据和操纵数据的算法(命令序列)组成。特定时间点的数据定义了程序的状态。当命令按特定顺序执行时,程序的状态会发生变化。数据存储在内存中。命令式编程语言提供了引用内存位置的变量、改变变量值的赋值操作以及控制程序流程的其他结构。在命令式编程中,您需要指定解决问题的步骤。

假设你有一个整数,比如说15,你想给它加 10。你的方法是将 1 到 15 相加 10 次,你得到结果,25。你可以用命令式语言写一个程序,把 10 加到 15,如下。请注意,您不需要理解以下代码的语法。试着感受一下:

int num = 15;              // num holds 15 at this point
int counter = 0;           // counter holds 0 at this point
while (counter < 10) {
    num = num + 1;         // Modifying data in num
    counter = counter + 1; // Modifying data in counter
}
// num holds 25 at this point

前两行是变量声明,表示程序的数据部分。while循环代表程序中对数据进行操作的算法部分。循环中的代码被执行十次。在每次迭代中,循环将存储在num变量中的数据递增 1。当循环结束时,它将num的值增加了 10。请注意,命令式编程中的数据是暂时的,而算法是永久的。FORTRAN、COBOL 和 C 是支持命令式范例的编程语言的几个例子。

程序范式

过程范式类似于命令范式,但有一点不同:它将多个命令组合在一个称为过程的单元中。过程作为一个单元执行。执行包含在过程中的命令被称为调用或调用过程。过程语言中的程序由数据和一系列操作数据的过程调用组成。下面这段代码是一个名为addTen的程序的典型代码:

void addTen(int num) {
    int counter = 0;
    while (counter < 10) {
        num = num + 1;          // Modifying data in num
        counter = counter + 1;  // Modifying data in counter
    }
    // num has been incremented by 10
}

addTen过程使用一个占位符(也称为参数)num,它是在执行时提供的。该代码忽略了num的实际值。它只是在num的当前值上加 10。让我们用下面这段代码把 10 加到 15。请注意,addTen程序的代码和以下代码不是使用任何特定的编程语言编写的。这里提供它们只是为了说明的目的:

int x = 15; // x holds 15 at this point
addTen(x);  // Call addTen procedure that will increment x by 10
            // x holds 25 at this point

您可能会注意到命令式范例中的代码和过程式范例中的代码在结构上是相似的。使用过程产生模块化代码并增加算法的可重用性。有些人忽略了这种差异,将命令式和程序式这两种范式视为相同。请注意,即使它们不同,过程范式也总是包含命令范式。在过程范式中,编程的单位不是一系列命令。相反,您将一系列命令抽象成一个过程,而您的程序由一系列过程组成。手术有副作用。它在执行逻辑时修改程序的数据部分。C、C++、Java 和 COBOL 是支持过程范式的编程语言的几个例子。

语句范式

在声明式范例中,程序由问题的描述组成,计算机找到解决方案。这个程序没有具体说明如何解决这个问题。当一个问题被描述给计算机时,它的工作就是得出一个解决方案。对比声明性范式和命令性范式。在命令式范式中,我们关心的是问题的“如何”部分。在声明性范例中,我们关心问题的“是什么”部分。我们关心的是问题是什么,而不是如何解决它。接下来描述的功能范式和逻辑范式是声明性范式的子类型。

使用结构化查询语言(SQL)编写数据库查询属于基于声明性范例的编程,在声明性范例中,您指定想要的数据,数据库引擎计算出如何为您检索数据。与命令式范式不同,在声明式范式中,数据是永久的,而算法是瞬时的。在命令式范例中,数据随着算法的执行而被修改。在声明性范例中,数据作为输入提供给算法,并且输入数据在算法执行时保持不变。该算法产生新数据,而不是修改输入数据。换句话说,在声明性范例中,算法的执行不会产生副作用。

功能范式

函数范式是基于数学函数的概念。您可以将函数想象成一种算法,它从一些给定的输入中计算出一个值。与过程式编程中的过程不同,函数没有副作用。在函数式编程中,值是不可变的。

通过对输入值应用函数来导出新值。输入值不会改变。函数式编程语言不使用用于修改数据的变量和赋值。在命令式编程中,使用循环结构执行重复的任务,例如,while循环。在函数式编程中,使用递归来执行重复的任务,这是一种根据函数本身来定义函数的方法。换句话说,递归函数做一些工作,然后调用自己。

当一个函数应用于相同的输入时,它总是产生相同的输出。可以应用于整数x以将整数n加到其上的函数,比如说add,可以定义如下:

int add(x, n) {
    if (n == 0) {
        return x;
    } else {
        return 1 + add(x, n-1); // Apply the add function recursively
    }
}

注意,add函数不使用任何变量,也不修改任何数据。它使用递归。您可以调用add函数将 10 加到 15,如下所示:

add(15, 10); // Results in 25

Haskell、Erlang 和 Scala 是支持函数范式的编程语言的几个例子。

Tip

Java SE 8 增加了一个新的语言结构,叫做 lambda expressions ,可以用来用 Java 编写函数式编程风格的代码。

逻辑范式

与命令式范式不同,逻辑范式关注的是问题的“是什么”部分,而不是如何解决它。你需要指定的只是需要解决的问题。程序会找出算法来解决它。算法对程序员来说不太重要。程序员的主要任务是尽可能地描述问题。在逻辑范式中,程序由一组公理和一个目标语句组成。公理集是构成理论的事实和推理规则的集合。目标语句是一个定理。该程序使用演绎来证明理论中的定理。逻辑编程使用集合论中一个叫做关系的数学概念。集合论中的关系被定义为两个或更多集合的笛卡尔积的子集。假设有两个集合,PersonsNationality,定义如下:

Person = {John, Li, Ravi}
Nationality = {American, Chinese, Indian}

两个集合的笛卡儿积表示为Person x Nationality,是另一个集合,如下所示:

Person x Nationality = {{John, American}, {John, Chinese}, {John, Indian},
                        {Li, American}, {Li, Chinese}, {Li, Indian},
                        {Ravi, American}, {Ravi, Chinese}, {Ravi, Indian}}

Person x Nationality的每个子集都是另一个定义数学关系的集合。一个关系的每个元素被称为一个元组。设PersonNationality为如下定义的关系:

PersonNationality = {{John, American}, {Li, Chinese}, {Ravi, Indian}}

在逻辑编程中,您可以使用PersonNationality关系作为已知为真的事实的集合。你可以这样语句目标语句(或问题)

PersonNationality(?, Chinese)

意思是“给我所有中国人的名字。”该程序将搜索PersonNationality关系,并提取匹配的元组,这些元组将是您的问题的答案(或解决方案)。在这种情况下,答案将是Li

Prolog 是支持逻辑范例的编程语言的一个例子。

面向对象的范例

在面向对象(OO)范例中,程序由相互作用的对象组成。对象封装了数据和算法。数据定义了对象的状态。算法定义了对象的行为。一个对象通过向其他对象发送消息来与它们通信。当一个对象收到一个消息时,它通过执行它的一个算法来响应,这可能会修改它的状态。将面向对象的范例与命令式和函数式范例进行对比。在命令式和函数式范例中,数据和算法是分离的,而在面向对象的范例中,数据和算法是不分离的;它们被组合在一个实体中,这个实体被称为对象。

类是面向对象范例中编程的基本单位。相似的对象被分组到一个定义中,称为类。类的定义用于创建对象。对象也称为类的实例。一个类由实例变量和方法组成。对象的实例变量的值定义了对象的状态。一个类的不同对象分别维护它们的状态。也就是说,类的每个对象都有自己的实例变量副本。对象的状态对该对象是私有的。也就是说,不能从对象外部直接访问或修改对象的状态。类中的方法定义了它的对象的行为。方法就像过程范式中的过程(或子例程)。方法可以访问/修改对象的状态。通过调用一个对象的方法将消息发送给该对象。

假设你想在你的程序中表现真实世界的人。您将创建一个Person类,它的实例将代表您程序中的人。可以如清单 1-1 所示定义Person类。这个例子使用了 Java 编程语言的语法。此时,您不需要理解您正在编写的程序中使用的语法;我将在后续章节中讨论定义类和创建对象的语法。

package com.jdojo.concepts;
public class Person {
    private String name;
    private String gender;
    public Person(String initialName, String initialGender) {
        name = initialName;
        gender = initialGender;
    }
    public String getName() {
        return name;
    }
    public void setName(String newName) {
        name = newName;
    }
    public String getGender() {
        return gender;
    }
}

Listing 1-1The Definition of a Person Class Whose Instances Represent Real-World Persons in a Program

Person类包括三样东西:

  • 两个实例变量 : namegender

  • 一名建造师 : Person(String initialName, String initialGender)

  • 三种方法 : getName()setName(String newName)getGender()

实例变量存储对象的内部数据。每个实例变量的值表示对象的相应属性的值。每个Person类的实例都有一个namegender数据的副本。对象在某一时间点的所有属性值(存储在实例变量中)共同定义了该对象在该时间点的状态。在现实世界中,一个人拥有许多属性,例如,姓名、性别、身高、体重、头发颜色、地址、电话号码等。然而,当您将现实世界中的人建模为一个类时,您只需要包括那些与被建模的系统相关的人的属性。在当前的演示中,让我们只对现实世界中一个人的两个属性——?? 和 ??——建模,作为Person类中的两个实例变量。

一个类包含对象的定义(或蓝图)。需要有一种方法来构造(创建或实例化)一个类的对象。对象还需要有其属性的初始值,这些初始值将决定其创建时的初始状态。类的构造器用于创建该类的对象。一个类可以有许多构造器,以便于创建具有不同初始状态的对象。Person类提供了一个构造器,允许您通过指定namegender的初始值来创建它的对象。下面的代码片段创建了两个Person类的对象:

Person john = new Person("John Jacobs", "Male");
Person donna = new Person("Donna Duncan", "Female");

第一个对象被称为john,其"John Jacobs""Male"分别作为其namegender属性的初始值。第二个对象被称为donna,分别用"Donna Duncan""Female"作为其namegender属性的初始值。

类的方法代表了它的对象的行为。比如在现实世界中,一个人是有名字的,当被问到名字时他们的反应能力就是他们的行为之一。Person类的对象能够响应三种不同的消息:getNamesetNamegetGender。对象响应消息的能力是使用方法实现的。你可以给一个Person对象发送一条消息,比如说getName,它会通过返回它的名字来响应。这就像问“你叫什么名字?”让对方告诉你他们的名字:

String johnName = john.getName();   // Send getName message to john
String donnaName = donna.getName(); // Send getName message to donna

发送给Person对象的setName消息要求将当前名称更改为新名称。以下代码片段将donna对象的名称从"Donna Duncan"更改为"Donna Jacobs":

donna.setName("Donna Jacobs");

如果此时将getName消息发送给donna对象,它将返回"Donna Jacobs",而不是“唐娜·邓肯”。

您可能会注意到您的Person对象没有能力响应像setGender这样的消息。人对象的性别是在对象创建时设置的,以后不能更改。但是,您可以通过向一个Person对象发送getGender消息来查询它的性别。对象可以(或不可以)响应什么消息是在设计时根据被建模系统的需要决定的。在Person对象的例子中,我们认为它们没有能力通过在Person类中不包含setGender(String newGender)方法来响应setGender消息。图 1-3 显示了名为johnPerson对象的状态和接口。

img/323069_3_En_1_Fig3_HTML.png

图 1-3

人对象的状态和接口

面向对象的范式是一种非常强大的范式,用于在计算模型中对现实世界的现象进行建模。在日常生活中,我们习惯于和周围的物体打交道。面向对象的范例是自然而直观的,因为它让您从对象的角度来思考。然而,它并没有给你正确思考事物的能力。有时,问题的解决方案不属于面向对象范例的范畴。在这种情况下,您需要使用最适合问题领域的范例。面向对象的范例有一个学习曲线。它不仅仅是在你的程序中创建和使用对象。抽象、封装、多态和继承是面向对象范例的一些重要特征。为了充分利用面向对象的范例,您必须理解并能够使用本书涵盖的这些特性。在后续章节中,我们将详细讨论这些特性以及如何在程序中实现它们。

仅举几个例子,C++、Java 和 C#(发音为“C sharp”)都是支持面向对象范例的编程语言。注意,编程语言本身并不是面向对象的。它是面向对象的范例。编程语言可能有也可能没有支持面向对象范例的特性。

Java 是什么?

Java 是一种通用编程语言。它具有支持基于面向对象、过程和函数范例的编程的特性。你经常会读到“Java 是一种面向对象的编程语言”这样的语句。这意味着 Java 语言具有支持面向对象范例的特性。编程语言不是面向对象的。它是面向对象的范例,而编程语言可能具有使实现面向对象范例变得容易的特性。有时候,程序员会有这样的误解,认为所有用 Java 编写的程序都是面向对象的。Java 还具有支持过程和函数范例的特性。你可以用 Java 写一个 100%过程化的程序,其中没有一点面向对象的成分。

Java 平台的最初版本是由 Sun Microsystems(自 2010 年 1 月起成为甲骨文公司的一部分)在 1995 年发布的。Java 编程语言的开发始于 1991 年。最初,这种语言被称为 Oak,意在用于电视机顶盒。

发布后不久,Java 成为一种非常流行的编程语言。它受欢迎的最重要的特征之一是它的“一次编写,随处运行”(WORA)特征。这个特性让您只需编写一次 Java 程序,就可以在任何平台上运行。例如,您可以在 UNIX 上编写和编译 Java 程序,并在 Microsoft Windows、Macintosh 或 UNIX 机器上运行它,而无需对源代码进行任何修改。WORA 是通过将 Java 程序编译成称为字节码的中间语言来实现的。字节码的格式是独立于平台的。一个称为 Java 虚拟机(JVM)的虚拟机用于在每个平台上运行字节码。注意,JVM 是一个用软件实现的程序。它不是物理机器,这就是它被称为“虚拟”机器的原因。JVM 的工作是根据它运行的平台将字节码转换成可执行代码。这个特性使得 Java 程序与平台无关。也就是说,同一个 Java 程序无需任何修改就可以在多个平台上运行。

以下是 Java 在软件行业中受欢迎和被接受背后的一些特征:

  • 简单

  • 各种各样的使用环境

  • 稳健性

在这种情况下,简单可能是一个主观的词。在 Java 发布的时候,C++是软件行业广泛使用的流行而强大的编程语言。如果你是一名 C++程序员,Java 将为你提供比 C++更简单的学习和使用体验。Java 保留了 C/C++的大部分语法,这对试图学习这种新语言的 C/C++程序员很有帮助。更好的是,它排除了 C++中一些最令人困惑和难以正确使用的特性(尽管功能强大)。例如,Java 没有指针和多重继承,而这些在 C++中都有。

如果你正在学习 Java 作为你的第一编程语言,它是否是一门简单的语言对你来说可能不是真的。这就是为什么我们说 Java 或者任何编程语言的简单性都是非常主观的原因。自从第一次发布以来,Java 语言及其库(一组包含 Java 类的包)一直在增长。为了成为一名真正的 Java 开发者,你需要付出一些认真的努力。

Java 可以用来开发可以在不同环境下使用的程序。你可以用 Java 编写能在客户机-服务器环境中使用的程序。Java 程序早期最流行的用途是开发小程序,这在 Java SE 9 中已被弃用。applet 是嵌入在网页中的 Java 程序,它使用超文本标记语言(HTML ),并在诸如 Firefox、Google Chrome 等网络浏览器中显示。applet 的代码存储在 web 服务器上,当浏览器加载包含 applet 引用的 HTML 页面时,下载到客户机上,并在客户机上运行。

Java 包含了一些使开发分布式应用程序变得容易的特性。分布式应用程序由运行在通过网络连接的不同机器上的程序组成。Java 的一些特性使得开发并发应用程序变得很容易。一个并发应用有多个并行运行的交互线程(一个线程就像一个程序中的独立进程,有自己的值和处理,独立于其他线程)。

程序的健壮性是指它合理处理意外情况的能力。程序中的意外情况也称为错误。Java 通过在程序生命周期的不同阶段提供许多错误检查特性来提供健壮性。以下是 Java 程序中可能出现的三种不同类型的错误:

  • 编译时错误

  • 运行时错误

  • 逻辑错误

编译时错误也称为语法错误。它们是由 Java 语言语法的不正确使用引起的。它们被 Java 编译器检测到。有编译时错误的程序在错误被纠正之前不会编译成字节码。语句末尾缺少分号,将十进制值(如 10.23)赋给整数类型的变量,等等。都是编译时错误的例子。

Java 程序运行时会出现运行时错误。这种错误不会被编译器检测到,因为编译器没有所有可用的运行时信息。Java 是一种强类型语言,它在编译时和运行时都有强大的类型检查功能。Java 提供了一种简洁的异常处理机制来处理运行时错误。当 Java 程序中出现运行时错误时,JVM 会抛出一个异常,程序可以捕捉并处理这个异常。例如,将整数除以零(如17/0)会产生运行时错误。Java 通过提供自动内存分配和释放的内置机制,避免了严重的运行时错误,如内存溢出和内存泄漏。自动内存释放的特性被称为垃圾收集。

逻辑错误是程序中最关键的错误,而且很难发现。它们是由程序员通过不正确地实现功能需求而引入的。Java 编译器或 Java 运行时无法检测到这种错误。当应用程序测试人员或用户将程序的实际行为与其预期行为进行比较时,他们会发现这些错误。有时,一些逻辑错误会潜入生产环境中,甚至在应用程序退役后也不会被注意到。

程序中的错误被称为 bug 。在程序中发现并修复错误的过程被称为调试。所有现代集成开发环境(ide),如 NetBeans、Eclipse、JDeveloper 和 IntelliJ IDEA,都为程序员提供了一种叫做调试器的工具,让他们一步一步地运行程序,并在每一步检查程序的状态以检测错误。调试是程序员日常活动的现实。如果你想成为一名优秀的程序员,你必须学习并善于使用你用来开发 Java 程序的开发工具中的调试器。

面向对象的范例和 Java

面向对象范式支持四大原则:抽象封装继承多态。它们也被称为面向对象范例的四大支柱。抽象是暴露一个实体的基本细节,同时忽略不相关的细节,以减少用户的复杂性的过程。封装是将数据和对数据的操作捆绑在一个实体中的过程。继承用于从现有类型派生新类型,从而建立父子关系。多态让一个实体在不同的上下文中有不同的含义。这四项原则将在接下来的章节中详细讨论。

抽象

程序为现实世界的问题提供了解决方案。程序的大小可能从几行到几百万行不等。它可以写成一个从第一行到第一百万行的整体结构。如果一个完整的程序超过 25-50 行,那么它将变得难以编写、理解和维护。为了更容易维护,一个大的整体程序必须分解成更小的子程序。子程序然后被组合在一起解决原来的问题。分解程序时必须小心。所有的子程序都必须足够简单和小,能够被它们自己理解,当它们被汇编时,它们必须解决原始的问题。让我们考虑对设备的以下要求:

设计并开发一种设备,让用户使用所有英文字母、数字和符号来键入文本。

设计这种设备的一种方法是提供一种键盘,该键盘具有用于所有字母、数字和符号的所有可能组合的键。这种解决方案是不合理的,因为设备的尺寸将是巨大的。你可能意识到我们正在谈论设计一个键盘。看看你的键盘,看看它是如何设计的。它将输入文本的问题分解为一次输入一个字母、一个数字或一个符号,这代表了原始问题的一小部分。如果您可以一次键入所有字母、所有数字和所有符号,则可以键入任意长度的文本。

原始问题的另一个分解可以包括两个键:一个键用于键入水平线,另一个键用于键入垂直线,用户可以使用这两个键来键入ETIFHL,因为这些字母仅由水平线和垂直线组成。使用这种解决方案,用户只需两个键的组合就可以键入六个字母。但是,根据您使用键盘的经验,您可能会意识到,分解按键以使一个按键仅用于输入字母的一部分并不是一个合理的解决方案,尽管这是一个解决方案。

为什么提供两个键来键入六个字母不是一个合理的解决方案?我们不是在节省空间和键盘上的键数吗?在这种情况下,“合理”一词的使用是相对的。从纯粹主义者的角度来看,这可能是一个合理的解决方案。我称之为“不合理”的理由是它不容易被用户理解。它向用户暴露了比需要的更多的细节。用户必须记住水平线位于T的顶部和L的底部。当用户为每个字母获得单独的密钥时,他们不必处理这些细节。重要的是,为部分原始问题提供解决方案的子程序必须被简化,以具有相同的细节水平,从而无缝地协同工作。同时,子程序不应该公开不需要知道的细节以便使用。

最后,所有的键都安装在一个键盘上,它们可以单独更换。如果一把钥匙坏了,它可以被替换,而不用担心其他钥匙。类似地,当程序被分解成子程序时,子程序中的修改不应该影响其他子程序。子程序还可以通过关注不同层次的细节而忽略其他细节来进一步分解。一个好的程序分解旨在提供以下特征:

  • 简单

  • 隔离

  • 可维护性

每个子程序都应该足够简单,便于自己理解。简单是通过关注相关的信息,忽略不相关的信息来实现的。哪些信息是相关的,哪些是不相关的,这取决于上下文。

每个子程序都应该与其他子程序隔离开来,以便子程序中的任何更改都应该具有局部影响。一个子程序中的更改不应影响任何其他子程序。子程序定义了与其他子程序交互的接口。子程序的内部细节对外界是隐藏的。只要子程序的接口保持不变,其内部细节的变化就不会影响与其交互的其他子程序。

每个子程序都应该足够小,以便于编写、理解和维护。

所有这些特征都是在一个问题(或解决一个问题的程序)的分解过程中通过一个叫做抽象的过程实现的。抽象是一种对问题进行分解的方法,它关注相关的细节,忽略特定上下文中与问题无关的细节。请注意,没有一个问题的细节是不相关的。换句话说,问题的每个细节都是相关的。然而,一些细节可能在一个上下文中相关,而一些在另一个上下文中相关。需要注意的是,是“上下文”决定了哪些细节是相关的,哪些是不相关的。例如,考虑设计和开发键盘的问题。从用户的角度来看,键盘由可以按下和释放以键入文本的键组成。键的数量、类型、大小和位置是与键盘用户相关的唯一细节。然而,按键并不是键盘的唯一细节。键盘有一个电子电路,它与电脑相连。当用户按键时,键盘和计算机内部会发生很多事情。键盘的内部工作与键盘设计者和制造商有关。然而,它们与键盘用户无关。你可以说不同的用户在不同的语境下对同一件事有不同的看法。关于事物的哪些细节是相关的,哪些是不相关的,这取决于用户和上下文。

抽象是指考虑在特定环境中以适当的方式看待问题所必需的细节,并忽略(隐藏、抑制或忘记)不必要的细节。抽象上下文中的“隐藏”和“抑制”等术语可能会产生误导。这些术语可能意味着隐藏问题的一些细节。抽象是关于一个事物的哪些细节应该被考虑,哪些不应该为了一个特定的目的而被考虑。这确实意味着隐藏细节。东西是如何隐藏的是另一个叫做信息隐藏的概念,这将在下一节讨论。

术语“抽象”用于表示两个事物之一:过程或实体。作为一个过程,它是一种技术,提取关于一个问题的相关细节,忽略不相关的细节。作为一个实体,它是对一个问题的特定观点,考虑一些相关的细节,忽略不相关的细节。

隐藏复杂性的抽象

我们来讨论一下抽象在现实编程中的应用。假设你想写一个程序来计算两个整数之间所有整数的和。假设您想计算 10 到 20 之间所有整数的和。你可以这样写程序。如果您不理解本节程序中使用的语法,请不要担心。试着理解抽象是如何被用来分解程序的:

int sum = 0;
int counter = 10;
while (counter <= 20) {
    sum = sum + counter;
    counter = counter + 1;
}
System.out.println(sum);

这段代码将添加10 + 11 + 12 + ... + 20并打印165。假设您想计算4060之间所有整数的和。以下是实现这一目标的计划:

int sum = 0;
int counter = 40;
while (counter <= 60) {
    sum = sum + counter;
    counter = counter + 1;
}
System.out.println(sum);

这段代码将对4060之间的所有整数求和,并打印出1050。请注意这两段代码的相似之处和不同之处。两者的逻辑是一样的。但是,范围的下限和上限是不同的。如果您可以忽略两个代码片段之间存在的差异,您将能够避免两个地方的逻辑重复。让我们考虑下面的代码片段:

int sum = 0;
int counter = lowerLimit;
while (counter <= upperLimit) {
    sum = sum + counter;
    counter = counter + 1;
}
System.out.println(sum);

这一次,您没有使用任何范围的下限和上限的任何实际值。相反,您使用了在编写代码时未知的lowerLimitupperLimit占位符。通过在代码中使用两个占位符,您隐藏了范围下限和上限的标识。换句话说,在编写这段代码时,您忽略了它们的实际值。您在代码中应用了抽象过程,忽略了范围的下限和上限的实际值。

当这段代码被执行时,实际值必须被替换为lowerLimitupperLimit占位符。在编程语言中,这是通过将代码片段打包在一个称为过程的模块(子例程或子程序)中实现的。占位符被定义为该过程的形式参数。清单 1-2 有这样一个程序的代码。

int getRangeSum(int lowerLimit, int upperLimit) {
    int sum = 0;
    int counter = lowerLimit;
    while (counter <= upperLimit) {
        sum = sum + counter;
        counter = counter + 1;
    }
    return sum;
}

Listing 1-2A Procedure Named getRangeSum to Compute the Sum of All Integers Between Two Integers

一个过程有一个名字,在这个例子中是getRangeSum。过程有一个返回类型,在它的名字前面指定。返回类型指示它将返回给调用者的值的类型。

在这种情况下,返回类型是int,这表明计算的结果将是一个整数。

一个过程有形参(可能是零),这些形参在名字后面的括号中指定。形参由数据类型和名称组成。在这种情况下,形参被命名为lowerLimitupperLimit,两者的数据类型都是int。它有一个主体,放在大括号内。过程的主体包含逻辑。

当您想要执行某个过程的代码时,您必须传递其形参的实际值。您可以计算并打印出1020之间所有整数的和,如下所示:

int s1 = getRangeSum(10, 20);
System.out.println(s1);

这段代码将打印165。要计算4060之间所有整数的总和,您可以执行以下代码片段:

int s2 = getRangeSum(40, 60);
System.out.println(s2);

这段代码将打印出1050,这与您之前获得的结果完全相同。

你在定义getRangeSum过程中使用的抽象方法被称为参数化抽象。过程中的形参用于隐藏过程主体操作的实际数据的身份。getRangeSum过程中的两个参数隐藏了整数范围的下限和上限。现在你已经看到了抽象的第一个具体例子。抽象是一个庞大的话题。在这一节中,我将讲述更多关于抽象的基础知识。

假设一个程序员编写了getRangeSum过程的代码,如清单 1-2 所示,另一个程序员想要使用它。第一个程序员是程序的设计者和编写者;第二个是过程的用户。使用getRangeSum程序的用户需要知道哪些信息?

在回答这个问题之前,让我们考虑一个设计和使用 DVD(数字多功能光盘)播放器的真实例子。DVD 播放器是由电子工程师设计开发的。你如何使用 DVD 播放器?在你使用 DVD 播放器之前,你不需要打开它来研究它的基于电子工程理论的所有细节。当你买它的时候,它有一本如何使用它的手册。一个 DVD 播放器被包装在一个盒子里。盒子里面藏着玩家的详细资料。同时,盒子以接口的形式向外界暴露了关于播放器的一些细节。DVD 播放器的界面由以下项目组成:

  • 输入和输出连接端口,用于连接电源插座、电视机等。

  • 插入 DVD 的面板

  • 执行弹出、播放、暂停、快进等操作的一组按钮。

DVD 播放机附带的手册描述了为用户提供的播放机界面的用法。DVD 用户不需要担心其内部工作的细节。手册还描述了操作它的一些条件。例如,在使用之前,您必须将电源线插入电源插座并打开电源。

程序的设计、开发和使用方式与 DVD 播放器相同。清单 1-2 中所示程序的用户无需担心用于实现程序的内部逻辑。程序的用户只需要知道它的用法,包括使用它的界面,以及在使用它之前和之后必须满足的条件。换句话说,你需要提供一份描述其用法的getRangeSum程序手册。使用getRangeSum程序的用户需要阅读其手册。一个程序的“手册”就是它的规格说明书。有时它也被称为文档或注释。它提供了另一种抽象方法,称为规范抽象。它描述(或公开或关注)程序的“是什么”部分,并对用户隐藏(或忽略或隐藏)程序的“如何”部分。

清单 1-3 显示了与其规格相同的getRangeSum程序代码。

/**
 * Computes and returns the sum of all integers between two
 * integers specified by lowerLimit and upperLimit parameters.
 *
 * The lowerLimit parameter must be less than or equal to the
 * upperLimit parameter. If the sum of all integers between the
 * lowerLimit and the upperLimit exceeds the range of the int data
 * type then result is not defined.
 *
 * @param lowerLimit The lower limit of the integer range
 * @param upperLimit The upper limit of the integer range
 * @return The sum of all integers between lowerLimit (inclusive)
 *         and upperLimit (inclusive)
 */
public static int getRangeSum(int lowerLimit, int upperLimit) {
    int sum = 0;
    int counter = lowerLimit;
    while (counter <= upperLimit) {
        sum = sum + counter;
        counter = counter + 1;
    }
    return sum;
}

Listing 1-3The getRangeSum Procedure with Its Specification for the Javadoc Tool

Javadoc 标准用于编写 Java 程序的规范,Javadoc 工具可以处理该规范以生成 HTML 页面。在 Java 中,程序元素的规范被放在元素前的/***/之间。该规范是为getRangeSum程序的用户准备的。Javadoc 工具将为getRangeSum过程生成规范,如图 1-4 所示。

img/323069_3_En_1_Fig4_HTML.png

图 1-4

getRangeSum 过程的规范

该规范提供了 getRangeSum 过程的描述(“什么”部分)。它还指定了两个条件,称为前置条件,在调用过程时这两个条件必须为真。第一个前提条件是下限必须小于或等于上限。第二个先决条件是,下限和上限的值必须足够小,以便它们之间的所有整数之和符合int数据类型的大小。它指定了另一个条件,称为后置条件,在“Returns”子句中指定。只要前提条件成立,后置条件就成立。前置条件和后置条件就像程序和用户之间的契约(或协议)。它声明,只要程序的用户确保前置条件成立,程序就保证后置条件成立。请注意,规范从未告诉用户程序如何实现(实现细节)后置条件。它只告诉“什么”它要做,而不是“如何”它要做。拥有规范的getRangeSum程序的用户不需要查看getRangeSum过程的主体来找出它使用的逻辑。换句话说,您向用户提供了这个规范,从而隐藏了getRangeSum过程的实现细节。也就是说,getRangeSum过程的用户可以为了使用它而忽略它的实现细节。这是抽象的另一个具体例子。通过使用规范来隐藏子程序的实现细节(“如何”部分)并公开其用法(“做什么”部分)的方法被称为通过规范的抽象

参数化抽象和规范抽象让程序的用户把程序看作一个黑盒,他们只关心程序产生的效果,而不关心程序如何产生这些效果。图 1-5 描述了getRangeSum程序的用户视图。注意,用户看不到(也不需要看到)包含细节的过程主体。细节只与程序的作者有关,与用户无关。

img/323069_3_En_1_Fig5_HTML.png

图 1-5

用户将 getRangeSum 过程视为使用抽象的黑盒

通过应用抽象来定义getRangeSum过程,您获得了哪些优势?最重要的优势之一就是隔离。它与其他程序相隔离。如果你修改了它主体内部的逻辑,其他程序,包括正在使用它的程序,都不需要修改。要打印1020之间的整数之和,可以使用以下程序:

int s1 = getRangeSum(10, 20);
System.out.println(s1);

该过程的主体使用一个while循环,循环的执行次数与上限和下限之间的整数次数一样多。getRangeSum程序内的while循环执行n次,其中n等于(upperLimit – lowerLimit + 1)。需要执行的指令数量取决于输入值。有一种更好的方法来计算两个整数lowerLimitupperLimit之间所有整数的和,使用下面的公式:

n = upperLimit - lowerLimit + 1;
sum = n * (2 * lowerLimit + (n-1))/2;

如果使用这个公式,计算两个整数之间所有整数之和所执行的指令数总是相同的。你可以重写getRangeSum过程的主体,如清单 1-4 所示。此处未显示getRangeSum程序的规格。

public int getRangeSum(int lowerLimit, int upperLimit) {
    int n = upperLimit - lowerLimit + 1;
    int sum = n * (2 * lowerLimit + (n-1))/2;
    return sum;
}

Listing 1-4Another Version of the getRangeSum Procedure with the Logic Changed Inside Its Body

注意清单 1-3 和清单 1-4 之间的getRangeSum过程的主体(实现或“如何”部分)已经改变。然而,getRangeSum过程的用户不会受到这一变化的影响,因为通过使用抽象,该过程的实现细节对其用户是隐藏的。如果您想使用清单 1-4 中所示的getRangeSum过程版本计算 10 到 20 之间所有整数的和,您的旧代码仍然有效:

int s1 = getRangeSum(10, 20);
System.out.println(s1);

您已经看到了抽象的最大好处之一,其中程序的实现细节(在本例中是一个过程)可以被改变,而无需保证使用该程序的代码的任何改变。这个好处也给你一个机会来重写你的程序逻辑,以提高将来的性能,而不影响应用程序的其他部分。

在本节中,我考虑了两种类型的抽象:

  • 过程抽象

  • 数据抽象

过程抽象允许您定义一个过程,例如getRangeSum,您可以将它用作一个动作或一个任务。到目前为止,我一直在讨论过程抽象。参数化抽象和规范抽象是实现过程抽象和数据抽象的两种方法。下一节将详细讨论数据抽象。

数据抽象

面向对象编程基于数据抽象。然而,在讨论数据抽象之前,我需要简单地讨论一下数据类型。数据类型(或简称为类型)由三个部分定义:

  • 一组值(或数据对象)

  • 可以应用于集合中所有值的一组运算

  • 一种数据表示形式,它决定了值的存储方式

编程语言提供了一些预定义的数据类型,称为内置数据类型。他们还让程序员定义他们自己的数据类型,这就是所谓的用户定义的数据类型。由一个不可分割的原子值组成的数据类型(在没有任何其他数据类型帮助的情况下定义)被称为原始数据类型。比如 Java 内置了intfloatbooleanchar等原语数据类型。在 Java 中定义int原始数据类型的三个组件如下:

  • int 数据类型由–2147483648 和 2147483647 之间的所有整数组成。

  • int数据类型定义了加、减、乘、除、比较等操作。

  • int数据类型的值在 32 位存储器中以 2 的补码形式表示。

数据类型的所有三个组成部分都是由 Java 语言预定义的。您不能扩展或重新定义int数据类型的定义。您可以将int数据类型的值命名为

int n1;

该语句说明n1是一个名称(技术上称为标识符),它可以与定义int数据类型的值的值集中的一个值相关联。例如,您可以使用如下赋值语句将整数26与名称n1相关联:

n1 = 26;

在这个阶段,您可能会问,“与名称n1相关联的值26存储在内存中的什么地方?”从int数据类型的定义中可以知道n1将占用 32 位内存。但是,你不知道,不能知道,也不需要知道 32 位在内存中的什么位置分配给了n1。你在这里看到抽象的例子了吗?如果你在这种情况下看到一个抽象的例子,你就对了。这是一个抽象的例子,它内置于 Java 语言中。在这种情况下,关于int数据类型的数据值的数据表示的信息对该数据类型的用户(程序员)是隐藏的。换句话说,程序员忽略了n1的内存位置,而专注于它的值和可以在其上执行的操作。程序员不关心n1的内存是分配在寄存器、RAM 还是硬盘中。

面向对象的编程语言(如 Java)允许您使用称为数据抽象的抽象机制来创建新的数据类型。新的数据类型被称为抽象数据类型(ADT)。ADT 中的数据对象可能由原始数据类型和其他 ADT 的组合组成。ADT 定义了一组可应用于其所有数据对象的操作。数据表示总是隐藏在 ADT 中。对于 ADT 的用户来说,它只包含操作。它的数据元素只能使用它的操作来访问和操作。使用数据抽象的优点是,它的数据表示形式可以更改,而不会影响任何使用 ADT 的代码。

Tip

数据抽象允许程序员创建一种新的数据类型,称为抽象数据类型,其中数据对象的存储表示对数据类型的用户是隐藏的。换句话说,ADT 仅根据可以应用于其类型的数据对象的操作来定义,而无需知道数据的内部表示。这种数据类型被称为抽象的原因是,ADT 的用户永远看不到数据值的表示。用户以抽象的方式查看 ADT 的数据对象,在不知道数据对象表示细节的情况下对它们应用操作。请注意,ADT 并不意味着没有数据表示。数据表示始终存在于 ADT 中。这只意味着对用户隐藏数据表示。

Java 有一些构造,例如类、接口、注释和enum,允许您定义新的 ADT。当您使用一个类来定义一个新的 ADT 时,您需要小心隐藏数据表示,因此您的新数据类型确实是抽象的。如果 Java 类中的数据表示没有被隐藏,那么该类会创建一个新的数据类型,但不会创建 ADT。Java 中的类为您提供了一些特性,您可以使用这些特性来公开或隐藏数据表示。在 Java 中,一个类数据类型的值的集合被称为对象。对对象的操作被称为方法。对象的实例变量(也称为字段)是类类型的数据表示。

Java 中的类允许你实现对数据表示的操作。Java 中的接口允许您创建纯 ADT。接口让您只提供可以应用于其类型的数据对象的操作规范。接口中不能提及操作或数据表示的实现。清单 1-1 显示了使用 Java 语言语法的Person类的定义。通过定义名为Person的类,您已经创建了一个新的 ADT。它对namegender的内部数据表示使用了String数据类型(String是 Java 类库提供的内置 ADT)。请注意,Person类的定义在namegender声明中使用了private关键字来对外界隐藏它们。Person类的用户不能访问namegender数据元素。它提供了四个操作:一个构造器和三个方法(getNamesetNamegetGender)。

构造器操作用于初始化新构造的Person类型的数据对象。getNamesetName操作分别用于访问和修改name数据元素。getGender操作用于访问gender数据元素的值。

Person类的用户必须只使用这四个操作来处理Person类型的数据对象。Person类型的用户不知道用于存储namegender数据元素的数据存储类型。我交替使用三个术语,“类型”、“类”和“接口”,因为它们在数据类型的上下文中表示相同的意思。它给了Person类型的开发人员改变namegender数据元素的数据表示的自由,而不会影响任何Person类型的用户。假设一个Person类型的用户有下面的代码片段:

Person john = new Person("John Jacobs", "Male");
String intialName = john.getName();
john.setName("Wally Jacobs");
String changedName = john.getName();

这段代码只是根据Person类型提供的操作编写的。它没有(也不可能)直接引用namegender实例变量。让我们看看如何在不影响代码片段的情况下改变Person类型的数据表示。清单 1-5 显示了Person类新版本的代码。

package com.jdojo.concepts;
public class Person {
    private String[] data = new String[2];
    public Person(String initialName, String initialGender) {
        data[0] = initialName;
        data[1] = initialGender;
    }
    public String getName() {
        return data[0];
    }
    public void setName(String newName) {
        data[0] = newName;
    }
    public String getGender() {
        return data[1];
    }
}

Listing 1-5Another Version of the Person Class That Uses a String Array of Two Elements to Store Name and Gender Values as Opposed to Two String Variables

比较清单 1-1 和清单 1-5 中的代码。这一次,您用一个包含两个元素的String数组替换了两个实例变量(namegender),它们是清单 1-1 中Person类型的数据表示。因为类中的操作(或方法)是对数据表示进行操作的,所以您必须改变Person类型中所有四个操作的实现。清单 1-5 中的客户端代码是根据四个操作的规范而不是它们的实现编写的。因为您没有更改任何操作的规范,所以您不需要更改使用Person类的代码片段;对于Person类型的新定义,它仍然有效,如清单 1-5 所示。Person类中的一些方法通过参数化使用抽象,所有的方法都通过规范使用抽象。我没有在这里展示方法的规范,这将是 Javadoc 注释。

在本节中,您已经看到了数据抽象的两个主要好处:

  • 它允许您通过定义新的数据类型来扩展编程语言。您创建的新数据类型取决于应用程序域。例如,对于银行系统,PersonCurrencyAccount可能是新数据类型的好选择,而对于汽车保险应用程序,PersonVehicleClaim可能是好选择。新数据类型中包含的操作取决于应用程序的需要。

  • 使用数据抽象创建的数据类型可以改变数据的表示,而不影响使用该数据类型的客户端代码。

封装和信息隐藏

术语封装用来表示两种不同的东西:一个过程或一个实体。作为一个流程,它是将一个或多个项目捆绑到一个容器中的行为。容器可以是物理的,也可以是逻辑的。作为一个实体,它是一个容纳一个或多个项目的容器。

编程语言以多种方式支持封装。过程是执行任务的步骤的封装;数组是几个相同类型元素的封装;等等。在面向对象编程中,封装是将数据和对数据的操作捆绑到一个称为类的实体中。Java 以多种方式支持封装:

  • 它允许您将数据和对数据进行操作的方法捆绑在一个名为的实体中。

  • 它允许您将一个或多个逻辑上相关的类捆绑在一个名为的实体中。Java 中的包是一个或多个相关类的逻辑集合。包创建了一个新的命名范围,其中所有的类都必须有唯一的名称。两个类在 Java 中可以有相同的名字,只要它们被捆绑(或封装)在两个不同的包中。

  • 它允许您将包捆绑到一个在 Java SE 9 中引入的模块中。模块可以导出它的包。其他模块可以访问导出包中定义的类型,而其他模块无法访问非导出包中定义的类型。

  • 它允许你将一个或多个相关的类捆绑在一个名为编译单元 *的实体中。*一个编译单元中的所有类都可以独立于其他编译单元进行编译。

在讨论面向对象编程的概念时,两个术语——封装信息隐藏——经常互换使用。然而,它们在面向对象编程中是不同的概念,因此不应该互换使用。封装就是将项目捆绑在一起成为一个实体。信息隐藏是隐藏可能改变的实现细节的过程。封装不关心捆绑在实体中的项目是否对应用程序中的其他模块隐藏。什么应该被隐藏(或忽略)和什么不应该被隐藏是抽象的关注点。抽象只关心应该隐藏哪一项。抽象不关心项目应该如何隐藏。信息隐藏关注的是如何隐藏一个项目。

封装、抽象和信息隐藏是三个独立的概念。不过,它们的关系非常密切。一个概念促进了其他概念的工作。理解它们在面向对象编程中所扮演角色的细微差别是很重要的。

Tip

在 Java SE 中,您会经常遇到类似“一个模块提供了强大的封装”这样的语句。这里,术语封装用于信息隐藏的意义上。这意味着模块中非导出包中的类型对其他模块是隐藏的(或不可访问的)。

可以使用隐藏或不隐藏任何信息的封装。例如,清单 1-1 中的Person类展示了一个封装和信息隐藏的例子。数据元素(namegender)和方法(getName()setName()getGender())被捆绑在一个名为Person的类中。这就是封装。换句话说,Person类是数据元素namegender加上方法getName()setName()getGender()的封装。同一个Person类通过对外界隐藏数据元素来使用信息隐藏。注意namegender数据元素使用 Java 关键字private,这实质上是对外界隐藏了它们。清单 1-6 显示了一个Person2类的代码。

package com.jdojo.concepts;
public class Person2 {
    public String name;   // Not hidden from its users
    public String gender; // Not hidden from its users
    public Person2(String initialName, String initialGender) {
        name = initialName;
        gender = initialGender;
    }
    public String getName() {
        return name;
    }
    public void setName(String newName) {
        name = newName;
    }
    public String getGender() {
        return gender;
    }
}

Listing 1-6The Definition of the Person2 Class in Which Data Elements Are Not Hidden by Declaring Them Public

清单 1-1 中的代码和清单 1-6 中的代码除了两个小的区别之外基本相同。Person2类使用关键字public来声明namegender数据元素。Person2类使用封装的方式与Person类相同。然而,namegender数据元素并未隐藏。也就是说,Person2类不使用数据隐藏(数据隐藏就是信息隐藏的一个例子)。如果你看看PersonPerson2类的构造器和方法,它们的主体使用了信息隐藏,因为写在它们主体内部的逻辑对它们的用户是隐藏的。

Tip

封装和信息隐藏是面向对象编程的两个不同概念。一个的存在并不意味着另一个的存在。

继承

继承是面向对象编程中的另一个重要概念。它让你以一种新的方式使用抽象。在前面的章节中,您已经看到了类是如何表示抽象的。清单 1-1 中显示的Person类代表了现实世界中一个人的抽象。继承机制允许您通过扩展现有的抽象来定义新的抽象。现有的抽象称为超类型、超类、父类或基类。新的抽象被称为子类型、子类、子类或派生类。据说子类型是从超类型派生(或继承)来的,超类型是子类型的泛化,子类型是超类型的特化。继承可以用来在多个层次上定义新的抽象。子类型可以用作超类型来定义另一个子类型,依此类推。继承产生了以层次形式排列的一族类型。

继承允许你在不同层次使用不同程度的抽象。在图 1-6 中,Person类位于继承层次的顶端(最高级别)。CustomerEmployee类位于继承层次的第二层。当您沿着继承层次向下移动时,您会关注更重要的信息。换句话说,在继承的更高层次上,你关注的是更大的图景;在较低层次的继承中,你关心越来越多的细节。从抽象的角度来看,还有另一种方式来看待继承层次。在图 1-6 中的Person关卡,你专注于CustomerEmployee的共同特点,忽略了它们之间的差异。在Employee级别,你关注ClerkProgrammerCashier的共同特征,忽略它们之间的差异。

img/323069_3_En_1_Fig6_HTML.jpg

图 1-6

人员类的继承层次结构

在继承层次结构中,超类型及其子类型代表一种“是-是”关系。即一个Employee是一个Person;一个Programmer是一个Employee;等等。因为较低层次的继承意味着更多的信息,所以子类型总是包含其父类型所拥有的,甚至更多。继承的这一特点导致了面向对象编程中的另一个特点,即所谓的替代性原则。这意味着父类型总是可以被它的子类型替换。例如,在你的Person抽象中,你只考虑了一个人的namegender信息。如果从Person继承EmployeeEmployee包含从Person继承的namegender信息。Employee可能包括更多信息,如员工 ID、雇佣日期、工资等。如果上下文中需要一个Person,这意味着只有namegender信息与该上下文相关。您总是可以用EmployeeCustomerClerkProgrammer替换该上下文中的Person,因为作为Person的子类型(直接或间接),这些抽象保证了它们至少有能力处理namegender信息。

在编程级别,继承提供了代码重用机制。超类型中编写的代码可以被其子类型重用。子类型可以通过添加更多的功能或重新定义其超类型的现有功能来扩展其超类型的功能。

Tip

继承也被用作实现多态的技术,这将在下一节讨论。继承让你编写多态代码。代码是根据超类型编写的,同样的代码也适用于子类型。

继承是一个很大的话题。这本书用了整整一章来讲述如何在 Java 中使用继承。

多态

“多态”一词源于两个希腊词:“poly”(表示许多)和“morphos”(表示形式)。在编程中,多态是一个实体(例如,变量、类、方法、对象、代码、参数等)的能力。)在不同的语境中呈现不同的含义。具有不同含义的实体称为多态实体。存在各种类型的多态。每种类型的多态都有一个名称,通常表明这种类型的多态在实践中是如何实现的。多态的正确使用会产生通用的和可重用的代码。多态的目的是通过编写适用于许多类型(或者理想情况下适用于所有类型)的通用类型来编写可重用和可维护的代码。多态可以分为以下两类:

  • 特定多态

  • 通用多态

如果一段代码适用于有限数量的类型,并且在编写代码时必须知道所有这些类型,这就是所谓的特定多态。特殊多态也被称为表观多态,因为它不是真正意义上的多态。一些计算机科学纯粹主义者根本不认为特别多态是多态。

临时多态进一步分为两类:

  • 重载多态

  • 强制多态

如果一段代码以这样一种方式编写,它适用于无限多种类型(也适用于编写代码时未知的新类型),它被称为通用多态。在通用多态中,相同的代码适用于许多类型,而在专用多态中,为不同的类型提供不同的代码实现,给人一种明显的多态印象。

通用多态进一步分为两类:

  • 包含多态

  • 参数多态

在随后的部分中,我将通过例子详细描述这些类型的多态。

重载多态

重载是一种特殊的多态。当一个方法(在 Java 中称为方法,在其他语言中称为函数)或一个操作符至少有两个作用于不同类型的定义时,就会导致重载。在这种情况下,相同的名称(对于方法或操作符)用于其不同的定义。也就是说,同一个名字表现出许多行为,因此具有多态。这样的方法和运算符称为重载方法和重载运算符。Java 让你定义重载的方法。Java 有一些重载的操作符,但是它不允许为 ADT 重载操作符。也就是说,不能在 Java 中为操作符提供新的定义。清单 1-7 显示了一个名为MathUtil的类的代码。

// MathUtil.java
package com.jdojo.concepts;
public class MathUtil {
    public static int max(int n1, int n2) {
        /* Code to determine the maximum of two integers goes here */
    }
    public static double max(double n1, double n2) {
        /* Code to determine the maximum of two floating-point numbers goes here */
    }
    public static int max(int[] num) {
        /* Code to determine the maximum in an array of int goes here */
    }
}

Listing 1-7An Example of an Overloaded Method in Java

MathUtil类的max()方法被重载。它有三个定义,每个定义都执行计算最大值的相同任务,但是是在不同的类型上。第一个定义最多计算两个int数据类型的数字,第二个定义最多计算两个double数据类型的浮点数,第三个定义最多计算一个int数据类型的数字数组。下面的代码片段使用了重载的max()方法的所有三个定义:

int max1 = MathUtil.max(10, 23);                 // Uses max(int, int)
double max2 = MathUtil.max(10.34, 2.89);         // Uses max(double, double)
int max3 = MathUtil.max(new int[]{1, 89, 8, 3}); // Uses max(int[])

请注意,方法重载只提供了方法名的共享。它不会导致方法定义的共享。在清单 1-7 中,方法名max被所有三个方法共享,但是它们都有自己的计算不同类型的最大值的定义。在方法重载中,方法的定义不必相关。他们可能做完全不同的事情,并分享相同的名字。

下面的代码片段展示了 Java 中运算符重载的一个例子。操作员是+。在下面的三条语句中,它执行三种不同的操作:

int n1 = 10 + 20;              // Adds two integers
double n2 = 10.20 + 2.18;      // Adds two floating-point numbers
String str = "Hi " + "there";  // Concatenates two strings

在第一条语句中,+运算符对两个整数1020执行加法运算,并返回30。在第二条语句中,它对两个浮点数10.202.18进行加法运算,并返回12.38。在第三个语句中,它执行两个字符串“Hi”和“there”的连接,并返回"Hi there"

在重载中,方法的实际参数的类型(在操作符的情况下是操作数的类型)用于确定使用哪个代码定义。方法重载只提供方法名的重用。只需为重载方法的所有版本提供一个唯一的名称,就可以移除方法重载。例如,您可以将max()方法的三个版本重命名为max2Int()max2Double()maxNInt()。请注意,重载方法或运算符的所有版本都不必执行相关或相似的任务。在 Java 中,重载方法名的唯一要求是,该方法的所有版本在形参的数量和/或类型上必须不同。

强制多态

强制是一种特殊的多态。当一种类型自动隐式转换(强制)为另一种类型时,即使不是显式的,也会发生强制。考虑以下 Java 语句:

int num = 707;
double d1 = (double)num; // Explicit conversion of int to double
double d2 = num;         // Implicit conversion of int to double (coercion)

在第一条语句中,变量num被声明为int数据类型,并被赋值为707。第二个语句使用了一个造型(double),将存储在num中的int值转换为double,并将转换后的值赋给一个名为d1的变量。这是从intdouble的显式转换的情况。在这种情况下,程序员通过使用强制转换来明确他们的意图。第三个语句的效果和第二个完全一样;然而,它依赖于 Java 语言提供的隐式转换(在 Java 中称为扩大转换),该语言在需要时自动将int转换为double。第三个语句是一个强迫的例子。编程语言(包括 Java)在不同的上下文中执行不同类型的强制:赋值(如前所示)、方法参数等。

考虑下面的代码片段,它显示了一个square()方法的定义,该方法接受一个double数据类型的参数:

double square(double num) {
    return num * num;
}

可以使用数据类型为double的实际参数调用square()方法,如下所示:

double d1 = 20.23;
double result = square(d1);

同样的square()方法也可以用int数据类型的实参调用,如下所示:

int k = 20;
double result = square(k);

您已经看到了square()方法对doubleint数据类型的参数都有效,尽管您只根据double数据类型的形参定义了它一次。这正是多态的含义。在这种情况下,square()方法被称为关于doubleint数据类型的多态方法。因此,square()方法表现出多态行为,即使编写代码的程序员并不打算这样做。因为 Java 语言提供的隐式类型转换(从intdouble的强制),所以square()方法是多态的。这里有一个多态方法的更正式的定义:

假设 m 是一个声明 T 类型形参的方法,如果 S 是一个可以隐式转换为 T 的类型,则称方法 m 相对于 S 和 T 是多态的

包含多态

包含是一种普遍的多态。它也被称为子类型(或子类)多态,因为它是使用子类型或子类实现的。这是面向对象编程语言支持的最常见的多态类型。Java 支持。

当使用某个类型编写的一段代码适用于它的所有子类型时,就会出现包含多态。根据子类型规则,属于子类型的值也属于父类型,这种类型的多态是可能的。假设T是一个类型,S1S2S3...T的子类型。属于S1S2S3...的一个值也属于T。这种子类型规则使得编写如下代码成为可能:

T t;
S1 s1;
S2 s2;
...
t = s1; // A value of type s1 can be assigned to a variable of type T
t = s2; // A value of type s2 can be assigned to a variable of type T

Java 使用继承支持包含多态,继承是一种子类化机制。你可以在 Java 中使用一个类型的形参来定义一个方法,比如Person,这个方法可以在它的所有子类型上被调用,比如EmployeeStudentCustomer等等。假设你有一个方法processDetails()如下:

void processDetails(Person p) {
    /*
Write code using the formal parameter p, which is of type Person. The same code will work if an object of any of the subclass of Person is passed to this method.
    */
}

processDetails()方法声明了一个Person类型的形参。您可以定义任意数量的类,这些类是Person类的子类。这种方法适用于这样的子类。假设EmployeeCustomerPerson类的子类。您可以编写这样的代码:

Person p1 = create a Person object;
Employee e1 = create an Employee object;
Customer c1 = create a Customer object;
processDetails(p1); // Use the Person type
processDetails(e1); // Use the Employee type, which is a subclass of Person
processDetails(c1); // Use the Customer type, which is a subclass of Person

子类型规则的作用是超类型包含(因此得名 inclusion)属于其子类型的所有值。只有当一段代码可以处理无限多种类型时,它才被称为通用多态。在包含多态的情况下,代码工作的类型数量是有限的,但却是无限的。约束条件是所有类型都必须是编写代码的那个类型的子类型。如果对一个类型可以有多少个子类型没有限制,那么子类型的数量是无限的(至少理论上是这样)。请注意,包含多态不仅让您编写可重用的代码,还让您编写可扩展和灵活的代码。processDetails()方法作用于Person类的所有子类。它将继续为Person类的所有子类工作,这将在未来定义,没有任何修改。Java 使用其他机制,如方法覆盖和动态调度(也称为后期绑定),以及子类化规则,使包含多态更加有效和有用。

参数多态

参数化是一种普遍的多态。它也被称为“真正的”多态,因为它允许您编写适用于任何类型(相关或不相关)的真正的通用代码。有时,它也被称为泛型。在参数多态中,一段代码以一种可以在任何类型上工作的方式编写。对比参数多态和包含多态。在包含多态中,代码是为一种类型编写的,它适用于它的所有子类型。这意味着代码在包含多态中工作的所有类型都通过超类型-子类型关系相关联。然而,在参数多态中,相同的代码适用于所有类型,这些类型不一定相关。参数多态是通过在编写代码时使用类型变量来实现的,而不是使用任何特定的类型。type 变量假定代码需要为特定类型执行。从 Java 5 到泛型,Java 都支持参数多态。Java 支持多态实体(例如,参数化类)以及使用参数多态的多态方法(参数化方法)。

在 Java 中,参数多态是通过使用泛型来实现的。Java 中的所有集合类型都使用泛型。您可以使用泛型编写代码,如下所示:

/* Example #1 */
// Create a List of String
List<String> sList = new ArrayList<String>();
// Add two Strings to the List
sList.add("string 1");
sList.add("string 2");
// Get the first String from the List
String s1 = sList.get(0);
/* Example #2 */
// Create a List of Integer
List<Integer> iList = new ArrayList<Integer>();
// Add two Integers to the list
iList.add(10);
iList.add(20);
// Get the first Integer from the List
int k1 = iList.get(0);

这段代码使用一个List对象作为一个String类型的列表,使用一个List对象作为一个Integer类型的列表。使用泛型,您可以将一个List对象视为 Java 中任何类型的列表。注意在这些例子中使用了<Xxx>来指定您想要实例化的List对象的类型。

摘要

为计算机编写一组指令来完成一项任务被称为编程。这组指令被称为程序。存在不同类型的编程语言。它们在接近硬件可以理解的指令或范例方面有所不同。机器语言让你用 0 和 1 写程序,是最低级的编程语言。用机器语言编写的程序被称为机器码。汇编语言让你用助记符编写程序。用汇编语言编写的程序被称为汇编代码。后来,更高级的编程语言被开发出来,使用一种类似英语的语言。

实践中有几种类型的编程范例。编程范式是以特定方式观察和分析现实世界问题的思维帽。命令式、过程式、函数式和面向对象是软件开发中一些广泛使用的范例。Java 是一种支持过程式、函数式和面向对象编程范例的编程语言。

抽象、封装、继承和多态是面向对象范例的四大支柱。抽象是隐藏与程序用户无关的程序细节的过程。封装是将多个项目捆绑成一个实体的过程。继承是以分层方式排列类以建立父类型-子类型关系的过程。继承通过允许程序员根据一个同样适用于所有子类型的超类型来编写代码,从而提高了代码的可重用性。多态是指一次编写一段可以在多种类型上操作的代码的方式。方法重载、方法覆盖、子类型和泛型是实现多态的一些方法。

EXERCISES

以下所有问题的答案都可以在本章的不同部分找到。

  1. 什么是编程,什么是程序?

  2. 汇编程序和编译器有什么区别?

  3. 什么是机器语言,用机器语言编写的程序由什么组成?

  4. 什么是汇编语言,用汇编语言写的程序由什么组成?

  5. 说出三种更高级的编程语言。

  6. 基于编程语言(1GL、2GL 等)的产生。),Java 和 SQL 属于什么类别?

  7. 什么是编程范例?用例子描述过程的、功能的和面向对象的范例。

  8. 说出面向对象编程的四个支柱,并用例子描述每个支柱。

  9. 什么是“真正的”多态,Java 是如何支持它的?

  10. 什么是抽象数据类型?Java 如何支持抽象数据类型?

二、设置环境

在本章中,您将学习:

  • 编写、编译和运行 Java 程序需要什么软件

  • 从哪里下载所需的软件

  • 如何验证 Java 开发工具包 17 (JDK 17)的安装

  • 如何启动让您运行 Java 代码片段的jshell命令行工具

  • 从哪里下载、安装和配置用于编写、编译、打包和运行 Java 程序的 NetBeans IDE(集成开发环境)

系统需求

您需要在您的计算机上安装以下软件,以遵循本书中的示例:

  • JDK 17

  • Java 编辑器,最好是 NetBeans 12.5 或更高版本

安装 JDK 17

你需要一个 JDK 来编译和运行 Java 程序。你可以从 https://jdk.java.net/17/ *下载适合你操作系统的 JDK 17。*按照此网页上的说明在您的操作系统上安装 JDK。位于 https://docs.oracle.com/en/java/javase/17/ 的网页包含 JDK 安装的详细说明。

在本书中,我们假设您已经在 Windows 的C:\java17目录中安装了 JDK。如果您已经将它安装在不同的目录中,或者您正在使用不同的操作系统,则需要在您的系统上使用 JDK 安装的路径。例如,如果你已经将它安装在 UNIX 类操作系统的/home/ksharan/jdk17目录中,那么只要我们在本书中使用C:\java17,就使用/home/ksharan/jdk17

当您使用 Java 时,您会经常听到三个术语:

  • JDK_HOME

  • JRE_HOME

  • JAVA_HOME

JDK_HOME指电脑上安装 JDK 的目录。如果您在C:\java17中安装了 JDK,JDK_HOME指的是C:\java17目录。

JDK 有一个子集,称为 JRE (Java 运行时环境)。如果您已经编译了 Java 代码,并且只想运行它,那么您只需要安装 JRE。JDK 包含带有几个工具的 JRE,比如 Java 编译器。JRE_HOME指计算机上安装 JRE 的目录。您总是可以使用 JDK 安装目录作为JRE_HOME的值,因为 JDK 包含 JRE。

通常,JAVA_HOME指的是JRE_HOME。根据上下文,它也可以指JDK_HOME

我在本书中使用术语JDK_HOME来指代 JDK 17 的安装目录。在接下来的两节中,我将解释 JDK 的目录结构以及如何验证 JDK 安装。

JDK 目录结构

在本节中,我们将解释 JDK 安装的目录结构。在 JDK 9 及更高版本中,JDK 目录及其内容的组织方式有一些重要的变化。我们还比较了 JDK 8 和 JDK 9 的目录结构。如果您想将 JDK 8 应用程序迁移到 JDK 9 或更高版本,新的 JDK 结构可能会中断您的应用程序,您需要密切关注本节中描述的变化。

在 JDK 9 之前,JDK 构建系统曾经产生两种类型的运行时映像——Java 运行时环境(JRE)和 Java 开发工具包(JDK)。JRE 是 Java SE 平台的完整实现,JDK 拥有嵌入式 JRE、开发工具和库。你可以选择只安装 JRE 或者安装一个内嵌了 JRE 的 JDK。图 2-1 显示了 Java SE 9 之前的 JDK 安装中的主要目录。JDK_HOME是安装 JDK 的目录。如果您只安装了 JRE,那么您将只有在jre目录下的目录。

img/323069_3_En_2_Fig1_HTML.png

图 2-1

Java SE 9 之前的 JDK 和 JRE 安装目录安排

JDK 8 中的安装目录排列如下:

  • bin目录包含了javacjarjavadoc等命令行开发调试工具。它还包含启动 Java 应用程序的java命令。

  • include目录包含编译本机代码时使用的 C/C++头文件。

  • 目录包含几个 jar 和其他类型的 JDK 工具文件。

    它有一个tools.jar文件,其中包含用于javac编译器的 Java 类。

  • jre\bin目录包含基本命令,如java命令。在 Windows 平台上,它包含系统运行时动态链接库(dll)。

  • jre\lib目录包含用户可编辑的配置文件,如.properties.policy文件。

  • jre\lib\endorsed目录包含允许认可的标准覆盖机制的 jar,该机制允许实现认可的标准或独立技术的类和接口的更高版本(在 Java 社区过程之外创建)被合并到 Java 平台中。这些 jar 被添加到 JVM 的引导类路径中,因此覆盖了 Java 运行时中存在的这些类和接口的任何定义。

  • jre\lib\ext目录包含允许扩展机制的 jar。这种机制通过一个扩展类装入器装入这个目录中的所有 jar,它是引导类装入器的子类,也是系统类装入器的父类,它装入所有应用程序类。通过将 jar 放在这个目录中,可以扩展 Java SE 平台。这些 jar 的内容对所有用这个运行时映像编译或运行的应用程序都是可见的。

  • jre\lib目录包含几个 jar。rt.jar文件包含运行时的 Java 类和资源文件。许多工具依赖于rt.jar文件的位置。

  • jre\lib目录包含用于非 Windows 平台的动态链接的本地库。

  • jre\lib目录包含其他几个子目录,这些子目录包含字体和图像等运行时文件。

没有嵌入 JDK 的 JDK 和 JRE 的根目录过去包含几个文件,如COPYRIGHTLICENSEREADME。根目录中的release文件包含一个描述运行时映像的键值对,比如 Java 版本、OS 版本和架构。以下是来自 JDK 8 的release文件示例,显示了部分内容:

JAVA_VERSION="1.8.0_66"
OS_NAME="Windows"
OS_VERSION="5.2"
OS_ARCH="amd64"
BUILD_TYPE="commercial"

Java SE 9 简化了 JDK 的目录层次结构,消除了 JDK 和 JRE 之间的区别。图 2-2 显示了 JDK 9 及以上版本中 JDK 安装的目录。JRE 8 安装不包含includejmods目录。

img/323069_3_En_2_Fig2_HTML.png

图 2-2

Java SE 9 及更高版本中的 JDK 目录排列

JDK 9+中的安装目录排列如下:

  • 没有名为jre的子目录。

  • bin目录包含所有命令。在 Windows 平台上,它继续包含系统的运行时动态链接库。

  • conf目录包含用户可编辑的配置文件,例如曾经在jre\lib目录中的.properties.policy文件。

  • include目录包含编译本机代码时使用的 C/C++头文件。它只存在于 JDK。

  • jmods目录包含 JMOD 格式的平台模块。创建自定义运行时映像时需要它。它只存在于 JDK,不存在于 JRE。

  • legal目录包含法律声明。

  • lib目录包含非 Windows 平台上的动态链接的本地库。它的子目录和文件不应该被开发者直接编辑或使用。它包含一个名为modules的文件,该文件包含内部格式为 JIMAGE 的 Java SE 平台模块。

    提示 JDK 9 或更高版本比 JDK 8 大得多,因为它包含两个平台模块副本——一个在 JMOD 格式的jmods目录中,另一个在lib\modules文件中,格式为JIMAGE

JDK 的根目录下继续有COPYRIGHTLICENSEREADME等文件。JDK 中的release文件包含一个带有MODULES键的新条目,其值是映像中包含的模块列表。JDK 17 图像中release文件的部分内容如下:

IMPLEMENTOR="Oracle Corporation"
JAVA_VERSION="17"
JAVA_VERSION_DATE="2021-09-14"
OS_ARCH="amd64"
OS_NAME="Windows"
MODULES="java.base java.compiler java.datatransfer”

我们在列表中只显示了三个模块。在完全 JDK 安装中,该列表将包括所有平台模块。在自定义运行时映像中,该列表将只包含您在映像中包含的模块。

Tip

JDK 中的lib\tools.jar和 JRE 中的lib\rt.jar在版本 9 中被从 Java SE 中移除。这些 jar 中可用的类和资源现在以内部格式存储在lib目录中。一种叫做jrt的新方案可以用来从运行时映像中检索那些类和资源。依赖于这些 jar 位置的应用程序将停止工作。

验证 JDK 安装

JDK_HOME\bin目录包含一个名为java的命令,用于启动 Java 应用程序。当使用以下选项之一运行java命令时,它会打印 JDK 版本信息:

  • -version

  • --version

  • -showversion

  • --show-version

所有四个选项打印相同的 JDK 版本信息。以一个连字符开头的选项是 UNIX 风格的选项,而以两个连字符开头的选项是 GNU 风格的选项。JDK 9 引入了 GNU 风格的选项。UNIX 风格的选项在标准错误流上打印 JDK 版本,而 GNU 风格的选项在标准输出流上打印它。-version--version选项在打印完信息后退出,而-showversion--show-version选项在打印完信息后继续执行其他选项。以下命令显示了如何打印 JDK 版本:

C:\>java -version
openjdk version "17-ea" 2021-09-14
OpenJDK Runtime Environment (build 17-ea+10-723)
OpenJDK 64-Bit Server VM (build 17-ea+10-723, mixed mode, sharing)

如果输出的第一行打印出"version 17",那么您的 JDK 安装是好的。您可能会得到如下所示的输出:

'java' is not recognized as an internal or external command, operable program or batch file.

该输出表明JDK_HOME\bin目录不包含在您计算机上的PATH环境变量中。在这种情况下,你可以使用java命令的完整路径来打印它的版本以及你需要它的任何地方。我的JDK_HOME是 Windows 上的C:\java17。以下命令向您展示了如何使用完整路径以及如何在命令提示符下设置PATH环境变量:

C:\>SET PATH=C:\java17\bin;%PATH%
C:\>java --version

您还可以使用以下命令在 Windows 上永久设置PATH环境变量:

 Control Panel > System and Security > System > Advanced system settings > Environment Variables

如果您的计算机上安装了多个 JDK,那么创建一个批处理(或 shell)脚本来打开命令提示符并在脚本中设置PATH环境变量会更容易。这样,您可以使用多个 JDK,而无需在系统级设置PATH环境变量。

启动 JShell 工具

JDK 9 及以上版本在JDK_HOME\bin目录中包含一个jshell工具。该工具允许您执行一段 Java 代码,而不是编写一个完整的 Java 程序。这对初学者很有帮助。第二十三章详细介绍了jshell工具。以下命令向您展示了如何启动jshell工具,执行一些 Java 代码片段,然后退出jshell工具:

C:\>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> System.out.println("Hello JDK 17!")
Hello JDK 17!
jshell> 2 + 2
$2 ==> 4
jshell> /exit
|  Goodbye
C:\>

在阅读后续章节时,您可以在命令提示符下启动jshell工具,并输入一段代码来查看结果。

安装 NetBeans 12

您需要一个 Java 编辑器来编写、打包、编译和运行您的 Java 应用程序,NetBeans 就是这样一个 Java 编辑器。本书的源代码包含 NetBeans 项目。但是,不一定要使用 NetBeans。您可以使用另一个 Java 编辑器,如 Eclipse、IntelliJ IDEA 或 JDeveloper。为了遵循本书中的示例,您需要将源代码(.java文件)复制到您使用另一个 Java 编辑器创建的项目中。

可以从 https://netbeans.org/ 下载 NetBeans 12.5 或以上版本。NetBeans 12 在 JDK 版本 8 和 11 以及当前的 JDK 版本上运行。当您安装 NetBeans 时,它会要求您选择 JDK 主目录。如果您在 JDK 11 上安装 NetBeans,您可以选择 JDK 17 作为 Java 平台,以便在 NetBeans 中使用 JDK 17。如果你把它安装在 JDK 17 上,JDK 17 将是 NetBeans 内部默认的 Java 平台。在下一节中,我们将向您展示如何在 NetBeans IDE 中选择 Java 平台。

配置 NetBeans

启动 NetBeans IDE。如果第一次打开 IDE,它会显示一个标题为“起始页”的窗格,如图 2-3 所示。如果不希望再次显示,可以取消选中面板右上角标有“启动时显示”的复选框。您可以通过单击窗格标题中的 X 来关闭起始页窗格。如果您想随时显示此页面,可以使用“帮助➤起始页”菜单项。

img/323069_3_En_2_Fig3_HTML.png

图 2-3

初始 NetBeans IDE 屏幕

选择工具➤“Java 平台”,显示 Java 平台管理器对话框,如图 2-4 所示。在此图中,NetBeans IDE 正在 JDK 17 上运行,它显示在平台列表中。如果您在 JDK 17 上运行它,JDK 17 将显示在平台列表中,您不需要任何进一步的配置。

img/323069_3_En_2_Fig4_HTML.png

图 2-4

“Java 平台管理器”对话框

如果您在平台列表中看到 JDK 17,您的 IDE 已经配置为使用 JDK 17,您可以通过单击关闭按钮来关闭对话框。如果在平台列表中没有看到 JDK 17,点击添加平台…按钮,打开添加 Java 平台对话框,如图 2-5 所示。确保选中了 Java Standard Edition 单选按钮。点击下一个➤按钮,显示添加 Java 平台对话框,如图 2-6 所示。

img/323069_3_En_2_Fig6_HTML.png

图 2-6

“添加 Java 平台”对话框

img/323069_3_En_2_Fig5_HTML.png

图 2-5

“选择平台类型”框

在“添加 Java 平台”对话框中,选择 JDK 17 的安装目录。在此示例中,我们在 Windows 上的 C:\Users\Adam\jdk-17 中安装了 JDK 17,因此我们在此对话框中选择了 C:\Users\Adam\jdk-17 目录。点击下一个➤按钮。显示如图 2-7 所示的添加 Java 平台对话框。“平台名称”和“平台源”字段是预填充的。

img/323069_3_En_2_Fig7_HTML.png

图 2-7

“添加 Java 平台,平台名”对话框

单击 Finish 按钮,这将使您返回到 Java Platform Manager 对话框,该对话框将 JDK 17 显示为平台列表中的一个项目,如图 2-8 所示。单击关闭按钮关闭对话框。您已经完成了将 NetBeans IDE 配置为使用 JDK 17 的操作。

img/323069_3_En_2_Fig8_HTML.png

图 2-8

加上 JDK 17 后

摘要

要使用 Java 程序,您需要安装一个 JDK,如 OpenJDK 17,以及一个 Java 编辑器,如 NetBeans。安装 JDK 的目录通常称为JDK_HOME。JDK 安装在JDK_HOME\bin目录中复制了许多 Java 工具/命令,比如编译 Java 程序的javac命令,运行 Java 程序的java命令,以及运行 Java 代码片段的jshell工具。

NetBeans IDE 可以安装在 JDK 8、11 或 17 之上。当您将它安装在非 17 版本的 JDK 之上时,如果您想将其用作 Java 平台,则需要将 IDE 配置为使用 JDK 17 版本。

三、编写 Java 程序

在本章中,您将学习:

  • Java 程序的结构

  • 如何组织 Java 程序的源代码

  • 如何使用 Java Shell、命令提示符和 NetBeans 集成开发环境(IDE)编写、编译和运行 Java 程序

  • 什么是模块图

  • 什么是模块路径和类路径,以及如何使用它们

  • 简要介绍 Java 平台和 Java 虚拟机(JVM)

本章和本书的其余章节假设您已经安装了 JDK 17 和 NetBeans IDE 12.5 或更高版本,如第二章所述。

目标语句

本章的主要目标很简单—编写一个 Java 程序在控制台上打印以下消息:

Welcome to Java 17!

你可能会想,“用 Java 打印一条消息会有多难?”事实上,用 Java 打印一条消息并不难。下面一行代码将打印这条消息:

System.out.println("Welcome to Java 17!");

然而,要在一个成熟的 Java 程序中打印这条消息,您必须做大量的准备工作。我们将向您展示如何使用三种方法在 Java 中打印消息:

  • 使用 Java Shell,也称为 JShell 工具

  • 使用命令提示符或终端

  • 使用 NetBeans IDE

使用 JShell 工具不需要做任何准备工作,这是最简单的。它将让您在不了解 Java 编程语言的任何其他知识的情况下打印一条消息。

使用命令提示符需要做大量的准备工作,并使您在按照本节所述的目标打印消息之前学习 Java 程序结构的基础知识。

使用 NetBeans IDE 需要做一些准备工作,它为开发人员提供了最大的帮助。在本章之后,您将只使用 NetBeans 来编写所有程序,除非需要其他两种方法来显示它们的特殊功能。以下部分向您展示了如何使用这三种方法来实现既定目标。

使用 JShell 工具

第二章快速介绍了 JShell 工具。您需要输入以下代码行来打印消息:

System.out.println("Welcome to Java 17!");

我们将在下一节中解释这段代码的每一部分。以下 JShell 会话显示了 Windows 命令提示符下的所有步骤:

c:\>jshell
|  Welcome to JShell -- Version 17
|  For an introduction type: /help intro
jshell> System.out.println("Welcome to Java 17!");
Welcome to Java 17!
jshell> /exit
|  Goodbye
c:\>

您已经看到了如何使用 JShell 执行 Java 语句。您还没有看到完整的 Java 程序。JShell 是一个非常强大的工具,你可以用它来快速学习 Java 语言。在接下来的几章中,它将是试验示例中使用的代码片段的便利工具。我们将在本书后面的一章中介绍它的所有细节。

什么是 Java 程序?

使用 Java 编程语言的规则和语法编写的 Java 程序是由计算机执行以完成任务的一组指令。在接下来的几节中,我们只解释编写 Java 程序所涉及的基础知识。我们将在后续章节中详细解释 Java 程序的所有方面。开发 Java 应用程序包括四个步骤:

  • 编写源代码

  • 编译源代码

  • 打包编译后的代码

  • 运行编译后的代码

您可以使用自己选择的文本编辑器编写 Java 程序,例如 Windows 上的记事本和 UNIX 上的 vi 编辑器。使用 Java 编译器将源代码编译成目标代码,也称为字节码。编译好的代码打包成 JAR(JavaArchive)文件。打包的编译代码由 JVM 运行。

当您使用 NetBeans 之类的 IDE 时,IDE 为您提供了一个内置编辑器来编写源代码。IDE 还为您提供了编译、打包和运行应用程序的简单方法。

编写源代码

本节涵盖了编写源代码的细节。我们通过在 Windows 上使用记事本来演示这一点。您可以使用操作系统上提供的文本编辑器。

Note

我将在本章后面介绍如何使用 NetBeans IDE。我首先想介绍使用文本编辑器,因为这个过程揭示了许多您需要了解的 Java 程序。

当你写完源代码后,你必须用扩展名.java保存文件。您将把您的源代码文件命名为Welcome.java。请注意,除了.java之外的任何文件扩展名都是不可接受的。例如,Welcome.txtWelcome.doc这样的名字就不是有效的源代码文件名。

每当你使用一种语言来写东西(在你的例子中,Java 源代码),你需要遵循该语言的语法,并根据你所写的东西使用特定的语法。让我们举一个给你朋友写信的例子。这封信有几个部分:标题、问候语、正文、结束语和你的签名。在一封信中,不仅仅是把五个部分放在一起很重要;相反,它们也应该按照特定的顺序排列。例如,结尾需要跟随身体。一封信中的一些部分是可选的,而其他部分是必须的。例如,在给朋友的信中不写回信地址是可以的,但在商务信函中却是必须的。一开始,你可以把写 Java 程序想象成类似于写信。

Java 程序由一个或多个模块组成。一个模块包含零个或多个包。一个包包含一种或多种类型。术语 type 是一个通用术语,指的是用户定义的数据类型。您可以有四种用户定义的数据类型—类、接口、枚举和注释。广义地说,枚举和注释分别是类和接口的特殊类型。在接下来的几章中,您将只使用类。图 3-1 显示了一个模块的布置。

img/323069_3_En_3_Fig1_HTML.png

图 3-1

Java 程序的结构

Note

在 JDK 9 中引入了模块。直到 JDK 8,你有包和类型,但没有模块。除了包之外,模块是组织代码的可选方式。

纯 Java 程序是操作系统不可知的。你在所有操作系统上写同样的 Java 代码。操作系统使用不同的语法来引用文件和分隔文件路径。使用 Java 程序时,您必须引用文件和目录,并且需要使用适用于您的操作系统的语法。Windows 在文件路径中使用反斜杠(\)作为目录分隔符,例如C:\javafun\src,而类 UNIX 操作系统使用正斜杠(/),例如/home/ksharan/javafun。Windows 使用分号(;)作为路径分隔符,例如C:\java9\bin;C:\bj9r,而类 UNIX 操作系统使用冒号(:),例如/home/ksharan/java9/bin:/home/ksharan/javafun。我使用 Windows 来处理本书中的示例。我还解释了使用不同操作系统时存在的差异。

我们使用以下目录结构来处理本节中的示例:

  • javafun

  • javafun\src

  • javafun\mod

  • javafun\lib

我们将顶级目录命名为javafun,这是开始Java17Fundamentals 的简称。您可以在计算机上的任何其他目录中创建此目录。例如,您可以在 Windows 上将其命名为C:\javafun,在 UNIX 上命名为/home/ksharan/javafun。您将在javafun\src目录中存储源代码,在javafun\mod目录中存储编译后的代码,在javafun\lib目录中存储打包后的代码。继续在您的计算机上创建这些目录。在接下来的章节中,您需要用到它们。

写评论

注释是不可执行的代码,用于记录代码。Java 编译器会忽略它们。它们包含在源代码中,以记录程序的功能和逻辑。Java 支持三种类型的注释:

  • 单行注释

  • 多行 comment

  • 文档注释或 Javadoc 注释

单行注释以两个正斜杠(//)开头,后跟文本,例如:

// This is a single-line comment
package com.jdojo.intro; // This is also a single-line comment

单行注释可以从一行的任何位置开始。从两个正斜杠开始到行尾的部分被认为是注释。如前所述,您还可以混合 Java 源代码,例如,在一行中混合一个包声明和一个注释。注意,这种类型的注释不能插入到 Java 代码中间。下面详细讨论的包声明是不正确的,因为包名和分号也被认为是注释的一部分:

package // An incorrect single-line comment com.jdojo.intro;

下面一行是单行注释。它有一个有效的包声明作为注释文本。

它将被视为注释,而不是包声明:

// package com.jdojo.intro;

第二种类型的注释称为多行注释。多行注释可以跨越多行。它以紧跟其后的星号(/*)的正斜杠开始,以紧跟其后的星号(*/)结束。Java 源代码中多行注释的示例如下:

/*
    This is a multi-line comment.
    It can span more than one line.
*/

该注释也可以使用两个单行注释来编写,如下所示:

// This is a multi-line comment.
// It can span more than one line

您在源代码中使用的注释风格是您个人的选择。可以在 Java 代码中间插入一个多行注释,如下所示。编译器忽略从/**/的所有文本:

package /* A correct comment */ com.jdojo.intro;

第三种类型的注释称为文档(或 Javadoc)注释,也是多行注释。它用于为 Java 程序生成文档。这种注释以紧跟其后的两个星号(/**)的正斜杠开始,以紧跟其后的一个星号(*/)结束。以下是文档注释的简单示例:

/**
   This is a documentation comment. javadoc generates documentation from such comments.
*/

编写 Javadoc 注释是一个庞大的主题。附录 B 详细介绍了这一点。本书中的所有源代码都以单行注释开始,注释中包含包含源代码的文件的名称,例如:

// Welcome.java

声明模块

模块充当包的容器。模块可以包含可以在模块内部使用或由其他模块使用的包。模块控制其包的可访问性。一个模块导出它的包供其他模块使用。如果一个模块需要使用另一个模块的包,第一个模块需要声明对第二个模块的依赖,第二个模块需要导出第一个模块使用的包。下面是声明模块的简化语法:

module <module-name> {
    <module-statement-1>
    <module-statement-2>
}

模块的声明以关键字module开始,后面是模块名。在大括号内,放置模块声明的主体,它包含零个或多个模块语句。清单 3-1 包含了一个名为jdojo.intro的模块的完整代码。

// module-info.java
module jdojo.intro {
    // An empty module body
}

Listing 3-1The Declaration of a Module Named jdojo.intro

jdojo.intro模块不包含模块语句。也就是说,它不导出任何包供其他模块使用,也不依赖于任何其他模块。JDK 由几个模块组成;其中一个模块被命名为java.basejava.base模块被称为原始模块。它不依赖于其他模块,所有其他模块——内置的和用户定义的——都隐式依赖于它。

Tip

在 Java 中,三个术语“依赖于”、“读取”和“需要”可以互换使用,以表示一个模块对另一个模块的依赖性。如果模块P依赖于模块Q,也可以表述为“模块P读取模块Q或者“模块P需要模块”)

模块的依赖关系是在模块体中用一个requires语句声明的。其最简单的语法如下:

requires <module-name>;

您没有为jdojo.intro模块声明任何依赖关系。然而,由于 Java 中的每个模块都隐式地依赖于java.base模块,编译器会在你的jdojo.intro模块中添加对java.base模块的依赖。编译器修改的模块声明如清单 3-2 所示。

// module-info.java
module jdojo.intro {
    requires java.base;
}

Listing 3-2The Compiler-Modified Declaration of the jdojo.intro Module

如果你愿意,你可以在你的模块声明中包含一个"requires java.base"语句。

如果您不这样做,编译器总是会为您添加它。在本书中,我没有将它包含在模块声明中。

为什么每个模块都依赖于java.base模块?java.base模块包含几个 Java 包,它们是在所有 Java 程序中提供基本功能所必需的。例如,您想在控制台上打印一条消息,打印功能包含在名为java.lang的包中的java.base模块中。

通常,模块声明保存在模块源代码根目录下的一个module-info.java文件中。在javafun\src目录中创建一个名为jdojo.intro的子目录,在其中放置jdojo.intro模块的所有源代码。将清单 3-1 中显示的代码保存在一个名为javafun\src\jdojo.intro\module-info.java的文件中。这就完成了您的模块声明。

是否必须将模块声明保存在与模块名同名的根目录下?不,这不是强制性的。您可以将module-info.java文件保存在javafun\src目录中,一切都会正常工作。将模块的所有源代码保存在以模块名称命名的目录中,可以使编译模块代码更加容易。JDK 还支持将模块的代码保存在不同的根目录下。

声明类型

一个包被分成几个编译单元。编译单元包含这些类型的源代码。在很大程度上,您可以将编译单元视为一个包含类和接口等类型的源代码的.java文件。当你编译一个 Java 程序时,你编译的是该程序包含的编译单元。通常,一个编译单元包含一种类型的声明。例如,您要声明一个名为Welcome的类,您将把Welcome类的源代码放在一个名为Welcome.java的编译单元(或文件)中。编译单元由三部分组成:

  • 一包申报

  • 零个或多个import声明

  • 零个或多个类型声明:类、接口、枚举或批注声明

所有三个部分,如果存在,必须按上述顺序指定。图 3-2 显示了一个编译单元的三个部分,其中包含一个类型声明。类型是一个名为Welcome的类。后续部分详细描述了编译单元的每个部分。

img/323069_3_En_3_Fig2_HTML.png

图 3-2

编译单元的组成部分

包装声明

包声明的一般语法如下:

package <your-package-name>;

包声明以关键字package开始,后跟用户提供的包名。空白(空格、制表符、换行符、回车符和换页符)分隔关键字package和包名。分号(;)结束包声明。例如,下面是名为com.jdojo.intro的包的包声明:

package com.jdojo.intro;

图 3-3 显示了包装声明的各个部分。

img/323069_3_En_3_Fig3_HTML.png

图 3-3

编译单元中的部分包声明

您提供包名。包名可以由一个或多个部分组成,用点(.).在这个例子中,包名由三部分组成:comjdojointro。包名中的部分数量没有限制。在一个编译单元中,最多可以有一个包声明。编译单元中声明的所有类型都成为该包的成员。以下是有效包声明的一些示例:

package intro;
package com.jdojo.intro.common;
package com.ksharan;
package com.jdojo.intro;

如何选择一个好的包名?保持包名的唯一性很重要,这样它们就不会与同一应用程序中使用的其他包名冲突。建议对包的开头部分使用反向域名符号,例如:com.yahoo代表 Yahoo, com.google代表 Google,等等。使用公司的反向域名作为包名的主要部分保证了包名不会与其他公司使用的包名冲突,只要它们遵循相同的准则。如果你没有域名,请创建一个唯一的域名。这只是一个指导方针。实际上,没有任何东西可以保证世界上所有 Java 程序都有一个唯一的包名。例如,我拥有一个名为jdojo.com的域名,我以com.jdojo开始我所有的包名,以保持它们的唯一性。在本书中,我以com.jdojo开始一个包名,后面跟着主题名。

为什么我们要使用包声明?包是类型的逻辑存储库。换句话说,它为相关类型提供了一个逻辑分组。包可以存储在特定于主机的文件系统或网络位置。在文件系统中,包名的每个部分表示主机系统上的一个目录。例如,包名com.jdojo.intro表示存在一个名为com的目录,该目录包含一个名为jdojo的子目录,该子目录包含一个名为intro的子目录。也就是说,包名com.jdojo.intro表明在 Windows 上存在一个com\jdojo\intro目录,在类 UNIX 操作系统上存在一个com/jdojo/intro目录。intro目录将包含为com.jdojo.intro包中的所有类型编译的 Java 代码。在主机系统上,用于分隔软件包名称中各部分的点被视为文件分隔符。注意,反斜杠(\)是 Windows 上的文件分隔符,正斜杠(/)用于类似 UNIX 的操作系统。

包名只指定编译后的 Java 程序(类文件)必须存在的部分目录结构。它没有指定类文件的完整路径。在这个例子中,包声明com.jdojo.intro没有指定com目录放在哪里。可以放在C:\目录或者C:\myprograms目录下,也可以放在文件系统中的任何其他目录下。仅仅知道包名不足以定位类文件,因为它只指定了类文件的部分路径。文件系统中类文件路径的前导部分是从 modulepath 中获取的,需要在编译运行 Java 程序时指定。在 JDK 9 之前,包中的类文件是使用类路径定位的,为了向后兼容,在 JDK 9–17 中仍然支持类路径。我们将在本章后面讨论这两种方法。

Java 源代码是区分大小写的。关键字package必须按原样书写——全部小写。单词PackagepackAge不能代替关键词package。包名也区分大小写。在某些操作系统上,文件和目录的名称区分大小写。在这些系统上,包名区分大小写,正如您所看到的:包名被视为主机系统上的目录名。包名com.jdojo.introCom.jdojo.intro可能不一样,这取决于您正在使用的主机系统。建议使用全部小写的包名。

在 JDK 9 之前,编译单元中的包声明是可选的。如果一个编译单元不包含包声明,那么在该编译单元中声明的类型属于一个名为未命名包的包。JDK 9 不允许模块中有未命名的包。如果将类型放在模块中,编译单元必须包含一个包声明。

进口申报

编译单元中的导入声明是可选的。您甚至可以不使用一个导入声明就开发一个 Java 应用程序。为什么需要进口申报单?使用进口报关让您的生活更轻松。它节省了您的一些输入,并使您的代码更干净,更容易阅读。在导入声明中,您告诉 Java 编译器您可以使用特定包中的一个或多个类型。每当在编译单元中使用某个类型时,必须用它的完全限定名来引用它。使用类型的导入声明可以让您使用类型的简单名称来引用该类型。我将很快讨论简单的和完全限定的类型名。

与包声明不同,源代码中对导入声明的数量没有限制。以下是两项进口申报:

import com.jdojo.intro.Account;
import com.jdojo.util.*;

我们将在本书的后面详细讨论导入声明。在本节中,我们只讨论进口申报单所有部分的含义。

进口申报以关键字import开始。进口申报的第二部分由两部分组成:

  • 要在当前编译单元中使用类型的包名

  • 类型名称或星号(*)表示您可以使用包中存储的一个或多个类型

最后,导入声明以分号结束。前两份进口申报说明如下:

  • 我们可以使用来自com.jdojo.intro包的简单名称命名为Account的类型。

  • 我们可以使用com.jdojo.util包中任何类型的简单名称。

如果您想使用来自com.jdojo.common包的名为Person的类,您需要在您的编译单元中包含以下两个导入声明之一:

import com.jdojo.common.Person;

或者

import com.jdojo.common.*;

以下导入声明不包括包comcom.jdojo中的类:

import com.jdojo.intro.Account;
import com.jdojo.intro.*;

你可能认为像这样的进口申报单

import com.*.*;

将允许您使用所有类型的简单名称,这些类型的包声明的第一部分是com。Java 不支持在导入声明中使用这种类型的通配符。您只能命名一个包中的一种类型(com.jdojo.intro.Account)或一个包中的所有类型(com.jdojo.intro)。*);导入类型的任何其他语法都是无效的。

编译单元中的第三部分包含类型声明,它可能包含零个或多个类型声明:类、接口、枚举和注释。根据 Java 语言规范,类型声明也是可选的。但是,如果您省略了这一部分,您的 Java 程序不会做任何事情。

要使 Java 程序有意义,必须在编译单元中至少包含一个类型声明。

我将把对接口、枚举和注释的讨论推迟到本书后面的章节。

让我们讨论如何在编译单元中声明一个类。

类别声明

最简单的形式是,类声明如下所示:

class Welcome {
    // Code for the class body goes here
};

图 3-4 显示了这个类声明的一部分。

img/323069_3_En_3_Fig4_HTML.png

图 3-4

编译单元中类声明的一部分

使用关键字class声明一个类,关键字后面跟有类的名称。在这个例子中,类的名称是Welcome

类的主体放在左大括号和右大括号之间。身体可能是空的。但是,您必须包括两个大括号来标记主体的开始和结束。

可选地,类声明可以以分号结束。本书不会使用可选的分号来结束类声明。

Java 程序中最简单的类声明可能如下所示:

class Welcome { }

这一次,我将整个类声明放在一行中。您可以将关键字class、类名Welcome和两个大括号放在任何您想要的位置,除了您必须包括至少一个空白字符(空格、换行符、制表符等等。)在关键字class和类名Welcome之间。Java 允许你以自由格式的文本格式编写源代码。以下三个类声明都是相同的:

// Class Declaration #1
class
Welcome { }
// Class Declaration #2
class
         Welcome {
}
// Class Declaration #3
class Welcome {
}

本书使用了如下的类声明格式:左大括号放在类名后面的同一行,右大括号放在单独的一行,与类声明第一行的第一个字符对齐,像这样:

class Welcome {
}

类的主体由四部分组成。所有部分都是可选的,可以按任何顺序出现,并且可以分成多个部分:

  • 字段声明

  • 初始化器:静态初始化器和实例初始化器

  • 构造器

  • 方法声明

Java 对类体的四个部分的出现顺序没有任何限制。我从方法声明开始,在本章中只讨论简单的方法声明。我们将在后面的章节中讨论方法声明的高级方面和类体声明的其他部分。

让我们讨论如何在类中声明一个方法。您可能会猜测方法声明将以关键字method开始,因为包和类声明分别以关键字packageclass开始。然而,方法声明不是以关键字method开始的。事实上,method在 Java 语言中并不是一个关键字。你用关键字class开始一个类声明,表明你将要声明一个类。但是,在方法声明的情况下,首先要指定的是方法将返回给调用者的值的类型。如果一个方法没有返回任何东西给它的调用者,你必须在方法声明的开始使用关键字void提到这个事实。方法的名称遵循方法的返回类型。方法名后跟一个左括号和一个右括号。像类一样,方法也有一个主体,用大括号括起来。Java 中最简单的方法声明如下所示:

<method-return-type> <method-name> (<arguments-list>) {
    // Body of the method goes here
}

下面是方法声明的一个示例:

void main() {
    // Empty body of the main method
}

这个方法声明包含四点:

  • 该方法不返回任何东西,如关键字void所示。

  • 方法的名字是main

  • 该方法不需要参数。

  • 该方法不做任何事情,因为它的主体是空的。

方法的返回值是该方法返回给调用者的东西。该方法的调用方可能还希望向该方法传递一些值。如果一个方法需要它的调用者传递值给它,这个事实必须在方法的声明中指出。您希望将值传递给方法的事实是在方法名后面的括号中指定的。关于要传递给方法的值,您需要指定两件事:

  • 要传递的值的类型。假设您想将一个整数(比如 10)传递给方法。您需要使用关键字int来表示这一点,该关键字用于表示一个整数值,如 10。

  • 标识符,它将保存您传递给方法的值。标识符是用户定义的名称。它被称为参数名。

如果您想让main方法从它的调用者那里接受一个整数值,那么它的声明将变成如下:

void main(int num) {
}

这里,num是一个标识符,它将保存传递给这个方法的值。除了num,您可以选择使用另一个标识符,例如num1num2num3myNumber等。main方法的声明如下:

main 方法接受一个 int 类型的参数,它不向其调用者返回任何值。

如果您想将两个整数传递给main方法,它的声明将更改如下:

void main(int num1, int num2) {
}

从这个声明中可以清楚地看出,您需要用逗号(,)来分隔传递给方法的参数。如果你想给这个方法传递 50 个整数,你会怎么做?您将得到一个类似这样的方法声明:

void main(int num1, int num2, ..., int num50) {
}

我只展示了三个参数声明。然而,当你写一个 Java 程序时,你将不得不键入所有的 50 个参数声明。让我们寻找一个更好的方法来给这个方法传递 50 个参数。在所有 50 个参数中有一个相似之处——它们都是同一类型,一个整数。任何值都不会包含像 20.11 或 45.09 这样的分数。所有参数之间的这种相似性允许您在 Java 语言中使用一种称为数组的神奇生物。使用数组向该方法传递 50 个整数参数需要什么?当你写作时

int num

这意味着num是类型int的标识符,它可以保存一个整数值。如果你在 int 后面放两个魔法括号([]),比如

int[] num

这意味着num是一个int的数组,它可以保存任意多的整数值。num所能容纳的整数数量是有限制的。但是,这个限制非常高,我们在详细讨论数组时会讨论这个限制。存储在num中的值可以使用下标访问:num[0]num[1]num[2]等。请注意,在声明一个类型为int的数组时,您没有提到希望num表示 50 个整数的事实。您修改后的main方法声明可以接受 50 个整数,如下所示:

void main(int[] num) {
}

你将如何声明让你传递 50 个人名字的main方法?由于int只能用于整数,所以您必须在 Java 语言中寻找其他表示文本的类型,因为人名将是文本,而不是整数。有一种类型String(注意String中的大写S)表示 Java 语言中的文本。因此,要向方法main传递 50 个名称,您可以如下更改其声明:

void main(String[] name) {
}

在该声明中,您不必将参数名从num更改为name。您更改它只是为了使参数的含义清晰直观。现在让我们向main方法的主体添加一些 Java 代码,这将在控制台上打印一条消息:

System.out.println("The message you want to print");

这不是讨论Systemoutprintln的合适场合。现在,只需输入System(注意System中的大写S)、一个点、out、一个点和println,后跟两个括号,括号中包含您想要打印的信息,并加上双引号。您想要打印一条消息"Welcome to Java 17!",那么您的main方法声明将如下所示:

void main(String[] name) {
    System.out.println("Welcome to Java 17!");
}

这是一个有效的方法声明,它将在控制台上打印一条消息。下一步是编译包含Welcome类声明的源代码,并运行编译后的代码。当您运行一个类时,Java 运行时会在该类中寻找一个名为main的方法,该方法的声明必须如下所示,尽管name可以是任何标识符。

public static void main(String[] name) {
}

除了publicstatic两个关键词,你应该能理解这个方法声明,声明:“main是一个方法,接受一个String的数组作为参数,不返回任何东西。”

现在,你可以把publicstatic看作是两个关键字,它们必须存在才能声明main方法。注意,Java 运行时还要求方法的名称是main。这就是我从一开始就选择main作为方法名称的原因。源代码的最终版本如清单 3-3 所示。我做了两处改动:

  • 我将Welcome类声明为 public。

  • 我将主方法的参数命名为args

将源代码保存在javafun\src\jdojo.intro\com\jdojo\intro目录下名为Welcome.java的文件中。

// Welcome.java
package com.jdojo.intro;
public class Welcome {
    public static void main(String[] args) {
        System.out.println("Welcome to Java 17!");
    }
}

Listing 3-3The Source Code for the Welcome Class

Java 编译器对源代码的文件名施加了限制。如果已经在编译单元中声明了公共类型(例如,类或接口),则编译单元的文件名必须与公共类型的名称相同。在这个例子中,您已经声明了Welcome类 public,这要求您将文件命名为Welcome.java。这也意味着不能在一个编译单元中声明多个公共类型。在一次编译中,最多可以有一个公共类型和任意数量的非公共类型。

此时,本示例的源目录和文件如下所示:

  • javafun\src\jdojo.intro\module-info.java

  • javafun\src\jdojo.intro\com\jdojo\intro\Welcome.java

类型有两个名称

Java 中的每个类(实际上是每个类型)都有两个名字:

  • 简单的名字

  • 完全限定的名称

类的简单名称是在类声明中出现在关键字class之后的名称。在这个例子中,Welcome是类的简单名称。一个类的完全限定名是它的包名后跟一个点和它的简单名。在本例中,com.jdojo.intro.Welcome是该类的完全限定名:

Simple-Name = "Name appearing in the type declaration"
Fully-Qualified-Name = "package name" + "." + "Simple-Name"

您脑海中可能出现的下一个问题是,“没有包声明的类的完全限定名是什么?”答案很简单。在这种情况下,类的简单名称和完全限定名称是相同的。如果您从源代码中删除包声明,Welcome将是您的类的两个名称。

编译源代码

编译是将源代码翻译成一种叫做字节码的特殊二进制格式的过程。这是使用 JDK 附带的一个名为javac的程序(通常称为编译器)来完成的。编译 Java 源代码的过程如图 3-5 所示。

img/323069_3_En_3_Fig5_HTML.png

图 3-5

将 Java 源代码编译成字节码的过程

您提供源代码(在您的例子中是Welcome.javamodule-info.java)作为 Java 编译器的输入,它生成两个扩展名为.class的文件。扩展名为.class的文件称为类文件。类文件是一种叫做字节码的特殊格式。字节码是 Java 虚拟机(JVM)的一种机器语言。我们将在本章的后面讨论 JVM 和字节码。

现在,我将介绍在 Windows 上编译源代码所需的步骤。对于其他平台,例如 UNIX 和 Mac OS X,您需要使用特定于这些平台的文件路径语法。我假设您已经在 Windows 上保存了两个源文件,如下所示:

  • C:\javafun\src\jdojo.intro\module-info.java

  • C:\javafun\src\jdojo.intro\com\jdojo\intro\Welcome.java

打开命令提示符,将当前目录更改为C:\javafun。提示符应该如下所示:

C:\javafun>

使用javac命令的语法如下:

javac -d <output-directory> <source-file1> <source-file2>...<source-fileN>

-d选项指定编译后的类文件将被放置的输出目录。您可以指定一个或多个源代码文件。如果没有指定-d选项,编译后的类文件将被放在与源文件相同的位置。

您的输出目录将是javafun\mod\jdojo.intro,因为您希望将所有的类文件放在这个目录中。您将指定两个源文件,分别是module-info.javaWelcome.java。以下命令将编译您的源代码。该命令是在一行中输入的,而不是如图所示的两行:

C:\javafun>javac -d mod\jdojo.intro src\jdojo.intro\module-info.java src\jdojo.intro\com\jdojo\intro\Welcome.java

注意,该命令使用相对路径,如modsrc,它们相对于当前目录C:\javafun。如果愿意,可以使用绝对路径,比如C:\javafun\mod\jdojo.intro

如果您没有收到错误消息,这意味着您的源文件编译成功,编译器生成了两个名为module-info.classWelcome.class的文件,如下所示:

  • C:\javafun\mod\jdojo.intro\module-info.class

  • C:\javafun\mod\jdojo.intro\com\jdojo\intro\Welcome.class

注意,编译器通过创建一个目录层次结构来放置Welcome.class文件,该目录层次结构反映了Welcome.java文件中的包声明。回想一下,包名反映了目录层次结构。例如,名为com.jdojo.intro的包对应于名为com\jdojo\intro的目录。您已经通过创建镜像包名的目录层次结构放置了Welcome.class文件。Java 编译器足够聪明,可以读取包名,并在输出目录中创建一个目录层次结构来放置生成的类文件。

如果在编译源代码时出现任何错误,可能有以下三种原因之一:

  • 您没有将module-info.javaWelcome.java文件保存在本节开头指定的目录中。

  • 您的计算机上可能没有安装 JDK 17。

  • 如果你已经安装了 JDK 17,你还没有将JDK_HOME\bin目录添加到PATH环境变量中,这里JDK_HOME指的是你在机器上安装 JDK 17 的目录。如果您在目录C:\java17中安装了 JDK 17,您需要将C:\java17\bin添加到您机器上的PATH环境变量中。

如果关于设置PATH环境变量的讨论没有帮助,您可以使用下面的命令。这个命令假设您已经在目录C:\java17中安装了 JDK:

C:\javafun> C:\java17\bin\javac -d mod\jdojo.intro src\jdojo.intro\module-info.java src\jdojo.intro\com\jdojo\intro\Welcome.java

如果您在编译源代码时收到以下错误消息,这意味着您正在使用旧版本的 JDK,例如 JDK 8:

src\jdojo.intro\module-info.java:1: error: class, interface, or enum expected
module jdojo.intro {

从 JDK 9 开始支持模块。在旧的 JDK 上编译module-info.java源文件会导致这个错误。修复方法是使用 JDK 17 的javac命令来编译你的源文件。

字节码文件(.class文件)的名称是Welcome.class。为什么编译器选择将类文件命名为Welcome.class?当您编写源代码并编译它时,您在三个地方使用了“欢迎”一词:

  • 首先,您声明了一个名为Welcome的类。

  • 其次,您将源代码保存在一个名为Welcome.java的文件中。

  • 第三,将Welcome.java文件名作为输入传递给编译器。

你的三个步骤中的哪一个促使编译器将生成的字节码文件命名为Welcome.class?首先,这似乎是第三步,即将Welcome.java作为输入文件名传递给 Java 编译器。然而,猜测是错误的。这是第一步,在文件Welcome.java中声明一个名为Welcome的类,这促使编译器将输出的字节码文件命名为Welcome.class。您可以在一个编译单元中声明任意多个类。假设您在一个名为Welcome.java的编译单元中声明了两个类WelcomeBye。编译器将选择什么文件名来命名输出类文件?编译器扫描整个编译单元。它为编译单元中声明的每个类(实际上是每个类型)创建一个类文件。如果Welcome.java文件有三个类——WelcomeThanksBye——编译器将生成三个类文件——Welcome.classThanks.classBye.class

要运行 Java 程序,您可以安排类文件:

  • 在展开的目录中,正如您现在所看到的

  • 在一个或多个 JAR 文件中

  • 或者两者的组合——展开的目录和 JAR 文件

您现在可以使用javafun\mod\jdojo.intro目录中的类文件运行您的程序。我将暂时推迟运行它。首先,我们将在下一节向您展示如何将编译后的代码打包到一个 JAR 文件中。

打包编译后的代码

JDK 附带了一个名为jar的工具,用于将 Java 编译的代码打包到 JAR 文件中。JAR 文件格式使用 ZIP 格式。JAR 文件只是一个扩展名为.jar的 ZIP 文件,在它的META-INF目录中有一个MANIFEST.MF文件。MANIFEST.MF文件是一个文本文件,包含不同 Java 工具使用的 JAR 文件及其内容的信息。JDK 还包含以编程方式处理 JAR 文件的 API。在这一节中,我们简要说明如何使用jar工具创建一个 JAR 文件。使用jar命令的语法如下:

jar [options] [-C <dir-to-change>] <file-list>

--create选项创建一个新的 JAR 文件。--file选项用于指定要创建的 JAR 文件的名称。-C选项用于指定一个目录,该目录将作为当前目录,该选项后指定的所有文件都将包含在 JAR 文件中。如果您想包含几个目录中的文件,您可以多次指定-C选项。

下面的命令在C:\javafun\lib目录中创建一个名为com.jdojo.intro.jar的 JAR 文件。在运行命令之前,确保C:\javafun\lib目录存在:

C:\javafun>jar --create --file lib/com.jdojo.intro.jar -C mod/jdojo.intro .

这里

  • --create选项指定您想要创建一个新的 JAR 文件。

  • --file lib/com.jdojo.intro.jar选项指定新文件的路径和名称。注意,文件路径以lib开始,这是相对于C:\javafun目录的。您可以自由使用绝对路径,如C:\javafun\lib\com.jdjo.intro.jar

  • -C mod/jdojo.intro选项指定jar命令应该切换到mod/jdojo.intro目录。

  • 注意,-C选项后面是一个空格,然后是一个点,或者命令以一个点结束。点表示当前目录,这是用-C选项指定的目录。它要求切换到mod/jdojo.intro目录,并递归地包含该目录中的所有文件。

该命令创建以下文件:

C:\javafun\lib\com.jdojo.intro.jar

您可以在jar命令中使用--list选项来列出 JAR 文件的内容。使用以下命令列出由前面的命令创建的com.jdojo.intro.jar文件的内容:

C:\javafun>jar --list --file lib/com.jdojo.intro.jar
META-INF/
META-INF/MANIFEST.MF
module-info.class
com/
com/jdojo/
com/jdojo/intro/
com/jdojo/intro/Welcome.class

输出显示了 JAR 文件中的所有目录和文件。输入目录中没有名为MANIFEST.MF的文件。jar命令为您创建一个MANIFEST.MF文件。您还可以看到,JAR 文件的根目录包含了module-info.class文件,而Welcome.class文件被放在了一个目录中,该目录镜像了它被放在mod\jdojo.intro目录中的目录,而后者又镜像了在其包名中指定的目录层次结构。

如果一个 JAR 文件包含一个module-info.class文件,它也被称为模块描述符,这个文件被称为一个模块化 JAR。否则,该文件就被简单地称为 JAR。在这个例子中,com.jdojo.intro.jar文件是一个模块化的 JAR。如果你把module-info.class文件移除,它就会变成一个罐子。

Tip

根目录中包含模块描述符(module-info.class)的 JAR 文件称为模块化 JAR。在 JDK 9 之前没有模块,所以没有模块化的罐子。

您可以使用jar工具来描述使用--describe-module选项的模块,并通过使用--file选项指定模块化 JAR。以下命令描述了打包在com.jdojo.intro.jar文件中的模块:

C:\javafun>jar --describe-module --file lib/com.jdojo.intro.jar
jdojo.intro jar:file:///C:/javafun/lib/com.jdojo.intro.jar/!module-info.class
requires java.base mandated
contains com.jdojo.intro

考虑以下命令的输出:

  • 第一行以模块名开始,是jdojo.intro。名称后面是模块描述的路径。该路径使用一个jar方案,并指向文件系统。

  • 第二行提到一个requires语句,表明jdojo.intro模块需要java.base模块。回想一下,每个模块都隐式依赖于java.base模块。编译器为您添加了这个。最后一个词,mandated,表示对java.base模块的依赖是由 Java 模块系统规定的。

  • 第三行表示jdojo.intro模块包含一个名为com.jdojo.intro的包。contains这个短语用来表示这个包在模块中,但是它没有被模块导出,所以其他模块不能使用这个包。对于每个导出的包,该命令将打印以下内容:

exports <package-name>.

输出中的最后一行需要解释一下。图 3-1 显示一个模块包含一个或多个包。输出显示jdojo.intro模块包含一个com.jdojo.intro包。然而,您从未指定模块和包之间的链接——无论是在编写源代码时还是在编译或打包期间。那么模块如何知道它们包含的包呢?答案很简单。将module-info.class文件放在根目录下会使模块包含其下的所有包。在您的例子中,镜像Welcome类的com.jdojo.intro包的com/jdojo/intro目录位于模块 JAR 的根目录下。这就是它成为模块一部分的原因。

Tip

模块化 JAR 只包含一个模块的代码。根目录下的所有包都是该模块的一部分。

运行 Java 程序

Java 程序是由 JVM 运行的。使用名为java的命令调用 JVM,该命令位于JDK_HOME\bin目录中。java命令也被称为 Java 启动器。运行它的语法如下:

java [options] --module <module-name>[/<main-class-name>] [arguments]

这里

  • [options]表示传递给java命令的零个或多个选项。

  • --module选项指定模块名和模块内的主类名。<module-name>是模块名,例如jdojo.intro,<main-class-name>是主类的全限定名,例如com.jdojo.intro.Welcome。当您在模块化 JAR 中打包一个模块时,您可以为该模块指定主类名,它存储在模块描述符——module-info.class文件中。在上一节创建com.jdojo.intro.jar模块化 JAR 时,您还没有指定主类名。通过<main-class-name>是可选的。如果没有指定,那么java命令将使用模块描述符中的主类名。该命令调用<main-class-name>main()方法。

  • [arguments]是传递给主类的main()方法的以空格分隔的参数列表。注意,[options]被传递给java命令(或 JVM),而[arguments]被传递给正在运行的主类的main()方法。[arguments]必须在--module选项后指定。

让我们尝试使用以下命令运行Welcome类:

C:\javafun>java --module jdojo.intro/com.jdojo.intro.Welcome
Error occurred during initialization of boot layer
java.lang.module.FindException: Module jdojo.intro not found

哎呀!你弄错了。我们有意使用这个命令,以便您可以理解使用模块运行 Java 程序时发生的幕后过程。输出中有两条消息:

  • 第一条消息指出,当 JVM 试图初始化引导层时发生了一个错误。

  • 第二条消息声明 JVM 无法找到jdojo.intro模块。

在启动时,JVM 解析模块的依赖关系。如果启动时没有解析所有必需的模块,程序将无法启动。这是在 Java 9+中使用模块的一个重大改进,在 Java 9+中,所有依赖项都在启动时进行验证。否则,运行时会试图在程序需要依赖项(类型)时解析它们,而不是在启动时解析,这会导致许多运行时意外。

模块系统在一个阶段(编译时或运行时)可访问的所有模块称为可观察模块。模块解析从一组被称为根模块的初始模块开始,并遵循依赖链,直到到达java.base模块。分解模块的集合被称为模块图。在模块图中,每个模块都表示为一个节点。如果第一个模块依赖于第二个模块,则存在从一个模块到另一个模块的有向边。

图 3-6 显示了带有两个名为AB的根模块的模块图。模块A依赖于模块P,模块P又依赖于java.base模块。模块B依赖于模块Q,模块Q又依赖于java.base模块。Java 运行时将只使用已解析的模块。也就是说,Java 运行时只知道模块图中的模块。

img/323069_3_En_3_Fig6_HTML.png

图 3-6

模块图

通常,您只有一个根模块。根模块的集合是如何确定的?当您从一个模块运行 Java 程序时,包含主类的模块是唯一的默认根模块。

Tip

如果您需要解析额外的模块,否则默认情况下不会被解析,您可以使用--add-modules命令行选项将它们添加到默认的根模块集中。我们将把添加根模块的讨论推迟到后面的章节,因为这是一个高级主题。

让我们回到解决我们的错误。前面的命令试图运行jdojo.intro模块中的Welcome类。因此,jdojo.intro模块是唯一的根模块。如果一切正常,JVM 应该已经创建了如图 3-7 所示的模块图。

img/323069_3_En_3_Fig7_HTML.jpg

图 3-7

运行 Welcome 类时在启动时创建的模块图

为了构建这个模块图,JVM 需要定位根模块jdojo.intro。JVM 将只在可观察模块集中寻找一个模块。错误中的第二行表示 JVM 找不到根模块jdojo.intro。要修复这个错误,你需要将jdojo.intro模块包含在可观察模块集中。您知道模块的代码存在于两个位置:

  • javafun\mod\jdojo.intro directory

  • javafun\lib\com.jdojo.intro.jar file

有两种类型的模块:JDK 附带的内置模块和您创建的用户定义模块。JVM 知道所有的内置模块,并将它们包含在可观察模块集中。您需要使用--module-path选项指定用户定义模块的位置。在 modulepath 上找到的模块将包含在可观察模块集中,它们将在模块解析过程中被解析。使用此选项的语法如下:

--module-path <your-module-path>

Module-path是路径名的序列,其中路径名可以是目录、模块化 JAR 或 JMOD 文件的路径。路径可以是绝对的,也可以是相对的。我们将在本书的后面讨论 JMOD 文件。路径名由特定于平台的路径分隔符分隔,在类似 UNIX 的平台上是冒号(:),在 Windows 上是分号(;)。以下是 Windows 上的有效模块路径:

  • C:\javafun\lib

  • C:\javafun\lib;C:\javafun\mod\jdojo.contact\com.jdojo.contact.jar

  • C:\javafun\lib;C:\javafun\extlib

第一个 modulepath 包含一个名为C:\javafun\lib的目录的路径。第二个包含到一个C:\javafun\lib目录和一个位于C:\javafun\mod\jdojo.contact\com.jdojo.contact.jar的模块化 JAR 的路径。第三个包含两个目录的路径— C:\javafun\libC:\javafun\extlib。在类似 UNIX 的平台上,这些模块路径的等效路径看起来类似于:

  • /home/ksharan/javafun/lib

  • /home/ksharan/javafun/lib:/home/ksharan/javafun/mod/jdojo.contact/com.jdojo.contact.jar

  • /home/ksharan/javafun/lib:/home/ksharan/javafun/extlib

JVM 如何使用 modulepath 找到模块?JVM 使用以下规则扫描 modulepaths 上存在的所有模块:

  • 如果路径名是一个目录,那么将在三个地方扫描包含模块的module-info.class文件:目录本身、所有直接子目录和目录中所有模块化 jar 的根目录。如果在这些地方找到了一个module-info.class文件,那么这个模块就包含在可观察模块集中。请注意,子目录不会被递归扫描。

  • 如果路径名是模块化 JAR 或 JMOD 文件,则模块化 JAR 或 JMOD 文件被认为包含模块,该模块被包括在可观察模块的集合中。

使用第一个规则,如果您将N模块化 jar 放在一个C:\javafun\lib目录中,那么在 modulepath 上指定这个目录将包含可观察模块集中的所有N模块。如果您在一个目录中有多个模块,但是您想在一组可观察的模块中只包含其中的几个,那么您可以使用第二种形式,即模块化 JAR 或 JMOD 文件的路径。

您已经将本例中名为com.jdojo.intro.jar的模块化 JAR 放到了C:\javafun\lib目录中。因此,指定C:\javafun\lib作为 modulepath 将使 JVM 能够找到jdojo.intro模块。让我们使用下面的命令来运行Welcome类:

C:\javafun>java --module-path C:\javafun\lib --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

该命令假设C:\javafun是当前目录。您可以在 modulepath 上使用相对路径,即lib而不是C:\javafun\lib,如下所示:

C:\javafun>java --module-path lib --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

这一次,JVM 能够找到jdojo.intro模块。它在包含jdojo.intro模块的C:\javafun\lib目录中找到了一个模块化 JAR 文件com.jdojo.intro.jar

您将模块的编译类保存在C:\javafun\mod\jdojo.intro目录中。模块代码存在于该目录中展开的目录中。根目录包含了module-info.class文件。你也可以在一个模块中运行一个类,它的代码保存在一个展开的目录结构中,就像在C:\javafun\mod\jdojo.intro目录中的那个。下面的命令运行jdojo.intro模块中的Welcome类,该模块的代码在C:\javafun\mod\jdojo.intro目录中:

C:\javafun>java --module-path C:\javafun\mod\jdojo.intro --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

这一次,JVM 扫描了C:\javafun\mod\jdojo.intro目录,发现了一个包含jdojo.intro模块描述符的module-info.class文件。

您可以使用C:\javafun\mod目录作为 modulepath 的一部分来运行相同的命令,如下所示:

C:\javafun>java --module-path C:\javafun\mod --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

JVM 这次是怎么找到jdojo.intro模块的?回想一下在目录中查找模块描述符的规则。JVM 在C:\javafun\mod目录中寻找一个不存在的module-info.class文件。它在目录中寻找任何模块化 jar,但没有找到。现在它寻找C:\javafun\mod目录的直接子目录。它找到了一个名为jdojo.intro的子目录。它扫描了jdojo.intro子目录中的module-info.class文件,并找到了一个包含jdojo.intro模块的模块描述符的文件。这就是如何找到jdojo.intro模块的。

许多 GNU 风格的选项也有较短的名称。例如,您可以分别为--module-path–-module选项使用较短的名称-p-m。前面的命令也可以写成如下形式:

C:\javafun>java -p C:\javafun\mod -m jdojo.intro/com.jdojo.intro.Welcome

解析模块不会加载该模块中的所有类。一次加载所有模块中的所有类是低效的。当程序中第一次引用类时,类被加载。JVM 只定位模块,并做一些内务处理以获得关于模块的更多信息。例如,它跟踪模块包含的所有包。JVM 是如何加载Welcome类的?JVM 使用了类的三条信息:模块路径、模块名和类的完全限定名。在运行Welcome类时,您指定了两条信息:

  • 主模块名,即jdojo.intro。这使得 JVM 定位了这个模块,并且知道这个模块包含了com.jdojo.intro包。回想一下,一个包对应一个目录结构。在这种情况下,JVM 知道在模块内容、模块化 JAR 或包含模块描述符的目录中,存在一个包com/jdojo/intro,它保存了com.jdojo.intro包中的内容。

  • 除了主模块,您还指定了主类的完全限定名,即com.jdojo.intro.Welcome。为了定位Welcome类,JVM 首先找到包含com.jdojo.intro包的模块。它找到jdojo.intro模块来包含这个包。它将包名转换成一个目录层次结构,在类名后面附加一个.class扩展名,并尝试在com/jdojo/intro/Welcome.class定位该类。

根据这两条规则,我们来定位Welcome类文件。如果你指定了javafun\lib目录作为模块路径,com.jdojo.intro.jar文件包含了jdojo.intro模块的内容,这个文件也包含了com/jdojo/intro/Welcome.class文件。这就是Welcome类文件的定位和加载方式。如果您将javafun\mod目录指定为 modulepath,则javafun\mod\jdojo.intro目录包含jdojo.intro模块的内容,并且该目录还包含com/jdojo/intro/Welcome.class文件。图 3-8 描述了需要加载Welcome类时寻找Welcome.class文件的过程。图中使用了C:\javafun\mod\jdojo.intro作为模块和 Windows 路径分隔符的位置,这是一个反斜杠。在类 UNIX 操作系统上,路径分隔符将是一个正斜杠。JAR 文件也使用正斜杠作为路径分隔符。

img/323069_3_En_3_Fig8_HTML.png

图 3-8

使用模块路径在模块中查找类文件的过程

这个例子很简单。它只涉及两个模块— java.basejdojo.intro。如果您关注了讨论,您就会知道当您运行Welcome类时这些模块是如何被解析的。有几个命令行选项可以帮助您理解使用模块时幕后发生的事情。下一节将探讨这样的命令行选项。

使用模块选项

有一些命令行选项可以让您获得关于使用了哪些模块以及如何解析这些模块的更多信息。这些选项对于调试或减少已解析模块的数量非常有用。在这一节中,我将向您展示一些使用这些选项的例子。

列出可观察模块

使用带有java命令的--list-modules选项,您可以打印可观察模块的列表。该选项不接受任何参数。以下命令将打印可观察模块集中包含的所有平台模块的列表。该命令打印大约 100 个模块。显示了部分输出:

C:\javafun>java --list-modules
java.activation@17
java.base@17
java.desktop@17
java.se@17
java.se.ee@17
...

在输出中,模块名后跟一个字符串"@17"。如果模块描述符包含模块版本,则版本显示在@符号之后。如果你使用的是 JDK 17 的最终版本,版本号将会是"17"

要将您的模块包含在可观察模块集中,您需要指定放置模块的 modulepath。以下命令将把jdojo.intro模块包含在可观察模块集中。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib --list-modules
java.activation@17
java.base@17
java.desktop@17
java.se@17
java.se.ee@17
...
jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar

注意输出中的最后一项:

  • 它没有打印jdojo.intro模块的模块版本。这是因为您在创建模块化 JARcom.jdojo.intro.jar时没有指定模块版本。我将在下一节向您展示如何指定模块的版本。

  • 它打印出发现了jdojo.intro模块的模块化 JAR 的路径。当您的模块没有被正确解析时,这对于调试非常有帮助。

限制可观察模块

您可以使用--limit-modules减少可观察模块的数量。它接受逗号分隔的模块名称列表:

--limit-modules <module-name>[,<module-name>...]

可观察的模块限于指定模块的列表以及它们递归依赖的模块,加上使用--module选项指定的主模块,加上使用--add-modules选项指定的任何模块。当您通过将 jar 放在类路径上以遗留模式运行 Java 程序时,此选项非常有用,在这种情况下,所有平台模块都包含在根模块集中。

让我们通过在运行Welcome类时使用它来看看这个选项的效果。Welcome类只使用了java.base模块。为了将可观察的模块限制在java.basejdojo.intro模块,您可以将java.base指定为--limit-modules选项的值,如下所示:

C:\javafun>java --module-path C:\javafun\lib --limit-modules java.base --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

注意,尽管您只为--limit-modulus选项指定了java.base模块,但是jdojo.intro模块也包含在可观察模块中,因为它是您正在运行的主模块。

您可以使用-verbose:module选项打印加载的模块。下面的命令运行带有--limit-module选项的Welcome类,并且只加载两个模块:

C:\javafun>java --module-path C:\javafun\lib --limit-modules java.base -verbose:module --module jdojo.intro/com.jdojo.intro.Welcome
[0.079s][info][module,load] java.base location: jrt:/java.base
[0.135s][info][module,load] jdojo.intro location: file:///C:/javafun/lib/com.jdojo.intro.jar
Welcome to Java 17!

以下命令运行不带--limit-module选项的Welcome类,并加载大约 40 个模块。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib -verbose:module --module jdojo.intro/com.jdojo.intro.Welcome
[0.082s][info][module,load] java.base location: jrt:/java.base
[0.142s][info][module,load] jdk.naming.rmi location: jrt:/jdk.naming.rmi
[0.144s][info][module,load] jdk.scripting location: jrt:/jdk.scripting
[0.144s][info][module,load] java.logging location: jrt:/java.logging
[0.144s][info][module,load] jdojo.intro location: file:///C:/javafun/lib/com.jdojo.intro.jar
[0.156s][info][module,load] java.management location: jrt:/java.management
...
Welcome to Java 17!

描述模块

您可以使用带有java命令的--describe-module选项来描述一个模块。回想一下,您也可以使用这个选项和jar命令(参见“打包编译后的代码”一节中的例子)来描述模块化 JAR 中的模块。请确保在描述模块时指定模块路径。要描述平台模块,您不需要指定 modulepath。以下命令显示了几个示例:

C:\javafun>java --module-path C:\javafun\lib --describe-module jdojo.intro
jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar
requires java.base mandated
contains com.jdojo.intro
C:\javafun>java --describe-module java.sql
java.sql@17
exports java.sql
exports javax.sql
requires java.logging transitive
requires java.xml transitive
requires java.base mandated
requires java.transaction.xa transitive
uses java.sql.Driver

打印模块分辨率详细信息

使用带有java命令的--show-module-resolution选项,您可以打印启动时发生的模块解析过程的细节。当运行Welcome类时,下面的命令使用这个选项。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib --show-module-resolution --module jdojo.intro/com.jdojo.intro.Welcome

root jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar
java.base binds jdk.zipfs jrt:/jdk.zipfs
java.base binds jdk.jdeps jrt:/jdk.jdeps
java.base binds java.desktop jrt:/java.desktop
java.desktop requires java.xml jrt:/java.xml
java.desktop requires java.datatransfer jrt:/java.datatransfer
java.desktop requires java.prefs jrt:/java.prefs
...
Welcome to Java 17!

输出中的第一行显示了被解析的根模块以及根模块的位置。java.base模块不需要任何其他模块。然而,它使用许多服务提供商,如果他们存在的话。输出中的"java.base binds ..."文本表明java.base模块使用的服务提供者存在于可观察模块集中,并且它们被解析。服务提供者模块可能需要其他模块,这也将被解决。java.desktop模块的分辨率就是这样一种情况。java.desktop模块被解析是因为它提供了java.base模块使用的服务,该服务触发了java.xmljava.datatransferjava.prefs模块的解析,因为java.desktop模块需要这三个模块。

Tip

即使您的程序只使用了不需要任何其他模块的java.base模块,其他平台模块也会被解析,因为它们提供了由java.base模块使用的服务。将平台模块限制为java.base模块的最佳方式是使用--limit-modules选项,并将java.base作为其值。

模拟运行您的程序

您可以使用--dry-run选项来模拟运行一个类。它创建 JVM 并加载主类,但不执行主类的main()方法。此选项对于验证模块配置和调试非常有用。以下命令显示了它的用法。输出不包含欢迎消息,因为没有执行Welcome类的main()方法。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib --dry-run --show-module-resolution --module jdojo.intro/com.jdojo.intro.Welcome
root jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar
java.base binds jdk.zipfs jrt:/jdk.zipfs
java.base binds java.logging jrt:/java.logging
java.base binds jdk.localedata jrt:/jdk.localedata
...

增强模块描述符

你在一个module-info.java文件中声明一个模块。模块声明被编译成一个名为module-info.class的类文件。模块的设计者可以使用 XML 或 JSON 格式来声明模块。他们为什么选择类文件格式来存储模块声明?这有几个原因:

  • Java 社区已经熟知了类文件格式。

  • 类文件格式是可扩展的。也就是说,工具可以在编译后扩充module-info.class文件。

  • JDK 已经支持一个名为package-info.java的类似文件,它被编译成一个package-info。用于存储包信息的类文件。

jar工具包含几个选项来扩充模块描述符,其中两个是模块版本和主类名。不能在其声明中指定模块的版本。JDK 9 的设计者在其声明中避免处理模块的版本,声明管理模块的版本是诸如 Maven 或 Gradle 等构建工具的工作,而不是模块系统提供者的工作。鉴于模块描述符的可扩展特性,您可以将模块的版本作为类文件的属性存储在module-info.class文件中。作为开发人员,添加类文件属性并不容易。您可以使用jar工具的--module-version选项将模块版本添加到module-info.class文件中。您已经创建了一个com.jdojo.intro.jar文件,它包含了jdojo.intro模块的模块描述符。让我们重新运行描述现有com.jdojo.intro.jar文件中的jdojo.intro模块的命令,如下所示:

C:\javafun>jar --describe-module --file lib/com.jdojo.intro.jar
jdojo.intro jar:file:///C:/javafun/lib/com.jdojo.intro.jar/!module-info.class
requires java.base mandated
contains com.jdojo.intro

输出中没有模块版本。以下命令通过将模块版本指定为 1.0 来重新创建com.jdojo.intro.jar文件:

C:\javafun>jar --create --module-version 1.0 --file lib/com.jdojo.intro.jar -C mod/jdojo.intro.

Tip

通常,您应该将模块版本附加到模块 JAR 名称后面。在前面的例子中,您应该将文件命名为com.jdojo.intro-1.0.jar,这样它的所有者就会知道这个模块化 JAR 中存储的是什么版本的模块。我选择了相同的名称(com.jdojo.intro.jar)来简化这个例子。

以下命令重新描述模块,输出显示模块名称及其版本。如果有版本,模块名以<module-name>@<module-version>的形式打印出来:

C:\javafun>jar --describe-module --file lib/com.jdojo.intro.jar
jdojo.intro@1.0 jar:file:///C:/javafun/lib/com.jdojo.intro.jar/!module-info.class
requires java.base mandated
contains com.jdojo.intro

在一个典型的应用程序中,您将有一个主模块,它是一个包含主类的模块。您可以将主类的名称存储在模块描述符中。当您创建或更新模块化 JAR 时,您所需要做的就是使用带有jar工具的--main-class选项。主类名是包含您想用作应用程序入口点的main()方法的类的完全限定名。以下命令更新现有的模块化 JAR 以添加主类名:

C:\javafun>jar --update --main-class com.jdojo.intro.Welcome --file lib\com.jdojo.intro.jar

以下命令使用模块版本和主类名重新创建模块化 JAR:

C:\javafun>jar --create --module-version 1.0 --main-class com.jdojo.intro.Welcome --file lib/com.jdojo.intro.jar -C mod/jdojo.intro .

模块描述符中的模块版本和主类怎么处理?模块版本旨在供 Maven 和 Gradle 等构建工具使用。当模块存在多个版本时,您需要在应用程序中包含模块的正确版本。如果您的模块描述符包含一个主类属性,您可以使用模块的名称来运行应用程序。JVM 将从模块描述符中读取主类名。现在,jdojo.intro模块的模块描述符包含了主类名。以下命令将运行Welcome类:

C:\javafun>java --module-path C:\javafun\lib --module jdojo.intro
Welcome to Java 17!

在传统模式下运行 Java 程序

模块系统是在 JDK 9。以前 Java 程序是如何编写、编译、打包、运行的?把模块系统拿出来,你会发现以前 Java 程序几乎都是这么写的。然而,运行它们的机制是不同的。你在这一章中编写的Welcome类也将在 JDK 8 中编译和运行。除了少数例外,Java 一直是向后兼容的。你在 JDK 8 中编写的程序也可以在 JDK 17 中运行。

在 JDK 9 之前,类总是使用类路径定位。类路径是一系列目录、JAR 文件和 ZIP 文件。类路径中的每个条目由特定于平台的路径分隔符分隔,在 Windows 上是分号(;),在类似 UNIX 的操作系统上是冒号(:)。如果比较类路径和模块路径的定义,它们看起来是一样的。它们之间的区别在于类路径用于定位类(更具体地说是类型),而模块路径用于定位模块。

Tip

您将会遇到两个术语,“加载类”和“加载模块”当加载一个类时,从模块路径或类路径中读取它的类文件,该类在运行时表示为一个对象。当一个模块被加载时,模块描述符(module-info.class文件)和其他一些内务处理一起被读取;该模块在运行时表示为一个对象。加载一个模块并不意味着加载该模块中的所有类,这将是非常低效的。模块中的类在运行时第一次被程序引用时被加载。

JDK 17 允许你只使用模块路径,只使用类路径,或者两者结合使用。仅使用 modulepath 意味着您的程序仅由模块组成。只使用类路径意味着你的程序不包含模块。使用两者的组合意味着你的程序的一部分由一些模块组成,而另一部分没有。JDK 9 模块化的 JDK 代码。例如,无论您是否从一个模块运行程序,总是使用java.base模块。Java 支持三种模式:

  • 模块模式

  • 传统模式

  • 混合模式

在你的程序中只使用模块被称为模块模式,并且只使用模块路径。仅使用类路径被称为遗留模式,并且仅使用类路径。使用两者的组合被称为混合模式。JDK 9+支持这些向后兼容的模式。例如,您应该能够在 JDK 17 中使用遗留模式“按原样”运行您的 JDK 8 程序,在该模式下,您将把所有现有的 jar 放在类路径中。如果您正在使用模块开发一个新的 Java 应用程序,但是仍然有一些来自 JDK 8 的 jar,那么您可以使用混合模式,将您的模块化 jar 放在 modulepath 上,将现有的 jar 放在类路径上。

可以使用三个同义选项来指定一个类:--class-path-classpath-cp。第一个选项是在 JDK 9 中添加的,另外两个选项以前就有了。在传统模式下运行 Java 程序的一般语法如下:

java [options] <main-class-name> [arguments]

这里,[options][arguments]与上一节“运行 Java 程序”中讨论的含义相同由于在遗留模式中没有用户定义的模块,您只需简单地指定您想要运行的主类的完全限定名为<main-class-name>。因为必须在模块模式下指定 modulepath,所以必须在遗留模式下指定 class-path。

以下命令在传统模式下运行Welcome类。您不需要重新编译Welcome类。您可以保留或删除module-info.class文件,因为它不会在传统模式下使用:

C:\javafun>java --class-path C:\javafun\mod\jdojo.intro com.jdojo.intro.Welcome
Welcome to Java 17!

JVM 使用以下步骤来运行Welcome类:

  • 它检测到您正试图运行com.jdojo.intro.Welcome类。

  • 它将主类名转换成文件路径com\jdojo\intro\Welcome.class

  • 它获取类路径中的第一个条目,并查找在上一步中计算的Welcome.class文件的路径是否存在。类路径中只有一个条目,它使用那个条目找到了Welcome.class文件。JVM 尝试使用类路径中的所有条目来查找类文件,直到找到该类文件。如果没有找到使用所有条目的类文件,它抛出一个ClassNotFoundException

类路径和模块路径的工作方式有一些不同。类路径中的条目“按原样”使用也就是说,如果在类路径上指定一个目录路径,该目录路径将被附加到类文件路径的前面,以便查找类文件。与之相比,modulepath 包含一个目录路径,在该路径中搜索目录本身、目录中的所有模块化 jar 以及所有直接子目录,以查找模块描述符。使用这个规则,如果您想从 JAR 文件中以遗留模式运行Welcome类,您需要在类路径上指定 JAR 的完整路径。

以下命令无法找到Welcome类,因为在C:\javafun\modC:\javafun\lib目录中没有找到com\jdojo\intro\Welcome.class文件:

C:\javafun>java --class-path C:\javafun\mod com.jdojo.intro.Welcome
Error: Could not find or load main class com.jdojo.intro.Welcome
Caused by: java.lang.ClassNotFoundException: com.jdojo.intro.Welcome
C:\javafun>java --class-path C:\javafun\lib com.jdojo.intro.Welcome
Error: Could not find or load main class com.jdojo.intro.Welcome
Caused by: java.lang.ClassNotFoundException: com.jdojo.intro.Welcome

以下命令找到了Welcome类,因为您在类路径中指定了 JAR 路径:

C:\javafun>java --class-path C:\javafun\lib\com.jdojo.intro.jar com.jdojo.intro.Welcome
Welcome to Java 17!

拥有多个 jar 是典型的非平凡 Java 应用程序。将所有 jar 的完整路径添加到类路径中非常不方便。为了支持这个用例,class-path 语法支持在条目中使用星号(*)作为最后一个字符,这扩展到该条目所代表的目录中的所有 JAR 和 ZIP 文件。假设您有一个名为cdir的目录,其中包含两个 jar—x.jary.jar。要在类路径中包含这两个 jar,您可以在 Windows 中使用以下路径序列之一:

  • cdir\x.jar;cdir\y.jar

  • cdir\*

第二种情况下的星号将被扩展为cdir目录中每个 JAR/ZIP 文件一个条目。这种扩展发生在 JVM 启动之前。以下命令显示了如何在类路径中使用星号:

C:\javafun>java -cp C:\javafun\lib\* com.jdojo.intro.Welcome
Welcome to Java 17!

您必须在类路径条目的末尾使用星号或单独使用星号。如果单独使用星号,星号将被扩展为包括当前目录中的所有 JAR/ZIP 文件。以下命令使用C:\javafun\lib目录作为当前目录,并使用星号作为运行Welcome类的类路径:

C:\javafun\lib>java -cp * com.jdojo.intro.Welcome
Welcome to Java 17!

在混合模式下,可以像这样同时使用 modulepath 和 class-path:

java --module-path <module-path> --class-path <class-path> <other-arguments>

可能会有这样的情况,您可能有重复的类——一个副本在模块路径上,另一个副本在类路径上。在这种情况下,使用 modulepath 上的版本,实际上忽略了类路径副本。如果类路径中存在重复的类,则使用在类路径中最先找到的类。模块之间不允许有重复的包和重复的类。也就是说,如果您有一个名为com.jdojo.intro的包,那么这个包中的所有类都必须通过一个模块可用。否则,您的应用程序将无法编译/运行。

如果 Java 只处理模块,那么从类路径加载的非模块类型是如何使用的?类型是由类装入器装入的。每个类装入器都有一个名为的未命名的模块。从类路径加载的所有类型都成为其类加载器的未命名模块的成员。从 modulepath 加载的所有模块都是声明它们的模块的成员。我们将在后面的章节中重新讨论未命名的模块。

模块路径上的重复模块

有时,在 modulepath 上可能有相同模块的多个版本。模块系统如何从模块路径中选择使用哪个模块副本?在 modulepath 中有两个同名的模块总是错误的。模块系统以有限的方式防止你犯这样的错误。

让我们从一个例子开始,来理解解决重复模块的规则。您有两个版本的jdojo.intro模块——一个在C:\javafun\lib目录下的com.jdojo.intro.jar文件中,另一个在C:\javafun\mod\jdojo.intro目录下。运行Welcome类并在 modulepath 中包含这两个目录:

C:\javafun>java --module-path C:\javafun\lib;C:\javafun\mod\jdojo.intro --module jdojo.intro/com.jdojo.intro.Welcome
Welcome to Java 17!

您可能已经预料到这个命令会失败,因为运行一个程序时,运行时系统可以访问同一个模块的两个版本是没有意义的。此命令使用了模块的哪个副本?很难通过查看输出来判断,因为模块的两个副本包含相同的代码。您可以使用--show-module-resolution选项查看模块加载的位置。下面的命令可以做到这一点。显示了部分输出:

C:\javafun>java --module-path C:\javafun\lib;C:\javafun\mod\jdojo.intro --show-module-resolution --module jdojo.intro/com.jdojo.intro.Welcome
root jdojo.intro file:///C:/javafun/lib/com.jdojo.intro.jar
...
Welcome to Java 17!

输出表明jdojo.intro模块,在本例中是根模块,是从C:\javafun\lib目录中的模块化 JAR com.jdojo.intro.jar中加载的。让我们交换 modulepath 中条目的顺序,然后重新运行命令:

C:\javafun>java --module-path C:\javafun\mod\jdojo.intro;C:\javafun\lib --show-module-resolution --module jdojo.intro/com.jdojo.intro.Welcome
root jdojo.intro file:///C:/javafun/mod/jdojo.intro/
...
Welcome to Java 17!

这一次,输出表明从C:\javafun\mod\jdojo.intro目录加载了jdojo.intro模块。规则如下:

如果通过 modulepath 中的不同条目可以访问一个模块的多个同名副本,则使用 modulepath 中最先找到的模块副本。

使用这个规则,当在 modulepath 中首先列出了lib目录时,从lib目录中使用jdojo.intro模块,并忽略mod\jdojo.intro目录中的模块副本。当您颠倒了 modulepath 中这些条目的顺序时,就使用了mod\jdojo.intro目录中的模块。

注意规则中 modulepath" 短语中的不同条目可以访问的*。只要一个模块的多个副本存在于不同的 modulepath 条目中,这个规则就适用。但是,如果通过 modulepath 中的同一条目可以访问一个模块的多个副本,则会发生错误。你怎么会陷入这种境地?以下是一些可能性:*

  • 多个具有不同文件名的模块化 jar,但是具有相同名称的模块代码,可能存在于同一个目录中。如果这样的目录是 modulepath 中的一个条目,则可以通过这个单一的 modulepath 条目来访问一个模块的多个副本。

  • 当一个目录被用作 modulepath 条目时,该目录中的所有模块化 jar 和所有包含模块描述符的直接子目录都通过该 modulepath 条目来定位模块。这为通过单个 modulepath 条目访问多个同名模块提供了可能性。

在我们的例子中,jdojo.intro模块的两个副本不能通过单个 modulepath 条目访问。让我们使用以下步骤来模拟错误:

  • 创建一个名为C:\javafun\temp的目录。

  • C:\lib\com.jdojo.intro.jar文件复制到C:\javafun\temp目录。

  • C:\mod\jdojo.intro目录复制到C:\javafun\temp目录。

此时,您拥有以下文件:

  • C:\javafun\temp\com.jdojo.intro.jar

  • C:\javafun\temp\jdojo.intro\module-info.class

  • C:\javafun\temp\jdojo.intro\com\jdojo\intro\Welcome.class

如果在 modulepath 中包含了C:\javafun\temp目录,那么就可以访问jdojo.intro模块的两个副本——一个在模块 JAR 中,一个在子目录中。以下命令失败,并显示一条指明问题的明确消息:

C:\javafun>java --module-path C:\lib;C:\javafun\temp --module jdojo.intro/com.jdojo.intro.Welcome
Error occurred during initialization of boot layer
java.lang.module.FindException: Error reading module: C:\lib\com.jdojo.intro-1.0.jar
Caused by: java.lang.module.InvalidModuleDescriptorException: this_class should be module-info

下面的命令将C:\javafun\lib目录作为 modulepath 中的第一个条目,在这里只能找到模块的一个副本。它将C:\javafun\temp目录作为 modulepath 中的第二个条目。您仍然会得到相同的错误:

C:\javafun>java --module-path C:\javafun\lib;C:\javafun\temp --module jdojo.intro/com.jdojo.intro.Welcome
Error occurred during initialization of boot layer
java.lang.module.FindException: Two versions of module jdojo.intro found in C:\javafun\temp (jdojo.intro and com.jdojo.intro.jar)

命令行选项的语法

JDK 17 支持两种指定命令行选项的方式:

  • UNIX 风格

  • GNU 风格

UNIX 样式的选项以连字符(-)开头,后跟作为一个单词的选项名,例如-p-m-cp。GNU 风格的选项以两个连字符(--)开头,后跟选项名,其中选项名中的每个单词都用连字符连接,例如--module-path--module--class-path

JDK 的设计者已经没有对开发者有意义的选项的简称了。因此,JDK(版本 9)开始使用 GNU 风格的选项。大多数选项在两种风格中都可用。如果可能的话,我们鼓励您使用 GNU 风格的选项,因为它们更容易记忆,对读者来说更直观。

Tip

要打印 JDK 工具支持的所有标准选项的列表,使用--help-h选项运行工具,对于所有非标准选项,使用--help-extra-X选项运行工具。例如,java --helpjava --help-extra命令分别打印java命令的标准和非标准选项列表。

选项可以将一个值作为其参数。选项的值跟在选项名称后面。选项名称和值必须由一个或多个空格分隔。以下示例显示了如何使用两个选项通过java命令指定 modulepath:

// Using the UNIX-style option
C:\javafun>java -p C:\applib;C:\extlib <other-args-go-here>
// Using the GNU-style option
C:\javafun>java --module-path C:\applib;C:\lib <other-args-go-here>

当您使用 GNU 风格的选项时,您可以用以下两种形式之一指定该选项的值:

  • --<name> <value>

  • --<name>=<value>

前面的命令也可以写成如下形式:

// Using the GNU-style option
C:\>java -–module-path=C:\applib;C:\lib <other-args-go-here>

当使用空格作为name-value分隔符时,至少需要使用一个空格。当使用=作为name-value分隔符时,不得在其周围包含任何空格。这个选项

 --module-path=C:\applib

是有效的,而此选项

 --module-path =C:\applib

无效,因为" =C:\applib"将被解释为无效路径 modulepath。

使用 NetBeans IDE 编写 Java 程序

您可以使用 NetBeans IDE 来编写、编译和运行 Java 程序。在本节中,我们将引导您完成使用 NetBeans 的步骤。首先,您将学习如何创建一个新的 Java 项目,编写一个简单的 Java 程序,编译并运行它。最后,您将了解如何打开本书的 NetBeans 项目并使用本书提供的源代码。关于如何下载、安装和配置 NetBeans IDE,请参考第二章。

Note

在撰写本文时,NetBeans IDE 12.5 尚未发布。它将与 JDK 17 一起发布。当您阅读本章时,最终发布版本 12.5 应该已经发布。在本节中,我们使用 NetBeans 12.5 测试版的夜间版本。

创建 Java 项目

当您启动 NetBeans IDE 时,会显示启动页面,如图 3-9 所示。启动页面包含对开发人员有用的链接,如 Java、JavaFX、C++等教程的链接。如果不希望每次启动 IDE 时都显示启动页面,则需要取消选中启动页面右上角的“启动时显示”复选框。您可以通过点击起始页选项卡中显示的X图标来关闭起始页。使用帮助➤起始页可以随时打开起始页。

img/323069_3_En_3_Fig9_HTML.png

图 3-9

带有启动页面的 NetBeans IDE

要创建新的 Java 项目,请遵循以下步骤:

img/323069_3_En_3_Fig10_HTML.png

图 3-10

新项目对话框

  1. 选择文件➤新建项目或按 Ctrl+Shift+N,弹出新建项目对话框,如图 3-10 所示。

img/323069_3_En_3_Fig11_HTML.png

图 3-11

“新建 Java 模块化应用程序”对话框

  1. 在“新建项目”对话框的“类别”列表中,选择“Java with Ant”。在“项目”列表中,可以选择“Java 应用程序”、“Java 类库”或“Java 模块化项目”。当您选择一个类别时,其描述会显示在底部。在前两个类别中,您只能拥有一个 Java 模块,而第三个类别允许您拥有多个 Java 模块。选择 Java 模块化项目选项,然后单击下一个➤按钮。显示如图 3-11 所示的新建 Java 模块化应用程序对话框。

img/323069_3_En_3_Fig12_HTML.png

图 3-12

带有 Java17Fundamentals Java 项目的 NetBeans IDE

  1. 在新建 Java 模块化应用程序对话框中,输入Java17Fundamentals作为项目名称。在“项目位置”字段中,输入或浏览到要保存项目文件的位置。我输入C:\作为项目地点。NetBeans 将创建一个C:\Java17Fundamentals目录,用于存储Java17Fundamentals项目的所有文件。从平台下拉列表中选择 JDK 17 作为 Java 平台。如果没有 JDK 17 版可供选择,请单击管理平台...按钮并创建一个新的 Java 平台。创建一个新的 Java 平台只是在文件系统中添加一个存储 JDK 的位置,并给这个位置命名。完成后,单击“完成”按钮。新的Java17Fundamentals项目显示在 IDE 中,如图 3-12 所示。

在左上角,您可以看到三个选项卡:项目、文件和服务。“项目”选项卡显示所有与项目相关的文件。“文件”选项卡允许您查看计算机上的所有系统文件。“服务”选项卡允许您使用数据库和 web 服务器等服务。如果关闭这些选项卡,可以使用“窗口”菜单下与这些选项卡同名的子菜单重新打开它们。

至此,您已经创建了一个不包含任何模块的模块化 Java 应用程序项目。您需要向项目中添加模块。要新建模块,在项目页签中选择项目名称Java17Fundamentals,右键选择新建➤模块,如图 3-13 所示。显示新模块对话框,如图 3-14 所示。输入jdojo.intro作为模块名,并点击 Finish 按钮。

img/323069_3_En_3_Fig14_HTML.png

图 3-14

“新建模块”对话框

img/323069_3_En_3_Fig13_HTML.png

图 3-13

选择模块菜单项以创建菜单模块

图 3-15 显示了打开module-info.java文件的编辑器。我已经删除了 NetBeans IDE 添加的注释,并在顶部添加了一个注释。您可能需要在“项目”选项卡中展开文件树才能看到所有文件。创建一个jdojo.intro模块创建了一个module-info.java文件,其中包含了对jdojo.intro模块的模块声明。当在编辑器中打开一个module-info.java文件时,NetBeans IDE 会显示三个选项卡——源代码、历史记录和图表。选择图形选项卡显示模块图形,如图 3-16 所示。右键单击模块图中的空白区域,查看用于定制图形的选项。使用布局选项,您可以用不同的方式排列图表中的节点。我更喜欢通过分层排列节点来查看图表。使用“导出为图像”右键选项将图像导出为 PNG 图像。选择一个节点会突出显示进出所选节点的所有边,这使您可以轻松地在图形中可视化模块的角色。选择module-info.java选项卡下的源代码选项卡,查看模块的源代码。

img/323069_3_En_3_Fig16_HTML.png

图 3-16

由 NetBeans IDE 创建的模块图

img/323069_3_En_3_Fig15_HTML.png

图 3-15

jdojo.intro 模块及其 module-info.java 文件已在编辑器中打开

现在您已经准备好将Welcome类添加到jdojo.intro模块中。在项目选项卡中选择jdojo.intro模块节点,然后右键单击。然后选择新➤ Java 类...,显示如图 3-17 所示的新建 Java 类对话框。输入Welcome作为类名,输入com.jdojo.intro作为包名。然后单击“完成”按钮。

img/323069_3_En_3_Fig17_HTML.png

图 3-17

在“新建 Java 类”对话框中输入类的详细信息

图 3-18 显示了为Welcome类创建的源代码。我已经清理了创建新类时 NetBeans 添加的注释。您需要向Welcome类添加一个main()方法,如清单 3-3 所示。图 3-19 显示了使用main()方法的Welcome类。您可以通过按 Ctrl+Shift+S 保存所有更改,也可以使用 Ctrl+S 保存活动文件中的更改。或者,您可以使用文件➤全部保存和文件➤保存菜单或工具栏按钮。

img/323069_3_En_3_Fig19_HTML.png

图 3-19

带有 main()方法的 Welcome 类代码

img/323069_3_En_3_Fig18_HTML.png

图 3-18

NetBeans 创建的欢迎类

使用 NetBeans 时,不需要编译代码。默认情况下,NetBeans 会在您保存代码时对其进行编译。现在您已经准备好运行Welcome类了。NetBeans 允许您运行一个项目或单个 Java 类。如果 Java 文件包含主类,您可以运行它。要运行Welcome类,您需要在 NetBeans 中运行Welcome.java文件。您可以通过以下方式之一运行Welcome类:

  • 在编辑器中打开Welcome.java文件,按下 Shift+F6。或者,您可以在编辑器中右键单击Welcome.java文件并选择 Run File。

  • 在“项目”标签中选择Welcome.java文件,然后按 Shift+F6。或者,在项目选项卡中选择Welcome.java文件,然后选择运行文件。

  • 在“项目”选项卡中选择Welcome.java文件,然后选择“运行➤”“运行文件”。

当您运行 Welcome 类时,输出会出现在 output 选项卡中,如图 3-20 所示。

img/323069_3_En_3_Fig20_HTML.png

图 3-20

欢迎类运行时的输出

在 NetBeans 中创建模块化 jar

您可以从 NetBeans IDE 内部构建模块化 JAR。按 F11 构建项目,这将为您添加到 NetBeans 项目中的每个模块创建一个模块化 JAR。您可以按 Shift+F11 进行清理和构建,这将删除所有现有的已编译类文件和模块化 jar,并在创建新的模块化 jar 之前重新编译所有类。或者,您可以选择运行➤构建项目()菜单项来构建您的项目。

当您构建一个项目时,在哪里创建模块化 jar?NetBeans 在项目目录下创建一个dist目录。回想一下,您已经在C:\Java17Fundamentals中保存了 NetBeans 项目,所以当您在 IDE 中构建项目时,NetBeans 将创建一个C:\Java17Fundamentals\dist目录。假设您的项目中有两个模块— jdojo.introjdojo.test。构建项目将创建以下两个模块化 jar:

  • C:\Java17Fundamentals\dist\jdojo.intro.jar

  • C:\Java17Fundamentals\dist\jdojo.test.jar

NetBeans 项目目录结构

NetBeans 使用默认的目录结构来存储源代码、编译代码和打包代码。以下目录是在 NetBeans 项目目录下创建的:

  • src\<module-name>\classes

  • build\modules\<module-name>

  • dist

这里,<module-name>是你的模块名比如jdojo.introsrc\<module-name>\classes目录存储了特定模块的源代码。模块的module-info.java文件存储在classes子目录中。classes子目录可能有几个子目录,这些子目录反映了模块中存储的类型包所需的目录结构。

build\modules\<module-name>目录存储模块的编译代码。例如,jdojo.intro模块的module-info.class文件将存储在build\modules\jdojo.intro\module-info.classbuild\modules\<module-name>目录镜像了存储在模块中的类型包。例如,我们示例中的Welcome.class文件将存储在build\modules\jdojo.intro\com\jdojo\intro\Welcome.class。当您清理一个项目(右键单击并选择 clean)或清理并构建一个项目时,整个build目录将被删除并重新创建。

dist目录为项目中的每个模块存储了一个模块化 JAR。项目上的CleanClean+Build动作删除所有模块化 jar 并重新创建它们。

本书将在后续章节中引用 NetBeans 目录结构,向您展示在命令行中使用相同模块的示例。您可以使用 NetBeans 编写模块的代码,并为该模块构建一个模块化的 JAR。您可以将 NetBeans 项目的dist目录添加到 modulepath 中,以便在命令行上使用模块化 jar。

向模块添加类

通常,一个模块中有几个类。要向模块中添加新类,请在“项目”选项卡中右键单击该模块,然后选择“新建➤ Java 类”....在“新建 Java 类”对话框中填写类名和包名。

自定义 NetBeans 项目属性

NetBeans 允许您使用“项目属性”对话框为 Java 项目自定义几个属性。要打开“项目属性”对话框,请在“项目”选项卡中右键单击项目名称,然后选择“属性”。Java17Fundamentals项目的项目属性对话框如图 3-21 所示。

img/323069_3_En_3_Fig21_HTML.png

图 3-21

Java17Fundamentals 项目的项目属性对话框

对话框的左侧是属性类别列表。当您选择一个属性类别时,详细信息会显示在右侧。以下是每个属性类别的简要描述:

  • 来源:用于设置与源代码相关的属性,如源文件夹、格式、JDK、编码等。当您从“源/二进制格式”下拉列表中选择 JDK 时,NetBeans IDE 将限制您使用该 JDK 版本之外的 API。“包括/排除”按钮允许您在项目中包括和排除字段。当您想在项目中保留一些文件,但不想编译它们时,使用此按钮,例如,文件可能因为不完整而无法编译。

  • :在几个属性中,它允许您设置三个重要的属性:Java 平台、模块路径和类路径。单击管理平台...按钮打开“Java 平台管理器”对话框,您可以在其中选择现有平台或添加新平台。使用ModulepathClasspath右侧的+号,使用添加项目、添加库和添加 JAR/folder 按钮将项目、预定义的 JAR 文件集和 JAR/Folder 添加到模块路径和类路径中。这里设置的 modulepath 和 classpath 用于编译和运行 Java 项目。请注意,添加到项目中的所有模块都会自动添加到 modulepath 中。如果在当前 NetBeans 项目之外有模块化 jar,可以使用此对话框将它们添加到 modulepath 中。

  • Build :它可以让你设置几个子类别的属性。在“编译”子类别下,可以设置与编译器相关的选项。您可以选择在保存源代码时编译它,也可以选择使用 IDE 中的菜单选项自己编译源代码。在打包子类别下,您可以设置打包模块的选项。“文档”子类别允许您为项目设置生成 Java 文档的选项。

  • Run :这个类别允许您设置用于运行项目的属性。您可以设置 Java 平台和 JVM 参数。使用类别,您可以为项目设置一个主类。通常,当您学习时,您会像在前面章节中一样运行一个 Java 文件,而不是一个模块化的 Java 项目。

打开现有的 NetBeans 项目

假设您已经下载了这本书的源代码。源代码包含一个 NetBeans 项目。要打开项目,请按照下列步骤操作:

img/323069_3_En_3_Fig22_HTML.png

图 3-22

打开 NetBeans Java 项目获取本书的源代码

  1. 按 Ctrl+Shift+O 或选择文件➤打开项目。将显示“打开项目”对话框。

  2. 导航到包含解压缩的下载源代码的文件夹。显示项目Java17Fundamentals,如图 3-22 所示。

  3. 选择项目,然后单击“打开项目”按钮。NetBeans 在 IDE 中打开该项目。使用左侧的“项目”或“文件”选项卡浏览本书所有章节的源代码。参考前面关于如何在源代码中编译、构建和运行类的章节。

在幕后

本节回答了一些与编译和运行 Java 程序相关的一般问题。比如我们为什么要把 Java 源代码编译成字节码格式再运行?什么是 Java 平台?什么是 JVM,它是如何工作的?对这些主题的详细讨论超出了本书的范围。请参考 JVM 规范,了解有关 JVM 功能的任何主题的详细讨论。JVM 规范可以在 http://docs.oracle.com/javase/specs 在线获得。

让我们看一个简单的日常生活例子。假设有一个法国人只会说法语,他必须与另外三个人交流——一个美国人、一个德国人和一个俄罗斯人——而这三个人只懂一种语言(分别是英语、德语和俄语)。法国人将如何与其他三人沟通?有许多方法可以解决这个问题:

  • 这个法国人可能会学习所有三种语言。

  • 法国人可能会雇一个懂四种语言的翻译。

  • 法国人可以雇佣三名懂法英、法德、法俄的翻译。

这个问题还有许多其他可能的解决方案。让我们在运行 Java 程序的上下文中考虑类似的问题。Java 源代码被编译成字节码。相同的字节码需要在所有操作系统上运行,无需任何修改。Java 语言的设计者选择了第三种选择,为每个操作系统配备一个翻译器。翻译器的工作是将字节码翻译成机器码,机器码是运行翻译后的代码的操作系统所固有的。这个翻译器叫做 Java 虚拟机(JVM)。每个操作系统都需要一个 JVM。图 3-23 是 JVM 如何在字节码(类文件)和不同操作系统之间充当翻译器的示意图。

img/323069_3_En_3_Fig23_HTML.png

图 3-23

JVM 作为字节码和操作系统之间的翻译器

编译成字节码格式的 Java 程序有两个优点:

  • 如果想在另一台装有不同操作系统的机器上运行源代码,不需要重新编译。在 Java 中也叫平台独立性。对于 Java 代码,它也被称为“一次编写,随处运行”。

  • 如果您在网络上运行 Java 程序,由于字节码格式的紧凑大小,程序运行得更快,从而减少了网络上的加载时间。

为了在网络上运行 Java 程序,Java 代码的大小必须足够紧凑,以便在网络上更快地传输。由 Java 编译器以字节码格式生成的类文件非常紧凑。这是以字节码格式编译 Java 源代码的优点之一。

使用字节码格式的第二个重要优点是它是架构中立的。字节码格式是与体系结构无关的,这意味着如果你在一个特定的主机系统上编译 Java 源代码,比如说,在 Windows 上,生成的类文件没有提到或影响它是在 Windows 上生成的。如果在两个不同的主机系统(例如 Windows 和 UNIX)上编译相同的 Java 源代码,两个类文件将是相同的。

字节码格式的类文件不能在主机系统上直接执行,因为它没有任何特定于主机系统的直接指令。换句话说,我们可以说字节码不是任何特定主机系统的机器语言。现在的问题是,谁理解字节码,谁把它翻译成底层的特定于主机系统的机器码?JVM 执行这项工作。字节码是 JVM 的机器语言。如果您在 Windows 上编译 Java 源代码来生成一个类文件,那么如果您在运行 UNIX 的机器上有 Java 平台(JVM 和 Java API 统称为 Java 平台),那么您也可以在 UNIX 上运行相同的类文件。您不需要重新编译源代码来为 UNIX 生成新的类文件,因为运行在 UNIX 上的 JVM 可以理解您在 Windows 上生成的字节码。这就是 Java 程序如何实现“编写一次,在任何地方运行”的概念。

Java 平台,也称为 Java 运行时系统,由两部分组成:

  • Java 虚拟机(JVM)

  • Java 应用程序编程接口(Java API)

术语“JVM”在三种上下文中使用:

  • JVM 规范:它是一个抽象机器的规范或标准,Java 编译器可以为其生成字节码。

  • JVM 规范的具体实现:如果你想运行你的 Java 程序,你需要有一个真正的 JVM,它是使用 JVM 的抽象规范开发的。为了运行上一节中的 Java 程序,您使用了java命令,这是抽象 JVM 规范的具体实现。命令(或者 JVM)已经完全用软件实现了。然而,JVM 可以用软件或硬件或两者的组合来实现。

  • 一个正在运行的 JVM 实例:当您调用java命令时,您有一个正在运行的 JVM 实例。

这本书对这三种情况都使用了术语 JVM。它的实际含义应该根据其使用的上下文来理解。

JVM 执行的工作之一是执行字节码并为主机系统生成特定于机器的指令集。JVM 有类装入器和执行引擎。类加载器在需要时读取类文件的内容,并将其加载到内存中。执行引擎的工作是执行字节码。

JVM 也被称为 Java 解释器。“Java 解释器”这个术语经常会引起误解,尤其是对于那些刚刚开始学习 Java 语言的人。对于术语“Java 解释器”,他们的结论是 JVM 的执行引擎一次解释一个字节码,所以 Java 一定非常慢。JVM 的名称“Java 解释器”与执行引擎用来执行字节码的技术没有任何关系。执行引擎执行字节码可能选择的实际技术取决于 JVM 的具体实现。一些执行引擎类型有解释器、实时编译器和自适应优化器。在最简单的解释器中,执行引擎一次解释一个字节码,因此速度较慢。在第二种类型中,即实时编译器,它在方法第一次被调用时,用底层主机语言编译该方法的全部代码。然后在下一次调用相同的方法时重用编译后的代码。与第一种相比,这种执行引擎速度更快,但需要更多内存来缓存编译后的代码。在自适应优化器技术中,它不编译和缓存整个字节码;相反,它只对字节码中使用最频繁的部分这样做。

什么是 API(应用编程接口)?API 是一组特定的方法,由操作系统或应用程序提供给程序员直接使用。在前面的小节中,您在com.jdojo.intro包中创建了Welcome类,它声明了一个方法main,该方法接受一个数组String作为参数,并且不返回任何内容(由关键字void指示)。如果您公开所有这些关于所创建的包、类和方法的信息,并使它们可供其他程序员使用,那么您在Welcome类中的方法main就是一个典型的 API 示例,尽管这并不重要。通常,当我们使用术语“API”时,我们指的是可供程序员使用的一组方法。现在很容易理解 Java API 的意思了。Java API 是程序员在编写 Java 源代码时可以使用的所有类和其他组件的集合。在您的Welcome类示例中,您已经使用了一个 Java API。您在main方法体中使用它来在控制台上打印消息。使用 Java API 的代码是

System.out.println("Welcome to Java 17!");

您没有在代码中声明任何名为println的方法。这个方法通过 Java API 在运行时对 JVM 可用,Java API 是 Java 平台的一部分。概括地说,Java API 可以分为两类:核心 API 和扩展 API。每个 JDK 都必须支持核心 API。核心 Java APIs 的例子是 Java 运行时(例如,小应用程序、AWT、I/O 等。),JFC,JDBC 等。Java 扩展 API 有 JavaMail、JNDI (Java 命名和目录接口)等。Java 包含 JavaFX API 作为扩展 API。编译和运行 Java 程序的过程如图 3-24 所示。

img/323069_3_En_3_Fig24_HTML.png

图 3-24

编译和运行 Java 程序所涉及的组件

摘要

Java 程序是使用文本编辑器或 IDE 以纯文本格式编写的。Java 源代码也称为编译单元,它存储在扩展名为.java的文件中。市场上有一些免费的 Java 集成开发环境(ide ),比如 NetBeans。使用 IDE 开发 Java 应用程序减少了开发 Java 应用程序所需的时间和精力。

JDK 9 向 Java 平台引入了模块系统。模块包含包,包又由类型组成。类型可以是类、接口、枚举或注释。一个模块在一个名为module-info.java的源文件中声明,它被编译成一个名为module-info.class的类文件。一个编译单元包含一个或多个类型的源代码。编译编译单元时,会为编译单元中声明的每个类型生成一个类文件。

使用 Java 编译器将 Java 源代码编译成类文件。类文件包含字节码。JDK 附带的 Java 编译器叫做javac。使用名为jar的工具将编译后的代码打包成 JAR 文件。当一个 JAR 文件在其根目录下包含一个module-info.class文件,它是一个模块描述符,这个 JAR 文件被称为模块化 JAR。编译后的代码由 JVM 运行。JDK 安装一个可以作为java命令运行的 JVM。javacjava命令都位于JDK_HOME\bin目录下,其中JDK_HOME是 JDK 的安装目录。

一个模块可以包含内部和外部使用的包。如果一个模块导出一个包,则该包中包含的公共类型可能会被其他模块使用。如果一个模块想要使用另一个模块导出的包,第一个模块必须声明对第二个模块的依赖。JDK 9+由几个模块组成,称为平台模块。java.base模块是一个原始模块,所有其他模块都隐式依赖于它。

modulepath 是路径名的序列,其中路径名可以是目录、模块化 JAR 或 JMOD 文件的路径。modulepath 中的每个条目由特定于平台的路径分隔符分隔,在 Windows 上是分号(;),在类似 UNIX 的操作系统上是冒号(:)。用户定义的模块由模块系统使用 modulepath 定位。modulepath 是使用--module-path(或者更简短的版本-p)命令行选项设置的。

可以使用类路径来定位类。类路径是一系列目录、JAR 文件和 ZIP 文件。类路径中的每个条目由特定于平台的路径分隔符分隔,在 Windows 上是分号(;),在类似 UNIX 的操作系统上是冒号(:)。您可以使用--class-path(或者-cp或者-classpath)命令行选项来指定类路径。

classpath 和 modulepath 的值可能看起来相同,但它们用于不同的目的。classpath 用于定位类(更具体地说是类型),而 modulepath 用于定位模块。您可以通过jarjava命令使用--describe-module(或更短版本的-d)选项打印模块的描述。如果你有一个模块化的 JAR,使用jar命令。如果在模块路径上的模块 JAR 或展开目录中有一个模块,使用java命令。

模块系统在任何阶段(编译时或运行时)可访问的所有模块都称为可观察模块。您可以使用--list-modules命令行选项打印可观察模块的列表。模块系统通过隐晦地解析一组被称为根模块的模块相对于该组可观察模块的依赖性来创建模块图。在编译时,所有被编译的模块组成了根模块集。运行主类的主模块在运行时构成了根模块集。如果主类在类路径上,那么所有系统模块都是根模块。您可以使用--add-modules命令行选项将模块添加到根模块集中。您可以使用--limit-modules命令行选项来限制可观察模块的数量。

JDK 9+只适用于你的代码是否在模块内的模块。每个类装入器都有一个未命名的模块。如果类装入器从 modulepath 装入一个类型,该类型就是一个命名模块的成员。如果类装入器从类路径中装入一个类型,该类型将成为该类装入器的未命名模块的成员。

Java 代码被编译成字节码,由 JVM (Java 虚拟机)运行。这允许相同的代码在许多不同的操作系统上运行。

EXERCISES

  1. 包含 Java 程序源代码的文件的扩展名是什么?

  2. 什么是编译单元?

  3. 在一个编译单元中可以声明多少个类型?

  4. 在一个编译单元中可以声明多少个公共类型?

  5. 如果编译单元包含公共类型,那么对它的命名有什么限制?如果编译单元包含一个名为HelloWorld的公共类的声明,它的名字会是什么?

  6. 在一个编译单元中,以下结构是按什么顺序指定的:类型声明、包和导入语句?

  7. 一个编译单元中可以有多少个 package 语句?

  8. 包含 Java 编译代码的文件的扩展名是什么?

  9. 包含 Java 模块的源代码和编译代码的文件名是什么?

  10. 你用什么关键字来声明一个模块?

  11. 在一个module-info.java文件中可以声明多少个模块?

  12. 什么是未命名模块?一个类装入器可以有多少个未命名的模块?一个类型(例如,一个类)什么时候成为一个未命名模块的成员?

  13. 什么是罐子?JAR 文件和 ZIP 文件有什么区别?

  14. 什么是模块化 JAR,它与 JAR 有什么不同?你能把一个模块化的 JAR 作为一个 JAR 使用吗,反之亦然?

*提示*:模块化 JAR 也是一个 JAR,也可以这样使用。放置在模块路径上的罐子充当模块罐子;在这种情况下,模块定义由模块系统自动导出。这种模块被称为*自动*模块。
  1. 您使用什么命令来启动 JShell 工具,该命令位于哪里?

  2. 你用什么命令编译 Java 源代码?

  3. 你用什么命令把 Java 编译的代码打包成一个 JAR 或者一个模块化的 JAR?

  4. 模块描述符(module-info.class文件)放在模块化 JAR 的什么地方?

  5. 您在C:\lib\com.jdojo.test.jar保存了一个模块化 JAR。它包含一个名为jdojo.test的模块和一个名为com.jdojo.test.Test的主类。编写在模块模式和传统模式下运行该类的命令。

  6. 您在C:\lib\com.jdojo.test.jar保存了一个模块化 JAR。使用jar命令编写命令来描述这个模块化 JAR 中打包的模块。

  7. 什么是模块描述符?在声明模块时,可以指定模块的版本吗?如何指定模块版本?

  8. 什么是可观测模块?什么是根模块,在构建模块图时如何使用它们?

  9. 写出用于将模块添加到根模块集中的命令行选项的名称。

  10. 您使用什么命令行选项来打印可观察模块的列表?

  11. 您使用什么命令行选项来限制可观察模块的集合?

  12. 用于指定 modulepath 的 GNU 风格的选项名是--module-path。它的等价 UNIX 风格选项是什么?

  13. 有哪些选项可以打印命令的帮助?如何为命令的非标准选项打印额外的帮助?