多线程下扩容造成的死循环和数据丢失
在jdk1.7中,链表采用的是头插法(每次插入节点都是从头插入)。
假设这里有两个线程同时执行了put()操作(扩容),并进入了transfer()方法。线程A先进行操作
线程A在执行到 newTable[i] = e后被挂起,因为 newTable[i] = null,又因为 e.next = newTable[i],所以e.next = null
transfer()方法部分源码:
while(null !=e)
{
Entry next =e.next; //next = 3.next = 7
e.next = newTable[i]; //3.next = null
newTable[i] = e;//线程A执行到这里被挂起了
e = next;
}
开始执行线程B,并完成了扩容。这时候 7.next = 3;3.next = null;
继续执行线程A,执行 newTable[i] = e,因为当时 e = 3,所以将3放到对应位置,此时执行 e = next,因为 next = 7(第②步),所以 e = 7
while(null !=e)
{
Entry next =e.next; //next = 3.next = 7
e.next = newTable[i]; //3.next = null
newTable[i] = e;//继续从这里执行 newTable[i] = 3
e = next; //e = 7
}
上轮循环之后e=7,从主内存中读取e.next时发现主内存中7.next=3,此时next=3,并将7采用头插法的方式放入新数组中,并继续执行完此轮循环。
while(null !=e)
{
Entry next =e.next; //next = 7.next = 3
e.next = newTable[i]; //7.next = 3
newTable[i] = e;//newTable[i] = 7
e = next; //e = 3
}
上轮循环7.next=3,而e=3,执行下一次循环可以发现,因为3.next=null,所以循环之后 e = null,所以循环会退出。
while(null !=e)
{
Entry next =e.next; // next = 3.next = null
e.next = newTable[i]; //3.next = 7 (此处3指向7,同时之前7也指向了3,所以会形成闭环)
newTable[i] = e; //newTable[i] = 3
e = next; //e = null(退出循环条件)
}
数据覆盖(jdk1.8)
jdk1.8中已经不再采用头插法,改为尾插法,即直接插入链表尾部,因此不会出现死循环和数据丢失,但是在多线程环境下仍然会有数据覆盖的问题。
当你调用put()方法时,putVal()方法里面有两处代码会产生数据覆盖。
假设两个线程都进行put操作,线程A和线程B通过哈希函数算出的储存下标是一致的,当线程A判断完之后,然后挂起,然后线程B判断完进入,把元素放到储存位置,然后线程A继续执行,把元素放到储存位置,因为线程A和线程B存储位置一样,所以线程A会覆盖线程B的元素。
同样在putVal()方法里。两个线程,假设HashMap的size为15,线程A从主内存获得15,准备进行++的操作的时候,被挂起,然后线程B拿到size并执行++操作,并写回主内存,这时size是16,然后线程A继续执行(这时A线程内存size还是15)++操作,然后写回主内存,即线程A和线程B都进行了put操作,然后size值增加了1,所以数据被覆盖了。
HashMap解决线程不安全
使用HashTable解决线程不安全问题
因为HashTable解决线程不安全就是在其方法加上同步关键字(synchronized),会导致效率很低下。
HashMap和HashTable的区别
①线程是否安全
HashMap线程不安全。
HashTable线程安全,但是效率较低。
②是否null
HashMap中key只能有一个null,value可以多个为null。
HashTable不允许键或值为null。
③容量
HashMap底层数组长度必须为2的幂(16,32,128…),默认为16。
HashTable底层数组长度可以为任意值,导致hash算法散射不均匀,容易造成hash冲突,默认为11。
④底层区别
HashMap是底层由数组+链表形成,在JDK1.8之后链表长度大于8时转化为红黑树。
HashTable一直都是数组+链表。
⑤继承关系
HashTable继承自Dictionary类。
HashMap继承自AbstractMap类。
(3)Collections.synchronizedMap(不常用)
Map map = Collections.synchronizedMap(new HashMap<>());
1
可以看到SynchronizedMap 是一个实现了Map接口的代理类,该类中对Map接口中的方法使用synchronized同步关键字来保证对Map的操作是线程安全的。
ConcurrentHashMap(常用)
① jdk1.7使用分段锁,底层采用数组+链表的存储结构,包括两个核心静态内部类 Segment(锁角色) 和 HashEntry(存放键值对)。
分段锁:Segment(继承ReentrantLock来加锁)数组中,一个Segment对象就是一把锁,对应n个HashEntry数组,不同HashEntry数组的读写互不干扰。
JDK 1.8抛弃了原有的 Segment 分段锁,来保证采用Node + CAS + Synchronized来保证并发安全性。取消Segment类,直接用数组存储键值对。
为什么使用synchronized替换ReentrantLock锁呢
① 减少内存开销。假设使用可重入锁来获得同步支持,那么每个节点都需要通过继承AQS(队列同步器)来获得同步支持。但并不是每个节点都需要获得同步支持的,只有链表的头节点(红黑树的根节点)需要同步,这无疑带来了巨大内存浪费。
② 获得JVM的支持。可重入锁毕竟是API这个级别的,后续的性能优化空间很小。 synchronized则是JVM直接支持的,JVM能够在运行时作出相应的优化措施:锁粗化、锁消除、锁自旋等等。这就使得synchronized能够随着JDK版本的升级而不改动代码的前提下获得性能上的提升。
③在 JDK1.6 中,对 synchronized 锁的实现引入了大量的优化,并且 synchronized 有多种锁状态,会从无锁 -> 偏向锁 -> 轻量级锁 -> 重量级锁一步步转换。
AQS:是一个队列同步器,同步队列是一个双向链表,有一个状态标志位state,如果state为1的时候,表示有线程占用,其他线程会进入同步队列等待,如果有的线程需要等待一个条件,会进入等待队列,当满足这个条件的时候才进入同步队列,ReentrantLock就是基于AQS实现的
锁升级方式:就是先使用偏向锁优先同一线程然后再次获取锁,如果失败,就升级为CAS(compare and swap 原子操作) 轻量级锁,如果失败就会短暂自旋(不停的判断比较,看能否将值交换),防止线程被系统挂起。最后如果以上都失败就升级为重量级锁。
偏向锁:减少无竞争且只有一个线程使用锁的情况下,使用轻量级锁产生的性能消耗。轻量级锁每次申请、释放锁都至少需要一次CAS,但偏向锁只有初始化时需要一次CAS。
轻量级锁:当有两个线程,竞争的时候就会升级为轻量级锁。轻量级锁的目标是,减少无实际竞争情况下,使用重量级锁产生的性能消耗,包括系统调用引起的内核态与用户态切换、线程阻塞造成的线程切换等。
重量级锁:大多数情况下,在同一时间点,常常有多个线程竞争同一把锁,悲观锁的方式,竞争失败的线程会不停的在阻塞及被唤醒态之间切换,代价比较大。
4.HashMap底层 数组 + 链表 / 红黑树(面试题)
红黑树:平衡二叉查找树
(1)HashMap为什么引入链表
因为HashMap在put()操作时,会进行哈希值得计算,算出储存下标要放在数组那个位置时,当多个元素要放在同一位置时就会出现哈希冲突,然后引进链表,把相同位置的元素放进同一个链表(链地址法)。
(2)HashMap为什么引入红黑树
因为当链表长度大于8时,链表遍历查询速度比较慢,所以引入红黑树。
(3)为什么不一开始就使用红黑树
因为树相对链表维护成本更大,红黑树在插入新数据之后,可能会通过左旋、右旋、变色来保持平衡,造成维护成本过高,故链路较短时,不适合用红黑树。
(4)说说你对红黑树的理解
红黑树是一种平衡二叉查找树,是一种数据结构。除了具备二叉查找树特性以外,还具备以下特性
1.根节点是黑色
2.节点是黑色或红色
3.每个叶子节点是黑色
4.红色节点的子节点都是黑色
5.从任意节点到其叶子节点的所有路径都包含相同数目的黑色节点
说出以上就很好了
补充:以上性质强制了红黑树的关键性质从根到叶子的最长的可能路径不多于最短的可能路径的两倍长。保证了红黑树的高效。
(5) 红黑树为什么要变色、左旋和右旋操作
当我们在对红黑树进行插入和删除等操作时,对树做了修改,那么可能会违背红黑树的性质。
过程:先变色如果变色还不满足红黑树的性质,那就进行左旋或者右旋,然后继续变色,以此循环直至符合红黑树的性质。
5.HashMap链表和红黑树转换(面试题)
链表长度大于8,并且表的长度大于64 数组 + 红黑树
链表长度大于8,并且表的长度不大于64 数组 + 链表 会扩容
当数的节点小于6 数组 + 链表
(1) 为什么链表长度大于8,并且表的长度大于64的时候,链表会转换成红黑树?
因为链表长度符合泊松分布,长度越长哈希冲突概率就越小,当链表长度为8时,概率仅为 0.00000006,这时是一个千万分之一的概率,然后我们map也不会存储那么多的数据,所以链表长度不超过8没有必要转换成红黑树。如果出现这种大量数据的话,转为红黑树可以增加查询和插入效率。长度大于64,只是注释写了 最低要在 4*8,我也没弄懂,请大佬指导。
原理如下:
In usages with well-distributed user hashCodes, tree bins
are rarely used. Ideally, under random hashCodes, the
frequency of nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because
of resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million //翻译:更多:少于千万分之一
负载因子是0.75和长度为8转为红黑树的原理:由上面我们可以看出 当负载因子为0.75时,哈希冲突出现的频率遵循参数为0.5的泊松分布。
常数0.5是作为参数代入泊松分布来计算的,而加载因子0.75是作为一个条件。
泊松分布是一种离散概率分布,泊松分布的概率质量函数:
x=(0,1,2,...)。
λ:单位时间内随机事件的平均发生率。因为我们从上面知道平均发生率是0.5
e^(-0.5) = 0.60653065971264 //e的负0.5次方
阶乘:指从1乘以2乘以3乘以4一直乘到所要求的数。比如 3! = 1 * 2 * 3
In usages with well-distributed user hashCodes, tree bins
are rarely used. Ideally, under random hashCodes, the
frequency of nodes in bins follows a Poisson distribution
(http://en.wikipedia.org/wiki/Poisson_distribution) with a
parameter of about 0.5 on average for the default resizing
threshold of 0.75, although with a large variance because
of resizing granularity. Ignoring variance, the expected
occurrences of list size k are (exp(-0.5) * pow(0.5, k) /
factorial(k)). The first values are:
0: 0.60653066
1: 0.30326533
2: 0.07581633
3: 0.01263606
4: 0.00157952
5: 0.00015795
6: 0.00001316
7: 0.00000094
8: 0.00000006
more: less than 1 in ten million //翻译:更多:少于千万分之一
负载因子是0.75和长度为8转为红黑树的原理:由上面我们可以看出 当负载因子为0.75时,哈希冲突出现的频率遵循参数为0.5的泊松分布。
常数0.5是作为参数代入泊松分布来计算的,而加载因子0.75是作为一个条件。
泊松分布是一种离散概率分布,泊松分布的概率质量函数:
x=(0,1,2,...)。
λ:单位时间内随机事件的平均发生率。因为我们从上面知道平均发生率是0.5
e^(-0.5) = 0.60653065971264 //e的负0.5次方
阶乘:指从1乘以2乘以3乘以4一直乘到所要求的数。比如 3! = 1 * 2 * 3
P(0) = (0.50 * e-0.5) / 0! ≈ 0.60653066
P(1) = (0.51 * e-0.5) / 1! ≈ 0.30326533
P(2) = (0.52 * e-0.5) / 2! ≈ 0.07581633
后面就不给大家计算了,有兴趣可以自己算一下。
(2) 为什么转成红黑树是8呢?而重新转为链表阈值是6呢?
如果转为链表也是8,那如果在8这个位置发生哈希冲突,那红黑树和链表就会频繁切换,就会浪费资源。
(3) 为什么负载因子是0.75?
根据上面的泊松分布来看,表长度达到8个元素的时候,概率为0.00000006,几乎是一个不可能事件,减少了哈希冲突。
加载因子 = 填入表中的元素个数 / 散列表的长度
加载因子越大,填满的元素越多,空间利用率越高,但发生冲突的机会变大了;
加载因子越小,填满的元素越少,冲突发生的机会减小,但空间浪费了更多了,而且还会提高扩容rehash操作的次数。
“冲突的机会”与“空间利用率”之间,寻找一种平衡与折中。
6.HashMap扩容(面试题)
(1)什么时候会发生扩容?
元素个数 > 数组长度 * 负载因子 例如 16 * 0.75 = 12,当元素超过12个时就会扩容。
链表长度大于8并且表长小于64,也会扩容
(2)为什么不是满了扩容?
因为越元素越接近数组长度,哈希冲突概率就越大,所以不是满了扩容。
(3)扩容过程
jdk1.7
创建一个新的table,并调用transfer()方法把旧数组中的数据迁移到新数组中,使用的是头插法,也就是说,新table中链表的顺序和旧列表中是相反的,在HashMap线程不安全的情况下,这种头插法可能会导致死链和数据丢失现象。
jdk1.8
①在resize()方法中,定义了oldCap参数,记录了原table的长度,定义了newCap参数,记录新table长度,newCap是oldCap长度的2倍,然后循环原table,把原table中的每个链表中的每个元素放入新table。
②计算索引做了优化:hash(原始hash) & oldCap(原始容量) == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap(原始容量)。
注意
扩容是一个特别耗性能的操作,所以当程序员在使用HashMap的时候,估算map的大小,初始化的时候给一个大致的数值,避免map进行频繁的扩容。
HashMap的容量达到2的30次方,就不会在进行扩容了。