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

  Java与C++之间有一堵由内存动态分配和垃圾收集技术所围成的“高墙”,墙外面的人想进去,墙里面的人却想出来——《围Java》·周志明

  Java程序员将c++中繁琐的delete交给了虚拟机,虽然我们一直创建对象,但从未手动删除过对象,这一切正悄然发生着。我们把内存控制的权利交给了JVM,直到那一天,Java程序员终于想起了曾一度被他们支配的恐怖和被囚禁于鸟笼中的那份屈辱。

一、运行时数据区/内存模型

  运行时数据可划分为若干个数据区域,其中有的属于线程共享,而又有些为线程私有。共享区域会随着虚拟机进程的启动而存在,而线程私有的区域会随着线程的启动和结束而建立和销毁。

  

二、程序计数器(Program Counter Register)

  我更喜欢用“寄存器”的概念去理解他,我们知道在CPU中有寄存器用于保存指令地址和保存计算结果。类似的,程序计数器就像一个指针,保存了指向方法区中的方法字节码的地址,程序寄存器只占用很小的内存空间,是线程私有的,可以看做是当前线程所执行的字节码的行号指示器,如果你查看程序字节码,会发现很多指令,程序计数器按顺序存储这些字节码,执行引擎执行计数器中的字节码以实现循环、跳转、分支、线程恢复等操作。由于程序计数器是线程私有的,每一条线程都会有一个独立的程序计数器,所以各个线程之间的程序计数器互不影响,独立存储。如果正在执行的是本地方法,这个计数器的值则为空,因为native方法的执行不归Java管。

三、方法区

  方法区是供各线程共享的运行时内存区域,它存储了一个类的结构信息,例如运行时常量池、字段和方法数据、构造函数和普通方法的字节码内容,在前面的文章中提到方法区:8版本管他叫元空间,8以前有人管他叫“永久代”,而“xx代”听起来好像是堆空间的一部分。在周志明老师的书中是这么讲解的:虽然Java虚拟机“规范”把方法区描述为堆的一个“逻辑部分”,但是它却有一个别名叫做Non-Heap(非堆),目的应该是与Java堆区分开。

  很多人愿意把方法区称为“永久代”,但是两者本质上并不同,仅仅是因为HotSpot虚拟机的设计团队将GC分代收集机制扩展到了方法区,使得HotSpot的垃圾回收器可以向管理Java堆一样管理方法区这部分内存,省了专门为方法区再写一个垃圾回收器。但是这样做更容易导致内存溢出,所以后来的版本中逐渐放弃了使用所谓的“永久代”去实现方法区,而使用本地内存(堆外内存)来实现方法区。

  在使用“永久代”实现方法区时,并不是说数据进入方法区就高枕无忧了,只是垃圾回收行为在这部分内存区域比较少见,方法区发生垃圾回收主要的回收目标就是常量池数据的回收和类型的卸载。

四、运行时常量池

  上面说运行时常量池时方法区的一部分,但是这部分内容比较重要,所以单独拎出来讲一讲。在class文件中除了有类的版本、字段、方法、接口等元数据外,还有一项信息是“常量池”,即“class文件常量池”,存放了编译期生成的常量(字面量)和符号引用。类加载后会将“class文件常量池”的内容放入方法区中此类的“运行时常量池”。

  在上一篇介绍类初始化的案例中,我们发现:运行期间也可以产生常量,当一个常量在编译期可以确定的时候,此类的class文件被加载后“class文件常量池”内容进入运行时常量池,使用此常量的类会在编译期将运行时常量池的此常量“拷贝”一份到自己的字节码文件中,所以并不构成对声明此常量类的主动使用,也就不会导致定义此常量的类的初始化。而如果此常量在编译期无法确定值,比如:public final static String str = UUID.radomUUID().toString()。假设此常量为A类定义,调用方为类B,由于此常量在B的编译期时的运行时常量池中并不存在,常量值必须由A执行确定,所以会导致会导致A的初始化。这也从侧面说明运行期间也可能将新的常量放入池中,因此运行时常量池是“动态的”。

五、Java栈【栈管运行】【重要】

  在程杰老师的《大话数据结构》一书中用“弹夹”形容“栈”这种数据结构,在初学Java时,我并不明白为什么引用放在Java栈中是什么意思,也不明此栈和数据结构中的“栈”有什么关系,作为一个数学类专业的学生,看到这些既陌生又好奇,还好我坚持下来了。

  本篇不对栈这种数据结构做仔细介绍,只提两点:1、先进后出。2、字节码指令将数据“推送至”栈顶。

  Java栈作为线程私有的内存,与线程同生共死,因此不存在垃圾回收行为。Java栈存储了8种基本类型的变量+对象的引用变量+实例方法(方法和变量[类变量+实例变量]一样分为[类方法+实例方法])。

  多说无益,请看案例:

public class StackDemo {
    public static void main(String[] args) {
        method();
    }
    public static void method(){
        System.out.println("***Method***");
        method();
    }
}
Exception in thread "main" java.lang.StackOverflowError
    at sun.nio.cs.UTF_8$Encoder.encodeLoop(UTF_8.java:691)
    at java.nio.charset.CharsetEncoder.encode(CharsetEncoder.java:579)
    at sun.nio.cs.StreamEncoder.implWrite(StreamEncoder.java:271)
    at sun.nio.cs.StreamEncoder.write(StreamEncoder.java:125)
    at java.io.OutputStreamWriter.write(OutputStreamWriter.java:207)
    at java.io.BufferedWriter.flushBuffer(BufferedWriter.java:129)

  为什么会报出StackOverflowError错误呢(注意这是个错误不是异常)?因为main线程请求的栈深度大于虚拟机所允许的深度,当然你可以扩展你的虚拟机栈深度,但是如果扩展是没有申请到足够的内存,就会抛出OutOfMemoryError错误。本案例使用递归的方式让主线程的栈中存储大量的实例方法,导致内存溢出

  

  在上面的视图中,每一个曲边矩形表示一个“栈针”,在代码中,我们称之为方法的东西表现为Java栈中的栈针。如上图所示,main方法作为程序入口,必然是最先入栈的,在main方法中调用了method方法,此时main方法并未执行完,因此main所代表的栈针不会弹出,method方法的栈针会入栈,然后层层递归,永无出头之日,最后抛出错误。如果删除method中的递归调用,那么method栈针入栈后执行完毕弹出,然后main方法顺利执行,main栈针弹出,程序不会错误。

  栈针中包含了对应方法中的参数,本地变量,内部类等等信息:

  

 

 

   下面用一个案例做栈针的进一步的介绍

  案例:

public class StackDemo2 {
    public void changeValue1(int age){
        age = 30;
    }
    public void changeValue2(Person person){
        person.setName("XXXX");
    }
    public void changeValue3(String str){
        str = "XXXX";
    }

    public static void main(String[] args) {
        StackDemo2 stackDemo2 = new StackDemo2();
        int age = 20;
        stackDemo2.changeValue1(age);
        System.out.println(age);

        Person person = new Person("zhangsan");
        stackDemo2.changeValue2(person);
        System.out.println(person.getName());

        String str = "ABC";
        stackDemo2.changeValue3(str);
        System.out.println(str);
    }
}

  输出结果就不给了,朋友们可以自己试一下。这里只讲原理:首先main方法对应的栈针入栈,此栈针中存有基本类型变量age,然后调用changeValue1方法,则changeValue1对应的栈针入栈,因为age是基本类型变量,所以这里传入的并不是引用,而是 “复制了值20”传给了changeValue1方法,changeValue1方法只是用了一个int 类型的变量age接收了此值,当然你也可以用别的变量名,作为形参的变量名是此方法对应栈针私有的,和main方法中的并不是同一块内存,所以当changeValue1执行完毕栈针弹出后,changeValue1内的age随之消失,而main栈针对应的age变量值保持不变。

  当传入person引用类型变量时,此时 “复制了一份地址” 传给了changeValue2方法,所以你如果打印person得到的是一个地址。因为传进来的是地址,changeValue2也只是用了一个同类型的变量接收,由于此变量与main方法中的person变量保存的地址相同,所以指向了堆中同一个实例对象,所以当changeValue2对person对象的属性做修改后,方法对应的栈针弹出栈,随之消失的只是一个person变量,方法对堆空间中对象造成的影响不会随之消失,结果一目了然。

  但是String类型就比较坑了,他既是引用类型,又是字符串常量。执行changeValue3,对应栈针入栈,同样的changeValue3用了一个同类型的同名变量接收了传过来的地址,至于为什么println(str)直接输出值而不是地址,是因为String类重写了toString方法。在changeValue3内执行str=“XXXX”时,发现在常量池中并没有值为XXXX的常量,于是会在常量池中创建此常量,然后改变了栈内变量(即形参变量)存储的地址,新地址指向了新值“XXXX”,当方法执行完毕,栈针弹出后,栈内str变量失效,由于常量XXXX没有引用指向它,他会被GC回收。而main栈针的str所指向的地址的值不变,结果一目了然。

  三个都是复制了变量存储的值,而不是复制变量指向的值,接收方用一个局部变量接收,方法执行完毕后,局部变量也随之在内存中消失。关键的难点在于复制的值是什么,之后的操作有没有对本栈针以外的内存空间造成影响,如果造成影响,是对堆空间还是方法区的常量池造成了影响。

  

   元数据:类的结构信息

 六、堆空间简介【GC主要工作地址】

  在这里仅仅做一个粗略的介绍,真正的堆讲解放在下一篇中和垃圾回收一起介绍。

  逻辑上我们把堆空间分为三部分:新生代、老年代、永久代,之前说永久代是用来实现方法区的,这是7版本的做法,由于容易导致OOM,所以在8版本推出了使用对外内存实现方法区,我们称之为——元空间。由于方法区中存有字符串常量池,所以当不再使用永久代实现方法区后,字符串常量池也不再放在永久代中【堆内内存】,而是放在对元空间中【堆外内存】。

  物理上,堆空间分为两层:新生代和老年代。

  新生代分为三个区:Eden区:对象创建时放在这里。幸存0区(from区)、幸存1区(to区),他两不是固定的,每次GC回收之后,他两谁为空谁是幸存1区,也就是互换。

  每创建一个对象都先放入Eden区,随着垃圾回收的进行,Eden区存活的实例对象进入幸存0区,逐一往上,直到进入老年代。对于不同的区域,垃圾回收的工作频率是不同的。新生代的垃圾回收器被称为YGC,也叫轻GC,老年代的GC被称为Full GC,如果老年代的空间不足,就会爆出OOM错误。

  当出现OOM错误,说明堆空间内存不够,可以通过以下方式解决排查:

  1、java虚拟机的堆内存配置不够,可以通过-Xms  -Xmx来调整堆空间的大小

  2、代码中创建了大量的对象,并且长时间不能被垃圾回收器收集(即栈中存在指向此对象的引用)

  

posted @ 2019-10-13 21:28  菜菜菜鸡  阅读(195)  评论(0编辑  收藏  举报