2.4 从命令行运行JVM应用程序

在JVM中运行应用程序通常被视为一个极度复杂的主题。前一章说过,JVM编译器将源代码编译成扩展名为.class的二进制文件。要让JVM实例能够运行.class文件中的代码,必须遵循一些规则:

  • 至少有一个类包含静态方法main()
  • 所有的类文件都必须存储在特定的目录中;
  • 必须指定ClassPath;
  • 类文件可放在JAR归档容器中;
  • 要运行程序,可使用命令java

我们将简要地介绍每条规则,然后使用Java编写一个演示项目,以尽可能清晰地说明这些规则。

2.4.1 至少有一个类包含静态方法main()

使用命令java手动运行JVM应用程序时,需要指定包含如下静态方法的类:

  1. public static void main(String[] args) {
  2. }

JVM实例初始化完毕后,命令java将调用这个方法,它相当于C和C++中著名的入口函数main(),其中的字符串数组args包含操作系统传递的命令行参数。

上述代码片段演示的是Java的情况。有些JVM语言不要求程序员手工编写这个方法,而会在编译类时自动生成它。另外,有些JVM框架会自动生成或提供这个方法。

方法main()并非必须与前面演示的完全相同。

  • 这个方法的参数的名称无关紧要。根据约定,将其命名为args,有时使用名称argv,但使用其他类似的名称也可行。
  • 参数的类型是可以调整的,通常为字符串数组(在Java中为String[]),但使用数量可变的参数(String…,这将在下一章讨论)也可行。

下面是一个有效的Java main()函数,其中参数类型是String…,参数名称也不是args

  1. public static void main(String... commandLineArguments) { }

在项目中,可以有多个类包含静态方法main(),但每次只能运行其中的一个。使用命令java运行应用程序时,必须在命令行中指定全限定类名。

2.4.2 存储类文件的目录结构

类文件必须存储在与包名匹配的目录结构中。包名中的每个句点都意味着一个新的子目录。没有放在包中的类存储在根目录中,这个目录包含各个子目录。

假设有一个项目,它包含的类的全限定名如下:

  • Main
  • com.example.app.model.MyModel
  • com.example.app.view.MyView
  • com.example.app.controller.MyController

则编译后,目录结构将类似于下面这样:

2.4 从命令行运行JVM应用程序 - 图1

在项目中,应避免包含未放在包中的类。在前面的示例中,最好将Main类放在一个包中。

请注意,编译器通常会创建正确的目录结构。为更好地理解ClassPath,务必熟悉这里介绍的需求。另外,有些编译器(包括Java编译器)要求以同样的方式组织源代码。

2.4.3 为JVM实例设置ClassPath

ClassPath是一个由目录和/或JAR归档文件组成的列表,JVM将使用它来查找项目中引用的类。命令java、编译器(Java和其他大多数JVM语言编译器)以及其他与JDK和JVM相关的众多工具都要用到它。

如果没有显式地设置,ClassPath将默认为当前目录。如果项目使用的所有类文件都存储在用来启动程序的目录中,且类文件的包名与目录结构匹配,就无需显式地设置ClassPath。

在现实世界中,通常会用到附加库。前一章说过,很多JVM语言都要求加载运行时库;如果没有它,应用程序将无法运行。通常,将这种附加库文件(常被称为依赖项)放在一个独立的子目录中。库放在JAR文件中时(通常如此),你只能在ClassPath指定它,因为JVM不会自动加载JAR文件。

下面来看一个真实的示例,这是在我的Windows计算机中启动开源应用程序服务器Apache TomCat所需的ClassPath:

  1. C:\apache-tomcat-8.0.44\bin\bootstrap.jar;C:\apachetomcat-
  2. 8.0.44\bin\tomcat-juli.jar

显然,启动Apache TomCat所需的类存储在两个JAR文件中:bootstrap.jar和tomcat-juli.jar。这些文件存储在Apache Tomcat安装目录下的子目录bin中。JAR文件包含一系列的类文件,这将在后面更详细地讨论。

对于目录和JAR文件,可指定其绝对路径,也可指定其相对路径。相对路径的起点为执行命令(如javajavac)时所在的目录。按从左到右的顺序读取ClassPath中的条目,直到找到指定的类,因此条目的排列顺序至关重要。

