Java运行时数据区

目录

Java堆(Heap)

栈上分配

Java虚拟机栈

局部变量表

操作数栈

帧数据区

本地方法栈(Native Method Stack)

方法区(Method Area)

常量池

常量池存放什么?

常量池的分类

直接内存

本文脑图


Java堆(Heap)

对于大多数应用而言,Java堆(Heap)是Java虚拟机所管理的内存中最大的一块,它被所有线程共享。几乎所有的对象实例都在这里分配内存,且每次分配的空间是不定长的。再Heap中分配一定的内存来保存对象实例,实际上只是保存对象实例的属性值、属性的类型和对象本身的类型标记等,并不保存对象的方法(方法是指令,保存在Stack中)。对象实例在Heap中分配好之后,需要再Stack中保存一个4字节的Heap内存地址,用来定位该对象实例再Heap中的位置,以便找到该对象实例。

根据垃圾回收机制的不同,Java堆有可能拥有不同的结构。最常见的一种构成是将整个Java堆分为新生代和老年代。其中新生代存放新生对象或年量不大的对象,老年代则存放老年对象。新生代有可能分为eden区、s0区、s1区。s0和s1也称为from和to,他们是两块大小相等、可以互换角色的内存空间。

在绝对多数情况下,对象首先分配在eden区,再一次垃圾回收后,如果对象还存活就进入s0或者s1,之后没经过一次新生代的回收,如果对象存活,他的年龄就增加1。当年龄达到一定条件后,就会被认为是老年对象,从而进入老年代。

栈上分配

Java虚拟机规范中描述道:所有的对象实例以及数组都要在堆上分配,但是随着JIT编译器的发展和逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都在堆上分配的定论也并不“绝对”了。

栈上分配是Java虚拟机提供的一项优化技术,他的基本思想是,对于那些线程私有的对象(这是指不可能被其他线程访问的对象),可以将他们打散分配在栈上,而不是分配在堆上。分配在栈上的好处就是可以在函数调用结束后自行销毁,而不需要垃圾回收器的介入,从而提高系统性能。

栈上分布的技术基础是进行逃逸分析。逃逸分析的目的是判断对象的作用域是否有可能逃逸出函数体

Java虚拟机栈

Java虚拟机栈是一块线程私有的内存空间。如果说,Java堆是和程序数据密切相关,那么Java栈就是和线程执行密切相关。线程执行的基本单位是函数调用,而每次函数调用的数据都是通过Java栈传递的。

Java栈中保存的主要内容为栈帧,栈帧中存储着局部变量表、操作数栈、动态链接、方法出口等信息。每一次函数调用都伴随着对应栈帧的入栈,而当函数调用结束响应或者抛出异常,栈帧会被弹出Java栈。所以栈顶所对应的帧就是当前正在执行的函数。如下图:

 

局部变量表

局部变量表是栈帧重要组成部分。它保存函数的参数以及局部变量。局部变量表中的变量也只在当前函数中有效,当函数调用结束后,随着函数栈帧的弹栈,局部变量表也会随之销毁。

局部变量表以槽位(Slot)为最小单位,《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存大小,而是很有导向性的说每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。也就是说它屏蔽了底层实现上的差异,允许变量槽的长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化。

对于64位长度的long和double类型的数据会占用2个局部变量空间(Slot),其余数据类型只会占用一个。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间就已经确定,方法运行期间不会改变局部变量表的大小

操作数栈

操作数栈用于保存计算过程的中间结果,同时作为计算过程中变量的临时存储空间。当一个方法刚开始执行的时候,这个方法的操作数栈还是空的,在方法执行过程中各种字节码指令会往操作数栈中写入和提取内容,也就是出站和入栈操作。

上面的东西听起来太抽象,我们举个例子。我现在有如下方法:

public int add() {
    int a = 500;
    int b = 200;
    return a + b;
}

我们对他进行反编译,得到这个方法的字节码指令。接下来会画图来分析它的执行过程。

 

stack=2表示栈的最大深度为2,locals=3说明局部变量表有3个Slot。其中第0项目表示当前对象this,剩下的两个就是我们定义局部变量a、b,这样刚好是3个。

在字节码部分,左侧的数字符号表示字节码偏移量,既当前字节码所在的位置,字节码的偏移量总是和前几个字节码的长度相关,第一条字节码为sipush,自然偏移量为0,而sipush这条指定本身占用1个字节,但它接收一个双字节的参数500,故占3个字节。

 

如上图,只需计数器是0就表示执行第一条指令。第一条指令sipush的作用是将给定参数压入操作数栈,故执行完sipush 500后操作数栈中含有数字500。

store指令表示从操作数栈中弹出第一个元素,并将其放在局部变量表中。而为了尽可能的压缩指令大小,store_1后边的参数表示将弹出的元素放在局部变量表的第几个位置。所以执行store_1指令后,操作数栈的第一个元素500弹出,存入局部变量表1的位置。如下图:

 

接下来了sipush 200,还是将200压入操作数栈。istore_2将操作数栈第一个元素200弹出,存入局部变量表2的位置。此时内存数据如下:

指令iload_1将局部变量表第1个位置的值压入操作数栈,和store指令类似,后面的数字代表位置。所以iload_1、iload_2就是分别将局部变量表第1个位置的元素的和第2个位置的元素压入操作数栈。

接下来指令iadd表示吧操作数栈中最上面的两个元素弹出并做加法,然后再压回操作数栈。此时弹出操作数栈200和500相加得到700然后压栈,如下图

