OOM

本文是<<iOS開(kāi)發(fā)高手課>> 第十四篇學(xué)習(xí)筆記.

OOM 的全稱是 Out-Of-Memory,是由于 iOS 的 Jetsam 機(jī)制造成的一種“另類” Crash,它不同于常規(guī)的 Crash,通過(guò) Signal 捕獲等 Crash 監(jiān)控方案無(wú)法捕獲到 OOM 事件。

JetSam 機(jī)制,指的是操作系統(tǒng)為了控制內(nèi)存資源過(guò)度使用而采用的一種資源管控機(jī)制。

2種情況觸發(fā) OOM:

  • 系統(tǒng)由于整體內(nèi)存使用過(guò)高,會(huì)基于優(yōu)先級(jí)策略殺死優(yōu)先級(jí)較低的 App;
  • 當(dāng)前 App 達(dá)到了 "highg water mark" ,系統(tǒng)也會(huì)強(qiáng)殺當(dāng)前 App(超過(guò)系統(tǒng)對(duì)當(dāng)前單個(gè) App 的內(nèi)存限制值)

通過(guò) JetsamEvent 日志計(jì)算內(nèi)存限制值

想要了解不同機(jī)器在不同系統(tǒng)版本的情況下,對(duì) App 的內(nèi)存限制是怎樣的,有一種方法就是查看手機(jī)中以 JetsamEvent 開(kāi)頭的系統(tǒng)日志(我們可以從設(shè)置 -> 隱私 -> 分析中看到這些日志)。

在這些系統(tǒng)日志中,查找崩潰原因時(shí)我們需要關(guān)注 per-process-limit 部分的 rpages。rpages 表示的是 ,App 占用的內(nèi)存頁(yè)數(shù)量;per-process-limit 表示的是,App 占用的內(nèi)存超過(guò)了系統(tǒng)對(duì)單個(gè) App 的內(nèi)存限制。

所有 App 徹底退出,只跑了一個(gè)為了測(cè)試內(nèi)存臨界值的 Demo App。循環(huán)申請(qǐng)內(nèi)存,ViewController 代碼如下

- (void)viewDidLoad {
    [super viewDidLoad];
    NSMutableArray *array = [NSMutableArray array];
    for (NSInteger index = 0; index < 10000000; index++) {
        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
        UIImage *image = [UIImage imageNamed:@"AppIcon"];
        imageView.image = image;
        [array addObject:imageView];
    }
}

崩潰后這部分日志的結(jié)構(gòu)如下:

{
    "uuid" : "fa38f53d-55ba-37e0-8392-800ea0a88018",
// states:當(dāng)前應(yīng)用的運(yùn)行狀態(tài),對(duì)于這個(gè)應(yīng)用而言是正在前臺(tái)運(yùn)行的狀態(tài),這類崩潰我們稱之為FOOM(Foreground Out Of Memory);與此相對(duì)應(yīng)的也有應(yīng)用程序在后臺(tái)發(fā)生的 OOM 崩潰,這類崩潰我們稱之為BOOM(Background Out Of Memory)。

    "states" : [
      "frontmost",// 前臺(tái)
    ],
    "lifetimeMax" : 1488,
    "killDelta" : 20204,
    "age" : 229126847641,
    "purgeable" : 0,
    "fds" : 50,
    "genCount" : 0,
    "coalition" : 386,
// rpages:是resident pages的縮寫(xiě),表明進(jìn)程當(dāng)前占用的內(nèi)存頁(yè)數(shù)量,
    "rpages" : 89600,
    "priority" : 0,
// reason:表明進(jìn)程被終止的的原因,Heimdallr-Example這個(gè)應(yīng)用被終止的原因是超過(guò)了操作系統(tǒng)允許的單個(gè)進(jìn)程物理內(nèi)存占用的上限。
    "reason" : "per-process-limit",
    "pid" : 4493,
    "idleDelta" : 35156829,
    "name" : "fp",
    "cpuTime" : 8.6578689999999998
  },

現(xiàn)在,我們已經(jīng)知道了內(nèi)存頁(yè)數(shù)量 rpages 為 89600,只要再知道內(nèi)存頁(yè)大小的值,就可以計(jì)算出系統(tǒng)對(duì)單個(gè) App 限制的內(nèi)存是多少了。

  "memoryStatus" : {
  "compressorSize" : 29982,
  "compressions" : 10864669,
  "decompressions" : 7348104,
  "zoneMapCap" : 1109458944,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 29573120,
// pageSize:指的是當(dāng)前設(shè)備物理內(nèi)存頁(yè)的大小,當(dāng)前設(shè)備是iPhoneXs Max,大小是 16KB,蘋(píng)果 A7 芯片之前的設(shè)備物理內(nèi)存頁(yè)大小則是 4KB。
  "pageSize" : 16384,
  "uncompressed" : 87247,
  "zoneMapSize" : 147537920,
  "memoryPages" : {
    "active" : 58130,
    "throttled" : 0,
    "fileBacked" : 38218,
    "wired" : 30456,
    "anonymous" : 77619,
    "purgeable" : 8645,
    "inactive" : 53608,
    "free" : 8058,
    "speculative" : 4099
  }
},

內(nèi)存頁(yè)大小的值,我們也可以在 JetsamEvent 開(kāi)頭的系統(tǒng)日志里找到,也就是 pageSize 的值, 16384。

我們就可以計(jì)算出當(dāng)前 App 的內(nèi)存限制值:pageSize * rpages / 1024 /1024 =16384 * 89600 / 1024 / 1024 得到的值是 1400 MB,即 1.4G。

并不是所有的 JetsamEvent 中都可以拿到準(zhǔn)確的閾值,有的存在偏差。。。
比如有些JetsamEvent日志里, rpages 非常小,可能是在后臺(tái),內(nèi)存吃緊被殺死的.
不同手機(jī) OOM 臨界值不同

這些 JetsamEvent 日志,都是系統(tǒng)在殺掉 App 后留在手機(jī)里的。在查看這些日志時(shí),我們就會(huì)發(fā)現(xiàn),很多日志都是 iOS 系統(tǒng)內(nèi)核強(qiáng)殺掉那些優(yōu)先級(jí)不高,并且占用的內(nèi)存超過(guò)限制的 App 后留下的。

