第三个遗留问题,就是引用和对象是什么?这个问题会在“面向对象编程”详细讲解,在这里提出这个问题主要是借机学习一下JVM运行原理(这个知识非常重要!!!)。
JVM在执行Java程序的过程中会把本进程所管理的内存划分为五个部分,分别是程序计数器、虚拟机栈、本地方法栈、方法区和堆。
第一部分:程序计数器
类似于PC寄存器,是一块较小的内存空间,它可以看作是当前线程所执行的字节码指令的行号指示器。为了让线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器。如果线程正在执行一个Java方法,这个计数器记录的是正在执行的字节码指令的地址;如果正在执行的是Native方法,计数器值为Undefined。
第二部分:虚拟机栈
与程序计数器一样,虚拟机栈也是线程私有的,JVM创建新线程的同时会为这个线程创建一个虚拟机栈,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个帧栈,用于储存局部变量表、操作数和方法出口等信息,当前被执行的帧栈会被放在栈顶,方法调用结束帧栈弹出。帧栈分为局部变量区、操作数栈和帧数据区三个部分,方法中的局部变量就是存储在局部变量区的。注意虚拟机栈中存储的变量没有默认初始化,所以局部变量必须要初始化。
第三部分:本地方法栈
类似虚拟机栈,区别在于虚拟机栈是为执行Java方法服务的,本地方法栈是为执行Native方法服务的。
第四部分:方法区
是被各个线程共享的内存区域,用于存储已被JVM加载的类型信息、常量池(上篇文章的两座大山之一)、静态变量、即时编译器编译后的代码等数据。其中类型信息包括类及其父类的全限定名、类的类型(Class or Interface)、访问修饰符(public、abstract、final)、实现的接口的全限定名列表、字段信息(仅包括字段名。类型和修饰符)和方法信息(包括方法的字节码);常量池用于存放编译器生成的各种字面量和符号引用(暂时不知道这是啥)。方法区还有一部分是运行时常量池,它不仅包括常量池的所有内容,也有可能加入运行期间产生的新的常量(比如String的intern()方法)。方法区中存储的静态变量是默认初始化的,所以静态变量不必须初始化。
注:java7字符串常量池从方法区移到堆中。java8 整个常量池从方法区中移除。方法区使用元空间(MetaSpace)实现
第五部分:堆
堆也是被各个线程共享的内存区域,是JVM所管理内存中最大的一块,此区域存在的唯一目的就是存放对象实例(其实就是存储对象的实例变量)。按照类型信息创建对象实例时,会为这个实例分配内存。堆中存储的变量是默认初始化的,所以实例变量不必须初始化。
注:栈空间不足会报*Error,堆和常量池空间不足会报OutOfMemoryError
下面通过程序示例说明JVM的工作原理(以单线程为例):
public class Test22 {
private static int a=1;//静态变量a存储在方法区,字面量1存储在运行时常量池
private final int b=2;//字面量2存储在运行时常量池
private int c=3;//字面量3存储在运行时常量池
private String str_1="abc";//字面量"abc"存储在运行时常量池
private String str_2=new String("abc");//字符串没有被创建
public static void main(String[] args){
A obj=new A(25,"k");
obj.toMyPrint(1);
System.out.println(a);//静态变量可以直接在静态方法中调用
//!System.out.println(b); 非静态变量不可以直接在静态方法中调用
//!System.out.println(c); 非静态变量不可以直接在静态方法中调用
//!System.out.println(str_1); 非静态变量不可以直接在静态方法中调用
//!System.out.println(str_2); 非静态变量不可以直接在静态方法中调用
//aa(); 静态方法可以直接在静态方法中调用
//!bb(); 非静态方法不可以直接在静态方法中调用
}
public static int getA() {
return a;
}
public static void setA(int a) {
Test22.a = a;
}
public int getC() {
return c;
}
public void setC(int c) {
this.c = c;
}
public String getStr_1() {
return str_1;
}
public void setStr_1(String str_1) {
this.str_1 = str_1;
}
public String getStr_2() {
return str_2;
}
public void setStr_2(String str_2) {
this.str_2 = str_2;
}
public int getB() {
return b;
}
public static void aa(){}//静态方法aa()的字节码被存储在方法区,可以使用类.方法调用
public void bb(){}//方法bb()的字节码被存储在方法区,由于没有创建Test22对象所以无法调用该方法
}
public class A { private String name="abc";//此时常量池中已经存在"abc",不用再创建 private int age=20; //构造函数的重载版本 public A(){} public A(String name){this(); this.name=name; } public A(int age,String name){ this(name); this.age=age; } public void toMyPrint(int x){ int y=x; myPrint(y); } public void myPrint(int z){ System.out.println(z); } public String getName() { return name; } public void setName(String name) { this.name = name; } public int getAge() { return age; } public void setAge(int age) { this.age = age; }}
下面结合程序和内存图例说一下JVM的运行原理:
- 启动程序后,操作系统会为其启动一个JVM进程,并为它分配一片内存空间
- JVM将这片内存空间分为上文说的五个部分
- JVM从classpath找到Test22.class,读取文件中的二进制数据,将Test22类的类型信息、静态变量a存储进方法区,并将字面量存储进方法区中的运行时常量池。这个过程称为类的加载
- JVM先将静态变量a默认初始化为0,然后再将运行时常量池中的字面量1赋给它
- JVM读取方法区中Test22类的main()方法的字节码,然后在虚拟机栈创建main()帧栈并执行main()方法
- JVM执行语句A obj=new A(25,"k"); 首先加载A类,然后声明A类引用obj并存储进main()帧栈,再然后在堆上创建A类对象并给成员变量默认初始化,同时调用构造函数A(age, name),在虚拟机栈创建A(age,name)帧栈并放到栈顶,再然后创建局部变量age和name,赋值并存储到帧栈,再然后继续调用两个构造函数A(name)和A(),重复之前过程,三个构造函数分别调用结束,它们的帧栈弹出虚拟机栈,最后将A类对象赋给A类引用obj。
- JVM执行语句obj.toMyPrint(1); 先在虚拟机栈上创建toMyPrint()帧栈并放到栈顶,然后声明int型变量x和y并存储到帧栈,最后给x和y赋值1。
- JVM执行语句myPrint(y); 先在虚拟机栈上创建myPrint()帧栈并放到栈顶,声明int型变量z并存储到帧栈,再然后给z赋值1,最后打印z的值到控制台上。
- myPrint()方法和toMyPrint()方法接连结束调用,它们的帧栈弹出虚拟机栈。
- JVM执行main()方法的最后一条语句打印静态变量a的值到控制台。
- main()方法结束调用,帧栈弹出虚拟机栈。
- 进程结束。