JVM运行时数据区(Run-TimeDataAreas)及内存结构

运行时数据区:

  在类加载阶段的第2,3步可以涉及有运行时数据,堆,方法区等名词。( 2.将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。3.在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口 )

  说白了就是类文件被类装载器装载进来之后,类中的内容(比如变量,常量,方法,对象等)这些数据得要有个去处,也就是要存储起来,存储的位置肯定是在JVM中有对应的空间。

  oracle官网介绍:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.5  。Java虚拟机定义了在程序执行期间使用的各种运行时数据区域。其中一些数据区域是在Java虚拟机启动时创建的,只有在Java虚拟机退出时才会销毁。其他数据区域是每个线程。每个线程的数据区域在线程创建时创建,在线程退出时销毁。关于运行时数据区可以用以下图形来表示:

Method Area(方法区):

  方法区域是在虚拟机启动时创建的。虽然方法区域在逻辑上是堆的一部分,但简单的实现可能选择不进行垃圾收集或压缩。此规范并不强制要求方法区域的位置或用于管理已编译代码的策略。方法区域可以是固定大小的,也可以根据计算的需要进行扩展,如果不需要更大的方法区域,则可以收缩。方法区域的内存不需要是连续的。方法区是各个线程共享的内存区域,在虚拟机启动时创建。用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。虽然Java虚拟机规范把方法区描述为堆的一个逻辑部分,但是它却又一个别名叫做Non-Heap(非堆),目的是与Java堆区分开来。Java虚拟机实现可以为程序员或用户提供对方法区域初始大小的控制,在可变大小方法区域的情况下,还可以提供对最大和最小方法区域大小的控制。以下是与方法区域相关的异常情况:如果方法区域中的内存不能满足分配请求,则Java虚拟机抛出

OutOfMemoryError。

  此时回过头思考一下类加载阶段的第二步:将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构,就是将类信息、常量、静态变量、即时编译器编译后的代码等数据存放到方法区。Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息就是常量池,用于存放编译时期生成的各种字面量和符号引用,这部分内容将在类加载后进入方法区的运行时常量池中存放。值得一提的是方法区在JDK 8中就是Metaspace,在JDK6或7中就是Perm Space。

Heap(堆):

  Java堆是Java虚拟机所管理内存中最大的一块,在虚拟机启动时创建,被所有线程共享。Java对象实例以及数组都在堆上分配。对象的堆存储由自动存储管理系统(称为垃圾收集器)回收;对象从不显式释放。Java虚拟机假设没有特定类型的自动存储管理系统,可以根据实现者的系统需求选择存储管理技术。堆的大小可以是固定的,也可以根据计算的需要进行扩展,如果不需要更大的堆,则可以收缩。堆的内存不需要是连续的。Java虚拟机实现可以为程序员或用户提供对堆初始大小的控制,如果可以动态扩展或收缩堆,还可以控制堆的最大和最小大小。

  此时回看类加载阶段的第3步:在Java堆中生成一个代表这个类的java.lang.Class对象,作为对方法区中这些数据的访问入口,就是将这个对象分配到堆区。

Run-Time Constant Pool(运行时常量池):

  运行时常量池是类文件中constant_pool表的每个类或每个接口的运行时表示。它包含几种类型的常量,从编译时已知的数值常量到必须在运行时解析的方法和字段引用。运行时常量池的功能类似于传统编程语言的符号表,尽管它包含的数据范围比典型的符号表更广。每个运行时常量池都是从Java虚拟机的方法区域中分配

。类或接口的运行时常量池是在Java虚拟机创建类或接口时构造的。在创建类或接口时,如果构建运行时常量池所需的内存超过了Java虚拟机的方法区域所能提供的内存,则Java虚拟机将抛出OutOfMemoryError。

The pc Register(程序计数器):

  程序计数器占用的内存空间很小,由于Java虚拟机的多线程是通过线程轮流切换,并分配处理器执行时间的方式来实现的,在任意时刻,一个处理器只会执行一条线程中的指令。因此,为了线程切换后能够恢复到正确的执行位置,每条线程需要有一个独立的程序计数器(线程私有)。如果线程正在执行Java方法,则计数器记录的是正在执行的虚拟机字节码指令的地址;如果正在执行的是Native方法,则这个计数器为空。

Native Method Stacks(本地方法栈):

  如果当前线程执行的方法是Native类型的,这些方法就会在本地方法栈中执行。

