JVM内存结构33连问

JVM内存结构分为5大区域,程序计数器、虚拟机栈、本地方法栈、堆、方法区。

程序计数器是什么?

特点:

  • 线程私有
  • CPU会为每个线程分配时间片,当当前线程的时间片使用完以后,CPU就会去执行另一个线程中的代码
  • 程序计数器是每个线程所私有的,当另一个线程的时间片用完,又返回来执行当前线程的代码时,通过程序计数器可以知道应该执行哪一句指令
  • 不存在内存溢出

程序计数器的作用?

线程私有的,作为当前线程的行号指示器,用于记录当前虚拟机正在执行的线程指令地址。

  1. 当前线程所执行的字节码的行号指示器,通过它实现代码的流程控制,如:顺序执行、选择、循环、异常处理。

  2. 在多线程的情况下,程序计数器用于记录当前线程执行的位置,当线程被切换回来的时候能够知道它上次执行的位置。

程序计数器会出现OOM吗?

程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,它的生命周期随着线程的创建而创建,随着线程的结束而死亡

虚拟机栈是什么?

定义:每个线程在创建的时候都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次 Java 方法调用,是线程私有的,生命周期和线程一致。

作用:主管 Java 程序的运行,它保存方法的局部变量、部分结果,并参与方法的调用和返回。

特点:

  • 每个线程运行需要的内存空间,称为虚拟机栈。是线程私有的,每个线程都有各自的 Java 虚拟机栈,而且随着线程的创建而创建,随着线程的死亡而死亡

  • Java 虚拟机栈是由一个个栈帧组成,对应着每次调用方法时所占用的内存。每一次函数调用都会有一个对应的栈帧被压入虚拟机栈,每一个函数调用结束后,都会有一个栈帧被弹出。两种返回函数的方式,不管用哪种方式,都会导致栈帧被弹出

    • 正常的函数返回,使用 return 指令

    • 抛出异常

  • 每个线程只能有一个活动栈帧,栈顶存放当前当前正在执行的方法

虚拟机栈里有什么?

每个栈帧中都存储着:

  • 局部变量表(Local Variables)

  • 操作数栈(Operand Stack)(或称为表达式栈)

  • 动态链接(Dynamic Linking):指向运行时常量池的方法引用

  • 方法返回地址(Return Address):方法正常退出或异常退出的地址

虚拟机栈会发生stackOverflowError吗?

stackOverflowError发生原因

  • 虚拟机栈中,栈帧过多(无限递归)

  • 每个栈帧所占用内存过大

虚拟机栈会发生OutOfMemoryError吗?

OutOfMemoryError发生原因:

  • 在单线程程序中,无法出现OOM异常;但是通过循环创建线程(线程体调用方法),可以产生OOM异常。此时OOM异常产生的原因与栈空间是否足够大无关。

  • 线程动态扩展,没有足够的内存供申请时会产生OOM

垃圾回收是否涉及栈内存?

不需要。因为虚拟机栈中是由一个个栈帧组成的,在方法执行完毕后,对应的栈帧就会被弹出栈。所以无需通过垃圾回收机制去回收内存。

栈内存的分配越大越好吗?

不是。因为物理内存是一定的,栈内存越大,可以支持更多的递归调用,但是可执行的线程数就会越少。

方法内的局部变量是否是线程安全的?

如果方法内局部变量没有逃离方法的作用范围,则是线程安全的;如果如果局部变量引用了对象,并逃离了方法的作用范围,则需要考虑线程安全问题

本地方法栈是什么?

也是线程私有的

虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。Native 方法一般是用其它语言(C、C++等)编写的。

本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

为什么需要本地方法?

一些带有native关键字的方法就是需要JAVA去调用C或者C++方法,因为JAVA有时候没法直接和操作系统底层交互,所以需要用到本地方法

Native Method Stack:它的具体做法是Native Method Stack中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies

