向万鹏的独立博客


  • 首页

  • 分类

  • 归档

  • 标签

对象死亡的过程

发表于 2016-10-25   |   分类于 JVM   |  

  在《JVM中判断对象是否存活的方法》这篇博文中,我们了解了主流的Java虚拟机都是通过可达性分析算法判断对象是否存活的。其实,即使在可达性分析算法中被判断为不可达的对象,也并非是“非死不可”的,它们此时处于“缓刑”阶段,要真正宣告一个对象的死亡,至少要经历两次标记过程:如果对象在可达性分析算法中被判断为不可达,即不存在任何GC Roots到这个对象的引用链,那么这个对象会被第一次标记,并进行一次筛选,筛选条件是“这个对象是否有必要执行finalize()方法”。若对象没有覆盖finalize()方法,或者该对象的finalize()方法已经被JVM调用过,这两种情况都被视为“没有必要执行”,没有必要执行finalize()方法的对象将被第二次标记,随后便被回收。如果此对象被判定为有必要执行finalize()方法,那么这个对象将被放置在一个叫做F-Queue的队列中,这个队列将被一个由JVM自动建立,低优先级的Finalizer线程去执行。这里的“执行”只意味着JVM会触发对象的finalize()方法,但不承诺会等待它运行结束,因为如果一个对象在finalize()中执行缓慢,或者发生了死循环,这将可能导致F-Queue队列中其他对象永久处于等待,甚至导致整个内存回收系统崩溃。finalize()方法是对象逃脱死亡命运的最后一次机会,稍后GC将对F-Queue中的对象进行第二次标记,如果对象要在finalize()方法中拯救自己,那么它只要重新与引用链上的任意一个对象建立关联即可,譬如通过this关键字把自己赋值给某个类变量或者对象的成员变量,那么在第二次标记时它将被移除出“即将回收”的集合;如果到这个时候对象还没逃脱死亡的命运,它将被第二次标记,那么基本上它就真的可以被回收了。这个过程可以通过下面的流程图来表示:
image_1avv8t3n01u7r5f9p4utfb25813.png-62.9kB

  此外,我们可以通过下面的程序看到,一个对象的finalize()方法被执行,但是它仍然可以存活:

  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();
        // 因为Finalizer方法优先级很低,暂停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();
        // 因为Finalizer方法优先级很低,暂停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 :(

