IT教程 ·

手把手带你浏览Mybatis源码(三)缓存篇

高并发之——从源码角度分析创建线程池究竟有哪些方式

媒介

人人好,这一篇文章是MyBatis系列的末了一篇文章,前面两篇文章: 和 ,重要说清楚明了MyBatis是怎样将我们的xml设置文件构建为其内部的Configuration对象和MappedStatement对象的,然后在第二篇我们说了构建完成后MyBatis是怎样一步一步地实行我们的SQL语句而且对效果集举行封装的。

那末这篇作为MyBatis系列的末了一篇,自然是要来聊聊MyBatis中的一个不可无视的功用,一级缓存和二级缓存

何谓缓存?

虽然这篇说的是MyBatis的缓存,然则我愿望正在进修计算机的小伙伴纵然还没有运用过MyBatis框架也能看邃晓本日这篇文章。

缓存是什么?我来说说个人的明白,末了再上比较官方的观点。

缓存(Cache),望文生义,有临时存储的意义。计算机中的缓存,我们能够直接明白为,存储在内存中的数据的容器,这与物理存储是有差别的,由于内存的读写速率比物理存储凌驾几个数量级,所以程序直接从内存中取数据和从物理硬盘中取数据的效力是差别的,所以有一些常常须要读取的数据,设计师们平常会将其放在缓存中,以便于程序对其举行读取。

然则,缓存是有价值的,适才我们说过,缓存就是在内存中的数据的容器,一条64G的内存条,平常能够买3-4块1T-2T的机器硬盘了,所以缓存不能无节制地运用,如许成本会剧增,所以平常缓存中的数据都是须要频仍查询,然则又不常修正的数据

而在平常营业中,查询平常会经由以下步骤。

读操纵 --> 查询缓存中已存在数据 -->假如不存在则查询数据库,假如存在则直接查询缓存-->数据库查询返回数据的同时,写入缓存中。

写操纵 --> 清空缓存数据 -->写入数据库

手把手带你浏览Mybatis源码(三)缓存篇 IT教程 第1张缓存流程

比较官方的观点

缓存就是数据交换的缓冲区(称作:Cache),当某一硬件要读取数据时,会起首从缓存汇总查询数据,有则直接实行,不存在时从内存中猎取。由于缓存的数据比内存快的多,所以缓存的作用就是协助硬件更快的运转。

缓存每每运用的是RAM(断电既掉的非永远存储),所以在用完后照样会把文件送到硬盘等存储器中永远存储。电脑中最大缓存就是内存条,硬盘上也有16M或许32M的缓存。

高速缓存是用来谐和CPU与主存之间存取速率的差别而设置的。平常CPU事情速率高,但内存的事情速率相对较低,为了处置惩罚这个问题,平常运用高速缓存,高速缓存的存取速率介于CPU与主存之间。体系将一些CPU在近来几个时刻段常常接见的内容存在高速缓存,如许就在肯定程度上缓解了由于主存速率低形成的CPU“停工待料”的状态。

缓存就是把一些外存上的数据保留在内存上罢了,为何保留在内存上,我们运转的一切程序内里的变量都是寄存在内存中的,所以假如想将值放入内存上,能够经由历程变量的体式格局存储。在JAVA中一些缓存平常都是经由历程Map鸠合来完成的。

MyBatis的缓存

在说MyBatis的缓存之前,先相识一下Java中的缓存平常都是怎样完成的,我们平常会运用Java中的Map,来完成缓存,所以在以后的缓存这个观点,就能够把它直接明白为一个Map,存的就是键值对。

一级缓存简介

MyBatis中的一级缓存,是默许开启且没法封闭的一级缓存默许的作用域是一个SqlSession,解释一下,就是当SqlSession被构建了以后,缓存就存在了,只需这个SqlSession不封闭,这个缓存就会一向存在,换言之,只需SqlSession不封闭,那末这个SqlSession处置惩罚的统一条SQL就不会被挪用两次,只有当会话终了了以后,这个缓存才会一并被开释。

虽然说我们不能封闭一级缓存,然则作用域是能够修正的,比方能够修正为某个Mapper。

一级缓存的生命周期:

1、假如SqlSession挪用了close()要领,会开释掉一级缓存PerpetualCache对象,一级缓存将不可用。

2、假如SqlSession挪用了clearCache(),会清空PerpetualCache对象中的数据,然则该对象仍可运用。

3、SqlSession中实行了任何一个update操纵(update()、delete()、insert()) ,都邑清空PerpetualCache对象的数据,然则该对象能够继承运用。

节选自:https://www.cnblogs.com/happyflyingpig/p/7739749.html

手把手带你浏览Mybatis源码(三)缓存篇 IT教程 第2张MyBatis一级缓存简朴示意图

二级缓存简介

MyBatis的二级缓存是默许封闭的,假如要开启有两种体式格局:

1.在mybatis-config.xml中到场以下设置片断

     <!-- 全局设置参数,须要时再设置 -->
     <settings>
            <!-- 开启二级缓存  默许值为true -->
         <setting name="cacheEnabled" value="true"/>

     </settings>

2.在mapper.xml中开启

     <!--开启本mapper的namespace下的二级缓存-->
     <!--
             eviction:代表的是缓存接纳战略,如今MyBatis供应以下战略。
             (1) LRU,近来起码运用的,一处最长时刻不必的对象
             (2) FIFO,先进先出,按对象进入缓存的次序来移除他们
             (3) SOFT,软援用,移除基于垃圾接纳器状态和软援用划定规矩的对象
             (4) WEAK,弱援用,更主动的移除基于垃圾收集器状态和弱援用划定规矩的对象。
                 这里采纳的是LRU,  移除最长时刻不必的对抽象

             flushInterval:革新间隔时刻,单元为毫秒,假如你不设置它,那末当
             SQL被实行的时刻才会去革新缓存。

             size:援用数量,一个正整数,代表缓存最多能够存储多少个对象,不宜设置过大。设置过大会致使内存溢出。
             这里设置的是1024个对象

             readOnly:只读,意味着缓存数据只能读取而不能修正,如许设置的优点是我们能够疾速读取缓存,瑕玷是我们没有
             方法修正缓存,他的默许值是false,不允许我们修正
      -->
     <cache eviction="接纳战略" type="缓存类"/>

二级缓存的作用域与一级缓存差别,一级缓存的作用域是一个SqlSession,然则二级缓存的作用域是一个namespace,什么意义呢,你能够把它明白为一个mapper,在这个mapper中操纵的一切SqlSession都能够同享这个二级缓存。然则假定有两条雷同的SQL,写在差别的namespace下,那这个SQL就会被实行两次,而且发作两份value雷同的缓存。

MyBatis缓存的实行流程

依旧是用前两篇的测试用例,我们从源码的角度看看缓存是怎样实行的。

public static void main(String[] args) throws Exception {
    String resource = "mybatis.xml";
    InputStream inputStream = Resources.getResourceAsStream(resource);
    SqlSessionFactory sqlSessionFactory = new SqlSessionFactoryBuilder().build(inputStream);
    SqlSession sqlSession = sqlSessionFactory.openSession();
    //从挪用者角度来说 与数据库打交道的对象 SqlSession
    DemoMapper mapper = sqlSession.getMapper(DemoMapper.class);
    Map<String,Object> map = new HashMap<>();
    map.put("id","2121");
    //实行这个要领现实上会走到invoke
    System.out.println(mapper.selectAll(map));
    sqlSession.close();
    sqlSession.commit();
  }

这里会实行到query()要领:

public <E> List<E> query(MappedStatement ms, Object parameterObject, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql)
      throws SQLException {
    //二级缓存的Cache,经由历程MappedStatement猎取
    Cache cache = ms.getCache();
    if (cache != null) {
      //是不是须要革新缓存
      //在<select>标签中也能够设置flushCache属性来设置是不是查询前要革新缓存,默许增编削革新缓存查询不革新
      flushCacheIfRequired(ms);
      //推断这个mapper是不是开启了二级缓存
      if (ms.isUseCache() && resultHandler == null) {
        //不论
        ensureNoOutParams(ms, boundSql);
        @SuppressWarnings("unchecked")
        //先从缓存拿
        List<E> list = (List<E>) tcm.getObject(cache, key);
        if (list == null) {
            //假如缓存即是空,那末查询一级缓存
          list = delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
          //查询终了后将数据放入二级缓存
          tcm.putObject(cache, key, list); // issue #578 and #116
        }
        //返回
        return list;
      }
    }
    //假如二级缓存为null,那末直接查询一级缓存
    return delegate.query(ms, parameterObject, rowBounds, resultHandler, key, boundSql);
}