Native Interface本地接口:本地接口的作用是融合不同的编程语言为Java所用,它的初衷是融合C/C程序, Java在诞生的时候是C/C横行的时候,想要立足,必须有调用C、C++的程序,于是就在内存中专门开辟了块区域处理标记为native的代码,它的具体做法是在Native Method Stack 中登记native方法,在( Execution Engine )执行引擎执行的时候加载Native Libraies。  目前该方法使用的越来越少了,除非是与硬件有关的应用,比如通过Java程序驱动打印机或者Java系统管理生产设备,在企业级应用中已经比较少见。因为现在的异构领域间通信很发达,比如可以使用Socket通信,也可以使用Web Service等等

在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一

堆是什么?

通过new关键字创建的对象都会被放在堆内存

  • 所有线程共享,堆内存中的对象都需要考虑线程安全问题

  • 有垃圾回收机制

  • 堆中的区域:新生代( Eden 空间、 From Survivor 、 To Survivor 空间)和老年代。

说一下堆栈的区别?

  1. 堆的物理地址分配是不连续的,性能较慢;栈的物理地址分配是连续的,性能相对较快。
  2. 堆存放的是对象的实例和数组;栈存放的是局部变量,操作数栈,返回结果等。
  3. 堆是线程共享的;栈是线程私有的。

如何设置堆内存大小

Java 堆用于存储 Java 对象实例,那么堆的大小在 JVM 启动的时候就确定了,我们可以通过 -Xmx 和 -Xms 来设定

  • -Xms 用来表示堆的起始内存,等价于 -XX:InitialHeapSize

  • -Xmx 用来表示堆的最大内存,等价于 -XX:MaxHeapSize

如果堆的内存大小超过 -Xmx 设定的最大内存, 就会抛出 OutOfMemoryError 异常。

为什么通常会将 -Xmx 和 -Xms 两个参数配置为相同的值?

目的是为了能够在垃圾回收机制清理完堆区后不再需要重新分隔计算堆的大小,从而提高性能。

如果 -Xms-Xmx 设置为不同的值,JVM 在运行时可能会根据内存使用情况不断调整堆的大小。这种动态调整需要进行内存分配和垃圾收集,可能会增加系统的开销和延迟。而将这两个参数设置为相同的值,JVM 在启动时就分配好固定量的堆内存,从而避免了内存重新分配的开销。

了解TLAB吗?

TLAB是虚拟机在堆内存的eden划分出来的一块专用空间,是线程专属的。在虚拟机的TLAB功能启动的情况下,在线程初始化时,虚拟机会为每个线程分配一块TLAB空间(包含在 Eden 空间内),只给当前线程使用,这样每个线程都单独拥有一个空间,如果需要分配内存,就在自己的空间上分配,这样就不存在竞争的情况,可以大大提升分配效率。

多线程同时分配内存时,使用 TLAB 可以避免一系列的非线程安全问题,同时还能提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略

为什么要有TLAB?

  • 堆区是线程共享的,任何线程都可以访问到堆区中的共享数据

  • 由于对象实例的创建在 JVM 中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的

  • 为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度

当然了,不是所有的对象实例都能够在 TLAB 中成功分配内存,但 JVM 确实是将 TLAB 作为内存分配的首选。

在程序中,可以通过 -XX:UseTLAB 设置是否开启 TLAB 空间。

默认情况下,TLAB 空间的内存非常小,仅占有整个 Eden 空间的 1%,可以通过 -XX:TLABWasteTargetPercent 设置 TLAB 空间所占用 Eden 空间的百分比大小。

一旦对象在 TLAB 空间分配内存失败时,JVM 就会尝试着通过使用加锁机制确保数据操作的原子性,从而直接在 Eden 空间中分配内存

对象一定分配在堆中吗?