Java Virtual Machine Stacks(虚拟机栈):

  经过上面的分析,类加载机制的装载过程已经完成,后续的链接,初始化也会相应的生效。每个java方法在执行时,会创建一个“栈帧(stack frame)”,栈帧的结构分为“局部变量表、操作数栈、动态链接、方法出口”几个部分。虚拟机栈是一个线程执行的区域,保存着一个线程中方法的调用状态。换句话说,一个Java线程的运行状态,由一个虚拟机栈来保存,所以虚拟机栈肯定是线程私有的,独有的,随着线程的创建而创建。每一个被线程执行的方法,为该栈中的栈帧,即每个方法对应一个栈帧。调用一个方法,就会向栈中压入一个栈帧;一个方法调用完成,就会把该栈帧从栈中弹出。图示如下:

  每个栈帧中包括局部变量表(Local Variables)、操作数栈(Operand Stack)、指向运行时常量池的引用(A reference to the run-time constant pool)、方法返回地址(Return Address)和附加信息。

  1. 局部变量表:方法中定义的局部变量以及方法的参数存放在这张表中,局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。
  2. 操作数栈:以压栈和出栈的方式存储操作数的。
  3. 动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。例如只有在程序运行的时候才能确定某变量的类型。
  4. 方法返回地址:执行到那了。当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。

  oracle官网的描述:https://docs.oracle.com/javase/specs/jvms/se8/html/jvms-2.html#jvms-2.6 .其结构如下图:

   下面我们可以通过一个简单的例子来看一下虚拟机栈在线程执行方法得时候是怎么操作的。例如下面的方法及字节码指令,可以通过javap -v XX.class 查看。javap -c XX.class  >XX.txt输出到文件。

class Person{
    private String name="wuzz";
    private int age;
    private final double salary=100;
    private static String address;
    private final static String hobby="Programming";
    public void say(){
        System.out.println("person say...");
     }
    public static int calc(int op1,int op2){
        op1=3;
        int result=op1+op2;
        return result;
    }
    public static void order(){
   
     }
    public static void main(String[] args){
        calc(1,2);
        order();
    }
}    
//抽取calc这个方法进行举例
public static int calc(int, int);
  Code:
   0: iconst_3   //将int类型常量3压入[操作数栈]
   1: istore_0   //将int类型值存入[局部变量0]
   2: iload_0    //从[局部变量0]中装载int类型值入栈
   3: iload_1    //从[局部变量1]中装载int类型值入栈
   4: iadd     //将栈顶元素弹出栈,执行int类型的加法,结果入栈
   5: istore_2   //将栈顶int类型值保存到[局部变量2]中
   6: iload_2    //从[局部变量2]中装载int类型值入栈
   7: ireturn    //从方法中返回int类型的数据

  可以看到字节码指令分为了7步。至于具体每一步做了什么操作我们可以通过官网提供的表格进行查询 https://docs.oracle.com/javase/specs/jvms/se8/html/index.html .

  其中字节码的 0-4 步的相关操作如下图:

  1. 如果在栈帧中有一个变量,类型为引用类型,比如Object obj=new Object(),这时候就是典型的栈中元素指向堆中的对象。
  2. 方法区中会存放静态变量,常量等数据。如果是下面这种情况,private static Object obj=new Object(); 就是典型的方法区中元素指向堆中的对象。
  3. 堆指向方法区:方法区中会包含类的信息,堆中会有对象,那么对象是哪个类创建的,一个对象怎么知道它是由哪个类创建的,这些信息存储在Java对象的内存布局具体信息里面。

  Java对象内存布局:

  一个Java对象在内存中包括3个部分:对象头、实例数据和对齐填充

JVM 内存模型:

  一块是非堆区,JVM用永久代(PermanetGeneration)来存放方法区,(在JDK的HotSpot虚拟机中,可以认为方法区就是永久代,但是在其他类型的虚拟机中,没有永久代的概念)。一块是堆区。堆区分为两大块,一个是Old区(老年代),一个是Young区(新生代)。Young区分为两大块,一个是Survivor(S0+S1),一块是Eden区。 Eden:S0:S1=8:1:1S0和S1一样大,也可以叫From和To。图示如下:

  根据之前对于Heap的介绍可以知道,一般对象和数组的创建会在堆中分配内存空间,关键是堆中有这么多区域,那一个对象的创建到底在哪个区域呢?

  一般情况下,新创建的对象都会被分配到Eden区,一些特殊的大的对象会直接分配到Old区。比如有对象A,B,C等创建在Eden区,但是Eden区的内存空间肯定有限,比如有100M,假如已经使用了100M或者达到一个设定的临界值,这时候就需要对Eden内存空间进行清理,即垃圾收集(Garbage Collect),这样的GC我们称之为Minor GC,Minor GC指得是Young区的GC。经过GC之后,有些对象就会被清理掉,有些对象可能还存活着,对于存活着的对象需要将其复制到Survivor区,然后再清空Eden区中的这些对象。

