Java虚拟机之内存分配详解

文章目录

  • 永久代
  • 虚拟机栈
  • 栈帧组成之局部变量表

java虚拟机所管理的内存中最大的一块。几乎所有的对象都是存储在堆中,并且堆是完全自动化管理的,通过垃圾回收机制,垃圾对象会被自动清理,而不需要显式的释放。
Java虚拟机之内存分配详解_第1张图片
Java虚拟机之内存分配详解_第2张图片
因为GC垃圾回收采用分代收集算法,因此堆空间的结构一般也为上述结构(这里只描述常见的情况):分为新生代和老年代两块区域。刚new出来的对象实例是存在新生代中的,当Minor GC回收次数达到某个条件(根据使用的垃圾收集器而定),而该对象若未被回收,则会将该对象实例移到老年代。

新生代分为Eden:FromSpace:ToSpace,默认比例8:1:1

  • eden(伊甸区)

    大多数情况下,new出来的对象首先分配在eden区。当eden区没有足够空间进行分配时,虚拟机将发起一次MinorGC。GC回收一次后若还活着,则移到s0

  • s0、s1区

    同一时刻,所有对象只会存在s0或s1区域,根据GC回收的复制算法(后面的文章会讲到GC算法),当进行一次回收后,s0区域会出现分片的内存空间,不利于后续对象的存储,浪费内存空间,此时会将s0区域的对象按内存空间顺序整理后拷贝到s1空间,然后s0空间重置为空,此时的s1就变回原来的from s0区域,而s0变回原来的to s1区域

  • tenured区

    俗称的老年代区域,当老年代区域的内存满了,则会触发full GC,full GC会使程序暂停,所以要避免老年代区域的内存达到所配置的大小。对象进入老年代的情况有如下几种情况:

    • 长期存活的对象将进入老年代。虚拟机给每个对象定义了一个对象年龄计数器,如果对象在eden出生并经过第一次Minor GC后仍然存活,并且能被s0容纳的话,将被移动到s0空间,并且对象年龄设为1。对象在s0区中每熬过一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认15岁),就会被晋升到老年代。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。
    • 动态对象年龄判定。如果在s0空间中相同年龄所有对象大小的总和超出s0的空间,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄
    • 大对象直接进入老年代。大对象对虚拟机的内存分配来说就是一个坏消息(比遇到一个大对象更坏的消息就是遇到一群"朝生熄灭"的"短命大对象"),经常出现大对象容易导致内存还有不少空间时就提前出发垃圾收集以获取足够的连续空间来安放它们。

来看一段代码:

package com.jvm;

public class SimpleHeap {
  private int id;
  public SimpleHeap(int id){
    this.id = id;
  }
  public void show(){
    System.out.println("My id is "+id);
  }

  public static void main(String[] args) {
    SimpleHeap s1 = new SimpleHeap(1);
    SimpleHeap s2 = new SimpleHeap(2);
    s1.show();
    s2.show();
  }
}

该代码声明了一个类,并在main函数中创建了两个SimpleHeap实例。
此时,各对象和局部变量的存放情况如图:
Java虚拟机之内存分配详解_第3张图片
SimpleHeap实例本身分配在堆中,描述SimpleHeap类的信息存放在方法区,main函数中的s1、s2局部变量存放在java栈上,并指向堆中两个实例

永久代

永久代主要用于存储加载/缓存到内存中的class定义,包括class的 名称(name), 字段(fields), 方法(methods)和字节码(method bytecode); 以及常量池(constant pool information); 对象数组(object arrays)/类型数组(type arrays)所关联的 class, 还有 JIT 编译器优化后的class信息等。

  • java7之前,方法区位于永久代(PermGen),永久代和堆相互隔离,永久代的大小在启动JVM时可以设置一个固定值,不可变;
  • java7中,static变量从永久代移到堆中;
  • java8中,取消永久代,方法存放于元空间(Metaspace),元空间仍然与堆不相连,但与堆共享物理内存,逻辑上可认为在堆中

在JDK1.7中,当永久代内存不够的时候,会报错:java.lang.OutOfMemoryError:PermGen space
主要原因是与JVM加载的class数量有很大的关系,可以调整参数:-XX:PermSize=10M -XX:MaxPermSize=10M

在JDK1.8中,去掉了-XX:PermSize和-XX:MaxPermSize(永久代),新增了-XX:MetaspaceSize和-XX:MaxMetaspaceSize(元空间)

虚拟机栈