从输出结果的第一、二行我们可以知道,SAVE_HOOK曾引用的对象,其finalize()方法的确被GC触发过,但是它在finalize()中成功拯救了自己,得以不被回收。但是在第三行中,程序中的一段与之前一模一样的代码,执行结果却是“no, i am dead :(”,这是因为任何一个对象的finalize()方法只会被系统自动调用一次。如果对象面临下一次回收,它的finalize()方法不会再被执行。
  从上面的程序中,我们再次体会到了,finalize()方法的局限性,它不同于C/C++中的析构函数,它的运行代价高昂,不确定性大,无法保证各个对象的调用顺序,所以建议大家完全可以忘掉Java语言中这个方法的存在,这在《finalize()方法的使用》中我们已经介绍过。

JVM中判断对象是否存活的方法

发表于 2016-10-24   |   分类于 JVM   |  

  Java中几乎所有的对象实例都存放在堆中,在垃圾收集器对堆内存进行回收前,第一件事情就是要确定哪些对象还“存活”,哪些对象已经“死去”(即不可能再通过任何途径被使用)。

引用计数算法

  首先需要声明,至少主流的Java虚拟机里面都没有选用引用计数算法来管理内存。
  什么是引用计数算法:给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值加1;当引用失效时,计数器值减1.任何时刻计数器值为0的对象就是不可能再被使用的。那为什么主流的Java虚拟机里面都没有选用这种算法呢?其中最主要的原因是它很难解决对象之间相互循环引用的问题。在下面的代码中,我们在testGC()中new两个ReferenceCountingGC对象objA和objB,然后令objA.instance=objB,objB.instance=objA,最后令objA和objB都为null。此时,这两个对象实际上已经不可能再被访问,但由于它们互相引用着对方,导致它们的引用计数器值都不为0,于是使用引用计数算法无法通知GC收集器回收它们。

  public class ReferenceCountingGC {

    public Object instance = null;

    private static final int _1MB = 1024 * 1024;

    /**
     * 这个成员属性的唯一意义就是占点内存,以便在能在GC日志中看清楚是否有回收过
     */
    private byte[] bigSize = new byte[2 * _1MB];

    public static void testGC() {
        ReferenceCountingGC objA = new ReferenceCountingGC();
        ReferenceCountingGC objB = new ReferenceCountingGC();
        objA.instance = objB;
        objB.instance = objA;

        objA = null;
        objB = null;

        // 假设在这行发生GC,objA和objB是否能被回收?
        System.gc();
    }

    public static void main(String[] args) {
        testGC();
    }
}

  现在,我们在main方法中调用testGC(),然后打印GC日志(在Myeclipse中打印GC日志的方法)。得到结果为:

0.161: [GC 4761K->568K(124416K), 0.0022505 secs]
0.163: [Full GC 568K->471K(124416K), 0.0201927 secs]

  由“4761K->568K”我们可以得知,虚拟机并没有因为这两个对象相互引用就不回收它们,这也从侧面说明了hotspot虚拟机并不是通过引用计数算法来判断对象是否存活的。

那么,在主流的商用程序语言的主流实现中,是通过什么方法来判断对象是否存活的呢?答案是下面将要介绍的可达性分析算法。

可达性分析算法

  可达性分析算法的基本思路是:通过一系列的称为“GC Roots”的对象作为起始点,从这些节点考试向下探索,搜索所走过的路径称为“引用链”,当一个对象到GC Roots没有任何引用链相连(用图论的话来说,就是从GC Roots到这个对象不可达)时,则证明此对象是不可用的。如下图中,对象object5、object6、object7虽然互有关联,但是它们到GC Roots是不可达的,所以它们将会被判定为是可回收的对象。
  image_1avqosqe6jtf15gi1h0mrlsu989.png-609.3kB
  
在Java语言中,可作为GC Roots的对象包括下面几种:

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

如何通过MyEclipse打印GC日志

发表于 2016-10-24   |   分类于 JVM   |  

第一步:右键项目或文件——Debug As——Debug Configurations。

第二步:点击Arguments,在VM arguments中填写-Xloggc:G:/gc.log,点击Apply,最后点击Debug。(可在自己选定目录中生成gc.log,在这里我选择的是G盘目录下)

image_1avqnnobn15sg19n915ufiabafu9.png-66.6kB

第三步:进入G盘,这时候会看到在G盘中生成了文件“gc.log”,用记事本打开即可查看该文件运行的GC日志。

image_1avqnv445kpr1rn61q161b3q1vlnm.png-61.6kB

通过JNI实现Java与C++的通信

发表于 2016-10-20   |   分类于 JNI   |  

  JNI是Java Native Interface的缩写,它提供了若干的API实现了Java和其他语言的通信(主要是C&C++)。使用Java与本地已编译的代码交互,通常会丧失平台可移植性。但是,有些情况下这样做是可以接受的,甚至是必须的。例如,使用一些旧的库,与硬件、操作系统进行交互,或者为了提高程序的性能。下面我们实现一个简单的例子,通过JNI调用C++程序,输出“Hello world!”。   

1、创建一个Java类,定义一个本地方法(包名为util,类名为HelloWorld):

public native void helloWorld();

我们可以看到,这个方法只有声明,因为它是在本地被实现的。

2、在DOS窗口中进入文件src目录,输入命令“javah util.HelloWorld”,其中util为包名,HelloWorld为类名,这会在src目录下生成一个头文件util_HelloWorld.h,用记事本打开它我们可以看到:

/* DO NOT EDIT THIS FILE - it is machine generated */
#include <jni.h>
/* Header for class util_HelloWorld */

#ifndef _Included_util_HelloWorld
#define _Included_util_HelloWorld
#ifdef __cplusplus
extern "C" {
#endif
/*
 * Class:     util_HelloWorld
 * Method:    helloWorld
 * Signature: ()V
 */
JNIEXPORT void JNICALL Java_util_HelloWorld_helloWorld
  (JNIEnv *, jobject);

#ifdef __cplusplus
}
#endif
#endif

其中,
JNIEXPORT void JNICALL Java_util_HelloWorld_helloWorld
(JNIEnv , jobject); 是对本地方法的声明,*在C++程序中定义的方法必须与这个声明完全一致,否则可能导致无法加载本地方法。

3、现在我们开始编写C++本地代码,并生成DLL动态链接库文件。
如果使用VC6.0,则步骤为:文件——新建——Win32 Dynamic-Link Library,在这里我创建了一个名为util的工程,然后添加上面生成的头文件到该工程中,然后创建一个源文件(我在这里命名为MyDLL),include该头文件,并实现头文件中声明的函数:

#include<iostream.h>
#include"util_HelloWorld.h"
JNIEXPORT void JNICALL Java_util_HelloWorld_helloWorld(JNIEnv *, jobject);
{
cout<<"hello world!"<<endl;
}

由于util_HelloWorld.h中引入了jni.h,所以我们需要进入JDK安装目录,将include目录下的jni.h和include/win32目录下的jawt_md.h、jni_md.h复制到VC6.0目录下的include目录中。现在我们就可以构建该工程了,直接按F7,就会在该工程文件下的Debug目录中生成DLL文件(我目录下生成的文件名为util.dll)。

4、现在我们将上面得到的DLL文件导入到Java文件的根目录下,在Java程序中加载这个动态链接库文件,然后调用该本地方法:

public static void main(String[] args) {
        System.loadLibrary("util");
        new HelloWorld().helloWorld();
    }

便成功输出了“Hello World!”。

  注意,如果你是在64位的操作系统上安装的64位的JDK,那么在加载由VC6.0生成的DLL文件时会报错:Can’t load IA 32-bit .dll on a AMD 64-bit platform。由于VC6.0只能生成32位的DLL文件,我们就需要使用VS来生成64位的DLL文件。具体步骤为打开VS,这里我使用的是VS2012,点击文件——新建——项目——Visual C++——win32项目,输入项目名称,选择应用程序类型为DLL,点击完成,然后选择 生成——配置管理器,选择平台——新建——选择x64,按F7生成即可。(同样我们需要将jni.h、jawt_md.h、jni_md.h等头文件添加到VS目录下的VC/include中,且生成的DLL文件在项目文件下的x64/Debug中)。

Java中的散列存储

发表于 2016-10-17   |   分类于 Java基础   |  

  Java中散列存储的数据结构主要是指HashSet、HashMap、LinkedHashSet、LinkedHashMap以及HashTable等。要理解Java中的散列存储机制,那么我们必须先理解两个方法:equals()和hashCode()。关于equals()方法以及其与“==”关系操作符的区别,我们在另一篇文章中已经说明了。而对于hashCode(),它是在Object类中定义的一个方法:

public native int hashCode();

这是一个返回int值的本地方法,在Object类中没有被实现。这个方法主要被应用于使用散列的数据结构中,配合基于散列的集合一起正常运行,例如,在向一个容器(我们假设是HashMap)中插入一个对象时,怎样判断容器中是否已经存在该对象了呢?由于容器中的元素可能成千上万,使用equals()方法依次进行比较是非常低效的。散列的价值在于速度,它将键保存在某处,以便能够很快找到。存储一组元素最快的数据结构是数组,所以使用它来存储键的信息(注意是键的信息,而非键本身)。但是因为数组不能调整容量,因此就有一个问题:我们希望在Map中保存数量不确定的值,但是如果键的数量被数组的容量限制了,该怎么办呢?
  答案就是:数组不保存键本身,而是通过键对象生成一个数字,将其作为数组的下标,这个数字就是散列码(hashcode),由定义在Object中的、且可能由你的类覆盖的hashCode()方法生成。为解决数组容量被固定的问题,不同的键可以产生相同的下标,这种现象被称为冲突。于是,在容器中查询一个值的过程是:先通过hashCode()计算待插入对象的散列码,然后使用散列码查询数组。对于冲突的处理,常常是通过外部链接,即数组并不直接保存值,而是保存值的list,然后对list中的值进行线性查询,这部分查询自然会比较慢。但是,如果散列函数足够好的话,数组的每个位置就只有较少的值。因此,散列机制便可以快速地跳到数组的某个位置,只对很少的元素进行比较。这就是HashMap会如此快的原因,我们可以通过HashMap.put()方法体会到:

public V put(K key, V value) {
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        if (key == null)
            return putForNullKey(value);
        int hash = hash(key);
        int i = indexFor(hash, table.length);
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                e.recordAccess(this);
                return oldValue;
            }
        }

        modCount++;
        addEntry(hash, key, value, i);
        return null;
    }

