HashMap分析(线程不安全)
通过数组+链表的方式组合构成,通过hash的方式找到数组的下标
hash碰撞问题 -> 多个key通过hash运算后得到同一个数组下标
- 线性探索(开放寻址法) ,threadLocal通过这个方法解决,用通俗易懂的语音描述就是:在插入的过程中定位到table中的位置判断是否为空,若为空直接设置值,若不为空则,且Entry对象的key刚好是设置的key值则刷新value,如果不一致则找下一个位置,知道不为空为止
- 链式地址法,hashMap通过此方法解决,在插入的过程中,若table中有值则插入到链表中(这里分成头插法和尾插法),1.8之后换成了尾插法,因为在多线程中若用头插法可能出现环形链,会抛出异常
- 再hash法(通过多个hash函数),布隆过滤器中使用了(主要是解决redis中的缓存穿透),通过多次hash然后去比较hash后的值是否都为1,若有一个为0,则说明不存在,若都为1,并不能说明一定存在,会有些许误差,具体可以参考redsi相关学习记录
- 建立公共溢出区,为所有冲突的关键字记录建立一个公共的溢出区来存放。在查找时,对给定关键字通过散列函数计算出散列地址后,先与基本表的相应位置进行比对,如果相等,则查找成功;如果不相等,则到溢出表进行顺序查找。如果相对于基本表而言,在有冲突的数据很少的情况下,公共溢出区的结构对查找性能来说还是非常高的
头插法、尾插法
- 当发生hash碰撞时,会在数组所在的位置上通过链表的方式添加,在java8之前通过头插法的方式插入,会将原有的值记到链表下,当容量不够时需要扩容时,进行resize,新建一个2倍于之前的长度,然后进行rehash,若是在多线程的环境下有可能出现环形链,导致抛出异常
- 在java8后优化了这种插入方式,通过尾插的方式,在rehash时会保持原有的链表顺序,不会出现环形链
- resize -> capacity:hashMap当前长度的,LoadFactor :负载因子默认为0.75f,当容量超过0.75时进行扩容,重新创建一个entry空数组,长度为原先的两倍
- rehash -> 因为是通过hash计算出所在数组的位置,而hash的公式是:index = hashCode (key) & (length -1) 与长度相关,所以长度发生变化之后,需要重新计算位置
在java1.8之后hashMap的链表长度超过8之后会转化成为红黑树
- 链表的寻址时间复杂度为O(N)
- 红黑树的寻址时间复杂度为O(lgN)
- 根据泊松分布,在负载因子默认为0.75的时候,单个hash槽内(即hash碰撞后)元素个数为8的概率小于百万分之一,所以将7作为一个分水岭,等于7的时候不转换,大于等于8的时候才进行转换,小于等于6的时候就化为链表。
初始容量为16,底层中即使传入了初始容量的值,也会找到最近的2的幂次值
- 为了减少hash碰撞的几率,使得元素分布的更加均匀
- 因为2的幂次方转化为二进制的低位值全是1,那么计算数组中的位置时,只要hashCode(key)的低位值够散列,那么key的位置就会更加均匀
- hashCode(key)如何更散列 -> 通过hashCode ^ (hashCode >>> 16) 计算之后得到,
- ^ : 为异或 , >>>:为不带符号位的右移,那么上面的公式就可以理解为hashCode异或hashCode的高16位
- 为啥用异或,因为如果是与、或都会偏向于0或1这样没法更加散列
线程安全的map集合
- hashTable -> 通过对每个方法加上synchronized(重量级锁)使得线程安全,并发效率低
- synchronized 在java1.6之前是一个重量级锁,通过monitor监视器锁来实现,每个对象中都会存在一个monitor与之关联,当线程持有monitor时就变成了重量级锁,其他线程会被阻塞,通过monitorrenter和monitorexit实现
- 线程的阻塞和唤醒,操作系统需要在用户态和内核态切换,这个切换非常的耗时
- monitor的本质是基于操作系统的mutex Lock互斥锁实现,这种依赖于mutex互斥锁实现的称为重量级锁
- 在java1.6之后进行了优化,有4个状态,无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁,具体可以参考线程篇
- Collections.synchronizedMap(Map)创建线程安全的map集合,也是通过对方法加锁的方式实现,会存在一个mutex互斥锁对象
- CurrentHashMap
- 在java1.7时,通过对table分块分到segment中,segment继承了reentranlock,采用分段锁的方式实现,但是这种方式一次锁住了多个key,在一定的程度上是比上面的两个快上不少,但是可以针对单个key,将锁的粒度细化,在效率上可以提升更多,实现线程安全 -> 通过scanAndLockForPut进行自旋尝试获取锁,如果自旋的次数到达最大阈值就会改为阻塞获取锁,保证能成功获取锁,因为数据结构还是数组+链表的方式获取,所以在查询效率上还是会很低(通过volatile实现多线程可见性、有序性,所以在get的时候不需要加锁,只需要遍历数组+链表即可)
- 在java1.8时,优化上面的缺陷,首先数据结构发生了变化,舍去了segment分段锁的实现方式,通过CAS + synchronized实现并发安全性,也把之前的hashEntry修改成了Node,作用是一样的,然后对其的值和next用volatile修饰,来保证get的时候不需要加锁,并且引入了红黑树,在链表长度大于一定值时转化成红黑树(默认值为8)
- put的操作比较复杂步骤大致可以分成以下步骤,具体的源码解析可以参考concurrentHashMap 1.8源码解析
- 首先根据key计算出hashcode
- 判断是否需要初始化,初始化的大小为16,具体原因上面分析过了
- 为当前的key定位出具体的位置,如果为空,通过CAS原子操作进行写入,失败的话进行自旋保证成功
- CAS -> compare and swap 比较并交换,基于底层实现,但是会出现ABA问题,但是在并发的最终结果上是不会发生变化的。
- ABA问题:如mysql中也会存在此类问题,大致解释为:此时来了一个线程将值改为B,然后又来了一个线程将值改回为A,这时候CAS就无法区分出有没有被改动过,但是如果只追求最后的结果正确,就没有影响
- 当然也有解决方案的,比如通过加上版本号,值与版本号一起判断,就可以避免,也可以通过时间戳去实现
- 如果当前的位置 hashcode == move == -1 ,则需要进行扩容、
- 如果都不满足则通过synchronized进行写入
- 当链表数量大于treeif_threshold 阈值时转化为红黑树
- java.util包下的集合中的一种机制快速失败- fail-fast,在使用迭代器时,此时如果值发生了改变就会抛出异常Concurrent Modification Exception
- 具体的原理为会存在一个modCount的变量,如果值发生变化时就会改变这个值,迭代器在迭代时,会去比较这个值是否发生改变
arrayList-通过数组实现
- 查询效率高,增删效率低,且线程不安全
- 通过无参构造的方式初始化,不会初始化数组容量,只有在第一次添加进行add的时候才会进行初始化容量,默认分配为10
- 扩容的方式,重新定一个空数组,长度为原先的1.5倍,然后把原先数组的指向地址换成新的地址,完成扩容,通过ensureCapacityInternal 判断长度,是否需要进行扩容,在java8时优化了通过位移的方式去提高效率,右移一位,其实就是除以2
- 若为指定位置的插入,实现方式大概为,先复制一下数组,在指定位置中空出,然后指定的位置进行插入
- 删除与指定位置的插入相似,都是通过拷贝的操作完成,所以增删的效率比较低
线程安全的List
- vector ,实现方式也是在所有的方法上都加上了synchronized
- 作为vector的代替 -> CopyOnWriteArrayList ,Copy-On-Write容器即写时复制的容器,当我们往一个容器添加元素的时候,不直接往当前容器添加,而是先将当前容器进行Copy,复制出一个新的容器,然后新的容器里添加元素,添加完元素之后,再将原容器的引用指向新的容器,其中的add与remove方法需要加锁,读不需要,实现了并发读,当然这样读到的数据不一定是最新的,因为写时复制是通过延时更新的策略完成实现最终数据一致性,并非强一致性
- 并没有解决同步容器的复合操作的线程安全性问题
- Collection.synchronizedList ,把普通的arrayList转化为线程安全的,也是加上一层 synchronized
|