区域划分
根据《Java虚拟机规范》JVM会把它管理的内存划分为若干个不同的数据区域,如下图所示:方法区、堆、栈(虚拟机栈、本地方法栈)、程序计数器。线程私有的意思是指,JVM每遇到一个新的线程就会为他们分配栈和程序计数器。
PS:
(1)非线程共享区域的生命周期与所属线程相同,而线程共享区域与JAVA程序运行生命周期相同,GC只发生在线程共享的区域。
(2)程序计数器无内存溢出异常,其他四个区域会抛出OutofMemoryError异常。
接下来我们一个一个分析JVM里这些区域是如何支撑我们的java代码跑起来的。
程序计数器
假设我们有一个类,HelloWorld:
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World");
}
}
这段代码首先会存在于“.java”后缀文件中,这个文件就是java源代码文件,此时通过编译器,把“.java”后缀的源代码文件编译为“.class”后缀的字节码文件。
这个“.class”后缀的字节码文件中存放的就是我们程序代码编译好的字节码了。
public java.lang.String getName();
descriptor:()Ljava/lang/String;
flags:ACC_PUBLIC
Code:
stack=1,local=1,args_size=1
0:aload_0
1:get_field #2
4:areturn
这里的字节码并不是完全对照HelloWorld这个类来写的,就是一段例子。
比如这里的“0: aload_0”这样的东西,就是“字节码指令”,他对应了一条一条的机器指令,计算机只有读到这种机器码指令,才知道具体应该要干什么。
总之,各种各样的指令,会指示计算机去干各种各样的事情。
所以:我们写好的java代码会被编译成字节码,对应各种字节码指令,接下来,在执行字节码指令事,JVM里的程序计数器就是用来记录每个线程当前执行的字节码指令的位置的,记录当前线程目前执行到哪一条字节码指令。因为会有多线程来并发的执行各种不同的代码,所以每个线程都有自己的一个程序计数器,专门记录当前线程目前执行到哪一条字节码指令了。
虚拟机栈
java代码执行的时候,一定是线程在执行某个方法中的代码,哪怕是是上面的HelloWorld,也会有一个main线程来执行main方法里的代码。
在方法里,经常会定义一些方法内的局部变量,
public void sayHello() {
String name = "hello";
}
所以JVM必须有一块区域来保存每个方法内的局部变量,这个区域就是虚拟机栈
每个线程都会去执行各种方法的代码,方法内还会嵌套调用其他的方法,所以每个线程都有自己的虚拟机栈
如果线程执行了一个方法,那么就会为这个方法调用创建对应的一个栈帧
举例,比如一个线程调用了上面写的“sayHello”方法,那么就会为“sayHello”方法创建一个栈帧,压入线程自己的虚拟机栈中。在栈帧的局部变量表就会有“name”的局部变量。
下图展示了这个过程:
接着如果“sayHello”方法调用了另外一个“greeting”方法:
public void sayHello(){
String name="hello";
greeting(name);
}
public void greeting(String name){
String greet=name+",greeting";
System.out.println(greet);
}
这是会给“greeting”方法又创建一个栈帧,压入线程的虚拟机栈里,因为此时开始执行“greeting”方法了。
而且“greeting”方法的栈帧的局部变量表里会有一个“greet”变量,这是“greeting”方法的局部变量。
如果“greeting”方法执行完毕了,就会把“greeting”方法对应的栈帧从虚拟机栈里出栈。接下如果“sayHello”方法也执行完了,“sayHello”方法也会从虚拟栈出栈。
这就是JVM中的 “ 虚拟机栈 ” 这个组件的作用:调用执行任何方法的时候,都会给方法创建栈帧,然后入栈。
而在栈帧里存放了这个方法对应的局部变量之类的数据,包括这个方法执行的其他相关的信息,方法执行完毕之后就出栈。
同时在一个栈帧中,至少要包含局部变量表,操作数栈和帧数据等几个部分。
- 局部变量表中存放了编译器可知的各种基本数据类型(boolean,byte,char,short,int,float,long,double)、对象引用(reference类型,不等同于对象本身,根据不同的虚拟机实现,它可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或者其他与此对象相关的位置)和return address类型(指向一条字节码指令的地址)。
- 操作数栈主要用于保存计算过程中的中间结果,同时作为计算过程中变量临时的存储空间,操作数栈也是一个先进后出的数据结构,只支持入栈和出栈两种操作。把局部变量区的东西拿过来入栈,出栈等等
a =2;
b = 3;
c = a + b;
return c;
c = a +b 时会把局部变量表的a 和 b拿过来入栈,进行运算
堆
JVM中有另一个非常关键的区域,就是堆内存,这里存放的使我们代码中创建的各种对象,比如下面的代码:
public void sayHello(String name) {
Student student = new Student(name);
student.study();
}
上面的“new Student(name)”就是创建了一个Student类型的对象实例,Student的“name”就是属于这个对象实例的数据,类似这样的对象,就会存放在堆内存中。
Java堆内存区域里会放入类似Student的对象,然后方法的栈帧的局部变量表里,会存放这个引用类型的“student”局部变量,即存放Student对象的地址。
相当于你可以认为局部变量表里的“student”指向了Java堆里的Student对象。
方法区
这个方法区在JDK1.8以前的版本中,代表JVM中的一块区域。
主要存放类似Student类自己的信息的,平时用到的各种类的信息,都是存放在这个区域里,还有一些类似常量池的东西放在这个区域里。
但在JDK1.8之后,这个区域改名叫“Metaspace”,可以认为是“元数据空间”的意思。当然还用来存放我们自己写的各种相关的类的信息。
本地方法栈
其实在JDK很多底层API里,比如IO相关的,NIO相关的,网络Socket相关的,如果去看内部源码,会发现很多地方都不是Java代码了。
很多地方都会调用native方法,去调用本地操作系统里的一些方法,可能调用的都是C语言写的方法,或者一些底层类库,比如:
public native int hashCode();
在调用native方法时,就会有线程对应的本地方法栈,这个里面也是跟虚拟机栈类似的,也是存放各种native方法的局部变量表之类的信息。
堆外内存
这个区域是不属于JVM的,通过NIO中的allocateDirect这种API,可以在Java堆外分配内存空间。
然后通过Java虚拟机里的 DirectByteBuffer 来引用和操作堆外内存空间,其实很多技术都会用这种方式,因为在一些场景下,堆外内存分配可以提升性能。
总结
我们的代码是通过JVM来运行时:
1. 首先会一行一行的执行编译好的字节码指令。
2. 然后在执行的过程中,对于方法的调用,会通过虚拟机栈来为每个方法创建栈帧来入栈和出栈,而且栈帧里有方法的局部变量表。
3. 对于对象的创建,会分配到堆内存里
4. 对于类信息的存放会存放在方法区/Metaspace里。
另外有两个特殊的区域:
-
本地方法栈:在调用native方法时用的栈,和虚拟机栈类似
-
堆外内存:可以在Java堆外分配内存来存储一些数据。
参考文档:《深入理解JAVA虚拟机》周志明著
下一章分析一下JVM内存溢出问题