其主要思想便是:在键不为空时,根据键对象获取到散列码hash,然后通过散列码得到数组的下标i。在table[i]所表示的list中进行迭代,通过equals()判断该键是否存在,如果存在,则用新的值更新旧的值,返回旧的值;否则将新的键值对添加到HashMap中。从这里可以看出,hashCode方法的存在是为了减少equals方法的调用次数,从而提高程序效率。
  这里我们需要注意到:hashCode()并不需要总是能够返回唯一的标识码,但是equals()方法必须严格地判断两个对象是否相同。

Java中“==”与equals()的区别

发表于 2016-10-17   |   分类于 Java基础   |  

  对于关系操作符“==”,《Java编程思想》中是这样描述的:“关系操作符生成的是一个boolean结果,它们计算的是操作数的值之间的关系”。这里的操作数的“值”值得我们注意。对于8种基本数据类型(boolean,byte,char,short,int,float,double,long),它们的变量直接存储的就是“值”。所以,我们用“==”对基本数据类型的变量进行比较时,实际比较的就是变量存储的值,例如:

public static void main(String[] args) {
        int a = 5, b = 5;
        System.out.println(a == b);
    }

很明显,程序将输出:true。但是,下面这段代码呢?

public static void main(String[] args) {
        Integer c = new Integer(5), d = new Integer(5);
        System.out.println(c == d);
    }

