改进Java中的对象管理方式,提高程序性能

来源:互联网 发布:NSGA-2优化对比 编辑:程序博客网 时间:2024/05/17 07:51

改进对象管理方式 提高程序性能
作者:仙人掌工作室    本文选自:赛迪网  2002年03月26日 
 
一、Java的内存管理

对象是功能强大的软件构造模块,在 Java中它们有着极其广泛的应用。实际上,由于对象的应用是如此广泛,开发者有时忘记了创建对象所要付出的代价,结果就导致程序进入了“对象搅拌器”(Object Churn)状态。在这种状态下,处理器的大部分时间消耗在周而复始的创建对象和回收被废弃对象的操作中。

对于熟悉C/C++的开发者来说,内存管理方面的简化是Java重要的特点。与C/C++要求程序显式地分配和释放内存相反,Java允许开发者根据需要为对象分配空间,并确保当程序不再需要对象时,对象占用的空间会被JVM回收。这些工作都在后台的垃圾回收进程中进行。

在编程语言中,用垃圾收集机制来管理内存可以回溯到计算机刚刚诞生的二十世纪六十年代。无论具体情形如何,垃圾收集的基本原理都是一样的:先标识出那些程序不再使用的对象,然后回收这些对象占用的内存。

一般地,JVM利用一种“可到达性”(Reachability)算法标识正在使用的对象,然后回收所有其余的对象。这个过程从一组程序直接使用的变量开始,包括每一个活动线程的局部变量、方法调用堆栈上的参数变量以及已装入类的静态变量所引用的对象。所有上述几类变量所引用的对象都被加入到“可到达”对象集合。接着,这些对象的成员变量所引用的每一个对象也被加入到可到达对象集合。这个过程重复进行直至结束;结束时,可到达对象集合中的任意对象所引用的每一个对象都已经在可到达集合中。所有不在可到达对象集合中的对象都被认为已经废弃不用,也就是可以安全地回收。

通常,Java的垃圾收集过程无需开发者干预。JVM周期性地运行垃圾收集过程,或者是当程序的线程因为等待外部事件,所以允许垃圾收集过程运行;或者是当程序创建新对象时内存不足,因而必须运行垃圾收集过程。尽管垃圾收集是自动进行的,但了解这一过程是很重要的,因为垃圾收集将占用很大一部分Java程序的开销。

除了垃圾收集的时间开销之外,Java中的对象还会产生大量的空间开销。对于每一个已分配的对象,JVM将加上必要的内部信息以便垃圾收集过程顺利进行。另外,JVM还要加入一些Java语言规范所要求的信息,这些信息对于某些功能来说是必不可少的,比如在任意对象上同步的功能。如果把JVM内部为每一个对象分配的空间计入对象的占用空间,则小型Java对象要比C/C++中等价的对象大得多。下面的表格显示了几种不同JVM下一些简单对象的内存占用情况,其中“内容”列显示用户可访问内容的大小,其余各列显示不同JVM下对象实际占用的内存大小,从该表可以看出JVM额外增加的内存开销。

   内容(字节数)  JRE 1.1.8 *(Sun)  JRE 1.1.8 (IBM)  JRE 1.2.2 (Classic)  JRE 1.2.2 (HotSpot 2.0 beta) 
java.lang.Object  0  26  31  28  18 
java.lang.Integer  4  26  31  28  26 
int[0]  4  26  31  28  26 
java.lang.String (4个字符)  8 + 4  58  63  60  58 


上面的空间开销是单个对象的值,因此,对于大对象来说空间开销的百分比将下降。如果程序使用了大量的小型对象,可能使性能变得很糟糕。

二、尽量使用基本数据类型

在Java程序中,减少对象创建操作最简单的策略或许就是尽可能地使用基本数据类型。然而,这个策略可适用的场合并不是很多,许多时候对象有充足的理由成为首选的数据格式,简单地用基本数据类型来替换对象不能满足设计要求。但是,在确实适用该策略的场合,它可以减少大量的开销。

