为什么需要虚拟机?

30
我在阅读这篇问题,想了解Java虚拟机和.NET CLR之间的区别,Benji的答案让我想知道为什么首先需要虚拟机。
根据我对Benji解释的理解,虚拟机的JIT编译器将中间代码解释为实际运行在CPU上的汇编代码。原因是因为CPU通常具有不同数量的寄存器,并且根据Benji的说法,“一些寄存器是特殊用途的,每个指令都期望在不同的寄存器中使用其操作数”。这就很有意义了,需要一个中介解释器(例如虚拟机),以便可以在任何CPU上运行相同的代码。
但是,如果这是情况,那么我不明白的是,已编译为机器代码的C或C++代码只要是正确的操作系统,就能够在任何计算机上运行。为什么我在Windows机器上使用Pentium编译的C程序能够在我的另一台使用AMD的Windows机器上运行?
如果C代码可以在任何CPU上运行,那么虚拟机的目的是什么?它是为了使相同的代码可以在任何操作系统上运行吗?我知道Java在几乎所有操作系统上都有虚拟机版本,但除了Windows之外,是否还有其他操作系统的CLR?
还是有什么我不明白的?操作系统是否对其运行的汇编代码进行其他解释以适应特定的CPU或其他东西?
我对所有这些都很好奇,所以非常需要一个清晰的解释。
请注意:我没有足够的积分在JVM vs. CLR问题中发布评论,所以我没有将我的查询作为评论发布。
10个回答

43

AMD和Intel处理器使用相同的指令集和机器架构(从执行机器代码的角度来看)。

C和C++编译器将编译代码转换为机器代码,并附带针对目标操作系统的头文件。一旦编译完成,它们就不再与编译所用的语言有任何关联,只是二进制可执行文件。(虽然存在可能显示编译自哪种语言的工件,但这并不是本文的重点)

因此,一旦编译完成,它们就与机器(x86,即Intel和AMD的指令集和架构)以及操作系统相关联。

这就是为什么它们可以在任何兼容的x86计算机上运行,以及任何兼容的操作系统上(对于某些软件而言,包括win95到winvista)。

然而,它们无法在OSX机器上运行,即使它运行在Intel处理器上- 除非您运行其他仿真软件(例如parallels或带有Windows的虚拟机)。

除此之外,如果要在ARM处理器、MIPS或PowerPC上运行它们,则必须运行完整的机器指令集模拟器,将X86的二进制机器代码解释为您正在运行其上的任何机器。

与此相反,是.NET。

.NET虚拟机的制造方式仿佛有比现在更好的处理器-理解对象、内存分配和垃圾收集等高级构造的处理器。它是一台非常复杂的机器,现在无法直接在硅中建造(性能良好),但可以编写一个仿真器,使其可以在任何现有处理器上运行。

突然之间,您可以为要在其上运行.NET的任何处理器编写一个特定于该处理器的仿真器,然后任何.NET程序都可以在其上运行。不需要担心操作系统或底层CPU架构-如果有.NET VM,则软件将运行。

但让我们进一步探讨-一旦拥有这种通用语言,为什么不制作编译器,将任何其他编写语言转换为它?

因此,现在你可以拥有C、C#、C++、Java、JavaScript、Basic、Python、Lua或任何其他编程语言编译器,将编写的代码转换为在此虚拟机上运行的代码。

您已将机器与语言分离了两个程度,并且只要有编译器和VM来映射这两个分离的程度,不需要太多工作,即可使任何人编写任何代码并在任何机器上运行。

如果您仍然想知道为什么这是好事,请考虑早期的DOS机器以及微软对世界的“真正”贡献:

Autocad必须为可以打印到的每台打印机编写驱动程序。Lotus 1-2-3也是如此。实际上,如果您想让软件打印,就必须编写自己的驱动程序。如果有10台打印机和10个程序,则必须单独且独立地编写100个基本相同的代码片段。

Windows 3.1(以及GEM和其他许多抽象层)试图实现的目标是使打印机制造商为其打印机编写一个驱动程序,而程序员则为Windows打印机类编写一个驱动程序。

