JVM内存模型

欢迎在评论区指出错误,看到都会回复;用爱发电,点个赞再走(只收藏不点赞不合适吧)~

JVM内存模型

简介

​ JVM 虚拟机在执⾏ Java 程序的过程中,会把它管理的内存划分成若⼲个不同的区域,每个 区域有各⾃的不同的⽤途、创建⽅式及管理⽅式。有些区域随着虚拟机的启动⼀直存在,有些区域则 随着⽤户线程的启动和结束⽽建⽴和销毁,这些共同组成了 Java 虚拟机的运⾏时数据区域,也被 称为 JVM 内存模型。

运行时数据区⽅法区(现在是元空间)、堆区、虚拟机栈、本地⽅法栈、程序计数器五部分组成。

在这里插入图片描述

整体结构

在这里插入图片描述

整体分为线程私有区域,线程共享区域;

线程私有区域里面又分为三个部分:虚拟机栈、本地方法栈、程序计数器

线程共享区域又分为:Heap堆区,Metaspace 元空间区;

线程私有区域

虚拟机栈

  虚拟机栈是线程执行方法时的,创建的临时内存区域,会随着线程的销毁而销毁。每个方法被调用的时候,都会在虚拟机栈创建出一个栈帧,每个栈帧存储了方法的参数局部变量操作数栈以及返回地址、返回类型和值等信息;
  (至于方法的定义,在方法区并不在虚拟机栈里面,虚拟机栈存储的多是方法运行时候的数据)

在这里插入图片描述

​   在jvm运行时候,所有指令都只能针对当前 活动栈帧进行操作,虚拟机栈通过入栈、出栈的方法,对每个栈帧进行处理(递归的实现就是通过该栈结构实现的)

在这里插入图片描述

虚拟机栈中栈弹出的几种方式:
  1. 方法执行了retrun语句
  2. 方法执行中遇到了异常
  3. 方法所有指令执行完毕(比如构造方法 但是构造方法有隐式的返回 返回那个创建的对象)
虚拟机栈可能产生的错误
  • StackOverFlowError:虚拟机栈中的栈帧个数,超过了虚拟机栈的最大深度,或者栈空间不足时就会抛出该错误;
  • OutOfMemoryError:虚拟机栈也会动态扩展内存,如果没申请到应有的空间,就会抛出该错误;

关于虚拟机栈,大家可能好奇,为什么有要限制栈的个数,还要限制栈的大小?
​  首先,谈谈个数,通过对虚拟机栈设置一个最大深度,当栈帧数量达到最大深度时候,就会报出相应错误,比如,一个函数出现了无限制递归,如果只使用限制栈的大小 那么如果这个栈帧的大小很小,可能要创建成千个栈帧 达到虚拟机栈的最大内存(虚拟机栈也是会动态扩展大小的 理论上只受JVM进程内存大小影响),这个过程是很漫长的,而如果使用最大深度,可能还没创建到栈空间的溢出,就会提示你,栈溢出的错误,这样就会更早的发现无限递归、或者方法互相调用等编程错误;
​   而限制大小,如果一些栈帧内部的局部变量非常多,栈帧也会很大,不到一会儿这些栈帧就会占满栈空间,这样同样会触发StackOverFlowError

-Xss设置虚拟机栈大小

​   虚拟栈的⼤⼩可以通过 -Xss 参数设置,默认单位是 byte ,也可以使⽤ k , m , g 作为单 位(不区分⼤⼩写)。例如: -Xss 1m
  在不同操作系统下的 -Xss 默认值不同 Linux : 1024k MacOs : 1024k Windows : 默认值依赖于虚拟机的内存

程序计数器

  程序计数器是⼀块较⼩的内存空间,是当前线程所执⾏的字节码的⾏号指示器(即记录程序执行位置)。

​  字节码解释器在解释字节码文件时候,每需要执行一条指令时候,就通过程序计数器完成,常见程序中的,循环(不断调到循环体的第一行指令出),分支(跨过不符合条件的执行代码)、跳转异常处理等等都是通过程序计数器完成.(递归要通过虚拟机栈的栈结构与程序计数器结合实现 其余的流程控制也都要通过程序计数器完成)

​>  程序计数器还有一个重要的作用是,线程的恢复(线程从阻塞、等待状态恢复到运行态),为了能让当前线程恢复到正确的执⾏位置, 每条线程都需要有⼀个独⽴的程序计数器去记录阻塞/等待前程序执行位置,并且各线程之间计数器互不影响,独⽴存储。