能够看到起首MyBatis在查询数据时会先看看这个mapper是不是开启了二级缓存,假如开启了,会先查询二级缓存,假如缓存中存在我们须要的数据,那末直接就从缓存返回数据,假如不存在,则继承往下走查询逻辑。

接着往下走,假如二级缓存不存在,那末就直接查询数据了吗?答案是不是定的,二级缓存假如不存在,MyBatis会再查询一次一级缓存,接着往下看。

public <E> List<E> query(MappedStatement ms, Object parameter, RowBounds rowBounds, ResultHandler resultHandler, CacheKey key, BoundSql boundSql) throws SQLException {
    ErrorContext.instance().resource(ms.getResource()).activity("executing a query").object(ms.getId());
    if (closed) {
      throw new ExecutorException("Executor was closed.");
    }
    if (queryStack == 0 && ms.isFlushCacheRequired()) {
      clearLocalCache();
    }
    List<E> list;
    try {
      queryStack++;
      //查询一级缓存(localCache)
      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);
         /**这个是queryFromDatabase的逻辑
         * //先往缓存中put一个占位符
            localCache.putObject(key, EXECUTION_PLACEHOLDER);
            try {
              list = doQuery(ms, parameter, rowBounds, resultHandler, boundSql);
            } finally {
              localCache.removeObject(key);
            }
            //往一级缓存中put实在数据
            localCache.putObject(key, list);
            if (ms.getStatementType() == StatementType.CALLABLE) {
              localOutputParameterCache.putObject(key, parameter);
            }
            return list;
         */
      }
    } finally {
      queryStack--;
    }
    if (queryStack == 0) {
      for (DeferredLoad deferredLoad : deferredLoads) {
        deferredLoad.load();
      }
      // issue #601
      deferredLoads.clear();
      if (configuration.getLocalCacheScope() == LocalCacheScope.STATEMENT) {
        // issue #482
        clearLocalCache();
      }
    }
    return list;
  }

一级缓存和二级缓存的查询逻辑实在差不多,都是先查询缓存,假如没有则举行下一步查询,只不过一级缓存中假如没有效果,那末就直接查询数据库,然后回写一级缓存。

讲到这里实在一级缓存和二级缓存的实行流程就说完了,缓存的逻辑实在都差不多,MyBatis的缓存是先查询一级缓存再查询二级缓存

然则文章到这里并没有终了,另有一些缓存相干的问题能够聊。

缓存事件问题

不知道这个问题人人有无想过,假定有这么一个场景,这里用二级缓存举例,由于二级缓存是跨事件的。

假定我们在查询之前开启了事件,而且举行数据库操纵:

1.往数据库中插进去一条数据(INSERT)

2.在统一个事件内查询数据(SELECT)

3.提交事件(COMMIT)

4.提交事件失利(ROLLBACK)

我们来剖析一下这个场景,起首SqlSession先实行了一个INSERT操纵,很显然,在我们适才剖析的逻辑基础上,此时缓存肯定会被清空,然后在统一个事件下查询数据,数据又从数据库中被加载到了缓存中,此时提交事件,然后事件提交失利了。

考虑一下此时会涌现什么状态,置信已有人想到了,事件提交失利以后,事件会举行回滚,那末实行INSERT插进去的这条数据就被回滚了,然则我们在插进去以后举行了一次查询,这个数据已放到了缓存中,下一次查询必定是直接查询缓存而不会再去查询数据库了,但是此时缓存和数据库之间已存在了数据不一致的问题。

问题的根本原因就在于,数据库提交事件失利了能够举行回滚,然则缓存不能举行回滚

我们来看看MyBatis是怎样处置惩罚这个问题的。

TransactionalCacheManager

