JVM

JVM体系结构

什么是JVM

JVM,即java虚拟机,英文名是Java Virtual Machine,它是运行在操作系统之上的软件,在实际的计算机上模拟计算机的各个功能的一种虚拟的计算机。JVM将字节码文件转换成操作系统能够识别的指令,从而实现了跨平台的特性。

一个java应用程序对应着一个进程,每个进程都会创建一个jvm实例。

JVM体系结构图

JVM由三个子系统组成:类加载子系统、运行时数据区、执行引擎。

 

 

 类加载器(ClassLoader)

java编译后的字节码文件是以 .class结尾的文件,存储在计算机的磁盘上,程序运行时,就需要将这些文件加载到内存,这就是类加载器的作用。

类加载器并不需要等到某个类被首次主动使用时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误。

一个类从加载进内存到卸载可以分为七个部分:加载,验证,准备,解析,初始化,使用,卸载。其中验证、准备、和解析一起被称作链接。

加载

加载是类加载机制的第一个过程,在加载阶段,虚拟机主要完成三件事:

1、通过类的全限定名来获取该类的二进制字节流(class文件)。获取的方式可以通过jar包、war包、网络中获取、JSP文件生成等方式。

2、将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构。(将静态常量池转化为运行时常量池)

3、在内存中生成一个代表这个类的java.lang.Class对象(这个对象表明了这个类是什么类型),作为方法区中这个类的各种数据的访问入口,即方法区中的静态变量可以这个Class对象访问。在JDK1.7后,Class对象是存放在堆中。

链接

链接包括三个过程:验证,准备,解析。

验证:验证被加载后的类是否有正确的结构,类数据是否会符合虚拟机的要求,确保不会危害虚拟机安全。

准备:为类的静态变量(static filed)在方法区分配内存,并赋默认初值(0值或null值)。对于final修饰的静态变量,会直接赋原值。

解析:将类的二进制数据中的符号引用换为直接引用。

  • 符号引用:用符号表示引用的目标,比如java.lang.System.out.println()代表了该类,还没有被加载。
  • 直接引用:直接指向目标的指针,该目标是已经加载过的。

初始化

初始化阶段是执行类构造器方法<client>()方法的过程,这个不同于类的构造方法。<client>()方法是由编译器自动收集类中的类变量的赋值操作和静态语句块中的语句合并而成的。虚拟机会保证<client>方法执行之前,父类的<client>方法已经执行完毕。如果一个类中没有对静态变量赋值也没有静态语句块,那么编译器可以不为这个类生成<client>()方法。在多线程情况下,JVM会保证对<client>()加锁。 

类初始化时机:只有当对类的主动使用的时候才会导致类的初始化,类的主动使用包括以下六种:

– 创建类的实例,也就是new的方式

– 访问某个类或接口的静态变量,或者对该静态变量赋值

– 调用类的静态方法

– 反射(如Class.forName(“com.shengsiyuan.Test”))

– 初始化某个类的子类,则其父类也会被初始化

– Java虚拟机启动时被标明为启动类的类(Java Test),直接使用java.exe命令来运行某个主类

类加载器的种类

JVM规范中提供了两类加载器

1、启动类加载器(Bootstrap ClassLoader):加载核心类库(\lib目录下),并不是继承ClassLoader。

2、扩展类加载器(Extension ClassLoader):可以识别用户自定义的jar包(\lib\ext)

3、应用程序类加载器(Application ClassLoader):加载当前应用的classpath的所有类。

4、自定义类加载器(User ClassLoader):继承ClassLoader可以实现自己的类加载器,这样我们就可以加载其他格式的类。

 注意:以上关系并不是继承关系

双亲委派机制

当一个类加载器收到类加载任务,会先交给其父类加载器去完成,因此最终加载任务都会传递到顶层的启动类加载器,也就是启动类加载器。如果父类加载器可以完成加载任务,就让父类加载器来进行加载,只有当父类加载器无法完成加载任务时,当前的类加载器才会尝试执行加载任务。

JVM判断两个Class对象是否是同一个类:

1、类的全限定类名必须相同。

