Skip to content

Commit afab788

Browse files
committed
update notes
1 parent 00746b8 commit afab788

File tree

4 files changed

+205
-89
lines changed

4 files changed

+205
-89
lines changed

JavaKnowledge/HashMap实现原理分析.md

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,7 @@ public V get(Object key) {
292292
// 使用红黑树树而不链表的容器计数阈值
293293
static final int TREEIFY_THRESHOLD = 8;
294294
// 用于在执行过程中取消使用红黑树(拆分)箱调整大小操作的箱计数阈值,
295+
// 为啥这里转成红黑树是8,而从红黑树转成链表是6? 在hash函数设计合理的情况下,发生hash碰撞8次的几率为百万分之6,概率说话。。因为8够用了,至于为什么转回来是6,因为如果hash碰撞次数在8附近徘徊,会一直发生链表和红黑树的转化,为了预防这种情况的发生。
295296
static final int UNTREEIFY_THRESHOLD = 6;
296297
// 哈希桶,存储数据的数组
297298
transient Node<K,V>[] table;
@@ -337,6 +338,17 @@ static final int hash(Object key) {
337338
}
338339
```
339340

341+
hash函数是先拿到通过key 的hashcode,是32位的int值,然后让hashcode的高16位和低16位进行异或操作。为什么要这样设计呢? 主要有两个原因 :
342+
343+
1. 一定要尽可能降低hash碰撞,越分散越好。
344+
2. 算法一定要尽可能高效,因为这是高频操作,因此采用位运算。
345+
346+
因为hashcode是32位的int值int值范围为**-2147483648~2147483647**,前后加起来大概40亿的映射空间。只要哈希函数映射得比较均匀松散,一般应用是很难出现碰撞的。但问题是一个40亿长度的数组,内存是放不下的。你想,如果HashMap数组的初始大小才16,用之前需要对数组的长度取模运算,得到的余数才能用来访问数组下标。源码中模运算就是把散列值和数组长度-1做一个"与"操作,位运算比%运算要快。
347+
348+
349+
350+
351+
340352
对于任意给定的对象,只要它的hashCode()返回值相同,那么程序调用所计算得到的Hash码值总是相同的。我们首先想到的就是把hash值对数组长度取模运算,这样一来,元素的分布相对来说是比较均匀的。但是,模运算的消耗还是比较大的,在HashMap中是这样做的:调用 (table.length -1) & hash来计算该对象应该保存在table数组的哪个索引处。这个方法非常巧妙,它通过 (table.length -1) & hash来得到该对象的保存位,而HashMap底层数组的长度总是2的n次方,当length总是2的n次方时, (table.length -1) & hash运算等价于对length取模,也就是h%length,但是&比%具有更高的效率。
341353

342354
当length总是2的倍数时,h & (length-1) 将是一个非常巧妙的设计:
@@ -709,8 +721,9 @@ final Node<K,V> getNode(int hash, Object key) {
709721
1. JDK8为红黑树 + 链表 + 数组的形式,当桶内元素大于8时,便会树化。
710722
2. hash值的计算方式不同 (jdk 8 简化)。
711723
3. JDK7中table在创建hashmap时分配空间,而8中在put的时候分配。
712-
4. 在发生冲突,插入链中时,7 是头插法,8 是尾插法
724+
4. 链表的插入方式从头插法改成了尾插法,简单说就是插入时,如果数组位置上已经有元素,1.7将新元素放到数组中,原始节点作为新节点的后继节点,1.8遍历链表,将元素放置到链表的最后;因为头插法会使链表发生反转,多线程环境下会产生环;
713725
5. 在resize操作中,7 需要重新进行index的计算,而8不需要,通过判断相应的位是0还是1,要么依旧是原index,要么是oldCap + 原 index。
726+
6. 在插入时,1.7先判断是否需要扩容,再插入,1.8先进行插入,插入完成再判断是否需要扩容;
714727

715728

716729

@@ -721,20 +734,15 @@ final Node<K,V> getNode(int hash, Object key) {
721734
1. 为什么capcity是2的幂?
722735
因为算index时用的是(n-1) & hash,这样就能保证n-1是全为1的二进制数,如果不全为1的话,存在某一位为 0,那么0,1与0与的结果都是0,这样便有可能将两个hash不同的值最终装入同一个桶中,造成冲突。所以必须是2的幂。这是为了服务key映射到index的Hash算法的,公式index=hashcode(key)&(length-1),初始长度(16-1),二进制为1111&hashcode结果为hashcode最后四位,能最大程度保持平均,二的幂数保证二进制为1,保持hashcode最后四位。这种算法在保持分布均匀之外,效率也非常高。
723736

724-
725-
726737
2. 为什么需要使用加载因子,为什么需要扩容呢?
727738

728739
因为如果填充比很大,说明利用的空间很多,如果一直不进行扩容的话,链表就会越来越长,这样查找的效率很低,因为链表的长度很大(当然最新版本使用了红黑树后会改进很多),扩容之后,将原来链表数组的每一个链表分成奇偶两个子链表分别挂在新链表数组的散列位置,这样就减少了每个链表的长度,增加查找效率。HashMap 本来是以空间换时间,所以填充比没必要太大。但是填充比太小又会导致空间浪费。如果关注内存,填充比可以稍大,如果主要关注查找性能,填充比可以稍小。
729740

730741
3. 为什么 HashMap 是线程不安全的,实际会如何体现?
731742

732743
- 如果多个线程同时使用put方法添加元素,假设正好存在两个put的key发生了碰撞 (hash值一样),那么根据HashMap的实现,这两个key会添加到数组的同一个位置,这样最终就会发生其中一个线程的put的数据被覆盖。
733-
734-
- 如果多个线程同时检测到元素个数超过数组大小 * loadFactor。这样会发生多个线程同时对hash数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给table,也就是说其他线程的都会丢失,并且各自线程put的数据也丢失。且会引起死循环的错误。
735-
736-
737-
744+
- 如果多个线程同时检测到元素个数超过数组大小 * loadFactor。这样会发生多个线程同时对hash数组进行扩容,都在重新计算元素位置以及复制数据,但是最终只有一个线程扩容后的数组会赋给table,也就是说其他线程的都会丢失,并且各自线程put的数据也丢失。且会引起死循环的错误。
745+
738746
4. 与HashTable的区别
739747

740748
Hashtable是遗留类,很多映射的常用功能与HashMap类似,不同的是它承自Dictionary类,并且是线程安全的,任一时间只有一个线程能写Hashtable,它的并发性不如ConcurrentHashMap,因为ConcurrentHashMap引入了分段锁。Hashtable不建议在新代码中使用,不需要线程安全的场合可以用HashMap替换,需要线程安全的场合可以用ConcurrentHashMap替换。
@@ -745,8 +753,16 @@ final Node<K,V> getNode(int hash, Object key) {
745753
- HashTable取哈希桶下标是直接用模运算%.(因为其默认容量也不是2的n次方。所以也无法用位运算替代模运算)
746754
- 扩容时,新容量是原来的2倍+1。int newCapacity = (oldCapacity << 1) + 1;
747755
- Hashtable是Dictionary的子类同时也实现了Map接口,HashMap是Map接口的一个实现类
756+
757+
5. 扩容的时候为什么1.8 不用重新hash就可以直接定位原节点在新数据的位置呢?
758+
759+
这是由于扩容是扩大为原数组大小的2倍,用于计算数组位置的掩码仅仅只是高位多了一个1,举个例子:
748760

761+
扩容前长度为16,用于计算 (n-1) & hash 的二进制n - 1为0000 1111,
749762

763+
扩容后为32后的二进制就高位多了1,============>为0001 1111。因为是& 运算,1和任何数 & 都是它本身,那就分二种情况,如下图:原数据hashcode高位第4位为0和高位为1的情况;第四位高位为0,重新hash数值不变,第四位为1,重新hash数值比原来大16(旧数组的容量)。
764+
765+
750766

751767

752768
参考:
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
# Java并发编程之原子性、可见性以及有序性
2+
3+
4+
5+
- 缓存导致的可见性问题
6+
- 线程切换带来的原子性问题
7+
- 编译优化带来的有序性问题
8+
9+
10+
11+
## 原子性(Atomicity)
12+
13+
众所周知,原子是构成物质的基本单位,所以原子代表着不可分。
14+
即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
15+
最简单的一个例子就是银行转账问题,赋值或者`return`。比如`a = 1;``return a;`这样的操作都具有原子性
16+
原子性不论是多核还是单核,具有原子性的量,同一时刻只能有一个线程来对它进行操作!
17+
加锁可以保证复合语句的原子性,Java中提供了两个高级指令 `monitorenter``monitorexit`,也就是对应的synchronized同步锁来保证原子性。
18+
19+
非原子性操作
20+
类似`a += b`这样的操作不具有原子性,在某些`JVM``a += b`可能要经过这样三个步骤:
21+
22+
- 取出`a``b`
23+
- 计算`a+b`
24+
- 将计算结果写入内存
25+
如果有两个线程`t1`,`t2`在进行这样的操作。`t1`在第二步做完之后还没来得及把数据写回内存就被线程调度器中断了,于是`t2`开始执行,`t2`执行完毕后`t1`又把没有完成的第三步做完。这个时候就出现了错误,
26+
相当于`t2`的计算结果被无视掉了。所以上面的买碘片例子在同步`add`方法之前,实际结果总是小于预期结果的,因为很多操作都被无视掉了。
27+
类似的,像`a++`这样的操作也都不具有原子性。所以在多线程的环境下一定要记得进行同步操作。
28+
29+
## 可见性(Visibility)
30+
31+
可见性指的是当一个线程修改了共享变量后,其他线程能够立即得知这个修改。
32+
33+
在多核处理器中,如果多个线程对一个变量进行操作,但是这多个线程有可能被分配到多个处理器中运行,那么编译器会对代码进行优化,当线程要处理该变量时,多个处理器会将变量从主内存复制一份分别存储在自己的片上存储器中,等到进行完操作后,再赋值回主存。(这样做的好处是提高了运行的速度,因为在处理过程中多个处理器减少了同主内存通信的次数);同样在单核处理器中这样由于备份造成的问题同样存在!这样的优化带来的问题之一是变量可见性——如果线程`t1`与线程`t2`分别被安排在了不同的处理器上面,那么`t1`与`t2`对于变量`A`的修改时相互不可见,如果`t1`给`A`赋值,然后`t2`又赋新值,那么`t2`的操作就将`t1`的操作覆盖掉了,这样会产生不可预料的结果。所以,即使有些操作时原子性的,但是如果不具有可见性,那么多个处理器中备份的存在就会使原子性失去意义。
34+
35+
volatile、synchronized、final都可以解决可见性问题。
36+
37+
## 有序性(Ordering)
38+
39+
有序性:即程序执行的顺序按照代码的先后顺序执行。
40+
41+
有序性简单来说就是程序代码执行的顺序是否按照我们编写代码的顺序执行,一般来说,为了提高性能,编译器和处理器会对指令做重排序,重排序分3类
42+
43+
- 编译器优化重排序,在不改变单线程程序语义的前提下,改变代码的执行顺序
44+
- 指令集并行的重排序,对于不存在数据依赖的指令,处理器可以改变语句对应指令的执行顺序来充分利用CPU资源
45+
- 内存系统的重排序,也就是前面说的CPU的内存乱序访问问题
46+
47+
也就是说,我们编写的源代码到最终执行的指令,会经过三种重排序。
48+
49+
比如编写时顺序如下的程序:
50+
51+
```java
52+
1. a = 5;
53+
2. b = 20;
54+
3. c = a + b;
55+
```
56+
57+
编译器优化后执行的顺序可能变成:
58+
59+
```java
60+
1. b = 20;
61+
2. a = 5;
62+
3. c = a + b;
63+
```
64+
65+
在这个例子中,编译器调整了语句的顺序,但是不影响程序的最终结果
66+
67+
在单例模式的实现上有一种双重检验锁定的方式(Double-checked Locking):
68+
69+
```java
70+
public class Singleton{
71+
private static Singleton instance;
72+
public static Singleton getInstance(){
73+
if (instance == null){
74+
synchronized(Singleton.class){
75+
if(instance == null) {
76+
instance = new Singleton();
77+
}
78+
}
79+
}
80+
return instance;
81+
}
82+
}
83+
```
84+
85+
我们先看 instance=newSingleton() 的未被编译器优化的操作
86+
87+
- 指令 1:分配一块内存 M;
88+
- 指令 2:在内存 M 上初始化 Singleton 对象;
89+
- 指令 3:然后 M 的地址赋值给 instance 变量。
90+
91+
编译器优化后的操作指令
92+
93+
- 指令 1:分配一块内存 M;
94+
- 指令 2:将 M 的地址赋值给 instance 变量;
95+
- 指令 3:然后在内存 M 上初始化 Singleton 对象。
96+
97+
现在有A,B两个线程,我们假设线程A先执行getInstance()方法,当执行编译器优化后的操作指令2时(此时候未完成对象的初始化),这时候发生了线程切换,那么线程B进入,刚好执行到第一次判断instance==nul会发现 instance不等于null了,所以直接返回instance,而此时的 instance 是没有初始化过的。
98+
99+
100+
101+
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由“一个变量在同一时刻只允许一条线程对其进行lock操作”这条规则来获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
102+
103+
## **先行发生原则:**
104+
105+
如果Java内存模型中所有的有序性都只靠volatile和synchronized来完成,那么有一些操作将会变得很啰嗦,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个“先行发生”(Happen-Before)的原则。这个原则非常重要,它是判断数据是否存在竞争,线程是否安全的主要依赖。
106+
107+
先行发生原则是指Java内存模型中定义的两项操作之间的依序关系,如果说操作A先行发生于操作B,其实就是说发生操作B之前,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。下面是Java内存模型下一些”天然的“先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。
108+
109+
- 程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。
110+
111+
- 管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。
112+
113+
- volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。
114+
115+
- 线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
116+
117+
- 线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。
118+
119+
- 线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。
120+
121+
- 对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。
122+
123+
- 传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
124+
125+
一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生“呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与先生发生原则之间基本没有什么关系,所以衡量并发安全问题一切必须以先行发生原则为准。
126+
127+
```java
128+
int i = 0;
129+
boolean flag = false;
130+
i = 1; //语句1
131+
flag = true; //语句2
132+
```
133+
上面代码定义了一个`int`型变量,定义了一个`boolean`类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么`JVM`在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?
134+
不一定,为什么呢?这里可能会发生指令重排序`(Instruction Reorder)`
135+
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?
136+
再看下面一个例子:
137+
138+
```java
139+
int a = 10; //语句1
140+
int r = 2; //语句2
141+
a = a + 3; //语句3
142+
r = a*a; //语句4
143+
```
144+
这段代码有4个语句,那么可能的一个执行顺序是:
145+
语句2->语句1->语句3->语句4
146+
那么可能不可能是这个执行顺序呢?语句2->语句1->语句4->语句3,这是不可能的,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,
147+
如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
148+
149+
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:
150+
```java
151+
//线程1:
152+
context = loadContext(); //语句1
153+
inited = true; //语句2
154+
155+
//线程2:
156+
while(!inited ){
157+
sleep()
158+
}
159+
doSomethingwithconfig(context);
160+
```
161+
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此时线程2会以为初始化工作已经完成,
162+
那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
163+
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
164+
165+
166+
167+
---
168+
- 邮箱 :charon.chui@gmail.com
169+
- Good Luck!
170+
171+

0 commit comments

Comments
 (0)