6.1 (番外)深入源码理解HashMap、LinkedHashMap,DiskLruCache

6.1 (番外)深入源码理解HashMap、LinkedHashMap,DiskLruCache

我们看OkHttp的源码可以知道,他的缓存算法主要是用LruCache算法实现的,Lru的一个典型的实现就是LinedkHashMap,LinkedHashMap又是基于HashMap实现的,所以要探究他的原理,我们要从HashMap开始说起了

(前排出售香烟啤酒火腿肠。。。)

(本文用的是java7,java8 HashMap有部分改动)

HashMap

HashMap继承自AbstactMap,实现的是Map接口,Map的实现类有一下几种

Map
├Hashtable
├HashMap
└WeakHashMap

get知识点

HashMap 和 HashTable 的区别是什么

自行搜索去,我不说,有本事你们打我😈

laji.jpg

我们要看HashMap的话首先要看他的父类,我们点到AbstractMap<K,V>类,大概看一下 ,里面有两个内部类,

SimpleEntry SimpleImmutableEntry,他们两个都是实现了Entry 接口的,我们来看一下比较重要(常用)的几个方法

  1. 构造函数

    ​ 构造方法AbstractMap只有一种,,就是默认的 不过他设置为了protected。

  2. put

    ​ Put方法在AbstractMap中只定义了出来,具体实现给子类实现

  3. get

    ​ get方法在AbstractMap中是有实现的,我们都知道HashMap是支持null的 所以这边先做了一个空的判断,如果是null,那么找key为null的值,否则的话遍历寻找key相同的Entry返回,如果都没找到返回null

     public V get(Object key) {
            Iterator<Entry<K,V>> i = entrySet().iterator();
            if (key==null) {
                while (i.hasNext()) {
                    Entry<K,V> e = i.next();
                    if (e.getKey()==null)
                        return e.getValue();
                }
            } else {
                while (i.hasNext()) {
                    Entry<K,V> e = i.next();
                    if (key.equals(e.getKey()))
                        return e.getValue();
                }
            }
            return null;
        }
    

  4. remove

    ​ 这个remove和get的方法基本是一样的,最后的差别是多了一个返回当前移除的值

  5. containsKey

    和get差不多,,,自己瞅,

  6. containsValue

    同上

上面写的好轻松,不过下面就开始悲剧了,我们来看HashMap,在java上HashMap的默认大小是16,在安卓上是4

还有几个比较重要的参数来解释一下

参数名字 默认值 描述
DEFAULT_INITIAL_CAPACITY java 16,安卓 4 HashMap默认的大小
MAXIMUM_CAPACITY 1<<30 具体数字1073741824 HashMap的最大容量
DEFAULT_LOAD_FACTOR 0.75 加载因子( 加载因子是哈希表在其容量自动增加之前可以达到多满的一种尺度,它衡量的是一个散列表的空间的使用程度,负载因子越大表示散列表的装填程度越高,反之愈小

以上就是几个比价重要的参数,然后我们按照上面的来分析代码,首先看他的构造函数

HashMap()   
HashMap(int initialCapacity)
HashMap(int initialCapacity, float loadFactor)
HashMap(Map<? extends K, ? extends V> m) 

上面的构造函数,前两个都是调用的第三个,默认都是传的默认值,我们直接看第三个

/**
     * Constructs an empty <tt>HashMap</tt> with the specified initial
     * capacity and load factor.
     *
     * 构造具有指定的初始容量和负载因子的空HashMap。
     *
     * @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;
        threshold = initialCapacity;
        init();
    }
    
     void init() {
    }

这边就是对于默认的几个数值做了一下过滤与判断,init方法是个空方法,接着我们看常用的put和get方法

put(key,value)

   /**
     * An empty table instance to share when the table is not inflated.
     */
    static final Entry<?,?>[] EMPTY_TABLE = {};

    /**
     * The table, resized as necessary. Length MUST Always be a power of two.
     */
    transient Entry<K,V>[] table = (Entry<K,V>[]) EMPTY_TABLE;
 
 (省略 、、、、、、)
 
 public V put(K key, V value) {
        //如果table等于一个空table的话,初始化table,大小如果没有指定的话就是默认的大小
        if (table == EMPTY_TABLE) {
            inflateTable(threshold);
        }
        //如果是null键的话 ,添加null的字符串
        if (key == null)
            return putForNullKey(value);
        //获取key的hash值进行两次hash计算  
        int hash = hash(key);
        //根据hash的值进行寻址,返回的是下标
        int i = indexFor(hash, table.length);
        //如果第table里面的的entry不为空,然后根据下标和引用开始向散列表里放值
        for (Entry<K,V> e = table[i]; e != null; e = e.next) {
            Object k;
            //如果添加成功则返回
            if (e.hash == hash && ((k = e.key) == key || key.equals(k))) {
                V oldValue = e.value;
                e.value = value;
                //每当一个条目中的值被对于已经在HashMap中的密钥k调用put(k,v)覆盖时,这个方法被调用。例如linkedHashMap
                e.recordAccess(this);
                return oldValue;
            }
        }
        
        /**这里当他是当前索引的第一个,调用addEntry添加**/
        
        //模数加一
        modCount++;
        //添加到table上,如果大小不够的话会进行扩容,倍数是2的整数倍
        addEntry(hash, key, value, i);
        return null;
    }

把以上的步骤总结来说就是这样

  1. 如果table是空的话初始化table的大小为默认值
  2. 如果key是null的话处理null值的value,hash值为0,index也是0,添加之后return
  3. 如果key不为null的话,获取他的hash值,获得再次进行两次hash的hash值,与table的长度进行&操作获取下标
  4. 遍历table,如果当前的下边的entry不为null,并且key和hash相同,就进行覆盖操作
  5. 如果不是的话新建一个entry,添加到table下标为i的地方,原来下标上的entry做为entry的下个节点

整个步骤拿图来讲的话是这样

流程.png

如果是null的键的话他永远在table的第0个下标,如果存在就是覆盖掉原来的value, 这就是他put 操作 ,核心就在他的散列算法,如何让各个不同的键均匀的分布,不然就会造成一个index下的entry有很长,这样效率会被降低。

他的散列就是Hash算法

final int hash(Object k) {
        int h = hashSeed;
        //hashSeed只有在hash表的大与等于 Integer.MAX_VALUE或者是设置的系统变量jdk.map.althashing.threshold, 才不为0(好多博客对这块描述不多或者理解有误,以为key是String 直接就进来了)
        if (0 != h && k instanceof String) {
            return sun.misc.Hashing.stringHash32((String) k);
        }

        h ^= k.hashCode();

        // This function ensures that hashCodes that differ only by
        // constant multiples at each bit position have a bounded
        // number of collisions (approximately 8 at default load factor).
        //此功能可确保每个位位置上不同倍数不同的hashCode具有有界数量的冲突(默认加载因子约为8)。
        h ^= (h >>> 20) ^ (h >>> 12);
        return h ^ (h >>> 7) ^ (h >>> 4);
    }

里面的hashSeed正常情况下都是0,非0的情况是你的table size大于等于设置的系统变量jdk.map.althashing.threshold 或者 Integer.MAX_VALU,一般都不会碰到这种情况

后面的hash看起来就关系就简单多了。获取key的hashCode,因为是native方法 看不到。h默认是0,进行的是一个^(异或)运算,然后下面的就是对h一顿无符号位移操作,进行了两次,确保他的不同。

接着的一个算法是寻找合适的下标的算法,indexFor

    static int indexFor(int h, int length) {
        // assert Integer.bitCount(length) == 1 : "length must be a non-zero power of 2";
        return h & (length-1);
    }

这不用多讲,一个与运算,基本put方法重要且难理解的都在这边讲解了,下面讲get方法

get

get的代吗还是比较少比较好理解的

public V get(Object key) {
        //null key 获取null key的值 table的第0位 寻找key为null的
        if (key == null)
            return getForNullKey();
        //和上面的套路差不多,通过调用hash 和 indexFor的方法找到他的下标,在下标里面遍历寻找到他的值
        Entry<K,V> entry = getEntry(key);
        //null的话返回null
        return null == entry ? null : entry.getValue();
    }
    
    -----  我是一个漂亮的分割线  ------
    //上面方法用用到的方法 
     
    //找null key
     private V getForNullKey() {
        if (size == 0) {
            return null;
        }
        for (Entry<K,V> e = table[0]; e != null; e = e.next) {
            if (e.key == null)
                return e.value;
        }
        return null;
    }
    //找非null key
     final Entry<K,V> getEntry(Object key) {
        if (size == 0) {
            return null;
        }

        int hash = (key == null) ? 0 : hash(key);
        for (Entry<K,V> e = table[indexFor(hash, table.length)];
             e != null;
             e = e.next) {
            Object k;
            if (e.hash == hash &&
                ((k = e.key) == key || (key != null && key.equals(k))))
                return e;
        }
        return null;
    }

其他几个方法我就不起一一分析了,基本上就是根据getEntry()来进行判断的 ,没办法 谁让我这么懒。。

HashMap看完之后我们来看LinkedHashMap,就轻松多了

LinkedHashMap

LinkedHashMap继承自HashMap,我们按照一样的套路去看LinkedHashMap,发现他的构造函数和HashMap的构造函数是一样的,不同的地方是多了一个accessOrder,acessOrder上面的描述是

该链接哈希映射的迭代排序方法:对于访问顺序为true,对于插入顺序为false。

这样的操作像不像LRU,不过默认是按照插入顺序排序的,也就是默认值是false

init方法里,初始化了header,是个Entry,继承自HashMap的Entry,是个双向链表,里面包含他的前一个节点和后一个节点

private static class Entry<K,V> extends HashMap.Entry<K,V> {
        // These fields comprise the doubly linked list used for iteration.
        Entry<K,V> before, after;

        Entry(int hash, K key, V value, HashMap.Entry<K,V> next) {
            super(hash, key, value, next);
        }
        
        (省略 添加和删除部分。。。。)
        
        //这个方法在HashMap中put操作的时候有被调用过,我们发现LinkedHashMap没有重写put方法,所以我们就分析这个方法,这个方法就是在插入的时候记录他的插入顺序(心机boy,藏得这么深,搞的这么巧妙)
         void recordAccess(HashMap<K,V> m) {
            LinkedHashMap<K,V> lm = (LinkedHashMap<K,V>)m;
            if (lm.accessOrder) {
                lm.modCount++;
                remove();
                addBefore(lm.header);
            }
        }
}

用图片表示的话就是这样

LinkedHashMap.png

他这边记录了两份,一份是在Hash表里的,一份是双向链表,如果accessOrder为true的话,每次都会把最近调用的放到链表的最后方,他调用的地方有两处

  1. get方法
  2. HashMap中put的方法(覆盖)

我们来运行个例子


        LinkedHashMap linedHashMap = new LinkedHashMap(16,0.75f,true);
        linedHashMap.put("key1","test1");
        linedHashMap.put("key2","test2");
        linedHashMap.put("key3","test3");
        linedHashMap.put("key4","test4");
        linedHashMap.put("key5","test5");

        for (Iterator<String> iterator = linedHashMap.values().iterator(); iterator
                   .hasNext();) {
                   String name = (String) iterator.next();
                   System.out.println(name);
        }

link1.png

当我们进行一些操作的时候


        LinkedHashMap linedHashMap = new LinkedHashMap(16,0.75f,true);
        linedHashMap.put("key1","test1");
        linedHashMap.put("key2","test2");
        linedHashMap.put("key3","test3");
        linedHashMap.put("key4","test4");
        linedHashMap.put("key5","test5");

        linedHashMap.get("key1");
        linedHashMap.get("key3");

        for (Iterator<String> iterator = linedHashMap.values().iterator(); iterator
                   .hasNext();) {
                   String name = (String) iterator.next();
                   System.out.println(name);
        }


link.png

我们发现, 最近使用的就排到了这个链表的最后面,nice,知道了这个原理之后,感觉自己都能写一个LRUcache算法了是不是

有没有很开心很激动?

别着急,,接着看

DiskLruCache

Jake Wharton的微笑镇楼

DisLruCache 这边我们拿JW大神的来讲,OkHttp里面的是被改造过的 ,会有点小的不同,不过问题不大,还有就是 ,我们是按照大体的流程来梳理一下,具体的细节就要考大家去自己看源码了。

我们的测试程序如下

public class TestMain {

    private static final long cacheSize = 1024 * 1024 * 20;//缓存文件最大限制大小20M
    private static String cachedirectory = "/Users/Mirsfang/Desktop" + "/caches";  //设置缓存文件路径,自己设置


    public static void main(String[] args) {
        testWirteDiskLruCache();
    }
    
    //写入
    private static void testWirteDiskLruCache() {

        DiskLruCache diskCache = null;
        try {
            diskCache = DiskLruCache.open(new File(cachedirectory), 10010, 1, cacheSize);

            String urlKey = "test_request1_1";
            String testJson = "{\"result\":{\"errmsg\":\"令牌失效\",\"errcode\":\"101\"}}";

            try {
                DiskLruCache.Editor editor = diskCache.edit(urlKey);

                OutputStream outPutStream = editor.newOutputStream(0);
                OutputStreamWriter steamWriter = new OutputStreamWriter(outPutStream);
                steamWriter.write(testJson);
                steamWriter.close();
                outPutStream.flush();
                outPutStream.close();

                editor.commit();

                diskCache.flush();
                diskCache.close();
            } catch (IOException e) {
                e.printStackTrace();
            } finally {

            }


        } catch (IOException e) {
            e.printStackTrace();
        }
    }
    //读取
    private static void testGetDiskLruCache() {
           DiskLruCache diskCache = null;
        StringBuilder sb = new StringBuilder();
        try {
            diskCache = DiskLruCache.open(new File(cachedirectory), 10010, 1, cacheSize);

            String urlKey = "test_request1_1";

            try {
                DiskLruCache.Snapshot snapshot = diskCache.get(urlKey);

                String inputSteam = snapshot.getString(0);


                diskCache.flush();
                diskCache.close();

                System.out.print("得到文本: "+inputSteam);
            } catch (IOException e) {
                e.printStackTrace();
            } finally {

            }


        } catch (IOException e) {
            e.printStackTrace();
        }
    }


}

首先还是从构建方法搞起,构建方法是不对外公开的 只能通过open去构建

/**
* 传入的参数
* @param directory  可写入的目录
* @param valueCount 每个缓存条目的值的数量。 必须是正的。
* @param maxSize 该缓存应用于存储的最大字节数
* @throws IOException if reading or writing the cache directory fails
*
**/

 public static DiskLruCache open(File directory, int appVersion, int valueCount, long maxSize)
      throws IOException {
    if (maxSize <= 0) {
      throw new IllegalArgumentException("maxSize <= 0");
    }
    if (valueCount <= 0) {
      throw new IllegalArgumentException("valueCount <= 0");
    }

    // If a bkp file exists, use it instead.如果存在bkp文件,请改用它。
    File backupFile = new File(directory, JOURNAL_FILE_BACKUP);
    if (backupFile.exists()) {
      File journalFile = new File(directory, JOURNAL_FILE);
      // If journal file also exists just delete backup file.
      // 如果日记文件也存在只是删除备份文件。
      if (journalFile.exists()) {
        backupFile.delete();
      } else {
        renameTo(backupFile, journalFile, false);
      }
    }

    // 初始化DisLruCache ,如果日志文件存在,,那么就用原来的
    DiskLruCache cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
    if (cache.journalFile.exists()) {
      try {
        cache.readJournal();
        cache.processJournal();
        return cache;
      } catch (IOException journalIsCorrupt) {
        System.out
            .println("DiskLruCache "
                + directory
                + " is corrupt: "
                + journalIsCorrupt.getMessage()
                + ", removing");
        cache.delete();
      }
    }

    //否则的话重新创建
    directory.mkdirs();
    cache = new DiskLruCache(directory, appVersion, valueCount, maxSize);
    //构建日志文件,写入日志的头
    cache.rebuildJournal();
    return cache;
  }

然后是edit(key)方法,构建一个DiskLruCache.Editor

public Editor edit(String key) throws IOException {
    return edit(key, ANY_SEQUENCE_NUMBER);
  }

  private synchronized Editor edit(String key, long expectedSequenceNumber) throws IOException {
    //判断journalWriter是否关闭
    checkNotClosed();
    //验证key是否合法(正则验证)
    validateKey(key);
    //从linkedHashMap中检查一下是否存在Entry
    Entry entry = lruEntries.get(key);
    
    //如果缓存是无效的,返回null
    if (expectedSequenceNumber != ANY_SEQUENCE_NUMBER && (entry == null
        || entry.sequenceNumber != expectedSequenceNumber)) {
      return null; // Snapshot is stale.
    }
    
    //如果entry为空,新new一个entry,添加到linkedHashMap中
    if (entry == null) {
      entry = new Entry(key);
      lruEntries.put(key, entry);
    } else if (entry.currentEditor != null) {
      return null; // Another edit is in progress.
    }
    
    //新建一个editor对象
    Editor editor = new Editor(entry);
    entry.currentEditor = editor;

    // 写入日志文件记录
    journalWriter.write(DIRTY + ' ' + key + '\n');
    journalWriter.flush();
    return editor;
  }

我们发现,基本的写入操作都是由Editor的newOutputStream的这个Steam完成的,我们去看那个方法

 public OutputStream newOutputStream(int index) throws IOException {
      //进行下标判断,是否合法
      if (index < 0 || index >= valueCount) {
        throw new IllegalArgumentException("Expected index " + index + " to "
                + "be greater than 0 and less than the maximum value count "
                + "of " + valueCount);
      }
      //为避免线程问题
      synchronized (DiskLruCache.this) {
        //看获取的entry的editor和当前的是否一直
        if (entry.currentEditor != this) {
          throw new IllegalStateException();
        }
        //如果readable是fasle,written[]这个数组index改为true,这个是为了兼容一个key对应多个的问题
        if (!entry.readable) {
          written[index] = true;
        }
        //根据index和entry,获取对应的缓存文件
        File dirtyFile = entry.getDirtyFile(index);
        FileOutputStream outputStream;
        try {
          //打开输入管道
          outputStream = new FileOutputStream(dirtyFile);
        } catch (FileNotFoundException e) {
          // Attempt to recreate the cache directory.
          //遇见异常失败的话尝试重新创建缓存目录。
          directory.mkdirs();
          try {
            outputStream = new FileOutputStream(dirtyFile);
          } catch (FileNotFoundException e2) {
            // We are unable to recover. Silently eat the writes.
            return NULL_OUTPUT_STREAM;
          }
        }
        //返回输出管道
        return new FaultHidingOutputStream(outputStream);
      }
    }

这样通过outputStream就写入到文件里了FaultHidingOutputStream是一个继承FilterOutputStream的包装类,对一些错误进行了标记,这个大概就是写入的这个流程,其实DiskLruCache主要的是get方法,看如何计数操作

get方法

public synchronized Snapshot get(String key) throws IOException {
    //检查文件流是否关闭
    checkNotClosed();
    //检查key的有效性
    validateKey(key);
    //通过LinkedHashMap中获取Entry
    Entry entry = lruEntries.get(key);
    
    //判断entry的合法性
    if (entry == null) {
      return null;
    }

    if (!entry.readable) {
      return null;
    }

    // Open all streams eagerly to guarantee that we see a single published
    // snapshot. If we opened streams lazily then the streams could come
    // from different edits.
    //积极地打开所有流,以保证我们看到一个已发布的snapshot。 如果我们懒加载的方式打开流,那么流可以来自不同的edits。
    //
    InputStream[] ins = new InputStream[valueCount];
    try {
      for (int i = 0; i < valueCount; i++) {
        ins[i] = new FileInputStream(entry.getCleanFile(i));
      }
    } catch (FileNotFoundException e) {
      // A file must have been deleted manually!
      for (int i = 0; i < valueCount; i++) {
        if (ins[i] != null) {
          Util.closeQuietly(ins[i]);
        } else {
          break;
        }
      }
      return null;
    }

    redundantOpCount++;
    journalWriter.append(READ + ' ' + key + '\n');
    //检测是否到达清理标准
    if (journalRebuildRequired()) {
      //开启线程进行清理
      executorService.submit(cleanupCallable);
    }

    return new Snapshot(key, entry.sequenceNumber, ins, entry.lengths);
  }

这里我们看到他是从LinkedHashMap中获取Entry的,后面,然后进行计数和日志的写入,最后的这个

if (journalRebuildRequired()) {
      //开启线程进行清理
      executorService.submit(cleanupCallable);
    }

是重点,他负责打开清理线程, 这是DiskLruCache的核心,为什么DiskLruCache会清理,我们来看这个线程

  private final Callable<Void> cleanupCallable = new Callable<Void>() {
    public Void call() throws Exception {
      synchronized (DiskLruCache.this) {
        //如果日志写入为空,说明现在的状态是异常的
        if (journalWriter == null) {
          return null; // Closed.
        }
        //清理垃圾缓存
        trimToSize();
        if (journalRebuildRequired()) {
        //我们只能重新建立这个日志的时候,它会缩小日志的大小,并且至少消除2000个记录。
          rebuildJournal();
          redundantOpCount = 0;
        }
      }
      return null;
    }
  };
  
  //垃圾清除,当size比最大的size大的时候,从不活跃的开始清除
  private void trimToSize() throws IOException {
    while (size > maxSize) {
      Map.Entry<String, Entry> toEvict = lruEntries.entrySet().iterator().next();
      remove(toEvict.getKey());
    }
  }
  
  

我们查看这个trimToSize()的调用的地方有

  1. flush()
  2. close()

调用清理线程的地方有

  1. get()
  2. setMaxSize()
  3. completeEdit()

可以发现这几个点 存数据,大小改变,获取数据,关闭 这几个地方都会去检测是否去清理垃圾。

大概主要的流程就是这样,DiskLruCache 其中比较细节的地方就需要自己去看了,这边就不细讲了,有什么问题的话可以进群交流 群号 579508560

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

推荐阅读更多精彩内容

  • 一、基本数据类型 注释 单行注释:// 区域注释:/* */ 文档注释:/** */ 数值 对于byte类型而言...
    龙猫小爷阅读 4,254评论 0 16
  • 实际上,HashSet 和 HashMap 之间有很多相似之处,对于 HashSet 而言,系统采用 Hash 算...
    曹振华阅读 2,508评论 1 37
  • 小的时候,父亲教育我们最常用的一句话就是:学习做事最忌三分钟热度。想想这么些年来,我所做的事,好像只有读书和做早餐...
    栾晓君阅读 1,047评论 14 38
  • 1.且歌且笑且珍惜,渐行渐远渐生疏。 2.我走过许多地方的路,行过许多地方的桥,看过许多次数的云,喝过许多种类的酒...
    xf特立独行的猫阅读 716评论 3 10
  • 我似乎一直没有停止这个爱读书的习惯。听我妈说,我很小的时候已经自己抓着小书静静地坐在小板凳上看。 在还不知道当当网...
    charr小梦阅读 180评论 0 2