面试常问点:深刻剖析JVM的那些事

文章较长,主要讲解了JVM的整个流程,其次介绍Dalvik与JVM的区别及ARThtml

Class文件结构 -> JVM内存模型 -> 类加载器 -> 类加载过程 -> 类的引用方式 -> 内存分配策略 -> GC -> 对象的引用类型 -> 类卸载java

先前知识算法

众所周知java是一种跨平台的语言,但实际上跨平台的并非java而是JVM。数组

JVM(Java Virtual Machine)是一种虚拟机,用来将由java文件编译成的class字节码文件再编译成机器语言,供机器识别。有了JVM中间人的存在就不须要直接与操做系统打交道,且不一样的操做系统有不一样的JVM,因而就屏蔽了操做系统间的差别,从而使java成为跨平台语言。缓存

DVM又是什么?安全

Dalvik Virtual Machine简称DVM也是一种虚拟机,是专门为Android平台开发的,它与JVM是有差异的。bash

Dalvik基于寄存器,而JVM 基于栈。性能有很大的提高。基于寄存器的虚拟机对于更大的程序来讲,在它们编译的时候,花费的时间更短。微信

寄存器的概念数据结构

寄存器是中央处理器内的组成部分。寄存器是有限存贮容量的高速存贮部件,它们可用来暂存指令、数据和位址。在中央处理器的控制部件中,包含的寄存器有指令寄存器(IR)和程序计数器(PC),在中央处理器的算术及逻辑部件中,包含的寄存器有累加器(ACC)多线程

栈的概念

栈是线程独有的,保存其运行状态和局部自动变量的(因此多线程中局部变量都是相互独立的,不一样于类变量)。栈在线程开始的时候初始化(线程的Start方法,初始化分配栈),每一个线程的栈互相独立。每一个函数都有本身的栈,栈被用来在函数之间传递参数。操做系统在切换线程的时候会自动的切换栈,就是切换SS/ESP寄存器。栈空间不须要在高级语言里面显式的分配和释放。

JVM

java的使用流程:

  • 一、编写.java文件

  • 二、编译成.class

    • a、打包成.jar(Java Archive) .war(Web Archive)使用
    • b、命令行则直接使用.class

其实.jar和.war是.class文件的压缩包,其中还包含了不一样的配置文件,使用时经过类加载器取其内部的.class字节码文件加载到JVM。

Class文件结构

JVM接收的最初数据是class字节码文件,由.java文件编译产生。并非只有java语言能够编译成class文件,其余语言也是能够(Scala、Groovy)。

class文件是由8位为一组的字节为基本单位,构成的二进制文件,为何是二进制文件呢?

  • 一、机器语言为二进制,因此使用便捷;
  • 二、占用空间小,3.1415927用文本文件存储须要将各个位转成ASCII码再存储需占用9字节,二进制文件存储只需四字节;
  • 三、存储数据精确不会丢失。

结构如上图,最上方为起始位,内容包含了.java文件的信息。

类型

class文件中只有两种类型:无符号数和表

无符号数为基本类型,有:u一、u二、u四、u8,数字表明字节数。无符号数能够表明数字、索引引用、数量值,或者按照UTF-8编码构成字符串值

表则是由基本类型和表构成的类型,属于组合类型

magic
文件最初的4个字节,称为魔数,是计算机识别文件类型的依据,不一样于感官,.word .png .avi这种经过扩展名识别文件类型的方式,计算机识别多数文件是经过文件头部的魔数。

这种作法的优势在于安全性,文件的扩展名能够人为随意的修改,也许并不会形成文件的不可用(早年间的“图种”一词不知多少人有经历),也可能形成文件不可用,但文件的类型在文件建立之初就被赋予魔数的话,就能够大限度的保证文件的安全性。

version
表示此.class的版本信息,有minor_version和major_version两种类型共占了4字节。

不一样版本Java有不一样的特性,产生的class结构也会不一样,JVM经过识别版本从而肯定是否可识别此文件。JVM是向下兼容的,若是.class版本太高则不能运行(Unsupported major.minor version **)。

