HashMap的結構圖示
jdk1.7的HashMap采用數組+單鏈表實現,盡管定義了hash函數來避免沖突,但因為數組長度有限,還是會出現兩個不同的Key經過計算后在數組中的位置一樣,1.7版本中采用了鏈表來解決。
從上面的簡易示圖中也能發現,如果位於鏈表中的結點過多,那么很顯然通過key值依次查找效率太低,所以在1.8中對其進行了改良,采用數組+鏈表+紅黑樹來實現,當鏈表長度超過閾值8時,將鏈表轉換為紅黑樹.具體細節參考我上一篇總結的 深入理解jdk8中的HashMap
從上面圖中也知道實際上每個元素都是Entry類型,所以下面再來看看Entry中有哪些屬性(在1.8中Entry改名為Node,同樣實現了Map.Entry)。
//hash標中的結點Node,實現了Map.Entry
static class Entry<K,V> implements Map.Entry<K,V> {
final K key;
V value;
Entry<K,V> next;
int hash;
//Entry構造器,需要key的hash,key,value和next指向的結點
Entry(int h, K k, V v, Entry<K,V> n) {
value = v;
next = n;
key = k;
hash = h;
}
public final K getKey() { return key; }
public final V getValue() { return value; }
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
//equals方法
public final boolean equals(Object o) {
if (!(o instanceof Map.Entry))
return false;
Map.Entry e = (Map.Entry)o;
Object k1 = getKey();
Object k2 = e.getKey();
if (k1 == k2 || (k1 != null && k1.equals(k2))) {
Object v1 = getValue();
Object v2 = e.getValue();
if (v1 == v2 || (v1 != null && v1.equals(v2)))
return true;
}
return false;
}
//重寫Object的hashCode
public final int hashCode() {
return Objects.hashCode(getKey()) ^ Objects.hashCode(getValue());
}
public final String toString() {
return getKey() + "=" + getValue();
}
//調用put(k,v)方法時候,如果key相同即Entry數組中的值會被覆蓋,就會調用此方法。
void recordAccess(HashMap<K,V> m) {
}
//只要從表中刪除entry,就會調用此方法
void recordRemoval(HashMap<K,V> m) {
}
}
HashMap中的成員變量以及含義
//默認初始化容量初始化=16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
//最大容量 = 1 << 30
static final int MAXIMUM_CAPACITY = 1 << 30;
//默認加載因子.一般HashMap的擴容的臨界點是當前HashMap的大小 > DEFAULT_LOAD_FACTOR *
//DEFAULT_INITIAL_CAPACITY = 0.75F * 16
static final float DEFAULT_LOAD_FACTOR = 0.75f;
//默認是空的table數組
static final Entry<?,?>[] EMPTY_TABLE = {};
//table[]默認也是上面給的EMPTY_TABLE空數組,所以在使用put的時候必須resize長度為2的冪次方值
transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
//map中的實際元素個數 != table.length
transient int size;
//擴容閾值,當size大於等於其值,會執行resize操作
//一般情況下threshold=capacity*loadFactor
int threshold;
//hashTable的加載因子
final float loadFactor;
/**
* The number of times this HashMap has been structurally modified
* Structural modifications are those that change the number of mappings in
* the HashMap or otherwise modify its internal structure (e.g.,
* rehash). This field is used to make iterators on Collection-views of
* the HashMap fail-fast. (See ConcurrentModificationException).
*/
transient int modCount;
//hashSeed用於計算key的hash值,它與key的hashCode進行按位異或運算
//hashSeed是一個與實例相關的隨機值,用於解決hash沖突
//如果為0則禁用備用哈希算法
transient int hashSeed = 0;
HashMap的構造方法
我們看看HashMap源碼中為我們提供的四個構造方法。
//(1)無參構造器:
//構造一個空的table,其中初始化容量為DEFAULT_INITIAL_CAPACITY=16。加載因子為DEFAULT_LOAD_FACTOR=0.75F
public HashMap() {
this(DEFAULT_INITIAL_CAPACITY, DEFAULT_LOAD_FACTOR);
}
//(2)指定初始化容量的構造器
//構造一個空的table,其中初始化容量為傳入的參數initialCapacity。加載因子為DEFAULT_LOAD_FACTOR=0.75F
public HashMap(int initialCapacity) {
this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
//(3)指定初始化容量和加載因子的構造器
//構造一個空的table,初始化容量為傳入參數initialCapacity,加載因子為loadFactor
public HashMap(int initialCapacity, float loadFactor) {
//對傳入初始化參數進行合法性檢驗,<0就拋出異常
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
//如果initialCapacity大於最大容量,那么容量=MAXIMUM_CAPACITY
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
//對傳入加載因子參數進行合法檢驗,
if (loadFactor <= 0 || Float.isNaN(loadFactor))
//<0或者不是Float類型的數值,拋出異常
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
//兩個參數檢驗完了,就給本map實例的屬性賦值
this.loadFactor = loadFactor;
threshold = initialCapacity;
//init是一個空的方法,模板方法,如果有子類需要擴展可以自行實現
init();
}
從上面的這3個構造方法中我們可以發現雖然指定了初始化容量大小,但此時的table還是空,是一個空數組,且擴容閾值threshold為給定的容量或者默認容量(前兩個構造方法實際上都是通過調用第三個來完成的)。在其put操作前,會創建數組(跟jdk8中使用無參構造時候類似).
//(4)參數為一個map映射集合
//構造一個新的map映射,使用默認加載因子,容量為參數map大小除以默認負載因子+1與默認容量的最大值
public HashMap(Map<? extends K, ? extends V> m) {
//容量:map.size()/0.75+1 和 16兩者中更大的一個
this(Math.max(
(int) (m.size() / DEFAULT_LOAD_FACTOR) + 1,
DEFAULT_INITIAL_CAPACITY),
DEFAULT_LOAD_FACTOR);
inflateTable(threshold);
//把傳入的map里的所有元素放入當前已構造的HashMap中
putAllForCreate(m);
}
這個構造方法便是在put操作前調用inflateTable方法,這個方法具體的作用就是創建一個新的table用以后面使用putAllForCreate裝入傳入的map中的元素,這個方法我們來看下,注意剛也提到了此時的threshold擴容閾值是初始容量。下面對其中的一些方法進行說明
(1)inflateTable方法說明
這個方法比較重要,在第四種構造器中調用了這個方法。而如果創建集合對象的時候使用的是前三種構造器的話會在調用put方法的時候調用該方法對table進行初始化
private void inflateTable(int toSize) {
//返回不小於number的最小的2的冪數,最大為MAXIMUM_CAPACITY,類比jdk8的實現中的tabSizeFor的作用
int capacity = roundUpToPowerOf2(toSize);
//擴容閾值為:(容量*加載因子)和(最大容量+1)中較小的一個
threshold = (int) Math.min(capacity * loadFactor, MAXIMUM_CAPACITY + 1);
//創建table數組
table = new Entry[capacity];
initHashSeedAsNeeded(capacity);
}
(2)roundUpToPowerOf方法說明
private static int roundUpToPowerOf2(int number) {
//number >= 0,不能為負數,
//(1)number >= 最大容量:就返回最大容量
//(2)0 =< number <= 1:返回1
//(3)1 < number < 最大容量:
return number >= MAXIMUM_CAPACITY
? MAXIMUM_CAPACITY
: (number > 1) ? Integer.highestOneBit((number - 1) << 1) : 1;
}
//該方法和jdk8中的tabSizeFor實現基本差不多
public static int highestOneBit(int i) {
//因為傳入的i>0,所以i的高位還是0,這樣使用>>運算符就相當於>>>了,高位0。
//還是舉個例子,假設i=5=0101
i |= (i >> 1); //(1)i>>1=0010;(2)i= 0101 | 0010 = 0111
i |= (i >> 2); //(1)i>>2=0011;(2)i= 0111 | 0011 = 0111
i |= (i >> 4); //(1)i>>4=0000;(2)i= 0111 | 0000 = 0111
i |= (i >> 8); //(1)i>>8=0000;(2)i= 0111 | 0000 = 0111
i |= (i >> 16); //(1)i>>16=0000;(2)i= 0111 | 0000 = 0111
return i - (i >>> 1); //(1)0111>>>1=0011(2)0111-0011=0100=4
//所以這里返回4。
//而在上面的roundUpToPowerOf2方法中,最后會將highestOneBit的返回值進行 << 1 操作,即最后的結果為4<<1=8.就是返回大於number的最小2次冪
}
(3)putAllForCreate方法說明
該方法就是遍歷傳入的map集合中的元素,然后加入本map實例中。下面我們來看看該方法的實現細節
private void putAllForCreate(Map<? extends K, ? extends V> m) {
//實際上就是遍歷傳入的map,將其中的元素添加到本map實例中(putForCreate方法實現)
for (Map.Entry<? extends K, ? extends V> e : m.entrySet())
putForCreate(e.getKey(), e.getValue());
}
putForCreate方法原理實現
private void putForCreate(K key, V value) {
//判斷key是否為null,如果為null那么對應的hash為0,否則調用剛剛上面說到的hash()方法計算hash值
int hash = null == key ? 0 : hash(key);
//根據剛剛計算得到的hash值計算在table數組中的下標
int i = indexFor(hash, table.length);
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//hash相同,key也相同,直接用舊的值替換新的值
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k)))) {
e.value = value;
return;
}
}
//這里就是:要插入的元素的key與前面的鏈表中的key都不相同,所以需要新加一個結點加入鏈表中
createEntry(hash, key, value, i);
}
(4)createEntry方法實現
void createEntry(int hash, K key, V value, int bucketIndex) {
//這里說的是,前面的鏈表中不存在相同的key,所以調用這個方法創建一個新的結點,並且結點所在的桶
//bucket的下標指定好了
Entry<K,V> e = table[bucketIndex];
/*Entry(int h, K k, V v, Entry<K,V> n) {value = v;next = n;key = k;hash = h;}*/
table[bucketIndex] = new Entry<>(hash, key, value, e);//Entry的構造器,創建一個新的結點作為頭節點(頭插法)
size++;//將當前hash表中的數量加1
}
HashMap確定元素在數組中的位置
1.7中的計算hash值的算法和1.8的實現是不一樣的,而hash值又關系到我們put新元素的位置、get查找元素、remove刪除元素的時候去通過indexFor查找下標。所以我們來看看這兩個方法
(1)hash方法
final int hash(Object k) {
int h = hashSeed;
//默認是0,不是0那么需要key是String類型才使用stringHash32這種hash方法
if (0 != h && k instanceof String) {
return sun.misc.Hashing.stringHash32((String) k);
}
//這段代碼是為了對key的hashCode進行擾動計算,防止不同hashCode的高位不同但低位相同導致的hash沖突。簡單點
//說,就是為了把高位的特征和低位的特征組合起來,降低哈希沖突的概率,也就是說,盡量做到任何一位的變化都能對
//最終得到的結果產生影響
h ^= k.hashCode();
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
我們通過下面的例子來說明對於key的hashCode進行擾動處理的重要性,我們現在想向一個map中put一個Key-Value對,Key的值為“fsmly”,不進行任何的擾動處理知識單純的經過簡單的獲取hashcode后,得到的值為“0000_0000_0011_0110_0100_0100_1001_0010”,如果當前map的中的table數組長度為16,最終得到的index結果值為10。由於15的二進制擴展到32位為“00000000000000000000000000001111”,所以,一個數字在和他進行按位與操作的時候,前28位無論是什么,計算結果都一樣(因為0和任何數做與,結果都為0,那這樣的話一個put的Entry結點就太過依賴於key的hashCode的低位值,產生沖突的概率也會大大增加)。如下圖所示
因為map的數組長度是有限的,這樣沖突概率大的方法是不適合使用的,所以需要對hashCode進行擾動處理降低沖突概率,而JDK7中對於這個處理使用了四次位運算,還是通過下面的簡單例子看一下這個過程.可以看到,剛剛不進行擾動處理的hashCode在進行處理后就沒有產生hash沖突了。
總結一下:我們會首先計算傳入的key的hash值然后通過下面的indexFor方法確定在table中的位置,具體實現就是通過一個計算出來的hash值和length-1做位運算,那么對於2^n來說,長度減一轉換成二進制之后就是低位全一(長度16,len-1=15,二進制就是1111)。上面四次擾動的這種設定的好處就是,對於得到的hashCode的每一位都會影響到我們索引位置的確定,其目的就是為了能讓數據更好的散列到不同的桶中,降低hash沖突的發生。關於Java集合中存在hash方法的更多原理和細節,請參考這篇hash()方法分析
(2)indexFor方法
static int indexFor(int h, int length) {
//還是使用hash & (n - 1)計算得到下標
return h & (length-1);
}
主要實現就是將計算的key的hash值與map中數組長度length-1進行按位與運算,得到put的Entry在table中的數組下標。具體的計算過程在上面hash方法介紹的時候也有示例,這里就不贅述了。
HashMap的put方法分析
(1)put方法
public V put(K key, V value) {
//我們知道Hash Map有四中構造器,而只有一種(參數為map的)初始化了table數組,其余三個構造器只
//是賦值了閾值和加載因子,所以使用這三種構造器創建的map對象,在調用put方法的時候table為{},
//其中沒有元素,所以需要對table進行初始化
if (table == EMPTY_TABLE) {
//調用inflateTable方法,對table進行初始化,table的長度為:
//不小於threshold的最小的2的冪數,最大為MAXIMUM_CAPACITY
inflateTable(threshold);
}
//如果key為null,表示插入一個鍵為null的K-V對,需要調用putForNullKey方法
if (key == null)
return putForNullKey(value);
//計算put傳入的key的hash值
int hash = hash(key);
//根據hash值和table的長度計算所在的下標
int i = indexFor(hash, table.length);
//從數組中下標為indexFor(hash, table.length)處開始(1.7中是用鏈表解決hash沖突的,這里就
//是遍歷鏈表),實際上就是已經定位到了下標i,這時候就需要處理可能出現hash沖突的問題
for (Entry<K,V> e = table[i]; e != null; e = e.next) {
Object k;
//hash值相同,key相同,替換該位置的oldValue為value
if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
V oldValue = e.value;
e.value = value;
//空方法,讓其子類重寫
e.recordAccess(this);
return oldValue;
}
}
//如果key不相同,即在鏈表中沒有找到相同的key,那么需要將這個結點加入table[i]這個鏈表中
//修改modCount值(后續總結文章會說到這個問題)
modCount++;
//遍歷沒有找到該key,就調用該方法添加新的結點
addEntry(hash, key, value, i);
return null;
}
(2)putForNullKey方法分析
這個方法是處理key為null的情況的,當傳入的key為null的時候,會在table[0]位置開始遍歷,遍歷的實際上是當前以table[0]為head結點的鏈表,如果找到鏈表中結點的key為null,那么就直接替換掉舊值為傳入的value。否則創建一個新的結點並且加入的位置為table[0]位置處。
//找到table數組中key為null的那個Entry對象,然后將其value進行替換
private V putForNullKey(V value) {
//從table[0]開始遍歷
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//key為null
if (e.key == null) {
//將value替換為傳遞進來的value
V oldValue = e.value;
e.value = value;
e.recordAccess(this);
return oldValue; //返回舊值
}
}
modCount++;
//若不存在,0位置桶上的鏈表中添加新結點
addEntry(0, null, value, 0);
return null;
}
(3)addEntry方法分析
addEntry方法的主要作用就是判斷當前的size是否大於閾值,然后根據結果判斷是否擴容,最終創建一個新的結點插入在鏈表的頭部(實際上就是table數組中的那個指定下標位置處)
/*
hashmap采用頭插法插入結點,為什么要頭插而不是尾插,因為后插入的數據被使用的頻次更高,而單鏈表無法隨機訪問只能從頭開始遍歷查詢,所以采用頭插.突然又想為什么不采用二維數組的形式利用線性探查法來處理沖突,數組末尾插入也是O(1),可數組其最大缺陷就是在於若不是末尾插入刪除效率很低,其次若添加的數據分布均勻那么每個桶上的數組都需要預留內存.
*/
void addEntry(int hash, K key, V value, int bucketIndex) {
//這里有兩個條件
//①size是否大於閾值
//②當前傳入的下標在table中的位置不為null
if ((size >= threshold) && (null != table[bucketIndex])) {
//如果超過閾值需要進行擴容
resize(2 * table.length);
//下面是擴容之后的操作
//計算不為null的key的hash值,為null就是0
hash = (null != key) ? hash(key) : 0;
//根據hash計算下標
bucketIndex = indexFor(hash, table.length);
}
//執行到這里表示(可能已經擴容也可能沒有擴容),創建一個新的Entry結點
createEntry(hash, key, value, bucketIndex);
}
(4)總結put方法的執行流程
- 首先判斷數組是否為空,若為空調用inflateTable進行擴容.
- 接着判斷key是否為null,若為null就調用putForNullKey方法進行put.(這里也說明HashMap允許key為null,默認插入在table中位置為0處)
- 調用hash()方法,將key進行一次哈希計算,得到的hash值和當前數組長度進行&計算得到數組中的索引
- 然后遍歷該數組索引下的鏈表,若key的hash和傳入key的hash相同且key的equals放回true,那么直接覆蓋 value
- 最后若不存在,那么在此鏈表中頭插創建新結點
HashMap的resize方法分析
(1)resize的大體流程
void resize(int newCapacity) {
//獲取map中的舊table數組暫存起來
Entry[] oldTable = table;
//獲取原table數組的長度暫存起來
int oldCapacity = oldTable.length;
//如果原table的容量已經超過了最大值,舊直接將閾值設置為最大值
if (oldCapacity == MAXIMUM_CAPACITY) {
threshold = Integer.MAX_VALUE;
return;
}
//以傳入的新的容量長度為新的哈希表的長度,創建新的數組
Entry[] newTable = new Entry[newCapacity];
//調用transfer
transfer(newTable, initHashSeedAsNeeded(newCapacity));
//table指向新的數組
table = newTable;
//更新閾值
threshold = (int)Math.min(newCapacity * loadFactor, MAXIMUM_CAPACITY + 1);
}
(2)transfer方法分析
transfer方法遍歷舊數組所有Entry,根據新的容量逐個重新計算索引頭插保存在新數組中。
void transfer(Entry[] newTable, boolean rehash) {
//新數組的長度
int newCapacity = newTable.length;
//遍歷舊數組
for (Entry<K,V> e : table) {
while(null != e) {
Entry<K,V> next = e.next;
if (rehash) {
//重新計算hash值
e.hash = null == e.key ? 0 : hash(e.key);
}
//這里根據剛剛得到的新hash重新調用indexFor方法計算下標索引
int i = indexFor(e.hash, newCapacity);
//假設當前數組中某個位置的鏈表結構為a->b->c;women
//(1)當為原鏈表中的第一個結點的時候:e.next=null;newTable[i]=e;e=e.next
//(2)當遍歷到原鏈表中的后續節點的時候:e.next=head;newTable[i]=e(這里將頭節點設置為新插入的結點,即頭插法);e=e.next
//(3)這里也是導致擴容后,鏈表順序反轉的原理(代碼就是這樣寫的,鏈表反轉,當然前提是計算的新下標還是相同的)
e.next = newTable[i];
newTable[i] = e;
e = next;
}
}
}
這個方法的主要部分就是,在重新計算hash之后對於原鏈表和新table中的鏈表結構的差異,我們通過下面這個簡單的圖理解一下,假設原table中位置為4處為一個鏈表entry1->entry2->entry3,三個結點在新數組中的下標計算還是4,那么這個流程大概如下圖所示
(3)resize擴容方法總結
- 創建一個新的數組(長度為原長度為2倍,如果已經超過最大值就設置為最大值)
- 調用transfer方法將entry從舊的table中移動到新的數組中,具體細節如上所示
- 將table指向新的table,更新閾值
HashMap的get方法分析
//get方法,其中調用的是getEntry方法沒如果不為null就返回對應entry的value
public V get(Object key) {
if (key == null)
return getForNullKey();
Entry<K,V> entry = getEntry(key);
return null == entry ? null : entry.getValue();
}
可以看到,get方法中是調用getEntry查詢到Entry對象,然后返回Entry的value的。所以下面看看getEntry方法的實現
getEntry方法
//這是getEntry的實現
final Entry<K,V> getEntry(Object key) {
//沒有元素自然返回null
if (size == 0) {
return null;
}
//通過傳入的key值調用hash方法計算哈希值
int hash = (key == null) ? 0 : hash(key);
//計算好索引之后,從對應的鏈表中遍歷查找Entry
for (Entry<K,V> e = table[indexFor(hash, table.length)];
e != null;
e = e.next) {
Object k;
//hash相同,key相同就返回
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
return e;
}
return null;
}
getForNullKey方法
//這個方法是直接查找key為null的
private V getForNullKey() {
if (size == 0) {
return null;
}
//直接從table中下標為0的位置處的鏈表(只有一個key為null的)開始查找
for (Entry<K,V> e = table[0]; e != null; e = e.next) {
//key為null,直接返回對應的value
if (e.key == null)
return e.value;
}
return null;
}
jdk7版本的實現簡單總結
(1)因為其put操作對key為null場景使用putForNullKey方法做了單獨處理,HashMap允許null作為Key
(2)在計算table的下標的時候,是根據key的hashcode值調用hash()方法之后獲取hash值與數組length-1進行&運算,length-1的二進制位全為1,這是為了能夠均勻分布,避免沖突(長度要求為2的整數冪次方)
(3)不管是get還是put以及resize,執行過程中都會對key的hashcode進行hash計算,而可變對象其hashcode很容易變化,所以HashMap建議用不可變對象(如String類型)作為Key.
(4)HashMap是線程不安全的,在多線程環境下擴容時候可能會導致環形鏈表死循環,所以若需要多線程場景下操作可以使用ConcurrentHashMap(下面我們通過圖示簡單演示一下這個情況)
(5)當發生沖突時,HashMap采用鏈地址法處理沖突
(6)HashMap初始容量定為16,簡單認為是8的話擴容閾值為6,閾值太小導致擴容頻繁;而32的話可能空間利用率低。
jdk7中並發情況下的環形鏈表問題
上面在說到resize方法的時候,我們也通過圖示實例講解了一個resize的過程,所以這里我們就不再演示單線程下面的執行流程了。我們首先記住resize方法中的幾行核心代碼
Entry<K,V> next = e.next;
//省略重新計算hash和index的兩個過程...
e.next = newTable[i];
newTable[i] = e;
e = next;
resize方法中調用的transfer方法的主要幾行代碼就是上面的這四行,下來簡單模擬一下假設兩個線程thread1和thread2執行了resize的過程.
(1)resize之前,假設table長度為2,假設現在再添加一個entry4,就需要擴容了
(2)假設現在thread1執行到了 Entry<K,V> next = e.next;這行代碼處,那么根據上面幾行代碼,我們簡單做個注釋
(3)然后由於線程調度輪到thread2執行,假設thread2執行完transfer方法(假設entry3和entry4在擴容后到了如下圖所示的位置,這里我們主要關注entry1和entry2兩個結點),那么得到的結果為
(4)此時thread1被調度繼續執行,將entry1插入到新數組中去,然后e為Entry2,輪到下次循環時next由於Thread2的操作變為了Entry1
- 先是執行 newTalbe[i] = e;在thread1執行時候,e指向的是entry1
- 然后是e = next,導致了e指向了entry2(next指向的是entry2)
- 而下一次循環的next = e.next,(即next=entry2.next=entry1這是thread2執行的結果)導致了next指向了entry1
如下圖所示
(5)thread1繼續執行,將entry2拿下來,放在newTable[1]這個桶的第一個位置,然后移動e和next
(6)e.next = newTable[1] 導致 entry1.next 指向了 entry2,也要注意,此時的entry2.next 已經指向了entry1(thread2執行的結果就是entry2->entry1,看上面的thread2執行完的示意圖), 環形鏈表就這樣出現了。