1.4 JVM概念
要有所作为,JVM开发人员必须熟悉下面这些最重要的JVM概念:
- JVM是一种虚拟机;
- JVM实现大都自带即时(just-in-time,JIT)编译器;
- JVM提供了一些内置的基本类型;
- 除基本类型之外的其他一切都是对象;
- 对象是通过引用类型来访问的;
- 垃圾收集器(garbage collector,GC)进程将过期的对象从内存中删除;
- JVM大量地使用构建工具。
1.4.1 虚拟机
Java虚拟机是一种虚拟机,这一点显而易见,但必须牢记在心。这意味着从理论上说,在一种计算机上开发的应用程序,可在另一种计算机上运行。
一般而言,代码在32位还是64位的Java运行时环境(Java Runtime Environment,JRE)中运行无关紧要。在64位的运行时环境中运行时,应用程序可使用的内存可能更多,但只要应用程序不执行原生操作系统调用或需要数GB的内存,这种差别就无关紧要。
在C等语言中,数据类型的长度取决于原生系统,而Java不存在这样的问题或特色(这是问题还是特色取决于你怎么看)。在JVM中,整型都是无符号的且长32位,而不管运行程序的是哪种计算机平台或系统架构。
最后,需要指出的是,在JVM中运行的每个应用程序都在系统内存中加载自己的JVM实例。这意味着同时运行多个Java应用程序时,每个应用程序都有自己的JVM副本;这还意味着在必要的情况下,不同的应用程序可使用不同的JVM版本。出于安全考虑,不建议在同一个系统中安装不同的JDK或JRE版本;通常最好只安装系统支持的最新版本。
1.4.2 JIT编译器
虽然没有规定,但所有流行的JVM实现都并非只有简单的解释器:除解释器外,它们还自带了复杂的JIT编译器。
启动Java应用程序时,将首先启动并初始化JVM。JVM启动并初始化后,它将立即开始解释并运行Java字节码。在合适的情况下,解释器将对程序的某些部分进行编译,再将原生可执行代码加载到内存中,并开始执行这些代码而不是经过解释后的Java字节码。这样生成的代码的执行速度通常要快得多。
对代码进行编译还是解释取决于很多因素。对于经常被调用的例程,JIT编译器很可能对其进行编译以生成原生代码。
JIT方法的优点在于,分发的文件可以是跨平台的,且用户无需等待整个应用程序编译完毕。JVM初始化后,应用程序将立即开始执行,而优化是在幕后完成的。
1.4.3 基本数据类型
JVM提供了几个内置的基本数据类型,这是Java未被视为纯粹的OOP语言的主要原因。这些类型的变量不是对象,且始终都包含值。
| Java名称 | 描述和长度 | 取值范围(含) |
|---|---|---|
byte
| 有符号字节(8位) | -128~127 |
short
| 有符号短整型(16位) | -32768~32767 |
int
| 有符号整型(32位) | -231~231-1 |
long
| 有符号长整型(64位) | -263 ~263-1 |
float
| 单精度浮点数(32位) | 不精确的浮点值 |
double
| 双精度浮点数(64位) | 不精确的浮点值 |
char
| 单个Unicode UTF-16字符(16位) | Unicode字符0~655535 |
boolean
| 布尔值 | True/False |
请注意,并非所有JVM语言都支持创建基本类型变量,并将其他一切都视为对象。你将看到,这通常不是问题,因为Java类库包含包装基本类型的包装对象,而包含Java在内的大多数语言都会在必要时自动使用这些包装对象。这个过程被称为自动装箱(auto-boxing)。
1.4.4 类
函数和变量都是在类中声明的。即便是应用程序的入口函数(在程序启动时调用的函数main())也是在类中声明的。
JVM只支持单继承模型,即类最多继承一个类。这影响不大,因为使用了名为“接口”的结构来缓解这种影响,你将在下一章看到。接口基本上是一个函数原型(只有函数的定义,而没有代码)和常量列表,编译器要求实现了接口的类必须提供这些函数的实现。类可实现任意数量的接口,但必须提供这些接口定义的每个方法的实现。
本书介绍的有些语言对开发人员隐藏了上述事实。例如,不同于Java,有些语言允许在类声明外面定义函数和变量,甚至允许可执行代码位于函数定义外面。还有些语言支持继承多个类。在内部,这些语言巧妙地规避了JVM的限制和设计决策。
JVM类通常以包的方式进行分组。在下一章,你将看到类是如何组织的。
1.4.5 引用类型
与大多数现代编程语言一样,JVM不直接操作指向对象的内存指针,而使用引用类型。引用变量要么指向特定的类实例,要么什么都不指向。
如果一个引用变量指向特定的对象,就可使用它来调用该对象的方法或访问其公有属性。
如果一个引用变量没指向任何东西,就被称为空引用(null reference)。使用空引用来调用方法或访问属性时,将在运行阶段引发错误。对于这个常见的问题,本书介绍的有些语言提供了解决方案。
引用和空引用
请看下面的代码:
Product p = new Product();p.setName("Box of biscuits");
假设这里的Product是当前程序可使用的一个类。我们创建一个Product实例,并让变量p指向它。接下来,我们对这个对象实例调用方法setName。
JVM没有提供直接访问这个Product对象所在内存单元的途径,而只提供了指向该对象的引用。当你使用变量p时,JVM将确定为访问这个变量指向的对象,需要访问哪个内存单元。
我们在前述代码片段中添加如下代码行:
p = null;p.setName("This line will produce an error at run-time");
可显式地将引用设置为null。请注意,对于在方法内声明的变量,并非必须这样做,因为方法结束时,将自动清理这些变量,但这样做也是完全合法的。现在变量p是一个空引用。下一段将介绍对象实例不再被任何引用变量指向后将发生的事情。
上述代码能够通过编译,但程序运行时,最后一行将引发NullPointerException异常。如果没有提供错误处理功能,应用程序将崩溃。很多现代IDE都力图发现这种情形,并向开发人员发出警告。
1.4.6 垃圾收集器
JVM不要求程序员在创建和销毁对象时手工分配和释放内存块。通常,程序员只需在需要时创建对象即可。
有一个名为GC的进程,它每隔一段时间让应用程序停止执行,并在内存中扫描不再在作用域内的对象(不能被任何对象访问的对象),再将这些对象从内存中删除,并收回释放的内存空间。
以前,这个进程会导致严重的性能问题,但它使用的算法已得到极大的改进。另外,根据应用程序的需求,系统管理员可配置GC的众多参数,以更好地控制它。
开发人员应始终牢记GC算法。如果你不断地创建大量的对象,并确保它们位于作用域内(即让所有这些对象都是可访问的,如将它们存储在应用程序可访问的列表中),那么迟早会导致内存耗尽,进而引发错误。
示例
假设你为一个在线商店开发了一个电子商务应用程序,同时假设每位已登录的用户都有一个ShoppingBasket实例,其中存储了该用户已加入到购物车中的商品。
现在假设今天有一位已登录的用户,他打算购买一块香皂和一盒饼干。对于这位用户,应用程序将创建两个Product实例(每件商品一个),并将它们添加到ShoppingBasket的products列表中,如下图所示。