我们运行程序,发现输出的是:false。这是因为Integer并非Java中的基本数据类型,其变量c、d在Java中被称为指向对象的引用,其存储的“值”是对象在内存中的地址,而非值“5”本身。所以,c和d实际存储的分别是两个value值都为“5”的Integer对象的地址,这两个对象不在同一块内存空间,“==”比较的结果自然是false了。
  那么equals()方法比较的是什么呢?equals()是基类Object中定义的方法,在Object类中,equals()方法定义为:

public boolean equals(Object obj) {
        return (this == obj);
    }

这等价于“==”。其实,equals()方法的意义在于重写,否则对equals()的调用是没有意义的。例如,继承自Object的Integer类便重写了equals()方法:

public boolean equals(Object obj) {
        if (obj instanceof Integer) {
            return value == ((Integer)obj).intValue();
        }
        return false;
    }

该方法的意义是对value值进行比较,那么如果我们对于两个具有相同value值的Integer对象调用equals()进行比较:

public static void main(String[] args) {
        Integer c = new Integer(5), d = new Integer(5);
        System.out.println(c.equals(d));
    }

结果便是true。

Java中异常的限制

发表于 2016-09-19   |   分类于 Java基础   |  

  在Java中,发现错误的最理想时机是在编译阶段,即运行程序之前。但是,在编译期间,并不能保证所有的错误都被发现,余下的问题必须在运行期间解决。这就需要错误源能通过某种方式,把适当的信息传递给某个接收者——该接收者将知道如何正确处理这个问题。异常处理是Java中唯一正式的错误报告机制,并且通过编译器强制执行。本文略过了异常处理流程等基本知识,直接讨论Java中异常的限制。
  异常的限制,这是指:当覆盖方法的时候,只能抛出在基类方法的异常说明里列出的那些异常。这样的限制可以保证,当派生类对象向上转型为基类对象时,它的某个方法不会抛出基类方法未声明的异常。下面的例1演示了这种限制:
  
