Java编程思想学习笔记(九)

5.5清理:终结处理和垃圾回收

大多数人都知道初始化的重要性,但常常会忘记同样重要的清理工作。但是在进行程序编写时,将一个对象用完之后直接不管并非总是安全的。Java的垃圾回收器负责回收无用对象占用的内存资源,但是这并不是万能的:假定你的对象通过非new的方法获得一块特殊的内存区域,由于垃圾回收器只知道释放那些经new分配的内存,所以他不知道怎么处理这块特殊的内存区域。针对这种情况,Java允许在类中定义一个名为finalize()的方法,并且在下一次垃圾回收工作发生时才会真正回收对象占用的内存。通过调用finalize(),就可以在垃圾回收时做一些重要的清理工作。

这看起来和C++的析构函数有点像,但是二者之间还是存在着区别,C++销毁对象必须用到析构函数,但是Java的对象可能不会被垃圾回收,所以说垃圾回收≠析构。

这也就是说,当我们不再需要某个对象了,如果我们需要执行某些动作,那么需要我们自己去做,而不是垃圾回收器来做。Java并没有析构函数的概念,类似析构函数的清理工作,需要我们编写一个执行清理函数的普通方法。

只要程序没有濒临存储空间用完的那一刻,对象占用的空间也总得不到释放,程序执行结束,并且垃圾回收器一直没有释放你创建的对象的存储空间,那么随着程序的退出,这些资源也会全部返还给OS,垃圾回收器本身也有开销,若是不使用他,也就不用支付这部分开销了。

5.5.1finalize()的用途

首先要知道:垃圾回收只和内存有关

这也就是说使用垃圾回收器的唯一原因是回收程序不再使用的内存,所以对于和垃圾回收相关的任何行为(包括finalize()在内)都必须和内存及其回收有关。

但是这并不意味着若对象中含有其他对象,finalize()就应该明确释放这些对象。无论对象如何创建,垃圾回收器都会负责释放对象占据的所有内存,这就将对finalize()的需求限制到了一种特殊情况,即:通过某种创建对象方式之外的方式为对象分配了存储空间。但是之前说过Java中一切皆对象,又何来非创建对象方式之外的分配方式呢?

看来之所以要有finalize(),是因为在分配内存时使用了类似于C的方法,而并不是Java本身的通常做法。这种情况通常发生在使用本地方法的情况下。本地方法是一种在Java中调用非Java代码的方式,目前只支持C和C++,但是他们又可以调用其他语言的代码。举个例子,本地方法调用了C的malloc函数分配了空间,若没有调用free函数,那么空间就不会被释放,从而造成内存泄漏,所以我们需要在finalize()中调用free。

5.5.2你必须实施清理

要清理一个对象,用户必须在需要清理的时刻调用执行清理动作的方法,这和C++的析构函数的概念有所不同,在C++中,所有对象都会被或者说都应该被销毁。若在C++中创建了一个局部对象(也就是在堆栈上创建的,这在Java中是不被允许的),那么此时的销毁动作是以其对应的"}"为便捷的,在这个局部对象作用域的末尾,若对象是通过new创建的,那么程序员调用C++的delete操作符(Java没有这个命令)时,就会调用析构函数,但是若程序员忘记delete,那么析构函数永远不会被调用,进而造成内存泄漏,这种缺陷很难追踪,这也是C++转Java的一个主要因素。

Java的处理方式是直接禁止创建局部对象,必须使用new关键字创建对象,释放空间也不是用delete,而是由垃圾回收器来帮助你释放,正是由于垃圾收集机制的存在,使得Java没有析构函数,但是实际上垃圾收集机制并不能完全取代析构函数。

无论是垃圾回收还是finalize,都不保证一定会发生,若JVM没有面临内存耗尽的情况,他是不会浪费时间去执行垃圾回收来回收内存的

5.5.1终结条件

通常不能指望finalize(),必须创建其他的清理方法,并且明确地调用他们,但是这并不意味着finalize就没什么用了,他还有一个有趣的用法:对对象终结条件的验证

当对某个对象不再感兴趣了,也就是说可以被清理了,这个对象应该处在某种状态下,使得其占用的内存可以被安全地释放。例如,对象代表一个被打开的文件,那么在对象被回收钱程序员应该关闭这个文件。这类由对象中存在没被适当清理的部分导致的隐晦缺陷可以由finalize来发现。

class Book{
    boolean checkedOut = false;
    Book(boolean checkOut){
        checkedOut = checkOut;
    }
    void checkIn(){
        checkedOut = false;
    }
    protected void finalize(){
        if(checkedOut)
            System.out.println("Error:checked out");
    }
}
public class UseFinalize {
    public static void main(String[] args){
        Book novel = new Book(true);
        novel.checkIn();
        new Book(true);
        System.gc();
    }
}

本例的终结条件是:所有的book对象在被当做垃圾回收之前都应该checkin,但是在main方法中,new了一个匿名对象没有被checkin,若没有finalize来验证这个终结条件,这个缺陷就很难被发现。

System.gc()用于强制进行终结动作。

5.5.4垃圾回收器如何工作

之前提到过,在堆上创建对象的代价很高,然而,Java的垃圾回收器对于提高对象的创建速度有明显的效果。听起来可能有点奇怪,存储空间的释放会影响存储空间的分配效率,但是这确实是某些JVM的工作方式。这就意味着Java堆分配空间的速度可以媲美其他从堆栈上分配空间的语言。

