JTable setRowHeight causes slow repainting

I’ve spent way too much time in the last few months fixing and optimising table rendering performance for a high performance Java application I’m working on at the moment. The problems were particularly hard to find becuase the app supports customization of almost every aspect of JTable UI, with custom rendering for rows, columns and individual cells according to user preferences. Some users would get terrible performance on some days, which would seem to disappear the next.

Some of the issues were hardware and graphics driver related, and there have been various minor and major victories along the way (more about these later), but just yesterday I found a very significant defect in JTable.tableChanged().

The problem occurs when you call setRowHeight(int row, int height) on a JTable, to set a customised table row height. You only need to do this once, and that causes JTable to initialize its internal rowModel to maintain the custom row heights. From that point onwards, any update to the TableModel (even if it is just an update event for a single cell()) will be processed by JTable.tableChanged() in such a way that the entire table component is marked as dirty. So this forces the RepaintManager to repaint the entire table every time, even for single cell updates.

The point at which everything appears to go wrong is at JTable line 4396 (in jdk 1.6.0_14) and line 3021 (in jdk 1.5.0_17), which is part of the processing for TableModel update events

// The totalRowHeight calculated below will be incorrect if
// there are variable height rows. Repaint the visible region,
// but don't return as a revalidate may be necessary as well.
if (rowModel != null) {
    repaint();
}

Essentially, if there is an internal rowModel set up (i.e the user has configured some custom row heights), then the call to repaint() in this snippit causes the entire JTable to be marked as dirty – so the RepaintManager repaints the entire table component rather than just the cells affected by an update.

The effect of this is dramatic. When I run my test class (included below):

***** Running test for table Standard JTable under 1.5.0_19
With all rows default height total time spent repainting table 24 millis
Setting custom row height for one row
With one custom row height total time spent repainting table 5427 millis
Thats 226 times slower than it should be to paint the table!

Here is a Fix for the issue

I’m pretty surprised this issue hasn’t been fixed already, since custom row heights can’t be all that rare. I have produced a workaround by extending JTable to override the tableChanged() handling for update events. Fixing this has solved major problems for our users whose tables were configured to require custom row heights.

This works fine for for me in 1.5.*.
It will also work for jdk 1.6.* but it does not support the 1.6 table row sorting (you’d need to find a way to fire sortedTableChanged() events to the sortManager to do this when the updates are processed)

   import javax.swing.table.TableModel;
import javax.swing.event.TableModelEvent;
import javax.swing.*;
import java.awt.*;


/**
 * Extends JTable to fix the broken repainting for updates when there are custom height rows
 * See http://www.objectdefinitions.com/odblog/2009/jtable-setrowheight-causes-slow-repainting/
 *
 * This fix does not support row sorting in jdk1.6 -
 * to support that would require firing sortedTableChanged to sortManager
 */

public class FixedForRowHeightJTable extends JTable {

    public FixedForRowHeightJTable() {
    }

    public FixedForRowHeightJTable(TableModel tableModel) {
        super(tableModel);
    }

    public void tableChanged(TableModelEvent e) {
        //if just an update, and not a data or structure changed event or an insert or delete, use the fixed row update handling
        //otherwise call super.tableChanged to let the standard JTable update handling manage it
        if ( e != null &&
            e.getType() == TableModelEvent.UPDATE &&
            e.getFirstRow() != TableModelEvent.HEADER_ROW &&
            e.getLastRow() != Integer.MAX_VALUE) {

            handleRowUpdate(e);
        } else {
            super.tableChanged(e);
        }
    }

    /**
     * This borrows most of the logic from the superclass handling of update events, but changes the calculation of the height
     * for the dirty region to provide proper handling for repainting custom height rows
     */

