JVM原理:JVM运行时内存模型(通俗易懂)

简介: JVM原理:JVM运行时内存模型(通俗易懂)

目录

前言

正文

虚拟机栈

局部变量表

操作数栈

动态链接

方法返回地址

本地方法栈

本地方法存在的意义

本地方法的调用

虚拟机堆

堆结构

Eden区

Survivor区域

老年代Old区

常用参数指令

方法区

常量池

运行时常量池

方法信息

类信息

域信息

JDK1.7前的方法区

JDK1.7时的方法区

JDK1.7后的方法区

程序计数器

总结

前言

做了几年开发,平时除了写代码造BUG和修复BUG之外,偶尔也会遇到反馈说程序较慢问题,要对程序性能排查与优化就得更深入学习,学习JVM可以帮助我们加深对JAVA的理解,让我们具备一定的性能排查与调优的能力,无非就是让程序别太卡或者别挂了,那挂了目前我遇到的主要是内存泄漏后导致OOM,或者内存分配不当,当机器内存不足时出发了Linux的保护机制,自动kill调占用内存最高的程序;所以我们要了解平时创建的对象、变量是如何存储的,这些知识点可以帮我们更好解决问题。

正文

文章中的JVM是以HotSpot为例:

JVM运行时数据区包括虚拟机栈、堆、方法区、程序计数器、本地方法栈。



虚拟机栈

虚拟机栈是线程私有的,也就是创建一个线程时,就会分配一个私有的栈,这个比较好理解,我们平时创建多线程时,每个方法里面的局部变量都是新的一份;


虚拟机栈维护了栈帧,每个方法都可以看作是一个栈帧。方法调用时会创建一个栈帧并且压栈,栈的特点是先进后出,比较符合我们程序的调用流程,比如A方法调用B方法,首先将创建栈帧A并压栈,当A方法中调用了B方法时创建栈帧B压栈,由于栈是先进后出,所以JVM在拿指令调用时,会先拿栈帧B进行调用。


自定义栈的大小

-Xss 512k


下面看下栈的结构:

ccd305842080346a8305ba7a11c6500.png

局部变量表

局部变量表保存着方法中定义的局部变量,如基本数据类型、指向引用类型的地址指针、以及 returnAddress 类型。


局部变量表的数据随着栈帧的创建而存在,随着栈帧的销毁而销毁,这个比较好理解,每个方法的局部变量都是独立的,当方法调用完成后,局部变量就消失了,所以局部变量表是线程安全的。


栈是有空间大小的,所以当调用的方法较多时,会创建大量的栈帧,而栈帧又是占用内存空间,当创建栈帧时内存不足会导致stackoverflow栈溢出,栈溢出不会导致整个程序挂掉,但会导致当前线程挂掉。所以平常写递归要留意,不能出现死循环,不然最后就会报stackoverflow;

操作数栈

操作数栈也是栈结构,先入后出。操作数栈就是在程序计算指令执行前后用于保存临时数据的空间;

举个例子吧:

int a=1;
int b=1;
int c=a+b;

程序要执行以上的计算时

1、执行存值指令,将a=1放入操作数栈中

2、执行存值指令,将b=1放入操作数栈中

3、执行相加指令,出栈拿到a、b的值进行运算

4、将运算结果存入操作数栈中

5、执行赋值指令,将结果赋值给c,也就是存入局部变量表里

动态链接

我们编写Person类后,将其编译成class字节码文件,使用类加载器将其加载到内存中,此时会将类名、修饰符、变量名、方法名、方法返回值等类信息存入到常量池中,这里我们简单理解为维护了key-value表,如#01:0X71;


当调用方法时,会将指向方法地址的符号#01进行解析替换,此时指向方法地址的值就不在是#01,而是0X71了;所以动态链接会在程序运行时将间接地址引用转为直接地址引用;

方法返回地址

当一个返回调用结束后,返回值给上个方法,而且上个方法接收到返回值之后继续往下进行。而方法返回地址就是记录上个栈帧的位置,此时的栈帧出栈后,将返回结果存入下个栈帧的操作数栈中,然后执行赋值指令将值存入下个栈帧的局部变量表中;


这里我们知道会有两种返回方式,一种是程序正常退出,此时会返回值(如果有定义的话);一种是当前方法执行过程中抛出异常中断了方法,此时不会返回值;

本地方法栈

本地方法栈和虚拟机栈结构差不多,区别是虚拟机栈是为java方法(java写的代码)而设立的,而本地方法栈是为java代码中的native修饰的代码而设立的;并不是所有的虚拟机都有本地方法栈,如Sun HotSpot虚拟机直接就把本地方法栈和虚拟机栈合二为一。

本地方法存在的意义

