首发地址:地址 (25w字学习笔记,记录众多知识点,欢迎大家前来阅读!)
本篇参考:
《深入理解JAVA虚拟机》第三版
第二章、第八章
尚硅谷JVM
39 - 101 集
之前我们了解了 JVM
的一个大体层级:
在上篇文章中,我们也提及到了 类加载器
的一个大致流程(文章地址)。而今天我们来看的就是 运行时数据区
这个部分。
♣️ 那什么是运行时数据区呢?
Java
虚拟机在执行 Java
程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域有各自的用途,以及创建和销毁的时间,有的区域随着虚拟机进程的启动而一直存在,有些区域则是依赖用户线程的启动和结束而建立和销毁的。
而 Java
虚拟机包含一下几个运行时数据区域:
注意:方法区、堆是所有线程共享的数据区。其他则是线程隔离!
如下图:
♣️ 什么是程序计数器呢?
程序计数器(Program Counter Register)
是一块较小的内存空间,它可以看作是当前线程所执行的字节码的行号指示器。
在 Java
虚拟机的概念模型里,字节码解释器工作时就是通过改变这个计数器的值来 选取下一条需要执行的字节码指令 ,它是程序控制流的指示器,分支、循环、跳转、异常处 理、线程恢复等基础功能都需要依赖这个计数器来完成。
如图:
执行引擎会从 PC
中取出指令地址,然后去操作相关的局部变量表和数栈并通过机器指令传达给 CPU
。
那么我们记下来通过两个问题来对 PC
寄存器进行更深入的理解。
♣️ 使用PC寄存器存储字节码指令地址有什么用呢?
♣️ 为什么使用PC寄存器记录当前线程的执行地址呢?
答:
因为 CPU
需要不停的切换各个线程,这个时候切换回来了以后,就得知道接着从哪开始继续执行。所以就需要 PC
来记录当前的字节码地址。
JVM
的字节码解释器就需要通过改变 PC
寄存器的值来明确下一条应该执行什么样的字节码指令。
与 PC
一样,VMS
也是线程私有的,它的生命周期和线程相同。虚拟机栈描述的是 Java
方法执行的线程内存模型:每个方法被执行的时候,Java
虚拟机都会同步创建一个 栈帧(Stack Frame)
用于 存储局部变量表(8中基本数据类型、对象的引用地址)、操作数栈、动态链接、方法出口等信息。 每一个方法被调用直到执行完毕的过程,就对应着一个个栈帧在 虚拟机栈(VMS)
中从入栈到出栈的过程。
优点:
JVM
直接对 Java
栈的操作只有两个: 🌹 栈中可能出现的异常
Java
虚拟机规范允许 Java 栈的大小是动态的或者是不变的。
Java
虚拟机栈,那每一个线程的 Java
虚拟机栈容量可以在线程创建的时候独立选定。如果是线程请求分配的栈容量超过 Java
虚拟机栈允许的最大容量, Java
虚拟机将会抛出一个 StackOverflowError
。Java
虚拟机栈可以动态扩展,并且在尝试扩展的时候无法申请到足够的内存,或者在创建新的线程的时候没有足够的内存去创建对应的虚拟机栈,那 Java
虚拟机可能会抛出一个 OutOfMemoryError
异常。例如:
public class StackOverflow {private static int count = 1;public static void main(String[] args) {System.out.println(count);count ++;main(args);}
}
//自己调用自己
每个线程都有自己的栈,栈中的数据都是以 栈帧为基本单位 。
在这个线程上正在执行的每个方法都各自对应一个 栈帧 。
栈帧 是一个内存区块,是一个数据集,维系着方法执行过程中的各种数据信息
。
不同线程中所包含的栈帧是不允许相互引用的,即不可能在一个 栈帧 之中引用另外一个线程的 栈帧 。
如果当前方法调用了其他方法,方法返回之际,当前 栈帧
会传回此方法的执行结果给前一个 栈帧 ,接着,虚拟机会丢弃当前 栈帧 ,使得前一个 栈帧 重新成为当前 栈帧 。
Java
方法有两种返回函数的方式,一种是正常的函数返回,使用 return
指令;另外一种是抛出异常。不管使用哪种方式,都会导致 栈帧 被弹出。
🌹 栈帧的内部结构:
♣️ 3.2.1 局部变量表
首先我们需要知道什么是局部变量表 ?
局部变量表
也被称为 局部变量数组或本地变量表
。returnAddress
类型。Code
属性的 maximun local variables
数据项中。在方法运行期间是不会改变局部变量表的大小的。Slot(变量槽)
,在局部变量表中,32位以内的类型只占用一个slot(包括returnAddress类型),64位的类型(long和double)占用两个slot。🌲 其他知识补充:
变量的分类:
♣️ 3.2.2 操作数栈(Operand Stack)
操作数栈也常被称为操作栈,在方法执行过程中通过 字节码 指令 ,往栈中写入数据或提取数据,即 入栈(push)/出栈(pop)
。
常见的作用:
操作数栈
就是 JVM
执行引擎的一个工作区,当一个方法刚开始执行的时候,一个新的栈帧也会随之被创建进来,这个方法的操作数栈是空的。Java
的数据类型。Java
虚拟机的解释执行引擎为称为 基于栈的执行引擎
,里面的 栈
就是操作数 栈
。⚠️
操作数栈中的元素数据类型必须是与字节码指令的序列严格匹配(字节码验证),在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
🌲 引入问题:i ++ 和 ++ i 的区别?
我们都知道:
i++ 后加加,原理:先自增,然后返回自增之前的值
++i 前加加,原理:先自增,然后返回自增之后的值
今天我们就可以通过操作数栈来解释这个问题
例如:
/*** 示例代码*/
public class demo {public void test1() {int i = 0;int j = i++;}public void test2() {int i = 0;int j = ++i;}public void test3() {int i = 0;int j = i++ + i++;}
}
通过 javac demo.java 进行编译,得到 demo.class 字节码文件。
再通过 javap -c demo.class 进行反编译,得到反编译代码如下:
public class demo {public demo();Code:0: aload_01: invokespecial #1 // Method java/lang/Object."":()V4: return// iload 加载到操作栈中// istore存储到局部变量表中// iinc 增加public void test1();Code:0: iconst_0 // 入栈常数01: istore_1 // 赋值1号存储单元为常数0(i = 0)2: iload_1 // 加载1号存储单元值到寄存器(记为stack[0] = 0)3: iinc 1, 1 // 递增1号存储单元值(i = i + 1 = 0 + 1 = 1)6: istore_2 // 赋值2号存储单元为寄存器值(j = stack[0] = 0)7: return // 返回(此时:i = 1,j = 0)public void test2();Code:0: iconst_0 // 入栈常数01: istore_1 // 赋值1号存储单元为常数0(i = 0)2: iinc 1, 1 // 递增1号存储单元值(i = i + 1 = 0 + 1 = 1)5: iload_1 // 加载1号存储单元值到寄存器(记为stack[0] = 1)6: istore_2 // 赋值2号存储单元为寄存器值(j = stack[0] = 1)7: return // 返回(此时:i = 1,j = 1)public void test3();Code:0: iconst_0 // 入栈常数01: istore_1 // 赋值1号存储单元为常数0(i = 0)2: iload_1 // 加载1号存储单元值到寄存器(记为stack[0] = 0)3: iinc 1, 1 // 递增1号存储单元值(i = i + 1 = 0 + 1 = 1)6: iload_1 // 加载1号存储单元值到寄存器(记为stack[1] = 1)7: iinc 1, 1 // 递增1号存储单元值(i = i + 1 = 1 + 1 = 2)10: iadd // 取寄存器值并执行相加操作(stack[0] = stack[0] + stack[1] = 0 + 1 = 1)11: istore_2 // 赋值2号存储单元为寄存器值(j = stack[0] = 1)12: return // 返回(此时:i = 2,j = 1)
}
过程:
i ++
: 1
入栈顶 iconst_0
。istore_1
。iload_1
。iinc
。istore_2
。++ i
:1
入栈顶 iconst_0
。istore_1
。iinc
。iload_1
。istore_2
。通过上述反编译内容可知,i++
和 ++i
操作是在加载 i
值至寄存器步骤的前后执行的,而赋值语句右侧的表达式计算时,是根据加载到寄存器的值进行计算的。
具体也可以参考本篇文章:地址
♣️ 3.2.3 动态链接
每个 栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的 动态链接
。
我们知道 Class
文件的常量池中存 有大量的符号引用,字节码中的方法调用指令就以常量池里指向方法的符号引用作为参数。这些符号 引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。 另外一部分将在每一次运行期间都转化为直接引用,这部分就称为 动态连接
。
♣️ 3.2.4 方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法:
返回的字节码指令
,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用 者或者主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种 退出方法的方式称为“正常调用完成”(Normal Method Invocation Completion)。异常
,并且这个异常没有在方法体内得到妥善处理。这种退出方法的方式称为“异常调用 完成(Abrupt Method Invocation Completion)”。 一个方法使用异常完成出口的方式退出,是不会给它的上层调用者提供任何返回值的。
♣️ 3.2.5 一些附加信息
《Java虚拟机规范》允许虚拟机实现增加规范里面没有描述的信息到栈帧之中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一般会把动态链接、方法返回地址与其他附加地址信息全部归为一类,称为栈帧信息。
补充知识
🌻 3.2.6 方法调用
在 JVM
中,将 符号引用
转化为 调用方法
的直接引用与方法的绑定机制相关。
当一个字节码文件被装载技能 JVM
内部时,如果被调用的 目标方法在编译期可知 ,且运行期保持不变时。这种情况下将调用方法的符号引用转换为 直接引用
的过程称之为 静态链接
。
如果 被调用的方法在编译期无法被确定下来, 也就说,只能够在程序运行期将调用方法的符号引用转换为直接引用,由于这种引用转换过程具备动态性,因此被称为 动态链接
。
🌲 3.2.7 栈的相关面试题
答案参考地址: 地址
本地方法栈(Native Method Stacks)
为了虚拟机使用到本地 (Native)
方法夫妇。线程私有,且允许固定大小和动态扩展内存大小。本地方法实现时 C语言实现
。
Java
实例只存在一个堆内存,堆也是 Java
内存管理的核心区域。Java
堆区 在 JVM
启动的时候创建,其空间大小也就确定了。是 JVM
管理的最大的一块内存。 Java
堆中可以划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer ,TLAB)。🌲 堆空间大小设置
-Xmx
和 -Xms
来进行设置。 -Xms
: 用于表示堆内存起始内存,等价于 -XX:InitialHeapSize
。-Xmx
:则用于表示堆区的最大内存,等价于 -XX:MaxHeapSize
-Xmx
所指定的最大内存是,将会抛出 OutOfMemoryError
异常。Young Generation Space
:新生区 (划分为 Eden
区和 Survivor
区)Tenure Generation Space
养老区Permanent Space
:永久区Young Generation Space
:新生区 (划分为 Eden
区和 Survivor
区)Tenure Generation Space
养老区Meta Space
:元空间⚠️ 注意: 新生区 == 新生代 == 年轻代 、 养老区 == 老年区 == 老年代 、永久区 == 永久代
♣️ 5.2.1 年轻代与老年代
JVM
中的 Java对象可以被划分为两类: JVM
的生命周期保持一致。年轻代和老年代
.Eden
空间、Survivor0
空间和 Survivo1
空间(有时也叫做 from 区、to区
)。如图:
那有么有想过 年轻代和老年代
在 堆空间
的占比是多少呢?
如图:
通过上图我们知道在 堆空间
中 年轻代
占比 1/3
,老年代
占比 2/3
。而我们也可以通过修改 -XX:NewRatio = 4
,表示 新生代
占比 1/5
,相反 老生代
占比 4/5
。
通过上述我们知道了 年轻代和老年代
的默认占比是 1:2
,我们也能看见 From区和To区
在年轻代中的占比是 8:1:1
。当然我们也可以通过 -XX:SurvivoRatio
来进行设置其他比例。
但是我们要知道,几乎所有 的Java对象都是在 Eden
被 new
出来的。
这里给大家简单的叙述一下 内存
中对象分配的具体过程。
new
的对象放在 Eden区
。此区有大小限制。Eden区
空间填满的时候,程序又需要创建新的对象,则此时 JVM
的垃圾回收机制将会对 Eden区
进行 垃圾回收(Minor GC)
,将 Eden区
中的不再被其他对象所引用的对象进行销毁。在加载新的对象到 Eden区
.Eden 区
中剩余的对象移动到 Survivor0区
。Survivor0区
,如果没有回收则就会放到 1区
。Survivor0区
接着再去 1区
。养老区
. 一般默认为 15
次。 -XX:MaxTenuringThreshold=
进行设置。总结:
- 对于
0、1区
复制后有交换,谁是空谁是to。- 对于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区/元空间收集。
⚠️ 注意: 如果当一个新的对象申请内存大于
Eden区
的内存时,则会放到老年区
如果老年区
放不下则会出现OOM
。当
Survivor
区中相同年龄的所有对象大小总和大于Survivor
空间的一半,年龄大于或等于该年龄的对象 可以直接进入老年代,无须等到MaxTenuringThreshold
中要求的年龄。
如果在面试中面试官问:为什么堆空间要进行分代,都放在一起不行吗?
此时面试官可能就在考验你是否真正的了解 堆空间
这个概念,和对垃圾回收的一个判断。
就刚才那个问题而言,都放在一起当然可以。但是如果没有分代,把所有的对象都放在一起,就如同把一个学校的所有人都关在教室里面。关在一个教师里面,老师如何去上课?是一起学习还是,一年级先上、六年级不听?这肯定是不合理的,这样就会对堆的所有区域进行扫描,而很多对象都是朝生夕死的。
如果我们分区处理,在年轻代进行垃圾回收频繁,在老年代少一点,元空间几乎不回收。这样能很大的提高性能,且能有效的腾出很大的空间。
🌻 什么是TLAB?
Eden
区域继续进行划分,JVM
为 每一个线程分配了一个私有缓存区域 ,它包含在 Eden
空间内。TLAB
可以避免一系列的非线程安全问题,同时还能够提升内存分配的吞吐量,因此我们可以将这种内存分配方式称之为 快速分配策略。🌻 为什么有TLAB?
JVM
中非常频繁,因此在并发环境下从堆区中划分内存空间是线程不安全的。注意:
TLAB
中成功分配内存,但 JVM确实是将TLAB作为内存分配的首选。-XX:UseTLAB
设置是否开启 TLAB
空间。TLAB
空间分配内存失败时,JVM
就会尝试着通过 使用加锁机制 确保数据操作的原子性,从而直接在 Eden
空间中分配内存。方法区(Method Area)
与 Java
堆一样,是各个线程共享的内存空间区域,它用于存储已被虚拟机加载的 类型信息、常量、静态变量、即时编译器编译后的代码缓存
等数据。
🌻 栈、堆、方法区之间的交互关系
例如:
Person person = new Person();
Person
:方法区person
:栈new Person()
:堆如图:
⚠️ 在jdk7以前,习惯上把方法区,称为永久代。jdk8开始,使用元空间取代了永久代。
- 元空间的本质和永久代类似,都是对
JVM
规范中方法区的实现。不过元空间与永久代最大的区别在于:元空间不在虚拟机设置的内存中,而是使用了本地内存。
- 永久代、元空间二者并不只是名字改变了,内部结构也调整了。
- 根据
《Java虚拟机规范》
的规定,如果方法区无法满足新的内存分配需求时,将抛出OOM
异常。
方法区的内部结构有:
♣️ 类型信息
对每个加载的类型(类class、接口interface、枚举enum、注解annotation),JVM
必须在方法区中存储以下类型信息:
interface
或是 java.lang.Object
,都没有父类)public,abstract,final
的某个子集)♣️ 域信息
JVM
必须在方法区中保存类型的所有域的相关信息以及域的声明顺序。public,private,prtected,static,final,volatile,transient
的某个子集)♣️ 方法信息
JVM
必须保存所有方法的以下信息,同 域信息一样包括声明顺序
:
void
)♣️ non-final 的类变量
运行时常量池(Runtime Constant Pool)
是方法区的一部分。
常量池表(Constant Pool Table)
是 Class
文件的一部分,用于存放编译期生成的各种字面量与符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。
一般在加载类和接口到虚拟机后,就会创建对应的运行时常量池。
运行时常量池中包含多种不同的常量,包括编译期就已经明确的数值字面量,也包括到运行期解析后才能获得的方法或者字段引用。此时不再是常量池中的符号地址了,这里换为真实地址。
Class
文件中的常量池具有动态性。运行时常量池类似于传统的编程语言中的符号表 (symbol table)
,但是它所包含的数据比符号表要更加丰富一些。
当创建类或接口的运行时常量池,如果构造运行时常量池所需要的内存空间超过了方法区所能提供的最大值,则 JVM
会抛出 OutOfMemoryError
异常。
⚠️ 注意:
- 在
jdk1.6
之前 静态变量存放在永久代上。- 在
jdk1.7
中 字符串常量池、静态变量移除,保存在堆中。- 在
jdk1.8
及之后 无永久代,类型信息字段、方法、常量保存在本地内存的元空间,但字符串常量池、静态变量仍在堆中。那为什么StringTable为什么要调整?
jdk7
中将StringTable
放到了堆空间中。因为永久代的回收效率很低,在full gc
的时候才会触发。而full gc
是老年代的空间不足、永久代不足时才会触发。这就导致StringTable
回收效率不高。而我们开发中会有大量的字符串被创建。回收效率低,导致永久代内存不足。放到堆里,能及时回收内存。
1、说一下JVM内存模型,有哪些区?分别是干什么的?
2、Java8的内存分代改进?
3、栈和堆的区别是什么?堆的结构是什么样子的?为什么有两个Survivor区?
4、Eden和Survivor的比例是多少?
5、Java分区为什么要有年轻代和老年代?
6、什么时候对象会进入老年代?
7、JVM内存模型,Java8做了什么修改?
8、jvm中的永久代会发生垃圾回收吗?
答案地址:地址