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

二、方法区

(一)方法区存储的内容

        

  主要有如下三种类型:

    1、Class

    (1)类型信息,比如Class(com.hero.User类)(类型的全限定名、父类的全限定名、接口的全限定名、类型标识(类或接口)、访问权限)

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

    (3)字段信息,比如Field(字段修饰符(类似访问标识)、字段的类型、字段的名称)

    (4)类变量(静态变量):JDK1.7之后,转移到堆中存储

    (5)方法表(方法调用的时候) 在A类的main方法中去调用B类的method1方法,是根据B类的方法表去查找合适的方法,进行调用的。(为了提高访问效率,JVM为会为每个非抽象类创建一个数组,数组的每个元素都是实例可能被调用的方法,包括从父类继承过来的方法。这个表在抽象类中是没有的。)

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

    2、运行时常量池(字符串常量池):从class中的常量池加载而来,JDK1.7之后,转移到堆中存储

    (1)字面量类型

    (2)引用类型-->内存地址

    3、JIT编译器编译之后的代码缓存

      如果需要访问方法区中类的其他信息,都必须先获得Class对象,才能取访问该Class对象关联的方法信息或者字段信息

  指向类加载器的引用:每一个被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常量池:一个class文件只有一个class常量池

      字面量:数值型(int、float、long、double)、双引号引起来的字符串值等

      符号引用:Class、Method、Field等

    运行时常量池:一个class对象有一个运行时常量池

      字面量:数值型(int、float、long、double)、双引号引起来的字符串值等

      符号引用:Class、Method、Field等

    字符串常量池:全局只有一个字符串常量池

      双引号引起来的字符串值

        

   字符串常量池如何存储数据?

    为了提高匹配速度, 即更快的查找某个字符串是否存在于常量池 Java 在设计字符串常量池的时候,还搞了一张StringTable, StringTable里面保存了字符串的引用。StringTable类似于HashTable(哈希表)。在JDK1.7+,StringTable可以通过参数指定 -XX:StringTableSize=99991

  什么是哈希表呢?

    哈希表(Hash table,也叫散列表),是根据关键码值(Key value)而直接进行访问的数据结构。也就是说,它通过把关键码值映射到表中一个位置来访问记录,以加快查找的速度。这个映射函数叫做散列函数,存放记录的数组叫做散列表。哈希表本质上是一个数组,目的 : 为了加快数据查找的速度。存在问题:hash冲突问题,一旦出现冲突,那么就会形成链表,链表的特点是增删快,但查询慢。

 字符串常量池如何查找字符串:

    根据字符串的hashcode找到对应entry,如果没有冲突,它可能只是一个entry,如何有冲突,它可能是一个entry的链表,然后Java再遍历链表,匹配引用对应的字符串,如果找到字符串,返回引用。如果找不到字符串,在使用intern()方法的时候,会将intern()方法调用者的引用放入到stringtable中。

  类信息的常量池是在编译阶段就产生的,存储在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;  同上

  总结:

    单独使用””引号创建的字符串都是常量,编译期就已经确定存储到String Pool中。使用new String(“”)创建的对象会存储到heap中,是运行期新创建的。使用只包含常量的字符串连接符如”aa”+”bb”创建的也是常量,编译期就能确定已经存储到String Pool中。使用包含变量的字符串连接如”aa”+s创建的对象是运行期才创建的,存储到heap中。

    运行期调用String的intern()方法可以向String Pool中动态添加对象。 

(五)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堆内存的创建和回收都是由垃圾收集器处理的。

