深入剖析Mybatis缓存机制

哈哈哈,终于考完试了,用了大概两天时间肝了这篇文章!!!
关于今天要讲的mybatis缓存机制,其实之前我已经有看过也用过,只不过平常不太留意,最近在看mybatis源码,就来讲一下这个缓存机制

前言

​ 本次分析的代码和数据表在gitee上,地址:https://gitee.com/professor_mai/mybatis_cache_demo

​ 关于这个Mybatis缓存,推荐这篇文章 https://tech.meituan.com/2018/01/19/mybatis-cache.html,下面的内容是基于这篇文章来写的,我写的内容是更偏重于原理级别的。

再次提醒,一定要把上面推荐的文章再来看下面的内容,不然你会很懵逼

一级缓存

​ 在进行数据库查询之前,MyBatis 首先会检查以及缓存中是否有相应的记录,若有的话直接返回即可。一级缓存是数据库的最后一道防护,若一级缓存未命中,查询请求将落到数据库上。一级缓存是在 BaseExecutor 被初始化的。

实验一:开启一级缓存,调用三次相同的查询操作

​ 通过上面文章大致了解了一级缓存后(再次提醒,一定要看上面推荐的文章),可以看看查询一级缓存的逻辑。image-20200715214953850

​ 经过上图的调用之后,最终是在BaseExecutorquery方法上执行。

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    BoundSql boundSql = ms.getBoundSql(parameter);
    // 创建 CacheKey
    CacheKey key = createCacheKey(ms, parameter, rowBounds, boundSql);
    return query(ms, parameter, rowBounds, resultHandler, key, boundSql);
}

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    // 省略部分代码
    
    List<E> list;
    try {
        queryStack++;
        // 查询一级缓存
        list = resultHandler == null ? (List<E>) localCache.getObject(key) : null;
        if (list != null) {
            // 存储过程相关逻辑,忽略
            handleLocallyCachedOutputParameters(ms, key, parameter, boundSql);
        } else {
            // 缓存未命中,则从数据库中查询
            list = queryFromDatabase(ms, parameter, rowBounds, resultHandler, key, boundSql);
        }
    } finally {
        queryStack--;
    }
    
    // 省略部分代码
    
    return list;
}

​ 如上的代码,就是查询一级缓存的实质,我们再一步一步细分一下

​ MyBatis 首先会调用 createCacheKey 方法创建 CacheKey,我们可以简单的把 CacheKey 看做是一个查询请求的 id

public CacheKey createCacheKey(MappedStatement ms, Object parameterObject, RowBounds rowBounds, BoundSql boundSql) {
    if (closed) {
        throw new ExecutorException("Executor was closed.");
    }
    // 创建 CacheKey 对象
    CacheKey cacheKey = new CacheKey();
    // 将 MappedStatement 的 id 作为影响因子进行计算
    cacheKey.update(ms.getId());
	// RowBounds 用于分页查询,下面将它的两个字段作为影响因子进行计算
    cacheKey.update(rowBounds.getOffset());
    cacheKey.update(rowBounds.getLimit());
    // 获取 sql 语句,并进行计算
    cacheKey.update(boundSql.getSql());
    List<ParameterMapping> parameterMappings = boundSql.getParameterMappings();
    TypeHandlerRegistry typeHandlerRegistry = ms.getConfiguration().getTypeHandlerRegistry();
    for (ParameterMapping parameterMapping : parameterMappings) {
        if (parameterMapping.getMode() != ParameterMode.OUT) {
            Object value;    // 运行时参数
            // 当前大段代码用于获取 SQL 中的占位符 #{xxx} 对应的运行时参数,
            // 前文有类似分析,这里忽略了
            String propertyName = parameterMapping.getProperty();
            if (boundSql.hasAdditionalParameter(propertyName)) {
                value = boundSql.getAdditionalParameter(propertyName);
            } else if (parameterObject == null) {
                value = null;
            } else if (typeHandlerRegistry.hasTypeHandler(parameterObject.getClass())) {
                value = parameterObject;
            } else {
                MetaObject metaObject = configuration.newMetaObject(parameterObject);
                value = metaObject.getValue(propertyName);
            }
            
            // 让运行时参数参与计算
            cacheKey.update(value);
        }
    }
    if (configuration.getEnvironment() != null) {
        // 获取 Environment id 遍历,并让其参与计算
        cacheKey.update(configuration.getEnvironment().getId());
    }
    return cacheKey;
}

