数组与对象在内存中的存储方式有何差异?

解读

国内大厂面试常把“数组 vs 对象”当成考察“PHP 底层是否看过”的试金石。
面试官真正想听的是:

  1. 你是否知道 PHP7 的 zval 重构、引用计数、写时复制(COW)机制;
  2. 你是否能区分“符号表”与“对象存储”两条路径;
  3. 你是否能据此写出高并发、低内存的代码。
    答“数组是哈希、对象是结构体”只能拿 60 分;答出“zval、refcount、COW、对象句柄、属性表、GC 根缓冲区”才能拿到 90 分以上。

知识点

  1. zval 结构:PHP7 将变量拆成 zval 与 zend_value,zval 存类型、值、refcount、type_flag。
  2. 数组(IS_ARRAY):zend_array 封装了 arData 有序哈希,冲突用链表法,负载因子触发 2 倍扩容;refcount 为 1 时直接修改,>1 时触发 COW。
  3. 对象(IS_OBJECT):zend_object 统一头 + 属性表(properties)+ 方法表(handlers)。对象在 EG(objects_store) 里按“句柄→对象”映射,zval 只保存句柄,因此传对象相当于传 8 字节指针,天然“引用语义”,不会 COW。
  4. 内存占用:空数组 ≈ 56 B,每增一个元素再 + 32 B(key+value+zend_array 扩容);空对象 ≈ 40 B,但属性表是 zend_array,所以属性越多越接近数组的内存曲线。
  5. 垃圾回收:数组循环引用由 GC 根缓冲区标记清除;对象因含析构函数可能进入“可能根”队列,触发时机更晚,写扩展时要特别留意。
  6. 性能差异:数组遍历走 C 指针连续内存,CPU cache 友好;对象属性访问需先取 handlers->read_property,多一次函数指针跳转,OPcache 可将热点属性内联,差距缩小到 5% 以内。

答案

在 PHP7 及以上版本,数组与对象虽然都属于复合类型,但内存布局与语义完全不同:

  1. 存储层级:数组的 zval 直接内嵌 zend_array,而对象的 zval 只保存一个数字句柄,真正的 zend_object 存在全局对象仓库。
  2. 复制语义:数组采用写时复制,refcount>1 时修改会整表复制;对象始终同一实例,传参、赋值、返回都只复制句柄,不会产生新对象。
  3. 生命周期:数组的 refcount 降到 0 立即释放;对象如果定义了析构函数,会先放入 GC 队列,可能延迟到下一次 GC 周期。
  4. 内存大小:空数组 56 B,空对象 40 B,但对象属性表本质也是 zend_array,因此属性膨胀后两者差距收敛;真正拉开差距的是数组的 COW 复制成本。
  5. 实际影响:高并发接口若把大数组当配置缓存,建议用对象或 SplFixedArray 封装,避免无意中触发 COW 导致内存瞬间翻倍;需要值语义时,用 clone 或深度拷贝库,而不是依赖数组的默认行为。

拓展思考

  1. 扩展开发:在 C 扩展里创建对象应调用 object_init_ex,并注册自定义 handlers,可让属性访问绕过 PHP 层,直接走 C 结构体,性能提升 3~5 倍。
  2. 常驻内存:Swoole\Table、Worker 全局变量、APCu 都禁止存对象,只能存数组,根本原因就是对象句柄只在当前请求有效,跨请求会悬空。
  3. 不可变对象:PHP 没有原生的 immutable,但可以用 final class + private readonly 属性 + 不提供 setter 模拟;配合 opcache.enable_cli=1,可将配置对象编译为共享内存,既保留对象语义,又避免 COW。
  4. 8.3 新特性:Lazy Objects 允许延迟初始化,属性表在第一次访问时才分配,大型 ORM 可借此把内存峰值再降 15% 左右,面试时提一句能体现你对新版本的跟进。