前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >UE4的智能指针 UObject相关

UE4的智能指针 UObject相关

作者头像
quabqi
发布2021-11-04 10:55:47
2.6K0
发布2021-11-04 10:55:47
举报
文章被收录于专栏:Dissecting UnrealDissecting Unreal

上一篇介绍了UE4普通的共享指针TSharedPtr,了解到了内部是使用引用计数来管理的。

但是一般情况下,TSharedPtr这类指针是不能直接用于UObject的(非得强行使用也不是不行,但是要自己实现Deleter),因为UE4对于UObject是在引擎内部管理的,不能直接delete,就像C#或Java一样,所有托管的对象都有个Object基类,UObject也是所有UE4托管对象的基类。当然UE4回收对象也和C#和Java差不多,需要通过垃圾回收来释放内存,虚拟机在做垃圾回收时如果发现了对象没有引用,就可以标记并清除掉对象。在C#或Java中,当把对象置空,只要代码中没有任何一个地方引用着这个对象,虚拟机就知道了没有引用,但UE4的代码主要是C++来编写,平常我们写的普通指针UE4并没有能力知道是否为一个UObject的引用,自然也就不清楚来管理这些对象是否被引用,当你使用一个已经被清除的对象,就像正常C++使用野指针的情况一样发生崩溃或各种意外情况。为了解决这样的问题,UE4也提供了一些包装UObject的智能指针,使用这些指针就可以让UE4清楚的了解到对象的引用情况。下面就主要来介绍这些指针,上一篇中也有列出有下面这些关于UObject的智能指针

持有UObject的智能指针

TStrongObjectPtr,TWeakObjectPtr,FSoftObjectPtr,TSoftObjectPtr,TSoftClassPtr,FSoftObjectPath,FLazyObjectPtr,TPersistentObjectPtr,FGCObject,FGCObjectScopeGuard,TGCObjectsScopeGuard,TWeakInterfacePtr

UObjectBase

在开始之前,需要先介绍一下UE4是怎样管理UObject的,在这期间就会穿插讲解对应的智能指针是怎样实现的。

UObject其实并不是最上层的基类,UE4可能是觉得代码都写在UObject里太多了,看起来比较麻烦,所以就让UObject还继承着别的基类,这样可以把不同代码写到不同的基类里。其中最上层的基类是UObjectBase,他在创建的时候会把自己交给UE4的两个全局容器来管理,在销毁的时候把自己从管理自己的容器中移除,具体可以看下面这张图:

1 创建

在UObjectBase构造函数中,会调用AddObject函数,在这个函数内,会把自己加到两个全局的大容器内,一个是UObjectArray,一个是UObjectHash,可以先不管具体这两个全局容器是做什么的,暂时只需要了解UObject创建后就存到了这两个容器里即可。下面会具体来说这两个容器,当然在给对象改名(调用Rename)的时候,会先移除再重新调用AddObject,如下图所示

上面这个AllocateUObjectIndexForCurrentThread函数:

下面的HashObject函数:

2 销毁

在析构时,会把自己从全局的UObjectArray上移除

在BeginDestroy时,最终会调用到LowLevelRename函数,在这个函数内,会先调用UnhashObject把自己从UObjectHash中移除。

你可能会好奇为什么这里调用的改名函数呢?因为这里其实就是把对象名改为None,上面HashObject代码可以看到,如果名字为None,就不会重新把Object加到UObjectHash中,所以直接复用改名函数就可以了。

创建的时候,两个容器都是在构造函数中一起加进去的,而销毁的时候Hash在BeginDestroy就清理了,而Array在析构函数才清理,这是一个要注意的地方,为什么有的对象可以在UObjectArray里看到但不能在UObjectHash里看到就是这个原因。

下面来说说UObjectArray和UObjectHash

UObjectArray

其实就像这个类的名字一样,UObjectArray其实就是一个大数组,只是由于整个游戏中要管理的UObject可能有几十万个,数量太多了,就很难用一个大数组来保存,即使可以存的下性能也不好。所以UE4为了性能更好一些,把这个大数组分成了很多个Chunk,每个Chunk中保存了64*1024也就是65536个UObjectItem。当一个Chunk满了会新开一个Chunk继续接着存,为什么是65536(64K)这个神奇的数字呢?我也不了解具体的原因,但是听各种小道消息说,这个数字跟硬件有关系,设64K对缓存友好。包括UE4自己的内存池,TChunkedArray以及Unity的ECS中,只要各种有Chunk这个关键字的地方,一般都是64K这么大。