Java的基本数据类型包括boolean、byte、char、double、float、int、long和short。使用基本数据类型的变量不会产生创建对象的开销,用完之后也不需要进行垃圾收集。对于局部方法变量,JVM将直接在堆栈分配变量空间;对于成员变量,JVM在对象使用的空间为变量分配空间。

Java为每一种基本数据类型定义了相应的封装类。封装类代表的是和基本数据类型值对应但不可变的值,使得基本数据类型的值可以作为对象处理;使用java.util.Vector、java.util.Stack、java.util.Hashtable等工具类时,这种对象非常有用。但除了这些特殊情况之外,应当避免使用封装类,尽量使用基本数据类型,避免创建对象所需要的内存和时间开销。

除了标准的封装类值外,Java类库中还有一些类是在基本类型的基础上加上一层新的语义和行为,java.util.Date和java.awt.Point都属于这类例子。在大量应用这类值的场合,存储和传递对应的基本类型值(只在必要的时候才把它们转换成相应的对象)能够减少不必要的对象创建操作。例如,对于Point类,我们可以直接访问Point内部的int值,或者把它们组合成一个long值使得方法调用只需返回一个基本类型的值。下面是一个计算中点的例子:

    ...
    // 用long值表示Point的例子。
      // 每一个long值的高位包含x坐标,
      // 低位包含y坐标
      public long midpoint(long a, long b) {
          // 计算每一个坐标上的中值
          int x = (int) (((a >> 32) + (b >> 32)) / 2);
          int y = ((int) a + (int) b) / 2;
          // 返回中点
          return (x << 32) + y;
      }
      ...  


三、专用对象重用

减少对象创建操作的另一途径是重用对象。被重用的对象可能是专门用于某一特定用途,也可能在不同的时刻用于不同的目的,因此,重用对象主要包括两种变化形式:专用对象重用,具有简单方便的特点;自由缓冲池重用,具有最好的对象重用效果。

最简单的对象重用情况是,一个频繁执行的任务需要一个或者多个起辅助作用的对象。许多应用经常进行日期格式化操作,下面我们就以此为例讨论专用对象重用。要从一个指定的日期值(按照前面尽量使用基本数据类型的要求,这个值是long类型)生成默认的字符串表示形式,我们可以:

  ...
  // 生成时间的默认字符串表示形式
    long time = ...;
    String display = DateFormat.getDateInstance().format(new Date(time));
    ... 


这个语句看起来很简单,实际上却进行了大量复杂的对象创建操作。DateFormat.getDateInstance()调用创建了一个新的SimpleDateFormat实例,后者又要创建一系列相关的对象;然后,format调用又要创建新的StringBuffer和FieldPosition对象。在JRE 1.2.2和Windows 98下,这个简单的语句实际分配的内存多达2400字节。如果这个语句需要频繁地执行,临时生成和丢弃的对象数量是相当可观的。

3.1 私有的对象

改进的方法是预先(一次性地)创建进行格式化所需要的对象,这组对象由使用它们的代码所拥有(专用),然后在需要时重用这些对象。例如,如果我们通过实例变量实现该方案,使得容器类的每一个实例拥有这些对象的唯一一份拷贝,修改后的代码如下:

    ...
    // 以成员变量的形式分配专用的日期格式化对象
      private final Date convertDate = new Date();
      private final DateFormat convertFormat = DateFormat.getDateInstance();
      private final StringBuffer convertBuffer = new StringBuffer();
      private final FieldPosition convertField = new FieldPosition(0);
      ...
          // 生成指定日期的默认字符串表示形式
          long time = ...;
          convertDate.setTime(time);
          convertBuffer.setLength(0);
          StringBuffer output =
              dateFormatter.format(convertDate, convertBuffer, convertField);
          String display = output.toString();
          ... 


这个代码片断显然要比原先的代码长,但在每次执行时只需创建一个输出的字符串对象,因而速度要快得多。简单的测试表明,改进后的代码100000次迭代只需8秒,而原来代码的相应时间则为50秒。由于用来格式化的对象并未在一次使用后马上释放,所以改进后的代码占用了更多的内存(或占用时间更长),但如果代码执行非常频繁,这个代价仍是非常合算的。