例1:

class BaseballException extends Exception {}
class Foul extends BaseballException {}
class Strike extends BaseballException {}

abstract class Inning {
  public Inning() throws BaseballException {}
  public void event() throws BaseballException {
  }
  public abstract void atBat() throws Strike, Foul;
  public void walk() {} 
}

class StormException extends Exception {}
class RainedOut extends StormException {}
class PopFoul extends Foul {}

interface Storm {
  public void event() throws RainedOut;
  public void rainHard() throws RainedOut;
}

public class StormyInning extends Inning implements Storm {
  public StormyInning()
    throws RainedOut, BaseballException {}
  public StormyInning(String s)
    throws Foul, BaseballException {}
  //! void walk() throws PopFoul {} //Compile error
  public void rainHard() throws RainedOut {}
  public void event() {}
  public void atBat() throws PopFoul {}
  public static void main(String[] args) {
    try {
      StormyInning si = new StormyInning();
      si.atBat();
    } catch(PopFoul e) {
      System.out.println("Pop foul");
    } catch(RainedOut e) {
      System.out.println("Rained out");
    } catch(BaseballException e) {
      System.out.println("Generic baseball exception");
    }
    try {
      Inning i = new StormyInning();
      i.atBat();
    } catch(Strike e) {
      System.out.println("Strike");
    } catch(Foul e) {
      System.out.println("Foul");
    } catch(RainedOut e) {
      System.out.println("Rained out");
    } catch(BaseballException e) {
      System.out.println("Generic baseball exception");
    }
  }
}

  我们可以看到,Inning类中的构造器和event()函数都声明抛出了异常,但实际上在方法体中并没有抛出,这是可行并且合理的,因为采用这种方式可以强制用户去捕获可能在覆盖后的event()方法中抛出的异常,这同样适用于抽象方法,如atBat()。
  我们注意观察一下接口Storm,它定义了一个和Inning类中event()方法同名的一个方法,而且参数列表也相同,并且该方法抛出了一个在Inning类中event()方法没有抛出的异常RainOut,那么如果StormyInning既继承了Inning类,又实现了Storm接口,这两个event()方法是不能互相改变彼此的异常接口的。换句话说,StormyInning中的event()方法不能声明抛出Inning类和Storm接口中event()方法声明的任何异常。
  但是,这种异常限制却对构造器不起作用。你可以发现,StormyInning类的构造器中声明抛出了Inning类构造器没有抛出的异常。但是,由于在创建子类对象时会自动调用基类的构造器,所以子类的构造器的异常声明必须包含基类构造器的异常声明。同时我们可以证明,派生类的构造器不能捕获它的基类构造器所抛出的异常,因为在派生类构造器中,super()函数的调用必须放在第一行,这便不能将try-catch语句插入。
  在StormyInning中,walk()函数不能通过编译,这是因为它声明抛出了在Inning.walk()中没有声明抛出的异常。如果编译器允许这么做的话,那么我们在调用Inning.walk()的时候不用做异常处理,但是在我们执行向上转型Inning inning = new StormyInning()后,再调用inning.walk()时,这个方法便可能抛出异常,但我们却并没有捕获。
  覆盖后的event()方法并没有声明抛出任何异常,这说明派生类中的方法可以不声明抛出异常。这样做是可行的,因为这并不会出现上一段所描述的情况。类似的情况出现在StormyInning.atBat()上,它抛出了异常PopFoul,而Inning.atBat()抛出了异常Foul,这么做可行是因为,PopFoul是Foul的子类,能捕获Foul异常的异常处理程序肯定也能捕获到PopFoul异常。
  最后我们注意到main(),如果我们使用的是StormyInning对象的话,那么编译器只会强制要求你捕获StormyInning类所抛出的异常。但是如果我们使用的是通过向上转型得到的基类Inning对象时,那么编译器就会强制你捕获基类Inning所抛出的异常。所有这些限制都是为了能产生更为强壮的异常处理代码。

