Java 优化技术
存在许多优化 Java 程序的有用技术。本文将优化过程视为一个整体,而不是集中于某一个特定技术。作者 Erwin Vervaet 和 Maarten De Cock 通过应用从简单技术技巧到更高级算法优化的各类技术,向读者逐步介绍解拼图程序的性能调整。最终结果是在第一个工作实现和完全优化的解决方案之间有了巨大 的性能改进(
一百万倍以上)。
大多数关于 Java 性能的文章主要讨论程序员用什么技术来加快程序的运行速度。在这些方法中,一端是对简单的编程习惯用法的描述,如 StringBuffer 类的使用。在另一端,您会发现更高级技术的讨论,如对象高速缓存的使用。我们将展示一个组合这些技术来加速解拼图程序的实际示例,而不是向这一系列技术中添加新技术。
我们将开发和优化的程序会计算出 Meteor 拼图所有可能的解决方案,Meteor 拼图是一种由 10 个拼图块构成的智力游戏,每种不同的颜色由 5 个六边形(正六边形)组成。拼图板自身是由按 5 乘 10 模式排列的 50 个六边形组成的矩形格子。通过使用 10 个可用块覆盖整个板来拼出该拼图。图 1 中显示了该拼图的一种可能的解决方案。
|
Eternity 拼图
虽然本文中讨论的 Java 程序解决 10 块 Meteor 拼图,但真实目的是为了解决更大的称为 Eternity 拼图的 209 块拼图,该拼图是由 Christopher Monckton 设计的并在 1999 年 6 月在英国作了介绍。发布 Eternity 拼图的同时,Monckton 发布了各种小拼图:Meteor、Delta 和 Heart。通过解决任何这些拼图,玩家可以通过邮件获取各种线索中的一种,这些线索显示 Eternity 网格特定块在 Monckton 解决方案中的位置。第一个解出 Eternity 拼图的人将获得一百万英镑(约合一百五十万美元),该奖金最后由 Alex Selby 和 Oliver Riordan 于 2000 年 5 月 15 日获得。后来 Guenter Stertenbrink 找到了第二种解决方案。有趣的是,这些解决方案都没有采用 Christopher Monckton 为其解决方案给出的六条线索,他的解决方案仍未公布。 |
|
图 1. Meteor 拼图的一个解决方案
这样一个解决方案看起来好象很简单,在计算机程序中实现其实不是个容易的问题。编写那个程序与您能在许多其它关于 Java 性能的文章中找到的人为设计的示例有着根本不同。这使得我们可以说明许多不同的优化技术和组合这些技术的方法。但是,在我们开始优化之前,首先需要开发一 种有效的解决方案。
首先,一种有效的解决方案
在本节中,我们将讨论我们的解拼图程序的初始实现。这将包含好几个代码段,所以耐心些;一旦解释了涉及的基本算法,我们就开始进行优化。可以在 参考资料中获取这一初始实现的源代码以及我们将在本文后面讨论的优化源代码。
解拼图算法
我们的解拼图算法将计算 Meteor 拼图所有可能的解决方案。这意味着我们必须穷举搜索使用块填充图板的每种可能方法。完成这个任务的一个步骤是确定块的所有 排列。 一种排列是在图板上放置一个图块的一种可能方法。我们知道每块都可以被上下翻转并且可以沿着其中一个六边形的六条边旋转,所以得到了总共 12 种(2 x 6)可能的方法将一块放入图板的一个位置。因为有 50 个图板位置,所以在图板上放置单一块的可能方法总共有 600(2 x 6 x 50)种。
当然,实际上所有这些“可能的方法”并非都行得通。例如,一些方法中有一块悬于图板的边上,这显然不能生成一种解决方案。对所有块递归地重复这一过程就产 生了第一种算法,它将通过尝试用块填充图板的每种可能方法来找出每种可能的解决方案。清单 1 显示了这一算法的代码。我们使用一个称为 pieceList 的简单 ArrayList 对象来保存所有块。 board 对象表示拼图板,我们将简略地讨论它。
清单 1. 初始的解拼图算法
public void solve() { if (!pieceList.isEmpty()) { // Take the first available piece Piece currentPiece = (Piece)pieceList.remove(0); for (int i = 0; i < Piece.NUMBEROFPERMUTATIONS; i++) { Piece permutation = currentPiece.nextPermutation(); for (int j = 0; j < Board.NUMBEROFCELLS; j++) { if (board.placePiece(permutation, j)) {
/* We have now put a piece on the board, so we have to continue this process with the next piece by recursively calling the solve() method */
solve();
/* We're back from the recursion and we have to continue searching at this level, so we remove the piece we just added from the board */
board.removePiece(permutation); } // Else the permutation doesn't fit on the board } } // We're done with this piece pieceList.add(0, currentPiece); } else {
/* All pieces have been placed on the board so we have found a solution! */
puzzleSolved(); } } |
既然我们已经建立了基本算法,现在就需要研究其它两个重要问题:
在清单 1 显示的算法中,我们使用 Piece 类和 Board 类。现在让我们研究一下这两个类的实现。
Piece 类
在开始设计 Piece 类之前,需要考虑这个类应该表示什么。当您查看图 2 时,可以看见 Meteor 拼图块是由 5 个相连的单元构成的。每个单元都是一个正六边形,它有六条边:EAST、SOUTHEAST、SOUTHWEST、WEST、NORTHWEST 和 NORTHEAST。当两个单元在特定边相连时,我们称它们 邻居。最后, Piece 只是一组五个相连的 Cell 对象。每个 Cell 对象都有 6 条边和 6 个可能相邻的单元。如清单 2 所示,实现 Cell 类很简单。注:我们在 Cell 对象中保留了一个 processed 标志。以后,我们将使用这一标志以避免无限循环。
图 2. 拼图块及其单元
清单 2. Cell 类
public class Cell { public static final int NUMBEROFSIDES = 6; // The sides of a cell public static final int EAST = 0; public static final int SOUTHEAST = 1; public static final int SOUTHWEST = 2; public static final int WEST = 3; public static final int NORTHWEST = 4; public static final int NORTHEAST = 5; private Cell[] neighbours = new Cell[NUMBEROFSIDES]; private boolean processed = false; public Cell getNeighbour(int side) { return neighbours[side]; } public void setNeighbour(int side, Cell cell) { neighbours[side] = cell; } public boolean isProcessed() { return processed; } public void setProcessed(boolean b) { processed = b; } } |
Piece 类更有趣,因为我们需要一种方法来计算 Piece 的排列。我们可以通过先将块沿着其中一个单元的 6 条边旋转,上下翻转,最后再次沿着其中一个单元的 6 条边旋转。正如我们以前提到的,块由 5 个相邻的单元构成。翻转或旋转块就是翻转或旋转其所有单元。因此对于 Cell 对象,我们需要 flip() 和 rotate() 方法。通过相应地更改相邻的边就可以容易地实现翻转和旋转。在 Cell 类的 PieceCell 子类中提供了这些方法,如清单 3 所示。 PieceCell 对象是在 Piece 对象中使用的单元。
清单 3. PieceCell 子类
public class PieceCell extends Cell { public void flip() { Cell buffer = getNeighbour(NORTHEAST); setNeighbour(NORTHEAST, getNeighbour(NORTHWEST)); setNeighbour(NORTHWEST, buffer); buffer = getNeighbour(EAST); setNeighbour(EAST, getNeighbour(WEST)); setNeighbour(WEST, buffer); buffer = getNeighbour(SOUTHEAST); setNeighbour(SOUTHEAST, getNeighbour(SOUTHWEST)); setNeighbour(SOUTHWEST, buffer); } public void rotate() { // Clockwise rotation Cell eastNeighbour = getNeighbour(EAST); setNeighbour(EAST, getNeighbour(NORTHEAST)); setNeighbour(NORTHEAST, getNeighbour(NORTHWEST)); setNeighbour(NORTHWEST, getNeighbour(WEST)); setNeighbour(WEST, getNeighbour(SOUTHWEST)); setNeighbour(SOUTHWEST, getNeighbour(SOUTHEAST)); setNeighbour(SOUTHEAST, eastNeighbour); } } |
通过使用 PieceCell 类,我们可以完成 Piece 类的实现。清单 4 向您显示了源代码:
清单 4. Piece 类
public class Piece { public static final int NUMBEROFCELLS = 5; public static final int NUMBEROFPERMUTATIONS = 12; private PieceCell[] pieceCells = new PieceCell[NUMBEROFCELLS]; private int currentPermutation = 0; private void rotatePiece() { for (int i = 0; i < NUMBEROFCELLS; i++) { pieceCells[i].rotate(); } } private void flipPiece() { for (int i = 0; i < NUMBEROFCELLS; i++) { pieceCells[i].flip(); } } public Piece nextPermutation() { if (currentPermutation == NUMBEROFPERMUTATIONS) currentPermutation = 0; switch (currentPermutation%6) { case 0: // Flip after every 6 rotations flipPiece(); break; default: rotatePiece(); break; } currentPermutation++; return this; } public void resetProcessed() { for (int i = 0; i < NUMBEROFCELLS; i++) { pieceCells[i].setProcessed(false); } } //Getters and setters have been omitted } |
Board 类
在实现 Board 类之前,需要处理两个有趣的问题。首先,我们必须确定数据结构。Meteor 拼图板基本上是 5 乘 10 格的正六边形,我们可以将它表示为 50 个 Cell 对象的数组。我们将使用 BoardCell 子类(如清单 5 所示),而不直接使用 Cell 类,BoardCell 子类跟踪占用单元的块:
清单 5. BoardCell 子类
public class BoardCell extends Cell { private Piece piece = null; public Piece getPiece() { return piece; } public void setPiece(Piece piece) { this.piece = piece; } } |
如果在一个数组中存储图板的所有 50 个图板单元,我们将必须编写某些冗长乏味的初始化代码。该初始化代码为图板上的每个单元标识相邻的图板单元,如图 3 所示。例如,单元 0 有两个邻居:右面的单元 1 和右下的单元 5。 清单 6 显示了 initializeBoardCell() 方法,从 Board 类的构造器调用该方法以完成这个初始化。
图 3. 表示成单元数组的图板
既然我们已经实现了图板的数据结构,则转向下一个问题:编写将块放入图板的 placePiece() 方法。编写该方法的最难部分是确定块是否适合图板上的给定位置。确定块是否适合的一种方法是:首先,找出所有这样的图板单元,如果将块放入图板,则这些图 板单元就可能会被块单元占用。获得这组图板单元后,我们可以容易地确定新的块是否合适:所有相应的块单元都需要是空的并且块要完全适合图板。这个过程是由 显示在清单 6 中的 findOccupiedBoardCells() 方法和 placePiece() 方法实现的。注:我们使用 PieceCell 对象的 processed 字段以避免 findOccupiedBoardCells() 方法中的无限递归。
清单 6. Board 类
public class Board { public static final int NUMBEROFCELLS = 50; public static final int NUMBEROFCELLSINROW = 5; private BoardCell[] boardCells = new BoardCell[NUMBEROFCELLS]; public Board() { for (int i = 0; i < NUMBEROFCELLS; i++) { boardCells[i] = new BoardCell(); } for (int i = 0; i < NUMBEROFCELLS; i++) { initializeBoardCell(boardCells[i], i); } } /** * Initialize the neighbours of the given boardCell at the given * index on the board */ private void initializeBoardCell(BoardCell boardCell, int index) { int row = index/NUMBEROFCELLSINROW; // Check if cell is in last or first column boolean isFirst = (index%NUMBEROFCELLSINROW == 0); boolean isLast = ((index+1)%NUMBEROFCELLSINROW == 0); if (row%2 == 0) { // Even rows if (row != 0) { // Northern neighbours if (!isFirst) { boardCell.setNeighbour(Cell.NORTHWEST, boardCells[index-6]); } boardCell.setNeighbour(Cell.NORTHEAST, boardCells[index-5]); } if (row != ((NUMBEROFCELLS/NUMBEROFCELLSINROW)-1)) { // Southern neighbours if (!isFirst) { boardCell.setNeighbour(Cell.SOUTHWEST, boardCells[index+4]); } boardCell.setNeighbour(Cell.SOUTHEAST, boardCells[index+5]); } } else { // Uneven rows // Northern neighbours if (!isLast) { boardCell.setNeighbour(Cell.NORTHEAST, boardCells[index-4]); } boardCell.setNeighbour(Cell.NORTHWEST, boardCells[index-5]); // Southern neighbours if (row != ((NUMBEROFCELLS/NUMBEROFCELLSINROW)-1)) { if (!isLast) { boardCell.setNeighbour(Cell.SOUTHEAST, boardCells[index+6]); } boardCell.setNeighbour(Cell.SOUTHWEST, boardCells[index+5]); } } // Set the east and west neighbours if (!isFirst) { boardCell.setNeighbour(Cell.WEST, boardCells[index-1]); } if (!isLast) { boardCell.setNeighbour(Cell.EAST, boardCells[index+1]); } } public void findOccupiedBoardCells( ArrayList occupiedCells, PieceCell pieceCell, BoardCell boardCell) { if (pieceCell != null && boardCell != null && !pieceCell.isProcessed()) { occupiedCells.add(boardCell);
/* Neighbouring cells can form loops, which would lead to an infinite recursion. Avoid this by marking the processed cells. */
pieceCell.setProcessed(true); // Repeat for each neighbour of the piece cell for (int i = 0; i < Cell.NUMBEROFSIDES; i++) { findOccupiedBoardCells(occupiedCells, (PieceCell)pieceCell.getNeighbour(i), (BoardCell)boardCell.getNeighbour(i)); } } } public boolean placePiece(Piece piece, int boardCellIdx) { // We will manipulate the piece using its first cell return placePiece(piece, 0, boardCellIdx); } public boolean placePiece(Piece piece, int pieceCellIdx, int boardCellIdx) { // We're going to process the piece piece.resetProcessed(); // Get all the boardCells that this piece would occupy ArrayList occupiedBoardCells = new ArrayList(); findOccupiedBoardCells(occupiedBoardCells, piece.getPieceCell(pieceCellIdx), boardCells[boardCellIdx]); if (occupiedBoardCells.size() != Piece.NUMBEROFCELLS) { // Some cells of the piece don't fall on the board return false; } for (int i = 0; i < occupiedBoardCells.size(); i++) { if (((BoardCell)occupiedBoardCells.get(i)).getPiece() != null) // The board cell is already occupied by another piece return false; } // Occupy the board cells with the piece for (int i = 0; i < occupiedBoardCells.size(); i++) { ((BoardCell)occupiedBoardCells.get(i)).setPiece(piece); } return true; // The piece fits on the board } public void removePiece(Piece piece) { for (int i = 0; i < NUMBEROFCELLS; i++) { // Piece objects are unique, so use reference equality if (boardCells[i].getPiece() == piece) { boardCells[i].setPiece(null); } } } } |
这完成了我们的初始解决方案的实现。让我们对它进行测试吧。
运行程序
既然已经完成了第一个解拼图程序,那么我们就可以运行它以找出 Meteor 拼图的所有可能的解决方案。前几节中描述的源代码可从源代码下载的 meteor.initial 包中找到。这个包含有 Solver 类,该类具有 solve() 方法和用以启动程序的 main() 方法。 Solver 类的构造器初始化所有拼图块,然后将它们添加到 pieceList 。我们可以使用 java meteor.initial.Solver 启动程序。
程序开始搜索解决方案,但如您将注意到的,它似乎什么解决方案也找不到。实际上,它确实能找到所有可能的解决方案,但您必须很耐心。找到一个解决方案就要 花几个小时。我们的测试计算机是一台运行 RedHat Linux 7.2 和 Java 1.4.0 的具有 512MB RAM 的 Athlon XP 1500+,它大约花了 8 小时才找到了第一个解决方案。找出所有解决方案即使不需要几年,也需要几个月。
很明显,我们遇到了性能问题。优化的第一个候选方法是解拼图算法。当前我们正在使用一种幼稚的蛮力方法来找出所有可能的解决方案。我们应该对这个算法进行 微调。我们能做的第二件事是对临时数据进行高速缓存。例如,我们可以对那些排列进行高速缓存,而不是每次都重新计算块的排列。最后,我们可以设法应用某些 低级别的优化技术,诸如避免不必要的方法调用。在下几节中,我们将研究这些优化技术。
改进算法
回顾 清单 1 并思考如何能够优化我们的初始解拼图算法。优化算法的一种好方法是使其可视化。可视化允许我们更好地理解正在实现的过程及其可能的缺点。下几节讨论可以辨别的两个低效率的实现。我们将解拼图程序的实际可视化代码留给感兴趣的读者。
孤岛检测剪枝
清单 1中 的算法将块(或者更精确地说是块的块单元)放到图板上的每个合适位置。图 4 显示了过程开始时可能的图板状态。蓝色块的当前排列已经被放置在第一个可用的图板位置上,并且黄色块的当前排列已经被移动到其第二个可能的图板位置上。然 后,我们的算法继续处理第三个块,以此类推。然而如果我们仔细看图 4,很明显,在图板的这些位置上放置蓝色和黄色的块不可能成为解决方案。原因是那两块已经形成了由三个相邻空单元组成的 孤岛。因为所有拼图块都由 5 个单元构成,所以无法填充这个孤岛。我们的算法努力尝试填充图板其余 8 块的工作都是徒劳的。如果我们在图板上检测到一个无法填充的孤岛,则需要调整我们的算法。
图 4. 图板上的孤岛
教科书中将这种中断递归搜索算法的过程称为 剪枝。将剪枝函数添加到我们的 Solver 类中是很容易的。在每次递归调用 solve() 方法前,我们都要检查图板上的孤岛。如果我们发现构成孤岛的空单元数不是 5 的倍数,我们就不进行递归调用。而算法继续当前级别的递归。清单 7 和 8 显示了必需的代码调整:
清单 7. 带剪枝的解拼图算法
public class Solver { public void solve() { ... if (!prune()) solve(); ... } private boolean prune() { /* We'll use the processed field of board cells to avoid infinite loops */ board.resetProcessed(); for (int i = 0; i < Board.NUMBEROFCELLS; i++) { if (board.getBoardCell(i).getIslandSize()%Piece.NUMBEROFCELLS != 0) { // We have found an unsolvable island return true; } } return false; } } |
清单 8. getIslandSize() 方法
public class BoardCell { public int getIslandSize() { if (!isProcessed() && isEmpty()) { setProcessed(true); // Avoid infinite recursion int numberOfCellsInIsland = 1; // this cell for (int i = 0; i < Cell.NUMBEROFSIDES; i++) { BoardCell neighbour=(BoardCell)getNeighbour(i); if (neighbour != null) { numberOfCellsInIsland += neighbour.getIslandSize(); } } return numberOfCellsInIsland; } else { return 0; } } } |
填补(fill-up)算法
我们的初始算法的第二个缺点是它本质上会生成许多孤岛。之所以发生这种情况是因为我们采用了块的一种排列,并且在切换至块的下一个排列之前在图板上移动了 该块。例如,在图 5 中,我们已经将蓝色块的当前排列移动到其第三个可能的图板位置上。正如您可以看到的,这在图板的顶部生成了一个孤岛。由于我们正生成许多孤岛,所以在上一 节添加的孤岛检测剪枝显著地改进了性能,尽管如此,如果可以更新我们的算法以便在一开始就使其生成的孤岛数目最小化,则会更好。
图 5. 生成孤岛
要减少生成的孤岛的数量,最好我们的算法集中于填充空的图板位置。因此,我们将尝试从左到右、从上到下来填充图板,而不是致力于尝试填充图板的每种可能方法。在清单 9 中显示了这个新的解拼图算法:
清单 9. 填补解拼图算法
public void solve() { if (!pieceList.isEmpty()) { // We'll try to find a piece that fits on this board cell int emptyBoardCellIdx = board.getFirstEmptyBoardCellIndex(); // Try all available pieces for (int h = 0; h < pieceList.size(); h++) { Piece currentPiece = (Piece)pieceList.remove(h); for (int i = 0; i < Piece.NUMBEROFPERMUTATIONS; i++) { Piece permutation = currentPiece.nextPermutation();
/* Instead of always using the first cell to manipulate the piece, we now try to fit any cell of the piece on the first empty board cell */
for (int j = 0; j < Piece.NUMBEROFCELLS; j++) { if (board.placePiece(permutation, j, emptyBoardCellIdx)) { if (!prune()) solve(); board.removePiece(permutation); } } }
/* Put the piece back into the list at the position where we took it to maintain the order of the list */
pieceList.add(h, currentPiece); } } else { puzzleSolved(); } } |
我们的新方法尝试将任何可用的块填充到第一个空的图 板单元。只尝试所有可用的块的全部可能排列是不够的。我们还应该尝试用块中的任何块单元覆盖空的图板单元。在初始算法中,我们以默认方式假设我们操作的块 使用它的第一个单元。现在,我们必须尝试块中的每个单元,如图 6 中所示。当我们尝试将带有索引 0 的块单元放入图板位置 5(图 6 中画圈的)时,粉红色块的当前排列不适合图板。然而,当我们使用第二个块单元时,它却很适合。
图 6. 块的单元
运行更新的程序
当我们运行初始程序时,它无法在适当的时间内找到任何解决方案。让我们使用改进的算法和孤岛检测剪枝再次尝试。可以在 meteor.algorithm 包中找到程序这一版本的代码。当我们用 java meteor.algorithm.Solver 启动它时,几乎立即看到弹出解决方案。我们的测试机器用 157 秒计算出所有 2,098 种可能的解决方案。这样,我们已经获得了巨大的性能改进:从几个小时生成一个解决方案到不到十分之一秒生成一个解决方案。快了大约 400,000 倍。另外,与孤岛检测剪枝结合的初始算法以 6,363 秒完成。因此,剪枝优化使速度提高了 10,000 倍,而填补算法使速度又提高了 40 倍。很明显,花一些时间研究您的算法并试图对其进行优化是值得的。
对中间结果进行高速缓存
重新设计我们的解拼图算法极大地提高了程序的执行速度。对于进一步的优化,我们将必须研究技术性的性能技术。Java 程序中要考虑的一个重要问题是垃圾收集。您可以使用 -verbose:gc 命令行开关在程序执行期间显示垃圾收集器的活动。
java -verbose:gc meteor.algorithm.Solver |
如果我们使用这个开关运行程序,则看到来自垃圾收集器的许多输出。对源代码的研究告诉我们问题出在对 Board 类的 placePiece() 方法的临时 ArrayList 对象的实例化(请参阅 清单 6)。我们使用这个 ArrayList 对象保存块的特定排列将占用的图板单元。最好对结果进行高速缓存以备以后引用,而不是每次都重新计算该列表。
如果将块的某个特定单元放置在特定的图板位置上, findOccupiedBoardCells() 方法确定将被拼图块占用的拼图图板单元。该方法的结果由三个参数确定:首先我们有拼图块或对它的排列;其次,我们有正在用来操作块的块单元;最后,我们有 将要放置块的图板的单元。要对这些结果进行高速缓存,我们可以将一张表与每个可能的块排列相关联。这张表使用特定块单元索引和图板单元位置保存了那个排列 的 findOccupiedBoardCells() 方法的结果。清单 10 显示了维护此类表的 Piece 类的一个更新版本:
清单 10. 对 findOccupiedBoardCells() 方法的结果进行高速缓存
public class Piece { private Piece[] permutations = new Piece[NUMBEROFPERMUTATIONS]; private ArrayList[][] occupiedBoardCells = new ArrayList[Piece.NUMBEROFCELLS][Board.NUMBEROFCELLS]; private void generatePermutations(Board board) { Piece prevPermutation=this; for (int i = 0; i < NUMBEROFPERMUTATIONS; i++) { // The original nextPermutation() has been renamed permutations[i]= ((Piece)prevPermutation.clone()).nextPermutation_orig(); prevPermutation=permutations[i]; } // Calculate occupied board cells for every permutation for (int i = 0; i < NUMBEROFPERMUTATIONS; i++) { permutations[i].generateOccupiedBoardCells(board); } } private void generateOccupiedBoardCells(Board board) { for (int i = 0; i < Piece.NUMBEROFCELLS; i++) { for (int j = 0; j < Board.NUMBEROFCELLS; j++) { occupiedBoardCells[i][j]=new ArrayList(); resetProcessed(); // We're going to process the piece board.findOccupiedBoardCells(occupiedBoardCells[i][j], pieceCells[i], board.getBoardCell(j)); } } } public Piece nextPermutation() { if (currentPermutation == NUMBEROFPERMUTATIONS) currentPermutation = 0; // The new implementation of nextPermutation() // accesses the cache return permutations[currentPermutation++]; } public ArrayList getOccupiedBoardCells(int pieceCellIdx, int boardCellIdx) { // Access requested data in cache return occupiedBoardCells[pieceCellIdx][boardCellIdx]; } } |
generatePermutations() 方法是在创建 Piece 对象时触发的。它计算了块的每个排列并对那些排列的 findOccupiedBoardCells() 方法的所有可能的结果进行了高速缓存。很明显,如果我们想计算被占用的图板单元,则我们将需要对拼图图板进行访问。还请注意,块的排列是原始的 Piece 对象的克隆。克隆 Piece 涉及对其所有单元的深层复制。
剩余的唯一一件事是从 Board 类的 placePiece() 方法访问高速缓存,在清单 11 中显示了该方法:
清单 11. 访问被占用的图板单元高速缓存
public class Board { public boolean placePiece(Piece piece, int pieceCellIdx, int boardCellIdx) { // Get all the boardCells that this piece would occupy ArrayList occupiedBoardCells = piece.getOccupiedBoardCells(pieceCellIdx, boardCellIdx); ... } } |
再次运行程序
可以在 meteor.caching 包中找到我们的解拼图程序的这个更新版本的源代码。运行 java meteor.caching.Solver 表明我们再次极大地改进了性能。在我们的测试机器上,用 25 秒找到了所有解决方案。高速缓存使速度提高了 6 倍。如果我们使用 -verbose:gc 开关,则还会看到垃圾收集已不成问题。
我们引入以实现高速缓存的额外代码明显使程序变复杂了。这是尝试通过存储中间结果来减少计算时间的性能技术的典型缺点。但在这种情况下,性能改进的效果似乎超过了增加代码复杂程度的代价。
编程优化
我们的解拼图程序的优化过程的最后一步可能是使用低级别 Java 代码优化习惯用法。我们并不在应用程序中操作任何字符串,因此应用众所周知的 StringBuffer 习惯用法是没有用的。我们可以通过使用直接成员访问替换读方法(getter)和写方法(setter)以避免那些读方法和写方法的方法调用开销。但是,这显然降低了代码的质量,测试显示这几乎对提高速度没有任何帮助。对于使用 final 方法,也是如此。通过将我们的方法声明为 final 避免了动态绑定并允许 Java 虚拟机使用更有效的静态绑定。但是,哎!这也未能明显地提高速度。同样,使用 Java 编译器的 -O 优化开关没有产生任何实际的性能改进。
通过改进 prune() 方法的实现仍然可以略微提高执行速度。 清单 7 中的代码总是调用递归的 getIslandSize() 方法,即使图板单元已被处理或不为空。如果我们在调用 getIslandSize() 之前就提前完成这些检查,则会使速度提高百分之十。
从这个讨论可以明显地看出,低级别优化带来很小的性能改进。低级别优化对性能的改进不显著,加上其中一些优化技术会损害代码质量,从而导致了低级别优化的使用缺乏吸引力。
结束语
改进我们的解拼图程序实现的所有工作当然得到了回报。表 1 总结了我们创建的不同版本及其执行时间。总体结果令人惊奇,速度提高了 2,000,000 倍。
表 1. 比较执行时间
版本 | 时间(秒) |
meteor.initial | ~ 60,422,400(大约 2 年) |
meteor.algorithm | 157 |
meteor.caching | 25 |
无论该优化给人的印象多么深刻,重要的问题是我们可以从这个试验中学到什么?我们使用的每种不同的优化技术都各有利弊。将它们组合到单个优化过程阐明它们的使用并且避免了混乱的应用:
- 我们使用的象 算法改进这 类高级优化技术具有很大潜力。如果您需要优化性能关键型代码块,请首先尝试分析这一代码实现的过程。使该过程可视化是更好地理解该过程的出色方法。也请尝 试从不同的角度处理该问题。您可能提出比您初始设计的解决方案好得多的解决方案。这类优化的一个明显的困难是难于一般化。每个算法都特定于特殊应用程序领 域,因此能够提供的通用指导原则几乎没有。这取决于程序员是否有创造力。
- 一旦您确定有了好的有效的解决方案,则是时候应用 技术性能改进技术了。基本思路是用 数据复杂度换取 时间复杂度。对象高速缓存是这类技术中最典型的技术。在 Java 程序中,对象高速缓存特别有用,因为它们帮您避免了耗费巨大的对象创建和垃圾收集开销。记住,此类系统向您的程序中添加了额外的基础结构代码,因此不要过早引入它。代码越复杂,就越难以优化。
- 最后,我们可以应用一定范围的 低级别编程优化。大多数 Java 程序员熟悉这类技术。但在大多数实际程序中,它们的优势有限。尽可能地使用它们,但是不要把您的所有优化工作都集中于此类习惯用法。它们应该是帮助您避免已知的性能陷阱的编程工具集的一部分。
我们在解拼图程序中通过组合不同的优化技术极大地改进了性能,这一成功应该激励所有 Java 程序员研究他们自己的代码以及如何对其进行优化。
来自 “ ITPUB博客 ” ,链接:http://blog.itpub.net/374079/viewspace-130123/,如需转载,请注明出处,否则将追究法律责任。