Appearance
Java 基础:包与 JAR 文件
1. 包
1.1. 包名
Java 创建者希望我们反向使用自己的网络域名,因为域名通常是唯一的。因此我的域名是 MindviewInc.com,所以我将我的 foibles 类库命名为 com.mindviewinc.utility.foibles。
使用这种命名空间方法却给源代码管理带来麻烦。例如在 com.mindviewinc.utility.foibles 这样的目录结构中,我们创建了 com 和 mindviewinc 空目录。它们存在的唯一目的就是用来表示这个反向的 URL。
Note:从编译器的角度来看,嵌套的包之间没有任何关系。例如,
java.util包与java.util.jar包毫无关系。每一个包都是独立的类集合。
1.2. 类的导入
一个类可以使用所属包中的所有类,以及其他包中的公共类(public class)。
我们可以采用两种方式访问另一个包中的公共类。第一种方式就是使用完全限定名(fully qualified name);就是包名后面跟着类名。例如:
Java
java.time.LocalDate today = java.time.LocalDate.now();这显然很烦琐。更简单且更常用的方式是使用 import 语句。import 语句是一种引用包中各个类的简捷方式。一旦使用了 import 语句,在使用类时,就不必写出类的全名了。
可以使用 import 语句导入一个特定的类或者整个包。import 语句应该位于源文件的顶部(但位于 package 语句的后面)。例如,可以使用下面这条语句导入 java.time 包中的所有类:
Java
import java.time.*;然后,就可以使用:
Java
LocalDate today = LocalDate.now();而无须在前面加上包前缀。还可以导入一个包中的特定类:
Java
import java.time.LocalDate;但是,需要注意的是,只能使用星号 * 导入一个包,而不能使用 import java.* 或 import java.*.* 导入以 java 为前缀的所有包。
在大多数情况下,可以只导入你需要的包,并不必过多地考虑它们。但在发生命名冲突的时候,就要注意包了。例如,java.util 和 java.sql 包都有 Date 类。如果在程序中导入了这两个包:
Java
import java.util.*;
import java.sql.*;1
2
2
在程序中使用 Date 类的时候,就会出现一个编译错误:
Java
Date today; // ERROR--java.util.Date or java.sql.Date?此时编译器无法确定你想使用的是哪一个 Date 类。可以增加一个特定的 import 语句来解决这个问题:
Java
import java.util.*;
import java.sql.*;
import java.util.Date;1
2
3
2
3
如果这两个 Date 类都需要使用,又该怎么办呢?答案是,在每个类名的前面加上完整的包名。
Java
var deadline = new java.util.Date();
var today = new java.sql.Date(...);1
2
2
在包中定位类是编译器(compiler)的工作。类文件中的字节码总是使用完整的包名引用其他类。
1.3. 静态导入
有一种 import 语句允许导入静态方法和静态字段,而不只是类。
例如,如果在源文件顶部,添加一条指令:
Java
import static java.lang.System.*;就可以使用 System 类的静态方法和静态字段,而不必加类名前缀:
Java
out.println("Goodbye, World!"); // i.e., System.out
exit(0); // i.e., System.exit1
2
2
另外,还可以导入特定的方法或字段:
Java
import static java.lang.System.out;1.4. 在包中增加类
要想将类放入包中,就必须将包的名字放在源文件的开头,即放在定义这个包中各个类的代码之前。例如:
Java
package com.horstmann.corejava;
public class Employee {
...
}1
2
3
4
5
2
3
4
5
如果没有在源文件中放置 package 语句,这个源文件中的类就属于无名包(unnamed package)。
将源文件放到与完整包名匹配的子目录中。例如,com.horstmann.corejava 包中的所有源文件应该放置在子目录 com/horstmann/corejava 中(Windows 中则是 com\horstmann\corejava)。编译器将类文件也放在相同的目录结构中。
Note
编译器在编译源文件的时候不检查目录结构。例如,假定一个源文件开头有以下指令:
Javapackage com.mycompany;即使这个源文件不在子目录
com/mycompany下,也可以进行编译。如果它不依赖于其他包,就可以通过编译而不会出现编译错误。但是,最终的程序将无法运行,除非先将所有类文件移到正确的位置上。如果包与目录不匹配,虚拟机就找不到类。
1.5. 包访问
前面己经接触过访问修饰符 public 和 private。标记为 public 的部分可以由任意类使用;标记为 private 的部分只能由定义它们的类使用。如果没有指定 public 或 private,这个部分(类、方法或变量)可以被同一个包中的所有方法访问。
对于类来说,这种默认方式是合乎情理的。但是,对于变量来说就有些不适宜了,变量必须显式地标记为 private,不然的话将默认为包可访问。显然,这样做会破坏封装性。问题是人们经常忘记键入关键字 private。java.awt 包中的 Window 类就是一个典型的示例。java.awt 包是 JDK 提供的部分源代码:
Java
public class Window extends Container {
String warningString;
...
}1
2
3
4
2
3
4
请注意,这里的 warningString 变量不是 private!这意味着 java.awt 包中的所有类的方法都可以访问该变量,并将它设置为任意值(例如,"Trust me!")。实际上,只有 Window 类的方法访问这个变量,因此本应该将它设置为私有变量才合适。可能是程序员敲代码时匆忙之中忘记 private 修饰符了?也可能是没有人关心这个问题?已经 20 多年了,这个变量仍然不是私有变量。不仅如此,这个类还陆续增加了一些新的字段,而其中大约有一半也不是私有的。
这可能会成为一个问题。在默认情况下,包不是封闭的实体。也就是说,任何人都可以向包中添加更多的类。当然,有恶意或低水平的程序员很可能利用包的可见性添加一些能修改变量的代码。例如,在 Java 程序设计语言的早期版本中,只需要将以下这条语句放在类文件的开头,就可以很容易地在 java.awt 包中混入其他类:
Java
package java.awt;然后,把得到的类文件放置在类路径上某处的 java/awt 子目录下,这样就可以访问 java.awt 包的内部了。
从 1.2 版开始,JDK 的实现者修改了类加载器,明确地禁止加载包名以 java. 开头的用户自定义的类!当然,用户自定义的类无法从这种保护中受益。另一种机制是让 JAR 文件声明包为密封的(sealed),以防止第三方修改,但这种机制已经过时。现在应当使用模块封装包。
1.6. 类路径
在前面已经看到,类存储在文件系统的子目录中。类的路径必须与包名匹配。
另外,类文件也可以存储在 JAR (Java 归档)文件中。在一个 JAR 文件中,可以包含多个压缩形式的类文件和子目录,这样既可以节省空间又可以改善性能。在程序中用到第三方的库文件时,你通常要得到一个或多个需要包含的 JAR 文件。
Note:JAR 文件使用 ZIP 格式组织文件和子目录。可以使用任何 ZIP 工具查看 JAR 文件。
为了使类能够被多个程序共享,需要做到下面几点:
- 把类文件放到一个目录中,例如
/home/user/classdir。需要注意,这个目录是包树状结构的基目录。如果希望增加com.horstmann.corejava.Employee类,那么Employee.class类文件就必须位于子目录/home/user/classdir/com/horstmann/corejava中; - 将 JAR 文件放在一个目录中,例如:
/home/user/archives; - 设置类路径(class path)。类路径是所有包含类文件的路径的集合;
在 UNIX 环境中,类路径中的各项之间用冒号 : 分隔:
Text
/home/user/classdir:.:/home/user/archives/archive.jar而在 Windows 环境中,则以分号 ; 分隔:
Text
c:\classdir;.;c:\archives\archive.jar不论是 UNIX 还是 Windows,都用句点 . 表示当前目录。
类路径包括:
- 基目录
/home/user/classdir或c:\classdir; - 当前目录
.; - JAR 文件
/home/user/archives/archive.jar或c:\archives\archive.jar.;
从 Java 6 开始,可以在 JAR 文件目录中指定通配符,如下:
Text
/home/user/classdir:.:/home/user/archives/'*'或者
Text
c:\classdir;.;c:\archives\*Warning:在 UNIX 中,
*必须转义以防止 shell 扩展。
上述示例中,archives 目录中的所有 JAR 文件(但不包括 .class 文件)都包含在这个类路径中。
由于总是会搜索 Java API 的类,所以不必显式地包含在类路径中。
Warning:
javac编译器总是在当前的目录中查找文件,但 java 虚拟机仅在类路径中包含.目录的时候才查看当前目录。如果没有设置类路径,那么没有什么问题,因为默认的类路径会包含.目录。但是如果你设置了类路径却忘记包含.目录,那么尽管你的程序可以没有错误地通过编译,但不能运行。
类路径所列出的目录和归档文件是搜寻类的起始点。下面看一个类路径示例:
Text
/home/user/classdir:.:/home/user/archives/archive.jar假定虚拟机要搜寻 com.horstmann.corejava.Employee 类的类文件。它首先要查看 Java API 类。显然,在那里找不到相应的类文件,所以转而查看类路径。然后查找以下文件:
/home/user/classdir/com/horstmann/corejava/Employee.classcom/horstmann/corejava/Employee.class(从当前目录开始)com/horstmann/corejava/Employee.class(/home/user/archives/archive.jar中)
编译器查找文件要比虚拟机复杂得多。如果引用了一个类,而没有指定这个类的包,那么编译器将首先查找包含这个类的包。它会查看所有的 import 指令,确定其中是否包含这个类。例如,假定源文件包含指令:
Java
import java.util.*;
import com.horstmann.corejava.*;1
2
2
并且源代码引用了 Employee 类。编译器将尝试查找 java.lang.Employee(因为 java.lang 包总是会默认导入)、java.util.Employee、com.horstmann.corejava.Employee 和当前包中的 Employee。它会在类路径所有位置中搜索以上各个类。如果找到了一个以上的类,就会产生编译时错误(因为完全限定类名必须是唯一的,所以 import 语句的次序并不重要)。
编译器的任务不止这些,它还要查看源文件是否比类文件新。如果是这样的话,那么源文件就被自动地重新编译。在前面已经知道,只可以导入其他包中的公共类。一个源文件只能包含一个公共类,并且文件名与公共类名必须匹配。因此,编译器很容易找到公共类的源文件。不过,还可以从当前包中导入非公共类。这些类有可能在与类名不同的源文件中定义。如果从当前包中导入一个类,编译器就要搜索当前包中的所有源文件,查看哪个源文件定义了这个类。
1.7. 设置类路径
最好使用 -classpath(或 -cp,或者 Java 9 中的 --class-path) 选项指定类路径:
Bash
$ java -classpath /home/user/classdir:.:/home/user/archives/archive.jar MyProg或者
Bash
$ java -classpath c:\classdir;.;c:\archives\archive.jar MyProg整个指令必须写在一行中。将这样一个很长的命令行放在一个 shell 脚本或一个批处理文件中是个不错的主意。
利用 -classpath 选项设置类路径是首选的方法,也可以通过设置 CLASSPATH 环境变量来指定。具体细节依赖于所使用的 shell。在 Bourne Again shell(bash)中,命令如下:
Bash
$ export CLASSPATH=/home/user/classdir:.:/home/user/archives/archive.jar在 Windows shell,命令如下:
PowerShell
set CLASSPATH=c:\classdir;.;c:\archives\archive.jar直到退出 shell 为止,类路径设置均有效。
Note 1:有人建议将
CLASSPATH环境变量设置为永久不变的值。一般来说这是一个糟糕的想法。人们有可能会忘记全局设置,因此,当他们的类没有正确地加载时,就会感到很奇怪。一个应该受到谴责的示例是 Windows 中 Apple 的 QuickTime 安装程序。很多年来,它都将CLASSPATH全局设置为指向它需要的一个 JAR 文件,而没有在类路径中包含当前路径。因此,当程序编译后却不能运行时,无数 Java 程序员不得不花费很多精力去解决这个问题。
Note 2:过去,有人建议完全绕开类路径,将所有的文件都放在
jre/lib/ext目录中。这种机制在 Java 9 中已经过时,不过不管怎样这都是一个不好的建议。很可能会从扩展目录加载一些已经遗忘很久的类,这会让人非常困惑。
在 Java 9 中,还可以从模块路径加载类。
2. JAR 文件
一个 JAR 文件既可以包含类文件,也可以包含诸如图像和声音等其他类型的文件。
2.1. 创建 JAR 文件
可以使用 jar 工具制作 JAR 文件(在默认的 JDK 安装中,这个工具位于 jdk/bin 目录下)。创建一个新 JAR 文件最常用的命令使用以下语法:
ABNF
cmd = "jar" options files
files = file {sp file}1
2
2
例如:
Bash
$ jar cvf CalculatorClasses.jar *.class icon.gif表 2.1 列出了 jar 程序的所有选项。它们类似于 UNIX tar 命令的选项。
| 选项 | 说明 |
|---|---|
c | 创建一个新的或者空的存档文件并加入文件。如果指定的文件名是目录,jar 程序将会对它们进行递归处理 |
C | 临时改变目录,例如 jar cvf jarFileName.jar -C classes *.class,切换到 classes 子目录以便增加类文件 |
e | 在清单文件中创建一个入口点(请参看可执行 JAR 文件小节) |
f | 指定 JAR 文件名作为第二个命令行参数。如果没有这个参数,jar 命令会将结果写至标准输出(在创建 JAR 文件时)或者从标准输入读取(在解压或者列出 JAR 文件内容时) |
i | 建立索引文件(用于加快大型归档中的查找) |
m | 将一个清单文件添加到 JAR 文件中。清单是对归档内容和来源的一个说明。每个归档有一个默认的清单文件。但是,如果想验证归档文件的内容,可以提供自己的清单文件 |
M | 不为条目创建清单文件 |
t | 显示内容表 |
u | 更新一个已有的 JAR 文件 |
v | 生成详细的输出结果 |
x | 解压文件。如果提供一个或多个文件名,只解压这些文件;否则,解压所有文件 |
0 | 存储,但不进行 ZIP 压缩 |
2.2. 资源
Class 类提供了一个很有用的服务可以查找资源文件。下面给出必要的步骤:
获得拥有资源的类的
Class对象,例如,ResourceTest.class;有些方法,如
ImageIcon类的getImage方法,接受描述资源位置的 URL。则要调用:JavaURL url = cl.getResource("about.gif");否则,使用
getResourceAsStream方法得到一个输入流来读取文件中的数据;
因为 Java 虚拟机知道如何查找一个类,所以它能搜索相同位置上的关联资源。
下面我们通过 resources/ResourceTest.java 程序来展示如何加载资源。首先编译、构建一个 JAR 文件并执行:
Bash
$ javac resource/ResourceTest.java
$ jar cvfe ResourceTest.jar resources.ResourceTest \
resources/*.class resources/*.gif resources/data/*.txt corejava/*.txt
$ java -jar ResourceTest.jar1
2
3
4
2
3
4
Note:可以将 JAR 文件移到另外一个不同的目录中,再运行它,以确认程序是从 JAR 文件中而不是从当前目录中读取资源。
resources/ResourceTest.java:
Java
package resources;
import java.io.*;
import java.net.*;
import java.nio.charset.*;
import javax.swing.*;
/**
* @version 1.5 2018-03-15
* @author Cay Horstmann
*/
public class ResourceTest {
public static void main(String[] args) throws IOException {
Class cl = ResourceTest.class;
URL aboutURL = cl.getResource("about.gif");
var icon = new ImageIcon(aboutURL);
InputStream stream = cl.getResourceAsStream("data/about.txt");
var about = new String(stream.readAllBytes(), "UTF-8");
InputStream stream2 = cl.getResourceAsStream("/corejava/title.txt");
var title = new String(stream2.readAllBytes(), StandardCharsets.UTF_8).trim();
JOptionPane.showMessageDialog(null, about, title, JOptionPane.INFORMATION_MESSAGE, icon);
}
}1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
2.3. 清单文件
除了类文件、图像和其他资源外,每个 JAR 文件还包含一个清单文件(manifest),用于描述归档文件的特殊特性。
清单文件被命名为 MANIFEST.MF,它位于 JAR 文件的一个特殊的 META-INF 子目录中。符合标准的最小清单文件极其简单:
Text
Manifest-Version: 1.0复杂的清单文件可能包含更多条目。这些清单条目被分成多个节。第一节被称为主节(main section)。它作用于整个 JAR 文件。随后的条目用来指定命名实体的属性,如单个文件、包或者 URL 它们都必须以一个 Name 条目开始。节与节之间用空行分开。例如:
Text
Manifest-Version: 1.0
lines describing this archive
Name: Woozle.class
lines describing this file
Name: com/mycompany/mypkg/
lines describing this package1
2
3
4
5
6
7
2
3
4
5
6
7
要想编辑清单文件,需要将希望添加到清单文件中的行放到文本文件中,然后运行:
ABNF
"jar cfm" jar-file-name manifest-file-name files例如,要创建一个包含清单文件的 JAR 文件,应该运行:
Bash
$ jar cfm MyArchive.jar manifest.mf com/mycompany/mypkg/*.class要想更新一个已有的 JAR 文件的清单,则需要将增加的部分放置到一个文本文件中,然后执行以下命令:
Bash
$ jar ufm MyArchive.jar manifest-additions.mfNote:请参看 https://docs.oracle.com/javase/10/docs/specs/jar/jar.html 获得有关 JAR 文件和清单文件格式的更多信息。
2.4. 可执行 JAR 文件
可以使用 jar 命令中的 e 选项指定程序的入口点,即通常需要在调用 java 程序启动器时指定的类:
Bash
$ jar cvfe MyProgram.jar com.mycompany.mypkg.MainAppClass other-files或者,可以在清单文件中指定程序的主类,包括以下形式的语句:
Text
Main-Class: com.mycompany.mypkg.MainAppClass不要为主类名增加扩展名 .class。
Tip:清单文件的最后一行必须以换行符结束。否则,清单文件将无法被正确地读取。常见的一个错误是创建了一个只包含
Main-Class行而没有行结束符的文本文件。
不论使用哪一种方法,用户可以简单地通过下面的命令来启动程序:
Bash
$ java -jar MyProgram.jar取决于操作系统的配置,用户甚至可以通过双击 JAR 文件图标来启动应用程序。下面是各种操作系统的操作方式:
- 在 Windows 平台中,Java 运行时安装程序将为
.jar扩展名创建一个文件关联,会用javaw -jar命令启动文件(与java命令不同,javaw命令不打开 shell 窗口); - 在 Mac OS X 平台中,操作系统能够识别
.jar扩展名文件。双击 JAR 文件时就会执行 Java 程序;
Note:不过,人们对 JAR 文件中的 Java 程序与原生应用还是感觉不同。在 Windows 平台中,可以使用第三方的包装器工具将 JAR 文件转换成 Windows 可执行文件。包装器是一个 Windows 程序,有大家熟悉的扩展名
.exe,它可以查找和加载 Java 虚拟机 (JVM),或者在没有找到 JVM 时会告诉用户应该做些什么。有许多商业的和开源的产品,例如,Launch4J 和 IzPack。
2.5. 多版本 JAR 文件
随着模块和包强封装的引入,之前可以访问的一些内部 API 不再可用。例如,JavaFX 8 有一个内部类 com.sun.javafx.css.CssParser。如果用它解析一个样式表,你会发现你的程序不再能正常编译了。补救很简单,只需要改用 Java 9 提供的 javafx.css.CssParser。不过这样会有一个问题。你需要向 Java 8 和 Java 9 用户发布不同的应用程序,或者需要利用类加载和反射等一些技巧。
为了解决类似这样的问题,Java 9 引入了多版本 JAR(multi-release JAR),其中可以包含面向不同 Java 版本的类文件。
为了保证向后兼容,额外的类文件放在 META-INF/versions 目录中:
Text
Application.class
BuildingBlocks.class
Util.class
META-INF
├─ MANIFEST.MF (with line Multi-Release: true)
├─ versions
├─ 9
│ ├─ Application.class
│ └─ BuildingBlocks.class
└─ 10
└─ BuildingBlocks.class1
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
假设 Application 类使用了 CssParser 类。那么遗留版本的 Application.class 文件可以使用 com.sun.javafx.css.CssParser,而 Java 9 版本可以使用 javafx.css.CssParser。
Java 8 完全不知道 META-INF/versions 目录,它只会加载遗留的类。Java 9 读取这个 JAR 文件时,则会使用新版本。
要增加不同版本的类文件,可以使用 --release 标志:
Bash
$ jar uf MyProgram.jar --release 9 Application.class要从头构建一个多版本 JAR 文件,可以使用 -C 选项,对应每个版本要切换到一个不同的类文件目录:
Bash
$ jar cf MyProgram.jar -C bin/8 . --release 9 -C bin/9 Application.class面向不同版本编译时,要使用 --release 标志和 -d 标志来指定输出目录:
Bash
$ javac -d bin/8 --release 8 ...在 Java 9 中,-d 选项会创建这个目录(如果原先该目录不存在)。
--release 标志也是 Java 9 新增的。在较早的版本中,需要使用 -source、-target 和 -bootclasspath 标志。JDK 现在为之前的两个版本提供了符号文件。在 Java 9 中,编译时可以将 --release 设置为 9、8 或 7。
多版本 JAR 并不适用于不同版本的程序或库。对于不同的版本,所有类的公共 API 都应当是一样的。多版本 JAR 的唯一目的是支持你的某个特定版本的程序或库能够在多个不同的 JDK 版本上运行。如果你增加了功能或者改变了一个 API,那就应当提供一个新版本的 JAR。
Note
javap之类的工具并没有改造为可以处理多版本 JAR 文件。如果调用:Bash$ javap -classpath MyProgram.jar Application.class你会得到类的基本版本(毕竟,它与更新的版本应该有相同的公共 API)。如果必须查看更新的版本,可以调用:
Bash$ javap -classpath MyProgram.jar\!/METAINF/versions/9/Application.class
2.6. 关于命令行选项的说明
Java 开发包(JDK)的命令行选项一直以来都使用单个短横线加多字母选项名的形式,如:
Bash
$ java -jar ...
$ javac -Xlint:unchecked -classpath ...1
2
2
但 jar 命令是个例外,这个命令遵循经典的 tar 命令选项格式,而没有短横线:
Bash
$ jar cvf ...从 Java 9 开始,Java 工具开始转向一种更常用的选项格式,多字母选项名前面加两个短横线,另外对于常用的选项可以使用单字母快捷方式。例如,调用 Linux ls 命令时可以提供一个 “human-readable” 选项:
Bash
$ ls --human-readable或者
Bash
$ ls -h在 Java 9 中,可以使用 --version 而不是 -version,另外可以使用 --class-path 而不是 -classpath。另外 --module-path 选项有一个快捷方式 -p。
详细内容可以参见 JEP 293 增强请求。在所有清理工作中,作者还提出要标准化选项参数。带 -- 和多字母的选项的参数用空格或者一个等号 = 分隔:
Bash
$ javac --class-path /home/user/classdir ...或
Bash
$ javac --class-path=/home/user/classdir ...单字母选项的参数可以用空格分隔,或者直接跟在选项后面:
Bash
$ javac -p moduledir ...或
Bash
$ javac -pmoduledir ...Note:后一种方式现在不能使用,而且一般来讲这也不是一个好主意。如果模块目录恰好是
arameters或rocessor,这就很容易与遗留的选项发生冲突,这又何必呢?
无参数的单字母选项可以组合在一起,如以下的 cvf 选项:
Bash
$ jar -cvf MyProgram.jar -e mypackage.MyProgram */*.classNote:目前不能使用这种方式。这肯定会带来混淆。假设
javac有一个-c选项。那么javac -cp是指javac -c -p还是-cp?
这就会带来一些混乱,希望过段时间能够解决这个问题。尽管我们想要远离这些古老的 jar 选项,但最好还是等到尘埃落定为妙。不过,如果你想做到最现代化,那么可以安全地使用 jar 命令的长选项:
Bash
$ jar --create --verbose --file jarFileName file1 file2 ...对于单字母选项,如果不组合,也是可以使用的:
Bash
$ jar -c -v -f jarFileName file1 file2 ...