本地方法会调用C语言或者C++类库,比如内核为了安全,要调度硬件时,不会轻易给你权限进行调度,你需要调用内核提供好的函数库接口,由它先来进行安全检查,再帮你进行调度。另一方面操作底层使用java代码难度较高,直接调用现成的库效率更高;

本地方法的调用

上图案例有点像调用Thread的start方法开辟新线程来执行Runnable的run方法过程;

虚拟机堆

当我们new一个对象时,会开虚拟机堆中开辟一块内存空间,引用类型和数组的数据都是存放在堆中。它是JVM中内存最大的一块空间,所有线程都可以访问它,所以它是共享,非线程安全的。

堆结构

我们程序中经常导致性能问题的地方就是堆了,由于它的空间很大,而程序长期运行必然会产生很多没有用的垃圾对象,所以JVM使用垃圾回收器对没有被引用的对象进行回收,垃圾回收过程会STW,用户线程会挂起,所以就必须减少STW的时间,垃圾回收算法后续文章会进行讲解;

由于堆空间很大,垃圾回收是需要对整个空间进行扫描的(Full GC),为了让垃圾回收得更快,这里将堆空间划分为年轻代和老年代,年轻代包括eden区、survivor0、survivor1区域;

Eden区

new对象时,会在Eden区开辟内存空间,当Eden区满了之后,会触发年轻代垃圾回收YGC

Survivor区域

这里有两块Survivor是因为YGC垃圾回收采用复制算法,将没有被垃圾回收的对象拷贝到另一块空间;


如Student对象刚开始在Eden区,当触发YGC之后,由于Student对象还被其它对象引用,所以不会进行回收。由于是采用复制算法,会将没有回收的对象迁移到Survivor中,当第二次YGC之后,Student对象还未被回收,将survivor0迁移到survivor1中。第三次YGC时,又将survivor1中没有被回收迁移到survivor0中。


那这些没有被垃圾回收的对象,一直占用着空间,如果占用较多时,YGC的频率就会特别高,所以这里引入了年龄的概念。每次YGC后,未被回收的将年龄+1,当年龄到达一定阈值时(默认15),迁移到老年代中,降低YGC频率。如果Survivor区满了,则直接进入老年代。

老年代Old区

老年代主要存放YGC过程中,一直没有被回收的对象。当老年代满了之后会触发FULL GC,此时老年代和年轻代都会进行垃圾回收,这个时间是比较久的,所以我们程序优化中要尽量减低FULL GC发生的频率;

常用参数指令

设置堆最小值:-Xms

设置堆最大值:-Xmx

设置年轻代大小:-Xmn

设置Eden:survivor0:survivor1比例:-XX:SurvivorRatio

晋升老年代的动态年龄: -XX:MaxTenuringThreshold

方法区

类编译后被加载到内存中后,其类修饰符、类名、父类信息、方法名称、变量名称等存入方法区的常量池中,方法区是各个线程共享的,JVM关闭时该区域空间就会被释放;

常量池

如下案例:

public class HelloWorld {
    public static void main(String[] args) {
        System.out.println("hello world");
    }
}

JVM翻译成指令后:

// ===========================================类的描述信息===============================================
Classfile /xx/xx/xx/xx/HelloWorld.class
  Last modified 2021-10-12; size 569 bytes
  MD5 checksum 7f4f0fe4b6e6d04ddaf30401a7b04f07
  Compiled from "HelloWorld.java"
public class org.memory.jvm.t5.HelloWorld
  minor version: 0
  major version: 49
  flags: ACC_PUBLIC, ACC_SUPER
// ===========================================常量池===============================================
Constant pool:
   #1 = Methodref          #6.#20         // java/lang/Object."<init>":()V
   #2 = Fieldref           #21.#22        // java/lang/System.out:Ljava/io/PrintStream;
   #3 = String             #23            // hello world
   #4 = Methodref          #24.#25        // java/io/PrintStream.println:(Ljava/lang/String;)V
   #5 = Class              #26            // org/memory/jvm/t5/HelloWorld
   #6 = Class              #27            // java/lang/Object
   #7 = Utf8               <init>
   #8 = Utf8               ()V
   #9 = Utf8               Code
  #10 = Utf8               LineNumberTable
  #11 = Utf8               LocalVariableTable
  #12 = Utf8               this
  #13 = Utf8               Lorg/memory/jvm/t5/HelloWorld;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               HelloWorld.java
  #20 = NameAndType        #7:#8          // "<init>":()V
  #21 = Class              #28            // java/lang/System
  #22 = NameAndType        #29:#30        // out:Ljava/io/PrintStream;
  #23 = Utf8               hello world
  #24 = Class              #31            // java/io/PrintStream
  #25 = NameAndType        #32:#33        // println:(Ljava/lang/String;)V
  #26 = Utf8               org/memory/jvm/t5/HelloWorld
  #27 = Utf8               java/lang/Object
  #28 = Utf8               java/lang/System
  #29 = Utf8               out
  #30 = Utf8               Ljava/io/PrintStream;
  #31 = Utf8               java/io/PrintStream
  #32 = Utf8               println
  #33 = Utf8               (Ljava/lang/String;)V
