深入理解 Java 虚拟机学习(一) -- java 内存区域与内存溢出异常

96
偷星辰夜
2017.09.28 14:34* 字数 5136

java 内存区域

要进行 java 虚拟机的深入学习,首先要了解的是 java 的内存划分。大部分程序员一开始接触 java ,对于内存的划分是印象是堆内存和栈内存,而这仅仅适合于入门的学习,实际上 java 的内存划分,远远复杂的多。


  1. java 内存划分

    java 虚拟机在执行 java 程序时,会把它所管理的内存区域划分为若干个不同的数据区域。这些区域各有用处、创建及销毁时间。有的区域是随虚拟机的启动而启动,属于线程共有的,而有的是随用户线程的启动而启动,属于线程私有的。下图给出了 java 虚拟机对于内存的划分关系:
    image
  2. 各部分内存详解 :

    • 程序计数器(Program Counter Register)

      我们知道 Java 虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时间,一个处理器(对于多核处理器来说就是一个内核)都只会执行一条线程的指令。那么对于每一个线程来说,我们就需要有一个记录器,来保证线程切换后能恢复到正确的执行位置,而且这个记录器必须是每个线程独立的,这个记录器就是我们的程序记录器
        程序记录器是一块较小的内存,它可以看做当前线程所执行的字节码的行号计数器。在虚拟机的实现原理中,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
         如果线程正在执行的是一个 java 方法,这个计数器记录的是正在执行的虚拟机字节码指令的地址;如果执行的是一个 Native 方法,这个计数器值则为空(Undefined)。此内存区域是唯一一个在 Java 虚拟机中没有定义 OutOfMemoryError 的区域。
    • Java 虚拟机栈(Java Virtual Machine Stacks)

      与程序计数器一样,虚拟机栈也是线程私有的内存,其生命周期跟线程相同。
        虚拟机栈描述的是 Java 方法执行的内存模型:学过数据结构的朋友都应该知道,每个方法在执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息,每一个方法的执行过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。
        局部变量表存放了编译器可知的各种基本数据类型、对象引用和 returnAddress 类型,其所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小
        在 Java 虚拟机规范中,对这个区域规定了两种异常情况:如果线程请求的栈深度大于虚拟机所允许的深入,将抛出 StackOverflowError 异常;如果虚拟机栈可以动态 扩展,而扩展时无法申请到足够的内存,将会抛出 OutOfMemoryError 异常。
    • 本地方法栈(Native Method Stack)

      本地方法栈与虚拟机栈发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行 Java 方法服务,而本地方法栈则为虚拟机使用到的 Native 方法服务。
        与虚拟机栈一样,本地方法区栈也会抛出 StackOverflowError 和 OutOfMemoryError 异常。
    • Java 堆(Java Heap)

      就像大多数朋友了解的一样,Java 堆唯一的目的是存放对象实例,几乎所有的对象实例都在这里分配内存。其也基本是 Java 虚拟机管理的内存中最大的一块,它是被所有线程共享的一块内存区域,在虚拟机启动时创建。
        Java 堆是垃圾回收器管理的主要区域,因此很多时候也被称作“GC 堆”。从内存回收的角度看,由于现在收集器基本都采用分代收集算法,所以 Java 堆中还可以细分为:新生代和老生代,再细致一点的有 Eden 空间,From Survivor 空间,To Survivor 空间等;从内存分配的角度看,线程共享的 Java 堆中可能划分出多个线程私有的分配缓冲区(Thread Local Allocation Buffer,TLAB)。无论如何划分,其目的都只是为了更好的回收内存,或者更快地分配内存,而具体细节的回收跟分配细节,我们将在后面的文章讨论。
         根据 Java 虚拟机规范的规定,Java 堆可以处于物理上不连续的内存空间中,只要逻辑上是连续的即可。在实现时,既可以实现为固定大小的,也可以实现为可扩展的,目前主流的虚拟机都是按照可扩展来实现的(通过 -Xms 和 -Xmx 控制)。如果在堆中没有内存完成实例分配,并且堆也无法再扩展时,将会抛出 OutOfMemoryError 异常。
    • 方法区(Method Area)

      方法区与 Java 堆一样,是各个线程共享的内存区域,它用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然 Java 虚拟机把方法区描述为堆的一个逻辑部分,但是它却有一个别名叫做 Non-Heap(非堆),目的是与 Java 堆区分开来。
        Java 虚拟机规范对方法区的限制非常宽松,除了和 Java 堆一样不需要连续的内存和可以选择固定大小或者可扩展外,还可以选择不实现垃圾收集。这区域的内存回收目标主要是针对常量池的回收和对类型的卸载,一般来说,这个区域的回收成绩比较难以令人满意,尤其是类型的卸载,条件相当苛刻,但这一部分的回收确实是必要的,具体的细节我们应该后面的文章有所介绍。
        根据 Java 虚拟机规范的规定,当方法区无法满足内存 分配需求时,将抛出 OutOfMemoryError 异常。
    • 运行时常量池(Runtime Constant Pool)

      运行时常量池实际上是方法区的一部分。Class 文件中除了有类的版本、字段、方法、接口等描述信息外,还要一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池存放。
        Java 虚拟机对 Class 文件每一部分的格式都有严格规定,每一个字节用于存储哪种数据都必须符合规范上的要求才会被虚拟机认可、装载和执行,但对于运行时常量池,Java 虚拟机规范并没有做任何细节的要求。不过一般来说,除了保存 Class 文件中描述的符号引用外,还会把翻译出来的直接引用也存储在运行时常量池中。
        既然运行时常量池是方法区的一部分,自然受到方法区内存的限制,当常量池无法再申请到内存时会抛出 OutOfMemoryError 异常。
    • 直接内存(Direct Memory)

      直接内存并不是虚拟机运行时数据区的一部分,也不是 Java 规范中定义的内存区域。但是这部分内存也被频繁的使用,而且也可能导致 OutOfMemoryError 异常,所以我们放在这里一起讲解。
        在 JDK 1.4 中新加入了 NIO(New Input/Output) 类,引入了一种基于通道与缓冲区的 I/O 方式,它可以使用 Native 函数库直接分配堆外内存,然后通过一个存储在 Java 堆中的 DirectByteBuffer 对象作为这块内存的引用进行操作。
        显然,本机直接内存的分配不会受到 Java 堆大小的限制,但是,既然是内存,肯定还是会受到本机总内存大小及处理器寻址空间的限制。服务器管理员在配置虚拟机参数时,会根据实际内存设置 -Xmx 等参数信息,但经常忽略直接内存,使得各个内存区域总和大于物理内存限制,从而导致动态扩展时出现 OutOfMemoryError 异常。
  3. OutOfMemoryError 异常测试

    在 Java 虚拟机规范中,除了程序计数器之外,另外几个内存区域都会出现 OutOfMemoryError 异常,现在我们通过几个代码段来测试一下这些异常发生的场景。这些测试的目的有两个:1、通过程序验证 Java 虚拟机运行时各个内存区域存储的内容;2、希望读者们在以后的编程过程中遇到内存溢出异常时,能够根据异常信息准确快速地判断哪个区域的内存溢出,并作出处理。
      接下来的代码段在执行的时候,都需要在执行设置虚拟机参数,这些参数在代码前注释都会有,而且这些参数对实验结果会有直接的影响,如果有读者也想实验试一下这些内存溢出情况的话,请不要忽略这些参数。如果实在命令行执行程序,那么直接在 Java 命令之后带上参数就可以了,如果是在 IDE 中执行,那么就在虚拟机选项中加上,下图为笔者在 IDEA 编辑器中的示例:
    java虚拟机参数示例
    • Java 堆溢出

      Java 堆用于存储对象示例,只要不断地创建对象,并且保证 GC Roots 到对象之间有可达路线来避免垃圾回收机制清理这些对象,那么在对象数量达到最大堆的容量限制后就会产生内存溢出异常。
      /**
       * Created by LinSY on 2017/9/23.
       * VM args: -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()) ;
              }
          }
      }
      
      运行结果:
        
      Java 堆溢出

        Java 堆的内出溢出异常时实际应用中最常见到的情况,当出现 Java 堆溢出异常时,异常堆信息 "java.lang.OutOfMemoryError" 会跟着进一步提示 "Java heap space" 。
        解决这个区域的异常,一般是通过内存映像分析工具进行分析,具体的分析方法我们看看后面有没有时间再专门整理出一片文章来给大家介绍下,这里先简单说一下思路:1、我们先分析内存中的对象是否必要存在,也就是判断是内存泄漏还是内存溢出。2、如果是内存溢出,我们可以通过工具进一步查看泄漏对象到 GC Roots 的引用链,找寻到垃圾回收机制无法回收这些对象的原因,然后就可以比较准确地定位泄漏代码的位置。3、如果不存在内存泄漏,也就是说内存中的对象是必要存在的,那么就应该检查虚拟机的堆参数,与机器的物理内存比较看是否还可以调大,从代码上检查是否某些对象的声明周期过长,尝试减少程序运行期的内存消耗等路径去解决问题。
    • 虚拟机栈和本地方法栈溢出

      笔记目前环境使用的是 HopSpot 虚拟机,它是不区分虚拟机栈和本地方法栈的,因此对于 HopSopt 来说,使用 -Xoss 参数设置本法方法栈的方法是无效的,栈容量只能由 -Xss 参数设定。
        上文提到的,虚拟机栈和本地方法栈规定了两种异常 OutOfMemoryError 异常和 StackOverflowError 异常,具体出现情况可以去上文查看。那么我们只需要定义虚拟机参数 -Xss 减少栈内存,再定义无限递归调用某一方法,应该就能获得 StackOverflowError 异常,异常出现时输出的堆栈深度相应减少:
      /**
       * VM Args: -Xss256k
       * Created by LinSY on 2017/9/23.
       */
      public class StackOverflowError {
          private int stackDepth = 1 ;
          public void stackLeak(){
              stackDepth++ ;
              stackLeak() ;
          }
      
          public static void main(String[] args) throws Throwable{
              StackOverflowError stackOverflowError = new StackOverflowError() ;
              try {
                  stackOverflowError.stackLeak();
              } catch (Throwable e) {
                  System.out.println("stack depth : " + stackOverflowError.stackDepth);
                  throw e ;
              }
          }
      }
      
      
      实验结果:
        
      虚拟机栈和本地方法区StackOverflowError

        我们知道,操作系统分配给每个进程的内存是有限的(32 位 Windows 限制为 2GB),虚拟机提供了参数来控制控制 Java 堆和方法区这两部分内存的最大值,剩余的内容为 2GB(操作系统限制) 减去 Xmx(最大堆容量),再减去 MaxPermSize(最大方法区容量),程序计数器消耗的内存很小可以忽略,如果虚拟机本身消耗的内存我们忽略了,那么剩下的内存就有本地方法栈和虚拟机栈瓜分了,每个线程分配的栈容量越大,可以建立的线程数量自然就越小,建立线程时就越容易把剩下的内存耗尽。所以我们可以通过建立不断建立线程的方式来产生 OutOfMemoryError 异常:
      /**
      * VM Args: -Xss2M(也可以设置再大一点)
      * Created by LinSY on 2017/9/26.
      */
      public class StackOOM {
         private void dontStop(){
             while (true){
             }
         }
      
         public void stackLeakByCreatThread(){
             while (true){
                 Thread thread = new Thread(new Runnable() {
                     @Override
                     public void run() {
                         dontStop();
                     }
                 }) ;
                 thread.start();
             }
         }
      
         public static void main(String[] args) {
             StackOOM stackOOM = new StackOOM() ;
             stackOOM.stackLeakByCreatThread();
         }
      }
      
      
      特别提醒:如果读者想尝试上面的代码,记得先保存当前的工作。因为在 Windows 虚拟机上,Java 的线程时映射到操作系统的内核线程上的,因此上面这段代码又较大的风险,有可能导致系统假死。
        出现 StackOverflowError 异常时,我们有错误堆栈可以阅读,相对来说,比较容易找到问题所在。但是,如果是建立过多线程导致的内存溢出,在不能减少线程数或者更换 64 位虚拟机的情况下,就只能通过减少最大堆和减少栈容量来换取更多的线程,这是一种解决多线程内存溢出的方法。
    • 方法区和运行时常量池溢出

      由于运行时常量池是方法区的一部分,因此这两个区域的溢出就放在一起测试。
        String.intern() 方法是一个 Native 方法,它的作用是:如果字符串常量池中包含了一个等于此字符串的对象,则返回字符串的 String 对象;否则将此字符串添加到常量池中,并返回此 String 对象的引用。因此,我们可以在设置方法区的最大容量之后,通过死循环来执行该方法,从而得到运行时常量池的 OOM 异常:
      import java.util.ArrayList;
      import java.util.List;
      
      /**
       * VM Args : -XX:PermSize=2M, -XX:MaxPermSize=2M
       * Created by LinSY on 2017/9/26.
       */
      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()) ;
              }
          }
      }
      
      
      运行结果:
        
      运行时常量池 OOM

        这里需要注意的是这个实验结果只有在JDK1.6及以下才能得到,在 JDK1.7 或以上该程序代码的循环会一直下去,这是受到 JDK1.7 逐步去除“永久带”的影响。
        方法区用于存放 Class 的相关信息,如类名、访问修饰符,常量池、字段描述、方法描述等。对于这类区域的测试,基本的思路是在运行时去产生大量的类去填满方法区,直到溢出。虽然直接使用 Java SE API 也可以产生动态类,但在本次实验中操作起来比较麻烦,在下面的代码中,笔者借助 CGLib 直接操作字节码运行时产生了大量的动态类。
      import net.sf.cglib.proxy.Enhancer;
      import net.sf.cglib.proxy.MethodInterceptor;
      import net.sf.cglib.proxy.MethodProxy;
      
      import java.lang.reflect.Method;
      
      /**
      * VM Args: -XX:PermSize=10M -XX:MaxPermSize=10M
      * Created by LinSY on 2017/9/26.
      */
      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 {
      
         }
      }
      
      方法区溢出也是一种常见的内存溢出,一个类要被垃圾收集器回收掉,判定条件是比较苛刻的。在经常生成大量 Class 的应用中,需要特别注意类的回收状况。
    • 本机直接内存溢出

      本机直接内存容量可以通过 -XX:MaxDirectMemorySize 指定,如果不指定,则默认与 Java 堆一样。下面的代码越过了 DirectByteBuffer 类,直接通过反射获取 UnSafe 实例进行内存分配。因为虽然使用 DirectByteBuffer 分配内存也会抛出内存异常,但它抛出异常时并没有真正向操作系统申请分配内存,而是通过计算得知内存无法分配,于是手动抛出异常,真正分配内存的方法是 unsafe.allocateMemory() 方法。
      import org.omg.CORBA.TRANSACTION_MODE;
      import org.omg.CORBA.UNSUPPORTED_POLICY;
      import sun.misc.Unsafe;
      
      import java.lang.reflect.Field;
      
      /**
       * VM Args: -Xmx20M -XX:MaxDirectMemorySize=10M
       * Created by LinSY on 2017/9/26.
       */
      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) ;
              }
          }
      }
      
      实验结果:
        
      本地直接内存溢出

        本地直接内存溢出并没有什么其他多于的提示信息,其主要标志是在 Heap Dump 文件中不会看见明显的异常,如果读者发现 OOM 之后的 Dump 文件很小,而程序中又直接或间接的使用了 NIO,那就可以考虑检查一下是不是这方面的原因。
  4. 小结

    在这篇文章中,我们明白了虚拟机中的内存是如何划分的,哪部分区域、什么样的代码和操作可能导致溢出异常并用代码实践了这些异常出现的结果。虽然 Java 有垃圾回收机制,但内存溢出离我们并不遥远,我们在实际应用中仍需十分小心并掌握异常出现后的解决办法。而下一篇文章,我们将讲解一下 Java 垃圾回收机制为了避免内存溢出异常都做出了哪些努力。
深入理解 Java 虚拟机
Web note ad 1