在这里插入图片描述

​ 程序记数器也是唯一一个不会出现OutOfMemoryError的内存区域

本地方法栈

  类似于虚拟机栈,本地方法栈也是存放本地方法运行时数据的,包括方法调用栈帧、局部变量和参数、操作数栈以及动态链接信息等。

  前文提到过程序计数器是唯一一个不会出现OutOfMemoryError的内存区域,有的人可能好奇,native方法不都是java根据不同的操作系统适配不同的jvm和本地方法来应用操作系统库函数或者操作系统指令的完成计算的;

  答案是no,native方法也可以自己编写,并且在调用java自带的native方法时候也是会出现参数设置不合理出现OutOfMemoryError错误的;

​ 下文就是如何创建并使用的例子;

  1. 在Java类中声明一个native方法:在Java类中,你可以使用native关键字来声明一个方法是本地的。这意味着这个方法的实现将在其他地方(比如一个C或C++的.dll或.so文件中)提供。
public class NativeDemo {  
    // 声明一个本地方法  
    public native void nativeMethod();  
  
    // 加载包含本地方法实现的库  
    static {  
        System.loadLibrary("NativeImpl");  
    }  
  
    public static void main(String[] args) {  
        new NativeDemo().nativeMethod();  
    }  
}

2.实现本地方法:在C或C++中,你需要实现这个本地方法。你需要遵循JNI的规范来编写代码,以便Java虚拟机能够调用它。这通常包括编写JNI函数,该函数与Java中声明的本地方法签名相对应,并处理与Java虚拟机的交互。

例如,对于上面的nativeMethod,你可能会有如下的C或C++实现:

#include <jni.h>  
#include "NativeDemo.h" // 这个头文件是由javah工具生成的  
  
JNIEXPORT void JNICALL Java_NativeDemo_nativeMethod  
  (JNIEnv *env, jobject obj) {  
    // 本地方法的实现  
    // ...  
}
  1. 编译和链接本地库:你需要使用C或C++编译器来编译你的本地代码,并可能需要链接到JVM的库来创建一个动态链接库(如Windows上的.dll文件或Linux/Unix上的.so文件)。
  2. 使用javah工具生成JNI头文件:在编写C/C++代码之前,你可能需要使用javah工具(从JDK中提供)来生成JNI头文件。这个头文件包含了Java类中声明的本地方法的C语言签名。
  3. 在Java代码中加载本地库:在Java代码中,你需要使用System.loadLibrary()方法来加载你的本地库。这告诉JVM在哪里可以找到你的本地方法实现。

本地方法栈,也会出现StackOverflowErrorOutOfMemoryError这两种错误的;

线程共享区域

线程共享区域分两大模块:堆内存,堆外内存;

说说堆内存:

  之前有提到过java内存是jvm进行自动管理的,而自动管理如果精细到每一个元素,每一个对象,又太过低效,且不具备普适性;

  因为,在jvm中 一个对象,有可能其存活状态长,有的存活状态久,如果垃圾回收(Garbage Collection)对每一个对象都使用相同的检验是否存活的方法 对所有对象使用相同频率的检验是否存活,会极大拖延程序的正常运行,拉低程序运行效率,损耗cpu执行时间;

  所以jvm把不同类型的对象分在不同的区域,通过对不同区域使用不同的管理方式、不同的回收器,来实现分种类管理;

堆内存

​ Heap 堆区,⽤于存放对象实例和数组的内存区域。 Heap 堆区,是 JVM 所管理的内存中最⼤的⼀块区域,被所有线程共享的⼀块内存区域。堆区中存放对象实例,“⼏乎”所有的对象实例以及数组都在这⾥分配内存。(为什么是几乎 下文的注解会有详细解释)

​ 每⼀个 JVM 进程只存在⼀个堆区,它在 JVM 启动时被创建, JVM 规范中规定堆区可以是物理上不连续的内存,但必须是逻辑上连续的内存。

  1. 堆区是线程共享共享的区域,同时也是 JVM 管理最⼤的内存区域。
  2. JVM 规范中描述,所有的对象实例及数组都应该在运⾏时分配在堆上。⽽他们的引⽤会被保存 在虚拟机栈中,当⽅法结束,这些实例不会被⽴即清除,⽽是等待 GC 垃圾回收。
  3. 由于堆占⽤内存⼤,所以是 GC 垃圾回收的重点区域,因此堆区也被称作 GC 堆( Garbag e Collected Heap )。

