java ThreadLocal的理解

前面在阅读关于Android的Handler和EventBus的源码时,发现他们都使用了ThreadLocal来确保线程变量安全。曾以为ThreadLocal的作用是使该线程持有唯一的一个对象,后来证明这个理解是错的。下面还是看下对ThreadLocal的理解吧!

关于ThreadLocal的线程隔离

关于线程隔离我的理解就是:在各自的线程中持有的变量不能个被其他线程访问并修改。使用ThreadLocal可以很好的实现线程隔离,但是下面这种情况怎么解释呢?
迷惑人的代码:

private static Test test = new Test();

public static class Test {
    private int a = 0;

    @Override
    public synchronized String toString() {
        return Thread.currentThread().getName() + " value = " + a + " ";
    }

    public synchronized void add() {
        a++;
    }
}

// 此处等同于,只不过换成了Lambda表达式实现
//private static ThreadLocal<Test> testThreadLocal = new ThreadLocal<Test>() {
//        @Override
//        protected Test initialValue() {
//            return test;
//        }
//    };
private static ThreadLocal<Test> testThreadLocal = ThreadLocal.withInitial(() -> test);

public static void main(String[] args) {
    for (int i = 0; i < 5; i++) {
        new Thread(() -> {
            if (testThreadLocal.get() != null) {
                testThreadLocal.get().add();
                System.out.println(testThreadLocal.get());
            }

        }).start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

代码很简单,创建了一个ThreadLocal对象,其初始化的时候返回静态变量test,接着创建5个线程访问此变量test,并且调用其add方法。我们看下最终输出:

Thread-0 value = 1 
Thread-1 value = 2 
Thread-2 value = 3 
Thread-3 value = 4 
Thread-4 value = 5 

很显然,这里的ThreadLocal中的test对象被其他线程访问,并没有达到线程隔离的作用。究其原因还是因为不同线程中访问的test对象都是同一个,所以线程隔离并没有什么卵用!那么这里就可以知道,如果在初始化的时候重新创建对象,此时结果肯定都是1(自己动手吧)。

ThreadLocal的常用方法

常用方法
  • initialValue()方法:用于重写此方法,设置初始值
  • set(T value)方法:用于重新设置值
  • get()方法:用于获取设置的值/初始值
  • remove():移除方法,将线程中此ThreadLocal对应的键值对移除

常用方法这里已经介绍了,关于如何使用还是不多写了,下面直接进行源码分析吧。

ThreadLocal中的ThreadLocalMap

ThreadLocalMap是Thread的成员变量threadLocals的类型,既然作为Map来命名,那么其一定是key-value的形式构成:

// 关于这个数,可以网上搜索下。
// 生成hash code间隙为这个魔数,可以让生成出来的值或者说ThreadLocal的ID较为均匀地分布在2的幂大小的数组中。
// 斐波那契散列的乘数可以用(long) ((1L << 31) * (Math.sqrt(5) - 1))可以得到2654435769,如果把这个值给转为带符号的int,则会得到-1640531527。
// ThreadLocalMap使用的是线性探测法,均匀分布的好处在于很快就能探测到下一个临近的可用slot,从而保证效率。
private static final int HASH_INCREMENT = 0x61c88647;
// 默认数组的长度,2的4次方(这里一定是2的幂),方便后面进行获得位置
private static final int INITIAL_CAPACITY = 16;
// Entry数组
private Entry[] table;
// 存储数量(真实存入的数量!=table.length)
private int size = 0;
// 阈值,当到达阈值的某个值时进行扩容
private int threshold; // Default to 0
// 设置阈值为传入值的2/3
private void setThreshold(int len) {
    threshold = len * 2 / 3;
}
// 下一个值,这里类似环形。如果是最后一个获取下一个,则返回第0个
private static int nextIndex(int i, int len) {
    return ((i + 1 < len) ? i + 1 : 0);
}
// 前一个值,同上
private static int prevIndex(int i, int len) {
    return ((i - 1 >= 0) ? i - 1 : len - 1);
}
// Map实体,继承弱引用,方便回收。但是最终也有可能造成内存泄漏。
static class Entry extends WeakReference<ThreadLocal<?>> {
    
    Object value;

    Entry(ThreadLocal<?> k, Object v) {
        super(k);
        value = v;
    }
}
// 构造方法
ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {
    // 创建大小为INITIAL_CAPACITY的Entry数组
    table = new Entry[INITIAL_CAPACITY];
    // 通过firstKey.threadLocalHashCode与INITIAL_CAPACITY取模运算,得到的位置为插入位置
    int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);
    // 为该位置设置值
    table[i] = new Entry(firstKey, firstValue);
    // 大小设为1
    size = 1;
    setThreshold(INITIAL_CAPACITY);
}

这里需要注意下这个0x61c88647数值,可以在网上搜索下讲解的很详细。
正如ThreadLocalMap的注释所说:

ThreadLocalMap is a customized hash map suitable only for maintaining thread local values. No operations are exported outside of the ThreadLocal class. The class is package private to allow declaration of fields in class Thread. To help deal with very large and long-lived usages, the hash table entries use WeakReferences for keys. However, since reference queues are not used, stale entries are guaranteed to be removed only when the table starts running out of space.
Google翻译了下,大致意思如下:
ThreadLocalMap是一个自定义哈希映射,仅适用于维护线程本地值。没有任何操作被导出到ThreadLocal类之外。该类是封装私有的,允许在Thread类中声明字段。为了处理非常大和长期使用的用法,哈希表条目使用WeakReferences作为键。但是,由于不使用引用队列,所以只有当表开始空间不足时才能删除旧条目。
这里的Entry继承了WeakReference,并将ThreadLocal作为其引用,当ThreadLocal可被回收时,此时由于是弱引用此时会将其置为null,但是此Entry仍然存在。之后,在调用ThreadLocal的get()/set()/remove()方法时,有可能将其变为可回收状态。

get()方法分析

get()方法用来取出ThreadLocal中线程独有的变量,其取出过程如下:

public T get() {
    Thread t = Thread.currentThread();
    // 根据Thread获得ThreadLocalMap对象
    ThreadLocalMap map = getMap(t);
    if (map != null) {
        // 获得当前ThreadLocal为key的Entry
        ThreadLocalMap.Entry e = map.getEntry(this);
        if (e != null) {
            @SuppressWarnings("unchecked")
            T result = (T)e.value;
            return result;
        }
    }
    // 初始化操作
    return setInitialValue();
}
// 返回Thread的threadLocals变量
ThreadLocalMap getMap(Thread t) {
    return t.threadLocals;
}

通过getMap(Thread)方法获得当前线程的ThreadLocalMap,如果对象不为null,则去查找以当前ThreadLocal作为Key的Entry,如有有的话返回其value。如果获得的Map对象为null,那么需要调用其初始化方法:

private T setInitialValue() {
    // 通过initialValue方法获得value
    T value = initialValue();
    Thread t = Thread.currentThread();
    // 如果ThreadLocalMap不为null,将key-value保存
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        // 为null,创建Map
        createMap(t, value);
    return value;
}
// 默认初始化,一般重写此方法返回默认值
protected T initialValue() {
    return null;
}
// 创建ThreadLocalMap,将key-value保存
void createMap(Thread t, T firstValue) {
    t.threadLocals = new ThreadLocalMap(this, firstValue);
}

整个初始化过程需要获得初始值并且如果t.threadLocals为null,则需要创建ThreadLocalMap对象。(总觉着在这里创建怪怪的)

private Entry getEntry(ThreadLocal<?> key) {
    int i = key.threadLocalHashCode & (table.length - 1);
    Entry e = table[i];
    if (e != null && e.get() == key)
        return e;
    else
        return getEntryAfterMiss(key, i, e);
}

这里着重看下ThreadLocalMap的getEntry()方法,该方法传入的参数是当前的ThreadLocal对象。对ThreadLocal的的threadLocalHashCode取16的模运算,找到对应的位置。这里可能出现碰撞,就是不同的ThreadLocal具有相同的位置。
关于散列冲突这里采用了开放寻址法中的线性检测方法,简单说就是如果此处有冲突了,那么需要找到下一个为null的位置将其放入。所以在查找的时候也应该采用相同的策略:通过k.threadLocalHashCode & (len - 1)获得位置,通过比较当前位置上的Entry的key和当前的key是否相同来确定是否为需要的Entry。
如果当前位置的key和当前的ThreadLocal相同,那么命中,直接返回。否则需要通过getEntryAfterMiss获得:

private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {
    Entry[] tab = table;
    int len = tab.length;
    // 找到下一个为null的Entry
    while (e != null) {
        ThreadLocal<?> k = e.get();
        // 如果后面有Entry的key与传入的key相同,命中并返回
        if (k == key)
            return e;
        // 如果数组中的Entry的key为null,则代表此Entry的ThreadLocal对象被回收,需要将其擦除
        if (k == null)
            expungeStaleEntry(i);
        else
            // 向后遍历
            i = nextIndex(i, len);
        e = tab[i];
    }
    return null;
}
// 擦除无用的Entry
private int expungeStaleEntry(int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    // 将其value置为null并将自己对象置为null
    // expunge entry at staleSlot
    tab[staleSlot].value = null;
    tab[staleSlot] = null;
    // size - 1
    size--;

    // Rehash until we encounter null
    // 重新排序
    Entry e;
    int i;
    for (i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        // 向后遍历,如果key为null,则需要设为null。
        if (k == null) {
            e.value = null;
            tab[i] = null;
            size--;
        } else {
            // 如果此时key不为null,并且其位置没有在“正确”的位置
            int h = k.threadLocalHashCode & (len - 1);
            if (h != i) {
                tab[i] = null;

                // Unlike Knuth 6.4 Algorithm R, we must scan until
                // null because multiple entries could have been stale.
                // 需要将此Entry放到其正确位置后面第一为null的位置
                // 可以看出这里是线性检测
                while (tab[h] != null)
                    h = nextIndex(h, len);
                tab[h] = e;
            }
        }
    }
    // 返回值是下一个为null的位置
    return i;
}

没有命中的话向后遍历,寻找key等于当前ThreadLocal的Entry,相同的话,直接返回。在遍历过程中,如果发现有key为null,那么需要进行擦除操作。将此处对应的value设为null,并且将Entry设为null,方便回收。接着仍需要向后遍历知道到下一个为null的位置,在遍历过程中,如果有key为null的Entry也需要设为null;如果key不为null,那么需要判断此Entry是否在“正确”的位置上,如果不是的话,从正确的位置向后查找到第一个为null的位置,将其放入。
前半部分解释起来很容易,毕竟为了垃圾回收,后面的部分又如何解释呢?其实这还是根据散列冲突这里采用了开放寻址法中的线性检测方法来解释:前面已经看到了,这里所有的遍历都是找到下一个为null的位置为止。那么假设当前无用的Entry在两个拥有相同k.threadLocalHashCode & (len - 1)值的ThreadLocal,那么将当前无用的设置为null之后,下一次查找的过程中遍历到这个空的位置后就不会进行下面的遍历,正确的结果没有找到,返回了null。为了解决这个问题,需要将key值不为null的Entry进行位置的重新排列,使得两个具有相同k.threadLocalHashCode & (len - 1)的中间没有空的值。
下面的图来解释下线性检测以及重新排列(主要是防止具有相同位置的ThreadLocal被空的Entry隔离,造成获取值为null):

手图

总结下get()方法:

  1. 对ThreadLocalthreadLocalHashCode进行值为len的取模运算,获得位置
  2. 如果该位置Entry的key就是当前的ThreadLocal,那么命中返回value;否则,向后遍历,直到下一个为null的位置,找到与当前key相同的Entry返回。
  3. 遍历过程中,如果遇到key为null的情况,那么将此处value设为null,Entry设为null。如果key不为null,但是位置不是“正确”的位置,那么将其移动到“正确”位置最近的一个为null的位置,进行排序。

set()方法分析

废话不多说,先看下set()方法:

public void set(T value) {
    Thread t = Thread.currentThread();
    ThreadLocalMap map = getMap(t);
    if (map != null)
        map.set(this, value);
    else
        createMap(t, value);
}

这一部分和get()差不多,也是对Map的判断,如果为null,则创建一个Map,没啥好说的。下面看下Map.set(key,value)方法:

private void set(ThreadLocal<?> key, Object value) {
    
    Entry[] tab = table;
    int len = tab.length;
    // 同样获取位置
    int i = key.threadLocalHashCode & (len-1);
    // 从这个位置开始遍历,直到下一个为null的位置
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        ThreadLocal<?> k = e.get();
        // key相同,替换value
        if (k == key) {
            e.value = value;
            return;
        }
        
        // 当前位置key为null了,说明key已经被回收,需要进行替换
        if (k == null) {
            replaceStaleEntry(key, value, i);
            return;
        }
    }
    
    // 1. key.threadLocalHashCode & (len-1)的位置没有值
    // 2. 有Entry但是key与当前ThreadLocal不相同,这里的i已经成为下一个为null的位置
    // 3. 直接创建Entry
    tab[i] = new Entry(key, value);
    int sz = ++size;
    // 启发式扫描部分位置寻找陈旧的条目&&当前size是否>=阈值
    if (!cleanSomeSlots(i, sz) && sz >= threshold)
        rehash();
}

set()方法最理想的肯定是计算出来位置并且该位置的Entry为null,此时可以直接为此位置赋值新的Entry。其他情况:

  1. 当前位置有Entry并且Entry的key与当前的ThreadLocal相同,将value赋值给Entry的value。
  2. 当前位置有Entry并且Entry的key与当前的ThreadLocal不相同,那么需要进行遍历,在遍历过程中如果有key为null,那么说明该key被回收了,需要进行替换操作(replaceStaleEntry后面会说)。
  3. 当前位置有Entry并且Entry的key与当前的ThreadLocal不相同,在下一个null的位置创建Entry对象。

按照顺序先看看cleanSomeSlots方法,方法注释写的是启发式扫描部分位置去寻找陈旧的条目,这里陈旧的条目也就是key为null的Entry,但是令我百思不得解的却是整个遍历过程是进行了log2(n)次,这种遍历过程能够全部找到吗?不得解。。

private boolean cleanSomeSlots(int i, int n) {
    boolean removed = false;
    Entry[] tab = table;
    int len = tab.length;
    do {
        // 向后遍历,需找Entry不为null并且key不为null或者Entry为null
        // 次数为log2(n)+1
        i = nextIndex(i, len);
        Entry e = tab[i];
        // 如果发现了key为null的Entry,那么需要擦除这个Entry
        // 调用expungeStaleEntry方法
        // 将n设为len,重新进行扫描
        if (e != null && e.get() == null) {
            n = len;
            removed = true;
            i = expungeStaleEntry(i);
        }
    } while ( (n >>>= 1) != 0);
    return removed;
}

扫描过程就这样,默认返回的是false,也就是说如果没有需要擦除的话才需要对size是否打到阈值进行判断,擦除了的话,size会相应减少,所以肯定不会打到阈值。紧接着看下rehash过程:

private void rehash() {
    // 这里是全部扫描判断是否有key为null的Entry,有的话擦除
    expungeStaleEntries();
    // 全部扫描后,size可能会减小,这里对其取threadold的3/4进行判断也就是整个数组长度的一半
    if (size >= threshold - threshold / 4)
        // 重新设置size,重新对位置进行排列
        resize();
}
// 全扫描,不多说
private void expungeStaleEntries() {
    Entry[] tab = table;
    int len = tab.length;
    for (int j = 0; j < len; j++) {
        Entry e = tab[j];
        if (e != null && e.get() == null)
            expungeStaleEntry(j);
    }
}

// 重新设置数组长度,长度为原来的2倍
private void resize() {
    Entry[] oldTab = table;
    int oldLen = oldTab.length;
    int newLen = oldLen * 2;
    Entry[] newTab = new Entry[newLen];
    int count = 0;
    // 需要重新进行k.threadLocalHashCode & (newLen - 1)操作,以便确定每个Entry在新的数组中的位置,同样使用线性检测法解决散列冲突。
    for (int j = 0; j < oldLen; ++j) {
        Entry e = oldTab[j];
        if (e != null) {
            ThreadLocal<?> k = e.get();
            if (k == null) {
                e.value = null; // Help the GC
            } else {
                int h = k.threadLocalHashCode & (newLen - 1);
                while (newTab[h] != null)
                    h = nextIndex(h, newLen);
                newTab[h] = e;
                count++;
            }
        }
    }
    // 重新设置阈值
    setThreshold(newLen);
    size = count;
    table = newTab;
}

rehash过程就相对比较简单,先进行一次全扫描,将所有Entry的key为null的擦除,全扫描后size可能会由于Entry的擦除减小,所以这里对阈值的3/4进行判断,>=这个值则进行扩容操作resize()resize()是将数组的长度设为原来长度的2倍,接着仍然进行k.threadLocalHashCode & (newLen - 1)操作,以便确定每个Entry在新的数组中的位置,同样使用线性检测法解决散列冲突。
扩容操作完成后,其实流程也就结束了。但是呢,分析的过程中漏掉了一部分replaceStaleEntry()。这部分是在设置值的过程中遇到了Entry的key为空的情况下进行的(set方法中有):

private void replaceStaleEntry(ThreadLocal<?> key, Object value,
                               int staleSlot) {
    Entry[] tab = table;
    int len = tab.length;
    Entry e;

    // 要擦除的位置
    int slotToExpunge = staleSlot;
    // 从当前的前一个位置开始向前遍历,直到为null的位置
    // 将查找key同样为null的值赋值给slotToExpunge
    for (int i = prevIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = prevIndex(i, len))
        if (e.get() == null)
            slotToExpunge = i;

    // 从当前的后一个位置开始向后遍历,直到为null的位置
    for (int i = nextIndex(staleSlot, len);
         (e = tab[i]) != null;
         i = nextIndex(i, len)) {
        ThreadLocal<?> k = e.get();
        
        // 向后遍历的过程中找到了与当前ThreadLocal相同key的Entry
        if (k == key) {
            // 将值赋给Entry的value
            e.value = value;
            // 将该位置和key为null的Entry交换
            tab[i] = tab[staleSlot];
            tab[staleSlot] = e;
            // 这里如果相同的话,此时要擦出的位置改为现在的位置i
            if (slotToExpunge == staleSlot)
                slotToExpunge = i;
            // 先去清理slotToExpunge位置的Entry,接着进行一次启发式扫描清理
            cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
            return;
        }

        // 如果key值为null,并且向前遍历没有发现key为null的位置
        if (k == null && slotToExpunge == staleSlot)
            // 设置擦除的位置为当前位置
            slotToExpunge = i;
    }
    
    // 没有找到相等的key,则将此处key为null的value设置为null,重新创建Entry,参数是当前的ThreadLocal和set的value。
    tab[staleSlot].value = null;
    tab[staleSlot] = new Entry(key, value);

    // 有新的要擦除的位置,说明还有其他的位置key也为null,需要进行启发式扫描清理
    if (slotToExpunge != staleSlot)
        cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}

整个替换过程都在代码中注释了,简单的讲就是向前遍历直到null的位置,其中如果找到有key为null的则更新slotToExpunge。紧接着向后遍历直到null位置,如果遇到key与当前的key相同的话,那么将值赋给此处,并将其与传入的key为空的位置交换。接着就会调用cleanSomeSlotsexpungeStaleEntry去清理。for循环完成后,那么说明向后遍历没有找到与当前ThreadLocal相同的key,那么需要将当前entry的value赋值为null(方便回收),接着以现在的ThreadLocal和value创建新的Entry。最后判断下是否有其他位置还有key为null,有的话则调用cleanSomeSlotsexpungeStaleEntry去清理。

remove()方法

移除方法东西很少,自己想想也应该知道做什么:

public void remove() {
    ThreadLocalMap m = getMap(Thread.currentThread());
    if (m != null)
        // 调用Map的remove方法
        m.remove(this);
}

private void remove(ThreadLocal<?> key) {
    Entry[] tab = table;
    int len = tab.length;
    // 计算位置
    int i = key.threadLocalHashCode & (len-1);
    // 向后遍历
    for (Entry e = tab[i];
         e != null;
         e = tab[i = nextIndex(i, len)]) {
        // 如果找到相同的话,移除
        if (e.get() == key) {
            e.clear();
            // 擦除
            expungeStaleEntry(i);
            return;
        }
    }
}

移除的方法就是将当前的ThreadLocal从Map中移除,移除过程和get()过程有些相似,如果“正确”位置找不到,那么向后查找,找到则移除,并且擦除此位置。

关于ThreadLocal的内存泄漏

讲了这么多,又是弱引用又是手动置为空的各种操作,那么使用ThreadLocal还会出现内存泄漏吗?可以肯定的说,会!从源码中可以知道,在get()/set()方法中都有可能去对key为null进行清理,但是这个过程是不确定的。也就是说,get()过程中如果没有散列冲突,那么直接返回找到的值并不会进行其他操作,也就没有了清理过程。
ThreadLocal的内存泄漏主要原因是:线程没有被销毁(线程池),并且最终没有调用remove方法,那么此时ThreadLocal可被回收,但是仍然存在当前线程对此弱引用和value的引用,造成内存泄漏
所以,在使用过程中要注意了,最好每次都调用remove以防万一*!

总结

关于ThreadLocal的理解,自己看的时间也挺久的。有时候真的很难理解这些大神的思想,我想过为什么不直接使用HashMap来实现这个过程呢?还需要自己实现散列冲突的解决,难道仅仅是为了效率?这些确实不解,那么就按照他们的思想一步步来吧!

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

推荐阅读更多精彩内容