JVM详解——内存结构

Posted 耶瞳

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JVM详解——内存结构相关的知识,希望对你有一定的参考价值。

如果有兴趣了解更多相关内容,欢迎来我的个人网站看看:耶瞳空间

一:JVM基本介绍

JVM是Java Virtual Machine(Java虚拟机)的缩写,JVM是一种用于计算设备的规范,具体实现有很多,以下内容如果不额外声明,默认是HotSpot JVM。JVM它是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。Java虚拟机包括一套字节码指令集、一组寄存器、一个栈、一个垃圾回收堆和一个存储方法域。JVM屏蔽了与具体操作系统平台相关的信息,使Java程序只需生成在Java虚拟机上运行的目标代码(字节码),就可以在多种平台上不加修改地运行。JVM在执行字节码时,实际上最终还是把字节码解释成具体平台上的机器指令执行。简单来说JVM就是用来解析和运行Java程序的。

JVM的组成部分:

其中JVM的内存结构十分重要,内存结构中的各个区的详细情况可参照下图(JDK8及以后的版本),下文也会对各个结构做详细说明。

JVM的好处:

  • 一次编写,到处运行
  • 自动内存管理,垃圾回收功能
  • 数组下标越界检查
  • 多态

JDK、JRE、JVM的关系如下:

二:JVM内存结构

2.1:程序计数器

程序计数器的英文全称是Program Counter Register,又叫程序计数寄存器。Register的命名源于CPU的寄存器,寄存器存储指令相关的现场信息。CPU只有把数据装载到寄存器才能够运行。我们的程序计数器是Java对物理硬件的屏蔽和抽象,他在物理上是通过寄存器来实现的。寄存器可以说是整个CPU组件里读取速度最快的一个单元,因为读取/写指令地址这个动作是非常频繁的。所以Java虚拟机在设计的时候就把CPU中的寄存器当做了程序计数器,用他来存储地址,将来去读取这个地址。

每个线程都有自己的程序计数器,这样当线程执行切换的时候就可以在上次执行的基础上继续执行。仅仅从一条线程线性执行的角度而言,它的作用可以看作是当前线程所执行的字节码的行号指示器。在虚拟机的概念模型里字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。

特点:

  • 程序计数器是线程私有的,这是为了能够准确记录各个线程正在执行的当前字节码指令地址,最好的办法就是为每一个线程分配一个程序计数器。每个线程在创建后,都会产生自己的程序计数器和栈帧,程序计数器在各个线程之间互不影响
  • 执行java方法时,程序计数器是有值的,执行native本地方法时,程序计数器的值为空(Undefined),因为native方法是java通过JNI直接调用本地C/C++库,可以近似的认为native方法相当于C/C++暴露给java的一个接口,java通过调用这个接口从而调用到C/C++方法。由于该方法是通过C/C++而不是java进行实现。那么自然无法产生相应的字节码,并且C/C++执行时的内存分配是由自己语言决定的,而不是由JVM决定的
  • 此内存区域是唯一一个在Java虚拟机规范中没有规定任何内存溢出情况的区域,因为程序计数器存储的是字节码文件的行号,而这个范围是可知晓的,在一开始分配内存时就可以分配一个绝对不会溢出的内存

2.2:虚拟机栈

java虚拟机栈(Java Virtual Machine Stacks)是每个线程运行时所需的内存。每个栈由多个栈帧(Stack Frame)组成,每个方法执行都会创建一个栈帧,对应着该方法调用时所占用的内存,栈帧包含局部变量表、操作数栈、动态连接、方法出口等,下面会详细讲。每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法。

2.2.1:栈与栈帧

每一个方法的开始执行到执行完成,都对应着一个栈帧在虚拟机中从入栈到出栈的过程。栈顶的栈帧就是当前执行方法的栈帧,称为当前栈帧(Current Stack Frame),这个栈帧关联的方法被称为当前方法(Current Method)。当这个方法调用其他方法的时候就会创建一个新的栈帧,这个新的栈帧会被放到虚拟机栈的栈顶,变为当前的活动栈,只有这时该栈帧的局部变量才能被使用。当这个栈帧所有指令都完成的时候,这个栈帧就会被移除,之前的栈帧变为活动栈,前面移除栈帧的返回值变为这个栈帧的一个操作数。

