 首次调用为何要锁定数据库?一次讲透)
1. 为什么第一次 getCount() 会卡住主线程如果你写过 Android 里的列表页大概率见过这种场景CursorAdapter刚绑定上SQLiteCursor界面还没画出来主线程就卡了一小下StrictMode 立刻在 Logcat 里甩出一条DiskReadViolation。很多人第一反应是「查询太慢了」但把 SQL 拿到数据库工具里跑明明几毫秒就返回。问题往往不在 SQL 本身而在SQLiteCursor.getCount()的第一次调用。SQLiteCursor是个懒加载游标。构造它的时候Android 只准备好了 SQL 语句和参数并没有真正去读数据。真正的数据读取发生在第一次需要知道「有多少行」或者「取某一行」的时候。getCount()就是最常见的触发点CursorAdapter.getCount()会调用它ListView/RecyclerView的适配器一绑定就会问行数于是第一次getCount()顺理成章地在主线程上触发了底层填充。这个填充过程要做两件事一是通过 JNI 调用native_fill_window把数据读进CursorWindow这块共享内存二是这个读操作需要持有数据库锁。锁的粒度、持有时长直接决定了你主线程被阻塞多久。理解这条链路才能知道该往哪儿优化。这篇就围绕SQLiteCursor、getCount、数据库锁定这三个关键词把机制、复现、检测和优化一次讲透。2. 拆解 getCount 到 native_fill_window 的调用链2.1 SQLiteCursor.getCount() 的懒加载逻辑先看SQLiteCursor里getCount()的实现思路。它内部有个mCount字段初始值是NO_COUNT-1。第一次调用时发现是NO_COUNT就去执行fillWindow(0)填充完把真实行数写回mCount。之后再调用getCount()直接返回缓存值不再碰数据库。// SQLiteCursor 简化逻辑 Override public int getCount() { if (mCount NO_COUNT) { fillWindow(0); } return mCount; }所以「第一次调用锁定数据库」这个说法是准确的只有首次会触发填充后续getCount()是纯内存读取。这也解释了为什么第二次进同一个页面感觉快很多——游标被复用了或者数据已经在窗口里。2.2 fillWindow 与 CursorWindow 的关系fillWindow(int startPos)的核心工作是准备一块CursorWindow。CursorWindow是一块跨进程共享的内存缓冲区用来在 SQLite native 层和 Java 层之间传递行数据。它有个容量上限默认约 2MB由CursorWindow的配置决定一次填充能装多少行取决于每行大小。private void fillWindow(int startPos) { if (mWindow null) { mWindow new CursorWindow(true /* local only */); } else { mCursorState; queryThreadLock(); try { mWindow.clear(); } finally { queryThreadUnlock(); } } mWindow.setStartPosition(startPos); mCount mQuery.fillWindow(mWindow, mInitialRead, 0); if (mCount NO_COUNT) { mCount startPos mInitialRead; Thread t new Thread(new QueryThread(mCursorState), query thread); t.start(); } }这里有个细节值得注意如果一次填充没读完mCount NO_COUNT它会另起一个QueryThread后台线程继续读剩余数据。但首次填充本身是在调用getCount()的那个线程上同步执行的。也就是说主线程调getCount()主线程就要等这次填充完成。2.3 SQLiteQuery.fillWindow 里的数据库锁真正加锁的地方在SQLiteQuery.fillWindow()。它先调用mDatabase.lock()然后进 JNI 执行native_fill_window最后在finally里mDatabase.unlock()。int fillWindow(CursorWindow window, int maxRead, int lastPos) { long timeStart SystemClock.uptimeMillis(); mDatabase.lock(); mDatabase.logTimeStat(mSql, timeStart, SQLiteDatabase.GET_LOCK_LOG_PREFIX); try { acquireReference(); try { window.acquireReference(); int numRows native_fill_window(window, window.getStartPosition(), mOffsetIndex, maxRead, lastPos); if (SQLiteDebug.DEBUG_SQL_STATEMENTS) { Log.d(TAG, fillWindow(): mSql); } mDatabase.logTimeStat(mSql, timeStart); return numRows; } catch (IllegalStateException e) { return 0; } finally { window.releaseReference(); } } finally { releaseReference(); mDatabase.unlock(); } }mDatabase.lock()拿的是SQLiteDatabase的连接锁。Android 的 SQLite 连接池SQLiteConnectionPool对每个数据库文件维护一组连接写操作需要独占连接读操作可以共享但连接池的获取和锁的竞争都会带来等待。首次getCount()时如果此时恰好有别的线程在写同一个库主线程就得排队等锁卡顿就出现了。2.4 连接池视角锁到底锁了什么把视角拉到连接池层面会更清楚。SQLiteDatabase内部通过SQLiteConnectionPool管理连接lock()实际是向连接池申请一个可用连接并加锁。首次填充需要执行 native 查询必须拿到连接。如果连接池里所有连接都被占用比如后台有个大批量写入还没结束主线程就会阻塞在lock()上。所以「锁定数据库」这个表述更准确的理解是首次 getCount 触发的填充需要占用一个数据库连接并在整个 native 读取期间持有它。持有时长 连接等待时间 native 读取时间。优化方向也就清晰了要么减少 native 读取量要么别在主线程做这件事。3. 用 TaoToken 统一 Key 接入 AI 工具辅助分析日志排查这类问题光看源码不够还得结合真实设备上的日志和耗时数据。我习惯把 StrictMode 的违规日志、SQLiteDebug的锁等待日志丢给 AI 工具做归纳让它帮我从一堆堆栈里找出规律。但不同 AI 工具的 Key 管理很烦换来换去容易乱。TaoToken 提供统一 Key 接入一个 Key 就能对接多种模型省去到处配置的麻烦。官网入口是 https://taotoken.net/?utm_sourcetaotoken_aicg_blog_endutm_mediumcsdnutm_campaignrewriteutm_content API 地址是 https://taotoken.net/api 。如果你只是想让 AI 帮你读日志、解释堆栈用模型对话就行https://taotoken.net/api/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 。要是你打算长期在编码和 Agent 场景里用它可以看 Coding Planhttps://taotoken.net/api/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。拿到 Key 的路径是控制台和 API Keys 页面https://taotoken.net/api/console?utm_sourcetaotoken_aicg_blog_endutm_contentconsoleutm_campaignrewrite 和 https://taotoken.net/api/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 。接入文档在 https://taotoken.net/api/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。下面这段 Python 脚本就是我用它来批量分析 StrictMode 日志的把日志文件路径传进去即可。import os import requests API_URL https://taotoken.net/api/chat/completions API_KEY os.environ.get(TAOTOKEN_API_KEY) def analyze_log(log_text): headers { Authorization: fBearer {API_KEY}, Content-Type: application/json, } payload { model: claude-sonnet-4-20250514, messages: [ { role: system, content: 你是 Android 性能分析助手请从日志中找出 SQLite 锁等待和主线程 IO 的规律输出耗时排序。, }, {role: user, content: log_text}, ], temperature: 0.2, } resp requests.post(API_URL, headersheaders, jsonpayload, timeout60) resp.raise_for_status() return resp.json()[choices][0][message][content] if __name__ __main__: with open(strictmode.log, r, encodingutf-8) as f: log f.read() print(analyze_log(log))把strictmode.log换成你从设备抓下来的日志运行后 AI 会按耗时给你排个序哪些getCount触发的填充最慢一目了然。这一步不是必须的但能帮你从「感觉卡」变成「知道卡在哪一行」。4. 可复制的复现 Demo 与 StrictMode 检测配置4.1 最小复现主线程首次 getCount下面这个 Demo 故意在主线程上构造SQLiteCursor并调用getCount()同时插入一批数据让填充有实际工作量。你可以直接放进一个 Activity 的onCreate里跑。public class CursorLockDemoActivity extends Activity { Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); SQLiteDatabase db openOrCreateDatabase(demo.db, MODE_PRIVATE, null); db.execSQL(CREATE TABLE IF NOT EXISTS t (id INTEGER PRIMARY KEY, name TEXT, payload TEXT)); // 插入 5000 行每行带一段文本让 CursorWindow 填充有实际开销 db.beginTransaction(); try { for (int i 0; i 5000; i) { db.execSQL(INSERT INTO t (name, payload) VALUES (?, ?), new Object[]{row- i, payload- i - repeat(x, 200)}); } db.setTransactionSuccessful(); } finally { db.endTransaction(); } // 关键在主线程构造 Cursor 并首次 getCount long start SystemClock.uptimeMillis(); Cursor cursor db.rawQuery(SELECT * FROM t, null); int count cursor.getCount(); // 首次调用触发 fillWindow 与数据库锁 long cost SystemClock.uptimeMillis() - start; Log.d(CursorLockDemo, first getCount count , cost cost ms); cursor.close(); db.close(); } private static String repeat(String s, int n) { StringBuilder sb new StringBuilder(); for (int i 0; i n; i) sb.append(s); return sb.toString(); } }跑起来后看 Logcat 里的cost在低端机上通常能看到几十到几百毫秒的耗时。这就是首次getCount()锁定数据库带来的主线程阻塞。4.2 StrictMode 配置抓住 DiskReadViolation光看耗时不够还要让 StrictMode 明确告诉你这是磁盘读。在Application或 Activity 里开启Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder() .detectDiskReads() .detectDiskWrites() .detectNetwork() .penaltyLog() .build()); StrictMode.setVmPolicy(new StrictMode.VmPolicy.Builder() .detectLeakedSqlLiteObjects() .detectLeakedClosableObjects() .penaltyLog() .build()); // ... 后续复现代码 }开启后首次getCount()会触发DiskReadViolation堆栈里能看到SQLiteCursor.getCount→fillWindow→SQLiteQuery.fillWindow→native_fill_window这条完整链路。把这条堆栈和耗时日志一起丢给第 3 节的脚本分析就能定位到具体是哪个查询、哪次填充最慢。4.3 优化前后对比把填充挪出主线程优化思路很直接别在主线程做首次填充。用AsyncTask、Executor或者协程都行这里用线程池演示。ExecutorService executor Executors.newSingleThreadExecutor(); executor.execute(() - { long start SystemClock.uptimeMillis(); Cursor cursor db.rawQuery(SELECT * FROM t, null); int count cursor.getCount(); // 在后台线程触发填充 long cost SystemClock.uptimeMillis() - start; Log.d(CursorLockDemo, bg first getCount count , cost cost ms); runOnUiThread(() - { // 填充完成后主线程再 getCount 就是纯内存读取 long uiStart SystemClock.uptimeMillis(); int cached cursor.getCount(); Log.d(CursorLockDemo, ui getCount cached , cost (SystemClock.uptimeMillis() - uiStart) ms); }); });实测下来后台线程首次填充的耗时和主线程差不多但主线程不再被阻塞StrictMode 的DiskReadViolation也消失了。主线程那次getCount()因为mCount已经缓存耗时基本是 0。这就是「锁定数据库」这件事被挪走之后最直观的收益。5. 本篇常见错排查5.1 为什么第二次 getCount 不卡了因为mCount已经被缓存getCount()直接返回不再进fillWindow。如果你发现某个页面第一次卡、第二次不卡基本可以确定是首次填充的问题而不是 SQL 慢。排查时重点看首次调用路径。5.2 后台线程填充后主线程还卡检查是不是在后台线程填充完成前主线程又调用了moveToFirst()或getString()之类的方法。这些方法同样可能触发窗口填充或数据读取。确保所有会碰数据的方法都在填充完成后、且在非主线程执行或者用CursorLoader这类框架帮你管理。5.3 StrictMode 没报 DiskReadViolation确认detectDiskReads()已开启且penaltyLog()生效。有些设备或 ROM 会过滤 StrictMode 日志可以换成penaltyDialog()临时确认。另外如果填充发生在ContentProvider的query()里违规点可能记在 Provider 线程上要看全量日志。5.4 锁等待时间远大于读取时间如果日志里GET_LOCK_LOG_PREFIX记录的锁等待很长说明连接池竞争激烈。检查是否有后台大批量写入、事务没及时提交或者多个线程同时操作同一个SQLiteDatabase。把写操作合并成批量事务、缩短事务持有时间能明显降低锁等待。5.5 CursorWindow 太小导致多次填充CursorWindow默认容量有限如果单行数据很大一次填充装不下会触发QueryThread后台继续读。这本身不阻塞主线程但会让getCount()返回的是「已读行数」而非总行数后续moveToPosition可能触发新的填充。排查时看CursorWindow的getNumRows()和实际总行数是否一致。6. 把首次填充挪出主线程锁就不再是问题回到最初的问题SQLiteCursor.getCount()首次调用为什么要锁定数据库因为首次调用会触发fillWindow而fillWindow需要占用一个数据库连接并在 native 读取期间持有它。锁本身不是 bug它是保证数据一致性的必要机制。真正的问题是「在主线程上做这件事」。我的经验是凡是会触发首次填充的操作都别放在主线程。用后台线程或框架提前把getCount()跑一遍让mCount缓存好主线程后续读取就是纯内存操作。配合 StrictMode 检测和日志分析你能清楚看到优化前后的耗时差异。如果你也想用 AI 辅助分析这些日志可以从模型对话开始试https://taotoken.net/api/model-chat?utm_sourcetaotoken_aicg_blog_endutm_contentmodel_chatutm_campaignrewrite 长期编码场景再看 Coding Planhttps://taotoken.net/api/coding-plan?utm_sourcetaotoken_aicg_blog_endutm_contentcoding_planutm_campaignrewrite 。接入前记得先去 API Keys 页面拿 Keyhttps://taotoken.net/api/api-keys?utm_sourcetaotoken_aicg_blog_endutm_contentapi_keysutm_campaignrewrite 具体调用方式看文档https://taotoken.net/api/doc?utm_sourcetaotoken_aicg_blog_endutm_contentdocutm_campaignrewrite 。