面试别再问我HashMap了——史上最全HashMap源码解读!

前言

本文原载于我的博客,地址:https://blog.guoziyang.top/archives/56/

HashMap也算是面试常客了。

HashMap几乎是我们在Java开发中最常用的类之一,它基于Hash表实现了一个Map结构,使得我们可以根据Key对Value进行快速查找,时间复杂度接近O(1)。HashMap允许null键和null值,其中null键的hash值记为0。除此以外,HashMap是线程不安全的一个类,当多个线程同时读写一个HashMap时,可能会出现问题。

但是,你真的了解HashMap吗?

本文,我会深入HashMap的源码,对其常用方法和相关属性进行解读,进一步了解HashMap的机制。

本文基于Java 8。

类签名与成员变量

源码中HashMap类签名如下:

public class HashMap<K,V> extends AbstractMap<K,V>
    implements Map<K,V>, Cloneable, Serializable

HashMap继承了AbstractMap类,并且实现了Map、Cloneable和Serializable接口。AbstractMap类主要也是对Map接口的一个初步的实现。Cloneable接口表示HashMap实现了Object类中的clone()方法,而Serializable接口则说明这个类是可序列化的。

HashMap有几个默认属性:

static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16
static final int MAXIMUM_CAPACITY = 1 << 30;
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;

这里不对这些属性进行解读,而是在方法中使用到这些属性时再做说明。

内部存储结构

在HashMap中有两个静态内部类,分别是Node<K, V>TreeNode<K, V>,其中,Node<K, V>继承了Map.Entry<K, V>,而TreeNode<K, V>继承了LinkedHashMap.Entry<K,V>,从而间接也继承了Node<K, V>类。Node类代表一个普通的键值对节点,而TreeNode代表一个二叉树节点。它们之间的变换将在下面讲解。

HashMap内部有一个成员变量:transient Node<K,V>[] table,是一个Node数组,HashMap中的所有数据实际上都存储在这个数组中。

构造方法

HashMap有三个构造方法:

public HashMap();
public HashMap(int initialCapacity);
public HashMap(int initialCapacity, float loadFactor);

第一个和第二个方法都需要调用第三个方法,不同的仅仅是使用默认值而已。第三个方法需要两个参数,initialCapacityloadFactor,分别是上面提到的Node数组table的初始化容量,和一个值loadFactor,表示负载系数。这两个值的默认值分别是DEFAULT_INITIAL_CAPACITYDEFAULT_LOAD_FACTOR。也就是说,在无参构造一个HashMap时,Hash桶的初始大小为16,且负载系数是0.75。那么负载系数是什么呢?

负载系数是Map扩容中的一个重要参数,当Map中的元素数量达到size / capacity(注意是size,是容量,而不是占用Hash桶的个数),就会触发HashMap进行一次扩容操作。那么,这个值的默认值为什么是0.75?

在HashMap的注释中,Java的开发者解释了这个问题。

As a general rule, the default load factor (.75) offers a good tradeoff between time and space costs. Higher values decrease the space overhead but increase the lookup cost (reflected in most of the operations of the HashMap class, including get and put). The expected number of entries in the map and its load factor should be taken into account when setting its initial capacity, so as to minimize the number of rehash operations. If the initial capacity is greater than the maximum number of entries divided by the load factor, no rehash operations will ever occur.

翻译过来就是

作为一般规则,默认负载因子(0.75)在时间和空间成本上提供了很好的折衷。较高的值会降低空间开销,但提高查找成本(体现在大多数的HashMap类的操作,包括get和put)。设置初始大小时,应该考虑预计的entry数在map及其负载系数,并且尽量减少rehash操作的次数。如果初始容量大于最大条目数除以负载因子,rehash操作将不会发生。

只说了大概是一个统计学原理,是一个折衷,但是依旧没有具体的原因……后来我在StackOverflow找到了一个大概的数学解释。如下:

<img width=80% src="https://s1.ax1x.com/2020/06/04/t0NbSU.png" alt="t0NbSU.png" border="0" />