constant
constant_pool为常量池,用来存放常量,constant_pool_count为池中的计数。

constant_pool的索引从1开始,当指针不想引用此constant_pool时则将指针指向0,此操做简单(赋值永远比删除简单)。

常量池中有两大类常量类型:字面量和符号引用

  • 一、字面量为java中的基本数据类型
  • 二、符号引用:以一组符号来描述所引用的目标,引用的目标并不必定已经加载到内存中,在类加载过程的解析阶段JVM将常量池内的符号引用替换为直接引用。类型有:
    • 一、CONSTANT_Class_info 类和接口的全限定名(该名称在全部类型中惟一标识该类型)
    • 二、CONSTANT_Fieldref_info 字段的名称和描述符
    • 三、CONSTANT_Methodref_info 方法的名称和描述符

常量池中每一项常量都是一个表

讲解:

class A{
    int i=9;
    int b=new B();
}

class B{
}
复制代码

编译时会产生A.class和B.class,此时A.class有两个常量9和B。JVM加载A.class时,将因为常量9属于字面量即基本数据类型,直接放入常量池。

到常量B时,因为常量B不属于字面量即基本数据类型,因此此时产生一个符号引用来表明常量B。

等到A.class加载到了解析阶段,须要将符号引用改成直接引用,但找不到符号引用B的直接引用,

在使用阶段,因为A对象主动引用了B类,因此JVM经过类加载器开始加载B.class(一样的加载步骤),并建立了B对象,并将符号引用B改成B对象的直接引用。

access
access_flags即java中的类修饰符

类的身份信息
一个类要有类名,关系要有extends和implement。java中类是单继承,因此除Object外全部类都有一个父类,而接口则能够有多实现。

this_class是这个类的全限定名
super_class是这个类父类的全限定名
interfaces是这个类实现接口的集合
interfaces_count是这个类实现接口的数量

fields
fields_count表示fields表中的数量

fields是表结构用来存放字段,字段即为类中声明的变量。字段包括了类级变量或实例级变量,static修饰符为判断依据。

public static final transient String str = "Hello World";
复制代码

一个字段包含的信息有:

  • 一、做用域(public、private、protected修饰符)
  • 二、类级变量仍是实例级变量(static修饰符)
  • 三、可变性(final)
  • 四、并发可见性(volatile修饰符,是否强制从主内存读写)
  • 五、能否序列化(transient修饰符)
  • 六、字段数据类型(基本类型、对象、数组)
  • 七、字段名称(str)

一个字段有多种修饰符,每种修饰符只有两种状态:有、没有,因此采用标志位来表示最为合理。字段的其余信息,叫什么名字、被定义为何数据类型,这些都是没法固定的,因此引用常量池中的常量来描述。

access_flags:修饰符

name_index:简单名称
指变量名,存放在常量池。例如字段str的简单名称“str”。

descriptor_index:描述符
描述字段的类型