這些日志屬于系統(tǒng)級(jí)的,會(huì)存在系統(tǒng)目錄下。App 上線后開(kāi)發(fā)者是沒(méi)有權(quán)限獲取到系統(tǒng)目錄內(nèi)容的,也就是說(shuō),被強(qiáng)殺掉的 App 是無(wú)法獲取到系統(tǒng)級(jí)日志的,只能線下設(shè)備通過(guò)連接 Xcode 獲取到這部分日志。獲取到 Jetsam 后,就能夠算出系統(tǒng)對(duì) App 設(shè)置的內(nèi)存限制值。

翻閱XNU源碼的時(shí)候我們可以看到在Jetsam機(jī)制終止進(jìn)程的時(shí)候最終是通過(guò)發(fā)送SIGKILL異常信號(hào)來(lái)完成的。

/*
 * The jetsam no frills kill call
 *      Return: 0 on success
 *      error code on failure (EINVAL...)
 */
static int
jetsam_do_kill(proc_t p, int jetsam_flags, os_reason_t jetsam_reason)
{
    int error = 0;
    error = exit_with_reason(p, W_EXITCODE(0, SIGKILL), (int *)NULL, FALSE, FALSE, jetsam_flags, jetsam_reason);
    return error;
}

#define SIGKILL 9       /* kill (cannot be caught or ignored) */

iOS 系統(tǒng)是怎么發(fā)現(xiàn) Jetsam

iOS 系統(tǒng)會(huì)開(kāi)啟優(yōu)先級(jí)最高的線程 vm_pressure_monitor 來(lái)監(jiān)控系統(tǒng)的內(nèi)存壓力情況,并通過(guò)一個(gè)堆棧來(lái)維護(hù)所有 App 的進(jìn)程。另外,iOS 系統(tǒng)還會(huì)維護(hù)一個(gè)內(nèi)存快照表,用于保存每個(gè)進(jìn)程內(nèi)存頁(yè)的消耗情況。

當(dāng)監(jiān)控系統(tǒng)內(nèi)存的線程發(fā)現(xiàn)某 App 內(nèi)存有壓力了,就發(fā)出通知,內(nèi)存有壓力的 App 就會(huì)去執(zhí)行對(duì)應(yīng)的代理,也就是你所熟悉的 didReceiveMemoryWarning 代理。通過(guò)這個(gè)代理,你可以獲得最后一個(gè)編寫(xiě)邏輯代碼釋放內(nèi)存的機(jī)會(huì)。這段代碼的執(zhí)行,就有可能會(huì)避免你的 App 被系統(tǒng)強(qiáng)殺。

系統(tǒng)在強(qiáng)殺 App 前,會(huì)先做優(yōu)先級(jí)判斷 , iOS 系統(tǒng)內(nèi)核里有一個(gè)數(shù)組,專門(mén)用于維護(hù)線程的優(yōu)先級(jí)。這個(gè)優(yōu)先級(jí)規(guī)定就是:內(nèi)核用線程的優(yōu)先級(jí)是最高的,操作系統(tǒng)的優(yōu)先級(jí)其次,App 的優(yōu)先級(jí)排在最后。并且,前臺(tái) App 程序的優(yōu)先級(jí)是高于后臺(tái)運(yùn)行 App 的;線程使用優(yōu)先級(jí)時(shí),CPU 占用多的線程的優(yōu)先級(jí)會(huì)被降低。

iOS 系統(tǒng)在因?yàn)閮?nèi)存占用原因強(qiáng)殺掉 App 前,至少有 6 秒鐘的時(shí)間可以用來(lái)做優(yōu)先級(jí)判斷。同時(shí),JetSamEvent 日志也是在這 6 秒內(nèi)生成的。

通過(guò) XNU 獲取內(nèi)存限制值

除了 JetSamEvent 日志外,我們還可以通過(guò) XNU 來(lái)獲取內(nèi)存的限制值。

在 XNU 中,有專門(mén)用于獲取內(nèi)存上限值的函數(shù)和宏。我們可以通過(guò) memorystatus_priority_entry 這個(gè)結(jié)構(gòu)體,得到進(jìn)程的優(yōu)先級(jí)和內(nèi)存限制值。

// 獲取進(jìn)程的 pid、優(yōu)先級(jí)、狀態(tài)、內(nèi)存閾值等信息,priority 表示的是進(jìn)程的優(yōu)先級(jí),limit 就是我們想要的進(jìn)程內(nèi)存限制值。
typedef struct memorystatus_priority_entry {
    pid_t pid;
    int32_t priority;
    uint64_t user_data;
    int32_t limit;
    uint32_t state;
} memorystatus_priority_entry_t;
 
 
// 基于下面這些宏可以達(dá)到查詢內(nèi)存閾值等信息,也可以修改內(nèi)存閾值等
/* Commands */
#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST            1
#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES      2
#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT          3
#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS          4
#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK   5    /* Set active memory limit = inactive memory limit, both non-fatal   */
#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT        6    /* Set active memory limit = inactive memory limit, both fatal   */
#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES      7    /* Set memory limits plus attributes independently           */
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES      8    /* Get memory limits plus attributes                 */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE   9    /* Set the task's status as a privileged listener w.r.t memory notifications  */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE  10   /* Reset the task's status as a privileged listener w.r.t memory notifications  */
/* Commands that act on a group of processes */
#define MEMORYSTATUS_CMD_GRP_SET_PROPERTIES           100
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "kern_memorystatus.h"
 
#define NUM_ENTRIES 1024
 
char *state_to_text(int State)
{
    // Convert kMemoryStatus constants to a textual representation
 
    static char returned[80];
 
    sprintf (returned, "0x%02x ",State);
 
    if (State & kMemorystatusSuspended) strcat(returned,"Suspended,");
 
    if (State & kMemorystatusFrozen) strcat(returned,"Frozen,");
 
    if (State & kMemorystatusWasThawed) strcat(returned,"WasThawed,");
 
    if (State & kMemorystatusTracked) strcat(returned,"Tracked,");
 
    if (State & kMemorystatusSupportsIdleExit) strcat(returned,"IdleExit,");
 
    if (State & kMemorystatusDirty) strcat(returned,"Dirty,");
 
    if (returned[strlen(returned) -1] == ',')
 
        returned[strlen(returned) -1] = '\0';
 
    return (returned);
}
 
