马上注册,结交更多好友,享用更多功能,让你轻松玩转社区。
您需要 登录 才可以下载或查看,没有账号?站点注册
×
PowerBuilder 对象生命周期与垃圾回收全解:GarbageCollect 三函数、引用计数与析构时机实测(PB10 基准 · PB12.5 实测)
阅读说明
- 适用版本:PowerBuilder 10 及以上(本文以 PB10 为写作与验证基准,PB12.5 双轨实测,全部读数两版一致)。本文不依赖 PBIDEA,纯 PowerScript 能力。
- 支持数据库:本文不涉及数据库。
- 操作系统与环境:Windows 7 及以上;任意 PB 可运行环境即可,无需第三方运行库。
- 难度系数:★★★☆☆(概念不难,但「对象到底死了没有」的验证方法和几个反直觉读数,需要一点动手耐心)。
- 其它:文中自检示例会在当前目录留下若干 gcmark 开头的标记文件,属于验证痕迹,可自行删除;建议先读第四节再回头看实测。
一、这篇讲什么
PB 里一个对象「不用了」和「已经销毁了」是两回事。你手里的引用一丢,对象未必马上消失;两个对象互相攥着对方,即使外面的引用全断了,它们也可能一直活着。管这件事的是两套机制:引用计数和垃圾回收,配套三个函数——GarbageCollect()、GarbageCollectGetTimeLimit()、GarbageCollectSetTimeLimit()。
这三个函数帮助文档里各只有几行字,看着人畜无害。但真跑起来,问题一个接一个:
- 局部变量出了作用域,析构事件到底触发没有?什么时候触发?
- 两个互相引用的对象,引用全断之后谁来收尸?
- GarbageCollect() 调了就一定回收吗?被时间限制拦住了你怎么知道?(它没有返回值,成败全靠猜)
- SetNull() 用在对象引用上会怎样?编译器居然放行,运行时呢?
这篇用「析构标记文件法」把析构时机变成肉眼可见的文件记录,配合二十多组单例探针,把上面的问题一次问清。所有读数 PB10 与 PB12.5 双轨跑出,逐字一致,不引文档、只报实测。
二、先给结论:一张速查表
| 场景 | 结论 | 依据(实测) | | 局部 NVO 出作用域 | 立即析构,不用等 GC,不用调 GarbageCollect | 探针方法一返回,析构标记文件立刻出现 | | destroy 显式销毁 | 析构立刻触发,同一方法内马上可见 | destroy 下一行 FileExists 已为 true | | 循环引用对象 | 引用全断后继续存活,显式 GarbageCollect() 后被回收 | 回收前标记不在、调用后标记出现,多次复现 | | 被活对象持有的对象 | GC 不回收;持有者销毁时连锁析构 | 持有者活着时反复 GC 标记始终不出现 | | autoinstantiate NVO | 出作用域即析构,但析构脚本触发两次 | 一个实例,标记文件多出一行,两版一致 | | SetNull(对象引用) | 编译通过;引用被释放、对象立即析构、IsValid 返回 NULL 而非 false | 逐项落盘取证 | | lp = null | 编译报 C0015: Undefined variable: null | 对象引用不能直赋 null 字面量 | | GC 默认最小间隔 | 500 毫秒(GarbageCollectGetTimeLimit() 初始读数) | 两版初始读数均为 500 | | GarbageCollectSetTimeLimit | 返回设置前的旧间隔;入参为 null 时返回 null 且不改设置 | 读数见第九节 | | 显式 GarbageCollect() | 通常立即生效;但它没有返回值,个别上下文里观测到未生效的案例,成败必须靠标记验证 | 见第十一节 | | PB10 与 PB12.5 差异 | 无,全部读数逐字一致 | 双轨对照 |
三、三件套函数全貌
三个都是全局函数,签名如下:
- GarbageCollect() // 强制立即做一次垃圾回收,无返回值
- GarbageCollectGetTimeLimit() // 返回 Long:当前 GC 最小间隔(毫秒)
- GarbageCollectSetTimeLimit(新间隔毫秒) // 返回 Long:设置之前的旧间隔
复制代码
第一件事先摸底:默认间隔是多少。
- // 输入值:无。本段直接读取系统当前 GC 最小间隔并弹窗显示
- long ll_limit
- ll_limit = GarbageCollectGetTimeLimit()
- MessageBox('GC 最小间隔', '当前最小间隔 = ' + String(ll_limit) + ' 毫秒')
- // 实测输出:当前最小间隔 = 500 毫秒(PB10 与 PB12.5 相同)
复制代码
GarbageCollectSetTimeLimit() 的返回值是旧值,这个设计可以无脑保存恢复,不用先 Get 再 Set:
- // 输入值:li_new 想设置的新间隔(毫秒),逐行注释见右
- long ll_prev
- long ll_now
- long ll_back
- long ll_new
- ll_new = 5000 // 想改成 5 秒
- ll_prev = GarbageCollectGetTimeLimit() // 先记下当前值(实测 500)
- GarbageCollectSetTimeLimit(ll_new) // 设置
- ll_now = GarbageCollectGetTimeLimit() // 读回应为 5000
- ll_back = GarbageCollectSetTimeLimit(ll_prev) // 恢复,返回值为设置前的 5000
- MessageBox('时间限制', 'prev=' + String(ll_prev) + ', now=' + String(ll_now) + ', back=' + String(ll_back))
- // 实测输出:prev=500, now=5000, back=5000(恢复后再读回 500)
复制代码
两个容易忽略的点,都是实测出来的:
- 入参传 null:函数返回 null,且当前间隔不被改动。也就是说它不是「设成 0」也不是报错,是安静地什么都不做;
- 传 0 是合法的:等于不节流,自动回收随时可以做。传很大的数(比如 2000000000)等价于长期压制自动回收。
- // 输入值:ll_null 置空后的 long,用于验证 null 入参行为
- long ll_null
- long ll_ret
- SetNull(ll_null) // 造一个 null 的 long
- ll_ret = GarbageCollectSetTimeLimit(ll_null) // 把 null 传进去
- MessageBox('null 入参', 'IsNull(返回值)=' + String(IsNull(ll_ret)) + ', 间隔仍是=' + String(GarbageCollectGetTimeLimit()))
- // 实测输出:IsNull(返回值)=true, 间隔仍是=500 —— 返回 null,设置未变
复制代码
GarbageCollect() 本身没有返回值、没有错误码,调用成败完全无从判断——这是它和 Trace 函数族最大的差别,也是第十一节要展开的坑。
四、验证方法:析构标记文件法
析构事件不可见、返回值不可用,怎么知道对象死了?给探针对象装一个「遗嘱」:destructor 事件里往文件追加一行字。文件出现了,析构就触发过;文件没出现,对象还活着。
- forward
- global type uo_probe from nonvisualobject
- end type
- end forward
- global type uo_probe from nonvisualobject
- end type
- global uo_probe uo_probe
- type variables
- string is_mark // 标记文件全路径,由 of_setmark 指定
- end variables
- forward prototypes
- public subroutine of_setmark (string as_path)
- public subroutine of_noop ()
- end prototypes
- // 析构前先设定标记文件写到哪
- public subroutine of_setmark (string as_path);
- is_mark = as_path
- end subroutine
- // 占位方法,让对象有事可做
- public subroutine of_noop ();
- end subroutine
- on uo_probe.create
- call super::create
- TriggerEvent( this, "constructor" )
- end on
- on uo_probe.destroy
- TriggerEvent( this, "destructor" )
- call super::destroy
- end on
- event destructor;
- long ll_f
- if Len(is_mark) > 0 then
- ll_f = FileOpen(is_mark, LineMode!, Write!, LockWrite!, Append!)
- if ll_f > 0 then
- FileWrite(ll_f, 'DESTR')
- FileClose(ll_f)
- end if
- end if
- end event
复制代码
用 Append! 追加而不覆盖,是因为析构可能触发不止一次——第十节会给你看一个真的触发两次的例子。这一招不挑版本、不挑环境,是验证「对象到底死了没有」最朴素的办法,本文所有读数都靠它。
一个重要前提:标记路径必须是绝对路径,而且检查和写入要用同一个路径。我们的第一版探针吃过亏——同一份逻辑在不同流程里跑出相反读数,最后发现是上一轮的旧标记文件污染了 FileExists 判断。所以每个用例的标记名都要带唯一前缀(比如时间戳),从不去猜文件是不是「这次的」。
五、实测一:局部对象出作用域,析构立即触发
第一个要验证的直觉:方法里的局部 NVO,方法一结束是不是马上销毁?答案是立刻,根本不用等 GC。
- forward
- global type nvo_gc_life from nonvisualobject
- end type
- end forward
- global type nvo_gc_life from nonvisualobject
- end type
- global nvo_gc_life nvo_gc_life
- forward prototypes
- public subroutine of_drop_local (string as_mark)
- end prototypes
- // 创建一个探针,方法结束时引用自然消失
- public subroutine of_drop_local (string as_mark);
- uo_probe_a lp
- lp = create uo_probe_a
- lp.of_setmark(as_mark)
- lp.of_noop()
- // 注意:这里不 destroy,也不调 GarbageCollect,引用随方法结束释放
- end subroutine
复制代码
调用方紧跟着检查标记文件:
- // 输入值:ls_mark 标记文件绝对路径(建议带时间戳前缀保证唯一),先赋值再传入
- string ls_mark
- nvo_gc_life lf
- ls_mark = 'gcmark_' + String(Now(), 'hhmmss') + '_l1.txt'
- lf = create nvo_gc_life
- lf.of_drop_local(ls_mark)
- MessageBox('析构时机', '方法返回后标记已出现 = ' + String(FileExists(ls_mark)))
- // 实测输出:true —— 引用计数归零当场析构,未调用任何 GC 函数
复制代码
这个读数在 PB10 与 PB12.5 上完全一致,而且反复跑了多轮全部如此。也就是说:普通对象你根本不用操心 GC,丢掉引用就完事了。GC 不是为它们准备的。
六、实测二:destroy 的析构时机与 IsValid 的三个读数
显式 destroy 更没有悬念——析构在 destroy 语句当行就完成:
- // 输入值:ls_mark 标记文件全路径
- uo_probe_a lp
- lp = create uo_probe_a
- lp.of_setmark(ls_mark)
- destroy lp
- // destroy 的下一行,标记文件已经存在
- MessageBox('destroy 时机', '析构已触发 = ' + String(FileExists(ls_mark)))
- // 实测输出:true
复制代码
顺手把 IsValid() 的三种读数也钉死:
| 对象状态 | IsValid 返回 | 实测 | | 从未 create 的局部引用 | false | neverCreated=false | | destroy 之后 | false | afterDestroy=false | | 还被持有时 | true | held=true |
注意第三行:IsValid 只能告诉你引用是否指向活对象,不能告诉你对象「还有没有人用」——被持有就是 true,哪怕你打算再也不用它。
七、实测三:循环引用——GC 存在的唯一理由
现在造一对互相引用的对象。这里有个导入器层面的坑要先交代:两个 NVO 在源码里互相引用对方类型,用 ORCA 方式导入时是「鸡生蛋」——先导谁都报 C0001 Illegal data type,因为对方还不在库里。解法是成员变量和参数都用祖先类型 nonvisualobject,运行时照样构成真正的循环引用,编译期却互不依赖:
- forward
- global type uo_circ1 from nonvisualobject
- end type
- end forward
- global type uo_circ1 from nonvisualobject
- end type
- global uo_circ1 uo_circ1
- type variables
- nonvisualobject ic2 // 用祖先类型,导入顺序就不再重要
- string is_mark
- end variables
- forward prototypes
- public subroutine of_setmark (string as_path)
- public subroutine of_link (nonvisualobject a_c2)
- end prototypes
- public subroutine of_setmark (string as_path);
- is_mark = as_path
- end subroutine
- // 指向对方,构成引用环
- public subroutine of_link (nonvisualobject a_c2);
- ic2 = a_c2
- end subroutine
- // uo_circ2 与此完全对称,成员是 nonvisualobject ic1
复制代码- forward
- global type nvo_gc_life from nonvisualobject
- end type
- end forward
- global type nvo_gc_life from nonvisualobject
- end type
- global nvo_gc_life nvo_gc_life
- forward prototypes
- public subroutine of_drop_cycle (string as_mark1, string as_mark2)
- public subroutine of_gc ()
- end prototypes
- // 创建一对循环引用,方法结束时外部引用全部消失
- public subroutine of_drop_cycle (string as_mark1, string as_mark2);
- uo_circ1 lc1
- uo_circ2 lc2
- lc1 = create uo_circ1
- lc2 = create uo_circ2
- lc1.of_setmark(as_mark1)
- lc2.of_setmark(as_mark2)
- lc1.of_link(lc2)
- lc2.of_link(lc1)
- // 两个局部变量互相攥着对方,出作用域后引用计数都到不了 0
- end subroutine
- // 显式做一次垃圾回收
- public subroutine of_gc ();
- GarbageCollect()
- end subroutine
复制代码
读数分两步看:
| 时机 | 循环对析构标记 | 结论 | | 引用全断后、未调 GC | 未出现(false) | 循环引用靠引用计数收不了,继续存活 | | 显式 GarbageCollect() 之后 | 出现(true / true,两个都收) | GC 是循环引用唯一的收尸人 |
这正是垃圾回收在 PB 里存在的理由:引用计数对付不了环,环只能靠 GC 从「可达性」上判断。反过来说,如果你的对象没有环,GarbageCollect() 基本无事可做。
补一个我们没打算深挖但如实报告的观察:在纯计算的测试进程里,Sleep(2) 睡两秒的窗口内循环引用并没有被自动回收(三次复跑一致);但另一次长流程中出现过未显式调用 GC、循环对却消失的情况。自动回收的触发时机依赖引擎内部状态,秒级精度下摸不准。教学口径一句话:有环就自己显式 GarbageCollect(),别指望自动。
八、实测四:被持有的对象,GC 拿它没办法
「对象没人用了」是人的判断,GC 只认引用。把探针塞进一个活对象的实例变量里,再怎么 GarbageCollect() 都白搭:
- type variables
- uo_probe_a ia_hold // nvo_gc_life 的实例变量,持有权在这里
- end variables
- forward prototypes
- public subroutine of_held_hold (string as_mark)
- end prototypes
- // 把探针挂到自己的实例变量上,本对象活着它就活着
- public subroutine of_held_hold (string as_mark);
- ia_hold = create uo_probe_a
- ia_hold.of_setmark(as_mark)
- end subroutine
复制代码- // 输入值:ls_mark 标记文件全路径
- nvo_gc_life lf
- lf = create nvo_gc_life
- lf.of_held_hold(ls_mark)
- GarbageCollect()
- MessageBox('持有验证', 'GC 之后标记是否出现 = ' + String(FileExists(ls_mark)))
- // 实测输出:false —— 持有者活着,GC 不回收
- destroy lf
- MessageBox('连锁析构', '持有者销毁后标记是否出现 = ' + String(FileExists(ls_mark)))
- // 实测输出:true —— 持有者一死,里面挂着的对象立刻析构
复制代码
两组读数两版一致。这条的实际意义:怀疑内存泄漏时,先画引用图——对象被谁挂在实例变量(或数组、DataStore 的对象属性)上,顺藤摸瓜找到那个忘了销毁的持有者,比狂调 GarbageCollect 有用得多。
九、实测五:autoinstantiate 的析构会跑两遍
autoinstantiate 的 NVO 声明即创建、出作用域即销毁,用起来省心。但我们给析构装上标记文件后,发现了一个怪事:声明一个实例,析构脚本跑了两遍。
验证方法很直接:一个方法里只声明一个 autoinstantiate 局部变量,方法结束后数标记文件里的行数。
- forward
- global type uo_probe_b from nonvisualobject autoinstantiate
- end type
- end forward
- global type uo_probe_b from nonvisualobject autoinstantiate
- end type
- global uo_probe_b uo_probe_b
- event destructor;
- long ll_f
- ll_f = FileOpen('gcmark_b.txt', LineMode!, Write!, LockWrite!, Append!)
- if ll_f > 0 then
- FileWrite(ll_f, 'DESTR')
- FileClose(ll_f)
- end if
- end event
复制代码- // 输入值:无。声明一个 autoinstantiate 局部对象,出作用域自动析构
- uo_probe_b lb
- lb.of_noop()
- // 方法结束后检查 gcmark_b.txt:一个实例,两行 DESTR
复制代码
实测:单个实例的析构标记出现 2 次,PB10 与 PB12.5 一致(我们跑了多轮、跨多个进程对账,行数永远比实例数多一倍)。同一份标记逻辑挂在普通 NVO 上就只触发一次。结论只能下到这里:autoinstantiate 对象的 destructor 事件脚本确实执行了两次——至于是引擎销毁路径走了两遍还是别的机制,没继续深挖。实用建议只有一条:destructor 里放「只许执行一次」的逻辑(关连接、删临时文件、写审计日志)时,别用 autoinstantiate,或者自己加防重入开关。
十、实测六:SetNull 对象引用,编译放行、运行埋雷
这一节最反直觉。SetNull() 文档说能用于「任何变量类型」,对象引用居然真的能编译通过:
- // 输入值:ls_mark 标记文件全路径
- uo_probe_a lp
- boolean lb_v
- lp = create uo_probe_a
- lp.of_setmark(ls_mark)
- SetNull(lp) // 编译器放行!
- lb_v = IsValid(lp)
复制代码
运行时逐项取证,读数如下:
| 检查项 | 读数 | 说明 | | IsValid(lp) | NULL(不是 false!) | IsNull(lb_v)=true,布尔结果都是 NULL | | 对象本体 | 立即析构 | 析构标记当场出现 | | 之后再调方法 | 运行期错误 Null object reference | 崩在调用行,TRY-CATCH 之外照常炸 |
也就是说 SetNull(对象) 的实际语义接近「释放引用并置空」:引用一空,引用计数归零,对象当场析构;而 IsValid 对空引用返回的不是 false 而是 NULL,拿它做布尔判断会静默走 NULL 分支,拼接字符串时整串悄悄变空。想安全释放引用,老老实实 destroy;SetNull 留给字符串、日期这些值类型。
顺带钉死另一扇门:lp = null 这种写法编译不过,报 C0015: Undefined variable: null。PB 的对象引用没有 null 字面量可赋,想置空只有 SetNull 一条路(而它的行为如上,慎用)。
十一、GarbageCollect 的节流与「成败不可知」
回到三件套的第三个函数。GarbageCollect() 有两个实测特性,合在一起构成它最大的使用陷阱:
一,它没有返回值、没有错误码。 调用是否真的执行了回收、回收了什么,一概不知。
二,它的生效与「上次回收过去多久」有关。 默认最小间隔 500 毫秒;GarbageCollectSetTimeLimit() 设置的间隔写读都正常(返回旧值、null 不改设置),但在「设了超大间隔后紧跟调用」的场景里,我们观测到过回收没有发生的案例——同一长流程里六次一致,可单例隔离复测却又回收了。用秒级的观测精度,实在给不出一条干净的边界线,这里只给结论不编故事:
- 显式 GarbageCollect() 通常立即生效(本文第七节全部读数依赖这一点,稳定复现);
- 但不要在代码里假设它百分之百立即执行。紧跟在上一次回收之后的调用,存在不生效的实测案例;
- 验证成败的唯一办法就是第四节那招——让对象的析构自己留下痕迹;
- 自动回收的触发时机同样摸不准(Sleep(2) 的窗口内我们没抓到过一次),所以「有环必须显式调用」这条前面已经说过了,这里不重复。
一句话总结这一节:GarbageCollect 是「尽力而为」的语义,不是「保证完成」的语义。
十二、PB12.5 差异
本文全部探针在 PB10 与 PB12.5 双轨运行,所有读数逐字一致,包括:
- 默认间隔 500、SetTimeLimit 返回旧值、null 入参行为;
- 引用计数立即析构、destroy 当行析构、循环引用等 GC、持有保护、连锁析构;
- autoinstantiate 析构双跳;
- SetNull(对象) 的三个读数(IsValid=NULL、立即析构、Null object reference)。
与 PB10 行为一致,无需单独差异清单。
十三、完整自检示例
把上面的结论拼成一个可以直接抄走的自检对象。两个辅助方法负责「创建并丢引用」——注意创建和检查必须分处两个方法,写在同一个方法里局部变量到函数结束都没出作用域,读数全错(我们第一版就栽在这里):
- forward
- global type nvo_gc_demo from nonvisualobject
- end type
- end forward
- global type nvo_gc_demo from nonvisualobject
- end type
- global nvo_gc_demo nvo_gc_demo
- type variables
- uo_probe_a ia_hold
- end variables
- forward prototypes
- public function string of_run ()
- public subroutine of_drop_local (string as_mark)
- public subroutine of_drop_cycle (string as_mark1, string as_mark2)
- end prototypes
- // 创建探针并丢引用(引用计数析构发生在方法结束时)
- public subroutine of_drop_local (string as_mark);
- uo_probe_a lp
- lp = create uo_probe_a
- lp.of_setmark(as_mark)
- lp.of_noop()
- end subroutine
- // 创建循环引用对并丢引用
- public subroutine of_drop_cycle (string as_mark1, string as_mark2);
- uo_circ1 lc1
- uo_circ2 lc2
- lc1 = create uo_circ1
- lc2 = create uo_circ2
- lc1.of_setmark(as_mark1)
- lc2.of_setmark(as_mark2)
- lc1.of_link(lc2)
- lc2.of_link(lc1)
- end subroutine
- // 全流程自检,返回报告字符串
- public function string of_run ();
- string ls_rpt
- string ls_tag
- ls_tag = String(Now(), 'hhmmss') // 时间戳前缀,保证标记文件唯一
- ls_rpt = ''
- // 1. 局部对象出作用域立即析构(引用计数)
- of_drop_local('gcm_' + ls_tag + '_l1.txt')
- ls_rpt += '1 local destructor fired=' + String(FileExists('gcm_' + ls_tag + '_l1.txt')) + '~n'
- // 2. 循环引用无 GC 不回收
- of_drop_cycle('gcm_' + ls_tag + '_c1.txt', 'gcm_' + ls_tag + '_c2.txt')
- ls_rpt += '2 cycle collected without GC=' + String(FileExists('gcm_' + ls_tag + '_c1.txt')) + '~n'
- // 3. 显式 GarbageCollect 收走循环对
- Sleep(1)
- GarbageCollect()
- ls_rpt += '3 after GC, cycle collected=' + String(FileExists('gcm_' + ls_tag + '_c1.txt')) + '/' + String(FileExists('gcm_' + ls_tag + '_c2.txt')) + '~n'
- // 4. 被持有的对象 GC 不收
- Sleep(1)
- ia_hold = create uo_probe_a
- ia_hold.of_setmark('gcm_' + ls_tag + '_h.txt')
- GarbageCollect()
- ls_rpt += '4 held object survives GC=' + String(not FileExists('gcm_' + ls_tag + '_h.txt'))
- // 5. 持有者销毁,连锁析构
- destroy ia_hold
- ls_rpt += ', dies with holder=' + String(FileExists('gcm_' + ls_tag + '_h.txt')) + '~n'
- return ls_rpt
- end function
复制代码
调用就一行,弹窗看报告:
- // 输入值:无。实例化自检对象并运行,报告经弹窗显示
- nvo_gc_demo ld
- ld = create nvo_gc_demo
- MessageBox('对象生命周期自检', ld.of_run())
- destroy ld
复制代码
实测输出(PB10 与 PB12.5 逐字一致):
- 1 local destructor fired=true
- 2 cycle collected without GC=false
- 3 after GC, cycle collected=true/true
- 4 held object survives GC=true, dies with holder=true
复制代码
四行读数,正好是第四到第八节的四个结论。
十四、坑清单
- 「不用了」不等于「已销毁」:普通对象丢引用即析构(引用计数),循环引用必须显式 GarbageCollect()。
- GarbageCollect() 无返回值:成败不可知,验证靠析构标记文件,别赌它一定执行。
- GC 只认引用不认用途:对象被谁挂在实例变量上,GC 就拿它没办法;查泄漏先查持有者。
- 析构验证的标记文件必须唯一:用时间戳前缀,否则上一轮的旧标记会污染 FileExists 判断(我们亲测翻过车)。
- 创建与检查要分处两个方法:写在同一方法里,局部变量没出作用域,读数全错。
- 循环引用对的源码写法:互相引用对方类型无法通过 ORCA 导入,成员与参数用祖先类型 nonvisualobject 即可,运行时环照成。
- autoinstantiate 的析构跑两遍:destructor 里有不可重复执行的操作时慎用,或加防重入开关。
- SetNull(对象引用) 三个雷:IsValid 返回 NULL 而非 false;对象立即析构;之后调方法直接 Null object reference。lp = null 则编译都不过(C0015)。
- GarbageCollectSetTimeLimit 返回旧值:可直接用于保存恢复;null 入参返回 null 且不改设置。
- 默认最小间隔 500 毫秒:它是自动回收的节流阀;显式调用通常立即生效,但成功与否永远以标记为准。
十五、结语
PB 的对象销毁模型其实很仁慈:引用计数把九成九的事在丢引用的瞬间做完,GC 专门兜循环引用的底。真正让人栽跟头的,从来不是机制本身,而是「我以为它死了」——在没有析构钩子、没有返回值、没有错误码的世界里,让对象自己留下痕迹是唯一可靠的验证手段。这套标记文件法不挑版本、五行代码就能装到任何 NVO 上,建议直接进你的调试工具箱。
(完) |