专栏首页JavaEdgeJava编程思想第五版精粹(五)-初始化和清理(中)

Java编程思想第五版精粹(五)-初始化和清理(中)

Finalization和 GC

1

初始化很重要,而清理工作也同样重要。毕竟,谁会去清理一个 int?但使用完一个对象就不管了,这并非总是安全的操作。

1.1

特殊场景

创建的对象不是通过 new 来分配内存,而GC只懂释放用 new 创建的对象的内存。为了处理这种情况,Java 允许在类中定义一个名为 finalize() 的方法。

1.2

工作原理

当GC准备回收时,首先会调用 finalize() 方法,并在下一轮的gc发生时,才会真正回收对象占用的内存。所以如果你打算使用 finalize() ,就能在GC时先做一些重要的清理工作。

finalize() 是一个潜在危险,因为一些程序员(尤为 C++)会把它误认为析构函数。

所以有必要明确区分:在 C++ 中,对象总是被销毁的,而在 Java 中,对象并非总是被,或者换句话说:

  1. 对象可能不被gc
  2. gc不等于析构

在不再需要某个对象前,如果必须执行某些动作,你得自己去做。Java 没有析构器或类似概念,必须得自己手动创建一个普通方法完成清理。

例如,对象在创建的过程中会将自己绘制到屏幕。如果不明确地从屏幕上将其擦除,它可能永远得不到清理。如果在 finalize() 方法中加入某种擦除功能,那么当GC时,finalize() 方法被调用(不保证一定会发生),图像就会被擦除,要是GC没发生,图像仍会保留下来。

只要程序没有将内存用尽,对象所占空间就永远得不到释放。如果程序执行完成,而GC一直没有释放你创建的任何对象的内存,则当程序退出时,那些资源会全部交还给OS。这个策略是恰当的,因为gc本身也有开销,要是不使用,就不用支付这开销。

1.3

finalize()的作用

如果不能将 finalize() 作为通用的清理方法,那它有什么用?

记住的第3点:

  1. gc只与内存有关 使用gc的唯一原因就是回收程序不再使用的内存。所以对于与gc有关的任何行为(尤其是 finalize() ),也必须仅和内存及其回收有关。

但这并不代表如果对象中包含其他对象,finalize() 方法就该明确释放它们。

无论对象如何创建的,GC都会负责释放对象所占用的所有内存。

这就将对 finalize() 的需求限制到一种特殊情况:通过某种创建对象方式之外的方式为对象分配了存储空间。而Java 中万物皆对象,这种情况怎么可能发生呢?

可以猜测之所以有 finalize() ,是因为在分配内存时采用了类似 C 语言中的机制。这种情况主要发生在使用 native方法 的情况。本地方法目前只支持 C 和 C++,但是它们却可以调用其他语言写的代码,所以实际上还是可以高效地调用任何代码。

在非 Java 代码中,也许会调用 C 的 malloc() 函数家族来分配存储空间,而且除非调用 free() 函数,不然存储空间永远得不到释放,造成内存泄露。但是,free() 是 C 和 C++ 中的函数,所以你需要在 finalize() 方法里用native方法调用它。

所以其实我们并不会过多使用 finalize() 方法。它确实不是普通清理的合适场所。那么问题又来了,普通的清理在哪里执行?

1.4

必须执行清理

要清理一个对象,用户必须在需要清理时调用执行清理方法。这听上去简单粗暴,但与 C++ 的析构函数抵触。在 C++ 中,所有对象都应该被销毁。如果在 C++ 中创建了一个局部对象(比如栈,在 Java 中不可能),销毁发生在右花括号边界的、此对象作用域的末尾。

如果对象是用 new 创建的(似于 Java 中),那么当SE调用 C++ 的 delete 操作符时(Java 中不存在),就会调用相应的析构函数。忘记调用 delete,就永远不会调用析构函数,导致内存泄露。