可在多个层级设置ClassPath。JVM按如下顺序选择要使用的ClassPath。

  • 如果设置了环境变量CLASSPATH,就使用该环境变量。
  • 如果在执行命令java(或其他JDK工具,如Java编译器javac)时指定了命令行选项-cp-classpath,就使用该选项。其他工具可能要求指定不同的命令行选项。

2.4 从命令行运行JVM应用程序 - 图2 设置环境变量CLASSPATH的做法真心不推荐,因为需要同时运行多个JVM应用程序时,将很难管理这个环境变量。

主要的JVM应用程序大都包含自动设置ClassPath的简单的操作系统shell脚本(用于Linux/macOS中)和批处理文件(用于Windows中),让用户只需启动脚本就能启动程序。有些应用程序甚至包含原生的可执行文件,它设置正确的ClassPath并在幕后启动JVM,从而对用户隐藏应用程序使用了JVM的事实。

为让开发人员能够在测试或开发期间轻松地启动JVM应用程序,大多数构建工具都提供了相应的任务(或实现这个任务的插件),让开发人员只需执行一个命令就能自动设置ClassPath并启动应用程序。第4章将介绍流行的构建工具Gradle提供的这种便利功能。

2.4.4 将类文件放在JAR归档文件中

为方便起见,可将一系列类文件归档为单个JAR文件。JAR文件是标准的ZIP文件(只是文件扩展名不同),但不同于ZIP文件,JAR文件有严格的内容管理规则。在ClassPath中指定JAR文件时,将加载其中所有的类,让JVM实例能够使用它们。

本章不会讨论如何创建JAR归档文件,而将这个主题留到第4章构建Java语言示例时再介绍。

2.4 从命令行运行JVM应用程序 - 图3 JAR文件可能有其外部依赖,在这种情况下,必须在ClassPath中包含这些依赖项。在提供JAR文件的库或工具的文档中,通常会指出这些外部依赖。

可运行的JAR文件

可对JAR文件进行设置,以便能够使用命令java来运行它,但仅当正确地配置了JAR文件时才有可能。在这种情况下,JAR文件指定了哪个类包含将被命令java运行的方法main()

对最终用户来说,这很方便,因为JAR文件是完全独立的:

  • JAR文件包含所有必要的依赖项;
  • 根本不需要(甚至无法)手动设置ClassPath;
  • 用户无需告诉JVM哪个类包含方法main()

2.4 从命令行运行JVM应用程序 - 图4 存在一些局限性:可运行的JAR文件无法查找它不包含的类;可运行的JAR文件中的类不能使用ClassPath。

2.4.5 使用命令java运行程序

命令java用于启动JVM实例和应用程序。面临的情形有两种:

  • 运行由独立的类文件组成的项目;
  • 运行存储在可运行的JAR文件中的项目。

我们还将介绍命令java的一些重要参数。

  • 运行由独立的类文件组成的项目

项目存储在包含类文件的目录中时,通常像下面这样使用命令java来运行它(即便项目依赖于JAR文件亦如此):

  1. java -cp "CLASSPATH" MAINCLASS ARGUMENTS

你需要将CLASSPATH替换为实际使用的类路径,并将MAINCLASS替换为包含静态方法main()的类的全限定名。如果这个类支持参数,可将ARGUMENTS替换为所需的参数。

下面来看一个真实的示例。这个示例摘自Oracle JDK组件JavaDB提供的Windows 批处理脚本,但稍微做了简化。从JDK安装目录的子目录db执行这个命令时,它将启动Apache Derby Network Server:

  1. java -cp
  2. "lib\derby.jar;lib\derbynet.jar;lib\derbyclient.jar;lib\derbytools.jar;lib\
  3. derbyoptionaltools.jar" org.apache.derby.drda.NetworkServerControl start

这个示例清楚地表明,必须在类路径中逐个指定所有必要的JAR文件,因为JVM实例根本不会尝试去加载未在类路径中指定的JAR文件。包含函数static void main()的类的全限定名为org.apache.derby.drda.NetworkServerControl,而向这个函数传递的命令行参数为start。由于每个类路径条目指定的都是一个JAR文件而不是目录,因此指定的类必须包含在其中的一个JAR文件中。

