JVM 基础(3) - (面试高频)JVM 内存结构:栈、堆、逃逸分析、元空间还是永久代

一. JVM组成

如下图展示了JVM的组成:两个子系统:用于类的加载和执行,两个组件:运行时数据区用于将 class 加载到内存、Native接口用于和native libs交互(是其他编程语言交互的接口)。
在这里插入图片描述
 
 

二. 运行时数据区

了解了JVM的整体结构之后,我们看一下JVM的运行时数据区。运行时数据区如下红框,包含方法区、堆、虚拟机栈、本地方法栈、程序计数器6个部分。
在这里插入图片描述

 

1. 程序计数器(Program Counter Register)

程序计数器是一块很小的内存,可以看作是当前线程执行的字节码的行号指示器。

程序计数器又称为程序计数寄存器,寄存器存储指令相关的线程信息,只有CPU把数据装载到寄存器才能够运行。

1.1. PC 寄存器的作用

PC 寄存器用来存储指向下一条指令的地址,即将要执行的指令代码。由执行引擎读取下一条指令。
在这里插入图片描述

1.2 PC寄存器相关理解

a. PC寄存器存储字节码指令地址的原因

因为CPU需要不停的切换线程执行,切换回来时,可以知道运行的位置。

JVM字节码解释器需要通过改变PC寄存器的值来明确下条命令要怎么执行。
 

b. PC寄存器为什么设定为线程私有

因为CPU不停的切换线程执行,这样会导致经常中断或恢复。所以需要对每个线程都分配一个PC寄存器。

 

1.3. 小结

  1. 每个线程都有自己的程序计数器,是线程私有的,生命周期与线程的生命周期一致。
  2. 分支、循环、跳转、异常处理、线程恢复等都需要计数器来完成。
  3. 字节码解释器通过改变计数器的值来选取下一条自己码指令。
  4. 唯一一个在JVM规范中没有规定任何 OutOfMemoryError 情况的区域。

 
 

2. JVM栈

2.1. 概述

  1. 每个线程在创建的时候都会创建一个虚拟机栈,内部保存着一个个栈帧(方法调用),是线程私有的,生命周期和线程一致。
     

  2. 栈主管java程序的运行,保存着方法的局部变量、部分结果,并参与方法的调用和返回。
     

  3. 栈不存在垃圾回收的问题。

 

栈中的OOM

JVM规范允许栈的大小是动态的或者是动态不变的。

  1. 如果是固定大小的栈(线程创建时分配),如果线程请求分配的栈容量超过栈所允许的最大容量,JVM将会抛出 StackOverflowError 异常。
     
  2. 如果栈可以动态拓展,当尝试拓展时无法申请到足够的内存,或者新建线程没有足够的内存去建立栈,那JVM将会抛出 OutOfMemoryError 异常。

通过参数 -Xss 设置线程的最大栈空间,栈的大小直接决定了函数调用的最大可达深度。

 

官方JVM参数参考

java Tool

 

2.2. 栈的运行原理

栈帧

栈中的数据以栈帧的格式存在,线程上执行的每一个方法都各自对应一个栈帧。

栈运行原理

JVM对栈的操作只有两个,进入方法-入栈,方法执行结束-出栈,遵循“先进后出/后进先出” 原则。

  1. 在一个线程中的一个时间点上,只会有一个活动的栈帧,即只有当前执行的方法的栈帧是有效的。具体地,执行引擎运行的所有字节码指令只针对当前栈帧操作。
     
  2. 如果该方法调用了其他方法,对应的新的栈帧会被创建出来,放在栈顶,称为新的当前栈帧。方法返回之际,当前栈帧会有返回值给前一个栈帧,接着JVM丢弃当前栈帧,前一个栈帧成为当前栈帧。
     
  3. 栈帧被弹出的情况:正常return返回、抛出异常。
     
  4. 因为栈是线程私有所以一个栈帧不可能被另一个线程的栈帧所引用。

debug,压栈出栈的情况。
在这里插入图片描述
 

2.3. 栈帧的内部结构

在这里插入图片描述

每个栈帧中存储着:

名称解释
局部变量表
1. 存储方法参数和方法内部局部变量,因为是线程私有所以是线程安全的。
2. 局部变量表中的变量只在当前方法调用中有效,方法执行时,JVM使用局部变量表完成 从参数值到参数变量列表的传递过程。方法调用结束,栈帧也会销毁,局部变量表也会销毁。
3. 局部变量表的存储单位是slot ing
操作数栈
1. 后进先出的结构。方法执行过程中,根据 字节码指令,在操作数栈写入数据(入栈),提取数据(出栈)。
2. 操作数栈主要用于保存计算过程的中间结果和计算过程中临时的存储空间
3. 如果被调用的方法带有返回值,返回值会被压入当前栈帧的操作数中,并更新PC寄存器中下一条需要执行的 字节码指令
动态链接
1. 每一个栈帧都包含一个 指向运行时常量池中该栈帧所属方法的引用,主要目的是 为了实现当前方法的动态链接。
2. java文件编译成字节码文件时,所有变量和方法引用都作为 符号引用 保存在常量池中。动态链接的作用就是将这些符号引用转换为调用方法的 直接引用*。 在这里插入图片描述
方法返回地址
1. 用于存放该方法的PC寄存器的值。
2. 当方法(正常,抛异常)退出后返回该方法被调用的位置。正常返回,调用者的PC计数器的值作为返回地址。异常退出,返回地址通过异常表确定。方法执行抛出异常时的异常处理保存在异常处理表,方便发生异常找到处理异常的代码。
3.方法的退出就是栈帧出栈的过程,此时返回值压入调用者栈帧的操作数栈、设置PC寄存器值等,让调用者继续执行下去。

 

3. 本地方法栈

  1. 本地方法接口:一个 Native Method 就是一个 Java 调用非 Java 代码的接口。我们知道的 Unsafe 类就有很多本地方法。 因为Java 应用需要与 Java 外面的环境交互,这就是本地方法存在的原因。
  2. 本地方法栈:用于管理本地方法的调用,也是线程私有。同样会报:StackOverflowError 或OutofMemoryError。
  3. 本地方法栈登记native方法,当一个线程调用方法时,执行引擎加载当地方法库,此时就进入不受JVM控制的世界。
  4. 在 Hotspot JVM 中,直接将本地方法栈和虚拟机栈合二为一。

栈是运行时的单位,而堆是存储的单位。
 
栈解决程序的运行问题,即程序如何执行,或者说如何处理数据。堆解决的是数据存储的问题,即数据怎么放、放在哪。

 
 

4. 堆内存

java堆是虚拟机管理的最大一块内存,被所有的线程所共用。此区域的目的是存放对象实例:几乎所有的对象及数据都在这里分配内存。

 

4.1. 内存区域划分

虚拟机把堆划分为三部分(只是为了更好的优化GC性能):

新生代 : (小的)新对象和没有到达一定的年龄都在新生代
老年代 :被长时间使用的对象,老年代的空间比新生代更大
元空间(1.8之前叫做永久代):一些方法中操作的临时对象。JDK 1.8 之前占用的是内存,之后直接使用物理内存。

在这里插入图片描述

 

4.2. 年轻代

年轻代划分为三个部分:eden 、 from/to survivor ,默认比例是8:1:1。大多数新建的对象都位于eden内存中。

垃圾回收与对象升级

a. 当eden 空间不够给新的对象分配空间时,就会发生Minor GC。垃圾回收后存活的对象会被复制到From Survivor中去。
b. 又触发Minor GC时,From 和 Eden存活的对象被复制到 To 区,然后清空Eden 和 From ,From 和 To 互换。
c. 当再次触发Minor GC 时,重复上上述步骤,每当From 和 To 互换时,对象的年龄就加1,当年龄到达15时,升级为老年代。

在这里插入图片描述
 

新生代分区原因
  1. 避免频繁触发Full GC:当存在创建大量小对象时,会频繁发生GC,如果新生代没有分区,那么老年代很快被填满从而触发MarjorGC,而Full GC 是比较耗时的。
  2. 设置两个 survivor 的原因是解决了内存的碎片化:From 和 to 区互换使用的复制算法 保证了 From 和 Eden 区占用连续的内存空间,避免了碎片化的发生。

 

TLAB

Thread Local Allocation Buffer:JVM为每一个线程分配了一个私有缓存区域,包含在Eden空间中。

使用TLAB的原因

堆是线程共享的,任何线程都可以访问堆区中的共享数据。
为了避免多个线程操作同一个地址,需要使用加锁等,但会影响内存分配速度。
如果对象在TLAB空间分配内存失败时,JVM就会尝试通过使用加锁机制来保证数据操作的原子性,从而直接在Eden空间中分配内存。

可以通过-XX:UseTLAB 开启TLAB空间。

默认地、TLAB空间仅占用Eden的1%,可以通过XX:TLABWasteTargetPercent 设置占Eden的比例。

 
 

4.3. 老年代

老年代内存满时触发Major GC,一般需要更长的时间。

大对象(指占用大量连续空间的对象)直接进入老年代,主要是为了避免Eden区和两个survivor区之间发生大量的内存拷贝。

 

4.4. 元空间

是方法区的实现,别名叫做 Non-Heap(非堆),目的是与java堆分开。

 

5. 设置堆大小

5.1. 设置

通过-Xmx(最大java堆大小)和-Xms(初始java堆大小)控制java堆大小。

Start with 128MB of memory, and allow the Java process to use up to 1024MB of memory.

