JVM--运行时数据区

一、运行时数据区概述

(一)JVM运行时数据区规范

  JVM运行时数据区按照线程占用的情况可以分为两类:线程共享和线程独享。线程共享的包括方法区和堆,线程独享的包括栈、本地方法栈和程序计数器。

        

   JVM运行时数据区各个模块的使用顺序:在JVM启动的时候,为方法区和堆分配初始内存并设置最大内存(一般建议初始内存和最大内存保持一致,这样可以减少扩容带来的性能损耗),在程序执行的时候,会用到所有的模块。

  对于Hotspot运行时数据区,在JDK1.8之前,方法区的实现称为永久代,在JDK1.8及以后,方法区的实现称为元空间。

  方法区是JVM虚拟机规范中的定义,而永久代和元空间是Hotspot的实现。

(二)分配JVM内存空间

  分配堆的大小:

    -Xms(初始堆大小)

    -Xmx(最大堆大小)

  分配方法区大小:

    -XX:PermSize(初始永久代大小)

    -XX:MaxPermSize(最大永久代大小)

    -XX:MetaspaceSize(初始元空间大小;达到该值就会触发垃圾收集进行类型卸载,同时GC会对该值进行调整,如果释放了大量的空间,则GC会向下调整该值的大小,如果释放了很少的空间,则GC会调大该值,但是不会超过最大值)

    -XX:MaxMetaspaceSize(最大元空间大小)

    --XX:MinMetaspaceFreeRatio:在GC后,最小的元空间剩余空间容量占比,减少为了分配空间所导致的垃圾回收。

    -XX:MaxMetaspaceFreeRatio:在GC后,最大的元空间剩余容器占比,减少为了释放空间所导致的垃圾回收。

    -Xss:  为JVM启动的每个线程分配内存大小,jdk1.4是256k,1.5及以后是1M

二、方法区

(一)方法区存储的内容

  方法区中存储的内容包括类型信息、方法信息、字段信息、code区信息、方法表、指向当前类和父类的引用、类常量池等信息。

  类型信息:类型的全限定名、父类的全限定名、接口的全限定名、类型标识(类或接口)、访问权限

  方法信息:方法修饰符(访问标识)、方法名、(方法的返回类型、方法参数个数、类型、集合)、方法字节码、操作数栈和该方法在栈帧中的局部变量大小、异常表。

  字段信息:字段修饰符(类似访问标识)、字段的类型、字段的名称

  code区:code区存储的是方法执行对应的字节码指令

  方法表:为了提高访问效率,JVM为会为每个非抽象类创建一个数组,数组的每个元素都是实例可能被调用的方法,包括从父类继承过来的方法。这个表在抽象类中是没有的。

  类变量:静态变量

  指向类加载器的引用:每一个被JVM加载的类,都保存着对应的类加载器的引用,随时会被用到。

  指向Class实例的引用:类加载过程中,虚拟机会创建该类的实例,方法区中必须保存实例的引用,通过Class.forName(String name)来获取该类实例的引用,然后创建该类的对象。

  常量池:class文件中除了存储类、方法、字段、接口等信息外,还存储了常量池。常量池用于存储编译器生成的各种字面量和符号引用,这部分内容在类被加载后,进入运行时常量池。

(二)方法区、永久代、元空间区别

  方法区是JVM规范中定义的区域,是抽象出来的概念,永久代是Hotspot在1.8之前对于方法区的实现方案,元空间是Hotspot在1.8及以后对于方法区的实现方案。

  永久代占用内存区域是JVM进程所占用的内存区域,因此永久代的大小受整个JVM内存大小的限制;而元空间占用的内存是物理机的内存,因此元空间的内存大小受整个物理机内存大小的限制。

  永久代存储的信息基本上就是上述的信息,但是元空间只存储了类的元信息,而类变量和运行时常量池则移到了堆中。

  那么为什么做这种转化呢?

    1、字符串存在永久代中,容易造成性能问题和永久代内存溢出

    2、类和方法的大小比较难预估,因此不太好设置永久代的大小,如果太大,容易造成老年代内存溢出,如果太小,容易造成永久代内存溢出。

    3、永久代内存GC带来不必要的复杂度,且回收效率极低

    4、Oracle使用的JVM虚拟机是JRockit,方法区的实现是元空间,Sun公司使用的是Hotspot,方法区的实现是永久代,而Oracle公司收购的Sun公司,收购之后,准备合二为一。