Survivor区详解:

  由图解可以看出,Survivor区分为两块S0和S1,也可以叫做From和To。在同一个时间点上,S0和S1只能有一个区有数据,另外一个是空的。接着上面的GC来说,比如一开始只有Eden区和From中有对象,To中是空的。此时进行一次GC操作,From区中对象的年龄就会+1,我们知道Eden区中所有存活的对象会被复制到To区,From区中还能存活的对象会有两个去处。若对象年龄达到之前设置好的年龄阈值,此时对象会被移动到Old区, 如果Eden区和From区 没有达到阈值的对象会被复制到To区。 此时Eden区和From区已经被清空(被GC的对象肯定没了,没有被GC的对象都有了各自的去处)。这时候From和To交换角色,之前的From变成了To,之前的To变成了From。也就是说无论如何都要保证名为To的Survivor区域是空的。Minor GC会一直重复这样的过程,直到To区被填满,然后会将所有对象复制到老年代中。

Old区详解:

  从上面的分析可以看出,一般Old区都是年龄比较大的对象,或者相对超过了某个阈值的对象。在Old区也会有GC的操作,Old区的GC我们称作为Major GC 。

Java对象的一辈子:

  我是一个普通的Java对象,我出生在Eden区,在Eden区我还看到和我长的很像的小兄弟,我们在Eden区中玩了挺长时间。有一天Eden区中的人实在是太多了,我就被迫去了Survivor区的“From”区,自从去了Survivor区,我就开始漂了,有时候在Survivor的“From”区,有时候在Survivor的“To”区,居无定所。直到我18岁的时候,爸爸说我成人了,该去社会上闯闯了。于是我就去了年老代那边,年老代里,人很多,并且年龄都挺大的,我在这里也认识了很多人。在年老代里,我生活了20年(每次GC加一岁),然后被回收。下图展现了一个对象的分配过程。

 

为什么需要Survivor区?只有Eden不行吗?减少Full GC

  如果没有Survivor,Eden区每进行一次Minor GC ,并且没有年龄限制的话, 存活的对象就会被送到老年代。这样一来,老年代很快被填满,触发Major GC(因为Major GC一般伴随着Minor GC,也可以看做触发了Full GC)。老年代的内存空间远大于新生代,进行一次Full GC消耗的时间比Minor GC长得多。执行时间长有什么坏处?频发的Full GC消耗的时间很长,会影响大型程序的执行和响应速度。可能你会说,那就对老年代的空间进行增加或者较少咯。假如增加老年代空间,更多存活对象才能填满老年代。虽然降低Full GC频率,但是随着老年代空间加大,一旦发生FullGC,执行所需要的时间更长。假如减少老年代空间,虽然Full GC所需时间减少,但是老年代很快被存活对象填满,Full GC频率增加。所以Survivor的存在意义,就是减少被送到老年代的对象,进而减少Full GC的发生,Survivor的预筛选保证,只有经历16次(默认第16次就去老年代)Minor GC还能在新生代中存活的对象,才会被送到老年代。

为什么需要两个Survivor区?去碎片化(就是整理一下,排排坐)

  最大的好处就是解决了碎片化。也就是说为什么一个Survivor区不行?第一部分中,我们知道了必须设置Survivor区。假设现在只有一个Survivor区,我们来模拟一下流程:刚刚新建的对象在Eden中,一旦Eden满了,触发一次Minor GC,Eden中的存活对象就会被移动到Survivor区。这样继续循环下去,下一次Eden满了的时候,问题来了,此时进行Minor GC,Eden和Survivor各有一些存活对象,如果此时把Eden区的存活对象硬放到Survivor区,很明显这两部分对象所占有的内存是不连续的,也就导致了内存碎片化。永远有一个Survivor space是空的,另一个非空的Survivor space无碎片。

新生代中Eden:S1:S2为什么是8:1:1?

  GC是统计学测算出当内存使用超过98%以上时,内存就应该被minor gc时回收一次。但是实际应用中,我们不能较真的只给 他们留下2%,换句话说当内存使用达到98%时才GC 就有点晚了,应该是多一些预留10%内存空间,这预留下来的空间我们称为S区(有两个s区  s1 和  s0),S区是用来存储新生代GC后存活下来的对象,大多数的对象都是朝生夕死,生命周期短(大多是web应用,比如一个订单下好了,就好了)。而我们知道新生代GC算法使用的是复制回收算法。所以我们实际GC发生是在,新生代内存使用达到90%时开始进行,复制存活的对象到S1区,要知道GC结束后在S1区活下来的对象,需要放回给S0区,也就是对调(对调是指,两个S区位置互换,意味着再一次minor gc 时的区域 是eden 加上一次存活的对象放入的S区),既然能对调,其实就是两个区域一般大。这也是为什么会再有个10%的S0区域出来。这样比例就是8:1:1了 ,这里的eden区(80%) 和其中的一个  S区(10%) 合起来共占据90%,GC就是清理的他们,始终保持着其中一个  S  区是空留的,保证GC的时候复制存活的对象有个存储的地方。