现在,对于10个程序和10个打印机,只需要编写20个代码片段,由于Microsoft端的代码对于每个人都是相同的,因此MS的示例意味着您需要做的工作非常少。

现在,程序不仅限于它们选择支持的10个打印机,而且限于所有其制造商在Windows中提供驱动程序的打印机。

应用程序开发也出现了同样的问题。有些非常棒的应用程序我无法使用,因为我没有使用MAC。存在大量重复(我们真正需要多少世界级文字处理器?)。

Java旨在解决这个问题,但它有很多限制,其中一些并没有真正解决。

.NET更接近,但没有人为除Windows之外的平台开发世界级VMs(mono非常接近...但还没有完全达到目标)。

所以…… 这就是为什么我们需要VMs。因为我不想仅仅因为他们选择了与我的不同的操作系统/机器组合而将自己限制在较小的受众范围内。

- Adam


1
啊,SO没有通知我其他人在发布。我以为这个问题只属于我自己!;-P - Adam Davis
是的,我们需要一种 AJAX 窗口来告诉我们我们正在与谁竞赛!哈哈 - Daniel
哇,你的回答太棒了!Casper的回答也很好,但我要把最佳答案给你,因为你的回答非常详细。谢谢,我学到了很多! - Daniel
@Adam:我有许多用Java编写的程序,复杂程度不一,但在许多操作系统和硬件平台上都能很好地运行 - 因此我认为Java非常接近解决这个问题;比CLI(只有Windows和几乎Linux)要接近得多。 - Lawrence Dol
1
@软件猿,Java是一种很棒的编程语言,但上次我使用它时发现在不同架构之间无法可靠地使用串口,并且音频和3D图形也存在问题,连通用应用程序都没有本地应用程序表达力强。.NET虽然不太便携,但本地应用程序仍然胜出。 - Adam Davis

8
您的假设是C代码可以在任何处理器上运行是不正确的。寄存器和字节序等因素会使编译后的C程序在某些平台上完全无法工作,而在另一些平台上可能可以正常工作。
然而,处理器之间也有一些相似之处,例如Intel x86处理器和AMD处理器共享了足够多的属性,大多数针对其中一个平台编译的代码将在另一个平台上运行。但是,如果您想使用特定于处理器的属性,则需要编写一个编译器或一组库来为您完成。
至于为什么要使用虚拟机,除了它可以为您处理处理器差异的陈述之外,还有这样一个事实,即虚拟机为C++(非托管)编译的程序提供了今天不可用的服务。
最突出的服务是CLR和JVM提供的垃圾回收。这两个虚拟机都免费为您提供此服务。它们为您管理内存。
类似边界检查、访问冲突(虽然仍然可能存在,但极为困难)等也被提供。
CLR还为您提供了一种形式的代码安全性。
这些服务并不是其他许多不使用虚拟机操作的语言的基本运行时环境的一部分。
您可以通过使用库来获得其中的一些服务,但这会强制您按照库的使用模式进行操作,而在.NET和Java中,CLR和JVM提供给您的服务在访问方面是一致的。

啊,好点,我竟然忽视了垃圾回收功能。光是这个就足以让我支持它了!内存泄漏真是令人头疼... - Daniel
垃圾回收并不是虚拟机的专利。对于编译语言,也有可以进行垃圾回收的库。 - user52875
实际上,垃圾收集并不是拥有虚拟机的主要原因... 主要原因是代码访问安全和即时编译。 - lubos hasko
@wdu和lubos hasko:如果你仔细阅读回复,你会发现我说的不是虚拟机的专属领域,而是虚拟机以一致的方式提供它们的服务,而在其他环境中,你有不同的模型,这些模型并不容易互换。 - casperOne
太遗憾了,我不能标记第二好的答案……或者第三好的答案。实际上,对于这个问题有很多非常好的答案,每个答案都有其他答案没有重点关注的东西。无论如何,在阅读了亚当的详细解释之前,你的答案是最好的。仍然,非常好的答案! - Daniel