    private void handleRowUpdate(TableModelEvent e) {
        int modelColumn = e.getColumn();
        int start = e.getFirstRow();
        int end = e.getLastRow();

        Rectangle dirtyRegion;
        if (modelColumn == TableModelEvent.ALL_COLUMNS) {
            // 1 or more rows changed
            dirtyRegion = new Rectangle(0, start * getRowHeight(),
                                        getColumnModel().getTotalColumnWidth(), 0);
        }
        else {
            // A cell or column of cells has changed.
            // Unlike the rest of the methods in the JTable, the TableModelEvent
            // uses the coordinate system of the model instead of the view.
            // This is the only place in the JTable where this "reverse mapping"
            // is used.
            int column = convertColumnIndexToView(modelColumn);
            dirtyRegion = getCellRect(start, column, false);
        }

        // Now adjust the height of the dirty region
        dirtyRegion.height = 0;
        for ( int row=start; row <= end; row ++ ) {
            dirtyRegion.height += getRowHeight(row);  //THIS IS CHANGED TO CALCULATE THE DIRTY REGION HEIGHT CORRECTLY
        }
        repaint(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
    }

}

The following Test demonstrates the problem, and that FixedForRowHeightJTable solves it

This test pops up a JTable in a frame and then changes cell values to trigger repaints.
While doing this we replace the standard RepaintManager with a TimingRepaintManager, which keeps track of the total time spent by the Swing event thread painting the table
We first run the test for a standard JTable, then for my fixed version, with the following results:

***** Running test for table Standard JTable under 1.5.0_19
With all rows default height total time spent repainting table 24 millis
Setting custom row height for one row
With one custom row height total time spent repainting table 5427 millis

***** Running test for table FixedForRowHeight JTable under 1.5.0_19
With all rows default height total time spent repainting table 2 millis
Setting custom row height for one row
With one custom row height total time spent repainting table 25 millis

Needless to say, I’ve submitted a bug report for this one!
(although technically once might argue its an RFE, since the table still gets painted, anything which degrades performance this much is a bug in my book!)

import javax.swing.table.DefaultTableModel;
import javax.swing.table.TableModel;
import javax.swing.*;
import javax.swing.event.TableModelEvent;
import java.awt.*;
import java.lang.reflect.InvocationTargetException;

/**
 * Created by IntelliJ IDEA.
 * User: Nick Ebbutt
 * Date: 06-Jun-2009
 * Time: 13:34:58
 *
 * A demonstration of JTable repainting bug for custom row heights
 */

public class DemoForJTableCustomRowHeightRepaintBug {

    private static final int COL_COUNT = 40;
    private static final int ROW_COUNT = 50;
    private static TimingRepaintManager timingRepaintManager;
    private static JTable table;
    private static FixedForRowHeightJTable fixedTable;

    public static void main(String[] args) throws Exception {
        SwingUtilities.invokeAndWait(new Runnable() {
            public void run() {
                timingRepaintManager = new TimingRepaintManager();
                RepaintManager.setCurrentManager(timingRepaintManager);
                table = new JTable(new TestTableModel());
                fixedTable = new FixedForRowHeightJTable(new TestTableModel());
            }
        });

        runTestForTable("Standard JTable", table);
        runTestForTable("FixedForRowHeight JTable", fixedTable);
    }

    private static void runTestForTable(String description, final JTable table) throws Exception {
        System.out.println("\n***** Running test for table " + description + " under " + System.getProperty("java.version"));
        showTable(table, description);
        timingRepaintManager.resetTotalRepaintTime();
        runTableModelUpdates(table.getModel());

        System.out.println("With all rows default height total time spent repainting table " + timingRepaintManager.getTotalRepaintTime() + " millis");

        System.out.println("Setting custom row height for one row");
        setRandomRowHeight(table);

        timingRepaintManager.resetTotalRepaintTime();
        runTableModelUpdates(table.getModel());
        System.out.println("With one custom row height total time spent repainting table " + timingRepaintManager.getTotalRepaintTime() + " millis");
    }

    private static void setRandomRowHeight(final JTable table) throws InterruptedException, InvocationTargetException {
        //now just set one row to a custom height and run the repaint timing test again
        SwingUtilities.invokeAndWait(
            new Runnable() {
                public void run() {
                    table.setRowHeight(getRandomRowOrCol(ROW_COUNT), 100);
                }
            }
        );
    }

    public static void showTable(final JTable t, final String name) throws Exception {
        SwingUtilities.invokeAndWait(new Runnable() {
            public void run() {
                JFrame f = new JFrame(name);
                f.getContentPane().add(new JScrollPane(t));
                f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
                f.setSize(new Dimension(1024,768));
                f.setVisible(true);
            }
        });
    }