​ 在上面代码中,若一级缓存为命中(很明显在我们的实验中,这个实验是参考我们上面这个文章的),BaseExecutor 会调用 queryFromDatabase 查询数据库,并将查询结果写入缓存中。下面看一下 queryFromDatabase 的逻辑。

private <E> List<E> queryFromDatabase(MappedStatement ms, Object parameter, RowBounds rowBounds,ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    List<E> list;
    // 向缓存中存储一个占位符
    localCache.putObject(key, EXECUTION_PLACEHOLDER);
    try {
        // 查询数据库
        list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
    } finally {
        // 移除占位符
        localCache.removeObject(key);
    }
    // 存储查询结果
    localCache.putObject(key, list);
    
    // 存储过程相关逻辑,忽略
    if (ms.getStatementType() == StatementType.CALLABLE) {
        localOutputParameterCache.putObject(key, parameter);
    }
    return list;
}

​ 那执行完之后,就已经存进这个localcache变量里面了,下次我们只需要直接getobject拿就行了。

所以就可以一级缓存的总结如下图整体过程

实验二:增加了对数据库的修改操作,验证在一次数据库会话中,如果对数据库发生了修改操作,一级缓存是否会失效

这里重点说一下,为什么一级缓存会失效:

​ 缓存究竟是在哪里拿的?

上面我们说过是从localCache.getObject中拿的,我们再往深一层,会发现调用的是PerpetualCache #getObject方法。image-20200715222213740

我们可以再看看,插入数据后的localCache发生了什么变化,这是插入前的image-20200715222538121

插入后,再次查询会发现localCache已经没有了,因为其最大的共享范围就是一个 SqlSession 内部image-20200715222636446

关于SqlSession,可以简单看看这个图流程图

也可以简单看一下这个调用栈,Executor是不直接暴露接口的,是通过Sqlsession接口的。image-20200715224236031

可能到这里你还是有点懵,这里再来简单说一下吧,先简单看看这个调用栈image-20200716082715542

insert语句会调用BaseExecutor#update方法,有朋友就很奇怪,为什么insert语句是调用update方法呢?为什么不是调用insert方法呢?这里简单提一下,答案是:只提供一个 update 方法从实现上完全可行,但是从接口的语义化的角度来说,这样做并不好。一般情况下,使用者觉得 update 接口方法应该仅负责执行 UPDATE 语句,如果它还兼职执行其他的 SQL 语句,会让使用者产生疑惑。对于对外的接口,接口功能越单一,语义越清晰越好。在日常开发中,我们为客户端提供接口时,也应该这样做。

接下来就来看看这个update方法做了什么操作

@Override
public int update(MappedStatement ms, Object parameter) throws SQLException {
  ErrorContext.instance().resource(ms.getResource()).activity("executing an update").object(ms.getId());
  if (closed) {
    throw new ExecutorException("Executor was closed.");
  }
    // 刷新一级缓存(看到方法名就知道是清空缓存了)
  clearLocalCache();
  return doUpdate(ms, parameter);
}

@Override
  public void clearLocalCache() {
    if (!closed) {
      localCache.clear();
      localOutputParameterCache.clear();
    }
  }
// 其实在这之前还会先调用CachingExecutor#update方法,看上面调用栈就知道了
public int update(MappedStatement ms, Object parameterObject) throws SQLException {
    // 刷新二级缓存(这里也会清除掉二级缓存)
    flushCacheIfRequired(ms);
    return delegate.update(ms, parameterObject);
}
private void flushCacheIfRequired(MappedStatement ms) {
    Cache cache = ms.getCache();
    if (cache != null && ms.isFlushCacheRequired()) {      
      tcm.clear(cache);
    }
  }

后面会出一篇Mybatis的执行Sql命令的完整流程,估计看完你就不懵了,敬请期待

实验三:开启两个SqlSession,在sqlSession1中查询数据,使一级缓存生效,在sqlSession2中更新数据库,验证一级缓存只在数据库会话内部共享。

通过调试,其实这两个sqlSession是两个不同的sqlSession更新语句,只能清楚sqlSession2的缓存,并不能清除sqlSession1的缓存,所以会出现脏读,所以一级缓存只在数据库会话内部共享。

image-20200716084952601