在Java中,传统上我们认为对象是在堆上分配内存的。但是,随着JVM优化技术的发展,尤其是在引入即时编译器(JIT)和逃逸分析(Escape Analysis)技术后,并非所有对象都一定在堆上分配内存。这有以下一些细节:

  • 逃逸分析(Escape Analysis): 逃逸分析是一种优化技术,用于确定对象的作用范围。如果JVM通过逃逸分析确定一个对象不会被方法之外的代码访问(即对象不会逃逸出方法),那么JVM可能会选择在栈上分配该对象。
  • 栈上分配(Stack Allocation): 如果对象可以被确定为不会逃逸出其方法,则JVM可以在栈上为该对象分配内存。这减少了垃圾回收的压力,因为栈上的内存在方法执行结束后自动释放。
  • 标量替换(Scalar Replacement): 如果对象的所有属性都可以独立处理,JVM可能会对对象进行标量替换,将对象分解为其基本类型的成员变量进行优化。这种情况下,原始的对象概念被消除,更谈不上在堆或栈上分配。
  • 寄存器分配(Registers Allocation): 在某些情况下,JIT编译器甚至可能将某些对象的内容存放在CPU寄存器中,以提高访问速度。

栈上分配的条件?

  • 作用域不会逃逸出方法的对象

  • 小对象(一般几十个byte);大对象无法在栈上分配

  • 标量替换:若逃逸分析证明一个对象不会逃逸出方法,不会被外部访问,并且这个对象是可以被分解的,那程序在真正执行的时候可能不创建这个对象,而是直接创建这个对象分解后的标量来代替。这样就无需在对对象分配空间了,只在栈上为分解出的变量分配内存即可。

什么是逃逸分析?

逃逸分析(Escape Analysis),是一种可以有效减少Java 程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围,从而决定是否要将这个对象分配到堆上。

  • 一个对象在方法中被定义后,对象如果只在方法内部使用,则认为没有发生逃逸;(没有发生逃逸的对象,会在栈上分配)

  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生了逃逸。

如何快速的判断是否发生了逃逸分析?

看new的对象实体是否有可能在方法外被调用。注意是看new 出来的实体,而不是那个引用变量。

通俗点讲,如果一个对象的指针被多个方法或者线程引用时,那么我们就称这个对象的指针发生了逃逸。

逃逸分析的好处

  • 栈上分配,可以降低垃圾收集器运行的频率。

  • 同步消除,如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作不需要同步。

  • 标量替换,把对象分解成一个个基本类型,并且内存分配不再是分配在堆上,而是分配在栈上。这样的好处有

    • 减少内存使用,因为不用生成对象头。

    • 程序内存回收效率高,并且GC频率也会减少。

逃逸分析一定好吗?

关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

方法区是什么?

方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

对方法区进行垃圾回收的主要目标是对常量池的回收和对类的卸载

方法区(method area)只是 JVM 规范中定义的一个概念 ,用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,并没有规定如何去实现它,不同的厂商有不同的实现。而永久代(PermGen)是 Hotspot 虚拟机特有的概念, Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。

  • 永久代

方法区是 JVM 的规范,而永久代 PermGen 是方法区的一种实现方式,并且只有 HotSpot 有永久代。对于其他类型的虚拟机,如 JRockit 没有永久代。由于方法区主要存储类的相关信息,所以对于动态生成类的场景比较容易出现永久代的内存溢出。

永久区是常驻内存的,是用来存放JDK自身携带的Class对象和interface元数据。这样这些数据就不会占用空间。用于存储java运行时环境。

  1. 在JDK1.7前,字符串存放在方法区之中
  2. 在JDK1.7后字符串被放在了堆
  3. 在Java8,取消了方法区,改用了直接使用直接内存的的元空间。即元空间逻辑上属于堆,但在物理内存上,元空间的内存并不由堆空间内存分配
  • 元空间

JDK 1.8 的时候, HotSpot 的永久代被彻底移除了,使用元空间替代。元空间的本质和永久代类似,都是对JVM规范中方法区的实现。两者最大的区别在于:元空间并不在虚拟机中,而是使用直接内存。

为什么要将永久代替换为元空间呢?

永久代内存受限于 JVM 可用内存,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是相比永久代内存溢出的概率更小。

可以减少 Full GC 问题:元空间独立于堆内存,大大减少了永久代相关的 Full GC 次数,因此在运行时减少了长时间的中断。

运行时常量池

