《深入理解Java虚拟机》读书笔记

作者:计算机教程

一、如何判断对象已死?

  我们知道程序计数器、虚拟机栈、本地方法栈三个区域都是线程私有的,它们的生命周期都是随着线程而生,随着线程而亡,因此是不需要过多考虑回收的问题,而Java Heap(堆)与Method Area(方法区)不一样,必须考虑内存回收问题。

我们只有当程序运行的时候才会知道会创建哪些类,而这些类占用内存也是不同的,不会固定,都是动态的,即这部分的内存的分配与回收都是动态的,垃圾收集器关注的就是这部分内存。

  VM判断对象是否死亡主要有两种方式,一种是通过计数器的方式,另外一种是通过引用链(根搜索算法):

Java 内存区域

我们通常说的Java内存主要指的在Java程序运行的过程中Java虚拟机把它所管理内存区域划分的不同的数据区域。这些不同的区域保存着不同的数据类型,有着不同的创建和销毁时间。Java的内存区域可以分为两大部分:方法区(Method Area)和堆(Heap) 各个线程公用的内存区域,俗称堆内存(不连续的内存空间);虚拟机栈(VM Stack) 、本地方法栈(Native Method stack) 、程序计数器(Program Counter Register)各个线程私有的,生命周期与线程保持一致,俗称栈内存,先进后出(连续的内存空间)。

nba买球 1

java内存区域.png

  • 程序计数器 :程序计数器是当前线程所执行的字节码的行好的指示器,是一块较小的内存区域。Java虚拟机的多线程是通过线程的轮流切换来实现的,在任何一个时刻,处理器的一个内核只能执行一个线程的指令,程序计数器保证了当前线程再次被执行时能执行到正确的位置,因此每个线程都会有一个独立的程序计数器,由于占用内存较小,此区域不会发生内存溢出。

  • 虚拟机栈:描述的是Java内存区域执行的内存模型:每个方法在执行过程中都会创建一个栈帧,用来保存局部变量表、操作数栈、动态链接、方法出口等信息。每个方法从开始执行到执行结束都对应着一个栈帧在虚拟机中从入栈到出栈的过程。

    • 通常所说的“栈”就是指的是虚拟机栈中的局部变量表部分。该部分保存了各种基本数据类型,对象的引用(reference),returnAddress类型(指向了一条字节码指令的地址)。
  • 本地方法栈:类似于虚拟机栈,为的是虚拟机执行Native方法服务。

  • 堆:在虚拟机启动时创建,是所有线程共享的一块内存区域。存放了所有的new出来的对象的实例和数组,对象的reference则在虚拟机栈上。这一块也是垃圾回收主要回收的区域,通常容易发生内存泄漏(分配出去的内存不能被回收,失去了对这块内存的控制。eg:保存了reference,但是一直没有使用)和内存溢出(程序需要的内存超出了虚拟机所分配的内存)。

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

1、计数器方式

  这是很多教科书是提及到的方法,可以算是经典的一种方式。

  (1)原理:通过引用一个计数器,每次对象被引用就会加1,当引用失败后,自然减1...如此下去,直到计数器为0(其实任何时刻都可能为0),那么可以认为此对象不可能再被引用了,也就是达到GC的条件。

  (2)优点:简单容易实现,判断效率也高,计数器的操作并不占用过多资源。

  (3)缺点:那很明显就是无法解决对象之间的循环引用问题,至于什么是对象之间的循环引用,可以通过下面的代码理解:

    

nba买球 2nba买球 3

private Object instance = null;

GCObject ojbA = new Object();
GCObject ojbB = new Object();
//对象之间的循环引用
objA.instance = objB;
objB.instance = objA;
//两个对象已经不能被引用
objA = null;
objB = null;
//这里开始GC
system.gc();

View Code

 

内存分配例子分析
public void test(){
   Person person = new Person();
}

这段代码涉及Java堆、虚拟机栈、方法区三个地区的内存分配。new Person()会在Java堆上开辟一块内存,创建了Person这个类的实例;person这个引用则会在虚拟机栈的本地变量表中,并指向Java堆上我们刚创建的对象;同时方法区中还保存了能查找到此对象类型数据的地址信息(如对象类型、父类、实现的接口、方法等)。

 2、根搜索(GC Roots Tracing)方式

  在很多主流的VM中都会使用到此方式,个人理解此方式主要是利用数据结构中“有向图”是否存在可达路径(其实也不太准确,但是说是Tree也是不准确的),不存在则说明引用链不存在,对象是死亡状态的。

  (1)原理:通过一系列可以被认为为根对象(GC Roots)的对象作为起始点,从这些起始点往下搜索遍历,得到的遍历路径称之为引用链(Reference Chain),当一个对象的到GC Roots不存在任何引用链时(不可达),则说明该对象不可用,引用此对象的其他对象可能也会随之不可用。

 

      nba买球 4

    

    图中Object7与GC Root之间已不存在引用链,随之带来的Object8、Object9亦然。故做“死亡”对象被GC。

 

  (2)GC Roots对象:

    • Java Stack中本地变量表中引用的对象:Java栈本地变量表中引用类型指向Java堆中的对象,即Object object = new Object()中object引用Object对象

    • Method Area中类static静态属性引用的对象:即static object...方法区中存储类的信息、常量(final)、静态(static)变量等

    • Method Area中常量引用的对象:即final Object...

-   本地方法栈中JNI的引用对象:即Native方法引用的对象

 

  (3)优点:可以避免对象循环引用问题,能彻底知道对象是否为死亡状态。

  (4)缺点:遍历需要时间,效率相对低。

Java 对象的创建

