Java内存结构与内存溢出实验
文章目录
一、程序计数器
1、作用:
程序计数器是一块较小的内存空间,用于指示当前线程所执行的字节码的行号,字节码解释器工作时会通过改变程序计数器的值来选取下一条需要执行的字节码指令,此外分治、循环、跳转、异常处理、线程恢复等操作都需要依赖程序计数器完成
2、线程私有:
程序计数器是线程私有的,由于JVM中的多线程是通过线程切换并分配处理器执行时间实现的,在任何一个确定的时刻,一个处理器只会执行某一个线程中的指令,因此,每一个线程都会拥有一个独立的程序计数器用于记录当前指令,以便线程切换后可以恢复到正确的执行位置
- 需要注意的是,程序计数器只会记录Java字节码指令的地址,若当前执行的是native方法,则程序计数器为空
二、Java虚拟机栈
1、作用:
Java虚拟机栈描述了Java方法执行的内存模型,每个方法在被执行时都会创建一个栈帧(Stack Frame)用于存储局部变量、操作栈、动态链接、方法出口等信息。每一个方法被调用到执行完成的过程,就对应着一个栈帧在虚拟机栈帧中从入栈到出栈的过程
2、线程私有:
Java虚拟机栈是线程私有的,其生命周期与线程相同
3、局部变量表:
局部变量表存放了编译期可知的各种基本数据类型、对象引用和returnAddress类型(指向上一条字节码指令的地址)。其中指的注意的是,对象(reference类型)引用不等于对象本身,下面Java堆中会说到对象是被存放在堆中的,对象可能是一个指向对象起始地址的引用指针,也可能指向一个代表对象的句柄或其他与此对象相关的位置,其具体的实现方式不同的虚拟机有所差异
若采用句柄访问方式,Java堆中会划分出一块内存作为句柄池,reference存储的是对象的句柄地址,而句柄中则包含了对象的实例数据和类型数据各自的具体地址信息。使用句柄的优点在于句柄地址十分稳定,在对象被移动时指挥改变句柄中的实例数据指针,而不需要修改reference
若直接使用指针访问,则reference中存储的就是对象地址。使用指针的优点在于速度相较于句柄更快,节省了一次指针定位的开销
4、异常:
StackOverflowError:
若线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常
OutOfMemoryError:
若虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存即会抛出OutOfMemoryError异常
三、本地方法栈
本地方法栈的作用于Java虚拟机栈的作用十分相似,但不同之处在于,本地方法栈用于为Native方法服务。本地方法栈与虚拟机栈一样会抛出StackOverflowError与OutOfMemoryError
四、Java堆
1、作用:
Java堆是Java虚拟机所管理的内存中最大的一块,Java堆的唯一目的是存放对象实例,绝大多数的对象实例以及数组都要在Java堆中分配内存。Java堆也是垃圾收集器管理的主要区域,因此也被称为GC堆。Java堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可
2、线程共享:
Java堆是所有线程共享的,在虚拟机启动时创建
3、异常:
Java堆既可以被实现称固定大小的,可以是可扩展的,但当前主流的JVM都是按照可扩展进行设计的。当Java堆中没有内存可以完成实例对象的分配,并且也无法扩展时,会抛出OutOfMemoryError异常
五、方法区
1、作用:
方法区用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据,是线程共享的。Java虚拟机规范赋方法区的限制十分宽松,方法区可以选择固定大小区域也可以实现为可扩展区域,方法区不需要连续的内存,并且也可以选择不实现垃圾收集,垃圾收集行为极少出现在该区域
2、运行时常量池:
运行时常量池是方法区的一部分,用于存放编译期生成的各种字面量和符号引用。除了保存Class文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
运行时常量池具有动态性的特征,这就意味着常量并不一定只能在编译期产生,运行期间也可能将新的常量放入常量池中
六、关于OutOfMemoryError的实践
1、Java堆的溢出
Java堆用于存储对象,那么我们就可以不断的创造对象,并且保证这些对象不被垃圾收集器回收,就可以使得这些对象所占用的内存达到堆的最大容量限制,产生异常
为了方便实验,首先需要修改JVM的运行参数,对Java堆的大小进行限制,打开IDEA后点击菜单栏Run->Edit Configurations,
接着找到Application,在右侧VM options中输入以下命令,-Xms 20m将堆的最小值设置为20m,-Xmx 20m将堆的最大值设置为20m,将最小值和最大值设置为一样可以避免堆自动扩展,-XX:+HeapDumpOutOfMemoryError可以让虚拟机在出现内存溢出异常时转储内存快照方便分析
接下来编写测试代码,为了使得创建出来的对象不会被垃圾收集器回收,我们使用一个链表来维持每一个创建的对象
public class Test {
public static void main(String[] args) {
List<Object> list = new ArrayList<Object>();
while (true){
list.add(new Object());
}
}
}
运行结果如下,可以发现在堆中抛出了OutOfMemoryError异常
在转储的快照文件中也可以看到堆区生成了大量的Object对象
2、虚拟机栈的溢出
对于虚拟机栈这一块区域存在这两种异常——StackOverflowError与OutOfMemoryError,StackOverflowError代表线程请求的栈深度超过了虚拟机栈所允许的最大深度,而OutOfMemoryError代表虚拟机栈在扩展时无法获得足够多的内存。因此这里采取两种策略在单线程下进行测试。
第一种使用-Xss108k参数将虚拟机最大栈空间调整到100kb,使用一个没有出口的递归函数不断的申请栈帧:
public class Test {
public static int depth = 1;
public static void main(String[] args) throws Throwable{
Test test = new Test();
try {
test.fun();
}catch (Throwable e){
System.out.println("栈深度:"+depth);
throw e;
}
}
public void fun(){
depth++;
fun();
}
}
运行结果如下,抛出了StackOverflowError异常
第二种策略,在方法中定义大量的double型变量已增加方法栈帧中的本地变量表的长度,结果中栈深度减小,但依然抛出的是StackOverflowError异常
StackOverflowError与OutOfMemoryError异常虽然看上去是两种完全不一样的异常,但仔细一想,这两种异常存在着大量的交集,当虚拟机栈无法申请到更多的内存的情况下,一个方法的栈帧自然很容易超出栈的大小。矛盾的地方就在与当栈无法申请到足够的内存时,是由于内存空间太小,还是由于需要申请的内存太大呢?实验结果也表明在单线程的情况下,无论是哪一种情况,抛出的都是StackOverflowError
3、方法区溢出
方法区存档了Class的相关信息,如类名、访问修饰符、常量池、方法描述等,那么想要让方法区溢出,最基本的思路就是创建大量的类去填满方法区,可以使用CGLib直接操作字节码生成大量动态类,但是由于目前对自己吗这一块还不是很了解,因此这一块的实验暂时就不做了
4、运行时常量池溢出
想要使运行时常量池溢出,最简单的方法就是不断地添加String常量。由于运行时常量池属于方法区,因此可以使用-XX:PermSize和-XX:MaxPermSize限制方法区大小来间接限制运行时常量池的大小。
此外我们可以使用String.intern()方法来帮助我们生成String常量,intern方法的作用是:若常量池中已存在一个等于该String对象的子串,则返回常量池中这个字符串对象,否则将String对象包含的字符串添加到常量池中。
public class Test {
public static void main(String[] args) throws Throwable{
List<String> list = new ArrayList<String>();
int i = 0;
while (true){
list.add(String.valueOf(i++).intern());
}
}
}
然而运行时发现运行了一段时间后内存暴增却没有抛出OutOfMemoryError异常,后来查阅资料后发现,原来在jdk1.8中HotSpot虚拟机移除了永久代(关于永久代后面复习垃圾收集机制时在整理),因此以上两个虚拟机运行参数也没有作用,此外更重要的是运行时常量池被移入了Java堆中,而非方法区,因此我将jdk更换到1.6之后再次尝试,结果显示永久代抛出了OutOfMemoryError异常