HashMap 源码分析

一、前言

花了一周学习 HashMap 的源码,探究其原理和设计思路,发现 HashMap 设计非常精妙,而且一直在精益求精地完善。学后发现 HashMap 有太多有价值的知识,难怪面试官对 HashMap 这么关注。

打算写一篇总结的文章,但是 HashMap 有非常多的细节,一篇文章肯定不够讲述清楚,本文只分析 HashMap 基础原理,后面再抽空再写其他文章补充细节。

二、概述

1. HashMap 简介

HashMap 是使用频率非常高的 Key-Value 关系的数据结构。从 JDK 1.2 开始就已经有该类,并在不同版本中不断迭代优化。

2. HashMap 特性

  • 允许 key 为 null,value 为 null。key 为 null 时,hash 值为 0。
  • HashMap 键值对是无序的,键值对存放的位置和插入的顺序无法。且在进行某些操作(put、remove)后,键值对的顺序可能会发生变化。
  • HashMap 非线程安全。

3. HashMap 原理

  1. 算法:HashMap 底层基于拉链式的散列算法实现。

  2. 数据结构:HashMap 在 JDK 1.7 基于 数组 + 链表 实现,在 JDK 1.8 基于 数组 + 链表 + 红黑树 实现。

三、源码分析

本文将分析 JDK 1.7 和 JDK 1.8 的 HashMap 源码。

1. 核心变量

JDK 1.8 中 HashMap 源码中主要的变量

    /* --------------  静态变量 -------------- */
    /**
     * The default initial capacity - MUST be a power of two.
     * 中文解释: 默认初始容量-必须是 2 的幂
     */
    static final int DEFAULT_INITIAL_CAPACITY = 1 << 4; // aka 16

    /**
     * The load factor used when none specified in constructor.
     * 中文解释: 默认的负载因子。构造函数中未指定负载因子时,将使用该默认值.
     */
    static final float DEFAULT_LOAD_FACTOR = 0.75f;

    /**
     * The bin count threshold for using a tree rather than list for a
     * bin.  Bins are converted to trees when adding an element to a
     * bin with at least this many nodes. The value must be greater
     * than 2 and should be at least 8 to mesh with assumptions in
     * tree removal about conversion back to plain bins upon
     * shrinkage.
     * 中文解释: 决定桶用红黑树而非链表的阀值。当添加一个元素到桶时,如果桶中元素个数达到该阀值,桶将转换为树。
     * 该值必须大于 2 且小于等于 8 。
     */
    static final int TREEIFY_THRESHOLD = 8;

    /**
     * The bin count threshold for untreeifying a (split) bin during a
     * resize operation. Should be less than TREEIFY_THRESHOLD, and at
     * most 6 to mesh with shrinkage detection under removal.
     * 中文解释: 用于容量调整操作时将拆分树结构的阀值。应该小于TREEIFY_THRESHOLD,且最大为6。
     */
    static final int UNTREEIFY_THRESHOLD = 6;

    /**
     * The smallest table capacity for which bins may be treeified.
     * (Otherwise the table is resized if too many nodes in a bin.)
     * Should be at least 4 * TREEIFY_THRESHOLD to avoid conflicts
     * between resizing and treeification thresholds.
     * 中文解释: 必须大于等于该值,桶才允许被转换成数结构。(否则数组将因为单个桶中节点过多而扩容)
     * 应该至少是 TREEIFY_THRESHOLD 的四倍,以避免调整大小和树化阈值之间的冲突。
     */
    static final int MIN_TREEIFY_CAPACITY = 64;


    /* --------------  成员变量 -------------- */
    /**
     * The table, initialized on first use, and resized as
     * necessary. When allocated, length is always a power of two.
     * (We also tolerate length zero in some operations to allow
     * bootstrapping mechanics that are currently not needed.)
     * 中文解释: 该 table 在首次使用时初始化,并根据需要调整大小。
     * 分配空间时,长度始终是 2 的幂。
     */
    transient Node<K,V>[] table;

    /**
     * Holds cached entrySet(). Note that AbstractMap fields are used
     * for keySet() and values().
     * 中文解释: 保存缓存的 entry 集合。          
     * 请注意,keySet() 和 values() 取的是 HashMap 的 父类 AbstractMap 中的字段 keySet 和keySet。
     */
    transient Set<Map.Entry<K,V>> entrySet;

    /**
     * The number of key-value mappings contained in this map.
     * 中文解释: 在当前 map 中 key-value 键值对的总数
     */
    transient int size;

    /**
     * The next size value at which to resize (capacity * load factor).
     * 中文解释: 到达该值时将跳转 table 的大小(该值 = 容量 * 负载因子)。
     * @serial
     */
    // (The javadoc description is true upon serialization.
    // Additionally, if the table array has not been allocated, this
    // field holds the initial array capacity, or zero signifying
    // DEFAULT_INITIAL_CAPACITY.)
    // 如果尚未分配表数组,则此字段保留初始数组容量,或者为零,表示DEFAULT_INITIAL_CAPACITY
    int threshold;

    /**
     * The load factor for the hash table.
     * 中文解释: hash table 的负载因子
     *
     * @serial
     */
    final float loadFactor;