(三)方法区异常演示

  类加载导致的OOM:以JDK1.8为例,设置元空间大小为16M:-XX:MetaspaceSize=16m -XX:MaxMetaspaceSize=16m

  代码如下:

package com.lcl.service;
@SpringBootTest(classes = ProjectApplication.class)
@RunWith(value = SpringRunner.class)
@Slf4j
public class LclTest { 
    @Test
    public void test2(){
        URL url = null;
        List<ClassLoader> classLoaderList = new ArrayList<ClassLoader>();try {
            url = new File("/tmp").toURI().toURL();
            URL[] urls = {url};
            while (true){
                ClassLoader loader = new URLClassLoader(urls);
                classLoaderList.add(loader);
                loader.loadClass("com.lcl.service.LclTest ");
            }
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

  执行结果:

    

   字符串OOM:

  代码:

    @Test
    public void test1(){
        String base = "String";
        List<String> list = new ArrayList<>();
        for (int i = 0; i < Integer.MAX_VALUE; i++){
            String s = base + base;
            base = s;
            list.add(s.intern());
        }
    }

  以1.8为例,执行结果:

         

(四)字符串常量池

  类信息的常量池是在编译阶段就产生的,存储在class文件中存储的都是符号引用;

  运行时常量池存储在JVM内存中,具有动态性,在类加载时,类信息的常量池会进入运行时常量池,但同时也可以在运行期将新的常量加入到运行时常量池中(例如String的intern()方法))。

  字符串常量池逻辑上属于运行时常量池的一部分,它是用来存储运行时常量池中的字符串常量的,但是它和运行时常量池的区别在于,字符串常量池是全局唯一的,而运行时常量池是每个类一个。在JDK1.6中,字符串常量池位于方法区中,在JDK1.7及以后,字符串常量池位于堆中。

  为了提高字符串的检索速度,JVM提供了一个StringTable用来存储字符串常量信息,其数据接口与HashMap类似,使用数组+链表的方式存储,里面存储了对于字符串的引用。

  在JDK1.6中,字符串常量池存储结构StringTable的数组长度为1009,在JDK1.7及以后可以使用下面参数进行设置,默认值改为99991.

-XX:StringTableSize=99991

  字符串常量池的好处:节省空间:字符串常量池是为节省内存空间而设置的一个内存区域,所有的类共享一个字符串常量池。

   对于字符串常量池的使用总结如下:

    1、单独使用双引号创建的字符串都是常量,直接存储在字符串常量池中。

    2、使用new String("ab")创建的字符串,会在字符串常量池中存储一个“ab”字符串常量,同时会在堆中开辟一个空间,将字符串常量池中常量的值(“ab”)复制到堆内存中,而最终返回的是堆中地址的引用。例如String s = new String("ab"),s指向的是堆中的地址。且如果再有一个String s1 = new String("ab"),那么s和s1也不是指向同一个地址。

    3、使用字符串常量池连接的字符串,由于JIT有方法内敛的优化,可将其直接替换为对应的值,因此也是一个字符串常量,例如String s = "a" + "b",就只会在字符串常量池中添加一个“ab”字符串,返回的s则是字符串常量池中“ab”字符串的引用。

    4、如果存在计算的情况,那么则是在运行期才创建的,也是会存入堆中的,例如String s = k + "a",那么s也是指向堆中的地址。

        /**
         * String str1 = "abc"  的步骤:
         *  1、栈中开辟一块空间存放引用str1
         *  2、String常量池开辟一块空间,存放String常量“abc”
         *  3、引用str1指向String常量“abc”
         * String str2 = new String("abc")  的步骤:
         *  1、栈中开辟一块空间存放引用str2
         *  2、堆中开辟一块空间存放一个新建的String对象“abc”
         *  3、引用str2指向堆中新建的对象“abc“
         */
        String str1 = "abc";
        System.out.println(str1 == "abc");//true; str1所指向的地址即是String常量“abc”的存放地址,因此输出为true
        String str2 = new String("abc");
        System.out.println(str1 == str2);//false; str1指向的是字符串常量池中的地址,str2指向的是堆中的地址,因此为false
        String str3 = new String("abc");
        System.out.println(str3 == str2);//false;  str2和str3指向的是堆中两个不同的地址
        String str4 = "a" + "b";
        System.out.println(str4 == "ab");//true;  两个常量相加,在JVM优化时,会使用方法内敛将其替换为”ab“,因此为true
        final String s = "a";
        String str5 = s + "b";
        System.out.println(str5 == "ab");//true;  由于s使用final修饰,并不会被修改,因此str5=a+b,也是用方法内敛优化将str5赋值为ab,因此为stru
        String s1 = "a";
        String s2 = "b";
        String str6 = s1 + s2;
        System.out.println(str6 == "ab");//false;  s1和s2都是变量且没有final修饰,因此在运行期可能发生变化,因此是通过计算而来的,将其存储在堆中,str6指向的是堆中的引用
        String str7 = "abc".substring(0, 2);
        System.out.println(str7 == "ab");//false;  同上,通过计算而来的存放在堆中,是堆中引用的地址,而”ab“是存在字符串常量池中的。
        String str8 = "abc".toUpperCase();
        System.out.println(str8 == "ABC");//false;  同上

(五)intern方法

  在上面提到,使用intern方法可以将字符串常量在运行期动态的添加到字符串常量池中,确切的说是intern可以把new出来的字符串引用添加到字符串常量池StringTable中,并返回字符串常量池中该字符串常量的引用。

  intern方法执行的步骤:先计算字符串的hashcode,通过hashcode在Stringtable查找是否存在对应的引用,如果存在,则不进行任何处理,直接返回引用;如果不存在,则将该引用放入字符串常量池Stringtable中,并返回引用。

  使用intern的好处:通过intern()方法显式的将字符串常量添加入字符串常量池,避免大量的字符串常量在堆中重复创建。在JDK1.6中,字符串常量池位于永久代中,大小受限,不建议使用intern()方法,在JDK1.7中,字符串常量池移动到了堆中,大小可控,可以重新考虑使用intern()方法,但是通过测试对比,使用intern()方法的性能耗时不容忽视,所以要慎重使用。

  案例:

        String s5 = "a";
        String s6 = "abc";
        String s7 = s5 + "bc";
        System.out.println(s6 == s7.intern());//true 虽然s7是经运算得来的,但是使用intern方法,返回的也是字符串常量池的地址
        String c = "world";
        System.out.println(c.intern() == c); //true intern方法,返回的也是字符串常量池的地址
        String d = new String("mike");
        System.out.println(d.intern() == d); //false d指向的是堆中的地址,而intern方法获取的是字符串常量池中的地址
        String e = new String("jo") + new String("hn");
        System.out.println(e.intern() == e); //true e的值为john,在字符串常量池中不存在,因此调用intern方法时,将其动态的添加进字符串常量池
        String f = new String("ja") + new String("va");
        System.out.println(f.intern() == f); //false java为关键字,在jvm启动时,已经将其添加进字符串常量池,因此调用intern后没有做任何事情,只是返回了java的字符串常量值地址,而f仍然指向堆中地址

三、Java堆

  Java堆被内存共享,在JVM虚拟机启动时创建,是虚拟机管理最大的一块内存区域。

  Java堆是垃圾回收的主要区域,而且主要采用分代回收算法,使用分带回收算法主要是为了更好、更快的回收内存。

  Java堆内存的创建和回收都是由垃圾收集器处理的。

(一)堆内存

  堆内存储内容:

    在JDK1.6及之前,Java堆中存储的是对象和数组,在JDK1.7及以后,Java堆中存储的是对象、数组、字符串常量、静态变量

  堆内存划分:

    Java堆分为新生代、老年代和永久代,其中永久代在1.8之后变更为元数据区,默认新生代和老年代的比例为1:2,其中新生代又分为Eden区和S区,Eden区和S区的比例为8:2,S区又分为S0和S1两个区域,比例为1:1。所以总体来说老年代:新生代(Eden:S0:S1)为1:2(8:1:1)。

  创建对象时内存分配的步骤:

    1、大对象直接进入老年代,大对象一般指很长的字符串或数组

    2、其余对象首先分配到Eden区,如果Eden区内存不足,则触发一次Minor GC

    3、长期存活的对象进入老年代,默认回收次数是15次。

    

   堆的内存分配方式:

    堆的内存分配方式有指针碰撞和空闲列表两种方式:

    指针碰撞:内存是连续的,年轻代使用,使用该种分配方式的垃圾回收器:Serial和ParNew收集器

    空闲列表:内存地址不连续,老年代使用,使用该种分配方式的垃圾回收器:CMS和Mark-Sweep收集器

  内存分配安全:

    在进行内存分配时,存在线程安全的问题,JVM的解决方案是通过TLAB和CAS来解决的。

    TLAB(本地线程分配缓存):为每一个线程预先分配一块内存,JVM在给线程中的对象分配内存时,先在TLAB上分配,如果内存不够,再使用CAS进行分配。

    CAS(比较和交换):CAS是乐观锁的一种实现方式,即每一次申请内存都不加锁,如果出现冲突进行重试,知道成功为止。

(二)对象内存布局及访问方式    

  对象的内存布局:

    对象在堆内存中的布局可以分为对象头、实例数据、对齐填充三个部分。

    对象头:对象头包含两部分数据,一部分是存储对象自身的运行数据,如hashcode、GC分代年龄、锁状态标识、线程持有的锁、偏向线程id、偏向时间戳等内容;另一部分是类型指针,它指向类元数据,虚拟机用这个指针来确定对象是哪个类的实例。如果对象是一个数组时,那么对象头还必须有一块用于记录数组长度的数据,因此JVM可以通过对象的元数据判断java对象的大小,但是数组不可以。

    实例数据:存储的是对象真正的信息

    对齐填充:在JVM中对象的大小必须是8字节的整数倍,对象头已经确定了是8字节的倍数,但是实例数据不一定是8个字节的倍数,因此如果最终对象头+实例数据的大小不是8字节的倍数,则需要对齐填充来对其进行填充。

  对象的访问方式:

    对象的访问方式分为句柄访问和直接指针访问。

    句柄访问:虚拟机栈中本地变量表中存储的是句柄池中句柄的指针,而句柄中有一个指向堆中对象实例数据的指针和一个指向方法区中对象类型的指针。句柄访问的优点是稳定,因为如果对象发生移动,则只需要改变句柄中指向堆中实例数据的指针即可。

    直接指针访问:虚拟机栈中本地变量表存储的是直接指向堆中对象的指针,对象中又包含实例数据和类型指针等信息。直接指针访问的优点是,访问速度快,节省了一次指针定位的开销。

    在Hotspott中,使用的是直接指针访问的方式。

         

 (三)数组内存分析

  对于数组,其在内存中的地址是连续的,变量对应的指针指向的是堆中连续空间的开始地址。

  一维数组:

    int[] arr = new int[3]:这行代码首先会将arr压入栈,然后在堆中开辟一个空间,然后将其赋上默认值,由于数组类型是int,因此被赋上默认值0

    int[] arr1 = arr:这行代码会将arr中的地址赋值给arr2,此时arr和arr2指向了同一块内存地址。

    arr[0] = 20:这行代码,将arr指针对应地址的第一个值更新为20

  二维数组:

    int[][] arr = new int[3][]:这样代码首先将arr压入栈,然后再堆中开辟一个内存空间,并附上默认值,由于是二维数组,因此其默认值为null,然后把该内存空间的地址赋值给arr

    int[0][] = new int[1]:这行代码将在对中开辟一个内存空间,然后赋上默认值(由于是int类型,默认值为0),并将该内存空间的地址赋值给一维数组的第一个数据。

        

四、虚拟机栈

(一)虚拟机栈

  1、栈帧

  虚拟机栈是线程私有的,且生命周期与线程也一样,每个java方法在执行的时候都会创建一个栈帧。

    栈帧定义:

    栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构。

    栈帧存储了局部变量表、操作数栈、动态连接和方法返回等信息,每一个方法从调用到执行完成的过程,都对应一个栈帧从入栈到出栈的过程。

        

    当前栈帧:

    一个线程中方法的调用链可能会很长,所以会有很多栈帧,只有位于JVM虚拟机栈栈顶的元素才是有效的,被称为当前栈帧,这个栈帧对应的方法称为当前方法,定义这个方法的类称为当前类。

    执行引擎运行的所有字节码指令都是针对当前栈帧的操作,如果当前方法调用了其他方法,那么被调用方法的栈帧就变为当前栈帧。

    栈帧的创建:

    调用新方法时,新的栈帧随之被创建,并且随着程序控制权转移到新方法,新的栈帧也变为当前栈帧。在方法返回时,该栈帧会返回方法的执行结果给之前的栈帧,随后虚拟机会丢弃该栈帧。

  2、局部变量表

    存储内容:

    局部变量表是一组变量值存储空间,用于存放方法参数和方法内定义的局部变量。

    一个局部变量可以存储的数据类型为:boolean、byte、char、short、int、float、reference、returnAddress,其中reference是对一个对象实例的引用。

    存储容量:

    局部变量表的容量以槽为最小的存储单位,JVM虚拟机并没有规定一个槽应该占用多少内存,但是规定了一个槽必须可以存储一个32位以内的数据类型。

    在类编译为class文件时,就在方法的code属性中的max_locals数据项中确定了该方法需要分配的最大槽数即最大容量。

    虚拟机通过索引定位到局部变量表的槽中,索引范围是0到局部变量表的最大槽数,如果槽是32位的,如果碰到64位的数据类型,则需要连续读取两个槽的数据。

  3、操作数栈

    定义及作用:

    操作数栈也可以被称为操作栈,是一个先入后出的栈,当一个方法刚刚开始执行时,其操作数栈是空的,随着方法和字节码指令的执行,会从局部变量表或对象实例的字段中赋值常量或变量到操作数栈中,在随着计算的进行将栈中元素出栈到局部变量表中或者返回给方法调用者,也就是出栈/入栈的操作。

    存储内容:

    操作数栈的每一个元素可以是任意java数据类型,32位的数据类型占一个栈容量,64位的数据类型占两个栈容量。

    存储容量:

    与局部变量表一样,其信息也在编译的时候存储在class文件的code区,其存储在code区中max_stacks属性中,在方法执行时,操作数栈的深度在任何时候都不会超过该值。

  4、动态连接

    在一个class文件中,一个方法要调用其他方法,需要将方法的符号引用替换为直接引用,而符号引用存储在方法区的运行时常量池中。

    在JVM虚拟机中,每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的符号引用,持有这个引用的目的是为了支持方法调用过程中的动态连接。

    这些符号引用有一部分会在类加载时或者第一次使用时就直接转化为直接引用,这类转化称为静态解析,另外一部分在每一次运行期间直接转换为直接引用,这部分转化称为动态连接。

  5、方法返回

    当一个方法开始执行的时候,可能有正常退出和异常退出两种情况。

    正常退出是指方法正常完成操作并推出,没有抛出任何异常,如果当前方法正常完成,则根据当前方法返回的字节码指令进行处理。该方法返回的字节码指令中有可能存在返回值,也可能不存在返回值。

    异常退出是指方法执行过程中遇到异常,并且这个异常在方法体内部没有得到处理,导致方法退出。也就是说无论是Java虚拟机抛出的异常还是代码中使用throw产生的异常,只要在本方法的异常表中没有找到对应的异常处理器,就会导致方法退出。

    无论方法采用何种方式退出,在方法退出后都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在当前栈帧中保存一些信息,用来帮它恢复其上层方法的执行状态。方法退出过程实际上等同于把当前栈帧出栈,因此退出可以执行的操作有:恢复上层方法的局部变量表和操作数栈,如果有返回值,需要将返回值压入调用者的操作数栈中,同时调整PC计数器的值以指向方法调用指令后的下一条指令。

    一般来说,方法正常退出时,调用者的PC计数器值可以作为返回地址,栈帧中可能保存此计数值,而方法异常退出时,返回地址是通过异常处理器表确定的,栈帧中一般不会保存此部分信息。

(二)栈异常

  JVM虚拟机规范中,对该区域规定了两种异常情况:

    1、如果线程请求的栈深度大于虚拟机栈所允许的最大深度,则会抛出StackOverflowError异常

    2、虚拟机栈可以动态扩展,当扩展时无法申请到足够的内存时,就会抛出OutOfMemoryError异常

    @Test
    public void testMain(){
        int i = 0;
        this.call(i);
    }

    private void call(int i) {
        i++;
        log.info("======{}", i);
        call(i);
    }

      

 五、本地方法栈

  什么是本地方法栈:

    本地方法栈和虚拟机栈类似,区别是虚拟机栈用来为虚拟机执行java服务,也就是执行字节码服务,而本地方法栈为虚拟机提供native方法服务,例如C++代码。简单地讲,一个Native方法就类似于java代码的一个接口,但是实现类是用其他与语言实现的,例如C++。

  为什么要用本地方法:

    java使用起来非常方便,但是有些层次的任务用java实现起来不容易,或者效率达不到要求。

    本地方法栈有效的扩充了jvm,例如在java并发场景中,线程的切换、等待、唤醒等操作,都是使用的本地方法与操作系统直接交互的。

  本地方法栈使用流程:

    当一个方法调用本地方法,本地方法执行后会回调虚拟机中的另一个java方法。一般情况下本地方法会有两个以上的函数,java程序调用的是第一个C语言函数,C语言的第一个函数调用C语言的第二个函数,最后由C语言的第二个函数回调虚拟机中的另一个java方法。

六、程序计数器

  程序计数器也叫PC寄存器,是一块较小的内存空间,他可以看作是当前线程所执行的字节码指令的行号指示器。字节码解释器的工作就是通过改变计数器的值来选取下一条需要执行的字节码指令,分支、循环、跳转、异常处理、线程恢复等都需要依赖这个程序计数器。

  由于java虚拟机的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时间,一个处理器都只会执行一个线程的指令,因此为了线程切换后可以恢复到正确的执行位置,每个线程都需要一个独立的程序计数器,各个线程之间互不影响,独立存储,因此程序计数器也是线程私有的。

  如果一个线程正在执行的是java方法,那么该线程的程序计数器记录的是虚拟机字节码指令的地址,如果正在执行的是一个Native方法,这个计数器的值为空。

  程序计数器是JVM中唯一没有任何OutOfMemoryError异常的区域

------------------------------------------------------------------
-----------------------------------------------------------
---------------------------------------------
朦胧的夜 留笔~~
原文地址:https://www.cnblogs.com/liconglong/p/14945668.html