JVM运行时数据区详解(超长)

时间:2020-8-24 作者:admin


一、运行时数据区的介绍

JVM运行时数据区详解(超长)

HotSpot VM的运行时数据区:
JVM运行时数据区详解(超长)

  • 不用的JVM对内存的划分和管理机制存在部分差异(主要是方法区的有无)。
  • Java虚拟机定义了若干个程序运行期间会使用到的运行时数据区,其中有一些会伴随着虚拟机启动而创建,随着虚拟机退出而销毁。另外一些则是跟线程相对应的,这些与线程对应的数据区的生命周期跟线程一致,随着线程的开始而出生,随着线程的结束而销毁。
    • 每个线程拥有独立的虚拟机栈、本地方法栈、程序计数器。
    • 多个线程共享堆、方法区(或者说每个进程共有的)。
  • 在jdk8后的方法区改名为元空间

二、Java中的线程的介绍

  • 线程是一个程序里的运行单元。JVM运行一个应用拥有多个线程并行的执行
  • 在HotSpot VM里面,每个线程与操作系统的本地线程直接映射:
    • 当一个Java线程准备好后,操作系统的本地线程也会同时创建。当这个Java线程执行终于后,本地线程也会回收。
  • 操作系统负责所有线程的安排调度到任何一个可用的CPU上。一旦被初始化成功,就会调用Java线程中的run()方法。
  • 如果线程出现异常,在没有捕获的情况下,会终止该线程。
  • Java中有两种线程,分别为守护线程和非守护线程(用户线程)
  • 守护线程是为了给非守护线程提供服务,例如GC就是一个守护线程,**当守护线程全都退出的时候,VM就会退出,也代表了程序将会终止。守护线程中产生的线程也是守护线程。**若结束的是非守护线程仅仅只是线程的销毁,VM还会在后台继续运行着。
  • 后台线程(主要的):
    • 虚拟机线程:这种线程的操作是需要JVM达到安全点才会出现。这些操作必须在不同的线程中发生的原因是他们都需要JVM达到安全点,这样堆才不会变化。这种线程的执行类型包括”stop-the-world”的垃圾收集,线程栈收集,线程挂起以及偏向锁撤销。
    • **周期任务线程:**这种线程是时间周期事件的体现(比如中断),他们一-般用于周期性。
    • **GC线程:**这种线程对在JVM里不同种类的垃圾收集行为提供了支持。
    • **编译线程:**这种线程在运行时会将字节码编译成到本地代码。
    • **信号调度线程:**这种线程接收信号并发送给JVM,在它内部通过调用适当的方法进行处理。

三、程序计数器(PC寄存器)

介绍:

JVM运行时数据区详解(超长)

  • 它是一块很小的内存空间,几乎可以忽略不记。也是运行速度最快的存储区域。
  • 在JVM规范中,每个线程都有它自己的程序计数器,是线程私有的,生命周期与线程的生命周期保持–致。
  • 任何时间一个线程都只有一个方法在执行,也就是所谓的当前方法。程序计数器会存储当前线程正在执行的Java方法的JVM指令地址。如果是在执行native方法,则是未指定值(undefned)。
  • 它是程序控制流的指示器,分支、循环、跳转、异常处理、线程恢复等基础功能都需要依赖这个计数器来完成。
  • 字节码解释器工作时就是通过改变这个计数器的值来选取下–条需要执行的字节码指令。
  • 它是唯一个在Java虚拟机规范中没有规定任何OutOtMemoryError(OOM,内存溢出异常)情况的区域。
作用:

PC寄存器用来存储指向下一条指令的地址,也即将要执行的指令代码。由执行引擎读取下一条指令。

例子:

代码:

public class PCRegisterTest {
    public static void main(String[] args) {
        int i = 1;
        int j = 2;
        int k = i + j;
        
        String s = "abc";
    }
}

对生产的class文件反编译得到:

JVM运行时数据区详解(超长)


面试问题:
  • 为什么用pc寄存器记录当前线程的执行地址(pc寄存器的作用):因为在执行多线程的时候CPU会切换别的线程,这时候需要用到pc寄存器来存放当前线程(A线程)执行到哪一步,当CPU再次切换回A线程时,根据pc寄存器的值来明确下一条该执行什么字节码指令。
  • pc寄存器为什么是每个线程私有的:为了能够准确地记录各个线程正在执行的当前字节码指令地址,最好的办法自然是为每一个线程都分配一个PC寄存器,这样一来各个线程之间便可以进行独立计算,从而不会出现相互干扰的情况。
补充知识点:
  • CPU时间片:CPU给各个线程分配的时间段称为时间片。
  • 多线程:(单个CPU)在宏观上认为多个线程并行操作,在微观上是每个线程抢占式的方式来获取CPU时间片。

