跳至主要內容

①JVM JAVA内存区域

holic-x...大约 38 分钟JAVA基础

①JVM JAVA内存区域

学习核心

  • JVM内存区域

    • JVM 核心概念
    • 内存结构图示和各个部件的作用
      • 宏观划分:线程栈、堆内存,以及各自的分类和实现
      • 不同JVM实现(不同厂商或者同一厂商的不同版本的变化)
    • JMM核心:规范线程之间的交互操作
    • 扩展概念:重排序、内存屏障
  • 结合内存区域的划分,分析OOM的可能性分别对应哪个内存区域的异常状况

学习资料

深入学习专栏:(补充材料

JVM的引入

JVM:JVM是一个完整的计算机模型,其对应的内存模型被称为Java 内存模型(JMM:Java Memory Model)。JMM规定了JVM应该如何使用计算机内存(RAM),从广义上讲,JMM分为两个部分:

  • JVM 内存规范
  • JMM 与线程规范

​ JVM的学习路线:先学习基本的JVM内存结构,理解这些基本的知识点再去学习JMM和线程相关知识

为什么要学习JVM?

JAVA中的内存控制都是交给JVM处理的,一旦虚拟机运行出问题,如果不了解虚拟机管理内存的机制,排查运行问题就会特别困难

​ C++语言是充分相信人类的,将对象的内存的管理工作交给开发人员,例如要求开发自己生成的对象,在不用了之后,需要自己回收掉。开发人员拥有的极高的权利,导致的结果就是各种内存泄漏,double free等问题。

​ Java语言就不那么信任他的使用者,所以Java对于内存的管理工作采用的机制就是:“放着别动,让我来!”。所以整个内存控制的权利都交给了虚拟机,也正是由于这样,虚拟机一旦运行出问题了,如果不了解虚拟机管理使用内存的机制,那排查问题就会非常困难。所以学习虚拟机的内存管理机制还是很有必要的。

📌JVM 内存概念

JVM的内存结构,取决于其具体实现,不同厂商的JVM(或同一厂商发布的不同版本)都可能存在一定的差异

​ JVM整体的内存概念(宏观):逻辑上划分为堆内存(heap)、线程栈(thread stacks)

  • 堆内存:共享堆,只要线程可以拿到对象的引用地址,就可以访问堆中的对象
  • 线程栈:线程隔离(又被称为方法栈、调用栈call stack),包含当前正在执行的方法链/调用链上所有方法的状态信息

线程栈

​ 每启动一个线程,就会在栈空间分配对应的线程栈

​ 方法链/调用链:由多个方法调用组成的链路,可以理解为每个方法对应一个栈帧,每个栈帧对应着相应的方法调用

​ 线程栈的操作:入栈、出栈 分别对应方法的执行和退出

image-20240530113740208

堆内存

先从整体的内存概念切入,然后理解不同JVM的具体实现下的逻辑概念扩展

​ 例如针对堆内存,不同JVM的具体实现会有相应的优化,例如将逻辑上的Java堆划分为堆(Heap)和非堆(Non-Heap)两个部分。这种划分依据是编写的 Java 代码基本上只能使用 Heap 这部分空间(也是发生内存分配和回收的主要区域),所以此处的 Heap也可以理解为GC 管理的堆(GC Heap)

  • Heap(GC Heap):GC理论中涉及到一个重要的分代思想,将Heap内存分为年轻代和老年代两部分

    • 年轻代(Young generation):其划分3个内存池,新生代(Eden space)和2个存活区(Survivor space)

    • 老年代(Old generation, 也叫 Tenured)

  • Non-Heap :本质上还是Heap,只是不归GC管理,里面划分了3个内存池

    • Code Cache(代码缓存区):存放 JIT 编译器编译后的本地机器代码
    • Method Area(方法区):逻辑概念,JVM有不同的实现(JDK6&7:永久代;JDK8:元空间)
    • CCS(Compressed Class Space):存放 class 信息的,和方法区有交叉

image-20240530113757496

CPU指令

计算机按支持的指令大致可以分为两类:

  • 精简指令集计算机(RISC):代表是如今大家熟知的 ARM 芯片,功耗低,运算能力相对较弱。

  • 复杂指令集计算机(CISC):代表作是 Intel 的 X86 芯片系列,比如奔腾,酷睿,至强,以及 AMD 的 CPU。特点是性能强劲,功耗高。(实际上从奔腾 4架构开始,对外是复杂指令集,内部实现则是精简指令集,所以主频才能大幅度提高)

分类概念的引入:对于程序设计而言,同样的计算,可以有不同的实现方式。 而硬件指令设计同样如此,比如说系统需要实现某种功能,那么复杂点的办法就是在 CPU 中封装一个逻辑运算单元来实现这种的运算,对外暴露一个专用指令,当然也可以偷懒,不实现这个指令,而是由程序编译器想办法用原有的那些基础的,通用指令来模拟和拼凑出这个功能。那么随着时间的推移,实现专用指令的 CPU 指令集就会越来越复杂,,被称为复杂指令集。 而偷懒的CPU 指令集相对来说就会少很多,甚至砍掉了很多指令,所以叫精简指令集计算机。

​ 不管哪一种指令集,CPU 的实现都是采用流水线的方式。如果 CPU 一条指令一条指令地执行,那么很多流水线实际上是闲置的。简单理解,可以类比一个 KFC 的取餐窗口就是一条流水线。于是硬件设计人员就想出了一个好办法:“指令乱序”。 CPU 完全可以根据需要,通过内部调度把这些指令打乱了执行,充分利用流水线资源,只要最终结果是等价的,那么程序的正确性就没有问题。但这在如今多 CPU 内核的时代,随着复杂度的提升,并发执行的程序面临了很多问题。

image-20240530121808053

​ CPU 是多个核心一起执行,同时 JVM 中还有多个线程在并发执行,这种多对多让局面变得异常复杂,稍微控制不好,程序的执行结果可能就是错误的。

运行时数据区

​ 虚拟机在执行Java程序的过程中会将其管理的内存划分为若干个不同的数据区,这些区域有各自的用途 、创建和销毁时间:

  • 线程私有:程序计数器、Java虚拟机栈、本地方法栈
  • 线程共享:Java堆、方法区

​ Java对于内存的管理是采用分区的方式进行管理的,不同区域的特性,存储的数据都是不同的。根据《Java虚拟机规范》的规定,Java虚拟机所管理的内存将会包括以下几个运行时数据区域

image-20240531095207949

​ (1)程序计数器(PC,Program Counter Register),可理解为当前线程所执行字节码的行号指示器,字节码解释器工作时通过改变计数器的值来选取下一条执行指令。在 JVM 规范中,每个线程都有它自己的程序计数器,并且任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。

  • 如果执行Java方法:程序计数器会存储当前线程正在执行的 Java 方法的 JVM 指令地址

  • 如果是在执行本地方法,则是未指定值(undefined)

​ (2)Java 虚拟机栈(Java Virtual Machine Stack),早期也叫 Java 栈,用于描述Java方法。每个线程在创建时都会创建一个虚拟机栈,其内部保存一个个的栈帧(Stack Frame),对应着一次次的 Java 方法调用。在一个时间点,对应的只会有一个活动的栈帧,通常叫作当前帧,方法所在的类叫作当前类。如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,成为新的当前帧,一直到它返回结果或者执行结束。

​ JVM 直接对 Java 栈的操作只有两个,即对栈帧的压栈和出栈(每个方法的从【调用】=》【执行完成】的过程就是栈帧从【入栈】=》【出栈】的过程)

​ 栈帧中存储着局部变量表、操作数(operand)栈、动态链接、方法正常退出或者异常退出的定义等

  • 存在两类异常:
    • 线程请求的栈深度大于虚拟机允许的深度:抛出StackOverflowError
    • 如果JVM栈容量可以动态扩展,栈扩展无法申请足够内存:则抛出OOM(HotSpot不可动态扩展,不存在此问题)

​ (3)(Heap),它是 Java 内存管理的核心区域,用来放置 Java 对象实例,几乎所有创建的 Java 对象实例都是被直接分配在堆上

​ 堆被所有的线程共享,在虚拟机启动时,指定的“Xmx”之类参数就是用来指定最大堆空间等指标

​ 堆也是垃圾收集器重点照顾的区域,所以堆内空间还会被不同的垃圾收集器进行进一步的细分(分代思想:年轻代、老年代)

​ (4)方法区(Method Area):是所有线程共享的一块内存区域,用于存储所谓的元(Meta)数据,例如类结构信息,以及对应的运行时常量池、字段、方法代码等。

​ JDK6&7:永久代(Permanent Generation),早期的 Hotspot JVM的方法区实现;JDK8:移除永久代概念,引入元数据区(Metaspace)

​ (5)运行时常量池(Run-Time Constant Pool)是方法区的一部分。如果仔细分析过反编译的类文件结构,你能看到版本号、字段、方法、超类、接口等各种信息,还有一项信息就是常量池。Java 的常量池可以存放各种常量信息,不管是编译期生成的各种字面量,还是需要在运行时决定的符号引用,所以它比一般语言的符号表存储的信息更加宽泛

​ 运行时常量池相对于Class文件常量池的一个重要特性是动态性:Java不要求常量只有在编译时才能产生,运行时也可以将新的常量放入池中,例如String的intern方法则是利用了这种特性。

​ (6)本地方法栈(Native Method Stack)。它和 Java 虚拟机栈是非常相似的,支持对本地方法的调用,也是每个线程都会创建一个。在 Oracle Hotspot JVM 中,本地方法栈和 Java 虚拟机栈是在同一块儿区域,这完全取决于技术实现的决定,并未在规范中强制。

​ 类似地,本地方法栈在栈深度异常和栈扩展失败的时候分别抛出OverStackflowError和OutOfMemoryError

1.程序计数器

​ 程序计数器可以看作是当前线程所执行的字节码的行号指示器。它通过标示下一条需要执行的字节码指令完成指令切换,可以说一个线程的运行就是在该计数器的不断变化推动下一步一步完成的。

​ 关于程序计数器的几点总结:

  • 它是一块很小的内存空间,几乎可以忽略不计。也是运行速度最快的存储区域
  • 在 JVM 规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。如果当前线程正在执行的是 Java 方法,程序计数器记录的是 JVM 字节码指令地址,如果是执行native 方法,则是未指定值(undefined)
  • 它是唯一一个在 JVM 规范中没有规定任何 OutOfMemoryError(OOM) 情况的区域

2.虚拟机栈

image-20240530105532187image-20240530105448781

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

虚拟机栈的操作只有两个(入栈和出栈)。当调用一个新的方法时,就构建一个栈帧压入到栈中,而一个方法执行结束,就会有一个栈帧出栈,整个遵循**“先进后出/后进先出”的原则**。栈帧中主要存储了局部变量表、操作数栈、动态连接、方法出口等信息(具体结合运行时栈帧结构进行理解)。

​ 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧是有效的,这个栈帧被称为当前栈帧。不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧中引用另外一个线程的栈帧。如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着,虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。

​ Java 方法有两种返回函数的方式,一种是正常的函数返回(使用 return 指令),另一种是抛出异常,不管用哪种方式,都会导致栈帧被弹出

​ 在《Java虚拟机规范》中,对这个内存区域规定了两类异常状况:

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
  • 如果Java虚拟机栈容量可以动态扩展,当栈扩展时无法申请到足够的内存会抛出OutofMemoryError异常

概念理解

【1】方法执行、结束遵循栈:先进后出、后进先出的原则:进一步解释了,在调试程序的时候,方法的调用是一层层向下调用下去的,返回的时候是从下往上一层层返回上来的,其本质即底层虚拟机是使用的栈操作

【2】方法调用的理解:结合虚拟机栈的入栈、出栈操作理解方法调用的流程

  • 例如A方法中调用了B方法,则A、B先后入栈,将B作为当前栈帧
  • 当B执行完毕出栈之后,会将其执行结果返回给A(即前一个栈帧)
  • 随后虚拟机会抛弃当前线栈帧B,然后让A作为当前栈帧继续执行其他操作(整体栈操作流程:A入栈、B入栈、B执行完出栈、A执行完出栈=》先进后出)

👻运行时栈帧结构

(1)栈与栈帧整体结构

image-20240530084413393

线程调用一个方法执行和退出就对应着一个栈帧的入栈和出栈

  • 栈的顶部第一个栈帧叫做当前栈帧,对应的是一个线程需要执行的最新的方法;
  • 栈帧内部主要包括局部变量表、操作数栈、方法返回地址、动态连接等信息
(2)局部变量表

​ **局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。**主要包括编译期就可知道的各种基本数据类型,对象引用等,所以局部变量表所需要的容量大小编译期就能确定下来,并且在整个方法运行期间,都不会改变。

变量槽(Variable Slot):

  • 局部变量表的容量以变量槽(Variable Slot)为最小单位
  • Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。(对于两个相邻的共同存放一个64位数据 的两个变量槽,虚拟机不允许采用任何方式单独访问其中的某一个)。
  • 如果执行的是实例方法,那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用“this”为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。

概念理解

当前字节码PC计数器的值已经超出了某个变量的作用域:即表示这个变量使用完成了(留着没用了),那么它占用的那块内存就可以给别的变量使用,也就是说这个变量对应的变量槽可以给其他变量来重用

案例分析

public class JvmDemo {
    public static void main(String[] args) {
        String s1 = "aaa";
        String s2 = "bbb";
        String s3 = "ccc";
    }
}

# 编译JvmDemo.java文件,生成JvmDemo.class
javac -encoding UTF-8 .\JvmDemo.java
    
# 查看生成的class文件
javap -v .\JvmDemo.class
(3)操作数栈

​ 操作数栈:主要用于保存计算过程的中间结果,同时作为计算过程中变量临时的存储空间。当一个方法执行过程中,会有各种字节码指令对操作数栈出栈和入栈操作。例如在做算术运算的时候是通过将运算涉及的操作数栈压入栈顶后调用运算指令来进行的。比如字节码指令iadd,这条指令在运行的时候要 求操作数栈中最接近栈顶的两个元素已经存入了两个int型的数值,当执行这个指令时,会把这两个int 值出栈并相加,然后将相加的结果重新入栈。

​ 编译器在编译阶段就会保证操作数栈中元素的数据类型与字节码指令的序列严格匹配。同时在类加载过程中的类检验阶段的数据流分析阶段还会再次验证。

(4)动态连接

​ 每个栈帧都包含一个指向运行时常量池中该“栈帧所属方法”的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。在 Java 源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用保存在 Class 文件的常量池中。比如:描述一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,那么动态链接的作用就是为了将这些符号引用转换为调用方法的直接引用。

image-20240530090108291

引用:引用概念可以统一理解为指向一块数据地址的指针(指向一个地址)

动态连接:例如在main方法中调用了method_one方法,编译过程:首先在常量池(Constant pool)中创建该方法的符号引用(对应#3指令),然后执行#7指令,通过这个指令真正地调用这个符号所引用的方法。则动态连接的理解可以简化为:将符号引用转化为调用方法的直接引用

类元信息和运行时常量池:此处图示中类元信息和运行时常量池的图解是并列关系,但实际上不同版本下这两是存储在一起的。所以讨论存储区域的时候会把他们一块描述,但准确来说这两个不是一个概念,可以分开理解

image-20240530090829796

(5)方法返回地址

方法正常退出:一个方法正常执行完成之后,会遇到返回指令。这种情况会有一个预先定义好类型的返回值返回给调用方法。

方法异常退出:一个方法执行过程中,如果发生了异常,并且异常没有再方法中妥善的捕获处理,那也会触发方法的退出。这种情况是不会有返回值返回给调用方的。

​ 方法正常退出时,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。本质上,方法的退出就是当前栈帧出栈的过程。此时,需要恢复上层方法的局部变量表、操作数栈、将返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者方法继续执行下去。

(6)附加信息

​ 栈帧中还允许携带与 Java 虚拟机实现相关的一些附加信息。例如,对程序调试提供支持的信息,但这些信息取决于具体的虚拟机实现。

3.本地方法栈

​ 一个 Native Method 就是一个 Java 调用非 Java 代码的接口。 Unsafe 类open in new window就有很多本地方法。

​ 本地方法栈(Native M ethod Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native) 方法服务。

《Java虚拟机规范》对本地方法栈中方法使用的语言、使用方式与数据结构并没有任何强制规定,Hotspot虚拟机直接就把本地方法栈和虚拟机栈合二为一

如何理解二合一概念:使用同一个栈来支持Java方法和本地(Native)方法的执行,对外看起来是统一的。但是内部逻辑上,虚拟机仍然要区分Java方法调用和本地方法调用,因为这两种调用可是涉及到不同的处理逻辑

4.Java堆

​ Java堆是被所有线程共享的一块内存区域,“几乎”所有的对象实例都在这里分配内存。Java堆也是垃圾收集器管理的内存区域,以G1收集器的出现为分界,往前的收集器基本是采用分代收集理论进行设计,所以新生代老年代永久代Eden空间From Survivor空间To Survivor空间等概念都是分代设计下的产物,垃圾分代的唯一目的就是优化GC性能。(todo JVM-垃圾收集器与内存分配策略)

​ 《Java 虚拟机规范》规定,Java 堆可以是处于物理上不连续的内存空间中,只要逻辑上是连续的即可,像磁盘空间一样。实现时,既可以是固定大小,也可以是可扩展的,主流虚拟机都是可扩展的(通过 -xmx 和 --xms 控制)如果堆中没有内存完成实例分配,并且堆无法再扩展时,就会抛出 OutOfMemoryError 异常。

Java对象是不是都创建在堆上呢?

​ 此处先了解一个概念逃逸分析逃逸分析(Escape Analysis)是分析新创建对象的作用范围,并决定是否在堆中分配内存的一项技术。因此有一些观点会认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这种观点理论上是可行的,但其取决于JVM设计者的选择。以Oracle Hotspot JVM为例,它并没有这么做open in new window,因此可以明确所有的对象实例都是创建在堆上的

​ 其次,目前很多书籍对JVM的分析是基于JDK7之前的版本(但JDK版本已经发生了很大的变化),例如Intern 字符串的缓存和静态变量曾经都被分配在永久代上,而永久代在JDK8版本中已经被元数据区取代。但是Intern 字符串的缓存和静态变量并不是被转移到元数据区,而是直接在堆上分配,因此也验证了上述观点:对象实例都分配在堆上

5.方法区

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

​ 虽然 Java 虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫 Non-Heap(非堆),目的应该是与 Java 堆区分开。方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出0utOfMemoryError错误。JVM 关闭后方法区即被释放。

​ 《Java虚拟机规范》对方法区的约束是非常宽松的,除了和Java堆一样不需要连续的内存和可以选 择固定大小或者可扩展外,甚至还可以选择不实现垃圾收集。方法区(method area)只是 JVM 规范中定义的一个概念,不同的厂商有不同的实现。而永久代(PermGen)是Hotspot 虚拟机特有的概念,Java8 的时候又被元空间取代了,永久代和元空间都可以理解为方法区的落地实现。两种实现存储内容不同,元空间存储类的元信息,而静态变量和字符串常量池等并入堆空间中,相当于永久代的数据被分到了堆空间和元空间中。

​ Java7 中通过 -xx:Permsize-xx:MaxPermsize 来设置永久代参数,Java8 之后,随着永久代的取消,这些参数也就随之失效了,改为通过 -XX:MetaspaceSize -XX:MaxMetaspacesize 用来设置元空间参数

Java8之后方法区的变化

  • 移除了永久代(PermGen),替换为元空间(Metaspace)
  • 永久代中的 class metadata 转移到了 native memory(本地内存,而不是虚拟机)
  • 永久代中的 interned Strings 和 class static variables转移到了Java堆
  • 永久代参数(PermSizeMaxPermSize)->元空间参数(MetaspaceSize MaxMetaspaceSize)

概念理解

【1】永久代和元空间都可以理解为方法区的落地实现:方法区是逻辑上的一块区域,这块区域存储类信息、字段信息等数据,不同的厂商有不同的实现;以Hotspot虚拟机为例,在JDK7中使用的是永久代存储这些数据,在JDK1.8中使用的是元空间存储这些数据,因此可以理解为永久代和元空间是方法区的具体实现

【2】类的元信息:主要包括类型信息、字段信息、方法信息、运行时常量池等

6.运行时常量池

​ 运行时常量池(Runtime Constant Pool)是方法区的一部分。Class 文件中除了有类的版本/字段/方法/接口等描述信息外,还有一项信息是常量池表(Constant Pool Table),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放,JVM 为每个已加载的类型(类或接口)都维护一个运行时常量池,在加载类和接口到虚拟机后创建。所以运行时常量池相对于Class文件常量池的另一重要特性:具备动态性

​ 既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出OutofMemoryError异常。

概念理解

常量池:就是一张表(存放编译期生成的各种字面量和符号引用),虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量信息等

运行时常量池:常量池是*.class文件中的,当该类class文件被加载之后,其常量池信息就会放入运行时常量池(加载到方法区),并将里面的符号地址变为真实地址

运行时常量池是方法区的一部分:一些资料显示JDK7之后将常量池移到了堆区(如何理解此处的堆区和方法区)此处区分物理概念和逻辑概念,此处的方法区是逻辑概念,即运行时常量池不管实际物理存储在任何区(堆区是物理概念),它逻辑上都属于方法区

7.本地内存和直接内存

​ 本地内存(Native Memory)并不是虚拟机运行时数据区的一部分,它也不是Java虚拟机规范定义的内存区域。我们可以看到在 HotSpot 中,JDK1.8就将方法区移除了,用元数据区来代替,并且将元数据区从虚拟机运行时数据区移除了,转到了本地内存中,也就是说这块区域不受JVM限制,而是受本机物理内存的限制,当申请的内存超过了本机物理内存,才会抛出 OutofMemoryError 异常。

​ 直接内存(Direct Memory)也是受本机物理内存的限制,在JDK1.4中新加入的N10 (new input/output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I0 方式,它可以使用Native 函数库直接分配堆外内存,然后通过一个存储在Java堆里面的 DirectByteBuffer 对象作为这块内存的引用操作,这样避免了在Java堆和Native堆中来回复制数据,显著提高性能。可以通过-XX:MaxDirectMemorySize 参数控制大小

  • Java 程序内存=JVM 内存+本地内存
  • 本地内存=元空间+直接内存

JDK 1.8 的 Java内存区域划分

image-20240531095647782

  • 对元空间的理解(因为元空间经过多次迭代,所以一些资料概念上可能会有点混淆,结合两点去理解)
    • 元空间是在堆上吗?
      • 元空间不是在堆上分配的,而是在堆外空间进行分配的,它的大小默认没有上限(可以理解为元空间是方法区的一种物理实现)
    • 字符串常量池是在哪个区域中?结合JDK版本理解
      • JDK1.7之前,字符串常量池存在于永久代的空间中(方法区),在JDK1.7版本字符串常量池从永久代移动到堆上
      • JDK1.8取消永久代,引入元空间概念

JMM

1.JMM核心概念

JMM 规范对应的是 JSR133, 现在由 Java 语言规范和 JVM 规范来维护

​ JVM 支持程序多线程执行,每个线程是一个 Thread,如果不指定明确的同步措施,那么多个线程在访问同一个共享变量时,就看会发生一些奇怪的问题。

​ 比如A线程读取了一个变量 a=10,想要做一个只要大于9就减2的操作,同时 B 线程先在 A 线程操作前设置 a=8,其实这时候已经不满足A 线程的操作条件了,但是A线程不知道,依然执行了 a-2,最终 a=6;实际上a的正确值应该是 8,这个没有同步的机制在多线程下导致了错误的最终结果。

​ 因此需要 JMM 定义多线程执行环境下的一些语义问题,也就是定义了哪些方式是允许。JMM 规范的是线程间的交互操作,而不管线程内部对局部变量进行的操作。从抽象角度来说,它决定一个线程对共享变量的写入何时对另一个线程可见

JMM 定义了一些术语和规定(了解):

  • 能被多个线程共享使用的内存称为“共享内存”或“堆内存
  • 所有的对象(包括内部的实例成员变量),static 变量,以及数组,都必须存放到堆内存中
  • 局部变量,方法的形参/入参,异常处理语句的入参不允许在线程之间共享,所以不受内存模型的影响
  • 多个线程同时对一个变量访问时【读取/写入】,这时候只要有某个线程执行的是写操作,那么这种现象就称之为“冲突”
  • 可以被其他线程影响或感知的操作,称为线程间的交互行为, 可分为: 读取、写入、同步操作、外部操作等等。 其中同步操作包括:对 volatile 变量的读写,对管程(monitor)的锁定与解锁,线程的起始操作与结尾操作,线程启动和结束等等。 外部操作则是指对线程执行环境之外的操作,比如停止其他线程等等

2.内存屏障概念

​ CPU会在合适的时机,按需要对将要进行的操作重新排序(CPU提升性能的利器),但是有时候这个重排机会导致代码跟预期不一致。

​ 怎么办呢?JMM 引入了内存屏障机制。

​ 内存屏障可分为读屏障写屏障,用于控制可见性。 常见的 内存屏障 包括:

#LoadLoad
#StoreStore
#LoadStore
#StoreLoad

​ 这些屏障的主要目的,是用来短暂屏蔽 CPU 的指令重排序功能。 和 CPU 约定好,看见这些指令时,就要保证这个指令前后的相应操作不会被打乱。

  • 比如看见 #LoadLoad, 那么屏障前面的 Load 指令就一定要先执行完,才能执行屏障后面的 Load 指令。
  • 比如我要先把 a 值写到 A 字段中,然后再将 b 值写到 B 字段对应的内存地址。如果要严格保障这个顺序,那么就可以在这两个 Store 指令之间加入一个 #StoreStore 屏障。
  • 遇到 #LoadStore 屏障时, CPU 自废武功,短暂屏蔽掉指令重排序功能。
  • #StoreLoad 屏障, 能确保屏障之前执行的所有 store 操作,都对其他处理器可见; 在屏障后面执行的 load 指令, 都能取得到最新的值。换句话说, 有效阻止屏障之前的 store 指令,与屏障之后的 load 指令乱序 、即使是多核心处理器,在执行这些操作时的顺序也是一致的。

​ 代价最高的是 #StoreLoad 屏障, 它同时具有其他几类屏障的效果,可以用来代替另外三种内存屏障。

​ 如何理解其代价高:只要有一个 CPU 内核收到这类指令,就会做一些操作,同时发出一条广播, 给某个内存地址打个标记,其他 CPU 内核与自己的缓存交互时,就知道这个缓存不是最新的,需要从主内存重新进行加载处理。

OOM

​ 对OOM的分析需要结合两个方面考虑,首先是堆JVM内存结构的理解,其次是掌握什么是OOM,以及OOM对应那些内存区域的异常情况

对象主要是在堆上分配的,可以把它想象成一个池子,对象不停地创建,后台的垃圾回收进程不断地清理不再使用的对象。当内存回收的速度赶不上对象创建的速度,这个对象池子就会产生溢出,即OOM

​ OOM:OutOfMemofy(内存溢出),即JVM内存不够用了。在javadoc中对OOM的解释:没有空闲内存,且垃圾收集器也无法提供更多内存。

​ 基于javadoc提供的概念,这里面有一层含义:在抛出OOM之前,通常垃圾收集器会被触发,尽其所能清理出空间,可以结合下述场景分析:

  • 引用机制分析:JVM会去尝试回收软引用指向的对象等

  • System.gc()调用:java.nio.Bits.reserveMemory()open in new window方法:查看源码分析,其会调用System.gc()以清理空间。但是如果JVM参数中设置了-XX:+DisableExplicitGC则会使代码中的System.gc()失效(在一些场景下为什么不推荐使用这个配置,因为有些框架中使用的是堆外内存,需要手动调用System.gc()进行释放,如果禁用掉就会导致堆外内存使用一直增长,进而造成内存泄漏)

    • 例如Netty框架经常会使用DirectByteBuffer来分配堆外内存,在分配之前会显式的调用System.gc(),如果开启了DisableExplicitGC这个参数,会导致System.gc()调用变成一个空调用(没有任何作用),反而会导致Netty框架无法申请到足够的堆外内存,从而产生java.lang.OutOfMemoryError: Direct buffer memory
  • 不是在任何情况下垃圾收集器都会被触发:例如去分配一个超大对象(类似一个超大数组超过堆的最大值),JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError

​ 从数据区的角度触发,除了程序计数器,其他区域都有可能会因为可能的空间不足发生 OutOfMemoryError:

  • 堆内存不足:是最常见的 OOM 原因之一,抛出的错误信息是java.lang.OutOfMemoryError:Java heap space,原因可能千奇百怪
    • 例如可能存在内存泄漏问题;也很有可能就是堆的大小不合理,比如要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值偏小;
    • 或者出现 JVM 处理引用不及时,导致堆积起来,内存无法释放等
  • Java 虚拟机栈和本地方法栈:而对于 Java 虚拟机栈和本地方法栈,这里要稍微复杂一点
    • 如果写一段程序不断的进行递归调用,而且没有退出条件,就会导致不断地进行压栈。类似这种情况,JVM 实际会抛出 StackOverFlowError;当然,如果 JVM 试图去扩展栈空间的的时候失败,则会抛出 OutOfMemoryError
  • 方法区
    • 对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如常量池回收、卸载不再需要的类型)非常不积极,所以当不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合;类似 Intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:java.lang.OutOfMemoryError: PermGen space
    • 随着元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息则变成了:java.lang.OutOfMemoryError: Metaspace
    • 运行时常量池:运行时常量池是方法区的一部分,受到方法区内存的限制,当常量池无法再申请到内存时则会抛出OOM
  • 直接内存不足:直接内存不属于运行时数据区,也不是虚拟机规范定义的内存区域,但是这部分内存被频繁使用,而且可能也会导致 OOM
    • JDK1.4中新加入了NIO(基于通道和缓冲区的IO),它使用Native函数库直接分配堆外内存,通过一个堆里的DirectByteBuffer对象作为内存的引用来进行操作,避免了在Java堆和Native堆来回复制数据。
    • 直接内存的分配不受 Java 堆大小的限制,但还是会受到本机总内存及处理器寻址空间限制,一般配置虚拟机参数时会根据实际内存设置 -xmx 等参数信息,但经常忽略直接内存,使内存区域总和大于物理内存限制,导致动态扩展时出现 OOM。
    • 由直接内存导致的内存溢出,一个明显的特征是在 Heap Dump 文件中不会看见明显的异常,如果发现内存溢出后产生的 Dump 文件很小,而程序中又直接或间接使用了直接内存(典型的间接使用就是 NIO),那么就可以考虑检查直接内存方面的原因

异常问题解读

内存溢出 VS 内存泄露

  • 内存溢出(OutOfMemory):指程序在申请内存时,没有足够的内存空间供其使用
  • 内存泄漏(Memory Leak):指程序在申请内存后,无法释放已申请的内存空间,内存泄漏最终将导致内存溢出

栈溢出的原因:栈深度异常、栈内存申请失败

​ 由于 HotSpot 不区分虚拟机和本地方法栈,设置本地方法栈大小的参数没有意义,栈容量只能由 --xss 参数来设定,存在两种异常:

​ StackOverflowError: 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出 StackOverflowError,例如一个递归方法不断调用自己。该异常有明确错误堆栈可供分析,容易定位到问题所在。

​ OutOfMemoryError::如果JVM 栈可以动态扩展,当扩展无法申请到足够内存时会抛出 OutOfMemoryError.HotSpot 不支持虚拟机栈扩展,所以除非在创建线程申请内存时就因无法获得足够内存而出现 OOM,否则在线程运行时是不会因为扩展而导致溢出的

运行时常量池溢出的原因

​ String 的 intern 方法是一个本地方法,作用是如果字符串常量池中已包含一个等于此 String 对象的字符串,则返回池中这个字符串的 String 对象的引用,否则将此 String 对象包含的字符串添加到常量池并返回此 String 对象的引用。

​ 在 JDK6 及之前常量池分配在永久代,因此可以通过-xx:Permsize 和 -xx:MaxPermsize 限制永久代大小间接限制常量池。在 while 死循环中调用 intern 方法导致运行时常量池溢出。在 JDK7 后不会出现该问题,因为存放在永久代的字符串常量池已经被移至堆中。

运行时常量池是方法区的一部分,受到方法区内存的限制,当常量池无法再申请到内存时则会抛出OOM

方法区溢出的原因

​ 方法区主要存放类型信息,如类名、访问修饰符、常量池、字段描述、方法描述等。只要不断在运行时产生大量类,方法区就会溢出。例如使用 JDK 反射或 CGLib 直接操作字节码在运行时生成大量的类。很多框架如 Spring、Hibernate 等对类增强时都会使用 CGLib 这类字节码技术,增强的类越多就需要越大的方法区保证动态生成的新类型可以载入内存,也就更容易导致方法区溢出。

​ JDK8使用元空间取代永久代,HotSpot提供了一些参数作为元空间防御措施,例如-xx:Metaspacesize 指定元空间初始大小,达到该值会触发 GC 进行类型卸载,同时收集器会对该值进行调整,如果释放大量空间就适当降低该值,如果释放很少空间就适当提高

评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3