总结

  1. MyBatis一级缓存的生命周期和SqlSession一致。
  2. MyBatis一级缓存内部设计简单,只是一个没有容量限定的HashMap,在缓存的功能性上有所欠缺。
  3. MyBatis的一级缓存最大范围是SqlSession内部,有多个SqlSession或者分布式的环境下,数据库写操作会引起脏数据,建议设定缓存级别为Statement。这里再来简单看看Statement,STATEMENT级别是一种缓存级别,可以理解为缓存只对当前执行的这一个Statement有效,可以看看BaseExecutor#query方法最后会用到这个判断来判断是不是STATEMENT如果是就会清空缓存!image-20200716085950395
  4. 我们也可以见到那看一下上面的脏读现象使用STATEMENT级别之后,有没有会出现,很明显是不会出现的,同时,一级缓存也失效了!image-20200716091508399

二级缓存

​ 在上文中提到的一级缓存中,其最大的共享范围就是一个 SqlSession 内部,如果多个 SqlSession 之间需要共享缓存,则需要使用到二级缓存。开启二级缓存后,会使用 CachingExecutor 装饰 Executor ,进入一级缓存的查询流程前,先在 CachingExecutor 进行二级缓存的查询,具体的工作流程如下所示。大体流程

​ 二级缓存开启后,同一个namespace下的所有操作语句,都影响着同一个Cache,即二级缓存被多个SqlSession共享,是一个全局的变量。

当开启缓存后,数据的查询执行的流程就是 二级缓存 -> 一级缓存 -> 数据库。

实验一:测试二级缓存效果,不提交事务,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据。

在讲结论之前,先简单介绍一下,支持二级缓存的 Executor 的实现类CachingExecutor


private Executor delegate;
// TransactionalCacheManager 对象,支持事务的缓存管理器。因为二级缓存是支持跨 Session 进行共享,此处需要考虑事务,那么,必然需要做到事务提交时,才将当前事务中查询时产生的缓存,同步到二级缓存中。这个功能,就通过 TransactionalCacheManager 来实现。
private TransactionalCacheManager tcm = new TransactionalCacheManager();

public CachingExecutor(Executor delegate) {
  this.delegate = delegate;
  delegate.setExecutorWrapper(this);
}