四、虚拟机栈

介绍:

JVM运行时数据区详解(超长)

  • 虚拟机栈描述的是Java方法执行的内存模型。

  • 虚拟机栈是线程私有的,生命周期与线程相同。

  • 如果线程请求的栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常;如果虚拟机栈可以动态扩展(当前大部分的Java虚拟机都可动态扩展,只不过Java虚拟机规范中也允许固定长度的虚拟机栈)也可以设置固定不变,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

  • 特点

    • 访问速度快,仅次于程序计数器
    • 只有两个操作:每个方法执行都会伴随着入栈,执行完方法后会出栈操作
    • GC不会管理栈。
  • 配置设置:

    • 可以通过参数设置大小(栈内存等于线程分配的内存 减去Xmx(最大堆容量),再减去MaxPermSize(最大方法区容量)),IDEA中可以在VM options中设置。

    • -Xms 为jvm启动时分配的内存,比如-Xms200m,表示分配200M

    • -Xmx 为jvm运行过程中分配的最大内存,比如-Xms500m,表示jvm进程最多只能够占用500M内存

    • -Xss 为jvm启动的每个线程分配的内存大小,默认JDK1.4中是256K,JDK1.5+中是1M

栈帧:

介绍:

  • 每个方法在执行的同时都会创建一个栈帧(Stack Frame)。用于存储 **局部变量表、操作数栈、动态链接、方法出口 **等信息。
  • 栈帧是一个内存区域,用于存储局部变量表、操作数栈、动态链接、方法出口等信息。
  • 在一条活动线程中,一个时间点上,只会有一个活动的栈帧。即只有当前正在执行的方法的栈帧(栈项栈帧)是有效的,这个栈帧被称为当前栈帧(Current Frame),与当前栈帧相对应的方法就是当前方法(Current Method),定义这个方法的类就是当前类(Current Class)。
  • 执行引擎运行的所有字节码指令只针对当前栈帧进行操作。
  • 如果在该方法中调用了其他方法,对应的新的栈帧会被创建出来,放在栈的顶端,成为新的当前帧。
  • 不同线程中所包含的栈帧是不允许存在相互引用的,即不可能在一个栈帧之中引用另外一个线程的栈帧。
  • 如果当前方法调用了其他方法,方法返回之际,当前栈帧会传回此方法的执行结果给前一个栈帧,接着虚拟机会丢弃当前栈帧,使得前一个栈帧重新成为当前栈帧。
  • Java方法有两种返回函数的方式,一种是正常的函数返回,使用return指令,另外一种是抛出异常。不管使用哪种方式,都会导致栈帧被弹出。

局部变量表:

  • 局部变量表也被称之为局部变量数组或本地变量表定义为一个数字数组,主要用于存储方法参数和定义在方法体内的局部变量这些数据类型包括各类基本数据类型、对象引用(reference) ,以及returnAddress类型。

  • 由于局部变量表是建立在线程的栈上,是线程的私有数据,因此不存在数据安全问题。

  • 局部变量表所需的容量大小是在编译期确定下来的,并保存在方法的Code属性的maximum local variables数据项中。在方法运行期间是不会改变局部变量表的大小的。

  • 方法嵌套调用的次数由栈的大小决定。一般来说,栈越大,方法嵌套调用次数越多。对一个函数而言,它的参数和局部变量越多,使得局部变量表膨胀,它的栈帧就越大,以满足方法调用所需传递的信息增大的需求。进而函数调用就会占用更多的栈空间,导致其嵌套调用次数就会减少。

  • 局部变量表中的变量只在当前方法调用中有效。在方法执行时,虚拟机通过使用局部变量表完成参数值到参数变量列表的传递过程。当方法调用结束后,随着方法栈帧的销毁,局部变量表也会随之销毁。

  • 例如:LocalVariablesTest类有以下成员方法

public static void main(String[] args) {
    LocalVariablesTest test = new LocalVariablesTest();
    int num = 10;
    test.test1();
}

 public void test1() {
        Date date = new Date();
        String name1 = "atguigu.com";
        test2(date, name1);
        System.out.println(date + name1);
}

反编译后:

Length表示变量的作用域长度。例如Start 8 Length 8 表示从第bipush 10(常量池中的第10号) 开始声明,到第16(8+8)的偏移坐标开始失效,也就是return指令

JVM运行时数据区详解(超长)

