定义
将任意长度的二进制值串映射为固定长度的二进制值串,这个映射的规则就是哈希算法,而通过原始数据映射之后得到的二进制值串就是哈希值。
要求
- 从哈希值不能反向推导出原始数据(所以哈希算法也叫单向哈希算法)
- 对输入数据非常敏感,哪怕原始数据只修改了一个 Bit,最后得到的哈希值也大不相同
- 散列冲突的概率要很小,对于不同的原始数据,哈希值相同的概率非常小
- 哈希算法的执行效率要尽量高效,针对较长的文本,也能快速地计算出哈希值
应用
- 安全加密
- 唯一标识
- 数据校验
- 散列函数、
- 负载均衡
- 数据分片
人的知识就好比一个圆圈,圆圈里面是已知的,圆圈外面是未知的。你知道得越多,圆圈也就越大,你不知道的也就越多。
散列表用的是数组支持按照下标随机访问数据的特性,所以散列表其实就是数组的一种扩展,由数组演化而来。可以说,如果没有数组,就没有散列表。
散列函数,顾名思义,它是一个函数。我们可以把它定义成hash(key),其中key表示元素的键值,hash(key)的值表示经过散列函数计算得到的散列值。
在散列表中,每个“桶(bucket)”或者“槽(slot)”会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
相较于链表发,开放寻址法优点在于散列表中的数据都存储在数组中,可以有效地利用 CPU 缓存加快查询速度,另外它不包含指针,序列化起来比较简单,缺点则是用开放寻址法解决冲突的散列表,删除数据的时候比较麻烦,需要特殊标记已经删除掉的数据。而且,在开放寻址法中,所有的数据都存储在一个数组中,比起链表法来说,冲突的代价更高。
当数据量比较小、装载因子小的时候,适合采用开放寻址法。这也是Java中的ThreadLocalMap使用开放寻址法解决散列冲突的原因。
基于链表的散列冲突处理方法比较适合存储大对象、大数据量的散列表,而且,比起开放寻址法,它更加灵活,支持更多的优化策略,比如用红黑树代替链表。
散列表的装载因子 = 填入表中的元素个数 / 散列表的长度
装载因子越大,说明空闲位置越少,冲突越多,散列表的性能会下降。不仅插入数据的过程要多次寻址或者拉很长的链,查找的过程也会因此变得很慢。
装载因子阈值的设置要权衡时间、空间复杂度。如果内存空间不紧张,对执行效率要求很高,可以降低负载因子的阈值;相反,如果内存空间紧张,对执行效率要求又不高,可以增加负载因子的值,甚至可以大于1。、、
当散列表的装载因子超过某个阈值时,就需要进行扩容。装载因子阈值需要选择得当。如果太大,会导致冲突过多;如果太小,会导致内存浪费严重。
通过这样均摊的方法,将一次性扩容的代价,均摊到多次插入操作中,就避免了一次性扩容耗时过多的情况。这种实现方式,任何情况下,插入一个数据的时间复杂度都是 O(1)。
所谓“没有共享,就没有伤害”,ThreadLocal 是一个本地线程副本变量工具类,它主要用于将私有线程和该线程存放的副本对象做一个映射,各个线程之间的变量互不干扰,在高并发场景下,可以实现无状态的调用,特别适用于各个线程依赖不同的变量值完成操作的场景。
1 | /** |
在解释 ThreadLocal 的工作原理之前,我们可以先自己想想:如果让我们来实现 ThreadLocal 的功能,会怎么设计呢?ThreadLocal 的目标是让不同的线程有不同的变量 v,那最直接的方法就是创建一个 Map,它的 Key 是线程,value 是每个线程拥有的变量 V,ThreadLocal 内部持有这样的一个 Map 就可以了。如下代码所示:
1 | class MyThreadLocal<T> { |
Java的实现里面也有一个Map,叫做 ThreadLocalMap ,不过持有 ThreadLocalMap 的不是 ThreadLocal,而是 Thread。Thread 这个类内部有一个私有属性 threadLocals,其类型就是 ThreadLocalMap,ThreadLocalMap 的 key 是 ThreadLocal。我们可以结合下面的示意图和精简之后的 Java 实现代码来理解。
1 | class Thread { |
初看上去,我们的设计方案和 Java 的实现仅仅是 Map 的持有方不同而已,我们的设计里面 Map 属于 ThreadLocal,而 Java 的实现里面 ThreadLocalMap 则是属于 Thread。这两种方式哪种更合理呢?
很显然 Java 的实现更合理一些。在 Java 的实现方案里面,ThreadLocal 仅仅是一个代理工具类,内部并不持有任何与线程相关的数据,所有和线程相关的数据都存储在Thread里面,这样的设计容易理解。而从数据的亲缘性上来讲,ThreadLocalMap 属于 Thread 也更加合理。
当然还有一个更加深层次的原因,那就是不容易产生内存泄露。在我们的设计方案中,ThreadLocal 持有的 Map 会持有 Thread 对象的引用,这就意味着,只要 ThreadLocal 对象存在,那么 Map 中的 Thread 对象就永远
不会被回收。ThreadLocal 的生命周期往往都比线程要长,所以这种设计方案很容易导致内存泄露。而 Java 的实现中 Thread 持有 ThreadLocalMap,而且 ThreadLocalMap 里对 ThreadLocal 的引用还是弱引用(WeakReference),所以只要 Thread 对象可以被回收,那么 ThreadLocalMap 就能被回收。Java 的这种实现方案虽然看上去复杂一些,但是更加安全。
ThreadLocal的原理:每个Thread内部维护着一个ThreadLocalMap,它是一个Map。这个映射表的Key是一个弱引用,其实就是ThreadLocal本身,Value是真正存的线程变量Object。
也就是说ThreadLocal本身并不真正存储线程的变量值,它只是一个工具,用来维护Thread内部的Map,帮助存和取。注意上图的虚线,它代表一个弱引用类型,而弱引用的生命周期只能存活到下次GC前。
Java 的 ThreadLocal 实现应该称得上深思熟虑了,不过即便如此深思熟虑,还是不能百分百地让程序员避免内存泄露,例如在线程池中使用 ThreadLocal,如果不谨慎就可能导致内存泄露。
之所以会内存泄露,是因为在线程池中线程的存活时间太长,往往都是和程序同生共死的,这就意味着 Thread 持有的 ThreadLocalMap 一直都不会被回收,但是 ThreadLocalMap 中的 Entry 对 ThreadLocal 是弱引用(WeakReference),这意味着只要ThreadLocal结束了自己的生命周期是可以被回收掉的(在下次JVM垃圾收集时),但是 Entry 中的 value 却是被 Entry 强引用的,所以即便value的生命周期结束了,value 也是无法被回收的,从而导致内存泄露。
Java 做了一些措施来保证 ThreadLocal 尽量不会内存泄漏:在 ThreadLocal 的 get()、set()、remove() 方法调用的时候会清除掉线程 ThreadLocalMap 中所有 Entry 中 key 为 null 的 value,并将整个 Entry 设置为 null,利于下次内存回收。
但这样也并不能保证 ThreadLocal 不会发生内存泄漏,例如:
每次使用完 ThreadLocal,都调用它的 remove() 方法手动释放对 value的强引用,清除数据。
1 | ExecutorService es; |
通过 ThreadLocal 创建的线程变量,其子线程是无法继承的。也就是说我们在线程中通过 ThreadLocal 创建了线程变量 V,而后该线程创建了子线程,那么在子线程中是无法通过 ThreadLocal 来访问父线程的线程变量 V 的。
如果需要子线程继承父线程的线程变量,我们可以使用 InheritableThreadLocal 来支持这种特性,InheritableThreadLocal 是 ThreadLocal 子类,所以用法和 ThreadLocal相同。
不过,完全不建议大家在线程池中使用 InheritableThreadLocal,不仅仅是因为它具有 ThreadLocal 相同的缺点——可能导致内存泄露,更重要的原因是:线程池中线程的创建是动态的,很容易导致继承关系错乱,如果我们的业务逻辑依赖 InheritableThreadLocal,那么很可能导致业务逻辑计算错误,而这个错误往往比内存泄露更要命。
之所以要求特征1,是因为完全二叉树要求除了最后一层,其他层的节点个数都是满的,最后一层的节点都靠左排列,这样用数组存储就不会有空隙。
对于每个节点的值都大于等于子树中每个节点值的堆,我们叫作“大顶堆”。对于每个节点的值都小于等于子树中每个节点值的堆,我们叫作“小顶堆”。
上图中,1、2是大顶堆,3是小顶堆,4不是顶堆。
根据堆的特征1,堆适用用数组来存储。
从图中我们可以看到,数组中下标为i的节点的左子节点,就是下标为i ∗ 2i的节点,右子节点就是下标为i ∗ 2 + 1的节点,父节点就是下标为i/2的节点。
让新插入的节点与父节点对比大小。如果不满足子节点小于等于父节点的大小关系,我们就互换两个节点。一直重复这个过程,直到父子节点之间满足刚说的那种大小关系。
示例:
代码:
1 | public class Heap { |
把最后一个节点放到堆顶,然后利用同样的父子节点对比方法。对于不满足父子节点大小关系的,互换两个节点,并且重复进行这个过程,直到父子节点之间满足大小关系为止。这就是从上往下的堆化方法。
示例:
代码:
1 | public void removeMax() { |
一个包含n个节点的完全二叉树,树的高度不会超过log2n。
堆化的过程是顺着节点所在路径比较交换的,所以堆化的时间复杂度跟树的高度成正比,也就是O(logn)。
插入数据和删除堆顶元素的主要逻辑就是堆化,所以,往堆中插入一个元素和删除堆顶元素的时间复杂度都是O(logn)。
可以把堆排序的过程大致分解成两个大的步骤,建堆和排序。
有“从下往上堆化”和“从上往下堆化”两种方式,后者效率更高,因为它只需要依次堆化非叶子节点。建堆时间复杂度为O(n)。
将下表从1到n的数据依次插入到堆中。
因为叶子节点往下堆化只能自己跟自己比较,所以我们直接从第一个非叶子节点开始,依次堆化就行了。
示例:
代码:
1 | private static void buildHeap(int[] a, int n) { |
建堆结束之后,数组中的数据已经是按照大顶堆的特性来组织的。
数组中的第一个元素就是堆顶,也就是最大的元素。我们把它跟最后一个元素交换,那最大元素就放到了下标为n的位置。
这个过程有点类似上面讲的“删除堆顶元素”的操作,当堆顶元素移除之后,我们把下标为n的元素放到堆顶,然后再通过堆化的方法,将剩下的n−1个元素重新构建成堆。
堆化完成之后,我们再取堆顶的元素,放到下标是n−1的位置,一直重复这个过程,直到最后堆中只剩下标为 111 的一个元素,排序工作就完成了。
示例:
代码:
1 | // n 表示数据的个数,数组 a 中的数据从下标 1 到 n 的位置。 |
时间复杂度:O(nlogn)
优先级队列
TOP K
中位数
如果“为每个任务分配一个线程”,那么当需要创建大量线程时,会有以下问题:
反之,合理地使用线程池不仅能避免上述问题的发生,还具备以下好处:
线程池的主要处理流程如下图所示:
从图中可以看出,当提交一个新任务到线程池时,线程池的处理流程如下:
线程池提供了以下构造函数:
1 | public ThreadPoolExecutor(int corePoolSize, |
corePoolSize
,那么即使其它空闲的基本线程能够执行新任务,线程池也会创建新的线程。prestartAllCoreThreads
方法。maximumPoolSize
,则线程池会再创建新的线程执行任务。Executors.newFixedThreadPool()
和Executors.newSingleThreadExecutor()
使用了这个队列。Executors.newCachedThreadPool()
使用了这个队列。1 | new ThreadFactoryBuilder().setNameFormat("xx-task-%d").build(); |
Executors
中定义了默认的实现DefaultThreadFactory
:1 | static class DefaultThreadFactory implements ThreadFactory { |
线程池默认的拒绝策略会throw RejectedExecutionException
,这是个运行时异常,对于运行时异常编译器并不强制catch它,所以开发人员很容易忽略。因此默认拒绝策略要慎重使用。如果线程池处理的任务非常重要,建议自定义自己的拒绝策略;并且在实际工作中,自定义的拒绝策略往往和降级策略配合使用。
1 | /** |
1 | /** |
1 | /** |
使用线程池,需要注意异常处理的问题,例如通过ThreadPoolExecutor
对象的execute()
方法提交任务时,如果任务在执行的过程中出现运行时异常,会导致执行任务的线程终止;不过,最致命的是任务虽然异常了,但是开发人员却获取不到任何通知,这会让开发人员误以为任务都执行得很正常。虽然线程池提供了很多用于异常处理的方法,但是最稳妥和简单的方案还是捕获所有异常并按需处理,我们可以参考下面的示例代码。
1 | try { |
1 | /** |
SingleThreadExecutor是使用单个Worker线程的Executor,下面是源码实现:
1 | public static ExecutorService newSingleThreadExecutor() { |
CachedThreadPool是一个会根据需要创建新线程的线程池,下面是源码实现:
1 | public static ExecutorService newCachedThreadPool() { |
SynchronousQueue
作为线程池的工作队列,它是一个没有容量的阻塞队列,每个插入操作必须等待另一个线程对应的移除操作,反之亦然。CachedThreadPool运行示意图如下:
如果在系统总大量使用线程池,则有必要对线程池进行监控,方便在出现问题时,可以根据线程池的使用状况快速定位问题。在监控线程池的时候可以使用以下属性:
另外还能通过继承线程池来自定义线程池,重写线程池的方法,来实现在任务执行前、执行后和线程池关闭前执行一些代码来进行监控。例如,监控任务的平均执行时间、最大执行时间和最小执行时间等。
1 | public class CustomThreadPoolExecutor extends ThreadPoolExecutor { |
要想合理地配置线程池,就必须首先分析任务特性,可以从以下几个角度来分析:
Runtime.getRuntime.availableProcessors()
方法获得当前设备地CPU个数。PriorityBlockingQueue
来处理。首先,看下线程池执行任务的方法execute
:
1 | /** |
然后,再看下Worker
是怎么执行任务的:
1 | final void runWorker(Worker w) { |
每个节点有三个字段,其中一个存储数据,另外两个是指向左右子节点的指针。我们只要拎住根节点,就可以通过左右子节点的指针,把整棵树都串起来。
把根节点存储在下标i = 1的位置,那左子节点存储在下标2 * i = 2的位置,右子节点存储在2 * i + 1 = 3的位置。以此类推。
对于树中的任意节点来说,先打印这个节点,然后再打印它的左子树,最后打印它的右子树。
1 | ```java |
对于树中的任意节点来说,先打印它的左子树,然后再打印这个节点,最后打印它的右子树。
1 | ```java |
对于树中的任意节点来说,先打印它的右子树,然后再打印它的左子树,最后打印这个节点。
1 | ```java |
按层遍历。
1 | public List<List<Integer>> levelOrder(TreeNode root) { |
1 | public BinaryTree find(BinaryTree tree, int data) { |
1 | public void insert(BinaryTree tree, int data) { |
1 | public void delete(BinaryTree tree, int data) { |
在前面的操作中,都是假定不存在键值相同的情况。如果存在重复数据,有两种解决方式。
方式一
一种是二叉查找树中每一个节点不仅会存储一个数据,因此我们通过链表和支持动态扩容的数组等数据结构,把值相同的数据都存储在同一个节点上。
方式二
另一种是每个节点仍然只存储一个数据。
在查找插入位置的过程中,如果碰到到一个节点的值,与要插入数据的值相同,我们就将这个要插入的数据放到这个节点的右子树,也就是说,把这个新插入的数据当作大于这个节点的值来处理。
当要查找数据的时候,遇到值相同的节点,我们并不停止查找操作,而是继续在右子树中查找,直到遇到叶子节点,才停止。这样就可以把键值等于要查找值的所有节点都找出来。
对于删除操作,我们也需要先查找到每个要删除的节点,然后再按前面讲的删除操作的方法,依次删除。
B+树通常用于实现索引,像Mysql索引就是基于B+树实现的,而索引除了要支持增删改查之外,还需要支持范围查询。并且由于索引通常是存在与磁盘上,这就还要求磁盘访问次数不能过多。
我们可以对二叉查找树进行改造,从而让其支持区间查询:树中的节点并不存储数据本身,而是只是作为索引。除此之外,我们把每个叶子节点串在一条链表上,链表中的数据是从小到大有序的。经过改造之后的二叉树,就像图中这样:
但是,当索引数据量达到几千万甚至上亿的时候,如果将索引存储在内存中,尽管内存访问的速度非常快,查询的效率非常高,但是占用的内存会非常多。
我们可以借助时间换空间的思路,把索引存储在硬盘中。不过磁盘是一个非常慢速的存储设备。通常内存的访问速度是纳秒级别的,而磁盘访问的速度是毫秒级别的。读取同样大小的数据,从磁盘中读取花费的时间,是从内存中读取所花费时间的上万倍,甚至几十万倍。这种将索引存储在硬盘中的方案,尽管减少了内存消耗,但是在数据查找的过程中,需要读取磁盘中的索引,因此数据查询效率就相应降低很多。
那么,接下来优化的重点就是尽量减少磁盘IO操作,也就是尽量降低树的高度:实现m叉树。
不管是内存中的数据,还是磁盘中的数据,操作系统都是按页来读取的,一次会读一页的数据。如果要读取的数据量超过一页的大小,就会触发多次IO操作。所以,我们在选择m大小的时候,要尽量让每个节点的大小等于一个页的大小。读取一个节点,,只需要一次磁盘IO操作。
对于一个B+树来说,m值是根据页的大小事先计算好的,也就是说,每个节点最多只能有m个子节点。在往数据库中写入数据的过程中,这样就有可能使索引中某些节点的子节点个数超过m,这个节点的大小超过了一个页的大小,读取这样一个节点,就会导致多次磁盘IO操作。
我们只需要将这个节点分裂成两个节点。但是,节点分裂之后,其上层父节点的子节点个数就有可能超过 m 个。不过这也没关系,我们可以用同样的方法,将父节点也分裂成两个节点。这种级联反应会从下往上,一直影响到根节点。
频繁的数据删除,就会导致某些结点中,子节点的个数变得非常少,长此以往,如果每个节点的子节点都比较少,势必会影响索引的效率。
我们可以设置一个阈值。在B+树中,这个阈值等于m/2。如果某个节点的子节点个数小于m/2,我们就将它跟相邻的兄弟节点合并。不过,合并之后结点的子节点个数有可能会超过m。针对这种情况,我们可以借助插入数据时候的处理方法,再分裂节点。
B* 树是B+树的变体,在B+树的非根和非叶子结点再增加指向兄弟的指针;B树定义了非叶子结点关键字个数至少为(2/3)M,即块的最低使用率为2/3(代替B+树的1/2)。
红黑树是一种自平衡二叉树:即在频繁的动态更新过程中,不会出现树的高度远大于log2n的情况,从而导致各个操作的效率下降。
红黑树具有以下性质:
根据性质3可知,最短的可能路径都是黑节点,最长的可能路径都是交替的黑节点和红节点,而性质4要求所有路径都包含相同数目的黑色节点,这就约束强制了红黑树的关键性质:从根节点到叶子节点的最长的可能路径不多于最短的可能路径的两倍长,即树的高度不会大于2lon2n。
我们知道,魔方的复原解法是由固定的算法的:遇到哪几面是什么样子,就对应怎么转几下。红黑树的自平衡过程跟魔方的复原非常神似,大致过程就是:遇到什么样的节点排布,就对应怎么去调整。只要按照这些固定的调整规则来操作,就能将一个非平衡的红黑树调整成平衡的。
上面的性质5:每个叶子节点都是黑色的空节点(NIL),就是为了方便的实现自平衡而添加的约束。
至于红黑树具体是怎样进行自平衡操作的,则首先需要了解两个非常重要的操作:左旋(rotate left)、右旋(rotate righ)。示例:
我们可以通过向ExecutorService
提交Callable
或FutureTask
来异步获取线程执行结果,但这种方式的缺陷在于Future.get()
会阻塞主线程,导致即使和它同时进行的其它线程已经执行完毕,也要等待这个耗时线程执行完才能获取结果,大大影响运行效率。ExecutorCompletionService
聚合了Executor
和BlockingQueue
,利用它我们每次都能从阻塞队列中获取到最近执行完毕的futureTask
,而避免等待某个耗时较长的任务执行。
1 | /** |
如上,我们通过调用submit(Callable<V> task)
方法向CompletionService
提交任务,获取任务结果则是先调用take()
获取到futureTask
,再调用futureTask.get()
获取任务执行结果,如果take()
没获取到futureTask
,主线程就会一直阻塞。
CompletionService
内部聚合了Executor
和BlockingQueue
,这样向其提交的任务都会交由Executor
执行,任务结果则存放在BlockingQueue
中,于是就能利用BlockingQueue
的特性,使得在获取任务结果时,如果还没有任务完成,就可以选择阻塞或返回null。
至于任务结果是如何存放到BlockingQueue
中的,则是通过将任务包装成QueueingFuture
,QueueingFuture
继承自FutureTask
并覆盖了done()
方法:task自行完毕后将结果保存到BlockingQueue
中。
源码如下:
1 | public class ExecutorCompletionService<V> implements CompletionService<V> { |
分治算法(divide and conquer)的核心思想其实就是四个字,分而治之 ,也就是将原问题划分成 n 个规模较小,并且结构与原问题相似的子问题,递归地解决这些子问题,然后再合并其结果,就得到原问题的解。
分治算法一般都比较适合用递归来实现。分治算法的递归实现中,每一层递归都会涉及这样三个操作:
分治算法能解决的问题,一般需要满足下面这几个条件