分区管理的具体实现就是:

  堆内存分为新生代( Young Generation )老年代区域( Old Generation ),而新⽣代被分 为伊甸区(Eden)和幸存者区( from+to ),幸存区⼜被分为 Survivor 0(from) 和 Survi vor 1(to) 。

  新⽣代和⽼年代⽐例为 1:2 ,伊甸区和 S0 、 S1 ⽐例为 8:1:1 ,不同区域存放对象的⽤ 途和⽅式不同;

在这里插入图片描述

新生代

 新生代被分 为伊甸区(Eden)和幸存者区( from+to ),伊甸区和 幸存者区S0 、 S1 ⽐例为 8:1:1;

​ 新生代整体又和老年代比例为1:2;

1.伊甸区( Eden ) :存放⼤部分新创建对象。

2.幸存区( Survivor ):存放 新⽣代回收YGC ( Minor GC/Young GC )之后, Eden 区和幸存区( Survivor )本身没有被 回收的对象 。

老年代

​ ⽼年代:存放 Minor GC 之后且年龄计数器达到 15 依然存活的对象、 Major GC 和 Ful l GC 之后仍然存活的对象。(关于年龄这个概念下文呢会说,这个值默认15也可由JVM参数-XX:MaxTenuringThreshold来控制)

新对象的内存分配与回收

  上文说的,新生代,老年代存放内容不同,有时候也并不绝对,内存的本质也是为执行程序服务的,也会发生新生对象存到,老年代的情况;

在这里插入图片描述

  当一个对象创建出来的时候,为其分配内存。

首先看新生代中的Eden区,如果Eden区放的下,放在Eden区

  如果放不下->执行YGC(新⽣代回收),对伊甸区,和其中一个Survior区使用相应的算法完成回收步骤,注意 上文说过是两个Survior区,可是检测的时候只检测一个,这样的原因是在新生代使用的算法,(注解2) 复制算法,在该算法下 两个survior区,是用来完成快速回收的设计实现,一个survior区域是空,另一个survior区域则是存活有内容的。

  执行YGC时候,会将Eden区域、使用状态的Survior0区中存活状态对象全都复制到另一个未使用的Survior1区(复制的时候是直接覆写,不必在意未使用区上的数据);然后Eden区是清空状态,另一个Survior区也是恢复成未使用状态(也相当于清空了 下一次直接覆写)

  下一次,Eden又满的情况再重复相应的过程时,就会又将Eden区和Survior1区的存活对象复制到Survior0区,这样切换着使用,如果一个对象经历了15次这样的MinorGC之后依然存活 那么下次使用minorGC时候就会将该对象放之老年代空间;

  如果被放置的Survior区(只是考虑其中一个空间,因为另一个空间是未使用状态)已经满了或者放不下,就会考虑直接将该对象放置在老年代空间(常见是创建的对象太大都放不下就直接方法在老年代了)
  放在老年代时候,老年代放得下就放,放不下就执行Old GC;

堆空间大小设置

  堆区的内存⼤⼩是可修改的,默认情况下,初始堆内存为物理内存的 1/64 ,最⼤为物理内存 的 1/4 。

  • -Xms :设置初始堆内存,例如: -Xms64m -Xmx : 设置最⼤堆内存,例如: -Xmx64m

  • -Xmn :设置年轻代内存,例如: -Xmx32m Heap

  • 堆区中的新⽣代、⽼年代的空间分配⽐例,可以通过 java -XX:+PrintFlagsFinal -version 命令查看:

    在这里插入图片描述

    在这里插入图片描述

    在这里插入图片描述

  • ​ InitialSurvivorRatio = 8 新⽣代 Young(Eden/Survivor) 空间的初始⽐例 = 8:代表 Eden 占新⽣代空间的 8 0% ;

  • uintx NewRatio = 2 ⽼年代 Old / 新⽣代 Young 的空间⽐例 = 2 : 代表⽼年代 Old 是新⽣代 Youn g 的 2 倍

  • 因为新⽣代是由 Eden + s0 + s1 组成的,所以按照上述默认⽐例,如果 Eden 区内存 ⼤⼩是 40M ,那么两个 Survivor 区就是 5M ,整个新⽣代区就是 50M ,然后可以算出 ⽼ 年代 Old 区内存⼤⼩是 100M ,堆区总⼤⼩就是 150M。

堆空间出现错误类型

  堆区最容易出现的就是 OutOfMemoryError 错误,这种错误的表现形式会有以下两种:

1.OutOfMemoryError: GC Overhead Limit Exceeded : 当 JVM 花太多时间执⾏垃 圾回收,并且只能回收很少的堆空间时,就会发⽣此错误。
2.OutOfMemoryError: Java heap space :假如在创建新的对象时, 堆内存中的空间不⾜ 以存放新创建的对象, 就会引发此错误。

堆外内存

Metaspace(元空间)

  元空间是替代了以前的方法区,但是区别于方法区,元空间是存在堆外内存上的,在本机的物理内存上存储的,也因此不受堆内存的管理,但仍受JVM管理管控;
  这样做的好处是,常量数目、类信息、类数目不必受堆内存限制、堆内存管理,但是当Metaspace内部数据多到超过JVM设置的阈值,可能就会触发一个OutOfMemoryError异常;
  Metaspace的大小是动态调整的,它的大小由JVM根据应用程序的需要和系统的物理内存来决定。

  虽然Full GC(堆回收)不会直接清理Metaspace,但在某些情况下,Full GC可能会间接地影响Metaspace的使用。例如,如果Full GC释放了大量的堆内存,那么JVM可能会利用这些释放的内存来扩展Metaspace的大小。

存放内容

  • 主要用于存储类的元数据,如类的结构信息(包括类名、父类、接口、字段等)、常量池、字段描述、方法描述等。

特点

  • 动态调整大小:MetaSpace的大小是动态的,会根据应用程序的需求进行调整。
  • 受系统内存限制:MetaSpace的大小受到系统可用内存的限制。
  • 存储类的元数据:当类被卸载时,其在MetaSpace中的元数据也会被回收。
  • 垃圾收集:如果MetaSpace的内存使用量超过了一定阈值,JVM会触发Full GC来清理不再使用的类的元数据。
CodeCache(JIT指令存储区)

存放内容

  • 主要用于存储即时编译器JIT编译好的热点代码;
  • 除了JIT编译的代码外,Java使用的本地代码(JNI)也会存储在CodeCache中。

特点

  • 缓存编译后的代码:Java代码在执行时一旦被编译器编译为机器码,下一次执行时就会直接执行编译后的代码,从而提高了执行效率。(因为CPU不必从JVM进程内访问编译指令,而是直接从物理内存上获取,效率也会提高)

​ CodeCache在JVM官方文档中被归于MetaSpace元空间(在更宽泛的意义上),但在某些文档和描述中可能被单独列出。

​ CodeCache的存储位置和管理方式与MetaSpace有所不同,但它同样不是JVM堆内存的一部分

DIrect Memory(直接内存)

存放内容

  • 这部分内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。
  • 如果在程序中频繁使用NIO(非阻塞IO),那么在这部分内存中分配的内存也会增加。一般比如Io流中的缓冲区就在这里面存着;

特点

  • 直接内存:直接内存的使用受操作系统的管理,而不是JVM。
  • 与NIO相关:当在Java中使用NIO时,数据会直接在DirectMemory中进行读写,从而提高了IO操作的效率。

注解:

1.哪种情况下对象不会存放在堆区里面?