局部变量表以及其中的Slot介绍:
  • 局部变量表的容量以变量槽(Variable Slot,下称Slot)为最小单位,虚拟机规范中并没

    有明确指明一个Slot应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能存放一

    个boolean、byte、char、short、int、float、reference或returnAddress类型的数据

  • Java中占用32位以内的数据类型有boolean、byte、char、short、int、float、reference和returnAddress 8种类型。

  • 对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot空间。64位的数据类型只有long和double两种。

  • 第0位索引的Slot默认是用于传递方 法所属对象实例的引用,在方法中可以通过关键字”this”来访问到这个隐含的参数。其余参数则按照参数表顺序排列。

  • Slot位是可以复用的。从而达到节省资源的目的:

    • 例如代码:

      public void test4() {
          {
              int a = 2;
              a = 0;
          }
          //a的作用域在大括号里,当结束括号时,a的生命周期结束,从slot取出,此时之前a所占的slot还是存在的可以复用,变量c占用该slot,详细看下边。
          int c = 3;
      }
      
    • 反编译后:JVM运行时数据区详解(超长)

补充知识点:
  1. 关于成员变量和局部变量:
    • 成员变量包括类变量(属于类的变量,使用static修饰的)与实例变量(未被static修饰的)
      • 类变量:在之前提到过的类加载子系统的Linking的准备阶段,给类变量和静态代码块进行初始化
      • 实例变量:当该类的对象被创建时,会在堆空间中分配空间,并完成默认赋值。
    • 局部变量:在方法中的变量,一定要先赋值才能够使用。
  2. 若要在栈帧中进行JVM调优,优先考虑的方向是局部变量表。
  3. 局部变量表中的变量也是最重要的垃圾回收根节点,只要被局部变量表中的直接或者间接引用的对象都不会被回收。

操作数栈:

介绍:
  • 操作数栈是以数组的结构实现的,同时只保留了关于栈的操作(进栈、出栈)以及栈的特性(先进后出),不能像数组一样根据索引访问,所以操作数栈的长度是固定的(反编译后的max_stack属性代表操作数栈的长度)。
  • 操作数栈用于保存计算过程的中间结果,同时作为计算过程变量的临时存储空间。
  • 操作数栈是JVM执行引擎的一个工作区,当该方法被执行的时候,就会创建一个栈帧,操作数栈也就随着栈帧的创建而被创建出来。
  • 如果A方法中调用B方法,且B方法有返回值,则会在A方法对应的栈帧中的操作数栈压入B方法的返回值。
  • 操作数栈中的元素的数据类型与字节码的序列要严格匹配(32bit的数据占用一个栈单位的深度,64bit的数据占用两个栈单位的深度),在编译期首次进行验证(来确顶该操作数栈的深度),在类加载过程的类验证阶段的数据分析阶段为再次进行验证。
  • Java虚拟机的解释引擎是基于操作数栈的执行引擎
例子:
  • 代码:

    public int getOperandStackReturn() {
        int a = 1;
        int b = 2;
        return a + b;
    }
    
    public void operandStackTest() {
        int a = getOperandStackReturn();
        int b = 3;
        int c = a + b;
    }
    

    执行operandStackTest()方法对应的操作:

JVM运行时数据区详解(超长)


动态连接:

介绍:

JVM运行时数据区详解(超长)

  • 每一个栈帧内部都包含一个指向运行时常量池中该栈帧所属方法的引用。包含这个引用的目的就是为了支持当前方法的代码能够实现动态连接(Dynamic Linking) 。
  • invokedynamic指令在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。
  • invokedynamic指令在Java源文件被编译到字节码文件中时,所有的变量和方法引用都作为符号引用(Symbolic Reference)保存在class文件的常量池里。当一个方法调用了另外的其他方法时,就是通过常量池中指向方法的符号引用来表示的,动态连接的作用就是为了将这些符号引用转换为调用方法的直接引用。

例如:

public class DynamicLinkingTest {

    int num = 10;

    public void methodA(){
        System.out.println("methodA()....");
    }

    public void methodB(){
        System.out.println("methodB()....");

        methodA();

        num++;
    }

}

methodB()对应的字节码文件解析:

JVM运行时数据区详解(超长)


方法返回地址 :

介绍:
  • 当一个方法开始执行后,只有两种方式可以退出这个方法。第一种是执行到方法返回的字节码指令,这时候会将返回值传递给上层的方法调用者(有返回值的情况下),属于正常完成出口。第二种是当方法执行的时候抛出异常,且这个异常没有得到处理,会导致方法退出,这时候不会给上层调用者产生返回值,这种退出的方式属于异常完成出口。
  • 无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能 继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的PC计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈 帧中一般不会保存这部分信息。
  • 方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。

附加信息:

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与 调试相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。


方法的调用:

解析:
  • 方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,这类方法的调用称为解析(Resolution)。

  • 在Java虚拟机里,一共提供了5条调用方法的字节码指令。分别是:

    • invokestatic(调用静态方法)

    • invokespecial(调用实例构造器<init>方法、私有方法和父类方法)

    • invokevirtual(调用所有的虚方法)

    • invokeinterface(调用接口方法,会在运行时再确定一个实现此接口的对象)

    • invokedynamic(先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方

      法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令

      的分派逻辑是由用户所设定的引导方法决定的。)

  • 调用invokestaticinvokespecial指令的方法,都可以在解析阶段确定唯一的调用版本,也就是静态方法、私有方法、实例构造器、父类方法这4类。它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final方法)。

  • 虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。所以final方法是一种非虚方法

分派:
  • 静态分派

    • 所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。

    • 静态分派的典型应用是方法重载overlord

      • public class StaticDispatch {
            static abstract class Human {
            }
        
            static class Man extends Human {
            }
        
            static class Woman extends Human {
            }
        
            public void sayHello(Human guy) {
                System.out.println("hello,guy!"");
            }
        
            public void sayHello(Man guy) {
                System.out.println("hello,gentleman!");
            }
        
            public void sayHello(Woman guy) {
                System.out.println("hello,lady!");
            }
        
            public static void main(String[] args) {
                Human man = new Man();
                Human woman = new Woman();
                StaticDispatch sr = new StaticDispatch();
                sr.sayHello(man);  //hello,guy!
                sr.sayHello(woman);  //hello,guy!
            }
        }
        

        这里的Human就是静态类型,它对Man或者Woman进行了”包装”,在编译期时我们就可以得知的类型,而它的实际类型(这里指的是Man或者Woman)只有在运行期才能知道。当然,如果我们运用强转换会改变它的静态类型。

        sr.sayHello((Man)man); //hello,gentleman!
        sr.sayHello((Woman)woman); //hello,lady!
        
    • 静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。

    • 很多情况下这个重载版本并不是”唯一的”,往往只能确定一个”更加合适的”版本。

      • 还是用上面的例子,将public void sayHello(Man guy) 方法注释掉

        public void sayHello(Human guy) {
              System.out.println("hello,guy!");
          }
        
        /*public void sayHello(Man guy) {
              System.out.println("hello,gentleman!");
          }*/
        
         public static void main(String[] args) {
                Human man = new Man();
                Human woman = new Woman();
                StaticDispatch sr = new StaticDispatch();
                sr.sayHello((Man)man);  //hello,guy!
                sr.sayHello((Woman)woman);  //hello,lady!
            }
        

        最后”sr.sayHello((Man)man); “的输出是hello,guy!,原因是它会去寻找”更加合适的”版本。

        比如一个字符’a’,调用的方法有char、int、long、float、double等多个重载版本,若注释掉char重载版本,它会去调用参数为int类型的重载版本(因为‘a’的Unicode数值为十进制数字97),若再去掉int类型的版本,会去调用long版本,按照char->int->long->float->double的顺序转型进行匹配。

  • 动态分派

    • 动态分派的典型应用是方法重写override

    • package com.atguigu.java2;
      
      public class DynamicDispatch {
          static abstract class Human {
              protected abstract void sayHello();
          }
      
          static class Man extends Human {
              @Override
              protected void sayHello() {
                  System.out.println("man say hello");
              }
          }
      
          static class Woman extends Human {
              @Override
              protected void sayHello() {
                  System.out.println("woman say hello");
              }
          }
      
          public static void main(String[] args) {
              Human man = new Man();
              Human woman = new Woman();
              man.sayHello();  //man say hello
              woman.sayHello();  //woman say hello
          }
      }
      

      man.sayHello();和woman.sayHello();对应的字节码指令是”invokevirtual #常量池引用”,然而invokevirtual指令的运行时解析过程大致分为以下几个步骤:

      1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。
      2. )如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
      3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
      4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

      简单的说就是子类中若有签名匹配的则直接选择子类的方法,若没有则向父类查找。

  • 单分派和多分派

    • 根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择

    • public class Dispatch {
          static class QQ {
          }
      
          static class _360
          {
          }
      
          public static class Father {
              public void hardChoice(QQ arg) {
                  System.out.println("father choose qq");
              }
      
              public void hardChoice(_360 arg) {
                  System.out.println("father choose 360");
              }
          }
      
          public static class Son extends Father {
              public void hardChoice(QQ arg) {
                  System.out.println("son choose qq");
              }
      
              public void hardChoice(_360 arg) {
                  System.out.println("son choose 360");
              }
          }
      
          public static void main(String[] args) {
              Father father = new Father();
              Father son = new Son();
              father.hardChoice(new _360()); //father choose 360
              son.hardChoice(new QQ());  //son choose qq
          }
      }
      

      (深入java虚拟机p285)

      编译阶段编译器的选择过程(是静态分派的过程):要进行两次宗量的选择,一是静态类型是Father还是Son,二是方法参数是QQ还是360。这次选择结果的 最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向 Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

      再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行”son.hardChoice(new QQ())“这句代码时,更准确地说,是在执行这句代码所对应的invokevirtual指令时,由于 编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的 参数”QQ”到底是”腾讯QQ”还是”奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的 选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

      根据上述论证的结果,我们可以总结一句:今天(直至还未发布的Java 1.8)的Java语言 是一门静态多分派、动态单分派的语言。强调”今天的Java语言”是因为这个结论未必会恒久 不变,C#在3.0及之前的版本与Java一样是动态单分派语言,但在C#4.0中引入了dynamic类型 后,就可以很方便地实现动态多分派。

