类和对象运行时在内存里是怎么样的?各种变量、方法在运行时是怎么交互的?
在回答这个问题之前先了解一下Java的一些基础知识。
我们知道Java程序运行在虚拟机环境里,那我们先看一下虚拟机的大致内存结构。如下图所示,虚线框为整个虚拟机内存区域,其中有颜色的区域为Java程序所占的内存区域。
图中可见Java程序所占的内存区域可划分成5个部分:程序计数器、虚拟机栈(线程栈)、本地方法栈、堆(heap)和方法区(内含常量池)。其中方法区和堆由所有线程共享。
这5个区域作用和功能分别如下:
程序计数器:
它类似CPU寄存器中的PC寄存器,用于存放指令地址。因为Java虚拟机是多线程的,所以每一个线程都有一个独立的程序计数器结构,它与线程共存亡。不过Java虚拟机中的程序计数器指向的是正在执行的字节码地址,而CPU的PC寄存器指向的是下一条指令的地址。当线程去执行Native方法时,程序计数器则为Undefined。
虚拟机栈(线程栈):
一个线程一个栈,并且生命周期与线程相同。它内部由一个个栈帧构成,一个栈帧代表一个调用的方法,线程在每次方法调用执行时创建一个栈帧然后压栈,栈帧用于存放局部变量、操作数、动态链接、方法出口等信息。方法执行完成后对应的栈帧出栈。我们平时说的栈内存就是指这个栈。
一个线程中的方法可能还会调用其他方法,这样就会构成方法调用链,而且这个链可能会很长,而且每个线程都有方法处于执行状态。对于执行引擎来说,只有活动线程栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧关联的方法称为当前方法(Current Method)。
栈帧的大致结构如下图所示:
每一个栈帧的结构都包括了局部变量表、操作数栈、方法返回地址和一些额外的附加信息。某个方法的栈帧需要多大的局部变量表、多深的操作数栈都在编译程序时完全确定了,并且写入到类方法表的相应属性中了,因此某个方法的栈帧需要分配多少内存,不会受到程序运行期变量数据变化的影响,而仅仅取决于具体虚拟机的实现。
栈帧结构各部分功能:
局部变量区域:存储方法的局部变量和参数,存储单位以slot(4 byte)为最小单位。局部变量存放的数据类型有:基本数据类型、对象引用和return address(指向一条字节码指令的地址)。其中64位长度的long和double类型的变量会占用2个slot,其它数据类型只占用1个slot。
类的静态方法和对象的实例方法被调用时,各自栈帧对应的局部变量结构基本类似。但有以下如图示区别:实例方法中第一个位置存放的是它所属对象的引用。而静态方法则没有对象的引用。另外静态方法里所操作的静态变量存放在方法区。
void test(Object object)
{int i=0;
Boolean b=false;
}
static void test1(int i ,Object object,boolean b)
{
...
}
关于局部变量,还有一点需要强调,就是局部变量不像类的实例变量那样会有默认初始化值。所以局部变量需要手工初始化,如果一个局部变量定义了但没有赋初始值是不能使用的。
操作数栈 所谓操作数是指那些被指令操作的数据。当需要对参数操作时如c=a+b,就将即将被操作的参数数据压栈,如将a 和b 压栈,然后由操作指令将它们弹出,并执行操作。虚拟机将操作数栈作为工作区。Java虚拟机没有寄存器,所有参数传递、值返回都是使用操作数栈来完成的。
Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
例如下面这段代码:
public static int add(int a,int b){
int c=0;
c=a+b;
return c;
}
add(25,23);
主要步骤如图:
压栈的步骤如下:
0: ....
1: iload_0 // 把局部变量0压栈,int a;
2: iload_1 // 局部变量1压栈,int b;
3: iadd //弹出2个变量,求和,结果压栈48
4: istore_2 //弹出结果,放于局部变量2;int c;
5: ...
动态连接,它是个指向运行时常量池中该栈帧所属方法的引用。这个引用是为了支持方法调用过程中能进行动态连接。我们知道Class文件的常量池存有方法的符号引用,字节码中的方法调用指令就以指向常量池中方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。余下部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
方法返回地址
正常退出,执行引擎遇到方法返回的字节码,将返回值传递给调用者
异常退出,遇到Exception,并且方法未捕捉异常,返回地址由异常处理器来确定,并且不会有任何返回值。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
额外附加信息,虚拟机规范没有明确规定,由具体虚拟机实现。
Java虚拟机规范规定该区域有两种异常:
StackOverFlowError:当线程请求栈深度超出虚拟机栈所允许的深度时抛出
OutOfMemoryError:当Java虚拟机动态扩展到无法申请足够内存时抛出
另外需要提醒一下,在规范模型中,栈帧相互之间是完全独立的。但在大多数虚拟机的实现里都会做一些优化处理,这样两个栈帧可能会出现一部分重叠。这样在下面的栈帧会有部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以有部分数据共享,而无须进行额外的参数复制传递了。具体情形如下图所示:
本地方法栈
Java可以通过java本地接口JNI(Java Native Interface)来调用其它语言编写(如C)的程序,在Java里面用native修饰符来描述一个方法是本地方法。本地方法栈就是虚拟机线程调用Native方法执行时的栈,它与虚拟机栈发挥类似的作用。但是要注意,虚拟机规范中没有对本地方法栈作强制规定,虚拟机可以自由实现,所以可以不是字节码。如果是以字节码实现的话,虚拟机栈本地方法栈就可以合二为一,事实上,OpenJDK和SunJDK所自带的HotSpot虚拟机就是直接将虚拟机栈和本地方法栈合二为一的。
Java虚拟机规范规定该区域也可抛出StackOverFlowError和OutOfMemoryError。
堆(heap)
这个区域用来放置所有对象实例以及数组。不过在JIT(Just-in-time)情况下有些时候也有可能在栈上分配对象实例。堆也是java垃圾收集器管理的主要区域(所以很多时候会称它为GC堆)。
从GC回收的角度看,由于现在GC基本都是采用的分代收集算法,所以堆内存结构还可以分块成:新生代和老年代;再细一点的有Eden空间、From Survivor空间、To Survivor空间等。如下图:
方法区
它是虚拟机在加载类文件时,用于存放加载过的类信息,常量,静态变量,及jit编译后的代码(类方法)等数据的内存区域。它是线程共享的。
方法区存放的信息包括:
类的基本信息:
每个类的全限定名
每个类的直接超类的全限定名(可约束类型转换)
该类是类还是接口
该类型的访问修饰符
直接超接口的全限定名的有序列表
已装载类的详细信息:
运行时常量池:
类信息除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池(Constant Pool Table),用于存放编译期生成的各种字面量、符号引用,文字字符串、final变量值、类名和方法名常量,这部分内容将在类加载后存放到方法区的运行时常量池中。它们以数组形式访问,是调用方法、与类联系及类的对象化的桥梁。
这里再讲一下,JDK1.7之前运行时常量池是方法区的一部分,JDK1.7及之后版本已经将运行时常量池从方法区中移了出来,在堆(Heap)中开辟了一块区域存放运行时常量池。
运行时常量池除了存放编译期产生的Class文件的常量外,还可存放在程序运行期间生成的新常量,比较常见增加新常量方法有String类的internd()方法。String.intern()是一个Native方法,它的作用是:如果运行时常量池中已经包含一个等于此String对象内容的字符串,则返回常量池中该字符串的引用;如果没有,则在常量池中创建与此String内容相同的字符串,并返回常量池中创建的字符串的引用。不过JDK7的intern()方法的实现有所不同,当常量池中没有该字符串时,不再是在常量池中创建与此String内容相同的字符串,而改为在常量池中记录堆中首次出现的该字符串的引用,并返回该引用。
字段信息:
字段信息存放类中声明的每一个字段(实例变量)的信息,包括字段的名、类型、修饰符。
如privateStringa=“”;则a为字段名,String为描述符,private为修饰符。
方法信息:
类中声明的每一个方法的信息,包括方法名、返回值类型、参数类型、修饰符、异常、方法的字节码。(在编译的时候,就已经将方法的局部变量表、操作数栈大小等完全确定并存放在字节码中,在加载载的时候,随着类一起装入方法区。)
在运行时,虚拟机线程调用方法时从常量池中获得符号引用,然后在运行时解析成方法的实际地址,最后通过常量池中的全限定名、方法和字段描述符,把当前类或接口中的代码与其它类或接口中的代码联系起来。
静态变量:
就是类变量,被类的所有实例对象共享,我们只需知道,在方法区有个静态区,静态区专门存放静态变量和静态块。
到类ClassLoader的引用:到该类的类装载器的引用。
到类Class的引用:虚拟机为每一个被装载的类型创建一个Class实例,用来代表这个被装载的类。
Java虚拟机规范规定该区域可抛出OutOfMemoryError。
直接内存
直接内存(Direct Memory)虽然不是程序运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域,但这部分内存也被频繁使用,而且它也可能导致OutOfMemoryError异常出现。
在JDK1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native方法库直接分配堆外内存,然后通过一个存储在Java堆里面的DirecByteBuffer对象作为这块内存的引用进行操作。这样能在某些应用场景中显著提高性能,因为它避免了在Java堆和Native堆中来回复制数据。
显然,本机直接内存的分配不会受到Java堆大小的限制,但是,还是会受到本机总内存(包括RAM及SWAP区或者分页文件)的大小及处理器寻址空间的限制,从而导致动态扩展时出现OutOfMemoryError异常。
执行引擎
将字节码即时编译 优化 为本地代码, 然后执行。
在了解完这些知识以后,就可以知道:类和对象在运行时的内存里是怎么样的?以及各类型变量、方法在运行时是怎么交互的?
在程序运行时类是在方法区,实例对象本身在堆里面。
方法字节码在方法区。线程调用方法执行时创建栈帧并压栈,方法的参数和局部变量在栈帧的局部变量表。
对象的实例变量和对象一起在堆里,所以各个线程都可以共享访问对象的实例变量。
静态变量在方法区,所有对象共享。字符串常量等常量在运行时常量池。
各线程调用的方法,通过堆内的对象,方法区的静态数据,可以共享交互信息。
各线程调用的方法所有参数传递、方法返回值的返回,都是使用栈帧里的操作数栈来完成的。