做一个「真能顶得住」的 Android 内存压力工具
工程路径:
MemoryUseDemo适用场景:低内存策略验证、LMK / 杀进程观察、整机内存压力测试
做系统稳定性、低内存策略相关验证时,经常需要人为制造内存压力。听起来简单------申请一大块内存不释放就行。真落地后会发现:
- 申请数字涨上去了,过一会儿 RSS 又掉下来
- 单进程刚到 六七百 MB 就申请失败
- 多进程方案写了,界面却一直停在「等待汇报」,全是 0
这个小工具就是为解决这些问题写的:用 native 内存 + 持续踩页 + 多进程分摊 + 前台服务保活,尽量把压力做「实」。
目录
- 它解决什么问题
- 踩过的坑:为什么形不成压力
- 整体架构
- [关键实现一:Native 申请与持续踩页](#关键实现一:Native 申请与持续踩页)
- 关键实现二:多进程突破单进程上限
- [关键实现三:前台服务保活(API 34+ 易踩坑)](#关键实现三:前台服务保活(API 34+ 易踩坑))
- 关键实现四:跨进程状态汇总
- 怎么用
- 小结
1. 它解决什么问题
常见诉求:
- 把整机可用内存压到紧张,观察行为变化
- 验证低内存回调、进程回收、前后台策略
- 需要「持续」压力,而不是申请完就被系统慢慢卸掉
工具能力:
| 能力 | 说明 |
|---|---|
| 按总目标申请 | 输入总 MB,自动拆分到各进程 |
| 多进程分摊 | 1~8 个独立进程,突破单进程额度 |
| 持续踩页 | 达标后周期性写回每一页,对抗换出/回收 |
| 状态汇总 | 展示总申请量、总 RSS / Swap,以及每进程明细 |
2. 踩过的坑:为什么形不成压力
2.1 共享内存:引用还在,物理页没了
第一版用 SharedMemory:映射后按页写一遍,数字很好看。系统一紧张,ashmem 类页往往可被回收,RSS 掉下去,压力也随之消失。
2.2 大图:也不一定「实」
第二版换 Bitmap。像素多在 native,看起来更靠谱,但在部分机型上仍可能被换出;申请线程达标就停、不再访问,RSS 一样会慢慢降。
2.3 单进程额度天花板
即使用更实的 native 缓冲,单进程大约 600MB 左右 就容易申请失败。要 GB 级压力,必须多进程。
2.4 结论
- 选不容易被当缓存丢掉的占用方式(优先
ByteBuffer.allocateDirect) - 占完之后还要 持续访问(踩页)
- 单进程不够就 多进程均分
- 子进程必须用正确类型的 前台服务,否则起不来
3. 整体架构
text
┌─────────────────────┐
│ MainActivity │ 拆分总目标、启停服务、读状态文件汇总
│ (主进程,几乎不占) │
└──────────┬──────────┘
│ startForegroundService × N
▼
┌──────────┴──────────┐
│ MemWorker0 :mem0 │──► MemoryAllocator(申请 + 踩页)
│ MemWorker1 :mem1 │──► 写 files/mem_workers/worker_N.txt
│ MemWorker2 :mem2 │
│ ... │
│ MemWorker7 :mem7 │
└─────────────────────┘
- 主进程:只负责调度与 UI,不扛大内存
- 子进程:各自申请、各自保压、各自汇报
- 状态文件 :同 UID 下
filesDir共享,用来跨进程通信(简单够用)
4. 关键实现一:Native 申请与持续踩页
核心类:MemoryAllocator.kt
4.1 分块申请 DirectBuffer
优先 allocateDirect(不走 Java 堆上限那一套);失败再退回堆上的 ByteArray。
kotlin
private fun allocateChunk(chunkSize: Int): ByteBuffer? {
try {
return ByteBuffer.allocateDirect(chunkSize)
} catch (_: OutOfMemoryError) {
}
try {
val bytes = ByteArray(chunkSize)
return ByteBuffer.wrap(bytes)
} catch (_: OutOfMemoryError) {
return null
}
}
申请后立刻 按页写入,确保物理页真正落下来(页大小按 4KB):
kotlin
private fun touchPages(buffer: ByteBuffer, value: Byte) {
val size = buffer.capacity()
var offset = 0
while (offset < size) {
buffer.put(offset, value)
offset += PAGE_SIZE // 4096
}
if (size > 0) {
buffer.put(size - 1, value)
}
}
4.2 达标后不退出:keepResident
这是「RSS 慢慢掉」的对症药。达到目标后进入循环,每隔 500ms 对所有块再踩一遍:
kotlin
fun start(targetBytes: Long) {
stop()
synchronized(state) {
state.targetBytes = targetBytes
state.status = 1
state.stopRequested = false
state.worker = Thread {
allocateUntilTarget(targetBytes)
if (!isStopRequested() && getAllocatedBytes() > 0) {
keepResident() // 关键:持续保压
}
}.apply {
name = "MemoryAllocator"
isDaemon = false
start()
}
}
}
private fun keepResident() {
var stamp = 1
while (!isStopRequested()) {
val snapshot = synchronized(state) { state.buffers.toList() }
val value = stamp.toByte()
for (buffer in snapshot) {
if (isStopRequested()) return
touchPages(buffer, value)
}
stamp = if (stamp >= 127) 1 else stamp + 1
Thread.sleep(KEEP_INTERVAL_MS) // 500ms
}
}
如何解读界面上的 RSS / Swap:
- RSS 降、Swap 升 → 多半被换出
- 踩页正常时,RSS 应被重新顶回去
5. 关键实现二:多进程突破单进程上限
5.1 Manifest:每个 Service 绑独立进程
最多 8 个 worker,用 android:process=":memN" 拉起私有进程。类型使用 specialUse(测试工具场景):
xml
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<service
android:name=".MemWorker0"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":mem0">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Local memory pressure testing" />
</service>
<service
android:name=".MemWorker1"
android:exported="false"
android:foregroundServiceType="specialUse"
android:process=":mem1">
<property
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
android:value="Local memory pressure testing" />
</service>
<!-- MemWorker2 ... MemWorker7 同理 -->
对应多个空子类,方便在 Manifest 里分别声明进程:
kotlin
open class MemoryHoldService : Service() { /* 共用逻辑 */ }
class MemWorker0 : MemoryHoldService()
class MemWorker1 : MemoryHoldService()
// ...
class MemWorker7 : MemoryHoldService()
companion object {
val workerClasses: Array<Class<out MemoryHoldService>> = arrayOf(
MemWorker0::class.java,
MemWorker1::class.java,
// ...
MemWorker7::class.java,
)
}
5.2 主界面:均分目标并启动
kotlin
fun startHolding(targetMb: Long, processCount: Int) {
stopAllWorkers()
AllocationReporter.clearAll(this)
totalTargetMb = targetMb
activeWorkerCount = processCount
val base = targetMb / processCount
val remainder = targetMb % processCount
for (index in 0 until processCount) {
val workerTarget = base + if (index < remainder) 1 else 0
if (workerTarget <= 0) continue
val serviceIntent = Intent(this, MemoryHoldService.workerClasses[index])
.setAction(MemoryHoldService.ACTION_START)
.putExtra(MemoryHoldService.EXTRA_TARGET_MB, workerTarget)
.putExtra(MemoryHoldService.EXTRA_WORKER_INDEX, index)
ContextCompat.startForegroundService(this, serviceIntent)
}
}
举例:总目标 2048MB、进程数 4 → 每进程约 512MB。单进程容易在 ~600MB 触顶时,用 4~8 个进程更容易摸到 GB 级。
5.3 停止:用 stopService,不要「发 STOP 再 START」
早期用 startService(STOP) 会 误拉起 子进程,再和 startForegroundService(START) 抢时机,容易导致子进程起不来或秒停。正确做法:
kotlin
private fun stopAllWorkers() {
for (index in 0 until MemoryHoldService.MAX_WORKERS) {
stopService(Intent(this, MemoryHoldService.workerClasses[index]))
}
AllocationReporter.clearAll(this)
}
6. 关键实现三:前台服务保活(API 34+ 易踩坑)
子进程若不当前台服务跑,很容易被系统立刻收回。
在 targetSdk 34+ / API 34+ 上,Manifest 声明了 foregroundServiceType 后,startForeground 必须带上对应类型,否则服务直接崩溃------表现就是界面一直「等待汇报」、全是 0。
kotlin
private fun promoteToForeground(targetMb: Long) {
val notification: Notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(R.mipmap.ic_launcher)
.setContentTitle(getString(R.string.notification_title_worker, workerIndex + 1))
.setContentText(getString(R.string.notification_text_worker, targetMb))
.setOngoing(true)
.build()
// 关键:必须带 FOREGROUND_SERVICE_TYPE_SPECIAL_USE,与 Manifest 一致
ServiceCompat.startForeground(
this,
NOTIFICATION_ID_BASE + workerIndex,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE,
)
}
服务启动流程(简化):
kotlin
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
workerIndex = intent?.getIntExtra(EXTRA_WORKER_INDEX, 0) ?: 0
val targetMb = intent?.getLongExtra(EXTRA_TARGET_MB, 0) ?: 0
writeStatus(targetMb = targetMb, allocatedMb = 0, chunkCount = 0, status = 1)
try {
promoteToForeground(targetMb)
} catch (error: Throwable) {
writeStatus(targetMb = targetMb, allocatedMb = 0, chunkCount = 0, status = 3)
stopSelf()
return START_NOT_STICKY
}
if (targetMb > 0) {
holding = true
MemoryAllocator.start(targetMb * MEBIBYTE)
startReporting()
}
return START_NOT_STICKY
}
可用 adb shell ps -A | grep memoryusedemo 确认是否出现 :mem0、:mem1 等进程。
7. 关键实现四:跨进程状态汇总
各子进程的 MemoryAllocator 是进程内单例,主进程读不到。做法是:每个 worker 周期性把状态写到:
text
/data/data/<package>/files/mem_workers/worker_N.txt
同应用多进程共享同一 filesDir,主界面定时 readAll 汇总。
7.1 写入
kotlin
object AllocationReporter {
fun write(
context: Context,
workerIndex: Int,
targetMb: Long,
allocatedMb: Long,
chunkCount: Int,
rssMb: Long,
swapMb: Long,
status: Int,
) {
val file = statusFile(context, workerIndex)
file.parentFile?.mkdirs()
file.writeText(
buildString {
append("targetMb=").append(targetMb).append('\n')
append("allocatedMb=").append(allocatedMb).append('\n')
append("chunkCount=").append(chunkCount).append('\n')
append("rssMb=").append(rssMb).append('\n')
append("swapMb=").append(swapMb).append('\n')
append("status=").append(status).append('\n')
}
)
}
private fun statusDir(context: Context): File {
return File(context.applicationContext.filesDir, "mem_workers")
}
private fun statusFile(context: Context, workerIndex: Int): File {
return File(statusDir(context), "worker_$workerIndex.txt")
}
}
7.2 读取 RSS / Swap
子进程从 /proc/self/status 读本进程数据写入文件:
kotlin
private fun readProcessMemoryMb(): Pair<Long, Long> {
var rssKb = 0L
var swapKb = 0L
File("/proc/self/status").forEachLine { line ->
when {
line.startsWith("VmRSS:") -> rssKb = parseKb(line)
line.startsWith("VmSwap:") -> swapKb = parseKb(line)
}
}
return rssKb / 1024L to swapKb / 1024L
}
主界面把各 worker 的 allocatedMb / rssMb / swapMb 求和,并列出每进程明细。
8. 怎么用
- 安装并打开「内存占用工具」
- 填写 总目标内存(MB)
- 填写 进程数(1~8);单进程易触顶时建议 4 / 6 / 8
- 点「开始占用」,观察总申请量与各进程状态
- 测完点「释放内存」
注意:
- 通知栏可能出现多个前台通知(一进程一个),属预期现象
- 整机内存不够时,部分子进程被杀是正常现象
- 「已申请」是逻辑进度;是否真正吃内存,以 RSS(及 Swap 变化) 为准
- 仅建议在本机测试环境使用,可能造成卡顿或杀进程
快速验证命令示例:
bash
# 启动并带参数(示例:总 512MB,2 进程)
adb shell am start -n com.zgh.memoryusedemo/.MainActivity \
--el target_mb 512 --ei process_count 2
# 查看是否拉起子进程
adb shell ps -A | grep memoryusedemo
# 查看状态文件
adb shell run-as com.zgh.memoryusedemo cat files/mem_workers/worker_0.txt
9. 小结
| 问题 | 对策 | 对应代码 |
|---|---|---|
| 申请后被回收 / 换出 | DirectBuffer + 持续踩页 | MemoryAllocator.touchPages / keepResident |
| 单进程几百 MB 触顶 | Manifest 多进程 + 均分启动 | android:process + startHolding |
| 子进程起不来、界面全 0 | 前台服务带类型;用 stopService 停止 |
ServiceCompat.startForeground(...SPECIAL_USE) |
| 主界面看不到子进程进度 | 写共享目录状态文件 | AllocationReporter |
做内存压力 Demo,难点不在「申请」本身,而在:占得实、留得住、撑得大、启得来。把这四件事做对,「假占用」才能变成「真压力」。
附:主要源码文件
| 文件 | 职责 |
|---|---|
MemoryAllocator.kt |
单进程内申请 / 踩页 / 保压 |
MemoryHoldService.kt + MemWorker0..7 |
前台服务、拉起独立进程 |
AllocationReporter.kt |
跨进程状态文件读写 |
MainActivity.kt |
UI、拆分目标、汇总展示 |
AndroidManifest.xml |
权限、多进程 Service 声明 |