ThreadLocal源码剖析

每个线程都保持对其线程局部变量副本的隐式引用,只要线程是活动的并且 ThreadLocal 实例是可访问的。
在线程消失之后,其线程局部实例的所有副本都会被垃圾回收,(除非存在对这些副本的其他引用)。

1.使用示例

public class ThreadId {
    // Atomic integer containing the next thread ID to be assigned
    private static final AtomicInteger nextId = new AtomicInteger(0);

    // Thread local variable containing each thread's ID
    private static final ThreadLocal<Integer> threadId =
            new ThreadLocal<Integer>() {
                @Override protected Integer initialValue() {
                    return nextId.getAndIncrement();
                }
            };

    // Returns the current thread's unique ID, assigning it if necessary
    public static int get() {
        return threadId.get();
    }

    public static void main(String[] args) throws InterruptedException {
        RunnableTask task = new RunnableTask();
        Thread t1 = new Thread(task, "线程1");
        t1.start();
        TimeUnit.MILLISECONDS.sleep(100);

        Thread t2 = new Thread(task, "线程2");
        t2.start();
        TimeUnit.MILLISECONDS.sleep(100);

        Thread t3 = new Thread(task, "线程3");
        t3.start();
        TimeUnit.MILLISECONDS.sleep(100);
    }

    static class RunnableTask implements Runnable {

        @Override
        public void run() {
            try {
                System.out.println("当前线程名为: " + Thread.currentThread().getName() + ", 分配的线程ID为: " + threadId.get());
            } finally {
                threadId.remove();
            }
        }
    }
}

结果:

当前线程名为: 线程1, 分配的线程ID为: 0
当前线程名为: 线程2, 分配的线程ID为: 1
当前线程名为: 线程3, 分配的线程ID为: 2

2.ThreadLocal的操作

2.1 get()

     /* 返回当前线程与当前ThreadLocal对象相关联的 线程局部变量,这个变量只有当前线程能访问到。
     * 如果当前线程 没有分配,则给当前线程去分配(使用initialValue方法)
     *
     */
    public T get() {
        //获取当前线程
        Thread t = Thread.currentThread();
        //获取到当前线程Thread对象的 threadLocals map引用
        ThreadLocalMap map = getMap(t);
        //条件成立:说明当前线程已经拥有自己的 ThreadLocalMap 对象了
        if (map != null) {
            //key:当前threadLocal对象
            //调用map.getEntry() 方法 获取threadLocalMap 中该threadLocal关联的 entry
            ThreadLocalMap.Entry e = map.getEntry(this);
            //条件成立:说明当前线程 初始化过 与当前threadLocal对象相关联的 线程局部变量
            if (e != null) {
                @SuppressWarnings("unchecked")
                T result = (T)e.value;
                //返回value..
                return result;
            }
        }

        //执行到这里有几种情况?
        //1.当前线程对应的threadLocalMap是空
        //2.当前线程与当前threadLocal对象没有生成过相关联的 线程局部变量..

        //setInitialValue方法初始化当前线程与当前threadLocal对象 相关联的value。
        //且 当前线程如果没有threadLocalMap的话,还会初始化创建map。
        return setInitialValue();
    }
    /**
     * Variant of set() to establish initialValue. Used instead
     * of set() in case user has overridden the set() method.
     *
     * setInitialValue方法初始化当前线程与当前threadLocal对象 相关联的value。
     * 且 当前线程如果没有threadLocalMap的话,还会初始化创建map。
     * @return the initial value
     */
    private T setInitialValue() {
        //调用的当前ThreadLocal对象的initialValue方法,这个方法 大部分情况下咱们都会重写。
        //value 就是当前ThreadLocal对象与当前线程相关联的 线程局部变量。
        T value = initialValue();
        //获取当前线程对象
        Thread t = Thread.currentThread();
        //获取当前线程内部的threadLocals    threadLocalMap对象。
        ThreadLocalMap map = getMap(t);
        //条件成立:说明当前线程内部已经初始化过 threadLocalMap对象了。 (线程的threadLocals 只会初始化一次。)
        if (map != null)
            //保存当前threadLocal与当前线程生成的 线程局部变量。
            //key: 当前threadLocal对象   value:线程与当前threadLocal相关的局部变量
            map.set(this, value);
        else
            //执行到这里,说明 当前线程内部还未初始化 threadLocalMap ,这里调用createMap 给当前线程创建map

            //参数1:当前线程   参数2:线程与当前threadLocal相关的局部变量
            createMap(t, value);

        //返回线程与当前threadLocal相关的局部变量
        return value;
    }
    void createMap(Thread t, T firstValue) {
        //传递t 的意义就是 要访问 当前这个线程 t.threadLocals 字段,给这个字段初始化。


        //new ThreadLocalMap(this, firstValue)
        //创建一个ThreadLocalMap对象 初始 k-v 为 : this <当前threadLocal对象> ,线程与当前threadLocal相关的局部变量
        t.threadLocals = new ThreadLocalMap(this, firstValue);
    }