相反,在 Java 中,没有用于释放对象的 delete,因为GC会帮助你释放。甚至可以肤浅地认为,正是由于GC的存在,使得 Java 没有析构函数。然而,随着学习的深入,会明白GC的存在并不能完全替代析构函数(而且绝对不能直接调用 finalize(),所以这也不是一种解决方案)。如果希望进行除释放存储空间之外的清理工作,还是得明确调用某个恰当的 Java 方法:这就等同于使用析构函数了,只是没有它方便。

记住,无论gc还是finalize,都不保证一定发生。如果JVM并未面临内存耗尽的情形,它可能不会浪费时间执行gc。

1.5

终结条件

不要指望 finalize() ,必须创建其他的"清理"方法,并显式调用。

所以finalize() 仅仅对大部分SE永远用不到的一些晦涩内存清理有用。

finalize() 还有一个有趣用法,它不依赖于每次都要对 finalize() 进行调用,这就是对象终结条件的验证。

当对某对象不再感兴趣——也就是将被清理,这个对象应该处于某种状态,这种状态下它占用的内存可以被安全释放。

例如,如果对象代表一个打开的文件,在对象被gc前SE应该关闭该文件。只要对象中存在没有被适当清理部分,程序就存在bug。finalize() 可以用来发现这个情况,尽管它并不总是被调。如果某次 finalize() 使得 bug 被发现,那么就可以据此找出问题所在。

示例如下:

// Using finalize() to detect a object that
// hasn't been properly cleaned up

import onjava.*;

class Book {
    boolean checkedOut = false;

    Book(boolean checkOut) {
        checkedOut = checkOut;
    }

    void checkIn() {
        checkedOut = false;
    }

    @Override
    protected void finalize() throws Throwable {
        if (checkedOut) {
            System.out.println("Error: checked out");
        }
        // Normally, you'll also do this:
        // super.finalize(); // Call the base-class version
    }
}

public class TerminationCondition {

    public static void main(String[] args) {
        Book novel = new Book(true);
        // 适当地清理:
        novel.checkIn();
        // Drop the reference,忘记清理:
        new Book(true);
        // 强制 gc & finalization:
        System.gc();
        new Nap(1); // 延迟 1s
    }

}

终结条件是:所有Book对象在被gc前被checkIn。但 main() 中,有一本书没有checkIn。要是没有 finalize() 方法验证终结条件,很难发现这个 bug。

在这里,使用重写注解告诉编译器这不是偶然地重定义 Object 类中的 finalize() 方法——程序员知道自己在做什么。编译器确保你没有拼错方法名,而且确保那个方法存于父类。注解也是对读者的提醒。

你应该总是假设父类的 finalize() 也做一些重要的事情,使用 super 调之(只是当前把它注释了而已)。

1.6

垃圾回收器工作原理

垃圾回收器能显著提高对象的创建速度。存储空间的释放影响了存储空间的分配?这确实是某JVM的工作方式。Java 从堆空间分配的速度可以和其他语言在栈上分配空间的速度媲美。

可以把 C++ 里的堆想象成一个院子,里面每个对象都负责管理自己的地盘。一段时间后,对象可能被销毁,但地盘必须复用。

在某些JVM,堆更像是传送带,每分配一个新对象,它就向前移动。这意味着对象存储空间的分配速度特别快。Java 的"堆指针"只是简单地移动到尚未分配的区域,所以它的效率与 C++ 在栈上分配空间的效率相当。(在簿记方面还有少量额外开销,但是这部分开销比不上查找可用空间)

Java 堆并非完全像传送带那样工作。要是那样,势必导致频繁内存页面调度——将其移进移出硬盘,因此会显得拥有比实际存在更多的内存。页面调度会显著影响性能。最终,在创建了足够多的对象后,内存资源被耗尽。

秘密就在于GC。它工作时,一边回收内存,一边使堆中对象紧凑,这样"堆指针"就很容易移动到更靠近传送带的开始处,就尽量避免了页面错误。GC通过重排对象,实现了一种高速的、有无限空间可分配的堆模型。

1.6.1

引用计数

一种简单但很慢的gc机制。每个对象中含有一个引用计数器:

  • 每当有引用指向该对象时,引用计数加 1
  • 当引用离开作用域或被置为 null 时,引用计数减 1