举个形象的例子:C++里的堆是一个院子,每个对象都负责管理自己的地盘,一段时间之后,对象可能被销毁,但是地盘必须加以重用;而在某些JVM中,堆的实现则截然不同,它更像一个传送带,每分配一个对象,它就向前移动一格,这使得对象存储空间的分配速度非常快。Java的堆指针只是简单地移动到尚未分配的区域,这种指针移动的开销很小速度很快,可以媲美C++在堆栈上分配空间的效率。

但是实际上,Java的堆并不是完全像传送带一样工作,那样会导致频繁的内存页面调度,页面调度会显著地影响性能,最终创建的对象过多,内存耗尽。Java解决这个问题的方法就是垃圾回收器,它一面回收空间,一面使堆中的对象紧凑排列,这样堆指针就可以很容易移动到更靠近传送带的开始处,也就避免了页面错误。通过垃圾回收器对对象重新排列,实现了一种高速的、有无限空间可分配的堆模型。

在理解Java的垃圾回收之前,先了解一下其他系统的垃圾回收机制:

  1.引用计数:简单但慢速的垃圾回收技术。每个对象含有一个引用计数器,当有引用链接至对象时,引用计数+1,相应的,引用离开作用域时,引用计数-1。虽然管理计数的开销不大,但是这个开销贯穿整个程序生命周期。垃圾回收器需要在含有全部对象的列表上遍历,当发现某个对象的引用计数为0时,释放其空间(但是引用计数模式经常会在计数值变为0时立刻释放对象)。这个方法的缺陷在于:若对象之间存在循环引用,那么就会出现计数不为0,但是这个对象应该被释放的情况。引用计数经常被用于解释说明垃圾收集的工作方式,但是似乎从来没被实装到任何一种JVM中。

  在一些更快的模式中,垃圾回收器并非基于引用计数,他们依据的思想是:对任何“活”的对象,一定可以追溯到其存在在堆栈或静态存储区的引用。这个引用链条可能会穿过数个对象,由此,若从堆栈和静态存储区开始,遍历所有的引用,就能找到所有活的对象。对于发现的每个引用,必须追踪他所引用的对象,然后是这个对象包含的所有引用,如此反复进行,直到 根源于堆栈和静态存储去的引用 所形成的网络全部被访问为止。这样就解决了循环、交互引用对象组的问题。

  2.在这种方式下,JVM将采用一种自适应的垃圾回收技术(至于如何找到活的对象,各个JVM各有实现方式)。有一种做法名为停止-复制(stop-and-copy),也就是先将程序停止运行,然后将所有存活的对象从当前的堆复制到另一个堆,没被复制的就是垃圾,同时,复制的时候保证这些存活下来的对象紧密排列,然后再按照之前提到的方法直接分配存储空间。

  将对象搬家的时候,所有指向他的引用都必须被修正,位于堆或静态存储区的引用可以直接修正,但是可能还存在其他的指向这个对象的引用,他们就需要在遍历的过程中才能被找到。

  这种复制的方式效率会降低。首先,需要至少两个堆才能实现这个搬家的操作;其次,复制也会引起问题,程序进入稳定状态之后可能不会大量的产生垃圾,但是复制式回收器仍会将所有内存来回地复制,这很浪费。面对这种情况,有的JVM会先进行检查,看是否产生了垃圾(也就是“自适应”),这种模式被称为标记-清扫(mark-and-sweep),对一般用途而言,这种模式速度很慢,但是当你知道程序不会产生那么多垃圾,甚至不产生垃圾之后,他的速度就快起来了。

  另外,从停止-复制的表面意思来看,这种垃圾回收工作不是在后台进行的,相反,他会将程序暂停。(Sun公司的文档中,许多参考文献将垃圾回收视为低优先级的后台进程,但事实上垃圾回收器在Sun公司的早期版本的JVM中并不是这么实现的)在可用内存数量较低时,Sun版本的垃圾回收器会暂停程序的运行以进行垃圾回收工作,同样标记-清扫也会将程序暂停。

  3.大对象复制开销大,怎么解决?JVM中,内存是以块为单位分配的,若对象较大,那么就会独自占据一个完整的块,严格来说,停止-复制要求释放旧有对象之前先将对象复制到新的堆,这会导致大量的内存复制行为。有了块之后,垃圾回收器就可以往废弃的块里拷贝对象,每个块都用代数(generation count)来记录是否存活。某个块被引用,代数增加,垃圾回收器对上次回收动作之后新分配的块进行整理。这对于处理大量短命的临时对象很有帮助。垃圾回收器会定期进行完整的清理动作,大型的对象不会被复制,只是会增加其代数,内含若干小对象的块会被复制并紧凑。这种方式可以被成为分代的,和上文提到的两种方式可以兼容。

  4.提升速度的附加技术:JVM中有很多附加技术用于提升速度,尤其是和加载器操作有关的,被成为即时编译器的技术(Just-In-Time,JIT)。这种技术会将程序全部或部分翻译成本地机器码(这本来是JVM的工作),程序运行速度得以提升。当需要装载某个类(一般是要创建这个类的第一个对象)时,编译器会先找到其.class 文件,将这个类的字节码装入内存。此时,有两周方案可以选择:

  一是让即时编译器编译所有代码,但是这个做法有两个缺陷:加载动作散落在整个    程序的生命周期中,累加起来花费的时间反而更多;会增加可执行代码的长度,这会导   致频繁的页面调度;

  另一种做法是惰性评估(lazy evaluation即时编译器只在必要的时候才编译代码,这样从不会被执行的代码也许压根就不会被编译。

 

2.15上传 P125

 

posted @ 2021-02-15 14:48  aLieb  阅读(77)  评论(0编辑  收藏  举报