五、本地方法栈

  • 本地方法栈(Native Method Stack)与虚拟机栈所发挥的作用是非常相似的,它们之间的区别不过是虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为虚拟机使用到的Native方法服务。在虚拟机规范中对本地方法栈中方法使用的语言、使用方式与数据结构并没有强制规定,因此具体的虚拟机可以自由实现它。甚至有的虚拟机(譬如 Sun HotSpot虚拟机)直接就把本地方法栈和虚拟机栈合二为一。与虚拟机栈一样,本地方法栈区域也会抛出StackOverflowError和OutOfMemoryError异常。
  • 本地方法栈主要管理本地方法(被native修饰的方法,与abstract修饰符不能同时存在),Java的本地方法主要是c/c++方法。

六、堆空间

介绍:JVM运行时数据区详解(超长)

  • 一个JVM实例中(或者说一个进程中)只存在一个堆,堆是管理Java内存的核心区域。
  • 堆伴随着JVM的启动而被创建,在启动的时候堆的大小已经确定下来了(可参数调节堆的大小后续JVM调优中提到)。是JVM管理的最大一块内存空间。
  • 堆处于物理空间上不连续,但在逻辑上被视为连续的(物理内存通过映射表来实现逻辑内存连续)
  • 堆空间并不是所有数据都是线程共享的,其中有一部分为线程私有的:缓冲区(TLAB)。
  • 几乎所有的类对象或者数组都在分配在堆空间上
  • 数组和对象可能永远不会存在栈上,因为栈帧中保存引用,这个引用指向对象或数组在堆中的位置。[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hVjBVvpy-1598076198651)(C:\Users\Administrator\Desktop\java_blog\jvm+juc\images\20200601205528.png)]
  • 堆中的对象不会随着方法结束立即被移除,只有在发生GC后才有可能被移除
    • 发生GC的时候,必须暂停其他所有的工作线程,直到它收集结束。这段时间称作(Stop The World),若每次调用方法结束后就发生GC,会导致用户体验极差。
  • 堆空间是GC执行垃圾回收的重点区域。

内存结构:

JVM运行时数据区详解(超长)

  • Java 7以及之前内存逻辑上分为三部分:新生代+老年代+永久区(Perm Space,属于方法区,在堆的外部)
  • Java 8以及之后(目前到Java 12)的内存逻辑上分为三部分:新生代+老年代+元空间(Meta Space,属于方法区,与堆不相连)
  • 由于GC的复制收集算法(后面会详解)将新生代又划分为三个部分:1个Eden区(伊甸区)+2个Survivor区,默认Eden和Survivor的大小比例是 8:1(后面提到参数设置)。

堆空间大小的设置:

  • 堆空间的大小可以根据”-Xmx”和”-Xms”来设置:
    • “-Xms”用于表示JVM启动时堆区的初始内存,等价于-XX:InitialHeapSize。-X:jvm的运行参数,mx: memory start
    • “-Xmx”用于表示堆空间允许申请的最大内存,等价于-XX:MaxheapSize
  • 默认的堆空间大小:
    • 初始内存:电脑物理内存大小的 1/64
    • 最大内存:电脑物理内存大小的 1/4
  • 若堆空间内存大小超出能申请的最大内存时,会抛出OutOfMemoryError异常
  • 通常都设置”-Xmx”和”-Xms”相同:
    • 为了能够让java垃圾回收机制清理完堆区后不需要重新分隔计算堆区内存的大小,从而提高性能。
  • 可以通过”jps”+“jstat”工具或者”-XX:+PrintGCDetails”参数来查看堆空间的参数。