2.2 set()

 
    / * 修改当前线程与当前threadLocal对象相关联的 线程局部变量。
     *
     */
    public void set(T value) {
        //获取当前线程
        Thread t = Thread.currentThread();
        //获取当前线程的threadLocalMap对象
        ThreadLocalMap map = getMap(t);
        //条件成立:说明当前线程的threadLocalMap已经初始化过了
        if (map != null)
            //调用threadLocalMap.set方法 进行重写 或者 添加。
            map.set(this, value);
        else
            //执行到这里,说明当前线程还未创建 threadLocalMap对象。

            //参数1:当前线程   参数2:线程与当前threadLocal相关的局部变量
            createMap(t, value);
    }

2.3 remove()

     /* 移除当前线程与当前threadLocal对象相关联的 线程局部变量。
     *
     * @since 1.5
     */
     public void remove() {
         //获取当前线程的 threadLocalMap对象
         ThreadLocalMap m = getMap(Thread.currentThread());
         //条件成立:说明当前线程已经初始化过 threadLocalMap对象了
         if (m != null)
             //调用threadLocalMap.remove( key = 当前threadLocal)
             m.remove(this);
     }

3.ThreadLocalMap操作

ThreadLocalMap 是一个定制的自定义 hashMap 哈希表,只适合用于维护线程对应ThreadLocal的值. 此类的方法没有在ThreadLocal 类外部暴露,此类是私有的,允许在 Thread 类中以字段的形式声明 ,以助于处理存储量大,生命周期长的使用用途,此类定制的哈希表实体键值对使用弱引用WeakReferences 作为key,但是, 一旦引用不在被使用,只有当哈希表中的空间被耗尽时,对应不再使用的键值对实体才会确保被移除回收。

弱引用.png

问题一:为什么ThreadLocal中的map对象为什么key是weak类型的?

弱引用概念:弱引用关联的对象只能存活到下一次垃圾回收发生之前。当发生GC时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。
使用WeakReference类实现弱引用

好处:ThreadLocal变更如果置为 null 后(ThreadLocal=null),即使不手工 remove,value也有很高的概率回收。
在调用ThreadLocal的get、set、remove方法的时候才会触发expungeStaleEntry方法的执行,才会把ThreadLocal为null所对应的value和Entry才会设置为null。换句话说,正常的情况是不会出现内存泄露的,但是如果我们没有调用ThreadLocal对应的set、get、remove方法就不会把对应的value和Entry设置为null,这样就可能会出现内存泄露情况。

问题:为什么说显式地进行remove是个很好的编码习惯,这样是不会引起内存泄漏?

调用remove方法,会调用expungeStaleEntry 方法立刻从当前slot开始清理,一定会清理当前对象。
如果没有调用remove方法,只能说如果对应线程之后调用ThreadLocal的get和set方法都有很高的概率会顺便清理掉无效对象,断开value强引用,从而value对象被收集器回收。但如果线程一直存在,又没有再调用set、get方法,对象就无法被回收,就会发生内存泄露