int main (int argc, char **argv)
{
    struct memorystatus_priority_entry memstatus[NUM_ENTRIES];
 
    size_t  count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES;
 
    // call memorystatus_control
 
    int rc = memorystatus_control (MEMORYSTATUS_CMD_GET_PRIORITY_LIST,    // 1 - only supported command on OS X
 
                                   0,    // pid
 
                                   0,    // flags
 
                                   memstatus, // buffer
 
                                   count); // buffersize
 
    if (rc < 0) { perror ("memorystatus_control"); exit(rc);}
 
    int entry = 0;
 
    for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry))
    {
        printf ("PID: %5d\tPriority:%2d\tUser Data: %llx\tLimit:%2d\tState:%s\n",
 
                memstatus[entry].pid,
 
                memstatus[entry].priority,
 
                memstatus[entry].user_data,
 
                memstatus[entry].limit,
 
                state_to_text(memstatus[entry].state));
 
        entry++;
    }
}

通過(guò) XNU 的宏獲取內(nèi)存限制,需要有 root 權(quán)限,而 App 內(nèi)的權(quán)限是不夠的,所以正常情況下,作為 App 開(kāi)發(fā)者你是看不到這個(gè)信息的。

通過(guò)內(nèi)存警告獲取內(nèi)存限制值

還可以利用 didReceiveMemoryWarning 這個(gè)內(nèi)存壓力代理事件來(lái)動(dòng)態(tài)地獲取內(nèi)存限制值。

iOS 系統(tǒng)在強(qiáng)殺掉 App 之前還有 6 秒鐘的時(shí)間,足夠你去獲取記錄內(nèi)存信息了。那么,如何獲取當(dāng)前內(nèi)存使用情況呢?

iOS 系統(tǒng)提供了一個(gè)函數(shù) task_info, 可以幫助我們獲取到當(dāng)前任務(wù)的信息。關(guān)鍵代碼如下:

#import <sys/sysctl.h>
#import <mach/mach.h>


struct mach_task_basic_info info;
mach_msg_type_number_t size = sizeof(info);
kern_return_t kl = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &size);

代碼中,task_info_t 結(jié)構(gòu)里包含了一個(gè) resident_size 字段,用于表示使用了多少內(nèi)存。這樣,我們就可以獲取到發(fā)生內(nèi)存警告時(shí),當(dāng)前 App 占用了多少內(nèi)存。代碼如下:

    struct mach_task_basic_info info;
    mach_msg_type_number_t size = sizeof(info);
    kern_return_t kl = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &size);
    if (kl != KERN_SUCCESS)
    {
       return NSNotFound;
    }
    
    float used_mem = info.resident_size;
    return used_mem;
    NSLog(@"使用了 %f MB 內(nèi)存", used_mem / 1024.0f / 1024.0f);

經(jīng)過(guò)測(cè)試,上面代碼獲取的內(nèi)存和Xcode上顯示的不一樣.

內(nèi)存信息存在 task_info.h (完整路徑 usr/include/mach/task.info.h)文件的 task_vm_info 結(jié)構(gòu)體中,其中 phys_footprint 就是物理內(nèi)存的使用,而不是駐留內(nèi)存 resident_size。結(jié)構(gòu)體里和內(nèi)存相關(guān)的代碼如下:


struct task_vm_info {
  mach_vm_size_t  virtual_size;       // 虛擬內(nèi)存大小
  integer_t region_count;             // 內(nèi)存區(qū)域的數(shù)量
  integer_t page_size;
  mach_vm_size_t  resident_size;      // 駐留內(nèi)存大小
  mach_vm_size_t  resident_size_peak; // 駐留內(nèi)存峰值
  ...
  /* added for rev1 */
  mach_vm_size_t  phys_footprint;     // 物理內(nèi)存
  ...

可以使用下面的函數(shù)

    int64_t memoryUsageInByte = 0;
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t kernelReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
    if(kernelReturn == KERN_SUCCESS) {
        memoryUsageInByte = (int64_t) vmInfo.phys_footprint;
        NSLog(@"使用了 %f MB 內(nèi)存", memoryUsageInByte/1024.0f/1024.0f);
    } else {
        NSLog(@"Error with task_info(): %s", mach_error_string(kernelReturn));
    }

適用于 iOS13 系統(tǒng)的獲取方式

if (@available(iOS 13.0, *)) {
    return os_proc_available_memory() / 1024.0 / 1024.0;
}

我們可以通過(guò) os_proc_available_memory 獲取到當(dāng)前可以用內(nèi)存,通過(guò) phys_footprint 獲取到當(dāng)前 App 占用內(nèi)存,2者的和也就是當(dāng)前設(shè)備的內(nèi)存上限,超過(guò)即觸發(fā) Jetsam 機(jī)制。

定位內(nèi)存問(wèn)題信息收集

現(xiàn)在,我們已經(jīng)可以通過(guò)三種方法來(lái)獲取內(nèi)存上限值了,而且通過(guò)內(nèi)存警告的方式還能夠動(dòng)態(tài)地獲取到這個(gè)值。有了這個(gè)內(nèi)存上限值以后,你就可以進(jìn)行內(nèi)存問(wèn)題的信息收集工作了。

要想精確地定位問(wèn)題,我們就需要 dump 出完整的內(nèi)存信息,包括所有對(duì)象及其內(nèi)存占用值,在內(nèi)存接近上限值的時(shí)候,收集并記錄下所需信息,并在合適的時(shí)機(jī)上報(bào)到服務(wù)器里,方便分析問(wèn)題。

獲取到了每個(gè)對(duì)象的內(nèi)存占用量還不夠,你還需要知道是誰(shuí)分配的內(nèi)存,這樣才可以精確定位到問(wèn)題的關(guān)鍵所在。一個(gè)對(duì)象可能會(huì)在不同的函數(shù)里被分配了內(nèi)存并被創(chuàng)建了出來(lái),當(dāng)這個(gè)對(duì)象內(nèi)存占用過(guò)大時(shí),如果不知道是在哪個(gè)函數(shù)里創(chuàng)建的話,問(wèn)題依然很難精確定位出來(lái)。

內(nèi)存分配函數(shù)malloccalloc 等默認(rèn)使用的是nano_zonenano_zone是 256B 以下小內(nèi)存的分配,大于 256B 的時(shí)候會(huì)使用 scalable_zone 來(lái)分配。

如果主要是針對(duì)大內(nèi)存的分配監(jiān)控,可以只針對(duì) scalable_zone 進(jìn)行分析,同時(shí)也可以過(guò)濾掉很多小內(nèi)存分配監(jiān)控。比如,malloc 函數(shù)用的是 malloc_zone_malloc,calloc 用的是 malloc_zone_calloc。

malloc_zone_malloc 函數(shù)的實(shí)現(xiàn)

void *
malloc_zone_malloc(malloc_zone_t *zone, size_t size)
{
    return _malloc_zone_malloc(zone, size, MZ_NONE);
}

static void *
_malloc_zone_malloc(malloc_zone_t *zone, size_t size, malloc_zone_options_t mzo)
{
    MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);

