当前位置:硬件测评 > 【第107期】说说面试必问的Java内存区域(运行时数据区域)和内存模型(JMM)

【第107期】说说面试必问的Java内存区域(运行时数据区域)和内存模型(JMM)

  • 发布:2023-10-04 10:33

点击上方“java面试题精选”,关注公众号 面试时画图,查漏补缺 >>番外:往期面试题,10个为单位放在这个公众号菜单栏->面试题,有需要的欢迎阅读 阶段总结合集:++小旗实现、百道面试题总结++ Java内存区域和内存模型是不同的东西。内存区域是指JVM运行时数据的存储区域,强调内存空间的划分。 内存模型(Java Memory Model,简称JMM)定义了线程和主存之间的抽象关系。也就是说,JMM 定义了 JVM 如何在计算机内存 (RAM) 中工作。如果我们想要深入了解Java并发编程,就必须首先了解Java内存模型。 Java运行时数据区 众所周知,Java虚拟机有一个自动内存管理机制。如果出现内存泄漏和溢出的问题,在排除故障时必须了解虚拟机如何使用内存。 下图是JDK8之后的JVM内存布局。 JDK8之前的内存区域图如下: 在HotSpot JVM中,永久代用于存储类和方法以及常量池的元数据,例如Class和Method。每当一个类第一次加载时,它的元数据就会被放置在永久代中。 永久代是有大小限制的,所以如果加载的类过多,很可能会导致永久代内存溢出,即万恶的java.lang.OutOfMemoryError: PermGen。为此,我们必须对虚拟机进行调优。 那么,为什么 PermGen 在 Java 8 中从 HotSpot JVM 中移出呢?我总结了两个主要原因: 由于PermGen内存经常会溢出,导致恼人的java.lang.OutOfMemoryError: PermGen,JVM开发者希望这块内存能够更灵活地管理,不再出现这样的OOM。删除 PermGen 可以促进 HotSpot JVM 和 JRockit VM 集成,因为 JRockit 没有永久代。 基于以上原因,最终移除了PermGen,将方法区移至Metaspace,将字符串常量移至Java Heap。 引用自https://www.sychzs.cn/posts/Java/jvm-metaspace/ 程序计数器程序计数器寄存器是一个很小的内存空间,可以看作是当前线程执行的字节码的行号指示器。 由于Java虚拟机的多线程是通过轮流切换线程和分配处理器执行时间来实现的,因此在任何给定时刻,一个处理器核只会执行一个线程中的指令。 因此,为了线程切换后能够回到正确的执行位置,每个线程都需要有一个独立的程序计数器。每个线程之间的计数器互不影响,独立存储。我们将这种类型的内存区域称为“线程私有”内存。 如果线程正在执行Java方法,则该计数器记录正在执行的虚拟机字节码指令的地址;如果线程正在执行 Native 方法,则计数器值为空(未定义)。该内存区域是唯一一个没有 Java 虚拟机规范中指定的任何 OutOfMemoryError 条件的区域。 Java虚拟机栈 与程序计数器一样,Java 虚拟机堆栈是线程私有的,并且与线程具有相同的生命周期。 虚拟机栈描述了Java方法执行的内存模型:每个方法都会创建一个栈帧(Stack Frame,是方法运行时的基本数据结构)来存储局部变量表、操作数栈、动态链接、方法导出和其他信息。每个方法从调用到执行完成的过程对应于将一个栈帧压入虚拟机栈到弹出的过程。 在活动线程中,只有堆栈顶部的帧才是有效的,称为当前堆栈帧。正在执行的方法称为当前方法,栈帧是方法执行的基本结构。当执行引擎运行时,所有指令只能对当前栈帧进行操作。 更多面试题欢迎关注公众号java面试题精选 1. 局部变量表 局部变量表是存储方法参数和局部变量的区域。局部变量没有准备阶段,必须显式初始化。如果是非静态方法,则该方法所属对象的实例引用存储在index[0]位置。引用变量占用4个字节,后面是参数和局部变量。字节码指令中的STORE指令是将操作栈中完成的局部变换写回局部变量表的存储空间。虚拟机栈规定了两种异常情况:如果线程请求的栈深度大于虚拟机允许的深度,则会抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(目前大多数Java虚拟机都可以动态扩展),如果扩展时无法申请到内存不足,就会抛出OutOfMemoryError异常。 2. 操作栈 操作栈是一个桶状结构栈,最初是空的。在方法执行期间,会有各种指令向堆栈写入和提取信息。 JVM的执行引擎是基于栈的执行引擎,这里的栈指的是操作栈。字节码指令集的定义是基于栈类型的,栈的深度在方法元信息的栈属性中。 i++ 和 ++i 的区别: i++:从局部变量表中取出i压入操作栈,然后将局部变量表中的i加1,取出操作栈顶值使用,最后使用栈顶值更新局部变量表,使线程从操作栈读取的是自增前的值。 ++i:首先将局部变量表中的i加1,然后取出来压入操作栈,然后取出操作栈顶的值使用。最后使用栈顶值更新局部变量表,线程从操作栈中读取。是增量后的值。 之所以说i++不是原子操作,即使用volatile修饰也不是线程安全的,是因为i可能会从局部变量表(内存)中取出,压入操作栈(寄存器)。操作栈递增并使用栈顶。值更新局部变量表(寄存器更新写入内存),分为3步。 volatile保证了可见性,保证每次从局部变量表中读取最新的值,但这3步可能会被另一个线程使用。三个步骤被中断,导致数据互相覆盖的问题,导致i的值比预期的要小。 3.动态链接 每个栈帧都包含常量池中当前方法的引用,目的是支持方法调用过程的动态连接。 4.方法返回地址 方法执行时有两种退出情况: 正常退出,即正常执行到任意方法的返回字节码指令,如RETURN、IRETURN、ARETURN等; 异常退出。 无论退出情况如何,都会返回到当前调用该方法的位置。方法退出的过程相当于弹出当前栈帧。退出的方式可能有以下三种: 返回值被推入上层调用堆栈帧。 异常信息被抛出到可以处理的堆栈帧中。 PC计数器指向方法调用后的下一条指令。 本地方法栈Native Method Stack和虚拟机栈所发挥的功能非常相似。它们之间唯一的区别是,虚拟机栈是为虚拟机执行Java方法(即字节码)服务的,而本地方法栈则是为虚拟机使用的Native方法服务的。 Sun HotSpot虚拟机直接将本地方法栈和虚拟机栈合二为一。和虚拟机栈一样,本地方法栈区也会抛出StackOverflowError和OutOfMemoryError异常。 当线程开始调用本机方法时,它就进入了一个不再受 JVM 束缚的世界。原生方法可以通过JNI(Java Native Interface)访问虚拟机运行时的数据区域,甚至可以调用寄存器,具有与JVM相同的能力和权限。当大量本地方法出现时,JVM对系统的控制力必然会减弱,因为它的错误信息相对来说是黑匣子。在内存不足的情况下,native方法栈仍然会抛出nativeheapOutOfMemory。 JNI类最著名的本地方法应该是System.currentTimeMillis()。 JNI使Java能够深入利用操作系统的特性并重用非Java代码。但是,在项目过程中,如果大量使用其他语言来实现JNI,就会失去跨平台的特性。 Java堆 对于大多数应用程序来说,Java堆是Java虚拟机管理的最大一块内存。 Java堆是所有线程共享的内存区域,在虚拟机启动时创建。该内存区域的唯一目的是存储对象实例,几乎所有对象实例都在这里分配内存。 堆是垃圾收集器管理的主要区域,因此通常被称为“GC堆”(Garbage Collected Heap)。从内存回收的角度来看,由于当前收集器基本采用分代收集算法,因此Java堆还可以细分为:新生代和老年代;更详细的包括Eden空间、From Survivor空间和To Survivor空间。从内存分配的角度来看,线程共享的Java堆可能会被划分为多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。Java堆可以位于物理上不连续的内存空间中,只要逻辑上连续即可。目前主流的虚拟机都是以可扩展的方式实现的(由-Xmx和-Xms控制)。如果堆中没有内存来完成实例分配,并且堆无法再扩展,则会抛出 OutOfMemoryError 异常。 方法区 方法区和Java堆一样,是每个线程共享的内存区域。它用于存储虚拟机已加载的类信息、常量、静态变量以及即时编译器编译的代码等数据。虽然Java虚拟机规范将方法区描述为堆的逻辑部分,但它有一个别名,称为Non-Heap(非堆),应该与Java堆区分开来。 Java虚拟机规范对方法区的限制非常宽松。除了不需要像Java堆那样的连续内存并可以选择固定大小或可扩展性之外,您还可以选择不实现垃圾收集。垃圾收集行为在这方面比较少见,其内存回收目标主要是常量池回收和类型卸载。当方法区无法满足内存分配要求时,会抛出OutOfMemoryError异常。 在JDK8之前,Hotspot中方法区的实现是永久代(Perm)。 JDK8开始使用元空间(Metaspace)。过去,永久代的所有内容的字符串常量都被移到了堆内存中,其他内容则被移到了元空间中。元空间直接在本地内存分配中。 为什么使用元空间而不是永久代实现? 字符串存储在永久代中,容易出现性能问题和内存溢出。 很难确定类和方法信息的大小,因此很难指定永久代的大小。如果太小,容易导致永久代溢出,如果太大,则容易导致老年代溢出。 永久代会给GC带来不必要的复杂性,回收效率低。 将 HotSpot 和 JRockit 合而为一。 运行时常量池 运行时常量池是方法区的一部分。 Class文件除了有类的版本、字段、方法、接口等描述信息外,还有一个常量池(Constant Pool Table),用于存储编译时生成的各种文字和符号引用。这部分内容在类加载后会存储在方法区的运行时常量池中。 一般来说,除了保存Class文件中描述的符号引用外,翻译后的直接引用也会保存在运行时常量池中。运行时常量池相对于Class文件常量池的另一个重要特点是它是动态的。 Java语言并不要求常量只能在编译期间生成。即Class文件中未预设的常量池内容可以进入方法区。在运行时常量池中,新的常量也可能在运行时被放入池中。开发人员经常通过 String 类的 intern() 方法来使用此功能。 由于运行时常量池是方法区的一部分,自然受到方法区内存的限制。当常量池无法再申请内存时,就会抛出OutOfMemoryError异常。 直接记忆 直接内存不是虚拟机运行时数据区域的一部分,也不是Java虚拟机规范中定义的内存区域。 JDK 1.4中新添加了NIO,引入了基于Channel和Buffer的I/O方法。它可以使用Native函数库直接分配堆外内存,然后通过一个DirectByteBuffer对象来操作存储在Java堆中,作为对这块内存的引用。这在某些场景下可以显着提高性能,因为它避免了在 Java 堆和 Native 堆之间来回复制数据。 显然,本地直接内存的分配不会受到Java堆大小的限制。但是,既然是内存,肯定会受到总本地内存(包括RAM和SWAP区域或分页文件)大小和处理器寻址空间的限制。 。服务器管理员在配置虚拟机参数时,会根据实际内存来设置-Xmx等参数信息,但往往会忽略直接内存,使得各个内存区域的总和大于物理内存限制(包括物理和操作系统级别的限制) ),导致动态扩展时出现OutOfMemoryError异常。 更多面试题欢迎关注Java面试题精选公众号 Java内存模型 Java内存模型是共享内存的并发模型。线程之间的隐式通信主要是通过读写共享变量(堆内存中的实例字段、静态字段和数组元素)来完成。 Java 内存模型 (JMM) 控制 Java 线程之间的通信,确定一个线程对共享变量的写入何时对另一个线程可见。 计算机缓存和缓存一致性 计算机在高速CPU和相对低速的存储设备之间使用高速缓存作为内存和处理器之间的缓冲区。将操作所需的数据复制到缓存中,以便操作可以快速运行。当操作完成后,会从缓存同步回内存。在多处理器系统(或单处理器多核系统)中,每个处理器核心都有自己的高速缓存,并且它们共享相同的主存。 当多个处理器的计算任务涉及同一主存区域时,各自的缓存数据可能会不一致。 为此,每个处理器在访问缓存时需要遵循一些协议,在读写时按照协议进行操作,以保持缓存的一致性。延伸:一百道面试题总结 JVM主存和工作内存 Java内存模型的主要目标是定义程序中每个变量的访问规则,即虚拟机中将变量(线程共享变量)存储到内存中以及从内存中检索变量的低级细节。 Java内存模型规定所有变量都存储在主存中,每个线程都有自己的工作内存。线程对变量的所有操作都必须在工作内存中进行,主内存中的变量不能直接读写。 。 这里的工作内存是JMM的一个抽象概念,也称为本地内存,它存储了线程可以读/写的共享变量的副本。 就像每个处理器核心都有自己的私有缓存一样,JMM 中的每个线程都有自己的私有本地内存。 不同的线程不能直接访问彼此工作内存中的变量。线程之间的通信一般有两种方式进行,一种是通过消息传递,另一种是共享内存。 Java 线程之间的通信使用共享内存。线程、主存、工作内存之间的交互关系如下图所示: 这里所说的主存、工作内存和Java内存区域中的Java堆、栈、方法区等并不是同一级别的内存划分。两者基本没有关系。如果两者必须勉强对应的话,从变量、主存、工作内存的定义来看,主存主要对应Java堆中的对象实例数据部分,而工作内存则对应虚拟机中的一些区域堆。 重新排序和先发生规则 为了提高执行程序时的性能,编译器和处理器经常对指令重新排序。重新排序分为三种类型: 编译器优化的重新排序。编译器可以重新排列语句的执行顺序,而不改变单线程程序的语义。 指令级并行重新排序。现代处理器使用指令级并行性 (ILP) 以重叠方式执行多条指令。如果不存在数据依赖性,则处理器可以更改语句对应于机器指令的顺序。 内存系统重新排序。由于处理器使用高速缓存和读/写缓冲区,这可能会使加载和存储操作看起来是无序执行的。 从java源代码到最终实际执行的指令序列,会经历以下三个重新排序:JMM 是一种语言级内存模型,通过禁止特定类型的编译器重新排序和处理器重新排序,确保跨不同编译器和不同处理器平台的程序员获得一致的内存可见性保证。 Java编译器通过在生成的指令序列中的适当位置插入内存屏障指令来禁止处理器重新排序(在重新排序期间,后续指令无法重新排序到内存屏障之前的位置)。 发生在之前 从JDK5开始,Java内存模型提出了happens-before的概念,通过它来解释操作之间的内存可见性。 如果一个操作的结果需要对另一操作可见,则两个操作之间必须存在happens-before关系。这里所说的两种操作可以在一个线程内,也可以在不同线程之间。 这里的“可见性”是指当一个线程修改这个变量的值时,新的值可以立即被其他线程知道。 如果 A 发生在 B 之前,那么 Java 内存模型将向程序员保证 A 操作的结果对 B 可见,并且 A 将在 B 之前执行。 重要的happens-before规则如下: 程序顺序规则:线程中的每个操作发生在该线程中的任何后续操作之前。 监视器锁规则:解锁监视器锁发生在后续锁定监视器锁之前。 易失性变量规则:对易失性字段的写入发生在对该易失性字段的任何后续读取之前。 传递性:如果 A 发生在 B 之前,B 发生在 C 之前,则 A 发生在 C 之前。 下图展示了happens-before和JMM的关系 易失性关键字 Volatile可以说是JVM提供的最轻量级的同步机制。当一个变量被定义为 volatile 时,它​​将具有两个特征: 保证该变量对所有线程都可见。普通变量无法做到这一点。普通变量的值需要通过主存在线程之间传输。注意,虽然 volatile 保证了可见性,但是 Java 中的操作不是原子操作,导致并发下对 volatile 变量的操作不安全。 Synchronized关键字是线程安全的,因为“一个变量只允许被一个线程同时锁定”这一规则。 禁用指令重新排序优化。普通变量只保证方法执行过程中所有依赖赋值结果的地方都能得到正确的结果,但并不能保证变量赋值操作的顺序与程序代码中执行的顺序一致。 最后,建议和感谢: 深入理解Java虚拟机(第二版)高效编码:Java开发手册Java内存模型原理,你真的看懂了吗? )深入理解Java内存模型 来源:www.sychzs.cn/czwbig/p/11127124.html 而不是在网上搜索问题?还不赶快关注我们吧~

相关文章