Java内存模型和内存泄漏实战(一)

 2019-12-10 16:04  阅读(1299)
文章分类:Java Core

java虚拟机在执行java程序过程中会把它所管理的内存划分为不同的数据区域。有的区域随着虚拟机进程的启动而存在,有些区域则依赖用户线程的启动和结束而简历和销毁。

20191210001537\_1.png
上图的java运行时数据区,大体分为线程私有区和线程共享区

线程私有区

  1. 程序计数器:一块较小的内存空间,可以看做是当前线程所执行的字节码的行号指示器。字节码解释器工作的时候就是通过改变这个计数器的值来选取下一条需要执行的字节码的指令,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。为了线程切换后能够恢复到正确的执行位置,每个线程都需要有一个独立的程序计数器。(如果正在执行的是一个java方法,计数器记录的是虚拟机正在执行的字节码的指令地址;如果是native方法,计数器值为空Undefined)。此内存是唯一一个在java虚拟机规范中没有规定OOM情况的区域。
  2. java虚拟机栈:生命周期和线程相同,描述的是java方法执行的内存模型;每个方法在执行的同时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法的出口等信息。每个方法从调用直至执行完成的过程,就对应一个栈帧在虚拟机栈中入栈到出栈的过程。
    局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象的引用和returnAddress类型。
    本地方法栈:和虚拟机栈作用类似,区别在于虚拟机方法栈为虚拟机执行java方法服务,本地方法栈则为虚拟机执行native方法服务。

线程共享区

  • java堆: 是虚拟机所管理的内存中最大的一块,在虚拟机启动时创建。几乎所有的对象实例都在这里分配内存。java堆是垃圾收集器管理的主要区域。从内存回收的角度来看可细分为:新生代和老年代;再细致一点有Eden空间、From Survivor空间、To Survivor空间等。从内存的角度来看分为多个线程私有的分配缓冲区(Thread Loacal Allocation Buffer,TLAB)。
  • 方法区:用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
  • 运行时常量池:方法区的一部分,class文件除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。运行时常量池相对于class文件的常量池的另一个重要的特征是具备动态性,java语言并不要求常量只有编译期才能产生,比较常见的是String类的intern()方法。
  • 直接内存:并不是虚拟机运行时数据的一部分,也不是java虚拟机规范中定义的内存区域。nio操作可以使用native函数直接分配堆外内存,然后通过一个存储在java堆中的DirectByteBuffer对象作为这块内存的引用进行操作。这样避免了java堆和native堆中来回复制数据。

对象的创建

在语言层面上,创建对象通常仅仅是一个new关键字而已。而在虚拟机中,虚拟机遇到一条new指令时:

  1. 检查这条指令的参数是否能在常量池中定位到一个类的符号引用
  2. 检查这个符号引用代表的类是否已被加载、解析和初始化过。(如果没有,先执行相应的类加载过程)
  3. 为新生对象分配内存。对象所需的内存大小在类加载完成后便可完全确定,为对象分配空间的任务等同于把一块确定大小的内存从java堆中划分出来。
  4. 虚拟机需要将分配的内存都初始化为零值(不包括对象头)
  5. 对对象进行必要的设置,例如这个对象是哪个类的实例、如何才能找到类的元数据信息、对象的哈希码、对象的gc分代年龄等信息。
  6. 执行init方法

对象的内存布局

对象在内存中存储的布局可以分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充(Padding)。

  • 对象头包括两部分信息:

    1. 存储对象自身的运行时数据:如哈希码、gc分代年龄、锁状态标识、线程持有的锁、偏向线程ID、偏向时间戳等,这部分数据在32位和64位的虚拟机中分别为32bit和64bit,官方称为”Mark Word”。Mark Word被设计成一个非固定的数据结构以便在极小的空间存储尽量多的信息,它会根据对象的状态复用自己的存储空间。例如,在32位的HotSpot虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的32bit空间中分配见下图:
      20191210001537\_2.png
    2. 类型指针:即对象指向它的类元数据的指针,虚拟机通过这个指针确定这个对象是哪个类的实例。另外对于一个java数组对象,那么在对象头还必须要有一块用于记录数组长度的数据,因为虚拟机可以通过普通java对象的元数据信息确定java对象的大小,但是从数组元数据中却无法知道数组的大小。
  • 实例数据:是对象真正存储的有效信息,也是在程序代码中定义的各种类型的字段内容。无论是从父类继承下来,还是子类中定义的,都需要记录起来。这部分的存储顺序会受到虚拟机分配策略参数(FieldsAllocationStyle)和字段在java源码中定义的顺序。HotSpot默认的分配策略为longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),从分配策略看出,相同字符宽度的字段总是被分配到一起。在满足这个的前提下,在父类中出现的变量会出现在子类之前。如果CompactsFields参数值为true(默认为true),那么子类之中较窄的变量也可能会插入到父类变量的空隙之中。

  • 对齐填充:并不是必然存在的,也没有特别的含义,仅仅起着占位符的作用。对象的起始地址必须是8字节的整数倍(即对象的大小必须是8字节的整数倍)。而对象头刚好是8字节的倍数(1倍或者2倍),因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