这个类是MyBatis用于缓存事件管理的类,我们能够看看其数据结构。

  public class TransactionalCacheManager {

   //事件缓存
    private final Map<Cache, TransactionalCache> transactionalCaches = new HashMap<>();

    public void clear(Cache cache) {
      getTransactionalCache(cache).clear();
    }

    public Object getObject(Cache cache, CacheKey key) {
      return getTransactionalCache(cache).getObject(key);
    }

    public void putObject(Cache cache, CacheKey key, Object value) {
      getTransactionalCache(cache).putObject(key, value);
    }

    public void commit() {
      for (TransactionalCache txCache : transactionalCaches.values()) {
        txCache.commit();
      }
    }

    public void rollback() {
      for (TransactionalCache txCache : transactionalCaches.values()) {
        txCache.rollback();
      }
    }

    private TransactionalCache getTransactionalCache(Cache cache) {
      return transactionalCaches.computeIfAbsent(cache, TransactionalCache::new);
    }

  }

TransactionalCacheManager中封装了一个Map,用于将事件缓存对象缓存起来,这个Map的Key是我们的二级缓存对象,而Value是一个叫做TransactionalCache,望文生义,这个缓存就是事件缓存,我们来看看其内部的完成。

  public class TransactionalCache implements Cache {

    private static final Log log = LogFactory.getLog(TransactionalCache.class);

    //实在缓存对象
    private final Cache delegate;
    //是不是须要清空提交空间的标识
    private boolean clearOnCommit;
    //一切待提交的缓存
    private final Map<Object, Object> entriesToAddOnCommit;
    //未掷中的缓存鸠合,防备击穿缓存,而且假如查询到的数据为null,申明要经由历程数据库查询,有大概存在数据不一致,都记录到这个处所
    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 String getId() {
      return delegate.getId();
    }

    @Override
    public int getSize() {
      return delegate.getSize();
    }

    @Override
    public Object getObject(Object key) {
      // issue #116
      Object object = delegate.getObject(key);
      if (object == null) {
          //假如掏出的是空,那末放到未掷中缓存,而且在查询数据库以后putObject中将本应当放到实在缓存中的键值对放到待提交事件缓存
        entriesMissedInCache.add(key);
      }
      //假如不为空
      // issue #146
      //检察缓存清空标识是不是为false,假如事件提交了就为true,事件提交了会更新缓存,所以返回null。
      if (clearOnCommit) {
        return null;
      } else {
          //假如事件没有提交,那末返回原本缓存中的数据,
        return object;
      }
    }

    @Override
    public void putObject(Object key, Object object) {
        //假如返回的数据为null,那末有大概到数据库查询,查询到的数据先安排到待提交事件的缓存中
        //原本应当put到缓存中,如今put到待提交事件的缓存中去。
      entriesToAddOnCommit.put(key, object);
    }

    @Override
    public Object removeObject(Object key) {
      return null;
    }

    @Override
    public void clear() {
        //假如事件提交了,那末将清空缓存提交标识设置为true
      clearOnCommit = true;
      //清空entriesToAddOnCommit
      entriesToAddOnCommit.clear();
    }

    public void commit() {
      if (clearOnCommit) {
          //假如为true,那末就清空缓存。
        delegate.clear();
      }
      //把当地缓存革新到实在缓存。
      flushPendingEntries();
      //然后将一切值复位。
      reset();
    }

    public void rollback() {
        //事件回滚
      unlockMissedEntries();
      reset();
    }

    private void reset() {
        //复位操纵。
      clearOnCommit = false;
      entriesToAddOnCommit.clear();
      entriesMissedInCache.clear();
    }

    private void flushPendingEntries() {
        //遍历事件管理器中待提交的缓存
      for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
          //写入到实在的缓存中。
        delegate.putObject(entry.getKey(), entry.getValue());
      }
      for (Object entry : entriesMissedInCache) {
          //把未掷中的一同put
        if (!entriesToAddOnCommit.containsKey(entry)) {
          delegate.putObject(entry, null);
        }
      }
    }

    private void unlockMissedEntries() {
      for (Object entry : entriesMissedInCache) {
          //清空实在缓存区中未掷中的缓存。
      try {
          delegate.removeObject(entry);
        } catch (Exception e) {
          log.warn("Unexpected exception while notifiying a rollback to the cache adapter."
              + "Consider upgrading your cache adapter to the latest version.  Cause: " + e);
        }
      }
    }

  }

