Java内存区域详解(重点)
一、前言
对于 Java 程序员来说,在虚拟机自动内存管理机制下,不再需要像 C/C++程序开发程序员这样为每一个 new 操作去写对应的 delete/free 操作,不容易出现内存泄漏和内存溢出问题。正是因为 Java 程序员把内存控制权利交给 Java 虚拟机,一旦出现内存泄漏和溢出方面的问题,如果不了解虚拟机是怎样使用内存的,那么排查错误将会是一个非常艰巨的任务。
二、运行时数据区域
Java 运行时数据区域
在 Java 程序执行的过程中,JVM 会将内存划分为多个不同的区域,每个区域都有特定的用途。这些区域分为线程私有和线程共享两大类。以下是对这些区域的简要概述,结合 JDK 1.7 和 JDK 1.8 的一些差异。
线程私有的区域
程序计数器
程序计数器是每个线程都有的私有内存区域,它用于记录当前线程所执行的字节码指令的位置。对于多线程程序,程序计数器确保每个线程在执行时互不干扰。程序计数器有时也用来实现控制流(如条件分支、循环、异常处理等)。它是线程切换时唯一不会丢失的信息。虚拟机栈
每个线程都会分配一个虚拟机栈,栈中包含的栈帧用于存储方法调用时的局部变量、操作数栈、动态链接等信息。每次方法调用都会创建一个新的栈帧,方法执行结束后,栈帧被销毁。虚拟机栈的主要作用是帮助管理方法执行过程中的临时数据,通常来说,栈内存是分配在方法调用过程中,栈空间是按需增长的。本地方法栈
本地方法栈与虚拟机栈类似,但它主要是为 JNI(Java Native Interface)提供支持。每当调用一个本地方法(比如用 C 或 C++ 编写的代码)时,就会使用到本地方法栈。它的功能与虚拟机栈基本相似,区别在于它处理的是本地方法的调用,而不是 Java 方法。
线程共享的区域
堆
堆是 JVM 中最重要的内存区域,所有的对象实例都存放在堆中。堆是线程共享的内存区域,所有线程都可以访问其中的对象。堆的内存大小通常可以在启动 JVM 时进行配置,JVM 还会在运行时根据实际情况对堆进行扩展。垃圾回收器会定期清理堆中的无用对象,以避免内存泄漏。方法区
方法区用于存储 JVM 类的相关信息,包括类的字节码、常量池、字段信息、方法信息等。它是线程共享的区域。虽然方法区在 JDK 1.7 之前称为 "永久代",但从 JDK 1.8 开始,永久代被废弃,取而代之的是 "元空间"。元空间是 JVM 使用的本地内存,不再受堆大小限制,能够更好地适应类的动态加载和卸载。直接内存
直接内存并不属于 JVM 运行时数据区的一部分,但它也是由操作系统直接分配的内存区域,通常用于优化 I/O 操作。Java 的 NIO(New I/O)库通过ByteBuffer
直接与操作系统内存交互,不经过 JVM 的堆,从而避免了不必要的内存复制,提高了 I/O 性能。
JVM 内存管理特点
堆内存管理与垃圾回收
JVM 的堆内存管理通常由垃圾回收器(GC)进行。GC 会定期扫描堆中的对象,回收那些不再被引用的对象,从而避免内存泄漏。不同的垃圾回收算法(如串行收集器、并行收集器、G1 收集器等)各有特点,开发者可以根据应用需求选择合适的 GC 算法。内存空间动态调整
JVM 在运行时对内存的管理非常灵活。堆空间的大小可以通过 JVM 参数动态调整。堆内存的大小调整通常是由 GC 策略决定的,JVM 会根据系统的负载和可用内存动态分配堆空间,以确保性能。线程私有与线程共享
Java 的内存管理将不同的区域分配给线程私有或线程共享。在多线程环境下,私有区域(如程序计数器和虚拟机栈)保证了线程间的隔离,而共享区域(如堆和方法区)则允许线程间的数据共享。这样设计的目的是为了提高并发性和线程的独立性。JDK 1.8 之后的改变
在 JDK 1.8 中,方法区被元空间(Metaspace)取代。元空间不再使用 JVM 堆内存,而是使用操作系统的本地内存。这意味着,JDK 1.8 在内存管理上更加灵活,并且能够更高效地处理类的元数据。
总结
Java 的运行时数据区域提供了灵活的内存管理方式,支持高效的垃圾回收和多线程并发执行。通过合理利用这些内存区域,可以有效提升 Java 程序的性能,尤其是在内存密集型应用和高并发环境中。JVM 对这些区域的管理既提供了必要的抽象,又允许在实现上有一定的自由度,使得不同 JVM 实现可以根据硬件和操作系统的特点进行优化。
程序计数器
程序计数器是 JVM 内存模型中最小的一块内存区域,它的作用主要是标记当前线程执行的字节码指令的位置。在 Java 中,每个线程都有自己的程序计数器,因此它是线程私有的内存区域。
作用
控制字节码的执行流
程序计数器通过记录下一条要执行的字节码指令的位置,实现程序的流程控制。字节码解释器根据计数器的值,依次读取指令并执行,这样可以实现程序的顺序执行、分支选择、循环跳转以及异常处理等功能。对于 JVM 内部的执行机制来说,每当一个线程要执行指令时,程序计数器记录的是字节码的行号。线程切换时保存状态
在多线程的环境下,当线程发生上下文切换时,程序计数器用于保存当前线程执行的位置,从而在该线程恢复执行时,能够准确地接续之前的指令位置。程序计数器确保了多线程之间的独立性,避免了不同线程之间相互影响。
线程私有
- 每个线程都有自己的程序计数器,且各线程之间的计数器互不干扰,因此程序计数器是线程私有的内存区域。这意味着,当多个线程并行执行时,每个线程都有一个独立的程序计数器来跟踪其执行状态,确保各自独立执行。
特殊性质
不会导致
OutOfMemoryError
程序计数器是唯一一个不会抛出OutOfMemoryError
的内存区域。它的内存需求非常小,通常情况下它的大小仅为几个字节。即使在极限情况下,它也不会因为内存溢出而崩溃,这使得程序计数器非常稳定和可靠。生命周期
程序计数器的生命周期与线程相同。当一个线程被创建时,程序计数器也随之创建;当线程结束时,程序计数器随之销毁。
总结
程序计数器在 JVM 中是非常重要的,因为它决定了每个线程执行指令的顺序和执行位置。在多线程环境下,程序计数器保证了线程独立的执行状态,是线程切换时最基础的保存机制。尽管程序计数器的功能简单,但它在 JVM 的多线程支持和字节码执行控制方面起着至关重要的作用。
Java 虚拟机栈
Java 虚拟机栈(JVM Stack)是每个线程私有的内存区域,它的生命周期和线程一致,随着线程的创建而创建,随着线程的死亡而死亡。虚拟机栈的核心作用是执行 Java 方法调用、管理方法的局部变量、执行方法的中间结果以及支持方法调用的动态链接等。
栈帧结构
虚拟机栈由多个栈帧(Stack Frame)组成,每个栈帧在方法调用时被压入栈中,在方法返回时被弹出。每个栈帧包括以下几个主要部分:
局部变量表
存放方法的输入参数以及局部变量,基本数据类型(如int
、float
)和对象引用都在此存放。对于基本类型,局部变量表存储的是值,对于引用类型,则存储的是指向对象的引用。操作数栈
主要用于存储方法执行过程中产生的中间计算结果。操作数栈是一个栈结构,支持压入(push)和弹出(pop)操作,它可以作为算术运算的临时存储空间。动态链接
当方法调用其他方法时,需要通过动态链接来解析符号引用。虚拟机通过常量池中保存的符号引用(如方法的符号引用)来转换为实际的内存地址,从而完成方法调用。方法返回地址
当一个方法调用完成后,返回地址用于跳回到调用该方法的代码位置,继续执行调用后的语句。
栈的工作方式
每当一个方法被调用时,JVM 会为该方法分配一个新的栈帧,并将其压入当前线程的栈中。方法执行过程中,栈帧中的局部变量表、操作数栈和其他数据会随之变化。方法执行完毕后,栈帧被弹出,栈空间释放。
异常情况
栈在使用过程中可能会遇到以下两种异常:
StackOverflowError
这是由于栈深度过大导致的错误,通常发生在方法调用陷入无限递归时。每一次方法调用都会占用一个栈帧,如果调用深度过大,超出了栈的最大深度,JVM 会抛出StackOverflowError
错误。OutOfMemoryError
虽然虚拟机栈的深度有限,但如果栈的大小能够动态扩展,系统无法为扩展栈分配足够的内存时,会抛出OutOfMemoryError
错误。这通常与系统内存不足或栈大小设置过大有关。
栈与方法调用的关系
方法调用:
每次方法调用,JVM 会为该方法创建一个栈帧,栈帧会包括该方法的局部变量表、操作数栈等。方法调用结束后,栈帧被弹出。方法返回:
无论方法是正常执行完毕返回,还是由于异常被终止,栈帧都会被弹出。方法返回时,JVM 会根据栈帧中的返回地址跳转到调用该方法的位置,继续执行后续代码。
栈空间的使用
栈空间通常由操作系统提供,它的大小是有限的。栈的大小可以通过 JVM 启动参数进行配置:
-Xss
参数用于设置每个线程的栈大小。例如:-Xss1m
设置栈大小为 1MB。
总结
虚拟机栈是每个线程私有的内存区域,用于管理方法调用和执行过程中的数据。栈帧作为栈的基本单位,存储了局部变量、操作数栈、动态链接和方法返回地址。栈的正确管理对多线程环境下的程序稳定性至关重要。如果栈深度过大或栈空间不足,就可能导致 StackOverflowError
或 OutOfMemoryError
错误。
本地方法栈
本地方法栈(Native Method Stack)与虚拟机栈有些相似,都是线程私有的内存区域。它们的主要区别在于:虚拟机栈服务于执行 Java 方法,而本地方法栈则服务于执行 Native 方法。Native 方法是指那些用其他编程语言(如 C、C++)编写的方法,这些方法并不由 Java 字节码表示,而是直接与操作系统交互,通常用于性能要求较高或者需要与硬件、操作系统打交道的场景。
在 HotSpot 虚拟机中,本地方法栈和虚拟机栈是合并在一起的,并没有严格区分开来。因此,通常情况下我们并不需要关心本地方法栈和虚拟机栈的区别。
本地方法栈的工作方式
与虚拟机栈类似,当调用本地方法时,虚拟机会为该本地方法分配一个栈帧。栈帧用于存储以下内容:
- 局部变量表: 存放本地方法的输入参数、局部变量等。
- 操作数栈: 存放方法执行过程中需要的中间结果,类似于虚拟机栈中的操作数栈。
- 动态链接: 本地方法栈中的动态链接用于与本地方法所依赖的符号进行链接。
- 方法返回地址: 本地方法栈中的方法返回地址用于本地方法执行完后返回到上一级调用。
本地方法栈中的栈帧在方法执行完毕后被弹出并释放内存空间。
错误类型
本地方法栈和虚拟机栈一样,可能会抛出以下两种异常:
StackOverflowError
如果本地方法调用深度过大,栈帧的数量超出了本地方法栈的最大容量,就会抛出StackOverflowError
错误。通常这种错误出现在递归调用的情况下。OutOfMemoryError
如果虚拟机在动态扩展本地方法栈时无法获得足够的内存空间,可能会抛出OutOfMemoryError
错误。这通常与本地方法栈的大小设置过大或系统内存不足有关。
配置本地方法栈大小
本地方法栈的大小可以通过 JVM 参数进行配置:
-Xss
:用于设置每个线程的栈大小。对于本地方法栈和虚拟机栈,这个参数都适用。如果程序调用了大量的本地方法,可以调整这个参数来避免栈空间不足的情况。示例:
-Xss1m
设置栈大小为 1MB。
总结
本地方法栈是专门为虚拟机执行本地方法(Native 方法)而准备的内存区域。它的工作原理与虚拟机栈类似,只不过本地方法栈负责的是本地方法调用和执行,而虚拟机栈负责 Java 方法的调用。与虚拟机栈一样,本地方法栈也可能发生 StackOverflowError
和 OutOfMemoryError
错误,通常可以通过适当的参数调整来避免这些问题。
堆
Java 堆是 JVM 管理内存的主要区域之一,几乎所有的对象实例和数组都在此分配。Java 堆是线程共享的内存区域,虚拟机启动时就会创建,并在运行期间根据需求动态扩展。堆的主要作用是存放 Java 程序中的对象,因此,堆的管理与垃圾回收密切相关。
1. 堆的内存结构
Java 堆通常被分为多个区域,用以优化垃圾回收过程和内存分配的效率。以 JDK 7 和之前的版本为例,堆通常分为以下三个部分:
新生代 (Young Generation)
新生代用于存放新创建的对象。大部分对象都会首先分配在新生代的 Eden 区。如果对象在经历过一次垃圾回收后依然存活,它会被转移到 Survivor 区。随着对象的“年龄”增大,最终会被晋升到老年代。老年代 (Old Generation)
老年代存放长期存活的对象。这些对象经历了多次垃圾回收后仍然没有被销毁,因此被晋升到老年代。老年代的垃圾回收通常会比较慢,因为其存活的对象较多,回收的复杂度也相应较高。永久代 (Permanent Generation)
永久代主要用于存放类元数据,如类的定义、方法、常量池等。在 JDK 8 之前,永久代是堆的一部分。然而,在 JDK 8 中,永久代被 Metaspace 替代,Metaspace 使用的是本地内存而非堆内存。
2. 垃圾回收与对象晋升
新生代的对象分配遵循以下流程:
- 对象首先被分配在 Eden 区。
- 在进行一次垃圾回收后,如果对象仍然存活,它将被移动到 Survivor 区。新晋升的对象通常被分配到 S0 区或 S1 区。
- 对象在 Survivor 区的存活时间会增加,每次垃圾回收后,如果对象依然存活,它的“年龄”会递增。当对象的年龄达到一定的阈值时,便会晋升到 老年代。
对象的晋升年龄阈值可以通过 -XX:MaxTenuringThreshold
参数进行设置。默认值为 15,表示对象的年龄在存活 15 次 GC 后才会晋升到老年代。如果设置的值超过 15,会抛出错误 MaxTenuringThreshold of 20 is invalid; must be between 0 and 15
。
3. 堆内存的配置与常见错误
堆内存的大小可以通过 JVM 参数进行调整,主要有以下几个:
最大堆内存 (-Xmx)
设置最大堆内存的大小。比如,-Xmx2g
会将最大堆内存设置为 2GB。初始堆内存 (-Xms)
设置堆内存的初始大小。比如,-Xms512m
会将堆内存的初始大小设置为 512MB。堆内存的扩展方式
当堆内存不足时,JVM 会自动进行扩展,除非设置了最大堆内存的上限。不同操作系统对堆内存的限制也会影响最大堆内存的分配。
常见的堆内存错误包括:
java.lang.OutOfMemoryError: GC Overhead Limit Exceeded
该错误通常发生在垃圾回收占用了过多时间,但仍然回收不到足够的内存。此时,JVM 会放弃回收,抛出此错误。java.lang.OutOfMemoryError: Java heap space
当堆内存不足以容纳新创建的对象时,会发生此错误。通常是由于对象创建过多,超出了 JVM 配置的最大堆内存限制。java.lang.OutOfMemoryError: Metaspace
在 JDK 8 之后,永久代被 Metaspace 替代。如果 Metaspace 内存不足,可能会抛出此错误。Metaspace 的大小可以通过-XX:MaxMetaspaceSize
参数进行控制。
4. 堆内存的优化
通过合理配置堆内存,可以有效避免 OutOfMemoryError
。以下是几种常见的优化方式:
合理配置堆内存的大小
在生产环境中,根据应用的内存需求,适当调整-Xms
和-Xmx
参数。通过性能测试来确定合适的堆内存大小,避免因内存不足引发异常。监控垃圾回收行为
使用 JVM 提供的日志参数(如-Xlog:gc*
)监控垃圾回收的情况,分析是否存在频繁的垃圾回收问题,进而调整堆内存的配置或优化代码,减少对象的创建频率。使用对象池
对于一些频繁创建和销毁的对象,可以考虑使用对象池来复用对象,减少内存的频繁分配与回收。避免内存泄漏
使用工具(如 VisualVM 或 JProfiler)监控内存使用情况,确保没有对象因长时间无法被回收而导致内存泄漏。
总结
Java 堆是 JVM 内存管理的重要部分,主要用于存放对象实例和数组。通过合理配置堆内存大小、优化垃圾回收策略和避免内存泄漏,可以有效提升 Java 应用的性能和稳定性。在进行堆内存调优时,需要根据应用的具体需求和运行情况来进行优化。
方法区
方法区是 JVM 运行时数据区域的一部分,主要用于存储类的元数据、常量池、静态变量、即时编译器编译后的代码等信息。它是所有线程共享的内存区域,不同的虚拟机实现对方法区的具体实现有所不同。方法区的设计和实现可以追溯到《Java 虚拟机规范》,其中规定了方法区的作用,但具体的实现由不同的 JVM 提供商决定。
1. 方法区与永久代(PermGen)和元空间(Metaspace)
方法区与永久代(PermGen)
在 JDK 1.8 之前,HotSpot 虚拟机中的方法区是通过 永久代(PermGen) 来实现的。永久代用于存放类的元数据(如类信息、方法信息、字段信息等)、静态变量、常量池、类加载器等数据。永久代大小是固定的,无法根据运行时需求动态调整。JVM 会通过设置-XX:MaxPermSize
来限制永久代的最大大小,超出此限制时会抛出java.lang.OutOfMemoryError: PermGen
错误。方法区与元空间(Metaspace)
从 JDK 1.8 开始,永久代被 元空间(Metaspace) 替代。元空间并不位于堆内存中,而是使用本地内存。相较于永久代,元空间的最大优势在于它不再受到 JVM 内存的限制,而是受到物理内存的限制。元空间的大小可以通过系统的可用内存动态调整,默认情况下是无限制的,但可以通过-XX:MaxMetaspaceSize
参数进行限制。
2. 为什么永久代被替换为元空间
永久代被替换为元空间的原因有几个方面:
永久代的大小受到 JVM 的限制
永久代有一个固定的最大大小,不能动态扩展。如果没有适当地调整MaxPermSize
,在类加载过多时就可能导致永久代溢出。元空间使用的是本地内存
与永久代不同,元空间使用的是本地内存,受系统实际可用内存的限制。这样,JVM 可以加载更多的类和方法元数据,不会因为内存限制而导致加载失败。元空间提高了垃圾回收效率
永久代在垃圾回收时带来了较大的复杂性,并且回收效率较低。元空间的引入简化了类加载过程,并且对 GC 的影响也更小。合并了 HotSpot 和 JRockit
在 JDK 8 中,HotSpot 和 JRockit 的代码合并,JRockit 并没有永久代的概念,因此 HotSpot 中不再需要额外设置永久代。
3. 方法区常用的参数
在 JDK 1.8 之前,永久代的大小可以通过以下参数进行配置:
-XX:PermSize=N # 设置永久代初始大小
-XX:MaxPermSize=N # 设置永久代最大大小
在 JDK 1.8 之后,永久代被替换为元空间,常用的参数为:
-XX:MetaspaceSize=N # 设置元空间的初始大小(最小大小)
-XX:MaxMetaspaceSize=N # 设置元空间的最大大小
-XX:MetaspaceSize
用来设置元空间的初始大小,JVM 会根据需求动态扩展该大小,直到达到 MaxMetaspaceSize
设置的最大值。如果元空间的大小超过了系统内存限制,就会抛出 java.lang.OutOfMemoryError: Metaspace
错误。
4. 方法区的内存管理
类加载的过程
当虚拟机加载一个类时,它会解析.class
文件并将相关的元数据(如类名、父类信息、字段信息、方法信息等)存入方法区。随着 JVM 加载的类增多,方法区的内存占用也会逐渐增加。方法区的垃圾回收
与堆内存中的对象不同,方法区的垃圾回收通常比较少见。这是因为类的生命周期通常较长,而且类加载的频率也比较低。方法区的垃圾回收主要发生在类卸载的情况下,例如:在 Java 程序中没有引用某个类,或者该类的 ClassLoader 被回收时,类才会从方法区中被移除。元空间的动态调整
元空间不再是固定大小,JVM 会根据系统的可用内存动态调整元空间的大小。默认情况下,JVM 会自动扩展元空间的大小,直到系统内存被消耗殆尽。因此,使用-XX:MaxMetaspaceSize
参数限制最大内存使用量可以防止系统内存耗尽。
5. 总结
方法区是 JVM 中重要的内存区域,主要用于存储类的元数据、常量池、静态变量等信息。JDK 1.8 之前,方法区由永久代实现,JDK 1.8 后改为使用元空间。元空间使用的是本地内存,不再受到固定大小的限制,支持动态扩展,减少了内存溢出的风险,并提高了类加载和回收的效率。通过合理设置 JVM 参数,可以更好地管理方法区的内存使用,避免内存溢出错误的发生。
运行时常量池
运行时常量池(Runtime Constant Pool)是每个类(或接口)在加载到 JVM 时,会从其 .class
文件中的常量池表(Constant Pool Table)中提取字面量和符号引用信息,存储在方法区的一个专用内存区域中。这些字面量和符号引用的信息包括了类、字段、方法、接口等相关的数据。
1. 常量池的组成
常量池中的内容可以分为两大类:
- 字面量(Literals)
字面量是指代码中直接写出的固定值,比如常量值3.14
,整数100
,字符串"Hello"
等。字面量包括:- 整数字面量(如
10
,100
) - 浮点字面量(如
3.14
,2.71
) - 字符串字面量(如
"hello"
,"world"
)
- 整数字面量(如
- 符号引用(Symbolic References)
符号引用是通过符号名称来表示对类、字段、方法等的引用。符号引用不会直接包含目标对象的内存地址,而是包含指向目标对象的符号名称,JVM 在运行时会解析这些符号引用并转换为直接引用。常见的符号引用类型包括:- 类符号引用:指向某个类的符号。
- 字段符号引用:指向某个字段的符号。
- 方法符号引用:指向某个方法的符号。
- 接口方法符号引用:指向接口方法的符号。
2. 常量池的作用
常量池的作用是避免重复存储常用的字面量和符号引用,它作为一种缓存机制,提高了内存的使用效率。具体来说,它有以下功能:
- 节省内存:常量池确保类中的常量只会存储一次,从而减少内存消耗。
- 提高性能:通过对常量的重复利用,减少了常量的重新计算,提升了程序的执行效率。
- 符号引用的管理:符号引用在运行时会被解析为直接引用,确保了 Java 类的动态加载和方法调用的正确性。
3. 运行时常量池的存储位置
- 类加载后存储位置
类在加载后,常量池表中的字面量和符号引用会被加载到 方法区的运行时常量池 中。方法区作为 JVM 内存区域的一部分,保存着该类的元数据信息,包括运行时常量池。
4. 常量池的内存限制
由于运行时常量池属于方法区的一部分,它的内存空间也受到方法区内存大小的限制。当运行时常量池无法申请到足够的内存时,JVM 会抛出 OutOfMemoryError
错误。这通常发生在以下几种情况:
- 类的数量过多:每个类都会有一个常量池,如果加载的类非常多,常量池中的数据也会增多,最终可能会导致方法区内存的不足。
- 常量池中存储的数据过多:如果程序中使用了大量的字符串或常量值,常量池的内存需求可能会超出方法区的限制。
5. 运行时常量池的特点
- 与类紧密相关:每个类都拥有自己的运行时常量池,它随着类的加载而加载,并且每个类的常量池是独立的。
- 常量池的大小:JVM 在启动时会预先为方法区分配一定大小的内存,但这块内存的大小是有限的。通过合适的配置,可以调整方法区的大小,避免常量池内存溢出。
6. 常见错误
OutOfMemoryError
错误
当常量池的内存超出方法区的限制,JVM 无法再为其分配内存时,就会抛出OutOfMemoryError
。这个错误通常与类加载及常量池的使用密切相关,特别是在大量类或常量频繁加载的情况下。
7. 总结
运行时常量池是 JVM 中用于存放类的字面量和符号引用的一个内存区域。它的存在使得常量得以高效地存储和管理,避免了重复存储和计算。在方法区的管理下,常量池的大小有限制,当达到内存上限时,会抛出 OutOfMemoryError
错误。理解和合理配置常量池的内存,可以有效避免内存溢出的风险。
字符串常量池
字符串常量池(String Pool)是 JVM 为了优化内存使用、提高性能,特别针对 String
类而设置的一种机制。它的目的是避免字符串对象的重复创建,从而减少内存消耗和提高效率。
1. 字符串常量池的工作原理
在 Java 中,字符串是不可变的(immutable)。每次对字符串的修改都将创建一个新的字符串对象。在这种情况下,字符串常量池的作用尤为重要。它通过 共享字符串实例 来避免重复创建相同内容的字符串。
以下是一个简单的例子:
String aa = "ab"; // 在字符串常量池中创建字符串对象 "ab"
String bb = "ab"; // 直接返回常量池中的 "ab" 字符串对象
System.out.println(aa == bb); // true
上述代码输出 true
,因为 aa
和 bb
引用的是字符串常量池中的同一个 "ab"
对象。JVM 会确保相同内容的字符串只会在常量池中存储一份。
2. 字符串常量池的实现
字符串常量池的底层实现通常是一个固定大小的 哈希表(HashTable),其容量由 StringTableSize
参数控制。每个字符串常量都是哈希表中的一个条目,键是字符串内容,值是指向该字符串对象的引用。这样,当需要查找或创建一个字符串时,JVM 可以直接查找常量池中是否存在该字符串对象,避免了重复创建。
在 HotSpot 虚拟机中,StringTable
结构的实现位于 src/hotspot/share/classfile/stringTable.cpp
。
3. JDK 1.7 之前的字符串常量池
在 JDK 1.7 之前,字符串常量池被存放在 永久代(PermGen) 中,而永久代是方法区的一部分。随着虚拟机对内存管理的优化,JDK 1.7 将字符串常量池从永久代移到了 Java 堆 中。
在 JDK 1.6 及之前,字符串常量池和其他类的常量一起存放在永久代中。永久代的 GC 回收效率较低,通常只能在 Full GC 时进行回收。
在 JDK 1.7 中,字符串常量池和静态变量被迁移到了 Java 堆内存中。这样可以通过常规的垃圾回收机制(GC)更有效地管理字符串对象。
4. 为什么 JDK 1.7 将字符串常量池移到堆中?
主要原因是 永久代的垃圾回收效率较低,只有在 Full GC 时才会回收其中的内容。字符串常量池通常包含大量的字符串对象,这些对象可能会频繁地被创建和销毁。如果这些字符串对象仍然存储在永久代中,回收它们的效率会受到很大影响。因此,JDK 1.7 将字符串常量池移到了 Java 堆中,使得字符串对象能够及时且高效地回收,避免了内存泄漏和性能问题。
5. 字符串常量池的内存管理
虽然字符串常量池被移到了堆中,但它的管理和常规的堆内存还是有所不同的。常量池中的字符串对象在加载时会被立即创建,并且会被 JVM 引用,不容易被垃圾回收。在 JDK 1.7 及以后版本中,字符串常量池采用了更高效的回收机制,使得内存使用更加灵活。
6. 常见的字符串常量池相关问题
内存溢出
如果字符串常量池中存放了大量的字符串对象,或者程序频繁地创建大量字符串对象,可能会导致堆内存不足,进而引发OutOfMemoryError
错误。可以通过
-XX:StringTableSize
参数来设置字符串常量池的大小。字符串常量池的容量限制
字符串常量池有一个固定的大小(可以通过 JVM 参数调整),当池内存达到上限时,如果继续尝试存放新的字符串,会引发内存溢出错误。
7. 总结
字符串常量池是 JVM 中的一个优化机制,目的是避免重复创建相同内容的字符串对象,从而节省内存并提高性能。JDK 1.7 之后,字符串常量池从永久代移到了堆中,优化了垃圾回收机制,使得字符串对象的管理更加高效。
直接内存
直接内存是指不属于 Java 堆或方法区的一块内存,它通常通过 JNI(Java Native Interface) 直接分配在 本地内存 上。这种内存的分配方式并不受 JVM 的堆内存管理,而是通过底层操作系统的内存管理机制来进行管理。
1. 直接内存的概念与实现
直接内存并不是虚拟机运行时数据区的一部分,也不属于 Java 虚拟机规范中定义的内存区域。然而,它在 Java 中被频繁使用,特别是在需要进行 高效 I/O 操作 时。直接内存通过 NIO(New I/O) 引入,在 JDK 1.4 中首次支持,并通过 DirectByteBuffer
作为引用来操作这块内存。
通过 NIO,Java 程序可以直接使用操作系统的本地内存,而不必通过传统的 Java 堆内存进行数据的复制和缓冲,从而在某些场景下显著提升性能。例如,在文件或网络传输时,直接内存可以避免不必要的数据复制,从而减少 CPU 和内存的开销。
2. 直接内存与 Java 堆的区别
分配方式:
直接内存不在 Java 堆上分配,它是通过 JNI 调用操作系统的本地内存分配机制来进行的。内存管理:
直接内存的管理不受 JVM 的垃圾回收(GC)控制。它依赖于操作系统的内存管理和分配机制,因此在使用时,开发者需要显式地释放这部分内存。性能优势:
由于直接内存避免了 Java 堆与本地堆之间的数据复制,它在高效的 I/O 操作中能够显著提升性能。特别是在需要大数据量交换的场景中,减少了内存的拷贝和中间缓存,从而减少了延迟和内存开销。
3. 直接内存的使用场景
直接内存常用于 NIO(New I/O) 中,特别是当数据量较大时,直接内存能避免多次数据复制,提供更高效的 I/O 操作。例如:
- 文件操作:通过
FileChannel
可以直接操作文件,避免将文件内容复制到 Java 堆中。 - 网络操作:网络数据包的传输可以通过直接内存来避免不必要的数据复制。
ByteBuffer buffer = ByteBuffer.allocateDirect(1024); // 创建直接内存的缓冲区
4. 直接内存与堆外内存的关系
在一些文章中,直接内存和堆外内存经常被等同使用,但它们并不完全相同。
- 直接内存:是通过 JNI 分配的内存,通常用于高效的 I/O 操作。
- 堆外内存:一般是指分配在 Java 堆之外的任何内存,不一定非得是通过 JNI 分配,也可以是操作系统的其他内存区域。
尽管两者概念有些重叠,直接内存更多的是指通过特定方式(如 NIO)直接访问操作系统内存的机制。
5. 直接内存的限制
内存大小限制:直接内存不受 Java 堆内存大小的限制,但它的分配会受到操作系统的总内存大小以及处理器寻址空间的限制。在 32 位操作系统上,直接内存的分配可能会受到限制,而在 64 位操作系统上通常能分配更多的内存。
垃圾回收问题:由于直接内存不受 JVM 垃圾回收的管理,内存泄漏的风险更大。开发者需要手动释放不再使用的直接内存。
OutOfMemoryError
错误:如果系统没有足够的本地内存来分配直接内存时,可能会抛出OutOfMemoryError
。
6. 直接内存与 Java 堆内存的比较
特性 | 直接内存 | Java 堆内存 |
---|---|---|
分配方式 | 通过 JNI 分配,操作系统管理 | JVM 内部分配,垃圾回收管理 |
内存管理 | 操作系统管理 | JVM 垃圾回收自动管理 |
使用场景 | 高效 I/O 操作 | 一般 Java 对象存储和处理 |
性能 | 减少了内存复制,性能更优 | 较高的内存管理开销 |
释放方式 | 手动释放 | 自动由 GC 释放 |
错误 | 可能引发 OutOfMemoryError | 可能引发 OutOfMemoryError |
7. 总结
直接内存通过 JNI 实现,与 Java 堆内存不同,它直接在本地内存上分配,通常用于高效的 I/O 操作。尽管它不受 JVM 的垃圾回收管理,但通过减少内存复制,能够显著提升性能,特别是在处理大量数据时。然而,由于直接内存的使用与操作系统的内存管理有关,因此它也可能导致 OutOfMemoryError
错误,并需要开发者手动管理。
三、HotSpot 虚拟机对象探秘
通过上面的介绍我们大概知道了虚拟机的内存情况,下面我们来详细的了解一下 HotSpot 虚拟机在 Java 堆中对象分配、布局和访问的全过程。
对象的创建
Java 对象的创建过程涉及多个步骤,每个步骤都有其具体的任务,确保对象能够在堆内存中正确地分配、初始化,并最终能够被 Java 程序使用。下面是详细的对象创建过程:
Step 1: 类加载检查
- 目的:虚拟机首先要检查
new
指令中指定的类是否已经被加载。 - 过程:
- 检查类的符号引用是否存在于常量池中。
- 如果该类没有被加载、解析或初始化,虚拟机会触发类加载过程(包括类加载、解析和初始化)。
- 如果该类已经被加载过,则跳过加载过程,直接进入下一步。
Step 2: 分配内存
目的:为对象分配内存空间。
过程:
- 在类加载完成后,虚拟机会根据类的结构确定对象所需的内存大小。
- 然后,虚拟机会从 Java 堆中划分出一块合适的内存来存储对象数据。
内存分配的两种方式:
- 指针碰撞:
- 适用场合:堆内存规整(没有内存碎片)。
- 原理:堆内存中所有已使用的内存都被聚集到一侧,未使用的内存聚集到另一侧。一个指针用来标识未使用内存的起始位置,当新对象分配内存时,指针向空闲内存区域移动,直接为对象分配空间。
- 适用的 GC 收集器:Serial、ParNew。
- 空闲列表:
- 适用场合:堆内存不规整。
- 原理:虚拟机会维护一个记录可用内存块的列表,分配时会查找合适大小的空闲块并分配给对象,分配后更新列表。
- 适用的 GC 收集器:CMS(并发标记清除)。
内存分配的并发问题:
- 由于创建对象是一个高频操作,必须确保线程安全。
- CAS + 失败重试:虚拟机通过 CAS(比较并交换)机制来确保内存分配操作的原子性,避免多个线程同时分配内存。
- TLAB(Thread-Local Allocation Buffer):每个线程在 Eden 区分配一块内存空间,避免多个线程共享内存区域,从而减少锁竞争和同步开销。当 TLAB 空间不足时,再通过 CAS 进行分配。
Step 3: 初始化零值
- 目的:初始化对象的字段为默认零值(例如
0
、null
)。 - 过程:
- 在对象的内存分配完成后,虚拟机会将对象内存空间初始化为零值,这些零值对应于对象字段的数据类型。
- 这一步确保了对象字段可以在没有显式赋值的情况下,具有合理的初始值。
Step 4: 设置对象头
- 目的:设置对象的元数据和运行时信息。
- 过程:
- 对象的 对象头 包含重要信息,如:对象所属的类、对象的哈希码、对象的 GC 信息(代别信息)等。
- 此外,对象头还会根据当前虚拟机的状态(例如是否启用了偏向锁)进行设置。对象头包含两部分:
- Mark Word:包含对象的哈希码、GC 状态、锁状态等信息。
- Class Pointer:指向对象所属类的元数据(即类的
Class
对象)。
Step 5: 执行 <init>
方法
- 目的:完成对象的初始化。
- 过程:
- 虚拟机执行构造方法
<init>
,对对象进行初始化。 - 这一步是从 Java 程序的视角看待对象创建的关键步骤,完成了所有字段的赋值操作。
- 注意:虽然前面的步骤已经完成了内存分配和零值初始化,但对象的字段并没有按照程序员的意愿进行初始化,直到
<init>
方法执行后,对象才算完全准备好。
- 虚拟机执行构造方法
总结
Java 对象的创建是一个涉及多个步骤的过程,其中包括类加载、内存分配、零值初始化、对象头设置以及构造方法执行。每个步骤都有特定的目的和作用,共同保证了对象能够在堆内存中正确创建并可供 Java 程序使用。理解这些步骤有助于深入理解 Java 内存管理和虚拟机的运行机制。
对象的内存布局
在 HotSpot 虚拟机中,每个对象在内存中的布局分为 对象头(Header)、实例数据(Instance Data) 和 对齐填充(Padding) 三个部分。下面详细介绍每个部分的内容和作用。
1. 对象头(Header)
对象头是每个对象在内存中的第一个部分,通常包含两部分信息:
- 标记字段(Mark Word):
- 用于存储对象的一些运行时信息,如:
- 哈希码(HashCode):对象的唯一标识,用于计算对象的哈希值。
- GC 分代年龄:对象在垃圾回收中的代数(用于判断对象是否能被垃圾回收)。
- 锁状态标志:包括锁的状态信息,例如对象是否被加锁、锁的类型(偏向锁、轻量级锁或重量级锁)。
- 线程持有的锁:标识持有锁的线程信息(在对象加锁时使用)。
- 偏向线程 ID 和偏向时间戳:用于实现偏向锁时,存储当前拥有偏向锁的线程的 ID 和时间戳。
- 用于存储对象的一些运行时信息,如:
- 类型指针(Klass Pointer):
- 对象指向它的类元数据的指针,虚拟机通过这个指针来确定该对象是哪个类的实例。
- 这个指针实际上指向了对象的 类元数据,即
Class
对象,里面存储了对象所属类的结构信息(如字段、方法等)。
2. 实例数据(Instance Data)
实例数据是对象真正存储的有效数据,即在 Java 程序中定义的各种字段内容(如实例变量)。这部分内存区域存储着对象的属性或成员变量的值,是对象的实际数据部分。
- 字段内容:包含了对象的所有实例变量的值。根据对象所属类的不同,实例数据的结构和大小也会有所不同。
- 类型:实例数据区域的内容根据字段的类型而不同,例如
int
类型占 4 字节,long
类型占 8 字节等。
3. 对齐填充(Padding)
对齐填充并不是每个对象都会有,它的作用仅仅是为了满足虚拟机内存管理系统的对齐要求。
- 目的:HotSpot 虚拟机要求每个对象的起始地址必须是 8 字节的整数倍,也就是说对象的总大小必须是 8 字节的倍数。
- 作用:当对象的实例数据部分没有按 8 字节对齐时,虚拟机会使用对齐填充来确保对象大小是 8 字节的倍数。对齐填充不会存储任何实际数据,仅仅是占位符。
总结
- 对象头:包含标记字段和类型指针,标记字段存储运行时数据和锁信息,类型指针指向类元数据。
- 实例数据:存储对象的字段值,是对象的实际数据。
- 对齐填充:为了满足内存对齐要求,确保对象大小是 8 字节的整数倍,可能会有填充。
这种内存布局的设计,旨在提供高效的内存管理和快速的访问性能,特别是在垃圾回收、锁管理和对象访问方面。
对象的访问定位
在 HotSpot 虚拟机中,Java 程序通过引用(reference)来操作堆上的对象,而这些引用如何定位到对象的实际内存地址,则取决于虚拟机的实现方式。主要有两种方法:句柄(Handle)和 直接指针(Direct Pointer)。每种方法的实现和优缺点都有差异。
1. 句柄访问方式
句柄是对对象的一种间接引用方式。在这种方式下,虚拟机维护一个句柄池,所有的对象引用指向池中的句柄。句柄内部存储了对象的实例数据地址和类型数据地址。因此,对象的引用不是直接指向对象,而是指向一个包含对象地址信息的中介结构——句柄。
工作原理:
- 通过句柄池来间接引用对象。
- 句柄内部分为两个部分:
- 实例数据指针:指向对象的实际数据(字段)。
- 类型数据指针:指向对象所属类的元数据(类的结构、方法信息等)。
优点:
- 稳定性:在垃圾回收过程中,如果对象被移动,引用本身无需改变。只需修改句柄中的实例数据指针即可,避免了频繁修改引用。
- 支持对象的移动:由于引用的是句柄,而句柄的内存地址不受影响,因此可以方便地进行对象的移动。
缺点:
- 引入了额外的间接访问开销。每次访问对象时,程序必须先访问句柄池,再通过句柄定位到对象的内存。
2. 直接指针访问方式
直接指针方式,顾名思义,引用直接存储对象的内存地址。这是最简单且高效的访问方式,因为引用直接指向对象在堆内存中的位置。
工作原理:
- 对象的引用直接指向该对象的内存地址。
- 访问时,程序可以直接通过引用定位到对象,无需额外的查找操作。
优点:
- 快速访问:由于没有中介层(即句柄),访问对象的速度较快。程序只需直接操作内存地址,减少了查找过程中的额外开销。
缺点:
- 不支持对象移动:如果对象在垃圾回收过程中发生了移动,引用存储的地址就不再有效。此时,需要更新所有指向该对象的引用,增加了垃圾回收时的复杂性和开销。
3. HotSpot 虚拟机的实现
在 HotSpot 虚拟机中,主要采用 直接指针 的方式来访问对象。这意味着对象的引用直接指向对象的内存位置,而无需经过中介句柄。这样做的主要优势是 提高了访问效率,因为避免了每次访问时需要通过句柄来间接定位对象。
然而,直接指针方式的缺点是,它不支持对象在堆中的移动。如果对象移动,引用就会失效,需要修改所有引用。因此,HotSpot 在实现时必须使用 GC 更新引用,确保在垃圾回收过程中所有引用都能正确指向新的对象地址。
小结
- 句柄方式适用于对象的内存可以移动的情况,因为它通过句柄间接引用对象,减少了直接指针方式带来的问题,但增加了访问开销。
- 直接指针方式则是在性能要求高的场合更为常见,因为它减少了间接访问的时间开销,但不支持对象内存的移动。
HotSpot 虚拟机选择直接指针的方式以优化性能,尤其是在访问频繁的情况下,这种方式能够显著提高效率。然而,这也意味着 HotSpot 在垃圾回收过程中必须小心管理对象引用,防止因对象移动导致引用失效。