Java程序的运行过程
首先,编译器会将写好的源代码编译成字节码文件(也就是class文件),每个程序都需要运行在JVM上,JVM的字节码解释器对字节码文件加载运行。那么整个加载运行的过程又是如何开展的呢?
内存区域划分
大多数JVM将内存区域划分为程序计数器(Program Counter Register)、堆(Heap)、栈(VM Stack)、方法区(Method Area)、本地方法栈(Native Method Area)等5个部分。
- 堆和方法区是线程共享的,在JVM启动的时候就会分配好堆和方法区。
- 程序计数器、栈和本地方法栈是线程私有的,每遇到一个线程,就会为其分配一个程序计数器、栈和本地方法栈。
在线程终止时,对应的线程私有的部分也会终止,其占据的内存空间也会被释放掉。也就是说,线程私有的部分的生命周期与所属线程相同,而线程共享的部分的生命周期与JAVA程序相同。这也是为什么我们需要对线程共享部分——主要是堆,进行GC。
程序计数器
程序计数器可以看成是当前线程所执行代码的行号指示器,用来记录当前线程所执行到字节码的行号,它通过改变这个计数来选取下一条要执行的字节码指令,从而相应的分支、跳转、循环等操作。
该区域也是唯一一个没有OOM(OutOfMemoryError)的区域
虚拟机栈
每个方法在执行的同时,会创建一个栈帧,栈帧存储这局部变量表、操作数栈、动态链接、方法出口等信息。每一个方法从执行到结束的过程就对应着一个栈帧从入栈到出栈的过程(仔细想一想,在一个方法中调用其他方法,是不是和栈的过程非常相似)
局部变量表中存放了三种类型
- 8种基础数据类型:boolean、short、int、long、double、float、byte、char
- 对象引用(reference):对于对象的引用,可能是一个指向对象起始地点的引用指针,也可能是指向该现象的句柄或者其他与该对象相关的位置
- returnAddress:指向了一条字节码指令的地址
该区域可能会因为栈帧深度大于虚拟机运行的栈帧深度而发生StackOverflowError(stackoverflow也是一个非常著名的论坛网站),也会发生OOM错误
本地方法栈
本地方法栈和虚拟机栈相似,他们之间的区别在于虚拟机栈执行的是JAVA服务,而本地方法栈使用的是native方法,这些native方法并没有强制要求,可以由其他语言(比如c++)实现。
__该区域也会发生StackOverflowError和OOM错误。
堆
堆空间内存分配
堆是JAVA对象实例存放的内存空间,也是内存区域中最大的一块。此内存区域唯一的目的是存放对象实例,几乎所有的对象实例都在这里分配内存,而且JAVA堆可以处在不连续的内存空间中,只要逻辑上是连续的即可。一般而言,我们都是设计成JAVA堆可以自动扩展。
- -xms:设计JAVA堆的初始值
- -xmx:JAVA堆的最大值
当JAVA堆空间不足切无法扩展时,会发生OOM错误
此外,堆空间还是GC的主要区域,JAVA堆还可以细分为新生代和老年代。新生代还可以分为Eden、From Survivor、To Survivor空间。具体的内容我之后会写关于垃圾回收相关的博客,之后在详细的介绍
字符串常量池
从jdk1.7开始,sun公司就开始进行去永久代的工作,在jdk1.7中,字符串常量池已经从方法区移到堆中
方法区
方法区和堆一样,是线程共享的区域。用于存储已被虚拟机加载的类信息、常量、JIT编译后的代码等数据。
方法区在逻辑上是属于堆的一部分,但是它求有一个别名——Non-Heap(非堆),为的就是与Java堆分开。
与永久代的区别
很多人愿意把方法区称之为“永久代”,是因为HotSpot虚拟机的设计团队奖GC的分代收集扩展至方法区,或者说使用永久代来实现方法区,这样垃圾收集器就可以像管理JAVA堆一样管理这部分内存,能够省去专门为此区域编写内存管理代码的工作。但两者实质上是不同,其他的虚拟机,像Jrockit、IBM J9等,没有永久代的概念。
方法区的内存回收
JAVA虚拟机对于方法区的限制非常宽松,与堆一样,方法区也不需要连续的内存空间,还可以选择不进行垃圾回收。实际上,方法区的垃圾回收效率并不好,主要是进行对于常量的回收和对类型的卸载,尤其是对于类型的卸载,条件十分苛刻。但是,这部分的回收也是必要的。Sun公司的Bug列表中,曾经出现过若干个由于低版本的虚拟机未完全回收该区域而导致内存泄露。
动态生成是最容易造成方法区的OOM的原因
该区域同样有OOM错误
JDK1.8之后的方法区
Java8就彻底的移除了堆的永久区,取而代之的是元空间(MetaSpace),它最大的特点就是存储在物理内存(本地内存),这样的话减少了方法区进行垃圾回收的概率。一般情况下,是不会出现OOM的。
我们可以测试一下,通过一段程序来比较 JDK 1.6 与 JDK 1.7及 JDK 1.8 关于这一部分的区别,以字符串常量为例
package com.paddx.test.memory;
import java.util.ArrayList;
import java.util.List;
public class StringOomMock {
static String base = "string";
public static void main(String[] args) {
List<String> list = new ArrayList<String>();
for (int i=0;i< Integer.MAX_VALUE;i++){
String str = base + base;
base = str;
list.add(str.intern());
}
}
}
这段程序以2的指数级不断的生成新的字符串,这样可以比较快速的消耗内存。我们通过 JDK 1.6、JDK 1.7 和 JDK 1.8 分别运行:
JDK 1.6 的运行结果:
JDK 1.7的运行结果:
JDK 1.8的运行结果:
从上述结果可以看出,JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论。现在我们看看元空间到底是一个什么东西?
元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小:
- -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值。
- -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
对于该部分的垃圾收集,是全新设计的
运行时常量池
运行时常量池是方法区的一部分。
Class文件中除了存放类的版本、字段、方法、接口等,还有常量池,用来存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。