最后通过ireturn指令,表示将当前函数操作数栈顶元素弹出,并将这个元素压入调用者函数的操作数栈中(因为调用者非常关心函数的返回值),所以在当前函数操作数栈中其他元素会被丢弃。如果当前返回的synchronize方法,那么还会执行一个隐含的monitorexit指令退出临界区。最后,会丢弃当前方法的整个栈,恢复调用者的栈,并将控制权交给调用者。

帧数据区

除了局部变量表和操作数栈外,Java栈帧还需要一些数据来支持常量池解析、正常方法返回和异常处理等。大部分Java字节码指令需要进行常量池的访问,再帧数据区中保存访问常量池地址的指针,方便程序访问常量池。

此外,当函数返回或者发生异常时,虚拟机必须回复调用者函数的栈帧,并让调用者函数继续执行下去。对于异常处理,虚拟机必须有一个异常处理表,方便在发生异常的时候找到处理异常的代码,因此异常处理表也是帧数据区中重要的一部分。

public int add() {
    int c;
    int a = 500;
    int b = 200;
    try {
        c = a + b;
    } catch (Exception e) {
        throw new RuntimeException();
    }
    return c;
}

我们修改上文的程序,添加一段异常处理,反编译后的字节码文件就会多了一段异常处理表

它表示在字节码偏移量8~12字节处可能抛出“java/lang/Exception”异常,如果遇到异常,则跳转字节码偏移量15处执行。当方法抛出异常时,虚拟机就会查找类似的异常表来进行处理,如果无法在异常表中找到合适的处理方式,则会结束当前函数,返回调用函数,并在调用函数中抛出相同的异常,并查找调用函数的异常处理表。

本地方法栈(Native Method Stack)

本地方法栈与Java虚拟机栈的作用很相似,他们的区别在于虚拟机栈为虚拟机执行Java方法(既字节码)服务器。而本地方法栈则为虚拟机使用到的Native方法服务。

在虚拟机规范中对本地方法栈中使用的语言、方式和数据结构并无强制规定,因此具体的虚拟机可实现它。甚至有的虚拟机(Sun HotSpot虚拟机)直接把本地方法栈和虚拟机栈合二为一。与虚拟机一样,本地方法栈会抛出StackOverflowError和OutOfMemoryError异常。

 

方法区(Method Area)

和Java堆一样,方法区也是一块所有线程共享的内存区域。它用于保存系统的类信息,比如类的字段、方法、常量池等。方法区的大小决定了系统可以保存多少个类,如果系统定了太多的类,导致方法区溢出,虚拟机同样会抛出内存溢出错误。

在JDK1.7、1.7中,方法区可以理解为永久区(Perm)。而后在JDK1.8中,永久区已经被彻底移出,取而代之的是元数据区,元数据区是一块堆外直接内存。与永久区不同的是,如果不指定大小,默认情况下,虚拟机会耗尽所有的可用系统内存,所以这个区域也就不会发生OutOfMemoryError。

常量池

常量池可以比喻为Class文件里的资源仓库,它是Class文件结构中与其他项目关联最多的数据,通常也是占用Class文件空间最大的数据项目之一。

由于常量池中常量的数量是不固定的,所以在常量池的入口需要放置一项u2类型的数据,代表常量池容量计数值。这个容量的计数是从1开始的而不是0,这样的好处就是,如果后面某些只想常量池索引的数据在特定情况下需要表达“不引用任何一个常量池”的含义,可以把索引值设置为0。

常量池存放什么?

常量池中主要存放两大类常量:字面量(Literal)和符号引用(Symbolic References)。字面量比较接近于Java语言层面的常量概念,如文本字符串、被声明为final的常量值等。而符号引用则是一组符号用来描述所引用的目标,可以是任何形式的字面量,只要使用时能够无歧义的定位到目标就行。

常量池的分类

常量池以表的形式存在,实际可以分为两种,一种为静态常量池运行时常量池

静态常量池就是*.class文件中常量池,字符串字面值、类信息、方法的信息等,占用了较大部分的空间

运行时常量池用于存放编译期生成的各种字面量符号引用,这部分内容将在类加载后存放到方法区的运行时常量池中。java虚拟机完成类加载后的操作,将class文件中的常量加载到内存中,并保证在方法区

运行时常量池具有动态性,在运行期间也能产生新的常量放入池中。常量不一定要在编译期间产生,也可以在运行期间产生新的常量放入池中

我们初学Java的时候一定看过的面试题如下:

public static void main(String[] args) {
    String str1 = "shitian";
    String str2 = "shitian";
    System.out.println(str1 == str2);
}

str1==str2打印是什么?在java中符号“==”是用来比较地址,我们对这段代码进行反编译,得到如下字节码指令:

ldc字节码指令表示将常量池中的常量入栈,而偏移量0和3获取的常量是相同地址#2,所以我们就可以得出str1和str2都指向了同一个地址,所以结果是true;

所以我们就可以看出,常量池可以避免因为频繁的创建和销毁对象,从而导致系统性能降低,也实现了对象的共享,既可以节省内存空间,也可以节省运行时的时间。

直接内存

直接内存(Direct Memory)并不是虚拟机运行时数据区的一部分,也不是《Java虚拟机规范》中定义的内存区域。但是这部分内存也被频繁地使用,而且也可能导致OutOfMemoryError异常出现。

在JDK 1.4中新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆里面的DirectByteBuffer对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

显然,本机直接内存的分配不会受到Java堆大小的限制,但是,既然是内存,则肯定还是会受到本机总内存(包括物理内存、SWAP分区或者分页文件)大小以及处理器寻址空间的限制,一般服务器管理员配置虚拟机参数时,会根据实际内存去设置-Xmx等参数信息,但经常忽略掉直接内存,使得各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常

本文脑图

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值