解释是, 一个bucket空和非空的概率为0.5,通过牛顿二项式等数学计算,得到这个loadfactor的值为log(2),约等于0.693。回答者认为,任何小于0.75大于log(2)的值都能提供更好的性能,0.75可能就是随便写的一个值。

好的,会到主题,我们来看看构造方法~

public HashMap(int initialCapacity, float loadFactor) {
    ...
    this.loadFactor = loadFactor;
    this.threshold = tableSizeFor(initialCapacity);
}

注意一下这里的threshold表示下一次扩容操作时table要达到的容量,由于该构造方法并没有对table进行初始化,那么下一次扩容操作就需要扩容到threshold大小。而在其他情况下,threshold参数表示Map中元素的上限,即map的容量与负载系数的乘积。

除了对参数做了一些校验以外,就仅仅是把数值存了起来,并没有初始化什么东西。tableSizeFor()方法如下,作用是对于一个初始容量,给出大于等于这个容量的最小的2的幂次方:

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;
 }

这个步骤可以分成两部分,解释和图来源于知乎用户@终端研发部。从二进制的角度看一下这个流程:

t0wW90.jpg

5 -> 8、9 -> 16、19 -> 32、37 -> 64都经历了两个阶段:

Step 1,5->7
Step 2,7->8

Step 1,9->15
Step 2,15->16

Step 1,19->31
Step 2,31->32

那么对应到代码中,Step 1就是:

n |= n >>> 1;
n |= n >>> 2;
n |= n >>> 4;
n |= n >>> 8;
n |= n >>> 16;

Step 2就是:

return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;

Step 2很简单,就是做一下与极限值的判断,然后再将数值加一而已。

那么Step 1怎么理解呢?其实就是对一个二进制数依次右移,然后与原值取或。本质就是将第一个不为0的位的后面所有位都设置为1。

那么再加一个1,就是大于原数的第一个2的幂了!

这个办法确实很好,但是对于一个特殊情况确是行不通的,就是这个数原本就是2的幂,这种情况下这个数会变成自身的两倍。而方法的第一行先把这个数减去1,就完美地避免了这个情况。

put()方法

put()方法如下:

public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

主要功能被放在putVal()方法中实现。

注意这里传入putVal的是key的hash值,hash()方法的实现如下:

static final int hash(Object key) {
    int h;
    return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}

这里就体现了为什么HashMap的key可以是null,当key为null时,其Hash值被当作0处理。那么问题来了,为什么不直接使用key的hashCode,而是要将hashCode右移16位再和自己异或?这就是扰动算法的原理了。

hashCode()方法,是key的类自带的Hash函数,返回一个int的Hash值,理论上,这个值应当均匀得分布在int的范围内,即-2147483648到2147483647之间,接近40亿的空间,这显然是很难产生碰撞的。

但是问题在于,HashMap中的Hash桶并没有40亿大小,事实上,在最初默认初始化的时候,才16个桶。所以需要将这个HashCode映射到这16个桶上,一个比较简单均匀的办法,就是取模,对容量取模。例如,以初始长度16为例,二进制数为00000000 00000000 00001111,如果我们让10100101 11000100 00100101这个数对16取模的话,其实仅仅是取这个数的低四位,而高位全部清零。这种情况下,就会导致很严重的碰撞。

这时就需要扰动函数了,它将一个32位数的高16位和低16位做一个异或操作,就变相地将高位的部分信息保存在了低位,增加了低位的随机性,减少的碰撞的几率。

t0yEnK.jpg

putVal()方法将进行实际的put操作。

putVal()开头首先判断当前HashMap的table(存储空间)是否已经初始化或者为空,因为在构造函数中,并没有对table进行任何操作,如果是空的话需要进行初始的扩容:

if ((tab = table) == null || (n = tab.length) == 0)
    n = (tab = resize()).length;

扩容部分后续再说。