5
大多数编译器,包括本地代码编译器,都使用某种中间语言。这主要是为了降低编译器构建成本。世界上有许多(N)编程语言。世界上也有许多(M)硬件平台。如果编译器在不使用中间语言的情况下工作,则需要编写N*M个“编译器”来支持所有语言和所有硬件平台。然而,通过定义中间语言并将编译器分成两部分,即前端和后端,其中前端将源代码编译为IL,后端将IL编译为机器代码,您只需编写N+M个编译器即可。这最终节省了大量成本。
CLR / JVM编译器和本地代码编译器之间的重大区别在于前端和后端编译器如何链接到彼此。在本地代码编译器中,两个组件通常合并到同一个可执行文件中,并且当程序员在IDE中点击“生成”时,两者都会运行。
对于CLR / JVM编译器,前端和后端在不同时运行。前端在编译时运行,生成实际发送给客户端的IL。然后,在运行时调用后端组件。
因此,这引出了另一个问题,“延迟后端编译直到运行时有什么好处”?
答案是:“取决于情况”。
通过延迟后端编译直到运行时,可以在多个硬件平台上运行一个二进制文件集。它还使得程序能够利用后端编译技术的改进而无需重新部署。它还为有效实现许多动态语言功能提供了基础。最后,它提供了在前期机器代码编译中不可能实现的分别编译、动态链接库(dll)之间引入安全性和可靠性约束的能力。
但是,也存在一些缺点。实现广泛的编译器优化所需的分析可能很昂贵。这意味着“JIT”后端通常会比前端后端做更少的优化。这可能会影响性能。此外,需要在运行时调用编译器,这也增加了加载程序所需的时间。使用“前端”编译器生成的程序没有这些问题。

感谢您的回答。了解到本地代码编译器和虚拟机都使用中间语言,只是在不同的时间将其编译为机器语言,这使得整个情况更加清晰。 - Daniel

5
基本上,它允许“托管代码”,这意味着虚拟机在运行代码时管理代码。这带来的三个主要好处是即时编译、托管指针/垃圾回收和安全控制。
对于即时编译,虚拟机监视代码执行,因此随着代码的频繁运行,它会被重新优化以更快地运行。原生代码无法做到这一点。
托管指针也更容易优化,因为虚拟机跟踪它们的位置,并根据它们的大小和生命周期以不同的方式管理它们。在C++中很难做到这一点,因为你无法通过阅读代码来确定指针将去哪里。
安全性是一个不言自明的问题,虚拟机停止代码执行不应该做的事情,因为它正在监视。我个人认为这可能是微软选择使用C#进行托管代码的最大原因。
基本上我的观点是,由于虚拟机可以在代码运行时监视代码,因此它可以做出使程序员更轻松并使代码更快的事情。

哇,有趣,我不知道JIT可以像那样优化代码以使其运行更快! - Daniel

3
首先,机器码不是CPU指令的最低形式。如今的x86 CPU本身会使用微码将X86指令集解释为另一种内部格式。实际上编写微码的只有芯片开发工程师类型的人,他们忠实而无痛地模拟遗留的X86指令芯片,以利用当今的技术实现最大的性能。
开发人员一直在添加额外的抽象层,因为它们带来的功能和强大。毕竟,更好的抽象允许更快、更可靠地编写新应用程序。企业并不关心代码长什么样子或者怎么编写,他们只想要可靠和快速完成任务。如果C版本的应用程序需要少几毫秒但需要花费双倍的时间来开发,这真的很重要吗?
速度问题几乎是一个无关紧要的争论,因为许多为数百万人服务的企业应用程序都是使用像Java这样的平台/语言编写的,例如GMail、GMaps。忘记哪种语言/平台最快。更重要的是使用正确的算法编写高效的代码,并完成任务。

