JVM运行时数据区
在c/c++中,创建对象需要先申请内存,使用完后自己再释放内存。
在java中,开发人员不需要去申请和回收内存,都交给了JVM进行管理。
Java是编译执行的一门语言,我们常说java源文件编译成class字节码文件,字节码文件再被加载到JVM中去执行。
我们安装的JDK,安装到磁盘上就是一堆文件。安装的JDK中包含JRE(JRE也叫JAVA运行环境),JRE也就是JVM。将JRE这一堆文件加载到内存中成为一个进程,也就是JVM进程/java进程。
JVM运行时数据区就是指JVM进程的内存分配情况。
JAVA进程会有一个线程共享的区域,所有的线程都能访问,当每开启一个线程时,JVM会为该线程分配一块线程独占的内存区域
- 线程共享的内存区域:所以线程都能访问的内存区域,随着虚拟机或者GC而创建和销毁
- 线程独占的内存区域(线程的工作区):只有该线程能够访问,其他线程不能访问,当该线程执行完后,这块内存就会被回收,线程独占的内存区域的生命周期和该线程同步。
JVM内存区域划分:
-
堆内存又被分为新生代(Youn)和老年代(Old)。新生代又被划分为Eden、From Survivor、To
Survivor。这样划分的目的是为了使JVM能够更好的管理堆内存中的对象,包括内存的分配和回收。
新生代占堆内存的1/3空间,老年代占堆内存的2/3空间。
在新生代中,Eden占8/10,From和To区域分别占新生代的1/10。
线程独占的内存区域又分为虚拟机栈、本地方法栈、程序计数器。
线程共享的区域又分为方法区和堆内存。
线程共享的区域
方法区
作用:存储加载的类信息、常量、静态变量、JIT(Just In Time)编译后的代码等数据。
类信息:也叫元信息,指这个类能反应的所有信息,比如属于哪个包、类名、实现了哪些接口、有什么属性、有什么方法等等。
上面说到java源文件被编译成.class的字节码文件,字节码文件被加载到JVM中去执行。字节码文件就是被加载到方法区,也就是说类的所有信息都会被加载到方法区。
方法区存储加载的信息是以类对象的形式进行存储,也就是常说的Class对象。比如一个类Test,常用Test.class来获取类信息,其实获取到的就是类对象的引用,类对象存储在方法区。
- 方法区(jdk1.7叫方法区,1.8叫元空间):
- 数据区: 用来存储加载的类文件的数据结构。class文件经过类加载器加载到方法区的数据区,类对象中存在一个引用指向数据区的该类的数据结构,这样通过类对象才能访问数据区的该类的信息。
- 类信息:这个类能反应的所有信息,比如属于哪个包、类名、实现了哪些接口、有什么属性、有什么方法等等。
- 常量池:分为class常量池和运行时常量池,运行时的常量池是属于方法区的一部分,而Class常量池是属于类信息。
-
Class文件中的常量池:存储的是类的字面量和符号引用,字面量包括字符串,基本类型的常量。(仅仅存的是符号引用,这些引用并没有指向真正的对象)。
-
方法区中运行时常量池:当.class文件被加载到方法区时,Class文件中的常量池的内容会被加载到运行时常量池存放。在运行时常量池中,有一部分符号引用是会被转变为直接引用的,真正指向具体的对象或者字符串。
比如说类的静态方法或私有方法、实例构造方法、父类方法,因为这些方法不能被修改或重写,所以在.class文件加载到方法区时就将符号引用转变为直接引用。而其他的一些方法是在这个方法被第一次调用的时候才会将符号引用转变为直接引用的。
- 但是并不是只有class文件中常量池的内容才能进入方法区运行时常量池,也能后期代码运行期间将新的常量放入池中,比如:String类的intern()方法就能将String的值添加到String常量池中。String常量池是包含在运行时常量池里的,但在jdk1.8后,将String常量池放到了堆中。
所以说运行时常量池是.class文件的常量池在运行时的表示。
方法区存放类对象,所以也存在内存回收的问题
- 方法区存在垃圾回收,但回收效率低(存的是类的元数据,也就是这个类的元数据在被使用就不能被回收。
1.比如 实例化一个Demo对象,只要还有Demo对象没有被回收、在被使用,则类对象就不能被回收
2.只要有地方引用了Demo的类对象,拿着Demo.class这个引用,Demo的类对象也不能被回收)
方法区的内存回收条件很苛刻,每次做GC可能只会回收一点点内存
- 方法区的回收主要针对常量池的回收和类型的卸载
- 当方法区无法满足内存需求时,也会报OOM异常
类被用到时才会被加载到方法区,没有被用到时,会被回收,当再次被用到时,再把.class文件加载到方法区
堆内存
作用:唯一的目的就是存放对象实例,几乎所有的对象、数组都存放在这里。
对于大多数应用来说,堆是JVM管理的内存中最大的一块内存区域,也是最容易OOM的区域。
大多数JVM都将堆实现为大小可扩展的(通过-Xmx、-Xms控制)
堆中存的对象,到底存的是什么?
看图中的例子:
- 执行main方法,int x = 5; x是基础类型的局部变量,存放在栈上。
- 然后new一个Teacher对象,teacher引用存放在栈上,这时候会去把Teacher的二进制字节码指令加载到方法区,以类对象的形式存储。创建的teacher对象存放在堆中,栈上的teacher引用执行堆中的teacher对象。
- 在Teacher类中,静态变量age、静态方法和非静态方法是作为类信息存储在方法区。所以我们常说的对象存储在堆中,其实堆中存储的只有实例字段的值。
- 堆中的teacher对象存储实例字段值,若实例字段为基本数据类型则值存储在teacher对象上,否则teacher中也只是存引用,引用再指向具体的对象。如图中,name指向堆中的zhangsan字符串对象(也可能指向方法区的常量池中的字符串对象);student引用指向堆中的student对象。
- 所以常说的基础类型和对象的引用存放在栈这种说法是不正确的。
正确的说法是:以局部变量形式存在的变量存在栈中,以对象的字段/成员变量形式存在的变量存在堆中。 - 除了存储实例字段的值以外,堆中的对象还包含一个对象头和一个padding占位符。对象头指向方法区的类对象,这样才能区分堆中的对象是哪个类的实例对象。padding占位符的作用是对对象进行补齐,因为方便JVM进行管理,堆中存储对象的大小都是8个字节的整数倍(8*N byte)。对象头+数据的大小不一定是8个字节的整数倍,用padding来进行补齐。
- 上面所说的栈、堆、方法区、引用、对象等在JVM中都是以内存区域的形式体现。
对象何时被回收?
当一个对象没有在被使用,GC时就能把这个对象回收掉,怎么判断一个对象还有没有在被使用?
- 引用计数器算法
在对象中维护一个count值,初始为0,当被引用时count++;当引用撤销时,count–。GC进行回收时,判断count是否为0,当count为0时,就说明没有被引用,则将对象进行回收。
引用计数器算法存在一定的问题,
如图,当两个对象内部互相引用时,将d1和d2引用撤销后,Demo1和Demo2对象的count还不是0,这时即使对象不再使用了,也不会被回收,这时候就发生了内存泄漏。
所以现在商用的JVM(比如HotSpot VM、J9)都不使用引用计数器算法,都使用可达性分析算法。 - 可达性分析算法
主流的商用程序语言(java/C#)都是通过可达性分析算法来判定对象是否存活的。
从GC Roots开始通过引用向下搜索,能找到这个对象则表示可达,找不到则为不可达。不可达的对象会被回收。
如图中,object1、object2、object3、object4可达,不会被回收;object5、object6、object7不可达,会被回收。
GC Roots可以是:
1.虚拟机栈
2.方法中静态属性引用的对象(静态属性作为类对象的一部分存在于方法区,类对象/静态属性很难被回收)
3.方法区中常量池引用的对象(常量也很难被回收,只要是方法区的东西都很难被回收)
4.Native方法引用的对象
线程独占的区域
- 开启一个线程后,JVM为这个线程分配的独占的内存区域(只有该线程能够访问,其他线程不能访问),当这个线程运行结束后,该内存区域就会被回收,生命周期和该线程一样。
- 线程的工作区代表了这个线程,但一个Java线程不仅仅是一片内存区域,比如new Thread(): 会在堆中有一块内存存放thread对象,还会开启一块线程的工作区。
- 一个线程就是一个代码流,代码流:new Thread创建线程时需要重写run方法,run方法可能调用方法1,方法1可能调用方法2。当线程开启后(start后),执行顺序run >> 方法1 >> 方法2 (结束顺序:方法2先执行完 >> 方法1执行完 >> run执行完) 先执行的方法最后执行完,最后执行的方法最先执行完。(先进后出,后进先出,是不是很自然的想到了栈)
虚拟机栈
虚拟机栈:用来描述方法的先进后出的调用,线程中方法执行的模型,每个方法执行时,就会在虚拟机栈中创建一个栈帧(栈帧也是一块内存区域;栈帧/栈元素描述方法的调用,代表这个方法),每个方法从调用到执行的过程,就对应着栈帧在虚拟机栈中从入栈到出栈的过程。
main(){}中调用方法1,方法1调用方法2。当执行到一个方法时,JVM会在线程独占的内存区域为这个方法开辟一块内存(也就是栈帧),代表这个方法。然后这个方法入栈,进入虚拟机栈,方法执行完后,栈帧出栈,该栈帧的内存区域被回收。
栈帧
栈帧代表了一个方法,入栈出栈操作代表了这个方法的执行,栈帧的结构是什么样的呢?为什么能代表方法的执行?
栈帧包含了四大块内容:局部变量表、动态引用、操作数栈、返回值地址。
- 局部变量表
存放方法的局部变量。
一个方法的局部变量都存放在局部变量表中,局部变量的作用域仅仅是这个方法内部,方法执行完,局部变量就不起作用了,局部变量的生命周期就结束了。
public void test1 () {
int a = 5;
String name = "myname";
}
-
动态引用
指向方法区中该方法的代码。
上面已经说过了,不管是静态方法还是非静态方法都是作为类信息存储在方法区。栈帧这个结构就是为了执行方法,所以存在一个动态引用指向方法区中具体的代码,通过动态引用来找到具体的方法来执行。 -
操作数栈
是一个临时存储区,要用到某个数据时都会先经过操作数栈(都先存在操作数栈,再取出,运算结果也会先存在操作数栈)。
比如:加载二进制的字节码文件的参数时,先加载到操作数栈,再从操作数栈取出放到局部变量表。当要对局部变量表的参数进行操作时,从局部变量表取出数据放在操作数栈,再从操作数栈取出进行运行,再将运算结果放在操作数栈,再将结果从操作数栈取出放入局部变量表。
这里可能不太好理解,先看完下面的内容再回来理解就很容易了。 -
返回值地址
public void test1 () {
String str = test2();
}
public String test2 () {
return "test2";
}
如上,test1方法调用test2方法,test2方法有返回值。具体在JVM中执行时,会有两个栈帧代表test1和test2方法。test1栈帧知道在哪接收test2的返回值(开辟test1栈帧的哪块区域来接收test2的返回值),方法1调用方法2,在创建方法2的栈帧时,会告诉方法2的栈帧把返回结果返回到哪里去。也就是说,方法2的栈帧的返回值地址其实是方法1的栈帧的某一块区域,用来接收方法2的返回值。
这里可能有点绕,不太好理解。总结就是:返回值地址代表了当前栈帧执行完后的结果应该返回到哪个地方去(返回到上一个栈帧的哪个地方去)。
本地方法栈
和虚拟机栈功能类似,虚拟机栈是为虚拟机执行JAVA方法而准备的,本地方法栈是为虚拟机使用Native本地方法而准备的。
java中很多底层的代码,比如sleep(),再往下追踪,都是Native方法(是用c/c++写的)。jvm底层是c++写的,会给我们暴露出一些方法,像sleep(),再往下调用就是Native方法,Native方法在本地方法栈执行。
本地方法栈了解就行了,不用去深究具体怎么执行的。
直接内存:JVM之外的内存,开发人员自己分配和回收。
程序计数器
线程是为了执行二进制的.class文件(java文件编译后会生成指令表),指令表被加载到方法区。
每一个线程都有一个程序计数器,当线程开始执行时(也就是一步步执行指令表),多线程的情况下,每个线程都有自己的字节码指令要执行,JAVA的多线程是通过切换CPU上下文来实现多线程的执行。
当线程1执行一部分时CPU切换到线程2去执行线程2,线程2执行一部分再回到线程1(程序计数器来记录CPU执行字节码指令执行到那个地方了,CPU是不会记录哪个线程的字节码指令执行到哪个地方了)。
CPU多线程切换时,通过程序计数器找到当前线程下一个执行的指令,来进行多线程切换。
字节码的执行
模拟下面这段代码在JVM的执行过程:
编译后,在DOS窗口使用JDK自带的javap工具查看.class文件。
使用命令javap -verbose Demo
编译之后都是二进制指令,源文件中的代码都有对应的指令来描述。
首先将二进制指令加载到方法区,将Demo和Teacher类信息加载到方法区,在堆中创建Teacher对象,为线程开辟一块线程独占的内存区域,程序计数器为0,将main方法的栈帧入栈。
int a = 200;
第一行代码,定义了一个变量a,赋值为200。
在JVM中,会先将200这个值压入到操作数栈的栈底,局部变量是存在局部变量表,再将200放入到局部变量表中的第一个位置。指令执行到3。
局部变量表的第一个元素从逻辑上就是a。
再定义变量b,步骤和上面一样。先将100入栈,再放入局部变量表的第二个位置,指令执行到6。
局部变量表的第二个元素从逻辑上就是b。
我们定义的变量名a、b是不会出现在JVM中的,JVM只是将变量按序号存放在局部变量表中。
现在执行:
int c = a-b;
JVM并不知道什么a和b,这句代码在JVM中执行的是:局部变量表的第一个元素减局部变量表的第二个元素。也就是说方法中的每个变量对应局部变量表中的第几个位置在指令执行前就已经计算好了(编译的时候就已经对应好了)。
JVM执行:局部变量表的第一个元素减局部变量表的第二个元素。
会先将局部变量表的第一个元素压入操作数栈,再将局部变量表的第二个元素压入操作数栈。
然后执行减法操作,将200和100出栈,作为减法的两个参数。得到结果100,再将结果100入栈。
再将计算的结果100写入局部变量表。指令为10。
然后执行return,方法结束,栈帧出栈,内存被回收。
用JVM最大的好处就是JVM帮助我们进行内存的分配和回收。
方法区、堆内存、虚拟机栈、本地方法栈、程序计数器都是JVM运行时数据区,都是JVM进程的内存。