在java虚拟机规范的描述中,除了程序计数器外,虚拟机的其他几个内存都会发生OOM

虚拟机启动参数:

  • -verbose:gc 输出每次GC的相关情况
  • -Xms512m 设置JVM初始内存为512m。此值可以设置与-Xmx相同,以避免每次垃圾回收完成后JVM重新分配内存。
  • -Xmx512m ,设置JVM最大可用内存为512M。
  • -Xmn200m:设置年轻代大小为200M。整个堆大小=年轻代大小 + 年老代大小 + 持久代大小。持久代一般固定大小为64m,所以增大年轻代后,将会减小年老代大小。此值对系统性能影响较大,Sun官方推荐配置为整个堆的3/8。
  • -Xss128k:设置每个线程的堆栈大小。JDK5.0以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。更具应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。
  • -XX:+PrintGCDetails 每次GC时打印详细信息
  • -XX:+HeapDumpOnOutOfMemoryError 当首次遭遇OOM时导出此时堆中相关信息

java堆溢出

static class Obj{}

        /** * vm args -Xmx20m -Xmx20m -XX:+HeapDumpOnOutOfMemoryError * @param args */
        public static void main(String[] args) {
            List<Obj> list = new ArrayList<>();
            try {
                while (true){
                    list.add(new Obj());
                }
            }catch (Throwable t){
                t.printStackTrace();
                System.out.println("集合大小"+list.size());
            }
        }

运行结果:
java.lang.OutOfMemoryError: Java heap space
Dumping heap to java_pid10744.hprof …
Heap dump file created [28330602 bytes in 0.102 secs]
java.lang.OutOfMemoryError: Java heap space
集合大小810325
at java.util.Arrays.copyOf(Arrays.java:3210)

用MAT工具分析:
20191210001537\_3.png

可以看到:是main线程中的一个数组大小是810325,就是我们定义的那个集合,里面保存了太多的对象,导致java堆内存不够用

虚拟机栈和本地方法栈

jvm规范中描述了两种异常:

  1. 线程请求的栈深度大于jvm所允许的最大深度,抛出StackOverflowError
  2. 虚拟机在扩展栈时无法申请到足够的空间,抛出OOM

StackOverflowError:

static class StackOFE{
            int i;
            public void recursive(){
                i++;
                recursive();
            }
        }

        /** * vm args -Xss128k * @param args */
        public static void main(String[] args) {
            StackOFE stackOFE = new StackOFE();
            stackOFE.recursive();
        }

运行结果:
Exception in thread “main” java.lang.StackOverflowError
at MainStackOFE.recursive(Main.java:23)atMain S t a c k O F E . r e c u r s i v e ( M a i n . j a v a : 23 ) a t M a i n StackOFE.recursive(Main.java:23)
单线程中运行,不停的递归调用方法,一个一个方法栈帧入栈,导致本线程的128k栈内存不够使用

OOM:

static class OOM{
            public void doOOM(){
                while (true){
                    new Thread(){
                        @Override
                        public void run() {
                            while (true){}
                        }
                    }.start();
                }
            }
        }

        /** * vm args -Xss2M * @param args */
        public static void main(String[] args) {
            OOM oom = new OOM();
            oom.doOOM();
        }

20191210001537\_4.png

分析:操作系统分配给jvm的栈内存是一定的,每创建一个线程消耗2m的栈内存,最终到时jvm占内存不足

本机直接内存溢出

用Unsafe对象来直接操作本机的直接内存

/** * vm args -Xmx20m -XX:MaxDirectMemorySize=10m * @param args */
        public static void main(String[] args) throws Exception {
            Field field = Unsafe.class.getDeclaredFields()[0];
            field.setAccessible(true);
            Unsafe unsafe = (Unsafe) field.get(null);
            while (true){
                unsafe.allocateMemory(1024*1024);
            }
        }

运行结果:
Exception in thread “main” java.lang.OutOfMemoryError
at sun.misc.Unsafe.allocateMemory(Native Method)
at Main.main(Main.java:26)

由directMemory引起的内存泄漏,一般heap dump文件特别小

点赞(0)
版权归原创作者所有,任何形式转载请联系作者; Java 技术驿站 >> Java内存模型和内存泄漏实战(一)

相关推荐