initialCapacity

loadFactor

threshold

2. 核心方法

小提示

HashMap 源码中,经常会在 if 判断表达式、for 循环遍历等步骤中进行赋值操作,这个赋值操作可能比较容易忽略,且表达式过长导致阅读时不易理解,需要多留意。

在 putVal() 方法中有一段代码作为示例:

// 注意此时 p 被赋值了 tab[i = (n - 1) & hash]。与我们平时的习惯不一样,平时一般会在 if 判断前进行赋值
if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
else {
    Node<K,V> e; K k;
    // p 在此处用到
    if (p.hash == hash &&
        ((k = p.key) == key || (key != null && key.equals(k))))
        ......
        ......
        ......
}
}

2.1 构造函数

JDK 1.8 中 HashMap 的构造函数源码

    /**
     * 构造函数1(最常用): 无参构造函数。负载因子取默认的0.75
     * Constructs an empty <tt>HashMap</tt> with the default initial capacity
     * (16) and the default load factor (0.75).
     */
    public HashMap() {
        this.loadFactor = DEFAULT_LOAD_FACTOR; // all other fields defaulted
    }   

    /**
     * 构造函数2 (推荐使用): 指定初始容量。负载因子取默认的0.75
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and the default load factor (0.75).
     *
     * @param  initialCapacity the initial capacity.
     * @throws IllegalArgumentException if the initial capacity is negative.
     */
    public HashMap(int initialCapacity) {
        this(initialCapacity, DEFAULT_LOAD_FACTOR);
    }

    /**
     * 构造函数3: 指定初始容量和负载因子
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * @param  initialCapacity the initial capacity
     * @param  loadFactor      the load factor
     * @throws IllegalArgumentException if the initial capacity is negative
     *         or the load factor is nonpositive
     */
    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;
        // 找到大于或等于 initialCapacity 的最小2的幂,设置为容量
        this.threshold = tableSizeFor(initialCapacity);
    }

    /**
     * Returns a power of two size for the given target capacity.
     * 计算出大于或等于 initialCapacity 的最小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;
    }

    /**
     * 构造函数4: 指定要拷贝的map。负载因子取默认的0.75
     * Constructs a new <tt>HashMap</tt> with the same mappings as the
     * specified <tt>Map</tt>.  The <tt>HashMap</tt> is created with
     * default load factor (0.75) and an initial capacity sufficient to
     * hold the mappings in the specified <tt>Map</tt>.
     *
     * @param   m the map whose mappings are to be placed in this map
     * @throws  NullPointerException if the specified map is null
     */
    public HashMap(Map<? extends K, ? extends V> m) {
        this.loadFactor = DEFAULT_LOAD_FACTOR;
        putMapEntries(m, false);
    }

构造函数 1 是最常用的构造函数,但是推荐使用构造函数 2 ,指定初始容量。当未指定初始容量时,会指定默认的初始容量 16,如果在实际使用中容量需求远大于 16 ,则需要多次扩容,造成性能消耗。

2.2 put() 插入

JDK 1.8 中 HashMap 的 put() 方法源码

/**
 * Associates the specified value with the specified key in this map.
 * If the map previously contained a mapping for the key, the old
 * value is replaced.
 * 中文解释: 将指定的 value 与 当前 map 中指定的 key 相关联。
 * 如果当前 map 原先已有 指定 key 相关的 映射关系,则老的 value 将被覆盖。
 * @param key key with which the specified value is to be associated
 * 将与 value 关联的 key
 * @param value value to be associated with the specified key
 * 将与 key 关联的 value
 * @return the previous value associated with <tt>key</tt>, or
 *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
 *         (A <tt>null</tt> return can also indicate that the map
 *         previously associated <tt>null</tt> with <tt>key</tt>.)
 * 返回原先与 key 关联的值;如果没有 key 关联的映射,则为 null 。 (返回 null 也可能是因为原先 key 关联的 value 为 null。)
 */
public V put(K key, V value) {
    return putVal(hash(key), key, value, false, true);
}