    public static void runTableModelUpdates(final TableModel tableModel) {
        for ( int loop=0; loop < 500; loop++) {
            try {
                SwingUtilities.invokeAndWait(new Runnable() {
                    public void run() {
                        tableModel.setValueAt("Wibble", getRandomRowOrCol(ROW_COUNT), getRandomRowOrCol(COL_COUNT));
                    }
                });
                Thread.sleep(10);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    //record the total time spent repainting
    private static class TimingRepaintManager extends RepaintManager {
        private volatile long totalTime;

        public void paintDirtyRegions() {
            long startTime = System.currentTimeMillis();
            super.paintDirtyRegions();
            totalTime += System.currentTimeMillis() - startTime;
        }

        public long getTotalRepaintTime() {
            return totalTime;
        }

        public void resetTotalRepaintTime() {
            this.totalTime = 0;
        }
    }


    private static class TestTableModel extends DefaultTableModel {
        public TestTableModel() {
            setColumnCount(COL_COUNT);
            for (int row=0; row < ROW_COUNT; row++) {
                addRow(createRow(row));
            }
        }

        private Object[] createRow(int row) {
            String[] colNames = new String[COL_COUNT];
            for ( int col=0; col<COL_COUNT; col++) {
                colNames[col] = "Cell " + row + ":" + col;
            }
            return colNames;
        }
    }

    public static int getRandomRowOrCol(int maxVal) {
        return (int)(Math.random() * maxVal);
    }

    /**
     * Extends JTable to fix the broken repainting for updates when there are custom height rows
     *
     * This fix does not support row sorting in jdk1.6 -
     * to support that would require firing sortedTableChanged to sortManager
     */

    private static class FixedForRowHeightJTable extends JTable {

        public FixedForRowHeightJTable(TableModel tableModel) {
            super(tableModel);
        }

        public void tableChanged(TableModelEvent e) {

            //if just an update, and not a data or structure changed event or an insert or delete, use the fixed row update handling
            //otherwise call super.tableChanged to let the standard JTable update handling manage it
            if ( e != null &&
                e.getType() == TableModelEvent.UPDATE &&
                e.getFirstRow() != TableModelEvent.HEADER_ROW &&
                e.getLastRow() != Integer.MAX_VALUE) {

                handleRowUpdate(e);
            } else {
                super.tableChanged(e);
            }
        }

        /**
         * This borrows most of the logic from the superclass handling of update events, but changes the calculation of the height
         * for the dirty region to provide proper handling for repainting custom height rows
         */

        private void handleRowUpdate(TableModelEvent e) {
            int modelColumn = e.getColumn();
            int start = e.getFirstRow();
            int end = e.getLastRow();

            Rectangle dirtyRegion;
            if (modelColumn == TableModelEvent.ALL_COLUMNS) {
                // 1 or more rows changed
                dirtyRegion = new Rectangle(0, start * getRowHeight(),
                                            getColumnModel().getTotalColumnWidth(), 0);
            }
            else {
                // A cell or column of cells has changed.
                // Unlike the rest of the methods in the JTable, the TableModelEvent
                // uses the coordinate system of the model instead of the view.
                // This is the only place in the JTable where this "reverse mapping"
                // is used.
                int column = convertColumnIndexToView(modelColumn);
                dirtyRegion = getCellRect(start, column, false);
            }

            // Now adjust the height of the dirty region
            dirtyRegion.height = 0;
            for ( int row=start; row <= end; row ++ ) {
                dirtyRegion.height += getRowHeight(row);  //THIS IS CHANGED TO CALCULATE THE DIRTY REGION HEIGHT CORRECTLY
            }
            repaint(dirtyRegion.x, dirtyRegion.y, dirtyRegion.width, dirtyRegion.height);
        }
    }

}

You can follow any responses to this entry through the RSS 2.0 feed. You can leave a response, or trackback from your own site.

AddThis Social Bookmark Button

5 Responses to “JTable setRowHeight causes slow repainting”

  1. Thanks for the post Nick. What is the status of the bug report you submitted to sun? Do you have a link to it?

  2. Hi Dave

    I submitted the report etc. but never received anything back from Sun… you don’t get a bug id until they process it but it was a long time ago now. Perhaps I should try submitting it again. Have you seen this problem too?

    Nick

  3. Thanks Nick. Yes I have seen the same issue. I think it is a big problem, especially if the data in your table is frequently updated. Please could you update the post if your hear anything from Sun?

  4. I tried your workaround and it vastly improved performance but caused some repainting issues. I believe the calculation of the y coordinate for the dirty region is sometimes incorrect ( because it isn’t adjusted like the height is)

    I’ve changed this:
    dirtyRegion = new Rectangle(0, start * getRowHeight(),getColumnModel().getTotalColumnWidth(), 0);

    To this:
    int rowStart = 0;
    for ( int row=0; row < start; row++ ) {
    rowStart += getRowHeight(row);
    }
    dirtyRegion = new Rectangle(0, rowStart, getColumnModel().getTotalColumnWidth(), 0);

    Would be interested to hear if you agree or not.

  5. Thanks Dave, I’ll check that out later

Leave a Reply