但不管内部是否连续,对于外部用户来说,我们就把他当作一个大数组好了。我们知道数组是使用下标来访问元素的,因此我们只要知道了某个对象在这个UObjectArray数组的下标,我们就可以拿到这个对象。UE4的UObject确实就是这样做的,会把这个下标存到自己的成员变量上,如下图所示,这个InternalIndex就是全局大数组GObjectArray的下标。

你可能会说UObject析构的时候,就把自己从数组上删除了,当再创建新的UObject时,原来删除的空位是可能被重新分配的,这样原来的下标就会指向一个新的UObject,而如果业务一直保存着原来的下标不就取错了对象吗?实际上也确实如此,所以这个大数组存的并不是UObject本身,而是FUObjectItem,在这个结构里,除了UObject的指针意外,还有存另外一个字段,SerialNumber。如下图所示:

这个序列号其实就是一个从0开始递增的,当新申请一个的时候就加1,这样就肯定不会重复了。

FWeakObjectPtr,TWeakObjectPtr

所以业务只要拿着这个UObject的Index和序列号,就一定不会找错对象。这正好就是UE4的FWeakObjectPtr的内部实现,一个索引+一个序列号,如果对象没销毁,那么肯定能获取到对应的对象,如果销毁了,如果原来位置没有对象,肯定取不到,如果有一个新对象,那么序列号肯定不一样,这正好就实现了UObject的弱引用。下面是FWeakObjectPtr的成员变量,可以看到确实如此。如果在定义时就知道类型,就也可以使用TWeakObjectPtr,他们底层是完全一样的,C++类模板中的类型信息是编译时保存到类上的,并不会在运行时带来额外的性能开销。

可以看到,构造时候调用的是赋值,赋值参数为UObject时,实际是通过全局GUObjectArray获取的Index和序列号

Index就是直接返回Object里面这个InternalIndex

而序列号就是上面递增的那个序列号。

当通过Index获取对象时,实际就是通过Index到GUObjectArray去取

进入这个函数里面,可以看到实际就是用下标取对象

看过Unity中ECS代码的同学肯定会觉得这个Index+序列号的结构很熟悉,ECS中的Entity就是这样的结构,再加上前面说的Chunk,ECS主打的就是性能,那可想而知UE4这样做性能肯定也是有保证的。因为FWeakObjectPtr里面就是两个int成员变量,所以整个FWeakObjectPtr占8字节,大小正好和一个指针相同,取值时就是一个查询的开销,所以总的来看还是很省的,在业务代码中到处使用也不需要担心有性能问题。

TStrongObjectPtr

既然有弱对象指针,那么对应的肯定也会有强对象指针,StrongObjectPtr就是UObject的强引用指针,只要这个对象没有被析构,那么他所持有的Object就不会被gc掉。那么这个指针是怎样实现的呢?我们首先想象一下,正常C++对象如果要释放,那么肯定需要调用到析构函数,而UObject的析构函数是在对象GC的时候调用的,在GC期间之外,UObject对象本质上也是C++对象,行为其实和普通的C++对象没有任何区别,那么我们要持有这个对象的强指针,那么肯定不能像上面那样,只拿着对象的一个index,到使用的时候再把对象的指针换出来。也就是说,TStrongObjectPtr内部就一定要持有这个对象的原始指针。另外要避免在TStrongObjectPtr存活期间,UObject不能被GC掉,那么也就必须保证UE4在垃圾回收的时候,这个对象内部要有办法可以告诉垃圾回收器不要来删自己内部的这个UObject。只要保证了这两点,就能保证TStrongObjectPtr是一个强指针。下面就来看内部实现:

可以看到内部只有一个成员变量,这个变量是一个TUniquePtr包装的FInternalReferenceCollector,TUniquePtr其实就和std::unique_ptr一样,就像他的名字一样是unique的,简单说就是屏蔽掉了拷贝相关操作的智能指针,这样就可以防止FInternalReferenceCollector不会被拷贝到别的地方。那么关键的实现肯定是在FInternalReferenceCollector内部。