/**
 * Implements Map.put and related methods
 * 中文解释: 该方法是 Map.put() 和 其他相关方法 的具体实现
 * @param hash hash for key  中文解释:  key 的 hash 值
 * @param key the key        中文解释:  key 值
 * @param value the value to put   中文解释: put 设置的 value
 * @param onlyIfAbsent if true, don't change existing value 
 * 中文解释: onlyIfAbsent,直译是,是否仅仅在不存在时才执行。直白解释是,如果为 true ,则不改变已存在的 value。一般情况,都是 false,只有 putIfAbsent(K key, V value) 方法中 调用 putVal() 时,onlyIfAbsent = true。
 * @param evict if false, the table is in creation mode.  中文解释: 如果为 false,则 table 处于创建模式
 * @return previous value, or null if none 中文解释: 返回原先关联的 value ,如果无关联的 value 则返回 null
 */
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 为空,即未进行初始化,则进行初始化
    if ((tab = table) == null || (n = tab.length) == 0)
        // 用 resize() 扩容的方式进行初始化
        n = (tab = resize()).length;
    // 如果 table 中不包含 hash 值对应的节点,则新建键值对节点,并放入 table。注意此时已经将 p 指向了tab[i = (n - 1) & hash],即 table 中 hash 值相同
    if ((p = tab[i = (n - 1) & hash]) == null)
        tab[i] = newNode(hash, key, value, null);
    // 如果 table 中包含 hash 值对应的节点
    else {
        Node<K,V> e; K k;
        
        /* 
         * 如果当前桶中第一个节点的 hash 值、 键值都相等时,则将 e 指向该节点。
         * 为什么是第一个呢?因为 table 存放的是链表的第一个节点或者红黑树的根节点
         */
        if (p.hash == hash &&
                ((k = p.key) == key || (key != null && key.equals(k))))
            e = p;
        // 如果桶中的引用类型为TreeNode,则调用红黑树的插入方法
        else if (p instanceof TreeNode)
            e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
        // 如果不是红黑树,则一定是链表
        else {
            // 对链表进行遍历,并在遍历的过程中统计链表长度
            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;
                }
                // 如果当前链表包含了相同 key 的键值对,则终止遍历
                if (e.hash == hash &&
                        ((k = e.key) == key || (key != null && key.equals(k))))
                    break;
                p = e;
            }
        }
        
        // 当前 map 中 存在要插入的 key 
        if (e != null) { // existing mapping for key
            V oldValue = e.value;
            // onlyIfAbsent 如果为 false ,且 原先的 value 为 null 时,才覆盖旧的值。
            if (!onlyIfAbsent || oldValue == null)
                e.value = value;
            // 在 HashMap 中 afterNodeAccess(e) 是空方法
            afterNodeAccess(e);
            return oldValue;
        }
    }
    ++modCount;
    
    // 键值对数量超过阀值,则进行扩容
    if (++size > threshold)
        resize();
    
    // 在 HashMap 中 afterNodeInsertion(evict) 是空方法
    afterNodeInsertion(evict);
    return null;
}

HashMap会缩容吗?

HashMap 只有扩容,没有缩容的机制。

参考:为什么java的hashmap不支持动态缩小容量?

2.3 get() 查询

JDK 1.8 中 HashMap 的 get() 方法源码

/**
 * Returns the value to which the specified key is mapped,
 * or {@code null} if this map contains no mapping for the key.
 *
 * <p>More formally, if this map contains a mapping from a key
 * {@code k} to a value {@code v} such that {@code (key==null ? k==null :
 * key.equals(k))}, then this method returns {@code v}; otherwise
 * it returns {@code null}.  (There can be at most one such mapping.)
 *
 * <p>A return value of {@code null} does not <i>necessarily</i>
 * indicate that the map contains no mapping for the key; it's also
 * possible that the map explicitly maps the key to {@code null}.
 * The {@link #containsKey containsKey} operation may be used to
 * distinguish these two cases.
 *
 * @see #put(Object, Object)
 */
public V get(Object key) {
    Node<K,V> e;
    return (e = getNode(hash(key), key)) == null ? null : e.value;
}

/**
 * Implements Map.get and related methods
 * 中文解释: 该方法是 Map.get() 和 其他相关方法 的具体实现
 * @param hash hash for key  中文解释: key 的 hash 值
 * @param key the key   中文解释: key 值
 * @return the node, or null if none  中文解释: 返回 key 对应的节点,如果节点不存在,则返回 null
 */
final Node<K,V> getNode(int hash, Object key) {
    Node<K,V>[] tab; Node<K,V> first, e; int n; K k;
    
    // 如果 table 不为空,且 key 在 table 存在,则定位 key 对应节点所在位置
    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) {
            // 如果桶中的引用类型为TreeNode,则根据红黑树的查找方式查找节点
            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);
        }
    }
    
    
    // 如果 table 空,或 key 在 table 不存在,则返回 null
    return null;
}

