JVM组成
各个组件的说明
子系统名称 | 描述 | 线程共享 |
---|---|---|
类加载子系统 | 负责将.class文件加载到内存区域中。 | |
堆内存(Heap) | Java虚拟机中最大的一片空间,存储new出来的对象,以及通过其它方式构建出来的对象。也是GC主要回收的一片区域。 | 共享 |
方法区(Method Area) | 存储一些类的定义信息,类的元信息,类的方法定义,类中的常量。(元数据) | 共享 |
虚拟机栈(VM Stack) | 存储线程运行过程中的栈信息,程序的调用与执行过程,就好比异常时打印出来的栈信息就是其中之一。 | 线程私有 |
程序计数器 | 保存程序运行到哪里了。 | 线程私有 |
本地方法栈 | 与虚拟机栈类似,但是这个是针对于本地方法native方法的栈信息。而虚拟机栈是针对于Java方法的 | 线程私有 |
执行引擎
负责翻译字节码指令到操作系统认知的指令,调用操作系统的指令
1.程序计数器
用于记录我们的程序执行到哪个位置的一个组件。
我们的Java
程序其实就是字节码,执行需要字节码指令,我们知道Java是基于线程的,并且可以多个线程同时执行,因为CPU核心有限,想要执行多线程任务的话,就需要协调好各个线程的执行顺序,就是通过时间片来进行协调管理,那么就会存在线程的停止和启动,如果没有记录程序运行位置的话,程序停止后在运行就找不到该从哪开始执行,而程序计数器就恰恰解决了这个问题。
如果调用的是
navite
方法,那么程序计数器为空。因为
native
方法直接是调用的是c
程序,处于两个不同的内存空间。因此获取不到相关信息。
特性
- 程序计数器是记录着当前线程所即将执行的字节码指令行号
- 每一个线程都拥有自己的计数器
- 执行
Java
方法时,程序计数器是有值的 - 执行native本地方法时,计数器值为空
- 程序计数器占用内存非常少,不会出现
OutOfMemoryError
2.虚拟机栈
栈介绍:它是一种数据结构,特性如下
- 连续紧密存储
- 先进后出
- 压栈与出栈
- 集装箱的摆放方式就是栈结构,入栈就是堆叠集装箱,出栈就是挪走集装箱
虚拟机栈的生命周期是和线程一致的
线程运行时则虚拟机栈存在,线程销毁时,虚拟机栈也销毁。
栈大小、空间
- Java1.5后默认每个栈大小为1mb,在此之前为256kb
- Java在启动参数中配置
-Xss数值[k|m|g]
可以配置栈大小,例:-Xss10m
- 不建议手动设置大小,1mb可以满足使用,如果设置的太大会导致OS内存压力增大,影响高并发环境下的性能
- 栈分配的内存决定了栈的深度,超出栈的深度则会抛出栈溢出的异常
StackOverflowError
堆栈溢出OutOfMemoryError
内存溢出
栈帧的组成
一个栈帧就对应一个方法调用,也就是一个方法,除了方法具体的流程外,栈帧还包括其它内容:
- 局部变量表
- 方法内部的局部变量信息
- 操作数栈
- 保存中间计算的临时结果
- 动态链接
- 将符号引用转换为直接引用
- 返回地址
- 存放调用方法的程序计数器值
局部变量表
局部变量表存储以下两块的内容:
- 存储方法参数
- 存储方法内的局部变量
何为局部变量?
javapublic class Test{ // 成员变量 private int name; public void sayHi(){ // 局部变量,因为作用域仅仅是方法内 String text = "h"; } }
局部变量的特性
- 线程私有,不允许跨线程访问,随方法调用创建,方法退出销毁
- 编译期间局部变量表长度(变量个数)已确定,局部变量元数据会存在在字节码文件中。
- 局部变量表是栈帧中最主要的存储空间,大小影响栈的深度。
字节码文件内定义的局部变量表元数据
使用Jclasslib插件查看
sayHi()实例方法的源代码
java
public void sayHi(int printTotal) {
for (int i = 0; i < printTotal; i++) {
int a = 0;
String text = "你好";
System.out.println(text + a);
}
}
sayHi()实例方法对应的本地变量表
起始PC代表该变量是在第几行字节码创建的
main()静态方法的源代码
java
public static void main(String[] args) {
new LocalVarSample().sayHi(3);
}
main()方法对应的局部变量表
对比这两个方法(静态方法,实例方法),可以发现以下不同,也是最主要的区别
- 静态方法的局部变量表没有
this
,因为是static
方法,而sayHi()
方法对应的0号槽位(0号序号)就是this
变量
剩下的就是相同的地方
-
局部变量表的顺序由变量的书写顺序决定,如果是构造方法或者实例方法,则0号槽位(序号为0)存放this,静态方法则是第一个书写的变量。
-
局部变量表的长度固定(多少槽位),在字节码创建时就固定下来,称之为静态局部变量表
-
一个局部变量至少占用一个
Slot
槽位,对应LocalVariableTable
的Index
列,也就是jclasslib
的序号列。 -
StartPC
代表字节码行号(并非代码行号),Length
代表从StartPC
开始的后几行可以使用这个变量,
高级特性
-
上边提到局部变量表中,每个变量至少占用一个
Slot
,那什么情况下会占用多个Slot呢?答案如下:-
占用槽位多少是根据数据类型大小来决定的,32位以内的类型(int\float\char\引用类型..)占用1个槽位,大于32位的数据类型(long/double)就需要使用到2个槽位
-
如下图所示,money字段(序号2)为
double
类型,id字段(序号4)为long
类型,他俩的序号可以发现是从2-3,4-5,一个变量占用了两个槽位,因为他们超过了32位。
javadouble money = 32.1; long id = 1111111111111111111L;
-
-
Slot复用,Slot在字节码中被称为是静态的本地变量表,而JVM运行时会把静态变量表动态的加载到内存中去,因此槽位可能会产生变化,例如
Slot
复用的概念。示例代码如下:javapublic void sayHi(){ int a = 0; int b = 1; if(a < b){ int sum = a+b; a = sum; } int d = 4; System.out.println(a+d); }
-
在上边的示例代码中,我们创建了4个变量,
a
、b
、sum
、d
-
字节码文件的静态变量表如下:
Slot槽位序号 变量名 0 this 1 a 2 b 3 sum 4 d -
那程序真正执行过程中,因为
if
内的局部变量sum
作用域仅仅只有if
内部,当程序执行到if末尾时,就会把3号槽位的sum
给清除掉,因为已经不可能会被访问到了,在将d
加载到3号槽位,实现复用。好处如下:- 可以节省栈帧的空间,空间大了栈的深度就会更深。
- 提高性能:当槽位被复用时,可以避免创建新的槽位,从而减少内存的分配和回收,提高性能。
- 优化垃圾收集:未复用的局部变量槽位会误导垃圾收集器,阻止内存回收。通过槽位复用,垃圾收集器能更准确地回收内存。
-
操作数栈
字节码指令执行过程中的临时结果数据存放的地方就叫做操作数 栈
假如我们有一个方法,对应的字节码指令如下,因为方法还未运行,所以操作数栈为空:
当执行第一条指令bipush 10
时,10将被压入栈顶。此时栈如下
执行第二条指令istore_1
时,栈顶元素将被放入局部变量表,此时局部变量表和栈的情况如下:
10将被弹出栈,并被放入局部变量表,操作数栈清空
执行第三条指令bipush 18
时,18将被压入栈顶。此时局部变量表和栈的情况如下:
执行第四条指令istore_2
时,栈又会被清空,并将18放入局部变量表。
执行第五条指令iload_1
和第六条指令iload_2
后,局部变量表的10
和18
将被压入栈顶:
执行第七条指令iadd
时,操作数栈的栈顶元素与第二位元素将相加。
执行第八条指令istore_3
时,栈顶存放的相加后的元素28
将先被弹出栈顶,再被存入本地变量表。
执行第九条指令iload_3
时,28
将被压入栈顶。
执行最后一条指令ireturn
时将返回栈顶元素28
。
此时方法结束,局部变量表将被清空,操作数栈将被清空。方法返回结果。
动态链接
什么是动态链接?将字节码中的符号引用转换成内存的直接引用。
字节码文件中存储的引用信息都是cp_info #27
这种东西,可以理解为就是个字符串信息,也叫字面量,实际上就是一个引用的信息,假如字节码new
一个对象,在字节码中存储的是new #14
这种字符串,那在JVM运行时,想真正的找到这个对象的信息,就要去JVM方法区里去找,而这个#14
就好比是对象信息存储的位置,有了#14
就能找到LocalVarSample
这个对象。
在内存中执行的做个转换过程,就叫动态链接。
为什么要这么干呢?
- 因为栈帧的空间是有限的,不能直接存储对象的信息,如果只存储对象的指针,就能大大的节省栈帧的空间。并且方便
JVM
管理。
动态链接示意图
返回地址
它主要用来指示方法执行完毕后程序的执行流应该跳转到的位置。简单来说,就是告诉程序:我这个方法执行完了,你应该去哪里继续执行。
举个例子,假设你正在执行一个名为A的方法,然后在A中又调用了一个名为B的方法。那么在调用B的时候,虚拟机会在栈帧中保存一个返回地址,这个地址指向的是方法A中调用方法B那条指令的下一条指令。当方法B执行完毕后,程序就会跳转到这个返回地址,也就是回到方法A中继续执行。
爆栈
虚拟机栈,是个栈结构,既然是一个存储的结构那么必然是有大小限制,不可能无限制的一直入栈,这样内存也扛不住,那么爆栈的意思其实也很好理解,就是方法运行的层级过深,就好比递归,没有停止条件,就会导致一直入栈,一直入栈,直到把栈给顶爆,就抛出,StackOverflowError
异常。
java
public static void main(String[] args) {
// 调用爆栈(递归)方法
System.out.print(method1());
}
public int method1(){
// 无限制的递归
return method1();
}
结果
java
Exception in thread "main" java.lang.StackOverflowError
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
at cn.yufire.aqs.ZhanZhen.method1(ZhanZhen.java:29)
... 省略n行
3.本地方法栈
本地方法栈是专供于本地方法(navite
修饰)使用的,当JVM通过JNI调用本地方法时,本地方法栈就是主要记录JVM调用本地方法时的一些状态信息。
4.堆(Heap)
堆是JVM中最核心的内存区域,存放运行时实例化的对象实例。堆在JVM创建的时候就被创建,空间也被分配。堆是线程共享的一片大区域。
-
堆内存在物理上是分散的,在逻辑上是连续的,也就是说堆内存的数据可以分散在不同的内存颗粒上,但是JVM在使用时会通过指针将数据连续起来,在我们看来JVM堆就是连续的一片空间。
-
堆中包含线程私有的缓冲区(
TLAB
),可以有效的提高JVM的并发效率
TLAB(Thread-Local Allocation Buffer)是线程本地分配缓冲区的缩写。在Java中,为了提高内存分配的效率,JVM通常会为每个线程在堆中分配一个私有的缓冲区,这就是TLAB。
TLAB的主要作用是减少线程之间的竞争。在多线程环境下,如果每个线程都直接在堆上分配内存,那么这些线程可能会竞争同一块内存区域,从而导致性能下降。通过使用TLAB,每个线程都可以在自己的缓冲区中分配内存,从而避免了这种竞争。
TLAB只用于分配小对象。当一个线程需要分配一个大对象时,它会直接在堆上进行分配。当TLAB用完时,线程会申请一个新的TLAB。
总的来说,TLAB的引入是为了提高内存分配的效率,特别是在多线程环境下。
引用类型与堆的关系
- 引用类型的本质就是一个指针,指向堆内存的队列实例地址。
- 堆是垃圾回收(GC)的重点区域,方法结束后并不会立即进行垃圾回收,而是等JVM判断需要垃圾回收时才进行垃圾回收。
堆结构
- 新生代,主要存放刚创建的对象,这些对象是不稳定的,可能会被频繁GC回收。
- 老年代,存放相对稳定的对象(GC多次未被回收),不会进行频繁的GC行为。
- 元空间,内存中永久保存区域,用于存放类的描述信息,几乎不会GC。
堆大小
参数相关
-Xms数量[k|m|g]
设置堆空间(年轻代+老年代)的初始化内存大小,例如-Xms2g
-Xmx数量[k|m|g]
设置堆空间(年轻代+老年代)的最大内存大小,例如-Xmx2g
-XX:+PrintGCDetails
设置该参数可以查看GC相关的数据,在程序退出时打印
tex
Heap
PSYoungGen total 1378816K, used 161309K [0x0000000716580000, 0x000000076e180000, 0x00000007c0000000)
eden space 1364480K, 11% used [0x0000000716580000,0x0000000720307790,0x0000000769a00000)
from space 14336K, 0% used [0x000000076d380000,0x000000076d380000,0x000000076e180000)
to space 33792K, 0% used [0x0000000769f80000,0x0000000769f80000,0x000000076c080000)
ParOldGen total 459264K, used 73890K [0x00000005c3000000, 0x00000005df080000, 0x0000000716580000)
object space 459264K, 16% used [0x00000005c3000000,0x00000005c7828ba0,0x00000005df080000)
Metaspace used 92836K, capacity 98120K, committed 98752K, reserved 1134592K
class space used 12143K, capacity 13082K, committed 13184K, reserved 1048576K
设置建议:建议整个堆大小设置为
FullGC
后存活对象的3-4倍
默认值
- 堆默认大小为操作系统内存的
64
分之1
- 堆最大内存为操作系统的
4
分之1
- 新生代与老年代比例
- 新生代占用总堆的
3
分之1
- 老年代占用总堆的
3
分之2
- 新生代占用总堆的
新生代
新生代分为三个小区域:Eden(伊甸园)、Survivor-0(From)、Survivor-1(To)
- 内存分配比例为:
8:1:1
- 假如新生代有
1G
内存,那分配结果就是:800M
、100M
、100M
- 绝大多数刚创建的对象都会放在
Eden
区,因此需要有足够的空间存放这些对象
刚初始化的新生代,此时还没有数据时是长这样的
如果新创建一个对象:User user = new User()
此时User()
对象会被放入Eden
区。
如果Eden
区满了(默认)的时候,无法分配新对象的时候,就会触发一次Minor GC
,也叫Young GC
对新生代进行垃圾回收。
- 根据可达性算法扫描过所有对象后,会将对象分为两拨:无引用对象,持有引用对象
- 持有引用对象将会被通过
复制拷贝
算法到S0
区,剩下的所有对象将被垃圾回收掉。 - 并将对象的头属性
age + 1
当Eden
区再次满了之后,再次触发Minor GC
,再次通过可达性算法分析对象是否持有引用,再次将对象进行拷贝交互和清除。
假如我们的User
对象依然持有引用,User对象将被复制并拷贝到S1
区age + 1
,再将无引用对象进行垃圾回收
如果Eden
区再次满了,又会触发Minor GC
,此时我们的User
对象依然持有引用,那么它将被移动到S0
区age + 1
如果一直Minor GC
,直到User
的age
大于15时,User
对象会被JVM
认定为是稳定的对象,会被放入Old Gen
老年代里。
问题一
为什么
User
会从S1
移动到S0
呢?
- JVM为了方便关联内存数据,解决内存碎片问题,所以会将
S0
区和S1
区的数据进行互换,每次Minor GC
时Eden
区的对象会被放入空的Survivor
区,并将该区的所有对象移动到另一个Survivor
区- 出对象的
Survivor
区被称为From
区,移动到的Survivor
区被称为To
区
问题二
为什么垃圾回收要分代(新生代、老年代)处理
- 为了执行效率的考量,因为大多数对象的存活时间可能极低,可能方法执行完对象就没有引用了,如果触发全局
GC
(Full GC
),则会牵连无关紧要的对象,影响整体效率,增加程序响应时间。
新生代垃圾回收的特殊情况
其实特殊情况简单概括就是,新创建的对象没有足够的内存进行分配了该怎么办?
Eden区内存不足无法分配,对象被移动到S0区
- 执行
Minor GC
,并将对象移动到S0
区,但是S0
区满了,对象放不进去。 - 尝试直接将对象放入
Old Gen
老年代里。 - 如果老年代满了放不进去,则会触发
Full GC
后再次尝试存放- 如果还放不进去,则抛出
OOM
错误
- 如果还放不进去,则抛出
复制交换算法
5.方法区
存放类的信息,方法信息等字节码中的静态信息(元数据)。
- 方法区是线程共享的区域,是物理上分散,逻辑上连续的一片区域
保存的内容
- 类型信息:这包括类的名称、父类、接口、访问修饰符等信息。
- 字段信息:类中的字段(包括静态字段和非静态字段)的名称、类型、访问修饰符等信息。
- 方法信息:类中的方法(包括静态方法和非静态方法)的名称、返回类型、参数类型、访问修饰符等信息。
- 常量池:包括字面量(如文本字符串)和符号引用(如类和接口的全名、字段的名称和描述符、方法的名称和描述符)。
- 静态变量:类的静态变量。
- 即时编译后的代码:如果启用了即时编译(JIT),方法区还会保存编译后的本地机器代码。
永久代与元空间
永久代的数据是存放在JVM内存之内的,和JVM共享内存空间,而元空间的数据是直接存放在操作系统内存上的,与JVM内存互不影响,不占用JVM内存。
- 永久代占用JVM内存,容易导致内存溢出
- 元空间与物理机内存保持一致,最大可用内存为物理机剩余可用内存。
元空间大小
默认为(12mb~21mb),根据操作系统的不同在此区间内动态调整。如果慢了则会触发Full GC
,并提高元空间大小可能突破21mb
可以通过-XX:MetaSpaceSize=xxx[M|G]
设置元空间最小值、使用-XX:MaxMetaSpaceSize=xxx[M|G]
设置最大值
- 建议设置较大的元空间大小,减少因元空间内存不足导致的
Full GC
- 如果元空间内存超过了
MaxMetaSpaceSize
则会抛出OutOfMemoryError:MetaSpace
错误
不同版本JVM方法区的变化
方法区是个概念,JVM
规范要求必须要有这个东西,至于怎么实现就是JVM厂商做的事情。例如HotSpot
虚拟机
- 1.7前,方法区叫做永久代
Permanent Generation
- 1.7开始逐渐抛弃永久代
- 1.8彻底抛弃永久代并实现了元空间
Meta Space
好比公共场所的消防设施,消防队要求必须有,但是怎么去安装是公共场所的事情。
- 某棋牌室放了16个灭火器 + 3个消防栓作为消防设施
- 某酒店采用了热感喷头 + 36个消防栓作为消防设施
运行时常量区
存放来自字节码中常量池的数据,或者动态产生的新常量。
运行时常量区位与元空间内,属于内部的一片空间。
方法区历史变化
静态变量是存储在哪的?public static User user = new User();
不同版本JVM是不一样的,如下图所示
JDK版本 | 变化 |
---|---|
<=1.6 | 此时有永久代的概念,静态变量存放在永久代内 |
1.7 | 有永久代,但是逐渐移除永久代,字符串常量池,静态变量从永久代移除,改为存放在堆中 |
>= 1.8 | 已移除永久代,类型信息、字段、方法、常量保存在元空间内,字符串常量池,静态变量仍在堆中 |
静态变量是使用new
关键字创建的,如new User()
,那一定是存储在堆内存中的。
如果静态变量是基本类型如static int i = 1;
,那它会存放在元空间内。