栈是线程所有的,创建一个线程的同时会创建一个栈,栈的生命周期是跟随线程的
Java虚拟机之内存分配详解_第4张图片
java栈与数据结构上的栈有着类似的含义,它是一块先进后出的数据结构,只支持出栈和进栈两种操作,在java栈中保存的主要内容为栈帧。每一次函数调用,都会有一个对应的栈帧被压入java栈,每一个函数调用结束,都会有一个栈帧被弹出java栈。如下图:栈帧和函数调用。
Java虚拟机之内存分配详解_第5张图片

  • 函数1对应栈帧1,函数2对应栈帧2,依次类推。
  • 函数1中调用函数2,函数2中调用函数3,函数3调用函数4。
  • 当函数1被调用时,栈帧1入栈,当函数2调用时,栈帧2入栈,当函数3被调用时,栈帧3入栈,当函数4被调用时,栈帧4入栈。
  • 当前正在执行的函数所对应的帧就是当前帧(位于栈顶),它保存着当前函数的局部变量、中间计算结果等数据。
  • 当函数返回时,栈帧从java栈中被弹出,java方法区有两种返回函数的方式,一种是正常的函数返回,使用return指令,另一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

在一个栈帧中,至少包含局部变量表、操作数栈和帧数据区几个部分。

注意

  • 由于每次函数调用都会产生对应的栈帧,从而占用一定的栈空间,因此,如果栈空间不足,那么函数调用自然无法继续进行下去。当请求的栈深度大于最大可用栈深度时,系统会抛出StackOverflowError栈溢出错误。
  • 如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可以动态扩展),如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

来看一段代码:

package com.jvm;

  public class TestStackDeep {
    private static int count =0;
    public static void recursion(){
      count ++;
      recursion();
    }

    public static void main(String[] args) {
      try{
        recursion();
      }catch(Throwable e){
        System.out.println("deep of calling ="+count);
        e.printStackTrace();
    }
  }
}

使用递归,由于递归没有出口,这段代码可能会抛出栈溢出错误,在抛出栈溢出错误时,打印最大的调用深度。

使用参数-Xss128K执行上面代码,部分结果如图:
在这里插入图片描述
可以看出,在进行大约1079次调用之后,发生了栈溢出错误,通过增大-Xss的值,可以获得更深的层次调用,尝试使用参数-Xss256K执行上述代码,可能产生如下输出,很明显,调用层次有明显的增加:
在这里插入图片描述
函数嵌套调用的层次在很大程度上由栈的大小决定,栈越大,函数支持的嵌套调用次数就越多

栈帧组成之局部变量表

局部变量表是栈帧的重要组成部分之一。它用于保存函数的参数以及局部变量,局部变量表中的变量只在当前函数调用中有效,当函数调用结束,随着函数栈帧的弹出销毁,局部变量表也会随之销毁。

由于局部变量表在栈帧之中,因此,如果函数的参数和局部变量很多,会使得局部变量表膨胀,从而每一次函数调用就会占用更多的栈空间,最终导致函数的嵌套调用次数减少。

例子1

package com.jvm;

public class TestStackDeep2 {
  private static int count = 0;
  public static void recursion(long a,long b,long c){
    long e=1,f=2,g=3,h=4,i=5,k=6,q=7,x=8,y=9,z=10;
    count++;
    recursion(a,b,c);
  }
  public static void recursion(){
    count++;
    recursion();
  }

  public static void main(String[] args) {
    try{
      recursion(0L,0L,0L);
      //recursion();
    }catch(Throwable e){
      System.out.println("deep of calling = "+count);
      e.printStackTrace();
    }
  }
}

一个recursion函数含有3个参数和10个局部变量,因此,其局部变量表含有13个变量,而第二个recursion函数不再含有任何参数和局部变量,当这两个函数被嵌套调用时,第二个recursion函数可以拥有更深的调用层次

  • 使用参数-Xss128K执行上述代码中的第一个带参recursion(long a,long b,long c)函数,输出结果为:
    在这里插入图片描述
  • 使用虚拟机参数-Xss128K执行上述代码中第二个不带参数的recursion()函数(当然需要把第一个函数注释掉),输出结果为:
    在这里插入图片描述

可以看出,在相同的栈容量下,局部变量少的函数可以支持更深的函数调用

使用jclasslib工具可以查看函数的局部变量表,如下图:
Java虚拟机之内存分配详解_第6张图片
该图显示了第一个带参数方法recursion(long a,long b,long c)的最大局部变量表的大小为26个字,因为该函数包含总共13个参数和局部变量,且都为long型,long和double在局部变量表中需要占用2个字,其他如int short byte 对象引用等占用一个字

  • 说明:字(word)指的是计算机内存中占据一个单独的内存单元编号的一组二进制串,一般32位计算机上一个字为4个字节长度。

