如果您认为 Java 游戏开发人员是 Java 编程世界的一级方程式赛车手那么您就会明白为什么他们会如此地重视程序的性能 游戏开发人员几乎每天都要面对的性能问题往往超过了一般程序员考虑问题的范围哪里可以找到这些特殊的开发人员呢?Java 游戏社区就是一个好去处(参见 参考资料) 虽然在这个站点可能没有很多关于服务器端的应用但是我们依然可以从中受益看看这些惜比特如金的游戏开发人员每天所面对的我们往往能从中得到宝贵的经验让我们开始游戏吧! 对象洩漏 游戏程序员跟其他程序员一样??他们也需要理解 Java 运行时环境的一些微妙之处比如垃圾收集垃圾收集可能是使您感到难于理解的较难的概念之一 因为它并不能总是毫无遗漏地解决 Java 运行时环境中堆管理的问题似乎有很多类似这样的讨论它的开头或结尾写着我的问题是关于垃圾收集 假如您正面遭遇内存耗尽(outofmemory)的错误于是您使用检测工具想要找到问题所在但这是徒劳的您很容易想到另外一个比较可信的原因这是 Java 虚拟机堆管理的问题而不会认为这是您自己的程序的缘故但是正如 Java 游戏社区的资深专家不止一次地解释的Java 虚拟机并不存在任何被证实的对象洩漏问题实践证明垃圾收集器一般能够精确地判断哪些对象可被收集并且重新收回它们的内存空间给 Java 虚拟机所以如果您遇到了内存耗尽的错误那么这完全可能是由您的程序造成的也就是说您的程序中存在着无意识的对象保留(unintentional object retention) 内存洩漏与无意识的对象保留 内存洩漏和无意识的对象保留的区别是什么呢?对于用 Java 语言编写的程序来说确实没有区别两者都是指在您的程序中存在一些对象引用但实际上您并不需要引用这些对象一个典型的例子是向一个集合中加入一些对象以便以后使用它们但是您却忘了在使用完以后从集合中删除这些对象因为集合可以无限制地扩大并且从来不会变小所以当您在集合中加入了太多的对象(或者是有很多的对象被集合中的元素所引用)时您就会因为堆的空间被填满而导致内存耗尽的错误垃圾收集器不能收集这些您认为已经用完的对象因为对于垃圾收集器来说应用程序仍然可以通过这个集合在任何时候访问这些对象所以这些对象是不可能被当作垃圾的 对于没有垃圾收集的语言来说例如 C++ 内存洩漏和无意识的对象保留是有区别的C++ 程序跟 Java 程序一样可能产生无意识的对象保留但是 C++ 程序中存在真正的内存洩漏即应用程序无法访问一些对象以至于被这些对象使用的内存无法释放且返还给系统令人欣慰的是在 Java 程序中这种内存洩漏是不可能出现的所以我们更喜欢用无意识的对象保留来表示这个令 Java 程序员抓破头皮的内存问题这样我们就能区别于其他使用没有垃圾收集语言的程序员 跟蹤被保留的对象 那么当发现了无意识的对象保留该怎么办呢?首先需要确定哪些对象是被无意保留的并且需要找到究竟是哪些对象在引用它们然后必须安排好 应该在哪里释放它们最容易的方法是使用能够对堆产生快照的检测工具来标识这些对象比较堆的快照中对象的数目跟蹤这些对象找到引用这些对象的对象然后强制进行垃圾收集有了这样一个检测器接下来的工作相对而言就比较简单了: 等待直到系统达到一个稳定的状态这个状态下大多数新产生的对象都是暂时的符合被收集的条件这种状态一般在程序所有的初始化工作都完成了之后 强制进行一次垃圾收集并且对此时的堆做一份对象快照 进行任何可以产生无意地保留的对象的操作 再强制进行一次垃圾收集然后对系统堆中的对象做第二次对象快照 比较两次快照看看哪些对象的被引用数量比第一次快照时增加了因为您在快照之前强制进行了垃圾收集那么剩下的对象都应该是被应用程序所引用的对象并且通过比较两次快照我们可以准确地找出那些被程序保留的新产生的对象 根据您对应用程序本身的理解并且根据对两次快照的比较判断出哪些对象是被无意保留的 跟蹤这些对象的引用链找出究竟是哪些对象在引用这些无意地保留的对象直到您找到了那个根对象它就是产生问题的根源 显式地赋空(nulling)变量 一谈到垃圾收集这个主题总会涉及到这样一个吸引人的讨论即显式地赋空变量是否有助于程序的性能赋空变量是指简单地将 null 值显式地赋值给这个变量相对于让该变量的引用失去其作用域 清单 局部作用域 public static String scopingExample(String string) { StringBuffer sb = new StringBuffer(); sbappend(hello )append(string); sbappend( nice to see you!); return sbtoString(); } 当该方法执行时运行时栈保留了一个对 StringBuffer 对象的引用这个对象是在程序的第一行产生的在这个方法的整个执行期间栈保存的这个对象引用将会防止该对象被当作垃圾当这个方法执行完毕变量 sb 也就失去了它的作用域相应地运行时栈就会删除对该 StringBuffer 对象的引用于是不再有对该 StringBuffer 对象的引用现在它就可以被当作垃圾收集了栈删除引用的操作就等于在该方法结束时将 null 值赋给变量 sb 错误的作用域 既然 Java 虚拟机可以执行等价于赋空的操作那么显式地赋空变量还有什么用呢?对于在正确的作用域中的变量来说显式地赋空变量的确没用但是让我们来看看另外一个版本的 scopingExample 方法这一次我们将把变量 sb 放在一个错误的作用域中 清单 静态作用域 static StringBuffer sb = new StringBuffer(); public static String scopingExample(String string) { sb = new StringBuffer(); sbappend(hello )append(string); sbappend( nice to see you!); return sbtoString(); } 现在 sb 是一个静态变量所以只要它所在的类还装载在 Java 虚拟机中它也将一直存在该方法执行一次一个新的 StringBuffer 将被创建并且被 sb 变量引用在这种情况下sb 变量以前引用的 StringBuffer 对象将会死亡成为垃圾收集的对象也就是说这个死亡的 StringBuffer 对象被程序保留的时间比它实际需要保留的时间长得多??如果再也没有对该 scopingExample 方法的调用它将会永远保留下去 一个有问题的例子 即使如此显式地赋空变量能够提高性能吗?我们会发现我们很难相信一个对象会或多或少对程序的性能产生很大影响直到我看到了一个在 Java Games 的 Sun 工程师给出的一个例子这个例子包含了一个不幸的大型对象 清单 仍在静态作用域中的对象 private static Object bigObject; public static void test(int size) { long startTime = SystemcurrentTimeMillis(); long numObjects = ; while (true) { //bigObject = null; //explicit nulling //SizableObject could simply be a large array eg byte[] //In the JavaGaming discussion it was a BufferedImage bigObject = new SizableObject(size); long endTime = SystemcurrentTimeMillis(); ++numObjects; // We print stats for every two seconds if (endTime startTime >= ) { Systemoutprintln(Objects created per seconds = + numObjects); startTime = endTime; numObjects = ; } } } 这个例子有个简单的循环创建一个大型对象并且将它赋给同一个变量每隔两秒钟报告一次所创建的对象个数现在的 Java 虚拟机采用 generational 垃圾收集机制新的对象创建之后放在一个内存空间(取名 Eden)内然后将那些在第一次垃圾收集以后仍然保留的对象转移到另外一个内存空间在 Eden即创建新对象时所在的新一代空间中收集对象要比在老一代空间中快得多但是如果 Eden 空间已经满了没有空间可供分配那么就必须把 Eden 中的对象转移到老一代空间中腾出空间来给新创建的对象如果没有显式地赋空变量而且所创建的对象足够大那么 Eden 就会填满并且垃圾收集器就不能收集当前所引用的这个大型对象所产生的后果是这个大型对象被转移到老一代空间并且要花更多的时间来收集它 通过显式地赋空变量Eden 就能在新对象创建之前获得自由空间这样垃圾收集就会更快实际上在显式赋空的情况下该循环在两秒钟内创建的对象个数是没有显式赋空时的倍??但是仅当您选择创建的对象要足够大而可以填满 Eden 时才是如此 在 Windows 环境Java虚拟机 的默认配置下大概需要 KB那就是一行赋空操作产生的 倍的性能差距但是请注意这个性能差别产生的原因是变量的作用域不正确这正是赋空操作发挥作用的地方并且是因为所创建的对象非常大 最佳实践 这是一个有趣的例子但是值得强调的是 |