Java中的Map
1.基本介绍和api使用就免了
Java中的Map
是一种用于存储键值对(Key-Value)的接口,属于java.util
包,是集合框架的重要组成部分。
2.HashMap
从图中的关系可以看出这些类间关系了。
①基本分析
HashMap的一些属性
// 默认容量 16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
/*最大容量*/
static final int MAXIMUM_CAPACITY = 1 << 30;
/**负载因子*/
static final float DEFAULT_LOAD_FACTOR = 0.75f;
/*使用树而不是列表的bin计数阈值。当向至少有这么多节点的桶中添加元素时,桶将转换为树。该值必须大于2,并且应该至少为8,*/
static final int TREEIFY_THRESHOLD = 8;
//HashMap红黑树退化成链表的临界值
static final int UNTREEIFY_THRESHOLD = 6;
//HashMap链表升级成红黑树第二个条件:HashMap数组(桶)的长度大于等于64
static final int MIN_TREEIFY_CAPACITY = 64;
/* ---------------- Fields -------------- */
/*实际的键值对。在分配时,长度总是2的幂。*/
transient Node<K,V>[] table;
/*保存缓存的entrySet(*/
transient Set<Map.Entry<K,V>> entrySet;
transient int size;transient int modCount;
int threshold;
final float loadFactor;
静态内部类Node
static class Node<K,V> implements Map.Entry<K,V> {final int hash; // hash值final K key; //V value;Node<K,V> next; // 下一个元素}
这个静态内部类很简单吧。
public V put(K key, V value)
public V put(K key, V value) {return putVal(hash(key), key, value, false, true);
}// 计算hash值
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}// putVal
final V putVal(int hash, K key, V value, boolean onlyIfAbsent,boolean evict) {Node<K,V>[] tab; Node<K,V> p; int n, i;// 1.如果此时的table还没有初始化,就执行resize()操作if ((tab = table) == null || (n = tab.length) == 0)n = (tab = resize()).length;// 2.如果【计算的hash映射的位置没有元素】,就直接把元素放在上面// p是hash计算的下标所在位置的元素if ((p = tab[i = (n - 1) & hash]) == null)tab[i] = newNode(hash, key, value, null);// 3.【如果有冲突!!!!!!】else {Node<K,V> e; K k;// 【3.1】 如果p元素的hash值和put进来的hash相等,并且equals也相等if (p.hash == hash &&((k = p.key) == key || (key != null && key.equals(k))))e = p;// 【3.2】3.1如果是false,如果p是树节点类型的else if (p instanceof TreeNode)e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);// 【3.3】如果是普通节点类型// 下面可以看出是链表的尾插法!else {for (int binCount = 0; ; ++binCount) {if ((e = p.next) == null) {p.next = newNode(hash, key, value, null);// 链表上元素数量如果快要达到阈值了,就转换为树if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1sttreeifyBin(tab, hash);break;}// 如果链表上的节点与要插入的完全相同,就跳过if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))break;p = e;}}// 这里可以看到,如果存在一模一样的,新值会替换到旧值。if (e != null) { // existing mapping for keyV oldValue = e.value;// onlyIfAbsent传过来的是falseif (!onlyIfAbsent || oldValue == null)e.value = value; // 如果不存在一模一样的,那么这句话执不执行都一样了afterNodeAccess(e);return oldValue;}}++modCount;if (++size > threshold)resize(); // 扩容afterNodeInsertion(evict);return null;
}
扩容机制resize();
HashMap 的扩容机制是为了在元素数量增加时,维持合理的负载因子,减少哈希冲突,从而保证 HashMap 的性能。
new了一个HashMap对象的时候,第一次put才会去初始化tab的Node数组,这时就会 负载因子 * 容量
。【延迟初始化,嘿嘿嘿】
- 容量(Capacity):指 HashMap 内部数组的大小。初始容量默认为 16,并且必须是 2 的幂次方。
- 负载因子(Load Factor):衡量 HashMap 满的程度,默认为 0.75。它表示当 HashMap 中的元素数量达到容量的
负载因子 * 容量
时,就会触发扩容。 - 阈值(Threshold):等于
负载因子 * 容量
。当 HashMap 中的元素数量达到阈值时,就会进行扩容。
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
threshold = newThr;
扩容过程
- 创建新数组:扩容时,会创建一个新的数组,新数组的容量是原数组容量的 2 倍。例如,原数组容量为 16,扩容后新数组容量为 32。
- 重新计算哈希值和索引:将原数组中的所有键值对重新计算哈希值,并根据新数组的容量重新确定它们在新数组中的索引位置。这是因为数组容量变化后,
hash & (length - 1)
的结果会改变。- 迁移元素:遍历原数组,将每个桶(bucket,即数组中的每个位置)中的元素重新插入到新数组的相应位置。如果桶中只有一个元素,直接计算新索引插入即可。如果桶中是链表结构(JDK 1.7 及之前)或红黑树结构(JDK 1.8 及之后,当链表长度大于 8 且数组容量大于 64 时会转换为红黑树),则需要对链表或红黑树中的每个元素重新计算索引并插入到新数组对应位置。
final Node<K,V>[] resize() {......// 创建新数组Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];table = newTab;if (oldTab != null) {for (int j = 0; j < oldCap; ++j) {Node<K,V> e;if ((e = oldTab[j]) != null) {oldTab[j] = null;if (e.next == null)// 重新计算hashnewTab[e.hash & (newCap - 1)] = e;else if (e instanceof TreeNode)((TreeNode<K,V>)e).split(this, newTab, j, oldCap);else { // preserve order// 元素迁移【可以理解为我不想接着看源码了】........}}}}return newTab;
}
V get(Object key)获取元素
public V get(Object key) {Node<K,V> e;return (e = getNode(hash(key), key)) == null ? null : e.value;
}final Node<K,V> getNode(int hash, Object key) {Node<K,V>[] tab; Node<K,V> first, e; int n; K k;// 1.判空if ((tab = table) != null && (n = tab.length) > 0 &&(first = tab[(n - 1) & hash]) != null) {if (first.hash == hash && // always check first node((k = first.key) == key || (key != null && key.equals(k))))// 2.1如果hash第一个位置就是的return first; if ((e = first.next) != null) {// 2.1 找树,找链表,很简单嘛if (first instanceof TreeNode)return ((TreeNode<K,V>)first).getTreeNode(hash, key);do {if (e.hash == hash &&((k = e.key) == key || (key != null && key.equals(k))))return e;} while ((e = e.next) != null);}}return null;
}
通过源码可以看出,对于key为null,计算hash的时候返回就是0,
static final int hash(Object key) {int h;return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
value = null,那就无所谓了。
Map<String, String> m = new HashMap<>(10);
m.put("a", "a");
m.put(null, "b");
m.put(null, "c");
m.put("c", null);
m.get("a");
String s = m.get(null);
System.out.println(s);
②一些问题
为什么HashMap的Node数组长度必须是2的n次幂?
在计算存入结点下标时,会利用 key 的 hsah 值进行取余操作,而计算机计算时,并没有取余等运算,会将取余转化为其他运算。
/*
具体公式为 index = hash & (length - 1),这里length是 HashMap 的长度。
如果length是 2 的次幂,那么length - 1的二进制形式就是所有位都为 1。
例如,若length为 16(二进制10000),length - 1就是 15(二进制01111)。
这样做使得hash & (length - 1)等价于hash % length,但按位与运算比取模运算效率更高。
举例子:hash=20 length=16 h % l = 4 <==> 20&15 = 4
*/
HashMap不是有一个构造函数可以指定初始容量吗?我给一个不是2的n次幂你不就炸了?
public HashMap(int initialCapacity) {this(initialCapacity, DEFAULT_LOAD_FACTOR);
}
public HashMap(int initialCapacity, float loadFactor) {if (initialCapacity < 0)throw new IllegalArgumentException("Illegal initial capacity: " +initialCapacity);if (initialCapacity > MAXIMUM_CAPACITY)initialCapacity = MAXIMUM_CAPACITY;if (loadFactor <= 0 || Float.isNaN(loadFactor))throw new IllegalArgumentException("Illegal load factor: " +loadFactor);this.loadFactor = loadFactor;this.threshold = tableSizeFor(initialCapacity);
}
// 求第一个大于cap的二次幂
static final int tableSizeFor(int cap) {int n = cap - 1;n |= n >>> 1; // 【无符号右移,最高位补0】n |= n >>> 2;n |= n >>> 4;n |= n >>> 8;n |= n >>> 16;return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1;
}
看得懂不?
Map<String, String> m = new HashMap<>(10);
// new的时候 threshold = 16
// 在第一次put的时候,resize(),实际底层Node是16的长度,threshold = 16 * 0.75 = 12/*
假设给定的初始值是10(1010)【非二次幂】
n = 10 - 1 = 9 (1001)
n |= n >>> 1 ====> 1001 | 0100 = 1101
n |= n >>> 2 ====> 1101 | 0011 = 1111
.....(同理)
最后 n = 1111
n+1 = 10000(第一个大于n的二次幂)
*/
你传任你传,我自有办法变成二次幂!!!
3.HashTable
HashTable
是 Java 中的一个古老的键值对存储类,它是线程安全的,但现在已经不常用了。
Hashtable
采用 “数组 + 链表” 的结构来存储键值对 。它内部维护了一个Entry
类型的数组table
,数组的每个元素都是一个链表的头节点。当有新的键值对要插入时,会根据键的哈希值计算出在数组中的索引位置,然后将键值对插入到对应索引位置的链表中。如果发生哈希冲突(即不同的键计算出了相同的索引),则通过链表来解决冲突,新的键值对会被添加到链表的头部。
Hashtable
通过synchronized
关键字来保证线程安全。几乎所有对Hashtable
进行修改(如put
、remove
等)和部分读取(如get
)操作的方法都被synchronized
修饰。这意味着在任何时刻,只能有一个线程能够访问Hashtable
的这些同步方法,从而避免了多线程环境下的数据不一致问题。
public synchronized V put(K key, V value) {// 检查键是否为null,不允许null键if (key == null) {throw new NullPointerException();}Entry<?,?> tab[] = table;int hash = key.hashCode();int index = (hash & 0x7FFFFFFF) % tab.length;// 遍历链表查找键或插入新键值对for (Entry<K, V> e = (Entry<K, V>) tab[index]; e!= null; e = e.next) {if ((e.hash == hash) && e.key.equals(key)) {V old = e.value;e.value = value;return old;}}addEntry(hash, key, value, index);return null;
}private void addEntry(int hash, K key, V value, int index) {modCount++;Entry<?,?> tab[] = table;if (count >= threshold) {// 扩容rehash();tab = table;hash = key.hashCode();index = (hash & 0x7FFFFFFF) % tab.length;}// 创建新的Entry并插入链表头部@SuppressWarnings("unchecked")Entry<K,V> e = (Entry<K,V>) tab[index];tab[index] = new Entry<>(hash, key, value, e);count++;
}
看了源码,不允许 null 键值:Hashtable
不允许使用null
作为键或值。如果尝试插入null
键或null
值,会抛出NullPointerException
。这在某些场景下不够灵活,相比之下,HashMap
允许一个null
键和多个null
值。
import java.util.Hashtable;public class HashTableExample {public static void main(String[] args) {Hashtable<String, Integer> hashtable = new Hashtable<>();// 添加键值对hashtable.put("one", 1);hashtable.put("two", 2);hashtable.put("three", 3);//hashtable.put(null, 444); // ============== no!!// 获取值System.out.println(hashtable.get("two")); // 输出: 2// 遍历for (String key : hashtable.keySet()) {System.out.println(key + ": " + hashtable.get(key));}}
}
4.TreeMap
TreeMap
是 Java 集合框架中Map
接口的一个实现类,它基于红黑树(一种自平衡的二叉搜索树)来实现键值对的存储和操作。这使得TreeMap
在插入、删除和查找操作上都具有较好的性能,时间复杂度为 O (log n),其中 n 是TreeMap
中键值对的数量。
TreeMap
的一个重要特点是它会对键进行排序。排序方式有两种:
- 自然排序:如果键的类型实现了
Comparable
接口,TreeMap
会按照键的自然顺序进行排序。例如,如果键是Integer
类型,那么TreeMap
会按照数字大小对键进行升序排列;如果键是String
类型,则会按照字典序进行排序。【默认自然升序】 - 定制排序:可以在创建
TreeMap
时传入一个Comparator
接口的实现类,通过该比较器来定义键的排序规则。这使得TreeMap
可以对不具备自然排序能力的对象进行排序,或者按照自定义的规则对可自然排序的对象进行排序。
看源码得知,key不能为null!!!
如果是自定义key的类型。
- 要么Key对象实现
Comparable
接口,定义排序逻辑
public void testTreeMap() {Map<Student, String> m = new TreeMap<>((o1, o2) -> {if (o1.getAge() == o2.getAge()) return 0;return o1.getAge() > o2.getAge() ? -1 : 1; // 返回1,排后面儿去, -1排前面});m.put(new Student("a", 10), "a");m.put(new Student("b", 2), "b");m.put(new Student("c", 15), "c");System.out.println(m); // c, a, b
}
- 要么传入
Comparator
接口的实现类,里面定义排序逻辑
public class Student implements Comparable<Student>{private String name;private int age;@Overridepublic int compareTo(Student o) {if (this.getAge() == o.getAge()) return 0;return this.getAge() > o.getAge() ? 1 : -1;}
}public static void testTreeMap() {Map<Student, String> m = new TreeMap<>();m.put(new Student("a", 10), "a");m.put(new Student("b", 2), "b");m.put(new Student("c", 15), "c");System.out.println(m); // b, a, c
}
5.juc的Map
说实话,写到这儿有点累了。。。
①synchronizedMap
synchronizedMap
是 Java 中通过 Collections
工具类提供的一个线程安全的 Map
实现,用于将普通的 Map
(如 HashMap
)包装成一个线程安全的版本。它的核心思想是通过 同步锁(synchronized) 保证多线程环境下的安全性
Collections.synchronizedMap()
方法返回一个包装类,其内部通过 同步代码块(synchronized) 对原始 Map
的所有操作进行加锁。这个里面的同步安全集合类实现原理都类似啊。
public static <K,V> Map<K,V> synchronizedMap(Map<K,V> m) {return new SynchronizedMap<>(m);
}// 内部类
static class SynchronizedMap<K,V> implements Map<K,V> {private final Map<K,V> m; // 被包装的原始 Mapfinal Object mutex; // 同步锁对象public V put(K key, V value) {synchronized (mutex) { // 所有操作加锁return m.put(key, value);}}public int size() {synchronized (mutex) {return m.size();}}public boolean isEmpty() {synchronized (mutex) {return m.isEmpty();}}public boolean containsKey(Object key) {synchronized (mutex) {return m.containsKey(key);}}public boolean containsValue(Object value) {synchronized (mutex) {return m.containsValue(value);}}public V get(Object key) {synchronized (mutex) {return m.get(key);}}public V put(K key, V value) {synchronized (mutex) {return m.put(key, value);}}...........................// 其他方法也通过 synchronized 同步
} // 实现原理是不是简单粗暴
- 与
Hashtable
相比:Collections.synchronizedMap
可以基于任何Map
实现类(如HashMap
、TreeMap
)创建线程安全的Map
,而Hashtable
本身是一个特定的线程安全Map
实现类,并且Hashtable
不允许null
键和null
值,Collections.synchronizedMap
基于的HashMap
允许null
键和null
值(如果基于TreeMap
则不允许null
键)。两者在同步机制上类似,都是基于synchronized
关键字,但Collections.synchronizedMap
更灵活。 - 与
ConcurrentHashMap
相比:ConcurrentHashMap
在高并发环境下性能更好。ConcurrentHashMap
在 Java 7 及之前采用分段锁机制,Java 8 及之后采用 CAS 和synchronized
相结合的方式,允许多个线程同时进行读操作,部分线程进行写操作,而Collections.synchronizedMap
同一时间只能有一个线程进行操作。所以在高并发场景下,ConcurrentHashMap
更适合。
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;public class SynchronizedMapExample {public static void main(String[] args) {// 创建一个普通 HashMapMap<String, Integer> map = new HashMap<>();// 包装为线程安全的 MapMap<String, Integer> syncMap = Collections.synchronizedMap(map);// 多线程操作Runnable task = () -> {for (int i = 0; i < 1000; i++) {syncMap.put(Thread.currentThread().getName() + i, i);}};Thread t1 = new Thread(task);Thread t2 = new Thread(task);t1.start();t2.start();try {t1.join();t2.join();} catch (InterruptedException e) {e.printStackTrace();}System.out.println("Map size: " + syncMap.size()); // 输出: 2000}
}
②ConcurrentHashMap
见后续并发相关文章
③ConcurrentSkipListMap
见后续并发相关文章。
6.补充知识点简介
原本是想在写完ConcurrentHashMap后,把cas补上,谁知道先写了cas,aqs。。。。
①CAS
CAS 通常指 “Compare and Swap”(比较并交换) ,这是一种用于实现多线程同步的原子操作
他的主要机制:CAS 操作涉及三个操作数,分别是内存位置(V)、预期原值(A)和新值(B) 。当执行 CAS 操作时,它会先检查 V 处的值是否等于 A。如果相等,就将 V 处的值更新为 B;如果不相等,就不进行任何操作。整个操作是原子的,在多线程环境下能保证数据一致性。CAS是一个无锁机制的操作,底层是通过Unsafe类使用native本地方法进行的CAS操作,但是在操作系统层面,在操作系统层面,CAS还是会加锁的,通过加锁的方式锁定总线,避免其他CPU访问共享变量。
Unsafe类
Unsafe类是JDK提供的一个不安全的类,它提供了一些底层的操作,包括内存操作、线程调度、对象实例化等。它的作用是让Java可以在底层直接操作内存,从而提高程序的效率。CAS操作是基于底层硬件支持的原子性指令来实现的,所以它可以保证操作的原子性和线程安全性,同时也可以避免使用锁带来的性能开销。
在 Unsafe
类中,提供了compareAndSwapObject
、compareAndSwapInt
、compareAndSwapLong
方法来实现的对Object
、int
、long
类型的 CAS 操作
public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);
参数说明
参数 | 类型 | 说明 |
---|---|---|
o |
Object | 目标对象(包含需要修改的字段的实例)。 |
offset |
long | 目标字段在对象内存中的偏移量(通过 Unsafe.objectFieldOffset 获取)。 |
expected |
int | 期望的字段当前值(用于比较)。 |
x |
int | 要更新的新值。 |
offset获取,在AtomicInteger源码中可以看出。
static {try {valueOffset = unsafe.objectFieldOffset(AtomicInteger.class.getDeclaredField("value"));} catch (Exception ex) { throw new Error(ex); }
}
private volatile int value;
举个例子:假设有一个共享变量 count,初始值为 0。线程 1 想要将 count 的值加 1。它会使用 CAS 操作,预期原值 A 为 0,新值 B 为 1。如果此时内存中 count 的值确实为 0(即与预期原值相等),那么就会将 count 更新为 1。。若在这期间,线程 2 也对 count 进行了操作,使得 count 的值变为 1,那么线程 1 的 CAS 操作就不会成功,因为此时内存中的值(1)与预期原值(0)不相等。
在我大Java中,肯定有其身影。原子类:Java 的java.util.concurrent.atomic
包中,很多原子类(如AtomicInteger
、AtomicLong
等)就是基于 CAS 实现的。
private static void testAtomicInteger() {AtomicInteger atomicInteger = new AtomicInteger(1);atomicInteger.getAndSet(20);System.out.println(atomicInteger.get());
}
自旋锁:(死循环?) 基于 CAS 可以实现自旋锁。自旋锁是一种非阻塞锁,线程在获取锁失败时,不会进入阻塞状态,而是不断尝试获取锁(即自旋),直到获取到锁为止。
public class SpinLock {private AtomicReference<Thread> owner = new AtomicReference<>();public void lock() {Thread current = Thread.currentThread();while (!owner.compareAndSet(null, current)) {// 自旋等待}}public void unlock() {Thread current = Thread.currentThread();owner.compareAndSet(current, null);}
}
存在的问题:
- 其实可以很明显的看出他的问题嚯。在基于 CAS 实现的自旋锁中,当一个线程尝试获取锁失败时,它会在一个循环中不断重试,即自旋。如果自旋时间过长,会浪费大量的 CPU 资源,降低系统整体性能。假设有多个线程竞争一个资源,并且持有锁的线程执行时间较长。那么其他竞争线程会持续自旋等待获取锁,在这个过程中,它们不断消耗 CPU 资源,却没有实际的工作进展。如果自旋的线程数量较多,会导致 CPU 使用率急剧上升,影响整个系统的响应速度.针对这个。我们可以
- 自适应自旋:自旋时间不再固定,而是根据前一次在同一个锁上的自旋时间以及锁的拥有者的状态来调整。例如,如果前一次自旋成功获取到锁,那么下一次自旋时间可以适当延长;如果自旋多次都未能获取到锁,那么可以减少自旋时间甚至不再自旋,直接进入阻塞状态。
- 设置自旋次数上限:给自旋设置一个最大次数,当自旋次数达到上限后,线程就不再自旋,而是进入阻塞状态。这样可以避免线程无限制地自旋,减少 CPU 资源的浪费。
-
第二,只能保证一个共享变量的原子操作。CAS 操作只能对单个共享变量进行原子更新。如果需要对多个共享变量进行原子操作,单纯使用 CAS 无法满足需求。针对这个问题,我们可以将多个变量合并成一个:如果多个变量之间存在一定的逻辑关系,可以将它们合并成一个对象,然后对这个对象使用
AtomicReference
来保证原子性操作。例如,将x
和y
封装在一个自定义类中,然后使用AtomicReference<CustomClass>
来操作这个类的实例。 -
第三,ABA 问题:CAS 操作在检查值是否为预期值 A 时,如果值在检查和更新之间经历了从 A 变为 B 再变回 A 的过程,CAS 操作依然会认为值没有改变而成功执行更新。但实际上,这个值已经发生了变化,可能会导致一些隐藏的错误。针对这个问题。
- 使用版本号:在每次数据更新时,版本号加 1。这样即使值变回原来的样子,但版本号已经改变,CAS 操作会失败。例如,在 Java 的
AtomicStampedReference
类中,就通过维护一个 “戳”(stamp)来解决 ABA 问题。每次更新数据时,不仅更新数据值,也更新戳。在进行 CAS 操作时,同时比较数据值和戳,只有两者都匹配才执行更新。- 使用时间戳:类似于版本号,通过记录数据的修改时间来标记数据的变化。每次更新数据时更新时间戳,在进行 CAS 操作时比较时间戳,若时间戳不一致则不执行更新。
②AQS
- 公平锁和非公平锁
公平锁是指在锁的竞争中,按照请求锁的先后顺序来分配锁,即先请求锁的线程会先获得锁。这就像排队一样,先来的人先得到服务。
优点:公平锁保证了线程获取锁的公平性,避免了线程饥饿(某个线程长时间无法获取锁)的问题。
缺点:由于需要维护等待队列和按照顺序唤醒线程,公平锁的实现开销较大,性能相对较低。特别是在高并发场景下,频繁的线程切换会导致系统开销增加。
private static ReentrantLock fairLock = new ReentrantLock(true); // 公平锁
public static void main(String[] args) {Thread[] threads = new Thread[5];for (int i = 0; i < threads.length; i++) {threads[i] = new Thread(() -> {fairLock.lock();try {System.out.println(Thread.currentThread().getName() + " 获得了锁");// 模拟业务逻辑Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();} finally {fairLock.unlock();}});threads[i].start();}
}
//多个线程竞争锁时,会按照请求锁的先后顺序依次获得锁。
非公平锁在锁可用时,不考虑等待队列中线程的等待顺序,任何一个线程都有机会竞争并获得锁。也就是说,新请求锁的线程可能会在等待队列中的线程之前获得锁。
优点:非公平锁的实现开销相对较小,因为它不需要严格按照顺序唤醒线程。在高并发场景下,由于减少了线程切换的开销,性能通常比公平锁更好。
缺点:非公平锁可能导致部分线程长时间无法获得锁,即线程饥饿问题。特别是在有大量线程频繁请求锁的情况下,一些新线程可能会不断抢占锁,使得等待队列中的线程长时间得不到执行机会。
private static ReentrantLock unfairLock = new ReentrantLock(false); // false
public static void main(String[] args) {Thread[] threads = new Thread[5];for (int i = 0; i < threads.length; i++) {threads[i] = new Thread(() -> {unfairLock.lock();try {System.out.println(Thread.currentThread().getName() + " 获得了锁");// 模拟业务逻辑Thread.sleep(1000);} catch (InterruptedException e) {e.printStackTrace();} finally {unfairLock.unlock();}});threads[i].start();}
}
- aqs基本介绍
AQS 即 AbstractQueuedSynchronizer【抽象队列同步器】,是 Java 并发包中很多同步器实现的基础框架,如ReentrantLock
、Semaphore
、CountDownLatch
等。AQS作为一个抽象类,通常是通过继承来使用的。它本身是没有同步接口的,只是定义了同步状态和同步获取和同步释放的方法。
主要用途: 用于构建锁和同步器,它提供了一种在多线程环境下管理同步状态(state)、线程排队等待以及线程唤醒的机制。
public abstract class AbstractQueuedSynchronizer extends AbstractOwnableSynchronizer implements java.io.Serializable {// 内部类Nodestatic final class Node {.....volatile Node prev;volatile Node next;volatile Thread thread;}// 维护了一个逻辑上的基于双向链表的队列private transient volatile Node head;private transient volatile Node tail;// The synchronization state.private volatile int state;.......
}
从上面可以看到,双向链表有两个指针,一个指针指向前置节点,一个指针指向后继节点。这是一个虚拟的双向队列,用于存放等待获取同步状态的线程。队列节点(Node
)包含了线程引用、等待状态等信息。当一个线程无法获取同步状态时,它会被封装成一个Node
加入到队列尾部,并进入等待状态。当同步状态可用时,队列头部的线程会被唤醒尝试获取同步状态。
AQS使用一个volatile
的int类型的成员变量state
来表示同步状态,通过CAS修改同步状态的值。当线程调用 lock 方法时 ,如果 state
=0,说明没有任何线程占有共享资源的锁,可以获得锁并将 state
加1。如果 state
不为0,则说明有线程目前正在使用共享变量,其他线程必须加入同步队列进行等待。我们开发者只需关注具体同步逻辑(如获取和释放同步状态的条件),而无需关注线程排队、等待和唤醒等底层细节。
为啥是双向链表嘞?
- 找前驱的便利
首先,AQS节点有CANCELLED
、SIGNAL
、CONDITION
等状态,处理这些状态时需频繁调整节点链接。线程可能因中断或超时被取消排队,需从队列中移除,这个时候双向操作灵活性,将节点从队列中移除时,需同时更新前驱节点的next
和后继节点的prev
,双向链表更容易定位到前驱结点。双向链表通过prev
指针可直接定位前驱节点,调整指针即可完成删除;单向链表需遍历查找前驱节点,时间复杂度为O(n)。
双向链表在面对各种异常情况(如节点突然取消等待、线程意外中断等)时,能更好地保证链表结构的完整性和一致性。因为双向链表的结构特点使得在任何节点出现问题时,都能方便地从前后两个方向对链表进行调整。
没有竞争到锁的线程加入到阻塞队列,并且阻塞等待的前提是,当前线程所在节点的前置节点是正常状态,这样设计是为了避免链表中存在异常线程导致无法唤醒后续线程的问题。所以,线程阻塞之前需要判断前置节点的状态,如果没有指针指向前置节点,就需要从 Head 节点开始遍历,性能非常低。
- aqs类分析
public abstract class AbstractQueuedSynchronizerextends AbstractOwnableSynchronizer implements java.io.Serializable {private static final long serialVersionUID = 7373984972572414691L;// 内部类static final class Node { ... }// 维护了一个逻辑上的基于双向链表的队列private transient volatile Node head;private transient volatile Node tail;// The synchronization state.private volatile int state; // get set方法略去// 这里用到了casprotected final boolean compareAndSetState(int expect, int update) {return unsafe.compareAndSwapInt(this, stateOffset, expect, update);}//......// ======================【重要的方法 [子类去实现]】#####!!!!// 尝试以独占模式获取。该方法应该查询对象的状态是否允许以独占模式获取它,如果允许则获取它。protected boolean tryAcquire(int arg) {throw new UnsupportedOperationException();}// 尝试以独占模式释放protected boolean tryRelease(int arg) {throw new UnsupportedOperationException();}// 在共享模式下尝试获取。该方法应该查询对象的状态是否允许以共享模式获取它,如果允许则获取它。//此方法总是由执行acquire的线程调用。//如果此方法报告失败,则acquire方法可能会将线程(如果尚未排队)放入队列,直到其他线程发出释放信号为止。protected int tryAcquireShared(int arg) {throw new UnsupportedOperationException();}// 同理protected boolean tryReleaseShared(int arg) {throw new UnsupportedOperationException();}// 如果独占同步,则为True;否则 falseprotected boolean isHeldExclusively() {throw new UnsupportedOperationException();}// ==================// 重要的模板方法 ,子类可见,但是不允许重写//====================//============================================= 独占式/*acquire 方法构成了获取独占式同步状态的算法骨架。它首先调用 tryAcquire(arg) 尝试获取同步状态,这个方法是抽象的,需要子类去实现,以定义具体的获取同步状态逻辑。如果 tryAcquire(arg) 返回 false,表示获取失败,则通过 addWaiter(Node.EXCLUSIVE) 将当前线程封装成节点加入等待队列,然后调用 acquireQueued(addWaiter(Node.EXCLUSIVE), arg) 方法在队列中等待获取同步状态。selfInterrupt() 方法用于在必要时设置线程的中断状态。*/public final void acquire(int arg) {// 独占式获取同步状态,如果获取失败则进入等待队列。// 首先调用tryAcquire(arg)方法尝试获取同步状态,该方法需要子类实现。如果获取成功,acquire方法直接返回。if (!tryAcquire(arg) &&acquireQueued(addWaiter(Node.EXCLUSIVE), arg))selfInterrupt();}// 独占式获取同步状态,在获取过程中可以响应中断public final void acquireInterruptibly(int arg)throws InterruptedException {if (Thread.interrupted()) // 首先检查当前线程是否已被中断throw new InterruptedException();if (!tryAcquire(arg))//如果tryAcquire(arg)返回false,则通过addWaiter(Node.EXCLUSIVE)方法//将当前线程封装成一个"独占式节点"并添加到等待队列尾部。doAcquireInterruptibly(arg); // 这个方法里面}/*在等待队列中循环等待获取同步状态,每次循环会计算剩余时间nanosTimeout = deadline - System.nanoTime()。如果剩余时间小于等于 0,则返回false表示获取超时。如果在等待过程中获取到同步状态,则返回true。*/public final boolean tryAcquireNanos(int arg, long nanosTimeout)throws InterruptedException {if (Thread.interrupted())throw new InterruptedException();return tryAcquire(arg) ||doAcquireNanos(arg, nanosTimeout);}// 独占式释放同步状态,并唤醒等待队列中的一个线程。public final boolean release(int arg) {// 首先调用tryRelease(arg)方法尝试释放同步状态,该方法需要子类实现。// 如果释放成功(tryRelease(arg)返回true),则进入下一步。if (tryRelease(arg)) {Node h = head;if (h != null && h.waitStatus != 0)// 通过unparkSuccessor(Node node)方法唤醒等待队列中第一个处于等待状态且能够被唤醒的线程。// 如果没有这样的线程,则不进行任何操作。unparkSuccessor(h);return true;}return false;}//============================================= 共享式// 共享式获取同步状态,如果获取失败则进入等待队列。public final void acquireShared(int arg) {// 首先调用tryAcquireShared(arg)方法尝试获取同步状态,该方法需要子类实现。// 如果返回值大于等于 0,表示获取成功,acquireShared方法直接返回。if (tryAcquireShared(arg) < 0)// doAcquireShared(int arg)方法,使当前线程在等待队列中自旋等待获取同步状态。doAcquireShared(arg);}// 共享式获取同步状态,在获取过程中可以响应中断。public final void acquireSharedInterruptibly(int arg)throws InterruptedException {if (Thread.interrupted())throw new InterruptedException();if (tryAcquireShared(arg) < 0)// 如果tryAcquireShared(arg)返回值小于 0,表示获取失败,// 则通过addWaiter(Node.SHARED)方法将当前线程封装成一个"共享式节点"并添加到等待队列尾部。doAcquireSharedInterruptibly(arg);}public final boolean tryAcquireSharedNanos(int arg, long nanosTimeout)throws InterruptedException {if (Thread.interrupted())throw new InterruptedException();return tryAcquireShared(arg) >= 0 ||doAcquireSharedNanos(arg, nanosTimeout);}// 共享式释放同步状态,并唤醒等待队列中的其他线程。public final boolean releaseShared(int arg) {// 调用tryReleaseShared(arg)方法尝试释放同步状态,该方法需要子类实现。// 如果释放成功(tryReleaseShared(arg)返回true),则进入下一步。if (tryReleaseShared(arg)) {// 同理。通过doReleaseShared()方法唤醒等待队列中所有处于等待状态且能够被唤醒的"共享式节点"对应的线程。doReleaseShared();return true;}return false;}//.........}
通过上面的分析得知,aqs提供给我们的提供的模板方法主要分为三类:【模板方法的设计模式】
- 独占式地获取和释放锁;
- 共享式地获取和释放锁;
- 查询
AQS
的同步队列中正在等待的线程情况;
这里顺便说一下独占锁【排它锁、写锁】和共享锁【读锁】
独占锁:也叫排他锁,即锁只能由一个线程获取,若一个线程获取了锁,则其他想要获取锁的线程只能等待,直到锁被释放。比如说写锁,对于写操作,每次只能由一个线程进行,若多个线程同时进行写操作,将很可能出现线程安全问题;
共享锁:锁可以由多个线程同时获取,锁被获取一次,则锁的计数器+1。比较典型的就是读锁,读操作并不会产生副作用,所以可以允许多个线程同时对数据进行读操作,而不会有线程安全问题,当然,前提是这个过程中没有线程在进行写操作;
经过对AQS的源码分析,下面几个方法需要在子类中去实现。
// tryAcquire的作用就是尝试修改state值,也就是获取锁,若修改成功,则返回true,否则返回false
protected boolean tryAcquire(int arg) {throw new UnsupportedOperationException();
}
protected boolean tryRelease(int arg) {throw new UnsupportedOperationException();
}
protected int tryAcquireShared(int arg) {throw new UnsupportedOperationException();
}
protected boolean tryReleaseShared(int arg) {throw new UnsupportedOperationException();
}
protected boolean isHeldExclusively() {throw new UnsupportedOperationException();
}
end. 参考
-
https://blog.csdn.net/Aqting/article/details/129465893
-
https://blog.csdn.net/qq_49217297/article/details/126304736
-
https://www.cnblogs.com/tuyang1129/p/12368842.html
-
https://topjavaer.cn/java/java-concurrent.html#什么是aqs 【aqs】
-
https://blog.csdn.net/u010445301/article/details/125590758 【aqs】
-
https://www.cnblogs.com/tuyang1129/p/12670014.html 【aqs】