java -Xms128m -Xmx1024m

如果在堆中没有内存完成实例分配,即超出了-Xmx最大Java堆大小,将抛出OutOfMemoryError 。

我们通常将两个值设置成相同的值,目的是为了能够在垃圾回收清理完堆之后,不需要重新计算分配堆的大小,从而提高性能。

 

默认情况下

  • 初始堆大小为:电脑内存的1/64。
  • 最大堆大小为:电脑内存的1/4。

 

5.2. 查看设置值

public static void main(String[] args) {

  //返回 JVM 堆大小
  long initalMemory = Runtime.getRuntime().totalMemory() / 1024 /1024;
  //返回 JVM 堆的最大内存
  long maxMemory = Runtime.getRuntime().maxMemory() / 1024 /1024;

  System.out.println("-Xms : "+initalMemory + "M");
  System.out.println("-Xmx : "+maxMemory + "M");

  System.out.println("系统内存大小:" + initalMemory * 64 / 1024 + "G");
  System.out.println("系统内存大小:" + maxMemory * 4 / 1024 + "G");
}

 

5.3. 堆内存相关设置

配置说明
新生代和老年代的比例默认是1:2,可以通过 -XX:NewRatio=Num来配置
Eden:S0:S1的比例默认是8:1:1,可以通过-XX:SurvivorRatio=Num来配置
-XX:+UseAdaptiveSizePolicyJVM 会动态调整 JVM 堆中各个区域的大小以及进入老年代的年龄。JDK8是默认开启的,此时 –XX:NewRatio 和 -XX:SurvivorRatio 将会失效,不要随意关闭次配置,除非有明确规划。
-XX:MaxTenuringThreshold对象年轻计数器,如果分配的对象超过了-XX:PetenureSizeThreshold,对象会直接被分配到老年代。

 
 

6. 逃逸分析

通过逃逸分析,Java Hotspot编译器能够根据一个对象的作用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为:

  1. 一个对象在方法中定义后,只在方法内部使用,则认为没有发生逃逸。
  2. 一个对象在方法中定义后,被外部方法所引用,则认为发生逃逸。例如:作为参数传递到其他地方,称为方法逃逸。

 

看一个例子

    /**
     *
     * StringBuffer sb是一个方法内部变量,上述代码中直接将sb返回,这样这个 StringBuffer 有可能被其他方法所改变,
     * 这样它的作用域就不只是在方法内部,虽然它是一个局部变量,但是其逃逸到了方法外部。
     * 
     * 甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,此时称为线程逃逸。
     */
public static StringBuffer craeteStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb;
}


//不逃逸的方式
public static String createStringBuffer(String s1, String s2) {
   StringBuffer sb = new StringBuffer();
   sb.append(s1);
   sb.append(s2);
   return sb.toString();
}

 

在 JDK 6u23 版本之后,HotSpot中默认就已经开启了逃逸分析。如果使用较早版本,可以通过-XX+DoEscapeAnalysis显式开启

 

逃逸分析带来的优化

JIT编译器在编译器根据逃逸分析的结果,如果对象没有逃逸,则可能会被优化为在栈上分配。

对象空间分配完成后,继续在栈内执行,线程结束后,局部变量对象被回收,此时就无需垃圾回收了

同步省略

JIT 编译器借助逃逸分析来判断同步块所使用的锁对象是否能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT 编译器在编译这个同步块的时候就会取消对这个代码的同步。

public void keep() {
  Object keeper = new Object(); //变量的作用域只存在于栈,是线程隔离的
  synchronized(keeper) {
    System.out.println(keeper);
  }
}


//优化
public void keep() {
  Object keeper = new Object();
  System.out.println(keeper);
}

 

标量替换与栈上分配

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。
还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为其还可以分解成其他聚合量和标量。

标量替换:
通过逃逸分析确定对象无逃逸,则对象会被分解成多个标量在方法中。此时这些标量在栈帧和寄存器上分配空间。

标量替换将该对象分解在栈上分配内存,这样该对象所占用的内存空间就可以随栈帧出栈而销毁,就减轻了垃圾回收的压力。

public static void main(String[] args) {
   alloc();
}