哦,感谢您对微码的解释。我没有意识到即使汇编也是被解释的! - Daniel
@Daniel:术语“解释”并不是很准确,有几个原因。首先,即使在基于微码的处理器中,“微码指令集”通常也会包括围绕文档指令集设计的指令。例如,在8088上,“ADD <ea>,<reg>”指令集的主要微码可能是三个指令:“将寄存器操作数加载到op1;将EA操作数加载到op2并将op1 + op2放入res1;存储res1并准备下一条指令”。执行ADD SI,BX需要三个步骤,如上所示。 - supercat
对于像“ADD [SI+BX+12],DX”这样更复杂的指令,“将EA加载到op2”将触发一个嵌套序列,类似于“将SI加载到op1;将下一个指令字节加载到op2并将op1 + op2放入res2;用res2加载op1和BX加载op2,并将op1 + op2放入res2;在地址res2处获取内存到op2”。而“存储res1”将不是写入寄存器,而是存储地址为res2的内存。 - supercat
@Daniel:在更新的机器上,“解释性”的术语甚至更不适用了,因为解释程序通常会检查一条指令,决定该做什么,然后检查下一条指令,以此类推,但现代机器会将从内存中获取的指令转换为可以更高效运行的形式。这个转换后的形式可能在某种意义上像上面的8086代码一样“解释”,但“解释程序”能够在单个步骤中执行多个并行指令。 - supercat

2

AMD和Intel处理器都采用x86架构,如果您想在不同的架构上运行C/C++程序,您需要使用该架构的编译器,同一二进制可执行文件无法在不同的处理器架构上运行。


啊,我明白了,那就是我所缺少的那部分信息。那么,寄存器的数量和其他对汇编代码重要的CPU细节在所有X86处理器上都是标准的吗? - Daniel
你不需要不同的编译器,但你仍然需要在给定平台上运行的不同虚拟机。 - lubos hasko

2
我知道Java在几乎所有操作系统上都有虚拟机版本,但除了Windows之外,是否还有其他操作系统的CLR? Mono
(注:Mono是一个跨平台的实现.NET框架的软件项目,它可以在Linux、macOS、Windows等多个操作系统上运行。)

现在.NET框架已经开源。MSVC也可以编译Android、iOS或其他平台的应用程序。 - phuclv

2
简化地说,这是因为英特尔和AMD实现了相同的汇编语言,具有相同数量的寄存器等等...
因此,您的C编译器编译代码以在Linux上运行。该汇编使用Linux ABI,只要编译程序在x86汇编上运行,并且具有正确的函数签名,那么一切都很好。
现在尝试将已编译的代码放在Linux / PPC上(例如iBook上的Linux)。那是行不通的。而Java程序可以,因为JVM已经在Linux / PPC平台上实现了。
现代汇编语言基本上是另一种程序员可以编程的接口。 x86(32位)允许您访问eax,ebx,ecx,edx用于通用整数寄存器,f00-f07用于浮点寄存器。在幕后,CPU实际上还有更多寄存器,并将其混合在一起以挤出性能。

0

你的分析是正确的,Java或C#本来可以被设计成直接编译运行在任何机器上,如果这样做可能会更快。但虚拟机的方法可以完全控制代码运行的环境,虚拟机创建了一个安全沙盒,只允许具有正确安全访问权限的命令执行潜在破坏性的代码 - 比如更改密码或更新硬盘引导扇区。还有许多其他好处,但这是最重要的原因。在C#中无法获得StackOverflow...


一个方法递归调用自身并导致调用栈溢出的情况怎么办? - mP.
@mP:它不会溢出堆栈 - C# 在任何其他内存被覆盖之前停止执行,因此堆栈溢出变成了堆栈溢出错误。 - Adam Davis
好的,我的意思是经典的病毒黑客攻击,即在堆栈上覆盖返回地址 - 在C#中是否可能? - MrTelly
顺便问一句,能有人告诉我为什么我的答案不够好吗? - MrTelly
大多数操作系统在常规编译代码上执行安全控制方面做得很好。在类 UNIX 系统中,除非有权限,否则 C 程序无法更改引导扇区。此外,通过使用实现适当保障的编译语言,可以避免堆栈溢出等问题。 - Artelius

0

我认为你的问题前提是正确的 - 你肯定不是第一个问这个问题的人。所以请查看http://llvm.org,看看另一种方法(现在是由苹果运行或赞助的项目)。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接