深入理解Java虚拟机之Java自动内存管理机制

深入理解Java虚拟机之Java自动内存管理机制

概述

  • 对于Java开发人员来说,在虚拟机的自动内存管理机制的帮助下,不再需要为每一个new操作去写匹配的delete/free代码,不容易(但还是有可能)出现内存泄漏和内存溢出的问题,由虚拟机管理内存。也正是因为Java开发人员将内存控制的权利交给了JVM,一旦出现了内存泄漏和溢出方面的问题,如果不了解JVM是如何使用内存的,那么排查错误将成为一项异常艰难的工作。

运行时数据区

  • JVM在执行Java程序的过程中会把它所管理的内存划分为若干个不同的数据区。这些区域都有自己的用途,以及创建和销毁的时间。有些区域随着虚拟机进程的启动而存在,有些区域依赖于用户线程的启动和结束而建立和销毁。

JVM所管理的内存将会包括一下几个运行时的数据区域(即:Java的内存分为一下几个区域)。如图:

java_mem.jpeg
  • 程序计数器(Program Counter Register)
    • 程序计数器是一块比较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器 。字节码解释器工作时就是通过改变这个计数器的值来选择下一条需要执行的字节码指令。分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
      • 对于线程来说
        • 每一个线程都有一个独立的程序计数器,各个线程之间的计数器互不影响,独立存储。故线程私有(JVM的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个时确定的时刻,一个处理器都只会执行一条线程中的指令,故这也是为了线程切换后能够恢复到正确的执行位置)
      • 对于方法来说
        • 对于Java方法:程序计数器记录的是正在执行的虚拟机字节码指令的地址
        • 对于Native方法:程序计数器为空(undefined).
  • Java虚拟机栈(Java Virtual Machine Stacks)
    • Java虚拟机栈,线程私有。描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在JVM中入栈和出栈的过程。
    • 局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用类型(reference类型)(64位长度的long和double会占用两个局部变量空间(slot),剩余的数据类型只占用一个)。局部变量表所需的内存空间在编译期间完成分配,当进入一个方法时,这个方法需要在帧中分配多大的局部变量空间是完全确定的,在方法运行期间不会改变局部变量表的大小。
    • 可能出现的异常状况:
      • StackOverflowError异常,即线程请求的栈深度大于JVM所允许的深度
      • OutOfMemoryError 异常,即如果虚拟机栈可以动态拓展,而在拓展的过程中无法申请到足够的内存。
  • 本地方法栈(Native Method Stack)
    • 本地方法栈是为JVM所使用到的Native方法服务。在虚拟机的规范中,对本地方法栈中方法所使用的语言,数据结构没有强制的规定。因此具体的虚拟机可以自由实现。故有的虚拟机(例如:HotSpot)直接将本地方法栈与虚拟机栈合二为一。
    • 可能出现的异常:(与虚拟机栈一样)
      • StackOverflowError异常
      • OutOfMemoryError 异常
  • Java 堆(Java Heap)
    • Java堆是虚拟机中所管理的内存中最大的一块。Java堆是被所有的线程共享的一块内存区域。在虚拟机启动时创建。此内存区域的唯一目的就是存放变量实例。几乎所有的对象都在这里创建(JIT编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术会导致一系列的变化的发生,所有的对象都分配在堆上就不是那么绝对了)。
    • Java 堆是垃圾收集器管理的主要区域,因此会被称为"GC堆"。Java堆可以被细分为:(各个的作用在垃圾回收章节说明)
      • 新生代(1/3)
        • Eden空间)(即传说的伊甸园区)(8/10)
        • Survivor空间(幸存区)(2/10)
          • From Survivor空间(1/10)
          • To Survivor 空间(1/10)
      • 老年代(2/3)
    • 可能出现的异常
      • OutOfMemoryError 异常,即在堆中没有内存完成实例分配,并且堆也没有办法再拓展时。
    • 内存调整参数
      • -Xmx 设定程序运行期间最大可占用的内存大小
      • -Xms 设定程序启动时占用内存大小
  • 方法区(Method Area)
    • 方法区(别名:Non-heap:非堆) 也被称为”永久代“。与Java堆一样,也是各个线程共享的内存区域。用于存储已经被JVM加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。在HotSpot虚拟机中,将GC的分代收集拓展到方法区,或者说是使用永久代来实现方法区。那么,HotSpot的垃圾收集器就可以像管理Java堆一样来管理这一部分的内存。

    • 注意:

      • 在Java8中已经使用元空间来代替永久代,也就是在Java8中已经没有永久代了。类似-XX:MaxPermSize这些设置永久代内存大小的参数均已失效了。

      • JDK 1.7 的HotSpot中,将原本放在永久代的字符串常量池移出了。

      • GC回收的目标:1.常量池 2.类型的卸载

      • 测试:

        • 测试代码一
            import java.util.ArrayList; 
            import java.util.List;
        
            public class StringOomMock {
            static String  base = "string";
            public static void main(String[] args) {
                List list = new ArrayList();
                for (int i=0;i< 50;i++){
                    String str = base + base;
                    base = str;
                    list.add(str.intern());
                }
            }
            }
        
        • JDK1.6下运行


          1.6perm.png
        • JDK1.7下运行


          1.7perm.png
        • JDK1.8下运行


          1.8perm.png
      • 说明
      • JDK 1.6下,会出现“PermGen Space”的内存溢出,而在 JDK 1.7和 JDK 1.8 中,会出现堆内存溢出,并且 JDK 1.8中 PermSize 和 MaxPermGen 已经无效。因此,可以大致验证 JDK 1.7 和 1.8 将字符串常量由永久代转移到堆中,并且 JDK 1.8 中已经不存在永久代的结论
      • 元空间的本质和永久代类似,都是对JVM规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制,但可以通过以下参数来指定元空间的大小
        • -XX:MetaspaceSize,初始空间大小,达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整:如果释放了大量的空间,就适当降低该值;如果释放了很少的空间,那么在不超过MaxMetaspaceSize时,适当提高该值
        • -XX:MaxMetaspaceSize,最大空间,默认是没有限制的。
        • -XX:MinMetaspaceFreeRatio,在GC之后,最小的Metaspace剩余空间容量的百分比,减少为分配空间所导致的垃圾收集
        • -XX:MaxMetaspaceFreeRatio,在GC之后,最大的Metaspace剩余空间容量的百分比,减少为释放空间所导致的垃圾收集

      • 测试代码二
      import java.io.File;
      import java.net.URL;
      import java.net.URLClassLoader;
      import java.util.ArrayList;
      import java.util.List;
      
      public class PermGenOomMock{
          public static void main(String[] args) {
              URL url = null;
              List classLoaderList = new ArrayList();
              try {
                  url = new File("/tmp").toURI().toURL();
                  URL[] urls = {url};
                  while (true){
                      ClassLoader loader = new URLClassLoader(urls);
                      classLoaderList.add(loader);
                      loader.loadClass("com.paddx.test.memory.Test");
                  }
              } catch (Exception e) {
                  e.printStackTrace();
              }
          }
      }
      
      • JDK1.8下运行


        1.8meta.png
      • 说明:
      • 不再出现永久代溢出,而是出现了元空间的溢出。
      • 可能出现的异常
        • OutOfMemoryError异常:即方法区无法满足内存分配的需求时
  • 运行时常量池(Runntime Constant Pool)
    • 运行是常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池(Constant Pool Table),用于存放编译器期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。
    • 除了保存在Class文件中描述的符号引用,还会把翻译出来的直接引用也存储在运行时常量池中。
    • 运行时常量池相对于Class文件常量池的另外一个重要特征就是具备动态性。Java语言并不要求常量一定只有在编译期才能产生,也就是并非预置入Class文件中常量池的内存用才能进入方法区运行时常量池,运行期间也可能将新的常量放到池中
      • String类的intern()方法
    • 可能出现的异常:
      • OutOfMemoryError异常:即常量池无法再申请到内存时
  • 直接内存(Direct Memory)
    • 直接内存并不是JVM运行时数据区的一部分,也不是JVM规范中定义的内存区域。只是因为这一部分内存经常使用,也可能导致OutOfMemoryError异常。
    • 来源:
      • 在JDK1.4 中加入了NIO(new Input/Output)类,引入了一种基于通(Channel)与缓冲区(Buffer)的I/O方式,它可以使用Native函数库直接分配堆外内存,然后通过一个存储在Java堆上的DirectByteBuffer对象作为这块内存的引用进行操作。这样避免了在Java堆和Native堆中来回复制数据(可以学习一下Linux系统中的epoll(如何避免内核在用户态和内核态来回切换))
    • 注意:
      • 不要随意设置-Xmx,因为本机直接内存虽然不受Java堆大小的限制,但是会受到本机总内存的限制(包括 RAM 、SWAP区、分页文件)。如果忽略了直接内存,就可能导致各个内存区域的总和大于物理内存的限制(操作系统的和物理的)

