hash特点
Hash表= 数组 + 线性链表 + (>7)红黑树
相同的2个对象 hashCode一定相等
不同的2 个对象,hashCode 可能相等
问题:为嘛重写了equals(),一定要重写hashCode() ??
==>分别验证上方前后,否则违背该原则。
曾见过的哈希场景:
1.Object hashCode()
2.文件秒传:如网盘文件,服务商方只会存储相同的一份,MD5 看数据库中是否存着
3.HashMap HashTable ...
4.Redis集群
哈希函数特性
1.确定性 即变量值不变,哈希值不变
2.不可逆(单向)
3.分散性(敏感性) 即值有一丝变化,哈希值也不同
4.压缩性 eg.MD5
===》1.3 特性可用于:管理数据结构 密码学
哈希设计方式
加法哈希: hash +=key.charAt(i) 再%模
乘法哈希: hash =31* hash +key.charAt(i)
bernstein 乘法哈希: hash = 33*hash + key.charAt(i) 33自定义
位运算哈希(旋转哈希): hash = (hash << 4)^(hash >>28)^key.charAt(i) 再%模
除法哈希 : %
查表哈希: eg. CRC
混合哈希:
MD5
SHA secure hash a
哈希碰撞
1.collisions eg .不同对象值 得到的哈希值是相同
如何解决哈希冲突?
1.再哈希
2.链地址法 hashMap用此,即同一地址构建链表
3.开放定址 如果有相同哈希值已存储,则往后地址存放
4.公共溢出区 即单独创建数据结构 存放哈希冲突的元素
2.哈希破解方式
1.字典攻击 基于常用的存储反查
2.暴力攻击 即试错 不断尝试
3.查表法
4.彩虹表 即哈希链 查找已存在的哈希值
HashMap
new HashMap() ; 默认初始化容量为16 ,默认加载因子 loadFactor=0.75。
(加载因子决定了HashMap的数据密度,因子越大密度越大,越可能发生Hash碰撞。
链表也越容易长,查询插入次数增加,性能降低;因子越小越容易触发扩容,扩容也影响)
0.7-0.75 平均检索长度接近于常数
加载因子是当HashMap集合底层数据的容量达到75%时,数据开始扩容。
HashMap集合初始化容量是2的倍数—》达到散列均匀,提高hashmap集合的存取效率。
java1.7 数组 + 链表 头插法–死循环问题
rehash 时,链表元素倒置,多线程并发死循环问题
> 1.7 数组 + 链表 + 红黑树 尾插法–无死循环问题,但元素覆盖问题
添加元素时, if ((p=tab[i = (n-1)&hash])==null)
线程一判断为空之后,CPU 时间片到了,被挂起。
线程二也执行到此处判断为空,继续执行下一句,创建了一个新节点,插入到此下标位置。
然后,线程一解挂,同样认为此下标的元素为空,因此也创建了一个新节点放在此下标处,因此造成了元素的覆盖。
链表长度>= 8时,链表转红黑树;当红黑树节点数<6时,重新转为单链表数据结构
1、你了解/使用过HashMap吗?为什么是使用HashMap?
1.HashMap是一个散列桶(直观的说出来HashMap的数据结构特性:数组和链表),
存储的是key-value(键值对)映射
2.HashMap采用了数组和链表的数据结构,
能在查询和修改便于继承了数组的线性查找和链表的寻址修改提高效率
3.HashMap是非安全的(非synchronized),所以不保证安全的情况下,速度很快
4.HashMap可以是key和value都是null,
而Hashtable则不能(原因是Hashtable使用equal方法会产生空指针异常,
而HashMap经过API处理过的,不会出现在这种情况)
5.等等
2、HashMap的工作原理吗?你知道HashMap的put、get方法工作原理吗?
HashMap是基于hashing的原理,
使用put(key, value)存储对象到HashMap中,
使用get(key)从HashMap中获取对象。
当给put()方法传递键和值时,先对键调用hashCode()方法,
返回的hashCode用于找到bucket位置来储存Entry对象。
**(1)put过程大致可以分为一下阶段(针对常用的jdk1.8)**
1、对key值进行Hash值计算(hash方法),然后再在计算下标
2、如果key对象没有在backet中存在,则直接放在桶中存储(也称之为碰撞)
3、如果key对象存在,则会以链表的方式存储链接在后面
4、如果链表的长度超过了阈值(TREEIFY THRESHOLD==8),就会把链表转换为红黑树,链表长度低于6,就把红黑树转换为链表
(为啥加红黑树?==》hash值相等的元素较多时,通过key值依次查找的效率较低。
红黑树提高插入删除效率)
5、如果节点存在,就会替换旧值
6、如果桶满(容量16*加载因子0.75),就会resize(扩容2倍重排)
(为什么是2倍扩容?
1.hash值位运算充分散列,降低哈希冲突 2.操作系统也是二进制执行,执行效率快)
public V get(Object key) {
Node<K,V> e;
return (e = getNode(hash(key), key)) == null ? null : e.value;
}
**(2)get获取对象的工作原理**
当调用get方法时,HashMap会使用键对象的hashcode找到bucket的位置,
找到bucket位置后,会调用keys.equal()方法去找到链表中正确节点,最终找到值对象。
3、当两个对象的hashcode相同会发生什么?你会如何获取值对象?
如果hashcode相等,则可以判断他们的bucket位置相同,会产生“碰撞”,
因为HashMap使用链表存储对象,这个Entry(包含键值对的Map.Entry对象)会存储在链表。
bucket位置后,调用keys.equals()方法去找到链表中正确的节点,最终找到要找的值对象。
4、如果HashMap的大小超过了负载因子(load factor)定义的容量,怎么办?
当数据过大时候,Map则会进行一次rehashing。
默认的负载因子大小为0.75,也就是说,
当一个map填满了75%的bucket时,和其他集合类(如Array等)一样,
将会创建原来HashMap大小2倍的bucket数组,来重新调整map的大小,
并将原来的对象放入新的bucket数组中。
这个过程及时rehashing,毕竟在这个过程中调用hash方法找到了新的bucket位置
5、你了解重新调整HashMap大小存在什么问题吗?
当调整map大小,会产生条件竞争,
因为在多线程条件下,两个线程都发现HashMap需要调整大小,那么就会同时尝试调整,
在调整的过程中,存储在LinkedList中的元素次序会进行倒叙排列(因为移动到新的bucket位置时候,HashMap会将元素放在LinkedList的头部,而不是尾部,为了尾部遍历)。
一旦条件竞争发生了,就会出现死循环。
6、为什么多线程会导致死循环,它是怎么发生的?
HashMap的容量是有限的。
当经过多次元素插入时,使得HashMap达到一定的饱和度(接近加载因子0.75),
Key映射位置发生冲突的几率会逐渐提高。
这时候,HashMap需要扩展他的长度,也就是进行resize(扩容)。
===》:为什么在多线程下使用hashmap ?
HashMap的线程不安全主要体现在resize时的死循环及使用迭代器时的fast-fail上。
7、如果我想使用HashMap实现多线程,可以做到吗?
当然可以的。可以使用java.util.Collections.synchronizedMap(Map)的方式进行处理。
(synchronizedMap(Map)类似hashTable ,锁住整个table)
效率低下的HashTable容器*
HashTable容器使用synchronized来保证线程安全,
但在线程竞争激烈的情况下HashTable的效率非常低下。
因为当一个线程访问HashTable的同步方法时,
其他线程访问HashTable的同步方法时,可能会进入阻塞或轮询状态。
e.g. 线程1使用put进行添加元素,线程2不但不能使用put方法添加元素,
并且也不能使用get方法来获取元素,所以竞争越激烈效率越低。
其他参考资料:https://blog.csdn.net/weixin_44460333/article/details/86770169
ConcurrentHashMap
不允许key /value 为空。
线程安全。
允许通过Iterator遍历可修改,且行为对后续可见性。
多线程并发扩容
JDK1.7版本的ReentrantLock+Segment
JDK1.7版本锁的粒度是基于Segment的,包含多个HashEntry。
默认 最大并发度与Segment个数相等(默认16)
每个Segment中table数组的最小长度为2,且必须是2的n次幂
寻址方式:
在读写某个Key时,((hash(key) >>> segmentShift) & segmentMask) 找Segment数组中的位置。
并将哈希值的高N位对Segment个数取模从而得到该Key应该属于哪个Segment,
(segment[j] 初始化方法ensureSegment(j) 有使用 while 循环,内部用 CAS,当前线程成功设值或其他线程成功设值后,退出)
接着如同操作HashMap一样操作这个Segment 槽内部(链表是头插法)
再put, 先 tryLock() 获取该 segment 的独占锁----》hash 确定内部数组下标。
HashEntry
(scanAndLockForPut 会while循环尝试拿锁,当重试次数如果超过 MAX_SCAN_RETRIES(单核1多核64),那么不抢了,进入到阻塞队列等待锁。lock() 是阻塞方法,直到获取锁后返回。)
新加元素时,先扩容再添加新元素到新数组中。
put 还是要加锁
(尝试自旋获取锁—重试的次数达到了 MAX_SCAN_RETRIES 则改为阻塞锁获取,保证能获取成功)
get简单 无需加锁
计算 hash 值,找到 segment 数组中槽位,槽中也是一个数组,根据 hash 找到数组中具体的位置。
size 统计所有Segment中元素的总个数
1:不加锁的模式去尝试多次计算 ConcurrentHashMap 的 size,最多三次,比较前后两次计算的结果,结果一致就认为当前没有元素加入,计算的结果是准确的。
2:是如果第一种方案不符合,他就会给每个 Segment 加上锁,然后计算 ConcurrentHashMap 的 size 返回。
同步方式:
Segment继承自ReentrantLock,所以我们可以很方便的对每一个Segment上锁。
对于读操作,获取Key所在的Segment时,需要保证可见性–》用volatile关键字,也可使用锁
增加了synchronized同步的操作来控制并发
JDK1.8的实现降低锁的粒度,粒度就是HashEntry(首节点)
put
putVal() 判断是否达阀值
未达
hash 找数组位置—>
CAS 操作,
链表头结点为空,新值放入,失败即并发操作,进入下一次循环。
链表头结点不为空,获得头结点监视器锁 synchronized ,
达阀值 --扩容
链表元素个数>=默认
数组大小< 64 先扩容操作,否则转换成红黑树
当 == MOVED 发现其他线程扩容时,帮助扩容
扩容
当hash(key) 后的高位为1,那rehash 在新数组的位置为就数组中的位置+16
当。。。。。。。。。。0,rehash 新数组的位置 = 旧数组的位置
size 统计所有Segment中元素的总个数
size 是通过对 baseCount 和 counterCell 进行 CAS 计算,最终通过 baseCount 和 遍历 CounterCell 数组得出 size。
=》jdk 8推荐使用mappingCount 方法,因为该方法的返回值是 long 类型,不会因为 size 方法是 int 类型限制最大值。
get
hash找数组对应位置:(n-1)& h
该位置节点查找
如果该位置为 null,那么直接返回 null 就可以了
如果该位置处的节点刚好就是我们需要的,返回该节点的值即可
如果该位置节点的 hash 值小于 0,说明正在扩容(-1),或者是红黑树(-2),
就用find来解决get获取。(扩容的话如果为-1,说明结点转移了,就去nextTable里面去get)
如果以上 3 条都不满足,那就是链表,进行遍历比对即可。
JDK1.8为什么使用内置锁synchronized来代替重入锁ReentrantLock??
1.因为粒度降低了,在相对而言的低粒度加锁方式,synchronized并不比ReentrantLock差,
在粗粒度加锁中ReentrantLock可能通过Condition来控制各个低粒度的边界,更加的灵活,
而在低粒度中,Condition的优势就没有了
2.JVM的开发团队从来都没有放弃synchronized,
而且基于JVM的synchronized优化空间更大,使用内嵌的关键字比使用API更加自然
3.在大量的数据操作下,对于JVM的内存压力,基于API的ReentrantLock会开销更多的内存,
虽然不是瓶颈,但是也是一个选择依据。
**volatile **
可见性实现:缓存一致性协议
重排实现:JMM模型中有8个指令完成数据的读写 ,
通过其中load和store指令相互组合成的4个内存屏障实现。
java内存模型中的可见性,原子性,有序性
1.可见性:指线程间的可见性,一个线程修改的状态对另一个线程是可见的。
--》volatile 修饰的变量不允许线程内部缓存和重排序,即直接修改内存(新值立即同步到主内存),以及每次使用前立即从主存刷新
2.原子性:不可分割。 e.g. a=0 是 a++ = a=a+1 否
3.有序性: volatile(禁止指令重排) Synchronized(同一时刻只运行一个线程) 保证线程间操作有序
声明volatile 的变量,JVM保证了每次读变量都从内存中读,跳过CPU cache
指令重排序:CPU采用了允许将多条指令不按程序规定的顺序分开发送给各相应电路单元处理。
DCL(双重校验单例) :
1.new 分配内存空间
2. 初始化对象
3. 指向刚分配的地址
==》2 3 会发生重排序。导致 a 线程在初始化之后,b 线程判断对象不为null 就返回未初始化的对象了。
CAS (无锁优化 自旋 乐观锁) 是一种非阻塞式的同步方式
1、线程数少 执行时间不长适用CAS, 多 时间长适用sync。
2、CPU原语支持;JUC 下Atomicxxx 都是用的CAS
3、cas(V,Expected,newValue)
if V==E
v=new
otherwise try agin or fail
4、ABA问题
A 1.0
B 2.0
A 1.0
cas(version)
--若为基础类型,无所谓, 引用类型,地址引用不变,对象值可能变了。
LongAdder 内部是分段锁
Phaser 栅栏式,阶段性的任务
increment递增 :单机 LongAdder >Atomicxxx>sync
ReadWriteLock readWriteLock = new ReentrantReadWriteLock();
Lock readLock = readWriteLock.readLock();
Lock writeLock = readWriteLock.writeLock();
即共享锁(读锁允许读) 排它锁(也叫互斥锁,读锁不允许写)
Semaphore s = new Semaphore( 1 ); 信号灯,参数为允许的数量
s.acquire(); 取得信号1,取得 其他的就阻塞
执行完要s.release(); 让信号重新为1
场景:限流 还可以guava ratelimiter
Exchanger 交换器
Exchanger exc = new Exchanger<>();
exc.exchange(“T1”);线程1 exchange() 是阻塞方法
exc.exchange(“t2”); 线程2
===》JUC同步锁
ReentrantLock
CountDownLatch
CyclicBarrier
Phaser
ReadWriteLock --升级态 StampedLock
Semaphore
Exchanger
LockSupport --- 让当前线程阻塞 LockSupport.park()
AQS原理
用来构建锁和同步器的框架,各种 Lock 包中的锁(常用的有 ReentrantLock、ReadWriteLock),以及其他如 Semaphore、CountDownLatch,甚至是早期的 FutureTask 等,都是基于 AQS 来构建。
synchronized(悲观锁) /reentrantlock
**同**:
1.加锁方式同步
2.重入锁
3.阻塞式同步
---》线程阻碍/唤醒代价高(操作系统需在用户态与内核态间来回切换)
**功能区别**:
synchronized 是java关键字,是原语层面的互斥,需jvm实现,使用方便,解释器保证锁的添加和释放。
当异常时会释放锁。
reentrantlock在jdk1.5后为api层面的互斥锁,需lock()、unlock() 方法配合try/finnally完成,手工加锁和释放。
锁的细粒度与灵活度:reentrantlock > synchronized
**性能区别**:
synchronized 在+ 无锁 、偏向锁 、自旋锁、重量级锁 后 性能~~reentrantlock
如何实现可重入性:
Synchronized进过编译,会在同步块的前后分别形成**monitorenter和monitorexit这个两个字节码指令。**
在执行monitorenter指令时,首先要尝试获取对象锁。
若该对象没被锁定,或当前线程已拥有了那个对象锁,把锁的计算器加1。
在执行monitorexit指令时会将锁计算器就减1,当计算器为0时,锁就被释放了。
若获取对象锁失败,那当前线程就要阻塞,直到对象锁被另一个线程释放为止。
ReentrantLock是java.util.concurrent包下提供的一套互斥锁;
有3大高级功能:
1.等待可中断,即持有锁的线程长期不释放时,等待可放弃,
通过lock.lockInterruptibly()来实现机制。----避免死锁。
或者lock.tryLock(时间) 设定多长时间内尝试获得锁,没有获得锁就不等待,继续其他的。
2.公平锁,多线程等待同一个锁时,必须按照申请锁的时间顺序获得锁。
Synchronized锁非公平锁,但效率高,因无需记录等待时间,无需排队获得锁
ReentrantLock默认的构造函数是创建的非公平锁,
可以通过参数true设为公平锁,但公平锁表现的性能不是很好。
Lock lock = new ReentrantLock(true);
3.锁绑定多个条件
ReenTrantLock提供了一个Condition(条件)类,用来实现分组唤醒需要唤醒的线程们。
synchronized要么随机唤醒一个线程要么唤醒全部线程。
如何实现可重入性:
ReentrantLock在内部使用了内部类Sync来管理锁,所以真正的获取锁是由Sync的实现类控制的。
Sync有两个实现,分别为NonfairSync(非公平锁)和FairSync(公平锁)。
Sync通过继承AQS实现,在AQS中维护了一个private volatile int state来计数重入次数,避免了频繁的持有释放操作带来效率问题。
下面是部分ReentrantLock源码
// Sync继承于AQS
abstract static class Sync extends AbstractQueuedSynchronizer {
...
}
// ReentrantLock默认是非公平锁
public ReentrantLock() {
sync = new NonfairSync();
}
// 可以通过向构造方法中传true来实现公平锁
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
线程抢锁过程(公平锁实现):
protected final boolean tryAcquire(int acquires) {
// 当前想要获取锁的线程
final Thread current = Thread.currentThread();
// 当前锁的状态
int c = getState();
// state == 0 此时此刻没有线程持有锁
if (c == 0) {
// 虽然此时此刻锁是可以用的,但是这是公平锁,既然是公平,就得讲究先来后到,
// 看看有没有别人在队列中等了半天了
if (!hasQueuedPredecessors() &&
// 如果没有线程在等待,那就用CAS尝试一下,成功了就获取到锁了,
// 不成功的话,只能说明一个问题,就在刚刚几乎同一时刻有个线程抢先了 =_=
// 因为刚刚还没人的,我判断过了
compareAndSetState(0, acquires)) {
// 到这里就是获取到锁了,标记一下,告诉大家,现在是我占用了锁
setExclusiveOwnerThread(current);
return true;
}
}
// 会进入这个else if分支,说明是重入了,需要操作:state=state+1
// 这里不存在并发问题
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
// 如果到这里,说明前面的if和else if都没有返回true,说明没有获取到锁
return false;
}
从上面可以看出:
(1) 当一个线程在获取锁过程中,先判断state的值是否为0,如果是表示没有线程持有锁,就可以尝试获取锁(不一定获取成功)。
(2) 当state的值不为0时,表示锁已经被一个线程占用了,这时会做一个判断current == getExclusiveOwnerThread()(这个方法返回的是当前持有锁的线程),这个判断是看当前持有锁的线程是不是自己,如果是自己,那么将state的值加1就可以了,表示重入返回即可。
实现原理:
CAS+CLH队列来实现。
ReenTrantLock的实现是一种自旋锁,通过循环调用CAS操作来实现加锁。