1.0 第1章:Java内存的不同部分
你是否遇到过必须重启应用程序才能提升其性能的情况?如果是这样,你可能已经亲身体验了内存管理不佳的后果:内存被填满,应用程序变慢。虽然应用程序变慢的原因不止于此——例如处理来自服务器的数据或网络瓶颈等——但内存管理问题通常是导致应用性能下降的常见嫌疑。
你可能之前就听说过计算机科学领域中的“内存”。这很合理,因为计算机拥有内存,并在运行程序时(程序本身也是数据!)使用内存来存储和访问数据。
那么,应用程序何时会使用内存呢?例如,假设你想运行一个处理巨大视频文件的应用程序。如果你在打开该应用程序并加载视频时,打开了活动监视器(例如 macOS 上的 Activity Monitor 或 Windows 上的 Task Manager),你会看到已用内存增加了。内存是计算机上的有限资源,一旦耗尽,计算机就会变慢。
提升应用程序性能的方法有很多种。更深入地理解内存实际工作原理是其中一种可以帮助你提升应用性能的方法。通过在编码中采用良好实践来高效利用内存,将能提升应用程序的性能。因此,在内存管理方面,写好代码并时刻关注内存的工作原理,始终是达成高性能的首要方法。还有另一种影响 Java 内存管理的方式,那就是配置负责管理 Java 内存的 Java 虚拟机(JVM)。这将在我们准备好后,在第六章中介绍。
高效处理 Java 内存对于 Java 应用程序的性能至关重要。在 Java 中,这一点尤其重要,因为它包含了诸如垃圾回收(Garbage Collection)等高开销过程,同样,我们将在后续掌握了足够的基础知识后再来了解它。
内存管理对于并发环境下的数据完整性也很重要。如果现在这听起来非常复杂,不必担心。到本书结束时,你会明白其中的含义。
因此,为了优化应用程序 Java 内存的使用,我们首先需要理解这些内存的表现形式,并掌握内存的基本操作过程。本章将就此展开。我们将探索 Java 内存的不同部分,以及我们如何在日常编码中使用它。你将获得 Java 内存的良好概览,并为后续章节的深入探讨做好准备。为此,我们将涵盖以下主题:
- 技术要求
- 理解计算机内存与 Java 内存
- 创建变量
- 在栈上存储变量
- 创建对象
- 在堆上存储对象
- 探索元空间
技术要求
本章的代码可在 GitHub 上找到:PacktPublishing/B18762_Java-Memory-Management。
理解计算机内存与 Java 内存
首先,运行应用程序——无论是 Java 还是其他——都需要计算机内存。应用程序的内存就是计算机的物理内存。对计算机内存了解更多将有助于我们理解 Java 内存。因此,让我们更详细地讨论一下内存和 Java 内存的概念。
计算机内存
你可能已经知道这一点,但为了重申:计算机拥有内存。这是计算机中用于存储执行进程所需信息的部分。我们也将它称为主存或有时称作主存储器。这里需要强调的一点是,这与计算机存储(用于长期存储信息)不同。这种存储是长期的,因为 HDD 硬盘以磁性方式存储信息,而 SSD 可以归类为电可擦除可编程只读存储器(EEPROM)。它们不需要持续供电来保持数据。另一方面,一种常见的主存类型——随机存取存储器(RAM)——需要持续供电才能保持数据。
这可以部分地与我们的人类大脑相比。我们拥有长期记忆和短期记忆。长期记忆用于存储我们的“记忆”——例如,一个珍贵的童年回忆:父亲调皮地用独轮车推着你,母亲引用你最喜爱的故事书中的句子,而你穿着三岁时最爱的衣服(太美妙了,剩下的还是留给我的回忆录或治疗师吧)。短期记忆则非常适合记住两步验证所需的六位数字,而且如果你几分钟后就忘记了它们,那就更好了。
访问主存
计算机,或者说计算机的 CPU,访问主存的速度比访问永久存储空间要快得多。主存中存放着当前打开的程序以及它们正在使用的数据。
也许你记得有一天第一次打开电脑并启动一个每天使用的应用时,发现它需要几秒钟才能启动。如果关闭它(也许是不小心),然后紧接着再次打开,速度会快很多。主存相当于某种缓存或缓冲区,这就解释了第二次加载时间更短的现象。第二次可以从主存而不是存储中打开它,这证明(或至少支持)了主存更快的观点。
好消息是,你不需要理解计算机内存最微小的细节,但一个大致的概览会有所帮助。
主存概览
主存最常见的部分是 RAM。RAM 在很大程度上决定了计算机的性能。正在运行或活跃的应用程序需要 RAM 来存储和访问数据。应用程序和进程可以非常快速地访问这种内存。如果有足够的 RAM 可用,并且操作系统(OS)很好地管理了 RAM,那么你的应用程序就可以发挥其性能潜力。
你可以通过查看监视器应用来了解可用的 RAM 大小。对我而言,那是 Activity Monitor。如下图所示,我的计算机目前正在使用相当多的内存:
图 1.1 – macOS 12.5 上 Activity Monitor 的截图
我按内存使用量从高到低对进程进行了排序。在底部,你可以看到可用内存和已用内存的摘要。老实说,这看起来有点高,我应该在写完本章后调查一下。
为什么在仍有大量可用内存时还要调查呢?嗯,如果 RAM 被填得太满,正在运行的应用程序就只能非常缓慢地运行。当你运行了超过计算机规格允许数量的或更重的应用程序时,很可能已经经历过这种情况。
RAM 是易失性的。这意味着当你关闭电源时,信息就会丢失。主存不仅包含 RAM。只读存储器(ROM)也是主存的一部分,但它是非易失性的。它包含计算机启动所需的指令,所以幸运的是,当我们关闭电源时,这些信息不会消失!
趣闻
我们将主存称为 RAM,这是非常常见的术语,但现在你知道这在严格意义上是不准确的!确实是个趣闻。
Java 内存与 JVM
你可能在想我们是否还会涉及 Java 内存——是的,我们会!Java 内存与计算机内存模型有些相似之处,但也不同。然而,在我们讨论 Java 内存之前,我需要解释一下什么是 JVM。我必须说,非常感谢你的耐心。
JVM
JVM 执行 Java 应用程序。这是否意味着 JVM 理解 Java?不,完全不是!它理解的是字节码——即 .class 文件。这意味着编译后的 Java 程序。其他一些语言(如 Kotlin)的代码也会编译成 JVM 字节码,因此也能被 JVM 解释。这就是为什么它们有时被称为 JVM 语言,例如 Java、Kotlin、Scala 等。
步骤参见图 1.2:
图 1.2 – 一次编写,到处运行
源代码(图中假定是 Java 源代码)由 Java 编译器编译。结果是包含字节码的 .class 文件。这些字节码可以由 JVM 解释。每个平台,无论是 macOS、Windows 还是 Linux,都有自己的 JVM 版本来执行字节码。这意味着应用程序不需要修改就能在不同的环境中运行,因为特定于平台的 JVM 负责处理这一点。
JVM 实际上是 Java 曾经因“一次编写,到处运行”原则而闻名和受推崇的原因。Java 不再因这一点而那么出名的原因是,如今语言以这种方式工作已相当普遍。任何安装了 JVM 的平台都可以运行 Java,因为 JVM 负责将其翻译成所在机器能理解的指令。
我通常将此比作旅行插头转换器。插头在全球并不通用,因为不同地区使用不同的插座。如果你带上了合适的旅行转换插头,无论走到哪里,都可以使用自己的适配器。在这种情况下,旅行转换器就是 JVM。“无论走到哪里”就是你试图运行 Java 的平台,而你的适配器就是你的 Java 程序。
让我们看看 JVM 如何处理内存管理的基础知识。
内存管理与 JVM
Java 内存存储运行 Java 应用程序所需的数据。Java 应用程序中所有类的实例都存储在 Java 内存中。原始类型的值也是如此。常量呢?也存储在 Java 内存中!方法代码、本地方法、字段数据、方法数据以及方法执行的顺序呢?你大概猜到了,它们都存储在 Java 内存中!
JVM 的任务之一就是管理 Java 内存。没有这种内存管理,就无法分配内存,也无法存储对象。即使这部分到位,也永远不会有清理动作。因此,清理内存(也称为对象的释放)对于运行 Java 代码至关重要。没有它,代码无法运行,或者如果只分配不释放,内存会满,程序将耗尽内存。具体如何运作,我们将在第四章讨论释放过程(称为垃圾回收)时学习。
长话短说:内存管理很重要。它是 JVM 非常重要的任务之一。实际上,如今我们有点把自动内存管理视为理所当然,但在早期,这是非常新颖和特别的。让我们看看如果 JVM 不为我们管理内存,可能会发生什么。
Java 之前的内存管理
在更早的语言中,例如 C 和 C++,内存管理是开发者的责任。这意味着必须使用命令来分配和释放内存区域。例如,在 C 中,以下代码片段展示了如何分配一些内存并为其赋值。请注意,这只是一个展示自动垃圾回收有多棒的小例子,绝不是如何在 C 中进行内存分配的完整指南——其中涉及的内容远多于此:
int* x;
x = (int*)malloc(4 * sizeof(int));int* 表示 x 保存指向内存块基地址的指针的值。
malloc(代表内存分配)是一个用于分配内存的函数。
1.0 第1章:Java内存的不同部分
分配一个指定大小的内存块。在本例中,该指定大小是 int 大小的四倍。该函数返回基地址。
如果我们随后想要给该内存分配赋值,我们需要使用 *x 来实现——否则,我们会覆盖掉内存位置本身:
*x = 5;
printf("Our value: %d\n", *x);上述代码片段将值 5 赋值给 x 所指向的内存位置。因此,如果我们随后打印存储在该位置的值 (*x),将看到值为 5。注意,x 本身(而非 *x)才是内存地址。
当不再需要 *x 持有该内存位置时,我们需要手动释放内存。如果不这样做,内存将一直保持占用状态,被不必要的消耗掉。释放内存的方式如下:
free(x);
x = NULL;我们使用 free 函数使内存重新变得可用,以便后续再次请求其他内存块时可以重新分配。这样就算完成了吗?不,还没完成。我们仍然持有指向那个内存位置的指针。由于该内存位置现在已被释放,它可能被其他内容覆盖,我们将不知道那个地址存储了什么。因此,我们将指针设置为 NULL。
那么,释放内存后 x 指向什么?它仍然指向相同的地址——但里面是什么?这是不确定的。根据释放的实现方式,它可能是空的,也可能是尚未被覆盖的旧值,但一旦被覆盖,就会是覆盖后的新值。换句话说:一个巨大的惊喜!当然,我通常喜欢惊喜,但在代码变量值的问题上,可不太希望这样。
以下是手动进行内存管理时常见的一些问题:
手动内存管理的常见问题
- 悬空指针:如果在释放内存地址后没有将持有指针的变量设置为
NULL,就会发生这种情况。- 内存泄漏:如果不再需要某段内存时没有释放它,就会发生这种情况。内存不会被重新利用,而是被不必要地占用。最终,由于持有所有不需要的值,内存可能会耗尽。
- 样板代码:代码库中充斥着大量与分配和释放相关的代码,而不是业务逻辑。所有这些代码都需要维护。
- 易出错:尽管开发者(通常)知道需要做什么,但很容易犯小错误,例如忘记释放内存或将指针设置为
NULL。
还有其他常见的陷阱,但我认为这些已经足以让我们理解 JVM 及其垃圾回收器和自动分配的价值。接下来,让我们看看 JVM 中实现了哪些机制来支持这一切。
理解 JVM 的内存管理组件
为了能够执行应用程序,JVM 大致包含三个组件。第一个是用于加载所有类的类加载器。这本身就是一个复杂的过程:类被加载,字节码被验证。类的加载和字节码的执行都需要内存。这些内存用于存储类数据、内存分配以及正在执行的指令。这就是运行时数据区组件的作用,也是本书的核心内容:Java 内存。当类被加载后,这些文件需要被执行。执行引擎负责执行加载到主内存中的字节码,它依赖前两个组件。执行引擎通过Java本地接口(JNI) 与执行字节码所需的本地库进行交互。这些过程及其步骤如图 1.3 所示:
flowchart LR A[源码.java] --> B[编译器] B --> C[字节码.class] C --> D[类加载器] D --> E[运行时数据区] E --> F[执行引擎] F --> G[JNI] G --> H[本地库]
图 1.3 – JVM 组件用于应用程序执行的概览
现在我们已经知道了内存大致包含哪些元素,让我们更详细地探讨内存管理最重要的组件:运行时数据区。
运行时数据区
以下是 JVM 的运行时数据区:
- 栈
- 堆
- 方法区/元空间
- 运行时常量池
- 程序计数器寄存器
- 本地方法栈
Java 内存的不同部分如图 1.4 所示:
flowchart TD subgraph JVM内存 direction LR A[堆] B[栈] C[方法区/元空间] D[程序计数器寄存器] E[本地方法栈] end A --> A1[新生代] A --> A2[老年代] A --> A3[永久代<br>(Java 8前)/元空间] B --> B1[栈帧] C --> C1[运行时常量池] C --> C2[类信息] C --> C3[静态变量]
图 1.4 – 不同运行时区域的概览
如图所示,内存由不同的部分组成。所有这些部分对于 Java 应用程序的运行都是必需的。让我们详细看看每个内存部分。
堆
JVM 启动时,会从 RAM 中预留一块内存供 Java 应用程序用于动态内存分配。这块内存称为堆。这是运行时数据存储的区域。类的实例可以在堆上找到。JVM 负责为堆分配空间,并通过一个称为垃圾回收的过程清理堆。分配空间也称为分配,释放空间也称为去分配。堆上对象的去分配由 JVM 的垃圾回收过程处理。垃圾回收针对堆的不同区域工作。这些不同的区域以及垃圾回收是非常有趣的话题,我们将在后续章节中详细讨论。
第3章与第4章
堆上的对象释放由JVM的垃圾回收过程处理。垃圾回收作用于堆的不同区域。这些不同的区域以及垃圾回收是非常有趣的主题,我们将在第3章和第4章中详细讨论。
栈
栈,或更准确地说是JVM栈,用于存储基本类型和指向堆的指针。每当调用一个方法时,栈上会创建一个帧(frame),该帧保存该方法的值,例如部分结果和返回值。
栈并非只有一个。应用程序中的每个线程都拥有自己的栈。图1.5展示了这一点:
图 1.5 – 栈区域包含每个线程的栈
线程是一条执行路径。当一个应用程序有多个线程时,意味着多个事件同时发生。应用程序中的这种“同时发生”是一个非常重要的概念,称为并发(Concurrency)。
这意味着内存的栈区域实际上包含大量栈——每个线程一个。线程只能访问自己的栈,栈之间不能有链接。
因此,栈存储方法执行所需的值,每个线程都有自己的栈。接下来要讨论的运行时数据区部分是方法区。
方法区(元空间)
方法区用于存储类的运行时表示。方法区包含运行时代码、静态变量、常量池以及构造函数代码。总结来说:这是存储类元数据的地方。所有线程共享此方法区。JVM只规范了方法区,但从Java 8开始我们一直使用的实现称为元空间(Metaspace)。该区域以前的名称是永久代(PermGen,即永久生成)。实际上,永久代和元空间之间也存在一些差异,但这些有趣的细节留待后文讨论。难道我们不喜欢这种悬念吗?
PC寄存器
程序计数器(PC)寄存器通过保存其线程中正在执行的指令的地址来了解当前正在执行的代码。在图1.6中,你可以看到这一描述:
图 1.6 – PC寄存器包含每个线程的寄存器
每个线程都有自己的PC寄存器,有时也称为调用栈(Call Stack)。它知道需要执行的语句序列以及当前正在执行哪一条。这就是为什么每个线程需要一个独立的PC寄存器——如果只有一个PC寄存器,我们就无法同时执行多个线程!
这与栈区域类似,如图1.6与图1.5比较所示。
1.0 第1章:Java内存的不同部分
1.5和图1.6进行了对比。
本地方法栈
还有一个本地方法栈(native method stack),也称为C栈(C stack)。它用于执行本地代码。本地代码是Java实现中不是用Java编写的部分,例如用C编写。这些栈为本地代码存储值,就像JVM栈为Java代码所做的那样。同样,每个线程都有自己的本地方法栈。其实现方式取决于JVM的具体实现。有些JVM不支持本地代码;显然,它们也不需要本地栈。这一点可以在你所使用的JVM文档中找到。
至此,我们更详细地了解了Java运行时数据区的不同部分。可能有许多新信息向你扑面而来,这可能有点难!在继续之前,让我解释一下为什么我们想要知道这些。
此时,你可能想知道我还在等什么,并迫不及待地想开始——没错!我们将在本章详细讨论内存管理的基础知识、栈和堆内存以及元空间,但首先,我们需要看看在Java中创建变量的方式。
在Java中创建变量
在Java中创建变量意味着我们必须声明一个变量。如果还想使用它,就必须对其进行初始化。如你所知,声明是分配类型和名称的过程。初始化是为变量赋予实际值:
int number = 3;
char letter = 'z';这里我们在同一行声明并初始化了变量。我们通过类型和名称进行声明。这里的类型是int和char,变量名称是number和letter。也可以跨多行分开进行:
double percentage;
percentage = 8.6;JVM不再检查类型——这是在运行应用程序之前由编译器完成的。原始类型和引用类型的存储实际上存在差异。这就是我们现在要研究的内容。
原始类型和引用类型
JVM处理两种类型的变量:原始类型(primitives)和引用类型(reference types)。Java中有八种原始类型:
int | byte | short | long | float | double | boolean | char |
|---|
原始类型仅存储值,且限于八种类型。还有引用类型。引用类型是类的实例。你可以创建自己的类。因此,引用类型的数量没有实际限制。
当你创建变量时,其中可以存储两种类型的值:原始值(primitive values)和引用值(reference values)。原始值的类型是原始类型之一。引用值持有一个指向对象位置的指针。
引用分为四种形式:
- 类引用(Class references):保存(动态)创建的类对象。
- 数组引用(Array references):具有组件类型(component type)。这是数组的类型。如果组件类型不是数组类型,则称为元素类型(element type)。数组引用始终具有单一维度,但组件类型可以是另一个数组,从而创建多维数组。无论数组有多少维,最后一个组件类型都不是数组类型,因此就是元素类型。该元素类型可以是三种类型之一:原始类型、类类型或接口类型。
- 接口引用(Interface references)
- 空引用(null):特殊情况下,引用不指向任何东西。此时引用的值为
null。
这些变量是如何存储的呢?原始变量和引用变量存储在栈上。实际的对象存储在堆上。让我们先来看看在栈上存储变量。
在栈上存储变量
方法中使用的变量存储在栈上。栈内存是用于执行方法的内存。在图1.7中,我们展示了一个包含三个线程的栈区域,每个线程包含若干帧。
图1.7 – 三个线程的栈区域中的帧概览
方法内部存在原始类型和引用类型。应用程序中的每个线程都有自己的栈。栈由帧(frames)组成。每次调用方法时,都会在栈上创建一个新帧。方法执行完毕后,该帧被移除。
如果栈内存太小,无法存储帧所需的内容,则会抛出StackOverFlowError。当没有足够的空间为新线程创建新栈时,会抛出OutOfMemoryError。当前被线程执行的方法称为当前方法(current method),其数据保存在当前帧(current frame)中。
当前帧和当前方法
之所以称为栈,是因为它只能访问栈顶的帧。可以将其比作一叠盘子,你只能(安全地)从顶部取盘子。顶部的帧称为当前帧,因为它属于当前方法——正在执行的方法。
如果正在执行的方法调用了另一个方法,则会在该帧之上放置一个新帧。这个新帧成为当前帧,因为新调用的方法成为正在执行的当前方法。
在图1.7中,有三个当前帧,因为有三个线程。当前帧是位于顶部的那些帧。因此,我们看到:
- 线程1的方法y的帧
- 线程2的方法c的帧
- 线程3的方法k的帧
当方法执行完毕,它就会被移除。之前的帧再次成为当前帧,因为调用其他方法的方法重新获得控制权,并成为当前正在执行的方法(当前方法)。
帧的组成元素
一个帧包含若干元素。这些元素用于存储方法执行所需的所有数据。所有元素的概览如图1.8所示。
图1.8 – 栈帧的示意概览
如图所示,一个帧包含局部变量数组(local variable array)、操作数栈(operand stack)和帧数据(frame data)。让我们更详细地探讨帧的各个元素。
局部变量数组
帧的局部变量存储在一个数组中。该数组的长度在编译时确定。数组包含单槽(single spots)和双槽(double spots)。单槽用于int、short、char、float、byte、boolean和引用类型。双槽用于long和double(它们的大小为64位)。
局部变量可以通过其索引访问。方法有两种类型:静态方法(类方法)和实例方法(instance methods)。对于实例方法,局部变量数组的第一个元素始终是对方法所在对象的引用,也称为this。传递给方法的参数从局部变量数组的索引1开始。
对于静态方法,不需要向帧提供实例,因此它们从索引0开始存放用于调用它们的参数。
操作数栈
这个概念可能有点难以理解,请忍耐一下。每个栈帧都有一个操作数栈——堆栈(帧)上的一个栈(操作数栈)——该操作数栈用于写入操作数,以便对其进行操作。所有值都在这里飞来飞去。
这里需要一个例子,让我们来看一个。当帧刚刚创建时,操作数栈上没有任何内容,但假设创建帧的方法将要执行一个基本的数学运算,例如将x和y相加。
x和y是局部变量,它们的值位于前面提到的局部变量数组中。为了执行运算,需要将它们的值压入操作数栈——因此,首先压入x的值,然后压入y的值。
操作数栈是一个栈,因此当需要访问变量时,它只能从栈顶获取。它先弹出y,然后弹出x。之后,操作数栈再次为空。执行的操作知道弹出变量的顺序。操作完成后,结果被压入操作数栈,并可以从那里弹出。
操作数栈还用于其他重要操作,例如准备要发送给方法作为输入的参数,以及接收方法返回的结果。
帧数据
帧数据包含执行方法所需的各种数据。一些示例包括对常量池的引用、如何正常返回方法以及异常中止(或异常)的方法。
第一个示例——对常量池的引用——需要特别关注。类文件包含所有需要在运行时常量池中解析的符号引用。该常量池包含运行该类所需的所有常量,并由编译器生成。它包含类中标识符的名称,JVM在运行时使用该文件将类与其他类链接起来。
每个帧在运行时都有一个对当前方法常量池的引用。由于这是一个包含符号引用的运行时常量池,因此链接需要动态发生。
让我们看一下我们的Example类的常量池是什么样的。以下是Example类的代码:
package chapter1;
public class Example {
public static void main(String[] args) {
int number = 3;
char letter = 'z';
double percentage;
percentage = 8.6;
}
}通过运行以下命令(在我们使用javac Example.java编译之后),我们可以看到常量池:
javap -v Example.class以下是输出:
Classfile
/Users/maaikevanputten/Documents/packt/memorymanagement/src/main/java/chapter1/Example.class
Last modified 12 Jun 2022; size 298 bytes
SHA-256 checksum
b2a6321e598c50c5d97ba053ca0faf689197df18c5141b727603eaec0fecac3e
Compiled from "Example.java"
public class chapter1.Example
minor version: 0
major version: 61
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #9 // chapter1/Example
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Double 8.6d
#9 = Class #10 // chapter1/Example
#10 = Utf8 chapter1/Example
#11 = Utf8 Code
#12 = Utf8 LineNumberTable
#13 = Utf8 main
#14 = Utf8 ([Ljava/lang/String;)V
#15 = Utf8 SourceFile
#16 = Utf8 Example.java
{
public chapter1.Example();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=5, args_size=1
0: iconst_3
1: istore_1
2: bipush 122
4: istore_2
5: ldc2_w #7 // double 8.6d
8: dstore_3
9: return
LineNumberTable:
line 5: 0
line 6: 2
line 8: 5
line 9: 9
}
SourceFile: "Example.java"
如你所见,常量池有16个条目。这些条目有些是我们创建的,有些是Java创建的。它们对于程序的执行是必需的,因此程序名称、方法名称等都在常量池中创建以便运行程序。
栈上的值
注意:本节内容未完,后续将继续讨论栈上变量存储的细节。
栈上的值
基本类型局部变量的值直接存储在栈中——更准确地说,存储在方法栈帧的局部变量数组中。对象并不存储在栈上,而是将对象引用存储在栈上。对象引用是一个地址,用于在堆上找到该对象。
基本类型与包装类
注意不要将基本类型与其对应的对象包装类混淆。区分它们的简单方法是:包装类类型名首字母大写。包装类对象不在栈上存活,仅仅因为它们是对象。每当方法执行完毕后,关联的基本类型值会从栈上清除,永久消失。
有些包装类比其他更容易识别。来看一段代码片段:
int primitiveInt = 2;
Integer wrapperInt = 2;
char primitiveChar = 'A';
Character wrapperChar = 'A';如你所见,包装类以大写字母开头且名称更长。但对于许多类型,单词完全一样,唯一区别是首字母大写。我个人最常被 Boolean 和 boolean 欺骗(我归咎于 C#,因为 Java 中 boolean 基本类型在 C# 中对应的类型是 bool)。
下面展示其他基本类型与其引用类型之间的差异:
short primitiveShort = 15;
Short wrapperShort = 15;
long primitiveLong = 8L;
Long wrapperLong = 8L;
double primitiveDouble = 3.4;
Double wrapperDouble = 3.4;
float primitiveFloat = 5.6f;
Float wrapperFloat = 5.6f;
boolean primitiveBoolean = true;
Boolean wrapperBoolean = true;
byte primitiveByte = 0;
Byte wrapperByte = 0;请注意它们名称完全相同。我们需要看首字母来区分包装类与基本类型。
包装类是对象,它们的创建方式不同。接下来看看如何创建。
在Java中创建对象
对象是一组值的集合。在Java中,可以使用 new 关键字实例化类来创建对象。
下面是一个基础的 Person 类:
public class Person {
private String name;
private String hobby;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getHobby() {
return hobby;
}
public void setHobby(String hobby) {
this.hobby = hobby;
}
}如果要实例化它,我们会使用:
Person p = new Person();这行代码的作用是创建一个新的 Person 对象并将其存储在堆上。
在堆上存储需要更多的解释——这正是我们现在要深入探讨的!
在堆上存储对象
在堆上存储对象与在栈上存储值截然不同。正如我们刚刚看到的,指向堆上位置的引用存储在栈上。这些引用是内存地址,这些内存地址对应堆上存储该对象的某个位置。没有这个对象引用,我们就无法访问堆上的对象。
对象引用具有特定类型。Java中有许多内置类型可供使用,例如 ArrayList、String、所有包装类等等,但我们也可以创建自己的对象,这些对象同样存储在堆上。
堆内存保存着应用中存在的所有对象。堆上的对象可以通过对象的地址(即对象引用)从应用中的任何位置访问。对象包含的内容与栈上的块相同:直接包含基本类型值,以及指向堆上其他对象的地址。
在图1.9中,你可以看到栈和堆的概览,以及对于以下Java代码(简化视图)的样子:
public static void main(String[] args) {
int x = 5;
Person p = new Person();
p.setName("maaike");
p.setHobby("coding");
}图1.9 – 栈与堆之间连接的概览
这个图非常简化——例如,Person 对象中的 String 对象本身应该是独立的对象。接下来我们将重点放在堆上。
第1章:Java内存的不同部分
第3章将更深入地理解堆区域。
那么,当我们耗尽堆内存时会发生什么?如果应用程序需要的堆空间超过可用量,就会抛出 OutOfMemoryError。
好了,我们已经了解了栈和堆。这里还需要讨论一个内存区域——元空间。
探索元空间
元空间 是保存运行时所需的类元数据的内存空间。在 JVM 规范中它被称为方法区,并且在 Java SE 7 之后的大多数主流 Java 实现中,这个区域被称为元空间。
如果你听说过 PermGen(永久代),或者将来遇到它,只需知道这是旧的内存区域,用于存储所有类元数据。它存在一些局限性,已被元空间取代。
那么,回到这个类元数据。它到底是什么?类元数据是 Java 类的运行时表示,是程序运行所必需的。实际上它包含很多东西,例如:
- Klass 结构(我们将在第5章深入探讨元空间时进一步了解)
- 方法的字节码
- 常量池
- 注解 等
就是这样!这些是 Java 内存管理的基础。关于各个具体部分还有很多可以讨论的内容。下一章我们将开始更仔细地研究堆上的原始类型和对象,但首先,让我们回顾一下本章的内容。
总结
在本章中,我们回顾了 Java 内存的概况。我们从计算机内存开始,了解到计算机有主存和辅助存储。主存对我们来说最重要,因为它是用于运行程序(包括 Java 程序)的内存。
主存由 RAM 和 ROM 组成。Java 应用程序使用 RAM 运行。Java 应用程序由 JVM 执行。JVM 执行 Java 应用程序,为此它包含三个组件:类加载器、运行时数据区和执行引擎。
我们重点研究了运行时数据区的不同组件:堆、栈、方法区、PC 寄存器和本地方法栈。
- 栈 是用于以帧的形式存储方法变量和值的内存区域。
- 堆 用于存储对象。栈持有对堆上对象的引用。堆在应用程序中的任何位置都可访问,任何拥有堆上对象地址的代码都能访问该对象。栈只能由创建该栈的线程访问。
元空间 是存储运行时所需的类元数据的内存区域。
在下一章中,我们将通过可视化更深入地了解堆和栈内存是如何结合的。