栈帧包含局部变量表、操作数栈、动态连接、方法出口等数据:

  • 局部变量表(Local Variable Table):局部变量表是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在java编译成class文件的时候,就在方法的Code属性的max_locals数据项中确定该方法需要分配的最大局部变量表的容量
    • 局部变量表的容量以变量槽(Slot)为最小单位,32位虚拟机中一个Slot可以存放32位(4字节)以内的数据类型(boolean、byte、char、short、int、float、reference和returnAddress)
    • 对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的Slot空间,也就是相当于把一次long和double数据类型读写分割成为两次32位读写
    • 对于reference类型,虚拟机规范没有明确说明它的长度,但一般来说,虚拟机实现至少都应当能从此引用中直接或者间接地查找到对象在Java堆中的起始地址索引和方法区中的对象类型数据
    • 为了尽可能节省栈帧空间,Slot是可以重用的,也就是说当程序计数器的指令指已经超出了某个变量的作用域(执行完毕),那这个变量对应的Slot就可以交给其他变量使用。但是这个机制会影响到GC,比如说假设有大方法占用较多的Slot,执行完该方法的作用域后没有对Slot赋值或者清空设置null值,垃圾回收器便不能及时的回收该内存。
    • 系统不会为局部变量赋予初始值(实例变量和类变量都会被赋予初始值)。也就是说不存在类变量那样的准备阶段
  • 操作数栈:每一个独立的栈帧中除了包含局部变量表以外,还包含一个后进先出(Last-In-First-Out)的操作数栈,也可以称之为表达式栈(Expression Stack)。当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作(例如:在做算术运算的时候是通过操作数栈来进行的,又或者在调用其它方法的时候是通过操作数栈来进行参数传递的)
    • 操作数栈和局部变量表一样,在编译时期就已经确定了该方法所需要分配的局部变量表的最大容量,并保存在方法的Code属性中
    • 每一个操作数栈都会拥有一个明确的栈深度用于存储数值,32位数据类型所占的栈容量为1,64位数据类型占用的栈容量为2
    • 操作数栈和局部变量表在访问方式上存在着较大差异,操作数栈并非采用访问索引的方式来进行数据访问的,而是通过标准的入栈和出栈操作来完成一次数据访问。
    • 在概念模型里,栈帧之间是应该是相互独立的,不过大多数虚拟机都会做一些优化处理,使两个栈帧的局部变量表和操作数栈之间有部分重叠,这样在进行方法调用的时候可以直接共用参数,而不需要做额外的参数复制等工作
  • 动态连接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中方法的符号引用为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用(静态方法,私有方法等),这种转化称为静态解析,另一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
  • 方法返回地址:当一个方法开始执行后,只有下列两种方式可以退出这个方法。无论使用哪种方式退出方法,都要返回到方法被调用的位置,程序才能继续执行。方法返回时可能会在栈帧中保存一些信息,用来恢复上层方法的执行状态。一般方法正常退出的时候,调用者的pc计数器的值可以作为返回地址,帧栈中很有可能会保存这个计数器的值作为返回地址。方法退出的过程就是栈帧在虚拟机栈上的出栈过程,因此退出时的操作可能有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者的操作数栈,调整程序计数器的值指向调用该方法的后一条指令。
    • 执行引擎遇到任意一个方法返回的字节码指令:传递给上层的方法调用者,是否有返回值和返回值类型将根据遇到何种方法来返回指令决定,这种退出的方法称为正常完成出口
    • 方法执行过程中遇到异常:无论是java虚拟机内部产生的异常还是代码中throw出的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出的方式称为异常完成出口,一个方法若使用该方式退出,是不会给上层调用者任何返回值的。

栈顶缓存技术(Top Of Stack Cashing):基于栈式架构的虚拟机所使用的零地址指令更加紧凑,但完成一项操作的时候必然需要使用更多的入栈和出栈指令,这同时也就意味着将需要更多的指令分派(instruction dispatch)次数和内存读/写次数。由于操作数是存储在内存中的,因此频繁地执行内存读/写操作必然会影响执行速度。为了解决这个问题,HotSpot JVM的设计者们提出了栈顶缓存(Tos,Top-of-Stack Cashing)技术,将栈顶元素全部缓存在物理CPU的寄存器(寄存器的主要优点:指令更少,执行速度快)中,以此降低对内存的读/写次数,提升执行引擎的执行效率。