接着获取了table上对应位置的Hash桶,并判断是否为null,如果是null,说明没有发生Hash碰撞,直接新建一个节点放进去即可,否则要进行碰撞处理:

if ((p = tab[i = (n - 1) & hash]) == null)
    tab[i] = newNode(hash, key, value, null);
else {
    // 碰撞处理部分
    ...
}

注意这个地方取出Hash桶的下标的方法,桶的下标为:(n - 1) & hash,其中n是桶的个数。上面说过,理论上将大的Hash值映射到较小的数组上,使用的取模运算。

这个公式其实就是一种快速的取模运算,大致原理可以看上面那张图的计算下标部分。这种方法有一个局限性,就是n-1的二进制表示必须全是1,也就是说,n必须是2的幂次方!这就是table的容量必须是2的幂次方的原因。

接着我们看一下碰撞处理的部分:

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 {
    ...
}

分为三种情况讨论,第一种就是当前桶的头节点的hash和现有的相等,且key也相同的情况下,将其暂时保存在e中,后面需要进行替换,说明这次push是更新一个已存在的key的value。

否则,如果当前桶的头节点是一个树节点(TreeNode),那么就调用putTreeVal()方法向二叉树中插入这个节点。

否则,就是一个普通的链表插入操作了:

for (int binCount = 0; ; ++binCount) {
    if ((e = p.next) == null) {
        p.next = newNode(hash, key, value, null);
        if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
            treeifyBin(tab, hash);
        break;
    }
    if (e.hash == hash &&
        ((k = e.key) == key || (key != null && key.equals(k))))
        break;
    p = e;
}

遍历链表至链表尾,将节点插在尾部,同时检查了一下链表的长度,如果长度大于了TREEIFY_THRESHOLD的长度,就需要将这条链表转化为一棵红黑树,调用的是treeifyBin()方法。注意这里提一嘴treeifyBin()方法,调用了treeifyBin()方法并不是绝对会转化为红黑树,在该方法开头有一个判断:

if (tab == null || (n = tab.length) < MIN_TREEIFY_CAPACITY)
    resize();

这里要求table的length,即桶的数量大于等于MIN_TREEIFY_CAPACITY的值,才可以进行下一步,否则就仅仅会进行一次扩容,而不会将链表转化为红黑树。主要是为了防止在HashMap建立初期,多个键值对恰好被放入了同一个链表中而导致不必要的转化。

如果在遍历过程中发现了相同的key,说明这次put就是一个更新操作,将在下面进行更新。

if (e != null) { // existing mapping for key
    V oldValue = e.value;
    if (!onlyIfAbsent || oldValue == null)
        e.value = value;
    afterNodeAccess(e);
    return oldValue;
}

如果put是一个更新操作,就需要使用新值覆盖,并将旧的值返回,这里调用的afterNodeAccess()可以说是一个回调函数,在HashMap中并没有实现,在LinkedHashMap中有实现。

最终size自加一后判断与threshold的大小关系,如果大于threshold,表明需要进行扩容,调用resize()方法扩容。

get()方法

方法签名如下:

public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

也是一个包装方法,实际的get操作在getNode()方法中实现,如果getNode()返回null,get也就返回null,否则就返回这个Node的value。

getNode()方法用于根据给定的hash和key在table中寻找Node。

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) {
        if (first.hash == hash && // always check first node
            ((k = first.key) == key || (key != null && key.equals(k))))
            return first;
        if ((e = first.next) != null) {
            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;
}

它的实现很简单,大致流程是,首先根据hash值找到对应的hash桶,如果hash桶的头节点的key就等于想要的key,那就直接返回头节点。否则,判断头节点的类型,如果是树节点,就使用getTreeNode()方法在红黑树中进行查找,否则就使用普通的链表查找,找到的话返回该节点,否则返回null。

resize()方法

resize()方法用于对table进行初始化,或者将table的容量扩容为原来的两倍。

if (oldCap > 0) {
    if (oldCap >= MAXIMUM_CAPACITY) {
        threshold = Integer.MAX_VALUE;
        return oldTab;
    }
    else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
        oldCap >= DEFAULT_INITIAL_CAPACITY)
        newThr = oldThr << 1; // double threshold
}
else if (oldThr > 0) // initial capacity was placed in threshold
    newCap = oldThr;