//里面有个query方法,重点
// CachingExecutor.java
@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler) throws SQLException {
    // 获得 BoundSql 对象
    BoundSql boundSql = ms.getBoundSql(parameterObject);
    // 创建 CacheKey 对象
    CacheKey key = createCacheKey(ms, parameterObject, rowBounds, boundSql);
    // 查询
    return query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

@Override
public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
        throws SQLException {
    // <1> 调用 MappedStatement#getCache() 方法,获得 Cache 对象,即当前 MappedStatement 对象的二级缓存。
    Cache cache = ms.getCache();
    if (cache != null) { // <2>  如果有 Cache 对象,说明该 MappedStatement 对象,有设置二级缓存
        // <2.1> 如果需要清空缓存,则进行清空,注意这里清空缓存,只是清空未提交事务之前的缓存,而真正的清空,在事务的提交时。这是为什么呢?还是因为二级缓存是跨 Session 共享缓存,在事务尚未结束时,不能对二级缓存做任何修改
        flushCacheIfRequired(ms);
        if (ms.isUseCache() && resultHandler == null) { // <2.2>
            // 暂时忽略,存储过程相关
            ensureNoOutParams(ms, boundSql);
            @SuppressWarnings("unchecked")
            // <2.3> 从二级缓存中,获取结果
            List<E> list = (List<E>) tcm.getObject(cache, key);
            if (list == null) {
                // <2.4.1> 如果不存在,则从数据库中查询
                list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
                // <2.4.2> 缓存结果到二级缓存中
                tcm.putObject(cache, key, list); // issue #578 and #116
            }
            // <2.5> 如果存在,则直接返回结果
            return list;
        }
    }
    // <3> 不使用缓存,则从数据库中查询,如果没有 Cache 对象,说明该 MappedStatement 对象,未设置二级缓存,则调用 delegate 属性的 #query方法,直接从数据库中查询
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

再看看TransactionalCacheManagerTransactionalCache 管理器

// TransactionalCacheManager.java
/** * Cache 和 TransactionalCache 的映射 */
private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();

这个类的主要作用如上图所示,就是要维护Cache 和TransactionalCache的关系

TransactionalCache 是怎么创建的呢?答案在 #getTransactionalCache(Cache cache) 方法,代码如下:

// TransactionalCacheManager.java
private TransactionalCache getTransactionalCache(Cache cache) {   
    return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
}
  • 优先,从 transactionalCaches 获得 Cache 对象,对应的 TransactionalCache 对象。
  • 如果不存在,则创建一个 TransactionalCache 对象,并添加到 transactionalCaches 中。

#commit() 方法,提交所有 TransactionalCache 。代码如下:

// TransactionalCacheManager.java

public void commit() {
    for (TransactionalCache txCache : transactionalCaches.values()) {
        txCache.commit();
    }
}
  • 通过调用该方法,TransactionalCache 存储的当前事务的缓存,会同步到其对应的 Cache 对象。

再来看看上面提到的TransactionalCache

// TransactionalCache.java

/**
 * 委托的 Cache 对象。
 *
 * 实际上,就是二级缓存 Cache 对象。
 */
private final Cache delegate;
/**
 * 提交时,清空 {@link #delegate}
 *
 * 初始时,该值为 false
 * 清理后{@link #clear()} 时,该值为 true ,表示持续处于清空状态
 */
private boolean clearOnCommit;
/**
 * 待提交的 KV 映射,在事务被提交前,所有从数据库中查询的结果将缓存在此集合中
 */
private final Map<Object, Object> entriesToAddOnCommit;
/**
 * 查找不到的 KEY 集合, 在事务被提交前,当缓存未命中时,CacheKey 将会被存储在此集合中
 */
private final Set<Object> entriesMissedInCache;

public TransactionalCache(Cache delegate) {
    this.delegate = delegate;
    this.clearOnCommit = false;
    this.entriesToAddOnCommit = new HashMap<>();
    this.entriesMissedInCache = new HashSet<>();
}

@Override
public Object getObject(Object key) {
    // <1> 从 delegate 中获取 key 对应的 value
    Object object = delegate.getObject(key);
    // <2> 如果不存在,则添加到 entriesMissedInCache 中,这个操作真的是神操作啊!!!!,看看下面的commit() 和 rollback() 方法
    if (object == null) {
        entriesMissedInCache.add(key);
    }
    // <3> 如果 clearOnCommit 为 true ,表示处于持续清空状态,则返回 null
    if (clearOnCommit) {
        return null;
    // <4> 返回 value
    } else {
        return object;
    }
}

public void commit() {
    // <1> 如果 clearOnCommit 为 true ,则清空 delegate 缓存
    if (clearOnCommit) {
        delegate.clear();
    }
    // 将 entriesToAddOnCommit、entriesMissedInCache 刷入 delegate 中
    // 调用 flushPendingEntries() 方法,将 entriesToAddOnCommit、entriesMissedInCache 同步到 delegate 中
    flushPendingEntries();
    // 重置
    reset();
}

private void flushPendingEntries() {
    // 将 entriesToAddOnCommit 刷入 delegate 中
    for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
        delegate.putObject(entry.getKey(), entry.getValue());
    }
    // 将 entriesMissedInCache 刷入 delegate 中
    for (Object entry : entriesMissedInCache) {
        if (!entriesToAddOnCommit.containsKey(entry)) {
            delegate.putObject(entry, null);
        }
    }
}

public void rollback() {
    // <1> 从 delegate 移除出 entriesMissedInCache
    unlockMissedEntries();
    // <2> 重置
    reset();
}

大致介绍完了上面的类,可以先说一下结论了当sqlsession没有调用commit()方法时,二级缓存并没有起到作用,我们先看看下面的提交了事务之后为什么可以查到缓存说起吧。

看完下面的内容,应该这里就很容易理解了,也就是未提交之前,查询会存在entriesMissedInCache或者entriesToAddOnCommit上,还没有刷回去delegate集合 ,所以就查不到缓存了!!

​ 其实,这里我一直有个疑惑,为什么还要这么麻烦,提交事务之后又要刷一次缓存,为什么不直接用这个entriesMissedInCache或者entriesToAddOnCommit上的缓存呢?

​ 这里参考了一下别人的博客的这张图最终是明白了,这里我把这张图放出来,相信你也会明白img

这里如果直接用的话,像上面那样子就会出现脏数据问题。

而把缓存刷回去的话就像下面那样解决了脏读问题img

但需要注意的时,MyBatis 缓存事务机制只能解决脏读问题,并不能解决“不可重复读”问题。再回到上图,事务 B 在被提交前进行了三次查询。前两次查询得到的结果为记录 A,最后一次查询得到的结果为 A′。最有一次的查询结果与前两次不同,这就会导致“不可重复读”的问题。MyBatis 的缓存事务机制最高只支持“读已提交”,并不能解决“不可重复读”问题。即使数据库使用了更高的隔离级别解决了这个问题,但因 MyBatis 缓存事务机制级别较低。此时仍然会导致“不可重复读”问题的发生,这个在日常开发中需要注意一下。

-from 田小波的博客

测试二级缓存效果,当提交事务时,sqlSession1查询完数据后,sqlSession2相同的查询是否会从缓存中获取数据。

sqlSession1.close();实验代码中,有这个代码是代表关闭这个Session,然后就会自动提交事务

下面主要分析一下这个方法的调用栈

image-20200716105450866

// DefaultSqlSession
@Override
public void close() {
  try {
      // 进去这个方法
    executor.close(isCommitOrRollbackRequired(false));
    closeCursors();
    dirty = false;
  } finally {
    ErrorContext.instance().reset();
  }
}

  @Override
  public void close(boolean forceRollback) {
    try {
      //issues #499, #524 and #573
        // 会先判断是不是会滚操作
      if (forceRollback) { 
        tcm.rollback();
      } else {
          // 提交操作
        tcm.commit();
      }
    } finally {
      delegate.close(forceRollback);
    }
  }

// 本质:又到了这里了,下面就不用说了,就是把那些缓存刷进去
public void commit() {
    // <1> 如果 clearOnCommit 为 true ,则清空 delegate 缓存
    if (clearOnCommit) {
        delegate.clear();
    }
    // 将 entriesToAddOnCommit、entriesMissedInCache 刷入 delegate 中
    // 调用 flushPendingEntries() 方法,将 entriesToAddOnCommit、entriesMissedInCache 同步到 delegate 中
    flushPendingEntries();
    // 重置
    reset();
}

然后,我们可以看是否可以查询二级缓存成功,结果肯定没问题了,通过这个TransactionalCache#getObject方法拿到缓存image-20200716110000646

实验3:测试update操作是否会刷新该namespace下的二级缓存。

先来看看调用栈:

image-20200716112607052

看看这个刷新缓存的本质:

// TransactionalCache.java
@Override
public void clear() {
    //方便下面清空真正的缓存
  clearOnCommit = true;
    // 清空事务未提交的缓存
  entriesToAddOnCommit.clear();
}

public void commit() {
  if (clearOnCommit) {
      // 当事务提交的时候会清空真正的缓存
    delegate.clear();
  }
  flushPendingEntries();
  reset();
}

结论很明显了:,在sqlSession3更新数据库,并提交事务后,sqlsession2StudentMapper namespace下的查询走了数据库,没有走Cache

实验四和实验五就不多说了,挺简单的

具有 LRU 策略的缓存 LruCache

public class LruCache implements Cache {

    private final Cache delegate;
    private Map<Object, Object> keyMap;
    private Object eldestKey;

    public LruCache(Cache delegate) {
        this.delegate = delegate;
        setSize(1024);
    }
    
    public int getSize() {
        return delegate.getSize();
    }

    public void setSize(final int size) {
        /*
         * 初始化 keyMap,注意,keyMap 的类型继承自 LinkedHashMap,
         * 并覆盖了 removeEldestEntry 方法
         */
        keyMap = new LinkedHashMap<Object, Object>(size, .75F, true) {
            private static final long serialVersionUID = 4267176411845948333L;

            // 覆盖 LinkedHashMap 的 removeEldestEntry 方法
            @Override
            protected boolean removeEldestEntry(Map.Entry<Object, Object> eldest) {
                boolean tooBig = size() > size;
                if (tooBig) {
                    // 获取将要被移除缓存项的键值
                    eldestKey = eldest.getKey();
                }
                return tooBig;
            }
        };
    }

    @Override
    public void putObject(Object key, Object value) {
        // 存储缓存项
        delegate.putObject(key, value);
        cycleKeyList(key);
    }

    @Override
    public Object getObject(Object key) {
        // 刷新 key 在 keyMap 中的位置
        keyMap.get(key);
        // 从被装饰类中获取相应缓存项
        return delegate.getObject(key);
    }

    @Override
    public Object removeObject(Object key) {
        // 从被装饰类中移除相应的缓存项
        return delegate.removeObject(key);
    }

    @Override
    public void clear() {
        delegate.clear();
        keyMap.clear();
    }

    private void cycleKeyList(Object key) {
        // 存储 key 到 keyMap 中
        keyMap.put(key, key);
        if (eldestKey != null) {
            // 从被装饰类中移除相应的缓存项
            delegate.removeObject(eldestKey);
            eldestKey = null;
        }
    }
   
    // 省略部分代码
}

上面我们用到的缓存都是可保证线程安全的缓存 SynchronizedCache,那关于这个LRU缓存就简单拓展一下JDK的LinkedHashMap

LinkedHashMap

LinkedHashMap ,在 HashMap 的基础之上,提供了顺序访问的特性:

  1. 按照 key-value 的插入顺序进行访问

  2. 按照 key-value 的访问顺序进行访问

谈谈LinkedHashMap 的Entry

下面还是通过一个具体的案例来理解这个类的作用吧

基于 LinkedHashMap 实现LRU缓存

关于LRU就不多说了,可以我写过的内存管理,那里有简单提到过!

public class SimpleCache<K, V> extends LinkedHashMap<K, V> {
    private static final int MAX_NODE_NUM = 100;

    private int limit;

    public SimpleCache(){
        this(MAX_NODE_NUM);
    }

    public SimpleCache(int limit) {
        super(limit, 0.75f, true);
        this.limit = limit;
    }

    public V save(K key, V val){
        return put(key, val);
    }

    public V getOne(K key) {
        return get(key);
    }

    public boolean exists(K key) {
        return containsKey(key);
    }


    /**
     * 判断节点数是否超限
     * @param eldest
     * @return 超限返回 true,否则返回 false
     */
    @Override
    protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
        return size() > limit;
    }

}

// 测试类

public class SimpleCacheTest {
    @Test
    public void test() throws Exception {
        SimpleCache<Integer, Integer> cache = new SimpleCache(3);

        for (int i = 0; i < 10; i++) {
            cache.save(i, i * i);
        }

        System.out.println("插入10个键值对后,缓存内容:");
        System.out.println(cache + "\n");

        System.out.println("访问键值为7的节点后,缓存内容:");
        cache.getOne(7);
        System.out.println(cache + "\n");

        System.out.println("插入键值为1的键值对后,缓存内容:");
        cache.save(1, 1);
        System.out.println(cache);
    }
}

我们不妨可以先看看结果,到底这个LinkedHashMap有什么功能那么强大

​ 在测试代码中,设定缓存大小为3。在向缓存中插入10个键值对后,只有最后3个被保存下来了,其他的都被移除了。然后通过访问键值为7的节点,使得该节点被移到双向链表的最后位置。当我们再次插入一个键值对时,键值为7的节点就不会被移除。image-20200715155049149

我们可以不妨debug进去源码看看它是怎么插入和顺序是怎么调整的!!

  1. 首先是看看LinkedHashMap的构造函数

    public LinkedHashMap(int initialCapacity,
                         float loadFactor,
                         boolean accessOrder) {
        super(initialCapacity, loadFactor);
        // 增加了一个标志顺序的变量
        this.accessOrder = accessOrder;
    }
    
  2. 然后是这个save方法,内部调用的还是hashmap的putVal方法

    // LinkedHashMap并没有覆写该方法,但在 HashMap 中,put 方法插入的是 HashMap 内部类 Node 类型的节点,该类型的节点并不具备与 LinkedHashMap 内部类 Entry 及其子类型节点组成链表的能力
    public V put(K key, V value) {
        return putVal(hash(key), key, value, false, true);
    }
    

    调用过程:

    1. HashMapputVal方法中先调用newNode,而newNode方法被LinkedHashMap覆写,最终调用的是LinkedHashMapnewNode方法,我们来看看这个方法image-20200715163740893

      Node<K,V> newNode(int hash, K key, V value, Node<K,V> e) {
          LinkedHashMap.Entry<K,V> p =
              new LinkedHashMap.Entry<K,V>(hash, key, value, e);
          // 这里又调用了linkNodeLast方法将 Entry 接在双向链表的尾部,实现了双向链表的建立
          linkNodeLast(p);
          return p;
      }
      
       private void linkNodeLast(LinkedHashMap.Entry<K,V> p) {
              LinkedHashMap.Entry<K,V> last = tail;
              tail = p;
              if (last == null)
                  head = p;
              else {
                  p.before = last;
                  last.after = p;
              }
          }
      

    ​ 总结一下这个调用流程就是:image-20200715164305795

  3. 再来看看getOne方法是怎么访问一个节点的。默认情况下,LinkedHashMap是按插入顺序维护链表。不过我们可以在初始化 LinkedHashMap,指定 accessOrder 参数为 true,即可让它按访问顺序维护链表。

    // LinkedHashMap 覆写了get方法
    public V get(Object key) {
            Node<K,V> e;
            if ((e = getNode(hash(key), key)) == null)
                return null;
        	// 默认情况是true
            if (accessOrder)
                afterNodeAccess(e);
            return e.value;
        }
    
    // 只需要将这些方法访问的节点移动到链表的尾部即可
    void afterNodeAccess(Node<K,V> e) { // move node to last
        LinkedHashMap.Entry<K,V> last;
        if (accessOrder && (last = tail) != e) {
            LinkedHashMap.Entry<K,V> p =
                (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
            p.after = null;
            if (b == null)
                head = a;
            else
                b.after = a;
            if (a != null)
                a.before = b;
            else
                last = b;
            if (last == null)
                head = p;
            else {
                p.before = last;
                last.after = p;
            }
            tail = p;
            ++modCount;
        }
    }
    
  4. 最后再看看这个删除过程,我们在这里调用了cache.save(1, 1);,看看插入的过程会怎么淘汰里面的节点

    可以看看调用栈:前面的过程都是一样的,唯独这里的处理有点不同,这里调用了LinkedHashMap实现的afterNodeInsertion方法image-20200715165851879

    void afterNodeInsertion(boolean evict) { // possibly remove eldest
        LinkedHashMap.Entry<K,V> first;
        // 根据条件判断是否移除最近最少被访问的节点
        if (evict && (first = head) != null && removeEldestEntry(first)) {
            K key = first.key;
            removeNode(hash(key), key, null, false, true);
        }
    }
    
    // 移除最近最少被访问条件之一,通过覆盖此方法可实现不同策略的缓存
    protected boolean removeEldestEntry(Map.Entry<K,V> eldest) {
        return false;
    }
    
     /**
         * 自己实现的策略,判断节点数是否超限
         * @param eldest
         * @return 超限返回 true,否则返回 false
         */
        @Override
        protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
            return size() > limit;
        }
    // 很明显这里已经超过了限制的数量,就要调用HashMap#removeNode方法
    
    // HashMap 中实现
    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;
        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) {
                if (p instanceof TreeNode) {...}
                else {
                    // 遍历单链表,寻找要删除的节点,并赋值给 node 变量
                    do {
                        if (e.hash == hash &&
                            ((k = e.key) == key ||
                             (key != null && key.equals(k)))) {
                            node = e;
                            break;
                        }
                        p = e;
                    } while ((e = e.next) != null);
                }
            }
            if (node != null && (!matchValue || (v = node.value) == value ||
                                 (value != null && value.equals(v)))) {
                if (node instanceof TreeNode) {...}
                // 将要删除的节点从单链表中移除
                else if (node == p)
                    tab[index] = node.next;
                else
                    p.next = node.next;
                ++modCount;
                --size;
                afterNodeRemoval(node);    // 调用删除回调方法进行后续操作
                return node;
            }
        }
        return null;
    }
    
    // LinkedHashMap 中覆写
    void afterNodeRemoval(Node<K,V> e) { // unlink
        LinkedHashMap.Entry<K,V> p =
            (LinkedHashMap.Entry<K,V>)e, b = p.before, a = p.after;
        // 将 p 节点的前驱后后继引用置空
        p.before = p.after = null;
        // b 为 null,表明 p 是头节点
        if (b == null)
            head = a;
        else
            b.after = a;
        // a 为 null,表明 p 是尾节点
        if (a == null)
            tail = b;
        else
            a.before = b;
    }
    

    删除的过程并不复杂,上面这么多代码其实就做了三件事:

    1. 根据 hash 定位到桶位置
    2. 遍历链表或调用红黑树相关的删除方法
    3. 从 LinkedHashMap 维护的双链表中移除要删除的节点

个人唠叨

​ 大家应该能猜到我这期个人唠叨要说什么了吧!没错,就是关于本次操作系统的期末考试,想想也很久没有这种做不完试卷的感觉了,回想起来,这种感觉也要追溯到两年前的高考了,当时做数学就是这种感觉了,做题的感觉是真的美妙,看起来都会,写上去就会出现各种各样的错误!!!好了,废话不多说了,马上进入主题!

谈谈我对这次考试的看法

​ 相信参加了本次考试的同学都很清楚这次考试的难度是什么样的?以我来看,本次考试确实是正常难度,题量相对来说是偏多了,老师出题的目的很简单,就是想通过这份卷子来巩固我们一个学期下来学习到的知识点,所以,题量是会多了的!image-20200715133640078

​ 其实我是很赞同他的这种想法的,有些知识点确实是你糊弄地学会了,你并不是真正地学会,而这些必修的知识点是非常重要的,一定不能随便搞!这也让我想起超哥,他当初也是这样子,为了是我们可以学得更好。

考完之后,我才发现我是真的菜,这也许是少刷题的原因吧,导致每道题有思路,但是写得都好慢,生怕会出错的样子,想不到时间也越来越紧迫了,最后导致自己的心态都崩了!嗯,确实崩了,可能是自从上大学之后太久没遇到过这种情况,导致出一点点差错,心态就容易崩。直到现在,心态才开始慢慢恢复上来!