2.2.2:栈的问题辨析

  • 垃圾回收是否涉及栈内存:不涉及,栈内存无非是一次次方法调用所产生的栈帧内存,而栈帧内存会随着方法调用结束被弹出栈,也就是被自动回收掉,所以不需要垃圾回收来管理栈内存。垃圾回收主要是回收堆内存的无用对象。
  • 栈内存的分配是越大越好吗:不是,栈内存越大,会导致最大线程数变少,因为物理内存的大小是一定的。并且栈内存越大不会提高单个线程的运行效率,只会增加方法调用或潜嵌套调用的数量。因此只要程序编写正常的情况下不会导致StackOverflowError,就无需调大栈内存
  • 方法内的局部变量是否是线程安全:
    • 如果方法内的局部变量没有逃离方法的作用范围,则是线程安全的
    • 如果是局部变量引用了对象,并逃离方法的作用范围,则需要考虑线程安全

对于栈的线程安全问题,有以下情况需要说明:

/**
 * @author eyesYeager
 * @date 2023/2/4 19:37
 */
public class Demo 
  public static void main(String[] args) 
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < 10; i++) 
      new Thread(() -> m(sb)).start();
    
  

  // 存在线程安全问题,因为sb是对象,存储在堆中,可以被其他线程访问
  public static void m(StringBuilder sb) 
    sb.append(1);
    System.out.print("");  // 为了让append执行间距拉大,使结果更明显
    sb.append(2);
    sb.append(3);
    sb.append("  ");
    System.out.println(sb);
  

然后还有这种情况,下面在这里插入代码片这个函数即使是多线程调用,每个线程拿到的StringBuilder都会是"123",但是它并不是线程安全的,需要说明的是,线程安全问题并不只是说多个线程调用这个方法不会出问题。一个程序在多线程环境下执行可能出现错误,那这个程序就是线程不安全。如果下面的方法在某程序中,它的方法返回的值可以在其他方法内被多线程访问,就认为它是线程不安全的。

  public static StringBuilder m() 
    StringBuilder sb = new StringBuilder();
    sb.append(1);
    System.out.print("");
    sb.append(2);
    sb.append(3);
    sb.append("  ");
    return sb;
  

2.2.3:内存溢出

JVM的内存溢出有以下几种情况:

  • 栈溢出(StackOverflowError)
  • 堆溢出(OutOfMemoryError:java heap space)
  • 永久代溢出(OutOfMemoryError:PermGen space)
  • OutOfMemoryError:unable to create native thread

这里主要谈栈溢出(StackOverflowError),《Java 虚拟机规范》中文版描述如下:如果线程请求的栈容量超过栈允许的最大容量的话,Java虚拟机将抛出一个StackOverflow异常;如果Java虚拟机栈可以动态扩展,并且扩展的动作已经尝试过,但是无法申请到足够的内存去完成扩展,或者在新建立线程的时候没有足够的内存去创建对应的虚拟机栈,那么Java虚拟机将抛出一个OutOfMemory 异常。

出现栈溢出的情况,一般情况下是程序错误所致的,比如死递归,代码如下:

/**
 * @author eyesYeager
 * @date 2023/2/4 21:16
 */

public class Demo 
  private static int count = 0;

  public static void m() 
    count++;
    m();
  

  public static void main(String[] args) 
    try 
      m();
     catch (Throwable e) 
      System.out.println(count);
      e.printStackTrace();
    
  

可以看到当第33917次递归的时候,出现了栈溢出的情况。

我们在运行程序的时候,可以通过设置-Xss来修改栈的最大容量(idea各版本界面不一样,总之在VM options框里填):

修改成512k之后,结果如下:

2.2.4:线程运行诊断

对于线程运行诊断,有以下两个案例:

  • CPU占用高
  • 迟迟得不到结果

