【Java_基础】JVM内存模型与垃圾回收机制

2019/03/12 16:35
阅读数 18

1. JVM内存模型

Java虚拟机在程序执行过程会把jvm的内存分为若干个不同的数据区域来管理,这些区域有自己的用途,以及创建和销毁时间。

JVM内存模型如下图所示

1.1 程序计数器

       程序计数器(Program Counter Register),也有称作为PC寄存器。JVM中的程序计数器跟汇编语言中的程序计数器在功能上是相同的,即指示待执行指令的地址。当 CPU 需要执行指令时,需要从程序计数器中得到当前需要执行的指令所在存储单元的地址,然后根据得到的地址获取到指令,在得到指令之后,程序计数器便自动加 1 或者根据转移指针得到下一条指令的地址,如此循环,直至执行完所有的指令。
      Java中多线程是通过线程轮流切换来获得CPU执行时间的,因此,在任一具体时刻,一个CPU的内核只会执行一个线程中的指令,因此,为了能够使得每个线程都在线程切换后能够恢复在切换之前的程序执行位置,每个线程都需要有自己独立的程序计数器,并且不能互相被干扰,否则就会影响到程序的正常执行次序,因此 程序计数器是线程私有的。

1.2 Java栈

      Java栈也叫虚拟机栈,也就是我们常常所说的栈。当线程执行一个方法时,就会为之创建一个对应的栈帧,方法从调用到执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。因此可知,线程当前执行的方法所对应的栈帧必定位于Java栈的顶部。讲到这里,大家就应该会明白为什么在使用递归方法的时候容易导致栈内存溢出的现象了以及为什么栈区的空间不用程序员去管理了(当然在Java中,程序员基本不用关系到内存分配和释放的事情,因为Java有自己的垃圾回收机制),这部分空间的分配和释放都是由系统自动实施的。对于所有的程序设计语言来说,栈这部分空间对程序员来说是不透明的。

下图表示了一个Java栈的模型:

局部变量表:对于方法中的基本数据类型,则直接存储它的值,对于引用类型的变量,则存的是堆区中对象实例的地址。局部变量表的大小在编译器就可以确定其大小了,因此在程序执行期间局部变量表的大小是不会改变的。

操作数栈:想必学过数据结构中的栈的朋友想必对表达式求值问题不会陌生,栈最典型的一个应用就是用来对表达式求值。想想一个线程执行方法的过程中,实际上就是不断执行语句的过程,而归根到底就是进行计算的过程。因此可以这么说,程序中的所有计算过程都是在借助于操作数栈来完成的。

指向运行时常量池的引用:因为在方法执行的过程中可能需要用到类中的常量,所以必须要有一个指向运行时常量的引用。

方法返回地址:当一个方法执行完毕之后,要返回到之前调用它的地方,因此在栈帧中必须保存一个方法的返回地址。

由于每个线程正在执行的方法可能不同,因此每个线程都会有一个自己的Java栈,因此Java栈是线程私有的。

1.3 本地方法栈

本地方法栈与Java栈的作用和原理非常相似。区别只不过是Java栈是为执行Java方法服务的,而本地方法栈则是为执行本地方法(Native Method)服务的。在JVM规范中,并没有对本地方法栈的具体实现方法以及数据结构作强制规定,虚拟机可以自由实现它。在HotSopt虚拟机中直接就把本地方法栈和Java栈合二为一。

1.4 堆

在C语言中,堆这部分空间是唯一一个程序员可以管理的内存区域。程序员可以通过malloc函数和free函数在堆上申请和释放空间。

Java中的堆是用来存储对象本身的以及数组(当然,数组引用是存放在Java栈中的)。只不过和C语言中的不同,在Java中,程序员基本不用去关心空间释放的问题,Java的垃圾回收机制会自动进行处理。因此这部分空间也是Java垃圾收集器管理的主要区域。另外,堆是被所有线程共享的,在JVM中只有一个堆。

1.5 方法区

方法区在JVM中也是一个非常重要的区域,它同堆一样是被线程共享的区域。在方法区中,存储了每个类的元数据信息(包括类、属性、方法的描述信息)、静态变量以及常量池。有关常量池的介绍可参考博文:【Java_基础】java中的常量池

各个区域的特点:

程序计数器:线程私有,唯一一个不会有 OutOfMemoryError 情况的区域。

虚拟机栈:线程私有,如果请求栈深度大于虚拟机所允许的深度,将抛出StackOverflowError异常,如果扩展时无法申请到足够的内存,就会抛出OutOfMemoryError异常。

本地方法栈:线程私有,有StackOverflowError异常和OutOfMemoryError异常。

:线程共享,如果堆中没有内存完成实例分配,并且堆也无法扩展时,就会抛出OutOfMemoryError异常。

方法区:线程共享,当方法区无法满足内存分配需要时,将抛出OutOfMemoryError异常。

java类各种变量存储的位置

java中的变量可分为三类:成员变量、局部变量和常量。

成员变量包括:实例变量——实例变量是与对象绑定的,每个对象都有自己单独的实例变量,实例变量存储在堆空间。

          静态变量——静态变量是与类绑定的,被该类的所有对象共享,存储在方法区的静态变量中。    

局部变量:局部变量不能被static关键字所修饰,作用域仅为其所在块或函数中。若为基本类型的局部变量其值就直接存在局部变量表中,若为引用类型的局部变量,则局部变量表中存对象的地址,指向堆中的对象实例。

常量:一切经final关键字修饰的变量(包括成员变量和局部变量)均为常量,string以及基本类型的封装类型若以字面量赋值也同样为常量(即常说的对象字面量)。常量的值均存在方法区的常量池中,值相等的常量共享常量池中同一存储空间。

          对象字面量和final常量的区别:虽然二者的值都来源于常量池中,但是final常量必须在定义时就赋初值,对象字面量可以先定义在赋值。

 2.垃圾回收机制

程序计数器、虚拟机栈、本地方法栈、堆区、方法区。其中程序计数器、虚拟机栈、本地方法栈3个区域随线程而生、随线程而灭,因此这几个区域的内存分配和回收都具备确定性,就不需要过多考虑回收的问题,方法结束或者线程结束时,内存自然就跟随着回收了。而Java堆区和方法区则与之不同,这部分内存的分配和回收是动态的,正是垃圾收集器所需关注的部分。

2.1 垃圾对象的确定

    引用计数法

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

public class ReferenceFindTest {
    public static void main(String[] args) {
        MyObject object1 = new MyObject();
        MyObject object2 = new MyObject();
          
        object1.object = object2;
        object2.object = object1;
          
        object1 = null;
        object2 = null;
    }
}

这段代码是用来验证引用计数算法不能检测出循环引用。最后面两句将object1和object2赋值为null,也就是说object1和object2指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为0,那么垃圾收集器就永远不会回收它们。

  可达性分析算法

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

在Java中,可作为GC Roots的对象包括下面几种:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象。
  • 方法区中类静态属性引用的对象。
  • 方法区中常量引用的对象。
  • 本地方法栈中JNI(即一般说的Native方法)引用的对象。

 2.2 垃圾对象的回收

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

/**  
 * 此代码演示了两点:  
 * 1.重写了finalize()方法的对象,可在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();  //系统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 :( 

2.3 典型的垃圾回收算法

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

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

  从图中可以看出它的核心思想就是标记垃圾对象然后清理,缺点就是容易造成垃圾碎片。

2.Copying(复制)算法

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

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

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

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

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

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算法。

本文参考与:JVM内存结构   Java内存模型与垃圾回收

展开阅读全文
打赏
0
0 收藏
分享
加载中
更多评论
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部