jvm内存模型和垃圾回收机制
一、Java虚拟机组成部分
执行流程
Java虚拟机中的类加载子系统将字节码文件load到运行时数据区(jvm内存模型)里面,然后通过执行引擎(跟CUP相关)跟内存区域做交互执行程序
二、jvm内存模型
java8以后jvm大致可以分为栈,堆,本地方法栈,程序计数器和方法区(元空间)5个区域。栈、本地方法栈、程序计数器这三种是线程私有的,每一个线程在运行过程中会单独开辟这样一份内存。堆区和方法区是全局共享的。
1.本地方法栈
本地方法栈保存的是native方法的信息,当一个JVM创建的线程调用native方法后,JVM不再为其在虚拟机栈中创建栈帧,JVM只是简单地动态链接并直接调用native方法
2.程序计数器
较小的内存空间,当前线程执行的字节码的行号指示器;各线程之间独立存储,互不影响;指向线程马上要执行的下一行代码对应的内存指针
3.方法区
存储元数据信息,jdk1.7之前又叫做永久代,jdk8后改为元数据空间,主要存储一些常量、静态(static)的方法或者变量、类元信息(反射调用的类元信息,字节码解析出的方法、常量之类的信息 ,描述了类里面大概的代码的组成)以及类加载器(classloader)等等全局的数据信息
在1.8以后,严格来讲方法区不算是jvm内存,它实际上引用了机器的直接内存1
4.栈
存储方法运行过程中的一些临时变量。
描述的是java 方法执行的内存模型:
每个方法被执行的时候 都会创建一个“栈帧”用于存储局部变量表2 (包括参数)、操作栈、方法出口等信息。每个方法被调用到执行完的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。声明周期与线程相同,是线程私有的。
栈区实际存储的是对象的引用类型,即存储的是一个对象的地址,最终指向堆区实际存在的对象
5.堆
堆区主要用来存new的对象
三、jvm内存中各个区的作用与联系
1.例一:
public class Math {
public static final Integer CONSTANT = 666;
public int math() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.math();
}
}
在以上代码中
main方法程序开始运行的过程中,java虚拟机马上在内存空间里面给这个线程分配一个栈内存空间(栈帧),用来放局部变量。程序接着执行,执行到math方法。发现a,b,c这三个变量也是需要内存区域来存放的,就会在内存区域中给math方法开辟一块栈帧区域。3
1.1 内存中的栈帧结构
栈帧数据结构
栈帧(Stack Frame)是用来支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。
栈帧(Stack Frame)存储了方法的局部变量表、操作数栈、动态连接、和方法出口、额外的附加信息。
每个方法在执行的同时,都会创建一个栈帧(Stack Frame)。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
- 局部变量表
局部变量表(Local Variable Table)是一组变量值存贮空间,用于存放方法参数和方法内定义的局部变量。在Java程序编译为Class文件时候,就在方法的Code属性的max_locals数据项中确定了该方法所需要分配的局部变量表的最大容量。单位为Slot。
局部变量表的容量以变量槽(Variable Slot)为最小单位。每个变量槽都可以存储32位长度的内存空间,例如boolean、byte、char、short、int、float、reference。
对于64位长度的数据类型(long,double),虚拟机会以高位对齐方式为其分配两个连续的Slot空间,也就是相当于把一次long和double数据类型读写分割成为两次32位读写。
其中:8bit=1b [一个Byte等于8个bit(位)],1024b=1kb
为了节省栈帧空间,局部变量表中的Slot是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。优点 : 节省栈帧空间。 缺点 : 影响到系统的垃圾收集行为。(如大方法占用较多的Slot,执行完该方法的作用域后没有对Slot赋值或者清空设置null值,垃圾回收器便不能及时的回收该内存。)
- 操作数栈
操作数栈(operand Stack)也常称为操作栈,它是一个后入先出栈。和局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks中。
操作数栈的每一个元素可用是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型占用的栈容量为2。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法执行的过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者在调用其它方法的时候是通过操作数栈来进行参数传递的。
- 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
Class文件的常量池中存在大量符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数,这些符号引用一部分在类加载阶段中的解析阶段会转为直接引用,这种转化也称为静态解析。另外的一部分将在每一次运行时期转化为直接引用。这部分称为动态连接。
- 方法返回地址
当一个方法开始执行后,只有2种方式可以退出这个方法 :
方法返回指令 : 执行引擎遇到一个方法返回的字节码指令,这时候有可能会有返回值传递给上层的方法调用者,这种退出方式称为正常完成出口。
异常退出 : 在方法执行过程中遇到了异常,并且没有处理这个异常,就会导致方法退出。
无论采用任何退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信。用来帮助恢复它的上层方法的执行状态。
方法退出的过程实际上就等于把当前栈帧出栈,因此退出可能执行的操作有:1.恢复上层方法的局部变量表和操作数栈2.把返回值(如果存在返回值)压入调用者栈帧的操作数栈中3.调整PC计数器的值以指向方法调用指令后面的一条指令
- 附加信息
虚拟机规范允许具体的虚拟机实现增强一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息取决于具体的虚拟机实现。
栈帧的内存分配
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的Code属性中了,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体虚拟机的实现。
当前栈帧(Current Stack Frame)
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是最有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有的字节码指令都只针对当前栈帧进行操作。在概念模型上,典型的栈帧结构图如下:
1.2 代码执行过程中栈的变化
这里我们以Math类中的math方法为例子
int a =1
- 执行int a =1,会先把1这个常量分配到操作数栈中.,如下图所示:
- 然后jvm虚拟机会在局部变量表中给a这个局部变量分配一块内存区域
- 把操作数栈里面的1出栈然后赋值给局部变量表里的a
int b = 2;
int c = (a + b) * 10;
- int b = 2在栈中的变化类似与上面的int a = 1,把2分配到操作数栈,然后给b在局部变量表中分配一块内存,并将2出栈赋值给b
- int c = (a + b) * 10
从局部变量中拿出a变量的值,放入临时的操作数栈里面
从局部变量中拿出b变量的值,放入临时的操作数栈里面4
- 执行int类型的加法5。执行之后,再将10压入操作数栈中,然后再执行int类型的乘法,最后把结果重新压回到栈里
- jvm给c在局部变量表里分配一块内存,将操作数栈里的30出栈,赋值给c
- 从局部变量表中load出c的值,然后return。
1.3 代码执行过程中各个区域的联系
math方法执行完毕之后要通过方法出口返回到主方法对应的线程,记录了一些math方法调用完了之后返回到main方法可能需要的一些信息。
public class Math {
public static final Integer CONSTANT = 666;
public int math() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.math();
}
}
- main方法里有一个局部变量math,它的值是new出来的Math对象(假定这个对象为math0),new出来的对象(math0)默认情况下是放在堆上面的,所以这个局部变量math有一个指针指向堆里面的math0。
假设在这个main方法中还有一个对象math2
public class Math {
public static final Integer CONSTANT = 666;
public int math() {
int a = 1;
int b = 2;
int c = (a + b) * 10;
return c;
}
public static void main(String[] args) {
Math math = new Math();
math.math();
Math math2 = new Math();
math2.math();
}
}
- math2这个变量同样也new出来了一个Math对象,在局部变量表中也有一个引用指针指向堆里的Math对象,math和math2对象都是由同一个类new出来的6。
- 在每一个对象里面有一个对象头,对象头里面由一个指针指向new出来这个对象的类模板的类元信息上面去。
1.4 垃圾回收算法
在堆中gc的过程中,判断是否需要将对象清理的标准是GCRoot7
1.4.1 标记-清理算法:
最简单的算法就是先扫描一遍所有对象,再在对象后面打标。
如果是要删除的都标记,就对它进行标记。
标记完了之后,再扫描一遍所有对象,把含有标记的对象删除。
缺点:会产生内存碎片
1.4.2 标记-整理算法:
在清除无效对象之后,后面的对象要补上来,会减少内存的碎片。
缺点:代价太大(删除标记对象之后,其他所有对象都要往前移)
1.4.3 赋值算法:
将整个内存一分为二(1区和2区),1区上创建对象,进行标记,等到1区快满了的时 候,将1区的数据复制到2区(只复制不需要删除的对象,需要删除的不复制)
缺点:需要两倍的内存
1.4.4 堆内存中实际的GC:
堆里面主要分为年轻代和老年代,年轻代又分Eden区和Survivor区,Survivor区又分为From区和To区。给堆分配600M的内存,默认情况下老年代占400M(3/2)的空间,年轻代占200M(3/1).。年轻代里面Eden区占160M(8/10)、Survivor区占40M(2/10,其中From和To各占一半)
-
最先new出来的对象放在Eden区
-
不断地有new出来地对象放在Eden区,当Eden区放满之后,Java虚拟机的执行引擎会开启一个线程,做一个叫做minor gc8的操作,清理无效的对象。
-
剩余的有效对象会移动到Survivor区里面的From区,随着程序继续new对象,重复前面的步骤,From区域也会很快的放满。From区放满之后,gc会清空From的无效对象
-
剩下还有引用的对象,java虚拟机会将其挪到To区域,随着程序继续执行,继续往Eden区new对象不断执行往复的过程,From区执行gc之后,To区变成新的From区域,下一次Eden执行gc存活的对象会放到新的From区9。
2.例二:
public class Main {
public static void main(String[] args) {
int a = 10;
new Main().func1(a);
System.out.println(a);
}
public void func1(int a) {
int b = 10;
System.out.println(a+b);
a = 11;
}
}
运行结果为:
20
10
func1():
-
栈:在运行过程中,拿到了一个参数a
-
a在栈中申请空间,值为10,大小为4个字节
b在栈中申请空间,值为10,大小为4个字节(值类型,没有引用) -
打印a+b = 20
-
最后将11赋值给a
程序运行结束,删除栈空间,根据先进后出的原则,先删除b,再删除a,最后将内存区域全部删除
func1()的栈区前面紧跟着main()的栈区,在main()中声明了a =
10,然后运行了func1(),最后打印了a。在打印a的时候func1()的内存已经全部删除了,所以打印的a取的是main()的栈区的a值。(栈区内部进行的任何修改都不影响外部的值)
public class Main {
public static void main(String[] args) {
int a = 10;
new Main().func1(a);
System.out.println(a);
}
public void func1(int a) {
int b = 10;
Person p = new Person();
p.id = 1;
p.name = "zhangsan";
System.out.println(a+b);
a = 11;
}
}
class Person{
int id;
String name;
}
func1():
-
栈:在运行过程中,拿到了一个参数a
-
a在栈中申请空间,值为10,大小为4个字节
b在栈中申请空间,值为10,大小为4个字节(值类型,没有引用) -
先运行new Person(),到堆上开辟一块内存,存入一个Person对象10(new是给对象开辟内存的关键字)
在栈中创建一个指针p11,p中只存储了一个地址(int类型,占4个字节),地址指向堆中的Person对象
p.id = 1:p在栈中拿到的是一个地址类型,就通过地址区寻址,找到堆中的Person对象所在的内存,将id设为1 -
p.name = “zhangsan”:(String类型也是一个对象),先在堆上创建一个String对象,String对象由一个Char数组(值类型)组成,存入”zhangsan”,(java的基础数据类型都是值类型,指针也是值类型,因而是直接存到内存,不是存地址区寻址)。p在栈中通拿到地址指向堆中Person里面的name,Person里的name里面存储的也是一个Int类型的地址值,指向String的地址。
-
打印a+b = 20
-
最后将11赋值给a
堆中对象不能够随着方法运行完毕自动清理的(无法直接清空),这里就用到了GC机制。
方法区:main方法存在于方法区,或者类似与static Integer i = 10;
i存在与方法区,不同方法的栈中都可以调用i
程序在启动过程中要把很多字节码加载到方法区,字节码大小是无法预估的,很容易造成java虚拟机内存溢出,所以jvm做了一个优化,将它放到直接内存中,1.8以后,对直接内存做了一个动态扩容算法 ↩︎
局部变量表存放了编译器可知的各种基本数据类型(boolean、byte、char、short、int、float、long、 double)、对象引用(引用指针,并非对象本身),其中64位长度的long和double类型的数据会占用2个局部变量的空间,其余数据类型只占1 个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在栈帧中分配多大的局部变量是完全确定的,在运行期间栈帧不会改变局部 变量表的大小空间。 ↩︎
因为栈是一种先进后出的数据结构,所以main方法对应的栈帧在math方法对应的栈帧的下面(出栈的时候先销毁math方法,后销毁main方法) ↩︎
这里只是把变量的值拿过来,变量本身还在局部变量表中 ↩︎
加减乘除四则运算都是从操作数栈栈顶弹出两个元素来执行,再将计算结果重新压入操作数栈中 ↩︎
类通过java虚拟机的类装载子系统,加载到java虚拟机里面的内存。Math的字节码文件最终加载到java虚拟机里面的方法区里面。 ↩︎
指被栈或者本地方法栈直接或间接引用、方法区静态static的变量或常量是不能被删除的 ↩︎
发生在年轻代(Young)Eden区的GC称为minorGC,也叫YoungGC,采用复制算法 ↩︎
这里的From(S0)区和To(S1)区是交替工作的(复制算法)。
Eden +S0 复制到 S1
Eden +S1 复制到 S0
如此反复交替运行 ↩︎刚开始是一个空对象,这个对象由两部分组成,一部分是地址,一部分是数据信息(id和name) ↩︎
p是引用类型,p是指针,p是地址是一个意思,p是只占4字节的地址 ↩︎