在较新的JRE版本中,可指定通配符,这将加载匹配的JAR文件。例如,前面的示例可简化成下面这样:

  1. java -cp "lib\*" org.apache.derby.drda.NetworkServerControl start

为加载JAR文件,通配符*必不可少。如果只指定目录名,而没有通配符,该目录中的JAR文件将不会添加到ClassPath中,而只会添加目录中的.class文件。

  • 运行放在可运行的JAR文件中的项目

要自动运行经过正确配置的JAR文件(这在前面讨论过,并非所有的JAR文件都如此),可像下面这样使用命令java

  1. java -jar PATH

请将PATH替换为JAR文件的绝对路径或相对路径。如果JAR文件配置正确,这将运行程序。

请注意,在这种情况下,无法设置类路径(因为将忽略环境变量CLASSPATH以及命令java-cp-classpath参数),因此指定的JAR归档文件必须包含所有必要的依赖项。

  • 命令java的其他很有用的参数

要查看完整的选项列表,可执行命令java而无需指定任何选项。

其中一些需要注意的选项如下:

  • 传递属性和值的选项-D
  • 启用断言的选项-ea
    有些选项既有简写形式,又有冗长形式;但这里只列出了简写形式。另外,参数是区分大小写的。

(1) 传递属性和值的选项-D

-D用于设置属性。属性是可在代码中读取的字符串,可以多种方式传递给JVM,包括使用选项-D。可使用这个选项多次:要传递给程序的每个参数/值对一次。

下面是一个这样的示例:

  1. java -cp CLASSPATH -DProperty1=Value1 -DProperty2=Value2 MAINCLASS

要在代码中读取属性,可使用java.lang.System类的方法getProperty。在后面的示例中,还使用了这个方法来读取预定义的系统属性。

(2) 启用断言的-ea

使用这个选项可启用默认被关闭的断言。

在支持断言的语言中,程序员可添加运行阶段条件检查。在Java中,这是通过添加assert语句并在其中指定条件实现的。断言被禁用时,这些语句将被忽略,但断言被启用时,JVM将在条件不满足时引发错误。这可用来检查程序是否像期望的那样工作。下面是一个Java assert语句:

  1. int i = 25;
  2. assert i < 24;

使用选项-ea启用了断言时,上述assert语句将导致JVM引发java.lang.Error。如果没有在命令行中显式地指定选项-ea,上述代码将不会引发错误。

可全局启用断言,也可针对特定的包启用断言,为此可使用-ea:PACKAGE,其中PACKAGE必须替换为完整的包名。你可为每个要启用断言的包指定选项-ea:PACKAGE

要详尽地测试代码,编写单元测试是更佳的做法,但断言在有些情况下也能派上用场。

2.4.6 在JVM中运行的示例项目

下面来创建一个稍微有点设计过度的程序,它在控制台中打印一些JVM信息,由三个类组成。这里不会使用IDE,而使用常规文本编辑器来编写代码,并使用命令提示符(Windows)或Terminal(macOS/Linux)来编译代码。最后,我们将从命令行运行这个应用程序。这个项目中的类放在下面几个包中:

  • com.example.app
  • com.example.app.model
  • com.example.app.view

请创建一个根目录来存储源代码文件和编译得到的文件,再在这个目录中创建子目录src和bin。

在目录src中,创建如下子目录:

  • com
  • com\example
  • com\example\app
  • com\example\app\model
  • com\example\app\view

启动你喜欢的文本编辑器,并在子目录model中创建一个名为ModelFoo.java的文件,它包含如下内容:

  1. package com.example.app.model;
  2. public class ModelFoo {
  3. public String getJVMInfo() {
  4. return "JVM version " + System.getProperty("java.version") +
  5. " by " + System.getProperty("java.vendor");
  6. }
  7. }

ModelFoo类只包含一个公有方法,这个方法返回一个String对象,其中包含一些有关当前使用的JVM的信息。在Java中,可直接使用System类,这将在第3章介绍。这个类的静态方法getProperty()返回指定属性的值,这里使用的两个属性都是内置的,它们分别包含JRE版本和厂商。