可以看到,这个类的内部确实持有了一个UObject的裸指针,满足条件1。而对于条件2是怎样做到的呢?可以看这个类又继承了一个FGCObject,其中override基类了一个AddReferencedObjects函数,在这个函数内调用Collector把Object的裸指针加了进去,从名字我们大概就能猜到,这个Collector肯定是垃圾回收中引用的收集器,UE4在GC的时候会调用这个函数,通过把Object当作参数传给Collector,这样UE4就知道了这个对象存在引用,不回收这个对象。如上图所示,这个类内并没有发现有把当前对象注册到UE4垃圾回收器之类的逻辑,而且也没看到Collector定义,那么这套实现肯定是在基类FGCObject来做的。

可以看到,基类里出现了一个GGCObjectReferencer,而且是静态的UObject,这个对象在一个静态初始化函数中创建出来,并且加到了Root上,就像C#或Java语言一样,只要标为了Root,UE4在垃圾回收的时候会从Root还是收集引用,那么这个对象肯定就不会被GC了。(UE4的Root并不像其他语言是一个根对象,而是很多个,但道理一样这里不深究)。那么可以想到,只要我们内部的UObject被GGCObjectReferencer引用住,就不会被GC了。上面Init函数是成员函数,那么每个对象调用Init时候肯定都会调用一次StaticInit,但StaticInit内部只有在对象GGCObjectReferencer为NULL时候才会执行,所以即使调用了多次真正只会构造一个GGCObjectReferencer,所以这个对象是全局唯一的。调用StaticInit之后,又调用了GGCObjectReferencer->AddObject(this),这里的this并不是UObject本身,而是FGCObject,也就是说FGCObject将自己交给了GGCObjectReferencer来管理。

在FGCObject构造函数中可以看到,就直接调用了Init,在析构函数中调用了GGCObjectReferencer->RemoveObject(this),这就说明了FGCObject自己存活的时候就在GGCObjectReferencer内部,自己析构了就从GGCObjectReferencer上移除了。

可以看到UGCObjectReferencer内,保存了一个FGCObject*的数组,其中AddObject和RemoveObject的实现就是把参数传入的FGCObject加到这个数组上和从这个数组上删除。

因为GGCObjectReferencer是一个Root的UObject,UE4在GC的时候,会调用他的AddReferencedObjects函数,这个函数实现内部可以看到,他遍历了FGCObject*的数组依次调用AddReferencedObjects,这样就相当于是把TStrongObjectPtr内部持有的UObject加入了GGCObjectReferencer的引用,那只要TStrongObjectPtr存活,UE4自然就不会去GC他内部持有的UObject了。

可能有人会说标记UPROPERTY宏的成员变量UObject裸指针就相当于有了引用,这里其实并不一样,首先这个TStrongObjectPtr可以在任何地方使用,包括非UObject类的内部,比如某个F开头结构体的一个成员变量,另外标记UPROPERTY并不代表一定不会被GC,如果他所属的对象被GC,那么就会跟着GC,而TStrongObjectPtr是存活期间一定不会GC。但是能用TStrongObjectPtr替换掉所有的UPROPERTY标记的UObject吗?从理论上说可以这么做,但最好不要这样做,因为还是有一些对性能不太好的地方。可以想象的到,这个对象要等到析构才会释放,而UE4的垃圾回收是标记-清除两个阶段,如果所属的对象是一个UObject,这个UObject被标记的时候,内部的TStrongObjectPtr还是存活的,而只有在清除的时候才会调用到析构函数从而把TStrongObjectPtr析构掉,而TStrongObjectPtr析构只是把自己从GGCObjectReferencer上移除,那么内部持有的UObject要等到下一轮GC才会被释放掉,也就是说比自己的属主晚了一轮GC,这样就造成了在两轮GC之间有内存的浪费。

当然TStrongObjectPtr只是一个简单的模板类,在了解清楚了机制后,我们完全可以自己实现自定义的类继承FGCObject,并实现AddReferencedObjects函数,就可以支持自己管理UObject,比如UE4的加载资源的类FStreamableManager就是这么做的,可以看到是F开头的类,本身并不是一个UObject,但为了防止正在加载的资源被GC,就继承了FGCObject,一样可以保证安全的持有对象的引用。

UObjectHash

前面的UObjectArray是通过数组管理的,而UObjectHash从名字可以看出,肯定就是通过Hash来管理对象的,内部肯定是一个类似Map的结构保存着UObject。

