ContentCache.java revision c159d32be05ee744f3726579d9cd8eed39545137
1/*
2 * Copyright (C) 2010 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.email.provider;
18
19import com.android.email.Email;
20
21import android.content.ContentValues;
22import android.database.Cursor;
23import android.database.CursorWrapper;
24import android.database.MatrixCursor;
25import android.net.Uri;
26import android.util.Log;
27
28import java.util.ArrayList;
29import java.util.Arrays;
30import java.util.HashMap;
31import java.util.LinkedHashMap;
32import java.util.Map;
33import java.util.Set;
34
35/**
36 * An LRU cache for EmailContent (Account, HostAuth, Mailbox, and Message, thus far).  The intended
37 * user of this cache is EmailProvider itself; caching is entirely transparent to users of the
38 * provider.
39 *
40 * Usage examples; id is a String representation of a row id (_id), as it might be retrieved from
41 * a uri via getPathSegment
42 *
43 * To create a cache:
44 *    ContentCache cache = new ContentCache(name, projection, max);
45 *
46 * To (try to) get a cursor from a cache:
47 *    Cursor cursor = cache.getCursor(id, projection);
48 *
49 * To read from a table and cache the resulting cursor:
50 * 1. Get a CacheToken: CacheToken token = cache.getToken(id);
51 * 2. Get a cursor from the database: Cursor cursor = db.query(....);
52 * 3. Put the cursor in the cache: cache.putCursor(cursor, id, token);
53 * Only cursors with the projection given in the definition of the cache can be cached
54 *
55 * To delete one or more rows or update multiple rows from a table that uses cached data:
56 * 1. Lock the row in the cache: cache.lock(id);
57 * 2. Delete/update the row(s): db.delete(...);
58 * 3. Invalidate any other caches that might be affected by the delete/update:
59 *      The entire cache: affectedCache.invalidate()*
60 *      A specific row in a cache: affectedCache.invalidate(rowId)
61 * 4. Unlock the row in the cache: cache.unlock(id);
62 *
63 * To update a single row from a table that uses cached data:
64 * 1. Lock the row in the cache: cache.lock(id);
65 * 2. Update the row: db.update(...);
66 * 3. Unlock the row in the cache, passing in the new values: cache.unlock(id, values);
67 */
68public final class ContentCache extends LinkedHashMap<String, Cursor> {
69    private static final long serialVersionUID = 1L;
70
71    private static final boolean DEBUG_CACHE = false;  // DO NOT CHECK IN TRUE
72    private static final boolean DEBUG_TOKENS = false;  // DO NOT CHECK IN TRUE
73    private static final boolean DEBUG_NOT_CACHEABLE = false;  // DO NOT CHECK IN TRUE
74
75    // If false, reads will not use the cache; this is intended for debugging only
76    private static final boolean READ_CACHE_ENABLED = true;  // DO NOT CHECK IN FALSE
77
78    // Count of non-cacheable queries (debug only)
79    private static int sNotCacheable = 0;
80    // A map of queries that aren't cacheable (debug only)
81    private static final CounterMap<String> sNotCacheableMap = new CounterMap<String>();
82
83    // All defined caches
84    private static final ArrayList<ContentCache> sContentCaches = new ArrayList<ContentCache>();
85    // A set of all unclosed, cached cursors; this will typically be a very small set, as cursors
86    // tend to be closed quickly after use.  The value, for each cursor, is its reference count
87    /*package*/ static CounterMap<Cursor> sActiveCursors;
88
89    // A set of locked content id's
90    private final CounterMap<String> mLockMap = new CounterMap<String>(4);
91    // A set of active tokens
92    /*package*/ TokenList mTokenList;
93
94    // The name of the cache (used for logging)
95    private final String mName;
96    // The base projection (only queries in which all columns exist in this projection will be
97    // able to avoid a cache miss)
98    private final String[] mBaseProjection;
99    // The number of items (cursors) to cache
100    private final int mMaxSize;
101    // The tag used for logging
102    private final String mLogTag;
103    // Cache statistics
104    private final Statistics mStats;
105
106    /**
107     * A synchronized reference counter for arbitrary objects
108     */
109    /*package*/ static class CounterMap<T> {
110        private static final long serialVersionUID = 1L;
111        private HashMap<T, Integer> mMap;
112
113        /*package*/ CounterMap(int maxSize) {
114            mMap = new HashMap<T, Integer>(maxSize);
115        }
116
117        /*package*/ CounterMap() {
118            mMap = new HashMap<T, Integer>();
119        }
120
121        /*package*/ synchronized int subtract(T object) {
122            Integer refCount = mMap.get(object);
123            int newCount;
124            if (refCount == null || refCount.intValue() == 0) {
125                throw new IllegalStateException();
126            }
127            if (refCount > 1) {
128                newCount = refCount - 1;
129                mMap.put(object, newCount);
130            } else {
131                newCount = 0;
132                mMap.remove(object);
133            }
134            return newCount;
135        }
136
137        /*package*/ synchronized void add(T object) {
138            Integer refCount = mMap.get(object);
139            if (refCount == null) {
140                mMap.put(object, 1);
141            } else {
142                mMap.put(object, refCount + 1);
143            }
144        }
145
146        /*package*/ synchronized boolean contains(T object) {
147            return mMap.containsKey(object);
148        }
149
150        /*package*/ synchronized int getCount(T object) {
151            Integer refCount = mMap.get(object);
152            return (refCount == null) ? 0 : refCount.intValue();
153        }
154
155        synchronized int size() {
156            return mMap.size();
157        }
158
159        /**
160         * For Debugging Only - not efficient
161         */
162        synchronized Set<HashMap.Entry<T, Integer>> entrySet() {
163            return mMap.entrySet();
164        }
165    }
166
167    /**
168     * A list of tokens that are in use at any moment; there can be more than one token for an id
169     */
170    /*package*/ static class TokenList extends ArrayList<CacheToken> {
171        private static final long serialVersionUID = 1L;
172        private final String mLogTag;
173
174        /*package*/ TokenList(String name) {
175            mLogTag = "TokenList-" + name;
176        }
177
178        /*package*/ int invalidateTokens(String id) {
179            if (DEBUG_TOKENS) {
180                Log.d(mLogTag, "============ Invalidate tokens for: " + id);
181            }
182            ArrayList<CacheToken> removeList = new ArrayList<CacheToken>();
183            int count = 0;
184            for (CacheToken token: this) {
185                if (token.getId().equals(id)) {
186                    token.invalidate();
187                    removeList.add(token);
188                    count++;
189                }
190            }
191            for (CacheToken token: removeList) {
192                remove(token);
193            }
194            return count;
195        }
196
197        /*package*/ void invalidate() {
198            if (DEBUG_TOKENS) {
199                Log.d(mLogTag, "============ List invalidated");
200            }
201            for (CacheToken token: this) {
202                token.invalidate();
203            }
204            clear();
205        }
206
207        /*package*/ boolean remove(CacheToken token) {
208            boolean result = super.remove(token);
209            if (DEBUG_TOKENS) {
210                if (result) {
211                    Log.d(mLogTag, "============ Removing token for: " + token.mId);
212                } else {
213                    Log.d(mLogTag, "============ No token found for: " + token.mId);
214                }
215            }
216            return result;
217        }
218
219        public CacheToken add(String id) {
220            CacheToken token = new CacheToken(id);
221            super.add(token);
222            if (DEBUG_TOKENS) {
223                Log.d(mLogTag, "============ Taking token for: " + token.mId);
224            }
225            return token;
226        }
227    }
228
229    /**
230     * A CacheToken is an opaque object that must be passed into putCursor in order to attempt to
231     * write into the cache.  The token becomes invalidated by any intervening write to the cached
232     * record.
233     */
234    public static final class CacheToken {
235        private static final long serialVersionUID = 1L;
236        private final String mId;
237        private boolean mIsValid = READ_CACHE_ENABLED;
238
239        /*package*/ CacheToken(String id) {
240            mId = id;
241        }
242
243        /*package*/ String getId() {
244            return mId;
245        }
246
247        /*package*/ boolean isValid() {
248            return mIsValid;
249        }
250
251        /*package*/ void invalidate() {
252            mIsValid = false;
253        }
254
255        @Override
256        public boolean equals(Object token) {
257            return ((token instanceof CacheToken) && ((CacheToken)token).mId.equals(mId));
258        }
259
260        @Override
261        public int hashCode() {
262            return mId.hashCode();
263        }
264    }
265
266    /**
267     * The cached cursor is simply a CursorWrapper whose underlying cursor contains zero or one
268     * rows.  We handle simple movement (moveToFirst(), moveToNext(), etc.), and override close()
269     * to keep the underlying cursor alive (unless it's no longer cached due to an invalidation).
270     * Multiple CachedCursor's can use the same underlying cursor, so we override the various
271     * moveX methods such that each CachedCursor can have its own position information
272     */
273    public static final class CachedCursor extends CursorWrapper {
274        // The cursor we're wrapping
275        private final Cursor mCursor;
276        // The cache which generated this cursor
277        private final ContentCache mCache;
278        // The current position of the cursor (can only be 0 or 1)
279        private int mPosition = -1;
280        // The number of rows in this cursor (-1 = not determined)
281        private int mCount = -1;
282        private boolean isClosed = false;
283
284        public CachedCursor(Cursor cursor, ContentCache cache, String name) {
285            super(cursor);
286            mCursor = cursor;
287            mCache = cache;
288            // Add this to our set of active cursors
289            sActiveCursors.add(cursor);
290        }
291
292        /**
293         * Close this cursor; if the cursor's cache no longer contains the underlying cursor, and
294         * there are no other users of that cursor, we'll close it here. In any event,
295         * we'll remove the cursor from our set of active cursors.
296         */
297        @Override
298        public void close() {
299            int count = sActiveCursors.subtract(mCursor);
300            if ((count == 0) && !mCache.containsValue(mCursor)) {
301                super.close();
302            }
303            isClosed = true;
304        }
305
306        @Override
307        public boolean isClosed() {
308            return isClosed;
309        }
310
311        @Override
312        public int getCount() {
313            if (mCount < 0) {
314                mCount = super.getCount();
315            }
316            return mCount;
317        }
318
319        /**
320         * We'll be happy to move to position 0 or -1
321         */
322        @Override
323        public boolean moveToPosition(int pos) {
324            if (pos >= getCount() || pos < -1) {
325                return false;
326            }
327            mPosition = pos;
328            return true;
329        }
330
331        @Override
332        public boolean moveToFirst() {
333            return moveToPosition(0);
334        }
335
336        @Override
337        public boolean moveToNext() {
338            return moveToPosition(mPosition + 1);
339        }
340
341        @Override
342        public boolean moveToPrevious() {
343            return moveToPosition(mPosition - 1);
344        }
345
346        @Override
347        public int getPosition() {
348            return mPosition;
349        }
350
351        @Override
352        public final boolean move(int offset) {
353            return moveToPosition(mPosition + offset);
354        }
355
356        @Override
357        public final boolean moveToLast() {
358            return moveToPosition(getCount() - 1);
359        }
360
361        @Override
362        public final boolean isLast() {
363            return mPosition == (getCount() - 1);
364        }
365
366        @Override
367        public final boolean isBeforeFirst() {
368            return mPosition == -1;
369        }
370
371        @Override
372        public final boolean isAfterLast() {
373            return mPosition == 1;
374        }
375    }
376
377    /**
378     * Public constructor
379     * @param name the name of the cache (used for logging)
380     * @param baseProjection the projection used for cached cursors; queries whose columns are not
381     *  included in baseProjection will always generate a cache miss
382     * @param maxSize the maximum number of content cursors to cache
383     */
384    public ContentCache(String name, String[] baseProjection, int maxSize) {
385        super();
386        mName = name;
387        mMaxSize = maxSize;
388        mBaseProjection = baseProjection;
389        mLogTag = "ContentCache-" + name;
390        sContentCaches.add(this);
391        mTokenList = new TokenList(mName);
392        sActiveCursors = new CounterMap<Cursor>(maxSize);
393        mStats = new Statistics(this);
394    }
395
396    /**
397     * Return the base projection for cached rows
398     * Get the projection used for cached rows (typically, the largest possible projection)
399     * @return
400     */
401    public String[] getProjection() {
402        return mBaseProjection;
403    }
404
405
406    /**
407     * Get a CacheToken for a row as specified by its id (_id column)
408     * @param id the id of the record
409     * @return a CacheToken needed in order to write data for the record back to the cache
410     */
411    public synchronized CacheToken getCacheToken(String id) {
412        // If another thread is already writing the data, return an invalid token
413        CacheToken token = mTokenList.add(id);
414        if (mLockMap.contains(id)) {
415            token.invalidate();
416        }
417        return token;
418    }
419
420    /* (non-Javadoc)
421     * @see java.util.LinkedHashMap#removeEldestEntry(java.util.Map.Entry)
422     */
423    @Override
424    public synchronized boolean removeEldestEntry(Map.Entry<String,Cursor> entry) {
425        // If we're above the maximum size for this cache, remove the LRU cache entry
426        if (size() > mMaxSize) {
427            Cursor cursor = entry.getValue();
428            // Close this cursor if it's no longer being used
429            if (!sActiveCursors.contains(cursor)) {
430                cursor.close();
431            }
432            return true;
433        }
434        return false;
435    }
436
437    /**
438     * Try to cache a cursor for the given id and projection; returns a valid cursor, either a
439     * cached cursor (if caching was successful) or the original cursor
440     *
441     * @param c the cursor to be cached
442     * @param id the record id (_id) of the content
443     * @param projection the projection represented by the cursor
444     * @return whether or not the cursor was cached
445     */
446    public Cursor putCursor(Cursor c, String id, String[] projection, CacheToken token) {
447        // Make sure the underlying cursor is at the first row, and do this without synchronizing,
448        // to prevent deadlock with a writing thread (which might, for example, be calling into
449        // CachedCursor.invalidate)
450        c.moveToPosition(0);
451        return putCursorImpl(c, id, projection, token);
452    }
453
454    public synchronized Cursor putCursorImpl(Cursor c, String id, String[] projection,
455            CacheToken token) {
456        try {
457            if (!token.isValid()) {
458                if (DEBUG_CACHE) {
459                    Log.d(mLogTag, "============ Stale token for " + id);
460                }
461                mStats.mStaleCount++;
462                return c;
463            }
464            if (c != null && projection == mBaseProjection) {
465                if (DEBUG_CACHE) {
466                    Log.d(mLogTag, "============ Caching cursor for: " + id);
467                }
468                // If we've already cached this cursor, invalidate the older one
469                Cursor existingCursor = get(id);
470                if (existingCursor != null) {
471                   unlockImpl(id, null, false);
472                }
473                put(id, c);
474                return new CachedCursor(c, this, id);
475            }
476            return c;
477        } finally {
478            mTokenList.remove(token);
479        }
480    }
481
482    /**
483     * Find and, if found, return a cursor, based on cached values, for the supplied id
484     * @param id the _id column of the desired row
485     * @param projection the requested projection for a query
486     * @return a cursor based on cached values, or null if the row is not cached
487     */
488    public synchronized Cursor getCachedCursor(String id, String[] projection) {
489        if (Email.DEBUG) {
490            // Every 200 calls to getCursor, report cache statistics
491            dumpOnCount(200);
492        }
493        if (projection == mBaseProjection) {
494            return getCachedCursorImpl(id);
495        } else {
496            return getMatrixCursor(id, projection);
497        }
498    }
499
500    private CachedCursor getCachedCursorImpl(String id) {
501        Cursor c = get(id);
502        if (c != null) {
503            mStats.mHitCount++;
504            return new CachedCursor(c, this, id);
505        }
506        mStats.mMissCount++;
507        return null;
508    }
509
510    private MatrixCursor getMatrixCursor(String id, String[] projection) {
511        return getMatrixCursor(id, projection, null);
512    }
513
514    private MatrixCursor getMatrixCursor(String id, String[] projection,
515            ContentValues values) {
516        Cursor c = get(id);
517        if (c != null) {
518            // Make a new MatrixCursor with the requested columns
519            MatrixCursor mc = new MatrixCursor(projection, 1);
520            Object[] row = new Object[projection.length];
521            if (values != null) {
522                // Make a copy; we don't want to change the original
523                values = new ContentValues(values);
524            }
525            int i = 0;
526            for (String column: projection) {
527                int columnIndex = c.getColumnIndex(column);
528                if (columnIndex < 0) {
529                    mStats.mProjectionMissCount++;
530                    return null;
531                } else {
532                    String value;
533                    if (values != null && values.containsKey(column)) {
534                        Object val = values.get(column);
535                        if (val instanceof Boolean) {
536                            value = (val == Boolean.TRUE) ? "1" : "0";
537                        } else {
538                            value = values.getAsString(column);
539                        }
540                        values.remove(column);
541                    } else {
542                        value = c.getString(columnIndex);
543                    }
544                    row[i++] = value;
545                }
546            }
547            if (values != null && values.size() != 0) {
548                return null;
549            }
550            mc.addRow(row);
551            mStats.mHitCount++;
552            return mc;
553        }
554        mStats.mMissCount++;
555        return null;
556    }
557
558    /**
559     * Lock a given row, such that no new valid CacheTokens can be created for the passed-in id.
560     * @param id the id of the row to lock
561     */
562    public synchronized void lock(String id) {
563        // Prevent new valid tokens from being created
564        mLockMap.add(id);
565        // Invalidate current tokens
566        int count = mTokenList.invalidateTokens(id);
567        if (DEBUG_TOKENS) {
568            Log.d(mTokenList.mLogTag, "============ Lock invalidated " + count +
569                    " tokens for: " + id);
570        }
571    }
572
573    /**
574     * Unlock a given row, allowing new valid CacheTokens to be created for the passed-in id.
575     * @param id the id of the item whose cursor is cached
576     */
577    public synchronized void unlock(String id) {
578        unlockImpl(id, null, true);
579    }
580
581    /**
582     * If the row with id is currently cached, replaces the cached values with the supplied
583     * ContentValues.  Then, unlock the row, so that new valid CacheTokens can be created.
584     *
585     * @param id the id of the item whose cursor is cached
586     * @param values updated values for this row
587     */
588    public synchronized void unlock(String id, ContentValues values) {
589        unlockImpl(id, values, true);
590    }
591
592    /**
593     * If values are passed in, replaces any cached cursor with one containing new values, and
594     * then closes the previously cached one (if any, and if not in use)
595     * If values are not passed in, removes the row from cache
596     * If the row was locked, unlock it
597     * @param id the id of the row
598     * @param values new ContentValues for the row (or null if row should simply be removed)
599     * @param wasLocked whether or not the row was locked; if so, the lock will be removed
600     */
601    public void unlockImpl(String id, ContentValues values, boolean wasLocked) {
602        Cursor c = get(id);
603        if (c != null) {
604            if (DEBUG_CACHE) {
605                Log.d(mLogTag, "=========== Unlocking cache for: " + id);
606            }
607            if (values != null) {
608                MatrixCursor cursor = getMatrixCursor(id, mBaseProjection, values);
609                if (cursor != null) {
610                    if (DEBUG_CACHE) {
611                        Log.d(mLogTag, "=========== Recaching with new values: " + id);
612                    }
613                    cursor.moveToFirst();
614                    put(id, cursor);
615                } else {
616                    remove(id);
617                }
618            } else {
619                remove(id);
620            }
621            // If there are no cursors using the old cached cursor, close it
622            if (!sActiveCursors.contains(c)) {
623                c.close();
624            }
625        }
626        if (wasLocked) {
627            mLockMap.subtract(id);
628        }
629    }
630
631    /**
632     * Invalidate the entire cache, without logging
633     */
634    public synchronized void invalidate() {
635        invalidate(null, null, null);
636    }
637
638    /**
639     * Invalidate the entire cache; the arguments are used for logging only, and indicate the
640     * write operation that caused the invalidation
641     *
642     * @param operation a string describing the operation causing the invalidate (or null)
643     * @param uri the uri causing the invalidate (or null)
644     * @param selection the selection used with the uri (or null)
645     */
646    public synchronized void invalidate(String operation, Uri uri, String selection) {
647        if (DEBUG_CACHE && (operation != null)) {
648            Log.d(mLogTag, "============ INVALIDATED BY " + operation + ": " + uri +
649                    ", SELECTION: " + selection);
650        }
651        mStats.mInvalidateCount++;
652        // Close all cached cursors that are no longer in use
653        for (String id: keySet()) {
654            Cursor c = get(id);
655            if (!sActiveCursors.contains(c)) {
656                c.close();
657            }
658        }
659        clear();
660        // Invalidate all current tokens
661        mTokenList.invalidate();
662    }
663
664    // Debugging code below
665
666    private void dumpOnCount(int num) {
667        mStats.mOpCount++;
668        if ((mStats.mOpCount % num) == 0) {
669            dumpStats();
670        }
671    }
672
673    /*package*/ void recordQueryTime(Cursor c, long nanoTime) {
674        if (c instanceof CachedCursor) {
675            mStats.hitTimes += nanoTime;
676            mStats.hits++;
677        } else {
678            if (c.getCount() == 1) {
679                mStats.missTimes += nanoTime;
680                mStats.miss++;
681            }
682        }
683    }
684
685    public static synchronized void notCacheable(Uri uri, String selection) {
686        if (DEBUG_NOT_CACHEABLE) {
687            sNotCacheable++;
688            String str = uri.toString() + "$" + selection;
689            sNotCacheableMap.add(str);
690        }
691    }
692
693    private static class CacheCounter implements Comparable<Object> {
694        String uri;
695        Integer count;
696
697        CacheCounter(String _uri, Integer _count) {
698            uri = _uri;
699            count = _count;
700        }
701
702        @Override
703        public int compareTo(Object another) {
704            CacheCounter x = (CacheCounter)another;
705            return x.count > count ? 1 : x.count == count ? 0 : -1;
706        }
707    }
708
709    public static void dumpNotCacheableQueries() {
710        int size = sNotCacheableMap.size();
711        CacheCounter[] array = new CacheCounter[size];
712
713        int i = 0;
714        for (Entry<String, Integer> entry: sNotCacheableMap.entrySet()) {
715            array[i++] = new CacheCounter(entry.getKey(), entry.getValue());
716        }
717        Arrays.sort(array);
718        for (CacheCounter cc: array) {
719            Log.d("NotCacheable", cc.count + ": " + cc.uri);
720        }
721    }
722
723    // For use with unit tests
724    public static void invalidateAllCachesForTest() {
725        for (ContentCache cache: sContentCaches) {
726            cache.invalidate();
727        }
728    }
729
730    static class Statistics {
731        private final ContentCache mCache;
732        private final String mName;
733
734        // Cache statistics
735        // The item is in the cache AND is used to create a cursor
736        private int mHitCount = 0;
737        // Basic cache miss (the item is not cached)
738        private int mMissCount = 0;
739        // Incremented when a cachePut is invalid due to an intervening write
740        private int mStaleCount = 0;
741        // A projection miss occurs when the item is cached, but not all requested columns are
742        // available in the base projection
743        private int mProjectionMissCount = 0;
744        // Incremented whenever the entire cache is invalidated
745        private int mInvalidateCount = 0;
746        // Count of operations put/get
747        private int mOpCount = 0;
748        // The following are for timing statistics
749        private long hits = 0;
750        private long hitTimes = 0;
751        private long miss = 0;
752        private long missTimes = 0;
753
754        // Used in toString() and addCacheStatistics()
755        private int mCursorCount = 0;
756        private int mTokenCount = 0;
757
758        Statistics(ContentCache cache) {
759            mCache = cache;
760            mName = mCache.mName;
761        }
762
763        Statistics(String name) {
764            mCache = null;
765            mName = name;
766        }
767
768        private void addCacheStatistics(ContentCache cache) {
769            if (cache != null) {
770                mHitCount += cache.mStats.mHitCount;
771                mMissCount += cache.mStats.mMissCount;
772                mProjectionMissCount += cache.mStats.mProjectionMissCount;
773                mStaleCount += cache.mStats.mStaleCount;
774                hitTimes += cache.mStats.hitTimes;
775                missTimes += cache.mStats.missTimes;
776                hits += cache.mStats.hits;
777                miss += cache.mStats.miss;
778                mCursorCount += cache.size();
779                mTokenCount += cache.mTokenList.size();
780            }
781        }
782
783        private void append(StringBuilder sb, String name, Object value) {
784            sb.append(", ");
785            sb.append(name);
786            sb.append(": ");
787            sb.append(value);
788        }
789
790        @Override
791        public String toString() {
792            if (mHitCount + mMissCount == 0) return "No cache";
793            int totalTries = mMissCount + mProjectionMissCount + mHitCount;
794            StringBuilder sb = new StringBuilder();
795            sb.append("Cache " + mName);
796            append(sb, "Cursors", mCache == null ? mCursorCount : mCache.size());
797            append(sb, "Hits", mHitCount);
798            append(sb, "Misses", mMissCount + mProjectionMissCount);
799            append(sb, "Inval", mInvalidateCount);
800            append(sb, "Tokens", mCache == null ? mTokenCount : mCache.mTokenList.size());
801            append(sb, "Hit%", mHitCount * 100 / totalTries);
802            append(sb, "\nHit time", hitTimes / 1000000.0 / hits);
803            append(sb, "Miss time", missTimes / 1000000.0 / miss);
804            return sb.toString();
805        }
806    }
807
808    public static void dumpStats() {
809        Statistics totals = new Statistics("Totals");
810
811        for (ContentCache cache: sContentCaches) {
812            if (cache != null) {
813                Log.d(cache.mName, cache.mStats.toString());
814                totals.addCacheStatistics(cache);
815            }
816        }
817        Log.d(totals.mName, totals.toString());
818    }
819}
820