对象揭秘

创建流程

new_object.jpg
  • 说明:
    • 如何判断类是否加载
      • 检查new指令的参数是否能在常量池中定位到一个类的符号引用并且检查这个符号引用代表的类是否已经被加载、解析、初始化过。

对象的内存布局

  • 对象在内存中的布局分为三部分:
    • 对象头(Header)(实现synchronized的基础)
      • 对象头包含两部分信息
        • 一:存储对象自身的运行数据(MarkWord)。如哈希码、GC分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等(如下图,每一行代表一种状态即根据对象的状态来复用自己的存储空间)

          MarkWord.jpeg

        • 二:类型指针。即对象指向它的类元数据指针,JVM通过该指针来确定这个对像是哪个类的实例。

    • 实例数据(Instance Data)
      • 对象整正存储的有效信息,也是在程序代码中所定义的各种类型字段的内容。无论是从父类继承下来的还是在子类中定义的,都需要记录下来。
    • 对齐填充(padding)
      • 因为JVM要求对象的起始地址必须是8字节整数倍,这个部分就是为了字节对齐

对象的访问定位

  • Java程序需要通过栈上的reference数据来操作堆上具体的对象。由于reference类型在JVM规范中只规定了一个执行对象的引用,并没有定义这个引用该通过什么方式去定位、访问堆上的对象的具体位置,所以对象的访问取决于虚拟机的实现而定的。
  • 访问的方式
    • 使用句柄访问
      • 优势:reference中存储的是稳定的句柄地址,在对象移动时只会改变句柄中的数据指针,而reference本身不需要改变


        jubing_fangwen.png
  • 直接使用指针访问
    • 优势:访问速度更快。少了一次指针定位的开销。


      zhizhen_fangwen.png

你可能感兴趣的:(深入理解Java虚拟机之Java自动内存管理机制)