1. CPU占用高
基本步骤为:

  • top定位是哪个进程对CPU的占用过高
  • ps H -eo pid,tid,%cpu | grep 进程id (用ps命令进一步定位是哪个线程引起的CPU占用过高)
  • jstack 进程id (jstack是JVM自带的Java堆栈跟踪工具,用于打印出给定的java进程ID、core file、远程调试服务的Java堆栈信息)

演示如下:


jstack获取到的线程id是十六进制的,而ps命令获取到的是十进制的,因此需要先做个转换,再去jstack找对应线程。

2. 迟迟得不到结果
这里当然不包括网络不好之类的情况,一般出现该情况都是因为出现线程死锁。使用jstack 进程id查看进程信息:

2.3:本地方法栈

本地方法栈(Native Method Stacks)与虚拟机栈所发挥的作用是非常相似的,其区别只是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则是为虚拟机使用到的本地(Native)方法服务,即非java方法,一般是使用C/C++语言进行实现。并且jvm规范中对本地方法栈中方法使用的语言、方式、数据结构并没有任何强制规定,所以具体虚拟机可以根据需求自由的去实现它。甚至HotSpot虚拟机直接把本地方法栈和虚拟机栈合二为一。

java虚拟机调用本地方法时,需要给这个本地方法提供的内存空间。本地方法翻译过来Native Method,在这里是指不是由java代码编写的方法,因为java代码有一定的限制,java有时候不能和操作系统底层交互,所以就需要使用C或者C++ 等一些别的语言和操作系统进行交互,然后由java代码调用这些封装好的本地方法接口间接和操作系统进行交互。最典型的使用地方就是所有的Object类,Object中clone方法声明就是native,会发现在java源码中所有的native方法是没有方法实现的,它所有的实现都是由其它语言进行编写的比如C语言,java通过clone方法接口去进行调用C和C++的方法实现。

其特点如下:

  • 本地方法栈是线程私有的
  • 允许被实现成固定或者是可动态扩展的内存大小(在内存溢出方面和虚拟机栈相同)
    • 如果线程请求分配的栈容量超过本地方法栈允许的最大容量,Java虚拟机将会抛出StackOverflowError异常
    • 如果本地方法栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程时没有足够的内存去创建对应的本地方法栈,那么Java虚拟机将会抛出一个OutOfMemoryError异常
  • native方法一般由C/C++实现,它的具体做法是Native Method Stack中登记native方法,在Execution Engine执行时加载本地方法库。

2.4:堆

2.4.1:堆的基本概念

《Java虚拟机规范》中对Java堆的描述是:所有的对象实例以及数组都应当在运行时分配在堆上。但实际上并不能说所有,只能说大部分的对象实例以及数组。因为JVM为了提高性能,引入了逃逸分析技术,逃逸分析技术会判断方法中new的对象是否发生逃逸,如果没有发生逃逸,就可以对该对象进行栈上分配,有关逃逸分析后面会详细描述。

一个JVM实例只有一个堆内存,堆也是Java内存管理的核心区域,堆在JVM启动的时候创建,其空间大小也被创建,物理上不需要连续但逻辑上连续,是JVM中最大的一块内存空间。所有线程共享Java堆,在方法结束后,堆中的对象不会马上删除,仅仅在垃圾收集的时候被删除,堆是GC(垃圾收集器)执行垃圾回收的重点区域。不马上删除也是为了效率最大化,因为GC会影响用户线程,频繁GC会导致程序性能下降,所以应该尽量减少GC频率。

我们可以看堆内存是如何划分的,先声明一下:

  • 新生区、新生代、年轻代是一个意思
  • 养老区、老年区、老年代是一个意思
  • 永久区、永久代是一个意思

