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