首发于UE4随笔
UE4 FName原理分析

UE4 FName原理分析

简介

UE4中使用字符串类型时,常用的为FString和FName,FString更贴近C++中的string,而FName比较特别,它不直接存储字符串数据, 而是通过索引值关联到真正字符串数据。FName使用一个轻型字符串系统,在此系统中,特定字符串会被重复使用,数据表也只存储一次。FName不可变,也无法被操作。FNames的存储系统和静态特性决定了通过键进行FNames的查找和访问速度较快。因此UObject的Name,函数、属性的名称,都使用FName存储,它们不会被频繁改变,用FName既省空间,查起来也快。FName子系统的另一个功能是使用散列表提供strong到FName的快速转换。

有一点需要注意,FName是大小写不敏感的。

相关数据结构

FName

FName的实体,Editor中占用12字节,打包版本中占用8字节

FName有一个特点,就是形如xxx_12的字符串,会被分成string part和number part,string part只包含xxx,number part只包含12。可能是UE内部通常会使用这种形式的字符串作为Object的Name,但又不想在NamePool中对它们都创建一份存储吧。

属性:

FNameEntryId ComparisonIndex Name array中的下标,用于比较

FNameEntryId DisplayIndex Name array中的下标,用于显示

Number string/number对的number部分

FNameEntryId

用作一个Name在NamePool中的id

属性:

Uint32 Value 由Blockindex和Block内offset组成,关于Block会在下面介绍

FNamePool

FName的字符串池

属性:

FNamePoolShard<ENameCase::IgnoreCase> ComparisonShards[FNamePoolShards] Shard的哈希表

FNamePoolShard

FNamePool中的槽位,管理了一系列slot和真正存储字符串的内存块

属性:

FRWLock Lock 锁

FNameSlot* Slots slot哈希表,用于存储字符串索引,并快速判断字符串是不是已经在NamePool存在

FNameEntryAllocator* Entries 内存分配管理器,用于管理存储字符串的Block,包括添加字符串到Block,扩容Block等功能

FNameEntry

对字符串的包装,存储在Block中

属性:

FNameEntryHeader Header 字符串的描述信息,包括是否是宽字符串,长度等

union

{

ANSICHAR AnsiName[NAME_SIZE];

WIDECHAR WideName[NAME_SIZE];

};

字符数组,虽然声明了NAME_SIZE长度的数组,但并不会直接创建FNameEntry实例,而是用FNameEntry去解释一块内存,因此字符数组是变长的。

总体结构图:

创建FName

我们可以使用纯C字符串来创建一个FName,支持ANSI字符串和宽字符串。

比如我们可以这么创建:FName TestName = Fname(TEXT("TestTest"))

将会使用构造函数

FindType默认使用Fname_Add,即先查找,没有就添加Name

函数内容为

MakeUnconvertedView不复杂,会生成一个struct,包含字符串指针和length。

MakeDetectNumber根据View和FindType生成FName

有些字符串会形如xxx_1,xxx_2,ParseNumber会把字符串后面的数字提取出来,原字符串就变成了xxx,后面的数字会存储在InternaleNumber中。

MakeWithNumber最终会调用Make方法,主要内容如下:

这里终于出现FName所使用的字符串池FNamePool了,看下它的Store方法

先不管WITH_CASE_PRESERVING_NAME宏内代码,只关注中间几行关键代码。

首先,需要根据Name创建FNameComparisonValue,这是关键的一步,涉及字符串的哈希计算。哈希计算有两种方式,一种是大小写敏感,直接根据字符串计算,一种是无视大小写,把字符串都转成小写后计算

因为IgnoreCase只是把字符都转成小写,因此直接看CaseSensitive就好

FNameHash首先会根据字符串生成64位的哈希,然后把高32位和ShardMask的与结果作为ShardIndex,ShardMask默认是2^10-1,ShardIndex会作为NamePool中ComparisonShards的下标。然后把UnmaskedSlotIndex设置为低32位。

之后把ShardIndex作为哈希值,在ComparisonShards中初步找到对应的Shard,根据UnmaskedSlotIndex在Shard中找到对应slot,找到空闲slot或者存储内容和当前字符串相同的slot,都返回。

找对应slot也用了开散列表哈希的方式,如果有哈希碰撞,就顺序往下查询slot。以下为查找slot的代码:

在找到一个空闲slot后,就要把字符串存入了。slot本身并不存储字符串,只存储索引,Shard通过NameEntryAllocator管理Block数组,通过Block真正存储字符串数据。一个Block是一段连续内存,Editor下是128KB,字符串按照顺序从前往后存储在Block中。字符串并不是直接紧密存储在Block中的,而是通过FNameEntry包装一下,真正存储在Block中的是FNameEntry,它有额外的header属性,用于描述字符串的基本信息,包括是否是宽字符串,字符串长度等。

完成插入后,就可以为字符串生成一个NameEntryId了,可以作为字符串的唯一标识,它是一个int32类型整数,由Block下标和字符串在Block中偏移组成。FName的ComparisonId属性、字符串对应的slot都会存储这个NameEntryId。

流程图例:

从FName获取字符串

在了解FName的创建流程后,就不难理解如何从FName获取到原字符串了。既然FName的ComparisonId包含了Block和offset信息,那只要从中还原出Block和Offset,就可以读取到原字符串了。

FName判断相等

之前介绍过,FName会把形如xxx_12的字符串分成string part和number part,判断相等可以指定是否比较number part,默认比较。比较时,只需判断两个FName的ComparisonId和number是否相等即可,效率很高。因此Map的key通常都使用了FName。

关于锁

FName可以被多线程使用,自然需要锁。锁的粒度不算大,由FNamePoolShard管理。这个锁是读写锁,查找时用读锁,插入时采用写锁,比单纯的互斥锁效率高很多,因为FName的读场景较多。

发布于 05-31

文章被以下专栏收录