最后
权威指南-第一本Docker书
引领完成Docker的安装、部署、管理和扩展,让其经历从测试到生产的整个开发生命周期,深入了解Docker适用于什么场景。并且这本Docker的学习权威指南介绍了其组件的基础知识,然后用Docker构建容器和服务来完成各种任务:利用Docker为新项目建立测试环境,演示如何使用持续集成的工作流集成Docker,如何构建应用程序服务和平台,如何使用Docker的API,如何扩展Docker。
总共包含了:简介、安装Docker、Docker入门、使用Docker镜像和仓库、在测试中使用Docker、使用Docker构建服务、使用Fig编配Docke、使用Docker API、获得帮助和对Docker进行改进等9个章节的知识。
关于阿里内部都在强烈推荐使用的“K8S+Docker学习指南”—《深入浅出Kubernetes:理论+实战》、《权威指南-第一本Docker书》,看完之后两个字形容,爱了爱了!
1.2.3 Thread、ThreadLocal、ThreadLocalMap 的关系
1.3 ThreadLocal 里的 get、set 以及 remove 方法能保证不内存泄露吗?
这个问题使我们本文重点分析的问题,这里老周先说下结论。
get,set 两个方法都不能完全防止内存泄漏,还是每次用完 ThreadLocal 都勤奋的 remove 一下靠谱。
再详细分析这个问题之前,我们下面会来看下所需的前置知识。
2.1 什么是内存泄露?
首先你得知道什么叫内存泄露吧,不然后面分析会很吃力。
内存泄漏(Memory Leak)是指程序中已动态分配的堆内存由于某种原因程序未释放或无法释放,造成系统内存的浪费,导致程序运行速度减慢甚至系统崩溃等严重后果。
站在 Java 的角度来说,就是 JVM 创建的对象永远都无法访问到,但是 GC 又不能回收对象所占用的内存。少量的内存泄漏并不会出现什么严重问题,无非是浪费了一些内存资源罢了,但是随着时间的积累,内存泄漏的越来越多就会导致内存溢出,程序崩溃。
2.2 Java 四中引用类型
在 JDK1.2 之前,“引用”的概念过于狭隘,如果 Reference 类型的数据存储的是另外一块内存的起始地址,就称该 Reference 数据是某块地址、对象的引用,对象只有两种状态:被引用、未被引用。
这样的描述未免过于僵硬,对于这一类对象则无法描述:内存足够时暂不回收,内存吃紧时进行回收。例如:缓存数据。
在 JDK1.2 之后,Java 对引用的概念做了一些扩充,将引用分为四种,由强到弱依次为:
- 强引用
在 Java 中最常见的就是强引用,把一个对象赋给一个引用变量,这个引用变量就是一个强引用。当一个对象被强引用变量引用时,它处于可达状态,它是不可能被垃圾回收机制回收的,即使该对象以后永远都不会被用到 JVM 也不会回收。因此强引用是造成 Java 内存泄漏的主要原因之一。
- 软引用
软引用需要用 SoftReference 类来实现,对于只有软引用的对象来说,当系统内存足够时它不会被回收,当系统内存空间不足时它会被回收。软引用通常用在对内存敏感的程序中。
- 弱引用
弱引用需要用 WeakReference 类来实现,它比软引用的生存期更短,对于只有弱引用的对象来说,只要垃圾回收机制一运行,不管 JVM 的内存空间是否足够,总会回收该对象占用的内存。
- 虚引用
虚引用需要 PhantomReference 类来实现,它不能单独使用,必须和引用队列联合使用。虚引用的主要作用是跟踪对象被垃圾回收的状态。
三、为什么 ThreadLocalMap 采用开放地址法来解决哈希冲突
JDK 中大多数的类都是采用了链地址法来解决 hash 冲突,为什么 ThreadLocalMap 采用开放地址法来解决哈希冲突呢?首先我们来看看这两种不同的方式:
3.1 链地址法
这种方法的基本思想是将所有哈希地址为 i 的元素构成一个称为同义词链的单链表,并将单链表的头指针存在哈希表的第 i 个单元中,因而查找、插入和删除主要在同义词链中进行。列如对于关键字集合{28, 93, 90, 3, 21, 11, 19, 31, 18},我们假如数组的长度为 8,那我们用 8 为除数,进行除留余数法:
3.2 开放地址法
这种方法的基本思想是一旦发生了冲突,就去寻找下一个空的散列地址(这非常重要,源码都是根据这个特性,必须理解这里才能往下走),只要散列表足够大,空的散列地址总能找到,并将记录存入。
我们还是以上面的关键字集合 {28, 93, 90, 3, 21, 11, 19, 31, 18} 来演示,我们用散列函数 f(key) = key mod 16。 当计算前 S 个数 {28, 93, 90, 3, 21, 11} 时,都是没有冲突的散列地址,直接存入(蓝色下标代表已存入了数据,空白的下标可以存放数据):
当计算到集合中的 19 的时候,发现 f(19) = 3,此时就与 3 所在的位置冲突。
于是我们应用上面的公式f(19) = (f(19)+1) mod 10 = 4。于是将 19 存入下标为 4 的位置。这其实就是房子被人买了于是买下一间的作法,以此类推。
3.3 链地址法和开放地址法的优缺点
3.3.1 链地址
优点:
-
处理冲突简单,且无堆积现象,平均查找长度短;
-
链表中的结点是动态申请的,适合构造表不能确定长度的情况;
-
相对而言,拉链法的指针域可以忽略不计,因此较开放地址法更加节省空间;
-
插入结点应该在链首,删除结点比较方便,只需调整指针而不需要对其他冲突元素作调整。
缺点:
- 指针占用较大空间时,会造成空间浪费。
3.3.2 开放地址法
优点:
- 当节点规模较少,或者装载因子较少的时候,使用开发寻址较为节省空间,如果将链式表的指针用于扩大散列表的规模时,可使得装载因子变小从而减少了开放寻址中的冲突,从而提高平均查找效率。
缺点:
-
容易产生堆积问题;
-
不适于大规模的数据存储;
-
结点规模很大时会浪费很多空间;
-
散列函数的设计对冲突会有很大的影响;
-
插入时可能会出现多次冲突的现象,删除的元素是多个冲突元素中的一个,需要对后面的元素作处理,实现较复杂。
3.4 ThreadLocalMap 采用开放地址法原因
-
ThreadLocal 中看到一个属性 HASH_INCREMENT = 0x61c88647 ,0x61c88647 是一个神奇的数字,让哈希码能均匀的分布在 2 的 N 次方的数组里, 即 Entry[] table,关于这个神奇的数字网上有很多解析,这里就不多说了。
-
ThreadLocal 往往存放的数据量不会特别大(而且key 是弱引用又会被垃圾回收,及时让数据量更小),这个时候开放地址法简单的结构会显得更省空间,同时数组的查询效率也是非常高,加上第一点的保障,冲突概率也低。
上面我们已经说了内存泄露的概念,这里我还是再说下 ThreadLocal 的内存泄露是怎么回事。
根据 ThreadLocal 的源码,可以画出一些对象之间的引用关系图,实线表示强引用,虚线表示弱引用:
如上图,ThreadLocalMap 使用 ThreadLocal 的弱引用作为 key,如果一个 ThreadLocal 没有外部强引用它,那么系统 gc 的时候,这个 ThreadLocal 势必会被回收,这样一来,ThreadLocalMap 中就会出现 key 为 null 的 Entry,就没有办法访问这些 key 为 null 的 Entry 的 value,如果当前线程再迟迟不结束的话,这些 key 为 null 的 Entry 的 value 就会一直存在一条强引用链:
Thread Ref -> Thread -> ThreaLocalMap -> Entry -> value
永远无法回收,造成内存泄露。
那 Java 源码团队就没有啥解决方案吗?当然有,前面已经说过,由于 key 是弱引用,因此 ThreadLocal 可以通过 key.get()==null 来判断 key 是否已经被回收,如果 key 被回收,就说明当前 Entry 是一个废弃的过期节点,ThreadLocal 会自发的将其清理掉。
ThreadLocal 会在以下过程中清理过期节点:
-
调用 set() 方法时,采样清理、全量清理,扩容时还会继续检查。
-
调用 get() 方法,没有直接命中,向后环形查找。
-
调用 remove() 时,除了清理当前 Entry,还会向后继续清理。
那么正好回到我们前言的第三个问题:
还有说 ThreadLocal 存在内存泄露,但里面的 get、set 以及 remove 方法能防止 ThreadLocal 内存泄露问题。
那么老周的问题是:get、set 以及 remove 方法真的能防止 ThreadLocal 内存泄露吗?
这里你自己可以翻看源码思考下再来看我接下写的,这样有思考收获才更大。
这里分界线假装你思考完了哈,那我们就来讲本文的最重要的一部分了。
事先约定:
4.1 remove 方法能否防止内存泄露?
一开始都是有效的 entry,并且每个 entry 的 key 通过散列算法后算出的位置都是自己所在的位置(都在自己的位置上的话之后的线性清扫中不会造成搬移,因为 ThreadLocalMap 的散列表用的是开放地址法,所以 entry 可能因为 hash 冲突而不在自己位置上)
要达成下面的效果,就要一直没有失效的 entry 出现,并且一直实现插入,也就是一直执行 set 方法。
假设 entry 循环数组有 16 个槽位
如果执行一次 remove,把图中的某个 entry 无效化。
然后我们来看下 ThreadLocal#remove 方法的实现:
因为每个 entry 都在自己的位置上,所以下图的 if (e.get() == key)
会在第一个循环就成立,也就是 remove 会执行 e.clear()
来把弱引用置空,无效化,并且执行一次线性清扫后返回。
我们跟着 expungeStaleEntry 方法进去看:
上面画红框的部分把要清扫的位置给置空了(黑色下标 entry 的槽位置空):
向后遍历整个数组,直到遇到空槽为止,并且第一种情况 (k == null)
为真的情况下,会把无效 entry 置空,防止内存泄漏。
其实就是向后扫描,遇到无效的就顺带干掉,直到遇到空槽为止。
接着再看第二种情况 (k != null)
的分支的(h != i)
场景:
也就是说遇到的 entry 是有效的,但是不是在自己原本的位置上,而是被 hash 冲突到其它位置上了,则把它们搬去离原本位置最近的后边空槽上。这样在 get 的时候,会最快找到这个 entry,减少开放地址法遍历数组的时间。
小结:
-
执行 remove 方法后,会执行
e.clear()
来把弱引用置空,无效化。 -
并且执行一次线性清扫后返回。
-
线性清扫把要清扫的位置给置空了,然后继续往后遍历,直到遇到空槽位为止,如果遇到无效entry, 就把无效 entry 的槽位置空,防止内存泄漏。
-
第二种情况可能遇到的 entry 是有效的,但是不是在自己原本的位置上,而是被 hash 冲突到其它位置上了,则把它们搬去离原本位置最近的后边空槽上。这样在 get 的时候,会最快找到这个 entry,减少开放地址法遍历数组的时间。
结论:
remove 方法能防止内存泄露
4.2 set 方法能否防止内存泄露?
看完 remove 方法后,我们再来看下 set 方法能否防止内存泄漏。
因为每个 entry 都在自己的位置上,并且没有遇到无效的 entry,最终的效果只是把 remove 的位置置为空槽。所以上面 remove 方法得到的效果图:
同理,假设再经历 4 次 remove,可以得出下面的效果图:
如果此时,这时候正好有两个 entry 的 key,也即是 ThreadLocal 的所有强应用被置空,于是这两个 entry 无效。
如果之后只执行 set 方法,是否会内存泄漏呢?是否任意调用 set 之后就保证内存不会泄漏了呢?
带着这两个问题我们来看下 ThreadLocal#set 方法
4.2.1 代码块①
遇到 key 和我们当前调用 set 的 ThreadLocal 相等的 entry,则只用直接把 entry 的 value 设置一下就好了,和 HashMap 的 put(key, A); put(key, B); 中 A 被替换成 B 同理。
4.2.2 代码块②
遇到无效 entry,是我们重点关注的地方。
4.2.3 代码块③
遇到空槽,直接插入,并且尝试指数清扫,如果指数清扫不成功并且当前 entry 的使用槽数到达阈值则重散列。
最后
每年转战互联网行业的人很多,说白了也是冲着高薪去的,不管你是即将步入这个行业还是想转行,学习是必不可少的。作为一个Java开发,学习成了日常生活的一部分,不学习你就会被这个行业淘汰,这也是这个行业残酷的现实。
如果你对Java感兴趣,想要转行改变自己,那就要趁着机遇行动起来。或许,这份限量版的Java零基础宝典能够对你有所帮助。
前 entry 的使用槽数到达阈值则重散列。
最后
每年转战互联网行业的人很多,说白了也是冲着高薪去的,不管你是即将步入这个行业还是想转行,学习是必不可少的。作为一个Java开发,学习成了日常生活的一部分,不学习你就会被这个行业淘汰,这也是这个行业残酷的现实。
如果你对Java感兴趣,想要转行改变自己,那就要趁着机遇行动起来。或许,这份限量版的Java零基础宝典能够对你有所帮助。
[外链图片转存中…(img-2gksSZst-1715347549013)]