// =======================================虚拟机中执行编译的方法===========================================
{
  public org.memory.jvm.t5.HelloWorld();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1                  // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 7: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   Lorg/memory/jvm/t5/HelloWorld;
  // main方法JVM指令码
  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    // main方法访问修饰符描述
    flags: ACC_PUBLIC, ACC_STATIC
    // main方法中的代码执行部分
    // ===============================解释器读取下面的JVM指令解释并执行===================================             
    Code:
      stack=2, locals=1, args_size=1
         // 从常量池中符号地址为 #2 的地方,先获取静态变量System.out
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         // 从常量池中符号地址为 #3 的地方加载常量 hello world
         3: ldc           #3                  // String hello world
         // 从常量池中符号地址为 #3 的地方获取要执行的方法描述,并执行方法输出hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         // main方法返回
         8: return
    // ==================================解释器读取上面的JVM指令解释并执行================================
      // 行号映射表
      LineNumberTable:
        line 9: 0
        line 10: 8
      // 局部变量表
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;
}

上面类经过编译成.class文件后,再然后将class文件反编译为JVM指令码后,我们可以看到常量池中记录了类的名称、方法名等符号引用,还有#23等字面量;


字面量包括:1.文本字符串 2.八种基本类型的值 3.被声明为final的常量等;如: #23 = Utf8 hello world

符号引用包括:1.类和方法的全限定名 2.字段的名称和描述符 3.方法的名称和描述符。如: #3 = String #23

运行时常量池

运行时常量池是当类被加载到内存时的版本,上述案例可以看到每个类都会存在常量池,所以当常量池被加载进内存,将数据放入运行时常量池之后,也是每个类都有一份,此时符号引用会被解析成直接引用;


如上述案例中的 #3 = String #23 会变成 #3 = String hello world

运行时常量池是一个统称,它包括了字符串常量池、类名称常量、方法名称常量、静态变量池、基础数据常量池等;

方法信息

存储方法要运行的指令、局部变量表、返回类型等信息

类信息

存储类的描述信息、枚举、接口、父类等信息

域信息

域名称、域类型、域修饰符(public,private,protected,static,final,volatile,transient的某个子集)

JDK1.7前的方法区

此类版本的方法区实现叫永久代,它存在于JVM内存之内,堆内存之外,这块区域由于有空间大小限制,当JVM加载太多的Class类时,垃圾回空间跟不上存入空间时,会报会报内存溢出异常OutOfMemoryError:PermGen space。


下面命令可用来指定空间大小:

  • -XX:PermSize来设置永久代初始分配空间。
  • -XX:MaxPermSize来设定永久代最大可分配空间。

880b10002605e87ca5661c342943c12.png

JDK1.7时的方法区

此版本将字符串常量和静态变量池放到了堆中,其它还是在永久代中


JDK1.7后的方法区

此版本的方法区,由metaSpace实现,存在于本地内存中,也就是JVM外的内存空间,它受限与物理内存,当物理内存不足时,会内存溢出;

程序计数器

每个线程都有一个程序计数器用于记录当前线程要执行的指令地址;由于程序运行一般是多线程,单CPU数量少于线程数量时,就会存在并发,每个线程会获得CPU的执行权。如执行线程A时,由于CPU分配的时间片到了,此时将当前线程挂起,线程B获取CPU的使用权,当线程B执行完毕后或者时间片用完后,需要恢复线程A的执行,此时如果想CPU从上次执行点开始往下执行的话,就需要记录之前的指令;


程序计数器记录着当前线程要执行的下条执行,当CPU从程序计数器拿到指令的引用之后,需要将下条指令的引用地址维护进来。如果是调用native方法时,程序计数器的记录值就为空。

总结

JVM理论知识点很多,平常如果很少实操这些东西是很难真正懂的,中文社区的JVM文章五花八门,很多写的都不一样。这篇文章有些按自己的理解不一定准确,但是我感觉这些知识点有利于我们解决问题也就够了,如有错误望指出修改。


内存模型了解之后,我们知道了数据存哪里了,那内存只有那么大,而程序一直运行,会不断占用内存空间,程序又是如何保证内存一直能放得下数据,那就得学习垃圾回收了。


