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

如果数组还太小,不会急着树化,而是优先扩容。 因为有时候链表长只是因为容量太小,扩容后元素重新分布,冲突可能就下降了。

  1. 调整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 修饰的对象

  1. 修饰实例方法: 锁的是当前的实例对象(this)。

  2. 修饰静态方法: 锁的是当前类的 Class 字节码对象(全局唯一)。

  3. 修饰代码块: 锁的是括号里配置的对象。一般推荐使用一个 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)等。