    void *ptr = NULL;

    if (malloc_check_start) {
        internal_check();
    }
    if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
        goto out;
    }

    ptr = zone->malloc(zone, size);     // if lite zone is passed in then we still call the lite methods
        // 在 zone 分配完內(nèi)存后就開(kāi)始使用 malloc_logger 進(jìn)行進(jìn)行記錄
    if (os_unlikely(malloc_logger)) {
        malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
    }

    MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
out:
    if (os_unlikely(ptr == NULL)) {
        malloc_set_errno_fast(mzo, ENOMEM);
    }
    return ptr;
}

其他使用 scalable_zone 分配內(nèi)存的函數(shù)的方法也類似,所有大內(nèi)存的分配,不管外部函數(shù)是怎么包裝的,最終都會(huì)調(diào)用 malloc_logger 函數(shù)。
可以 去 Hook 這個(gè)函數(shù),加上自己的統(tǒng)計(jì)記錄就能夠通盤(pán)掌握內(nèi)存的分配情況。出現(xiàn)問(wèn)題時(shí),將內(nèi)存分配記錄的日志撈上來(lái),你就能夠跟蹤到導(dǎo)致內(nèi)存不合理增大的原因了。

malloc_logger hook代碼

typedef void(malloc_logger_t)(
        uint32_t type,
        uintptr_t arg1,
        uintptr_t arg2,
        uintptr_t arg3,
        uintptr_t result,
        uint32_t num_hot_frames_to_skip);

extern malloc_logger_t *malloc_logger;

void malloc_logger_impl(uint32_t type,uintptr_t arg1,uintptr_t arg2,uintptr_t arg3,uintptr_t result,uint32_t num_hot_frames_to_skip){
    printf("%d-%lu-%lu-%lu-%lu-%d\n",type,arg1,arg2,arg3,result,num_hot_frames_to_skip);
}
int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
        malloc_logger = malloc_logger_impl;
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

收集上報(bào)

分配堆棧可以用 backtrace 函數(shù)捕獲,但捕獲到的地址是虛擬內(nèi)存地址,不能從符號(hào)表 dsym 解析符號(hào)。所以還要記錄每個(gè) image 加載時(shí)的偏移 slide,符號(hào)表地址 = 堆棧地址 - slide。

降低OOM

基于內(nèi)存快照

抖音有一篇文章講述了基于內(nèi)存快照,降低OOM的文章:https://juejin.cn/post/6885144933997494280

簡(jiǎn)單總結(jié):

線上 Memory Graph 采集內(nèi)存快照主要是為了獲取當(dāng)前運(yùn)行狀態(tài)下所有內(nèi)存對(duì)象以及對(duì)象之間的引用關(guān)系,用于后續(xù)的問(wèn)題分析。主要需要獲取的信息如下:

  • 所有內(nèi)存的節(jié)點(diǎn),以及其符號(hào)信息(如OC/Swift/C++ 實(shí)例類名,或者是某種有特殊用途的 VM 節(jié)點(diǎn)的 tag 等)。
  • 節(jié)點(diǎn)之間的引用關(guān)系,以及符號(hào)信息(偏移,或者實(shí)例變量名),OC/Swift成員變量還需要記錄引用類型。

由于采集的過(guò)程發(fā)生在程序正常運(yùn)行的過(guò)程中,為了保證不會(huì)因?yàn)椴杉瘍?nèi)存快照導(dǎo)致程序運(yùn)行異常,整個(gè)采集過(guò)程需要在一個(gè)相對(duì)靜止的運(yùn)行環(huán)境下完成。因此,整個(gè)快照采集的過(guò)程大致分為以下幾個(gè)步驟:

  • 掛起所有非采集線程。
  • 獲取所有的內(nèi)存節(jié)點(diǎn),內(nèi)存對(duì)象引用關(guān)系以及相應(yīng)的輔助信息。
  • 寫(xiě)入文件。
  • 恢復(fù)線程狀態(tài)。

具體的過(guò)程

內(nèi)存節(jié)點(diǎn)的獲取

程序的內(nèi)存都是由虛擬內(nèi)存組成的,每一塊單獨(dú)的虛擬內(nèi)存被稱之為VM Region,通過(guò) mach 內(nèi)核的vm_region_recurse/vm_region_recurse64函數(shù)我們可以遍歷進(jìn)程內(nèi)所有VM Region,并通過(guò)vm_region_submap_info_64結(jié)構(gòu)體獲取以下信息:

  • 虛擬地址空間中的地址和大小。
  • Dirty 和 Swapped 內(nèi)存頁(yè)數(shù),表示該VM Region的真實(shí)物理內(nèi)存使用。
  • 是否可交換,Text 段、共享 mmap 等只讀或隨時(shí)可以被交換出去的內(nèi)存,無(wú)需關(guān)注。
  • user_tag,用戶標(biāo)簽,用于提供該VM Region的用途的更準(zhǔn)確信息。
kern_return_t krc = KERN_SUCCESS;
vm_address_t address = 0;
vm_size_t size = 0;
uint32_t depth = 1;
pid_t pid = getpid();
char buf[PATH_MAX];
while (1) {
    struct vm_region_submap_info_64 info;
    mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
    krc = vm_region_recurse_64(mach_task_self(), &address, &size, &depth, (vm_region_info_64_t)&info, &count);
    if (krc == KERN_INVALID_ADDRESS){
        break;
    }
    if (info.is_submap){
        depth++;
    } else {
        //do stuff
        proc_regionfilename(pid, address, buf, sizeof(buf));
        printf("Found VM Region: %08x to %08x (depth=%d) user_tag:%s name:%s\n", (uint32_t)address, (uint32_t)(address+size), depth, [visualMemoryTypeString(info.user_tag) cStringUsingEncoding:NSUTF8StringEncoding], buf);
        address += size;
    }
}

內(nèi)存節(jié)點(diǎn)大致分為這幾類:

  • App的二進(jìn)制文件在內(nèi)存的映射(如OnlineMemoryGraphDemo)
  • 動(dòng)態(tài)庫(kù)在內(nèi)存中的映射(如libBacktraceRecording.dylib,libdispatch.dylib等)
  • 系統(tǒng)或自定義字體等資源(SFUI.ttf, PingFang.ttc)
  • 棧區(qū)(STACK name:/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64)
  • Malloc Zone,Malloc Zone分為Nano和Scalable,Nano分配16B~256B(16B的整數(shù)倍)的內(nèi)存,Scalable分配256B以上的大內(nèi)存

大多數(shù) VM Region 作為一個(gè)單獨(dú)的內(nèi)存節(jié)點(diǎn),僅記錄起始地址和 Dirty、Swapped 內(nèi)存作為大小,以及與其他節(jié)點(diǎn)之間的引用關(guān)系;而 libmalloc 維護(hù)的堆內(nèi)存所在的 VM Region 則由于往往包含大多數(shù)業(yè)務(wù)邏輯中的 Objective-C 對(duì)象、C/C++對(duì)象、buffer 等,可以獲取更詳細(xì)的引用信息

在 iOS 系統(tǒng)中為了避免所有的內(nèi)存分配都使用系統(tǒng)調(diào)用產(chǎn)生性能問(wèn)題,相關(guān)的庫(kù)負(fù)責(zé)一次申請(qǐng)大塊內(nèi)存,再在其之上進(jìn)行二次分配并進(jìn)行管理,提供給小塊需要?jiǎng)討B(tài)分配的內(nèi)存對(duì)象使用,稱之為堆內(nèi)存。

程序中使用到絕大多數(shù)的動(dòng)態(tài)內(nèi)存都通過(guò)堆進(jìn)行管理,在 iOS 操作系統(tǒng)上,主要的業(yè)務(wù)邏輯分配的內(nèi)存都通過(guò)libmalloc進(jìn)行管理,部分系統(tǒng)庫(kù)為了性能也會(huì)使用自己的單獨(dú)的堆管理,例如WebKit內(nèi)核使用bmalloc,CFNetwork也使用自己獨(dú)立的堆,在這里我們只關(guān)注libmalloc內(nèi)部的內(nèi)存管理狀態(tài),而不關(guān)心其它可能的堆(即這部分特殊內(nèi)存會(huì)以VM Region的粒度存在,不分析其內(nèi)部的節(jié)點(diǎn)引用關(guān)系)。

我們可以通過(guò)malloc_get_all_zones獲取libmalloc內(nèi)部所有的zone,并遍歷每個(gè)zone中管理的內(nèi)存節(jié)點(diǎn),獲取 libmalloc 管理的存活的所有內(nèi)存節(jié)點(diǎn)的指針和大小。
獲取所有Malloc Zone

vm_address_t *zones = NULL;
unsigned int zoneCount = 0;
kern_return_t result = malloc_get_all_zones(TASK_NULL, memory_reader_callback, &zones, &zoneCount);
if (result == KERN_SUCCESS) {    
    for (unsigned int i = 0; i < zoneCount; i++) {        
        malloc_zone_t *zone = (malloc_zone_t *)zones[i];               
        printf("Found zone name:%s\n", zone->zone_name);
    }
}

獲取Zone內(nèi)所有分配的節(jié)點(diǎn)

malloc_introspection_t *introspection = zone->introspect;

if (!introspection) {
    continue;
}

void (*lock_zone)(malloc_zone_t *zone)   = introspection->force_lock;
void (*unlock_zone)(malloc_zone_t *zone) = introspection->force_unlock;

// Callback has to unlock the zone so we freely allocate memory inside the given block
malloc_object_enumeration_block_t callback = ^(__unsafe_unretained id object, __unsafe_unretained Class actualClass) {
    unlock_zone(zone);
    block(object, actualClass);
    lock_zone(zone);
};

BOOL lockZoneValid = mallocPointerIsReadable((void *)lock_zone);
BOOL unlockZoneValid = mallocPointerIsReadable((void *)unlock_zone);

// There is little documentation on when and why
// any of these function pointers might be NULL
// or garbage, so we resort to checking for NULL
// and whether the pointer is readable
if (introspection->enumerator && lockZoneValid && unlockZoneValid) {
    lock_zone(zone);
    introspection->enumerator(TASK_NULL, (void *)&callback, MALLOC_PTR_IN_USE_RANGE_TYPE, (vm_address_t)zone, memory_reader_callback, &vm_range_recorder_callback);
    unlock_zone(zone);
}
符號(hào)化

獲取內(nèi)存節(jié)點(diǎn)之后,我們需要為每個(gè)節(jié)點(diǎn)找到更加詳細(xì)的類型名稱,用于后續(xù)的分析。

  • 對(duì)于 VM Region 內(nèi)存節(jié)點(diǎn),我們可以通過(guò) user_tag 賦予它有意義的符號(hào)信息;
  • 堆內(nèi)存對(duì)象包含 raw buffer,Objective-C/Swift、C++等對(duì)象。對(duì)于 Objective-C/Swift、C++這部分,我們通過(guò)內(nèi)存中的一些運(yùn)行時(shí)信息,嘗試符號(hào)化獲取更加詳細(xì)的信息。

Objective/Swift 對(duì)象的符號(hào)化相對(duì)比較簡(jiǎn)單,Swift在內(nèi)存布局上兼容了Objective-C,也有isa指針,objc相關(guān)方法可以作用于兩種語(yǔ)言的對(duì)象上。只要保證 isa 指針合法,對(duì)象實(shí)例大小滿足條件即可認(rèn)為正確。

獲取所有OC/SwiftClass類型

CFMutableSetRef registeredClasses;

unsigned int updateRegisteredClasses() {
    if (!registeredClasses) {
        registeredClasses = CFSetCreateMutable(NULL, 0, NULL);
    } else {
        CFSetRemoveAllValues(registeredClasses);
    }
    unsigned int count = 0;
    Class *classes = objc_copyClassList(&count);
    for (unsigned int i = 0; i < count; i++) {
        CFSetAddValue(registeredClasses, (__bridge const void *)(classes[i]));
    }
    free(classes);
    return count;
}

判斷isa是否合法

typedef struct {
    Class isa;
} malloc_maybe_object_t;

void vm_range_recorder_callback(task_t task, void *context, unsigned type, vm_range_t *ranges, unsigned rangeCount) {
    if (!context) {
        return;
    }

    for (unsigned int i = 0; i < rangeCount; i++) {
        vm_range_t range = ranges[i];
        malloc_maybe_object_t *tryObject = (malloc_maybe_object_t *)range.address;
        Class tryClass = NULL;
#ifdef __arm64__
        // See http://www.sealiesoftware.com/blog/archive/2013/09/24/objc_explain_Non-pointer_isa.html
        extern uint64_t objc_debug_isa_class_mask WEAK_IMPORT_ATTRIBUTE;
        tryClass = (__bridge Class)((void *)((uint64_t)tryObject->isa & objc_debug_isa_class_mask));
#else
        tryClass = tryObject->isa;
#endif
        // 1\. 判斷是否為OC/SwiftObject
        if (CFSetContainsValue(registeredClasses, (__bridge const void *)(tryClass))) {
            (*(malloc_object_enumeration_block_t __unsafe_unretained *)context)((__bridge id)tryObject, tryClass);
        } 
        // 2\. 判斷是否是一個(gè)保護(hù)type_info的C++對(duì)象
        else if ([CPPObjectUtil cppTypeInfoName:(void *)range.address] != NULL) {
            NSLog(@"Find a Cpp Object:%s!", [CPPObjectUtil cppTypeInfoName:(void *)range.address]);
        }
    }
}

C++對(duì)象根據(jù)是否包含虛表可以分成兩類。對(duì)于不包含虛表的對(duì)象,因?yàn)槿狈\(yùn)行時(shí)數(shù)據(jù),無(wú)法進(jìn)行處理。
對(duì)于對(duì)于包含虛表的對(duì)象, 可以通過(guò) std::type_info 和以下幾個(gè) section 的信息獲取對(duì)應(yīng)的類型信息。

  • type_name string - 類名對(duì)應(yīng)的常量字符串,存儲(chǔ)在__TEXT/__RODATA段的__const section中。
  • type_info - 存放在__DATA/__DATA_CONST段的__const section中。
  • vtable - 存放在__DATA/__DATA_CONST段的__const section中。

如何判斷是不是一個(gè)C++對(duì)象

獲取App二進(jìn)制加載到內(nèi)存中起始地址,_dyld_register_func_for_add_image方法當(dāng)App的二進(jìn)制或者動(dòng)態(tài)庫(kù)等MachO格式的文件映射到內(nèi)存后,啟動(dòng)App時(shí)的回調(diào),我們可以通過(guò)這個(gè)拿到App執(zhí)行二進(jìn)制的起始地址,從而拿到段中的C++類型信息。

獲取App二進(jìn)制起始地址

/*
 * The following functions allow you to install callbacks which will be called   
 * by dyld whenever an image is loaded or unloaded.  During a call to _dyld_register_func_for_add_image()
 * the callback func is called for every existing image.  Later, it is called as each new image
 * is loaded and bound (but initializers not yet run).  The callback registered with
 * _dyld_register_func_for_remove_image() is called after any terminators in an image are run
 * and before the image is un-memory-mapped.
 */
extern void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide))    __OSX_AVAILABLE_STARTING(__MAC_10_1, __IPHONE_2_0);

獲取所有C++type_info

typedef std::vector<struct segment_command_64 const *>    Segment64Vector;
typedef std::set<uint64_t *>    CxxTypeInfoSet;

static Segment64Vector *segments_64 = NULL;
static CxxTypeInfoSet *cxxTypeInfoSet = NULL;

// 記錄Data Segment中__const段的有效最大最小地址,合法的C++ type_info地址不會(huì)超出這里
uint64_t dataConstMinAddress = NULL;
uint64_t dataConstMaxAddress = NULL;

static void dyld_callback(const struct mach_header *mhp, intptr_t vmaddr_slide)
{
    // 這里只分析App的二進(jìn)制
    if (mhp->filetype != MH_EXECUTE) {
        return;
    }

    segments_64 = new Segment64Vector();
    cxxTypeInfoSet = new CxxTypeInfoSet();
    size_t header_size = sizeof(struct mach_header_64);
    uint64_t *load_comandPtr = (uint64_t *)((unsigned char *)mhp + header_size);
    uint64_t address = (uint64_t)((uint64_t *)mhp);
    uint32_t ptrSize = sizeof(uint64_t);
    for (int i=0; i<mhp->ncmds; i++) {
        struct load_command *load_command = (struct load_command *)load_comandPtr;
        segments_64->push_back((struct segment_command_64 const *)load_command);
        NSString *cmdType = loadCommandMap[@(load_command->cmd)];
        NSLog(@"dyld_callback load_command cmd:%@", cmdType);
        // 分析 Data Segment中__const段,獲取有效最大最小地址
        if (load_command->cmd == LC_SEGMENT_64) {
            struct segment_command_64 *segment_64 = (struct segment_command_64 *)load_command;
            if (strcmp(segment_64->segname, "__DATA") == 0) {
                const struct section_64 *sec = (struct section_64 *)(segment_64 + 1);
                for (int j=0; j<segment_64->nsects; j++) {
                    if (strcmp(sec[j].sectname, "__const") == 0) {
                        dataConstMinAddress = (((uint64_t)(uint64_t *)mhp) + sec[j].offset);
                        dataConstMaxAddress = (((uint64_t)(uint64_t *)mhp) + sec[j].offset + sec[j].size);
                    }
                }
            }

        } 
        // 分析動(dòng)態(tài)鏈接段的信息,獲取App內(nèi)C++ type_info的地址
        else if (load_command->cmd == LC_DYLD_INFO ||
                   load_command->cmd == LC_DYLD_INFO_ONLY) {
            struct dyld_info_command *dyldCommand = (struct dyld_info_command *)load_command;
            uint8_t *bytePtr = (uint8_t *)((uint8_t *)mhp + dyldCommand->bind_off); // Dynamic Loader Info Bind部分的起始地址
            uint64_t dyldMaxAddress = (((uint64_t)(uint64_t *)mhp) + dyldCommand->bind_off + dyldCommand->bind_size);
            uint64_t doBindLocation = *((uint64_t *)bytePtr);

            int32_t libOrdinal = 0;
            uint32_t type = 0;
            int64_t addend = 0;
            NSString * symbolName = nil;
            uint32_t symbolFlags = 0;
            BOOL isDone = NO;
            while (((uint64_t)(uint64_t *)bytePtr) < dyldMaxAddress) {
                uint8_t byte = read_int8(&bytePtr);
                uint8_t opcode = byte & BIND_OPCODE_MASK;
                uint8_t immediate = byte & BIND_IMMEDIATE_MASK;
                NSLog(@"dyld_callback load_command opcode:%d, immediate:%d", opcode, immediate);
                switch (opcode)
                {
                    case BIND_OPCODE_DONE:
                        // The lazy bindings have one of these at the end of each bind.
                        isDone = YES;

                        doBindLocation = (*((uint64_t *)bytePtr) + 1);

                        break;

                    case BIND_OPCODE_SET_DYLIB_ORDINAL_IMM:
                        libOrdinal = immediate;
                        break;

                    case BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB:

                        libOrdinal = (uint32_t)read_uleb128(&bytePtr);

                        break;

                    case BIND_OPCODE_SET_DYLIB_SPECIAL_IMM:
                    {
                        // Special means negative
                        if (immediate == 0)
                        {
                            libOrdinal = 0;
                        }
                        else
                        {
                            int8_t signExtended = immediate | BIND_OPCODE_MASK; // This sign extends the value

                            libOrdinal = signExtended;
                        }
                    } break;

                    case BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM:
                        symbolFlags = immediate;
                        symbolName = read_string(&bytePtr);
                        break;

                    case BIND_OPCODE_SET_TYPE_IMM:
                        type = immediate;
                        break;

                    case BIND_OPCODE_SET_ADDEND_SLEB:

                        addend = read_sleb128(&bytePtr);
                        break;
                        //
                    case BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB:
                    {
                        uint32_t segmentIndex = immediate;
                        uint64_t val = read_uleb128(&bytePtr);

                        if (segmentIndex < segments_64->size())
                        {
                            address += (*segments_64)[segmentIndex]->fileoff + val;
                        }
                    } break;

                    case BIND_OPCODE_ADD_ADDR_ULEB:
                    {
                        uint64_t val = read_uleb128(&bytePtr);
                        address += val;
                    } break;

                    case BIND_OPCODE_DO_BIND:
                    {
                        // 獲取C++ type_info地址
                        NSLog(@"dyld_callback Bind SymbolName:%@", symbolName);
                        if ([symbolName hasPrefix:@"__ZTVN10__cxxabi"]) {
                            std::type_info *type_info = (std::type_info *)address;
                            NSLog(@"std::type_info name:%s address:%p", type_info->name(), type_info);
                            cxxTypeInfoSet->insert((uint64_t *)address);
                        }
                        doBindLocation = *((uint64_t *)bytePtr);

                        address += ptrSize;
                    } break;

                    case BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB:
                    {
                        uint64_t startNextBind = *((uint64_t *)bytePtr);

                        uint64_t val = read_uleb128(&bytePtr);
                        doBindLocation = startNextBind;

                        address += ptrSize + val;
                    } break;

                    case BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED:
                    {
                        uint32_t scale = immediate;
                        // 獲取C++ type_info地址
                        if ([symbolName hasPrefix:@"__ZTVN10__cxxabi"]) {
                            std::type_info *type_info = (std::type_info *)address;
                            NSLog(@"std::type_info name:%s address:%p", type_info->name(), type_info);
                            cxxTypeInfoSet->insert((uint64_t *)address);
                        }

                        doBindLocation = *((uint64_t *)bytePtr);

                        address += ptrSize + scale * ptrSize;
                    } break;

                    case BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB:
                    {
                        uint64_t startNextBind = *((uint64_t *)bytePtr);

                        uint64_t count = read_uleb128(&bytePtr);

                        uint64_t skip = read_uleb128(&bytePtr);

                        for (uint64_t index = 0; index < count; index++)
                        {
                            doBindLocation = startNextBind;

                            address += ptrSize + skip;
                        }
                    } break;

                    default:
                        break;
                }
            }
        }
        load_comandPtr = (uint64_t *)((unsigned char *)load_comandPtr + load_command->cmdsize);
    }
}

判斷一個(gè)地址是否為一個(gè)C++Object(有type_info的)

typedef std::set<uint64_t *>    CxxTypeInfoSet;
static CxxTypeInfoSet *cxxTypeInfoSet = NULL;

+ (const char *) cppTypeInfoName:(void *) ptr {
    uint64_t *typeInfoPtr = (uint64_t*)(*((uint64_t *)ptr) - 8);
    uint64_t typeInfoAddress = (uint64_t)typeInfoPtr;
    if (typeInfoAddress >= dataConstMinAddress && typeInfoAddress < dataConstMaxAddress) {
        uint64_t *typeInfo = (uint64_t *)(*typeInfoPtr);
        if (cxxTypeInfoSet->find(typeInfo) != cxxTypeInfoSet->end()) {
            const char *name = ((std::type_info *)typeInfo)->name();
            return name;
        }
    }
    return NULL;
}

在 iOS 系統(tǒng)內(nèi),還有一類特殊的對(duì)象,即CoreFoundation。除了我們熟知的CFString、CFDictionary外等,很多很多系統(tǒng)庫(kù)也使用 CF 對(duì)象,比如CGImage、CVObject等。從它們的 isa 指針獲取的Objective-C類型被統(tǒng)一成__NSCFType。由于 CoreFoundation 類型支持實(shí)時(shí)的注冊(cè)、注銷(xiāo)類型,為了細(xì)化這部分的類型,我們通過(guò)逆向拿到 CoreFoundation 維護(hù)的類型 slot 數(shù)組的位置并讀取其數(shù)據(jù),保證能夠安全的獲取準(zhǔn)確的類型。

UIImage *image = [UIImage imageNamed:@""];
CFTypeID typeId = CFGetTypeID(image.CGImage);
CFStringRef className = CFCopyTypeIDDescription(typeId);
NSLog(@"%@",className);
引用關(guān)系的構(gòu)建

整個(gè)內(nèi)存快照的核心在于重新構(gòu)建內(nèi)存節(jié)點(diǎn)之間的引用關(guān)系。在虛擬內(nèi)存中,如果一個(gè)內(nèi)存節(jié)點(diǎn)引用了其它內(nèi)存節(jié)點(diǎn),則對(duì)應(yīng)的內(nèi)存地址中會(huì)存儲(chǔ)指向?qū)Ψ降闹羔樦怠S幸韵路桨福?/p>

  • 遍歷一個(gè)內(nèi)存節(jié)點(diǎn)中所有可能存儲(chǔ)了指針的范圍獲取其存儲(chǔ)的值 A。
  • 搜索所有獲得的節(jié)點(diǎn),判斷 A 是不是某一個(gè)內(nèi)存節(jié)點(diǎn)中任何一個(gè)字節(jié)的地址,如果是,則認(rèn)為是一個(gè)引用關(guān)系。
  • 對(duì)所有內(nèi)存節(jié)點(diǎn)重復(fù)以上操作。

對(duì)于一些特定的內(nèi)存區(qū)域,為了獲取更詳細(xì)的信息用于排查問(wèn)題,對(duì)棧內(nèi)存以及 Objective-C/Swift 的堆內(nèi)存進(jìn)行了一些額外的處理。
其中,棧內(nèi)存也以VM Region的形式存在,棧上保存了臨時(shí)變量和 TLS 等數(shù)據(jù),獲取相應(yīng)的引用信息可以幫助排查諸如 autoreleasepool 造成的內(nèi)存問(wèn)題。由于棧并不會(huì)使用整個(gè)棧內(nèi)存,為了獲取 Stack 的引用關(guān)系,根據(jù)寄存器以及棧內(nèi)存獲取當(dāng)前的棧可用范圍,排除未使用的棧內(nèi)存造成的無(wú)效引用。

而對(duì)于Objective-C/Swift對(duì)象,由于運(yùn)行時(shí)包含額外的信息,我們可以獲得Ivar的強(qiáng)弱引用關(guān)系以及Ivar的名字,帶上這些信息有助于我們分析問(wèn)題。 通過(guò)獲得Ivar的偏移,如果找到的引用關(guān)系的偏移和Ivar的偏移一致,則認(rèn)為這個(gè)引用關(guān)系就是這個(gè)Ivar,可以將Ivar相關(guān)的信息附加上去。

參考鏈接

https://juejin.cn/post/6885144933997494280

https://juejin.cn/post/6895583288451465230

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,818評(píng)論 6 531
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡,警方通過(guò)查閱死者的電腦和手機(jī),發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 98,185評(píng)論 3 414
  • 文/潘曉璐 我一進(jìn)店門(mén),熙熙樓的掌柜王于貴愁眉苦臉地迎上來(lái),“玉大人,你說(shuō)我怎么就攤上這事。” “怎么了?”我有些...
    開(kāi)封第一講書(shū)人閱讀 175,656評(píng)論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長(zhǎng)。 經(jīng)常有香客問(wèn)我,道長(zhǎng),這世上最難降的妖魔是什么? 我笑而不...
    開(kāi)封第一講書(shū)人閱讀 62,647評(píng)論 1 309
  • 正文 為了忘掉前任,我火速辦了婚禮,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 71,446評(píng)論 6 405
  • 文/花漫 我一把揭開(kāi)白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上,一...
    開(kāi)封第一講書(shū)人閱讀 54,951評(píng)論 1 321
  • 那天,我揣著相機(jī)與錄音,去河邊找鬼。 笑死,一個(gè)胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,041評(píng)論 3 440
  • 文/蒼蘭香墨 我猛地睜開(kāi)眼,長(zhǎng)吁一口氣:“原來(lái)是場(chǎng)噩夢(mèng)啊……” “哼!你這毒婦竟也來(lái)了?” 一聲冷哼從身側(cè)響起,我...
    開(kāi)封第一講書(shū)人閱讀 42,189評(píng)論 0 287
  • 序言:老撾萬(wàn)榮一對(duì)情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒(méi)想到半個(gè)月后,有當(dāng)?shù)厝嗽跇?shù)林里發(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 48,718評(píng)論 1 333
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長(zhǎng)有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 40,602評(píng)論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 42,800評(píng)論 1 369
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,316評(píng)論 5 358
  • 正文 年R本政府宣布,位于F島的核電站,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 44,045評(píng)論 3 347
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開(kāi)封第一講書(shū)人閱讀 34,419評(píng)論 0 26
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽(yáng)。三九已至,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開(kāi)封第一講書(shū)人閱讀 35,671評(píng)論 1 281
  • 我被黑心中介騙來(lái)泰國(guó)打工, 沒(méi)想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個(gè)月前我還...
    沈念sama閱讀 51,420評(píng)論 3 390
  • 正文 我出身青樓,卻偏偏與公主長(zhǎng)得像,于是被迫代替她去往敵國(guó)和親。 傳聞我的和親對(duì)象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 47,755評(píng)論 2 371

推薦閱讀更多精彩內(nèi)容