值得指出的是,如果在一个执行大量迭代的方法之内有一个内部循环,上面讨论的技术同样有用。我们不一定要把循环内用到的对象转移到包含该方法的类之内,而是可以把这些对象的创建操作移到循环之外,使得创建操作只执行一次。按照这种思想,代码可以为:

        // 分配循环内要用到的对象
          Date date = new Date();
          DateFormat formatter = DateFormat.getDateInstance();
          StringBuffer buffer= new StringBuffer();
          FieldPosition field = new FieldPosition(0);
          // 执行循环
          for (...) {
              // 生成指定时间的字符串表示形式
              long time = ...;
              date.setTime(time);
              buffer.setLength(0);
              StringBuffer output = formatter.format(date, buffer, field);
              String display = output.toString();
          } 


结合前面介绍的用基本类型值替代相应对象类型值技术,这种专用对象重用技术的效果更好。专用对象可以从基本类型值实例化然后传递给Java标准类库中要求对象作为参数类型的方法。上面的专用Date对象就是一个很好的例子。

3.2 多个线程公用的私有对象

假设我们有一组私有的对象,有多个线程并发地执行使用这些对象的代码,因此必须避免不同的线程操作这些对象可能引起的冲突。实现这个目标最简单的方法是指定其中一个对象作为整组对象的锁,把使用这些对象的代码封装在一个在锁对象上同步的块中。尽管每次使用私有对象时都会增加加锁操作的开销,但和创建对象的时间相比,加锁操作的开销较小。

假定以convertDate对象作为锁,则使用这些对象的代码应该改为:         // 获得私有对象的使用权
          synchronized (convertDate) {
              // 获得指定时间的默认字符串表示形式
              long time = ...;
              convertDate.setTime(time);
              convertBuffer.setLength(0);
              StringBuffer output =
                dateFormatter.format(convertDate, convertBuffer, convertField);
              String display = output.toString();
          }
  


如果使用私有对象的代码总是以单线程方式运行,加锁这一步骤就不再必要。然而,增加加锁机制后代码更加灵活。

例如,前面例子中我们为私有对象创建的是实例变量,也就是说每一个容器类的实例都包含这样一组对象。当私有对象的调用极其频繁,或容器类的每一个实例必须以不同的方式配置这组私有对象时,我们应该使用实例变量。然而,如果对私有对象的调用不是特别频繁,而且无需为容器类的每一个实例定制这些私有对象,那么,让类本身拥有这些对象也许更好。为此,我们只需把成员变量指定为static:

    ...
    // 以静态成员变量的形式分配专用日期格式化对象
      // (使用这些对象中的任何一个时必须在convertDate上同步)
      private static final Date convertDate = new Date();
      private static final DateFormat convertFormat = DateFormat.getDateInstance();
      private static final StringBuffer convertBuffer = new StringBuffer();
      private static final FieldPosition convertField = new FieldPosition(0);
      ...
          // 获得私有对象的使用权
          synchronized (convertDate) {
              // 获得指定日期的默认字符串表示形式
              long time = ...;
              convertDate.setTime(time);
              convertBuffer.setLength(0);
              StringBuffer output =
                  dateFormatter.format(convertDate, convertBuffer, convertField);
              String display = output.toString();
          }
          ...
  


这种方法既有专用对象重用技术在速度上的优势,又在该类的所有实例之间共享了内存。

四、缓冲池对象重用

缓冲池是对象重用的另一种方式。使用这种对象重用方式时,如果程序使用的对象属于被缓冲的类型,则它必须在使用完毕之后显式地把对象返回给缓冲池。缓冲池收集可重用的对象,把程序释放的对象加入到可重用对象集合;当程序请求对象时,缓冲池从可重用对象集合中移出并重新初始化一个对象,而不是重新创建对象。只有当可用对象集合为空时,缓冲池才会创建新的对象。

维护可用对象集合的管理开销使得这种对象重用方式的性能改进程度有所削弱,然而,在那些频繁重用特定类型对象的环境,这种技术仍旧是很有用的。下面我们将讨论管理可用对象集合的各种方法,通过实践深入了解每一种方法的适用场合。

