一、Java 内存结构
Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会被销毁。其他数据区域是每个线程的。每线程数据区域在创建线程时创建,在线程退出时销毁。
JVM结构官方文档
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5.5
1、图解运行时数据区
2、方法区-Method Area
方法区是各个线程共享的内存区域,在虚拟机启动时创建。
用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
由
类加载机制
中的装载
阶段将class文件中的静态结构转换为方法区的运行时数据结构。
2.1)永久代/元空间
方法区在JDK8中是Metaspace(元空间),在JDK6或JDK7中是Perm Space(永久代)。
2.2)运行时常量池
Run-Time Constant Pool
Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译时期生成的各种字面量和符号引用,这些内容将在类加载后存放到方法区的运行时常量池中。
每个运行时常量池都是从Java虚拟机的方法区域分配的。类或接口的运行时常量池是在Java虚拟机创建类或接口时构造的。
在创建类或接口时,如果构建运行时常量池所需的内存超过了Java虚拟机的方法区域中可用的内存,则Java虚拟机将抛出OutOfMemoryError。
3、堆-Heap
Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。
Java对象实例以及数组都在堆上分配。
3.1、新生代 - Young区
Young区分为Eden区、From区、To区 三个区域,其比例默认为8:1:1
。
Young区的GC操作,叫做Minor GC
。
1)伊甸园 - Eden区
一般情况下,新创建的对象都会分配到Eden区,一些特殊的大的对象会直接分配到Old区。
比如有A,B,C三个对象创建在Eden区,但是Eden区的内存空间肯定有限,比如有100M,假如已经使用了100M或者达到一个设定的临界值,这时就需要对Eden内存空间进行清理,即垃圾收集(Garbage Collect), 这样的GC称之为
Minor GC
,Minor GC
指得是Young区的GC。经过GC之后,有些对象会被清理掉,有些对象可能还存活着,对于存活着的对象需要将其复制到Survivor区,然后再清空Eden区中的这些对象。每一次复制,对象的年龄会+1,当存活对象的年龄超过15时,会进入到Old区。
2)幸存区 - Survivor区
Survivor区 分为 S0 和 S1 两个区域,也就是From 和 To区。通过参数 –XX:SurvivorRatio 来设定。
Survivor区主要是为了减少送到老年代的对象,到达减少Full GC的目的。
在同一个时间点上,S0和S1只有一个区有数据,另外一个是空的。
接着上面的GC来说,比如一开始只有Eden区和From中有对象,To区是空的。
此时进行一次GC操作,From区中对象的年龄就会+1,我们知道Eden区中所有存活的对象会被复制到To区,From区中还存活的对象会有两个去处。
若对象年龄达到设置的年龄阈值,这类对象会被移动到Old区,而Eden区和From区没有达到阈值的对象会被复制到To区。 此时Eden区和From区已经被清空(被GC的对象已被清理,没有被GC的对象也有各自的去处)。
这时From和To交换角色,之前的From变成了To,之前的To变成了From。 也就是说无论如何都要保证名称为To的Survivor区域是空的。
Minor GC会一直重复这个过程,直到To区被填满,然后会将所有对象复制到老年代中。
3.2、老生代 - Old区
一般情况下,Old区都是年龄比较大的对象,或者相对超过了某个阈值的对象。
Old区的GC的操作,叫做 Major GC
。
3.3、常见问题
1)Minor/Major/Full GC的区别?
Minor GC: 针对新生代的GC。
Major GC: 针对老年代的GC。
Full GC: 针对新生代+老年代的GC。
2)为什么需要Survivor区?只有Eden区不行吗?
如果没有Survivor区,Eden区每进行一次Minor GC,并且没有年龄限制的话,存活的对象就会被送到老年代。 这样一来,老年代很快被填满并触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。
老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。
执行时间长有什么坏处?频发的Full GC消耗的时间很长,会影响大型程序的执行和响应速度。
所以Survivor区存在的意义就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor区的预筛选保证只有经历16次Minor GC还在新生代中存活的对象,才会被移动到老年代。
3)为什么需要两个Survivor区?
解决内存碎片问题。
有一个Survivor space永远是空的,另一个非空的Survivor space无碎片。
4)为什么Eden:S0:S1是8:1:1?
1、实际上GC是在新生代内存使用达到**90%**时开始进行的,通过复制回收算法将存活的对象复制到S1区。此时可知道S1区占比10%,也就是说新生代使用内存和S1的比例为9:1。
2、GC结束后在S1区存活下来的对象,需要放回给S0区,也就是S1和S0对调(名称互换)。既然可以对调,那么可知S1和S0的大小是一样的。由此可得出结论:还有一个占新生代10%空间的S0区域,所以新生代中Eden:S0:S1 = 8:1:1。
3、GC所清理的90%的新生代内存就是
Eden区
(80%)和S0区
(10%)
4、虚拟机栈-Java Virtual Machine Stack
虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态,为线程私有。
一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建。 每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。
每调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。
4.1、栈帧结构 Frames
每次调用方法时都会创建一个新栈帧。当一个栈帧的方法调用完成时,不管该完成是正常的还是突然的(它抛出一个未捕获的异常),它都会被销毁。帧是从创建帧的线程的Java虚拟机堆栈分配的。每个帧都有自己的局部变量数组、自己的操作数堆栈和对当前方法类的运行时常量池的引用。
一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
1)局部变量表 Local variable Table
方法中定义的局部变量以及方法的参数存放在这张表中,局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。
-
在编译程序代码的时候就可以确定栈帧中需要多大的局部变量表,具体大小可在编译后的 Class 文件中看到。
-
局部变量表的容量以 Variable Slot(变量槽)为最小单位,每个变量槽都可以存储 32 位长度的内存空间。
-
在方法执行时,虚拟机使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法,那局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用(在方法中可以通过关键字 this 来访问到这个隐含的参数)。
-
其余参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot。
-
基本类型数据以及引用和 returnAddress(返回地址)占用一个变量槽,long 和 double 需要两个。
2)操作数栈 Operand Stacks
-
方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作(与 Java 栈中栈帧操作类似)。
-
操作栈调用其它有返回结果的方法时,会把结果 push 到栈上(通过操作数栈来进行参数传递)。
3)动态链接 Dynamic Linking
-
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。
-
在类加载阶段中的解析阶段会将符号引用转为直接引用,这种转化也称为静态解析。另外的一部分将在运行时转化为直接引用,这部分称为动态链接。
4)返回地址 Return Address
当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。
4.2、结合字节码指令理解Java虚拟机栈和栈帧
1)源代码
package com.coy.gupaoedu.study.jvm;
/**
* 编译:javac StackFrameTest.java
* 反编译:javap -p -v StackFrameTest.class
*
* @author chenck
* @date 2020/7/30 10:01
*/
public class StackFrameTest {
public static void main(String[] args) {
add(5, 7);
}
private static int add(int a, int b) {
int c = 0;
c = a + b;
return c;
}
}
2)反编译
反编译后生成字节码。
javap -p -v StackFrameTest.class
3)字节码分析
主要分析 StackFrameTest.add(int,int)
方法。下面去掉了不相关的字节码。
JVM指令可参考官方文档
https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-3.html
Compiled from "StackFrameTest.java"
public class com.coy.study.jvm.StackFrameTest
minor version: 0 // JDK 最低版本号
major version: 52 // JDK 最高版本号
flags: ACC_PUBLIC, ACC_SUPER // 访问标志
Constant pool: // 常量池
... ...
private static int add(int, int);
// 方法描述
// 括号内表示入参类型。即 (II) 表示两个 int 类型参数
// 括号外表示返回类型。即 I 表示返回 int 类型
descriptor: (II)I
// 访问标志:这里表示私有静态方法
flags: ACC_PRIVATE, ACC_STATIC
// 代码块
Code:
// 操作数栈为2
// 本地变量数为3
// 入参个数为2
stack=2, locals=3, args_size=2
0: iconst_0 // 将int类型常量0压入[操作数栈]
1: istore_2 // 将[操作数栈]栈顶元素取出,并保存到[局部变量2]
2: iload_0 // 从[局部变量0]中装载值压入[操作数栈]
3: iload_1 // 从[局部变量1]中装载值压入[操作数栈]
4: iadd // 将[操作数栈]栈顶元素取出,执行int类型的加法,结果压入[操作数栈]
5: istore_2 // 将[操作数栈]栈顶元素保存到[局部变量2]
6: iload_2 // 从[局部变量2]中装载值压入[操作数栈]
7: ireturn // 返回[操作数栈]栈顶元素
// 行号表,表示代码行号与字节码行号的对应关系
LineNumberTable:
line 17: 0 // 表示第17行代码,对应的指令为0
line 18: 2 // 表示第18行代码,对应的指令为2
line 19: 6 // 表示第19行代码,对应的指令为6
}
SourceFile: "StackFrameTest.java"
4)方法执行过程中栈帧的变化
分析 add(5,7)
的执行过程。最后 ireturn
会将操作数栈的栈顶元素返回调用方。
5、程序计数器-The PC Register
程序计数器记录的是正在执行的虚拟机字节码指令的地址,可以认作是当前线程的行号指示器。 为线程私有。
在执行Native方法时程序计数器为空。
程序计数器占用的内存空间很小,由于Java虚拟机的多线程是通过线程轮流切换,并分配处理器执行时间的方式来实现的,在任意时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能够恢复到正确的执行位置,每条线程需要有一个独立的程序计数器(线程私有)。
6、本地方法栈-Native Method Stack
如果当前线程执行的方法是Native类型的,这些方法就会在本地方法栈中执行,为线程私有。