因此,管理引用计数开销不大但是在程序的整个生命周期频繁发生。垃圾回收器会遍历含有全部对象的列表,当发现某个对象的引用计数为 0 时,就释放其所占空间(但是,引用计数经常会在计数为 0 时立即释放对象)。

  • 缺点 如果对象之间存在循环引用,那么它们的引用计数都不为 0,就会出现应该被回收但无法被回收的情况。对垃圾回收器而言,定位这样的循环引用所需的工作量极大。引用计数常用来说明垃圾回收的工作方式,但从未被应用于任何一种JVM

在更快的策略中,依据的是:对于任意"活"对象,一定能追溯到其存活在栈或静态存储区中的引用。该链可能会穿过若干层对象。由此,如果从栈或静态存储区出发,遍历所有的引用,将会发现所有"活"的对象。

对于发现的每个引用,必须追踪它所引用的对象,然后是该对象包含的所有引用,如此反复进行,直到访问完"根源于栈或静态存储区的引用"所形成的整个网络。你所访问过的对象必须都是"活"的。

这也解决了对象间循环引用问题,这些对象不会被发现,因此也就被自动回收。

JVM采用一种自适应的垃圾回收技术。至于如何处理找到的存活对象,取决JVM 具体实现。其中有一种做法叫做

1

停止-复制(stop-and-copy)

先暂停程序的运行(所以不属于后台回收),然后将所有存活对象从当前堆复制到另一个,遗留的就是垃圾对象。被复制到新堆时,对象是一个挨着一个紧凑排列,然后就可以按照前面描述的那样简单粗暴分配新空间。

当对象被复制移动,其所有引用都得修正。位于栈或静态存储区的引用可以马上被修正,但可能还有其他引用,它们在遍历过程中才能被找到(可以想象成一个表格,将旧地址映射到新地址)。对于这种"复制回收器"

效率低下的两大主因

  1. 得有两个堆 然后在这两个分离的堆之间来回折腾,得维护比实际需要多一倍的空间。某些JVM对此问题的处理方式是,按需从堆中分配几块较大的内存,复制动作发生在这些大块内存之间。
  2. 复制本身 一旦程序稳定,可能只会产生少量甚至无垃圾。尽管如此,复制回收器仍然会将所有内存从一处复制到另一处,这很浪费。 因此一些 JVM会检查:要是没有新垃圾产生,就会转换到另一种模式(自适应)。这种模式称为 2 标记-清扫(mark-and-sweep)

Sun 早期版本的JVM一直使用该技术。一般而言,"标记-清扫"相当慢,但当你知道程序只会产生少量垃圾甚至不产生垃圾时,它就很快了。

"标记-清扫"所依据的思路仍是从栈和静态存储区出发,遍历所有引用,找出所有存活的对象。但是,每当找到一个存活对象,就给对象设一个标记,并不回收它。只有当标记过程完成后,清理动作才开始。

在清理过程中,没有标记的对象将被释放,不会发生任何复制动作。"标记-清扫"后剩下的堆空间是不连续的,要是想得到连续空间,就得整理。

"停止-复制"指的是这种gc不是在后台进行,而是gc发生同时,程序就会暂停。在 Oracle 文档中会发现,许多参考文献将gc过程视为低优先级的后台进程,但早期版本JVM并非这么实现。而是当可用内存较低时, GC会暂停程序。同样,"标记-清扫"工作也要求程序暂停。

这里讨论的JVM,内存分配以较大的"块"为单位。如果分配的对象较大,它会占用单独的块。严格的"停止-复制"要求在释放旧对象之前,必须先将所有存活对象从旧堆复制到新堆,这导致大量的内存复制。有了块,垃圾回收器就可以把对象复制到废弃的块。每个块都有年代数来记录自己是否存活。通常,如果块在某处被引用,其年代数加 1,垃圾回收器会对上次回收动作之后新分配的块进行整理。这对处理大量短命的临时对象很有帮助。垃圾回收器会定期进行完整的清理动作——大型对象仍然不会复制(只是年代数会增加),含有小型对象的那些块则被复制并整理。Java 虚拟机会监视,如果所有对象都很稳定,垃圾回收的效率降低的话,就切换到"标记-清扫"方式。