4.1 通用缓冲池

缓冲池的构造和管理可以按照多种方式实现。最灵活的方式是被缓冲的对象类型在缓冲池之外指定,一种可能的实现如下:

import java.lang.*;
  import java.util.*;
 
  public class ObjectPool
  {
      private final Class objectType;
      private final Vector freeStack;
 
      public ObjectPool(Class type) {
          objectType = type;
          freeStack = new Vector();
      }
 
      public ObjectPool(Class type, int size) {
          objectType = type;
          freeStack = new Vector(size);
      }
 
      public synchronized Object getInstance() {
          // 检查缓冲池是否空
          if (freeStack.isEmpty()) {
              // 如缓冲池空,创建一个新的对象
              try {
               return objectType.newInstance();
              } catch (InstantiationException ex) {}
              catch (IllegalAccessException ex) {}
              throw new RuntimeException("创建新实例时出现异常");
          } else {
              // 从缓冲池末尾移出对象
              Object result = freeStack.lastElement();
              freeStack.setSize(freeStack.size() - 1);
              return result;
          }
      }
 
      public synchronized void freeInstance(Object obj) {
          // 确保对象具有正确的类型
          if (objectType.isInstance(obj)) {
              freeStack.addElement(obj);
          } else {
              throw new IllegalArgumentException("该缓冲池不能缓冲指定的对象类型");
          }
      }
  } 


这些代码利用一个java.util.Vector作为可扩展的缓冲池。ObjectPool的构造函数要求指定待缓冲的Class类型(也可以指定缓冲池的大小,可选)。有新的对象加入到缓冲池时,它将检查对象的类型是否正确。当缓冲池里面不再有可用对象时,它将创建并返回被缓冲对象类型的一个实例。

例如,假定我们在编写一个处理图形的程序,要大量地使用矩形。由于频繁地创建短期使用的java.awt.Rectangle对象会增加大量的开销,利用ObjectPool类可以方便地避免这类创建对象的开销:

// 为Rectangle对象创建一个共享的缓冲池
      private static final ObjectPool rectanglePool = new ObjectPool(Rectangle);
      ...
          // 创建一个Rectangle对象
          Rectangle rect = (Rectangle) rectanglePool.getInstance();
          rect.height = height;
          rect.width = width;
          rect.x = x;
          rect.y = y;
          ...
          // 把Rectangle对象返回给缓冲池
          rectanglePool.freeInstance(rect);
          ... 


虽然这个缓冲池用起来很方便,但遗憾的是,上面的代码比直接创建Rectangle对象还要慢!所有额外增加的代码(特别是大量使用的类型定型(cast)操作),加上对Vector类的同步,使得通过缓冲池使用对象要消耗两倍于直接分配和释放对象方式的时间。

应该说明的是,得出上述结果的测试偏向了不经缓冲池直接分配和释放对象的方式,因为它的Rectangle实例的生存周期很短,而且程序中其他对象的数量也少到了最低的限度(因而使得垃圾收集器的工作效率达到最高)。然而这个测试结果说明了,即使在最理想的情况下,这种缓冲池对性能的改进也非常有限;在最差的情况下,程序的性能反而有所降低。这种缓冲方式通用程度高,对于用来控制资源(比如数据库连接)的对象缓冲池比较有效。然而,为进一步降低分配对象的开销,我们还需要速度更快的对象缓冲池。

4.2 内建的缓冲池

通用缓冲池ObjectPool的管理开销过大,抵消了重用对象带来的大部分优势。通用性的代码往往存在这类问题:虽然它们实现了代码重用,但通常都伴随着性能损失。

为解决ObjectPool这个通用缓冲池所面临的问题,我们可以把对象缓冲池直接构造到待缓冲对象的里面。也就是说,定义一个和Rectangle等价但带有缓冲池的类(为简单计,我们把它定义为固定大小):

