Java虚拟机2:Java内存区域

1.几个计算机的概念

为以后写文章考虑,也为巩固自己的知识和一些基本概念,这里要理清楚几个计算机中的概念。

1、计算机存储单位

从小到大依次为位Bit字节Byte千字节KB兆M千兆GBTB,相邻单位之间都是1024倍,1024为2的10次方,即:

  • 1Byte = 8bit
  • 1K = 1024Byte
  • 1M = 1024K
  • 1G = 1024M
  • 1T = 1024G

2、计算机存储元件

寄存器:中央处理器CPU的一部分,是计算机中读写速度最快的存储元件,但是容量很少

内存:属于独立的一个部件,是和CPU沟通的桥梁,用于存放CPU中的运算数据以及与外部存储器交换的数据。尽管在今天,对内存的读写速度已经很快了,但是由于寄存器是在CPU上的,所以对于内存的读写速度和对于寄存器的读写速度上还是有几个数量级的差距。但是没办法,对于内存的读写I/O操作是很难消除的,寄存器数量有限,不可能通过寄存器来完成所有的运算任务

3、内核空间和用户空间

连接内存和寄存器的是地址总线,地址总线的宽度影响了物理地址的索引范围,因为总线宽度决定了处理器一次可以从寄存器或内存中获取多少个Bit,同时也决定了处理器最大可以寻址的地址空间。比如32位CPU的系统,可寻址范围为0x00000000~0xFFFFFFFF,即232=4294967296个内存位置,每个内存位置1个字节,即32位CPU系统可以有4GB的内存空间。不过应用程序是不可以完全使用这些地址空间的,因为这些地址空间被划分为了内核空间和用户空间,程序只能使用用户空间的内存。内核空间主要是指操作系统运行时所使用的用于程序调度、虚拟内存的使用或者链接硬件资源的程序逻辑。区分内核空间和用户空间的目的主要是从系统的稳定性的角度考虑的。Windows 32操作系统默认内核空间和用户空间的比例是1:1,即2G内核空间、2G内存空间,32位Linux系统中默认比例则是1:3,即1G内核空间,3G内存空间。

4、字长

CPU的主要技术指标之一,指的是CPU一次能并行处理二进制的位数(Bit)。通常称处理字长为8位数据的CPU为8位CPU,32位CPU就是在同一时间内处理字长为32位的二进制数据。不过目前虽然CPU大多是64位的,但还是以32位字长运行

2.前言

说到Java内存区域,可能很多人第一反应是“堆栈”。首先堆栈不是一个概念,而是两个概念,堆和栈是两块不同的内存区域,简单理解的话,堆是用来存放对象而栈是用来执行程序的。其次,堆内存和栈内存的这种划分方式比较粗糙,这种划分方式只能说明大多数程序员最关注的、与对象内存分配关系最密切的内存区域是这两块,Java内存区域的划分实际上远比这复杂。对于Java程序员来说,在虚拟机自动内存管理机制的帮助下,不再需要为每一个new操作去配对delete/free代码,不容易出现内存泄露和内存溢出问题。但是,也正是因为Java把内存控制权交给了虚拟机,一旦出现内存泄露和内存溢出的问题,就难以排查,因此一个好的Java程序员应该去了解虚拟机的内存区域以及会引起内存泄露和内存溢出的场景。

3.运行时数据区域

Java虚拟机(JVM)内部定义了程序在运行时需要使用到的内存区域:

image

之所以要划分这么多区域出来是因为这些区域都有自己的用途,以及创建和销毁的时间。有些区域随着虚拟机进程的启动而存在,有的区域则依赖用户线程的启动和结束而销毁和建立。

线程共享内存区: 方法区和堆,

线程私有内存区: 虚拟机栈、本地方法栈、程序技术器,基本上随着线程产生和消亡,也就是说生命周期和线程相同,因此基本不需要考虑内存回收的问题,编译时确定所需内存大小。

从这个分类角度来看一下这几个数据区。

3.1、线程独有的内存区域

(1)PROGRAM COUNTER REGISTER,程序计数器

这块内存区域很小,它是当前线程所执行的字节码的行号指示器,字节码解释器通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,跳转,循环等基础功能都要依赖它来实现。

这里需要注意三点内容:

  • 每条线程都有一个独立的程序计数器,各线程间的计数器互不影响,因此该区域是线程私有的。
  • 当线程在执行一个Java方法时,该计数器记录的是正在执行的虚拟机字节码指令的地址,也就是说有值,当线程在执行的是Native方法(调用本地操作系统方法)时,该计数器的值为空
  • 另外,该内存区域是唯一一个在Java虚拟机规范中没有规定任何OOM(内存溢出:OutOfMemoryError)情况的区域,也就是说此块区域不会抛出内存溢出的异常。