2、加载该类的ClassLoader必须是同一个。

运行时数据区

当类被加载完成后,就该运行时数据区出场了。

 从上面的结构图我们可以看到,运行时数据区由两大部分组成:

线程共享部分

    •   方法区
    •   堆

线程独占部分

    •   虚拟机栈
    •   本地方法栈
    •   程序计数器(PC寄存器)

方法区

java虚拟机规范中的描述:Java虚拟机中有一个被所有jvm线程共享的方法区。方法区有点类似于传统编程语言中的编译代码块或者操作系统层面的代码段。它存储着每个类的构造信息,比如运行时的常量池,字段,方法数据,以及方法和构造方法的代码,包括一些在类和实例初始化和接口初始化时候使用的特殊方法。

前面我们知道类加载器会将类的二进制字节码文件加载进虚拟机,而方法区就是存储这些信息的地方。方法区的大小决定了 jvm 可以加载的类的多少。值得注意的是,方法区的大小不必是固定的,jvm可以根据应用的需要动态调整。

方法区只是一个概念,并不是一块连续的内存空间。例如在JDK1.7中,字符串常量池和静态变量就已经被移到了堆中,但逻辑上还是属于方法区。JVM可以允许用户和程序指定方法区的初始大小,最小和最大尺寸。方法区中包括以下信息:

1、类的基本信息

  • 类的全限定名。
  • 类的直接超类的全限定名(除非这个类型是java.lang.Object或者interface,它没有超类)。
  • 类是类类型还是接口类型。
  • 类型的访问修饰符(public、abstract或final的某个子集)。
  • 任何直接超接口的全限定名的有序列表。

2、运行时常量池

jvm为每个已加载的类型都维护一个常量池。常量池就是这个类型用到的常量的一个有序集合,包括字面量(字符串和基本数据类型常亮)和符号引用(类型,域和方法的符号引用)。池中的数据项象数组项一样,是通过索引访问的。因为常量池存储了一个类型所使用到的所有类型,域和方法的符号引用,所以它在java程序的动态链接中起了核心的作用。

3、字段的信息和描述

4、方法的信息和描述

5、除了常量外的所有的静态变量

6、一个到类ClassLoader的引用

7、一个到Class对象的引用

方法区中存在的异常:OOM

final修饰的静态变量的值在编译时候就确定了,而静态变量的值是在类初始化阶段才会赋值。

方法区中的垃圾回收主要有两部分内容:常量池中废弃的常量和不再使用的类型。

不再使用的类型:

  • 该类所有的实例都已经被回收。
  • 加载该类的类加载器已经被回收。
  • 该类的 Class 对象没有在任何地方被引用,在反射时会用到 Class 对象。

常量池

class常量池:当 .java文件被转译成.class文件之后的字节码中包含一系列描述信息、符号引用和字面量信息。在jvm启动时,这些信息会被加载到class常量池中,当一个类要被编译加载之前这些符号和字符串会经过JVM的加载器将其实例化成为一个常量值(Class对象的实例)存在在运行时常量区。所谓的class常量池并不会真的需要分配一个内存空间(常量池),直接从本地磁盘上加载转换也是可行的,这主要取决与JVM的版本和一些参数的配置处理。

运行时常量池:运行时常量池(Runtime Constant Pool)主要用于存放jvm在运行时所有静态量。参考"深入理解java虚拟机"一书2.2.6对其的描述:运行时常量池是方法区的一部分。class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池表(Constant Pool Tabel),用于存放编译期生成的各种字面量和符号引用,这部分内容将在类加载后进入该常量池。运行时常量池并不仅仅局限于加载类时产生常量,与class常量池的区别是可以在运行期间添加各种数据到这个区域,例如jvm会将代码中直接声明的字符串放置到常量池中,这些字符串被称为字面量。通过String::intern 也可以向常量池表添加新的字面量。

字符串常量池、包装类的常量池参照 Java常量池理解与总结

变量名存储在哪?

局部变量:变量名存储在虚拟机栈的局部变量表中。

成员变量:变量名随对象一起存储在堆中。

