java虚拟机01-java内存区域与内存溢出异常

Posted xiaobai1202

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了java虚拟机01-java内存区域与内存溢出异常相关的知识,希望对你有一定的参考价值。

1.运行时数据区域

技术图片

    

    1.程序计数器:是一块较小的内存空间,它可以看作是当前线程所执行字节码的行号指示器,字节码解释器工作时就是通过改变程序计数器的值来选取下一条指令的地址。分支、循环、跳转、异常处理、线程恢复等基础功能都是由这个计数器来完成。

             每一条线程都要有一个属于自己的独立的程序计数器,所以该块内存是线程私有的

             如果当前执行的是一个java方法,则这个计数器记录的是正在执行指令的字节码地址;如果当前当前执行的是一个native方法,则计数器的值为空 (undefined)

             程序计数器所在的内存区域是唯一一个没有被规定OutOfMemoryError的区域

    2.虚拟机栈:  同样,虚拟机栈也是线程私有的,他的声明周期与线程的生命周期是相同的,虚拟机栈描述的是java方法执行的内存模型。

            每个方法在执行的同时都会创建一个栈帧(Stsck Frame) 用来存储 局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法从调用到完成的过程就是一个栈帧在虚拟机栈中从入栈到出栈的过程。

               局部变量表存放了编译期可预知的数据类型(8大数据类型+引用类型和returnAddress类型),其中long和double会占用两个局部变量空间(Solt),其余类型占一个

            两个异常:1.如果线程请求的栈深度大于虚拟机所允许的深度,将会抛出StackOverflowError异常 

                2.如果虚拟机可以动态扩展,当扩展时无法申请到足够的内存,将会抛出OutOfMemoryError异常

    3.本地方法栈:本地方法栈与虚拟机栈发挥的作用基本是一样的,他们之间的区别时虚拟机栈为虚拟机执行java方法而本地方法栈为虚拟机执行native方法。同样,该区域也是线程私有的

    4.java堆: 对于大多数应用来说,java堆是虚拟机所管理的最大的一块内存,是被所有线程所共享的区域。该区域随虚拟机启动时创建,主要的目的就是为了存储java对象实例,几乎所有的对象实例都在这里分配内存。

            java堆是垃圾收集器管理的主要区域,因此很多时候也被称为GC堆(Garbage Collection Heap) java堆还可以被细分为老年代,新生代,新生代又被分为Eden、from survivor、to survivor等等,后面我们细说。

           当前主流的虚拟机的堆都是按照可拓展来实现的(通过 -Xmx和-Xms 来控制) 如果在堆中没有内存完成实例对象内存分配并且堆也无法再扩展时,将会抛出OutOfMemoryError异常。

    5.方法区:与java堆一样,是各个线程共享的内存区域,它用于存储已被加载的类的信息、常量、静态变量、即时编译器编译后的代码等数据,他还有一个别名叫做 Non-Heap(非堆),目的是应该与java堆区分开来。

           很多人愿意把方法区称为永久代,实际上两者并不等价,仅仅是因为HotSpot开发团队把GC分代收集拓展至方法区,或者说用永久代实现方法区而已。

           当方法区无法满足内存分配需求时,将会抛出OutOfMemoryError异常。

    还有两个需要特别加以描述的区域:

          1.运行时常量池:该区域时方法区的一部分,Class文件中除了有l编译器生成的类的版本、方法、属性、接口等信息外。还有一项信息时常量池,用于存储编译时生成的各种字面量和符号引用,

           这部分内容将在类加载后进入方法区的运行时常量池中存放。

          2.直接内存:该区域并不是虚拟机运行时数据区域的一部分,但这部分内存被频繁使用,同时也存在OutOfMemoryError异常。在JDK1.4之后加入了NIO类(new input/output)引入了一种基于通道与缓冲区的IO方式

             它可以使用navtive函数库直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象为这块内存的引用进行操作。(该区域受物理机内存限制)

2. 对象的创建

          普通对象的创建过程:

          1. 当虚拟机遇到一条new 指令时,首先去检查这个指令的参数是否能在常量池中定位到一个类的符号引用并且检查这个符号引用代表的类是否已被加载解析和初始化过。

          如果没有被初始化,则需要先执行类的初始化(后面会提到,这里先不详细解释)

          2. 在类加载检查通过后,接下来虚拟机为新生对象分配内存,对象所需的内存大小在类加载完成后便可以确定。为对象分配空间的任务相当于把一块确定大小的内存从java堆中划分出来给对象

            分配方式有两种   具体使用哪一种看实际情况。

                 第一种: 指针碰撞式: 在连续剩余空间中分配内存。用一个指针指向内存已用区和空闲区的分界点,需要分配新的内存时候,只需要将指针向空闲区移动相应的距离即可。

                 第二种: 空闲列表法: 在不规整的剩余空间中分配内存。如果剩余内存是不规整的,就需要用一个列表记录下哪些内存块是可用的,当需要分配内存的时候就需要在这个列表中查找,

                             找到一个足够大的空间进行分配,然后在更新这个列表。

            同时我们还要考虑一个并发的问题,由于虚拟机在无时无刻的创建对象,假设使用的是指针碰撞法,可能会出现A分配内存之后指针没来得及修改B又使用了该块内存

                该问题的解决办法又两种  1.对分配空间操作进行同步处理(java虚拟机使用的方式是CAS)  2.把内存分配的动作按照线程划分在不同空间中进行(TLAB本地线程分配缓冲)

                实际上虚拟机采用的是CAS配上失败重试的方式保证更新操作的原子性 我们也可以通过调整参数 -XX: +/-UseTLAB 来设定虚拟机使用TLAB方式

          3. 内存分配完成后,虚拟机需要将分配到的内存空间都初始化为0值(不包括对象头)【如果使用TLAB方式,这部操作在TLAB分配之前进行】,这一步操作保证了对象的实例字段在java代码中不赋

           初始值就直接使用,程序能访问到这些字段的数据类型所对应的0值。

          4. 接下来就是对对象进行必要的设置,例如这个类是哪个类的实例,如何才能找到类的元数据信息,对象的哈希码,对象的GC分代年龄信息,这些信息都存放在对象头中(Object Header)

          5. 上面的工作完成之后,从虚拟机的角度看,一个新的对象已经产生了。但是从java程序的角度来看,对象创建才刚刚开始,<init>方法还没有执行,所有的字段都还是0,执行完new指令

            接下来就要执行<init>方法,把对象按照程序员的意愿进行初始化,这样一个真正的可用的对象才算完全生产出来。