Java中构造器内部的多态方法的行为

发表于 2016-09-12   |   分类于 Java基础   |  

  这篇文章主要讨论的是,若在一个构造器中调用正在构造的对象的某个动态绑定的方法时会出现的情况。在此之前,我们需要知道构造器是如何在复杂的层次结构中运作的,尽管构造方法并不具有多态性,因为它们实际上是static方法,只不过是隐式声明的static。

复杂层次结构中构造器的调用顺序

  基类的构造器总是在导出类的构造过程中被调用,而且按照继承层次逐渐向上链接,以使每个基类的构造器都能得到调用。这样做是因为,在Java类中,我们通常将字段设置为private类型,也就是说,在子类中通常无法直接访问基类的字段,那么只有通过调用基类的构造器才能对基类对象的元素进行初始化,那么就必须保证所有的构造器都得到调用,这样才能正确地构造完整的对象。下面的例1展示了包含有组合与继承关系的各类中构造器的调用顺序:
  
例1:

class Meal {
  Meal() { System.out.println("Meal()"); }
}

class Bread {
  Bread() { System.out.println("Bread()"); }
}

class Cheese {
  Cheese() { System.out.println("Cheese()"); }
}

class Lettuce {
  Lettuce() { System.out.println("Lettuce()"); }
}

class Lunch extends Meal {
  Lunch() { System.out.println("Lunch()"); }
}

class PortableLunch extends Lunch {
  PortableLunch() { System.out.println("PortableLunch()");}
}

public class Sandwich extends PortableLunch {
  private Bread b = new Bread();
  private Cheese c = new Cheese();
  private Lettuce l = new Lettuce();
  public Sandwich() { System.out.println("Sandwich()"); }
  public static void main(String[] args) {
    new Sandwich();
  }
}

  例1反映了关于Meal、Lunch和Sandwich之间三层继承关系(不包含Object类),以及Bread、Cheese和Lettuce与Sandwich的组合关系。在main函数中创建一个Sandwich对象后,我们就可以看到输出结果:
  
sandwich1.png-14.5kB

  这说明在复杂的层次结构中构造器的调用遵从的顺序为:
  
  1、调用基类构造器。这个步骤会不断地反复递归下去,首先是构造这种层次结构的根,然后是下一层导出类,等等,直到最低层的导出类;
  2、按声明的顺序调用成员的初始化方法;
  3、调用导出类构造器的主体。
     

构造器内部的多态方法的行为

  那么,现在我们回到文章开头提到的问题,若在一个构造器中调用正在构造的对象的某个动态绑定的方法,会出现什么情况呢?我们知道,动态绑定(或后期绑定)的方法的调用是在运行时才决定的,因为对象在程序运行之前无从得知它自己到底是基类的对象,还是某个导出类的对象。如果在基类的构造器内部调用某个动态绑定方法,该方法是被导出类覆盖的,那么这便可能产生难以预料的后果,因为该导出类的对象还未被完全构造,但它的方法却被调用了。我们可以通过例2看到问题所在:

例2:

class Glyph {
   void draw() { System.out.println("Glyph.draw()"); }
   Glyph() {
     System.out.println("Glyph() before draw()");
     draw();
     System.out.println("Glyph() after draw()");
   }
 }    

 class RoundGlyph extends Glyph {
   private int radius = 1;
   RoundGlyph(int r) {
     radius = r;
     System.out.println("RoundGlyph.RoundGlyph(), radius = " + radius);
   }
   void draw() {
     System.out.println("RoundGlyph.draw(), radius = " + radius);
   }
 }    

 public class PolyConstructors {
   public static void main(String[] args) {
     new RoundGlyph(5);
   }
 }

