JVM总结之内存区域
Java虚拟机在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区域。这些区域都有各自的用途,有的区域是线程共享的,有的区域是线程隔离的。如下图:
程序计数器
程序计数器(Program Counter Register)是一块较小的内存空间,它可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作时就是通过这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
程序计数器是线程私有的内存区域,保证线程切换后能恢复到正确的执行位置。
执行Java方法的时候,这个计数器记录的是正在执行的虚拟机字节码的指令的地址;执行native方法的时候,计数器的值为空(null)。
Java虚拟机栈
Java虚拟机栈(Java Virtual Machine Stacks)描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表、操作数栈、动态链接、方法出口灯信息。每个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
Java虚拟机栈也是线程私有的内存区域,生命周期和线程相同。
栈帧
在活动线程中,只有虚拟机栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),这个栈帧所关联的方法称为当前方法(Current Method)。执行引用所运行的所有字节码指令都只针对当前栈帧进行操作。栈帧的概念结构如下图所示:
- 局部变量表:局部变量表是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法表的Code属性的max_locals数据项中确定了该方法需要分配的最大局部变量表的容量。
- 操作数栈:操作数栈也常被称为操作栈,它是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也是编译的时候被写入到方法表的Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。栈容量的单位为“字宽”,对于32位虚拟机来说,一个”字宽“占4个字节,对于64位虚拟机来说,一个”字宽“占8个字节。
- 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属性方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。在Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用为参数。这些符号引用一部分会在类加载阶段或第一次使用的时候转化为直接引用,这种转化称为静态解析。另外一部分将在每一次的运行期期间转化为直接引用,这部分称为动态连接。
- 返回地址:在方法退出之前,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者PC计数器的值就可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器来确定的,栈帧中一般不会保存这部分信息。方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用都栈帧的操作数栈中,调用PC计数器的值以指向方法调用指令后面的一条指令等。
java虚拟机栈可以抛出如下异常:
- StackOverflowError:线程请求的栈深度大于JVM允许的深度,抛出该异常;
- OutOfMemoryError:如果虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存,抛出该异常。
本地方法栈
本地方法栈与虚拟机栈的作用是相似的,它们的区别是:
- 虚拟机栈为JVM执行的Java方法服务;
- 本地方法栈为JVM使用到的Native方法服务。
与虚拟机栈一样,本地方法栈也会抛出StackOverflowError和OutOfMemoryError。
Java堆
栈代表了处理逻辑,而堆代表了数据。
Java堆是Java虚拟机所管理的内存最大的一块,也是垃圾收集器管理的主要区域。
Java堆是所有线程共享的,堆中的共享常量和缓存可以被所有栈访问,节省了空间。
为了更好的回收内存,或者更好的分配内存,将Java堆细分为新生代和老年代。
- 新生代(还可细分为den区、Form Survivor区和To Survivor区)
- 老年代
关于每个区如何回收内存,何时回收内存在下一篇中再总结。
堆的大小也是可以调整的,可以通过虚拟机参数-Xmx和-Xms控制。同样,在该区域,如果没有内存分配给对象实例,并且堆也无法再扩展,会抛出OutOfMemoryError异常。
方法区
方法区主要用来存储已经被JVM加载的类信息、常量、静态变量等。
在过去(当自定义类加载器使用不普遍的时候),类几乎是“静态的”并且很少被卸载和回收,因此类也可以被看成“永久的”。另外由于类作为JVM实现的一部分,它们不由程序来创建,因为它们也被认为是“非堆”的内存。
方法区也是所有线程共享的内存区域。
方法区和Java堆一样,也是需要进行垃圾回收的,该区域的内存回收目标主要是针对常量池的回收的和对类型的卸载。
当方法区无法满足内存分配需求时,将抛出OutOfMemoryError异常。
Hotspot废除永久代
永久代的问题
永久代是Hotspot中的一个概念,其他JVM的实现未必有,例如JRockit就没有(只要不触碰进程可用内存上限就不会出问题)。Hotspot使用在内存中划分出一块区域来存储类的元信息、类变量以及内部字符串等,称为永久代,把它作为方法区来使用。
永久代是一段连续的内存空间,我们在JVM启动之前可以通过设置-XX:MaxPermSize的值来控制永久代的大小,32位机器默认的永久代的大小为64M,64位的机器则为85M。永久代的垃圾回收和老年代的垃圾回收是绑定的,一旦其中一个区域被占满,这两个区都要进行垃圾回收。
- 问题1:由于我们可以通过‑XX:MaxPermSize 设置永久代的大小,一旦类的元数据超过了设定的大小,程序就会耗尽内存,并出现内存溢出错误(OOM)。
- 问题2:永久代中的元数据可能会随着每一次Full GC发生而进行移动。并且为永久代设置空间大小也是很难确定的,因为这其中有很多影响因素,比如类的总数,常量池的大小和方法数量等。
- 问题3:HotSpot虚拟机的每种类型的垃圾回收器都需要特殊处理永久代中的元数据。
迎来元空间
Hotspot在Java8中将类的元数据移到了一个与堆不相连的本地内存区域,这个区域就是元空间。
由于类的元数据分配在本地内存中,元空间的最大可分配内存就是系统可用内存空间。用户可以通过-XX:MaxMetaspaceSize为元空间设置一个空间可用最大值,如果不进行设置,JVM会根据类的元数据大小动态增加元空间的容量。
每一个类加载器的存储区域都称作一个元空间,所有的元空间合在一起就是我们一直说的元空间。当一个类加载器被垃圾回收器标记为不再存活,其对应的元空间会被回收。在元空间的回收过程中没有重定位和压缩等操作。但是元空间内的元数据会进行扫描来确定Java引用。
元空间虚拟机负责元空间的分配,其采用的形式为组块分配。组块的大小因类加载器的类型而异。在元空间虚拟机中存在一个全局的空闲组块列表。当一个类加载器需要组块时,它就会从这个全局的组块列表中获取并维持一个自己的组块列表。当一个类加载器不再存活,那么其持有的组块将会被释放,并返回给全局组块列表。
元空间存在的问题
元空间虚拟机采用了组块分配的形式,同时区块的大小由类加载器类型决定。类信息并不是固定大小,因此有可能分配的空闲区块和类需要的区块大小不同,这种情况下可能导致碎片存在。元空间虚拟机目前并不支持压缩操作,所以碎片化是目前最大的问题。
参考资料: