put 的过程
1.hash 的过程进行hash扰动 将key的高位与地位进行异或,高位与低位进行异或
h = key.hashCode();
hash = h ^ (h >>> 16);
这样做的目的,是把高位信息混入低位。 因为数组下标的计算通常只用到低几位,如果只看原始 hashCode 的低位,容易导致分布不均。 2.根据哈希值定位数组下标
定位方式一般是:
index = (n - 1) & hash
这里 n 是数组长度。
这样做比取模更高效,但前提是数组长度必须是 2 的幂次方。 这也是为什么 HashMap 的容量总会被维护成 2 的幂。
3.判断该位置是否为空
如果这个桶位置没有元素,那最简单:
直接创建一个 Node 放到这个位置 put 结束
这说明没有哈希冲突。
4.判断需要覆盖还是挂载 这时会分几种情况:
情况一:桶中第一个节点就是同一个 key
判断条件通常是:
hash 相同 并且 key 相等,== 或 equals()
如果是同一个 key,就不是新增,而是 替换旧值。 也就是把旧 value 改成新 value,然后返回旧值。
情况二:当前桶是红黑树
如果这个桶里的结构已经不是链表,而是红黑树,那就按红黑树的方式插入或查找。
情况三:当前桶是链表
那就沿着链表往后找:
找到相同 key:替换 value 没找到:挂到链表尾部
6.链表过长时,可能树化
在 JDK 1.8 里,如果同一个桶中的链表长度超过阈值,一般是 8,就会考虑把链表转成红黑树。
但这里还有一个前提:
数组长度要至少达到 64
如果数组还太小,不会急着树化,而是优先扩容。 因为有时候链表长只是因为容量太小,扩容后元素重新分布,冲突可能就下降了。
- 调整size
调整size 不仅 是包括 size++; 判断是否超过扩容阈值:
threshold = capacity * loadFactor
默认负载因子是 0.75。
如果超过阈值,就触发扩容,一般是 容量翻倍。
扩容后,元素要重新分布到新的数组中。 JDK 1.8 对这一块做了优化:元素不是重新完全计算位置,而是通过判断某一位是 0 还是 1,决定留在原索引还是移动到 原索引 + oldCap,效率更高。
hash map 对null 值的操作
对 null key,它会把 hash 直接当成 0
JDK 1.8 里本质上类似这样:
static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h »> 16); }
也就是说:
如果 key == null 那 hash = 0
继续走定位的逻辑:
index = (n - 1) & 0 = 0
所以 null key 一定会被放到数组下标 0 的桶里。
hashmap 的扩容
hash map 的扩容时机:
hashMap默认的负载因子是0.75,即如果hashmap中的元素个数超过了总容量75%,就会触发扩容。
为了保持数组长度始终是2的幂,扩容后的容量直接是当前容量的2倍。 原下标计算公式是:
index = (oldCap - 1) & hash
//扩容后变成
index = (newCap - 1) & hash
扩容后,元素要重新分布到新的数组中,这样就可以hash的新的最高位,通过判断某一位是 0 还是 1,决定留在原索引还是移动到 原索引 + oldCap,效率更高。
通过 容量翻倍与保持2的幂,这样元素的位置就只有两种可能,要么在原位置,要么在原位置+oldCap的位置。
concurrentHashMap
ConcurrentHashMap 是高并发、线程安全的 Map,底层采用 数组 + 链表 + 红黑树,并通过 CAS + synchronized + volatile 来保证并发安全和性能。
ConcurrentHashMap 的 put 过程,可以概括成 5 步:
1)先计算 hash,定位桶位置
先根据 key 算出 hash,然后定位到数组中的某个桶。
2)如果桶为空,先用 CAS 尝试插入
如果这个位置还没有节点,它不会立刻加锁,而是优先用 CAS 直接插入。
也就是说:
无冲突时,尽量无锁插入 这样可以减少锁竞争,提高并发性能
这个点要强调,因为这是它性能高的重要原因。
3)如果桶不为空,说明发生哈希冲突
这时候就不能直接 CAS 了,而是要进入同步逻辑。
在 Java 1.8 中,它会对 当前桶的头节点 加 synchronized 锁,然后在桶内处理插入:
如果 key 已存在,就覆盖旧值 如果是链表,就遍历链表插入 如果已经是红黑树,就按红黑树方式插入
所以你可以总结成一句:
ConcurrentHashMap 不是锁整个 Map,而是冲突时只锁当前桶,锁粒度很细。
4)链表过长时会树化
如果一个桶里的链表长度太长,比如达到阈值,就会考虑转换成红黑树。
这样做是为了避免冲突严重时,链表查询退化成 O(n),树化后可以优化到 O(log n)。
5)插入后可能触发扩容
如果整体元素数量达到扩容条件,就会触发扩容。
ConcurrentHashMap 的 get 在大多数情况下是 无锁 的。 因为它通过 volatile 保证了关键字段的可见性,所以读线程通常不需要加锁,就能读取到最新的有效数据。
ConcurrentHashMap是弱一致性的,遍历时允许其他线程继续修改 不会像普通 hashmap 那样直接 fail-fast 但是遍历结果不一定是绝对实时、绝对完整的最新状态
linkedhashmap
LinkedHashMap 在 HashMap 的基础上增加了一条双向链表,用来维护节点顺序,所以它既保留了 HashMap 按 key 快速查找的能力,又能保证遍历有序。
LinkedHashMap 最大的经典应用,就是基于 accessOrder + removeEldestEntry() 实现一个简单的 LRU 缓存。
java 内存模型(JMM)
一般来说,编程语言也可以直接复用操作系统层面的内存模型。不过,不同的操作系统内存模型不同。如果直接复用操作系统层面的内存模型,就可能会导致同样一套代码换了一个操作系统就无法执行了。Java 语言是跨平台的,它需要自己提供一套内存模型以屏蔽系统差异。 此外,Java 来说,可以把 JMM 看作是 Java 定义的并发编程相关的一组规范,除了抽象了线程和主内存之间的关系之外,其主要目的是为了简化多线程编程,增强程序可移植性的。JMM 允许在单线程语义不变的前提下进行重排序,JMM 里 规定什么时候从主内存中读数据,什么时候写会主内存。可见性不是天然保证的,必须借助同步手段。
JMM就是为了解决并发时,变量的可见性、有序性和原子性问题的规范
java为此提供了两个关键字。
1.volatile volatile 主要保证可见性和一定程度的有序性,但不保证复合操作的原子性。
比如一个线程修改了 volatile 变量,其他线程马上能看到。 同时,volatile 会通过内存屏障禁止部分指令重排。
但是像 count++ 这种操作,哪怕 count 是 volatile,也还是线程不安全,因为它不是原子操作。
2.synchronized
synchronized 既能保证原子性,也能保证可见性和有序性。
因为线程进入 synchronized 块时,需要获取锁;退出时会把工作内存中的共享变量刷新回主内存。 这样别的线程再拿到同一把锁时,就能看到最新值。
所以从能力上说,synchronized 比 volatile 更强,但开销一般也更大。
synchronized关键字
synchronized 有两个作用: 第一,保证同一时刻只有一个线程进入临界区,解决并发安全问题; 第二,配合 Java 内存模型保证可见性和有序性。
synchronized 修饰的对象
修饰实例方法: 锁的是当前的实例对象(this)。
修饰静态方法: 锁的是当前类的 Class 字节码对象(全局唯一)。
修饰代码块: 锁的是括号里配置的对象。一般推荐使用一个 final 修饰的 Object 对象或者类的 Class 对象作为锁,尽量不要用 String 常量或包装类对象。”
synchronized 锁的原理
本质上,每一个 Java 对象头上都有一个指针指向一个 Monitor 对象(由 C++ 实现的 ObjectMonitor),里面维护了获取锁的线程ID、重入次数以及阻塞等待队列等信息。可以是用javap 命令查看编译后的直接,就可以查看到Monitor
对于同步代码块,Java 编译器会在代码块的前后分别插入 monitorenter 和 monitorexit 字节码指令。线程执行到 monitorenter 时会尝试获取对象的 Monitor 的所有权。
对于同步方法,JVM 则是通过在方法的访问标志(flags)中加上 ACC_SYNCHRONIZED 标志来实现的。当调用方法时,如果检查到该标志,线程就会先隐式地获取 Monitor 锁。
这个也是为什么 wait/notify 必须在 synchronized 中使用? 因为 wait/notify 操作的就是对象监视器,必须先持有这个对象的 monitor,才能调用,否则会抛 IllegalMonitorStateException,同时注意wait的关于虚假唤醒(while + 条件继续判断) 和 唤醒丢失问题。
synchronized 锁的升级过程
JDK 1.6 引入了锁升级机制,锁的状态被保存在对象头的 Mark Word 中。状态会随着竞争激烈程度从低到高升级,且不可降级:
1.无锁: 对象刚创建时的初始状态。
2.偏向锁: 当一段同步代码一直被同一个线程访问时,对象头会记录该线程的 ID。下次该线程进入时无需 CAS 操作,直接获取锁,开销极低。
3.轻量级锁: 当有另外的线程尝试竞争偏向锁时,偏向锁撤销,升级为轻量级锁。其他线程会通过**自旋(CAS操作)**尝试获取锁,不会立即阻塞,从而减少用户态到内核态的切换。
4.重量级锁: 如果自旋达到一定次数仍然没有获取到锁,或者又有第三个线程来竞争,锁就会膨胀为重量级锁。此时竞争失败的线程会被挂起(阻塞),交由操作系统调度。 此外,JVM 还引入了锁消除(JIT 编译时消除不可能存在共享资源竞争的锁)和锁粗化(将多次连续的加锁解锁操作合并为一次范围更大的加锁)来进一步优化性能。”
基于以上内容所以synchronized 是 可重入锁,可以防止自己在递归或调用自身其他同步方法时发生死锁”。 同时synchronized 是非公平锁,因为它不会管等待队列里是否已经有其他线程在排队,而是先尝试自旋去抢占锁。如果抢到了就直接执行,抢不到才会进入 WaitSet 或 EntryList 等待。 synchronized 锁也还是一个不可中断的锁。
ReentrantLock
ReentrantLock 相比 synchronized 额外实现的高级功能: 等待可中断:使用 lockInterruptibly(),线程在等待锁时可以响应中断,防止死等。
尝试非阻塞/超时获取锁:使用 tryLock(),拿不到锁立即返回或等待一段设定时间,避免线程长时间阻塞。
支持公平锁:构造函数可设为 true,确保先排队的线程先拿到锁(synchronized 仅支持非公平)。
多条件变量(Condition):一个锁可以绑定多个 Condition 对象,实现对线程分组、精准的唤醒(synchronized 只能 notifyAll 全部唤醒)。
状态监控:提供 API 查询重入次数(getHoldCount)、是否有线程排队(hasQueuedThreads)等。