深入理解Java虚拟机,Java虚拟机总结给面试的你

时间:2019-10-04 14:48来源:编程技术
Java虚拟机一直是Java的重难点,一方面由于系统封装得太好,你平常写程序的时候几乎感觉不到它的存在,另一方面了解必要的Java虚拟机工作原理才能对真实工作环境下的bug进行对症下

Java虚拟机一直是Java的重难点,一方面由于系统封装得太好,你平常写程序的时候几乎感觉不到它的存在,另一方面了解必要的Java虚拟机工作原理才能对真实工作环境下的bug进行对症下药,另外虚拟机这一部分也一直是面试考官爱问的问题。于是这篇博客就针对Java虚拟机的各个知识点进行归纳。

文章作为《深入理解Java虚拟机》读书笔记,讲的可能就没书本详细。

图片 1内存模型

Java内存模型

Java虚拟机在执行程序时把它管理的内存分为若干数据区域,这些数据区域分布情况如下图所示:

图片 2

运行时数据区域

  • 程序计数器:一块较小内存区域,指向当前所执行的字节码。如果线程正在执行一个Java方法,这个计数器记录正在执行的虚拟机字节码指令的地址,如果执行的是Native方法,这个计算器值为空。(线程私有)

  • Java虚拟机栈:线程私有的,其生命周期和线程一致,描述的是Java方法执行的内存模型。每个方法执行时都会创建一个栈帧用于存储局部变量表、操作数栈、动态链接、方法出口等信息。(线程私有)

局部变量表:存放了编译期可知的各种基本数据类型,对象引用。其中64位长度的long和double类型的数据会占用2个局部变量空间,其余的数据类型只占用一个。如果线程请求的栈深度大于虚拟机所允许的深度,则抛出StackOverflowError异常。如果动态扩展时无法申请到足够的内存,则抛出OutOfMemoryError异常。

  • 本地方法栈:与虚拟机栈功能类似,只不过虚拟机栈为虚拟机执行Java方法(也就是字节码)服务,而本地方法栈则为使用到的Native方法服务。

  • Java堆:是虚拟机管理内存中最大的一块,被所有线程共享,该区域用于存放对象实例,几乎所有的对象都在该区域分配。Java堆是内存回收的主要区域,从内存回收角度看,由于现在的收集器大都采用分代收集算法,所以Java堆还
    可以细分为:新生代和老年代,再细分一点的话可以分为Eden空间、From Survivor空间、To Survivor空间等。根据Java虚拟机规范规定,Java堆可以处于物理上不连续的空间,只要逻辑上是连续的就行。(线程共享)

  • 方法区:与Java一样,是各个线程所共享的,用于存储已被虚拟机加载类信息、常量、静态变量、即时编译器编译后的代码等数据。(线程共享)

  • 运行时常量池:运行时常量池是方法区的一部分,Class文件中除了有类的版本、字段、方法、接口等描述信息外,还有一项信息是常量池,用于存放编译期生成的各种字面量和符号引用。运行期间可以将新的常量放入常量池中,用得比较多的就是String类的intern()方法,当一个String实例调用intern时,Java查找常量池中是否有相同的Unicode的字符串常量,若有,则返回其引用;若没有,则在常量池中增加一个Unicode等于该实例字符串并返回它的引用。

程序计数器

程序计数器是当前线程执行的字节码的行号指示器,线程私有,独立存储