在TransactionalCache中有一个实在缓存对象Cache,这个实在缓存对象就是我们真正的二级缓存,另有一个 entriesToAddOnCommit,这个Map对象中寄存的是一切待提交事件的缓存

我们在二级缓存实行的代码中,看到在缓存中get或许put效果时,都是叫tcm的对象挪用了getObject()要领和putObject()要领,这个对象现实上就是TransactionalCacheManager的实体对象,而这个对象现实上是挪用了TransactionalCache的要领,我们来看看这两个要领是怎样完成的。

  @Override
  public Object getObject(Object key) {
      // issue #116
      Object object = delegate.getObject(key);
      if (object == null) {
          //假如掏出的是空,那末放到未掷中缓存,而且在查询数据库以后putObject中将本应当放到实在缓存中的键值对放到待提交事件缓存
        entriesMissedInCache.add(key);
      }
      //假如不为空
      // issue #146
      //检察缓存清空标识是不是为false,假如事件提交了就为true,事件提交了会更新缓存,所以返回null。
      if (clearOnCommit) {
        return null;
      } else {
          //假如事件没有提交,那末返回原本缓存中的数据,
        return object;
      }
  }
  @Override
  public void putObject(Object key, Object object) {
        //假如返回的数据为null,那末有大概到数据库查询,查询到的数据先安排到待提交事件的缓存中
        //原本应当put到缓存中,如今put到待提交事件的缓存中去。
      entriesToAddOnCommit.put(key, object);
  }

在getObject()要领中存在两个分支:

假如发明缓存中掏出的数据为null,那末会把这个key放到entriesMissedInCache中,这个对象的重要作用就是将我们未掷中的key全都保留下来,防备缓存被击穿,而且当我们在缓存中没法查询到数据,那末就有大概到一级缓存和数据库中查询,那末查询事后会挪用putObject()要领,这个要领本应当将我们查询到的数据put到真是缓存中,然则如今由于存在事件,所以临时先放到entriesToAddOnCommit中。

假如发明缓存中掏出的数据不为null,那末会检察事件提交标识(clearOnCommit)是不是为true,假如为true,代表事件已提交了,以后缓存会被清空,所以返回null,假如为false,那末由于事件还没有被提交,所以返回当前缓存中存的数据。

那末当事件提交胜利或提交失利,又会是什么状态呢?无妨看看commit和rollback要领。

  public void commit() {
      if (clearOnCommit) {
          //假如为true,那末就清空缓存。
        delegate.clear();
      }
      //把当地缓存革新到实在缓存。
      flushPendingEntries();
      //然后将一切值复位。
      reset();
  }

  public void rollback() {
        //事件回滚
      unlockMissedEntries();
      reset();
  }

先剖析事件提交胜利的状态,假如事件一般提交了,那末会有这么几步操纵:

  1. 清空实在缓存。
  2. 将当地缓存(未提交的事件缓存 entriesToAddOnCommit)革新到实在缓存。
  3. 将一切值复位。

我们来看看代码是怎样完成的:

  private void flushPendingEntries() {
        //遍历事件管理器中待提交的缓存
      for (Map.Entry<Object, Object> entry : entriesToAddOnCommit.entrySet()) {
          //写入到实在的缓存中。
        delegate.putObject(entry.getKey(), entry.getValue());
      }
      for (Object entry : entriesMissedInCache) {
          //把未掷中的一同put
        if (!entriesToAddOnCommit.containsKey(entry)) {
          delegate.putObject(entry, null);
        }
      }
  }
  private void reset() {
        //复位操纵。
      clearOnCommit = false;
      entriesToAddOnCommit.clear();
      entriesMissedInCache.clear();
  }
  public void clear() {
      //假如事件提交了,那末将清空缓存提交标识设置为true
      clearOnCommit = true;
      //清空事件提交缓存
      entriesToAddOnCommit.clear();
  }

清空实在缓存就不说了,就是Map挪用clear要领,清空一切的键值对。