问题:ThreadLocal中的map对象是map吗?

是一个环状数组,用开放录址法解决冲突。


3.1 属性值

如下:

  • 数组初始长度为16
  • 扩容阈值为容量的2/3

3.2 构造方法

         /* 因为Thread.threadLocals字段是延迟初始化的,只有线程第一次存储 threadLocal-value 时 才会创建 threadLocalMap对象。
         *
         * firstKey :threadLocal对象
         * firstValue: 当前线程与threadLocal对象关联的value。
         */
        ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
            //创建entry数组长度为16,表示threadLocalMap内部的散列表。
            table = new Entry[INITIAL_CAPACITY];
            //寻址算法:key.threadLocalHashCode & (table.length - 1)
            //table数组的长度一定是 2 的次方数。
            //2的次方数-1 有什么特征呢?  转化为2进制后都是1.    16==> 1 0000 - 1 => 1111
            //1111 与任何数值进行&运算后 得到的数值 一定是 <= 1111

            //i 计算出来的结果 一定是 <= B1111
            int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);

            //创建entry对象 存放到 指定位置的slot中。
            table[i] = new Entry(firstKey, firstValue);
            //设置size=1
            size = 1;
            //设置扩容阈值 (当前数组长度 * 2)/ 3  => 16 * 2 / 3 => 10
            setThreshold(INITIAL_CAPACITY);
        }

3.3 getEntry()

         /* ThreadLocal对象 get() 操作 实际上是由 ThreadLocalMap.getEntry() 代理完成的。
         *
         * key:某个 ThreadLocal对象,因为 散列表中存储的entry.key 类型是 ThreadLocal。
         *
         * @param  key the thread local object
         * @return the entry associated with key, or null if no such
         */
        private Entry getEntry(ThreadLocal<?> key) {
            //路由规则: ThreadLocal.threadLocalHashCode & (table.length - 1) ==》 index
            int i = key.threadLocalHashCode & (table.length - 1);
            //访问散列表中 指定指定位置的 slot
            Entry e = table[i];
            //条件一:成立 说明slot有值
            //条件二:成立 说明 entry#key 与当前查询的key一致,返回当前entry 给上层就可以了。
            if (e != null && e.get() == key)
                return e;
            else
                //有几种情况会执行到这里?
                //1.e == null
                //2.e.key != key


            //getEntryAfterMiss 方法 会继续向当前桶位后面继续搜索 e.key == key 的entry.

            //为什么这样做呢??
            //因为 存储时  发生hash冲突后,并没有在entry层面形成 链表.. 存储时的处理 就是线性的向后找到一个可以使用的slot,并且存放进去。
                return getEntryAfterMiss(key, i, e);
        }

        /**
         * Version of getEntry method for use when key is not found in
         * its direct hash slot.
         *
         * @param  key the thread local object           threadLocal对象 表示key
         * @param  i the table index for key's hash code  key计算出来的index
         * @param  e the entry at table[i]                table[index] 中的 entry
         * @return the entry associated with key, or null if no such
         */
        private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
            //获取当前threadLocalMap中的散列表 table
            Entry[] tab = table;
            //获取table长度
            int len = tab.length;

            //条件:e != null 说明 向后查找的范围是有限的,碰到 slot == null 的情况,搜索结束。
            //e:循环处理的当前元素
            while (e != null) {
                //获取当前slot 中entry对象的key
                ThreadLocal<?> k = e.get();
                //条件成立:说明向后查询过程中找到合适的entry了,返回entry就ok了。
                if (k == key)
                    //找到的情况下,就从这里返回了。
                    return e;
                //条件成立:说明当前slot中的entry#key 关联的 ThreadLocal对象已经被GC回收了.. 因为key 是弱引用, key = e.get() == null.
                if (k == null)
                    //做一次 探测式过期数据回收。
                    expungeStaleEntry(i);
                else
                    //更新index,继续向后搜索。
                    i = nextIndex(i, len);
                //获取下一个slot中的entry。
                e = tab[i];
            }

            //执行到这里,说明关联区段内都没找到相应数据。
            return null;
        }
        /**
         * Expunge a stale entry by rehashing any possibly colliding entries
         * lying between staleSlot and the next null slot.  This also expunges
         * any other stale entries encountered before the trailing null.  See
         * Knuth, Section 6.4
         *
         * 参数 staleSlot   table[staleSlot] 就是一个过期数据,以这个位置开始 继续向后查找过期数据,直到碰到 slot == null 的情况结束。
         *
         * @param staleSlot index of slot known to have null key
         * @return the index of the next null slot after staleSlot
         * (all between staleSlot and this slot will have been checked
         * for expunging).
         */
        private int expungeStaleEntry(int staleSlot) {
            //获取散列表
            Entry[] tab = table;
            //获取散列表当前长度
            int len = tab.length;

            // expunge entry at staleSlot
            //help gc
            tab[staleSlot].value = null;
            //因为staleSlot位置的entry 是过期的 这里直接置为Null
            tab[staleSlot] = null;
            //因为上面干掉一个元素,所以 -1.
            size--;

            // Rehash until we encounter null
            //e:表示当前遍历节点
            Entry e;
            //i:表示当前遍历的index
            int i;

            //for循环从 staleSlot + 1的位置开始搜索过期数据,直到碰到 slot == null 结束。
            for (i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                //进入到for循环里面 当前entry一定不为null


                //获取当前遍历节点 entry 的key.
                ThreadLocal<?> k = e.get();

                //条件成立:说明k表示的threadLocal对象 已经被GC回收了... 当前entry属于脏数据了...
                if (k == null) {
                    //help gc
                    e.value = null;
                    //脏数据对应的slot置为null
                    tab[i] = null;
                    //因为上面干掉一个元素,所以 -1.
                    size--;
                } else {
                    //执行到这里,说明当前遍历的slot中对应的entry 是非过期数据
                    //因为前面有可能清理掉了几个过期数据。
                    //且当前entry 存储时有可能碰到hash冲突了,往后偏移存储了,这个时候 应该去优化位置,让这个位置更靠近 正确位置。
                    //这样的话,查询的时候 效率才会更高!

                    //重新计算当前entry对应的 index
                    int h = k.threadLocalHashCode & (len - 1);
                    //条件成立:说明当前entry存储时 就是发生过hash冲突,然后向后偏移过了...
                    if (h != i) {
                        //将entry当前位置 设置为null
                        tab[i] = null;

                        // Unlike Knuth 6.4 Algorithm R, we must scan until
                        // null because multiple entries could have been stale.


                        //h 是正确位置。

                        //以正确位置h 开始,向后查找第一个 可以存放entry的位置。
                        while (tab[h] != null)
                            h = nextIndex(h, len);

                        //将当前元素放入到 距离正确位置 更近的位置(有可能就是正确位置)。
                        tab[h] = e;
                    }
                }
            }
            return i;
        }
expungeStaleEntry 探测式清理过期数据.png