虚拟机对象

  • 对象的创建:虚拟机遇到一条new指令时,首先将去检查这个指令的参数是否能在常量池中定位到一个类的符号引用,并且检查这个符号引用代表的类是否已被加载,解析和初始化。如果没有则先执行相应的类加载过程。
    在堆中为对象分配内存有:“指针碰撞”(堆连续) 和 “空闲列表”(堆不连续)。由Java堆是否规整决定。

  • 对象的内存布局:对象在内存中存储的布局可以分为3块区域。对象头(Header),实例数据(Instance Data)和对齐填充(Padding)

    • 对象头:分为两部分
      ①第一部分用于存储对象自身的运行时数据,如哈希码,GC分代年龄等
      ②另一部分是类型指针,即对象指向它的类元数据的指针。虚拟机通过这个指针来确定这个对象是哪个类的实例。

    • 实例数据:是对象真正存储的有效信息,也是在程序代码中所定义的各种类型的字段内容

    • 对齐填充:起着占位符的作用。由于HotSpot VM的自动内存管理系统要求对象起始地址必须是8字节的整数倍,也就是对象的大小必须是8字节的整数倍。而对象对不正好是8字节的倍数。因此,当对象实例数据部分没有对齐时,就需要通过对齐填充来补全。

Java虚拟机栈

Java虚拟机栈是线程私有,与Java的方法执行模型有关,描述Java方法执行的内存模型:方法执行时创建栈帧用于储存局部变量表等信息,方法调用返回对应栈帧再虚拟机中的入栈出栈。

既然是栈那么深度就是一定的,若线程请求栈深度大于虚拟机所规定的深度,则抛出StackOverflowError异常。若虚拟机栈请求扩展时无法申请到足够的内存,则抛出OOM异常。

内存回收GC

前面内存模型讲到5个数据区域,其中程序计数器,虚拟机栈,本地方法栈3个区域随线程而生,随线程而灭,栈中的栈帧随着方法的进入和退出而有条不紊地执行着出栈和入栈操作。在这几个区域就不需要过多考虑内存回收问题,因为方法结束或者线程结束时,内存自然就跟着回收了。而Java堆和方法区则不一样,这部分内存的分配和回收都是动态的。

垃圾对象如何确定?
Java堆中存放着几所所有的对象实例,垃圾收集器在对堆进行回收前,首先需要确定哪些对象还"活着",哪些已经"死亡",也就是不会被任何途径使用的对象。 对象存活判定方法:

  • 引用计数算法

引用计数法实现简单,效率较高,在大部分情况下是一个不错的算法。其原理是:给对象添加一个引用计数器,每当有一个地方引用该对象时,计数器加1,当引用失效时,计数器减1,当计数器值为0时表示该对象不再被使用。需要注意的是:引用计数法很难解决对象之间相互循环引用的问题,主流Java虚拟机没有选用引用计数法来管理内存。

  • 可达性分析算法

这个算法的基本思路就是通过一系列的称为“GC Roots”的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如图所示,对象object 5、object 6、object 7虽然互相有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。

图片 3

可达性分析算法

在Java语言中,可作为GC Roots的对象包括下面几种:
①虚拟机栈(栈帧中的本地变量表)中引用的对象。
②方法区中类静态属性引用的对象。
③方法区中常量引用的对象。
④本地方法栈中JNI(即一般说的Native方法)引用的对象。

即使在可达性分析算法中不可达的对象,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段,要真正宣告一个对象死亡,至少要经历两次标记过程:如果对象在进行可达性分析后发现没有与GC Roots相连接的引用链,那它将会被第一次标记并且进行一次筛选,筛选的条件是此对象是否有必要执行finalize()方法。当对象没有覆盖finalize()方法,或者finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为“没有必要执行”。

程序中可以通过覆盖finalize()来一场"惊心动魄"的自我拯救过程,但是,这只有一次机会。

/**  
 * 此代码演示了两点:  
 * 1.对象可以在被GC时自我拯救。  
 * 2.这种自救的机会只有一次,因为一个对象的finalize()方法最多只会被系统自动调用一次  
 * @author zzm  
 */  
public class FinalizeEscapeGC {  

  public static FinalizeEscapeGC SAVE_HOOK = null;  

  public void isAlive() {  
   System.out.println("yes, i am still alive :)");  
  }  