3.对象的内存布局

               在HotSpot虚拟机中,对象在内存中的存储布局可分为三个区域:对象头(Header)  实例数据(Instance Data)和  对齐填充(Padding)

          对象头用于存储对象自身运行时数据:第一部分存储 哈希码、GC分代年龄、锁状态标志、线程持有的锁、偏向线程ID、偏向时间戳等。 第二部分是一个类型指针,即对象指向它的元数据的指针,通过这个指针判断该对象是哪个类的实例。

          实例数据部分是对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容。无论是从父类继承的还是在子类中定义的,都要记录起来。这部分的分配策略会收到虚拟机分配策略参数和字段在java源码中定义顺序影响。

              HotSpot分配策略为:longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers) 从分配策略可以看出相同长度的字段总是被分配到一起。

              在满足分配策略的前提下,父类定义的变量会出现在子类之前,如果CompactFields参数设置为true,那么子类中较窄的变量也会插入到父类变量的空隙中

          对齐填充部分不是必须的也没有特别的含义,仅仅起着占位符的作用 HotSpot内存管理系统要求对象的起始地址必须是8字节的整数倍,头部信息刚好是8字节一倍或者2倍,所以当实例数据没对齐8字节整数倍的部分,由对其填充部分进行填充。

4.对象访问定位

          java通过栈上 的一个reference数据来操作堆上的具体对象,该reference只是指向一个对象,具体怎么指向由java虚拟机来完成,即对象访问定位。

          对象访问定位方式由两种:一种是使用句柄  另一种是直接指针

 

          如果通过句柄来访问对象,Java堆中会划出一块内存作为句柄池,reference中存储句柄地址,而句柄中包含对象的实例数据与类型数据各自的地址。这样就能访问到对象了。

          技术图片

      

            

 

          直接指针,就是指reference中直接存储对象的地址。但是Java堆对象的布局中就必须考虑如何防止访问类型数据相关信息。

技术图片

 

          两种访问方式各有优势:

              使用句柄访问最大的好处是reference中存储的是稳定的句柄地址,在对象被移动时只需改变句柄中实例数据的指针,而不会改变reference的值(注意垃圾收集时对象的移动是非常普遍的行为)

              使用直接指针最大的好处就是速度快,他节省了一次指针定位的时间开销。HotSpot使用的就是这种方式

5.异常演示

    5.1 堆溢出

      我们通过限制堆内存大小,不断创建新对象来消耗堆内存,直至消耗完毕来模拟堆溢出

      首先,我们先用记事本写一个java源文件

 1 import java.util.ArrayList;
 2 
 3 public class TestMemory {
 4     public static void main(String[] args) {
 5         ArrayList<OutOfMemoryTest> list = new ArrayList<>();
 6         while(true) {
 7             list.add(new OutOfMemoryTest());
 8         }
 9     }
10 }
11 
12 class OutOfMemoryTest{
13     long[]  arr = new long[10];
14 }

    然后保存到任意目录,打开cmd 在该目录进行编译

//使用命令  
javac TestMemory.java

    然后使用命令运行,设定虚拟机内存大小

java -Xmx20m -Xmx20m TestMemory

    根据电脑性能不同,稍等一会,我们就可以看到一个异常抛出

 

技术图片

 

    5.2栈溢出 

        这个就很简单了,递归调用不反回就会造成栈溢出,请自行测试

public class TestMemory {
    public static void main(String[] args) {
        testStack();
    }
    
    public static void testStack() {
        testStack();
    }
}

    5.3方法区和运行常量池溢出

        由于运行常量池是方法区的一部分,所以这里就放在一起测试

        String类的intern方法是一个本地方法,它的作用是:如果常量池已经存在等于此字符串的对象,直接返回该对象,否则新建一个对象放到常量池,并返回对象。

        我们通过不断创建新的String对象来模拟方法区溢出

import java.util.ArrayList;

public class TestMemory {
    public static void main(String[] args) {
        ArrayList<String> list = new ArrayList<>();
        int i = 0;
        while(true) {
            list.add(String.valueOf(i).intern());
        }
    }
    
}

     结果:(由于jdk1.8已经该变了原来的设计思路,不再支持Perm参数 并且异常是出现在堆上的,这里值得我们思考一下!)

技术图片

 

  

 

以上是关于java虚拟机01-java内存区域与内存溢出异常的主要内容,如果未能解决你的问题,请参考以下文章

java虚拟机java内存区域与内存溢出异常

深入了解Java虚拟机java内存区域与内存溢出异常

Java内存区域与内存溢出异常

java虚拟机—-java内存区域与内存溢出异常

深入理解Java虚拟机——java内存区域与内存溢出异常

《深入理解Java虚拟机》笔记02:Java内存区域与内存溢出异常