运行时常量池是方法区的一部分,在类加载之后,会将编译器生成的各种字面量和符号引号放到运行时常量池。在运行期间动态生成的常量,如 String 类的 intern()方法,也会被放入运行时常量池。

Class常量池和运行时常量池的区别

Class 常量池(Class Constant Pool) Class 常量池是Java类文件的一部分,它由编译器在编译Java源文件时生成,存储在.class文件中。它包含了类或接口的字面量(如字符串、整数常量等)以及符号引用(如类和接口的名字、字段和方法的名字及描述符)。

运行时常量池(Runtime Constant Pool) 是Class常量池在类加载到JVM后的一种表现形式。它是类加载过程的一部分,在类或接口被载入JVM时,Class常量池的信息被载入运行时常量池。 它在类加载时被创建,是方法区的一部分(在Java 8后部分实现为元空间的一部分)。

运行时常量池是动态的,可以在运行时扩展,因为它不仅包含Class常量池的映射数据,还允许在运行时添加新的常量,例如通过字符串interning。

Class常量池和运行时常量池的关系

来源与转换

  • Class常量池是从Java编译器生成的静态数据结构,是.class文件的一部分。
  • 运行时常量池是JVM执行环境的一部分,是Class常量池在类加载时被解析、验证后存储的方法区中的数据结构。

作用域与用途:

  • Class常量池是在磁盘上文件级别的数据结构,定义了类的编译时依赖和信息。
  • 运行时常量池存在于内存中,在类加载期间被JVM转化和使用,维护符号引用的解析,动态链接和跨越生命周期的优化。

使用与管理:

  • 编译器生成Class常量池,它是只读的。
  • 运行时常量池在运行期间可以被动态更新,允许JVM对类执行管理和优化。

总结来说,Class常量池和运行时常量池在Java的编译和执行阶段分别扮演着不同的角色。Class常量池是类文件的组成部分,而运行时常量池是JVM的执行环境结构,它将Class常量池的数据转换成JVM可以理解和使用的形式,并对其进行动态管理。

运行时常量池和字符串常量池的区别

  • 运行时常量池:用于存储类常量、方法和字段的引用,以及字符串字面量等。
  • 字符串常量池:专注于优化字符串使用,通过在堆中存储唯一的字符串实例来减少内存消耗。 字符串常量池可以被视为是运行时常量池的一个特殊部分,专门用于字符串字面量的存储和重用。
  • 运行时常量池位于方法区/元空间。 字符串常量池位于堆内存中。

直接内存(堆外内存)是什么?

直接内存并不是虚拟机运行时数据区的一部分,也不是虚拟机规范中定义的内存区域,也称之为堆外内存。

但是这部分内存也被频繁地使用。而且也可能导致 OutOfMemoryError 错误出现。

直接内存的读写操作比堆内存快,可以提升程序I/O操作的性能。通常在I/O通信过程中,会存在堆内内存到堆外内存的数据拷贝操作,对于需要频繁进行内存间数据拷贝且生命周期较短的暂存数据,都建议存储到直接内存。

堆外内存的特点

  • 性能优势:
    • 堆外内存可以减少垃圾回收(Garbage Collection)带来的停顿时间,因为它不参与普通的JVM垃圾回收过程。
    • 提供更好的内存管理和减少了GC造成的性能波动。
  • 使用局限:
    • 手动管理导致更大复杂性:开发者需要显式释放堆外内存,避免内存泄露。
    • 不支持GC,所以必须非常小心管理生命周期。需要手动清理垃圾,增加代码开发的复杂性;不参与jvm垃圾回收,因此可以减少gc带来的停顿时间;
  • 应用场景:
    • 大数据和分布式系统中需要处理大量数据时。
    • 需要高性能、低延迟的应用程序,如游戏服务器或金融系统。
    • 缓存系统,如Memcached、Redis等。

如何使用堆外内存

NIO的Buffer提供了DirectBuffer ,可以直接访问系统物理内存,避免堆内内存到堆外内存的数据拷贝操作,提高效率。DirectBuffer 直接分配在物理内存中,并不占用堆空间,其可申请的最大内存受操作系统限制,不受最大堆内存的限制。