  @Override  
  protected void finalize() throws Throwable {  
   super.finalize();  
   System.out.println("finalize mehtod executed!");  
   FinalizeEscapeGC.SAVE_HOOK = this;  
  }  

  public static void main(String[] args) throws Throwable {  
   SAVE_HOOK = new FinalizeEscapeGC();  

   //对象第一次成功拯救自己  
   SAVE_HOOK = null;  
   System.gc();  
   //因为finalize方法优先级很低,所以暂停0.5秒以等待它  
   Thread.sleep(500);  
   if (SAVE_HOOK != null) {  
    SAVE_HOOK.isAlive();  
   } else {  
    System.out.println("no, i am dead :(");  
   }  

   //下面这段代码与上面的完全相同,但是这次自救却失败了  
   SAVE_HOOK = null;  
   System.gc();  
   //因为finalize方法优先级很低,所以暂停0.5秒以等待它  
   Thread.sleep(500);  
   if (SAVE_HOOK != null) {  
    SAVE_HOOK.isAlive();  
   } else {  
    System.out.println("no, i am dead :(");  
   }  
  }  
} 



运行结果为:
finalize mehtod executed!  
yes, i am still alive :)  
no, i am dead :(

任何一个对象的finalize()方法都只会被系统自动调用一次,如果对象面临下一次回收,它的finalize()方法不会被再次执行。因此第二段代码的自救行动失败了。

前面的算法讲的是如何判定垃圾对象,判定完后则该如何处理进行垃圾回收?

本地方法栈

就是Native方法所用到的栈,与虚拟机栈作用类似。

典型的垃圾回收算法

1.Mark-Sweep(标记-清除)算法

这是最基础的垃圾回收算法,之所以说它是最基础的是因为它最容易实现,思想也是最简单的。标记-清除算法分为两个阶段:标记阶段和清除阶段。标记阶段的任务是标记出所有需要被回收的对象,清除阶段就是回收被标记的对象所占用的空间。具体过程如下图所示:

图片 4

标记-清除

从图中可以很容易看出标记-清除算法实现起来比较容易,但是有一个比较严重的问题就是容易产生内存碎片,碎片太多可能会导致后续过程中需要为大对象分配空间时无法找到足够的空间而提前触发新的一次垃圾收集动作。

2.Copying(复制)算法

为了解决Mark-Sweep算法的缺陷,Copying算法就被提了出来。它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这一块的内存用完了,就将还存活着的对象复制到另外一块上面,然后再把已使用的内存空间一次清理掉,这样一来就不容易出现内存碎片的问题。具体过程如下图所示:

图片 5

复制

这种算法虽然实现简单,运行高效且不容易产生内存碎片,但是却对内存空间的使用做出了高昂的代价,因为能够使用的内存缩减到原来的一半。

很显然,Copying算法的效率跟存活对象的数目多少有很大的关系,如果存活对象很多,那么Copying算法的效率将会大大降低。

3.Mark-Compact(标记-整理)算法

为了解决Copying算法的缺陷,充分利用内存空间,提出了Mark-Compact算法。该算法标记阶段和Mark-Sweep一样,但是在完成标记之后,它不是直接清理可回收对象,而是将存活对象都向一端移动,然后清理掉端边界以外的内存。具体过程如下图所示:

图片 6

标记-整理

4.Generational Collection(分代收集)算法

分代收集算法是目前大部分JVM的垃圾收集器采用的算法。它的核心思想是根据对象存活的生命周期将内存划分为若干个不同的区域。一般情况下将堆区划分为老年代(Tenured Generation)和新生代(Young Generation),老年代的特点是每次垃圾收集时只有少量对象需要被回收,而新生代的特点是每次垃圾回收时都有大量的对象需要被回收,那么就可以根据不同代的特点采取最适合的收集算法。

目前大部分垃圾收集器对于新生代都采取Copying算法,因为新生代中每次垃圾回收都要回收大部分对象,也就是说需要复制的操作次数较少,但是实际中并不是按照1:1的比例来划分新生代的空间的,一般来说是将新生代划分为一块较大的Eden空间和两块较小的Survivor空间(一般为8:1:1),每次使用Eden空间和其中的一块Survivor空间,当进行回收时,将Eden和Survivor中还存活的对象复制到另一块Survivor空间中,然后清理掉Eden和刚才使用过的Survivor空间。

而由于老年代的特点是每次回收都只回收少量对象,一般使用的是Mark-Compact标记-整理算法。


参考资料:《深入理解Java虚拟机》2章和3章内容

Java堆

Java堆是被所有线程共享的一块内存区域,属于线程共享区,在虚拟机启动时创建。它主要作用是存放对象实例和进行垃圾收集管理。

方法区

方法区也是各个线程共享的内存区域,用于储存已被虚拟机加载的类信息,常量,静态变量,即时编译器编译后的代码等数据。

运行时常量池

运行时常量池其实属于方法区,它主要用于存放编译期生成的各种字面量和符号引用,并且具有动态的特点。

  1. 检查指令的参数能否在常量池中定位到一个类的符号引用
  2. 检查是否已经加载解析和初始化
  3. 从Java堆中划分内存给新生对象,使用CAS保证分配的原子性
  4. 将内存空间初始化为零值
  5. 对对象进行设置,存放在对象头中
  6. 执行<init>方法,按照程序员的意愿进行初始化

分配方式

  1. 指针碰撞

    若Java堆中的内存都是规整的,用过的内存都在左边,没用过的都在右边,中间指针指向临界点,分配内存就很简单,只用把指针往右移动和待分配对象一样的内存区域就行了。

  2. 空闲列表

    如果内存不是规整的,用过的和没用过的内存交错在一起,就不能使用指针碰撞了,需要维护一个列表记录可用的内存块,分配内存时就从列表中找一块足够大的内存记录下来。

对象头

储存对象自身的运行时数据,eg:哈希码,GC分代年龄,锁状态标志等。还有类型指针指向它的类元数据的指针,通过这个指针确定这个对象是哪个类的实例。若是Java数组则对象头还有一块记录数组长度的数据。

实例数据

程序代码中所定义的各种类型的字段内容,相同宽度的字段分配到一起

虚拟机通过栈上的reference数据来操作堆上的具体对象。

访问方式

  1. 使用句柄

    包含对象实例数据与类型数据各自的地址信息,reference中储存的就是对象的句柄地址。句柄地址稳定,对象移动时只改变句柄中的实例数据指针,reference本身不修改。

  2. 直接指针

    reference中储存的就是对象地址,速度更快

给对象添加一个引用计数器,有一个地方引用它时,计数器值就加一,引用失效时就减一,任何时刻计数值为0的对象就死了。这个算法虽然简单但是有一个致命的缺点就是无法解决对象之间相互循环引用的关系。可达性分析算法应运而生。

GC Roots作为起点向下搜索,若一个对象到GC Roots没有引用链的话,则证明此对象不可用,可以回收。搜索的对象有:

  • 虚拟机栈中引用的对象
  • 方法区中静态属性引用的对象
  • 方法区中常量引用的对象
  • 本地方法栈中Native 方法引用的对象

对象在没有引用链通往GC Roots时,需要经过两次标记才能真正死亡。

  1. 对象在进行可达性分析后如果没有与GC Roots相连接的引用链,会被第一次标记并筛选,若对象没有覆盖finalize方法或者已经调用过了则不会调用finalize。如果需要调用finalize方法,则对象被放在F-Queue队列中,等待线程执行。
  2. 对象如果想存活下去,finalize方法是最后的机会,否则GC对F-Queue队列进行第二次标记后对象真正死亡。

标记-消除算法

首先标记出所有需要回收的对象,在标记完成后统一回收,缺点是效率低下而且产生大量的内存碎片。

复制算法

将内存划分为大小相等的两块,每次只使用其中的一块,当这一块的内存用完了,就将还存活的对象复制到另外一块上面,然后把已经使用的内存空间一次清理掉。缺点是将内存缩小为了原来的一半,代价较高,对象存活率较高时效率低。

HotSpot实际使用则是将内存划分为较大的Eden区和两块较小的Survivor区,一块Eden区和一块Survivor区大小比例为8:1,垃圾回收时就将Eden区和已使用的Survivor区中还存活的对象移到另一块Survivor区中,由于根据统计,98%的对象都是很快死亡的,所以按照8:1:1的比例来划分内存明显比1:1划分内存效率要高很多。

标记-整理算法

标记出需要回收的对象,然后让所有存活的对象都向一段移动,将另一端的内存区域清除掉。

分代收集算法

根据新生代和老年代的不同特点选择不同的算法,新生代使用复制算法,老年代使用标记清楚或标记整理算法,虚拟机实际使用这种算法。

对象优先在Eden上分配

GC分类

  1. Monior GC,新生代GC,指发生在新生代的垃圾收集动作,因为Java对象大多都具备朝生夕灭的特点,所以Monior GC很频繁,速度也很快
  2. Major GC/Full GC,老年代GC,指发生在老年代的垃圾回收动作,一般比Monior GC慢十倍以上。

大对象直接进入老年代

大对象指需要大量连续内存空间的Java对象,如很长的字符串以及数组。直接进入老年代避免频繁的GC活动。

长期存活的对象将进入老年代

对象在新生代区域每熬过一次Minor GC,年龄就增加一岁(Age Count),超过15岁,就会被晋升到老年代中。

动态年龄判定

如果相同年龄的对象所占内存大于Survivor空间的一半,年龄大于等于该年龄的对象就可以直接进入老年代。

一组以八位字节为基础的二进制流,各个数据项目严格按照顺序紧凑地排列在Class文件之中,中间没有任何分隔符。

储存结构

无符号数,用来描述数字,索引引用,数量值或UTF-8编码的字符串

,多个无符号数+表=表,_info结尾,Class实际上就是一张表

魔数

每个Class文件的头4个字节,确定这个文件是否为一个能被虚拟机接受的Class文件。class文件的魔数是0XCAFEBABE。

Class文件的版本号

紧跟魔数的四个字节确定版本号:5,6字节为次版本号,7,8字节为主版本号。jdk向下兼容,不向上兼容。

常量池

紧随主次版本号之后包含:

  • 字面量文本字符串,申明为final的常量值。
  • 符号引用
    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符
  • 动态连接各个字段的内存信息,从常量池中获得对应的读出引用,再在类创建时或运行解析翻译到具体的内存地址之中。
  • 每一项常量都是一个表,每个表的第一位都是一个是一个u1类型的标志位,代表这个常量属于哪种常量类型。

访问标志

紧随常量池后面,两个字节代表访问标志,标识类或接口的访问信息。如这个Class是类还是接口,public类型等。

类索引,父类索引,接口索引集合

除了接口索引是集合外,其他索引都只有一个,用这三个索引确定类的继承关系。类索引用于确定类的全限定名,父类索引用于确定父类的全限定名。

字段表集合

用于描述类或接口中声明的变量,字段包括类级变量和实例级变量,不包括方法中声明的局部变量,描述字段的属性如public,static,final等用一个布尔变量表示,刚好使用一个标志位,通过引用常量池中的常量来确定。

方法表集合

与字段表相似。

属性表集合

Class文件,字段表,方法表都可以携带自己的属性表集合,用于描述某些场景专有的信息。

操作码长度为一个字节,所以总数最多不超过256条。

编辑:编程技术 本文来源:深入理解Java虚拟机,Java虚拟机总结给面试的你

关键词:

  • 上一篇:没有了
  • 下一篇:没有了