将未提交事件缓存革新到实在缓存,起首会遍历entriesToAddOnCommit,然后挪用实在缓存的putObject要领,将entriesToAddOnCommit中的键值对put到实在缓存中,这步完成后,还会将未掷中缓存中的数据一同put进去,值设置为null。

末了举行复位,将提交事件标识设为false,未掷中缓存、未提交事件缓存中的一切数据全都清空。

假如事件没有一般提交,那末就会发作回滚,再来看看回滚是什么流程:

  1. 清空实在缓存中未掷中的缓存。
  2. 将一切值复位

     

  public void rollback() {
        //事件回滚
      unlockMissedEntries();
      reset();
  }

  private void unlockMissedEntries() {
      for (Object entry : entriesMissedInCache) {
          //清空实在缓存区中未掷中的缓存。
      try {
          delegate.removeObject(entry);
        } catch (Exception e) {
          log.warn("Unexpected exception while notifiying a rollback to the cache adapter."
              + "Consider upgrading your cache adapter to the latest version.  Cause: " + e);
        }
      }
  }

由于通常在缓存中未掷中的key,都邑被记录到entriesMissedInCache这个缓存中,所以这个缓存中包含了一切查询数据库的key,所以终究只须要在实在缓存中把这部份key和对应的value给删除即可。

缓存事件总结

简而言之,缓存事件的掌握重如果经由历程TransactionalCacheManager掌握TransactionCache完成的,症结就在于TransactionCache中的entriesToAddCommit和entriesMissedInCache这两个对象,entriesToAddCommit在事件开启到提交时期作为实在缓存的替代品,将从数据库中查询到的数据先放到这个Map中,待事件提交后,再将这个对象中的数据革新到实在缓存中,假如事件提交失利了,则清空这个缓存中的数据即可,并不会影响到实在的缓存。

entriesMissedInCache重如果用来保留在查询历程当中在缓存中没有掷中的key,由于没有掷中,申明须要到数据库中查询,那末查询事后会保留到entriesToAddCommit中,那末假定在事件提交历程当中失利了,而此时entriesToAddCommit的数据又都革新到缓存中了,那末此时挪用rollback就会经由历程entriesMissedInCache中保留的key,来清算实在缓存,如许就能够保证在事件中缓存数据与数据库的数据保持一致。

缓存事件

一些运用缓存的履历

二级缓存不能存在一向增加的数据

由于二级缓存的影响局限不是SqlSession而是namespace,所以二级缓存会在你的运用启动时一向存在直到运用封闭,所以二级缓存中不能存在跟着时刻数据量越来越大的数据,如许有大概会形成内存空间被占满。

二级缓存有大概存在脏读的问题(可防止)

由于二级缓存的作用域为namespace,那末就能够假定这么一个场景,有两个namespace操纵一张表,第一个namespace查询该表并回写到内存中,第二个namespace往表中插一条数据,那末第一个namespace的二级缓存是不会清空这个缓存的内容的,鄙人一次查询中,还会经由历程缓存去查询,如许会形成数据的不一致。

所以当项目里有多个定名空间操纵统一张表的时刻,最好不要用二级缓存,或许运用二级缓存时防止用两个namespace操纵一张表。

Spring整合MyBatis缓存失效问题

一级缓存的作用域是SqlSession,而运用者能够自定义SqlSession什么时刻涌现什么时刻烧毁,在这段时期一级缓存都是存在的。
当运用者挪用close()要领以后,就会烧毁一级缓存。

然则,我们在和Spring整合以后,Spring帮我们跳过了SqlSessionFactory这一步,我们能够直接挪用Mapper,致使在操纵完数据库以后,Spring就将SqlSession就烧毁了,一级缓存就随之烧毁了,所以一级缓存就失效了。

那末怎样能让缓存见效呢

  1. 开启事件,由于一旦开启事件,Spring就不会在实行完SQL以后就烧毁SqlSession,由于SqlSession一旦封闭,事件就没了,一旦我们开启事件,在事件时期内,缓存会一向存在。
  2. 运用二级缓存。

结语

Hello world.

写给Unity开发者的iOS内存调试指南

参与评论