前言
前两篇文章 part1 和 part2 基本上理清了 IsSplitter()
运行缓慢的原因 —— 在函数内部使用了带 Compile
选项的正则表达式。
但是没想到在 IsSplitter()
内部使用不带 Compiled
选项的正则表达式,整个程序运行起来非常快,跟静态函数版本的运行速度不相上下。又有了如下疑问:
- 为什么使用不带
Compiled
选项实例化的Regex
速度会这么快? - 为什么把
Regex
变量从局部改成全局变量后运行速度有了极大提升?除了避免重复实例化,还有哪些提升? - 为什么
PerfView
收集到的采样数据,大部分发生在MatchCollections.Count
内部,极少发生在Regex
的构造函数内部?(使用带Compiled
选项的正则表达式的时候) Regex.IsMatch()
是如何使用缓存的?- 直接实例化的
Regex
对象会使用正则表达式引擎内部的缓存吗? - 正则表达式引擎内部根据什么缓存的?
- 什么时候会生成动态方法?生成的动态方法是在哪里调用的?
本文会继续使用 Perfview
抓取一些关键数据进行分析,有些疑问需要到 .NET
源码中寻找答案。在查看代码的过程中,发现有些逻辑单纯看源码不太容易理解,于是又调试跟踪了 .NET
中正则表达式相关源码。由于篇幅原因,本篇不会介绍如何下载 .NET
源码,如何调试 .NET
源码的方法。但是会单独写一篇简单的介绍文章 。
解惑
为什么使用不带
Compiled
选项实例化的Regex
速度会这么快?还是使用
PerfView
采集性能数据并分析,如下图:可以发现,
IsSplitter()
函数只在第一次被调用时发生了一次JIT
,后续调用耗时不到0.1ms
(图中最后一次调用耗时:4090.629-4090.597 = 0.032ms
)。使用带
Compiled
选项实例化的Regex
的IsSplitter()
函数,如下图:每次调用大概要消耗
11ms
(5616.375 - 5604.637 = 11.738 ms
)。
至于为什么不带 Compiled
选项的正则表达式在调用过程中没有多余的 JIT
,与疑问7一起到源码中找答案。
为什么把
Regex
变量从局部改成全局变量后运行速度有了极大提升?除了避免重复实例化,还有哪些提升?修改代码,把局部变量改成全局变量,编译。再次使用
PerfView
采集性能数据并分析,如下图:可以发现与使用不带
Compiled
选项的局部变量版本一样,只发生了一次JIT
。所以把局部变量改成全局变量后,除了避免了重复实例化的开销(很小),更重要的是避免了多余的JIT
操作。
为什么
PerfView
收集到的采样数据,大部分发生在MatchCollections.Count
内部,极少发生在Regex
的构造函数内部?(使用带Compiled
选项的正则表达式的时候)Regex
构造函数只被JIT
了一次,后面的调用都是在执行原生代码,执行速度非常快。而MatchCollections.Count
每次执行的时候都需要执行JIT
(每次都需要10ms
以 上),所以大部分数据在MatchCollections.Count
内部,是非常合理的。
Regex.IsMatch()
是如何使用缓存的?Regex.IsMatch()
有很多重载版本,最后都会调用下面的版本:1
2
3static bool IsMatch(String input, String pattern, RegexOptions options, TimeSpan matchTimeout) {
return new Regex(pattern, options, matchTimeout, true).IsMatch(input);
}该函数会在内部构造一个临时的
Regex
对象,并且构造函数的最后一个参数useCaChe
的值是true
,表示使用缓存。
疑问5 和 疑问6 的答案在 Regex
的构造函数中,先看看 Regex
的构造函数。
Regex 构造函数
Regex
有很多个构造函数,列举如下:
1 | public Regex(String pattern) |
注意: 以上构造函数的最后一个参数都是
false
,表示不使用缓存。
这些构造函数最后都会调用下面的私有构造函数(代码有所精简调整):
1 | private Regex(String pattern, RegexOptions options, TimeSpan matchTimeout, bool useCache) |
注意: 带
bool useCache
标记的构造函数是私有的,也就是说不能直接使用此构造函数实例化Regex
。
首先会根据 option + culture + pattern
到缓存中查找。如果没找到缓存就生成类型为 RegexCodes
的 code
(包含了字节码等信息),如果找到了缓存就使用缓存中的信息。 如果指定了 Compiled
选项(UseOptionC()
会返回 true
),并且 factory
是空(没使用缓存或者缓存中的 _factory
是空),就会执行 Compile()
函数,并把返回值保存到 factory
成员中。
至此,可以回答第 5 6
两个疑问了。
直接实例化的
Regex
对象会使用正则表达式引擎内部的缓存吗?会优先根据
option + culture + pattern
到缓存中查找,但是否更新缓存是由最后一个参数useCache
决定的,与是否指定Compiled
选项无关。
正则表达式引擎内部根据什么缓存的?
根据
option + culture + pattern
缓存。
疑问7 与由 疑问1 引申出来的 JIT
问题是一个问题。之所以会 JIT
,是因为有需要 JIT
的代码,如果不断有新的动态方法产生出来并执行,那么就需要不断地 JIT
。由于此问题涉及到的代码量比较大,逻辑比较复杂,需要深入 .NET
源码进行查看。为了更好的理解整个过程,我简单梳理了 IsSpitter()
函数中涉及到的关键类以及类之间的关系,整理成下图,供参考。
流程 & 类关系梳理
看完上图后,可以继续看剩下的 JIT
问题了。因为大多数 JIT
都出现在 MatchCollection.Count
中,可以由此切入。
MatchCollection.Count
实现代码如下:
1 | public int Count { |
Count
会调用 GetMatch()
函数,而 GetMatch()
函数会不断调用 _regex.Run()
函数。
_regex
是哪来的呢?在构造 MatchCollection
实例时传过来的。
MatchCollection
是由 Regex.Matches()
实例化的,代码如下(去掉了判空逻辑):
1 | public MatchCollection Matches(String input, int startat) { |
该函数会实例化一个 MatchCollection
对象,并把当前 Regex
实例作为第一个参数传给 MatchCollection
的构造函数。该参数会被保存到 MatchCollection
实例的 _regex
成员中。
接下来继续查看 Regex.Run
函数的实现。
Regex.Run()
具体实现代码如下(代码有精简):
1 | internal Match Run(bool quick, int prevlen, String input, int beginning, int length, int startat) { |
逻辑还是非常清晰的,先找到或者创建(通过 factory.CreateInstance()
或者直接 new
)一个类型为 RegexRunner
实例 runner
,然后调用 runner->Scan()
进行匹配。
对于使用 Compiled
选项创建的 Regex
,其 factory
成员变量会在 Regex
构造函数中赋值,对应的语句是 factory = Compile(code, roptions);
,类型是 CompiledRegexRunnerFactory
。
我们先来看看 CompiledRegexRunnerFactory.CreateInstance()
的实现。
CompiledRegexRunnerFactory.CreateInstance()
代码如下:
1 | protected internal override RegexRunner CreateInstance() { |
该函数返回的是 CompiledRegexRunner
类型的 runner
。在返回之前会先调用 runner.SetDelegates
为对应的关键函数(Go
, FindFirstChar
, InitTrackCount
)赋值。参数中的 goMethod, findFirstCharMethod, initTrackCountMethod
是在哪里赋值的呢?在 Regex.Compile()
函数中赋值的。
Regex.Compile()
Regex.Compile()
会直接转调 RegexCompiler
的静态函数 Compile()
,相关代码如下(有调整):
1 | internal static RegexRunnerFactory Compile(RegexCode code, RegexOptions options) { |
该函数直接调用了 RegexLWCGCompiler
类的 FactoryInstanceFromCode()
成员函数。相关代码如下(有删减):
1 | internal RegexRunnerFactory FactoryInstanceFromCode(RegexCode code, RegexOptions options) { |
该函数非常清晰易懂,但却是非常关键的一个函数,会生成三个动态函数(也就是通过 PerfView
采集到的 FindFirstCharXXX
,GoXXX
,InitTrackCountXXX
),最后会构造一个类型为 CompiledRegexRunnerFactory
的实例,并把生成的动态函数作为参数传递给 CompiledRegexRunnerFactory
的构造函数。
至此,已经找到生成动态函数的地方了。动态函数是什么时候被调用的呢?在 runner.Scan()
函数中被调用的。
RegexRunner.Scan()
关键代码如下(做了大量删减):
1 | Match Scan(Regex regex, String text, int textbeg, int textend, int textstart, int prevlen, bool quick, TimeSpan timeout) { |
可以看到,Scan()
函数内部会调用 FindFirstChar()
和 Go()
,而且只有当 FindFirstChar()
返回 true
的时候,才会调用 Go()
。这两个函数是虚函数,具体的子类会重写。对于 Compiled
类型的正则表达式,对应的 runner
类型是 CompiledRegexRunner
。这三个关键的函数实现如下:
1 | internal sealed class CompiledRegexRunner : RegexRunner { |
现在可以回答疑问7 及疑问1 引申出来的 JIT
问题了。
什么时候会生成动态方法?生成的动态方法是在哪里调用的?
在指定了
Compiled
标志的Regex
的构造函数内部会调用RegexCompiler.Compile()
函数,Compile()
函数又会调用RegexLWCGCompiler.FactoryInstanceFromCode()
,FactoryInstanceFromCode()
函数内部会分别调用GenerateFindFirstChar()
,GenerateGo()
,GenerateInitTrackCount()
生成对应的动态方法。
在执行 MatchCollection.Count
的时候,会调用 MatchCollection.GetMatch()
函数,GetMatch()
函数会调用对应 RegexRunner
的 Scan()
函数。Scan()
函数会调用 RegexRunner.FindFirstChar()
,而 CompiledRegexRunner
类型中的 FindFirstChar()
函数调用的是设置好的动态函数。
Compiled 与 非 Compiled 对比
1. 构造函数
*带 Compiled
选项的 Regex
*
useCache
传递的是 false
,表示不使用缓存。因为指定了 RegexOptions.Compiled
选项, Regex
的构造函数内部会调用 RegexCompiler.Compile()
函数,Compile()
函数又会调用 RegexLWCGCompiler.FactoryInstanceFromCode()
,FactoryInstanceFromCode()
函数内部会分别调用 GenerateFindFirstChar()
, GenerateGo()
, GenerateInitTrackCount()
生成对应的动态方法,然后返回 CompiledRegexRunnerFactory
类型的实例。如下图:
*不带 Compiled
选项的 Regex
*
构造函数与 Compiled
的基本一致,useCache
传递的也是 false
,不使用缓存。因为 UseOptionC()
返回的是 false
,所以不会执行 Compile()
函数。所以 factory
成员变量是 null
。
这里就不贴图了。
2. matches.Count
*带 Compiled
选项的 Regex
*
MatchCollection.Count
内部会调用 GetMatch()
函数,GetMatch()
函数会调用对应 RegexRunner
的 Scan()
函数(这里的 runner
类型是 CompiledRegexRunner
)。Scan()
内部会调用 FindFirstChar()
函数,而 CompiledRegexRunner
类型的 FindFirstChar()
函数内部调用的是设置好的动态方法。
*不带 Compiled
选项的 Regex
*
与带 Compiled
版本的调用栈基本一致,不一样的是这里 runner
的类型是 RegexInterpreter
,该类型的 FindFirstChar()
函数调用的代码不是动态生成的。
3. runner 赋值
当 runner
是 null
的时候,需要根据情况获取对应的 runner
。
*带 Compiled
选项的 Regex
*
factory
成员在 Regex
构造函数里通过 Compile()
赋过值,runner
会通过下图 1306
行的 factory.CreateInstance()
赋值。
*不带 Compiled
选项的 Regex
*
factory
成员没有被赋过值,因此是空的,runner
会通过下图 1308
行的 new RegexInterpreter()
赋值。
总结
- 不要在循环内部创建编译型的正则表达式(带
Compiled
选项),会频繁导致JIT
的发生进而影响效率。 Regex.IsMatch()
也会创建 Regex 实例,但是最后一个参数bUseCache
是true
,表示使用缓存。Regex
构造函数的最后一个参数bUseCache
是true
的时候才会更新缓存。- 正则表达式引擎内部会根据
option + culture + pattern
查找缓存。