Java是一门面向对象的语言,在虚拟机运行时时刻都有对象被创建出来。在new 创建对象时,虚拟机首先会检查对象对应的类是否被加载了出来,如果没有会先执行类加载过程。类加载完成后,对象所需的内存大小已经确定了下来,为对象分配内存有两种方式:

  • 指针碰撞:Java堆内存是规整的,在已用内存和未用内存中间有个指针,每次新分配一块内存,指针会偏移一段,保持在已用和未用内存之间。
  • 空闲列表:Java堆内存不是规整的,已用和未用内存相互交错,虚拟机在分配内存时必须维护了一个列表,上面记录了已用和未用的内存区域,每次分配内存时都从列表中找到一块足够大的内存来分配内存。

在对象内存分配结束后,虚拟机会为对象分配到的内存空间都初始化为0,所以不进行赋值就能访问到对象的属性字段的零值。

二、被死亡标记的对象不能有机会复活吗?

 

  经过根搜索算法后,那些不可达的对象也不是一定会要被执行“死刑”的,实际上只是暂时处于“缓刑”状态,GC会给它们再一次“复活”的机会。真正要执行“死刑”的对象都要经过两次标记过程:

  (1)不可达的对象将会被第一次标记并进行一次筛选(此对象是否有必要执行finalize()方法),有两种情况时没必要的:对象没有覆盖finalize()方法,或者finalize()方法已经被VM调用过。

  (2)如果是有必要执行finalize()方法,那么该对象会被放入一个F-Quence队列中,然后VM自动建立一个低优先级的Finalizer线程与执行(触发finalize()方法),大不会等待它结束为止。这是因为防止对象在执行finalize()时死循环或者其他更严重的情况,那么F-Queue队列中其他对象都会一直等待。在F-Quece队列中对象只要在执行finalize()方法过程中能够再次建立一个引用链,救能把自己“救活”,那么VM就会进行第二次标记将该已经“自我救赎”的对象移出F-Quence队列。反之如果没有任何的引用链,那么该对象的生命就到期结束。但这种“自我救赎”只能有一次机会,因为一个对象的finalize()方法只能被系统自动调用一次。注意:finalize()析构函数不像C 那样比用,在Java中是不推荐使用的,是因为运行成本高且不确定性大

    总结起来就是:VM会对那些执行了finalize()方法的对象利用F-Quence队列进行一次finalize()方法中是否存在存在引用链(自我救赎)的确认。有则存活(得感谢自己最后一次挣扎!),无则死亡(神仙都救不了你!)

  通过下面的代码实现来更加理解这个过程:

 

 1 /**
 2  * 模拟对象在生死边缘拯救自己的过程
 3  * @author Lijian
 4  */
 5 public class FinalizeEscapeGC {
 6     
 7     public static FinalizeEscapeGC SAVE_HOOK = null; //没有引用链,很危险很可能被回收
 8     public void isAlive() {
 9         System.out.println("I am still alive :)");
10     }
11 
12         //重写finalize()方法:有必要执行finalize()方法,放入F-Quece队列
13     //VM 创建的线程Finalizer会执行finalize()方法,这是对象唯一一次自我救赎机会
14     @Override
15     protected void finalize() throws Throwable {
16         super.finalize();
17         System.out.println("finalize method executed!");
18         FinalizeEscapeGC.SAVE_HOOK = this;//真正的自我救赎,重新在finalize()中建立引用链
19     }
20   public static void main(String[] args) throws Throwable{
21         SAVE_HOOK = new FinalizeEscapeGC();
22         
23         //第一次救赎
24         SAVE_HOOK = null;//不存在引用链,引起GC注意将会第一次标记
25         System.gc();
26         Thread.sleep(500);//Finalzier方法优先级低,所以先暂定0.5m。等待它
27         if (SAVE_HOOK != null) {
28             SAVE_HOOK.isAlive();//还活着    
29         }else {
30             System.out.println("No, I am dead :(");
31         }
32         //不等于null证明活过来了
33         System.out.println(SAVE_HOOK);
34         
35         //再一次救赎:活过来之后再失去引用链,再次尝试自我拯救
36         //但是finalize()方法只能被系统调用一次,结果是失败的
37         SAVE_HOOK = null;//活过来之后再失去引用链
38         System.gc();
39         Thread.sleep(500);
40         if (SAVE_HOOK != null) {
41             SAVE_HOOK.isAlive();
42         }else {
43             System.out.println("No, I am dead!");
44         }
45     }
46     
47 }    

 

输出结果:

1 finalize method executed!
2 I am still alive :)
3 main.FinalizeEscapeGC@7852e922
4 No, I am dead!

整个流程如下:

nba买球 5

 

 接下来就是垃圾回收算法、垃圾回收器、内存分配与回收等。下一步将会做进一步介绍!

 

Java 对象的定位

访问对象需要通过栈上的reference类型引用堆内存上的对象,引用方式分为两种:

  • 使用句柄:Java堆上划分出一块句柄内存,reference指向句柄,而句柄指向对象和方法区上的类型数据。
  • 直接引用:reference直接指向对象。

nba买球,Java 内存分配和垃圾回收

Java内存结构中,虚拟机栈,程序计数器,本地方法栈这三个区域随线程而生灭,栈中的栈帧随着方法的进入和退出而进行出入栈操作,每一个栈帧需要的内存在类结构确定下来的时候就已经确定了,这几个区域不需要考虑到垃圾回收的问题。而Java堆和方法区只有在运行时才可以确定内存的大小,这部分内存的分配和回收都是动态的,垃圾回收机制处理的主要是这一部分内存。

本文由nba买球发布,转载请注明来源

关键词: