HashMapの原理とソースコード解析
- HashMap-プロファイルデザイン
- 考え
- ソースコード解析 - 変数の定義
- ソースコード解析 - コンストラクタ
- ソースコード解析 - 挿入データ
- ソースコード解析 - データ収集
- ソースコード解析 - データを削除
- ソースコード解析 - 値が含まれています
- ソースコード解析 - 空のマップ
HashMap-プロファイルデザイン
図の理解を容易にするための所定の用語を確認します。
- バケット(バケット)と呼ばれるテーブルの列、
- このようなテーブルのようなテーブルの配列要素[1]は、スロットと呼ばれます
- スロットは、スロットと呼ばれるリストに対応する単一のリンクリストデータ(スロットを含むデータ)がある場合
NOが存在する場合、図は、赤黒木上、8に等しいだけでなく、データの量を決定するテーブル64を超えたスロットに対応するリストテーブルは、ノードの数よりも大きい場合であっても、実際には、あるに不正確さを有する変換されテーブルの拡張をすることなく、赤黒木に変換されるよりも
上記のように、組成HashMapのは、次のコンポーネントから構成されています
- テーブルの列
- 単一のリスト
- 赤黒木を(特定の状況下でのフォローアップは、説明しますのみ存在します)
考え
前のソースコードを見て、問題をより深く理解した上で、ソースコードを参照してください、しかしその前に、我々は熟考するためにいくつかの質問を持っています
- HashMapの展開は再びそれを行うにはputValハッシュ・ストレージ・メソッドを呼び出したデータのそれぞれに判明していないのですか?
- なぜ単一のリスト番号8またはより大きいまたは64に等しいとテーブル赤黒木に変換するための時間の配列?
- HashMapのは、達成するために、配列+ +リスト赤黒木を使用するのはなぜ?
- 膨張はかなりサイズ=時間のtable.lengthより、膨張あるとするとき、なぜサイズ>閾値限界値が存在する必要がありますか?
- なぜ、赤黒木ノードの数が6未満リストに低下する場合?
- HashMapのであれば、拡張は、それが実装されているか、拡張の効率を改善するために再ハッシュを必要としないとき?
- HashMapのは、それを命じたのですか?
- HashMapのは、スレッドセーフですか?
質問2:アレイは、赤黒木に変換するときに64以上なぜ単独でリンクされたリスト、テーブル番号8または?
ここでは、最初に言及する必要がありますバイナリ検索ツリーをリンクリストに退化し、我々は極端なケースでは二分探索木を知っています、
これは、彼の表情は非常に効率的なO(log2n)、一つは平衡二分探索木である。図から分かるように、一方、図II我々は似ておくことができますので、もし、単一リンクリストとして分解効率のO(n)を検索この状態の図は、その後、我々はそれが二分探索木のバランスを取る、赤、黒の木が最も広く工業用に使用平衡二分探索木、につながるので、効率を維持するために見ることができます。ブラックレッドツリーのノードを追加することにより、ツリーはバランスの取れた状態のままであるように、赤黒木の原理は複雑ではありませんサブツリーや他の操作を回転ノード赤と黒の色を変更しますが、コードは比較的複雑興味がある学生は、検索に行くことができます達成することですここでは、一時的に話すことはありません、赤黒木を説明するために、別の場所で説明しました。
ここでは、膨張、換算すると8以上のスロットとテーブル64以上の配列を示しています理由テーブル自体は、実際には、小さい場合、次いで、これは、の収入のバランスによるもので、その理由は、赤黒木に変換されなければならない知っています価格は非常に低いまたは非常に高い効率です。
質問3:なぜ、HashMapのは、達成するために、配列+ +リスト赤黒木を使用するには?
(1配列インデックス(table.length 1)を計算する際、直接テーブルを計算し、その後、キーの次の訪問は、あなたが直接Oの効率化にアクセスすることができます - 配列のインデックス位置を保存するために、テーブルを使用して、インデックス位置をハッシュに基づいています(キー)& )、なぜ我々はそれのリストを増やす必要があり、これは、データを別のスロットにハッシュすることができたとしても、ハッシュアルゴリズム自体は、高効率であるハッシュアルゴリズムの問題のハッシュアルゴリズムので、小さなハッシュの競合はとても良いですそのようなハッシュの衝突のような、実際には、完全には保証できないハッシュアルゴリズムは、確かに存在し、その後、ハッシュ衝突がある場合、それはハッシュを決定するために、一枚ずつ取り出されたときに、衝突の終わりにデータを追加するために、スロットデータのために頭になり、キーが見つかった対応するデータと等しいかどうかを確認し、そしてなぜブラックツリー質問2が記載されています
質問4:この拡張は、拡張を実施する際に、なぜサイズが>閾値限界値がありますか?
なぜなら、特定の状況の容量の可能性が高い、格納されたデータの量が大きく、その後、ハッシュ衝突新たに追加されたデータがあり、膨張しきい値しきい値は拡張のためのHashMapの必要な値に記憶容量を表す時間を与えるために必要です
質問5:なぜ回数は、リンクされたリストに退化する赤黒木ノード6よりも小さいですか?
単一リンクリストノードデータが十分に小さい場合、トラバーサル時間が十分に検索速度が速い、無視できます。リストの複雑さは、HashMapの拡張で、また、リストを分割する方がよい、また、低メンテナンスと赤黒木よりも優れています
その他の問題については、我々はソースからの答えを見つけるために、だけでなく、上記のコードは動作を実現することであるかを確認する必要があります。まず、(特定のコードが説明するのだろうとき、いくつかの一般的な印象を持っているために、ここで、それは問題ではありません理解していない)で表される定義されたそのメンバ変数のいくつかの意味を見て
変数の定義
// 默认的初始容量为 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
// 最大容量为 2^30
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的装载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当一个桶中链表元素个数大于等于 8 的时候转化为红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当一个桶中链表元素个数小于等于 6 的时候将红黑树转化为链表
static final int UNTREEIFY_THRESHOLD = 6;
// 当桶的个数达到 64 的时候并且单个槽位链表结点数量大于等 8 的时候进行树化
static final int MIN_TREEIFY_CAPACITY = 64;
// 数组,也就是桶
transient Node<K,V>[] table;
// 作为 entrySet() 的缓存
transient Set<Map.Entry<K,V>> entrySet;
// 元素的数量
transient int size;
// 修改次数,用于在迭代的时候执行 fail-fast
transient int modCount;
// 当桶的使用数量达到多少时候进行扩容
int threshold;
// 装载因子
final float loadFactor;
/**
* 单链表结点
* @param <K>
* @param <V>
*/
static class Node<K,V> implements Map.Entry<K,V> {
// 存储 key 的 hash 值
final int hash;
final K key;
V value;
// 下一个结点
Node<K,V> next;
Node(int hash, K key, V value, Node<K,V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final String toString() { return key + "=" + value; }
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this)
return true;
if (o instanceof Map.Entry) {
Map.Entry<?,?> e = (Map.Entry<?,?>)o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
复制代码
コンストラクタ
// 指定容量和装载因子构建 HashMap
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
this.loadFactor = loadFactor;
this.threshold = tableSizeFor(initialCapacity);
}
// 指定容量构造 map
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
// 无参构造全部使用默认值
public HashMap() {
this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
}
// 根据 map 来创建一个 HashMap 使用默认的参数
public HashMap(Map<? extends K, ? extends V> m) {
this.loadFactor = DEFAULT_LOAD_FACTOR;
// 将传入 map 数据复制到新的 map 中
putMapEntries(m, false);
}
复制代码
上記の方法は、命令をするときの変数のみ、InitialCapacityのloadFactor容量と負荷係数、他のすべてのデフォルト値は、着信量マップに基づいて新しいHashMapを構築するための最後のコンストラクタ、のを見てみましょう指定することができるマップを作成する以外の何ものでもありませんputMapEntries(メートル、偽);どのように実現しています。
// 方法是 final 不可被覆写
final void putMapEntries(Map<? extends K, ? extends V> m, boolean evict) {
int s = m.size();
if (s > 0) {
// 如果桶数组还没有创建,则先进行创建
if (table == null) { // pre-size
// 使用传入 map 的长度 / 装载因子 + 1 作为当前的 map 容量
float ft = ((float)s / loadFactor) + 1.0F;
// 如果该值超出 map 承受的最大值,则取 map 最大值作为容量
int t = ((ft < (float)MAXIMUM_CAPACITY) ?
(int)ft : MAXIMUM_CAPACITY);
// 如果 map 当前的容量超出了扩容限定值,则进行扩容
if (t > threshold)
// 扩容并且返回新的 map 扩容限定值
threshold = tableSizeFor(t);
}
// 如果 map 的数据大小超出了扩容阀值
else if (s > threshold)
// 将数据迁移到一个新的 map 中搬移所有数据
resize();
// 遍历传入 map
for (Map.Entry<? extends K, ? extends V> e : m.entrySet()) {
K key = e.getKey();
V value = e.getValue();
// 将数据一个一个的重新放入 map 中
putVal(hash(key), key, value, false, evict);
}
}
}
复制代码
ここでは、そのセクションに入れて)、しきい値を増やすpuValを(使用)(putValの新しいHashMapのデータに変わりますしtableSizeFor(T)と呼ばれる、(ここではtableSizeForを見ます)
/**
* 返回一个大于等于且最接近 cap 的 2 的幂次方整数
* cap 无符号右移 1 位然后位或, 然后右移 2 位然后位或 3 .. 得到最终的结果
* @param cap
* @return
*/
static final int tableSizeFor(int cap) {
int n = cap - 1;
n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;
return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
复制代码
データの挿入
public V put(K key, V value) {
// onlyIfAbsent:false 表示如果存在则更新,不存在则插入
return putVal(hash(key), key, value, false, true);
}
/**
* 根据传入 key 的 hashCode 的无符号右移 16 位次方作为其 map 中的 hash 值
* @param key
* @return
*/
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
// 如果 table 为 null 先进行初始化
if ((tab = table) == null || (n = tab.length) == 0)
n = (tab = resize()).length;
// 如果 hash 后制定的槽位为 null 则直接放入数据即可
if ((p = tab[i = (n - 1) & hash]) == null)
tab[i] = newNode(hash, key, value, null);
else {
// 槽位存在数据就需要检查槽位链表是否存在对应的数据
// 如果有根据策略选择是更新还是放弃
// 如果没有这执行插入
Node<K,V> e; K k;
// 已经存在对应的 key 直接进行赋值后续根据 putIfAbsent 决定是否更新
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
e = p;
// 如果当前节点是一个树节点那么将数据放入红黑树中
else if (p instanceof TreeNode)
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
else {
// binCount 临时统计链表数量
for (int binCount = 0; ; ++binCount) {
// 如果不存在对应的 key 则直接执行插入
if ((e = p.next) == null) {
// 创建一个新结点
p.next = newNode(hash, key, value, null);
// 当链表中的数据数量大于等于 8 的时候
// 需要进行树化
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
treeifyBin(tab, hash);
break;
}
// 如果已经存在对应的结点则直接返回后续根据 onlyIfAbsent 决定是否更新
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
p = e;
}
}
// 如果存在待更新的值
if (e != null) { // existing mapping for key
V oldValue = e.value;
// 是否更新数据
if (!onlyIfAbsent || oldValue == null)
e.value = value;
afterNodeAccess(e);
return oldValue;
}
}
// 修改次数 + 1
// 该字段用于后续迭代器 fail-fast
++modCount;
// 数据量大于 threshold 进行 table 扩容
if (++size > threshold)
resize();
afterNodeInsertion(evict);
return null;
}
复制代码
いくつかの重要なポイントがあります上記のコードは、要約します
- スロットは、データを直接挿入されていない場合
- データが検出された場合、挿入スロットノードのキーは、現在のデータスロットを更新する必要がある現在のスロットではありません
- キーを更新するかどうかを選択し、対応するスロットのリストが存在するか否かを検出します
- ツリーノードが直接ツリーに挿入された場合
- リストは、各対応するキーのスロットが追加されている場合
- 8つ以上を追加するノードのリストがときを検出することができるツリーを実行することが以下に説明するtreeifyBin決意条件もあるので、これが可能であることに注意
- 最後に、もしサイズ>容量拡張のためのしきい値
ここでは、リサイズ()拡張コードを解析し続けるとtreeifyBin()コードツリー
final void treeifyBin(Node<K,V>[] tab, int hash) {
int n, index; Node<K,V> e;
// 如果数组槽位小于 64 不进行树化,而是对 table 进行扩容
if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
resize();
// 否则进行树化
else if ((e = tab[index = (n - 1) & hash]) != null) {
TreeNode<K,V> hd = null, tl = null;
do {
TreeNode<K,V> p = replacementTreeNode(e, null);
if (tl == null)
hd = p;
else {
p.prev = tl;
tl.next = p;
}
tl = p;
} while ((e = e.next) != null);
if ((tab[index] = hd) != null)
hd.treeify(tab);
}
}
复制代码
このことから、あなたがそれ以外の場合は、赤、黒の木に変換したときにするとき、単一リンクリストデータとテーブル8または以上のアレイ64意志の容量に等しい、リサイズ()展開を実行するために行くことを見ることができ、リンクリストは、単一の移行うちの膨張によって長く8以上です。
final Node<K,V>[] resize() {
// 重置之前暂记录之前数组桶的信息及相关配置信息
Node<K,V>[] oldTab = table;
int oldCap = (oldTab == null) ? 0 : oldTab.length;
int oldThr = threshold;
int newCap, newThr = 0;
// 如果之前 table 中有数据的话
if (oldCap > 0) {
// 如果超出了最大容量值,设置 threshold 最大值
if (oldCap >= MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return oldTab;
}
// 将之前的 table 大小扩大一倍作为新的数组桶的容量,当然不能超出最大值
// 前提是之前 table 大小要大于默认值,不然数据量小没有扩容的必要直接使用默认值即可
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // 如果之前 table 中没有数据,将之前 table 的 threshold 作为新 table 的容量大小
newCap = oldThr;
else { // 如果 oldCap 与 oldThr 之前都没有指定那么使用默认值创建,初始化创建 map 其实就是进入的这个分支
newCap = DEFAULT_INITIAL_CAPACITY;
// 装载因子 * 默认容量大小作为新的 threshold
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
// 如果新的 threshold == 0 使用新的容量大小重新计算
if (newThr == 0) {
float ft = (float)newCap * loadFactor;
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
// 替换掉原先的 threshold 为新的值
threshold = newThr;
// 创建一个新的数组桶准备复制迁移数据
@SuppressWarnings({"rawtypes","unchecked"})
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;
// 如果之前的 table 不为 null 开始迁移数据
if (oldTab != null) {
// 遍历之前的 table
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
// 处理不为 null 的数据
if ((e = oldTab[j]) != null) {
// 将原 table 中的数据置为 null 便于断开其可能存在的引用链利于垃圾回收
oldTab[j] = null;
// 如果只有数组桶的一个数据,也就是槽位链表没有数据,这直接放入新的 table 槽位即可
if (e.next == null)
newTab[e.hash & (newCap - 1)] = e;
// 如果节点是树节点 红黑树挡在单独章节分析 - TODO
// 如果链表结点数据小于 6 会将红黑树退化为链表
else if (e instanceof TreeNode)
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
else { // 处理 table 中槽位存在链表的情况并且不是树的情况,将原先的单个链表分化为 2 个链表
// 通过这段代码就避免了添加数据需要再次 hash puVal() 的低效率问题
Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
next = e.next;
// 低位存储在 loHead 中
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
else { // 否则放入 hiHead 链表中也就是 原索引槽位 + oldCap
if (hiTail == null)
hiHead = e;
else
hiTail.next = e;
hiTail = e;
}
} while ((e = next) != null);
// 将低位链表放置的位置与原先桶一样
if (loTail != null) {
loTail.next = null;
newTab[j] = loHead;
}
// 将高位链表反制的位置到原先的位置 + 原先的容量处
if (hiTail != null) {
hiTail.next = null;
newTab[j + oldCap] = hiHead;
}
}
}
}
}
return newTab;
}
复制代码
6未満の赤黒ツリーノードデータは、リンクリストに退化します場合は、ここでは+この条件はスロットの元の位置に配置されます、または私達はスロットインデックスを入れ満たし、このコードのリストを見てoldCap上の位置、そしてなぜ?
if ((e.hash & oldCap) == 0) {
if (loTail == null)
loHead = e;
else
loTail.next = e;
loTail = e;
}
复制代码
例えば、ここで、膨張時にキャップが膨張の2倍であるため、ハッシュ(キーA)は、この位置に14、キャップ= 16、古いリストの位置14&スロット(16--1)= 14をIF =それは、新しいキャップ= 32と同じであり、その後、リンクリストデータ移行のスロット14の前にスロットがそれを実行する必要がありますか?シンプルなアイデアがあります
- 変更は元の位置に格納することができるようにする必要がないように、得られたインデックス・スロットなら
- それ以外の場合は、移行されます
- Javaの8が最適化され、データの格納に1 - java8は、ハッシュ&長をやっている)(putValを呼び出している前に、
14&(32--1)= 14は、データを見て、移行する必要がないことを示しe.hash&oldCap = 14&= 0 16は元の位置、ここで注意すべき点は、キャップ容量は常に2 ^ N手段であることを示しています大規模なデータに&そのデータよりものみ(17、18、19 .... N)&16 = 16、及び16は、単に現在の拡張部であるので、e.hash> = 16、例えば、16れるであろうデータは、スロットの元の位置は、+ 16上に配置されます。
データを取得します
public V get(Object key) {
Node<K,V> e;
// 先计算其 hash 值然后调用 getNode
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
final Node<K,V> getNode(int hash, Object key) {
Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
// 如果有数据的话
if ((tab = table) != null && (n = tab.length) > 0 &&
(first = tab[(n - 1) & hash]) != null) {
// 如果槽位中的数据 hash 值和 key 的 hash 相等
// 并且他们的 key 相等(== 和 equals)
// 那么槽位中的数据就是目标数据直接返回即可
if (first.hash == hash && // always check first node
((k = first.key) == key || (key != null && key.equals(k))))
return first;
// 如果槽位中存在链表
if ((e = first.next) != null) {
// 如果是红黑树就去红黑树中找 -- TODO
if (first instanceof TreeNode)
return ((TreeNode<K,V>)first).getTreeNode(hash, key);
do {
// 遍历链表知道找到目标值后返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
} while ((e = e.next) != null);
}
}
return null;
}
复制代码
データを削除します。
彼らは知るように、実際にデータを削除し、非常に単純な、データを挿入し、データを追加する方法であります
public V remove(Object key) {
Node<K,V> e;
return (e = removeNode(hash(key), key, null, false, true)) == null ?
null : e.value;
}
final Node<K,V> removeNode(int hash, Object key, Object value,
boolean matchValue, boolean movable) {
Node<K,V>[] tab; Node<K,V> p; int n, index;
// 如果 table 中存在 key 对应的 hash 值
if ((tab = table) != null && (n = tab.length) > 0 &&
(p = tab[index = (n - 1) & hash]) != null) {
Node<K,V> node = null, e; K k; V v;
// 如果 key 就是对应槽位的 key 则找到数据
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
node = p;
// 去槽位链表中查找
else if ((e = p.next) != null) {
// 如果是一个树去树节点中查找
if (p instanceof TreeNode)
node = ((TreeNode<K,V>)p).getTreeNode(hash, key);
else {
do {
// 遍历槽位链表查找对应的数据
if (e.hash == hash &&
((k = e.key) == key ||
(key != null && key.equals(k)))) {
node = e;
break;
}
p = e;
} while ((e = e.next) != null);
}
}
// 如果找到了 key 对应的值
// 根据后续的判断确定是否需要删除对应的数据结点
// 默认 remove, matchValue: false 需要进行删除
if (node != null && (!matchValue || (v = node.value) == value ||
(value != null && value.equals(v)))) {
// 如果是树节点则删除树中的结点
if (node instanceof TreeNode)
((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
// 如果是 table 槽位上的值,则将其下一个结点复制到槽位上
else if (node == p)
tab[index] = node.next;
// 如果在槽位链表上删除当前节点
else
p.next = node.next;
// 修改次数 + 1 用于迭代器 fail-fast
++modCount;
// 数据长度 - 1
--size;
// 删除后要做的事情留个子类实现
afterNodeRemoval(node);
return node;
}
}
return null;
}
复制代码
クリアマップ
public void clear() {
Node<K,V>[] tab;
// 修改次数 + 1
modCount++;
// 如果 table 不为空
if ((tab = table) != null && size > 0) {
// 重置 size 属性
size = 0;
// 遍历将每个槽位数据置位 null
for (int i = 0; i < tab.length; ++i)
tab[i] = null;
}
}
复制代码
これは、値が含まれています
public boolean containsValue(Object value) {
Node<K,V>[] tab; V v;
// 如果 table 不为空
if ((tab = table) != null && size > 0) {
// 遍历 map 所有槽位
for (int i = 0; i < tab.length; ++i) {
// 遍历每个槽位链表如果找到则返回 true
for (Node<K,V> e = tab[i]; e != null; e = e.next) {
if ((v = e.value) == value ||
(value != null && value.equals(v)))
return true;
}
}
}
return false;
}
复制代码