(2)JAVA STACK,虚拟机栈

  • 该区域也是线程私有的,它的生命周期也与线程相同

  • 虚拟机栈也就是我们平常所称的栈内存,它是为java方法服务的,描述了java方法执行的内存模型

  • 每一个方法从调用直至执行完毕的过程,就对应着一个栈帧在虚拟机中入栈到出栈的过程。

  • Java方法执行的内存模型:每个方法被执行的时候都会同时创建一个栈帧,栈帧用于存储局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。栈它是用于支持续虚拟机进行方法调用和方法执行的数据结构。对于执行引擎来讲,活动线程中,只有栈顶的栈帧是有效的,称为当前栈帧,这个栈帧所关联的方法称为当前方法,执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作。在编译程序代码时,栈帧中需要多大的局部变量表、多深的操作数栈都已经完全确定了,并且写入了方法表的Code属性之中。因此,一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。

  • 栈的大小通常在256K~756K之间,具体和JVM的实现有关。

  • 在Java虚拟机规范中,对这个区域规定了两种异常情况

                    1、如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常。
    
                    2、如果虚拟机在动态扩展栈时无法申请到足够的内存空间,则抛出OutOfMemoryError异常。
    

    这两种情况存在着一些互相重叠的地方:当栈空间无法继续分配时,到底是内存太小,还是已使用的栈空间太大,其本质上只是对同一件事情的两种描述而已。在单线程的操作中,无论是由于栈帧太大,还是虚拟机栈空间太小,当栈空间无法分配时,虚拟机抛出的都是StackOverflowError异常,而不会得到OutOfMemoryError异常。而在多线程环境下,则会抛出OutOfMemoryError异常。

下面详细说明栈帧中所存放的各部分信息的作用和数据结构。

** 1、局部变量表**

局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量,其中存放的数据的类型是编译期可知的各种基本数据类型、对象引用(reference(不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置))和returnAddress类型(它指向了一条字节码指令的地址)。

局部变量表所需的内存空间在编译期间完成分配,即在Java程序被编译成Class文件时,就确定了所需分配的最大局部变量表的容量。当进入一个方法时,这个方法需要在栈中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

局部变量表的容量以变量槽(Slot)为最小单位。在虚拟机规范中并没有明确指明一个Slot应占用的内存空间大小(允许其随着处理器、操作系统或虚拟机的不同而发生变化),一个Slot可以存放一个32位以内的数据类型:boolean、byte、char、short、int、float、reference和returnAddresss。reference是对象的引用类型,returnAddress是为字节指令服务的,它执行了一条字节码指令的地址。对于64位的数据类型(long和double),虚拟机会以高位在前的方式为其分配两个连续的Slot空间。

虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始到局部变量表最大的Slot数量,对于32位数据类型的变量,索引n代表第n个Slot,对于64位的,索引n代表第n和第n+1两个Slot。

在方法执行时,虚拟机是使用局部变量表来完成参数值到参数变量列表的传递过程的,如果是实例方法(非static),则局部变量表中的第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字“this”来访问这个隐含的参数。其余参数则按照参数表的顺序来排列,占用从1开始的局部变量Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的Slot。

局部变量表中的Slot是可重用的,方法体中定义的变量,作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超过了某个变量的作用域,那么这个变量对应的Slot就可以交给其他变量使用。这样的设计不仅仅是为了节省空间,在某些情况下Slot的复用会直接影响到系统的而垃圾收集行为。

** 2、操作数栈**

  • 操作数栈又常被称为操作栈,主要用来存储运算结果以及运算的操作数。
  • 操作数栈的最大深度也是在编译的时候就确定了。32位数据类型所占的栈容量为1,64为数据类型所占的栈容量为2。
  • 它不同于局部变量表通过索引来访问,而是通过压栈和出栈的方式:当一个方法开始执行时,它的操作栈是空的,在方法的执行过程中,会有各种字节码指令(比如:加操作、赋值等)向操作栈中写入和提取内容。
  • Java虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。因此我们也称Java虚拟机是基于栈的,这点不同于Android虚拟机,Android虚拟机是基于寄存器的。基于栈的指令集最主要的优点是可移植性强,主要的缺点是执行速度相对会慢些;而由于寄存器由硬件直接提供,所以基于寄存器指令集最主要的优点是执行速度快,主要的缺点是可移植性差。

** 3、动态连接**

每个栈帧都包含一个指向运行时常量池(在方法区中,后面介绍)中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。Class文件的常量池中存在有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用,一部分会在类加载阶段或第一次使用的时候转化为直接引用(如final、static域等),称为静态解析,另一部分将在每一次的运行期间转化为直接引用,这部分称为动态连接。

** 4、方法返回地址**

当一个方法被执行后,有两种方式退出该方法:执行引擎遇到了任意一个方法返回的字节码指令或遇到了异常,并且该异常没有在方法体内得到处理。无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行。方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值就可以作为返回地址,栈帧中很可能保存了这个计数器值,而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出站,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,则把它压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令。

