您的位置:首页 > 编程语言 > Java开发

《深入理解JAVA虚拟机》第二章 java内存区域与内存溢出异常

2019-02-13 22:03 316 查看

《深入理解java虚拟机 笔记》

  • 内存溢出异常如何分析
  • 堆的内存结构
  • 第二章 java内存区域与内存溢出异常

      java程序是如何运行的?java虚拟机在其中扮演了怎样的角色?如何让java程序具有更高的并发性?此三个问题,希望在阅读完此本书后能回答。
    补充第一章的基础知识:
      什么是JDK? java程序设计语言、java虚拟机、java API类库三部分称为JDK。绝大多数的java程序员只接触java程序设计语言、java API类库,而不知java虚拟机运行原理。
      什么是JRE?java API类库中的javaSE API和java 虚拟机称为JRE(java runtime Environment)。java平台版本有javaee、javase、javame,其中javase 为(Java Platform,Standard Edition)标准版本。

    虚拟机是如何使用内存的

    虚拟机运行时数据放在哪

             
      虚拟机运行时,内存中数据区分方法区、虚拟机栈、本地方法栈、堆、程序计数器
      程序计数器:是一块较小的内存空间,每个线程都有一个独立的程序计数器。程序计数器中存放下一条指令存放的地址,字节码解释器工作是就是通过改变这个计数器值来执行下一条需要执行的字节码指令。
      java虚拟机栈:每个线程都有一个独立的java虚拟机栈,我们所说的“堆”、“栈”中“栈”就是虚拟机栈,虚拟机栈是用来描述java方法执行的内存模型。每个方法在执行过程会创建一个栈帧用于存储局部变量表、操作数栈等信息。每个方法从调用到执行完过程对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
      本地方法栈:与虚拟机栈作用类似,虚拟机栈执行java方法(字节码)服务,而本地方法栈为虚拟机使用的是Native方法服务。(native关键字修饰的方法叫做本地方法,本地方法和其它方法不一样,其他方法运行在虚拟机栈中,本地方法运行在本地方法栈中,native方法主要用于加载文件和动态链接库,与本地平台有关,可移植性不太高。)
      java堆:java堆(java Heap)是虚拟机所管理的内存最大一块,是被所有线程共享的一块内存区域,几乎所有的对象实例以及数组都要在堆上分配内存,堆也是垃圾收集器管理的主要的区域。
      方法区:方法区与堆一样是各个线程共享的内存区域,但不是描述java方法执行的内存模型,而是存储被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等。编译期生成的各种字面量和符号的引用在类加载后进入方法区的常量池中存放。

    虚拟机中对象是如何创建的

      java程序语言创建一个对象仅仅使用一个关键字new,而在虚拟机中,虚拟机碰到一个new指令,1、先检查这个类是否已被加载,没有则加载。首先先检查这个指令的参数是否能在常量池中定位到这个类的符号的引用,并且检查这个负荷引用代表的类是否被加载、解析、和初始化过,如果没有,那先执行相应的类加载过程。
      2、类加载后,接下来给该对象分配内存,分配内存时指针修改不是线程安全的。类加载完成后为对象分配的内存大小就确定了。a.如果内存是规整的,已使用的内存在一边,未使用的在另一边,中间是分界指针。为对象分配内存就是将指针想空闲区域移分配空间大小的位置。该情况称为指针碰撞。b.如果内存空间不规则,空闲和使用空间交错。那么需要一个表进行记录空间使用情况。分配空间时,在表中取一块足够大的内存进行分配,然后更新该表。这种情况称为空闲列表。内存是否规整取决于垃圾回收器是否带压缩(compact)功能。如果带压缩功能则使用指针碰撞算法,否则使用空闲列表算法
      由于对象在堆上的创建非常频繁,所以分配内存时指针的修改也不是线程安全的。例如A对象使用指针分配内存后还没来的及修改指针,B对象就使用原来的指针分配对象造成冲突。解决改问题有两种方法:1.使分配内存的动作进行同步处理。2.给每个线程预分配一块内存,称为本地线程分配缓冲(TLAB thread local allocaion buffer),每个线程分配内存时首先在TLAB上进行分配。只有当TLAB使用完后分配新的TLAB时,才需要同步锁定。虚拟机是否使用同步锁定,可以通过-XX:+/-UseTLAB。
      3、内存分配完后,虚拟机需要把内存的初始值都置为0,对象头中记录类的信息、对象哈希码,使用TLAB时,该操作可以在分配TLAB时进行。这样保证在程序执行时不给变量赋初值可以直接使用数据类型对应的0值。接下来虚拟机会对对象进行一些设置。如该对象是哪个类的实例,如何找到类的元数据信息,对象的哈希码,对象GC时处于的年龄带等,都会记录在对象头中。
      以上的步骤都完成后,虚拟机层面上对象已经完成,但是程序层面上创建对象才刚刚开始。init方法还没有执行。所有初始值还都是0,需要按程序员的意愿全部初始化后,一个对象才算真正完成。

    对象在虚拟机内存中如何存放、定位

      对象在内存中的存储分为3块:对象头,实例数据, 对齐填充
    对象头分为两部分:
      一部分:存储对象运行时信息。如哈希码,GC分带年龄,线程持有的锁,锁状态,偏向线程ID等。这部分空间在32位和64位虚拟机上分别对应32bit和64bit。官方称为“mark word”,它被设计为非固定的数据结构,在不同的状态下会复用自己的空间。例如32位的虚拟机里,25bit存储哈希码,4位存储对象分带年龄,2位存储锁状态,1位固定为0.
      二部分:存储对象的类型指针,及指向类元数据的指针。用于确定该对象是哪个类的实例。但是并不是所有对象都需要存储类型指针,换言之并不需要通过对象本身查找它的元数据。另外如果对象是一个java数组,还对象头还需要存储数组的大小,因为普通对象虚拟机可以通过元数据确定对象的大小,但是数组不行。
      对象的实例数据是对象真正存储的有效信息,继承下来的父类的字段和子类定义的字段都会被记录下来。字段存储的顺序取决于(FieldAllocationStyle)参数和程序中定义的顺序。虚拟机默认的顺序是把宽带相同的变量放在一起。然后父类放在子类之前。
      对齐填充不是必须的,只是起一个占位符的作用。由于虚拟机规定内存存储是8字节的整数倍,所以空间有空闲的用填充补齐。

    对象的定位访问:
      java对象访问,是使用栈上的reference数据指向堆上的对象进行访问的。进行访问的方式一般有两种:1.句柄访问。2直接指针。
      句柄访问:如果使用句柄访问,java堆上会分配一个句柄池,reference此时存储的句柄池的地址。句柄池里存放着指向实例数据的指针和方法区上数据类型数据的指针。该方法的好处是reference的值稳定,当对象移动时(垃圾回收器工作时经常使对象移动),只改变实例对象的指针,reference的值不用变。
              
      直接指针访问:此时reference中存储的是实例的地址。而实例中有指向方法区上对象类型数据的指针。该方法的好处是,节省了一次指针定位消耗的时间。由于java对象的访问很频繁,积累下来的时间也是很大的成本。hotspot 虚拟机使用的改种方式。
              

    内存溢出异常如何分析

      虚拟机内存中几个数据区域处理程序计数器,方法区、虚拟机栈、本地方法栈、堆四个地方均可能发生内存溢出(OutOfMemoryError)。OutOfMemoryError简称OOM,OOM发生时如何判断是哪个区域的内存溢出?什么样的代码可能会导致这些区域内存溢出?溢出异常该如何处理?

    堆异常实例分析

    先设置运行vm参数:-verbose:gc -Xms20M -Xmx20M -Xmn10M -XX:+HeapDumpOnOutOfMemoryError
    其中: -XX:+HeapDumpOnOutOfMemoryError可以让虚拟机在出现内存溢出异常Dump出当前的内存转储快照
    -Xms20M -Xmx20M 堆的最小值 堆的最大值
    工作目录下存放文件转储信息
          

    public class HeapOOM {
    static class OOMObject {}
    public static void main(String[] args) {
    List<OOMObject> list = new ArrayList<OOMObject>();
    while(true){
    list.add(new OOMObject());
    }
    }
    }

    运行结果:
            
      打开MemoryAnalyzer:https://download.csdn.net/download/weixin_41262453/10956048
      file–open–heap dump,可以查看不同对象占堆中的大小比例。
      点击Dominator Tree可以进一步查看大对象,它们是最有可能内存泄露的地方。


    OOMObject占了百分之97.29的内存。

    虚拟机栈和本地方法栈溢出实例分析

      在hotspot虚拟机上,虚拟机栈和本地方法栈是不区分的。所以使用-Xoss(设置本地方法栈)参数无效,栈容量只有 -Xss设置。
      栈上存在两种异常:1、线程申请的栈的深度大于虚拟机允许的大小,导致stackoverflow异常。2、虚拟机扩展栈时申请不到足够的内存,导致的outofmemory异常。
      在JavaVMStackOverFlow测试例子 中,-Xss参数设置栈内存容量128k,本地定义大量本地变量会增大该方法帧中本地变量表的长度,因此会抛出StackOverflowError。

    /*
    * VM args: -Xss128k
    * */
    public class JavaVMStackOverFlow {
    private int stackLength = 1;
    private void stackleak() {
    stackLength++;
    stackleak();
    }
    public static void main(String[] args) {
    JavaVMStackOverFlow sof = new JavaVMStackOverFlow();
    try {
    sof.stackleak();
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }

      书中还有个创建线程导致内存溢出异常:此例子建议不要瞎运行,java线程是映射至操作系统的内核线程上,因此有极大的风险导致操作系统假死。

    /*
    * VM args: -Xss2M
    * */
    public class JavaVMStackOverFlow {
    private void dontStop() {
    while(true) {
    }
    
    }
    public void stackLeakByThread() {
    while(true) {
    Thread thread = new Thread(new Runnable() {
    @Override
    public void run() {
    dontStop();
    }
    });
    thread.run();
    }
    }
    public static void main(String[] args) {
    JavaVMStackOverFlow sof = new JavaVMStackOverFlow();
    sof.stackLeakByThread();
    }
    }

    方法区与其运行时变量池溢出实例分析

    public class RuntimeConstantPoolOOM {
    public static void main(String[] args) {
    String str1 = new StringBuilder("计算机").append("软件").toString();
    System.out.println(str1.intern() == str1);
    String str2 = new StringBuilder("ja").append("va").toString();
    System.out.println(str2.intern() == str2);
    String str3 = new StringBuilder("ja").append("va1").toString();
    System.out.println(str3.intern() == str3);
    }
    }

    运行结果:

      第一个true:String.intern()是一个Native方法,它的作用是:如果字符串常量池中已经包含一个等于此String对象的字符串就返回字符串常量池中该String对象,否则将此String对象的包含的字符串添加到常量池中,再返回引用。由于str1开始在常量池中不存在,所以调用intern()后会将该字符串的引用放入常量池中,然后返回相同的引用,所以第一个结果是true。
      第二个false:是因为在字符串常量池中已经有一个“java"字符串的引用,不要问为什么有,虚拟机方法区中的常量池里面估计设计的时候就是有,所以执行String.intern()返回的是常量池原有的”java"引用,和新建StringBuilder字符串(在堆上)是不同的引用。
      第三个true:因为方法区的常量池有“java”,但是原来是没有“java1”,所以是第一种情况。
      方法区中存放Class相关信息:如类名、访问修饰符、常量池、字段描述、方法描述。在一些框架中如Spring会使用cglib(CGLIB 是一个强大的,高性能,高质量的Code生成类库,开源项目)的字节码技术动态生产很多代理类。这些代理类需要足够的方法区去装载,否则容易出现OutOfMemoryError异常。

    堆的内存结构

      Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。在 Java 中,**堆被划分成两个不同的区域:新生代 ( Young )、老年代 ( Old )。新生代 ( Young ) 又被划分为三个区域:Eden、From Survivor、To Survivor。**这样划分的目的是为了使 JVM 能够更好的管理堆内存中的对象,包括内存的分配以及回收。
      堆的内存模型大致为:
            
      从图中可以看出: 堆大小 = 新生代 + 老年代。其中,堆的大小可以通过参数 –Xms、-Xmx 来指定。(本人使用的是 JDK1.6,以下涉及的 JVM 默认值均以该版本为准。)默认的,新生代 ( Young ) 与老年代 ( Old ) 的比例的值为 1:2 ( 该值可以通过参数 –XX:NewRatio 来指定 ),即:新生代 ( Young ) = 1/3 的堆空间大小。老年代 ( Old ) = 2/3 的堆空间大小。其中,新生代 ( Young ) 被细分为 Eden 和 两个 Survivor 区域,这两个 Survivor 区域分别被命名为 from 和 to,以示区分。默认的,Edem : from : to = 8 : 1 : 1 ( 可以通过参数 –XX:SurvivorRatio 来设定 ),即: Eden = 8/10 的新生代空间大小,from = to = 1/10 的新生代空间大小。JVM 每次只会使用 Eden 和其中的一块 Survivor 区域来为对象服务,所以无论什么时候,总是有一块 Survivor 区域是空闲着的。

    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: 
    相关文章推荐