我们应该抱着一种什么心态来看这种考试呢

​ 回想一下,当初我高考完知道分数后,知道自己彻底完蛋了,一直持续了这种颓废的状态好久好久好久。。

​ 回到这次考试上,如果你也像我这样,考崩了,我可以教你一个方法,那就是你要尝试把这个结果推迟一年,一年不行推迟五年,试想一下,我们挂科了,推迟一年之后,这个结果会影响你的什么?嗯,影响只不过是我要去补考,我拿不到奖学金,推迟五年,对我影响可以说是没了的,因为什么拿不到奖学金,补考这些东西对你将来的工作有影响吗?没有了,所以,我根本就不用焦虑,改过的生活还是照常过,该学习还是要继续学习

​ 除了这个方法之外,还教大家一个方法,比如:我的目标是进大厂,那考试挂科对你进大厂有没影响呢?哈哈哈哈,这里就以我个人看法来谈谈有没影响,不喜勿喷,我觉得是根本没有影响的,你去面试的时候难道真的会认真看你简历上的成绩单吗(简历上甚至是不会放成绩单的,985 211 除外),难道真的会因为你挂过科不会给你机会吗?是不会的

谈谈笔试

​ 我们都知道,很多大厂面试之前都是要进行笔试的,那我上面说到的那种考试的方式其实是和笔试差不多的,所以,我们不能排斥这个考试,每一次考试都要认真准备,那其实这个笔试跟我们的应试考试是有点差别的,笔试是为了筛选人才(好像这一次操作系统的考试,有筛选人才的意思,因为这个题量,你没有一定的熟练程度是没办法做完并且拿高分的),而我们的应试考试只是一个形式而已!!

