☰
SQLiteCursor.getCount() 首次调用为何要锁定数据库?一次讲透
2026/9/29 22:42:02 网站建设 项目流程

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_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= ,API 地址是 https://taotoken.net/api 。如果你只是想让 AI 帮你读日志、解释堆栈,用模型对话就行:https://taotoken.net/api/model-chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 。要是你打算长期在编码和 Agent 场景里用它,可以看 Coding Plan:https://taotoken.net/api/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。

拿到 Key 的路径是控制台和 API Keys 页面:https://taotoken.net/api/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 和 https://taotoken.net/api/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 。接入文档在 https://taotoken.net/api/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。下面这段 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": f"Bearer {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, headers=headers, json=payload, timeout=60) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] if __name__ == "__main__": with open("strictmode.log", "r", encoding="utf-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_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite ,长期编码场景再看 Coding Plan:https://taotoken.net/api/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。接入前记得先去 API Keys 页面拿 Key:https://taotoken.net/api/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite ,具体调用方式看文档:https://taotoken.net/api/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询