FST源码分析
我们以下面方法为入口查看FST的创建过程,这里in数组为写入的数据,out数组为对应的输出,通过builder协助构造FST
@Test
public void testFST() throws IOException {
String in[] = {"cat", "deep", "do", "dog", "dogs"};
long out[] = {5, 7, 17, 18, 21};
PositiveIntOutputs singleton = PositiveIntOutputs.getSingleton();
Builder<Long> longBuilder = new Builder<Long>(FST.INPUT_TYPE.BYTE1, singleton);
IntsRefBuilder intsRef = new IntsRefBuilder();
for (int i = 0; i < in.length; i++) {
String s = in[i];
BytesRef bytesRef = new BytesRef(s);
longBuilder.add(Util.toIntsRef(bytesRef, intsRef), out[i]);
}
FST<Long> fst = longBuilder.finish();
Long values = Util.get(fst, new BytesRef("cat"));
System.out.println(values);
}
由于FST的创建非常复杂,这里需要借助Builder来帮助创建,builder内容如下
public Builder(FST.INPUT_TYPE inputType, Outputs<T> outputs) {
this(inputType, 0, 0, true, true, Integer.MAX_VALUE, outputs, true, 15);
}
public Builder(FST.INPUT_TYPE inputType, int minSuffixCount1, int minSuffixCount2, boolean doShareSuffix,
boolean doShareNonSingletonNodes, int shareMaxTailLength, Outputs<T> outputs,
boolean allowArrayArcs, int bytesPageBits) {
//穿越某个节点的边的数量的下限,如果小于这个值,则要删除这个节点
this.minSuffixCount1 = minSuffixCount1;
//是0,在代码中不起作用
this.minSuffixCount2 = minSuffixCount2;
//当编译某一个节点的时候,如果这个节点是多个term都穿过的,是否要共享此节点,如果不共享,则直接编译入fst中,否则要放入一个去重的对象中,让其他的节点共享这个节点。
this.doShareNonSingletonNodes = doShareNonSingletonNodes;
//可以共享后缀个数
this.shareMaxTailLength = shareMaxTailLength;
//边是否启用定长存储,当某个节点发出的边非常多的时候可以使用fixedArray的格式,使用二分搜索加快查询速度,以空间换时间
this.allowArrayArcs = allowArrayArcs;
//封装的fst对象,bytesPageBits为BytesStore对象记录fst编译后的二进制内容时,使用的byte[]的大小
fst = new FST<>(inputType, outputs, bytesPageBits);
bytes = fst.bytes;
assert bytes != null;
//共享后缀的部分,用来查找共享后缀
if (doShareSuffix) {
dedupHash = new NodeHash<>(fst, bytes.getReverseReader(false));
} else {
dedupHash = null;
}
//arc无输出时候的默认输出
NO_OUTPUT = outputs.getNoOutput();
//frontier数组用于辅助构建fst,主要保存没有保存到fst的节点
@SuppressWarnings({"rawtypes","unchecked"}) final UnCompiledNode<T>[] f =
(UnCompiledNode<T>[]) new UnCompiledNode[10];
frontier = f;
for(int idx=0;idx<frontier.length;idx++) {
frontier[idx] = new UnCompiledNode<>(this, idx);
}
}
我们先看下arc的属性
public static final class Arc<T> {
public int label;
public T output;
/** To node (ord or address) */
public long target;
byte flags;
public T nextFinalOutput;
// address (into the byte[]), or ord/address if label == END_LABEL
long nextArc;
/** Where the first arc in the array starts; only valid if
* bytesPerArc != 0 */
public long posArcsStart;
/** Non-zero if this arc is part of an array, which means all
* arcs for the node are encoded with a fixed number of bytes so
* that we can random access by index. We do when there are enough
* arcs leaving one node. It wastes some bytes but gives faster
* lookups. */
public int bytesPerArc;
/** Where we are in the array; only valid if bytesPerArc != 0. */
public int arcIdx;
/** How many arcs in the array; only valid if bytesPerArc != 0. */
public int numArcs;
- label:存放输入值的字符作为key,但是为ASCII的二进制形式
- output:存放对应的输出值value
- target:节点地址
- flags:标记位,每一位都记录了arc的属性信息,通过位运算获取信息
flags记录了arc的属性,主要有几下几种:
static final int BIT_FINAL_ARC = 1 << 0;
static final int BIT_LAST_ARC = 1 << 1;
static final int BIT_TARGET_NEXT = 1 << 2;
static final int BIT_STOP_NODE = 1 << 3;
public static final int BIT_ARC_HAS_OUTPUT = 1 << 4;
static final int BIT_ARC_HAS_FINAL_OUTPUT = 1 << 5;
- BIT_FINAL_ARC:arc对应字符是不是term最后一个字符
- BIT_LAST_ARC:arc是不是当前节点最后一条边
- BIT_TARGET_NEXT:arc连接的两个节点是临近节点,及需不需要记录跳转信息
- BIT_STOP_NODE:arc的目标节点是一个终止节点
- BIT_ARC_HAS_OUTPUT:arc是否有output输出值
- BIT_ARC_HAS_FINAL_OUTPUT:arc是否有final output输出值,当output不能满足输出时需要通过final output协助完成
这里我们以cat/5、deep/7、do/17、dog/18、dogs/21为例看下FST是如何生成的
第一步先写入cat/5,节点全部为UnCompiledNode保存在frontier数组中,并且每个节点都有一条边,通过arc保存了label值和边的属性
第二步插入deep/7,FST写入的数据必须是已经排序好的,上面的cat以c开头,当写入deep时候,说明以c开头的数据已经写入完毕,可以将cat字符串中的‘t’和’a’,进行冻结存入FST。FST的写入为倒叙,首先会写入flags,这里flags为15(BIT_FINAL_ARC+BIT_LAST_ARC+BIT_TARGET_NEXT+BIT_STOP_NODE)表明arc对应字符是term最后一个字符,arc是当前节点最后一条边,arc连接的两个节点是临近节点不需要记录target,arc的目标节点是一个终止节点。然后写入字符‘t’对应的二进制,最后对写入部分进行翻转。然后将字符‘a’写入FST,其flags为6(BIT_LAST_ARC+BIT_TARGET_NEXT)表明arc是当前节点最后一条边而且arc连接的两个节点是临近节点不需要记录target
第三步插入do/17,do和deep有公共字符d可以将字符p和字符e写入FST,这里字符p的flags和上面字符t的flags相同为15,表明arc对应字符是term最后一个字符,arc是当前节点最后一条边,arc连接的两个节点是临近节点不需要记录target,arc的目标节点是一个终止节点,字符e的flags和上面的字符a的flags相同,表明arc是当前节点最后一条边而且arc连接的两个节点是临近节点
第四步插入dog/18和dogs/21,和上面的do公共前缀为do,这里无法知道以do开头的字符是否写入结束此时无数据写入FST
第五步所有数据写入完毕,调用finish方法将剩余数据写入FST,首先写入字符s,字符s的flags为31(BIT_FINAL_ARC+BIT_LAST_ARC+BIT_TARGET_NEXT+BIT_STOP_NODE+BIT_ARC_HAS_OUTPUT)表明arc对应字符是term最后一个字符,arc是当前节点最后一条边,arc连接的两个节点是临近节点不需要记录target,arc的目标节点是一个终止节点,并且arc记录了output并且值为3
将字符g写入FST,flags为23(BIT_FINAL_ARC+BIT_LAST_ARC+BIT_TARGET_NEXT+BIT_ARC_HAS_OUTPUT)表明arc对应字符是term最后一个字符,arc是当前节点最后一条边,arc连接的两个节点是临近节点不需要记录target,arc的目标节点是一个终止节点,并且arc记录了output并且值为1
然后将字符o和字符e写入FST,字符o的flags为23和上面一样,而字符e的flags为0说明上面所有属性都是没有的,字符e和前一个节点也不是临近节点所以这里记录了target值为8,8就是current数组的索引值,这里就是指向字符e的flags
最近将从root发出的所有边及字符d和c写入FST,这里d的flags为22(BIT_LAST_ARC+BIT_TARGET_NEXT+BIT_ARC_HAS_OUTPUT)表明arc是当前节点最后一条边,arc连接的两个节点是临近节点不需要记录target,并且arc记录了output并且值为7。字符c的flags为16(BIT_ARC_HAS_OUTPUT)表明arc记录了output并且值为5,记录了target值为4,指向了这里字符a的flags。下图current数组记录了FST的最终结果。
当我们需要读取FST时,比如读取cat时会先找到字符c对应的flags,该值为16说明arc记录了output值,不是指向最终节点,同时需要通过target找到下一个字符,这里target为4需要重置读取索引到4,读取字符a的flags为6,不是指向最终节点并且前一个节点为临近节点,则读取前一个字符t的flags值为15,说明字符t指向了最终节点结束,且字符和cat完全匹配,将查找过程经过的output值累加这里就一个output值及输出5。上面的图画的不够标准,这里主要为了方便理解
下面分析一下FST实现的源码,调用builder的add方法添加数据
public void add(IntsRef input, T output) throws IOException {
// De-dup NO_OUTPUT since it must be a singleton:
//无输出时候的默认值
if (output.equals(NO_OUTPUT)) {
output = NO_OUTPUT;
}
assert lastInput.length() == 0 || input.compareTo(lastInput.get()) >= 0: "inputs are added out of order lastInput=" + lastInput.get() + " vs input=" + input;
assert validOutput(output);
//System.out.println("\nadd: " + input);
//输入内容为空时候的处理,只允许作为第一个输入
if (input.length == 0) {
// empty input: only allowed as first input. we have
// to special case this because the packed FST
// format cannot represent the empty input since
// 'finalness' is stored on the incoming arc, not on
// the node
frontier[0].inputCount++;
frontier[0].isFinal = true;
fst.setEmptyOutput(output);
return;
}
// compare shared prefix length
//一、查询上次数据和本次数据共享前缀长度
int pos1 = 0;
int pos2 = input.offset;
//有个字符串结束就结束了
final int pos1Stop = Math.min(lastInput.length(), input.length);
while(true) {
//穿过节点的数量+1
frontier[pos1].inputCount++;
//System.out.println(" incr " + pos1 + " ct=" + frontier[pos1].inputCount + " n=" + frontier[pos1]);
//找到不相同的字符或已经到达其中一个字符串的尾部,也就是字符串遍历完了,就退出
if (pos1 >= pos1Stop || lastInput.intAt(pos1) != input.ints[pos2]) {
break;
}
pos1++;
pos2++;
}
//公共前缀的数量,由于第一个是root所以要加一
final int prefixLenPlus1 = pos1+1;
//二、frontier初始化时候的数组长度默认为10,如果字符串长度超过了,则需要对frontier进行扩容
if (frontier.length < input.length+1) {
final UnCompiledNode<T>[] next = ArrayUtil.grow(frontier, input.length+1);
for(int idx=frontier.length;idx<next.length;idx++) {
next[idx] = new UnCompiledNode<>(this, idx);
}
frontier = next;
}
// minimize/compile states from previous input's
// orphan'd suffix
//三、将prefixLenPlus1下标的这个节点包括后续的节点都要冷冻,也就是进入FST
freezeTail(prefixLenPlus1);
// init tail states for current input
//将input数据中非共享的部分存入frontier数组
//idx<=input.length:多添加一个,最后一个为stop节点(结束节点)
for(int idx=prefixLenPlus1;idx<=input.length;idx++) {
//添加一个边指向下一个节点
frontier[idx-1].addArc(input.ints[input.offset + idx - 1],
frontier[idx]);
//指向该节点的边个数+1
frontier[idx].inputCount++;
}
//最后一个节点,及结束节点
final UnCompiledNode<T> lastNode = frontier[input.length];
//本次添加的字符串长度和上次添加的长度相同,或者是完全相同则不需要设置结束节点,因为之前这条路径已经设置过了
if (lastInput.length() != input.length || prefixLenPlus1 != input.length + 1) {
//结束标志
lastNode.isFinal = true;
//默认没有输出
lastNode.output = NO_OUTPUT;
}
// push conflicting outputs forward, only as far as
// needed
//idx=0为root节点跳过
for(int idx=1;idx<prefixLenPlus1;idx++) {
//获取节点
final UnCompiledNode<T> node = frontier[idx];
//父节点
final UnCompiledNode<T> parentNode = frontier[idx-1];
//
final T lastOutput = parentNode.getLastOutput(input.ints[input.offset + idx - 1]);
assert validOutput(lastOutput);
//公共输出
final T commonOutputPrefix;
//现在的输出-公共输出,转移到下一个输出
final T wordSuffix;
if (lastOutput != NO_OUTPUT) {
//公共输出
commonOutputPrefix = fst.outputs.common(output, lastOutput);
assert validOutput(commonOutputPrefix);
//转移到下一个输出
wordSuffix = fst.outputs.subtract(lastOutput, commonOutputPrefix);
assert validOutput(wordSuffix);
//修改父节点的输出为公共输出
parentNode.setLastOutput(input.ints[input.offset + idx - 1], commonOutputPrefix);
//将剩余输入设置到当前节点
node.prependOutput(wordSuffix);
} else {
commonOutputPrefix = wordSuffix = NO_OUTPUT;
}
//更新output,然后继续看下一个节点
output = fst.outputs.subtract(output, commonOutputPrefix);
assert validOutput(output);
}
//两次输入的内容相同,则需要将输出合并
if (lastInput.length() == input.length && prefixLenPlus1 == 1+input.length) {
// same input more than 1 time in a row, mapping to
// multiple outputs
lastNode.output = fst.outputs.merge(lastNode.output, output);
} else {
// this new arc is private to this new input; set its
// arc output to the leftover output:
//将剩余的输出写到arc上
frontier[prefixLenPlus1-1].setLastOutput(input.ints[input.offset + prefixLenPlus1-1], output);
}
// save last input
//保存上一个输入
lastInput.copyInts(input);
//System.out.println(" count[0]=" + frontier[0].inputCount);
}
这段代码主要做了以下几件事情:
- 获取前一个写入数据和后一个写入的公共前缀,及prefixLenPlus1就表示公共前缀的长度
- 检查frontier的容量是否需要扩容
- 调用freezeTail方法将已经确定下来不再改变的后缀冻结,就是写入fst
- 构建output输出值
freezeTail方法就是将冻结的尾部写入fst,这个是构造fst的核心方法
//prefixLenPlus1及其之后的节点需要冻结,及写入fst
private void freezeTail(int prefixLenPlus1) throws IOException {
//System.out.println(" compileTail " + prefixLenPlus1);
//第一个节点是root节点,此处至少从第二个节点开始冻结,只有在最后的时候才会为0,冻结所有节点
final int downTo = Math.max(1, prefixLenPlus1);
for(int idx=lastInput.length(); idx >= downTo; idx--) {
//doPrune都是false
boolean doPrune = false;
//doCompile都是true
boolean doCompile = false;
//需要冻结的节点
final UnCompiledNode<T> node = frontier[idx];
//需要冻结的节点的父节点
final UnCompiledNode<T> parent = frontier[idx-1];
//minSuffixCount1为0
if (node.inputCount < minSuffixCount1) {
doPrune = true;
doCompile = true;
} else if (idx > prefixLenPlus1) {
// prune if parent's inputCount is less than suffixMinCount2
//minSuffixCount2也为0
if (parent.inputCount < minSuffixCount2 || (minSuffixCount2 == 1 && parent.inputCount == 1 && idx > 1)) {
// my parent, about to be compiled, doesn't make the cut, so
// I'm definitely pruned
// if minSuffixCount2 is 1, we keep only up
// until the 'distinguished edge', ie we keep only the
// 'divergent' part of the FST. if my parent, about to be
// compiled, has inputCount 1 then we are already past the
// distinguished edge. NOTE: this only works if
// the FST outputs are not "compressible" (simple
// ords ARE compressible).
doPrune = true;
} else {
// my parent, about to be compiled, does make the cut, so
// I'm definitely not pruned
doPrune = false;
}
doCompile = true;
} else {
// if pruning is disabled (count is 0) we can always
// compile current node
doCompile = minSuffixCount2 == 0;
}
//System.out.println(" label=" + ((char) lastInput.ints[lastInput.offset+idx-1]) + " idx=" + idx + " inputCount=" + frontier[idx].inputCount + " doCompile=" + doCompile + " doPrune=" + doPrune);
//minSuffixCount2为0
if (node.inputCount < minSuffixCount2 || (minSuffixCount2 == 1 && node.inputCount == 1 && idx > 1)) {
// drop all arcs
for(int arcIdx=0;arcIdx<node.numArcs;arcIdx++) {
@SuppressWarnings({"rawtypes","unchecked"}) final UnCompiledNode<T> target =
(UnCompiledNode<T>) node.arcs[arcIdx].target;
target.clear();
}
node.numArcs = 0;
}
if (doPrune) {
// this node doesn't make it -- deref it
node.clear();
parent.deleteLast(lastInput.intAt(idx-1), node);
} else {
if (minSuffixCount2 != 0) {
compileAllTargets(node, lastInput.length()-idx);
}
//要冷冻的节点的输出,也就是在他位置结束的term的finalOutput,这个必须单独拿出来,因为在已经编译到fst的节点中是没有nextFinalOutput的,所以要将这个nextFinalOutput转移到前面的arc中去
final T nextFinalOutput = node.output;
// We "fake" the node as being final if it has no
// outgoing arcs; in theory we could leave it
// as non-final (the FST can represent this), but
// FSTEnum, Util, etc., have trouble w/ non-final
// dead-end states:
final boolean isFinal = node.isFinal || node.numArcs == 0;
if (doCompile) {
// this node makes it and we now compile it. first,
// compile any targets that were previously
// undecided:
// 先对node进行编译,进入fst,返回一个fst中的节点(编译过的节点),然后再用父节点的arc指向这个新的编译过的节点(使用的父节点的arc一定是最后一个,如果不是最后一个早就进入fst了)
parent.replaceLast(lastInput.intAt(idx-1),
compileNode(node, 1+lastInput.length()-idx),
nextFinalOutput,
isFinal);
} else {
// replaceLast just to install
// nextFinalOutput/isFinal onto the arc
parent.replaceLast(lastInput.intAt(idx-1),
node,
nextFinalOutput,
isFinal);
// this node will stay in play for now, since we are
// undecided on whether to prune it. later, it
// will be either compiled or pruned, so we must
// allocate a new node:
frontier[idx] = new UnCompiledNode<>(this, idx);
}
}
}
}
将父节点指向冻结后的节点,冻结后的节点就是compileNode,该方法就是将节点转变为冻结节点
private CompiledNode compileNode(UnCompiledNode<T> nodeIn, int tailLength) throws IOException {
final long node;
//bytes编译之后存储的字节数组
long bytesPosStart = bytes.getPosition();
//共享后缀,多个路线穿过的节点要共享
if (dedupHash != null && (doShareNonSingletonNodes ||
//单个的当然更要共享
nodeIn.numArcs <= 1) &&
//尾部共享长度不能超过阈值,默认是Integer.MAX_VALUE
tailLength <= shareMaxTailLength ) {
//最后的节点无arc,直接写入fst,因为判断重复是根据arc判断的
if (nodeIn.numArcs == 0) {
//写入fst
node = fst.addNode(this, nodeIn);
//如果两个节点紧挨着,则就不需要target记录跳转
lastFrozenNode = node;。
} else {
//不是最后一个节点,对节点进行去重,共享后缀的实现
node = dedupHash.add(this, nodeIn);
}
} else {
//不去重的逻辑,此时直接进入fst
node = fst.addNode(this, nodeIn);
}
assert node != -2;
//重新获取编译后的字节数组结尾位置
long bytesPosEnd = bytes.getPosition();
//不相等说明写入了节点
if (bytesPosEnd != bytesPosStart) {
// The FST added a new node:
assert bytesPosEnd > bytesPosStart;
//节点没有被去重,最后冻结的节点更新为本节点
lastFrozenNode = node;
}
//添加进fst后可以清除arc了
nodeIn.clear();
//构造已经写入fst的节点然后返回
final CompiledNode fn = new CompiledNode();
fn.node = node;
return fn;
}
上面代码中有一个dedupHash.add(this, nodeIn)方法,改方法主要是为了实现共享后缀,dedupHash其实可以认为是一个hashmap用来去重
public long add(Builder<T> builder, Builder.UnCompiledNode<T> nodeIn) throws IOException {
//System.out.println("hash: add count=" + count + " vs " + table.size() + " mask=" + mask);
//获取hash值
final long h = hash(nodeIn);
//坐标
long pos = h & mask;
int c = 0;
while(true) {
//v=0表示该坐标无数据,也就是没有重复
final long v = table.get(pos);
if (v == 0) {
// freeze & add
//将节点添加到fst中
final long node = fst.addNode(builder, nodeIn);
//System.out.println(" now freeze node=" + node);
assert hash(node) == h : "frozenHash=" + hash(node) + " vs h=" + h;
//记录写入fst节点数
count++;
//将数据设置到该坐标
table.set(pos, node);
// Rehash at 2/3 occupancy:
//数量达到table长度的2/3,需要进行扩容
if (count > 2*table.size()/3) {
rehash();
}
return node;
} else if (nodesEqual(nodeIn, v)) {
// same node is already here
//节点重复,返回之前的节点
return v;
}
// quadratic probe
pos = (pos + (++c)) & mask;
}
}
我们知道hashmap其中最主要的方法就是hash值的计算,下面看下这里是如何计算hash值的,也就是在什么情况下发生hash冲突
private long hash(Builder.UnCompiledNode<T> node) {
//31是一个质数减少碰撞的概率提高散列(hashing)算法的性能和效率而且31可以方便地通过位运算来进行乘法
final int PRIME = 31;
//System.out.println("hash unfrozen");
long h = 0;
// TODO: maybe if number of arcs is high we can safely subsample?
//所有边都要参与计算
for(int arcIdx=0;arcIdx<node.numArcs;arcIdx++) {
final Builder.Arc<T> arc = node.arcs[arcIdx];
//System.out.println(" label=" + arc.label + " target=" + ((Builder.CompiledNode) arc.target).node + " h=" + h + " output=" + fst.outputs.outputToString(arc.output) + " isFinal?=" + arc.isFinal);
//label参与计算
h = PRIME * h + arc.label;
//目标节点
long n = ((Builder.CompiledNode) arc.target).node;
h = PRIME * h + (int) (n^(n>>32));
//输出值
h = PRIME * h + arc.output.hashCode();
h = PRIME * h + arc.nextFinalOutput.hashCode();
if (arc.isFinal) {
h += 17;
}
}
//System.out.println(" ret " + (h&Integer.MAX_VALUE));
return h & Long.MAX_VALUE;
}
nodesEqual判断两个节点是否相同,如果相同说明是重复而非hash冲突
private boolean nodesEqual(Builder.UnCompiledNode<T> node, long address) throws IOException {
fst.readFirstRealTargetArc(address, scratchArc, in);
//边数量不同
if (scratchArc.bytesPerArc != 0 && node.numArcs != scratchArc.numArcs) {
return false;
}
//所有边上的属性都相同,才说明相同
for(int arcUpto=0;arcUpto<node.numArcs;arcUpto++) {
final Builder.Arc<T> arc = node.arcs[arcUpto];
if (arc.label != scratchArc.label ||
!arc.output.equals(scratchArc.output) ||
((Builder.CompiledNode) arc.target).node != scratchArc.target ||
!arc.nextFinalOutput.equals(scratchArc.nextFinalOutput) ||
arc.isFinal != scratchArc.isFinal()) {
return false;
}
if (scratchArc.isLast()) {
//数量一致
if (arcUpto == node.numArcs-1) {
return true;
} else {
return false;
}
}
fst.readNextRealArc(scratchArc, in);
}
return false;
}
下面看看addNode方法如何将node添加到fst中
long addNode(Builder<T> builder, Builder.UnCompiledNode<T> nodeIn) throws IOException {
T NO_OUTPUT = outputs.getNoOutput();
//System.out.println("FST.addNode pos=" + bytes.getPosition() + " numArcs=" + nodeIn.numArcs);
//没有边的节点且是结束节点则统一返回-1
if (nodeIn.numArcs == 0) {
if (nodeIn.isFinal) {
return FINAL_END_NODE;
} else {
return NON_FINAL_END_NODE;
}
}
//节点写入的起始位置
final long startAddress = builder.bytes.getPosition();
//System.out.println(" startAddr=" + startAddress);
//如果节点太多则可以使用固定长度保存arc,及长度相同,因为arc上的label是已经排好序的则可以使用二分查找,以空间换时间加速查询速度
final boolean doFixedArray = shouldExpand(builder, nodeIn);
if (doFixedArray) {
//System.out.println(" fixedArray");
//记录arc占用内存的数据扩容
if (builder.reusedBytesPerArc.length < nodeIn.numArcs) {
builder.reusedBytesPerArc = new int[ArrayUtil.oversize(nodeIn.numArcs, 1)];
}
}
//arc的数量
builder.arcCount += nodeIn.numArcs;
//最后一条边
final int lastArc = nodeIn.numArcs-1;
//记录一个arc写入的开始
long lastArcStart = builder.bytes.getPosition();
//做大的arc占用的大小
int maxBytesPerArc = 0;
//遍历节点所有的arc
for(int arcIdx=0;arcIdx<nodeIn.numArcs;arcIdx++) {
//边
final Builder.Arc<T> arc = nodeIn.arcs[arcIdx];
//边指向的目的节点
final Builder.CompiledNode target = (Builder.CompiledNode) arc.target;
//开始计算flags
int flags = 0;
//System.out.println(" arc " + arcIdx + " label=" + arc.label + " -> target=" + target.node);
//如果是该节点最后一条边,则需要记录到flags中
if (arcIdx == lastArc) {
flags += BIT_LAST_ARC;
}
//上一次冻结的节点就是target节点,及两个节点是紧挨着的节点,此时不用存储target
if (builder.lastFrozenNode == target.node && !doFixedArray) {
// TODO: for better perf (but more RAM used) we
// could avoid this except when arc is "near" the
// last arc:
flags += BIT_TARGET_NEXT;
}
//指向结束节点
if (arc.isFinal) {
flags += BIT_FINAL_ARC;
//finalOutput存在
if (arc.nextFinalOutput != NO_OUTPUT) {
flags += BIT_ARC_HAS_FINAL_OUTPUT;
}
} else {
assert arc.nextFinalOutput == NO_OUTPUT;
}
//大于0说明不是最后一个节点
boolean targetHasArcs = target.node > 0;
//是最后一个节点则记录到flags中
if (!targetHasArcs) {
flags += BIT_STOP_NODE;
}
//flags记录存在output值
if (arc.output != NO_OUTPUT) {
flags += BIT_ARC_HAS_OUTPUT;
}
//保存flags
builder.bytes.writeByte((byte) flags);
//保存label
writeLabel(builder.bytes, arc.label);
// System.out.println(" write arc: label=" + (char) arc.label + " flags=" + flags + " target=" + target.node + " pos=" + bytes.getPosition() + " output=" + outputs.outputToString(arc.output));
//将输出保存
if (arc.output != NO_OUTPUT) {
outputs.write(arc.output, builder.bytes);
//System.out.println(" write output");
}
//将final输出保存
if (arc.nextFinalOutput != NO_OUTPUT) {
//System.out.println(" write final output");
outputs.writeFinalOutput(arc.nextFinalOutput, builder.bytes);
}
//targetHasArcs==ture:表示不是结束节点
//(flags & BIT_TARGET_NEXT) == 0:表示目标节点和本节点不是紧挨着
if (targetHasArcs && (flags & BIT_TARGET_NEXT) == 0) {
assert target.node > 0;
//System.out.println(" write target");
//需要记录目标节点的编号位置
builder.bytes.writeVLong(target.node);
}
// just write the arcs "like normal" on first pass,
// but record how many bytes each one took, and max
// byte size:
//使用固定长度保存arc
if (doFixedArray) {
//记录每个arc使用的内存
builder.reusedBytesPerArc[arcIdx] = (int) (builder.bytes.getPosition() - lastArcStart);
lastArcStart = builder.bytes.getPosition();
//arc占用的最大内存
maxBytesPerArc = Math.max(maxBytesPerArc, builder.reusedBytesPerArc[arcIdx]);
//System.out.println(" bytes=" + builder.reusedBytesPerArc[arcIdx]);
}
}
// TODO: try to avoid wasteful cases: disable doFixedArray in that case
/*
*
* LUCENE-4682: what is a fair heuristic here?
* It could involve some of these:
* 1. how "busy" the node is: nodeIn.inputCount relative to frontier[0].inputCount?
* 2. how much binSearch saves over scan: nodeIn.numArcs
* 3. waste: numBytes vs numBytesExpanded
*
* the one below just looks at #3
if (doFixedArray) {
// rough heuristic: make this 1.25 "waste factor" a parameter to the phd ctor????
int numBytes = lastArcStart - startAddress;
int numBytesExpanded = maxBytesPerArc * nodeIn.numArcs;
if (numBytesExpanded > numBytes*1.25) {
doFixedArray = false;
}
}
*/
//按照固定长度重写一遍
if (doFixedArray) {
final int MAX_HEADER_SIZE = 11; // header(byte) + numArcs(vint) + numBytes(vint)
assert maxBytesPerArc > 0;
// 2nd pass just "expands" all arcs to take up a fixed
// byte size
//System.out.println("write int @pos=" + (fixedArrayStart-4) + " numArcs=" + nodeIn.numArcs);
// create the header
// TODO: clean this up: or just rewind+reuse and deal with it
byte header[] = new byte[MAX_HEADER_SIZE];
ByteArrayDataOutput bad = new ByteArrayDataOutput(header);
// write a "false" first arc:
//写入header标记
bad.writeByte(ARCS_AS_FIXED_ARRAY);
bad.writeVInt(nodeIn.numArcs);
bad.writeVInt(maxBytesPerArc);
int headerLen = bad.getPosition();
//写完header之后的位置
final long fixedArrayStart = startAddress + headerLen;
// expand the arcs in place, backwards
//写入之前的起始位置
long srcPos = builder.bytes.getPosition();
// 写完所有的arc之后的position
long destPos = fixedArrayStart + nodeIn.numArcs*maxBytesPerArc;
assert destPos >= srcPos;
if (destPos > srcPos) {
//空出位置
builder.bytes.skipBytes((int) (destPos - srcPos));
//从后往前写入,将之前写入的arc移动到新位置,这里没有重新创建一个byte进行copy,而是在原来的byte进行移动,srcPos代表原来arc的起始位置,destpos是新的需要写入数据的起始位置
for(int arcIdx=nodeIn.numArcs-1;arcIdx>=0;arcIdx--) {
//arc将要写入的开始位置
destPos -= maxBytesPerArc;
//之前写的这个arc的开始位置,也就是将从srcPos位置开始的数据copy到这里以destPos起始的位置
srcPos -= builder.reusedBytesPerArc[arcIdx];
//System.out.println(" repack arcIdx=" + arcIdx + " srcPos=" + srcPos + " destPos=" + destPos);
//相等说明左右两边移动到的位置刚好相等,则不需要将数据copy
if (srcPos != destPos) {
//System.out.println(" copy len=" + builder.reusedBytesPerArc[arcIdx]);
assert destPos > srcPos: "destPos=" + destPos + " srcPos=" + srcPos + " arcIdx=" + arcIdx + " maxBytesPerArc=" + maxBytesPerArc + " reusedBytesPerArc[arcIdx]=" + builder.reusedBytesPerArc[arcIdx] + " nodeIn.numArcs=" + nodeIn.numArcs;
//拷贝数据到新位置
builder.bytes.copyBytes(srcPos, destPos, builder.reusedBytesPerArc[arcIdx]);
}
}
}
// now write the header
//将header写入
builder.bytes.writeBytes(startAddress, header, 0, headerLen);
}
//节点写入结束的位置
final long thisNodeAddress = builder.bytes.getPosition()-1;
//fst存储节点是倒叙存储,也就是最前写入的数据保存到最后,在读取的时候方便读取,刚刚添加的这个node的所有的arc都是正序的,所以把这个node的所有的arc都倒叙。
builder.bytes.reverse(startAddress, thisNodeAddress);
//计数+1
builder.nodeCount++;
//返回结束位置,及倒叙读取的第一个arc
return thisNodeAddress;
}
后面构造OUT输出值,根据需要调整node的输出值。上面调用结束后还有部分数据没有写入FST,最后需要调用finish方法将所有内容写入。
public FST<T> finish() throws IOException {
//根节点
final UnCompiledNode<T> root = frontier[0];
// minimize nodes in the last word's suffix
//冻结所有,及将所有数据写入FST
freezeTail(0);
if (root.inputCount < minSuffixCount1 || root.inputCount < minSuffixCount2 || root.numArcs == 0) {
if (fst.emptyOutput == null) {
return null;
} else if (minSuffixCount1 > 0 || minSuffixCount2 > 0) {
// empty string got pruned
return null;
}
} else {
if (minSuffixCount2 != 0) {
compileAllTargets(root, lastInput.length());
}
}
//if (DEBUG) System.out.println(" builder.finish root.isFinal=" + root.isFinal + " root.output=" + root.output);
//将root写入FST
fst.finish(compileNode(root, lastInput.length()).node);
return fst;
}
freezeTail(0)方法传入的是0,但是内部会转变为1,final int downTo = Math.max(1, prefixLenPlus1),目的就是将frontier数组中的未编译的数据写入FST中。该方法结束后则所有term都写带了FST中,然后调用FST的finish方法对root节点进行编译,因为这里root发出的arc还没有编译
void finish(long newStartNode) throws IOException {
assert newStartNode <= bytes.getPosition();
if (startNode != -1) {
throw new IllegalStateException("already finished");
}
if (newStartNode == FINAL_END_NODE && emptyOutput != null) {
newStartNode = 0;
}
//节点在bytes中的开始位置
startNode = newStartNode;
//将current数据copy进blocks中
bytes.finish();
//缓存从root发出的边
cacheRootArcs();
}
首先设置节点位置,然后判断current数组不为空则将数据copy到blocks中
//copy数据,存入blocks中
public void finish() {
if (current != null) {
byte[] lastBuffer = new byte[nextWrite];
System.arraycopy(current, 0, lastBuffer, 0, nextWrite);
blocks.set(blocks.size()-1, lastBuffer);
current = null;
}
}
然后这里为了提高效率和后续使用将缓存从root节点发出的所有边,因为在查询的时候从root发出的边每次都会被查找。
缓存root发出的边
private void cacheRootArcs() throws IOException {
// We should only be called once per FST:
assert cachedArcsBytesUsed == 0;
//虚拟创建一个指向root的边
final Arc<T> arc = new Arc<>();
//填充上面虚拟指向root的边的数据
getFirstArc(arc);
//这个边有目标节点
if (targetHasArcs(arc)) {
//用来读取bytes
final BytesReader in = getBytesReader();
//用来存储查询到的边,大小为128
Arc<T>[] arcs = (Arc<T>[]) new Arc[0x80];
//读取真正第一个边
readFirstRealTargetArc(arc.target, arc, in);
//缓存个数
int count = 0;
while(true) {
assert arc.label != END_LABEL;
//label小于128才会进行缓存
if (arc.label < arcs.length) {
arcs[arc.label] = new Arc<T>().copyFrom(arc);
} else {
break;
}
//节点最后一个边
if (arc.isLast()) {
break;
}
readNextRealArc(arc, in);
count++;
}
//缓存占用内存大小
int cacheRAM = (int) ramBytesUsed(arcs);
// Don't cache if there are only a few arcs or if the cache would use > 20% RAM of the FST itself:
//当边的数量最少为5一条和缓存占用内存小于20%才会进行缓存
if (count >= FIXED_ARRAY_NUM_ARCS_SHALLOW && cacheRAM < ramBytesUsed()/5) {
cachedRootArcs = arcs;
cachedArcsBytesUsed = cacheRAM;
}
}
}
这里会先创建一个虚拟的arc边,然后填充数据,判断这个虚拟的从root发出的边是不是有目标节点,如果没有则什么也不做,如果有目标节点则获取真实的从root发出的边并判断label值是否小于128,只有小于128才会进行缓存,最后再判断从root发出的边的个数是否超过阈值如果小于5则不进行缓存,边的数量太少没有缓存的价值,同时需要判断缓存需要使用的内存占用不能超过内存使用的20%,否则也不会进行缓存
获取一个真实从root发出的边
public Arc<T> readFirstRealTargetArc(long node, Arc<T> arc, final BytesReader in) throws IOException {
//节点位置
final long address = node;
//定位到需要读取的节点位置
in.setPosition(address);
//System.out.println(" readFirstRealTargtArc address="
//+ address);
//System.out.println(" flags=" + arc.flags);
//如果节点是固定长度存储则先读取header信息,然后在读取边
if (in.readByte() == ARCS_AS_FIXED_ARRAY) {
//System.out.println(" fixedArray");
// this is first arc in a fixed-array
// 这个节点发出的arc的数量
arc.numArcs = in.readVInt();
//占用字节数
arc.bytesPerArc = in.readVInt();
arc.arcIdx = -1;
arc.nextArc = arc.posArcsStart = in.getPosition();
//System.out.println(" bytesPer=" + arc.bytesPerArc + " numArcs=" + arc.numArcs + " arcsStart=" + pos);
} else {
//不是固定长度存储则直接获取arc的开始位置
//arc.flags = b;
arc.nextArc = address;
arc.bytesPerArc = 0;
}
//定位到第一个arc的位置,这里开始读取
return readNextRealArc(arc, in);
}
public Arc<T> readNextRealArc(Arc<T> arc, final BytesReader in) throws IOException {
// TODO: can't assert this because we call from readFirstArc
// assert !flag(arc.flags, BIT_LAST_ARC);
// this is a continuing arc in a fixed array
//固定长度形式
if (arc.bytesPerArc != 0) {
// arcs are at fixed entries
arc.arcIdx++;
assert arc.arcIdx < arc.numArcs;
//定位读取位置
in.setPosition(arc.posArcsStart);
//回退指定的长度然后开始读取
in.skipBytes(arc.arcIdx*arc.bytesPerArc);
} else {
//非定长存储,直接设置读取位置
// arcs are packed
in.setPosition(arc.nextArc);
}
//先读取flags和label
arc.flags = in.readByte();
arc.label = readLabel(in);
//根据flag判断是否有OUT输出
if (arc.flag(BIT_ARC_HAS_OUTPUT)) {
arc.output = outputs.read(in);
} else {
arc.output = outputs.getNoOutput();
}
//根据flag判断是否有FINAL_OUT输出
if (arc.flag(BIT_ARC_HAS_FINAL_OUTPUT)) {
arc.nextFinalOutput = outputs.readFinalOutput(in);
} else {
arc.nextFinalOutput = outputs.getNoOutput();
}
//指向结束节点,说明路线是一个完整路线,不需要读取下个节点了
if (arc.flag(BIT_STOP_NODE)) {
if (arc.flag(BIT_FINAL_ARC)) {
arc.target = FINAL_END_NODE;
} else {
arc.target = NON_FINAL_END_NODE;
}
arc.nextArc = in.getPosition();
//前一个节点和后一个节点是临近节点,不需要跳转
} else if (arc.flag(BIT_TARGET_NEXT)) {
//获取下一条边的读取位置
arc.nextArc = in.getPosition();
// TODO: would be nice to make this lazy -- maybe
// caller doesn't need the target and is scanning arcs...
//最后一条边
if (!arc.flag(BIT_LAST_ARC)) {
if (arc.bytesPerArc == 0) {
// must scan
//跳转下一个节点
seekToNextNode(in);
} else {
//固定长度
in.setPosition(arc.posArcsStart);
in.skipBytes(arc.bytesPerArc * arc.numArcs);
}
}
arc.target = in.getPosition();
} else {
//设置目的连接节点
arc.target = readUnpackedNodeTarget(in);
//下一条边
arc.nextArc = in.getPosition();
}
return arc;
}
这样就完成了边的缓存。
经过这些步骤就完成了所有的term的添加形成了一个FST,数据最终存储在BytesStore中,不过FST在存储的时候是倒叙的,就是先冻结的节点和边会存储到前面,而最后由root发出的边才会进行存储,如果需要读取FST数据,则必须要从后向前读取
最后我们看一下BytesStore的主要属性和方法
class BytesStore extends DataOutput implements Accountable {
//内存使用
private static final long BASE_RAM_BYTES_USED =
RamUsageEstimator.shallowSizeOfInstance(BytesStore.class)
+ RamUsageEstimator.shallowSizeOfInstance(ArrayList.class);
//数据会先写入current数组然后达到blockSize后写入blocks
private final List<byte[]> blocks = new ArrayList<>();
private final int blockSize;
//默认为15
private final int blockBits;
private final int blockMask;
private byte[] current;
private int nextWrite;
public BytesStore(int blockBits) {
this.blockBits = blockBits;
blockSize = 1 << blockBits;
blockMask = blockSize-1;
nextWrite = blockSize;
}
//将索引位置数据覆盖为目标值
public void writeByte(int dest, byte b) {}
//追加写入
public void writeByte(byte b) {}
//将数组数据从offet开始len长的数据写入current数组
public void writeBytes(byte[] b, int offset, int len) {}
//覆盖写入一个int
public void writeInt(long pos, int value) {}
//将指定区间内容的数据翻转
public void reverse(long srcPos, long destPos) {}
//挪动指针,跳过len长度,创造空间
public void skipBytes(int len) {}
//返回指针位置
public long getPosition() {}
//截断数据
public void truncate(long newLen) {}
//
public void finish() {}
//获取一个从前往后读取器
public FST.BytesReader getForwardReader() {}
//创建一个从后往前读取器
FST.BytesReader getReverseReader(boolean allowSingle) {}
//内存占用大小
public long ramBytesUsed() {}
}
总结一下:到这里我们分析完了FST的创建过程,本篇文章没有介绍FST的概念,只是阅读一下lucene的FST实现,FST可以实现key-value的映射关系,对内存使用非常友好,查询性能对应HashMap来说稍有下降,但同等数量的key-value数据来说HashMap使用内存比FST要大很多,当然如果内存对于用户来说是足够的,使用HashMap肯定比FST的查询速度更快,也是一个优化方向。lucene在构造term索引时就用到了FST,目的就是在有限的内存情况下缓存更多的term索引,term索引并不是全局存储在一个FST中,而是针对不同的段下不同的字段下构建多个层级的FST结构,目的就是为了加快查询索引字典速度。