private static void alloc() {
   Point point = new Point(1,2;
   System.out.println("point.x="+point.x+"; point.y="+point.y);
}
class Point{
    private int x;
    private int y;
}

//分解成如下
private static void alloc() {
   int x = 1;
   int y = 2;
   System.out.println("point.x="+x+"; point.y="+y);
}

通过 -XX:+EliminateAllocations 可以开启标量替换,-XX:+PrintEliminateAllocations 查看标量替换情况。

 

总结: 关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才有实现,而且这项技术到如今也并不是十分成熟的。
其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。
 
一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

 
 

7. 方法区与java8的元空间

7.1. 概述

方法区与堆一样,是所有线程共享的内存区域,别名叫非堆。

用于存储类信息、常量池、静态变量、JIT编译后的代码等数据,不同厂商有不同的实现,永久代是HotSpot虚拟机特有的概念,java8时又被元空间取代。
 

永久代和元空间

java7的永久代和新生代、老年代的空间时连续的,因此受垃圾回收器管理。
java8的元空间存在于本地内存,是堆外内存不受垃圾回收器管理,也就比较难发生OOM。

永久代中的class metadata 转移到了 native memory(本地内存)
永久代中的interned Strings 和 class static variables 转移到了堆

 

OOM

方法区的大小和堆空间一样,可以选择固定大小也可选择可扩展,方法区的大小决定了系统可以放多少个类,如果系统类太多,导致方法区溢出,虚拟机同样会抛出内存溢出错误。
 
受方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。

 

7.2. 设置方法区(JDK8)

使用-XX:MetaspaceSize-XX:MaxMetaspaceSize 指定元空间大小。

高水位线:

-XX:MetaspaceSize :设置初始元空间大小。对于一个64位的服务器端JVM来说,默认值为20.75MB。
 
这是初始的高水位线,一旦触及这个水位线,Full GC将会被触发并卸载没用的类,然后高水位会重置,新的值取决于GC后释放了多少元空间。如果释放的空间不足,那么在不超过MaxMetaspaceSize时,适当提高该值。如果释放的空间过多,则适当降低该值。

如果高水位设置过低,上述高水位会调整多次,即可以通过垃圾回收日志观察到Full GC多次调用。为了避免频繁GC,可以将-XX:MetaspaceSize设置为一个相对较高的值。

 

7.3. 方法区的内部结构

方法区用于存储已被虚拟机加载的class信息、常量、静态变量和即时编译器编译后的代码缓存等。

分类说明
类型信息
class的全限定名,直接父类的全限定名,直接接口的有序列表,类的修饰符
Field信息
域的名称、类型、修饰符
方法信息
方法名、返回类型、参数的数量和类型、修饰符、字符码、操作数栈、局部变量表及大小、异常表(每个异常处理的开始位置、结束位置、代码处理在程序计数器的偏移地址、被捕获的异常类的常量池索引)

 

7.4 运行时常量池

一个字节码文件除了包含类、字段、方法、接口等描述信息之外,还包含常量池表:包含各种字面量(各种常量值:1、"Hello"等)和对类型、字段和方法的符号引用。

为什么需要常量池

一个字节码文件中需要数据支持,但通常这些数据很大一般存不到字节码中,所以可以存到常量池,字节码包含了指向常量池的引用。在创建动态链接时用到的就是运行时常量池。

如下,我们通过 jclasslib 查看一个只有 Main 方法的简单类,字节码中的 #2 指向的就是 Constant Pool
在这里插入图片描述

 

7.5 运行时常量池的运行逻辑

类加载到JVM后,就会创建对应的运行时常量池,生成各种字面量和符号引用等。池中的数据向数组一样通过索引访问。

常量池的动态性

常量池中包含各种不同的常量,包括编译器就已经明确的数值字面量,也包括到运行期才解析的方法或字段引用,此时就不是常量池中符号地址,而是真实地址。
 
常量池的动态性:java语言并不要求常量只有编译器才能产生,运行时也可以将新的常量放入池中,例如String的intern方法。

OOM
当创建类或接口的运行时常量池时,如果所需内存空间超过了此时方法区所能提供的最大值,则JVM会抛出OutOfMemoryError 异常。

 
 

8. 面试小问 ing

一个方法调用另一个方法,会创建其他栈

1、栈中的动态链接包含调用其他方法,就会创建新的栈帧;
2、栈帧的顺序:被调用者排在后面

栈指向堆是什么意思?

栈中使用成员变量;栈中不会存储成员变量,只会存储一个应用地址

递归的调用自己会创建很多栈帧吗?

会的,在栈中从上往下排下去

GC垃圾回收简介
针对 HotSpot VM 的实现,它里面的 GC 按照回收区域又分为两大类:部分收集(Partial GC),整堆收集(Full GC)

部分收集:不是完整收集整个 Java 堆的垃圾收集。其中又分为:

  • 新生代收集(Minor GC/Young GC):只是新生代的垃圾收集
  • 老年代收集(Major GC/Old GC):只是老年代的垃圾收集;目前,只有 CMS GC 会有单独收集老年代的行为。

混合收集(Mixed GC):收集整个新生代以及部分老年代的垃圾收集,目前只有 G1 GC 会有这种行为。
整堆收集(Full GC):收集整个 Java 堆和方法区的垃圾。

 
 

参考:
https://pdai.tech/md/java/jvm/java-jvm-struct.html

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

roman_日积跬步-终至千里

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值