例子:
java.lang.String[][] —— [[Ljava/lang/String
int[] —— [I
String s —— Ljava/lang/String

attributes:属性集合,以用于描述某些场景专有的信息。

上面的类型只定义了变量信息,那变量的初始赋值操做呢?

赋值操做是将常量赋值给变量,常量有字面量和符号引用,字面量会在常量池中,符号引用依据状况会在解析或使用阶段改成直接引用。

字段赋值的时机:
a:对于非静态的field字段的赋值将会出如今实例构造方法()中
b:对于静态的field字段,有两个选择:

一、在类构造方法()中进行;
 二、使用ConstantValue属性进行赋值
编译器对于静态field字段的初始化赋值策略:

若是final和static同时修饰一个字段,而且这个字段是基本类型或者String类型的,
那么编译器在编译这个字段的时候,会在对应的field_info结构体中增长一个ConstantValue类型的结构体,在赋值的时候使用这个ConstantValue进行赋值;

若是该field字段并无被final修饰,或者不是基本类型或者String类型,那么将在类构造方法中赋值。

对于全局变量的值是被编译在构造器中赋值的

https://www.cnblogs.com/straybirds/p/8331687.html
复制代码

methods
methods_count表示methods表中的数量

methods是表结构用来存放方法,表结构和字段的表结构一致

因为部分关键字相对于变量和方法是有区别的

例子:
int indexOf(char[] source,int sourceOffset,int sourceCount,char[] targetOffset,int targetCount,int fromIndex) —— ([CII[CII)I

方法内部的代码存到什么地方了?

attributes:属性表
在字段表和方法表中都有属性表

属性表所能识别的属性有

方法中到具体代码就存放在方法表中属性表的Code属性中

参考 https://blog.csdn.net/sinat_37138973/article/details/54378263
复制代码

在了解了字节码文件的结构后,JVM要想使用此文件,首先要将其加载到内存,那内存结构是怎样的呢?

JVM内存模型

  • 一、PC
    与CPU中的PC不一样,CPU中的PC是记录即将执行的下条指令的地址,而JVM中记录的是正在执行的虚拟机字节码指令的地址,且执行native方法时PC为空
  • 二、虚拟机栈
    每一个方法被执行的时候都会建立一个栈帧,用于存储局部变量表、操做栈、动态连接、方法出口等信息。每个方法被调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中从入栈到出栈的过程。
    若是线程请求的栈深度大于虚拟机所容许的深度,将抛出StackOverflowError 异常;若是虚拟机栈能够动态扩展当扩展时没法申请到足够的内存时会抛出OutOfMemoryError 异常。
  • 三、本地方法栈
    为虚拟机使用到的Native方法服务
    本地方法栈区域也会抛出StackOverflowError 和OutOfMemoryError异常。
  • 四、方法区
    用于存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。当方法区没法知足内存分配需求时,将抛出OutOfMemoryError 异常。
  • 五、堆
    存放对象实例,若是在堆中没有内存完成实例分配,而且堆也没法再扩展时,将会抛出OutOfMemoryError 异常。
参考 https://www.cnblogs.com/dingyingsi/p/3760447.html
复制代码

在知道了class字节码文件结构和JVM内存模型后,须要一个过程将字节码文件加载到内存。

类加载器

负责将字节码文件载入到内存,

BootstrapClassLoader – JRE/lib/rt.jar
ExtensionClassLoader – JRE/lib/ext或者java.ext.dirs指向的目录
ApplicationClassLoader – CLASSPATH环境变量, 由-classpath或-cp选项定义,或者是JAR中的Manifest的classpath属性定义

从上至下依次为父子关系,并非继承关系。

机制

  • 一、委托机制
    当加载B.class时,请求首先发到ApplicationClassLoader,ApplicationClassLoader看都不看就交给父亲ExtensionClassLoader,ExtensionClassLoader也是看都不看就交给父亲BootstrapClassLoader,BootstrapClassLoader为始祖了,因而在本身的管辖区内查找看有没有B.class,有就加载没有就告诉儿子ExtensionClassLoader,你本身处理,ExtensionClassLoader收到父亲的信息后在本身的管辖区内查找B.class,有就加载没有就告诉儿子ApplicationClassLoader,你本身处理,ApplicationClassLoader收到父亲的信息后在本身的管辖区内查找B.class,有就加载没有就ClassNotFoundException。
  • 二、可见性机制
    子类加载器能够看到父类加载器加载的类
  • 三、单一性机制
    因委托机制的关系,一个类(惟一的全限定名)只能被一个类加载器加载一次

加载方式

  • 一、显式加载
    经过class.forname()等方法,显式加载须要的类

  • 二、隐式加载
    程序在运行过程当中当碰到经过new等方式生成对象时,隐式调用类装载器加载对应的类到jvm中

自定义类加载器

以上三种类加载器,在某些场景下就不适用。因为以上三种类加载器都是加载指定位置的class,当加载异地加密的class时就没法使用,此时须要自定义类加载器,加载指定位置的class到内存并在执行解密后使用。

有了class字节码文件结构、JVM内存模型和类加载器这三个部分的初识,接着就是三个独立部分合做的场景:类加载过程

类加载过程

类加载器将字节码文件载入JVM内存的过程

  • 一、加载
    主要是获取定义此类的二进制字节流,并将这个字节流所表明的静态存储结构转化为方法区的运行时数据结构,最后在Java堆中生成一个表明这个类的java.lang.Class对象做为方法区这些数据的访问入口。
    类加载器参与的阶段。
  • 二、验证
    确保Class文件的字节流中包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。主要验证过程包括:文件格式验证(魔数、版本号),元数据验证(类关系),字节码验证(数据流、控制流)以及符号引用验证(引用可达)。
  • 三、准备
    正式为类变量(static变量)分配内存并设置类变量初始值的阶段。关于准备阶段为类变量设置零值的惟一例外就是当这个类变量同时也被final修饰,那么在编译时,就会直接为这个常量赋上目标值。
  • 四、解析
    解析时虚拟机将常量池中的符号引用替换为直接引用。
  • 五、初始化
    初始化阶段是执行类构造器()方法的过程。类构造器()方法是由编译器自动收藏类中的全部类变量的赋值动做和静态语句块(static块)中的语句合并产生。
    当初始化一个类的时候,若是发现其父类尚未进行过初始化,则须要先触发其父类的初始化。
    虚拟机会保证一个类的()方法在多线程环境中被正确加锁和同步。
参考 https://www.cnblogs.com/dooor/p/5289994.html
复制代码

一个项目、jar包、war包中有数百成千上万的字节码文件,一个字节码文件只能被加载一次(类加载器的委托机制),JVM是一次性加载所有文件的吗?确定不是,具体的实现由不一样的JVM自由发挥,但对于初始化阶段JVM有明确要求(被引用),天然初始化以前的阶段也必须完成。

类的引用方式

主动引用
一、当使用new关键字实例化对象时,当读取或者设置一个类的静态字段(被final修饰的除外)时,以及当调用一个类的静态方法时(好比构造方法就是静态方法),若是类未初始化,则需先初始化。
二、经过反射机制对类进行调用时,若是类未初始化,则需先初始化。
三、当初始化一个类时,若是其父类未初始化,先初始化父类。
四、用户指定的执行主类(含main方法的那个类)在虚拟机启动时会先被初始化。

被动引用
除了上面这4种方式,全部引用类的方式都不会触发初始化,称为被动引用。如:
经过子类引用父类的静态字段,不会致使子类初始化;
经过数组定义来引用类,不会触发此类的初始化;
引用类的静态常量不会触发定义常量的类的初始化,由于常量在编译阶段已经被放到常量池中了。

参考 https://blog.csdn.net/zcxwww/article/details/51330327
复制代码

初始化的对象会被放在什么地方呢?

内存分配策略

两个存储位置:本地线程缓存TLAB和堆

新对象产生时首先检查本地线程是否开启了缓存,是则存储在TLAB,不然去堆中寻找位置。

堆又分了:Eden、两个Survivor、Tenured共4个区,Eden与Survivor大小比是8:1,Eden和Survivor称为新生代,Tenured称为老年代(JDK8已经没有持久代了)

当新对象产生时,存放在Eden,当Eden放不下时触发Minor GC,将Eden中存活的对象复制到一Survivor中。继续存放对象到Eden,当Eden放不下时触发Minor GC,将Eden和非空闲Survivor中存活的对象复制到空闲Survivor中,往复操做。每通过一次Minor GC,对象的年龄加1,当对象年龄达到阀值(默认15)进入Tenured。若是在Minor GC期间发现存活对象没法放入空闲的Survivor区,则会经过空间分配担保机制使对象提早进入Tenured。若是在Survivor空间中的相同年龄的全部对象大小的总和大于Survivor空间的一半,年龄大于和等于该年的对象就能够直接进入老年代,无需等到指定的阀值。

空间分配担保机制:
在执行Minor GC前, VM会首先检查Tenured是否有足够的空间存放新生代尚存活对象,因为新生代使用复制收集算法,为了提高内存利用率,只使用了其中一个Survivor做为轮换备份,所以当出现大量对象在Minor GC后仍然存活的状况时,就须要老年代进行分配担保,让Survivor没法容纳的对象直接进入老年代,但前提是老年代须要有足够的空间容纳这些存活对象。但存活对象的大小在实际完成GC前是没法明确知道的,所以Minor GC前,VM会先首先检查老年代连续空间是否大于新生代对象总大小或历次晋升的平均大小,若是条件成立, 则进行Minor GC,不然进行Full GC(让老年代腾出更多空间)。然而取历次晋升的对象的平均大小也是有必定风险的,若是某次Minor GC存活后的对象突增,远远高于平均值的话,依然可能致使担保失败(Handle Promotion Failure,老年代也没法存放这些对象了),此时就只好在失败后从新发起一次Full GC(让老年代腾出更多空间)。

分代的惟一理由就是优化GC性能,让GC在固定区域工做。

GC

  • 一、Minor GC
    在年轻代(Eden和Survivor)中执行的GC
  • 二、Major GC
    在老年代(Tenured)中执行的GC
  • 三、Full GC
    清理整个堆空间包括年轻代和老年代

垃圾回收最重要的一点是如何判断对象为垃圾?

可达性分析算法

经过一系列称为GC Roots的对象做为起点,而后向下搜索,搜索所走过的路径称为引用链/Reference Chain,当一个对象到GC Roots没有任何引用链相连时,即该对象不可达,也就说明此对象是不可用的,如图:Object五、六、7虽然互有关联,但它们到GC Roots是不可达的,所以也会被断定为可回收的对象。

回收时的回收算法:

分代
根据对象存活周期的不一样将内存划分为几块,如JVM中的新生代、老年代,这样就能够根据各年代特色分别采用最适当的GC算法:

在新生代:每次垃圾收集都能发现大批对象已死,只有少许存活。所以选用复制算法,只须要付出少许存活对象的复制成本就能够完成收集。

在老年代:由于对象存活率高、没有额外空间对它进行分配担保,就必须采用“标记—清理”或“标记—整理”算法来进行回收,没必要进行内存复制,且直接腾出空闲内存。

新生代-复制算法

该算法的核心是将可用内存按容量划分为大小相等的两块,每次只用其中一块,当这一块的内存用完,就将还存活的对象复制到另一块上面,而后把已使用过的内存空间一次清理掉。

这使得每次只对其中一块内存进行回收,分配也就不用考虑内存碎片等复杂状况,实现简单且运行高效。

但因为新生代中的98%的对象都是生存周期极短的,所以并不需彻底按照1:1的比例划分新生代空间,因此新生代划分为一块较大的Eden区和两块较小的Survivor区。

老年代-标记清除算法
该算法分为“标记”和“清除”两个阶段:首先标记出全部须要回收的对象(可达性分析), 在标记完成后统一清理掉全部被标记的对象。


该算法会有如下两个问题:
一、效率问题:标记和清除过程的效率都不高;
二、空间问题:标记清除后会产生大量不连续的内存碎片,空间碎片太多可能会致使在运行过程当中须要分配较大对象时没法找到足够的连续内存而不得不提早触发另外一次垃圾收集。

老年代-标记整理算法

标记清除算法会产生内存碎片问题,而复制算法须要有额外的内存担保空间,因而针对老年代的特色,又有了标记整理算法。标记整理算法的标记过程与标记清除算法相同,但后续步骤再也不对可回收对象直接清理,而是让全部存活的对象都向一端移动,而后清理掉端边界之外的内存。

分区

将整个堆空间划分为连续的不一样小区间,每一个小区间独立使用,独立回收。这样作的好处是能够控制一次回收多少个小区间。

在相同条件下,堆空间越大,一次GC耗时就越长,从而产生的停顿也越长。为了更好地控制GC产生的停顿时间,将一块大的内存区域分割为多个小块,根据目标停顿时间,每次合理地回收若干个小区间(而不是整个堆),从而减小一次GC所产生的停顿。

参考 http://www.importnew.com/23035.html
复制代码

对象逃逸(点到为止)

本该销毁的对象,逃到了它处。

public class A {
    public static Object obj;
    public void globalVariableEscape() {  // 给全局变量赋值,发生逃逸
        obj = new Object();//new的对象本该在栈帧出栈时销毁,但被外部static引用致使进入方法区常量池
    }
    public Object methodEscape() {  // 方法返回值,发生逃逸
        return new Object();//new的对象本该在栈帧出栈时销毁,但被外部方法或线程引用,致使对象只能在外部方法栈帧出栈或线程销毁时被清理
    }
    public void instanceEscape() {  // 实例引用发生逃逸
        b = new B(this); //(示意而已,并不许确)新建B对象时引用了A对象,除B使用外,其他无引用A,此时本能够回收A,但B却引用致使没法回收。循环引用就是A在引用B,致使互相引用都不能被回收。
    }
}

public class B {
    public static Object obj;
    public void instance(A a) {  // 引用传入的实例
        obj = a;
    }
}
复制代码

对象的引用类型(强软弱虚)

JVM中真正将一个对象判死刑至少须要经历两次标记过程:

第一个过程,可达性分析算法
第二个过程,判断这个对象是否须要执行finalize()方法。

第一次GC时,对象在经历了可达性分析算法被标记后,若对象重写了finalize()方法且没被执行过则会被放入F-Queue队列中,不然回收。
第二次GC时,JVM会有一个优先级比较低的线程去执行队列中对象的finalize()方法,执行只是触发finalize()方法并不会确保方法必定完成,防止死循环或异常等状况致使对象不可被回收,这时第二次标记完成对象被回收。

只有当对象存在引用链链接GC Roots时才确保不会被回收,即对象为强引用。那么有些对象,咱们但愿在内存充足的状况下不要回收,在内存不足的时候再将其回收掉。若是只有强引用,那这个对象永远都不会被回收。因而有了软引用、弱引用、虚引用的概念。

  • 一、强引用
    即便OOM也不会被回收
  • 二、软引用
    内存不足时才会被回收
  • 三、弱引用
    只要GC就会被回收
  • 四、虚引用
    惟一的做用就是监听被回收
参考 https://blog.csdn.net/huachao1001/article/details/51547290
复制代码

类卸载

卸载须要知足三个条件:
一、该类全部的实例已经被回收
二、加载该类的ClassLoder已经被回收
三、该类对应的java.lang.Class对象没有被引用

JVM自带的根类加载器、扩展类加载器和系统类加载器,JVM自己会始终引用这些类加载器,所以条件2不会造成。

而这些类加载器则会始终引用它们所加载的类对象,所以条件3也不会造成。

惟一会被卸载的类只有自定义的类加载器加载的类。

Dalvik(DVM)

为何大篇幅讲JVM,由于Dalvik虚拟机是Google按照JVM虚拟机规范定制的虚拟机,所应用的可能是处理能力、内存、和存储等处理能力受限的设备,更符合移动设备的环境要求。

架构
JVM基于栈架构,Dalvik基于寄存器架构,所以读写速度较快

空间
可执行程序的字节码不一样
JVM:java -> class -> jar
DVM:java -> class -> dex -> apk

jar由多个class构成,而dex是由多个class合并构成,消除了数据冗余节省了空间,但合并后方法数变多,产生了方法数受限(65535)的问题。

沙盒
Dalvik虚拟机容许在内存中建立多个实例,以隔离不一样的应用程序。这样,当一个应用程序在本身的进程中崩溃后,不会影响其它进程的运行。

ART

在Dalvik下,应用每次运行都须要经过即时编译器(JIT)将字节码转换为机器码,即每次都要编译加运行,因此启动时间长。

ART则在应用安装时就预编译字节码到机器语言,因此安装过程较Dalvik时间长存储空间占用更大,但应用每次运行时不须要编译减小了CPU的负担,启动时间短。

原创做者:s1991721
原文连接:https://www.jianshu.com/u/0790f0629fc6
欢迎关注个人微信公众号「码农突围」,分享Python、Java、大数据、机器学习、人工智能等技术,关注码农技术提高•职场突围•思惟跃迁,20万+码农成长充电第一站,陪有梦想的你一块儿成长。

相关文章
相关标签/搜索