Java 7及之前堆内存逻辑上分为三部分:新生代+老年代+永久代。但在Java 8版本废弃了永久代,替代的是元空间(MetaSpace),元空间与永久代类似,都是方法区的实现。方法区会在后面详细介绍。再次声明,本文没有额外声明说的都是HotSpot JVM,《Java虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。而永久代就是HotSpot JVM在Java 8之前对方法区的实现,其他版本的JVM并没有永久代。

  • JVM内存划分为堆内存和非堆内存,堆内存分为年轻代(Young Generation)、老年代(Old Generation),非堆内存就一个永久代(Permanent Generation)/元空间(MetaSpace)
  • 年轻代又分为Eden和Survivor区。Survivor区由FromSpace和ToSpace组成。Eden区占大容量,Survivor两个区占小容量,默认比例是8:1:1。Eden区占比大是由于每次垃圾回收时大部分对象都被回收掉,只有少数的需要移到S0区。
  • 堆内存用途:存放的是对象,垃圾收集器就是收集这些对象,然后根据GC算法回收。
  • 非堆内存用途:比如说方法区(永久代/元空间),存储程序运行时长期存活的对象,比如类的元数据、方法、常量、属性等。
  • 移除永久代原因:为融合HotSpot JVM与JRockit VM(新JVM技术)而做出的改变,因为JRockit没有永久代。永久代与元空间最大区别是,元空间并不在JVM中,而是使用本地内存。以为如果在JVM中,就会有内存上线,容易遇到内存溢出问题,而使用本地就可以加载更多的类。

需要注意的是,对于永久代和元空间在不在堆上,网上各个资料都不太统一或者说比较模糊。我查了一下Java虚拟机规范手册,可以看到,方法区在逻辑上是属于堆的。但是物理上在不在没有说明,因此得看各种虚拟机的具体实现。可以肯定的是,对于HotSpot JVM来说,永久代物理上是在堆中的,但元空间是不在的,元空间物理上是在本地内存中。

对象分配过程:

  • 新生成的对象首先放到年轻代Eden区,当Eden空间满了,触发Minor GC,将不再被其他对象所引用的对象进行销毁,再加载新的对象放到Eden区。大部分对象会在Minor GC时被销毁
  • Eden中Minor GC存活下来的对象移动到Survivor0区
  • Survivor0区满后触发执行Minor GC,Survivor0区存活对象移动到Suvivor1区,这样保证了一段时间内总有一个survivor区为空
  • 经过多次Minor GC仍然存活的对象移动到老年代,具体几次Minor GC后放到老年代可以通过参数-XX:MaxTenuringThreshold=<N>设置,默认是15次。老年代存储长期存活的对象,占满时会触发Major GC=Full GC,GC期间会停止所有线程等待GC完成,所以对响应要求高的应用尽量减少发生Major GC,避免响应超时。

为什么分代:将对象根据存活概率进行分类,对存活时间长的对象,放到固定区,从而减少扫描垃圾时间及GC频率。针对分类进行不同的垃圾回收算法,对算法扬长避短。争取最大的效率。

为什么survivor分为两块相等大小的幸存空间:主要为了解决碎片化。如果内存碎片化严重,也就是两个对象占用不连续的内存,已有的连续内存不够新对象存放,就会触发GC。

JVM堆内存常用参数:

参数描述
-Xms堆内存初始大小,单位m、g
-Xmx(MaxHeapSize)堆内存最大允许大小,一般不要大于物理内存的80%
-XX:PermSize非堆内存初始大小,一般应用设置初始化200m,最大1024m就够了
-XX:MaxPermSize非堆内存最大允许大小
-XX:NewSize(-Xns)年轻代内存初始大小
-XX:MaxNewSize(-Xmn)年轻代内存最大允许大小,也可以缩写
-XX:SurvivorRatio=8年轻代中Eden区与Survivor区的容量比例值,默认为8,即8:1
-Xss堆栈内存大小

在生产环境中,往往需要根据硬件情况调整堆内存初始大小(-Xms)与堆内存最大允许大小(-Xmx),一般推荐将两者调整的一样大(尚硅谷宋红康说的,不是我说的),因为堆的扩容与回收,是需要GC的,频繁GC会影响程序性能。

TLAB(thread Local Allocation Buffer),即线程本地分配缓存区。TLAB在Eden区,是JVM为每个线程分配的一个私有缓存区域。多线程同时分配内存时,使用TLAB可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称为快速分配策略。

  • 为什么会有TLAB:堆区是线程共享区域,任何线程都可以访问到堆区中的共享数据,但由于对象实例的创建在JVM中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。为避免多个线程操作同一地址,需要使用加锁等机制,进而影响分配速度。为了避免这种情况,就有了TLAB。
  • TLAB占用的是Eden区并且基本上大小只是Eden的1%(可以通过-XX:TLABWasteTargetPercent自定义),因此它占用的内存很少,那肯定就会面临TLAB中剩余内存比申请对象小的问题,比如100kb的TLAB,装了80KB,又来了个30KB的对象,此时TLAB放不下,所以JVM做了以下处理,设置了最大浪费空间。当剩余的空间小于最大浪费空间,那该TLAB属于的线程再重新向Eden区申请一个TLAB空间。进行对象创建,如果还是空间不够,就直接在Eden区创建。当剩余的空间大于最大浪费空间时,直接去Eden区创建

