美文网首页
1.Java内存区域与内存溢出异常

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

作者: xMustang | 来源:发表于2020-02-22 23:13 被阅读0次

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

    1. 运行时数据区域

    《Java虚拟机规范(Java SE 7)》规定,Java虚拟机所管理的内存包括以下几个运行时数据区域。

    JVM运行时数据区

    JDK 1.8之前的运行时数据区域:

    JVM运行时数据区域

    JDK 1.8之后的运行时数据区域:

    Java运行时数据区域JDK1.8
    • 方法区(Method Area)
    • 堆(Heap)
    • 虚拟机栈(VM Stack)
    • 本地方法栈(Native Method Stack)
    • 程序计数器(Program Counter Register)
    1. 线程共享的数据区:方法区、堆、直接内存
    2. 线程私有的数据区:虚拟机栈、本地方法栈、程序计数器

    直接内存是非运行时数据区的一部分。

    JVM内存另一种表示

    下面是上图中的新生代、老年代英文名称:

    1. 新生代(Young Generation)
    2. 老年代(Old Generation)

    1.1 程序计数器

    如果线程正在执行一个Java方法,程序计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行Native方法,程序计数器的值为空(Undefined)。

    程序计数器是唯一一个不会出现 OutOfMemoryError 的内存区域,是在Java虚拟机规范中没有规定任何OutOfMemoryError情况的区域。

    1.2 虚拟机栈

    虚拟机栈描述的是Java方法执行的内存模型。

    Java每个方法在执行的同时都会创建一个栈帧(Stack Frame),用于存储:局部变量表、操作数栈、动态链接、方法出口等信息。

    每一个方法从调用直到执行完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。

    Java 方法有两种返回方式:不管哪种返回方式都会导致栈帧被弹出。

    1. return 语句。
    2. 抛出异常。

    局部变量表存放编译期可知的各种基本数据类型(int、short等)、对象引用、returnAddress类型(指向一条字节码指令的地址)。

    局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。

    异常:

    1. 如果线程请求的栈深度大于虚拟机栈所允许的深度,抛出StackOverflowError异常。
    2. 大部分的JVM允许虚拟机栈动态扩展,如果扩展时无法申请到足够的内存,抛出OutOfMemoryError异常。

    1.3 本地方法栈

    本地方法栈为虚拟机使用到的Native方法服务。

    本地方法被执行的时候,在本地方法栈也会创建一个栈帧,用于存放该本地方法的局部变量表、操作数栈、动态链接、出口信息。

    HotSpot虚拟机直接将本地方法栈和虚拟机栈合二为一。

    与虚拟机栈一样,本地方法栈会抛StackOverflowError、OutOfMemoryError异常。

    1.4 堆

    此内存区域的唯一目的就是存放对象实例。所有的对象实例和数组都要在堆上分配内存。栈上分配、标量优化技术导致所有的对象都不是那么“绝对”地分配在堆上。

    从内存分配的角度来看,线程共享的Java堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。

    由于现在收集器基本都采用分代垃圾收集算法,所以 Java 堆还可以细分为:新生代和老年代。再细致一点有:Eden 空间、From Survivor、To Survivor 空间等。

    在堆中没有内存来进行实例分配,并且堆无法再扩展时,抛出OutOfMemoryError异常。

    1.5 方法区

    存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。

    HotSpot虚拟机为了使垃圾收集器可以像管理Java堆一样管理方法区内存,省去专门为方法区编写内存管理代码的工作,使用永久代实现方法区,所以方法区也被称为永久代(Permanent Generation)。

    对于其他虚拟机,不存在永久代的概念。也就是说虚拟机规范中有方法区,没有永久代。

    JDK1.7的HotSpot VM已经把原本放在永久代的字符串常量池移出,但是字符串常量池仍是方法区的一部分(虚拟机规范)。JDK1.7将JDK 6中永久代部分数据移动到其他空间包括:符号引用转移到native heap;字面量转移到Java堆;类的静态变量转移到Java堆。

    HotSpot虚拟机规划,逐步放弃永久代,改用Native Memory实现方法区,在Java 8中使用元空间(Metaspace)实现方法区。

    方法区的内存回收目标主要是对常量池的回收、对类型的卸载。

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

    1.5.1 方法区与永久代的关系

    方法区是 Java 虚拟机规范中的定义,是一种规范,而永久代是 HotSpot 的概念,是方法区的一种具体实现。

    一个是标准一个是实现,其他的虚拟机实现并没有永久代这一说法。

    另外在Java 8中,HotSpot使用元空间实现方法区。元空间使用的是直接内存。

    永久代、元空间是HotSpot虚拟机对方法区的两种具体实现。

    元空间虚拟机参数配置:

    -XX:MetaspaceSize=N    //设置 Metaspace 的初始(和最小大小)。如果未指定此标志,则 Metaspace 将根据运行时的应用程序需求动态地重新调整大小。默认值为 unlimited,这意味着它只受系统内存的限制。
    -XX:MaxMetaspaceSize=N //设置 Metaspace 的最大大小。
    

    为什么要将永久代 (PermGen) 替换为元空间 (MetaSpace) :

    1. 整个永久代有一个 JVM 本身设置固定大小上限,无法进行调整,而元空间使用的是直接内存,受本机可用内存的限制,虽然元空间仍旧可能溢出,但是比原来出现的几率会更小。元空间里面存放的是类的元数据,这样加载多少类的元数据就不由 MaxPermSize(永久代控制参数) 控制了,而由系统的实际可用空间来控制,这样能加载的类就更多了。

      元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

    2. 在 JDK8,合并 HotSpot 和 JRockit 的代码时,JRockit 从来没有一个叫永久代的东西,合并之后就没有必要额外的设置这么一个永久代的地方了。

    1.5.2 运行时常量池

    运行时常量池(Runtime Constant Pool)是方法区的一部分。

    Class文件常量池(Constant Pool Table,用于存放编译期生成的各种字面量和符号引用,是静态常量池,不是本节中说的运行时常量池),在类加载后进入方法区的运行时常量池(Runtime Constant Pool)。

    Java虚拟机规范未对运行时常量池做任何细节的要求,不同的虚拟机提供商可以按照自己的需求来实现这个内存区域。

    运行时常量池具备动态性,既包含Class文件常量池中的内容,也包含运行期间产生的新常量(如String的intern())。

    运行时常量池无法再申请到内存时,抛出OutOfMemoryError异常。

    字符串内存分配:

    1. 在定义字符串变量时赋值,如果表达式右边只有字符串常量,就把变量存放在常量池里。
    2. new出来的字符串存放在堆里。
    3. 对字符串进行拼接操作,也就是做"+"运算的时候:
      • 表达式右边是纯字符串常量,存放在常量池里。
      • 表达式右边存在字符串引用,存放在堆里面。
    4. String.intern():如果字符串常量池中已经包含一个等于此String对象的字符串,则返回代表池中这个字符串的String对象;否则,将此String对象包含的字符串添加到常量池,并且返回此String对象的引用。

    在HotSpot VM JDK1.7中,字符串创建在堆中,不会再复制字符串实例到常量池中,常量池中只保存对堆中字符串实例的引用。

    上面牵涉的情况很多,只要记住:JDK 1.7之后,字符串常量保存在堆中,常量池只保存对堆中字符串实例的引用,new String("abcdefg")时会创建两个字符串实例对象(见《013.Java几种常量池的区分.md》中4.示例)

    String str1 = "aaa";
    String str2 = "bbb";
    String str3 = "aaabbb";
    String str4 = str1 + str2;
    String str5 = "aaa" + "bbb";
    
    System.out.println(str1 == new String("aaa"));// false
    System.out.println(str3 == str4);// false
    System.out.println(str3 == str4.intern());// true
    System.out.println(str3 == str5);// true
    

    1.6 直接内存

    直接内存也就是堆外内存。

    • 对内内存

      堆内内存 = 新生代+老年代+持久代

    • 堆外内存

      堆外内存就是把内存对象分配在Java虚拟机的堆以外的内存,这些内存直接受操作系统管理(而不是虚拟机)。

      我们经常用java.nio.DirectByteBuffer对象进行堆外内存的管理和使用,它会在对象创建时就分配堆外内存。

    不是虚拟机运行时数据区的一部分,也不是Java虚拟机规范中定义的内存区域。但是也可能抛出OutOfMemoryError异常。

    NIO引入基于通道(Channel)和缓冲区(Buffer)的I/O方式,可以使用Native函数库直接分配堆外内存,然后通过在Java堆中的DirectByteBuffer对象作为这块内存的引用进行操作,在某些场景中避免了在Java堆和Native堆中来回复制数据,显著提高性能。

    本机直接内存的分配不会受到Java堆大小的限制。但是,既然是内存,肯定还是会受到本机总内存大小、处理器寻址空间的限制。

    在配置虚拟机内存参数时,如果忽略直接内存,可能会使各个内存区域总和大于物理内存限制(包括物理的和操作系统级的限制),从而导致动态扩展时出现OutOfMemoryError异常。

    2. OutOfMemoryError异常实战

    OutOfMemoryError简称OOM。

    操作系统分配给每个进程的内存是有限制的,如32位Windows限制为2G。在这种情况下,Java运行时各数据区域占用的内存总和最大为2G。

    • 内存溢出(Out of memory):程序在申请内存时,没有足够的内存空间供其使用。
    • 内存泄露(Memory leak):程序在申请内存后,无法释放已申请的内存空间。一次内存泄露危害可以忽略,但内存泄露堆积后果很严重,无论多少内存,迟早会被占光。

    2.1 Java堆溢出

    https://github.com/iMustang/l-jdk/raw/master/src/main/java/jvm/HeapOOM.java

    OutOfMemoryError:Java heap space
    
    -Xms20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError
    
    public class HeapOOM {
        static class OOMObject {
    
        }
    
        public static void main(String[] args) {
            List<OOMObject> list = new ArrayList<>();
            while (true) {
                list.add(new OOMObject());
            }
        }
    }
    

    运行结果:

    OutOfMemoryError1

    输出的堆转储快照为:https://gitee.com/xMustang/notes/raw/master/.attachment/java_pid2020.hprof

    处理思路:

    • 如果是内存泄露,查看对象到GC Roots的引用链,找到泄露对象通过怎样的路径与GC Roots相关联导致垃圾收集器无法自动回收它们,即GC Roots引用链的信息,可以比较准确定位出泄露代码的位置。
    • 如果不存在内存泄露,就是内存中的对象确实还必须存活着,是否需要加大虚拟机的堆参数。从代码上检查是否存在某些对象生命周期过长、持有状态时间过长的情况,尝试减小程序运行期的内存消耗。

    2.2 虚拟机栈与本地方法栈溢出

    https://github.com/iMustang/l-jdk/raw/master/src/main/java/jvm/JavaVMStackSOF.java

    在HotSpot VM中不区分虚拟机栈和本地方法栈。
    对于HotSpot VM来说,-Xss(设置本地方法栈大小)是无效的。
    
    -Xss128k
    
    public class JavaVMStackSOF {
        private int stackLength = 1;
    
        public void stackLeak() {
            stackLength++;
            stackLeak();
        }
    
        public static void main(String[] args) throws Throwable {
            JavaVMStackSOF oom = new JavaVMStackSOF();
            try {
                oom.stackLeak();
            } catch (Throwable e) {
                System.out.println("stack length:" + oom.stackLength);
                throw e;
            }
        }
    }
    

    运行结果:

    StackOverflowError

    建立过多线程导致的内存溢出,在不能减少线程数或者更换64位虚拟机的情况下,可以通过减小最大堆来换取更多的线程。

    https://github.com/iMustang/l-jdk/raw/master/src/main/java/jvm/JavaVMStackOOM.java

    -Xss2M
    
    public class JavaVMStackOOM {
        private void dontStop() {
            while (true) {
    
            }
        }
    
        public void stackLeakByThread() {
            while (true) {
                Thread thread = new Thread(new Runnable() {
                    @Override
                    public void run() {
                        dontStop();
                    }
                });
                thread.start();
            }
        }
    
        public static void main(String[] args) {
            JavaVMStackOOM oom = new JavaVMStackOOM();
            oom.stackLeakByThread();
        }
    }
    

    Windows上,Java线程映射到操作系统的内核线程上,因此这段代码可能导致操作系统假死。

    Exception in thread "main" java.lang.OutOfMemoryError:unable to 
    create new native thread
    

    2.3 方法区和运行时常量池溢出

    1. 在JDK1.6及之前构造大量的字符串导致永久代溢出

      https://github.com/iMustang/l-jdk/raw/master/src/main/java/jvm/RuntimeConstantPoolOOM.java

      OutOfMemoryError:PerGen space
      在jdk1.6及之前,HotSpot用永久代实现方法区。在JDK1.7逐步去永久代。
      以下代码需要在JDK1.6测试
      VM参数:
      -XX:PermSize=10M -XX:MaxPermSize=10M
      
      public class RuntimeConstantPoolOOM {
          public static void main(String[] args) {
              List<String> list = new ArrayList<>();
              int i = 0;
              while (true) {
                  list.add(String.valueOf(i++).intern());
              }
          }
      }
      

      运行结果:

      方法区和运行时常量池溢出
    2. 使用CGLib动态产生类使方法区溢出

      -XX:PermSize=10M -XX:MaxPermSize=10M
      
      public class JavaMethodAreaOOM{
          public static void main(String[] args){
              while(true){
                  Enhancer enhancer = new Enhancer();
                  enhancer.setSuperclass(OOMObject.class);
                  enhancer.setUseCache(false);
                  enhancer.setCallback(new MethodInterceptor(){
                      public Object intercept(Object obj, Method method, Object[] args, MethodProxy proxy) throws Throwable{
                          return proxy.invokeSuper(obj,args);
                      }
                  });
                  enhancer.create();
              }
          }
      
          static class OOMObject{
      
          }
      }
      
      运行结果:
      Caused by: java.lang.OutOfMemoryError: PermGen space
      

      常见场景:

      1. 使用反射,动态代理、CGLib等动态生成类时。
      2. 在大量JSP或动态产生JSP文件的应用(JSP第一次运行时需要编译为Java类)
      3. 基于OSGi的应用(同一个类被不同的加载器加载也会视为不同的类)。
    以下代码在JDK1.6、JDK1.7结果不同:
    
    String str1 = new StringBuilder("计算机").append("软件").toString();
    // JDK1.6 false
    // JDK1.7 true
    System.out.println(str1.intern() == str1);
    
    String str2 = new StringBuilder("ja").append("va").toString();
    // JDK1.6 false
    // JDK1.7 false
    System.out.println(str2.intern() == str2);
    

    在下面分析具体原因前,先看一下StringBuilder的toString()源码

    public String toString() {
        // Create a copy, don't share the array
        return new String(value, 0, count);
    }
    

    JDK1.6中,StringBuilder.toString()创建的字符串实例在Java堆上,intern()将首次出现的字符串实例复制到永久代中,返回也是永久代这个字符串实例的引用,所以与StringBuilder.toString()创建的字符串实例必然不是同一个引用,将返回false。

    JDK1.7中,StringBuilder.toString()创建的字符串实例在Java堆上,intern()不会再将首次出现的字符串实例复制字符串常量池,只是在常量池中记录首次出现的实例的引用。因此intern()返回的引用和StringBuilder.toString()创建的字符串实例是同一个。

    上面代码在JDK1.7第二个结果为false是因为java这个字符串在运行new StringBuilder("ja").append("va").toString();代码之前早就存在JVM中了,不是首次出现。

    2.4 本机直接内存溢出

    https://github.com/iMustang/l-jdk/raw/master/src/main/java/jvm/DirectMemoryOOM.java

    OutOfMemoryError
    DirectMemory容量可通过-XX:MaxDirectMemorySize指定,如果不指定,默认与Java堆最大值(-Xmx)一样。
    
    -Xmx20M -XX:MaxDirectMemorySize=10M
    
    public class DirectMemoryOOM {
        private static final int _1MB = 1024 * 1024;
    
        public static void main(String[] args) throws IllegalAccessException {
            Field unsafeField = Unsafe.class.getDeclaredFields()[0];
            unsafeField.setAccessible(true);
            Unsafe unsafe = (Unsafe) unsafeField.get(null);
            while (true) {
                unsafe.allocateMemory(_1MB);
            }
        }
    }
    

    运行结果:

    DirectMemoryOOM

    由DirectMemory导致的内存溢出,明显的特征是Heap Dump文件中不会看到明显的异常。

    如果OOM后发现Dump文件很小,又直接或间接使用了NIO,考虑是不是本机直接内存溢出。

    相关文章

      网友评论

          本文标题:1.Java内存区域与内存溢出异常

          本文链接:https://www.haomeiwen.com/subject/xttdqhtx.html