ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波
1. 前言
ThreadLocal 也是一个使用频率较高的类,在框架中也经常见到,比如 Spring。
有关 ThreadLocal 源码分析的文章不少,其中有个问题常被提及:ThreadLocal 是否存在内存泄漏?
不少文章对此讲述比较模糊,经常让人看完脑子还是一头雾水,我也有此困惑。因此找时间跟小伙伴讨论了一番,总算对这个问题有了一定的理解,这里记录和分享一下,希望对有同样困惑的朋友们有所帮助。当然,若有理解不当的地方也欢迎指正。
啰嗦就到这里,下面先从 ThreadLocal 的一个应用场景开始分析吧。
2. 应用场景
ThreadLocal 的应用场景不少,这里举个简单的栗子:单点登录拦截。
也就是在处理一个 HTTP 请求之前,判断用户是否登录:
若未登录,跳转到登录页面;
若已登录,获取并保存用户的登录信息。
先定义一个 UserInfoHolder 类保存用户的登录信息,其内部用 ThreadLocal 存储,示例如下:
public class UserInfoHolder {private static final ThreadLocal<Map<String, String>> USER_INFO_THREAD_LOCAL = new ThreadLocal<>();public static void set(Map<String, String> map) {USER_INFO_THREAD_LOCAL.set(map);}public static Map<String, String> get() {return USER_INFO_THREAD_LOCAL.get();}public static void clear() {USER_INFO_THREAD_LOCAL.remove();}// ...
}
通过 UserInfoHolder 可以存储和获取用户的登录信息,以便在业务中使用。
Spring 项目中,如果我们想在处理一个 HTTP 请求之前或之后做些额外的处理,通常定义一个类继承 HandlerInterceptorAdapter,然后重写它的一些方法。举例如下(仅供参考,省略了一些代码):
public class LoginInterceptor extends HandlerInterceptorAdapter {// ...@Overridepublic boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)throws Exception {// ...// 请求执行前,获取用户登录信息并保存Map<String, String> userInfoMap = getUserInfo();UserInfoHolder.set(userInfoMap);return true;}@Overridepublic void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {// 请求执行后,清理掉用户信息UserInfoHolder.clear();}
}
在本例中,我们在处理一个请求之前获取用户的信息,在处理完请求之后,将用户信息清空。应该有朋友在框架或者自己的项目中见过类似代码。
下面我们深入 ThreadLocal 的内部,来分析这些方法做了些什么,跟内存泄漏又是怎么扯上关系的。
3. 源码剖析
3.1 类签名
先从头开始,也就是类签名:
public class ThreadLocal<T> {
}
可见它就是一个普通的类,并没有实现任何接口、也无父类继承。
3.2 构造器
ThreadLocal 只有一个无参构造器:
public ThreadLocal() {
}
此外,JDK 1.8 引入了一个使用 lambda 表达式初始化的静态方法 withInitial,如下:
public static <S> ThreadLocal<S> withInitial(Supplier<? extends S> supplier) {return new SuppliedThreadLocal<>(supplier);
}
该方法也可以初始化一个对象,和构造器也比较接近。
3.3 ThreadLocalMap
3.3.1 主要代码
ThreadLocalMap 是 ThreadLocal 的一个内部嵌套类。
由于 ThreadLocal 的主要操作实际都是通过 ThreadLocalMap 的方法实现的,因此先分析 ThreadLocalMap 的主要代码:
public class ThreadLocal<T> {// 生成 ThreadLocal 的哈希码,用于计算在 Entry 数组中的位置private final int threadLocalHashCode = nextHashCode();private static final int HASH_INCREMENT = 0x61c88647;private static int nextHashCode() {return nextHashCode.getAndAdd(HASH_INCREMENT);}// ...static class ThreadLocalMap {static class Entry extends WeakReference<ThreadLocal<?>> {Object value;Entry(ThreadLocal<?> k, Object v) {super(k);value = v;}}// 初始容量,必须是 2 的次幂private static final int INITIAL_CAPACITY = 16;// 存储数据的数组private Entry[] table;// table 中的 Entry 数量private int size = 0;// 扩容的阈值private int threshold; // Default to 0// 设置扩容阈值private void setThreshold(int len) {threshold = len * 2 / 3;} // 第一次添加元素使用的构造器ThreadLocalMap(ThreadLocal<?> firstKey, Object firstValue) {table = new Entry[INITIAL_CAPACITY];int i = firstKey.threadLocalHashCode & (INITIAL_CAPACITY - 1);table[i] = new Entry(firstKey, firstValue);size = 1;setThreshold(INITIAL_CAPACITY);}// ...}
}
ThreadLocalMap 的内部结构其实跟 HashMap 很类似,可以对比前面「JDK源码分析-HashMap(1)」对 HashMap 的分析。
二者都是「键-值对」构成的数组,对哈希冲突的处理方式不同,导致了它们在结构上产生了一些区别:
HashMap 处理哈希冲突使用的「链表法」。也就是当产生冲突时拉出一个链表,而且 JDK 1.8 进一步引入了红黑树进行优化。
ThreadLocalMap 则使用了「开放寻址法」中的「线性探测」。即,当某个位置出现冲突时,从当前位置往后查找,直到找到一个空闲位置。
其它部分大体是类似的。
3.3.2 注意事项
弱引用
有个值得注意的地方是:ThreadLocalMap 的 Entry 继承了 WeakReference 类,也就是弱引用类型。
跟进 Entry 的父类,可以看到 ThreadLocal 最终赋值给了 WeakReference 的父类 Reference 的 referent 属性。即,可以认为 Entry 持有了两个对象的引用:ThreadLocal 类型的「弱引用」和 Object 类型的「强引用」,其中 ThreadLocal 为 key,Object 为 value。如图所示:
ThreadLocal 在某些情况可能产生的「内存泄漏」就跟这个「弱引用」有关,后面再展开分析。
寻址
Entry 的 key 是 ThreadLocal 类型的,它是如何在数组中散列的呢?
ThreadLocal 有个 threadLocalHashCode 变量,每次创建 ThreadLocal 对象时,这个变量都会增加一个固定的值 HASH_INCREMENT
,即 0x61c88647,这个数字似乎跟黄金分割、斐波那契数有关,但这不是重点,有兴趣的朋友可以去深入研究下,这里我们知道它的目的就行了。与 HashMap 的 hash 算法的目的近似,就是为了散列的更均匀。
下面分析 ThreadLocal 的主要方法实现。
3.4 主要方法
ThreadLocal 主要有三个方法:set、get 和 remove,下面分别介绍。
3.4.1 set 方法
set 方法:新增/更新 Entry
public void set(T value) {// 获取当前线程Thread t = Thread.currentThread();// 从 Thread 中获取 ThreadLocalMapThreadLocalMap map = getMap(t);if (map != null)map.set(this, value);elsecreateMap(t, value);
}ThreadLocalMap getMap(Thread t) {return t.threadLocals;
}
threadLocals 是 Thread 持有的一个 ThreadLocalMap 引用,默认是 null:
public class Thread implements Runnable {// 其他代码...ThreadLocal.ThreadLocalMap threadLocals = null;
}
执行流程
若从当前 Thread 拿到的 ThreadLocalMap 为空,表示该属性并未初始化,执行 createMap 初始化:
void createMap(Thread t, T firstValue) {t.threadLocals = new ThreadLocalMap(this, firstValue);
}
若已存在,则调用 ThreadLocalMap 的 set 方法:
private void set(ThreadLocal<?> key, Object value) { Entry[] tab = table;int len = tab.length;// 1. 计算 key 在数组中的下标 iint i = key.threadLocalHashCode & (len-1);// 1.1 若数组下标为 i 的位置有元素// 判断 i 位置的 Entry 是否为空;不为空则从 i 开始向后遍历数组for (Entry e = tab[i];e != null;e = tab[i = nextIndex(i, len)]) {ThreadLocal<?> k = e.get();// 索引为 i 的元素就是要查找的元素,用新值覆盖旧值,到此返回if (k == key) {e.value = value;return;}// 索引为 i 的元素并非要查找的元素,且该位置中 Entry 的 Key 已经是 null// Key 为 null 表明该 Entry 已经过期了,此时用新值来替换这个位置的过期值if (k == null) {// 替换过期的 Entry,replaceStaleEntry(key, value, i);return;}}// 1.2 若数组下标为 i 的位置为空,将要存储的元素放到 i 的位置tab[i] = new Entry(key, value);int sz = ++size;// 若未清理过期的 Entry,且数组的大小达到阈值,执行 rehash 操作if (!cleanSomeSlots(i, sz) && sz >= threshold)rehash();
}
先总结下 set 方法主要流程:
首先根据 key 的 threadLocalHashCode 计算它的数组下标:
如果数组下标的 Entry 不为空,表示该位置已经有元素。由于可能存在哈希冲突,因此这个位置的元素可能并不是要找的元素,所以遍历数组去比较
如果找到等于当前 key 的 Entry,则用新值替换旧值,返回。
如果遍历过程中,遇到 Entry 不为空、但是 Entry 的 key 为空的情况,则会做一些清理工作。
如果数组下标的 Entry 为空,直接将元素放到这里,必要时进行扩容。
replaceStaleEntry:替换过期的值,并清理一些过期的 Entry
private void replaceStaleEntry(ThreadLocal<?> key, Object value,int staleSlot) {Entry[] tab = table;int len = tab.length;Entry e;// 从 staleSlot 开始向前遍历,若遇到过期的槽(Entry 的 key 为空),更新 slotToExpunge// 直到 Entry 为空停止遍历int slotToExpunge = staleSlot;for (int i = prevIndex(staleSlot, len);(e = tab[i]) != null;i = prevIndex(i, len))if (e.get() == null)slotToExpunge = i;// 从 staleSlot 开始向后遍历,若遇到与当前 key 相等的 Entry,更新旧值,并将二者换位置// 目的是把它放到「应该」在的位置for (int i = nextIndex(staleSlot, len);(e = tab[i]) != null;i = nextIndex(i, len)) {ThreadLocal<?> k = e.get();if (k == key) {// 更新旧值e.value = value;// 换位置tab[i] = tab[staleSlot];tab[staleSlot] = e;// Start expunge at preceding stale entry if it existsif (slotToExpunge == staleSlot)slotToExpunge = i;cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);return;}if (k == null && slotToExpunge == staleSlot)slotToExpunge = i;}// If key not found, put new entry in stale slot// 若未找到 key,说明 Entry 此前并不存在,新增tab[staleSlot].value = null;tab[staleSlot] = new Entry(key, value);// If there are any other stale entries in run, expunge themif (slotToExpunge != staleSlot)cleanSomeSlots(expungeStaleEntry(slotToExpunge), len);
}
replaceStaleEntry 的主要执行流程如下:
从 staleSlot 向前遍历数组,直到 Entry 为空时停止遍历。这一步的主要目的是查找 staleSlot 前面过期的 Entry 的数组下标 slotToExpunge。
从 staleSlot 向后遍历数组
若 Entry 的 key 与给定的 key 相等,将该 Entry 与 staleSlot 下标的 Entry 互换位置。目的是为了让新增的 Entry 放到它「应该」在的位置。
若找不到相等的 key,说明该 key 对应的 Entry 不在数组中,将新值放到 staleSlot 位置。该操作其实就是处理哈希冲突的「线性探测」方法:当某个位置已被占用,向后探测下一个位置。
若 staleSlot 前面存在过期的 Entry,则执行清理操作。
PS: 所谓 Entry「应该」在的位置,就是根据 key 的 threadLocalHashCode 与数组长度取余计算出来的位置,即
k.threadLocalHashCode & (len - 1)
,或者哈希冲突之后的位置,这里只是为了方便描述。
expungeStaleEntry:清理过期的 Entry
// staleSlot 表示过期的槽位(即 Entry 数组的下标)
private int expungeStaleEntry(int staleSlot) {Entry[] tab = table;int len = tab.length;// 1. 将给定位置的 Entry 置为 nulltab[staleSlot].value = null;tab[staleSlot] = null;size--;// Rehash until we encounter nullEntry e;int i;// 遍历数组for (i = nextIndex(staleSlot, len);(e = tab[i]) != null;i = nextIndex(i, len)) {// 获取 Entry 的 keyThreadLocal<?> k = e.get();if (k == null) {// 若 key 为 null,表示 Entry 过期,将 Entry 置空e.value = null;tab[i] = null;size--;} else {// key 不为空,表示 Entry 未过期// 计算 key 的位置,若 Entry 不在它「应该」在的位置,把它移到「应该」在的位置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.while (tab[h] != null)h = nextIndex(h, len);tab[h] = e;}}}return i;
}
该方法主要做了哪些工作呢?
清空给定位置的 Entry
从给定位置的下一个开始向后遍历数组
若遇到 Entry 为 null,结束遍历
若遇到 key 为空的 Entry(即过期的),就将该 Entry 置空
若遇到 key 不为空的 Entry,而且经过计算,该 Entry 并不在它「应该」在的位置,则将其移动到它「应该」在的位置
返回 staleSlot 后面的、Entry 为 null 的索引下标
cleanSomeSlots:清理一些槽(Slot)
private boolean cleanSomeSlots(int i, int n) {boolean removed = false;Entry[] tab = table;int len = tab.length;do {i = nextIndex(i, len);Entry e = tab[i];// Entry 不为空、key 为空,即 Entry 过期if (e != null && e.get() == null) {n = len;removed = true;// 清理 i 后面连续过期的 Entry,直到 Entry 为 null,返回该 Entry 的下标i = expungeStaleEntry(i);}} while ( (n >>>= 1) != 0);return removed;
}
该方法做了什么呢?从给定位置的下一个开始扫描数组,若遇到 key 为空的 Entry(过期的),则清理该位置及其后面过期的槽。
值得注意的是,该方法循环执行的次数为 log(n)。由于该方法是在 set 方法内部被调用的,也就是新增/更新时:
如果不扫描和清理,set 方法执行速度很快,但是会存在一些垃圾(过期的 Entry);
如果每次都扫描清理,不会存在垃圾,但是插入性能会降低到 O(n)。
因此,这个次数其实就一种平衡策略:Entry 数组较小时,就少清理几次;数组较大时,就多清理几次。
rehash:调整 Entry 数组
private void rehash() {// 清理数组中过期的 EntryexpungeStaleEntries();// Use lower threshold for doubling to avoid hysteresisif (size >= threshold - threshold / 4)resize();
}// 从头开始清理整个 Entry 数组
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);}
}
该方法主要作用:
清理数组中过期的 Entry
若清理后 Entry 的数量大于等于 threshold 的 3/4,则执行 resize 方法进行扩容
resize 方法:Entry 数组扩容
/*** Double the capacity of the table.*/
private void resize() {Entry[] oldTab = table;int oldLen = oldTab.length;int newLen = oldLen * 2; // 新长度为旧长度的两倍Entry[] newTab = new Entry[newLen];int count = 0;// 遍历旧的 Entry 数组,将数组中的值移到新数组中for (int j = 0; j < oldLen; ++j) {Entry e = oldTab[j];if (e != null) {ThreadLocal<?> k = e.get();// 若 Entry 的 key 已过期,则将 Entry 清理掉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;
}
该方法的作用是 Entry 数组扩容,主要流程:
创建一个新数组,长度为原数组的 2 倍;
从下标 0 开始遍历旧数组的所有元素
若元素已过期(key 为空),则将 value 也置空
将未过期的元素移到新数组
3.4.2 get 方法
分析完了 set 方法,再看 get 方法就相对容易了不少。
get 方法:获取 ThreadLocal 对应的 Entry
public T get() {Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null) {ThreadLocalMap.Entry e = map.getEntry(this);if (e != null) {@SuppressWarnings("unchecked")T result = (T)e.value;return result;}}return setInitialValue();
}
get 方法首先获取当前线程的 ThreadLocalMap 并判断:
若 Map 已存在,从 Map 中取值
若 Map 不存在,或者 Map 中获取的值为空,执行 setInitialValue 方法
setInitialValue 方法:获取/设置初始值
private T setInitialValue() {// 获取初始值T value = initialValue();Thread t = Thread.currentThread();ThreadLocalMap map = getMap(t);if (map != null)map.set(this, value);elsecreateMap(t, value);return value;
}protected T initialValue() {return null;
}
先取初始值,这个初始值默认为空(该方法是 protected,可以由子类初始化)。
若 Thread 的 ThreadLocalMap 已初始化,则将初始值存入 Map
否则,创建 ThreadLocalMap
返回初始值
除了初始值,其他逻辑跟 set 方法是一样的,这里不再赘述。
PS: 可以看到初始值是惰性初始化的。
getEntry:从 Entry 数组中获取给定 key 对应的 Entry
private Entry getEntry(ThreadLocal<?> key) {// 计算下标int i = key.threadLocalHashCode & (table.length - 1);Entry e = table[i];// 查找命中if (e != null && e.get() == key)return e;elsereturn getEntryAfterMiss(key, i, e);
}// key 未命中
private Entry getEntryAfterMiss(ThreadLocal<?> key, int i, Entry e) {Entry[] tab = table;int len = tab.length;// 遍历数组while (e != null) {ThreadLocal<?> k = e.get();if (k == key)return e; // 是要找的 key,返回if (k == null)expungeStaleEntry(i); // Entry 已过期,清理 Entryelsei = nextIndex(i, len); // 向后遍历e = tab[i];}return null;
}
3.4.3 remove 方法
remove 方法:移除 ThreadLocal 对应的 Entry
public void remove() {ThreadLocalMap m = getMap(Thread.currentThread());if (m != null)m.remove(this);
}
这里调用了 ThreadLocalMap 的 remove 方法:
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;}}
}
其中 e.clear 调用的是 Entry 的父类 Reference 的 clear 方法:
public void clear() {this.referent = null;
}
其实就是将 Entry 的 key 置空。
remove 方法的主要执行流程如下:
获取当前线程的 ThreadLocalMap
以当前 ThreadLocal 做为 key,从 Map 中查找相应的 Entry,将 Entry 的 key 置空
将该 ThreadLocal 对应的 Entry 置空,并向后遍历清理 Entry 数组,也就是 expungeStaleEntry 方法的操作,前面已经分析过了,这里不再赘述。
3.4.4 主要方法小结
ThreadLocal 的主要方法 set、get 和 remove 前面已经分析过,这里简单做个小结。
set 方法
以当前 ThreadLocal 为 key、新增的 Object 为 value 组成一个 Entry,放入 ThreadLocalMap,也就是 Entry 数组中。
计算 Entry 的位置后
若该槽为空,直接放到这里;并清理一些过期的 Entry,必要时进行扩容。
当遇到散列冲突时,线性探测向后查找数组中为空的、或者已经过期的槽,用新值替换。
get 方法
以当前 ThreadLocal 为 key,从 Entry 数组中查找对应 Entry 的 value
若 ThreadLocalMap 未初始化,则用给定初始值将其初始化
若 ThreadLocalMap 已初始化,从 Entry 数组查找 key
remove 方法:以当前 ThreadLocal 为 key,从 Entry 数组清理掉对应的 Entry,并且再清理该位置后面的、过期的 Entry
方法虽少,但是稍微有点绕,除了做本身的功能,都执行了一些额外的清理操作。
分析了这几个方法的源码之后,下面就来研究一下内存泄漏的问题。
4. 内存泄漏分析
首先说明一点,ThreadLocal 通常作为成员变量或静态变量来使用(也就是共享的),比如前面应用场景中的例子。因为局部变量已经在同一条线程内部了,没必要使用 ThreadLocal。
为便于理解,这里先给出了 Thread、ThreadLocal、ThreadLocalMap、Entry 这几个类在 JVM 的内存示意图:
简单说明:
当一个线程运行时,栈中存在当前 Thread 的栈帧,它持有 ThreadLocalMap 的强引用。
ThreadLocal 所在的类持有一个 ThreadLocal 的强引用;同时,ThreadLocalMap 中的 Entry 持有一个 ThreadLocal 的弱引用。
4.1 场景一
若方法执行完毕、线程正常消亡,则 Thread 的 ThreadLocalMap 引用将断开,如图:
以后 GC 发生时,弱引用也会断开,整个 ThreadLocalMap 都会被回收掉,不存在内存泄漏。
4.2 场景二
如果是线程池中的线程呢?也就是线程一直存活。经过 GC 后 Entry 持有的 ThreadLocal 引用断开,Entry 的 key 为空,value 不为空,如图所示:
此时,如果没有任何 remove 或者 get 等清理 Entry 数组的动作,那么该 Entry 的 value 持有的 Object 就不会被回收掉。这样就产生了内存泄漏。
这种情况其实也很容易避免,使用完执行 remove 方法就行了。
5. 小结
本文分析了 ThreadLocal 的主要方法实现,并分析了它可能存在内存泄漏的场景。
ThreadLocal 主要用于当前线程从共享变量中保存一份「副本」,常用的一个场景就是单点登录保存用户的登录信息。
ThreadLocal 将数据存储在 ThreadLocalMap 中,ThreadLocalMap 是由 Entry 构成的数组,结构有点类似 HashMap。
ThreadLocal 使用不当可能会造成内存泄漏。避免内存泄漏的方法是在方法调用结束前执行 ThreadLocal 的 remove 方法。
有道无术,术可成;有术无道,止于术
欢迎大家关注Java之道公众号
好文章,我在看❤️
ThreadLocal到底有没有内存泄漏?从源码角度来剖析一波相关推荐
- Java Review - 线程池中使用ThreadLocal不当导致的内存泄漏案例源码分析
文章目录 概述 Why 内存泄露 ? 在线程池中使用ThreadLocal导致的内存泄漏 概述 ThreadLocal的基本使用我们就不赘述了,可以参考 每日一博 - ThreadLocal VS I ...
- ThreadLocal到底有没有内存泄漏?
点击上方蓝色"程序猿DD",选择"设为星标" 回复"资源"获取独家整理的学习资料! 1. 前言 ThreadLocal 也是一个使用频率较高 ...
- threadlocal使用场景_ThreadLocal有没有内存泄漏?源码给你安排得明明白白
1. 前言 ThreadLocal 也是一个使用频率较高的类,在框架中也经常见到,比如 Spring. 有关 ThreadLocal 源码分析的文章不少,其中有个问题常被提及:ThreadLocal ...
- fork的黑科技,它到底做了个啥,源码级分析linux内核的内存管理
最近一直在学习linux内核源码,总结一下 https://github.com/xiaozhang8tuo/linux-kernel-0.11 一份带注释的源码,学习用. fork的黑科技,它到底做 ...
- 透析ThreadLocal(以源码角度讲解原理)
目录 序言 初探ThreadLocal 深入ThreadLocal 拓展ThreadLocal 设置初始值 继承父线程的ThreadLocal 进阶ThreadLocal ThreadLocalMap ...
- C#共享内存实例 附源码
原文 C#共享内存实例 附源码 网上有C#共享内存类,不过功能太简单了,并且写内存每次都从开头写.故对此进行了改进,并做了个小例子,供需要的人参考. 主要改进点: 通过利用共享内存的一部分空间(以下称 ...
- 从源码角度来读Handler
最近打算从源码角度来读一下Handler,MessageQueue,Message,Looper,这四个面试必考项.所以今天先从Handler开始. 一.Handler的作用 源码定义 There a ...
- 从源码角度分析MapReduce的map-output流程
文章目录 前言 流程图 源码分析 1 runNewMapper方法 2.NewOutputCollector方法 2.1 createSortingCollector方法 2.1.1 collecto ...
- Mybatis底层原理学习(二):从源码角度分析一次查询操作过程
在阅读这篇文章之前,建议先阅读一下我之前写的两篇文章,对理解这篇文章很有帮助,特别是Mybatis新手: 写给mybatis小白的入门指南 mybatis底层原理学习(一):SqlSessionFac ...
最新文章
- git stash 个人理解
- ERROR Streaming result set com.mysql.jdbc.RowDataDynamic@1d5a7f6 is still active. No statements may
- JS-Object 对象的相关方法
- 中国大学MOOC-数据结构基础习题集、06-2、旅游规划
- linux如何停用xdmcp服务,如何禁用XDMCP服务
- JAVA分析命令:jps、jstack、jmap、jhat
- c/c++ 指针函数 和 函数指针
- 12.程序员的自我修养---系统调用与API
- java基础知识总结(经典)_Java基础知识总结(超级经典)(三)
- mac 设置环境变量path的几种方法
- 利用武汉市遥感影像进行土地利用分类分析
- srs流媒体服务器拉流的流程
- 市面上主流RTC竞品对比分析
- 【51Job数据爬取日志】前程无忧URL请求字段分析
- 数据标注员是职位,人工智能训练师是职业
- scrapy 两类item_手把手教你进行Scrapy中item类的实例化操作
- 大学物理质点动力学思维导图_《大学物理》(2019-2020-2)
- 安装完PHP后启动httpd报错:httpd: Syntax error on line 53 ...
- Maven项目打包时报错Cannot invoke “org.apache.maven.surefire.shade.org.apache.commons.lang3.JavaVersion.atLe
- 基础知识 | BOM 事件
热门文章
- 中山市区电信5g覆盖地图_热点 | 百度与中国电信达成战略合作 覆盖5G、互联网等领域...
- android 坐标绘图 缩放,在缩放/缩小或拖放到Android后获取画布坐标
- python编写计算器输入1或2代表+或x_Python实现两款计算器功能示例
- (计算机组成原理)第三章存储系统-第五节1:双端口RAM和多模块存储器
- 207. 课程表/210. 课程表 II
- js获取数组前n项的和
- 沉淀再出发:PHP的中级内容
- Python sqlalchemy orm 多对多外键关联
- 设计模式笔记-命令模式
- AJAX使用技巧:如何处理书签和翻页按扭