谈谈我对挂科的看法

​ 好了,这里又引申到另一个主题了,我对挂科的看法,很多人都说“没有挂过科的大学是不完整的!”,哈哈哈哈,我是完全否认这句话的,能不挂科一定不要挂科,真的,因为后续的工作是很麻烦的!当然啦,那什么是能不挂科尽量不挂科?就是你有认真在学习,有认真刷题,认真弄懂每一个知识点,但是老师就是要为难你,那你没办法,那说回这次考试,大家都觉得自己会挂科,那自己平时真的有弄懂每一个知识点吗?真的有认真刷题吗?如果你回答是,那我无话可说,挂科只能怪老天,不能怪你,但我相信绝大多数朋友(包括我在内)都不怎么会认真对待这门课,因为这门课又枯燥,又难啃,别说还另外拿时间出来刷题了!所以,挂科了也不能怨别人了,只能怪自己并没有好好重视这门课!

image-20200715141511358

​ 那挂了科真的代表你不行了吗?我觉得,考试考得好只能说明你做题真的很牛逼!但是你试想一下,你真正干活的时候,真的是像考试做题那样吗?那其实并不是这样的,我们会发现很多成绩特别特别优异的同学,他们的编程能力其实并不好(我觉得我大一的时候就是这样一种人,过分追求分数了!!)。在大学的时候,那些编程能力最强的往往是那些成绩比较一般的。

为什么会这样呢?

​ 我觉得主要是一个思维的转变问题。很多人学习编程的时候,总是想着我要把这个 API 、把这种题型记下来,把这个库的用法记下来。这样学习,导致的结果只有一个那就是你会很难受!因为,这些根本不是要死记硬背的东西啊!真还当这是上课考试啊!**你要从如何用你学的东西来解决实际编程问题出发,站在做一个实际的项目的角度来学习。**所以,我认为做项目,写课程设计报告真的是一种很好的学习方式!

好了,总结一下,我们不要把学习编程还当做一场应试考试来看了!!!!

​ 花了近一个小时来写这个个人唠叨,目的不为啥,就是为了想让你们能和我一样尽快建立一种对考试的正确认知!!不要让挂科覆盖了你的整个人生!最后,希望大家不要以应试考试的方式学习编程、多实践、造轮子是一种特别能够提高自己系统编程能力的手段等等。说了这么多,如果你没有将这些学习编程的正确姿势用到自己平时学习中的话,这篇文章对你的帮助可能非常有限。

​ 接下来这个暑假,希望大家能一起加油,一起变强,共勉!!!

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值