(一)堆内存

  1、堆内存

    Java堆在JVM启动时创建内存区域去实现对象、数组与运行时常量的内存分配,堆内存的结构是由GC算法来决定的。

    堆内存是所有线程共用的内存空间,在传统的JVM堆内存划分中,将 Heap 内存分为年轻代(Young generation)和老年代(Old generation, 也叫 Tenured)两部分。年轻代还划分为 3 个内存池,新生代(Edenspace)和存活区(Survivor space), 在大部分 GC 算法中有 2 个存活区(S0, S1),在我们可以观察到的任何时刻,S0 和 S1 总有一个是空的,但一般较小,也不浪费多少空间。

    其实在JVM堆内存空间中还有个非堆,非堆本质上还是堆内存,其和堆最大的区别是,堆中存放的主要是代码里创建的对象,归GC管理,而非堆存储的是主要是元数据和JVM在内存运行时所使用的的其他数据,例如我们定义的各种类在JVM层面相关的描述数据,还包括常量池中的常量、方法区等,这些一般不归GC管理。

    非堆里面划分为三个内存池:

      Metaspace, 以前叫持久代(永久代, Permanentgeneration), Java8 换了个名字叫 Metaspace。

      CCS, Compressed Class Space, 存放 class 信息的,和 Metaspace 有交叉。

      Code Cache存放 JIT 编译器编译后的本地机器代码、缓存,特别是我们开启压缩对象的引用地址时,JVM就会把压缩指针存放在非堆区,因为默认情况下引用地址都是64位的,如果将其压缩到32位,就会节省一半的这种堆地址存储所占的空间,这样就可以节约大量的空间。

    堆内存、非堆内存、JVM作为一个进程自身使用的内存、堆外内存这几部分统一组成了堆的内存结构。

        

  2、堆内存划分

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

    堆内存为什么会存在新生代和老年代?

      分代收集理论:当前商业虚拟机的垃圾收集器,大多数都遵循了“分代收集”(Generational Collection)的理论进行设计,分代收集名为理论,实质是一套符合大多数程序运行实际情况的经验法则,它建立在两个分代假说之上:

        弱分代假说(Weak Generational Hypothesis):绝大多数对象都是朝生夕灭的。

        强分代假说(Strong Generational Hypothesis):熬过越多次垃圾收集过程的对象就越难以消亡。

      这两个分代假说共同奠定了多款常用的垃圾收集器的一致的设计原则:收集器应该将Java堆划分出不同的区域,然后将回收对象依据其年龄(年龄即对象熬过垃圾收集过程的次数)分配到不同的区域之中存储。如果一个区域中大多数对象都是朝生夕灭,难以熬过垃圾收集过程的话,那么把它们集中放在一起,每次回收时只关注如何保留少量存活而不是去标记那些大量将要被回收的对象,就能以较低代价回收到大量的空间;如果剩下的都是难以消亡的对象,那把它们集中放在一块,虚拟机便可以使用较低的频率来回收这个区域。这就同时兼顾了垃圾收集的时间开销和内存的空间有效利用。

(二)内存模型变迁

  1、JDKJ1.7

    Young 年轻区 :主要保存年轻对象,分为三部分,Eden区、两个Survivor区。

    Tenured 年老区 :主要保存年长对象,当对象在Young复制转移一定的次数后,对象就会被转移到Tenured区。

    Perm 永久区 :主要保存class、method、filed对象,这部份的空间一般不会溢出,除非一次性加载了很多的类,不过在涉及到热部署的应用服务器的时候,有时候会遇到OOM :PermGen space 的错误。

    Virtual区: 最大内存和初始内存的差值,就是Virtual

  2、JDK1.8

    由2部分组成,新生代(Eden + 2*Survivor ) + 年老代(OldGen )。JDK1.8中变化最大是,的Perm永久区用Metaspace进行了替换。

    注意:Metaspace所占用的内存空间不是在虚拟机内部,而是在本地内存空间中。区别于JDK1.7

  3、JDK1.9

    取消新生代、老年代的物理划分将堆划分为若干个区域(Region),这些区域中包含了有逻辑上的新生代、老年代区域

(三)内存信息案例

package com.hero.jvm.memory; 
/**
    * -Xms100m -Xmx100m 
*/ 
public class HeapDemo { 
    public static void main(String[] args) { 
        System.out.println("======start========="); 
        try {
            Thread.sleep(1000000L); 
        } catch (InterruptedException e) { 
            e.printStackTrace(); 
        }
        System.out.println("========end========="); 
    } 
}

  

C:\develop\java\jdk1.6.0_45\bin\javac HeapDemo.java 
C:\develop\java\jdk1.6.0_45\bin\java -Xms100m -Xmx100m HeapDemo 
C:\develop\java\jdk1.6.0_45\bin\jmap -heap 3612

 

                

C:\develop\java\jdk1.7.0_80\bin\javac HeapDemo.java 
C:\develop\java\jdk1.7.0_80\bin\java -Xms100m -Xmx100m HeapDemo 
C:\develop\java\jdk1.7.0_80\bin\jmap -heap 10420

                

C:\develop\java\jdk1.8.0_251\bin\javac HeapDemo.java 
C:\develop\java\jdk1.8.0_251\bin\java -Xms100m -Xmx100m HeapDemo 
C:\develop\java\jdk1.8.0_251\bin\jmap -heap 18276

                 

C:\develop\java\jdk-11.0.7\bin\javac HeapDemo.java 
C:\develop\java\jdk-11.0.7\bin\java -Xms100m -Xmx100m HeapDemo 
C:\develop\java\jdk-11.0.7\bin\jhsdb jmap --heap --pid 19380

                

四、虚拟机栈