同样,Java 虚拟机会跟踪"标记-清扫"的效果,如果堆空间出现很多碎片,就会切换回"停止-复制"方式。这就是"自适应"的由来,你可以给它个啰嗦的称呼:"自适应的、分代的、停止-复制、标记-清扫"式的垃圾回收器。

1.7

JIT 即时解释器

Java 虚拟机中有许多附加技术用来提升速度。尤其是与加载器操作有关的-"即时"(Just-In-Time, JIT)编译器的技术。可以把程序全部或部分翻译成本地机器码,所以不需要 JVM 来进行翻译,因此运行得更快。当需要装载某个类(通常是创建该类的第一个对象)时,编译器会先找到其 .class 文件,然后将该类的字节码装入内存。

可以让即时编译器编译所有代码,缺点:

1. 这种加载动作贯穿整个程序生命周期内,累加起来需要花更多时间

2.增加可执行代码的长度(字节码要比即时编译器展开后的本地机器码小很多),这会导致页面调度,从而一定降低程序速度。另一种做法称为惰性评估,意味着即时编译器只有在必要的时候才编译代码。这样,从未被执行的代码也许就压根不会被 JIT 编译。新版 JDK 中的 Java HotSpot 技术就采用了类似的做法,代码每被执行一次就优化一些,所以执行的次数越多,它的速度就越快。

本文参与腾讯云自媒体分享计划,欢迎正在阅读的你也加入,一起分享。

我来说两句

0 条评论
登录 后参与评论

相关文章

  • 面试官问我JVM垃圾回收算法,还好我看了这篇

    程序计数器、虚拟机栈、本地方法栈都是线程私有的,会随着线程而生,随线程而灭; 栈中的栈帧随着方法的进入和退出有条不紊的执行着出栈和入栈操作. 每个栈帧中的本...

    JavaEdge
  • Java 编程思想精华总结(一)- 对象导论(上)

    程序可以通过添加新的对象使自身更适用于某特定问题。因此阅读代码其实也就是在阅读问题的描述。

    JavaEdge
  • Java中的VO,PO等1.2.3.VO(value object) 值对象

    JavaEdge
  • 类和对象

    帅飞
  • 关于面向对象 女神告诉你什么是三大特性

    教材描述问题首先要考虑的就是严谨,不能有错误,但是正是因为严谨,导致语义晦涩难懂,所以往往成了劝退教材。有些看上去高大上,让人摸不着头脑的词,一旦你理解了,发现...

    用户5745563
  • 领域驱动设计(DDD)实践之路(三):如何设计聚合

    这是“领域驱动设计实践之路”系列的第三篇文章,分析了如何设计聚合。聚合这个概念看似很简单,实际上有很多因素导致我们建立不正确的聚合模型。本文对这些问题逐一进行剖...

    2020labs小助手
  • 重学Java-一个Java对象到底占多少内存

    内存是程序员逃不开的话题,当然Java因为有GC使得我们不用手动申请和释放内存,但是了解Java内存分配是做内存优化的基础,如果不了解Java内存分配的知识,可...

    Rouse
  • iOS两年前的面试题总结,现在的你掌握了嘛?

    这些面试题是两年前的标准了,虽然跟现在的面试需求显得相对简单了点,但是也是可以帮着梳理一下基础方面的知识的!

    iOSSir
  • 一分钟学java | PO,VO,TO,QO,BO

    在 o/r 映射的时候出现的概念,如果没有 o/r 映射,没有这个概念存在了。通常对应数据模型 ( 数据库 ),本身还有部分业务逻辑的处理。可以看成是与数据库中...

    微笑的小小刀
  • Java中的VO,PO等1.2.3.VO(value object) 值对象

    JavaEdge

扫码关注云+社区

领取腾讯云代金券