运行结果:

sandwich2.png-14.4kB

  在运行结果中,我们看到,基类Glyph的构造器中调用了被子类RoundGlyph覆盖的draw()方法,并且输出了radius=0,这显然是一个错误,因为这个“0”是在其他任何事物发生之前,系统分配给对象的存储空间的初始值——二进制的零,而非我们想要设定的初始值“1”。这是因为,我们在创建子类(RoundGlyph)对象时会先调用基类(Glyph)的构造器构造基类对象,而在基类的构造器中却调用了被子类覆盖的动态绑定的方法(draw()),而这个方法所操纵的可能是子类中的还未进行初始化的成员(radius),这便会招致灾难,尽管编译器并没有报错。
  因此,在编写构造器中有一条有效的准则:“用尽可能简单的方法使对象进入正常状态;如果可以的话,避免调用其他方法”。在构造器中,唯一能够安全调用的是基类中的final方法(包括private方法),因为这些方法不能被子类覆盖,也就不会出现上述的问题。

  
参考书籍:《Java编程思想》

static方法能否被重写

发表于 2016-09-11   |   分类于 Java基础   |  

  在Java中,子类可继承父类中的方法,而不需要重新编写相同的方法。但有时子类并不想原封不动地继承父类的方法,而是想作一定的修改,这就需要采用方法的重写(Override)。方法重写又称方法覆盖。
  在《Java编程思想》中提及到:

  “覆盖”只有在某方法是基类的接口的一部分时才会出现。即,必须能将一个对象向上转型为它的基本类型并调用相同的方法。

  那么,我们便可以据此来对static方法能否被重写的问题进行验证:
  
例1:   

class StaticSuper{
    public static String staticGet(){
        return "Base staticGet()";
    }
    public String dynamicGet(){
        return "Base dynamicGet()";
    }
}

class StaticSub extends StaticSuper{
    public static String staticGet(){
        return "Derived staticGet()";
    }
    public String dynamicGet(){
        return "Derived dynamicGet()";
    }
}

public class StaticPolyMorphism {
    public static void main(String[] args) {
        StaticSuper sup = new StaticSub();
        System.out.println(sup.staticGet());
        System.out.println(sup.dynamicGet());
    }
}

  在例1中,如果基类StaticSuper中的static方法staticGet()在子类StaticSub中被重写了,那么sup.staticGet()返回的结果应该是“Derived staticGet()”,实际上结果是如何呢?运行程序后,我们看到输出是:
  
  Base staticGet()
  Derived dynamicGet() 
  
  这说明,非静态方法dynamicGet()的确在子类中被重写了,而静态方法staticGet()却没有。对于这一点,我们也可以通过在子类方法上添加@Overide注解进行验证:
  override.png-18.6kB
  
  如图所示,在子类中的静态方法staticGet()上添加@Override注解会导致编译报错:The method staticGet() of type StaticSub must override or implement a supertype method(StaticSub类的staticGet()方法必须覆盖或者实现一个父型的方法),而非静态方法dynamicGet()则无此报错信息,这也就印证了我们上面的推论。其实,在Java中,如果父类中含有一个静态方法,且在子类中也含有一个返回类型、方法名、参数列表均与之相同的静态方法,那么该子类实际上只是将父类中的该同名方法进行了隐藏,而非重写。换句话说,父类和子类中含有的其实是两个没有关系的方法,它们的行为也并不具有多态性。正如同《Java编程思想》中所说:“一旦你了解了多态机制,可能就会认为所有事物都可以多态地发生。然而,只有普通方法的调用可以是多态的。”这也很好地理解了,为什么在Java中,static方法和final方法(private方法属于final方法)是前期绑定,而其他所有的方法都是后期绑定了。

  

finalize()方法的使用