可以看到确实如此。我们知道,UObject的数量级很大,一般游戏都会有几十万个,UObjectArray为了保证能存下这么多UObject就划分了Chunk,而为什么UObjectHash就直接用TMap不划分Chunk呢?这里再仔细想想,其实TMap本质上就已经按Hash值将结果划分到不同的地方了,而且从上面定义也可以看出来,其实TMap的Value是FHashBucket,这个FHashBucket是一个桶,说明不止是一个对象,有可能是多个。

这里不详细讲解这个Map建立以及得到Key的过程了,具体讲解可以看我另一篇资源管理的文章,有详细说明:

其中Key值int32是对象的名字的Hash值,名字本身是字符串,可以想象出来一定会产生Hash冲突,那么相同的Hash值自然可以保存到同一个FHashBucket内。

这个Bucket本身也没有什么特殊的,在UObject数量小于等于两个的时候,是直接存在ElementsOrSetPtr上的,而当超过两个的时候,会new一个TSet,将UObject存入其中,这个Set会存到2号位上,1号位置空。你可能会说为什么不直接用TSet,其实是因为一个空的TSet本身就会占用80字节,因为这里管理的是整个游戏所有的UObject,如果直接用TSet那么就会非常浪费。

FSoftObjectPtr

UE4里还有一种UObject对象的智能指针,既不是弱指针也不是强指针,而是软指针。前面说了弱指针是拿着对象的index,在使用的时候去UObjectArray上查询UObject指针本身,而这个软指针FSoftObjectPtr,实际上就是拿着对象的路径,在使用的时候去UObjectHash上去查询UObject指针本身。

可能你有疑问,为什么有了弱引用,还需要一个软引用呢?这是因为通过弱引用持有的对象,并不能保证每次进程启动都在同样的index上,这样当我们在保存对象到文件上的时候并不能保存index,而路径就不同了,一个资源对象的路径,并不会随着每次进程启动而发生变化,这样我们只要保存着这个路径,就相当于保存了这个资源对象的引用。

这里再额外提一点,即使不是资源对象,由引擎或者业务直接在运行中创建的UObject对象,他的名字也是唯一的,这些对象比较类似于Unity的prefab,其实都是从CDO上复制出来的,他的名字和原始的资源名字或类名相同,但是会有_1,_2这样的后缀,当然这个名字也可以自己在NewObject的时候指定,UE4也提供了MakeUniqueObjectName函数生成类似UE4这种带后缀名字的辅助函数。

下面来看FSoftObjectPtr的实现

这个类本身没有任何成员变量,但是继承了一个基类,并将FSoftObjectPath作为参数传给了基类,而FSoftObjectPath在另一篇资源管理的文章里也提到了,就是保存着对象的路径。

这个基类有3个成员函数,可以看到第一个成员函数就是一个WeakPtr,说明这个软引用,本身内部是持有着对象的弱引用的,而第三个参数就是通过模板传入的FSoftObjectPath,也就是资源的路径。第二个参数是用来校验的,可以先不管他。可以看是怎样获取到真正的对象的:

这里可以看到会先从WeakPtr上面取,取不到就会去用路径ResolveObject,资源管理那篇有说过这里最终就是通过路径的hash到UObjectHash上面查询对象。当拿到之后就会保存到WeakPtr上。当然如果对象还没加载,还可以根据需要同步加载,这个类也提供了接口。

如果需要其他方式加载,比如异步加载,还可以转换成FSoftObjectPath进行操作。本身没有什么特别的地方,就是获取第三个成员变量。

对于其他几个智能指针TSoftObjectPtr,TSoftClassPtr其实也就是FSoftObjectPtr的针对模板子类或UClass的特殊版本。

FLazyObjectPtr

还有一个智能指针,和FSoftObjectPtr差不多,但不常用,只有在开发编辑器时才会用到。都是继承自TPersistentObjectPtr的,唯一区别就是模板参数换成了FUniqueObjectGuid,也就是说不是通过路径来获取Object了,而是使用GUID。既然不是路径了,但是获取对象的方式都是ResolveObject,那么肯定不是从UObjectHash上获取了,和FSoftObjectPtr的唯一区别也就在这里。

可以看到,这个ResolveObject实际上是用Guid从GuidAnnotation上面去找的对象,然后这个GuidAnnotation实际是给编辑器用的一个临时存对象的地方,这里就不详细说了。

本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 1 创建
  • 2 销毁
  • UObjectArray
  • FWeakObjectPtr,TWeakObjectPtr
  • TStrongObjectPtr
  • UObjectHash
  • FSoftObjectPtr
  • FLazyObjectPtr
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档