else {               // zero initial threshold signifies using defaults
    newCap = DEFAULT_INITIAL_CAPACITY;
    newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
table = newTab;

初始,对原本table的大小和threshold进行了一系列判断,如是否是最新初始化或者原大小是否已经超过上限之类的。最终确定要扩容到的大小newCap和新的阈值newThr。如果没有超出上限的话,newCap,即新桶的大小,是oldCap左移一位,即原桶数量的两倍,threshold也直接变为原来的两倍。

接着按照newCap的大小初始化一个Node数组newTab,并直接将table的引用指向了newTab,那么旧的桶则由oldTab持有。判断oldTab是否为null,如果是null,则说明这是HashMap构造完成后的第一次resize(),无需迁移节点,直接返回newTab即可。

迁移过程如下:

for (int j = 0; j < oldCap; ++j) {
    Node<K,V> e;
    if ((e = oldTab[j]) != null) {
        oldTab[j] = null;
        if (e.next == null)
            newTab[e.hash & (newCap - 1)] = e;
        else if (e instanceof TreeNode)
            ((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
        else { // preserve order
            Node<K,V> loHead = null, loTail = null;
            Node<K,V> hiHead = null, hiTail = null;
            Node<K,V> next;
            do {
                next = e.next;
                if ((e.hash & oldCap) == 0) {
                    if (loTail == null)
                        loHead = e;
                    else
                        loTail.next = e;
                    loTail = e;
                }
                else {
                    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;
            }
        }
    }
}

迁移过程,主要就是对节点重新计算Hash并安排的过程。主要就是对桶的遍历,并且根据桶的头节点的类型来进行不同的动作,如果头节点是一个TreeNode,那么就需要调用split()方法对树进行拆分(第8行),否则就对链表进行拆分。对树的操作暂且不说,主要来看对链表的拆分操作,从第10行开始。

这里先说结论,在原Map中位于同一个桶的链表节点,在扩容后重新Hash后,必然位于两个桶之中(也有可能还在一个桶),且这两个桶位置相差oldCap。较小的那个桶位于原位置。

我们来证明一下:

例如原table大小长度为16,那么扩容后的大小为32,有两个节点的Hash分别为5和21(简化情况),那么根据公式(n-1)\&hash我们来计算这两个节点在新table和旧table中的位置:

  0000 0000 0000 0000 0000 0000 0000 1111 (16-1)
& 0000 0000 0000 0000 0000 0000 0000 0101 (5)
= 0000 0000 0000 0000 0000 0000 0000 0101

  0000 0000 0000 0000 0000 0000 0001 1111 (32-1)
& 0000 0000 0000 0000 0000 0000 0000 0101 (5)
= 0000 0000 0000 0000 0000 0000 0000 0101

  0000 0000 0000 0000 0000 0000 0000 1111 (16-1)
& 0000 0000 0000 0000 0000 0000 0001 0101 (21)
= 0000 0000 0000 0000 0000 0000 0000 0101

  0000 0000 0000 0000 0000 0000 0001 1111 (32-1)
& 0000 0000 0000 0000 0000 0000 0001 0101 (21)
= 0000 0000 0000 0000 0000 0000 0001 0101

Hash为5和21的桶显然在原table中位于一个桶,在计算扩容后的桶的位置时,我们可以注意到,n-1的二进制数多了一个1,在这个例子中,就是在第5位上多了一个1,那么由于是与操作,计算后的结果是否改变就仅仅取决于该节点的Hash的第5位上是否为1,如果是0的话,那么桶的位置不变,否则,计算出的桶的位置就比原桶的位置大2^4,正好是原桶的大小。且旧元素迁移到新元素时重新计算Hash只有这两种情况,即在同一个旧桶中的元素,迁移到新的table时,只有可能保持不变,或者到一个新的桶中,这个桶的index为原桶index+原table大小。

再回到代码:

Node<K,V> loHead = null, loTail = null;
Node<K,V> hiHead = null, hiTail = null;
Node<K,V> next;
do {
    next = e.next;
    if ((e.hash & oldCap) == 0) {
        if (loTail == null)
            loHead = e;
        else
            loTail.next = e;
        loTail = e;
    }
    else {
        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;
}

初始化的两个链表loHead和hiHead分别代表保持原位置的节点组成的链表和迁移到新位置的节点组成的链表。接着遍历原链表上的所有节点,将节点分配到两条链表上。第6行的if中条件,(e.hash & oldCap)即可判断出那个关键的二进制位上是否为0,为0的节点放入原位链表loHead,否则放入新链表hiHead。最后将这两条链表放到新table的对应位置即可。

remove()方法

remove()方法用于在HashMap中删除对应Key的Entry,方法签名如下:

public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

可以看到核心的逻辑是调用removeNode()方法,该方法返回被删除的Entry的value。

removeNode()方法的很多逻辑和put()方法很接近,就不具体展开解释了。

红黑树退化成链表

在代码中有一个常量为UNTREEIFY_THRESHOLD:

static final int UNTREEIFY_THRESHOLD = 6;

这个常量仅仅会在split()方法中使用,用于分裂红黑树并重新计算Hash,split()也只会在resize()中调用,即只有在扩容时,对红黑树进行分裂,如果树的大小不足6,就会退化为链表。

if (loHead != null) {
    if (lc <= UNTREEIFY_THRESHOLD)
        tab[index] = loHead.untreeify(map);
    else {
        tab[index] = loHead;
        if (hiHead != null) // (else is already treeified)
            loHead.treeify(tab);
    }
}
if (hiHead != null) {
    if (hc <= UNTREEIFY_THRESHOLD)
        tab[index + bit] = hiHead.untreeify(map);
    else {
        tab[index + bit] = hiHead;
    if (loHead != null)
        hiHead.treeify(tab);
    }
}

注意到TREEIFY_THRESHOLD和UNTREEIFY_THRESHOLD的值并不相等,这主要是因为防止某个桶中的节点在这个值附近震荡,就会导致频繁的红黑树和链表的转化。

但是,在代码中,红黑树退化为链表的方法untreeify()并不仅仅在这一处被调用,也就是说,退化并不仅有这一个条件。

removeTreeNode()方法中,也调用了该方法,removeTreeNode()是在remove()删除节点时使用的。在删除红黑树节点时,有如下判断:

if (root == null
    || (movable
        && (root.right == null
            || (rl = root.left) == null
            || rl.left == null))) {
    tab[index] = first.untreeify(map);  // too small
    return;
}

这里是对红黑树的结构进行判断,如果满足一定的条件,就会退化为链表,退化条件如下:

  • 根的左子树为空
  • 根的右子树为空
  • 根的左孙子为空

注意这时还没有对节点进行删除,也就是说,这是在删除节点之前进行退化的。

这里我们来看一下什么样的结构会导致退化,图源https://my.oschina.net/u/580483/blog/4294852

最小临界情况:

20200529121648.png

这是满足不退化的最小情况,如果删除任何一个节点,就会导致下一次remove时进行退化。红黑树节点个数为3。

最大临界情况:

20200529135247.png

这时,如果删除了0005节点,树不会发生旋转,但是下次remove时因为满足第三个条件而导致退化,红黑树节点个数为10。

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 157,298评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,701评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,078评论 0 237
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,687评论 0 202
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,018评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,410评论 1 211
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,729评论 2 310
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,412评论 0 194
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,124评论 1 239
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,379评论 2 242
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,903评论 1 257
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,268评论 2 251
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,894评论 3 233
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,014评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,770评论 0 192
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,435评论 2 269
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,312评论 2 260