2.4.2:内存溢出

使用以下程序进行演示(此时-Xmx为1m):

/**
 * @author eyesYeager
 * @date 2023/2/4 21:16
 */
public class Demo 
  public static void main(String[] args) 
    int i = 0;
    try 
      List<String> list = new ArrayList<>();
      String a = "hello";
      while (true) 
        i++;
        list.add(a);
        a += a;
      
     catch (Throwable e) 
      System.out.println(i);
      e.printStackTrace();
    
  

调整-Xmx为100m,再次运行:


2.4.3:内存诊断

堆内存诊断的一些工具:

  • jps工具:查看当前系统中有哪些java进程
  • jmap工具:查看某时刻堆内存占用情况
  • jconsole工具:图形界面的,多功能的监测工具,可以连续监测

这里就不详细介绍了,有兴趣可以参考下列博客:

  • Java的jmap命令使用详解:https://cloud.tencent.com/developer/article/1985770
  • JVM 监控工具——jconsole:https://www.cnblogs.com/virgosnail/p/11552363.html

2.4.4:逃逸分析

逃逸分析(Escape Analysis),是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,JVM能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

如果没有逃逸分析,那么对象只能分配到堆中,当我们在一个方法体内new一个对象,并且该对象在方法执行过程中未发生逃逸,那么按照JVM的机制,首先会在堆内存创建类的实例,然后将此对象的引用压入调用栈,继续执行。但如果采用逃逸分析对JVM进行优化。即针对栈的重新分配方式,首先找出未逃逸的变量,将该变量直接存到栈里,无需进入堆,分配完成后,继续调用栈内执行,最后线程执行结束,栈空间被回收,局部变量也被回收了。这种操作方式减少了堆中对象的分配和销毁,从而优化性能。

逃逸方式分为两种:

  • 方法逃逸:在一个方法体内,定义一个局部变量,而它可能被外部方法引用,比如作为调用参数传递给方法,或作为对象直接返回。或者,可以理解成对象跳出了方法。
  • 线程逃逸:这个对象被其他线程访问到,比如赋值给了实例变量,并被其他线程访问到了。对象逃出了当前线程。

逃逸分析的好处:如果一个对象不会在方法体内,或线程内发生逃逸(或者说是通过逃逸分析后,使其未能发生逃逸)

  • 栈上分配:一般情况下,不会逃逸的对象所占空间比较大,如果能使用栈上的空间,那么大量的对象将随方法的结束而销毁,减轻了GC压力
  • 同步消除:如果定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行
  • 标量替换:Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们可以称为标量。相对的,如果一个数据可以继续分解,那它称为聚合量,Java中最典型的聚合量是对象。如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了

2.5:方法区

2.5.1:方法区介绍