(一)虚拟机栈

  每启动一个线程,JVM就会在栈空间分配对应的线程栈,比如 1MB的空间(-Xss1m),线程栈也叫做Java方法栈,如果使用了JNI方法,则会分配一个单独的本地方法栈。线程执行过程中,一般会有多个方法组成调用栈,每执行到一个方法,就会创建对应的栈针。

  栈针是一个逻辑上的概念,具体的大小在一个方法编写完成后基本上就已经确定。每个栈针的结构包括返回值、局部变量表、操作数栈、Class/Method指针。

  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异常的区域

七、直接内存

  直接内存并不是虚拟机运行时数据区的一部分,也不是Java 虚拟机规范中定义的内存区域。在JDK1.4 中,新加入了NIO(New Input/Output)类,引入了一种基于通道(Channel)与缓冲区(Buffer)的I/O 方式,它可以使用native 函数库直接分配堆外内存,然后通过一个存储在Java堆中的DirectByteBuffer 对象作为这块内存的引用进行操作。这样能在一些场景中显著提高性能,因为避免了在Java堆和Native堆中来回复制数据。

  本机直接内存的分配不会受到Java 堆大小的限制,受到本机总内存大小限制,配置虚拟机参数时,不要忽略直接内存,防止出现OutOfMemoryError异常。

  直接内存(堆外内存)与堆内存比较:

    直接内存申请空间耗费更高的性能,当频繁申请到一定量时尤为明显

    直接内存IO读写的性能要优于普通的堆内存,在多次读写操作的情况下差异明显

public class ByteBufferCompare {
    public static void main(String[] args) {
        //allocateCompare(); //分配比较
        operateCompare(); //读写比较
    }


    /**
     * 直接内存 和 堆内存的 分配空间比较
     * 结论: 在数据量提升时,直接内存相比非直接内的申请,有很严重的性能问题
     */
    public static void allocateCompare() {
        int time = 1000 * 10000;
        //操作次数,1千万
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            //ByteBuffer.allocate(int capacity) 分配一个新的字节缓冲区。
            ByteBuffer buffer = ByteBuffer.allocate(2); //非直接内存分配申请
        }
        long et = System.currentTimeMillis();
        System.out.println("在进行" + time + "次分配操作时,堆内存 分配耗时:" + (et - st) + "ms");
        long st_heap = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            //ByteBuffer.allocateDirect(int capacity) 分配新的直接字节缓冲区。
            ByteBuffer buffer = ByteBuffer.allocateDirect(2); //直接内存分配申 请
        }
        long et_direct = System.currentTimeMillis();
        System.out.println("在进行" + time + "次分配操作时,直接内存 分配耗时:" + (et_direct - st_heap) + "ms");
    }


    /**
     * 直接内存 和 堆内存的 读写性能比较
     * 结论:直接内存在直接的IO 操作上,在频繁的读写时 会有显著的性能提升
     */
    public static void operateCompare() {
        int time = 10 * 10000 * 10000; //操作次数,10亿
        ByteBuffer buffer = ByteBuffer.allocate(2 * time);
        long st = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            // putChar(char value) 用来写入 char 值的相对 put 方法
            buffer.putChar('a');
        }
        buffer.flip();
        for (int i = 0; i < time; i++) {
            buffer.getChar();
        }
        long et = System.currentTimeMillis();
        System.out.println("在进行" + time + "次读写操作时,非直接内存读写耗时:" + (et - st) + "ms");
        ByteBuffer buffer_d = ByteBuffer.allocateDirect(2 * time);
        long st_direct = System.currentTimeMillis();
        for (int i = 0; i < time; i++) {
            // putChar(char value) 用来写入 char 值的相对 put 方法
            buffer_d.putChar('a');
        }
        buffer_d.flip();
        for (int i = 0; i < time; i++) {
            buffer_d.getChar();
        }
        long et_direct = System.currentTimeMillis();
        System.out.println("在进行" + time + "次读写操作时,直接内存读写耗时:" + (et_direct - st_direct) + "ms");
    }
}

  输出:

    在进行10000000次分配操作时,堆内存 分配耗时:82ms

    在进行10000000次分配操作时,直接内存 分配耗时:6817ms

    在进行1000000000次读写操作时,堆内存 读写耗时:1137ms

    在进行1000000000次读写操作时,直接内存 读写耗时:512ms 

  为什么会是这样?从数据流的角度,来看:

      非直接内存作用链:本地IO –>直接内存–>非直接内存–>直接内存–>本地IO

      直接内存作用链:本地IO–>直接内存–>本地IO

  直接内存的使用场景:

    有很大的数据需要存储,它的生命周期很长

    适合频繁的IO操作,例如:网络并发场景 

  

 

 

posted @ 2021-06-30 21:57  李聪龙  阅读(316)  评论(0编辑  收藏  举报