通过jclasslib工具查看该类的Class文件中局部变量表的内容,(这里说的局部变量表和上述说的局部变量表不同,这里指Class文件的一个属性,而上述的局部变量表指java栈空间的一部分)
Java虚拟机之内存分配详解_第7张图片
可以看到,在Class文件的局部变量表中,显示了每个局部变量的作用域范围、所在槽位的索引(index列)、变量名(name列)和数据类型(J表示long型)

栈帧中局部变量表中的槽位是可以重用的,如果一个局部变量过了其作用域,那么在其作用域之后申明的新的局部变量就很有可能会复用过期局部变量的槽位,从而达到节省资源的目的。


例子2

package com.jvm;

public class TestReuse {
  public static void localvar1(){
    int a=0;
    System.out.println(a);
    int b=0;
  }
  public static void localvar2(){
    {
      int a=0;
      System.out.println(a);
    }
    int b=0;
  }
}

显示局部变量表的复用,在localvar1函数中,局部变量a和b都作用到了函数的末尾,故b无法复用a所在的位置。而在localvar2()函数中,局部变量a在大括号后面不再有效,故局部变量b可以复用a的槽位(1个字)

  • localvar1()的情况
    Java虚拟机之内存分配详解_第8张图片
    如上图显示localvar1()函数的局部变量表,该函数局部变量大小为2个字,(最大局部变量表中一般第一个局部变量槽位是this引用)第一个槽位是变量a,第二个槽位是变量b,每个变量占一个字。

  • localvar2()的情况
    Java虚拟机之内存分配详解_第9张图片
    localvar2()函数的局部变量表信息如下图,虽然和localvar1()一样,但是b复用了a的槽位,(从他们都占用同一个槽位index都是0可以看出),因此在整个函数执行中,同时存在的局部变量为1字。


例子3

局部变量表中的变量也是垃圾回收根节点,只要被局部变量表中直接或者间接引用的对象都是不会被回收的。

通过一个简单示例,展示局部变量对垃圾回收的影响:

package com.jvm;

public class LocalvarGC {
  public void localvarGc1(){
    byte[] a = new byte[6*1024*1024];//6M
    System.gc();
  }
  public void localvarGc2(){
    byte[] a = new byte[6*1024*1024];
    a = null;
    System.gc();
  } 
  public void localvarGc3(){
    {
      byte[] a = new byte[6*1024*1024];
    }
    System.gc();
  } 
  public void localvarGc4(){
    {
      byte[] a = new byte[6*1024*1024];
    }
    int c = 10;
    System.gc();
  } 
  public void localvarGc5(){
    localvarGc1();
    System.gc();
  } 
  public static void main(String[] args) {
    LocalvarGC ins = new LocalvarGC();
    ins.localvarGc1();
  }
}

每一个localvarGcN()函数都分配了一块6M的堆内存,并使用局部变量引用这块空间。

  • 在localvarGc1()中,在申请空间后,立即进行垃圾回收,很明显由于byte数组被变量a引用,因此无法回收这块空间。
  • 在localvarGc2()中,在垃圾回收前,先将变量a置为null,使得byte数组失去强引用,故垃圾回收可以顺利回收byte数组。
  • 在localvarGc3()中,在进行垃圾回收前,先使局部变量a失效,虽然变量a已经离开了作用域,但是变量a依然存在于局部变量表中,并且也指向这块byte数组,故byte数组依然无法被回收。
  • 对于localvarGc4(),在垃圾回收之前,不仅使变量a失效,更是声明了变量c,使变量c复用了变量a的字(会覆盖a所占用的内存index位置),由于变量a此时被销毁,故垃圾回收器可以顺利回收数组byte
  • 对于localvarGc5(),它首先调用了localvarGc1(),很明显,在localvarGc1()中并没有释放byte数组,但在localvarGc1()返回后,它的栈帧被销毁,自然也包含了栈帧中的所有局部变量,故byte数组失去了引用,在localvarGc5()的垃圾回收中被回收

可以使用-XX:+printGC执行上述几个函数,在输出日志里,可以看到垃圾回收前后堆的大小,进而推断出byte数组是否被回收。

下面的输出是函数localvarGc4()的运行结果:

[GC (System.gc()) 7618K->624K(94208K), 0.0015613 secs]
[Full GC (System.gc()) 624K->526K(94208K), 0.0070718 secs]

从日志中可以看出,堆空间从回收前的7618K变为回收后的624K,释放了>6M的空间,byte数组已经被回收释放

你可能感兴趣的:(Java虚拟机)