目录
相关文章
|
22天前
|
存储 安全 Java
jvm 锁的 膨胀过程?锁内存怎么变化的
【10月更文挑战第3天】在Java虚拟机(JVM)中,`synchronized`关键字用于实现同步,确保多个线程在访问共享资源时的一致性和线程安全。JVM对`synchronized`进行了优化,以适应不同的竞争场景,这种优化主要体现在锁的膨胀过程,即从偏向锁到轻量级锁,再到重量级锁的转变。下面我们将详细介绍这一过程以及锁在内存中的变化。
33 4
|
22天前
|
缓存 算法 Java
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
这篇文章详细介绍了Java虚拟机(JVM)中的垃圾回收机制,包括垃圾的定义、垃圾回收算法、堆内存的逻辑分区、对象的内存分配和回收过程,以及不同垃圾回收器的工作原理和参数设置。
45 4
JVM知识体系学习六:JVM垃圾是什么、GC常用垃圾清除算法、堆内存逻辑分区、栈上分配、对象何时进入老年代、有关老年代新生代的两个问题、常见的垃圾回收器、CMS
|
22小时前
|
算法 JavaScript 前端开发
新生代和老生代内存划分的原理是什么?
【10月更文挑战第29天】新生代和老生代内存划分是JavaScript引擎为了更高效地管理内存、提高垃圾回收效率而采用的一种重要策略,它充分考虑了不同类型对象的生命周期和内存使用特点,通过不同的垃圾回收算法和晋升机制,实现了对内存的有效管理和优化。
|
22天前
|
存储 SQL 小程序
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
这篇文章详细介绍了Java虚拟机(JVM)的运行时数据区域和JVM指令集,包括程序计数器、虚拟机栈、本地方法栈、直接内存、方法区和堆,以及栈帧的组成部分和执行流程。
24 2
JVM知识体系学习五:Java Runtime Data Area and JVM Instruction (java运行时数据区域和java指令(大约200多条,这里就将一些简单的指令和学习))
|
12天前
|
存储 算法 Java
聊聊jvm的内存结构, 以及各种结构的作用
【10月更文挑战第27天】JVM(Java虚拟机)的内存结构主要包括程序计数器、Java虚拟机栈、本地方法栈、Java堆、方法区和运行时常量池。各部分协同工作,为Java程序提供高效稳定的内存管理和运行环境,确保程序的正常执行、数据存储和资源利用。
37 10
|
11天前
|
存储 算法 Java
Java虚拟机(JVM)的内存管理与性能优化
本文深入探讨了Java虚拟机(JVM)的内存管理机制,包括堆、栈、方法区等关键区域的功能与作用。通过分析垃圾回收算法和调优策略,旨在帮助开发者理解如何有效提升Java应用的性能。文章采用通俗易懂的语言,结合具体实例,使读者能够轻松掌握复杂的内存管理概念,并应用于实际开发中。
|
21天前
|
存储 监控 算法
JVM调优深度剖析:内存模型、垃圾收集、工具与实战
【10月更文挑战第9天】在Java开发领域,Java虚拟机(JVM)的性能调优是构建高性能、高并发系统不可或缺的一部分。作为一名资深架构师,深入理解JVM的内存模型、垃圾收集机制、调优工具及其实现原理,对于提升系统的整体性能和稳定性至关重要。本文将深入探讨这些内容,并提供针对单机几十万并发系统的JVM调优策略和Java代码示例。
45 2
|
22天前
|
Arthas 监控 Java
JVM知识体系学习七:了解JVM常用命令行参数、GC日志详解、调优三大方面(JVM规划和预调优、优化JVM环境、JVM运行出现的各种问题)、Arthas
这篇文章全面介绍了JVM的命令行参数、GC日志分析以及性能调优的各个方面,包括监控工具使用和实际案例分析。
37 3
|
19天前
|
存储 Kubernetes 架构师
阿里面试:JVM 锁内存 是怎么变化的? JVM 锁的膨胀过程 ?
尼恩,一位经验丰富的40岁老架构师,通过其读者交流群分享了一系列关于JVM锁的深度解析,包括偏向锁、轻量级锁、自旋锁和重量级锁的概念、内存结构变化及锁膨胀流程。这些内容不仅帮助群内的小伙伴们顺利通过了多家一线互联网企业的面试,还整理成了《尼恩Java面试宝典》等技术资料,助力更多开发者提升技术水平,实现职业逆袭。尼恩强调,掌握这些核心知识点不仅能提高面试成功率,还能在实际工作中更好地应对高并发场景下的性能优化问题。
|
3月前
|
存储 编译器 C语言
【C语言篇】数据在内存中的存储(超详细)
浮点数就采⽤下⾯的规则表⽰,即指数E的真实值加上127(或1023),再将有效数字M去掉整数部分的1。
325 0