新生代与老年代:

  • 新生代与老年代默认的初始内存比例是1:2。可以通过-NewRatio参数设置它们的比例,一般情况下不会去修改。

  • 新生代中的Eden和Survivor的大小比例默认是 8:1(官方文档指出是8:1,但实际中可能因为内存的自动适应等原因,比例为6:1),当然我们可以通过”-XX:SurvivorRatio”参数进行更改(实际操作中有些细节需要注意)。

    • 未设置任何参数:JVM运行时数据区详解(超长)

    • 只设置参数-XX:SurvivorRatio=8:[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

    • 发现只设置参数-XX:SurvivorRatio=8还不一定能起作用(但是修改了Survivor区中可以申请的上限),所以这里猜想可能跟内存的分配有关,再增加”-Xms600m -Xmx600m”参数,结果确实达到8:1,如下图:JVM运行时数据区详解(超长)

  • 几乎所有的Java对象都是在Eden中被new出来的(也有可能会在老年代中创建,后面提起空间分配担保再解释)。

  • 绝大部分对象的销毁都处于新生代中(根据IBM公司研究大概在80%左右,也就是为什么Eden区跟Survivor区默认为8:1的原因)。

  • 可以用参数”-Xmn”设置新生代的最大内存(比较少用到)。

对象的分类与回收:

1.注:当Eden区空间满了,这时候再创建对象的时候才会发生GC,并不是对象一死亡就发生GC。
2. JVM运行时数据区详解(超长)

3.JVM运行时数据区详解(超长)

以上为对象比较普遍的分类与回收,当然下面是比较详细的情况:

JVM运行时数据区详解(超长)


内存分配担保:

  • 优先分配到Eden区
  • 大对象直接分配到老年代
  • 长期存活对象(超过一定的”年龄”)分配到老年代
  • 动态对象年龄判定:
    • 如果在Survivor空间中相同年龄所有对象大小的总 和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代
  • 内存分配担保判定:
    • 在发生Minor GC之前,虚拟机会先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那么Minor GC可以确保是安全的。如果不成立,则虚拟机会查看HandlePromotionFailure设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试着进行 一次Minor GC,尽管这次Minor GC是有风险的;如果小于,或者HandlePromotionFailure设置不允许冒险,那这时也要改为进行一次Full GC。
    • 在JDK 7以及之后,”HandlePromotionFailure”参数失效,也就是默认允许担保,并且不能修改。

缓存区(TLAB):

JVM运行时数据区详解(超长)

  • JVM为每个线程在堆空间中都分配了一份缓存区在Eden区,这个缓存区是线程私有的
  • 多线程同时分配内存的时候,使用TLAB可以避免一系列安全问题。
  • 目前所有的OpenJDK衍生出来的JVM都支持TLAB的划分。
  • JVM默认设置”-XX:UserTLAB”参数为开启,也就是在内存分配中,TLAB是分配的首选,如果TLAB分配内存失败,JVM就是尝试通过加锁的机制确保数据操作的原子性,从而从Eden区直接分配内存,这种分配策略称:快速分配策略
  • TLAB可以通过参数”-XX:TLABWasteTargetPercent”参数来设置百分比,默认是占用Eden区的1%。
  • TLAB中有三个指针:start、top、end,作用让其他线程不能申请start与end指针中间的空间,top指针表示已经占用的空间大小

一些参数设置:

  • -XX:PrintFlagsInitial:虚拟机默认参数设置情况
  • -XX:PrintFlagsFinal:虚拟机最终参数设置情况(目前)
  • -Xms:初始堆空间大小,默认内存的1/64
  • -Xmx:最大堆空间内存,默认内存的1/4
  • -Xmn:设置新生代的大小
  • -XX:NEWRatio:设置新生代跟老年代的比例,默认是1:2
  • -XX:SurvivorRatio:设置Eden区跟Survivor区(幸存者区)的比例,默认8:1
  • -XX:MaxTenuringThreshold:设置新生代到老年代所需要年龄(阈值)
  • -XX:PrintGCDetails:控制台打印详细的GC日志
  • -XX:HandlePromotionFailure:是否运行内存分配担保

逃逸分析:

  • 为了减少Java程序中的同步负载(多线程数据安全问题)和堆内存分配压力,引入了逃逸分析技术,之后根据逃逸分析技术可以实现优化。
  • 逃逸分析技术是分析对象动态作用域
  • 判断对象是否发生逃逸:
    • 当一个对象在方法中被定义后,它的作用域只在该方法中,则没有发生逃逸。
    • 当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。
    • 甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。
  • JDK7以及新版本的Server Compiler中,默认打开逃逸分析,参数”-XX:+DoEscapeAnalysis”

例如:

public static StringBuffer createStringBuffer(String str){
    StringBuffer sb = new StringBuffer();
    sb.append(str);
    return sb;
}
public static void test(){
    StringBuffer stringBuffer = createStringBuffer("hello");
}

test在createStringBuffer()方法的外部,却能引用到createStringBuffer()方法中的对象sb,则认为对象发生逃逸。

public static StringBuffer createStringBuffer(String str){
    StringBuffer sb = new StringBuffer();
    sb.append(str);
    return sb.toString();
}
public static void test(){
    String string = createStringBuffer("hello");
}

这个情况就没有发生逃逸:因为createStringBuffer()中返回时调用toString(),在方法外部就无法引用对象sb了。


根据逃逸分析对代码的优化:

  • 栈上分配:

    • 如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的内存空间就可以随栈帧出栈而销毁。这样那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多。
  • 同步消除:

    • 如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉。
  • 标量替换:

    • 标量(Scalar):是指一个数据已经无法再分解成更小 的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。

    • 把一个Java对象拆散, 根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。

    • 如果逃 逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替,就可以将这些成员变量分配在栈上。

      public class ScalarReplace {
          public static class Position {
              public int x;
              public int y;
              
              public Position(int x, int y) {
                  this.x = x;
                  this.y = y;
              }
          }
          
          /**
          *若开启标量替换,test1方法会转变成test2方法,将操作数x,y存入栈中
          */
          
          public static void test1() {
              Position p = new Position(1,1);
          }
          
          public static void test2() {
              int x = 1;
              int y = 1;
          }
      }
      

总结:

  • 关于逃逸分析的参数:
    • “-XX:+DoEscapeAnalysis” 手动开启逃逸分析
    • “-XX:+PrintEscapeAnalysis” 来查看分析结果
    • “-XX:+EliminateAllocations” 开启标量替换
    • “+XX:+EliminateLocks” 开启同步消除
    • “-XX:+PrintEliminateAllocations” 查看标量 的替换情况
  • 逃逸分析并不成熟,由于HotSpot虚拟机目前的实现方式导致栈上分配实现起来比较复杂,因此在HotSpot中暂时还没有做”栈上分配“,所以目前还是认为所有对象实例都是在堆上进行分配的。

七、方法区

介绍:

JVM运行时数据区详解(超长)

  • 在逻辑上方法区属于堆的一部分,但在Hotspot JVM中明确区分方法区和堆,并且还有个别名叫Non-Heap(非堆)。
  • 方法区是线程共享的内存区域。
  • 方法区的生命周期:
    • 随着JVM启动的时候被创建,随着JVM关闭被释放
  • 方法区的大小可以通过参数设置固定大小或者可扩展,其实际的物理内存可以是不连续的。
  • 方法区内部主要保存类信息,若存放太多类信息会导致方法区溢出,从而抛出OOM错误。
  • 对于hotspot而言,JDK7的方法区称为永久代。JDK8开始,方法区称为元空间。两者的区别在于:
    • 永久代使用的是虚拟内存,通过参数”-XX:MaxPermSize”设置上限
    • 元空间使用的是本地内存(物理内存)

大小的设置:

  • JDK7及之前:
    • 通过”-XX:PermSize”设置永久代的初始内存大小,默认为20.75M
    • 通过”-XX:MaxPermSize”设置永久代允许的最大内存,32位机器默认是64M,64位机器默认是82M。
  • JDK8及之后:
    • 通过”-XX:MetaspaceSize”设置元空间的初始内存大小,默认为21M(Windows下),这个值也叫做”高水位线”,当元空间的内存使用达到这个”高水位线”时,会发生Full GC并且卸载没用的类,然后会根据GC后释放的内存的大小来判断是否要提高还是降低元空间的容量。
    • 通过”-XX:MaxMetaspaceSize”设置元空间允许的最大内存,默认值是-1,代表该机器的内存大小。

内部存储:

  • 内部存储类型信息、常量、静态变量、JIT编译后的代码缓存等

1.类型信息:

  • 该类的全限定类名
  • 该类的直接父类的全限定类名(如果是interface或者是java.lang.Object类是没有父类的)
  • 该类的修饰符(public、abstract,final等等)
  • 该类的直接接口的有序列表(例如实现A和B接口,A跟B要有序的放入列表)

2.域(Field)信息:

  • 域名称
  • 域类型
  • 域修饰符

3.方法(Method)信息:

  • 方法名称
  • 方法返回类型
  • 方法参数的个数和类型(有序)
  • 方法的修饰符(public、private、protected、static、final、synchronized、native、abstract)
  • 方法的字节码(bytecode)、操作数栈、局部变量表及大小
  • 异常表(abstra和native方法除外)

常量池与运行时常量池:

  • 常量池表:

    • 是class文件的一部分,用于存放编译器生成的各种字面量与符号引用,这部分内容将会在类加载后存放到方法区的运行时常量池中。

    • 包括了关于类、方法、接口等中的常量,也包括字符串常量和符号引用

      public class MethodInnerStrucTest extends Object implements Comparable<String>,Serializable {
      
          public int num = 10;
          private static String str = "测试方法的内部结构";
          
          public static int test2(int cal){
              int result = 0;
              try {
                  int value = 30;
                  result = value / cal;
              } catch (Exception e) {
                  e.printStackTrace();
              }
              return result;
          }
      }
      

      字节码文件:JVM运行时数据区详解(超长)

JVM运行时数据区详解(超长)

JVM运行时数据区详解(超长)

补充:

- 全局常量:用static final修饰符修饰的,该常量在编译的时候就被分配了(也就是还没有被类加载器所加载就已经完成了分配)
  • 运行时常量池:

    • 当加载类和接口到虚拟机后,就会在方法区创建对应的运行时常量池。
    • 池中的数据是通过索引访问。
    • 具有动态性:并不是只有在编译器中生成的常量才能进入运行时常量池,运行期间也可能会向池中加入新的常量,例如String的intern()方法。

迭代演进:

  • JDK1.6以及之前:
    • 有永久代,静态变量存放在永久代中。
  • JDK1.7:
    • 有永久代,字符串常量池以及静态变量移动到堆中
  • JDK1.8及以后:
    • 永久代被删除,类型信息、字段、方法、常量保存在本地内存的元空间,但字符串常量、静态变量还在堆中。

元空间替换永久代的优势:

  • 由于永久代用的是虚拟内存,而一个应用的内存空间大小我们是很难确定的。若永久代中设置虚拟内存过小,就会引起频繁的Full GC,Full GC所用的STW时间是相对较长的,若动态的加载类过多还有可能会引起永久代中的OOM。

字符串常量池移动到堆中的优势:

  • 在JDK6及之前,字符串常量池是在方法区中,只有对方法区进行回收才能同时回收”无用”的字符串,而方法区的回收”成绩”比较难以令人满意(《深入Java虚拟机》第2.2.5章节提到)。

静态变量的位置:

  • 不管在JDK版本还是是否用static修饰的变量的实例都是存放在堆空间中的,换句话说就是new出来的对象都是存放在堆当中的。
  • 静态对象的引用根据不同版本JDK有不同版本的位置:
    • JDK 6及之前存放在方法区中。
    • JDK 7及之后存放在堆中。

方法区的垃圾回收:

常量的回收:

  • 方法区中的常量有两大类,分别是字面量和符号引用。
    • 字面量有:文本字符串、被final修饰的常量值等。
    • 符号引用有:类和接口的全限定类名、字段的名称和描述符、方法的名称和描述符。
  • 回收机制:
    • 只要是常量池中的常量没有被任何地方引用,就可以被回收。

类的回收:

  • 回收机制要满足以下三个条件(只是允许回收,不是满足就触发回收):
    1. 该类的所有实例都已经被回收了,也就是Java堆中不存在该类以及该类的派生子类的实例。
    2. 加载该类的类加载器已经被回收。
    3. java.lang.Class对象没有在任何地方引用,无法在任何地方通过反射访问到该类的方法。
  • 在大量使用反射、动态代理、CGLIB等字节码框架,动态生成JSP以及OSGi这类频繁的自定义类加载器的场景中,通常都需要Java虚拟机具备类卸载能力来保证不会对方法区造成过大压力。

注:方法区的回收效果并不理想,尤其是对类型的回收。所以Java虚拟机规范中并没有强制规定对方法区进行回收,但有时候确实有必要对方法区进行回收。

直接内存(扩展知识点):

  • 直接内存处于Java堆外,向系统申请的内存。

  • 来源于NIO(JDK1.4后引入,New-IO),通过堆中的DirectByteBuffer操作Native内存。

  • 直接内存大小可以通过参数”MaxDirectMemorySize”设置,若不指定则跟-Xmx参数一致(默认为-1,无上限)。

  • 直接内存也会发生OOM。

  • 不受JVM管理,回收成本高。

  • 访问直接内存速度会快于Java堆,即读写性能高,若读写频率高的话建议使用直接内存。

    • JVM运行时数据区详解(超长)因为访问物理磁盘需要从用户态转换为内核态再去访问,这期间所需要耗费的时间较多。
    • JVM运行时数据区详解(超长)借用物理内存映射文件来访问,无需转成内核态。

2

八、总结

JVM运行时数据区详解(超长)

声明:本文内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎进行举报,并提供相关证据,工作人员会在5个工作日内联系你,一经查实,本站将立刻删除涉嫌侵权内容。