方法区用于存储已被已经被虚拟机加载的类型信息、常量、静态变量、即时编译器编译后的代码等等。示意图如下,下面的图片显示的是JVM加载类的时候,方法区存储的信息:

  • 类型信息
    • 类型的全限定名
    • 超类的全限定名
    • 直接超接口的全限定名
    • 类型标志(该类是类类型还是接口类型)
    • 类的访问描述符(public、private、default、abstract、final、static)
  • 类型的常量池:存放该类型所用到的常量的有序集合,包括直接常量(如字符串、整数、浮点数的常量)和对其他类型、字段、方法的符号引用。常量池中每一个保存的常量都有一个索引,就像数组中的字段一样。因为常量池中保存着所有类型使用到的类型、字段、方法的字符引用,所以它也是动态连接的主要对象(在动态链接中起到核心作用)
  • 字段信息(该类声明的所有字段,又叫域信息)
    • 字段修饰符(public、protect、private、default)
    • 字段的类型
    • 字段名称
  • 方法信息:方法信息中包含类的所有方法,每个方法包含以下信息:
    • 方法修饰符
    • 方法返回类型
    • 方法名
    • 方法参数个数、类型、顺序等
    • 方法字节码
    • 操作数栈和该方法在栈帧中的局部变量区大小
    • 异常表
  • 类变量(静态变量):指该类所有对象共享的变量,即使没有任何实例对象时,也可以访问的类变量。它们与类进行绑定。
  • 指向类加载器的引用:每一个被JVM加载的类型,都保存这个类加载器的引用,类加载器动态链接时会用到。
  • 指向Class实例的引用:类加载的过程中,虚拟机会创建该类型的Class实例,方法区中必须保存对该对象的引用。通过Class.forName(String className)来查找获得该实例的引用,然后创建该类的对象。
  • 方法表:为了提高访问效率,JVM可能会对每个装载的非抽象类,都创建一个数组,数组的每个元素是实例可能调用的方法的直接引用,包括父类中继承过来的方法。这个表在抽象类或者接口中是没有的,类似C++虚函数表vtbl。
  • 运行时常量池:这个后面会详细介绍。


方法区(Method Area)的基本理解:

  • 方法区与堆一样,是各个线程共享的内存区域
  • 方法区在JVM启动的时候创建,并且它的实际的物理内存空间和堆一样都可以是不连续的
  • 方法区的大小,跟堆空间一样,可以选择固定大小或者可扩展
  • 方法区的大小决定了系统可以保存多少个类,如果系统定义了太多的类,导致方法区的溢出,虚拟机同样会抛出内存溢出错误:java.lang.OutOfMemoryError:PermGen space或者java.lang.OutOfMemoryError:Metaspace
  • 关闭JVM就会释放这个内存区域

《Java 虚拟机规范》中明确说明:“尽管方法区在逻辑上是堆的一部分,但如果只是想简单地实现它,可以选择不进行垃圾回收或对其进行压缩。”可以看出虚拟机规范只规定逻辑上方法区是堆的一部分,并未约束物理层面的实现,对于HotSpot JVM而言,在Java8以前方法区被称为永久代,使用的就是堆内存

但是从Java8开始,HotSpot JVM为了融合JRockit VM而废除了永久代,改为元空间,此时方法区使用的是本地内存,而非堆内存。其大小可以通过-XX:MetaspaceSize=N-XX:MaxMetaspaceSize=N设置,MaxMetaspaceSize默认是-1,此时元空间最大内存为本地内存。如果超出限制,也会报OOM。

栈、堆、方法区的交互关系:

2.5.2:运行时常量池

在上图中我们可以看到,Class文件中除了有类的版本、字段、方法、接口等描述信息外, 还有一项信息是常量池,用于存放编译器生成的各种静态常量(或者叫做字面常量/字面量)和符号引用,这部分内容被类加载后进入方法区的运行时常量池中存放。 运行时常量池相对于Class文件常量池的另外一个特征具有动态性,可以在运行期间将新的常量放入池中(典型的如String类的intern方法)。

运行时常量池和class文件的常量池是一一对应的,它就是class文件的常量池来构建的。运行时常量池中有两种类型,分别是symbolic references符号引用和static constants静态常量,其中静态常量不需要后续解析,而符号引用需要进一步进行解析处理。

  • 静态常量(字面量):指由字母、数字等构成的字符串或者数字常量。静态常量只可以右值出现,所谓的右值就是指等号右边的值,比如:int a=1这里的a为左值,1为右值,在这个例子中1就是静态常量
  • 符号引用:符号引用是编译原理中的概念,是相对于直接引用来说的,主要包括下列三类常量。在int a = 1的例子中,a是字段名称,所以是符号引用。
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

最开始这些静态常量和符号引用都是保存在常量池中,他们都是静态信息。当程序运行时被加载到内存后,这些符号才有对应的内存地址信息。这些常量一旦被转入内存就会变成运行时常量池。运行时常量池在方法区中。

再说的明白一些,到底什么是常量池,什么是运行时常量池?Math类,生成的对应的class文件,class文件中定义了一个常量池集合,这个集合用来存储一系列的常量。这时候的常量池是静态常量池。