查找的步骤还是比较简单的。

2.4 遍历

遍历的方式

Map<String, Integer> map = new HashMap<>(16);
map.put("a", 1);
map.put("b", 2);

// 遍历方式1
for (Map.Entry<String, Integer> next : map.entrySet()) {
    System.out.println("key=" + next.getKey() + " value=" + next.getValue());
}

// 遍历方式2
for (String key : map.keySet()) {
    System.out.println("key=" + key + " value=" + map.get(key));
}

推荐使用方式 1 ,因为第一种方式可以直接把 key 和 value 取出,而方式 2 需要通过 key 再去搜索一次,效率差得多。

2.5 remove() 删除

JDK 1.8 中 HashMap 的 remove() 方法源码

/**
 * Removes the mapping for the specified key from this map if present.
 * 删除当前 map 中指定 key 对应的映射关系
 *
 * @param  key key whose mapping is to be removed from the map
 * 中文解释: 要从 map 中删除对应映射关系的 key
 * @return the previous value associated with <tt>key</tt>, or
 *         <tt>null</tt> if there was no mapping for <tt>key</tt>.
 *         (A <tt>null</tt> return can also indicate that the map
 *         previously associated <tt>null</tt> with <tt>key</tt>.)
 * 中文解释: 返回 key 原先关联的 value,当原先的 value 为 null,或 key 无对应的映射关系时,则返回 null
 */
public V remove(Object key) {
    Node<K,V> e;
    return (e = removeNode(hash(key), key, null, false, true)) == null ?
        null : e.value;
}

/**
 * Implements Map.remove and related methods
 * 中文解释: 该方法是 Map.remove() 和 其他相关方法 的具体实现
 * @param hash hash for key 中文解释: key 对应的 hash 值
 * @param key the key 中文解释: key
 * @param value the value to match if matchValue, else ignored 中文解释: 如果matchValue 为 true 才匹配的 value , 否则忽略 TODO 还是不太理解干啥用的
 * @param matchValue if true only remove if value is equal 中文解释: 如果为 true ,则仅在 value 相等时才删除
 * @param movable if false do not move other nodes while removing 中文解释: 如果为 false ,则在删除时,不会移动其他节点
 * @return the node, or null if none 中文解释: 返回 key 对应的节点,如果不存在对应节点,则返回 null
 */
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;
  
  // 和 get() 类似,如果 table 不为空,且 key 在 table 存在,则定位 key 对应节点所在位置
    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;
    
    // 如果正好是桶的第一个节点
        if (p.hash == hash &&
            ((k = p.key) == key || (key != null && key.equals(k))))
            node = p;
    // 如果不是桶的第一个节点,则继续往后查找
        else if ((e = p.next) != null) {
      // 如果桶中节点的引用类型为TreeNode,则根据红黑树的查找方式查找节点
            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 的目的是用 p 指向当前节点,而while 表达式中 e = e.next 是让 e 指向 下一个节点,该步骤是后面移除节点,修复链表的准备工作
                    p = e;
                } while ((e = e.next) != null); // 如果 (e = e.next) == null 说明已经遍历到最后一个节点,依旧没有找到对应的节点,将退出 do-while 循环,此时 node 依旧为 null
            }
        }
    
    // 如果存在对应节点,则删除节点,并修复链表或红黑树
        if (node != null && (!matchValue || (v = node.value) == value ||
                             (value != null && value.equals(v)))) {
      // 如果桶中节点的引用类型为TreeNode,则按红黑树删除节点的方式删除该节点
            if (node instanceof TreeNode)
                ((TreeNode<K,V>)node).removeTreeNode(this, tab, movable);
      // 如果不是树,则一定是链表结构。如果是第一个节点,将 table 的 index 下标的位置指向 node 节点的下一个节点,即第二个节点移动到第一个节点,这样 node 节点就在链表中删除了
            else if (node == p)
                tab[index] = node.next;
      // 如果是链表结构,且不是第一个第一个节点,则 key 对应的节点 node 的前一个节点的 next 指 向 node 之后的节点,这样 node 节点就就在链表中删除了
            else
                p.next = node.next;
            ++modCount;
      
      // map 的 键值对的总数 size 减一 
            --size;
      // 在 HashMap 中 afterNodeRemoval(e) 是空方法
            afterNodeRemoval(node);
            return node;
        }
    }
  
  // 如果 table 空,或 key 在 table 不存在,则返回 null
    return null;
}

romove() 前半部分和 get() 方法步骤是几乎是一样的。

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

推荐阅读更多精彩内容