3.4 set()

        /**
         * Set the value associated with key.
         *
         * ThreadLocal 使用set方法 给当前线程添加 threadLocal-value   键值对。
         *
         * @param key the thread local object
         * @param value the value to be set
         */
        private void set(ThreadLocal<?> key, Object value) {
            //获取散列表
            Entry[] tab = table;
            //获取散列表数组长度
            int len = tab.length;
            //计算当前key 在 散列表中的对应的位置
            int i = key.threadLocalHashCode & (len-1);


            //以当前key对应的slot位置 向后查询,找到可以使用的slot。
            //什么slot可以使用呢??
            //1.k == key 说明是替换
            //2.碰到一个过期的 slot ,这个时候 咱们可以强行占用呗。
            //3.查找过程中 碰到 slot == null 了。
            for (Entry e = tab[i];
                 e != null;
                 e = tab[i = nextIndex(i, len)]) {

                //获取当前元素key
                ThreadLocal<?> k = e.get();

                //条件成立:说明当前set操作是一个替换操作。
                if (k == key) {
                    //做替换逻辑。
                    e.value = value;
                    return;
                }

                //条件成立:说明 向下寻找过程中 碰到entry#key == null 的情况了,说明当前entry 是过期数据。
                if (k == null) {
                    //碰到一个过期的 slot ,这个时候 咱们可以强行占用呗。
                    //替换过期数据的逻辑。
                    replaceStaleEntry(key, value, i);
                    return;
                }
            }



            //执行到这里,说明for循环碰到了 slot == null 的情况。
            //在合适的slot中 创建一个新的entry对象。
            tab[i] = new Entry(key, value);
            //因为是新添加 所以++size.
            int sz = ++size;

            //做一次启发式清理
            //条件一:!cleanSomeSlots(i, sz) 成立,说明启发式清理工作 未清理到任何数据..
            //条件二:sz >= threshold 成立,说明当前table内的entry已经达到扩容阈值了..会触发rehash操作。
            if (!cleanSomeSlots(i, sz) && sz >= threshold)
                rehash();
        }

3.4.1 replaceStaleEntry替换过期数据的逻辑

         
         /* staleSlot: 上层方法 set方法,迭代查找时 发现的当前这个slot是一个过期的 entry。
         */
        private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                                       int staleSlot) {
            //获取散列表
            Entry[] tab = table;
            //获取散列表数组长度
            int len = tab.length;
            //临时变量
            Entry e;

            //表示 开始探测式清理过期数据的 开始下标。默认从当前 staleSlot开始。
            int slotToExpunge = staleSlot;


            //以当前staleSlot开始 向前迭代查找,找有没有过期的数据。for循环一直到碰到null结束。
            for (int i = prevIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = prevIndex(i, len)){
                //条件成立:说明向前找到了过期数据,更新 探测清理过期数据的开始下标为 i
                if (e.get() == null){
                    slotToExpunge = i;
                }
            }

            //以当前staleSlot向后去查找,直到碰到null为止。
            for (int i = nextIndex(staleSlot, len);
                 (e = tab[i]) != null;
                 i = nextIndex(i, len)) {
                //获取当前元素 key
                ThreadLocal<?> k = e.get();

                //条件成立:说明咱们是一个 替换逻辑。
                if (k == key) {
                    //替换新数据。
                    e.value = value;

                    //交换位置的逻辑..
                    //将table[staleSlot]这个过期数据 放到 当前循环到的 table[i] 这个位置。
                    tab[i] = tab[staleSlot];
                    //将tab[staleSlot] 中保存为 当前entry。 这样的话,咱们这个数据位置就被优化了..
                    tab[staleSlot] = e;

                    //条件成立:
                    // 1.说明replaceStaleEntry 一开始时 的向前查找过期数据 并未找到过期的entry.
                    // 2.向后检查过程中也未发现过期数据..
                    if (slotToExpunge == staleSlot)
                        //开始探测式清理过期数据的下标 修改为 当前循环的index。
                        slotToExpunge = i;


                    //cleanSomeSlots :启发式清理
                    cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
                    return;
                }

                //条件1:k == null 成立,说明当前遍历的entry是一个过期数据..
                //条件2:slotToExpunge == staleSlot 成立,一开始时 的向前查找过期数据 并未找到过期的entry.
                if (k == null && slotToExpunge == staleSlot)
                    //因为向后查询过程中查找到一个过期数据了,更新slotToExpunge 为 当前位置。
                    //前提条件是 前驱扫描时 未发现 过期数据..
                    slotToExpunge = i;
            }

            //什么时候执行到这里呢?
            //向后查找过程中 并未发现 k == key 的entry,说明当前set操作 是一个添加逻辑..

            //直接将新数据添加到 table[staleSlot] 对应的slot中。
            tab[staleSlot].value = null;
            tab[staleSlot] = new Entry(key, value);


            //条件成立:除了当前staleSlot 以外 ,还发现其它的过期slot了.. 所以要开启 清理数据的逻辑..
            if (slotToExpunge != staleSlot)
                cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
        }
replaceStaleEntry工作原理.png

3.4.2 启发式清理cleanSomeSlots()

         /* 参数 i 启发式清理工作开始位置
         * 参数 n 一般传递的是 table.length ,这里n 也表示结束条件。
         * @return true if any stale entries have been removed.
         */
        private boolean cleanSomeSlots(int i, int n) {
            //表示启发式清理工作 是否清楚过过期数据
            boolean removed = false;
            //获取当前map的散列表引用
            Entry[] tab = table;
            //获取当前散列表数组长度
            int len = tab.length;

            do {
                //这里为什么不是从i就检查呢?
                //因为cleanSomeSlots(i = expungeStaleEntry(???), n)  expungeStaleEntry(???) 返回值一定是null。

                //获取当前i的下一个 下标
                i = nextIndex(i, len);
                //获取table中当前下标为i的元素
                Entry e = tab[i];
                //条件一:e != null 成立
                //条件二:e.get() == null 成立,说明当前slot中保存的entry 是一个过期的数据..
                if (e != null && e.get() == null) {
                    //重新更新n为 table数组长度
                    n = len;
                    //表示清理过数据.
                    removed = true;
                    //以当前过期的slot为开始节点 做一次 探测式清理工作
                    i = expungeStaleEntry(i);
                }


                // 假设table长度为16
                // 16 >>> 1 ==> 8
                // 8 >>> 1 ==> 4
                // 4 >>> 1 ==> 2
                // 2 >>> 1 ==> 1
                // 1 >>> 1 ==> 0
            } while ( (n >>>= 1) != 0);

            return removed;
        }

cleanSomeSlots 启发式清理工作.png

3.4.3 扩容rehash

扩容条件:

  • cleanSomeSlots()没有清理任何数据
  • table中数量达到阈值threshold
  • 清理完哈希表中所有的过期数据以后,数量还是大于等于threshold的3/4,也即容量的1/2,就会调用resize()扩容方法
        private void rehash() {
            //这个方法执行完后,当前散列表内的所有过期的数据,都会被干掉。
            expungeStaleEntries();


            // Use lower threshold for doubling to avoid hysteresis
            //条件成立:说明清理完 过期数据后,当前散列表内的entry数量仍然达到了 threshold * 3/4,真正触发 扩容!
            if (size >= threshold - threshold / 4)
                //扩容。
                resize();
        }

真正的扩容方法

        /**
         * Double the capacity of the table.
         */
        private void resize() {
            //获取当前散列表
            Entry[] oldTab = table;
            //获取当前散列表长度
            int oldLen = oldTab.length;
            //计算出扩容后的表大小  oldLen * 2
            int newLen = oldLen * 2;
            //创建一个新的散列表
            Entry[] newTab = new Entry[newLen];
            //表示新table中的entry数量。
            int count = 0;

            //遍历老表 迁移数据到新表。
            for (int j = 0; j < oldLen; ++j) {
                //访问老表的指定位置的slot
                Entry e = oldTab[j];
                //条件成立:说明老表中的指定位置 有数据
                if (e != null) {
                    //获取entry#key
                    ThreadLocal<?> k = e.get();
                    //条件成立:说明老表中的当前位置的entry 是一个过期数据..
                    if (k == null) {
                        e.value = null; // Help the GC
                    } else {
                        //执行到这里,说明老表的当前位置的元素是非过期数据 正常数据,需要迁移到扩容后的新表。。

                        //计算出当前entry在扩容后的新表的 存储位置。
                        int h = k.threadLocalHashCode & (newLen - 1);
                        //while循环 就是拿到一个距离h最近的一个可以使用的slot。
                        while (newTab[h] != null)
                            h = nextIndex(h, newLen);

                        //将数据存放到 新表的 合适的slot中。
                        newTab[h] = e;
                        //数量+1
                        count++;
                    }
                }
            }


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

推荐阅读更多精彩内容