当程序运行起来,会将类信息加载到方法区中,并为这些常量分配内存地址,这时原来的静态常量池就转变成了运行时常量池。符号引用在程序运行以后被加载到内存中,原来的代码就会被分配内存地址,引用这个对象的地方就会变成直接引用,也就是我们说的动态链接了。

可以用做个演示,代码如下:

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

我们让它生成字节码,并用jdk自带的工具javap将字节码反编译,可以看到下面分别是类的基本信息、常量池与类方法定义:

F:\\language\\java\\code\\code\\base>javap -v target/classes/Demo.class
Classfile /F:/language/java/code/code/base/target/classes/Demo.class
  Last modified 2023-2-11; size 515 bytes
  MD5 checksum 31f60b2a177f239676128ac620cd0d71
  Compiled from "Demo.java"
public class Demo
  minor version: 0
  major version: 52
  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            // Demo
   #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               LDemo;
  #14 = Utf8               main
  #15 = Utf8               ([Ljava/lang/String;)V
  #16 = Utf8               args
  #17 = Utf8               [Ljava/lang/String;
  #18 = Utf8               SourceFile
  #19 = Utf8               Demo.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               Demo
  #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 Demo();
    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 6: 0
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       5     0  this   LDemo;

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=1, args_size=1
         0: getstatic     #2                  // Field java/lang/System.out:Ljava/io/PrintStream;
         3: ldc           #3                  // String hello world
         5: invokevirtual #4                  // Method java/io/PrintStream.println:(Ljava/lang/String;)V
         8: return
      LineNumberTable:
        line 8: 0
        line 9: 8
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0       9     0  args   [Ljava/lang/String;

SourceFile: "Demo.java"


3: ldc #3为例,ldc表示加载参数,那怎么知道它加载哪个参数呢?可以看到后面有个#3,这就是指向常量池的一个参数,我们回到常量池进行查表,可以看到:



其他指令同理,解释器执行时都会去常量池查表。

以上演示都是常量池,然后回到运行时常量池,常量池是class文件中的,当该类被加载,他的常量池信息就会放到运行时常量池,并把里面的符号地址变为真实地址。

2.5.3:StringTable

StringTable是JVM里的一个重要的部分。在了解前可以先看看下面这段代码的运行结果:

public class Demo 
  public static void main(String[] args) 
    String a = "a";
    String b = "b";
    String s0 = "ab";
    String s1 = "a" + "b";
    String s2 = a + b;
    String s3 = new String("a") + new String("b");
    String s4 = s0.intern();

    System.out.println(s0 == s1);  // true
    System.out.println(s0 == s2);  // false
    System.out.println(s0 == s3);  // false
    System.out.println(s0 == s4);  // true
  

对于不了解JVM的人来说,这个结果应该是有些出乎意料的,我们可以先去了解StringTable,再来看这个运行结果。

StringTable在jdk1.7以前是存在于方法区的运行时常量池中,但在1.7之后,改为了存在于堆中。String table中存储的并不是String类型的对象,存储的而是指向String对象的索引,真实对象还是存储在堆中。详细的可以参考这个:看了这篇文章,我搞懂了StringTable

String具有不可变性,当我们如下定义一个字符串时,“hello”就被存放在StringTable中,而变量s是一个引用,s指向了StringTable中的“hello”:

String s = "hello"

当我们把s的值改一下,改成"hello world",这时候,并不是原先s指向的”hello“的值改变为了”hello world“,而是指向了一个新的字符串。

String s = "hello";
s = "hello world";

如何去验证是指向了一个新的字符串而不是修改其内容呢,我们可以打印一下hash值看看:

再来看字符串的拼接:

String s0 = "ab";
String s1 = "a" + "b";

使用javap反编译字节码如下,可以看到s0和s1是加载的同一个地址,这其实

以上是关于JVM详解——内存结构的主要内容,如果未能解决你的问题,请参考以下文章

详解Jvm内存结构

详解Jvm内存结构

详解Jvm内存结构

JVM整体结构-java栈详解

JVM(Java虚拟机)详解(JVM 内存模型堆GC直接内存性能调优)

JVM之内存结构图文详解