一文说清楚jvm 内存模型 & 栈上分配& 标量替换
今天简单讲一下jvm 内存模型(JDK1.8版本)
jvm 内存模型主要可以分为以下几个模块
- 堆内存
- 栈内存
- 本地方法栈
- 方法区
- 程序计数器
堆内存
其实开发过程中我们多多少少都听说过:“我们创建出来的对象都在堆内存里面”,这个的确是没有错的,我们创建出的对象大多数都在堆内存里面,但是请注意,我说的是大多数对象,不是全部的对象,而且,堆内存也不仅仅是存放我们创建出来的对象,还有一些其他的信息,比如类的反射信息,没错,我们在用反射的时候,其实是有一个类的反射对象在堆内存的,我们调用反射方法其实就是调用者反射类的方法
栈内存
栈内存,也叫线程栈,所谓线程栈,就是每个线程运行时所拥有的的一块专属的内存空间,jvm会在创建的线程时给线程分配,每个每个线程在执行方法的时候,又会给每个方法分配独立的内存空间,这块空间称之为栈帧,一个方法对应一个栈帧空间,栈帧空间与栈帧空间之间相互独立,并且存在当前线程栈之中,而栈帧里面又包括一些其他的信息,比如
- 局部变量表
- 操作数栈
- 动态链接
- 方法出口
下面我就来讲一下每一块内存都是什么意思,有什么作用
局部变量表:
就是用来存放当前方法内部的局部变量的
操作数栈:
假如我在方法内申明了一个数字类型的变量,或者进行了数值方面的运算,这个时候就会用到操作数栈
动态链接:
这个很难理解,我举个例子,我们都知道,java是一门支持多态的语言,子类可以重新父类的方法,而我们执行方法的时候,在编译期间是不知道具体执行的是子类的的方法还是父类的方法的,这个只有在运行的时候才能拿知道,这样的话,这个运行的方法就是动态链接,所谓动态链接,其实就是存放的具体执行方法的代码的地址
方法出口:
方法出口很好理解,就是a方法调用了b 方法,b方法执行完毕值,要回到被调用的地方,这个时候会需要一个内存来存放这个代码的地址,这就类似于我们打游戏进入副本,刷完副本之后你会回到进入副本前的位置,方法出口就是记录这个进入副本前的位置的
本地方法栈:
本地方法栈也很好理解,我们都知道java是1995年诞生的,在此之前基本都是C/C++的天下,很多东西都是C/C++实现的,所以我们java 在执行某些方法的时候,会调用C++ 代码(就是虚拟机目录下dll文件,调用的过程就类似于我们调用了一个第三方jar包),这个方法调用的过程被称之为调用本地方法,而本地方法执行所需要的内存都是在本地方法栈里面的
程序计数器:
这个也是每一个线程独有的,java是一门多线程语言,每个线程可以独立运行代码,当我们线程a运行method方法的时候,运行到一半,时间片用完了,这个时候线程b也来运行method方法,也运行到一半运行完了,时间片回到线程a,这个时候,需要 一个内存空间来记录当前线程运行到哪一行代码了,这个程序计数器就是来记录运行代码的行记录
方法区:
这个区域可能会存放好几块信息
- 常量池
- 代码元数据
- 静态变量
- klass对象
常量池:
很好理解,和名字一样,就是用来存放常量的,就是我们用final修饰的变量,当然,还有一种情况,我们string a=“abc”
这个abc 字符串也会放在常量里面,还有integer包装类 0-128 这几个数字也在常量,当然其他包装类型也有一些数据放在常量池里面,这里不展开讲,有兴趣评论区留言,到时候单独出一期来讲一讲
代码元数据:
这个就是我们写的代码,我们写的代码就是存放在这块空间的
静态变量:
就是被我们用static修饰的变量,这个也会被放在方法区
klass对象:
么错,就是klass 这个不是我们java的class对象,是jvm使用的klass对象,这个是jvm使用的,是C++ 的对象
例子
我上面讲可能比较抽象,下面我来举一个例子,如下代码
public Math {
public static final String str="111";
public Math math=new Math();
public static Math math1=new Math();
public void method(){
int a=10;
int b=20;
int c=(a+b)*10;
Math math4=new Math();
String s=math.str;
Math math5=math.math;
}
public Math method2(){
Math math2=new Math();
math2.str="100";
return math2;
}
public static void main(String[]args){
Math math3=new Math();
math3.method();
math3.hashcode();
math3.method2();
}
}
Math 类有普通放,一个main方法,有两个静态变量,一个普通变量,
- 这些代码信息就是存放在我们的方法区里面的
- 两个静态变量,变量名存放在方法区,但是创建出来的对象放在堆内存,普通属性,也就事math属性,math 变量名称放在栈空间,而创建出来的对象放在堆空间
- 我们来运行代码,首先,我们运行main函数,jvm 会创建一个线程,这个线程我们称之为主线程,jvm会给主线程分配内存,也就是给主线程自己独立的 线程栈空间,然后主线程来运行main方法,这个时候,jvm会在主线程的线程栈里面划分出一块空间用来运行main方法,这个空间就是mian方法的栈帧空间,
- 我们来看第一行代码 Math math3=new Math(); 我们创建了一个对象,对象名称叫math3 ,按照上面的我说的逻辑,这个math3 会放在main栈帧空间的局部变量表里面,然后创建出来的这个对象放在对内存里面,注意,局部变量表里面的math3 其实存放的是 math3 这个对象的在堆内存的内存地址,
- 然后第二行代码,math3.method(); 我们执行了Math类的mathod 方法,这个时候,jvm又会在当前线程的线程栈空间里面划分出一个栈帧空间用来供 method方法 使用, 注意这个栈帧空间也是在当前线程栈里面,但是是和main方法线程栈隔开的,是相互独立的
- 然后我们进入mathod方法第一行代码,int a=10; 这个时候,当前栈帧空间里面 的局部变量表里面已经有 三个局部变量了:a,b,c(这个局部变量在分配线程栈空间的时候就会存在,只不过那时候都是默认值,没有赋予真正的值),然后jvm 会进行赋值操作,jvm 会把 10 压入操作数栈,然后在把10 赋值给局部变量表里面的a ,这个时候 局部变量表里面的a 才会变成a=10
- 第二行代码和第一行一样,先把20 压入操作数栈,然后赋值给局部变量表里面的b
- 第三行代码,jvm 会吧 a的10 压入操作数栈(注意,上一步执行完赋值操作之后,操作数栈就空了),然后把b的20 压入操作数栈,然后把 10 和20 传给cpu ,cpu进行运行,得到30 ,然后把30 压回操作数栈,然后10 被压入操作数栈,然后10 和 30 被压入cpu,cpu运行得到300,300被压入操作数栈,然后300 被赋值给 局部变量表里面的c,这个时候c =300。
- 以上就是栈空间的局部变量表和操作数栈的用法
- 接着我们继续往下看,method方法执行完毕,方法返回mian,这个时候,jvm怎么知道需要返回到main方法的哪一行代码呢,这个时候时候,方法出口就排上用场了,method方法的栈帧的方法出口记录了mathod 方法运行完毕之后,jvm应该返回到调用mathod方法代码的具体位置,然后接着往下执行,这就是方法出口的作用
- 接下来执行math3.hashcode(); 注意,hashcode 是math父类object类方法,这个方法在调用的时候才知道具体执行的是父类的还是子类的方法,这种在运行期间才能确定的方法我们也需要用一块内存来记录,这块内存就是动态链接,动态链接具体存储的就是运行的具体代码的位置。
- hashcode 方法运行完毕之后,然后jvm运行下一行代码math3.method2();
- 其实mathod2 方法 和mathod 差不多,也是 局部变量存 栈的局部变量表里面,然后对象存在堆内存,这里不在重复,我这里说一个重点,就是他们的返回值,在mathod方法中是没有返回值的,也就是说在mathod创建的对象,他的作用域没有超过这个方法本身(这个过程被称为对象的逃逸分析,就是判断这个对象的作用范围有没有逃逸出这个方法本身),所以,在这种情况下,jvm其实不会把创建出来的对象放在堆内存,而是会直接放在栈内存,应为这样方法结束之后,栈帧空间被回收,对象也就是直接回收了,而如果放在堆内存里面,堆内存满了,需要做gc,gc会产生stw,这样影响性能。
- 除了对象栈上分配之外,其实jvm还会做一件事,就是标量替换,因为如果对象本身没有逃逸出这个方法,其实jvm在方法内部使用的的就是对象的属性和方法罢了,如果是属性,我直接给属性分配内存并且赋值就可以了,如果是方法,那么就再申请一块栈帧空间,这样的话,我连对象都不用创建的了(创建对象本身也需要 内存空间,在64位机器上开启指正压缩为16个字节,对象头8个字节,对象指针4个字节,对其填充位4个字节),所以其实连对象都不用创建,只要给对象的属性赋值就可以了,这个过程被称之为标量替换