使用jvisualvm查看内存分配及内存溢出实战:

  在${JAVA_HOME}/bin 目录下找到 jvisualvm.exe 双击运行即可,这里需要安装一个插件 com-sun-tools-visualvm-modules-visualgc.nbm 。下载地址 https://visualvm.github.io/pluginscenters.html--->选择对应版本链接--->Tools--->Visual GC。

  堆内存溢出:

  随便创建一个 springboot 项目,设置好参数比如-Xmx20M -Xms20M,如果本身项目里信息比较多,不能设置太小,会导致启动失败。然后加个接口(Person类随便加两个属性即可,或者随便一个对象):

@RestController
public class HeapController {
  List<Person> list=new ArrayList<Person>();
  @GetMapping("/heap")
  public String heap() throws Exception{
    while(true){
      list.add(new Person());
      Thread.sleep(1);
   }
 }
}

   然后打开jvisualvm进行查看如下,访问上面这个接口:

   能非常直观的感受到 Eden 区一次一次的进行GC,然后S0与S1 两个区域来回对调,导致old区的对象啊越来越多最后导致对内存溢出:

  方法区内存溢出:

  从上面的介绍我们知道方法区主要存储类信息、常量、静态变量、即时编译器编译后的代码等数据。那么我们就模拟添加类的元数据信息,不断的创建区添加。需要添加asm工具进行模拟。asm依赖和Class代码:

<dependency>
  <groupId>asm</groupId>
  <artifactId>asm</artifactId>
  <version>3.3.1</version>
</dependency>
public class MyMetaspace extends ClassLoader {   public static List<Class<?>> createClasses() {     List<Class<?>> classes = new ArrayList<Class<?>>();     for (int i = 0; i < 10000000; ++i) {       ClassWriter cw = new ClassWriter(0);       cw.visit(Opcodes.V1_1, Opcodes.ACC_PUBLIC, "Class" + i, null,           "java/lang/Object", null);       MethodVisitor mw = cw.visitMethod(Opcodes.ACC_PUBLIC, "<init>",           "()V", null, null);       mw.visitVarInsn(Opcodes.ALOAD, 0);       mw.visitMethodInsn(Opcodes.INVOKESPECIAL, "java/lang/Object",           "<init>", "()V");       mw.visitInsn(Opcodes.RETURN);       mw.visitMaxs(1, 1);       mw.visitEnd();       MyMetaspace test = new MyMetaspace();       byte[] code = cw.toByteArray();       Class<?> exampleClass = test.defineClass("Class" + i, code, 0, code.length);       classes.add(exampleClass);    }     return classes;  } }
@RestController
public class NonHeapController {   List<Class<?>> list=new ArrayList<Class<?>>();   @GetMapping("/nonheap")   public String nonheap() throws Exception{     while(true){       list.addAll(MyMetaspace.createClasses());       Thread.sleep(5);    }  } }

   设置Metaspace的大小,比如-XX:MetaspaceSize=50M -XX:MaxMetaspaceSize=50M .设置太小同样会报错哦:Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

   然后访问接口,直接就报错了:

   虚拟机栈StackOverFlow:

public class StackDemo {
  public static long count=0;
  public static void method(long i){
    System.out.println(count++);
    method(i);
 }
  public static void main(String[] args) {
    method(1);
 }
}

   通过上面这个代码进行运行,无限递归进行压栈:

 

    可以看到这里再7298的时候抛出了异常,可以通过-Xss128k:设置每个线程的堆栈大小。设置的越小 7298这个临界数也越小。

  Stack Space用来做方法的递归调用时压入Stack Frame(栈帧)。所以当递归调用太深的时候,就有可能耗尽Stack Space,爆出StackOverflow的错误。JDK 5以后每个线程堆栈大小为1M,以前每个线程堆栈大小为256K。根据应用的线程所需内存大小进行调整。在相同物理内存下,减小这个值能生成更多的线程。但是操作系统对一个进程内的线程数还是有限制的,不能无限生成,经验值在3000~5000左右。线程栈的大小是个双刃剑,如果设置过小,可能会出现栈溢出,特别是在该线程内有递归、大的循环时出现溢出的可能性更大,如果该值设置过大,就有影响到创建栈的数量,如果是多线程的应用,就会出现内存溢出的错误。

posted @ 2020-03-10 15:09  吴振照  阅读(347)  评论(0编辑  收藏