结账前,这位用户发现Amazon也有这样的饼干,但价格低得多,因此决定将其从购物车中删除。从技术上说,应用程序将从products列表中删除相应的Product实例。但这样做后,表示Chocolate cookies的Product实例就成了孤儿对象。鉴于没有任何引用指向它,应用程序再也无法访问它,如下图所示。

过段时间后,JVM的GC启动,它发现应用程序无法访问表示Chocolate cookies的Product对象,因此决定将其删除,从而释放它占用的内存,如下图所示。

为避免GC将对象删除,有多种技巧。其中一个著名的技巧是,在应用程序需要使用大量类似的对象时,将这些对象放在一个对象池(对象列表)中。在应用程序需要对象时,只需从池中取回一个,并根据需要修改它。使用完毕并不再需要该对象时,将其放回到对象池中。由于这些对象始终在作用域内(未用时,这些对象位于应用程序能够访问的对象池中),GC不会销毁它们。
1.4.7 向后兼容
负责维护JVM和Java类库的人深知企业开发人员的需求:现在编写的代码最好以后也能运行。在向后兼容方面,JVM做得很不错;如果你熟悉Python 2和Python 3,就知道情况并非总是如此。
较新的JVM版本能够运行针对较旧的JVM版本编译的应用程序,条件是应用程序没有使用在较新的JVM版本中已删除的API或技术。例如,在Java 8 JVM实例上运行的项目可加载并使用针对Java 6编译的库,但反过来行不通,即在Java 6 JVM实例上运行的应用程序不能加载针对更高版本编译的类。
当然,与其他平台和语言一样,负责维护JDK和Java类库的人必须时不时地摒弃一些类和技术。在向后兼容性方面,JVM虽然存在问题,但总体而言比众多其他的平台和语言要好得多。另外,通常仅当有合适的替代品后,才会将API删除。
1.4.8 构建工具
在项目比较简单的年代,为自动化编译和打包过程,使用的是简单的批文件或操作系统shell脚本文件。随着项目越来越复杂,定义这样的脚本越来越难。另外,对于不同的操作系统,必须编写完全不同的脚本。
不久后,第一套专用的Java构建工具应运而生。它们使用的是XML构建文件,让你能够编写跨平台的脚本。最初,必须编写冗长而繁琐的脚本;但后来,这些工具采用了约定优先于配置的范式。遵循这些工具指定的约定时,需要编写的代码少得多;但如果你面对的不是默认情形,可能需要花很大的精力来让工具按你希望的做。为自动化构建过程,较新的工具放弃了XML文件,转而提供了脚本语言。
在这些工具中,很多都提供了如下功能。
- 内置的依赖管理器:能够从著名的网络仓库下载附加库。
- 自动运行单元测试,并在测试失败时停止打包。
JDK本身没有提供构建工具,但几乎每个项目都至少使用了下面一个开源的构建自动化工具。
- Apache Ant(没有内置的依赖管理器,使用的是基于XML的构建脚本)。
- Apache Maven(通过使用XML文件引入了约定优先于配置的原则,并使用插件)。
- Gradle(构建脚本是使用Groovy或Kotlin编写的)。
只要使用的是流行的IDE,JVM程序员就无需过多地考虑构建自动化工具,因为所有IDE都能够生成构建脚本。如果要获得更大的控制权,可手工编写脚本,并让IDE根据你编写的脚本来编译、测试和运行项目。