(3)NATIVE METHOD STACK,本地方法栈

和虚拟机栈起的作用一样,只不过方法栈为虚拟机使用到的Native方法服务。虚拟机规范并没有对这个区域有什么强制规定,因此我们使用的HotSpot虚拟机,就干脆没有这块区域了,它和虚拟机栈是一起的。

3.2、线程间共享的内存区域

(1)HEAP,堆

  • Java Heap是虚拟机所管理的内存中最大的一块,它是所有线程共享的一块内存区域,JVM启动时创建。
  • 此内存区域的唯一目的就是存放对象实例和数组,几乎所有的对象实例都要在这里分配内存。
  • Java Heap 是垃圾收集器管理的主要区域,因此 很多 时候也被称为"GC堆"。由于现在垃圾收集器采用的基本都是分代收集算法,所以堆还可以细分为新生代和老年代,再细致一点还有Eden区、From Survivior区、To Survivor区等。(这个后面讲)
  • 根据Java虚拟机规范的规定,堆可以处在物理上不连续的内存空间中,只要逻辑上是连续的即可。如果在堆中没有内存可分配时,并且堆也无法扩展时,将会抛出OutOfMemoryError异常。

(2)METHOD AREA,方法区

这块区域用于存储虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,虚拟机规范是把这块区域描述为堆的一个逻辑部分的,但实际它应该是要和堆区分开的。从上面提到的分代收集算法的角度看,HotSpot中,方法区≈永久代。不过JDK 7之后,我们使用的HotSpot应该就没有永久代这个概念了,会采用Native Memory来实现方法区的规划了。默认最小值为16MB,最大值为64MB,可以通过-XX:PermSize 和 -XX:MaxPermSize 参数限制方法区的大小。

  • 方法区也是各个线程共享的内存区域,它用于存储已经被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 方法区又被称为“永久代”,不过JDK7, JRockit和IBM J9等JVM已经没有永久代的概念了。
  • Java虚拟机规范把方法区描述为Java堆的一个逻辑部分,而且它和Java Heap一样不需要连续的内存,对于方法区的分配会采用Native Memory来实现。
  • 垃圾收集行为在这个区域比较少出现,该区域内存的回收目标是对废弃常量池和无用类的回收。
  • 运行时常量池是方法区的一部分,用于存放编译时期生成的各种字面量和符号引用,该常量池具有动态性。
  • 根据Java虚拟机规范的规定,当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常(OOM)。

(3)RUNTIME CONSTANT POOL,运行时常量池

Class文件中除了有类的版本信息、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译期间生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中,另外翻译出来的直接引用也会存储在这个区域中。这个区域另外一个特点就是动态性,Java并不要求常量就一定要在编译期间才能产生,运行期间产生的常量也会存在这个常量池中,String.intern()方法就是这个特性的应用。

关于字面量、符号引用和直接引用:

字面量相当于Java语言层面常量的概念,如文本字符串,声明为final的常量值等。

符号引用则属于编译原理方面的概念,包括了如下三种类型的常量:1、类和接口的全限定名 2、字段名称和描述符 3、方法名称和描述符。

直接引用可以是直接指向引用目标的指针、相对偏移量或者是一个能够间接定位到目标的句柄。直接引用是和虚拟机的内存布局有关的,同一个符号引用在不同的虚拟机上翻译的直接引用一般是不同的。如果有了直接引用,那么引用的目标必定是存在内存中的。

4、直接内存

直接内存并不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。它直接从操作系统中分配,因此不受Java堆大小的限制,但是还是会受到本机总内存(包括RAM、SWAP区)大小以及处理器寻址空间的限制,因此它也可能导致OutOfMemoryError异常出现。

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

5、总结

简单的总结一下:

  • 程序计数器(PC):Java线程私有,类似于操作系统里的PC计数器,用于指定下一条需要执行的字节码的地址;
  • Java虚拟机栈:Java线程私有,虚拟机展描述的是Java方法执行的内存模型:每个方法在执行的时候,都会创建一个栈帧用于存储局部变量、操作数、动态链接、方法出口等信息;每个方法调用都意味着一个栈帧在虚拟机栈中入栈到出栈的过程;
  • 本地方法栈:和Java虚拟机栈的作用类似,区别是该该区域为JVM调用到的本地方法服务;
  • 堆(Heap):所有线程共享的一块区域,垃圾收集器管理的主要区域。目前主要的垃圾回收算法都是分代收集,因此该区域还可以细分为如下区域: – 年轻代 – Eden空间 – From Survivor空间1,From Survivor空间2,用于存储在Young gc过程中幸存的对象; – 老年代
  • 方法区:各个线程共享的一个区域,用于存储虚拟机加载的类信息、常量、静态变量等信息;
  • 运行时常量池:方法区的一部分,用于存放编译器生成的各种字面量和符号引用;

你可能感兴趣的:(Java虚拟机2:Java内存区域)