发表于 2016-09-06   |   分类于 Java基础   |  

  Java定义了finalize()方法,用于在垃圾收集器将对象从内存中清除出去之前做必要的清理工作。在《Java编程思想》一书中,有这样一段话:

  Java有垃圾回收器负责回收无用对象占据的内存资源。但也有特殊情况:假定你的对象(并非使用new)获得了一块“特殊”的内存区域,由于垃圾回收器只知道释放那些经由new分配的内存,所有它不知道如何释放该对象的这块“特殊”内存。为了应对这种情况,Java允许在类中定义一个名为finalize()的方法。它的工作原理“假定”是这样的:一旦垃圾回收器准备好释放对象占用的存储空间,将首先调用其finalize()方法,并且在下一次垃圾回收动作发生时,才会真正回收对象占用的内存。所以要是你打算用finalize(),就能在垃圾回收时刻做一些重要的清理工作。

- 和析构函数的区别

  finalize()方法与C++中的析构函数有几分相似之处,但是二者又不完全等同。在C++中,既有基于栈区的局部对象,又有基于堆区的全局对象,其中栈区对象的内存由编译器自动分配和释放,而堆区对象的内存则需要由程序员来分配和释放;而在Java中,由于所有对象都在堆内存区,废弃对象所占用的内存都由Java中的垃圾回收器来回收,Java的设计者认为不需要析构函数的存在,这关键在于Java的“不需要人工参与”的垃圾回收机制:
  当你不再需要使用一个对象,或者说,当不存在引用指向某一个对象时,Java虚拟机(JVM)会将该对象标记为释放状态。但是,这并不意味着JVM一定会释放这些对象所占用的内存,或许只有到程序濒临存储空间被用完的那一刻,对象占用的空间才会得到释放,即使你调用System.gc()方法也不见得一定有效,因为这只是给JVM一个建议,而非命令。
  这便造成了:在C++中,当要销毁某个对象时,必须调用该对象的析构函数,这是可预期的;而在Java中,对象却并非总是被垃圾回收,这是不可预期的。      

- finalize()的用途

  垃圾回收器虽然可以自动回收不再被使用的对象的内存,但是它却也仅限于能够释放通过使用new而获得的存储区域。换句话说,若要释放通过某种创建对象方式以外的方式为对象分配的存储空间,这便是finalize()的用处所在。这主要发生在使用“本地方法”的情况下,本地方法是一种在Java中调用非Java代码的方式。在非Java代码中,也许会调用C的malloc()函数系列来分配存储空间,而且除非调用了free()函数,否则存储空间将得不到释放,从而造成内存泄漏。当然,free()是C和C++中的函数,所以需要在finalize()中用本地方法调用它。但是,你不可能知道何时——甚至是否——finalize()被调用,因此你不应该依赖于使用finalize方法来回收某些短缺的资源,而必须创建其他的“清理”方法,并且明确地调用它们。
  不过,finalize()还有一个并不依赖于每次都要被调用的用法,那就是对象终结条件的验证。通俗地讲就是:某个对象要么不被销毁,一旦要被销毁,就应该满足某种终结条件。finali()可以用来检查要被销毁的对象是否满足这种条件。我们通过下面这个例子来进行说明:

class Book {
   boolean checkedOut = false;
   Book(boolean checkOut) {
    checkedOut = checkOut;
   }
  void checkIn() {
   checkedOut = false;
  }
  public void finalize() {
   if(checkedOut)
    System.out.println("Error: checked out");
   // Normally, you'll also do this:
   // super.finalized();
  }
  }
  public class TerminationCondition {
   public static void main(String[] args) {
    Book novel = new Book(true);
    // Proper cleanup:
    novel.checkIn();
    // Drop the reference, forget to clean up:
    new Book(true);
    // Force garbage collection & finalization:
    System.gc();
   }
  }

  在该例中,任何Book对象在被销毁之前,都应该被被签入(check in),这便是终结条件。如果由于程序员的失误导致某本书未被签入就使得该对象被销毁的话,finalize()就会发现这一缺陷。

1…567
Alan

Alan

Better late than never.

61 日志
11 分类
74 标签
GitHub weibo
© 2016 Alan
由 Hexo 强力驱动
主题 - NexT.Pisces