静态变量:变量名随 Class 对象一起存储在堆上(逻辑上属于方法区)。

常量:变量名存储在常量池中。

一个进程对应一个jvm实例。一个jvm实例中有一个Runtime实例(Runtime实例对应的就是运行时数据区),里面包含了运行时数据区的各个部分(堆区、方法区、虚拟机栈、程序计数器、本地方法栈)。每个JVM实例都有自己的一个堆区。

堆区是jvm管理的最大的内存区域,其内存大小是可以调节的。

几乎所有的对象都存放在堆上,在我们创建的对象中,有些对象一直被使用,而有些对象使用一次之后就不再使用了,根据对象使用的时间,从JDK1.8开始,我们把堆空间分为新生代,老年代,元空间。元空间在逻辑上是属于方法区的,并且堆里面的GC并会处理元空间中的数据。

在方法结束执行之后,堆中的对象不会被立即被移除,需要等待垃圾回收器来回收。垃圾回收(GC)主要在堆区上进行。

堆和方法区一样,被线程共享。JDK1.7及以后堆里面引入了线程的缓冲区(Thread Local Allocation Buffer,TLAB),该区域是线程私有的。

堆中存在的异常:OOM。

堆内存分区

堆内存可以分为三个区域:新生代,老年代,元空间(逻辑上是方法区)。

新生代又可以分为三个区域:Eden(伊甸区),Survivor0(幸存区),Survivor1(幸存区)。

JDK1.8以后,类型信息,字段、方法信息,常量都都保存在元空间,且元空间不在虚拟机设置的内存中,使用的是本地内存(这也是元空间和永久代的本质区别)。而字符串常量池和静态变量仍在堆中。

对象分配内存的过程

堆是用来存储对象的,当我们需要创建一个对象时,JVM会按以下的步骤对其分配内存:

1、检查 Eden 是否有足够的空间创建这个对象,如果有足够的空间,就在 Eden 中创建这个对象。

2、如果 Eden 内存不足,就进行MGC(YGC,两者等价),如果进行垃圾回收后,Eden 空间足够,就在 Eden 中创建该对象。

3、如果第二步中在进行垃圾回收之后,Eden 仍然没有足够的空间(在进行MGC后,Eden 中没有存储任何对象),那么可以判断这个对象是个大对象,Eden 容纳不了这个对象,JVM就会尝试把这个对象在老年代中创建。如果老年代可以存放这个对象,那么就在老年代创建这个对象。

4、如果老年代也没有足够的空间容纳这个对象,就会进行FGC,如果在进行 FGC 垃圾回收之后,老年代仍然没有足够的空间,JVM就会报 OOM 异常。

堆上的垃圾回收

1、当新生代空间不足时,会触发 Minor GC 或者 Young GC(YGC),将之后不再使用的对象进行销毁,并且将 Eden 中仍然使用的对象转移到 survivor0(s0)或者 survivor1(s1)中(s0 和 s1 哪个是空的就放到哪个里面)。假设 s0 为空,其实在 MGC 时,会把 Eden 和 s1 中的对象都转移到 s0 中,这样 Eden 和 s1 都变为空,下次 Eden 空间不足时,就会把 Eden 和 s0 中的对象转移到 s1 中。s0 和 s1 是被动进行垃圾收集,s0 或者 s1 空间满时,并不会触发 YGC,只有当 Eden 空间不足时,才会触发 YGC。

2、对于进行 YGC 后仍然幸存的对象(存在于 survivor 中),JVM会把它们年代加1,当加到一定的阈值后(默认是15),JVM就会把它们放到老年代。

在上面JVM给一个对象分配内存的时候,是用了多次垃圾回收(GC),堆上的垃圾回收可以分为两类(只针对Oracle规范的虚拟机 HotSpot VM):部分收集(Partial GC),整堆收集(Full GC)。

部分收集(Partial GC):

1、新生代收集(Minor GC,或者 Young GC),只针对 Eden 进行垃圾回收。

2、老年代收集(Major GC,或者 Old GC),只针对老年代进行收集。目前只有 CMS GC 会有只收集老年代的行为。注意区别整堆收集。

3、混合收集(Mixed GC),收集整个新生代和部分老年代,目前只有 G1 GC 会有这种行为。

整堆收集(Full GC):收集整个堆以及方法区的垃圾。

内存分配策略(对象提升(Promotion)规则)

针对不同年龄段的对象分配规则如下:

1、优先分配到 Eden

2、大对象直接分配到老年代(尽量避免程序中出现过多的大对象)

3、长期存活的对象分配到老年代

4、动态对象年龄判断:如果 Survivor 区中相同年龄的所有对象的大小之和大于 Survivor 空间的一半,则年龄大于或等于该年龄的所有对象直接进入老年代。

快速分配策略——TLAB(Thread Local Allocation Buffer)

前面我们提到,对象基本都是在堆中创建的,但是堆区是线程共享的,为了解决线程不安全的问题,我们在用 new 创建一个对象时,会对整个堆区加锁。在很多情况下,我们创建的都是一些小对象,而且对象用了一次后就不在使用了。由于这些原因,避免频繁加锁造成资源浪费,引入了TLAB。

TLAB是 Eden 中的一小块区域,这块区域是线程私有的,即每个线程都有一个属于自己的TLAB。当多个线程同时需要分配内存时,JVM会优先在TLAB中分配,这样就避免了每次都对整个堆区进行加锁。这个策略被称作快速分配策略。

TLAB是 Eden 中很小的一块区域,大概为 Eden 的1%,如果对象需要的内存空间大于TLANB的大小,那么JVM就会对堆区加锁,在堆上分配内存。

尽管不是所有对象都能在TLAB中分配内存,但是JVM还是会TLAB作为内存分配的首选。

堆空间的参数设置

1、-Xms:设置初始堆空间的大小,默认为物理内存的1/64。

2、-Xmx:设置堆的最大空间内存,默认为物理内存的 1/4。

3、-Xmn:设置新生代的大小。

4、-XX:NewRatio:设置新生代与老年代的比例,默认值是2。

5、-XX:SurvivorRatio:设置设置 Eden 与 survivor 大小的比例,默认是8:1:1。

6、-XX:MaxTenuringThreshold=<N>:设置新生代到老年代的阈值,默认为15。

逃逸分析——所有对象都在堆上创建吗?

逃逸分析(Escape Analysis)简单来讲就是,Java Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术。

对象逃逸状态:

1、全局逃逸:即一个对象的作用范围逃出了当前方法或者当前线程,有以下几种场景:

  • 对象是一个静态变量
  • 对象是一个已经发生逃逸的对象
  • 对象作为方法的返回值

2、参数逃逸:即一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的。

3、没有逃逸:即方法中的对象没有发生逃逸。

逃逸分析优化

逃逸分析在JDK1.8及之后是默认开启的。当一个对象没有发生发生逃逸时,我们可以对代码做如下的优化:

同步消除:使用同步锁是一个非常消耗资源的过程,如果通过逃逸分析已经知道一个对象不会逃离该方法或者该线程,那么我们就可以移除该对象的同步锁从而避免加锁时的资源浪费。

例如 StringBuffer 是通过 synchronized 实现的,但 StringBuffer 对象一般只会在当前方法中使用,通过逃逸分析后进行锁消除,将大大提高程序运行效率。

StringBuffer str = new StringBuffer();

return str;  

//如果str没有逃离该方法,那么可以将其转换成String
return str.toString();

标量替换:标量是不可再分的数据,聚合量是可以再分解为其他标量的数据。基本数据类型以及对象的引用被称作标量,而对象本身是聚合量。如果一个对象没有逃离当前方法,并且我们只需要使用到对象的某些属性。那么我们可以直接在栈上创建该方法需要用到的成员变量,就没必要继续创建出整个对象。

栈上分配:如果一个对象没有逃离当前方法,并且我们需要用到这个对象的属性和方法,那么我们可以直接在栈上为这个对象分配内存,这样方法执行完毕对象就被销毁,不需要在经过GC。

程序计数器

占据一块较小的内存空间,存储指向下一条指令的地址,可以看做当前线程所执行的字节码的行号指示器。在虚拟机概念模型里,字节码解释器工作时就是通过改变这个计数器的值来选取下一条需要执行的字节码指令,分支,循环,跳转,异常处理,线程恢复等基础功能都需要依赖这个计数器来完成。

由于jvm的多线程是通过线程轮流切换并分配处理器执行时间的方式来实现的,在任何一个确定的时刻,一个处理器都只会执行一条线程中的指令。因此未来线程切换后能恢复到正确的执行位置,每条线程都需要有一个独立的程序计数器,各条线程之间计数器互不影响,独立存储,我们成这类内存区域为“线程私有”的内存。

如果线程正在执行的是一个Java方法,这个计数器记录的下一条字节码指令的地址;如果正在执行的是Native方法,这个计数器则是未指定值(undefined)。

此内存区域是唯一一个在Java的虚拟机规范中没有规定任何OutOfMemoryError异常情况的区域。

java虚拟机栈

线程私有,生命周期和线程一样,虚拟机栈描述的是Java方法执行的内存模型。当一个线程执行某个方法时,JVM就会在虚拟机栈中给这个方法创建一个栈桢(stack frame),这个栈桢存储了该方法运行时需要的信息,包括:局部变量表(Local Variables)、操作数栈(Operand Stack)、指向运行时常量池的引用(A reference to the run-time constant pool)、方法返回地址(Return Address)和附加信息。java虚拟机栈中不存在GC。

局部变量表:方法中定义的局部变量以及方法的参数存放在这张表中,局部变量表中的变量不可直接使用,如需要使用的话,必须通过相关指令将其加载至操作数栈中作为操作数使用。实例方法和构造函数会将 this 也放入局部变量表,并且在索引为0的位置。在之前类加载时我们知道静态变量在准备阶段会先赋默认值,但是局部变量必须显示赋值,JVM不会对其进行默认赋值,如果不进行显示赋值就无法使用。

操作数栈:以压栈和出栈的方式操作局部变量。操作数栈和局部变量表的大小在编译后就确定了,之后不会再改变。不过操作数栈大小虽然确定了,但是编译后操作数栈里面是空的,没有存放内容。

动态链接:每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。例如只有在程序运行的时候才能确定某变量的类型,多态的实现。

方法返回地址:方法执行完后返回的到什么位置。当一个方法开始执行后,只有两种方式可以退出,一种是遇到方法返回的字节码指令;一种是遇见异常,并且这个异常没有在方法体内得到处理。

java虚拟机规范允许虚拟机栈的大小既可以是动态的,也可以是固定不变的(通过-Xss256k设置大小)。java虚拟机规范对虚拟机栈规定了两种异常:

1、StackOverflowError异常。如果虚拟机栈的大小设置为固定不变,当线程请求分配的栈容量超过java虚拟机栈的容量上限时就会出现该异常。

2、OutOfMemoryError异常。如果虚拟机的大小设置为动态可扩展的,当线程尝试扩展的时候无法继续申请足够的内存,或者在创建新的线程时没有足够的内存创建虚拟机栈就会出现该异常。

下面我们可以通过一个简单的例子来看一下虚拟机栈在线程执行方法得时候是怎么操作的。

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);  //该方法为静态变量,局部变量表中没有存储this,所以索引为0的位置存储的不是this
  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类型的数据

其中字节码的 0-4 步的相关操作如下图:(只能在操作数栈中使用局部变量表中的数据)

本地方法栈

本地方法栈是用来运行本地方法的,和虚拟机栈一样,每个方法都会创建一个栈桢,同样存在 OOM 和 StackOverflowError 异常。

参考资料

JVM类加载机制详解(一)JVM类加载过程

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

Jvm运行时数据区

Jvm方法区以及static的内存分配图

彻底弄懂java中的常量池

java方法区究竟存储了什么?

28.JVM运行时数据区之堆区概述

simpleGq的专栏

对象都是在堆上分配的吗?

面试问我 Java 逃逸分析,瞬间被秒杀了

posted @ 2020-07-07 10:38  路半_知风  阅读(218)  评论(0编辑  收藏  举报