在子目录view中,创建文件ViewBar.java:

  1. package com.example.app.view;
  2. import com.example.app.model.ModelFoo;
  3. public class ViewBar {
  4. public void showJVMInfo(ModelFoo model) {
  5. System.out.println("This program is running on " +
  6. model.getJVMInfo());
  7. }
  8. }

这个类在控制台中打印对象model提供的版本信息。

最后,在子目录app中创建文件Controller.java:

  1. package com.example.app;
  2. import com.example.app.model.ModelFoo;
  3. import com.example.app.view.ViewBar;
  4. public class Controller {
  5. public static void main(String[] args) {
  6. ViewBar view = new ViewBar();
  7. view.showJVMInfo(new ModelFoo());
  8. }
  9. }

Controller类将其他两个类粘合起来,且包含方法main()。这个示例没有很好地实现“模型-视图-控制器”设计模式,因为为节省篇幅,我有点偷工减料了。

请注意,子目录src的结构与包名匹配。这是Java遵循的一个约定,其他一些语言并未遵循这种源代码文件存储约定,但JVM要求编译后的文件必须遵循这种存储约定。

打开命令提示符(Windows)或Terminal窗口(macOS/Linux),并切换到这个项目的根目录(即子目录src和bin所在的目录)。运行下面的命令来编译代码(请按你使用的操作系统要求的方式指定Controller.java的路径,这里遵循的是Windows使用的约定):

  1. javac -sourcepath src -d bin src\com\example\app\Controller.java

这里发生了很多事情。

  • 选项-sourcpath src告诉编译器,所有的源代码都位于子目录src中。
  • 选项-d binjavac将编译得到的文件放在子目录bin中。这个目录必须存在,但javac会根据需要自动创建子目录。
  • 最后,传递了主程序的源代码文件的路径。

由于源代码文件Controller.java导入了其他两个类,且目录src的结构与所有的包名都匹配,因此Java编译器能够找到所有的类并编译它们。

这将生成如下图所示的输出目录bin:

2.4 从命令行运行JVM应用程序 - 图5

下面来使用命令java运行这个应用程序。为此,在命令行窗口中切换到子目录bin,再执行如下命令:

  1. java com.example.app.Controller

在我的计算机上,输出如下:

  1. This is running on JVM version 1.8.0_112 by Oracle Corporation

ClassPath示例

为演示ClassPath的工作原理,我们将一个类文件移动另一个目录中。从概念上说,这与使用外部依赖没什么不同,因为根据约定,外部依赖也与项目的类存储在不同的目录中。请执行如下步骤:

  • 在项目的根目录(即包含子目录src和bin的目录)中,创建目录lib;
  • 在新创建的目录lib中,创建子目录com\example\app\model;
  • 将文件ModelFoo.class移到刚创建的子目录model中;
  • 为清晰起见,将空目录bin\com\example\app\model删除。

现在,目录结构应类似于下图这样:

2.4 从命令行运行JVM应用程序 - 图6

在命令提示符或Terminal窗口中,切换到项目根目录下的子目录bin,并尝试再次运行这个程序:

  1. java com.example.app.Controller

你将看到一个Java栈跟踪。请习惯这种情况,因为在JVM开发过程中,你经常会遇到这样的错误。在我的计算机上,出现的栈跟踪类似于下面这样(为简洁起见有删节):

  1. Error: A JNI error has occurred, please check your installation and try
  2. Again
  3. Exception in thread "main" java.lang.NoClassDefFoundError:
  4. com/example/app/model/ModelFoo
  5. at java.lang.Class.getDeclaredMethods0(Native Method)
  6. at java.lang.Class.privateGetDeclaredMethods(Unknown Source)
  7. at java.lang.Class.privateGetMethodRecursive(Unknown Source)
  8. at java.lang.Class.getMethod0(Unknown Source)
  9. ...

现在让java命令在当前目录和lib目录(它位于当前目录bin的父目录中)中查找代码中引用的类,为此可使用选项-cp来设置ClassPath:

  1. java -cp ".;..\lib" com.example.app.Controller

查找类时,JVM将首先在当前目录中查找,如果找不到,再在子目录..lib中查找。请注意,必须在类名前指定选项-cp及其值,否则它们将被传递给main函数的String数组参数,而不是命令java