import java.awt.*;
  import java.lang.*;
  import java.util.*;
 
  public class ImmutableRectangle
  {
      private static final int FREE_POOL_SIZE = 40;  // 缓冲能力
      // 缓冲池由类拥有
      private static final ImmutableRectangle[] freeStack =
          new ImmutableRectangle[FREE_POOL_SIZE];
      private static int countFree;
      // 表示状态的成员变量
      private int xValue;
      private int yValue;
      private int widthValue;
      private int heightValue;
 
      private ImmutableRectangle() {
      }
 
      public static synchronized
       ImmutableRectangle getInstance(int x, int y, int width, int height) {
          // 检查缓冲区是否空
          ImmutableRectangle result;
          if (countFree == 0) {
              // 如果缓冲区空,创建一个新的对象
              result = new ImmutableRectangle();
          } else {
              // 从缓冲区末尾移出一个对象
              result = freeStack[--countFree];
          }
          // 把对象初始化为指定的状态
          result.xValue = x;
          result.yValue = y;
          result.widthValue = width;
          result.heightValue = height;
          return result;
      }
 
      public static ImmutableRectangle getInstance(int width, int height) {
          return getInstance(0, 0, width, height);  }
      public static ImmutableRectangle getInstance(Point p, Dimension d) {
          return getInstance(p.x, p.y, d.width, d.height);  }
      public static ImmutableRectangle getInstance() {
          return getInstance(0, 0, 0, 0);  }
      public static synchronized void freeInstance(ImmutableRectangle rect) {
          if (countFree < FREE_POOL_SIZE) {
              freeStack[countFree++] = rect;
          }
      }
 
      public int getX() { return xValue; }
      public int getY() { return yValue; }
      public int getWidth() { return widthValue; }
      public int getHeight() { return heightValue; }
  } 


用这种方法我们能够得到可重用的对象。和分配再回收重用的方法相比,即使是对于Rectangle这样的简单对象,新方法的性能表现也有所提高;对于复杂的对象,性能提高程度更是明显。

如果缓冲池中的对象只供一个线程使用,这里还有一种进一步提高性能的措施。这时,getInstance()和freeInstance()方法上可以不用synchronized关键词,使性能比分配再回收的方式高几倍以上。但应注意的是,这时的缓冲池缺乏线程安全性。

通过缓冲池实现对象重用有这样一个特点:当使用完对象之后,使用对象的代码必须把对象返回给缓冲池。从某些方面来看,这就好像重新退回到了C/C++,即程序显式地参与分配和释放过程。然而,与C/C++不同的是,在Java中我们可以选择在哪些地方实现缓冲池形式的对象管理。对于程序中使用频率很高的对象,采用显式地分配和释放对象的管理策略将对提高性能大有好处。

另外,这种类型的缓冲池要比C/C++“宽容”得多。一旦出现意外的情况,即对象一直没有被返回到缓冲池,唯一的影响是稍微降低了一些性能——当程序不再使用对象时,这个对象最终肯定会被作为垃圾回收,我们只需在适当的时候重新分配一个对象即可。在C/C++中,没有被释放的对象在程序生存期间一直存在,从而导致可能使C/C++程序崩溃的内存漏洞。对象池还经常用来管理一些有限的(或昂贵的)资源,比如数据库连接。这种类型的缓冲池可能没有那么宽容了。如果资源没有正确地释放,则在重新启动JVM之前它将不能被重用。

【结束语】:在这篇文章中,我们分析了一些Java对象管理方面的问题,讨论了几种有效减少对象创建操作和垃圾回收操作的技术。然而,在程序中应用某种优化技术之前,请注意以下几点:

不要为了优化而优化,只有能够证明必须进行优化时才进行优化。用有效的计时技术或可靠的执行分析器找出具体的性能问题所在,然后再进行优化。

优化应当小心地进行;否则,可能引入新的BUG。记住,较慢但稳定的代码比速度虽然快但不稳定的代码更好。

进行优化之后,再次用适当的工具分析代码,确保优化已经达到预期的效果。当优化是对一个有多个模块共享被优化部分的系统进行时,这一步骤尤其重要。

原创粉丝点击