在Java中,大多数对象实例确实是在堆内存中分配的。然而,有几个例外情况需要注意:

  1. 基本数据类型:基本数据类型(如int, double, char等)和其对应的包装类(如Integer, Double, Character等)的值在内存中的存储位置是不同的。基本数据类型直接存储在栈内存中(作为局部变量或数组的一部分),而包装类的对象实例则存储在堆内存中。
  2. 栈内存中的对象引用:虽然对象实例本身存储在堆内存中,但对象的引用(即指向对象的指针或句柄)可以存储在栈内存中,作为局部变量或方法参数.(有些人认为引用也是一种对象 但是这和我们通俗意义上认识的对象不一致)
  3. 逃逸分析(Escape Analysis):在某些情况下,JIT(Just-In-Time)编译器可能会进行逃逸分析,以确定某个对象是否只在一个线程中访问,并且它的生命周期是否不会超出当前方法或线程的执行范围。如果分析结果表明对象不会“逃逸”到方法或线程之外,那么JIT编译器可能会选择将该对象的内存分配在栈上,而不是堆上。这称为栈上分配(Stack Allocation)或标量替换(Scalar Replacement),它可以减少垃圾收集的压力并提高性能。但是,这种情况并不常见,并且完全取决于JIT编译器的实现和决策。
  4. 对象引⽤( reference 类型,它 不同于对象本身,可能是⼀个指向对象起始地址的引⽤指针,也可能是指向⼀个代表对象的句柄或其 他与此对象相关的位置
  5. 方法区中的类元数据信息:虽然类元数据信息(包括类的结构、方法、字段等)不直接存储在堆内存中,但它们通常存储在方法区(在Java 8及以后版本中称为元空间Metaspace)中。方法区是JVM规范中定义的一个逻辑区域,用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。比如,如果一个类,其有一个类级别的静态变量是一个在类内部初始化好的对象,那么这个对象就会作为类元信息存储在方法区(或者元空间中);

综上所述,虽然大多数Java对象实例都存储在堆内存中,但也有一些例外情况。

2.几种垃圾检测算法

在Java中,垃圾回收器回收时检测对象是否是垃圾的算法主要有以下几种:

  1. 引用计数算法(Reference Counting Algorithm)
    • 原理:为每个对象维护一个引用计数器,每当有一个地方引用该对象时,计数器加1;当引用被释放时,计数器减1。
    • 判定:当对象的引用计数器为0时,表示该对象不再被使用,可以被回收。
    • 缺点:无法解决循环引用的问题,因此在实际的JVM中很少使用。
  2. 可达性分析算法(Reachability Analysis Algorithm)
    • 原理:通过一系列称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain)。
    • 判定:当一个对象到GC Roots没有任何引用链相连(即从GC Roots到这个对象不可达)时,则证明此对象是不可用的,可以被回收。
    • 优点:能够解决循环引用的问题,是现代Java虚拟机(JVM)普遍采用的垃圾回收算法。
  3. 标记-清除算法(Mark-Sweep Algorithm)
    • 原理:分为标记阶段和清除阶段。
      • 标记阶段:从根对象开始,递归地访问这些对象的子对象,找出所有存活对象,并做上标记。
      • 清除阶段:遍历堆内存,回收未被标记(即不可达)的对象,并释放其占用的内存空间。
    • 缺点:会产生内存碎片,导致后续分配大对象时可能无法找到连续的内存空间。
  4. 复制算法(Copying Algorithm)
    • 原理:将内存划分为等大小的两个区域,每次只使用其中一个区域进行对象分配。当该区域内存耗尽时,进行垃圾回收,将存活的对象复制到另一块空闲区域,然后一次性清理掉原区域的所有对象。
    • 优点:实现简单,运行高效,不会产生内存碎片。
    • 缺点:内存使用效率不高,只有一半的内存空间被利用。
  5. 标记-整理算法(Mark-Compact Algorithm)
    • 原理:标记阶段与标记-清除算法相同。整理阶段则不是直接清理未标记的对象,而是将所有存活的对象都移动到一端,然后直接清理掉边界以外的内存空间。
    • 优点:解决了内存碎片问题。
    • 缺点:效率上不如复制算法,且移动存活对象需要额外的时间和空间开销。
  6. 分代收集算法(Generational Collection Algorithm)
    • 原理:根据对象存活周期的不同将内存划分为几块,如新生代和老年代。在新生代中,由于对象存活率低,一般使用复制算法;而在老年代中,由于对象存活率高,一般使用标记-清除或标记-整理算法。

理算法(Mark-Compact Algorithm)

  • 原理:标记阶段与标记-清除算法相同。整理阶段则不是直接清理未标记的对象,而是将所有存活的对象都移动到一端,然后直接清理掉边界以外的内存空间。
  • 优点:解决了内存碎片问题。
  • 缺点:效率上不如复制算法,且移动存活对象需要额外的时间和空间开销。
  1. 分代收集算法(Generational Collection Algorithm)
    • 原理:根据对象存活周期的不同将内存划分为几块,如新生代和老年代。在新生代中,由于对象存活率低,一般使用复制算法;而在老年代中,由于对象存活率高,一般使用标记-清除或标记-整理算法。

在实际应用中,JVM会根据具体的应用场景和需求,选择合适的垃圾回收算法或算法组合来进行垃圾回收。

  • 28
    点赞
  • 27
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值